1994 lines
70 KiB
GDScript
1994 lines
70 KiB
GDScript
extends Node
|
||
class_name GauntletManager
|
||
|
||
# GauntletManager - Handles Candy Pump Survival (Gauntlet) game mode
|
||
# Pattern: StopNGoManager + PortalModeManager
|
||
|
||
signal phase_changed(phase_index: int, phase_name: String)
|
||
signal growth_tick(cells: Array)
|
||
signal player_trapped(player_id: int)
|
||
signal cleanser_granted(player_id: int)
|
||
|
||
# =============================================================================
|
||
# Constants
|
||
# =============================================================================
|
||
|
||
const ARENA_COLUMNS: int = 20
|
||
const ARENA_ROWS: int = 20
|
||
const NPC_SIZE: int = 3
|
||
const NPC_CENTER: Vector2i = Vector2i(9, 9) # Center of 20x20 (0-indexed, center of 3x3 block)
|
||
|
||
# Tile IDs (matching MeshLibrary)
|
||
const TILE_WALKABLE: int = 0
|
||
const TILE_OBSTACLE: int = 4
|
||
const TILE_STICKY: int = 17 # New candy-pink overlay (Layer 2)
|
||
const TILE_TELEGRAPH: int = 18 # Warning glow (Layer 2, temporary)
|
||
|
||
# Cell states (v2 ground-growth model). Logical state of each playable cell.
|
||
enum CellState {
|
||
SAFE, # Can be entered, crossed, collected
|
||
TELEGRAPHED, # Warned as future sticky, still passable (1s)
|
||
STICKY, # Covered in sticky candy, blocks + traps
|
||
BUBBLE_GROWING, # Candy bubble growing, not yet exploded
|
||
BLOCKED, # NPC zone or permanent obstacle
|
||
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
|
||
const PHASE_3_START: float = 120.0 # Survival Endgame
|
||
|
||
# =============================================================================
|
||
# Phase System
|
||
# =============================================================================
|
||
|
||
enum Phase { OPEN_ARENA, ROUTE_PRESSURE, SURVIVAL_ENDGAME }
|
||
var current_phase: Phase = Phase.OPEN_ARENA
|
||
var elapsed_time: float = 0.0
|
||
var is_active: bool = false
|
||
|
||
# =============================================================================
|
||
# Growth State (v2 ground-growth model — replaces cannon volley)
|
||
# =============================================================================
|
||
|
||
var growth_timer: float = 0.0
|
||
var growth_interval: float = 3.0 # seconds between growth ticks
|
||
var telegraph_duration: float = 1.0 # seconds telegraphed cells stay passable
|
||
var sticky_cells: Dictionary = {} # Vector2i → true
|
||
var telegraphed_cells: Dictionary = {} # Vector2i → time remaining (still passable)
|
||
var _last_tick_cells: Array = [] # cells selected last tick (for repetition penalty)
|
||
|
||
# Camping detection (#073): time each player has spent in their current 4x4
|
||
# region. player_id -> {"region": Vector2i, "time": float}.
|
||
var _camp_tracking: Dictionary = {}
|
||
const CAMP_REGION_SIZE: int = 4
|
||
|
||
# Movement buffers (#083): hidden, decaying penalties on SAFE cells that form
|
||
# critical movement corridors. Detected dynamically each growth tick; never
|
||
# shown to players. pos(Vector2i) -> {"penalty": float, "adjacent": bool}.
|
||
# The penalty discourages the growth algorithm from sealing off a corridor too
|
||
# early, then fades over time / phases so the arena still closes in by the end.
|
||
var movement_buffers: Dictionary = {}
|
||
var _buffer_decay_timer: float = 0.0
|
||
const BUFFER_DECAY_INTERVAL: float = 5.0 # seconds between decay steps
|
||
const BUFFER_DECAY_FACTOR: float = 0.75 # each step keeps 75% (−25%)
|
||
const BUFFER_PHASE_DECAY: float = 0.5 # phase change halves all penalties
|
||
const BUFFER_MIN_PENALTY: float = 4.0 # prune below this magnitude
|
||
# Base "inside a buffer corridor" penalty per phase (adjacent = half).
|
||
const BUFFER_BASE_PENALTY: Array = [40.0, 20.0, 10.0]
|
||
# A SAFE cell is a corridor if removing it drops a player's reachable region
|
||
# below this many cells (i.e. it is a genuine chokepoint, not open floor).
|
||
const BUFFER_CORRIDOR_THRESHOLD: int = 12
|
||
|
||
# Candy bubbles (#082): occasional anti-camping hazards that grow from 1x1 and
|
||
# explode into a 3x3 sticky area. Separate from normal ground growth.
|
||
# active_bubbles entries: {"center": Vector2i, "timer": float, "cells": Array}.
|
||
var active_bubbles: Array = []
|
||
var bubble_cells: Dictionary = {} # Vector2i -> true (BUBBLE_GROWING state)
|
||
var recent_bubble_positions: Array = [] # centers of recent bubbles (anti-stacking)
|
||
var bubbles_this_phase: int = 0 # spawned in the current phase
|
||
var bubbles_total: int = 0 # spawned this round
|
||
const MAX_BUBBLES_PER_PHASE: Array = [0, 2, 3] # phase 1 / 2 / 3
|
||
const BUBBLE_GROW_DURATION: float = 2.75 # seconds from spawn to explosion (2.5–3)
|
||
const BUBBLE_EXPLOSION_RADIUS: int = 1 # 1 => 3x3 area
|
||
const BUBBLE_RECENT_MEMORY: int = 4 # how many recent centers to remember
|
||
const BUBBLE_RECENT_RADIUS: int = 3 # anti-stacking exclusion distance
|
||
|
||
# Phase-specific growth parameters (cells-per-tick range per phase).
|
||
# Layer weights: [outer, middle, inner] priority for the current pressure layer.
|
||
var phase_growth_config: Array = [
|
||
# Phase 0 (Outer Pressure): 4-6 cells/tick, push from the outside in
|
||
{"cells_min": 4, "cells_max": 6, "layer_weights": {"outer": 60, "middle": 15, "inner": -40}},
|
||
# Phase 1 (Middle Pressure): 6-8 cells/tick
|
||
{"cells_min": 6, "cells_max": 8, "layer_weights": {"outer": 20, "middle": 60, "inner": 5}},
|
||
# Phase 2 (Inner Survival): 8-10 cells/tick
|
||
{"cells_min": 8, "cells_max": 10, "layer_weights": {"outer": 10, "middle": 35, "inner": 60}},
|
||
]
|
||
|
||
# =============================================================================
|
||
# Smack State (per-player)
|
||
# =============================================================================
|
||
|
||
func has_smack_charged(pid: int) -> bool:
|
||
if smack_charged.has(pid) and smack_charged[pid] > 0:
|
||
return true
|
||
return false
|
||
|
||
@rpc("any_peer", "call_local", "reliable")
|
||
func consume_smack(pid: int) -> void:
|
||
# Local state reset
|
||
smack_charged[pid] = 0.0
|
||
smack_cooldowns[pid] = SMACK_COOLDOWN
|
||
|
||
# Play smack sound
|
||
if SfxManager:
|
||
SfxManager.rpc("play_rpc", "attack_mode") if _can_rpc() else SfxManager.play("attack_mode")
|
||
|
||
var all_players = get_tree().get_nodes_in_group("Players")
|
||
for player in all_players:
|
||
var curr_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
|
||
if curr_pid == pid:
|
||
if player.has_method("sync_modulate"):
|
||
if _can_rpc():
|
||
player.rpc("sync_modulate", Color.WHITE)
|
||
else:
|
||
player.sync_modulate(Color.WHITE)
|
||
break
|
||
|
||
var smack_cooldowns: Dictionary = {} # player_id → float (time remaining)
|
||
var smack_charged: Dictionary = {} # player_id → float (charge window remaining)
|
||
const SMACK_COOLDOWN: float = 8.0
|
||
const SMACK_CHARGE_WINDOW: float = 3.0
|
||
|
||
# =============================================================================
|
||
# Cleanser Tracking
|
||
# =============================================================================
|
||
|
||
var player_mission_completions: Dictionary = {} # player_id → int
|
||
var player_cleansers: Dictionary = {} # player_id → int (0 or 1)
|
||
var cleanser_active: Dictionary = {} # player_id → true when immunity active
|
||
var cleanser_cells_left: Dictionary = {} # player_id → int (cells remaining)
|
||
const CLEANSER_MAX_CELLS: int = 5
|
||
const CLEANSER_ACTIVATION_DELAY: float = 0.3
|
||
|
||
# =============================================================================
|
||
# Trapped Players
|
||
# =============================================================================
|
||
|
||
var trapped_players: Dictionary = {} # player_id → true (legacy; sticky now slows)
|
||
|
||
# Sticky entry slows the player instead of trapping them (per-player, fair in MP).
|
||
const STICKY_SLOW_DURATION: float = 2.0
|
||
|
||
# =============================================================================
|
||
# Slow-Mo Effect
|
||
# =============================================================================
|
||
|
||
var slowmo_active: bool = false
|
||
var slowmo_timer: float = 0.0
|
||
var slowmo_duration: float = 4.0
|
||
const SLOWMO_SCALE: float = 0.25 # 1/4 speed
|
||
var slowmo_overlay: ColorRect = null
|
||
|
||
# =============================================================================
|
||
# References
|
||
# =============================================================================
|
||
|
||
var main_scene: Node = null
|
||
var gridmap: Node = null
|
||
# Static Candy Pump NPC model at the arena center (the v2 "pump" that injects
|
||
# candy into the ground). Purely visual now — projectile logic was removed.
|
||
var candy_pump_scene: PackedScene = preload("res://scenes/candy_cannon.tscn")
|
||
var pump_instance: Node3D = null
|
||
|
||
# HUD
|
||
var hud_layer: CanvasLayer
|
||
var phase_label: Label
|
||
var cleanser_label: Label
|
||
var cleanser_icon: TextureRect
|
||
var cleanser_count: int = 0
|
||
var slowmo_label: Label
|
||
var _gauntlet_hud_scene: PackedScene = preload("res://scenes/gauntlet_hud.tscn")
|
||
|
||
# =============================================================================
|
||
# Lifecycle
|
||
# =============================================================================
|
||
|
||
func _ready():
|
||
set_process(false)
|
||
_setup_hud()
|
||
|
||
func _exit_tree():
|
||
# Ensure time_scale is always restored when leaving Gauntlet mode
|
||
Engine.time_scale = 1.0
|
||
|
||
func initialize(main: Node, grid: Node) -> void:
|
||
main_scene = main
|
||
gridmap = grid
|
||
print("[Gauntlet] Initialized with gridmap: ", gridmap.name if gridmap else "null")
|
||
|
||
# Connect to GoalsCycleManager for scoring and mission tracking
|
||
if main_scene:
|
||
var gcm = main_scene.get_node_or_null("GoalsCycleManager")
|
||
if gcm:
|
||
gcm.goal_count_updated.connect(_on_goal_count_updated)
|
||
gcm.score_updated.connect(_on_score_updated)
|
||
print("[Gauntlet] Connected to GoalsCycleManager")
|
||
|
||
func _process(delta: float) -> void:
|
||
if not is_active:
|
||
return
|
||
|
||
if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer == null:
|
||
return
|
||
|
||
elapsed_time += delta
|
||
|
||
# Phase escalation
|
||
_check_phase_transition()
|
||
|
||
# Server only logic
|
||
if multiplayer.is_server():
|
||
# Track camping behaviour for candidate scoring (#073)
|
||
_update_camp_tracking(delta)
|
||
|
||
# Growth tick timer
|
||
growth_timer -= delta
|
||
if growth_timer <= 0.0:
|
||
_process_growth_tick()
|
||
growth_timer = growth_interval
|
||
|
||
# Decay cleansed-cell protection windows
|
||
if not cleansed_cells.is_empty():
|
||
_tick_cleansed_cells(delta)
|
||
|
||
# Decay hidden movement buffers over time (#083)
|
||
_decay_movement_buffers(delta)
|
||
|
||
# Advance candy-bubble grow timers; explode when ready (#082)
|
||
_update_bubbles(delta)
|
||
|
||
# Smack mechanic update (ALL PEERS)
|
||
var all_players = get_tree().get_nodes_in_group("Players")
|
||
for player in all_players:
|
||
var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
|
||
|
||
# Allow local peer to predict setup
|
||
if not smack_cooldowns.has(pid) and not smack_charged.has(pid):
|
||
smack_cooldowns[pid] = SMACK_COOLDOWN
|
||
smack_charged[pid] = 0.0
|
||
|
||
if smack_cooldowns[pid] > 0:
|
||
smack_cooldowns[pid] -= delta
|
||
if smack_cooldowns[pid] <= 0:
|
||
smack_cooldowns[pid] = 0.0
|
||
smack_charged[pid] = SMACK_CHARGE_WINDOW
|
||
if player.has_method("sync_modulate"):
|
||
if multiplayer.is_server() and _can_rpc():
|
||
player.rpc("sync_modulate", Color.PINK)
|
||
elif not multiplayer.is_server():
|
||
player.sync_modulate(Color.PINK)
|
||
elif smack_charged[pid] > 0:
|
||
smack_charged[pid] -= delta
|
||
if smack_charged[pid] <= 0:
|
||
smack_charged[pid] = 0.0
|
||
smack_cooldowns[pid] = SMACK_COOLDOWN
|
||
if player.has_method("sync_modulate"):
|
||
if multiplayer.is_server() and _can_rpc():
|
||
player.rpc("sync_modulate", Color.WHITE)
|
||
elif not multiplayer.is_server():
|
||
player.sync_modulate(Color.WHITE)
|
||
|
||
# Cleanser input (local player only)
|
||
if Input.is_action_just_pressed("use_cleanser"):
|
||
_try_use_cleanser()
|
||
|
||
# Slow-mo timer (all peers for visual consistency)
|
||
if slowmo_active:
|
||
slowmo_timer -= delta
|
||
if slowmo_timer <= 0:
|
||
_end_slowmo()
|
||
# =============================================================================
|
||
# Game Mode Start
|
||
# =============================================================================
|
||
|
||
func start_game_mode() -> void:
|
||
if multiplayer.is_server():
|
||
activate_client_side()
|
||
_start_phase(Phase.OPEN_ARENA)
|
||
|
||
func activate_client_side() -> void:
|
||
is_active = true
|
||
if hud_layer:
|
||
hud_layer.visible = true
|
||
set_process(true)
|
||
|
||
# =============================================================================
|
||
# Phase Management
|
||
# =============================================================================
|
||
|
||
func _check_phase_transition() -> void:
|
||
var new_phase = current_phase
|
||
|
||
if elapsed_time >= PHASE_3_START:
|
||
new_phase = Phase.SURVIVAL_ENDGAME
|
||
elif elapsed_time >= PHASE_2_START:
|
||
new_phase = Phase.ROUTE_PRESSURE
|
||
|
||
if new_phase != current_phase:
|
||
_start_phase(new_phase)
|
||
|
||
func _start_phase(phase: Phase) -> void:
|
||
current_phase = phase
|
||
# Growth config is read per-tick from phase_growth_config[current_phase];
|
||
# resetting the timer keeps tick cadence aligned to the phase boundary.
|
||
growth_timer = growth_interval
|
||
|
||
# Phase change relaxes movement buffers by 50% — the arena is allowed to
|
||
# close in more aggressively as pressure escalates (#083).
|
||
if not movement_buffers.is_empty():
|
||
_scale_all_buffers(BUFFER_PHASE_DECAY)
|
||
|
||
# Reset the per-phase candy-bubble budget (#082).
|
||
bubbles_this_phase = 0
|
||
|
||
var phase_name = _phase_to_string(phase)
|
||
print("[Gauntlet] Phase changed to: ", phase_name)
|
||
|
||
if _can_rpc():
|
||
rpc("sync_phase", int(phase), phase_name)
|
||
|
||
emit_signal("phase_changed", int(phase), phase_name)
|
||
|
||
func _phase_to_string(phase: Phase) -> String:
|
||
match phase:
|
||
Phase.OPEN_ARENA:
|
||
return "Outer Pressure"
|
||
Phase.ROUTE_PRESSURE:
|
||
return "Middle Pressure"
|
||
Phase.SURVIVAL_ENDGAME:
|
||
return "Inner Survival"
|
||
_:
|
||
return "Unknown"
|
||
|
||
@rpc("authority", "call_local", "reliable")
|
||
func sync_phase(phase_index: int, phase_name: String) -> void:
|
||
if not is_active:
|
||
activate_client_side()
|
||
current_phase = phase_index as Phase
|
||
_update_hud_phase(phase_name)
|
||
|
||
# =============================================================================
|
||
# Arena Setup
|
||
# =============================================================================
|
||
|
||
func _setup_arena() -> void:
|
||
"""Called by host in main._setup_host_game()"""
|
||
if not gridmap:
|
||
gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
||
if not gridmap:
|
||
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
||
if not gridmap:
|
||
push_error("[Gauntlet] No EnhancedGridMap found!")
|
||
return
|
||
|
||
print("[Gauntlet] Setting up %dx%d Arena..." % [ARENA_COLUMNS, ARENA_ROWS])
|
||
|
||
# Sync to clients
|
||
if _can_rpc():
|
||
rpc("sync_arena_setup")
|
||
|
||
# Apply locally for server
|
||
_apply_arena_setup()
|
||
|
||
@rpc("authority", "call_remote", "reliable")
|
||
func sync_arena_setup() -> void:
|
||
print("[Gauntlet] Client: Syncing Arena Setup (%dx%d)..." % [ARENA_COLUMNS, ARENA_ROWS])
|
||
_apply_arena_setup()
|
||
|
||
func _apply_arena_setup() -> void:
|
||
"""Shared arena layout logic for host + clients."""
|
||
if not gridmap:
|
||
gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
||
if not gridmap:
|
||
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
||
if not gridmap: return
|
||
|
||
# Resize grid (bypass setters that wipe the map)
|
||
gridmap.set("columns", ARENA_COLUMNS)
|
||
gridmap.set("rows", ARENA_ROWS)
|
||
|
||
# Clear all
|
||
gridmap.clear()
|
||
|
||
# Build the 20x20 arena
|
||
for x in range(ARENA_COLUMNS):
|
||
for z in range(ARENA_ROWS):
|
||
var pos = Vector2i(x, z)
|
||
|
||
# Center 3x3 block: NPC obstacle (Candy Pump)
|
||
if _is_npc_zone(pos):
|
||
# Make the floor walkable visually instead of obstacle red
|
||
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE)
|
||
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||
continue
|
||
|
||
# Boundary walls: perimeter (row 0, row 19, col 0, col 19)
|
||
if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1:
|
||
# Also make border walls visually walkable floors instead of red blocks
|
||
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE)
|
||
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||
continue
|
||
|
||
# Interior: walkable floor
|
||
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE)
|
||
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||
|
||
gridmap.diagonal_movement = true
|
||
gridmap.update_grid_data()
|
||
gridmap.initialize_astar()
|
||
|
||
if not pump_instance and main_scene:
|
||
pump_instance = candy_pump_scene.instantiate()
|
||
pump_instance.name = "CandyPump"
|
||
var cx = NPC_CENTER.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0
|
||
var cz = NPC_CENTER.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0
|
||
pump_instance.position = Vector3(cx, 0, cz)
|
||
main_scene.add_child(pump_instance)
|
||
|
||
print("[Gauntlet] Arena setup complete. Boundary walls at perimeter. Center NPC at (%d,%d)" % [
|
||
NPC_CENTER.x, NPC_CENTER.y
|
||
])
|
||
|
||
func _is_npc_zone(pos: Vector2i) -> bool:
|
||
"""Check if a position is within the center 3x3 NPC zone."""
|
||
var half = NPC_SIZE / 2 # integer division = 1
|
||
var min_coord = NPC_CENTER - Vector2i(half, half) # (8, 8)
|
||
var max_coord = NPC_CENTER + Vector2i(half, half) # (10, 10)
|
||
return pos.x >= min_coord.x and pos.x <= max_coord.x and pos.y >= min_coord.y and pos.y <= max_coord.y
|
||
|
||
func get_spawn_points(player_count: int) -> Array[Vector2i]:
|
||
"""Return spawn positions based on player count. Inside boundary walls."""
|
||
# 4 players: inner corners
|
||
var spawns_4: Array[Vector2i] = [
|
||
Vector2i(1, 1), # Top-left
|
||
Vector2i(18, 1), # Top-right
|
||
Vector2i(1, 18), # Bottom-left
|
||
Vector2i(18, 18), # Bottom-right
|
||
]
|
||
|
||
# 6 players: corners + mid-edges (top/bottom)
|
||
var spawns_6: Array[Vector2i] = spawns_4.duplicate()
|
||
spawns_6.append(Vector2i(10, 1)) # Top-mid
|
||
spawns_6.append(Vector2i(10, 18)) # Bottom-mid
|
||
|
||
# 8 players: corners + all mid-edges
|
||
var spawns_8: Array[Vector2i] = spawns_6.duplicate()
|
||
spawns_8.append(Vector2i(1, 10)) # Left-mid
|
||
spawns_8.append(Vector2i(18, 10)) # Right-mid
|
||
|
||
match player_count:
|
||
4:
|
||
return spawns_4
|
||
5, 6:
|
||
return spawns_6
|
||
_, 7, 8:
|
||
return spawns_8
|
||
_:
|
||
return spawns_4
|
||
|
||
# =============================================================================
|
||
# Tile Spawning & Mission System (Task #3)
|
||
# =============================================================================
|
||
|
||
func setup_mission_tiles() -> void:
|
||
"""Public wrapper called from main.gd before countdown. Server-only."""
|
||
if multiplayer.is_server():
|
||
_spawn_mission_tiles()
|
||
|
||
func _spawn_mission_tiles() -> void:
|
||
"""Distribute colored goal tiles across the 20x20 arena.
|
||
Follows StopNGoManager._spawn_mission_tiles() pattern.
|
||
Excludes center 3x3 NPC zone."""
|
||
if not gridmap:
|
||
gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
||
if not gridmap:
|
||
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
||
if not gridmap: return
|
||
|
||
# Goal items: Heart(7), Diamond(8), Star(9), Coin(10)
|
||
var goal_items = [7, 8, 9, 10]
|
||
var tiles_spawned: int = 0
|
||
|
||
for x in range(ARENA_COLUMNS):
|
||
for z in range(ARENA_ROWS):
|
||
var pos = Vector2i(x, z)
|
||
|
||
# Skip NPC pump zone (center 3x3)
|
||
if _is_npc_zone(pos):
|
||
continue
|
||
|
||
# Check base floor — don't spawn on void (or walls if they were still obstacles)
|
||
var base_tile = gridmap.get_cell_item(Vector3i(x, 0, z))
|
||
if base_tile == -1:
|
||
continue
|
||
|
||
# Ensure we don't spawn powerups on the perimeter walls even though they look like floors
|
||
if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1:
|
||
continue
|
||
|
||
# Skip if something already exists on Layer 1
|
||
var current_item = gridmap.get_cell_item(Vector3i(x, 1, z))
|
||
if current_item != -1:
|
||
continue
|
||
|
||
# Spawn tiles with 60% density (40% chance to skip)
|
||
if randf() > 0.6:
|
||
continue
|
||
|
||
var tile_type = goal_items[randi() % goal_items.size()]
|
||
gridmap.set_cell_item(Vector3i(x, 1, z), tile_type)
|
||
tiles_spawned += 1
|
||
|
||
# Sync to clients
|
||
var main = get_node("/root/Main")
|
||
if main:
|
||
main.rpc("sync_grid_item", x, 1, z, tile_type)
|
||
|
||
print("[Gauntlet] Spawned %d mission tiles across %dx%d arena" % [tiles_spawned, ARENA_COLUMNS, ARENA_ROWS])
|
||
|
||
# =============================================================================
|
||
# Growth Logic (Server Only) — v2 ground-growth, replaces cannon volley
|
||
# =============================================================================
|
||
|
||
func _process_growth_tick() -> void:
|
||
"""One growth tick: score SAFE cells, weight-select, path-check, telegraph."""
|
||
if not multiplayer.is_server():
|
||
return
|
||
|
||
var count := _cells_this_tick()
|
||
# Detect hidden movement-buffer corridors before scoring so the candidate
|
||
# scores reflect them this tick (#083; satisfies #067's buffer-check item).
|
||
_detect_movement_buffers()
|
||
var candidates := _generate_candidates()
|
||
if candidates.is_empty():
|
||
return
|
||
|
||
var selected := _select_cells_weighted(candidates, count)
|
||
selected = _apply_path_safety(selected)
|
||
if selected.is_empty():
|
||
return
|
||
|
||
_last_tick_cells = selected.duplicate()
|
||
|
||
# Telegraph now (passable for telegraph_duration), then convert to sticky.
|
||
for pos in selected:
|
||
telegraphed_cells[pos] = telegraph_duration
|
||
if _can_rpc():
|
||
rpc("sync_growth_telegraph", selected)
|
||
else:
|
||
sync_growth_telegraph(selected)
|
||
|
||
await get_tree().create_timer(telegraph_duration).timeout
|
||
|
||
for pos in selected:
|
||
telegraphed_cells.erase(pos)
|
||
if _can_rpc():
|
||
rpc("sync_growth_apply", selected)
|
||
else:
|
||
sync_growth_apply(selected)
|
||
|
||
emit_signal("growth_tick", selected)
|
||
|
||
# Possibly start a candy bubble this tick (anti-camping hazard, #082).
|
||
_try_spawn_bubble()
|
||
|
||
func _cells_this_tick() -> int:
|
||
"""Random cell count within this phase's configured range."""
|
||
var cfg = phase_growth_config[int(current_phase)]
|
||
var lo: int = cfg["cells_min"]
|
||
var hi: int = cfg["cells_max"]
|
||
if hi <= lo:
|
||
return lo
|
||
return lo + randi() % (hi - lo + 1)
|
||
|
||
func _generate_candidates() -> Array:
|
||
"""Build a list of {pos, score} for every SAFE, growable cell."""
|
||
var candidates: Array = []
|
||
var player_cells := _active_player_cells() # gathered once per tick
|
||
for x in range(ARENA_COLUMNS):
|
||
for z in range(ARENA_ROWS):
|
||
var pos := Vector2i(x, z)
|
||
# Only SAFE cells are growable; skip blocked, sticky, telegraphed,
|
||
# and cleansed (temporary regrowth protection from #068).
|
||
if cell_state(pos) != CellState.SAFE:
|
||
continue
|
||
candidates.append({"pos": pos, "score": _calculate_candidate_score(pos, player_cells)})
|
||
return candidates
|
||
|
||
func _calculate_candidate_score(pos: Vector2i, player_cells: Array = []) -> float:
|
||
"""Full v2 candidate score (#073). Higher score = higher pick chance.
|
||
|
||
CandidateScore =
|
||
LayerPriority + StickyNeighbor + InwardPressure + PlayerPressure
|
||
+ ClusterGrowth + CampingPressure + RandomNoise
|
||
+ MovementBuffer + PathSafety + Repetition
|
||
"""
|
||
var score := 0.0
|
||
score += _score_layer_priority(pos)
|
||
score += _score_sticky_neighbor(pos)
|
||
score += _score_inward_pressure(pos)
|
||
score += _score_player_pressure(pos, player_cells)
|
||
score += _score_cluster_growth(pos)
|
||
score += _score_camping_pressure(pos)
|
||
score += randf_range(-20.0, 20.0) # RandomNoise — keep growth imperfect
|
||
score += _score_movement_buffer(pos)
|
||
score += _score_path_safety(pos)
|
||
score += _score_repetition(pos)
|
||
return score
|
||
|
||
# --- score components (#073) -------------------------------------------------
|
||
|
||
func _score_layer_priority(pos: Vector2i) -> float:
|
||
"""Steer growth to the current phase's pressure ring."""
|
||
var weights: Dictionary = phase_growth_config[int(current_phase)]["layer_weights"]
|
||
return float(weights[_layer_of(pos)])
|
||
|
||
func _score_sticky_neighbor(pos: Vector2i) -> float:
|
||
"""Prefer growing adjacent to existing sticky: +8 each, capped +64."""
|
||
return min(_sticky_neighbor_count(pos) * 8.0, 64.0)
|
||
|
||
func _score_inward_pressure(pos: Vector2i) -> float:
|
||
"""Push candy inward more strongly as the round progresses. Scales with how
|
||
close the cell is to the center within the per-phase range."""
|
||
var d := _chebyshev(pos, NPC_CENTER)
|
||
var max_d := float(maxi(ARENA_COLUMNS, ARENA_ROWS) / 2) # ~10
|
||
var closeness := clampf(1.0 - float(d) / max_d, 0.0, 1.0)
|
||
match int(current_phase):
|
||
0: return lerpf(0.0, 10.0, closeness)
|
||
1: return lerpf(5.0, 20.0, closeness)
|
||
_: return lerpf(10.0, 30.0, closeness)
|
||
|
||
func _score_player_pressure(pos: Vector2i, player_cells: Array) -> float:
|
||
"""Pressure players without directly targeting them.
|
||
- 2-4 cells away: +20
|
||
- directly under a player: -50 (before final 30s), +10 (final 30s)."""
|
||
if player_cells.is_empty():
|
||
return 0.0
|
||
var best := 0.0
|
||
var final_window := float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW
|
||
for pcell in player_cells:
|
||
var d := _chebyshev(pos, pcell)
|
||
var s := 0.0
|
||
if d == 0:
|
||
s = 10.0 if final_window else -50.0
|
||
elif d >= 2 and d <= 4:
|
||
s = 20.0
|
||
if abs(s) > abs(best):
|
||
best = s
|
||
return best
|
||
|
||
func _score_cluster_growth(pos: Vector2i) -> float:
|
||
"""Reward expanding/connecting sticky clusters. Distinct sticky neighbours
|
||
spanning more than one direction implies a bridge between clusters."""
|
||
var neighbours := _sticky_neighbor_count(pos)
|
||
if neighbours == 0:
|
||
return 0.0
|
||
if neighbours >= 3:
|
||
return 25.0 # connects clusters
|
||
return 15.0 # expands a cluster
|
||
|
||
func _score_camping_pressure(pos: Vector2i) -> float:
|
||
"""Target areas where a player has lingered.
|
||
>5s: +20, >8s: +40, >10s: +60."""
|
||
var t := _camp_time_for_region(_region_of(pos))
|
||
if t > 10.0:
|
||
return 60.0
|
||
elif t > 8.0:
|
||
return 40.0
|
||
elif t > 5.0:
|
||
return 20.0
|
||
return 0.0
|
||
|
||
func _score_movement_buffer(pos: Vector2i) -> float:
|
||
"""Respect hidden safe zones. Two complementary parts (#083):
|
||
1. Dynamically-detected buffer corridors (decaying) — `_buffer_penalty_at`.
|
||
2. A light proximity floor around players so the immediate ring stays open.
|
||
Both lift entirely in the final window so the arena can close out."""
|
||
var final_window := float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW
|
||
if final_window:
|
||
return 0.0
|
||
|
||
# 1. Detected corridor buffers (strongest signal).
|
||
var buffer := _buffer_penalty_at(pos)
|
||
if buffer < 0.0:
|
||
return buffer
|
||
|
||
# 2. Proximity floor (kept from #073) — discourage sealing the ring next to a
|
||
# player even when no corridor was detected there.
|
||
var player_cells := _active_player_cells()
|
||
var min_d := INF
|
||
for pcell in player_cells:
|
||
min_d = min(min_d, float(_chebyshev(pos, pcell)))
|
||
if min_d == INF:
|
||
return 0.0
|
||
match int(current_phase):
|
||
0:
|
||
if min_d <= 1: return -40.0
|
||
elif min_d <= 2: return -20.0
|
||
1:
|
||
if min_d <= 1: return -20.0
|
||
elif min_d <= 2: return -10.0
|
||
_:
|
||
if min_d <= 1: return -10.0
|
||
return 0.0
|
||
|
||
func _score_path_safety(pos: Vector2i) -> float:
|
||
"""Soft penalty that discourages selections which would strand a player.
|
||
The hard guarantee is enforced separately by _apply_path_safety()."""
|
||
if float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW:
|
||
return 0.0
|
||
var extra := {pos: true}
|
||
for pcell in _active_player_cells():
|
||
if not _player_has_safe_region(pcell, extra):
|
||
return -100.0 # would fully trap a player
|
||
return 0.0
|
||
|
||
func _score_repetition(pos: Vector2i) -> float:
|
||
"""Avoid spammy growth on last tick's footprint."""
|
||
for last in _last_tick_cells:
|
||
if _chebyshev(pos, last) <= 1:
|
||
return -30.0
|
||
return 0.0
|
||
|
||
func _select_cells_weighted(candidates: Array, count: int) -> Array:
|
||
"""Weighted-random selection: higher score = higher pick chance.
|
||
|
||
Scores are shifted positive so the lowest-scoring cell still has a small
|
||
non-zero weight, preserving organic unpredictability.
|
||
"""
|
||
var pool: Array = candidates.duplicate()
|
||
var picked: Array = []
|
||
|
||
# Find the minimum score to offset all weights into the positive range.
|
||
var min_score := INF
|
||
for c in pool:
|
||
min_score = min(min_score, c["score"])
|
||
var offset := 1.0 - min_score # ensures every weight >= 1.0
|
||
|
||
var n: int = min(count, pool.size())
|
||
for _i in range(n):
|
||
var total := 0.0
|
||
for c in pool:
|
||
total += c["score"] + offset
|
||
if total <= 0.0:
|
||
break
|
||
var roll := randf() * total
|
||
var acc := 0.0
|
||
var chosen_idx := 0
|
||
for j in range(pool.size()):
|
||
acc += pool[j]["score"] + offset
|
||
if roll <= acc:
|
||
chosen_idx = j
|
||
break
|
||
picked.append(pool[chosen_idx]["pos"])
|
||
pool.remove_at(chosen_idx)
|
||
|
||
return picked
|
||
|
||
# --- scoring helpers ---------------------------------------------------------
|
||
|
||
func _layer_of(pos: Vector2i) -> String:
|
||
"""Classify a cell into outer / middle / inner rings by Chebyshev distance
|
||
from the arena center (matches the NPC pump at the middle)."""
|
||
var d := _chebyshev(pos, NPC_CENTER)
|
||
if d >= 7:
|
||
return "outer"
|
||
elif d >= 4:
|
||
return "middle"
|
||
return "inner"
|
||
|
||
func _sticky_neighbor_count(pos: Vector2i) -> int:
|
||
"""Count of the 8 surrounding cells that are already sticky."""
|
||
var c := 0
|
||
for dx in range(-1, 2):
|
||
for dz in range(-1, 2):
|
||
if dx == 0 and dz == 0:
|
||
continue
|
||
if sticky_cells.has(pos + Vector2i(dx, dz)):
|
||
c += 1
|
||
return c
|
||
|
||
func _chebyshev(a: Vector2i, b: Vector2i) -> int:
|
||
return max(abs(a.x - b.x), abs(a.y - b.y))
|
||
|
||
# --- camping tracking --------------------------------------------------------
|
||
|
||
func _region_of(pos: Vector2i) -> Vector2i:
|
||
"""Coarse 4x4 region key a cell belongs to (for camping detection)."""
|
||
return Vector2i(pos.x / CAMP_REGION_SIZE, pos.y / CAMP_REGION_SIZE)
|
||
|
||
func _update_camp_tracking(delta: float) -> void:
|
||
"""Accumulate time each player spends in their current 4x4 region.
|
||
Resets the timer when a player moves to a new region. Server-side."""
|
||
var seen := {}
|
||
for player in get_tree().get_nodes_in_group("Players"):
|
||
var pid = player.get("peer_id") if "peer_id" in player else -1
|
||
if pid == -1 or not ("current_position" in player) or player.current_position == null:
|
||
continue
|
||
seen[pid] = true
|
||
var region := _region_of(player.current_position)
|
||
var rec = _camp_tracking.get(pid)
|
||
if rec == null or rec["region"] != region:
|
||
_camp_tracking[pid] = {"region": region, "time": 0.0}
|
||
else:
|
||
rec["time"] += delta
|
||
# Drop tracking for players that left the match.
|
||
for pid in _camp_tracking.keys():
|
||
if not seen.has(pid):
|
||
_camp_tracking.erase(pid)
|
||
|
||
func _camp_time_for_region(region: Vector2i) -> float:
|
||
"""Longest camp time any player has accrued in the given region."""
|
||
var best := 0.0
|
||
for pid in _camp_tracking:
|
||
var rec = _camp_tracking[pid]
|
||
if rec["region"] == region:
|
||
best = max(best, rec["time"])
|
||
return best
|
||
|
||
# =============================================================================
|
||
# Growth Telegraph & Apply (RPCs) — v2
|
||
# =============================================================================
|
||
|
||
@rpc("authority", "call_local", "reliable")
|
||
func sync_growth_telegraph(cells: Array) -> void:
|
||
"""Warn that the given cells will become sticky. Cells stay passable until
|
||
sync_growth_apply fires (telegraph_duration later)."""
|
||
if not gridmap: return
|
||
|
||
for cell in cells:
|
||
var pos = cell as Vector2i
|
||
# Telegraph overlay tile on Layer 2 (still passable).
|
||
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH)
|
||
_spawn_telegraph_highlight(pos)
|
||
|
||
# NEW: Throw projectile from pump for normal growth
|
||
if pump_instance and pump_instance.has_method("spawn_projectile"):
|
||
var target_world_pos = Vector3(
|
||
pos.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0,
|
||
0.5,
|
||
pos.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0
|
||
)
|
||
pump_instance.spawn_projectile(target_world_pos, telegraph_duration)
|
||
|
||
# Audio: warning pulse
|
||
if SfxManager:
|
||
SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile")
|
||
|
||
func _spawn_telegraph_highlight(pos: Vector2i) -> void:
|
||
"""Two-stage amber warning under a telegraphed cell (#069):
|
||
• Build-up (0–0.8s): amber glow ramps alpha 0→1.
|
||
• Flash (0.8–1.0s): flickers to bright amber just before impact.
|
||
Auto-removed at the end of the telegraph window. Amber here is deliberately
|
||
distinct from the pink/magenta sticky overlay so the two never read alike."""
|
||
var cs = gridmap.cell_size
|
||
var world_pos = Vector3(pos.x * cs.x + cs.x / 2.0, 0.15, pos.y * cs.z + cs.z / 2.0)
|
||
|
||
var mesh_inst = MeshInstance3D.new()
|
||
var box = BoxMesh.new()
|
||
box.size = Vector3(cs.x * 0.8, 0.02, cs.z * 0.8)
|
||
mesh_inst.mesh = box
|
||
mesh_inst.position = world_pos
|
||
|
||
var amber := Color(1.0, 0.65, 0.1) # syrup amber — clearly not sticky pink
|
||
var mat = StandardMaterial3D.new()
|
||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||
mat.albedo_color = Color(amber.r, amber.g, amber.b, 0.0)
|
||
mat.emission_enabled = true
|
||
mat.emission = amber
|
||
mat.emission_energy_multiplier = 1.5
|
||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||
mesh_inst.material_override = mat
|
||
|
||
var main = get_node_or_null("/root/Main")
|
||
if not main:
|
||
return
|
||
main.add_child(mesh_inst)
|
||
|
||
# Split the telegraph window 80% build-up / 20% flash.
|
||
var build := telegraph_duration * 0.8
|
||
var flash := telegraph_duration * 0.2
|
||
|
||
var tween = create_tween()
|
||
# Build-up: fade in to a steady amber.
|
||
tween.tween_method(func(a): mat.albedo_color.a = a, 0.0, 0.55, build)
|
||
# Flash: quick bright flicker (alpha + emission energy) right before impact.
|
||
tween.tween_method(func(e): mat.emission_energy_multiplier = e, 1.5, 4.0, flash * 0.5)
|
||
tween.parallel().tween_method(func(a): mat.albedo_color.a = a, 0.55, 0.9, flash * 0.5)
|
||
tween.tween_method(func(e): mat.emission_energy_multiplier = e, 4.0, 2.5, flash * 0.5)
|
||
|
||
var remove_timer = get_tree().create_timer(telegraph_duration)
|
||
remove_timer.timeout.connect(func():
|
||
if is_instance_valid(mesh_inst):
|
||
mesh_inst.queue_free()
|
||
)
|
||
|
||
@rpc("authority", "call_local", "reliable")
|
||
func sync_growth_apply(cells: Array) -> void:
|
||
"""Convert telegraphed cells to permanent sticky candy."""
|
||
if not gridmap: return
|
||
for cell in cells:
|
||
var pos = cell as Vector2i
|
||
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_STICKY)
|
||
sticky_cells[pos] = true
|
||
|
||
# Screen shake for impact
|
||
if main_scene and main_scene.get("screen_shake_manager"):
|
||
main_scene.screen_shake_manager.shake(0.15, 0.4)
|
||
|
||
# Audio: sticky splat
|
||
if SfxManager:
|
||
SfxManager.rpc("play_rpc", "tile_scatter") if _can_rpc() else SfxManager.play("tile_scatter")
|
||
|
||
_spawn_impact_particles(cells)
|
||
|
||
# Re-evaluate trapped players after the new sticky cells land.
|
||
_check_all_players_trapped()
|
||
|
||
func _spawn_impact_particles(targets: Array) -> void:
|
||
"""Spawn candy splash particles at impact locations."""
|
||
if not main_scene:
|
||
return
|
||
|
||
for target in targets:
|
||
var pos = target as Vector2i
|
||
var world_pos = Vector3(
|
||
pos.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0,
|
||
0.5, # Slightly above floor
|
||
pos.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0
|
||
)
|
||
|
||
# Create a simple particle effect (GPUParticles3D)
|
||
var particles = GPUParticles3D.new()
|
||
particles.emitting = true
|
||
particles.one_shot = true
|
||
particles.amount = 8
|
||
particles.lifetime = 0.5
|
||
particles.explosiveness = 1.0
|
||
|
||
# Candy pink color
|
||
var material = ParticleProcessMaterial.new()
|
||
material.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
|
||
material.emission_sphere_radius = 0.2
|
||
material.direction = Vector3(0, 1, 0)
|
||
material.spread = 45.0
|
||
material.initial_velocity_min = 2.0
|
||
material.initial_velocity_max = 4.0
|
||
material.gravity = Vector3(0, -9.8, 0)
|
||
material.scale_min = 0.1
|
||
material.scale_max = 0.3
|
||
|
||
# Define visual mesh
|
||
var mesh = BoxMesh.new()
|
||
mesh.size = Vector3(0.2, 0.2, 0.2)
|
||
var spatial_mat = StandardMaterial3D.new()
|
||
spatial_mat.albedo_color = Color(1.0, 0.6, 0.8) # Candy pink
|
||
spatial_mat.emission_enabled = true
|
||
spatial_mat.emission = Color(1.0, 0.6, 0.8)
|
||
spatial_mat.emission_energy_multiplier = 2.0
|
||
mesh.material = spatial_mat
|
||
particles.draw_pass_1 = mesh
|
||
|
||
particles.process_material = material
|
||
particles.position = world_pos
|
||
|
||
main_scene.add_child(particles)
|
||
|
||
# Auto-remove after particles finish
|
||
await get_tree().create_timer(1.0).timeout
|
||
if particles and is_instance_valid(particles):
|
||
particles.queue_free()
|
||
|
||
# =============================================================================
|
||
func _spawn_cleanser_particles(pos: Vector2i) -> void:
|
||
"""Spawn bright cleansing particles when sticky is cleared."""
|
||
if not main_scene or not gridmap:
|
||
return
|
||
|
||
var world_pos = Vector3(
|
||
pos.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0,
|
||
0.5,
|
||
pos.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0
|
||
)
|
||
|
||
var particles = GPUParticles3D.new()
|
||
particles.emitting = true
|
||
particles.one_shot = true
|
||
particles.amount = 12
|
||
particles.lifetime = 0.6
|
||
particles.explosiveness = 0.9
|
||
|
||
var material = ParticleProcessMaterial.new()
|
||
material.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
|
||
material.emission_sphere_radius = 0.3
|
||
material.direction = Vector3(0, 1, 0)
|
||
material.spread = 180.0
|
||
material.initial_velocity_min = 3.0
|
||
material.initial_velocity_max = 5.0
|
||
material.gravity = Vector3(0, -5.0, 0)
|
||
material.scale_min = 0.05
|
||
material.scale_max = 0.15
|
||
|
||
var mesh = SphereMesh.new()
|
||
mesh.radius = 0.2
|
||
mesh.height = 0.4
|
||
var spatial_mat = StandardMaterial3D.new()
|
||
spatial_mat.albedo_color = Color(0.2, 1.0, 1.0) # Cyan/Blue for cleanser
|
||
spatial_mat.emission_enabled = true
|
||
spatial_mat.emission = Color(0.2, 1.0, 1.0)
|
||
spatial_mat.emission_energy_multiplier = 3.0
|
||
mesh.material = spatial_mat
|
||
particles.draw_pass_1 = mesh
|
||
|
||
particles.process_material = material
|
||
particles.position = world_pos
|
||
|
||
main_scene.add_child(particles)
|
||
|
||
await get_tree().create_timer(1.2).timeout
|
||
if particles and is_instance_valid(particles):
|
||
particles.queue_free()
|
||
|
||
# =============================================================================
|
||
# Sticky / Trap System
|
||
|
||
func is_sticky_cell(pos: Vector2i) -> bool:
|
||
return sticky_cells.has(pos)
|
||
|
||
func is_cleansed_cell(pos: Vector2i) -> bool:
|
||
return cleansed_cells.has(pos)
|
||
|
||
func cell_state(pos: Vector2i) -> CellState:
|
||
"""Logical state of a playable cell (v2 ground-growth model)."""
|
||
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
|
||
|
||
# NEW: Ensure bubbles never pick boundary tiles or NPC area as center
|
||
if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1:
|
||
continue
|
||
if _is_npc_zone(pos):
|
||
continue
|
||
|
||
candidates.append({"pos": pos, "score": _calculate_bubble_score(pos, player_cells)})
|
||
return candidates
|
||
|
||
func _calculate_bubble_score(pos: Vector2i, player_cells: Array = []) -> float:
|
||
"""Bubble-specific scoring (#082). Higher = better bubble target.
|
||
|
||
BubbleScore = Camping + UntouchedArea + PlayerCluster + RandomNoise
|
||
+ DirectHitPenalty + RecentBubblePenalty + UnfairTrapPenalty
|
||
"""
|
||
var score := 0.0
|
||
score += _bubble_score_camping(pos)
|
||
score += _bubble_score_untouched_area(pos)
|
||
score += _bubble_score_player_cluster(pos, player_cells)
|
||
score += randf_range(-20.0, 20.0)
|
||
score += _bubble_score_direct_hit(pos, player_cells)
|
||
score += _bubble_score_recent(pos)
|
||
score += _bubble_score_unfair_trap(pos)
|
||
return score
|
||
|
||
func _bubble_score_camping(pos: Vector2i) -> float:
|
||
"""Reward targeting campers. +40 >5s, +60 >8s, +80 >10s-with-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")
|
||
|
||
# NEW: VFX projectile from center pump if it exists
|
||
if pump_instance and pump_instance.has_method("spawn_projectile"):
|
||
var target_world_pos = Vector3(
|
||
center.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0,
|
||
0.5,
|
||
center.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0
|
||
)
|
||
pump_instance.spawn_projectile(target_world_pos, BUBBLE_GROW_DURATION)
|
||
|
||
@rpc("authority", "call_local", "reliable")
|
||
func sync_bubble_explode(center: Vector2i, cells: Array) -> void:
|
||
"""Apply the 3x3 sticky overlay + explosion VFX on all clients."""
|
||
if not gridmap:
|
||
return
|
||
for c in cells:
|
||
var pos = c as Vector2i
|
||
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_STICKY)
|
||
sticky_cells[pos] = true
|
||
# Medium shake — bubbles hit harder than a normal growth tick.
|
||
if main_scene and main_scene.get("screen_shake_manager"):
|
||
main_scene.screen_shake_manager.shake(0.3, 0.6)
|
||
if SfxManager:
|
||
SfxManager.rpc("play_rpc", "tile_scatter") if _can_rpc() else SfxManager.play("tile_scatter")
|
||
_spawn_impact_particles(cells)
|
||
|
||
func _spawn_bubble_visual(center: Vector2i) -> void:
|
||
"""A pulsing candy bubble sphere that grows over the bubble's lifetime."""
|
||
if not gridmap:
|
||
return
|
||
var cs = gridmap.cell_size
|
||
var world_pos = Vector3(center.x * cs.x + cs.x / 2.0, 0.4, center.y * cs.z + cs.z / 2.0)
|
||
|
||
var mesh_inst = MeshInstance3D.new()
|
||
var sphere = SphereMesh.new()
|
||
sphere.radius = 0.25
|
||
sphere.height = 0.5
|
||
mesh_inst.mesh = sphere
|
||
mesh_inst.position = world_pos
|
||
|
||
var mat = StandardMaterial3D.new()
|
||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||
mat.albedo_color = Color(1.0, 0.2, 0.6, 0.7) # candy pink
|
||
mat.emission_enabled = true
|
||
mat.emission = Color(1.0, 0.2, 0.6)
|
||
mat.emission_energy_multiplier = 1.5
|
||
mesh_inst.material_override = mat
|
||
|
||
var main = get_node_or_null("/root/Main")
|
||
if not main:
|
||
return
|
||
main.add_child(mesh_inst)
|
||
|
||
# Grow + pulse over the grow duration, then remove (explosion VFX takes over).
|
||
var tween = create_tween()
|
||
tween.tween_property(mesh_inst, "scale", Vector3(3.0, 3.0, 3.0), BUBBLE_GROW_DURATION) \
|
||
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
|
||
tween.parallel().tween_method(func(e): mat.emission_energy_multiplier = e, 1.5, 4.0, BUBBLE_GROW_DURATION)
|
||
var remove_timer = get_tree().create_timer(BUBBLE_GROW_DURATION + 0.05)
|
||
remove_timer.timeout.connect(func():
|
||
if is_instance_valid(mesh_inst):
|
||
mesh_inst.queue_free()
|
||
)
|
||
|
||
func gauntlet_round_duration() -> int:
|
||
"""Round length in seconds (from lobby settings, with a sane fallback)."""
|
||
if LobbyManager and "gauntlet_round_duration" in LobbyManager:
|
||
return LobbyManager.gauntlet_round_duration
|
||
return 180
|
||
|
||
func _check_all_players_trapped() -> void:
|
||
"""After growth lands, slow any player standing on a fresh sticky cell."""
|
||
if not multiplayer.is_server(): return
|
||
var all_players = get_tree().get_nodes_in_group("Players")
|
||
for player in all_players:
|
||
var pos = player.current_position if player.get("current_position") else Vector2i(-1, -1)
|
||
if is_sticky_cell(pos):
|
||
var pid = player.get("peer_id") if "peer_id" in player else -1
|
||
if pid != -1 and 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."""
|
||
if _can_rpc():
|
||
if multiplayer.is_server():
|
||
rpc("sync_clear_sticky_cell", pos)
|
||
else:
|
||
sync_clear_sticky_cell(pos) # Predictive local clear
|
||
rpc("rpc_use_cleanser", pos)
|
||
else:
|
||
sync_clear_sticky_cell(pos)
|
||
|
||
@rpc("authority", "call_local", "reliable")
|
||
func sync_clear_sticky_cell(pos: Vector2i) -> void:
|
||
sticky_cells.erase(pos)
|
||
mark_cleansed(pos) # temporary regrowth protection (v2)
|
||
if gridmap:
|
||
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1)
|
||
|
||
# Play VFX and SFX
|
||
_spawn_cleanser_particles(pos)
|
||
if SfxManager:
|
||
SfxManager.play("pick_up_power_tile")
|
||
|
||
# Sync removal to main scene's gridmap if needed
|
||
if main_scene and main_scene.has_method("sync_grid_item"):
|
||
main_scene.sync_grid_item(pos.x, 2, pos.y, -1)
|
||
|
||
func _try_use_cleanser() -> void:
|
||
"""Local player attempts to activate Cleanser for 5-cell sticky immunity."""
|
||
var local_pid = multiplayer.get_unique_id()
|
||
var count = player_cleansers.get(local_pid, 0)
|
||
if count <= 0:
|
||
return
|
||
|
||
# Block activation during stun
|
||
var all_players = get_tree().get_nodes_in_group("Players")
|
||
var local_player = null
|
||
for p in all_players:
|
||
var pid = p.get("peer_id") if "peer_id" in p else p.name.to_int()
|
||
if pid == local_pid:
|
||
local_player = p
|
||
break
|
||
if not local_player:
|
||
return
|
||
if local_player.get("is_frozen") or local_player.get("is_stop_frozen"):
|
||
return
|
||
# 0.3s activation delay
|
||
await get_tree().create_timer(CLEANSER_ACTIVATION_DELAY).timeout
|
||
|
||
# Re-validate after delay
|
||
if not is_instance_valid(local_player):
|
||
return
|
||
if local_player.get("is_frozen") or local_player.get("is_stop_frozen"):
|
||
return
|
||
|
||
# Consume cleanser from inventory (only if client, host relies on rpc)
|
||
if not multiplayer.is_server():
|
||
cleanser_active[local_pid] = true
|
||
cleanser_cells_left[local_pid] = CLEANSER_MAX_CELLS
|
||
player_cleansers[local_pid] = max(0, player_cleansers[local_pid] - 1)
|
||
update_cleanser_ui(player_cleansers[local_pid])
|
||
|
||
NotificationManager.send_message(local_player, "Cleanser Used! (5 charges)", NotificationManager.MessageType.POWERUP)
|
||
|
||
# Sync to server/clients
|
||
if not multiplayer.is_server() and _can_rpc():
|
||
rpc("rpc_activate_cleanser", local_pid)
|
||
elif multiplayer.is_server():
|
||
# Call RPC logic directly for host (it will set active/cells_left/consume)
|
||
rpc_activate_cleanser(local_pid)
|
||
|
||
@rpc("any_peer", "call_local", "reliable")
|
||
func deactivate_cleanser(player_id: int) -> void:
|
||
"""Deactivate cleanser immunity for a player."""
|
||
cleanser_active.erase(player_id)
|
||
cleanser_cells_left.erase(player_id)
|
||
|
||
func is_cleanser_active(player_id: int) -> bool:
|
||
"""Check if a player has active cleanser immunity."""
|
||
return cleanser_active.has(player_id)
|
||
|
||
func use_cleanser_cell(player_id: int) -> bool:
|
||
"""Use one cleanser cell. Returns true if still active, false if exhausted."""
|
||
if not cleanser_active.has(player_id):
|
||
return false
|
||
cleanser_cells_left[player_id] -= 1
|
||
if cleanser_cells_left[player_id] <= 0:
|
||
if _can_rpc():
|
||
rpc("deactivate_cleanser", player_id)
|
||
else:
|
||
deactivate_cleanser(player_id)
|
||
return false
|
||
return true
|
||
|
||
func notify_movement_stopped(player_id: int, pos: Vector2i) -> void:
|
||
"""Called from PlayerMovementManager when a move chain settles.
|
||
Previously deactivated cleanser here, but now immunity persists
|
||
until charges run out to allow repeated use across safe gaps."""
|
||
pass
|
||
|
||
@rpc("any_peer", "call_local", "reliable")
|
||
func rpc_activate_cleanser(pid: int) -> void:
|
||
"""RPC for clients to activate cleanser on server."""
|
||
if multiplayer.is_server():
|
||
# Verify they actually have a cleanser charge (prevents spam/cheats)
|
||
if player_cleansers.get(pid, 0) <= 0:
|
||
return
|
||
|
||
# Always apply the state and AoE, since this is the server authority
|
||
cleanser_active[pid] = true
|
||
cleanser_cells_left[pid] = CLEANSER_MAX_CELLS
|
||
player_cleansers[pid] = max(0, player_cleansers[pid] - 1)
|
||
if _can_rpc():
|
||
rpc("sync_cleanser_count", pid, player_cleansers[pid])
|
||
|
||
# NEW: Clear 3x3 area around player
|
||
var all_players = get_tree().get_nodes_in_group("Players")
|
||
var target_player = null
|
||
for p in all_players:
|
||
var target_pid = p.get("peer_id") if "peer_id" in p else p.name.to_int()
|
||
if target_pid == pid:
|
||
target_player = p
|
||
break
|
||
|
||
if gridmap and is_instance_valid(target_player):
|
||
var map_pos = gridmap.local_to_map(target_player.global_position)
|
||
var center_pos = Vector2i(map_pos.x, map_pos.z)
|
||
|
||
# 3x3 neighborhood
|
||
for dx in range(-1, 2):
|
||
for dz in range(-1, 2):
|
||
var check_pos = center_pos + Vector2i(dx, dz)
|
||
if is_sticky_cell(check_pos):
|
||
clear_sticky_cell(check_pos)
|
||
|
||
# Remove slow effect for any player in the cleansed area
|
||
for p in all_players:
|
||
if is_instance_valid(p) and p.has_method("remove_slow_effect"):
|
||
if gridmap:
|
||
var p_map_pos = gridmap.local_to_map(p.global_position)
|
||
var p_cell_pos = Vector2i(p_map_pos.x, p_map_pos.z)
|
||
if abs(p_cell_pos.x - center_pos.x) <= 1 and abs(p_cell_pos.y - center_pos.y) <= 1:
|
||
if _can_rpc():
|
||
p.rpc("remove_slow_effect")
|
||
else:
|
||
p.remove_slow_effect()
|
||
|
||
print("[Cleanser] Server cleared 3x3 area around %s for player %d" % [center_pos, pid])
|
||
|
||
@rpc("any_peer", "call_local", "reliable")
|
||
func rpc_use_cleanser(pos: Vector2i) -> void:
|
||
"""RPC for clients to clear a sticky cell via Cleanser."""
|
||
if multiplayer.is_server():
|
||
clear_sticky_cell(pos)
|
||
|
||
@rpc("any_peer", "call_local", "reliable")
|
||
func rpc_consume_cleanser(pid: int) -> void:
|
||
"""RPC for clients to report Cleanser consumption to server."""
|
||
if multiplayer.is_server():
|
||
player_cleansers[pid] = 0
|
||
if _can_rpc():
|
||
rpc("sync_cleanser_count", pid, 0)
|
||
|
||
@rpc("any_peer", "reliable")
|
||
func rpc_trigger_slowmo() -> void:
|
||
"""RPC for clients to request slow-mo from server."""
|
||
if multiplayer.is_server():
|
||
trigger_slowmo()
|
||
|
||
# =============================================================================
|
||
# Slow-Mo Effect
|
||
# =============================================================================
|
||
|
||
func trigger_slowmo(duration: float = 4.0) -> void:
|
||
"""Trigger slow-motion effect at 1/4 speed. Server-authoritative."""
|
||
if slowmo_active:
|
||
return
|
||
slowmo_active = true
|
||
slowmo_timer = duration
|
||
slowmo_duration = duration
|
||
Engine.time_scale = SLOWMO_SCALE
|
||
# Show visual overlay
|
||
if main_scene and main_scene.has_node("Camera3D200"):
|
||
_show_slowmo_overlay()
|
||
# Show slow-mo HUD label
|
||
if slowmo_label:
|
||
slowmo_label.visible = true
|
||
if _can_rpc():
|
||
rpc("sync_slowmo_start", duration)
|
||
|
||
func _end_slowmo() -> void:
|
||
slowmo_active = false
|
||
Engine.time_scale = 1.0
|
||
_hide_slowmo_overlay()
|
||
# Hide slow-mo HUD label
|
||
if slowmo_label:
|
||
slowmo_label.visible = false
|
||
if _can_rpc():
|
||
rpc("sync_slowmo_end")
|
||
|
||
func _show_slowmo_overlay() -> void:
|
||
if slowmo_overlay:
|
||
return
|
||
slowmo_overlay = ColorRect.new()
|
||
slowmo_overlay.color = Color(0.3, 0.5, 1.0, 0.1) # Subtle blue tint
|
||
slowmo_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
slowmo_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
var cam = main_scene.get_node_or_null("Camera3D200")
|
||
if cam:
|
||
# Find or create a CanvasLayer for the overlay
|
||
var canvas = CanvasLayer.new()
|
||
canvas.layer = 4
|
||
main_scene.add_child(canvas)
|
||
canvas.add_child(slowmo_overlay)
|
||
# Fade in
|
||
slowmo_overlay.color.a = 0.0
|
||
var tween = create_tween()
|
||
tween.tween_property(slowmo_overlay, "color:a", 0.1, 0.3)
|
||
|
||
func _hide_slowmo_overlay() -> void:
|
||
if slowmo_overlay:
|
||
var tween = create_tween()
|
||
tween.tween_property(slowmo_overlay, "color:a", 0.0, 0.3)
|
||
tween.tween_callback(slowmo_overlay.get_parent().queue_free)
|
||
slowmo_overlay = null
|
||
|
||
@rpc("authority", "call_local", "reliable")
|
||
func sync_slowmo_start(duration: float) -> void:
|
||
slowmo_active = true
|
||
slowmo_timer = duration
|
||
Engine.time_scale = SLOWMO_SCALE
|
||
_show_slowmo_overlay()
|
||
if slowmo_label:
|
||
slowmo_label.visible = true
|
||
|
||
@rpc("authority", "call_local", "reliable")
|
||
func sync_slowmo_end() -> void:
|
||
_end_slowmo()
|
||
|
||
# =============================================================================
|
||
# HUD
|
||
# =============================================================================
|
||
|
||
func _setup_hud() -> void:
|
||
var hud_instance = _gauntlet_hud_scene.instantiate()
|
||
hud_layer = hud_instance
|
||
hud_layer.visible = false
|
||
add_child(hud_layer)
|
||
phase_label = hud_layer.get_node("BottomContainer/VBoxContainer/PhaseLabel")
|
||
cleanser_icon = hud_layer.get_node("BottomContainer/VBoxContainer/CleanserHBox/CleanserIcon")
|
||
cleanser_label = hud_layer.get_node("BottomContainer/VBoxContainer/CleanserHBox/CleanserLabel")
|
||
slowmo_label = hud_layer.get_node_or_null("TopContainer/SlowMoLabel")
|
||
_generate_cleanser_icon()
|
||
|
||
func _generate_cleanser_icon() -> void:
|
||
var icon_img = Image.create(16, 16, false, Image.FORMAT_RGBA8)
|
||
icon_img.fill(Color(0.4, 0.9, 1.0))
|
||
icon_img.blend_rect(icon_img, Rect2i(2, 2, 12, 12), Vector2i(1, 1))
|
||
for x in range(16):
|
||
icon_img.set_pixel(x, 0, Color(0.2, 0.6, 0.7))
|
||
icon_img.set_pixel(x, 15, Color(0.2, 0.6, 0.7))
|
||
for y in range(16):
|
||
icon_img.set_pixel(0, y, Color(0.2, 0.6, 0.7))
|
||
icon_img.set_pixel(15, y, Color(0.2, 0.6, 0.7))
|
||
for i in range(4, 12):
|
||
icon_img.set_pixel(i, 7, Color(1.0, 1.0, 1.0, 0.8))
|
||
icon_img.set_pixel(7, i, Color(1.0, 1.0, 1.0, 0.8))
|
||
cleanser_icon.texture = ImageTexture.create_from_image(icon_img)
|
||
|
||
func _update_hud_phase(phase_name: String) -> void:
|
||
if phase_label:
|
||
var icon = "🍬"
|
||
match phase_name:
|
||
"Middle Pressure":
|
||
icon = "⚠️"
|
||
phase_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2)) # Warning gold
|
||
"Inner Survival":
|
||
icon = "💀"
|
||
phase_label.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3)) # Danger red
|
||
_:
|
||
phase_label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.8)) # Candy pink
|
||
phase_label.text = "%s %s" % [icon, phase_name.to_upper()]
|
||
|
||
# Animate phase label with bounce effect
|
||
_animate_phase_label()
|
||
|
||
func update_cleanser_ui(count: int) -> void:
|
||
cleanser_count = count
|
||
if cleanser_label:
|
||
cleanser_label.text = "[E] Cleanser (%d)" % count
|
||
# Show/hide icon based on availability
|
||
if cleanser_icon:
|
||
cleanser_icon.visible = count > 0
|
||
if count > 0:
|
||
# Pulse animation when cleanser is available
|
||
var tween = create_tween()
|
||
tween.set_loops(2)
|
||
tween.tween_property(cleanser_icon, "modulate", Color(1.5, 1.5, 1.5, 1), 0.3)
|
||
tween.tween_property(cleanser_icon, "modulate", Color.WHITE, 0.3)
|
||
|
||
func _animate_phase_label() -> void:
|
||
"""Animate phase label with bounce effect."""
|
||
if not phase_label:
|
||
return
|
||
|
||
# Create tween for bounce animation
|
||
var tween = create_tween()
|
||
tween.set_ease(Tween.EASE_OUT)
|
||
tween.set_trans(Tween.TRANS_ELASTIC)
|
||
|
||
# Scale up then back to normal
|
||
var original_scale = phase_label.scale
|
||
tween.tween_property(phase_label, "scale", original_scale * 1.2, 0.1)
|
||
tween.tween_property(phase_label, "scale", original_scale, 0.2)
|
||
|
||
# Flash effect
|
||
tween.tween_property(phase_label, "modulate", Color(2, 2, 2, 1), 0.1)
|
||
tween.tween_property(phase_label, "modulate", Color.WHITE, 0.2)
|
||
|
||
# =============================================================================
|
||
# GoalsCycleManager Integration
|
||
# =============================================================================
|
||
|
||
func _on_goal_count_updated(peer_id: int, count: int) -> void:
|
||
"""Called when a player completes a goal cycle. Grant cleanser every 2 missions."""
|
||
if not multiplayer.is_server():
|
||
return
|
||
|
||
# Track mission completions per player
|
||
if not player_mission_completions.has(peer_id):
|
||
player_mission_completions[peer_id] = 0
|
||
player_mission_completions[peer_id] += 1
|
||
|
||
# Grant cleanser every 2 missions
|
||
var completions = player_mission_completions[peer_id]
|
||
if completions % 2 == 0:
|
||
if not player_cleansers.has(peer_id):
|
||
player_cleansers[peer_id] = 0
|
||
|
||
# Allow stacking cleanser charges instead of capping at 1
|
||
player_cleansers[peer_id] += 1
|
||
emit_signal("cleanser_granted", peer_id)
|
||
print("[Gauntlet] Player %d granted Cleanser (Total: %d) (mission %d)" % [peer_id, player_cleansers[peer_id], completions])
|
||
|
||
# Sync cleanser count to HUD
|
||
rpc("sync_cleanser_count", peer_id, player_cleansers.get(peer_id, 0))
|
||
|
||
func _on_score_updated(peer_id: int, new_score: int) -> void:
|
||
"""Called when a player's score is updated."""
|
||
pass # Score sync handled by GoalsCycleManager
|
||
|
||
|
||
|
||
@rpc("authority", "call_local", "reliable")
|
||
func sync_cleanser_count(peer_id: int, count: int) -> void:
|
||
"""Sync cleanser count to HUD for specific player."""
|
||
# Update local player's cleanser UI
|
||
var local_pid = multiplayer.get_unique_id()
|
||
if peer_id == local_pid:
|
||
update_cleanser_ui(count)
|
||
|
||
# =============================================================================
|
||
# Utility
|
||
# =============================================================================
|
||
|
||
func _can_rpc() -> bool:
|
||
if not multiplayer.has_multiplayer_peer(): return false
|
||
if multiplayer.multiplayer_peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED: return false
|
||
return true
|