Files
tekton/scripts/managers/gauntlet_manager.gd
T

1117 lines
37 KiB
GDScript

extends Node
class_name GauntletManager
# GauntletManager - Handles Candy Cannon Survival (Gauntlet) game mode
# Pattern: StopNGoManager + PortalModeManager
signal phase_changed(phase_index: int, phase_name: String)
signal cannon_fired(targets: 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)
# 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
# =============================================================================
# Cannon State
# =============================================================================
var cannon_timer: float = 0.0
var cannon_interval: float = 5.0 # seconds between volleys
var volley_size: int = 5
var sticky_cells: Dictionary = {} # Vector2i → true
var last_targeted_player_id: int = -1
# 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},
]
# =============================================================================
# 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)
# =============================================================================
# Trapped Players
# =============================================================================
var trapped_players: Dictionary = {} # player_id → true
# =============================================================================
# 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
var candy_cannon_scene: PackedScene = preload("res://scenes/candy_cannon.tscn")
var cannon_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
# =============================================================================
# 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
elapsed_time += delta
# Phase escalation
_check_phase_transition()
# Server only logic
if multiplayer.is_server():
# Cannon timer
cannon_timer -= delta
if cannon_timer <= 0.0:
_fire_volley()
cannon_timer = cannon_interval
# 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
var config = phase_configs[int(phase)]
cannon_interval = config["interval"]
volley_size = config["volley"]
cannon_timer = cannon_interval
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 "Open Arena"
Phase.ROUTE_PRESSURE:
return "Route Pressure"
Phase.SURVIVAL_ENDGAME:
return "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
var config = phase_configs[phase_index]
cannon_interval = config["interval"]
volley_size = config["volley"]
_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 Cannon)
if _is_npc_zone(pos):
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_OBSTACLE)
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:
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_OBSTACLE)
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 cannon_instance and main_scene:
cannon_instance = candy_cannon_scene.instantiate()
cannon_instance.name = "CandyCannon"
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)
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 cannon zone (center 3x3)
if _is_npc_zone(pos):
continue
# Check base floor — don't spawn on obstacles or void
var base_tile = gridmap.get_cell_item(Vector3i(x, 0, z))
if base_tile == TILE_OBSTACLE or base_tile == -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])
# =============================================================================
# Cannon Logic (Server Only)
# =============================================================================
func _fire_volley() -> void:
"""Select target cells, highlight, telegraph, then apply sticky after delay."""
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 = []
for x in range(ARENA_COLUMNS):
for z in range(ARENA_ROWS):
var pos = Vector2i(x, z)
if _is_npc_zone(pos):
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
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
# =============================================================================
# Telegraph & Impact (RPCs)
# =============================================================================
@rpc("authority", "call_local", "reliable")
func sync_telegraph_highlight(targets: Array) -> void:
"""Show pulsing floor highlight on target cells BEFORE the telegraph drop."""
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
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH)
# Animate telegraph with Tween (build-up phase)
_animate_telegraph(targets)
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
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
@rpc("authority", "call_local", "reliable")
func sync_impact(targets: Array) -> void:
"""Apply sticky cells at target positions."""
if not gridmap: return
for target in targets:
var pos = target as Vector2i
# Replace telegraph with sticky on Layer 2
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
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
_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
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()
# =============================================================================
# Sticky / Trap System
# =============================================================================
func is_sticky_cell(pos: Vector2i) -> bool:
return sticky_cells.has(pos)
func _check_all_players_trapped() -> void:
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)
func _trap_player(player: Node) -> void:
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)
if gridmap:
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1)
# Sync removal to clients
if main_scene and _can_rpc():
main_scene.rpc("sync_grid_item", pos.x, 2, pos.y, -1)
func _try_use_cleanser() -> void:
"""Local player attempts to use Cleanser on adjacent sticky cells."""
var local_pid = multiplayer.get_unique_id()
var count = player_cleansers.get(local_pid, 0)
if count <= 0:
return
# Find local player
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 or not gridmap:
return
# Get player grid position
var player_pos = local_player.global_position
var grid_pos = Vector2i(int(player_pos.x), int(player_pos.z))
# Clear sticky cells in 3x3 area around player
var cleared_any = false
for dx in range(-1, 2):
for dz in range(-1, 2):
var check_pos = grid_pos + Vector2i(dx, dz)
if sticky_cells.has(check_pos):
if multiplayer.is_server():
clear_sticky_cell(check_pos)
else:
rpc("rpc_use_cleanser", check_pos)
cleared_any = true
if cleared_any:
# Consume cleanser
player_cleansers[local_pid] = 0
update_cleanser_ui(0)
# Trigger slow-mo for dramatic effect
if multiplayer.is_server():
trigger_slowmo()
else:
rpc("rpc_trigger_slowmo")
# Notify server if we're a client
if not multiplayer.is_server() and _can_rpc():
rpc("rpc_consume_cleanser", local_pid)
elif multiplayer.is_server():
# Sync to all clients
if _can_rpc():
rpc("sync_cleanser_count", local_pid, 0)
@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()
if _can_rpc():
rpc("sync_slowmo_start", duration)
func _end_slowmo() -> void:
slowmo_active = false
Engine.time_scale = 1.0
_hide_slowmo_overlay()
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()
@rpc("authority", "call_local", "reliable")
func sync_slowmo_end() -> void:
_end_slowmo()
# =============================================================================
# HUD
# =============================================================================
func _setup_hud() -> void:
hud_layer = CanvasLayer.new()
hud_layer.layer = 5
hud_layer.visible = false
add_child(hud_layer)
var custom_font = load("res://assets/fonts/Nougat-ExtraBlack.ttf")
# Phase label (top-center)
var top_container = CenterContainer.new()
top_container.set_anchors_preset(Control.PRESET_CENTER_TOP)
top_container.grow_horizontal = Control.GROW_DIRECTION_BOTH
top_container.grow_vertical = Control.GROW_DIRECTION_END
top_container.offset_top = 70
hud_layer.add_child(top_container)
phase_label = Label.new()
phase_label.text = "🍬 OPEN ARENA"
phase_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
if custom_font: phase_label.add_theme_font_override("font", custom_font)
phase_label.add_theme_font_size_override("font_size", 24)
phase_label.add_theme_color_override("font_outline_color", Color.BLACK)
phase_label.add_theme_constant_override("outline_size", 6)
phase_label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.8)) # Candy pink
top_container.add_child(phase_label)
# Cleanser HUD (bottom-center) with icon
var bottom_container = CenterContainer.new()
bottom_container.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
bottom_container.grow_horizontal = Control.GROW_DIRECTION_BOTH
bottom_container.grow_vertical = Control.GROW_DIRECTION_BEGIN
bottom_container.offset_bottom = -50
hud_layer.add_child(bottom_container)
var cleanser_hbox = HBoxContainer.new()
cleanser_hbox.add_theme_constant_override("separation", 6)
bottom_container.add_child(cleanser_hbox)
# Cleanser icon (colored square as visual indicator)
cleanser_icon = TextureRect.new()
cleanser_icon.custom_minimum_size = Vector2(20, 20)
cleanser_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
# Generate a simple colored texture for the cleanser icon
var icon_img = Image.create(16, 16, false, Image.FORMAT_RGBA8)
icon_img.fill(Color(0.4, 0.9, 1.0)) # Cyan/teal for cleanser
icon_img.blend_rect(icon_img, Rect2i(2, 2, 12, 12), Vector2i(1, 1))
# Add a darker border
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))
# Add a cross/sparkle pattern for "cleansing" effect
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))
var icon_tex = ImageTexture.create_from_image(icon_img)
cleanser_icon.texture = icon_tex
cleanser_hbox.add_child(cleanser_icon)
# Cleanser text label
cleanser_label = Label.new()
cleanser_label.text = "Cleanser: 0"
cleanser_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
if custom_font: cleanser_label.add_theme_font_override("font", custom_font)
cleanser_label.add_theme_font_size_override("font_size", 20)
cleanser_label.add_theme_color_override("font_outline_color", Color.BLACK)
cleanser_label.add_theme_constant_override("outline_size", 6)
cleanser_hbox.add_child(cleanser_label)
func _update_hud_phase(phase_name: String) -> void:
if phase_label:
var icon = "🍬"
match phase_name:
"Route Pressure":
icon = "⚠️"
phase_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2)) # Warning gold
"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 = "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 (max 1)
var completions = player_mission_completions[peer_id]
if completions % 2 == 0:
if not player_cleansers.has(peer_id):
player_cleansers[peer_id] = 0
if player_cleansers[peer_id] < 1:
player_cleansers[peer_id] = 1
print("[Gauntlet] Player %d granted Cleanser (mission %d)" % [peer_id, completions])
# Respawn mission tiles in non-sticky locations
_respawn_mission_tiles()
# 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
func _respawn_mission_tiles() -> void:
"""Respawn mission tiles in non-sticky locations after mission completion."""
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
# Find empty non-sticky cells to place new tiles
var empty_cells: Array = []
for x in range(ARENA_COLUMNS):
for z in range(ARENA_ROWS):
var pos = Vector2i(x, z)
if _is_npc_zone(pos):
continue
# Skip boundary walls
if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1:
continue
# Skip sticky cells
if sticky_cells.has(pos):
continue
# Check if cell is empty on Layer 1
var current_item = gridmap.get_cell_item(Vector3i(x, 1, z))
if current_item == -1:
empty_cells.append(pos)
# Shuffle and place tiles
empty_cells.shuffle()
var tiles_to_place = min(empty_cells.size(), 20) # Limit respawn count
for i in range(tiles_to_place):
var pos = empty_cells[i]
var tile_type = goal_items[randi() % goal_items.size()]
gridmap.set_cell_item(Vector3i(pos.x, 1, pos.z), tile_type)
tiles_spawned += 1
# Sync to clients
if main_scene:
main_scene.rpc("sync_grid_item", pos.x, 1, pos.z, tile_type)
print("[Gauntlet] Respawned %d mission tiles" % tiles_spawned)
@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