@@ -1,1825 +0,0 @@
extends Node
class_name GauntletManager
# GauntletManager - Handles Candy Pump Survival (Gauntlet) game mode
# Pattern: StopNGoManager + GauntletManager
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 %d x %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 ( %d x %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 %d x %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