feat: update
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# Candy Cannon Survival (Gauntlet) — Technical Implementation Plan
|
||||
# Candy Pump Survival (Gauntlet) — Technical Implementation Plan
|
||||
|
||||
## 1. Feasibility Summary
|
||||
|
||||
@@ -8,21 +8,33 @@
|
||||
|
||||
| GDD Feature | Existing System | Reuse Level | New Work |
|
||||
|---|---|---|---|
|
||||
| Game Mode registration | `GameMode.gd` enum + `LobbyManager` | **Direct** | Add enum entry + strings |
|
||||
| 20×20 Arena setup | `StopNGoManager._setup_arena()` pattern | **Heavy** | Custom layout, same GridMap API |
|
||||
| Game Mode registration | `GameMode.gd` enum + `LobbyManager` | **Direct** | Already registered (`GAUNTLET = 3`) |
|
||||
| 24×24 Arena setup | `StopNGoManager._setup_arena()` pattern | **Heavy** | Custom layout, same GridMap API |
|
||||
| Tile collection / scoring | `GoalsCycleManager` | **Direct** | Reuse goal completion + scoring |
|
||||
| Mission system (goals) | `GoalManager` + `goals_cycle_manager.gd` | **Direct** | Same 3×3 pattern matching |
|
||||
| Timed match (3 min) | `GoalsCycleManager.start_match()` | **Direct** | Pass 180s duration |
|
||||
| Player movement | `PlayerMovementManager` | **Direct** | No changes |
|
||||
| Powerup system | `SpecialTilesManager` | **Partial** | Cleanser is a new powerup type |
|
||||
| Attack/Push mechanic | `PlayerMovementManager.try_push()` | **Adapt** | Smack = modified push with new rules |
|
||||
| NPC (Candy Cannon) | `tekton.gd` + `TektonController` | **Pattern** | New NPC, reuses projectile/animation patterns |
|
||||
| Player movement | `PlayerMovementManager` | **Direct** | Add sticky checks to `simple_move_to()` |
|
||||
| Sticky cells | `StopNGoManager` safe zone overlay (Layer 2) | **Pattern** | New tile type, same GridMap layer approach |
|
||||
| Telegraph VFX | `VFXManager` / `animation.gd` | **Pattern** | New animations, same system |
|
||||
| Telegraph VFX | Existing GauntletManager telegraph system | **Direct** | Adapt for growth ticks instead of cannon |
|
||||
| Smack mechanic | Existing GauntletManager smack system | **Direct** | Already implemented |
|
||||
| Cleanser power-up | Existing GauntletManager cleanser system | **Direct** | Already implemented |
|
||||
| HUD | `StopNGoManager._setup_hud()` pattern | **Direct** | Mode-specific labels |
|
||||
| Network sync | RPC patterns throughout codebase | **Direct** | Same `rpc()` / `sync_*` patterns |
|
||||
| Lobby settings | `LobbyManager` signal/sync pattern | **Direct** | Add gauntlet-specific settings |
|
||||
| Bot AI | `BotController` + `BotStrategicPlanner` | **Adapt** | New strategy for cannon avoidance |
|
||||
| Bot AI | `BotController` + `BotStrategicPlanner` | **Adapt** | New strategy for sticky avoidance |
|
||||
| Candy bubbles | **NEW** | **New** | Bubble spawn, grow, explode system |
|
||||
| Candidate scoring | **NEW** | **New** | Cellular-automation growth algorithm |
|
||||
| Movement buffers | **NEW** | **New** | Hidden safe zone detection/decay |
|
||||
|
||||
### What Changes from Current Implementation
|
||||
|
||||
The current `GauntletManager` uses a **cannon shooting** model (NPC fires projectiles at targets). The new GDD replaces this with a **ground growth** model (candy spreads from the ground via cellular-automation scoring). This requires:
|
||||
|
||||
1. **Remove** `_fire_volley()`, cannon timer, volley size, projectile spawning
|
||||
2. **Add** growth tick timer, candidate scoring, weighted cell selection
|
||||
3. **Add** candy bubble system (spawn, grow, explode)
|
||||
4. **Add** movement buffer detection and decay
|
||||
5. **Add** layer-based priority logic
|
||||
6. **Change** arena from 20×20 to 24×24
|
||||
|
||||
---
|
||||
|
||||
@@ -30,19 +42,21 @@
|
||||
|
||||
```
|
||||
main.gd
|
||||
├── _init_managers() ← Add GauntletManager instantiation (same as StopNGoManager pattern)
|
||||
├── _setup_host_game() ← Add gauntlet arena setup branch
|
||||
├── _start_game() ← Add gauntlet start_game_mode() call
|
||||
├── _init_managers() ← GauntletManager instantiation (existing)
|
||||
├── _setup_host_game() ← GauntletManager._setup_arena()
|
||||
├── _start_game() ← GauntletManager.start_game_mode()
|
||||
│
|
||||
GauntletManager (NEW)
|
||||
├── _setup_arena() ← 20×20 grid, center 3×3 NPC zone
|
||||
GauntletManager (MODIFY EXISTING)
|
||||
├── _setup_arena() ← 24×24 grid, center 3×3 NPC zone
|
||||
├── _setup_hud() ← Mission label, cleanser indicator
|
||||
├── start_game_mode() ← Start cannon timer, spawn tiles
|
||||
├── _process() ← Cannon volley timer, phase escalation
|
||||
├── CandyCannonController ← Targeting logic, volley fire
|
||||
├── start_game_mode() ← Start growth timer, spawn tiles
|
||||
├── _process() ← Growth tick timer, bubble timer, phase escalation
|
||||
├── GrowthTick system ← Candidate scoring, weighted selection, telegraph
|
||||
├── CandyBubble system ← Bubble spawn, grow, explode
|
||||
├── StickyCell system ← Layer 2 overlay, trap logic
|
||||
├── Cleanser system ← New powerup unlocked via missions
|
||||
├── Smack system ← Modified push with charge/cooldown
|
||||
├── MovementBuffer system ← Hidden safe zone detection, decay, camping override
|
||||
├── Cleanser system ← Existing powerup
|
||||
├── Smack system ← Existing modified push
|
||||
└── Win condition ← Highest score at timer end
|
||||
```
|
||||
|
||||
@@ -50,342 +64,730 @@ GauntletManager (NEW)
|
||||
|
||||
## 3. File-by-File Implementation
|
||||
|
||||
### 3.1 Game Mode Registration
|
||||
### 3.1 Game Mode Registration — Already Done
|
||||
|
||||
#### `scripts/game_mode.gd`
|
||||
The existing `game_mode.gd` already has:
|
||||
```gdscript
|
||||
enum Mode {
|
||||
FREEMODE = 0,
|
||||
STOP_N_GO = 1,
|
||||
TEKTON_DOORS = 2,
|
||||
GAUNTLET = 3 # NEW
|
||||
GAUNTLET = 3 # Already registered
|
||||
}
|
||||
|
||||
# Add to from_string(), mode_to_string(), get_all_modes(), is_restricted()
|
||||
```
|
||||
|
||||
#### `scripts/managers/lobby_manager.gd`
|
||||
- Add `"Candy Cannon Survival"` to `available_game_modes`
|
||||
- Add `_update_available_areas()` entry → `"Gauntlet Arena"`
|
||||
- Add gauntlet-specific lobby settings (mirroring Stop N Go pattern):
|
||||
- `gauntlet_round_duration: int = 180`
|
||||
- `gauntlet_cannon_interval: int = 5`
|
||||
- `gauntlet_volley_size: int = 5`
|
||||
- Corresponding `set_gauntlet_*()`, `sync_gauntlet_*()` RPCs
|
||||
- Corresponding signals
|
||||
And `LobbyManager` already has `"Candy Cannon Survival"` in `available_game_modes`. The mode name string can remain as-is or be updated to `"Candy Pump Survival"` if desired.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Core Manager — `gauntlet_manager.gd` (NEW)
|
||||
### 3.2 Core Manager — `gauntlet_manager.gd` (MODIFY EXISTING)
|
||||
|
||||
**Location:** `scripts/managers/gauntlet_manager.gd`
|
||||
|
||||
**Pattern source:** `StopNGoManager` + `PortalModeManager`
|
||||
**Major structural changes:**
|
||||
|
||||
#### Remove (cannon-based system):
|
||||
|
||||
```
|
||||
var cannon_timer: float
|
||||
var cannon_interval: float
|
||||
var volley_size: int
|
||||
var last_targeted_player_id: int
|
||||
func _fire_volley()
|
||||
func _select_targets()
|
||||
func _get_near_player_target()
|
||||
func _get_route_blocking_target()
|
||||
func _get_random_non_sticky_target()
|
||||
func _get_random_target()
|
||||
```
|
||||
|
||||
#### Add (growth-based system):
|
||||
|
||||
```gdscript
|
||||
class_name GauntletManager
|
||||
extends Node
|
||||
|
||||
# Signals
|
||||
signal phase_changed(phase_index: int)
|
||||
signal cannon_fired(targets: Array)
|
||||
signal growth_tick(targets: Array)
|
||||
signal player_trapped(player_id: int)
|
||||
signal cleanser_granted(player_id: int)
|
||||
signal bubble_spawned(center: Vector2i)
|
||||
signal bubble_exploded(center: Vector2i, area: Array[Vector2i])
|
||||
|
||||
# Constants
|
||||
const ARENA_SIZE = 20
|
||||
const ARENA_SIZE = 24
|
||||
const NPC_SIZE = 3
|
||||
const NPC_CENTER = Vector2i(9, 9) # Center of 20×20
|
||||
const TILE_STICKY = 17 # New MeshLibrary item ID
|
||||
const NPC_CENTER = Vector2i(11, 11) # Center of 24×24
|
||||
const TILE_STICKY = 17
|
||||
const TILE_TELEGRAPH = 18
|
||||
const TILE_WALKABLE = 0
|
||||
const TILE_OBSTACLE = 4
|
||||
|
||||
# Phase timing
|
||||
enum Phase { OPEN_ARENA, ROUTE_PRESSURE, SURVIVAL_ENDGAME }
|
||||
var current_phase: Phase = Phase.OPEN_ARENA
|
||||
enum Phase { OUTER_PRESSURE, MIDDLE_PRESSURE, INNER_SURVIVAL }
|
||||
var current_phase: Phase = Phase.OUTER_PRESSURE
|
||||
var elapsed_time: float = 0.0
|
||||
|
||||
# Cannon state
|
||||
var cannon_timer: float = 0.0
|
||||
var cannon_interval: float = 5.0
|
||||
var volley_size: int = 5
|
||||
var sticky_cells: Dictionary = {} # Vector2i → true
|
||||
var last_targeted_player_id: int = -1
|
||||
# Growth tick state
|
||||
var growth_timer: float = 0.0
|
||||
var growth_interval: float = 3.0
|
||||
var telegraph_duration: float = 1.0
|
||||
var sticky_cells: Dictionary = {} # Vector2i -> true
|
||||
var telegraphed_cells: Dictionary = {} # Vector2i -> true
|
||||
|
||||
# Smack state (per-player)
|
||||
var smack_cooldowns: Dictionary = {} # player_id → float (time remaining)
|
||||
var smack_charged: Dictionary = {} # player_id → float (charge window remaining)
|
||||
|
||||
# Cleanser tracking
|
||||
var player_mission_completions: Dictionary = {} # player_id → int
|
||||
var player_cleansers: Dictionary = {} # player_id → int (0 or 1)
|
||||
|
||||
# Trapped players
|
||||
var trapped_players: Dictionary = {} # player_id → true
|
||||
```
|
||||
|
||||
#### Key methods (mapped to existing patterns):
|
||||
|
||||
| Method | Pattern Source | Purpose |
|
||||
|---|---|---|
|
||||
| `_setup_arena()` | `StopNGoManager._setup_arena()` | 20×20 grid, center 3×3 NPC block, walkable floor |
|
||||
| `_setup_hud()` | `StopNGoManager._setup_hud()` | Mission label, cleanser indicator |
|
||||
| `start_game_mode()` | `StopNGoManager.start_game_mode()` | Initialize cannon, spawn tiles, activate HUD |
|
||||
| `_process(delta)` | `StopNGoManager._process()` | Tick cannon timer, fire volleys, update phase |
|
||||
| `_fire_volley()` | NEW (uses `tekton.gd` projectile pattern) | Select targets, telegraph, apply sticky |
|
||||
| `_apply_sticky(pos)` | `StopNGoManager._spawn_dynamic_safe_zone()` (Layer 2 overlay) | Set GridMap Layer 2 to TILE_STICKY |
|
||||
| `_check_player_trapped(player)` | `StopNGoManager._is_in_safe_zone()` (inverted) | Check if player is on sticky cell |
|
||||
| `check_win_condition()` | `StopNGoManager.check_win_condition()` | Highest score at match end |
|
||||
| `sync_phase()` RPC | `StopNGoManager.sync_phase()` | Broadcast phase to clients |
|
||||
| `sync_sticky_cells()` RPC | `main.rpc("sync_grid_item")` | Sync sticky cell state |
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Candy Cannon NPC — `candy_cannon_controller.gd` (NEW)
|
||||
|
||||
**Location:** `scripts/controllers/candy_cannon_controller.gd`
|
||||
|
||||
**Pattern source:** `TektonController` + `tekton.gd` projectile system
|
||||
|
||||
```
|
||||
class_name CandyCannonController
|
||||
extends Node
|
||||
|
||||
var gauntlet_manager: GauntletManager
|
||||
var npc_center: Vector2i
|
||||
var gridmap: Node
|
||||
|
||||
# Targeting weights per phase
|
||||
var phase_weights: Array = [
|
||||
# Phase 0 (Open Arena): 1×1=60%, 1×2=40%, 2×2=0%
|
||||
{"1x1": 0.6, "1x2": 0.4, "2x2": 0.0},
|
||||
# Phase 1 (Route Pressure): 1×1=30%, 1×2=55%, 2×2=15%
|
||||
{"1x1": 0.3, "1x2": 0.55, "2x2": 0.15},
|
||||
# Phase 2 (Survival): 1×1=15%, 1×2=55%, 2×2=30%
|
||||
{"1x1": 0.15, "1x2": 0.55, "2x2": 0.30}
|
||||
# Phase-based growth config
|
||||
var phase_growth_config: Array = [
|
||||
{"cells_per_tick": [4, 6], "distribution": {"outer": 0.75, "middle": 0.10, "inner": 0.00, "near_player": 0.10, "random": 0.05}},
|
||||
{"cells_per_tick": [6, 8], "distribution": {"outer": 0.20, "middle": 0.50, "inner": 0.00, "near_player": 0.15, "sticky_expansion": 0.10, "random": 0.05}},
|
||||
{"cells_per_tick": [8, 10], "distribution": {"outer": 0.10, "middle": 0.25, "inner": 0.35, "near_player": 0.15, "sticky_expansion": 0.15, "random": 0.10}},
|
||||
]
|
||||
|
||||
# Candy bubble state
|
||||
var bubble_timer: float = 0.0
|
||||
var bubbles_this_phase: int = 0
|
||||
var max_bubbles_per_phase: Array = [0, 2, 3]
|
||||
var active_bubbles: Array = [] # [{center, grow_timer, warning_area}]
|
||||
var recent_bubble_positions: Array = [] # For RepetitionPenalty
|
||||
|
||||
# Movement buffer state
|
||||
var movement_buffers: Dictionary = {} # Vector2i -> {penalty: float, created_at: float}
|
||||
var camping_tracker: Dictionary = {} # player_id -> {position: Vector2i, since: float}
|
||||
|
||||
# Smack state (per-player) — unchanged
|
||||
var smack_cooldowns: Dictionary = {}
|
||||
var smack_charged: Dictionary = {}
|
||||
|
||||
# Cleanser tracking — unchanged
|
||||
var player_mission_completions: Dictionary = {}
|
||||
var player_cleansers: Dictionary = {}
|
||||
|
||||
# Trapped players — unchanged
|
||||
var trapped_players: Dictionary = {}
|
||||
|
||||
# Arena layer cache
|
||||
var arena_layers: Dictionary = {} # Vector2i -> "outer"/"middle"/"inner"
|
||||
```
|
||||
|
||||
**Targeting logic** reuses the `_is_position_valid()` and `get_neighbors()` from `EnhancedGridMap`, and `get_nodes_in_group("Players")` for player-proximity targeting.
|
||||
|
||||
**Projectile visuals** reuse `tekton.gd`'s `spawn_projectile_rpc()` pattern (arc tween from cannon → target cell).
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Sticky Cell System
|
||||
### 3.3 Arena Setup — `_setup_arena()`
|
||||
|
||||
**Approach:** Use GridMap Layer 2 (same as `StopNGoManager` safe zone overlay and `SpecialTilesManager` freeze overlay).
|
||||
**Pattern source:** `StopNGoManager._setup_arena()`
|
||||
|
||||
**New MeshLibrary item:** `TILE_STICKY = 17` — Pink/candy-colored semi-transparent panel (same approach as TILE_SAFE = 2).
|
||||
|
||||
| Feature | Implementation |
|
||||
|---|---|
|
||||
| Visual | Layer 2 overlay with transparent candy-pink mesh |
|
||||
| Movement block | `PlayerMovementManager.simple_move_to()` — add sticky check alongside wall check |
|
||||
| Trap on step | `GauntletManager._check_player_on_sticky()` in `_process()` |
|
||||
| Trap on push | `PlayerMovementManager.try_push()` — check landing cell for sticky |
|
||||
| Cleanser pass-through | Similar to `is_invisible` wall bypass — temporary flag |
|
||||
|
||||
**Network sync:** Use existing `main.rpc("sync_grid_item", x, 2, z, TILE_STICKY)` — identical to how safe zones and freeze overlays sync.
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Telegraph System
|
||||
|
||||
**Pattern source:** `StopNGoManager`'s `sync_all_safe_zones_vfx()` + `_animate_safe_zone_appear()`
|
||||
|
||||
1. Server selects target cells
|
||||
2. `rpc("sync_telegraph", targets)` — all clients show pink glow
|
||||
3. 1-second delay (Timer)
|
||||
4. `rpc("sync_impact", targets)` — apply sticky, VFX, screen shake
|
||||
|
||||
**Visual approach:**
|
||||
- Reuse Layer 2 overlay with a temporary "warning" tile ID (e.g., `TILE_TELEGRAPH = 18`)
|
||||
- Animate alpha 0 → 1 over 0.8s (same `_animate_safe_zone_appear()` tween pattern)
|
||||
- On impact: replace with `TILE_STICKY`, play `screen_shake_manager` via `player.rpc("trigger_screen_shake", "medium")`
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Smack Mechanic
|
||||
|
||||
**Pattern source:** `PowerUpManager.use_special_effect()` + `PlayerMovementManager.try_push()`
|
||||
|
||||
The smack mechanic is a reskin of the existing Attack Mode push, with modifications:
|
||||
|
||||
| Property | Current Attack Mode | Gauntlet Smack |
|
||||
|---|---|---|
|
||||
| Charge source | `PowerUpManager.current_boost >= 100` | 8s cooldown timer (auto-refill) |
|
||||
| Activation | Toggle `is_attack_mode` | 3s charged window (pink model) |
|
||||
| Push distance | 3 cells backward (X=-1) | 3 cells in push direction |
|
||||
| Stagger | 1.5s `apply_stagger()` | 1.0s stun |
|
||||
| Sticky landing | N/A | Trapped on first sticky cell in path |
|
||||
| Clash | N/A | Both stunned, no push, bars consumed |
|
||||
|
||||
**Implementation in GauntletManager:**
|
||||
- New per-player smack state (cooldown, charged flag)
|
||||
- Override or extend `PlayerMovementManager.try_push()` behavior when in gauntlet mode
|
||||
- Sticky landing check: iterate push path, stop at first sticky cell → call `trap_player()`
|
||||
- Clash detection: if two players activate smack within 0.5s of each other and are in range
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Cleanser Power-Up
|
||||
|
||||
**Pattern source:** `SpecialTilesManager.inventory` system
|
||||
|
||||
| Property | Implementation |
|
||||
|---|---|
|
||||
| Unlock trigger | `GoalsCycleManager.goal_count_updated` signal — grant when `count % 2 == 0` |
|
||||
| Storage | `GauntletManager.player_cleansers[peer_id] = 1` |
|
||||
| Activation | New input action or existing powerup key |
|
||||
| Effect | For 5 cells of movement, ignore sticky checks + clear sticky overlay on traversed cells |
|
||||
| Sync | `rpc("sync_cleanser_state", peer_id, count)` |
|
||||
| Clear sticky | `main.rpc("sync_grid_item", x, 2, z, -1)` — same as safe zone clear |
|
||||
|
||||
---
|
||||
|
||||
### 3.8 Candy Cannon NPC Scene — `candy_cannon.tscn` (NEW)
|
||||
|
||||
**Pattern source:** `tekton.tscn` + `static_tekton_stand.tscn`
|
||||
|
||||
- 3×3 footprint centered at `(9, 9)` in 20×20 grid
|
||||
- Static body (non-movable, non-interactable)
|
||||
- Animated mesh (cannon rotation, firing animation)
|
||||
- No grab/throw/knock interactions (like `is_static_turret = true`)
|
||||
|
||||
---
|
||||
|
||||
### 3.9 Arena Scene — `gauntlet.tscn` (NEW) or `gauntlet.scn`
|
||||
|
||||
**Location:** `scenes/arena/gauntlet.tscn`
|
||||
|
||||
**Pattern source:** `scenes/arena/freemode.tscn`, `scenes/arena/stop_n_go.scn`
|
||||
|
||||
- 3D environment for the gauntlet arena
|
||||
- Referenced in `main.gd._apply_arena_background()` under `"Gauntlet Arena"` match case
|
||||
|
||||
---
|
||||
|
||||
### 3.10 Integration Points in `main.gd`
|
||||
|
||||
Following the exact pattern of StopNGoManager / PortalModeManager:
|
||||
Key changes from 20×20 to 24×24:
|
||||
|
||||
```gdscript
|
||||
# _init_managers() — Add after portal_mode_manager block:
|
||||
if LobbyManager.game_mode == "Candy Cannon Survival":
|
||||
gauntlet_manager = load("res://scripts/managers/gauntlet_manager.gd").new()
|
||||
gauntlet_manager.name = "GauntletManager"
|
||||
add_child(gauntlet_manager)
|
||||
gauntlet_manager.initialize(self, $EnhancedGridMap)
|
||||
|
||||
# _setup_host_game() — Add arena setup branch:
|
||||
elif LobbyManager.game_mode == "Candy Cannon Survival" and gauntlet_manager:
|
||||
gauntlet_manager._setup_arena()
|
||||
|
||||
# _start_game() — Add game mode start:
|
||||
elif LobbyManager.game_mode == "Candy Cannon Survival":
|
||||
if gauntlet_manager:
|
||||
gauntlet_manager.start_game_mode()
|
||||
if goals_cycle_manager:
|
||||
var match_duration = LobbyManager.get_match_duration()
|
||||
goals_cycle_manager.start_match(float(match_duration))
|
||||
func _setup_arena():
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
# Resize gridmap to 24×24
|
||||
enhanced_gridmap.columns = ARENA_SIZE
|
||||
enhanced_gridmap.rows = ARENA_SIZE
|
||||
enhanced_gridmap.floors = 3
|
||||
# Clear all layers
|
||||
enhanced_gridmap.clear_floor(0)
|
||||
enhanced_gridmap.clear_floor(1)
|
||||
enhanced_gridmap.clear_floor(2)
|
||||
# Fill Floor 0 with walkable tiles
|
||||
for x in range(ARENA_SIZE):
|
||||
for z in range(ARENA_SIZE):
|
||||
enhanced_gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE)
|
||||
# Block center 3×3 for Candy Pump NPC
|
||||
for x in range(NPC_CENTER.x - 1, NPC_CENTER.x + 2):
|
||||
for z in range(NPC_CENTER.y - 1, NPC_CENTER.y + 2):
|
||||
enhanced_gridmap.set_cell_item(Vector3i(x, 0, z), TILE_OBSTACLE)
|
||||
# Build arena layer map
|
||||
_build_arena_layers()
|
||||
# Sync to clients
|
||||
rpc("sync_arena_setup", ARENA_SIZE, NPC_CENTER)
|
||||
enhanced_gridmap.initialize_astar()
|
||||
enhanced_gridmap.update_astar_costs()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Layer Calculation — `_build_arena_layers()`
|
||||
|
||||
**New method.** Precomputes the layer for every cell based on edge distance.
|
||||
|
||||
```gdscript
|
||||
func _build_arena_layers():
|
||||
arena_layers.clear()
|
||||
for x in range(ARENA_SIZE):
|
||||
for z in range(ARENA_SIZE):
|
||||
var edge_dist = mini(x, z, ARENA_SIZE - 1 - x, ARENA_SIZE - 1 - z)
|
||||
var layer: String
|
||||
if edge_dist <= 3:
|
||||
layer = "outer"
|
||||
elif edge_dist <= 7:
|
||||
layer = "middle"
|
||||
else:
|
||||
layer = "inner"
|
||||
arena_layers[Vector2i(x, z)] = layer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Growth Tick System — `_process_growth_tick()`
|
||||
|
||||
**Replaces** `_fire_volley()`. Called every 3 seconds.
|
||||
|
||||
```gdscript
|
||||
func _process_growth_tick():
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
var config = phase_growth_config[current_phase]
|
||||
var cell_count = randi_range(config.cells_per_tick[0], config.cells_per_tick[1])
|
||||
var candidates = _generate_candidates()
|
||||
var selected = _select_cells_weighted(candidates, cell_count)
|
||||
# Path safety check
|
||||
selected = _apply_path_safety(selected)
|
||||
# Movement buffer check
|
||||
selected = _apply_movement_buffer_check(selected)
|
||||
# Telegraph
|
||||
_telegraph_cells(selected)
|
||||
# After telegraph_duration: apply sticky
|
||||
get_tree().create_timer(telegraph_duration).timeout.connect(func():
|
||||
_apply_sticky_cells(selected)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Candidate Generation — `_generate_candidates()`
|
||||
|
||||
**New method.** Builds scored list of all SAFE cells.
|
||||
|
||||
```gdscript
|
||||
func _generate_candidates() -> Array:
|
||||
var candidates: Array = []
|
||||
var players = get_tree().get_nodes_in_group("Players")
|
||||
for x in range(ARENA_SIZE):
|
||||
for z in range(ARENA_SIZE):
|
||||
var pos = Vector2i(x, z)
|
||||
if not _is_cell_valid_for_growth(pos):
|
||||
continue
|
||||
var score = _calculate_candidate_score(pos, players)
|
||||
candidates.append({"pos": pos, "score": score})
|
||||
return candidates
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Candidate Scoring — `_calculate_candidate_score()`
|
||||
|
||||
**New method.** Implements the full Candidate Score formula from the GDD.
|
||||
|
||||
```gdscript
|
||||
func _calculate_candidate_score(pos: Vector2i, players: Array) -> float:
|
||||
var score: float = 0.0
|
||||
|
||||
# LayerPriority
|
||||
var layer = arena_layers.get(pos, "outer")
|
||||
var layer_scores = {
|
||||
Phase.OUTER_PRESSURE: {"outer": 60.0, "middle": 15.0, "inner": -40.0},
|
||||
Phase.MIDDLE_PRESSURE: {"outer": 20.0, "middle": 60.0, "inner": 5.0},
|
||||
Phase.INNER_SURVIVAL: {"outer": 10.0, "middle": 35.0, "inner": 60.0},
|
||||
}
|
||||
score += layer_scores[current_phase].get(layer, 0.0)
|
||||
|
||||
# StickyNeighborScore (+8 per sticky neighbor, max +64)
|
||||
var neighbors = _get_8_neighbors(pos)
|
||||
for n in neighbors:
|
||||
if sticky_cells.has(n):
|
||||
score += 8.0
|
||||
|
||||
# InwardPressureScore
|
||||
var center_dist = pos.distance_to(Vector2(NPC_CENTER))
|
||||
var max_dist = Vector2(ARENA_SIZE, ARENA_SIZE).length() / 2.0
|
||||
var inward_ratio = 1.0 - (center_dist / max_dist)
|
||||
match current_phase:
|
||||
Phase.OUTER_PRESSURE: score += lerpf(0.0, 10.0, inward_ratio)
|
||||
Phase.MIDDLE_PRESSURE: score += lerpf(5.0, 20.0, inward_ratio)
|
||||
Phase.INNER_SURVIVAL: score += lerpf(10.0, 30.0, inward_ratio)
|
||||
|
||||
# PlayerPressureScore
|
||||
var min_player_dist = INF
|
||||
for p in players:
|
||||
var p_pos = Vector2i(p.grid_position.x, p.grid_position.z) if p.has_method("get_grid_position") else Vector2i(p.position.x, p.position.z)
|
||||
var dist = pos.distance_to(p_pos)
|
||||
min_player_dist = mini(min_player_dist, int(dist))
|
||||
if min_player_dist >= 2 and min_player_dist <= 4:
|
||||
score += 20.0
|
||||
elif min_player_dist == 0:
|
||||
if elapsed_time < 150.0: # Before final 30s
|
||||
score -= 50.0
|
||||
else:
|
||||
score += 10.0
|
||||
|
||||
# ClusterGrowthScore
|
||||
if _connects_sticky_clusters(pos):
|
||||
score += 25.0
|
||||
elif _expands_sticky_cluster(pos):
|
||||
score += 15.0
|
||||
|
||||
# RoutePressureScore
|
||||
if _is_high_traffic_route(pos):
|
||||
score += randf_range(10.0, 25.0)
|
||||
|
||||
# CampingPressureScore
|
||||
for pid in camping_tracker:
|
||||
var camp = camping_tracker[pid]
|
||||
if pos.distance_to(camp.position) <= 4:
|
||||
var camp_duration = elapsed_time - camp.since
|
||||
if camp_duration > 10.0 and player_cleansers.get(pid, 0) > 0:
|
||||
score += 60.0
|
||||
elif camp_duration > 8.0:
|
||||
score += 40.0
|
||||
elif camp_duration > 5.0:
|
||||
score += 20.0
|
||||
|
||||
# RandomNoise
|
||||
score += randf_range(-20.0, 20.0)
|
||||
|
||||
# MovementBufferPenalty
|
||||
if movement_buffers.has(pos):
|
||||
var buffer = movement_buffers[pos]
|
||||
var penalty = _get_buffer_penalty(buffer.penalty)
|
||||
score += penalty
|
||||
|
||||
# PathSafetyPenalty
|
||||
if _would_trap_player(pos) and elapsed_time < 150.0:
|
||||
score -= 100.0
|
||||
elif _removes_last_exit(pos):
|
||||
score -= 60.0
|
||||
elif _makes_route_too_narrow(pos):
|
||||
score -= 20.0
|
||||
|
||||
# RepetitionPenalty
|
||||
if _was_recently_targeted(pos):
|
||||
score -= 30.0
|
||||
elif _region_targeted_repeatedly(pos):
|
||||
score -= 15.0
|
||||
|
||||
return score
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.8 Weighted Cell Selection — `_select_cells_weighted()`
|
||||
|
||||
**New method.** Selects cells using weighted randomness from scored candidates.
|
||||
|
||||
```gdscript
|
||||
func _select_cells_weighted(candidates: Array, count: int) -> Array[Vector2i]:
|
||||
# Sort by score descending
|
||||
candidates.sort_custom(func(a, b): return a.score > b.score)
|
||||
# Build weight array
|
||||
var weights: Array[float] = []
|
||||
var total_weight: float = 0.0
|
||||
for c in candidates:
|
||||
var w = maxf(c.score + 100.0, 1.0) # Offset to ensure positive weights
|
||||
weights.append(w)
|
||||
total_weight += w
|
||||
# Weighted random selection without replacement
|
||||
var selected: Array[Vector2i] = []
|
||||
var available = candidates.duplicate()
|
||||
var available_weights = weights.duplicate()
|
||||
for i in range mini(count, available.size()):
|
||||
var roll = randf() * total_weight
|
||||
var cumulative = 0.0
|
||||
for j in range(available.size()):
|
||||
cumulative += available_weights[j]
|
||||
if roll <= cumulative:
|
||||
selected.append(available[j].pos)
|
||||
total_weight -= available_weights[j]
|
||||
available.remove_at(j)
|
||||
available_weights.remove_at(j)
|
||||
break
|
||||
return selected
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.9 Candy Bubble System
|
||||
|
||||
#### Bubble Spawn Timer
|
||||
|
||||
```gdscript
|
||||
func _process_bubbles(delta: float):
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
# Tick active bubbles
|
||||
for i in range(active_bubbles.size() - 1, -1, -1):
|
||||
var bubble = active_bubbles[i]
|
||||
bubble.grow_timer -= delta
|
||||
if bubble.grow_timer <= 0:
|
||||
_explode_bubble(bubble)
|
||||
active_bubbles.remove_at(i)
|
||||
```
|
||||
|
||||
#### Bubble Spawn Logic
|
||||
|
||||
```gdscript
|
||||
func _try_spawn_bubble():
|
||||
var max_bubbles = max_bubbles_per_phase[current_phase]
|
||||
if bubbles_this_phase >= max_bubbles:
|
||||
return
|
||||
var candidates = _generate_bubble_candidates()
|
||||
if candidates.is_empty():
|
||||
return
|
||||
# Weighted selection
|
||||
var selected = _select_bubble_target(candidates)
|
||||
_spawn_bubble(selected)
|
||||
bubbles_this_phase += 1
|
||||
```
|
||||
|
||||
#### Bubble Candidate Scoring
|
||||
|
||||
```gdscript
|
||||
func _generate_bubble_candidates() -> Array:
|
||||
var candidates: Array = []
|
||||
var players = get_tree().get_nodes_in_group("Players")
|
||||
for x in range(ARENA_SIZE):
|
||||
for z in range(ARENA_SIZE):
|
||||
var pos = Vector2i(x, z)
|
||||
if not _is_cell_valid_for_bubble(pos):
|
||||
continue
|
||||
var score = _calculate_bubble_score(pos, players)
|
||||
candidates.append({"pos": pos, "score": score})
|
||||
return candidates
|
||||
|
||||
func _calculate_bubble_score(pos: Vector2i, players: Array) -> float:
|
||||
var score: float = 0.0
|
||||
|
||||
# CampingScore
|
||||
for pid in camping_tracker:
|
||||
var camp = camping_tracker[pid]
|
||||
if pos.distance_to(camp.position) <= 4:
|
||||
var camp_duration = elapsed_time - camp.since
|
||||
if camp_duration > 10.0 and player_cleansers.get(pid, 0) > 0:
|
||||
score += 80.0
|
||||
elif camp_duration > 8.0:
|
||||
score += 60.0
|
||||
elif camp_duration > 5.0:
|
||||
score += 40.0
|
||||
|
||||
# UntouchedAreaScore
|
||||
if _is_near_untouched_cluster(pos):
|
||||
score += 30.0
|
||||
|
||||
# PlayerClusterScore
|
||||
var nearby_players = 0
|
||||
for p in players:
|
||||
var p_pos = Vector2i(p.position.x, p.position.z)
|
||||
if pos.distance_to(p_pos) <= 5:
|
||||
nearby_players += 1
|
||||
if nearby_players >= 2:
|
||||
score += 20.0
|
||||
|
||||
# MissionRouteScore
|
||||
if _is_important_for_scoring(pos):
|
||||
score += randf_range(10.0, 20.0)
|
||||
|
||||
# RandomNoise
|
||||
score += randf_range(-20.0, 20.0)
|
||||
|
||||
# DirectHitPenalty
|
||||
for p in players:
|
||||
var p_pos = Vector2i(p.position.x, p.position.z)
|
||||
if pos == p_pos:
|
||||
score -= 60.0
|
||||
break
|
||||
|
||||
# RecentBubblePenalty
|
||||
for recent in recent_bubble_positions:
|
||||
if pos.distance_to(recent) <= 5:
|
||||
score -= 50.0
|
||||
break
|
||||
|
||||
# UnfairTrapPenalty
|
||||
if _would_create_unfair_trap(pos):
|
||||
score -= 100.0
|
||||
|
||||
return score
|
||||
```
|
||||
|
||||
#### Bubble Explosion
|
||||
|
||||
```gdscript
|
||||
func _explode_bubble(bubble: Dictionary):
|
||||
var center = bubble.center
|
||||
var explosion_area: Array[Vector2i] = []
|
||||
for dx in range(-1, 2):
|
||||
for dz in range(-1, 2):
|
||||
var pos = Vector2i(center.x + dx, center.y + dz)
|
||||
if _is_cell_valid_for_growth(pos):
|
||||
explosion_area.append(pos)
|
||||
# Telegraph 3×3 area briefly, then apply sticky
|
||||
_telegraph_cells(explosion_area)
|
||||
get_tree().create_timer(0.5).timeout.connect(func():
|
||||
_apply_sticky_cells(explosion_area)
|
||||
rpc("sync_bubble_explode", center, explosion_area)
|
||||
recent_bubble_positions.append(center)
|
||||
if recent_bubble_positions.size() > 5:
|
||||
recent_bubble_positions.remove_at(0)
|
||||
)
|
||||
rpc("sync_bubble_explode_vfx", center)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.10 Movement Buffer System
|
||||
|
||||
#### Buffer Detection
|
||||
|
||||
```gdscript
|
||||
func _detect_movement_buffers():
|
||||
# Find all connected clusters of SAFE cells
|
||||
var visited: Dictionary = {}
|
||||
var clusters: Array = []
|
||||
for x in range(ARENA_SIZE):
|
||||
for z in range(ARENA_SIZE):
|
||||
var pos = Vector2i(x, z)
|
||||
if visited.has(pos) or not _is_cell_safe(pos):
|
||||
continue
|
||||
var cluster = _flood_fill_safe_cluster(pos, visited)
|
||||
clusters.append(cluster)
|
||||
# Apply buffer penalties to clusters that are critical for movement
|
||||
for cluster in clusters:
|
||||
if _is_critical_for_movement(cluster):
|
||||
for pos in cluster:
|
||||
if not movement_buffers.has(pos):
|
||||
movement_buffers[pos] = {"penalty": 1.0, "created_at": elapsed_time}
|
||||
```
|
||||
|
||||
#### Buffer Decay
|
||||
|
||||
```gdscript
|
||||
func _decay_movement_buffers():
|
||||
var to_remove: Array = []
|
||||
for pos in movement_buffers:
|
||||
var buffer = movement_buffers[pos]
|
||||
# Every 5 seconds: reduce penalty by 25%
|
||||
var age = elapsed_time - buffer.created_at
|
||||
var decay_cycles = int(age / 5.0)
|
||||
buffer.penalty *= pow(0.75, decay_cycles)
|
||||
# Phase change: reduce by 50%
|
||||
# (Applied once at phase transition, tracked separately)
|
||||
# Final 30s: remove most
|
||||
if elapsed_time > 150.0:
|
||||
buffer.penalty *= 0.1
|
||||
if buffer.penalty < 0.05:
|
||||
to_remove.append(pos)
|
||||
for pos in to_remove:
|
||||
movement_buffers.erase(pos)
|
||||
```
|
||||
|
||||
#### Camping Detection
|
||||
|
||||
```gdscript
|
||||
func _update_camping_tracker():
|
||||
var players = get_tree().get_nodes_in_group("Players")
|
||||
for p in players:
|
||||
var pid = p.get_multiplayer_authority()
|
||||
var p_pos = Vector2i(p.position.x, p.position.z)
|
||||
if camping_tracker.has(pid):
|
||||
var camp = camping_tracker[pid]
|
||||
if p_pos == camp.position:
|
||||
pass # Still camping
|
||||
else:
|
||||
camping_tracker[pid] = {"position": p_pos, "since": elapsed_time}
|
||||
else:
|
||||
camping_tracker[pid] = {"position": p_pos, "since": elapsed_time}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.11 Sticky Cell Application
|
||||
|
||||
```gdscript
|
||||
func _apply_sticky_cells(positions: Array[Vector2i]):
|
||||
for pos in positions:
|
||||
if not _is_cell_valid_for_growth(pos):
|
||||
continue
|
||||
sticky_cells[pos] = true
|
||||
telegraphed_cells.erase(pos)
|
||||
# Set Layer 2 overlay
|
||||
enhanced_gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_STICKY)
|
||||
# Check if any player is now on sticky
|
||||
_check_players_on_sticky()
|
||||
# Update A* costs
|
||||
enhanced_gridmap.update_astar_costs()
|
||||
# Sync to clients
|
||||
rpc("sync_sticky_cells", sticky_cells.keys())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.12 Player Sticky Check
|
||||
|
||||
```gdscript
|
||||
func _check_players_on_sticky():
|
||||
var players = get_tree().get_nodes_in_group("Players")
|
||||
for p in players:
|
||||
var p_pos = Vector2i(p.position.x, p.position.z)
|
||||
if sticky_cells.has(p_pos):
|
||||
var pid = p.get_multiplayer_authority()
|
||||
if is_cleanser_active(pid):
|
||||
clear_sticky_cell(p_pos)
|
||||
use_cleanser_cell(pid)
|
||||
else:
|
||||
_trap_player(p)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.13 Path Safety Check
|
||||
|
||||
```gdscript
|
||||
func _apply_path_safety(selected: Array[Vector2i]) -> Array[Vector2i]:
|
||||
if elapsed_time > 150.0: # Final 30s: softer rules
|
||||
return selected
|
||||
var players = get_tree().get_nodes_in_group("Players")
|
||||
var result = selected.duplicate()
|
||||
for p in players:
|
||||
var pid = p.get_multiplayer_authority()
|
||||
if trapped_players.has(pid):
|
||||
continue
|
||||
var p_pos = Vector2i(p.position.x, p.position.z)
|
||||
# Temporarily apply selected cells
|
||||
var temp_sticky = sticky_cells.duplicate()
|
||||
for pos in result:
|
||||
temp_sticky[pos] = true
|
||||
# Check if player has reachable safe cells within 6–8 cells
|
||||
var has_escape = _has_reachable_safe_cell(p_pos, temp_sticky, 8)
|
||||
if not has_escape:
|
||||
# Replace some cells with safer alternatives
|
||||
result = _replace_with_safer_candidates(result, 2)
|
||||
return result
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.14 Telegraph System (Modified)
|
||||
|
||||
The existing telegraph system works but needs adaptation for growth ticks instead of cannon volleys.
|
||||
|
||||
```gdscript
|
||||
func _telegraph_cells(positions: Array[Vector2i]):
|
||||
for pos in positions:
|
||||
telegraphed_cells[pos] = true
|
||||
enhanced_gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH)
|
||||
rpc("sync_growth_telegraph", positions)
|
||||
# Animate telegraph
|
||||
_animate_growth_telegraph(positions)
|
||||
```
|
||||
|
||||
**Reuse existing** `_animate_telegraph()` tween pattern from current GauntletManager.
|
||||
|
||||
---
|
||||
|
||||
### 3.15 Network Sync
|
||||
|
||||
| Data | Sync Method | Pattern |
|
||||
|---|---|---|
|
||||
| Sticky cells | `rpc("sync_sticky_cells", positions)` | Same as `sync_grid_item` |
|
||||
| Growth telegraph | `rpc("sync_growth_telegraph", positions)` | Same as `sync_telegraph` |
|
||||
| Phase changes | `rpc("sync_gauntlet_phase", phase_idx, elapsed)` | Same as `sync_phase` |
|
||||
| Bubble spawn | `rpc("sync_bubble_spawn", center, grow_duration)` | New RPC |
|
||||
| Bubble explode | `rpc("sync_bubble_explode", center, area)` | New RPC |
|
||||
| Trap state | `player.rpc("sync_trapped", true)` | Same as `sync_stop_freeze` |
|
||||
| Cleanser grant | `rpc("sync_cleanser", peer_id, count)` | Same as `sync_goal_count` |
|
||||
| Smack state | `player.rpc("sync_smack_state", charged)` | Same as `sync_modulate` |
|
||||
|
||||
---
|
||||
|
||||
### 3.16 Integration Points in `main.gd`
|
||||
|
||||
The existing integration in `main.gd` already handles GauntletManager. No changes needed unless the mode name string is updated.
|
||||
|
||||
---
|
||||
|
||||
## 4. New Files Summary
|
||||
|
||||
| File | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `scripts/managers/gauntlet_manager.gd` | Script | Core mode logic, phases, sticky cells, cleanser, smack |
|
||||
| `scripts/controllers/candy_cannon_controller.gd` | Script | Cannon targeting, volley fire, telegraph |
|
||||
| `scenes/arena/gauntlet.tscn` | Scene | 3D arena environment |
|
||||
| `scenes/candy_cannon.tscn` | Scene | Candy Cannon NPC (3×3, static) |
|
||||
| (none) | — | All changes are modifications to existing `gauntlet_manager.gd` |
|
||||
|
||||
## 5. Modified Files Summary
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `scripts/game_mode.gd` | Add `GAUNTLET = 3` enum, string mappings |
|
||||
| `scripts/managers/lobby_manager.gd` | Add mode to available list, gauntlet settings, area mapping |
|
||||
| `scenes/main.gd` | Add gauntlet_manager init, arena setup branch, start branch |
|
||||
| `scripts/managers/player_movement_manager.gd` | Add sticky cell check in `simple_move_to()`, sticky landing in push |
|
||||
| `scripts/managers/goals_cycle_manager.gd` | Cleanser grant on every 2nd goal completion (gauntlet mode only) |
|
||||
| `scripts/managers/special_tiles_manager.gd` | Restrict certain powerups in gauntlet mode (like Stop N Go restrictions) |
|
||||
| MeshLibrary `.tres` | Add TILE_STICKY (17) and TILE_TELEGRAPH (18) mesh items |
|
||||
| `scripts/managers/gauntlet_manager.gd` | **Major rewrite:** Replace cannon system with growth tick system, add candidate scoring, add candy bubble system, add movement buffer system, add layer calculation, change arena to 24×24 |
|
||||
| `scripts/game_mode.gd` | Optionally rename string to `"Candy Pump Survival"` |
|
||||
| `scripts/managers/lobby_manager.gd` | Optionally rename mode string; update settings (remove cannon_interval, volley_size; add growth_interval, cells_per_tick) |
|
||||
| `scripts/mode_config.gd` | Update schema: remove `gauntlet_cannon_interval`, `gauntlet_volley_size`; add `gauntlet_growth_interval`, `gauntlet_cells_per_tick_phase1/2/3` |
|
||||
| `scenes/main.gd` | Update mode string match if renamed |
|
||||
|
||||
## 6. Anti-Unfairness Implementation
|
||||
---
|
||||
|
||||
## 6. Helper Methods Required
|
||||
|
||||
These utility methods need to be added to `gauntlet_manager.gd`:
|
||||
|
||||
```gdscript
|
||||
# In CandyCannonController._select_targets():
|
||||
func _select_targets(count: int) -> Array[Vector2i]:
|
||||
var targets: Array[Vector2i] = []
|
||||
var players = get_tree().get_nodes_in_group("Players")
|
||||
|
||||
for i in range(count):
|
||||
var roll = randf()
|
||||
var target: Vector2i
|
||||
|
||||
if roll < 0.60:
|
||||
# Near a player (not same as last targeted)
|
||||
target = _get_near_player_target(players)
|
||||
elif roll < 0.85:
|
||||
# Route-blocking (pathfinding bottleneck)
|
||||
target = _get_route_blocking_target()
|
||||
elif roll < 0.95:
|
||||
# Random non-sticky
|
||||
target = _get_random_non_sticky_target()
|
||||
else:
|
||||
# Chaos (anywhere)
|
||||
target = _get_random_target()
|
||||
|
||||
targets.append(target)
|
||||
|
||||
return targets
|
||||
# Cell validation
|
||||
func _is_cell_valid_for_growth(pos: Vector2i) -> bool
|
||||
func _is_cell_valid_for_bubble(pos: Vector2i) -> bool
|
||||
func _is_cell_safe(pos: Vector2i) -> bool
|
||||
|
||||
# Anti-unfairness rules:
|
||||
# 1. last_targeted_player_id tracking prevents same-player targeting
|
||||
# 2. 2×2 shots never placed directly ON a player (offset by 1)
|
||||
# 3. Path validation: ensure at least one path from each active player
|
||||
# to a non-sticky region (using EnhancedGridMap.initialize_astar())
|
||||
# 4. Exception: final 30s allows aggressive blocking
|
||||
# Neighbor queries
|
||||
func _get_8_neighbors(pos: Vector2i) -> Array[Vector2i]
|
||||
func _flood_fill_safe_cluster(start: Vector2i, visited: Dictionary) -> Array[Vector2i]
|
||||
|
||||
# Cluster analysis
|
||||
func _expands_sticky_cluster(pos: Vector2i) -> bool
|
||||
func _connects_sticky_clusters(pos: Vector2i) -> bool
|
||||
func _is_near_untouched_cluster(pos: Vector2i) -> bool
|
||||
func _is_critical_for_movement(cluster: Array) -> bool
|
||||
|
||||
# Route analysis
|
||||
func _is_high_traffic_route(pos: Vector2i) -> bool
|
||||
func _is_important_for_scoring(pos: Vector2i) -> bool
|
||||
func _would_trap_player(pos: Vector2i) -> bool
|
||||
func _removes_last_exit(pos: Vector2i) -> bool
|
||||
func _makes_route_too_narrow(pos: Vector2i) -> bool
|
||||
func _would_create_unfair_trap(pos: Vector2i) -> bool
|
||||
func _has_reachable_safe_cell(from: Vector2i, temp_sticky: Dictionary, radius: int) -> bool
|
||||
|
||||
# Repetition tracking
|
||||
func _was_recently_targeted(pos: Vector2i) -> bool
|
||||
func _region_targeted_repeatedly(pos: Vector2i) -> bool
|
||||
|
||||
# Bubble helpers
|
||||
func _select_bubble_target(candidates: Array) -> Vector2i
|
||||
func _replace_with_safer_candidates(selected: Array[Vector2i], count: int) -> Array[Vector2i]
|
||||
```
|
||||
|
||||
## 7. Network Considerations
|
||||
---
|
||||
|
||||
All sync follows existing patterns:
|
||||
## 7. Implementation Priority (Recommended Order)
|
||||
|
||||
| Data | Sync Method | Existing Pattern |
|
||||
|---|---|---|
|
||||
| Sticky cells | `main.rpc("sync_grid_item", x, 2, z, 17)` | Safe zone / freeze overlay |
|
||||
| Telegraph | `rpc("sync_telegraph", targets_array)` | `StopNGoManager.sync_phase()` |
|
||||
| Phase changes | `rpc("sync_gauntlet_phase", phase_idx, elapsed)` | `StopNGoManager.sync_phase()` |
|
||||
| Trap state | `player.rpc("sync_trapped", true)` | `player.rpc("sync_stop_freeze", true)` |
|
||||
| Cleanser grant | `rpc("sync_cleanser", peer_id, count)` | `goals_cycle_manager.sync_goal_count()` |
|
||||
| Smack state | `player.rpc("sync_smack_state", charged)` | `player.rpc("sync_modulate", color)` |
|
||||
| Cannon NPC | Static scene, no movement sync needed | `static_tekton_stand.tscn` |
|
||||
1. **Update arena to 24×24** — Modify `_setup_arena()`, update `NPC_CENTER`, update `_build_arena_layers()`
|
||||
2. **Replace cannon with growth tick** — Remove `_fire_volley()`, add `_process_growth_tick()`, `_generate_candidates()`, `_calculate_candidate_score()`
|
||||
3. **Weighted cell selection** — `_select_cells_weighted()`, sticky application, A* cost update
|
||||
4. **Movement buffer system** — `_detect_movement_buffers()`, `_decay_movement_buffers()`, buffer penalty in scoring
|
||||
5. **Path safety check** — `_apply_path_safety()`, `_has_reachable_safe_cell()`, replace unsafe selections
|
||||
6. **Candy bubble system** — Bubble timer, `_try_spawn_bubble()`, bubble scoring, `_explode_bubble()`
|
||||
7. **Camping detection** — `_update_camping_tracker()`, camping score in candidate and bubble scoring
|
||||
8. **Update HUD** — Growth tick indicator, bubble warning, phase label
|
||||
9. **Network sync** — New RPCs for growth telegraph, bubble spawn/explode
|
||||
10. **Bot AI** — Sticky avoidance, pathfinding through sticky, cleanser usage
|
||||
11. **Polish** — VFX for growth ticks, bubble animations, screen shake on explosion, sound effects
|
||||
12. **Update lobby settings** — Replace cannon/volley settings with growth settings in `lobby_manager.gd` and `mode_config.gd`
|
||||
|
||||
## 8. Implementation Priority (Recommended Order)
|
||||
---
|
||||
|
||||
1. **Game Mode Registration** — `game_mode.gd`, `lobby_manager.gd`, `main.gd` branches
|
||||
2. **Arena Setup** — `gauntlet_manager._setup_arena()`, 20×20 grid, NPC zone block
|
||||
3. **Tile Spawning** — Reuse `StopNGoManager._spawn_mission_tiles()` pattern
|
||||
4. **Cannon Timer + Volley** — Basic 5s interval, 5 shots, 1×1 only (no sizes yet)
|
||||
5. **Sticky Cell System** — Layer 2 overlay, movement blocking, trap detection
|
||||
6. **Telegraph VFX** — Warning glow → impact
|
||||
7. **Impact Sizes** — 1×2 and 2×2 shapes, phase-based weights
|
||||
8. **Smack Mechanic** — Modified push with cooldown/charge
|
||||
9. **Cleanser** — Unlock tracking, activated movement through sticky
|
||||
10. **Targeting Intelligence** — Player proximity, route blocking, anti-unfairness
|
||||
11. **Bot AI** — Cannon avoidance, sticky path planning
|
||||
12. **Polish** — VFX, SFX, HUD animations, 3D arena scene
|
||||
|
||||
## 9. Risk Assessment
|
||||
## 8. Risk Assessment
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| GridMap Layer 2 conflict with existing freeze/safe overlays | Gauntlet mode is exclusive — no freeze/safe tiles in this mode |
|
||||
| 20×20 grid performance (400 cells + overlays) | Existing 23×12 (Stop N Go) and 14×14 (Tekton Doors) work fine; 20×20 is comparable |
|
||||
| Cannon targeting causing impossible arenas | Anti-unfairness pathfinding check via `EnhancedGridMap.initialize_astar()` |
|
||||
| New MeshLibrary items (17, 18) colliding with existing IDs | Verify current max ID in `.tres` before adding |
|
||||
| Smack clash detection timing | Use server-authoritative timestamp comparison (< 0.5s window) |
|
||||
| 24×24 grid performance (576 cells + scoring every 3s) | Scoring runs on server only; candidate list is max 567 cells; weighted selection is O(n log n) |
|
||||
| Movement buffer creating invisible safe zones that feel unfair | Buffers decay aggressively; camping override removes them; final 30s removes most; players experience it as "uneven growth" not "protected zones" |
|
||||
| Path safety check preventing any arena pressure | Only triggers when a player would be fully trapped; final 30s disables strict check |
|
||||
| Bubble stacking creating unavoidable traps | RecentBubblePenalty (-50) prevents nearby bubbles; max 5 per round; UnfairTrapPenalty (-100) prevents instant failures |
|
||||
| Candidate scoring feeling too complex to tune | Start with simple weights; each component is independent and tunable; playtest to adjust |
|
||||
| A* pathfinding cost updates every 3s causing lag | `update_astar_costs()` is lightweight (updates existing AStar2D); only runs on server |
|
||||
|
||||
Reference in New Issue
Block a user