From ebfa8f99a712e2f37ebabb6e7e28ab36eda8c09f Mon Sep 17 00:00:00 2001 From: adtpdn Date: Thu, 5 Mar 2026 02:07:14 +0800 Subject: [PATCH] feat: Implement the main game scene with new player functionality, Stop n Go and Portal Mode managers, a dynamic message bar, and pre-game countdown logic. --- scenes/main.gd | 39 +++- scenes/player.gd | 38 ++-- scripts/managers/stop_n_go_manager.gd | 287 ++++++++++++++++++++++++-- 3 files changed, 318 insertions(+), 46 deletions(-) diff --git a/scenes/main.gd b/scenes/main.gd index 3eb119d..d494944 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -807,15 +807,41 @@ func _assign_random_spawn_positions(): print("Assigned spawn %s to player %s" % [assigned_pos, player.name]) func _assign_stop_n_go_spawn_positions(all_players: Array): - """Assigns spawns to the far left columns (Start Line) for Stop N Go mode.""" + """Assigns random spawns on walkable arena tiles for Stop N Go mode.""" # 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 spawn_index = 0 + var enhanced_gridmap = $EnhancedGridMap + if not enhanced_gridmap: + return + + # Collect all valid walkable positions (not obstacle, not void) + var valid_positions: Array[Vector2i] = [] + for x in range(enhanced_gridmap.columns): + for z in range(enhanced_gridmap.rows): + var tile = enhanced_gridmap.get_cell_item(Vector3i(x, 0, z)) + # Accept walkable (0), start (3), finish (3) — skip obstacles (4) and void (-1) + if tile != -1 and tile != 4: + valid_positions.append(Vector2i(x, z)) + + valid_positions.shuffle() + + var used_positions: Array[Vector2i] = [] + for player in all_players: - # Use deterministic assignment from (0, 1) to (0, 8) to keep players separate - # Start Line is Column 0. We use rows 1 to 8. - var assigned_pos = Vector2i(0, spawn_index + 1) + var assigned_pos = Vector2i(-1, -1) + + # Find a random walkable position not occupied by another player + for pos in valid_positions: + if pos not in used_positions: + assigned_pos = pos + break + + if assigned_pos == Vector2i(-1, -1): + # Fallback: center of arena + assigned_pos = Vector2i(int(enhanced_gridmap.columns / 2), int(enhanced_gridmap.rows / 2)) + + used_positions.append(assigned_pos) # Ensure immediate sync player.position = player.grid_to_world(assigned_pos) @@ -825,8 +851,7 @@ func _assign_stop_n_go_spawn_positions(all_players: Array): if can_rpc(): player.rpc("set_spawn_position", assigned_pos) - spawn_index += 1 - print("[StopNGo] Assigned fixed starting block %s to player %s" % [assigned_pos, player.name]) + print("[StopNGo] Assigned random 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.""" diff --git a/scenes/player.gd b/scenes/player.gd index 5fcd459..8a1e373 100644 --- a/scenes/player.gd +++ b/scenes/player.gd @@ -737,11 +737,11 @@ func apply_stagger(duration: float = 1.5): # If still immune, show immunity tint (Green?), otherwise White # UNLESS we are still stop-frozen (Cyan) if is_stop_frozen: - _apply_tint_recursive(self, Color.CYAN) + _apply_tint_recursive(self , Color.CYAN) elif immunity_timer > 0: - _apply_tint_recursive(self, Color(0.5, 1.0, 0.5)) # Light Green for immunity + _apply_tint_recursive(self , Color(0.5, 1.0, 0.5)) # Light Green for immunity else: - _apply_tint_recursive(self, Color.WHITE) # Remove tint + _apply_tint_recursive(self , Color.WHITE) # Remove tint @rpc("any_peer", "call_local", "reliable") func sync_stop_freeze(enabled: bool): @@ -753,16 +753,16 @@ func sync_stop_freeze(enabled: bool): is_stop_frozen = enabled if enabled: - _apply_tint_recursive(self, Color.CYAN) + _apply_tint_recursive(self , Color.CYAN) print("[STOP n GO] Player %s FROZEN until GO phase" % name) else: # Restore appropriate tint if is_frozen: - _apply_tint_recursive(self, Color.BLUE) + _apply_tint_recursive(self , Color.BLUE) elif immunity_timer > 0: - _apply_tint_recursive(self, Color(0.5, 1.0, 0.5)) + _apply_tint_recursive(self , Color(0.5, 1.0, 0.5)) else: - _apply_tint_recursive(self, Color.WHITE) + _apply_tint_recursive(self , Color.WHITE) print("[STOP n GO] Player %s UNFROZEN" % name) @rpc("any_peer", "call_local") @@ -905,7 +905,7 @@ func on_stop_phase_violation(): var cell = Vector3i(pos.x, 1, pos.y) rpc("sync_grid_item", cell.x, cell.y, cell.z, item_id) - NotificationManager.send_message(self, "STOP VIOLATION! Tiles scattered!", NotificationManager.MessageType.WARNING) + NotificationManager.send_message(self , "STOP VIOLATION! Tiles scattered!", NotificationManager.MessageType.WARNING) func _find_multiple_drop_positions(count: int) -> Array: var positions = [] @@ -1051,7 +1051,7 @@ func _process_remote_interpolation(_delta): # Fallback to simple lerp if not enough snapshots # Keep this very soft to smooth out transitions between tween and interpolation if global_position.distance_squared_to(target_visual_position) > 0.001: - global_position = global_position.lerp(target_visual_position, _delta * 10.0) + global_position = global_position.lerp(target_visual_position, _delta * 10.0) return var render_time = Time.get_ticks_msec() - INTERPOLATION_OFFSET @@ -1063,7 +1063,7 @@ func _process_remote_interpolation(_delta): for i in range(1, snapshot_buffer.size()): if snapshot_buffer[i].time > render_time: newer = snapshot_buffer[i] - older = snapshot_buffer[i-1] + older = snapshot_buffer[i - 1] break if newer: @@ -1257,7 +1257,7 @@ func _find_random_spawn_position() -> Vector2i: # We should check if it is NOT TILE_OBSTACLE. var item = enhanced_gridmap.get_cell_item(Vector3i(x, 0, z)) # Assuming 4 is obstacle, and -1 is void. 0 is walkable, 2 is safe zone. - if item != -1 and item != 4: + if item != -1 and item != 4: if not is_position_occupied(pos): available_positions.append(pos) @@ -2164,13 +2164,13 @@ func sync_throw_tekton(target_pos: Vector2i): # 1. Stun nearby players (Radius 2?) # "if there's a player around that floor they will got stunned" -> "around that floor" implies radius var impact_center = target_pos - var stun_radius = 1.5 + var stun_radius = 1.5 var players = get_tree().get_nodes_in_group("Players") print("[Throw] Checking stun impact at %s. Found %d players." % [impact_center, players.size()]) for p in players: - if p == self: continue + if p == self: continue # Check distance var dist = Vector2(p.current_position.x, p.current_position.y).distance_to(Vector2(impact_center.x, impact_center.y)) @@ -2184,7 +2184,7 @@ func sync_throw_tekton(target_pos: Vector2i): # 2. Tekton drops tiles (Spawn tiles around) AND shrinks if tekton.has_method("on_thrown_landing"): - tekton.on_thrown_landing(self, 2.0) + tekton.on_thrown_landing(self , 2.0) else: # Fallback tekton.on_hit(self , 1.0) @@ -2218,7 +2218,7 @@ func sync_drop_tekton(): # Trigger landing effects (minimal scale) if tekton.has_method("on_thrown_landing"): - tekton.on_thrown_landing(self, 1.0) # Minimal scale impact + tekton.on_thrown_landing(self , 1.0) # Minimal scale impact print("[Player %s] Dropped Tekton at %s" % [name, current_position]) @@ -2231,7 +2231,7 @@ func enter_attack_mode(): is_attack_mode = true is_knock_mode = false # Mutually exclusive - NotificationManager.send_message(self, "Attack Mode ACTIVATED (Red)", NotificationManager.MessageType.POWERUP) + NotificationManager.send_message(self , "Attack Mode ACTIVATED (Red)", NotificationManager.MessageType.POWERUP) update_active_player_indicator() func enter_knock_mode(): @@ -2239,7 +2239,7 @@ func enter_knock_mode(): is_knock_mode = true is_attack_mode = false # Mutually exclusive - NotificationManager.send_message(self, "Knock Mode ACTIVATED (Yellow)", NotificationManager.MessageType.POWERUP) + NotificationManager.send_message(self , "Knock Mode ACTIVATED (Yellow)", NotificationManager.MessageType.POWERUP) update_active_player_indicator() func update_active_player_indicator(): @@ -2276,7 +2276,7 @@ func knock_tekton(): # Reset Knock Mode after successful hit is_knock_mode = false - NotificationManager.send_message(self, "Knock Successful!", NotificationManager.MessageType.POWERUP) + NotificationManager.send_message(self , "Knock Successful!", NotificationManager.MessageType.POWERUP) update_active_player_indicator() else: # If we called knock_tekton but nothing was nearby, maybe we just enter the mode? @@ -2289,7 +2289,7 @@ func sync_knock_tekton(tekton_path: NodePath): if tekton: # Intensity 2.0 for knock (drops 200% tiles) + Shrink/Recover # Use on_thrown_landing to trigger shrink animation and floor freeze - tekton.on_thrown_landing(self, 2.0) + tekton.on_thrown_landing(self , 2.0) print("[Player %s] Knocked Tekton %s" % [name, tekton.name]) # Visual feedback (Juice) diff --git a/scripts/managers/stop_n_go_manager.gd b/scripts/managers/stop_n_go_manager.gd index 3a5e791..d8affc2 100644 --- a/scripts/managers/stop_n_go_manager.gd +++ b/scripts/managers/stop_n_go_manager.gd @@ -13,6 +13,16 @@ 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 @@ -82,6 +92,10 @@ func _process(delta): 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) @@ -198,7 +212,6 @@ func activate_client_side(): 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! @@ -217,8 +230,20 @@ func _start_phase(phase: Phase): if can_rpc(): rpc("sync_phase", phase_name, phase_timer) - # If GO phase starts, clear all STOP phase freezes + 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"): @@ -273,8 +298,8 @@ func _apply_arena_setup(): # Clear existing items on all layers gridmap.clear() - # Safe Zones Columns: 6, 7, 8 (Only one band now) - var safe_columns = [6, 7, 8] + # 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): @@ -283,8 +308,6 @@ func _apply_arena_setup(): tile_id = TILE_START elif x == gridmap.columns - 1: tile_id = TILE_FINISH - elif x in safe_columns: - tile_id = TILE_SAFE for z in range(gridmap.rows): gridmap.set_cell_item(Vector3i(x, 0, z), tile_id) @@ -306,8 +329,8 @@ func _spawn_mission_tiles(): gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: return - # Forbidden Zones (Start, Safe, Finish) - No items here - var forbidden_x = [0, 6, 7, 8, 21] + # 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] @@ -350,18 +373,16 @@ func sync_missions(missions: Dictionary): 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: - var gridmap = get_parent().get_node_or_null("EnhancedGridMap") - if not gridmap: - gridmap = get_node_or_null("/root/Main/EnhancedGridMap") - if gridmap: - # Check FROM position. If you were safe, you can move? - # Rules: "If a player moves during this phase". - # Usually implies checking if you ARE in a safe zone. - var tile_from = gridmap.get_cell_item(Vector3i(from.x, 0, from.y)) - - if tile_from != TILE_SAFE and tile_from != TILE_START and tile_from != TILE_FINISH: - _penalize_player(player_id) - return true + # 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): @@ -418,3 +439,229 @@ func check_win_condition(player_id: int, position: Vector2i) -> bool: 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)