extends Node class_name GauntletManager # GauntletManager - Handles Candy Pump Survival (Gauntlet) game mode # Pattern: StopNGoManager + PortalModeManager signal phase_changed(phase_index: int, phase_name: String) signal growth_tick(cells: Array) signal player_trapped(player_id: int) signal ghost_granted(player_id: int) # ============================================================================= # Constants # ============================================================================= const ARENA_COLUMNS: int = 20 const ARENA_ROWS: int = 20 const NPC_SIZE: int = 3 const NPC_CENTER: Vector2i = Vector2i(9, 9) # Center of 20x20 (0-indexed, center of 3x3 block) # Tile IDs (matching MeshLibrary) const TILE_WALKABLE: int = 0 const TILE_OBSTACLE: int = 4 const TILE_STICKY: int = 17 # New candy-pink overlay (Layer 2) const TILE_TELEGRAPH: int = 18 # Warning glow (Layer 2, temporary) # Cell states (v2 ground-growth model). Logical state of each playable cell. enum CellState { SAFE, # Can be entered, crossed, collected TELEGRAPHED, # Warned as future sticky, still passable (1s) STICKY, # Covered in sticky candy, blocks + traps BUBBLE_GROWING, # Candy bubble growing, not yet exploded BLOCKED, # NPC zone or permanent obstacle } # Cells temporarily protected after Ghost-clearing (not used — kept for compat). var cleansed_cells: Dictionary = {} const CLEANSED_PROTECTION_TIME: float = 5.0 # Phase timing thresholds (seconds elapsed) const PHASE_1_START: float = 0.0 # Open Arena const PHASE_2_START: float = 60.0 # Route Pressure const PHASE_3_START: float = 120.0 # Survival Endgame # ============================================================================= # Phase System # ============================================================================= enum Phase { OPEN_ARENA, ROUTE_PRESSURE, SURVIVAL_ENDGAME } var current_phase: Phase = Phase.OPEN_ARENA var elapsed_time: float = 0.0 var is_active: bool = false # ============================================================================= # Growth State (v2 ground-growth model — replaces cannon volley) # ============================================================================= var growth_timer: float = 0.0 var growth_interval: float = 3.0 # seconds between growth ticks var telegraph_duration: float = 1.0 # seconds telegraphed cells stay passable var sticky_cells: Dictionary = {} # Vector2i → true var telegraphed_cells: Dictionary = {} # Vector2i → time remaining (still passable) var _last_tick_cells: Array = [] # cells selected last tick (for repetition penalty) # Camping detection (#073): time each player has spent in their current 4x4 # region. player_id -> {"region": Vector2i, "time": float}. var _camp_tracking: Dictionary = {} const CAMP_REGION_SIZE: int = 4 # Movement buffers (#083): hidden, decaying penalties on SAFE cells that form # critical movement corridors. Detected dynamically each growth tick; never # shown to players. pos(Vector2i) -> {"penalty": float, "adjacent": bool}. # The penalty discourages the growth algorithm from sealing off a corridor too # early, then fades over time / phases so the arena still closes in by the end. var movement_buffers: Dictionary = {} var _buffer_decay_timer: float = 0.0 const BUFFER_DECAY_INTERVAL: float = 5.0 # seconds between decay steps const BUFFER_DECAY_FACTOR: float = 0.75 # each step keeps 75% (−25%) const BUFFER_PHASE_DECAY: float = 0.5 # phase change halves all penalties const BUFFER_MIN_PENALTY: float = 4.0 # prune below this magnitude # Base "inside a buffer corridor" penalty per phase (adjacent = half). const BUFFER_BASE_PENALTY: Array = [40.0, 20.0, 10.0] # A SAFE cell is a corridor if removing it drops a player's reachable region # below this many cells (i.e. it is a genuine chokepoint, not open floor). const BUFFER_CORRIDOR_THRESHOLD: int = 12 # Candy bubbles (#082): occasional anti-camping hazards that grow from 1x1 and # explode into a 3x3 sticky area. Separate from normal ground growth. # active_bubbles entries: {"center": Vector2i, "timer": float, "cells": Array}. var active_bubbles: Array = [] var bubble_cells: Dictionary = {} # Vector2i -> true (BUBBLE_GROWING state) var recent_bubble_positions: Array = [] # centers of recent bubbles (anti-stacking) var bubbles_this_phase: int = 0 # spawned in the current phase var bubbles_total: int = 0 # spawned this round const MAX_BUBBLES_PER_PHASE: Array = [0, 2, 3] # phase 1 / 2 / 3 const BUBBLE_GROW_DURATION: float = 2.75 # seconds from spawn to explosion (2.5–3) const BUBBLE_EXPLOSION_RADIUS: int = 1 # 1 => 3x3 area const BUBBLE_RECENT_MEMORY: int = 4 # how many recent centers to remember const BUBBLE_RECENT_RADIUS: int = 3 # anti-stacking exclusion distance # Phase-specific growth parameters (cells-per-tick range per phase). # Layer weights: [outer, middle, inner] priority for the current pressure layer. var phase_growth_config: Array = [ # Phase 0 (Outer Pressure): 4-6 cells/tick, push from the outside in {"cells_min": 4, "cells_max": 6, "layer_weights": {"outer": 60, "middle": 15, "inner": -40}}, # Phase 1 (Middle Pressure): 6-8 cells/tick {"cells_min": 6, "cells_max": 8, "layer_weights": {"outer": 20, "middle": 60, "inner": 5}}, # Phase 2 (Inner Survival): 8-10 cells/tick {"cells_min": 8, "cells_max": 10, "layer_weights": {"outer": 10, "middle": 35, "inner": 60}}, ] # ============================================================================= # Smack State (per-player) # ============================================================================= func has_smack_charged(pid: int) -> bool: if smack_charged.has(pid) and smack_charged[pid] > 0: return true return false @rpc("any_peer", "call_local", "reliable") func consume_smack(pid: int) -> void: # Local state reset smack_charged[pid] = 0.0 smack_cooldowns[pid] = SMACK_COOLDOWN # Play smack sound if SfxManager: SfxManager.rpc("play_rpc", "attack_mode") if _can_rpc() else SfxManager.play("attack_mode") var all_players = get_tree().get_nodes_in_group("Players") for player in all_players: var curr_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int() if curr_pid == pid: if player.has_method("sync_modulate"): if _can_rpc(): player.rpc("sync_modulate", Color.WHITE) else: player.sync_modulate(Color.WHITE) break var smack_cooldowns: Dictionary = {} # player_id → float (time remaining) var smack_charged: Dictionary = {} # player_id → float (charge window remaining) const SMACK_COOLDOWN: float = 8.0 const SMACK_CHARGE_WINDOW: float = 3.0 # ============================================================================= # Ghost Reward Tracking (replaces Cleanser) # ============================================================================= var player_mission_completions: Dictionary = {} # player_id → int # ============================================================================= # Trapped Players # ============================================================================= var trapped_players: Dictionary = {} # player_id → true (legacy; sticky now slows) # Sticky entry slows the player instead of trapping them (per-player, fair in MP). const STICKY_SLOW_DURATION: float = 2.0 # ============================================================================= # Slow-Mo Effect # ============================================================================= var slowmo_active: bool = false var slowmo_timer: float = 0.0 var slowmo_duration: float = 4.0 const SLOWMO_SCALE: float = 0.25 # 1/4 speed var slowmo_overlay: ColorRect = null # ============================================================================= # References # ============================================================================= var main_scene: Node = null var gridmap: Node = null # Static Candy Pump NPC model at the arena center (the v2 "pump" that injects # candy into the ground). Purely visual now — projectile logic was removed. var candy_pump_scene: PackedScene = preload("res://scenes/candy_cannon.tscn") var pump_instance: Node3D = null # HUD var hud_layer: CanvasLayer var phase_label: Label var slowmo_label: Label var _gauntlet_hud_scene: PackedScene = preload("res://scenes/gauntlet_hud.tscn") # ============================================================================= # Lifecycle # ============================================================================= func _ready(): set_process(false) _setup_hud() func _exit_tree(): # Ensure time_scale is always restored when leaving Gauntlet mode Engine.time_scale = 1.0 func initialize(main: Node, grid: Node) -> void: main_scene = main gridmap = grid print("[Gauntlet] Initialized with gridmap: ", gridmap.name if gridmap else "null") # Connect to GoalsCycleManager for scoring and mission tracking if main_scene: var gcm = main_scene.get_node_or_null("GoalsCycleManager") if gcm: gcm.goal_count_updated.connect(_on_goal_count_updated) gcm.score_updated.connect(_on_score_updated) print("[Gauntlet] Connected to GoalsCycleManager") func _process(delta: float) -> void: if not is_active: return if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer == null: return elapsed_time += delta # Phase escalation _check_phase_transition() # Server only logic if multiplayer.is_server(): # Track camping behaviour for candidate scoring (#073) _update_camp_tracking(delta) # Growth tick timer growth_timer -= delta if growth_timer <= 0.0: _process_growth_tick() growth_timer = growth_interval # Decay cleansed-cell protection windows if not cleansed_cells.is_empty(): _tick_cleansed_cells(delta) # Decay hidden movement buffers over time (#083) _decay_movement_buffers(delta) # Advance candy-bubble grow timers; explode when ready (#082) _update_bubbles(delta) # Smack mechanic update (ALL PEERS) var all_players = get_tree().get_nodes_in_group("Players") for player in all_players: var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int() # Allow local peer to predict setup if not smack_cooldowns.has(pid) and not smack_charged.has(pid): smack_cooldowns[pid] = SMACK_COOLDOWN smack_charged[pid] = 0.0 if smack_cooldowns[pid] > 0: smack_cooldowns[pid] -= delta if smack_cooldowns[pid] <= 0: smack_cooldowns[pid] = 0.0 smack_charged[pid] = SMACK_CHARGE_WINDOW if player.has_method("sync_modulate"): if multiplayer.is_server() and _can_rpc(): player.rpc("sync_modulate", Color.PINK) elif not multiplayer.is_server(): player.sync_modulate(Color.PINK) elif smack_charged[pid] > 0: smack_charged[pid] -= delta if smack_charged[pid] <= 0: smack_charged[pid] = 0.0 smack_cooldowns[pid] = SMACK_COOLDOWN if player.has_method("sync_modulate"): if multiplayer.is_server() and _can_rpc(): player.rpc("sync_modulate", Color.WHITE) elif not multiplayer.is_server(): player.sync_modulate(Color.WHITE) # Slow-mo timer (all peers for visual consistency) if slowmo_active: slowmo_timer -= delta if slowmo_timer <= 0: _end_slowmo() # ============================================================================= # Game Mode Start # ============================================================================= func start_game_mode() -> void: if multiplayer.is_server(): activate_client_side() _start_phase(Phase.OPEN_ARENA) func activate_client_side() -> void: is_active = true if hud_layer: hud_layer.visible = true set_process(true) # ============================================================================= # Phase Management # ============================================================================= func _check_phase_transition() -> void: var new_phase = current_phase if elapsed_time >= PHASE_3_START: new_phase = Phase.SURVIVAL_ENDGAME elif elapsed_time >= PHASE_2_START: new_phase = Phase.ROUTE_PRESSURE if new_phase != current_phase: _start_phase(new_phase) func _start_phase(phase: Phase) -> void: current_phase = phase # Growth config is read per-tick from phase_growth_config[current_phase]; # resetting the timer keeps tick cadence aligned to the phase boundary. growth_timer = growth_interval # Phase change relaxes movement buffers by 50% — the arena is allowed to # close in more aggressively as pressure escalates (#083). if not movement_buffers.is_empty(): _scale_all_buffers(BUFFER_PHASE_DECAY) # Reset the per-phase candy-bubble budget (#082). bubbles_this_phase = 0 var phase_name = _phase_to_string(phase) print("[Gauntlet] Phase changed to: ", phase_name) if _can_rpc() and multiplayer.is_server(): rpc("sync_phase", int(phase), phase_name) # Update phase explicitly with setup_arena _shrink_arena() emit_signal("phase_changed", int(phase), phase_name) func _phase_to_string(phase: Phase) -> String: match phase: Phase.OPEN_ARENA: return "Outer Pressure" Phase.ROUTE_PRESSURE: return "Middle Pressure" Phase.SURVIVAL_ENDGAME: return "Inner Survival" _: return "Unknown" @rpc("authority", "call_local", "reliable") func sync_phase(phase_index: int, phase_name: String) -> void: if not is_active: activate_client_side() current_phase = phase_index as Phase if not multiplayer.is_server(): var bounds = get_arena_bounds() for x in range(ARENA_COLUMNS): for z in range(ARENA_ROWS): var pos = Vector2i(x, z) if pos.x <= bounds.min or pos.x >= bounds.max or pos.y <= bounds.min or pos.y >= bounds.max: if not sticky_cells.has(pos): sticky_cells[pos] = true _update_hud_phase(phase_name) # ============================================================================= # Arena Setup # ============================================================================= func _setup_arena() -> void: """Called by host in main._setup_host_game()""" if not gridmap: gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: push_error("[Gauntlet] No EnhancedGridMap found!") return print("[Gauntlet] Setting up %dx%d Arena..." % [ARENA_COLUMNS, ARENA_ROWS]) # Sync to clients if _can_rpc(): rpc("sync_arena_setup") # Apply locally for server _apply_arena_setup() @rpc("authority", "call_remote", "reliable") func sync_arena_setup() -> void: print("[Gauntlet] Client: Syncing Arena Setup (%dx%d)..." % [ARENA_COLUMNS, ARENA_ROWS]) _apply_arena_setup() func _apply_arena_setup() -> void: """Shared arena layout logic for host + clients.""" if not gridmap: gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: return # Resize grid (bypass setters that wipe the map) gridmap.set("columns", ARENA_COLUMNS) gridmap.set("rows", ARENA_ROWS) # Clear all gridmap.clear() # Build the 20x20 arena for x in range(ARENA_COLUMNS): for z in range(ARENA_ROWS): var pos = Vector2i(x, z) # Center 3x3 block: NPC obstacle (Candy Pump) if x >= 8 and x <= 10 and z >= 8 and z <= 10: # Hardcode clear all possible layers beneath the Candy Pump for layer in range(5): gridmap.set_cell_item(Vector3i(x, layer, z), -1) continue # Boundary walls: perimeter (row 0, row 19, col 0, col 19) if pos.x <= 0 or pos.x >= 19 or pos.y <= 0 or pos.y >= 19: # Also make border walls visually walkable floors instead of red blocks gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE) gridmap.set_cell_item(Vector3i(x, 1, z), -1) gridmap.set_cell_item(Vector3i(x, 2, z), TILE_STICKY) sticky_cells[pos] = true continue # Interior: walkable floor gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE) gridmap.set_cell_item(Vector3i(x, 1, z), -1) gridmap.set_cell_item(Vector3i(x, 2, z), -1) gridmap.diagonal_movement = true gridmap.update_grid_data() gridmap.initialize_astar() if not pump_instance and main_scene: pump_instance = candy_pump_scene.instantiate() pump_instance.name = "CandyPump" var cx = NPC_CENTER.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0 var cz = NPC_CENTER.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0 pump_instance.position = Vector3(cx, 0, cz) main_scene.add_child(pump_instance) print("[Gauntlet] Arena setup complete. Boundary walls at perimeter. Center NPC at (%d,%d)" % [ NPC_CENTER.x, NPC_CENTER.y ]) func _is_npc_zone(pos: Vector2i) -> bool: """Check if a position is within the center 3x3 NPC zone.""" return pos.x >= 8 and pos.x <= 10 and pos.y >= 8 and pos.y <= 10 func get_spawn_points(player_count: int) -> Array[Vector2i]: """Return spawn positions based on player count. Inside boundary walls.""" # 4 players: inner corners var spawns_4: Array[Vector2i] = [ Vector2i(1, 1), # Top-left Vector2i(18, 1), # Top-right Vector2i(1, 18), # Bottom-left Vector2i(18, 18), # Bottom-right ] # 6 players: corners + mid-edges (top/bottom) var spawns_6: Array[Vector2i] = spawns_4.duplicate() spawns_6.append(Vector2i(10, 1)) # Top-mid spawns_6.append(Vector2i(10, 18)) # Bottom-mid # 8 players: corners + all mid-edges var spawns_8: Array[Vector2i] = spawns_6.duplicate() spawns_8.append(Vector2i(1, 10)) # Left-mid spawns_8.append(Vector2i(18, 10)) # Right-mid match player_count: 4: return spawns_4 5, 6: return spawns_6 _, 7, 8: return spawns_8 _: return spawns_4 # ============================================================================= # Tile Spawning & Mission System (Task #3) # ============================================================================= func setup_mission_tiles() -> void: """Public wrapper called from main.gd before countdown. Server-only.""" if multiplayer.is_server(): _spawn_mission_tiles() func _spawn_mission_tiles() -> void: """Distribute colored goal tiles across the 20x20 arena. Follows StopNGoManager._spawn_mission_tiles() pattern. Excludes center 3x3 NPC zone.""" if not gridmap: gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: return # Goal items: Heart(7), Diamond(8), Star(9), Coin(10) var goal_items = [7, 8, 9, 10] var tiles_spawned: int = 0 var main = get_node_or_null("/root/Main") for x in range(ARENA_COLUMNS): for z in range(ARENA_ROWS): var pos = Vector2i(x, z) # Skip NPC pump zone (center 3x3) if x >= 8 and x <= 10 and z >= 8 and z <= 10: continue # Check base floor — don't spawn on void (or walls if they were still obstacles) var base_tile = gridmap.get_cell_item(Vector3i(x, 0, z)) if base_tile == -1: continue # Ensure we don't spawn powerups on the perimeter walls even though they look like floors if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1: continue # Skip if something already exists on Layer 1 var current_item = gridmap.get_cell_item(Vector3i(x, 1, z)) if current_item != -1: continue # Spawn tiles with 60% density (40% chance to skip) if randf() > 0.6: continue var tile_type = goal_items[randi() % goal_items.size()] gridmap.set_cell_item(Vector3i(x, 1, z), tile_type) tiles_spawned += 1 # Sync to clients if main: main.rpc("sync_grid_item", x, 1, z, tile_type) print("[Gauntlet] Spawned %d mission tiles across %dx%d arena" % [tiles_spawned, ARENA_COLUMNS, ARENA_ROWS]) # ============================================================================= # Growth Logic (Server Only) — v2 ground-growth, replaces cannon volley # ============================================================================= func _process_growth_tick() -> void: """One growth tick: score SAFE cells, weight-select, path-check, telegraph.""" if not multiplayer.is_server(): return var count := _cells_this_tick() # Detect hidden movement-buffer corridors before scoring so the candidate # scores reflect them this tick (#083; satisfies #067's buffer-check item). _detect_movement_buffers() var candidates := _generate_candidates() if candidates.is_empty(): return var selected := _select_cells_weighted(candidates, count) selected = _apply_path_safety(selected) if selected.is_empty(): return _last_tick_cells = selected.duplicate() # Telegraph now (passable for telegraph_duration), then convert to sticky. for pos in selected: telegraphed_cells[pos] = telegraph_duration if _can_rpc(): rpc("sync_growth_telegraph", selected) else: sync_growth_telegraph(selected) await get_tree().create_timer(telegraph_duration).timeout for pos in selected: telegraphed_cells.erase(pos) if _can_rpc(): rpc("sync_growth_apply", selected) else: sync_growth_apply(selected) emit_signal("growth_tick", selected) # Possibly start a candy bubble this tick (anti-camping hazard, #082). _try_spawn_bubble() func _cells_this_tick() -> int: """Random cell count within this phase's configured range.""" var cfg = phase_growth_config[int(current_phase)] var lo: int = cfg["cells_min"] var hi: int = cfg["cells_max"] if hi <= lo: return lo return lo + randi() % (hi - lo + 1) func _generate_candidates() -> Array: """Build a list of {pos, score} for every SAFE, growable cell.""" var candidates: Array = [] var player_cells := _active_player_cells() # gathered once per tick for x in range(ARENA_COLUMNS): for z in range(ARENA_ROWS): var pos := Vector2i(x, z) # Only SAFE cells are growable; skip blocked, sticky, telegraphed, # and cleansed (temporary regrowth protection from #068). if cell_state(pos) != CellState.SAFE: continue candidates.append({"pos": pos, "score": _calculate_candidate_score(pos, player_cells)}) return candidates func _calculate_candidate_score(pos: Vector2i, player_cells: Array = []) -> float: """Full v2 candidate score (#073). Higher score = higher pick chance. CandidateScore = LayerPriority + StickyNeighbor + InwardPressure + PlayerPressure + ClusterGrowth + CampingPressure + RandomNoise + MovementBuffer + PathSafety + Repetition """ var score := 0.0 score += _score_layer_priority(pos) score += _score_sticky_neighbor(pos) score += _score_inward_pressure(pos) score += _score_player_pressure(pos, player_cells) score += _score_cluster_growth(pos) score += _score_camping_pressure(pos) score += randf_range(-20.0, 20.0) # RandomNoise — keep growth imperfect score += _score_movement_buffer(pos) score += _score_path_safety(pos) score += _score_repetition(pos) return score # --- score components (#073) ------------------------------------------------- func _score_layer_priority(pos: Vector2i) -> float: """Steer growth to the current phase's pressure ring.""" var weights: Dictionary = phase_growth_config[int(current_phase)]["layer_weights"] return float(weights[_layer_of(pos)]) func _score_sticky_neighbor(pos: Vector2i) -> float: """Prefer growing adjacent to existing sticky: +8 each, capped +64.""" return min(_sticky_neighbor_count(pos) * 8.0, 64.0) func _score_inward_pressure(pos: Vector2i) -> float: """Push candy inward more strongly as the round progresses. Scales with how close the cell is to the center within the per-phase range.""" var d := _chebyshev(pos, NPC_CENTER) var max_d := float(maxi(ARENA_COLUMNS, ARENA_ROWS) / 2) # ~10 var closeness := clampf(1.0 - float(d) / max_d, 0.0, 1.0) match int(current_phase): 0: return lerpf(0.0, 10.0, closeness) 1: return lerpf(5.0, 20.0, closeness) _: return lerpf(10.0, 30.0, closeness) func _score_player_pressure(pos: Vector2i, player_cells: Array) -> float: """Pressure players without directly targeting them. - 2-4 cells away: +20 - directly under a player: -50 (before final 30s), +10 (final 30s).""" if player_cells.is_empty(): return 0.0 var best := 0.0 var final_window := float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW for pcell in player_cells: var d := _chebyshev(pos, pcell) var s := 0.0 if d == 0: s = 10.0 if final_window else -50.0 elif d >= 2 and d <= 4: s = 20.0 if abs(s) > abs(best): best = s return best func _score_cluster_growth(pos: Vector2i) -> float: """Reward expanding/connecting sticky clusters. Distinct sticky neighbours spanning more than one direction implies a bridge between clusters.""" var neighbours := _sticky_neighbor_count(pos) if neighbours == 0: return 0.0 if neighbours >= 3: return 25.0 # connects clusters return 15.0 # expands a cluster func _score_camping_pressure(pos: Vector2i) -> float: """Target areas where a player has lingered. >5s: +20, >8s: +40, >10s: +60.""" var t := _camp_time_for_region(_region_of(pos)) if t > 10.0: return 60.0 elif t > 8.0: return 40.0 elif t > 5.0: return 20.0 return 0.0 func _score_movement_buffer(pos: Vector2i) -> float: """Respect hidden safe zones. Two complementary parts (#083): 1. Dynamically-detected buffer corridors (decaying) — `_buffer_penalty_at`. 2. A light proximity floor around players so the immediate ring stays open. Both lift entirely in the final window so the arena can close out.""" var final_window := float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW if final_window: return 0.0 # 1. Detected corridor buffers (strongest signal). var buffer := _buffer_penalty_at(pos) if buffer < 0.0: return buffer # 2. Proximity floor (kept from #073) — discourage sealing the ring next to a # player even when no corridor was detected there. var player_cells := _active_player_cells() var min_d := INF for pcell in player_cells: min_d = min(min_d, float(_chebyshev(pos, pcell))) if min_d == INF: return 0.0 match int(current_phase): 0: if min_d <= 1: return -40.0 elif min_d <= 2: return -20.0 1: if min_d <= 1: return -20.0 elif min_d <= 2: return -10.0 _: if min_d <= 1: return -10.0 return 0.0 func _score_path_safety(pos: Vector2i) -> float: """Soft penalty that discourages selections which would strand a player. The hard guarantee is enforced separately by _apply_path_safety().""" if float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW: return 0.0 var extra := {pos: true} for pcell in _active_player_cells(): if not _player_has_safe_region(pcell, extra): return -100.0 # would fully trap a player return 0.0 func _score_repetition(pos: Vector2i) -> float: """Avoid spammy growth on last tick's footprint.""" for last in _last_tick_cells: if _chebyshev(pos, last) <= 1: return -30.0 return 0.0 func _select_cells_weighted(candidates: Array, count: int) -> Array: """Weighted-random selection: higher score = higher pick chance. Scores are shifted positive so the lowest-scoring cell still has a small non-zero weight, preserving organic unpredictability. """ var pool: Array = candidates.duplicate() var picked: Array = [] # Find the minimum score to offset all weights into the positive range. var min_score := INF for c in pool: min_score = min(min_score, c["score"]) var offset := 1.0 - min_score # ensures every weight >= 1.0 var n: int = min(count, pool.size()) for _i in range(n): var total := 0.0 for c in pool: total += c["score"] + offset if total <= 0.0: break var roll := randf() * total var acc := 0.0 var chosen_idx := 0 for j in range(pool.size()): acc += pool[j]["score"] + offset if roll <= acc: chosen_idx = j break picked.append(pool[chosen_idx]["pos"]) pool.remove_at(chosen_idx) return picked # --- scoring helpers --------------------------------------------------------- func _layer_of(pos: Vector2i) -> String: """Classify a cell into outer / middle / inner rings by Chebyshev distance from the arena center (matches the NPC pump at the middle).""" var d := _chebyshev(pos, NPC_CENTER) if d >= 7: return "outer" elif d >= 4: return "middle" return "inner" func _sticky_neighbor_count(pos: Vector2i) -> int: """Count of the 8 surrounding cells that are already sticky.""" var c := 0 for dx in range(-1, 2): for dz in range(-1, 2): if dx == 0 and dz == 0: continue if sticky_cells.has(pos + Vector2i(dx, dz)): c += 1 return c func _chebyshev(a: Vector2i, b: Vector2i) -> int: return max(abs(a.x - b.x), abs(a.y - b.y)) # --- camping tracking -------------------------------------------------------- func _region_of(pos: Vector2i) -> Vector2i: """Coarse 4x4 region key a cell belongs to (for camping detection).""" return Vector2i(pos.x / CAMP_REGION_SIZE, pos.y / CAMP_REGION_SIZE) func _update_camp_tracking(delta: float) -> void: """Accumulate time each player spends in their current 4x4 region. Resets the timer when a player moves to a new region. Server-side.""" var seen := {} for player in get_tree().get_nodes_in_group("Players"): var pid = player.get("peer_id") if "peer_id" in player else -1 if pid == -1 or not ("current_position" in player) or player.current_position == null: continue seen[pid] = true var region := _region_of(player.current_position) var rec = _camp_tracking.get(pid) if rec == null or rec["region"] != region: _camp_tracking[pid] = {"region": region, "time": 0.0} else: rec["time"] += delta # Drop tracking for players that left the match. for pid in _camp_tracking.keys(): if not seen.has(pid): _camp_tracking.erase(pid) func _camp_time_for_region(region: Vector2i) -> float: """Longest camp time any player has accrued in the given region.""" var best := 0.0 for pid in _camp_tracking: var rec = _camp_tracking[pid] if rec["region"] == region: best = max(best, rec["time"]) return best # ============================================================================= # Growth Telegraph & Apply (RPCs) — v2 # ============================================================================= @rpc("authority", "call_local", "reliable") func sync_growth_telegraph(cells: Array) -> void: """Warn that the given cells will become sticky. Cells stay passable until sync_growth_apply fires (telegraph_duration later).""" if not gridmap: return for cell in cells: var pos = cell as Vector2i if pos.x >= 8 and pos.x <= 10 and pos.y >= 8 and pos.y <= 10: continue # Telegraph overlay tile on Layer 2 (still passable). gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH) _spawn_telegraph_highlight(pos) # NEW: Throw projectile from pump for normal growth if pump_instance and pump_instance.has_method("spawn_projectile"): var target_world_pos = Vector3( pos.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0, 0.5, pos.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0 ) pump_instance.spawn_projectile(target_world_pos, telegraph_duration) # Audio: warning pulse if SfxManager: SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile") func _spawn_telegraph_highlight(pos: Vector2i) -> void: """Two-stage amber warning under a telegraphed cell (#069): • Build-up (0–0.8s): amber glow ramps alpha 0→1. • Flash (0.8–1.0s): flickers to bright amber just before impact. Auto-removed at the end of the telegraph window. Amber here is deliberately distinct from the pink/magenta sticky overlay so the two never read alike.""" var cs = gridmap.cell_size var world_pos = Vector3(pos.x * cs.x + cs.x / 2.0, 0.15, pos.y * cs.z + cs.z / 2.0) var mesh_inst = MeshInstance3D.new() var box = BoxMesh.new() box.size = Vector3(cs.x * 0.8, 0.02, cs.z * 0.8) mesh_inst.mesh = box mesh_inst.position = world_pos var amber := Color(1.0, 0.65, 0.1) # syrup amber — clearly not sticky pink var mat = StandardMaterial3D.new() mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.albedo_color = Color(amber.r, amber.g, amber.b, 0.0) mat.emission_enabled = true mat.emission = amber mat.emission_energy_multiplier = 1.5 mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mesh_inst.material_override = mat var main = get_node_or_null("/root/Main") if not main: return main.add_child(mesh_inst) # Split the telegraph window 80% build-up / 20% flash. var build := telegraph_duration * 0.8 var flash := telegraph_duration * 0.2 var tween = create_tween() # Build-up: fade in to a steady amber. tween.tween_method(func(a): mat.albedo_color.a = a, 0.0, 0.55, build) # Flash: quick bright flicker (alpha + emission energy) right before impact. tween.tween_method(func(e): mat.emission_energy_multiplier = e, 1.5, 4.0, flash * 0.5) tween.parallel().tween_method(func(a): mat.albedo_color.a = a, 0.55, 0.9, flash * 0.5) tween.tween_method(func(e): mat.emission_energy_multiplier = e, 4.0, 2.5, flash * 0.5) var remove_timer = get_tree().create_timer(telegraph_duration) remove_timer.timeout.connect(func(): if is_instance_valid(mesh_inst): mesh_inst.queue_free() ) @rpc("authority", "call_local", "reliable") func sync_growth_apply(cells: Array) -> void: """Convert telegraphed cells to permanent sticky candy.""" if not gridmap: return for cell in cells: var pos = cell as Vector2i if pos.x >= 8 and pos.x <= 10 and pos.y >= 8 and pos.y <= 10: continue gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_STICKY) sticky_cells[pos] = true # Screen shake for impact if main_scene and main_scene.get("screen_shake_manager"): main_scene.screen_shake_manager.shake(0.15, 0.4) # Audio: sticky splat if SfxManager: SfxManager.rpc("play_rpc", "tile_scatter") if _can_rpc() else SfxManager.play("tile_scatter") _spawn_impact_particles(cells) # Re-evaluate trapped players after the new sticky cells land. _check_all_players_trapped() func _spawn_impact_particles(targets: Array) -> void: """Spawn candy splash particles at impact locations.""" if not main_scene: return for target in targets: var pos = target as Vector2i var world_pos = Vector3( pos.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0, 0.5, # Slightly above floor pos.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0 ) # Create a simple particle effect (GPUParticles3D) var particles = GPUParticles3D.new() particles.emitting = true particles.one_shot = true particles.amount = 8 particles.lifetime = 0.5 particles.explosiveness = 1.0 # Candy pink color var material = ParticleProcessMaterial.new() material.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE material.emission_sphere_radius = 0.2 material.direction = Vector3(0, 1, 0) material.spread = 45.0 material.initial_velocity_min = 2.0 material.initial_velocity_max = 4.0 material.gravity = Vector3(0, -9.8, 0) material.scale_min = 0.1 material.scale_max = 0.3 # Define visual mesh var mesh = BoxMesh.new() mesh.size = Vector3(0.2, 0.2, 0.2) var spatial_mat = StandardMaterial3D.new() spatial_mat.albedo_color = Color(1.0, 0.6, 0.8) # Candy pink spatial_mat.emission_enabled = true spatial_mat.emission = Color(1.0, 0.6, 0.8) spatial_mat.emission_energy_multiplier = 2.0 # Outline shader for splash VFX var outline_mat = ShaderMaterial.new() outline_mat.shader = load("res://assets/shaders/outline3d.gdshader") spatial_mat.next_pass = outline_mat mesh.material = spatial_mat particles.draw_pass_1 = mesh particles.process_material = material particles.position = world_pos main_scene.add_child(particles) # Auto-remove after particles finish await get_tree().create_timer(1.0).timeout if particles and is_instance_valid(particles): particles.queue_free() # ============================================================================= # Sticky / Trap System func is_sticky_cell(pos: Vector2i) -> bool: return sticky_cells.has(pos) func is_cleansed_cell(pos: Vector2i) -> bool: return cleansed_cells.has(pos) func cell_state(pos: Vector2i) -> CellState: """Logical state of a playable cell (v2 ground-growth model).""" var b = get_arena_bounds() if pos.x <= b.min or pos.x >= b.max or pos.y <= b.min or pos.y >= b.max: return CellState.STICKY if _is_npc_zone(pos) or _is_boundary(pos): return CellState.BLOCKED if is_sticky_cell(pos): return CellState.STICKY if cleansed_cells.has(pos): return CellState.BLOCKED # Protected from regrowth temporarily if telegraphed_cells.has(pos): return CellState.TELEGRAPHED if bubble_cells.has(pos): return CellState.BUBBLE_GROWING return CellState.SAFE func mark_cleansed(pos: Vector2i) -> void: """Flag a cell as recently cleansed, granting temporary regrowth protection.""" cleansed_cells[pos] = CLEANSED_PROTECTION_TIME func _tick_cleansed_cells(delta: float) -> void: """Count down cleansed-cell protection; expire when it runs out.""" var expired: Array[Vector2i] = [] for pos in cleansed_cells: cleansed_cells[pos] -= delta if cleansed_cells[pos] <= 0.0: expired.append(pos) for pos in expired: cleansed_cells.erase(pos) func get_arena_bounds() -> Dictionary: match current_phase: Phase.OPEN_ARENA: return {"min": 0, "max": 19} # 20x20 Phase.ROUTE_PRESSURE: return {"min": 1, "max": 18} # 18x18 Phase.SURVIVAL_ENDGAME: return {"min": 6, "max": 12} # 7x7 return {"min": 0, "max": 19} func _shrink_arena() -> void: if not multiplayer.is_server(): return var b = get_arena_bounds() var new_sticky = [] for x in range(ARENA_COLUMNS): for z in range(ARENA_ROWS): var pos = Vector2i(x, z) if _is_npc_zone(pos) or _is_boundary(pos): continue if pos.x <= b.min or pos.x >= b.max or pos.y <= b.min or pos.y >= b.max: if not sticky_cells.has(pos): new_sticky.append(pos) if new_sticky.size() > 0: if _can_rpc() and multiplayer.is_server(): rpc("sync_growth_apply", new_sticky) else: sync_growth_apply(new_sticky) func _is_boundary(pos: Vector2i) -> bool: return pos.x <= 0 or pos.x >= ARENA_COLUMNS - 1 or pos.y <= 0 or pos.y >= ARENA_ROWS - 1 # ============================================================================= # Coverage tracking (v2 target: 70-75%, down from v1's 80%) # ============================================================================= const COVERAGE_TARGET_MIN: float = 0.70 const COVERAGE_TARGET_MAX: float = 0.75 func playable_cell_count() -> int: """Number of cells that can ever become sticky (interior, minus NPC zone).""" var b = get_arena_bounds() var count := 0 for x in range(ARENA_COLUMNS): for z in range(ARENA_ROWS): var pos := Vector2i(x, z) if _is_boundary(pos) or _is_npc_zone(pos): continue if pos.x <= b.min or pos.x >= b.max or pos.y <= b.min or pos.y >= b.max: continue count += 1 return count func coverage_ratio() -> float: """Fraction of playable cells currently sticky (0.0-1.0).""" var playable := playable_cell_count() if playable <= 0: return 0.0 return float(sticky_cells.size()) / float(playable) func is_coverage_reached() -> bool: """True once sticky coverage hits the v2 minimum target.""" return coverage_ratio() >= COVERAGE_TARGET_MIN # ============================================================================= # Path safety (v2): never trap a player before the final window # ============================================================================= const SAFE_REGION_MIN_CELLS: int = 6 # each player must keep this many reachable safe cells const FORCED_TRAP_WINDOW: float = 30.0 # final seconds where trapping is allowed func _is_cell_passable(pos: Vector2i, extra_sticky: Dictionary = {}) -> bool: """Can a player stand on / move through this cell, given a hypothetical sticky set?""" var b = get_arena_bounds() if pos.x <= b.min or pos.x >= b.max or pos.y <= b.min or pos.y >= b.max: return false if _is_boundary(pos) or _is_npc_zone(pos): return false if sticky_cells.has(pos) or extra_sticky.has(pos): return false return true func _reachable_safe_cells(start: Vector2i, extra_sticky: Dictionary, limit: int) -> int: """Flood-fill from start over passable cells; stop early once `limit` reached.""" if not _is_cell_passable(start, extra_sticky): return 0 var visited := {start: true} var queue: Array[Vector2i] = [start] var count := 0 const NEIGHBORS := [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)] while not queue.is_empty(): var cur: Vector2i = queue.pop_front() count += 1 if count >= limit: return count for d in NEIGHBORS: var nxt: Vector2i = cur + d if visited.has(nxt): continue if _is_cell_passable(nxt, extra_sticky): visited[nxt] = true queue.push_back(nxt) return count func _player_has_safe_region(start: Vector2i, extra_sticky: Dictionary) -> bool: """Player at `start` still has at least SAFE_REGION_MIN_CELLS reachable cells.""" return _reachable_safe_cells(start, extra_sticky, SAFE_REGION_MIN_CELLS) >= SAFE_REGION_MIN_CELLS func _apply_path_safety(candidates: Array) -> Array: """Filter a candidate sticky-cell list so no active player is trapped. During the final FORCED_TRAP_WINDOW seconds, trapping is allowed and the candidate list is returned unchanged. """ var time_left := float(gauntlet_round_duration() - elapsed_time) if time_left <= FORCED_TRAP_WINDOW: return candidates var player_cells := _active_player_cells() if player_cells.is_empty(): return candidates var accepted: Array = [] var pending := {} for c in candidates: pending[c] = true for c in candidates: # Tentatively accept c, then verify every player keeps a safe region. var trial := pending.duplicate() # `pending` holds all not-yet-rejected candidates; treat accepted ones as sticky. var trial_sticky := {} for a in accepted: trial_sticky[a] = true trial_sticky[c] = true var safe_for_all := true for pcell in player_cells: if not _player_has_safe_region(pcell, trial_sticky): safe_for_all = false break if safe_for_all: accepted.append(c) else: pending.erase(c) return accepted # ============================================================================= # Movement buffers (#083): hidden, decaying safe corridors # ============================================================================= func _detect_movement_buffers() -> void: """Find SAFE cells that are critical movement corridors for active players and register/refresh a hidden penalty on them. A corridor is a passable cell near a player whose removal would shrink that player's reachable region below BUFFER_CORRIDOR_THRESHOLD (a genuine chokepoint, not open floor). Campers don't get fresh buffers near them — staying put forfeits protection. Runs server-side once per growth tick, before scoring.""" var player_cells := _active_player_cells() if player_cells.is_empty(): return var base: float = BUFFER_BASE_PENALTY[int(current_phase)] const NEIGHBORS := [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)] for pcell in player_cells: # Camping override: a player lingering in one region loses buffer help. if _camp_time_for_region(_region_of(pcell)) > 5.0: continue # Examine the passable cells immediately around the player. for d in NEIGHBORS: var cell: Vector2i = pcell + d if not _is_cell_passable(cell): continue # Is this a chokepoint? Removing it must noticeably cut reachability. var without := _reachable_safe_cells(pcell, {cell: true}, BUFFER_CORRIDOR_THRESHOLD) if without < BUFFER_CORRIDOR_THRESHOLD: _register_buffer(cell, base) func _register_buffer(pos: Vector2i, penalty: float) -> void: """Add or refresh a buffer cell at full penalty for the current phase.""" if movement_buffers.has(pos): # Refresh to the stronger of the existing or the new base penalty. movement_buffers[pos]["penalty"] = max(movement_buffers[pos]["penalty"], penalty) else: movement_buffers[pos] = {"penalty": penalty} func _decay_movement_buffers(delta: float) -> void: """Reduce buffer penalties by 25% every BUFFER_DECAY_INTERVAL seconds, then prune any that have faded below BUFFER_MIN_PENALTY. Server-side each tick.""" if movement_buffers.is_empty(): return _buffer_decay_timer += delta if _buffer_decay_timer < BUFFER_DECAY_INTERVAL: return _buffer_decay_timer = 0.0 _scale_all_buffers(BUFFER_DECAY_FACTOR) func _scale_all_buffers(factor: float) -> void: """Multiply every buffer penalty by `factor`, pruning faded entries.""" for pos in movement_buffers.keys(): var p: float = movement_buffers[pos]["penalty"] * factor if p < BUFFER_MIN_PENALTY: movement_buffers.erase(pos) else: movement_buffers[pos]["penalty"] = p func _buffer_penalty_at(pos: Vector2i) -> float: """Penalty for landing growth on a buffer cell (inside = full, adjacent = half). Lifts entirely in the final window so the arena can close out.""" if movement_buffers.is_empty(): return 0.0 if float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW: return 0.0 if movement_buffers.has(pos): return -movement_buffers[pos]["penalty"] # Adjacent to a buffer cell → half penalty. const NEIGHBORS := [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)] for d in NEIGHBORS: if movement_buffers.has(pos + d): return -movement_buffers[pos + d]["penalty"] * 0.5 return 0.0 func _active_player_cells() -> Array[Vector2i]: """Current grid cells of non-trapped players.""" var cells: Array[Vector2i] = [] for player in get_tree().get_nodes_in_group("Players"): var pid = player.get("peer_id") if "peer_id" in player else -1 if trapped_players.has(pid): continue if "current_position" in player and player.current_position != null: cells.append(player.current_position) return cells # ============================================================================= # Candy bubbles (#082): anti-camping hazards (1x1 grow → 3x3 explosion) # ============================================================================= func _bubble_budget_for_phase() -> int: """How many bubbles this phase is allowed to spawn in total.""" return MAX_BUBBLES_PER_PHASE[int(current_phase)] func _generate_bubble_candidates() -> Array: """Score every SAFE cell as a potential bubble center. Returns {pos, score}.""" var candidates: Array = [] var player_cells := _active_player_cells() for x in range(ARENA_COLUMNS): for z in range(ARENA_ROWS): var pos := Vector2i(x, z) if cell_state(pos) != CellState.SAFE: continue # NEW: Ensure bubbles never pick boundary tiles or NPC area as center if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1: continue if _is_npc_zone(pos): continue candidates.append({"pos": pos, "score": _calculate_bubble_score(pos, player_cells)}) return candidates func _calculate_bubble_score(pos: Vector2i, player_cells: Array = []) -> float: """Bubble-specific scoring (#082). Higher = better bubble target. BubbleScore = Camping + UntouchedArea + PlayerCluster + RandomNoise + DirectHitPenalty + RecentBubblePenalty + UnfairTrapPenalty """ var score := 0.0 score += _bubble_score_camping(pos) score += _bubble_score_untouched_area(pos) score += _bubble_score_player_cluster(pos, player_cells) score += randf_range(-20.0, 20.0) score += _bubble_score_direct_hit(pos, player_cells) score += _bubble_score_recent(pos) score += _bubble_score_unfair_trap(pos) return score func _bubble_score_camping(pos: Vector2i) -> float: """Reward targeting campers. +40 >5s, +60 >8s, +80 >10s-with-ghost.""" var t := _camp_time_for_region(_region_of(pos)) if t > 10.0: # Stronger only if a nearby player is in ghost mode. if _any_ghost_player_near(pos): return 80.0 return 60.0 elif t > 8.0: return 60.0 elif t > 5.0: return 40.0 return 0.0 func _bubble_score_untouched_area(pos: Vector2i) -> float: """+30 when the cell sits in a large untouched (sticky-free) region.""" var open := _reachable_safe_cells(pos, {}, 30) return 30.0 if open >= 24 else 0.0 func _bubble_score_player_cluster(pos: Vector2i, player_cells: Array) -> float: """+20 when 2+ players are nearby (within 4 cells).""" var near := 0 for pcell in player_cells: if _chebyshev(pos, pcell) <= 4: near += 1 return 20.0 if near >= 2 else 0.0 func _bubble_score_direct_hit(pos: Vector2i, player_cells: Array) -> float: """-60 if a bubble would erupt directly under a player (unfair, unreadable).""" for pcell in player_cells: if pos == pcell: return -60.0 return 0.0 func _bubble_score_recent(pos: Vector2i) -> float: """-50 if a recent bubble erupted in/near this region (anti-stacking).""" for c in recent_bubble_positions: if _chebyshev(pos, c) <= BUBBLE_RECENT_RADIUS: return -50.0 return 0.0 func _bubble_score_unfair_trap(pos: Vector2i) -> float: """-100 if the 3x3 explosion would strand a player (before the final window).""" if float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW: return 0.0 var blast := {} for cell in _bubble_blast_cells(pos): blast[cell] = true for pcell in _active_player_cells(): if blast.has(pcell): continue # direct-hit handled separately if not _player_has_safe_region(pcell, blast): return -100.0 return 0.0 func _bubble_blast_cells(center: Vector2i) -> Array: """The 3x3 (radius 1) sticky cells a bubble at `center` would create, clipped to passable/playable cells.""" var b = get_arena_bounds() var cells: Array = [] for dx in range(-BUBBLE_EXPLOSION_RADIUS, BUBBLE_EXPLOSION_RADIUS + 1): for dz in range(-BUBBLE_EXPLOSION_RADIUS, BUBBLE_EXPLOSION_RADIUS + 1): var c := center + Vector2i(dx, dz) if _is_boundary(c) or _is_npc_zone(c): continue if c.x <= b.min or c.x >= b.max or c.y <= b.min or c.y >= b.max: continue cells.append(c) return cells func _bubble_footprint(center: Vector2i) -> Array: return _bubble_blast_cells(center) func _any_ghost_player_near(pos: Vector2i) -> bool: """True if a player in ghost mode is within the camping region.""" for player in get_tree().get_nodes_in_group("Players"): if not player.get("is_invisible"): continue if "current_position" in player and player.current_position != null: if _region_of(player.current_position) == _region_of(pos): return true return false # --- bubble lifecycle (server-authoritative) --------------------------------- func _try_spawn_bubble() -> void: """Maybe spawn one candy bubble this growth tick, if the phase still has budget. Server-side; called from _process_growth_tick after normal growth.""" if not multiplayer.is_server(): return if bubbles_this_phase >= _bubble_budget_for_phase(): return # Probabilistic so bubbles don't all fire on the first ticks of a phase. # ~1 in 4 eligible ticks; the per-phase cap still bounds the total. if randf() > 0.25: return var candidates := _generate_bubble_candidates() if candidates.is_empty(): return var picked := _select_cells_weighted(candidates, 1) if picked.is_empty(): return var center: Vector2i = picked[0] # Reject low-quality targets (e.g. recent/unfair) — only spawn if the chosen # cell scores non-negative, so penalties can veto a bad bubble. var best_score := -INF for c in candidates: if c["pos"] == center: best_score = c["score"] break if best_score < 0.0: return _spawn_bubble(center) func _spawn_bubble(center: Vector2i) -> void: """Begin a bubble at `center`: mark the 3x3 footprint BUBBLE_GROWING and start its grow timer. Broadcasts the warning to clients.""" bubbles_this_phase += 1 bubbles_total += 1 var cells := _bubble_blast_cells(center) for c in cells: bubble_cells[c] = true active_bubbles.append({"center": center, "timer": BUBBLE_GROW_DURATION, "cells": cells}) # Anti-stacking memory. recent_bubble_positions.append(center) while recent_bubble_positions.size() > BUBBLE_RECENT_MEMORY: recent_bubble_positions.pop_front() if _can_rpc(): rpc("sync_bubble_spawn", center, cells) else: sync_bubble_spawn(center, cells) func _update_bubbles(delta: float) -> void: """Advance grow timers; explode bubbles whose timer elapses. Server-side.""" if active_bubbles.is_empty(): return var exploded: Array = [] for b in active_bubbles: b["timer"] -= delta if b["timer"] <= 0.0: exploded.append(b) for b in exploded: active_bubbles.erase(b) _explode_bubble(b["center"], b["cells"]) func _explode_bubble(center: Vector2i, cells: Array) -> void: """Convert a bubble's 3x3 footprint to sticky, slow players caught inside, and broadcast the explosion.""" for c in cells: bubble_cells.erase(c) sticky_cells[c] = true if _can_rpc(): rpc("sync_bubble_explode", center, cells) else: sync_bubble_explode(center, cells) # Slow any player standing in the blast (consistent with sticky entry, #068). var blast := {} for c in cells: blast[c] = true for player in get_tree().get_nodes_in_group("Players"): if "current_position" in player and player.current_position != null: if blast.has(player.current_position): var pid = player.get("peer_id") if "peer_id" in player else -1 if pid != -1 and player.get("is_invisible"): continue apply_sticky_slow(player) # Bot paths through the new sticky are now invalid. if gridmap and gridmap.has_method("initialize_astar"): gridmap.initialize_astar() @rpc("authority", "call_local", "reliable") func sync_bubble_spawn(center: Vector2i, cells: Array) -> void: """Show the growing bubble + 3x3 warning area on all clients.""" if not gridmap: return # Telegraph-style warning overlay on the footprint (still passable). for c in cells: var pos = c as Vector2i if pos.x >= 8 and pos.x <= 10 and pos.y >= 8 and pos.y <= 10: continue gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH) _spawn_bubble_visual(center) if SfxManager: SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile") # NEW: VFX projectile from center pump if it exists if pump_instance and pump_instance.has_method("spawn_projectile"): var target_world_pos = Vector3( center.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0, 0.5, center.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0 ) pump_instance.spawn_projectile(target_world_pos, BUBBLE_GROW_DURATION) @rpc("authority", "call_local", "reliable") func sync_bubble_explode(center: Vector2i, cells: Array) -> void: """Apply the 3x3 sticky overlay + explosion VFX on all clients.""" if not gridmap: return for c in cells: var pos = c as Vector2i if pos.x >= 8 and pos.x <= 10 and pos.y >= 8 and pos.y <= 10: continue gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_STICKY) sticky_cells[pos] = true # Medium shake — bubbles hit harder than a normal growth tick. if main_scene and main_scene.get("screen_shake_manager"): main_scene.screen_shake_manager.shake(0.3, 0.6) if SfxManager: SfxManager.rpc("play_rpc", "tile_scatter") if _can_rpc() else SfxManager.play("tile_scatter") _spawn_impact_particles(cells) func _spawn_bubble_visual(center: Vector2i) -> void: """A pulsing candy bubble sphere that grows over the bubble's lifetime.""" if not gridmap: return var cs = gridmap.cell_size var world_pos = Vector3(center.x * cs.x + cs.x / 2.0, 0.4, center.y * cs.z + cs.z / 2.0) var mesh_inst = MeshInstance3D.new() var sphere = SphereMesh.new() sphere.radius = 0.25 sphere.height = 0.5 mesh_inst.mesh = sphere mesh_inst.position = world_pos var mat = StandardMaterial3D.new() mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.albedo_color = Color(1.0, 0.2, 0.6, 0.7) # candy pink mat.emission_enabled = true mat.emission = Color(1.0, 0.2, 0.6) mat.emission_energy_multiplier = 1.5 var outline_mat = ShaderMaterial.new() outline_mat.shader = load("res://assets/shaders/outline3d.gdshader") mat.next_pass = outline_mat mesh_inst.material_override = mat var main = get_node_or_null("/root/Main") if not main: return main.add_child(mesh_inst) # Grow + pulse over the grow duration, then remove (explosion VFX takes over). var tween = create_tween() tween.tween_property(mesh_inst, "scale", Vector3(3.0, 3.0, 3.0), BUBBLE_GROW_DURATION) \ .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN) tween.parallel().tween_method(func(e): mat.emission_energy_multiplier = e, 1.5, 4.0, BUBBLE_GROW_DURATION) var remove_timer = get_tree().create_timer(BUBBLE_GROW_DURATION + 0.05) remove_timer.timeout.connect(func(): if is_instance_valid(mesh_inst): mesh_inst.queue_free() ) func gauntlet_round_duration() -> int: """Round length in seconds (from lobby settings, with a sane fallback).""" if LobbyManager and "gauntlet_round_duration" in LobbyManager: return LobbyManager.gauntlet_round_duration return 180 func _check_all_players_trapped() -> void: """After growth lands, slow any player standing on a fresh sticky cell.""" if not multiplayer.is_server(): return var all_players = get_tree().get_nodes_in_group("Players") for player in all_players: var pos = player.current_position if player.get("current_position") else Vector2i(-1, -1) if is_sticky_cell(pos): var pid = player.get("peer_id") if "peer_id" in player else -1 if pid != -1 and player.get("is_invisible"): continue # ghost players are immune to the slow apply_sticky_slow(player) func apply_sticky_slow(player: Node) -> void: """Sticky candy slows a single player to a crawl (no global time_scale, no hard freeze). The player can still struggle free at reduced speed.""" if not player or not player.has_method("apply_slow_effect"): return if _can_rpc(): player.rpc("apply_slow_effect", STICKY_SLOW_DURATION) else: player.apply_slow_effect(STICKY_SLOW_DURATION) func _trap_player(player: Node) -> void: """Legacy hard-trap. No longer used for sticky entry (sticky now slows). Kept for potential future hazards.""" var pid = player.get("peer_id") if "peer_id" in player else -1 if pid == -1: return trapped_players[pid] = true print("[Gauntlet] Player %d TRAPPED at %s" % [pid, str(player.current_position)]) emit_signal("player_trapped", pid) # Apply visual feedback and notify if player.has_method("apply_stagger"): if _can_rpc(): player.rpc("apply_stagger", 999.0) # Basically infinite until cleansed else: player.apply_stagger(999.0) NotificationManager.send_message(player, "Stuck in Candy!", NotificationManager.MessageType.WARNING) func clear_sticky_cell(pos: Vector2i) -> void: """Remove a sticky cell (used when ghost player walks through).""" if _can_rpc(): if multiplayer.is_server(): rpc("sync_clear_sticky_cell", pos) else: sync_clear_sticky_cell(pos) # Predictive local clear else: sync_clear_sticky_cell(pos) @rpc("authority", "call_local", "reliable") func sync_clear_sticky_cell(pos: Vector2i) -> void: sticky_cells.erase(pos) mark_cleansed(pos) # temporary regrowth protection if gridmap: gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1) if SfxManager: SfxManager.play("pick_up_power_tile") # Sync removal to main scene's gridmap if needed if main_scene and main_scene.has_method("sync_grid_item"): main_scene.sync_grid_item(pos.x, 2, pos.y, -1) @rpc("any_peer", "reliable") func rpc_trigger_slowmo() -> void: """RPC for clients to request slow-mo from server.""" if multiplayer.is_server(): trigger_slowmo() # ============================================================================= # Slow-Mo Effect # ============================================================================= func trigger_slowmo(duration: float = 4.0) -> void: """Trigger slow-motion effect at 1/4 speed. Server-authoritative.""" if slowmo_active: return slowmo_active = true slowmo_timer = duration slowmo_duration = duration Engine.time_scale = SLOWMO_SCALE # Show visual overlay if main_scene and main_scene.has_node("Camera3D200"): _show_slowmo_overlay() # Show slow-mo HUD label if slowmo_label: slowmo_label.visible = true if _can_rpc(): rpc("sync_slowmo_start", duration) func _end_slowmo() -> void: slowmo_active = false Engine.time_scale = 1.0 _hide_slowmo_overlay() # Hide slow-mo HUD label if slowmo_label: slowmo_label.visible = false if _can_rpc(): rpc("sync_slowmo_end") func _show_slowmo_overlay() -> void: if slowmo_overlay: return slowmo_overlay = ColorRect.new() slowmo_overlay.color = Color(0.3, 0.5, 1.0, 0.1) # Subtle blue tint slowmo_overlay.set_anchors_preset(Control.PRESET_FULL_RECT) slowmo_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE var cam = main_scene.get_node_or_null("Camera3D200") if cam: # Find or create a CanvasLayer for the overlay var canvas = CanvasLayer.new() canvas.layer = 4 main_scene.add_child(canvas) canvas.add_child(slowmo_overlay) # Fade in slowmo_overlay.color.a = 0.0 var tween = create_tween() tween.tween_property(slowmo_overlay, "color:a", 0.1, 0.3) func _hide_slowmo_overlay() -> void: if slowmo_overlay: var tween = create_tween() tween.tween_property(slowmo_overlay, "color:a", 0.0, 0.3) tween.tween_callback(slowmo_overlay.get_parent().queue_free) slowmo_overlay = null @rpc("authority", "call_local", "reliable") func sync_slowmo_start(duration: float) -> void: slowmo_active = true slowmo_timer = duration Engine.time_scale = SLOWMO_SCALE _show_slowmo_overlay() if slowmo_label: slowmo_label.visible = true @rpc("authority", "call_local", "reliable") func sync_slowmo_end() -> void: _end_slowmo() # ============================================================================= # HUD # ============================================================================= func _setup_hud() -> void: var hud_instance = _gauntlet_hud_scene.instantiate() hud_layer = hud_instance hud_layer.visible = false add_child(hud_layer) phase_label = hud_layer.get_node("BottomContainer/VBoxContainer/PhaseLabel") slowmo_label = hud_layer.get_node_or_null("TopContainer/SlowMoLabel") func _update_hud_phase(phase_name: String) -> void: if phase_label: var icon = "🍬" match phase_name: "Middle Pressure": icon = "⚠️" phase_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2)) # Warning gold "Inner Survival": icon = "💀" phase_label.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3)) # Danger red _: phase_label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.8)) # Candy pink phase_label.text = "%s %s" % [icon, phase_name.to_upper()] # Animate phase label with bounce effect _animate_phase_label() func _animate_phase_label() -> void: """Animate phase label with bounce effect.""" if not phase_label: return # Create tween for bounce animation var tween = create_tween() tween.set_ease(Tween.EASE_OUT) tween.set_trans(Tween.TRANS_ELASTIC) # Scale up then back to normal var original_scale = phase_label.scale tween.tween_property(phase_label, "scale", original_scale * 1.2, 0.1) tween.tween_property(phase_label, "scale", original_scale, 0.2) # Flash effect tween.tween_property(phase_label, "modulate", Color(2, 2, 2, 1), 0.1) tween.tween_property(phase_label, "modulate", Color.WHITE, 0.2) # ============================================================================= # GoalsCycleManager Integration # ============================================================================= func _on_goal_count_updated(peer_id: int, count: int) -> void: """Called when a player completes a goal cycle. Grant ghost powerup every 2 missions.""" if not multiplayer.is_server(): return # Track mission completions per player if not player_mission_completions.has(peer_id): player_mission_completions[peer_id] = 0 player_mission_completions[peer_id] += 1 # Grant ghost powerup every 2 missions var completions = player_mission_completions[peer_id] if completions % 2 == 0: _grant_ghost_powerup(peer_id) func _grant_ghost_powerup(peer_id: int) -> void: """Grant the ghost (invisible mode) powerup to a player.""" var all_players = get_tree().get_nodes_in_group("Players") for p in all_players: var pid = p.get("peer_id") if "peer_id" in p else p.name.to_int() if pid == peer_id: var stm = p.get_node_or_null("SpecialTilesManager") if stm and stm.has_method("add_powerup_from_item"): stm.add_powerup_from_item(14) # 14 = Ghost / INVISIBLE_MODE emit_signal("ghost_granted", peer_id) print("[Gauntlet] Player %d granted Ghost powerup (mission %d)" % [peer_id, player_mission_completions[peer_id]]) NotificationManager.send_message(p, "Ghost Power Earned!", NotificationManager.MessageType.POWERUP) break func _on_score_updated(peer_id: int, new_score: int) -> void: """Called when a player's score is updated.""" pass # Score sync handled by GoalsCycleManager # ============================================================================= # Utility # ============================================================================= func _can_rpc() -> bool: if not multiplayer.has_multiplayer_peer(): return false if multiplayer.multiplayer_peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED: return false return true