refactor: enhance test framework with automated resource tracking and scripted error capture capabilities
This commit is contained in:
+1027
-252
@@ -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 volleys
|
||||
var volley_size: int = 5
|
||||
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 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 "Route Pressure"
|
||||
return "Middle 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 %dx%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:
|
||||
"""Flicker 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 for the animation
|
||||
pass
|
||||
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_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:
|
||||
"Route Pressure":
|
||||
"Middle 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 count
|
||||
var tiles_to_place = min(empty_cells.size(), 6) # Light refill — avoid flooding the board while players collect
|
||||
|
||||
for i in range(tiles_to_place):
|
||||
var pos = empty_cells[i]
|
||||
|
||||
Reference in New Issue
Block a user