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