refactor: enhance test framework with automated resource tracking and scripted error capture capabilities
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user