@@ -1,11 +1,11 @@
extends Node
class_name GauntletManager
# GauntletManager - Handles Candy Cannon Survival (Gauntlet) game mode
# GauntletManager - Handles Candy Pump Survival (Gauntlet) game mode
# Pattern: StopNGoManager + PortalModeManager
signal phase_changed ( phase_index : int , phase_name : String )
signal cannon_fired ( targets : Array )
signal growth_tick ( cells : Array )
signal player_trapped ( player_id : int )
signal cleanser_granted ( player_id : int )
@@ -24,6 +24,20 @@ 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
CLEANSED , # Recently cleaned by Cleanser (temp protection)
}
# Cells temporarily protected after a Cleanser pass (Vector2i -> time remaining).
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
@@ -39,23 +53,61 @@ var elapsed_time: float = 0.0
var is_active : bool = false
# =============================================================================
# Cannon State
# Growth State (v2 ground-growth model — replaces cannon volley)
# =============================================================================
var cannon_timer : float = 0.0
var cannon_interval : float = 5.0 # seconds between volley s
var volley_size : int = 5
var growth_timer : float = 0.0
var growth_interval : float = 3.0 # seconds between growth tick s
var telegraph_duration : float = 1.0 # seconds telegraphed cells stay passable
var sticky_cells : Dictionary = { } # Vector2i → true
var last_targeted_player_id : int = - 1
var telegraphed_cells : Dictionary = { } # Vector2i → time remaining (still passable)
var _last_tick_cells : Array = [ ] # cells selected last tick (for repetition penalty)
# Phase-specific cannon parameters
var phase_configs : Array = [
# Phase 0 (Open Arena): slow, small volleys
{ " interval " : 5.0 , " volley " : 5 , " telegraph_time " : 1.2 } ,
# Phase 1 (Route Pressure): faster, bigger volleys
{ " interval " : 4.0 , " volley " : 8 , " telegraph_time " : 1.0 } ,
# Phase 2 (Survival Endgame): rapid fire, huge volleys
{ " interval " : 3.0 , " volley " : 12 , " telegraph_time " : 0.8 } ,
# 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 } } ,
]
# =============================================================================
@@ -108,7 +160,10 @@ const CLEANSER_ACTIVATION_DELAY: float = 0.3
# Trapped Players
# =============================================================================
var trapped_players : Dictionary = { } # player_id → true
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
@@ -126,8 +181,10 @@ var slowmo_overlay: ColorRect = null
var main_scene : Node = null
var gridmap : Node = null
var candy_cannon_scene : PackedScene = preload ( " res://scenes/candy_cannon.tscn " )
var cannon_instance : Node3D = 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
@@ -177,11 +234,24 @@ func _process(delta: float) -> void:
# Server only logic
if multiplayer . is_server ( ) :
# Cannon timer
cannon_timer - = delta
if cannon_timer < = 0.0 :
_fire_volley ( )
cannon_timer = cannon_interval
# 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 " )
@@ -255,11 +325,18 @@ func _check_phase_transition() -> void:
func _start_phase ( phase : Phase ) - > void :
current_phase = phase
var config = phase_configs [ int ( phase ) ]
cannon_interval = config [ " interval " ]
volley_size = config [ " volley " ]
cannon_timer = cannon_interval
# 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 )
@@ -271,11 +348,11 @@ func _start_phase(phase: Phase) -> void:
func _phase_to_string ( phase : Phase ) - > String :
match phase :
Phase . OPEN_ARENA :
return " Open Arena "
return " Outer Pressure "
Phase . ROUTE_PRESSURE :
return " Rout e Pressure"
return " Middl e Pressure"
Phase . SURVIVAL_ENDGAME :
return " Survival! "
return " Inner Survival"
_ :
return " Unknown "
@@ -284,9 +361,6 @@ func sync_phase(phase_index: int, phase_name: String) -> void:
if not is_active :
activate_client_side ( )
current_phase = phase_index as Phase
var config = phase_configs [ phase_index ]
cannon_interval = config [ " interval " ]
volley_size = config [ " volley " ]
_update_hud_phase ( phase_name )
# =============================================================================
@@ -337,7 +411,7 @@ func _apply_arena_setup() -> void:
for z in range ( ARENA_ROWS ) :
var pos = Vector2i ( x , z )
# Center 3x3 block: NPC obstacle (Candy Cannon )
# Center 3x3 block: NPC obstacle (Candy Pump )
if _is_npc_zone ( pos ) :
gridmap . set_cell_item ( Vector3i ( x , 0 , z ) , TILE_OBSTACLE )
gridmap . set_cell_item ( Vector3i ( x , 1 , z ) , - 1 )
@@ -357,13 +431,13 @@ func _apply_arena_setup() -> void:
gridmap . update_grid_data ( )
gridmap . initialize_astar ( )
if not cannon_instance and main_scene :
cannon_instance = candy_cannon_scene . instantiate ( )
cannon_instance . name = " CandyCannon "
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
cannon_instance . position = Vector3 ( cx , 0 , cz )
main_scene . add_child ( cannon_instance )
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
@@ -433,7 +507,7 @@ func _spawn_mission_tiles() -> void:
for z in range ( ARENA_ROWS ) :
var pos = Vector2i ( x , z )
# Skip NPC cannon zone (center 3x3)
# Skip NPC pump zone (center 3x3)
if _is_npc_zone ( pos ) :
continue
@@ -463,234 +537,400 @@ func _spawn_mission_tiles() -> void:
print ( " [Gauntlet] Spawned %d mission tiles across %d x %d arena " % [ tiles_spawned , ARENA_COLUMNS , ARENA_ROWS ] )
# =============================================================================
# Cannon Logic (Server Only)
# Growth Logic (Server Only) — v2 ground-growth, replaces cannon volley
# =============================================================================
func _fire_volley ( ) - > void :
" " " Select target cells, highlight, telegraph, then apply sticky after delay ." " "
func _process_growth_tick ( ) - > void :
" " " One growth tick: score SAFE cells, weight-select, path-check, telegraph ." " "
if not multiplayer . is_server ( ) :
return
var targets = _select_targets ( )
if targets . is_empty ( ) :
return
var config = phase_configs [ int ( current_phase ) ]
var telegraph_time = config [ " telegraph_time " ]
var highlight_time : float = 0.8 # Floor highlight duration before telegraph
# Highlight phase — show pulsing floor warning BEFORE telegraph
if _can_rpc ( ) :
rpc ( " sync_telegraph_highlight " , targets )
await get_tree ( ) . create_timer ( highlight_time ) . timeout
# Telegraph phase — show warning overlay
if _can_rpc ( ) :
rpc ( " sync_telegraph " , targets )
# Shoot projectiles visually with 0.1s offset between shots
if cannon_instance and cannon_instance . has_method ( " spawn_projectile_rpc " ) and cannon_instance . can_rpc ( ) :
var cs = gridmap . cell_size
for i in range ( targets . size ( ) ) :
var target = targets [ i ]
var target_pos = Vector3 ( target . x * cs . x + cs . x / 2.0 , 0 , target . y * cs . z + cs . z / 2.0 )
# Stagger shots: 0.1s offset per projectile
await get_tree ( ) . create_timer ( i * 0.1 ) . timeout
cannon_instance . rpc ( " spawn_projectile_rpc " , target_pos , telegraph_time )
# Wait remaining telegraph duration, then apply impact
var remaining_time = telegraph_time - ( targets . size ( ) - 1 ) * 0.1
if remaining_time > 0 :
await get_tree ( ) . create_timer ( remaining_time ) . timeout
if _can_rpc ( ) :
rpc ( " sync_impact " , targets )
emit_signal ( " cannon_fired " , targets )
func _select_targets ( ) - > Array :
" " " Pick target cells for this volley based on current phase weights. " " "
var targets : Array = [ ]
var all_players = get_tree ( ) . get_nodes_in_group ( " Players " )
# Collect all valid walkable positions (excluding NPC zone and existing sticky)
var valid_positions : Array = [ ]
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 )
if _is_npc_zone ( pos ) :
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
if sticky_cells . has ( pos ) :
continue
valid_positions . append ( pos )
if valid_positions . is_empty ( ) :
return targets
# Simple targeting: mix of random + player-adjacent
var remaining = volley_size
# 40% of volley near players
var player_targets = int ( remaining * 0.4 )
for i in range ( player_targets ) :
if all_players . is_empty ( ) :
break
# Pick a random player
var player = all_players [ randi ( ) % all_players . size ( ) ]
var player_pos = player . current_position if player . get ( " current_position " ) else Vector2i ( 10 , 10 )
# Pick a cell near them (within 3 tiles)
var nearby = _get_nearby_valid_cells ( player_pos , 3 , valid_positions )
if not nearby . is_empty ( ) :
var target = nearby [ randi ( ) % nearby . size ( ) ]
if target not in targets :
targets . append ( target )
remaining - = 1
# Remaining: random scatter
valid_positions . shuffle ( )
for pos in valid_positions :
if remaining < = 0 :
break
if pos not in targets :
targets . append ( pos )
remaining - = 1
return targets
candidates . append ( { " pos " : pos , " score " : _calculate_candidate_score ( pos , player_cells ) } )
return candidates
func _get_nearby_valid_cells ( center : Vector2i , radius : int , valid : Array ) - > Array :
var result : Array = [ ]
for pos in valid :
if abs ( pos . x - center . x ) < = radius and abs ( pos . y - center . y ) < = radius :
result . append ( pos )
return result
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
# =============================================================================
# Telegraph & Impact (RPCs)
# =============================================================================
# Growth Telegraph & Apply (RPCs) — v2
# =============================================================================
@rpc ( " authority " , " call_local " , " reliable " )
func sync_telegraph_highlight ( targets : Array ) - > void :
" " " Show pulsing floor highlight on target cells BEFORE the telegraph drop. " " "
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
# Create programmatic highlight overlays (pulsing circles on floor)
for target in targets :
var pos = target as Vector2i
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 )
# Create a flat pulsing indicator mesh
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 mat = StandardMaterial3D . new ( )
mat . transparency = BaseMaterial3D . TRANSPARENCY_ALPHA
mat . albedo_color = Color ( 1.0 , 0.3 , 0.5 , 0.4 ) # Pink warning glow
mat . emission_enabled = true
mat . emission = Color ( 1.0 , 0.3 , 0.5 )
mat . emission_energy_multiplier = 2.0
mat . shading_mode = BaseMaterial3D . SHADING_MODE_UNSHADED
mesh_inst . material_override = mat
# Add to scene tree under main
var main = get_node_or_null ( " /root/Main " )
if main :
main . add_child ( mesh_inst )
# Pulse animation
var tween = create_tween ( ) . set_loops ( )
tween . tween_method ( func ( a ) : mat . albedo_color . a = a , 0.4 , 0.1 , 0.2 )
tween . tween_method ( func ( a ) : mat . albedo_color . a = a , 0.1 , 0.4 , 0.2 )
# Auto-remove after highlight duration
var remove_timer = get_tree ( ) . create_timer ( 0.8 )
remove_timer . timeout . connect ( func ( ) :
if is_instance_valid ( mesh_inst ) :
mesh_inst . queue_free ( )
)
# Play warning sound
if SfxManager :
SfxManager . rpc ( " play_rpc " , " generate_tile " ) if _can_rpc ( ) else SfxManager . play ( " generate_tile " )
@rpc ( " authority " , " call_local " , " reliable " )
func sync_telegraph ( targets : Array ) - > void :
" " " Show warning overlay on target cells with multi-stage animation. " " "
if not gridmap : return
# Place telegraph tiles
for target in targets :
var pos = target as Vector2i
for cell in cells :
var pos = cell as Vector2i
# Telegraph overlay tile on Layer 2 (still passable).
gridmap . set_cell_item ( Vector3i ( pos . x , 2 , pos . y ) , TILE_TELEGRAPH )
# Animate telegraph with Tween (build-up phase)
_animate_telegraph ( targets )
_spawn_telegraph_highlight ( pos )
func _animate_telegraph ( targets : Array ) - > void :
" " " Tween animation for telegraph: fade in, flash, then transition to sticky. " " "
var config = phase_configs [ int ( current_phase ) ]
var telegraph_time = config [ " telegraph_time " ]
var build_up_time = telegraph_time * 0.8 # 80% for build-up
var flash_time = telegraph_time * 0.2 # 20% for flash
# Create tween for visual feedback
var tween = create_tween ( )
tween . set_parallel ( true )
# Phase 1: Fade in (alpha 0 -> 1) during build-up
# Note: GridMap tiles don't support alpha directly, so we use modulation
# We'll animate the gridmap overlay opacity conceptually
for target in targets :
var pos = target as Vector2i
# Tween the cell brightness by swapping between telegraph variants
tween . tween_callback ( _flash_telegraph . bind ( targets , 0 ) ) . set_delay ( 0.0 )
tween . tween_callback ( _flash_telegraph . bind ( targets , 1 ) ) . set_delay ( 0.4 )
tween . tween_callback ( _flash_telegraph . bind ( targets , 0 ) ) . set_delay ( 0.8 )
# Audio: rising pitch during build-up
# Audio: warning pulse
if SfxManager :
SfxManager . rpc ( " play_rpc " , " generate_tile " ) if _can_rpc ( ) else SfxManager . play ( " generate_tile " )
await get_tree ( ) . create_timer ( 1.0 ) . timeout
func _flash_telegraph ( targets : Array , brightness : int ) - > void :
" " " Flick er telegraph tiles between normal and bright. " " "
if not gridmap : return
# Toggle visual feedback - in full implementation would modify material/overlay
# For now, this provides the timing structure fo r t he animation
pass
func _spawn_telegraph_highlight ( pos : Vector2i ) - > void :
" " " Two-stage amber warning und er 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. Ambe r 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_impact ( targets : Array ) - > void :
" " " Apply sticky cells at target positions ." " "
func sync_growth_apply ( cells : Array ) - > void :
" " " Convert telegraphed cells to permanent sticky candy ." " "
if not gridmap : return
for target in targets :
var pos = target as Vector2i
# Replace telegraph with sticky on Layer 2
for cell in cells :
var pos = cell as Vector2i
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: impact splat sound
# Audio: sticky splat
if SfxManager :
SfxManager . rpc ( " play_rpc " , " tile_scatter " ) if _can_rpc ( ) else SfxManager . play ( " tile_scatter " )
# Spawn candy splash particles at impact locations
_spawn_impact_particles ( targets )
# Check if any player is now trapped
_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 (
@@ -698,7 +938,7 @@ func _spawn_impact_particles(targets: Array) -> void:
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
@@ -706,7 +946,7 @@ func _spawn_impact_particles(targets: Array) -> void:
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
@@ -718,12 +958,12 @@ func _spawn_impact_particles(targets: Array) -> void:
material . gravity = Vector3 ( 0 , - 9.8 , 0 )
material . scale_min = 0.1
material . scale_max = 0.3
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 ) :
@@ -736,33 +976,564 @@ func _spawn_impact_particles(targets: Array) -> void:
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). " " "
if _is_npc_zone ( pos ) or _is_boundary ( pos ) :
return CellState . BLOCKED
if sticky_cells . has ( pos ) :
return CellState . STICKY
if cleansed_cells . has ( pos ) :
return CellState . CLEANSED
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 _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 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
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? " " "
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
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-cleanser. " " "
var t := _camp_time_for_region ( _region_of ( pos ) )
if t > 10.0 :
# Stronger only if a nearby player actually holds a cleanser.
if _any_cleanser_holder_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 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
cells . append ( c )
return cells
func _any_cleanser_holder_near ( pos : Vector2i ) - > bool :
" " " True if a player holding a Cleanser charge is within the camping region. " " "
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 :
continue
if player_cleansers . get ( pid , 0 ) < = 0 :
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 is_cleanser_active ( pid ) :
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
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 " )
@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
gridmap . set_cell_item ( Vector3i ( pos . x , 2 , pos . y ) , TILE_STICKY )
# 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
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 ) and not trapped_players . has ( player . get ( " peer_id " ) if " peer_id " in player else - 1 ) :
_trap_player ( player )
if is_sticky_cell ( pos ) :
var pid = player . get ( " peer_id " ) if " peer_id " in player else - 1
if pid != - 1 and is_cleanser_active ( pid ) :
continue # cleansing 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 :
" " " Used by Cleanser power-up to remove a sticky cell. " " "
sticky_cells . erase ( pos )
mark_cleansed ( pos ) # temporary regrowth protection (v2)
if gridmap :
gridmap . set_cell_item ( Vector3i ( pos . x , 2 , pos . y ) , - 1 )
@@ -816,13 +1587,7 @@ func _try_use_cleanser() -> void:
elif multiplayer . is_server ( ) :
if _can_rpc ( ) :
rpc ( " sync_cleanser_count " , local_pid , 0 )
# Trigger slow-mo for dramatic effect
if multiplayer . is_server ( ) :
trigger_slowmo ( )
else :
rpc ( " rpc_trigger_slowmo " )
NotificationManager . send_message ( local_player , " Cleanser Active! (5 cells) " , NotificationManager . MessageType . POWERUP )
func deactivate_cleanser ( player_id : int ) - > void :
@@ -844,6 +1609,16 @@ func use_cleanser_cell(player_id: int) -> bool:
return false
return true
func notify_movement_stopped ( player_id : int , pos : Vector2i ) - > void :
" " " Cleanser also ends when the player comes to rest on a safe (non-sticky)
cell — they're clear of the candy, so immunity is no longer needed (#072).
Called from PlayerMovementManager when a move chain settles. Gauntlet-only;
a no-op when the player has no active cleanser. " " "
if not cleanser_active . has ( player_id ) :
return
if not is_sticky_cell ( pos ) :
deactivate_cleanser ( player_id )
@rpc ( " any_peer " , " call_local " , " reliable " )
func rpc_activate_cleanser ( pid : int ) - > void :
" " " RPC for clients to activate cleanser on server. " " "
@@ -979,10 +1754,10 @@ func _update_hud_phase(phase_name: String) -> void:
if phase_label :
var icon = " 🍬 "
match phase_name :
" Rout e Pressure" :
" Middl e Pressure" :
icon = " ⚠️ "
phase_label . add_theme_color_override ( " font_color " , Color ( 1.0 , 0.8 , 0.2 ) ) # Warning gold
" Survival! " :
" Inner Survival" :
icon = " 💀 "
phase_label . add_theme_color_override ( " font_color " , Color ( 1.0 , 0.3 , 0.3 ) ) # Danger red
_ :
@@ -1091,7 +1866,7 @@ func _respawn_mission_tiles() -> void:
# Shuffle and place tiles
empty_cells . shuffle ( )
var tiles_to_place = min ( empty_cells . size ( ) , 20 ) # Limit respawn coun t
var tiles_to_place = min ( empty_cells . size ( ) , 6 ) # Light refill — avoid flooding the board while players collec t
for i in range ( tiles_to_place ) :
var pos = empty_cells [ i ]