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} # Dynamic Safe Zone var active_safe_zone_rects: Array[Rect2i] = [] var spawned_safe_zones: int = 0 var _safe_zone_animating: bool = false var _outline_nodes: Array[Node3D] = [] # Track perimeter outline containers for cleanup var _cached_safe_zone_mesh: Mesh = null # Power-Up Tile Spawning const POWERUP_TILES = [11, 14] # Speed, Ghost (Freeze and Wall excluded in this mode) const POWERUP_SPAWN_COUNT: int = 5 # Number of power-up tiles to spawn var powerups_spawned: bool = false var stop_phase_occurred: bool = false # Safe zone walls removed for fully open dynamic zones const PERMANENT_POWERUP_LOCATIONS: Array[Vector2i] = [ Vector2i(4, 1), # Power up 1 Vector2i(3, 9), # Power up 2 Vector2i(9, 6), # Power up 3 Vector2i(16, 2), # Power up 4 Vector2i(18, 8) # Power up 5 ] var current_phase: Phase = Phase.GO var phase_timer: float = 20.0 # Initialized dynamically later var is_active: bool = false var player_missions: Dictionary = {} # player_id -> {target_tile: int, required: int, current: int} var finish_line_x: int = 22 # 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 const TILE_LIGHTNING_STONE = 15 # Ancient Rock with Lightning Symbol const TILE_SAFE_WALL = 16 # Safe Zone 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 empty_styles: Array[StyleBoxTexture] = [] var filled_styles: Array[StyleBoxTexture] = [] 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) # 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 if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer == null: return # Decrement timer locally for all peers (smoother HUD than waiting for RPC) phase_timer -= delta if multiplayer.is_server(): if current_phase == Phase.GO: # Spawn all 3 safe zones at once, 6 seconds before STOP if phase_timer <= 6.0 and spawned_safe_zones == 0: print("[StopNGo] GO phase ending soon. Spawning 3 safe zones...") for i in range(3): _spawn_dynamic_safe_zone() # Trigger global VFX and outline drawing on all clients if can_rpc(): rpc("sync_all_safe_zones_vfx") else: sync_all_safe_zones_vfx() if phase_timer <= 0: if current_phase == Phase.GO: print("[StopNGo] GO Timer reached 0. Starting STOP Phase.") _start_phase(Phase.STOP) else: print("[StopNGo] STOP Timer reached 0. Starting GO Phase.") _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(): 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 var required_goals = LobbyManager.sng_required_goals 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) # Play looping "GoFinish" animation to signals the Go phase for this player if main and main.get("vfx_manager"): if main.vfx_manager.has_method("play_go_finish_animation"): main.vfx_manager.play_go_finish_animation() elif main.vfx_manager.has_method("play_go_animation"): main.vfx_manager.play_go_animation() 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() empty_styles.clear() filled_styles.clear() for i in range(3): var seg = hbox.get_node_or_null("Segment%d" % i) if seg: stop_segments.append(seg) var e_style = StyleBoxTexture.new() e_style.texture = load("res://assets/graphics/gui/stop_timer/Segment%d_empty.png" % i) empty_styles.append(e_style) var f_style = StyleBoxTexture.new() f_style.texture = load("res://assets/graphics/gui/stop_timer/Segment%d_filled.png" % i) filled_styles.append(f_style) 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 empty unless in last 3 seconds (lights up 3, 2, 1s) for i in range(stop_segments.size()): var threshold = 3.0 - (i * 1.0) if phase_timer <= threshold: stop_segments[i].add_theme_stylebox_override("panel", filled_styles[i]) else: stop_segments[i].add_theme_stylebox_override("panel", empty_styles[i]) else: # STOP Phase: All Red for i in range(stop_segments.size()): stop_segments[i].add_theme_stylebox_override("panel", filled_styles[i]) 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 rotate_players_to_start(): """Force all players to face East (towards finish line). Called before countdown.""" if not multiplayer.is_server(): return var all_players = get_tree().get_nodes_in_group("Players") for p in all_players: if p.has_method("rpc"): p.rpc("sync_rotation", PI/2) func _start_phase(phase: Phase): current_phase = phase phase_timer = float(LobbyManager.sng_go_duration) if phase == Phase.GO else float(LobbyManager.sng_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: stop_phase_occurred = true # --- STATIC SAFE ZONE: Penalize players outside the zone --- var all_players = get_tree().get_nodes_in_group("Players") for p in all_players: # POSITION SYNC FIX: If player is currently moving on their client, # the server's 'current_position' might still be the old one. # Use 'target_position' if they are moving. var check_pos = p.current_position if p.get("is_player_moving") and p.get("target_position") != Vector2i(-1, -1): check_pos = p.target_position if not _is_in_safe_zone(check_pos): _scatter_player_tiles(p) # Refresh power-ups every STOP phase _spawn_powerup_tiles() # If GO phase starts, clear all STOP phase freezes and dynamic safe zones if phase == Phase.GO: _clear_dynamic_safe_zones() 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 # Trigger Phase Animations var main = get_node_or_null("/root/Main") if main and main.get("vfx_manager"): if phase_name == "STOP": if main.vfx_manager.has_method("play_stop_phase"): main.vfx_manager.play_stop_phase() elif main.vfx_manager.get("animation_player"): main.vfx_manager.animation_player.play("stop-phase") 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 (23x12)...") _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", 23) gridmap.set("rows", 12) # Clear existing items on all layers gridmap.clear() # Dynamic Safe Zone: No static safe columns anymore # Safe zone spawns randomly during gameplay var non_walkable_coords = [ Vector2i(0,0), Vector2i(1,0), Vector2i(2,0), Vector2i(3,0), Vector2i(4,0), Vector2i(5,0), Vector2i(6,0), Vector2i(7,0), Vector2i(8,0), Vector2i(9,0), Vector2i(10,0), Vector2i(13,0), Vector2i(19,0), Vector2i(20,0), Vector2i(21,0), Vector2i(22,0), Vector2i(0,1), Vector2i(1,1), Vector2i(2,1), Vector2i(3,1), Vector2i(6,1), Vector2i(0,2), Vector2i(1,2), Vector2i(2,2), Vector2i(3,2), Vector2i(17,9), Vector2i(18,9), Vector2i(19,9), Vector2i(20,9), Vector2i(21,9), Vector2i(22,9), Vector2i(11,10), Vector2i(12,10), Vector2i(13,10), Vector2i(15,10), Vector2i(16,10), Vector2i(17,10), Vector2i(18,10), Vector2i(19,10), Vector2i(20,10), Vector2i(21,10), Vector2i(22,10), Vector2i(0,11), Vector2i(4,11), Vector2i(5,11), Vector2i(6,11), Vector2i(9,11), Vector2i(10,11), Vector2i(11,11), Vector2i(12,11), Vector2i(13,11), Vector2i(14,11), Vector2i(15,11), Vector2i(16,11), Vector2i(17,11), Vector2i(18,11), Vector2i(19,11), Vector2i(20,11), Vector2i(21,11), Vector2i(22,11), # Fixed User Obstacles Below: Vector2i(2,8), # Cactus 1 Vector2i(4,4), # Cactus 2 Vector2i(12,6), # Cactus 3 Vector2i(12,9), # Cactus 4 Vector2i(5,9), Vector2i(5,10), # Wall rock 1 Vector2i(10,1), Vector2i(10,2), Vector2i(11,1), # Wall rock 2 Vector2i(17,6), # Wall rock 3 Vector2i(7,4), Vector2i(7,5), # Tree 1 Vector2i(10,4), # Tree 2 Vector2i(13,3), Vector2i(14,3), Vector2i(14,4), # Tree 3 Vector2i(20,4), Vector2i(20,5), # Tree 4 Vector2i(9,8), # Statue 1 Vector2i(17,3) # Statue 2 ] # Create bands based on X (Horizontal Progress) for x in range(gridmap.columns): for z in range(gridmap.rows): var current_pos = Vector2i(x, z) if current_pos in non_walkable_coords: gridmap.set_cell_item(Vector3i(x, 0, z), TILE_OBSTACLE) # wall block on Floor 0 gridmap.set_cell_item(Vector3i(x, 1, z), -1) continue var tile_id = TILE_WALKABLE if x == 0: tile_id = TILE_START elif x == gridmap.columns - 1: tile_id = TILE_FINISH gridmap.set_cell_item(Vector3i(x, 0, z), tile_id) gridmap.set_cell_item(Vector3i(x, 1, z), -1) # Create rooms (Option B: hollow inside, walls around edges) # Room 1: (7,6) to (11,9) - 5x4 Area. Boundaries: X[7..11], Z[6..9] var r1_entrances = [Vector2i(7, 8), Vector2i(9, 6), Vector2i(9, 9), Vector2i(11, 8)] _create_room_with_edge_walls(gridmap, 7, 6, 11, 9, r1_entrances) # Non-walkable obstacle inside Room 1 gridmap.set_cell_item(Vector3i(9, 0, 8), TILE_OBSTACLE) # Room 2: (15,1) to (19,5) - 5x5 Area. Boundaries: X[15..19], Z[1..5] var r2_entrances = [Vector2i(15, 2), Vector2i(17, 1), Vector2i(19, 2), Vector2i(18, 5)] _create_room_with_edge_walls(gridmap, 15, 1, 19, 5, r2_entrances) gridmap.diagonal_movement = true gridmap.update_grid_data() gridmap.initialize_astar() func _create_room_with_edge_walls(gridmap: GridMap, x_start: int, z_start: int, x_end: int, z_end: int, entrances: Array): for x in range(x_start, x_end + 1): for z in range(z_start, z_end + 1): var is_north = (z == z_start) var is_south = (z == z_end) var is_west = (x == x_start) var is_east = (x == x_end) if is_north or is_south or is_west or is_east: if not Vector2i(x, z) in entrances: # Determine placement SIDE and rotation if is_north: _instantiate_safe_zone_wall(gridmap, x, z, "NORTH") if is_south: _instantiate_safe_zone_wall(gridmap, x, z, "SOUTH") if is_west: _instantiate_safe_zone_wall(gridmap, x, z, "WEST") if is_east: _instantiate_safe_zone_wall(gridmap, x, z, "EAST") # Ensure ground is floor var current_f0 = gridmap.get_cell_item(Vector3i(x, 0, z)) if current_f0 == -1: gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE) func _instantiate_safe_zone_wall(gridmap: GridMap, x: int, z: int, side: String): var wall_scene = load("res://scenes/safe_zone_wall.tscn") if not wall_scene: return var wall = wall_scene.instantiate() gridmap.add_child(wall) # Slightly shorten the wall (from 1.0 to 0.95) to allow diagonal raycasts to pass corners wall.scale.x = 0.95 var pos = Vector3( x * gridmap.cell_size.x + gridmap.cell_size.x/2, 0.5, z * gridmap.cell_size.z + gridmap.cell_size.z/2 ) # Adjust position to the LINE between tiles based on side match side: "NORTH": pos.z = z * gridmap.cell_size.z # Top edge of cell wall.rotation_degrees.y = 0 "SOUTH": pos.z = (z + 1) * gridmap.cell_size.z # Bottom edge wall.rotation_degrees.y = 0 "WEST": pos.x = x * gridmap.cell_size.x # Left edge wall.rotation_degrees.y = 90 "EAST": pos.x = (x + 1) * gridmap.cell_size.x # Right edge wall.rotation_degrees.y = 90 wall.add_to_group("SafeZoneWalls") wall.global_position = pos func setup_mission_tiles(): """Public wrapper to trigger mission tile spawning before game start.""" if multiplayer.is_server(): _spawn_mission_tiles() func spawn_initial_powerups(): """Public wrapper to spawn powerups before game start.""" if multiplayer.is_server(): _spawn_powerup_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, gridmap.columns - 1] # 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, void, or static powerup pods if base_tile in [TILE_OBSTACLE, -1, TILE_LIGHTNING_STONE] or current_item == TILE_OBSTACLE: 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 >= LobbyManager.sng_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!" % LobbyManager.sng_required_goals, NotificationManager.MessageType.WARNING) print("[StopNGo] Player %d reached finish but goals incomplete." % player_id) return false # ============================================================================= # Static Safe Zone # ============================================================================= func _is_in_safe_zone(pos: Vector2i) -> bool: var gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: return false # 1. Check Floor 0 for Start (3) or Finish (3) lines var floor_tile = gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y)) if floor_tile == TILE_START or floor_tile == TILE_FINISH: return true # 2. Check Layer 2 (Overlay layer) for Dynamic Safe Zone tile var overlay_tile = gridmap.get_cell_item(Vector3i(pos.x, 2, pos.y)) return overlay_tile == TILE_SAFE func _spawn_dynamic_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 var main = get_node("/root/Main") var possible_rects = [] # Check all possible 3x2 and 2x3 areas for x in range(1, gridmap.columns - 3): for z in range(1, gridmap.rows - 2): if _is_valid_safe_zone_area(gridmap, x, z, 3, 2): possible_rects.append(Rect2i(x, z, 3, 2)) if _is_valid_safe_zone_area(gridmap, x, z, 2, 3): possible_rects.append(Rect2i(x, z, 2, 3)) if possible_rects.size() > 0: var rect = possible_rects.pick_random() active_safe_zone_rects.append(rect) spawned_safe_zones += 1 print("[StopNGo] Spawning Safe Zone %d at %s" % [spawned_safe_zones, rect]) # Paint Floor 2 (Overlay layer) to TILE_SAFE # This allows seeing items below on Floor 1 for rx in range(rect.size.x): for rz in range(rect.size.y): var px = rect.position.x + rx var pz = rect.position.y + rz gridmap.set_cell_item(Vector3i(px, 2, pz), TILE_SAFE) if can_rpc() and main: main.rpc("sync_grid_item", px, 2, pz, TILE_SAFE) @rpc("authority", "call_local", "reliable") func sync_all_safe_zones_vfx(): var main = get_node_or_null("/root/Main") if main and main.get("vfx_manager"): if main.vfx_manager.has_method("play_safe_zone_appear"): main.vfx_manager.play_safe_zone_appear() elif main.vfx_manager.get("animation_player"): main.vfx_manager.animation_player.play("safe-zone-appear") # Build and animate the CONTINUOUS outer outline for ALL zones combined var outline = _create_merged_safe_zone_outlines() if outline: _outline_nodes.append(outline) _animate_outline_appear(outline) # Animate the safe zone panels appearing (alpha 0 → 0.65) _animate_safe_zone_appear() func _animate_safe_zone_appear(): """Two-phase appear: sharp bright flash then settle to semi-transparent. Guarded so only ONE animation runs even when all 3 zones spawn at once.""" if _safe_zone_animating: return _safe_zone_animating = true var gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap or not gridmap.mesh_library: _safe_zone_animating = false return if not _cached_safe_zone_mesh: _cached_safe_zone_mesh = gridmap.mesh_library.get_item_mesh(TILE_SAFE) var original_mesh = _cached_safe_zone_mesh if not is_instance_valid(original_mesh): _safe_zone_animating = false return var mat = original_mesh.material if not is_instance_valid(mat): _safe_zone_animating = false return # Duplicate mesh+material so we animate without touching the shared .tres on disk. var anim_mat: StandardMaterial3D = mat.duplicate() anim_mat.albedo_color = Color(mat.albedo_color.r, mat.albedo_color.g, mat.albedo_color.b, 0.0) var anim_mesh = original_mesh.duplicate() anim_mesh.material = anim_mat gridmap.mesh_library.set_item_mesh(TILE_SAFE, anim_mesh) const TARGET_ALPHA := 0.65 var tween = create_tween() # Phase 1 — Sharp bright flash: alpha 0 → 1.0 in 0.2s (EXPO ease-out = instant pop) tween.tween_method( func(a: float): if is_instance_valid(anim_mat): anim_mat.albedo_color.a = a, 0.0, 1.0, 0.2 ).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_EXPO) # Phase 2 — Settle: alpha 1.0 → 0.65 in 0.35s (CUBIC ease-in-out = soft land) tween.tween_method( func(a: float): if is_instance_valid(anim_mat): anim_mat.albedo_color.a = a, 1.0, TARGET_ALPHA, 0.35 ).set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_CUBIC) # Done — anim_mesh stays in library at alpha=0.65 (no restore needed) tween.tween_callback(func(): _safe_zone_animating = false ) func _create_merged_safe_zone_outlines() -> Node3D: """Create thin edge BoxMesh strips ONLY on the outer perimeter of the merged safe zones. Inner edges between adjacent safe zones are skipped.""" var gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: return null var cs: Vector3 = gridmap.cell_size # Sit slightly above the safe panel (layer 2, panel top ~= 2*cs.y + 0.05) var y := 2.0 * cs.y + 0.09 const T := 0.09 # outline thickness (world units) const H := 0.045 # outline height # One shared material (transparent, emissive lime-green) — starts invisible for animation var mat := StandardMaterial3D.new() mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.albedo_color = Color(0.15, 1.0, 0.3, 0.0) # start at alpha=0 mat.emission_enabled = true mat.emission = Color(0.1, 0.9, 0.25) mat.emission_energy_multiplier = 1.8 mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.cull_mode = BaseMaterial3D.CULL_DISABLED var container := Node3D.new() container.add_to_group("SafeZoneOutlines") gridmap.add_child(container) var corner_points = {} # Scan layer 2 for boundaries for x in range(gridmap.columns): for z in range(gridmap.rows): if gridmap.get_cell_item(Vector3i(x, 2, z)) != TILE_SAFE: continue var cell_cx = x * cs.x + cs.x * 0.5 var cell_cz = z * cs.z + cs.z * 0.5 # Check North (z - 1) if gridmap.get_cell_item(Vector3i(x, 2, z - 1)) != TILE_SAFE: var mi := MeshInstance3D.new() var box := BoxMesh.new() box.size = Vector3(cs.x, H, T) box.material = mat mi.mesh = box mi.position = Vector3(cell_cx, y, z * cs.z) container.add_child(mi) corner_points[Vector2i(x, z)] = true corner_points[Vector2i(x + 1, z)] = true # Check South (z + 1) if gridmap.get_cell_item(Vector3i(x, 2, z + 1)) != TILE_SAFE: var mi := MeshInstance3D.new() var box := BoxMesh.new() box.size = Vector3(cs.x, H, T) box.material = mat mi.mesh = box mi.position = Vector3(cell_cx, y, z * cs.z + cs.z) container.add_child(mi) corner_points[Vector2i(x, z + 1)] = true corner_points[Vector2i(x + 1, z + 1)] = true # Check West (x - 1) if gridmap.get_cell_item(Vector3i(x - 1, 2, z)) != TILE_SAFE: var mi := MeshInstance3D.new() var box := BoxMesh.new() box.size = Vector3(T, H, cs.z) box.material = mat mi.mesh = box mi.position = Vector3(x * cs.x, y, cell_cz) container.add_child(mi) corner_points[Vector2i(x, z)] = true corner_points[Vector2i(x, z + 1)] = true # Check East (x + 1) if gridmap.get_cell_item(Vector3i(x + 1, 2, z)) != TILE_SAFE: var mi := MeshInstance3D.new() var box := BoxMesh.new() box.size = Vector3(T, H, cs.z) box.material = mat mi.mesh = box mi.position = Vector3(x * cs.x + cs.x, y, cell_cz) container.add_child(mi) corner_points[Vector2i(x + 1, z)] = true corner_points[Vector2i(x + 1, z + 1)] = true # Add a small cylinder at every exposed corner to connect lines and round the tips for cp in corner_points: var mi := MeshInstance3D.new() var cyl := CylinderMesh.new() cyl.top_radius = T * 0.5 cyl.bottom_radius = T * 0.5 cyl.height = H * 0.98 # Slightly shorter to prevent top-face Z-fighting with lines cyl.radial_segments = 16 cyl.material = mat mi.mesh = cyl mi.position = Vector3(cp.x * cs.x, y, cp.y * cs.z) container.add_child(mi) return container func _animate_outline_appear(container: Node3D): """Tween the shared outline material from transparent to full opacity, mirroring the safe zone panel appear animation.""" if not is_instance_valid(container) or container.get_child_count() == 0: return var mi := container.get_child(0) as MeshInstance3D if not mi or not mi.mesh: return var mat := mi.mesh.material as StandardMaterial3D if not mat: return var tween := create_tween() # Flash in: alpha 0→1 in 0.2s tween.tween_method( func(a: float): if is_instance_valid(mat): mat.albedo_color.a = a, 0.0, 1.0, 0.2 ).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_EXPO) # Settle: alpha 1→0.9 in 0.35s tween.tween_method( func(a: float): if is_instance_valid(mat): mat.albedo_color.a = a, 1.0, 0.9, 0.35 ).set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_CUBIC) func _is_valid_safe_zone_area(gridmap: Node, start_x: int, start_z: int, width: int, height: int) -> bool: # Avoid bounds or start/finish cols if start_x < 2 or start_x + width > gridmap.columns - 2: return false if start_z < 1 or start_z + height > gridmap.rows - 1: return false var test_rect = Rect2i(start_x, start_z, width, height) for existing in active_safe_zone_rects: if test_rect.intersects(existing): return false for x in range(start_x, start_x + width): for z in range(start_z, start_z + height): var floor_0 = gridmap.get_cell_item(Vector3i(x, 0, z)) # Floor must be purely TILE_WALKABLE (0) # We no longer check Floor 1 (items) so safe zones can spawn ON TOP of tiles if floor_0 != TILE_WALKABLE: return false return true @rpc("authority", "call_local", "reliable") func sync_safe_zone_disappear_vfx(): _animate_safe_zone_disappear() func _animate_safe_zone_disappear(): """Two-phase disappear: quick flicker-brighten then fade to invisible. Plays before cells are cleared so the panel smoothly vanishes.""" var gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap or not gridmap.mesh_library: return var cur_mesh = gridmap.mesh_library.get_item_mesh(TILE_SAFE) if not is_instance_valid(cur_mesh): return var cur_mat = cur_mesh.material if not is_instance_valid(cur_mat): return # Instead of continuously duplicating, use the current duplicated material for tweening. var fade_mat: StandardMaterial3D = cur_mat var start_alpha: float = fade_mat.albedo_color.a var tween = create_tween() # Phase 1 — Hold alpha steady (warn the player via outlines only to prevent panel z-fighting glitch) tween.tween_method( func(a: float): if is_instance_valid(fade_mat): fade_mat.albedo_color.a = a, start_alpha, start_alpha, 0.15 ).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_SINE) # Phase 2 — Fade out: alpha start_alpha → 0 in 0.4s (EXPO ease-in = fast vanish) tween.tween_method( func(a: float): if is_instance_valid(fade_mat): fade_mat.albedo_color.a = a, start_alpha, 0.0, 0.4 ).set_ease(Tween.EASE_IN).set_trans(Tween.TRANS_EXPO) # Fade out all outline nodes in sync for outline in _outline_nodes: if not is_instance_valid(outline) or outline.get_child_count() == 0: continue var mi := outline.get_child(0) as MeshInstance3D if not mi or not mi.mesh: continue var omat := mi.mesh.material as StandardMaterial3D if not omat: continue var fade_start := omat.albedo_color.a var otween := create_tween() # Flicker brighten otween.tween_method( func(a: float): if is_instance_valid(omat): omat.albedo_color.a = a, fade_start, 1.0, 0.15 ).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_SINE) # Fade to zero otween.tween_method( func(a: float): if is_instance_valid(omat): omat.albedo_color.a = a, 1.0, 0.0, 0.4 ).set_ease(Tween.EASE_IN).set_trans(Tween.TRANS_EXPO) # Wait for animation to finish, then free outlines locally await get_tree().create_timer(0.6).timeout for outline in _outline_nodes: if is_instance_valid(outline): outline.queue_free() _outline_nodes.clear() # Restore the original safe zone mesh to prevent material/alpha leakage if _cached_safe_zone_mesh and gridmap and gridmap.mesh_library: gridmap.mesh_library.set_item_mesh(TILE_SAFE, _cached_safe_zone_mesh) func _clear_dynamic_safe_zones(): var gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: return var main = get_node_or_null("/root/Main") print("[StopNGo] Clearing %d active safe zones." % active_safe_zone_rects.size()) # Play disappear animation on all peers, then wait for it to finish (0.55s) before clearing. if can_rpc(): rpc("sync_safe_zone_disappear_vfx") else: sync_safe_zone_disappear_vfx() await get_tree().create_timer(0.55).timeout for rect in active_safe_zone_rects: for rx in range(rect.size.x): for rz in range(rect.size.y): var px = rect.position.x + rx var pz = rect.position.y + rz # Only clear layer 2 if gridmap.get_cell_item(Vector3i(px, 2, pz)) == TILE_SAFE: gridmap.set_cell_item(Vector3i(px, 2, pz), -1) if can_rpc() and main: main.rpc("sync_grid_item", px, 2, pz, -1) active_safe_zone_rects.clear() spawned_safe_zones = 0 # Outline deletion and mesh restoration is now handled locally by each client # at the end of _animate_safe_zone_disappear() 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 # Collect all non-empty tile indices from playerboard var occupied_indices: Array[int] = [] for i in range(playerboard.size()): if playerboard[i] != -1: occupied_indices.append(i) if occupied_indices.is_empty(): return # Nothing to scatter # Select up to 3 random tiles to scatter var rng = RandomNumberGenerator.new() rng.randomize() occupied_indices.shuffle() var tiles_to_scatter: Array[int] = [] var scatter_count = min(occupied_indices.size(), 3) for i in range(scatter_count): var board_idx = occupied_indices[i] tiles_to_scatter.append(playerboard[board_idx]) playerboard[board_idx] = -1 # Remove only the scattered tiles from board # 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, not static powerup spawn) var floor_tile = gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y)) if floor_tile == -1 or floor_tile == TILE_OBSTACLE or floor_tile == TILE_LIGHTNING_STONE: 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 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 # Sync cleared playerboard via the player's own RPC (more robust than Main lookup) player_node.rpc("sync_playerboard", playerboard) # FREEZE FIX: If they were caught outside, they should also be frozen like a movement violation player_node.rpc("sync_stop_freeze", true) # Notify the player SfxManager.rpc("play_rpc", "tile_scatter") NotificationManager.send_message(player_node, "Not in Safe Zone! Tiles scattered!", NotificationManager.MessageType.WARNING) # Playerboard scatter VFX: one-shot, only on the affected player's own UI. # Routed through the player node (same pattern as play_scatter_knock) because # the player-node name is not a valid network peer ID for rpc_id here. if can_rpc(): player_node.rpc("play_playerboard_scatter") else: player_node.play_playerboard_scatter() # Screen shake and animation if can_rpc(): if player_node.has_method("trigger_screen_shake"): player_node.rpc("trigger_screen_shake", "heavy") if player_node.has_method("play_scatter_knock"): player_node.rpc("play_scatter_knock") print("[StopNGo] Caught outside: Scattered %d tiles from Player %d" % [tiles_to_scatter.size(), peer_id]) # ============================================================================= # OLD STATIC SAFE ZONE LOGIC (Retained for Reference) # ============================================================================= # var safe_zone_columns: Array[int] = [5, 10, 15] # func _is_in_safe_zone_old(pos: Vector2i) -> bool: # return pos.x in safe_zone_columns # func _paint_static_safe_zones(gridmap): # for x in safe_zone_columns: # for z in range(gridmap.rows): # gridmap.set_cell_item(Vector3i(x, 0, z), TILE_SAFE) # # Optional: instantiate walls # # _instantiate_safe_zone_wall(gridmap, x, z) # func _instantiate_safe_zone_wall(gridmap, x: int, z: int): # var wall_scene = load("res://scenes/environment/safe_zone_wall.tscn") # if wall_scene: # var wall = wall_scene.instantiate() # gridmap.add_child(wall) # wall.global_position = Vector3( # x * gridmap.cell_size.x + gridmap.cell_size.x/2, # 0, # z * gridmap.cell_size.z + gridmap.cell_size.z/2 # ) # ============================================================================= # ============================================================================= # Power-Up Tile Spawning (Speed & Ghost) # ============================================================================= func _spawn_powerup_tiles(): """Server: Spawn 5 permanent power-up tiles at fixed 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 print("[StopNGo] Spawning/Refreshing 5 static power-up tiles...") for i in range(PERMANENT_POWERUP_LOCATIONS.size()): var pos = PERMANENT_POWERUP_LOCATIONS[i] # Set Floor 0 beneath power-up to ID 15 (Ancient Lightning Stone) gridmap.set_cell_item(Vector3i(pos.x, 0, pos.y), TILE_LIGHTNING_STONE) # Select a random power-up type (User Request: ensure all types can spawn) var tile_id = POWERUP_TILES.pick_random() # Place on Floor 1 gridmap.set_cell_item(Vector3i(pos.x, 1, pos.y), tile_id) # Sync both floor and tile to all clients and host if can_rpc(): main.rpc("sync_grid_item", pos.x, 0, pos.y, TILE_LIGHTNING_STONE) # Sync floor change (Stone on) main.rpc("sync_grid_item", pos.x, 1, pos.y, tile_id) # Sync power-up powerups_spawned = true print("[StopNGo] Static power-up refresh completed.")