refactor: enhance test framework with automated resource tracking and scripted error capture capabilities

This commit is contained in:
2026-06-26 09:40:17 +08:00
parent 948a99cf90
commit 00f9d98f4b
58 changed files with 3594 additions and 1289 deletions
+3 -3
View File
@@ -16,7 +16,7 @@ static func from_string(mode: String) -> Mode:
return Mode.STOP_N_GO
"Tekton Doors":
return Mode.TEKTON_DOORS
"Candy Cannon Survival":
"Candy Pump Survival":
return Mode.GAUNTLET
_:
return Mode.FREEMODE
@@ -30,7 +30,7 @@ static func mode_to_string(mode: Mode) -> String:
Mode.TEKTON_DOORS:
return "Tekton Doors"
Mode.GAUNTLET:
return "Candy Cannon Survival"
return "Candy Pump Survival"
_:
return "Freemode"
@@ -38,4 +38,4 @@ static func is_restricted(mode: Mode) -> bool:
return mode == Mode.STOP_N_GO or mode == Mode.TEKTON_DOORS or mode == Mode.GAUNTLET
static func get_all_modes() -> Array[String]:
return ["Freemode", "Stop n Go", "Tekton Doors", "Candy Cannon Survival"]
return ["Freemode", "Stop n Go", "Tekton Doors", "Candy Pump Survival"]
+14 -13
View File
@@ -12,11 +12,12 @@ var player: Node3D
@export var z_offset: float = 12.0
@export var default_y: float = 19.636
var bounds_gauntlet = { "min_x": 0.0, "max_x": 20.0, "min_z": 10.0, "max_z": 32.0 }
# Bounds Definitions { min_x, max_x, min_z, max_z }
var bounds_freemode = { "min_x": 3.0, "max_x": 11.0, "min_z": 13.0, "max_z": 22.5 }
var bounds_stop_n_go = { "min_x": 3.0, "max_x": 19.5, "min_z": 13.0, "max_z": 19.5 }
var bounds_doors = { "min_x": 7.0, "max_x": 7.0, "min_z": 25.8, "max_z": 25.8 } # Static overlook
var bounds_gauntlet = { "min_x": 0.0, "max_x": 20.0, "min_z": 0.0, "max_z": 20.0 } # 20x20 arena
func initialize(p_camera: Camera3D, _p_shake_manager: Node):
camera = p_camera
@@ -29,38 +30,38 @@ func set_player(p_player: Node3D):
func _physics_process(delta):
if not player or not camera or not is_instance_valid(player):
return
var target_pos = _calculate_target_position()
# Smoothly interpolate to target
camera.position = camera.position.lerp(target_pos, smooth_speed * delta)
func _calculate_target_position() -> Vector3:
var player_pos = player.global_position
var mode = LobbyManager.get_game_mode()
# Initial target based on player position + offsets
var target_x = player_pos.x
var target_y = default_y
var target_z = player_pos.z + z_offset
# Apply Mode-Specific Clamping
var mode = LobbyManager.get_game_mode()
var bounds = bounds_freemode # Default
if mode == GameMode.Mode.STOP_N_GO:
if mode == GameMode.Mode.GAUNTLET:
bounds = bounds_gauntlet
elif mode == GameMode.Mode.STOP_N_GO:
bounds = bounds_stop_n_go
elif mode == GameMode.Mode.TEKTON_DOORS:
bounds = bounds_doors
target_y = 32.3 # Doors uses a higher overlook
elif mode == GameMode.Mode.GAUNTLET:
bounds = bounds_gauntlet
# Clamp X and Z
target_x = clamp(target_x, bounds.min_x, bounds.max_x)
target_z = clamp(target_z, bounds.min_z, bounds.max_z)
# Special case for Setup C in Freemode (Lower Y at bottom edges)
if mode == GameMode.Mode.FREEMODE and target_z > 21.0:
target_y = 19.22636
return Vector3(target_x, target_y, target_z)
+1027 -252
View File
@@ -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.53)
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 (00.8s): amber glow ramps alpha 0→1.
• Flash (0.81.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]
+30 -26
View File
@@ -33,8 +33,8 @@ signal doors_required_goals_changed(goals: int)
# Gauntlet settings signals
signal gauntlet_round_duration_changed(duration: int)
signal gauntlet_cannon_interval_changed(interval: int)
signal gauntlet_volley_size_changed(size: int)
signal gauntlet_growth_interval_changed(interval: float)
signal gauntlet_cells_per_tick_changed(cells: Dictionary)
# Room data structure
var current_room: Dictionary = {}
@@ -81,8 +81,12 @@ var doors_required_goals: int = 8
# Gauntlet settings
var gauntlet_round_duration: int = 180
var gauntlet_cannon_interval: int = 5
var gauntlet_volley_size: int = 5
var gauntlet_growth_interval: float = 3.0 # seconds between growth ticks
var gauntlet_cells_per_tick: Dictionary = {
"phase1": [4, 6],
"phase2": [6, 8],
"phase3": [8, 10],
}
# Rematch tracking
var rematch_votes: Array = [] # [player_id, ...]
@@ -90,7 +94,7 @@ var rematch_votes: Array = [] # [player_id, ...]
# Character and area selection
var available_characters: Array[String] = ["Copper", "Dabro", "Gatot", "Pip", "Random"]
var available_areas: Array[String] = []
var available_game_modes: Array[String] = ["Freemode", "Stop n Go", "Candy Cannon Survival"]
var available_game_modes: Array[String] = ["Freemode", "Stop n Go", "Candy Pump Survival"]
var selected_area: String = "Freemode Arena" # Host-controlled
var game_mode: String = "Freemode" # Host-controlled
var local_character_index: int = 0 # Local player's character index
@@ -145,7 +149,7 @@ func _update_available_areas(mode: String) -> void:
available_areas = ["Freemode Arena", "Classic", "Colloseum"]
"Stop n Go":
available_areas = ["Stop N Go Arena"]
"Candy Cannon Survival":
"Candy Pump Survival":
available_areas = ["Gauntlet Arena"]
_:
available_areas = ["Classic"]
@@ -562,23 +566,23 @@ func sync_gauntlet_round_duration(duration: int) -> void:
gauntlet_round_duration = duration
emit_signal("gauntlet_round_duration_changed", duration)
func set_gauntlet_cannon_interval(interval: int) -> void:
gauntlet_cannon_interval = interval
if is_host: rpc("sync_gauntlet_cannon_interval", interval)
func set_gauntlet_growth_interval(interval: float) -> void:
gauntlet_growth_interval = interval
if is_host: rpc("sync_gauntlet_growth_interval", interval)
@rpc("authority", "call_local", "reliable")
func sync_gauntlet_cannon_interval(interval: int) -> void:
gauntlet_cannon_interval = interval
emit_signal("gauntlet_cannon_interval_changed", interval)
func sync_gauntlet_growth_interval(interval: float) -> void:
gauntlet_growth_interval = interval
emit_signal("gauntlet_growth_interval_changed", interval)
func set_gauntlet_volley_size(size: int) -> void:
gauntlet_volley_size = size
if is_host: rpc("sync_gauntlet_volley_size", size)
func set_gauntlet_cells_per_tick(cells: Dictionary) -> void:
gauntlet_cells_per_tick = cells
if is_host: rpc("sync_gauntlet_cells_per_tick", cells)
@rpc("authority", "call_local", "reliable")
func sync_gauntlet_volley_size(size: int) -> void:
gauntlet_volley_size = size
emit_signal("gauntlet_volley_size_changed", size)
func sync_gauntlet_cells_per_tick(cells: Dictionary) -> void:
gauntlet_cells_per_tick = cells
emit_signal("gauntlet_cells_per_tick_changed", cells)
# =============================================================================
# Character Selection
@@ -738,8 +742,8 @@ func set_game_mode(mode: String) -> void:
set_area("Stop n Go Area")
elif mode == "Tekton Doors" and "Tekton Doors Area" in available_areas:
set_area("Tekton Doors Area")
elif mode == "Gauntlet" and "Candy Pump Arena" in available_areas:
set_area("Candy Pump Arena")
elif mode == "Candy Pump Survival" and "Gauntlet Arena" in available_areas:
set_area("Gauntlet Arena")
@rpc("authority", "call_local", "reliable")
func sync_game_mode(mode: String) -> void:
@@ -754,8 +758,8 @@ func sync_game_mode(mode: String) -> void:
selected_area = "Stop n Go Area"
elif mode == "Tekton Doors" and "Tekton Doors Area" in available_areas:
selected_area = "Tekton Doors Area"
elif mode == "Gauntlet" and "Candy Pump Arena" in available_areas:
selected_area = "Candy Pump Arena"
elif mode == "Candy Pump Survival" and "Gauntlet Arena" in available_areas:
selected_area = "Gauntlet Arena"
elif selected_area not in available_areas:
selected_area = available_areas[0]
@@ -786,8 +790,8 @@ func start_game(force: bool = false) -> void:
rpc("sync_doors_required_goals", doors_required_goals)
# Sync gauntlet settings
rpc("sync_gauntlet_round_duration", gauntlet_round_duration)
rpc("sync_gauntlet_cannon_interval", gauntlet_cannon_interval)
rpc("sync_gauntlet_volley_size", gauntlet_volley_size)
rpc("sync_gauntlet_growth_interval", gauntlet_growth_interval)
rpc("sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick)
# Sync game mode
rpc("sync_game_mode", game_mode)
@@ -864,8 +868,8 @@ func request_room_info(requester_id: int, requester_name: String, requester_char
rpc_id(requester_id, "sync_doors_refresh_time", doors_refresh_time)
rpc_id(requester_id, "sync_doors_required_goals", doors_required_goals)
rpc_id(requester_id, "sync_gauntlet_round_duration", gauntlet_round_duration)
rpc_id(requester_id, "sync_gauntlet_cannon_interval", gauntlet_cannon_interval)
rpc_id(requester_id, "sync_gauntlet_volley_size", gauntlet_volley_size)
rpc_id(requester_id, "sync_gauntlet_growth_interval", gauntlet_growth_interval)
rpc_id(requester_id, "sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick)
rpc_id(requester_id, "sync_game_mode", game_mode)
rpc_id(requester_id, "sync_area", selected_area)
+13 -3
View File
@@ -29,10 +29,20 @@ func fetch_mails() -> void:
var payload = result.get("data", {})
if payload and payload is Dictionary:
mails = payload.get("mails", [])
var raw_mails = payload.get("mails", [])
if typeof(raw_mails) == TYPE_ARRAY:
mails = raw_mails
elif typeof(raw_mails) == TYPE_DICTIONARY:
mails = raw_mails.values()
else:
mails = []
var state = payload.get("state", {})
claimed_ids = state.get("claimed_ids", [])
read_ids = state.get("read_ids", [])
if typeof(state) != TYPE_DICTIONARY:
state = {}
var raw_claimed_ids = state.get("claimed_ids", [])
var raw_read_ids = state.get("read_ids", [])
claimed_ids = raw_claimed_ids if typeof(raw_claimed_ids) == TYPE_ARRAY else []
read_ids = raw_read_ids if typeof(raw_read_ids) == TYPE_ARRAY else []
# Sort by date descending
mails.sort_custom(func(a, b):
+21 -15
View File
@@ -135,13 +135,8 @@ func simple_move_to(grid_position: Vector2i) -> bool:
var main_gauntlet = player.get_tree().root.get_node_or_null("Main")
if main_gauntlet and main_gauntlet.get("gauntlet_manager"):
gm = main_gauntlet.gauntlet_manager
# Check if currently trapped
if gm and gm.is_active:
var pid = player.get("peer_id") if "peer_id" in player else -1
if pid != -1 and gm.trapped_players.has(pid):
print("[Move] Failed: Player is trapped in a sticky cell")
return false
# Sticky no longer hard-traps — players are slowed instead and can move freely.
# Check for Tekton interaction (Charged Strike Mode)
# If moving into a Tekton's space while Charged, trigger knock
@@ -154,19 +149,19 @@ func simple_move_to(grid_position: Vector2i) -> bool:
player.knock_tekton()
return false # Don't move into the tile, just knock
# If moving into a sticky cell, trigger trap (unless cleanser active)
# If moving into a sticky cell: slow the player (unless cleanser active,
# which clears the cell instead). Sticky no longer hard-traps.
if gm and gm.is_active and gm.is_sticky_cell(grid_position):
var pid = player.get("peer_id") if "peer_id" in player else -1
if pid != -1 and gm.is_cleanser_active(pid):
# Cleanser immunity: clear sticky cell, use one cell, don't trap
# Cleanser immunity: clear sticky cell, use one cell, don't slow
gm.clear_sticky_cell(grid_position)
gm.use_cleanser_cell(pid)
print("[Move] Cleanser cleared sticky cell at %s (%d cells left)" % [grid_position, gm.cleanser_cells_left.get(pid, 0)])
else:
print("[Move] Player stepping into sticky cell at %s" % grid_position)
movement_queue.clear()
print("[Move] Player stepping into sticky cell at %s — slowed" % grid_position)
if player.is_multiplayer_authority() or multiplayer.is_server():
gm._trap_player(player)
gm.apply_sticky_slow(player)
rotate_towards_target(grid_position)
@@ -348,9 +343,9 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
gm_sticky.use_cleanser_cell(push_pid)
print("[Move] Cleanser cleared push-into-sticky at %s" % pushed_to_pos)
else:
print("[Move] Player pushed into sticky cell at %s" % pushed_to_pos)
print("[Move] Player pushed into sticky cell at %s — slowed" % pushed_to_pos)
if multiplayer.is_server() or other_player.is_multiplayer_authority():
gm_sticky._trap_player(other_player)
gm_sticky.apply_sticky_slow(other_player)
# 2. Apply freeze/stun effect
var stun_duration = 1.0 if (gm_push and gm_push.is_active) else 1.5
@@ -397,7 +392,7 @@ func set_speed_multiplier(multiplier: float):
func _on_movement_finished():
if not movement_queue.is_empty():
var next_target = movement_queue.pop_front()
# Use a small delay or call_deferred to avoid recursion issues,
# Use a small delay or call_deferred to avoid recursion issues,
# but keep it snappy by executing immediately if possible.
if not simple_move_to(next_target):
# If next move failed, clear queue and signal finished
@@ -406,6 +401,17 @@ func _on_movement_finished():
emit_signal("movement_finished")
else:
current_move_direction = Vector2i.ZERO
# Gauntlet (#072): a Cleanser ends early once the player rests on a safe
# cell. Gated on gm.is_active so other game modes are never affected.
var gm = null
var main_node = player.get_tree().root.get_node_or_null("Main")
if main_node and main_node.get("gauntlet_manager"):
gm = main_node.gauntlet_manager
if gm and gm.is_active and player.get("current_position") != null:
var mpid = player.get("peer_id") if "peer_id" in player else -1
if mpid != -1 and gm.is_cleanser_active(mpid):
if multiplayer.is_server() or player.is_multiplayer_authority():
gm.notify_movement_stopped(mpid, player.current_position)
emit_signal("movement_finished")
func move_to_clicked_position(grid_position: Vector2i) -> bool:
+3 -3
View File
@@ -24,10 +24,10 @@ const SCHEMA = {
"doors_refresh_time": {"type": TYPE_INT, "default": 25, "min": 15, "max": 40},
"doors_required_goals": {"type": TYPE_INT, "default": 8, "min": 5, "max": 12}
},
"Candy Cannon Survival": {
"Candy Pump Survival": {
"match_duration": {"type": TYPE_INT, "default": 180, "min": 60, "max": 600},
"gauntlet_cannon_interval": {"type": TYPE_FLOAT, "default": 5.0, "min": 2.0, "max": 10.0},
"gauntlet_volley_size": {"type": TYPE_INT, "default": 5, "min": 3, "max": 15}
"gauntlet_growth_interval": {"type": TYPE_FLOAT, "default": 3.0, "min": 1.0, "max": 10.0},
"gauntlet_cells_per_tick": {"type": TYPE_DICTIONARY, "default": {"phase1": [4, 6], "phase2": [6, 8], "phase3": [8, 10]}}
}
}
+285 -61
View File
@@ -197,14 +197,15 @@ func _setup_columns() -> void:
_mail_root = mail_tree.create_item()
# Chat Storage
chat_tree.set_column_title(0, "Sender")
chat_tree.set_column_title(1, "Content")
chat_tree.set_column_title(2, "Date")
chat_tree.set_column_title(3, "ID")
chat_tree.set_column_custom_minimum_width(0, 100)
chat_tree.set_column_expand(1, true)
chat_tree.set_column_custom_minimum_width(2, 120)
chat_tree.set_column_custom_minimum_width(3, 100)
chat_tree.set_column_title(0, "Select")
chat_tree.set_column_title(1, "Sender")
chat_tree.set_column_title(2, "Content")
chat_tree.set_column_title(3, "Date / ID")
chat_tree.set_column_custom_minimum_width(0, 70)
chat_tree.set_column_expand(0, false)
chat_tree.set_column_custom_minimum_width(1, 100)
chat_tree.set_column_expand(2, true)
chat_tree.set_column_custom_minimum_width(3, 180)
_chat_tree_root = chat_tree.create_item()
func _connect_signals() -> void:
@@ -256,8 +257,8 @@ func _connect_signals() -> void:
# Chat Storage actions
load_messages_btn.pressed.connect(_on_load_chat_messages)
refresh_chat_btn.pressed.connect(_on_load_chat_messages)
delete_selected_btn.pressed.connect(_on_delete_chat_message)
refresh_chat_btn.pressed.connect(_on_load_more_chat_messages)
delete_selected_btn.pressed.connect(_on_delete_selected_chat_messages)
# =============================================================================
# Core Panel Logic
@@ -281,6 +282,8 @@ func _on_tab_changed(tab_index: int) -> void:
await _load_leaderboard()
elif tab_index == 2:
await _load_daily_rewards_config()
elif tab_index == 3:
_update_announcement_count()
elif tab_index == 4:
await _load_mail()
elif tab_index == 5:
@@ -371,12 +374,15 @@ func _on_user_tree_button_clicked(item: TreeItem, _col: int, _id: int, _mouse: i
func _show_edit_user_dialog(user: Dictionary) -> void:
var uid: String = user.get("user_id", "")
var uname: String = user.get("username", "")
var display_name: String = user.get("display_name", uname)
var role: String = user.get("role", "player")
var banned: bool = user.get("banned", false)
var detail := await _rpc("admin_get_user_detail", {"user_id": uid})
var detail_user: Dictionary = detail.get("user", {}) if not detail.has("error") else {}
var dialog := AcceptDialog.new()
dialog.title = "Edit User: " + uname
dialog.min_size = Vector2i(380, 260)
dialog.min_size = Vector2i(460, 420)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 8)
@@ -385,6 +391,25 @@ func _show_edit_user_dialog(user: Dictionary) -> void:
id_lbl.add_theme_color_override("font_color", CLR_DIM)
vbox.add_child(id_lbl)
var email_lbl := Label.new()
var email = detail_user.get("email", "")
var verified = detail_user.get("email_verified", false)
email_lbl.text = "Email: %s (%s)" % [email if not str(email).is_empty() else "none", "verified" if verified else "unverified"]
email_lbl.add_theme_color_override("font_color", CLR_DIM)
vbox.add_child(email_lbl)
var name_grid := GridContainer.new()
name_grid.columns = 2
name_grid.add_theme_constant_override("h_separation", 8)
name_grid.add_theme_constant_override("v_separation", 8)
var username_lbl := Label.new(); username_lbl.text = "Username:"; name_grid.add_child(username_lbl)
var username_input := LineEdit.new(); username_input.text = detail_user.get("username", uname); name_grid.add_child(username_input)
var display_lbl := Label.new(); display_lbl.text = "Display Name:"; name_grid.add_child(display_lbl)
var display_input := LineEdit.new(); display_input.text = detail_user.get("display_name", display_name); name_grid.add_child(display_input)
var password_lbl := Label.new(); password_lbl.text = "New Password:"; name_grid.add_child(password_lbl)
var password_input := LineEdit.new(); password_input.placeholder_text = "Leave empty to keep"; password_input.secret = true; name_grid.add_child(password_input)
vbox.add_child(name_grid)
var role_hbox := HBoxContainer.new()
var role_lbl := Label.new()
role_lbl.text = "Role: "
@@ -419,18 +444,31 @@ func _show_edit_user_dialog(user: Dictionary) -> void:
save_btn.pressed.connect(func():
var new_role: String = roles[role_option.selected]
await _save_user_edit(uid, uname, new_role, ban_check.button_pressed, reason_input.text)
await _save_user_edit(uid, username_input.text.strip_edges(), display_input.text.strip_edges(), password_input.text, new_role, ban_check.button_pressed, reason_input.text)
dialog.queue_free()
)
func _save_user_edit(uid: String, uname: String, new_role: String, new_banned: bool, reason: String) -> void:
func _save_user_edit(uid: String, uname: String, display_name: String, new_password: String, new_role: String, new_banned: bool, reason: String) -> void:
_set_status("Saving...")
var identity_res := await _rpc("admin_update_user_identity", {
"user_id": uid,
"username": uname,
"display_name": display_name
})
if identity_res.has("error"):
_set_status("Identity save failed: " + str(identity_res.error), CLR_STATUS_ERR)
return
if not new_password.strip_edges().is_empty():
var password_res := await _rpc("admin_set_user_password", {"user_id": uid, "password": new_password})
if password_res.has("error"):
_set_status("Password save failed: " + str(password_res.error), CLR_STATUS_ERR)
return
await _rpc("admin_set_user_role", {"user_id": uid, "role": new_role})
if new_banned:
await _rpc("admin_ban_player", {"user_id": uid, "reason": reason, "duration_hours": 0})
else:
await _rpc("admin_unban_player", {"user_id": uid})
_set_status("Saved: " + uname, CLR_STATUS_OK)
_set_status("Saved: " + display_name, CLR_STATUS_OK)
await _load_users()
# =============================================================================
@@ -529,19 +567,47 @@ func _on_history_pressed() -> void:
return
var uid = selected_data[0].get("user_id", "")
_set_status("Fetching history for user...", CLR_STATUS_OK)
_set_status("Fetching user details...", CLR_STATUS_OK)
var detail_res = await _rpc("admin_get_user_detail", {"user_id": uid})
var res = await _rpc("admin_get_user_history", {"user_id": uid})
if res.has("error"):
_set_status("Failed to get history: " + str(res.error), CLR_STATUS_ERR)
if detail_res.has("error") and res.has("error"):
_set_status("Failed to get user details: " + str(detail_res.error), CLR_STATUS_ERR)
return
_set_status("History loaded.", CLR_STATUS_OK)
_set_status("User details loaded.", CLR_STATUS_OK)
var h = res.get("history", {})
var text = "[b]=== USER HISTORY ===[/b]\n"
var h = res.get("history", {}) if not res.has("error") else {}
var details = detail_res if not detail_res.has("error") else {}
var detail_user: Dictionary = details.get("user", {})
var text = "[b]=== USER DETAIL ===[/b]\n"
text += "User ID: " + uid + "\n\n"
text += "[b]-- Account --[/b]\n"
text += "Username: %s\n" % detail_user.get("username", "")
text += "Display Name: %s\n" % detail_user.get("display_name", "")
text += "Email: %s (%s)\n" % [detail_user.get("email", "none"), "verified" if detail_user.get("email_verified", false) else "unverified"]
text += "Created: %s\n" % str(detail_user.get("create_time", ""))
text += "Wallet: %s\n" % str(detail_user.get("wallet", {}))
text += "Subscription: %s\n\n" % str(details.get("subscription", {}))
text += "[b]-- Friends --[/b]\n"
var friends = details.get("friends", [])
if friends.is_empty():
text += "No friends found.\n"
else:
for f in friends:
text += "- %s (%s) state=%s\n" % [f.get("username", ""), f.get("user_id", ""), str(f.get("state", ""))]
text += "\n"
text += "[b]-- Purchase History / Receipts --[/b]\n"
var purchases = details.get("purchases", [])
if purchases.is_empty():
text += "No purchases found.\n"
else:
for p in purchases:
text += "- %s: %s\n" % [p.get("key", ""), str(p.get("value", {}))]
text += "\n"
# Logins
text += "[b]-- Recent Logins --[/b]\n"
var logins = h.get("logins", [])
@@ -575,6 +641,17 @@ func _on_history_pressed() -> void:
else:
for m in matches:
text += "- " + str(m) + "\n"
text += "\n[b]-- Storage Objects --[/b]\n"
var storage = details.get("storage", {})
if storage.is_empty():
text += "No storage objects found.\n"
else:
for collection in storage.keys():
var objects = storage[collection]
text += "\n[b]%s[/b] (%d)\n" % [collection, objects.size()]
for obj in objects:
text += "- %s: %s\n" % [obj.get("key", ""), str(obj.get("value", {}))]
history_text.text = text
history_dialog.popup_centered()
@@ -614,7 +691,15 @@ func _load_leaderboard() -> void:
return
var raw_lb = res.get("leaderboard", [])
lb_data = raw_lb if typeof(raw_lb) == TYPE_ARRAY else []
if typeof(raw_lb) == TYPE_ARRAY:
lb_data = raw_lb
elif typeof(raw_lb) == TYPE_DICTIONARY:
lb_data = raw_lb.values()
else:
lb_data = []
if lb_data.is_empty():
lb_data = await _fetch_native_leaderboard_for_admin()
count_label.text = "%d records" % lb_data.size()
lb_data.sort_custom(func(a, b): return a.get("high_score", 0) > b.get("high_score", 0))
@@ -631,6 +716,37 @@ func _load_leaderboard() -> void:
rank += 1
_set_status("")
func _fetch_native_leaderboard_for_admin() -> Array:
var result = await NakamaManager.client.list_leaderboard_records_async(
NakamaManager.session,
"global_high_score",
[],
null,
100
)
if result.is_exception():
push_warning("[AdminPanel] Native leaderboard load failed: " + result.get_exception().message)
return []
var data: Array = []
for record in result.records:
var meta: Dictionary = {}
if record.metadata and not record.metadata.is_empty():
var parsed = JSON.parse_string(record.metadata)
if parsed is Dictionary:
meta = parsed
data.append({
"user_id": record.owner_id,
"username": record.username,
"display_name": record.username if (record.username and not record.username.is_empty()) else "Unknown",
"avatar_url": meta.get("avatar_url", ""),
"loadout_character": meta.get("loadout_character", "Copper"),
"high_score": int(record.score),
"games_played": int(meta.get("games_played", 0)),
"games_won": int(meta.get("games_won", 0))
})
return data
func _on_lb_tree_button_clicked(item: TreeItem, _col: int, _id: int, _mouse: int) -> void:
_show_edit_score_dialog(item.get_metadata(0))
@@ -729,6 +845,7 @@ func _load_daily_rewards_config() -> void:
month_option_btn.select(0)
_build_dr_grid()
_update_daily_reward_count()
_set_status("Config Loaded", CLR_STATUS_OK)
func _on_dr_month_selected(index: int) -> void:
@@ -737,6 +854,7 @@ func _on_dr_month_selected(index: int) -> void:
_current_dr_month = month_option_btn.get_item_metadata(index)
_build_dr_grid()
_update_daily_reward_count()
func _save_current_grid_to_dict() -> void:
if _current_dr_month.is_empty(): return
@@ -784,6 +902,11 @@ func _build_dr_grid() -> void:
spin.value = int(rdata)
opt.select(0)
func _update_daily_reward_count() -> void:
var rewards = _daily_reward_config_data.get(_current_dr_month, [])
var count: int = rewards.size() if typeof(rewards) == TYPE_ARRAY else 0
count_label.text = "%d reward days" % count
func _save_daily_rewards_config() -> void:
_save_current_grid_to_dict()
_set_status("Saving config...")
@@ -802,7 +925,15 @@ func _on_add_reward_pressed() -> void:
row.visible = true
rewards_list.add_child(row)
var remove_btn = row.get_node("RemoveBtn") as Button
remove_btn.pressed.connect(func(): row.queue_free())
remove_btn.pressed.connect(func(): row.queue_free(); _update_announcement_count())
_update_announcement_count()
func _update_announcement_count() -> void:
var count := 0
for child in rewards_list.get_children():
if child.visible:
count += 1
count_label.text = "%d rewards attached" % count
func _on_find_user() -> void:
var input = target_user_edit.text.strip_edges()
@@ -927,6 +1058,7 @@ func _on_send_mail() -> void:
end_date_edit.clear_date()
for child in rewards_list.get_children():
if child.visible: child.queue_free()
_update_announcement_count()
# =============================================================================
# TAB 5: MAIL MANAGER
@@ -1245,6 +1377,7 @@ func _save_featured_banners() -> void:
# =============================================================================
func _load_chat_config() -> void:
chat_status_label.text = "Loading config..."
count_label.text = "chat config"
var res := await _rpc("admin_get_chat_config", {})
if res.has("error"):
chat_status_label.text = "Failed: " + str(res.error)
@@ -1344,18 +1477,19 @@ func _on_save_chat_config() -> void:
func _on_load_chat_messages() -> void:
var channel_id := chat_channel_id_edit.text.strip_edges()
if channel_id.is_empty():
_set_status("Enter a Channel ID first.", CLR_STATUS_ERR)
return
# Default to the global lobby room rather than erroring out.
channel_id = "social_global"
chat_channel_id_edit.text = channel_id
# Auto-resolve "social_global" to the actual Nakama Channel ID if the admin is in the lobby
# Best-effort: resolve "social_global" to the real hashed Nakama Channel ID so
# the admin sees it in the UI. If resolution fails (not in lobby / socket
# down), fall through with the room name — the server resolves it
# authoritatively via nk.channel_id_build.
if channel_id == "social_global":
var lobby = get_tree().get_first_node_in_group("Lobby")
if lobby and lobby.get("chat") and lobby.chat.get("_chat_channel"):
channel_id = lobby.chat._chat_channel.id
chat_channel_id_edit.text = channel_id # Update UI so admin sees the real ID
else:
_set_status("Cannot resolve social_global. Join chat first.", CLR_STATUS_ERR)
return
var resolved := await _resolve_global_chat_channel_id()
if not resolved.is_empty():
channel_id = resolved
chat_channel_id_edit.text = channel_id # show the admin the real ID
_chat_channel_id = channel_id
_chat_cursor = ""
@@ -1364,6 +1498,35 @@ func _on_load_chat_messages() -> void:
await _fetch_chat_messages_batch()
func _resolve_global_chat_channel_id() -> String:
# Nakama Room channel IDs are deterministically hashed from the type and room name.
# For type=2 (Room) and name="social_global", the ID format is always:
# "2." + uri_encoded_room_name + "." # no domain needed for rooms.
# But Nakama's format often just uses "2.RoomName." - let's ensure we try the exact determinism if socket fails.
var lobby = get_tree().get_first_node_in_group("Lobby")
if lobby and lobby.get("chat") and lobby.chat.get("_chat_channel"):
return lobby.chat._chat_channel.id
var socket = NakamaManager.socket
if socket and socket.is_connected_to_host():
var result = await socket.join_chat_async("social_global", NakamaSocket.ChannelType.Room, true, false)
if not result.is_exception():
return result.id
# Fallback if no socket or join failed: construct the exact ID the Web UI expects.
# Type 2 (Room), Name "social_global"
return "2." + "social_global".uri_encode() + "."
func _on_load_more_chat_messages() -> void:
if _chat_channel_id.is_empty():
await _on_load_chat_messages()
return
if _chat_cursor.is_empty():
_set_status("No more messages to load.", CLR_STATUS_OK)
return
await _fetch_chat_messages_batch()
func _fetch_chat_messages_batch() -> void:
_set_status("Loading messages...")
var payload := {
@@ -1380,19 +1543,42 @@ func _fetch_chat_messages_batch() -> void:
var msgs = res.get("messages", [])
var next_cursor = res.get("next_cursor", "")
if (typeof(msgs) == TYPE_DICTIONARY and msgs.is_empty()) or (typeof(msgs) == TYPE_ARRAY and msgs.is_empty()):
var fallback = await NakamaManager.client.list_channel_messages_async(NakamaManager.session, _chat_channel_id, 50, false, _chat_cursor)
if not fallback.is_exception():
msgs = fallback.messages if fallback.messages else []
next_cursor = fallback.next_cursor
else:
_set_status("Failed: " + fallback.get_exception().message, CLR_STATUS_ERR)
return
if typeof(msgs) == TYPE_DICTIONARY:
msgs = msgs.values()
elif typeof(msgs) != TYPE_ARRAY:
msgs = []
var added_count := 0
for msg in msgs:
for raw_msg in msgs:
var msg := _normalize_chat_storage_message(raw_msg)
if msg.is_empty():
continue
_chat_messages_data.append(msg)
var item := _chat_tree_root.create_child()
item.set_text(0, msg.get("username", msg.get("sender_id", "?").substr(0, 8)))
item.set_text(1, msg.get("content", ""))
item.set_text(2, msg.get("create_time", "").substr(0, 19).replace("T", " "))
item.set_cell_mode(0, TreeItem.CELL_MODE_CHECK)
item.set_editable(0, true)
item.set_text(1, msg.get("username", msg.get("sender_id", "?").substr(0, 8)))
item.set_text(2, _format_chat_storage_content(msg.get("content", "")))
item.set_text(3, msg.get("create_time", "").substr(0, 19).replace("T", " "))
var mid = msg.get("message_id", "")
item.set_text(3, mid)
item.set_tooltip_text(3, mid)
item.set_metadata(0, msg)
added_count += 1
count_label.text = "%d messages loaded" % _chat_messages_data.size()
chat_tree.queue_redraw()
if added_count == 0:
_set_status("No stored messages returned for channel: " + _chat_channel_id, CLR_STATUS_ERR)
return
if not next_cursor.is_empty():
_chat_cursor = next_cursor
@@ -1401,37 +1587,75 @@ func _fetch_chat_messages_batch() -> void:
_chat_cursor = ""
_set_status("All messages loaded.", CLR_STATUS_OK)
func _on_delete_chat_message() -> void:
var item = chat_tree.get_selected()
if not item:
_set_status("Select a message to delete.", CLR_STATUS_ERR)
return
func _format_chat_storage_content(content) -> String:
if typeof(content) == TYPE_DICTIONARY:
return str(content.get("msg", content))
var msg = item.get_metadata(0)
if not msg:
return
var text := str(content)
var parsed = JSON.parse_string(text)
if typeof(parsed) == TYPE_DICTIONARY:
return str(parsed.get("msg", text))
return text
var msg_id = msg.get("message_id", "")
if msg_id.is_empty():
func _normalize_chat_storage_message(raw_msg) -> Dictionary:
if typeof(raw_msg) == TYPE_DICTIONARY:
return raw_msg
if typeof(raw_msg) != TYPE_OBJECT:
return {}
return {
"message_id": raw_msg.message_id,
"sender_id": raw_msg.sender_id,
"username": raw_msg.username,
"content": raw_msg.content,
"create_time": raw_msg.create_time,
"update_time": raw_msg.update_time,
"channel_id": raw_msg.channel_id
}
func _on_delete_selected_chat_messages() -> void:
var items := _get_checked_chat_items()
if items.is_empty():
var selected = chat_tree.get_selected()
if selected:
items.append(selected)
if items.is_empty():
_set_status("Select one or more messages to delete.", CLR_STATUS_ERR)
return
var confirm := ConfirmationDialog.new()
confirm.title = "Delete Message?"
confirm.dialog_text = "Permanently delete message from " + msg.get("username", "?") + "?"
confirm.title = "Delete %d Message(s)?" % items.size()
confirm.dialog_text = "Permanently delete selected chat messages?"
add_child(confirm)
confirm.popup_centered()
confirm.confirmed.connect(func():
_set_status("Deleting message...")
var res = await _rpc("admin_delete_channel_message", {
"channel_id": _chat_channel_id,
"message_id": msg_id
})
if res.get("success", false):
_set_status("Message deleted!", CLR_STATUS_OK)
chat_tree.get_root().remove_child(item)
item.free()
count_label.text = "%d messages loaded" % _chat_messages_data.size()
else:
_set_status("Failed: " + str(res.get("error", "")), CLR_STATUS_ERR)
_set_status("Deleting %d message(s)..." % items.size())
var deleted := 0
for item in items:
var msg = item.get_metadata(0)
if typeof(msg) != TYPE_DICTIONARY:
continue
var msg_id = msg.get("message_id", "")
if msg_id.is_empty():
continue
var res = await _rpc("admin_delete_channel_message", {
"channel_id": _chat_channel_id,
"message_id": msg_id
})
if res.get("success", false):
deleted += 1
_chat_messages_data.erase(msg)
_chat_tree_root.remove_child(item)
item.free()
count_label.text = "%d messages loaded" % _chat_messages_data.size()
_set_status("Deleted %d message(s)" % deleted, CLR_STATUS_OK if deleted > 0 else CLR_STATUS_ERR)
confirm.queue_free()
)
func _get_checked_chat_items() -> Array:
var items: Array = []
var child = _chat_tree_root.get_first_child() if _chat_tree_root else null
while child:
if child.is_checked(0):
items.append(child)
child = child.get_next()
return items