extends Node class_name StopNGoManager # StopNGoManager - Handles phase transitions, missions, and movement penalties signal phase_changed(new_phase: String, remaining_time: float) signal mission_status_updated(player_id: int, completed: bool) signal player_penalized(player_id: int) enum Phase {GO, STOP} const GO_DURATION: float = 8.0 const STOP_DURATION: float = 4.0 const REQUIRED_GOALS: int = 8 # Dynamic Safe Zone const SAFE_ZONE_PRE_TIME: float = 5.0 # Seconds before STOP to spawn safe zone const SAFE_ZONE_RADIUS: int = 2 # 5x5 area (radius 2 from center) var safe_zone_center: Vector2i = Vector2i(-1, -1) var safe_zone_spawned: bool = false # Power-Up Tile Spawning const POWERUP_TILES = [11, 14] # Speed (11) and Ghost (14) const POWERUP_SPAWN_COUNT: int = 5 # Number of power-up tiles to spawn var current_phase: Phase = Phase.GO var phase_timer: float = GO_DURATION var is_active: bool = false var player_missions: Dictionary = {} # player_id -> {target_tile: int, required: int, current: int} var finish_line_x: int = 21 # Right side of the map for win condition # Tile IDs const TILE_WALKABLE = 0 const TILE_START = 3 # Start Line const TILE_FINISH = 3 # Finish Line const TILE_SAFE = 2 # Green Safe Zone const TILE_OBSTACLE = 4 # Wall var hud_layer: CanvasLayer var mission_label: Label var red_tint_overlay: ColorRect # Traffic Light / StopTimer Visuals var stop_timer_node: PanelContainer var stop_segments: Array[Panel] = [] var lit_style: StyleBoxFlat var dim_style: StyleBoxFlat var red_style: StyleBoxFlat func _ready(): set_process(false) _setup_hud() func _setup_hud(): hud_layer = CanvasLayer.new() hud_layer.layer = 5 # Ensure it's above normal UI but below Pause Menu (10) hud_layer.visible = false add_child(hud_layer) # Full-screen red tint overlay (below everything else in this layer, but above game) red_tint_overlay = ColorRect.new() red_tint_overlay.color = Color(1.0, 0.0, 0.0, 0.25) # Transparent red red_tint_overlay.set_anchors_preset(Control.PRESET_FULL_RECT) # Cover whole screen red_tint_overlay.visible = false # Hidden initially hud_layer.add_child(red_tint_overlay) # New container for bottom-mid label var bottom_container = CenterContainer.new() bottom_container.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) bottom_container.grow_horizontal = Control.GROW_DIRECTION_BOTH bottom_container.grow_vertical = Control.GROW_DIRECTION_BEGIN bottom_container.offset_bottom = -50 hud_layer.add_child(bottom_container) var custom_font = load("res://assets/fonts/Nougat-ExtraBlack.ttf") mission_label = Label.new() mission_label.text = "MISSION: Collect Goals" mission_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER if custom_font: mission_label.add_theme_font_override("font", custom_font) mission_label.add_theme_font_size_override("font_size", 28) # Slightly larger since it's centered mission_label.add_theme_color_override("font_outline_color", Color.BLACK) mission_label.add_theme_constant_override("outline_size", 8) bottom_container.add_child(mission_label) func _process(delta): if not is_active: return # Decrement timer locally for all peers (smoother HUD than waiting for RPC) phase_timer -= delta if multiplayer.is_server(): # Spawn safe zone 5 seconds before STOP phase begins if current_phase == Phase.GO and not safe_zone_spawned and phase_timer <= SAFE_ZONE_PRE_TIME: _spawn_safe_zone() if phase_timer <= 0: if current_phase == Phase.GO: _start_phase(Phase.STOP) else: _start_phase(Phase.GO) # Update HUD locally _update_hud_visuals() func _on_goal_count_updated(_peer_id: int, _count: int): # Refresh visuals whenever points change _update_hud_visuals() var _has_notified_mission_complete: bool = false func _update_hud_visuals(): # Toggle Red Screen Tint if red_tint_overlay: red_tint_overlay.visible = (current_phase == Phase.STOP) var my_id = multiplayer.get_unique_id() if mission_label: var main = get_node_or_null("/root/Main") var goals_cycle_manager = main.get_node_or_null("GoalsCycleManager") if main else null # Get count from GoalsCycleManager (Source of truth for PlayerBoardLabel) var completed_count = goals_cycle_manager.player_goal_counts.get(my_id, 0) if goals_cycle_manager else 0 mission_label.text = "GOALS (%d/%d)" % [completed_count, REQUIRED_GOALS] if completed_count >= REQUIRED_GOALS: mission_label.text = "ALL GOALS COMPLETE!\nREACH THE FINISH!" mission_label.add_theme_color_override("font_color", Color.GOLD) # Notify player once if my_id == multiplayer.get_unique_id() and not _has_notified_mission_complete: _has_notified_mission_complete = true var player_node = main.get_node_or_null(str(my_id)) if player_node: NotificationManager.send_message(player_node, "ALL GOALS COMPLETE!", NotificationManager.MessageType.GOAL) else: mission_label.add_theme_color_override("font_color", Color.WHITE) _has_notified_mission_complete = false # Update StopTimer (Traffic Light) _update_stop_timer_visuals() func _update_stop_timer_visuals(): if not stop_timer_node: # Try to find it once var main = get_node_or_null("/root/Main") if main: stop_timer_node = main.get_node_or_null("StopTimer") if stop_timer_node: var hbox = stop_timer_node.get_node_or_null("HBox") if hbox: stop_segments.clear() for i in range(3): var seg = hbox.get_node_or_null("Segment%d" % i) if seg: stop_segments.append(seg) # Prepare styles lit_style = StyleBoxFlat.new() lit_style.bg_color = Color.YELLOW lit_style.border_width_left = 2 lit_style.border_width_top = 2 lit_style.border_width_right = 2 lit_style.border_width_bottom = 2 lit_style.border_color = Color(1.0, 1.0, 1.0, 0.5) dim_style = StyleBoxFlat.new() dim_style.bg_color = Color(0.1, 0.1, 0.1, 0.8) # Dark dim red_style = StyleBoxFlat.new() red_style.bg_color = Color.RED red_style.border_width_left = 2 red_style.border_width_top = 2 red_style.border_width_right = 2 red_style.border_width_bottom = 2 red_style.border_color = Color(1.0, 0.5, 0.5, 0.5) if not stop_timer_node: return # ALWAYS VISIBLE in Stop n Go mode stop_timer_node.visible = true if current_phase == Phase.GO: # GO Phase: All dim unless in last 3 seconds for i in range(stop_segments.size()): var threshold = 3.0 - i if phase_timer <= threshold: stop_segments[i].add_theme_stylebox_override("panel", lit_style) else: stop_segments[i].add_theme_stylebox_override("panel", dim_style) else: # STOP Phase: All Red for seg in stop_segments: seg.add_theme_stylebox_override("panel", red_style) func activate_client_side(): is_active = true if hud_layer: hud_layer.visible = true # Connect to GoalsCycleManager for immediate HUD updates var main = get_node_or_null("/root/Main") if main: var gcm = main.get_node_or_null("GoalsCycleManager") if gcm and not gcm.goal_count_updated.is_connected(_on_goal_count_updated): gcm.goal_count_updated.connect(_on_goal_count_updated) set_process(true) func start_game_mode(): # This should primarily be called by the Server # Clients get activated via RPCs (sync_phase, etc) if multiplayer.is_server(): activate_client_side() # Server also needs local processing # _setup_arena() # REMOVED: Already explicitly called in main.gd _setup_host_game to prepare floor before spawns! _assign_missions() _start_phase(Phase.GO) else: # Clients just wait for updates, but can enable HUD if needed # activate_client_side() can be called when first sync arrives pass func _start_phase(phase: Phase): current_phase = phase phase_timer = GO_DURATION if phase == Phase.GO else STOP_DURATION var phase_name = "GO" if phase == Phase.GO else "STOP" if can_rpc(): rpc("sync_phase", phase_name, phase_timer) if phase == Phase.STOP: # --- DYNAMIC SAFE ZONE: Penalize players outside the zone --- if safe_zone_spawned: var all_players = get_tree().get_nodes_in_group("Players") for p in all_players: if not _is_in_safe_zone(p.current_position): _scatter_player_tiles(p) # --- POWER-UP TILES: Spawn 5 Speed & Ghost tiles --- _spawn_powerup_tiles() # If GO phase starts, clear all STOP phase freezes and safe zone if phase == Phase.GO: _clear_safe_zone() var all_players = get_tree().get_nodes_in_group("Players") for p in all_players: if p.has_method("sync_stop_freeze"): p.rpc("sync_stop_freeze", false) emit_signal("phase_changed", phase_name, phase_timer) func can_rpc() -> bool: if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED: return false return true @rpc("authority", "call_local", "reliable") func sync_phase(phase_name: String, duration: float): if not is_active: activate_client_side() current_phase = Phase.GO if phase_name == "GO" else Phase.STOP phase_timer = duration func _setup_arena(): var gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: return print("[StopNGo] Setting up 22x10 Arena with Randomized Obstacles...") # Explicitly sync dimensions and clear grid on all clients if can_rpc(): rpc("sync_arena_setup") # Apply locally for Server (RPC is call_remote) _apply_arena_setup() @rpc("authority", "call_remote", "reliable") func sync_arena_setup(): print("[StopNGo] Client: Syncing Arena Setup (22x10)...") _apply_arena_setup() func _apply_arena_setup(): # Shared logic for resizing and clearing var gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: # Fallback just in case gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: return # Set Size for Stop n Go explicitly, bypassing setters that wipe the map gridmap.set("columns", 22) gridmap.set("rows", 10) # Clear existing items on all layers gridmap.clear() # Dynamic Safe Zone: No static safe columns anymore # Safe zone spawns randomly during gameplay # Create bands based on X (Horizontal Progress) for x in range(gridmap.columns): var tile_id = TILE_WALKABLE if x == 0: tile_id = TILE_START elif x == gridmap.columns - 1: tile_id = TILE_FINISH for z in range(gridmap.rows): gridmap.set_cell_item(Vector3i(x, 0, z), tile_id) # Note: Specific obstacles removed as per user request to replace with random ones. # MISSION TILES: Moved to start_game_mode() to ensure they spawn AFTER walls. gridmap.update_grid_data() gridmap.initialize_astar() func setup_mission_tiles(): """Public wrapper to trigger mission tile spawning before game start.""" if multiplayer.is_server(): _spawn_mission_tiles() func _spawn_mission_tiles(): var gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: return # Forbidden Zones (Start, Finish) - No items here var forbidden_x = [0, 21] # Goal items: Heart(7), Diamond(8), Star(9), Coin(10) var goal_items = [7, 8, 9, 10] for x in range(gridmap.columns): if x in forbidden_x: continue # Clear zone for z in range(gridmap.rows): # Ensure we don't spawn on obstacles var base_tile = gridmap.get_cell_item(Vector3i(x, 0, z)) var current_item = gridmap.get_cell_item(Vector3i(x, 1, z)) # PROTECTED FLOOR CHECK: Don't spawn on walls or void if base_tile in [TILE_OBSTACLE, -1] or current_item == TILE_OBSTACLE or current_item == 13: continue # Spawn tiles with 60% density if randf() > 0.6: gridmap.set_cell_item(Vector3i(x, 1, z), -1) continue var tile_type = goal_items[randi() % goal_items.size()] gridmap.set_cell_item(Vector3i(x, 1, z), tile_type) # Sync to clients var main = get_node("/root/Main") if main: main.rpc("sync_grid_item", x, 1, z, tile_type) func _assign_missions(): # NO-OP: Missions are now achievement-based (Complete 3 Goals) # which is tracked natively by GoalsCycleManager. pass @rpc("authority", "call_local", "reliable") func sync_missions(missions: Dictionary): player_missions = missions func check_movement_violation(player_id: int, from: Vector2i, to: Vector2i) -> bool: """Check if movement is illegal (during STOP phase and not in safe zone).""" if current_phase == Phase.STOP: # Use dynamic safe zone position check instead of static tile check if not _is_in_safe_zone(from): var gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if gridmap: var tile_from = gridmap.get_cell_item(Vector3i(from.x, 0, from.y)) if tile_from != TILE_START and tile_from != TILE_FINISH: _penalize_player(player_id) return true return false func _penalize_player(player_id: int): if not multiplayer.is_server(): return var main = get_node("/root/Main") if not main: return var player_node = main.get_node_or_null(str(player_id)) if player_node: # We now use the 'on_stop_phase_violation' RPC which scatters tiles around the player # like they got attacked, instead of just dropping them in one pile. if can_rpc(): player_node.rpc("on_stop_phase_violation") # Notification is also handled inside on_stop_phase_violation on the player node emit_signal("player_penalized", player_id) func update_mission_progress(_player_id: int, _tile_id: int): # Redundant in Board-based mode, but kept for compatibility. # The board is synced separately via sync_playerboard in playerboard_manager.gd. pass @rpc("any_peer", "call_local", "reliable") func sync_mission_progress(_player_id: int, _mission_index: int, _current: int): # Deprecated pass func is_mission_complete(player_id: int) -> bool: var main = get_node_or_null("/root/Main") if not main: return false var goals_cycle_manager = main.get_node_or_null("GoalsCycleManager") if not goals_cycle_manager: return false var completed_count = goals_cycle_manager.player_goal_counts.get(player_id, 0) return completed_count >= REQUIRED_GOALS func check_win_condition(player_id: int, position: Vector2i) -> bool: # 1. Must reach the finish line (Column 21) if position.x < finish_line_x: return false # 2. Must have enough Goal Completions if is_mission_complete(player_id): print("[StopNGo] Player %d REACHED FINISH with goals complete!" % player_id) return true else: # Inform the player locally if they reach the end without goals var main = get_node_or_null("/root/Main") var player_node = main.get_node_or_null(str(player_id)) if main else null if player_node: NotificationManager.send_message(player_node, "Incomplete! Achieve %d goals to win!" % REQUIRED_GOALS, NotificationManager.MessageType.WARNING) print("[StopNGo] Player %d reached finish but goals incomplete." % player_id) return false # ============================================================================= # Dynamic Safe Zone # ============================================================================= func _spawn_safe_zone(): """Server: Pick a random walkable position and spawn the safe zone.""" if not multiplayer.is_server(): return var gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: return # Collect valid center positions (not too close to edges so the zone fits) var valid_positions: Array[Vector2i] = [] for x in range(SAFE_ZONE_RADIUS, gridmap.columns - SAFE_ZONE_RADIUS): for z in range(SAFE_ZONE_RADIUS, gridmap.rows - SAFE_ZONE_RADIUS): var tile = gridmap.get_cell_item(Vector3i(x, 0, z)) # Only walkable tiles (not start/finish) if tile == TILE_WALKABLE: valid_positions.append(Vector2i(x, z)) if valid_positions.is_empty(): print("[StopNGo] WARNING: No valid position for safe zone!") return # Pick random center var rng = RandomNumberGenerator.new() rng.randomize() safe_zone_center = valid_positions[rng.randi() % valid_positions.size()] safe_zone_spawned = true print("[StopNGo] Safe Zone spawned at %s (radius %d)" % [safe_zone_center, SAFE_ZONE_RADIUS]) # Sync to all peers if can_rpc(): rpc("sync_safe_zone", safe_zone_center, SAFE_ZONE_RADIUS) func _clear_safe_zone(): """Server: Clear the safe zone overlay and reset state.""" if not multiplayer.is_server(): return if safe_zone_spawned: safe_zone_spawned = false safe_zone_center = Vector2i(-1, -1) if can_rpc(): rpc("sync_clear_safe_zone") func _is_in_safe_zone(pos: Vector2i) -> bool: """Check if a position is within the dynamic safe zone.""" if not safe_zone_spawned or safe_zone_center == Vector2i(-1, -1): return false # Chebyshev distance (square area) var dx = abs(pos.x - safe_zone_center.x) var dz = abs(pos.y - safe_zone_center.y) return dx <= SAFE_ZONE_RADIUS and dz <= SAFE_ZONE_RADIUS func _scatter_player_tiles(player_node: Node): """Server: Take all tiles from player's playerboard and scatter them onto nearby grid cells.""" if not multiplayer.is_server(): return var main = get_node_or_null("/root/Main") if not main: return var gridmap = main.get_node_or_null("EnhancedGridMap") if not gridmap: return var peer_id = player_node.name.to_int() var playerboard = player_node.playerboard var tiles_to_scatter: Array[int] = [] # Collect all non-empty tiles from playerboard for i in range(playerboard.size()): if playerboard[i] != -1: tiles_to_scatter.append(playerboard[i]) playerboard[i] = -1 if tiles_to_scatter.is_empty(): return # Nothing to scatter # Find valid nearby positions to drop tiles (within radius 3 of player) var center = player_node.current_position var valid_drop_positions: Array[Vector2i] = [] for dx in range(-3, 4): for dz in range(-3, 4): var pos = Vector2i(center.x + dx, center.y + dz) # Bounds check if pos.x < 0 or pos.x >= gridmap.columns or pos.y < 0 or pos.y >= gridmap.rows: continue # Check floor is walkable (not void, not obstacle) var floor_tile = gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y)) if floor_tile == -1 or floor_tile == TILE_OBSTACLE: continue # Check floor 1 is empty (no existing item) var existing_item = gridmap.get_cell_item(Vector3i(pos.x, 1, pos.y)) if existing_item != -1: continue valid_drop_positions.append(pos) # Scatter tiles onto valid positions var rng = RandomNumberGenerator.new() rng.randomize() for tile in tiles_to_scatter: if valid_drop_positions.is_empty(): break # No more space var idx = rng.randi() % valid_drop_positions.size() var drop_pos = valid_drop_positions[idx] valid_drop_positions.remove_at(idx) # Place tile on grid Floor 1 gridmap.set_cell_item(Vector3i(drop_pos.x, 1, drop_pos.y), tile) # Sync to all clients main.rpc("sync_grid_item", drop_pos.x, 1, drop_pos.y, tile) # Sync cleared playerboard to all clients main.rpc("sync_playerboard", peer_id, playerboard) # Notify the player NotificationManager.send_message(player_node, "Not in Safe Zone! Tiles scattered!", NotificationManager.MessageType.WARNING) # Screen shake if player_node.has_method("trigger_screen_shake") and can_rpc(): player_node.rpc("trigger_screen_shake", "heavy") print("[StopNGo] Scattered %d tiles from Player %d" % [tiles_to_scatter.size(), peer_id]) @rpc("authority", "call_local", "reliable") func sync_safe_zone(center: Vector2i, radius: int): """Client: Show the safe zone overlay on the grid.""" safe_zone_center = center safe_zone_spawned = true var gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: return # Paint safe zone on Floor 2 (overlay layer) with TILE_SAFE visual for dx in range(-radius, radius + 1): for dz in range(-radius, radius + 1): var x = center.x + dx var z = center.y + dz if x >= 0 and x < gridmap.columns and z >= 0 and z < gridmap.rows: gridmap.set_cell_item(Vector3i(x, 2, z), TILE_SAFE) # Notify local player var my_id = multiplayer.get_unique_id() var main = get_node_or_null("/root/Main") var player_node = main.get_node_or_null(str(my_id)) if main else null if player_node: NotificationManager.send_message(player_node, "⚠ Safe Zone spawned! Get inside!", NotificationManager.MessageType.WARNING) @rpc("authority", "call_local", "reliable") func sync_clear_safe_zone(): """Client: Clear the safe zone overlay.""" var gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: return if safe_zone_center != Vector2i(-1, -1): for dx in range(-SAFE_ZONE_RADIUS, SAFE_ZONE_RADIUS + 1): for dz in range(-SAFE_ZONE_RADIUS, SAFE_ZONE_RADIUS + 1): var x = safe_zone_center.x + dx var z = safe_zone_center.y + dz if x >= 0 and x < gridmap.columns and z >= 0 and z < gridmap.rows: gridmap.set_cell_item(Vector3i(x, 2, z), -1) safe_zone_center = Vector2i(-1, -1) safe_zone_spawned = false # ============================================================================= # Power-Up Tile Spawning (Speed & Ghost) # ============================================================================= func _spawn_powerup_tiles(): """Server: Spawn 5 Speed & Ghost power-up tiles at random walkable positions.""" if not multiplayer.is_server(): return var main = get_node_or_null("/root/Main") if not main: return var gridmap = main.get_node_or_null("EnhancedGridMap") if not gridmap: return # Collect valid positions (walkable floor, no existing item on Floor 1) var valid_positions: Array[Vector2i] = [] for x in range(gridmap.columns): for z in range(gridmap.rows): var floor_tile = gridmap.get_cell_item(Vector3i(x, 0, z)) # Skip void, obstacles, start, finish if floor_tile == -1 or floor_tile == TILE_OBSTACLE: continue # Skip cells that already have items on Floor 1 var existing_item = gridmap.get_cell_item(Vector3i(x, 1, z)) if existing_item != -1: continue valid_positions.append(Vector2i(x, z)) if valid_positions.is_empty(): print("[StopNGo] WARNING: No valid positions for power-up tiles!") return # Shuffle and pick up to POWERUP_SPAWN_COUNT positions var rng = RandomNumberGenerator.new() rng.randomize() valid_positions.shuffle() var spawn_count = min(POWERUP_SPAWN_COUNT, valid_positions.size()) for i in range(spawn_count): var pos = valid_positions[i] var tile_id = POWERUP_TILES[rng.randi() % POWERUP_TILES.size()] # Place on Floor 1 gridmap.set_cell_item(Vector3i(pos.x, 1, pos.y), tile_id) # Sync to all clients if can_rpc(): main.rpc("sync_grid_item", pos.x, 1, pos.y, tile_id) print("[StopNGo] Spawned %d power-up tiles (Speed & Ghost)" % spawn_count)