diff --git a/scenes/arena/gauntlet.tscn b/scenes/arena/candy_survival.tscn similarity index 96% rename from scenes/arena/gauntlet.tscn rename to scenes/arena/candy_survival.tscn index d5cb7ce..0fe8c0c 100644 --- a/scenes/arena/gauntlet.tscn +++ b/scenes/arena/candy_survival.tscn @@ -24,7 +24,7 @@ albedo_color = Color(0.4973, 0.6, 0.12599999, 1) material = SubResource("StandardMaterial3D_86fyc") 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] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 10, 0, 10) diff --git a/scenes/gauntlet_hud.tscn b/scenes/candy_survival_hud.tscn similarity index 96% rename from scenes/gauntlet_hud.tscn rename to scenes/candy_survival_hud.tscn index a80c02a..89fa643 100644 --- a/scenes/gauntlet_hud.tscn +++ b/scenes/candy_survival_hud.tscn @@ -2,7 +2,7 @@ [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 visible = false diff --git a/scenes/candy_cannon.tscn b/scenes/candy_survival_npc.tscn similarity index 94% rename from scenes/candy_cannon.tscn rename to scenes/candy_survival_npc.tscn index a752ae3..48d8b12 100644 --- a/scenes/candy_cannon.tscn +++ b/scenes/candy_survival_npc.tscn @@ -1,6 +1,6 @@ [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"] albedo_color = Color(0.15, 0.15, 0.2, 1) @@ -43,7 +43,7 @@ inner_radius = 0.95 outer_radius = 1.15 rings = 32 -[node name="CandyCannon" type="Node3D" unique_id=1515964905] +[node name="CandySurvivalNPC" type="Node3D" unique_id=1515964905] script = ExtResource("1_canon") [node name="Base" type="MeshInstance3D" parent="." unique_id=867716100] diff --git a/scenes/lobby.gd b/scenes/lobby.gd index 5ec7f1d..7fc9dbf 100644 --- a/scenes/lobby.gd +++ b/scenes/lobby.gd @@ -86,7 +86,7 @@ var sng_go_option: OptionButton var sng_stop_option: OptionButton var sng_goals_option: OptionButton -# Gauntlet settings +# Candy Survival settings @onready var players_container = $LobbyPanel/PlayersContainer @onready var players_container2 = $LobbyPanel/PlayersContainer2 @onready var player_slots: Array[Control] = [] diff --git a/scenes/main.gd b/scenes/main.gd index 9e918c7..3f0f39a 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -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 is_match_ended: bool = false var obstacle_manager -var gauntlet_manager +var candy_survival_manager var vfx_manager # Minimal local state @@ -144,9 +144,9 @@ func _apply_arena_background(): texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg" # Fallback texture _instantiate_3d_arena("res://scenes/arena/freemode.tscn") _hide_ground_tiles() - "Gauntlet Arena": + "Candy Survival Arena": 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() "Classic", _: 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 - # Gauntlet manager for Candy Pump Survival mode - if LobbyManager.game_mode == "Candy Pump Survival": - gauntlet_manager = load("res://scripts/managers/gauntlet_manager.gd").new() - gauntlet_manager.name = "GauntletManager" - add_child(gauntlet_manager) - gauntlet_manager.initialize(self, $EnhancedGridMap) + # Candy Survival manager + if LobbyManager.game_mode == "Candy Survival": + candy_survival_manager = load("res://scripts/managers/candy_survival_manager.gd").new() + candy_survival_manager.name = "CandySurvivalManager" + add_child(candy_survival_manager) + candy_survival_manager.initialize(self, $EnhancedGridMap) # Screen shake manager for impact feedback screen_shake_manager = load("res://scripts/managers/screen_shake.gd").new() @@ -601,8 +601,8 @@ func _setup_host_game(): # Spawning and arena setup if LobbyManager.game_mode == "Stop n Go" and stop_n_go_manager: stop_n_go_manager._setup_arena() - elif LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager: - gauntlet_manager._setup_arena() + elif LobbyManager.game_mode == "Candy Survival" and candy_survival_manager: + candy_survival_manager._setup_arena() else: # Randomize grid first to ensure Floor 0 is walkable for pre-calculation randomize_game_grid() @@ -705,9 +705,9 @@ func _setup_client_game(): add_player_character(i, true) print("Client: Pre-spawned potential bot ", i) - # Initialize arena locally for Candy Pump Survival - if LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager: - gauntlet_manager._apply_arena_setup() + # Initialize arena locally for Candy Survival + if LobbyManager.game_mode == "Candy Survival" and candy_survival_manager: + candy_survival_manager._apply_arena_setup() # Ensure local player setup (UI, controls) is verified 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.spawn_initial_powerups() # Ensure power-ups exist before 1,2,3 Go - # Gauntlet: Spawn mission tiles across 20x20 arena BEFORE countdown - if LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager: - gauntlet_manager.setup_mission_tiles() + # Candy Survival: Spawn mission tiles BEFORE countdown + if LobbyManager.game_mode == "Candy Survival" and candy_survival_manager: + candy_survival_manager.setup_mission_tiles() # Spawn Static Tektons and random tiles BEFORE countdown (Free Mode Only) - # Exclude for Stop n Go and Candy Pump Survival - if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Candy Pump Survival": + # Exclude for Stop n Go and Candy Survival + if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Candy Survival": spawn_static_tektons() # STOP N GO: Rotate players to face East BEFORE countdown @@ -838,9 +838,9 @@ func _start_game(): if goals_cycle_manager: var match_duration = LobbyManager.get_match_duration() goals_cycle_manager.start_match(float(match_duration), false) # No cycles for Stop n Go - elif LobbyManager.game_mode == "Candy Pump Survival": - if gauntlet_manager: - gauntlet_manager.start_game_mode() + elif LobbyManager.game_mode == "Candy Survival": + if candy_survival_manager: + candy_survival_manager.start_game_mode() if goals_cycle_manager: var match_duration = LobbyManager.get_match_duration() @@ -1763,7 +1763,7 @@ func randomize_item_at_position(grid_position: Vector2i): if is_ground: 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 if randf() <= 0.6: return [7, 8, 9, 10].pick_random() diff --git a/scenes/player.gd b/scenes/player.gd index 203fc38..63529cd 100644 --- a/scenes/player.gd +++ b/scenes/player.gd @@ -1140,11 +1140,11 @@ func _find_valid_drop_position() -> Vector2i: var item_cell = Vector3i(pos.x, 1, pos.y) if enhanced_gridmap.get_cell_item(item_cell) == -1: if not is_position_occupied(pos): - # Gauntlet Mode explicit overrides + # Candy Survival explicit overrides var gm = null - var main_gauntlet = get_tree().root.get_node_or_null("Main") - if main_gauntlet and main_gauntlet.get("gauntlet_manager"): - gm = main_gauntlet.gauntlet_manager + var main_candy_survival = get_tree().root.get_node_or_null("Main") + if main_candy_survival and main_candy_survival.get("candy_survival_manager"): + gm = main_candy_survival.candy_survival_manager 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: continue diff --git a/scripts/bot_controller.gd b/scripts/bot_controller.gd index d1b8512..8334bb7 100644 --- a/scripts/bot_controller.gd +++ b/scripts/bot_controller.gd @@ -210,7 +210,7 @@ func _run_ai_tick(): print("[BotController] Action Taken: Attack Pursuit") 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(): print("[BotController] Action Taken: Ghost (trapped)") return @@ -260,7 +260,7 @@ func _run_ai_tick(): return # ============================================================================= -# Gauntlet (#075) — Ghost Powerup + Sticky Avoidance wiring +# Candy Survival (#075) — Ghost Powerup + Sticky Avoidance wiring # ============================================================================= func _try_activate_ghost() -> bool: @@ -268,7 +268,7 @@ func _try_activate_ghost() -> bool: Uses the existing SpecialTilesManager to activate the held ghost powerup. 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 if not strategic_planner.should_activate_ghost_now(): return false @@ -291,9 +291,9 @@ func _on_step_onto_unsafe() -> bool: return false # Post-move guard: if we somehow landed on a sticky without ghost active, # 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(): - 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): print("[BotController] %s stepped onto sticky at %s — activating Ghost" % [actor.name, here]) return _try_activate_ghost() diff --git a/scripts/bot_strategic_planner.gd b/scripts/bot_strategic_planner.gd index 8658f01..cb5ed85 100644 --- a/scripts/bot_strategic_planner.gd +++ b/scripts/bot_strategic_planner.gd @@ -8,38 +8,38 @@ class_name BotStrategicPlanner var actor: Node3D var enhanced_gridmap: Node -# Optional explicit gauntlet_manager binding (set by tests to avoid scene-tree -# traversal collisions; production code uses _get_gauntlet_manager() instead). -var gauntlet_manager_override: Node = null +# Optional explicit candy_survival_manager binding (set by tests to avoid scene-tree +# traversal collisions; production code uses _get_candy_survival_manager() instead). +var candy_survival_manager_override: Node = null # Tile type constants const GOAL_TILES = [7, 8, 9, 10] # Heart, Diamond, Star, Coin 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. -const GAUNTLET_OVERLAY_LAYER: int = 2 +const CANDY_SURVIVAL_OVERLAY_LAYER: int = 2 const TILE_STICKY: int = 17 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: - return LobbyManager and LobbyManager.is_game_mode(GameMode.Mode.GAUNTLET) +func is_candy_survival_mode() -> bool: + return LobbyManager and LobbyManager.is_game_mode(GameMode.Mode.CANDY_SURVIVAL) -func _get_gauntlet_manager() -> Node: - """Resolve the active GauntletManager. +func _get_candy_survival_manager() -> Node: + """Resolve the active CandySurvivalManager. Order of resolution: - 1. Explicit `gauntlet_manager_override` (used by tests). - 2. Walk actor's ancestors for any node containing a GauntletManager child + 1. Explicit `candy_survival_manager_override` (used by tests). + 2. Walk actor's ancestors for any node containing a CandySurvivalManager child (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): - return gauntlet_manager_override + if candy_survival_manager_override and is_instance_valid(candy_survival_manager_override): + return candy_survival_manager_override var root: Node = null if actor and actor.is_inside_tree(): root = actor.get_tree().get_root() @@ -47,7 +47,7 @@ func _get_gauntlet_manager() -> Node: # nested under Main → Arena → Player). var n: Node = actor.get_parent() while n: - var gm = n.get_node_or_null("GauntletManager") + var gm = n.get_node_or_null("CandySurvivalManager") if gm: return gm n = n.get_parent() @@ -56,7 +56,7 @@ func _get_gauntlet_manager() -> Node: # Last-resort scan of root children (helps in unusual scene trees). for child in root.get_children(): 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: return gm2 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.""" if not enhanced_gridmap: 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 -func _is_cell_unsafe_in_gauntlet(pos: Vector2i) -> bool: - """Cell is unsafe in Gauntlet if it's sticky/telegraphed — unless the bot's +func _is_cell_unsafe_in_candy_survival(pos: Vector2i) -> bool: + """Cell is unsafe in Candy Survival if it's sticky/telegraphed — unless the bot's Ghost mode is active (grants sticky bypass).""" - if not is_gauntlet_mode(): + if not is_candy_survival_mode(): return false if _is_bot_ghost_active(): 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): return true return _is_overlay_unsafe(pos) @@ -101,7 +101,7 @@ func _count_unsafe_neighbors(pos: Vector2i) -> int: func should_activate_ghost_now() -> bool: """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 if not _bot_has_ghost_powerup(): 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): 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. # 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 # exempt via the helper. - if _is_cell_unsafe_in_gauntlet(pos): + if _is_cell_unsafe_in_candy_survival(pos): return false return true diff --git a/scripts/controllers/candy_cannon_controller.gd b/scripts/controllers/candy_survival_npc_controller.gd similarity index 97% rename from scripts/controllers/candy_cannon_controller.gd rename to scripts/controllers/candy_survival_npc_controller.gd index 85e31ff..787f8dc 100644 --- a/scripts/controllers/candy_cannon_controller.gd +++ b/scripts/controllers/candy_survival_npc_controller.gd @@ -1,5 +1,5 @@ extends Node3D -class_name CandyCannonController +class_name CandySurvivalNPCController @export var is_static_turret: bool = true @@ -67,7 +67,7 @@ func spawn_projectile(target_world_pos: Vector3, duration: float) -> void: projectile.mesh = sphere 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 = Color(1.0, 0.4, 0.8) mat.emission_energy_multiplier = 3.0 diff --git a/scripts/controllers/candy_cannon_controller.gd.uid b/scripts/controllers/candy_survival_npc_controller.gd.uid similarity index 100% rename from scripts/controllers/candy_cannon_controller.gd.uid rename to scripts/controllers/candy_survival_npc_controller.gd.uid diff --git a/scripts/game_mode.gd b/scripts/game_mode.gd index 8c2c931..ed17b2d 100644 --- a/scripts/game_mode.gd +++ b/scripts/game_mode.gd @@ -1,7 +1,7 @@ extends RefCounted 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: match mode: @@ -9,8 +9,8 @@ static func from_string(mode: String) -> Mode: return Mode.FREEMODE "Stop n Go": return Mode.STOP_N_GO - "Candy Pump Survival": - return Mode.GAUNTLET + "Candy Survival": + return Mode.CANDY_SURVIVAL return Mode.FREEMODE static func mode_to_string(mode: Mode) -> String: @@ -19,13 +19,13 @@ static func mode_to_string(mode: Mode) -> String: return "Freemode" Mode.STOP_N_GO: return "Stop n Go" - Mode.GAUNTLET: - return "Candy Pump Survival" + Mode.CANDY_SURVIVAL: + return "Candy Survival" _: return "Freemode" 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]: - return ["Freemode", "Stop n Go", "Candy Pump Survival"] + return ["Freemode", "Stop n Go", "Candy Survival"] diff --git a/scripts/managers/camera_context_manager.gd b/scripts/managers/camera_context_manager.gd index 42fd1db..f90ee34 100644 --- a/scripts/managers/camera_context_manager.gd +++ b/scripts/managers/camera_context_manager.gd @@ -12,7 +12,7 @@ var player: Node3D @export var z_offset: float = 12.0 @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 } 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 var bounds = bounds_freemode # Default - if mode == GameMode.Mode.GAUNTLET: - bounds = bounds_gauntlet + if mode == GameMode.Mode.CANDY_SURVIVAL: + bounds = bounds_candy_survival elif mode == GameMode.Mode.STOP_N_GO: bounds = bounds_stop_n_go diff --git a/scripts/managers/candy_survival_manager.gd b/scripts/managers/candy_survival_manager.gd new file mode 100644 index 0000000..b9a2b00 --- /dev/null +++ b/scripts/managers/candy_survival_manager.gd @@ -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 diff --git a/scripts/managers/gauntlet_manager.gd.uid b/scripts/managers/candy_survival_manager.gd.uid similarity index 100% rename from scripts/managers/gauntlet_manager.gd.uid rename to scripts/managers/candy_survival_manager.gd.uid diff --git a/scripts/managers/gauntlet_manager.gd b/scripts/managers/gauntlet_manager.gd deleted file mode 100644 index 6ceed8e..0000000 --- a/scripts/managers/gauntlet_manager.gd +++ /dev/null @@ -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.5–3) -const BUBBLE_EXPLOSION_RADIUS: int = 1 # 1 => 3x3 area -const BUBBLE_RECENT_MEMORY: int = 4 # how many recent centers to remember -const BUBBLE_RECENT_RADIUS: int = 3 # anti-stacking exclusion distance - -# Phase-specific growth parameters (cells-per-tick range per phase). -# Layer weights: [outer, middle, inner] priority for the current pressure layer. -var phase_growth_config: Array = [ - # Phase 0 (Outer Pressure): 4-6 cells/tick, push from the outside in - {"cells_min": 4, "cells_max": 6, "layer_weights": {"outer": 60, "middle": 15, "inner": -40}}, - # Phase 1 (Middle Pressure): 6-8 cells/tick - {"cells_min": 6, "cells_max": 8, "layer_weights": {"outer": 20, "middle": 60, "inner": 5}}, - # Phase 2 (Inner Survival): 8-10 cells/tick - {"cells_min": 8, "cells_max": 10, "layer_weights": {"outer": 10, "middle": 35, "inner": 60}}, -] - -# ============================================================================= -# Smack State (per-player) -# ============================================================================= - -func has_smack_charged(pid: int) -> bool: - if smack_charged.has(pid) and smack_charged[pid] > 0: - return true - return false - -@rpc("any_peer", "call_local", "reliable") -func consume_smack(pid: int) -> void: - # Local state reset - smack_charged[pid] = 0.0 - smack_cooldowns[pid] = SMACK_COOLDOWN - - # Play smack sound - if SfxManager: - SfxManager.rpc("play_rpc", "attack_mode") if _can_rpc() else SfxManager.play("attack_mode") - - var all_players = get_tree().get_nodes_in_group("Players") - for player in all_players: - var curr_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int() - if curr_pid == pid: - if player.has_method("sync_modulate"): - if _can_rpc(): - player.rpc("sync_modulate", Color.WHITE) - else: - player.sync_modulate(Color.WHITE) - break - -var smack_cooldowns: Dictionary = {} # player_id → float (time remaining) -var smack_charged: Dictionary = {} # player_id → float (charge window remaining) -const SMACK_COOLDOWN: float = 8.0 -const SMACK_CHARGE_WINDOW: float = 3.0 - -# ============================================================================= -# 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 (0–0.8s): amber glow ramps alpha 0→1. - • Flash (0.8–1.0s): flickers to bright amber just before impact. - Auto-removed at the end of the telegraph window. Amber here is deliberately - distinct from the pink/magenta sticky overlay so the two never read alike.""" - var cs = gridmap.cell_size - var world_pos = Vector3(pos.x * cs.x + cs.x / 2.0, 0.15, pos.y * cs.z + cs.z / 2.0) - - var mesh_inst = MeshInstance3D.new() - var box = BoxMesh.new() - box.size = Vector3(cs.x * 0.8, 0.02, cs.z * 0.8) - mesh_inst.mesh = box - mesh_inst.position = world_pos - - var amber := Color(1.0, 0.65, 0.1) # syrup amber — clearly not sticky pink - var mat = StandardMaterial3D.new() - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.albedo_color = Color(amber.r, amber.g, amber.b, 0.0) - mat.emission_enabled = true - mat.emission = amber - mat.emission_energy_multiplier = 1.5 - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mesh_inst.material_override = mat - - var main = get_node_or_null("/root/Main") - if not main: - return - main.add_child(mesh_inst) - - # Split the telegraph window 80% build-up / 20% flash. - var build := telegraph_duration * 0.8 - var flash := telegraph_duration * 0.2 - - var tween = create_tween() - # Build-up: fade in to a steady amber. - tween.tween_method(func(a): mat.albedo_color.a = a, 0.0, 0.55, build) - # Flash: quick bright flicker (alpha + emission energy) right before impact. - tween.tween_method(func(e): mat.emission_energy_multiplier = e, 1.5, 4.0, flash * 0.5) - tween.parallel().tween_method(func(a): mat.albedo_color.a = a, 0.55, 0.9, flash * 0.5) - tween.tween_method(func(e): mat.emission_energy_multiplier = e, 4.0, 2.5, flash * 0.5) - - var remove_timer = get_tree().create_timer(telegraph_duration) - remove_timer.timeout.connect(func(): - if is_instance_valid(mesh_inst): - mesh_inst.queue_free() - ) - -@rpc("authority", "call_local", "reliable") -func sync_growth_apply(cells: Array) -> void: - """Convert telegraphed cells to permanent sticky candy.""" - if not gridmap: return - for cell in cells: - var pos = cell as Vector2i - 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 diff --git a/scripts/managers/lobby_manager.gd b/scripts/managers/lobby_manager.gd index a06980d..e933def 100644 --- a/scripts/managers/lobby_manager.gd +++ b/scripts/managers/lobby_manager.gd @@ -26,10 +26,10 @@ signal sng_go_duration_changed(duration: int) signal sng_stop_duration_changed(duration: int) signal sng_required_goals_changed(goals: int) -# Gauntlet settings signals -signal gauntlet_round_duration_changed(duration: int) -signal gauntlet_growth_interval_changed(interval: float) -signal gauntlet_cells_per_tick_changed(cells: Dictionary) +# Candy Survival settings signals +signal candy_survival_duration_changed(duration: int) +signal candy_survival_growth_interval_changed(interval: float) +signal candy_survival_cells_per_tick_changed(cells: Dictionary) # Room data structure var current_room: Dictionary = {} @@ -69,10 +69,10 @@ var sng_go_duration: int = 20 var sng_stop_duration: int = 4 var sng_required_goals: int = 8 -# Gauntlet settings -var gauntlet_round_duration: int = 180 -var gauntlet_growth_interval: float = 3.0 # seconds between growth ticks -var gauntlet_cells_per_tick: Dictionary = { +# Candy Survival settings +var candy_survival_round_duration: int = 180 +var candy_survival_growth_interval: float = 3.0 # seconds between growth ticks +var candy_survival_cells_per_tick: Dictionary = { "phase1": [4, 6], "phase2": [6, 8], "phase3": [8, 10], @@ -84,7 +84,7 @@ var rematch_votes: Array = [] # [player_id, ...] # Character and area selection var available_characters: Array[String] = ["Copper", "Dabro", "Gatot", "Pip", "Random"] 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 game_mode: String = "Freemode" # Host-controlled 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"] "Stop n Go": available_areas = ["Stop N Go Arena"] - "Candy Pump Survival": - available_areas = ["Gauntlet Arena"] + "Candy Survival": + available_areas = ["Candy Survival Arena"] _: available_areas = ["Classic"] @@ -513,35 +513,35 @@ func sync_sng_required_goals(goals: int) -> void: emit_signal("sng_required_goals_changed", goals) # ============================================================================= -# Gauntlet Settings +# Candy Survival Settings # ============================================================================= -func set_gauntlet_round_duration(duration: int) -> void: - gauntlet_round_duration = duration - if is_host: rpc("sync_gauntlet_round_duration", duration) +func set_candy_survival_round_duration(duration: int) -> void: + candy_survival_round_duration = duration + if is_host: rpc("sync_candy_survival_round_duration", duration) @rpc("authority", "call_local", "reliable") -func sync_gauntlet_round_duration(duration: int) -> void: - gauntlet_round_duration = duration - emit_signal("gauntlet_round_duration_changed", duration) +func sync_candy_survival_round_duration(duration: int) -> void: + candy_survival_round_duration = duration + emit_signal("candy_survival_duration_changed", duration) -func set_gauntlet_growth_interval(interval: float) -> void: - gauntlet_growth_interval = interval - if is_host: rpc("sync_gauntlet_growth_interval", interval) +func set_candy_survival_growth_interval(interval: float) -> void: + candy_survival_growth_interval = interval + if is_host: rpc("sync_candy_survival_growth_interval", interval) @rpc("authority", "call_local", "reliable") -func sync_gauntlet_growth_interval(interval: float) -> void: - gauntlet_growth_interval = interval - emit_signal("gauntlet_growth_interval_changed", interval) +func sync_candy_survival_growth_interval(interval: float) -> void: + candy_survival_growth_interval = interval + emit_signal("candy_survival_growth_interval_changed", interval) -func set_gauntlet_cells_per_tick(cells: Dictionary) -> void: - gauntlet_cells_per_tick = cells - if is_host: rpc("sync_gauntlet_cells_per_tick", cells) +func set_candy_survival_cells_per_tick(cells: Dictionary) -> void: + candy_survival_cells_per_tick = cells + if is_host: rpc("sync_candy_survival_cells_per_tick", cells) @rpc("authority", "call_local", "reliable") -func sync_gauntlet_cells_per_tick(cells: Dictionary) -> void: - gauntlet_cells_per_tick = cells - emit_signal("gauntlet_cells_per_tick_changed", cells) +func sync_candy_survival_cells_per_tick(cells: Dictionary) -> void: + candy_survival_cells_per_tick = cells + emit_signal("candy_survival_cells_per_tick_changed", cells) # ============================================================================= # Character Selection @@ -699,8 +699,8 @@ func set_game_mode(mode: String) -> void: set_area("Free Mode Area") elif mode == "Stop n Go" and "Stop n Go Area" in available_areas: set_area("Stop n Go Area") - elif mode == "Candy Pump Survival" and "Gauntlet Arena" in available_areas: - set_area("Gauntlet Arena") + elif mode == "Candy Survival" and "Candy Survival Arena" in available_areas: + set_area("Candy Survival Arena") @rpc("authority", "call_local", "reliable") func sync_game_mode(mode: String) -> void: @@ -713,8 +713,8 @@ func sync_game_mode(mode: String) -> void: selected_area = "Free Mode Area" elif mode == "Stop n Go" and "Stop n Go Area" in available_areas: selected_area = "Stop n Go Area" - elif mode == "Candy Pump Survival" and "Gauntlet Arena" in available_areas: - selected_area = "Gauntlet Arena" + elif mode == "Candy Survival" and "Candy Survival Arena" in available_areas: + selected_area = "Candy Survival Arena" elif selected_area not in available_areas: 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_stop_duration", sng_stop_duration) rpc("sync_sng_required_goals", sng_required_goals) - # Sync gauntlet settings - rpc("sync_gauntlet_round_duration", gauntlet_round_duration) - rpc("sync_gauntlet_growth_interval", gauntlet_growth_interval) - rpc("sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick) + # Sync Candy Survival settings + rpc("sync_candy_survival_round_duration", candy_survival_round_duration) + rpc("sync_candy_survival_growth_interval", candy_survival_growth_interval) + rpc("sync_candy_survival_cells_per_tick", candy_survival_cells_per_tick) # Sync 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_stop_duration", sng_stop_duration) 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_gauntlet_growth_interval", gauntlet_growth_interval) - rpc_id(requester_id, "sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick) + rpc_id(requester_id, "sync_candy_survival_round_duration", candy_survival_round_duration) + rpc_id(requester_id, "sync_candy_survival_growth_interval", candy_survival_growth_interval) + 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_area", selected_area) diff --git a/scripts/managers/player_movement_manager.gd b/scripts/managers/player_movement_manager.gd index 1220b69..7e11b11 100644 --- a/scripts/managers/player_movement_manager.gd +++ b/scripts/managers/player_movement_manager.gd @@ -112,19 +112,19 @@ func simple_move_to(grid_position: Vector2i) -> bool: print("[MovementManager] Hard block at %s. Ghost pass denied." % grid_position) var gm = null - var main_gauntlet = player.get_tree().root.get_node_or_null("Main") - if main_gauntlet and main_gauntlet.get("gauntlet_manager"): - gm = main_gauntlet.gauntlet_manager + var main_candy_survival = player.get_tree().root.get_node_or_null("Main") + if main_candy_survival and main_candy_survival.get("candy_survival_manager"): + gm = main_candy_survival.candy_survival_manager # Check Floor 0 (Basic Walkability/Void) 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) 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._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 # 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 # 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 player.get("is_invisible"): # Ghost mode: walk through sticky tile freely print("[Move] Ghost mode bypassed sticky cell at %s" % grid_position) 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 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) return false - # === NEW LOGIC: Only allow push if in ATTACK MODE and NOT GHOST === - var has_smack = false - var main_for_smack = 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 - if gm_for_smack and gm_for_smack.is_active: + # Candy Survival: check if attacker has knock charges + var has_knock = false + var main_for_knock = player.get_tree().root.get_node_or_null("Main") + var gm_for_knock = main_for_knock.get("candy_survival_manager") if main_for_knock else null + 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() - 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) print("[Move] Push blocked: Not charged or is Ghost (%s trying to push %s)" % [player.name, other_player.name]) if _can_rpc(): @@ -243,47 +244,20 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool: var gm = null var main_push_check = player.get_tree().root.get_node_or_null("Main") - if main_push_check and main_push_check.get("gauntlet_manager"): - gm = main_push_check.gauntlet_manager + if main_push_check and main_push_check.get("candy_survival_manager"): + gm = main_push_check.candy_survival_manager - # IF Gauntlet Mode is active, handle special Gauntlet Smacks - if gm and gm.is_active: + # Candy Survival: knock mechanic (candy-steal or backfire) + 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 other_pid = other_player.get("peer_id") if "peer_id" in other_player else other_player.name.to_int() - - # Check if attacker has smack - if not gm.has_smack_charged(pid): - # bump visuals - if _can_rpc(): - player.rpc("sync_bump", target_pos, true) - elif player.has_method("sync_bump"): - 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) + if gm.try_knock(pid, other_pid): + print("[CandySurvival] Player %d knocked %d, stole candies!" % [pid, other_pid]) + else: + print("[CandySurvival] Player %d tried to knock %d (backfire or no charges)" % [pid, other_pid]) + if _can_rpc(): + player.rpc("sync_bump", target_pos, false) + return false # === SUPER PUSH (Attack Mode) === 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 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: push_direction = direction # Use the direction of the attack 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 var main_sticky = player.get_tree().root.get_node_or_null("Main") - if main_sticky and main_sticky.get("gauntlet_manager"): - var gm_sticky = main_sticky.gauntlet_manager + if main_sticky and main_sticky.get("candy_survival_manager"): + var gm_sticky = main_sticky.candy_survival_manager if gm_sticky.is_active and gm_sticky.is_sticky_cell(pushed_to_pos): if other_player.get("is_invisible"): # Ghost mode: pushed player bypasses sticky diff --git a/scripts/managers/special_tiles_manager.gd b/scripts/managers/special_tiles_manager.gd index c522766..26da95b 100644 --- a/scripts/managers/special_tiles_manager.gd +++ b/scripts/managers/special_tiles_manager.gd @@ -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): # Spawn ONLY common tiles (7-10) in Stop n Go mode (User Request) item_id = rng.randi_range(7, 10) - elif LobbyManager.is_game_mode(GameMode.Mode.GAUNTLET): - # Gauntlet mode: mostly common tiles, but ghost (14) can spawn too. + elif LobbyManager.is_game_mode(GameMode.Mode.CANDY_SURVIVAL): + # Candy Survival mode: mostly common tiles, but ghost (14) can spawn too. if rng.randf() < 0.85: item_id = rng.randi_range(7, 10) else: diff --git a/scripts/mode_config.gd b/scripts/mode_config.gd index 344c5c3..1c564af 100644 --- a/scripts/mode_config.gd +++ b/scripts/mode_config.gd @@ -18,10 +18,10 @@ const SCHEMA = { "sng_stop_duration": {"type": TYPE_INT, "default": 4, "min": 2, "max": 10}, "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}, - "gauntlet_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_growth_interval": {"type": TYPE_FLOAT, "default": 3.0, "min": 1.0, "max": 10.0}, + "candy_survival_cells_per_tick": {"type": TYPE_DICTIONARY, "default": {"phase1": [4, 6], "phase2": [6, 8], "phase3": [8, 10]}} } } diff --git a/scripts/tekton.gd b/scripts/tekton.gd index cbed9e0..a65f803 100644 --- a/scripts/tekton.gd +++ b/scripts/tekton.gd @@ -505,8 +505,8 @@ func spawn_tiles_around(count: int = 4): 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) item_id = rng.randi_range(7, 10) - elif LobbyManager and LobbyManager.get_game_mode() == GameMode.Mode.GAUNTLET: - # Gauntlet mode: No power-up spawns from Tekton grabs + elif LobbyManager and LobbyManager.get_game_mode() == GameMode.Mode.CANDY_SURVIVAL: + # Candy Survival mode: No power-up spawns from Tekton grabs item_id = rng.randi_range(7, 10) else: # 40% PowerUp (11-14) diff --git a/test_mp.gd b/test_mp.gd index bb51f11..62a02e4 100644 --- a/test_mp.gd +++ b/test_mp.gd @@ -1,4 +1,4 @@ extends SceneTree func _init(): - print("Testing gauntlet multiplayer") + print("Testing candy_survival multiplayer") quit() diff --git a/tests/helpers/gridmap_mock.gd b/tests/helpers/gridmap_mock.gd index 2ea8e79..731f515 100644 --- a/tests/helpers/gridmap_mock.gd +++ b/tests/helpers/gridmap_mock.gd @@ -1,6 +1,6 @@ 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 # real GridMap. Only the surface the manager touches is implemented. diff --git a/tests/helpers/main_mock.gd b/tests/helpers/main_mock.gd index 1835d5e..d318cdb 100644 --- a/tests/helpers/main_mock.gd +++ b/tests/helpers/main_mock.gd @@ -1,7 +1,7 @@ extends Node -# Minimal "Main" stand-in for Gauntlet headless tests. Provides the RPC methods -# the GauntletManager calls on its main_scene so calls resolve without the full +# Minimal "Main" stand-in for Candy Survival headless tests. Provides the RPC methods +# 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. @rpc("any_peer", "call_local", "reliable") diff --git a/tests/test_bot_gauntlet.gd b/tests/test_bot_gauntlet.gd index 84c482f..a777fc6 100644 --- a/tests/test_bot_gauntlet.gd +++ b/tests/test_bot_gauntlet.gd @@ -1,13 +1,13 @@ 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: -# • Detects Gauntlet mode and exposes helpers. +# • Detects Candy Survival mode and exposes helpers. # • Rejects sticky / telegraphed cells in _is_valid_move_target. # • Activates Cleanser when boxed in or standing on telegraphed ground. # • 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") @@ -26,8 +26,8 @@ class StubActor extends Node3D: func is_position_occupied(_p: Vector2i) -> bool: return false -class StubGauntletManager extends Node: - # Mimics the slice of GauntletManager API the bot planner depends on. +class StubCandySurvivalManager extends Node: + # Mimics the slice of CandySurvivalManager API the bot planner depends on. var sticky_map: Dictionary = {} # Vector2i -> true var player_cleansers: Dictionary = {} # peer_id -> int var cleanser_active: Dictionary = {} # peer_id -> true @@ -46,7 +46,7 @@ class StubGauntletManager extends Node: var main_node: Node var gridmap: Node -var gauntlet_manager: StubGauntletManager +var candy_survival_manager: StubCandySurvivalManager var actor: StubActor var planner: RefCounted @@ -68,9 +68,9 @@ func before_each(): gridmap.set_cell_item(Vector3i(x, 0, z), 1) main_node.add_child(gridmap) - gauntlet_manager = StubGauntletManager.new() - gauntlet_manager.name = "GauntletManager" - main_node.add_child(gauntlet_manager) + candy_survival_manager = StubCandySurvivalManager.new() + candy_survival_manager.name = "CandySurvivalManager" + main_node.add_child(candy_survival_manager) actor = StubActor.new() actor.enhanced_gridmap = gridmap @@ -78,17 +78,17 @@ func before_each(): main_node.add_child(actor) 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. - LobbyManager.game_mode = "Candy Pump Survival" + # Default to Candy Survival mode for these tests. + LobbyManager.game_mode = "Candy Survival" func after_each(): if is_instance_valid(main_node): main_node.queue_free() actor = null planner = null - gauntlet_manager = null + candy_survival_manager = null gridmap = null # Reset lobby mode so other tests aren't affected. LobbyManager.game_mode = "Freemode" @@ -97,19 +97,19 @@ func after_each(): # Mode detection # ============================================================================= -func test_is_gauntlet_mode_true_when_set(): - assert_true(planner.is_gauntlet_mode(), "Detects Gauntlet via LobbyManager") +func test_is_candy_survival_mode_true_when_set(): + 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" - 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" - 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(): - var gm = planner._get_gauntlet_manager() - assert_not_null(gm, "Resolves GauntletManager under /root/Main") - assert_eq(gm, gauntlet_manager, "Same instance as the one we added") +func test_get_candy_survival_manager_resolves_from_main(): + var gm = planner._get_candy_survival_manager() + assert_not_null(gm, "Resolves CandySurvivalManager under /root/Main") + assert_eq(gm, candy_survival_manager, "Same instance as the one we added") # ============================================================================= # 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, 1, 3), 17) 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 @@ -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). gridmap.set_cell_item(Vector3i(3, 2, 3), 17) 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(): gridmap.set_cell_item(Vector3i(5, 2, 5), 18) 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(): 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), "Safety check still active with ignore_players=true") -func test_valid_move_target_outside_gauntlet_allows_sticky(): - # Outside Gauntlet, layer-2 sticky overlays are not safety-relevant. +func test_valid_move_target_outside_candy_survival_allows_sticky(): + # Outside Candy Survival, layer-2 sticky overlays are not safety-relevant. LobbyManager.game_mode = "Freemode" gridmap.set_cell_item(Vector3i(3, 2, 3), 17) 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(): - 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) assert_true(planner._is_valid_move_target(Vector2i(3, 3)), "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 - # GauntletManager's authoritative sticky_cells map must block the move. - gauntlet_manager.sticky_map[Vector2i(7, 7)] = true + # CandySurvivalManager's authoritative sticky_cells map must block the move. + candy_survival_manager.sticky_map[Vector2i(7, 7)] = true assert_false(planner._is_valid_move_target(Vector2i(7, 7)), "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") 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) for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: var n = Vector2i(5, 5) + d @@ -230,7 +230,7 @@ func test_should_activate_cleanser_true_when_surrounded(): "Trapped with charge → activate Cleanser") 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) gridmap.set_cell_item(Vector3i(5, 2, 5), 18) # bot standing on telegraph # 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") func test_should_activate_cleanser_false_when_already_active(): - gauntlet_manager.player_cleansers[actor.peer_id] = 1 - gauntlet_manager.cleanser_active[actor.peer_id] = true + candy_survival_manager.player_cleansers[actor.peer_id] = 1 + candy_survival_manager.cleanser_active[actor.peer_id] = true actor.current_position = Vector2i(5, 5) for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: 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(): 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) for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: var n = Vector2i(5, 5) + d gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17) 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(): - gauntlet_manager.player_cleansers[actor.peer_id] = 1 + candy_survival_manager.player_cleansers[actor.peer_id] = 1 actor.current_position = Vector2i(5, 5) # No sticky neighbors, plenty of room. 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") 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") func test_is_bot_cleanser_active_reflects_manager(): 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") # ============================================================================= -# BotController → GauntletManager RPC integration +# BotController → CandySurvivalManager RPC integration # ============================================================================= 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.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) for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: var n = Vector2i(5, 5) + d @@ -303,9 +303,9 @@ func test_bot_controller_requests_cleanser_when_trapped(): var requested = ctrl._try_activate_cleanser() 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") - 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") ctrl.queue_free() @@ -317,11 +317,11 @@ func test_bot_controller_does_not_request_when_safe(): ctrl.strategic_planner = planner ctrl.actor = actor - gauntlet_manager.player_cleansers[actor.peer_id] = 1 + candy_survival_manager.player_cleansers[actor.peer_id] = 1 # No sticky anywhere. var requested = ctrl._try_activate_cleanser() 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") ctrl.queue_free() @@ -334,13 +334,13 @@ func test_bot_controller_skips_cleanser_outside_gauntlet(): ctrl.actor = actor 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) for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: var n = Vector2i(5, 5) + d gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17) var requested = ctrl._try_activate_cleanser() - assert_false(requested, "No request outside Gauntlet") + assert_false(requested, "No request outside Candy Survival") ctrl.queue_free() \ No newline at end of file diff --git a/tests/test_gauntlet_bubble.gd b/tests/test_gauntlet_bubble.gd index 8cac01f..b174828 100644 --- a/tests/test_gauntlet_bubble.gd +++ b/tests/test_gauntlet_bubble.gd @@ -1,14 +1,14 @@ 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, # the 3x3 blast footprint, and the grow→explode lifecycle. # Runs headless (no multiplayer peer): elapsed_time = 0 so the final-30s window # 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") var manager var main_mock: Node @@ -20,7 +20,7 @@ func before_each(): gridmap_mock = GridMapMock.new() gridmap_mock.name = "EnhancedGridMap" main_mock.add_child(gridmap_mock) - manager = GauntletManager.new() + manager = CandySurvivalManager.new() main_mock.add_child(manager) manager.initialize(main_mock, gridmap_mock) manager.current_phase = 0 diff --git a/tests/test_gauntlet_cleanser.gd b/tests/test_gauntlet_cleanser.gd index ded99b8..3b0e3a6 100644 --- a/tests/test_gauntlet_cleanser.gd +++ b/tests/test_gauntlet_cleanser.gd @@ -1,13 +1,13 @@ 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, # sticky clearing, stun-blocked activation, and the safe-stop early termination. # 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 MainMock = preload("res://tests/helpers/main_mock.gd") var manager @@ -20,7 +20,7 @@ func before_each(): gridmap_mock = GridMapMock.new() gridmap_mock.name = "EnhancedGridMap" main_mock.add_child(gridmap_mock) - manager = GauntletManager.new() + manager = CandySurvivalManager.new() main_mock.add_child(manager) manager.initialize(main_mock, gridmap_mock) manager.current_phase = 0 diff --git a/tests/test_gauntlet_floor_highlight.gd b/tests/test_gauntlet_floor_highlight.gd index 54829d1..7083eef 100644 --- a/tests/test_gauntlet_floor_highlight.gd +++ b/tests/test_gauntlet_floor_highlight.gd @@ -1,13 +1,13 @@ 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 # telegraph window: amber color, two-stage alpha (build-up → flash), lifetime # 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") var main_mock: Node @@ -15,7 +15,7 @@ var gridmap_mock: Node var manager: Node 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(): main_mock = Node.new() @@ -26,7 +26,7 @@ func before_each(): gridmap_mock.name = "EnhancedGridMap" main_mock.add_child(gridmap_mock) - manager = GauntletManager.new() + manager = CandySurvivalManager.new() main_mock.add_child(manager) 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 # sync_growth_apply triggers _check_all_players_trapped which needs an # 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. _without_peer(func(): manager.sync_growth_telegraph([Vector2i(7, 7)]) diff --git a/tests/test_gauntlet_growth_tick.gd b/tests/test_gauntlet_growth_tick.gd index d410ff1..0d6a24e 100644 --- a/tests/test_gauntlet_growth_tick.gd +++ b/tests/test_gauntlet_growth_tick.gd @@ -1,19 +1,19 @@ 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, # candidate generation, cells-per-tick ranges, weighted selection, and # cleansed-cell exclusion. # ============================================================================= -const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd") -var gauntlet_manager: Node +const CandySurvivalManager = preload("res://scripts/managers/candy_survival_manager.gd") +var candy_survival_manager: Node var main_mock: Node var gridmap_mock: Node func before_all(): - gut.p("=== Feature Tests [Gauntlet #067 Growth Tick] ===") + gut.p("=== Feature Tests Candy Survival #067 Growth Tick] ===") func before_each(): main_mock = Node.new() @@ -22,9 +22,9 @@ func before_each(): gridmap_mock.name = "EnhancedGridMap" main_mock.add_child(gridmap_mock) - gauntlet_manager = GauntletManager.new() - main_mock.add_child(gauntlet_manager) - gauntlet_manager.initialize(main_mock, gridmap_mock) + candy_survival_manager = CandySurvivalManager.new() + main_mock.add_child(candy_survival_manager) + candy_survival_manager.initialize(main_mock, gridmap_mock) func after_each(): if main_mock: @@ -38,43 +38,43 @@ func after_all(): # ============================================================================= 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(): - 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(): - 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 # ============================================================================= 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(): - 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_max"], 6, "Phase 1 max 6 cells/tick") 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_max"], 8, "Phase 2 max 8 cells/tick") 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_max"], 10, "Phase 3 max 10 cells/tick") func test_cells_this_tick_in_phase_range(): for phase in range(3): - gauntlet_manager.current_phase = phase - var cfg = gauntlet_manager.phase_growth_config[phase] + candy_survival_manager.current_phase = phase + var cfg = candy_survival_manager.phase_growth_config[phase] # Sample several times since the count is randomized. 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"], "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(): - gauntlet_manager.current_phase = 0 - var candidates = gauntlet_manager._generate_candidates() + candy_survival_manager.current_phase = 0 + var candidates = candy_survival_manager._generate_candidates() # 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") func test_candidates_exclude_sticky(): - gauntlet_manager.sticky_cells[Vector2i(3, 3)] = true - var candidates = gauntlet_manager._generate_candidates() + candy_survival_manager.sticky_cells[Vector2i(3, 3)] = true + var candidates = candy_survival_manager._generate_candidates() var found := false for c in candidates: if c["pos"] == Vector2i(3, 3): @@ -99,8 +99,8 @@ func test_candidates_exclude_sticky(): assert_false(found, "Sticky cells are excluded from candidates") func test_candidates_exclude_cleansed(): - gauntlet_manager.mark_cleansed(Vector2i(4, 4)) - var candidates = gauntlet_manager._generate_candidates() + candy_survival_manager.mark_cleansed(Vector2i(4, 4)) + var candidates = candy_survival_manager._generate_candidates() var found := false for c in candidates: 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)") func test_candidates_exclude_npc_and_boundary(): - var candidates = gauntlet_manager._generate_candidates() + var candidates = candy_survival_manager._generate_candidates() for c in candidates: var p = c["pos"] - assert_false(gauntlet_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_npc_zone(p), "No NPC-zone candidates") + assert_false(candy_survival_manager._is_boundary(p), "No boundary candidates") 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[0].has("score"), "Candidate carries a score") @@ -124,13 +124,13 @@ func test_candidates_have_scores(): # ============================================================================= func test_select_count_respected(): - var candidates = gauntlet_manager._generate_candidates() - var picked = gauntlet_manager._select_cells_weighted(candidates, 5) + var candidates = candy_survival_manager._generate_candidates() + var picked = candy_survival_manager._select_cells_weighted(candidates, 5) assert_eq(picked.size(), 5, "Selects exactly the requested count") func test_select_no_duplicates(): - var candidates = gauntlet_manager._generate_candidates() - var picked = gauntlet_manager._select_cells_weighted(candidates, 10) + var candidates = candy_survival_manager._generate_candidates() + var picked = candy_survival_manager._select_cells_weighted(candidates, 10) var seen := {} for p in picked: assert_false(seen.has(p), "No duplicate selections") @@ -138,7 +138,7 @@ func test_select_no_duplicates(): func test_select_capped_at_pool_size(): 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") # ============================================================================= @@ -146,14 +146,14 @@ func test_select_capped_at_pool_size(): # ============================================================================= func test_layer_classification(): - assert_eq(gauntlet_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(9, 9)), "inner", "Center is inner") + assert_eq(candy_survival_manager._layer_of(Vector2i(1, 1)), "outer", "Corner is outer") func test_sticky_neighbor_count(): - gauntlet_manager.sticky_cells[Vector2i(5, 5)] = true - gauntlet_manager.sticky_cells[Vector2i(5, 6)] = true - assert_eq(gauntlet_manager._sticky_neighbor_count(Vector2i(6, 5)), 2, + candy_survival_manager.sticky_cells[Vector2i(5, 5)] = true + candy_survival_manager.sticky_cells[Vector2i(5, 6)] = true + assert_eq(candy_survival_manager._sticky_neighbor_count(Vector2i(6, 5)), 2, "Counts 8-directional sticky neighbors") 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") diff --git a/tests/test_gauntlet_movement_buffer.gd b/tests/test_gauntlet_movement_buffer.gd index 14e29fe..a64f36e 100644 --- a/tests/test_gauntlet_movement_buffer.gd +++ b/tests/test_gauntlet_movement_buffer.gd @@ -1,13 +1,13 @@ 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. # Runs headless; elapsed_time = 0 so 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 main_mock: Node var gridmap_mock: Node @@ -18,7 +18,7 @@ func before_each(): gridmap_mock = Node.new() gridmap_mock.name = "EnhancedGridMap" main_mock.add_child(gridmap_mock) - manager = GauntletManager.new() + manager = CandySurvivalManager.new() main_mock.add_child(manager) manager.initialize(main_mock, gridmap_mock) manager.current_phase = 0 @@ -61,7 +61,7 @@ func test_buffer_penalty_far_is_zero(): func test_buffer_penalty_lifts_in_final_window(): 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") func test_buffer_penalty_empty_is_zero(): diff --git a/tests/test_gauntlet_registration.gd b/tests/test_gauntlet_registration.gd index 2f67791..396adf0 100644 --- a/tests/test_gauntlet_registration.gd +++ b/tests/test_gauntlet_registration.gd @@ -1,11 +1,11 @@ -# tests/test_gauntlet_registration.gd -# Tests for [Gauntlet] #1 Game Mode Registration -# Validates GAUNTLET enum, string conversion, lobby integration, and arena setup +# tests/test_candy_survival_registration.gd +# Tests for Candy Survival] #1 Game Mode Registration +# Validates CANDY_SURVIVAL enum, string conversion, lobby integration, and arena setup extends GutTest func before_all(): - gut.p("=== Gauntlet Registration Tests [Gauntlet #1] ===") + gut.p("=== Candy Survival Registration Tests Candy Survival #1] ===") func after_each(): pass @@ -14,35 +14,35 @@ func after_each(): # GameMode Enum Tests # ============================================================================= -# Test 1: GAUNTLET enum value exists and equals 3 -func test_gauntlet_enum_exists(): - assert_eq(GameMode.Mode.GAUNTLET, 3, "GAUNTLET should be enum value 3") +# Test 1: CANDY_SURVIVAL enum value exists and equals 3 +func test_candy_survival_enum_exists(): + assert_eq(GameMode.Mode.CANDY_SURVIVAL, 3, "CANDY_SURVIVAL should be enum value 3") # Test 2: All 3 modes are present in enum func test_all_modes_in_enum(): 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.GAUNTLET, 3, "GAUNTLET should be 3") + assert_eq(GameMode.Mode.CANDY_SURVIVAL, 3, "CANDY_SURVIVAL should be 3") # ============================================================================= # String Conversion Tests # ============================================================================= -# Test 3: from_string recognizes "Candy Pump Survival" -func test_from_string_candy_cannon(): - var result = GameMode.from_string("Candy Pump Survival") - assert_eq(result, GameMode.Mode.GAUNTLET, "from_string should parse 'Candy Pump Survival' as GAUNTLET") +# Test 3: from_string recognizes "Candy Survival" +func test_from_string_candy_survival(): + var result = GameMode.from_string("Candy Survival") + 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(): - var result = GameMode.mode_to_string(GameMode.Mode.GAUNTLET) - assert_eq(result, "Candy Pump Survival", "mode_to_string should return 'Candy Pump Survival'") + var result = GameMode.mode_to_string(GameMode.Mode.CANDY_SURVIVAL) + assert_eq(result, "Candy Survival", "mode_to_string should return 'Candy Survival'") # Test 5: Round-trip conversion is lossless 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) - 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 func test_existing_modes_round_trip(): @@ -60,10 +60,10 @@ func test_unknown_string_defaults_freemode(): # 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(): 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 func test_get_all_modes_count(): @@ -75,16 +75,16 @@ func test_get_all_modes_order(): var modes = GameMode.get_all_modes() 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[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 # ============================================================================= -# Test 11: GAUNTLET is a restricted mode -func test_gauntlet_is_restricted(): - var result = GameMode.is_restricted(GameMode.Mode.GAUNTLET) - assert_true(result, "GAUNTLET should be restricted (dedicated arena)") +# Test 11: CANDY_SURVIVAL is a restricted mode +func test_candy_survival_is_restricted(): + var result = GameMode.is_restricted(GameMode.Mode.CANDY_SURVIVAL) + assert_true(result, "CANDY_SURVIVAL should be restricted (dedicated arena)") # Test 12: FREEMODE is NOT restricted func test_freemode_not_restricted(): @@ -94,46 +94,46 @@ func test_freemode_not_restricted(): # Test 13: All restricted modes are confirmed 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.GAUNTLET), "GAUNTLET should be restricted") + assert_true(GameMode.is_restricted(GameMode.Mode.CANDY_SURVIVAL), "CANDY_SURVIVAL should be restricted") # ============================================================================= # 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(): 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 -func test_gauntlet_manager_script_exists(): - var script_exists = ResourceLoader.exists("res://scripts/managers/gauntlet_manager.gd") - assert_true(script_exists, "gauntlet_manager.gd should exist") +# Test 15: candy_survival_manager.gd script file exists +func test_candy_survival_manager_script_exists(): + var script_exists = ResourceLoader.exists("res://scripts/managers/candy_survival_manager.gd") + assert_true(script_exists, "candy_survival_manager.gd should exist") -# Test 16: GauntletManager class can be loaded -func test_gauntlet_manager_loads(): - var script = load("res://scripts/managers/gauntlet_manager.gd") - assert_not_null(script, "gauntlet_manager.gd should load without errors") +# Test 16: CandySurvivalManager class can be loaded +func test_candy_survival_manager_loads(): + var script = load("res://scripts/managers/candy_survival_manager.gd") + assert_not_null(script, "candy_survival_manager.gd should load without errors") -# Test 17: GauntletManager has required methods -func test_gauntlet_manager_has_methods(): - var manager = GauntletManager.new() - assert_true(manager.has_method("_setup_arena"), "GauntletManager should have _setup_arena()") - assert_true(manager.has_method("_apply_arena_setup"), "GauntletManager should have _apply_arena_setup()") - assert_true(manager.has_method("start_game_mode"), "GauntletManager should have start_game_mode()") - assert_true(manager.has_method("initialize"), "GauntletManager should have initialize()") +# Test 17: CandySurvivalManager has required methods +func test_candy_survival_manager_has_methods(): + var manager = CandySurvivalManager.new() + assert_true(manager.has_method("_setup_arena"), "CandySurvivalManager should have _setup_arena()") + assert_true(manager.has_method("_apply_arena_setup"), "CandySurvivalManager should have _apply_arena_setup()") + assert_true(manager.has_method("start_game_mode"), "CandySurvivalManager should have start_game_mode()") + assert_true(manager.has_method("initialize"), "CandySurvivalManager should have initialize()") manager.free() -# Test 18: GauntletManager arena constants are correct -func test_gauntlet_arena_constants(): - assert_eq(GauntletManager.ARENA_COLUMNS, 20, "Arena should be 20 columns") - assert_eq(GauntletManager.ARENA_ROWS, 20, "Arena should be 20 rows") - assert_eq(GauntletManager.NPC_SIZE, 3, "NPC zone should be 3x3") - assert_eq(GauntletManager.NPC_CENTER, Vector2i(9, 9), "NPC center should be at (9,9)") +# Test 18: CandySurvivalManager arena constants are correct +func test_candy_survival_arena_constants(): + assert_eq(CandySurvivalManager.ARENA_COLUMNS, 20, "Arena should be 20 columns") + assert_eq(CandySurvivalManager.ARENA_ROWS, 20, "Arena should be 20 rows") + assert_eq(CandySurvivalManager.NPC_SIZE, 3, "NPC zone should be 3x3") + assert_eq(CandySurvivalManager.NPC_CENTER, Vector2i(9, 9), "NPC center should be at (9,9)") # Test 19: NPC zone detection works func test_npc_zone_detection(): - var manager = GauntletManager.new() + var manager = CandySurvivalManager.new() # Center of NPC zone assert_true(manager._is_npc_zone(Vector2i(9, 9)), "Center (9,9) should be NPC zone") # Edges of NPC zone @@ -146,10 +146,10 @@ func test_npc_zone_detection(): manager.free() # Test 20: Phase enum has 3 phases -func test_gauntlet_phases(): - assert_eq(GauntletManager.Phase.OPEN_ARENA, 0, "OPEN_ARENA should be 0") - assert_eq(GauntletManager.Phase.ROUTE_PRESSURE, 1, "ROUTE_PRESSURE should be 1") - assert_eq(GauntletManager.Phase.SURVIVAL_ENDGAME, 2, "SURVIVAL_ENDGAME should be 2") +func test_candy_survival_phases(): + assert_eq(CandySurvivalManager.Phase.OPEN_ARENA, 0, "OPEN_ARENA should be 0") + assert_eq(CandySurvivalManager.Phase.ROUTE_PRESSURE, 1, "ROUTE_PRESSURE should be 1") + assert_eq(CandySurvivalManager.Phase.SURVIVAL_ENDGAME, 2, "SURVIVAL_ENDGAME should be 2") func after_all(): - gut.p("=== Gauntlet Registration Tests Complete ===") + gut.p("=== Candy Survival Registration Tests Complete ===") diff --git a/tests/test_gauntlet_scoring.gd b/tests/test_gauntlet_scoring.gd index 97f5de0..0938d2f 100644 --- a/tests/test_gauntlet_scoring.gd +++ b/tests/test_gauntlet_scoring.gd @@ -1,13 +1,13 @@ 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 # composition. Runs headless (no multiplayer peer), so elapsed_time = 0 and # 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 main_mock: Node var gridmap_mock: Node @@ -18,7 +18,7 @@ func before_each(): gridmap_mock = Node.new() gridmap_mock.name = "EnhancedGridMap" main_mock.add_child(gridmap_mock) - manager = GauntletManager.new() + manager = CandySurvivalManager.new() main_mock.add_child(manager) manager.initialize(main_mock, gridmap_mock) 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") 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)] assert_eq(manager._score_player_pressure(Vector2i(5, 5), players), 10.0, "Under player (final) = +10") diff --git a/tests/test_gauntlet_sticky_system.gd b/tests/test_gauntlet_sticky_system.gd index ebbf8f4..5d84cda 100644 --- a/tests/test_gauntlet_sticky_system.gd +++ b/tests/test_gauntlet_sticky_system.gd @@ -1,16 +1,16 @@ 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 # path-safety reachability (BFS) check. # ============================================================================= -var GauntletManagerScript = load("res://scripts/managers/gauntlet_manager.gd") -var manager: GauntletManager +var CandySurvivalManagerScript = load("res://scripts/managers/candy_survival_manager.gd") +var manager: CandySurvivalManager func before_each(): - manager = GauntletManagerScript.new() + manager = CandySurvivalManagerScript.new() add_child(manager) func after_each(): diff --git a/tests/test_gauntlet_tile_spawning.gd b/tests/test_gauntlet_tile_spawning.gd index 201ef0a..c057997 100644 --- a/tests/test_gauntlet_tile_spawning.gd +++ b/tests/test_gauntlet_tile_spawning.gd @@ -1,14 +1,14 @@ 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 manager: GauntletManager +var CandySurvivalManagerScript = load("res://scripts/managers/candy_survival_manager.gd") +var manager: CandySurvivalManager func before_each(): - manager = GauntletManagerScript.new() + manager = CandySurvivalManagerScript.new() add_child(manager) func after_each(): @@ -123,22 +123,22 @@ func test_clear_sticky_cell(): # ============================================================================= 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(): - 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(): - 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(): - 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 # ============================================================================= 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 assert_eq(total, 180.0, "Total match should be 180 seconds")