# ------------------------------------------------------------------------------------- # Tekton Dash - Multiplayer Board Game - 2024 # ------------------------------------------------------------------------------------- extends Node3D # Manager references var ui_manager var goals_cycle_manager var screen_shake_manager var touch_controls var camera_context_manager var stop_n_go_manager var stop_n_go_winner_id: int = -1 # Track who finished first in Stop n Go mode var portal_mode_winner_id: int = -1 var is_match_ended: bool = false var obstacle_manager var portal_mode_manager var gauntlet_manager var vfx_manager # Minimal local state var _connection_check_timer: float = 0.0 var reserved_static_positions: Array[Vector2i] = [] var _unstuck_cooldown_remaining: float = 0.0 const UNSTUCK_COOLDOWN = 120.0 # 2 minutes func _can_rpc() -> bool: if not is_inside_tree(): return false if not multiplayer.has_multiplayer_peer(): return false if multiplayer.multiplayer_peer.get_class() == "OfflineMultiplayerPeer": return false if multiplayer.multiplayer_peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED: return false return true func _ready(): # Initialize scene managers _init_managers() # Connect to multiplayer signals multiplayer.peer_connected.connect(_on_peer_connected) multiplayer.peer_disconnected.connect(_on_peer_disconnected) LobbyManager.host_disconnected.connect(_on_host_disconnected) LobbyManager.game_starting.connect(_on_rematch_starting) # Connect to Nakama signals NakamaManager.match_joined.connect(_on_match_joined) # Setup visual elevations for effects _setup_effect_elevation() # Start background music for the game mode MusicManager.start_music() # Setup UI ui_manager.setup_playerboard_ui() ui_manager.setup_timer_labels(self ) ui_manager.setup_playerboard_label(self ) # NEW ui_manager.setup_leaderboard_ui(self ) ui_manager.setup_powerup_bar_ui(self ) # GlobalMatchTimer is now static in main.tscn - no setup needed # NetworkPanel is visible during gameplay # Auto-start game if coming from lobby (already connected to match) # Works for both Nakama mode and LAN direct mode (ENet). var is_lan_connected = LobbyManager.is_lan_mode and multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED if (NakamaManager.is_connected_to_nakama() or is_lan_connected) and multiplayer.get_unique_id() != 0: print("Coming from lobby - auto-starting game...") await get_tree().process_frame _auto_start_from_lobby() # Hide MessageBar (User Request) if message_bar: message_bar.visible = false # Ensure grid is randomized with Scarcity if server if multiplayer.is_server(): randomize_game_grid() # Setup global multiplayer spawners (Stands, etc.) _setup_multiplayer_spawners() # Apply Arena Background _apply_arena_background() # HUD Settings connection is now handled internally by TouchControlsManager # which calls _toggle_pause_menu() on this scene. # Programmatically connect Pause Menu Settings button to ensure it works var pause_settings = get_node_or_null("PauseMenu/Panel/VBox/SettingsBtn") if pause_settings: if pause_settings.pressed.is_connected(_on_settings_pressed): pause_settings.pressed.disconnect(_on_settings_pressed) pause_settings.pressed.connect(_on_settings_pressed) # Connect the new top-level Help and Settings buttons var top_help_btn = get_node_or_null("TopMenuUI/HelpBtn") if top_help_btn: if not top_help_btn.pressed.is_connected(_on_how_to_play_pressed): top_help_btn.pressed.connect(_on_how_to_play_pressed) # SettingsBtn opens the PauseMenu (acts as a pause/menu toggle) var top_settings_btn = get_node_or_null("TopMenuUI/SettingsBtn") if top_settings_btn: if not top_settings_btn.pressed.is_connected(_toggle_pause_menu): top_settings_btn.pressed.connect(_toggle_pause_menu) # Tutorial Override if LobbyManager.get("is_tutorial_mode"): var tutorial_script = load("res://scripts/managers/tutorial_manager.gd") if tutorial_script: var tutorial_node = Node.new() tutorial_node.set_script(tutorial_script) tutorial_node.name = "TutorialManager" add_child(tutorial_node) func _setup_multiplayer_spawners(): # Setup MultiplayerSpawner for Static Tekton Stands # Create a container node for strict pathing if not has_node("Stands"): var stands_container = Node3D.new() stands_container.name = "Stands" add_child(stands_container) if not has_node("StandSpawner"): var stand_spawner = MultiplayerSpawner.new() stand_spawner.name = "StandSpawner" stand_spawner.spawn_path = NodePath("../Stands") # Relative to Spawner, finding sibling stand_spawner.add_spawnable_scene("res://scenes/static_tekton_stand.tscn") add_child(stand_spawner) func _apply_arena_background(): var arena_bg = get_node_or_null("ArenaBG") var selected_area = LobbyManager.selected_area var texture_path = "" match selected_area: "Colloseum": texture_path = "res://assets/graphics/level_bg/level_bg_colloseum.jpg" "Stop N Go Arena": texture_path = "res://assets/graphics/level_bg/placeholder_stop_n_go.jpg" _instantiate_3d_arena("res://scenes/arena/stop_n_go.scn") _hide_ground_tiles() "Freemode Arena": texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg" # Fallback texture _instantiate_3d_arena("res://scenes/arena/freemode.tscn") _hide_ground_tiles() "Tekton Doors Arena": texture_path = "res://assets/graphics/level_bg/placeholder_tekton_doors.jpg" "Gauntlet Arena": texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg" _instantiate_3d_arena("res://scenes/arena/gauntlet.tscn") _hide_ground_tiles() "Classic", _: texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg" if arena_bg and texture_path != "": if ResourceLoader.exists(texture_path): var tex = load(texture_path) if tex: arena_bg.texture = tex else: print("Arena bg texture not found: ", texture_path) func _instantiate_3d_arena(scene_path: String): if ResourceLoader.exists(scene_path): var arena_scene = load(scene_path) if arena_scene: var arena_instance = arena_scene.instantiate() arena_instance.name = "ArenaEnvironment3D" add_child(arena_instance) move_child(arena_instance, 0) print("Instantiated 3D Arena: ", scene_path) func _hide_ground_tiles(): # Make normal and auxiliary ground floors invisible # by shrinking their scale to 0. We EXCLUDE Item 4 (Wall) and 5 (Freeze) # so they can still be seen above the 3D arena. var em = $EnhancedGridMap if em and em.mesh_library: var ml = em.mesh_library.duplicate() for id in [0, 6]: # Scale to 0 to hide it without triggering invalid mesh errors ml.set_item_mesh_transform(id, Transform3D().scaled(Vector3.ZERO)) em.mesh_library = ml print("[Main] Hide tiles 0, 6 via zero-scale transform.") func _setup_effect_elevation(): var em = get_node_or_null("EnhancedGridMap") if em and em.mesh_library: # USER REQUEST: Do not apply visual Y-elevation for walls in Stop n Go mode if LobbyManager.game_mode == "Stop n Go": print("[Main] Stop n Go mode detected: Skipping effect elevation for walls.") return var ml = em.mesh_library.duplicate() # Height 0.8: Above 3D arena, but below pickups (Y=1.0) var lift_transform = Transform3D().translated(Vector3(0, 0.28, 0)) # Lift Wall (4) and Freeze (5) ml.set_item_mesh_transform(4, lift_transform) ml.set_item_mesh_transform(5, lift_transform) em.mesh_library = ml print("[Main] MeshLibrary elevation applied: Wall(4) and Freeze(5) at Y=0.8") @rpc("any_peer", "call_local", "reliable") func sync_portal_configs(configs: Array): if portal_mode_manager: # Temporarily store the configs and trigger spawn # Note: We use a custom property in manager to pass this portal_mode_manager.set_meta("door_configs", configs) portal_mode_manager._spawn_portal_doors() # Force gridmap cell size to match player logic (1, 0.05, 1) - >0.001 to avoid errors var em = $EnhancedGridMap if em: em.cell_size = Vector3(1, 0.05, 1) func _on_goal_count_updated(peer_id: int, count: int): # Only update for local player if peer_id == multiplayer.get_unique_id(): ui_manager.update_goal_count_label(count) func _init_managers(): # Create and attach scene managers ui_manager = load("res://scripts/managers/ui_manager.gd").new() ui_manager.name = "UIManager" add_child(ui_manager) ui_manager.initialize(self ) # Goals cycle manager for 60-second timer and scoring goals_cycle_manager = load("res://scripts/managers/goals_cycle_manager.gd").new() goals_cycle_manager.name = "GoalsCycleManager" add_child(goals_cycle_manager) goals_cycle_manager.initialize(self ) # Stop n Go manager for phase-based gameplay if LobbyManager.game_mode == "Stop n Go": stop_n_go_manager = load("res://scripts/managers/stop_n_go_manager.gd").new() stop_n_go_manager.name = "StopNGoManager" add_child(stop_n_go_manager) # No direct initialize() yet, but we'll call start_game_mode later # Portal manager for Tekton Doors mode if LobbyManager.game_mode == "Tekton Doors": portal_mode_manager = load("res://scripts/managers/portal_mode_manager.gd").new() portal_mode_manager.name = "PortalModeManager" add_child(portal_mode_manager) portal_mode_manager.initialize(self , $EnhancedGridMap) # Gauntlet manager for Candy Cannon Survival mode if LobbyManager.game_mode == "Candy Cannon Survival": gauntlet_manager = load("res://scripts/managers/gauntlet_manager.gd").new() gauntlet_manager.name = "GauntletManager" add_child(gauntlet_manager) gauntlet_manager.initialize(self, $EnhancedGridMap) # Screen shake manager for impact feedback screen_shake_manager = load("res://scripts/managers/screen_shake.gd").new() screen_shake_manager.name = "ScreenShakeManager" add_child(screen_shake_manager) screen_shake_manager.initialize($Camera3D200) # Touch controls for mobile touch_controls = get_node_or_null("TouchLayer/TouchControls") if not touch_controls: touch_controls = get_node_or_null("TouchControls") # fallback if not touch_controls: print("TouchControls node not found in scene, creating instance...") touch_controls = load("res://scripts/managers/touch_controls.gd").new() touch_controls.name = "TouchControls" add_child(touch_controls) touch_controls.initialize(self ) # NEW: Camera Context Manager for dynamic camera position camera_context_manager = load("res://scripts/managers/camera_context_manager.gd").new() camera_context_manager.name = "CameraContextManager" add_child(camera_context_manager) camera_context_manager.initialize($Camera3D200, screen_shake_manager) # Obstacle manager for dynamic walls obstacle_manager = load("res://scripts/managers/obstacle_manager.gd").new() obstacle_manager.name = "ObstacleManager" add_child(obstacle_manager) obstacle_manager.initialize(self , $EnhancedGridMap) # NEW: VFX Animation Manager for Ready-Go, Stop-Phase, etc. vfx_manager = load("res://scenes/animation.tscn").instantiate() vfx_manager.name = "VFXManager" add_child(vfx_manager) # Connect signals for UI updates goals_cycle_manager.timer_updated.connect(_on_timer_updated) goals_cycle_manager.score_updated.connect(_on_score_updated) goals_cycle_manager.goal_count_updated.connect(_on_goal_count_updated) # NEW goals_cycle_manager.leaderboard_updated.connect(_on_leaderboard_updated) goals_cycle_manager.global_timer_updated.connect(_on_global_timer_updated) goals_cycle_manager.match_ended.connect(_on_match_ended) # Message Bar Configuration const MAX_MESSAGES := 5 const MESSAGE_DURATION := 4.0 @onready var message_bar: PanelContainer = $MessageBar @onready var message_container: VBoxContainer = $MessageBar/MarginContainer/MessageContainer var last_messages = {} # {player_name: {text: String, time: int}} # Message types for different styling enum MessageType {NORMAL, POWERUP, GOAL, CYCLE, WARNING} func add_message_to_bar(player_name: String, message: String, type: int = MessageType.NORMAL): # Deduplication check var current_time = Time.get_ticks_msec() if player_name in last_messages: var last = last_messages[player_name] # Ignore if same message within 2 seconds if last.text == message and current_time - last.time < 2000: return last_messages[player_name] = {"text": message, "time": current_time} if not message_container: return # Create message label with rich styling var label = Label.new() label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART #label.custom_minimum_size = Vector2(400, 0) # Width constraint for wrapping label.add_theme_font_size_override("font_size", 16) label.modulate.a = 0.0 # Start invisible for fade-in # Style based on message type var icon = "" var color = Color.WHITE match type: MessageType.POWERUP: icon = "⚡ " color = Color(0.4, 1.0, 0.4) # Bright green MessageType.GOAL: icon = "🎯 " color = Color(1.0, 0.85, 0.2) # Gold MessageType.CYCLE: icon = "⏱️ " color = Color(0.4, 0.8, 1.0) # Light blue MessageType.WARNING: icon = "⚠️ " color = Color(1.0, 0.5, 0.3) # Orange _: icon = "💬 " color = Color(0.9, 0.9, 0.9) # Light gray # Include player name in message if provided if player_name and player_name != "": label.text = "%s[%s] %s" % [icon, player_name, message] else: label.text = "%s%s" % [icon, message] label.add_theme_color_override("font_color", color) # Add shadow for better visibility label.add_theme_constant_override("shadow_offset_x", 2) label.add_theme_constant_override("shadow_offset_y", 2) label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.7)) # Add to container message_container.add_child(label) # Show the message bar with fade if not message_bar.visible: message_bar.visible = true message_bar.modulate.a = 0.0 var bar_tween = create_tween() bar_tween.tween_property(message_bar, "modulate:a", 1.0, 0.2) # Animate label entrance (slide in + fade) label.position.x = -50 var entrance_tween = create_tween() entrance_tween.set_parallel(true) entrance_tween.tween_property(label, "modulate:a", 1.0, 0.3) entrance_tween.tween_property(label, "position:x", 0.0, 0.3).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) # Powerup gets extra pulse effect if type == MessageType.POWERUP: await entrance_tween.finished if is_instance_valid(label): var pulse_tween = create_tween() pulse_tween.set_loops(2) pulse_tween.tween_property(label, "scale", Vector2(1.1, 1.1), 0.15).set_trans(Tween.TRANS_SINE) pulse_tween.tween_property(label, "scale", Vector2(1.0, 1.0), 0.15).set_trans(Tween.TRANS_SINE) # Remove oldest messages if over limit while message_container.get_child_count() > MAX_MESSAGES: var oldest = message_container.get_child(0) message_container.remove_child(oldest) oldest.queue_free() # Auto-remove after duration with fade-out await get_tree().create_timer(MESSAGE_DURATION).timeout if is_instance_valid(label): var exit_tween = create_tween() exit_tween.set_parallel(true) exit_tween.tween_property(label, "modulate:a", 0.0, 0.3) exit_tween.tween_property(label, "position:x", 50.0, 0.3) await exit_tween.finished if is_instance_valid(label): label.queue_free() # Hide bar when empty with fade await get_tree().process_frame if message_container.get_child_count() == 0: var hide_tween = create_tween() hide_tween.tween_property(message_bar, "modulate:a", 0.0, 0.3) await hide_tween.finished message_bar.visible = false @rpc("any_peer", "call_local") func broadcast_message(player_name: String, message: String, type: int = MessageType.NORMAL): add_message_to_bar(player_name, message, type) func _start_pre_game_countdown(): """Trigger the premium Ready-Go animation on all clients.""" if can_rpc(): rpc("sync_ready_go") # Delay game start until the 4.0s animation finishes await get_tree().create_timer(4.0).timeout @rpc("call_local", "reliable") func sync_ready_go(): if vfx_manager and vfx_manager.has_method("play_ready_go"): vfx_manager.play_ready_go() @rpc("call_local", "reliable") func sync_countdown(text: String): # Use a CanvasLayer to ensure the countdown is on top of everything var countdown_layer = get_node_or_null("CountdownLayerUI") if not countdown_layer: countdown_layer = CanvasLayer.new() countdown_layer.name = "CountdownLayerUI" countdown_layer.layer = 100 # Very high priority add_child(countdown_layer) var label = countdown_layer.get_node_or_null("CountdownLabel") if not label and text != "": label = Label.new() label.name = "CountdownLabel" countdown_layer.add_child(label) # Center and Style label.anchors_preset = Control.PRESET_CENTER label.set_anchors_and_offsets_preset(Control.PRESET_CENTER) label.grow_horizontal = Control.GROW_DIRECTION_BOTH label.grow_vertical = Control.GROW_DIRECTION_BOTH label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.add_theme_font_size_override("font_size", 140) label.add_theme_color_override("font_outline_color", Color.BLACK) label.add_theme_constant_override("outline_size", 20) label.add_theme_color_override("font_color", Color.YELLOW) # Use Nougat font if available var nougat = load("res://assets/fonts/Nougat-ExtraBlack.ttf") if nougat: label.add_theme_font_override("font", nougat) if label: label.text = text if text == "": countdown_layer.queue_free() elif text == "GO!": label.add_theme_color_override("font_color", Color.GREEN) func _setup_global_match_timer_ui(): """Create the global match timer display at the top of the screen.""" # Check if timer check is enabled in lobby settings OR if in Stop n Go mode if not LobbyManager.enable_cycle_timer and LobbyManager.game_mode != "Stop n Go": var existing = get_node_or_null("GlobalMatchTimer") if existing: existing.visible = false return var existing = get_node_or_null("GlobalMatchTimer") if existing: existing.visible = true return # Create timer panel var panel = PanelContainer.new() panel.name = "GlobalMatchTimer" # Position at top center panel.set_anchors_preset(Control.PRESET_CENTER_TOP) panel.offset_left = -80 panel.offset_right = 80 panel.offset_top = 10 panel.offset_bottom = 60 # Style var style = StyleBoxFlat.new() style.bg_color = Color(0.1, 0.1, 0.15, 0.9) style.border_width_left = 2 style.border_width_top = 2 style.border_width_right = 2 style.border_width_bottom = 2 style.border_color = Color(0.647, 0.996, 0.224, 0.8) style.corner_radius_top_left = 8 style.corner_radius_top_right = 8 style.corner_radius_bottom_right = 8 style.corner_radius_bottom_left = 8 panel.add_theme_stylebox_override("panel", style) # VBox for content var vbox = VBoxContainer.new() vbox.name = "VBox" vbox.alignment = BoxContainer.ALIGNMENT_CENTER panel.add_child(vbox) # Label var label = Label.new() label.name = "TimerLabel" label.text = "03:00" label.add_theme_font_size_override("font_size", 28) label.add_theme_color_override("font_color", Color(0.647, 0.996, 0.224)) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER vbox.add_child(label) add_child(panel) func _process(delta): if not is_inside_tree(): return if not check_multiplayer(): return # Tick down unstuck cooldown and update button label if _unstuck_cooldown_remaining > 0.0: _unstuck_cooldown_remaining -= delta var unstuck_btn = get_node_or_null("PauseMenu/Panel/VBox/UnstuckBtn") if unstuck_btn: if _unstuck_cooldown_remaining > 0.0: unstuck_btn.text = "Unstuck (%ds)" % ceil(_unstuck_cooldown_remaining) unstuck_btn.disabled = true else: _unstuck_cooldown_remaining = 0.0 unstuck_btn.text = "Unstuck" unstuck_btn.disabled = false if ui_manager and get_tree(): var all_players = get_tree().get_nodes_in_group("Players") if all_players.size() > 0: ui_manager.update_live_leaderboard(all_players) if multiplayer.is_server() and GameStateManager.is_game_started(): if TurnManager.turn_based_mode: rpc("sync_turn_index", TurnManager.current_turn_index) _connection_check_timer += delta if _connection_check_timer >= 5.0: _connection_check_timer = 0.0 verify_all_connections() # ============================================================================= # Network Callbacks # ============================================================================= func _on_match_joined(match_id: String): var network_panel = get_node_or_null("PauseMenu/Panel/NetworkPanel") if network_panel: network_panel.get_node("NetworkInfo/UniquePeerID").text = str(multiplayer.get_unique_id()) if multiplayer.is_server(): network_panel.get_node("NetworkInfo/NetworkSideDisplay").text = "Server (Match: %s)" % match_id _setup_host_game() else: network_panel.get_node("NetworkInfo/NetworkSideDisplay").text = "Client" _setup_client_game() else: if multiplayer.is_server(): _setup_host_game() else: _setup_client_game() # ============================================================================= # Game Setup # ============================================================================= func _setup_host_game(): # Generate goals GoalManager.generate_preset_goals(GameStateManager.max_players) # Add host player var player_id = 1 var player_character = PlayerManager.add_player_character(player_id) add_child(player_character) player_character.add_to_group("Players", true) GameStateManager.add_player(player_id) GameStateManager.local_player_character = player_character ui_manager.set_local_player(player_character) if touch_controls: touch_controls.set_player(player_character) if camera_context_manager: camera_context_manager.set_player(player_character) # Set host name player_character.display_name = LobbyManager.local_player_name # Spawn client players that joined via lobby (need to add them first) var lobby_players = LobbyManager.get_players() for lobby_player in lobby_players: var peer_id = lobby_player.get("id", 0) if peer_id != 1 and peer_id != 0: # Skip host (1) and invalid (0) print("Spawning lobby player: ", peer_id) _spawn_lobby_client_sync(peer_id) # Spawning and arena setup if LobbyManager.game_mode == "Stop n Go" and stop_n_go_manager: stop_n_go_manager._setup_arena() elif LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager: portal_mode_manager.setup_arena_locally() elif LobbyManager.game_mode == "Candy Cannon Survival" and gauntlet_manager: gauntlet_manager._setup_arena() else: # Randomize grid first to ensure Floor 0 is walkable for pre-calculation randomize_game_grid() # 1. PVT: Pre-calculate Static Tekton positions AFTER arena size is known _precalculate_static_positions() # Wait for players to be fully ready (player.gd has 0.1s await in _ready before managers init) # Faster for LAN mode var setup_delay = 0.1 if LobbyManager.is_lan_mode else 0.3 await get_tree().create_timer(setup_delay).timeout # Set host goals - get goals directly from GoalManager var host_goals = GoalManager.get_goals_for_player(0) player_character.goals = host_goals rpc("sync_player_goals", player_id, host_goals) rpc("sync_preset_goals", GoalManager.preset_goals) # Update the goals UI immediately for the host ui_manager.update_playerboard_ui() # Set goals for lobby client players var player_index = 1 for lobby_player in lobby_players: var peer_id = lobby_player.get("id", 0) if peer_id != 1 and peer_id != 0: var client_player = get_node_or_null(str(peer_id)) if client_player and player_index < GoalManager.preset_goals.size(): var client_goals = GoalManager.preset_goals[player_index].duplicate() client_player.goals = client_goals call_deferred("_deferred_set_player_goals", peer_id, client_goals) player_index += 1 # Add bots to fill remaining slots (regardless of player count) if GameStateManager.enable_bots: var current_players = lobby_players.size() for i in range(current_players + 1, GameStateManager.max_players + 1): _add_bot(i) # Ensure Bots are in the tree before assigning positions await get_tree().process_frame _start_game() func _spawn_lobby_client_sync(peer_id: int): """Spawn a client player synchronously (no await).""" if has_node(str(peer_id)): return var player_character = PlayerManager.add_player_character(peer_id) add_child(player_character) player_character.add_to_group("Players", true) GameStateManager.add_player(peer_id) # Set name from LobbyManager data if available var lobby_players = LobbyManager.get_players() for p_data in lobby_players: if p_data.get("id") == peer_id: player_character.display_name = p_data.get("name", "Player") break # Tell all clients to create this player rpc("add_newly_connected_player_character", peer_id) # Goals will be assigned after players are ready in _setup_host_game func _setup_client_game(): """Setup client when transitioning from lobby.""" var my_id = multiplayer.get_unique_id() print("Client setup - my peer ID: ", my_id) # INITIALIZE ARENA SIZE for Stop n Go locally to prevent out-of-bounds before sync arrives if LobbyManager.game_mode == "Stop n Go": if not stop_n_go_manager: stop_n_go_manager = load("res://scripts/managers/stop_n_go_manager.gd").new() stop_n_go_manager.name = "StopNGoManager" add_child(stop_n_go_manager) stop_n_go_manager._apply_arena_setup() # Pre-spawn ALL players known from LobbyManager (including Host ID 1) # This ensures nodes exist to receive RPCs (like 'set_spawn_position') that might arrive before full sync var lobby_players = LobbyManager.get_players() for player_data in lobby_players: var p_id = player_data.get("id", 0) if p_id != 0: add_player_character(p_id) print("Client: Pre-spawned player ", p_id) # Pre-spawn potential bots (IDs from count+1 to MaxPlayers) to prevent RPC "Node not found" errors # Bots use small integer IDs (e.g. 2, 3, 4...) while clients use large unique IDs if GameStateManager.enable_bots: # Server spawns bots starting after the last human player index # So if we have 2 humans, bots start at ID 3. var start_bot_id = lobby_players.size() + 1 for i in range(start_bot_id, GameStateManager.max_players + 1): # Only spawn if not already existing (e.g. if a human somehow got this ID, though unlikely) if not has_node(str(i)): # Spawning as BOT add_player_character(i, true) print("Client: Pre-spawned potential bot ", i) # Initialize arena locally for Tekton Doors if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager: portal_mode_manager.setup_arena_locally() # Initialize arena locally for Candy Cannon Survival if LobbyManager.game_mode == "Candy Cannon Survival" and gauntlet_manager: gauntlet_manager._apply_arena_setup() # Ensure local player setup (UI, controls) is verified var player_character = get_node_or_null(str(my_id)) if player_character: # If we just spawned it above, we need to set these locally too if GameStateManager.local_player_character != player_character: GameStateManager.local_player_character = player_character ui_manager.set_local_player(player_character) if touch_controls: touch_controls.set_player(player_character) if camera_context_manager: camera_context_manager.set_player(player_character) print("Client: Configured local player ", my_id) # ALWAYS setup PowerUpUI when we have the local player, just in case var powerup_ui = get_node_or_null("TouchLayer/TouchControls/PowerUpInventoryUI") if powerup_ui: powerup_ui.setup(player_character) print("Client: PowerUpInventoryUI setup forced for ", my_id) # Wait for host to be ready, then request full sync # Snappier for LAN mode as peer is already established var client_setup_delay = 0.2 if LobbyManager.is_lan_mode else 1.0 await get_tree().create_timer(client_setup_delay).timeout if check_multiplayer(): # Ensure we see the server (Peer 1) if 1 in multiplayer.get_peers(): rpc_id(1, "request_full_player_sync", my_id) rpc_id(1, "request_full_grid_sync") else: print("Client: Connected but Peer 1 not found yet. Retrying in 1s...") await get_tree().create_timer(0.5).timeout if check_multiplayer() and 1 in multiplayer.get_peers(): rpc_id(1, "request_full_player_sync", my_id) rpc_id(1, "request_full_grid_sync") func _auto_start_from_lobby(): """Called when main.tscn is loaded from lobby - game is already connected.""" # Get match ID from LobbyManager var match_id = LobbyManager.current_room.get("match_id", "") var short_id = match_id.substr(0, 8) if match_id.length() > 8 else match_id # Update NetworkPanel in PauseMenu (if exists) var network_panel = get_node_or_null("PauseMenu/Panel/NetworkPanel") if network_panel: network_panel.get_node("NetworkInfo/UniquePeerID").text = str(multiplayer.get_unique_id()) if multiplayer.is_server(): network_panel.get_node("NetworkInfo/NetworkSideDisplay").text = "Host (Match: %s)" % short_id else: network_panel.get_node("NetworkInfo/NetworkSideDisplay").text = "Client (Match: %s)" % short_id if multiplayer.is_server(): print("Auto-starting as HOST - Match: ", short_id) _setup_host_game() else: print("Auto-starting as CLIENT - Match: ", short_id) _setup_client_game() func _start_game(): if multiplayer.is_server(): # Delay spawn assignment to allow clients to finish instantiating Player nodes # via MultiplayerSpawner. If called too early, the RPC is dropped and clients # are left misplaced at the default starting position. await get_tree().create_timer(1.0).timeout # NOW assign spawn positions for EVERYONE (Host, Client, Bots) _assign_random_spawn_positions() # Wait for Nakama websocket to actually be open, up to 5 seconds # SKIP THIS FOR LAN MODE if not LobbyManager.is_lan_mode: var nakama = get_node_or_null("/root/NakamaManager") if nakama and nakama.has_method("is_connected_to_nakama"): var wait_time = 0.0 while not nakama.is_connected_to_nakama() and wait_time < 5.0: await get_tree().create_timer(0.2).timeout wait_time += 0.2 # Stabilization delay to allow clients to finish loading and spawning # We wait 0.5s to ensure the remainder of the 1.2s loading screen buffer has finished # before the countdown starts. await get_tree().create_timer(0.5).timeout # PRE-GAME COUNTDOWN (3s) # Spawn static obstacles before countdown starts (Stop n Go only) if obstacle_manager and LobbyManager.game_mode == "Stop n Go": # obstacle_manager.spawn_random_obstacles(15) # Disabled: Using fixed obstacles now pass # Spawn mission and power-up tiles BEFORE countdown but AFTER walls (Stop n Go only) if LobbyManager.game_mode == "Stop n Go" and stop_n_go_manager: stop_n_go_manager.setup_mission_tiles() stop_n_go_manager.spawn_initial_powerups() # Ensure power-ups exist before 1,2,3 Go # Gauntlet: Spawn mission tiles across 20x20 arena BEFORE countdown if LobbyManager.game_mode == "Candy Cannon Survival" and gauntlet_manager: gauntlet_manager.setup_mission_tiles() # Spawn Static Tektons and random tiles BEFORE countdown (Free Mode Only) # Exclude for Stop n Go and Tekton Doors if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors" and LobbyManager.game_mode != "Candy Cannon Survival": spawn_static_tektons() # Tekton Doors: Randomize connections BEFORE countdown so colors show if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager: portal_mode_manager._randomize_connections() # STOP N GO: Rotate players to face East BEFORE countdown if LobbyManager.game_mode == "Stop n Go" and stop_n_go_manager: stop_n_go_manager.rotate_players_to_start() await _start_pre_game_countdown() GameStateManager.start_game() if can_rpc(): rpc("sync_game_start", GameStateManager.players, TurnManager.turn_based_mode) if TurnManager.turn_based_mode: TurnManager.reset_turn() var next_player = TurnManager.next_turn(GameStateManager.players) if can_rpc(): rpc("set_current_turn", next_player) # Start the global match timer (this also starts the first cycle) if LobbyManager.game_mode == "Stop n Go": # Only Server starts the mode logic (arena setup, missions, etc) if stop_n_go_manager: stop_n_go_manager.start_game_mode() # Also start global match timer for Stop n Go if goals_cycle_manager: var match_duration = LobbyManager.get_match_duration() goals_cycle_manager.start_match(float(match_duration), false) # No cycles for Stop n Go elif LobbyManager.game_mode == "Tekton Doors": if portal_mode_manager: portal_mode_manager.start_game_mode() if goals_cycle_manager: var match_duration = LobbyManager.get_match_duration() goals_cycle_manager.start_match(float(match_duration)) elif LobbyManager.game_mode == "Candy Cannon Survival": if gauntlet_manager: gauntlet_manager.start_game_mode() if goals_cycle_manager: var match_duration = LobbyManager.get_match_duration() goals_cycle_manager.start_match(float(match_duration), true) # Enable cycles for 3x3 pattern missions elif goals_cycle_manager: var match_duration = LobbyManager.get_match_duration() goals_cycle_manager.start_match(float(match_duration)) # Initialize leaderboard with all players if ui_manager: var all_players = get_tree().get_nodes_in_group("Players") ui_manager.initialize_leaderboard_with_players(all_players) # Spawn Tekton NPC spawn_tekton_npc() # ============================================================================= # Spawn Zone System - Prevents edge gaps with 1-tile perimeter buffer # ============================================================================= const PERIMETER_BUFFER = 1 # 1-tile safe zone on all sides enum SpawnZone { TOP_LEFT_CORNER, TOP_CENTER, TOP_RIGHT_CORNER, MIDDLE_LEFT, MIDDLE_CENTER, MIDDLE_RIGHT, BOTTOM_LEFT_CORNER, BOTTOM_CENTER, BOTTOM_RIGHT_CORNER } func _get_spawn_zones(gridmap: Node) -> Dictionary: """Returns a dictionary of spawn zones based on 3x3 grid layout. Corner zones are for Tektons, middle zones are for players.""" var width = gridmap.columns var height = gridmap.rows # Apply perimeter buffer var safe_width = width - (PERIMETER_BUFFER * 2) var safe_height = height - (PERIMETER_BUFFER * 2) # Divide safe area into 3x3 grid var zone_w = safe_width / 3 var zone_h = safe_height / 3 var zones = {} # Define 9 zones with buffer offsets for row in range(3): for col in range(3): var zone_idx = row * 3 + col var zone_rect = Rect2i( PERIMETER_BUFFER + (col * zone_w), PERIMETER_BUFFER + (row * zone_h), zone_w, zone_h ) zones[zone_idx] = zone_rect return zones func _is_position_in_zone(pos: Vector2i, zone: Rect2i) -> bool: """Check if position is within a spawn zone.""" return zone.has_point(pos) func _get_tekton_spawn_zones(zones: Dictionary) -> Array: """Returns corner zones for Tekton spawning.""" return [ zones[SpawnZone.TOP_LEFT_CORNER], zones[SpawnZone.TOP_RIGHT_CORNER], zones[SpawnZone.BOTTOM_LEFT_CORNER], zones[SpawnZone.BOTTOM_RIGHT_CORNER] ] func _get_player_spawn_zones(zones: Dictionary) -> Array: """Returns middle zones for player spawning.""" return [ zones[SpawnZone.TOP_CENTER], zones[SpawnZone.MIDDLE_LEFT], zones[SpawnZone.MIDDLE_CENTER], zones[SpawnZone.MIDDLE_RIGHT], zones[SpawnZone.BOTTOM_CENTER] ] func _assign_random_spawn_positions(): """Assign spawn positions distributed across middle zones (avoiding corners reserved for Tektons).""" var enhanced_gridmap = $EnhancedGridMap if not enhanced_gridmap: return # Get spawn zones with perimeter buffer var spawn_zones = _get_spawn_zones(enhanced_gridmap) var player_zones = _get_player_spawn_zones(spawn_zones) # Lists for player spawns in each zone var spawns_by_zone = {} # zone_rect -> [positions] for zone in player_zones: spawns_by_zone[zone] = [] var all_spawns = [] # Fallback # Stop n Go Custom Spawn Logic if LobbyManager.game_mode == "Stop n Go": var all_players = get_tree().get_nodes_in_group("Players") _assign_stop_n_go_spawn_positions(all_players) return # Tekton Doors Custom Spawn Logic if LobbyManager.game_mode == "Tekton Doors": var all_players = get_tree().get_nodes_in_group("Players") _assign_portal_mode_spawn_positions(all_players) return # If static positions were not calculated yet, do it now to avoid players spawning in them if reserved_static_positions.is_empty() and LobbyManager.game_mode != "Stop n Go": if not static_tekton_manager: static_tekton_manager = preload("res://scripts/managers/static_tekton_manager.gd").new() reserved_static_positions = static_tekton_manager.calculate_spawn_points(3, enhanced_gridmap) # Scan grid for walkable positions within player zones (respecting buffer) for x in range(PERIMETER_BUFFER, enhanced_gridmap.columns - PERIMETER_BUFFER): for z in range(PERIMETER_BUFFER, enhanced_gridmap.rows - PERIMETER_BUFFER): var ground = enhanced_gridmap.get_cell_item(Vector3i(x, 0, z)) if ground == 0: # Walkable var pos = Vector2i(x, z) # SAFETY CHECK: Is this reserved for a Static Tekton Stand? var is_safe = true for reserved in reserved_static_positions: if abs(x - reserved.x) <= 1 and abs(z - reserved.y) <= 1: is_safe = false break if not is_safe: continue # Check if position is in any player zone var in_player_zone = false for zone in player_zones: if _is_position_in_zone(pos, zone): spawns_by_zone[zone].append(pos) in_player_zone = true break # Add to fallback list regardless of zone if in_player_zone: all_spawns.append(pos) # Shuffle each zone's spawn list for randomization for zone in spawns_by_zone: spawns_by_zone[zone].shuffle() # Fallback shuffle all_spawns.shuffle() # Get all players and sort them for deterministic assignment var all_players = get_tree().get_nodes_in_group("Players") all_players.sort_custom(func(a, b): return a.name.to_int() < b.name.to_int()) var spawn_index = 0 # Round-robin assignment across player zones var zone_list = spawns_by_zone.keys() var zone_arrays = spawns_by_zone.values() for player in all_players: var assigned_pos = Vector2i(-1, -1) # Try to get from the current zone (round-robin) var zone_idx = spawn_index % zone_arrays.size() var zone_spawns = zone_arrays[zone_idx] if zone_spawns.size() > 0: assigned_pos = zone_spawns.pop_back() else: # Fallback: Try other zones if preferred one is empty for zone_arr in zone_arrays: if zone_arr.size() > 0: assigned_pos = zone_arr.pop_back() break # Ultimate fallback: Random from anywhere if assigned_pos == Vector2i(-1, -1) and all_spawns.size() > 0: assigned_pos = all_spawns.pop_back() if assigned_pos != Vector2i(-1, -1): # Set position and sync to all clients player.current_position = assigned_pos player.position = player.grid_to_world(assigned_pos) player.is_player_moving = false player.spawn_point_selected = true if can_rpc(): player.rpc("set_spawn_position", assigned_pos) else: print("Critical: No spawn point found for player ", player.name) spawn_index += 1 print("Assigned spawn %s to player %s" % [assigned_pos, player.name]) func _assign_stop_n_go_spawn_positions(all_players: Array): """Assigns spawns in the left columns of the Stop N Go arena, validating walkability.""" # Sort players for deterministic assignment based on ID all_players.sort_custom(func(a, b): return a.name.to_int() < b.name.to_int()) var enhanced_gridmap = $EnhancedGridMap if not enhanced_gridmap: return # Collect valid walkable spawn positions from the leftmost columns var valid_spawns: Array[Vector2i] = [] for col in range(0, min(5, enhanced_gridmap.columns)): # Check first 5 columns for row in range(enhanced_gridmap.rows): var tile = enhanced_gridmap.get_cell_item(Vector3i(col, 0, row)) if tile == 0 or tile == 3: # Walkable or Start valid_spawns.append(Vector2i(col, row)) if valid_spawns.size() >= all_players.size(): break # Fallback: if somehow no valid spawns found, use old logic if valid_spawns.is_empty(): for row in range(3, 3 + all_players.size()): valid_spawns.append(Vector2i(0, row)) for i in range(all_players.size()): var player = all_players[i] var assigned_pos = valid_spawns[i % valid_spawns.size()] # Ensure immediate sync player.position = player.grid_to_world(assigned_pos) player.current_position = assigned_pos player.is_player_moving = false player.spawn_point_selected = true if can_rpc(): player.rpc("set_spawn_position", assigned_pos) print("[StopNGo] Assigned spawn %s to player %s" % [assigned_pos, player.name]) func _assign_portal_mode_spawn_positions(all_players: Array): """Assigns spawns to different quadrants for Tekton Doors mode, avoiding stands and intersections.""" if not portal_mode_manager: _assign_random_spawn_positions() # Fallback return # Sort players for deterministic assignment all_players.sort_custom(func(a, b): return a.name.to_int() < b.name.to_int()) # Get baseline quadrant centers (3,3), (10,3), etc. var base_spawn_points = portal_mode_manager.get_spawn_points() var spawn_index = 0 var assigned_positions: Array[Vector2i] = [] for player in all_players: var center_pos = base_spawn_points[spawn_index % base_spawn_points.size()] var assigned_pos = center_pos # Fallback position # Spiral search for a valid spot (walkable, not in stand zone, not occupied) var found = false for radius in range(0, 5): # Increase search radius for dx in range(-radius, radius + 1): for dz in range(-radius, radius + 1): # Only check the "ring" at the current radius if abs(dx) != radius and abs(dz) != radius and radius > 0: continue var test_pos = center_pos + Vector2i(dx, dz) # 1. Check map bounds var em = $EnhancedGridMap if not em or test_pos.x < 0 or test_pos.x >= em.columns or test_pos.y < 0 or test_pos.y >= em.rows: continue # 2. Check if walkable floor (Floor 0, ID 0) if em.get_cell_item(Vector3i(test_pos.x, 0, test_pos.y)) != 0: continue # 3. Check if reserved for a Static Tekton Stand (3x3 area, use 2-tile buffer) var is_reserved = false for reserved in reserved_static_positions: if abs(test_pos.x - reserved.x) <= 2 and abs(test_pos.y - reserved.y) <= 2: is_reserved = true break if is_reserved: continue # 4. Check if occupied by another already-assigned player if assigned_positions.has(test_pos): continue assigned_pos = test_pos found = true break if found: break if found: break assigned_positions.append(assigned_pos) # Sync and place player.position = player.grid_to_world(assigned_pos) player.current_position = assigned_pos player.is_player_moving = false player.spawn_point_selected = true if can_rpc(): player.rpc("set_spawn_position", assigned_pos) spawn_index += 1 print("[PortalMode] Assigned Quadrant Pos %s to player %s" % [assigned_pos, player.name]) # ============================================================================= # Tekton NPC Management # ============================================================================= const StaticTektonManager = preload("res://scripts/managers/static_tekton_manager.gd") var static_tekton_manager func spawn_tekton_npc(): """Spawn Tektons in corner zones only (avoiding player spawn areas).""" if not multiplayer.is_server(): return var enhanced_gridmap = $EnhancedGridMap if not enhanced_gridmap: return # Get corner zones for Tekton spawning var spawn_zones = _get_spawn_zones(enhanced_gridmap) var tekton_zones = _get_tekton_spawn_zones(spawn_zones) # Collect valid spawn positions in corner zones var valid_positions = [] for zone in tekton_zones: for x in range(zone.position.x, zone.position.x + zone.size.x): for y in range(zone.position.y, zone.position.y + zone.size.y): var cell = Vector3i(x, 0, y) if enhanced_gridmap.get_cell_item(cell) == 0: # Walkable floor # Ensure not occupied by static tekton stand var item_id = enhanced_gridmap.get_cell_item(Vector3i(x, 1, y)) if item_id == 4: continue # Wall/Stand # Check RESERVED positions (static tekton stands) var is_safe = true for reserved in reserved_static_positions: if abs(x - reserved.x) <= 1 and abs(y - reserved.y) <= 1: is_safe = false break if not is_safe: continue valid_positions.append(Vector2i(x, y)) # Shuffle and spawn 3 Roaming Tektons valid_positions.shuffle() var spawned_count = 0 for pos in valid_positions: if spawned_count >= 3: break # Generate a consistent ID/Name for sync var tekton_id = Time.get_ticks_msec() + spawned_count _create_tekton(pos, tekton_id) # Only broadcast to clients if there are remote peers connected if can_rpc() and multiplayer.get_peers().size() > 0: rpc("sync_spawn_tekton", pos, tekton_id) spawned_count += 1 print("[Main] Spawned Tekton %d at %s (Corner Zone)" % [spawned_count, pos]) @rpc("call_remote", "reliable") func sync_spawn_tekton(pos: Vector2i, tekton_id: int): # Safety: only create if scene is fully ready if not is_inside_tree(): return _create_tekton(pos, tekton_id) func _create_tekton(pos: Vector2i, tekton_id: int, is_static: bool = false): var node_name = "Tekton_%d" % tekton_id if has_node(node_name): return var tekton_scene = load("res://scenes/tekton.tscn") var tekton = tekton_scene.instantiate() tekton.name = node_name add_child(tekton) # Initialize if is_static: tekton.is_static_turret = true if tekton.has_method("initialize"): if has_node("EnhancedGridMap"): tekton.initialize(pos, $EnhancedGridMap) # If Static, swap controller if is_static: # tekton.is_static_turret = true # Already set above var old_controller = tekton.get_node_or_null("TektonController") if old_controller: old_controller.queue_free() var static_controller = load("res://scripts/static_tekton_controller.gd").new() static_controller.name = "StaticTektonController" tekton.add_child(static_controller) print("[Main] Spawned STATIC Tekton at %s (ID: %d)" % [pos, tekton_id]) else: print("[Main] Spawned Tekton at %s (ID: %d)" % [pos, tekton_id]) func _precalculate_static_positions(): """Calculate and reserve Static Tekton positions early.""" if not multiplayer.is_server(): return # Static Tekton stands only exist in Free Mode if LobbyManager.game_mode == "Stop n Go": reserved_static_positions = [] return var enhanced_gridmap = $EnhancedGridMap if not enhanced_gridmap: return if not static_tekton_manager: static_tekton_manager = StaticTektonManager.new() # Calculate 3 spots and STORE them var points: Array[Vector2i] = [] points = static_tekton_manager.calculate_spawn_points(5, enhanced_gridmap) reserved_static_positions = points print("[Main] Pre-calculated Static Tekton Positions: %s" % str(reserved_static_positions)) func spawn_static_tektons(): """Spawn fixed static tektons using StaticTektonManager.""" if not multiplayer.is_server(): return # Disable for Stop n Go mode if LobbyManager.game_mode == "Stop n Go": return var enhanced_gridmap = $EnhancedGridMap if not enhanced_gridmap: return print("[Main] Initializing StaticTektonManager...") if not static_tekton_manager: static_tekton_manager = StaticTektonManager.new() # Use pre-calculated points if available var spawn_points: Array[Vector2i] = [] if not reserved_static_positions.is_empty(): # Pick exactly 3 from the 5 reserved potential spots as requested var possible_points = reserved_static_positions.duplicate() possible_points.shuffle() spawn_points = possible_points.slice(0, 3) else: # Fallback if not pre-calculated var raw_points = static_tekton_manager.calculate_spawn_points(5, enhanced_gridmap) raw_points.shuffle() spawn_points = raw_points.slice(0, 3) reserved_static_positions = raw_points # Save all 5 for player avoidance print("[Main] Spawning Static Tektons at: %s" % str(spawn_points)) for i in range(spawn_points.size()): var pos = spawn_points[i] # ID: 99000 + i (Consistent IDs for Static Tektons) var id = 99000 + i # Pick Shape on Server (0: Seat 1, 1: Seat 2) var shape_idx = randi() % 2 # Spawn on Server AND Sync to Clients (call_local handles both) rpc("sync_spawn_static_setup", pos, id, shape_idx) @rpc("call_local", "reliable") func sync_spawn_static_setup(pos: Vector2i, tekton_id: int, shape_idx: int): # Call local creation logic on all peers. # Server: Spawns Stand + Void + Tekton # Client: Avoids Stand (Spawner) + Void + Tekton _create_static_setup(pos, tekton_id, shape_idx) func _create_static_setup(pos: Vector2i, tekton_id: int, shape_idx: int): """Creates both the Stand and the Static Tekton at the position.""" var enhanced_gridmap = $EnhancedGridMap # 1. Create Stand (Server Only - Synced via Spawner) # IMPORTANT: Clients receive the Stand via MultiplayerSpawner. # They MUST NOT spawn it manually here or we get duplicates. if multiplayer.is_server(): var stands_container = get_node_or_null("Stands") if stands_container: var stand_name = "StaticStand_%d" % tekton_id if not stands_container.has_node(stand_name): var stand_scene = load("res://scenes/static_tekton_stand.tscn") if stand_scene: var stand = stand_scene.instantiate() stand.name = stand_name # Set Shape Index BEFORE adding to tree (so _ready picks it up/syncs) if "shape_index" in stand: stand.shape_index = shape_idx stands_container.add_child(stand) # Position Stand if enhanced_gridmap: # Convert grid to world var world_pos = Vector3(pos.x + 0.5, 0, pos.y + 0.5) if "cell_size" in enhanced_gridmap: world_pos = Vector3( pos.x * enhanced_gridmap.cell_size.x + enhanced_gridmap.cell_size.x / 2, 0.28, # Match the 0.28 elevation of the arena floor pos.y * enhanced_gridmap.cell_size.z + enhanced_gridmap.cell_size.z / 2 ) stand.global_position = world_pos # 2. Modify Base (Void) - Runs on ALL peers to update local GridMap visual/collision if enhanced_gridmap: var floor_count = 3 if "floors" in enhanced_gridmap: floor_count = enhanced_gridmap.floors for dx in range(-1, 2): for dy in range(-1, 2): var tile_pos_x = pos.x + dx var tile_pos_z = pos.y + dy # Clear ALL vertical layers (Ground, Items, etc.) for f in range(floor_count): var tile_pos = Vector3i(tile_pos_x, f, tile_pos_z) enhanced_gridmap.set_cell_item(tile_pos, -1) # -1 = Empty/Void # CRITICAL: Force AStar update so Bots and Pathfinding know about the new walls if enhanced_gridmap.has_method("update_astar_costs"): enhanced_gridmap.update_astar_costs() # 3. Create Tekton Visual - Runs on ALL peers # NOTE: Tekton NPC is currently not managed by a specialized Spawner for static setup? # Or it is? If _create_tekton adds it to a path watched by a spawner, we should duplicate check. # _create_tekton instantiates 'tekton.tscn' and adds to 'Main'. # Main usually has a MultiplayerSpawner for 'Players' etc., but let's check. # The original logic spawned it everywhere, so we keep that behavior to be safe. # But we add a check to avoid duplicates if it already came in via sync. if not has_node("Tekton_%d" % tekton_id): _create_tekton(pos, tekton_id, true) # ============================================================================= # Player Management # ============================================================================= func _add_bot(bot_id: int): rpc("create_bot", bot_id) @rpc("call_local") func create_bot(bot_id: int): if not GameStateManager.enable_bots: return if has_node(str(bot_id)): return var bot_character = PlayerManager.create_bot(bot_id) call_deferred("add_child", bot_character) bot_character.add_to_group("Players", true) bot_character.add_to_group("Bots", true) if multiplayer.is_server(): GameStateManager.add_bot(bot_id) var goal_index = bot_id - 1 if goal_index < GoalManager.preset_goals.size(): # Wait for bot managers to be ready (race_manager is created at T=0.5) await get_tree().create_timer(0.75).timeout bot_character.goals = GoalManager.preset_goals[goal_index].duplicate() # Use deferred goals sync to avoid timing issues call_deferred("_deferred_set_player_goals", bot_id, bot_character.goals) @rpc("any_peer", "call_local") func add_player_character(peer_id: int, is_bot: bool = false): print("[Main] add_player_character called for %d (is_bot: %s)" % [peer_id, is_bot]) if has_node(str(peer_id)): print("[Main] Player %d already exists! Skipping spawn." % peer_id) return var player_character if is_bot: player_character = PlayerManager.create_bot(peer_id) player_character.add_to_group("Bots", true) else: player_character = PlayerManager.add_player_character(peer_id) # Set properties BEFORE adding to tree (ensure _ready sees correct state) # create_bot already sets is_bot=true, but we ensure consistency player_character.is_bot = is_bot add_child(player_character) player_character.add_to_group("Players", true) GameStateManager.add_player(peer_id) # Try to set name from LobbyManager data if available var lobby_players = LobbyManager.get_players() for p_data in lobby_players: if p_data.get("id") == peer_id: var p_name = p_data.get("name", "Player") player_character.display_name = p_name print("[Main] Set player %d name to %s from Lobby data" % [peer_id, p_name]) break if peer_id == multiplayer.get_unique_id(): GameStateManager.local_player_character = player_character ui_manager.set_local_player(player_character) if touch_controls: touch_controls.set_player(player_character) if camera_context_manager: camera_context_manager.set_player(player_character) ui_manager.update_playerboard_ui() func _on_peer_connected(new_peer_id: int): if not is_inside_tree(): return if multiplayer.is_server(): await get_tree().create_timer(0.1).timeout add_player_character(new_peer_id) rpc("add_newly_connected_player_character", new_peer_id) # Wait for player to be ready then assign goals await get_tree().create_timer(0.3).timeout var player = get_node_or_null(str(new_peer_id)) if player: # Get the next available goal set for this player var player_index = GameStateManager.players.find(new_peer_id) if player_index >= 0 and player_index < GoalManager.preset_goals.size(): var player_goals = GoalManager.preset_goals[player_index].duplicate() player.goals = player_goals # Update goals UI for all clients call_deferred("_deferred_set_player_goals", new_peer_id, player_goals) @rpc func add_newly_connected_player_character(new_peer_id: int): add_player_character(new_peer_id) func _on_peer_disconnected(peer_id: int): if not is_inside_tree(): return if not multiplayer.has_multiplayer_peer(): return if multiplayer.is_server(): print("[Main] Peer %d disconnected. Checking for bot replacement..." % peer_id) var player_node = get_node_or_null(str(peer_id)) if player_node and not player_node.is_bot: # Cache state before removing var pos = player_node.current_position var p_score = player_node.score var p_goals = player_node.goals.duplicate() var p_char = player_node.selected_character # Remove human player GameStateManager.remove_player(peer_id) player_node.queue_free() # Add replacement bot if GameStateManager.enable_bots: var next_bot_id = PlayerManager.get_next_available_bot_id(GameStateManager.max_players, GameStateManager.players) if next_bot_id != -1: print("[Main] Replacing Player %d with Bot %d" % [peer_id, next_bot_id]) _replace_player_with_bot(next_bot_id, pos, p_score, p_goals, p_char) else: GameStateManager.remove_player(peer_id) func _replace_player_with_bot(bot_id: int, pos: Vector2i, p_score: int, p_goals: Array, p_char: String): """Creates a bot to replace a disconnected player and restores their state.""" rpc("create_bot_with_state", bot_id, pos, p_score, p_goals, p_char) @rpc("call_local") func create_bot_with_state(bot_id: int, pos: Vector2i, p_score: int, p_goals: Array, p_char: String): if not GameStateManager.enable_bots: return if has_node(str(bot_id)): return var bot_character = PlayerManager.create_bot(bot_id) call_deferred("add_child", bot_character) bot_character.add_to_group("Players", true) bot_character.add_to_group("Bots", true) # Apply transferred state bot_character.current_position = pos bot_character.score = p_score bot_character.goals = p_goals bot_character.selected_character = p_char if multiplayer.is_server(): GameStateManager.add_bot(bot_id) # Ensure position is synced bot_character.update_player_position(pos) func _on_host_disconnected(): if not is_inside_tree(): return """Called when the host leaves. Returns clients to the lobby.""" print("[Main] Host disconnected. Match terminated. Cleaning up and returning to lobby...") LobbyManager.leave_room() # Use loading screen to return to lobby var loading_screen_scene = load("res://scenes/loading_screen/loading_screen.tscn") if loading_screen_scene: var loading_screen = loading_screen_scene.instantiate() get_tree().root.add_child(loading_screen) loading_screen.load_level("res://scenes/lobby.tscn") else: get_tree().change_scene_to_file("res://scenes/lobby.tscn") func _on_rematch_starting(): if not is_inside_tree(): return """Called when a rematch is triggered. Reloads the game scene.""" print("[Main] Rematch starting. Resetting local state...") # Reset singletons/managers that persist across scene reloads GameStateManager.reset() GoalManager.reset() TurnManager.reset() is_match_ended = false get_tree().change_scene_to_file("res://scenes/main.tscn") # ============================================================================= # Turn Management (RPC Handlers) # ============================================================================= @rpc("reliable") func sync_turn_index(index: int): TurnManager.current_turn_index = index @rpc("any_peer", "call_local") func set_current_turn(player_id: int): if not TurnManager.turn_based_mode: return for player in get_tree().get_nodes_in_group("Players"): var is_current_turn = player.name == str(player_id) player.is_my_turn = is_current_turn if is_current_turn and not (player.is_bot or player.is_in_group("Bots")): player.has_moved_this_turn = false player.has_performed_action = false player.start_turn() player.clear_highlights() player.clear_playerboard_highlights() else: player.is_my_turn = false @rpc("call_local") func sync_game_start(player_list: Array, is_turn_based: bool): GameStateManager.players = player_list TurnManager.turn_based_mode = is_turn_based GameStateManager.start_game() if LobbyManager.game_mode == "Stop n Go": if not stop_n_go_manager: stop_n_go_manager = load("res://scripts/managers/stop_n_go_manager.gd").new() stop_n_go_manager.name = "StopNGoManager" add_child(stop_n_go_manager) stop_n_go_manager.activate_client_side() elif LobbyManager.game_mode == "Tekton Doors": if portal_mode_manager: portal_mode_manager.activate_client_side() # Initialize leaderboard for all peers (after a delay to ensure players loaded) call_deferred("_deferred_init_leaderboard") # ============================================================================= # UI / Action State Management # ============================================================================= # ============================================================================= # Goal & Playerboard Sync # ============================================================================= @rpc("reliable") func sync_preset_goals(goals_list: Array): GoalManager.preset_goals = goals_list @rpc("any_peer", "call_local") func sync_player_goals(player_id: int, goals: Array): var player = get_node_or_null(str(player_id)) if player: # Defer the goal setting to ensure managers are ready call_deferred("_deferred_set_player_goals", player_id, goals) func _deferred_set_player_goals(player_id: int, goals: Array): # The player node waits 0.5s in its _ready() function before initializing managers. # We MUST cleanly wait until it finishes creating them, instead of silently aborting. var player = get_node_or_null(str(player_id)) # Wait until player and its race_manager thoroughly exist var wait_limit = 2.0 # Safety limit var waited = 0.0 while (not player or not player.race_manager) and waited < wait_limit: await get_tree().create_timer(0.1).timeout waited += 0.1 player = get_node_or_null(str(player_id)) if player and player.race_manager: player.goals = goals.duplicate() # UI UPDATE FIX: explicitly refresh local UI upon receiving starting goals if player_id == multiplayer.get_unique_id() and GameStateManager.local_player_character: ui_manager.update_playerboard_ui() @rpc("any_peer", "call_local", "reliable") func sync_playerboard(player_id: int, new_playerboard: Array): # Find the player and update their playerboard var player = get_node_or_null(str(player_id)) if player: player.playerboard = new_playerboard.duplicate() # Update UI for local player if player_id == multiplayer.get_unique_id() and GameStateManager.local_player_character: ui_manager.update_playerboard_ui() @rpc("any_peer", "call_local", "reliable") func sync_playerboard_slot(player_id: int, slot_index: int, item_id: int): """Patch a single playerboard slot without touching other slots. Used by _execute_grab on grab confirmation to avoid overwriting concurrent in-flight optimistic grab updates on high-latency clients.""" var player = get_node_or_null(str(player_id)) if player and slot_index >= 0 and slot_index < player.playerboard.size(): player.playerboard[slot_index] = item_id # Update UI for local player only if player_id == multiplayer.get_unique_id() and GameStateManager.local_player_character: ui_manager.update_playerboard_ui() # ============================================================================= # ============================================================================= # Connection Verification # ============================================================================= func verify_all_connections(): if multiplayer.is_server(): for peer_id in GameStateManager.players: # Skip host (1) and bots (bots don't have real network connections) if peer_id != 1 and not peer_id in GameStateManager.bots: rpc_id(peer_id, "connection_verify", GameStateManager.players) @rpc func connection_verify(expected_players: Array): for peer_id in expected_players: if peer_id != multiplayer.get_unique_id() and not has_node(str(peer_id)): rpc_id(1, "request_specific_player_data", peer_id) @rpc("any_peer") func request_specific_player_data(requested_peer_id: int): if multiplayer.is_server(): var player = get_node_or_null(str(requested_peer_id)) if player: var player_data = { "peer_id": requested_peer_id, "position": player.current_position, "goals": player.goals, "playerboard": player.playerboard, "is_bot": player.is_bot || player.is_in_group("Bots") } rpc_id(multiplayer.get_remote_sender_id(), "create_specific_player", player_data) @rpc("any_peer") func request_full_player_sync(requesting_peer_id: int): if multiplayer.is_server(): for peer_id in GameStateManager.players: var player = get_node_or_null(str(peer_id)) if player: var player_data = { "peer_id": peer_id, "position": player.current_position, "name": player.display_name, "goals": player.goals, "playerboard": player.playerboard, "spawn_point_selected": player.spawn_point_selected } if _can_rpc(): rpc_id(requesting_peer_id, "create_specific_player", player_data) await get_tree().create_timer(0.1).timeout @rpc("reliable") func create_specific_player(data: Dictionary): var peer_id = data["peer_id"] var player_character = null var node_already_exists = has_node(str(peer_id)) if node_already_exists: # Player already exists, just get the reference player_character = get_node(str(peer_id)) else: # Create new player player_character = PlayerManager.add_player_character(peer_id) player_character.current_position = data["position"] add_child(player_character) player_character.add_to_group("Players", true) # Set display name if data.has("name"): player_character.display_name = data["name"] if data["is_bot"]: player_character.add_to_group("Bots", true) player_character.is_bot = true # Set spawn flag and visibility for BOTH new and existing players if data.has("spawn_point_selected") and data["spawn_point_selected"]: player_character.spawn_point_selected = true player_character.visible = true # Ensure visual position matches logical var new_pos = player_character.grid_to_world(data["position"]) player_character.global_position = new_pos player_character.target_visual_position = new_pos # Force collision update if player_character.has_node("CollisionShape3D"): player_character.get_node("CollisionShape3D").disabled = false # Check if this is the local player (client's own player) var is_local_player = (peer_id == multiplayer.get_unique_id()) if is_local_player and GameStateManager.local_player_character == null: GameStateManager.local_player_character = player_character ui_manager.set_local_player(player_character) # Wait for player managers to initialize (player.gd has 0.1s await in _ready) await get_tree().create_timer(0.2).timeout # Now set goals and playerboard after managers are ready var goals_to_set = data["goals"].duplicate() if data.has("goals") else [] if goals_to_set.size() > 0 and player_character.race_manager: player_character.goals = goals_to_set var playerboard_to_set = data["playerboard"].duplicate() if data.has("playerboard") else [] if playerboard_to_set.size() > 0 and player_character.race_manager: player_character.playerboard = playerboard_to_set # Always update position (including for existing nodes, so client sees host correctly) player_character.current_position = data["position"] # Use the player's own grid_to_world to respect cell_size (1,1,1) var new_world_pos = player_character.grid_to_world(data["position"]) player_character.global_position = new_world_pos player_character.target_visual_position = new_world_pos # Update playerboard UI for local player if is_local_player: ui_manager.update_playerboard_ui() # ============================================================================= # Grid Item Randomization # ============================================================================= func randomize_item_at_position(grid_position: Vector2i): if not multiplayer.is_server(): rpc_id(1, "request_randomize_item", grid_position) return var enhanced_gridmap = $EnhancedGridMap if enhanced_gridmap: var cell = Vector3i(grid_position.x, 1, grid_position.y) var current_item = enhanced_gridmap.get_cell_item(cell) # If current item exists, replace it (scarcity aware) # If current item exists OR we are forcing a spawn on valid ground var floor_0_item = enhanced_gridmap.get_cell_item(Vector3i(grid_position.x, 0, grid_position.y)) var is_ground = (floor_0_item != -1 and floor_0_item != 4) # Skip walls (4) and empty space (-1) # Prevent stacking on players if is_ground: for player in get_tree().get_nodes_in_group("Players"): if Vector2i(player.current_position.x, player.current_position.y) == grid_position: is_ground = false break if is_ground: var new_item = 7 var get_mode_specific_tile = func(): if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors" and LobbyManager.game_mode != "Candy Cannon Survival": # 60% Chance for Common (7-10), 40% for PowerUp if randf() <= 0.6: return [7, 8, 9, 10].pick_random() else: return ScarcityModel.SPECIAL_TILES.pick_random() return ScarcityController.get_random_tile_id() new_item = get_mode_specific_tile.call() # If we are replacing an existing item, try to ensure it changes if current_item != -1: var max_retries = 3 while new_item == current_item and max_retries > 0: new_item = get_mode_specific_tile.call() max_retries -= 1 sync_grid_item(cell.x, cell.y, cell.z, new_item) rpc("sync_grid_item", cell.x, cell.y, cell.z, new_item) @rpc("any_peer") func request_randomize_item(grid_position: Vector2i): if multiplayer.is_server(): randomize_item_at_position(grid_position) @rpc("any_peer", "call_local", "reliable") func sync_grid_item(x: int, y: int, z: int, item: int): var enhanced_gridmap = $EnhancedGridMap if enhanced_gridmap: # FLOOR ENFORCEMENT: Visual tiles (IDs 7-20) must ALWAYS be on layer Y=1. # If somehow sent to Y=0, redirect them to Y=1. if item >= 7 and item <= 20 and y == 0: push_warning("[Main] Tile %d was sent to floor Y=0 at (%d,0,%d). Redirecting to Y=1." % [item, x, z]) y = 1 # PROTECTED FLOOR CHECK: Block tiles (7-20) from being placed on walls (4) or void (-1) # Note: We allow spawning on Safe Zones, Start, and Finish as it's on Layer 1. if y == 1 and item >= 7 and item <= 20: var f0 = enhanced_gridmap.get_cell_item(Vector3i(x, 0, z)) var f1 = enhanced_gridmap.get_cell_item(Vector3i(x, 1, z)) # Block if Layer 0 is Wall (4) or Void (-1) # OR Layer 1 is already a wall (4) if f0 in [4, -1] or f1 == 4: return # TEKTON DOORS: Prevent placing items on portal doors if LobbyManager.is_game_mode(GameMode.Mode.TEKTON_DOORS): var doors = get_tree().get_nodes_in_group("PortalDoors") for door in doors: var door_grid = enhanced_gridmap.local_to_map(enhanced_gridmap.to_local(door.global_position)) if door_grid.x == x and door_grid.z == z: return enhanced_gridmap.set_cell_item(Vector3i(x, y, z), item) # Force visual update if enhanced_gridmap.has_method("update_grid_data"): enhanced_gridmap.update_grid_data() @rpc("any_peer", "call_local", "reliable") func sync_grid_items_batch(data: Array): # data is an array of dictionaries: [{x: int, y: int, z: int, item: int}, ...] var enhanced_gridmap = $EnhancedGridMap if not enhanced_gridmap: return for entry in data: var x = entry.get("x", 0) var y = entry.get("y", 0) var z = entry.get("z", 0) var item = entry.get("item", -1) # FLOOR ENFORCEMENT: Visual tiles (IDs 7-20) must ALWAYS be on layer Y=1. if item >= 7 and item <= 20 and y == 0: y = 1 # PROTECTED FLOOR CHECK if y == 1 and item >= 7 and item <= 20: var f0 = enhanced_gridmap.get_cell_item(Vector3i(x, 0, z)) var f1 = enhanced_gridmap.get_cell_item(Vector3i(x, 1, z)) if f0 in [4, -1] or f1 == 4: continue # TEKTON DOORS: Prevent placing items on portal doors if LobbyManager.is_game_mode(GameMode.Mode.TEKTON_DOORS) and y == 1: var doors = get_tree().get_nodes_in_group("PortalDoors") var on_door = false for door in doors: var door_grid = enhanced_gridmap.local_to_map(enhanced_gridmap.to_local(door.global_position)) if door_grid.x == x and door_grid.z == z: on_door = true break if on_door: continue enhanced_gridmap.set_cell_item(Vector3i(x, y, z), item) # Force visual update ONCE after batch if enhanced_gridmap.has_method("update_grid_data"): enhanced_gridmap.update_grid_data() func randomize_game_grid(): if LobbyManager.game_mode == "Stop n Go" or LobbyManager.game_mode == "Tekton Doors": return # These modes manage their own arena setup and item spawning var enhanced_gridmap = $EnhancedGridMap if enhanced_gridmap: # FIRST: Ensure Floor 0 is entirely filled with walkable ground (ID 0) # This ensures StaticTektonManager calculation (which checks Floor 0) succeeds. for x in range(enhanced_gridmap.columns): for z in range(enhanced_gridmap.rows): var f0_item = enhanced_gridmap.get_cell_item(Vector3i(x, 0, z)) # If empty OR it's a tile/powerup (7-20) accidentally stuck on the ground layer, replace with Base Floor (0) if f0_item == -1 or (f0_item >= 7 and f0_item <= 20): enhanced_gridmap.set_cell_item(Vector3i(x, 0, z), 0) # Custom spawn ratio for Free Mode: 80% common tiles, 20% empty tiles (start of game) var density_callable = func(): if randf() <= 0.8: return ScarcityModel.STANDARD_TILES.pick_random() else: return -1 # Empty enhanced_gridmap.randomize_floor(1, density_callable) # Sync with all clients immediately if multiplayer.is_server(): var grid_data = enhanced_gridmap.get_floor_data(1) rpc("sync_full_grid_data", grid_data) print("[Main] Server: Randomized grid (80%% density) and broadcasted to clients.") @rpc("authority", "call_local", "reliable") func sync_full_grid_data_stop_n_go(floor0: PackedInt32Array, floor1: PackedInt32Array, cols: int, rows: int): # Deprecated: Kept for signature compatibility but disabled to prevent MTU payload overflow. pass @rpc("any_peer") func request_full_grid_sync(): if multiplayer.is_server(): var sender_id = multiplayer.get_remote_sender_id() print("[Main] Grid sync requested by %d" % sender_id) var enhanced_gridmap = $EnhancedGridMap if enhanced_gridmap: if LobbyManager.game_mode == "Stop n Go" and stop_n_go_manager: # Resync Phase and Missions since they might have missed the initial broadcast var phase_name = "GO" if stop_n_go_manager.current_phase == stop_n_go_manager.Phase.GO else "STOP" stop_n_go_manager.rpc_id(sender_id, "sync_phase", phase_name, stop_n_go_manager.phase_timer) if stop_n_go_manager.player_missions.has(sender_id): var mission_dict = {sender_id: stop_n_go_manager.player_missions[sender_id]} stop_n_go_manager.rpc_id(sender_id, "sync_missions", mission_dict) # For all modes, only sync Floor 1 (Items) to prevent MTU packet overflow. # Floor 0 logic is deterministic and generated locally on level load. var grid_data = enhanced_gridmap.get_floor_data(1) print("[Main] Server: Prepared grid data. Size: %d. Sending to %d..." % [grid_data.size(), sender_id]) # Delay slightly to ensure socket stability after player syncs await get_tree().create_timer(0.2).timeout if sender_id in multiplayer.get_peers(): rpc_id(sender_id, "sync_full_grid_data", grid_data) print("[Main] Server: Sent grid sync rpc_id to %d" % sender_id) # If Tekton Doors, sync portal connections too if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager: portal_mode_manager.sync_to_client(sender_id) @rpc("authority", "call_local", "reliable") func sync_full_grid_data(data: PackedInt32Array): print("[Main] sync_full_grid_data received. Items: %d" % (data.size() / 3)) var enhanced_gridmap = $EnhancedGridMap if not enhanced_gridmap: print("[Main] Error: EnhancedGridMap not found!") return # Reapply deterministic Floor 0 before syncing Floor 1 items if LobbyManager.game_mode == "Stop n Go": if not stop_n_go_manager: stop_n_go_manager = load("res://scripts/managers/stop_n_go_manager.gd").new() stop_n_go_manager.name = "StopNGoManager" add_child(stop_n_go_manager) stop_n_go_manager._apply_arena_setup() elif LobbyManager.game_mode == "Tekton Doors": if not portal_mode_manager: portal_mode_manager = load("res://scripts/managers/portal_mode_manager.gd").new() portal_mode_manager.name = "PortalModeManager" add_child(portal_mode_manager) portal_mode_manager.initialize(self , enhanced_gridmap) portal_mode_manager.setup_arena_locally() else: # Freemode: Ensure Floor 0 is entirely walkable (reset stale state from previous modes) for x in range(enhanced_gridmap.columns): for z in range(enhanced_gridmap.rows): var f0_item = enhanced_gridmap.get_cell_item(Vector3i(x, 0, z)) if f0_item == -1 or (f0_item >= 7 and f0_item <= 20): enhanced_gridmap.set_cell_item(Vector3i(x, 0, z), 0) # Apply the synced data to Floor 1 enhanced_gridmap.set_floor_data(1, data) enhanced_gridmap.update_grid_data() enhanced_gridmap.initialize_astar() print("[Main] Grid sync complete.") # ============================================================================= # Goals Cycle & Leaderboard UI # ============================================================================= func _on_timer_updated(time_remaining: float): # Update standalone timer display # DISABLED: Now used for PowerUp Cooldown (handled in UIManager) # var time_text = str(int(time_remaining)) # # var timer_panel = get_node_or_null("GoalsTimer") # if timer_panel: # var timer_label = timer_panel.get_node_or_null("VBox/TimerLabel") # if timer_label: # timer_label.text = time_text pass func _on_score_updated(peer_id: int, new_score: int): # Update player's score display var player = get_node_or_null(str(peer_id)) if player: player.score = new_score # Update leaderboard UI _update_leaderboard_display() func _on_leaderboard_updated(sorted_scores: Array): # Update the leaderboard panel locally _update_leaderboard_display() # Server broadcasts updated leaderboard to all clients if multiplayer.is_server(): var player_data = [] for p in get_tree().get_nodes_in_group("Players"): player_data.append({ # Use name.to_int() to correctly identify bots (Authority 1) vs Players "peer_id": p.name.to_int(), "name": p.display_name if not p.display_name.is_empty() else str(p.name), "score": goals_cycle_manager.get_player_score(p.name.to_int()) if goals_cycle_manager else 0 }) rpc("sync_leaderboard_data", player_data) # Update player rank visuals for everyone (Client + Server) var all_players = get_tree().get_nodes_in_group("Players") var sorted_players = [] for p in all_players: var peer_id = p.name.to_int() var score = goals_cycle_manager.get_player_score(peer_id) if goals_cycle_manager else 0 sorted_players.append({"node": p, "score": score}) # Sort by score descending (with Stop n Go winner priority) if LobbyManager.game_mode == "Stop n Go" and stop_n_go_winner_id != -1: sorted_players.sort_custom(func(a, b): var a_id = a.node.name.to_int() var b_id = b.node.name.to_int() if a_id == stop_n_go_winner_id: return true if b_id == stop_n_go_winner_id: return false return a.score > b.score ) else: sorted_players.sort_custom(func(a, b): return a.score > b.score) # Assign rank for i in range(sorted_players.size()): var p_node = sorted_players[i].node var rank = i + 1 if p_node.has_method("update_rank_visuals"): p_node.update_rank_visuals(rank) func _on_global_timer_updated(time_remaining: float): """Update the global match timer display.""" var global_timer_panel = get_node_or_null("GlobalMatchTimer") if global_timer_panel: var timer_label = global_timer_panel.get_node_or_null("VBox/TimerLabel") if timer_label: var minutes = int(time_remaining) / 60 var seconds = int(time_remaining) % 60 timer_label.text = "%02d:%02d" % [minutes, seconds] # Trigger countdown animations (30s and 15s) if int(time_remaining) == 30 and int(time_remaining + 0.1) > 30: if vfx_manager and vfx_manager.has_method("play_countdown_30s"): vfx_manager.play_countdown_30s() elif int(time_remaining) == 15 and int(time_remaining + 0.1) > 15: if vfx_manager and vfx_manager.has_method("play_countdown_15s"): vfx_manager.play_countdown_15s() @rpc("any_peer", "call_local", "reliable") func sync_game_end_stop_n_go(winner_id: int): print("[STOP n GO] Game ended! Winner: ", winner_id) stop_n_go_winner_id = winner_id if goals_cycle_manager: goals_cycle_manager.stop_n_go_winner_id = winner_id var winner_name = "Player " + str(winner_id) var player_node = get_node_or_null(str(winner_id)) if player_node: winner_name = player_node.display_name # Broadcast win (Validation already done in check_win_condition) add_message_to_bar("MATCH COMPLETE", winner_name + " Wins with 8 Missions!", MessageType.GOAL) # Stop logic if stop_n_go_manager: stop_n_go_manager.is_active = false # Trigger match end _on_match_ended() @rpc("any_peer", "call_local", "reliable") func sync_game_end_portal_mode(winner_id: int): print("[TEKTON DOORS] Game ended! Winner: ", winner_id) portal_mode_winner_id = winner_id var winner_name = "Player " + str(winner_id) var player_node = get_node_or_null(str(winner_id)) if player_node: winner_name = player_node.display_name # Broadcast win add_message_to_bar("MATCH COMPLETE", winner_name + " Wins with 8 Missions!", MessageType.GOAL) # Stop logic if portal_mode_manager: if portal_mode_manager.swap_timer: portal_mode_manager.swap_timer.stop() if portal_mode_manager.tile_refresh_timer: portal_mode_manager.tile_refresh_timer.stop() # Trigger match end _on_match_ended() func _on_match_ended(): """Called when the global match timer ends - show game over screen.""" if is_match_ended: return is_match_ended = true print("[Main] Match ended! Showing game over screen...") # Signal Global Game End (Stops Bot ticks and logic) GameStateManager.end_game() # Freeze all game actors (Players, Bots, Tektons) _freeze_all_game_actors() # Show game over overlay _show_game_over_panel() func _freeze_all_game_actors(): """Manually stop all game entities from processing without pausing the UI.""" print("[Main] Freezing all game actors recursively...") var groups = ["Players", "Bots", "Tektons", "StaticTektonStands"] for group_name in groups: var nodes = get_tree().get_nodes_in_group(group_name) for node in nodes: _freeze_node_recursive(node) func _freeze_node_recursive(node: Node): """Recursively disable processing and stop tweens for a node and its children.""" if node.has_method("set_physics_process"): node.set_physics_process(false) if node.has_method("set_process"): node.set_process(false) # Kill movement tweens if it's a character if "_movement_tween" in node and node._movement_tween: node._movement_tween.kill() node._movement_tween = null # Recursive call for all children for child in node.get_children(): _freeze_node_recursive(child) func _show_game_over_panel(): """Instantiate and display the game over panel scene.""" # Check if panel already exists var existing_layer = get_node_or_null("GameOverLayer") if existing_layer: existing_layer.show() return # Hide Gameplay UI var actions_btn = get_node_or_null("TouchControls/TouchControls/ActionsBtn") if actions_btn: actions_btn.hide() var touch_layer = get_node_or_null("TouchLayer") if touch_layer: touch_layer.hide() var top_menu_ui = get_node_or_null("TopMenuUI") if top_menu_ui: top_menu_ui.hide() if stop_n_go_manager and stop_n_go_manager.hud_layer: stop_n_go_manager.hud_layer.hide() if portal_mode_manager and portal_mode_manager.hud_layer: portal_mode_manager.hud_layer.hide() # ========================================================================= # Gather + sort player data # ========================================================================= var all_player_scores = [] for p in get_tree().get_nodes_in_group("Players"): var pid = p.name.to_int() all_player_scores.append({ "peer_id": pid, "name": p.display_name if not p.display_name.is_empty() else str(p.name), "score": goals_cycle_manager.get_player_score(pid) if goals_cycle_manager else 0, "goal_count": goals_cycle_manager.player_goal_counts.get(pid, 0) if goals_cycle_manager else 0, "character": p._selected_character if "_selected_character" in p else "Masbro" }) # Sort players by score (with winner priority for special modes) if LobbyManager.game_mode == "Stop n Go" and stop_n_go_winner_id != -1: all_player_scores.sort_custom(func(a, b): if a.peer_id == stop_n_go_winner_id: return true if b.peer_id == stop_n_go_winner_id: return false return a.score > b.score ) elif LobbyManager.game_mode == "Tekton Doors" and portal_mode_winner_id != -1: all_player_scores.sort_custom(func(a, b): if a.peer_id == portal_mode_winner_id: return true if b.peer_id == portal_mode_winner_id: return false return a.score > b.score ) else: all_player_scores.sort_custom(func(a, b): return a.score > b.score) # ========================================================================= # Instantiate the scene # ========================================================================= var panel_scene = load("res://scenes/ui/game_over_panel.tscn") if not panel_scene: push_error("[Main] Failed to load game_over_panel.tscn") return # CanvasLayer for proper z-ordering var canvas_layer = CanvasLayer.new() canvas_layer.name = "GameOverLayer" canvas_layer.layer = 50 add_child(canvas_layer) var panel = panel_scene.instantiate() canvas_layer.add_child(panel) # Populate data var local_peer_id = multiplayer.get_unique_id() panel.setup(all_player_scores, local_peer_id) # --- REPORT NAKAMA MATCH STATS --- var local_player_won = false var local_player_score = 0 if all_player_scores.size() > 0: # all_player_scores is sorted descending by score or winner ID, so index 0 is the winner if all_player_scores[0].peer_id == local_peer_id: local_player_won = true for data in all_player_scores: if data.peer_id == local_peer_id: local_player_score = data.score break # Send match result to Nakama storage via UserProfileManager (runs async) if get_node_or_null("/root/UserProfileManager"): UserProfileManager.record_game_result(local_player_won, local_player_score) # --------------------------------- # Connect signals panel.back_pressed.connect(_on_back_to_menu_pressed) panel.rematch_pressed.connect(func(): LobbyManager.request_rematch.rpc(multiplayer.get_unique_id()) ) LobbyManager.rematch_votes_updated.connect(func(count, required): if is_instance_valid(panel): panel.update_rematch_votes(count, required) ) func _on_back_to_menu_pressed(): """Return to lobby/main menu and clean up game state.""" print("[Main] Returning to lobby...") # Proper ordered cleanup to avoid ghost players and desync LobbyManager.leave_room() # Small delay to let cleanup settle await get_tree().create_timer(0.2).timeout # Go back to lobby if get_tree(): get_tree().change_scene_to_file("res://scenes/lobby.tscn") func _cleanup_multiplayer(): """Properly leave Nakama match and cleanup multiplayer state.""" NakamaManager.cleanup() func _deferred_init_leaderboard(): """Initialize leaderboard after a delay to ensure all players are loaded.""" # Longer delay ensures players are synced await get_tree().create_timer(1.5).timeout # Request leaderboard sync from server for accurate data if not multiplayer.is_server(): if 1 in multiplayer.get_peers(): rpc_id(1, "request_leaderboard_sync") else: # Server can update directly _update_leaderboard_display() @rpc("any_peer") func request_leaderboard_sync(): """Client requests leaderboard data from server.""" if multiplayer.is_server(): var sender_id = multiplayer.get_remote_sender_id() # Build player list with peer_ids and names var player_data = [] for p in get_tree().get_nodes_in_group("Players"): player_data.append({ # Use name.to_int() for consistent ID "peer_id": p.name.to_int(), "name": p.display_name if not p.display_name.is_empty() else str(p.name), "score": goals_cycle_manager.get_player_score(p.name.to_int()) if goals_cycle_manager else 0 }) rpc_id(sender_id, "sync_leaderboard_data", player_data) @rpc("authority", "call_local", "reliable") func sync_leaderboard_data(player_data: Array): """Receive leaderboard data from server and update UI.""" # Update local player node scores first so live UI tick stays synced for data in player_data: var p_node = get_node_or_null(str(data.peer_id)) if p_node: p_node.score = data.score var leaderboard_panel = get_node_or_null("LeaderboardPanel") if not leaderboard_panel: return var vbox = leaderboard_panel.get_node_or_null("MarginContainer/VBox") if not vbox: return # Sort by score descending (with Stop n Go winner priority) if LobbyManager.game_mode == "Stop n Go" and stop_n_go_winner_id != -1: player_data.sort_custom(func(a, b): if a.peer_id == stop_n_go_winner_id: return true if b.peer_id == stop_n_go_winner_id: return false return a.score > b.score ) else: player_data.sort_custom(func(a, b): return a.score > b.score) # Update entries _render_leaderboard_entries(player_data) func _update_leaderboard_display(): var leaderboard_panel = get_node_or_null("LeaderboardPanel") if not leaderboard_panel: return # Try both possible paths for vbox var vbox = leaderboard_panel.get_node_or_null("MarginContainer/VBox") if not vbox: vbox = leaderboard_panel.get_node_or_null("VBox") if not vbox: return # Get all players in game var all_players = get_tree().get_nodes_in_group("Players") # Build scores array with all players var player_data = [] for p in all_players: var peer_id = p.name.to_int() var score = goals_cycle_manager.get_player_score(peer_id) if goals_cycle_manager else 0 p.score = score # Assign locally so ui_manager.gd reads correct score player_data.append({"peer_id": peer_id, "name": p.display_name if not p.display_name.is_empty() else str(p.name), "score": score}) # Sort by score descending (with Stop n Go winner priority) if LobbyManager.game_mode == "Stop n Go" and stop_n_go_winner_id != -1: player_data.sort_custom(func(a, b): if a.peer_id == stop_n_go_winner_id: return true if b.peer_id == stop_n_go_winner_id: return false return a.score > b.score ) else: player_data.sort_custom(func(a, b): return a.score > b.score) _render_leaderboard_entries(player_data) func _render_leaderboard_entries(sorted_player_data: Array): var leaderboard_panel = get_node_or_null("LeaderboardPanel") if not leaderboard_panel: return var vbox = leaderboard_panel.get_node_or_null("MarginContainer/VBox") if not vbox: vbox = leaderboard_panel.get_node_or_null("VBox") if not vbox: return var my_id = multiplayer.get_unique_id() var my_index = -1 for i in range(sorted_player_data.size()): if sorted_player_data[i].peer_id == my_id: my_index = i break # Determine items to display (Max 4 slots for HUD) var items_to_display = [] # add top 3 for i in range(min(3, sorted_player_data.size())): items_to_display.append({"data": sorted_player_data[i], "rank": i + 1}) # add 4th slot (Smart Slot) if sorted_player_data.size() >= 4: # If local player is outside top 3 (index > 2), show them in 4th slot # But if they are exactly 4th (index 3), it's the same as showing 4th place. # If they are 5th (index 4) or worse, we replace 4th place with them. if my_index > 3: # Show local player items_to_display.append({"data": sorted_player_data[my_index], "rank": my_index + 1}) else: # Show standard 4th place items_to_display.append({"data": sorted_player_data[3], "rank": 4}) # Render # We assume the HUD has 4 slots max, but code iterates up to 8 just in case UI has more and we want to hide them. for i in range(8): var entry = vbox.get_node_or_null("Entry" + str(i + 1)) if not entry: continue # Only show up to 4 entries in this new "Smart" mode if i < items_to_display.size() and i < 4: var item = items_to_display[i] var data = item.data var rank = item.rank var rank_label = entry.get_node_or_null("RankLabel") var name_label = entry.get_node_or_null("NameLabel") var score_label = entry.get_node_or_null("ScoreLabel") if rank_label: rank_label.text = _get_ordinal(rank) if name_label: name_label.text = str(data.name) if score_label: score_label.text = str(data.score) entry.visible = true if data.peer_id == my_id: entry.modulate = Color(1.0, 1.0, 0.0) # Yellow else: entry.modulate = Color.WHITE else: entry.visible = false func _get_ordinal(n: int) -> String: match n: 1: return "1st" 2: return "2nd" 3: return "3rd" _: return str(n) + "th" # ============================================================================= # Pause Menu & Settings # ============================================================================= func _input(event): if event.is_action_pressed("ui_cancel"): _toggle_pause_menu() # DEBUG: check all floors if event is InputEventKey and event.pressed and event.keycode == KEY_F9: check_all_floors() func check_all_floors(): print("--- CHECKING ALL FLOORS (Debug F9) ---") var enhanced_gridmap = get_node_or_null("EnhancedGridMap") if not enhanced_gridmap: print("Error: EnhancedGridMap not found.") return var missing_count = 0 var total_checked = 0 # Assuming standard 14x14 board (0-13) for x in range(14): for y in range(14): total_checked += 1 var cell_3d = Vector3i(x, 0, y) var item = enhanced_gridmap.get_cell_item(cell_3d) if item == -1: print("MISSING FLOOR at [%d, %d]! (Item: -1)" % [x, y]) missing_count += 1 # Optional: Auto-fix? # enhanced_gridmap.set_cell_item(cell_3d, 1) elif item == 6: print("ICE/CRACK FLOOR at [%d, %d] (Item: 6)" % [x, y]) print("--- CHECK COMPLETE: Found %d missing floors out of %d checked. ---" % [missing_count, total_checked]) var msg_type = NotificationManager.MessageType.WARNING if missing_count > 0 else NotificationManager.MessageType.NORMAL NotificationManager.send_message(GameStateManager.local_player_character, "Checked Floors: %d Missing" % missing_count, msg_type) func _toggle_pause_menu(): var pause_menu = get_node_or_null("PauseMenu") if pause_menu: pause_menu.visible = not pause_menu.visible # Hide NetworkInfo in pause menu as requested (too technical/unnecessary for local pause) var network_info = pause_menu.get_node_or_null("Panel/NetworkPanel/NetworkInfo") if network_info: network_info.visible = false func _on_resume_pressed(): var pause_menu = get_node_or_null("PauseMenu") if pause_menu: pause_menu.visible = false # get_tree().paused = false # Removed for multiplayer consistency func _on_how_to_play_pressed(): """Open How To Play panel directly, regardless of where it was called from.""" var help_panel = get_node_or_null("HowToPlayPanel") if help_panel: help_panel.visible = true func _on_how_to_play_back_pressed(): """Just close the How To Play panel.""" var help_panel = get_node_or_null("HowToPlayPanel") if help_panel: help_panel.visible = false func _on_settings_pressed(): var pause_menu = get_node_or_null("PauseMenu") if pause_menu: pause_menu.visible = false var settings_menu = get_node_or_null("SettingsMenu") if not settings_menu: var scene = load("res://scenes/ui/settings_menu.tscn") if scene: settings_menu = scene.instantiate() settings_menu.name = "SettingsMenu" add_child(settings_menu) # Connect close button var close_btn = settings_menu.get_node_or_null("PanelContainer/VBoxContainer/Header/CloseButton") if close_btn: if close_btn.pressed.is_connected(_on_settings_back_pressed): close_btn.pressed.disconnect(_on_settings_back_pressed) close_btn.pressed.connect(_on_settings_back_pressed) if settings_menu: settings_menu.open() func _on_quit_match_pressed(): get_tree().paused = false # Ensure unpaused when returning to menu # Properly disconnect from Nakama and clear lobby state to prevent desync LobbyManager.leave_room() # Return to lobby via loading screen var loading_screen_scene = load("res://scenes/loading_screen/loading_screen.tscn") if loading_screen_scene: var loading_screen = loading_screen_scene.instantiate() get_tree().root.add_child(loading_screen) loading_screen.load_level("res://scenes/lobby.tscn") else: # Fallback get_tree().change_scene_to_file("res://scenes/lobby.tscn") func _on_settings_back_pressed(): var pause_menu = get_node_or_null("PauseMenu") var settings_menu = get_node_or_null("SettingsMenu") if settings_menu: settings_menu.visible = false if pause_menu: pause_menu.visible = true func _on_unstuck_pressed(): """Teleport the local player to a safe spawn position when stuck.""" if _unstuck_cooldown_remaining > 0.0: print("[Unstuck] On cooldown: %.0fs remaining" % _unstuck_cooldown_remaining) return var enhanced_gridmap = $EnhancedGridMap if not enhanced_gridmap: print("[Unstuck] No gridmap found") return # Find the local player var local_player = null var all_players = get_tree().get_nodes_in_group("Players") for player in all_players: if player.is_multiplayer_authority(): local_player = player break if not local_player: print("[Unstuck] No local player found") return # Find a safe spawn position using the spawn zone system var safe_pos = _find_safe_spawn_position(enhanced_gridmap, local_player) if safe_pos == Vector2i(-1, -1): print("[Unstuck] Failed to find safe position") return # Teleport the player to the safe position local_player.current_position = safe_pos local_player.position = local_player.grid_to_world(safe_pos) local_player.is_player_moving = false # Sync the new position to all clients if local_player.has_method("rpc"): local_player.rpc("sync_position", safe_pos) print("[Unstuck] Teleported player to safe position: %s" % safe_pos) # Start cooldown _unstuck_cooldown_remaining = UNSTUCK_COOLDOWN var unstuck_btn = get_node_or_null("PauseMenu/Panel/VBox/UnstuckBtn") if unstuck_btn: unstuck_btn.disabled = true # Close the pause menu var pause_menu = get_node_or_null("PauseMenu") if pause_menu: pause_menu.visible = false func _find_safe_spawn_position(gridmap: Node, player: Node) -> Vector2i: """Find a safe spawn position using the existing spawn zone system. Prioritizes player zones but will fall back to any walkable position.""" # Get spawn zones with perimeter buffer var spawn_zones = _get_spawn_zones(gridmap) var player_zones = _get_player_spawn_zones(spawn_zones) # Collect valid positions from player zones var valid_positions = [] for zone in player_zones: for x in range(zone.position.x, zone.position.x + zone.size.x): for z in range(zone.position.y, zone.position.y + zone.size.y): var cell = Vector3i(x, 0, z) if gridmap.get_cell_item(cell) == 0: # Walkable floor # Check for obstacles on layer 1 var layer1_item = gridmap.get_cell_item(Vector3i(x, 1, z)) if layer1_item == -1 or layer1_item in [7, 8, 9, 10, 11, 12, 13, 14]: # Empty or pickable tiles # Check if position is not occupied by another player var occupied = false var all_players = get_tree().get_nodes_in_group("Players") for p in all_players: if p != player and p.current_position == Vector2i(x, z): occupied = true break if not occupied: valid_positions.append(Vector2i(x, z)) # If we found valid positions, pick a random one if valid_positions.size() > 0: valid_positions.shuffle() return valid_positions[0] # Fallback: Search the entire grid with buffer for ANY walkable position for x in range(PERIMETER_BUFFER, gridmap.columns - PERIMETER_BUFFER): for z in range(PERIMETER_BUFFER, gridmap.rows - PERIMETER_BUFFER): var cell = Vector3i(x, 0, z) if gridmap.get_cell_item(cell) == 0: # Walkable var layer1_item = gridmap.get_cell_item(Vector3i(x, 1, z)) if layer1_item == -1 or layer1_item in [7, 8, 9, 10, 11, 12, 13, 14]: var occupied = false var all_players = get_tree().get_nodes_in_group("Players") for p in all_players: if p != player and p.current_position == Vector2i(x, z): occupied = true break if not occupied: return Vector2i(x, z) # Ultimate fallback: center of the map return Vector2i(gridmap.columns / 2, gridmap.rows / 2) func _on_button_size_changed(value: float): if touch_controls: touch_controls.button_size = value touch_controls._save_settings() func _on_opacity_changed(value: float): if touch_controls: touch_controls.button_opacity = value touch_controls._save_settings() func _on_joystick_toggled(enabled: bool): if touch_controls: touch_controls.set_joystick_enabled(enabled) touch_controls._save_settings() func can_rpc() -> bool: if not check_multiplayer(): return false if LobbyManager.is_lan_mode: return true var nakama = get_node_or_null("/root/NakamaManager") if nakama and nakama.has_method("is_connected_to_nakama"): return nakama.is_connected_to_nakama() return true func check_multiplayer() -> bool: """Safety check for multiplayer peer access.""" if not is_inside_tree(): return false # Accessing multiplayer here is safe because we checked is_inside_tree var peer = multiplayer.multiplayer_peer if not peer: return false if peer.get_connection_status() == MultiplayerPeer.CONNECTION_DISCONNECTED: return false return true @rpc("authority", "call_local", "reliable") func display_message(message: String, type: int = 0): """Broadcasts a message to the local player's UI. This is called via main.rpc from various managers.""" # Find local player var all_players = get_tree().get_nodes_in_group("Players") for player in all_players: # Check if this player is controlled by THIS client if player.is_multiplayer_authority(): if player.has_method("display_message"): player.display_message(message, type) break