7380161743
Version bump to 2.3.6. New game mode features 20×20 arena with central cannon obstacle, three escalating phases (Open Arena, Route Pressure, Survival), and collectible tiles (Hearts, Diamonds, Stars, Coins) with pattern-matching missions. Players dodge candy volleys while completing collection goals. Updated export paths and version strings across all platforms (Windows, Android, Web, Linux).
538 lines
18 KiB
GDScript
538 lines
18 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)
|
|
# =============================================================================
|
|
|
|
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
|
|
|
|
# =============================================================================
|
|
# References
|
|
# =============================================================================
|
|
|
|
var main_scene: Node = null
|
|
var gridmap: Node = null
|
|
|
|
# HUD
|
|
var hud_layer: CanvasLayer
|
|
var phase_label: Label
|
|
var cleanser_label: Label
|
|
|
|
# =============================================================================
|
|
# Lifecycle
|
|
# =============================================================================
|
|
|
|
func _ready():
|
|
set_process(false)
|
|
_setup_hud()
|
|
|
|
func initialize(main: Node, grid: Node) -> void:
|
|
main_scene = main
|
|
gridmap = grid
|
|
print("[Gauntlet] Initialized with gridmap: ", gridmap.name if gridmap else "null")
|
|
|
|
func _process(delta: float) -> void:
|
|
if not is_active:
|
|
return
|
|
|
|
elapsed_time += delta
|
|
|
|
# Phase escalation
|
|
_check_phase_transition()
|
|
|
|
# Cannon timer (server only)
|
|
if multiplayer.is_server():
|
|
cannon_timer -= delta
|
|
if cannon_timer <= 0.0:
|
|
_fire_volley()
|
|
cannon_timer = cannon_interval
|
|
|
|
# =============================================================================
|
|
# 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
|
|
|
|
# Outer edge (row 0, row 19, col 0, col 19) — cannon spawn positions
|
|
# These are walkable but used as spawn reference
|
|
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()
|
|
|
|
print("[Gauntlet] Arena setup complete. Center NPC at (%d,%d), size %dx%d" % [
|
|
NPC_CENTER.x, NPC_CENTER.y, NPC_SIZE, NPC_SIZE
|
|
])
|
|
|
|
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
|
|
|
|
# =============================================================================
|
|
# 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, 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"]
|
|
|
|
# Telegraph phase — show warning
|
|
if _can_rpc():
|
|
rpc("sync_telegraph", targets)
|
|
|
|
# Wait telegraph duration, then apply impact
|
|
await get_tree().create_timer(telegraph_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(targets: Array) -> void:
|
|
"""Show warning overlay on target cells."""
|
|
if not gridmap: return
|
|
for target in targets:
|
|
var pos = target as Vector2i
|
|
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH)
|
|
|
|
@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, 4.0)
|
|
|
|
# Check if any player is now trapped
|
|
_check_all_players_trapped()
|
|
|
|
# =============================================================================
|
|
# 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", -1)):
|
|
_trap_player(player)
|
|
|
|
func _trap_player(player: Node) -> void:
|
|
var pid = player.get("peer_id", -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)
|
|
|
|
# TODO: Apply movement lockout, score penalty, visual feedback
|
|
# For now, just mark as trapped — will be expanded in Task #4
|
|
|
|
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)
|
|
|
|
# =============================================================================
|
|
# 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 label (bottom-center)
|
|
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)
|
|
|
|
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)
|
|
bottom_container.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()]
|
|
|
|
func update_cleanser_ui(count: int) -> void:
|
|
if cleanser_label:
|
|
cleanser_label.text = "🧹 Cleanser: %d" % 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
|