rename gauntlet→candy_survival + rewrite Candy Survival per boss design

- Rename enum GAUNTLET→CANDY_SURVIVAL, all gauntlet_*→candy_survival_*
- Rename files: gauntlet_manager→candy_survival_manager, candy_cannon→candy_survival_npc, gauntlet.tscn→candy_survival.tscn
- Rewrite candy_survival_manager.gd: blueprints, candy stack, Mekton delivery, Sugar Rush, knock/ghost charges, sticky-as-wall
- Update player_movement_manager.gd: smack→knock system, ghost integration
- All Candy Survival issues (#54-57, #65-70) retitled per boss design
- Shared managers (goals_cycle, goal, player_race, playerboard, turn) untouched
This commit is contained in:
god
2026-07-06 01:28:39 +08:00
parent 114748a54f
commit 528e22875d
33 changed files with 731 additions and 2181 deletions
@@ -24,7 +24,7 @@ albedo_color = Color(0.4973, 0.6, 0.12599999, 1)
material = SubResource("StandardMaterial3D_86fyc") material = SubResource("StandardMaterial3D_86fyc")
size = Vector2(50, 50) size = Vector2(50, 50)
[node name="Gauntlet" type="Node3D" unique_id=1063002869] [node name="Candy Survival" type="Node3D" unique_id=1063002869]
[node name="PlaceholderFloor" type="MeshInstance3D" parent="." unique_id=932640085] [node name="PlaceholderFloor" type="MeshInstance3D" parent="." unique_id=932640085]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 10, 0, 10) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 10, 0, 10)
@@ -2,7 +2,7 @@
[ext_resource type="FontFile" uid="uid://xnjx058n4tsw" path="res://assets/fonts/Nougat-ExtraBlack.ttf" id="1_font"] [ext_resource type="FontFile" uid="uid://xnjx058n4tsw" path="res://assets/fonts/Nougat-ExtraBlack.ttf" id="1_font"]
[node name="GauntletHUD" type="CanvasLayer"] [node name="CandySurvivalHUD" type="CanvasLayer"]
layer = 5 layer = 5
visible = false visible = false
@@ -1,6 +1,6 @@
[gd_scene format=3 uid="uid://ddy2r7xto80gq"] [gd_scene format=3 uid="uid://ddy2r7xto80gq"]
[ext_resource type="Script" uid="uid://du7cne5070ia0" path="res://scripts/controllers/candy_cannon_controller.gd" id="1_canon"] [ext_resource type="Script" uid="uid://du7cne5070ia0" path="res://scripts/controllers/candy_survival_npc_controller.gd" id="1_canon"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_base"] [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_base"]
albedo_color = Color(0.15, 0.15, 0.2, 1) albedo_color = Color(0.15, 0.15, 0.2, 1)
@@ -43,7 +43,7 @@ inner_radius = 0.95
outer_radius = 1.15 outer_radius = 1.15
rings = 32 rings = 32
[node name="CandyCannon" type="Node3D" unique_id=1515964905] [node name="CandySurvivalNPC" type="Node3D" unique_id=1515964905]
script = ExtResource("1_canon") script = ExtResource("1_canon")
[node name="Base" type="MeshInstance3D" parent="." unique_id=867716100] [node name="Base" type="MeshInstance3D" parent="." unique_id=867716100]
+1 -1
View File
@@ -86,7 +86,7 @@ var sng_go_option: OptionButton
var sng_stop_option: OptionButton var sng_stop_option: OptionButton
var sng_goals_option: OptionButton var sng_goals_option: OptionButton
# Gauntlet settings # Candy Survival settings
@onready var players_container = $LobbyPanel/PlayersContainer @onready var players_container = $LobbyPanel/PlayersContainer
@onready var players_container2 = $LobbyPanel/PlayersContainer2 @onready var players_container2 = $LobbyPanel/PlayersContainer2
@onready var player_slots: Array[Control] = [] @onready var player_slots: Array[Control] = []
+23 -23
View File
@@ -14,7 +14,7 @@ var stop_n_go_manager
var stop_n_go_winner_id: int = -1 # Track who finished first in Stop n Go mode var stop_n_go_winner_id: int = -1 # Track who finished first in Stop n Go mode
var is_match_ended: bool = false var is_match_ended: bool = false
var obstacle_manager var obstacle_manager
var gauntlet_manager var candy_survival_manager
var vfx_manager var vfx_manager
# Minimal local state # Minimal local state
@@ -144,9 +144,9 @@ func _apply_arena_background():
texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg" # Fallback texture texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg" # Fallback texture
_instantiate_3d_arena("res://scenes/arena/freemode.tscn") _instantiate_3d_arena("res://scenes/arena/freemode.tscn")
_hide_ground_tiles() _hide_ground_tiles()
"Gauntlet Arena": "Candy Survival Arena":
texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg" texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg"
_instantiate_3d_arena("res://scenes/arena/gauntlet.tscn") _instantiate_3d_arena("res://scenes/arena/candy_survival.tscn")
_hide_ground_tiles() _hide_ground_tiles()
"Classic", _: "Classic", _:
texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg" texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg"
@@ -234,12 +234,12 @@ func _init_managers():
# No direct initialize() yet, but we'll call start_game_mode later # No direct initialize() yet, but we'll call start_game_mode later
# Gauntlet manager for Candy Pump Survival mode # Candy Survival manager
if LobbyManager.game_mode == "Candy Pump Survival": if LobbyManager.game_mode == "Candy Survival":
gauntlet_manager = load("res://scripts/managers/gauntlet_manager.gd").new() candy_survival_manager = load("res://scripts/managers/candy_survival_manager.gd").new()
gauntlet_manager.name = "GauntletManager" candy_survival_manager.name = "CandySurvivalManager"
add_child(gauntlet_manager) add_child(candy_survival_manager)
gauntlet_manager.initialize(self, $EnhancedGridMap) candy_survival_manager.initialize(self, $EnhancedGridMap)
# Screen shake manager for impact feedback # Screen shake manager for impact feedback
screen_shake_manager = load("res://scripts/managers/screen_shake.gd").new() screen_shake_manager = load("res://scripts/managers/screen_shake.gd").new()
@@ -601,8 +601,8 @@ func _setup_host_game():
# Spawning and arena setup # Spawning and arena setup
if LobbyManager.game_mode == "Stop n Go" and stop_n_go_manager: if LobbyManager.game_mode == "Stop n Go" and stop_n_go_manager:
stop_n_go_manager._setup_arena() stop_n_go_manager._setup_arena()
elif LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager: elif LobbyManager.game_mode == "Candy Survival" and candy_survival_manager:
gauntlet_manager._setup_arena() candy_survival_manager._setup_arena()
else: else:
# Randomize grid first to ensure Floor 0 is walkable for pre-calculation # Randomize grid first to ensure Floor 0 is walkable for pre-calculation
randomize_game_grid() randomize_game_grid()
@@ -705,9 +705,9 @@ func _setup_client_game():
add_player_character(i, true) add_player_character(i, true)
print("Client: Pre-spawned potential bot ", i) print("Client: Pre-spawned potential bot ", i)
# Initialize arena locally for Candy Pump Survival # Initialize arena locally for Candy Survival
if LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager: if LobbyManager.game_mode == "Candy Survival" and candy_survival_manager:
gauntlet_manager._apply_arena_setup() candy_survival_manager._apply_arena_setup()
# Ensure local player setup (UI, controls) is verified # Ensure local player setup (UI, controls) is verified
var player_character = get_node_or_null(str(my_id)) var player_character = get_node_or_null(str(my_id))
@@ -804,13 +804,13 @@ func _start_game():
stop_n_go_manager.setup_mission_tiles() stop_n_go_manager.setup_mission_tiles()
stop_n_go_manager.spawn_initial_powerups() # Ensure power-ups exist before 1,2,3 Go stop_n_go_manager.spawn_initial_powerups() # Ensure power-ups exist before 1,2,3 Go
# Gauntlet: Spawn mission tiles across 20x20 arena BEFORE countdown # Candy Survival: Spawn mission tiles BEFORE countdown
if LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager: if LobbyManager.game_mode == "Candy Survival" and candy_survival_manager:
gauntlet_manager.setup_mission_tiles() candy_survival_manager.setup_mission_tiles()
# Spawn Static Tektons and random tiles BEFORE countdown (Free Mode Only) # Spawn Static Tektons and random tiles BEFORE countdown (Free Mode Only)
# Exclude for Stop n Go and Candy Pump Survival # Exclude for Stop n Go and Candy Survival
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Candy Pump Survival": if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Candy Survival":
spawn_static_tektons() spawn_static_tektons()
# STOP N GO: Rotate players to face East BEFORE countdown # STOP N GO: Rotate players to face East BEFORE countdown
@@ -838,9 +838,9 @@ func _start_game():
if goals_cycle_manager: if goals_cycle_manager:
var match_duration = LobbyManager.get_match_duration() var match_duration = LobbyManager.get_match_duration()
goals_cycle_manager.start_match(float(match_duration), false) # No cycles for Stop n Go goals_cycle_manager.start_match(float(match_duration), false) # No cycles for Stop n Go
elif LobbyManager.game_mode == "Candy Pump Survival": elif LobbyManager.game_mode == "Candy Survival":
if gauntlet_manager: if candy_survival_manager:
gauntlet_manager.start_game_mode() candy_survival_manager.start_game_mode()
if goals_cycle_manager: if goals_cycle_manager:
var match_duration = LobbyManager.get_match_duration() var match_duration = LobbyManager.get_match_duration()
@@ -1763,7 +1763,7 @@ func randomize_item_at_position(grid_position: Vector2i):
if is_ground: if is_ground:
var get_mode_specific_tile = func(): var get_mode_specific_tile = func():
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Candy Pump Survival": if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Candy Survival":
# 60% Chance for Common (7-10), 40% for PowerUp # 60% Chance for Common (7-10), 40% for PowerUp
if randf() <= 0.6: if randf() <= 0.6:
return [7, 8, 9, 10].pick_random() return [7, 8, 9, 10].pick_random()
+4 -4
View File
@@ -1140,11 +1140,11 @@ func _find_valid_drop_position() -> Vector2i:
var item_cell = Vector3i(pos.x, 1, pos.y) var item_cell = Vector3i(pos.x, 1, pos.y)
if enhanced_gridmap.get_cell_item(item_cell) == -1: if enhanced_gridmap.get_cell_item(item_cell) == -1:
if not is_position_occupied(pos): if not is_position_occupied(pos):
# Gauntlet Mode explicit overrides # Candy Survival explicit overrides
var gm = null var gm = null
var main_gauntlet = get_tree().root.get_node_or_null("Main") var main_candy_survival = get_tree().root.get_node_or_null("Main")
if main_gauntlet and main_gauntlet.get("gauntlet_manager"): if main_candy_survival and main_candy_survival.get("candy_survival_manager"):
gm = main_gauntlet.gauntlet_manager gm = main_candy_survival.candy_survival_manager
if gm and gm.is_active: if gm and gm.is_active:
if pos.x == 0 or pos.x == gm.ARENA_COLUMNS - 1 or pos.y == 0 or pos.y == gm.ARENA_ROWS - 1: if pos.x == 0 or pos.x == gm.ARENA_COLUMNS - 1 or pos.y == 0 or pos.y == gm.ARENA_ROWS - 1:
continue continue
+5 -5
View File
@@ -210,7 +210,7 @@ func _run_ai_tick():
print("[BotController] Action Taken: Attack Pursuit") print("[BotController] Action Taken: Attack Pursuit")
return return
# Priority 0.5: Gauntlet (#075) — use Ghost powerup if boxed in # Priority 0.5: Candy Survival (#075) — use Ghost powerup if boxed in
if await _try_activate_ghost(): if await _try_activate_ghost():
print("[BotController] Action Taken: Ghost (trapped)") print("[BotController] Action Taken: Ghost (trapped)")
return return
@@ -260,7 +260,7 @@ func _run_ai_tick():
return return
# ============================================================================= # =============================================================================
# Gauntlet (#075) — Ghost Powerup + Sticky Avoidance wiring # Candy Survival (#075) — Ghost Powerup + Sticky Avoidance wiring
# ============================================================================= # =============================================================================
func _try_activate_ghost() -> bool: func _try_activate_ghost() -> bool:
@@ -268,7 +268,7 @@ func _try_activate_ghost() -> bool:
Uses the existing SpecialTilesManager to activate the held ghost powerup. Uses the existing SpecialTilesManager to activate the held ghost powerup.
Returns true if activation was triggered.""" Returns true if activation was triggered."""
if not strategic_planner or not strategic_planner.is_gauntlet_mode(): if not strategic_planner or not strategic_planner.is_candy_survival_mode():
return false return false
if not strategic_planner.should_activate_ghost_now(): if not strategic_planner.should_activate_ghost_now():
return false return false
@@ -291,9 +291,9 @@ func _on_step_onto_unsafe() -> bool:
return false return false
# Post-move guard: if we somehow landed on a sticky without ghost active, # Post-move guard: if we somehow landed on a sticky without ghost active,
# burn Ghost powerup to phase through next tick. # burn Ghost powerup to phase through next tick.
if strategic_planner.is_gauntlet_mode() and strategic_planner._is_overlay_unsafe(here): if strategic_planner.is_candy_survival_mode() and strategic_planner._is_overlay_unsafe(here):
if not strategic_planner._is_bot_ghost_active(): if not strategic_planner._is_bot_ghost_active():
var gm = strategic_planner._get_gauntlet_manager() var gm = strategic_planner._get_candy_survival_manager()
if gm and gm.has_method("is_sticky_cell") and gm.is_sticky_cell(here): if gm and gm.has_method("is_sticky_cell") and gm.is_sticky_cell(here):
print("[BotController] %s stepped onto sticky at %s — activating Ghost" % [actor.name, here]) print("[BotController] %s stepped onto sticky at %s — activating Ghost" % [actor.name, here])
return _try_activate_ghost() return _try_activate_ghost()
+25 -25
View File
@@ -8,38 +8,38 @@ class_name BotStrategicPlanner
var actor: Node3D var actor: Node3D
var enhanced_gridmap: Node var enhanced_gridmap: Node
# Optional explicit gauntlet_manager binding (set by tests to avoid scene-tree # Optional explicit candy_survival_manager binding (set by tests to avoid scene-tree
# traversal collisions; production code uses _get_gauntlet_manager() instead). # traversal collisions; production code uses _get_candy_survival_manager() instead).
var gauntlet_manager_override: Node = null var candy_survival_manager_override: Node = null
# Tile type constants # Tile type constants
const GOAL_TILES = [7, 8, 9, 10] # Heart, Diamond, Star, Coin const GOAL_TILES = [7, 8, 9, 10] # Heart, Diamond, Star, Coin
const HOLO_TILES = [11, 12, 13, 14] # Power-up holo tiles const HOLO_TILES = [11, 12, 13, 14] # Power-up holo tiles
# Gauntlet overlay layer (v2 ground-growth model — sticky/telegraph on layer 2). # Candy Survival overlay layer (v2 ground-growth model — sticky/telegraph on layer 2).
# Bots must avoid these cells or use Ghost mode to cross. # Bots must avoid these cells or use Ghost mode to cross.
const GAUNTLET_OVERLAY_LAYER: int = 2 const CANDY_SURVIVAL_OVERLAY_LAYER: int = 2
const TILE_STICKY: int = 17 const TILE_STICKY: int = 17
const TILE_TELEGRAPH: int = 18 const TILE_TELEGRAPH: int = 18
# ============================================================================= # =============================================================================
# Gauntlet mode helpers (#075 — Bot AI: Sticky Avoidance & Pathfinding) # Candy Survival mode helpers (#075 — Bot AI: Sticky Avoidance & Pathfinding)
# ============================================================================= # =============================================================================
func is_gauntlet_mode() -> bool: func is_candy_survival_mode() -> bool:
return LobbyManager and LobbyManager.is_game_mode(GameMode.Mode.GAUNTLET) return LobbyManager and LobbyManager.is_game_mode(GameMode.Mode.CANDY_SURVIVAL)
func _get_gauntlet_manager() -> Node: func _get_candy_survival_manager() -> Node:
"""Resolve the active GauntletManager. """Resolve the active CandySurvivalManager.
Order of resolution: Order of resolution:
1. Explicit `gauntlet_manager_override` (used by tests). 1. Explicit `candy_survival_manager_override` (used by tests).
2. Walk actor's ancestors for any node containing a GauntletManager child 2. Walk actor's ancestors for any node containing a CandySurvivalManager child
(production path — robust to non-standard scene trees). (production path — robust to non-standard scene trees).
3. Fallback: scan /root children for a GauntletManager. 3. Fallback: scan /root children for a CandySurvivalManager.
""" """
if gauntlet_manager_override and is_instance_valid(gauntlet_manager_override): if candy_survival_manager_override and is_instance_valid(candy_survival_manager_override):
return gauntlet_manager_override return candy_survival_manager_override
var root: Node = null var root: Node = null
if actor and actor.is_inside_tree(): if actor and actor.is_inside_tree():
root = actor.get_tree().get_root() root = actor.get_tree().get_root()
@@ -47,7 +47,7 @@ func _get_gauntlet_manager() -> Node:
# nested under Main → Arena → Player). # nested under Main → Arena → Player).
var n: Node = actor.get_parent() var n: Node = actor.get_parent()
while n: while n:
var gm = n.get_node_or_null("GauntletManager") var gm = n.get_node_or_null("CandySurvivalManager")
if gm: if gm:
return gm return gm
n = n.get_parent() n = n.get_parent()
@@ -56,7 +56,7 @@ func _get_gauntlet_manager() -> Node:
# Last-resort scan of root children (helps in unusual scene trees). # Last-resort scan of root children (helps in unusual scene trees).
for child in root.get_children(): for child in root.get_children():
if child.name.begins_with("Main") or child.name.begins_with("BotTestMain"): if child.name.begins_with("Main") or child.name.begins_with("BotTestMain"):
var gm2 = child.get_node_or_null("GauntletManager") var gm2 = child.get_node_or_null("CandySurvivalManager")
if gm2: if gm2:
return gm2 return gm2
return null return null
@@ -76,17 +76,17 @@ func _is_overlay_unsafe(pos: Vector2i) -> bool:
"""True if the cell carries a sticky or telegraphed overlay on layer 2.""" """True if the cell carries a sticky or telegraphed overlay on layer 2."""
if not enhanced_gridmap: if not enhanced_gridmap:
return false return false
var item = enhanced_gridmap.get_cell_item(Vector3i(pos.x, GAUNTLET_OVERLAY_LAYER, pos.y)) var item = enhanced_gridmap.get_cell_item(Vector3i(pos.x, CANDY_SURVIVAL_OVERLAY_LAYER, pos.y))
return item == TILE_STICKY or item == TILE_TELEGRAPH return item == TILE_STICKY or item == TILE_TELEGRAPH
func _is_cell_unsafe_in_gauntlet(pos: Vector2i) -> bool: func _is_cell_unsafe_in_candy_survival(pos: Vector2i) -> bool:
"""Cell is unsafe in Gauntlet if it's sticky/telegraphed — unless the bot's """Cell is unsafe in Candy Survival if it's sticky/telegraphed — unless the bot's
Ghost mode is active (grants sticky bypass).""" Ghost mode is active (grants sticky bypass)."""
if not is_gauntlet_mode(): if not is_candy_survival_mode():
return false return false
if _is_bot_ghost_active(): if _is_bot_ghost_active():
return false return false
var gm = _get_gauntlet_manager() var gm = _get_candy_survival_manager()
if gm and gm.has_method("is_sticky_cell") and gm.is_sticky_cell(pos): if gm and gm.has_method("is_sticky_cell") and gm.is_sticky_cell(pos):
return true return true
return _is_overlay_unsafe(pos) return _is_overlay_unsafe(pos)
@@ -101,7 +101,7 @@ func _count_unsafe_neighbors(pos: Vector2i) -> int:
func should_activate_ghost_now() -> bool: func should_activate_ghost_now() -> bool:
"""True if the bot is boxed in / about to be sealed and should use Ghost powerup.""" """True if the bot is boxed in / about to be sealed and should use Ghost powerup."""
if not is_gauntlet_mode(): if not is_candy_survival_mode():
return false return false
if not _bot_has_ghost_powerup(): if not _bot_has_ghost_powerup():
return false return false
@@ -613,12 +613,12 @@ func _is_valid_move_target(pos: Vector2i, ignore_players: bool = false) -> bool:
if not ignore_players and actor.is_position_occupied(pos): if not ignore_players and actor.is_position_occupied(pos):
return false return false
# Gauntlet mode (#075): reject cells that are sticky or telegraphed — # Candy Survival mode (#075): reject cells that are sticky or telegraphed —
# stepping onto them either traps the bot or strands it within 1s. # stepping onto them either traps the bot or strands it within 1s.
# Safety applies even when ignore_players is true (a sticky cell is unsafe # Safety applies even when ignore_players is true (a sticky cell is unsafe
# regardless of whether another player is on it). Ghost-active bots are # regardless of whether another player is on it). Ghost-active bots are
# exempt via the helper. # exempt via the helper.
if _is_cell_unsafe_in_gauntlet(pos): if _is_cell_unsafe_in_candy_survival(pos):
return false return false
return true return true
@@ -1,5 +1,5 @@
extends Node3D extends Node3D
class_name CandyCannonController class_name CandySurvivalNPCController
@export var is_static_turret: bool = true @export var is_static_turret: bool = true
@@ -67,7 +67,7 @@ func spawn_projectile(target_world_pos: Vector3, duration: float) -> void:
projectile.mesh = sphere projectile.mesh = sphere
var mat = StandardMaterial3D.new() var mat = StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.4, 0.8) # Candy pink for Gauntlet mat.albedo_color = Color(1.0, 0.4, 0.8) # Candy pink for Candy Survival
mat.emission_enabled = true mat.emission_enabled = true
mat.emission = Color(1.0, 0.4, 0.8) mat.emission = Color(1.0, 0.4, 0.8)
mat.emission_energy_multiplier = 3.0 mat.emission_energy_multiplier = 3.0
+7 -7
View File
@@ -1,7 +1,7 @@
extends RefCounted extends RefCounted
class_name GameMode class_name GameMode
enum Mode { FREEMODE = 0, STOP_N_GO = 1, GAUNTLET = 2 } enum Mode { FREEMODE = 0, STOP_N_GO = 1, CANDY_SURVIVAL = 2 }
static func from_string(mode: String) -> Mode: static func from_string(mode: String) -> Mode:
match mode: match mode:
@@ -9,8 +9,8 @@ static func from_string(mode: String) -> Mode:
return Mode.FREEMODE return Mode.FREEMODE
"Stop n Go": "Stop n Go":
return Mode.STOP_N_GO return Mode.STOP_N_GO
"Candy Pump Survival": "Candy Survival":
return Mode.GAUNTLET return Mode.CANDY_SURVIVAL
return Mode.FREEMODE return Mode.FREEMODE
static func mode_to_string(mode: Mode) -> String: static func mode_to_string(mode: Mode) -> String:
@@ -19,13 +19,13 @@ static func mode_to_string(mode: Mode) -> String:
return "Freemode" return "Freemode"
Mode.STOP_N_GO: Mode.STOP_N_GO:
return "Stop n Go" return "Stop n Go"
Mode.GAUNTLET: Mode.CANDY_SURVIVAL:
return "Candy Pump Survival" return "Candy Survival"
_: _:
return "Freemode" return "Freemode"
static func is_restricted(mode: Mode) -> bool: static func is_restricted(mode: Mode) -> bool:
return mode == Mode.STOP_N_GO or mode == Mode.GAUNTLET return mode == Mode.STOP_N_GO or mode == Mode.CANDY_SURVIVAL
static func get_all_modes() -> Array[String]: static func get_all_modes() -> Array[String]:
return ["Freemode", "Stop n Go", "Candy Pump Survival"] return ["Freemode", "Stop n Go", "Candy Survival"]
+3 -3
View File
@@ -12,7 +12,7 @@ var player: Node3D
@export var z_offset: float = 12.0 @export var z_offset: float = 12.0
@export var default_y: float = 19.636 @export var default_y: float = 19.636
var bounds_gauntlet = { "min_x": 0.0, "max_x": 20.0, "min_z": 10.0, "max_z": 32.0 } var bounds_candy_survival = { "min_x": 0.0, "max_x": 20.0, "min_z": 10.0, "max_z": 32.0 }
# Bounds Definitions { min_x, max_x, min_z, max_z } # Bounds Definitions { min_x, max_x, min_z, max_z }
var bounds_freemode = { "min_x": 3.0, "max_x": 11.0, "min_z": 13.0, "max_z": 22.5 } var bounds_freemode = { "min_x": 3.0, "max_x": 11.0, "min_z": 13.0, "max_z": 22.5 }
@@ -48,8 +48,8 @@ func _calculate_target_position() -> Vector3:
# Apply Mode-Specific Clamping # Apply Mode-Specific Clamping
var bounds = bounds_freemode # Default var bounds = bounds_freemode # Default
if mode == GameMode.Mode.GAUNTLET: if mode == GameMode.Mode.CANDY_SURVIVAL:
bounds = bounds_gauntlet bounds = bounds_candy_survival
elif mode == GameMode.Mode.STOP_N_GO: elif mode == GameMode.Mode.STOP_N_GO:
bounds = bounds_stop_n_go bounds = bounds_stop_n_go
+401
View File
@@ -0,0 +1,401 @@
extends Node
class_name CandySurvivalManager
# Candy Survival — Sugar Rush Chaos
# Speed Building Sabotage (blueprints + power-ups)
# ponytail: This replaces the old gauntlet ground-growth/sticky/smack system entirely.
# Legacy smack/cleanser/sticky_growth methods deleted — all movement blocking
# is now pure-collision sticky walls. Add back growth if boss requests a
# "rising sticky tide" mechanic later.
signal sugar_rush_started(pid)
signal sugar_rush_ended(pid)
signal mekton_face_changed(color_index)
# ── Arena ──
const ARENA_COLS: int = 18
const ARENA_ROWS: int = 18
const NPC_CENTER: Vector2i = Vector2i(8, 8)
const NPC_CELLS: int = 3 # 3x3 Mekton zone
# ── Blueprint ──
const BLUEPRINT_SIZE: int = 3
const OFF_COLOR_PENALTY: float = 0.5
# ── Candy ──
const CANDY_PPS: float = 1.0 # points per second per candy
const MULTI_STEP: float = 0.1 # extra multiplier per candy
# ── Sugar Rush ──
const SR_BASE_TIME: float = 2.0
const SR_PER_CANDY: float = 0.4 # extra seconds per delivered candy
const SR_SPEED: float = 2.0
# ── Knock / Ghost ──
const START_KNOCK: int = 5
const START_GHOST: int = 5
const GHOST_DURATION: float = 4.0
# ── Mekton ──
const COLOR_CYCLE_TIME: float = 5.0 # seconds between face color changes
enum CandyColor { HEART, DIAMOND, STAR, COIN }
# ── State ──
var active: bool = false
var arena_width: int = ARENA_COLS
var arena_height: int = ARENA_ROWS
var main_scene: Node
var gridmap: Node
var mekton_node: Node
var current_face: CandyColor = CandyColor.HEART
var face_timer: float = 0.0
var game_elapsed: float = 0.0
# Per-player state
var player_candies: Dictionary = {} # pid -> int (stack count)
var player_candy_color: Dictionary = {} # pid -> CandyColor (-1 if none)
var player_knocks: Dictionary = {} # pid -> int (charges left)
var player_ghosts: Dictionary = {} # pid -> int (charges left)
var player_ghost_active: Dictionary = {} # pid -> bool
var player_ghost_timer: Dictionary = {} # pid -> float
var player_score: Dictionary = {} # pid -> int
var player_blueprints: Dictionary = {} # pid -> int (completed count)
var player_position: Dictionary = {} # pid -> Vector2i
var player_sugar_rush: Dictionary = {} # pid -> float (remaining, 0=none)
var player_last_knocked_by: Dictionary = {} # pid -> int
# Sticky wall cells (pre-placed collision)
var sticky_cells: Dictionary = {} # Vector2i -> bool
# Pre-placed sticky positions for 18x18 arena
var preplaced_sticky: Array = []
func initialize(main, grid) -> void:
main_scene = main
gridmap = grid
_define_sticky_walls()
print("[CandySurvival] Initialized")
func _define_sticky_walls() -> void:
# Block Mekton NPC zone (3x3 center)
var cx = NPC_CENTER.x
var cy = NPC_CENTER.y
for dx in range(NPC_CELLS):
for dy in range(NPC_CELLS):
preplaced_sticky.append(Vector2i(cx + dx, cy + dy))
# Block arena outer boundary walls
for x in range(ARENA_COLS):
preplaced_sticky.append(Vector2i(x, 0))
preplaced_sticky.append(Vector2i(x, ARENA_ROWS - 1))
for y in range(ARENA_ROWS):
preplaced_sticky.append(Vector2i(0, y))
preplaced_sticky.append(Vector2i(ARENA_COLS - 1, y))
# Lobby-zone rows (rows 0-1 and rows 16-17 outside play field)
for x in range(2, ARENA_COLS - 2):
for y in [1, ARENA_ROWS - 2]:
preplaced_sticky.append(Vector2i(x, y))
func start_game_mode() -> void:
active = true
current_face = CandyColor.HEART
face_timer = 0.0
game_elapsed = 0.0
player_candies.clear()
player_candy_color.clear()
player_knocks.clear()
player_ghosts.clear()
player_ghost_active.clear()
player_ghost_timer.clear()
player_score.clear()
player_blueprints.clear()
player_position.clear()
player_sugar_rush.clear()
player_last_knocked_by.clear()
sticky_cells.clear()
# Mark pre-placed sticky as active
for pos in preplaced_sticky:
sticky_cells[pos] = true
# Init players from lobby
var pids = _get_player_ids()
for pid in pids:
player_candies[pid] = 0
player_candy_color[pid] = -1
player_knocks[pid] = START_KNOCK
player_ghosts[pid] = START_GHOST
player_ghost_active[pid] = false
player_ghost_timer[pid] = 0.0
player_score[pid] = 0
player_blueprints[pid] = 0
player_sugar_rush[pid] = 0.0
player_last_knocked_by[pid] = 0
print("[CandySurvival] Started with %d players" % pids.size())
func _process(delta: float) -> void:
if not active:
return
game_elapsed += delta
# Mekton face color cycle
face_timer += delta
if face_timer >= COLOR_CYCLE_TIME:
face_timer = 0.0
current_face = CandyColor.values()[(current_face + 1) % CandyColor.size()]
mekton_face_changed.emit(current_face)
# Sugar Rush timers
for pid in player_sugar_rush.keys():
if player_sugar_rush[pid] > 0:
player_sugar_rush[pid] -= delta
if player_sugar_rush[pid] <= 0:
player_sugar_rush[pid] = 0.0
_restore_time_scale()
sugar_rush_ended.emit(pid)
# Ghost timers
for pid in player_ghost_active.keys():
if player_ghost_active[pid]:
player_ghost_timer[pid] -= delta
if player_ghost_timer[pid] <= 0:
player_ghost_active[pid] = false
player_ghost_timer[pid] = 0.0
# Candy stack: points per second per candy
for pid in player_candies.keys():
var count = player_candies.get(pid, 0)
if count > 0:
var points = count * CANDY_PPS * delta
_add_score(pid, int(points))
# ── Blueprint ──
func on_blueprint_completed(pid: int, primary_color: int, off_color: bool) -> void:
# Called when a 3x3 pattern is detected as complete
var base_points = 1000
var points = base_points if not off_color else int(base_points * OFF_COLOR_PENALTY)
_add_score(pid, points)
player_blueprints[pid] = player_blueprints.get(pid, 0) + 1
# Award candy on head (matching blueprint primary color)
_give_candy(pid, primary_color)
func can_finish_with_off_color(pid: int, primary_color: int) -> bool:
# Returns true if primary color tiles are exhausted on the grid
return not _grid_has_color_tiles(primary_color)
func _grid_has_color_tiles(color: int) -> bool:
# Check if any tile of given color exists on gridmap layer 1
if not gridmap:
return true
var tile_ids = {0: 7, 1: 8, 2: 9, 3: 10} # Heart/Diamond/Star/Coin
var target = tile_ids.get(color, 7)
for x in range(1, ARENA_COLS - 1):
for y in range(1, ARENA_ROWS - 1):
var v = Vector3i(x, 1, y)
if gridmap.get_cell_item(v) == target:
return true
return false
# ── Candy Stack ──
func _give_candy(pid: int, color: int) -> void:
player_candies[pid] = player_candies.get(pid, 0) + 1
player_candy_color[pid] = color
_update_candy_badge(pid)
func _update_candy_badge(pid: int) -> void:
var count = player_candies.get(pid, 0)
var mult = 1.0 + count * MULTI_STEP
# RPC to update HUD
rpc("sync_candy_badge", pid, count, mult)
func get_multiplier(pid: int) -> float:
var count = player_candies.get(pid, 0)
return 1.0 + count * MULTI_STEP
# ── Mekton Delivery ──
func try_deliver(pid: int) -> bool:
# Player at Mekton zone tries to deliver matching-color candies
var color = player_candy_color.get(pid, -1)
if color == -1 or player_candies.get(pid, 0) == 0:
return false # No candies to deliver
if color != current_face:
return false # Color mismatch
# Successful delivery: consume all candies of held color
var count = player_candies[pid]
var mult = get_multiplier(pid)
var points = count * 500 # base delivery points
_add_score(pid, points)
# Clear candy stack
player_candies[pid] = 0
player_candy_color[pid] = -1
_update_candy_badge(pid)
# Trigger Sugar Rush
_trigger_sugar_rush(pid, count, mult)
return true
func _trigger_sugar_rush(pid: int, candies: int, mult: float) -> void:
var duration = SR_BASE_TIME + candies * SR_PER_CANDY * mult
player_sugar_rush[pid] = duration
Engine.time_scale = SR_SPEED
sugar_rush_started.emit(pid)
func _restore_time_scale() -> void:
# Only restore if no other player is in rush
var any_rush = false
for pid in player_sugar_rush.keys():
if player_sugar_rush[pid] > 0:
any_rush = true
break
if not any_rush:
Engine.time_scale = 1.0
# ── Knock / Ghost ──
func try_knock(attacker: int, target: int) -> bool:
if player_ghost_active.get(target, false):
return false # Ghost immunity
if player_knocks.get(attacker, 0) <= 0:
return false # No charges
var target_candies = player_candies.get(target, 0)
if target_candies == 0:
# Backfire: attacker also knocked
player_knocks[attacker] = player_knocks.get(attacker, 0) - 1
# Both knocked (stunned briefly)
return false
# Steal all candies
player_candies[attacker] = player_candies.get(attacker, 0) + target_candies
player_candies[target] = 0
player_candy_color[target] = -1
player_candy_color[attacker] = player_candy_color.get(target, -1)
player_knocks[attacker] = player_knocks.get(attacker, 0) - 1
player_last_knocked_by[target] = attacker
_add_score(attacker, target_candies * 100)
_update_candy_badge(attacker)
_update_candy_badge(target)
rpc("sync_knock_result", attacker, target, target_candies)
return true
func try_activate_ghost(pid: int) -> bool:
if player_ghost_active.get(pid, false):
return false # Already ghosted
if player_ghosts.get(pid, 0) <= 0:
return false # No charges left
player_ghosts[pid] = player_ghosts.get(pid, 0) - 1
player_ghost_active[pid] = true
player_ghost_timer[pid] = GHOST_DURATION
rpc("sync_ghost_active", pid, true)
return true
func is_ghost_active(pid: int) -> bool:
return player_ghost_active.get(pid, false)
# ── Arena ──
func _setup_arena() -> void:
if not gridmap:
return
gridmap.clear_layer(0)
gridmap.clear_layer(1)
gridmap.clear_layer(2)
# Build floor
for x in range(ARENA_COLS):
for y in range(ARENA_ROWS):
gridmap.set_cell_item(Vector3i(x, 0, y), 0)
# Apply sticky cells on layer 2
for pos in sticky_cells.keys():
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), 14) # sticky overlay id
# Spawn Mekton NPC at center
if mekton_node:
mekton_node.queue_free()
if main_scene and main_scene.has_method("_spawn_mekton_npc"):
mekton_node = main_scene._spawn_mekton_npc(NPC_CENTER)
print("[CandySurvival] Arena 18x18 setup with Mekton at center")
func _apply_arena_setup() -> void:
# Client-side visual sync
pass
func setup_mission_tiles() -> void:
# Spawn tiles on layer 1 at 60% density, avoiding NPC zone + sticky
if not gridmap:
return
randomize()
var tile_ids = [7, 8, 9, 10] # Heart, Diamond, Star, Coin
var count = 0
for x in range(1, ARENA_COLS - 1):
for y in range(1, ARENA_ROWS - 1):
if sticky_cells.has(Vector2i(x, y)):
continue
if randf() < 0.6:
var tile = tile_ids[randi() % tile_ids.size()]
gridmap.set_cell_item(Vector3i(x, 1, y), tile)
count += 1
print("[CandySurvival] Spawned %d mission tiles" % count)
func get_spawn_points(count: int) -> Array:
# Return spawn points in quadrants avoiding NPC zone
var points = []
var quadrants = [
[Vector2i(2, 2), Vector2i(4, 2), Vector2i(2, 4)],
[Vector2i(ARENA_COLS - 3, 2), Vector2i(ARENA_COLS - 5, 2), Vector2i(ARENA_COLS - 3, 4)],
[Vector2i(2, ARENA_ROWS - 3), Vector2i(4, ARENA_ROWS - 3), Vector2i(2, ARENA_ROWS - 5)],
[Vector2i(ARENA_COLS - 3, ARENA_ROWS - 3), Vector2i(ARENA_COLS - 5, ARENA_ROWS - 3), Vector2i(ARENA_COLS - 3, ARENA_ROWS - 5)]
]
for i in range(min(count, quadrants.size())):
points.append(quadrants[i][0])
return points
func get_arena_positions() -> Dictionary:
return {"columns": ARENA_COLS, "rows": ARENA_ROWS}
func get_arena_bounds() -> Dictionary:
return {"min_x": -1.0, "max_x": float(ARENA_COLS), "min_z": -1.0, "max_z": float(ARENA_ROWS)}
# ── Sticky (now pure wall collision) ──
func is_sticky_cell(pos: Vector2i) -> bool:
return sticky_cells.has(pos)
func _is_npc_zone(pos: Vector2i) -> bool:
var cx = NPC_CENTER.x
var cy = NPC_CENTER.y
return pos.x >= cx and pos.x < cx + NPC_CELLS and pos.y >= cy and pos.y < cy + NPC_CELLS
# ── Scorring ──
func _add_score(pid: int, points: int) -> void:
player_score[pid] = player_score.get(pid, 0) + points
if main_scene and main_scene.has_method("add_points"):
main_scene.add_points(pid, points)
# ── Helpers ──
func _get_player_ids() -> Array:
if main_scene and main_scene.has_method("get_player_ids"):
return main_scene.get_player_ids()
return []
func candy_survival_round_duration() -> int:
return 180
func get_score(pid: int) -> int:
return player_score.get(pid, 0)
# ── RPCs ──
func sync_candy_badge(pid: int, count: int, mult: float) -> void:
pass # Client handles HUD update
func sync_knock_result(attacker: int, target: int, candies: int) -> void:
pass # Client handles animation
func sync_ghost_active(pid: int, active_on: bool) -> void:
pass # Client handles visual
-1825
View File
@@ -1,1825 +0,0 @@
extends Node
class_name GauntletManager
# GauntletManager - Handles Candy Pump Survival (Gauntlet) game mode
# Pattern: StopNGoManager + GauntletManager
signal phase_changed(phase_index: int, phase_name: String)
signal growth_tick(cells: Array)
signal player_trapped(player_id: int)
signal ghost_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
}
# Cells temporarily protected after Ghost-clearing (not used — kept for compat).
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.53)
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
# =============================================================================
# Ghost Reward Tracking (replaces Cleanser)
# =============================================================================
var player_mission_completions: Dictionary = {} # player_id → int
# =============================================================================
# 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 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)
# 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() and multiplayer.is_server():
rpc("sync_phase", int(phase), phase_name)
# Update phase explicitly with setup_arena
_shrink_arena()
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
if not multiplayer.is_server():
var bounds = get_arena_bounds()
for x in range(ARENA_COLUMNS):
for z in range(ARENA_ROWS):
var pos = Vector2i(x, z)
if pos.x <= bounds.min or pos.x >= bounds.max or pos.y <= bounds.min or pos.y >= bounds.max:
if not sticky_cells.has(pos):
sticky_cells[pos] = true
_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 x >= 8 and x <= 10 and z >= 8 and z <= 10:
# Hardcode clear all possible layers beneath the Candy Pump
for layer in range(5):
gridmap.set_cell_item(Vector3i(x, layer, z), -1)
continue
# Boundary walls: perimeter (row 0, row 19, col 0, col 19)
if pos.x <= 0 or pos.x >= 19 or pos.y <= 0 or pos.y >= 19:
# 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)
gridmap.set_cell_item(Vector3i(x, 2, z), TILE_STICKY)
sticky_cells[pos] = true
continue
# Interior: walkable floor
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE)
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
gridmap.set_cell_item(Vector3i(x, 2, 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."""
return pos.x >= 8 and pos.x <= 10 and pos.y >= 8 and pos.y <= 10
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
var main = get_node_or_null("/root/Main")
for x in range(ARENA_COLUMNS):
for z in range(ARENA_ROWS):
var pos = Vector2i(x, z)
# Skip NPC pump zone (center 3x3)
if x >= 8 and x <= 10 and z >= 8 and z <= 10:
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
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
if pos.x >= 8 and pos.x <= 10 and pos.y >= 8 and pos.y <= 10: continue
# 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 (00.8s): amber glow ramps alpha 01.
Flash (0.81.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
if pos.x >= 8 and pos.x <= 10 and pos.y >= 8 and pos.y <= 10: continue
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
# Outline shader for splash VFX
var outline_mat = ShaderMaterial.new()
outline_mat.shader = load("res://assets/shaders/outline3d.gdshader")
spatial_mat.next_pass = outline_mat
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()
# =============================================================================
# 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)."""
var b = get_arena_bounds()
if pos.x <= b.min or pos.x >= b.max or pos.y <= b.min or pos.y >= b.max:
return CellState.STICKY
if _is_npc_zone(pos) or _is_boundary(pos):
return CellState.BLOCKED
if is_sticky_cell(pos):
return CellState.STICKY
if cleansed_cells.has(pos):
return CellState.BLOCKED # Protected from regrowth temporarily
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 get_arena_bounds() -> Dictionary:
match current_phase:
Phase.OPEN_ARENA:
return {"min": 0, "max": 19} # 20x20
Phase.ROUTE_PRESSURE:
return {"min": 1, "max": 18} # 18x18
Phase.SURVIVAL_ENDGAME:
return {"min": 6, "max": 12} # 7x7
return {"min": 0, "max": 19}
func _shrink_arena() -> void:
if not multiplayer.is_server(): return
var b = get_arena_bounds()
var new_sticky = []
for x in range(ARENA_COLUMNS):
for z in range(ARENA_ROWS):
var pos = Vector2i(x, z)
if _is_npc_zone(pos) or _is_boundary(pos):
continue
if pos.x <= b.min or pos.x >= b.max or pos.y <= b.min or pos.y >= b.max:
if not sticky_cells.has(pos):
new_sticky.append(pos)
if new_sticky.size() > 0:
if _can_rpc() and multiplayer.is_server():
rpc("sync_growth_apply", new_sticky)
else:
sync_growth_apply(new_sticky)
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 b = get_arena_bounds()
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
if pos.x <= b.min or pos.x >= b.max or pos.y <= b.min or pos.y >= b.max:
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?"""
var b = get_arena_bounds()
if pos.x <= b.min or pos.x >= b.max or pos.y <= b.min or pos.y >= b.max:
return false
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-ghost."""
var t := _camp_time_for_region(_region_of(pos))
if t > 10.0:
# Stronger only if a nearby player is in ghost mode.
if _any_ghost_player_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 b = get_arena_bounds()
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
if c.x <= b.min or c.x >= b.max or c.y <= b.min or c.y >= b.max:
continue
cells.append(c)
return cells
func _bubble_footprint(center: Vector2i) -> Array:
return _bubble_blast_cells(center)
func _any_ghost_player_near(pos: Vector2i) -> bool:
"""True if a player in ghost mode is within the camping region."""
for player in get_tree().get_nodes_in_group("Players"):
if not player.get("is_invisible"):
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 player.get("is_invisible"):
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
if pos.x >= 8 and pos.x <= 10 and pos.y >= 8 and pos.y <= 10: continue
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
if pos.x >= 8 and pos.x <= 10 and pos.y >= 8 and pos.y <= 10: continue
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
var outline_mat = ShaderMaterial.new()
outline_mat.shader = load("res://assets/shaders/outline3d.gdshader")
mat.next_pass = outline_mat
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 player.get("is_invisible"):
continue # ghost 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:
"""Remove a sticky cell (used when ghost player walks through)."""
if _can_rpc():
if multiplayer.is_server():
rpc("sync_clear_sticky_cell", pos)
else:
sync_clear_sticky_cell(pos) # Predictive local clear
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
if gridmap:
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1)
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)
@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")
slowmo_label = hud_layer.get_node_or_null("TopContainer/SlowMoLabel")
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 _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 ghost powerup 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 ghost powerup every 2 missions
var completions = player_mission_completions[peer_id]
if completions % 2 == 0:
_grant_ghost_powerup(peer_id)
func _grant_ghost_powerup(peer_id: int) -> void:
"""Grant the ghost (invisible mode) powerup to a player."""
var all_players = get_tree().get_nodes_in_group("Players")
for p in all_players:
var pid = p.get("peer_id") if "peer_id" in p else p.name.to_int()
if pid == peer_id:
var stm = p.get_node_or_null("SpecialTilesManager")
if stm and stm.has_method("add_powerup_from_item"):
stm.add_powerup_from_item(14) # 14 = Ghost / INVISIBLE_MODE
emit_signal("ghost_granted", peer_id)
print("[Gauntlet] Player %d granted Ghost powerup (mission %d)" % [peer_id, player_mission_completions[peer_id]])
NotificationManager.send_message(p, "Ghost Power Earned!", NotificationManager.MessageType.POWERUP)
break
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
# =============================================================================
# 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
+41 -41
View File
@@ -26,10 +26,10 @@ signal sng_go_duration_changed(duration: int)
signal sng_stop_duration_changed(duration: int) signal sng_stop_duration_changed(duration: int)
signal sng_required_goals_changed(goals: int) signal sng_required_goals_changed(goals: int)
# Gauntlet settings signals # Candy Survival settings signals
signal gauntlet_round_duration_changed(duration: int) signal candy_survival_duration_changed(duration: int)
signal gauntlet_growth_interval_changed(interval: float) signal candy_survival_growth_interval_changed(interval: float)
signal gauntlet_cells_per_tick_changed(cells: Dictionary) signal candy_survival_cells_per_tick_changed(cells: Dictionary)
# Room data structure # Room data structure
var current_room: Dictionary = {} var current_room: Dictionary = {}
@@ -69,10 +69,10 @@ var sng_go_duration: int = 20
var sng_stop_duration: int = 4 var sng_stop_duration: int = 4
var sng_required_goals: int = 8 var sng_required_goals: int = 8
# Gauntlet settings # Candy Survival settings
var gauntlet_round_duration: int = 180 var candy_survival_round_duration: int = 180
var gauntlet_growth_interval: float = 3.0 # seconds between growth ticks var candy_survival_growth_interval: float = 3.0 # seconds between growth ticks
var gauntlet_cells_per_tick: Dictionary = { var candy_survival_cells_per_tick: Dictionary = {
"phase1": [4, 6], "phase1": [4, 6],
"phase2": [6, 8], "phase2": [6, 8],
"phase3": [8, 10], "phase3": [8, 10],
@@ -84,7 +84,7 @@ var rematch_votes: Array = [] # [player_id, ...]
# Character and area selection # Character and area selection
var available_characters: Array[String] = ["Copper", "Dabro", "Gatot", "Pip", "Random"] var available_characters: Array[String] = ["Copper", "Dabro", "Gatot", "Pip", "Random"]
var available_areas: Array[String] = [] var available_areas: Array[String] = []
var available_game_modes: Array[String] = ["Freemode", "Stop n Go", "Candy Pump Survival"] var available_game_modes: Array[String] = ["Freemode", "Stop n Go", "Candy Survival"]
var selected_area: String = "Freemode Arena" # Host-controlled var selected_area: String = "Freemode Arena" # Host-controlled
var game_mode: String = "Freemode" # Host-controlled var game_mode: String = "Freemode" # Host-controlled
var local_character_index: int = 0 # Local player's character index var local_character_index: int = 0 # Local player's character index
@@ -139,8 +139,8 @@ func _update_available_areas(mode: String) -> void:
available_areas = ["Freemode Arena", "Classic", "Colloseum"] available_areas = ["Freemode Arena", "Classic", "Colloseum"]
"Stop n Go": "Stop n Go":
available_areas = ["Stop N Go Arena"] available_areas = ["Stop N Go Arena"]
"Candy Pump Survival": "Candy Survival":
available_areas = ["Gauntlet Arena"] available_areas = ["Candy Survival Arena"]
_: _:
available_areas = ["Classic"] available_areas = ["Classic"]
@@ -513,35 +513,35 @@ func sync_sng_required_goals(goals: int) -> void:
emit_signal("sng_required_goals_changed", goals) emit_signal("sng_required_goals_changed", goals)
# ============================================================================= # =============================================================================
# Gauntlet Settings # Candy Survival Settings
# ============================================================================= # =============================================================================
func set_gauntlet_round_duration(duration: int) -> void: func set_candy_survival_round_duration(duration: int) -> void:
gauntlet_round_duration = duration candy_survival_round_duration = duration
if is_host: rpc("sync_gauntlet_round_duration", duration) if is_host: rpc("sync_candy_survival_round_duration", duration)
@rpc("authority", "call_local", "reliable") @rpc("authority", "call_local", "reliable")
func sync_gauntlet_round_duration(duration: int) -> void: func sync_candy_survival_round_duration(duration: int) -> void:
gauntlet_round_duration = duration candy_survival_round_duration = duration
emit_signal("gauntlet_round_duration_changed", duration) emit_signal("candy_survival_duration_changed", duration)
func set_gauntlet_growth_interval(interval: float) -> void: func set_candy_survival_growth_interval(interval: float) -> void:
gauntlet_growth_interval = interval candy_survival_growth_interval = interval
if is_host: rpc("sync_gauntlet_growth_interval", interval) if is_host: rpc("sync_candy_survival_growth_interval", interval)
@rpc("authority", "call_local", "reliable") @rpc("authority", "call_local", "reliable")
func sync_gauntlet_growth_interval(interval: float) -> void: func sync_candy_survival_growth_interval(interval: float) -> void:
gauntlet_growth_interval = interval candy_survival_growth_interval = interval
emit_signal("gauntlet_growth_interval_changed", interval) emit_signal("candy_survival_growth_interval_changed", interval)
func set_gauntlet_cells_per_tick(cells: Dictionary) -> void: func set_candy_survival_cells_per_tick(cells: Dictionary) -> void:
gauntlet_cells_per_tick = cells candy_survival_cells_per_tick = cells
if is_host: rpc("sync_gauntlet_cells_per_tick", cells) if is_host: rpc("sync_candy_survival_cells_per_tick", cells)
@rpc("authority", "call_local", "reliable") @rpc("authority", "call_local", "reliable")
func sync_gauntlet_cells_per_tick(cells: Dictionary) -> void: func sync_candy_survival_cells_per_tick(cells: Dictionary) -> void:
gauntlet_cells_per_tick = cells candy_survival_cells_per_tick = cells
emit_signal("gauntlet_cells_per_tick_changed", cells) emit_signal("candy_survival_cells_per_tick_changed", cells)
# ============================================================================= # =============================================================================
# Character Selection # Character Selection
@@ -699,8 +699,8 @@ func set_game_mode(mode: String) -> void:
set_area("Free Mode Area") set_area("Free Mode Area")
elif mode == "Stop n Go" and "Stop n Go Area" in available_areas: elif mode == "Stop n Go" and "Stop n Go Area" in available_areas:
set_area("Stop n Go Area") set_area("Stop n Go Area")
elif mode == "Candy Pump Survival" and "Gauntlet Arena" in available_areas: elif mode == "Candy Survival" and "Candy Survival Arena" in available_areas:
set_area("Gauntlet Arena") set_area("Candy Survival Arena")
@rpc("authority", "call_local", "reliable") @rpc("authority", "call_local", "reliable")
func sync_game_mode(mode: String) -> void: func sync_game_mode(mode: String) -> void:
@@ -713,8 +713,8 @@ func sync_game_mode(mode: String) -> void:
selected_area = "Free Mode Area" selected_area = "Free Mode Area"
elif mode == "Stop n Go" and "Stop n Go Area" in available_areas: elif mode == "Stop n Go" and "Stop n Go Area" in available_areas:
selected_area = "Stop n Go Area" selected_area = "Stop n Go Area"
elif mode == "Candy Pump Survival" and "Gauntlet Arena" in available_areas: elif mode == "Candy Survival" and "Candy Survival Arena" in available_areas:
selected_area = "Gauntlet Arena" selected_area = "Candy Survival Arena"
elif selected_area not in available_areas: elif selected_area not in available_areas:
selected_area = available_areas[0] selected_area = available_areas[0]
@@ -740,10 +740,10 @@ func start_game(force: bool = false) -> void:
rpc("sync_sng_go_duration", sng_go_duration) rpc("sync_sng_go_duration", sng_go_duration)
rpc("sync_sng_stop_duration", sng_stop_duration) rpc("sync_sng_stop_duration", sng_stop_duration)
rpc("sync_sng_required_goals", sng_required_goals) rpc("sync_sng_required_goals", sng_required_goals)
# Sync gauntlet settings # Sync Candy Survival settings
rpc("sync_gauntlet_round_duration", gauntlet_round_duration) rpc("sync_candy_survival_round_duration", candy_survival_round_duration)
rpc("sync_gauntlet_growth_interval", gauntlet_growth_interval) rpc("sync_candy_survival_growth_interval", candy_survival_growth_interval)
rpc("sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick) rpc("sync_candy_survival_cells_per_tick", candy_survival_cells_per_tick)
# Sync game mode # Sync game mode
rpc("sync_game_mode", game_mode) rpc("sync_game_mode", game_mode)
@@ -816,9 +816,9 @@ func request_room_info(requester_id: int, requester_name: String, requester_char
rpc_id(requester_id, "sync_sng_go_duration", sng_go_duration) rpc_id(requester_id, "sync_sng_go_duration", sng_go_duration)
rpc_id(requester_id, "sync_sng_stop_duration", sng_stop_duration) rpc_id(requester_id, "sync_sng_stop_duration", sng_stop_duration)
rpc_id(requester_id, "sync_sng_required_goals", sng_required_goals) rpc_id(requester_id, "sync_sng_required_goals", sng_required_goals)
rpc_id(requester_id, "sync_gauntlet_round_duration", gauntlet_round_duration) rpc_id(requester_id, "sync_candy_survival_round_duration", candy_survival_round_duration)
rpc_id(requester_id, "sync_gauntlet_growth_interval", gauntlet_growth_interval) rpc_id(requester_id, "sync_candy_survival_growth_interval", candy_survival_growth_interval)
rpc_id(requester_id, "sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick) rpc_id(requester_id, "sync_candy_survival_cells_per_tick", candy_survival_cells_per_tick)
rpc_id(requester_id, "sync_game_mode", game_mode) rpc_id(requester_id, "sync_game_mode", game_mode)
rpc_id(requester_id, "sync_area", selected_area) rpc_id(requester_id, "sync_area", selected_area)
+29 -55
View File
@@ -112,19 +112,19 @@ func simple_move_to(grid_position: Vector2i) -> bool:
print("[MovementManager] Hard block at %s. Ghost pass denied." % grid_position) print("[MovementManager] Hard block at %s. Ghost pass denied." % grid_position)
var gm = null var gm = null
var main_gauntlet = player.get_tree().root.get_node_or_null("Main") var main_candy_survival = player.get_tree().root.get_node_or_null("Main")
if main_gauntlet and main_gauntlet.get("gauntlet_manager"): if main_candy_survival and main_candy_survival.get("candy_survival_manager"):
gm = main_gauntlet.gauntlet_manager gm = main_candy_survival.candy_survival_manager
# Check Floor 0 (Basic Walkability/Void) # Check Floor 0 (Basic Walkability/Void)
if (cell_floor == -1 or cell_floor in enhanced_gridmap.non_walkable_items) and not is_wall_passable: if (cell_floor == -1 or cell_floor in enhanced_gridmap.non_walkable_items) and not is_wall_passable:
print("[Move] Failed: Floor Item %d is non-walkable" % cell_floor) print("[Move] Failed: Floor Item %d is non-walkable" % cell_floor)
return false return false
# Gauntlet Mode explicit wall overrides (since we visually removed the wall blocks) # Candy Survival explicit wall overrides (since we visually removed the wall blocks)
if gm and gm.is_active: if gm and gm.is_active:
if gm._is_npc_zone(grid_position): if gm._is_npc_zone(grid_position):
print("[Move] Failed: Blocked by Gauntlet NPC center at %s" % grid_position) print("[Move] Failed: Blocked by Candy Survival NPC center at %s" % grid_position)
return false return false
# Check Floor 1 (Obstacles/Walls) # Check Floor 1 (Obstacles/Walls)
@@ -156,13 +156,13 @@ func simple_move_to(grid_position: Vector2i) -> bool:
return false # Don't move into the tile, just knock return false # Don't move into the tile, just knock
# If moving into a sticky cell: block movement unless player is in ghost # If moving into a sticky cell: block movement unless player is in ghost
# mode (is_invisible), which lets them bypass sticky tiles in gauntlet. # mode (is_invisible), which lets them bypass sticky tiles in Candy Survival
if gm and gm.is_active and gm.is_sticky_cell(grid_position): if gm and gm.is_active and gm.is_sticky_cell(grid_position):
if player.get("is_invisible"): if player.get("is_invisible"):
# Ghost mode: walk through sticky tile freely # Ghost mode: walk through sticky tile freely
print("[Move] Ghost mode bypassed sticky cell at %s" % grid_position) print("[Move] Ghost mode bypassed sticky cell at %s" % grid_position)
else: else:
print("[Move] Failed: Blocked by Gauntlet Sticky cell at %s" % grid_position) print("[Move] Failed: Blocked by Candy Survival Sticky cell at %s" % grid_position)
return false return false
rotate_towards_target(grid_position) rotate_towards_target(grid_position)
@@ -208,15 +208,16 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
NotificationManager.send_message(player, "Target is Immune!", NotificationManager.MessageType.WARNING) NotificationManager.send_message(player, "Target is Immune!", NotificationManager.MessageType.WARNING)
return false return false
# === NEW LOGIC: Only allow push if in ATTACK MODE and NOT GHOST === # Candy Survival: check if attacker has knock charges
var has_smack = false var has_knock = false
var main_for_smack = player.get_tree().root.get_node_or_null("Main") var main_for_knock = player.get_tree().root.get_node_or_null("Main")
var gm_for_smack = main_for_smack.get("gauntlet_manager") if main_for_smack else null var gm_for_knock = main_for_knock.get("candy_survival_manager") if main_for_knock else null
if gm_for_smack and gm_for_smack.is_active: if gm_for_knock and gm_for_knock.is_active:
var att_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int() var att_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
has_smack = gm_for_smack.has_smack_charged(att_pid) var charges = gm_for_knock.player_knocks.get(att_pid, 0) if gm_for_knock.player_knocks else 0
has_knock = charges > 0
if (not player.get("is_charged_strike") and not has_smack) or player.get("is_invisible"): if (not player.get("is_charged_strike") and not has_knock) or player.get("is_invisible"):
# Standard bumping effect (Visual only) # Standard bumping effect (Visual only)
print("[Move] Push blocked: Not charged or is Ghost (%s trying to push %s)" % [player.name, other_player.name]) print("[Move] Push blocked: Not charged or is Ghost (%s trying to push %s)" % [player.name, other_player.name])
if _can_rpc(): if _can_rpc():
@@ -243,47 +244,20 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
var gm = null var gm = null
var main_push_check = player.get_tree().root.get_node_or_null("Main") var main_push_check = player.get_tree().root.get_node_or_null("Main")
if main_push_check and main_push_check.get("gauntlet_manager"): if main_push_check and main_push_check.get("candy_survival_manager"):
gm = main_push_check.gauntlet_manager gm = main_push_check.candy_survival_manager
# IF Gauntlet Mode is active, handle special Gauntlet Smacks # Candy Survival: knock mechanic (candy-steal or backfire)
if gm and gm.is_active: if gm and gm.is_active and gm.has_method("try_knock"):
var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int() var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
var other_pid = other_player.get("peer_id") if "peer_id" in other_player else other_player.name.to_int() var other_pid = other_player.get("peer_id") if "peer_id" in other_player else other_player.name.to_int()
if gm.try_knock(pid, other_pid):
# Check if attacker has smack print("[CandySurvival] Player %d knocked %d, stole candies!" % [pid, other_pid])
if not gm.has_smack_charged(pid): else:
# bump visuals print("[CandySurvival] Player %d tried to knock %d (backfire or no charges)" % [pid, other_pid])
if _can_rpc(): if _can_rpc():
player.rpc("sync_bump", target_pos, true) player.rpc("sync_bump", target_pos, false)
elif player.has_method("sync_bump"): return false
player.sync_bump(target_pos, true)
return false
# Smack Clash: Both charged
if gm.has_smack_charged(other_pid):
print("[Move] SMACK CLASH! Both %s and %s consumed." % [player.name, other_player.name])
if multiplayer.is_server():
gm.consume_smack(pid)
gm.consume_smack(other_pid)
elif _can_rpc():
gm.rpc("consume_smack", pid) # Assuming consume_smack is @rpc
gm.rpc("consume_smack", other_pid)
if _can_rpc():
player.rpc("apply_stagger", 1.0)
other_player.rpc("apply_stagger", 1.0)
else:
player.apply_stagger(1.0)
other_player.apply_stagger(1.0)
return false
# Else standard push
if multiplayer.is_server():
gm.consume_smack(pid)
elif _can_rpc():
gm.rpc("consume_smack", pid)
# === SUPER PUSH (Attack Mode) === # === SUPER PUSH (Attack Mode) ===
print("Player %s SUPER PUSHING %s!" % [player.name, other_player.name]) print("Player %s SUPER PUSHING %s!" % [player.name, other_player.name])
@@ -300,7 +274,7 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
var push_direction = Vector2i(-1, 0) # Default back (Stop N Go) var push_direction = Vector2i(-1, 0) # Default back (Stop N Go)
var main_push = player.get_tree().root.get_node_or_null("Main") var main_push = player.get_tree().root.get_node_or_null("Main")
var gm_push = main_push.gauntlet_manager if main_push and main_push.has_node("GauntletManager") else (main_push.get("gauntlet_manager") if main_push else null) var gm_push = main_push.candy_survival_manager if main_push and main_push.has_node("CandySurvivalManager") else (main_push.get("candy_survival_manager") if main_push else null)
if gm_push and gm_push.is_active: if gm_push and gm_push.is_active:
push_direction = direction # Use the direction of the attack push_direction = direction # Use the direction of the attack
var pushed_to_pos = target_pos var pushed_to_pos = target_pos
@@ -335,8 +309,8 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
# Check if landing spot is sticky # Check if landing spot is sticky
var main_sticky = player.get_tree().root.get_node_or_null("Main") var main_sticky = player.get_tree().root.get_node_or_null("Main")
if main_sticky and main_sticky.get("gauntlet_manager"): if main_sticky and main_sticky.get("candy_survival_manager"):
var gm_sticky = main_sticky.gauntlet_manager var gm_sticky = main_sticky.candy_survival_manager
if gm_sticky.is_active and gm_sticky.is_sticky_cell(pushed_to_pos): if gm_sticky.is_active and gm_sticky.is_sticky_cell(pushed_to_pos):
if other_player.get("is_invisible"): if other_player.get("is_invisible"):
# Ghost mode: pushed player bypasses sticky # Ghost mode: pushed player bypasses sticky
+2 -2
View File
@@ -546,8 +546,8 @@ func spawn_powerups_around(center: Vector2i, force_powerups: bool = true, only_c
if only_common or LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO): if only_common or LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO):
# Spawn ONLY common tiles (7-10) in Stop n Go mode (User Request) # Spawn ONLY common tiles (7-10) in Stop n Go mode (User Request)
item_id = rng.randi_range(7, 10) item_id = rng.randi_range(7, 10)
elif LobbyManager.is_game_mode(GameMode.Mode.GAUNTLET): elif LobbyManager.is_game_mode(GameMode.Mode.CANDY_SURVIVAL):
# Gauntlet mode: mostly common tiles, but ghost (14) can spawn too. # Candy Survival mode: mostly common tiles, but ghost (14) can spawn too.
if rng.randf() < 0.85: if rng.randf() < 0.85:
item_id = rng.randi_range(7, 10) item_id = rng.randi_range(7, 10)
else: else:
+3 -3
View File
@@ -18,10 +18,10 @@ const SCHEMA = {
"sng_stop_duration": {"type": TYPE_INT, "default": 4, "min": 2, "max": 10}, "sng_stop_duration": {"type": TYPE_INT, "default": 4, "min": 2, "max": 10},
"sng_required_goals": {"type": TYPE_INT, "default": 8, "min": 3, "max": 20} "sng_required_goals": {"type": TYPE_INT, "default": 8, "min": 3, "max": 20}
}, },
"Candy Pump Survival": { "Candy Survival": {
"match_duration": {"type": TYPE_INT, "default": 180, "min": 60, "max": 600}, "match_duration": {"type": TYPE_INT, "default": 180, "min": 60, "max": 600},
"gauntlet_growth_interval": {"type": TYPE_FLOAT, "default": 3.0, "min": 1.0, "max": 10.0}, "candy_survival_growth_interval": {"type": TYPE_FLOAT, "default": 3.0, "min": 1.0, "max": 10.0},
"gauntlet_cells_per_tick": {"type": TYPE_DICTIONARY, "default": {"phase1": [4, 6], "phase2": [6, 8], "phase3": [8, 10]}} "candy_survival_cells_per_tick": {"type": TYPE_DICTIONARY, "default": {"phase1": [4, 6], "phase2": [6, 8], "phase3": [8, 10]}}
} }
} }
+2 -2
View File
@@ -505,8 +505,8 @@ func spawn_tiles_around(count: int = 4):
if roll < 0.6 or (LobbyManager and LobbyManager.game_mode == "Stop n Go"): if roll < 0.6 or (LobbyManager and LobbyManager.game_mode == "Stop n Go"):
# 60% Normal Tile (7-10) OR 100% if Stop n Go (User Request) # 60% Normal Tile (7-10) OR 100% if Stop n Go (User Request)
item_id = rng.randi_range(7, 10) item_id = rng.randi_range(7, 10)
elif LobbyManager and LobbyManager.get_game_mode() == GameMode.Mode.GAUNTLET: elif LobbyManager and LobbyManager.get_game_mode() == GameMode.Mode.CANDY_SURVIVAL:
# Gauntlet mode: No power-up spawns from Tekton grabs # Candy Survival mode: No power-up spawns from Tekton grabs
item_id = rng.randi_range(7, 10) item_id = rng.randi_range(7, 10)
else: else:
# 40% PowerUp (11-14) # 40% PowerUp (11-14)
+1 -1
View File
@@ -1,4 +1,4 @@
extends SceneTree extends SceneTree
func _init(): func _init():
print("Testing gauntlet multiplayer") print("Testing candy_survival multiplayer")
quit() quit()
+1 -1
View File
@@ -1,6 +1,6 @@
extends Node extends Node
# Minimal EnhancedGridMap stand-in for Gauntlet headless tests. Records # Minimal EnhancedGridMap stand-in for Candy Survival headless tests. Records
# set_cell_item calls so lifecycle tests can run the local sync path without a # set_cell_item calls so lifecycle tests can run the local sync path without a
# real GridMap. Only the surface the manager touches is implemented. # real GridMap. Only the surface the manager touches is implemented.
+2 -2
View File
@@ -1,7 +1,7 @@
extends Node extends Node
# Minimal "Main" stand-in for Gauntlet headless tests. Provides the RPC methods # Minimal "Main" stand-in for Candy Survival headless tests. Provides the RPC methods
# the GauntletManager calls on its main_scene so calls resolve without the full # the CandySurvivalManager calls on its main_scene so calls resolve without the full
# game scene. Methods are no-ops that just need to exist + be rpc-tagged. # game scene. Methods are no-ops that just need to exist + be rpc-tagged.
@rpc("any_peer", "call_local", "reliable") @rpc("any_peer", "call_local", "reliable")
+50 -50
View File
@@ -1,13 +1,13 @@
extends GutTest extends GutTest
# ============================================================================= # =============================================================================
# Test: Bot AI — Sticky Avoidance & Pathfinding [Gauntlet #075] # Test: Bot AI — Sticky Avoidance & Pathfinding Candy Survival #075]
# Verifies the bot's strategic planner correctly: # Verifies the bot's strategic planner correctly:
# • Detects Gauntlet mode and exposes helpers. # • Detects Candy Survival mode and exposes helpers.
# • Rejects sticky / telegraphed cells in _is_valid_move_target. # • Rejects sticky / telegraphed cells in _is_valid_move_target.
# • Activates Cleanser when boxed in or standing on telegraphed ground. # • Activates Cleanser when boxed in or standing on telegraphed ground.
# • Honours an active Cleanser (treats sticky cells as passable). # • Honours an active Cleanser (treats sticky cells as passable).
# • Calls rpc_activate_cleanser on the GauntletManager when triggered. # • Calls rpc_activate_cleanser on the CandySurvivalManager when triggered.
# ============================================================================= # =============================================================================
const BotStrategicPlanner = preload("res://scripts/bot_strategic_planner.gd") const BotStrategicPlanner = preload("res://scripts/bot_strategic_planner.gd")
@@ -26,8 +26,8 @@ class StubActor extends Node3D:
func is_position_occupied(_p: Vector2i) -> bool: func is_position_occupied(_p: Vector2i) -> bool:
return false return false
class StubGauntletManager extends Node: class StubCandySurvivalManager extends Node:
# Mimics the slice of GauntletManager API the bot planner depends on. # Mimics the slice of CandySurvivalManager API the bot planner depends on.
var sticky_map: Dictionary = {} # Vector2i -> true var sticky_map: Dictionary = {} # Vector2i -> true
var player_cleansers: Dictionary = {} # peer_id -> int var player_cleansers: Dictionary = {} # peer_id -> int
var cleanser_active: Dictionary = {} # peer_id -> true var cleanser_active: Dictionary = {} # peer_id -> true
@@ -46,7 +46,7 @@ class StubGauntletManager extends Node:
var main_node: Node var main_node: Node
var gridmap: Node var gridmap: Node
var gauntlet_manager: StubGauntletManager var candy_survival_manager: StubCandySurvivalManager
var actor: StubActor var actor: StubActor
var planner: RefCounted var planner: RefCounted
@@ -68,9 +68,9 @@ func before_each():
gridmap.set_cell_item(Vector3i(x, 0, z), 1) gridmap.set_cell_item(Vector3i(x, 0, z), 1)
main_node.add_child(gridmap) main_node.add_child(gridmap)
gauntlet_manager = StubGauntletManager.new() candy_survival_manager = StubCandySurvivalManager.new()
gauntlet_manager.name = "GauntletManager" candy_survival_manager.name = "CandySurvivalManager"
main_node.add_child(gauntlet_manager) main_node.add_child(candy_survival_manager)
actor = StubActor.new() actor = StubActor.new()
actor.enhanced_gridmap = gridmap actor.enhanced_gridmap = gridmap
@@ -78,17 +78,17 @@ func before_each():
main_node.add_child(actor) main_node.add_child(actor)
planner = BotStrategicPlanner.new(actor, gridmap) planner = BotStrategicPlanner.new(actor, gridmap)
planner.gauntlet_manager_override = gauntlet_manager planner.candy_survival_manager_override = candy_survival_manager
# Default to Gauntlet mode for these tests. # Default to Candy Survival mode for these tests.
LobbyManager.game_mode = "Candy Pump Survival" LobbyManager.game_mode = "Candy Survival"
func after_each(): func after_each():
if is_instance_valid(main_node): if is_instance_valid(main_node):
main_node.queue_free() main_node.queue_free()
actor = null actor = null
planner = null planner = null
gauntlet_manager = null candy_survival_manager = null
gridmap = null gridmap = null
# Reset lobby mode so other tests aren't affected. # Reset lobby mode so other tests aren't affected.
LobbyManager.game_mode = "Freemode" LobbyManager.game_mode = "Freemode"
@@ -97,19 +97,19 @@ func after_each():
# Mode detection # Mode detection
# ============================================================================= # =============================================================================
func test_is_gauntlet_mode_true_when_set(): func test_is_candy_survival_mode_true_when_set():
assert_true(planner.is_gauntlet_mode(), "Detects Gauntlet via LobbyManager") assert_true(planner.is_candy_survival_mode(), "Detects Candy Survival via LobbyManager")
func test_is_gauntlet_mode_false_in_other_modes(): func test_is_candy_survival_mode_false_in_other_modes():
LobbyManager.game_mode = "Stop n Go" LobbyManager.game_mode = "Stop n Go"
assert_false(planner.is_gauntlet_mode(), "Stop n Go is not Gauntlet") assert_false(planner.is_candy_survival_mode(), "Stop n Go is not Candy Survival")
LobbyManager.game_mode = "Freemode" LobbyManager.game_mode = "Freemode"
assert_false(planner.is_gauntlet_mode(), "Freemode is not Gauntlet") assert_false(planner.is_candy_survival_mode(), "Freemode is not Candy Survival")
func test_get_gauntlet_manager_resolves_from_main(): func test_get_candy_survival_manager_resolves_from_main():
var gm = planner._get_gauntlet_manager() var gm = planner._get_candy_survival_manager()
assert_not_null(gm, "Resolves GauntletManager under /root/Main") assert_not_null(gm, "Resolves CandySurvivalManager under /root/Main")
assert_eq(gm, gauntlet_manager, "Same instance as the one we added") assert_eq(gm, candy_survival_manager, "Same instance as the one we added")
# ============================================================================= # =============================================================================
# Overlay detection # Overlay detection
@@ -134,7 +134,7 @@ func test_overlay_unsafe_ignores_layer0_and_layer1():
gridmap.set_cell_item(Vector3i(3, 0, 3), 17) gridmap.set_cell_item(Vector3i(3, 0, 3), 17)
gridmap.set_cell_item(Vector3i(3, 1, 3), 17) gridmap.set_cell_item(Vector3i(3, 1, 3), 17)
assert_false(planner._is_overlay_unsafe(Vector2i(3, 3)), assert_false(planner._is_overlay_unsafe(Vector2i(3, 3)),
"Only layer 2 overlay matters for Gauntlet safety") "Only layer 2 overlay matters for Candy Survival safety")
# ============================================================================= # =============================================================================
# _is_valid_move_target integration # _is_valid_move_target integration
@@ -144,12 +144,12 @@ func test_valid_move_target_rejects_sticky_in_gauntlet():
# Make a sticky cell pass all other checks (valid position, walkable floor). # Make a sticky cell pass all other checks (valid position, walkable floor).
gridmap.set_cell_item(Vector3i(3, 2, 3), 17) gridmap.set_cell_item(Vector3i(3, 2, 3), 17)
assert_false(planner._is_valid_move_target(Vector2i(3, 3)), assert_false(planner._is_valid_move_target(Vector2i(3, 3)),
"Sticky cell rejected in Gauntlet mode") "Sticky cell rejected in Candy Survival mode")
func test_valid_move_target_rejects_telegraphed_in_gauntlet(): func test_valid_move_target_rejects_telegraphed_in_gauntlet():
gridmap.set_cell_item(Vector3i(5, 2, 5), 18) gridmap.set_cell_item(Vector3i(5, 2, 5), 18)
assert_false(planner._is_valid_move_target(Vector2i(5, 5)), assert_false(planner._is_valid_move_target(Vector2i(5, 5)),
"Telegraphed cell rejected in Gauntlet mode") "Telegraphed cell rejected in Candy Survival mode")
func test_valid_move_target_accepts_clean_cells(): func test_valid_move_target_accepts_clean_cells():
assert_true(planner._is_valid_move_target(Vector2i(8, 8)), assert_true(planner._is_valid_move_target(Vector2i(8, 8)),
@@ -163,27 +163,27 @@ func test_valid_move_target_ignores_players_when_requested():
assert_false(planner._is_valid_move_target(Vector2i(3, 3), true), assert_false(planner._is_valid_move_target(Vector2i(3, 3), true),
"Safety check still active with ignore_players=true") "Safety check still active with ignore_players=true")
func test_valid_move_target_outside_gauntlet_allows_sticky(): func test_valid_move_target_outside_candy_survival_allows_sticky():
# Outside Gauntlet, layer-2 sticky overlays are not safety-relevant. # Outside Candy Survival, layer-2 sticky overlays are not safety-relevant.
LobbyManager.game_mode = "Freemode" LobbyManager.game_mode = "Freemode"
gridmap.set_cell_item(Vector3i(3, 2, 3), 17) gridmap.set_cell_item(Vector3i(3, 2, 3), 17)
assert_true(planner._is_valid_move_target(Vector2i(3, 3)), assert_true(planner._is_valid_move_target(Vector2i(3, 3)),
"Sticky overlay ignored in non-Gauntlet modes") "Sticky overlay ignored in non-Candy Survival modes")
func test_valid_move_target_allows_sticky_when_cleanser_active(): func test_valid_move_target_allows_sticky_when_cleanser_active():
gauntlet_manager.cleanser_active[actor.peer_id] = true candy_survival_manager.cleanser_active[actor.peer_id] = true
gridmap.set_cell_item(Vector3i(3, 2, 3), 17) gridmap.set_cell_item(Vector3i(3, 2, 3), 17)
assert_true(planner._is_valid_move_target(Vector2i(3, 3)), assert_true(planner._is_valid_move_target(Vector2i(3, 3)),
"Active Cleanser grants temporary immunity") "Active Cleanser grants temporary immunity")
# ============================================================================= # =============================================================================
# Sticky-cell awareness via GauntletManager authority # Sticky-cell awareness via CandySurvivalManager authority
# ============================================================================= # =============================================================================
func test_valid_move_target_uses_gauntlet_manager_sticky_map(): func test_valid_move_target_uses_candy_survival_manager_sticky_map():
# Even if the gridmap overlay hasn't landed yet (RPC in flight), the # Even if the gridmap overlay hasn't landed yet (RPC in flight), the
# GauntletManager's authoritative sticky_cells map must block the move. # CandySurvivalManager's authoritative sticky_cells map must block the move.
gauntlet_manager.sticky_map[Vector2i(7, 7)] = true candy_survival_manager.sticky_map[Vector2i(7, 7)] = true
assert_false(planner._is_valid_move_target(Vector2i(7, 7)), assert_false(planner._is_valid_move_target(Vector2i(7, 7)),
"Manager's sticky map blocks moves before overlay tiles land") "Manager's sticky map blocks moves before overlay tiles land")
@@ -221,7 +221,7 @@ func test_should_activate_cleanser_false_without_charge():
"Trapped but no charge → cannot activate") "Trapped but no charge → cannot activate")
func test_should_activate_cleanser_true_when_surrounded(): func test_should_activate_cleanser_true_when_surrounded():
gauntlet_manager.player_cleansers[actor.peer_id] = 1 candy_survival_manager.player_cleansers[actor.peer_id] = 1
actor.current_position = Vector2i(5, 5) actor.current_position = Vector2i(5, 5)
for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]:
var n = Vector2i(5, 5) + d var n = Vector2i(5, 5) + d
@@ -230,7 +230,7 @@ func test_should_activate_cleanser_true_when_surrounded():
"Trapped with charge → activate Cleanser") "Trapped with charge → activate Cleanser")
func test_should_activate_cleanser_true_when_on_telegraphed(): func test_should_activate_cleanser_true_when_on_telegraphed():
gauntlet_manager.player_cleansers[actor.peer_id] = 1 candy_survival_manager.player_cleansers[actor.peer_id] = 1
actor.current_position = Vector2i(5, 5) actor.current_position = Vector2i(5, 5)
gridmap.set_cell_item(Vector3i(5, 2, 5), 18) # bot standing on telegraph gridmap.set_cell_item(Vector3i(5, 2, 5), 18) # bot standing on telegraph
# 3+ unsafe neighbors required by spec; add three. # 3+ unsafe neighbors required by spec; add three.
@@ -241,8 +241,8 @@ func test_should_activate_cleanser_true_when_on_telegraphed():
"Standing on telegraph with 3 sticky neighbors → activate Cleanser") "Standing on telegraph with 3 sticky neighbors → activate Cleanser")
func test_should_activate_cleanser_false_when_already_active(): func test_should_activate_cleanser_false_when_already_active():
gauntlet_manager.player_cleansers[actor.peer_id] = 1 candy_survival_manager.player_cleansers[actor.peer_id] = 1
gauntlet_manager.cleanser_active[actor.peer_id] = true candy_survival_manager.cleanser_active[actor.peer_id] = true
actor.current_position = Vector2i(5, 5) actor.current_position = Vector2i(5, 5)
for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]:
var n = Vector2i(5, 5) + d var n = Vector2i(5, 5) + d
@@ -252,16 +252,16 @@ func test_should_activate_cleanser_false_when_already_active():
func test_should_activate_cleanser_false_outside_gauntlet(): func test_should_activate_cleanser_false_outside_gauntlet():
LobbyManager.game_mode = "Stop n Go" LobbyManager.game_mode = "Stop n Go"
gauntlet_manager.player_cleansers[actor.peer_id] = 1 candy_survival_manager.player_cleansers[actor.peer_id] = 1
actor.current_position = Vector2i(5, 5) actor.current_position = Vector2i(5, 5)
for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]:
var n = Vector2i(5, 5) + d var n = Vector2i(5, 5) + d
gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17) gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17)
assert_false(planner.should_activate_cleanser_now(), assert_false(planner.should_activate_cleanser_now(),
"Outside Gauntlet → never auto-activate Cleanser") "Outside Candy Survival → never auto-activate Cleanser")
func test_should_activate_cleanser_false_when_not_trapped(): func test_should_activate_cleanser_false_when_not_trapped():
gauntlet_manager.player_cleansers[actor.peer_id] = 1 candy_survival_manager.player_cleansers[actor.peer_id] = 1
actor.current_position = Vector2i(5, 5) actor.current_position = Vector2i(5, 5)
# No sticky neighbors, plenty of room. # No sticky neighbors, plenty of room.
assert_false(planner.should_activate_cleanser_now(), assert_false(planner.should_activate_cleanser_now(),
@@ -275,16 +275,16 @@ func test_bot_has_cleanser_charge_false_when_empty():
assert_false(planner._bot_has_cleanser_charge(), "Empty by default") assert_false(planner._bot_has_cleanser_charge(), "Empty by default")
func test_bot_has_cleanser_charge_true_when_granted(): func test_bot_has_cleanser_charge_true_when_granted():
gauntlet_manager.player_cleansers[actor.peer_id] = 1 candy_survival_manager.player_cleansers[actor.peer_id] = 1
assert_true(planner._bot_has_cleanser_charge(), "Charge granted") assert_true(planner._bot_has_cleanser_charge(), "Charge granted")
func test_is_bot_cleanser_active_reflects_manager(): func test_is_bot_cleanser_active_reflects_manager():
assert_false(planner._is_bot_cleanser_active(), "Inactive by default") assert_false(planner._is_bot_cleanser_active(), "Inactive by default")
gauntlet_manager.cleanser_active[actor.peer_id] = true candy_survival_manager.cleanser_active[actor.peer_id] = true
assert_true(planner._is_bot_cleanser_active(), "Manager reports active") assert_true(planner._is_bot_cleanser_active(), "Manager reports active")
# ============================================================================= # =============================================================================
# BotController → GauntletManager RPC integration # BotController → CandySurvivalManager RPC integration
# ============================================================================= # =============================================================================
func test_bot_controller_requests_cleanser_when_trapped(): func test_bot_controller_requests_cleanser_when_trapped():
@@ -295,7 +295,7 @@ func test_bot_controller_requests_cleanser_when_trapped():
ctrl.strategic_planner = planner ctrl.strategic_planner = planner
ctrl.actor = actor ctrl.actor = actor
gauntlet_manager.player_cleansers[actor.peer_id] = 1 candy_survival_manager.player_cleansers[actor.peer_id] = 1
actor.current_position = Vector2i(5, 5) actor.current_position = Vector2i(5, 5)
for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]:
var n = Vector2i(5, 5) + d var n = Vector2i(5, 5) + d
@@ -303,9 +303,9 @@ func test_bot_controller_requests_cleanser_when_trapped():
var requested = ctrl._try_activate_cleanser() var requested = ctrl._try_activate_cleanser()
assert_true(requested, "Controller requests Cleanser when trapped") assert_true(requested, "Controller requests Cleanser when trapped")
assert_eq(gauntlet_manager.activate_rpc_calls.size(), 1, assert_eq(candy_survival_manager.activate_rpc_calls.size(), 1,
"RPC called exactly once") "RPC called exactly once")
assert_eq(gauntlet_manager.activate_rpc_calls[0], actor.peer_id, assert_eq(candy_survival_manager.activate_rpc_calls[0], actor.peer_id,
"Correct peer id sent") "Correct peer id sent")
ctrl.queue_free() ctrl.queue_free()
@@ -317,11 +317,11 @@ func test_bot_controller_does_not_request_when_safe():
ctrl.strategic_planner = planner ctrl.strategic_planner = planner
ctrl.actor = actor ctrl.actor = actor
gauntlet_manager.player_cleansers[actor.peer_id] = 1 candy_survival_manager.player_cleansers[actor.peer_id] = 1
# No sticky anywhere. # No sticky anywhere.
var requested = ctrl._try_activate_cleanser() var requested = ctrl._try_activate_cleanser()
assert_false(requested, "No request when not trapped") assert_false(requested, "No request when not trapped")
assert_eq(gauntlet_manager.activate_rpc_calls.size(), 0, assert_eq(candy_survival_manager.activate_rpc_calls.size(), 0,
"No RPC fired") "No RPC fired")
ctrl.queue_free() ctrl.queue_free()
@@ -334,13 +334,13 @@ func test_bot_controller_skips_cleanser_outside_gauntlet():
ctrl.actor = actor ctrl.actor = actor
LobbyManager.game_mode = "Stop n Go" LobbyManager.game_mode = "Stop n Go"
gauntlet_manager.player_cleansers[actor.peer_id] = 1 candy_survival_manager.player_cleansers[actor.peer_id] = 1
actor.current_position = Vector2i(5, 5) actor.current_position = Vector2i(5, 5)
for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]:
var n = Vector2i(5, 5) + d var n = Vector2i(5, 5) + d
gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17) gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17)
var requested = ctrl._try_activate_cleanser() var requested = ctrl._try_activate_cleanser()
assert_false(requested, "No request outside Gauntlet") assert_false(requested, "No request outside Candy Survival")
ctrl.queue_free() ctrl.queue_free()
+3 -3
View File
@@ -1,14 +1,14 @@
extends GutTest extends GutTest
# ============================================================================= # =============================================================================
# Test: Gauntlet Candy Bubble System (v2) [Gauntlet #082] # Test: Candy Survival Candy Bubble System (v2) Candy Survival #082]
# Covers bubble-specific scoring components, phase budgets, anti-stacking, # Covers bubble-specific scoring components, phase budgets, anti-stacking,
# the 3x3 blast footprint, and the grow→explode lifecycle. # the 3x3 blast footprint, and the grow→explode lifecycle.
# Runs headless (no multiplayer peer): elapsed_time = 0 so the final-30s window # Runs headless (no multiplayer peer): elapsed_time = 0 so the final-30s window
# is inactive unless a test sets it directly. # is inactive unless a test sets it directly.
# ============================================================================= # =============================================================================
const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd") const CandySurvivalManager = preload("res://scripts/managers/candy_survival_manager.gd")
const GridMapMock = preload("res://tests/helpers/gridmap_mock.gd") const GridMapMock = preload("res://tests/helpers/gridmap_mock.gd")
var manager var manager
var main_mock: Node var main_mock: Node
@@ -20,7 +20,7 @@ func before_each():
gridmap_mock = GridMapMock.new() gridmap_mock = GridMapMock.new()
gridmap_mock.name = "EnhancedGridMap" gridmap_mock.name = "EnhancedGridMap"
main_mock.add_child(gridmap_mock) main_mock.add_child(gridmap_mock)
manager = GauntletManager.new() manager = CandySurvivalManager.new()
main_mock.add_child(manager) main_mock.add_child(manager)
manager.initialize(main_mock, gridmap_mock) manager.initialize(main_mock, gridmap_mock)
manager.current_phase = 0 manager.current_phase = 0
+3 -3
View File
@@ -1,13 +1,13 @@
extends GutTest extends GutTest
# ============================================================================= # =============================================================================
# Test: Gauntlet Cleanser Power-Up (v2) [Gauntlet #072] # Test: Candy Survival Cleanser Power-Up (v2) Candy Survival #072]
# Covers grant cadence (every 2 missions, max 1), 5-cell immunity lifecycle, # Covers grant cadence (every 2 missions, max 1), 5-cell immunity lifecycle,
# sticky clearing, stun-blocked activation, and the safe-stop early termination. # sticky clearing, stun-blocked activation, and the safe-stop early termination.
# Runs headless; uses a GridMap mock so clear_sticky_cell can run locally. # Runs headless; uses a GridMap mock so clear_sticky_cell can run locally.
# ============================================================================= # =============================================================================
const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd") const CandySurvivalManager = preload("res://scripts/managers/candy_survival_manager.gd")
const GridMapMock = preload("res://tests/helpers/gridmap_mock.gd") const GridMapMock = preload("res://tests/helpers/gridmap_mock.gd")
const MainMock = preload("res://tests/helpers/main_mock.gd") const MainMock = preload("res://tests/helpers/main_mock.gd")
var manager var manager
@@ -20,7 +20,7 @@ func before_each():
gridmap_mock = GridMapMock.new() gridmap_mock = GridMapMock.new()
gridmap_mock.name = "EnhancedGridMap" gridmap_mock.name = "EnhancedGridMap"
main_mock.add_child(gridmap_mock) main_mock.add_child(gridmap_mock)
manager = GauntletManager.new() manager = CandySurvivalManager.new()
main_mock.add_child(manager) main_mock.add_child(manager)
manager.initialize(main_mock, gridmap_mock) manager.initialize(main_mock, gridmap_mock)
manager.current_phase = 0 manager.current_phase = 0
+5 -5
View File
@@ -1,13 +1,13 @@
extends GutTest extends GutTest
# ============================================================================= # =============================================================================
# Test: Gauntlet Telegraph Floor Highlight [Gauntlet #081] # Test: Candy Survival Telegraph Floor Highlight Candy Survival #081]
# Verifies the amber floor overlay placed under cells during the 1-second # Verifies the amber floor overlay placed under cells during the 1-second
# telegraph window: amber color, two-stage alpha (build-up → flash), lifetime # telegraph window: amber color, two-stage alpha (build-up → flash), lifetime
# bound to telegraph_duration, distinct from sticky pink, RPC broadcast. # bound to telegraph_duration, distinct from sticky pink, RPC broadcast.
# ============================================================================= # =============================================================================
const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd") const CandySurvivalManager = preload("res://scripts/managers/candy_survival_manager.gd")
const GridMapMock = preload("res://tests/helpers/gridmap_mock.gd") const GridMapMock = preload("res://tests/helpers/gridmap_mock.gd")
var main_mock: Node var main_mock: Node
@@ -15,7 +15,7 @@ var gridmap_mock: Node
var manager: Node var manager: Node
func before_all(): func before_all():
gut.p("=== Feature Tests [Gauntlet #081 Telegraph Floor Highlight] ===") gut.p("=== Feature Tests Candy Survival #081 Telegraph Floor Highlight] ===")
func before_each(): func before_each():
main_mock = Node.new() main_mock = Node.new()
@@ -26,7 +26,7 @@ func before_each():
gridmap_mock.name = "EnhancedGridMap" gridmap_mock.name = "EnhancedGridMap"
main_mock.add_child(gridmap_mock) main_mock.add_child(gridmap_mock)
manager = GauntletManager.new() manager = CandySurvivalManager.new()
main_mock.add_child(manager) main_mock.add_child(manager)
manager.initialize(main_mock, gridmap_mock) manager.initialize(main_mock, gridmap_mock)
@@ -63,7 +63,7 @@ func test_telegraph_apply_converts_to_sticky():
# Verify the tile ID conversion by inspecting state directly — invoking # Verify the tile ID conversion by inspecting state directly — invoking
# sync_growth_apply triggers _check_all_players_trapped which needs an # sync_growth_apply triggers _check_all_players_trapped which needs an
# active multiplayer peer. The conversion is exercised by the # active multiplayer peer. The conversion is exercised by the
# test_gauntlet_growth_tick.gd suite; here we only confirm the # test_candy_survival_growth_tick.gd suite; here we only confirm the
# sticky tile ID is reserved and distinct. # sticky tile ID is reserved and distinct.
_without_peer(func(): _without_peer(func():
manager.sync_growth_telegraph([Vector2i(7, 7)]) manager.sync_growth_telegraph([Vector2i(7, 7)])
+39 -39
View File
@@ -1,19 +1,19 @@
extends GutTest extends GutTest
# ============================================================================= # =============================================================================
# Test: Gauntlet Growth Tick System (v2) [Gauntlet #067] # Test: Candy Survival Growth Tick System (v2) Candy Survival #067]
# Replaces the old cannon-timer test. Covers growth timing, phase configs, # Replaces the old cannon-timer test. Covers growth timing, phase configs,
# candidate generation, cells-per-tick ranges, weighted selection, and # candidate generation, cells-per-tick ranges, weighted selection, and
# cleansed-cell exclusion. # cleansed-cell exclusion.
# ============================================================================= # =============================================================================
const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd") const CandySurvivalManager = preload("res://scripts/managers/candy_survival_manager.gd")
var gauntlet_manager: Node var candy_survival_manager: Node
var main_mock: Node var main_mock: Node
var gridmap_mock: Node var gridmap_mock: Node
func before_all(): func before_all():
gut.p("=== Feature Tests [Gauntlet #067 Growth Tick] ===") gut.p("=== Feature Tests Candy Survival #067 Growth Tick] ===")
func before_each(): func before_each():
main_mock = Node.new() main_mock = Node.new()
@@ -22,9 +22,9 @@ func before_each():
gridmap_mock.name = "EnhancedGridMap" gridmap_mock.name = "EnhancedGridMap"
main_mock.add_child(gridmap_mock) main_mock.add_child(gridmap_mock)
gauntlet_manager = GauntletManager.new() candy_survival_manager = CandySurvivalManager.new()
main_mock.add_child(gauntlet_manager) main_mock.add_child(candy_survival_manager)
gauntlet_manager.initialize(main_mock, gridmap_mock) candy_survival_manager.initialize(main_mock, gridmap_mock)
func after_each(): func after_each():
if main_mock: if main_mock:
@@ -38,43 +38,43 @@ func after_all():
# ============================================================================= # =============================================================================
func test_growth_timer_starts_zero(): func test_growth_timer_starts_zero():
assert_eq(gauntlet_manager.growth_timer, 0.0, "Growth timer starts at 0.0") assert_eq(candy_survival_manager.growth_timer, 0.0, "Growth timer starts at 0.0")
func test_growth_interval_default(): func test_growth_interval_default():
assert_eq(gauntlet_manager.growth_interval, 3.0, "Growth interval defaults to 3.0s") assert_eq(candy_survival_manager.growth_interval, 3.0, "Growth interval defaults to 3.0s")
func test_telegraph_duration_default(): func test_telegraph_duration_default():
assert_eq(gauntlet_manager.telegraph_duration, 1.0, "Telegraph duration defaults to 1.0s") assert_eq(candy_survival_manager.telegraph_duration, 1.0, "Telegraph duration defaults to 1.0s")
# ============================================================================= # =============================================================================
# Phase Growth Config # Phase Growth Config
# ============================================================================= # =============================================================================
func test_phase_growth_config_has_three_phases(): func test_phase_growth_config_has_three_phases():
assert_eq(gauntlet_manager.phase_growth_config.size(), 3, "Three phase growth configs") assert_eq(candy_survival_manager.phase_growth_config.size(), 3, "Three phase growth configs")
func test_phase1_cells_range(): func test_phase1_cells_range():
var cfg = gauntlet_manager.phase_growth_config[0] var cfg = candy_survival_manager.phase_growth_config[0]
assert_eq(cfg["cells_min"], 4, "Phase 1 min 4 cells/tick") assert_eq(cfg["cells_min"], 4, "Phase 1 min 4 cells/tick")
assert_eq(cfg["cells_max"], 6, "Phase 1 max 6 cells/tick") assert_eq(cfg["cells_max"], 6, "Phase 1 max 6 cells/tick")
func test_phase2_cells_range(): func test_phase2_cells_range():
var cfg = gauntlet_manager.phase_growth_config[1] var cfg = candy_survival_manager.phase_growth_config[1]
assert_eq(cfg["cells_min"], 6, "Phase 2 min 6 cells/tick") assert_eq(cfg["cells_min"], 6, "Phase 2 min 6 cells/tick")
assert_eq(cfg["cells_max"], 8, "Phase 2 max 8 cells/tick") assert_eq(cfg["cells_max"], 8, "Phase 2 max 8 cells/tick")
func test_phase3_cells_range(): func test_phase3_cells_range():
var cfg = gauntlet_manager.phase_growth_config[2] var cfg = candy_survival_manager.phase_growth_config[2]
assert_eq(cfg["cells_min"], 8, "Phase 3 min 8 cells/tick") assert_eq(cfg["cells_min"], 8, "Phase 3 min 8 cells/tick")
assert_eq(cfg["cells_max"], 10, "Phase 3 max 10 cells/tick") assert_eq(cfg["cells_max"], 10, "Phase 3 max 10 cells/tick")
func test_cells_this_tick_in_phase_range(): func test_cells_this_tick_in_phase_range():
for phase in range(3): for phase in range(3):
gauntlet_manager.current_phase = phase candy_survival_manager.current_phase = phase
var cfg = gauntlet_manager.phase_growth_config[phase] var cfg = candy_survival_manager.phase_growth_config[phase]
# Sample several times since the count is randomized. # Sample several times since the count is randomized.
for _i in range(20): for _i in range(20):
var n = gauntlet_manager._cells_this_tick() var n = candy_survival_manager._cells_this_tick()
assert_true(n >= cfg["cells_min"] and n <= cfg["cells_max"], assert_true(n >= cfg["cells_min"] and n <= cfg["cells_max"],
"Phase %d cells/tick %d within [%d,%d]" % [phase, n, cfg["cells_min"], cfg["cells_max"]]) "Phase %d cells/tick %d within [%d,%d]" % [phase, n, cfg["cells_min"], cfg["cells_max"]])
@@ -83,15 +83,15 @@ func test_cells_this_tick_in_phase_range():
# ============================================================================= # =============================================================================
func test_candidates_are_all_safe_cells(): func test_candidates_are_all_safe_cells():
gauntlet_manager.current_phase = 0 candy_survival_manager.current_phase = 0
var candidates = gauntlet_manager._generate_candidates() var candidates = candy_survival_manager._generate_candidates()
# Fresh arena: every playable cell is SAFE. # Fresh arena: every playable cell is SAFE.
assert_eq(candidates.size(), gauntlet_manager.playable_cell_count(), assert_eq(candidates.size(), candy_survival_manager.playable_cell_count(),
"All playable cells are candidates on a fresh arena") "All playable cells are candidates on a fresh arena")
func test_candidates_exclude_sticky(): func test_candidates_exclude_sticky():
gauntlet_manager.sticky_cells[Vector2i(3, 3)] = true candy_survival_manager.sticky_cells[Vector2i(3, 3)] = true
var candidates = gauntlet_manager._generate_candidates() var candidates = candy_survival_manager._generate_candidates()
var found := false var found := false
for c in candidates: for c in candidates:
if c["pos"] == Vector2i(3, 3): if c["pos"] == Vector2i(3, 3):
@@ -99,8 +99,8 @@ func test_candidates_exclude_sticky():
assert_false(found, "Sticky cells are excluded from candidates") assert_false(found, "Sticky cells are excluded from candidates")
func test_candidates_exclude_cleansed(): func test_candidates_exclude_cleansed():
gauntlet_manager.mark_cleansed(Vector2i(4, 4)) candy_survival_manager.mark_cleansed(Vector2i(4, 4))
var candidates = gauntlet_manager._generate_candidates() var candidates = candy_survival_manager._generate_candidates()
var found := false var found := false
for c in candidates: for c in candidates:
if c["pos"] == Vector2i(4, 4): if c["pos"] == Vector2i(4, 4):
@@ -108,14 +108,14 @@ func test_candidates_exclude_cleansed():
assert_false(found, "Cleansed cells are excluded from candidates (regrowth protection)") assert_false(found, "Cleansed cells are excluded from candidates (regrowth protection)")
func test_candidates_exclude_npc_and_boundary(): func test_candidates_exclude_npc_and_boundary():
var candidates = gauntlet_manager._generate_candidates() var candidates = candy_survival_manager._generate_candidates()
for c in candidates: for c in candidates:
var p = c["pos"] var p = c["pos"]
assert_false(gauntlet_manager._is_npc_zone(p), "No NPC-zone candidates") assert_false(candy_survival_manager._is_npc_zone(p), "No NPC-zone candidates")
assert_false(gauntlet_manager._is_boundary(p), "No boundary candidates") assert_false(candy_survival_manager._is_boundary(p), "No boundary candidates")
func test_candidates_have_scores(): func test_candidates_have_scores():
var candidates = gauntlet_manager._generate_candidates() var candidates = candy_survival_manager._generate_candidates()
assert_true(candidates.size() > 0, "Has candidates") assert_true(candidates.size() > 0, "Has candidates")
assert_true(candidates[0].has("score"), "Candidate carries a score") assert_true(candidates[0].has("score"), "Candidate carries a score")
@@ -124,13 +124,13 @@ func test_candidates_have_scores():
# ============================================================================= # =============================================================================
func test_select_count_respected(): func test_select_count_respected():
var candidates = gauntlet_manager._generate_candidates() var candidates = candy_survival_manager._generate_candidates()
var picked = gauntlet_manager._select_cells_weighted(candidates, 5) var picked = candy_survival_manager._select_cells_weighted(candidates, 5)
assert_eq(picked.size(), 5, "Selects exactly the requested count") assert_eq(picked.size(), 5, "Selects exactly the requested count")
func test_select_no_duplicates(): func test_select_no_duplicates():
var candidates = gauntlet_manager._generate_candidates() var candidates = candy_survival_manager._generate_candidates()
var picked = gauntlet_manager._select_cells_weighted(candidates, 10) var picked = candy_survival_manager._select_cells_weighted(candidates, 10)
var seen := {} var seen := {}
for p in picked: for p in picked:
assert_false(seen.has(p), "No duplicate selections") assert_false(seen.has(p), "No duplicate selections")
@@ -138,7 +138,7 @@ func test_select_no_duplicates():
func test_select_capped_at_pool_size(): func test_select_capped_at_pool_size():
var small = [{"pos": Vector2i(2, 2), "score": 1.0}, {"pos": Vector2i(2, 3), "score": 1.0}] var small = [{"pos": Vector2i(2, 2), "score": 1.0}, {"pos": Vector2i(2, 3), "score": 1.0}]
var picked = gauntlet_manager._select_cells_weighted(small, 10) var picked = candy_survival_manager._select_cells_weighted(small, 10)
assert_eq(picked.size(), 2, "Cannot select more than pool size") assert_eq(picked.size(), 2, "Cannot select more than pool size")
# ============================================================================= # =============================================================================
@@ -146,14 +146,14 @@ func test_select_capped_at_pool_size():
# ============================================================================= # =============================================================================
func test_layer_classification(): func test_layer_classification():
assert_eq(gauntlet_manager._layer_of(Vector2i(9, 9)), "inner", "Center is inner") assert_eq(candy_survival_manager._layer_of(Vector2i(9, 9)), "inner", "Center is inner")
assert_eq(gauntlet_manager._layer_of(Vector2i(1, 1)), "outer", "Corner is outer") assert_eq(candy_survival_manager._layer_of(Vector2i(1, 1)), "outer", "Corner is outer")
func test_sticky_neighbor_count(): func test_sticky_neighbor_count():
gauntlet_manager.sticky_cells[Vector2i(5, 5)] = true candy_survival_manager.sticky_cells[Vector2i(5, 5)] = true
gauntlet_manager.sticky_cells[Vector2i(5, 6)] = true candy_survival_manager.sticky_cells[Vector2i(5, 6)] = true
assert_eq(gauntlet_manager._sticky_neighbor_count(Vector2i(6, 5)), 2, assert_eq(candy_survival_manager._sticky_neighbor_count(Vector2i(6, 5)), 2,
"Counts 8-directional sticky neighbors") "Counts 8-directional sticky neighbors")
func test_chebyshev(): func test_chebyshev():
assert_eq(gauntlet_manager._chebyshev(Vector2i(0, 0), Vector2i(3, 1)), 3, "Chebyshev distance") assert_eq(candy_survival_manager._chebyshev(Vector2i(0, 0), Vector2i(3, 1)), 3, "Chebyshev distance")
+4 -4
View File
@@ -1,13 +1,13 @@
extends GutTest extends GutTest
# ============================================================================= # =============================================================================
# Test: Gauntlet Movement Buffer System (v2) [Gauntlet #083] # Test: Candy Survival Movement Buffer System (v2) Candy Survival #083]
# Hidden, decaying safe-corridor penalties layered onto candidate scoring. # Hidden, decaying safe-corridor penalties layered onto candidate scoring.
# Runs headless; elapsed_time = 0 so the final-30s window is inactive unless a # Runs headless; elapsed_time = 0 so the final-30s window is inactive unless a
# test sets elapsed_time directly. # test sets elapsed_time directly.
# ============================================================================= # =============================================================================
const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd") const CandySurvivalManager = preload("res://scripts/managers/candy_survival_manager.gd")
var manager var manager
var main_mock: Node var main_mock: Node
var gridmap_mock: Node var gridmap_mock: Node
@@ -18,7 +18,7 @@ func before_each():
gridmap_mock = Node.new() gridmap_mock = Node.new()
gridmap_mock.name = "EnhancedGridMap" gridmap_mock.name = "EnhancedGridMap"
main_mock.add_child(gridmap_mock) main_mock.add_child(gridmap_mock)
manager = GauntletManager.new() manager = CandySurvivalManager.new()
main_mock.add_child(manager) main_mock.add_child(manager)
manager.initialize(main_mock, gridmap_mock) manager.initialize(main_mock, gridmap_mock)
manager.current_phase = 0 manager.current_phase = 0
@@ -61,7 +61,7 @@ func test_buffer_penalty_far_is_zero():
func test_buffer_penalty_lifts_in_final_window(): func test_buffer_penalty_lifts_in_final_window():
manager._register_buffer(Vector2i(6, 6), 40.0) manager._register_buffer(Vector2i(6, 6), 40.0)
manager.elapsed_time = manager.gauntlet_round_duration() - 5.0 # within final 30s manager.elapsed_time = manager.candy_survival_round_duration() - 5.0 # within final 30s
assert_eq(manager._buffer_penalty_at(Vector2i(6, 6)), 0.0, "Final window lifts buffers") assert_eq(manager._buffer_penalty_at(Vector2i(6, 6)), 0.0, "Final window lifts buffers")
func test_buffer_penalty_empty_is_zero(): func test_buffer_penalty_empty_is_zero():
+54 -54
View File
@@ -1,11 +1,11 @@
# tests/test_gauntlet_registration.gd # tests/test_candy_survival_registration.gd
# Tests for [Gauntlet] #1 Game Mode Registration # Tests for Candy Survival] #1 Game Mode Registration
# Validates GAUNTLET enum, string conversion, lobby integration, and arena setup # Validates CANDY_SURVIVAL enum, string conversion, lobby integration, and arena setup
extends GutTest extends GutTest
func before_all(): func before_all():
gut.p("=== Gauntlet Registration Tests [Gauntlet #1] ===") gut.p("=== Candy Survival Registration Tests Candy Survival #1] ===")
func after_each(): func after_each():
pass pass
@@ -14,35 +14,35 @@ func after_each():
# GameMode Enum Tests # GameMode Enum Tests
# ============================================================================= # =============================================================================
# Test 1: GAUNTLET enum value exists and equals 3 # Test 1: CANDY_SURVIVAL enum value exists and equals 3
func test_gauntlet_enum_exists(): func test_candy_survival_enum_exists():
assert_eq(GameMode.Mode.GAUNTLET, 3, "GAUNTLET should be enum value 3") assert_eq(GameMode.Mode.CANDY_SURVIVAL, 3, "CANDY_SURVIVAL should be enum value 3")
# Test 2: All 3 modes are present in enum # Test 2: All 3 modes are present in enum
func test_all_modes_in_enum(): func test_all_modes_in_enum():
assert_eq(GameMode.Mode.FREEMODE, 0, "FREEMODE should be 0") assert_eq(GameMode.Mode.FREEMODE, 0, "FREEMODE should be 0")
assert_eq(GameMode.Mode.STOP_N_GO, 1, "STOP_N_GO should be 1") assert_eq(GameMode.Mode.STOP_N_GO, 1, "STOP_N_GO should be 1")
assert_eq(GameMode.Mode.GAUNTLET, 3, "GAUNTLET should be 3") assert_eq(GameMode.Mode.CANDY_SURVIVAL, 3, "CANDY_SURVIVAL should be 3")
# ============================================================================= # =============================================================================
# String Conversion Tests # String Conversion Tests
# ============================================================================= # =============================================================================
# Test 3: from_string recognizes "Candy Pump Survival" # Test 3: from_string recognizes "Candy Survival"
func test_from_string_candy_cannon(): func test_from_string_candy_survival():
var result = GameMode.from_string("Candy Pump Survival") var result = GameMode.from_string("Candy Survival")
assert_eq(result, GameMode.Mode.GAUNTLET, "from_string should parse 'Candy Pump Survival' as GAUNTLET") assert_eq(result, GameMode.Mode.CANDY_SURVIVAL, "from_string should parse 'Candy Survival' as CANDY_SURVIVAL")
# Test 4: mode_to_string returns "Candy Pump Survival" for GAUNTLET # Test 4: mode_to_string returns "Candy Survival" for CANDY_SURVIVAL
func test_mode_to_string_gauntlet(): func test_mode_to_string_gauntlet():
var result = GameMode.mode_to_string(GameMode.Mode.GAUNTLET) var result = GameMode.mode_to_string(GameMode.Mode.CANDY_SURVIVAL)
assert_eq(result, "Candy Pump Survival", "mode_to_string should return 'Candy Pump Survival'") assert_eq(result, "Candy Survival", "mode_to_string should return 'Candy Survival'")
# Test 5: Round-trip conversion is lossless # Test 5: Round-trip conversion is lossless
func test_round_trip_conversion(): func test_round_trip_conversion():
var mode_str = GameMode.mode_to_string(GameMode.Mode.GAUNTLET) var mode_str = GameMode.mode_to_string(GameMode.Mode.CANDY_SURVIVAL)
var mode_enum = GameMode.from_string(mode_str) var mode_enum = GameMode.from_string(mode_str)
assert_eq(mode_enum, GameMode.Mode.GAUNTLET, "Round-trip should preserve GAUNTLET") assert_eq(mode_enum, GameMode.Mode.CANDY_SURVIVAL, "Round-trip should preserve CANDY_SURVIVAL")
# Test 6: All existing modes still round-trip correctly # Test 6: All existing modes still round-trip correctly
func test_existing_modes_round_trip(): func test_existing_modes_round_trip():
@@ -60,10 +60,10 @@ func test_unknown_string_defaults_freemode():
# get_all_modes Tests # get_all_modes Tests
# ============================================================================= # =============================================================================
# Test 8: get_all_modes includes "Candy Pump Survival" # Test 8: get_all_modes includes "Candy Survival"
func test_get_all_modes_includes_gauntlet(): func test_get_all_modes_includes_gauntlet():
var modes = GameMode.get_all_modes() var modes = GameMode.get_all_modes()
assert_has(modes, "Candy Pump Survival", "get_all_modes should include 'Candy Pump Survival'") assert_has(modes, "Candy Survival", "get_all_modes should include 'Candy Survival'")
# Test 9: get_all_modes returns exactly 3 entries # Test 9: get_all_modes returns exactly 3 entries
func test_get_all_modes_count(): func test_get_all_modes_count():
@@ -75,16 +75,16 @@ func test_get_all_modes_order():
var modes = GameMode.get_all_modes() var modes = GameMode.get_all_modes()
assert_eq(modes[0], "Freemode", "First mode should be Freemode") assert_eq(modes[0], "Freemode", "First mode should be Freemode")
assert_eq(modes[1], "Stop n Go", "Second mode should be Stop n Go") assert_eq(modes[1], "Stop n Go", "Second mode should be Stop n Go")
assert_eq(modes[2], "Candy Pump Survival", "Third mode should be Candy Pump Survival") assert_eq(modes[2], "Candy Survival", "Third mode should be Candy Survival")
# ============================================================================= # =============================================================================
# is_restricted Tests # is_restricted Tests
# ============================================================================= # =============================================================================
# Test 11: GAUNTLET is a restricted mode # Test 11: CANDY_SURVIVAL is a restricted mode
func test_gauntlet_is_restricted(): func test_candy_survival_is_restricted():
var result = GameMode.is_restricted(GameMode.Mode.GAUNTLET) var result = GameMode.is_restricted(GameMode.Mode.CANDY_SURVIVAL)
assert_true(result, "GAUNTLET should be restricted (dedicated arena)") assert_true(result, "CANDY_SURVIVAL should be restricted (dedicated arena)")
# Test 12: FREEMODE is NOT restricted # Test 12: FREEMODE is NOT restricted
func test_freemode_not_restricted(): func test_freemode_not_restricted():
@@ -94,46 +94,46 @@ func test_freemode_not_restricted():
# Test 13: All restricted modes are confirmed # Test 13: All restricted modes are confirmed
func test_all_restricted_modes(): func test_all_restricted_modes():
assert_true(GameMode.is_restricted(GameMode.Mode.STOP_N_GO), "STOP_N_GO should be restricted") assert_true(GameMode.is_restricted(GameMode.Mode.STOP_N_GO), "STOP_N_GO should be restricted")
assert_true(GameMode.is_restricted(GameMode.Mode.GAUNTLET), "GAUNTLET should be restricted") assert_true(GameMode.is_restricted(GameMode.Mode.CANDY_SURVIVAL), "CANDY_SURVIVAL should be restricted")
# ============================================================================= # =============================================================================
# LobbyManager Integration Tests # LobbyManager Integration Tests
# ============================================================================= # =============================================================================
# Test 14: Lobby available_game_modes includes "Candy Pump Survival" # Test 14: Lobby available_game_modes includes "Candy Survival"
func test_lobby_modes_includes_gauntlet(): func test_lobby_modes_includes_gauntlet():
var modes = LobbyManager.available_game_modes var modes = LobbyManager.available_game_modes
assert_has(modes, "Candy Pump Survival", "LobbyManager.available_game_modes should include 'Candy Pump Survival'") assert_has(modes, "Candy Survival", "LobbyManager.available_game_modes should include 'Candy Survival'")
# Test 15: gauntlet_manager.gd script file exists # Test 15: candy_survival_manager.gd script file exists
func test_gauntlet_manager_script_exists(): func test_candy_survival_manager_script_exists():
var script_exists = ResourceLoader.exists("res://scripts/managers/gauntlet_manager.gd") var script_exists = ResourceLoader.exists("res://scripts/managers/candy_survival_manager.gd")
assert_true(script_exists, "gauntlet_manager.gd should exist") assert_true(script_exists, "candy_survival_manager.gd should exist")
# Test 16: GauntletManager class can be loaded # Test 16: CandySurvivalManager class can be loaded
func test_gauntlet_manager_loads(): func test_candy_survival_manager_loads():
var script = load("res://scripts/managers/gauntlet_manager.gd") var script = load("res://scripts/managers/candy_survival_manager.gd")
assert_not_null(script, "gauntlet_manager.gd should load without errors") assert_not_null(script, "candy_survival_manager.gd should load without errors")
# Test 17: GauntletManager has required methods # Test 17: CandySurvivalManager has required methods
func test_gauntlet_manager_has_methods(): func test_candy_survival_manager_has_methods():
var manager = GauntletManager.new() var manager = CandySurvivalManager.new()
assert_true(manager.has_method("_setup_arena"), "GauntletManager should have _setup_arena()") assert_true(manager.has_method("_setup_arena"), "CandySurvivalManager should have _setup_arena()")
assert_true(manager.has_method("_apply_arena_setup"), "GauntletManager should have _apply_arena_setup()") assert_true(manager.has_method("_apply_arena_setup"), "CandySurvivalManager should have _apply_arena_setup()")
assert_true(manager.has_method("start_game_mode"), "GauntletManager should have start_game_mode()") assert_true(manager.has_method("start_game_mode"), "CandySurvivalManager should have start_game_mode()")
assert_true(manager.has_method("initialize"), "GauntletManager should have initialize()") assert_true(manager.has_method("initialize"), "CandySurvivalManager should have initialize()")
manager.free() manager.free()
# Test 18: GauntletManager arena constants are correct # Test 18: CandySurvivalManager arena constants are correct
func test_gauntlet_arena_constants(): func test_candy_survival_arena_constants():
assert_eq(GauntletManager.ARENA_COLUMNS, 20, "Arena should be 20 columns") assert_eq(CandySurvivalManager.ARENA_COLUMNS, 20, "Arena should be 20 columns")
assert_eq(GauntletManager.ARENA_ROWS, 20, "Arena should be 20 rows") assert_eq(CandySurvivalManager.ARENA_ROWS, 20, "Arena should be 20 rows")
assert_eq(GauntletManager.NPC_SIZE, 3, "NPC zone should be 3x3") assert_eq(CandySurvivalManager.NPC_SIZE, 3, "NPC zone should be 3x3")
assert_eq(GauntletManager.NPC_CENTER, Vector2i(9, 9), "NPC center should be at (9,9)") assert_eq(CandySurvivalManager.NPC_CENTER, Vector2i(9, 9), "NPC center should be at (9,9)")
# Test 19: NPC zone detection works # Test 19: NPC zone detection works
func test_npc_zone_detection(): func test_npc_zone_detection():
var manager = GauntletManager.new() var manager = CandySurvivalManager.new()
# Center of NPC zone # Center of NPC zone
assert_true(manager._is_npc_zone(Vector2i(9, 9)), "Center (9,9) should be NPC zone") assert_true(manager._is_npc_zone(Vector2i(9, 9)), "Center (9,9) should be NPC zone")
# Edges of NPC zone # Edges of NPC zone
@@ -146,10 +146,10 @@ func test_npc_zone_detection():
manager.free() manager.free()
# Test 20: Phase enum has 3 phases # Test 20: Phase enum has 3 phases
func test_gauntlet_phases(): func test_candy_survival_phases():
assert_eq(GauntletManager.Phase.OPEN_ARENA, 0, "OPEN_ARENA should be 0") assert_eq(CandySurvivalManager.Phase.OPEN_ARENA, 0, "OPEN_ARENA should be 0")
assert_eq(GauntletManager.Phase.ROUTE_PRESSURE, 1, "ROUTE_PRESSURE should be 1") assert_eq(CandySurvivalManager.Phase.ROUTE_PRESSURE, 1, "ROUTE_PRESSURE should be 1")
assert_eq(GauntletManager.Phase.SURVIVAL_ENDGAME, 2, "SURVIVAL_ENDGAME should be 2") assert_eq(CandySurvivalManager.Phase.SURVIVAL_ENDGAME, 2, "SURVIVAL_ENDGAME should be 2")
func after_all(): func after_all():
gut.p("=== Gauntlet Registration Tests Complete ===") gut.p("=== Candy Survival Registration Tests Complete ===")
+4 -4
View File
@@ -1,13 +1,13 @@
extends GutTest extends GutTest
# ============================================================================= # =============================================================================
# Test: Gauntlet Candidate Scoring System (v2) [Gauntlet #073] # Test: Candy Survival Candidate Scoring System (v2) Candy Survival #073]
# Covers each score component, camping accumulation, and full-formula # Covers each score component, camping accumulation, and full-formula
# composition. Runs headless (no multiplayer peer), so elapsed_time = 0 and # composition. Runs headless (no multiplayer peer), so elapsed_time = 0 and
# the final-30s window is inactive unless a test sets elapsed_time directly. # the final-30s window is inactive unless a test sets elapsed_time directly.
# ============================================================================= # =============================================================================
const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd") const CandySurvivalManager = preload("res://scripts/managers/candy_survival_manager.gd")
var manager var manager
var main_mock: Node var main_mock: Node
var gridmap_mock: Node var gridmap_mock: Node
@@ -18,7 +18,7 @@ func before_each():
gridmap_mock = Node.new() gridmap_mock = Node.new()
gridmap_mock.name = "EnhancedGridMap" gridmap_mock.name = "EnhancedGridMap"
main_mock.add_child(gridmap_mock) main_mock.add_child(gridmap_mock)
manager = GauntletManager.new() manager = CandySurvivalManager.new()
main_mock.add_child(manager) main_mock.add_child(manager)
manager.initialize(main_mock, gridmap_mock) manager.initialize(main_mock, gridmap_mock)
manager.current_phase = 0 manager.current_phase = 0
@@ -94,7 +94,7 @@ func test_player_pressure_under_player_penalized():
assert_eq(manager._score_player_pressure(Vector2i(5, 5), players), -50.0, "Under player (early) = -50") assert_eq(manager._score_player_pressure(Vector2i(5, 5), players), -50.0, "Under player (early) = -50")
func test_player_pressure_under_player_final_window(): func test_player_pressure_under_player_final_window():
manager.elapsed_time = manager.gauntlet_round_duration() - 5.0 # within final 30s manager.elapsed_time = manager.candy_survival_round_duration() - 5.0 # within final 30s
var players = [Vector2i(5, 5)] var players = [Vector2i(5, 5)]
assert_eq(manager._score_player_pressure(Vector2i(5, 5), players), 10.0, "Under player (final) = +10") assert_eq(manager._score_player_pressure(Vector2i(5, 5), players), 10.0, "Under player (final) = +10")
+4 -4
View File
@@ -1,16 +1,16 @@
extends GutTest extends GutTest
# ============================================================================= # =============================================================================
# Test: Gauntlet Sticky Cell System (v2) [Gauntlet #068] # Test: Candy Survival Sticky Cell System (v2) Candy Survival #068]
# Covers cell states, CLEANSED protection, coverage helpers, and the # Covers cell states, CLEANSED protection, coverage helpers, and the
# path-safety reachability (BFS) check. # path-safety reachability (BFS) check.
# ============================================================================= # =============================================================================
var GauntletManagerScript = load("res://scripts/managers/gauntlet_manager.gd") var CandySurvivalManagerScript = load("res://scripts/managers/candy_survival_manager.gd")
var manager: GauntletManager var manager: CandySurvivalManager
func before_each(): func before_each():
manager = GauntletManagerScript.new() manager = CandySurvivalManagerScript.new()
add_child(manager) add_child(manager)
func after_each(): func after_each():
+9 -9
View File
@@ -1,14 +1,14 @@
extends GutTest extends GutTest
# ============================================================================= # =============================================================================
# Test: Gauntlet Tile Spawning & Mission System (Task #3) # Test: Candy Survival Tile Spawning & Mission System (Task #3)
# ============================================================================= # =============================================================================
var GauntletManagerScript = load("res://scripts/managers/gauntlet_manager.gd") var CandySurvivalManagerScript = load("res://scripts/managers/candy_survival_manager.gd")
var manager: GauntletManager var manager: CandySurvivalManager
func before_each(): func before_each():
manager = GauntletManagerScript.new() manager = CandySurvivalManagerScript.new()
add_child(manager) add_child(manager)
func after_each(): func after_each():
@@ -123,22 +123,22 @@ func test_clear_sticky_cell():
# ============================================================================= # =============================================================================
func test_initial_phase_is_open_arena(): func test_initial_phase_is_open_arena():
assert_eq(manager.current_phase, GauntletManager.Phase.OPEN_ARENA, "Should start in Open Arena") assert_eq(manager.current_phase, CandySurvivalManager.Phase.OPEN_ARENA, "Should start in Open Arena")
func test_phase_to_string_open_arena(): func test_phase_to_string_open_arena():
assert_eq(manager._phase_to_string(GauntletManager.Phase.OPEN_ARENA), "Outer Pressure") assert_eq(manager._phase_to_string(CandySurvivalManager.Phase.OPEN_ARENA), "Outer Pressure")
func test_phase_to_string_route_pressure(): func test_phase_to_string_route_pressure():
assert_eq(manager._phase_to_string(GauntletManager.Phase.ROUTE_PRESSURE), "Middle Pressure") assert_eq(manager._phase_to_string(CandySurvivalManager.Phase.ROUTE_PRESSURE), "Middle Pressure")
func test_phase_to_string_survival(): func test_phase_to_string_survival():
assert_eq(manager._phase_to_string(GauntletManager.Phase.SURVIVAL_ENDGAME), "Inner Survival") assert_eq(manager._phase_to_string(CandySurvivalManager.Phase.SURVIVAL_ENDGAME), "Inner Survival")
# ============================================================================= # =============================================================================
# Match Timer Integration # Match Timer Integration
# ============================================================================= # =============================================================================
func test_match_duration_180s(): func test_match_duration_180s():
# Gauntlet uses 180s match (3 phases: 0-60, 60-120, 120-180) # Candy Survival uses 180s match (3 phases: 0-60, 60-120, 120-180)
var total = manager.PHASE_3_START + 60.0 # Phase 3 starts at 120, runs 60s var total = manager.PHASE_3_START + 60.0 # Phase 3 starts at 120, runs 60s
assert_eq(total, 180.0, "Total match should be 180 seconds") assert_eq(total, 180.0, "Total match should be 180 seconds")