feat: add Candy Cannon Survival game mode with collectible tiles
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).
This commit is contained in:
@@ -0,0 +1,537 @@
|
||||
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
|
||||
Reference in New Issue
Block a user