feat: add Candy Cannon Survival game mode with collectible tiles
Version bump to 2.3.6. New game mode features 20×20 arena with central cannon obstacle, three escalating phases (Open Arena, Route Pressure, Survival), and collectible tiles (Hearts, Diamonds, Stars, Coins) with pattern-matching missions. Players dodge candy volleys while completing collection goals. Updated export paths and version strings across all platforms (Windows, Android, Web, Linux).
This commit is contained in:
@@ -0,0 +1,164 @@
|
|||||||
|
---
|
||||||
|
description: This document tells AI agents how to work on Tekton Dash tasks end-to-end.
|
||||||
|
---
|
||||||
|
|
||||||
|
# SKILLS.md — AI Agent Workflow Guide for Tekton Dash
|
||||||
|
|
||||||
|
This document tells AI agents how to work on Tekton Dash tasks end-to-end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Task Source: Notion MCP
|
||||||
|
|
||||||
|
All tasks live on the **"TektonDash - Armageddon PR Tasks"** Notion board.
|
||||||
|
|
||||||
|
https://www.notion.so/danchiego-game/36433be43b29800c8422ed5bdd65671b?v=36633be43b29803891cd000c6f6e5c5f
|
||||||
|
|
||||||
|
### Finding Tasks
|
||||||
|
|
||||||
|
Should always start with this to find tasks, find the highest priority task that is not done, second is In Progress, then To Do.
|
||||||
|
|
||||||
|
example, query for "Gauntlet"
|
||||||
|
```
|
||||||
|
Use: mcp_notion-mcp-server_API-post-search
|
||||||
|
query: "[Gauntlet]" or task name
|
||||||
|
filter: {"property": "object", "value": "page"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reading a Task
|
||||||
|
|
||||||
|
Each task page has these properties:
|
||||||
|
|
||||||
|
| Property | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| **Name** | title | Task title, e.g. `[Gauntlet] #1 Game Mode Registration` |
|
||||||
|
| **Status** | select | `To Do` → `In Progress` → `Done` |
|
||||||
|
| **Priority** | select | `P0` (critical) / `P1` / `P2` / `P3` |
|
||||||
|
| **Effort** | select | `S - Small` / `M - Medium` / `L - Large` / `XL - Epic` |
|
||||||
|
| **Sprint** | select | `Alpha` / `Beta` / `Release` |
|
||||||
|
| **ProjectType** | select | `CORE` / `CLIENT` / `SERVER` / `INFRA` |
|
||||||
|
| **Description** | rich_text | Full task description — **read this to understand what to do** |
|
||||||
|
| **Acceptance** | checkbox | Check when task is verified complete |
|
||||||
|
| **DueDate** | date | Optional deadline |
|
||||||
|
| **UnitTest** | date | Optional test completion date |
|
||||||
|
|
||||||
|
### Task Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
To Do → In Progress → Done
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Pick up task**: Set `Status` → `In Progress`
|
||||||
|
2. **Do the work**: Read `Description`, implement the changes
|
||||||
|
3. **Write unit tests**: Follow pattern in `tests/` directory
|
||||||
|
4. **Mark complete**: Set `Status` → `Done`, check `Acceptance` ✅
|
||||||
|
5. **Update changelog**: Add entry to `CHANGELOG_DRAFT.md` (consumer language)
|
||||||
|
6. **Bump version**: Update `project.godot` + `export_presets.cfg`
|
||||||
|
|
||||||
|
```
|
||||||
|
Use: mcp_notion-mcp-server_API-patch-page
|
||||||
|
page_id: "<task_page_id>"
|
||||||
|
properties: {"Status": {"select": {"name": "Done"}}, "Acceptance": {"checkbox": true}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Code Structure
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `scripts/game_mode.gd` | GameMode enum + helpers (add new modes here) |
|
||||||
|
| `scripts/managers/` | All game mode managers (lobby, stop_n_go, portal, gauntlet) |
|
||||||
|
| `scenes/main.gd` | Central orchestrator — init, setup, game start routing |
|
||||||
|
| `tests/` | GUT unit tests — one file per task/feature |
|
||||||
|
|
||||||
|
### Adding a New Game Mode
|
||||||
|
|
||||||
|
1. Add enum to `scripts/game_mode.gd` → update `from_string()`, `mode_to_string()`, `get_all_modes()`, `is_restricted()`
|
||||||
|
2. Add mode name to `LobbyManager.available_game_modes` in `lobby_manager.gd`
|
||||||
|
3. Add arena name to `_update_available_areas()` in `lobby_manager.gd`
|
||||||
|
4. Add manager var + init branch in `main.gd` `_init_managers()`
|
||||||
|
5. Add setup branch in `_setup_host_game()` and `_setup_client_game()`
|
||||||
|
6. Add start branch in `_start_game()`
|
||||||
|
7. Add background in `_apply_arena_background()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Unit Testing
|
||||||
|
|
||||||
|
### Pattern
|
||||||
|
|
||||||
|
All tests extend `GutTest` and live in `tests/`. Naming: `test_<feature>.gd`
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
extends GutTest
|
||||||
|
|
||||||
|
func before_all():
|
||||||
|
gut.p("=== Feature Tests [Task ID] ===")
|
||||||
|
|
||||||
|
func test_something():
|
||||||
|
assert_eq(actual, expected, "Description")
|
||||||
|
|
||||||
|
func after_all():
|
||||||
|
gut.p("=== Feature Tests Complete ===")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
run_tests.cmd # all tests
|
||||||
|
run_tests.cmd test_gauntlet_registration # specific test
|
||||||
|
```
|
||||||
|
|
||||||
|
Reports saved to `test_reports/` with timestamps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Version Bumping
|
||||||
|
|
||||||
|
**Before bumping, check git for existing uncommitted version changes:**
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
git diff --cached -- project.godot CHANGELOG_DRAFT.md
|
||||||
|
git diff -- project.godot CHANGELOG_DRAFT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### If version changes already exist (staged or unstaged):
|
||||||
|
→ **APPEND** your changelog bullet to the existing version block in `CHANGELOG_DRAFT.md`
|
||||||
|
→ **DO NOT** bump `project.godot` or `export_presets.cfg` — you're joining an in-progress batch
|
||||||
|
|
||||||
|
### If NO version changes exist (clean state):
|
||||||
|
→ **BUMP** version (increment patch: `2.3.5` → `2.3.6`)
|
||||||
|
→ **UPDATE** all locations below
|
||||||
|
|
||||||
|
Version appears in **4 locations** — all must match:
|
||||||
|
|
||||||
|
| File | Field |
|
||||||
|
|---|---|
|
||||||
|
| `CHANGELOG_DRAFT.md` | `## [X.Y.Z] — YYYY-MM-DD` header |
|
||||||
|
| `project.godot` | `config/version="X.Y.Z"` |
|
||||||
|
| `export_presets.cfg` | `application/file_version` and `application/product_version` (per preset) |
|
||||||
|
| `export_presets.cfg` | `export_path` filenames containing version |
|
||||||
|
| `export_presets.cfg` | `version/name` (Android preset) |
|
||||||
|
|
||||||
|
### Changelog Style
|
||||||
|
|
||||||
|
Entries are **consumer-facing** (readable by players). No internal jargon.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## [2.3.6] — 2026-05-22
|
||||||
|
- Added new game mode: Candy Cannon Survival
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bad:** "Added GAUNTLET = 3 to GameMode.Mode enum"
|
||||||
|
**Good:** "Added new game mode: Candy Cannon Survival"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Key Conventions
|
||||||
|
|
||||||
|
- **Caveman Mode**: Be terse. No filler. Execute first, talk second.
|
||||||
|
- **Read before edit**: Always check whole files before modifying `.gd`, `.tscn`, `.tres`, `.res` files.
|
||||||
|
- **Notion status flow**: `To Do` → `In Progress` → `Done` (never skip steps).
|
||||||
|
- **Test everything**: Every completed task gets a `test_<feature>.gd` in `tests/`.
|
||||||
|
- **GUT framework**: Tests use the Godot Unit Test (GUT) addon at `addons/gut/`.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# SKILLS.md — AI Agent Workflow Guide for Tekton Dash
|
||||||
|
|
||||||
|
This document tells AI agents how to work on Tekton Dash tasks end-to-end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Task Source: Notion MCP
|
||||||
|
|
||||||
|
All tasks live on the **"TektonDash - Armageddon PR Tasks"** Notion board.
|
||||||
|
|
||||||
|
https://www.notion.so/danchiego-game/36433be43b29800c8422ed5bdd65671b?v=36633be43b29803891cd000c6f6e5c5f
|
||||||
|
|
||||||
|
### Finding Tasks
|
||||||
|
|
||||||
|
Should always start with this to find tasks, find the highest priority task that is not done, second is In Progress, then To Do.
|
||||||
|
|
||||||
|
example, query for "Gauntlet"
|
||||||
|
```
|
||||||
|
Use: mcp_notion-mcp-server_API-post-search
|
||||||
|
query: "[Gauntlet]" or task name
|
||||||
|
filter: {"property": "object", "value": "page"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reading a Task
|
||||||
|
|
||||||
|
Each task page has these properties:
|
||||||
|
|
||||||
|
| Property | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| **Name** | title | Task title, e.g. `[Gauntlet] #1 Game Mode Registration` |
|
||||||
|
| **Status** | select | `To Do` → `In Progress` → `Done` |
|
||||||
|
| **Priority** | select | `P0` (critical) / `P1` / `P2` / `P3` |
|
||||||
|
| **Effort** | select | `S - Small` / `M - Medium` / `L - Large` / `XL - Epic` |
|
||||||
|
| **Sprint** | select | `Alpha` / `Beta` / `Release` |
|
||||||
|
| **ProjectType** | select | `CORE` / `CLIENT` / `SERVER` / `INFRA` |
|
||||||
|
| **Description** | rich_text | Full task description — **read this to understand what to do** |
|
||||||
|
| **Acceptance** | checkbox | Check when task is verified complete |
|
||||||
|
| **DueDate** | date | Optional deadline |
|
||||||
|
| **UnitTest** | date | Optional test completion date |
|
||||||
|
|
||||||
|
### Task Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
To Do → In Progress → Done
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Pick up task**: Set `Status` → `In Progress`
|
||||||
|
2. **Do the work**: Read `Description`, implement the changes
|
||||||
|
3. **Write unit tests**: Follow pattern in `tests/` directory
|
||||||
|
4. **Mark complete**: Set `Status` → `Done`, check `Acceptance` ✅
|
||||||
|
5. **Update changelog**: Add entry to `CHANGELOG_DRAFT.md` (consumer language)
|
||||||
|
6. **Bump version**: Update `project.godot` + `export_presets.cfg`
|
||||||
|
|
||||||
|
```
|
||||||
|
Use: mcp_notion-mcp-server_API-patch-page
|
||||||
|
page_id: "<task_page_id>"
|
||||||
|
properties: {"Status": {"select": {"name": "Done"}}, "Acceptance": {"checkbox": true}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Code Structure
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `scripts/game_mode.gd` | GameMode enum + helpers (add new modes here) |
|
||||||
|
| `scripts/managers/` | All game mode managers (lobby, stop_n_go, portal, gauntlet) |
|
||||||
|
| `scenes/main.gd` | Central orchestrator — init, setup, game start routing |
|
||||||
|
| `tests/` | GUT unit tests — one file per task/feature |
|
||||||
|
|
||||||
|
### Adding a New Game Mode
|
||||||
|
|
||||||
|
1. Add enum to `scripts/game_mode.gd` → update `from_string()`, `mode_to_string()`, `get_all_modes()`, `is_restricted()`
|
||||||
|
2. Add mode name to `LobbyManager.available_game_modes` in `lobby_manager.gd`
|
||||||
|
3. Add arena name to `_update_available_areas()` in `lobby_manager.gd`
|
||||||
|
4. Add manager var + init branch in `main.gd` `_init_managers()`
|
||||||
|
5. Add setup branch in `_setup_host_game()` and `_setup_client_game()`
|
||||||
|
6. Add start branch in `_start_game()`
|
||||||
|
7. Add background in `_apply_arena_background()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Unit Testing
|
||||||
|
|
||||||
|
### Pattern
|
||||||
|
|
||||||
|
All tests extend `GutTest` and live in `tests/`. Naming: `test_<feature>.gd`
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
extends GutTest
|
||||||
|
|
||||||
|
func before_all():
|
||||||
|
gut.p("=== Feature Tests [Task ID] ===")
|
||||||
|
|
||||||
|
func test_something():
|
||||||
|
assert_eq(actual, expected, "Description")
|
||||||
|
|
||||||
|
func after_all():
|
||||||
|
gut.p("=== Feature Tests Complete ===")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
run_tests.cmd # all tests
|
||||||
|
run_tests.cmd test_gauntlet_registration # specific test
|
||||||
|
```
|
||||||
|
|
||||||
|
Reports saved to `test_reports/` with timestamps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Version Bumping
|
||||||
|
|
||||||
|
**Before bumping, check git for existing uncommitted version changes:**
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
git diff --cached -- project.godot CHANGELOG_DRAFT.md
|
||||||
|
git diff -- project.godot CHANGELOG_DRAFT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### If version changes already exist (staged or unstaged):
|
||||||
|
→ **APPEND** your changelog bullet to the existing version block in `CHANGELOG_DRAFT.md`
|
||||||
|
→ **DO NOT** bump `project.godot` or `export_presets.cfg` — you're joining an in-progress batch
|
||||||
|
|
||||||
|
### If NO version changes exist (clean state):
|
||||||
|
→ **BUMP** version (increment patch: `2.3.5` → `2.3.6`)
|
||||||
|
→ **UPDATE** all locations below
|
||||||
|
|
||||||
|
Version appears in **4 locations** — all must match:
|
||||||
|
|
||||||
|
| File | Field |
|
||||||
|
|---|---|
|
||||||
|
| `CHANGELOG_DRAFT.md` | `## [X.Y.Z] — YYYY-MM-DD` header |
|
||||||
|
| `project.godot` | `config/version="X.Y.Z"` |
|
||||||
|
| `export_presets.cfg` | `application/file_version` and `application/product_version` (per preset) |
|
||||||
|
| `export_presets.cfg` | `export_path` filenames containing version |
|
||||||
|
| `export_presets.cfg` | `version/name` (Android preset) |
|
||||||
|
|
||||||
|
### Changelog Style
|
||||||
|
|
||||||
|
Entries are **consumer-facing** (readable by players). No internal jargon.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## [2.3.6] — 2026-05-22
|
||||||
|
- Added new game mode: Candy Cannon Survival
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bad:** "Added GAUNTLET = 3 to GameMode.Mode enum"
|
||||||
|
**Good:** "Added new game mode: Candy Cannon Survival"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Key Conventions
|
||||||
|
|
||||||
|
- **Caveman Mode**: Be terse. No filler. Execute first, talk second.
|
||||||
|
- **Read before edit**: Always check whole files before modifying `.gd`, `.tscn`, `.tres`, `.res` files.
|
||||||
|
- **Notion status flow**: `To Do` → `In Progress` → `Done` (never skip steps).
|
||||||
|
- **Test everything**: Every completed task gets a `test_<feature>.gd` in `tests/`.
|
||||||
|
- **GUT framework**: Tests use the Godot Unit Test (GUT) addon at `addons/gut/`.
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
## [2.3.6] — 2026-05-22
|
||||||
|
- Added new game mode: **Candy Cannon Survival** — dodge candy volleys from a giant cannon in the center of the arena!
|
||||||
|
- New 20×20 arena with a central Candy Cannon obstacle and three escalating phases: Open Arena, Route Pressure, and Survival.
|
||||||
|
- Candy Cannon Survival is now selectable from the lobby game mode list with its own dedicated arena.
|
||||||
|
- The arena now spawns collectible tiles (Hearts, Diamonds, Stars, Coins) with pattern-matching missions — complete goals while dodging candy!
|
||||||
|
|
||||||
## [2.3.5] — 2026-05-22
|
## [2.3.5] — 2026-05-22
|
||||||
- Refactored `lobby.gd` into modular helper classes (`LobbyChat`, `LobbyMainMenu`, `LobbyRoomList`, `LobbyRoom`) to reduce file size and improve maintainability.
|
- Refactored `lobby.gd` into modular helper classes (`LobbyChat`, `LobbyMainMenu`, `LobbyRoomList`, `LobbyRoom`) to reduce file size and improve maintainability.
|
||||||
- Externalized Nakama connection config in `nakama_manager.gd` — server key, host, port, and scheme now read from environment variables with ProjectSettings fallback.
|
- Externalized Nakama connection config in `nakama_manager.gd` — server key, host, port, and scheme now read from environment variables with ProjectSettings fallback.
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# SKILLS.md — AI Agent Workflow Guide for Tekton Dash
|
||||||
|
|
||||||
|
This document tells AI agents how to work on Tekton Dash tasks end-to-end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Task Source: Notion MCP
|
||||||
|
|
||||||
|
All tasks live on the **"TektonDash - Armageddon PR Tasks"** Notion board.
|
||||||
|
|
||||||
|
https://www.notion.so/danchiego-game/36433be43b29800c8422ed5bdd65671b?v=36633be43b29803891cd000c6f6e5c5f
|
||||||
|
|
||||||
|
### Finding Tasks
|
||||||
|
|
||||||
|
Should always start with this to find tasks, find the highest priority task that is not done, second is In Progress, then To Do.
|
||||||
|
|
||||||
|
example, query for "Gauntlet"
|
||||||
|
```
|
||||||
|
Use: mcp_notion-mcp-server_API-post-search
|
||||||
|
query: "[Gauntlet]" or task name
|
||||||
|
filter: {"property": "object", "value": "page"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reading a Task
|
||||||
|
|
||||||
|
Each task page has these properties:
|
||||||
|
|
||||||
|
| Property | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| **Name** | title | Task title, e.g. `[Gauntlet] #1 Game Mode Registration` |
|
||||||
|
| **Status** | select | `To Do` → `In Progress` → `Done` |
|
||||||
|
| **Priority** | select | `P0` (critical) / `P1` / `P2` / `P3` |
|
||||||
|
| **Effort** | select | `S - Small` / `M - Medium` / `L - Large` / `XL - Epic` |
|
||||||
|
| **Sprint** | select | `Alpha` / `Beta` / `Release` |
|
||||||
|
| **ProjectType** | select | `CORE` / `CLIENT` / `SERVER` / `INFRA` |
|
||||||
|
| **Description** | rich_text | Full task description — **read this to understand what to do** |
|
||||||
|
| **Acceptance** | checkbox | Check when task is verified complete |
|
||||||
|
| **DueDate** | date | Optional deadline |
|
||||||
|
| **UnitTest** | date | Optional test completion date |
|
||||||
|
|
||||||
|
### Task Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
To Do → In Progress → Done
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Pick up task**: Set `Status` → `In Progress`
|
||||||
|
2. **Do the work**: Read `Description`, implement the changes
|
||||||
|
3. **Write unit tests**: Follow pattern in `tests/` directory
|
||||||
|
4. **Mark complete**: Set `Status` → `Done`, check `Acceptance` ✅
|
||||||
|
5. **Update changelog**: Add entry to `CHANGELOG_DRAFT.md` (consumer language)
|
||||||
|
6. **Bump version**: Update `project.godot` + `export_presets.cfg`
|
||||||
|
|
||||||
|
```
|
||||||
|
Use: mcp_notion-mcp-server_API-patch-page
|
||||||
|
page_id: "<task_page_id>"
|
||||||
|
properties: {"Status": {"select": {"name": "Done"}}, "Acceptance": {"checkbox": true}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Code Structure
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `scripts/game_mode.gd` | GameMode enum + helpers (add new modes here) |
|
||||||
|
| `scripts/managers/` | All game mode managers (lobby, stop_n_go, portal, gauntlet) |
|
||||||
|
| `scenes/main.gd` | Central orchestrator — init, setup, game start routing |
|
||||||
|
| `tests/` | GUT unit tests — one file per task/feature |
|
||||||
|
|
||||||
|
### Adding a New Game Mode
|
||||||
|
|
||||||
|
1. Add enum to `scripts/game_mode.gd` → update `from_string()`, `mode_to_string()`, `get_all_modes()`, `is_restricted()`
|
||||||
|
2. Add mode name to `LobbyManager.available_game_modes` in `lobby_manager.gd`
|
||||||
|
3. Add arena name to `_update_available_areas()` in `lobby_manager.gd`
|
||||||
|
4. Add manager var + init branch in `main.gd` `_init_managers()`
|
||||||
|
5. Add setup branch in `_setup_host_game()` and `_setup_client_game()`
|
||||||
|
6. Add start branch in `_start_game()`
|
||||||
|
7. Add background in `_apply_arena_background()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Unit Testing
|
||||||
|
|
||||||
|
### Pattern
|
||||||
|
|
||||||
|
All tests extend `GutTest` and live in `tests/`. Naming: `test_<feature>.gd`
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
extends GutTest
|
||||||
|
|
||||||
|
func before_all():
|
||||||
|
gut.p("=== Feature Tests [Task ID] ===")
|
||||||
|
|
||||||
|
func test_something():
|
||||||
|
assert_eq(actual, expected, "Description")
|
||||||
|
|
||||||
|
func after_all():
|
||||||
|
gut.p("=== Feature Tests Complete ===")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
run_tests.cmd # all tests
|
||||||
|
run_tests.cmd test_gauntlet_registration # specific test
|
||||||
|
```
|
||||||
|
|
||||||
|
Reports saved to `test_reports/` with timestamps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Version Bumping
|
||||||
|
|
||||||
|
**Before bumping, check git for existing uncommitted version changes:**
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
git diff --cached -- project.godot CHANGELOG_DRAFT.md
|
||||||
|
git diff -- project.godot CHANGELOG_DRAFT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### If version changes already exist (staged or unstaged):
|
||||||
|
→ **APPEND** your changelog bullet to the existing version block in `CHANGELOG_DRAFT.md`
|
||||||
|
→ **DO NOT** bump `project.godot` or `export_presets.cfg` — you're joining an in-progress batch
|
||||||
|
|
||||||
|
### If NO version changes exist (clean state):
|
||||||
|
→ **BUMP** version (increment patch: `2.3.5` → `2.3.6`)
|
||||||
|
→ **UPDATE** all locations below
|
||||||
|
|
||||||
|
Version appears in **4 locations** — all must match:
|
||||||
|
|
||||||
|
| File | Field |
|
||||||
|
|---|---|
|
||||||
|
| `CHANGELOG_DRAFT.md` | `## [X.Y.Z] — YYYY-MM-DD` header |
|
||||||
|
| `project.godot` | `config/version="X.Y.Z"` |
|
||||||
|
| `export_presets.cfg` | `application/file_version` and `application/product_version` (per preset) |
|
||||||
|
| `export_presets.cfg` | `export_path` filenames containing version |
|
||||||
|
| `export_presets.cfg` | `version/name` (Android preset) |
|
||||||
|
|
||||||
|
### Changelog Style
|
||||||
|
|
||||||
|
Entries are **consumer-facing** (readable by players). No internal jargon.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## [2.3.6] — 2026-05-22
|
||||||
|
- Added new game mode: Candy Cannon Survival
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bad:** "Added GAUNTLET = 3 to GameMode.Mode enum"
|
||||||
|
**Good:** "Added new game mode: Candy Cannon Survival"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Key Conventions
|
||||||
|
|
||||||
|
- **Caveman Mode**: Be terse. No filler. Execute first, talk second.
|
||||||
|
- **Read before edit**: Always check whole files before modifying `.gd`, `.tscn`, `.tres`, `.res` files.
|
||||||
|
- **Notion status flow**: `To Do` → `In Progress` → `Done` (never skip steps).
|
||||||
|
- **Test everything**: Every completed task gets a `test_<feature>.gd` in `tests/`.
|
||||||
|
- **GUT framework**: Tests use the Godot Unit Test (GUT) addon at `addons/gut/`.
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# [ ADT ] Report: Nakama Friend System & Steam Auth Implementation
|
|
||||||
|
|
||||||
## Session Summary
|
|
||||||
Implemented comprehensive Nakama friend system with lobby invites, direct messaging, global chat, and Steam authentication fallback. Migrated all UI from dynamic node creation to scene-based architecture.
|
|
||||||
|
|
||||||
## Completed Changes
|
|
||||||
|
|
||||||
### Server (Nakama)
|
|
||||||
✅ Added `afterAuthenticateSteam` hook in `tekton_admin.js` to set Steam persona name as display name
|
|
||||||
✅ Added `rpcSendLobbyInvite` RPC for sending lobby invite notifications to friends
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
✅ Implemented Steam login fallback to email-style auth in `auth_manager.gd` for dev/testing without publisher key
|
|
||||||
✅ Set Steam username as display name with default password matching username
|
|
||||||
✅ Set `is_guest = false` and `auth_mode = "steam"` on Steam login
|
|
||||||
|
|
||||||
### Friend System
|
|
||||||
✅ Created `FriendManager.gd` autoload singleton for Nakama friend list, DM channels, and lobby invite notifications
|
|
||||||
✅ Registered FriendManager in `project.godot` autoload
|
|
||||||
✅ Added `nakama_id` field to LobbyManager player data
|
|
||||||
✅ Extended `request_room_info` RPC to include `nakama_id` for all players
|
|
||||||
|
|
||||||
### Lobby UI
|
|
||||||
✅ Added optional `InviteBtn` reference in `lobby.gd` with safe node resolution
|
|
||||||
✅ Connected `invite_btn.pressed` to open invite friends dialog
|
|
||||||
✅ Connected FriendManager signals for lobby invite notifications
|
|
||||||
✅ Added + Friend buttons in player slots with friend status display
|
|
||||||
✅ Implemented invite friends popup using `invite_friends_dialog.tscn` scene
|
|
||||||
✅ Implemented lobby invite notification popup using `lobby_invite_popup.tscn` scene
|
|
||||||
✅ Added social panel modal opening from lobby
|
|
||||||
|
|
||||||
### Social Panel
|
|
||||||
✅ Created `social_panel.tscn` with full UI layout (tabs, friend list, global chat, DM views)
|
|
||||||
✅ Created `social_panel.gd` with `@onready` references (no dynamic UI creation)
|
|
||||||
✅ Implemented friend list using `friend_row.tscn` instances
|
|
||||||
✅ Implemented global chat and DM tabs with UI nodes
|
|
||||||
✅ Handled DM history and message sending
|
|
||||||
|
|
||||||
### UI Scenes (All .tscn based, no .new() calls)
|
|
||||||
✅ Created `friend_row.tscn` + `friend_row.gd` (friend list row with chat/accept/decline/remove buttons)
|
|
||||||
✅ Created `invite_friends_dialog.tscn` + `invite_friends_dialog.gd` (invite friends popup)
|
|
||||||
✅ Created `invite_row.tscn` + `invite_row.gd` (single invite row with invite button)
|
|
||||||
✅ Created `lobby_invite_popup.tscn` + `lobby_invite_popup.gd` (lobby invite notification dialog)
|
|
||||||
|
|
||||||
### Profile Panel
|
|
||||||
✅ Fixed account type display to include Steam auth mode in `profile_panel.gd`
|
|
||||||
|
|
||||||
### Version & Changelog
|
|
||||||
✅ Updated `CHANGELOG_DRAFT.md` with all changes (7 player-facing entries)
|
|
||||||
✅ Bumped version to 2.1.9 via `generate_version_json.py`
|
|
||||||
✅ Updated `assets/data/version.json` with new version and changelog
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
- `server/nakama/tekton_admin.js`
|
|
||||||
- `scripts/managers/auth_manager.gd`
|
|
||||||
- `scripts/managers/friend_manager.gd`
|
|
||||||
- `scripts/managers/lobby_manager.gd`
|
|
||||||
- `scripts/ui/profile_panel.gd`
|
|
||||||
- `scripts/ui/social_panel.gd`
|
|
||||||
- `scripts/ui/friend_row.gd`
|
|
||||||
- `scripts/ui/invite_friends_dialog.gd`
|
|
||||||
- `scripts/ui/invite_row.gd`
|
|
||||||
- `scripts/ui/lobby_invite_popup.gd`
|
|
||||||
- `scenes/lobby.gd`
|
|
||||||
- `project.godot`
|
|
||||||
- `CHANGELOG_DRAFT.md`
|
|
||||||
- `assets/data/version.json`
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
- `scenes/ui/social_panel.tscn`
|
|
||||||
- `scenes/ui/friend_row.tscn`
|
|
||||||
- `scenes/ui/invite_friends_dialog.tscn`
|
|
||||||
- `scenes/ui/invite_row.tscn`
|
|
||||||
- `scenes/ui/lobby_invite_popup.tscn`
|
|
||||||
|
|
||||||
## Version Bumped
|
|
||||||
**2.1.8 → 2.1.9** (2026-04-29)
|
|
||||||
|
|
||||||
## Player-Facing Changelog
|
|
||||||
- Added friend system with friend list, direct messaging, and lobby invitations
|
|
||||||
- Improved Steam login support
|
|
||||||
- Added Social Panel with Friends, Global Chat, and Direct Message tabs
|
|
||||||
- Added ability to add/remove friends from lobby player slots
|
|
||||||
- Added Invite Friends button to lobby
|
|
||||||
- Added lobby invite notifications
|
|
||||||
- Fixed account type display in profile panel
|
|
||||||
@@ -0,0 +1,1366 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Candy Cannon Survival — Technical Documentation</title>
|
||||||
|
<meta name="description" content="Technical implementation documentation for the Candy Cannon Survival (Gauntlet) game mode in Tekton Dash">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a0f;
|
||||||
|
--bg-secondary: #111118;
|
||||||
|
--bg-card: #16161f;
|
||||||
|
--bg-card-hover: #1c1c28;
|
||||||
|
--bg-code: #1a1a26;
|
||||||
|
--border: #2a2a3a;
|
||||||
|
--border-glow: #ff6bb5;
|
||||||
|
--text-primary: #e8e6f0;
|
||||||
|
--text-secondary: #8b89a0;
|
||||||
|
--text-muted: #5a586e;
|
||||||
|
--accent-pink: #ff6bb5;
|
||||||
|
--accent-pink-dim: #ff6bb540;
|
||||||
|
--accent-candy: #ff85c8;
|
||||||
|
--accent-purple: #a855f7;
|
||||||
|
--accent-blue: #6366f1;
|
||||||
|
--accent-cyan: #22d3ee;
|
||||||
|
--accent-green: #34d399;
|
||||||
|
--accent-yellow: #fbbf24;
|
||||||
|
--accent-orange: #fb923c;
|
||||||
|
--accent-red: #f87171;
|
||||||
|
--new-badge: #34d399;
|
||||||
|
--existing-badge: #6366f1;
|
||||||
|
--adapt-badge: #fbbf24;
|
||||||
|
--glass: rgba(22, 22, 31, 0.7);
|
||||||
|
--glass-border: rgba(255, 107, 181, 0.12);
|
||||||
|
--shadow-xl: 0 25px 50px -12px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--accent-pink-dim) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === AMBIENT BG === */
|
||||||
|
.ambient-bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ambient-bg .orb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(120px);
|
||||||
|
opacity: 0.15;
|
||||||
|
animation: orbFloat 20s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.ambient-bg .orb:nth-child(1) {
|
||||||
|
width: 600px; height: 600px;
|
||||||
|
background: var(--accent-pink);
|
||||||
|
top: -200px; left: -100px;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
.ambient-bg .orb:nth-child(2) {
|
||||||
|
width: 500px; height: 500px;
|
||||||
|
background: var(--accent-purple);
|
||||||
|
bottom: -150px; right: -100px;
|
||||||
|
animation-delay: -7s;
|
||||||
|
}
|
||||||
|
.ambient-bg .orb:nth-child(3) {
|
||||||
|
width: 400px; height: 400px;
|
||||||
|
background: var(--accent-blue);
|
||||||
|
top: 50%; left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
animation-delay: -14s;
|
||||||
|
}
|
||||||
|
@keyframes orbFloat {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||||
|
25% { transform: translate(30px, -40px) scale(1.05); }
|
||||||
|
50% { transform: translate(-20px, 20px) scale(0.95); }
|
||||||
|
75% { transform: translate(40px, 30px) scale(1.02); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === LAYOUT === */
|
||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === HERO === */
|
||||||
|
.hero {
|
||||||
|
padding: 80px 0 60px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.hero-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 100px;
|
||||||
|
background: var(--accent-pink-dim);
|
||||||
|
border: 1px solid var(--accent-pink);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
color: var(--accent-candy);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.hero-badge .dot {
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-pink);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: clamp(2.5rem, 5vw, 4rem);
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 1.1;
|
||||||
|
background: linear-gradient(135deg, #fff 0%, var(--accent-candy) 50%, var(--accent-purple) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.hero .subtitle {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto 40px;
|
||||||
|
}
|
||||||
|
.hero-stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 32px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.hero-stat {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.hero-stat .value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--accent-candy);
|
||||||
|
}
|
||||||
|
.hero-stat .label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === NAV === */
|
||||||
|
.sticky-nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
background: rgba(10,10,15,0.85);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
.nav-inner {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.nav-inner::-webkit-scrollbar { display: none; }
|
||||||
|
.nav-link {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.nav-link:hover, .nav-link.active {
|
||||||
|
background: var(--accent-pink-dim);
|
||||||
|
color: var(--accent-candy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === SECTIONS === */
|
||||||
|
section {
|
||||||
|
margin-bottom: 64px;
|
||||||
|
}
|
||||||
|
.section-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.section-header h2 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.section-header h2 .icon {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.section-header p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 8px;
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === CARDS === */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--glass-border);
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
box-shadow: 0 0 30px var(--accent-pink-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === GLOSSARY === */
|
||||||
|
.glossary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.glossary-item {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.glossary-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 4px; height: 100%;
|
||||||
|
border-radius: 4px 0 0 4px;
|
||||||
|
}
|
||||||
|
.glossary-item.new::before { background: var(--new-badge); }
|
||||||
|
.glossary-item.adapt::before { background: var(--adapt-badge); }
|
||||||
|
.glossary-item.existing::before { background: var(--existing-badge); }
|
||||||
|
|
||||||
|
.glossary-item:hover {
|
||||||
|
border-color: var(--glass-border);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 30px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.glossary-icon {
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.glossary-item.new .glossary-icon { background: rgba(52,211,153,0.12); }
|
||||||
|
.glossary-item.adapt .glossary-icon { background: rgba(251,191,36,0.12); }
|
||||||
|
.glossary-item.existing .glossary-icon { background: rgba(99,102,241,0.12); }
|
||||||
|
|
||||||
|
.glossary-content h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.glossary-content p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.glossary-content .code-ref {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
background: rgba(34,211,238,0.08);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === BADGES === */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
}
|
||||||
|
.badge-new { background: rgba(52,211,153,0.15); color: var(--new-badge); }
|
||||||
|
.badge-adapt { background: rgba(251,191,36,0.15); color: var(--adapt-badge); }
|
||||||
|
.badge-existing { background: rgba(99,102,241,0.15); color: var(--existing-badge); }
|
||||||
|
|
||||||
|
/* === LEGEND === */
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.legend-item {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 13px; color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.legend-dot {
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.legend-dot.new { background: var(--new-badge); }
|
||||||
|
.legend-dot.adapt { background: var(--adapt-badge); }
|
||||||
|
.legend-dot.existing { background: var(--existing-badge); }
|
||||||
|
|
||||||
|
/* === ARCHITECTURE DIAGRAM === */
|
||||||
|
.arch-diagram {
|
||||||
|
background: var(--bg-code);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.arch-tree {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
.arch-tree .node { color: var(--accent-cyan); font-weight: 600; }
|
||||||
|
.arch-tree .new-node { color: var(--accent-green); font-weight: 600; }
|
||||||
|
.arch-tree .label { color: var(--text-muted); }
|
||||||
|
.arch-tree .connector { color: var(--text-muted); }
|
||||||
|
.arch-tree .tag-new {
|
||||||
|
font-size: 10px;
|
||||||
|
background: rgba(52,211,153,0.15);
|
||||||
|
color: var(--new-badge);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 4px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === TABLES === */
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
thead th {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 14px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
tbody td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(42,42,58,0.5);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
tbody tr:last-child td { border-bottom: none; }
|
||||||
|
tbody tr:hover { background: rgba(255,107,181,0.03); }
|
||||||
|
tbody td:first-child { font-weight: 600; }
|
||||||
|
.td-code {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === REUSE TABLE === */
|
||||||
|
.reuse-direct { color: var(--accent-green); font-weight: 600; }
|
||||||
|
.reuse-heavy { color: var(--accent-cyan); font-weight: 600; }
|
||||||
|
.reuse-partial { color: var(--accent-yellow); font-weight: 600; }
|
||||||
|
.reuse-adapt { color: var(--accent-orange); font-weight: 600; }
|
||||||
|
.reuse-pattern { color: var(--accent-purple); font-weight: 600; }
|
||||||
|
|
||||||
|
/* === PHASE TIMELINE === */
|
||||||
|
.timeline {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) { .timeline { grid-template-columns: 1fr; } }
|
||||||
|
.phase-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.phase-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
.phase-card.phase-1::after { background: linear-gradient(90deg, var(--accent-green), var(--accent-cyan)); }
|
||||||
|
.phase-card.phase-2::after { background: linear-gradient(90deg, var(--accent-yellow), var(--accent-orange)); }
|
||||||
|
.phase-card.phase-3::after { background: linear-gradient(90deg, var(--accent-orange), var(--accent-red)); }
|
||||||
|
|
||||||
|
.phase-time {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.phase-card h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.phase-card ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.phase-card li {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.phase-card li::before {
|
||||||
|
content: '›';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--accent-pink);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === FILE LISTS === */
|
||||||
|
.file-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.file-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.file-card:hover {
|
||||||
|
border-color: var(--glass-border);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.file-icon {
|
||||||
|
width: 36px; height: 36px; min-width: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.file-card.new-file .file-icon { background: rgba(52,211,153,0.12); }
|
||||||
|
.file-card.mod-file .file-icon { background: rgba(251,191,36,0.12); }
|
||||||
|
.file-info h4 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
.file-info p {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === CODE BLOCKS === */
|
||||||
|
.code-block {
|
||||||
|
background: var(--bg-code);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.code-block .kw { color: var(--accent-purple); }
|
||||||
|
.code-block .fn { color: var(--accent-cyan); }
|
||||||
|
.code-block .str { color: var(--accent-green); }
|
||||||
|
.code-block .cm { color: var(--text-muted); font-style: italic; }
|
||||||
|
.code-block .num { color: var(--accent-orange); }
|
||||||
|
.code-block .type { color: var(--accent-yellow); }
|
||||||
|
|
||||||
|
/* === RISK CARDS === */
|
||||||
|
.risk-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.risk-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.risk-indicator {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
background: rgba(248,113,113,0.12);
|
||||||
|
}
|
||||||
|
.risk-content h4 { font-size: 14px; font-weight: 700; margin-bottom: 4px; }
|
||||||
|
.risk-content p { font-size: 12px; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* === PRIORITY LIST === */
|
||||||
|
.priority-list {
|
||||||
|
counter-reset: priority;
|
||||||
|
}
|
||||||
|
.priority-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid rgba(42,42,58,0.4);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.priority-item:last-child { border-bottom: none; }
|
||||||
|
.priority-num {
|
||||||
|
counter-increment: priority;
|
||||||
|
width: 36px; height: 36px; min-width: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--accent-pink-dim);
|
||||||
|
color: var(--accent-candy);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.priority-info h4 { font-size: 14px; font-weight: 700; }
|
||||||
|
.priority-info p {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === NETWORK TABLE ACCENTS === */
|
||||||
|
.sync-method {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === FOOTER === */
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === SEARCH === */
|
||||||
|
.search-bar {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.search-bar input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px 12px 44px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.search-bar input:focus {
|
||||||
|
border-color: var(--accent-pink);
|
||||||
|
}
|
||||||
|
.search-bar input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.search-bar .search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px; top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === FILTER BTNS === */
|
||||||
|
.filter-btns {
|
||||||
|
display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.filter-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.filter-btn:hover, .filter-btn.active {
|
||||||
|
border-color: var(--accent-pink);
|
||||||
|
color: var(--accent-candy);
|
||||||
|
background: var(--accent-pink-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === SCROLL TOP === */
|
||||||
|
.scroll-top {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 32px; right: 32px;
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--accent-pink);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
display: none;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
box-shadow: 0 4px 20px var(--accent-pink-dim);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.scroll-top:hover { transform: translateY(-2px); }
|
||||||
|
.scroll-top.show { display: flex; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="ambient-bg">
|
||||||
|
<div class="orb"></div>
|
||||||
|
<div class="orb"></div>
|
||||||
|
<div class="orb"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wrapper">
|
||||||
|
|
||||||
|
<!-- ============ HERO ============ -->
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-badge"><span class="dot"></span> Technical Documentation</div>
|
||||||
|
<h1>Candy Cannon Survival</h1>
|
||||||
|
<p class="subtitle">Gauntlet Mode — Implementation blueprint mapping GDD mechanics to existing Tekton Dash systems</p>
|
||||||
|
<div class="hero-stats">
|
||||||
|
<div class="hero-stat"><div class="value">70%</div><div class="label">Code Reuse</div></div>
|
||||||
|
<div class="hero-stat"><div class="value">4</div><div class="label">New Files</div></div>
|
||||||
|
<div class="hero-stat"><div class="value">7</div><div class="label">Modified Files</div></div>
|
||||||
|
<div class="hero-stat"><div class="value">12</div><div class="label">New Terms</div></div>
|
||||||
|
<div class="hero-stat"><div class="value">10</div><div class="label">Reused Terms</div></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ============ NAV ============ -->
|
||||||
|
<div class="sticky-nav">
|
||||||
|
<nav class="nav-inner wrapper">
|
||||||
|
<a class="nav-link" href="#glossary">Glossary</a>
|
||||||
|
<a class="nav-link" href="#architecture">Architecture</a>
|
||||||
|
<a class="nav-link" href="#reuse">Reuse Map</a>
|
||||||
|
<a class="nav-link" href="#phases">Phases</a>
|
||||||
|
<a class="nav-link" href="#systems">Systems</a>
|
||||||
|
<a class="nav-link" href="#files">Files</a>
|
||||||
|
<a class="nav-link" href="#network">Network</a>
|
||||||
|
<a class="nav-link" href="#priority">Priority</a>
|
||||||
|
<a class="nav-link" href="#risks">Risks</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ GLOSSARY ============ -->
|
||||||
|
<section id="glossary">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2><span class="icon" style="background:rgba(255,107,181,0.12)">📖</span> Glossary</h2>
|
||||||
|
<p>All terms used in Gauntlet mode — categorized by whether they're new, adapted, or already implemented in Tekton Dash.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legend">
|
||||||
|
<div class="legend-item"><div class="legend-dot new"></div> New — unique to Gauntlet</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot adapt"></div> Adapted — modified from existing mechanic</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot existing"></div> Existing — already in game, reused as-is</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-bar">
|
||||||
|
<span class="search-icon">🔍</span>
|
||||||
|
<input type="text" id="glossarySearch" placeholder="Search terms..." oninput="filterGlossary()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-btns">
|
||||||
|
<button class="filter-btn active" onclick="setFilter('all', this)">All</button>
|
||||||
|
<button class="filter-btn" onclick="setFilter('new', this)">🟢 New Only</button>
|
||||||
|
<button class="filter-btn" onclick="setFilter('adapt', this)">🟡 Adapted</button>
|
||||||
|
<button class="filter-btn" onclick="setFilter('existing', this)">🔵 Existing</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-grid" id="glossaryGrid">
|
||||||
|
|
||||||
|
<!-- ===== NEW TERMS ===== -->
|
||||||
|
<div class="glossary-item new" data-type="new" data-name="sticky cell">
|
||||||
|
<div class="glossary-icon">🍬</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Sticky Cell <span class="badge badge-new">New</span></h3>
|
||||||
|
<p>A grid cell hit by the Candy Cannon that becomes impassable. Players stepping onto or pushed into a sticky cell are <em>trapped</em>. Remains until cleansed or round ends. Rendered as Layer 2 overlay (pink translucent mesh, ID 17).</p>
|
||||||
|
<span class="code-ref">TILE_STICKY = 17 → GridMap Layer 2</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item new" data-type="new" data-name="telegraph">
|
||||||
|
<div class="glossary-icon">⚡</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Telegraph <span class="badge badge-new">New</span></h3>
|
||||||
|
<p>1-second warning before cannon impact. Target cell glows pink/candy color with a shadow preview and charge-up sound. Uses temporary overlay tile (ID 18) on Layer 2, animated alpha 0→1 over 0.8s, then replaced by Sticky Cell on impact.</p>
|
||||||
|
<span class="code-ref">TILE_TELEGRAPH = 18 → rpc("sync_telegraph")</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item new" data-type="new" data-name="candy cannon">
|
||||||
|
<div class="glossary-icon">💥</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Candy Cannon <span class="badge badge-new">New</span></h3>
|
||||||
|
<p>Central NPC occupying a permanent 3×3 zone at arena center. Fires volleys of 5 candy shots every 5 seconds, creating sticky cells. Static body — cannot be grabbed, thrown, or interacted with. Not a Tekton — it's a dedicated hazard entity.</p>
|
||||||
|
<span class="code-ref">CandyCannonController → candy_cannon.tscn</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item new" data-type="new" data-name="volley">
|
||||||
|
<div class="glossary-icon">🎯</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Volley <span class="badge badge-new">New</span></h3>
|
||||||
|
<p>A batch of 5 simultaneous cannon shots fired at different target cells. One volley fires every 5 seconds (36 total over 3 minutes = 180 impacts). Each shot in a volley has an independent impact size roll (1×1, 1×2, or 2×2).</p>
|
||||||
|
<span class="code-ref">_fire_volley() → cannon_interval = 5.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item new" data-type="new" data-name="impact size">
|
||||||
|
<div class="glossary-icon">📐</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Impact Size <span class="badge badge-new">New</span></h3>
|
||||||
|
<p>The footprint of each cannon shot. Three sizes: 1×1 (single cell), 1×2 (two adjacent), 2×2 (four cells square). Distribution changes per phase — early favors 1×1, endgame favors 2×2.</p>
|
||||||
|
<span class="code-ref">phase_weights[phase_idx]["2x2"]</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item new" data-type="new" data-name="trapped">
|
||||||
|
<div class="glossary-icon">🪤</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Trapped <span class="badge badge-new">New</span></h3>
|
||||||
|
<p>Player state when standing on a sticky cell. Cannot move normally. Escape only via Cleanser power-up. Players can be trapped by stepping onto sticky, being pushed into sticky, or direct cannon hit. Trapped players keep their score but are out of active play.</p>
|
||||||
|
<span class="code-ref">trapped_players: Dict → rpc("sync_trapped")</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item new" data-type="new" data-name="cleanser">
|
||||||
|
<div class="glossary-icon">✨</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Cleanser <span class="badge badge-new">New</span></h3>
|
||||||
|
<p>Power-up earned by completing 2 missions. Allows 5 cells of movement through sticky candy, cleansing traversed cells back to walkable. Inventory limit: 1. Cannot activate while stunned. 0.3s activation delay.</p>
|
||||||
|
<span class="code-ref">player_cleansers[peer_id] → GoalsCycleManager signal</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item new" data-type="new" data-name="clash">
|
||||||
|
<div class="glossary-icon">💫</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Clash <span class="badge badge-new">New</span></h3>
|
||||||
|
<p>When two players activate Smack simultaneously (within 0.5s) and are in range of each other. Both get stunned for 1.0s, no push occurs, both smack bars are consumed. Server-authoritative timestamp comparison.</p>
|
||||||
|
<span class="code-ref">clash detection → 0.5s window, server authority</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item new" data-type="new" data-name="charged state">
|
||||||
|
<div class="glossary-icon">🔋</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Charged State <span class="badge badge-new">New</span></h3>
|
||||||
|
<p>3-second window after Smack activation where the player model turns pink. If a target enters range during this window, the smack triggers. If no target is hit within 3s, energy is consumed with no effect.</p>
|
||||||
|
<span class="code-ref">smack_charged[player_id] → 3.0s window</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item new" data-type="new" data-name="anti-unfairness">
|
||||||
|
<div class="glossary-icon">⚖️</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Anti-Unfairness <span class="badge badge-new">New</span></h3>
|
||||||
|
<p>Targeting rules preventing the cannon from feeling random/cheap. No same-player twice in a row, 2×2 never directly on player, path validation ensures escape routes exist (except final 30s). Uses AStar pathfinding.</p>
|
||||||
|
<span class="code-ref">last_targeted_player_id → EnhancedGridMap.initialize_astar()</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item new" data-type="new" data-name="route blocking">
|
||||||
|
<div class="glossary-icon">🚧</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Route Blocking <span class="badge badge-new">New</span></h3>
|
||||||
|
<p>Cannon targeting strategy (25% chance) that places sticky cells on pathfinding bottlenecks — narrow corridors between sticky regions. Forces players to reroute. Calculated using EnhancedGridMap neighbor analysis.</p>
|
||||||
|
<span class="code-ref">_get_route_blocking_target() → 25% weight</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item new" data-type="new" data-name="gauntlet arena">
|
||||||
|
<div class="glossary-icon">🏟️</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Gauntlet Arena <span class="badge badge-new">New</span></h3>
|
||||||
|
<p>20×20 cell arena with 391 playable cells (400 minus 3×3 NPC zone). Players spawn at outer edges/corners. Target: 80% sticky coverage by round end (313 cells), leaving ~78 safe cells.</p>
|
||||||
|
<span class="code-ref">ARENA_SIZE = 20 → gauntlet.tscn</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== ADAPTED TERMS ===== -->
|
||||||
|
<div class="glossary-item adapt" data-type="adapt" data-name="smack">
|
||||||
|
<div class="glossary-icon">👊</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Smack <span class="badge badge-adapt">Adapted</span></h3>
|
||||||
|
<p>Gauntlet-specific melee push. Adapts existing <code>try_push()</code> from Attack Mode but replaces boost-meter gating with 8s auto-refill cooldown, adds 3s charged window, sticky landing trap, and clash detection. Push distance: 3 cells.</p>
|
||||||
|
<span class="code-ref">PlayerMovementManager.try_push() → smack_cooldowns</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item adapt" data-type="adapt" data-name="phase">
|
||||||
|
<div class="glossary-icon">⏱️</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Phase <span class="badge badge-adapt">Adapted</span></h3>
|
||||||
|
<p>Three escalation phases in Gauntlet: <strong>Open Arena</strong> (0–60s), <strong>Route Pressure</strong> (60–120s), <strong>Survival Endgame</strong> (120–180s). Adapts StopNGoManager's Go/Stop phase pattern but uses time-elapsed triggers instead of cycle signals.</p>
|
||||||
|
<span class="code-ref">enum Phase { OPEN_ARENA, ROUTE_PRESSURE, SURVIVAL_ENDGAME }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item adapt" data-type="adapt" data-name="bot ai cannon avoidance">
|
||||||
|
<div class="glossary-icon">🤖</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Bot AI — Cannon Avoidance <span class="badge badge-adapt">Adapted</span></h3>
|
||||||
|
<p>Extends BotStrategicPlanner with Gauntlet-specific logic: telegraph awareness, sticky path planning, safe-zone pathfinding. Adapts existing bot movement heuristics to factor in shrinking arena.</p>
|
||||||
|
<span class="code-ref">BotStrategicPlanner → new evaluate_gauntlet()</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== EXISTING TERMS ===== -->
|
||||||
|
<div class="glossary-item existing" data-type="existing" data-name="attack mode">
|
||||||
|
<div class="glossary-icon">⚔️</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Attack Mode <span class="badge badge-existing">Existing</span></h3>
|
||||||
|
<p>Existing player state toggled via PowerUpManager when boost bar is full. In Gauntlet, <em>not used directly</em> — replaced by Smack mechanic. The push physics from <code>try_push()</code> are reused but the activation logic differs.</p>
|
||||||
|
<span class="code-ref">PowerUpManager.is_attack_mode → NOT used in Gauntlet</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item existing" data-type="existing" data-name="stagger">
|
||||||
|
<div class="glossary-icon">😵</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Stagger <span class="badge badge-existing">Existing</span></h3>
|
||||||
|
<p>Existing 1.5s movement disable after being push-attacked. Gauntlet's Smack uses a shorter 1.0s stun, but the underlying <code>apply_stagger()</code> function is reused with a duration parameter.</p>
|
||||||
|
<span class="code-ref">PlayerMovementManager.apply_stagger(duration)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item existing" data-type="existing" data-name="mission / goals">
|
||||||
|
<div class="glossary-icon">🎯</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Mission / Goals <span class="badge badge-existing">Existing</span></h3>
|
||||||
|
<p>3×3 pattern-matching tile collection system. Reused as-is from GoalManager + GoalsCycleManager. In Gauntlet, completing every 2 missions also triggers Cleanser unlock (new hook on existing signal).</p>
|
||||||
|
<span class="code-ref">GoalManager → GoalsCycleManager.goal_count_updated</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item existing" data-type="existing" data-name="layer 2 overlay">
|
||||||
|
<div class="glossary-icon">🗂️</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Layer 2 Overlay <span class="badge badge-existing">Existing</span></h3>
|
||||||
|
<p>GridMap's Y=2 layer used for visual overlays (safe zones in Stop N Go, freeze in Freemode, highlights). Gauntlet uses it for Sticky Cell and Telegraph meshes. No conflict — modes are mutually exclusive.</p>
|
||||||
|
<span class="code-ref">GridMap.set_cell_item(Vector3i(x, 2, z), id)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item existing" data-type="existing" data-name="try push">
|
||||||
|
<div class="glossary-icon">🫸</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>try_push() <span class="badge badge-existing">Existing</span></h3>
|
||||||
|
<p>Player push mechanic in PlayerMovementManager. Pushes target 3 cells backward. Gauntlet's Smack wraps this with direction-based push, sticky landing detection, and clash rules.</p>
|
||||||
|
<span class="code-ref">PlayerMovementManager.try_push(target, direction)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item existing" data-type="existing" data-name="screen shake">
|
||||||
|
<div class="glossary-icon">📳</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Screen Shake <span class="badge badge-existing">Existing</span></h3>
|
||||||
|
<p>Camera shake effect triggered via RPC. Used on cannon impact with "medium" intensity. Already implemented system-wide.</p>
|
||||||
|
<span class="code-ref">player.rpc("trigger_screen_shake", "medium")</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item existing" data-type="existing" data-name="tekton projectile">
|
||||||
|
<div class="glossary-icon">🎪</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Tekton Projectile <span class="badge badge-existing">Existing</span></h3>
|
||||||
|
<p>Arc-tween projectile from Tekton NPC. Candy Cannon reuses this exact visual pattern (spawn_projectile_rpc) — creating a mesh, arc-tweening position, then freeing on arrival.</p>
|
||||||
|
<span class="code-ref">tekton.gd → spawn_projectile_rpc(target, duration)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item existing" data-type="existing" data-name="rpc sync pattern">
|
||||||
|
<div class="glossary-icon">📡</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>RPC Sync Pattern <span class="badge badge-existing">Existing</span></h3>
|
||||||
|
<p>Server-authoritative state sync via <code>@rpc("authority", "call_local", "reliable")</code>. All Gauntlet state changes (sticky, phase, trap, cleanser) use this identical pattern.</p>
|
||||||
|
<span class="code-ref">@rpc("authority", "call_local", "reliable")</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item existing" data-type="existing" data-name="timed match">
|
||||||
|
<div class="glossary-icon">⏰</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>Timed Match <span class="badge badge-existing">Existing</span></h3>
|
||||||
|
<p>Global match timer from GoalsCycleManager. Gauntlet passes 180s duration. System handles countdown, HUD timer, and match-end trigger.</p>
|
||||||
|
<span class="code-ref">goals_cycle_manager.start_match(180.0)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glossary-item existing" data-type="existing" data-name="special tiles manager">
|
||||||
|
<div class="glossary-icon">💎</div>
|
||||||
|
<div class="glossary-content">
|
||||||
|
<h3>SpecialTilesManager <span class="badge badge-existing">Existing</span></h3>
|
||||||
|
<p>Handles power-up tiles, inventory, and effects. Gauntlet restricts certain powerups (like Stop N Go restrictions) and adds Cleanser as a new inventory slot via the existing signal/slot system.</p>
|
||||||
|
<span class="code-ref">SpecialTilesManager.inventory → mode-based restrictions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ ARCHITECTURE ============ -->
|
||||||
|
<section id="architecture">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2><span class="icon" style="background:rgba(99,102,241,0.12)">🏗️</span> Architecture</h2>
|
||||||
|
<p>How GauntletManager slots into the existing manager tree, following the StopNGoManager pattern exactly.</p>
|
||||||
|
</div>
|
||||||
|
<div class="arch-diagram">
|
||||||
|
<div class="arch-tree">
|
||||||
|
<span class="node">main.gd</span><br>
|
||||||
|
<span class="connector">├──</span> <span class="node">_init_managers()</span> <span class="label">← instantiate GauntletManager</span><br>
|
||||||
|
<span class="connector">├──</span> <span class="node">_setup_host_game()</span> <span class="label">← arena setup branch</span><br>
|
||||||
|
<span class="connector">├──</span> <span class="node">_start_game()</span> <span class="label">← start_game_mode() call</span><br>
|
||||||
|
<span class="connector">│</span><br>
|
||||||
|
<span class="new-node">GauntletManager</span> <span class="tag-new">NEW</span><br>
|
||||||
|
<span class="connector">├──</span> <span class="new-node">_setup_arena()</span> <span class="label">← 20×20 grid, center 3×3 NPC zone</span><br>
|
||||||
|
<span class="connector">├──</span> <span class="new-node">_setup_hud()</span> <span class="label">← mission label, cleanser indicator</span><br>
|
||||||
|
<span class="connector">├──</span> <span class="new-node">start_game_mode()</span> <span class="label">← start cannon timer, spawn tiles</span><br>
|
||||||
|
<span class="connector">├──</span> <span class="new-node">_process()</span> <span class="label">← cannon volley timer, phase escalation</span><br>
|
||||||
|
<span class="connector">├──</span> <span class="new-node">CandyCannonController</span> <span class="tag-new">NEW</span> <span class="label">← targeting, volley fire</span><br>
|
||||||
|
<span class="connector">├──</span> <span class="new-node">StickyCell system</span> <span class="tag-new">NEW</span> <span class="label">← Layer 2 overlay, trap logic</span><br>
|
||||||
|
<span class="connector">├──</span> <span class="new-node">Cleanser system</span> <span class="tag-new">NEW</span> <span class="label">← powerup via missions</span><br>
|
||||||
|
<span class="connector">├──</span> <span class="new-node">Smack system</span> <span class="tag-new">NEW</span> <span class="label">← modified push with charge/cooldown</span><br>
|
||||||
|
<span class="connector">└──</span> <span class="new-node">Win condition</span> <span class="label">← highest score at timer end</span><br>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ REUSE MAP ============ -->
|
||||||
|
<section id="reuse">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2><span class="icon" style="background:rgba(52,211,153,0.12)">♻️</span> Reuse Map</h2>
|
||||||
|
<p>How each GDD feature maps to existing systems — showing what's reused vs what's new.</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>GDD Feature</th><th>Existing System</th><th>Reuse</th><th>New Work</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Game Mode Registration</td><td class="td-code">GameMode.gd + LobbyManager</td><td><span class="reuse-direct">Direct</span></td><td>Add enum + strings</td></tr>
|
||||||
|
<tr><td>20×20 Arena</td><td class="td-code">StopNGoManager._setup_arena()</td><td><span class="reuse-heavy">Heavy</span></td><td>Custom layout, same API</td></tr>
|
||||||
|
<tr><td>Tile Collection / Scoring</td><td class="td-code">GoalsCycleManager</td><td><span class="reuse-direct">Direct</span></td><td>Reuse as-is</td></tr>
|
||||||
|
<tr><td>Mission System</td><td class="td-code">GoalManager + goals_cycle_manager</td><td><span class="reuse-direct">Direct</span></td><td>Same 3×3 pattern matching</td></tr>
|
||||||
|
<tr><td>Timed Match</td><td class="td-code">GoalsCycleManager.start_match()</td><td><span class="reuse-direct">Direct</span></td><td>Pass 180s duration</td></tr>
|
||||||
|
<tr><td>Player Movement</td><td class="td-code">PlayerMovementManager</td><td><span class="reuse-direct">Direct</span></td><td>No changes</td></tr>
|
||||||
|
<tr><td>Powerup System</td><td class="td-code">SpecialTilesManager</td><td><span class="reuse-partial">Partial</span></td><td>Cleanser = new type</td></tr>
|
||||||
|
<tr><td>Smack Mechanic</td><td class="td-code">PlayerMovementManager.try_push()</td><td><span class="reuse-adapt">Adapt</span></td><td>Modified push rules</td></tr>
|
||||||
|
<tr><td>Candy Cannon NPC</td><td class="td-code">tekton.gd + TektonController</td><td><span class="reuse-pattern">Pattern</span></td><td>New NPC, reuses projectile</td></tr>
|
||||||
|
<tr><td>Sticky Cells</td><td class="td-code">StopNGoManager safe zone overlay</td><td><span class="reuse-pattern">Pattern</span></td><td>New tile type, same layer</td></tr>
|
||||||
|
<tr><td>Telegraph VFX</td><td class="td-code">VFXManager / animation.gd</td><td><span class="reuse-pattern">Pattern</span></td><td>New animations, same system</td></tr>
|
||||||
|
<tr><td>HUD</td><td class="td-code">StopNGoManager._setup_hud()</td><td><span class="reuse-direct">Direct</span></td><td>Mode-specific labels</td></tr>
|
||||||
|
<tr><td>Network Sync</td><td class="td-code">RPC patterns</td><td><span class="reuse-direct">Direct</span></td><td>Same patterns</td></tr>
|
||||||
|
<tr><td>Lobby Settings</td><td class="td-code">LobbyManager signal/sync</td><td><span class="reuse-direct">Direct</span></td><td>Gauntlet settings</td></tr>
|
||||||
|
<tr><td>Bot AI</td><td class="td-code">BotController + BotStrategicPlanner</td><td><span class="reuse-adapt">Adapt</span></td><td>Cannon avoidance strategy</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ PHASES ============ -->
|
||||||
|
<section id="phases">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2><span class="icon" style="background:rgba(251,191,36,0.12)">🌊</span> Phase Timeline</h2>
|
||||||
|
<p>Three escalation phases that control cannon intensity and impact size distribution.</p>
|
||||||
|
</div>
|
||||||
|
<div class="timeline">
|
||||||
|
<div class="phase-card phase-1">
|
||||||
|
<div class="phase-time">0:00 — 1:00</div>
|
||||||
|
<h3>Open Arena</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Collect tiles, learn the mission</li>
|
||||||
|
<li>Slow candy pressure</li>
|
||||||
|
<li>1×1 shots: <strong>60%</strong></li>
|
||||||
|
<li>1×2 shots: <strong>40%</strong></li>
|
||||||
|
<li>2×2 shots: <strong>0%</strong></li>
|
||||||
|
<li>~60 impacts total</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="phase-card phase-2">
|
||||||
|
<div class="phase-time">1:00 — 2:00</div>
|
||||||
|
<h3>Route Pressure</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Candy shapes arena topology</li>
|
||||||
|
<li>Smack becomes dangerous</li>
|
||||||
|
<li>1×1 shots: <strong>30%</strong></li>
|
||||||
|
<li>1×2 shots: <strong>55%</strong></li>
|
||||||
|
<li>2×2 shots: <strong>15%</strong></li>
|
||||||
|
<li>Cleanser used strategically</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="phase-card phase-3">
|
||||||
|
<div class="phase-time">2:00 — 3:00</div>
|
||||||
|
<h3>Survival Endgame</h3>
|
||||||
|
<ul>
|
||||||
|
<li>~80% arena is sticky</li>
|
||||||
|
<li>Safe zones limited, high tension</li>
|
||||||
|
<li>1×1 shots: <strong>15%</strong></li>
|
||||||
|
<li>1×2 shots: <strong>55%</strong></li>
|
||||||
|
<li>2×2 shots: <strong>30%</strong></li>
|
||||||
|
<li>Aggressive route-blocking allowed</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ CORE SYSTEMS ============ -->
|
||||||
|
<section id="systems">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2><span class="icon" style="background:rgba(168,85,247,0.12)">⚙️</span> Core Systems</h2>
|
||||||
|
<p>Deep-dive into the four new systems and how they integrate.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sticky Cell -->
|
||||||
|
<div class="card" style="margin-bottom: 16px;">
|
||||||
|
<h3 style="margin-bottom:16px; display:flex; align-items:center; gap:8px;">🍬 Sticky Cell System</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Feature</th><th>Implementation</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Visual</td><td>Layer 2 overlay — transparent candy-pink mesh (ID 17)</td></tr>
|
||||||
|
<tr><td>Movement Block</td><td class="td-code">PlayerMovementManager.simple_move_to() — add sticky check alongside wall check</td></tr>
|
||||||
|
<tr><td>Trap on Step</td><td class="td-code">GauntletManager._check_player_on_sticky() in _process()</td></tr>
|
||||||
|
<tr><td>Trap on Push</td><td class="td-code">PlayerMovementManager.try_push() — check landing cell</td></tr>
|
||||||
|
<tr><td>Cleanser Bypass</td><td>Temporary flag (like <code>is_invisible</code> wall bypass)</td></tr>
|
||||||
|
<tr><td>Network Sync</td><td class="td-code">main.rpc("sync_grid_item", x, 2, z, 17)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Smack -->
|
||||||
|
<div class="card" style="margin-bottom: 16px;">
|
||||||
|
<h3 style="margin-bottom:16px; display:flex; align-items:center; gap:8px;">👊 Smack vs Attack Mode</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Property</th><th>Current Attack Mode</th><th>Gauntlet Smack</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Charge Source</td><td>Boost bar fills to 100</td><td>8s auto-refill cooldown</td></tr>
|
||||||
|
<tr><td>Activation</td><td>Toggle <code>is_attack_mode</code></td><td>3s charged window (pink model)</td></tr>
|
||||||
|
<tr><td>Push Distance</td><td>3 cells backward</td><td>3 cells in push direction</td></tr>
|
||||||
|
<tr><td>Stagger Duration</td><td>1.5s <code>apply_stagger()</code></td><td>1.0s stun</td></tr>
|
||||||
|
<tr><td>Sticky Landing</td><td>N/A</td><td>Trapped on first sticky cell</td></tr>
|
||||||
|
<tr><td>Clash Rule</td><td>N/A</td><td>Both stunned, no push, bars consumed</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cleanser -->
|
||||||
|
<div class="card" style="margin-bottom: 16px;">
|
||||||
|
<h3 style="margin-bottom:16px; display:flex; align-items:center; gap:8px;">✨ Cleanser Power-Up</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Property</th><th>Value</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Unlock Trigger</td><td class="td-code">GoalsCycleManager.goal_count_updated → count % 2 == 0</td></tr>
|
||||||
|
<tr><td>Storage</td><td class="td-code">GauntletManager.player_cleansers[peer_id] = 1</td></tr>
|
||||||
|
<tr><td>Effect</td><td>5 cells movement through sticky — crossed cells become passable</td></tr>
|
||||||
|
<tr><td>Sync</td><td class="td-code">rpc("sync_cleanser_state", peer_id, count)</td></tr>
|
||||||
|
<tr><td>Clear Sticky</td><td class="td-code">main.rpc("sync_grid_item", x, 2, z, -1)</td></tr>
|
||||||
|
<tr><td>Inventory Limit</td><td>1 per player</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cannon Targeting -->
|
||||||
|
<div class="card">
|
||||||
|
<h3 style="margin-bottom:16px; display:flex; align-items:center; gap:8px;">🎯 Cannon Targeting Intelligence</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Roll %</th><th>Target Strategy</th><th>Purpose</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><strong>60%</strong></td><td>Near a player (not same as last)</td><td>Direct pressure</td></tr>
|
||||||
|
<tr><td><strong>25%</strong></td><td>Route-blocking bottleneck</td><td>Cut escape paths</td></tr>
|
||||||
|
<tr><td><strong>10%</strong></td><td>Random non-sticky area</td><td>Spread coverage</td></tr>
|
||||||
|
<tr><td><strong>5%</strong></td><td>Previously sticky / chaos</td><td>Unpredictability</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ FILES ============ -->
|
||||||
|
<section id="files">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2><span class="icon" style="background:rgba(34,211,238,0.12)">📁</span> File Changes</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin-bottom:12px; font-size:14px; color:var(--accent-green);">New Files</h3>
|
||||||
|
<div class="file-grid" style="margin-bottom:28px">
|
||||||
|
<div class="file-card new-file">
|
||||||
|
<div class="file-icon">📜</div>
|
||||||
|
<div class="file-info"><h4>gauntlet_manager.gd</h4><p>Core mode logic, phases, sticky cells, cleanser, smack</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="file-card new-file">
|
||||||
|
<div class="file-icon">📜</div>
|
||||||
|
<div class="file-info"><h4>candy_cannon_controller.gd</h4><p>Cannon targeting, volley fire, telegraph</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="file-card new-file">
|
||||||
|
<div class="file-icon">🎬</div>
|
||||||
|
<div class="file-info"><h4>gauntlet.tscn</h4><p>3D arena environment scene</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="file-card new-file">
|
||||||
|
<div class="file-icon">🎬</div>
|
||||||
|
<div class="file-info"><h4>candy_cannon.tscn</h4><p>Candy Cannon NPC (3×3, static)</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin-bottom:12px; font-size:14px; color:var(--accent-yellow);">Modified Files</h3>
|
||||||
|
<div class="file-grid">
|
||||||
|
<div class="file-card mod-file">
|
||||||
|
<div class="file-icon">✏️</div>
|
||||||
|
<div class="file-info"><h4>game_mode.gd</h4><p>Add GAUNTLET = 3 enum, string mappings</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="file-card mod-file">
|
||||||
|
<div class="file-icon">✏️</div>
|
||||||
|
<div class="file-info"><h4>lobby_manager.gd</h4><p>Mode list, gauntlet settings, area mapping</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="file-card mod-file">
|
||||||
|
<div class="file-icon">✏️</div>
|
||||||
|
<div class="file-info"><h4>main.gd</h4><p>Manager init, arena setup branch, start branch</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="file-card mod-file">
|
||||||
|
<div class="file-icon">✏️</div>
|
||||||
|
<div class="file-info"><h4>player_movement_manager.gd</h4><p>Sticky check in move + push</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="file-card mod-file">
|
||||||
|
<div class="file-icon">✏️</div>
|
||||||
|
<div class="file-info"><h4>goals_cycle_manager.gd</h4><p>Cleanser grant on 2nd goal</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="file-card mod-file">
|
||||||
|
<div class="file-icon">✏️</div>
|
||||||
|
<div class="file-info"><h4>special_tiles_manager.gd</h4><p>Gauntlet powerup restrictions</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="file-card mod-file">
|
||||||
|
<div class="file-icon">✏️</div>
|
||||||
|
<div class="file-info"><h4>MeshLibrary .tres</h4><p>Add TILE_STICKY (17) and TILE_TELEGRAPH (18)</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ NETWORK ============ -->
|
||||||
|
<section id="network">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2><span class="icon" style="background:rgba(248,113,113,0.12)">📡</span> Network Sync</h2>
|
||||||
|
<p>All sync follows existing RPC patterns — no new networking paradigms needed.</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Data</th><th>Sync Method</th><th>Existing Pattern</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Sticky Cells</td><td class="sync-method">main.rpc("sync_grid_item", x, 2, z, 17)</td><td>Safe zone / freeze overlay</td></tr>
|
||||||
|
<tr><td>Telegraph</td><td class="sync-method">rpc("sync_telegraph", targets_array)</td><td>StopNGoManager.sync_phase()</td></tr>
|
||||||
|
<tr><td>Phase Changes</td><td class="sync-method">rpc("sync_gauntlet_phase", idx, elapsed)</td><td>StopNGoManager.sync_phase()</td></tr>
|
||||||
|
<tr><td>Trap State</td><td class="sync-method">player.rpc("sync_trapped", true)</td><td>player.rpc("sync_stop_freeze")</td></tr>
|
||||||
|
<tr><td>Cleanser Grant</td><td class="sync-method">rpc("sync_cleanser", peer_id, count)</td><td>goals_cycle_manager.sync_goal_count()</td></tr>
|
||||||
|
<tr><td>Smack State</td><td class="sync-method">player.rpc("sync_smack_state", charged)</td><td>player.rpc("sync_modulate")</td></tr>
|
||||||
|
<tr><td>Cannon NPC</td><td colspan="2" style="color:var(--text-muted)">Static scene — no movement sync needed</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ PRIORITY ============ -->
|
||||||
|
<section id="priority">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2><span class="icon" style="background:rgba(251,191,36,0.12)">📋</span> Implementation Priority</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="priority-list">
|
||||||
|
<div class="priority-item"><div class="priority-num">1</div><div class="priority-info"><h4>Game Mode Registration</h4><p>game_mode.gd, lobby_manager.gd, main.gd</p></div></div>
|
||||||
|
<div class="priority-item"><div class="priority-num">2</div><div class="priority-info"><h4>Arena Setup</h4><p>gauntlet_manager._setup_arena(), 20×20 grid</p></div></div>
|
||||||
|
<div class="priority-item"><div class="priority-num">3</div><div class="priority-info"><h4>Tile Spawning</h4><p>StopNGoManager._spawn_mission_tiles() pattern</p></div></div>
|
||||||
|
<div class="priority-item"><div class="priority-num">4</div><div class="priority-info"><h4>Cannon Timer + Volley</h4><p>5s interval, 5 shots, 1×1 only</p></div></div>
|
||||||
|
<div class="priority-item"><div class="priority-num">5</div><div class="priority-info"><h4>Sticky Cell System</h4><p>Layer 2 overlay, movement block, trap detection</p></div></div>
|
||||||
|
<div class="priority-item"><div class="priority-num">6</div><div class="priority-info"><h4>Telegraph VFX</h4><p>Warning glow → impact transition</p></div></div>
|
||||||
|
<div class="priority-item"><div class="priority-num">7</div><div class="priority-info"><h4>Impact Sizes</h4><p>1×2 and 2×2 shapes, phase weights</p></div></div>
|
||||||
|
<div class="priority-item"><div class="priority-num">8</div><div class="priority-info"><h4>Smack Mechanic</h4><p>Modified push with cooldown/charge</p></div></div>
|
||||||
|
<div class="priority-item"><div class="priority-num">9</div><div class="priority-info"><h4>Cleanser</h4><p>Unlock tracking, sticky bypass</p></div></div>
|
||||||
|
<div class="priority-item"><div class="priority-num">10</div><div class="priority-info"><h4>Targeting Intelligence</h4><p>Player proximity, route blocking, anti-unfairness</p></div></div>
|
||||||
|
<div class="priority-item"><div class="priority-num">11</div><div class="priority-info"><h4>Bot AI</h4><p>Cannon avoidance, sticky path planning</p></div></div>
|
||||||
|
<div class="priority-item"><div class="priority-num">12</div><div class="priority-info"><h4>Polish</h4><p>VFX, SFX, HUD animations, 3D scene</p></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ RISKS ============ -->
|
||||||
|
<section id="risks">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2><span class="icon" style="background:rgba(248,113,113,0.12)">⚠️</span> Risk Assessment</h2>
|
||||||
|
</div>
|
||||||
|
<div class="risk-grid">
|
||||||
|
<div class="risk-card">
|
||||||
|
<div class="risk-indicator">🗂️</div>
|
||||||
|
<div class="risk-content">
|
||||||
|
<h4>Layer 2 Conflict</h4>
|
||||||
|
<p>GridMap Layer 2 used by freeze/safe overlays. <strong>Mitigated:</strong> Gauntlet mode is exclusive — no freeze/safe tiles exist.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="risk-card">
|
||||||
|
<div class="risk-indicator">📊</div>
|
||||||
|
<div class="risk-content">
|
||||||
|
<h4>20×20 Grid Performance</h4>
|
||||||
|
<p>400 cells + overlays. <strong>Mitigated:</strong> Existing 23×12 and 14×14 arenas work fine; 20×20 comparable.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="risk-card">
|
||||||
|
<div class="risk-indicator">🚫</div>
|
||||||
|
<div class="risk-content">
|
||||||
|
<h4>Impossible Arenas</h4>
|
||||||
|
<p>Cannon could seal all paths. <strong>Mitigated:</strong> AStar pathfinding check before each volley.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="risk-card">
|
||||||
|
<div class="risk-indicator">🔢</div>
|
||||||
|
<div class="risk-content">
|
||||||
|
<h4>MeshLibrary ID Collision</h4>
|
||||||
|
<p>IDs 17–18 might exist. <strong>Mitigated:</strong> Verify max ID in .tres before adding.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="risk-card">
|
||||||
|
<div class="risk-indicator">⏱️</div>
|
||||||
|
<div class="risk-content">
|
||||||
|
<h4>Smack Clash Timing</h4>
|
||||||
|
<p>Network latency affects clash detection. <strong>Mitigated:</strong> Server-authoritative timestamp, 0.5s window.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div class="wrapper">
|
||||||
|
Tekton Dash — Candy Cannon Survival Technical Docs · Generated from gauntlet-technical-implementation.md
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<button class="scroll-top" id="scrollTop" onclick="window.scrollTo({top:0,behavior:'smooth'})">↑</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Scroll-to-top button
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
document.getElementById('scrollTop').classList.toggle('show', window.scrollY > 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Active nav link
|
||||||
|
const sections = document.querySelectorAll('section[id]');
|
||||||
|
const navLinks = document.querySelectorAll('.nav-link');
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
let current = '';
|
||||||
|
sections.forEach(s => {
|
||||||
|
if (window.scrollY >= s.offsetTop - 120) current = s.id;
|
||||||
|
});
|
||||||
|
navLinks.forEach(l => {
|
||||||
|
l.classList.toggle('active', l.getAttribute('href') === '#' + current);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Glossary filter
|
||||||
|
let activeFilter = 'all';
|
||||||
|
function setFilter(type, btn) {
|
||||||
|
activeFilter = type;
|
||||||
|
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
filterGlossary();
|
||||||
|
}
|
||||||
|
function filterGlossary() {
|
||||||
|
const q = document.getElementById('glossarySearch').value.toLowerCase();
|
||||||
|
document.querySelectorAll('.glossary-item').forEach(item => {
|
||||||
|
const name = item.dataset.name;
|
||||||
|
const type = item.dataset.type;
|
||||||
|
const text = item.textContent.toLowerCase();
|
||||||
|
const matchFilter = activeFilter === 'all' || type === activeFilter;
|
||||||
|
const matchSearch = !q || text.includes(q);
|
||||||
|
item.style.display = matchFilter && matchSearch ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
# Candy Cannon Survival (Gauntlet) — Technical Implementation Plan
|
||||||
|
|
||||||
|
## 1. Feasibility Summary
|
||||||
|
|
||||||
|
**Verdict: Feasible.** The existing codebase provides ~70% of the infrastructure needed. The game mode architecture is modular — each mode has its own manager (`StopNGoManager`, `PortalModeManager`) that handles arena setup, HUD, phase logic, and win conditions. A new `GauntletManager` follows this identical pattern.
|
||||||
|
|
||||||
|
### Reuse Breakdown
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| 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 |
|
||||||
|
| 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 |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
│
|
||||||
|
GauntletManager (NEW)
|
||||||
|
├── _setup_arena() ← 20×20 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
|
||||||
|
├── StickyCell system ← Layer 2 overlay, trap logic
|
||||||
|
├── Cleanser system ← New powerup unlocked via missions
|
||||||
|
├── Smack system ← Modified push with charge/cooldown
|
||||||
|
└── Win condition ← Highest score at timer end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. File-by-File Implementation
|
||||||
|
|
||||||
|
### 3.1 Game Mode Registration
|
||||||
|
|
||||||
|
#### `scripts/game_mode.gd`
|
||||||
|
```gdscript
|
||||||
|
enum Mode {
|
||||||
|
FREEMODE = 0,
|
||||||
|
STOP_N_GO = 1,
|
||||||
|
TEKTON_DOORS = 2,
|
||||||
|
GAUNTLET = 3 # NEW
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Core Manager — `gauntlet_manager.gd` (NEW)
|
||||||
|
|
||||||
|
**Location:** `scripts/managers/gauntlet_manager.gd`
|
||||||
|
|
||||||
|
**Pattern source:** `StopNGoManager` + `PortalModeManager`
|
||||||
|
|
||||||
|
```
|
||||||
|
class_name GauntletManager
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
# Signals
|
||||||
|
signal phase_changed(phase_index: int)
|
||||||
|
signal cannon_fired(targets: Array)
|
||||||
|
signal player_trapped(player_id: int)
|
||||||
|
signal cleanser_granted(player_id: int)
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
const ARENA_SIZE = 20
|
||||||
|
const NPC_SIZE = 3
|
||||||
|
const NPC_CENTER = Vector2i(9, 9) # Center of 20×20
|
||||||
|
const TILE_STICKY = 17 # New MeshLibrary item ID
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**Approach:** Use GridMap Layer 2 (same as `StopNGoManager` safe zone overlay and `SpecialTilesManager` freeze overlay).
|
||||||
|
|
||||||
|
**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:
|
||||||
|
|
||||||
|
```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))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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) |
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|
||||||
|
## 6. Anti-Unfairness Implementation
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Network Considerations
|
||||||
|
|
||||||
|
All sync follows existing patterns:
|
||||||
|
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
| 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) |
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# Candy Cannon Survival (Gauntlet) — Game Design Document
|
||||||
|
|
||||||
|
## 1. High Concept
|
||||||
|
|
||||||
|
A time-limited survival-collection arena mode where players move across a grid of colored tiles, collect mission-specific tiles for points, and survive a central NPC that fires candy cannon shots across the arena. Candy impacts turn cells sticky and unpassable. Players must keep scoring while the arena slowly becomes dangerous. The stage pressure escalates over 3 minutes. By the end of the round, around 80% of the playable arena is sticky candy, leaving only 20% safe space.
|
||||||
|
|
||||||
|
## 2. Core Fantasy
|
||||||
|
|
||||||
|
Players feel like they are racing through a colorful board-game arena while the floor is being eaten by candy chaos. The round starts open and playful, then becomes tighter, more tactical, and more desperate as safe routes disappear.
|
||||||
|
|
||||||
|
## 3. Game Mode Summary
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|---|---|
|
||||||
|
| Mode Name | Candy Cannon Survival |
|
||||||
|
| Round Duration | 3 minutes |
|
||||||
|
| Recommended Players | 4–8 |
|
||||||
|
| Arena Type | Cell/grid-based arena |
|
||||||
|
| Primary Goal | Score points by collecting mission-required colored tiles |
|
||||||
|
| Secondary Goal | Survive until the timer ends |
|
||||||
|
| Main Hazard | Central NPC candy cannon creates sticky unpassable cells |
|
||||||
|
| Player Interaction | Smack/sabotage other players into danger |
|
||||||
|
| Comeback Tool | Cleanser power-up after completing 2 missions |
|
||||||
|
|
||||||
|
## 4. Recommended Arena Setup
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|---|---|
|
||||||
|
| Arena Size | 20×20 cells |
|
||||||
|
| Total Cells | 400 |
|
||||||
|
| Central NPC footprint | 3×3 = 9 cells |
|
||||||
|
| Playable Cells | 391 |
|
||||||
|
| Target sticky coverage (3 min) | 80% = 313 sticky cells |
|
||||||
|
| Remaining safe cells | 78 |
|
||||||
|
|
||||||
|
## 5. Arena Layout
|
||||||
|
|
||||||
|
- Grid: 20×20 square grid
|
||||||
|
- Each cell contains a colored tile
|
||||||
|
- Center 3×3 area occupied by Candy Cannon NPC (permanently blocked)
|
||||||
|
- Player spawns near outer edges / corners
|
||||||
|
- Mission tiles distributed across arena
|
||||||
|
|
||||||
|
**Spawn positions:**
|
||||||
|
- 4 players: top-left, top-right, bottom-left, bottom-right outer quadrants
|
||||||
|
- 6–8 players: side-edge and corner-adjacent spawns
|
||||||
|
|
||||||
|
## 6. Candy Cannon NPC
|
||||||
|
|
||||||
|
- Position: Exact center, 3×3 cell area
|
||||||
|
- Fires candy cannon shots at cells
|
||||||
|
- Impacted cells become **sticky**
|
||||||
|
|
||||||
|
### Sticky Cell Rules
|
||||||
|
|
||||||
|
- Cannot be passed through
|
||||||
|
- Cannot be collected from
|
||||||
|
- Traps players who step onto it
|
||||||
|
- Traps players pushed into it
|
||||||
|
- Remains sticky until cleansed or round ends
|
||||||
|
|
||||||
|
### Player Hit Rule
|
||||||
|
|
||||||
|
- Direct hit → player is **trapped** (not eliminated)
|
||||||
|
- If they have Cleanser, they may escape
|
||||||
|
- Without Cleanser, they are out of active play
|
||||||
|
|
||||||
|
## 7. Candy Cannon Timing & Math
|
||||||
|
|
||||||
|
- Cannon fires **one volley every 5 seconds**
|
||||||
|
- Each volley: **5 shots** at different target cells
|
||||||
|
- 180 seconds / 5 = **36 volleys**
|
||||||
|
- 36 × 5 = **180 total impacts**
|
||||||
|
|
||||||
|
### Telegraph
|
||||||
|
|
||||||
|
- **1 second** telegraph before impact (pink glow, syrup preview, charge sound, final flash)
|
||||||
|
|
||||||
|
### Impact Size Mix (full round)
|
||||||
|
|
||||||
|
| Size | Chance |
|
||||||
|
|---|---|
|
||||||
|
| 1×1 (1 cell) | 35% |
|
||||||
|
| 1×2 (2 cells) | 50% |
|
||||||
|
| 2×2 (4 cells) | 15% |
|
||||||
|
|
||||||
|
### Phase-Based Volley Pattern
|
||||||
|
|
||||||
|
| Time | Shots | Mix | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0:00–1:00 | 5/volley | Mostly 1×1, 1×2 | Slow arena pressure |
|
||||||
|
| 1:00–2:00 | 5/volley | Mostly 1×2, some 2×2 | Route cutting |
|
||||||
|
| 2:00–3:00 | 5/volley | More 2×2 | Strong endgame pressure |
|
||||||
|
|
||||||
|
| Phase | 1×1 | 1×2 | 2×2 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0:00–1:00 | 60% | 40% | 0% |
|
||||||
|
| 1:00–2:00 | 30% | 55% | 15% |
|
||||||
|
| 2:00–3:00 | 15% | 55% | 30% |
|
||||||
|
|
||||||
|
## 8. Cannon Targeting Logic — Controlled Chaos
|
||||||
|
|
||||||
|
| Chance | Target |
|
||||||
|
|---|---|
|
||||||
|
| 60% | Non-sticky area near a player |
|
||||||
|
| 25% | Non-sticky area blocking common routes |
|
||||||
|
| 10% | Random non-sticky area |
|
||||||
|
| 5% | Previously sticky / semi-blocked (chaos) |
|
||||||
|
|
||||||
|
### Anti-Unfairness Rules
|
||||||
|
|
||||||
|
- Do not target same player twice in a row
|
||||||
|
- Do not place 2×2 directly on a player without warning
|
||||||
|
- Do not fully seal all exits from a pocket (except final 30s)
|
||||||
|
- Maintain at least one path from each active player to a safe region when possible
|
||||||
|
|
||||||
|
## 9. Telegraph System
|
||||||
|
|
||||||
|
- **1 second** warning before impact
|
||||||
|
- Target cell glows pink/candy color
|
||||||
|
- Shadow or syrup splash preview
|
||||||
|
- Impact sound charges up
|
||||||
|
- Final 0.2s flash before landing
|
||||||
|
|
||||||
|
## 10. Sticky Trap Rules
|
||||||
|
|
||||||
|
- Step onto sticky cell → **trapped**
|
||||||
|
- Cannot move normally
|
||||||
|
- Escape only via Cleanser
|
||||||
|
- Direct cannon hit → trapped (Cleanser may save)
|
||||||
|
- Pushed into sticky → trapped (pusher rewarded indirectly)
|
||||||
|
|
||||||
|
## 11. Cleanser Power-Up
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|---|---|
|
||||||
|
| Unlock | Every 2 completed missions |
|
||||||
|
| Effect | Move up to 5 cells through sticky candy |
|
||||||
|
| Cleanse | Crossed sticky cells become passable again |
|
||||||
|
| Ends | After 5 cells or when stopping on a safe cell |
|
||||||
|
| Inventory limit | 1 |
|
||||||
|
| Cannot activate | While stunned |
|
||||||
|
| Activation delay | ~0.3 seconds |
|
||||||
|
|
||||||
|
## 12. Smack / Sabotage Mechanic
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|---|---|
|
||||||
|
| Smack energy refill | 8 seconds (full bar) |
|
||||||
|
| Smack activation | Model turns pink for 3 seconds (charged state) |
|
||||||
|
| Trigger | Target enters range during charged state |
|
||||||
|
| Cancel | No target within 3s → energy consumed, no effect |
|
||||||
|
| Effect on hit | Target pushed **3 cells** away, stunned after landing |
|
||||||
|
| Sticky landing | Target trapped on first sticky cell touched |
|
||||||
|
|
||||||
|
### Smack Clash Rule
|
||||||
|
|
||||||
|
- Two players activate smack simultaneously, both in range → **both smacked, both stunned, no push, both bars consumed**
|
||||||
|
- Stun duration for clash: **1.0 second**
|
||||||
|
|
||||||
|
## 13. Win Condition
|
||||||
|
|
||||||
|
- Highest score at end of 3 minutes wins
|
||||||
|
- Trapped/eliminated players keep earned score
|
||||||
|
- Surviving players receive survival bonus
|
||||||
|
|
||||||
|
## 14. Flow Phases
|
||||||
|
|
||||||
|
### Phase 1 — Open Arena (0:00–1:00)
|
||||||
|
|
||||||
|
- Collect tiles, understand mission
|
||||||
|
- Slow candy pressure (1×1, 1×2 mostly)
|
||||||
|
|
||||||
|
### Phase 2 — Route Pressure (1:00–2:00)
|
||||||
|
|
||||||
|
- Candy shapes arena
|
||||||
|
- Smack becomes dangerous
|
||||||
|
- Cleanser used strategically
|
||||||
|
- 1×2 and 2×2 shots common, targeting near players/paths
|
||||||
|
|
||||||
|
### Phase 3 — Survival Endgame (2:00–3:00)
|
||||||
|
|
||||||
|
- Arena mostly sticky, safe zones limited
|
||||||
|
- Secure final points or focus survival
|
||||||
|
- 2×2 shots frequent, rare 1×3 line shots possible
|
||||||
|
- Aggressive route-blocking allowed
|
||||||
|
|
||||||
|
## 15. Balance Recommendations (Starting Values)
|
||||||
|
|
||||||
|
| System | Value |
|
||||||
|
|---|---|
|
||||||
|
| Arena size | 20×20 |
|
||||||
|
| NPC size | 3×3 center |
|
||||||
|
| Round duration | 180 seconds |
|
||||||
|
| Cannon interval | 5 seconds |
|
||||||
|
| Telegraph duration | 1 second |
|
||||||
|
| Target sticky coverage | 80% playable cells |
|
||||||
|
| Smack charge window | 3 seconds |
|
||||||
|
| Smack push distance | 3 cells |
|
||||||
|
| Smack energy refill | 8 seconds |
|
||||||
|
| Stun duration | 1.0 second |
|
||||||
|
| Cleanser unlock | Every 2 completed missions |
|
||||||
|
| Cleanser movement limit | 5 cells |
|
||||||
|
| Cleanser inventory limit | 1 |
|
||||||
|
|
||||||
|
## 16. Risk Areas & Solutions
|
||||||
|
|
||||||
|
| Risk | Solution |
|
||||||
|
|---|---|
|
||||||
|
| Arena impossible too early | Reduce early 2×2 shots; preserve escape paths |
|
||||||
|
| Smack too strong | Increase refill to 10s; reduce push to 2 cells; brief invuln after smack |
|
||||||
|
| Cleanser too weak | Increase movement to 6 cells; clear adjacent cells |
|
||||||
|
| Cannon feels random/unfair | Readable telegraphs; avoid targeting same player; avoid full enclosure before final 30s |
|
||||||
|
|
||||||
|
## 17. Prototype Setup
|
||||||
|
|
||||||
|
- Arena: 20×20
|
||||||
|
- NPC: 3×3 center
|
||||||
|
- Players: 4
|
||||||
|
- Round time: 3 minutes
|
||||||
|
- Cannon interval: 5 seconds
|
||||||
|
- Volley size: 5 shots
|
||||||
|
- Telegraph: 1 second
|
||||||
|
- Impact pattern as defined in Phase-Based table above
|
||||||
|
- Smack refill: 8s / window: 3s / push: 3 cells / stun: 1s
|
||||||
|
- Cleanser: every 2 missions / 5 cells movement / hold 1
|
||||||
|
|
||||||
|
## 18. Design Notes
|
||||||
|
|
||||||
|
- Mode is about **choosing** when to score, sabotage, save Cleanser, or abandon a mission
|
||||||
|
- Cannon creates **pressure**, not instant failure
|
||||||
|
- Smack creates **player-driven danger**
|
||||||
|
- Cleanser gives **recovery** and rewards brave play near danger
|
||||||
|
- Emotional curve: playful collection → tactical routing → chaotic survival
|
||||||
+9
-9
@@ -8,7 +8,7 @@ custom_features=""
|
|||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter=""
|
include_filter=""
|
||||||
exclude_filter=""
|
exclude_filter=""
|
||||||
export_path="build/tekton_armageddon_v2.3.5.exe"
|
export_path="build/tekton_armageddon_v2.3.6.exe"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
patch_delta_encoding=false
|
patch_delta_encoding=false
|
||||||
patch_delta_compression_level_zstd=19
|
patch_delta_compression_level_zstd=19
|
||||||
@@ -42,8 +42,8 @@ application/modify_resources=false
|
|||||||
application/icon=""
|
application/icon=""
|
||||||
application/console_wrapper_icon=""
|
application/console_wrapper_icon=""
|
||||||
application/icon_interpolation=4
|
application/icon_interpolation=4
|
||||||
application/file_version="2.3.5"
|
application/file_version="2.3.6"
|
||||||
application/product_version="2.3.5"
|
application/product_version="2.3.6"
|
||||||
application/company_name="DanchieGo"
|
application/company_name="DanchieGo"
|
||||||
application/product_name="Tekton Armageddon"
|
application/product_name="Tekton Armageddon"
|
||||||
application/file_description=""
|
application/file_description=""
|
||||||
@@ -80,7 +80,7 @@ custom_features=""
|
|||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter=""
|
include_filter=""
|
||||||
exclude_filter=""
|
exclude_filter=""
|
||||||
export_path="build/tekton-dash-armageddon-v.2.3.5.apk"
|
export_path="build/tekton-dash-armageddon-v.2.3.6.apk"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
patch_delta_encoding=false
|
patch_delta_encoding=false
|
||||||
patch_delta_compression_level_zstd=19
|
patch_delta_compression_level_zstd=19
|
||||||
@@ -111,7 +111,7 @@ architectures/arm64-v8a=true
|
|||||||
architectures/x86=false
|
architectures/x86=false
|
||||||
architectures/x86_64=false
|
architectures/x86_64=false
|
||||||
version/code=3
|
version/code=3
|
||||||
version/name="2.3.5"
|
version/name="2.3.6"
|
||||||
package/unique_name="com.danchiego.$genname"
|
package/unique_name="com.danchiego.$genname"
|
||||||
package/name="Tekton Dash Armageddon"
|
package/name="Tekton Dash Armageddon"
|
||||||
package/signed=true
|
package/signed=true
|
||||||
@@ -306,7 +306,7 @@ custom_features=""
|
|||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter=""
|
include_filter=""
|
||||||
exclude_filter=""
|
exclude_filter=""
|
||||||
export_path="build/tekton_armageddon_v2.3.5.zip"
|
export_path="build/tekton_armageddon_v2.3.6.zip"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
patch_delta_encoding=false
|
patch_delta_encoding=false
|
||||||
patch_delta_compression_level_zstd=19
|
patch_delta_compression_level_zstd=19
|
||||||
@@ -565,8 +565,8 @@ codesign/digest_algorithm=1
|
|||||||
codesign/identity_type=0
|
codesign/identity_type=0
|
||||||
application/modify_resources=false
|
application/modify_resources=false
|
||||||
application/console_wrapper_icon=""
|
application/console_wrapper_icon=""
|
||||||
application/file_version="2.3.5"
|
application/file_version="2.3.6"
|
||||||
application/product_version="2.3.5"
|
application/product_version="2.3.6"
|
||||||
application/company_name="DanchieGo"
|
application/company_name="DanchieGo"
|
||||||
application/product_name="Tekton Armageddon"
|
application/product_name="Tekton Armageddon"
|
||||||
application/file_description=""
|
application/file_description=""
|
||||||
@@ -582,7 +582,7 @@ custom_features=""
|
|||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter=""
|
include_filter=""
|
||||||
exclude_filter=""
|
exclude_filter=""
|
||||||
export_path="build/tekton_armageddon_v2.3.5.x86_64"
|
export_path="build/tekton_armageddon_v2.3.6.x86_64"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
patch_delta_encoding=false
|
patch_delta_encoding=false
|
||||||
patch_delta_compression_level_zstd=19
|
patch_delta_compression_level_zstd=19
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
|
|||||||
[application]
|
[application]
|
||||||
|
|
||||||
config/name="Tekton Dash Armageddon"
|
config/name="Tekton Dash Armageddon"
|
||||||
config/version="2.3.5"
|
config/version="2.3.6"
|
||||||
run/main_scene="res://scenes/ui/boot_screen.tscn"
|
run/main_scene="res://scenes/ui/boot_screen.tscn"
|
||||||
config/features=PackedStringArray("4.6", "Forward Plus")
|
config/features=PackedStringArray("4.6", "Forward Plus")
|
||||||
boot_splash/bg_color=Color(0.16470589, 0.6745098, 0.9372549, 1)
|
boot_splash/bg_color=Color(0.16470589, 0.6745098, 0.9372549, 1)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
@echo off
|
||||||
|
REM ============================================================================
|
||||||
|
REM Tekton Dash — GUT Test Runner with Report Generation
|
||||||
|
REM Usage: run_tests.cmd [test_file]
|
||||||
|
REM No args = run ALL tests in tests/
|
||||||
|
REM With arg = run specific test, e.g. run_tests.cmd test_gauntlet_registration
|
||||||
|
REM ============================================================================
|
||||||
|
|
||||||
|
setlocal EnableDelayedExpansion
|
||||||
|
|
||||||
|
REM --- Config ---
|
||||||
|
set GODOT_PATH=godot
|
||||||
|
set PROJECT_PATH=%~dp0
|
||||||
|
set REPORT_DIR=%PROJECT_PATH%test_reports
|
||||||
|
set TIMESTAMP=%DATE:~10,4%-%DATE:~4,2%-%DATE:~7,2%_%TIME:~0,2%-%TIME:~3,2%-%TIME:~6,2%
|
||||||
|
set TIMESTAMP=%TIMESTAMP: =0%
|
||||||
|
set REPORT_FILE=%REPORT_DIR%\test_report_%TIMESTAMP%.txt
|
||||||
|
|
||||||
|
REM --- Create report directory ---
|
||||||
|
if not exist "%REPORT_DIR%" mkdir "%REPORT_DIR%"
|
||||||
|
|
||||||
|
REM --- Determine test target ---
|
||||||
|
set TEST_ARG=
|
||||||
|
if not "%~1"=="" (
|
||||||
|
set TEST_ARG=-gtest=res://tests/%~1.gd
|
||||||
|
echo Running specific test: %~1
|
||||||
|
) else (
|
||||||
|
set TEST_ARG=-gdir=res://tests/
|
||||||
|
echo Running ALL tests in tests/
|
||||||
|
)
|
||||||
|
|
||||||
|
REM --- Header ---
|
||||||
|
echo ============================================================================ > "%REPORT_FILE%"
|
||||||
|
echo TEKTON DASH — Test Report >> "%REPORT_FILE%"
|
||||||
|
echo Generated: %DATE% %TIME% >> "%REPORT_FILE%"
|
||||||
|
echo Project: Tekton Dash Armageddon >> "%REPORT_FILE%"
|
||||||
|
echo ============================================================================ >> "%REPORT_FILE%"
|
||||||
|
echo. >> "%REPORT_FILE%"
|
||||||
|
|
||||||
|
REM --- Run GUT tests via Godot headless ---
|
||||||
|
echo Running tests...
|
||||||
|
%GODOT_PATH% --headless --path "%PROJECT_PATH%" -s res://addons/gut/gut_cmdln.gd %TEST_ARG% -gexit 2>&1 | tee -a "%REPORT_FILE%" 2>nul
|
||||||
|
|
||||||
|
REM Fallback if tee is not available (most Windows systems)
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
%GODOT_PATH% --headless --path "%PROJECT_PATH%" -s res://addons/gut/gut_cmdln.gd %TEST_ARG% -gexit >> "%REPORT_FILE%" 2>&1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM --- Footer ---
|
||||||
|
echo. >> "%REPORT_FILE%"
|
||||||
|
echo ============================================================================ >> "%REPORT_FILE%"
|
||||||
|
echo End of Report >> "%REPORT_FILE%"
|
||||||
|
echo ============================================================================ >> "%REPORT_FILE%"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ============================================
|
||||||
|
echo Report saved to: %REPORT_FILE%
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM --- Open report ---
|
||||||
|
type "%REPORT_FILE%"
|
||||||
|
|
||||||
|
endlocal
|
||||||
+29
-1
@@ -16,6 +16,7 @@ var portal_mode_winner_id: int = -1
|
|||||||
var is_match_ended: bool = false
|
var is_match_ended: bool = false
|
||||||
var obstacle_manager
|
var obstacle_manager
|
||||||
var portal_mode_manager
|
var portal_mode_manager
|
||||||
|
var gauntlet_manager
|
||||||
var vfx_manager
|
var vfx_manager
|
||||||
|
|
||||||
# Minimal local state
|
# Minimal local state
|
||||||
@@ -147,6 +148,9 @@ func _apply_arena_background():
|
|||||||
_hide_ground_tiles()
|
_hide_ground_tiles()
|
||||||
"Tekton Doors Arena":
|
"Tekton Doors Arena":
|
||||||
texture_path = "res://assets/graphics/level_bg/placeholder_tekton_doors.jpg"
|
texture_path = "res://assets/graphics/level_bg/placeholder_tekton_doors.jpg"
|
||||||
|
"Gauntlet Arena":
|
||||||
|
texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg"
|
||||||
|
_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"
|
||||||
|
|
||||||
@@ -247,6 +251,13 @@ func _init_managers():
|
|||||||
add_child(portal_mode_manager)
|
add_child(portal_mode_manager)
|
||||||
portal_mode_manager.initialize(self , $EnhancedGridMap)
|
portal_mode_manager.initialize(self , $EnhancedGridMap)
|
||||||
|
|
||||||
|
# Gauntlet manager for Candy Cannon Survival mode
|
||||||
|
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)
|
||||||
|
|
||||||
# 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()
|
||||||
screen_shake_manager.name = "ScreenShakeManager"
|
screen_shake_manager.name = "ScreenShakeManager"
|
||||||
@@ -609,6 +620,8 @@ func _setup_host_game():
|
|||||||
stop_n_go_manager._setup_arena()
|
stop_n_go_manager._setup_arena()
|
||||||
elif LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
|
elif LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
|
||||||
portal_mode_manager.setup_arena_locally()
|
portal_mode_manager.setup_arena_locally()
|
||||||
|
elif LobbyManager.game_mode == "Candy Cannon Survival" and gauntlet_manager:
|
||||||
|
gauntlet_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()
|
||||||
@@ -715,6 +728,10 @@ func _setup_client_game():
|
|||||||
if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
|
if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
|
||||||
portal_mode_manager.setup_arena_locally()
|
portal_mode_manager.setup_arena_locally()
|
||||||
|
|
||||||
|
# Initialize arena locally for Candy Cannon Survival
|
||||||
|
if LobbyManager.game_mode == "Candy Cannon Survival" and gauntlet_manager:
|
||||||
|
gauntlet_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))
|
||||||
if player_character:
|
if player_character:
|
||||||
@@ -810,9 +827,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
|
||||||
|
if LobbyManager.game_mode == "Candy Cannon Survival" and gauntlet_manager:
|
||||||
|
gauntlet_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 Tekton Doors
|
# Exclude for Stop n Go and Tekton Doors
|
||||||
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors":
|
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors" and LobbyManager.game_mode != "Candy Cannon Survival":
|
||||||
spawn_static_tektons()
|
spawn_static_tektons()
|
||||||
|
|
||||||
# Tekton Doors: Randomize connections BEFORE countdown so colors show
|
# Tekton Doors: Randomize connections BEFORE countdown so colors show
|
||||||
@@ -851,6 +872,13 @@ 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))
|
goals_cycle_manager.start_match(float(match_duration))
|
||||||
|
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), true) # Enable cycles for 3x3 pattern missions
|
||||||
elif goals_cycle_manager:
|
elif 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))
|
goals_cycle_manager.start_match(float(match_duration))
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ class_name GameMode
|
|||||||
enum Mode {
|
enum Mode {
|
||||||
FREEMODE = 0,
|
FREEMODE = 0,
|
||||||
STOP_N_GO = 1,
|
STOP_N_GO = 1,
|
||||||
TEKTON_DOORS = 2
|
TEKTON_DOORS = 2,
|
||||||
|
GAUNTLET = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
static func from_string(mode: String) -> Mode:
|
static func from_string(mode: String) -> Mode:
|
||||||
@@ -15,6 +16,8 @@ static func from_string(mode: String) -> Mode:
|
|||||||
return Mode.STOP_N_GO
|
return Mode.STOP_N_GO
|
||||||
"Tekton Doors":
|
"Tekton Doors":
|
||||||
return Mode.TEKTON_DOORS
|
return Mode.TEKTON_DOORS
|
||||||
|
"Candy Cannon Survival":
|
||||||
|
return Mode.GAUNTLET
|
||||||
_:
|
_:
|
||||||
return Mode.FREEMODE
|
return Mode.FREEMODE
|
||||||
|
|
||||||
@@ -26,11 +29,13 @@ static func mode_to_string(mode: Mode) -> String:
|
|||||||
return "Stop n Go"
|
return "Stop n Go"
|
||||||
Mode.TEKTON_DOORS:
|
Mode.TEKTON_DOORS:
|
||||||
return "Tekton Doors"
|
return "Tekton Doors"
|
||||||
|
Mode.GAUNTLET:
|
||||||
|
return "Candy Cannon 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.TEKTON_DOORS
|
return mode == Mode.STOP_N_GO or mode == Mode.TEKTON_DOORS or mode == Mode.GAUNTLET
|
||||||
|
|
||||||
static func get_all_modes() -> Array[String]:
|
static func get_all_modes() -> Array[String]:
|
||||||
return ["Freemode", "Stop n Go", "Tekton Doors"]
|
return ["Freemode", "Stop n Go", "Tekton Doors", "Candy Cannon Survival"]
|
||||||
|
|||||||
@@ -0,0 +1,537 @@
|
|||||||
|
extends Node
|
||||||
|
class_name GauntletManager
|
||||||
|
|
||||||
|
# GauntletManager - Handles Candy Cannon Survival (Gauntlet) game mode
|
||||||
|
# Pattern: StopNGoManager + PortalModeManager
|
||||||
|
|
||||||
|
signal phase_changed(phase_index: int, phase_name: String)
|
||||||
|
signal cannon_fired(targets: Array)
|
||||||
|
signal player_trapped(player_id: int)
|
||||||
|
signal cleanser_granted(player_id: int)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Constants
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
const ARENA_COLUMNS: int = 20
|
||||||
|
const ARENA_ROWS: int = 20
|
||||||
|
const NPC_SIZE: int = 3
|
||||||
|
const NPC_CENTER: Vector2i = Vector2i(9, 9) # Center of 20x20 (0-indexed, center of 3x3 block)
|
||||||
|
|
||||||
|
# Tile IDs (matching MeshLibrary)
|
||||||
|
const TILE_WALKABLE: int = 0
|
||||||
|
const TILE_OBSTACLE: int = 4
|
||||||
|
const TILE_STICKY: int = 17 # New candy-pink overlay (Layer 2)
|
||||||
|
const TILE_TELEGRAPH: int = 18 # Warning glow (Layer 2, temporary)
|
||||||
|
|
||||||
|
# Phase timing thresholds (seconds elapsed)
|
||||||
|
const PHASE_1_START: float = 0.0 # Open Arena
|
||||||
|
const PHASE_2_START: float = 60.0 # Route Pressure
|
||||||
|
const PHASE_3_START: float = 120.0 # Survival Endgame
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Phase System
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
enum Phase { OPEN_ARENA, ROUTE_PRESSURE, SURVIVAL_ENDGAME }
|
||||||
|
var current_phase: Phase = Phase.OPEN_ARENA
|
||||||
|
var elapsed_time: float = 0.0
|
||||||
|
var is_active: bool = false
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Cannon State
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
var cannon_timer: float = 0.0
|
||||||
|
var cannon_interval: float = 5.0 # seconds between volleys
|
||||||
|
var volley_size: int = 5
|
||||||
|
var sticky_cells: Dictionary = {} # Vector2i → true
|
||||||
|
var last_targeted_player_id: int = -1
|
||||||
|
|
||||||
|
# Phase-specific cannon parameters
|
||||||
|
var phase_configs: Array = [
|
||||||
|
# Phase 0 (Open Arena): slow, small volleys
|
||||||
|
{"interval": 5.0, "volley": 5, "telegraph_time": 1.2},
|
||||||
|
# Phase 1 (Route Pressure): faster, bigger volleys
|
||||||
|
{"interval": 4.0, "volley": 8, "telegraph_time": 1.0},
|
||||||
|
# Phase 2 (Survival Endgame): rapid fire, huge volleys
|
||||||
|
{"interval": 3.0, "volley": 12, "telegraph_time": 0.8},
|
||||||
|
]
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Smack State (per-player)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
var smack_cooldowns: Dictionary = {} # player_id → float (time remaining)
|
||||||
|
var smack_charged: Dictionary = {} # player_id → float (charge window remaining)
|
||||||
|
const SMACK_COOLDOWN: float = 8.0
|
||||||
|
const SMACK_CHARGE_WINDOW: float = 3.0
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Cleanser Tracking
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
var player_mission_completions: Dictionary = {} # player_id → int
|
||||||
|
var player_cleansers: Dictionary = {} # player_id → int (0 or 1)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Trapped Players
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
var trapped_players: Dictionary = {} # player_id → true
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# References
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
var main_scene: Node = null
|
||||||
|
var gridmap: Node = null
|
||||||
|
|
||||||
|
# HUD
|
||||||
|
var hud_layer: CanvasLayer
|
||||||
|
var phase_label: Label
|
||||||
|
var cleanser_label: Label
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Lifecycle
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
set_process(false)
|
||||||
|
_setup_hud()
|
||||||
|
|
||||||
|
func initialize(main: Node, grid: Node) -> void:
|
||||||
|
main_scene = main
|
||||||
|
gridmap = grid
|
||||||
|
print("[Gauntlet] Initialized with gridmap: ", gridmap.name if gridmap else "null")
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if not is_active:
|
||||||
|
return
|
||||||
|
|
||||||
|
elapsed_time += delta
|
||||||
|
|
||||||
|
# Phase escalation
|
||||||
|
_check_phase_transition()
|
||||||
|
|
||||||
|
# Cannon timer (server only)
|
||||||
|
if multiplayer.is_server():
|
||||||
|
cannon_timer -= delta
|
||||||
|
if cannon_timer <= 0.0:
|
||||||
|
_fire_volley()
|
||||||
|
cannon_timer = cannon_interval
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Game Mode Start
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func start_game_mode() -> void:
|
||||||
|
if multiplayer.is_server():
|
||||||
|
activate_client_side()
|
||||||
|
_start_phase(Phase.OPEN_ARENA)
|
||||||
|
|
||||||
|
func activate_client_side() -> void:
|
||||||
|
is_active = true
|
||||||
|
if hud_layer:
|
||||||
|
hud_layer.visible = true
|
||||||
|
set_process(true)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Phase Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func _check_phase_transition() -> void:
|
||||||
|
var new_phase = current_phase
|
||||||
|
|
||||||
|
if elapsed_time >= PHASE_3_START:
|
||||||
|
new_phase = Phase.SURVIVAL_ENDGAME
|
||||||
|
elif elapsed_time >= PHASE_2_START:
|
||||||
|
new_phase = Phase.ROUTE_PRESSURE
|
||||||
|
|
||||||
|
if new_phase != current_phase:
|
||||||
|
_start_phase(new_phase)
|
||||||
|
|
||||||
|
func _start_phase(phase: Phase) -> void:
|
||||||
|
current_phase = phase
|
||||||
|
var config = phase_configs[int(phase)]
|
||||||
|
cannon_interval = config["interval"]
|
||||||
|
volley_size = config["volley"]
|
||||||
|
cannon_timer = cannon_interval
|
||||||
|
|
||||||
|
var phase_name = _phase_to_string(phase)
|
||||||
|
print("[Gauntlet] Phase changed to: ", phase_name)
|
||||||
|
|
||||||
|
if _can_rpc():
|
||||||
|
rpc("sync_phase", int(phase), phase_name)
|
||||||
|
|
||||||
|
emit_signal("phase_changed", int(phase), phase_name)
|
||||||
|
|
||||||
|
func _phase_to_string(phase: Phase) -> String:
|
||||||
|
match phase:
|
||||||
|
Phase.OPEN_ARENA:
|
||||||
|
return "Open Arena"
|
||||||
|
Phase.ROUTE_PRESSURE:
|
||||||
|
return "Route Pressure"
|
||||||
|
Phase.SURVIVAL_ENDGAME:
|
||||||
|
return "Survival!"
|
||||||
|
_:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func sync_phase(phase_index: int, phase_name: String) -> void:
|
||||||
|
if not is_active:
|
||||||
|
activate_client_side()
|
||||||
|
current_phase = phase_index as Phase
|
||||||
|
var config = phase_configs[phase_index]
|
||||||
|
cannon_interval = config["interval"]
|
||||||
|
volley_size = config["volley"]
|
||||||
|
_update_hud_phase(phase_name)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Arena Setup
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func _setup_arena() -> void:
|
||||||
|
"""Called by host in main._setup_host_game()"""
|
||||||
|
if not gridmap:
|
||||||
|
gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
||||||
|
if not gridmap:
|
||||||
|
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
||||||
|
if not gridmap:
|
||||||
|
push_error("[Gauntlet] No EnhancedGridMap found!")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("[Gauntlet] Setting up %dx%d Arena..." % [ARENA_COLUMNS, ARENA_ROWS])
|
||||||
|
|
||||||
|
# Sync to clients
|
||||||
|
if _can_rpc():
|
||||||
|
rpc("sync_arena_setup")
|
||||||
|
|
||||||
|
# Apply locally for server
|
||||||
|
_apply_arena_setup()
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func sync_arena_setup() -> void:
|
||||||
|
print("[Gauntlet] Client: Syncing Arena Setup (%dx%d)..." % [ARENA_COLUMNS, ARENA_ROWS])
|
||||||
|
_apply_arena_setup()
|
||||||
|
|
||||||
|
func _apply_arena_setup() -> void:
|
||||||
|
"""Shared arena layout logic for host + clients."""
|
||||||
|
if not gridmap:
|
||||||
|
gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
||||||
|
if not gridmap:
|
||||||
|
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
||||||
|
if not gridmap: return
|
||||||
|
|
||||||
|
# Resize grid (bypass setters that wipe the map)
|
||||||
|
gridmap.set("columns", ARENA_COLUMNS)
|
||||||
|
gridmap.set("rows", ARENA_ROWS)
|
||||||
|
|
||||||
|
# Clear all
|
||||||
|
gridmap.clear()
|
||||||
|
|
||||||
|
# Build the 20x20 arena
|
||||||
|
for x in range(ARENA_COLUMNS):
|
||||||
|
for z in range(ARENA_ROWS):
|
||||||
|
var pos = Vector2i(x, z)
|
||||||
|
|
||||||
|
# Center 3x3 block: NPC obstacle (Candy Cannon)
|
||||||
|
if _is_npc_zone(pos):
|
||||||
|
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_OBSTACLE)
|
||||||
|
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Outer edge (row 0, row 19, col 0, col 19) — cannon spawn positions
|
||||||
|
# These are walkable but used as spawn reference
|
||||||
|
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE)
|
||||||
|
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||||||
|
|
||||||
|
gridmap.diagonal_movement = true
|
||||||
|
gridmap.update_grid_data()
|
||||||
|
gridmap.initialize_astar()
|
||||||
|
|
||||||
|
print("[Gauntlet] Arena setup complete. Center NPC at (%d,%d), size %dx%d" % [
|
||||||
|
NPC_CENTER.x, NPC_CENTER.y, NPC_SIZE, NPC_SIZE
|
||||||
|
])
|
||||||
|
|
||||||
|
func _is_npc_zone(pos: Vector2i) -> bool:
|
||||||
|
"""Check if a position is within the center 3x3 NPC zone."""
|
||||||
|
var half = NPC_SIZE / 2 # integer division = 1
|
||||||
|
var min_coord = NPC_CENTER - Vector2i(half, half) # (8, 8)
|
||||||
|
var max_coord = NPC_CENTER + Vector2i(half, half) # (10, 10)
|
||||||
|
return pos.x >= min_coord.x and pos.x <= max_coord.x and pos.y >= min_coord.y and pos.y <= max_coord.y
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tile Spawning & Mission System (Task #3)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func setup_mission_tiles() -> void:
|
||||||
|
"""Public wrapper called from main.gd before countdown. Server-only."""
|
||||||
|
if multiplayer.is_server():
|
||||||
|
_spawn_mission_tiles()
|
||||||
|
|
||||||
|
func _spawn_mission_tiles() -> void:
|
||||||
|
"""Distribute colored goal tiles across the 20x20 arena.
|
||||||
|
Follows StopNGoManager._spawn_mission_tiles() pattern.
|
||||||
|
Excludes center 3x3 NPC zone."""
|
||||||
|
if not gridmap:
|
||||||
|
gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
||||||
|
if not gridmap:
|
||||||
|
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
||||||
|
if not gridmap: return
|
||||||
|
|
||||||
|
# Goal items: Heart(7), Diamond(8), Star(9), Coin(10)
|
||||||
|
var goal_items = [7, 8, 9, 10]
|
||||||
|
var tiles_spawned: int = 0
|
||||||
|
|
||||||
|
for x in range(ARENA_COLUMNS):
|
||||||
|
for z in range(ARENA_ROWS):
|
||||||
|
var pos = Vector2i(x, z)
|
||||||
|
|
||||||
|
# Skip NPC cannon zone (center 3x3)
|
||||||
|
if _is_npc_zone(pos):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check base floor — don't spawn on obstacles or void
|
||||||
|
var base_tile = gridmap.get_cell_item(Vector3i(x, 0, z))
|
||||||
|
if base_tile == TILE_OBSTACLE or base_tile == -1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if something already exists on Layer 1
|
||||||
|
var current_item = gridmap.get_cell_item(Vector3i(x, 1, z))
|
||||||
|
if current_item != -1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Spawn tiles with 60% density (40% chance to skip)
|
||||||
|
if randf() > 0.6:
|
||||||
|
continue
|
||||||
|
|
||||||
|
var tile_type = goal_items[randi() % goal_items.size()]
|
||||||
|
gridmap.set_cell_item(Vector3i(x, 1, z), tile_type)
|
||||||
|
tiles_spawned += 1
|
||||||
|
|
||||||
|
# Sync to clients
|
||||||
|
var main = get_node("/root/Main")
|
||||||
|
if main:
|
||||||
|
main.rpc("sync_grid_item", x, 1, z, tile_type)
|
||||||
|
|
||||||
|
print("[Gauntlet] Spawned %d mission tiles across %dx%d arena" % [tiles_spawned, ARENA_COLUMNS, ARENA_ROWS])
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Cannon Logic (Server Only)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func _fire_volley() -> void:
|
||||||
|
"""Select target cells, telegraph, then apply sticky after delay."""
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
|
||||||
|
var targets = _select_targets()
|
||||||
|
if targets.is_empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
var config = phase_configs[int(current_phase)]
|
||||||
|
var telegraph_time = config["telegraph_time"]
|
||||||
|
|
||||||
|
# Telegraph phase — show warning
|
||||||
|
if _can_rpc():
|
||||||
|
rpc("sync_telegraph", targets)
|
||||||
|
|
||||||
|
# Wait telegraph duration, then apply impact
|
||||||
|
await get_tree().create_timer(telegraph_time).timeout
|
||||||
|
|
||||||
|
if _can_rpc():
|
||||||
|
rpc("sync_impact", targets)
|
||||||
|
|
||||||
|
emit_signal("cannon_fired", targets)
|
||||||
|
|
||||||
|
func _select_targets() -> Array:
|
||||||
|
"""Pick target cells for this volley based on current phase weights."""
|
||||||
|
var targets: Array = []
|
||||||
|
var all_players = get_tree().get_nodes_in_group("Players")
|
||||||
|
|
||||||
|
# Collect all valid walkable positions (excluding NPC zone and existing sticky)
|
||||||
|
var valid_positions: Array = []
|
||||||
|
for x in range(ARENA_COLUMNS):
|
||||||
|
for z in range(ARENA_ROWS):
|
||||||
|
var pos = Vector2i(x, z)
|
||||||
|
if _is_npc_zone(pos):
|
||||||
|
continue
|
||||||
|
if sticky_cells.has(pos):
|
||||||
|
continue
|
||||||
|
valid_positions.append(pos)
|
||||||
|
|
||||||
|
if valid_positions.is_empty():
|
||||||
|
return targets
|
||||||
|
|
||||||
|
# Simple targeting: mix of random + player-adjacent
|
||||||
|
var remaining = volley_size
|
||||||
|
|
||||||
|
# 40% of volley near players
|
||||||
|
var player_targets = int(remaining * 0.4)
|
||||||
|
for i in range(player_targets):
|
||||||
|
if all_players.is_empty():
|
||||||
|
break
|
||||||
|
# Pick a random player
|
||||||
|
var player = all_players[randi() % all_players.size()]
|
||||||
|
var player_pos = player.current_position if player.get("current_position") else Vector2i(10, 10)
|
||||||
|
|
||||||
|
# Pick a cell near them (within 3 tiles)
|
||||||
|
var nearby = _get_nearby_valid_cells(player_pos, 3, valid_positions)
|
||||||
|
if not nearby.is_empty():
|
||||||
|
var target = nearby[randi() % nearby.size()]
|
||||||
|
if target not in targets:
|
||||||
|
targets.append(target)
|
||||||
|
remaining -= 1
|
||||||
|
|
||||||
|
# Remaining: random scatter
|
||||||
|
valid_positions.shuffle()
|
||||||
|
for pos in valid_positions:
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
if pos not in targets:
|
||||||
|
targets.append(pos)
|
||||||
|
remaining -= 1
|
||||||
|
|
||||||
|
return targets
|
||||||
|
|
||||||
|
func _get_nearby_valid_cells(center: Vector2i, radius: int, valid: Array) -> Array:
|
||||||
|
var result: Array = []
|
||||||
|
for pos in valid:
|
||||||
|
if abs(pos.x - center.x) <= radius and abs(pos.y - center.y) <= radius:
|
||||||
|
result.append(pos)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Telegraph & Impact (RPCs)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func sync_telegraph(targets: Array) -> void:
|
||||||
|
"""Show warning overlay on target cells."""
|
||||||
|
if not gridmap: return
|
||||||
|
for target in targets:
|
||||||
|
var pos = target as Vector2i
|
||||||
|
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH)
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func sync_impact(targets: Array) -> void:
|
||||||
|
"""Apply sticky cells at target positions."""
|
||||||
|
if not gridmap: return
|
||||||
|
for target in targets:
|
||||||
|
var pos = target as Vector2i
|
||||||
|
# Replace telegraph with sticky on Layer 2
|
||||||
|
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_STICKY)
|
||||||
|
sticky_cells[pos] = true
|
||||||
|
|
||||||
|
# Screen shake for impact
|
||||||
|
if main_scene and main_scene.get("screen_shake_manager"):
|
||||||
|
main_scene.screen_shake_manager.shake(0.15, 4.0)
|
||||||
|
|
||||||
|
# Check if any player is now trapped
|
||||||
|
_check_all_players_trapped()
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Sticky / Trap System
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func is_sticky_cell(pos: Vector2i) -> bool:
|
||||||
|
return sticky_cells.has(pos)
|
||||||
|
|
||||||
|
func _check_all_players_trapped() -> void:
|
||||||
|
if not multiplayer.is_server(): return
|
||||||
|
var all_players = get_tree().get_nodes_in_group("Players")
|
||||||
|
for player in all_players:
|
||||||
|
var pos = player.current_position if player.get("current_position") else Vector2i(-1, -1)
|
||||||
|
if is_sticky_cell(pos) and not trapped_players.has(player.get("peer_id", -1)):
|
||||||
|
_trap_player(player)
|
||||||
|
|
||||||
|
func _trap_player(player: Node) -> void:
|
||||||
|
var pid = player.get("peer_id", -1)
|
||||||
|
if pid == -1: return
|
||||||
|
trapped_players[pid] = true
|
||||||
|
print("[Gauntlet] Player %d TRAPPED at %s" % [pid, str(player.current_position)])
|
||||||
|
emit_signal("player_trapped", pid)
|
||||||
|
|
||||||
|
# TODO: Apply movement lockout, score penalty, visual feedback
|
||||||
|
# For now, just mark as trapped — will be expanded in Task #4
|
||||||
|
|
||||||
|
func clear_sticky_cell(pos: Vector2i) -> void:
|
||||||
|
"""Used by Cleanser power-up to remove a sticky cell."""
|
||||||
|
sticky_cells.erase(pos)
|
||||||
|
if gridmap:
|
||||||
|
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# HUD
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func _setup_hud() -> void:
|
||||||
|
hud_layer = CanvasLayer.new()
|
||||||
|
hud_layer.layer = 5
|
||||||
|
hud_layer.visible = false
|
||||||
|
add_child(hud_layer)
|
||||||
|
|
||||||
|
var custom_font = load("res://assets/fonts/Nougat-ExtraBlack.ttf")
|
||||||
|
|
||||||
|
# Phase label (top-center)
|
||||||
|
var top_container = CenterContainer.new()
|
||||||
|
top_container.set_anchors_preset(Control.PRESET_CENTER_TOP)
|
||||||
|
top_container.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||||
|
top_container.grow_vertical = Control.GROW_DIRECTION_END
|
||||||
|
top_container.offset_top = 70
|
||||||
|
hud_layer.add_child(top_container)
|
||||||
|
|
||||||
|
phase_label = Label.new()
|
||||||
|
phase_label.text = "🍬 OPEN ARENA"
|
||||||
|
phase_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
if custom_font: phase_label.add_theme_font_override("font", custom_font)
|
||||||
|
phase_label.add_theme_font_size_override("font_size", 24)
|
||||||
|
phase_label.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||||
|
phase_label.add_theme_constant_override("outline_size", 6)
|
||||||
|
phase_label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.8)) # Candy pink
|
||||||
|
top_container.add_child(phase_label)
|
||||||
|
|
||||||
|
# Cleanser label (bottom-center)
|
||||||
|
var bottom_container = CenterContainer.new()
|
||||||
|
bottom_container.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
|
||||||
|
bottom_container.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||||
|
bottom_container.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||||
|
bottom_container.offset_bottom = -50
|
||||||
|
hud_layer.add_child(bottom_container)
|
||||||
|
|
||||||
|
cleanser_label = Label.new()
|
||||||
|
cleanser_label.text = "🧹 Cleanser: 0"
|
||||||
|
cleanser_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
if custom_font: cleanser_label.add_theme_font_override("font", custom_font)
|
||||||
|
cleanser_label.add_theme_font_size_override("font_size", 20)
|
||||||
|
cleanser_label.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||||
|
cleanser_label.add_theme_constant_override("outline_size", 6)
|
||||||
|
bottom_container.add_child(cleanser_label)
|
||||||
|
|
||||||
|
func _update_hud_phase(phase_name: String) -> void:
|
||||||
|
if phase_label:
|
||||||
|
var icon = "🍬"
|
||||||
|
match phase_name:
|
||||||
|
"Route Pressure":
|
||||||
|
icon = "⚠️"
|
||||||
|
phase_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2)) # Warning gold
|
||||||
|
"Survival!":
|
||||||
|
icon = "💀"
|
||||||
|
phase_label.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3)) # Danger red
|
||||||
|
_:
|
||||||
|
phase_label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.8)) # Candy pink
|
||||||
|
phase_label.text = "%s %s" % [icon, phase_name.to_upper()]
|
||||||
|
|
||||||
|
func update_cleanser_ui(count: int) -> void:
|
||||||
|
if cleanser_label:
|
||||||
|
cleanser_label.text = "🧹 Cleanser: %d" % count
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Utility
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func _can_rpc() -> bool:
|
||||||
|
if not multiplayer.has_multiplayer_peer(): return false
|
||||||
|
if multiplayer.multiplayer_peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED: return false
|
||||||
|
return true
|
||||||
@@ -80,7 +80,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"]
|
var available_game_modes: Array[String] = ["Freemode", "Stop n Go", "Candy Cannon 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
|
||||||
@@ -135,6 +135,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 Cannon Survival":
|
||||||
|
available_areas = ["Gauntlet Arena"]
|
||||||
_:
|
_:
|
||||||
available_areas = ["Classic"]
|
available_areas = ["Classic"]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# tests/test_gauntlet_registration.gd
|
||||||
|
# Tests for [Gauntlet] #1 Game Mode Registration
|
||||||
|
# Validates GAUNTLET enum, string conversion, lobby integration, and arena setup
|
||||||
|
|
||||||
|
extends GutTest
|
||||||
|
|
||||||
|
func before_all():
|
||||||
|
gut.p("=== Gauntlet Registration Tests [Gauntlet #1] ===")
|
||||||
|
|
||||||
|
func after_each():
|
||||||
|
pass
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GameMode Enum Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Test 1: GAUNTLET enum value exists and equals 3
|
||||||
|
func test_gauntlet_enum_exists():
|
||||||
|
assert_eq(GameMode.Mode.GAUNTLET, 3, "GAUNTLET should be enum value 3")
|
||||||
|
|
||||||
|
# Test 2: All 4 modes are present in enum
|
||||||
|
func test_all_modes_in_enum():
|
||||||
|
assert_eq(GameMode.Mode.FREEMODE, 0, "FREEMODE should be 0")
|
||||||
|
assert_eq(GameMode.Mode.STOP_N_GO, 1, "STOP_N_GO should be 1")
|
||||||
|
assert_eq(GameMode.Mode.TEKTON_DOORS, 2, "TEKTON_DOORS should be 2")
|
||||||
|
assert_eq(GameMode.Mode.GAUNTLET, 3, "GAUNTLET should be 3")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# String Conversion Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Test 3: from_string recognizes "Candy Cannon Survival"
|
||||||
|
func test_from_string_candy_cannon():
|
||||||
|
var result = GameMode.from_string("Candy Cannon Survival")
|
||||||
|
assert_eq(result, GameMode.Mode.GAUNTLET, "from_string should parse 'Candy Cannon Survival' as GAUNTLET")
|
||||||
|
|
||||||
|
# Test 4: mode_to_string returns "Candy Cannon Survival" for GAUNTLET
|
||||||
|
func test_mode_to_string_gauntlet():
|
||||||
|
var result = GameMode.mode_to_string(GameMode.Mode.GAUNTLET)
|
||||||
|
assert_eq(result, "Candy Cannon Survival", "mode_to_string should return 'Candy Cannon Survival'")
|
||||||
|
|
||||||
|
# Test 5: Round-trip conversion is lossless
|
||||||
|
func test_round_trip_conversion():
|
||||||
|
var mode_str = GameMode.mode_to_string(GameMode.Mode.GAUNTLET)
|
||||||
|
var mode_enum = GameMode.from_string(mode_str)
|
||||||
|
assert_eq(mode_enum, GameMode.Mode.GAUNTLET, "Round-trip should preserve GAUNTLET")
|
||||||
|
|
||||||
|
# Test 6: All existing modes still round-trip correctly
|
||||||
|
func test_existing_modes_round_trip():
|
||||||
|
for mode in [GameMode.Mode.FREEMODE, GameMode.Mode.STOP_N_GO, GameMode.Mode.TEKTON_DOORS]:
|
||||||
|
var s = GameMode.mode_to_string(mode)
|
||||||
|
var back = GameMode.from_string(s)
|
||||||
|
assert_eq(back, mode, "Round-trip failed for %s" % s)
|
||||||
|
|
||||||
|
# Test 7: Unknown string defaults to FREEMODE
|
||||||
|
func test_unknown_string_defaults_freemode():
|
||||||
|
var result = GameMode.from_string("NonExistentMode")
|
||||||
|
assert_eq(result, GameMode.Mode.FREEMODE, "Unknown mode string should default to FREEMODE")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# get_all_modes Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Test 8: get_all_modes includes "Candy Cannon Survival"
|
||||||
|
func test_get_all_modes_includes_gauntlet():
|
||||||
|
var modes = GameMode.get_all_modes()
|
||||||
|
assert_has(modes, "Candy Cannon Survival", "get_all_modes should include 'Candy Cannon Survival'")
|
||||||
|
|
||||||
|
# Test 9: get_all_modes returns exactly 4 entries
|
||||||
|
func test_get_all_modes_count():
|
||||||
|
var modes = GameMode.get_all_modes()
|
||||||
|
assert_eq(modes.size(), 4, "get_all_modes should return 4 modes")
|
||||||
|
|
||||||
|
# Test 10: get_all_modes order is correct
|
||||||
|
func test_get_all_modes_order():
|
||||||
|
var modes = GameMode.get_all_modes()
|
||||||
|
assert_eq(modes[0], "Freemode", "First mode should be Freemode")
|
||||||
|
assert_eq(modes[1], "Stop n Go", "Second mode should be Stop n Go")
|
||||||
|
assert_eq(modes[2], "Tekton Doors", "Third mode should be Tekton Doors")
|
||||||
|
assert_eq(modes[3], "Candy Cannon Survival", "Fourth mode should be Candy Cannon Survival")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# is_restricted Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Test 11: GAUNTLET is a restricted mode
|
||||||
|
func test_gauntlet_is_restricted():
|
||||||
|
var result = GameMode.is_restricted(GameMode.Mode.GAUNTLET)
|
||||||
|
assert_true(result, "GAUNTLET should be restricted (dedicated arena)")
|
||||||
|
|
||||||
|
# Test 12: FREEMODE is NOT restricted
|
||||||
|
func test_freemode_not_restricted():
|
||||||
|
var result = GameMode.is_restricted(GameMode.Mode.FREEMODE)
|
||||||
|
assert_false(result, "FREEMODE should not be restricted")
|
||||||
|
|
||||||
|
# Test 13: All restricted modes are confirmed
|
||||||
|
func test_all_restricted_modes():
|
||||||
|
assert_true(GameMode.is_restricted(GameMode.Mode.STOP_N_GO), "STOP_N_GO should be restricted")
|
||||||
|
assert_true(GameMode.is_restricted(GameMode.Mode.TEKTON_DOORS), "TEKTON_DOORS should be restricted")
|
||||||
|
assert_true(GameMode.is_restricted(GameMode.Mode.GAUNTLET), "GAUNTLET should be restricted")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LobbyManager Integration Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Test 14: Lobby available_game_modes includes "Candy Cannon Survival"
|
||||||
|
func test_lobby_modes_includes_gauntlet():
|
||||||
|
var modes = LobbyManager.available_game_modes
|
||||||
|
assert_has(modes, "Candy Cannon Survival", "LobbyManager.available_game_modes should include 'Candy Cannon Survival'")
|
||||||
|
|
||||||
|
# Test 15: gauntlet_manager.gd script file exists
|
||||||
|
func test_gauntlet_manager_script_exists():
|
||||||
|
var script_exists = ResourceLoader.exists("res://scripts/managers/gauntlet_manager.gd")
|
||||||
|
assert_true(script_exists, "gauntlet_manager.gd should exist")
|
||||||
|
|
||||||
|
# Test 16: GauntletManager class can be loaded
|
||||||
|
func test_gauntlet_manager_loads():
|
||||||
|
var script = load("res://scripts/managers/gauntlet_manager.gd")
|
||||||
|
assert_not_null(script, "gauntlet_manager.gd should load without errors")
|
||||||
|
|
||||||
|
# Test 17: GauntletManager has required methods
|
||||||
|
func test_gauntlet_manager_has_methods():
|
||||||
|
var manager = GauntletManager.new()
|
||||||
|
assert_true(manager.has_method("_setup_arena"), "GauntletManager should have _setup_arena()")
|
||||||
|
assert_true(manager.has_method("_apply_arena_setup"), "GauntletManager should have _apply_arena_setup()")
|
||||||
|
assert_true(manager.has_method("start_game_mode"), "GauntletManager should have start_game_mode()")
|
||||||
|
assert_true(manager.has_method("initialize"), "GauntletManager should have initialize()")
|
||||||
|
manager.free()
|
||||||
|
|
||||||
|
# Test 18: GauntletManager arena constants are correct
|
||||||
|
func test_gauntlet_arena_constants():
|
||||||
|
assert_eq(GauntletManager.ARENA_COLUMNS, 20, "Arena should be 20 columns")
|
||||||
|
assert_eq(GauntletManager.ARENA_ROWS, 20, "Arena should be 20 rows")
|
||||||
|
assert_eq(GauntletManager.NPC_SIZE, 3, "NPC zone should be 3x3")
|
||||||
|
assert_eq(GauntletManager.NPC_CENTER, Vector2i(9, 9), "NPC center should be at (9,9)")
|
||||||
|
|
||||||
|
# Test 19: NPC zone detection works
|
||||||
|
func test_npc_zone_detection():
|
||||||
|
var manager = GauntletManager.new()
|
||||||
|
# Center of NPC zone
|
||||||
|
assert_true(manager._is_npc_zone(Vector2i(9, 9)), "Center (9,9) should be NPC zone")
|
||||||
|
# Edges of NPC zone
|
||||||
|
assert_true(manager._is_npc_zone(Vector2i(8, 8)), "Corner (8,8) should be NPC zone")
|
||||||
|
assert_true(manager._is_npc_zone(Vector2i(10, 10)), "Corner (10,10) should be NPC zone")
|
||||||
|
# Outside NPC zone
|
||||||
|
assert_false(manager._is_npc_zone(Vector2i(7, 9)), "Outside (7,9) should NOT be NPC zone")
|
||||||
|
assert_false(manager._is_npc_zone(Vector2i(11, 9)), "Outside (11,9) should NOT be NPC zone")
|
||||||
|
assert_false(manager._is_npc_zone(Vector2i(0, 0)), "Corner (0,0) should NOT be NPC zone")
|
||||||
|
manager.free()
|
||||||
|
|
||||||
|
# Test 20: Phase enum has 3 phases
|
||||||
|
func test_gauntlet_phases():
|
||||||
|
assert_eq(GauntletManager.Phase.OPEN_ARENA, 0, "OPEN_ARENA should be 0")
|
||||||
|
assert_eq(GauntletManager.Phase.ROUTE_PRESSURE, 1, "ROUTE_PRESSURE should be 1")
|
||||||
|
assert_eq(GauntletManager.Phase.SURVIVAL_ENDGAME, 2, "SURVIVAL_ENDGAME should be 2")
|
||||||
|
|
||||||
|
func after_all():
|
||||||
|
gut.p("=== Gauntlet Registration Tests Complete ===")
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
extends GutTest
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test: Gauntlet Tile Spawning & Mission System (Task #3)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
var GauntletManagerScript = load("res://scripts/managers/gauntlet_manager.gd")
|
||||||
|
var manager: GauntletManager
|
||||||
|
|
||||||
|
func before_each():
|
||||||
|
manager = GauntletManagerScript.new()
|
||||||
|
add_child(manager)
|
||||||
|
|
||||||
|
func after_each():
|
||||||
|
manager.queue_free()
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Arena Constants
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func test_arena_size_20x20():
|
||||||
|
assert_eq(manager.ARENA_COLUMNS, 20, "Arena should be 20 columns")
|
||||||
|
assert_eq(manager.ARENA_ROWS, 20, "Arena should be 20 rows")
|
||||||
|
|
||||||
|
func test_npc_center_position():
|
||||||
|
assert_eq(manager.NPC_CENTER, Vector2i(9, 9), "NPC center should be at (9,9)")
|
||||||
|
|
||||||
|
func test_npc_size_3x3():
|
||||||
|
assert_eq(manager.NPC_SIZE, 3, "NPC zone should be 3x3")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# NPC Zone Exclusion
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func test_npc_zone_center_is_excluded():
|
||||||
|
assert_true(manager._is_npc_zone(Vector2i(9, 9)), "Center (9,9) should be NPC zone")
|
||||||
|
|
||||||
|
func test_npc_zone_corners_are_excluded():
|
||||||
|
assert_true(manager._is_npc_zone(Vector2i(8, 8)), "Top-left (8,8) should be NPC zone")
|
||||||
|
assert_true(manager._is_npc_zone(Vector2i(10, 8)), "Top-right (10,8) should be NPC zone")
|
||||||
|
assert_true(manager._is_npc_zone(Vector2i(8, 10)), "Bottom-left (8,10) should be NPC zone")
|
||||||
|
assert_true(manager._is_npc_zone(Vector2i(10, 10)), "Bottom-right (10,10) should be NPC zone")
|
||||||
|
|
||||||
|
func test_outside_npc_zone_not_excluded():
|
||||||
|
assert_false(manager._is_npc_zone(Vector2i(7, 9)), "Left of NPC zone should NOT be excluded")
|
||||||
|
assert_false(manager._is_npc_zone(Vector2i(11, 9)), "Right of NPC zone should NOT be excluded")
|
||||||
|
assert_false(manager._is_npc_zone(Vector2i(9, 7)), "Above NPC zone should NOT be excluded")
|
||||||
|
assert_false(manager._is_npc_zone(Vector2i(9, 11)), "Below NPC zone should NOT be excluded")
|
||||||
|
|
||||||
|
func test_arena_corners_not_excluded():
|
||||||
|
assert_false(manager._is_npc_zone(Vector2i(0, 0)), "Top-left corner should be walkable")
|
||||||
|
assert_false(manager._is_npc_zone(Vector2i(19, 0)), "Top-right corner should be walkable")
|
||||||
|
assert_false(manager._is_npc_zone(Vector2i(0, 19)), "Bottom-left corner should be walkable")
|
||||||
|
assert_false(manager._is_npc_zone(Vector2i(19, 19)), "Bottom-right corner should be walkable")
|
||||||
|
|
||||||
|
func test_npc_zone_total_cells():
|
||||||
|
var npc_count = 0
|
||||||
|
for x in range(manager.ARENA_COLUMNS):
|
||||||
|
for z in range(manager.ARENA_ROWS):
|
||||||
|
if manager._is_npc_zone(Vector2i(x, z)):
|
||||||
|
npc_count += 1
|
||||||
|
assert_eq(npc_count, 9, "NPC zone should occupy exactly 9 cells (3x3)")
|
||||||
|
|
||||||
|
func test_walkable_cells_count():
|
||||||
|
# 20x20 = 400 total, minus 9 NPC = 391 walkable
|
||||||
|
var walkable = 400 - 9
|
||||||
|
assert_eq(walkable, 391, "Should have 391 walkable cells")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tile Constants
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func test_goal_tile_ids_valid():
|
||||||
|
# Heart(7), Diamond(8), Star(9), Coin(10) — match StopNGoManager
|
||||||
|
var goal_items = [7, 8, 9, 10]
|
||||||
|
for item in goal_items:
|
||||||
|
assert_gt(item, 0, "Goal tile ID %d should be positive" % item)
|
||||||
|
assert_lt(item, 17, "Goal tile ID %d should not conflict with sticky(17)" % item)
|
||||||
|
|
||||||
|
func test_tile_walkable_id():
|
||||||
|
assert_eq(manager.TILE_WALKABLE, 0, "Walkable tile should be ID 0")
|
||||||
|
|
||||||
|
func test_tile_obstacle_id():
|
||||||
|
assert_eq(manager.TILE_OBSTACLE, 4, "Obstacle tile should be ID 4")
|
||||||
|
|
||||||
|
func test_tile_sticky_id():
|
||||||
|
assert_eq(manager.TILE_STICKY, 17, "Sticky tile should be ID 17")
|
||||||
|
|
||||||
|
func test_tile_telegraph_id():
|
||||||
|
assert_eq(manager.TILE_TELEGRAPH, 18, "Telegraph tile should be ID 18")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Method Existence
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func test_setup_mission_tiles_exists():
|
||||||
|
assert_true(manager.has_method("setup_mission_tiles"), "Should have setup_mission_tiles()")
|
||||||
|
|
||||||
|
func test_spawn_mission_tiles_exists():
|
||||||
|
assert_true(manager.has_method("_spawn_mission_tiles"), "Should have _spawn_mission_tiles()")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Sticky Cell System
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func test_sticky_cells_initially_empty():
|
||||||
|
assert_eq(manager.sticky_cells.size(), 0, "Sticky cells should start empty")
|
||||||
|
|
||||||
|
func test_is_sticky_cell_false_for_clean():
|
||||||
|
assert_false(manager.is_sticky_cell(Vector2i(5, 5)), "Clean cell should not be sticky")
|
||||||
|
|
||||||
|
func test_is_sticky_cell_true_after_marking():
|
||||||
|
manager.sticky_cells[Vector2i(5, 5)] = true
|
||||||
|
assert_true(manager.is_sticky_cell(Vector2i(5, 5)), "Marked cell should be sticky")
|
||||||
|
|
||||||
|
func test_clear_sticky_cell():
|
||||||
|
manager.sticky_cells[Vector2i(3, 3)] = true
|
||||||
|
manager.clear_sticky_cell(Vector2i(3, 3))
|
||||||
|
assert_false(manager.is_sticky_cell(Vector2i(3, 3)), "Cleared cell should no longer be sticky")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Phase Interaction with Tile Spawning
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func test_initial_phase_is_open_arena():
|
||||||
|
assert_eq(manager.current_phase, GauntletManager.Phase.OPEN_ARENA, "Should start in Open Arena")
|
||||||
|
|
||||||
|
func test_phase_to_string_open_arena():
|
||||||
|
assert_eq(manager._phase_to_string(GauntletManager.Phase.OPEN_ARENA), "Open Arena")
|
||||||
|
|
||||||
|
func test_phase_to_string_route_pressure():
|
||||||
|
assert_eq(manager._phase_to_string(GauntletManager.Phase.ROUTE_PRESSURE), "Route Pressure")
|
||||||
|
|
||||||
|
func test_phase_to_string_survival():
|
||||||
|
assert_eq(manager._phase_to_string(GauntletManager.Phase.SURVIVAL_ENDGAME), "Survival!")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Match Timer Integration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func test_match_duration_180s():
|
||||||
|
# Gauntlet uses 180s match (3 phases: 0-60, 60-120, 120-180)
|
||||||
|
var total = manager.PHASE_3_START + 60.0 # Phase 3 starts at 120, runs 60s
|
||||||
|
assert_eq(total, 180.0, "Total match should be 180 seconds")
|
||||||
Reference in New Issue
Block a user