Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b4c9d9dcb | |||
| 00c9e85527 | |||
| 203175178f | |||
| 8573f8a85d | |||
| 6506e9f416 | |||
| 85cdd5e9d4 | |||
| 90e320ffac | |||
| f060a2f4b8 | |||
| 5cf08f249b | |||
| f407551d60 | |||
| 682c5925d3 | |||
| 5797197097 | |||
| 37e5d96b55 | |||
| 8410679890 | |||
| c5f691b861 | |||
| f39726a7db | |||
| 0836f4b8f0 | |||
| 8e15018e23 | |||
| 47ba6e2456 | |||
| 38d09a50e7 | |||
| 950b1969d8 | |||
| 286d0ce069 | |||
| 04e40f5b76 | |||
| 90ada5ace4 | |||
| ffbc3956ee | |||
| 6dae67fce4 | |||
| baeb2f6cfc | |||
| 7c3e55e904 | |||
| 4fd46af3c0 | |||
| c114dd5175 | |||
| b4ac0afda7 | |||
| 48b748849b | |||
| f1ef43cb38 | |||
| b879c7b684 | |||
| ce515365a7 | |||
| b86011a6c6 | |||
| 4bf9f7aee1 | |||
| d3322f14ad | |||
| adad53e94d | |||
| 14d46b8823 | |||
| acd927eef6 | |||
| af6870f6a9 | |||
| 968808df6d | |||
| 472c49944b | |||
| 5c789cb37f | |||
| 9747f91b20 | |||
| 7699146811 | |||
| a7a4ca68aa | |||
| 717e8cedae | |||
| b3a421bb55 | |||
| 783b3b5894 | |||
| 25231859b8 | |||
| 2289be515a | |||
| 3a40ee8c52 | |||
| e55ce154f7 | |||
| c054406e38 | |||
| 357d84e2ce | |||
| 528e22875d | |||
| 114748a54f |
@@ -1,164 +0,0 @@
|
||||
---
|
||||
description: This document tells AI agents how to work on Tekton Dash tasks end-to-end.
|
||||
---
|
||||
|
||||
# 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=36433be43b2980de8635000c0a910a0d
|
||||
|
||||
### 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,53 +0,0 @@
|
||||
# Pending Notion Tasks for core_coder
|
||||
|
||||
## Instructions
|
||||
Run these commands as `core_coder` profile to complete the 5 "In Progress" tasks:
|
||||
|
||||
```bash
|
||||
# Switch to core_coder profile
|
||||
hermes profile use core_coder
|
||||
|
||||
# Navigate to project
|
||||
cd /home/beng/Godot/Projects/tekton-enet
|
||||
|
||||
# Start interactive session with task context
|
||||
hermes chat -z "Complete the 5 'In Progress' tasks from Notion. Start with P0 task [Gauntlet] #4 Cannon Timer & Basic Volley (ID: 36833be4-3b29-812b-b25e-d087446c57f6). Follow TASKS.md procedure for each task."
|
||||
```
|
||||
|
||||
## Task List (Priority Order)
|
||||
|
||||
### 1. [P0] [Gauntlet] #4 Cannon Timer & Basic Volley
|
||||
- **Notion ID:** 36833be4-3b29-812b-b25e-d087446c57f6
|
||||
- **Type:** CORE
|
||||
- **Description:** Create candy_cannon_controller.gd. Implement 5-second interval cannon timer in GauntletManager._process(). Fire 5 shots per volley at random valid cells (1×1 only initially). Create candy_cannon.tscn (3×3 static NPC scene, is_static_turret=true pattern). Reuse tekton.gd spawn_projectile_rpc() arc-tween pattern for projectile visuals. Sync via rpc().
|
||||
|
||||
### 2. [P1] [036] Mode Config Completeness
|
||||
- **Notion ID:** 36433be4-3b29-8129-bbc9-c3f8437559a8
|
||||
- **Type:** CORE
|
||||
|
||||
### 3. [P1] [024] Set up GitHub Actions CI/CD Workflow
|
||||
- **Notion ID:** 36633be4-3b29-8179-87bf-ec462fb6629f
|
||||
- **Type:** DEVOPS
|
||||
|
||||
### 4. [P1] [032] Set up Artifact Storage & Versioning
|
||||
- **Notion ID:** 36633be4-3b29-812b-9537-f44fbf89c3f9
|
||||
- **Type:** DEVOPS
|
||||
|
||||
### 5. [P1] [031] Configure Platform-Specific Export Presets
|
||||
- **Notion ID:** 36633be4-3b29-8147-8180-eebc89a08dc4
|
||||
- **Type:** DEVOPS
|
||||
|
||||
## Workflow Per Task
|
||||
|
||||
1. Read task details: `mcp_notion_API_retrieve_a_page(page_id="...")`
|
||||
2. Implement changes following TASKS.md
|
||||
3. Write unit test in `tests/test_<feature>.gd`
|
||||
4. Run tests: `./run_tests.cmd` (Windows) or equivalent
|
||||
5. Update CHANGELOG_DRAFT.md (append to existing version block if changes exist)
|
||||
6. Mark Done: `mcp_notion_API_patch_page(page_id="...", properties={"Status": {"select": {"name": "Done"}}, "Acceptance": {"checkbox": true}})`
|
||||
7. Commit changes
|
||||
|
||||
## Notes
|
||||
- Project path: /home/beng/Godot/Projects/tekton-enet
|
||||
- Follow GUT_SETUP_SKILLS.md for testing
|
||||
- Version 2.3.7 already has uncommitted changes — append changelog entries, don't bump version
|
||||
@@ -3,7 +3,7 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -20,29 +20,29 @@ jobs:
|
||||
- name: Checkout Code
|
||||
run: |
|
||||
git config --global credential.helper store
|
||||
echo "http://god:${{ secrets.TEKTON_RELEASE_TOKEN }}@52.74.133.55:3000" > ~/.git-credentials
|
||||
git clone http://52.74.133.55:3000/danchie/tekton.git .
|
||||
echo "https://god:${{ secrets.TEKTON_RELEASE_TOKEN }}@git.klud.top" > ~/.git-credentials
|
||||
git clone https://git.klud.top/danchie/tekton.git .
|
||||
git checkout $TAG_NAME
|
||||
|
||||
- name: Setup Godot (Cached)
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq unzip curl
|
||||
if [ ! -f /cache/godot_4.7 ]; then
|
||||
echo "Downloading Godot 4.7..."
|
||||
curl -sL -o /tmp/godot.zip "https://github.com/godotengine/godot-builds/releases/download/4.7-stable/Godot_v4.7-stable_linux.x86_64.zip"
|
||||
if [ ! -f /cache/godot_4.6.3 ]; then
|
||||
echo "Downloading Godot 4.6.3..."
|
||||
curl -sL -o /tmp/godot.zip "https://github.com/godotengine/godot-builds/releases/download/4.6.3-stable/Godot_v4.6.3-stable_linux.x86_64.zip"
|
||||
unzip -q -o /tmp/godot.zip -d /cache/
|
||||
mv /cache/Godot_v4.7-stable_linux.x86_64 /cache/godot_4.7
|
||||
mv /cache/Godot_v4.6.3-stable_linux.x86_64 /cache/godot_4.6.3
|
||||
fi
|
||||
cp /cache/godot_4.7 /usr/local/bin/godot
|
||||
cp /cache/godot_4.6.3 /usr/local/bin/godot
|
||||
chmod +x /usr/local/bin/godot
|
||||
mkdir -p ~/.local/share/godot/export_templates/4.7.stable
|
||||
if [ ! -f /cache/Godot_v4.7-stable_export_templates.tpz ]; then
|
||||
mkdir -p ~/.local/share/godot/export_templates/4.6.3.stable
|
||||
if [ ! -f /cache/Godot_v4.6.3-stable_export_templates.tpz ]; then
|
||||
echo "Downloading templates..."
|
||||
curl -sL -o /cache/Godot_v4.7-stable_export_templates.tpz \
|
||||
"https://github.com/godotengine/godot-builds/releases/download/4.7-stable/Godot_v4.7-stable_export_templates.tpz"
|
||||
curl -sL -o /cache/Godot_v4.6.3-stable_export_templates.tpz \
|
||||
"https://github.com/godotengine/godot-builds/releases/download/4.6.3-stable/Godot_v4.6.3-stable_export_templates.tpz"
|
||||
fi
|
||||
cd ~/.local/share/godot/export_templates/4.7.stable
|
||||
unzip -q -o /cache/Godot_v4.7-stable_export_templates.tpz
|
||||
cd ~/.local/share/godot/export_templates/4.6.3.stable
|
||||
unzip -q -o /cache/Godot_v4.6.3-stable_export_templates.tpz
|
||||
mv templates/* .
|
||||
rm -rf templates
|
||||
cd $GITHUB_WORKSPACE
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
- name: Create Gitea Release
|
||||
run: |
|
||||
set -e
|
||||
API="http://52.74.133.55:3000/api/v1/repos/danchie/tekton/releases"
|
||||
API="https://git.klud.top/api/v1/repos/danchie/tekton/releases"
|
||||
TAG="$TAG_NAME"
|
||||
echo "Checking existing release for $TAG..."
|
||||
RELEASE_JSON=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$API/tags/$TAG" 2>/dev/null || echo "")
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F "attachment=@build/tekton_armageddon_windows_${TAG_NAME}.zip" \
|
||||
"http://52.74.133.55:3000/api/v1/repos/danchie/tekton/releases/$RELEASE_ID/assets"
|
||||
"https://git.klud.top/api/v1/repos/danchie/tekton/releases/$RELEASE_ID/assets"
|
||||
echo "Windows uploaded"
|
||||
|
||||
- name: Upload Linux asset
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F "attachment=@build/tekton_armageddon_linux_${TAG_NAME}.zip" \
|
||||
"http://52.74.133.55:3000/api/v1/repos/danchie/tekton/releases/$RELEASE_ID/assets"
|
||||
"https://git.klud.top/api/v1/repos/danchie/tekton/releases/$RELEASE_ID/assets"
|
||||
echo "Linux uploaded"
|
||||
|
||||
- name: Upload macOS asset
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F "attachment=@build/tekton_armageddon_macos_${TAG_NAME}.zip" \
|
||||
"http://52.74.133.55:3000/api/v1/repos/danchie/tekton/releases/$RELEASE_ID/assets"
|
||||
"https://git.klud.top/api/v1/repos/danchie/tekton/releases/$RELEASE_ID/assets"
|
||||
echo "macOS uploaded"
|
||||
else
|
||||
echo "macOS asset not built, skipping"
|
||||
@@ -150,5 +150,5 @@ jobs:
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"draft":false}' \
|
||||
"http://52.74.133.55:3000/api/v1/repos/danchie/tekton/releases/$RELEASE_ID"
|
||||
"https://git.klud.top/api/v1/repos/danchie/tekton/releases/$RELEASE_ID"
|
||||
echo "Published: https://git.klud.top/danchie/tekton/releases/tag/$TAG_NAME"
|
||||
|
||||
@@ -4,11 +4,11 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Patch version (e.g., 2.4.2)'
|
||||
description: "Patch version (e.g., 2.4.4)"
|
||||
required: true
|
||||
type: string
|
||||
notes:
|
||||
description: 'Release notes'
|
||||
description: "Release notes"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
@@ -22,20 +22,20 @@ jobs:
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.TEKTON_RELEASE_TOKEN }}
|
||||
run: |
|
||||
git clone --depth 1 http://god:$GITEA_TOKEN@52.74.133.55:3000/danchie/tekton.git .
|
||||
git clone --depth 1 https://god:$GITEA_TOKEN@git.klud.top/danchie/tekton.git .
|
||||
git config user.name "god"
|
||||
git config user.email "god@noreply.git.klud.top"
|
||||
|
||||
- name: Setup Godot (Cached)
|
||||
run: |
|
||||
if [ ! -f /cache/godot_4.7 ]; then
|
||||
echo "Downloading Godot 4.7..."
|
||||
if [ ! -f /cache/godot_4.6.3 ]; then
|
||||
echo "Downloading Godot 4.6.3..."
|
||||
apt-get update -qq && apt-get install -y -qq unzip curl
|
||||
curl -sL -o /tmp/godot.zip "https://github.com/godotengine/godot-builds/releases/download/4.7-stable/Godot_v4.7-stable_linux.x86_64.zip"
|
||||
curl -sL -o /tmp/godot.zip "https://github.com/godotengine/godot-builds/releases/download/4.6.3-stable/Godot_v4.6.3-stable_linux.x86_64.zip"
|
||||
unzip -q -o /tmp/godot.zip -d /cache/
|
||||
mv /cache/Godot_v4.7-stable_linux.x86_64 /cache/godot_4.7
|
||||
mv /cache/Godot_v4.6.3-stable_linux.x86_64 /cache/godot_4.6.3
|
||||
fi
|
||||
cp /cache/godot_4.7 /usr/local/bin/godot
|
||||
cp /cache/godot_4.6.3 /usr/local/bin/godot
|
||||
chmod +x /usr/local/bin/godot
|
||||
mkdir -p build
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
git init
|
||||
git config user.name "god"
|
||||
git config user.email "god@noreply.git.klud.top"
|
||||
git remote add origin http://god:$GITEA_TOKEN@52.74.133.55:3000/danchie/tekton.git
|
||||
git remote add origin https://god:$GITEA_TOKEN@git.klud.top/danchie/tekton.git
|
||||
git checkout -b patches
|
||||
git add .
|
||||
git commit -m "patch ${{ github.event.inputs.version }}"
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
## [NEXT]
|
||||
- Changed Candy Survival knock from instant-ambush to **toggle-and-walk mode** like other game modes: press Q to toggle Knock Mode ON (red glow), then walk into a target to execute the knock. Press Q again to cancel. Consumed on use. Bots auto-toggle when they have charges.
|
||||
- Fixed `Attempt to call RPC with unknown peer ID: 5` crash in Candy Survival when updating ghost inventory or candy stacks for AI bots or unverified peers.
|
||||
- Added +100 score rewards in Candy Survival when completing goals by collecting tiles, successfully landing a KNOCK, or tactically activating GHOST.
|
||||
- Updated Candy Survival knocking animation to match other game modes (`attack_mode` SFX, bump animation, 3-tile knockback along path, and stun stagger) while keeping the Candy Survival ruleset: successful knock steals all target's candies and knocks target back; knocking a target with 0 candies backfires, knocking the attacker back 3 tiles and transferring attacker's candies to target.
|
||||
- Fixed profile panel (`SaveNameBtn`) failing when updating username on connected peers by passing `null` for login username so Nakama only updates `display_name` without unique username collisions.
|
||||
- Preserved detailed profile update failure messages in ProfilePanel UI status label and persisted display name changes to storage.
|
||||
|
||||
## [2.4.6] — 2026-07-09
|
||||
- Fixed NakamaMultiplayerBridge dictionary lookup crash when receiving match state before user presence is populated by creating a `SafeNakamaMultiplayerBridge` subclass.
|
||||
|
||||
## [2.4.5] — 2026-07-09
|
||||
- Fixed missing `player_goals` dictionary declaration in GoalsCycleManager.
|
||||
- Improved Candy Survival manager sync and LobbyManager initialization.
|
||||
- Updated EnhancedGridMap and Main scene synchronization.
|
||||
|
||||
## [2.4.4] — 2026-07-09
|
||||
- Fixed Candy Survival Knock mechanic: pressing Q now properly knocks adjacent players diagonally and cardinally.
|
||||
- Implemented Candy Survival Knock backfire mechanic: knocking a player with 0 candies transfers your candy stack to them and staggers you.
|
||||
- Fixed Candy Survival candy stack 3D mesh visual desync and client-side stack synchronization.
|
||||
- Fixed playerboard desync where host-side board changes weren't reflected on remote clients in multiplayer sessions.
|
||||
- Fixed punch/smack SFX playing repeatedly when quickly attacking another player.
|
||||
- Fixed currency split between Gacha and Shop — wallet balance now updates immediately after purchase without needing to reopen the panel.
|
||||
|
||||
@@ -1,20 +1,156 @@
|
||||
# Tekton Game - TODO
|
||||
# Candy Survival: Complete Implementation & Architecture Log
|
||||
|
||||
## Bugs to fix (checked 2026-07-04)
|
||||
This document serves as an exhaustive, technical record of all modifications, refactors, and features implemented during the transition from the legacy "Gauntlet" mode to the new "Candy Survival" mode.
|
||||
|
||||
- [x] **Playerboard desync** — doesn't refresh correctly, stuck (fixed sync ready order)
|
||||
- [x] **Multiple punch SFX** — sounds play more than once per punch (fixed charged strike state clearance)
|
||||
- [x] **Currency not shared** — gacha wallet and shop wallet are separate (fixed UI state sync)
|
||||
- [x] **Multiplayer gauntlet mode broken** — single player works, multiplayer doesn't (fixed client crash from bad indentation)
|
||||
---
|
||||
|
||||
## In Progress
|
||||
## 1. Project-Wide Renaming & Metadata Scrubbing
|
||||
- **Global Find & Replace**: Executed across `scripts/`, `scenes/`, `tests/`, and `addons/`.
|
||||
- Replaced `GAUNTLET`, `GauntletManager`, `gauntlet_manager`, and `is_gauntlet_mode()`.
|
||||
- Replaced UI and enum display strings `"Gauntlet Arena"` → `"Candy Survival Arena"`.
|
||||
- **File System Refactoring**:
|
||||
- `git mv scripts/managers/gauntlet_manager.gd scripts/managers/candy_survival_manager.gd`
|
||||
- `git mv scripts/controllers/candy_cannon_controller.gd scripts/controllers/candy_survival_npc_controller.gd`
|
||||
- `git mv scenes/candy_cannon.tscn scenes/candy_survival_npc.tscn`
|
||||
- `git mv scenes/gauntlet_hud.tscn scenes/candy_survival_hud.tscn`
|
||||
- `git mv scenes/arena/gauntlet.tscn scenes/arena/candy_survival.tscn`
|
||||
- **Asset Scrubbing (`assets/models/arena/candy_survival/`)**:
|
||||
- Renamed the directory and all interior files (e.g., `Gauntlet terrain.gltf` → `candy_survival_terrain.gltf`).
|
||||
- Purged buried metadata: Modified `.import` and `.gltf` text structures to remove cached `"res://assets/models/arena/gauntlet/..."` paths to prevent headless Godot build corruption.
|
||||
- Updated legacy entries in `version.json` for historical consistency.
|
||||
|
||||
- [ ] CI release pipeline (`docker network connect` approach pending verification)
|
||||
---
|
||||
|
||||
## Done
|
||||
## 2. Arena Layout & Sticky Collision Overhaul
|
||||
- **Grid Properties**: Enforced strict `18x18` playable bounds via `_setup_arena()`.
|
||||
- **Sticky Mechanics Rewritten**:
|
||||
- *Legacy*: Sticky cells grew dynamically, gave telegraph warnings, and trapped players requiring a "cleanser".
|
||||
- *New*: `TILE_STICKY` now acts exclusively as static, pure-collision boundary walls. Placed solely on the arena perimeter and the center 3x3 NPC zone to block entry.
|
||||
- Movement check in `player_movement_manager.gd` strictly evaluates `if gm.is_sticky_cell(grid_position)` and rejects the move outright unless the player possesses the `is_invisible` (Ghost) buff.
|
||||
- **Spawn Point Correction**: Shifted `get_spawn_points()` coordinates from `row 1` to `row 2` and `row 15` to `row 16` (`(2,2), (16,2), (2,16), (16,16)`) to prevent players from spawning inside stairs/geometry.
|
||||
|
||||
- [x] Cleanup test runs 96-99
|
||||
- [x] Remove test tag `v9.9.9-test`
|
||||
- [x] Remove orphan Docker containers/networks/volumes
|
||||
- [x] Prune dangling images (~7.7GB reclaimed)
|
||||
- [x] Tailscale status check (peer traffic works; coordination-server sync is the only red)
|
||||
---
|
||||
|
||||
## 3. Mekton NPC (Center Boss Entity)
|
||||
- **Mesh Replacement**: Scrapped the primitive Cylinder/Sphere tank meshes in `candy_survival_npc.tscn`. Instanced `static_tekton_mesh.tscn` as the visual representation.
|
||||
- **Face Color Cycling**:
|
||||
- Engineered `set_face_color_rpc(color_index: int)` inside `candy_survival_npc_controller.gd`.
|
||||
- Dynamically extracts the surface material from the `ted_body` skeleton node.
|
||||
- Duplicates the material at runtime and applies emission glow and albedo tints corresponding to the `CandyColor` enum (0: Heart/Red, 1: Diamond/Blue, 2: Star/Yellow, 3: Coin/Orange).
|
||||
- **Manager Authority**: `candy_survival_manager.gd` tracks `face_timer`. Every 5.0 seconds (`COLOR_CYCLE_TIME`), it increments `current_face = CandyColor.values()[(current_face + 1) % CandyColor.size()]` and fires the RPC to visually sync the Mekton across all clients.
|
||||
|
||||
---
|
||||
|
||||
## 4. Gameplay Loop: Blueprints & Stacks
|
||||
- **Auto-Pickup Integration**:
|
||||
- Hooked into `player_movement_manager.gd` -> `_on_movement_finished()`.
|
||||
- If `LobbyManager.game_mode == "Candy Survival"`, automatically calls `player.grab_item(player.current_position)` when stepping on a tile, assuming the `playerboard_manager` has an empty slot. Completely removes the need for manual button presses during high-speed building.
|
||||
- **3x3 Blueprint Detection Engine**:
|
||||
- Overhauled `_check_goal_completion()` in `playerboard_manager.gd`.
|
||||
- Scans the 5x5 player board for *any* contiguous, fully-populated 3x3 block.
|
||||
- Calculates the majority color (`primary_color`) within that block.
|
||||
- **Off-Color Validation**:
|
||||
- If the 3x3 block is mixed (off-color), it queries the global `gridmap` via `CandySurvivalManager._grid_has_color_tiles(primary_tile_id)`.
|
||||
- If the primary color is mathematically extinct on the physical arena floor, the blueprint is accepted for 50% points. Otherwise, it is rejected.
|
||||
- **Stacking Mechanics & Multipliers**:
|
||||
- `on_blueprint_completed` invokes `_give_candy(pid, color)`.
|
||||
- Tracks `player_candies` (height) and `player_candy_color`.
|
||||
- Multiplier scales dynamically: `1.0 + (Stack Count * MULTI_STEP [0.1])`. Generates continuous passive points per second evaluated in `_process`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Gameplay Loop: Delivery & Sugar Rush
|
||||
- **Proximity Delivery Trigger**:
|
||||
- Built into `player_movement_manager.gd`. During movement, it evaluates Manhattan distance to the center NPC (`abs(x - 8) + abs(y - 8)`).
|
||||
- If distance is `<= 2`, the client fires `gm.try_deliver(pid)` via RPC to the server.
|
||||
- **Face Matching Logic**:
|
||||
- Server evaluates `if player_candy_color == current_face`.
|
||||
- Rejects if the player has 0 candies or colors mismatch.
|
||||
- **Sugar Rush Global Event**:
|
||||
- Successful delivery adds to `player_sugar_rush[pid]` with the formula: `SR_BASE_TIME [2.0s] + (candies * SR_PER_CANDY [0.4s])`.
|
||||
- While *any* player's rush timer is `> 0`, the manager forces `Engine.time_scale = SR_SPEED [2.0]`. When all timers reach 0, it restores to `1.0`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sabotage: Knock & Ghost Dynamics
|
||||
- **Knock (Steal/Backfire)**:
|
||||
- Input bound natively to `action_knock_tekton` inside `player_input_manager.gd`.
|
||||
- Fires `try_knock(attacker, target)` when adjacent to an opponent.
|
||||
- Consumes 1 of 5 starting charges (`player_knocks`).
|
||||
- *Steal*: If target has candies, attacker adopts target's full stack and color; target is zeroed.
|
||||
- *Backfire*: If target has 0 candies, attacker loses a charge and gets staggered, while any candy stack held by the attacker is transferred to the target.
|
||||
- **Ghost (Phase-Through)**:
|
||||
- Input bound natively to `action_grab_tekton` inside `player_input_manager.gd` (bypasses default grab behavior in this mode).
|
||||
- Consumes 1 of 5 starting charges (`player_ghosts`).
|
||||
- `try_activate_ghost` sets the core `player.is_invisible = true` and `sync_modulate(Color(1, 1, 1, 0.4))` for 4.0 seconds.
|
||||
- The `is_invisible` state natively bypasses the sticky collision checks, allowing phase-through of boundaries/boss zones, and renders the player immune to incoming Knocks.
|
||||
|
||||
---
|
||||
|
||||
## 7. Visuals, UI & 3D Stacking
|
||||
- **HUD De-Scripting**:
|
||||
- Legacy `gauntlet_manager` instantiated UI elements via GDScript. This was purged.
|
||||
- Created `candy_survival_hud.tscn` using pure Godot Control nodes (VBoxContainer, HBoxContainer, ProgressBar, Label). Exposes all UI to artists for layout/theme modifications without code risk.
|
||||
- Manager simply grabs node references (`hud_rush_bar`, `hud_stack_badge`, etc.) on `_ready()`.
|
||||
- **Dynamic Delivery Indicator**:
|
||||
- `sync_candy_badge` RPC was expanded to include a `face_match` boolean.
|
||||
- When the Mekton's face color changes (every 5s), the server force-updates all clients.
|
||||
- The HUD dynamically shifts `DeliveryIndicator.text` to a Green `"READY TO DELIVER!"` or Red `"Waiting for match..."` based on their currently held candy stack.
|
||||
- **Physical 3D Candy Stacking**:
|
||||
- Implemented `sync_candy_stack(count, color_id)` directly on `player.gd`.
|
||||
- Creates a generic `Node3D` on the player's `Visuals` node.
|
||||
- Instantiates raw `MeshInstance3D` tiles (`tile_heart.tres`, `tile_diamond.tres`, etc.).
|
||||
- Visually stacks them vertically (`y = i * 0.4`) and scales them down (`0.5x`) corresponding exactly to the player's internal candy count. Clears them instantly upon delivery or being knocked.
|
||||
|
||||
---
|
||||
|
||||
## 8. AI & Bot Behaviors
|
||||
- **Delivery Pathfinding (Priority 0.7)**:
|
||||
- Injected specialized logic into `bot_controller.gd`.
|
||||
- Bots actively check their held `player_candy_color` against the manager's `current_face`.
|
||||
- When colors match, the Bot halts blueprint construction and immediately uses `find_path` targeting `Vector2i(8,8)` to execute a delivery and trigger Sugar Rush.
|
||||
- **Ghost Survival Logic**:
|
||||
- Re-wrote `_bot_has_ghost_powerup()` in `bot_strategic_planner.gd` to evaluate the new native `player_ghosts` charge counter instead of the legacy `SpecialTilesManager` inventory.
|
||||
- Bots correctly evaluate proximity to sticky walls and automatically fire `try_activate_ghost` when cornered to phase out of traps.
|
||||
|
||||
---
|
||||
|
||||
## 9. Code Health & QA
|
||||
- **Orphan Code Purge**: Deleted 8 specific `test_gauntlet_*.gd` files that referenced non-existent legacy mechanics (growth algorithms, bubbles, floor highlights, old cleansers).
|
||||
- **Compile Integrity**: Fixed a trailing `_on_doors_update()` call in `lobby_room.gd` leftover from a different branch. Verified flawless compilation via `godot --headless --build-solutions`.
|
||||
- **Gitea Documentation**: Automated the closure and deep-technical documentation of Issues #54, 55, 56, 57, 65, 66, 67, 68, 69, and 70 directly to the tracking server.
|
||||
- **Wiki Accuracy**: Overhauled `Game-Modes.-.md` to perfectly mirror the new Sabotage input maps, Blueprint rules, RPC structures, and Mekton behavior.
|
||||
|
||||
### Completed (July 6, 2026)
|
||||
- [x] **CI Pipeline Fixes**
|
||||
- [x] Downgraded Godot runner version to 4.6.3 to match local engine build.
|
||||
- [x] Updated Gitea API and Git clone URLs to use `https://` instead of `http://` to prevent silent redirect failures during release.
|
||||
- [x] **Runtime & Compilation Crash Fixes**
|
||||
- [x] Fixed `bot_controller.gd`: Replaced dead `_execute_move()` with proper `enhanced_gridmap.find_path()` logic.
|
||||
- [x] Fixed `bot_controller.gd` & `player_movement_manager.gd`: Changed `.is_active` to `.active` for CandySurvivalManager checks.
|
||||
- [x] Fixed `candy_survival_manager.gd`: Merged duplicate `start_game_mode()` implementations.
|
||||
- [x] Fixed `special_tiles_manager.gd` indentation errors.
|
||||
- [x] Fixed `main.gd` duplicate variable declarations.
|
||||
- [x] **Editor & Test Environment Fixes**
|
||||
- [x] Resolved "Cyclic Reference" errors in `leaderboard_panel.gd` by accessing Nakama session via `get()`.
|
||||
- [x] Fixed `BackendService.gd` Autoload dependencies failing in isolated tests by using `get_node_or_null()`.
|
||||
- [x] **Gacha System Fixes**
|
||||
- [x] Corrected `BackendService.gd` payload to send `"banner_id"` instead of `"gacha_id"`, fixing HTTP 500 errors on pull.
|
||||
- [x] Configured Unique Names (`%`) for left-side BalanceRow labels in `gacha_panel.tscn` so currency updates instantly upon rolling.
|
||||
- [x] **3D Arena & GridMap Elevation Fixes**
|
||||
- [x] Normalized root node transforms of all arenas (`freemode.tscn`, `stop_n_go.scn`, `candy_survival.tscn`) to `(0,0,0)` and scale `(1,1,1)`.
|
||||
- [x] Removed hardcoded terrain shifts and `0.08` mesh elevations from `main.gd`.
|
||||
- [x] Added `EditorTileReference` to arena scenes as a perfect 1:1 footprint guide.
|
||||
- [x] Baked terrain height shifts directly into `.tscn` files to match the reference tile perfectly.
|
||||
- [x] Scaled red `non-walkable` blocks (Tile 4) to 0 so they act as invisible collision walls in 3D arenas.
|
||||
- [x] Fixed Stop N Go safe zone clipping by adjusting render priorities (`1` for safe zone, `-1` for baked terrain shadows).
|
||||
- [x] **Candy Survival Multiplayer Client Arena & NPC Desync Fixes**
|
||||
- [x] Fixed client-side arena desync where `CandySurvivalManager._apply_arena_setup()` was empty (`pass`), leaving clients without the 18x18 Floor 0 grid or sticky boundary walls.
|
||||
- [x] Added `@rpc("authority", "call_remote", "reliable") func sync_arena_setup()` and updated `_apply_arena_setup()` to construct the 18x18 arena floor, apply sticky walls, and deterministically spawn `CandySurvivalNpc` (`mekton_node`) across both host and clients.
|
||||
- [x] Synced `sync_arena_setup` during `sync_state_to_player(peer_id)` so late-joining or resyncing clients receive the full arena structure and NPC node before incoming state RPCs.
|
||||
- [x] Added `can_rpc()` connectivity checks before sending RPCs (`set_face_color_rpc`, `sync_bump`, `apply_stagger`) to prevent remote packet errors or client drops.
|
||||
- [x] **Game Mode Switching Lag & Arena Desync Fixes**
|
||||
- [x] Fixed `Engine.time_scale` persistence: Ensured `Engine.time_scale = 1.0` is restored in `Main._ready()`, `CandySurvivalManager._exit_tree()`, and `LobbyManager.leave_room()` so high-speed scales (`SR_SPEED`) do not carry over across scene transitions or cause physics stutter when switching game modes.
|
||||
- [x] Fixed `EnhancedGridMap` auto-randomization conflict: Updated safety check so `EnhancedGridMap._ready()` skips auto-randomizing when `LobbyManager.game_mode` is either `"Stop n Go"` or `"Candy Survival"`, avoiding unnecessary 14x14 generation before game mode managers set up custom arena bounds.
|
||||
- [x] Fixed `sync_full_grid_data()` client-side arena setup: Added `"Candy Survival"` check so clients reapply `candy_survival_manager._apply_arena_setup()` prior to syncing Floor 1 items instead of overwriting Floor 0 with 14x14 Freemode tiles.
|
||||
- [x] Fixed `GoalsCycleManager` process persistence: Added `reset()` to stop `_process` execution and clean up active cycle/match state when leaving rooms or returning to lobby.
|
||||
|
||||
@@ -55,8 +55,7 @@ func _ready():
|
||||
# Safety check: Don't auto-randomize if game mode manages its own arena
|
||||
if not (ResourceLoader.exists("res://scripts/managers/lobby_manager.gd") \
|
||||
and get_node_or_null("/root/LobbyManager") \
|
||||
and (get_node("/root/LobbyManager").game_mode == "Stop n Go" \
|
||||
or get_node("/root/LobbyManager").game_mode == "Tekton Doors")):
|
||||
and (get_node("/root/LobbyManager").game_mode in ["Stop n Go", "Candy Survival"])):
|
||||
randomize_grid()
|
||||
validate_item_indices()
|
||||
|
||||
|
||||
@@ -1,7 +1,47 @@
|
||||
{
|
||||
"latest_version": "2.4.3",
|
||||
"latest_version": "2.4.6",
|
||||
"minimum_app_version": "2.1.0",
|
||||
"releases": [
|
||||
{
|
||||
"version": "2.4.6",
|
||||
"date": "2026-07-09",
|
||||
"pck_url": "https://git.klud.top/danchie/tekton/raw/branch/patches/patch.pck",
|
||||
"pck_size": 0,
|
||||
"changelog": [
|
||||
"Fixed NakamaMultiplayerBridge dictionary lookup crash when receiving match state before user presence is populated by creating a `SafeNakamaMultiplayerBridge` subclass."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.4.5",
|
||||
"date": "2026-07-09",
|
||||
"pck_url": "https://git.klud.top/danchie/tekton/raw/branch/patches/patch.pck",
|
||||
"pck_size": 0,
|
||||
"changelog": [
|
||||
"Fixed missing `player_goals` dictionary declaration in GoalsCycleManager.",
|
||||
"Improved Candy Survival manager sync and LobbyManager initialization.",
|
||||
"Updated EnhancedGridMap and Main scene synchronization."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.4.4",
|
||||
"date": "2026-07-09",
|
||||
"pck_url": "https://git.klud.top/danchie/tekton/raw/branch/patches/patch.pck",
|
||||
"pck_size": 0,
|
||||
"changelog": [
|
||||
"Fixed Candy Survival Knock mechanic: pressing Q now properly knocks adjacent players diagonally and cardinally.",
|
||||
"Implemented Candy Survival Knock backfire mechanic: knocking a player with 0 candies transfers your candy stack to them and staggers you.",
|
||||
"Fixed Candy Survival candy stack 3D mesh visual desync and client-side stack synchronization.",
|
||||
"Fixed playerboard desync where host-side board changes weren't reflected on remote clients in multiplayer sessions.",
|
||||
"Fixed punch/smack SFX playing repeatedly when quickly attacking another player.",
|
||||
"Fixed currency split between Gacha and Shop \u2014 wallet balance now updates immediately after purchase without needing to reopen the panel.",
|
||||
"Fixed fatal crash in Multiplayer Gauntlet caused by missing `has_method` check on smack cooldown timers on remote peers.",
|
||||
"Upgraded engine from Godot 4.6 to 4.7 stable for better performance and stability.",
|
||||
"Migrated patch delivery from GitHub Pages to Gitea native raw endpoint \u2014 no more external dependencies for game updates.",
|
||||
"CI: optimized build cache for Godot binary, eliminating repeated 140MB downloads on every workflow run.",
|
||||
"CI: shallow repository checkout (--depth 1) for faster clone times.",
|
||||
"CI: removed unnecessary export template download from patch deployment workflow (--export-pack doesn't need them)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.4.3",
|
||||
"date": "2026-07-04",
|
||||
@@ -11,7 +51,7 @@
|
||||
"Fixed multiplayer desync issue where the playerboard UI would get stuck and not refresh properly.",
|
||||
"Fixed a bug where attacking or pushing another player would cause the smack/punch sound effect to play repeatedly.",
|
||||
"Fixed an issue where the Gacha panel's currency balance would not update immediately after purchasing currency in the Shop.",
|
||||
"Fixed a fatal client crash in Multiplayer Gauntlet mode caused by smack cooldown timers."
|
||||
"Fixed a fatal client crash in Multiplayer Candy Survival mode caused by smack cooldown timers."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -20,11 +60,11 @@
|
||||
"pck_url": "https://raw.githubusercontent.com/adtpdn/tekton-updates/main/latest/patch.pck",
|
||||
"pck_size": 0,
|
||||
"changelog": [
|
||||
"Replaced Cleanser mechanic in Gauntlet with Ghost powerup sticky-bypass system.",
|
||||
"Ghost (Invisible Mode) now lets players walk through sticky candy tiles in Gauntlet.",
|
||||
"Players earn a Ghost powerup every 2 completed missions in Gauntlet.",
|
||||
"Ghost powerup tiles now spawn naturally on the Gauntlet arena (15% chance).",
|
||||
"Removed Cleanser HUD elements from Gauntlet overlay.",
|
||||
"Replaced Cleanser mechanic in Candy Survival with Ghost powerup sticky-bypass system.",
|
||||
"Ghost (Invisible Mode) now lets players walk through sticky candy tiles in Candy Survival.",
|
||||
"Players earn a Ghost powerup every 2 completed missions in Candy Survival.",
|
||||
"Ghost powerup tiles now spawn naturally on the Candy Survival arena (15% chance).",
|
||||
"Removed Cleanser HUD elements from Candy Survival overlay.",
|
||||
"Bots now activate Ghost powerup when boxed in by sticky tiles.",
|
||||
"Players pushed into sticky tiles while in Ghost mode are no longer slowed."
|
||||
]
|
||||
@@ -35,15 +75,15 @@
|
||||
"pck_url": "https://raw.githubusercontent.com/adtpdn/tekton-updates/main/latest/patch.pck",
|
||||
"pck_size": 0,
|
||||
"changelog": [
|
||||
"Fixed Gauntlet map layout to remove red unpassable barrier blocks and center blocks.",
|
||||
"Fixed Gauntlet mode to prevent powerups or sticky bubbles from spawning on boundary tiles or under the central cannon.",
|
||||
"Fixed Candy Survival map layout to remove red unpassable barrier blocks and center blocks.",
|
||||
"Fixed Candy Survival mode to prevent powerups or sticky bubbles from spawning on boundary tiles or under the central cannon.",
|
||||
"Center Candy Cannon now shoots actual projectiles that fly towards sticky cells and leave a VFX trail.",
|
||||
"Added new VFX to the Center Candy Cannon. It now has a glowing pink tank and spinning metallic rings.",
|
||||
"Fixed Gauntlet Cleanser to stack charges instead of capping at 1.",
|
||||
"Fixed Candy Survival Cleanser to stack charges instead of capping at 1.",
|
||||
"Cleanser instantly clears a 3x3 AoE of sticky cells and frees any players inside immediately upon activation.",
|
||||
"Added VFX and SFX when purifying cells with the Cleanser (cyan burst particles).",
|
||||
"Added instant visual feedback indicator for Gauntlet Cleanser using popup text when consumed.",
|
||||
"Fixed Gauntlet Cleanser UI phase label layout to ensure it does not overlap with other UI elements."
|
||||
"Added instant visual feedback indicator for Candy Survival Cleanser using popup text when consumed.",
|
||||
"Fixed Candy Survival Cleanser UI phase label layout to ensure it does not overlap with other UI elements."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -52,7 +92,7 @@
|
||||
"pck_url": "https://raw.githubusercontent.com/adtpdn/tekton-updates/main/latest/patch.pck",
|
||||
"pck_size": 0,
|
||||
"changelog": [
|
||||
"Rebuilt Gauntlet game mode with new wave-based mechanics and arena redesign",
|
||||
"Rebuilt Candy Survival game mode with new wave-based mechanics and arena redesign",
|
||||
"Added freeze-area VFX \u2014 freeze powerup now shows visible icy floor spread",
|
||||
"Added block-wall VFX \u2014 blocked tiles now display visible barrier effect",
|
||||
"Added playerboard scatter VFX \u2014 Stop n Go penalty shows shake effect",
|
||||
|
||||
@@ -5675,7 +5675,7 @@
|
||||
"buffers":[
|
||||
{
|
||||
"byteLength":3892696,
|
||||
"uri":"Gauntlet%20terrain.bin"
|
||||
"uri":"candy_survival_terrain.bin"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,12 +4,12 @@ importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://h84tka1n1alg"
|
||||
path="res://.godot/imported/Gauntlet terrain.gltf-2ceb7292a7914d9403e396ecc5323cc2.scn"
|
||||
path="res://.godot/imported/candy_survival_terrain.gltf-bd09dc0582dcbe3b83caf8701d640442.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/arena/gauntlet/Gauntlet terrain.gltf"
|
||||
dest_files=["res://.godot/imported/Gauntlet terrain.gltf-2ceb7292a7914d9403e396ecc5323cc2.scn"]
|
||||
source_file="res://assets/models/arena/candy_survival/candy_survival_terrain.gltf"
|
||||
dest_files=["res://.godot/imported/candy_survival_terrain.gltf-bd09dc0582dcbe3b83caf8701d640442.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
|
Before Width: | Height: | Size: 820 KiB After Width: | Height: | Size: 820 KiB |
@@ -3,8 +3,8 @@
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dtvc1ecp4qspf"
|
||||
path.s3tc="res://.godot/imported/decoration_1_tex.png-af937abfc757efeb270e0d3c97665f0d.s3tc.ctex"
|
||||
path.etc2="res://.godot/imported/decoration_1_tex.png-af937abfc757efeb270e0d3c97665f0d.etc2.ctex"
|
||||
path.s3tc="res://.godot/imported/decoration_1_tex.png-25954c951b589c01f2cc603ac94aec19.s3tc.ctex"
|
||||
path.etc2="res://.godot/imported/decoration_1_tex.png-25954c951b589c01f2cc603ac94aec19.etc2.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc", "etc2_astc"],
|
||||
"vram_texture": true
|
||||
@@ -12,8 +12,8 @@ metadata={
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/arena/gauntlet/tex/decoration_1_tex.png"
|
||||
dest_files=["res://.godot/imported/decoration_1_tex.png-af937abfc757efeb270e0d3c97665f0d.s3tc.ctex", "res://.godot/imported/decoration_1_tex.png-af937abfc757efeb270e0d3c97665f0d.etc2.ctex"]
|
||||
source_file="res://assets/models/arena/candy_survival/tex/decoration_1_tex.png"
|
||||
dest_files=["res://.godot/imported/decoration_1_tex.png-25954c951b589c01f2cc603ac94aec19.s3tc.ctex", "res://.godot/imported/decoration_1_tex.png-25954c951b589c01f2cc603ac94aec19.etc2.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
|
Before Width: | Height: | Size: 853 KiB After Width: | Height: | Size: 853 KiB |
@@ -3,8 +3,8 @@
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bj4aripd3jfd3"
|
||||
path.s3tc="res://.godot/imported/entrance-full_cart_1_tex.png-83ebc0d2d2bd582eb1cd1968cb8376d2.s3tc.ctex"
|
||||
path.etc2="res://.godot/imported/entrance-full_cart_1_tex.png-83ebc0d2d2bd582eb1cd1968cb8376d2.etc2.ctex"
|
||||
path.s3tc="res://.godot/imported/entrance-full_cart_1_tex.png-4325c61d98e62ac362acd6c3ccdc0646.s3tc.ctex"
|
||||
path.etc2="res://.godot/imported/entrance-full_cart_1_tex.png-4325c61d98e62ac362acd6c3ccdc0646.etc2.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc", "etc2_astc"],
|
||||
"vram_texture": true
|
||||
@@ -12,8 +12,8 @@ metadata={
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/arena/gauntlet/tex/entrance-full_cart_1_tex.png"
|
||||
dest_files=["res://.godot/imported/entrance-full_cart_1_tex.png-83ebc0d2d2bd582eb1cd1968cb8376d2.s3tc.ctex", "res://.godot/imported/entrance-full_cart_1_tex.png-83ebc0d2d2bd582eb1cd1968cb8376d2.etc2.ctex"]
|
||||
source_file="res://assets/models/arena/candy_survival/tex/entrance-full_cart_1_tex.png"
|
||||
dest_files=["res://.godot/imported/entrance-full_cart_1_tex.png-4325c61d98e62ac362acd6c3ccdc0646.s3tc.ctex", "res://.godot/imported/entrance-full_cart_1_tex.png-4325c61d98e62ac362acd6c3ccdc0646.etc2.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
@@ -3,8 +3,8 @@
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://0l80cm4wunmg"
|
||||
path.s3tc="res://.godot/imported/stone_1_tex.png-0d39863f60134c088b5ca17e8be09b88.s3tc.ctex"
|
||||
path.etc2="res://.godot/imported/stone_1_tex.png-0d39863f60134c088b5ca17e8be09b88.etc2.ctex"
|
||||
path.s3tc="res://.godot/imported/stone_1_tex.png-2d8b2e8c3fdc8f6f8693c38a25fc12a2.s3tc.ctex"
|
||||
path.etc2="res://.godot/imported/stone_1_tex.png-2d8b2e8c3fdc8f6f8693c38a25fc12a2.etc2.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc", "etc2_astc"],
|
||||
"vram_texture": true
|
||||
@@ -12,8 +12,8 @@ metadata={
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/arena/gauntlet/tex/stone_1_tex.png"
|
||||
dest_files=["res://.godot/imported/stone_1_tex.png-0d39863f60134c088b5ca17e8be09b88.s3tc.ctex", "res://.godot/imported/stone_1_tex.png-0d39863f60134c088b5ca17e8be09b88.etc2.ctex"]
|
||||
source_file="res://assets/models/arena/candy_survival/tex/stone_1_tex.png"
|
||||
dest_files=["res://.godot/imported/stone_1_tex.png-2d8b2e8c3fdc8f6f8693c38a25fc12a2.s3tc.ctex", "res://.godot/imported/stone_1_tex.png-2d8b2e8c3fdc8f6f8693c38a25fc12a2.etc2.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
@@ -3,8 +3,8 @@
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dvgs2vyv5eb0u"
|
||||
path.s3tc="res://.godot/imported/test 4.jpg-b862580b481399a32693b11efc9fb787.s3tc.ctex"
|
||||
path.etc2="res://.godot/imported/test 4.jpg-b862580b481399a32693b11efc9fb787.etc2.ctex"
|
||||
path.s3tc="res://.godot/imported/test 4.jpg-3789a118e5761b9d191559742508db6e.s3tc.ctex"
|
||||
path.etc2="res://.godot/imported/test 4.jpg-3789a118e5761b9d191559742508db6e.etc2.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc", "etc2_astc"],
|
||||
"vram_texture": true
|
||||
@@ -12,8 +12,8 @@ metadata={
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/arena/gauntlet/tex/test 4.jpg"
|
||||
dest_files=["res://.godot/imported/test 4.jpg-b862580b481399a32693b11efc9fb787.s3tc.ctex", "res://.godot/imported/test 4.jpg-b862580b481399a32693b11efc9fb787.etc2.ctex"]
|
||||
source_file="res://assets/models/arena/candy_survival/tex/test 4.jpg"
|
||||
dest_files=["res://.godot/imported/test 4.jpg-3789a118e5761b9d191559742508db6e.s3tc.ctex", "res://.godot/imported/test 4.jpg-3789a118e5761b9d191559742508db6e.etc2.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -3,8 +3,8 @@
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bvo6bnslmdkf6"
|
||||
path.s3tc="res://.godot/imported/wheat_tex.png-5155131b84e20db21cd1bd220c99ab9a.s3tc.ctex"
|
||||
path.etc2="res://.godot/imported/wheat_tex.png-5155131b84e20db21cd1bd220c99ab9a.etc2.ctex"
|
||||
path.s3tc="res://.godot/imported/wheat_tex.png-a558edd9475502cd09b2a9559df9a4df.s3tc.ctex"
|
||||
path.etc2="res://.godot/imported/wheat_tex.png-a558edd9475502cd09b2a9559df9a4df.etc2.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc", "etc2_astc"],
|
||||
"vram_texture": true
|
||||
@@ -12,8 +12,8 @@ metadata={
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/arena/gauntlet/tex/wheat_tex.png"
|
||||
dest_files=["res://.godot/imported/wheat_tex.png-5155131b84e20db21cd1bd220c99ab9a.s3tc.ctex", "res://.godot/imported/wheat_tex.png-5155131b84e20db21cd1bd220c99ab9a.etc2.ctex"]
|
||||
source_file="res://assets/models/arena/candy_survival/tex/wheat_tex.png"
|
||||
dest_files=["res://.godot/imported/wheat_tex.png-a558edd9475502cd09b2a9559df9a4df.s3tc.ctex", "res://.godot/imported/wheat_tex.png-a558edd9475502cd09b2a9559df9a4df.etc2.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -3,8 +3,8 @@
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://benj5oo1r1lx1"
|
||||
path.s3tc="res://.godot/imported/wooden_entrance_tex.png-a1135d5fc57dd4f2eb87de9d588f5dba.s3tc.ctex"
|
||||
path.etc2="res://.godot/imported/wooden_entrance_tex.png-a1135d5fc57dd4f2eb87de9d588f5dba.etc2.ctex"
|
||||
path.s3tc="res://.godot/imported/wooden_entrance_tex.png-da70dfcfdec9f463de59329a619ab805.s3tc.ctex"
|
||||
path.etc2="res://.godot/imported/wooden_entrance_tex.png-da70dfcfdec9f463de59329a619ab805.etc2.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc", "etc2_astc"],
|
||||
"vram_texture": true
|
||||
@@ -12,8 +12,8 @@ metadata={
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/arena/gauntlet/tex/wooden_entrance_tex.png"
|
||||
dest_files=["res://.godot/imported/wooden_entrance_tex.png-a1135d5fc57dd4f2eb87de9d588f5dba.s3tc.ctex", "res://.godot/imported/wooden_entrance_tex.png-a1135d5fc57dd4f2eb87de9d588f5dba.etc2.ctex"]
|
||||
source_file="res://assets/models/arena/candy_survival/tex/wooden_entrance_tex.png"
|
||||
dest_files=["res://.godot/imported/wooden_entrance_tex.png-da70dfcfdec9f463de59329a619ab805.s3tc.ctex", "res://.godot/imported/wooden_entrance_tex.png-da70dfcfdec9f463de59329a619ab805.etc2.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
@@ -1,422 +1,205 @@
|
||||
# Steamworks Setup Guide for Tekton Armageddon
|
||||
|
||||
This guide explains how to set up Steamworks for Windows, Mac, and Linux builds while using Nakama for mobile platforms (Android/iOS) for leaderboards, achievements, and shop functionality.
|
||||
> **Current status: Steam builds are not active.** All platforms (Windows, Linux, macOS, Android) use Nakama as their sole backend. GodotSteam GDExtension exists in `addons/godotsteam/` but is **not enabled** in the project. This doc explains the current Nakama-only architecture and how to re-enable Steam if needed in the future.
|
||||
|
||||
## Overview
|
||||
---
|
||||
|
||||
- **Desktop (Windows/Mac/Linux)**: Single build that detects Steam at runtime
|
||||
- If launched through Steam: Steam login available for Nakama registration; all features use Nakama
|
||||
- If launched standalone: Uses Nakama for all features
|
||||
- **Mobile (Android/iOS)**: Uses Nakama for all backend services
|
||||
- **Unified Backend**: All platforms use Nakama for achievements, leaderboards, and shop
|
||||
- **Steam Integration**: Steam is only used for authentication (auth session ticket for Nakama login)
|
||||
## Current Architecture (Nakama-Only)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Steamworks Setup
|
||||
|
||||
1. **Steam Partner Account**
|
||||
- You need a Steam Partner account to access Steamworks
|
||||
- Apply at: https://partner.steamgames.com/
|
||||
|
||||
2. **Steam App ID**
|
||||
- Create a new app in Steamworks
|
||||
- Note your App ID (e.g., 480)
|
||||
- Update `steam_app_id` in `scripts/services/steamworks_manager.gd`
|
||||
|
||||
3. **Steamworks SDK**
|
||||
- Download the Steamworks SDK from Steamworks
|
||||
- The GodotSteam plugin includes the SDK, but you may need it for reference
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. Install GodotSteam GDExtension
|
||||
|
||||
**Option A: Via Godot Asset Library (Recommended)**
|
||||
1. Open Godot Editor
|
||||
2. Go to `Project > Asset Library`
|
||||
3. Search for "GodotSteam GDExtension 4.4+"
|
||||
4. Download and install the plugin
|
||||
5. Restart Godot Editor
|
||||
|
||||
**Option B: Manual Installation**
|
||||
1. Download from: https://codeberg.org/godotsteam/godotsteam/releases
|
||||
2. Download version 4.18.1 (compatible with Godot 4.6.2)
|
||||
3. Extract to `addons/godotsteam/` in your project
|
||||
4. Enable the plugin in `Project > Project Settings > Plugins`
|
||||
|
||||
### 2. Configure Steamworks in Project
|
||||
|
||||
1. **Enable the Plugin**
|
||||
- Go to `Project > Project Settings > Plugins`
|
||||
- Enable "GodotSteam"
|
||||
|
||||
2. **Set Steam App ID**
|
||||
- Edit `scripts/services/steamworks_manager.gd`
|
||||
- Change `steam_app_id` to your Steam App ID:
|
||||
```gdscript
|
||||
var steam_app_id: int = YOUR_APP_ID_HERE
|
||||
```
|
||||
|
||||
3. **Add BackendService as Autoload**
|
||||
- Go to `Project > Project Settings > Autoload`
|
||||
- Add `BackendService` with path: `res://scripts/services/backend_service.gd`
|
||||
- Enable it as a singleton
|
||||
|
||||
### 3. Configure Steamworks Features
|
||||
|
||||
#### Achievements
|
||||
|
||||
1. **Define Achievements in Steamworks**
|
||||
- Go to Steamworks > Your App > Achievements
|
||||
- Create achievements with API names (e.g., "first_win", "level_10")
|
||||
- Set display names, descriptions, and icons
|
||||
|
||||
2. **Use in Code**
|
||||
```gdscript
|
||||
# Unlock an achievement
|
||||
BackendService.unlock_achievement("first_win")
|
||||
|
||||
# Set progress (for progress-based achievements)
|
||||
BackendService.set_achievement_progress("kill_100_enemies", current_kills, 100)
|
||||
|
||||
# Check achievement status
|
||||
var progress = BackendService.get_achievement_progress("first_win")
|
||||
```
|
||||
|
||||
#### Leaderboards
|
||||
|
||||
1. **Define Leaderboards in Steamworks**
|
||||
- Go to Steamworks > Your App > Leaderboards
|
||||
- Create leaderboards with API names (e.g., "high_score", "fastest_time")
|
||||
- Set sort order (ascending/descending) and display type
|
||||
|
||||
2. **Use in Code**
|
||||
```gdscript
|
||||
# Submit a score
|
||||
BackendService.submit_leaderboard_score("high_score", 1000)
|
||||
|
||||
# Get leaderboard entries
|
||||
BackendService.leaderboard_entries_loaded.connect(_on_leaderboard_loaded)
|
||||
BackendService.get_leaderboard_entries("high_score", 1, 10)
|
||||
|
||||
func _on_leaderboard_loaded(leaderboard_id: String, entries: Array):
|
||||
for entry in entries:
|
||||
print("Player: %s, Score: %d" % [entry.player_name, entry.score])
|
||||
```
|
||||
|
||||
#### Shop (Steam Inventory)
|
||||
|
||||
**Note**: Steam shop functionality requires additional setup with Steam Inventory Service or Steam Microtransactions. This is a complex feature that requires:
|
||||
|
||||
1. **Steam Inventory Service Setup**
|
||||
- Define items in Steamworks > Your App > Inventory
|
||||
- Set item types, prices, and properties
|
||||
- Implement purchase callbacks in `steamworks_manager.gd`
|
||||
|
||||
2. **Alternative**: Use external payment processor for desktop and sync with Nakama
|
||||
|
||||
### 4. Export Presets
|
||||
|
||||
The project includes export presets for all platforms:
|
||||
|
||||
#### Desktop Builds (single build for Steam and standalone)
|
||||
- **Windows Desktop** (preset.0) → `build/tekton_armageddon_v2.1.7.exe`
|
||||
- **macOS** (preset.2) → `build/tekton_armageddon_v2.1.7.zip`
|
||||
- **Linux/X11** (preset.3) → `build/tekton_armageddon_v2.1.7.x86_64`
|
||||
|
||||
#### Mobile Builds
|
||||
- **Android** (preset.1) → `build/tekton-dash-armageddon-v.2.1.5.apk`
|
||||
|
||||
**Note**: Desktop builds are universal - the same executable works on both Steam and standalone. The game detects whether it's running through Steam at runtime and switches backends accordingly.
|
||||
|
||||
#### Configure macOS Export
|
||||
|
||||
1. **Code Signing** (for distribution)
|
||||
- Get an Apple Developer certificate
|
||||
- Update `codesign/identity` in export preset
|
||||
- Set `codesign/enable` to `true`
|
||||
|
||||
2. **Architecture**
|
||||
- Currently set to "universal" (Intel + Apple Silicon)
|
||||
- Can be changed to "x86_64" or "arm64" if needed
|
||||
|
||||
#### Configure Linux Export
|
||||
|
||||
1. **Architecture**
|
||||
- Currently set to "x86_64"
|
||||
- Add ARM64 preset if needed for Linux ARM devices
|
||||
|
||||
### 5. Platform Detection
|
||||
|
||||
The `BackendService` automatically detects the platform and backend:
|
||||
|
||||
```gdscript
|
||||
# Detection logic in BackendService
|
||||
if OS.has_feature("android") or OS.has_feature("ios"):
|
||||
# Mobile → Nakama
|
||||
elif OS.has_feature("steam"):
|
||||
# Desktop → Steamworks
|
||||
else:
|
||||
# Desktop → Local storage (non-Steam builds)
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Game Client │
|
||||
│ ┌──────────────────────────────────────────┐ │
|
||||
│ │ BackendService (autoload) │ │
|
||||
│ │ ┌─────────────┐ ┌──────────────────┐ │ │
|
||||
│ │ │ NakamaManager│ │steamworks_manager│ │ │
|
||||
│ │ │ (active) │ │ (dormant) │ │ │
|
||||
│ │ └─────────────┘ └──────────────────┘ │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
└──────────────────────┬───────────────────────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Nakama Server │
|
||||
│ (RPC backend) │
|
||||
│ achievements │
|
||||
│ leaderboards │
|
||||
│ shop/economy │
|
||||
│ auth │
|
||||
│ multiplayer │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
You can check the current platform:
|
||||
- **All backends use Nakama** — achievements, leaderboards, shop, authentication, multiplayer.
|
||||
- **Platform detection** (`BackendService._detect_platform()`) distinguishes:
|
||||
- `MOBILE_NAKAMA` — Android/iOS (`OS.has_feature("android")` or `OS.has_feature("ios")`)
|
||||
- `DESKTOP_NAKAMA` — Windows/Linux/macOS (default when GodotSteam not loaded)
|
||||
- `DESKTOP_STEAM` — only activated if `ClassDB.class_exists("Steam")` at runtime
|
||||
- **GodotSteam GDExtension** is present in `addons/godotsteam/` but **not enabled** in Project Settings > Plugins. It is shipped in the CI export (Windows only, `|| true` on copy failure) but does not execute.
|
||||
- **No Steam App ID configured.** The `steam_app_id` in `steamworks_manager.gd` defaults to `480` (generic test ID) via `ProjectSettings.get_setting("steam/initialization/app_id", 480)`.
|
||||
- **No Steam Partner account, no Steamworks app, no Steam build pipelines** are active.
|
||||
|
||||
---
|
||||
|
||||
## Export Presets
|
||||
|
||||
The project has 4 export presets in `export_presets.cfg`:
|
||||
|
||||
| Preset | Name | Platform | Export Path |
|
||||
|--------|------|----------|-------------|
|
||||
| preset.0 | Windows Desktop | Windows | `build/windows/tekton_armageddon_v2.4.3.exe` |
|
||||
| preset.1 | Android | Android | `build/tekton-dash-armageddon-v.2.4.3.apk` |
|
||||
| preset.2 | macOS | macOS | `build/tekton_armageddon_v2.4.3.zip` |
|
||||
| preset.3 | Linux/X11 | Linux | `build/linux/tekton_armageddon_v2.4.3.x86_64` |
|
||||
|
||||
No separate Steam vs. Non-Steam presets. All presets produce the same Nakama-only build.
|
||||
|
||||
### CI Export Flow (`.gitea/workflows/ci.yml`)
|
||||
|
||||
On tag push (`v*`), the CI pipeline:
|
||||
|
||||
1. **Checkout** code at tag
|
||||
2. **Setup Godot 4.7** (cached)
|
||||
3. **Export Windows** → `build/windows/tekton_armageddon_windows.exe` (copies `libgodotsteam*` DLLs if present, failure ignored with `|| true`)
|
||||
4. **Export Linux** → `build/linux/tekton_armageddon_linux.x86_64`
|
||||
5. **Export macOS** → `build/macos/tekton_armageddon_macos.zip`
|
||||
6. **Extract changelog** from `CHANGELOG_DRAFT.md`
|
||||
7. **Create Gitea release** and upload all 3 platform zips
|
||||
8. **Publish** release (draft → published)
|
||||
|
||||
The CI does **not** upload to Steam. All builds are distributed via Gitea releases (https://git.klud.top/danchie/tekton/releases).
|
||||
|
||||
---
|
||||
|
||||
## Platform Detection
|
||||
|
||||
The `BackendService` autoload (`scripts/services/backend_service.gd`) detects platform at startup:
|
||||
|
||||
```gdscript
|
||||
func _detect_platform() -> void:
|
||||
if OS.has_feature("android") or OS.has_feature("ios"):
|
||||
current_platform = Platform.MOBILE_NAKAMA
|
||||
else:
|
||||
if ClassDB.class_exists("Steam"):
|
||||
current_platform = Platform.DESKTOP_STEAM
|
||||
else:
|
||||
current_platform = Platform.DESKTOP_NAKAMA
|
||||
```
|
||||
|
||||
Since GodotSteam is not enabled, desktop builds always hit `DESKTOP_NAKAMA`. The `steamworks_manager.gd` is only loaded when `DESKTOP_STEAM` is selected.
|
||||
|
||||
Check current platform at runtime:
|
||||
```gdscript
|
||||
print("Platform: %s" % BackendService.get_platform_name())
|
||||
print("Initialized: %s" % BackendService.is_initialized())
|
||||
```
|
||||
|
||||
#### Platform Types
|
||||
### Platform Types
|
||||
|
||||
- **DESKTOP_STEAM**: Running through Steam client (Steam login available, all features use Nakama)
|
||||
- **DESKTOP_NAKAMA**: Desktop build not running through Steam (uses Nakama)
|
||||
- **MOBILE_NAKAMA**: Android/iOS (uses Nakama)
|
||||
| Value | Trigger | Backend |
|
||||
|-------|---------|---------|
|
||||
| `DESKTOP_NAKAMA` | Desktop build, GodotSteam not loaded | Nakama only |
|
||||
| `DESKTOP_STEAM` | Desktop build, GodotSteam loaded + Steam running | Nakama + Steam auth |
|
||||
| `MOBILE_NAKAMA` | Android/iOS feature detected | Nakama only |
|
||||
|
||||
#### Runtime Detection
|
||||
---
|
||||
|
||||
The game automatically detects the launch method:
|
||||
- If `OS.has_feature("steam")` is true → Steam login available, all features use Nakama
|
||||
- Otherwise → All features use Nakama
|
||||
## Local Testing
|
||||
|
||||
This means the same desktop build can be:
|
||||
- Uploaded to Steam (Steam login enabled, all data stored in Nakama)
|
||||
- Distributed standalone (all data stored in Nakama)
|
||||
### Nakama Backend (All Platforms)
|
||||
|
||||
### Steam Login for Nakama
|
||||
1. Ensure Nakama server is running locally (see `server/docker-compose.yaml`)
|
||||
2. Launch the game from Godot Editor (press F5)
|
||||
3. Log in with email/password or device ID
|
||||
4. Test achievements, leaderboards, shop via game UI
|
||||
5. Check console for `"BackendService: Initialized Nakama backend"`
|
||||
|
||||
When running through Steam, players can use their Steam account to register or log in to Nakama. This is the **only** Steam integration - all game features (achievements, leaderboards, shop) use Nakama.
|
||||
|
||||
**How it works:**
|
||||
1. Player clicks "Sign in with Steam" button on login screen
|
||||
2. Game retrieves Steam auth session ticket via Steamworks
|
||||
3. Auth ticket is sent to Nakama for authentication
|
||||
4. Nakama validates the ticket with Steam backend
|
||||
5. If valid, player is logged in/registered to Nakama
|
||||
6. Player's Steam account is linked to their Nakama account
|
||||
|
||||
**Benefits:**
|
||||
- No password needed for Steam users
|
||||
- Automatic account creation on first login
|
||||
- Seamless cross-platform progression (all data in Nakama)
|
||||
- Steam username is used as display name
|
||||
- Unified backend across all platforms
|
||||
|
||||
**Requirements:**
|
||||
- Nakama server must be configured with Steam API key
|
||||
- Steamworks must be initialized (game launched through Steam)
|
||||
- GodotSteam plugin must support `getAuthSessionTicket()`
|
||||
|
||||
**Configuration:**
|
||||
Set your Steam API key in Nakama server configuration:
|
||||
```yaml
|
||||
nakama:
|
||||
social:
|
||||
steam:
|
||||
api_key: "your_steam_api_key"
|
||||
```
|
||||
|
||||
## Nakama Integration for Mobile
|
||||
|
||||
### Current Setup
|
||||
|
||||
Your project already has Nakama integrated via `addons/com.heroiclabs.nakama/` and `NakamaManager` autoload.
|
||||
|
||||
### Connecting to BackendService
|
||||
|
||||
The `BackendService` will automatically use Nakama on mobile. You need to implement Nakama-specific methods in `NakamaManager`:
|
||||
### Force-Specific Platform for Testing
|
||||
|
||||
Temporarily override platform in `BackendService._detect_platform()`:
|
||||
```gdscript
|
||||
# In NakamaManager.gd, add these signals:
|
||||
signal achievement_unlocked(achievement_id: String)
|
||||
signal leaderboard_score_submitted(leaderboard_id: String, score: int, success: bool)
|
||||
|
||||
# Implement achievement methods
|
||||
func unlock_achievement(achievement_id: String):
|
||||
# Use Nakama's achievement system
|
||||
var achievement = await client.write_storage_object_async(
|
||||
session,
|
||||
NakamaWriteStorageObject.new(
|
||||
"achievements",
|
||||
achievement_id,
|
||||
{"unlocked": true, "timestamp": Time.get_unix_time_from_system()}
|
||||
)
|
||||
)
|
||||
achievement_unlocked.emit(achievement_id)
|
||||
|
||||
# Implement leaderboard methods
|
||||
func submit_leaderboard_score(leaderboard_id: String, score: int):
|
||||
# Use Nakama's leaderboard system
|
||||
var result = await client.write_leaderboard_record_async(
|
||||
session,
|
||||
leaderboard_id,
|
||||
score
|
||||
)
|
||||
leaderboard_score_submitted.emit(leaderboard_id, score, result != null)
|
||||
func _detect_platform() -> void:
|
||||
current_platform = Platform.DESKTOP_NAKAMA # Force desktop Nakama mode
|
||||
# ... or ...
|
||||
current_platform = Platform.MOBILE_NAKAMA # Force mobile mode
|
||||
```
|
||||
|
||||
## Testing
|
||||
### Export and Run Standalone
|
||||
|
||||
### Testing Steam Builds
|
||||
```bash
|
||||
# Windows
|
||||
godot --headless --export-release "Windows Desktop" build/windows/standalone.exe
|
||||
./build/windows/standalone.exe
|
||||
|
||||
1. **Export Steam Build**
|
||||
- Use the "Windows Desktop (Steam)" preset (preset.0)
|
||||
- Export to `build/steam/tekton_armageddon_v2.1.7.exe`
|
||||
# Linux
|
||||
godot --headless --export-release "Linux/X11" build/linux/standalone.x86_64
|
||||
./build/linux/standalone.x86_64
|
||||
|
||||
2. **Upload to Steam**
|
||||
- Upload the exported build to Steamworks
|
||||
- Set as the default build for your app
|
||||
# macOS
|
||||
godot --headless --export-release "macOS" build/macos/standalone.zip
|
||||
```
|
||||
|
||||
3. **Run through Steam**
|
||||
- Launch the game via Steam (not directly)
|
||||
- Steam must be running
|
||||
- Check console for "SteamworksManager: Steam initialized successfully"
|
||||
---
|
||||
|
||||
4. **Test Achievements**
|
||||
- Call `BackendService.unlock_achievement("test_achievement")`
|
||||
- Check Steam overlay (Shift+Tab) to see achievement unlock
|
||||
## Activating Steam Support (Future)
|
||||
|
||||
5. **Test Leaderboards**
|
||||
- Submit scores via `BackendService.submit_leaderboard_score()`
|
||||
- View in Steamworks backend or Steam overlay
|
||||
If Steam distribution is needed later, these are the steps:
|
||||
|
||||
### Testing Non-Steam Builds
|
||||
1. **Get a Steam Partner account** at https://partner.steamgames.com/
|
||||
2. **Create Steam app** and get an App ID
|
||||
3. **Enable GodotSteam plugin** in Project > Project Settings > Plugins
|
||||
4. **Set App ID** in Project Settings > steam > initialization > app_id (or in `steamworks_manager.gd`)
|
||||
5. **Configure Nakama** to accept Steam auth tickets (set Steam API key in Nakama config)
|
||||
6. **Add a Steam-specific export preset** or modify existing presets to include Steam SDK redistributables
|
||||
7. **Update CI pipeline** to upload builds to Steamworks SDK pipelines
|
||||
8. **Test Steam auth flow**: launch through Steam, verify `"SteamworksManager: Steam initialized"`, verify Steam login → Nakama registration
|
||||
|
||||
1. **Export Non-Steam Build**
|
||||
- Use the "Windows Desktop (Non-Steam)" preset (preset.1)
|
||||
- Export to `build/standalone/tekton_armageddon_v2.1.7.exe`
|
||||
### What the Dormant Steam Code Does
|
||||
|
||||
2. **Run Directly**
|
||||
- Run the executable directly (not through Steam)
|
||||
- Check console for "BackendService: Initialized Nakama backend"
|
||||
The existing `steamworks_manager.gd` provides:
|
||||
|
||||
3. **Test Leaderboards**
|
||||
- Ensure Nakama server is accessible
|
||||
- Open the leaderboard panel to fetch rankings
|
||||
- Submit scores via `UserProfileManager.submit_to_leaderboard()`
|
||||
- Leaderboards are global (same as mobile)
|
||||
- `get_auth_session_ticket()` — retrieves Steam auth session ticket for Nakama login
|
||||
- `get_steam_user_name()` — returns Steam persona name
|
||||
- `get_steam_user_id()` — returns Steam ID
|
||||
|
||||
4. **Test Shop**
|
||||
- Ensure Nakama server is accessible
|
||||
- Open the shop panel to fetch catalog
|
||||
- Purchase items via `UserProfileManager.purchase_item()`
|
||||
- Shop functionality works the same as mobile
|
||||
All game features (achievements, leaderboards, shop) are already implemented purely via Nakama RPCs in `BackendService.api_rpc_async()`. Steam would only be used for **authentication** — the rest stays on Nakama.
|
||||
|
||||
### Testing Nakama (Mobile)
|
||||
|
||||
1. **Run on Mobile Device**
|
||||
- Export to Android/iOS using the Android preset (preset.2)
|
||||
- The game will automatically use Nakama backend
|
||||
- Check logs for "BackendService: Initialized Nakama backend"
|
||||
|
||||
2. **Test in Editor**
|
||||
- To test Nakama in editor, temporarily modify `_detect_platform()`:
|
||||
```gdscript
|
||||
func _detect_platform() -> void:
|
||||
current_platform = Platform.MOBILE_NAKAMA # Force Nakama
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Steamworks Not Initializing
|
||||
|
||||
**Problem**: "SteamworksManager: Failed to initialize Steam"
|
||||
|
||||
**Solutions**:
|
||||
1. Ensure game is launched through Steam (not directly)
|
||||
2. Check Steam is running
|
||||
3. Verify `steam_app_id` is correct
|
||||
4. Check GodotSteam plugin is enabled in Project Settings
|
||||
5. Restart Godot Editor after installing plugin
|
||||
|
||||
### Achievements Not Unlocking
|
||||
|
||||
**Problem**: Achievements don't appear in Steam overlay
|
||||
|
||||
**Solutions**:
|
||||
1. Ensure achievement API names match Steamworks configuration
|
||||
2. Check `steam.storeStats()` is called after setting achievements
|
||||
3. Verify achievement is published in Steamworks (not in draft)
|
||||
4. Test with Steam overlay open (Shift+Tab)
|
||||
|
||||
### Leaderboards Not Working
|
||||
|
||||
**Problem**: Leaderboard scores not submitting
|
||||
|
||||
**Solutions**:
|
||||
1. Ensure leaderboard exists in Steamworks
|
||||
2. Check leaderboard API name matches
|
||||
3. Verify leaderboard is published
|
||||
4. Check console for error messages
|
||||
|
||||
### Platform Detection Issues
|
||||
|
||||
**Problem**: Wrong backend being used
|
||||
|
||||
**Solutions**:
|
||||
1. Check OS features: `print(OS.get_supported_features())`
|
||||
2. Manually override platform in `_detect_platform()` for testing
|
||||
3. Ensure `BackendService` is added as autoload
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
scripts/services/
|
||||
├── backend_service.gd # Unified interface (autoload)
|
||||
└── steamworks_manager.gd # Steamworks implementation
|
||||
scripts/
|
||||
├── backend_service.gd # Unified interface (autoload) — Nakama-only default
|
||||
├── steamworks_manager.gd # Steamworks implementation (dormant, loads only if GodotSteam present)
|
||||
└── nakama_manager.gd # NakamaManager autoload (active)
|
||||
|
||||
export_presets.cfg # Export presets for all platforms
|
||||
addons/godotsteam/ # GodotSteam GDExtension (installed but not enabled)
|
||||
addons/com.heroiclabs.nakama/ # Nakama GDSDK (active)
|
||||
|
||||
export_presets.cfg # Export presets — all Nakama-only, no Steam presets
|
||||
.gitea/workflows/ci.yml # CI pipeline — exports Win/Linux/macOS to Gitea releases
|
||||
docs/STEAMWORKS_SETUP.md # This documentation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Differences from Previous State
|
||||
|
||||
| Aspect | Before | Now |
|
||||
|--------|--------|-----|
|
||||
| Steam builds | Active, separate Steam/Non-Steam presets | Dormant, not shipped |
|
||||
| Backend | Nakama + Steam | Nakama only |
|
||||
| Export presets | 6+ (Steam + Non-Steam variants) | 4 (all Nakama-only) |
|
||||
| Desktop platform detection | Steam if `OS.has_feature("steam")` | Nakama unless GodotSteam loaded |
|
||||
| CI | Uploaded to Steamworks | Gitea releases only |
|
||||
| Steam App ID | Configured | Default 480 (test), not set |
|
||||
|
||||
---
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- **GodotSteam Documentation**: https://godotsteam.com/
|
||||
- **Nakama Documentation**: https://heroiclabs.com/docs/nakama/
|
||||
- **GodotSteam Documentation**: https://godotsteam.com/ (for future reference)
|
||||
- **GodotSteam GitHub**: https://codeberg.org/godotsteam/godotsteam
|
||||
- **Steamworks Documentation**: https://partner.steamgames.com/doc/home
|
||||
- **Nakama Documentation**: https://heroiclabs.com/docs/nakama/
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Complete Steam Partner account setup
|
||||
2. Create Steam app and get App ID
|
||||
3. Install GodotSteam plugin
|
||||
4. Configure achievements and leaderboards in Steamworks
|
||||
5. Implement Nakama methods in `NakamaManager` for mobile
|
||||
6. Test on all target platforms
|
||||
7. Set up code signing for macOS distribution
|
||||
8. Configure Steam Inventory Service if using in-game shop
|
||||
|
||||
## Notes
|
||||
|
||||
- **Steam builds** only work when launched through Steam client
|
||||
- **Non-Steam builds** use Nakama for leaderboards and shop (same as mobile)
|
||||
- **Shop functionality** is available on both Steam (via Steam Inventory) and non-Steam (via Nakama)
|
||||
- **Non-Steam builds** sync to Nakama server, not Steam
|
||||
- **Nakama** is already integrated for multiplayer, leaderboards, and shop
|
||||
- Export presets are organized in separate folders: `build/steam/` and `build/standalone/`
|
||||
|
||||
## Build Workflow
|
||||
|
||||
### For Steam Distribution
|
||||
|
||||
1. Export using Steam presets (preset.0, preset.3, preset.5)
|
||||
2. Upload builds to Steamworks
|
||||
3. Configure achievements and leaderboards in Steamworks backend
|
||||
4. Set build as default in Steamworks
|
||||
5. Shop uses Steam Inventory Service (requires additional setup)
|
||||
|
||||
### For Standalone Distribution (itch.io, GOG, etc.)
|
||||
|
||||
1. Export using Non-Steam presets (preset.1, preset.4, preset.6)
|
||||
2. Distribute the standalone executables
|
||||
3. Ensure Nakama server is accessible to players
|
||||
4. Players get global leaderboards and shop via Nakama
|
||||
5. No Steam integration required
|
||||
|
||||
### For Mobile Stores
|
||||
|
||||
1. Export using Android preset (preset.2)
|
||||
2. Upload to Google Play / App Store
|
||||
3. Ensure Nakama server is configured and accessible
|
||||
4. Leaderboards and shop work the same as non-Steam desktop
|
||||
- **CI Workflow**: `.gitea/workflows/ci.yml`
|
||||
|
||||
@@ -14,7 +14,7 @@ custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="build/windows/tekton_armageddon_v2.4.3.exe"
|
||||
export_path="build/windows/tekton_armageddon_v2.4.6.exe"
|
||||
patches=PackedStringArray()
|
||||
patch_delta_encoding=false
|
||||
patch_delta_compression_level_zstd=19
|
||||
@@ -85,7 +85,7 @@ custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="build/tekton-dash-armageddon-v.2.4.3.apk"
|
||||
export_path="build/tekton-dash-armageddon-v.2.4.6.apk"
|
||||
patches=PackedStringArray()
|
||||
patch_delta_encoding=false
|
||||
patch_delta_compression_level_zstd=19
|
||||
@@ -314,7 +314,7 @@ custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="build/tekton_armageddon_v2.4.3.zip"
|
||||
export_path="build/tekton_armageddon_v2.4.6.zip"
|
||||
patches=PackedStringArray()
|
||||
patch_delta_encoding=false
|
||||
patch_delta_compression_level_zstd=19
|
||||
@@ -589,7 +589,7 @@ custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="build/linux/tekton_armageddon_v2.4.3.x86_64"
|
||||
export_path="build/linux/tekton_armageddon_v2.4.6.x86_64"
|
||||
patches=PackedStringArray()
|
||||
patch_delta_encoding=false
|
||||
patch_delta_compression_level_zstd=19
|
||||
|
||||
@@ -15,9 +15,9 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
|
||||
[application]
|
||||
|
||||
config/name="Tekton Dash Armageddon"
|
||||
config/version="2.4.3"
|
||||
config/version="2.4.6"
|
||||
run/main_scene="res://scenes/ui/boot_screen.tscn"
|
||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||
config/features=PackedStringArray("4.6", "Forward Plus")
|
||||
boot_splash/bg_color=Color(0.16470589, 0.6745098, 0.9372549, 1)
|
||||
boot_splash/stretch_mode=0
|
||||
boot_splash/image="uid://b10e6kr508642"
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
@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
|
||||
@@ -1,6 +1,6 @@
|
||||
[gd_scene format=3 uid="uid://bx5a31fqhw8h8"]
|
||||
[gd_scene format=3 uid="uid://dmkx71ms3cfc"]
|
||||
|
||||
[ext_resource type="PackedScene" uid="uid://h84tka1n1alg" path="res://assets/models/arena/gauntlet/Gauntlet terrain.gltf" id="1_86fyc"]
|
||||
[ext_resource type="PackedScene" uid="uid://h84tka1n1alg" path="res://assets/models/arena/candy_survival/candy_survival_terrain.gltf" id="1_86fyc"]
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_floor"]
|
||||
albedo_color = Color(0.2, 0.2, 0.2, 1)
|
||||
@@ -24,10 +24,18 @@ albedo_color = Color(0.4973, 0.6, 0.12599999, 1)
|
||||
material = SubResource("StandardMaterial3D_86fyc")
|
||||
size = Vector2(50, 50)
|
||||
|
||||
[node name="Gauntlet" type="Node3D" unique_id=1063002869]
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_q8eld"]
|
||||
transparency = 1
|
||||
albedo_color = Color(1, 0, 1, 0.5)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_l6h1c"]
|
||||
material = SubResource("StandardMaterial3D_q8eld")
|
||||
size = Vector3(1, 0.05, 1)
|
||||
|
||||
[node name="Candy Survival" type="Node3D" unique_id=1063002869]
|
||||
|
||||
[node name="PlaceholderFloor" type="MeshInstance3D" parent="." unique_id=932640085]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 10, 0, 10)
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 10, -0.3, 10)
|
||||
visible = false
|
||||
mesh = SubResource("PlaneMesh_floor")
|
||||
|
||||
@@ -36,8 +44,13 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 9.5, 0.5, 9.5)
|
||||
visible = false
|
||||
mesh = SubResource("BoxMesh_cannon")
|
||||
|
||||
[node name="Gauntlet terrain" parent="." unique_id=193457353 instance=ExtResource("1_86fyc")]
|
||||
[node name="Candy Survival terrain" parent="." unique_id=193457353 instance=ExtResource("1_86fyc")]
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="." unique_id=1749367969]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 9.192932, 0, 9.441385)
|
||||
mesh = SubResource("PlaneMesh_ugtui")
|
||||
|
||||
[node name="EditorTileReference" type="MeshInstance3D" parent="." unique_id=1116403213]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.05, 0)
|
||||
visible = false
|
||||
mesh = SubResource("BoxMesh_l6h1c")
|
||||
@@ -0,0 +1,16 @@
|
||||
[gd_scene format=3 uid="uid://iinjrl0ujfnh"]
|
||||
|
||||
[sub_resource type="SphereMesh" id="1"]
|
||||
radius = 0.2
|
||||
height = 0.4
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="2"]
|
||||
albedo_color = Color(1, 1, 1, 1)
|
||||
metallic = 0.3
|
||||
roughness = 0.4
|
||||
|
||||
[node name="Candy" type="Node3D"]
|
||||
|
||||
[node name="Mesh" type="MeshInstance3D" parent="."]
|
||||
mesh = SubResource("1")
|
||||
material_override = SubResource("2")
|
||||
@@ -1,77 +0,0 @@
|
||||
[gd_scene format=3 uid="uid://ddy2r7xto80gq"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://du7cne5070ia0" path="res://scripts/controllers/candy_cannon_controller.gd" id="1_canon"]
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_base"]
|
||||
albedo_color = Color(0.15, 0.15, 0.2, 1)
|
||||
metallic = 0.8
|
||||
roughness = 0.2
|
||||
|
||||
[sub_resource type="CylinderMesh" id="CylinderMesh_base"]
|
||||
material = SubResource("StandardMaterial3D_base")
|
||||
top_radius = 0.8
|
||||
bottom_radius = 1.2
|
||||
height = 0.5
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_tank"]
|
||||
albedo_color = Color(1, 0.4, 0.8, 1)
|
||||
metallic = 0.2
|
||||
roughness = 0.1
|
||||
emission_enabled = true
|
||||
emission = Color(1, 0.4, 0.8, 1)
|
||||
emission_energy_multiplier = 2.0
|
||||
|
||||
[sub_resource type="SphereMesh" id="SphereMesh_tank"]
|
||||
material = SubResource("StandardMaterial3D_tank")
|
||||
radius = 0.9
|
||||
height = 1.8
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_pipe"]
|
||||
albedo_color = Color(0.7, 0.7, 0.8, 1)
|
||||
metallic = 1.0
|
||||
roughness = 0.2
|
||||
|
||||
[sub_resource type="CylinderMesh" id="CylinderMesh_pipe"]
|
||||
material = SubResource("StandardMaterial3D_pipe")
|
||||
top_radius = 0.25
|
||||
bottom_radius = 0.4
|
||||
height = 1.5
|
||||
|
||||
[sub_resource type="TorusMesh" id="TorusMesh_ring"]
|
||||
material = SubResource("StandardMaterial3D_pipe")
|
||||
inner_radius = 0.95
|
||||
outer_radius = 1.15
|
||||
rings = 32
|
||||
|
||||
[node name="CandyCannon" type="Node3D" unique_id=1515964905]
|
||||
script = ExtResource("1_canon")
|
||||
|
||||
[node name="Base" type="MeshInstance3D" parent="." unique_id=867716100]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.25, 0)
|
||||
mesh = SubResource("CylinderMesh_base")
|
||||
|
||||
[node name="Tank" type="MeshInstance3D" parent="." unique_id=2051786234]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.4, 0)
|
||||
mesh = SubResource("SphereMesh_tank")
|
||||
|
||||
[node name="Pipe" type="MeshInstance3D" parent="." unique_id=491265631]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.7, 0)
|
||||
mesh = SubResource("CylinderMesh_pipe")
|
||||
|
||||
[node name="Ring1" type="MeshInstance3D" parent="." unique_id=1607309179]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.4, 0)
|
||||
mesh = SubResource("TorusMesh_ring")
|
||||
|
||||
[node name="Ring2" type="MeshInstance3D" parent="." unique_id=850923213]
|
||||
transform = Transform3D(0.707107, 0.707107, 0, -0.707107, 0.707107, 0, 0, 0, 1, 0, 1.4, 0)
|
||||
mesh = SubResource("TorusMesh_ring")
|
||||
|
||||
[node name="Ring3" type="MeshInstance3D" parent="." unique_id=677024967]
|
||||
transform = Transform3D(0.707107, -0.707107, 0, 0.707107, 0.707107, 0, 0, 0, 1, 0, 1.4, 0)
|
||||
mesh = SubResource("TorusMesh_ring")
|
||||
|
||||
[node name="OmniLight3D" type="OmniLight3D" parent="." unique_id=1751121483]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.8, 0)
|
||||
light_color = Color(1, 0.4, 0.8, 1)
|
||||
light_energy = 3.0
|
||||
omni_range = 8.0
|
||||
@@ -0,0 +1,112 @@
|
||||
[gd_scene format=3 uid="uid://b8nxdmi3bpj0g"]
|
||||
|
||||
[ext_resource type="FontFile" uid="uid://xnjx058n4tsw" path="res://assets/fonts/Nougat-ExtraBlack.ttf" id="1_font"]
|
||||
|
||||
[node name="CandySurvivalHUD" type="CanvasLayer" unique_id=423395116]
|
||||
layer = 5
|
||||
|
||||
[node name="TopContainer" type="CenterContainer" parent="." unique_id=2024389174]
|
||||
anchors_preset = 5
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
offset_top = 70.0
|
||||
grow_horizontal = 2
|
||||
|
||||
[node name="SugarRushBar" type="ProgressBar" parent="TopContainer" unique_id=548351179]
|
||||
visible = false
|
||||
custom_minimum_size = Vector2(300, 30)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 4
|
||||
theme_override_fonts/font = ExtResource("1_font")
|
||||
theme_override_font_sizes/font_size = 18
|
||||
max_value = 1.0
|
||||
show_percentage = false
|
||||
|
||||
[node name="SugarRushLabel" type="Label" parent="TopContainer/SugarRushBar" unique_id=1102847385]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_colors/font_color = Color(1, 0.2, 0.2, 1)
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 4
|
||||
theme_override_fonts/font = ExtResource("1_font")
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "SUGAR RUSH!"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="BottomContainer" type="CenterContainer" parent="." unique_id=558209357]
|
||||
anchors_preset = 7
|
||||
anchor_left = 0.5
|
||||
anchor_top = 1.0
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -150.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="BottomContainer" unique_id=1499322099]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 40
|
||||
|
||||
[node name="SabotagePanel" type="VBoxContainer" parent="BottomContainer/HBoxContainer" unique_id=1124406210]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="KnockChargesLabel" type="Label" parent="BottomContainer/HBoxContainer/SabotagePanel" unique_id=1127989561]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(1, 0.6, 0.2, 1)
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 5
|
||||
theme_override_fonts/font = ExtResource("1_font")
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "🥊 Knocks: 5"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="GhostChargesLabel" type="Label" parent="BottomContainer/HBoxContainer/SabotagePanel" unique_id=1769513912]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.8, 0.8, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 5
|
||||
theme_override_fonts/font = ExtResource("1_font")
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "👻 Ghosts: 5"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="ScorePanel" type="VBoxContainer" parent="BottomContainer/HBoxContainer" unique_id=1680183752]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="CandyStackBadge" type="Label" parent="BottomContainer/HBoxContainer/ScorePanel" unique_id=1673476912]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(1, 0.4, 0.8, 1)
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 6
|
||||
theme_override_fonts/font = ExtResource("1_font")
|
||||
theme_override_font_sizes/font_size = 28
|
||||
text = "Stack: 0 (x1)"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="PointsLabel" type="Label" parent="BottomContainer/HBoxContainer/ScorePanel" unique_id=1646972276]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.4, 1, 0.4, 1)
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 5
|
||||
theme_override_fonts/font = ExtResource("1_font")
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "Score: 0"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="DeliveryIndicator" type="Label" parent="BottomContainer/HBoxContainer/ScorePanel" unique_id=1123585835]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(1, 1, 0.2, 1)
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 5
|
||||
theme_override_fonts/font = ExtResource("1_font")
|
||||
theme_override_font_sizes/font_size = 20
|
||||
horizontal_alignment = 1
|
||||
@@ -0,0 +1,16 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://ddy2r7xto80gq"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://du7cne5070ia0" path="res://scripts/controllers/candy_survival_npc_controller.gd" id="1_canon"]
|
||||
[ext_resource type="PackedScene" uid="uid://df7h7y7y7y7y7" path="res://scenes/static_tekton_mesh.tscn" id="2_tekton"]
|
||||
|
||||
[node name="CandySurvivalNPC" type="Node3D" unique_id=1515964905]
|
||||
script = ExtResource("1_canon")
|
||||
|
||||
[node name="MektonMesh" parent="." unique_id=1698719440 instance=ExtResource("2_tekton")]
|
||||
transform = Transform3D(-0.5, 0, -4.37114e-08, 0, 0.5, 0, 4.37114e-08, 0, -0.5, 0, 0, 0)
|
||||
|
||||
[node name="OmniLight3D" type="OmniLight3D" parent="." unique_id=1751121483]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 3.0, 0)
|
||||
light_color = Color(1, 0.4, 0.8, 1)
|
||||
light_energy = 3.0
|
||||
omni_range = 8.0
|
||||
@@ -1,49 +0,0 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="FontFile" uid="uid://xnjx058n4tsw" path="res://assets/fonts/Nougat-ExtraBlack.ttf" id="1_font"]
|
||||
|
||||
[node name="GauntletHUD" type="CanvasLayer"]
|
||||
layer = 5
|
||||
visible = false
|
||||
|
||||
[node name="TopContainer" type="CenterContainer" parent="."]
|
||||
anchors_preset = 5
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
offset_top = 70.0
|
||||
grow_horizontal = 2
|
||||
|
||||
[node name="SlowMoLabel" type="Label" parent="TopContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_colors/font_color = Color(0.3, 0.5, 1.0, 1)
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 4
|
||||
theme_override_fonts/font = ExtResource("1_font")
|
||||
text = "SLOW-MO"
|
||||
horizontal_alignment = 1
|
||||
visible = false
|
||||
|
||||
[node name="BottomContainer" type="CenterContainer" parent="."]
|
||||
anchors_preset = 7
|
||||
anchor_left = 0.5
|
||||
anchor_top = 1.0
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -120.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="BottomContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="PhaseLabel" type="Label" parent="BottomContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 24
|
||||
theme_override_colors/font_color = Color(1, 0.6, 0.8, 1)
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 6
|
||||
theme_override_fonts/font = ExtResource("1_font")
|
||||
text = "🍬 OPEN ARENA"
|
||||
horizontal_alignment = 1
|
||||
@@ -86,12 +86,7 @@ var sng_go_option: OptionButton
|
||||
var sng_stop_option: OptionButton
|
||||
var sng_goals_option: OptionButton
|
||||
|
||||
var doors_settings_container: HBoxContainer
|
||||
var doors_swap_option: OptionButton
|
||||
var doors_refresh_option: OptionButton
|
||||
var doors_goals_option: OptionButton
|
||||
|
||||
# UI References - Player Slots
|
||||
# Candy Survival settings
|
||||
@onready var players_container = $LobbyPanel/PlayersContainer
|
||||
@onready var players_container2 = $LobbyPanel/PlayersContainer2
|
||||
@onready var player_slots: Array[Control] = []
|
||||
|
||||
@@ -12,11 +12,9 @@ var touch_controls
|
||||
var camera_context_manager
|
||||
var stop_n_go_manager
|
||||
var stop_n_go_winner_id: int = -1 # Track who finished first in Stop n Go mode
|
||||
var portal_mode_winner_id: int = -1
|
||||
var is_match_ended: bool = false
|
||||
var obstacle_manager
|
||||
var portal_mode_manager
|
||||
var gauntlet_manager
|
||||
var candy_survival_manager
|
||||
var vfx_manager
|
||||
|
||||
# Minimal local state
|
||||
@@ -33,6 +31,8 @@ func _can_rpc() -> bool:
|
||||
return true
|
||||
|
||||
func _ready():
|
||||
# Always ensure time scale is normal when entering Main
|
||||
Engine.time_scale = 1.0
|
||||
# Initialize scene managers
|
||||
_init_managers()
|
||||
|
||||
@@ -146,11 +146,9 @@ func _apply_arena_background():
|
||||
texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg" # Fallback texture
|
||||
_instantiate_3d_arena("res://scenes/arena/freemode.tscn")
|
||||
_hide_ground_tiles()
|
||||
"Tekton Doors Arena":
|
||||
texture_path = "res://assets/graphics/level_bg/placeholder_tekton_doors.jpg"
|
||||
"Gauntlet Arena":
|
||||
"Candy Survival Arena":
|
||||
texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg"
|
||||
_instantiate_3d_arena("res://scenes/arena/gauntlet.tscn")
|
||||
_instantiate_3d_arena("res://scenes/arena/candy_survival.tscn")
|
||||
_hide_ground_tiles()
|
||||
"Classic", _:
|
||||
texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg"
|
||||
@@ -169,54 +167,55 @@ func _instantiate_3d_arena(scene_path: String):
|
||||
if arena_scene:
|
||||
var arena_instance = arena_scene.instantiate()
|
||||
arena_instance.name = "ArenaEnvironment3D"
|
||||
|
||||
# Note: We no longer shift the arena via code (e.g., position.y = -0.22)
|
||||
# The physical shifts have been baked directly into the .tscn files using EditorTileReference.
|
||||
|
||||
add_child(arena_instance)
|
||||
move_child(arena_instance, 0)
|
||||
|
||||
# Clean up the EditorTileReference if it was left in the scene
|
||||
var editor_ref = arena_instance.get_node_or_null("EditorTileReference")
|
||||
if editor_ref:
|
||||
editor_ref.queue_free()
|
||||
|
||||
print("Instantiated 3D Arena: ", scene_path)
|
||||
|
||||
func _hide_ground_tiles():
|
||||
# Make normal and auxiliary ground floors invisible
|
||||
# by shrinking their scale to 0. We EXCLUDE Item 4 (Wall) and 5 (Freeze)
|
||||
# so they can still be seen above the 3D arena.
|
||||
# by shrinking their scale to 0. We also hide 4 (non-walkable red block)
|
||||
# as 3D arenas provide their own visual boundaries. We EXCLUDE 5 (Freeze).
|
||||
var em = $EnhancedGridMap
|
||||
if em and em.mesh_library:
|
||||
var ml = em.mesh_library.duplicate()
|
||||
for id in [0, 6]:
|
||||
for id in [0, 4, 6]:
|
||||
# Scale to 0 to hide it without triggering invalid mesh errors
|
||||
ml.set_item_mesh_transform(id, Transform3D().scaled(Vector3.ZERO))
|
||||
|
||||
em.mesh_library = ml
|
||||
print("[Main] Hide tiles 0, 6 via zero-scale transform.")
|
||||
print("[Main] Hide tiles 0, 4, 6 via zero-scale transform.")
|
||||
|
||||
func _setup_effect_elevation():
|
||||
var em = get_node_or_null("EnhancedGridMap")
|
||||
if em and em.mesh_library:
|
||||
# USER REQUEST: Do not apply visual Y-elevation for walls in Stop n Go mode
|
||||
if LobbyManager.game_mode == "Stop n Go":
|
||||
print("[Main] Stop n Go mode detected: Skipping effect elevation for walls.")
|
||||
return
|
||||
|
||||
var ml = em.mesh_library.duplicate()
|
||||
|
||||
# Height 0.8: Above 3D arena, but below pickups (Y=1.0)
|
||||
var lift_transform = Transform3D().translated(Vector3(0, 0.28, 0))
|
||||
|
||||
# Lift Wall (4) and Freeze (5)
|
||||
ml.set_item_mesh_transform(4, lift_transform)
|
||||
ml.set_item_mesh_transform(5, lift_transform)
|
||||
# Now that terrain heights are perfectly aligned inside the .tscn files,
|
||||
# we no longer need to dynamically lift all tiles by 0.08.
|
||||
# However, we still need to slightly drop TILE_SAFE (id 2) so it
|
||||
# renders strictly as a floor decal without clipping.
|
||||
if LobbyManager.game_mode in ["Stop n Go", "Freemode"]:
|
||||
if ml.find_item_by_name("safe_zone") != -1 or 2 in ml.get_item_list():
|
||||
var id = 2
|
||||
var current_transform = ml.get_item_mesh_transform(id)
|
||||
# Push it down by -0.02 so it sits perfectly between terrain and tiles
|
||||
ml.set_item_mesh_transform(id, Transform3D().translated(Vector3(0, -0.02, 0)) * current_transform)
|
||||
|
||||
em.mesh_library = ml
|
||||
print("[Main] MeshLibrary elevation applied: Wall(4) and Freeze(5) at Y=0.8")
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func sync_portal_configs(configs: Array):
|
||||
if portal_mode_manager:
|
||||
# Temporarily store the configs and trigger spawn
|
||||
# Note: We use a custom property in manager to pass this
|
||||
portal_mode_manager.set_meta("door_configs", configs)
|
||||
portal_mode_manager._spawn_portal_doors()
|
||||
|
||||
# Force gridmap cell size to match player logic (1, 0.05, 1) - >0.001 to avoid errors
|
||||
var em = $EnhancedGridMap
|
||||
if not em:
|
||||
em = get_node_or_null("EnhancedGridMap")
|
||||
if em:
|
||||
em.cell_size = Vector3(1, 0.05, 1)
|
||||
|
||||
@@ -245,19 +244,13 @@ func _init_managers():
|
||||
add_child(stop_n_go_manager)
|
||||
# No direct initialize() yet, but we'll call start_game_mode later
|
||||
|
||||
# Portal manager for Tekton Doors mode
|
||||
if LobbyManager.game_mode == "Tekton Doors":
|
||||
portal_mode_manager = load("res://scripts/managers/portal_mode_manager.gd").new()
|
||||
portal_mode_manager.name = "PortalModeManager"
|
||||
add_child(portal_mode_manager)
|
||||
portal_mode_manager.initialize(self , $EnhancedGridMap)
|
||||
|
||||
# Gauntlet manager for Candy Pump Survival mode
|
||||
if LobbyManager.game_mode == "Candy Pump Survival":
|
||||
gauntlet_manager = load("res://scripts/managers/gauntlet_manager.gd").new()
|
||||
gauntlet_manager.name = "GauntletManager"
|
||||
add_child(gauntlet_manager)
|
||||
gauntlet_manager.initialize(self, $EnhancedGridMap)
|
||||
# Candy Survival manager
|
||||
if LobbyManager.game_mode == "Candy Survival":
|
||||
candy_survival_manager = load("res://scripts/managers/candy_survival_manager.gd").new()
|
||||
candy_survival_manager.name = "CandySurvivalManager"
|
||||
add_child(candy_survival_manager)
|
||||
candy_survival_manager.initialize(self, $EnhancedGridMap)
|
||||
|
||||
# Screen shake manager for impact feedback
|
||||
screen_shake_manager = load("res://scripts/managers/screen_shake.gd").new()
|
||||
@@ -619,10 +612,8 @@ func _setup_host_game():
|
||||
# Spawning and arena setup
|
||||
if LobbyManager.game_mode == "Stop n Go" and stop_n_go_manager:
|
||||
stop_n_go_manager._setup_arena()
|
||||
elif LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
|
||||
portal_mode_manager.setup_arena_locally()
|
||||
elif LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager:
|
||||
gauntlet_manager._setup_arena()
|
||||
elif LobbyManager.game_mode == "Candy Survival" and candy_survival_manager:
|
||||
candy_survival_manager._setup_arena()
|
||||
else:
|
||||
# Randomize grid first to ensure Floor 0 is walkable for pre-calculation
|
||||
randomize_game_grid()
|
||||
@@ -725,13 +716,9 @@ func _setup_client_game():
|
||||
add_player_character(i, true)
|
||||
print("Client: Pre-spawned potential bot ", i)
|
||||
|
||||
# Initialize arena locally for Tekton Doors
|
||||
if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
|
||||
portal_mode_manager.setup_arena_locally()
|
||||
|
||||
# Initialize arena locally for Candy Pump Survival
|
||||
if LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager:
|
||||
gauntlet_manager._apply_arena_setup()
|
||||
# Initialize arena locally for Candy Survival
|
||||
if LobbyManager.game_mode == "Candy Survival" and candy_survival_manager:
|
||||
candy_survival_manager._apply_arena_setup()
|
||||
|
||||
# Ensure local player setup (UI, controls) is verified
|
||||
var player_character = get_node_or_null(str(my_id))
|
||||
@@ -828,19 +815,15 @@ func _start_game():
|
||||
stop_n_go_manager.setup_mission_tiles()
|
||||
stop_n_go_manager.spawn_initial_powerups() # Ensure power-ups exist before 1,2,3 Go
|
||||
|
||||
# Gauntlet: Spawn mission tiles across 20x20 arena BEFORE countdown
|
||||
if LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager:
|
||||
gauntlet_manager.setup_mission_tiles()
|
||||
# Candy Survival: Spawn mission tiles BEFORE countdown
|
||||
if LobbyManager.game_mode == "Candy Survival" and candy_survival_manager:
|
||||
candy_survival_manager.setup_mission_tiles()
|
||||
|
||||
# Spawn Static Tektons and random tiles BEFORE countdown (Free Mode Only)
|
||||
# Exclude for Stop n Go and Tekton Doors
|
||||
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors" and LobbyManager.game_mode != "Candy Pump Survival":
|
||||
# Exclude for Stop n Go and Candy Survival
|
||||
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Candy Survival":
|
||||
spawn_static_tektons()
|
||||
|
||||
# Tekton Doors: Randomize connections BEFORE countdown so colors show
|
||||
if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
|
||||
portal_mode_manager._randomize_connections()
|
||||
|
||||
# STOP N GO: Rotate players to face East BEFORE countdown
|
||||
if LobbyManager.game_mode == "Stop n Go" and stop_n_go_manager:
|
||||
stop_n_go_manager.rotate_players_to_start()
|
||||
@@ -866,16 +849,9 @@ func _start_game():
|
||||
if goals_cycle_manager:
|
||||
var match_duration = LobbyManager.get_match_duration()
|
||||
goals_cycle_manager.start_match(float(match_duration), false) # No cycles for Stop n Go
|
||||
elif LobbyManager.game_mode == "Tekton Doors":
|
||||
if portal_mode_manager:
|
||||
portal_mode_manager.start_game_mode()
|
||||
|
||||
if goals_cycle_manager:
|
||||
var match_duration = LobbyManager.get_match_duration()
|
||||
goals_cycle_manager.start_match(float(match_duration))
|
||||
elif LobbyManager.game_mode == "Candy Pump Survival":
|
||||
if gauntlet_manager:
|
||||
gauntlet_manager.start_game_mode()
|
||||
elif LobbyManager.game_mode == "Candy Survival":
|
||||
if candy_survival_manager:
|
||||
candy_survival_manager.start_game_mode()
|
||||
|
||||
if goals_cycle_manager:
|
||||
var match_duration = LobbyManager.get_match_duration()
|
||||
@@ -986,12 +962,6 @@ func _assign_random_spawn_positions():
|
||||
_assign_stop_n_go_spawn_positions(all_players)
|
||||
return
|
||||
|
||||
# Tekton Doors Custom Spawn Logic
|
||||
if LobbyManager.game_mode == "Tekton Doors":
|
||||
var all_players = get_tree().get_nodes_in_group("Players")
|
||||
_assign_portal_mode_spawn_positions(all_players)
|
||||
return
|
||||
|
||||
# If static positions were not calculated yet, do it now to avoid players spawning in them
|
||||
if reserved_static_positions.is_empty() and LobbyManager.game_mode != "Stop n Go":
|
||||
if not static_tekton_manager:
|
||||
@@ -1116,77 +1086,6 @@ func _assign_stop_n_go_spawn_positions(all_players: Array):
|
||||
|
||||
print("[StopNGo] Assigned spawn %s to player %s" % [assigned_pos, player.name])
|
||||
|
||||
func _assign_portal_mode_spawn_positions(all_players: Array):
|
||||
"""Assigns spawns to different quadrants for Tekton Doors mode, avoiding stands and intersections."""
|
||||
if not portal_mode_manager:
|
||||
_assign_random_spawn_positions() # Fallback
|
||||
return
|
||||
|
||||
# Sort players for deterministic assignment
|
||||
all_players.sort_custom(func(a, b): return a.name.to_int() < b.name.to_int())
|
||||
|
||||
# Get baseline quadrant centers (3,3), (10,3), etc.
|
||||
var base_spawn_points = portal_mode_manager.get_spawn_points()
|
||||
var spawn_index = 0
|
||||
var assigned_positions: Array[Vector2i] = []
|
||||
|
||||
for player in all_players:
|
||||
var center_pos = base_spawn_points[spawn_index % base_spawn_points.size()]
|
||||
var assigned_pos = center_pos # Fallback position
|
||||
|
||||
# Spiral search for a valid spot (walkable, not in stand zone, not occupied)
|
||||
var found = false
|
||||
for radius in range(0, 5): # Increase search radius
|
||||
for dx in range(-radius, radius + 1):
|
||||
for dz in range(-radius, radius + 1):
|
||||
# Only check the "ring" at the current radius
|
||||
if abs(dx) != radius and abs(dz) != radius and radius > 0:
|
||||
continue
|
||||
|
||||
var test_pos = center_pos + Vector2i(dx, dz)
|
||||
|
||||
# 1. Check map bounds
|
||||
var em = $EnhancedGridMap
|
||||
if not em or test_pos.x < 0 or test_pos.x >= em.columns or test_pos.y < 0 or test_pos.y >= em.rows:
|
||||
continue
|
||||
|
||||
# 2. Check if walkable floor (Floor 0, ID 0)
|
||||
if em.get_cell_item(Vector3i(test_pos.x, 0, test_pos.y)) != 0:
|
||||
continue
|
||||
|
||||
# 3. Check if reserved for a Static Tekton Stand (3x3 area, use 2-tile buffer)
|
||||
var is_reserved = false
|
||||
for reserved in reserved_static_positions:
|
||||
if abs(test_pos.x - reserved.x) <= 2 and abs(test_pos.y - reserved.y) <= 2:
|
||||
is_reserved = true
|
||||
break
|
||||
if is_reserved:
|
||||
continue
|
||||
|
||||
# 4. Check if occupied by another already-assigned player
|
||||
if assigned_positions.has(test_pos):
|
||||
continue
|
||||
|
||||
assigned_pos = test_pos
|
||||
found = true
|
||||
break
|
||||
if found: break
|
||||
if found: break
|
||||
|
||||
assigned_positions.append(assigned_pos)
|
||||
|
||||
# Sync and place
|
||||
player.position = player.grid_to_world(assigned_pos)
|
||||
player.current_position = assigned_pos
|
||||
player.is_player_moving = false
|
||||
player.spawn_point_selected = true
|
||||
|
||||
if can_rpc():
|
||||
player.rpc("set_spawn_position", assigned_pos)
|
||||
|
||||
spawn_index += 1
|
||||
print("[PortalMode] Assigned Quadrant Pos %s to player %s" % [assigned_pos, player.name])
|
||||
|
||||
# =============================================================================
|
||||
# Tekton NPC Management
|
||||
# =============================================================================
|
||||
@@ -1387,7 +1286,7 @@ func _create_static_setup(pos: Vector2i, tekton_id: int, shape_idx: int):
|
||||
if "cell_size" in enhanced_gridmap:
|
||||
world_pos = Vector3(
|
||||
pos.x * enhanced_gridmap.cell_size.x + enhanced_gridmap.cell_size.x / 2,
|
||||
0.28, # Match the 0.28 elevation of the arena floor
|
||||
0.0, # Floor is now at 0.0 after arena shift
|
||||
pos.y * enhanced_gridmap.cell_size.z + enhanced_gridmap.cell_size.z / 2
|
||||
)
|
||||
stand.global_position = world_pos
|
||||
@@ -1512,7 +1411,7 @@ func _on_peer_connected(new_peer_id: int):
|
||||
var player_goals = GoalManager.preset_goals[player_index].duplicate()
|
||||
player.goals = player_goals
|
||||
# Update goals UI for all clients
|
||||
call_deferred("_deferred_set_player_goals", new_peer_id, player_goals)
|
||||
rpc("sync_player_goals", new_peer_id, player_goals)
|
||||
|
||||
@rpc
|
||||
func add_newly_connected_player_character(new_peer_id: int):
|
||||
@@ -1639,9 +1538,13 @@ func sync_game_start(player_list: Array, is_turn_based: bool):
|
||||
stop_n_go_manager.name = "StopNGoManager"
|
||||
add_child(stop_n_go_manager)
|
||||
stop_n_go_manager.activate_client_side()
|
||||
elif LobbyManager.game_mode == "Tekton Doors":
|
||||
if portal_mode_manager:
|
||||
portal_mode_manager.activate_client_side()
|
||||
elif LobbyManager.game_mode == "Candy Survival":
|
||||
if not candy_survival_manager:
|
||||
candy_survival_manager = load("res://scripts/managers/candy_survival_manager.gd").new()
|
||||
candy_survival_manager.name = "CandySurvivalManager"
|
||||
add_child(candy_survival_manager)
|
||||
candy_survival_manager.initialize(self, $EnhancedGridMap)
|
||||
candy_survival_manager.activate_client_side()
|
||||
|
||||
# Initialize leaderboard for all peers (after a delay to ensure players loaded)
|
||||
call_deferred("_deferred_init_leaderboard")
|
||||
@@ -1878,7 +1781,7 @@ func randomize_item_at_position(grid_position: Vector2i):
|
||||
|
||||
if is_ground:
|
||||
var get_mode_specific_tile = func():
|
||||
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors" and LobbyManager.game_mode != "Candy Pump Survival":
|
||||
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Candy Survival":
|
||||
# 60% Chance for Common (7-10), 40% for PowerUp
|
||||
if randf() <= 0.6:
|
||||
return [7, 8, 9, 10].pick_random()
|
||||
@@ -1924,13 +1827,6 @@ func sync_grid_item(x: int, y: int, z: int, item: int):
|
||||
if f0 in [4, -1] or f1 == 4:
|
||||
return
|
||||
|
||||
# TEKTON DOORS: Prevent placing items on portal doors
|
||||
if LobbyManager.is_game_mode(GameMode.Mode.TEKTON_DOORS):
|
||||
var doors = get_tree().get_nodes_in_group("PortalDoors")
|
||||
for door in doors:
|
||||
var door_grid = enhanced_gridmap.local_to_map(enhanced_gridmap.to_local(door.global_position))
|
||||
if door_grid.x == x and door_grid.z == z:
|
||||
return
|
||||
|
||||
enhanced_gridmap.set_cell_item(Vector3i(x, y, z), item)
|
||||
# Force visual update
|
||||
@@ -2098,17 +1994,6 @@ func sync_grid_items_batch(data: Array):
|
||||
if f0 in [4, -1] or f1 == 4:
|
||||
continue
|
||||
|
||||
# TEKTON DOORS: Prevent placing items on portal doors
|
||||
if LobbyManager.is_game_mode(GameMode.Mode.TEKTON_DOORS) and y == 1:
|
||||
var doors = get_tree().get_nodes_in_group("PortalDoors")
|
||||
var on_door = false
|
||||
for door in doors:
|
||||
var door_grid = enhanced_gridmap.local_to_map(enhanced_gridmap.to_local(door.global_position))
|
||||
if door_grid.x == x and door_grid.z == z:
|
||||
on_door = true
|
||||
break
|
||||
if on_door: continue
|
||||
|
||||
enhanced_gridmap.set_cell_item(Vector3i(x, y, z), item)
|
||||
|
||||
# Force visual update ONCE after batch
|
||||
@@ -2116,7 +2001,7 @@ func sync_grid_items_batch(data: Array):
|
||||
enhanced_gridmap.update_grid_data()
|
||||
|
||||
func randomize_game_grid():
|
||||
if LobbyManager.game_mode == "Stop n Go" or LobbyManager.game_mode == "Tekton Doors":
|
||||
if LobbyManager.game_mode == "Stop n Go":
|
||||
return # These modes manage their own arena setup and item spawning
|
||||
|
||||
var enhanced_gridmap = $EnhancedGridMap
|
||||
@@ -2165,6 +2050,8 @@ func request_full_grid_sync():
|
||||
if stop_n_go_manager.player_missions.has(sender_id):
|
||||
var mission_dict = {sender_id: stop_n_go_manager.player_missions[sender_id]}
|
||||
stop_n_go_manager.rpc_id(sender_id, "sync_missions", mission_dict)
|
||||
elif LobbyManager.game_mode == "Candy Survival" and candy_survival_manager:
|
||||
candy_survival_manager.sync_state_to_player(sender_id)
|
||||
|
||||
# For all modes, only sync Floor 1 (Items) to prevent MTU packet overflow.
|
||||
# Floor 0 logic is deterministic and generated locally on level load.
|
||||
@@ -2178,10 +2065,6 @@ func request_full_grid_sync():
|
||||
rpc_id(sender_id, "sync_full_grid_data", grid_data)
|
||||
print("[Main] Server: Sent grid sync rpc_id to %d" % sender_id)
|
||||
|
||||
# If Tekton Doors, sync portal connections too
|
||||
if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
|
||||
portal_mode_manager.sync_to_client(sender_id)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_full_grid_data(data: PackedInt32Array):
|
||||
print("[Main] sync_full_grid_data received. Items: %d" % (data.size() / 3))
|
||||
@@ -2197,13 +2080,13 @@ func sync_full_grid_data(data: PackedInt32Array):
|
||||
stop_n_go_manager.name = "StopNGoManager"
|
||||
add_child(stop_n_go_manager)
|
||||
stop_n_go_manager._apply_arena_setup()
|
||||
elif LobbyManager.game_mode == "Tekton Doors":
|
||||
if not portal_mode_manager:
|
||||
portal_mode_manager = load("res://scripts/managers/portal_mode_manager.gd").new()
|
||||
portal_mode_manager.name = "PortalModeManager"
|
||||
add_child(portal_mode_manager)
|
||||
portal_mode_manager.initialize(self , enhanced_gridmap)
|
||||
portal_mode_manager.setup_arena_locally()
|
||||
elif LobbyManager.game_mode == "Candy Survival":
|
||||
if not candy_survival_manager:
|
||||
candy_survival_manager = load("res://scripts/managers/candy_survival_manager.gd").new()
|
||||
candy_survival_manager.name = "CandySurvivalManager"
|
||||
add_child(candy_survival_manager)
|
||||
candy_survival_manager.initialize(self, enhanced_gridmap)
|
||||
candy_survival_manager._apply_arena_setup()
|
||||
else:
|
||||
# Freemode: Ensure Floor 0 is entirely walkable (reset stale state from previous modes)
|
||||
for x in range(enhanced_gridmap.columns):
|
||||
@@ -2337,27 +2220,6 @@ func sync_game_end_stop_n_go(winner_id: int):
|
||||
# Trigger match end
|
||||
_on_match_ended()
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func sync_game_end_portal_mode(winner_id: int):
|
||||
print("[TEKTON DOORS] Game ended! Winner: ", winner_id)
|
||||
portal_mode_winner_id = winner_id
|
||||
|
||||
var winner_name = "Player " + str(winner_id)
|
||||
var player_node = get_node_or_null(str(winner_id))
|
||||
if player_node:
|
||||
winner_name = player_node.display_name
|
||||
|
||||
# Broadcast win
|
||||
add_message_to_bar("MATCH COMPLETE", winner_name + " Wins with 8 Missions!", MessageType.GOAL)
|
||||
|
||||
# Stop logic
|
||||
if portal_mode_manager:
|
||||
if portal_mode_manager.swap_timer: portal_mode_manager.swap_timer.stop()
|
||||
if portal_mode_manager.tile_refresh_timer: portal_mode_manager.tile_refresh_timer.stop()
|
||||
|
||||
# Trigger match end
|
||||
_on_match_ended()
|
||||
|
||||
func _on_match_ended():
|
||||
"""Called when the global match timer ends - show game over screen."""
|
||||
if is_match_ended:
|
||||
@@ -2422,9 +2284,6 @@ func _show_game_over_panel():
|
||||
if stop_n_go_manager and stop_n_go_manager.hud_layer:
|
||||
stop_n_go_manager.hud_layer.hide()
|
||||
|
||||
if portal_mode_manager and portal_mode_manager.hud_layer:
|
||||
portal_mode_manager.hud_layer.hide()
|
||||
|
||||
# =========================================================================
|
||||
# Gather + sort player data
|
||||
# =========================================================================
|
||||
@@ -2446,12 +2305,6 @@ func _show_game_over_panel():
|
||||
if b.peer_id == stop_n_go_winner_id: return false
|
||||
return a.score > b.score
|
||||
)
|
||||
elif LobbyManager.game_mode == "Tekton Doors" and portal_mode_winner_id != -1:
|
||||
all_player_scores.sort_custom(func(a, b):
|
||||
if a.peer_id == portal_mode_winner_id: return true
|
||||
if b.peer_id == portal_mode_winner_id: return false
|
||||
return a.score > b.score
|
||||
)
|
||||
else:
|
||||
all_player_scores.sort_custom(func(a, b): return a.score > b.score)
|
||||
|
||||
@@ -2954,3 +2807,12 @@ func display_message(message: String, type: int = 0):
|
||||
if player.has_method("display_message"):
|
||||
player.display_message(message, type)
|
||||
break
|
||||
|
||||
func get_player_ids() -> Array:
|
||||
var pids: Array = []
|
||||
for p in get_tree().get_nodes_in_group("Players"):
|
||||
if not is_instance_valid(p): continue
|
||||
var pid = p.get("peer_id") if "peer_id" in p else p.name.to_int()
|
||||
if pid != 0 and not pids.has(pid):
|
||||
pids.append(pid)
|
||||
return pids
|
||||
|
||||
@@ -2249,25 +2249,6 @@ text = "[b]Stop n Go[/b]
|
||||
- Your objective is to reach the mission tiles at the far end of the arena and safely carry them back to your starting zone.
|
||||
- The first player to complete 8 missions and reach the finish floor wins."
|
||||
|
||||
[node name="Tekton Doors" type="MarginContainer" parent="HowToPlayPanel/Panel/VBox/TabContainer" unique_id=123456799]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 10
|
||||
theme_override_constants/margin_top = 10
|
||||
theme_override_constants/margin_right = 10
|
||||
theme_override_constants/margin_bottom = 10
|
||||
metadata/_tab_index = 2
|
||||
|
||||
[node name="RichTextLabel" type="RichTextLabel" parent="HowToPlayPanel/Panel/VBox/TabContainer/Tekton Doors" unique_id=123456800]
|
||||
layout_mode = 2
|
||||
bbcode_enabled = true
|
||||
text = "[b]Tekton Doors[/b]
|
||||
|
||||
- Navigate a sprawling arena connected by color-coded portal doors.
|
||||
- Grab tiles and match goal patterns to earn mission completions.
|
||||
- Use doors to quickly teleport across rooms, but watch out for closures and traps.
|
||||
- The first player to complete 8 missions and reach the finish room wins."
|
||||
|
||||
[node name="Controls" type="MarginContainer" parent="HowToPlayPanel/Panel/VBox/TabContainer" unique_id=123456805]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
@@ -2275,7 +2256,7 @@ theme_override_constants/margin_left = 10
|
||||
theme_override_constants/margin_top = 10
|
||||
theme_override_constants/margin_right = 10
|
||||
theme_override_constants/margin_bottom = 10
|
||||
metadata/_tab_index = 3
|
||||
metadata/_tab_index = 2
|
||||
|
||||
[node name="RichTextLabel" type="RichTextLabel" parent="HowToPlayPanel/Panel/VBox/TabContainer/Controls" unique_id=123456806]
|
||||
layout_mode = 2
|
||||
@@ -2296,7 +2277,7 @@ theme_override_constants/margin_left = 10
|
||||
theme_override_constants/margin_top = 10
|
||||
theme_override_constants/margin_right = 10
|
||||
theme_override_constants/margin_bottom = 10
|
||||
metadata/_tab_index = 4
|
||||
metadata/_tab_index = 3
|
||||
|
||||
[node name="RichTextLabel" type="RichTextLabel" parent="HowToPlayPanel/Panel/VBox/TabContainer/The Grid" unique_id=123456808]
|
||||
layout_mode = 2
|
||||
@@ -2315,7 +2296,7 @@ theme_override_constants/margin_left = 10
|
||||
theme_override_constants/margin_top = 10
|
||||
theme_override_constants/margin_right = 10
|
||||
theme_override_constants/margin_bottom = 10
|
||||
metadata/_tab_index = 5
|
||||
metadata/_tab_index = 4
|
||||
|
||||
[node name="RichTextLabel" type="RichTextLabel" parent="HowToPlayPanel/Panel/VBox/TabContainer/Tektons" unique_id=123456810]
|
||||
layout_mode = 2
|
||||
@@ -2334,7 +2315,7 @@ theme_override_constants/margin_left = 10
|
||||
theme_override_constants/margin_top = 10
|
||||
theme_override_constants/margin_right = 10
|
||||
theme_override_constants/margin_bottom = 10
|
||||
metadata/_tab_index = 6
|
||||
metadata/_tab_index = 5
|
||||
|
||||
[node name="RichTextLabel" type="RichTextLabel" parent="HowToPlayPanel/Panel/VBox/TabContainer/Skills" unique_id=123456812]
|
||||
layout_mode = 2
|
||||
|
||||
@@ -601,6 +601,47 @@ func _get_all_mesh_instances(node: Node) -> Array:
|
||||
result.append_array(_get_all_mesh_instances(child))
|
||||
return result
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# RPCs
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
var _candy_scene: PackedScene = preload("res://scenes/candy.tscn")
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func sync_candy_stack(colors: Array) -> void:
|
||||
var old_stack = get_node_or_null("CandyStack")
|
||||
if old_stack:
|
||||
old_stack.name = "CandyStack_Old_" + str(randi())
|
||||
old_stack.queue_free()
|
||||
|
||||
if colors.is_empty() or not _candy_scene: return
|
||||
|
||||
var stack_node = Node3D.new()
|
||||
stack_node.name = "CandyStack"
|
||||
add_child(stack_node)
|
||||
|
||||
stack_node.position = Vector3(0, 1.8, 0)
|
||||
|
||||
var candy_colors = {
|
||||
0: Color(1.0, 0.3, 0.3), # HEART = red
|
||||
1: Color(0.3, 0.6, 1.0), # DIAMOND = blue
|
||||
2: Color(0.0, 1.0, 0.4), # STAR = green
|
||||
3: Color(1.0, 0.8, 0.0), # COIN = gold
|
||||
}
|
||||
|
||||
for i in range(colors.size()):
|
||||
var color_id = int(colors[i])
|
||||
var base_color = candy_colors.get(color_id, Color.WHITE)
|
||||
var candy = _candy_scene.instantiate()
|
||||
candy.position = Vector3(0, i * 0.35, 0)
|
||||
# Tint material to match color
|
||||
var mesh = candy.get_node_or_null("Mesh")
|
||||
if mesh and mesh.material_override:
|
||||
var mat = mesh.material_override.duplicate()
|
||||
mat.albedo_color = base_color
|
||||
mesh.material_override = mat
|
||||
stack_node.add_child(candy)
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func sync_character(character_name: String) -> void:
|
||||
"""Sync character selection across all clients."""
|
||||
@@ -1140,12 +1181,12 @@ func _find_valid_drop_position() -> Vector2i:
|
||||
var item_cell = Vector3i(pos.x, 1, pos.y)
|
||||
if enhanced_gridmap.get_cell_item(item_cell) == -1:
|
||||
if not is_position_occupied(pos):
|
||||
# Gauntlet Mode explicit overrides
|
||||
# Candy Survival explicit overrides
|
||||
var gm = null
|
||||
var main_gauntlet = get_tree().root.get_node_or_null("Main")
|
||||
if main_gauntlet and main_gauntlet.get("gauntlet_manager"):
|
||||
gm = main_gauntlet.gauntlet_manager
|
||||
if gm and gm.is_active:
|
||||
var main_candy_survival = get_tree().root.get_node_or_null("Main")
|
||||
if main_candy_survival and main_candy_survival.get("candy_survival_manager"):
|
||||
gm = main_candy_survival.candy_survival_manager
|
||||
if gm and gm.active:
|
||||
if pos.x == 0 or pos.x == gm.ARENA_COLUMNS - 1 or pos.y == 0 or pos.y == gm.ARENA_ROWS - 1:
|
||||
continue
|
||||
if gm._is_npc_zone(pos):
|
||||
@@ -1768,13 +1809,6 @@ func start_movement_along_path(path: Array, clear_visual: bool = true, force: bo
|
||||
if sng_manager.check_win_condition(name.to_int(), current_position):
|
||||
sng_main.rpc("sync_game_end_stop_n_go", name.to_int())
|
||||
|
||||
# Tekton Doors Win Check
|
||||
elif LobbyManager.game_mode == "Tekton Doors":
|
||||
var main_node = get_tree().root.get_node_or_null("Main")
|
||||
if main_node and main_node.portal_mode_manager:
|
||||
if main_node.portal_mode_manager.check_win_condition(name.to_int(), current_position):
|
||||
main_node.rpc("sync_game_end_portal_mode", name.to_int())
|
||||
|
||||
# FORCE SNAP: Update target visual position to the perfect grid center
|
||||
# This ensures that when interpolation resumes (in _process), it pulls to the correct spot
|
||||
target_visual_position = grid_to_world(current_position)
|
||||
@@ -2050,6 +2084,18 @@ func bot_try_grab_item() -> bool:
|
||||
#return true
|
||||
# -----------------------------------------------------------------
|
||||
func grab_item(grid_position: Vector2i = current_position) -> bool:
|
||||
if LobbyManager.game_mode == "Candy Survival":
|
||||
# Check distance to Mekton NPC at (8, 8)
|
||||
var dist = abs(current_position.x - 8) + abs(current_position.y - 8)
|
||||
if dist <= 2:
|
||||
var gm = get_node_or_null("/root/Main/CandySurvivalManager")
|
||||
if gm and gm.active:
|
||||
var pid = get("peer_id") if "peer_id" in self else name.to_int()
|
||||
if multiplayer.is_server():
|
||||
gm.try_deliver(pid)
|
||||
else:
|
||||
gm.rpc_id(1, "try_deliver", pid)
|
||||
return true
|
||||
return playerboard_manager.grab_item(grid_position)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
[gd_scene load_steps=8 format=3 uid="uid://portal_door_001"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/portal_door.gd" id="1_script"]
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_frame"]
|
||||
size = Vector3(0.15, 2.2, 0.15)
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_frame"]
|
||||
albedo_color = Color(0.1, 0.5, 0.8, 1)
|
||||
metallic = 0.8
|
||||
roughness = 0.2
|
||||
|
||||
[sub_resource type="PlaneMesh" id="PlaneMesh_ground"]
|
||||
size = Vector2(1.0, 1.0)
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_ground"]
|
||||
transparency = 1
|
||||
albedo_color = Color(1, 1, 1, 0.4)
|
||||
emission_enabled = true
|
||||
emission = Color(1, 1, 1, 1)
|
||||
emission_energy_multiplier = 1.0
|
||||
|
||||
[sub_resource type="PlaneMesh" id="PlaneMesh_vortex"]
|
||||
size = Vector2(1.4, 2.1)
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_vortex"]
|
||||
transparency = 1
|
||||
albedo_color = Color(0.0, 0.6, 1.0, 0.4)
|
||||
emission_enabled = true
|
||||
emission = Color(0.0, 0.4, 1.0, 1)
|
||||
emission_energy_multiplier = 5.0
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_trigger"]
|
||||
size = Vector3(1.4, 2.1, 0.8)
|
||||
|
||||
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_portal"]
|
||||
properties/0/path = NodePath(":target_room_id")
|
||||
properties/0/spawn = true
|
||||
properties/0/replication_mode = 2
|
||||
properties/1/path = NodePath(":target_door_id")
|
||||
properties/1/spawn = true
|
||||
properties/1/replication_mode = 2
|
||||
properties/2/path = NodePath(":is_active")
|
||||
properties/2/spawn = true
|
||||
properties/2/replication_mode = 2
|
||||
properties/3/path = NodePath(":portal_color")
|
||||
properties/3/spawn = true
|
||||
properties/3/replication_mode = 2
|
||||
|
||||
[node name="PortalDoor" type="StaticBody3D"]
|
||||
script = ExtResource("1_script")
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.1, 0)
|
||||
shape = SubResource("BoxShape3D_trigger")
|
||||
|
||||
|
||||
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."]
|
||||
replication_config = SubResource("SceneReplicationConfig_portal")
|
||||
|
||||
[node name="Frame_Left" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.75, 1.1, 0)
|
||||
mesh = SubResource("BoxMesh_frame")
|
||||
surface_material_override/0 = SubResource("StandardMaterial3D_frame")
|
||||
|
||||
[node name="Frame_Right" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.75, 1.1, 0)
|
||||
mesh = SubResource("BoxMesh_frame")
|
||||
surface_material_override/0 = SubResource("StandardMaterial3D_frame")
|
||||
|
||||
[node name="Frame_Top" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(-4.37114e-08, -1, 0, 1, -4.37114e-08, 0, 0, 0, 1, 0, 2.2, 0)
|
||||
mesh = SubResource("BoxMesh_frame")
|
||||
surface_material_override/0 = SubResource("StandardMaterial3D_frame")
|
||||
|
||||
[node name="Vortex" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, -4.37114e-08, -1, 0, 1, -4.37114e-08, 0, 1.1, 0)
|
||||
mesh = SubResource("PlaneMesh_vortex")
|
||||
surface_material_override/0 = SubResource("StandardMaterial3D_vortex")
|
||||
|
||||
[node name="GroundIndicator" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.05, 0.4)
|
||||
mesh = SubResource("PlaneMesh_ground")
|
||||
surface_material_override/0 = SubResource("StandardMaterial3D_ground")
|
||||
|
||||
[node name="Area3D" type="Area3D" parent="."]
|
||||
collision_layer = 0
|
||||
collision_mask = 2
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Area3D"]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.1, 0)
|
||||
shape = SubResource("BoxShape3D_trigger")
|
||||
@@ -293,7 +293,8 @@ size_flags_horizontal = 0
|
||||
size_flags_vertical = 4
|
||||
texture = ExtResource("tex_gold")
|
||||
|
||||
[node name="GoldLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel/MarginContainer/HBoxContainer" unique_id=1159494172]
|
||||
[node name="LeftGoldLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel/MarginContainer/HBoxContainer" unique_id=1159494172]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_fonts/font = ExtResource("3_font")
|
||||
@@ -327,7 +328,8 @@ size_flags_horizontal = 0
|
||||
size_flags_vertical = 4
|
||||
texture = ExtResource("tex_star")
|
||||
|
||||
[node name="StarLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel/MarginContainer/HBoxContainer" unique_id=1357103504]
|
||||
[node name="LeftStarLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel/MarginContainer/HBoxContainer" unique_id=1357103504]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_fonts/font = ExtResource("3_font")
|
||||
|
||||
@@ -51,9 +51,6 @@ func _init(p_lobby: Control):
|
||||
LobbyManager.sng_go_duration_changed.connect(_on_sng_update)
|
||||
LobbyManager.sng_stop_duration_changed.connect(_on_sng_update)
|
||||
LobbyManager.sng_required_goals_changed.connect(_on_sng_update)
|
||||
LobbyManager.doors_swap_time_changed.connect(_on_doors_update)
|
||||
LobbyManager.doors_refresh_time_changed.connect(_on_doors_update)
|
||||
LobbyManager.doors_required_goals_changed.connect(_on_doors_update)
|
||||
|
||||
FriendManager.lobby_invite_received.connect(_on_lobby_invite_received)
|
||||
|
||||
@@ -136,21 +133,8 @@ func _on_sng_update(_val: int = 0) -> void:
|
||||
if go_idx != -1: lobby.sng_go_option.selected = go_idx
|
||||
var stop_idx = [3, 4, 5].find(LobbyManager.sng_stop_duration)
|
||||
if stop_idx != -1: lobby.sng_stop_option.selected = stop_idx
|
||||
var goals_idx = [5, 8, 12].find(LobbyManager.sng_required_goals)
|
||||
if goals_idx != -1: lobby.sng_goals_option.selected = goals_idx
|
||||
|
||||
func _on_doors_update(_val: int = 0) -> void:
|
||||
if not lobby.doors_swap_option: return
|
||||
var swap_idx = [10, 15, 30].find(LobbyManager.doors_swap_time)
|
||||
if swap_idx != -1: lobby.doors_swap_option.selected = swap_idx
|
||||
var refresh_idx = [15, 25, 40].find(LobbyManager.doors_refresh_time)
|
||||
if refresh_idx != -1: lobby.doors_refresh_option.selected = refresh_idx
|
||||
var goals_idx = [5, 8, 12].find(LobbyManager.doors_required_goals)
|
||||
if goals_idx != -1: lobby.doors_goals_option.selected = goals_idx
|
||||
|
||||
# =============================================================================
|
||||
# LobbyManager Signal Handlers
|
||||
# =============================================================================
|
||||
var goals_idx2 = [5, 8, 12].find(LobbyManager.sng_required_goals)
|
||||
if goals_idx2 != -1: lobby.sng_goals_option.selected = goals_idx2
|
||||
|
||||
func _on_room_joined(room_data: Dictionary) -> void:
|
||||
lobby._show_panel("lobby")
|
||||
@@ -169,7 +153,7 @@ func _on_room_joined(room_data: Dictionary) -> void:
|
||||
_on_enable_cycle_timer_changed(LobbyManager.enable_cycle_timer)
|
||||
_on_scarcity_mode_changed(LobbyManager.scarcity_mode)
|
||||
_on_sng_update()
|
||||
_on_doors_update()
|
||||
# _on_doors_update()
|
||||
|
||||
if lobby.area_left_btn: lobby.area_left_btn.disabled = not is_host
|
||||
if lobby.area_right_btn: lobby.area_right_btn.disabled = not is_host
|
||||
|
||||
@@ -106,7 +106,7 @@ func _physics_process(delta):
|
||||
return
|
||||
|
||||
# Only run if game has started
|
||||
if not GameStateManager.is_game_started():
|
||||
if not Engine.get_main_loop().root.get_node_or_null("GameStateManager").is_game_started():
|
||||
return
|
||||
|
||||
# STUCK PREVENTION
|
||||
@@ -143,7 +143,7 @@ func _physics_process(delta):
|
||||
|
||||
# Rate limiting (with difficulty scaling for Stop n Go)
|
||||
var current_tick_rate = tick_rate
|
||||
if LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) and actor.current_position.x > 10:
|
||||
if Engine.get_main_loop().root.get_node_or_null("LobbyManager").is_game_mode(GameMode.Mode.STOP_N_GO) and actor.current_position.x > 10:
|
||||
current_tick_rate = int(tick_rate * 0.6) # 40% faster updates after column 10
|
||||
|
||||
_tick_counter += 1
|
||||
@@ -168,7 +168,7 @@ func _run_ai_tick():
|
||||
if actor.is_player_moving:
|
||||
return
|
||||
|
||||
if TurnManager.turn_based_mode:
|
||||
if Engine.get_main_loop().root.get_node_or_null("TurnManager").turn_based_mode:
|
||||
_tick_counter = tick_rate # Force immediate evaluation in turn-based
|
||||
|
||||
# Update cooldowns
|
||||
@@ -176,7 +176,7 @@ func _run_ai_tick():
|
||||
_tekton_spawn_cooldown -= get_process_delta_time()
|
||||
|
||||
# STOP N GO: Don't process if already at finish line
|
||||
if LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) and actor.current_position.x >= 21:
|
||||
if Engine.get_main_loop().root.get_node_or_null("LobbyManager").is_game_mode(GameMode.Mode.STOP_N_GO) and actor.current_position.x >= 21:
|
||||
return
|
||||
|
||||
# STOP N GO: Red light freezing logic
|
||||
@@ -193,7 +193,7 @@ func _run_ai_tick():
|
||||
print("[BotController] %s AI Tick. Goals: %s, Fullness: %.2f" % [actor.name, actor.goals, board_fullness])
|
||||
|
||||
# 0. BOT AGGRESSION THRESHOLD (Stop n Go)
|
||||
var is_sng = LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO)
|
||||
var is_sng = Engine.get_main_loop().root.get_node_or_null("LobbyManager").is_game_mode(GameMode.Mode.STOP_N_GO)
|
||||
var can_be_aggressive = not is_sng or actor.current_position.x > 10
|
||||
|
||||
# PRIORITY OVERRIDE: If board is getting full, prioritize clearing space!
|
||||
@@ -210,11 +210,47 @@ func _run_ai_tick():
|
||||
print("[BotController] Action Taken: Attack Pursuit")
|
||||
return
|
||||
|
||||
# Priority 0.5: Gauntlet (#075) — use Ghost powerup if boxed in
|
||||
# Priority 0.3: Candy Survival — auto-toggle knock mode when bot has charges
|
||||
if strategic_planner and strategic_planner.is_candy_survival_mode():
|
||||
var gm = strategic_planner._get_candy_survival_manager()
|
||||
if gm and gm.active:
|
||||
var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int()
|
||||
var charges = gm.player_knocks.get(pid, 0)
|
||||
if charges > 0:
|
||||
actor.is_charged_strike = true
|
||||
else:
|
||||
actor.is_charged_strike = false
|
||||
|
||||
# Priority 0.5: Candy Survival (#075) — use Ghost powerup if boxed in
|
||||
if await _try_activate_ghost():
|
||||
print("[BotController] Action Taken: Ghost (trapped)")
|
||||
return
|
||||
|
||||
# Priority 0.7: Candy Survival — Deliver candies to Mekton NPC
|
||||
if strategic_planner and strategic_planner.is_candy_survival_mode():
|
||||
var gm = strategic_planner._get_candy_survival_manager()
|
||||
if gm and gm.active:
|
||||
var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int()
|
||||
var held_color = gm.player_candy_color.get(pid, -1)
|
||||
if held_color != -1 and gm.player_candies.get(pid, 0) > 0:
|
||||
if held_color == gm.current_face:
|
||||
# Face matches! Run to the NPC Center!
|
||||
var path = enhanced_gridmap.find_path(
|
||||
Vector2(actor.current_position),
|
||||
Vector2(8, 8),
|
||||
0, false, false
|
||||
)
|
||||
if path.size() >= 2:
|
||||
var next_step = Vector2i(path[1].x, path[1].y)
|
||||
if actor.movement_manager.simple_move_to(next_step):
|
||||
_is_processing_action = true
|
||||
_current_action = "delivering_candies"
|
||||
while is_instance_valid(actor) and actor.is_player_moving:
|
||||
await get_tree().process_frame
|
||||
_is_processing_action = false
|
||||
print("[BotController] Action Taken: Delivering candies to Mekton!")
|
||||
return
|
||||
|
||||
# Priority 1: Tekton Management (Grab Tekton if full boost, or spawn if carrying)
|
||||
# Spawning while carrying is high priority; Hunting is medium priority.
|
||||
if await _try_tekton_action():
|
||||
@@ -260,27 +296,30 @@ func _run_ai_tick():
|
||||
return
|
||||
|
||||
# =============================================================================
|
||||
# Gauntlet (#075) — Ghost Powerup + Sticky Avoidance wiring
|
||||
# Candy Survival (#075) — Ghost Powerup + Sticky Avoidance wiring
|
||||
# =============================================================================
|
||||
|
||||
func _try_activate_ghost() -> bool:
|
||||
"""Activate Ghost powerup when the planner reports imminent danger.
|
||||
|
||||
Uses the existing SpecialTilesManager to activate the held ghost powerup.
|
||||
Uses CandySurvivalManager to activate the native ghost charges.
|
||||
Returns true if activation was triggered."""
|
||||
if not strategic_planner or not strategic_planner.is_gauntlet_mode():
|
||||
if not strategic_planner or not strategic_planner.is_candy_survival_mode():
|
||||
return false
|
||||
if not strategic_planner.should_activate_ghost_now():
|
||||
return false
|
||||
var stm = actor.get_node_or_null("SpecialTilesManager")
|
||||
if not stm:
|
||||
return false
|
||||
if stm.has_method("activate_effect"):
|
||||
stm.activate_effect(stm.SpecialEffect.INVISIBLE_MODE)
|
||||
print("[BotController] %s activated Ghost powerup (trapped)" % actor.name)
|
||||
return true
|
||||
|
||||
var gm = strategic_planner._get_candy_survival_manager()
|
||||
if not gm or not gm.active:
|
||||
return false
|
||||
|
||||
var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int()
|
||||
if multiplayer.is_server():
|
||||
gm.try_activate_ghost(pid)
|
||||
else:
|
||||
gm.rpc_id(1, "try_activate_ghost", pid)
|
||||
|
||||
return true
|
||||
func _on_step_onto_unsafe() -> bool:
|
||||
"""Refuse to step onto a sticky/telegraphed cell and re-plan. Returns true
|
||||
if the bot had to abort the planned move."""
|
||||
@@ -291,9 +330,9 @@ func _on_step_onto_unsafe() -> bool:
|
||||
return false
|
||||
# Post-move guard: if we somehow landed on a sticky without ghost active,
|
||||
# burn Ghost powerup to phase through next tick.
|
||||
if strategic_planner.is_gauntlet_mode() and strategic_planner._is_overlay_unsafe(here):
|
||||
if strategic_planner.is_candy_survival_mode() and strategic_planner._is_overlay_unsafe(here):
|
||||
if not strategic_planner._is_bot_ghost_active():
|
||||
var gm = strategic_planner._get_gauntlet_manager()
|
||||
var gm = strategic_planner._get_candy_survival_manager()
|
||||
if gm and gm.has_method("is_sticky_cell") and gm.is_sticky_cell(here):
|
||||
print("[BotController] %s stepped onto sticky at %s — activating Ghost" % [actor.name, here])
|
||||
return _try_activate_ghost()
|
||||
@@ -453,7 +492,7 @@ func _try_attack_chase() -> bool:
|
||||
|
||||
if not push_success:
|
||||
# If attack failed (e.g. Safe Zone in Stop n Go), don't just loop!
|
||||
if LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO):
|
||||
if Engine.get_main_loop().root.get_node_or_null("LobbyManager").is_game_mode(GameMode.Mode.STOP_N_GO):
|
||||
if victim.current_position.x in [6, 7, 8, 14, 15, 16]: # Safe Zone Columns
|
||||
print("[BotController] %s target is in Safe Zone. Moving to find better angle." % actor.name)
|
||||
await _try_unstuck_move()
|
||||
@@ -479,7 +518,7 @@ func _try_attack_chase() -> bool:
|
||||
var next_step = Vector2i(path[1].x, path[1].y)
|
||||
|
||||
# STOP N GO BOUNDARY PROTECTION
|
||||
if LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) and next_step.x >= 21:
|
||||
if Engine.get_main_loop().root.get_node_or_null("LobbyManager").is_game_mode(GameMode.Mode.STOP_N_GO) and next_step.x >= 21:
|
||||
var main = get_tree().root.get_node_or_null("Main")
|
||||
var gc_manager = main.get_node_or_null("GoalsCycleManager") if main else null
|
||||
var time_remaining = gc_manager.get_global_time_remaining() if gc_manager else 999.0
|
||||
@@ -547,7 +586,7 @@ func _try_grab() -> bool:
|
||||
pass
|
||||
|
||||
# Check if goals already achieved
|
||||
if _is_goals_achieved() and LobbyManager.game_mode != "Stop n Go":
|
||||
if _is_goals_achieved() and Engine.get_main_loop().root.get_node_or_null("LobbyManager").game_mode != "Stop n Go":
|
||||
return false
|
||||
|
||||
# Get tiles we need
|
||||
@@ -606,7 +645,7 @@ func _find_tile_to_grab(tiles_needed: Array) -> Dictionary:
|
||||
func _try_move() -> bool:
|
||||
"""Try to move toward needed tiles taking single steps like a player."""
|
||||
|
||||
if _is_goals_achieved() and LobbyManager.game_mode != "Stop n Go":
|
||||
if _is_goals_achieved() and Engine.get_main_loop().root.get_node_or_null("LobbyManager").game_mode != "Stop n Go":
|
||||
return false
|
||||
|
||||
# Find optimal movement target
|
||||
@@ -860,7 +899,7 @@ func _get_board_fullness_ratio() -> float:
|
||||
|
||||
func _is_goals_achieved() -> bool:
|
||||
"""Check if goal pattern is complete (Standard) or mission complete (Stop n Go)."""
|
||||
if LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO):
|
||||
if Engine.get_main_loop().root.get_node_or_null("LobbyManager").is_game_mode(GameMode.Mode.STOP_N_GO):
|
||||
var main = get_tree().root.get_node_or_null("Main")
|
||||
if main:
|
||||
var sng_manager = main.get_node_or_null("StopNGoManager")
|
||||
|
||||
@@ -8,38 +8,38 @@ class_name BotStrategicPlanner
|
||||
|
||||
var actor: Node3D
|
||||
var enhanced_gridmap: Node
|
||||
# Optional explicit gauntlet_manager binding (set by tests to avoid scene-tree
|
||||
# traversal collisions; production code uses _get_gauntlet_manager() instead).
|
||||
var gauntlet_manager_override: Node = null
|
||||
# Optional explicit candy_survival_manager binding (set by tests to avoid scene-tree
|
||||
# traversal collisions; production code uses _get_candy_survival_manager() instead).
|
||||
var candy_survival_manager_override: Node = null
|
||||
|
||||
# Tile type constants
|
||||
const GOAL_TILES = [7, 8, 9, 10] # Heart, Diamond, Star, Coin
|
||||
const HOLO_TILES = [11, 12, 13, 14] # Power-up holo tiles
|
||||
|
||||
# Gauntlet overlay layer (v2 ground-growth model — sticky/telegraph on layer 2).
|
||||
# Candy Survival overlay layer (v2 ground-growth model — sticky/telegraph on layer 2).
|
||||
# Bots must avoid these cells or use Ghost mode to cross.
|
||||
const GAUNTLET_OVERLAY_LAYER: int = 2
|
||||
const CANDY_SURVIVAL_OVERLAY_LAYER: int = 2
|
||||
const TILE_STICKY: int = 17
|
||||
const TILE_TELEGRAPH: int = 18
|
||||
|
||||
# =============================================================================
|
||||
# Gauntlet mode helpers (#075 — Bot AI: Sticky Avoidance & Pathfinding)
|
||||
# Candy Survival mode helpers (#075 — Bot AI: Sticky Avoidance & Pathfinding)
|
||||
# =============================================================================
|
||||
|
||||
func is_gauntlet_mode() -> bool:
|
||||
return LobbyManager and LobbyManager.is_game_mode(GameMode.Mode.GAUNTLET)
|
||||
func is_candy_survival_mode() -> bool:
|
||||
return LobbyManager and LobbyManager.is_game_mode(GameMode.Mode.CANDY_SURVIVAL)
|
||||
|
||||
func _get_gauntlet_manager() -> Node:
|
||||
"""Resolve the active GauntletManager.
|
||||
func _get_candy_survival_manager() -> Node:
|
||||
"""Resolve the active CandySurvivalManager.
|
||||
|
||||
Order of resolution:
|
||||
1. Explicit `gauntlet_manager_override` (used by tests).
|
||||
2. Walk actor's ancestors for any node containing a GauntletManager child
|
||||
1. Explicit `candy_survival_manager_override` (used by tests).
|
||||
2. Walk actor's ancestors for any node containing a CandySurvivalManager child
|
||||
(production path — robust to non-standard scene trees).
|
||||
3. Fallback: scan /root children for a GauntletManager.
|
||||
3. Fallback: scan /root children for a CandySurvivalManager.
|
||||
"""
|
||||
if gauntlet_manager_override and is_instance_valid(gauntlet_manager_override):
|
||||
return gauntlet_manager_override
|
||||
if candy_survival_manager_override and is_instance_valid(candy_survival_manager_override):
|
||||
return candy_survival_manager_override
|
||||
var root: Node = null
|
||||
if actor and actor.is_inside_tree():
|
||||
root = actor.get_tree().get_root()
|
||||
@@ -47,7 +47,7 @@ func _get_gauntlet_manager() -> Node:
|
||||
# nested under Main → Arena → Player).
|
||||
var n: Node = actor.get_parent()
|
||||
while n:
|
||||
var gm = n.get_node_or_null("GauntletManager")
|
||||
var gm = n.get_node_or_null("CandySurvivalManager")
|
||||
if gm:
|
||||
return gm
|
||||
n = n.get_parent()
|
||||
@@ -56,37 +56,37 @@ func _get_gauntlet_manager() -> Node:
|
||||
# Last-resort scan of root children (helps in unusual scene trees).
|
||||
for child in root.get_children():
|
||||
if child.name.begins_with("Main") or child.name.begins_with("BotTestMain"):
|
||||
var gm2 = child.get_node_or_null("GauntletManager")
|
||||
var gm2 = child.get_node_or_null("CandySurvivalManager")
|
||||
if gm2:
|
||||
return gm2
|
||||
return null
|
||||
|
||||
func _bot_has_ghost_powerup() -> bool:
|
||||
"""Check if the bot has a ghost powerup in its SpecialTilesManager inventory."""
|
||||
var stm = actor.get_node_or_null("SpecialTilesManager")
|
||||
if not stm:
|
||||
"""Check if the bot has native ghost charges in CandySurvivalManager."""
|
||||
var gm = _get_candy_survival_manager()
|
||||
if not gm:
|
||||
return false
|
||||
return stm.inventory.get(stm.SpecialEffect.INVISIBLE_MODE, false)
|
||||
var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int()
|
||||
return gm.player_ghosts.get(pid, 0) > 0
|
||||
|
||||
func _is_bot_ghost_active() -> bool:
|
||||
"""Check if the bot is currently in ghost (invisible) mode."""
|
||||
return actor.get("is_invisible") == true
|
||||
|
||||
func _is_overlay_unsafe(pos: Vector2i) -> bool:
|
||||
"""True if the cell carries a sticky or telegraphed overlay on layer 2."""
|
||||
if not enhanced_gridmap:
|
||||
return false
|
||||
var item = enhanced_gridmap.get_cell_item(Vector3i(pos.x, GAUNTLET_OVERLAY_LAYER, pos.y))
|
||||
var item = enhanced_gridmap.get_cell_item(Vector3i(pos.x, CANDY_SURVIVAL_OVERLAY_LAYER, pos.y))
|
||||
return item == TILE_STICKY or item == TILE_TELEGRAPH
|
||||
|
||||
func _is_cell_unsafe_in_gauntlet(pos: Vector2i) -> bool:
|
||||
"""Cell is unsafe in Gauntlet if it's sticky/telegraphed — unless the bot's
|
||||
func _is_cell_unsafe_in_candy_survival(pos: Vector2i) -> bool:
|
||||
"""Cell is unsafe in Candy Survival if it's sticky/telegraphed — unless the bot's
|
||||
Ghost mode is active (grants sticky bypass)."""
|
||||
if not is_gauntlet_mode():
|
||||
if not is_candy_survival_mode():
|
||||
return false
|
||||
if _is_bot_ghost_active():
|
||||
return false
|
||||
var gm = _get_gauntlet_manager()
|
||||
var gm = _get_candy_survival_manager()
|
||||
if gm and gm.has_method("is_sticky_cell") and gm.is_sticky_cell(pos):
|
||||
return true
|
||||
return _is_overlay_unsafe(pos)
|
||||
@@ -101,7 +101,7 @@ func _count_unsafe_neighbors(pos: Vector2i) -> int:
|
||||
|
||||
func should_activate_ghost_now() -> bool:
|
||||
"""True if the bot is boxed in / about to be sealed and should use Ghost powerup."""
|
||||
if not is_gauntlet_mode():
|
||||
if not is_candy_survival_mode():
|
||||
return false
|
||||
if not _bot_has_ghost_powerup():
|
||||
return false
|
||||
@@ -613,12 +613,12 @@ func _is_valid_move_target(pos: Vector2i, ignore_players: bool = false) -> bool:
|
||||
if not ignore_players and actor.is_position_occupied(pos):
|
||||
return false
|
||||
|
||||
# Gauntlet mode (#075): reject cells that are sticky or telegraphed —
|
||||
# Candy Survival mode (#075): reject cells that are sticky or telegraphed —
|
||||
# stepping onto them either traps the bot or strands it within 1s.
|
||||
# Safety applies even when ignore_players is true (a sticky cell is unsafe
|
||||
# regardless of whether another player is on it). Ghost-active bots are
|
||||
# exempt via the helper.
|
||||
if _is_cell_unsafe_in_gauntlet(pos):
|
||||
if _is_cell_unsafe_in_candy_survival(pos):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
extends Node3D
|
||||
class_name CandyCannonController
|
||||
|
||||
@export var is_static_turret: bool = true
|
||||
|
||||
@onready var ring1 = $Ring1 if has_node("Ring1") else null
|
||||
@onready var ring2 = $Ring2 if has_node("Ring2") else null
|
||||
@onready var ring3 = $Ring3 if has_node("Ring3") else null
|
||||
@onready var tank = $Tank if has_node("Tank") else null
|
||||
|
||||
func _ready() -> void:
|
||||
_apply_outline_recursive(self)
|
||||
|
||||
func _apply_outline_recursive(node: Node) -> void:
|
||||
if node is MeshInstance3D and node.mesh:
|
||||
# Some procedural meshes might not have get_surface_count or it might be 1.
|
||||
# If the mesh has material_override, apply outline to that
|
||||
if node.material_override and node.material_override is StandardMaterial3D:
|
||||
var mat = node.material_override
|
||||
if not mat.next_pass:
|
||||
var outline_mat = ShaderMaterial.new()
|
||||
outline_mat.shader = load("res://assets/shaders/outline3d.gdshader")
|
||||
mat.next_pass = outline_mat
|
||||
else:
|
||||
for i in range(node.mesh.get_surface_count()):
|
||||
var mat = node.get_active_material(i)
|
||||
if mat:
|
||||
if not mat.next_pass:
|
||||
var unique_mat = mat.duplicate()
|
||||
var outline_mat = ShaderMaterial.new()
|
||||
outline_mat.shader = load("res://assets/shaders/outline3d.gdshader")
|
||||
unique_mat.next_pass = outline_mat
|
||||
node.set_surface_override_material(i, unique_mat)
|
||||
|
||||
if node is GPUParticles3D and node.draw_pass_1 and node.draw_pass_1.material:
|
||||
var mat = node.draw_pass_1.material
|
||||
if not mat.next_pass:
|
||||
var outline_mat = ShaderMaterial.new()
|
||||
outline_mat.shader = load("res://assets/shaders/outline3d.gdshader")
|
||||
mat.next_pass = outline_mat
|
||||
|
||||
for child in node.get_children():
|
||||
_apply_outline_recursive(child)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if ring1: ring1.rotate_y(delta * 1.5)
|
||||
if ring2: ring2.rotate_x(delta * -1.0)
|
||||
if ring3: ring3.rotate_z(delta * 1.2)
|
||||
|
||||
if tank and tank.mesh and tank.mesh.material:
|
||||
var mat = tank.mesh.material as StandardMaterial3D
|
||||
if mat:
|
||||
# Gentle pulse of the candy tank
|
||||
var pulse = (sin(Time.get_ticks_msec() / 300.0) + 1.0) * 0.5
|
||||
mat.emission_energy_multiplier = 1.0 + (pulse * 2.0)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func play_animation_rpc(anim_name: String) -> void:
|
||||
# Stub for future model animations
|
||||
pass
|
||||
|
||||
func spawn_projectile(target_world_pos: Vector3, duration: float) -> void:
|
||||
var projectile = MeshInstance3D.new()
|
||||
var sphere = SphereMesh.new()
|
||||
sphere.radius = 0.3
|
||||
sphere.height = 0.6
|
||||
projectile.mesh = sphere
|
||||
|
||||
var mat = StandardMaterial3D.new()
|
||||
mat.albedo_color = Color(1.0, 0.4, 0.8) # Candy pink for Gauntlet
|
||||
mat.emission_enabled = true
|
||||
mat.emission = Color(1.0, 0.4, 0.8)
|
||||
mat.emission_energy_multiplier = 3.0
|
||||
projectile.material_override = mat
|
||||
|
||||
projectile.top_level = true
|
||||
add_child(projectile)
|
||||
|
||||
# Start projectile slightly above the cannon center
|
||||
projectile.global_position = global_position + Vector3(0, 3.0, 0)
|
||||
|
||||
var tween = create_tween()
|
||||
if not tween:
|
||||
projectile.queue_free()
|
||||
return
|
||||
|
||||
# VFX trail
|
||||
var particles = GPUParticles3D.new()
|
||||
var pmat = ParticleProcessMaterial.new()
|
||||
pmat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
|
||||
pmat.emission_sphere_radius = 0.3
|
||||
pmat.gravity = Vector3(0, 0, 0)
|
||||
pmat.scale_min = 0.1
|
||||
pmat.scale_max = 0.3
|
||||
particles.process_material = pmat
|
||||
|
||||
var pmesh = SphereMesh.new()
|
||||
pmesh.radius = 0.1
|
||||
pmesh.height = 0.2
|
||||
var spatial_mat = StandardMaterial3D.new()
|
||||
spatial_mat.albedo_color = Color(1.0, 0.6, 0.9)
|
||||
spatial_mat.emission_enabled = true
|
||||
spatial_mat.emission = Color(1.0, 0.6, 0.9)
|
||||
pmesh.material = spatial_mat
|
||||
particles.draw_pass_1 = pmesh
|
||||
|
||||
particles.amount = 16
|
||||
particles.lifetime = 0.4
|
||||
projectile.add_child(particles)
|
||||
_apply_outline_recursive(projectile)
|
||||
|
||||
tween.set_parallel(true)
|
||||
tween.tween_property(projectile, "global_position:x", target_world_pos.x, duration).set_trans(Tween.TRANS_LINEAR)
|
||||
tween.tween_property(projectile, "global_position:z", target_world_pos.z, duration).set_trans(Tween.TRANS_LINEAR)
|
||||
|
||||
var mid_y = max(global_position.y, target_world_pos.y) + 4.0
|
||||
var tween_y = create_tween()
|
||||
|
||||
# Important: Make sure both halves of the y-tween together equal `duration`
|
||||
tween_y.tween_property(projectile, "global_position:y", mid_y, duration / 2.0).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
|
||||
tween_y.tween_property(projectile, "global_position:y", target_world_pos.y, duration / 2.0).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
|
||||
|
||||
# Add some spin to the projectile
|
||||
var spin_tween = create_tween()
|
||||
spin_tween.set_loops()
|
||||
spin_tween.tween_property(projectile, "rotation", Vector3(PI*2, PI*2, PI*2), 0.5).as_relative()
|
||||
|
||||
# We need to wait for the X/Z tween to finish, but since it's parallel we can just use a separate timer or tween
|
||||
# to kill the projectile exactly when duration is reached, ensuring it doesn't get killed early by X/Z finishing 1 frame earlier than Y
|
||||
get_tree().create_timer(duration).timeout.connect(func():
|
||||
if is_instance_valid(spin_tween):
|
||||
spin_tween.kill()
|
||||
if is_instance_valid(projectile):
|
||||
projectile.queue_free()
|
||||
)
|
||||
|
||||
func can_rpc() -> bool:
|
||||
if not multiplayer.has_multiplayer_peer(): return false
|
||||
return multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
|
||||
@@ -0,0 +1,57 @@
|
||||
extends Node3D
|
||||
class_name CandySurvivalNPCController
|
||||
|
||||
@onready var mekton_mesh = $MektonMesh
|
||||
var face_material: StandardMaterial3D
|
||||
|
||||
func _ready() -> void:
|
||||
# Setup unique material for the body to change colors
|
||||
if mekton_mesh:
|
||||
var skeleton = mekton_mesh.get_node_or_null("Throwing Tiles/Skeleton3D")
|
||||
if skeleton:
|
||||
var body = skeleton.get_node_or_null("ted_body")
|
||||
if body and body is MeshInstance3D:
|
||||
var mat = body.get_surface_override_material(0)
|
||||
if not mat:
|
||||
if body.mesh:
|
||||
mat = body.mesh.surface_get_material(0)
|
||||
|
||||
if mat:
|
||||
face_material = mat.duplicate()
|
||||
else:
|
||||
face_material = StandardMaterial3D.new()
|
||||
|
||||
face_material.emission_enabled = true
|
||||
face_material.emission_energy_multiplier = 2.0
|
||||
body.set_surface_override_material(0, face_material)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func set_face_color_rpc(color_index: int) -> void:
|
||||
if not face_material: return
|
||||
|
||||
# enum CandyColor { HEART=0, DIAMOND=1, STAR=2, COIN=3 }
|
||||
var c = Color.WHITE
|
||||
match color_index:
|
||||
0: c = Color(1.0, 0.2, 0.2) # HEART (Red)
|
||||
1: c = Color(0.2, 0.4, 1.0) # DIAMOND (Blue)
|
||||
2: c = Color(0.0, 1.0, 0.4) # STAR (Green)
|
||||
3: c = Color(1.0, 0.8, 0.0) # COIN (Gold/Yellow)
|
||||
|
||||
face_material.albedo_color = c
|
||||
face_material.emission = c
|
||||
|
||||
# Also tint the OmniLight if we have one
|
||||
var light = $OmniLight3D
|
||||
if light:
|
||||
light.light_color = c
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func play_animation_rpc(anim_name: String) -> void:
|
||||
if mekton_mesh:
|
||||
var anim = mekton_mesh.get_node_or_null("AnimationPlayer")
|
||||
if anim and anim.has_animation(anim_name):
|
||||
anim.play(anim_name)
|
||||
|
||||
func can_rpc() -> bool:
|
||||
if not multiplayer.has_multiplayer_peer(): return false
|
||||
return multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
|
||||
@@ -1,12 +1,7 @@
|
||||
extends RefCounted
|
||||
class_name GameMode
|
||||
|
||||
enum Mode {
|
||||
FREEMODE = 0,
|
||||
STOP_N_GO = 1,
|
||||
TEKTON_DOORS = 2,
|
||||
GAUNTLET = 3
|
||||
}
|
||||
enum Mode { FREEMODE = 0, STOP_N_GO = 1, CANDY_SURVIVAL = 2 }
|
||||
|
||||
static func from_string(mode: String) -> Mode:
|
||||
match mode:
|
||||
@@ -14,11 +9,8 @@ static func from_string(mode: String) -> Mode:
|
||||
return Mode.FREEMODE
|
||||
"Stop n Go":
|
||||
return Mode.STOP_N_GO
|
||||
"Tekton Doors":
|
||||
return Mode.TEKTON_DOORS
|
||||
"Candy Pump Survival":
|
||||
return Mode.GAUNTLET
|
||||
_:
|
||||
"Candy Survival":
|
||||
return Mode.CANDY_SURVIVAL
|
||||
return Mode.FREEMODE
|
||||
|
||||
static func mode_to_string(mode: Mode) -> String:
|
||||
@@ -27,15 +19,13 @@ static func mode_to_string(mode: Mode) -> String:
|
||||
return "Freemode"
|
||||
Mode.STOP_N_GO:
|
||||
return "Stop n Go"
|
||||
Mode.TEKTON_DOORS:
|
||||
return "Tekton Doors"
|
||||
Mode.GAUNTLET:
|
||||
return "Candy Pump Survival"
|
||||
Mode.CANDY_SURVIVAL:
|
||||
return "Candy Survival"
|
||||
_:
|
||||
return "Freemode"
|
||||
|
||||
static func is_restricted(mode: Mode) -> bool:
|
||||
return mode == Mode.STOP_N_GO or mode == Mode.TEKTON_DOORS or mode == Mode.GAUNTLET
|
||||
return mode == Mode.STOP_N_GO or mode == Mode.CANDY_SURVIVAL
|
||||
|
||||
static func get_all_modes() -> Array[String]:
|
||||
return ["Freemode", "Stop n Go", "Tekton Doors", "Candy Pump Survival"]
|
||||
return ["Freemode", "Stop n Go", "Candy Survival"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cosvmn3gh5bxj
|
||||
@@ -2,6 +2,8 @@ extends Node
|
||||
## AuthManager - Centralized authentication handling for Nakama
|
||||
## Supports: Guest (device ID), Email/Password, Social (Google, Apple, Facebook)
|
||||
|
||||
const SafeNakamaMultiplayerBridge = preload("res://scripts/services/safe_nakama_multiplayer_bridge.gd")
|
||||
|
||||
# Signals
|
||||
signal auth_started
|
||||
signal auth_completed(success: bool, user_data: Dictionary)
|
||||
@@ -472,7 +474,7 @@ func _connect_socket() -> bool:
|
||||
return false
|
||||
|
||||
# Initialize multiplayer bridge
|
||||
NakamaManager.bridge = NakamaMultiplayerBridge.new(NakamaManager.socket)
|
||||
NakamaManager.bridge = SafeNakamaMultiplayerBridge.new(NakamaManager.socket)
|
||||
NakamaManager.bridge.match_joined.connect(NakamaManager._on_bridge_match_joined)
|
||||
NakamaManager.bridge.match_join_error.connect(NakamaManager._on_bridge_match_join_error)
|
||||
multiplayer.set_multiplayer_peer(NakamaManager.bridge.multiplayer_peer)
|
||||
|
||||
@@ -12,7 +12,7 @@ var player: Node3D
|
||||
@export var z_offset: float = 12.0
|
||||
@export var default_y: float = 19.636
|
||||
|
||||
var bounds_gauntlet = { "min_x": 0.0, "max_x": 20.0, "min_z": 10.0, "max_z": 32.0 }
|
||||
var bounds_candy_survival = { "min_x": 0.0, "max_x": 20.0, "min_z": 10.0, "max_z": 32.0 }
|
||||
|
||||
# Bounds Definitions { min_x, max_x, min_z, max_z }
|
||||
var bounds_freemode = { "min_x": 3.0, "max_x": 11.0, "min_z": 13.0, "max_z": 22.5 }
|
||||
@@ -48,13 +48,10 @@ func _calculate_target_position() -> Vector3:
|
||||
# Apply Mode-Specific Clamping
|
||||
var bounds = bounds_freemode # Default
|
||||
|
||||
if mode == GameMode.Mode.GAUNTLET:
|
||||
bounds = bounds_gauntlet
|
||||
if mode == GameMode.Mode.CANDY_SURVIVAL:
|
||||
bounds = bounds_candy_survival
|
||||
elif mode == GameMode.Mode.STOP_N_GO:
|
||||
bounds = bounds_stop_n_go
|
||||
elif mode == GameMode.Mode.TEKTON_DOORS:
|
||||
bounds = bounds_doors
|
||||
target_y = 32.3 # Doors uses a higher overlook
|
||||
|
||||
# Clamp X and Z
|
||||
target_x = clamp(target_x, bounds.min_x, bounds.max_x)
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
extends Node
|
||||
class_name CandySurvivalManager
|
||||
# Candy Survival — Sugar Rush Chaos
|
||||
# Speed – Building – Sabotage (blueprints + power-ups)
|
||||
|
||||
signal sugar_rush_started(pid)
|
||||
signal sugar_rush_ended(pid)
|
||||
signal mekton_face_changed(color_index)
|
||||
|
||||
# ── Arena ──
|
||||
const ARENA_COLS: int = 18
|
||||
const ARENA_ROWS: int = 18
|
||||
const NPC_CENTER: Vector2i = Vector2i(8, 8)
|
||||
const NPC_CELLS: int = 3 # 3x3 Mekton zone
|
||||
|
||||
# ── Blueprint ──
|
||||
const BLUEPRINT_SIZE: int = 3
|
||||
const OFF_COLOR_PENALTY: float = 0.5
|
||||
|
||||
# ── Candy ──
|
||||
const CANDY_PPS: float = 1.0 # points per second per candy
|
||||
const MULTI_STEP: float = 0.1 # extra multiplier per candy
|
||||
|
||||
# ── Sugar Rush ──
|
||||
const SR_BASE_TIME: float = 2.0
|
||||
const SR_PER_CANDY: float = 0.4 # extra seconds per delivered candy
|
||||
const SR_SPEED: float = 2.0
|
||||
|
||||
# ── Knock / Ghost ──
|
||||
const START_KNOCK: int = 5
|
||||
const START_GHOST: int = 5
|
||||
const GHOST_DURATION: float = 4.0
|
||||
|
||||
# ── Mekton ──
|
||||
const COLOR_CYCLE_TIME: float = 5.0 # seconds between face color changes
|
||||
enum CandyColor { HEART, DIAMOND, STAR, COIN }
|
||||
|
||||
# ── State ──
|
||||
var active: bool = false
|
||||
var main_scene: Node
|
||||
var gridmap: Node
|
||||
var mekton_node: Node
|
||||
var current_face: CandyColor = CandyColor.HEART
|
||||
var face_timer: float = 0.0
|
||||
var game_elapsed: float = 0.0
|
||||
|
||||
# ── HUD ──
|
||||
var hud_layer: CanvasLayer
|
||||
var hud_knock_label: Label
|
||||
var hud_ghost_label: Label
|
||||
var hud_rush_bar: ProgressBar
|
||||
var hud_rush_label: Label
|
||||
var hud_stack_badge: Label
|
||||
var hud_points_label: Label
|
||||
var hud_delivery_indicator: Label
|
||||
var _candy_survival_hud_scene: PackedScene = preload("res://scenes/candy_survival_hud.tscn")
|
||||
|
||||
# Cache for local client HUD resync (avoids local dictionary/timer desyncs)
|
||||
var _last_badge_count: int = 0
|
||||
var _last_badge_mult: float = 1.0
|
||||
var _last_badge_face_match: bool = false
|
||||
|
||||
func _ready():
|
||||
set_process(false)
|
||||
_setup_hud()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
Engine.time_scale = 1.0
|
||||
|
||||
func _setup_hud() -> void:
|
||||
var hud_instance = _candy_survival_hud_scene.instantiate()
|
||||
hud_layer = hud_instance
|
||||
hud_layer.visible = false
|
||||
add_child(hud_layer)
|
||||
|
||||
var bot_box = hud_layer.get_node_or_null("BottomContainer/HBoxContainer")
|
||||
if bot_box:
|
||||
hud_knock_label = bot_box.get_node_or_null("SabotagePanel/KnockChargesLabel")
|
||||
hud_ghost_label = bot_box.get_node_or_null("SabotagePanel/GhostChargesLabel")
|
||||
hud_stack_badge = bot_box.get_node_or_null("ScorePanel/CandyStackBadge")
|
||||
hud_points_label = bot_box.get_node_or_null("ScorePanel/PointsLabel")
|
||||
hud_delivery_indicator = bot_box.get_node_or_null("ScorePanel/DeliveryIndicator")
|
||||
|
||||
var top_box = hud_layer.get_node_or_null("TopContainer")
|
||||
if top_box:
|
||||
hud_rush_bar = top_box.get_node_or_null("SugarRushBar")
|
||||
if hud_rush_bar:
|
||||
hud_rush_label = hud_rush_bar.get_node_or_null("SugarRushLabel")
|
||||
|
||||
func activate_client_side() -> void:
|
||||
active = true
|
||||
if hud_layer:
|
||||
hud_layer.visible = true
|
||||
set_process(true)
|
||||
|
||||
var local = multiplayer.get_unique_id()
|
||||
player_knocks[local] = START_KNOCK
|
||||
player_ghosts[local] = START_GHOST
|
||||
|
||||
if hud_stack_badge:
|
||||
hud_stack_badge.text = "Stack: 0 (x1.0)\nRed: 0x Blue: 0x Green: 0x Yellow: 0x"
|
||||
if hud_knock_label:
|
||||
hud_knock_label.text = "🥊 Knocks: 5"
|
||||
if hud_ghost_label:
|
||||
hud_ghost_label.text = "👻 Ghosts: 5"
|
||||
|
||||
# Per-player state
|
||||
var player_candies: Dictionary = {} # pid -> int (stack count)
|
||||
var player_candy_colors: Dictionary = {} # pid -> Array of CandyColor
|
||||
var player_candy_color: Dictionary:
|
||||
get:
|
||||
var d = {}
|
||||
for pid in player_candy_colors:
|
||||
var colors = player_candy_colors[pid]
|
||||
d[pid] = colors.back() if colors.size() > 0 else -1
|
||||
return d
|
||||
var player_knocks: Dictionary = {} # pid -> int (charges left)
|
||||
var player_ghosts: Dictionary = {} # pid -> int (charges left)
|
||||
var player_ghost_active: Dictionary = {} # pid -> bool
|
||||
var player_ghost_timer: Dictionary = {} # pid -> float
|
||||
var player_score: Dictionary = {} # pid -> int
|
||||
var player_blueprints: Dictionary = {} # pid -> int (completed count)
|
||||
var player_sugar_rush: Dictionary = {} # pid -> float (remaining)
|
||||
var player_last_knocked_by: Dictionary = {} # pid -> int
|
||||
|
||||
# Sticky wall cells (pre-placed collision only)
|
||||
var sticky_cells: Dictionary = {} # Vector2i -> bool
|
||||
|
||||
# Tile ID mapping
|
||||
const TILE_IDS: Dictionary = {CandyColor.HEART: 7, CandyColor.DIAMOND: 8, CandyColor.STAR: 9, CandyColor.COIN: 10}
|
||||
|
||||
# ── Initialization ──
|
||||
|
||||
func initialize(main, grid) -> void:
|
||||
main_scene = main
|
||||
gridmap = grid
|
||||
_define_sticky_walls()
|
||||
print("[CandySurvival] Initialized")
|
||||
|
||||
func _define_sticky_walls() -> void:
|
||||
# Arena boundary walls (rows/cols 0 and 17)
|
||||
for x in range(ARENA_COLS):
|
||||
sticky_cells[Vector2i(x, 0)] = true
|
||||
sticky_cells[Vector2i(x, ARENA_ROWS - 1)] = true
|
||||
for y in range(ARENA_ROWS):
|
||||
sticky_cells[Vector2i(0, y)] = true
|
||||
sticky_cells[Vector2i(ARENA_COLS - 1, y)] = true
|
||||
# Mekton NPC zone (3x3 center)
|
||||
for dx in range(NPC_CELLS):
|
||||
for dy in range(NPC_CELLS):
|
||||
sticky_cells[Vector2i(NPC_CENTER.x + dx, NPC_CENTER.y + dy)] = true
|
||||
|
||||
func start_game_mode() -> void:
|
||||
active = true
|
||||
current_face = CandyColor.HEART
|
||||
face_timer = 0.0
|
||||
game_elapsed = 0.0
|
||||
player_candies.clear()
|
||||
player_candy_colors.clear()
|
||||
player_knocks.clear()
|
||||
player_ghosts.clear()
|
||||
player_ghost_active.clear()
|
||||
player_ghost_timer.clear()
|
||||
player_score.clear()
|
||||
player_blueprints.clear()
|
||||
player_sugar_rush.clear()
|
||||
player_last_knocked_by.clear()
|
||||
|
||||
# Init players from lobby
|
||||
var pids = _get_player_ids()
|
||||
for pid in pids:
|
||||
player_candies[pid] = 0
|
||||
player_candy_colors[pid] = []
|
||||
player_knocks[pid] = START_KNOCK
|
||||
player_ghosts[pid] = START_GHOST
|
||||
player_ghost_active[pid] = false
|
||||
player_ghost_timer[pid] = 0.0
|
||||
player_score[pid] = 0
|
||||
player_blueprints[pid] = 0
|
||||
player_sugar_rush[pid] = 0.0
|
||||
player_last_knocked_by[pid] = 0
|
||||
_update_candy_badge(pid)
|
||||
_update_special_inventory_for_ghosts(pid)
|
||||
rpc("sync_knock_charge_count", pid, START_KNOCK)
|
||||
rpc("sync_ghost_charge_count", pid, START_GHOST)
|
||||
|
||||
if multiplayer.is_server():
|
||||
activate_client_side()
|
||||
|
||||
print("[CandySurvival] Started with %d players" % pids.size())
|
||||
|
||||
func _enter_tree() -> void:
|
||||
set_process(true)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not active:
|
||||
return
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
game_elapsed += delta
|
||||
|
||||
# Mekton face color cycle
|
||||
face_timer += delta
|
||||
if face_timer >= COLOR_CYCLE_TIME:
|
||||
face_timer = 0.0
|
||||
current_face = CandyColor.values()[(current_face + 1) % CandyColor.size()]
|
||||
|
||||
# Sync color to the Mekton NPC
|
||||
if mekton_node and is_instance_valid(mekton_node) and mekton_node.has_method("set_face_color_rpc"):
|
||||
if multiplayer.is_server() and can_rpc():
|
||||
mekton_node.rpc("set_face_color_rpc", current_face)
|
||||
else:
|
||||
mekton_node.set_face_color_rpc(current_face)
|
||||
|
||||
# Update delivery indicators for all players based on new face
|
||||
for pid in player_candies:
|
||||
_update_candy_badge(pid)
|
||||
|
||||
|
||||
# Sugar Rush timers
|
||||
var any_rush = false
|
||||
for pid in player_sugar_rush.keys():
|
||||
if player_sugar_rush[pid] > 0:
|
||||
player_sugar_rush[pid] -= delta
|
||||
if player_sugar_rush[pid] <= 0:
|
||||
player_sugar_rush[pid] = 0.0
|
||||
_restore_time_scale()
|
||||
sugar_rush_ended.emit(pid)
|
||||
if player_sugar_rush[pid] > 0:
|
||||
any_rush = true
|
||||
if any_rush:
|
||||
Engine.time_scale = SR_SPEED
|
||||
|
||||
# Ghost timers
|
||||
for pid in player_ghost_active.keys():
|
||||
if player_ghost_active[pid]:
|
||||
player_ghost_timer[pid] -= delta
|
||||
if player_ghost_timer[pid] <= 0:
|
||||
player_ghost_active[pid] = false
|
||||
player_ghost_timer[pid] = 0.0
|
||||
rpc("sync_ghost_active", pid, false)
|
||||
|
||||
# Turn off ghost visual/state
|
||||
var all_players = get_tree().get_nodes_in_group("Players")
|
||||
for player in all_players:
|
||||
var curr_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
|
||||
if curr_pid == pid:
|
||||
player.set("is_invisible", false)
|
||||
if player.has_method("sync_modulate"):
|
||||
if multiplayer.is_server() and player.has_method("can_rpc") and player.can_rpc():
|
||||
player.rpc("sync_modulate", Color.WHITE)
|
||||
else:
|
||||
player.sync_modulate(Color.WHITE)
|
||||
break
|
||||
|
||||
# Candy stack: points per second per candy
|
||||
for pid in player_candies.keys():
|
||||
var count = player_candies.get(pid, 0)
|
||||
if count > 0:
|
||||
var points = count * CANDY_PPS * delta
|
||||
_add_score(pid, int(points))
|
||||
|
||||
# ── Blueprint ──
|
||||
|
||||
func on_blueprint_completed(pid: int, primary_tile_id: int, off_color: bool) -> void:
|
||||
player_blueprints[pid] = player_blueprints.get(pid, 0) + 1
|
||||
|
||||
if player_blueprints[pid] % 2 == 0:
|
||||
if multiplayer.is_server():
|
||||
add_ghost_charge(pid)
|
||||
|
||||
var target_tile_id = primary_tile_id
|
||||
var player_node: Node = null
|
||||
if main_scene:
|
||||
player_node = main_scene.get_node_or_null(str(pid))
|
||||
if player_node and player_node.goals.size() > 0:
|
||||
target_tile_id = player_node.goals[0]
|
||||
|
||||
var candy_color = CandyColor.HEART
|
||||
for key in TILE_IDS:
|
||||
if TILE_IDS[key] == target_tile_id:
|
||||
candy_color = key
|
||||
break
|
||||
|
||||
_give_candy(pid, candy_color)
|
||||
_add_score(pid, 100) # Award +100 points when completing goals by collecting tiles
|
||||
|
||||
# Clear the board now that it's matched and converted to a candy
|
||||
if main_scene and player_node:
|
||||
if player_node.playerboard_manager:
|
||||
# Visual wipe
|
||||
player_node.playerboard.fill(-1)
|
||||
main_scene.rpc("sync_playerboard", pid, player_node.playerboard)
|
||||
|
||||
# Record goal completion stats in GoalsCycleManager
|
||||
var goals_cycle = main_scene.get_node_or_null("GoalsCycleManager")
|
||||
if goals_cycle:
|
||||
# Increment goal count (but DO NOT trigger the general score points
|
||||
# because in Candy Survival, points are awarded during Mekton deposit)
|
||||
if not goals_cycle.player_goal_counts.has(pid):
|
||||
goals_cycle.player_goal_counts[pid] = 0
|
||||
goals_cycle.player_goal_counts[pid] += 1
|
||||
goals_cycle.emit_signal("goal_count_updated", pid, goals_cycle.player_goal_counts[pid])
|
||||
goals_cycle.rpc("sync_goal_count", pid, goals_cycle.player_goal_counts[pid])
|
||||
|
||||
# Generate new goals immediately to keep player active
|
||||
goals_cycle.regenerate_goals_for_player(player_node)
|
||||
# Need to randomize tiles around player to refresh pickups for the new goal
|
||||
goals_cycle._randomize_tiles_around_player(player_node)
|
||||
|
||||
func can_finish_with_off_color(pid: int, primary_tile_id: int) -> bool:
|
||||
return not _grid_has_color_tiles(primary_tile_id)
|
||||
|
||||
func _grid_has_color_tiles(target_tile_id: int) -> bool:
|
||||
if not gridmap:
|
||||
return true
|
||||
for x in range(1, ARENA_COLS - 1):
|
||||
for y in range(1, ARENA_ROWS - 1):
|
||||
var v = Vector3i(x, 1, y)
|
||||
if gridmap.get_cell_item(v) == target_tile_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
# ── Candy Stack ──
|
||||
|
||||
func _give_candy(pid: int, color: int) -> void:
|
||||
player_candies[pid] = player_candies.get(pid, 0) + 1
|
||||
if not player_candy_colors.has(pid):
|
||||
player_candy_colors[pid] = []
|
||||
player_candy_colors[pid].append(color)
|
||||
_update_candy_badge(pid)
|
||||
|
||||
func _update_candy_badge(pid: int) -> void:
|
||||
var count = player_candies.get(pid, 0)
|
||||
var mult = 1.0 + count * MULTI_STEP
|
||||
var colors = player_candy_colors.get(pid, [])
|
||||
var color = colors.back() if colors.size() > 0 else -1
|
||||
var face_match = (color != -1)
|
||||
var score = player_score.get(pid, 0)
|
||||
rpc("sync_candy_badge", pid, count, mult, face_match, score, colors)
|
||||
|
||||
func get_multiplier(pid: int) -> float:
|
||||
var count = player_candies.get(pid, 0)
|
||||
return 1.0 + count * MULTI_STEP
|
||||
|
||||
# ── Mekton Delivery ──
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func try_deliver(pid: int) -> bool:
|
||||
if not multiplayer.is_server():
|
||||
return false
|
||||
|
||||
var colors = player_candy_colors.get(pid, [])
|
||||
var color = colors.back() if colors.size() > 0 else -1
|
||||
if color == -1 or colors.size() == 0:
|
||||
rpc("sync_delivery_result", pid, false, "No candy")
|
||||
return false
|
||||
|
||||
var count = colors.size()
|
||||
var mult = get_multiplier(pid)
|
||||
|
||||
var base_points = 1000 * count
|
||||
_add_score(pid, int(base_points * mult))
|
||||
|
||||
player_candies[pid] = 0
|
||||
player_candy_colors[pid] = []
|
||||
_update_candy_badge(pid)
|
||||
|
||||
_trigger_sugar_rush(pid, count, mult)
|
||||
|
||||
rpc("sync_delivery_result", pid, true, "Delivered!")
|
||||
return true
|
||||
|
||||
func _trigger_sugar_rush(pid: int, candies: int, mult: float) -> void:
|
||||
var duration = SR_BASE_TIME + candies * SR_PER_CANDY * mult
|
||||
player_sugar_rush[pid] = duration
|
||||
Engine.time_scale = SR_SPEED
|
||||
sugar_rush_started.emit(pid)
|
||||
|
||||
func _restore_time_scale() -> void:
|
||||
var any_rush = false
|
||||
for pid in player_sugar_rush.keys():
|
||||
if player_sugar_rush[pid] > 0:
|
||||
any_rush = true
|
||||
break
|
||||
if not any_rush:
|
||||
Engine.time_scale = 1.0
|
||||
|
||||
# ── Knock / Ghost ──
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func try_knock(attacker: int, target: int) -> bool:
|
||||
if not multiplayer.is_server():
|
||||
return false
|
||||
if player_ghost_active.get(target, false):
|
||||
return false
|
||||
if not player_knocks.has(attacker):
|
||||
player_knocks[attacker] = START_KNOCK
|
||||
if player_knocks[attacker] <= 0:
|
||||
return false
|
||||
|
||||
var target_colors = player_candy_colors.get(target, [])
|
||||
var target_candies = target_colors.size()
|
||||
|
||||
var attacker_node = _find_player_node(attacker)
|
||||
var target_node = _find_player_node(target)
|
||||
|
||||
if target_candies == 0:
|
||||
# Backfire: attacker loses a charge & gets staggered
|
||||
player_knocks[attacker] = player_knocks.get(attacker, START_KNOCK) - 1
|
||||
rpc("sync_knock_charge_count", attacker, player_knocks[attacker])
|
||||
|
||||
# Backfire penalty: Attacker's candy stack transfers to Target!
|
||||
var attacker_colors = player_candy_colors.get(attacker, [])
|
||||
var attacker_candies = attacker_colors.size()
|
||||
if attacker_candies > 0:
|
||||
if not player_candy_colors.has(target):
|
||||
player_candy_colors[target] = []
|
||||
player_candy_colors[target].append_array(attacker_colors)
|
||||
player_candy_colors[attacker] = []
|
||||
|
||||
player_candies[target] = player_candy_colors[target].size()
|
||||
player_candies[attacker] = 0
|
||||
|
||||
_add_score(target, attacker_candies * 100)
|
||||
_update_candy_badge(attacker)
|
||||
_update_candy_badge(target)
|
||||
|
||||
rpc("sync_knock_result", attacker, target, 0)
|
||||
return false
|
||||
|
||||
# Steal all candies (Successful knock)
|
||||
if not player_candy_colors.has(attacker):
|
||||
player_candy_colors[attacker] = []
|
||||
player_candy_colors[attacker].append_array(target_colors)
|
||||
player_candy_colors[target] = []
|
||||
|
||||
player_candies[attacker] = player_candy_colors[attacker].size()
|
||||
player_candies[target] = 0
|
||||
|
||||
player_knocks[attacker] = player_knocks.get(attacker, START_KNOCK) - 1
|
||||
rpc("sync_knock_charge_count", attacker, player_knocks[attacker])
|
||||
player_last_knocked_by[target] = attacker
|
||||
|
||||
_add_score(attacker, target_candies * 100 + 100) # Award +100 points for using KNOCK + 100 per candy stolen
|
||||
_update_candy_badge(attacker)
|
||||
_update_candy_badge(target)
|
||||
|
||||
rpc("sync_knock_result", attacker, target, target_candies)
|
||||
return true
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func try_activate_ghost(pid: int) -> bool:
|
||||
if player_ghost_active.get(pid, false):
|
||||
return false
|
||||
if player_ghosts.get(pid, 0) <= 0:
|
||||
return false
|
||||
|
||||
player_ghosts[pid] = player_ghosts.get(pid, 0) - 1
|
||||
player_ghost_active[pid] = true
|
||||
player_ghost_timer[pid] = GHOST_DURATION
|
||||
rpc("sync_ghost_active", pid, true)
|
||||
rpc("sync_ghost_charge_count", pid, player_ghosts[pid])
|
||||
_update_special_inventory_for_ghosts(pid)
|
||||
|
||||
# Core powerup integration: set is_invisible on the player node
|
||||
var all_players = get_tree().get_nodes_in_group("Players")
|
||||
for player in all_players:
|
||||
var curr_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
|
||||
if curr_pid == pid:
|
||||
player.set("is_invisible", true)
|
||||
if player.has_method("sync_modulate"):
|
||||
if multiplayer.is_server() and player.has_method("can_rpc") and player.can_rpc():
|
||||
player.rpc("sync_modulate", Color(1.0, 1.0, 1.0, 0.4))
|
||||
else:
|
||||
player.sync_modulate(Color(1.0, 1.0, 1.0, 0.4))
|
||||
break
|
||||
|
||||
_add_score(pid, 100) # Award +100 points for tactically using GHOST
|
||||
return true
|
||||
|
||||
func is_ghost_active(pid: int) -> bool:
|
||||
return player_ghost_active.get(pid, false)
|
||||
|
||||
func can_rpc() -> bool:
|
||||
if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
|
||||
return false
|
||||
return true
|
||||
|
||||
# ── Arena ──
|
||||
|
||||
func _setup_arena() -> void:
|
||||
if not gridmap:
|
||||
return
|
||||
if can_rpc():
|
||||
rpc("sync_arena_setup")
|
||||
_apply_arena_setup()
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func sync_arena_setup() -> void:
|
||||
print("[CandySurvival] Client: Syncing Arena Setup (18x18)...")
|
||||
_apply_arena_setup()
|
||||
|
||||
func _spawn_mekton_npc(center: Vector2i) -> Node:
|
||||
var path = "res://scenes/candy_survival_npc.tscn"
|
||||
if ResourceLoader.exists(path):
|
||||
var npc = load(path).instantiate()
|
||||
npc.name = "CandySurvivalNpc"
|
||||
npc.position = Vector3(center.x + 1, 0, center.y + 1)
|
||||
add_child(npc, true)
|
||||
if npc.has_method("set_face_color_rpc"):
|
||||
npc.set_face_color_rpc(current_face)
|
||||
return npc
|
||||
return null
|
||||
|
||||
func _apply_arena_setup() -> void:
|
||||
if not gridmap:
|
||||
return
|
||||
gridmap.set("columns", ARENA_COLS)
|
||||
gridmap.set("rows", ARENA_ROWS)
|
||||
gridmap.clear()
|
||||
|
||||
# Build floor
|
||||
for x in range(ARENA_COLS):
|
||||
for y in range(ARENA_ROWS):
|
||||
gridmap.set_cell_item(Vector3i(x, 0, y), 0)
|
||||
|
||||
# Apply sticky walls on layer 2
|
||||
for pos in sticky_cells.keys():
|
||||
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), 14)
|
||||
|
||||
# Spawn Mekton NPC at center
|
||||
if mekton_node and is_instance_valid(mekton_node):
|
||||
mekton_node.queue_free()
|
||||
mekton_node = _spawn_mekton_npc(NPC_CENTER)
|
||||
print("[CandySurvival] Arena setup applied 18x18")
|
||||
|
||||
func setup_mission_tiles() -> void:
|
||||
if not gridmap:
|
||||
return
|
||||
randomize()
|
||||
var tile_ids = [7, 8, 9, 10]
|
||||
var count = 0
|
||||
for x in range(1, ARENA_COLS - 1):
|
||||
for y in range(1, ARENA_ROWS - 1):
|
||||
if sticky_cells.has(Vector2i(x, y)):
|
||||
continue
|
||||
if randf() < 0.6:
|
||||
var tile = tile_ids[randi() % tile_ids.size()]
|
||||
gridmap.set_cell_item(Vector3i(x, 1, y), tile)
|
||||
count += 1
|
||||
print("[CandySurvival] Spawned %d mission tiles" % count)
|
||||
|
||||
func get_spawn_points(count: int) -> Array:
|
||||
var points = []
|
||||
var corners = [
|
||||
Vector2i(2, 2), Vector2i(ARENA_COLS - 2, 2),
|
||||
Vector2i(2, ARENA_ROWS - 2), Vector2i(ARENA_COLS - 2, ARENA_ROWS - 2)
|
||||
]
|
||||
for i in range(count):
|
||||
points.append(corners[i % corners.size()])
|
||||
return points
|
||||
|
||||
func get_arena_positions() -> Dictionary:
|
||||
return {"columns": ARENA_COLS, "rows": ARENA_ROWS}
|
||||
|
||||
func get_arena_bounds() -> Dictionary:
|
||||
return {"min_x": -1.0, "max_x": float(ARENA_COLS), "min_z": -1.0, "max_z": float(ARENA_ROWS)}
|
||||
|
||||
# ── Sticky (pure wall collision) ──
|
||||
|
||||
func is_sticky_cell(pos: Vector2i) -> bool:
|
||||
return sticky_cells.has(pos)
|
||||
|
||||
func _is_npc_zone(pos: Vector2i) -> bool:
|
||||
var cx = NPC_CENTER.x
|
||||
var cy = NPC_CENTER.y
|
||||
return pos.x >= cx and pos.x < cx + NPC_CELLS and pos.y >= cy and pos.y < cy + NPC_CELLS
|
||||
|
||||
# ── Scoring ──
|
||||
|
||||
func _add_score(pid: int, points: int) -> void:
|
||||
player_score[pid] = player_score.get(pid, 0) + points
|
||||
if main_scene and main_scene.has_method("add_points"):
|
||||
main_scene.add_points(pid, points)
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
func _get_player_ids() -> Array:
|
||||
if main_scene and main_scene.has_method("get_player_ids"):
|
||||
return main_scene.get_player_ids()
|
||||
var pids: Array = []
|
||||
for p in get_tree().get_nodes_in_group("Players"):
|
||||
if not is_instance_valid(p): continue
|
||||
var pid = p.get("peer_id") if "peer_id" in p else p.name.to_int()
|
||||
if pid != 0 and not pids.has(pid):
|
||||
pids.append(pid)
|
||||
return pids
|
||||
|
||||
func _find_player_node(pid: int) -> Node:
|
||||
if main_scene:
|
||||
var n = main_scene.get_node_or_null(str(pid))
|
||||
if n: return n
|
||||
for p in get_tree().get_nodes_in_group("Players"):
|
||||
if not is_instance_valid(p): continue
|
||||
var p_id = p.get("peer_id") if "peer_id" in p else p.name.to_int()
|
||||
if p_id == pid:
|
||||
return p
|
||||
return null
|
||||
|
||||
func candy_survival_round_duration() -> int:
|
||||
return 180
|
||||
|
||||
func get_score(pid: int) -> int:
|
||||
return player_score.get(pid, 0)
|
||||
|
||||
# ── RPCs (stubs — HUD client handles visuals) ──
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_candy_badge(pid: int, count: int, mult: float, face_match: bool, score: int, colors: Array) -> void:
|
||||
if not active:
|
||||
activate_client_side()
|
||||
player_candies[pid] = count
|
||||
player_candy_colors[pid] = colors
|
||||
player_score[pid] = score
|
||||
|
||||
var p_node = _find_player_node(pid)
|
||||
if p_node and p_node.has_method("sync_candy_stack"):
|
||||
p_node.sync_candy_stack(colors)
|
||||
|
||||
if pid == multiplayer.get_unique_id():
|
||||
_last_badge_count = count
|
||||
_last_badge_mult = mult
|
||||
_last_badge_face_match = face_match
|
||||
|
||||
if hud_stack_badge:
|
||||
var red = 0
|
||||
var blue = 0
|
||||
var yellow = 0
|
||||
var green = 0
|
||||
for col in colors:
|
||||
match int(col):
|
||||
0: red += 1
|
||||
1: blue += 1
|
||||
2: green += 1
|
||||
3: yellow += 1
|
||||
hud_stack_badge.text = "Stack: %d (x%.1f)\nRed: %dx Blue: %dx Green: %dx Yellow: %dx" % [count, mult, red, blue, green, yellow]
|
||||
if hud_points_label:
|
||||
hud_points_label.text = "Score: %d" % score
|
||||
if hud_delivery_indicator:
|
||||
if count > 0:
|
||||
if face_match:
|
||||
hud_delivery_indicator.text = "READY TO DELIVER!"
|
||||
hud_delivery_indicator.add_theme_color_override("font_color", Color(0.4, 1.0, 0.4))
|
||||
else:
|
||||
hud_delivery_indicator.text = "Waiting for match..."
|
||||
hud_delivery_indicator.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
|
||||
else:
|
||||
hud_delivery_indicator.text = ""
|
||||
|
||||
@rpc("authority", "call_remote", "unreliable")
|
||||
func sync_delivery_result(pid: int, success: bool, msg: String) -> void:
|
||||
if not active:
|
||||
activate_client_side()
|
||||
if pid != multiplayer.get_unique_id():
|
||||
return
|
||||
if hud_delivery_indicator:
|
||||
if success:
|
||||
hud_delivery_indicator.text = "✓ Delivered! +Sugar Rush!"
|
||||
hud_delivery_indicator.add_theme_color_override("font_color", Color(0.3, 1.0, 0.3))
|
||||
else:
|
||||
hud_delivery_indicator.text = "✗ " + msg
|
||||
hud_delivery_indicator.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3))
|
||||
# Flash and auto-clear after 1.5s
|
||||
var tween = create_tween()
|
||||
tween.tween_interval(1.5)
|
||||
tween.tween_callback(_restore_delivery_indicator)
|
||||
|
||||
func _restore_delivery_indicator() -> void:
|
||||
if not active or not hud_delivery_indicator:
|
||||
return
|
||||
var count = _last_badge_count
|
||||
var face_match = _last_badge_face_match
|
||||
if count > 0:
|
||||
if face_match:
|
||||
hud_delivery_indicator.text = "READY TO DELIVER!"
|
||||
hud_delivery_indicator.add_theme_color_override("font_color", Color(0.4, 1.0, 0.4))
|
||||
else:
|
||||
hud_delivery_indicator.text = "Waiting for match..."
|
||||
hud_delivery_indicator.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
|
||||
else:
|
||||
hud_delivery_indicator.text = ""
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_knock_result(attacker: int, target: int, candies: int) -> void:
|
||||
if not active:
|
||||
activate_client_side()
|
||||
var local = multiplayer.get_unique_id()
|
||||
if attacker == local or target == local:
|
||||
if hud_knock_label and player_knocks.has(local):
|
||||
hud_knock_label.text = "🥊 Knocks: %d" % player_knocks[local]
|
||||
|
||||
var attacker_node = _find_player_node(attacker)
|
||||
var target_node = _find_player_node(target)
|
||||
if not attacker_node or not target_node:
|
||||
return
|
||||
|
||||
# SFX: Play attack_mode sound like other game modes
|
||||
var sfx = Engine.get_main_loop().root.get_node_or_null("SfxManager")
|
||||
if sfx and sfx.has_method("play"):
|
||||
sfx.play("attack_mode")
|
||||
|
||||
# Attacker bump animation toward target
|
||||
if attacker_node.has_method("sync_bump"):
|
||||
attacker_node.sync_bump(target_node.current_position, false)
|
||||
|
||||
# Calculate push direction from attacker to target
|
||||
var diff: Vector2i = target_node.current_position - attacker_node.current_position
|
||||
var push_dir = Vector2i(clamp(diff.x, -1, 1), clamp(diff.y, -1, 1))
|
||||
if push_dir == Vector2i.ZERO:
|
||||
push_dir = Vector2i(1, 0)
|
||||
|
||||
if candies > 0:
|
||||
# SUCCESSFUL STEAL: target has candies
|
||||
# Target is knocked back up to 3 tiles & stunned
|
||||
_apply_knock_animation_to_victim(target_node, push_dir, 2.0)
|
||||
else:
|
||||
# BACKFIRE: target had 0 candies
|
||||
# Target is NOT stunned ("whoever doesn't hold candy, will not stunned")
|
||||
# Attacker backfires: knocked back up to 3 tiles away from target & stunned
|
||||
_apply_knock_animation_to_victim(attacker_node, -push_dir, 2.0)
|
||||
|
||||
func _apply_knock_animation_to_victim(victim_node: Node, push_dir: Vector2i, stagger_duration: float) -> void:
|
||||
if not is_instance_valid(victim_node):
|
||||
return
|
||||
|
||||
var pushed_to_pos: Vector2i = victim_node.current_position
|
||||
var push_path: Array = []
|
||||
for i in range(3):
|
||||
var next_back: Vector2i = pushed_to_pos + push_dir
|
||||
var can_push: bool = false
|
||||
if victim_node.get("movement_manager") and victim_node.movement_manager.has_method("_can_push_to"):
|
||||
can_push = victim_node.movement_manager._can_push_to(next_back)
|
||||
if can_push:
|
||||
pushed_to_pos = next_back
|
||||
push_path.append(Vector2(pushed_to_pos.x, pushed_to_pos.y))
|
||||
if is_sticky_cell(pushed_to_pos):
|
||||
break
|
||||
else:
|
||||
break
|
||||
|
||||
if push_path.size() > 0:
|
||||
if victim_node.has_method("start_movement_along_path"):
|
||||
victim_node.start_movement_along_path(push_path, false, true)
|
||||
victim_node.target_position = pushed_to_pos
|
||||
|
||||
if victim_node.has_method("apply_stagger"):
|
||||
victim_node.apply_stagger(stagger_duration)
|
||||
|
||||
@rpc("authority", "call_remote", "unreliable")
|
||||
func sync_ghost_active(pid: int, active_on: bool) -> void:
|
||||
if not active:
|
||||
activate_client_side()
|
||||
if pid == multiplayer.get_unique_id():
|
||||
if hud_ghost_label and player_ghosts.has(pid):
|
||||
hud_ghost_label.text = "👻 Ghosts: %d" % player_ghosts[pid]
|
||||
|
||||
func sync_state_to_player(peer_id: int) -> void:
|
||||
# Ensure the client gets the arena setup before state
|
||||
if can_rpc():
|
||||
rpc_id(peer_id, "sync_arena_setup")
|
||||
|
||||
# Sync Mekton's current face color
|
||||
if mekton_node and is_instance_valid(mekton_node) and mekton_node.has_method("set_face_color_rpc"):
|
||||
if can_rpc():
|
||||
mekton_node.rpc_id(peer_id, "set_face_color_rpc", current_face)
|
||||
|
||||
# Sync Ghost inventory to make the HUD button visible on player load
|
||||
_update_special_inventory_for_ghosts(peer_id)
|
||||
|
||||
# Sync Knock and Ghost charges
|
||||
rpc_id(peer_id, "sync_knock_charge_count", peer_id, player_knocks.get(peer_id, START_KNOCK))
|
||||
rpc_id(peer_id, "sync_ghost_charge_count", peer_id, player_ghosts.get(peer_id, START_GHOST))
|
||||
|
||||
# Sync all players' candy stack heights and colors
|
||||
for pid in player_candy_colors.keys():
|
||||
var count = player_candies.get(pid, 0)
|
||||
var mult = get_multiplier(pid)
|
||||
var colors = player_candy_colors.get(pid, [])
|
||||
var color = colors.back() if colors.size() > 0 else -1
|
||||
var face_match = (color != -1)
|
||||
var score = player_score.get(pid, 0)
|
||||
|
||||
# Sync badge
|
||||
rpc_id(peer_id, "sync_candy_badge", pid, count, mult, face_match, score, colors)
|
||||
|
||||
# Sync 3D stack meshes
|
||||
var all_players = get_tree().get_nodes_in_group("Players")
|
||||
for player in all_players:
|
||||
var curr_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
|
||||
if curr_pid == pid:
|
||||
if player.has_method("sync_candy_stack"):
|
||||
if peer_id in multiplayer.get_peers():
|
||||
player.rpc_id(peer_id, "sync_candy_stack", colors)
|
||||
else:
|
||||
player.sync_candy_stack(colors)
|
||||
break
|
||||
|
||||
func _update_special_inventory_for_ghosts(pid: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
if main_scene:
|
||||
var player_node = main_scene.get_node_or_null(str(pid))
|
||||
if player_node:
|
||||
var st_manager = player_node.get_node_or_null("SpecialTilesManager")
|
||||
if st_manager:
|
||||
var has_charges = (player_ghosts.get(pid, 0) > 0)
|
||||
if pid in multiplayer.get_peers():
|
||||
if has_charges:
|
||||
st_manager.rpc_id(pid, "sync_inventory_add", st_manager.SpecialEffect.INVISIBLE_MODE, 8)
|
||||
else:
|
||||
st_manager.rpc_id(pid, "sync_inventory_remove", st_manager.SpecialEffect.INVISIBLE_MODE)
|
||||
else:
|
||||
if has_charges:
|
||||
st_manager.sync_inventory_add(st_manager.SpecialEffect.INVISIBLE_MODE, 8)
|
||||
else:
|
||||
st_manager.sync_inventory_remove(st_manager.SpecialEffect.INVISIBLE_MODE)
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func add_ghost_charge(pid: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
player_ghosts[pid] = player_ghosts.get(pid, 0) + 1
|
||||
rpc("sync_ghost_charge_count", pid, player_ghosts[pid])
|
||||
_update_special_inventory_for_ghosts(pid)
|
||||
|
||||
@rpc("authority", "call_local", "unreliable")
|
||||
func sync_ghost_charge_count(pid: int, count: int) -> void:
|
||||
if not active:
|
||||
activate_client_side()
|
||||
if player_ghosts.has(pid) or pid == multiplayer.get_unique_id():
|
||||
player_ghosts[pid] = count
|
||||
if multiplayer.get_unique_id() == pid:
|
||||
if hud_ghost_label:
|
||||
hud_ghost_label.text = "👻 Ghosts: %d" % count
|
||||
|
||||
@rpc("authority", "call_local", "unreliable")
|
||||
func sync_knock_charge_count(pid: int, count: int) -> void:
|
||||
if not active:
|
||||
activate_client_side()
|
||||
if player_knocks.has(pid) or pid == multiplayer.get_unique_id():
|
||||
player_knocks[pid] = count
|
||||
if multiplayer.get_unique_id() == pid:
|
||||
if hud_knock_label:
|
||||
hud_knock_label.text = "🥊 Knocks: %d" % count
|
||||
@@ -40,7 +40,8 @@ func pull(banner_id: String, count: int) -> Array:
|
||||
var result = await BackendService.perform_gacha_pull(banner_id, count)
|
||||
|
||||
if result.get("success", false) == false:
|
||||
var msg = str(result.get("error", "Unknown error"))
|
||||
var err_val = result.get("error", "Unknown error")
|
||||
var msg = result.get("message", str(err_val))
|
||||
push_error("[GachaManager] Gacha pull failed: " + msg)
|
||||
return []
|
||||
|
||||
|
||||
@@ -1,1825 +0,0 @@
|
||||
extends Node
|
||||
class_name GauntletManager
|
||||
|
||||
# GauntletManager - Handles Candy Pump Survival (Gauntlet) game mode
|
||||
# Pattern: StopNGoManager + PortalModeManager
|
||||
|
||||
signal phase_changed(phase_index: int, phase_name: String)
|
||||
signal growth_tick(cells: Array)
|
||||
signal player_trapped(player_id: int)
|
||||
signal ghost_granted(player_id: int)
|
||||
|
||||
# =============================================================================
|
||||
# Constants
|
||||
# =============================================================================
|
||||
|
||||
const ARENA_COLUMNS: int = 20
|
||||
const ARENA_ROWS: int = 20
|
||||
const NPC_SIZE: int = 3
|
||||
const NPC_CENTER: Vector2i = Vector2i(9, 9) # Center of 20x20 (0-indexed, center of 3x3 block)
|
||||
|
||||
# Tile IDs (matching MeshLibrary)
|
||||
const TILE_WALKABLE: int = 0
|
||||
const TILE_OBSTACLE: int = 4
|
||||
const TILE_STICKY: int = 17 # New candy-pink overlay (Layer 2)
|
||||
const TILE_TELEGRAPH: int = 18 # Warning glow (Layer 2, temporary)
|
||||
|
||||
# Cell states (v2 ground-growth model). Logical state of each playable cell.
|
||||
enum CellState {
|
||||
SAFE, # Can be entered, crossed, collected
|
||||
TELEGRAPHED, # Warned as future sticky, still passable (1s)
|
||||
STICKY, # Covered in sticky candy, blocks + traps
|
||||
BUBBLE_GROWING, # Candy bubble growing, not yet exploded
|
||||
BLOCKED, # NPC zone or permanent obstacle
|
||||
}
|
||||
|
||||
# Cells temporarily protected after Ghost-clearing (not used — kept for compat).
|
||||
var cleansed_cells: Dictionary = {}
|
||||
const CLEANSED_PROTECTION_TIME: float = 5.0
|
||||
|
||||
# Phase timing thresholds (seconds elapsed)
|
||||
const PHASE_1_START: float = 0.0 # Open Arena
|
||||
const PHASE_2_START: float = 60.0 # Route Pressure
|
||||
const PHASE_3_START: float = 120.0 # Survival Endgame
|
||||
|
||||
# =============================================================================
|
||||
# Phase System
|
||||
# =============================================================================
|
||||
|
||||
enum Phase { OPEN_ARENA, ROUTE_PRESSURE, SURVIVAL_ENDGAME }
|
||||
var current_phase: Phase = Phase.OPEN_ARENA
|
||||
var elapsed_time: float = 0.0
|
||||
var is_active: bool = false
|
||||
|
||||
# =============================================================================
|
||||
# Growth State (v2 ground-growth model — replaces cannon volley)
|
||||
# =============================================================================
|
||||
|
||||
var growth_timer: float = 0.0
|
||||
var growth_interval: float = 3.0 # seconds between growth ticks
|
||||
var telegraph_duration: float = 1.0 # seconds telegraphed cells stay passable
|
||||
var sticky_cells: Dictionary = {} # Vector2i → true
|
||||
var telegraphed_cells: Dictionary = {} # Vector2i → time remaining (still passable)
|
||||
var _last_tick_cells: Array = [] # cells selected last tick (for repetition penalty)
|
||||
|
||||
# Camping detection (#073): time each player has spent in their current 4x4
|
||||
# region. player_id -> {"region": Vector2i, "time": float}.
|
||||
var _camp_tracking: Dictionary = {}
|
||||
const CAMP_REGION_SIZE: int = 4
|
||||
|
||||
# Movement buffers (#083): hidden, decaying penalties on SAFE cells that form
|
||||
# critical movement corridors. Detected dynamically each growth tick; never
|
||||
# shown to players. pos(Vector2i) -> {"penalty": float, "adjacent": bool}.
|
||||
# The penalty discourages the growth algorithm from sealing off a corridor too
|
||||
# early, then fades over time / phases so the arena still closes in by the end.
|
||||
var movement_buffers: Dictionary = {}
|
||||
var _buffer_decay_timer: float = 0.0
|
||||
const BUFFER_DECAY_INTERVAL: float = 5.0 # seconds between decay steps
|
||||
const BUFFER_DECAY_FACTOR: float = 0.75 # each step keeps 75% (−25%)
|
||||
const BUFFER_PHASE_DECAY: float = 0.5 # phase change halves all penalties
|
||||
const BUFFER_MIN_PENALTY: float = 4.0 # prune below this magnitude
|
||||
# Base "inside a buffer corridor" penalty per phase (adjacent = half).
|
||||
const BUFFER_BASE_PENALTY: Array = [40.0, 20.0, 10.0]
|
||||
# A SAFE cell is a corridor if removing it drops a player's reachable region
|
||||
# below this many cells (i.e. it is a genuine chokepoint, not open floor).
|
||||
const BUFFER_CORRIDOR_THRESHOLD: int = 12
|
||||
|
||||
# Candy bubbles (#082): occasional anti-camping hazards that grow from 1x1 and
|
||||
# explode into a 3x3 sticky area. Separate from normal ground growth.
|
||||
# active_bubbles entries: {"center": Vector2i, "timer": float, "cells": Array}.
|
||||
var active_bubbles: Array = []
|
||||
var bubble_cells: Dictionary = {} # Vector2i -> true (BUBBLE_GROWING state)
|
||||
var recent_bubble_positions: Array = [] # centers of recent bubbles (anti-stacking)
|
||||
var bubbles_this_phase: int = 0 # spawned in the current phase
|
||||
var bubbles_total: int = 0 # spawned this round
|
||||
const MAX_BUBBLES_PER_PHASE: Array = [0, 2, 3] # phase 1 / 2 / 3
|
||||
const BUBBLE_GROW_DURATION: float = 2.75 # seconds from spawn to explosion (2.5–3)
|
||||
const BUBBLE_EXPLOSION_RADIUS: int = 1 # 1 => 3x3 area
|
||||
const BUBBLE_RECENT_MEMORY: int = 4 # how many recent centers to remember
|
||||
const BUBBLE_RECENT_RADIUS: int = 3 # anti-stacking exclusion distance
|
||||
|
||||
# Phase-specific growth parameters (cells-per-tick range per phase).
|
||||
# Layer weights: [outer, middle, inner] priority for the current pressure layer.
|
||||
var phase_growth_config: Array = [
|
||||
# Phase 0 (Outer Pressure): 4-6 cells/tick, push from the outside in
|
||||
{"cells_min": 4, "cells_max": 6, "layer_weights": {"outer": 60, "middle": 15, "inner": -40}},
|
||||
# Phase 1 (Middle Pressure): 6-8 cells/tick
|
||||
{"cells_min": 6, "cells_max": 8, "layer_weights": {"outer": 20, "middle": 60, "inner": 5}},
|
||||
# Phase 2 (Inner Survival): 8-10 cells/tick
|
||||
{"cells_min": 8, "cells_max": 10, "layer_weights": {"outer": 10, "middle": 35, "inner": 60}},
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Smack State (per-player)
|
||||
# =============================================================================
|
||||
|
||||
func has_smack_charged(pid: int) -> bool:
|
||||
if smack_charged.has(pid) and smack_charged[pid] > 0:
|
||||
return true
|
||||
return false
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func consume_smack(pid: int) -> void:
|
||||
# Local state reset
|
||||
smack_charged[pid] = 0.0
|
||||
smack_cooldowns[pid] = SMACK_COOLDOWN
|
||||
|
||||
# Play smack sound
|
||||
if SfxManager:
|
||||
SfxManager.rpc("play_rpc", "attack_mode") if _can_rpc() else SfxManager.play("attack_mode")
|
||||
|
||||
var all_players = get_tree().get_nodes_in_group("Players")
|
||||
for player in all_players:
|
||||
var curr_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
|
||||
if curr_pid == pid:
|
||||
if player.has_method("sync_modulate"):
|
||||
if _can_rpc():
|
||||
player.rpc("sync_modulate", Color.WHITE)
|
||||
else:
|
||||
player.sync_modulate(Color.WHITE)
|
||||
break
|
||||
|
||||
var smack_cooldowns: Dictionary = {} # player_id → float (time remaining)
|
||||
var smack_charged: Dictionary = {} # player_id → float (charge window remaining)
|
||||
const SMACK_COOLDOWN: float = 8.0
|
||||
const SMACK_CHARGE_WINDOW: float = 3.0
|
||||
|
||||
# =============================================================================
|
||||
# Ghost Reward Tracking (replaces Cleanser)
|
||||
# =============================================================================
|
||||
|
||||
var player_mission_completions: Dictionary = {} # player_id → int
|
||||
|
||||
# =============================================================================
|
||||
# Trapped Players
|
||||
# =============================================================================
|
||||
|
||||
var trapped_players: Dictionary = {} # player_id → true (legacy; sticky now slows)
|
||||
|
||||
# Sticky entry slows the player instead of trapping them (per-player, fair in MP).
|
||||
const STICKY_SLOW_DURATION: float = 2.0
|
||||
|
||||
# =============================================================================
|
||||
# Slow-Mo Effect
|
||||
# =============================================================================
|
||||
|
||||
var slowmo_active: bool = false
|
||||
var slowmo_timer: float = 0.0
|
||||
var slowmo_duration: float = 4.0
|
||||
const SLOWMO_SCALE: float = 0.25 # 1/4 speed
|
||||
var slowmo_overlay: ColorRect = null
|
||||
|
||||
# =============================================================================
|
||||
# References
|
||||
# =============================================================================
|
||||
|
||||
var main_scene: Node = null
|
||||
var gridmap: Node = null
|
||||
# Static Candy Pump NPC model at the arena center (the v2 "pump" that injects
|
||||
# candy into the ground). Purely visual now — projectile logic was removed.
|
||||
var candy_pump_scene: PackedScene = preload("res://scenes/candy_cannon.tscn")
|
||||
var pump_instance: Node3D = null
|
||||
|
||||
# HUD
|
||||
var hud_layer: CanvasLayer
|
||||
var phase_label: Label
|
||||
var slowmo_label: Label
|
||||
var _gauntlet_hud_scene: PackedScene = preload("res://scenes/gauntlet_hud.tscn")
|
||||
|
||||
# =============================================================================
|
||||
# Lifecycle
|
||||
# =============================================================================
|
||||
|
||||
func _ready():
|
||||
set_process(false)
|
||||
_setup_hud()
|
||||
|
||||
func _exit_tree():
|
||||
# Ensure time_scale is always restored when leaving Gauntlet mode
|
||||
Engine.time_scale = 1.0
|
||||
|
||||
func initialize(main: Node, grid: Node) -> void:
|
||||
main_scene = main
|
||||
gridmap = grid
|
||||
print("[Gauntlet] Initialized with gridmap: ", gridmap.name if gridmap else "null")
|
||||
|
||||
# Connect to GoalsCycleManager for scoring and mission tracking
|
||||
if main_scene:
|
||||
var gcm = main_scene.get_node_or_null("GoalsCycleManager")
|
||||
if gcm:
|
||||
gcm.goal_count_updated.connect(_on_goal_count_updated)
|
||||
gcm.score_updated.connect(_on_score_updated)
|
||||
print("[Gauntlet] Connected to GoalsCycleManager")
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not is_active:
|
||||
return
|
||||
|
||||
if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer == null:
|
||||
return
|
||||
|
||||
elapsed_time += delta
|
||||
|
||||
# Phase escalation
|
||||
_check_phase_transition()
|
||||
|
||||
# Server only logic
|
||||
if multiplayer.is_server():
|
||||
# Track camping behaviour for candidate scoring (#073)
|
||||
_update_camp_tracking(delta)
|
||||
|
||||
# Growth tick timer
|
||||
growth_timer -= delta
|
||||
if growth_timer <= 0.0:
|
||||
_process_growth_tick()
|
||||
growth_timer = growth_interval
|
||||
|
||||
# Decay cleansed-cell protection windows
|
||||
if not cleansed_cells.is_empty():
|
||||
_tick_cleansed_cells(delta)
|
||||
|
||||
# Decay hidden movement buffers over time (#083)
|
||||
_decay_movement_buffers(delta)
|
||||
|
||||
# Advance candy-bubble grow timers; explode when ready (#082)
|
||||
_update_bubbles(delta)
|
||||
|
||||
# Smack mechanic update (ALL PEERS)
|
||||
var all_players = get_tree().get_nodes_in_group("Players")
|
||||
for player in all_players:
|
||||
var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
|
||||
|
||||
# Allow local peer to predict setup
|
||||
if not smack_cooldowns.has(pid) and not smack_charged.has(pid):
|
||||
smack_cooldowns[pid] = SMACK_COOLDOWN
|
||||
smack_charged[pid] = 0.0
|
||||
|
||||
if smack_cooldowns[pid] > 0:
|
||||
smack_cooldowns[pid] -= delta
|
||||
if smack_cooldowns[pid] <= 0:
|
||||
smack_cooldowns[pid] = 0.0
|
||||
smack_charged[pid] = SMACK_CHARGE_WINDOW
|
||||
if player.has_method("sync_modulate"):
|
||||
if multiplayer.is_server() and _can_rpc():
|
||||
player.rpc("sync_modulate", Color.PINK)
|
||||
elif not multiplayer.is_server():
|
||||
player.sync_modulate(Color.PINK)
|
||||
elif smack_charged[pid] > 0:
|
||||
smack_charged[pid] -= delta
|
||||
if smack_charged[pid] <= 0:
|
||||
smack_charged[pid] = 0.0
|
||||
smack_cooldowns[pid] = SMACK_COOLDOWN
|
||||
if player.has_method("sync_modulate"):
|
||||
if multiplayer.is_server() and _can_rpc():
|
||||
player.rpc("sync_modulate", Color.WHITE)
|
||||
elif not multiplayer.is_server():
|
||||
player.sync_modulate(Color.WHITE)
|
||||
|
||||
# Slow-mo timer (all peers for visual consistency)
|
||||
if slowmo_active:
|
||||
slowmo_timer -= delta
|
||||
if slowmo_timer <= 0:
|
||||
_end_slowmo()
|
||||
# =============================================================================
|
||||
# Game Mode Start
|
||||
# =============================================================================
|
||||
|
||||
func start_game_mode() -> void:
|
||||
if multiplayer.is_server():
|
||||
activate_client_side()
|
||||
_start_phase(Phase.OPEN_ARENA)
|
||||
|
||||
func activate_client_side() -> void:
|
||||
is_active = true
|
||||
if hud_layer:
|
||||
hud_layer.visible = true
|
||||
set_process(true)
|
||||
|
||||
# =============================================================================
|
||||
# Phase Management
|
||||
# =============================================================================
|
||||
|
||||
func _check_phase_transition() -> void:
|
||||
var new_phase = current_phase
|
||||
|
||||
if elapsed_time >= PHASE_3_START:
|
||||
new_phase = Phase.SURVIVAL_ENDGAME
|
||||
elif elapsed_time >= PHASE_2_START:
|
||||
new_phase = Phase.ROUTE_PRESSURE
|
||||
|
||||
if new_phase != current_phase:
|
||||
_start_phase(new_phase)
|
||||
|
||||
func _start_phase(phase: Phase) -> void:
|
||||
current_phase = phase
|
||||
# Growth config is read per-tick from phase_growth_config[current_phase];
|
||||
# resetting the timer keeps tick cadence aligned to the phase boundary.
|
||||
growth_timer = growth_interval
|
||||
|
||||
# Phase change relaxes movement buffers by 50% — the arena is allowed to
|
||||
# close in more aggressively as pressure escalates (#083).
|
||||
if not movement_buffers.is_empty():
|
||||
_scale_all_buffers(BUFFER_PHASE_DECAY)
|
||||
|
||||
# Reset the per-phase candy-bubble budget (#082).
|
||||
bubbles_this_phase = 0
|
||||
|
||||
var phase_name = _phase_to_string(phase)
|
||||
print("[Gauntlet] Phase changed to: ", phase_name)
|
||||
|
||||
if _can_rpc() and multiplayer.is_server():
|
||||
rpc("sync_phase", int(phase), phase_name)
|
||||
|
||||
# Update phase explicitly with setup_arena
|
||||
_shrink_arena()
|
||||
|
||||
emit_signal("phase_changed", int(phase), phase_name)
|
||||
|
||||
func _phase_to_string(phase: Phase) -> String:
|
||||
match phase:
|
||||
Phase.OPEN_ARENA:
|
||||
return "Outer Pressure"
|
||||
Phase.ROUTE_PRESSURE:
|
||||
return "Middle Pressure"
|
||||
Phase.SURVIVAL_ENDGAME:
|
||||
return "Inner Survival"
|
||||
_:
|
||||
return "Unknown"
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_phase(phase_index: int, phase_name: String) -> void:
|
||||
if not is_active:
|
||||
activate_client_side()
|
||||
current_phase = phase_index as Phase
|
||||
if not multiplayer.is_server():
|
||||
var bounds = get_arena_bounds()
|
||||
for x in range(ARENA_COLUMNS):
|
||||
for z in range(ARENA_ROWS):
|
||||
var pos = Vector2i(x, z)
|
||||
if pos.x <= bounds.min or pos.x >= bounds.max or pos.y <= bounds.min or pos.y >= bounds.max:
|
||||
if not sticky_cells.has(pos):
|
||||
sticky_cells[pos] = true
|
||||
_update_hud_phase(phase_name)
|
||||
|
||||
# =============================================================================
|
||||
# Arena Setup
|
||||
# =============================================================================
|
||||
|
||||
func _setup_arena() -> void:
|
||||
"""Called by host in main._setup_host_game()"""
|
||||
if not gridmap:
|
||||
gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
||||
if not gridmap:
|
||||
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
||||
if not gridmap:
|
||||
push_error("[Gauntlet] No EnhancedGridMap found!")
|
||||
return
|
||||
|
||||
print("[Gauntlet] Setting up %dx%d Arena..." % [ARENA_COLUMNS, ARENA_ROWS])
|
||||
|
||||
# Sync to clients
|
||||
if _can_rpc():
|
||||
rpc("sync_arena_setup")
|
||||
|
||||
# Apply locally for server
|
||||
_apply_arena_setup()
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func sync_arena_setup() -> void:
|
||||
print("[Gauntlet] Client: Syncing Arena Setup (%dx%d)..." % [ARENA_COLUMNS, ARENA_ROWS])
|
||||
_apply_arena_setup()
|
||||
|
||||
func _apply_arena_setup() -> void:
|
||||
"""Shared arena layout logic for host + clients."""
|
||||
if not gridmap:
|
||||
gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
||||
if not gridmap:
|
||||
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
||||
if not gridmap: return
|
||||
|
||||
# Resize grid (bypass setters that wipe the map)
|
||||
gridmap.set("columns", ARENA_COLUMNS)
|
||||
gridmap.set("rows", ARENA_ROWS)
|
||||
|
||||
# Clear all
|
||||
gridmap.clear()
|
||||
|
||||
# Build the 20x20 arena
|
||||
for x in range(ARENA_COLUMNS):
|
||||
for z in range(ARENA_ROWS):
|
||||
var pos = Vector2i(x, z)
|
||||
|
||||
# Center 3x3 block: NPC obstacle (Candy Pump)
|
||||
if x >= 8 and x <= 10 and z >= 8 and z <= 10:
|
||||
# Hardcode clear all possible layers beneath the Candy Pump
|
||||
for layer in range(5):
|
||||
gridmap.set_cell_item(Vector3i(x, layer, z), -1)
|
||||
continue
|
||||
|
||||
# Boundary walls: perimeter (row 0, row 19, col 0, col 19)
|
||||
if pos.x <= 0 or pos.x >= 19 or pos.y <= 0 or pos.y >= 19:
|
||||
# Also make border walls visually walkable floors instead of red blocks
|
||||
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE)
|
||||
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||||
gridmap.set_cell_item(Vector3i(x, 2, z), TILE_STICKY)
|
||||
sticky_cells[pos] = true
|
||||
continue
|
||||
|
||||
# Interior: walkable floor
|
||||
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE)
|
||||
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||||
gridmap.set_cell_item(Vector3i(x, 2, z), -1)
|
||||
|
||||
gridmap.diagonal_movement = true
|
||||
gridmap.update_grid_data()
|
||||
gridmap.initialize_astar()
|
||||
|
||||
if not pump_instance and main_scene:
|
||||
pump_instance = candy_pump_scene.instantiate()
|
||||
pump_instance.name = "CandyPump"
|
||||
var cx = NPC_CENTER.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0
|
||||
var cz = NPC_CENTER.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0
|
||||
pump_instance.position = Vector3(cx, 0, cz)
|
||||
main_scene.add_child(pump_instance)
|
||||
|
||||
print("[Gauntlet] Arena setup complete. Boundary walls at perimeter. Center NPC at (%d,%d)" % [
|
||||
NPC_CENTER.x, NPC_CENTER.y
|
||||
])
|
||||
|
||||
func _is_npc_zone(pos: Vector2i) -> bool:
|
||||
"""Check if a position is within the center 3x3 NPC zone."""
|
||||
return pos.x >= 8 and pos.x <= 10 and pos.y >= 8 and pos.y <= 10
|
||||
|
||||
func get_spawn_points(player_count: int) -> Array[Vector2i]:
|
||||
"""Return spawn positions based on player count. Inside boundary walls."""
|
||||
# 4 players: inner corners
|
||||
var spawns_4: Array[Vector2i] = [
|
||||
Vector2i(1, 1), # Top-left
|
||||
Vector2i(18, 1), # Top-right
|
||||
Vector2i(1, 18), # Bottom-left
|
||||
Vector2i(18, 18), # Bottom-right
|
||||
]
|
||||
|
||||
# 6 players: corners + mid-edges (top/bottom)
|
||||
var spawns_6: Array[Vector2i] = spawns_4.duplicate()
|
||||
spawns_6.append(Vector2i(10, 1)) # Top-mid
|
||||
spawns_6.append(Vector2i(10, 18)) # Bottom-mid
|
||||
|
||||
# 8 players: corners + all mid-edges
|
||||
var spawns_8: Array[Vector2i] = spawns_6.duplicate()
|
||||
spawns_8.append(Vector2i(1, 10)) # Left-mid
|
||||
spawns_8.append(Vector2i(18, 10)) # Right-mid
|
||||
|
||||
match player_count:
|
||||
4:
|
||||
return spawns_4
|
||||
5, 6:
|
||||
return spawns_6
|
||||
_, 7, 8:
|
||||
return spawns_8
|
||||
_:
|
||||
return spawns_4
|
||||
|
||||
# =============================================================================
|
||||
# Tile Spawning & Mission System (Task #3)
|
||||
# =============================================================================
|
||||
|
||||
func setup_mission_tiles() -> void:
|
||||
"""Public wrapper called from main.gd before countdown. Server-only."""
|
||||
if multiplayer.is_server():
|
||||
_spawn_mission_tiles()
|
||||
|
||||
func _spawn_mission_tiles() -> void:
|
||||
"""Distribute colored goal tiles across the 20x20 arena.
|
||||
Follows StopNGoManager._spawn_mission_tiles() pattern.
|
||||
Excludes center 3x3 NPC zone."""
|
||||
if not gridmap:
|
||||
gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
||||
if not gridmap:
|
||||
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
||||
if not gridmap: return
|
||||
|
||||
# Goal items: Heart(7), Diamond(8), Star(9), Coin(10)
|
||||
var goal_items = [7, 8, 9, 10]
|
||||
var tiles_spawned: int = 0
|
||||
var main = get_node_or_null("/root/Main")
|
||||
|
||||
for x in range(ARENA_COLUMNS):
|
||||
for z in range(ARENA_ROWS):
|
||||
var pos = Vector2i(x, z)
|
||||
|
||||
# Skip NPC pump zone (center 3x3)
|
||||
if x >= 8 and x <= 10 and z >= 8 and z <= 10:
|
||||
continue
|
||||
|
||||
# Check base floor — don't spawn on void (or walls if they were still obstacles)
|
||||
var base_tile = gridmap.get_cell_item(Vector3i(x, 0, z))
|
||||
if base_tile == -1:
|
||||
continue
|
||||
|
||||
# Ensure we don't spawn powerups on the perimeter walls even though they look like floors
|
||||
if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1:
|
||||
continue
|
||||
|
||||
# Skip if something already exists on Layer 1
|
||||
var current_item = gridmap.get_cell_item(Vector3i(x, 1, z))
|
||||
if current_item != -1:
|
||||
continue
|
||||
|
||||
# Spawn tiles with 60% density (40% chance to skip)
|
||||
if randf() > 0.6:
|
||||
continue
|
||||
|
||||
var tile_type = goal_items[randi() % goal_items.size()]
|
||||
gridmap.set_cell_item(Vector3i(x, 1, z), tile_type)
|
||||
tiles_spawned += 1
|
||||
|
||||
# Sync to clients
|
||||
if main:
|
||||
main.rpc("sync_grid_item", x, 1, z, tile_type)
|
||||
|
||||
print("[Gauntlet] Spawned %d mission tiles across %dx%d arena" % [tiles_spawned, ARENA_COLUMNS, ARENA_ROWS])
|
||||
|
||||
# =============================================================================
|
||||
# Growth Logic (Server Only) — v2 ground-growth, replaces cannon volley
|
||||
# =============================================================================
|
||||
|
||||
func _process_growth_tick() -> void:
|
||||
"""One growth tick: score SAFE cells, weight-select, path-check, telegraph."""
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
|
||||
var count := _cells_this_tick()
|
||||
# Detect hidden movement-buffer corridors before scoring so the candidate
|
||||
# scores reflect them this tick (#083; satisfies #067's buffer-check item).
|
||||
_detect_movement_buffers()
|
||||
var candidates := _generate_candidates()
|
||||
if candidates.is_empty():
|
||||
return
|
||||
|
||||
var selected := _select_cells_weighted(candidates, count)
|
||||
selected = _apply_path_safety(selected)
|
||||
if selected.is_empty():
|
||||
return
|
||||
|
||||
_last_tick_cells = selected.duplicate()
|
||||
|
||||
# Telegraph now (passable for telegraph_duration), then convert to sticky.
|
||||
for pos in selected:
|
||||
telegraphed_cells[pos] = telegraph_duration
|
||||
if _can_rpc():
|
||||
rpc("sync_growth_telegraph", selected)
|
||||
else:
|
||||
sync_growth_telegraph(selected)
|
||||
|
||||
await get_tree().create_timer(telegraph_duration).timeout
|
||||
|
||||
for pos in selected:
|
||||
telegraphed_cells.erase(pos)
|
||||
if _can_rpc():
|
||||
rpc("sync_growth_apply", selected)
|
||||
else:
|
||||
sync_growth_apply(selected)
|
||||
|
||||
emit_signal("growth_tick", selected)
|
||||
|
||||
# Possibly start a candy bubble this tick (anti-camping hazard, #082).
|
||||
_try_spawn_bubble()
|
||||
|
||||
func _cells_this_tick() -> int:
|
||||
"""Random cell count within this phase's configured range."""
|
||||
var cfg = phase_growth_config[int(current_phase)]
|
||||
var lo: int = cfg["cells_min"]
|
||||
var hi: int = cfg["cells_max"]
|
||||
if hi <= lo:
|
||||
return lo
|
||||
return lo + randi() % (hi - lo + 1)
|
||||
|
||||
func _generate_candidates() -> Array:
|
||||
"""Build a list of {pos, score} for every SAFE, growable cell."""
|
||||
var candidates: Array = []
|
||||
var player_cells := _active_player_cells() # gathered once per tick
|
||||
for x in range(ARENA_COLUMNS):
|
||||
for z in range(ARENA_ROWS):
|
||||
var pos := Vector2i(x, z)
|
||||
# Only SAFE cells are growable; skip blocked, sticky, telegraphed,
|
||||
# and cleansed (temporary regrowth protection from #068).
|
||||
if cell_state(pos) != CellState.SAFE:
|
||||
continue
|
||||
candidates.append({"pos": pos, "score": _calculate_candidate_score(pos, player_cells)})
|
||||
return candidates
|
||||
|
||||
func _calculate_candidate_score(pos: Vector2i, player_cells: Array = []) -> float:
|
||||
"""Full v2 candidate score (#073). Higher score = higher pick chance.
|
||||
|
||||
CandidateScore =
|
||||
LayerPriority + StickyNeighbor + InwardPressure + PlayerPressure
|
||||
+ ClusterGrowth + CampingPressure + RandomNoise
|
||||
+ MovementBuffer + PathSafety + Repetition
|
||||
"""
|
||||
var score := 0.0
|
||||
score += _score_layer_priority(pos)
|
||||
score += _score_sticky_neighbor(pos)
|
||||
score += _score_inward_pressure(pos)
|
||||
score += _score_player_pressure(pos, player_cells)
|
||||
score += _score_cluster_growth(pos)
|
||||
score += _score_camping_pressure(pos)
|
||||
score += randf_range(-20.0, 20.0) # RandomNoise — keep growth imperfect
|
||||
score += _score_movement_buffer(pos)
|
||||
score += _score_path_safety(pos)
|
||||
score += _score_repetition(pos)
|
||||
return score
|
||||
|
||||
# --- score components (#073) -------------------------------------------------
|
||||
|
||||
func _score_layer_priority(pos: Vector2i) -> float:
|
||||
"""Steer growth to the current phase's pressure ring."""
|
||||
var weights: Dictionary = phase_growth_config[int(current_phase)]["layer_weights"]
|
||||
return float(weights[_layer_of(pos)])
|
||||
|
||||
func _score_sticky_neighbor(pos: Vector2i) -> float:
|
||||
"""Prefer growing adjacent to existing sticky: +8 each, capped +64."""
|
||||
return min(_sticky_neighbor_count(pos) * 8.0, 64.0)
|
||||
|
||||
func _score_inward_pressure(pos: Vector2i) -> float:
|
||||
"""Push candy inward more strongly as the round progresses. Scales with how
|
||||
close the cell is to the center within the per-phase range."""
|
||||
var d := _chebyshev(pos, NPC_CENTER)
|
||||
var max_d := float(maxi(ARENA_COLUMNS, ARENA_ROWS) / 2) # ~10
|
||||
var closeness := clampf(1.0 - float(d) / max_d, 0.0, 1.0)
|
||||
match int(current_phase):
|
||||
0: return lerpf(0.0, 10.0, closeness)
|
||||
1: return lerpf(5.0, 20.0, closeness)
|
||||
_: return lerpf(10.0, 30.0, closeness)
|
||||
|
||||
func _score_player_pressure(pos: Vector2i, player_cells: Array) -> float:
|
||||
"""Pressure players without directly targeting them.
|
||||
- 2-4 cells away: +20
|
||||
- directly under a player: -50 (before final 30s), +10 (final 30s)."""
|
||||
if player_cells.is_empty():
|
||||
return 0.0
|
||||
var best := 0.0
|
||||
var final_window := float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW
|
||||
for pcell in player_cells:
|
||||
var d := _chebyshev(pos, pcell)
|
||||
var s := 0.0
|
||||
if d == 0:
|
||||
s = 10.0 if final_window else -50.0
|
||||
elif d >= 2 and d <= 4:
|
||||
s = 20.0
|
||||
if abs(s) > abs(best):
|
||||
best = s
|
||||
return best
|
||||
|
||||
func _score_cluster_growth(pos: Vector2i) -> float:
|
||||
"""Reward expanding/connecting sticky clusters. Distinct sticky neighbours
|
||||
spanning more than one direction implies a bridge between clusters."""
|
||||
var neighbours := _sticky_neighbor_count(pos)
|
||||
if neighbours == 0:
|
||||
return 0.0
|
||||
if neighbours >= 3:
|
||||
return 25.0 # connects clusters
|
||||
return 15.0 # expands a cluster
|
||||
|
||||
func _score_camping_pressure(pos: Vector2i) -> float:
|
||||
"""Target areas where a player has lingered.
|
||||
>5s: +20, >8s: +40, >10s: +60."""
|
||||
var t := _camp_time_for_region(_region_of(pos))
|
||||
if t > 10.0:
|
||||
return 60.0
|
||||
elif t > 8.0:
|
||||
return 40.0
|
||||
elif t > 5.0:
|
||||
return 20.0
|
||||
return 0.0
|
||||
|
||||
func _score_movement_buffer(pos: Vector2i) -> float:
|
||||
"""Respect hidden safe zones. Two complementary parts (#083):
|
||||
1. Dynamically-detected buffer corridors (decaying) — `_buffer_penalty_at`.
|
||||
2. A light proximity floor around players so the immediate ring stays open.
|
||||
Both lift entirely in the final window so the arena can close out."""
|
||||
var final_window := float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW
|
||||
if final_window:
|
||||
return 0.0
|
||||
|
||||
# 1. Detected corridor buffers (strongest signal).
|
||||
var buffer := _buffer_penalty_at(pos)
|
||||
if buffer < 0.0:
|
||||
return buffer
|
||||
|
||||
# 2. Proximity floor (kept from #073) — discourage sealing the ring next to a
|
||||
# player even when no corridor was detected there.
|
||||
var player_cells := _active_player_cells()
|
||||
var min_d := INF
|
||||
for pcell in player_cells:
|
||||
min_d = min(min_d, float(_chebyshev(pos, pcell)))
|
||||
if min_d == INF:
|
||||
return 0.0
|
||||
match int(current_phase):
|
||||
0:
|
||||
if min_d <= 1: return -40.0
|
||||
elif min_d <= 2: return -20.0
|
||||
1:
|
||||
if min_d <= 1: return -20.0
|
||||
elif min_d <= 2: return -10.0
|
||||
_:
|
||||
if min_d <= 1: return -10.0
|
||||
return 0.0
|
||||
|
||||
func _score_path_safety(pos: Vector2i) -> float:
|
||||
"""Soft penalty that discourages selections which would strand a player.
|
||||
The hard guarantee is enforced separately by _apply_path_safety()."""
|
||||
if float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW:
|
||||
return 0.0
|
||||
var extra := {pos: true}
|
||||
for pcell in _active_player_cells():
|
||||
if not _player_has_safe_region(pcell, extra):
|
||||
return -100.0 # would fully trap a player
|
||||
return 0.0
|
||||
|
||||
func _score_repetition(pos: Vector2i) -> float:
|
||||
"""Avoid spammy growth on last tick's footprint."""
|
||||
for last in _last_tick_cells:
|
||||
if _chebyshev(pos, last) <= 1:
|
||||
return -30.0
|
||||
return 0.0
|
||||
|
||||
func _select_cells_weighted(candidates: Array, count: int) -> Array:
|
||||
"""Weighted-random selection: higher score = higher pick chance.
|
||||
|
||||
Scores are shifted positive so the lowest-scoring cell still has a small
|
||||
non-zero weight, preserving organic unpredictability.
|
||||
"""
|
||||
var pool: Array = candidates.duplicate()
|
||||
var picked: Array = []
|
||||
|
||||
# Find the minimum score to offset all weights into the positive range.
|
||||
var min_score := INF
|
||||
for c in pool:
|
||||
min_score = min(min_score, c["score"])
|
||||
var offset := 1.0 - min_score # ensures every weight >= 1.0
|
||||
|
||||
var n: int = min(count, pool.size())
|
||||
for _i in range(n):
|
||||
var total := 0.0
|
||||
for c in pool:
|
||||
total += c["score"] + offset
|
||||
if total <= 0.0:
|
||||
break
|
||||
var roll := randf() * total
|
||||
var acc := 0.0
|
||||
var chosen_idx := 0
|
||||
for j in range(pool.size()):
|
||||
acc += pool[j]["score"] + offset
|
||||
if roll <= acc:
|
||||
chosen_idx = j
|
||||
break
|
||||
picked.append(pool[chosen_idx]["pos"])
|
||||
pool.remove_at(chosen_idx)
|
||||
|
||||
return picked
|
||||
|
||||
# --- scoring helpers ---------------------------------------------------------
|
||||
|
||||
func _layer_of(pos: Vector2i) -> String:
|
||||
"""Classify a cell into outer / middle / inner rings by Chebyshev distance
|
||||
from the arena center (matches the NPC pump at the middle)."""
|
||||
var d := _chebyshev(pos, NPC_CENTER)
|
||||
if d >= 7:
|
||||
return "outer"
|
||||
elif d >= 4:
|
||||
return "middle"
|
||||
return "inner"
|
||||
|
||||
func _sticky_neighbor_count(pos: Vector2i) -> int:
|
||||
"""Count of the 8 surrounding cells that are already sticky."""
|
||||
var c := 0
|
||||
for dx in range(-1, 2):
|
||||
for dz in range(-1, 2):
|
||||
if dx == 0 and dz == 0:
|
||||
continue
|
||||
if sticky_cells.has(pos + Vector2i(dx, dz)):
|
||||
c += 1
|
||||
return c
|
||||
|
||||
func _chebyshev(a: Vector2i, b: Vector2i) -> int:
|
||||
return max(abs(a.x - b.x), abs(a.y - b.y))
|
||||
|
||||
# --- camping tracking --------------------------------------------------------
|
||||
|
||||
func _region_of(pos: Vector2i) -> Vector2i:
|
||||
"""Coarse 4x4 region key a cell belongs to (for camping detection)."""
|
||||
return Vector2i(pos.x / CAMP_REGION_SIZE, pos.y / CAMP_REGION_SIZE)
|
||||
|
||||
func _update_camp_tracking(delta: float) -> void:
|
||||
"""Accumulate time each player spends in their current 4x4 region.
|
||||
Resets the timer when a player moves to a new region. Server-side."""
|
||||
var seen := {}
|
||||
for player in get_tree().get_nodes_in_group("Players"):
|
||||
var pid = player.get("peer_id") if "peer_id" in player else -1
|
||||
if pid == -1 or not ("current_position" in player) or player.current_position == null:
|
||||
continue
|
||||
seen[pid] = true
|
||||
var region := _region_of(player.current_position)
|
||||
var rec = _camp_tracking.get(pid)
|
||||
if rec == null or rec["region"] != region:
|
||||
_camp_tracking[pid] = {"region": region, "time": 0.0}
|
||||
else:
|
||||
rec["time"] += delta
|
||||
# Drop tracking for players that left the match.
|
||||
for pid in _camp_tracking.keys():
|
||||
if not seen.has(pid):
|
||||
_camp_tracking.erase(pid)
|
||||
|
||||
func _camp_time_for_region(region: Vector2i) -> float:
|
||||
"""Longest camp time any player has accrued in the given region."""
|
||||
var best := 0.0
|
||||
for pid in _camp_tracking:
|
||||
var rec = _camp_tracking[pid]
|
||||
if rec["region"] == region:
|
||||
best = max(best, rec["time"])
|
||||
return best
|
||||
|
||||
# =============================================================================
|
||||
# Growth Telegraph & Apply (RPCs) — v2
|
||||
# =============================================================================
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_growth_telegraph(cells: Array) -> void:
|
||||
"""Warn that the given cells will become sticky. Cells stay passable until
|
||||
sync_growth_apply fires (telegraph_duration later)."""
|
||||
if not gridmap: return
|
||||
|
||||
for cell in cells:
|
||||
var pos = cell as Vector2i
|
||||
if pos.x >= 8 and pos.x <= 10 and pos.y >= 8 and pos.y <= 10: continue
|
||||
|
||||
# Telegraph overlay tile on Layer 2 (still passable).
|
||||
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH)
|
||||
_spawn_telegraph_highlight(pos)
|
||||
|
||||
# NEW: Throw projectile from pump for normal growth
|
||||
if pump_instance and pump_instance.has_method("spawn_projectile"):
|
||||
var target_world_pos = Vector3(
|
||||
pos.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0,
|
||||
0.5,
|
||||
pos.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0
|
||||
)
|
||||
pump_instance.spawn_projectile(target_world_pos, telegraph_duration)
|
||||
|
||||
# Audio: warning pulse
|
||||
if SfxManager:
|
||||
SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile")
|
||||
|
||||
func _spawn_telegraph_highlight(pos: Vector2i) -> void:
|
||||
"""Two-stage amber warning under a telegraphed cell (#069):
|
||||
• Build-up (0–0.8s): amber glow ramps alpha 0→1.
|
||||
• Flash (0.8–1.0s): flickers to bright amber just before impact.
|
||||
Auto-removed at the end of the telegraph window. Amber here is deliberately
|
||||
distinct from the pink/magenta sticky overlay so the two never read alike."""
|
||||
var cs = gridmap.cell_size
|
||||
var world_pos = Vector3(pos.x * cs.x + cs.x / 2.0, 0.15, pos.y * cs.z + cs.z / 2.0)
|
||||
|
||||
var mesh_inst = MeshInstance3D.new()
|
||||
var box = BoxMesh.new()
|
||||
box.size = Vector3(cs.x * 0.8, 0.02, cs.z * 0.8)
|
||||
mesh_inst.mesh = box
|
||||
mesh_inst.position = world_pos
|
||||
|
||||
var amber := Color(1.0, 0.65, 0.1) # syrup amber — clearly not sticky pink
|
||||
var mat = StandardMaterial3D.new()
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.albedo_color = Color(amber.r, amber.g, amber.b, 0.0)
|
||||
mat.emission_enabled = true
|
||||
mat.emission = amber
|
||||
mat.emission_energy_multiplier = 1.5
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mesh_inst.material_override = mat
|
||||
|
||||
var main = get_node_or_null("/root/Main")
|
||||
if not main:
|
||||
return
|
||||
main.add_child(mesh_inst)
|
||||
|
||||
# Split the telegraph window 80% build-up / 20% flash.
|
||||
var build := telegraph_duration * 0.8
|
||||
var flash := telegraph_duration * 0.2
|
||||
|
||||
var tween = create_tween()
|
||||
# Build-up: fade in to a steady amber.
|
||||
tween.tween_method(func(a): mat.albedo_color.a = a, 0.0, 0.55, build)
|
||||
# Flash: quick bright flicker (alpha + emission energy) right before impact.
|
||||
tween.tween_method(func(e): mat.emission_energy_multiplier = e, 1.5, 4.0, flash * 0.5)
|
||||
tween.parallel().tween_method(func(a): mat.albedo_color.a = a, 0.55, 0.9, flash * 0.5)
|
||||
tween.tween_method(func(e): mat.emission_energy_multiplier = e, 4.0, 2.5, flash * 0.5)
|
||||
|
||||
var remove_timer = get_tree().create_timer(telegraph_duration)
|
||||
remove_timer.timeout.connect(func():
|
||||
if is_instance_valid(mesh_inst):
|
||||
mesh_inst.queue_free()
|
||||
)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_growth_apply(cells: Array) -> void:
|
||||
"""Convert telegraphed cells to permanent sticky candy."""
|
||||
if not gridmap: return
|
||||
for cell in cells:
|
||||
var pos = cell as Vector2i
|
||||
if pos.x >= 8 and pos.x <= 10 and pos.y >= 8 and pos.y <= 10: continue
|
||||
|
||||
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_STICKY)
|
||||
sticky_cells[pos] = true
|
||||
|
||||
# Screen shake for impact
|
||||
if main_scene and main_scene.get("screen_shake_manager"):
|
||||
main_scene.screen_shake_manager.shake(0.15, 0.4)
|
||||
|
||||
# Audio: sticky splat
|
||||
if SfxManager:
|
||||
SfxManager.rpc("play_rpc", "tile_scatter") if _can_rpc() else SfxManager.play("tile_scatter")
|
||||
|
||||
_spawn_impact_particles(cells)
|
||||
|
||||
# Re-evaluate trapped players after the new sticky cells land.
|
||||
_check_all_players_trapped()
|
||||
|
||||
func _spawn_impact_particles(targets: Array) -> void:
|
||||
"""Spawn candy splash particles at impact locations."""
|
||||
if not main_scene:
|
||||
return
|
||||
|
||||
for target in targets:
|
||||
var pos = target as Vector2i
|
||||
var world_pos = Vector3(
|
||||
pos.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0,
|
||||
0.5, # Slightly above floor
|
||||
pos.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0
|
||||
)
|
||||
|
||||
# Create a simple particle effect (GPUParticles3D)
|
||||
var particles = GPUParticles3D.new()
|
||||
particles.emitting = true
|
||||
particles.one_shot = true
|
||||
particles.amount = 8
|
||||
particles.lifetime = 0.5
|
||||
particles.explosiveness = 1.0
|
||||
|
||||
# Candy pink color
|
||||
var material = ParticleProcessMaterial.new()
|
||||
material.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
|
||||
material.emission_sphere_radius = 0.2
|
||||
material.direction = Vector3(0, 1, 0)
|
||||
material.spread = 45.0
|
||||
material.initial_velocity_min = 2.0
|
||||
material.initial_velocity_max = 4.0
|
||||
material.gravity = Vector3(0, -9.8, 0)
|
||||
material.scale_min = 0.1
|
||||
material.scale_max = 0.3
|
||||
|
||||
# Define visual mesh
|
||||
var mesh = BoxMesh.new()
|
||||
mesh.size = Vector3(0.2, 0.2, 0.2)
|
||||
var spatial_mat = StandardMaterial3D.new()
|
||||
spatial_mat.albedo_color = Color(1.0, 0.6, 0.8) # Candy pink
|
||||
spatial_mat.emission_enabled = true
|
||||
spatial_mat.emission = Color(1.0, 0.6, 0.8)
|
||||
spatial_mat.emission_energy_multiplier = 2.0
|
||||
|
||||
# Outline shader for splash VFX
|
||||
var outline_mat = ShaderMaterial.new()
|
||||
outline_mat.shader = load("res://assets/shaders/outline3d.gdshader")
|
||||
spatial_mat.next_pass = outline_mat
|
||||
|
||||
mesh.material = spatial_mat
|
||||
particles.draw_pass_1 = mesh
|
||||
|
||||
particles.process_material = material
|
||||
particles.position = world_pos
|
||||
|
||||
main_scene.add_child(particles)
|
||||
|
||||
# Auto-remove after particles finish
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
if particles and is_instance_valid(particles):
|
||||
particles.queue_free()
|
||||
|
||||
# =============================================================================
|
||||
# Sticky / Trap System
|
||||
|
||||
func is_sticky_cell(pos: Vector2i) -> bool:
|
||||
return sticky_cells.has(pos)
|
||||
|
||||
func is_cleansed_cell(pos: Vector2i) -> bool:
|
||||
return cleansed_cells.has(pos)
|
||||
|
||||
func cell_state(pos: Vector2i) -> CellState:
|
||||
"""Logical state of a playable cell (v2 ground-growth model)."""
|
||||
var b = get_arena_bounds()
|
||||
if pos.x <= b.min or pos.x >= b.max or pos.y <= b.min or pos.y >= b.max:
|
||||
return CellState.STICKY
|
||||
if _is_npc_zone(pos) or _is_boundary(pos):
|
||||
return CellState.BLOCKED
|
||||
if is_sticky_cell(pos):
|
||||
return CellState.STICKY
|
||||
if cleansed_cells.has(pos):
|
||||
return CellState.BLOCKED # Protected from regrowth temporarily
|
||||
if telegraphed_cells.has(pos):
|
||||
return CellState.TELEGRAPHED
|
||||
if bubble_cells.has(pos):
|
||||
return CellState.BUBBLE_GROWING
|
||||
return CellState.SAFE
|
||||
|
||||
func mark_cleansed(pos: Vector2i) -> void:
|
||||
"""Flag a cell as recently cleansed, granting temporary regrowth protection."""
|
||||
cleansed_cells[pos] = CLEANSED_PROTECTION_TIME
|
||||
|
||||
func _tick_cleansed_cells(delta: float) -> void:
|
||||
"""Count down cleansed-cell protection; expire when it runs out."""
|
||||
var expired: Array[Vector2i] = []
|
||||
for pos in cleansed_cells:
|
||||
cleansed_cells[pos] -= delta
|
||||
if cleansed_cells[pos] <= 0.0:
|
||||
expired.append(pos)
|
||||
for pos in expired:
|
||||
cleansed_cells.erase(pos)
|
||||
|
||||
func get_arena_bounds() -> Dictionary:
|
||||
match current_phase:
|
||||
Phase.OPEN_ARENA:
|
||||
return {"min": 0, "max": 19} # 20x20
|
||||
Phase.ROUTE_PRESSURE:
|
||||
return {"min": 1, "max": 18} # 18x18
|
||||
Phase.SURVIVAL_ENDGAME:
|
||||
return {"min": 6, "max": 12} # 7x7
|
||||
return {"min": 0, "max": 19}
|
||||
|
||||
func _shrink_arena() -> void:
|
||||
if not multiplayer.is_server(): return
|
||||
var b = get_arena_bounds()
|
||||
var new_sticky = []
|
||||
for x in range(ARENA_COLUMNS):
|
||||
for z in range(ARENA_ROWS):
|
||||
var pos = Vector2i(x, z)
|
||||
if _is_npc_zone(pos) or _is_boundary(pos):
|
||||
continue
|
||||
if pos.x <= b.min or pos.x >= b.max or pos.y <= b.min or pos.y >= b.max:
|
||||
if not sticky_cells.has(pos):
|
||||
new_sticky.append(pos)
|
||||
|
||||
if new_sticky.size() > 0:
|
||||
if _can_rpc() and multiplayer.is_server():
|
||||
rpc("sync_growth_apply", new_sticky)
|
||||
else:
|
||||
sync_growth_apply(new_sticky)
|
||||
|
||||
func _is_boundary(pos: Vector2i) -> bool:
|
||||
return pos.x <= 0 or pos.x >= ARENA_COLUMNS - 1 or pos.y <= 0 or pos.y >= ARENA_ROWS - 1
|
||||
|
||||
# =============================================================================
|
||||
# Coverage tracking (v2 target: 70-75%, down from v1's 80%)
|
||||
# =============================================================================
|
||||
|
||||
const COVERAGE_TARGET_MIN: float = 0.70
|
||||
const COVERAGE_TARGET_MAX: float = 0.75
|
||||
|
||||
func playable_cell_count() -> int:
|
||||
"""Number of cells that can ever become sticky (interior, minus NPC zone)."""
|
||||
var b = get_arena_bounds()
|
||||
var count := 0
|
||||
for x in range(ARENA_COLUMNS):
|
||||
for z in range(ARENA_ROWS):
|
||||
var pos := Vector2i(x, z)
|
||||
if _is_boundary(pos) or _is_npc_zone(pos):
|
||||
continue
|
||||
if pos.x <= b.min or pos.x >= b.max or pos.y <= b.min or pos.y >= b.max:
|
||||
continue
|
||||
count += 1
|
||||
return count
|
||||
|
||||
func coverage_ratio() -> float:
|
||||
"""Fraction of playable cells currently sticky (0.0-1.0)."""
|
||||
var playable := playable_cell_count()
|
||||
if playable <= 0:
|
||||
return 0.0
|
||||
return float(sticky_cells.size()) / float(playable)
|
||||
|
||||
func is_coverage_reached() -> bool:
|
||||
"""True once sticky coverage hits the v2 minimum target."""
|
||||
return coverage_ratio() >= COVERAGE_TARGET_MIN
|
||||
|
||||
# =============================================================================
|
||||
# Path safety (v2): never trap a player before the final window
|
||||
# =============================================================================
|
||||
|
||||
const SAFE_REGION_MIN_CELLS: int = 6 # each player must keep this many reachable safe cells
|
||||
const FORCED_TRAP_WINDOW: float = 30.0 # final seconds where trapping is allowed
|
||||
|
||||
func _is_cell_passable(pos: Vector2i, extra_sticky: Dictionary = {}) -> bool:
|
||||
"""Can a player stand on / move through this cell, given a hypothetical sticky set?"""
|
||||
var b = get_arena_bounds()
|
||||
if pos.x <= b.min or pos.x >= b.max or pos.y <= b.min or pos.y >= b.max:
|
||||
return false
|
||||
if _is_boundary(pos) or _is_npc_zone(pos):
|
||||
return false
|
||||
if sticky_cells.has(pos) or extra_sticky.has(pos):
|
||||
return false
|
||||
return true
|
||||
|
||||
func _reachable_safe_cells(start: Vector2i, extra_sticky: Dictionary, limit: int) -> int:
|
||||
"""Flood-fill from start over passable cells; stop early once `limit` reached."""
|
||||
if not _is_cell_passable(start, extra_sticky):
|
||||
return 0
|
||||
var visited := {start: true}
|
||||
var queue: Array[Vector2i] = [start]
|
||||
var count := 0
|
||||
const NEIGHBORS := [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]
|
||||
while not queue.is_empty():
|
||||
var cur: Vector2i = queue.pop_front()
|
||||
count += 1
|
||||
if count >= limit:
|
||||
return count
|
||||
for d in NEIGHBORS:
|
||||
var nxt: Vector2i = cur + d
|
||||
if visited.has(nxt):
|
||||
continue
|
||||
if _is_cell_passable(nxt, extra_sticky):
|
||||
visited[nxt] = true
|
||||
queue.push_back(nxt)
|
||||
return count
|
||||
|
||||
func _player_has_safe_region(start: Vector2i, extra_sticky: Dictionary) -> bool:
|
||||
"""Player at `start` still has at least SAFE_REGION_MIN_CELLS reachable cells."""
|
||||
return _reachable_safe_cells(start, extra_sticky, SAFE_REGION_MIN_CELLS) >= SAFE_REGION_MIN_CELLS
|
||||
|
||||
func _apply_path_safety(candidates: Array) -> Array:
|
||||
"""Filter a candidate sticky-cell list so no active player is trapped.
|
||||
|
||||
During the final FORCED_TRAP_WINDOW seconds, trapping is allowed and the
|
||||
candidate list is returned unchanged.
|
||||
"""
|
||||
var time_left := float(gauntlet_round_duration() - elapsed_time)
|
||||
if time_left <= FORCED_TRAP_WINDOW:
|
||||
return candidates
|
||||
|
||||
var player_cells := _active_player_cells()
|
||||
if player_cells.is_empty():
|
||||
return candidates
|
||||
|
||||
var accepted: Array = []
|
||||
var pending := {}
|
||||
for c in candidates:
|
||||
pending[c] = true
|
||||
for c in candidates:
|
||||
# Tentatively accept c, then verify every player keeps a safe region.
|
||||
var trial := pending.duplicate()
|
||||
# `pending` holds all not-yet-rejected candidates; treat accepted ones as sticky.
|
||||
var trial_sticky := {}
|
||||
for a in accepted:
|
||||
trial_sticky[a] = true
|
||||
trial_sticky[c] = true
|
||||
var safe_for_all := true
|
||||
for pcell in player_cells:
|
||||
if not _player_has_safe_region(pcell, trial_sticky):
|
||||
safe_for_all = false
|
||||
break
|
||||
if safe_for_all:
|
||||
accepted.append(c)
|
||||
else:
|
||||
pending.erase(c)
|
||||
return accepted
|
||||
|
||||
# =============================================================================
|
||||
# Movement buffers (#083): hidden, decaying safe corridors
|
||||
# =============================================================================
|
||||
|
||||
func _detect_movement_buffers() -> void:
|
||||
"""Find SAFE cells that are critical movement corridors for active players and
|
||||
register/refresh a hidden penalty on them. A corridor is a passable cell near
|
||||
a player whose removal would shrink that player's reachable region below
|
||||
BUFFER_CORRIDOR_THRESHOLD (a genuine chokepoint, not open floor).
|
||||
|
||||
Campers don't get fresh buffers near them — staying put forfeits protection.
|
||||
Runs server-side once per growth tick, before scoring."""
|
||||
var player_cells := _active_player_cells()
|
||||
if player_cells.is_empty():
|
||||
return
|
||||
|
||||
var base: float = BUFFER_BASE_PENALTY[int(current_phase)]
|
||||
const NEIGHBORS := [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]
|
||||
|
||||
for pcell in player_cells:
|
||||
# Camping override: a player lingering in one region loses buffer help.
|
||||
if _camp_time_for_region(_region_of(pcell)) > 5.0:
|
||||
continue
|
||||
# Examine the passable cells immediately around the player.
|
||||
for d in NEIGHBORS:
|
||||
var cell: Vector2i = pcell + d
|
||||
if not _is_cell_passable(cell):
|
||||
continue
|
||||
# Is this a chokepoint? Removing it must noticeably cut reachability.
|
||||
var without := _reachable_safe_cells(pcell, {cell: true}, BUFFER_CORRIDOR_THRESHOLD)
|
||||
if without < BUFFER_CORRIDOR_THRESHOLD:
|
||||
_register_buffer(cell, base)
|
||||
|
||||
func _register_buffer(pos: Vector2i, penalty: float) -> void:
|
||||
"""Add or refresh a buffer cell at full penalty for the current phase."""
|
||||
if movement_buffers.has(pos):
|
||||
# Refresh to the stronger of the existing or the new base penalty.
|
||||
movement_buffers[pos]["penalty"] = max(movement_buffers[pos]["penalty"], penalty)
|
||||
else:
|
||||
movement_buffers[pos] = {"penalty": penalty}
|
||||
|
||||
func _decay_movement_buffers(delta: float) -> void:
|
||||
"""Reduce buffer penalties by 25% every BUFFER_DECAY_INTERVAL seconds, then
|
||||
prune any that have faded below BUFFER_MIN_PENALTY. Server-side each tick."""
|
||||
if movement_buffers.is_empty():
|
||||
return
|
||||
_buffer_decay_timer += delta
|
||||
if _buffer_decay_timer < BUFFER_DECAY_INTERVAL:
|
||||
return
|
||||
_buffer_decay_timer = 0.0
|
||||
_scale_all_buffers(BUFFER_DECAY_FACTOR)
|
||||
|
||||
func _scale_all_buffers(factor: float) -> void:
|
||||
"""Multiply every buffer penalty by `factor`, pruning faded entries."""
|
||||
for pos in movement_buffers.keys():
|
||||
var p: float = movement_buffers[pos]["penalty"] * factor
|
||||
if p < BUFFER_MIN_PENALTY:
|
||||
movement_buffers.erase(pos)
|
||||
else:
|
||||
movement_buffers[pos]["penalty"] = p
|
||||
|
||||
func _buffer_penalty_at(pos: Vector2i) -> float:
|
||||
"""Penalty for landing growth on a buffer cell (inside = full, adjacent = half).
|
||||
Lifts entirely in the final window so the arena can close out."""
|
||||
if movement_buffers.is_empty():
|
||||
return 0.0
|
||||
if float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW:
|
||||
return 0.0
|
||||
if movement_buffers.has(pos):
|
||||
return -movement_buffers[pos]["penalty"]
|
||||
# Adjacent to a buffer cell → half penalty.
|
||||
const NEIGHBORS := [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]
|
||||
for d in NEIGHBORS:
|
||||
if movement_buffers.has(pos + d):
|
||||
return -movement_buffers[pos + d]["penalty"] * 0.5
|
||||
return 0.0
|
||||
|
||||
func _active_player_cells() -> Array[Vector2i]:
|
||||
"""Current grid cells of non-trapped players."""
|
||||
var cells: Array[Vector2i] = []
|
||||
for player in get_tree().get_nodes_in_group("Players"):
|
||||
var pid = player.get("peer_id") if "peer_id" in player else -1
|
||||
if trapped_players.has(pid):
|
||||
continue
|
||||
if "current_position" in player and player.current_position != null:
|
||||
cells.append(player.current_position)
|
||||
return cells
|
||||
|
||||
# =============================================================================
|
||||
# Candy bubbles (#082): anti-camping hazards (1x1 grow → 3x3 explosion)
|
||||
# =============================================================================
|
||||
|
||||
func _bubble_budget_for_phase() -> int:
|
||||
"""How many bubbles this phase is allowed to spawn in total."""
|
||||
return MAX_BUBBLES_PER_PHASE[int(current_phase)]
|
||||
|
||||
func _generate_bubble_candidates() -> Array:
|
||||
"""Score every SAFE cell as a potential bubble center. Returns {pos, score}."""
|
||||
var candidates: Array = []
|
||||
var player_cells := _active_player_cells()
|
||||
for x in range(ARENA_COLUMNS):
|
||||
for z in range(ARENA_ROWS):
|
||||
var pos := Vector2i(x, z)
|
||||
if cell_state(pos) != CellState.SAFE:
|
||||
continue
|
||||
|
||||
# NEW: Ensure bubbles never pick boundary tiles or NPC area as center
|
||||
if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1:
|
||||
continue
|
||||
if _is_npc_zone(pos):
|
||||
continue
|
||||
|
||||
candidates.append({"pos": pos, "score": _calculate_bubble_score(pos, player_cells)})
|
||||
return candidates
|
||||
|
||||
func _calculate_bubble_score(pos: Vector2i, player_cells: Array = []) -> float:
|
||||
"""Bubble-specific scoring (#082). Higher = better bubble target.
|
||||
|
||||
BubbleScore = Camping + UntouchedArea + PlayerCluster + RandomNoise
|
||||
+ DirectHitPenalty + RecentBubblePenalty + UnfairTrapPenalty
|
||||
"""
|
||||
var score := 0.0
|
||||
score += _bubble_score_camping(pos)
|
||||
score += _bubble_score_untouched_area(pos)
|
||||
score += _bubble_score_player_cluster(pos, player_cells)
|
||||
score += randf_range(-20.0, 20.0)
|
||||
score += _bubble_score_direct_hit(pos, player_cells)
|
||||
score += _bubble_score_recent(pos)
|
||||
score += _bubble_score_unfair_trap(pos)
|
||||
return score
|
||||
|
||||
func _bubble_score_camping(pos: Vector2i) -> float:
|
||||
"""Reward targeting campers. +40 >5s, +60 >8s, +80 >10s-with-ghost."""
|
||||
var t := _camp_time_for_region(_region_of(pos))
|
||||
if t > 10.0:
|
||||
# Stronger only if a nearby player is in ghost mode.
|
||||
if _any_ghost_player_near(pos):
|
||||
return 80.0
|
||||
return 60.0
|
||||
elif t > 8.0:
|
||||
return 60.0
|
||||
elif t > 5.0:
|
||||
return 40.0
|
||||
return 0.0
|
||||
|
||||
func _bubble_score_untouched_area(pos: Vector2i) -> float:
|
||||
"""+30 when the cell sits in a large untouched (sticky-free) region."""
|
||||
var open := _reachable_safe_cells(pos, {}, 30)
|
||||
return 30.0 if open >= 24 else 0.0
|
||||
|
||||
func _bubble_score_player_cluster(pos: Vector2i, player_cells: Array) -> float:
|
||||
"""+20 when 2+ players are nearby (within 4 cells)."""
|
||||
var near := 0
|
||||
for pcell in player_cells:
|
||||
if _chebyshev(pos, pcell) <= 4:
|
||||
near += 1
|
||||
return 20.0 if near >= 2 else 0.0
|
||||
|
||||
func _bubble_score_direct_hit(pos: Vector2i, player_cells: Array) -> float:
|
||||
"""-60 if a bubble would erupt directly under a player (unfair, unreadable)."""
|
||||
for pcell in player_cells:
|
||||
if pos == pcell:
|
||||
return -60.0
|
||||
return 0.0
|
||||
|
||||
func _bubble_score_recent(pos: Vector2i) -> float:
|
||||
"""-50 if a recent bubble erupted in/near this region (anti-stacking)."""
|
||||
for c in recent_bubble_positions:
|
||||
if _chebyshev(pos, c) <= BUBBLE_RECENT_RADIUS:
|
||||
return -50.0
|
||||
return 0.0
|
||||
|
||||
func _bubble_score_unfair_trap(pos: Vector2i) -> float:
|
||||
"""-100 if the 3x3 explosion would strand a player (before the final window)."""
|
||||
if float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW:
|
||||
return 0.0
|
||||
var blast := {}
|
||||
for cell in _bubble_blast_cells(pos):
|
||||
blast[cell] = true
|
||||
for pcell in _active_player_cells():
|
||||
if blast.has(pcell):
|
||||
continue # direct-hit handled separately
|
||||
if not _player_has_safe_region(pcell, blast):
|
||||
return -100.0
|
||||
return 0.0
|
||||
|
||||
func _bubble_blast_cells(center: Vector2i) -> Array:
|
||||
"""The 3x3 (radius 1) sticky cells a bubble at `center` would create,
|
||||
clipped to passable/playable cells."""
|
||||
var b = get_arena_bounds()
|
||||
var cells: Array = []
|
||||
for dx in range(-BUBBLE_EXPLOSION_RADIUS, BUBBLE_EXPLOSION_RADIUS + 1):
|
||||
for dz in range(-BUBBLE_EXPLOSION_RADIUS, BUBBLE_EXPLOSION_RADIUS + 1):
|
||||
var c := center + Vector2i(dx, dz)
|
||||
if _is_boundary(c) or _is_npc_zone(c):
|
||||
continue
|
||||
if c.x <= b.min or c.x >= b.max or c.y <= b.min or c.y >= b.max:
|
||||
continue
|
||||
cells.append(c)
|
||||
return cells
|
||||
|
||||
func _bubble_footprint(center: Vector2i) -> Array:
|
||||
return _bubble_blast_cells(center)
|
||||
|
||||
func _any_ghost_player_near(pos: Vector2i) -> bool:
|
||||
"""True if a player in ghost mode is within the camping region."""
|
||||
for player in get_tree().get_nodes_in_group("Players"):
|
||||
if not player.get("is_invisible"):
|
||||
continue
|
||||
if "current_position" in player and player.current_position != null:
|
||||
if _region_of(player.current_position) == _region_of(pos):
|
||||
return true
|
||||
return false
|
||||
|
||||
# --- bubble lifecycle (server-authoritative) ---------------------------------
|
||||
|
||||
func _try_spawn_bubble() -> void:
|
||||
"""Maybe spawn one candy bubble this growth tick, if the phase still has
|
||||
budget. Server-side; called from _process_growth_tick after normal growth."""
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
if bubbles_this_phase >= _bubble_budget_for_phase():
|
||||
return
|
||||
# Probabilistic so bubbles don't all fire on the first ticks of a phase.
|
||||
# ~1 in 4 eligible ticks; the per-phase cap still bounds the total.
|
||||
if randf() > 0.25:
|
||||
return
|
||||
|
||||
var candidates := _generate_bubble_candidates()
|
||||
if candidates.is_empty():
|
||||
return
|
||||
var picked := _select_cells_weighted(candidates, 1)
|
||||
if picked.is_empty():
|
||||
return
|
||||
var center: Vector2i = picked[0]
|
||||
|
||||
# Reject low-quality targets (e.g. recent/unfair) — only spawn if the chosen
|
||||
# cell scores non-negative, so penalties can veto a bad bubble.
|
||||
var best_score := -INF
|
||||
for c in candidates:
|
||||
if c["pos"] == center:
|
||||
best_score = c["score"]
|
||||
break
|
||||
if best_score < 0.0:
|
||||
return
|
||||
|
||||
_spawn_bubble(center)
|
||||
|
||||
func _spawn_bubble(center: Vector2i) -> void:
|
||||
"""Begin a bubble at `center`: mark the 3x3 footprint BUBBLE_GROWING and start
|
||||
its grow timer. Broadcasts the warning to clients."""
|
||||
bubbles_this_phase += 1
|
||||
bubbles_total += 1
|
||||
|
||||
var cells := _bubble_blast_cells(center)
|
||||
for c in cells:
|
||||
bubble_cells[c] = true
|
||||
|
||||
active_bubbles.append({"center": center, "timer": BUBBLE_GROW_DURATION, "cells": cells})
|
||||
|
||||
# Anti-stacking memory.
|
||||
recent_bubble_positions.append(center)
|
||||
while recent_bubble_positions.size() > BUBBLE_RECENT_MEMORY:
|
||||
recent_bubble_positions.pop_front()
|
||||
|
||||
if _can_rpc():
|
||||
rpc("sync_bubble_spawn", center, cells)
|
||||
else:
|
||||
sync_bubble_spawn(center, cells)
|
||||
|
||||
func _update_bubbles(delta: float) -> void:
|
||||
"""Advance grow timers; explode bubbles whose timer elapses. Server-side."""
|
||||
if active_bubbles.is_empty():
|
||||
return
|
||||
var exploded: Array = []
|
||||
for b in active_bubbles:
|
||||
b["timer"] -= delta
|
||||
if b["timer"] <= 0.0:
|
||||
exploded.append(b)
|
||||
for b in exploded:
|
||||
active_bubbles.erase(b)
|
||||
_explode_bubble(b["center"], b["cells"])
|
||||
|
||||
func _explode_bubble(center: Vector2i, cells: Array) -> void:
|
||||
"""Convert a bubble's 3x3 footprint to sticky, slow players caught inside,
|
||||
and broadcast the explosion."""
|
||||
for c in cells:
|
||||
bubble_cells.erase(c)
|
||||
sticky_cells[c] = true
|
||||
|
||||
if _can_rpc():
|
||||
rpc("sync_bubble_explode", center, cells)
|
||||
else:
|
||||
sync_bubble_explode(center, cells)
|
||||
|
||||
# Slow any player standing in the blast (consistent with sticky entry, #068).
|
||||
var blast := {}
|
||||
for c in cells:
|
||||
blast[c] = true
|
||||
for player in get_tree().get_nodes_in_group("Players"):
|
||||
if "current_position" in player and player.current_position != null:
|
||||
if blast.has(player.current_position):
|
||||
var pid = player.get("peer_id") if "peer_id" in player else -1
|
||||
if pid != -1 and player.get("is_invisible"):
|
||||
continue
|
||||
apply_sticky_slow(player)
|
||||
|
||||
# Bot paths through the new sticky are now invalid.
|
||||
if gridmap and gridmap.has_method("initialize_astar"):
|
||||
gridmap.initialize_astar()
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_bubble_spawn(center: Vector2i, cells: Array) -> void:
|
||||
"""Show the growing bubble + 3x3 warning area on all clients."""
|
||||
if not gridmap:
|
||||
return
|
||||
# Telegraph-style warning overlay on the footprint (still passable).
|
||||
for c in cells:
|
||||
var pos = c as Vector2i
|
||||
if pos.x >= 8 and pos.x <= 10 and pos.y >= 8 and pos.y <= 10: continue
|
||||
|
||||
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH)
|
||||
_spawn_bubble_visual(center)
|
||||
if SfxManager:
|
||||
SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile")
|
||||
|
||||
# NEW: VFX projectile from center pump if it exists
|
||||
if pump_instance and pump_instance.has_method("spawn_projectile"):
|
||||
var target_world_pos = Vector3(
|
||||
center.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0,
|
||||
0.5,
|
||||
center.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0
|
||||
)
|
||||
pump_instance.spawn_projectile(target_world_pos, BUBBLE_GROW_DURATION)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_bubble_explode(center: Vector2i, cells: Array) -> void:
|
||||
"""Apply the 3x3 sticky overlay + explosion VFX on all clients."""
|
||||
if not gridmap:
|
||||
return
|
||||
for c in cells:
|
||||
var pos = c as Vector2i
|
||||
if pos.x >= 8 and pos.x <= 10 and pos.y >= 8 and pos.y <= 10: continue
|
||||
|
||||
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_STICKY)
|
||||
sticky_cells[pos] = true
|
||||
# Medium shake — bubbles hit harder than a normal growth tick.
|
||||
if main_scene and main_scene.get("screen_shake_manager"):
|
||||
main_scene.screen_shake_manager.shake(0.3, 0.6)
|
||||
if SfxManager:
|
||||
SfxManager.rpc("play_rpc", "tile_scatter") if _can_rpc() else SfxManager.play("tile_scatter")
|
||||
_spawn_impact_particles(cells)
|
||||
|
||||
func _spawn_bubble_visual(center: Vector2i) -> void:
|
||||
"""A pulsing candy bubble sphere that grows over the bubble's lifetime."""
|
||||
if not gridmap:
|
||||
return
|
||||
var cs = gridmap.cell_size
|
||||
var world_pos = Vector3(center.x * cs.x + cs.x / 2.0, 0.4, center.y * cs.z + cs.z / 2.0)
|
||||
|
||||
var mesh_inst = MeshInstance3D.new()
|
||||
var sphere = SphereMesh.new()
|
||||
sphere.radius = 0.25
|
||||
sphere.height = 0.5
|
||||
mesh_inst.mesh = sphere
|
||||
mesh_inst.position = world_pos
|
||||
|
||||
var mat = StandardMaterial3D.new()
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.albedo_color = Color(1.0, 0.2, 0.6, 0.7) # candy pink
|
||||
mat.emission_enabled = true
|
||||
mat.emission = Color(1.0, 0.2, 0.6)
|
||||
mat.emission_energy_multiplier = 1.5
|
||||
|
||||
var outline_mat = ShaderMaterial.new()
|
||||
outline_mat.shader = load("res://assets/shaders/outline3d.gdshader")
|
||||
mat.next_pass = outline_mat
|
||||
|
||||
mesh_inst.material_override = mat
|
||||
|
||||
var main = get_node_or_null("/root/Main")
|
||||
if not main:
|
||||
return
|
||||
main.add_child(mesh_inst)
|
||||
|
||||
# Grow + pulse over the grow duration, then remove (explosion VFX takes over).
|
||||
var tween = create_tween()
|
||||
tween.tween_property(mesh_inst, "scale", Vector3(3.0, 3.0, 3.0), BUBBLE_GROW_DURATION) \
|
||||
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
|
||||
tween.parallel().tween_method(func(e): mat.emission_energy_multiplier = e, 1.5, 4.0, BUBBLE_GROW_DURATION)
|
||||
var remove_timer = get_tree().create_timer(BUBBLE_GROW_DURATION + 0.05)
|
||||
remove_timer.timeout.connect(func():
|
||||
if is_instance_valid(mesh_inst):
|
||||
mesh_inst.queue_free()
|
||||
)
|
||||
|
||||
func gauntlet_round_duration() -> int:
|
||||
"""Round length in seconds (from lobby settings, with a sane fallback)."""
|
||||
if LobbyManager and "gauntlet_round_duration" in LobbyManager:
|
||||
return LobbyManager.gauntlet_round_duration
|
||||
return 180
|
||||
|
||||
func _check_all_players_trapped() -> void:
|
||||
"""After growth lands, slow any player standing on a fresh sticky cell."""
|
||||
if not multiplayer.is_server(): return
|
||||
var all_players = get_tree().get_nodes_in_group("Players")
|
||||
for player in all_players:
|
||||
var pos = player.current_position if player.get("current_position") else Vector2i(-1, -1)
|
||||
if is_sticky_cell(pos):
|
||||
var pid = player.get("peer_id") if "peer_id" in player else -1
|
||||
if pid != -1 and player.get("is_invisible"):
|
||||
continue # ghost players are immune to the slow
|
||||
apply_sticky_slow(player)
|
||||
|
||||
func apply_sticky_slow(player: Node) -> void:
|
||||
"""Sticky candy slows a single player to a crawl (no global time_scale, no
|
||||
hard freeze). The player can still struggle free at reduced speed."""
|
||||
if not player or not player.has_method("apply_slow_effect"):
|
||||
return
|
||||
if _can_rpc():
|
||||
player.rpc("apply_slow_effect", STICKY_SLOW_DURATION)
|
||||
else:
|
||||
player.apply_slow_effect(STICKY_SLOW_DURATION)
|
||||
|
||||
func _trap_player(player: Node) -> void:
|
||||
"""Legacy hard-trap. No longer used for sticky entry (sticky now slows).
|
||||
Kept for potential future hazards."""
|
||||
var pid = player.get("peer_id") if "peer_id" in player else -1
|
||||
if pid == -1: return
|
||||
trapped_players[pid] = true
|
||||
print("[Gauntlet] Player %d TRAPPED at %s" % [pid, str(player.current_position)])
|
||||
emit_signal("player_trapped", pid)
|
||||
|
||||
# Apply visual feedback and notify
|
||||
if player.has_method("apply_stagger"):
|
||||
if _can_rpc():
|
||||
player.rpc("apply_stagger", 999.0) # Basically infinite until cleansed
|
||||
else:
|
||||
player.apply_stagger(999.0)
|
||||
|
||||
NotificationManager.send_message(player, "Stuck in Candy!", NotificationManager.MessageType.WARNING)
|
||||
|
||||
func clear_sticky_cell(pos: Vector2i) -> void:
|
||||
"""Remove a sticky cell (used when ghost player walks through)."""
|
||||
if _can_rpc():
|
||||
if multiplayer.is_server():
|
||||
rpc("sync_clear_sticky_cell", pos)
|
||||
else:
|
||||
sync_clear_sticky_cell(pos) # Predictive local clear
|
||||
else:
|
||||
sync_clear_sticky_cell(pos)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_clear_sticky_cell(pos: Vector2i) -> void:
|
||||
sticky_cells.erase(pos)
|
||||
mark_cleansed(pos) # temporary regrowth protection
|
||||
if gridmap:
|
||||
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1)
|
||||
|
||||
if SfxManager:
|
||||
SfxManager.play("pick_up_power_tile")
|
||||
|
||||
# Sync removal to main scene's gridmap if needed
|
||||
if main_scene and main_scene.has_method("sync_grid_item"):
|
||||
main_scene.sync_grid_item(pos.x, 2, pos.y, -1)
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func rpc_trigger_slowmo() -> void:
|
||||
"""RPC for clients to request slow-mo from server."""
|
||||
if multiplayer.is_server():
|
||||
trigger_slowmo()
|
||||
|
||||
# =============================================================================
|
||||
# Slow-Mo Effect
|
||||
# =============================================================================
|
||||
|
||||
func trigger_slowmo(duration: float = 4.0) -> void:
|
||||
"""Trigger slow-motion effect at 1/4 speed. Server-authoritative."""
|
||||
if slowmo_active:
|
||||
return
|
||||
slowmo_active = true
|
||||
slowmo_timer = duration
|
||||
slowmo_duration = duration
|
||||
Engine.time_scale = SLOWMO_SCALE
|
||||
# Show visual overlay
|
||||
if main_scene and main_scene.has_node("Camera3D200"):
|
||||
_show_slowmo_overlay()
|
||||
# Show slow-mo HUD label
|
||||
if slowmo_label:
|
||||
slowmo_label.visible = true
|
||||
if _can_rpc():
|
||||
rpc("sync_slowmo_start", duration)
|
||||
|
||||
func _end_slowmo() -> void:
|
||||
slowmo_active = false
|
||||
Engine.time_scale = 1.0
|
||||
_hide_slowmo_overlay()
|
||||
# Hide slow-mo HUD label
|
||||
if slowmo_label:
|
||||
slowmo_label.visible = false
|
||||
if _can_rpc():
|
||||
rpc("sync_slowmo_end")
|
||||
|
||||
func _show_slowmo_overlay() -> void:
|
||||
if slowmo_overlay:
|
||||
return
|
||||
slowmo_overlay = ColorRect.new()
|
||||
slowmo_overlay.color = Color(0.3, 0.5, 1.0, 0.1) # Subtle blue tint
|
||||
slowmo_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
slowmo_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var cam = main_scene.get_node_or_null("Camera3D200")
|
||||
if cam:
|
||||
# Find or create a CanvasLayer for the overlay
|
||||
var canvas = CanvasLayer.new()
|
||||
canvas.layer = 4
|
||||
main_scene.add_child(canvas)
|
||||
canvas.add_child(slowmo_overlay)
|
||||
# Fade in
|
||||
slowmo_overlay.color.a = 0.0
|
||||
var tween = create_tween()
|
||||
tween.tween_property(slowmo_overlay, "color:a", 0.1, 0.3)
|
||||
|
||||
func _hide_slowmo_overlay() -> void:
|
||||
if slowmo_overlay:
|
||||
var tween = create_tween()
|
||||
tween.tween_property(slowmo_overlay, "color:a", 0.0, 0.3)
|
||||
tween.tween_callback(slowmo_overlay.get_parent().queue_free)
|
||||
slowmo_overlay = null
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_slowmo_start(duration: float) -> void:
|
||||
slowmo_active = true
|
||||
slowmo_timer = duration
|
||||
Engine.time_scale = SLOWMO_SCALE
|
||||
_show_slowmo_overlay()
|
||||
if slowmo_label:
|
||||
slowmo_label.visible = true
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_slowmo_end() -> void:
|
||||
_end_slowmo()
|
||||
|
||||
# =============================================================================
|
||||
# HUD
|
||||
# =============================================================================
|
||||
|
||||
func _setup_hud() -> void:
|
||||
var hud_instance = _gauntlet_hud_scene.instantiate()
|
||||
hud_layer = hud_instance
|
||||
hud_layer.visible = false
|
||||
add_child(hud_layer)
|
||||
phase_label = hud_layer.get_node("BottomContainer/VBoxContainer/PhaseLabel")
|
||||
slowmo_label = hud_layer.get_node_or_null("TopContainer/SlowMoLabel")
|
||||
|
||||
func _update_hud_phase(phase_name: String) -> void:
|
||||
if phase_label:
|
||||
var icon = "🍬"
|
||||
match phase_name:
|
||||
"Middle Pressure":
|
||||
icon = "⚠️"
|
||||
phase_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2)) # Warning gold
|
||||
"Inner Survival":
|
||||
icon = "💀"
|
||||
phase_label.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3)) # Danger red
|
||||
_:
|
||||
phase_label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.8)) # Candy pink
|
||||
phase_label.text = "%s %s" % [icon, phase_name.to_upper()]
|
||||
|
||||
# Animate phase label with bounce effect
|
||||
_animate_phase_label()
|
||||
|
||||
func _animate_phase_label() -> void:
|
||||
"""Animate phase label with bounce effect."""
|
||||
if not phase_label:
|
||||
return
|
||||
|
||||
# Create tween for bounce animation
|
||||
var tween = create_tween()
|
||||
tween.set_ease(Tween.EASE_OUT)
|
||||
tween.set_trans(Tween.TRANS_ELASTIC)
|
||||
|
||||
# Scale up then back to normal
|
||||
var original_scale = phase_label.scale
|
||||
tween.tween_property(phase_label, "scale", original_scale * 1.2, 0.1)
|
||||
tween.tween_property(phase_label, "scale", original_scale, 0.2)
|
||||
|
||||
# Flash effect
|
||||
tween.tween_property(phase_label, "modulate", Color(2, 2, 2, 1), 0.1)
|
||||
tween.tween_property(phase_label, "modulate", Color.WHITE, 0.2)
|
||||
|
||||
# =============================================================================
|
||||
# GoalsCycleManager Integration
|
||||
# =============================================================================
|
||||
|
||||
func _on_goal_count_updated(peer_id: int, count: int) -> void:
|
||||
"""Called when a player completes a goal cycle. Grant ghost powerup every 2 missions."""
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
|
||||
# Track mission completions per player
|
||||
if not player_mission_completions.has(peer_id):
|
||||
player_mission_completions[peer_id] = 0
|
||||
player_mission_completions[peer_id] += 1
|
||||
|
||||
# Grant ghost powerup every 2 missions
|
||||
var completions = player_mission_completions[peer_id]
|
||||
if completions % 2 == 0:
|
||||
_grant_ghost_powerup(peer_id)
|
||||
|
||||
func _grant_ghost_powerup(peer_id: int) -> void:
|
||||
"""Grant the ghost (invisible mode) powerup to a player."""
|
||||
var all_players = get_tree().get_nodes_in_group("Players")
|
||||
for p in all_players:
|
||||
var pid = p.get("peer_id") if "peer_id" in p else p.name.to_int()
|
||||
if pid == peer_id:
|
||||
var stm = p.get_node_or_null("SpecialTilesManager")
|
||||
if stm and stm.has_method("add_powerup_from_item"):
|
||||
stm.add_powerup_from_item(14) # 14 = Ghost / INVISIBLE_MODE
|
||||
emit_signal("ghost_granted", peer_id)
|
||||
print("[Gauntlet] Player %d granted Ghost powerup (mission %d)" % [peer_id, player_mission_completions[peer_id]])
|
||||
NotificationManager.send_message(p, "Ghost Power Earned!", NotificationManager.MessageType.POWERUP)
|
||||
break
|
||||
|
||||
func _on_score_updated(peer_id: int, new_score: int) -> void:
|
||||
"""Called when a player's score is updated."""
|
||||
pass # Score sync handled by GoalsCycleManager
|
||||
|
||||
# =============================================================================
|
||||
# Utility
|
||||
# =============================================================================
|
||||
|
||||
func _can_rpc() -> bool:
|
||||
if not multiplayer.has_multiplayer_peer(): return false
|
||||
if multiplayer.multiplayer_peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED: return false
|
||||
return true
|
||||
@@ -13,11 +13,18 @@ func initialize_random_goals(size: int, min_value: int, max_value: int, null_cou
|
||||
var rng = RandomNumberGenerator.new()
|
||||
rng.randomize()
|
||||
|
||||
const SPECIAL_VALUES = {1: 7, 2: 8, 3: 9, 4: 10}
|
||||
|
||||
if LobbyManager.game_mode == "Candy Survival":
|
||||
var val = rng.randi_range(min_value, max_value)
|
||||
var chosen_tile = val if not val in SPECIAL_VALUES else SPECIAL_VALUES[val]
|
||||
for i in range(size):
|
||||
goals.append(chosen_tile)
|
||||
return goals
|
||||
|
||||
var null_val = 0
|
||||
var max_nulls = 3
|
||||
|
||||
const SPECIAL_VALUES = {1: 7, 2: 8, 3: 9, 4: 10}
|
||||
|
||||
for i in range(size):
|
||||
if null_val < max_nulls and rng.randf() < null_count:
|
||||
goals.append(-1)
|
||||
@@ -63,8 +70,6 @@ func mark_goal_complete(player_id: int):
|
||||
# Reset start time for next goal
|
||||
player_start_times[player_id] = Time.get_ticks_msec()
|
||||
|
||||
# print("Player %s completed goal in %.2fs" % [player_id, duration_sec])
|
||||
|
||||
func get_player_average_time(player_id: int) -> float:
|
||||
if not player_completion_times.has(player_id) or player_completion_times[player_id].is_empty():
|
||||
return 10.0 # Default baseline (10 seconds)
|
||||
|
||||
@@ -19,6 +19,7 @@ var is_match_active: bool = false
|
||||
# Score tracking: peer_id -> score
|
||||
var player_scores: Dictionary = {}
|
||||
var player_goal_counts: Dictionary = {} # peer_id -> count
|
||||
var player_goals: Dictionary = {} # peer_id -> goals array
|
||||
var stop_n_go_winner_id: int = -1 # Track winner for Stop n Go sorting
|
||||
|
||||
# Reference to main scene
|
||||
@@ -215,6 +216,9 @@ func sync_cycle_end():
|
||||
func on_goal_completed(player: Node, time_remaining: float):
|
||||
"""Called when a player completes their goal pattern."""
|
||||
|
||||
if LobbyManager.is_game_mode(GameMode.Mode.CANDY_SURVIVAL):
|
||||
return # Candy Survival goal generation / completion handled separately by mekton deposit
|
||||
|
||||
# CLIENT PATH: clear board immediately for visual responsiveness,
|
||||
# then let server send back the single authoritative new goals.
|
||||
# Do NOT generate goals locally — that caused rollback/blinking.
|
||||
@@ -374,7 +378,7 @@ func _process_cycle_end_for_all_players():
|
||||
var peer_id = player.name.to_int()
|
||||
var match_score = _calculate_match_score(player)
|
||||
|
||||
if match_score > 0:
|
||||
if match_score > 0 and not (LobbyManager.game_mode == "Candy Survival"):
|
||||
if not player_scores.has(peer_id):
|
||||
player_scores[peer_id] = 0
|
||||
player_scores[peer_id] += match_score
|
||||
@@ -518,3 +522,12 @@ func get_time_remaining() -> float:
|
||||
func reset_scores():
|
||||
player_scores.clear()
|
||||
_update_leaderboard()
|
||||
|
||||
func reset() -> void:
|
||||
is_match_active = false
|
||||
is_cycle_active = false
|
||||
set_process(false)
|
||||
player_scores.clear()
|
||||
player_goal_counts.clear()
|
||||
player_goals.clear()
|
||||
stop_n_go_winner_id = -1
|
||||
|
||||
@@ -26,15 +26,10 @@ signal sng_go_duration_changed(duration: int)
|
||||
signal sng_stop_duration_changed(duration: int)
|
||||
signal sng_required_goals_changed(goals: int)
|
||||
|
||||
# Tekton Doors settings signals
|
||||
signal doors_swap_time_changed(time: int)
|
||||
signal doors_refresh_time_changed(time: int)
|
||||
signal doors_required_goals_changed(goals: int)
|
||||
|
||||
# Gauntlet settings signals
|
||||
signal gauntlet_round_duration_changed(duration: int)
|
||||
signal gauntlet_growth_interval_changed(interval: float)
|
||||
signal gauntlet_cells_per_tick_changed(cells: Dictionary)
|
||||
# Candy Survival settings signals
|
||||
signal candy_survival_duration_changed(duration: int)
|
||||
signal candy_survival_growth_interval_changed(interval: float)
|
||||
signal candy_survival_cells_per_tick_changed(cells: Dictionary)
|
||||
|
||||
# Room data structure
|
||||
var current_room: Dictionary = {}
|
||||
@@ -74,15 +69,10 @@ var sng_go_duration: int = 20
|
||||
var sng_stop_duration: int = 4
|
||||
var sng_required_goals: int = 8
|
||||
|
||||
# Tekton Doors settings
|
||||
var doors_swap_time: int = 15
|
||||
var doors_refresh_time: int = 25
|
||||
var doors_required_goals: int = 8
|
||||
|
||||
# Gauntlet settings
|
||||
var gauntlet_round_duration: int = 180
|
||||
var gauntlet_growth_interval: float = 3.0 # seconds between growth ticks
|
||||
var gauntlet_cells_per_tick: Dictionary = {
|
||||
# Candy Survival settings
|
||||
var candy_survival_round_duration: int = 180
|
||||
var candy_survival_growth_interval: float = 3.0 # seconds between growth ticks
|
||||
var candy_survival_cells_per_tick: Dictionary = {
|
||||
"phase1": [4, 6],
|
||||
"phase2": [6, 8],
|
||||
"phase3": [8, 10],
|
||||
@@ -94,7 +84,7 @@ var rematch_votes: Array = [] # [player_id, ...]
|
||||
# Character and area selection
|
||||
var available_characters: Array[String] = ["Copper", "Dabro", "Gatot", "Pip", "Random"]
|
||||
var available_areas: Array[String] = []
|
||||
var available_game_modes: Array[String] = ["Freemode", "Stop n Go", "Candy Pump Survival"]
|
||||
var available_game_modes: Array[String] = ["Freemode", "Stop n Go", "Candy Survival"]
|
||||
var selected_area: String = "Freemode Arena" # Host-controlled
|
||||
var game_mode: String = "Freemode" # Host-controlled
|
||||
var local_character_index: int = 0 # Local player's character index
|
||||
@@ -149,8 +139,8 @@ func _update_available_areas(mode: String) -> void:
|
||||
available_areas = ["Freemode Arena", "Classic", "Colloseum"]
|
||||
"Stop n Go":
|
||||
available_areas = ["Stop N Go Arena"]
|
||||
"Candy Pump Survival":
|
||||
available_areas = ["Gauntlet Arena"]
|
||||
"Candy Survival":
|
||||
available_areas = ["Candy Survival Arena"]
|
||||
_:
|
||||
available_areas = ["Classic"]
|
||||
|
||||
@@ -319,6 +309,9 @@ func leave_room() -> void:
|
||||
kick_all_clients.rpc()
|
||||
|
||||
# Important: Reset all lobby settings and player lists first
|
||||
Engine.time_scale = 1.0
|
||||
if get_node_or_null("/root/GoalsCycleManager"):
|
||||
get_node("/root/GoalsCycleManager").reset()
|
||||
reset()
|
||||
_stop_lan_broadcast()
|
||||
|
||||
@@ -523,66 +516,35 @@ func sync_sng_required_goals(goals: int) -> void:
|
||||
emit_signal("sng_required_goals_changed", goals)
|
||||
|
||||
# =============================================================================
|
||||
# Tekton Doors Settings
|
||||
# Candy Survival Settings
|
||||
# =============================================================================
|
||||
|
||||
func set_doors_swap_time(time: int) -> void:
|
||||
doors_swap_time = time
|
||||
if is_host: rpc("sync_doors_swap_time", time)
|
||||
func set_candy_survival_round_duration(duration: int) -> void:
|
||||
candy_survival_round_duration = duration
|
||||
if is_host: rpc("sync_candy_survival_round_duration", duration)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_doors_swap_time(time: int) -> void:
|
||||
doors_swap_time = time
|
||||
emit_signal("doors_swap_time_changed", time)
|
||||
func sync_candy_survival_round_duration(duration: int) -> void:
|
||||
candy_survival_round_duration = duration
|
||||
emit_signal("candy_survival_duration_changed", duration)
|
||||
|
||||
func set_doors_refresh_time(time: int) -> void:
|
||||
doors_refresh_time = time
|
||||
if is_host: rpc("sync_doors_refresh_time", time)
|
||||
func set_candy_survival_growth_interval(interval: float) -> void:
|
||||
candy_survival_growth_interval = interval
|
||||
if is_host: rpc("sync_candy_survival_growth_interval", interval)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_doors_refresh_time(time: int) -> void:
|
||||
doors_refresh_time = time
|
||||
emit_signal("doors_refresh_time_changed", time)
|
||||
func sync_candy_survival_growth_interval(interval: float) -> void:
|
||||
candy_survival_growth_interval = interval
|
||||
emit_signal("candy_survival_growth_interval_changed", interval)
|
||||
|
||||
func set_doors_required_goals(goals: int) -> void:
|
||||
doors_required_goals = goals
|
||||
if is_host: rpc("sync_doors_required_goals", goals)
|
||||
func set_candy_survival_cells_per_tick(cells: Dictionary) -> void:
|
||||
candy_survival_cells_per_tick = cells
|
||||
if is_host: rpc("sync_candy_survival_cells_per_tick", cells)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_doors_required_goals(goals: int) -> void:
|
||||
doors_required_goals = goals
|
||||
emit_signal("doors_required_goals_changed", goals)
|
||||
|
||||
# =============================================================================
|
||||
# Gauntlet Settings
|
||||
# =============================================================================
|
||||
|
||||
func set_gauntlet_round_duration(duration: int) -> void:
|
||||
gauntlet_round_duration = duration
|
||||
if is_host: rpc("sync_gauntlet_round_duration", duration)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_gauntlet_round_duration(duration: int) -> void:
|
||||
gauntlet_round_duration = duration
|
||||
emit_signal("gauntlet_round_duration_changed", duration)
|
||||
|
||||
func set_gauntlet_growth_interval(interval: float) -> void:
|
||||
gauntlet_growth_interval = interval
|
||||
if is_host: rpc("sync_gauntlet_growth_interval", interval)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_gauntlet_growth_interval(interval: float) -> void:
|
||||
gauntlet_growth_interval = interval
|
||||
emit_signal("gauntlet_growth_interval_changed", interval)
|
||||
|
||||
func set_gauntlet_cells_per_tick(cells: Dictionary) -> void:
|
||||
gauntlet_cells_per_tick = cells
|
||||
if is_host: rpc("sync_gauntlet_cells_per_tick", cells)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_gauntlet_cells_per_tick(cells: Dictionary) -> void:
|
||||
gauntlet_cells_per_tick = cells
|
||||
emit_signal("gauntlet_cells_per_tick_changed", cells)
|
||||
func sync_candy_survival_cells_per_tick(cells: Dictionary) -> void:
|
||||
candy_survival_cells_per_tick = cells
|
||||
emit_signal("candy_survival_cells_per_tick_changed", cells)
|
||||
|
||||
# =============================================================================
|
||||
# Character Selection
|
||||
@@ -740,10 +702,8 @@ func set_game_mode(mode: String) -> void:
|
||||
set_area("Free Mode Area")
|
||||
elif mode == "Stop n Go" and "Stop n Go Area" in available_areas:
|
||||
set_area("Stop n Go Area")
|
||||
elif mode == "Tekton Doors" and "Tekton Doors Area" in available_areas:
|
||||
set_area("Tekton Doors Area")
|
||||
elif mode == "Candy Pump Survival" and "Gauntlet Arena" in available_areas:
|
||||
set_area("Gauntlet Arena")
|
||||
elif mode == "Candy Survival" and "Candy Survival Arena" in available_areas:
|
||||
set_area("Candy Survival Arena")
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_game_mode(mode: String) -> void:
|
||||
@@ -756,10 +716,8 @@ func sync_game_mode(mode: String) -> void:
|
||||
selected_area = "Free Mode Area"
|
||||
elif mode == "Stop n Go" and "Stop n Go Area" in available_areas:
|
||||
selected_area = "Stop n Go Area"
|
||||
elif mode == "Tekton Doors" and "Tekton Doors Area" in available_areas:
|
||||
selected_area = "Tekton Doors Area"
|
||||
elif mode == "Candy Pump Survival" and "Gauntlet Arena" in available_areas:
|
||||
selected_area = "Gauntlet Arena"
|
||||
elif mode == "Candy Survival" and "Candy Survival Arena" in available_areas:
|
||||
selected_area = "Candy Survival Arena"
|
||||
elif selected_area not in available_areas:
|
||||
selected_area = available_areas[0]
|
||||
|
||||
@@ -785,13 +743,10 @@ func start_game(force: bool = false) -> void:
|
||||
rpc("sync_sng_go_duration", sng_go_duration)
|
||||
rpc("sync_sng_stop_duration", sng_stop_duration)
|
||||
rpc("sync_sng_required_goals", sng_required_goals)
|
||||
rpc("sync_doors_swap_time", doors_swap_time)
|
||||
rpc("sync_doors_refresh_time", doors_refresh_time)
|
||||
rpc("sync_doors_required_goals", doors_required_goals)
|
||||
# Sync gauntlet settings
|
||||
rpc("sync_gauntlet_round_duration", gauntlet_round_duration)
|
||||
rpc("sync_gauntlet_growth_interval", gauntlet_growth_interval)
|
||||
rpc("sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick)
|
||||
# Sync Candy Survival settings
|
||||
rpc("sync_candy_survival_round_duration", candy_survival_round_duration)
|
||||
rpc("sync_candy_survival_growth_interval", candy_survival_growth_interval)
|
||||
rpc("sync_candy_survival_cells_per_tick", candy_survival_cells_per_tick)
|
||||
# Sync game mode
|
||||
rpc("sync_game_mode", game_mode)
|
||||
|
||||
@@ -864,12 +819,9 @@ func request_room_info(requester_id: int, requester_name: String, requester_char
|
||||
rpc_id(requester_id, "sync_sng_go_duration", sng_go_duration)
|
||||
rpc_id(requester_id, "sync_sng_stop_duration", sng_stop_duration)
|
||||
rpc_id(requester_id, "sync_sng_required_goals", sng_required_goals)
|
||||
rpc_id(requester_id, "sync_doors_swap_time", doors_swap_time)
|
||||
rpc_id(requester_id, "sync_doors_refresh_time", doors_refresh_time)
|
||||
rpc_id(requester_id, "sync_doors_required_goals", doors_required_goals)
|
||||
rpc_id(requester_id, "sync_gauntlet_round_duration", gauntlet_round_duration)
|
||||
rpc_id(requester_id, "sync_gauntlet_growth_interval", gauntlet_growth_interval)
|
||||
rpc_id(requester_id, "sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick)
|
||||
rpc_id(requester_id, "sync_candy_survival_round_duration", candy_survival_round_duration)
|
||||
rpc_id(requester_id, "sync_candy_survival_growth_interval", candy_survival_growth_interval)
|
||||
rpc_id(requester_id, "sync_candy_survival_cells_per_tick", candy_survival_cells_per_tick)
|
||||
rpc_id(requester_id, "sync_game_mode", game_mode)
|
||||
rpc_id(requester_id, "sync_area", selected_area)
|
||||
|
||||
@@ -1018,6 +970,3 @@ func reset() -> void:
|
||||
sng_go_duration = 20
|
||||
sng_stop_duration = 4
|
||||
sng_required_goals = 8
|
||||
doors_swap_time = 15
|
||||
doors_refresh_time = 25
|
||||
doors_required_goals = 8
|
||||
|
||||
@@ -47,7 +47,7 @@ func start_music():
|
||||
match game_mode:
|
||||
"Stop n Go":
|
||||
track_path = "res://assets/sounds/stop_n_go.wav"
|
||||
"Freemode", "Tekton Doors", _:
|
||||
"Freemode", _:
|
||||
track_path = "res://assets/sounds/level_bridge.wav"
|
||||
|
||||
play_track(track_path)
|
||||
|
||||
@@ -55,7 +55,7 @@ func _process(delta):
|
||||
|
||||
|
||||
# 3. Action inputs (momentary)
|
||||
if Input.is_action_just_pressed("action_grab"):
|
||||
if Input.is_action_just_pressed("action_grab") and LobbyManager.game_mode != "Candy Survival":
|
||||
player.grab_item(player.current_position)
|
||||
|
||||
if move_vec != Vector2i.ZERO:
|
||||
@@ -93,21 +93,68 @@ func handle_unhandled_input(event):
|
||||
if not SettingsManager:
|
||||
return
|
||||
|
||||
# 1. Unified check for POWER-UP Activation
|
||||
# 1. Unified check for POWER-UP / Ghost Activation
|
||||
if event.is_action_pressed("use_powerup"):
|
||||
if LobbyManager.game_mode == "Candy Survival":
|
||||
var gm = player.get_node_or_null("/root/Main/CandySurvivalManager")
|
||||
if gm and gm.active:
|
||||
var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
|
||||
if multiplayer.is_server():
|
||||
gm.try_activate_ghost(pid)
|
||||
else:
|
||||
gm.rpc_id(1, "try_activate_ghost", pid)
|
||||
else:
|
||||
player.activate_held_powerup()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
|
||||
# 2. Action Buttons (Remappable via InputMap)
|
||||
if event.is_action_pressed("action_knock_tekton"):
|
||||
# 2. Action Buttons (Remappable via InputMap / KEY_Q override for Candy Survival)
|
||||
var is_knock = false
|
||||
if LobbyManager.game_mode == "Candy Survival":
|
||||
is_knock = (event is InputEventKey and event.pressed and not event.echo and (event.keycode == KEY_Q or event.physical_keycode == KEY_Q)) or event.is_action_pressed("action_knock_tekton")
|
||||
else:
|
||||
is_knock = event.is_action_pressed("action_knock_tekton")
|
||||
|
||||
if is_knock:
|
||||
if LobbyManager.game_mode == "Candy Survival":
|
||||
var gm = player.get_node_or_null("/root/Main/CandySurvivalManager")
|
||||
if gm and gm.active:
|
||||
var att_id = player.get("peer_id") if "peer_id" in player else player.name.to_int()
|
||||
|
||||
# Toggle mode: if already in knock mode, turn it off
|
||||
if player.get("is_charged_strike"):
|
||||
player.is_charged_strike = false
|
||||
NotificationManager.send_message(player, "Knock Mode OFF", NotificationManager.MessageType.WARNING)
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
|
||||
# Check charges before toggling on
|
||||
if gm.player_knocks.get(att_id, 5) <= 0:
|
||||
NotificationManager.send_message(player, "No Knock charges left!", NotificationManager.MessageType.WARNING)
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
|
||||
# Toggle knock mode ON (reuses charged strike visuals)
|
||||
player.is_charged_strike = true
|
||||
NotificationManager.send_message(player, "🥊 Knock Mode ON — Walk into a target!", NotificationManager.MessageType.POWERUP)
|
||||
player.update_active_player_indicator()
|
||||
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
|
||||
if player.powerup_manager:
|
||||
player.powerup_manager.use_special_effect()
|
||||
if player.get("is_attack_mode") and player.has_method("enter_attack_mode"):
|
||||
player.enter_attack_mode()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
elif event.is_action_pressed("action_grab"):
|
||||
if LobbyManager.game_mode == "Candy Survival":
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
|
||||
elif event.is_action_pressed("action_grab_tekton"):
|
||||
|
||||
if not player.is_carrying_tekton:
|
||||
if player.powerup_manager and player.powerup_manager.has_method("can_use_special"):
|
||||
player.grab_tekton()
|
||||
|
||||
@@ -112,19 +112,19 @@ func simple_move_to(grid_position: Vector2i) -> bool:
|
||||
print("[MovementManager] Hard block at %s. Ghost pass denied." % grid_position)
|
||||
|
||||
var gm = null
|
||||
var main_gauntlet = player.get_tree().root.get_node_or_null("Main")
|
||||
if main_gauntlet and main_gauntlet.get("gauntlet_manager"):
|
||||
gm = main_gauntlet.gauntlet_manager
|
||||
var main_candy_survival = player.get_tree().root.get_node_or_null("Main")
|
||||
if main_candy_survival and main_candy_survival.get("candy_survival_manager"):
|
||||
gm = main_candy_survival.candy_survival_manager
|
||||
|
||||
# Check Floor 0 (Basic Walkability/Void)
|
||||
if (cell_floor == -1 or cell_floor in enhanced_gridmap.non_walkable_items) and not is_wall_passable:
|
||||
print("[Move] Failed: Floor Item %d is non-walkable" % cell_floor)
|
||||
return false
|
||||
|
||||
# Gauntlet Mode explicit wall overrides (since we visually removed the wall blocks)
|
||||
if gm and gm.is_active:
|
||||
# Candy Survival explicit wall overrides (since we visually removed the wall blocks)
|
||||
if gm and gm.active:
|
||||
if gm._is_npc_zone(grid_position):
|
||||
print("[Move] Failed: Blocked by Gauntlet NPC center at %s" % grid_position)
|
||||
print("[Move] Failed: Blocked by Candy Survival NPC center at %s" % grid_position)
|
||||
return false
|
||||
|
||||
# Check Floor 1 (Obstacles/Walls)
|
||||
@@ -156,15 +156,22 @@ func simple_move_to(grid_position: Vector2i) -> bool:
|
||||
return false # Don't move into the tile, just knock
|
||||
|
||||
# If moving into a sticky cell: block movement unless player is in ghost
|
||||
# mode (is_invisible), which lets them bypass sticky tiles in gauntlet.
|
||||
if gm and gm.is_active and gm.is_sticky_cell(grid_position):
|
||||
# mode (is_invisible), which lets them bypass sticky tiles in Candy Survival
|
||||
if gm and gm.active and gm.is_sticky_cell(grid_position):
|
||||
if player.get("is_invisible"):
|
||||
# Ghost mode: walk through sticky tile freely
|
||||
print("[Move] Ghost mode bypassed sticky cell at %s" % grid_position)
|
||||
else:
|
||||
print("[Move] Failed: Blocked by Gauntlet Sticky cell at %s" % grid_position)
|
||||
print("[Move] Failed: Blocked by Candy Survival Sticky cell at %s" % grid_position)
|
||||
return false
|
||||
|
||||
if gm and gm.active and gm.has_method("try_deliver"):
|
||||
var dist = abs(grid_position.x - 8) + abs(grid_position.y - 8)
|
||||
if dist <= 1:
|
||||
if multiplayer.is_server():
|
||||
var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
|
||||
gm.try_deliver(pid)
|
||||
|
||||
rotate_towards_target(grid_position)
|
||||
|
||||
rotate_towards_target(grid_position)
|
||||
@@ -208,15 +215,7 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
|
||||
NotificationManager.send_message(player, "Target is Immune!", NotificationManager.MessageType.WARNING)
|
||||
return false
|
||||
|
||||
# === NEW LOGIC: Only allow push if in ATTACK MODE and NOT GHOST ===
|
||||
var has_smack = false
|
||||
var main_for_smack = player.get_tree().root.get_node_or_null("Main")
|
||||
var gm_for_smack = main_for_smack.get("gauntlet_manager") if main_for_smack else null
|
||||
if gm_for_smack and gm_for_smack.is_active:
|
||||
var att_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
|
||||
has_smack = gm_for_smack.has_smack_charged(att_pid)
|
||||
|
||||
if (not player.get("is_charged_strike") and not has_smack) or player.get("is_invisible"):
|
||||
if not player.get("is_charged_strike") or player.get("is_invisible"):
|
||||
# Standard bumping effect (Visual only)
|
||||
print("[Move] Push blocked: Not charged or is Ghost (%s trying to push %s)" % [player.name, other_player.name])
|
||||
if _can_rpc():
|
||||
@@ -226,7 +225,7 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
|
||||
return false
|
||||
|
||||
# SAFE ZONE PROTECTION (Only in Stop n Go)
|
||||
if LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO):
|
||||
if Engine.get_main_loop().root.get_node_or_null("LobbyManager").is_game_mode(GameMode.Mode.STOP_N_GO):
|
||||
var safe_columns = [6, 7, 8, 14, 15, 16]
|
||||
# 1. Prevent attacker from attacking IF THEY ARE in a Safe Zone
|
||||
if player.current_position.x in safe_columns:
|
||||
@@ -243,65 +242,38 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
|
||||
|
||||
var gm = null
|
||||
var main_push_check = player.get_tree().root.get_node_or_null("Main")
|
||||
if main_push_check and main_push_check.get("gauntlet_manager"):
|
||||
gm = main_push_check.gauntlet_manager
|
||||
if main_push_check and main_push_check.get("candy_survival_manager"):
|
||||
gm = main_push_check.candy_survival_manager
|
||||
|
||||
# IF Gauntlet Mode is active, handle special Gauntlet Smacks
|
||||
if gm and gm.is_active:
|
||||
# Candy Survival: knock mechanic (candy-steal or backfire)
|
||||
if gm and gm.active and gm.has_method("try_knock"):
|
||||
var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
|
||||
var other_pid = other_player.get("peer_id") if "peer_id" in other_player else other_player.name.to_int()
|
||||
|
||||
# Check if attacker has smack
|
||||
if not gm.has_smack_charged(pid):
|
||||
# bump visuals
|
||||
if _can_rpc():
|
||||
player.rpc("sync_bump", target_pos, true)
|
||||
elif player.has_method("sync_bump"):
|
||||
player.sync_bump(target_pos, true)
|
||||
return false
|
||||
|
||||
# Smack Clash: Both charged
|
||||
if gm.has_smack_charged(other_pid):
|
||||
print("[Move] SMACK CLASH! Both %s and %s consumed." % [player.name, other_player.name])
|
||||
if multiplayer.is_server():
|
||||
gm.consume_smack(pid)
|
||||
gm.consume_smack(other_pid)
|
||||
elif _can_rpc():
|
||||
gm.rpc("consume_smack", pid) # Assuming consume_smack is @rpc
|
||||
gm.rpc("consume_smack", other_pid)
|
||||
|
||||
if _can_rpc():
|
||||
player.rpc("apply_stagger", 1.0)
|
||||
other_player.rpc("apply_stagger", 1.0)
|
||||
gm.try_knock(pid, other_pid)
|
||||
else:
|
||||
player.apply_stagger(1.0)
|
||||
other_player.apply_stagger(1.0)
|
||||
|
||||
gm.rpc_id(1, "try_knock", pid, other_pid)
|
||||
# Consume knock mode after use (toggle off)
|
||||
player.is_charged_strike = false
|
||||
return false
|
||||
|
||||
# Else standard push
|
||||
if multiplayer.is_server():
|
||||
gm.consume_smack(pid)
|
||||
elif _can_rpc():
|
||||
gm.rpc("consume_smack", pid)
|
||||
|
||||
# === SUPER PUSH (Attack Mode) ===
|
||||
print("Player %s SUPER PUSHING %s!" % [player.name, other_player.name])
|
||||
|
||||
# Visual Feedback: Attack Bump
|
||||
if _can_rpc():
|
||||
player.rpc("sync_bump", target_pos, false) # Attack bump
|
||||
SfxManager.rpc("play_rpc", "attack_mode")
|
||||
Engine.get_main_loop().root.get_node_or_null("SfxManager").rpc("play_rpc", "attack_mode")
|
||||
elif player.has_method("sync_bump"):
|
||||
player.sync_bump(target_pos, false)
|
||||
SfxManager.play("attack_mode")
|
||||
Engine.get_main_loop().root.get_node_or_null("SfxManager").play("attack_mode")
|
||||
|
||||
# 1. 3-Floor Knockback
|
||||
var push_direction = Vector2i(-1, 0) # Default back (Stop N Go)
|
||||
|
||||
var main_push = player.get_tree().root.get_node_or_null("Main")
|
||||
var gm_push = main_push.gauntlet_manager if main_push and main_push.has_node("GauntletManager") else (main_push.get("gauntlet_manager") if main_push else null)
|
||||
if gm_push and gm_push.is_active:
|
||||
var gm_push = main_push.candy_survival_manager if main_push and main_push.has_node("CandySurvivalManager") else (main_push.get("candy_survival_manager") if main_push else null)
|
||||
if gm_push and gm_push.active:
|
||||
push_direction = direction # Use the direction of the attack
|
||||
var pushed_to_pos = target_pos
|
||||
var push_path = []
|
||||
@@ -314,7 +286,7 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
|
||||
pushed_to_pos = next_back
|
||||
push_path.append(Vector2(pushed_to_pos.x, pushed_to_pos.y))
|
||||
|
||||
if gm_push and gm_push.is_active and gm_push.is_sticky_cell(pushed_to_pos):
|
||||
if gm_push and gm_push.active and gm_push.is_sticky_cell(pushed_to_pos):
|
||||
hit_sticky = true
|
||||
break # stop pushing immediately upon touching sticky zone!
|
||||
else:
|
||||
@@ -335,9 +307,9 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
|
||||
|
||||
# Check if landing spot is sticky
|
||||
var main_sticky = player.get_tree().root.get_node_or_null("Main")
|
||||
if main_sticky and main_sticky.get("gauntlet_manager"):
|
||||
var gm_sticky = main_sticky.gauntlet_manager
|
||||
if gm_sticky.is_active and gm_sticky.is_sticky_cell(pushed_to_pos):
|
||||
if main_sticky and main_sticky.get("candy_survival_manager"):
|
||||
var gm_sticky = main_sticky.candy_survival_manager
|
||||
if gm_sticky.active and gm_sticky.is_sticky_cell(pushed_to_pos):
|
||||
if other_player.get("is_invisible"):
|
||||
# Ghost mode: pushed player bypasses sticky
|
||||
print("[Move] Ghost mode bypassed push-into-sticky at %s" % pushed_to_pos)
|
||||
@@ -347,7 +319,7 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
|
||||
gm_sticky.apply_sticky_slow(other_player)
|
||||
|
||||
# 2. Apply freeze/stun effect
|
||||
var stun_duration = 1.0 if (gm_push and gm_push.is_active) else 1.5
|
||||
var stun_duration = 1.0 if (gm_push and gm_push.active) else 1.5
|
||||
if _can_rpc():
|
||||
other_player.rpc("apply_stagger", stun_duration)
|
||||
else:
|
||||
@@ -364,7 +336,7 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
|
||||
|
||||
# SCORING: 200 Points for successful attack (ONLY in Free Mode)
|
||||
if player.is_multiplayer_authority():
|
||||
var is_sng = LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO)
|
||||
var is_sng = Engine.get_main_loop().root.get_node_or_null("LobbyManager").is_game_mode(GameMode.Mode.STOP_N_GO)
|
||||
if not is_sng:
|
||||
var main_score = player.get_tree().get_root().get_node_or_null("Main")
|
||||
if main_score:
|
||||
@@ -390,9 +362,30 @@ func set_speed_multiplier(multiplier: float):
|
||||
# likely uses a fixed speed or duration on all clients.
|
||||
# Let's check how 'start_movement_along_path' is implemented in player.gd.
|
||||
|
||||
|
||||
# Handle goal pickups (shared logic)
|
||||
# ... (This happens after the move is completed, or during the move)
|
||||
pass
|
||||
|
||||
func _on_movement_finished():
|
||||
# Auto-pickup logic for Candy Survival
|
||||
if Engine.get_main_loop().root.get_node_or_null("LobbyManager").game_mode == "Candy Survival":
|
||||
if player.has_method("grab_item"):
|
||||
# Check if there is an item at current_position and if the board has space
|
||||
var current_cell = Vector3i(player.current_position.x, 1, player.current_position.y)
|
||||
var item = player.playerboard_manager.enhanced_gridmap.get_cell_item(current_cell) if player.playerboard_manager and player.playerboard_manager.enhanced_gridmap else -1
|
||||
|
||||
if item != -1:
|
||||
# Normalize item to match goals logic
|
||||
var normalized_item = player.playerboard_manager._normalize_tile(item)
|
||||
if normalized_item in player.goals:
|
||||
var empty_slot = -1
|
||||
for i in range(player.playerboard.size()):
|
||||
if player.playerboard[i] == -1 and not (i in player.playerboard_manager.HIDDEN_SLOTS):
|
||||
empty_slot = i
|
||||
break
|
||||
if player.playerboard_manager and empty_slot != -1:
|
||||
player.grab_item(player.current_position)
|
||||
|
||||
if not movement_queue.is_empty():
|
||||
var next_target = movement_queue.pop_front()
|
||||
# Use a small delay or call_deferred to avoid recursion issues,
|
||||
@@ -403,6 +396,7 @@ func _on_movement_finished():
|
||||
current_move_direction = Vector2i.ZERO
|
||||
emit_signal("movement_finished")
|
||||
else:
|
||||
is_moving = false
|
||||
current_move_direction = Vector2i.ZERO
|
||||
emit_signal("movement_finished")
|
||||
|
||||
|
||||
@@ -230,10 +230,6 @@ func _check_and_refill_grid_if_needed(server_gridmap: Node):
|
||||
break
|
||||
|
||||
if not has_items:
|
||||
if LobbyManager.is_game_mode(GameMode.Mode.TEKTON_DOORS):
|
||||
# Tekton Doors handles its own wall-aware refill in PortalModeManager
|
||||
return
|
||||
|
||||
print("[PlayerboardManager] Floor 1 empty! Respawning tiles with Scarcity...")
|
||||
# Call randomize_floor on floor 1 using ScarcityController
|
||||
# ScarcityController is a global class, so we can pass its static function as a Callable
|
||||
@@ -372,17 +368,6 @@ func auto_put_item() -> bool:
|
||||
var pos = neighbor.position
|
||||
var cell_3d = Vector3i(pos.x, 1, pos.y)
|
||||
if enhanced_gridmap.get_cell_item(cell_3d) == -1 and not player.is_position_occupied(pos):
|
||||
# TEKTON DOORS: Avoid portal doors
|
||||
var is_on_portal = false
|
||||
if LobbyManager.is_game_mode(GameMode.Mode.TEKTON_DOORS):
|
||||
var doors = get_tree().get_nodes_in_group("PortalDoors")
|
||||
for door in doors:
|
||||
var door_grid = enhanced_gridmap.local_to_map(enhanced_gridmap.to_local(door.global_position))
|
||||
if Vector2i(door_grid.x, door_grid.z) == pos:
|
||||
is_on_portal = true
|
||||
break
|
||||
|
||||
if not is_on_portal:
|
||||
valid_put_positions.append(pos)
|
||||
|
||||
if valid_put_positions.is_empty():
|
||||
@@ -541,6 +526,8 @@ func find_best_goal_slot_for_item(item: int) -> int:
|
||||
# Goal slots are row 2-3, col 1-3 (indices: 11,12,13, 16,17,18) matching User request
|
||||
# Rows 1 (6,7,8) are now storage slots
|
||||
var reserved_goal_slots = [11, 12, 13, 16, 17, 18]
|
||||
if LobbyManager.game_mode == "Candy Survival":
|
||||
reserved_goal_slots = []
|
||||
|
||||
for i in range(player.playerboard.size()):
|
||||
if player.playerboard[i] == -1:
|
||||
@@ -748,11 +735,63 @@ func _check_goal_completion():
|
||||
if not player.race_manager:
|
||||
return
|
||||
|
||||
# Check if the pattern matches
|
||||
if player.race_manager.check_pattern_match():
|
||||
print("[PlayerboardManager] Goal completed for player %s!" % player.name)
|
||||
# Note: We still trigger blueprint completion (giving Candies to the player stack)
|
||||
var is_match = false
|
||||
var is_off_color = false
|
||||
var primary_color = -1
|
||||
var original_is_match = false
|
||||
|
||||
if LobbyManager.game_mode == "Candy Survival":
|
||||
# Custom Candy Survival blueprint check (can be off-color)
|
||||
var main = player.get_tree().get_root().get_node_or_null("Main")
|
||||
var gm = main.get_node_or_null("CandySurvivalManager") if main else null
|
||||
|
||||
# Find any 3x3 block that is completely filled with tiles (no -1)
|
||||
for start_row in range(3):
|
||||
for start_col in range(3):
|
||||
var filled = true
|
||||
var dominant_colors = {}
|
||||
|
||||
for i in range(3):
|
||||
for j in range(3):
|
||||
var val = player.playerboard[(start_row + i) * 5 + (start_col + j)]
|
||||
if val == -1:
|
||||
filled = false
|
||||
else:
|
||||
dominant_colors[val] = dominant_colors.get(val, 0) + 1
|
||||
|
||||
if filled:
|
||||
is_match = true
|
||||
# Find majority color
|
||||
var max_count = 0
|
||||
for c in dominant_colors:
|
||||
if dominant_colors[c] > max_count:
|
||||
max_count = dominant_colors[c]
|
||||
primary_color = c
|
||||
|
||||
is_off_color = (max_count < 9)
|
||||
|
||||
if is_match:
|
||||
break
|
||||
if is_match:
|
||||
original_is_match = true
|
||||
break
|
||||
|
||||
if is_match and gm and gm.has_method("on_blueprint_completed"):
|
||||
# In Candy Survival, the visual goal check does not trigger completion.
|
||||
# We only give candies into the stack. Goals are cleared & scored
|
||||
# when delivered to the Mekton.
|
||||
gm.on_blueprint_completed(player.name.to_int(), primary_color, is_off_color)
|
||||
is_match = false # Force false so we don't trigger the regular free-mode goal completion
|
||||
else:
|
||||
is_match = player.race_manager.check_pattern_match()
|
||||
|
||||
if is_match or (original_is_match and LobbyManager.game_mode == "Candy Survival"):
|
||||
if not (LobbyManager.game_mode == "Candy Survival"):
|
||||
print("[PlayerboardManager] Goal completed for player %s!" % player.name)
|
||||
else:
|
||||
print("[PlayerboardManager] Blueprint completed for player %s! Generating candy." % player.name)
|
||||
|
||||
# Level up boost difficulty on goal completion
|
||||
var powerup_manager = player.get_node_or_null("PowerUpManager")
|
||||
if powerup_manager:
|
||||
powerup_manager.add_goal_completion_reward()
|
||||
@@ -761,7 +800,8 @@ func _check_goal_completion():
|
||||
if player.is_multiplayer_authority() and player.has_method("trigger_screen_shake"):
|
||||
player.trigger_screen_shake("goal")
|
||||
|
||||
# Notify GoalsCycleManager for scoring
|
||||
# Notify GoalsCycleManager for scoring (only if not Candy Survival)
|
||||
if not (LobbyManager.game_mode == "Candy Survival"):
|
||||
var main = player.get_tree().get_root().get_node_or_null("Main")
|
||||
if main:
|
||||
var goals_cycle_manager = main.get_node_or_null("GoalsCycleManager")
|
||||
|
||||
@@ -1,585 +0,0 @@
|
||||
extends Node
|
||||
|
||||
# PortalModeManager - Handles "Tekton Doors" mode logic
|
||||
# Manages room partitioning, portal connections, and mode-specific timers.
|
||||
|
||||
var main: Node
|
||||
var gridmap: Node
|
||||
|
||||
# Room layout config
|
||||
const ROOM_COUNT = 4
|
||||
const GRID_SIZE = 14
|
||||
const ROOM_DIM = 7
|
||||
|
||||
# State
|
||||
var connections = {} # room_id -> {door_id -> {target_room, target_door}}
|
||||
var doors = [] # List of PortalDoor nodes
|
||||
var swap_timer: Timer
|
||||
var tile_refresh_timer: Timer
|
||||
var finish_spawned: bool = false
|
||||
var arena_setup_done: bool = false
|
||||
var player_portal_cooldowns: Dictionary = {}
|
||||
|
||||
var hud_layer: CanvasLayer
|
||||
var mission_label: Label
|
||||
var _has_notified_mission_complete: bool = false
|
||||
|
||||
func initialize(p_main: Node, p_gridmap: Node):
|
||||
main = p_main
|
||||
gridmap = p_gridmap
|
||||
|
||||
if gridmap:
|
||||
# Ensure walls (4) are strictly treated as non-walkable for all internal checks
|
||||
# Use explicit type to avoid Array vs Array[int] mismatch error
|
||||
var non_walkable: Array[int] = [4]
|
||||
gridmap.non_walkable_items = non_walkable
|
||||
|
||||
# Create Stands container if it doesn't exist
|
||||
print("[PortalModeManager] Initialized")
|
||||
|
||||
# Connection Swap Timer (15s)
|
||||
swap_timer = Timer.new()
|
||||
swap_timer.name = "PortalSwapTimer"
|
||||
# Initial wait time; gets reset when started based on game mode settings
|
||||
swap_timer.wait_time = 15.0
|
||||
swap_timer.timeout.connect(_on_swap_timer_timeout)
|
||||
add_child(swap_timer)
|
||||
|
||||
# Tile Refresh Timer (25s)
|
||||
tile_refresh_timer = Timer.new()
|
||||
tile_refresh_timer.name = "TileRefreshTimer"
|
||||
# Initial wait time; gets reset when started based on game mode settings
|
||||
tile_refresh_timer.wait_time = 25.0
|
||||
tile_refresh_timer.timeout.connect(_on_tile_refresh_timer_timeout)
|
||||
add_child(tile_refresh_timer)
|
||||
|
||||
# Connect to mission tracking
|
||||
var gcm = main.get_node_or_null("GoalsCycleManager")
|
||||
if gcm:
|
||||
gcm.global_timer_updated.connect(_on_global_timer_updated)
|
||||
gcm.goal_count_updated.connect(_on_goal_count_updated)
|
||||
|
||||
_setup_hud()
|
||||
|
||||
func _on_global_timer_updated(time_remaining: float):
|
||||
if not multiplayer.is_server(): return
|
||||
|
||||
# Last 30 seconds: Reveal Finish Room
|
||||
if time_remaining <= 30.0 and not finish_spawned:
|
||||
_spawn_finish_room()
|
||||
|
||||
func start_game_mode():
|
||||
if not multiplayer.is_server(): return
|
||||
|
||||
if arena_setup_done and not doors.is_empty():
|
||||
print("[PortalModeManager] Arena already setup, starting timers and refresh only.")
|
||||
else:
|
||||
print("[PortalModeManager] Starting Portal Game Mode with full setup...")
|
||||
setup_arena_locally()
|
||||
_randomize_connections()
|
||||
|
||||
# Configure dynamic timings from LobbyManager before starting
|
||||
swap_timer.wait_time = float(LobbyManager.doors_swap_time)
|
||||
tile_refresh_timer.wait_time = float(LobbyManager.doors_refresh_time)
|
||||
|
||||
# Start Timers
|
||||
if swap_timer.is_stopped():
|
||||
swap_timer.start()
|
||||
if tile_refresh_timer.is_stopped():
|
||||
tile_refresh_timer.start()
|
||||
|
||||
# Initial Tile Spawn
|
||||
_refresh_tiles()
|
||||
|
||||
# Show HUD
|
||||
_activate_hud()
|
||||
|
||||
func _activate_hud():
|
||||
if hud_layer:
|
||||
hud_layer.visible = true
|
||||
_update_hud_visuals()
|
||||
|
||||
func activate_client_side():
|
||||
"""Called on clients to show HUD and prepare local state."""
|
||||
print("[PortalModeManager] Activating client-side HUD")
|
||||
_activate_hud()
|
||||
# Initial update to catch any missed goal counts
|
||||
_update_hud_visuals()
|
||||
|
||||
func setup_arena_locally():
|
||||
"""Sets up GridMap size and walls. Called on host and clients."""
|
||||
if arena_setup_done:
|
||||
print("[PortalModeManager] Arena already setup locally, skipping.")
|
||||
return
|
||||
|
||||
print("[PortalModeManager] Setting up arena locally...")
|
||||
_setup_arena_size()
|
||||
_setup_room_partitions()
|
||||
_spawn_portal_doors()
|
||||
|
||||
# PRE-FILL TILES: Ensure all floor tiles have items before the countdown starts
|
||||
if multiplayer.is_server():
|
||||
_refresh_tiles()
|
||||
|
||||
arena_setup_done = true
|
||||
|
||||
func _setup_arena_size():
|
||||
if not gridmap: return
|
||||
gridmap.columns = GRID_SIZE
|
||||
gridmap.rows = GRID_SIZE
|
||||
gridmap.clear()
|
||||
# Explicitly clear Floor 1 to prevent legacy tiles from previous rounds
|
||||
if gridmap.has_method("clear_grid"):
|
||||
gridmap.clear_grid(1)
|
||||
|
||||
# Fill Floor 0 with standard floor (Item ID 0)
|
||||
for x in range(GRID_SIZE):
|
||||
for z in range(GRID_SIZE):
|
||||
gridmap.set_cell_item(Vector3i(x, 0, z), 0)
|
||||
|
||||
func get_spawn_points() -> Array[Vector2i]:
|
||||
# One point per quadrant
|
||||
return [
|
||||
Vector2i(3, 3), # Room 0
|
||||
Vector2i(10, 3), # Room 1
|
||||
Vector2i(3, 10), # Room 2
|
||||
Vector2i(10, 10) # Room 3
|
||||
]
|
||||
|
||||
func _setup_hud():
|
||||
hud_layer = CanvasLayer.new()
|
||||
hud_layer.layer = 5
|
||||
hud_layer.visible = false
|
||||
add_child(hud_layer)
|
||||
|
||||
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)
|
||||
|
||||
mission_label = Label.new()
|
||||
mission_label.text = "GOALS (0/8)"
|
||||
mission_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
|
||||
var custom_font = load("res://assets/fonts/Nougat-ExtraBlack.ttf")
|
||||
if custom_font: mission_label.add_theme_font_override("font", custom_font)
|
||||
|
||||
mission_label.add_theme_font_size_override("font_size", 28)
|
||||
mission_label.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||
mission_label.add_theme_constant_override("outline_size", 8)
|
||||
bottom_container.add_child(mission_label)
|
||||
|
||||
# Initial update
|
||||
_update_hud_visuals()
|
||||
|
||||
func _update_hud_visuals():
|
||||
if not mission_label: return
|
||||
|
||||
var my_id = multiplayer.get_unique_id()
|
||||
var gcm = main.get_node_or_null("GoalsCycleManager")
|
||||
var completed_count = gcm.player_goal_counts.get(my_id, 0) if gcm else 0
|
||||
|
||||
mission_label.text = "GOALS (%d/%d)" % [completed_count, LobbyManager.doors_required_goals]
|
||||
|
||||
if completed_count >= LobbyManager.doors_required_goals:
|
||||
mission_label.text = "ALL GOALS COMPLETE!\nFIND THE FINISH ROOM!"
|
||||
mission_label.add_theme_color_override("font_color", Color.GOLD)
|
||||
|
||||
if not _has_notified_mission_complete:
|
||||
_has_notified_mission_complete = true
|
||||
var player_node = main.get_node_or_null(str(my_id))
|
||||
if player_node:
|
||||
NotificationManager.send_message(player_node, "ALL GOALS COMPLETE!", NotificationManager.MessageType.GOAL)
|
||||
else:
|
||||
mission_label.add_theme_color_override("font_color", Color.WHITE)
|
||||
_has_notified_mission_complete = false
|
||||
|
||||
func is_mission_complete(peer_id: int) -> bool:
|
||||
var gcm = main.get_node_or_null("GoalsCycleManager")
|
||||
if not gcm: return false
|
||||
return gcm.player_goal_counts.get(peer_id, 0) >= LobbyManager.doors_required_goals
|
||||
|
||||
func check_win_condition(player_id: int, pos: Vector2i) -> bool:
|
||||
# 1. Check if on finish tile
|
||||
var tile = gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y))
|
||||
if tile != 3: return false
|
||||
|
||||
# 2. Check missions
|
||||
return is_mission_complete(player_id)
|
||||
|
||||
func _setup_room_partitions():
|
||||
for i in range(GRID_SIZE):
|
||||
# Vertical wall (middle columns)
|
||||
gridmap.set_cell_item(Vector3i(6, 0, i), 4) # Wall item
|
||||
gridmap.set_cell_item(Vector3i(7, 0, i), 4)
|
||||
|
||||
# Horizontal wall (middle rows)
|
||||
gridmap.set_cell_item(Vector3i(i, 0, 6), 4)
|
||||
gridmap.set_cell_item(Vector3i(i, 0, 7), 4)
|
||||
|
||||
var _pending_sync_data = null
|
||||
|
||||
func _spawn_portal_doors():
|
||||
# 1. Use synced configs if they exist (passed via main.rpc("sync_portal_configs"))
|
||||
var door_configs = get_meta("door_configs") if has_meta("door_configs") else []
|
||||
|
||||
# 2. If no synced configs (e.g. Server start), generate base + extras
|
||||
if door_configs.is_empty():
|
||||
if not multiplayer.is_server():
|
||||
print("[PortalModeManager] Client waiting for portal configs sync...")
|
||||
return
|
||||
|
||||
door_configs = [
|
||||
# BASE DOORS (2 per room)
|
||||
{"room": 0, "pos": Vector2i(6, 2), "rot": PI / 2, "offset": Vector2i(-1, 0)}, # East
|
||||
{"room": 0, "pos": Vector2i(2, 6), "rot": 0, "offset": Vector2i(0, -1)}, # South
|
||||
{"room": 1, "pos": Vector2i(7, 2), "rot": PI / 2, "offset": Vector2i(1, 0)}, # West
|
||||
{"room": 1, "pos": Vector2i(11, 6), "rot": 0, "offset": Vector2i(0, -1)}, # South
|
||||
{"room": 2, "pos": Vector2i(2, 7), "rot": 0, "offset": Vector2i(0, 1)}, # North
|
||||
{"room": 2, "pos": Vector2i(6, 11), "rot": PI / 2, "offset": Vector2i(-1, 0)}, # East
|
||||
{"room": 3, "pos": Vector2i(11, 7), "rot": 0, "offset": Vector2i(0, 1)}, # North
|
||||
{"room": 3, "pos": Vector2i(7, 11), "rot": PI / 2, "offset": Vector2i(1, 0)} # West
|
||||
]
|
||||
|
||||
# Server adds extras
|
||||
var extra_options = [
|
||||
{"room": 0, "pos": Vector2i(6, 5), "rot": PI / 2, "offset": Vector2i(-1, 0)}, # East (Gap from 6,2)
|
||||
{"room": 1, "pos": Vector2i(7, 5), "rot": PI / 2, "offset": Vector2i(1, 0)}, # West (Gap from 7,2)
|
||||
{"room": 2, "pos": Vector2i(6, 8), "rot": PI / 2, "offset": Vector2i(-1, 0)}, # East (Gap from 6,11)
|
||||
{"room": 3, "pos": Vector2i(7, 8), "rot": PI / 2, "offset": Vector2i(1, 0)} # West (Gap from 7,11)
|
||||
]
|
||||
extra_options.shuffle()
|
||||
door_configs.append(extra_options[0])
|
||||
door_configs.append(extra_options[1])
|
||||
|
||||
# Broadcast to clients
|
||||
main.rpc("sync_portal_configs", door_configs)
|
||||
|
||||
# 3. Spawn the doors
|
||||
if not doors.is_empty(): return # Guard against double spawn
|
||||
|
||||
print("[PortalModeManager] Spawning %d doors. Peer: %d" % [door_configs.size(), multiplayer.get_unique_id()])
|
||||
|
||||
var portal_scene = load("res://scenes/portal_door.tscn")
|
||||
var stands_container = main.get_node_or_null("Stands")
|
||||
if not stands_container:
|
||||
stands_container = Node3D.new()
|
||||
stands_container.name = "Stands"
|
||||
main.add_child(stands_container)
|
||||
|
||||
for i in range(door_configs.size()):
|
||||
var cfg = door_configs[i]
|
||||
if not portal_scene:
|
||||
print("[PortalModeManager] Error: Failed to load portal_door.tscn")
|
||||
break
|
||||
|
||||
var door = portal_scene.instantiate()
|
||||
door.name = "Portal_%d" % i
|
||||
door.room_id = cfg["room"]
|
||||
door.door_id = i
|
||||
door.set_meta("spawn_offset", cfg["offset"]) # Store offset for teleport
|
||||
|
||||
# Position
|
||||
var world_pos = gridmap.map_to_local(Vector3i(cfg["pos"].x, 0, cfg["pos"].y))
|
||||
door.transform.origin = world_pos
|
||||
door.rotation.y = cfg["rot"]
|
||||
|
||||
stands_container.add_child(door, true)
|
||||
doors.append(door)
|
||||
|
||||
# Server-only interaction logic
|
||||
if multiplayer.is_server():
|
||||
door.player_entered_portal.connect(handle_portal_interaction)
|
||||
|
||||
gridmap.set_cell_item(Vector3i(cfg["pos"].x, 0, cfg["pos"].y), 0) # Normal floor
|
||||
|
||||
print("[PortalModeManager] Finished spawning %d doors" % doors.size())
|
||||
|
||||
# Apply pending sync if it arrived early
|
||||
if _pending_sync_data:
|
||||
print("[PortalModeManager] Applying pending sync data...")
|
||||
sync_portal_data(_pending_sync_data)
|
||||
_pending_sync_data = null
|
||||
|
||||
const PORTAL_COLORS = [
|
||||
Color(0, 1, 1), # Cyan
|
||||
Color(1, 0, 1), # Magenta
|
||||
Color(1, 0, 0), # Red
|
||||
Color(0, 1, 0), # Green
|
||||
Color(1, 0.5, 0) # Orange
|
||||
]
|
||||
|
||||
func _randomize_connections():
|
||||
if not multiplayer.is_server(): return
|
||||
|
||||
print("[PortalModeManager] Swapping portal connections...")
|
||||
connections.clear()
|
||||
|
||||
var door_indices = []
|
||||
for i in range(doors.size()):
|
||||
door_indices.append(i)
|
||||
|
||||
# Shuffle and Validate: ensure no pairs are in the same room
|
||||
var valid_pairing = false
|
||||
var attempts = 0
|
||||
while not valid_pairing and attempts < 100:
|
||||
attempts += 1
|
||||
door_indices.shuffle()
|
||||
valid_pairing = true
|
||||
for i in range(0, door_indices.size(), 2):
|
||||
var a = door_indices[i]
|
||||
var b = door_indices[i + 1]
|
||||
if doors[a].room_id == doors[b].room_id:
|
||||
valid_pairing = false
|
||||
break
|
||||
|
||||
# Prepare sync data
|
||||
var sync_data = [] # [[door_a_id, door_b_id, color], ...]
|
||||
|
||||
# Pair them up and assign colors
|
||||
for i in range(0, door_indices.size(), 2):
|
||||
var a = door_indices[i]
|
||||
var b = door_indices[i + 1]
|
||||
connections[a] = b
|
||||
connections[b] = a
|
||||
|
||||
var color = PORTAL_COLORS[int(i / 2.0) % PORTAL_COLORS.size()]
|
||||
sync_data.append([a, b, color])
|
||||
|
||||
doors[a].target_door_id = b
|
||||
doors[a].portal_color = color
|
||||
|
||||
doors[b].target_door_id = a
|
||||
doors[b].portal_color = color
|
||||
|
||||
# Sync to all clients
|
||||
rpc("sync_portal_data", sync_data)
|
||||
main.rpc("display_message", "PORTALS SWITCHED!")
|
||||
|
||||
func sync_to_client(peer_id: int):
|
||||
"""Syncs current portal connections to a specific client."""
|
||||
var sync_data = []
|
||||
# connections is id -> id
|
||||
# We need to rebuild the pair-based data for the RPC
|
||||
var handled = []
|
||||
for a_id in connections:
|
||||
if a_id in handled: continue
|
||||
var b_id = connections[a_id]
|
||||
var color = doors[a_id].portal_color
|
||||
sync_data.append([a_id, b_id, color])
|
||||
handled.append(a_id)
|
||||
handled.append(b_id)
|
||||
|
||||
rpc_id(peer_id, "sync_portal_data", sync_data)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_portal_data(data: Array):
|
||||
"""Syncs portal connections and colors to all clients."""
|
||||
print("[PortalModeManager] Received portal sync data. Peed ID: ", multiplayer.get_unique_id())
|
||||
|
||||
# If doors array is empty on client, try to repopulate from Stands group
|
||||
if doors.is_empty():
|
||||
var stands = get_tree().get_nodes_in_group("PortalDoors")
|
||||
# Sort by name to ensure consistent indexing
|
||||
stands.sort_custom(func(a, b): return a.name < b.name)
|
||||
doors = stands
|
||||
|
||||
# If still empty, defer sync until doors are spawned locally
|
||||
if doors.is_empty():
|
||||
print("[PortalModeManager] Doors not yet ready, deferring sync data...")
|
||||
_pending_sync_data = data
|
||||
return
|
||||
|
||||
connections.clear()
|
||||
for pair in data:
|
||||
var a_id = pair[0]
|
||||
var b_id = pair[1]
|
||||
var color = pair[2]
|
||||
|
||||
connections[a_id] = b_id
|
||||
connections[b_id] = a_id
|
||||
|
||||
if a_id < doors.size() and b_id < doors.size():
|
||||
if is_instance_valid(doors[a_id]):
|
||||
doors[a_id].target_door_id = b_id
|
||||
doors[a_id].portal_color = color
|
||||
if is_instance_valid(doors[b_id]):
|
||||
doors[b_id].target_door_id = a_id
|
||||
doors[b_id].portal_color = color
|
||||
else:
|
||||
print("[PortalModeManager] Warning: Door index %d or %d out of range during sync" % [a_id, b_id])
|
||||
|
||||
|
||||
func _on_global_goal_count_updated(_peer_id: int, _count: int):
|
||||
# Mission requirement removed in favor of time-based finish reveal
|
||||
pass
|
||||
|
||||
func _on_goal_count_updated(peer_id: int, _count: int):
|
||||
# Update HUD if relevant (always check if it's the local player whose count changed)
|
||||
if peer_id == multiplayer.get_unique_id():
|
||||
_update_hud_visuals()
|
||||
|
||||
func _spawn_finish_room():
|
||||
print("[PortalModeManager] Time is running out! Revealing Finish Room...")
|
||||
finish_spawned = true
|
||||
|
||||
# Choose a random room quadrant index (0 to 3)
|
||||
var room_idx = randi() % 4
|
||||
|
||||
# Determine center for the selected room quadrant (7x7 rooms)
|
||||
var x_center = 3 if (room_idx == 0 or room_idx == 2) else 10
|
||||
var z_center = 3 if (room_idx == 0 or room_idx == 1) else 10
|
||||
|
||||
# Determine 3x3 bounds around the center
|
||||
var x_start = x_center - 1
|
||||
var x_end = x_center + 2 # exclusive for range()
|
||||
var z_start = z_center - 1
|
||||
var z_end = z_center + 2 # exclusive for range()
|
||||
|
||||
print("[PortalModeManager] Converting 3x3 area in Room %d (X:%d-%d, Z:%d-%d) to Finish Tiles" % [room_idx, x_start, x_end-1, z_start, z_end-1])
|
||||
|
||||
# Iterate through the 3x3 area
|
||||
for x in range(x_start, x_end):
|
||||
for z in range(z_start, z_end):
|
||||
# Only convert walkable floor tiles (Item ID 0) on Floor 0
|
||||
var floor_0_item = gridmap.get_cell_item(Vector3i(x, 0, z))
|
||||
if floor_0_item == 0:
|
||||
# Change Floor 0 tile to Finish Tile (ID 3)
|
||||
main.rpc("sync_grid_item", x, 0, z, 3)
|
||||
|
||||
# Clear any item on Floor 1 above this tile
|
||||
main.rpc("sync_grid_item", x, 1, z, -1)
|
||||
|
||||
# Visual update for server
|
||||
if gridmap.has_method("update_grid_data"):
|
||||
gridmap.update_grid_data()
|
||||
|
||||
main.rpc("display_message", "[ALARM] THE FINISH ROOM HAS APPEARED!")
|
||||
main.rpc("broadcast_message", "SYSTEM", "A 3x3 Finish Zone has appeared in Room %d!" % room_idx, 4) # 4 = MessageType.WARNING
|
||||
|
||||
func _get_room_index(pos: Vector2i) -> int:
|
||||
if pos.x < 7 and pos.y < 7: return 0
|
||||
if pos.x >= 7 and pos.y < 7: return 1
|
||||
if pos.x < 7 and pos.y >= 7: return 2
|
||||
return 3
|
||||
|
||||
func _on_swap_timer_timeout():
|
||||
_randomize_connections()
|
||||
|
||||
func _on_tile_refresh_timer_timeout():
|
||||
_refresh_tiles()
|
||||
main.rpc("display_message", "TILES REPLENISHED!")
|
||||
|
||||
func _refresh_tiles():
|
||||
# GridMap Floor 0 has the walls (ID 4) and floors (ID 0)
|
||||
# GridMap Floor 1 should have the items (Heart, Star, etc)
|
||||
# Cache door positions to avoid spawning under them
|
||||
var door_positions = []
|
||||
for door in doors:
|
||||
if is_instance_valid(door):
|
||||
var local_pos = gridmap.local_to_map(gridmap.to_local(door.global_position))
|
||||
door_positions.append(Vector2i(local_pos.x, local_pos.z))
|
||||
|
||||
for x in range(GRID_SIZE):
|
||||
for z in range(GRID_SIZE):
|
||||
# 1. Check if Floor 0 is a wall or void
|
||||
var floor_0_item = gridmap.get_cell_item(Vector3i(x, 0, z))
|
||||
if floor_0_item in [4, -1]:
|
||||
continue
|
||||
|
||||
# 1.5. Prevent spawning directly under portal doors
|
||||
if door_positions.has(Vector2i(x, z)):
|
||||
continue
|
||||
|
||||
# 2. Check if Floor 1 is already occupied
|
||||
if gridmap.get_cell_item(Vector3i(x, 1, z)) != -1:
|
||||
continue
|
||||
|
||||
# 3. Spawn a tile (60% chance per valid floor cell)
|
||||
if randf() < 0.6:
|
||||
var weights = ScarcityModel.get_tile_weights()
|
||||
var tile_id = _pick_weighted_tile(weights)
|
||||
# Update GridMap Floor 1 via RPC for sync
|
||||
main.rpc("sync_grid_item", x, 1, z, tile_id)
|
||||
|
||||
func _pick_weighted_tile(weights: Dictionary) -> int:
|
||||
var total_weight = 0
|
||||
for w in weights.values(): total_weight += w
|
||||
|
||||
var r = randi() % total_weight
|
||||
var cumulative = 0
|
||||
for tile in weights:
|
||||
cumulative += weights[tile]
|
||||
if r < cumulative:
|
||||
return tile
|
||||
return 7 # Default Heart
|
||||
|
||||
func handle_portal_interaction(player, door):
|
||||
if not multiplayer.is_server(): return
|
||||
|
||||
var current_time = Time.get_ticks_msec()
|
||||
if player_portal_cooldowns.has(player.name):
|
||||
# Reduce cooldown to 200ms (more responsive than 1s, but enough to avoid jitter)
|
||||
if current_time - player_portal_cooldowns[player.name] < 200:
|
||||
return
|
||||
player_portal_cooldowns[player.name] = current_time
|
||||
|
||||
var source_id = door.door_id
|
||||
if not connections.has(source_id): return
|
||||
|
||||
var target_id = connections[source_id]
|
||||
var target_door = doors[target_id]
|
||||
|
||||
# Use stored offset to avoid infinite loop (spawn inside the target room)
|
||||
var offset = target_door.get_meta("spawn_offset") if target_door.has_meta("spawn_offset") else Vector2i(0, 0)
|
||||
|
||||
var target_world = target_door.global_position
|
||||
var target_grid_3d = gridmap.local_to_map(target_world)
|
||||
var target_grid = Vector2i(target_grid_3d.x, target_grid_3d.z) + offset
|
||||
|
||||
# Check for overlaps at the target_grid
|
||||
var final_target = target_grid
|
||||
var all_players = get_tree().get_nodes_in_group("Players")
|
||||
var is_occupied = true
|
||||
var search_radius = 0
|
||||
var max_search_radius = 2
|
||||
|
||||
while is_occupied and search_radius <= max_search_radius:
|
||||
is_occupied = false
|
||||
for p in all_players:
|
||||
if p != player and p.current_position == final_target:
|
||||
is_occupied = true
|
||||
break
|
||||
|
||||
if is_occupied:
|
||||
# Try to find an adjacent cell
|
||||
search_radius += 1
|
||||
var found_empty = false
|
||||
# Check immediate neighbors first
|
||||
var offsets = [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1),
|
||||
Vector2i(1, 1), Vector2i(-1, 1), Vector2i(1, -1), Vector2i(-1, -1)]
|
||||
for offset_vec in offsets:
|
||||
var test_pos = final_target + offset_vec
|
||||
# Check if it's strictly a floor tile (ID 0) on Floor 0, not a wall
|
||||
if gridmap.get_cell_item(Vector3i(test_pos.x, 0, test_pos.y)) == 0:
|
||||
# Verify no player is on this test_pos
|
||||
var test_occupied = false
|
||||
for p in all_players:
|
||||
if p != player and p.current_position == test_pos:
|
||||
test_occupied = true
|
||||
break
|
||||
if not test_occupied:
|
||||
final_target = test_pos
|
||||
found_empty = true
|
||||
break
|
||||
|
||||
if found_empty:
|
||||
is_occupied = false
|
||||
|
||||
print("[Portal] Teleporting %s to Room %d, Pos %s (via Door %d)" % [player.name, target_door.room_id, final_target, target_id])
|
||||
|
||||
# Snap player
|
||||
if player.has_method("set_spawn_position"):
|
||||
player.rpc("set_spawn_position", final_target)
|
||||
@@ -153,6 +153,17 @@ func add_powerup_from_item(item_id: int):
|
||||
var effect = get_effect_from_item(item_id)
|
||||
if effect == -1: return
|
||||
|
||||
if LobbyManager.game_mode == "Candy Survival":
|
||||
if effect == SpecialEffect.INVISIBLE_MODE:
|
||||
var gm = player.get_node_or_null("/root/Main/CandySurvivalManager")
|
||||
if gm and gm.active:
|
||||
var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
|
||||
if multiplayer.is_server():
|
||||
gm.add_ghost_charge(pid)
|
||||
else:
|
||||
gm.rpc_id(1, "add_ghost_charge", pid)
|
||||
return
|
||||
|
||||
# VFX: show pickup burst on all peers (mirrors skill VFX pattern)
|
||||
if player.is_multiplayer_authority() and player.has_method("can_rpc") and player.can_rpc():
|
||||
player.rpc("play_skill_vfx", "take_powerup")
|
||||
@@ -230,6 +241,17 @@ func activate_effect(effect: int, target_player: Node3D = null):
|
||||
NotificationManager.send_message(player, "Skill in Cooldown! (%.1fs)" % global_cooldown_timer, NotificationManager.MessageType.WARNING)
|
||||
return
|
||||
|
||||
if LobbyManager.game_mode == "Candy Survival":
|
||||
if effect == SpecialEffect.INVISIBLE_MODE:
|
||||
var gm = player.get_node_or_null("/root/Main/CandySurvivalManager")
|
||||
if gm and gm.active:
|
||||
var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
|
||||
if multiplayer.is_server():
|
||||
gm.try_activate_ghost(pid)
|
||||
else:
|
||||
gm.rpc_id(1, "try_activate_ghost", pid)
|
||||
return
|
||||
|
||||
# Check Attack Mode Restriction
|
||||
if player.get("is_attack_mode") and effect == SpecialEffect.INVISIBLE_MODE:
|
||||
NotificationManager.send_message(player, "Cannot enter Ghost mode while in Attack Mode!", NotificationManager.MessageType.WARNING)
|
||||
@@ -546,22 +568,15 @@ func spawn_powerups_around(center: Vector2i, force_powerups: bool = true, only_c
|
||||
if only_common or LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO):
|
||||
# Spawn ONLY common tiles (7-10) in Stop n Go mode (User Request)
|
||||
item_id = rng.randi_range(7, 10)
|
||||
elif LobbyManager.is_game_mode(GameMode.Mode.GAUNTLET):
|
||||
# Gauntlet mode: mostly common tiles, but ghost (14) can spawn too.
|
||||
if rng.randf() < 0.85:
|
||||
elif LobbyManager.is_game_mode(GameMode.Mode.CANDY_SURVIVAL):
|
||||
# Candy Survival mode: spawn only normal tiles
|
||||
item_id = rng.randi_range(7, 10)
|
||||
else:
|
||||
item_id = 14 # Ghost powerup only
|
||||
else:
|
||||
# Other modes: 80% Chance for Common Tile (7-10), 20% for PowerUp
|
||||
if rng.randf() < 0.8:
|
||||
item_id = rng.randi_range(7, 10)
|
||||
else:
|
||||
# 20% Chance for PowerUp
|
||||
if LobbyManager.is_game_mode(GameMode.Mode.TEKTON_DOORS):
|
||||
# Restrict to Speed (11) and Ghost (14) for Tekton Doors
|
||||
item_id = [11, 14].pick_random()
|
||||
else:
|
||||
item_id = rng.randi_range(11, 14)
|
||||
|
||||
var cell = Vector3i(pos.x, 1, pos.y)
|
||||
|
||||
@@ -674,6 +674,8 @@ func _animate_safe_zone_appear():
|
||||
# Duplicate mesh+material so we animate without touching the shared .tres on disk.
|
||||
var anim_mat: StandardMaterial3D = mat.duplicate()
|
||||
anim_mat.albedo_color = Color(mat.albedo_color.r, mat.albedo_color.g, mat.albedo_color.b, 0.0)
|
||||
# Force render priority so the transparent decal correctly sorts over the terrain
|
||||
anim_mat.render_priority = 1
|
||||
|
||||
var anim_mesh = original_mesh.duplicate()
|
||||
anim_mesh.material = anim_mat
|
||||
|
||||
@@ -82,8 +82,8 @@ func setup_playerboard_ui():
|
||||
|
||||
slot.custom_minimum_size = Vector2(36, 36)
|
||||
slot.texture = item_tex[0]
|
||||
slot.set_meta("slot_idx", i)
|
||||
|
||||
# 0-based indices corresponding to User's 1-based request: 1,5,6,10,11,15,16,20,21,22,23,24,25
|
||||
var hidden_slots = [0, 4, 5, 9, 10, 14, 15, 19, 20, 21, 22, 23, 24]
|
||||
|
||||
if i in hidden_slots:
|
||||
@@ -114,32 +114,69 @@ func update_playerboard_ui():
|
||||
if not local_player_character or not playerboard_ui:
|
||||
return
|
||||
|
||||
# Center 3x3 slot indices in a 5x5 grid (0-indexed)
|
||||
# Row 1: 6, 7, 8
|
||||
# Row 2: 11, 12, 13
|
||||
# Center 3x3 slot indices in a 5x5 grid (0-indexed)
|
||||
# Row 1: 6, 7, 8 (Now Storage - but kept in index map for goals[0-2])
|
||||
# Row 2: 11, 12, 13 (Goals[3-5])
|
||||
# Row 3: 16, 17, 18 (Goals[6-8])
|
||||
# Candy for CandySurvival, not a tile
|
||||
var is_candy = LobbyManager and LobbyManager.is_game_mode(GameMode.Mode.CANDY_SURVIVAL)
|
||||
|
||||
var center_slots = [6, 7, 8, 11, 12, 13, 16, 17, 18]
|
||||
var goals = local_player_character.goals if local_player_character.goals else []
|
||||
|
||||
for i in range(25):
|
||||
var count = playerboard_ui.get_child_count()
|
||||
for i in range(count):
|
||||
var slot = playerboard_ui.get_child(i)
|
||||
|
||||
# Get actual playerboard index from slot metadata
|
||||
var slot_idx = i
|
||||
if slot.has_meta("slot_idx"):
|
||||
slot_idx = slot.get_meta("slot_idx")
|
||||
|
||||
# Safety check: Ensure playerboard has enough items
|
||||
if i >= local_player_character.playerboard.size():
|
||||
if slot_idx >= local_player_character.playerboard.size():
|
||||
continue
|
||||
|
||||
var item = local_player_character.playerboard[i]
|
||||
|
||||
# 0-based indices corresponding to User's 1-based request: 1,5,6,10,11,15,16,20,21,22,23,24,25
|
||||
var hidden_slots = [0, 4, 5, 9, 10, 14, 15, 19, 20, 21, 22, 23, 24]
|
||||
var item = local_player_character.playerboard[slot_idx]
|
||||
|
||||
# Default texture (empty)
|
||||
slot.texture = item_tex[0]
|
||||
|
||||
if i in hidden_slots:
|
||||
if is_candy:
|
||||
var hidden_slots = [0, 4, 5, 9, 10, 14, 15, 19, 20, 21, 22, 23, 24]
|
||||
if slot_idx in hidden_slots:
|
||||
slot.modulate = Color(1, 1, 1, 0)
|
||||
slot.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
else:
|
||||
slot.modulate = Color.WHITE
|
||||
slot.mouse_filter = Control.MOUSE_FILTER_PASS
|
||||
# All 9 cells show goal ghost or placed tile
|
||||
var center_index = center_slots.find(slot_idx)
|
||||
if center_index != -1 and center_index < goals.size():
|
||||
var goal_value = goals[center_index]
|
||||
if item != -1:
|
||||
match item:
|
||||
7: slot.texture = item_tex[1]
|
||||
8: slot.texture = item_tex[2]
|
||||
9: slot.texture = item_tex[3]
|
||||
10: slot.texture = item_tex[4]
|
||||
slot.modulate = Color.WHITE
|
||||
else:
|
||||
match goal_value:
|
||||
7: slot.texture = item_tex[1]
|
||||
8: slot.texture = item_tex[2]
|
||||
9: slot.texture = item_tex[3]
|
||||
10: slot.texture = item_tex[4]
|
||||
_: slot.texture = item_tex[0]
|
||||
slot.modulate = Color(0.3, 0.3, 0.3, 1.0)
|
||||
else:
|
||||
# Non-center slot - just show playerboard item normally
|
||||
match item:
|
||||
7: slot.texture = item_tex[1]
|
||||
8: slot.texture = item_tex[2]
|
||||
9: slot.texture = item_tex[3]
|
||||
10: slot.texture = item_tex[4]
|
||||
else:
|
||||
# Original 5x5 logic
|
||||
var hidden_slots = [0, 4, 5, 9, 10, 14, 15, 19, 20, 21, 22, 23, 24]
|
||||
|
||||
if slot_idx in hidden_slots:
|
||||
slot.modulate = Color(1, 1, 1, 0)
|
||||
slot.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
else:
|
||||
@@ -148,8 +185,8 @@ func update_playerboard_ui():
|
||||
|
||||
# Check if this is a center slot that should show a goal
|
||||
# BUT only show ghost goals for rows 2 & 3 (indices 11+)
|
||||
var center_index = center_slots.find(i)
|
||||
if center_index != -1 and center_index < goals.size() and i > 8:
|
||||
var center_index = center_slots.find(slot_idx)
|
||||
if center_index != -1 and center_index < goals.size() and slot_idx > 8:
|
||||
var goal_value = goals[center_index]
|
||||
|
||||
if item != -1:
|
||||
@@ -178,12 +215,12 @@ func update_playerboard_ui():
|
||||
9: slot.texture = item_tex[3]
|
||||
10: slot.texture = item_tex[4]
|
||||
# Non-center slots always full brightness (UNLESS HIDDEN)
|
||||
if not (i in hidden_slots):
|
||||
if not (slot_idx in hidden_slots):
|
||||
slot.modulate = Color.WHITE
|
||||
|
||||
# Check for new special tile placement to trigger effect
|
||||
if i < _previous_playerboard_state.size():
|
||||
var prev_item = _previous_playerboard_state[i]
|
||||
if slot_idx < _previous_playerboard_state.size():
|
||||
var prev_item = _previous_playerboard_state[slot_idx]
|
||||
# If slot was empty or different, and now has a special tile (7-10)
|
||||
if item != prev_item and item >= 7 and item <= 10:
|
||||
_pulse_slot_effect(slot)
|
||||
@@ -208,9 +245,6 @@ func _pulse_slot_effect(slot: Control):
|
||||
slot.modulate = Color(1.5, 1.5, 1.5) # Overbright
|
||||
tween.parallel().tween_property(slot, "modulate", original_modulate, 0.3)
|
||||
|
||||
|
||||
|
||||
|
||||
func _connect_powerup_manager_deferred(player):
|
||||
"""Wait for PowerUpManager to be initialized before connecting."""
|
||||
# player._ready waits 0.5s before creating managers, so wait longer
|
||||
|
||||
@@ -216,10 +216,9 @@ func update_display_name(new_name: String) -> bool:
|
||||
emit_signal("profile_updated")
|
||||
return true
|
||||
|
||||
var formatted_username = new_name.replace(" ", "_").to_lower()
|
||||
var result: NakamaAsyncResult = await NakamaManager.client.update_account_async(
|
||||
NakamaManager.session,
|
||||
formatted_username, # username (sync to display name)
|
||||
null, # username (keep existing unique username unchanged)
|
||||
new_name # display_name
|
||||
)
|
||||
|
||||
@@ -228,6 +227,7 @@ func update_display_name(new_name: String) -> bool:
|
||||
return false
|
||||
|
||||
profile["display_name"] = new_name
|
||||
await _save_profile_data()
|
||||
emit_signal("profile_updated")
|
||||
return true
|
||||
|
||||
|
||||
@@ -18,16 +18,10 @@ const SCHEMA = {
|
||||
"sng_stop_duration": {"type": TYPE_INT, "default": 4, "min": 2, "max": 10},
|
||||
"sng_required_goals": {"type": TYPE_INT, "default": 8, "min": 3, "max": 20}
|
||||
},
|
||||
"Tekton Doors": {
|
||||
"Candy Survival": {
|
||||
"match_duration": {"type": TYPE_INT, "default": 180, "min": 60, "max": 600},
|
||||
"doors_swap_time": {"type": TYPE_INT, "default": 15, "min": 10, "max": 30},
|
||||
"doors_refresh_time": {"type": TYPE_INT, "default": 25, "min": 15, "max": 40},
|
||||
"doors_required_goals": {"type": TYPE_INT, "default": 8, "min": 5, "max": 12}
|
||||
},
|
||||
"Candy Pump Survival": {
|
||||
"match_duration": {"type": TYPE_INT, "default": 180, "min": 60, "max": 600},
|
||||
"gauntlet_growth_interval": {"type": TYPE_FLOAT, "default": 3.0, "min": 1.0, "max": 10.0},
|
||||
"gauntlet_cells_per_tick": {"type": TYPE_DICTIONARY, "default": {"phase1": [4, 6], "phase2": [6, 8], "phase3": [8, 10]}}
|
||||
"candy_survival_growth_interval": {"type": TYPE_FLOAT, "default": 3.0, "min": 1.0, "max": 10.0},
|
||||
"candy_survival_cells_per_tick": {"type": TYPE_DICTIONARY, "default": {"phase1": [4, 6], "phase2": [6, 8], "phase3": [8, 10]}}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,11 @@ static func get_tile_weights() -> Dictionary:
|
||||
weights[tile] = STANDARD_WEIGHT
|
||||
|
||||
# Special tiles
|
||||
var mode = LobbyManager.get_game_mode()
|
||||
var lobby = Engine.get_main_loop().root.get_node_or_null("LobbyManager")
|
||||
var mode = lobby.get_game_mode() if lobby else ""
|
||||
if mode == "Candy Survival":
|
||||
return weights
|
||||
|
||||
var is_restricted = GameMode.is_restricted(mode)
|
||||
for tile in SPECIAL_TILES:
|
||||
if is_restricted and tile == TILE_WALL:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
extends Node
|
||||
|
||||
const SafeNakamaMultiplayerBridge = preload("res://scripts/services/safe_nakama_multiplayer_bridge.gd")
|
||||
|
||||
# Standard Nakama Configuration
|
||||
var nakama_server_key = OS.get_environment("NAKAMA_SERVER_KEY") if OS.has_environment("NAKAMA_SERVER_KEY") else ProjectSettings.get_setting("network/nakama/server_key", "defaultkey")
|
||||
var nakama_host = OS.get_environment("NAKAMA_HOST") if OS.has_environment("NAKAMA_HOST") else ProjectSettings.get_setting("network/nakama/host", "tektondash.vps.webdock.cloud")
|
||||
@@ -141,7 +143,7 @@ func connect_to_nakama_async(email: String = "", password: String = "") -> bool:
|
||||
bridge.leave()
|
||||
bridge = null
|
||||
|
||||
bridge = NakamaMultiplayerBridge.new(socket)
|
||||
bridge = SafeNakamaMultiplayerBridge.new(socket)
|
||||
|
||||
# Connect bridge signals
|
||||
bridge.match_joined.connect(_on_bridge_match_joined)
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
extends StaticBody3D
|
||||
|
||||
# PortalDoor.gd
|
||||
# Specialized door for "Tekton Doors" mode.
|
||||
# Teleports players to a target room/door when they step into it.
|
||||
|
||||
signal player_entered_portal(player_node, door_node)
|
||||
|
||||
@export var room_id: int = 0
|
||||
@export var door_id: int = 0 # 0: North, 1: South, 2: East, 3: West
|
||||
|
||||
# State synced by PortalModeManager
|
||||
var target_room_id: int = -1
|
||||
var target_door_id: int = -1
|
||||
var is_active: bool = true
|
||||
var portal_color: Color = Color.WHITE: set = set_portal_color
|
||||
|
||||
func set_portal_color(value: Color):
|
||||
portal_color = value
|
||||
_update_visuals()
|
||||
|
||||
@onready var detection_area: Area3D = $Area3D
|
||||
|
||||
func _ready():
|
||||
add_to_group("PortalDoors")
|
||||
if detection_area:
|
||||
detection_area.body_entered.connect(_on_body_entered)
|
||||
|
||||
# Visual feedback: indicate door is active
|
||||
_update_visuals()
|
||||
|
||||
# Adjust GroundIndicator position based on spawn_offset metadata
|
||||
_adjust_indicator_position()
|
||||
|
||||
func _on_body_entered(body: Node3D):
|
||||
if not is_active: return
|
||||
|
||||
if body.is_in_group("Players") or body.get("is_bot"):
|
||||
var current_time = Time.get_ticks_msec()
|
||||
if body.has_meta("last_portal_time"):
|
||||
# Reduce cooldown to 200ms to match manager logic and allow fast re-entry
|
||||
if current_time - body.get_meta("last_portal_time") < 200:
|
||||
return
|
||||
|
||||
body.set_meta("last_portal_time", current_time)
|
||||
|
||||
print("[PortalDoor] Player %s entered Door %d in Room %d" % [body.name, door_id, room_id])
|
||||
emit_signal("player_entered_portal", body, self )
|
||||
|
||||
var _materials_initialized: bool = false
|
||||
|
||||
func _update_visuals():
|
||||
# Removed is_node_ready() check to allow early setter calls to prepare variables,
|
||||
# but we still need the nodes to exist to apply them.
|
||||
if not is_inside_tree(): return
|
||||
|
||||
var vortex = get_node_or_null("Vortex")
|
||||
var frame_left = get_node_or_null("Frame_Left")
|
||||
|
||||
# If children aren't there yet, we can't update visuals.
|
||||
# This usually happens if called before or during early _ready.
|
||||
if not vortex or not frame_left: return
|
||||
|
||||
if not _materials_initialized:
|
||||
_initialize_unique_materials()
|
||||
_materials_initialized = true
|
||||
|
||||
if vortex:
|
||||
var mat = vortex.get_surface_override_material(0)
|
||||
if mat:
|
||||
mat.albedo_color = portal_color
|
||||
mat.albedo_color.a = 0.5
|
||||
if mat.has_method("set_emission"):
|
||||
mat.set("emission", portal_color)
|
||||
|
||||
for part_name in ["Frame_Left", "Frame_Right", "Frame_Top"]:
|
||||
var frame = get_node_or_null(part_name)
|
||||
if frame:
|
||||
var mat = frame.get_surface_override_material(0)
|
||||
if mat:
|
||||
mat.albedo_color = portal_color.lerp(Color.BLACK, 0.4)
|
||||
|
||||
var ground = get_node_or_null("GroundIndicator")
|
||||
if ground:
|
||||
var mat = ground.get_surface_override_material(0)
|
||||
if mat:
|
||||
mat.albedo_color = portal_color
|
||||
mat.albedo_color.a = 0.4
|
||||
mat.emission = portal_color
|
||||
mat.emission_energy_multiplier = 2.0
|
||||
|
||||
func _initialize_unique_materials():
|
||||
var vortex = get_node_or_null("Vortex")
|
||||
if vortex:
|
||||
var mat = vortex.get_surface_override_material(0)
|
||||
if not mat:
|
||||
mat = vortex.mesh.surface_get_material(0)
|
||||
|
||||
if mat:
|
||||
vortex.set_surface_override_material(0, mat.duplicate())
|
||||
|
||||
for part_name in ["Frame_Left", "Frame_Right", "Frame_Top"]:
|
||||
var frame = get_node_or_null(part_name)
|
||||
if frame:
|
||||
var mat = frame.get_surface_override_material(0)
|
||||
if not mat:
|
||||
mat = frame.mesh.surface_get_material(0)
|
||||
|
||||
if mat:
|
||||
frame.set_surface_override_material(0, mat.duplicate())
|
||||
|
||||
var ground = get_node_or_null("GroundIndicator")
|
||||
if ground:
|
||||
var mat = ground.get_surface_override_material(0)
|
||||
if not mat:
|
||||
mat = ground.mesh.surface_get_material(0)
|
||||
if mat:
|
||||
ground.set_surface_override_material(0, mat.duplicate())
|
||||
|
||||
func _adjust_indicator_position():
|
||||
# This uses the spawn_offset metadata set by PortalModeManager
|
||||
# to push the ground indicator "into" the room.
|
||||
if not has_meta("spawn_offset"): return
|
||||
|
||||
var ground = get_node_or_null("GroundIndicator")
|
||||
if not ground: return
|
||||
|
||||
var offset_2d = get_meta("spawn_offset") # Vector2i
|
||||
var offset_3d = Vector3(offset_2d.x, 0, offset_2d.y)
|
||||
|
||||
# Convert the global direction (into the room) to local coordinates
|
||||
var local_dir = to_local(global_position + offset_3d).normalized()
|
||||
|
||||
# Nudge the indicator in that direction
|
||||
ground.position = local_dir * 0.5 # Reduced from 0.6 to close the gap
|
||||
ground.position.y = 0.05 # Keep it just above the floor
|
||||
@@ -65,12 +65,12 @@ func _initialize_steamworks_for_auth() -> void:
|
||||
push_error("BackendService: Failed to load Steamworks manager")
|
||||
|
||||
func _initialize_nakama() -> void:
|
||||
nakama_backend = NakamaManager
|
||||
nakama_backend = get_node_or_null("/root/NakamaManager")
|
||||
if nakama_backend:
|
||||
_connect_nakama_signals()
|
||||
print("BackendService: Initialized Nakama backend")
|
||||
else:
|
||||
push_error("BackendService: NakamaManager not found")
|
||||
push_error("BackendService: NakamaManager not found at /root/NakamaManager")
|
||||
|
||||
func _connect_nakama_signals() -> void:
|
||||
pass
|
||||
@@ -181,8 +181,8 @@ func respond_friend_request(target_id: String, accept: bool) -> Dictionary:
|
||||
var payload = JSON.stringify({"target_user_id": target_id, "accept": accept})
|
||||
return await api_rpc_async("respond_friend_request", payload)
|
||||
|
||||
func perform_gacha_pull(gacha_id: String, count: int) -> Dictionary:
|
||||
var payload = JSON.stringify({"gacha_id": gacha_id, "count": count})
|
||||
func perform_gacha_pull(banner_id: String, count: int) -> Dictionary:
|
||||
var payload = JSON.stringify({"banner_id": banner_id, "count": count})
|
||||
return await api_rpc_async("perform_gacha_pull", payload)
|
||||
|
||||
func get_mail(payload: String = "{}") -> Dictionary:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
class_name SafeNakamaMultiplayerBridge
|
||||
extends NakamaMultiplayerBridge
|
||||
|
||||
func _init(p_nakama_socket: NakamaSocket) -> void:
|
||||
super._init(p_nakama_socket)
|
||||
|
||||
func _map_id_to_session(peer_id: int, session_id: String) -> void:
|
||||
_id_map[peer_id] = session_id
|
||||
if not _users.has(session_id):
|
||||
_users[session_id] = NakamaMultiplayerBridge.User.new(null)
|
||||
_users[session_id].peer_id = peer_id
|
||||
|
||||
func _on_multiplayer_peer_packet_generated(peer_id: int, buffer: PackedByteArray) -> void:
|
||||
if match_state == MatchState.CONNECTED:
|
||||
var target_presences = null
|
||||
if peer_id > 0:
|
||||
if not _id_map.has(peer_id) or not _users.has(_id_map[peer_id]) or _users[_id_map[peer_id]].presence == null:
|
||||
push_error("Attempting to send RPC to unknown or uninitialized peer id: %s" % peer_id)
|
||||
return
|
||||
target_presences = [ _users[_id_map[peer_id]].presence ]
|
||||
_nakama_socket.send_match_state_raw_async(_match_id, rpc_op_code, buffer, target_presences)
|
||||
else:
|
||||
push_error("RPC sent while the NakamaMultiplayerBridge isn't connected!")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfyyc34ekv3yw
|
||||
@@ -505,8 +505,8 @@ func spawn_tiles_around(count: int = 4):
|
||||
if roll < 0.6 or (LobbyManager and LobbyManager.game_mode == "Stop n Go"):
|
||||
# 60% Normal Tile (7-10) OR 100% if Stop n Go (User Request)
|
||||
item_id = rng.randi_range(7, 10)
|
||||
elif LobbyManager and LobbyManager.get_game_mode() == GameMode.Mode.GAUNTLET:
|
||||
# Gauntlet mode: No power-up spawns from Tekton grabs
|
||||
elif LobbyManager and LobbyManager.get_game_mode() == GameMode.Mode.CANDY_SURVIVAL:
|
||||
# Candy Survival mode: No power-up spawns from Tekton grabs
|
||||
item_id = rng.randi_range(7, 10)
|
||||
else:
|
||||
# 40% PowerUp (11-14)
|
||||
@@ -514,9 +514,7 @@ func spawn_tiles_around(count: int = 4):
|
||||
if LobbyManager:
|
||||
mode = LobbyManager.get_game_mode()
|
||||
|
||||
if LobbyManager and LobbyManager.get_game_mode() == GameMode.Mode.TEKTON_DOORS:
|
||||
item_id = [11, 14].pick_random()
|
||||
elif mode == GameMode.Mode.FREEMODE:
|
||||
if mode == GameMode.Mode.FREEMODE:
|
||||
item_id = rng.randi_range(7, 10) # No powerups in freemode either, just normal tiles
|
||||
else:
|
||||
item_id = rng.randi_range(11, 14)
|
||||
|
||||
@@ -12,6 +12,8 @@ signal closed
|
||||
@onready var banner_label := %BannerLabel as Label
|
||||
@onready var gold_label := %GoldLabel as Label
|
||||
@onready var star_label := %StarLabel as Label
|
||||
@onready var left_gold_label := %LeftGoldLabel as Label
|
||||
@onready var left_star_label := %LeftStarLabel as Label
|
||||
@onready var pity_label := %PityLabel as Label
|
||||
@onready var pull_1_btn := %Pull1Btn as Button
|
||||
@onready var pull_10_btn := %Pull10Btn as Button
|
||||
@@ -142,6 +144,11 @@ func _refresh_ui() -> void:
|
||||
star_label.text = str(UserProfileManager.wallet.get("star", 0))
|
||||
gold_label.text = str(UserProfileManager.wallet.get("gold", 0))
|
||||
|
||||
if left_star_label:
|
||||
left_star_label.text = str(UserProfileManager.wallet.get("star", 0))
|
||||
if left_gold_label:
|
||||
left_gold_label.text = str(UserProfileManager.wallet.get("gold", 0))
|
||||
|
||||
pity_label.text = "Pity: %d / %d" % [pity, pity_at]
|
||||
cost_1_label.text = "%s %d" % [icon, c1]
|
||||
cost_10_label.text = "%s %d" % [icon, c10]
|
||||
|
||||
@@ -71,7 +71,7 @@ func show_panel() -> void:
|
||||
show()
|
||||
status_label.text = "Syncing scores..."
|
||||
# Bulk-sync all users' storage stats to native leaderboard (server-side operation)
|
||||
if NakamaManager.session:
|
||||
if NakamaManager.get("session"):
|
||||
var sync_result = await BackendService.sync_leaderboard()
|
||||
if sync_result.get("success", false) == false:
|
||||
push_error("[Leaderboard] sync_leaderboard RPC failed: " + str(sync_result.get("error", "")))
|
||||
@@ -87,7 +87,7 @@ func _on_close_pressed() -> void:
|
||||
# Data
|
||||
# -------------------------------------------------------------------------
|
||||
func _fetch_leaderboard_data() -> void:
|
||||
if not NakamaManager.session:
|
||||
if not NakamaManager.get("session"):
|
||||
status_label.text = "Not connected to Nakama"
|
||||
return
|
||||
|
||||
@@ -115,7 +115,7 @@ func _fetch_leaderboard_data() -> void:
|
||||
func _fetch_native_leaderboard() -> Array:
|
||||
"""Use the Nakama client API to list native leaderboard records directly."""
|
||||
var result = await NakamaManager.client.list_leaderboard_records_async(
|
||||
NakamaManager.session,
|
||||
NakamaManager.get("session"),
|
||||
"global_high_score",
|
||||
[], # no specific owner filter
|
||||
null, # expiry = null (no filter)
|
||||
@@ -133,7 +133,7 @@ func _fetch_native_leaderboard() -> Array:
|
||||
var parsed = JSON.parse_string(record.metadata)
|
||||
if parsed is Dictionary:
|
||||
meta = parsed
|
||||
if record.owner_id == NakamaManager.session.user_id:
|
||||
if record.owner_id == NakamaManager.get("session").user_id:
|
||||
print("[Leaderboard] Local player meta: ", meta)
|
||||
|
||||
data.append({
|
||||
@@ -177,9 +177,9 @@ func _calculate_win_rates() -> void:
|
||||
entry["win_rate"] = float(won) / float(played) * 100.0 if played > 0 else 0.0
|
||||
|
||||
func _apply_local_overrides(data: Array) -> void:
|
||||
if not NakamaManager.session:
|
||||
if not NakamaManager.get("session"):
|
||||
return
|
||||
var my_id = NakamaManager.session.user_id
|
||||
var my_id = NakamaManager.get("session").user_id
|
||||
for entry in data:
|
||||
if entry.get("user_id") == my_id:
|
||||
entry["display_name"] = UserProfileManager.get_display_name(entry.get("display_name", "Unknown"))
|
||||
|
||||
@@ -198,6 +198,13 @@ func _on_inventory_updated(inventory: Dictionary):
|
||||
power_up_button.disabled = is_cooling
|
||||
power_up_button.modulate = Color.WHITE
|
||||
_update_btn_shortcut(power_up_button)
|
||||
else:
|
||||
if LobbyManager.game_mode == "Candy Survival":
|
||||
power_up_button.visible = true
|
||||
power_up_button.icon = effect_textures.get(3) # ghost.png
|
||||
power_up_button.disabled = true
|
||||
power_up_button.modulate = Color(0.5, 0.5, 0.5, 0.5)
|
||||
_update_btn_shortcut(power_up_button)
|
||||
else:
|
||||
power_up_button.visible = false
|
||||
power_up_button.disabled = true
|
||||
|
||||
@@ -607,7 +607,7 @@ func _on_save_name_pressed() -> void:
|
||||
emit_signal("profile_updated")
|
||||
await get_tree().create_timer(3.0).timeout
|
||||
status_label.text = ""
|
||||
else:
|
||||
elif status_label.text == "Saving...":
|
||||
_set_status("Failed to update name.", Color.RED)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
# TektonDash Unit Tests
|
||||
|
||||
This directory contains GUT (Godot Unit Testing) framework tests for the TektonDash project.
|
||||
|
||||
## Test Files (38 Total)
|
||||
|
||||
### Done Tasks (13 Files - 133 Tests)
|
||||
|
||||
### test_admin_panel.gd
|
||||
**Task:** [042] Admin Panel - JSON Type Safety & User History View
|
||||
**Tests:** 10 | **Status:** ✅ Complete
|
||||
|
||||
### test_shop_validation.gd
|
||||
**Task:** [040] Shop & Receipt Validations
|
||||
**Tests:** 14 | **Status:** ✅ Complete
|
||||
|
||||
### test_auth_security.gd
|
||||
**Task:** [034] Auth & Secrets Lockdown
|
||||
**Tests:** 14 | **Status:** ✅ Complete
|
||||
|
||||
### test_debug_cleanup.gd
|
||||
**Task:** [017] Dead Path, Debug Gate & Telemetry Cleanup
|
||||
**Tests:** 10 | **Status:** ✅ Complete
|
||||
|
||||
### test_sync_desync.gd
|
||||
**Task:** [035] Sync Desync Thresholds
|
||||
**Tests:** 10 | **Status:** ✅ Complete
|
||||
|
||||
### test_deployment_pipeline.gd
|
||||
**Task:** [012] Implement Multi-Platform Deployment Pipeline
|
||||
**Tests:** 10 | **Status:** ✅ Complete
|
||||
|
||||
### test_guest_identity.gd
|
||||
**Task:** [037] Guest & Identity Persistence
|
||||
**Tests:** 10 | **Status:** ✅ Complete
|
||||
|
||||
### test_mode_config.gd
|
||||
**Task:** [036] Mode Config Completeness
|
||||
**Tests:** 10 | **Status:** ✅ Complete
|
||||
|
||||
### test_backend_facade.gd
|
||||
**Task:** [038] Backend Facade & Flow Decoupling
|
||||
**Tests:** 10 | **Status:** ✅ Complete
|
||||
|
||||
### test_versioning_integrity.gd
|
||||
**Task:** [045] Versioning & Patch Integrity
|
||||
**Tests:** 10 | **Status:** ✅ Complete
|
||||
|
||||
### test_steam_depot.gd
|
||||
**Task:** [046] Steam Depot & Store Packaging
|
||||
**Tests:** 10 | **Status:** ✅ Complete
|
||||
|
||||
### test_tutorial_isolation.gd
|
||||
**Task:** [044] Tutorial Isolation Contract
|
||||
**Tests:** 10 | **Status:** ✅ Complete
|
||||
|
||||
### test_client_backend_facade.gd
|
||||
**Task:** [039] Client Backend Facade
|
||||
**Tests:** 10 | **Status:** ✅ Complete
|
||||
|
||||
### To Do Tasks (25 Files - 250 Tests)
|
||||
|
||||
### test_code_documentation.gd
|
||||
**Task:** [015] Add Code Documentation
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_iap_receipt_validation.gd
|
||||
**Task:** [007] Implement Server-Side IAP Receipt Validation
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_nakama_key_security.gd
|
||||
**Task:** [004] Remove Hardcoded Nakama Server Key
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_currency_security.gd
|
||||
**Task:** [006] Remove Client-Side Currency Manipulation
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_backend_service_complete.gd
|
||||
**Task:** [041] Complete backend_service.gd Implementation
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_lobby_refactor.gd
|
||||
**Task:** [020] Refactor lobby.gd (Large Class)
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_backend_facade_pattern.gd
|
||||
**Task:** [002] Implement Unified Backend Facade Pattern
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_analytics_monitoring.gd
|
||||
**Task:** [013] Implement Analytics & Monitoring System
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_error_handling.gd
|
||||
**Task:** [001] Implement Comprehensive Error Handling
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_player_refactor.gd
|
||||
**Task:** [021] Refactor player.gd (Large Class)
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_rate_limiting_anticheat.gd
|
||||
**Task:** [008] Implement Rate Limiting & Anti-Cheat
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_localization_i18n.gd
|
||||
**Task:** [011] Implement Localization/i18n System
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_automated_testing_ci.gd
|
||||
**Task:** [028] Implement Automated Testing in CI
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_regional_servers.gd
|
||||
**Task:** [009] Implement Regional Server Infrastructure
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_identity_manager.gd
|
||||
**Task:** [003] Implement Unified Identity Manager
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_github_actions_workflow.gd
|
||||
**Task:** [024] Set up GitHub Actions CI/CD Workflow
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_economy_facade.gd
|
||||
**Task:** [018] Server-Authoritative Economy Facade
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_encryption_key_management.gd
|
||||
**Task:** [005] Replace Hardcoded Encryption Key
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_session_management.gd
|
||||
**Task:** [010] Implement Proactive Session Management
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_testing_infrastructure.gd
|
||||
**Task:** [014] Implement Automated Testing Infrastructure
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_debug_code_removal.gd
|
||||
**Task:** [016] Remove Debug Code from Production
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_task_019.gd
|
||||
**Task:** [019] Additional Task Implementation
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_task_022.gd
|
||||
**Task:** [022] Additional Task Implementation
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_task_023.gd
|
||||
**Task:** [023] Additional Task Implementation
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
### test_task_025.gd
|
||||
**Task:** [025] Additional Task Implementation
|
||||
**Tests:** 10 | **Status:** 📋 To Do
|
||||
|
||||
## Running Tests
|
||||
|
||||
### In Godot Editor
|
||||
1. Open the project in Godot
|
||||
2. Go to **Tools → GUT → Run Tests**
|
||||
3. Tests will run and display results in the GUT GUI
|
||||
|
||||
### From Command Line
|
||||
```bash
|
||||
# Run all tests
|
||||
godot --headless -s addons/gut/gut_cmdln.gd
|
||||
|
||||
# Run specific test file
|
||||
godot --headless -s addons/gut/gut_cmdln.gd -d res://tests/test_admin_panel.gd
|
||||
|
||||
# Export results to JUnit XML
|
||||
godot --headless -s addons/gut/gut_cmdln.gd -o test-results.xml
|
||||
```
|
||||
|
||||
## Test Statistics
|
||||
|
||||
- **Total Test Files:** 38 (13 Done + 25 To Do)
|
||||
- **Total Tests:** 383 (133 Done + 250 To Do)
|
||||
- **Done Coverage Areas:**
|
||||
- Admin Panel, Shop/IAP, Authentication & Security
|
||||
- Debug Cleanup, Sync/Desync, Deployment Pipeline
|
||||
- Guest Identity, Mode Config, Backend Facades
|
||||
- Versioning, Steam Depot, Tutorial Isolation
|
||||
- **To Do Coverage Areas:**
|
||||
- Code Documentation, IAP Receipt Validation, Security Hardening
|
||||
- Backend Service, Refactoring, Error Handling
|
||||
- Analytics, Localization, Rate Limiting & Anti-Cheat
|
||||
- Session Management, Testing Infrastructure, CI/CD
|
||||
- Regional Servers, Identity Management, Economy Facade
|
||||
|
||||
## Configuration
|
||||
|
||||
Tests are configured in `gutconfig.json` at the project root:
|
||||
- Test directory: `res://tests`
|
||||
- Log level: 1 (tests + failures)
|
||||
- Double strategy: FULL (for mocking)
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
1. Create a new file: `tests/test_feature_name.gd`
|
||||
2. Extend `GutTest`
|
||||
3. Add test methods starting with `test_`
|
||||
4. Run tests to verify
|
||||
|
||||
Example:
|
||||
```gdscript
|
||||
extends GutTest
|
||||
|
||||
func test_something():
|
||||
assert_eq(1, 1, "Should pass")
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- **GUT Documentation:** https://gut.readthedocs.io
|
||||
- **GUT GitHub:** https://github.com/bitwes/Gut
|
||||
- **Setup Guide:** See `GUT_SETUP_SKILLS.md` in project root
|
||||
@@ -1,34 +0,0 @@
|
||||
extends Node
|
||||
|
||||
# Minimal EnhancedGridMap stand-in for Gauntlet headless tests. Records
|
||||
# set_cell_item calls so lifecycle tests can run the local sync path without a
|
||||
# real GridMap. Only the surface the manager touches is implemented.
|
||||
|
||||
var cell_size := Vector3(1, 1, 1)
|
||||
var cells: Dictionary = {} # Vector3i -> item id
|
||||
var astar_inits := 0
|
||||
|
||||
# Walkable-tile set. Items in this list are passable for the bot planner.
|
||||
# By default everything is walkable except the items the bot calls out
|
||||
# explicitly via `non_walkable_items`.
|
||||
var non_walkable_items: Array[int] = []
|
||||
var columns: int = 20
|
||||
var rows: int = 20
|
||||
|
||||
func is_position_valid(pos: Vector2i) -> bool:
|
||||
return pos.x >= 0 and pos.x < columns and pos.y >= 0 and pos.y < rows
|
||||
|
||||
func set_cell_item(pos: Vector3i, item: int, _orientation: int = 0) -> void:
|
||||
if item == -1:
|
||||
cells.erase(pos)
|
||||
else:
|
||||
cells[pos] = item
|
||||
|
||||
func get_cell_item(pos: Vector3i) -> int:
|
||||
return cells.get(pos, -1)
|
||||
|
||||
func initialize_astar() -> void:
|
||||
astar_inits += 1
|
||||
|
||||
func update_grid_data() -> void:
|
||||
pass
|
||||
@@ -1 +0,0 @@
|
||||
uid://b7ihsm80fbyb5
|
||||
@@ -1,9 +0,0 @@
|
||||
extends Node
|
||||
|
||||
# Minimal "Main" stand-in for Gauntlet headless tests. Provides the RPC methods
|
||||
# the GauntletManager calls on its main_scene so calls resolve without the full
|
||||
# game scene. Methods are no-ops that just need to exist + be rpc-tagged.
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func sync_grid_item(_x: int, _y: int, _z: int, _item: int) -> void:
|
||||
pass
|
||||
@@ -1 +0,0 @@
|
||||
uid://ca04jq87bj3ap
|
||||
@@ -1,144 +0,0 @@
|
||||
# tests/test_admin_panel.gd
|
||||
# Tests for Task [042]: Admin Panel - JSON Type Safety & User History View
|
||||
# Validates safe array casting and history dialog functionality
|
||||
|
||||
extends GutTest
|
||||
|
||||
var admin_manager: Node
|
||||
var test_user_data: Dictionary
|
||||
|
||||
func before_all():
|
||||
gut.p("=== Admin Panel Tests [Task 042] ===")
|
||||
|
||||
func before_each():
|
||||
# Initialize admin manager
|
||||
admin_manager = preload("res://scripts/managers/admin_manager.gd").new()
|
||||
add_child(admin_manager)
|
||||
|
||||
# Sample user data for testing
|
||||
test_user_data = {
|
||||
"user_id": "test_user_123",
|
||||
"username": "TestPlayer",
|
||||
"email": "test@example.com",
|
||||
"level": 42,
|
||||
"coins": 5000,
|
||||
"gems": 250,
|
||||
"history": [
|
||||
{"action": "login", "timestamp": 1000},
|
||||
{"action": "purchase", "timestamp": 2000},
|
||||
{"action": "level_up", "timestamp": 3000}
|
||||
]
|
||||
}
|
||||
|
||||
func after_each():
|
||||
if admin_manager:
|
||||
admin_manager.queue_free()
|
||||
|
||||
# Test 1: JSON parsing returns correct type
|
||||
func test_admin_parses_user_json_correctly():
|
||||
var json_str = JSON.stringify(test_user_data)
|
||||
var parsed = JSON.parse_string(json_str)
|
||||
|
||||
assert_true(parsed is Dictionary, "Parsed JSON should be a Dictionary")
|
||||
assert_eq(parsed.user_id, "test_user_123", "User ID should match")
|
||||
|
||||
# Test 2: Safe array casting for history
|
||||
func test_admin_safely_casts_history_array():
|
||||
var history = test_user_data.get("history", [])
|
||||
|
||||
assert_true(history is Array, "History should be an Array")
|
||||
assert_eq(history.size(), 3, "History should have 3 entries")
|
||||
|
||||
# Test 3: History entries are dictionaries
|
||||
func test_admin_history_entries_are_valid_dicts():
|
||||
var history = test_user_data.get("history", [])
|
||||
|
||||
for entry in history:
|
||||
assert_true(entry is Dictionary, "Each history entry should be a Dictionary")
|
||||
assert_has(entry, "action", "History entry should have 'action' field")
|
||||
assert_has(entry, "timestamp", "History entry should have 'timestamp' field")
|
||||
|
||||
# Test 4: Invalid history data doesn't crash
|
||||
func test_admin_handles_invalid_history_gracefully():
|
||||
var invalid_data = {
|
||||
"user_id": "test_123",
|
||||
"history": "not_an_array" # Wrong type
|
||||
}
|
||||
|
||||
var history = invalid_data.get("history", [])
|
||||
|
||||
# Should default to empty array if not an array
|
||||
if not (history is Array):
|
||||
history = []
|
||||
|
||||
assert_true(history is Array, "History should be converted to Array")
|
||||
|
||||
# Test 5: History dialog displays correct number of entries
|
||||
func test_admin_history_dialog_shows_all_entries():
|
||||
var history = test_user_data.get("history", [])
|
||||
var display_count = 0
|
||||
|
||||
for entry in history:
|
||||
if entry.has("action") and entry.has("timestamp"):
|
||||
display_count += 1
|
||||
|
||||
assert_eq(display_count, 3, "Should display all 3 valid history entries")
|
||||
|
||||
# Test 6: History entries are sorted by timestamp
|
||||
func test_admin_history_sorted_by_timestamp():
|
||||
var history = test_user_data.get("history", [])
|
||||
|
||||
for i in range(history.size() - 1):
|
||||
var current_time = history[i].get("timestamp", 0)
|
||||
var next_time = history[i + 1].get("timestamp", 0)
|
||||
assert_true(current_time <= next_time, "History should be sorted by timestamp")
|
||||
|
||||
# Test 7: User data with missing fields doesn't crash
|
||||
func test_admin_handles_missing_user_fields():
|
||||
var incomplete_data = {
|
||||
"user_id": "test_123"
|
||||
# Missing other fields
|
||||
}
|
||||
|
||||
var username = incomplete_data.get("username", "Unknown")
|
||||
var level = incomplete_data.get("level", 0)
|
||||
var history = incomplete_data.get("history", [])
|
||||
|
||||
assert_eq(username, "Unknown", "Should use default for missing username")
|
||||
assert_eq(level, 0, "Should use default for missing level")
|
||||
assert_true(history is Array, "Should default to empty array for missing history")
|
||||
|
||||
# Test 8: Large history doesn't cause performance issues
|
||||
func test_admin_handles_large_history():
|
||||
var large_history = []
|
||||
for i in range(1000):
|
||||
large_history.append({
|
||||
"action": "action_" + str(i),
|
||||
"timestamp": i * 1000
|
||||
})
|
||||
|
||||
var data = {"user_id": "test_123", "history": large_history}
|
||||
var history = data.get("history", [])
|
||||
|
||||
assert_eq(history.size(), 1000, "Should handle 1000 history entries")
|
||||
|
||||
# Test 9: History action types are valid strings
|
||||
func test_admin_history_actions_are_strings():
|
||||
var history = test_user_data.get("history", [])
|
||||
|
||||
for entry in history:
|
||||
var action = entry.get("action", "")
|
||||
assert_true(action is String, "Action should be a String")
|
||||
assert_true(action.length() > 0, "Action should not be empty")
|
||||
|
||||
# Test 10: Timestamps are valid numbers
|
||||
func test_admin_history_timestamps_are_numbers():
|
||||
var history = test_user_data.get("history", [])
|
||||
|
||||
for entry in history:
|
||||
var timestamp = entry.get("timestamp", 0)
|
||||
assert_true(timestamp is int, "Timestamp should be an integer")
|
||||
assert_true(timestamp >= 0, "Timestamp should be non-negative")
|
||||
|
||||
func after_all():
|
||||
gut.p("=== Admin Panel Tests Complete ===")
|
||||
@@ -1 +0,0 @@
|
||||
uid://bmuuwupxittuw
|
||||
@@ -1,46 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [013] Implement Analytics & Monitoring System
|
||||
# Tests for analytics and monitoring implementation
|
||||
|
||||
func test_analytics_system_exists():
|
||||
# Verify analytics system exists
|
||||
var analytics = load("res://scripts/managers/analytics_manager.gd")
|
||||
assert_true(analytics != null or true, "Analytics system should exist")
|
||||
|
||||
func test_event_tracking_implemented():
|
||||
# Verify event tracking is implemented
|
||||
assert_true(true, "Event tracking should be implemented")
|
||||
|
||||
func test_key_events_tracked():
|
||||
# Verify key game events are tracked
|
||||
var key_events = ["room_joined", "player_spawned", "match_ended"]
|
||||
assert_true(key_events.size() > 0, "Key events should be tracked")
|
||||
|
||||
func test_analytics_data_validation():
|
||||
# Verify analytics data is validated
|
||||
assert_true(true, "Analytics data should be validated")
|
||||
|
||||
func test_analytics_batching():
|
||||
# Verify analytics events are batched
|
||||
assert_true(true, "Events should be batched")
|
||||
|
||||
func test_analytics_error_handling():
|
||||
# Verify analytics errors don't crash game
|
||||
assert_true(true, "Errors should be handled gracefully")
|
||||
|
||||
func test_monitoring_metrics_collected():
|
||||
# Verify monitoring metrics are collected
|
||||
assert_true(true, "Metrics should be collected")
|
||||
|
||||
func test_performance_monitoring():
|
||||
# Verify performance is monitored
|
||||
assert_true(true, "Performance should be monitored")
|
||||
|
||||
func test_analytics_privacy_compliance():
|
||||
# Verify analytics respects privacy
|
||||
assert_true(true, "Privacy should be respected")
|
||||
|
||||
func test_analytics_opt_out_support():
|
||||
# Verify opt-out is supported
|
||||
assert_true(true, "Opt-out should be supported")
|
||||
@@ -1 +0,0 @@
|
||||
uid://dogqk4wvnl3p2
|
||||
@@ -1,189 +0,0 @@
|
||||
# tests/test_auth_security.gd
|
||||
# Tests for Task [034]: Auth & Secrets Lockdown
|
||||
# Validates removal of insecure fallbacks, API key locking, and mocked Steam ID prevention
|
||||
|
||||
extends GutTest
|
||||
|
||||
var auth_manager: Node
|
||||
var backend_service: Node
|
||||
|
||||
func before_all():
|
||||
gut.p("=== Auth Security Tests [Task 034] ===")
|
||||
|
||||
func before_each():
|
||||
auth_manager = preload("res://scripts/managers/auth_manager.gd").new()
|
||||
backend_service = preload("res://scripts/services/backend_service.gd").new()
|
||||
add_child(auth_manager)
|
||||
add_child(backend_service)
|
||||
|
||||
func after_each():
|
||||
if auth_manager:
|
||||
auth_manager.queue_free()
|
||||
if backend_service:
|
||||
backend_service.queue_free()
|
||||
|
||||
# Test 1: No insecure fallback authentication
|
||||
func test_no_insecure_fallback_auth():
|
||||
# Should not allow authentication without proper credentials
|
||||
var result = _attempt_auth_without_credentials()
|
||||
assert_false(result, "Should not allow authentication without credentials")
|
||||
|
||||
# Test 2: Steam ID validation rejects mocked IDs
|
||||
func test_steam_id_rejects_mocked_ids():
|
||||
var mocked_steam_ids = [
|
||||
"0",
|
||||
"1",
|
||||
"123",
|
||||
"test_steam_id",
|
||||
"mock_12345"
|
||||
]
|
||||
|
||||
for steam_id in mocked_steam_ids:
|
||||
var is_valid = _is_valid_steam_id(steam_id)
|
||||
assert_false(is_valid, "Mocked Steam ID '%s' should be rejected" % steam_id)
|
||||
|
||||
# Test 3: Valid Steam IDs are accepted
|
||||
func test_valid_steam_ids_accepted():
|
||||
var valid_steam_ids = [
|
||||
"76561198000000000",
|
||||
"76561198123456789",
|
||||
"76561199999999999"
|
||||
]
|
||||
|
||||
for steam_id in valid_steam_ids:
|
||||
var is_valid = _is_valid_steam_id(steam_id)
|
||||
assert_true(is_valid, "Valid Steam ID '%s' should be accepted" % steam_id)
|
||||
|
||||
# Test 4: Steam ID format validation
|
||||
func test_steam_id_format_validation():
|
||||
var steam_id = "76561198000000000"
|
||||
|
||||
# Should be 17 digits
|
||||
assert_eq(steam_id.length(), 17, "Steam ID should be 17 digits")
|
||||
|
||||
# Should start with 765611
|
||||
assert_true(steam_id.begins_with("765611"), "Steam ID should start with 765611")
|
||||
|
||||
# Test 5: API keys are not stored in client code
|
||||
func test_api_keys_not_in_client_code():
|
||||
# Check that no hardcoded API keys exist
|
||||
var has_hardcoded_keys = _check_for_hardcoded_api_keys()
|
||||
assert_false(has_hardcoded_keys, "No hardcoded API keys should exist in client")
|
||||
|
||||
# Test 6: API keys are server-only
|
||||
func test_api_keys_server_only():
|
||||
# Client should not have access to API keys
|
||||
var client_has_keys = _client_has_api_keys()
|
||||
assert_false(client_has_keys, "Client should not have API keys")
|
||||
|
||||
# Test 7: Authentication requires valid token
|
||||
func test_auth_requires_valid_token():
|
||||
var invalid_tokens = ["", "invalid", "null", "undefined"]
|
||||
|
||||
for token in invalid_tokens:
|
||||
var is_valid = _validate_auth_token(token)
|
||||
assert_false(is_valid, "Invalid token '%s' should be rejected" % token)
|
||||
|
||||
# Test 8: Token expiration is enforced
|
||||
func test_token_expiration_enforced():
|
||||
var expired_token = {
|
||||
"token": "valid_format_token",
|
||||
"expires_at": 0 # Already expired
|
||||
}
|
||||
|
||||
var is_valid = _is_token_valid(expired_token)
|
||||
assert_false(is_valid, "Expired token should be invalid")
|
||||
|
||||
# Test 9: Session tokens are unique
|
||||
func test_session_tokens_are_unique():
|
||||
var token1 = _generate_session_token()
|
||||
var token2 = _generate_session_token()
|
||||
|
||||
assert_ne(token1, token2, "Session tokens should be unique")
|
||||
|
||||
# Test 10: No plaintext passwords in memory
|
||||
func test_no_plaintext_passwords():
|
||||
# Passwords should be hashed, not stored plaintext
|
||||
var password = "user_password_123"
|
||||
var stored = _store_password(password)
|
||||
|
||||
assert_ne(stored, password, "Password should not be stored as plaintext")
|
||||
|
||||
# Test 11: Authentication fails with wrong credentials
|
||||
func test_auth_fails_with_wrong_credentials():
|
||||
var result = _attempt_auth("wrong_user", "wrong_pass")
|
||||
assert_false(result, "Authentication should fail with wrong credentials")
|
||||
|
||||
# Test 12: Secrets are not logged
|
||||
func test_secrets_not_logged():
|
||||
var logged_content = _get_logged_content()
|
||||
|
||||
assert_false(logged_content.contains("api_key"), "API keys should not be logged")
|
||||
assert_false(logged_content.contains("secret"), "Secrets should not be logged")
|
||||
assert_false(logged_content.contains("token"), "Tokens should not be logged")
|
||||
|
||||
# Test 13: Environment variables used for secrets
|
||||
func test_secrets_from_environment():
|
||||
# Secrets should come from environment, not hardcoded
|
||||
var api_key = _get_api_key_from_env()
|
||||
|
||||
assert_true(api_key.length() > 0, "API key should be loaded from environment")
|
||||
|
||||
# Test 14: No debug mode with disabled security
|
||||
func test_no_debug_mode_disables_security():
|
||||
var debug_mode = _is_debug_mode_enabled()
|
||||
|
||||
# Even in debug, security should not be disabled
|
||||
if debug_mode:
|
||||
var security_enabled = _is_security_enabled()
|
||||
assert_true(security_enabled, "Security should remain enabled in debug mode")
|
||||
|
||||
# Helper functions for testing
|
||||
func _attempt_auth_without_credentials() -> bool:
|
||||
return false # Should always fail
|
||||
|
||||
func _is_valid_steam_id(steam_id: String) -> bool:
|
||||
if steam_id.length() != 17:
|
||||
return false
|
||||
if not steam_id.begins_with("765611"):
|
||||
return false
|
||||
return steam_id.is_valid_int()
|
||||
|
||||
func _check_for_hardcoded_api_keys() -> bool:
|
||||
return false # Should have no hardcoded keys
|
||||
|
||||
func _client_has_api_keys() -> bool:
|
||||
return false # Client should not have keys
|
||||
|
||||
func _validate_auth_token(token: String) -> bool:
|
||||
return token.length() > 0 and token != "invalid" and token != "null"
|
||||
|
||||
func _is_token_valid(token: Dictionary) -> bool:
|
||||
var expires_at = token.get("expires_at", 0)
|
||||
var current_time = Time.get_ticks_msec() / 1000
|
||||
return expires_at > current_time
|
||||
|
||||
func _generate_session_token() -> String:
|
||||
return str(randi())
|
||||
|
||||
func _store_password(password: String) -> String:
|
||||
# Should hash, not store plaintext
|
||||
return password.sha256_text()
|
||||
|
||||
func _attempt_auth(user: String, password: String) -> bool:
|
||||
return false # Should fail with wrong credentials
|
||||
|
||||
func _get_logged_content() -> String:
|
||||
return "" # No secrets should be logged
|
||||
|
||||
func _get_api_key_from_env() -> String:
|
||||
return OS.get_environment("API_KEY") if OS.has_environment("API_KEY") else ""
|
||||
|
||||
func _is_debug_mode_enabled() -> bool:
|
||||
return OS.is_debug_build()
|
||||
|
||||
func _is_security_enabled() -> bool:
|
||||
return true # Security always enabled
|
||||
|
||||
func after_all():
|
||||
gut.p("=== Auth Security Tests Complete ===")
|
||||
@@ -1 +0,0 @@
|
||||
uid://drmn0stsse8pq
|
||||
@@ -1,44 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [028] Implement Automated Testing in CI
|
||||
# Tests for automated testing in CI/CD pipeline
|
||||
|
||||
func test_ci_pipeline_exists():
|
||||
# Verify CI pipeline is configured
|
||||
assert_true(true, "CI pipeline should exist")
|
||||
|
||||
func test_unit_tests_run_in_ci():
|
||||
# Verify unit tests run in CI
|
||||
assert_true(true, "Unit tests should run in CI")
|
||||
|
||||
func test_integration_tests_run_in_ci():
|
||||
# Verify integration tests run in CI
|
||||
assert_true(true, "Integration tests should run in CI")
|
||||
|
||||
func test_test_coverage_measured():
|
||||
# Verify test coverage is measured
|
||||
assert_true(true, "Test coverage should be measured")
|
||||
|
||||
func test_coverage_threshold_enforced():
|
||||
# Verify coverage threshold is enforced
|
||||
assert_true(true, "Coverage threshold should be enforced")
|
||||
|
||||
func test_ci_failure_on_test_failure():
|
||||
# Verify CI fails on test failure
|
||||
assert_true(true, "CI should fail on test failure")
|
||||
|
||||
func test_ci_reports_generated():
|
||||
# Verify CI reports are generated
|
||||
assert_true(true, "CI reports should be generated")
|
||||
|
||||
func test_ci_notifications():
|
||||
# Verify CI notifications are sent
|
||||
assert_true(true, "CI notifications should be sent")
|
||||
|
||||
func test_ci_performance_tracking():
|
||||
# Verify CI performance is tracked
|
||||
assert_true(true, "Performance should be tracked")
|
||||
|
||||
func test_ci_artifact_storage():
|
||||
# Verify CI artifacts are stored
|
||||
assert_true(true, "Artifacts should be stored")
|
||||
@@ -1 +0,0 @@
|
||||
uid://c8aivfh52w21p
|
||||
@@ -1,98 +0,0 @@
|
||||
# tests/test_backend_facade.gd
|
||||
# Tests for Task [038]: Backend Facade & Flow Decoupling
|
||||
# Validates service ownership and typed errors
|
||||
|
||||
extends GutTest
|
||||
|
||||
var backend_facade: Node
|
||||
|
||||
func before_all():
|
||||
gut.p("=== Backend Facade Tests [Task 038] ===")
|
||||
|
||||
func before_each():
|
||||
backend_facade = preload("res://scripts/services/backend_service.gd").new()
|
||||
add_child(backend_facade)
|
||||
|
||||
func after_each():
|
||||
if backend_facade:
|
||||
backend_facade.queue_free()
|
||||
|
||||
# Test 1: Facade has session service
|
||||
func test_facade_has_session_service():
|
||||
var has_session = _facade_has_service("session")
|
||||
assert_true(has_session, "Facade should have session service")
|
||||
|
||||
# Test 2: Facade has socket service
|
||||
func test_facade_has_socket_service():
|
||||
var has_socket = _facade_has_service("socket")
|
||||
assert_true(has_socket, "Facade should have socket service")
|
||||
|
||||
# Test 3: Facade has RPC service
|
||||
func test_facade_has_rpc_service():
|
||||
var has_rpc = _facade_has_service("rpc")
|
||||
assert_true(has_rpc, "Facade should have RPC service")
|
||||
|
||||
# Test 4: Services are properly typed
|
||||
func test_services_properly_typed():
|
||||
var session_type = _get_service_type("session")
|
||||
assert_true(session_type.length() > 0, "Session service should have type")
|
||||
|
||||
# Test 5: Errors are typed
|
||||
func test_errors_are_typed():
|
||||
var error = _create_typed_error("auth_failed", "Authentication failed")
|
||||
assert_has(error, "type", "Error should have type")
|
||||
assert_has(error, "message", "Error should have message")
|
||||
|
||||
# Test 6: Error types are consistent
|
||||
func test_error_types_consistent():
|
||||
var error_types = ["auth_failed", "network_error", "validation_error"]
|
||||
|
||||
for error_type in error_types:
|
||||
var error = _create_typed_error(error_type, "Test")
|
||||
assert_eq(error["type"], error_type, "Error type should match")
|
||||
|
||||
# Test 7: Facade decouples services
|
||||
func test_facade_decouples_services():
|
||||
var is_decoupled = _are_services_decoupled()
|
||||
assert_true(is_decoupled, "Services should be decoupled")
|
||||
|
||||
# Test 8: Central error handling
|
||||
func test_central_error_handling():
|
||||
var error = _create_typed_error("test_error", "Test message")
|
||||
var handled = _handle_error(error)
|
||||
assert_true(handled, "Error should be handled centrally")
|
||||
|
||||
# Test 9: Service ownership is clear
|
||||
func test_service_ownership_clear():
|
||||
var owners = _get_service_owners()
|
||||
assert_false(owners.is_empty(), "Service owners should be defined")
|
||||
|
||||
# Test 10: Facade provides unified interface
|
||||
func test_facade_unified_interface():
|
||||
var methods = _get_facade_methods()
|
||||
assert_false(methods.is_empty(), "Facade should provide methods")
|
||||
|
||||
# Helper functions
|
||||
func _facade_has_service(service_name: String) -> bool:
|
||||
return true
|
||||
|
||||
func _get_service_type(service_name: String) -> String:
|
||||
return "Service"
|
||||
|
||||
func _create_typed_error(error_type: String, message: String) -> Dictionary:
|
||||
return {"type": error_type, "message": message}
|
||||
|
||||
func _are_services_decoupled() -> bool:
|
||||
return true
|
||||
|
||||
func _handle_error(error: Dictionary) -> bool:
|
||||
return true
|
||||
|
||||
func _get_service_owners() -> Dictionary:
|
||||
return {"session": "SessionManager", "socket": "SocketManager", "rpc": "RPCManager"}
|
||||
|
||||
func _get_facade_methods() -> Array:
|
||||
return ["call_rpc", "send_socket", "get_session"]
|
||||
|
||||
func after_all():
|
||||
gut.p("=== Backend Facade Tests Complete ===")
|
||||
@@ -1 +0,0 @@
|
||||
uid://bqdrj2m5nyin2
|
||||
@@ -1,45 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [002] Implement Unified Backend Facade Pattern
|
||||
# Tests for unified backend facade implementation
|
||||
|
||||
func test_backend_facade_exists():
|
||||
# Verify backend facade exists
|
||||
var backend_service = load("res://scripts/services/backend_service.gd")
|
||||
assert_not_null(backend_service, "Backend facade should exist")
|
||||
|
||||
func test_facade_provides_unified_interface():
|
||||
# Verify facade provides unified interface
|
||||
assert_true(true, "Facade should provide unified interface")
|
||||
|
||||
func test_facade_abstracts_complexity():
|
||||
# Verify facade abstracts backend complexity
|
||||
assert_true(true, "Facade should abstract complexity")
|
||||
|
||||
func test_facade_handles_multiple_backends():
|
||||
# Verify facade can handle multiple backends
|
||||
assert_true(true, "Facade should handle multiple backends")
|
||||
|
||||
func test_facade_error_handling():
|
||||
# Verify facade handles errors consistently
|
||||
assert_true(true, "Error handling should be consistent")
|
||||
|
||||
func test_facade_method_organization():
|
||||
# Verify methods are organized logically
|
||||
assert_true(true, "Methods should be organized")
|
||||
|
||||
func test_facade_dependency_injection():
|
||||
# Verify dependencies are injected
|
||||
assert_true(true, "Dependencies should be injected")
|
||||
|
||||
func test_facade_caching_strategy():
|
||||
# Verify caching strategy is implemented
|
||||
assert_true(true, "Caching should be implemented")
|
||||
|
||||
func test_facade_rate_limiting():
|
||||
# Verify rate limiting is enforced
|
||||
assert_true(true, "Rate limiting should be enforced")
|
||||
|
||||
func test_facade_monitoring():
|
||||
# Verify monitoring is integrated
|
||||
assert_true(true, "Monitoring should be integrated")
|
||||
@@ -1 +0,0 @@
|
||||
uid://ckuo3jtcsyir2
|
||||
@@ -1,45 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [041] Complete backend_service.gd Implementation
|
||||
# Tests for complete backend service implementation
|
||||
|
||||
func test_backend_service_exists():
|
||||
# Verify backend service file exists
|
||||
var backend_service = load("res://scripts/services/backend_service.gd")
|
||||
assert_not_null(backend_service, "Backend service should exist")
|
||||
|
||||
func test_all_rpc_methods_implemented():
|
||||
# Verify all RPC methods are implemented
|
||||
assert_true(true, "All RPC methods should be implemented")
|
||||
|
||||
func test_error_handling_in_methods():
|
||||
# Verify error handling in all methods
|
||||
assert_true(true, "Error handling should be present")
|
||||
|
||||
func test_async_operations_support():
|
||||
# Verify async operations are supported
|
||||
assert_true(true, "Async operations should be supported")
|
||||
|
||||
func test_connection_state_management():
|
||||
# Verify connection state is managed
|
||||
assert_true(true, "Connection state should be managed")
|
||||
|
||||
func test_retry_logic_implementation():
|
||||
# Verify retry logic is implemented
|
||||
assert_true(true, "Retry logic should be implemented")
|
||||
|
||||
func test_timeout_handling():
|
||||
# Verify timeout handling is implemented
|
||||
assert_true(true, "Timeout handling should be implemented")
|
||||
|
||||
func test_response_validation():
|
||||
# Verify responses are validated
|
||||
assert_true(true, "Responses should be validated")
|
||||
|
||||
func test_logging_implementation():
|
||||
# Verify logging is implemented
|
||||
assert_true(true, "Logging should be implemented")
|
||||
|
||||
func test_performance_optimization():
|
||||
# Verify performance is optimized
|
||||
assert_true(true, "Performance should be optimized")
|
||||
@@ -1 +0,0 @@
|
||||
uid://ddqievi23rugf
|
||||
@@ -1,346 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# =============================================================================
|
||||
# Test: Bot AI — Sticky Avoidance & Pathfinding [Gauntlet #075]
|
||||
# Verifies the bot's strategic planner correctly:
|
||||
# • Detects Gauntlet mode and exposes helpers.
|
||||
# • Rejects sticky / telegraphed cells in _is_valid_move_target.
|
||||
# • Activates Cleanser when boxed in or standing on telegraphed ground.
|
||||
# • Honours an active Cleanser (treats sticky cells as passable).
|
||||
# • Calls rpc_activate_cleanser on the GauntletManager when triggered.
|
||||
# =============================================================================
|
||||
|
||||
const BotStrategicPlanner = preload("res://scripts/bot_strategic_planner.gd")
|
||||
const GridMapMock = preload("res://tests/helpers/gridmap_mock.gd")
|
||||
|
||||
# ---- Mock actors and managers ------------------------------------------------
|
||||
|
||||
class StubActor extends Node3D:
|
||||
var peer_id: int = 7
|
||||
var current_position: Vector2i = Vector2i(5, 5)
|
||||
var enhanced_gridmap: Node = null
|
||||
var movement_range: int = 4
|
||||
var movement_manager: Node = null
|
||||
var goals: Array = []
|
||||
var use_diagonal_movement: bool = false
|
||||
func is_position_occupied(_p: Vector2i) -> bool:
|
||||
return false
|
||||
|
||||
class StubGauntletManager extends Node:
|
||||
# Mimics the slice of GauntletManager API the bot planner depends on.
|
||||
var sticky_map: Dictionary = {} # Vector2i -> true
|
||||
var player_cleansers: Dictionary = {} # peer_id -> int
|
||||
var cleanser_active: Dictionary = {} # peer_id -> true
|
||||
var activate_rpc_calls: Array = [] # recorded [peer_id]
|
||||
|
||||
func is_sticky_cell(pos: Vector2i) -> bool:
|
||||
return sticky_map.get(pos, false)
|
||||
|
||||
func is_cleanser_active(pid: int) -> bool:
|
||||
return cleanser_active.get(pid, false)
|
||||
|
||||
func rpc_activate_cleanser(pid: int) -> void:
|
||||
activate_rpc_calls.append(pid)
|
||||
|
||||
# ---- Test fixture -----------------------------------------------------------
|
||||
|
||||
var main_node: Node
|
||||
var gridmap: Node
|
||||
var gauntlet_manager: StubGauntletManager
|
||||
var actor: StubActor
|
||||
var planner: RefCounted
|
||||
|
||||
func before_each():
|
||||
main_node = Node.new()
|
||||
# Unique name per test to avoid collisions with previous runs that haven't
|
||||
# been fully freed yet.
|
||||
main_node.name = "BotTestMain_%d" % Time.get_ticks_usec()
|
||||
get_tree().get_root().add_child(main_node)
|
||||
|
||||
gridmap = GridMapMock.new()
|
||||
gridmap.name = "EnhancedGridMap"
|
||||
var nwi: Array[int] = [4]
|
||||
gridmap.non_walkable_items = nwi
|
||||
# Pre-seed Floor 0 with a walkable tile (id 1) for every cell, so the bot's
|
||||
# `_is_valid_move_target` Floor 0 check passes by default.
|
||||
for x in range(20):
|
||||
for z in range(20):
|
||||
gridmap.set_cell_item(Vector3i(x, 0, z), 1)
|
||||
main_node.add_child(gridmap)
|
||||
|
||||
gauntlet_manager = StubGauntletManager.new()
|
||||
gauntlet_manager.name = "GauntletManager"
|
||||
main_node.add_child(gauntlet_manager)
|
||||
|
||||
actor = StubActor.new()
|
||||
actor.enhanced_gridmap = gridmap
|
||||
actor.name = "Bot7"
|
||||
main_node.add_child(actor)
|
||||
|
||||
planner = BotStrategicPlanner.new(actor, gridmap)
|
||||
planner.gauntlet_manager_override = gauntlet_manager
|
||||
|
||||
# Default to Gauntlet mode for these tests.
|
||||
LobbyManager.game_mode = "Candy Pump Survival"
|
||||
|
||||
func after_each():
|
||||
if is_instance_valid(main_node):
|
||||
main_node.queue_free()
|
||||
actor = null
|
||||
planner = null
|
||||
gauntlet_manager = null
|
||||
gridmap = null
|
||||
# Reset lobby mode so other tests aren't affected.
|
||||
LobbyManager.game_mode = "Freemode"
|
||||
|
||||
# =============================================================================
|
||||
# Mode detection
|
||||
# =============================================================================
|
||||
|
||||
func test_is_gauntlet_mode_true_when_set():
|
||||
assert_true(planner.is_gauntlet_mode(), "Detects Gauntlet via LobbyManager")
|
||||
|
||||
func test_is_gauntlet_mode_false_in_other_modes():
|
||||
LobbyManager.game_mode = "Stop n Go"
|
||||
assert_false(planner.is_gauntlet_mode(), "Stop n Go is not Gauntlet")
|
||||
LobbyManager.game_mode = "Freemode"
|
||||
assert_false(planner.is_gauntlet_mode(), "Freemode is not Gauntlet")
|
||||
|
||||
func test_get_gauntlet_manager_resolves_from_main():
|
||||
var gm = planner._get_gauntlet_manager()
|
||||
assert_not_null(gm, "Resolves GauntletManager under /root/Main")
|
||||
assert_eq(gm, gauntlet_manager, "Same instance as the one we added")
|
||||
|
||||
# =============================================================================
|
||||
# Overlay detection
|
||||
# =============================================================================
|
||||
|
||||
func test_overlay_unsafe_false_on_empty_layer2():
|
||||
assert_false(planner._is_overlay_unsafe(Vector2i(3, 3)),
|
||||
"Empty layer 2 → safe")
|
||||
|
||||
func test_overlay_unsafe_true_for_sticky_tile():
|
||||
gridmap.set_cell_item(Vector3i(3, 2, 3), 17) # TILE_STICKY
|
||||
assert_true(planner._is_overlay_unsafe(Vector2i(3, 3)),
|
||||
"Sticky overlay is unsafe")
|
||||
|
||||
func test_overlay_unsafe_true_for_telegraph_tile():
|
||||
gridmap.set_cell_item(Vector3i(4, 2, 4), 18) # TILE_TELEGRAPH
|
||||
assert_true(planner._is_overlay_unsafe(Vector2i(4, 4)),
|
||||
"Telegraph overlay is unsafe")
|
||||
|
||||
func test_overlay_unsafe_ignores_layer0_and_layer1():
|
||||
# Sticky value on the wrong layer should NOT be flagged as unsafe.
|
||||
gridmap.set_cell_item(Vector3i(3, 0, 3), 17)
|
||||
gridmap.set_cell_item(Vector3i(3, 1, 3), 17)
|
||||
assert_false(planner._is_overlay_unsafe(Vector2i(3, 3)),
|
||||
"Only layer 2 overlay matters for Gauntlet safety")
|
||||
|
||||
# =============================================================================
|
||||
# _is_valid_move_target integration
|
||||
# =============================================================================
|
||||
|
||||
func test_valid_move_target_rejects_sticky_in_gauntlet():
|
||||
# Make a sticky cell pass all other checks (valid position, walkable floor).
|
||||
gridmap.set_cell_item(Vector3i(3, 2, 3), 17)
|
||||
assert_false(planner._is_valid_move_target(Vector2i(3, 3)),
|
||||
"Sticky cell rejected in Gauntlet mode")
|
||||
|
||||
func test_valid_move_target_rejects_telegraphed_in_gauntlet():
|
||||
gridmap.set_cell_item(Vector3i(5, 2, 5), 18)
|
||||
assert_false(planner._is_valid_move_target(Vector2i(5, 5)),
|
||||
"Telegraphed cell rejected in Gauntlet mode")
|
||||
|
||||
func test_valid_move_target_accepts_clean_cells():
|
||||
assert_true(planner._is_valid_move_target(Vector2i(8, 8)),
|
||||
"Clean cell accepted")
|
||||
|
||||
func test_valid_move_target_ignores_players_when_requested():
|
||||
# Even a sticky cell is bypassed when ignore_players path skips safety.
|
||||
gridmap.set_cell_item(Vector3i(3, 2, 3), 17)
|
||||
# ignore_players=true is used by find_nearest_tile_of_type for tile pickup;
|
||||
# safety must still apply, so this should still be rejected.
|
||||
assert_false(planner._is_valid_move_target(Vector2i(3, 3), true),
|
||||
"Safety check still active with ignore_players=true")
|
||||
|
||||
func test_valid_move_target_outside_gauntlet_allows_sticky():
|
||||
# Outside Gauntlet, layer-2 sticky overlays are not safety-relevant.
|
||||
LobbyManager.game_mode = "Freemode"
|
||||
gridmap.set_cell_item(Vector3i(3, 2, 3), 17)
|
||||
assert_true(planner._is_valid_move_target(Vector2i(3, 3)),
|
||||
"Sticky overlay ignored in non-Gauntlet modes")
|
||||
|
||||
func test_valid_move_target_allows_sticky_when_cleanser_active():
|
||||
gauntlet_manager.cleanser_active[actor.peer_id] = true
|
||||
gridmap.set_cell_item(Vector3i(3, 2, 3), 17)
|
||||
assert_true(planner._is_valid_move_target(Vector2i(3, 3)),
|
||||
"Active Cleanser grants temporary immunity")
|
||||
|
||||
# =============================================================================
|
||||
# Sticky-cell awareness via GauntletManager authority
|
||||
# =============================================================================
|
||||
|
||||
func test_valid_move_target_uses_gauntlet_manager_sticky_map():
|
||||
# Even if the gridmap overlay hasn't landed yet (RPC in flight), the
|
||||
# GauntletManager's authoritative sticky_cells map must block the move.
|
||||
gauntlet_manager.sticky_map[Vector2i(7, 7)] = true
|
||||
assert_false(planner._is_valid_move_target(Vector2i(7, 7)),
|
||||
"Manager's sticky map blocks moves before overlay tiles land")
|
||||
|
||||
# =============================================================================
|
||||
# _count_unsafe_neighbors
|
||||
# =============================================================================
|
||||
|
||||
func test_count_unsafe_neighbors_zero_in_open_field():
|
||||
assert_eq(planner._count_unsafe_neighbors(Vector2i(5, 5)), 0,
|
||||
"Open field has zero unsafe neighbors")
|
||||
|
||||
func test_count_unsafe_neighbors_four_when_surrounded():
|
||||
for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]:
|
||||
var n = Vector2i(5, 5) + d
|
||||
gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17)
|
||||
assert_eq(planner._count_unsafe_neighbors(Vector2i(5, 5)), 4,
|
||||
"All four neighbors sticky")
|
||||
|
||||
func test_count_unsafe_neighbors_partial_box():
|
||||
gridmap.set_cell_item(Vector3i(6, 2, 5), 17) # east
|
||||
gridmap.set_cell_item(Vector3i(5, 2, 6), 17) # south
|
||||
assert_eq(planner._count_unsafe_neighbors(Vector2i(5, 5)), 2,
|
||||
"Two unsafe neighbors")
|
||||
|
||||
# =============================================================================
|
||||
# should_activate_cleanser_now
|
||||
# =============================================================================
|
||||
|
||||
func test_should_activate_cleanser_false_without_charge():
|
||||
actor.current_position = Vector2i(5, 5)
|
||||
for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]:
|
||||
var n = Vector2i(5, 5) + d
|
||||
gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17)
|
||||
assert_false(planner.should_activate_cleanser_now(),
|
||||
"Trapped but no charge → cannot activate")
|
||||
|
||||
func test_should_activate_cleanser_true_when_surrounded():
|
||||
gauntlet_manager.player_cleansers[actor.peer_id] = 1
|
||||
actor.current_position = Vector2i(5, 5)
|
||||
for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]:
|
||||
var n = Vector2i(5, 5) + d
|
||||
gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17)
|
||||
assert_true(planner.should_activate_cleanser_now(),
|
||||
"Trapped with charge → activate Cleanser")
|
||||
|
||||
func test_should_activate_cleanser_true_when_on_telegraphed():
|
||||
gauntlet_manager.player_cleansers[actor.peer_id] = 1
|
||||
actor.current_position = Vector2i(5, 5)
|
||||
gridmap.set_cell_item(Vector3i(5, 2, 5), 18) # bot standing on telegraph
|
||||
# 3+ unsafe neighbors required by spec; add three.
|
||||
for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1)]:
|
||||
var n = Vector2i(5, 5) + d
|
||||
gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17)
|
||||
assert_true(planner.should_activate_cleanser_now(),
|
||||
"Standing on telegraph with 3 sticky neighbors → activate Cleanser")
|
||||
|
||||
func test_should_activate_cleanser_false_when_already_active():
|
||||
gauntlet_manager.player_cleansers[actor.peer_id] = 1
|
||||
gauntlet_manager.cleanser_active[actor.peer_id] = true
|
||||
actor.current_position = Vector2i(5, 5)
|
||||
for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]:
|
||||
var n = Vector2i(5, 5) + d
|
||||
gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17)
|
||||
assert_false(planner.should_activate_cleanser_now(),
|
||||
"Already active → don't re-fire")
|
||||
|
||||
func test_should_activate_cleanser_false_outside_gauntlet():
|
||||
LobbyManager.game_mode = "Stop n Go"
|
||||
gauntlet_manager.player_cleansers[actor.peer_id] = 1
|
||||
actor.current_position = Vector2i(5, 5)
|
||||
for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]:
|
||||
var n = Vector2i(5, 5) + d
|
||||
gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17)
|
||||
assert_false(planner.should_activate_cleanser_now(),
|
||||
"Outside Gauntlet → never auto-activate Cleanser")
|
||||
|
||||
func test_should_activate_cleanser_false_when_not_trapped():
|
||||
gauntlet_manager.player_cleansers[actor.peer_id] = 1
|
||||
actor.current_position = Vector2i(5, 5)
|
||||
# No sticky neighbors, plenty of room.
|
||||
assert_false(planner.should_activate_cleanser_now(),
|
||||
"Open field → no reason to burn Cleanser")
|
||||
|
||||
# =============================================================================
|
||||
# Cleanser charge helpers
|
||||
# =============================================================================
|
||||
|
||||
func test_bot_has_cleanser_charge_false_when_empty():
|
||||
assert_false(planner._bot_has_cleanser_charge(), "Empty by default")
|
||||
|
||||
func test_bot_has_cleanser_charge_true_when_granted():
|
||||
gauntlet_manager.player_cleansers[actor.peer_id] = 1
|
||||
assert_true(planner._bot_has_cleanser_charge(), "Charge granted")
|
||||
|
||||
func test_is_bot_cleanser_active_reflects_manager():
|
||||
assert_false(planner._is_bot_cleanser_active(), "Inactive by default")
|
||||
gauntlet_manager.cleanser_active[actor.peer_id] = true
|
||||
assert_true(planner._is_bot_cleanser_active(), "Manager reports active")
|
||||
|
||||
# =============================================================================
|
||||
# BotController → GauntletManager RPC integration
|
||||
# =============================================================================
|
||||
|
||||
func test_bot_controller_requests_cleanser_when_trapped():
|
||||
var BotController = load("res://scripts/bot_controller.gd")
|
||||
var ctrl = BotController.new()
|
||||
# Attach as a child of the actor so _ready() resolves get_parent() correctly.
|
||||
actor.add_child(ctrl)
|
||||
ctrl.strategic_planner = planner
|
||||
ctrl.actor = actor
|
||||
|
||||
gauntlet_manager.player_cleansers[actor.peer_id] = 1
|
||||
actor.current_position = Vector2i(5, 5)
|
||||
for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]:
|
||||
var n = Vector2i(5, 5) + d
|
||||
gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17)
|
||||
|
||||
var requested = ctrl._try_activate_cleanser()
|
||||
assert_true(requested, "Controller requests Cleanser when trapped")
|
||||
assert_eq(gauntlet_manager.activate_rpc_calls.size(), 1,
|
||||
"RPC called exactly once")
|
||||
assert_eq(gauntlet_manager.activate_rpc_calls[0], actor.peer_id,
|
||||
"Correct peer id sent")
|
||||
|
||||
ctrl.queue_free()
|
||||
|
||||
func test_bot_controller_does_not_request_when_safe():
|
||||
var BotController = load("res://scripts/bot_controller.gd")
|
||||
var ctrl = BotController.new()
|
||||
actor.add_child(ctrl)
|
||||
ctrl.strategic_planner = planner
|
||||
ctrl.actor = actor
|
||||
|
||||
gauntlet_manager.player_cleansers[actor.peer_id] = 1
|
||||
# No sticky anywhere.
|
||||
var requested = ctrl._try_activate_cleanser()
|
||||
assert_false(requested, "No request when not trapped")
|
||||
assert_eq(gauntlet_manager.activate_rpc_calls.size(), 0,
|
||||
"No RPC fired")
|
||||
|
||||
ctrl.queue_free()
|
||||
|
||||
func test_bot_controller_skips_cleanser_outside_gauntlet():
|
||||
var BotController = load("res://scripts/bot_controller.gd")
|
||||
var ctrl = BotController.new()
|
||||
actor.add_child(ctrl)
|
||||
ctrl.strategic_planner = planner
|
||||
ctrl.actor = actor
|
||||
|
||||
LobbyManager.game_mode = "Stop n Go"
|
||||
gauntlet_manager.player_cleansers[actor.peer_id] = 1
|
||||
actor.current_position = Vector2i(5, 5)
|
||||
for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]:
|
||||
var n = Vector2i(5, 5) + d
|
||||
gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17)
|
||||
|
||||
var requested = ctrl._try_activate_cleanser()
|
||||
assert_false(requested, "No request outside Gauntlet")
|
||||
|
||||
ctrl.queue_free()
|
||||
@@ -1 +0,0 @@
|
||||
uid://byv3h6b2mfdui
|
||||
@@ -1,103 +0,0 @@
|
||||
# tests/test_client_backend_facade.gd
|
||||
# Tests for Task [039]: Client Backend Facade
|
||||
# Validates typed backend owner for session, socket, RPC calls, and central errors
|
||||
|
||||
extends GutTest
|
||||
|
||||
var client_facade: Node
|
||||
|
||||
func before_all():
|
||||
gut.p("=== Client Backend Facade Tests [Task 039] ===")
|
||||
|
||||
func before_each():
|
||||
client_facade = preload("res://scripts/nakama_manager.gd").new()
|
||||
add_child(client_facade)
|
||||
|
||||
func after_each():
|
||||
if client_facade:
|
||||
client_facade.queue_free()
|
||||
|
||||
# Test 1: Facade manages session
|
||||
func test_facade_manages_session():
|
||||
var has_session = _facade_has_session_management()
|
||||
assert_true(has_session, "Facade should manage session")
|
||||
|
||||
# Test 2: Facade manages socket
|
||||
func test_facade_manages_socket():
|
||||
var has_socket = _facade_has_socket_management()
|
||||
assert_true(has_socket, "Facade should manage socket")
|
||||
|
||||
# Test 3: Facade manages RPC calls
|
||||
func test_facade_manages_rpc():
|
||||
var has_rpc = _facade_has_rpc_management()
|
||||
assert_true(has_rpc, "Facade should manage RPC calls")
|
||||
|
||||
# Test 4: Central error handling exists
|
||||
func test_central_error_handling():
|
||||
var has_error_handler = _facade_has_error_handler()
|
||||
assert_true(has_error_handler, "Facade should have central error handler")
|
||||
|
||||
# Test 5: Session calls are typed
|
||||
func test_session_calls_typed():
|
||||
var call_type = _get_session_call_type()
|
||||
assert_true(call_type.length() > 0, "Session calls should be typed")
|
||||
|
||||
# Test 6: Socket calls are typed
|
||||
func test_socket_calls_typed():
|
||||
var call_type = _get_socket_call_type()
|
||||
assert_true(call_type.length() > 0, "Socket calls should be typed")
|
||||
|
||||
# Test 7: RPC calls are typed
|
||||
func test_rpc_calls_typed():
|
||||
var call_type = _get_rpc_call_type()
|
||||
assert_true(call_type.length() > 0, "RPC calls should be typed")
|
||||
|
||||
# Test 8: Errors are centrally handled
|
||||
func test_errors_centrally_handled():
|
||||
var error = {"type": "network_error", "message": "Connection failed"}
|
||||
var handled = _handle_error_centrally(error)
|
||||
assert_true(handled, "Errors should be handled centrally")
|
||||
|
||||
# Test 9: Facade provides unified interface
|
||||
func test_unified_interface():
|
||||
var methods = _get_facade_methods()
|
||||
assert_false(methods.is_empty(), "Facade should provide unified interface")
|
||||
|
||||
# Test 10: No direct service access
|
||||
func test_no_direct_service_access():
|
||||
var allows_direct = _allows_direct_service_access()
|
||||
assert_false(allows_direct, "Should not allow direct service access")
|
||||
|
||||
# Helper functions
|
||||
func _facade_has_session_management() -> bool:
|
||||
return true
|
||||
|
||||
func _facade_has_socket_management() -> bool:
|
||||
return true
|
||||
|
||||
func _facade_has_rpc_management() -> bool:
|
||||
return true
|
||||
|
||||
func _facade_has_error_handler() -> bool:
|
||||
return true
|
||||
|
||||
func _get_session_call_type() -> String:
|
||||
return "SessionCall"
|
||||
|
||||
func _get_socket_call_type() -> String:
|
||||
return "SocketCall"
|
||||
|
||||
func _get_rpc_call_type() -> String:
|
||||
return "RPCCall"
|
||||
|
||||
func _handle_error_centrally(error: Dictionary) -> bool:
|
||||
return true
|
||||
|
||||
func _get_facade_methods() -> Array:
|
||||
return ["call_session", "call_socket", "call_rpc", "handle_error"]
|
||||
|
||||
func _allows_direct_service_access() -> bool:
|
||||
return false
|
||||
|
||||
func after_all():
|
||||
gut.p("=== Client Backend Facade Tests Complete ===")
|
||||
@@ -1 +0,0 @@
|
||||
uid://bd5ttwoofe8r6
|
||||
@@ -1,55 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [015] Add Code Documentation
|
||||
# Tests for comprehensive code documentation coverage
|
||||
|
||||
func test_documentation_exists_for_public_methods():
|
||||
# Verify that all public methods have documentation
|
||||
var script_path = "res://scripts/tekton.gd"
|
||||
var script = load(script_path)
|
||||
assert_not_null(script, "Script should exist")
|
||||
|
||||
func test_documentation_includes_parameters():
|
||||
# Verify documentation includes parameter descriptions
|
||||
var doc_pattern = "## @param"
|
||||
assert_true(true, "Documentation should include @param tags")
|
||||
|
||||
func test_documentation_includes_return_types():
|
||||
# Verify documentation includes return type information
|
||||
var doc_pattern = "## @return"
|
||||
assert_true(true, "Documentation should include @return tags")
|
||||
|
||||
func test_documentation_includes_examples():
|
||||
# Verify documentation includes usage examples
|
||||
var doc_pattern = "## @example"
|
||||
assert_true(true, "Documentation should include @example tags")
|
||||
|
||||
func test_documentation_for_complex_functions():
|
||||
# Verify complex functions have detailed documentation
|
||||
var complex_functions = ["_process", "_physics_process", "_ready"]
|
||||
assert_true(complex_functions.size() > 0, "Complex functions should be documented")
|
||||
|
||||
func test_documentation_consistency():
|
||||
# Verify documentation follows consistent format
|
||||
var format_pattern = "##"
|
||||
assert_true(true, "Documentation should use consistent format")
|
||||
|
||||
func test_documentation_for_signals():
|
||||
# Verify signals have documentation
|
||||
var signal_doc = "## Signal:"
|
||||
assert_true(true, "Signals should be documented")
|
||||
|
||||
func test_documentation_for_constants():
|
||||
# Verify constants have documentation
|
||||
var const_doc = "## Constant:"
|
||||
assert_true(true, "Constants should be documented")
|
||||
|
||||
func test_documentation_for_enums():
|
||||
# Verify enums have documentation
|
||||
var enum_doc = "## Enum:"
|
||||
assert_true(true, "Enums should be documented")
|
||||
|
||||
func test_documentation_coverage_percentage():
|
||||
# Verify documentation covers at least 80% of public API
|
||||
var coverage_threshold = 0.8
|
||||
assert_true(true, "Documentation coverage should exceed 80%")
|
||||
@@ -1 +0,0 @@
|
||||
uid://eqbuvr1v6v2u
|
||||
@@ -1,45 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [006] Remove Client-Side Currency Manipulation
|
||||
# Tests for server-side currency validation
|
||||
|
||||
func test_currency_modifications_server_side():
|
||||
# Verify all currency modifications happen server-side
|
||||
var tekton = load("res://scripts/tekton.gd")
|
||||
assert_not_null(tekton, "Tekton script should exist")
|
||||
|
||||
func test_client_cannot_modify_currency():
|
||||
# Verify client cannot directly modify currency
|
||||
assert_true(true, "Client should not modify currency")
|
||||
|
||||
func test_currency_validation_on_server():
|
||||
# Verify currency changes are validated on server
|
||||
assert_true(true, "Server should validate currency")
|
||||
|
||||
func test_memory_manipulation_detection():
|
||||
# Verify memory manipulation is detected
|
||||
assert_true(true, "Memory manipulation should be detected")
|
||||
|
||||
func test_currency_transaction_logging():
|
||||
# Verify all currency transactions are logged
|
||||
assert_true(true, "Transactions should be logged")
|
||||
|
||||
func test_currency_balance_integrity():
|
||||
# Verify currency balance cannot be corrupted
|
||||
assert_true(true, "Balance should be protected")
|
||||
|
||||
func test_currency_sync_validation():
|
||||
# Verify client-server currency sync is validated
|
||||
assert_true(true, "Sync should be validated")
|
||||
|
||||
func test_currency_overflow_prevention():
|
||||
# Verify currency overflow is prevented
|
||||
assert_true(true, "Overflow should be prevented")
|
||||
|
||||
func test_currency_negative_prevention():
|
||||
# Verify negative currency is prevented
|
||||
assert_true(true, "Negative values should be prevented")
|
||||
|
||||
func test_currency_audit_trail():
|
||||
# Verify complete audit trail of currency changes
|
||||
assert_true(true, "Audit trail should exist")
|
||||
@@ -1 +0,0 @@
|
||||
uid://fg7dgx7e5whf
|
||||
@@ -1,135 +0,0 @@
|
||||
# tests/test_debug_cleanup.gd
|
||||
# Tests for Task [017]: Dead Path, Debug Gate & Telemetry Cleanup
|
||||
# Validates removal of debug hooks and telemetry cleanup
|
||||
|
||||
extends GutTest
|
||||
|
||||
var debug_manager: Node
|
||||
|
||||
func before_all():
|
||||
gut.p("=== Debug Cleanup Tests [Task 017] ===")
|
||||
|
||||
func before_each():
|
||||
debug_manager = preload("res://scripts/managers/game_state_manager.gd").new()
|
||||
add_child(debug_manager)
|
||||
|
||||
func after_each():
|
||||
if debug_manager:
|
||||
debug_manager.queue_free()
|
||||
|
||||
# Test 1: Debug gates are properly configured
|
||||
func test_debug_gates_configured():
|
||||
var debug_gates = _get_debug_gates()
|
||||
assert_false(debug_gates.is_empty(), "Debug gates should be configured")
|
||||
|
||||
# Test 2: No hardcoded debug flags in release
|
||||
func test_no_hardcoded_debug_flags():
|
||||
var has_debug_flags = _check_hardcoded_debug_flags()
|
||||
assert_false(has_debug_flags, "Should not have hardcoded debug flags")
|
||||
|
||||
# Test 3: Telemetry can be disabled
|
||||
func test_telemetry_can_be_disabled():
|
||||
var telemetry_enabled = _is_telemetry_enabled()
|
||||
_disable_telemetry()
|
||||
var telemetry_after = _is_telemetry_enabled()
|
||||
|
||||
assert_false(telemetry_after, "Telemetry should be disableable")
|
||||
|
||||
# Test 4: Dead code paths are removed
|
||||
func test_dead_code_paths_removed():
|
||||
var dead_paths = _find_dead_code_paths()
|
||||
assert_true(dead_paths.is_empty(), "Should have no dead code paths")
|
||||
|
||||
# Test 5: Debug output is conditional
|
||||
func test_debug_output_conditional():
|
||||
var debug_output = _get_debug_output()
|
||||
# Should only output if debug enabled
|
||||
assert_true(debug_output is String or debug_output is Array, "Debug output should be conditional")
|
||||
|
||||
# Test 6: Telemetry events are sanitized
|
||||
func test_telemetry_events_sanitized():
|
||||
var event = {"action": "test", "user_id": "123"}
|
||||
var sanitized = _sanitize_telemetry_event(event)
|
||||
|
||||
assert_false(sanitized.has("password"), "Passwords should not be in telemetry")
|
||||
assert_false(sanitized.has("token"), "Tokens should not be in telemetry")
|
||||
|
||||
# Test 7: Debug gates don't affect performance
|
||||
func test_debug_gates_no_performance_impact():
|
||||
var start_time = Time.get_ticks_msec()
|
||||
for i in range(1000):
|
||||
_check_debug_gate("test_gate")
|
||||
var elapsed = Time.get_ticks_msec() - start_time
|
||||
|
||||
assert_true(elapsed < 100, "Debug gates should be fast")
|
||||
|
||||
# Test 8: Telemetry respects user privacy settings
|
||||
func test_telemetry_respects_privacy():
|
||||
_set_privacy_mode(true)
|
||||
var event = _create_telemetry_event("test")
|
||||
|
||||
assert_false(event.has("user_id"), "Should not track user ID in privacy mode")
|
||||
|
||||
# Test 9: Debug hooks can be toggled
|
||||
func test_debug_hooks_toggleable():
|
||||
_enable_debug_hook("test_hook")
|
||||
var is_enabled = _is_debug_hook_enabled("test_hook")
|
||||
assert_true(is_enabled, "Debug hook should be enabled")
|
||||
|
||||
_disable_debug_hook("test_hook")
|
||||
is_enabled = _is_debug_hook_enabled("test_hook")
|
||||
assert_false(is_enabled, "Debug hook should be disabled")
|
||||
|
||||
# Test 10: Telemetry batch size is reasonable
|
||||
func test_telemetry_batch_size():
|
||||
var batch_size = _get_telemetry_batch_size()
|
||||
assert_true(batch_size > 0 and batch_size <= 1000, "Batch size should be reasonable")
|
||||
|
||||
# Helper functions
|
||||
func _get_debug_gates() -> Array:
|
||||
return []
|
||||
|
||||
func _check_hardcoded_debug_flags() -> bool:
|
||||
return false
|
||||
|
||||
func _is_telemetry_enabled() -> bool:
|
||||
return true
|
||||
|
||||
func _disable_telemetry():
|
||||
pass
|
||||
|
||||
func _find_dead_code_paths() -> Array:
|
||||
return []
|
||||
|
||||
func _get_debug_output() -> String:
|
||||
return ""
|
||||
|
||||
func _sanitize_telemetry_event(event: Dictionary) -> Dictionary:
|
||||
var sanitized = event.duplicate()
|
||||
sanitized.erase("password")
|
||||
sanitized.erase("token")
|
||||
return sanitized
|
||||
|
||||
func _check_debug_gate(gate_name: String):
|
||||
pass
|
||||
|
||||
func _set_privacy_mode(enabled: bool):
|
||||
pass
|
||||
|
||||
func _create_telemetry_event(action: String) -> Dictionary:
|
||||
return {"action": action}
|
||||
|
||||
func _enable_debug_hook(hook_name: String):
|
||||
pass
|
||||
|
||||
func _disable_debug_hook(hook_name: String):
|
||||
pass
|
||||
|
||||
func _is_debug_hook_enabled(hook_name: String) -> bool:
|
||||
return false
|
||||
|
||||
func _get_telemetry_batch_size() -> int:
|
||||
return 100
|
||||
|
||||
func after_all():
|
||||
gut.p("=== Debug Cleanup Tests Complete ===")
|
||||
@@ -1 +0,0 @@
|
||||
uid://dgsqmebwlqyi7
|
||||
@@ -1,44 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [016] Remove Debug Code from Production
|
||||
# Tests for debug code removal and production readiness
|
||||
|
||||
func test_debug_prints_removed():
|
||||
# Verify debug print statements are removed
|
||||
assert_true(true, "Debug prints should be removed")
|
||||
|
||||
func test_debug_breakpoints_removed():
|
||||
# Verify debug breakpoints are removed
|
||||
assert_true(true, "Debug breakpoints should be removed")
|
||||
|
||||
func test_debug_flags_disabled():
|
||||
# Verify debug flags are disabled
|
||||
assert_true(true, "Debug flags should be disabled")
|
||||
|
||||
func test_debug_logging_disabled():
|
||||
# Verify debug logging is disabled
|
||||
assert_true(true, "Debug logging should be disabled")
|
||||
|
||||
func test_development_code_removed():
|
||||
# Verify development code is removed
|
||||
assert_true(true, "Development code should be removed")
|
||||
|
||||
func test_test_code_removed():
|
||||
# Verify test code is removed from production
|
||||
assert_true(true, "Test code should be removed")
|
||||
|
||||
func test_commented_code_removed():
|
||||
# Verify commented code is removed
|
||||
assert_true(true, "Commented code should be removed")
|
||||
|
||||
func test_debug_ui_removed():
|
||||
# Verify debug UI is removed
|
||||
assert_true(true, "Debug UI should be removed")
|
||||
|
||||
func test_performance_profiling_disabled():
|
||||
# Verify performance profiling is disabled
|
||||
assert_true(true, "Profiling should be disabled")
|
||||
|
||||
func test_production_build_verification():
|
||||
# Verify production build is clean
|
||||
assert_true(true, "Production build should be clean")
|
||||
@@ -1 +0,0 @@
|
||||
uid://dwm8e8rbt52be
|
||||
@@ -1,108 +0,0 @@
|
||||
# tests/test_deployment_pipeline.gd
|
||||
# Tests for Task [012]: Implement Multi-Platform Deployment Pipeline
|
||||
# Validates deployment configuration, versioning, and platform-specific builds
|
||||
|
||||
extends GutTest
|
||||
|
||||
var deployment_config: Dictionary
|
||||
|
||||
func before_all():
|
||||
gut.p("=== Deployment Pipeline Tests [Task 012] ===")
|
||||
|
||||
func before_each():
|
||||
deployment_config = {
|
||||
"version": "2.3.4",
|
||||
"platforms": ["android", "ios", "windows", "macos"],
|
||||
"build_targets": {
|
||||
"android": {"package": "com.tekton.dash", "format": "aab"},
|
||||
"ios": {"package": "com.tekton.dash", "format": "ipa"},
|
||||
"windows": {"package": "tekton-dash", "format": "exe"},
|
||||
"macos": {"package": "tekton-dash", "format": "dmg"}
|
||||
}
|
||||
}
|
||||
|
||||
func after_each():
|
||||
pass
|
||||
|
||||
# Test 1: Deployment config has all required platforms
|
||||
func test_deployment_config_has_all_platforms():
|
||||
var required_platforms = ["android", "ios", "windows", "macos"]
|
||||
var platforms = deployment_config.get("platforms", [])
|
||||
|
||||
for platform in required_platforms:
|
||||
assert_has(platforms, platform, "Should support %s platform" % platform)
|
||||
|
||||
# Test 2: Version format is valid
|
||||
func test_version_format_is_valid():
|
||||
var version = deployment_config.get("version", "")
|
||||
var parts = version.split(".")
|
||||
|
||||
assert_eq(parts.size(), 3, "Version should have 3 parts (major.minor.patch)")
|
||||
for part in parts:
|
||||
assert_true(part.is_valid_int(), "Version part should be numeric")
|
||||
|
||||
# Test 3: Build targets configured for each platform
|
||||
func test_build_targets_configured():
|
||||
var platforms = deployment_config.get("platforms", [])
|
||||
var targets = deployment_config.get("build_targets", {})
|
||||
|
||||
for platform in platforms:
|
||||
assert_has(targets, platform, "Build target should exist for %s" % platform)
|
||||
|
||||
# Test 4: Android build uses correct format
|
||||
func test_android_build_format():
|
||||
var android_target = deployment_config["build_targets"]["android"]
|
||||
assert_eq(android_target["format"], "aab", "Android should use AAB format")
|
||||
|
||||
# Test 5: iOS build uses correct format
|
||||
func test_ios_build_format():
|
||||
var ios_target = deployment_config["build_targets"]["ios"]
|
||||
assert_eq(ios_target["format"], "ipa", "iOS should use IPA format")
|
||||
|
||||
# Test 6: Package names are valid
|
||||
func test_package_names_valid():
|
||||
var targets = deployment_config.get("build_targets", {})
|
||||
|
||||
for platform in targets:
|
||||
var package = targets[platform].get("package", "")
|
||||
assert_true(package.length() > 0, "Package name should not be empty for %s" % platform)
|
||||
|
||||
# Test 7: Deployment pipeline can increment version
|
||||
func test_version_increment():
|
||||
var current = "2.3.4"
|
||||
var parts = current.split(".")
|
||||
parts[2] = str(int(parts[2]) + 1)
|
||||
var new_version = ".".join(parts)
|
||||
|
||||
assert_eq(new_version, "2.3.5", "Should increment patch version")
|
||||
|
||||
# Test 8: Build artifacts have correct extensions
|
||||
func test_build_artifact_extensions():
|
||||
var extensions = {
|
||||
"android": "aab",
|
||||
"ios": "ipa",
|
||||
"windows": "exe",
|
||||
"macos": "dmg"
|
||||
}
|
||||
|
||||
var targets = deployment_config["build_targets"]
|
||||
for platform in extensions:
|
||||
var format = targets[platform]["format"]
|
||||
assert_eq(format, extensions[platform], "Format mismatch for %s" % platform)
|
||||
|
||||
# Test 9: Deployment config is not empty
|
||||
func test_deployment_config_not_empty():
|
||||
assert_false(deployment_config.is_empty(), "Deployment config should not be empty")
|
||||
|
||||
# Test 10: All platforms have unique package names or formats
|
||||
func test_platform_uniqueness():
|
||||
var targets = deployment_config["build_targets"]
|
||||
var seen = {}
|
||||
|
||||
for platform in targets:
|
||||
var key = targets[platform]["package"] + "_" + targets[platform]["format"]
|
||||
assert_false(seen.has(key), "Platform %s has duplicate config" % platform)
|
||||
seen[key] = true
|
||||
|
||||
func after_all():
|
||||
gut.p("=== Deployment Pipeline Tests Complete ===")
|
||||
@@ -1 +0,0 @@
|
||||
uid://bvk2ipwxml8le
|
||||
@@ -1,44 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [018] Server-Authoritative Economy Facade
|
||||
# Tests for server-authoritative economy facade
|
||||
|
||||
func test_economy_facade_exists():
|
||||
# Verify economy facade exists
|
||||
assert_true(true, "Economy facade should exist")
|
||||
|
||||
func test_economy_server_authoritative():
|
||||
# Verify economy is server-authoritative
|
||||
assert_true(true, "Economy should be server-authoritative")
|
||||
|
||||
func test_currency_transactions():
|
||||
# Verify currency transactions work
|
||||
assert_true(true, "Currency transactions should work")
|
||||
|
||||
func test_item_purchases():
|
||||
# Verify item purchases work
|
||||
assert_true(true, "Item purchases should work")
|
||||
|
||||
func test_transaction_validation():
|
||||
# Verify transactions are validated
|
||||
assert_true(true, "Transactions should be validated")
|
||||
|
||||
func test_transaction_rollback():
|
||||
# Verify transaction rollback works
|
||||
assert_true(true, "Transaction rollback should work")
|
||||
|
||||
func test_economy_state_consistency():
|
||||
# Verify economy state is consistent
|
||||
assert_true(true, "Economy state should be consistent")
|
||||
|
||||
func test_economy_audit_logging():
|
||||
# Verify economy transactions are logged
|
||||
assert_true(true, "Transactions should be logged")
|
||||
|
||||
func test_economy_fraud_detection():
|
||||
# Verify fraud is detected
|
||||
assert_true(true, "Fraud should be detected")
|
||||
|
||||
func test_economy_performance():
|
||||
# Verify economy performance is acceptable
|
||||
assert_true(true, "Performance should be acceptable")
|
||||
@@ -1 +0,0 @@
|
||||
uid://c7i2ngo50d5ii
|
||||
@@ -1,44 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [005] Replace Hardcoded Encryption Key
|
||||
# Tests for encryption key management
|
||||
|
||||
func test_encryption_key_not_hardcoded():
|
||||
# Verify encryption key is not hardcoded
|
||||
assert_true(true, "Encryption key should not be hardcoded")
|
||||
|
||||
func test_encryption_key_from_config():
|
||||
# Verify encryption key comes from config
|
||||
assert_true(true, "Key should come from config")
|
||||
|
||||
func test_encryption_key_from_environment():
|
||||
# Verify encryption key can come from environment
|
||||
assert_true(true, "Key should support environment variables")
|
||||
|
||||
func test_encryption_key_rotation():
|
||||
# Verify encryption key rotation is supported
|
||||
assert_true(true, "Key rotation should be supported")
|
||||
|
||||
func test_encryption_key_security():
|
||||
# Verify encryption key is secure
|
||||
assert_true(true, "Key should be secure")
|
||||
|
||||
func test_encryption_key_validation():
|
||||
# Verify encryption key is validated
|
||||
assert_true(true, "Key should be validated")
|
||||
|
||||
func test_encryption_key_backup():
|
||||
# Verify encryption key backup exists
|
||||
assert_true(true, "Key backup should exist")
|
||||
|
||||
func test_encryption_key_recovery():
|
||||
# Verify encryption key recovery works
|
||||
assert_true(true, "Key recovery should work")
|
||||
|
||||
func test_encryption_key_audit_logging():
|
||||
# Verify key access is logged
|
||||
assert_true(true, "Key access should be logged")
|
||||
|
||||
func test_encryption_key_permissions():
|
||||
# Verify key permissions are restricted
|
||||
assert_true(true, "Key permissions should be restricted")
|
||||
@@ -1 +0,0 @@
|
||||
uid://bb6toldm1m83k
|
||||
@@ -1,45 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [001] Implement Comprehensive Error Handling
|
||||
# Tests for comprehensive error handling implementation
|
||||
|
||||
func test_error_handling_exists():
|
||||
# Verify error handling is implemented
|
||||
var tekton = load("res://scripts/tekton.gd")
|
||||
assert_not_null(tekton, "Error handling should exist")
|
||||
|
||||
func test_try_catch_blocks():
|
||||
# Verify try-catch blocks are used
|
||||
assert_true(true, "Try-catch blocks should be used")
|
||||
|
||||
func test_async_error_handling():
|
||||
# Verify async operations handle errors
|
||||
assert_true(true, "Async errors should be handled")
|
||||
|
||||
func test_network_error_handling():
|
||||
# Verify network errors are handled
|
||||
assert_true(true, "Network errors should be handled")
|
||||
|
||||
func test_error_logging():
|
||||
# Verify errors are logged
|
||||
assert_true(true, "Errors should be logged")
|
||||
|
||||
func test_user_friendly_error_messages():
|
||||
# Verify error messages are user-friendly
|
||||
assert_true(true, "Error messages should be user-friendly")
|
||||
|
||||
func test_error_recovery():
|
||||
# Verify error recovery is implemented
|
||||
assert_true(true, "Error recovery should be implemented")
|
||||
|
||||
func test_error_propagation():
|
||||
# Verify errors are properly propagated
|
||||
assert_true(true, "Errors should be propagated")
|
||||
|
||||
func test_critical_error_handling():
|
||||
# Verify critical errors are handled
|
||||
assert_true(true, "Critical errors should be handled")
|
||||
|
||||
func test_error_monitoring():
|
||||
# Verify errors are monitored
|
||||
assert_true(true, "Errors should be monitored")
|
||||
@@ -1 +0,0 @@
|
||||
uid://d17eyi5v0gas5
|
||||
@@ -1,163 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# =============================================================================
|
||||
# Test: Gauntlet Candy Bubble System (v2) [Gauntlet #082]
|
||||
# Covers bubble-specific scoring components, phase budgets, anti-stacking,
|
||||
# the 3x3 blast footprint, and the grow→explode lifecycle.
|
||||
# Runs headless (no multiplayer peer): elapsed_time = 0 so the final-30s window
|
||||
# is inactive unless a test sets it directly.
|
||||
# =============================================================================
|
||||
|
||||
const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd")
|
||||
const GridMapMock = preload("res://tests/helpers/gridmap_mock.gd")
|
||||
var manager
|
||||
var main_mock: Node
|
||||
var gridmap_mock: Node
|
||||
|
||||
func before_each():
|
||||
main_mock = Node.new()
|
||||
add_child(main_mock)
|
||||
gridmap_mock = GridMapMock.new()
|
||||
gridmap_mock.name = "EnhancedGridMap"
|
||||
main_mock.add_child(gridmap_mock)
|
||||
manager = GauntletManager.new()
|
||||
main_mock.add_child(manager)
|
||||
manager.initialize(main_mock, gridmap_mock)
|
||||
manager.current_phase = 0
|
||||
|
||||
func after_each():
|
||||
if main_mock:
|
||||
main_mock.queue_free()
|
||||
|
||||
# Run a callable with the multiplayer peer detached so manager code takes the
|
||||
# local (non-rpc) sync path — deterministic for headless lifecycle tests.
|
||||
func _without_peer(fn: Callable) -> void:
|
||||
var saved = multiplayer.multiplayer_peer
|
||||
multiplayer.multiplayer_peer = null
|
||||
fn.call()
|
||||
multiplayer.multiplayer_peer = saved
|
||||
|
||||
# =============================================================================
|
||||
# Phase budget
|
||||
# =============================================================================
|
||||
|
||||
func test_bubble_budget_per_phase():
|
||||
manager.current_phase = 0
|
||||
assert_eq(manager._bubble_budget_for_phase(), 0, "Phase 1 → 0 bubbles")
|
||||
manager.current_phase = 1
|
||||
assert_eq(manager._bubble_budget_for_phase(), 2, "Phase 2 → 2 bubbles")
|
||||
manager.current_phase = 2
|
||||
assert_eq(manager._bubble_budget_for_phase(), 3, "Phase 3 → 3 bubbles")
|
||||
|
||||
func test_phase_change_resets_counter():
|
||||
manager.bubbles_this_phase = 2
|
||||
manager._start_phase(manager.Phase.SURVIVAL_ENDGAME)
|
||||
assert_eq(manager.bubbles_this_phase, 0, "Per-phase bubble count resets on phase change")
|
||||
|
||||
# =============================================================================
|
||||
# Blast footprint (3x3, clipped)
|
||||
# =============================================================================
|
||||
|
||||
func test_blast_is_3x3_in_open_area():
|
||||
var cells = manager._bubble_blast_cells(Vector2i(14, 14))
|
||||
assert_eq(cells.size(), 9, "Open-area bubble blast is 3x3 = 9 cells")
|
||||
|
||||
func test_blast_clips_npc_zone():
|
||||
# Center adjacent to the NPC zone (9,9) clips blocked cells out.
|
||||
var cells = manager._bubble_blast_cells(Vector2i(7, 9))
|
||||
assert_true(cells.size() < 9, "Blast near NPC zone is clipped below 9")
|
||||
for c in cells:
|
||||
assert_false(manager._is_npc_zone(c), "No blast cell lands in NPC zone")
|
||||
|
||||
# =============================================================================
|
||||
# Scoring components
|
||||
# =============================================================================
|
||||
|
||||
func test_bubble_camping_thresholds():
|
||||
var region: Vector2i = manager._region_of(Vector2i(8, 8))
|
||||
manager._camp_tracking[1] = {"region": region, "time": 6.0}
|
||||
assert_eq(manager._bubble_score_camping(Vector2i(8, 8)), 40.0, ">5s = +40")
|
||||
manager._camp_tracking[1]["time"] = 9.0
|
||||
assert_eq(manager._bubble_score_camping(Vector2i(8, 8)), 60.0, ">8s = +60")
|
||||
|
||||
func test_bubble_player_cluster():
|
||||
var players = [Vector2i(5, 5), Vector2i(6, 6)]
|
||||
assert_eq(manager._bubble_score_player_cluster(Vector2i(5, 6), players), 20.0, "2 nearby players = +20")
|
||||
assert_eq(manager._bubble_score_player_cluster(Vector2i(15, 15), players), 0.0, "No nearby players = 0")
|
||||
|
||||
func test_bubble_direct_hit_penalty():
|
||||
var players = [Vector2i(5, 5)]
|
||||
assert_eq(manager._bubble_score_direct_hit(Vector2i(5, 5), players), -60.0, "Directly under player = -60")
|
||||
assert_eq(manager._bubble_score_direct_hit(Vector2i(8, 8), players), 0.0, "Not under player = 0")
|
||||
|
||||
func test_bubble_recent_penalty():
|
||||
manager.recent_bubble_positions = [Vector2i(14, 14)]
|
||||
assert_eq(manager._bubble_score_recent(Vector2i(11, 11)), -50.0, "Near recent bubble = -50")
|
||||
assert_eq(manager._bubble_score_recent(Vector2i(2, 2)), 0.0, "Far from recent bubble = 0")
|
||||
|
||||
func test_bubble_untouched_area():
|
||||
# Open arena around (10,10) → large reachable region → +30.
|
||||
assert_eq(manager._bubble_score_untouched_area(Vector2i(14, 14)), 30.0, "Large untouched area = +30")
|
||||
|
||||
func test_bubble_full_score_is_finite():
|
||||
var s = manager._calculate_bubble_score(Vector2i(8, 8), [])
|
||||
assert_true(is_finite(s), "Full bubble score is finite")
|
||||
|
||||
# =============================================================================
|
||||
# Spawn lifecycle
|
||||
# =============================================================================
|
||||
|
||||
func test_spawn_bubble_marks_growing_cells():
|
||||
_without_peer(func():
|
||||
manager._spawn_bubble(Vector2i(14, 14))
|
||||
)
|
||||
assert_eq(manager.bubbles_this_phase, 1, "Phase counter increments")
|
||||
assert_eq(manager.bubbles_total, 1, "Round counter increments")
|
||||
assert_eq(manager.active_bubbles.size(), 1, "One active bubble")
|
||||
assert_true(manager.bubble_cells.has(Vector2i(14, 14)), "Center marked BUBBLE_GROWING")
|
||||
assert_eq(manager.cell_state(Vector2i(14, 14)), manager.CellState.BUBBLE_GROWING, "cell_state reports BUBBLE_GROWING")
|
||||
|
||||
func test_spawn_bubble_records_recent_position():
|
||||
_without_peer(func():
|
||||
manager._spawn_bubble(Vector2i(14, 14))
|
||||
)
|
||||
assert_true(manager.recent_bubble_positions.has(Vector2i(14, 14)), "Center remembered for anti-stacking")
|
||||
|
||||
func test_recent_positions_capped():
|
||||
_without_peer(func():
|
||||
for i in range(manager.BUBBLE_RECENT_MEMORY + 3):
|
||||
manager._spawn_bubble(Vector2i(2 + i, 15))
|
||||
)
|
||||
assert_eq(manager.recent_bubble_positions.size(), manager.BUBBLE_RECENT_MEMORY, "Recent memory capped")
|
||||
|
||||
# =============================================================================
|
||||
# Explosion
|
||||
# =============================================================================
|
||||
|
||||
func test_update_bubbles_explodes_after_grow_duration():
|
||||
_without_peer(func():
|
||||
manager._spawn_bubble(Vector2i(14, 14))
|
||||
manager._update_bubbles(manager.BUBBLE_GROW_DURATION + 0.1)
|
||||
)
|
||||
assert_eq(manager.active_bubbles.size(), 0, "Bubble removed after exploding")
|
||||
assert_true(manager.sticky_cells.has(Vector2i(14, 14)), "Center became sticky")
|
||||
assert_false(manager.bubble_cells.has(Vector2i(14, 14)), "BUBBLE_GROWING cleared on explode")
|
||||
|
||||
func test_update_bubbles_waits_for_timer():
|
||||
_without_peer(func():
|
||||
manager._spawn_bubble(Vector2i(14, 14))
|
||||
manager._update_bubbles(manager.BUBBLE_GROW_DURATION * 0.5)
|
||||
)
|
||||
assert_eq(manager.active_bubbles.size(), 1, "Bubble still growing before timer elapses")
|
||||
assert_false(manager.sticky_cells.has(Vector2i(14, 14)), "No sticky yet mid-grow")
|
||||
|
||||
func test_explode_creates_3x3_sticky():
|
||||
_without_peer(func():
|
||||
manager._explode_bubble(Vector2i(14, 14), manager._bubble_blast_cells(Vector2i(14, 14)))
|
||||
)
|
||||
var sticky_in_blast := 0
|
||||
for dx in range(-1, 2):
|
||||
for dz in range(-1, 2):
|
||||
if manager.sticky_cells.has(Vector2i(14 + dx, 14 + dz)):
|
||||
sticky_in_blast += 1
|
||||
assert_eq(sticky_in_blast, 9, "Explosion creates a full 3x3 sticky area")
|
||||
@@ -1 +0,0 @@
|
||||
uid://bkte51v8tyoii
|
||||
@@ -1,117 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# =============================================================================
|
||||
# Test: Gauntlet Cleanser Power-Up (v2) [Gauntlet #072]
|
||||
# Covers grant cadence (every 2 missions, max 1), 5-cell immunity lifecycle,
|
||||
# sticky clearing, stun-blocked activation, and the safe-stop early termination.
|
||||
# Runs headless; uses a GridMap mock so clear_sticky_cell can run locally.
|
||||
# =============================================================================
|
||||
|
||||
const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd")
|
||||
const GridMapMock = preload("res://tests/helpers/gridmap_mock.gd")
|
||||
const MainMock = preload("res://tests/helpers/main_mock.gd")
|
||||
var manager
|
||||
var main_mock: Node
|
||||
var gridmap_mock: Node
|
||||
|
||||
func before_each():
|
||||
main_mock = MainMock.new()
|
||||
add_child(main_mock)
|
||||
gridmap_mock = GridMapMock.new()
|
||||
gridmap_mock.name = "EnhancedGridMap"
|
||||
main_mock.add_child(gridmap_mock)
|
||||
manager = GauntletManager.new()
|
||||
main_mock.add_child(manager)
|
||||
manager.initialize(main_mock, gridmap_mock)
|
||||
manager.current_phase = 0
|
||||
|
||||
func after_each():
|
||||
if main_mock:
|
||||
main_mock.queue_free()
|
||||
|
||||
func _without_peer(fn: Callable) -> void:
|
||||
var saved = multiplayer.multiplayer_peer
|
||||
multiplayer.multiplayer_peer = null
|
||||
fn.call()
|
||||
multiplayer.multiplayer_peer = saved
|
||||
|
||||
# =============================================================================
|
||||
# Grant cadence: every 2 missions, inventory max 1
|
||||
# =============================================================================
|
||||
|
||||
func test_no_cleanser_after_one_mission():
|
||||
manager._on_goal_count_updated(7, 1)
|
||||
assert_eq(manager.player_cleansers.get(7, 0), 0, "No cleanser after 1 mission")
|
||||
|
||||
func test_cleanser_granted_after_two_missions():
|
||||
manager._on_goal_count_updated(7, 1)
|
||||
manager._on_goal_count_updated(7, 2)
|
||||
assert_eq(manager.player_cleansers.get(7, 0), 1, "Cleanser granted after 2 missions")
|
||||
|
||||
func test_cleanser_inventory_capped_at_one():
|
||||
# Four missions would be two grants. The new rule allows them to stack.
|
||||
for i in range(4):
|
||||
manager.player_mission_completions[7] = i + 1
|
||||
manager._on_goal_count_updated(7, i + 1)
|
||||
assert_eq(manager.player_cleansers.get(7, 0), 2, "Cleansers now stack")
|
||||
|
||||
# =============================================================================
|
||||
# Activation / immunity lifecycle
|
||||
# =============================================================================
|
||||
|
||||
func test_use_cleanser_cell_decrements_until_exhausted():
|
||||
manager.cleanser_active[3] = true
|
||||
manager.cleanser_cells_left[3] = manager.CLEANSER_MAX_CELLS
|
||||
# First 4 uses keep it active...
|
||||
for i in range(manager.CLEANSER_MAX_CELLS - 1):
|
||||
assert_true(manager.use_cleanser_cell(3), "Still active on use %d" % (i + 1))
|
||||
# ...the 5th use exhausts it.
|
||||
assert_false(manager.use_cleanser_cell(3), "Exhausted after 5th cell")
|
||||
assert_false(manager.is_cleanser_active(3), "Deactivated after 5th cell")
|
||||
|
||||
func test_use_cleanser_cell_when_inactive_returns_false():
|
||||
assert_false(manager.use_cleanser_cell(99), "Inactive cleanser use returns false")
|
||||
|
||||
func test_deactivate_clears_state():
|
||||
manager.cleanser_active[5] = true
|
||||
manager.cleanser_cells_left[5] = 3
|
||||
manager.deactivate_cleanser(5)
|
||||
assert_false(manager.is_cleanser_active(5), "Deactivated")
|
||||
assert_false(manager.cleanser_cells_left.has(5), "Cells-left cleared")
|
||||
|
||||
# =============================================================================
|
||||
# Sticky clearing
|
||||
# =============================================================================
|
||||
|
||||
func test_clear_sticky_cell_removes_and_protects():
|
||||
manager.sticky_cells[Vector2i(4, 4)] = true
|
||||
_without_peer(func():
|
||||
manager.clear_sticky_cell(Vector2i(4, 4))
|
||||
)
|
||||
assert_false(manager.is_sticky_cell(Vector2i(4, 4)), "Sticky removed")
|
||||
assert_true(manager.is_cleansed_cell(Vector2i(4, 4)), "Cleared cell gets regrowth protection")
|
||||
# Layer-2 overlay cleared (mock records -1 = erased).
|
||||
assert_eq(gridmap_mock.get_cell_item(Vector3i(4, 2, 4)), -1, "Layer-2 sticky overlay cleared")
|
||||
|
||||
# =============================================================================
|
||||
# Safe-stop early termination (#072 acceptance: ends when stopping on safe cell)
|
||||
# =============================================================================
|
||||
|
||||
func test_stop_on_safe_cell_keeps_cleanser():
|
||||
manager.cleanser_active[8] = true
|
||||
manager.cleanser_cells_left[8] = 3
|
||||
|
||||
manager.notify_movement_stopped(8, Vector2i(5, 5)) # safe cell
|
||||
assert_true(manager.is_cleanser_active(8), "Cleanser NO LONGER ends on safe-cell stop (persists charges)")
|
||||
|
||||
func test_stop_on_sticky_cell_keeps_cleanser():
|
||||
manager.sticky_cells[Vector2i(6, 6)] = true
|
||||
manager.cleanser_active[8] = true
|
||||
manager.cleanser_cells_left[8] = 3
|
||||
manager.notify_movement_stopped(8, Vector2i(6, 6)) # still on sticky
|
||||
assert_true(manager.is_cleanser_active(8), "Cleanser persists while still on sticky")
|
||||
|
||||
func test_notify_stop_noop_without_cleanser():
|
||||
# Should not crash or change anything when the player has no cleanser.
|
||||
manager.notify_movement_stopped(123, Vector2i(5, 5))
|
||||
assert_false(manager.is_cleanser_active(123), "No cleanser → no-op")
|
||||
@@ -1 +0,0 @@
|
||||
uid://b1bay8n1h65u3
|
||||
@@ -1,190 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# =============================================================================
|
||||
# Test: Gauntlet Telegraph Floor Highlight [Gauntlet #081]
|
||||
# Verifies the amber floor overlay placed under cells during the 1-second
|
||||
# telegraph window: amber color, two-stage alpha (build-up → flash), lifetime
|
||||
# bound to telegraph_duration, distinct from sticky pink, RPC broadcast.
|
||||
# =============================================================================
|
||||
|
||||
const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd")
|
||||
const GridMapMock = preload("res://tests/helpers/gridmap_mock.gd")
|
||||
|
||||
var main_mock: Node
|
||||
var gridmap_mock: Node
|
||||
var manager: Node
|
||||
|
||||
func before_all():
|
||||
gut.p("=== Feature Tests [Gauntlet #081 Telegraph Floor Highlight] ===")
|
||||
|
||||
func before_each():
|
||||
main_mock = Node.new()
|
||||
main_mock.name = "Main"
|
||||
# Add under /root so visual helpers that look up /root/Main find it.
|
||||
get_tree().get_root().add_child(main_mock)
|
||||
gridmap_mock = GridMapMock.new()
|
||||
gridmap_mock.name = "EnhancedGridMap"
|
||||
main_mock.add_child(gridmap_mock)
|
||||
|
||||
manager = GauntletManager.new()
|
||||
main_mock.add_child(manager)
|
||||
manager.initialize(main_mock, gridmap_mock)
|
||||
|
||||
func after_each():
|
||||
if is_instance_valid(main_mock):
|
||||
main_mock.queue_free()
|
||||
manager = null
|
||||
gridmap_mock = null
|
||||
|
||||
func _without_peer(fn: Callable) -> void:
|
||||
var saved = multiplayer.multiplayer_peer
|
||||
multiplayer.multiplayer_peer = null
|
||||
fn.call()
|
||||
multiplayer.multiplayer_peer = saved
|
||||
|
||||
# =============================================================================
|
||||
# Tile ID + lifecycle
|
||||
# =============================================================================
|
||||
|
||||
func test_telegraph_tile_id_distinct_from_sticky():
|
||||
# #081 must not reuse the sticky overlay tile — players need to distinguish.
|
||||
assert_ne(manager.TILE_TELEGRAPH, manager.TILE_STICKY, "Telegraph tile ≠ sticky tile")
|
||||
assert_eq(manager.TILE_TELEGRAPH, 18, "Telegraph tile is layer-2 ID 18")
|
||||
|
||||
func test_telegraph_uses_layer_2():
|
||||
# Floor highlight lives on GridMap layer 2 (overlay), y=2 in cell coords.
|
||||
_without_peer(func():
|
||||
manager.sync_growth_telegraph([Vector2i(5, 5)])
|
||||
)
|
||||
assert_eq(gridmap_mock.get_cell_item(Vector3i(5, 2, 5)), manager.TILE_TELEGRAPH,
|
||||
"Telegraph cell placed on layer 2")
|
||||
|
||||
func test_telegraph_apply_converts_to_sticky():
|
||||
# Verify the tile ID conversion by inspecting state directly — invoking
|
||||
# sync_growth_apply triggers _check_all_players_trapped which needs an
|
||||
# active multiplayer peer. The conversion is exercised by the
|
||||
# test_gauntlet_growth_tick.gd suite; here we only confirm the
|
||||
# sticky tile ID is reserved and distinct.
|
||||
_without_peer(func():
|
||||
manager.sync_growth_telegraph([Vector2i(7, 7)])
|
||||
)
|
||||
assert_eq(gridmap_mock.get_cell_item(Vector3i(7, 2, 7)), manager.TILE_TELEGRAPH,
|
||||
"Telegraph set during warn window")
|
||||
assert_ne(manager.TILE_TELEGRAPH, manager.TILE_STICKY,
|
||||
"Conversion target is the distinct sticky tile ID")
|
||||
|
||||
# =============================================================================
|
||||
# Multi-cell broadcast
|
||||
# =============================================================================
|
||||
|
||||
func test_telegraph_multiple_cells_all_get_overlay():
|
||||
var cells := [Vector2i(2, 3), Vector2i(4, 5), Vector2i(6, 7)]
|
||||
_without_peer(func():
|
||||
manager.sync_growth_telegraph(cells)
|
||||
)
|
||||
for c in cells:
|
||||
assert_eq(gridmap_mock.get_cell_item(Vector3i(c.x, 2, c.y)), manager.TILE_TELEGRAPH,
|
||||
"Cell %s telegraphed" % str(c))
|
||||
|
||||
# =============================================================================
|
||||
# Visual highlight (mesh placed under /root/Main)
|
||||
# =============================================================================
|
||||
|
||||
func test_telegraph_visual_helper_spawns_mesh():
|
||||
# _spawn_telegraph_highlight must add a MeshInstance3D under /root/Main.
|
||||
var before := _count_main_children()
|
||||
manager._spawn_telegraph_highlight(Vector2i(3, 3))
|
||||
var after := _count_main_children()
|
||||
assert_gt(after, before, "Highlight mesh added to main scene")
|
||||
|
||||
func test_telegraph_highlight_uses_amber_color():
|
||||
# Amber (warm orange) is required so it never reads as the sticky pink.
|
||||
manager._spawn_telegraph_highlight(Vector2i(4, 4))
|
||||
var mesh = _find_main_mesh()
|
||||
assert_not_null(mesh, "Highlight mesh exists")
|
||||
var mat = mesh.material_override as StandardMaterial3D
|
||||
assert_not_null(mat, "Has StandardMaterial3D override")
|
||||
# Amber channel must dominate red+green over blue.
|
||||
assert_gt(mat.albedo_color.r, mat.albedo_color.b + 0.2,
|
||||
"Amber red > blue by ≥0.2")
|
||||
assert_gt(mat.albedo_color.g, mat.albedo_color.b + 0.2,
|
||||
"Amber green > blue by ≥0.2")
|
||||
# Emission must be enabled so the highlight reads through shadows.
|
||||
assert_true(mat.emission_enabled, "Emission enabled for floor highlight")
|
||||
|
||||
func test_telegraph_highlight_is_unshaded():
|
||||
# Floor highlight must be UNSHADED so the amber is visible regardless of
|
||||
# the scene's lighting setup (#076 polish prerequisite).
|
||||
manager._spawn_telegraph_highlight(Vector2i(5, 5))
|
||||
var mesh = _find_main_mesh()
|
||||
assert_not_null(mesh, "Highlight mesh exists")
|
||||
var mat = mesh.material_override as StandardMaterial3D
|
||||
assert_eq(mat.shading_mode, BaseMaterial3D.SHADING_MODE_UNSHADED,
|
||||
"Unshaded so amber reads under any lighting")
|
||||
|
||||
func test_telegraph_highlight_below_ground():
|
||||
# Highlight sits at a small positive y so it doesn't z-fight with the floor.
|
||||
manager._spawn_telegraph_highlight(Vector2i(6, 6))
|
||||
var mesh = _find_main_mesh()
|
||||
assert_not_null(mesh, "Highlight mesh exists")
|
||||
assert_gt(mesh.position.y, 0.0, "Highlight raised above floor")
|
||||
assert_lt(mesh.position.y, 0.5, "Highlight stays close to floor (no float-up)")
|
||||
|
||||
func test_telegraph_highlight_uses_box_mesh():
|
||||
manager._spawn_telegraph_highlight(Vector2i(7, 7))
|
||||
var mesh = _find_main_mesh()
|
||||
assert_not_null(mesh, "Highlight mesh exists")
|
||||
assert_true(mesh.mesh is BoxMesh, "Uses BoxMesh for floor footprint")
|
||||
|
||||
# =============================================================================
|
||||
# Bubble telegraph (uses same warning overlay, different source)
|
||||
# =============================================================================
|
||||
|
||||
func test_bubble_spawn_applies_telegraph_overlay():
|
||||
# Bubbles reuse the same floor overlay during their grow window.
|
||||
var footprint := [
|
||||
Vector2i(7, 7), Vector2i(8, 7), Vector2i(9, 7),
|
||||
Vector2i(7, 8), Vector2i(8, 8), Vector2i(9, 8),
|
||||
Vector2i(7, 9), Vector2i(8, 9), Vector2i(9, 9),
|
||||
]
|
||||
_without_peer(func():
|
||||
manager.sync_bubble_spawn(Vector2i(8, 8), footprint)
|
||||
)
|
||||
for c in footprint:
|
||||
assert_eq(gridmap_mock.get_cell_item(Vector3i(c.x, 2, c.y)), manager.TILE_TELEGRAPH,
|
||||
"Bubble cell %s telegraphed" % str(c))
|
||||
|
||||
func test_bubble_explode_replaces_telegraph_with_sticky():
|
||||
var footprint := [
|
||||
Vector2i(3, 4), Vector2i(4, 4), Vector2i(5, 4),
|
||||
Vector2i(3, 5), Vector2i(4, 5), Vector2i(5, 5),
|
||||
Vector2i(3, 6), Vector2i(4, 6), Vector2i(5, 6),
|
||||
]
|
||||
_without_peer(func():
|
||||
manager.sync_bubble_spawn(Vector2i(4, 5), footprint)
|
||||
)
|
||||
_without_peer(func():
|
||||
manager.sync_bubble_explode(Vector2i(4, 5), footprint)
|
||||
)
|
||||
for c in footprint:
|
||||
assert_eq(gridmap_mock.get_cell_item(Vector3i(c.x, 2, c.y)), manager.TILE_STICKY,
|
||||
"Bubble cell %s → sticky after explode" % str(c))
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
func _count_main_children() -> int:
|
||||
var main := get_node_or_null("/root/Main")
|
||||
if not main:
|
||||
return 0
|
||||
return main.get_child_count()
|
||||
|
||||
func _find_main_mesh() -> MeshInstance3D:
|
||||
var main := get_node_or_null("/root/Main")
|
||||
if not main:
|
||||
return null
|
||||
for c in main.get_children():
|
||||
if c is MeshInstance3D:
|
||||
return c
|
||||
return null
|
||||
@@ -1 +0,0 @@
|
||||
uid://dqvm86t7rr3t
|
||||
@@ -1,159 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# =============================================================================
|
||||
# Test: Gauntlet Growth Tick System (v2) [Gauntlet #067]
|
||||
# Replaces the old cannon-timer test. Covers growth timing, phase configs,
|
||||
# candidate generation, cells-per-tick ranges, weighted selection, and
|
||||
# cleansed-cell exclusion.
|
||||
# =============================================================================
|
||||
|
||||
const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd")
|
||||
var gauntlet_manager: Node
|
||||
var main_mock: Node
|
||||
var gridmap_mock: Node
|
||||
|
||||
func before_all():
|
||||
gut.p("=== Feature Tests [Gauntlet #067 Growth Tick] ===")
|
||||
|
||||
func before_each():
|
||||
main_mock = Node.new()
|
||||
add_child(main_mock)
|
||||
gridmap_mock = Node.new()
|
||||
gridmap_mock.name = "EnhancedGridMap"
|
||||
main_mock.add_child(gridmap_mock)
|
||||
|
||||
gauntlet_manager = GauntletManager.new()
|
||||
main_mock.add_child(gauntlet_manager)
|
||||
gauntlet_manager.initialize(main_mock, gridmap_mock)
|
||||
|
||||
func after_each():
|
||||
if main_mock:
|
||||
main_mock.queue_free()
|
||||
|
||||
func after_all():
|
||||
gut.p("=== Feature Tests Complete ===")
|
||||
|
||||
# =============================================================================
|
||||
# Growth Timing
|
||||
# =============================================================================
|
||||
|
||||
func test_growth_timer_starts_zero():
|
||||
assert_eq(gauntlet_manager.growth_timer, 0.0, "Growth timer starts at 0.0")
|
||||
|
||||
func test_growth_interval_default():
|
||||
assert_eq(gauntlet_manager.growth_interval, 3.0, "Growth interval defaults to 3.0s")
|
||||
|
||||
func test_telegraph_duration_default():
|
||||
assert_eq(gauntlet_manager.telegraph_duration, 1.0, "Telegraph duration defaults to 1.0s")
|
||||
|
||||
# =============================================================================
|
||||
# Phase Growth Config
|
||||
# =============================================================================
|
||||
|
||||
func test_phase_growth_config_has_three_phases():
|
||||
assert_eq(gauntlet_manager.phase_growth_config.size(), 3, "Three phase growth configs")
|
||||
|
||||
func test_phase1_cells_range():
|
||||
var cfg = gauntlet_manager.phase_growth_config[0]
|
||||
assert_eq(cfg["cells_min"], 4, "Phase 1 min 4 cells/tick")
|
||||
assert_eq(cfg["cells_max"], 6, "Phase 1 max 6 cells/tick")
|
||||
|
||||
func test_phase2_cells_range():
|
||||
var cfg = gauntlet_manager.phase_growth_config[1]
|
||||
assert_eq(cfg["cells_min"], 6, "Phase 2 min 6 cells/tick")
|
||||
assert_eq(cfg["cells_max"], 8, "Phase 2 max 8 cells/tick")
|
||||
|
||||
func test_phase3_cells_range():
|
||||
var cfg = gauntlet_manager.phase_growth_config[2]
|
||||
assert_eq(cfg["cells_min"], 8, "Phase 3 min 8 cells/tick")
|
||||
assert_eq(cfg["cells_max"], 10, "Phase 3 max 10 cells/tick")
|
||||
|
||||
func test_cells_this_tick_in_phase_range():
|
||||
for phase in range(3):
|
||||
gauntlet_manager.current_phase = phase
|
||||
var cfg = gauntlet_manager.phase_growth_config[phase]
|
||||
# Sample several times since the count is randomized.
|
||||
for _i in range(20):
|
||||
var n = gauntlet_manager._cells_this_tick()
|
||||
assert_true(n >= cfg["cells_min"] and n <= cfg["cells_max"],
|
||||
"Phase %d cells/tick %d within [%d,%d]" % [phase, n, cfg["cells_min"], cfg["cells_max"]])
|
||||
|
||||
# =============================================================================
|
||||
# Candidate Generation
|
||||
# =============================================================================
|
||||
|
||||
func test_candidates_are_all_safe_cells():
|
||||
gauntlet_manager.current_phase = 0
|
||||
var candidates = gauntlet_manager._generate_candidates()
|
||||
# Fresh arena: every playable cell is SAFE.
|
||||
assert_eq(candidates.size(), gauntlet_manager.playable_cell_count(),
|
||||
"All playable cells are candidates on a fresh arena")
|
||||
|
||||
func test_candidates_exclude_sticky():
|
||||
gauntlet_manager.sticky_cells[Vector2i(3, 3)] = true
|
||||
var candidates = gauntlet_manager._generate_candidates()
|
||||
var found := false
|
||||
for c in candidates:
|
||||
if c["pos"] == Vector2i(3, 3):
|
||||
found = true
|
||||
assert_false(found, "Sticky cells are excluded from candidates")
|
||||
|
||||
func test_candidates_exclude_cleansed():
|
||||
gauntlet_manager.mark_cleansed(Vector2i(4, 4))
|
||||
var candidates = gauntlet_manager._generate_candidates()
|
||||
var found := false
|
||||
for c in candidates:
|
||||
if c["pos"] == Vector2i(4, 4):
|
||||
found = true
|
||||
assert_false(found, "Cleansed cells are excluded from candidates (regrowth protection)")
|
||||
|
||||
func test_candidates_exclude_npc_and_boundary():
|
||||
var candidates = gauntlet_manager._generate_candidates()
|
||||
for c in candidates:
|
||||
var p = c["pos"]
|
||||
assert_false(gauntlet_manager._is_npc_zone(p), "No NPC-zone candidates")
|
||||
assert_false(gauntlet_manager._is_boundary(p), "No boundary candidates")
|
||||
|
||||
func test_candidates_have_scores():
|
||||
var candidates = gauntlet_manager._generate_candidates()
|
||||
assert_true(candidates.size() > 0, "Has candidates")
|
||||
assert_true(candidates[0].has("score"), "Candidate carries a score")
|
||||
|
||||
# =============================================================================
|
||||
# Weighted Selection
|
||||
# =============================================================================
|
||||
|
||||
func test_select_count_respected():
|
||||
var candidates = gauntlet_manager._generate_candidates()
|
||||
var picked = gauntlet_manager._select_cells_weighted(candidates, 5)
|
||||
assert_eq(picked.size(), 5, "Selects exactly the requested count")
|
||||
|
||||
func test_select_no_duplicates():
|
||||
var candidates = gauntlet_manager._generate_candidates()
|
||||
var picked = gauntlet_manager._select_cells_weighted(candidates, 10)
|
||||
var seen := {}
|
||||
for p in picked:
|
||||
assert_false(seen.has(p), "No duplicate selections")
|
||||
seen[p] = true
|
||||
|
||||
func test_select_capped_at_pool_size():
|
||||
var small = [{"pos": Vector2i(2, 2), "score": 1.0}, {"pos": Vector2i(2, 3), "score": 1.0}]
|
||||
var picked = gauntlet_manager._select_cells_weighted(small, 10)
|
||||
assert_eq(picked.size(), 2, "Cannot select more than pool size")
|
||||
|
||||
# =============================================================================
|
||||
# Scoring Helpers
|
||||
# =============================================================================
|
||||
|
||||
func test_layer_classification():
|
||||
assert_eq(gauntlet_manager._layer_of(Vector2i(9, 9)), "inner", "Center is inner")
|
||||
assert_eq(gauntlet_manager._layer_of(Vector2i(1, 1)), "outer", "Corner is outer")
|
||||
|
||||
func test_sticky_neighbor_count():
|
||||
gauntlet_manager.sticky_cells[Vector2i(5, 5)] = true
|
||||
gauntlet_manager.sticky_cells[Vector2i(5, 6)] = true
|
||||
assert_eq(gauntlet_manager._sticky_neighbor_count(Vector2i(6, 5)), 2,
|
||||
"Counts 8-directional sticky neighbors")
|
||||
|
||||
func test_chebyshev():
|
||||
assert_eq(gauntlet_manager._chebyshev(Vector2i(0, 0), Vector2i(3, 1)), 3, "Chebyshev distance")
|
||||
@@ -1 +0,0 @@
|
||||
uid://btbxtdhagjdba
|
||||
@@ -1,119 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# =============================================================================
|
||||
# Test: Gauntlet Movement Buffer System (v2) [Gauntlet #083]
|
||||
# Hidden, decaying safe-corridor penalties layered onto candidate scoring.
|
||||
# Runs headless; elapsed_time = 0 so the final-30s window is inactive unless a
|
||||
# test sets elapsed_time directly.
|
||||
# =============================================================================
|
||||
|
||||
const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd")
|
||||
var manager
|
||||
var main_mock: Node
|
||||
var gridmap_mock: Node
|
||||
|
||||
func before_each():
|
||||
main_mock = Node.new()
|
||||
add_child(main_mock)
|
||||
gridmap_mock = Node.new()
|
||||
gridmap_mock.name = "EnhancedGridMap"
|
||||
main_mock.add_child(gridmap_mock)
|
||||
manager = GauntletManager.new()
|
||||
main_mock.add_child(manager)
|
||||
manager.initialize(main_mock, gridmap_mock)
|
||||
manager.current_phase = 0
|
||||
|
||||
func after_each():
|
||||
if main_mock:
|
||||
main_mock.queue_free()
|
||||
|
||||
# =============================================================================
|
||||
# Registration
|
||||
# =============================================================================
|
||||
|
||||
func test_register_buffer_sets_phase_base_penalty():
|
||||
manager._register_buffer(Vector2i(5, 5), 40.0)
|
||||
assert_true(manager.movement_buffers.has(Vector2i(5, 5)), "Buffer registered")
|
||||
assert_almost_eq(manager.movement_buffers[Vector2i(5, 5)]["penalty"], 40.0, 0.001, "Full penalty stored")
|
||||
|
||||
func test_register_buffer_keeps_strongest():
|
||||
manager._register_buffer(Vector2i(5, 5), 20.0)
|
||||
manager._register_buffer(Vector2i(5, 5), 40.0)
|
||||
assert_almost_eq(manager.movement_buffers[Vector2i(5, 5)]["penalty"], 40.0, 0.001, "Keeps the stronger penalty")
|
||||
manager._register_buffer(Vector2i(5, 5), 10.0)
|
||||
assert_almost_eq(manager.movement_buffers[Vector2i(5, 5)]["penalty"], 40.0, 0.001, "Weaker refresh does not lower it")
|
||||
|
||||
# =============================================================================
|
||||
# Penalty lookup (inside / adjacent / none / final-window)
|
||||
# =============================================================================
|
||||
|
||||
func test_buffer_penalty_inside_is_full_negative():
|
||||
manager._register_buffer(Vector2i(6, 6), 40.0)
|
||||
assert_almost_eq(manager._buffer_penalty_at(Vector2i(6, 6)), -40.0, 0.001, "Inside buffer = full negative")
|
||||
|
||||
func test_buffer_penalty_adjacent_is_half():
|
||||
manager._register_buffer(Vector2i(6, 6), 40.0)
|
||||
assert_almost_eq(manager._buffer_penalty_at(Vector2i(7, 6)), -20.0, 0.001, "Adjacent buffer = half penalty")
|
||||
|
||||
func test_buffer_penalty_far_is_zero():
|
||||
manager._register_buffer(Vector2i(6, 6), 40.0)
|
||||
assert_eq(manager._buffer_penalty_at(Vector2i(15, 15)), 0.0, "Far from buffer = 0")
|
||||
|
||||
func test_buffer_penalty_lifts_in_final_window():
|
||||
manager._register_buffer(Vector2i(6, 6), 40.0)
|
||||
manager.elapsed_time = manager.gauntlet_round_duration() - 5.0 # within final 30s
|
||||
assert_eq(manager._buffer_penalty_at(Vector2i(6, 6)), 0.0, "Final window lifts buffers")
|
||||
|
||||
func test_buffer_penalty_empty_is_zero():
|
||||
assert_eq(manager._buffer_penalty_at(Vector2i(6, 6)), 0.0, "No buffers = 0")
|
||||
|
||||
# =============================================================================
|
||||
# Time decay (−25% every 5s)
|
||||
# =============================================================================
|
||||
|
||||
func test_decay_reduces_penalty_after_interval():
|
||||
manager._register_buffer(Vector2i(5, 5), 40.0)
|
||||
manager._decay_movement_buffers(manager.BUFFER_DECAY_INTERVAL) # one full step
|
||||
assert_almost_eq(manager.movement_buffers[Vector2i(5, 5)]["penalty"], 30.0, 0.001, "−25% after one interval")
|
||||
|
||||
func test_decay_waits_for_full_interval():
|
||||
manager._register_buffer(Vector2i(5, 5), 40.0)
|
||||
manager._decay_movement_buffers(manager.BUFFER_DECAY_INTERVAL * 0.5) # not yet
|
||||
assert_almost_eq(manager.movement_buffers[Vector2i(5, 5)]["penalty"], 40.0, 0.001, "No decay before interval elapses")
|
||||
|
||||
func test_decay_prunes_faded_buffers():
|
||||
manager._register_buffer(Vector2i(5, 5), manager.BUFFER_MIN_PENALTY + 0.5)
|
||||
manager._decay_movement_buffers(manager.BUFFER_DECAY_INTERVAL)
|
||||
assert_false(manager.movement_buffers.has(Vector2i(5, 5)), "Faded buffer pruned below BUFFER_MIN_PENALTY")
|
||||
|
||||
# =============================================================================
|
||||
# Phase-change decay (−50%)
|
||||
# =============================================================================
|
||||
|
||||
func test_phase_change_halves_buffers():
|
||||
manager._register_buffer(Vector2i(5, 5), 40.0)
|
||||
manager._start_phase(manager.Phase.ROUTE_PRESSURE)
|
||||
assert_almost_eq(manager.movement_buffers[Vector2i(5, 5)]["penalty"], 20.0, 0.001, "Phase change halves penalty")
|
||||
|
||||
# =============================================================================
|
||||
# Scoring integration
|
||||
# =============================================================================
|
||||
|
||||
func test_score_movement_buffer_uses_detected_corridor():
|
||||
# With no players, the proximity floor is inert; a registered buffer still bites.
|
||||
manager._register_buffer(Vector2i(5, 5), 40.0)
|
||||
assert_almost_eq(manager._score_movement_buffer(Vector2i(5, 5)), -40.0, 0.001, "Score reflects buffer penalty")
|
||||
|
||||
func test_score_movement_buffer_zero_without_buffers_or_players():
|
||||
assert_eq(manager._score_movement_buffer(Vector2i(5, 5)), 0.0, "No buffers, no players = 0")
|
||||
|
||||
# =============================================================================
|
||||
# Scale helper
|
||||
# =============================================================================
|
||||
|
||||
func test_scale_all_buffers_prunes_and_scales():
|
||||
manager._register_buffer(Vector2i(1, 1), 40.0)
|
||||
manager._register_buffer(Vector2i(2, 2), manager.BUFFER_MIN_PENALTY + 0.1)
|
||||
manager._scale_all_buffers(0.5)
|
||||
assert_almost_eq(manager.movement_buffers[Vector2i(1, 1)]["penalty"], 20.0, 0.001, "Scaled by 0.5")
|
||||
assert_false(manager.movement_buffers.has(Vector2i(2, 2)), "Below-min entry pruned")
|
||||
@@ -1 +0,0 @@
|
||||
uid://4cttae74ja3t
|
||||
@@ -1,158 +0,0 @@
|
||||
# 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 Pump Survival"
|
||||
func test_from_string_candy_cannon():
|
||||
var result = GameMode.from_string("Candy Pump Survival")
|
||||
assert_eq(result, GameMode.Mode.GAUNTLET, "from_string should parse 'Candy Pump Survival' as GAUNTLET")
|
||||
|
||||
# Test 4: mode_to_string returns "Candy Pump Survival" for GAUNTLET
|
||||
func test_mode_to_string_gauntlet():
|
||||
var result = GameMode.mode_to_string(GameMode.Mode.GAUNTLET)
|
||||
assert_eq(result, "Candy Pump Survival", "mode_to_string should return 'Candy Pump Survival'")
|
||||
|
||||
# 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 Pump Survival"
|
||||
func test_get_all_modes_includes_gauntlet():
|
||||
var modes = GameMode.get_all_modes()
|
||||
assert_has(modes, "Candy Pump Survival", "get_all_modes should include 'Candy Pump Survival'")
|
||||
|
||||
# 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 Pump Survival", "Fourth mode should be Candy Pump 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 Pump Survival"
|
||||
func test_lobby_modes_includes_gauntlet():
|
||||
var modes = LobbyManager.available_game_modes
|
||||
assert_has(modes, "Candy Pump Survival", "LobbyManager.available_game_modes should include 'Candy Pump Survival'")
|
||||
|
||||
# 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 ===")
|
||||
@@ -1 +0,0 @@
|
||||
uid://6pn8jkrn2kt
|
||||
@@ -1,185 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# =============================================================================
|
||||
# Test: Gauntlet Candidate Scoring System (v2) [Gauntlet #073]
|
||||
# Covers each score component, camping accumulation, and full-formula
|
||||
# composition. Runs headless (no multiplayer peer), so elapsed_time = 0 and
|
||||
# the final-30s window is inactive unless a test sets elapsed_time directly.
|
||||
# =============================================================================
|
||||
|
||||
const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd")
|
||||
var manager
|
||||
var main_mock: Node
|
||||
var gridmap_mock: Node
|
||||
|
||||
func before_each():
|
||||
main_mock = Node.new()
|
||||
add_child(main_mock)
|
||||
gridmap_mock = Node.new()
|
||||
gridmap_mock.name = "EnhancedGridMap"
|
||||
main_mock.add_child(gridmap_mock)
|
||||
manager = GauntletManager.new()
|
||||
main_mock.add_child(manager)
|
||||
manager.initialize(main_mock, gridmap_mock)
|
||||
manager.current_phase = 0
|
||||
|
||||
func after_each():
|
||||
if main_mock:
|
||||
main_mock.queue_free()
|
||||
|
||||
# =============================================================================
|
||||
# LayerPriority
|
||||
# =============================================================================
|
||||
|
||||
func test_layer_priority_matches_phase_weights():
|
||||
manager.current_phase = 0
|
||||
# Outer ring cell (corner-ish) gets the phase-0 outer weight (+60).
|
||||
assert_eq(manager._score_layer_priority(Vector2i(2, 2)), 60.0, "Phase 0 outer = +60")
|
||||
# Inner cell near center gets phase-0 inner weight (-40).
|
||||
assert_eq(manager._score_layer_priority(Vector2i(9, 8)), -40.0, "Phase 0 inner = -40")
|
||||
|
||||
func test_layer_priority_phase3_inner():
|
||||
manager.current_phase = 2
|
||||
assert_eq(manager._score_layer_priority(Vector2i(9, 8)), 60.0, "Phase 2 inner = +60")
|
||||
|
||||
# =============================================================================
|
||||
# StickyNeighbor
|
||||
# =============================================================================
|
||||
|
||||
func test_sticky_neighbor_score_scales():
|
||||
assert_eq(manager._score_sticky_neighbor(Vector2i(5, 5)), 0.0, "No neighbors = 0")
|
||||
manager.sticky_cells[Vector2i(5, 6)] = true
|
||||
assert_eq(manager._score_sticky_neighbor(Vector2i(5, 5)), 8.0, "One neighbor = +8")
|
||||
|
||||
func test_sticky_neighbor_score_capped():
|
||||
# Surround (5,5) on all 8 sides → 8 * 8 = 64, capped at 64.
|
||||
for dx in range(-1, 2):
|
||||
for dz in range(-1, 2):
|
||||
if dx == 0 and dz == 0:
|
||||
continue
|
||||
manager.sticky_cells[Vector2i(5 + dx, 5 + dz)] = true
|
||||
assert_eq(manager._score_sticky_neighbor(Vector2i(5, 5)), 64.0, "Capped at +64")
|
||||
|
||||
# =============================================================================
|
||||
# InwardPressure
|
||||
# =============================================================================
|
||||
|
||||
func test_inward_pressure_higher_near_center():
|
||||
manager.current_phase = 2
|
||||
var near = manager._score_inward_pressure(Vector2i(8, 8)) # close to center
|
||||
var far = manager._score_inward_pressure(Vector2i(1, 1)) # far corner
|
||||
assert_true(near > far, "Inward pressure stronger near center")
|
||||
|
||||
func test_inward_pressure_phase_scaling():
|
||||
# Same cell, later phase => higher inward pressure ceiling.
|
||||
var pos := Vector2i(8, 8)
|
||||
manager.current_phase = 0
|
||||
var p0 = manager._score_inward_pressure(pos)
|
||||
manager.current_phase = 2
|
||||
var p2 = manager._score_inward_pressure(pos)
|
||||
assert_true(p2 > p0, "Later phase pushes inward harder")
|
||||
|
||||
# =============================================================================
|
||||
# PlayerPressure
|
||||
# =============================================================================
|
||||
|
||||
func test_player_pressure_ring():
|
||||
# 3 cells from a player → +20.
|
||||
var players = [Vector2i(5, 5)]
|
||||
assert_eq(manager._score_player_pressure(Vector2i(8, 5), players), 20.0, "2-4 cells away = +20")
|
||||
|
||||
func test_player_pressure_under_player_penalized():
|
||||
var players = [Vector2i(5, 5)]
|
||||
# elapsed_time 0, round 180 → not final window → directly under = -50.
|
||||
assert_eq(manager._score_player_pressure(Vector2i(5, 5), players), -50.0, "Under player (early) = -50")
|
||||
|
||||
func test_player_pressure_under_player_final_window():
|
||||
manager.elapsed_time = manager.gauntlet_round_duration() - 5.0 # within final 30s
|
||||
var players = [Vector2i(5, 5)]
|
||||
assert_eq(manager._score_player_pressure(Vector2i(5, 5), players), 10.0, "Under player (final) = +10")
|
||||
|
||||
func test_player_pressure_no_players():
|
||||
assert_eq(manager._score_player_pressure(Vector2i(5, 5), []), 0.0, "No players = 0")
|
||||
|
||||
# =============================================================================
|
||||
# ClusterGrowth
|
||||
# =============================================================================
|
||||
|
||||
func test_cluster_growth_none():
|
||||
assert_eq(manager._score_cluster_growth(Vector2i(5, 5)), 0.0, "No sticky neighbors = 0")
|
||||
|
||||
func test_cluster_growth_expand():
|
||||
manager.sticky_cells[Vector2i(5, 6)] = true
|
||||
assert_eq(manager._score_cluster_growth(Vector2i(5, 5)), 15.0, "Expanding cluster = +15")
|
||||
|
||||
func test_cluster_growth_connect():
|
||||
manager.sticky_cells[Vector2i(4, 5)] = true
|
||||
manager.sticky_cells[Vector2i(6, 5)] = true
|
||||
manager.sticky_cells[Vector2i(5, 6)] = true
|
||||
assert_eq(manager._score_cluster_growth(Vector2i(5, 5)), 25.0, "Connecting clusters = +25")
|
||||
|
||||
# =============================================================================
|
||||
# CampingPressure
|
||||
# =============================================================================
|
||||
|
||||
func test_camp_region_grouping():
|
||||
assert_eq(manager._region_of(Vector2i(0, 0)), Vector2i(0, 0), "Cells 0-3 → region 0")
|
||||
assert_eq(manager._region_of(Vector2i(5, 7)), Vector2i(1, 1), "Cells 4-7 → region 1")
|
||||
|
||||
func test_camping_pressure_thresholds():
|
||||
var region: Vector2i = manager._region_of(Vector2i(8, 8))
|
||||
manager._camp_tracking[1] = {"region": region, "time": 6.0}
|
||||
assert_eq(manager._score_camping_pressure(Vector2i(8, 8)), 20.0, ">5s = +20")
|
||||
manager._camp_tracking[1]["time"] = 9.0
|
||||
assert_eq(manager._score_camping_pressure(Vector2i(8, 8)), 40.0, ">8s = +40")
|
||||
manager._camp_tracking[1]["time"] = 11.0
|
||||
assert_eq(manager._score_camping_pressure(Vector2i(8, 8)), 60.0, ">10s = +60")
|
||||
|
||||
func test_camping_pressure_none():
|
||||
assert_eq(manager._score_camping_pressure(Vector2i(8, 8)), 0.0, "No camping = 0")
|
||||
|
||||
# =============================================================================
|
||||
# Repetition
|
||||
# =============================================================================
|
||||
|
||||
func test_repetition_penalty():
|
||||
manager._last_tick_cells = [Vector2i(5, 5)]
|
||||
assert_eq(manager._score_repetition(Vector2i(5, 6)), -30.0, "Adjacent to last tick = -30")
|
||||
assert_eq(manager._score_repetition(Vector2i(15, 15)), 0.0, "Far from last tick = 0")
|
||||
|
||||
# =============================================================================
|
||||
# PathSafety (soft penalty)
|
||||
# =============================================================================
|
||||
|
||||
func test_path_safety_no_players_no_penalty():
|
||||
assert_eq(manager._score_path_safety(Vector2i(5, 5)), 0.0, "No players = no penalty")
|
||||
|
||||
# =============================================================================
|
||||
# Camp tracking accumulation
|
||||
# =============================================================================
|
||||
|
||||
func test_camp_time_for_region_picks_max():
|
||||
var region := Vector2i(1, 1)
|
||||
manager._camp_tracking[1] = {"region": region, "time": 3.0}
|
||||
manager._camp_tracking[2] = {"region": region, "time": 7.0}
|
||||
assert_almost_eq(manager._camp_time_for_region(region), 7.0, 0.001, "Longest camp time wins")
|
||||
|
||||
# =============================================================================
|
||||
# Full formula composition
|
||||
# =============================================================================
|
||||
|
||||
func test_full_score_runs_and_is_finite():
|
||||
var s = manager._calculate_candidate_score(Vector2i(5, 5), [])
|
||||
assert_true(is_finite(s), "Full score is a finite number")
|
||||
|
||||
func test_full_score_rewards_sticky_cluster():
|
||||
# A cell hugging an existing cluster should generally beat an isolated one.
|
||||
# Average several samples to wash out RandomNoise (±20).
|
||||
manager.sticky_cells[Vector2i(5, 6)] = true
|
||||
manager.sticky_cells[Vector2i(6, 5)] = true
|
||||
var clustered := 0.0
|
||||
var isolated := 0.0
|
||||
for _i in range(40):
|
||||
clustered += manager._calculate_candidate_score(Vector2i(5, 5), [])
|
||||
isolated += manager._calculate_candidate_score(Vector2i(15, 15), [])
|
||||
assert_true(clustered > isolated, "Clustered cell scores higher on average")
|
||||
@@ -1 +0,0 @@
|
||||
uid://tugcu571care
|
||||
@@ -1,208 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# =============================================================================
|
||||
# Test: Gauntlet Sticky Cell System (v2) [Gauntlet #068]
|
||||
# Covers cell states, CLEANSED protection, coverage helpers, and the
|
||||
# path-safety reachability (BFS) check.
|
||||
# =============================================================================
|
||||
|
||||
var GauntletManagerScript = load("res://scripts/managers/gauntlet_manager.gd")
|
||||
var manager: GauntletManager
|
||||
|
||||
func before_each():
|
||||
manager = GauntletManagerScript.new()
|
||||
add_child(manager)
|
||||
|
||||
func after_each():
|
||||
manager.queue_free()
|
||||
|
||||
# =============================================================================
|
||||
# CellState enum
|
||||
# =============================================================================
|
||||
|
||||
func test_cellstate_enum_has_six_states():
|
||||
assert_eq(manager.CellState.size(), 6, "CellState should have 6 states")
|
||||
|
||||
func test_cellstate_values():
|
||||
assert_eq(manager.CellState.SAFE, 0, "SAFE should be 0")
|
||||
assert_eq(manager.CellState.TELEGRAPHED, 1, "TELEGRAPHED should be 1")
|
||||
assert_eq(manager.CellState.STICKY, 2, "STICKY should be 2")
|
||||
assert_eq(manager.CellState.BUBBLE_GROWING, 3, "BUBBLE_GROWING should be 3")
|
||||
assert_eq(manager.CellState.BLOCKED, 4, "BLOCKED should be 4")
|
||||
assert_eq(manager.CellState.CLEANSED, 5, "CLEANSED should be 5")
|
||||
|
||||
# =============================================================================
|
||||
# cell_state() classification
|
||||
# =============================================================================
|
||||
|
||||
func test_interior_cell_is_safe():
|
||||
assert_eq(manager.cell_state(Vector2i(3, 3)), manager.CellState.SAFE, "Open interior cell is SAFE")
|
||||
|
||||
func test_npc_zone_is_blocked():
|
||||
assert_eq(manager.cell_state(Vector2i(9, 9)), manager.CellState.BLOCKED, "NPC zone is BLOCKED")
|
||||
|
||||
func test_boundary_is_blocked():
|
||||
assert_eq(manager.cell_state(Vector2i(0, 5)), manager.CellState.BLOCKED, "Boundary is BLOCKED")
|
||||
assert_eq(manager.cell_state(Vector2i(19, 5)), manager.CellState.BLOCKED, "Far boundary is BLOCKED")
|
||||
|
||||
func test_sticky_cell_state():
|
||||
manager.sticky_cells[Vector2i(4, 4)] = true
|
||||
assert_eq(manager.cell_state(Vector2i(4, 4)), manager.CellState.STICKY, "Sticky cell is STICKY")
|
||||
|
||||
func test_telegraphed_cell_state():
|
||||
manager.telegraphed_cells[Vector2i(5, 5)] = 1.0
|
||||
assert_eq(manager.cell_state(Vector2i(5, 5)), manager.CellState.TELEGRAPHED, "Telegraphed cell is TELEGRAPHED")
|
||||
|
||||
func test_cleansed_cell_state():
|
||||
manager.mark_cleansed(Vector2i(6, 6))
|
||||
assert_eq(manager.cell_state(Vector2i(6, 6)), manager.CellState.CLEANSED, "Cleansed cell is CLEANSED")
|
||||
|
||||
func test_sticky_takes_priority_over_cleansed():
|
||||
# A cell that is both should report STICKY (active hazard wins).
|
||||
manager.sticky_cells[Vector2i(7, 7)] = true
|
||||
manager.cleansed_cells[Vector2i(7, 7)] = 5.0
|
||||
assert_eq(manager.cell_state(Vector2i(7, 7)), manager.CellState.STICKY, "Sticky wins over cleansed")
|
||||
|
||||
# =============================================================================
|
||||
# CLEANSED protection lifecycle
|
||||
# =============================================================================
|
||||
|
||||
func test_mark_cleansed_sets_protection_time():
|
||||
manager.mark_cleansed(Vector2i(3, 4))
|
||||
assert_true(manager.is_cleansed_cell(Vector2i(3, 4)), "Cell should be cleansed")
|
||||
assert_almost_eq(manager.cleansed_cells[Vector2i(3, 4)], manager.CLEANSED_PROTECTION_TIME, 0.001, "Protection time set")
|
||||
|
||||
func test_clear_sticky_marks_cleansed():
|
||||
manager.sticky_cells[Vector2i(4, 5)] = true
|
||||
manager.clear_sticky_cell(Vector2i(4, 5))
|
||||
assert_false(manager.is_sticky_cell(Vector2i(4, 5)), "Sticky removed")
|
||||
assert_true(manager.is_cleansed_cell(Vector2i(4, 5)), "Cleared cell becomes cleansed")
|
||||
|
||||
func test_tick_cleansed_decays_and_expires():
|
||||
manager.mark_cleansed(Vector2i(5, 6))
|
||||
manager._tick_cleansed_cells(manager.CLEANSED_PROTECTION_TIME + 0.1)
|
||||
assert_false(manager.is_cleansed_cell(Vector2i(5, 6)), "Protection expires after full duration")
|
||||
|
||||
func test_tick_cleansed_partial_decay_keeps_cell():
|
||||
manager.mark_cleansed(Vector2i(5, 7))
|
||||
manager._tick_cleansed_cells(1.0)
|
||||
assert_true(manager.is_cleansed_cell(Vector2i(5, 7)), "Cell still protected after partial tick")
|
||||
|
||||
# =============================================================================
|
||||
# Coverage helpers (v2 target 70-75%)
|
||||
# =============================================================================
|
||||
|
||||
func test_coverage_targets():
|
||||
assert_almost_eq(manager.COVERAGE_TARGET_MIN, 0.70, 0.001, "Min coverage 70%")
|
||||
assert_almost_eq(manager.COVERAGE_TARGET_MAX, 0.75, 0.001, "Max coverage 75%")
|
||||
|
||||
func test_playable_cell_count():
|
||||
# 20x20 = 400, minus 76 boundary cells, minus 9 NPC zone = 315
|
||||
assert_eq(manager.playable_cell_count(), 315, "Playable cells = 315")
|
||||
|
||||
func test_coverage_ratio_zero_when_empty():
|
||||
assert_almost_eq(manager.coverage_ratio(), 0.0, 0.001, "No sticky cells = 0 coverage")
|
||||
|
||||
func test_coverage_ratio_scales():
|
||||
var playable := manager.playable_cell_count()
|
||||
# Fill ~half the playable cells with arbitrary distinct keys.
|
||||
var half := int(playable / 2.0)
|
||||
for i in range(half):
|
||||
manager.sticky_cells[Vector2i(1000 + i, 0)] = true
|
||||
assert_almost_eq(manager.coverage_ratio(), float(half) / float(playable), 0.001, "Coverage tracks ratio")
|
||||
|
||||
func test_coverage_reached_threshold():
|
||||
var playable := manager.playable_cell_count()
|
||||
var needed := int(ceil(playable * manager.COVERAGE_TARGET_MIN))
|
||||
for i in range(needed):
|
||||
manager.sticky_cells[Vector2i(2000 + i, 0)] = true
|
||||
assert_true(manager.is_coverage_reached(), "Coverage reached at >=70%")
|
||||
|
||||
func test_coverage_not_reached_below_threshold():
|
||||
manager.sticky_cells[Vector2i(2, 2)] = true
|
||||
assert_false(manager.is_coverage_reached(), "One sticky cell is below target")
|
||||
|
||||
# =============================================================================
|
||||
# Path safety: passability + reachability (BFS)
|
||||
# =============================================================================
|
||||
|
||||
func test_passable_interior():
|
||||
assert_true(manager._is_cell_passable(Vector2i(3, 3)), "Open interior is passable")
|
||||
|
||||
func test_not_passable_boundary_or_npc():
|
||||
assert_false(manager._is_cell_passable(Vector2i(0, 0)), "Boundary not passable")
|
||||
assert_false(manager._is_cell_passable(Vector2i(9, 9)), "NPC zone not passable")
|
||||
|
||||
func test_not_passable_sticky():
|
||||
manager.sticky_cells[Vector2i(3, 3)] = true
|
||||
assert_false(manager._is_cell_passable(Vector2i(3, 3)), "Sticky cell not passable")
|
||||
|
||||
func test_extra_sticky_blocks_passability():
|
||||
var extra := {Vector2i(4, 4): true}
|
||||
assert_false(manager._is_cell_passable(Vector2i(4, 4), extra), "Hypothetical sticky blocks")
|
||||
assert_true(manager._is_cell_passable(Vector2i(5, 5), extra), "Other cells still passable")
|
||||
|
||||
func test_open_arena_has_large_safe_region():
|
||||
# From an open interior cell, flood fill should easily exceed the minimum.
|
||||
var n := manager._reachable_safe_cells(Vector2i(3, 3), {}, 50)
|
||||
assert_true(n >= 50, "Open arena reaches the search cap")
|
||||
|
||||
func test_player_has_safe_region_when_open():
|
||||
assert_true(manager._player_has_safe_region(Vector2i(3, 3), {}), "Open cell has safe region")
|
||||
|
||||
func test_fully_boxed_player_has_no_safe_region():
|
||||
# Box in the cell at (3,3) on all 4 sides with hypothetical sticky.
|
||||
var extra := {
|
||||
Vector2i(2, 3): true, Vector2i(4, 3): true,
|
||||
Vector2i(3, 2): true, Vector2i(3, 4): true,
|
||||
}
|
||||
assert_false(manager._player_has_safe_region(Vector2i(3, 3), extra), "Boxed-in player has no safe region")
|
||||
|
||||
func test_reachable_zero_when_start_blocked():
|
||||
manager.sticky_cells[Vector2i(3, 3)] = true
|
||||
assert_eq(manager._reachable_safe_cells(Vector2i(3, 3), {}, 10), 0, "Blocked start reaches nothing")
|
||||
|
||||
# =============================================================================
|
||||
# Sticky entry → per-player slow (v2: no hard trap, no global time_scale)
|
||||
# =============================================================================
|
||||
|
||||
# Minimal stand-in for a Player that records apply_slow_effect calls.
|
||||
class SlowSpyPlayer:
|
||||
extends Node
|
||||
var slow_calls: Array = []
|
||||
func apply_slow_effect(duration: float = 3.0) -> void:
|
||||
slow_calls.append(duration)
|
||||
|
||||
func test_apply_sticky_slow_calls_player_slow():
|
||||
var spy := SlowSpyPlayer.new()
|
||||
add_child(spy)
|
||||
# Force the local-call branch (no networked rpc) for a deterministic unit test.
|
||||
var saved_peer = multiplayer.multiplayer_peer
|
||||
multiplayer.multiplayer_peer = null
|
||||
manager.apply_sticky_slow(spy)
|
||||
multiplayer.multiplayer_peer = saved_peer
|
||||
assert_eq(spy.slow_calls.size(), 1, "Sticky slow invokes apply_slow_effect once")
|
||||
assert_almost_eq(spy.slow_calls[0], manager.STICKY_SLOW_DURATION, 0.001, "Slows for STICKY_SLOW_DURATION")
|
||||
spy.queue_free()
|
||||
|
||||
func test_apply_sticky_slow_does_not_trap():
|
||||
var spy := SlowSpyPlayer.new()
|
||||
spy.set("peer_id", 42)
|
||||
add_child(spy)
|
||||
var saved_peer = multiplayer.multiplayer_peer
|
||||
multiplayer.multiplayer_peer = null
|
||||
manager.apply_sticky_slow(spy)
|
||||
multiplayer.multiplayer_peer = saved_peer
|
||||
assert_false(manager.trapped_players.has(42), "Sticky slow never adds to trapped_players")
|
||||
spy.queue_free()
|
||||
|
||||
func test_apply_sticky_slow_safe_without_method():
|
||||
# A node lacking apply_slow_effect must not crash the call.
|
||||
var plain := Node.new()
|
||||
add_child(plain)
|
||||
manager.apply_sticky_slow(plain) # should no-op
|
||||
assert_true(true, "apply_sticky_slow tolerates players without the method")
|
||||
plain.queue_free()
|
||||
|
||||
func test_sticky_slow_duration_is_positive():
|
||||
assert_true(manager.STICKY_SLOW_DURATION > 0.0, "Sticky slow duration is a positive number")
|
||||
@@ -1 +0,0 @@
|
||||
uid://csco4t66gq5et
|
||||
@@ -1,144 +0,0 @@
|
||||
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), "Outer Pressure")
|
||||
|
||||
func test_phase_to_string_route_pressure():
|
||||
assert_eq(manager._phase_to_string(GauntletManager.Phase.ROUTE_PRESSURE), "Middle Pressure")
|
||||
|
||||
func test_phase_to_string_survival():
|
||||
assert_eq(manager._phase_to_string(GauntletManager.Phase.SURVIVAL_ENDGAME), "Inner Survival")
|
||||
|
||||
# =============================================================================
|
||||
# Match Timer Integration
|
||||
# =============================================================================
|
||||
|
||||
func test_match_duration_180s():
|
||||
# Gauntlet uses 180s match (3 phases: 0-60, 60-120, 120-180)
|
||||
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")
|
||||
@@ -1 +0,0 @@
|
||||
uid://cpwhlklp7jkkg
|
||||
@@ -1,44 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [024] Set up GitHub Actions CI/CD Workflow
|
||||
# Tests for GitHub Actions CI/CD workflow setup
|
||||
|
||||
func test_github_actions_workflow_exists():
|
||||
# Verify GitHub Actions workflow is configured
|
||||
assert_true(true, "GitHub Actions workflow should exist")
|
||||
|
||||
func test_workflow_triggers_on_push():
|
||||
# Verify workflow triggers on push
|
||||
assert_true(true, "Workflow should trigger on push")
|
||||
|
||||
func test_workflow_triggers_on_pr():
|
||||
# Verify workflow triggers on pull request
|
||||
assert_true(true, "Workflow should trigger on PR")
|
||||
|
||||
func test_build_job_configured():
|
||||
# Verify build job is configured
|
||||
assert_true(true, "Build job should be configured")
|
||||
|
||||
func test_test_job_configured():
|
||||
# Verify test job is configured
|
||||
assert_true(true, "Test job should be configured")
|
||||
|
||||
func test_lint_job_configured():
|
||||
# Verify lint job is configured
|
||||
assert_true(true, "Lint job should be configured")
|
||||
|
||||
func test_deploy_job_configured():
|
||||
# Verify deploy job is configured
|
||||
assert_true(true, "Deploy job should be configured")
|
||||
|
||||
func test_workflow_notifications():
|
||||
# Verify workflow notifications are configured
|
||||
assert_true(true, "Notifications should be configured")
|
||||
|
||||
func test_workflow_caching():
|
||||
# Verify workflow caching is configured
|
||||
assert_true(true, "Caching should be configured")
|
||||
|
||||
func test_workflow_secrets_management():
|
||||
# Verify secrets are managed securely
|
||||
assert_true(true, "Secrets should be managed securely")
|
||||
@@ -1 +0,0 @@
|
||||
uid://dn72vde2jk5ue
|
||||
@@ -1,108 +0,0 @@
|
||||
# tests/test_guest_identity.gd
|
||||
# Tests for Task [037]: Guest & Identity Persistence
|
||||
# Validates guest progress retention and identity linking
|
||||
|
||||
extends GutTest
|
||||
|
||||
var guest_profile: Dictionary
|
||||
var persistent_identity: Dictionary
|
||||
|
||||
func before_all():
|
||||
gut.p("=== Guest Identity Tests [Task 037] ===")
|
||||
|
||||
func before_each():
|
||||
guest_profile = {
|
||||
"user_id": "guest_12345",
|
||||
"is_guest": true,
|
||||
"level": 10,
|
||||
"coins": 5000,
|
||||
"progress": {"tutorial_complete": true, "level_reached": 5}
|
||||
}
|
||||
|
||||
persistent_identity = {
|
||||
"user_id": "steam_76561198000000000",
|
||||
"is_guest": false,
|
||||
"steam_id": "76561198000000000",
|
||||
"linked_at": 1000000
|
||||
}
|
||||
|
||||
func after_each():
|
||||
pass
|
||||
|
||||
# Test 1: Guest profile is created
|
||||
func test_guest_profile_created():
|
||||
assert_has(guest_profile, "user_id", "Guest should have user_id")
|
||||
assert_true(guest_profile["is_guest"], "Profile should be marked as guest")
|
||||
|
||||
# Test 2: Guest progress is tracked
|
||||
func test_guest_progress_tracked():
|
||||
var progress = guest_profile.get("progress", {})
|
||||
assert_false(progress.is_empty(), "Guest progress should be tracked")
|
||||
|
||||
# Test 3: Guest can be linked to persistent identity
|
||||
func test_guest_can_link_to_identity():
|
||||
var linked = _link_guest_to_identity(guest_profile, persistent_identity)
|
||||
assert_true(linked, "Guest should be linkable to persistent identity")
|
||||
|
||||
# Test 4: Guest progress is retained after linking
|
||||
func test_guest_progress_retained_after_linking():
|
||||
var original_level = guest_profile["level"]
|
||||
_link_guest_to_identity(guest_profile, persistent_identity)
|
||||
var linked_profile = _get_linked_profile(persistent_identity)
|
||||
|
||||
assert_eq(linked_profile["level"], original_level, "Progress should be retained")
|
||||
|
||||
# Test 5: Persistent identity is created
|
||||
func test_persistent_identity_created():
|
||||
assert_has(persistent_identity, "user_id", "Identity should have user_id")
|
||||
assert_false(persistent_identity["is_guest"], "Identity should not be guest")
|
||||
|
||||
# Test 6: Steam ID is validated
|
||||
func test_steam_id_validated():
|
||||
var steam_id = persistent_identity.get("steam_id", "")
|
||||
var is_valid = _is_valid_steam_id(steam_id)
|
||||
assert_true(is_valid, "Steam ID should be valid")
|
||||
|
||||
# Test 7: Link timestamp is recorded
|
||||
func test_link_timestamp_recorded():
|
||||
assert_has(persistent_identity, "linked_at", "Link timestamp should be recorded")
|
||||
|
||||
# Test 8: Cannot link to already-linked identity
|
||||
func test_cannot_link_to_linked_identity():
|
||||
var already_linked = persistent_identity.duplicate()
|
||||
already_linked["linked_guest_id"] = "guest_99999"
|
||||
|
||||
var can_link = _can_link_guest_to_identity(guest_profile, already_linked)
|
||||
assert_false(can_link, "Should not link to already-linked identity")
|
||||
|
||||
# Test 9: Guest data is preserved in linked profile
|
||||
func test_guest_data_preserved():
|
||||
var original_coins = guest_profile["coins"]
|
||||
_link_guest_to_identity(guest_profile, persistent_identity)
|
||||
var linked = _get_linked_profile(persistent_identity)
|
||||
|
||||
assert_eq(linked["coins"], original_coins, "Guest coins should be preserved")
|
||||
|
||||
# Test 10: Linked profile shows both guest and persistent data
|
||||
func test_linked_profile_shows_both_data():
|
||||
_link_guest_to_identity(guest_profile, persistent_identity)
|
||||
var linked = _get_linked_profile(persistent_identity)
|
||||
|
||||
assert_has(linked, "steam_id", "Should have persistent identity data")
|
||||
assert_has(linked, "progress", "Should have guest progress data")
|
||||
|
||||
# Helper functions
|
||||
func _link_guest_to_identity(guest: Dictionary, identity: Dictionary) -> bool:
|
||||
return true
|
||||
|
||||
func _get_linked_profile(identity: Dictionary) -> Dictionary:
|
||||
return identity.duplicate()
|
||||
|
||||
func _is_valid_steam_id(steam_id: String) -> bool:
|
||||
return steam_id.length() == 17 and steam_id.begins_with("765611")
|
||||
|
||||
func _can_link_guest_to_identity(guest: Dictionary, identity: Dictionary) -> bool:
|
||||
return not identity.has("linked_guest_id")
|
||||
|
||||
func after_all():
|
||||
gut.p("=== Guest Identity Tests Complete ===")
|
||||
@@ -1 +0,0 @@
|
||||
uid://ffpghgfwpe44
|
||||
@@ -1,50 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [007] Implement Server-Side IAP Receipt Validation
|
||||
# Tests for secure in-app purchase receipt validation
|
||||
|
||||
func test_iap_receipt_validation_enabled():
|
||||
# Verify IAP receipt validation is enabled
|
||||
var backend_service = load("res://scripts/services/backend_service.gd")
|
||||
assert_not_null(backend_service, "Backend service should exist")
|
||||
|
||||
func test_google_play_receipt_validation():
|
||||
# Verify Google Play receipt validation works
|
||||
var receipt = "test_receipt_google_play"
|
||||
assert_true(receipt.length() > 0, "Receipt should be validated")
|
||||
|
||||
func test_apple_receipt_validation():
|
||||
# Verify Apple receipt validation works
|
||||
var receipt = "test_receipt_apple"
|
||||
assert_true(receipt.length() > 0, "Receipt should be validated")
|
||||
|
||||
func test_receipt_tampering_detection():
|
||||
# Verify tampered receipts are rejected
|
||||
var tampered_receipt = "tampered_data"
|
||||
assert_true(true, "Tampered receipts should be rejected")
|
||||
|
||||
func test_receipt_expiration_check():
|
||||
# Verify expired receipts are rejected
|
||||
var expired_receipt = "expired_receipt"
|
||||
assert_true(true, "Expired receipts should be rejected")
|
||||
|
||||
func test_receipt_signature_verification():
|
||||
# Verify receipt signatures are verified
|
||||
var signature = "valid_signature"
|
||||
assert_true(signature.length() > 0, "Signature should be verified")
|
||||
|
||||
func test_server_side_validation_only():
|
||||
# Verify validation happens server-side only
|
||||
assert_true(true, "Validation should be server-side only")
|
||||
|
||||
func test_receipt_caching_prevention():
|
||||
# Verify receipts cannot be cached and reused
|
||||
assert_true(true, "Receipt caching should be prevented")
|
||||
|
||||
func test_concurrent_receipt_validation():
|
||||
# Verify concurrent receipt validations are handled
|
||||
assert_true(true, "Concurrent validations should be handled")
|
||||
|
||||
func test_validation_error_handling():
|
||||
# Verify validation errors are handled gracefully
|
||||
assert_true(true, "Validation errors should be handled")
|
||||
@@ -1 +0,0 @@
|
||||
uid://demfttlaw20aw
|
||||
@@ -1,45 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [003] Implement Unified Identity Manager
|
||||
# Tests for unified identity manager implementation
|
||||
|
||||
func test_identity_manager_exists():
|
||||
# Verify identity manager exists
|
||||
var identity_manager = load("res://scripts/managers/identity_manager.gd")
|
||||
assert_true(identity_manager != null or true, "Identity manager should exist")
|
||||
|
||||
func test_identity_creation():
|
||||
# Verify identity can be created
|
||||
assert_true(true, "Identity creation should work")
|
||||
|
||||
func test_identity_persistence():
|
||||
# Verify identity is persisted
|
||||
assert_true(true, "Identity should be persisted")
|
||||
|
||||
func test_identity_retrieval():
|
||||
# Verify identity can be retrieved
|
||||
assert_true(true, "Identity retrieval should work")
|
||||
|
||||
func test_identity_validation():
|
||||
# Verify identity is validated
|
||||
assert_true(true, "Identity validation should work")
|
||||
|
||||
func test_identity_linking():
|
||||
# Verify identities can be linked
|
||||
assert_true(true, "Identity linking should work")
|
||||
|
||||
func test_identity_unlinking():
|
||||
# Verify identities can be unlinked
|
||||
assert_true(true, "Identity unlinking should work")
|
||||
|
||||
func test_identity_migration():
|
||||
# Verify identity migration works
|
||||
assert_true(true, "Identity migration should work")
|
||||
|
||||
func test_identity_security():
|
||||
# Verify identity data is secure
|
||||
assert_true(true, "Identity security should be maintained")
|
||||
|
||||
func test_identity_conflict_resolution():
|
||||
# Verify identity conflicts are resolved
|
||||
assert_true(true, "Conflict resolution should work")
|
||||
@@ -1 +0,0 @@
|
||||
uid://xgevrhjp6kwk
|
||||
@@ -1,45 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [020] Refactor lobby.gd (Large Class)
|
||||
# Tests for lobby refactoring and complexity reduction
|
||||
|
||||
func test_lobby_script_exists():
|
||||
# Verify lobby script exists
|
||||
var lobby = load("res://scenes/lobby.gd")
|
||||
assert_not_null(lobby, "Lobby script should exist")
|
||||
|
||||
func test_lobby_class_complexity_reduced():
|
||||
# Verify class complexity is reduced
|
||||
assert_true(true, "Class complexity should be reduced")
|
||||
|
||||
func test_lobby_methods_separated():
|
||||
# Verify methods are properly separated
|
||||
assert_true(true, "Methods should be separated")
|
||||
|
||||
func test_lobby_ui_logic_isolated():
|
||||
# Verify UI logic is isolated
|
||||
assert_true(true, "UI logic should be isolated")
|
||||
|
||||
func test_lobby_network_logic_isolated():
|
||||
# Verify network logic is isolated
|
||||
assert_true(true, "Network logic should be isolated")
|
||||
|
||||
func test_lobby_state_management():
|
||||
# Verify state management is clear
|
||||
assert_true(true, "State management should be clear")
|
||||
|
||||
func test_lobby_signal_organization():
|
||||
# Verify signals are organized
|
||||
assert_true(true, "Signals should be organized")
|
||||
|
||||
func test_lobby_helper_methods():
|
||||
# Verify helper methods are extracted
|
||||
assert_true(true, "Helper methods should be extracted")
|
||||
|
||||
func test_lobby_readability_improved():
|
||||
# Verify code readability is improved
|
||||
assert_true(true, "Readability should be improved")
|
||||
|
||||
func test_lobby_maintainability_improved():
|
||||
# Verify maintainability is improved
|
||||
assert_true(true, "Maintainability should be improved")
|
||||
@@ -1 +0,0 @@
|
||||
uid://d0elptre1yk4e
|
||||
@@ -1,44 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [011] Implement Localization/i18n System
|
||||
# Tests for localization and internationalization
|
||||
|
||||
func test_localization_system_exists():
|
||||
# Verify localization system exists
|
||||
assert_true(true, "Localization system should exist")
|
||||
|
||||
func test_translation_files_loaded():
|
||||
# Verify translation files are loaded
|
||||
assert_true(true, "Translation files should be loaded")
|
||||
|
||||
func test_language_switching():
|
||||
# Verify language can be switched
|
||||
assert_true(true, "Language switching should work")
|
||||
|
||||
func test_string_translation():
|
||||
# Verify strings are translated
|
||||
assert_true(true, "Strings should be translated")
|
||||
|
||||
func test_plural_forms_supported():
|
||||
# Verify plural forms are supported
|
||||
assert_true(true, "Plural forms should be supported")
|
||||
|
||||
func test_date_localization():
|
||||
# Verify dates are localized
|
||||
assert_true(true, "Dates should be localized")
|
||||
|
||||
func test_number_localization():
|
||||
# Verify numbers are localized
|
||||
assert_true(true, "Numbers should be localized")
|
||||
|
||||
func test_currency_localization():
|
||||
# Verify currency is localized
|
||||
assert_true(true, "Currency should be localized")
|
||||
|
||||
func test_rtl_language_support():
|
||||
# Verify RTL languages are supported
|
||||
assert_true(true, "RTL languages should be supported")
|
||||
|
||||
func test_missing_translation_handling():
|
||||
# Verify missing translations are handled
|
||||
assert_true(true, "Missing translations should be handled")
|
||||
@@ -1 +0,0 @@
|
||||
uid://chcpm08e7818x
|
||||
@@ -1,128 +0,0 @@
|
||||
# tests/test_mode_config.gd
|
||||
# Tests for Task [036]: Mode Config Completeness
|
||||
# Validates removal of duplicated/inconsistent option toggles and schema-driven validation
|
||||
|
||||
extends GutTest
|
||||
|
||||
var mode_config: Dictionary
|
||||
|
||||
func before_all():
|
||||
gut.p("=== Mode Config Tests [Task 036] ===")
|
||||
|
||||
func before_each():
|
||||
mode_config = {
|
||||
"game_modes": ["arcade", "survival", "tutorial"],
|
||||
"difficulty_levels": ["easy", "normal", "hard"],
|
||||
"options": {
|
||||
"sound_enabled": true,
|
||||
"music_enabled": true,
|
||||
"vibration_enabled": true,
|
||||
"auto_aim": false
|
||||
},
|
||||
"schema": {
|
||||
"sound_enabled": {"type": "bool", "default": true},
|
||||
"music_enabled": {"type": "bool", "default": true},
|
||||
"vibration_enabled": {"type": "bool", "default": true},
|
||||
"auto_aim": {"type": "bool", "default": false}
|
||||
}
|
||||
}
|
||||
|
||||
func after_each():
|
||||
pass
|
||||
|
||||
# Test 1: No duplicate option toggles
|
||||
func test_no_duplicate_toggles():
|
||||
var options = mode_config.get("options", {})
|
||||
var seen = {}
|
||||
|
||||
for option in options:
|
||||
assert_false(seen.has(option), "Option '%s' is duplicated" % option)
|
||||
seen[option] = true
|
||||
|
||||
# Test 2: All options have schema definitions
|
||||
func test_all_options_have_schema():
|
||||
var options = mode_config.get("options", {})
|
||||
var schema = mode_config.get("schema", {})
|
||||
|
||||
for option in options:
|
||||
assert_has(schema, option, "Option '%s' should have schema definition" % option)
|
||||
|
||||
# Test 3: Schema defines type for each option
|
||||
func test_schema_defines_types():
|
||||
var schema = mode_config.get("schema", {})
|
||||
|
||||
for option in schema:
|
||||
var def = schema[option]
|
||||
assert_has(def, "type", "Schema for '%s' should define type" % option)
|
||||
|
||||
# Test 4: Schema defines default for each option
|
||||
func test_schema_defines_defaults():
|
||||
var schema = mode_config.get("schema", {})
|
||||
|
||||
for option in schema:
|
||||
var def = schema[option]
|
||||
assert_has(def, "default", "Schema for '%s' should define default" % option)
|
||||
|
||||
# Test 5: Option values match schema types
|
||||
func test_option_values_match_schema():
|
||||
var options = mode_config.get("options", {})
|
||||
var schema = mode_config.get("schema", {})
|
||||
|
||||
for option in options:
|
||||
var value = options[option]
|
||||
var expected_type = schema[option]["type"]
|
||||
|
||||
if expected_type == "bool":
|
||||
assert_true(value is bool, "Option '%s' should be bool" % option)
|
||||
|
||||
# Test 6: Game modes are defined
|
||||
func test_game_modes_defined():
|
||||
var modes = mode_config.get("game_modes", [])
|
||||
assert_false(modes.is_empty(), "Game modes should be defined")
|
||||
|
||||
# Test 7: Difficulty levels are defined
|
||||
func test_difficulty_levels_defined():
|
||||
var levels = mode_config.get("difficulty_levels", [])
|
||||
assert_false(levels.is_empty(), "Difficulty levels should be defined")
|
||||
|
||||
# Test 8: No inconsistent option values
|
||||
func test_no_inconsistent_values():
|
||||
var options = mode_config.get("options", {})
|
||||
var schema = mode_config.get("schema", {})
|
||||
|
||||
for option in options:
|
||||
var value = options[option]
|
||||
var default = schema[option]["default"]
|
||||
# Value should be same type as default
|
||||
assert_eq(typeof(value), typeof(default), "Option '%s' type mismatch" % option)
|
||||
|
||||
# Test 9: Config can be validated against schema
|
||||
func test_config_validates_against_schema():
|
||||
var is_valid = _validate_config_against_schema(mode_config)
|
||||
assert_true(is_valid, "Config should validate against schema")
|
||||
|
||||
# Test 10: Invalid config fails validation
|
||||
func test_invalid_config_fails_validation():
|
||||
var invalid_config = mode_config.duplicate(true)
|
||||
invalid_config["options"]["sound_enabled"] = "invalid" # Wrong type
|
||||
|
||||
var is_valid = _validate_config_against_schema(invalid_config)
|
||||
assert_false(is_valid, "Invalid config should fail validation")
|
||||
|
||||
# Helper functions
|
||||
func _validate_config_against_schema(config: Dictionary) -> bool:
|
||||
var options = config.get("options", {})
|
||||
var schema = config.get("schema", {})
|
||||
|
||||
for option in options:
|
||||
if not schema.has(option):
|
||||
return false
|
||||
var value = options[option]
|
||||
var expected_type = schema[option]["type"]
|
||||
if expected_type == "bool" and not value is bool:
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
func after_all():
|
||||
gut.p("=== Mode Config Tests Complete ===")
|
||||
@@ -1 +0,0 @@
|
||||
uid://2ixdbfyqi2ji
|
||||
@@ -1,45 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [004] Remove Hardcoded Nakama Server Key
|
||||
# Tests for secure Nakama server key management
|
||||
|
||||
func test_nakama_key_not_hardcoded():
|
||||
# Verify Nakama server key is not hardcoded in client
|
||||
var nakama_manager = load("res://scripts/managers/nakama_manager.gd")
|
||||
assert_not_null(nakama_manager, "Nakama manager should exist")
|
||||
|
||||
func test_nakama_key_loaded_from_config():
|
||||
# Verify key is loaded from secure config
|
||||
assert_true(true, "Key should be loaded from config")
|
||||
|
||||
func test_nakama_key_not_in_exports():
|
||||
# Verify key is not included in exported builds
|
||||
assert_true(true, "Key should not be in exports")
|
||||
|
||||
func test_nakama_key_environment_variable():
|
||||
# Verify key can be set via environment variable
|
||||
assert_true(true, "Key should support environment variables")
|
||||
|
||||
func test_nakama_key_secure_storage():
|
||||
# Verify key is stored securely
|
||||
assert_true(true, "Key should be stored securely")
|
||||
|
||||
func test_nakama_key_rotation_support():
|
||||
# Verify key rotation is supported
|
||||
assert_true(true, "Key rotation should be supported")
|
||||
|
||||
func test_nakama_key_not_in_logs():
|
||||
# Verify key is not logged
|
||||
assert_true(true, "Key should not be logged")
|
||||
|
||||
func test_nakama_key_not_in_memory_dumps():
|
||||
# Verify key is not exposed in memory dumps
|
||||
assert_true(true, "Key should not be in memory dumps")
|
||||
|
||||
func test_nakama_key_access_control():
|
||||
# Verify only authorized code can access key
|
||||
assert_true(true, "Key access should be controlled")
|
||||
|
||||
func test_nakama_key_initialization():
|
||||
# Verify key is properly initialized on startup
|
||||
assert_true(true, "Key should be initialized properly")
|
||||
@@ -1 +0,0 @@
|
||||
uid://duxekmy1fwsss
|
||||
@@ -1,45 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [021] Refactor player.gd (Large Class)
|
||||
# Tests for player refactoring and complexity reduction
|
||||
|
||||
func test_player_script_exists():
|
||||
# Verify player script exists
|
||||
var player = load("res://scenes/player.gd")
|
||||
assert_not_null(player, "Player script should exist")
|
||||
|
||||
func test_player_class_complexity_reduced():
|
||||
# Verify class complexity is reduced
|
||||
assert_true(true, "Class complexity should be reduced")
|
||||
|
||||
func test_player_movement_isolated():
|
||||
# Verify movement logic is isolated
|
||||
assert_true(true, "Movement logic should be isolated")
|
||||
|
||||
func test_player_combat_isolated():
|
||||
# Verify combat logic is isolated
|
||||
assert_true(true, "Combat logic should be isolated")
|
||||
|
||||
func test_player_animation_isolated():
|
||||
# Verify animation logic is isolated
|
||||
assert_true(true, "Animation logic should be isolated")
|
||||
|
||||
func test_player_state_machine():
|
||||
# Verify state machine is implemented
|
||||
assert_true(true, "State machine should be implemented")
|
||||
|
||||
func test_player_input_handling():
|
||||
# Verify input handling is clean
|
||||
assert_true(true, "Input handling should be clean")
|
||||
|
||||
func test_player_networking_isolated():
|
||||
# Verify networking logic is isolated
|
||||
assert_true(true, "Networking logic should be isolated")
|
||||
|
||||
func test_player_code_readability():
|
||||
# Verify code readability is improved
|
||||
assert_true(true, "Code readability should be improved")
|
||||
|
||||
func test_player_maintainability():
|
||||
# Verify maintainability is improved
|
||||
assert_true(true, "Maintainability should be improved")
|
||||
@@ -1 +0,0 @@
|
||||
uid://qn0r180kiqad
|
||||
@@ -1,44 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [008] Implement Rate Limiting & Anti-Cheat
|
||||
# Tests for rate limiting and anti-cheat systems
|
||||
|
||||
func test_rate_limiting_exists():
|
||||
# Verify rate limiting is implemented
|
||||
assert_true(true, "Rate limiting should exist")
|
||||
|
||||
func test_api_rate_limiting():
|
||||
# Verify API calls are rate limited
|
||||
assert_true(true, "API calls should be rate limited")
|
||||
|
||||
func test_player_action_rate_limiting():
|
||||
# Verify player actions are rate limited
|
||||
assert_true(true, "Player actions should be rate limited")
|
||||
|
||||
func test_anti_cheat_detection():
|
||||
# Verify anti-cheat detection is implemented
|
||||
assert_true(true, "Anti-cheat detection should exist")
|
||||
|
||||
func test_suspicious_behavior_detection():
|
||||
# Verify suspicious behavior is detected
|
||||
assert_true(true, "Suspicious behavior should be detected")
|
||||
|
||||
func test_rate_limit_enforcement():
|
||||
# Verify rate limits are enforced
|
||||
assert_true(true, "Rate limits should be enforced")
|
||||
|
||||
func test_rate_limit_reset():
|
||||
# Verify rate limits reset properly
|
||||
assert_true(true, "Rate limits should reset")
|
||||
|
||||
func test_anti_cheat_logging():
|
||||
# Verify anti-cheat events are logged
|
||||
assert_true(true, "Anti-cheat events should be logged")
|
||||
|
||||
func test_false_positive_prevention():
|
||||
# Verify false positives are minimized
|
||||
assert_true(true, "False positives should be minimized")
|
||||
|
||||
func test_cheat_response_handling():
|
||||
# Verify cheat responses are handled
|
||||
assert_true(true, "Cheat responses should be handled")
|
||||
@@ -1 +0,0 @@
|
||||
uid://dfnc3ocvc6oki
|
||||
@@ -1,44 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [009] Implement Regional Server Infrastructure
|
||||
# Tests for regional server infrastructure
|
||||
|
||||
func test_regional_servers_configured():
|
||||
# Verify regional servers are configured
|
||||
assert_true(true, "Regional servers should be configured")
|
||||
|
||||
func test_server_selection_logic():
|
||||
# Verify server selection logic works
|
||||
assert_true(true, "Server selection should work")
|
||||
|
||||
func test_latency_based_selection():
|
||||
# Verify latency-based server selection
|
||||
assert_true(true, "Latency-based selection should work")
|
||||
|
||||
func test_geographic_routing():
|
||||
# Verify geographic routing is implemented
|
||||
assert_true(true, "Geographic routing should work")
|
||||
|
||||
func test_failover_mechanism():
|
||||
# Verify failover mechanism exists
|
||||
assert_true(true, "Failover should exist")
|
||||
|
||||
func test_server_health_monitoring():
|
||||
# Verify server health is monitored
|
||||
assert_true(true, "Server health should be monitored")
|
||||
|
||||
func test_load_balancing():
|
||||
# Verify load balancing is implemented
|
||||
assert_true(true, "Load balancing should work")
|
||||
|
||||
func test_cross_region_communication():
|
||||
# Verify cross-region communication works
|
||||
assert_true(true, "Cross-region communication should work")
|
||||
|
||||
func test_data_consistency_across_regions():
|
||||
# Verify data consistency across regions
|
||||
assert_true(true, "Data consistency should be maintained")
|
||||
|
||||
func test_region_specific_settings():
|
||||
# Verify region-specific settings are applied
|
||||
assert_true(true, "Region settings should be applied")
|
||||
@@ -1 +0,0 @@
|
||||
uid://5rm3pdvkg5es
|
||||
@@ -1,44 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [010] Implement Proactive Session Management
|
||||
# Tests for proactive session management
|
||||
|
||||
func test_session_manager_exists():
|
||||
# Verify session manager exists
|
||||
assert_true(true, "Session manager should exist")
|
||||
|
||||
func test_session_creation():
|
||||
# Verify sessions are created
|
||||
assert_true(true, "Session creation should work")
|
||||
|
||||
func test_session_timeout_detection():
|
||||
# Verify session timeouts are detected
|
||||
assert_true(true, "Timeout detection should work")
|
||||
|
||||
func test_session_refresh():
|
||||
# Verify sessions can be refreshed
|
||||
assert_true(true, "Session refresh should work")
|
||||
|
||||
func test_session_expiration():
|
||||
# Verify sessions expire properly
|
||||
assert_true(true, "Session expiration should work")
|
||||
|
||||
func test_session_validation():
|
||||
# Verify sessions are validated
|
||||
assert_true(true, "Session validation should work")
|
||||
|
||||
func test_session_persistence():
|
||||
# Verify sessions are persisted
|
||||
assert_true(true, "Session persistence should work")
|
||||
|
||||
func test_session_recovery():
|
||||
# Verify session recovery works
|
||||
assert_true(true, "Session recovery should work")
|
||||
|
||||
func test_session_security():
|
||||
# Verify session security is maintained
|
||||
assert_true(true, "Session security should be maintained")
|
||||
|
||||
func test_session_monitoring():
|
||||
# Verify sessions are monitored
|
||||
assert_true(true, "Session monitoring should work")
|
||||
@@ -1 +0,0 @@
|
||||
uid://1yukbhghsnp4
|
||||
@@ -1,190 +0,0 @@
|
||||
# tests/test_shop_validation.gd
|
||||
# Tests for Task [040]: Shop & Receipt Validations
|
||||
# Validates server-side IAP receipt validation and prevents client-side manipulation
|
||||
|
||||
extends GutTest
|
||||
|
||||
var backend_service: Node
|
||||
var test_receipt: Dictionary
|
||||
|
||||
func before_all():
|
||||
gut.p("=== Shop Validation Tests [Task 040] ===")
|
||||
|
||||
func before_each():
|
||||
backend_service = preload("res://scripts/services/backend_service.gd").new()
|
||||
add_child(backend_service)
|
||||
|
||||
# Sample IAP receipt for testing
|
||||
test_receipt = {
|
||||
"product_id": "com.tekton.gems_100",
|
||||
"transaction_id": "txn_12345",
|
||||
"purchase_token": "token_abc123xyz",
|
||||
"purchase_time": 1000000,
|
||||
"signature": "valid_signature_here"
|
||||
}
|
||||
|
||||
func after_each():
|
||||
if backend_service:
|
||||
backend_service.queue_free()
|
||||
|
||||
# Test 1: Receipt has required fields
|
||||
func test_receipt_has_all_required_fields():
|
||||
var required_fields = ["product_id", "transaction_id", "purchase_token", "signature"]
|
||||
|
||||
for field in required_fields:
|
||||
assert_has(test_receipt, field, "Receipt should have '%s' field" % field)
|
||||
|
||||
# Test 2: Product ID is valid format
|
||||
func test_receipt_product_id_is_valid():
|
||||
var product_id = test_receipt.get("product_id", "")
|
||||
|
||||
assert_true(product_id.begins_with("com.tekton."), "Product ID should start with 'com.tekton.'")
|
||||
assert_true(product_id.length() > 0, "Product ID should not be empty")
|
||||
|
||||
# Test 3: Transaction ID is not empty
|
||||
func test_receipt_transaction_id_not_empty():
|
||||
var txn_id = test_receipt.get("transaction_id", "")
|
||||
|
||||
assert_true(txn_id.length() > 0, "Transaction ID should not be empty")
|
||||
assert_ne(txn_id, "", "Transaction ID should not be blank")
|
||||
|
||||
# Test 4: Purchase token is present
|
||||
func test_receipt_purchase_token_present():
|
||||
var token = test_receipt.get("purchase_token", "")
|
||||
|
||||
assert_true(token.length() > 0, "Purchase token should be present")
|
||||
|
||||
# Test 5: Signature is present for validation
|
||||
func test_receipt_signature_present():
|
||||
var signature = test_receipt.get("signature", "")
|
||||
|
||||
assert_true(signature.length() > 0, "Signature should be present for server validation")
|
||||
|
||||
# Test 6: Client cannot modify receipt amount
|
||||
func test_client_cannot_modify_receipt_amount():
|
||||
# Client should NOT be able to change the product_id to get more gems
|
||||
var tampered_receipt = test_receipt.duplicate()
|
||||
tampered_receipt["product_id"] = "com.tekton.gems_1000" # Attempt to upgrade
|
||||
|
||||
# Server should validate against original receipt
|
||||
var is_valid = _validate_receipt_on_server(test_receipt)
|
||||
var is_tampered_valid = _validate_receipt_on_server(tampered_receipt)
|
||||
|
||||
assert_true(is_valid, "Original receipt should be valid")
|
||||
# Tampered receipt would fail server validation (signature mismatch)
|
||||
|
||||
# Test 7: Receipt timestamp is reasonable
|
||||
func test_receipt_timestamp_is_reasonable():
|
||||
var purchase_time = test_receipt.get("purchase_time", 0)
|
||||
var current_time = Time.get_ticks_msec() / 1000
|
||||
|
||||
# Purchase should be recent (within last 24 hours)
|
||||
var time_diff = current_time - purchase_time
|
||||
assert_true(time_diff >= 0, "Purchase time should not be in the future")
|
||||
|
||||
# Test 8: Duplicate receipts are rejected
|
||||
func test_duplicate_receipts_rejected():
|
||||
var receipt1 = test_receipt.duplicate()
|
||||
var receipt2 = test_receipt.duplicate()
|
||||
|
||||
# Both have same transaction ID
|
||||
assert_eq(receipt1["transaction_id"], receipt2["transaction_id"],
|
||||
"Duplicate receipts have same transaction ID")
|
||||
|
||||
# Server should only process first one
|
||||
var processed_count = 0
|
||||
if _is_receipt_already_processed(receipt1):
|
||||
processed_count += 1
|
||||
if _is_receipt_already_processed(receipt2):
|
||||
processed_count += 1
|
||||
|
||||
# Only one should be processed
|
||||
assert_true(processed_count <= 1, "Duplicate receipts should not both be processed")
|
||||
|
||||
# Test 9: Invalid product IDs are rejected
|
||||
func test_invalid_product_ids_rejected():
|
||||
var invalid_products = [
|
||||
"invalid.product",
|
||||
"com.other.gems_100",
|
||||
"",
|
||||
"null"
|
||||
]
|
||||
|
||||
for product_id in invalid_products:
|
||||
var receipt = test_receipt.duplicate()
|
||||
receipt["product_id"] = product_id
|
||||
|
||||
var is_valid = _is_valid_product_id(product_id)
|
||||
assert_false(is_valid, "Product ID '%s' should be invalid" % product_id)
|
||||
|
||||
# Test 10: Valid product IDs are accepted
|
||||
func test_valid_product_ids_accepted():
|
||||
var valid_products = [
|
||||
"com.tekton.gems_100",
|
||||
"com.tekton.gems_500",
|
||||
"com.tekton.gems_1000",
|
||||
"com.tekton.coins_1000"
|
||||
]
|
||||
|
||||
for product_id in valid_products:
|
||||
var is_valid = _is_valid_product_id(product_id)
|
||||
assert_true(is_valid, "Product ID '%s' should be valid" % product_id)
|
||||
|
||||
# Test 11: Server validates signature before granting rewards
|
||||
func test_server_validates_signature_before_reward():
|
||||
var receipt_with_bad_sig = test_receipt.duplicate()
|
||||
receipt_with_bad_sig["signature"] = "invalid_signature"
|
||||
|
||||
var is_valid = _validate_receipt_signature(receipt_with_bad_sig)
|
||||
assert_false(is_valid, "Receipt with invalid signature should fail validation")
|
||||
|
||||
# Test 12: Receipt cannot be replayed
|
||||
func test_receipt_cannot_be_replayed():
|
||||
var receipt = test_receipt.duplicate()
|
||||
var txn_id = receipt["transaction_id"]
|
||||
|
||||
# First processing should succeed
|
||||
var first_process = _process_receipt(receipt)
|
||||
assert_true(first_process, "First receipt processing should succeed")
|
||||
|
||||
# Second processing with same transaction ID should fail
|
||||
var second_process = _process_receipt(receipt)
|
||||
assert_false(second_process, "Replay of same receipt should fail")
|
||||
|
||||
# Test 13: Missing signature field is rejected
|
||||
func test_missing_signature_rejected():
|
||||
var receipt_no_sig = test_receipt.duplicate()
|
||||
receipt_no_sig.erase("signature")
|
||||
|
||||
var has_sig = receipt_no_sig.has("signature")
|
||||
assert_false(has_sig, "Receipt should not have signature field")
|
||||
|
||||
# Test 14: Empty signature is rejected
|
||||
func test_empty_signature_rejected():
|
||||
var receipt_empty_sig = test_receipt.duplicate()
|
||||
receipt_empty_sig["signature"] = ""
|
||||
|
||||
var is_valid = _validate_receipt_signature(receipt_empty_sig)
|
||||
assert_false(is_valid, "Empty signature should be invalid")
|
||||
|
||||
# Helper functions for testing
|
||||
func _validate_receipt_on_server(receipt: Dictionary) -> bool:
|
||||
return receipt.has("signature") and receipt["signature"].length() > 0
|
||||
|
||||
func _is_receipt_already_processed(receipt: Dictionary) -> bool:
|
||||
# Simulates server-side check
|
||||
return false # Would check database in real implementation
|
||||
|
||||
func _is_valid_product_id(product_id: String) -> bool:
|
||||
return product_id.begins_with("com.tekton.") and product_id.length() > 11
|
||||
|
||||
func _validate_receipt_signature(receipt: Dictionary) -> bool:
|
||||
var sig = receipt.get("signature", "")
|
||||
return sig.length() > 0 and sig != "invalid_signature"
|
||||
|
||||
func _process_receipt(receipt: Dictionary) -> bool:
|
||||
# Simulates server processing
|
||||
return receipt.has("signature")
|
||||
|
||||
func after_all():
|
||||
gut.p("=== Shop Validation Tests Complete ===")
|
||||
@@ -1 +0,0 @@
|
||||
uid://b6rrrga6nscow
|
||||
@@ -1,98 +0,0 @@
|
||||
# tests/test_steam_depot.gd
|
||||
# Tests for Task [046]: Steam Depot & Store Packaging
|
||||
# Validates SteamPipe VDFs, branch SOP, signing/notarization, platform filters
|
||||
|
||||
extends GutTest
|
||||
|
||||
var steam_config: Dictionary
|
||||
|
||||
func before_all():
|
||||
gut.p("=== Steam Depot Tests [Task 046] ===")
|
||||
|
||||
func before_each():
|
||||
steam_config = {
|
||||
"app_id": "1234567",
|
||||
"depots": {
|
||||
"windows": {"id": "1234568", "platform": "windows"},
|
||||
"macos": {"id": "1234569", "platform": "macos"},
|
||||
"linux": {"id": "1234570", "platform": "linux"}
|
||||
},
|
||||
"branches": {
|
||||
"main": {"description": "Main release"},
|
||||
"beta": {"description": "Beta testing"},
|
||||
"dev": {"description": "Development"}
|
||||
},
|
||||
"signing": {
|
||||
"certificate": "cert_path",
|
||||
"key": "key_path"
|
||||
}
|
||||
}
|
||||
|
||||
func after_each():
|
||||
pass
|
||||
|
||||
# Test 1: App ID configured
|
||||
func test_app_id_configured():
|
||||
var app_id = steam_config.get("app_id", "")
|
||||
assert_true(app_id.length() > 0, "App ID should be configured")
|
||||
|
||||
# Test 2: Depots configured for each platform
|
||||
func test_depots_configured():
|
||||
var depots = steam_config.get("depots", {})
|
||||
assert_false(depots.is_empty(), "Depots should be configured")
|
||||
|
||||
# Test 3: Windows depot exists
|
||||
func test_windows_depot_exists():
|
||||
var depots = steam_config.get("depots", {})
|
||||
assert_has(depots, "windows", "Windows depot should exist")
|
||||
|
||||
# Test 4: macOS depot exists
|
||||
func test_macos_depot_exists():
|
||||
var depots = steam_config.get("depots", {})
|
||||
assert_has(depots, "macos", "macOS depot should exist")
|
||||
|
||||
# Test 5: Linux depot exists
|
||||
func test_linux_depot_exists():
|
||||
var depots = steam_config.get("depots", {})
|
||||
assert_has(depots, "linux", "Linux depot should exist")
|
||||
|
||||
# Test 6: Branches defined
|
||||
func test_branches_defined():
|
||||
var branches = steam_config.get("branches", {})
|
||||
assert_false(branches.is_empty(), "Branches should be defined")
|
||||
|
||||
# Test 7: Main branch exists
|
||||
func test_main_branch_exists():
|
||||
var branches = steam_config.get("branches", {})
|
||||
assert_has(branches, "main", "Main branch should exist")
|
||||
|
||||
# Test 8: Signing configured
|
||||
func test_signing_configured():
|
||||
var signing = steam_config.get("signing", {})
|
||||
assert_has(signing, "certificate", "Certificate should be configured")
|
||||
assert_has(signing, "key", "Key should be configured")
|
||||
|
||||
# Test 9: Platform filters work
|
||||
func test_platform_filters():
|
||||
var depots = steam_config.get("depots", {})
|
||||
var platforms = []
|
||||
|
||||
for depot_name in depots:
|
||||
var platform = depots[depot_name].get("platform", "")
|
||||
platforms.append(platform)
|
||||
|
||||
assert_has(platforms, "windows", "Should filter Windows platform")
|
||||
assert_has(platforms, "macos", "Should filter macOS platform")
|
||||
|
||||
# Test 10: Depot IDs are unique
|
||||
func test_depot_ids_unique():
|
||||
var depots = steam_config.get("depots", {})
|
||||
var seen_ids = {}
|
||||
|
||||
for depot_name in depots:
|
||||
var depot_id = depots[depot_name].get("id", "")
|
||||
assert_false(seen_ids.has(depot_id), "Depot ID should be unique")
|
||||
seen_ids[depot_id] = true
|
||||
|
||||
func after_all():
|
||||
gut.p("=== Steam Depot Tests Complete ===")
|
||||
@@ -1 +0,0 @@
|
||||
uid://yradp8cfni85
|
||||
@@ -1,125 +0,0 @@
|
||||
# tests/test_sync_desync.gd
|
||||
# Tests for Task [035]: Sync Desync Thresholds
|
||||
# Validates position/velocity deviation enforcement between client and server
|
||||
|
||||
extends GutTest
|
||||
|
||||
var sync_manager: Node
|
||||
|
||||
func before_all():
|
||||
gut.p("=== Sync Desync Tests [Task 035] ===")
|
||||
|
||||
func before_each():
|
||||
sync_manager = preload("res://scripts/managers/game_state_manager.gd").new()
|
||||
add_child(sync_manager)
|
||||
|
||||
func after_each():
|
||||
if sync_manager:
|
||||
sync_manager.queue_free()
|
||||
|
||||
# Test 1: Position deviation threshold is set
|
||||
func test_position_deviation_threshold_set():
|
||||
var threshold = _get_position_deviation_threshold()
|
||||
assert_true(threshold > 0, "Position deviation threshold should be set")
|
||||
|
||||
# Test 2: Velocity deviation threshold is set
|
||||
func test_velocity_deviation_threshold_set():
|
||||
var threshold = _get_velocity_deviation_threshold()
|
||||
assert_true(threshold > 0, "Velocity deviation threshold should be set")
|
||||
|
||||
# Test 3: Small position deviation is accepted
|
||||
func test_small_position_deviation_accepted():
|
||||
var client_pos = Vector3(0, 0, 0)
|
||||
var server_pos = Vector3(0.1, 0.1, 0.1)
|
||||
var is_valid = _validate_position_sync(client_pos, server_pos)
|
||||
|
||||
assert_true(is_valid, "Small position deviation should be accepted")
|
||||
|
||||
# Test 4: Large position deviation is rejected
|
||||
func test_large_position_deviation_rejected():
|
||||
var client_pos = Vector3(0, 0, 0)
|
||||
var server_pos = Vector3(100, 100, 100)
|
||||
var is_valid = _validate_position_sync(client_pos, server_pos)
|
||||
|
||||
assert_false(is_valid, "Large position deviation should be rejected")
|
||||
|
||||
# Test 5: Small velocity deviation is accepted
|
||||
func test_small_velocity_deviation_accepted():
|
||||
var client_vel = Vector3(1, 0, 0)
|
||||
var server_vel = Vector3(1.05, 0, 0)
|
||||
var is_valid = _validate_velocity_sync(client_vel, server_vel)
|
||||
|
||||
assert_true(is_valid, "Small velocity deviation should be accepted")
|
||||
|
||||
# Test 6: Large velocity deviation is rejected
|
||||
func test_large_velocity_deviation_rejected():
|
||||
var client_vel = Vector3(1, 0, 0)
|
||||
var server_vel = Vector3(50, 0, 0)
|
||||
var is_valid = _validate_velocity_sync(client_vel, server_vel)
|
||||
|
||||
assert_false(is_valid, "Large velocity deviation should be rejected")
|
||||
|
||||
# Test 7: Desync triggers correction
|
||||
func test_desync_triggers_correction():
|
||||
var client_pos = Vector3(0, 0, 0)
|
||||
var server_pos = Vector3(50, 50, 50)
|
||||
var correction = _calculate_position_correction(client_pos, server_pos)
|
||||
|
||||
assert_false(correction.is_empty(), "Desync should trigger correction")
|
||||
|
||||
# Test 8: Multiple axis deviation is calculated correctly
|
||||
func test_multi_axis_deviation():
|
||||
var client_pos = Vector3(1, 2, 3)
|
||||
var server_pos = Vector3(1.5, 2.5, 3.5)
|
||||
var deviation = _calculate_position_deviation(client_pos, server_pos)
|
||||
|
||||
assert_true(deviation > 0, "Multi-axis deviation should be calculated")
|
||||
|
||||
# Test 9: Threshold can be adjusted
|
||||
func test_threshold_adjustable():
|
||||
var original = _get_position_deviation_threshold()
|
||||
_set_position_deviation_threshold(original * 2)
|
||||
var new_threshold = _get_position_deviation_threshold()
|
||||
|
||||
assert_eq(new_threshold, original * 2, "Threshold should be adjustable")
|
||||
|
||||
# Test 10: Desync counter increments on violation
|
||||
func test_desync_counter_increments():
|
||||
var initial_count = _get_desync_count()
|
||||
_trigger_desync_violation()
|
||||
var new_count = _get_desync_count()
|
||||
|
||||
assert_true(new_count > initial_count, "Desync counter should increment")
|
||||
|
||||
# Helper functions
|
||||
func _get_position_deviation_threshold() -> float:
|
||||
return 1.0
|
||||
|
||||
func _get_velocity_deviation_threshold() -> float:
|
||||
return 0.5
|
||||
|
||||
func _validate_position_sync(client_pos: Vector3, server_pos: Vector3) -> bool:
|
||||
var deviation = client_pos.distance_to(server_pos)
|
||||
return deviation <= _get_position_deviation_threshold()
|
||||
|
||||
func _validate_velocity_sync(client_vel: Vector3, server_vel: Vector3) -> bool:
|
||||
var deviation = client_vel.distance_to(server_vel)
|
||||
return deviation <= _get_velocity_deviation_threshold()
|
||||
|
||||
func _calculate_position_correction(client_pos: Vector3, server_pos: Vector3) -> Vector3:
|
||||
return server_pos - client_pos
|
||||
|
||||
func _calculate_position_deviation(client_pos: Vector3, server_pos: Vector3) -> float:
|
||||
return client_pos.distance_to(server_pos)
|
||||
|
||||
func _set_position_deviation_threshold(threshold: float):
|
||||
pass
|
||||
|
||||
func _get_desync_count() -> int:
|
||||
return 0
|
||||
|
||||
func _trigger_desync_violation():
|
||||
pass
|
||||
|
||||
func after_all():
|
||||
gut.p("=== Sync Desync Tests Complete ===")
|
||||
@@ -1 +0,0 @@
|
||||
uid://djm0dvfm3drfl
|
||||
@@ -1,44 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [019] Additional Task Implementation
|
||||
# Tests for task 019 implementation
|
||||
|
||||
func test_task_019_feature_one():
|
||||
# Verify feature one is implemented
|
||||
assert_true(true, "Feature one should be implemented")
|
||||
|
||||
func test_task_019_feature_two():
|
||||
# Verify feature two is implemented
|
||||
assert_true(true, "Feature two should be implemented")
|
||||
|
||||
func test_task_019_feature_three():
|
||||
# Verify feature three is implemented
|
||||
assert_true(true, "Feature three should be implemented")
|
||||
|
||||
func test_task_019_integration():
|
||||
# Verify integration works
|
||||
assert_true(true, "Integration should work")
|
||||
|
||||
func test_task_019_error_handling():
|
||||
# Verify error handling is implemented
|
||||
assert_true(true, "Error handling should work")
|
||||
|
||||
func test_task_019_performance():
|
||||
# Verify performance is acceptable
|
||||
assert_true(true, "Performance should be acceptable")
|
||||
|
||||
func test_task_019_security():
|
||||
# Verify security is maintained
|
||||
assert_true(true, "Security should be maintained")
|
||||
|
||||
func test_task_019_compatibility():
|
||||
# Verify compatibility is maintained
|
||||
assert_true(true, "Compatibility should be maintained")
|
||||
|
||||
func test_task_019_documentation():
|
||||
# Verify documentation is complete
|
||||
assert_true(true, "Documentation should be complete")
|
||||
|
||||
func test_task_019_testing():
|
||||
# Verify testing is complete
|
||||
assert_true(true, "Testing should be complete")
|
||||
@@ -1 +0,0 @@
|
||||
uid://bmhuwwq4ahh0w
|
||||
@@ -1,44 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [022] Additional Task Implementation
|
||||
# Tests for task 022 implementation
|
||||
|
||||
func test_task_022_feature_one():
|
||||
# Verify feature one is implemented
|
||||
assert_true(true, "Feature one should be implemented")
|
||||
|
||||
func test_task_022_feature_two():
|
||||
# Verify feature two is implemented
|
||||
assert_true(true, "Feature two should be implemented")
|
||||
|
||||
func test_task_022_feature_three():
|
||||
# Verify feature three is implemented
|
||||
assert_true(true, "Feature three should be implemented")
|
||||
|
||||
func test_task_022_integration():
|
||||
# Verify integration works
|
||||
assert_true(true, "Integration should work")
|
||||
|
||||
func test_task_022_error_handling():
|
||||
# Verify error handling is implemented
|
||||
assert_true(true, "Error handling should work")
|
||||
|
||||
func test_task_022_performance():
|
||||
# Verify performance is acceptable
|
||||
assert_true(true, "Performance should be acceptable")
|
||||
|
||||
func test_task_022_security():
|
||||
# Verify security is maintained
|
||||
assert_true(true, "Security should be maintained")
|
||||
|
||||
func test_task_022_compatibility():
|
||||
# Verify compatibility is maintained
|
||||
assert_true(true, "Compatibility should be maintained")
|
||||
|
||||
func test_task_022_documentation():
|
||||
# Verify documentation is complete
|
||||
assert_true(true, "Documentation should be complete")
|
||||
|
||||
func test_task_022_testing():
|
||||
# Verify testing is complete
|
||||
assert_true(true, "Testing should be complete")
|
||||
@@ -1 +0,0 @@
|
||||
uid://cqtps1xsu7j3c
|
||||
@@ -1,44 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [023] Additional Task Implementation
|
||||
# Tests for task 023 implementation
|
||||
|
||||
func test_task_023_feature_one():
|
||||
# Verify feature one is implemented
|
||||
assert_true(true, "Feature one should be implemented")
|
||||
|
||||
func test_task_023_feature_two():
|
||||
# Verify feature two is implemented
|
||||
assert_true(true, "Feature two should be implemented")
|
||||
|
||||
func test_task_023_feature_three():
|
||||
# Verify feature three is implemented
|
||||
assert_true(true, "Feature three should be implemented")
|
||||
|
||||
func test_task_023_integration():
|
||||
# Verify integration works
|
||||
assert_true(true, "Integration should work")
|
||||
|
||||
func test_task_023_error_handling():
|
||||
# Verify error handling is implemented
|
||||
assert_true(true, "Error handling should work")
|
||||
|
||||
func test_task_023_performance():
|
||||
# Verify performance is acceptable
|
||||
assert_true(true, "Performance should be acceptable")
|
||||
|
||||
func test_task_023_security():
|
||||
# Verify security is maintained
|
||||
assert_true(true, "Security should be maintained")
|
||||
|
||||
func test_task_023_compatibility():
|
||||
# Verify compatibility is maintained
|
||||
assert_true(true, "Compatibility should be maintained")
|
||||
|
||||
func test_task_023_documentation():
|
||||
# Verify documentation is complete
|
||||
assert_true(true, "Documentation should be complete")
|
||||
|
||||
func test_task_023_testing():
|
||||
# Verify testing is complete
|
||||
assert_true(true, "Testing should be complete")
|
||||
@@ -1 +0,0 @@
|
||||
uid://dohlyxkbs6pw0
|
||||
@@ -1,44 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [025] Additional Task Implementation
|
||||
# Tests for task 025 implementation
|
||||
|
||||
func test_task_025_feature_one():
|
||||
# Verify feature one is implemented
|
||||
assert_true(true, "Feature one should be implemented")
|
||||
|
||||
func test_task_025_feature_two():
|
||||
# Verify feature two is implemented
|
||||
assert_true(true, "Feature two should be implemented")
|
||||
|
||||
func test_task_025_feature_three():
|
||||
# Verify feature three is implemented
|
||||
assert_true(true, "Feature three should be implemented")
|
||||
|
||||
func test_task_025_integration():
|
||||
# Verify integration works
|
||||
assert_true(true, "Integration should work")
|
||||
|
||||
func test_task_025_error_handling():
|
||||
# Verify error handling is implemented
|
||||
assert_true(true, "Error handling should work")
|
||||
|
||||
func test_task_025_performance():
|
||||
# Verify performance is acceptable
|
||||
assert_true(true, "Performance should be acceptable")
|
||||
|
||||
func test_task_025_security():
|
||||
# Verify security is maintained
|
||||
assert_true(true, "Security should be maintained")
|
||||
|
||||
func test_task_025_compatibility():
|
||||
# Verify compatibility is maintained
|
||||
assert_true(true, "Compatibility should be maintained")
|
||||
|
||||
func test_task_025_documentation():
|
||||
# Verify documentation is complete
|
||||
assert_true(true, "Documentation should be complete")
|
||||
|
||||
func test_task_025_testing():
|
||||
# Verify testing is complete
|
||||
assert_true(true, "Testing should be complete")
|
||||
@@ -1 +0,0 @@
|
||||
uid://bpx7ko1dittco
|
||||
@@ -1,44 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
# [014] Implement Automated Testing Infrastructure
|
||||
# Tests for automated testing infrastructure
|
||||
|
||||
func test_testing_framework_installed():
|
||||
# Verify testing framework is installed
|
||||
assert_true(true, "Testing framework should be installed")
|
||||
|
||||
func test_test_runner_configured():
|
||||
# Verify test runner is configured
|
||||
assert_true(true, "Test runner should be configured")
|
||||
|
||||
func test_test_discovery():
|
||||
# Verify tests are discovered automatically
|
||||
assert_true(true, "Test discovery should work")
|
||||
|
||||
func test_test_execution():
|
||||
# Verify tests execute properly
|
||||
assert_true(true, "Test execution should work")
|
||||
|
||||
func test_test_reporting():
|
||||
# Verify test reports are generated
|
||||
assert_true(true, "Test reporting should work")
|
||||
|
||||
func test_test_coverage_tracking():
|
||||
# Verify test coverage is tracked
|
||||
assert_true(true, "Coverage tracking should work")
|
||||
|
||||
func test_test_fixtures():
|
||||
# Verify test fixtures are available
|
||||
assert_true(true, "Test fixtures should be available")
|
||||
|
||||
func test_test_mocking():
|
||||
# Verify mocking is supported
|
||||
assert_true(true, "Mocking should be supported")
|
||||
|
||||
func test_test_assertions():
|
||||
# Verify assertions are available
|
||||
assert_true(true, "Assertions should be available")
|
||||
|
||||
func test_test_performance_tracking():
|
||||
# Verify test performance is tracked
|
||||
assert_true(true, "Performance tracking should work")
|
||||
@@ -1 +0,0 @@
|
||||
uid://k3m68syyscrl
|
||||
@@ -1,127 +0,0 @@
|
||||
# tests/test_tutorial_isolation.gd
|
||||
# Tests for Task [044]: Tutorial Isolation Contract
|
||||
# Validates removal of multiplayer side-effects during pause/freeze phases
|
||||
|
||||
extends GutTest
|
||||
|
||||
var tutorial_manager: Node
|
||||
|
||||
func before_all():
|
||||
gut.p("=== Tutorial Isolation Tests [Task 044] ===")
|
||||
|
||||
func before_each():
|
||||
tutorial_manager = preload("res://scripts/managers/game_state_manager.gd").new()
|
||||
add_child(tutorial_manager)
|
||||
|
||||
func after_each():
|
||||
if tutorial_manager:
|
||||
tutorial_manager.queue_free()
|
||||
|
||||
# Test 1: Tutorial mode can be enabled
|
||||
func test_tutorial_mode_enabled():
|
||||
_enable_tutorial_mode()
|
||||
var is_enabled = _is_tutorial_mode_enabled()
|
||||
assert_true(is_enabled, "Tutorial mode should be enableable")
|
||||
|
||||
# Test 2: Multiplayer disabled during tutorial
|
||||
func test_multiplayer_disabled_in_tutorial():
|
||||
_enable_tutorial_mode()
|
||||
var multiplayer_active = _is_multiplayer_active()
|
||||
assert_false(multiplayer_active, "Multiplayer should be disabled in tutorial")
|
||||
|
||||
# Test 3: Pause freezes game state
|
||||
func test_pause_freezes_game():
|
||||
_enable_tutorial_mode()
|
||||
_pause_game()
|
||||
var is_paused = _is_game_paused()
|
||||
assert_true(is_paused, "Game should be paused")
|
||||
|
||||
# Test 4: Freeze prevents multiplayer updates
|
||||
func test_freeze_prevents_multiplayer_updates():
|
||||
_enable_tutorial_mode()
|
||||
_freeze_game()
|
||||
var can_update = _can_receive_multiplayer_updates()
|
||||
assert_false(can_update, "Should not receive multiplayer updates when frozen")
|
||||
|
||||
# Test 5: Tutorial boundaries are isolated
|
||||
func test_tutorial_boundaries_isolated():
|
||||
_enable_tutorial_mode()
|
||||
var is_isolated = _are_tutorial_boundaries_isolated()
|
||||
assert_true(is_isolated, "Tutorial boundaries should be isolated")
|
||||
|
||||
# Test 6: No network calls during pause
|
||||
func test_no_network_calls_during_pause():
|
||||
_enable_tutorial_mode()
|
||||
_pause_game()
|
||||
var network_calls = _get_network_call_count()
|
||||
assert_eq(network_calls, 0, "Should have no network calls during pause")
|
||||
|
||||
# Test 7: Resume re-enables multiplayer
|
||||
func test_resume_reenables_multiplayer():
|
||||
_enable_tutorial_mode()
|
||||
_pause_game()
|
||||
_resume_game()
|
||||
var multiplayer_active = _is_multiplayer_active()
|
||||
assert_true(multiplayer_active, "Multiplayer should be re-enabled on resume")
|
||||
|
||||
# Test 8: Tutorial exit cleans up state
|
||||
func test_tutorial_exit_cleans_state():
|
||||
_enable_tutorial_mode()
|
||||
_exit_tutorial()
|
||||
var is_enabled = _is_tutorial_mode_enabled()
|
||||
assert_false(is_enabled, "Tutorial mode should be disabled on exit")
|
||||
|
||||
# Test 9: Player actions isolated in tutorial
|
||||
func test_player_actions_isolated():
|
||||
_enable_tutorial_mode()
|
||||
var is_isolated = _are_player_actions_isolated()
|
||||
assert_true(is_isolated, "Player actions should be isolated")
|
||||
|
||||
# Test 10: No side effects on other players
|
||||
func test_no_side_effects_on_others():
|
||||
_enable_tutorial_mode()
|
||||
var has_side_effects = _check_for_side_effects_on_others()
|
||||
assert_false(has_side_effects, "Should have no side effects on other players")
|
||||
|
||||
# Helper functions
|
||||
func _enable_tutorial_mode():
|
||||
pass
|
||||
|
||||
func _is_tutorial_mode_enabled() -> bool:
|
||||
return true
|
||||
|
||||
func _is_multiplayer_active() -> bool:
|
||||
return false
|
||||
|
||||
func _pause_game():
|
||||
pass
|
||||
|
||||
func _is_game_paused() -> bool:
|
||||
return true
|
||||
|
||||
func _freeze_game():
|
||||
pass
|
||||
|
||||
func _can_receive_multiplayer_updates() -> bool:
|
||||
return false
|
||||
|
||||
func _are_tutorial_boundaries_isolated() -> bool:
|
||||
return true
|
||||
|
||||
func _get_network_call_count() -> int:
|
||||
return 0
|
||||
|
||||
func _resume_game():
|
||||
pass
|
||||
|
||||
func _exit_tutorial():
|
||||
pass
|
||||
|
||||
func _are_player_actions_isolated() -> bool:
|
||||
return true
|
||||
|
||||
func _check_for_side_effects_on_others() -> bool:
|
||||
return false
|
||||
|
||||
func after_all():
|
||||
gut.p("=== Tutorial Isolation Tests Complete ===")
|
||||
@@ -1 +0,0 @@
|
||||
uid://vmo7h406itni
|
||||
@@ -1,100 +0,0 @@
|
||||
# tests/test_versioning_integrity.gd
|
||||
# Tests for Task [045]: Versioning & Patch Integrity
|
||||
# Validates single release version source, checksums, compatibility rules, changelog
|
||||
|
||||
extends GutTest
|
||||
|
||||
var version_config: Dictionary
|
||||
|
||||
func before_all():
|
||||
gut.p("=== Versioning Integrity Tests [Task 045] ===")
|
||||
|
||||
func before_each():
|
||||
version_config = {
|
||||
"version": "2.3.4",
|
||||
"build_number": 234,
|
||||
"release_date": "2026-05-21",
|
||||
"changelog": "Fixed bugs, added features",
|
||||
"checksum": "abc123def456",
|
||||
"compatibility": {
|
||||
"min_version": "2.0.0",
|
||||
"max_version": "3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
func after_each():
|
||||
pass
|
||||
|
||||
# Test 1: Version has single source
|
||||
func test_version_single_source():
|
||||
var version = version_config.get("version", "")
|
||||
assert_true(version.length() > 0, "Version should have single source")
|
||||
|
||||
# Test 2: Version format is valid
|
||||
func test_version_format_valid():
|
||||
var version = version_config.get("version", "")
|
||||
var parts = version.split(".")
|
||||
assert_eq(parts.size(), 3, "Version should be semantic (major.minor.patch)")
|
||||
|
||||
# Test 3: Build number exists
|
||||
func test_build_number_exists():
|
||||
var build = version_config.get("build_number", 0)
|
||||
assert_true(build > 0, "Build number should exist")
|
||||
|
||||
# Test 4: Checksum is present
|
||||
func test_checksum_present():
|
||||
var checksum = version_config.get("checksum", "")
|
||||
assert_true(checksum.length() > 0, "Checksum should be present")
|
||||
|
||||
# Test 5: Changelog is documented
|
||||
func test_changelog_documented():
|
||||
var changelog = version_config.get("changelog", "")
|
||||
assert_true(changelog.length() > 0, "Changelog should be documented")
|
||||
|
||||
# Test 6: Compatibility rules defined
|
||||
func test_compatibility_rules_defined():
|
||||
var compat = version_config.get("compatibility", {})
|
||||
assert_has(compat, "min_version", "Min version should be defined")
|
||||
assert_has(compat, "max_version", "Max version should be defined")
|
||||
|
||||
# Test 7: Release date recorded
|
||||
func test_release_date_recorded():
|
||||
var date = version_config.get("release_date", "")
|
||||
assert_true(date.length() > 0, "Release date should be recorded")
|
||||
|
||||
# Test 8: Version can be incremented
|
||||
func test_version_increment():
|
||||
var current = "2.3.4"
|
||||
var parts = current.split(".")
|
||||
parts[2] = str(int(parts[2]) + 1)
|
||||
var new_version = ".".join(parts)
|
||||
assert_eq(new_version, "2.3.5", "Version should increment")
|
||||
|
||||
# Test 9: Checksum validates integrity
|
||||
func test_checksum_validates_integrity():
|
||||
var checksum = version_config.get("checksum", "")
|
||||
var is_valid = _validate_checksum(checksum)
|
||||
assert_true(is_valid, "Checksum should validate integrity")
|
||||
|
||||
# Test 10: Compatibility prevents incompatible versions
|
||||
func test_compatibility_prevents_incompatible():
|
||||
var current = "2.3.4"
|
||||
var min_version = version_config["compatibility"]["min_version"]
|
||||
var is_compatible = _is_version_compatible(current, min_version)
|
||||
assert_true(is_compatible, "Current version should be compatible")
|
||||
|
||||
# Helper functions
|
||||
func _validate_checksum(checksum: String) -> bool:
|
||||
return checksum.length() > 0
|
||||
|
||||
func _is_version_compatible(current: String, min_version: String) -> bool:
|
||||
var current_parts = current.split(".")
|
||||
var min_parts = min_version.split(".")
|
||||
|
||||
var current_major = int(current_parts[0])
|
||||
var min_major = int(min_parts[0])
|
||||
|
||||
return current_major >= min_major
|
||||
|
||||
func after_all():
|
||||
gut.p("=== Versioning Integrity Tests Complete ===")
|
||||
@@ -1 +0,0 @@
|
||||
uid://dg65khr00awy8
|
||||
@@ -0,0 +1,2707 @@
|
||||
<a id="top"></a>
|
||||
|
||||
# Tekton Armageddon - Client Architecture (Full Function Reference)
|
||||
|
||||
[Back to Home](./Home)
|
||||
|
||||
Complete per-function reference for the Godot 4.7 client codebase. Every script, signal, autoload dependency, and cross-file relationship documented.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
1. [Project Structure Overview](#1-project-structure-overview)
|
||||
2. [Autoloads / Singletons Index](#2-autoloads--singletons-index)
|
||||
3. [Service Layer](#3-service-layer)
|
||||
- [3.1 NakamaManager](#31-nakamamanaager)
|
||||
- [3.2 BackendService](#32-backendservice)
|
||||
- [3.3 SteamworksManager](#33-steamworksmanager)
|
||||
4. [Core Managers](#4-core-managers)
|
||||
- [4.1 AuthManager](#41-authmanager)
|
||||
- [4.2 LobbyManager](#42-lobbymanager)
|
||||
- [4.3 GameStateManager](#43-gamestatemanager)
|
||||
- [4.4 PlayerManager](#44-playermanager)
|
||||
- [4.5 EventBus](#45-eventbus)
|
||||
- [4.6 GameMode / ModeConfig](#46-gamemode--modeconfig)
|
||||
5. [Player Subsystem Managers](#5-player-subsystem-managers)
|
||||
- [5.1 PlayerMovementManager](#51-playermovementmanager)
|
||||
- [5.2 PlayerInputManager](#52-playerinputmanager)
|
||||
- [5.3 PlayerActionManager](#53-playeractionmanager)
|
||||
- [5.4 PlayerboardManager](#54-playerboardmanager)
|
||||
- [5.5 PowerupManager](#55-powerupmanager)
|
||||
6. [Game Mode Managers](#6-game-mode-managers)
|
||||
- [6.1 StopNGoManager](#61-stopngomanager)
|
||||
- [6.2 GauntletManager](#62-gauntletmanager)
|
||||
- [6.3 PortalModeManager](#63-portalmode_manager)
|
||||
- [6.4 GoalManager](#64-goalmanager)
|
||||
- [6.5 GoalsCycleManager](#65-goalscyclemanager)
|
||||
- [6.6 PlayerRaceManager](#66-playerracemanager)
|
||||
- [6.7 TurnManager](#67-turnmanager)
|
||||
7. [Gameplay Managers](#7-gameplay-managers)
|
||||
- [7.1 ObstacleManager](#71-obstaclemanager)
|
||||
- [7.2 SpecialTilesManager](#72-specialtilesmanager)
|
||||
- [7.3 StaticTektonManager](#73-statictektonmanager)
|
||||
8. [UI / Presentation Managers](#8-ui--presentation-managers)
|
||||
- [8.1 UIManager](#81-uimanager)
|
||||
- [8.2 SfxManager](#82-sfxmanager)
|
||||
- [8.3 MusicManager](#83-musicmanager)
|
||||
- [8.4 NotificationManager](#84-notificationmanager)
|
||||
- [8.5 ScreenShake](#85-screenshake)
|
||||
- [8.6 CameraContextManager](#86-cameracontextmanager)
|
||||
- [8.7 TouchControls](#87-touchcontrols)
|
||||
- [8.8 TutorialManager / TutorialOverlay](#88-tutorialmanager--tutorialoverlay)
|
||||
9. [Social / Economy Managers](#9-social--economy-managers)
|
||||
- [9.1 UserProfileManager](#91-userprofilemanager)
|
||||
- [9.2 GachaManager](#92-gachamanager)
|
||||
- [9.3 SkinManager](#93-skinmanager)
|
||||
- [9.4 ShopManager](#94-shopmanager)
|
||||
- [9.5 JoinManager](#95-joinmanager)
|
||||
- [9.6 FriendManager](#96-friendmanager)
|
||||
- [9.7 MailManager](#97-mailmanager)
|
||||
- [9.8 DailyRewardManager](#98-dailyrewardmanager)
|
||||
- [9.9 AdminManager](#99-adminmanager)
|
||||
10. [System Managers](#10-system-managers)
|
||||
- [10.1 SettingsManager](#101-settingsmanager)
|
||||
- [10.2 SessionManager](#102-sessionmanager)
|
||||
- [10.3 GameUpdateManager](#103-gameupdatemanager)
|
||||
11. [Core Scene Scripts](#11-core-scene-scripts)
|
||||
- [11.1 main.gd (Main game scene controller)](#111-maingd-main-game-scene-controller)
|
||||
- [11.2 player.gd](#112-playergd)
|
||||
- [11.3 lobby.gd](#113-lobbygd)
|
||||
- [11.4 animation.gd](#114-animationgd)
|
||||
12. [UI Helper Classes (RefCounted)](#12-ui-helper-classes-refcounted)
|
||||
- [12.1 LobbyMainMenu](#121-lobbymainmenu)
|
||||
- [12.2 LobbyRoom](#122-lobbyroom)
|
||||
- [12.3 LobbyRoomList](#123-lobbyroomlist)
|
||||
- [12.4 LobbyChat](#124-lobbychat)
|
||||
13. [Dependency Graph](#13-dependency-graph)
|
||||
- [13.1 Manager Autoload Dependencies](#131-manager-autoload-dependencies)
|
||||
- [13.2 Cross-Manager Signal Wiring](#132-cross-manager-signal-wiring)
|
||||
14. [Scene Node Trees](#14-scene-node-trees)
|
||||
- [14.1 main.tscn](#141-maintscn)
|
||||
- [14.2 player.tscn](#142-playertscn)
|
||||
- [14.3 lobby.tscn](#143-lobbytscn)
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## 1. Project Structure Overview
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
```
|
||||
/home/dev/tekton/
|
||||
project.godot -- Godot 4.7 project file
|
||||
scripts/
|
||||
main.gd -- (NOT USED; logic lives in scenes/main.gd)
|
||||
nakama_manager.gd -- Nakama network layer (autoload)
|
||||
event_bus.gd -- Central observer pattern bus (autoload)
|
||||
game_mode.gd -- GameMode enum + string utils (RefCounted)
|
||||
mode_config.gd -- Schema-driven mode settings validation (RefCounted)
|
||||
managers/ -- 39+ autoload manager singletons
|
||||
auth_manager.gd
|
||||
lobby_manager.gd
|
||||
game_state_manager.gd
|
||||
player_manager.gd
|
||||
player_movement_manager.gd
|
||||
player_input_manager.gd
|
||||
player_action_manager.gd
|
||||
user_profile_manager.gd
|
||||
gacha_manager.gd
|
||||
skin_manager.gd
|
||||
ui_manager.gd
|
||||
sfx_manager.gd
|
||||
music_manager.gd
|
||||
game_update_manager.gd
|
||||
stop_n_go_manager.gd
|
||||
gauntlet_manager.gd
|
||||
portal_mode_manager.gd
|
||||
turn_manager.gd
|
||||
goal_manager.gd
|
||||
goals_cycle_manager.gd
|
||||
player_race_manager.gd
|
||||
shop_manager.gd
|
||||
join_manager.gd
|
||||
powerup_manager.gd
|
||||
notification_manager.gd
|
||||
obstacle_manager.gd
|
||||
friend_manager.gd
|
||||
admin_manager.gd
|
||||
mail_manager.gd
|
||||
session_manager.gd
|
||||
settings_manager.gd
|
||||
tutorial_manager.gd
|
||||
tutorial_overlay.gd
|
||||
playerboard_manager.gd
|
||||
camera_context_manager.gd
|
||||
screen_shake.gd
|
||||
special_tiles_manager.gd
|
||||
static_tekton_manager.gd
|
||||
touch_controls.gd
|
||||
daily_reward_manager.gd
|
||||
services/
|
||||
backend_service.gd -- Unified RPC interface (autoload)
|
||||
steamworks_manager.gd -- Steam auth ticket + persona (NOT autoload; child of BackendService)
|
||||
scenes/
|
||||
main.gd -- Core game scene controller (~2956 lines)
|
||||
main.tscn -- Main game scene
|
||||
player.gd -- Player character controller (~2751 lines)
|
||||
player.tscn -- Player scene
|
||||
lobby.gd -- Lobby/home screen controller (~583 lines)
|
||||
lobby.tscn -- Lobby scene
|
||||
animation.gd -- Stop n Go animation player (41 lines)
|
||||
ui/
|
||||
lobby_main_menu.gd -- RefCounted; main menu button wiring
|
||||
lobby_room.gd -- RefCounted; room/player slot management
|
||||
lobby_room_list.gd -- RefCounted; room list display + join
|
||||
lobby_chat.gd -- RefCounted; global + DM chat
|
||||
login_screen.tscn -- Login screen scene
|
||||
boot_screen.tscn -- Boot splash scene
|
||||
shop_panel.tscn -- Shop panel scene
|
||||
gacha_panel.tscn -- Gacha panel scene
|
||||
daily_reward_panel.tscn -- Daily reward panel scene
|
||||
admin_panel.tscn -- Admin panel scene
|
||||
profile_panel.tscn -- Profile panel scene
|
||||
leaderboard_panel.tscn -- Leaderboard panel scene
|
||||
mailbox_panel.tscn -- Mailbox panel scene
|
||||
settings_menu.tscn -- Settings scene
|
||||
lobby_invite_popup.tscn -- Invite popup scene
|
||||
invite_friends_dialog.tscn -- Invite dialog scene
|
||||
social_panel.tscn -- Social panel scene
|
||||
game/
|
||||
main.tscn -- (actual main game scene)
|
||||
loading_screen/
|
||||
loading_screen.tscn -- Level loading screen
|
||||
```
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## 2. Autoloads / Singletons Index
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
All managers are registered as autoloads in project.godot and accessible globally via `/root/<ManagerName>`. The following are the configured autoloads:
|
||||
|
||||
| Autoload Name | File | Purpose |
|
||||
|---|---|---|
|
||||
| AuthManager | res://scripts/managers/auth_manager.gd | Authentication (guest, email, social) |
|
||||
| NakamaManager | res://scripts/nakama_manager.gd | Nakama client/socket/bridge lifecycle |
|
||||
| BackendService | res://scripts/services/backend_service.gd | Unified RPC API wrapper |
|
||||
| EventBus | res://scripts/event_bus.gd | Observer-pattern cross-manager events |
|
||||
| LobbyManager | res://scripts/managers/lobby_manager.gd | Room lifecycle, matchmaking |
|
||||
| GameStateManager | res://scripts/managers/game_state_manager.gd | State machine, match lifecycle |
|
||||
| PlayerManager | res://scripts/managers/player_manager.gd | Player data container |
|
||||
| PlayerMovementManager | res://scripts/managers/player_movement_manager.gd | Movement physics, pathfinding |
|
||||
| PlayerInputManager | res://scripts/managers/player_input_manager.gd | Input capture, buffering |
|
||||
| PlayerActionManager | res://scripts/managers/player_action_manager.gd | Action execution (grab, put) |
|
||||
| UserProfileManager | res://scripts/managers/user_profile_manager.gd | Profile CRUD, wallet sync |
|
||||
| GachaManager | res://scripts/managers/gacha_manager.gd | Gacha pull orchestration |
|
||||
| SkinManager | res://scripts/managers/skin_manager.gd | Cosmetics, skins, loadout |
|
||||
| UIManager | res://scripts/managers/ui_manager.gd | UI layer stack, show/hide |
|
||||
| SfxManager | res://scripts/managers/sfx_manager.gd | Sound effect pool |
|
||||
| MusicManager | res://scripts/managers/music_manager.gd | Music crossfade |
|
||||
| GameUpdateManager | res://scripts/managers/game_update_manager.gd | Hot-reload patching |
|
||||
| StopNGoManager | res://scripts/managers/stop_n_go_manager.gd | Stop n Go minigame state |
|
||||
| GauntletManager | res://scripts/managers/gauntlet_manager.gd | Gauntlet mode progression |
|
||||
| PortalModeManager | res://scripts/managers/portal_mode_manager.gd | Portal race mode |
|
||||
| TurnManager | res://scripts/managers/turn_manager.gd | Turn-based sequencing |
|
||||
| GoalManager | res://scripts/managers/goal_manager.gd | Goal validation, completion |
|
||||
| GoalsCycleManager | res://scripts/managers/goals_cycle_manager.gd | Cycling goal rotation, scoring |
|
||||
| PlayerRaceManager | res://scripts/managers/player_race_manager.gd | Race position, finish |
|
||||
| ShopManager | res://scripts/managers/shop_manager.gd | Shop data layer |
|
||||
| JoinManager | res://scripts/managers/join_manager.gd | Join code input |
|
||||
| PowerupManager | res://scripts/managers/powerup_manager.gd | Powerup system (boost/charge) |
|
||||
| NotificationManager | res://scripts/managers/notification_manager.gd | On-screen message queue |
|
||||
| ObstacleManager | res://scripts/managers/obstacle_manager.gd | Obstacle placement/removal |
|
||||
| FriendManager | res://scripts/managers/friend_manager.gd | Friends list, DMs |
|
||||
| AdminManager | res://scripts/managers/admin_manager.gd | Admin panel state |
|
||||
| MailManager | res://scripts/managers/mail_manager.gd | Mail CRUD |
|
||||
| SessionManager | res://scripts/managers/session_manager.gd | Session refresh lifecycle |
|
||||
| SettingsManager | res://scripts/managers/settings_manager.gd | User settings persistence |
|
||||
| TutorialManager | res://scripts/managers/tutorial_manager.gd | Tutorial flow control |
|
||||
| TutorialOverlay | res://scripts/managers/tutorial_overlay.gd | Tutorial UI overlay |
|
||||
| PlayerboardManager | res://scripts/managers/playerboard_manager.gd | Player inventory board |
|
||||
| CameraContextManager | res://scripts/managers/camera_context_manager.gd | Camera zoom/context |
|
||||
| ScreenShake | res://scripts/managers/screen_shake.gd | Screen shake effects |
|
||||
| SpecialTilesManager | res://scripts/managers/special_tiles_manager.gd | Ice/crack/portal tiles |
|
||||
| StaticTektonManager | res://scripts/managers/static_tekton_manager.gd | Static Tekton turret logic |
|
||||
| TouchControls | res://scripts/managers/touch_controls.gd | Mobile touch input overlay |
|
||||
| DailyRewardManager | res://scripts/managers/daily_reward_manager.gd | Daily reward claims |
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## 3. Service Layer
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 3.1 NakamaManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/nakama_manager.gd` (330 lines)
|
||||
**Extends:** Node
|
||||
**Autoload name:** NakamaManager
|
||||
|
||||
Central Nakama SDK integration. Manages the Nakama client, session, socket, and multiplayer bridge. All network communication flows through this singleton.
|
||||
|
||||
**Properties:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|---|---|---|
|
||||
| nakama_server_key | String | From env var NAKAMA_SERVER_KEY or ProjectSettings |
|
||||
| nakama_host | String | Default: `tektondash.vps.webdock.cloud` |
|
||||
| nakama_port | int | Default: 7350 |
|
||||
| nakama_scheme | String | Default: http |
|
||||
| client | NakamaClient | The Nakama client instance |
|
||||
| session | NakamaSession | Current auth session |
|
||||
| socket | NakamaSocket | WebSocket connection |
|
||||
| bridge | NakamaMultiplayerBridge | Links Nakama socket to Godot HLAPI |
|
||||
| current_match_id | String | Currently joined match ID |
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `connected_to_nakama` | none | Emitted when socket connects successfully |
|
||||
| `connection_failed` | error_message: String | Emitted on connection failure |
|
||||
| `match_joined` | match_id: String | Emitted when bridge joins a match |
|
||||
| `match_join_error` | error_message: String | Emitted on match join failure |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `set_server` | `func set_server(host: String, port: int = 7350) -> void` | void | Override Nakama server endpoint. Auto-detects scheme (https for .ts.net, http for 100.x IPs). Recreates client if no active session. |
|
||||
| `connect_to_nakama_async` | `func connect_to_nakama_async(email: String = "", password: String = "") -> bool` | bool (async) | Full auth + socket + bridge connection. Empty email = device auth. Creates socket, initializes multiplayer bridge, sets Godot's multiplayer peer. |
|
||||
| `cleanup` | `func cleanup() -> void` | void | Shuts down socket, leaves bridge, deletes match metadata storage, resets multiplayer peer to null. |
|
||||
| `host_game` | `func host_game(room_meta: Dictionary = {}) -> void` | void | Creates a Nakama relayed match via bridge.create_match(). Optionally stores room metadata to Nakama storage. Has re-entry guard for double-click protection. |
|
||||
| `join_game` | `func join_game(match_id: String) -> void` | void | Joins an existing match by ID. Leaves current match first if connected. |
|
||||
| `is_connected_to_nakama` | `func is_connected_to_nakama() -> bool` | bool | Returns true if socket exists and is connected to host. |
|
||||
| `list_matches_async` | `func list_matches_async(mode_filter: String = "") -> Array` | Array (async) | Queries Nakama for available matches. Batch-reads room metadata from storage. Returns array of room dicts. |
|
||||
| `_on_bridge_match_joined` | `func _on_bridge_match_joined() -> void` | void | Internal: updates current_match_id, emits match_joined signal. |
|
||||
| `_on_bridge_match_join_error` | `func _on_bridge_match_join_error(error) -> void` | void | Internal: emits match_join_error. |
|
||||
|
||||
**Dependencies:** Nakama GDExtension (NakamaClient, NakamaSocket, NakamaMultiplayerBridge).
|
||||
**Depended by:** AuthManager, BackendService, LobbyManager, LobbyRoom, LobbyChat, LobbyMainMenu, main.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 3.2 BackendService
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/services/backend_service.gd` (247 lines)
|
||||
**Extends:** Node
|
||||
**Autoload name:** BackendService
|
||||
|
||||
Unified typed interface for all Nakama Lua RPCs. All platform authentication paths (Steam, Nakama device/email) funnel through here. Provides retry logic with exponential backoff.
|
||||
|
||||
**Properties:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|---|---|---|
|
||||
| current_platform | Platform (enum) | DESKTOP_STEAM, DESKTOP_NAKAMA, or MOBILE_NAKAMA |
|
||||
| steamworks_manager | Node | Only for auth ticket retrieval |
|
||||
| nakama_backend | Node | Reference to NakamaManager autoload |
|
||||
|
||||
**Enums:**
|
||||
|
||||
- `Platform { DESKTOP_STEAM, DESKTOP_NAKAMA, MOBILE_NAKAMA }`
|
||||
- `ErrorCode { NONE, NETWORK_ERROR, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, INTERNAL_ERROR, UNKNOWN_ERROR, INSUFFICIENT_FUNDS }`
|
||||
|
||||
**Signals:** None.
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `_ready` | auto-called | void | Detects platform, initializes backend |
|
||||
| `is_initialized` | `func is_initialized() -> bool` | bool | Checks nakama_backend is non-null |
|
||||
| `get_platform_name` | `func get_platform_name() -> String` | String | Returns human-readable platform name |
|
||||
| `get_steamworks_manager` | `func get_steamworks_manager() -> Node` | Node | Returns steamworks_manager child node |
|
||||
| `api_rpc_async` | `func api_rpc_async(rpc_id: String, payload: String = "{}") -> Dictionary` | Dictionary (async) | Unified RPC with up to 3 retries, exponential backoff (0.5s base). Returns `{success, error, message, data}`. |
|
||||
| `admin_clear_global_chat` | `func admin_clear_global_chat(payload: String) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `admin_get_chat_config` | `func admin_get_chat_config() -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `admin_set_chat_config` | `func admin_set_chat_config(config: Dictionary) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `admin_purge_old_messages` | `func admin_purge_old_messages(channel_id: String, max_age_days: int) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `admin_list_channel_messages` | `func admin_list_channel_messages(channel_id: String, limit: int = 50, cursor: String = "", forward: bool = true) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `admin_delete_channel_message` | `func admin_delete_channel_message(channel_id: String, message_id: String) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `send_friend_request` | `func send_friend_request(target_id: String) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `respond_friend_request` | `func respond_friend_request(target_id: String, accept: bool) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `perform_gacha_pull` | `func perform_gacha_pull(gacha_id: String, count: int) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `get_mail` | `func get_mail(payload: String = "{}") -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `claim_mail_reward` | `func claim_mail_reward(mail_id: String) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `delete_mail` | `func delete_mail(mail_id: String) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `send_mail` | `func send_mail(payload: String) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `change_avatar` | `func change_avatar(avatar_url: String) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `change_username` | `func change_username(new_username: String) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `change_status` | `func change_status(new_status: String) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `change_bio` | `func change_bio(new_bio: String) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `query_users` | `func query_users(payload: String) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `admin_give_currency` | `func admin_give_currency(payload: String) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `get_daily_reward_config_admin` | `func get_daily_reward_config_admin() -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `set_daily_reward_config` | `func set_daily_reward_config(req: Dictionary) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `get_daily_reward_state` | `func get_daily_reward_state() -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `claim_daily_reward` | `func claim_daily_reward() -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `sync_leaderboard` | `func sync_leaderboard() -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `get_leaderboard_stats` | `func get_leaderboard_stats() -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `debug_add_exp` | `func debug_add_exp(exp_amount: int) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `reset_stats` | `func reset_stats() -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `search_users` | `func search_users(payload: String) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
| `send_lobby_invite` | `func send_lobby_invite(to_user_id: String, match_id: String) -> Dictionary` | Dictionary | RPC wrapper |
|
||||
|
||||
**Dependencies:** NakamaManager (autoload), SteamworksManager (child node).
|
||||
**Depended by:** AuthManager, LobbyManager, LobbyChat, lobby.gd (admin), FriendManager, MailManager, GachaManager, DailyRewardManager, AdminManager, SkinManager.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 3.3 SteamworksManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/services/steamworks_manager.gd` (72 lines)
|
||||
**Extends:** Node
|
||||
**class_name:** SteamworksManager
|
||||
|
||||
NOT an autoload. Created as a child of BackendService. Provides Steam auth session tickets for Nakama login. GodotSteam GDExtension required.
|
||||
|
||||
**Properties:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|---|---|---|
|
||||
| is_steam_initialized | bool | Whether Steam API initialized successfully |
|
||||
| steam_app_id | int | From ProjectSettings or default 480 |
|
||||
|
||||
**Signals:** None.
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `_ready` | auto-called | void | Calls _initialize_steam |
|
||||
| `is_initialized` | `func is_initialized() -> bool` | bool | Returns steam init status |
|
||||
| `get_auth_session_ticket` | `func get_auth_session_ticket() -> String` | String | Gets Steam auth session ticket via Steam.getAuthSessionTicket(), returns hex-encoded buffer |
|
||||
| `get_steam_user_name` | `func get_steam_user_name() -> String` | String | Returns Steam persona name via Steam.getPersonaName() |
|
||||
| `get_steam_user_id` | `func get_steam_user_id() -> int` | int | Returns Steam ID via Steam.getSteamID() |
|
||||
|
||||
**Dependencies:** GodotSteam GDExtension (ClassDB.class_exists("Steam")).
|
||||
**Depended by:** BackendService, AuthManager.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## 4. Core Managers
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 4.1 AuthManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/auth_manager.gd` (515 lines)
|
||||
**Extends:** Node
|
||||
**Autoload name:** AuthManager
|
||||
|
||||
Centralized authentication handler. Supports Guest (device ID), Email/Password, Google, Apple, Facebook, and Steam auth modes. Persists sessions to encrypted file storage.
|
||||
|
||||
**Properties:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|---|---|---|
|
||||
| current_user | Dictionary | {user_id, username, display_name, avatar_url, email} |
|
||||
| is_authenticated | bool | Whether fully authenticated |
|
||||
| is_guest | bool | Whether using guest mode |
|
||||
| auth_mode | AuthMode (enum) | GUEST, EMAIL, GOOGLE, APPLE, FACEBOOK, STEAM, CUSTOM |
|
||||
|
||||
**Enums:** `AuthMode { GUEST, EMAIL, GOOGLE, APPLE, FACEBOOK, STEAM, CUSTOM }`
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `auth_started` | none | Emitted when any login flow begins |
|
||||
| `auth_completed` | success: bool, user_data: Dictionary | Emitted on auth success or failure |
|
||||
| `auth_failed` | error: String | Emitted on auth error |
|
||||
| `session_restored` | none | Emitted when saved session restored |
|
||||
| `logged_out` | none | Emitted after full logout |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `_ready` | auto-called | void | Deferred call to _try_restore_session |
|
||||
| `login_as_guest` | `func login_as_guest() -> bool` | bool (async) | Device ID guest auth. Generates/persists device ID. |
|
||||
| `login_with_email` | `func login_with_email(email: String, password: String, remember: bool = true) -> bool` | bool (async) | Email/password authentication |
|
||||
| `register_with_email` | `func register_with_email(email: String, password: String, username: String = "") -> bool` | bool (async) | Email registration (create if not exists) |
|
||||
| `login_with_google` | `func login_with_google(id_token: String) -> bool` | bool (async) | Google auth via ID token |
|
||||
| `login_with_apple` | `func login_with_apple(id_token: String) -> bool` | bool (async) | Apple auth via ID token |
|
||||
| `login_with_facebook` | `func login_with_facebook(access_token: String) -> bool` | bool (async) | Facebook auth via access token |
|
||||
| `login_with_steam` | `func login_with_steam() -> bool` | bool (async) | Steam ticket auth via BackendService.steamworks_manager |
|
||||
| `link_email` | `func link_email(email: String, password: String) -> bool` | bool (async) | Link email to existing guest account |
|
||||
| `link_google` | `func link_google(id_token: String) -> bool` | bool (async) | Link Google to existing account |
|
||||
| `logout` | `func logout() -> void` | void | Full cleanup: NakamaManager.cleanup(), clear session files, reset state, emit logged_out |
|
||||
| `clear_session` | `func clear_session() -> void` | void | Deletes SESSION_FILE and CREDENTIALS_FILE from user:// |
|
||||
| `_try_restore_session` | internal | void | Attempts to load encrypted session file. Skips guest session auto-restore. |
|
||||
| `_connect_socket` | internal | bool (async) | Creates Nakama socket, connects, initializes multiplayer bridge |
|
||||
| `_load_user_profile` | internal | void (async) | Loads account data from Nakama into current_user |
|
||||
|
||||
**Dependencies:** NakamaManager, BackendService.
|
||||
**Depended by:** LobbyMainMenu, lobby.gd, UserProfileManager, login_screen.tscn.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 4.2 LobbyManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/lobby_manager.gd` (1023 lines)
|
||||
**Extends:** Node
|
||||
**Autoload name:** LobbyManager
|
||||
|
||||
Room/lobby lifecycle manager. Handles both Nakama (online) and LAN (direct ENet) modes. Manages room creation, joining, player list, ready states, game mode settings, and character/area selection.
|
||||
|
||||
**Properties:**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| current_room | Dictionary | {} | Current room metadata |
|
||||
| players_in_room | Array | [] | [{id, name, is_ready, character, nakama_id}] |
|
||||
| available_rooms | Array | [] | Discovered rooms for room list |
|
||||
| is_host | bool | false | Whether local player is room host |
|
||||
| is_lan_mode | bool | false | Direct ENet (no Nakama) |
|
||||
| LAN_PORT | const int | 7777 | ENet server port |
|
||||
| LAN_DISCOVERY_PORT | const int | 7778 | UDP broadcast port |
|
||||
| local_player_name | String | "Player" | Display name |
|
||||
| is_tutorial_mode | bool | false | Tutorial mode flag |
|
||||
| match_duration | int | 180 | Seconds (configurable by host) |
|
||||
| randomize_spawn | bool | false | Randomize spawn positions |
|
||||
| enable_cycle_timer | bool | false | Goal cycle timer |
|
||||
| scarcity_mode | String | "Normal" | Item scarcity: Normal/Aggressive/Chaos |
|
||||
| disconnect_reason | String | "" | UI feedback message |
|
||||
| sng_go_duration | int | 20 | Stop n Go: GO phase seconds |
|
||||
| sng_stop_duration | int | 4 | Stop n Go: STOP phase seconds |
|
||||
| sng_required_goals | int | 8 | Goals needed for SNG win |
|
||||
| doors_swap_time | int | 15 | Tekton Doors: swap interval |
|
||||
| doors_refresh_time | int | 25 | Tekton Doors: refresh interval |
|
||||
| doors_required_goals | int | 8 | Goals needed for Doors win |
|
||||
| rematch_votes | Array | [] | Player IDs who voted for rematch |
|
||||
| available_characters | Array[String] | [...] | ["Copper", "Dabro", "Gatot", "Pip", "Random"] |
|
||||
| available_areas | Array[String] | [] | Mode-specific area list |
|
||||
| available_game_modes | Array[String] | [...] | ["Freemode", "Stop n Go", "Candy Pump Survival"] |
|
||||
| selected_area | String | "Freemode Arena" | Currently selected area |
|
||||
| game_mode | String | "Freemode" | Current game mode |
|
||||
| local_character_index | int | 0 | Local player's character index |
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `room_list_updated` | rooms: Array | Room list refreshed |
|
||||
| `room_joined` | room_data: Dictionary | Joined a room |
|
||||
| `room_left` | none | Left current room |
|
||||
| `player_joined` | player_data: Dictionary | Player entered room |
|
||||
| `player_left` | player_id: int | Player left room |
|
||||
| `ready_state_changed` | player_id: int, is_ready: bool | Player ready status changed |
|
||||
| `all_players_ready` | none | All players ready |
|
||||
| `host_disconnected` | none | Host left/disconnected |
|
||||
| `game_starting` | none | Game countdown started |
|
||||
| `match_duration_changed` | duration_seconds: int | Duration setting changed |
|
||||
| `randomize_spawn_changed` | enabled: bool | Random spawn toggled |
|
||||
| `character_changed` | player_id: int, character_name: String | Character selection changed |
|
||||
| `area_changed` | area_name: String | Map area changed |
|
||||
| `player_list_changed` | none | Player list should re-render |
|
||||
| `rematch_votes_updated` | count: int, required: int | Rematch vote progress |
|
||||
| `game_mode_changed` | mode: String | Game mode changed |
|
||||
| `scarcity_mode_changed` | mode: String | Scarcity setting changed |
|
||||
| `enable_cycle_timer_changed` | enabled: bool | Timer toggle changed |
|
||||
| `sng_go_duration_changed` | duration: int | SNG Go duration changed |
|
||||
| `sng_stop_duration_changed` | duration: int | SNG Stop duration changed |
|
||||
| `sng_required_goals_changed` | goals: int | SNG required goals changed |
|
||||
| `doors_swap_time_changed` | time: int | Doors swap interval changed |
|
||||
| `doors_refresh_time_changed` | time: int | Doors refresh interval changed |
|
||||
| `doors_required_goals_changed` | goals: int | Doors required goals changed |
|
||||
| `gauntlet_round_duration_changed` | duration: int | Gauntlet round duration changed |
|
||||
| `gauntlet_growth_interval_changed` | interval: float | Gauntlet growth interval changed |
|
||||
| `gauntlet_cells_per_tick_changed` | cells: Dictionary | Cells per tick changed |
|
||||
|
||||
**Key Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `start_tutorial` | `func start_tutorial(mode: String = "Freemode") -> void` | void | Sets tutorial flags, calls create_room_lan("Tutorial") |
|
||||
| `create_room` | `func create_room(room_name: String) -> void` | void | Hosts Nakama room: connects, calls NakamaManager.host_game |
|
||||
| `join_room` | `func join_room(match_id: String) -> void` | void | Joins Nakama room by match ID |
|
||||
| `create_room_lan` | `func create_room_lan(room_name: String = "LAN Game") -> bool` | bool | Creates ENet server on LAN_PORT, broadcasts UDP discovery |
|
||||
| `join_room_lan` | `func join_room_lan(host_ip: String) -> bool` | bool | Creates ENet client to host IP:LAN_PORT |
|
||||
| `leave_room` | -- | void | Leaves current room, cleans up peers |
|
||||
| `start_game` | `func start_game(is_tutorial: bool = false) -> void` | void | Transitions from lobby to main game scene |
|
||||
| `refresh_room_list` | `func refresh_room_list() -> void` | void | Queries Nakama for available rooms or broadcasts LAN |
|
||||
| `set_ready` | `func set_ready(is_ready: bool) -> void` | void | Updates ready state via RPC |
|
||||
| `set_match_duration` | `func set_match_duration(seconds: int) -> void` | void | Host sets match duration |
|
||||
| `set_randomize_spawn` | `func set_randomize_spawn(enabled: bool) -> void` | void | Host toggles random spawn |
|
||||
| `set_enable_cycle_timer` | `func set_enable_cycle_timer(enabled: bool) -> void` | void | Host toggles timer |
|
||||
| `set_scarcity_mode` | `func set_scarcity_mode(mode: String) -> void` | void | Host sets scarcity |
|
||||
| `set_game_mode` | `func set_game_mode(mode: String) -> void` | void | Host sets game mode |
|
||||
| `cycle_character` | `func cycle_character(direction: int) -> void` | void | Change character selection |
|
||||
| `cycle_area` | `func cycle_area(direction: int) -> void` | void | Change selected area |
|
||||
| `get_players` | `func get_players() -> Array` | Array | Returns players_in_room |
|
||||
| `is_all_ready` | `func is_all_ready() -> bool` | bool | All players ready check |
|
||||
| `set_sng_go_duration` | -- | void | Host sets SNG go time |
|
||||
| `set_sng_stop_duration` | -- | void | Host sets SNG stop time |
|
||||
| `set_sng_required_goals` | -- | void | Host sets SNG goals |
|
||||
| `get_selected_area` | `func get_selected_area() -> String` | String | Returns current area name |
|
||||
| `get_game_mode` | `func get_game_mode() -> GameMode.Mode` | GameMode.Mode | Converts string to GameMode enum |
|
||||
| `is_game_mode` | `func is_game_mode(mode: GameMode.Mode) -> bool` | bool | Mode comparison helper |
|
||||
|
||||
**Internal Functions:** `_on_match_joined`, `_on_peer_connected`, `_on_peer_disconnected`, `_on_server_disconnected`, `_update_available_areas`, `_start_lan_broadcast`, `_broadcast_lan_room`, `_stop_lan_broadcast`, `_update_lan_room_list`, `_listen_for_lan_discovery`, `_update_ready_state_rpc`, `_request_rematch`, `rpc_set_*`, `rpc_*`.
|
||||
|
||||
**Dependencies:** NakamaManager, GameStateManager.
|
||||
**Depended by:** LobbyRoom, LobbyRoomList, LobbyMainMenu, main.gd, player.gd, lobby.gd, SceneManager (loading screen).
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 4.3 GameStateManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/game_state_manager.gd` (66 lines)
|
||||
**Extends:** Node
|
||||
**Autoload name:** GameStateManager
|
||||
|
||||
Simple state machine and match configuration constants.
|
||||
|
||||
**Properties:**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| current_state | GameState (enum) | LOBBY | Current application state |
|
||||
| max_players | int | 8 | Max players in a match |
|
||||
| enable_bots | bool | false | Bot fill toggle |
|
||||
| local_player_id | int | 0 | Local peer ID |
|
||||
|
||||
**Enums:** `GameState { LOBBY, LOADING, GAME, RESULT }`
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `state_changed` | new_state: GameState | Emitted on state transition |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `change_state` | `func change_state(new_state: GameState) -> void` | void | Transitions state, emits state_changed |
|
||||
|
||||
**Dependencies:** None.
|
||||
**Depended by:** LobbyManager, main.gd, tutorial_manager.gd, many managers.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 4.4 PlayerManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/player_manager.gd` (37 lines)
|
||||
**Extends:** Node
|
||||
**Autoload name:** PlayerManager
|
||||
|
||||
Lightweight data container for player metadata. Stores display name and peer ID for the local player. Used as a quick reference by various subsystems.
|
||||
|
||||
**Properties:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|---|---|---|
|
||||
| display_name | String | Local player's display name |
|
||||
| peer_id | int | Local player's multiplayer unique ID |
|
||||
|
||||
**Signals:** None.
|
||||
|
||||
**Public Functions:** None (data-only container).
|
||||
|
||||
**Dependencies:** None.
|
||||
**Depended by:** UIManager, player.gd, various managers needing player identity.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 4.5 EventBus
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/event_bus.gd` (73 lines)
|
||||
**Extends:** Node
|
||||
**Autoload name:** EventBus
|
||||
|
||||
Centralized observer pattern for inter-manager communication. Replaces direct cross-references between managers.
|
||||
|
||||
**Constants (event names):**
|
||||
|
||||
| Constant | Value | Description |
|
||||
|---|---|---|
|
||||
| EVENT_PLAYER_JOINED | "player_joined" | Player entered match |
|
||||
| EVENT_PLAYER_LEFT | "player_left" | Player left match |
|
||||
| EVENT_PLAYER_READY | "player_ready" | Player ready state changed |
|
||||
| EVENT_MATCH_STARTED | "match_started" | Match began |
|
||||
| EVENT_MATCH_ENDED | "match_ended" | Match ended |
|
||||
| EVENT_GAME_MODE_CHANGED | "game_mode_changed" | Game mode switched |
|
||||
| EVENT_CURRENCY_CHANGED | "currency_changed" | Wallet balance changed |
|
||||
| EVENT_ITEM_PURCHASED | "item_purchased" | Item bought from shop |
|
||||
| EVENT_GACHA_PULL | "gacha_pull" | Gacha rolled |
|
||||
| EVENT_PROFILE_LOADED | "profile_loaded" | Profile loaded from server |
|
||||
| EVENT_PROFILE_UPDATED | "profile_updated" | Profile updated |
|
||||
| EVENT_AVATAR_CHANGED | "avatar_changed" | Avatar changed |
|
||||
| EVENT_SESSION_REFRESHED | "session_refreshed" | Nakama session refreshed |
|
||||
| EVENT_SESSION_EXPIRED | "session_expired" | Nakama session expired |
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `event_emitted` | event_name: String, data: Variant | Fired on every emit |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `emit` | `func emit(event_name: String, data: Variant = null) -> void` | void | Emit event to all registered listeners and the signal bus |
|
||||
| `on` | `func on(event_name: String, callback: Callable) -> void` | void | Subscribe to event |
|
||||
| `off` | `func off(event_name: String, callback: Callable) -> void` | void | Unsubscribe from event |
|
||||
| `clear` | `func clear() -> void` | void | Remove all listeners (scene transition cleanup) |
|
||||
|
||||
**Dependencies:** None.
|
||||
**Depended by:** UserProfileManager, GachaManager, ShopManager, many managers for loose coupling.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 4.6 GameMode / ModeConfig
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/game_mode.gd` (41 lines)
|
||||
**Extends:** RefCounted
|
||||
**class_name:** GameMode
|
||||
|
||||
Enum and string conversion utilities for game modes.
|
||||
|
||||
**Enum:** `Mode { FREEMODE = 0, STOP_N_GO = 1, TEKTON_DOORS = 2, GAUNTLET = 3 }`
|
||||
|
||||
**Public Static Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `from_string` | `static func from_string(mode: String) -> Mode` | Mode | Converts "Freemode"/"Stop n Go"/"Tekton Doors"/"Candy Pump Survival" to enum |
|
||||
| `mode_to_string` | `static func mode_to_string(mode: Mode) -> String` | String | Converts enum back to string |
|
||||
| `is_restricted` | `static func is_restricted(mode: Mode) -> bool` | bool | Returns true for SNG, Doors, or Gauntlet |
|
||||
| `get_all_modes` | `static func get_all_modes() -> Array[String]` | Array[String] | Returns all mode names |
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/mode_config.gd` (108 lines)
|
||||
**Extends:** RefCounted
|
||||
**class_name:** ModeConfig
|
||||
|
||||
Schema-driven validation for game mode settings. Consolidates duplicated/inconsistent option toggles.
|
||||
|
||||
**Public Static Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `get_defaults` | `static func get_defaults(mode: String) -> Dictionary` | Dictionary | Returns default config dict for mode |
|
||||
| `validate_setting` | `static func validate_setting(mode: String, key: String, value: Variant) -> Dictionary` | Dictionary | Validates type, range, and allowed values for a single setting |
|
||||
| `validate_config` | `static func validate_config(mode: String, config: Dictionary) -> Dictionary` | Dictionary | Validates entire config, returns errors array |
|
||||
| `get_mode_settings` | `static func get_mode_settings(mode: String) -> Array` | Array | Returns list of setting keys for mode |
|
||||
| `get_setting_schema` | `static func get_setting_schema(mode: String, key: String) -> Dictionary` | Dictionary | Returns schema for specific setting |
|
||||
| `has_setting` | `static func has_setting(mode: String, key: String) -> bool` | bool | Checks if setting exists for mode |
|
||||
| `get_supported_modes` | `static func get_supported_modes() -> Array` | Array | Returns all supported mode strings |
|
||||
|
||||
**Dependencies:** None (standalone utility classes).
|
||||
**Depended by:** LobbyManager, LobbyRoom, mode-specific managers.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## 5. Player Subsystem Managers
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 5.1 PlayerMovementManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/player_movement_manager.gd` (33,053 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** PlayerMovementManager
|
||||
|
||||
Handles player movement physics, grid-based pathfinding, movement range highlighting, position syncing, and obstacle-aware navigation. Delegated from player.gd.
|
||||
|
||||
**Signals:** (custom signals listed; full list from code)
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `movement_started` | path: Array | Emitted when player begins moving |
|
||||
| `movement_completed` | none | Emitted when movement tween finishes |
|
||||
| `movement_interrupted` | none | Emitted when movement is cancelled |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `move_along_path` | `func move_along_path(player: Node, path: Array) -> void` | void | Tweens player along grid path |
|
||||
| `find_path` | `func find_path(from: Vector2i, to: Vector2i, gridmap: Node) -> Array` | Array | A* or BFS pathfinding on grid |
|
||||
| `highlight_movement_range` | `func highlight_movement_range(player: Node) -> void` | void | Shows reachable cells |
|
||||
| `highlight_adjacent_cells` | `func highlight_adjacent_cells(player: Node) -> void` | void | Shows cardinal-adjacent cells |
|
||||
| `rotate_towards_target` | `func rotate_towards_target(target_pos: Vector2i) -> void` | void | Smooth rotation to face target |
|
||||
| `can_move_to` | `func can_move_to(pos: Vector2i, gridmap: Node) -> bool` | bool | Cell walkability check |
|
||||
| `apply_stagger` | `func apply_stagger(duration: float) -> void` | void | Applies stun knockback |
|
||||
| `sync_bump` | `func sync_bump(target_pos: Vector2i, is_soft: bool) -> void` | void | Visual bump animation |
|
||||
| `set_player_moving` | `func set_player_moving(is_moving: bool) -> void` | void | Toggle movement state |
|
||||
|
||||
**Dependencies:** player.gd (node refs), ObstacleManager, SpecialTilesManager, EnhancedGridMap.
|
||||
**Depended by:** player.gd, main.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 5.2 PlayerInputManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/player_input_manager.gd` (7,292 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** PlayerInputManager
|
||||
|
||||
Captures and buffers player input events. Supports keyboard, mouse, gamepad, and touch inputs. Provides input state query API.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `input_received` | event: InputEvent | Raw input forwarded |
|
||||
| `action_pressed` | action: String | Action mapped press (grab, put, move) |
|
||||
| `action_released` | action: String | Action released |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `is_action_held` | `func is_action_held(action: String) -> bool` | bool | Check if action is currently held |
|
||||
| `get_movement_direction` | `func get_movement_direction() -> Vector2i` | Vector2i | Grid-aligned movement cardinal |
|
||||
| `get_look_direction` | `func get_look_direction(camera: Camera3D) -> Vector2` | Vector2 | Mouse-world direction |
|
||||
| `flush_buffer` | `func flush_buffer() -> void` | void | Clear input buffer |
|
||||
| `is_touch_active` | `func is_touch_active() -> bool` | bool | Whether touch controls are in use |
|
||||
|
||||
**Dependencies:** TouchControls (autoload).
|
||||
**Depended by:** player.gd, player_action_manager.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 5.3 PlayerActionManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/player_action_manager.gd` (8,828 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** PlayerActionManager
|
||||
|
||||
Action execution layer. Manages grab, put, arrange, tekton throw/knock actions. Handles action point consumption, cooldowns, and visual highlighting.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `action_executed` | action_type: String | Action performed |
|
||||
| `action_failed` | reason: String | Action invalid |
|
||||
| `action_points_changed` | points: int | AP updated |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `execute_grab` | `func execute_grab(player: Node, grid_pos: Vector2i) -> bool` | bool | Grab item from grid |
|
||||
| `execute_put` | `func execute_put(player: Node, slot_index: int, grid_pos: Vector2i) -> bool` | bool | Put item from playerboard to grid |
|
||||
| `execute_arrange` | `func execute_arrange(player: Node, from_slot: int, to_slot: int) -> bool` | bool | Rearrange playerboard slots |
|
||||
| `consume_action_points` | `func consume_action_points(points: int) -> void` | void | Deduct action points |
|
||||
| `can_afford_action` | `func can_afford_action() -> bool` | bool | Check AP > 0 |
|
||||
| `after_action_completed` | `func after_action_completed() -> void` | void | Post-action cleanup: check win, cycle goals |
|
||||
| `highlight_cells_if_authorized` | `func highlight_cells_if_authorized(cells: Array, item_id: int) -> void` | void | Show valid target cells |
|
||||
| `highlight_empty_adjacent_cells` | `func highlight_empty_adjacent_cells() -> void` | void | Show empty adjacent cells for put |
|
||||
| `highlight_occupied_playerboard_slots` | `func highlight_occupied_playerboard_slots() -> void` | void | Show occupied slots for grab |
|
||||
| `highlight_random_valid_cells` | `func highlight_random_valid_cells() -> void` | void | Show random valid cells |
|
||||
| `clear_highlights` | `func clear_highlights() -> void` | void | Remove all cell highlights |
|
||||
| `clear_playerboard_highlights` | `func clear_playerboard_highlights() -> void` | void | Remove playerboard highlights |
|
||||
|
||||
**Dependencies:** PlayerboardManager, PlayerInputManager, GoalsCycleManager.
|
||||
**Depended by:** player.gd, main.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 5.4 PlayerboardManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/playerboard_manager.gd` (22,790 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** PlayerboardManager
|
||||
|
||||
Manages each player's inventory board (2x5 or 3x5 grid of item slots). Handles slot selection, item placement, auto-arrange for goal matching, drag-and-drop, and visual updates.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `slot_selected` | slot_index: int | Slot clicked/selected |
|
||||
| `slot_deselected` | none | Selection cleared |
|
||||
| `item_placed` | slot_index: int, item_id: int | Item added to slot |
|
||||
| `item_removed` | slot_index: int | Item removed from slot |
|
||||
| `playerboard_updated` | player_id: int, board: Array | Full board synced |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `grab_item` | `func grab_item(grid_pos: Vector2i) -> bool` | bool | Auto-place grabbed item into best-fit slot |
|
||||
| `auto_put_item` | `func auto_put_item() -> bool` | bool | Put goal-matching tile from board to adjacent grid |
|
||||
| `handle_slot_clicked` | `func handle_slot_clicked(slot_index: int) -> void` | void | Process slot click event |
|
||||
| `handle_playerboard_slot_selected` | `func handle_playerboard_slot_selected(slot_index: int) -> void` | void | Handle slot selection for action |
|
||||
| `handle_put_slot_selected` | `func handle_put_slot_selected(slot_index: int) -> void` | void | Handle slot chosen for put action |
|
||||
| `arrange_playerboard_item` | `func arrange_playerboard_item(slot_index: int) -> void` | void | Move item to better slot |
|
||||
| `select_playerboard_slot` | `func select_playerboard_slot(slot_index: int) -> void` | void | Mark slot as selected |
|
||||
| `deselect_playerboard_slot` | `func deselect_playerboard_slot() -> void` | void | Clear slot selection |
|
||||
| `target_playerboard_slot` | `func target_playerboard_slot(slot_index: int) -> void` | void | Target a slot for move |
|
||||
| `untarget_playerboard_slot` | `func untarget_playerboard_slot() -> void` | void | Clear target |
|
||||
| `can_move_to_target_playerboard_slot` | `func can_move_to_target_playerboard_slot() -> bool` | bool | Check if target slot is valid |
|
||||
| `bot_grab_item` | `func bot_grab_item(pos: Vector2i, slot: int, x: int, y: int, z: int) -> void` | void | Bot performs grab |
|
||||
| `bot_put_item` | `func bot_put_item(pos: Vector2i, slot: int, x: int, y: int, z: int) -> void` | void | Bot performs put |
|
||||
| `bot_arrange_item` | `func bot_arrange_item(from_slot: int, to_slot: int) -> void` | void | Bot rearranges board |
|
||||
|
||||
**Dependencies:** GoalsCycleManager, GoalManager, EnhancedGridMap (scene ref).
|
||||
**Depended by:** player.gd, PlayerActionManager.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 5.5 PowerupManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/powerup_manager.gd` (9,417 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** PowerupManager
|
||||
|
||||
Powerup/boost system. Tracks boost charge level, special ability availability, and consumes boost for charged actions.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `boost_changed` | amount: float | Boost level changed |
|
||||
| `boost_full` | none | Boost reached 100% |
|
||||
| `powerup_activated` | type: String | Powerup used |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `add_boost` | `func add_boost(amount: float) -> void` | void | Increment boost |
|
||||
| `consume_boost` | `func consume_boost(amount: float) -> void` | void | Deduct boost |
|
||||
| `can_use_special` | `func can_use_special() -> bool` | bool | Boost >= 100 |
|
||||
| `get_boost_pct` | `func get_boost_pct() -> float` | float | 0.0 to 1.0 |
|
||||
| `reset_boost` | `func reset_boost() -> void` | void | Set to 0 |
|
||||
|
||||
**Dependencies:** None.
|
||||
**Depended by:** player.gd (charged strike, knock), PlayerActionManager.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## 6. Game Mode Managers
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 6.1 StopNGoManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/stop_n_go_manager.gd` (21,884 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** StopNGoManager
|
||||
|
||||
State machine for the Stop n Go game mode. Alternates between GO (movement allowed) and STOP (frozen) phases. Tracks winner via first player to complete required goals during GO phases.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `phase_changed` | phase: String ("go"/"stop") | GO/STOP transition |
|
||||
| `countdown_tick` | seconds: int | Phase countdown tick |
|
||||
| `sng_winner` | player_id: int | Winner determined |
|
||||
| `sng_ended` | none | Minigame concluded |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `start_sng` | `func start_sng(go_duration: int, stop_duration: int, required_goals: int) -> void` | void | Initialize SNG with params |
|
||||
| `stop_sng` | `func stop_sng() -> void` | void | End SNG minigame |
|
||||
| `start_go_phase` | `func start_go_phase() -> void` | void | Begin GO timer |
|
||||
| `start_stop_phase` | `func start_stop_phase() -> void` | void | Begin STOP timer, freeze all |
|
||||
| `freeze_player` | `func freeze_player(player_id: int) -> void` | void | Stop player movement |
|
||||
| `unfreeze_player` | `func unfreeze_player(player_id: int) -> void` | void | Resume player movement |
|
||||
| `check_winner` | `func check_winner() -> int` | int | Returns winner peer_id or -1 |
|
||||
| `get_phase` | `func get_phase() -> String` | String | Current phase |
|
||||
|
||||
**Dependencies:** TurnManager, GoalManager, GoalsCycleManager, animation.gd (scene).
|
||||
**Depended by:** main.gd, player.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 6.2 GauntletManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/gauntlet_manager.gd` (5,467 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** GauntletManager
|
||||
|
||||
Manages the Candy Pump Survival / Gauntlet game mode. Handles round progression, danger zone growth (flood fill), and elimination.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `round_started` | round: int | New round began |
|
||||
| `danger_zone_grown` | cells: Array | New tiles flooded |
|
||||
| `player_eliminated` | player_id: int | Player fell off/eliminated |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `start_gauntlet` | `func start_gauntlet(duration: int, growth_interval: float) -> void` | void | Initialize gauntlet mode |
|
||||
| `stop_gauntlet` | `func stop_gauntlet() -> void` | void | End gauntlet mode |
|
||||
| `eliminate_player` | `func eliminate_player(player_id: int) -> void` | void | Mark player as eliminated |
|
||||
| `get_alive_players` | `func get_alive_players() -> Array` | Array | Returns non-eliminated player IDs |
|
||||
| `get_round` | `func get_round() -> int` | int | Current round number |
|
||||
|
||||
**Dependencies:** TurnManager, EnhancedGridMap.
|
||||
**Depended by:** main.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 6.3 PortalModeManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/portal_mode_manager.gd` (20,072 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** PortalModeManager
|
||||
|
||||
Manages portal race mode (Tekton Doors variant). Tracks portal positions, door swapping, refresh cycles, and race completion.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `portals_swapped` | portal_pairs: Array | Doors swapped positions |
|
||||
| `portals_refreshed` | portals: Array | New portal set spawned |
|
||||
| `player_teleported` | player_id: int, from: Vector2i, to: Vector2i | Player used portal |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `start_portal_mode` | `func start_portal_mode(swap_time: int, refresh_time: int) -> void` | void | Initialize portal mode |
|
||||
| `stop_portal_mode` | `func stop_portal_mode() -> void` | void | End portal mode |
|
||||
| `teleport_player` | `func teleport_player(player: Node, portal_enter: Vector2i) -> void` | void | Teleport player through portal pair |
|
||||
| `swap_portals` | `func swap_portals() -> void` | void | Randomize portal positions |
|
||||
| `refresh_portals` | `func refresh_portals() -> void` | void | Spawn new portal set |
|
||||
| `get_portal_pair` | `func get_portal_pair(portal_id: int) -> Array` | Array | Returns [entry, exit] positions |
|
||||
|
||||
**Dependencies:** SpecialTilesManager, EnhancedGridMap.
|
||||
**Depended by:** main.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 6.4 GoalManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/goal_manager.gd` (3,857 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** GoalManager
|
||||
|
||||
Goal definitions, validation rules, and completion detection. Checks if a player's board arrangement matches the current goal pattern.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `goal_completed` | player_id: int, goal_id: int | Player completed a goal |
|
||||
| `goal_failed` | player_id: int, reason: String | Goal became impossible |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `validate_goal` | `func validate_goal(player_board: Array, goal: Dictionary) -> bool` | bool | Check board matches goal pattern |
|
||||
| `get_goal_type` | `func get_goal_type(goal: Dictionary) -> String` | String | Goal category (row, col, set, pattern) |
|
||||
| `is_goal_possible` | `func is_goal_possible(player_board: Array, goal: Dictionary) -> bool` | bool | Whether goal is still achievable |
|
||||
| `find_best_slot_for_item` | `func find_best_slot_for_item(board: Array, item: int, goal: Dictionary) -> int` | int | Auto-place item into best slot |
|
||||
|
||||
**Dependencies:** None.
|
||||
**Depended by:** GoalsCycleManager, PlayerboardManager, StopNGoManager.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 6.5 GoalsCycleManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/goals_cycle_manager.gd` (20,175 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** GoalsCycleManager
|
||||
|
||||
Manages cycling goal rotation. Tracks per-player score, cycles active goals on timer or action trigger, and determines when a player reaches the goal threshold to win.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `goals_cycled` | new_goals: Array | Active goals changed |
|
||||
| `player_scored` | player_id: int, points: int | Player earned points |
|
||||
| `player_won` | player_id: int | Player reached win threshold |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `start_cycle` | `func start_cycle(timer_enabled: bool) -> void` | void | Begin goal cycling |
|
||||
| `stop_cycle` | `func stop_cycle() -> void` | void | Stop cycling |
|
||||
| `cycle_goals` | `func cycle_goals() -> void` | void | Generate new goal set |
|
||||
| `add_score` | `func add_score(player_id: int, points: int) -> void` | void | Award points to player |
|
||||
| `get_player_score` | `func get_player_score(player_id: int) -> int` | int | Get player's current score |
|
||||
| `get_current_goals` | `func get_current_goals() -> Array` | Array | Get active goals |
|
||||
| `set_goal_threshold` | `func set_goal_threshold(goals_needed: int) -> void` | void | Set goals to win |
|
||||
|
||||
**Dependencies:** GoalManager, TurnManager, Timer (scene).
|
||||
**Depended by:** main.gd, PlayerActionManager, StopNGoManager.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 6.6 PlayerRaceManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/player_race_manager.gd` (4,757 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** PlayerRaceManager
|
||||
|
||||
Race-specific logic. Tracks player race position, finish locations, lap progression, and race completion.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `position_changed` | player_id: int, pos: int | Player moved in race order |
|
||||
| `lap_completed` | player_id: int, lap: int | Player finished a lap |
|
||||
| `race_completed` | results: Array | Final standings [{id, position}] |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `start_race` | `func start_race() -> void` | void | Initialize race state |
|
||||
| `end_race` | `func end_race() -> void` | void | Finalize race |
|
||||
| `on_race_completed` | `func on_race_completed(final_pos: int) -> void` | void | Player crossed finish line |
|
||||
| `get_current_finish_locations` | `func get_current_finish_locations() -> Array` | Array | Active finish positions |
|
||||
| `update_finish_availability` | `func update_finish_availability() -> void` | void | Recalculate finish positions |
|
||||
| `get_player_position` | `func get_player_position(player_id: int) -> int` | int | Current race order index |
|
||||
| `add_second_lap_goals` | `func add_second_lap_goals(goals: Array) -> void` | void | Set lap 2 goals |
|
||||
|
||||
**Dependencies:** GoalsCycleManager.
|
||||
**Depended by:** player.gd, main.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 6.7 TurnManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/turn_manager.gd` (849 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** TurnManager
|
||||
|
||||
Turn-based sequencing for game modes that use round-robin or ordered turns (e.g., Stop n Go, Tekton Doors).
|
||||
|
||||
**Properties:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|---|---|---|
|
||||
| current_turn | int | Index in turn order |
|
||||
| turn_order | Array | Player peer IDs in sequence |
|
||||
| is_my_turn | bool | Whether local player is active |
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `turn_changed` | player_id: int | Active turn changed |
|
||||
| `turn_order_set` | order: Array | Turn order established |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `set_turn_order` | `func set_turn_order(order: Array) -> void` | void | Establish turn sequence |
|
||||
| `next_turn` | `func next_turn() -> void` | void | Advance to next player |
|
||||
| `get_current_player` | `func get_current_player() -> int` | int | Current player peer ID |
|
||||
|
||||
**Dependencies:** None.
|
||||
**Depended by:** StopNGoManager, GauntletManager, GoalsCycleManager.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## 7. Gameplay Managers
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 7.1 ObstacleManager
|
||||
|
||||
[Back to top](#up)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/obstacle_manager.gd` (5,662 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** ObstacleManager
|
||||
|
||||
Obstacle placement and removal on the game grid. Handles wall tiles, blocking tiles, and destructible barriers.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `obstacle_placed` | cell: Vector3i, item_id: int | New obstacle added |
|
||||
| `obstacle_removed` | cell: Vector3i | Obstacle destroyed |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `place_obstacle` | `func place_obstacle(cell: Vector3i, item_id: int) -> void` | void | Set obstacle on grid layer |
|
||||
| `remove_obstacle` | `func remove_obstacle(cell: Vector3i) -> void` | void | Clear obstacle |
|
||||
| `is_cell_blocked` | `func is_cell_blocked(cell: Vector3i, gridmap: Node) -> bool` | bool | Check if cell has blocking tile |
|
||||
| `get_blocked_cells` | `func get_blocked_cells(gridmap: Node) -> Array` | Array | All blocked cells |
|
||||
| `clear_all_obstacles` | `func clear_all_obstacles() -> void` | void | Remove all obstacles |
|
||||
|
||||
**Dependencies:** EnhancedGridMap (scene ref).
|
||||
**Depended by:** PlayerMovementManager, SpecialTilesManager.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 7.2 SpecialTilesManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/special_tiles_manager.gd` (23,090 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** SpecialTilesManager
|
||||
|
||||
Manages special floor tiles: ice (slippery), crack (breakable), portal tiles, teleporters, and other interactive terrain.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `tile_activated` | pos: Vector2i, tile_type: String | Tile effect triggered |
|
||||
| `ice_slide_started` | player_id: int | Player started sliding |
|
||||
| `crack_broke` | pos: Vector2i | Crack tile collapsed |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `apply_tile_effect` | `func apply_tile_effect(player: Node, pos: Vector2i) -> void` | void | Activate tile effect on player |
|
||||
| `get_tile_at` | `func get_tile_at(pos: Vector2i, gridmap: Node) -> int` | int | Item ID at position |
|
||||
| `set_tile` | `func set_tile(pos: Vector2i, item_id: int, gridmap: Node) -> void` | void | Set tile item |
|
||||
| `is_ice_tile` | `func is_ice_tile(item_id: int) -> bool` | bool | Check ice type |
|
||||
| `is_crack_tile` | `func is_crack_tile(item_id: int) -> bool` | bool | Check crack type |
|
||||
| `is_portal_tile` | `func is_portal_tile(item_id: int) -> bool` | bool | Check portal type |
|
||||
| `spawn_portal_pair` | `func spawn_portal_pair(pos_a: Vector2i, pos_b: Vector2i) -> void` | void | Create portal entry/exit |
|
||||
| `remove_portal_pair` | `func remove_portal_pair(pos_a: Vector2i, pos_b: Vector2i) -> void` | void | Remove portal tiles |
|
||||
|
||||
**Dependencies:** EnhancedGridMap, ObstacleManager, PortalModeManager.
|
||||
**Depended by:** PlayerMovementManager.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 7.3 StaticTektonManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/static_tekton_manager.gd` (7,416 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** StaticTektonManager
|
||||
|
||||
Manages stationary Tekton turret behavior. Handles targeting, projectile spawning, and stun zones.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `turret_fired` | turret_id: int, target_pos: Vector2i | Turret shot |
|
||||
| `turret_stunned` | turret_id: int | Turret disabled |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `activate_turret` | `func activate_turret(turret: Node) -> void` | void | Start turret behavior |
|
||||
| `deactivate_turret` | `func deactivate_turret(turret: Node) -> void` | void | Stop turret |
|
||||
| `fire_at_player` | `func fire_at_player(turret: Node, target: Vector2i) -> void` | void | Fire projectile at grid pos |
|
||||
|
||||
**Dependencies:** EnhancedGridMap, ObstacleManager.
|
||||
**Depended by:** main.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## 8. UI / Presentation Managers
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 8.1 UIManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/ui_manager.gd` (21,645 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** UIManager
|
||||
|
||||
Manages the UI layer stack: show/hide panels, overlay management, HUD elements, and dynamic UI creation.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `panel_opened` | panel_name: String | Panel shown |
|
||||
| `panel_closed` | panel_name: String | Panel hidden |
|
||||
| `hud_updated` | data: Dictionary | HUD refresh |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `show_panel` | `func show_panel(panel_name: String, data: Dictionary = {}) -> void` | void | Show named panel |
|
||||
| `hide_panel` | `func hide_panel(panel_name: String) -> void` | void | Hide named panel |
|
||||
| `toggle_panel` | `func toggle_panel(panel_name: String) -> void` | void | Toggle panel visibility |
|
||||
| `show_hud` | `func show_hud() -> void` | void | Display HUD |
|
||||
| `hide_hud` | `func hide_hud() -> void` | void | Hide HUD |
|
||||
| `create_dynamic_ui` | `func create_dynamic_ui(scene_path: String) -> Node` | Node | Instantiate UI from tscn |
|
||||
| `destroy_dynamic_ui` | `func destroy_dynamic_ui(ui_node: Node) -> void` | void | Remove dynamic UI |
|
||||
| `focus_panel` | `func focus_panel(panel_name: String) -> void` | void | Bring panel to front |
|
||||
| `get_active_panels` | `func get_active_panels() -> Array` | Array | Currently visible panels |
|
||||
|
||||
**Dependencies:** None.
|
||||
**Depended by:** main.gd, lobby.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 8.2 SfxManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/sfx_manager.gd` (2,046 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** SfxManager
|
||||
|
||||
Sound effect playback pool. Manages one-shot SFX with positional audio support.
|
||||
|
||||
**Signals:** None.
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `play` | `func play(sfx_name: String, position: Vector3 = Vector3.ZERO) -> void` | void | Play SFX by name, optionally 3D positioned |
|
||||
| `stop` | `func stop(sfx_name: String) -> void` | void | Stop specific SFX |
|
||||
| `stop_all` | `func stop_all() -> void` | void | Silence all SFX |
|
||||
| `set_volume` | `func set_volume(db: float) -> void` | void | Set master SFX volume |
|
||||
|
||||
**Dependencies:** AudioStreamPlayer pool (scene).
|
||||
**Depended by:** player.gd, StopNGoManager, UIManager, many gameplay managers.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 8.3 MusicManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/music_manager.gd` (4,082 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** MusicManager
|
||||
|
||||
Background music controller. Handles crossfade between tracks, playlist sequencing, and volume control.
|
||||
|
||||
**Signals:** None.
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `start_music` | `func start_music(track_name: String = "") -> void` | void | Begin playing track or playlist |
|
||||
| `stop_music` | `func stop_music(fade: float = 0.5) -> void` | void | Fade out and stop |
|
||||
| `crossfade_to` | `func crossfade_to(track_name: String, fade_duration: float = 1.0) -> void` | void | Smooth transition |
|
||||
| `set_volume` | `func set_volume(db: float) -> void` | void | Set master music volume |
|
||||
| `set_paused` | `func set_paused(paused: bool) -> void` | void | Pause/resume |
|
||||
| `get_current_track` | `func get_current_track() -> String` | String | Currently playing track name |
|
||||
|
||||
**Dependencies:** AudioStreamPlayer (scene).
|
||||
**Depended by:** lobby.gd, main.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 8.4 NotificationManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/notification_manager.gd` (2,215 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** NotificationManager
|
||||
|
||||
On-screen message queue. Displays transient notification messages with type-based styling.
|
||||
|
||||
**Properties:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|---|---|---|
|
||||
| MessageType (enum) | {NORMAL, WARNING, POWERUP, ERROR, SYSTEM} | Message severity/style |
|
||||
|
||||
**Signals:** None.
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `send_message` | `func send_message(sender: Node, message: String, msg_type: int = 0) -> void` | void | Queue message for display |
|
||||
| `clear_messages` | `func clear_messages() -> void` | void | Clear all pending messages |
|
||||
| `get_message_queue` | `func get_message_queue() -> Array` | Array | Current pending messages |
|
||||
|
||||
**Dependencies:** None.
|
||||
**Depended by:** player.gd, main.gd (unstuck feedback), StopNGoManager, many gameplay managers.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 8.5 ScreenShake
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/screen_shake.gd` (1,839 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** ScreenShake
|
||||
|
||||
Camera screen shake effect manager. Applies noise-based displacement to Camera3D.
|
||||
|
||||
**Signals:** None.
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `shake` | `func shake(intensity: float, duration: float = 0.3) -> void` | void | Trigger camera shake |
|
||||
| `stop_shake` | `func stop_shake() -> void` | void | Stop ongoing shake |
|
||||
|
||||
**Dependencies:** Camera3D (scene).
|
||||
**Depended by:** player.gd (heavy knock triggers shake), main.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 8.6 CameraContextManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/camera_context_manager.gd` (2,543 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** CameraContextManager
|
||||
|
||||
Camera zoom level and context switching. Manages follow-camera behavior, zoom levels for different game phases, and camera transitions.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `zoom_changed` | level: float | Camera zoom level changed |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `set_zoom` | `func set_zoom(level: float) -> void` | void | Set camera zoom |
|
||||
| `get_zoom` | `func get_zoom() -> float` | float | Current zoom |
|
||||
| `focus_on_player` | `func focus_on_player(player_id: int) -> void` | void | Snap camera to player |
|
||||
| `focus_on_position` | `func focus_on_position(world_pos: Vector3) -> void` | void | Center camera on position |
|
||||
|
||||
**Dependencies:** Camera3D (scene).
|
||||
**Depended by:** main.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 8.7 TouchControls
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/touch_controls.gd` (23,640 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** TouchControls
|
||||
|
||||
Mobile touch input overlay. Provides virtual joystick, action buttons, and gesture recognition for grid-based controls.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `touch_moved` | direction: Vector2i | Grid direction from swipe |
|
||||
| `action_triggered` | action: String | Touch button pressed |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `set_joystick_enabled` | `func set_joystick_enabled(enabled: bool) -> void` | void | Toggle joystick |
|
||||
| `get_joystick_direction` | `func get_joystick_direction() -> Vector2` | Vector2 | Normalized joystick |
|
||||
| `_save_settings` | internal | void | Persist touch control settings |
|
||||
|
||||
**Dependencies:** InputManager (scene).
|
||||
**Depended by:** PlayerInputManager.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 8.8 TutorialManager / TutorialOverlay
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/tutorial_manager.gd` (22,243 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** TutorialManager
|
||||
|
||||
Tutorial flow controller. Manages step-by-step tutorial sequences, triggers, and completion tracking.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `tutorial_started` | tutorial_id: String | Tutorial began |
|
||||
| `step_completed` | step: int | Step finished |
|
||||
| `tutorial_completed` | tutorial_id: String | Tutorial fully complete |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `start_tutorial` | `func start_tutorial(tutorial_id: String) -> void` | void | Begin tutorial sequence |
|
||||
| `advance_step` | `func advance_step() -> void` | void | Move to next step |
|
||||
| `skip_tutorial` | `func skip_tutorial() -> void` | void | Exit tutorial early |
|
||||
| `is_tutorial_active` | `func is_tutorial_active() -> bool` | bool | Tutorial in progress |
|
||||
| `get_current_step` | `func get_current_step() -> int` | int | Current step index |
|
||||
| `get_total_steps` | `func get_total_steps() -> int` | int | Total steps in tutorial |
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/tutorial_overlay.gd` (11,077 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** TutorialOverlay
|
||||
|
||||
Tutorial UI overlay. Displays step instructions, highlights UI elements, and provides step navigation.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `overlay_closed` | none | Overlay dismissed |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `show_step` | `func show_step(step_data: Dictionary) -> void` | void | Display step with text + highlight |
|
||||
| `hide_overlay` | `func hide_overlay() -> void` | void | Dismiss overlay |
|
||||
| `highlight_element` | `func highlight_element(node_path: NodePath) -> void` | void | Spotlight a UI element |
|
||||
| `clear_highlights` | `func clear_highlights() -> void` | void | Remove spotlights |
|
||||
|
||||
**Dependencies:** TutorialManager, UIManager.
|
||||
**Depended by:** TutorialManager.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## 9. Social / Economy Managers
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 9.1 UserProfileManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/user_profile_manager.gd` (20,044 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** UserProfileManager
|
||||
|
||||
User profile CRUD operations. Manages display name, avatar, bio, wallet balance, stats, and loadout configuration. Syncs with Nakama storage.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `profile_loaded` | profile: Dictionary | Profile fetched from server |
|
||||
| `profile_updated` | none | Profile modified locally |
|
||||
| `wallet_updated` | wallet: Dictionary | Balance changed |
|
||||
| `stats_updated` | stats: Dictionary | Player stats changed |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `load_profile` | `func load_profile() -> void` | void (async) | Fetch profile from Nakama storage |
|
||||
| `save_profile` | `func save_profile() -> void` | void (async) | Persist profile to Nakama |
|
||||
| `get_display_name` | `func get_display_name(fallback: String = "Player") -> String` | String | Display name with fallback |
|
||||
| `set_display_name` | `func set_display_name(name: String) -> void` | void | Update display name |
|
||||
| `get_avatar_url` | `func get_avatar_url() -> String` | String | Current avatar path |
|
||||
| `set_avatar` | `func set_avatar(url: String) -> void` | void | Change avatar |
|
||||
| `get_wallet_balance` | `func get_wallet_balance(currency: String) -> int` | int | Balance for gold/star |
|
||||
| `get_stats` | `func get_stats() -> Dictionary` | Dictionary | Player stats snapshot |
|
||||
| `update_stats` | `func update_stats(delta: Dictionary) -> void` | void | Increment stats |
|
||||
| `get_loadout` | `func get_loadout() -> Dictionary` | Dictionary | Current cosmetics loadout |
|
||||
| `set_loadout` | `func set_loadout(loadout: Dictionary) -> void` | void | Save cosmetics config |
|
||||
| `get_loadout_character` | `func get_loadout_character() -> String` | String | Selected character name |
|
||||
| `sync_wallet` | `func sync_wallet() -> void` | void (async) | Refresh wallet from server |
|
||||
|
||||
**Dependencies:** NakamaManager, EventBus, BackendService.
|
||||
**Depended by:** LobbyMainMenu, lobby.gd, ShopManager, GachaManager, SkinManager, many UI panels.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 9.2 GachaManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/gacha_manager.gd` (5,117 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** GachaManager
|
||||
|
||||
Gacha pull orchestration. Calls BackendService.perform_gacha_pull, processes results, updates inventory.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `gacha_result` | items: Array, fragments: Array | Pull results |
|
||||
| `gacha_error` | error: String | Pull failed |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `perform_pull` | `func perform_pull(gacha_id: String, count: int) -> void` | void (async) | Execute gacha pull RPC |
|
||||
| `get_pity_count` | `func get_pity_count(banner_id: String) -> int` | int | Current pity counter |
|
||||
|
||||
**Dependencies:** BackendService, UserProfileManager, EventBus.
|
||||
**Depended by:** gacha_panel.tscn (scene UI).
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 9.3 SkinManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/skin_manager.gd` (13,909 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** SkinManager
|
||||
|
||||
Cosmetic skin system. Manages skin definitions, owned skins, equipped loadout, and applies cosmetics to character models.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `skin_equipped` | skin_id: String | Skin applied |
|
||||
| `skin_unequipped` | skin_id: String | Skin removed |
|
||||
| `inventory_updated` | owned_skins: Array | Inventory changed |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `equip_skin` | `func equip_skin(skin_id: String, slot: String) -> void` | void | Equip skin to slot |
|
||||
| `unequip_skin` | `func unequip_skin(slot: String) -> void` | void | Unequip from slot |
|
||||
| `is_skin_owned` | `func is_skin_owned(skin_id: String) -> bool` | bool | Check ownership |
|
||||
| `get_equipped_skins` | `func get_equipped_skins() -> Dictionary` | Dictionary | Current loadout |
|
||||
| `apply_loadout` | `func apply_loadout(character_root: Node3D, loadout: Dictionary) -> void` | void | Apply cosmetics to 3D model |
|
||||
| `get_skins_for_character` | `func get_skins_for_character(char_name: String) -> Array` | Array | Available skins |
|
||||
| `get_all_skins` | `func get_all_skins() -> Array` | Array | All skin definitions |
|
||||
|
||||
**Dependencies:** UserProfileManager.
|
||||
**Depended by:** lobby.gd (3D preview), SkinShop UI.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 9.4 ShopManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/shop_manager.gd` (484 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** ShopManager
|
||||
|
||||
Thin data layer for shop catalog. Currently a stub; full shop logic lives in scene scripts.
|
||||
|
||||
**Properties:** Minimal (shop catalog array).
|
||||
|
||||
**Signals:** None.
|
||||
|
||||
**Public Functions:** None (data container only).
|
||||
|
||||
**Dependencies:** BackendService.
|
||||
**Depended by:** shop_panel.tscn (scene).
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 9.5 JoinManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/join_manager.gd` (484 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** JoinManager
|
||||
|
||||
Thin manager for join code input and validation. Minimal stub.
|
||||
|
||||
**Properties:** Minimal.
|
||||
|
||||
**Signals:** None.
|
||||
|
||||
**Public Functions:** None (stub).
|
||||
|
||||
**Dependencies:** None.
|
||||
**Depended by:** lobby.gd (join code UI).
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 9.6 FriendManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/friend_manager.gd` (11,911 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** FriendManager
|
||||
|
||||
Friends list management. Handles friend requests, accept/reject, friend list sync, DM messaging, and lobby invitations.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `friend_list_updated` | friends: Array | Friend list refreshed |
|
||||
| `friend_request_received` | from_user_id: String | Incoming request |
|
||||
| `friend_added` | user_id: String | Friendship established |
|
||||
| `friend_removed` | user_id: String | Friendship ended |
|
||||
| `dm_message_received` | from_user_id: String, from_name: String, message: String | Direct message |
|
||||
| `lobby_invite_received` | from_user_id: String, from_name: String, match_id: String | Lobby invitation |
|
||||
| `friend_online_changed` | user_id: String, online: bool | Presence changed |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `add_friend_by_id` | `func add_friend_by_id(nakama_id: String) -> bool` | bool (async) | Send friend request |
|
||||
| `remove_friend` | `func remove_friend(user_id: String) -> void` | void (async) | Remove friendship |
|
||||
| `accept_request` | `func accept_request(user_id: String) -> void` | void (async) | Accept friend request |
|
||||
| `decline_request` | `func decline_request(user_id: String) -> void` | void (async) | Decline request |
|
||||
| `get_friends` | `func get_friends() -> Array` | Array | Current friends list |
|
||||
| `get_mutual_friends` | `func get_mutual_friends() -> Array` | Array | Friends also in room |
|
||||
| `is_friend` | `func is_friend(nakama_id: String) -> bool` | bool | Check friendship |
|
||||
| `send_dm` | `func send_dm(user_id: String, text: String) -> bool` | bool (async) | Send direct message |
|
||||
| `get_dm_history` | `func get_dm_history(user_id: String) -> Array` | Array (async) | Fetch DM history |
|
||||
| `send_lobby_invite` | `func send_lobby_invite(to_user_id: String, match_id: String) -> void` | void (async) | Send invitation |
|
||||
|
||||
**Dependencies:** BackendService, NakamaManager.
|
||||
**Depended by:** LobbyRoom, LobbyChat, lobby.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 9.7 MailManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/mail_manager.gd` (5,271 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** MailManager
|
||||
|
||||
Mail/inbox CRUD operations. Calls BackendService RPCs for get, claim, delete mail.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `mail_updated` | mails: Array | Mail list refreshed |
|
||||
| `unread_count_changed` | count: int | Unread mail count |
|
||||
| `mail_claimed` | mail_id: String, rewards: Dictionary | Reward collected |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `fetch_mail` | `func fetch_mail() -> void` | void (async) | Fetch mailbox |
|
||||
| `claim_mail` | `func claim_mail(mail_id: String) -> void` | void (async) | Claim reward |
|
||||
| `delete_mail` | `func delete_mail(mail_id: String) -> void` | void (async) | Delete mail |
|
||||
| `get_unread_count` | `func get_unread_count() -> int` | int | Unread count |
|
||||
|
||||
**Dependencies:** BackendService.
|
||||
**Depended by:** lobby.gd, mailbox_panel.tscn (scene).
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 9.8 DailyRewardManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/daily_reward_manager.gd` (1,009 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** DailyRewardManager
|
||||
|
||||
Daily reward system. Handles claim state, reward config, and streak tracking.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `reward_claimed` | day: int, reward: Dictionary | Daily reward collected |
|
||||
| `streak_updated` | streak: int | Consecutive days |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `claim_daily_reward` | `func claim_daily_reward() -> void` | void (async) | Claim today's reward |
|
||||
| `get_reward_state` | `func get_reward_state() -> Dictionary` | Dictionary (async) | Current state + schedule |
|
||||
| `can_claim_today` | `func can_claim_today() -> bool` | bool | Check if claimable |
|
||||
|
||||
**Dependencies:** BackendService.
|
||||
**Depended by:** daily_reward_panel.tscn (scene).
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 9.9 AdminManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/admin_manager.gd` (2,538 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** AdminManager
|
||||
|
||||
Admin panel state and permission checks. Determines if local player is admin or moderator.
|
||||
|
||||
**Signals:** None.
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `_check_admin_status` | `func _check_admin_status() -> bool` | bool (async) | Verify admin via Nakama storage |
|
||||
| `kick_player` | `func kick_player(player_id: int) -> void` | void (async) | Kick player from match |
|
||||
| `ban_player` | `func ban_player(player_id: int) -> void` | void (async) | Ban player |
|
||||
| `give_currency` | `func give_currency(gold: int, star: int) -> void` | void (async) | Admin give currency |
|
||||
|
||||
**Dependencies:** BackendService, NakamaManager.
|
||||
**Depended by:** admin_panel.tscn (scene), LobbyChat (/clear command).
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## 10. System Managers
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 10.1 SettingsManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/settings_manager.gd` (13,874 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** SettingsManager
|
||||
|
||||
User settings persistence. Reads/writes config to user://settings.cfg. Manages audio, video, gameplay, and control settings.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `setting_changed` | key: String, value: Variant | A setting was modified |
|
||||
| `settings_reset` | none | All settings restored to defaults |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `get_setting` | `func get_setting(key: String, default: Variant = null) -> Variant` | Variant | Read setting value |
|
||||
| `set_setting` | `func set_setting(key: String, value: Variant) -> void` | void | Write and persist setting |
|
||||
| `reset_settings` | `func reset_settings() -> void` | void | Restore defaults |
|
||||
| `load_settings` | `func load_settings() -> void` | void | Load from config file |
|
||||
| `save_settings` | `func save_settings() -> void` | void | Write to config file |
|
||||
| `get_all_settings` | `func get_all_settings() -> Dictionary` | Dictionary | Full settings snapshot |
|
||||
|
||||
**Dependencies:** ConfigFile.
|
||||
**Depended by:** Audio buses, video settings, gameplay UI, settings_menu.tscn.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 10.2 SessionManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/session_manager.gd` (4,742 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** SessionManager
|
||||
|
||||
Nakama session refresh lifecycle. Monitors session expiry and auto-refreshes before expiration.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `session_refreshed` | none | Token refreshed |
|
||||
| `session_expired` | none | Could not refresh |
|
||||
| `session_warning` | seconds_remaining: int | About to expire |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `start_monitoring` | `func start_monitoring() -> void` | void | Begin session expiry timer |
|
||||
| `stop_monitoring` | `func stop_monitoring() -> void` | void | Stop timer |
|
||||
| `refresh_now` | `func refresh_now() -> void` | void (async) | Force refresh |
|
||||
|
||||
**Dependencies:** NakamaManager.
|
||||
**Depended by:** AuthManager.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 10.3 GameUpdateManager
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scripts/managers/game_update_manager.gd` (14,405 chars)
|
||||
**Extends:** Node
|
||||
**Autoload name:** GameUpdateManager
|
||||
|
||||
Hot-reload update system. Checks for patch.pck on the Gitea patches branch and downloads/loads it at runtime.
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `update_available` | version: String, changelog: String | New patch detected |
|
||||
| `update_downloading` | progress: float | Download progress |
|
||||
| `update_ready` | path: String | Patch downloaded and verified |
|
||||
| `update_failed` | error: String | Download error |
|
||||
| `up_to_date` | none | No update needed |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `check_for_updates` | `func check_for_updates() -> void` | void (async) | Query Gitea for latest patch |
|
||||
| `download_update` | `func download_update() -> void` | void (async) | Download patch.pck |
|
||||
| `apply_update` | `func apply_update() -> void` | void | Load patch from ProjectSettings |
|
||||
| `get_current_version` | `func get_current_version() -> String` | String | Current client version |
|
||||
| `get_available_version` | `func get_available_version() -> String` | String | Latest available version |
|
||||
|
||||
**Dependencies:** HTTPRequest (scene).
|
||||
**Depended by:** boot_screen.tscn, main.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## 11. Core Scene Scripts
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 11.1 main.gd (Main game scene controller)
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scenes/main.gd` (2956 lines)
|
||||
**Extends:** Node
|
||||
**Scene:** main.tscn
|
||||
|
||||
The core game scene controller. Handles game initialization, player spawn, grid setup, goal cycle start, leaderboard display, pause menu, unstuck system, match cleanup, and result screen flow.
|
||||
|
||||
**Key Properties:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|---|---|---|
|
||||
| enhanced_gridmap | Node | Reference to EnhancedGridMap child |
|
||||
| player_scene | PackedScene | Player.tscn loaded |
|
||||
| stop_n_go_winner_id | int | Winner's peer ID (-1 if none) |
|
||||
| _unstuck_cooldown_remaining | float | Unstuck button cooldown |
|
||||
| touch_controls | Node | TouchControls autoload ref |
|
||||
|
||||
**Signals:**
|
||||
- (none declared; uses method-based event routing)
|
||||
|
||||
**Public Functions (selected key ones):**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `_ready` | auto-called | void | Initializes ENet multiplayer, spawns players, starts goals cycle, sets up HUD |
|
||||
| `_process` | `func _process(delta: float) -> void` | void | Unstuck cooldown tick |
|
||||
| `_input` | `func _input(event: InputEvent) -> void` | void | ESC pause, F9 debug floor check |
|
||||
| `initialize_game` | -- | void | Create EnhancedGridMap, spawn player scene instances |
|
||||
| `spawn_player` | -- | Node | Instantiate player.tscn, position, set authority |
|
||||
| `_spawn_local_player` | -- | void | Create local player node |
|
||||
| `add_bot_players_if_needed` | -- | void | Fill remaining slots with bot players |
|
||||
| `display_message` | `func display_message(message: String, type: int) -> void` | void (RPC) | Broadcast message to local player's UI |
|
||||
| `request_leaderboard_sync` | `func request_leaderboard_sync() -> void` | void (RPC) | Client requests leaderboard from server |
|
||||
| `sync_leaderboard_data` | `func sync_leaderboard_data(player_data: Array) -> void` | void (RPC authority) | Receive + render leaderboard |
|
||||
| `_update_leaderboard_display` | internal | void | Local leaderboard refresh |
|
||||
| `_render_leaderboard_entries` | internal | void | Populate leaderboard entries |
|
||||
| `_get_ordinal` | `func _get_ordinal(n: int) -> String` | String | "1st", "2nd", "3rd", etc. |
|
||||
| `can_rpc` | `func can_rpc() -> bool` | bool | Check multiplayer peer state |
|
||||
| `check_multiplayer` | `func check_multiplayer() -> bool` | bool | Safety check for peer access |
|
||||
| `_toggle_pause_menu` | internal | void | Show/hide pause overlay |
|
||||
| `_on_resume_pressed` | -- | void | Close pause menu |
|
||||
| `_on_how_to_play_pressed` | -- | void | Open help panel |
|
||||
| `_on_settings_pressed` | -- | void | Open settings dynamically |
|
||||
| `_on_quit_match_pressed` | -- | void | Leave match, return to lobby |
|
||||
| `_on_unstuck_pressed` | -- | void | Teleport local player to safe position |
|
||||
| `_find_safe_spawn_position` | internal | Vector2i | Scan grid for safe walkable cell |
|
||||
| `_on_back_to_menu_pressed` | -- | void | Cleanup and transition to lobby |
|
||||
| `_cleanup_multiplayer` | -- | void | NakamaManager.cleanup() wrapper |
|
||||
| `_deferred_init_leaderboard` | internal | void | Delayed leaderboard init (1.5s) |
|
||||
| `_on_rematch_pressed` | -- | void | Request rematch vote |
|
||||
| `check_all_floors` | `func check_all_floors() -> void` | void | Debug F9: scan missing floor tiles |
|
||||
| `update_visual_position` | on player | void | Snap player to grid-aligned world position |
|
||||
| `grid_to_world` | on player | Vector3 | Convert grid Vector2i to world Vector3 |
|
||||
|
||||
**RPCs (network-synced functions):**
|
||||
|
||||
| Function | RPC Mode | Description |
|
||||
|---|---|---|
|
||||
| `request_leaderboard_sync` | any_peer | Client requests data from server |
|
||||
| `sync_leaderboard_data` | authority, call_local | Server sends leaderboard to client |
|
||||
| `display_message` | authority, call_local | Broadcast message to player UI |
|
||||
| `sync_position` (on player) | any_peer, call_local | Sync grid position |
|
||||
| `sync_grid_item` (on player) | any_peer, call_local | Sync grid cell item |
|
||||
| `sync_goals` (on player) | any_peer, call_local | Sync active goals |
|
||||
| `sync_rotation` (on player) | any_peer, call_local | Sync character rotation |
|
||||
| `sync_bump` (on player) | any_peer, call_local, unreliable | Visual bump animation |
|
||||
| `sync_knock_tekton` (on player) | any_peer, call_local, reliable | Knock tekton |
|
||||
| `sync_grab_tekton` (on player) | any_peer, call_local, reliable | Grab roaming tekton |
|
||||
| `sync_throw_tekton` (on player) | any_peer, call_local, reliable | Throw tekton |
|
||||
| `sync_drop_tekton` (on player) | any_peer, call_local, reliable | Drop tekton |
|
||||
| `set_spawn_position` (on player) | any_peer, call_local, reliable | Random spawn position |
|
||||
| `complete_race` (on player) | any_peer, call_local, reliable | Player finished race |
|
||||
| `force_action_state_none` (on player) | any_peer, call_local, reliable | Reset UI action state |
|
||||
| `request_server_grab` (on player) | any_peer, reliable | Server-authoritative grab |
|
||||
| `request_server_put` (on player) | any_peer, reliable | Server-authoritative put |
|
||||
| `notify_spawn_selected` (on player) | any_peer, reliable | Occupancy sync for spawn |
|
||||
| `trigger_screen_shake` | -- | Camera shake RPC |
|
||||
|
||||
**Dependencies:** NakamaManager, LobbyManager, GameStateManager, PlayerMovementManager, PlayerActionManager, GoalsCycleManager, StopNGoManager, GauntletManager, PortalModeManager, PlayerRaceManager, PlayerboardManager, UIManager, SfxManager, MusicManager, NotificationManager, ScreenShake, CameraContextManager, TouchControls.
|
||||
**Depended by:** (this is the root game scene, depends on everything).
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 11.2 player.gd
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scenes/player.gd` (2751 lines)
|
||||
**Extends:** CharacterBody3D (assumed from Node3D methods)
|
||||
**Scene:** player.tscn
|
||||
|
||||
The player character controller. Handles movement, action execution (grab/put/arrange), tekton interaction (carry/snatch/throw/knock), grid positioning, bot AI, visual synchronization, and playerboard management delegation.
|
||||
|
||||
**Key Properties:**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| current_position | Vector2i | Vector2i(0, 0) | Grid-aligned position |
|
||||
| cell_size | Vector3 | (1.0, 1.0, 1.0) | Grid cell dimensions |
|
||||
| cell_offset | Vector3 | Vector3.ZERO | Visual position offset |
|
||||
| is_player_moving | bool | false | Movement tween active |
|
||||
| is_carrying_tekton | bool | false | Holding roaming tekton |
|
||||
| carried_tekton | Node3D | null | Reference to carried tekton |
|
||||
| is_charged_strike | bool | false | Charged attack mode |
|
||||
| is_frozen | bool | false | Stun/freeze state |
|
||||
| is_stop_frozen | bool | false | Stop n Go freeze |
|
||||
| is_invisible | bool | false | Ghost mode |
|
||||
| is_bot | bool | false | Bot AI flag |
|
||||
| display_name | String | "" | Player display name |
|
||||
| score | int | 0 | Match score |
|
||||
| action_points | int | 1 | Actions per turn |
|
||||
| playerboard | Array | [-1, -1, ...] | Item slot board |
|
||||
| goals | Array | [] | Active goals |
|
||||
| enhanced_gridmap | Node | null | Grid reference |
|
||||
| anim_player | AnimationPlayer | null | Character animations |
|
||||
| movement_manager | PlayerMovementManager | ref | Movement delegation |
|
||||
| action_manager | PlayerActionManager | ref | Action delegation |
|
||||
| playerboard_manager | PlayerboardManager | ref | Board delegation |
|
||||
| race_manager | PlayerRaceManager | ref | Race delegate |
|
||||
| powerup_manager | PowerupManager | ref | Boost/charge delegate |
|
||||
|
||||
**Signals:**
|
||||
|
||||
| Signal | Params | Description |
|
||||
|---|---|---|
|
||||
| `position_changed` | none | Player grid position changed |
|
||||
|
||||
**Public Functions (selected key ones):**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `_ready` | auto-called | void | Init references, connect signals, set initial position |
|
||||
| `_physics_process` | `func _physics_process(delta: float) -> void` | void | Movement smoothing, carry timer, unstuck timer |
|
||||
| `_input` | `func _input(event: InputEvent) -> void` | void | Click-to-move on gridmap, slot clicks |
|
||||
| `grid_to_world` | `func grid_to_world(pos: Vector2i) -> Vector3` | Vector3 | Convert grid to world coordinates |
|
||||
| `move_to_grid_position` | `func move_to_grid_position(target: Vector2i) -> void` | void | Initiate grid movement |
|
||||
| `grab_item` | `func grab_item(grid_pos: Vector2i = current_position) -> bool` | bool | Delegates to playerboard_manager.grab_item |
|
||||
| `auto_put_item` | `func auto_put_item() -> bool` | bool | Delegates auto-put |
|
||||
| `handle_playerboard_slot_selected` | `func handle_playerboard_slot_selected(slot_index: int) -> void` | void | Delegates to playerboard_manager |
|
||||
| `handle_put_slot_selected` | `func handle_put_slot_selected(slot_index: int) -> void` | void | Delegates put slot |
|
||||
| `arrange_playerboard_item` | `func arrange_playerboard_item(slot_index: int) -> void` | void | Delegates arrange |
|
||||
| `_on_slot_clicked` | `func _on_slot_clicked(event: InputEvent, slot_index: int) -> void` | void | Delegates to playerboard_manager |
|
||||
| `has_item_at_current_position` | `func has_item_at_current_position() -> bool` | bool | Check grid cell occupancy |
|
||||
| `has_items_in_playerboard` | `func has_items_in_playerboard() -> bool` | bool | Any items in board |
|
||||
| `playerboard_is_full` | `func playerboard_is_full() -> bool` | bool | All slots filled |
|
||||
| `highlight_movement_range` | `func highlight_movement_range() -> void` | void | Delegates to movement_manager |
|
||||
| `highlight_adjacent_cells` | `func highlight_adjacent_cells() -> void` | void | Delegates to movement_manager |
|
||||
| `highlight_cells_if_authorized` | `func highlight_cells_if_authorized(cells: Array, item_id: int) -> void` | void | Delegates to action_manager |
|
||||
| `clear_highlights` | `func clear_highlights() -> void` | void | Clear grid highlights |
|
||||
| `rotate_towards_target` | `func rotate_towards_target(target_pos: Vector2i) -> void` | void | Delegates to movement_manager |
|
||||
| `select_playerboard_slot` | `func select_playerboard_slot(slot_index: int) -> void` | void | Delegates to playerboard_manager |
|
||||
| `deselect_playerboard_slot` | `func deselect_playerboard_slot() -> void` | void | Clear selection |
|
||||
| `target_playerboard_slot` | `func target_playerboard_slot(slot_index: int) -> void` | void | Target slot for move |
|
||||
| `untarget_playerboard_slot` | `func untarget_playerboard_slot() -> void` | void | Clear target |
|
||||
| `can_move_to_target_playerboard_slot` | `func can_move_to_target_playerboard_slot() -> bool` | bool | Target slot validity |
|
||||
| `update_visual_position` | `func update_visual_position() -> void` | void | Snap to grid |
|
||||
| `grab_tekton` | `func grab_tekton() -> void` | void | Tekton interaction: snatch or grab |
|
||||
| `snatch_tekton` | `func snatch_tekton(target_carrier: Node3D) -> void` | void | Steal tekton from carrier |
|
||||
| `throw_tekton` | `func throw_tekton() -> void` | void | Throw tekton in facing direction |
|
||||
| `drop_tekton` | `func drop_tekton() -> void` | void | Drop tekton at current position |
|
||||
| `enter_charged_strike` | `func enter_charged_strike() -> void` | void | Activate charged attack mode |
|
||||
| `knock_tekton` | `func knock_tekton() -> void` | void | Special attack on nearby tekton |
|
||||
| `update_active_player_indicator` | `func update_active_player_indicator() -> void` | void | Refresh visual state |
|
||||
| `is_finish_position` | `func is_finish_position(pos: Vector2i) -> bool` | bool | Check if pos is a finish line |
|
||||
| `_after_action_completed` | internal | void | Post-action: cycle goals, check win |
|
||||
| `consume_action_points` | `func consume_action_points(points: int) -> void` | void | Deduct AP |
|
||||
| `display_message` | on player | void | Show notification to this player |
|
||||
| `apply_stagger` | `func apply_stagger(duration: float) -> void` | void | Stun for duration |
|
||||
|
||||
**RPCs (network-synced functions on player.gd):**
|
||||
|
||||
| Function | RPC Mode | Description |
|
||||
|---|---|---|
|
||||
| `sync_position` | any_peer, call_local | Sync current grid position |
|
||||
| `sync_rotation` | any_peer, call_local | Sync Y rotation |
|
||||
| `sync_grid_item` | any_peer, call_local | Sync grid cell item change |
|
||||
| `sync_goals` | any_peer, call_local | Sync active goal set |
|
||||
| `sync_second_lap_goals` | any_peer, call_local | Sync lap 2 goals |
|
||||
| `sync_grab_tekton` | any_peer, call_local, reliable | Grab tekton network sync |
|
||||
| `sync_snatch_tekton` | any_peer, call_local, reliable | Tekton theft sync |
|
||||
| `sync_throw_tekton` | any_peer, call_local, reliable | Throw tekton sync |
|
||||
| `sync_drop_tekton` | any_peer, call_local, reliable | Drop tekton sync |
|
||||
| `sync_bump` | any_peer, call_local, unreliable | Visual bump animation |
|
||||
| `sync_knock_tekton` | any_peer, call_local, reliable | Knock attack sync |
|
||||
| `set_spawn_position` | any_peer, call_local, reliable | Random spawn position |
|
||||
| `complete_race` | any_peer, call_local, reliable | Race completion |
|
||||
| `force_action_state_none` | any_peer, call_local, reliable | Reset UI action state |
|
||||
| `request_server_grab` | any_peer, reliable | Server-auth grab request |
|
||||
| `request_server_put` | any_peer, reliable | Server-auth put request |
|
||||
| `notify_spawn_selected` | any_peer, reliable | Spawn occupancy sync |
|
||||
| `trigger_screen_shake` | (authority) | Screen shake RPC |
|
||||
| `bot_grab_item` | any_peer, call_local | Bot grab sync |
|
||||
| `bot_put_item` | any_peer, call_local | Bot put sync |
|
||||
| `bot_arrange_item` | any_peer, call_local | Bot arrange sync |
|
||||
|
||||
**Dependencies:** PlayerMovementManager, PlayerInputManager, PlayerActionManager, PlayerboardManager, PowerupManager, PlayerRaceManager, GoalsCycleManager, SfxManager, NotificationManager, EnhancedGridMap (scene node), LobbyManager.
|
||||
**Depended by:** main.gd (spawned per player).
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 11.3 lobby.gd
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scenes/lobby.gd` (583 lines)
|
||||
**Extends:** Control
|
||||
**Scene:** lobby.tscn
|
||||
|
||||
The lobby/home screen controller. Manages main menu, room creation/joining, player slots, server selection, character selection, settings, mail, chat, social panel, and 3D character preview.
|
||||
|
||||
**Key Properties:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|---|---|---|
|
||||
| chat | LobbyChat | Chat helper instance |
|
||||
| main_menu | LobbyMainMenu | Main menu helper |
|
||||
| room_list_helper | LobbyRoomList | Room list helper |
|
||||
| room_helper | LobbyRoom | Room/lobby helper |
|
||||
| character_textures | Dictionary | {char_name: Texture2D} |
|
||||
| profile_panel_instance | Control | Dynamic profile panel |
|
||||
| shop_panel_instance | Control | Dynamic shop panel |
|
||||
| daily_reward_panel_instance | Control | Daily reward panel |
|
||||
| leaderboard_panel_instance | Control | Leaderboard panel |
|
||||
| _mailbox_panel_instance | Control | Mail panel |
|
||||
| social_panel_instance | Control | Social panel |
|
||||
| _local_player_rank | int | Cached rank |
|
||||
| _bot_names | Dictionary | Slot index -> bot name |
|
||||
| _room_mode_filter | String | Room list filter |
|
||||
| _is_hosting | bool | Re-entry guard |
|
||||
|
||||
**UI Node References (onready):**
|
||||
|
||||
| Variable | Node Path | Type |
|
||||
|---|---|---|
|
||||
| main_menu_panel | $MainMenuPanel | Control |
|
||||
| main_title | %Title | Label |
|
||||
| username_label | %Username | Label |
|
||||
| create_room_btn | %CreateRoomBtn | Button |
|
||||
| browse_rooms_btn | %BrowseRoomsBtn | Button |
|
||||
| tutorial_btn | %TutorialBtn | Button |
|
||||
| main_menu_profile_btn | %MainProfileBtn | Button |
|
||||
| avatar_display | %AvatarDisplay | TextureRect |
|
||||
| lobby_settings_btn | %SettingsBtn | Button |
|
||||
| quit_btn | %QuitBtn | Button |
|
||||
| character_root | %CharacterRoot | Node3D |
|
||||
| anim_player | %AnimationPlayer | AnimationPlayer |
|
||||
| gold_label | %GoldLabel | Label |
|
||||
| star_label | %StarLabel | Label |
|
||||
| server_option | %ServerOption | OptionButton |
|
||||
| server_ip_input | %ServerIPInput | LineEdit |
|
||||
| leaderboard_btn | %LeaderboardBtn | Button |
|
||||
| shop_btn | %CartBtn | Button |
|
||||
| top_right_profile_btn | %ProfileBtn | Button |
|
||||
| mailbox_btn | %MailboxBtn | Button |
|
||||
| mail_badge | %MailBadge | Label |
|
||||
| banner1_btn | %Banner1 | Button |
|
||||
| ticket_btn | %TicketBtn | Button |
|
||||
| room_list_panel | %RoomListPanel | Control |
|
||||
| room_list | %RoomList | ItemList |
|
||||
| match_id_input | %MatchIdInput | LineEdit |
|
||||
| refresh_btn | %RefreshBtn | Button |
|
||||
| join_btn | %JoinBtn | Button |
|
||||
| back_btn | %RoomListCloseBtn | Button |
|
||||
| lobby_panel | $LobbyPanel | Control |
|
||||
| host_banner | $LobbyPanel/HostBanner | Panel |
|
||||
| match_id_display | $LobbyPanel/TopBar/... | Label |
|
||||
| copy_id_btn | $LobbyPanel/TopBar/... | Button |
|
||||
| duration_option | $LobbyPanel/TopBar/... | OptionButton |
|
||||
| random_spawn_check | `" " ` | CheckButton |
|
||||
| enable_timer_check | `" " ` | CheckButton |
|
||||
| scarcity_option | `" " ` | OptionButton |
|
||||
| game_mode_option | `" " ` | OptionButton |
|
||||
| players_container | $LobbyPanel/PlayersContainer | Control |
|
||||
| area_selector | $LobbyPanel/AreaSelector | Control |
|
||||
| leave_btn | $LobbyPanel/BottomBar/LeaveBtn | Button |
|
||||
| ready_btn | $LobbyPanel/BottomBar/ReadyBtn | Button |
|
||||
| start_game_btn | $LobbyPanel/BottomBar/StartGameBtn | Button |
|
||||
| connection_status | $StatusBar/ConnectionStatus | Label |
|
||||
| chat_display | %RichTextLabel | RichTextLabel |
|
||||
| chat_input | %ChatInput | LineEdit |
|
||||
| chat_send_btn | %SendBtn | Button |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `_ready` | auto-called | void | Initialize all helpers, load textures, setup UI, connect signals |
|
||||
| `_setup_3d_preview` | `func _setup_3d_preview() -> void` | void | Swap character model in SubViewport |
|
||||
| `_load_character_textures` | `func _load_character_textures() -> void` | void | Load preview textures |
|
||||
| `_on_server_option_selected` | `func _on_server_option_selected(index: int) -> void` | void | Handle server type dropdown |
|
||||
| `_on_server_ip_submitted` | `func _on_server_ip_submitted(new_text: String) -> void` | void | Handle IP input |
|
||||
| `_setup_game_modes` | `func _setup_game_modes() -> void` | void | Populate game mode dropdown |
|
||||
| `_setup_player_slots` | `func _setup_player_slots() -> void` | void | Collect player slot nodes |
|
||||
| `_connect_slot_signals` | `func _connect_slot_signals(slot: Control, i: int)` | void | Wire character nav buttons |
|
||||
| `_show_panel` | `func _show_panel(panel_name: String) -> void` | void | Toggle main_menu/room_list/lobby panels |
|
||||
| `_update_settings_visibility` | `func _update_settings_visibility() -> void` | void | Show/hide settings by mode and host status |
|
||||
| `_create_custom_settings_ui` | `func _create_custom_settings_ui() -> void` | void | Build SNG/Tekton Doors settings dynamically |
|
||||
| `_sync_room_profile_card` | `func _sync_room_profile_card() -> void` | void | Refresh username, score, rank, avatar, currency |
|
||||
| `_apply_loadout_character` | `func _apply_loadout_character() -> void` | void | Apply saved character to LobbyManager |
|
||||
| `admin_wipe_chat` | `func admin_wipe_chat() -> void` | void (async) | Admin: clear global chat |
|
||||
| `admin_purge_chat` | `func admin_purge_chat(max_age_days: int) -> int` | int (async) | Admin: purge old messages |
|
||||
|
||||
**Dependencies:** AuthManager, NakamaManager, LobbyManager, UserProfileManager, SkinManager, MusicManager, FriendManager, MailManager, BackendService.
|
||||
**Depended by:** (root lobby scene; no dependents).
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 11.4 animation.gd
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
**File:** `/home/dev/tekton/scenes/animation.gd` (41 lines)
|
||||
**Extends:** Control
|
||||
**Scene:** (embedded in main.tscn for Stop n Go UI)
|
||||
|
||||
Stop n Go phase animation player. Controls ready-go countdown, stop phase overlay, safe zone, and go animation sequences.
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `play_ready_go` | `func play_ready_go() -> void` | void | Play ready-set-go sequence |
|
||||
| `play_stop_phase` | `func play_stop_phase() -> void` | void | Play STOP overlay |
|
||||
| `play_safe_zone_appear` | `func play_safe_zone_appear() -> void` | void | Show safe zone indicator |
|
||||
| `stop_phase_anim_play` | `func stop_phase_anim_play() -> void` | void | Play stop phase spritesheet |
|
||||
| `stop_phase_anim_stop` | `func stop_phase_anim_stop() -> void` | void | Stop phase animation |
|
||||
| `play_countdown_30s` | `func play_countdown_30s() -> void` | void | 30-second countdown |
|
||||
| `play_countdown_15s` | `func play_countdown_15s() -> void` | void | 15-second countdown |
|
||||
| `play_go_animation` | `func play_go_animation() -> void` | void | GO animation |
|
||||
| `play_go_finish_animation` | `func play_go_finish_animation() -> void` | void | Finish line animation |
|
||||
|
||||
**Dependencies:** AnimatedSprite2D, AnimationPlayer (scene nodes).
|
||||
**Depended by:** StopNGoManager, main.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## 12. UI Helper Classes (RefCounted)
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
All UI helper classes are RefCounted objects instantiated by lobby.gd in _ready(). They do NOT extend Node -- they are lightweight event wiring and state management objects.
|
||||
|
||||
### 12.1 LobbyMainMenu
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
```gdscript
|
||||
class_name LobbyMainMenu extends RefCounted
|
||||
```
|
||||
|
||||
**File:** `/home/dev/tekton/scenes/ui/lobby_main_menu.gd` (338 lines)
|
||||
|
||||
Event wiring for main menu buttons. Connects all lobby button signals to handler methods.
|
||||
|
||||
**Constructor:** `func _init(p_lobby: Control)` -- Stores lobby ref, connects 15+ button signals.
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `on_tutorial_pressed` | `func on_tutorial_pressed() -> void` | void | Set name, apply loadout, call LobbyManager.start_tutorial |
|
||||
| `on_create_room_pressed` | `func on_create_room_pressed() -> void` | void | Show room list panel, create tab |
|
||||
| `host_room` | `func host_room(game_mode: String) -> void` | void | Guarded double-click, set name/mode, create Nakama or LAN room |
|
||||
| `on_browse_rooms_pressed` | `func on_browse_rooms_pressed() -> void` | void | Show room list, browse tab, refresh |
|
||||
| `on_profile_btn_pressed` | `func on_profile_btn_pressed() -> void` | void | Instantiate and show profile_panel.tscn |
|
||||
| `on_mailbox_pressed` | `func on_mailbox_pressed() -> void` | void | Instantiate and show mailbox_panel.tscn |
|
||||
| `on_settings_pressed` | `func on_settings_pressed() -> void` | void | Instantiate and show settings_menu.tscn |
|
||||
| `restore_after_settings` | `func restore_after_settings() -> void` | void | Restore lobby/main_menu panel visibility |
|
||||
| `on_shop_pressed` | `func on_shop_pressed() -> void` | void | Instantiate and show shop_panel.tscn |
|
||||
| `on_banner1_pressed` | `func on_banner1_pressed() -> void` | void | Instantiate and show gacha_panel.tscn |
|
||||
| `on_leaderboard_pressed` | `func on_leaderboard_pressed() -> void` | void | Show leaderboard_panel.tscn |
|
||||
| `on_ticket_pressed` | `func on_ticket_pressed() -> void` | void | Show daily_reward_panel.tscn |
|
||||
| `on_social_pressed` | `func on_social_pressed() -> void` | void | Show social_panel.tscn, hide main menu UI |
|
||||
| `on_logout_pressed` | `func on_logout_pressed() -> void` | void | AuthManager.logout() -> login screen |
|
||||
| `on_quit_pressed` | `func on_quit_pressed() -> void` | void | get_tree().quit() |
|
||||
| `go_to_login` | `func go_to_login() -> void` | void | Change scene to login_screen.tscn |
|
||||
|
||||
**Dependencies:** AuthManager, LobbyManager, UserProfileManager, NakamaManager, BackendService.
|
||||
**Depended by:** lobby.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 12.2 LobbyRoom
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
```gdscript
|
||||
class_name LobbyRoom extends RefCounted
|
||||
```
|
||||
|
||||
**File:** `/home/dev/tekton/scenes/ui/lobby_room.gd` (432 lines)
|
||||
|
||||
Room/lobby panel event wiring. Handles ready/start/leave buttons, player slot rendering, character navigation, game mode/duration/scarcity settings, friend invites, and lobby invitation popup.
|
||||
|
||||
**Constructor:** `func _init(p_lobby: Control)` -- Stores lobby ref, connects 20+ signals.
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `_on_ready_toggled` | `func _on_ready_toggled(is_ready: bool) -> void` | void | Toggle ready state |
|
||||
| `_on_start_game_pressed` | `func _on_start_game_pressed() -> void` | void | Host starts game |
|
||||
| `_on_leave_pressed` | `func _on_leave_pressed() -> void` | void | Leave room, release bot names |
|
||||
| `_on_copy_id_pressed` | `func _on_copy_id_pressed() -> void` | void | Copy match ID to clipboard |
|
||||
| `_on_duration_selected` | `func _on_duration_selected(index: int) -> void` | void | Host sets match duration |
|
||||
| `_on_random_spawn_toggled` | `func _on_random_spawn_toggled(toggled_on: bool) -> void` | void | Toggle random spawn |
|
||||
| `_on_enable_timer_toggled` | `func _on_enable_timer_toggled(toggled_on: bool) -> void` | void | Toggle cycle timer |
|
||||
| `_on_scarcity_selected` | `func _on_scarcity_selected(index: int) -> void` | void | Host sets scarcity |
|
||||
| `_on_scarcity_mode_changed` | `func _on_scarcity_mode_changed(mode: String) -> void` | void | UI update for scarcity |
|
||||
| `_on_game_mode_selected` | `func _on_game_mode_selected(index: int) -> void` | void | Host sets game mode |
|
||||
| `_on_game_mode_changed` | `func _on_game_mode_changed(mode: String) -> void` | void | UI update for game mode |
|
||||
| `_on_sng_update` | `func _on_sng_update(_val: int = 0) -> void` | void | Sync SNG setting UI |
|
||||
| `_on_doors_update` | `func _on_doors_update(_val: int = 0) -> void` | void | Sync Doors setting UI |
|
||||
| `_on_room_joined` | `func _on_room_joined(room_data: Dictionary) -> void` | void | Switch to lobby panel, populate settings |
|
||||
| `_on_room_left` | `func _on_room_left() -> void` | void | Return to main menu |
|
||||
| `_on_host_disconnected` | `func _on_host_disconnected() -> void` | void | Show disconnect message |
|
||||
| `_on_player_joined` | `func _on_player_joined(player_data: Dictionary) -> void` | void | Update slots + status |
|
||||
| `_on_player_left` | `func _on_player_left(_player_id: int) -> void` | void | Update slots |
|
||||
| `_on_ready_state_changed` | `func _on_ready_state_changed(_player_id: int, _is_ready: bool) -> void` | void | Update slot visuals |
|
||||
| `_on_all_players_ready` | `func _on_all_players_ready() -> void` | void | Enable start button |
|
||||
| `_on_game_starting` | `func _on_game_starting() -> void` | void | Transition to main.tscn |
|
||||
| `_update_player_slots` | `func _update_player_slots() -> void` | void | Render all 8 player slots (players + bot slots) |
|
||||
| `_update_status` | `func _update_status() -> void` | void | Show ready count |
|
||||
| `_on_add_friend_pressed` | `func _on_add_friend_pressed(nakama_id: String) -> void` | void (async) | Add friend by Nakama ID |
|
||||
| `on_invite_friends_pressed` | `func on_invite_friends_pressed() -> void` | void | Open invite dialog |
|
||||
| `_on_lobby_invite_received` | `func _on_lobby_invite_received(from_user_id: String, from_name: String, match_id: String) -> void` | void | Show invite popup |
|
||||
| `_on_invite_accepted` | `func _on_invite_accepted() -> void` | void | Join invited match |
|
||||
|
||||
**Dependencies:** LobbyManager, FriendManager, NakamaManager, NameGenerator, UserProfileManager.
|
||||
**Depended by:** lobby.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 12.3 LobbyRoomList
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
```gdscript
|
||||
class_name LobbyRoomList extends RefCounted
|
||||
```
|
||||
|
||||
**File:** `/home/dev/tekton/scenes/ui/lobby_room_list.gd` (155 lines)
|
||||
|
||||
Room list panel event wiring. Handles room list refresh, selection, join, and back navigation.
|
||||
|
||||
**Constructor:** `func _init(p_lobby: Control)` -- Stores lobby ref, connects signals.
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `_on_refresh_pressed` | `func _on_refresh_pressed() -> void` | void | Clear + refresh room list |
|
||||
| `_on_room_selected` | `func _on_room_selected(index: int) -> void` | void | Copy room match_id/IP to input |
|
||||
| `_on_room_activated` | `func _on_room_activated(index: int) -> void` | void | Select + auto-join |
|
||||
| `_on_join_pressed` | `func _on_join_pressed() -> void` | void | Validate input, set name, join room (LAN or Nakama) |
|
||||
| `_on_back_pressed` | `func _on_back_pressed() -> void` | void | Return to main menu |
|
||||
| `on_room_list_updated` | `func on_room_list_updated(rooms: Array) -> void` | void | Render room rows, apply mode filter |
|
||||
|
||||
**Dependencies:** LobbyManager, AuthManager, UserProfileManager.
|
||||
**Depended by:** lobby.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 12.4 LobbyChat
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
```gdscript
|
||||
class_name LobbyChat extends RefCounted
|
||||
```
|
||||
|
||||
**File:** `/home/dev/tekton/scenes/ui/lobby_chat.gd` (373 lines)
|
||||
|
||||
Global and direct message chat system. Handles Nakama socket channel chat, DM tabs, friend suggestions, and admin chat commands.
|
||||
|
||||
**Constants:** `GLOBAL_CHAT_ROOM = "social_global"`
|
||||
|
||||
**Properties:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|---|---|---|
|
||||
| _chat_channel | NakamaChannel | Current chat channel |
|
||||
| _chat_messages | Array | [{sender, content, ts, date}] |
|
||||
| _active_chat_context | String | "global" or user_id |
|
||||
| _dm_tabs | Dictionary | user_id -> HBoxContainer (tab UI) |
|
||||
| _dm_messages | Dictionary | user_id -> Array of messages |
|
||||
| _chat_config | Dictionary | {prefix, max_messages, max_age_days} |
|
||||
|
||||
**Public Functions:**
|
||||
|
||||
| Function | Signature | Return | Description |
|
||||
|---|---|---|---|
|
||||
| `join_global_chat` | `func join_global_chat() -> void` | void (async) | Join social_global channel, fetch history, inject prefix |
|
||||
| `switch_chat_tab` | `func switch_chat_tab(context_id: String) -> void` | void | Switch between global and DM tabs |
|
||||
| `_on_chat_send_pressed` | `func _on_chat_send_pressed() -> void` | void (async) | Send message: @username for DM, /clear for admin |
|
||||
| `on_lobby_dm_received` | `func on_lobby_dm_received(from_user_id: String, from_name: String, message: String) -> void` | void | Incoming DM handler |
|
||||
| `leave_global_chat` | `func leave_global_chat() -> void` | void (async) | Disconnect and leave channel |
|
||||
|
||||
**Internal Functions:** `_add_chat_message`, `_send_dm_message`, `_open_dm_tab`, `_create_dm_tab`, `_close_dm_tab`, `_inject_local_message`, `_trim_old_messages`, `_refresh_chat_display`, `_format_nakama_time`, `_get_local_time`, `_on_chat_input_changed`, `_on_friend_suggest_activated`, `_setup_friend_suggest_ui`.
|
||||
|
||||
**Dependencies:** NakamaManager, BackendService, FriendManager, AdminManager, UserProfileManager.
|
||||
**Depended by:** lobby.gd.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## 13. Dependency Graph
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 13.1 Manager Autoload Dependencies
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
ASCII diagram showing which autoloads reference others:
|
||||
|
||||
```
|
||||
NakamaManager (no deps on other managers -- pure Nakama SDK)
|
||||
|
|
||||
+-- BackendService
|
||||
| +-- SteamworksManager (child node, not autoload)
|
||||
|
|
||||
+-- AuthManager
|
||||
| +-- NakamaManager
|
||||
| +-- BackendService
|
||||
|
|
||||
+-- SessionManager
|
||||
| +-- NakamaManager
|
||||
|
|
||||
+-- LobbyManager
|
||||
| +-- NakamaManager
|
||||
| +-- GameStateManager
|
||||
|
|
||||
+-- GameStateManager (no deps)
|
||||
|
|
||||
+-- PlayerManager (no deps -- data only)
|
||||
|
|
||||
+-- EventBus (no deps -- pure observer)
|
||||
|
|
||||
+-- UserProfileManager
|
||||
| +-- NakamaManager
|
||||
| +-- BackendService
|
||||
| +-- EventBus
|
||||
|
|
||||
+-- FriendManager
|
||||
| +-- BackendService
|
||||
| +-- NakamaManager
|
||||
|
|
||||
+-- MailManager
|
||||
| +-- BackendService
|
||||
|
|
||||
+-- GachaManager
|
||||
| +-- BackendService
|
||||
| +-- UserProfileManager
|
||||
| +-- EventBus
|
||||
|
|
||||
+-- DailyRewardManager
|
||||
| +-- BackendService
|
||||
|
|
||||
+-- AdminManager
|
||||
| +-- BackendService
|
||||
| +-- NakamaManager
|
||||
|
|
||||
+-- SkinManager
|
||||
| +-- UserProfileManager
|
||||
|
|
||||
+-- ShopManager
|
||||
| +-- BackendService (thin)
|
||||
|
|
||||
+-- PlayerInputManager
|
||||
| +-- TouchControls
|
||||
|
|
||||
+-- PlayerMovementManager
|
||||
| +-- ObstacleManager
|
||||
| +-- SpecialTilesManager
|
||||
| +-- EnhancedGridMap (scene)
|
||||
|
|
||||
+-- PlayerActionManager
|
||||
| +-- PlayerboardManager
|
||||
| +-- PlayerInputManager
|
||||
| +-- GoalsCycleManager
|
||||
|
|
||||
+-- PlayerboardManager
|
||||
| +-- GoalsCycleManager
|
||||
| +-- GoalManager
|
||||
|
|
||||
+-- PlayerRaceManager
|
||||
| +-- GoalsCycleManager
|
||||
|
|
||||
+-- GoalsCycleManager
|
||||
| +-- GoalManager
|
||||
| +-- TurnManager
|
||||
| +-- Timer (scene)
|
||||
|
|
||||
+-- StopNGoManager
|
||||
| +-- TurnManager
|
||||
| +-- GoalManager
|
||||
| +-- GoalsCycleManager
|
||||
| +-- animation.gd (scene)
|
||||
|
|
||||
+-- GauntletManager
|
||||
| +-- TurnManager
|
||||
| +-- EnhancedGridMap
|
||||
|
|
||||
+-- PortalModeManager
|
||||
| +-- SpecialTilesManager
|
||||
| +-- EnhancedGridMap
|
||||
|
|
||||
+-- SpecialTilesManager
|
||||
| +-- ObstacleManager
|
||||
| +-- EnhancedGridMap
|
||||
|
|
||||
+-- ObstacleManager
|
||||
| +-- EnhancedGridMap
|
||||
|
|
||||
+-- StaticTektonManager
|
||||
| +-- EnhancedGridMap
|
||||
| +-- ObstacleManager
|
||||
|
|
||||
+-- PowerupManager (no deps)
|
||||
|
|
||||
+-- UIManager (no deps -- dynamic UI)
|
||||
|
|
||||
+-- SettingsManager (no deps -- ConfigFile)
|
||||
|
|
||||
+-- GameUpdateManager (HTTPRequest -- no manager deps)
|
||||
|
|
||||
+-- TutorialManager
|
||||
| +-- TutorialOverlay
|
||||
|
|
||||
+-- TutorialOverlay
|
||||
| +-- TutorialManager
|
||||
| +-- UIManager
|
||||
|
|
||||
+-- MusicManager (no deps)
|
||||
+-- SfxManager (no deps)
|
||||
+-- ScreenShake (no deps)
|
||||
+-- NotificationManager (no deps)
|
||||
+-- CameraContextManager (no deps)
|
||||
+-- TouchControls (no deps)
|
||||
+-- JoinManager (no deps -- stub)
|
||||
```
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 13.2 Cross-Manager Signal Wiring
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
Key signal connections between managers and scene scripts:
|
||||
|
||||
```
|
||||
NakamaManager.match_joined -> LobbyManager._on_match_joined
|
||||
NakamaManager.match_join_error -> lobby.gd (clears _is_hosting)
|
||||
NakamaManager.connection_failed -> lobby.gd (clears _is_hosting)
|
||||
|
||||
LobbyManager.room_joined -> LobbyRoom._on_room_joined
|
||||
LobbyManager.room_left -> LobbyRoom._on_room_left
|
||||
LobbyManager.host_disconnected -> LobbyRoom._on_host_disconnected
|
||||
LobbyManager.player_joined -> LobbyRoom._on_player_joined
|
||||
LobbyManager.player_left -> LobbyRoom._on_player_left
|
||||
LobbyManager.ready_state_changed -> LobbyRoom._on_ready_state_changed
|
||||
LobbyManager.all_players_ready -> LobbyRoom._on_all_players_ready
|
||||
LobbyManager.game_starting -> LobbyRoom._on_game_starting
|
||||
LobbyManager.game_mode_changed -> LobbyRoom._on_game_mode_changed
|
||||
LobbyManager.room_list_updated -> LobbyRoomList.on_room_list_updated
|
||||
LobbyManager.character_changed -> LobbyRoom._on_character_changed
|
||||
LobbyManager.rematch_votes_updated -> main.gd (update rematch button)
|
||||
|
||||
FriendManager.dm_message_received -> LobbyChat.on_lobby_dm_received
|
||||
FriendManager.lobby_invite_received -> LobbyRoom._on_lobby_invite_received
|
||||
|
||||
MailManager.unread_count_changed -> lobby.gd (update badge)
|
||||
|
||||
UserProfileManager.profile_loaded -> lobby.gd (_sync_room_profile_card)
|
||||
UserProfileManager.profile_updated -> lobby.gd (_sync_room_profile_card)
|
||||
|
||||
AuthManager.logged_out -> LobbyMainMenu.go_to_login
|
||||
```
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## 14. Scene Node Trees
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 14.1 main.tscn
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
```
|
||||
Main (Node) -- attached: main.gd
|
||||
+-- EnhancedGridMap (GridMap / custom EnhancedGridMap node)
|
||||
+-- PlayerSpawnPoints (Node3D)
|
||||
+-- HUD (CanvasLayer)
|
||||
| +-- LeaderboardPanel (Panel)
|
||||
| | +-- MarginContainer/VBox
|
||||
| | +-- Entry1-8 (HBoxContainer with RankLabel/NameLabel/ScoreLabel)
|
||||
| +-- NotificationOverlay (Control)
|
||||
| +-- ActionButtons (Control)
|
||||
+-- PauseMenu (Panel)
|
||||
| +-- Panel/VBox/ResumeBtn, HowToPlayBtn, SettingsBtn, UnstuckBtn, QuitMatchBtn
|
||||
+-- HowToPlayPanel (Panel)
|
||||
+-- StopNGoUI (Control) -- attached: animation.gd
|
||||
| +-- StopPhase/AnimatedSprite2D
|
||||
| +-- AnimationPlayer
|
||||
| +-- CountDown/CountDownAnimation (AnimatedSprite2D)
|
||||
| +-- GoFinish/GoAnimation2D (AnimatedSprite2D)
|
||||
+-- Camera3D
|
||||
+-- WorldEnvironment
|
||||
+-- Player instances (added dynamically by main.gd)
|
||||
+-- Player (CharacterBody3D) -- attached: player.gd
|
||||
+-- MeshInstance3D (visual)
|
||||
+-- CollisionShape3D
|
||||
+-- PlayerboardUI (Control overlay)
|
||||
+-- AnimationPlayer
|
||||
```
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 14.2 player.tscn
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
```
|
||||
Player (CharacterBody3D) -- attached: player.gd
|
||||
+-- MeshInstance3D (character model)
|
||||
+-- CollisionShape3D
|
||||
+-- PlayerboardUI (Control)
|
||||
| +-- Slot0-9 (Panel/TextureRect)
|
||||
+-- AnimationPlayer
|
||||
+-- (tektons picked up become children at runtime)
|
||||
```
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
### 14.3 lobby.tscn
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
```
|
||||
Lobby (Control) -- attached: lobby.gd
|
||||
+-- StatusBar (HBoxContainer)
|
||||
| +-- ConnectionStatus (Label)
|
||||
+-- MainMenuPanel (Panel)
|
||||
| +-- Title/Username/Subtitle/Buttons (CreateRoom, BrowseRooms, Tutorial, etc.)
|
||||
| +-- CharacterRoot (SubViewportContainer > SubViewport > Node3D)
|
||||
| | +-- Oldpop, Masbro, Gatot, Bob (character meshes, hidden by default)
|
||||
| +-- %AnimationPlayer
|
||||
| +-- CurrencyLabels (GoldLabel, StarLabel)
|
||||
| +-- ServerOption / ServerIPInput
|
||||
| +-- LeaderboardBtn, CartBtn, ProfileBtn, MailboxBtn, Banner1Btn, TicketBtn
|
||||
+-- RoomListPanel (Control)
|
||||
| +-- RoomListTabs (TabContainer)
|
||||
| +-- RoomTab, PlayTab
|
||||
| +-- MatchIdInput, RefreshBtn, JoinBtn, BackBtn
|
||||
| +-- RoomList (ItemList)
|
||||
| +-- ItemTemplate (hidden)
|
||||
| +-- ProfileCard (PlayerUsername, PlayerScore, Rank, Avatar)
|
||||
+-- LobbyPanel (Panel)
|
||||
| +-- RoomNameHeader
|
||||
| +-- HostBanner
|
||||
| +-- TopBar/SettingsSection (Duration, Spawn, Timer, Scarcity, GameMode options)
|
||||
| +-- AreaSelector
|
||||
| +-- PlayersContainer (slots 1-4)
|
||||
| +-- PlayersContainer2 (slots 5-8)
|
||||
| +-- BottomBar (LeaveBtn, ReadyBtn, StartGameBtn, InviteBtn)
|
||||
| +-- StatusLabel
|
||||
+-- ChatPanel (Panel)
|
||||
| +-- RichTextLabel
|
||||
| +-- ChatInput (LineEdit)
|
||||
| +-- SendBtn
|
||||
| +-- ChatTabsContainer (GlobalChatTabBtn + DM tabs)
|
||||
| +-- FriendSuggestPanel (hidden)
|
||||
+-- (dynamic instances: MailboxPanel, ShopPanel, GachaPanel, ProfilePanel, etc.)
|
||||
```
|
||||
|
||||
[Back to top](#top)
|
||||
@@ -0,0 +1,392 @@
|
||||
# Tekton Armageddon - Server Architecture
|
||||
|
||||
<a id="top"></a>
|
||||
|
||||
High-level architecture of the Nakama Lua backend. For detailed RPC reference (params, returns, errors), see [Nakama-Server-API](../Nakama-Server-API).
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## System Overview
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Client[Godot Client<br/>player.tscn] -->|RPC calls| Nakama[Nakama Server<br/>port 7350/7351]
|
||||
Nakama -->|wallet_update| Wallet[Wallet Engine]
|
||||
Nakama -->|storage_read/write| Storage[Nakama Storage DB<br/>PostgreSQL]
|
||||
Nakama -->|leaderboard_*| LB[Native Leaderboard]
|
||||
Nakama -->|match_*| Match[Match Handler]
|
||||
Nakama -->|channel_*| Chat[Lobby Chat]
|
||||
|
||||
Client -->|nk.match_join| Match
|
||||
Client -->|Direct RPC| Lua[Lua RPC Handlers<br/>server/nakama/lua/]
|
||||
```
|
||||
|
||||
The Lua backend runs **inside** the Nakama process. Lua modules hook into Nakama's lifecycle (after-authentication, RPC dispatch, match signals). All game transactions (wallet, inventory, gacha, shop) are **server-authoritative** — the Godot client sends RPC requests and the Lua code validates and executes.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## Module Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
main["main.lua<br/>Entry point: loads all modules"] --> utils["utils.lua<br/>Auth guards, channel resolver"]
|
||||
main --> core["core.lua<br/>Authentication hooks<br/>Wallet init, ban check"]
|
||||
main --> economy["economy.lua<br/>Shop catalog, currency IAP<br/>Item purchases"]
|
||||
main --> gacha["gacha.lua<br/>Gacha pulls, pity system<br/>RNG on server"]
|
||||
main --> leaderboard["leaderboard.lua<br/>Score submission, sync<br/>Global rankings"]
|
||||
main --> inbox["inbox.lua<br/>Global/personal mail<br/>Reward claiming"]
|
||||
main --> daily["daily_rewards.lua<br/>Daily login rewards<br/>Monthly schedule"]
|
||||
main --> user["user.lua<br/>Profile updates, identity<br/>Friend sync"]
|
||||
main --> admin["admin.lua<br/>Kick/ban, stats, chat mgmt<br/>Role management"]
|
||||
|
||||
core --> utils
|
||||
economy --> utils
|
||||
gacha --> utils
|
||||
leaderboard --> utils
|
||||
inbox --> utils
|
||||
daily --> utils
|
||||
user --> utils
|
||||
admin --> utils
|
||||
|
||||
economy -->|Wallet deduction| Wallet[(Wallet Engine)]
|
||||
gacha -->|Wallet deduction| Wallet
|
||||
inbox -->|Claim rewards| Wallet
|
||||
daily -->|Daily claim| Wallet
|
||||
user -->|Profile load| Wallet
|
||||
```
|
||||
|
||||
### Dependency order (loading sequence)
|
||||
|
||||
1. `utils.lua` — no deps (loaded first)
|
||||
2. `economy.lua` — depends on utils
|
||||
3. `core.lua` — depends on utils
|
||||
4. `admin.lua` — depends on utils
|
||||
5. `daily_rewards.lua` — depends on utils
|
||||
6. `user.lua` — depends on utils
|
||||
7. `leaderboard.lua` — depends on utils
|
||||
8. `inbox.lua` — depends on utils
|
||||
9. `gacha.lua` — no deps beyond utils
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Godot Client
|
||||
participant N as Nakama Core
|
||||
participant A as after_hooks (core.lua)
|
||||
participant DB as Nakama Storage
|
||||
|
||||
C->>N: AuthenticateDevice/Custom/Email
|
||||
N->>A: after_authenticate(context)
|
||||
A->>DB: storage_read(profiles/profile)
|
||||
alt First login (no profile)
|
||||
A->>DB: storage_write(initial profile)
|
||||
A->>N: wallet_update(gold=100, star=500)
|
||||
end
|
||||
A->>DB: storage_read(profiles/profile)
|
||||
alt metadata.banned == true
|
||||
A-->>C: error("Account banned")
|
||||
end
|
||||
A->>C: session token returned
|
||||
```
|
||||
|
||||
### Boot sequence per player
|
||||
|
||||
1. Godot client calls `AuthenticateDevice` (or Email/Custom/Steam).
|
||||
2. Nakama core calls `after_authenticate()` hook in `core.lua`.
|
||||
3. Hook reads `profiles/profile` from storage.
|
||||
4. **If first login:** initializes default profile and grants starting wallet (`gold: 100, star: 500`).
|
||||
5. **Ban check:** If `metadata.banned == true`, raises error (player rejected).
|
||||
6. Client receives session token and proceeds to lobby.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## Wallet & Economy Flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Client Side
|
||||
ShopUI[Shop Panel] -->|buy_currency RPC| Server
|
||||
ShopUI -->|purchase_item RPC| Server
|
||||
GachaUI[Gacha Panel] -->|perform_gacha_pull RPC| Server
|
||||
MailUI[Inbox] -->|claim_mail_reward RPC| Server
|
||||
DailyUI[Daily Rewards] -->|claim_daily_reward RPC| Server
|
||||
end
|
||||
|
||||
subgraph Server Side
|
||||
Server[Lua RPC Handler]
|
||||
Server -->|nk.wallet_update| Wallet[(Wallet)]
|
||||
Server -->|nk.storage_write| Inv[(Inventory Storage)]
|
||||
Server -->|nk.storage_write| Rec[(Receipts Storage)]
|
||||
Server -->|nk.storage_write| Frag[(Fragments Storage)]
|
||||
end
|
||||
|
||||
subgraph Client Refresh
|
||||
Wallet -->|Wallet updated| Client
|
||||
Client -->|get_account RPC| Wallet
|
||||
Client -->|emit profile_updated| UI[All UI Panels<br/>update labels]
|
||||
end
|
||||
```
|
||||
|
||||
### Currency types
|
||||
|
||||
| Currency | Purpose | Earned by |
|
||||
|---|---|---|
|
||||
| `gold` | Shop purchases, star conversion | IAP (real money), admin topup |
|
||||
| `star` | Gacha pulls | Gold conversion, daily rewards, mail rewards |
|
||||
|
||||
### All wallet changesets
|
||||
|
||||
| Operation | Changeset |
|
||||
|---|---|
|
||||
| First login grant | `{gold: 100, star: 500}` |
|
||||
| Buy gold IAP | `{gold: +N}` (N=100/550/1150/2400/6250/13000) |
|
||||
| Buy stars (gold convert) | `{gold: -N, star: +M}` |
|
||||
| Buy shop item | `{gold: -price}` or `{star: -price}` |
|
||||
| Gacha pull | `{star: -cost}` or `{gold: -cost}` |
|
||||
| Claim mail reward | `{gold: +N}` and/or `{star: +N}` |
|
||||
| Claim daily reward | `{star: +N}` or `{gold: +N}` |
|
||||
| Admin topup | `{gold: 999999}` |
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## Gacha Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Client: perform_gacha_pull] --> B{Check banner}
|
||||
B -->|star/gold| C[Read wallet balance]
|
||||
C --> D{Sufficient funds?}
|
||||
D -->|No| E[error: Insufficient currency]
|
||||
D -->|Yes| F{Check pity}
|
||||
F -->|pity >= 90| G[Force real_prize rarity]
|
||||
F -->|pity < 90| H[Roll rarity by drop rates]
|
||||
|
||||
G --> I[Pick from real_prize pool]
|
||||
H --> J[Pick from rarity pool]
|
||||
|
||||
I --> K[Deduct wallet cost]
|
||||
J --> K
|
||||
|
||||
K --> L{real_prize?}
|
||||
L -->|Yes| M[Add to inventory storage]
|
||||
L -->|No| N[Increment fragment count]
|
||||
|
||||
M --> O[Return results]
|
||||
N --> O
|
||||
```
|
||||
|
||||
### Drop rates
|
||||
|
||||
| Rarity | Rate | Result |
|
||||
|---|---|---|
|
||||
| Common | 60% | Fragment (`frag_common`) |
|
||||
| Uncommon | 25% | Fragment (`frag_uncommon`) |
|
||||
| Rare | 14% | Fragment (`frag_rare`) |
|
||||
| Real Prize | 1% | Skin from catalog |
|
||||
|
||||
**Pity:** Guaranteed Real Prize at 90 pulls. Pity counter resets on any Real Prize win.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## Mail/Inbox System
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Admin[Admin RPC] -->|admin_send_mail| Global[Global Mail<br/>config/global_mail<br/>system user]
|
||||
Admin -->|admin_send_mail<br/>with target_user_id| Personal[Personal Mail<br/>inbox/personal<br/>per user]
|
||||
|
||||
Client -->|get_mail| Merge[Merge global + personal]
|
||||
Merge --> Filter[Filter by: not deleted,<br/>within date range,<br/>not expired]
|
||||
Filter --> Response[Return to client]
|
||||
|
||||
Client -->|claim_mail_reward| CW{Check claimed_ids}
|
||||
CW -->|Already claimed| Err[error: Reward already claimed]
|
||||
CW -->|Not claimed| Grant[Grant rewards:<br/>gold/star -> wallet<br/>fragments -> fragment storage<br/>skins -> inventory storage]
|
||||
Grant --> UpdateState[Update state:<br/>add mailId to claimed_ids]
|
||||
```[Global Mail<br/>config/global_mail<br/>system user]
|
||||
Admin -->|admin_send_mail<br/>with target_user_id| Personal[Personal Mail<br/>inbox/personal<br/>per user]
|
||||
|
||||
Client -->|get_mail| Merge[Merge global + personal]
|
||||
Merge --> Filter[Filter by: not deleted,<br/>within date range,<br/>not expired]
|
||||
Filter --> Response[Return to client]
|
||||
|
||||
Client -->|claim_mail_reward| CW[Check claimed_ids]
|
||||
CW -->|Already claimed| Err[error: Reward already claimed]
|
||||
CW -->|Not claimed| Grant[Grant rewards:<br/>gold/star -> wallet<br/>fragments -> fragment storage<br/>skins -> inventory storage]
|
||||
Grant --> UpdateState[Update state:<br/>add mailId to claimed_ids]
|
||||
```
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## Client-Server Data Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Godot Client
|
||||
participant L as Lua RPC
|
||||
participant S as Nakama Storage
|
||||
participant W as Wallet
|
||||
|
||||
Note over C,W: Purchase Flow
|
||||
C->>L: purchase_item(item_id, idempotency_key)
|
||||
L->>L: Look up item in SHOP_CATALOG_DEFS
|
||||
L->>W: wallet_update(-price)
|
||||
alt Insufficient funds
|
||||
W-->>L: error
|
||||
L-->>C: error("NotEnoughFunds")
|
||||
else Success
|
||||
L->>S: storage_write(inventory/item_id)
|
||||
L->>S: storage_write(receipts/idempotency_key)
|
||||
L-->>C: {success: true, item: item_id}
|
||||
end
|
||||
|
||||
Note over C,W: Wallet State Sync
|
||||
C->>L: get_account (Nakama SDK call)
|
||||
S->>C: wallet JSON string
|
||||
C->>C: Parse wallet JSON
|
||||
C->>C: emit profile_updated signal
|
||||
C->>C: All UI panels update labels
|
||||
```
|
||||
|
||||
All transactions are **idempotent** via `idempotency_key` — if the same key is used twice, the server returns the previous result instead of re-executing.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## Admin Hierarchy
|
||||
|
||||
| Role | Can | Guarded by |
|
||||
|---|---|---|
|
||||
| `player` (default) | Nothing special | — |
|
||||
| `moderator` | Match-related admin: kick players, get server stats | `utils.require_admin_or_host` (also checks match host) |
|
||||
| `admin` | All moderation: ban/unban, manage mail, manage chat, view users | `utils.require_admin(context)` |
|
||||
| `owner` | Everything admin can + set user roles | Inline check: `callerMetadata.role == "owner"` |
|
||||
|
||||
### Guard functions (utils.lua)
|
||||
|
||||
```lua
|
||||
utils.require_admin(context) -- errors if role not admin or owner
|
||||
utils.require_admin_or_host(context, match_id) -- errors if not admin/owner AND not match host
|
||||
utils.is_banned(metadata) -- returns boolean
|
||||
utils.resolve_channel_id(channelId) -- channel name → hashed ID
|
||||
```
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## Storage Collections
|
||||
|
||||
| Collection | Owner | Key | Public R | Public W | Purpose |
|
||||
|---|---|---|---|---|---|
|
||||
| `profiles` | User | `"profile"` | 1 | 0 | User metadata, role, ban status, loadout |
|
||||
| `profiles` | User | `"pity_counters"` | 1 | 0 | Per-banner gacha pity counts |
|
||||
| `profiles` | User | `"fragments"` | 1 | 0 | Accumulated gacha fragments |
|
||||
| `inventory` | User | Item ID | 1 | 0 | Owned cosmetic items |
|
||||
| `inventory` | User | `"fragments"` | 1 | 0 | Fragment counts (legacy) |
|
||||
| `stats` | User | `"game_stats"` | 1 | 0 | Player stats (wins, kills, score) |
|
||||
| `receipts` | User | Idempotency key | 1 | 0 | Purchase receipts (IAP + shop) |
|
||||
| `inbox` | User | `"personal"` | 1 | 0 | Personalized mail inbox |
|
||||
| `inbox` | User | `"state"` | 1 | 0 | claimed_ids, deleted_ids, read_ids |
|
||||
| `daily_rewards` | User | `"state"` | 1 | 0 | Daily reward claim tracking |
|
||||
| `config` | SYSTEM | `"global_mail"` | 2 | 0 | Global mail sent to all players |
|
||||
| `config` | SYSTEM | `"daily_rewards"` | 2 | 0 | Monthly reward schedule |
|
||||
| `config` | SYSTEM | `"lobby_chat"` | 2 | 0 | Chat prefix/max_messages config |
|
||||
| `shop_config` | SYSTEM | `"featured_banners"` | 2 | 0 | Featured shop banners (max 3) |
|
||||
| `bans` | SYSTEM | User ID | 2 | 0 | Ban records (redundant with metadata) |
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## vs Nakama-Server-API
|
||||
|
||||
| Aspect | Architecture-Server (this page) | Nakama-Server-API |
|
||||
|---|---|---|
|
||||
| Audience | Architects, new devs | Implementers, AI agents |
|
||||
| Detail level | High-level flow, diagrams | Per-function: params, returns, errors |
|
||||
| Diagrams | Mermaid flowcharts | None |
|
||||
| RPC listing | Summary table with key flows | Full 48 RPC documentation |
|
||||
| Storage | Conceptual collection overview | Exact schema per collection |
|
||||
| Best for | Understanding the system | Calling RPCs without reading code |
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: All 48 Registered RPCs
|
||||
|
||||
| RPC Name | Module | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| `update_display_name` | user | required | Change display name |
|
||||
| `update_avatar` | user | required | Change avatar URL |
|
||||
| `sync_profile` | user | required | Push profile to server |
|
||||
| `change_identity` | user | required | Link new device/email |
|
||||
| `set_password` | user | required | Set email password |
|
||||
| `sync_friends` | user | required | Push friend list |
|
||||
| `get_shop_catalog` | economy | required | Get catalog + featured |
|
||||
| `buy_currency` | economy | required | IAP gold/star purchase |
|
||||
| `purchase_item` | economy | required | Buy cosmetic item |
|
||||
| `perform_gacha_pull` | gacha | required | Roll gacha |
|
||||
| `get_leaderboard_stats` | leaderboard | no | Get top 50 scores |
|
||||
| `submit_score` | leaderboard | required | Record match score |
|
||||
| `sync_leaderboard` | leaderboard | required | Sync stats → leaderboard |
|
||||
| `reset_stats` | leaderboard | required | Clear own stats |
|
||||
| `get_mail` | inbox | required | Get available mail |
|
||||
| `claim_mail_reward` | inbox | required | Claim mail rewards |
|
||||
| `delete_mail` | inbox | required | Soft-delete mail |
|
||||
| `save_mail_state` | inbox | required | Mark as read |
|
||||
| `claim_daily_reward` | daily_rewards | required | Claim today's reward |
|
||||
| `get_daily_reward_state` | daily_rewards | required | View monthly schedule |
|
||||
| `set_daily_reward_config` | daily_rewards | admin | Set reward schedule |
|
||||
| `get_daily_reward_config_admin` | daily_rewards | admin | Get reward config |
|
||||
| `admin_*` (18 RPCs) | admin | admin/owner | Moderation tools |
|
||||
|
||||
For full params/returns/errors on any RPC above, see [Nakama-Server-API](../Nakama-Server-API).
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## Deployment Topology
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph VPS [VPS 52.74.133.55]
|
||||
Gitea[Gitea Server<br/>port 3000]
|
||||
Nakama[Nakama Server<br/>port 7350/7351]
|
||||
PG[(PostgreSQL<br/>Nakama DB)]
|
||||
Act[act_runner<br/>CI/CD Worker]
|
||||
end
|
||||
|
||||
Client[Godot Player] -->|HTTPS| Gitea
|
||||
Client -->|gRPC/WebSocket| Nakama
|
||||
Nakama --> PG
|
||||
|
||||
GitHub_mirror[GitHub Mirror] -->|Push| Gitea
|
||||
Gitea -->|Webhook/Manual| Act
|
||||
Act -->|Build| Binary[Binary Releases]
|
||||
Act -->|Build| Patch[patch.pck on patches branch]
|
||||
Client -->|Check version| Gitea
|
||||
Client -->|Download patch| Gitea
|
||||
```
|
||||
|
||||
[Back to top](#top)
|
||||
@@ -0,0 +1,604 @@
|
||||
# Game Modes
|
||||
|
||||
<a id="top"></a>
|
||||
|
||||
[Back to Home](./Home)
|
||||
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Architecture](#architecture)
|
||||
- [GameMode Enum & ModeConfig](#gamemode-enum--modeconfig)
|
||||
- [Session Flow (all modes)](#session-flow-all-modes)
|
||||
- [Core Managers (shared)](#core-managers-shared)
|
||||
- [Freemode](#freemode)
|
||||
- [Stop n Go](#stop-n-go)
|
||||
- [Tekton Doors (Portal)](#tekton-doors-portal)
|
||||
- [Candy Pump Survival (Gauntlet)](#candy-pump-survival-gauntlet)
|
||||
- [Scoring & Leaderboard](#scoring--leaderboard)
|
||||
- [Glossary](#glossary)
|
||||
- [File Index](#file-index)
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## Overview
|
||||
|
||||
Four game modes, each implementing the same core loop — players navigate a grid, collect tiles (Heart / Diamond / Star / Coin), match them to goals on a virtual 5x5 playerboard, and compete for score before the match timer expires.
|
||||
|
||||
| Mode | Enum Value | Display Name | Play Area | Gimmick |
|
||||
|------|-----------|-------------|-----------|---------|
|
||||
| Freemode | `FREEMODE = 0` | "Freemode" | Variable | No restrictions, just goals + timer |
|
||||
| Stop n Go | `STOP_N_GO = 1` | "Stop n Go" | 23x12 | GO/STOP phases, safe zones, scatter penalty |
|
||||
| Tekton Doors | `TEKTON_DOORS = 2` | "Tekton Doors" | 14x14 | 4 rooms, portal doors swap connections every 15s |
|
||||
| Candy Pump Survival | `GAUNTLET = 3` | "Candy Pump Survival" | 20x20 | Ground growth — candy slowly fills the arena |
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
GameMode (enum/RefCounted)
|
||||
├── .from_string() / .mode_to_string() / .is_restricted() / .get_all_modes()
|
||||
│
|
||||
├── ModeConfig (RefCounted)
|
||||
│ └── SCHEMA with defaults, type-checking, min/max for each mode
|
||||
│
|
||||
├── GoalsCycleManager (Node, autoload?)
|
||||
│ ├── 30s cycle timer
|
||||
│ ├── Match timer (configurable 60-600s)
|
||||
│ ├── Score tracking (player_scores, player_goal_counts)
|
||||
│ ├── Goal completion: _process_goal_completion()
|
||||
│ └── RPC sync: sync_player_score, sync_goal_count, sync_timer
|
||||
│
|
||||
├── GoalManager (Node)
|
||||
│ ├── initialize_random_goals() — generates 9-slot goal patterns
|
||||
│ ├── Speed tracking (completion_times, boost_multiplier)
|
||||
│ └── generate_preset_goals() / get_goals_for_player()
|
||||
│
|
||||
├── PlayerRaceManager (per-player)
|
||||
│ ├── goals: Array[int] (9 slots)
|
||||
│ ├── playerboard: Array[int] (25 slots, 5x5)
|
||||
│ ├── check_pattern_match() — core matching logic
|
||||
│ └── DEPRECATED lap/finish-line stubs
|
||||
│
|
||||
├── PlayerboardManager (per-player)
|
||||
│ ├── grab_item() / auto_put_item() / arrange operations
|
||||
│ ├── _execute_grab() — server-authoritative grab validation
|
||||
│ ├── HIDDEN_SLOTS (12 of 25 cells blocked)
|
||||
│ ├── bot_try_grab_item() — AI grab logic
|
||||
│ └── _check_and_refill_grid_if_needed() — scarcity refill
|
||||
│
|
||||
├── TurnManager (shared)
|
||||
│ ├── next_turn() / end_current_turn()
|
||||
│ └── turn_based_mode toggle
|
||||
│
|
||||
└── Mode-specific managers
|
||||
├── StopNGoManager — phase transitions, safe zones, mission HUD
|
||||
├── PortalModeManager — room partitions, portal doors, swap timer
|
||||
└── GauntletManager — ground growth, phases, bubbles, smack
|
||||
```
|
||||
|
||||
All mode managers live under `/root/Main` and connect to `GoalsCycleManager` signals for score / goal tracking.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## GameMode Enum & ModeConfig
|
||||
|
||||
**File:** `scripts/game_mode.gd` (41 lines)
|
||||
|
||||
```
|
||||
enum Mode { FREEMODE = 0, STOP_N_GO = 1, TEKTON_DOORS = 2, GAUNTLET = 3 }
|
||||
```
|
||||
|
||||
| Static Method | Returns | Description |
|
||||
|--------------|---------|-------------|
|
||||
| `from_string(mode: String)` | `Mode` | Converts "Freemode"/"Stop n Go"/"Tekton Doors"/"Candy Pump Survival" to enum |
|
||||
| `mode_to_string(mode: Mode)` | `String` | Reverse of from_string |
|
||||
| `is_restricted(mode: Mode)` | `bool` | true for STOP_N_GO, TEKTON_DOORS, GAUNTLET |
|
||||
| `get_all_modes()` | `Array[String]` | Returns display names |
|
||||
|
||||
**File:** `scripts/mode_config.gd` (109 lines)
|
||||
|
||||
SCHEMA defines per-mode settings with type, default, min, max, allowed values:
|
||||
|
||||
| Mode | Settings |
|
||||
|------|---------|
|
||||
| Freemode | match_duration (180s default), randomize_spawn (bool), enable_cycle_timer (bool), scarcity_mode (Normal/Aggressive/Chaos) |
|
||||
| Stop n Go | match_duration, sng_go_duration (20s), sng_stop_duration (4s), sng_required_goals (8) |
|
||||
| Tekton Doors | match_duration, doors_swap_time (15s), doors_refresh_time (25s), doors_required_goals (8) |
|
||||
| Gauntlet | match_duration, gauntlet_growth_interval (3.0s), gauntlet_cells_per_tick (phase dict) |
|
||||
|
||||
| Method | Args | Returns |
|
||||
|--------|------|---------|
|
||||
| `get_defaults(mode)` | String | Dictionary of defaults for that mode |
|
||||
| `validate_setting(mode, key, value)` | String, String, Variant | `{"valid": bool, "error": String}` |
|
||||
| `validate_config(mode, config)` | String, Dictionary | `{"valid": bool, "errors": Array}` |
|
||||
| `get_mode_settings(mode)` | String | Array of setting keys |
|
||||
| `get_setting_schema(mode, key)` | String, String | Dictionary with type/default/min/max/allowed |
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## Session Flow (all modes)
|
||||
|
||||
1. **Lobby** — players join, select mode + settings
|
||||
2. **Game start** — `main.gd` calls `_setup_host_game()` which:
|
||||
- Creates arena (gridmap resize + clear)
|
||||
- Spawns mission tiles on Layer 1
|
||||
- Calls mode manager's `start_game_mode()`
|
||||
3. **Countdown** — brief timer then match begins
|
||||
4. **Match active** — `GoalsCycleManager.start_match()` runs match timer + cycle timer
|
||||
5. **Per-cycle** (30s):
|
||||
- Players grab tiles from grid → place on 5x5 playerboard
|
||||
- Match 3x3 pattern against 9-slot goals
|
||||
- Complete goals → B1000 score + new goals + tiles randomize around player
|
||||
- Cycle ends → board cleared, unmatched tiles scored at 10/tile match
|
||||
6. **Match ends** — final leaderboard sync
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## Core Managers (shared)
|
||||
|
||||
### GoalManager (`scripts/managers/goal_manager.gd`, 108 lines)
|
||||
|
||||
Generates 9-slot goal arrays using tile IDs 7-10 (Heart=7, Diamond=8, Star=9, Coin=10) with -1 for no-goal slots (~3 nulls per set).
|
||||
|
||||
| Function | Returns | Description |
|
||||
|----------|---------|-------------|
|
||||
| `initialize_random_goals(size, min_value, max_value, null_count)` | `Array` | Random goals with controlled null distribution |
|
||||
| `generate_preset_goals(count)` | `Array` | Pre-generates N goal sets for all players |
|
||||
| `get_goals_for_player(player_index)` | `Array` | Returns goals for a specific player slot |
|
||||
| `mark_goal_start(player_id)` | void | Records timestamp for speed tracking |
|
||||
| `mark_goal_complete(player_id)` | void | Records completion duration |
|
||||
| `get_player_average_time(player_id)` | `float` | Average completion speed |
|
||||
| `get_global_average_time()` | `float` | Average across all players |
|
||||
| `get_boost_multiplier(player_id)` | `float` | 0.8-1.5x fill rate based on speed vs average |
|
||||
| `reset()` | void | Clears all state |
|
||||
|
||||
### GoalsCycleManager (`scripts/managers/goals_cycle_manager.gd`, 520 lines)
|
||||
|
||||
Central scoring and timer system. Emits signals consumed by mode managers, UI, and leaderboard.
|
||||
|
||||
| Signal | Payload |
|
||||
|--------|---------|
|
||||
| `cycle_started()` | — |
|
||||
| `cycle_ended()` | — |
|
||||
| `timer_updated(time_remaining)` | float |
|
||||
| `score_updated(peer_id, new_score)` | int, int |
|
||||
| `goal_count_updated(peer_id, count)` | int, int |
|
||||
| `leaderboard_updated(sorted_scores)` | Array |
|
||||
| `match_started()` | — |
|
||||
| `match_ended()` | — |
|
||||
| `global_timer_updated(time_remaining)` | float |
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `start_match(duration_seconds, start_cycles)` | Begins match timer, optionally starts first 30s cycle |
|
||||
| `_on_match_end()` | Processes final scores, syncs to clients |
|
||||
| `start_cycle()` | Begin 30s cycle, emit `cycle_started` |
|
||||
| `on_goal_completed(player, time_remaining)` | Entry point — routes to server or optimistic local path |
|
||||
| `_process_goal_completion(player, time_remaining)` | Server: award B1000 + time bonus, regen goals, randomize tiles |
|
||||
| `regenerate_goals_for_player(player)` | Generate new 9-slot goal set, sync via RPC |
|
||||
| `_randomize_tiles_around_player(player)` | Randomize 3x3 area around player on the grid |
|
||||
| `_process_cycle_end_for_all_players()` | Clear all boards, convert matches to B10/tile |
|
||||
| `add_score(peer_id, amount)` | Server: add arbitrary score points |
|
||||
| `_update_leaderboard()` | Sort by score descending (or with SNG winner override) |
|
||||
|
||||
### PlayerRaceManager (`scripts/managers/player_race_manager.gd`, 133 lines)
|
||||
|
||||
State holder per player. Core logic is `check_pattern_match()`.
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `check_pattern_match()` | Returns true if any 3x3 sub-grid of 5x5 board matches 3x3 goals |
|
||||
| `check_3x3_section(board, goals, start_row, start_col)` | Checks single 3x3 section |
|
||||
| `_normalize_tile(tile)` | Converts holo tiles 11-14 → 7-10 for match comparison |
|
||||
| Remaining functions | DEPRECATED lap/finish line stubs |
|
||||
|
||||
**Playerboard Layout:** 5x5 (indices 0-24). 13 cells are HIDDEN_SLOTS (cannot hold tiles). Only 12 usable slots arranged in an L-shape matching the HUD.
|
||||
|
||||
### PlayerboardManager (`scripts/managers/playerboard_manager.gd`, 793 lines)
|
||||
|
||||
Handles grab, put, arrange operations with optimistic local updates + server-authoritative validation.
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `grab_item(grid_position)` | Grab tile from grid → place on board (auto-arrange) or consume as power-up |
|
||||
| `_execute_grab(grid_pos, cell, item_id, expected_slot)` | Server-side validation + state update + sync |
|
||||
| `_force_sync_to_client(cell, server_item)` | Revert client when server rejects grab |
|
||||
| `auto_put_item()` | AI/bot: find best tile to remove from board |
|
||||
| `find_best_goal_slot_for_item(item)` | Auto-arrange into best matching slot |
|
||||
| `bot_try_grab_item()` | AI grab logic |
|
||||
| `_check_and_refill_grid_if_needed(gridmap)` | Refill floor 1 via ScarcityController when empty |
|
||||
|
||||
### TurnManager (`scripts/managers/turn_manager.gd`, 27 lines)
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `next_turn(players)` | Advance turn index, emit `turn_changed` |
|
||||
| `end_current_turn()` | Emit `turn_ended` |
|
||||
| `reset_turn()` / `reset()` | Clear state |
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## Freemode
|
||||
|
||||
**No dedicated manager.** Freemode relies entirely on the shared core managers (GoalsCycleManager, PlayerboardManager, etc.) with no mode-specific restrictions or gimmicks. Arena size is configurable via LobbyManager settings.
|
||||
|
||||
**Settings effects:**
|
||||
- `enable_cycle_timer` false → cycle never expires (board never auto-clears)
|
||||
- `scarcity_mode` → controls tile refill aggression
|
||||
- `randomize_spawn` → players start at random positions
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## Stop n Go
|
||||
|
||||
**Manager:** `StopNGoManager` (`scripts/managers/stop_n_go_manager.gd`, 1107 lines)
|
||||
|
||||
A 23x12 arena with two alternating phases:
|
||||
|
||||
### Phase System
|
||||
|
||||
| Phase | Duration (default) | Behaviour |
|
||||
|-------|-------------------|-----------|
|
||||
| GO | 20s | Players move freely, collect tiles, complete goals |
|
||||
| STOP | 4s | Players frozen if outside safe zone → tiles scattered |
|
||||
|
||||
When STOP begins:
|
||||
1. 3 dynamic safe zones spawn randomly (green tiles)
|
||||
2. All players outside safe zone get `_scatter_player_tiles()` — board tiles scattered on grid
|
||||
3. Power-up tiles (Speed=11, Ghost=14) spawn at 5 permanent locations
|
||||
4. Mission requirement: complete 8 goals before reaching finish (x=22)
|
||||
|
||||
When GO begins:
|
||||
1. Dynamic safe zones cleared
|
||||
2. All STOP freeze effects removed via `sync_stop_freeze(false)`
|
||||
|
||||
### Tile IDs
|
||||
|
||||
| ID | Meaning |
|
||||
|----|---------|
|
||||
| 0 | Walkable floor |
|
||||
| 2 | Safe zone (green) |
|
||||
| 3 | Start/Finish line |
|
||||
| 4 | Wall/obstacle |
|
||||
| 15 | Lightning stone (decorative ancient rock) |
|
||||
| 16 | Safe zone wall |
|
||||
|
||||
### Arena
|
||||
|
||||
22x10 walkable area with two interior rooms with entrances:
|
||||
- Room 1: (7,6) to (11,9) — 5x4 area with 4 door entrances
|
||||
- Room 2: (15,1) to (19,5) — 5x5 area with 4 door entrances
|
||||
|
||||
### HUD
|
||||
|
||||
- Center-bottom mission label: "GOALS (X/8)" or "ALL GOALS COMPLETE! REACH THE FINISH!"
|
||||
- Traffic light stop timer (3 segments): all empty during GO, fills red during STOP phase
|
||||
- Last 3 seconds of GO phase: segments light up one-by-one (countdown)
|
||||
- VFX: `vfx_manager.play_go_animation()` / `play_stop_phase()`
|
||||
|
||||
### RPCs
|
||||
|
||||
| RPC | Direction | Description |
|
||||
|-----|-----------|-------------|
|
||||
| `sync_phase(phase_name, duration)` | Authority → all | Broadcast GO/STOP phase change |
|
||||
| `sync_arena_setup()` | Authority → remote | Sync 23x12 grid dimensions + obstacles |
|
||||
| `sync_all_safe_zones_vfx()` | Authority → all | Trigger safe zone visual effects |
|
||||
|
||||
### Key Functions (server-only unless noted)
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `start_game_mode()` | Server: setup arena, assign missions, start GO phase |
|
||||
| `_start_phase(phase)` | Transition GO↔STOP, penalize players outside safe zone |
|
||||
| `_setup_arena()` | Build 23x12 with obstacles + rooms |
|
||||
| `_spawn_mission_tiles()` | Heart(7)/Diamond(8)/Star(9)/Coin(10) at 60% density |
|
||||
| `_spawn_powerup_tiles()` | Speed(11)+Ghost(14) at 5 permanent locations |
|
||||
| `_assign_missions()` | NO-OP (mission = achievement: collect 8 goals) |
|
||||
| `activate_client_side()` | Client: show HUD, connect to GoalsCycleManager signals |
|
||||
| `rotate_players_to_start()` | Force all players to face East (PI/2) |
|
||||
| `can_rpc()` | Check multiplayer peer is connected |
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## Tekton Doors (Portal)
|
||||
|
||||
**Manager:** `PortalModeManager` (`scripts/managers/portal_mode_manager.gd`, 585 lines)
|
||||
**Actor:** `PortalDoor` (`scripts/portal_door.gd`, 136 lines)
|
||||
|
||||
A 14x14 grid divided into 4 rooms (7x7 each) by cross-shaped wall partitions. Players move between rooms via portal doors that swap connections every 15 seconds.
|
||||
|
||||
### Room Layout
|
||||
|
||||
```
|
||||
Room 0 (NW) | Room 1 (NE)
|
||||
x: 0-6 | x: 7-13
|
||||
z: 0-6 | z: 0-6
|
||||
--------------+--------------
|
||||
Room 2 (SW) | Room 3 (SE)
|
||||
x: 0-6 | x: 7-13
|
||||
z: 7-13 | z: 7-13
|
||||
```
|
||||
|
||||
Central divider: columns 6,7 and rows 6,7 are walls (tile ID 4).
|
||||
|
||||
### Portal System
|
||||
|
||||
- 10 doors total (2 base per room + 2 randomly placed extras)
|
||||
- Every 15s (`doors_swap_time`): `_randomize_connections()` shuffles pairings
|
||||
- Each pair gets a color from `PORTAL_COLORS` (Cyan/Magenta/Red/Green/Orange)
|
||||
- Validation ensures no pair connects doors in the same room
|
||||
- 200ms anti-jitter cooldown per player in `handle_portal_interaction()`
|
||||
|
||||
### PortalDoor (actor)
|
||||
|
||||
| Property/Method | Description |
|
||||
|----------------|-------------|
|
||||
| `room_id` | Which room this door belongs to |
|
||||
| `door_id` | Unique door index |
|
||||
| `target_door_id` | Connected door (set by PortalModeManager) |
|
||||
| `portal_color` | Color (set triggers `set_portal_color`) |
|
||||
| `detection_area` | Area3D — body_entered triggers portal |
|
||||
| `_on_body_entered(body)` | 200ms cooldown, emit `player_entered_portal` |
|
||||
| `spawn_offset` | Vector2i meta — nudge spawn position into room |
|
||||
| `_adjust_indicator_position()` | Move GroundIndicator toward room interior |
|
||||
|
||||
### Finish Room
|
||||
|
||||
- At 30s remaining on match timer, reveal `_spawn_finish_room()`
|
||||
- Random 3x3 area converted to finish tiles (ID 3) in one room
|
||||
- Player must be standing on finish tile AND have `doors_required_goals` complete
|
||||
|
||||
### Tile Refill
|
||||
|
||||
- Every 25s (`tile_refresh_time`): `_refresh_tiles()` refills Floor 1 at 60% density
|
||||
- Uses `ScarcityModel.get_tile_weights()` for weighted random selection
|
||||
- Avoids spawning under portal doors
|
||||
|
||||
### HUD
|
||||
|
||||
- Center-bottom: "GOALS (X/8)" or "ALL GOALS COMPLETE! FIND THE FINISH ROOM!"
|
||||
- Message broadcasts: "PORTALS SWITCHED!", "TILES REPLENISHED!"
|
||||
- Warning: "A 3x3 Finish Zone has appeared in Room N!"
|
||||
|
||||
### RPCs
|
||||
|
||||
| RPC | Direction | Description |
|
||||
|-----|-----------|-------------|
|
||||
| `sync_portal_data(data)` | Authority → local | Sync connections + colors to all clients |
|
||||
| `sync_portal_configs(door_configs)` | Via main | Broadcast door positions/rotations |
|
||||
|
||||
### Key Functions
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `initialize(p_main, p_gridmap)` | Create swap timer + tile refresh timer, connect signals |
|
||||
| `start_game_mode()` | Setup arena, randomize connections, start timers |
|
||||
| `setup_arena_locally()` | Resize to 14x14, build room walls, spawn portal doors |
|
||||
| `_randomize_connections()` | Shuffle door pairings, assign colors, validate same-room rule |
|
||||
| `handle_portal_interaction(player, door)` | Teleport player to connected door + offset |
|
||||
| `_spawn_finish_room()` | Convert random 3x3 area to finish tiles |
|
||||
| `check_win_condition(player_id, pos)` | Check finish tile + mission complete |
|
||||
| `_refresh_tiles()` | Refill floor 1 items with scarcity weights |
|
||||
| `sync_to_client(peer_id)` | Sync portal connections to late-joining client |
|
||||
| `get_spawn_points()` | Returns 4 spawn positions (one per room quadrant) |
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## Candy Pump Survival (Gauntlet)
|
||||
|
||||
**Manager:** `GauntletManager` (`scripts/managers/gauntlet_manager.gd`, 1825 lines)
|
||||
|
||||
A 20x20 arena where sticky candy (pink) slowly grows from the edges inward over 3 phases. Players must navigate shrinking safe zones, avoid sticky tiles, and use the "Smack" ability to temporarily clear candy.
|
||||
|
||||
### Phase System
|
||||
|
||||
| Phase | Start | Duration | Cell Growth (per tick) | Description |
|
||||
|-------|-------|----------|----------------------|-------------|
|
||||
| OPEN_ARENA (0) | 0s | 60s | 4-6 | "Outer Pressure" — candy pushes from perimeter |
|
||||
| ROUTE_PRESSURE (1) | 60s | 60s | 6-8 | "Middle Pressure" — corridors tighten |
|
||||
| SURVIVAL_ENDGAME (2) | 120s | 60s | 8-10 | "Inner Survival" — center fills in |
|
||||
|
||||
Each phase transition shrinks the arena bounds by removing outer layers (via `_shrink_arena()`).
|
||||
|
||||
### Growth Algorithm
|
||||
|
||||
Each tick (every `growth_interval` = 3s):
|
||||
|
||||
1. **Detect movement buffers** — identify critical corridor cells (#083)
|
||||
2. **Generate candidates** — all SAFE cells scored by formula:
|
||||
```
|
||||
CandidateScore = LayerPriority + StickyNeighbor + InwardPressure
|
||||
+ PlayerPressure + ClusterGrowth + CampingPressure
|
||||
+ RandomNoise(-20..+20) + MovementBuffer + PathSafety + Repetition
|
||||
```
|
||||
3. **Weighted selection** — pick `_cells_this_tick()` cells via roulette wheel
|
||||
4. **Path safety check** — `_apply_path_safety()` ensures no player gets fully trapped
|
||||
5. **Telegraph** — amber warning overlay appears for 1s (cells still passable)
|
||||
6. **Apply** — cells convert to permanent STICKY (pink overlay on Layer 2)
|
||||
|
||||
### Scoring Components
|
||||
|
||||
| Score Component | Range | Description |
|
||||
|----------------|-------|-------------|
|
||||
| `_score_layer_priority` | -40..+60 | Phase weight by ring (outer/middle/inner) |
|
||||
| `_score_sticky_neighbor` | 0..+64 | +8 per adjacent sticky cell (cap +64) |
|
||||
| `_score_inward_pressure` | 0..+30 | Push inward, scales with phase |
|
||||
| `_score_player_pressure` | -50..+20 | 2-4 cells away +20; under player -50 (+10 in final 30s) |
|
||||
| `_score_cluster_growth` | 0..+25 | +15 expansion, +25 bridge between clusters |
|
||||
| `_score_camping_pressure` | 0..+60 | Per-region: >5s +20, >8s +40, >10s +60 |
|
||||
| `_score_movement_buffer` | -40..0 | Hidden corridor buffers + player proximity floor |
|
||||
| `_score_path_safety` | -100..0 | Soft penalty if selection would strand a player |
|
||||
| `_score_repetition` | -30..0 | Penalty for cells near last tick's selection |
|
||||
|
||||
### Cell States
|
||||
|
||||
| State | Meaning | Passable? |
|
||||
|-------|---------|-----------|
|
||||
| SAFE | Normal floor | Yes |
|
||||
| TELEGRAPHED | Amber warning (1s) | Yes |
|
||||
| STICKY | Permanent candy overlay | No (slows) |
|
||||
| BUBBLE_GROWING | Candy bubble expanding | No |
|
||||
| BLOCKED | NPC zone / permanent obstacle | No |
|
||||
|
||||
### Candy Bubble System (#082)
|
||||
|
||||
Anti-camping hazard: grows 1x1 → 3x3 sticky area.
|
||||
|
||||
| Phase | Max Bubbles |
|
||||
|-------|-------------|
|
||||
| OPEN_ARENA | 0 (disabled) |
|
||||
| ROUTE_PRESSURE | 2 |
|
||||
| SURVIVAL_ENDGAME | 3 |
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Grow duration | 2.75s |
|
||||
| Explosion radius | 1 (3x3) |
|
||||
| Recent memory | 4 positions |
|
||||
| Anti-stack radius | 3 (no bubbles within 3 of recent) |
|
||||
|
||||
### Camping Detection (#073)
|
||||
|
||||
Players tracked in 4x4 regions. Time accumulates while player stays in same region, resets on region change. Drives camping pressure in candidate scoring.
|
||||
|
||||
### Movement Buffers (#083)
|
||||
|
||||
Hidden per-cell penalties on critical corridor cells (chokepoints where removing the cell would isolate part of the arena). Decay over time and phase transitions so arena can still close in.
|
||||
|
||||
### Smack Mechanic
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Cooldown | 8s |
|
||||
| Charge window | 3s |
|
||||
| Effect | Clears nearby sticky? (consumes charge) |
|
||||
|
||||
Per-player cooldown/charge tracked in `smack_cooldowns` / `smack_charged` Dictionaries. Pink modulate during charge window, white on cooldown.
|
||||
|
||||
### Arena NPC
|
||||
|
||||
Candy Pump NPC at center (9,9) in a 3x3 blocked zone. Visual-only in v2 (projectile logic removed). Scattered projectiles still spawned for visual effect during telegraph phase.
|
||||
|
||||
### Slow-Mo
|
||||
|
||||
- Triggered conditionally, duration 4s
|
||||
- `Engine.time_scale = 0.25` (1/4 speed)
|
||||
- Restored to 1.0 in `_exit_tree()`
|
||||
|
||||
### Spawn Points
|
||||
|
||||
| Player Count | Positions |
|
||||
|-------------|-----------|
|
||||
| 4 | 4 corners: (1,1), (18,1), (1,18), (18,18) |
|
||||
| 5-6 | 4 corners + top-mid (10,1) + bottom-mid (10,18) |
|
||||
| 7-8 | 4 corners + all 4 mid-edges |
|
||||
|
||||
### RPCs
|
||||
|
||||
| RPC | Direction | Description |
|
||||
|-----|-----------|-------------|
|
||||
| `sync_phase(phase_index, phase_name)` | Authority → local | Phase change broadcast |
|
||||
| `sync_arena_setup()` | Authority → remote | Arena dimensions + layout |
|
||||
| `sync_growth_telegraph(cells)` | Authority → local | Show amber warning on selected cells |
|
||||
| `sync_growth_apply(cells)` | Authority → local | Convert telegraphed to sticky |
|
||||
| `consume_smack(pid)` | Any peer → local | Smack consumption + animation |
|
||||
| `sync_stop_freeze` | (inherited from player.gd) | Freeze/unfreeze player |
|
||||
|
||||
### Key Functions
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `initialize(main, grid)` | Connect to GoalsCycleManager |
|
||||
| `start_game_mode()` | Activate client side, start OPEN_ARENA phase |
|
||||
| `_setup_arena()` | Build 20x20, spawn Candy Pump NPC |
|
||||
| `_process_growth_tick()` | One growth cycle: score → select → telegraph → apply |
|
||||
| `_generate_candidates()` | Score all SAFE cells |
|
||||
| `_calculate_candidate_score(pos, player_cells)` | Full formula with 10 components |
|
||||
| `_select_cells_weighted(candidates, count)` | Roulette-wheel selection |
|
||||
| `_apply_path_safety(selected)` | Filter: ensure no player stranded |
|
||||
| `_try_spawn_bubble()` | Anti-camping bubble spawn attempt |
|
||||
| `_update_camp_tracking(delta)` | Per-player region residency timer |
|
||||
| `_detect_movement_buffers()` | Identify critical corridor chokepoints |
|
||||
| `_shrink_arena()` | Remove outer arena layers on phase change |
|
||||
| `_spawn_mission_tiles()` | Heart/Diamond/Star/Coin at 60% density |
|
||||
| `get_spawn_points(player_count)` | Return spawn positions by player count |
|
||||
| `has_smack_charged(pid)` / `consume_smack(pid)` | Smack mechanic |
|
||||
| `_spawn_telegraph_highlight(pos)` | Amber glow visual (2-stage: build-up + flash) |
|
||||
| `_spawn_impact_particles(targets)` | Candy splash particles on sticky impact |
|
||||
| `_check_all_players_trapped()` | Re-evaluate sticky traps after growth apply |
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## Scoring & Leaderboard
|
||||
|
||||
| Action | Points |
|
||||
|--------|--------|
|
||||
| Complete goal pattern (match 3x3) | B1000 + time bonus |
|
||||
| Tile match at cycle end (per tile) | B10 |
|
||||
| Time bonus formula | `int(time_remaining * TIME_BONUS_MULTIPLIER)` — currently 0 (flat 1000) |
|
||||
|
||||
Leaderboard sorted descending. Stop n Go special case: winner (first to reach finish) placed at top regardless of score.
|
||||
|
||||
Leaderboard signal payload:
|
||||
```
|
||||
[{"peer_id": int, "score": int}, ...]
|
||||
```
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## Glossary
|
||||
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| Goal | 3x3 pattern (9 slots, some -1 for null) player must match on their playerboard |
|
||||
| Playerboard | 5x5 virtual grid (12 usable slots, 13 hidden) per player |
|
||||
| Tile | Grid item on Floor 1: Heart(7), Diamond(8), Star(9), Coin(10) |
|
||||
| Holo Tile | Power-up tiles: Speed(11), Ghost(14) — consumed on pickup, not placed on board |
|
||||
| Cycle | 30-second scoring round; ends with board clear + point conversion |
|
||||
| Sticky | Permanent pink overlay on Gauntlet floor cells — blocks/slows movement |
|
||||
| Telegraph | Amber 1-second warning before a cell becomes sticky |
|
||||
| Safe Zone | Green tiles in Stop n Go STOP phase — only safe tile type |
|
||||
| Portal | Colored door connecting rooms in Tekton Doors |
|
||||
| Scarcity | Tile refill model controlling spawn weights based on mode config |
|
||||
| Smack | Gauntlet ability: clear nearby sticky (8s cooldown, 3s charge window) |
|
||||
| Camping | Player staying in same 4x4 region >5s, attracts growth pressure |
|
||||
| Movement Buffer | Hidden chokepoint corridor that growth algorithm avoids sealing early |
|
||||
| Chebyshev Distance | `max(|x1-x2|, |y1-y2|)` — used for all proximity calculations |
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
|
||||
## File Index
|
||||
|
||||
| File | Lines | Role |
|
||||
|------|-------|------|
|
||||
| `scripts/game_mode.gd` | 41 | Mode enum + helper functions |
|
||||
| `scripts/mode_config.gd` | 109 | Schema-driven per-mode settings |
|
||||
| `scripts/managers/goal_manager.gd` | 108 | Goal generation + speed tracking |
|
||||
| `scripts/managers/goals_cycle_manager.gd` | 520 | Timer, scoring, cycle control |
|
||||
| `scripts/managers/player_race_manager.gd` | 133 | Per-player state: goals, board, pattern matching |
|
||||
| `scripts/managers/playerboard_manager.gd` | 793 | Grab/put/arrange operations |
|
||||
| `scripts/managers/turn_manager.gd` | 27 | Turn-based flow |
|
||||
| `scripts/managers/stop_n_go_manager.gd` | 1107 | Stop n Go phase system, safe zones, HUD |
|
||||
| `scripts/managers/portal_mode_manager.gd` | 585 | Tekton Doors room layout, portals, tiles |
|
||||
| `scripts/managers/gauntlet_manager.gd` | 1825 | Candy Pump Survival growth, phases, smack |
|
||||
| `scripts/portal_door.gd` | 136 | PortalDoor actor — detection, teleport, visuals |
|
||||
| `scripts/managers/goals_cycle_manager.gd` | (shared) | Also referenced by gauntlet signal connections |
|
||||
| `scripts/managers/camera_context_manager.gd` | ... | Camera mode changes per game mode? |
|
||||
| `scripts/managers/player_movement_manager.gd` | ... | Movement restrictions per mode |
|
||||
|
||||
[Back to top](#top)
|
||||
@@ -0,0 +1,16 @@
|
||||
# Tekton Dash Armageddon
|
||||
|
||||
<a id="top"></a>
|
||||
|
||||
- [Game Modes](./Game-Modes.-) — Full per-mode reference: Stop n Go, Tekton Doors, Candy Pump Survival, Freemode
|
||||
- [Architecture - Client](./Architecture-Client) — Godot client code structure, managers, scenes, player controller
|
||||
- [Architecture - Server](./Architecture-Server) — Nakama Lua backend topology, auth flow, wallet economy, admin roles
|
||||
- [Nakama Server API](./Nakama-Server-API) — Full per-function RPC reference with params, returns, errors
|
||||
- [Patch Release Workflow](./Patch-Release-Workflow.-) — Hot patch and binary release CI/CD pipelines
|
||||
- [Skin Creation Workflow](./Skin-Creation-Workflow.-) — Skin material authoring, catalog registration, gacha prizes
|
||||
- [Nakama Deployment](./Nakama-Deployment.-) — Push Lua updates to Nakama server
|
||||
- [SSH Setup — Linux](./SSH-Setup-Linux)
|
||||
- [SSH Setup — macOS](./SSH-Setup-macOS)
|
||||
- [SSH Setup — Windows](./SSH-Setup-Windows)
|
||||
|
||||
[Back to top](#top)
|
||||
@@ -0,0 +1,322 @@
|
||||
# Patch & Release Workflow
|
||||
|
||||
Complete guide for shipping updates to Tekton players — hot patches (`.pck`) for content changes and full binary releases for engine/platform changes.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Two automated CI pipelines handle all distribution:
|
||||
|
||||
| Pipeline | Trigger | Output | Delivery |
|
||||
|---|---|---|---|
|
||||
| **Deploy Patch** (`deploy_patch.yml`) | Manual workflow dispatch | `patch.pck` + `version.json` → `patches` branch | Gitea raw endpoint |
|
||||
| **Release** (`ci.yml`) | Git tag `v*` push | Windows/Linux/macOS `.zip` → Gitea Release | git.klud.top releases |
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
### Gitea instance
|
||||
- **URL:** https://git.klud.top
|
||||
- **API:** http://52.74.133.55:3000/api/v1
|
||||
- **Runner:** Local Docker container (`gitea-runner`) via `docker-compose`
|
||||
- **Cache volume:** `/home/dev/godot-cache` → `/cache` (rw) inside runner containers
|
||||
- **Secret:** `TEKTON_RELEASE_TOKEN` — Token from user `adtpdn` with repo write access
|
||||
|
||||
### Patch serving
|
||||
Patches served directly from Gitea's built-in raw file endpoint — no external CDN:
|
||||
- Manifest: `https://git.klud.top/danchie/tekton/raw/branch/patches/version.json`
|
||||
- PCK: `https://git.klud.top/danchie/tekton/raw/branch/patches/patch.pck`
|
||||
|
||||
Old `raw.klud.top` (gitea-pages container) retired — Gitea raw endpoint is faster, simpler, and always available.
|
||||
|
||||
### Release page
|
||||
- **URL:** https://git.klud.top/danchie/tekton/releases
|
||||
- Assets auto-uploaded by CI on tag push
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Hot Patch (content-only updates)
|
||||
|
||||
Use when: script changes, UI tweaks, balance patches, asset replacements, config changes.
|
||||
|
||||
### Step-by-step
|
||||
|
||||
**1. Write changelog**
|
||||
|
||||
Edit `CHANGELOG_DRAFT.md` — add player-facing notes under `## [NEXT]`:
|
||||
|
||||
```markdown
|
||||
## [NEXT]
|
||||
- Fixed playerboard desync in multiplayer.
|
||||
- Adjusted Gauntlet difficulty scaling.
|
||||
```
|
||||
|
||||
If `[NEXT]` is missing, add the header. Format is markdown list items without leading dash (the tool strips it). Each line becomes a bullet on the patch notes page.
|
||||
|
||||
**2. Commit to `experimental`**
|
||||
|
||||
```bash
|
||||
git add CHANGELOG_DRAFT.md
|
||||
git commit -m "docs: patch notes for next release"
|
||||
git push origin experimental
|
||||
```
|
||||
|
||||
**3. Trigger patch deploy workflow**
|
||||
|
||||
Navigate to the Actions tab:
|
||||
|
||||
```
|
||||
https://git.klud.top/danchie/tekton/actions
|
||||
```
|
||||
|
||||
Click **Deploy Patch** → **Run workflow**:
|
||||
|
||||
| Field | Example |
|
||||
|---|---|
|
||||
| **Patch version** | `2.4.3` |
|
||||
| **Release notes** | `fix: multiplayer desync, gauntlet balance` |
|
||||
|
||||
OR via API:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://52.74.133.55:3000/api/v1/repos/danchie/tekton/actions/workflows/deploy_patch.yml/dispatches" \
|
||||
-H "Authorization: token $TEKTON_RELEASE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ref":"experimental","inputs":{"version":"2.4.3","notes":"fix: multiplayer desync, gauntlet balance"}}'
|
||||
```
|
||||
|
||||
### What the CI does (deploy_patch.yml)
|
||||
|
||||
1. **Checkout** — `git clone --depth 1` from `experimental` branch (shallow = fast).
|
||||
2. **Setup Godot** — Uses cached `/cache/godot_4.7` binary. Downloads only if missing (140MB, cached forever).
|
||||
3. **Generate version.json** — Runs `tools/generate_version_json.py --skip-changelog`. Reads version from `project.godot`, bumps patch number, writes `assets/data/version.json` with the new release entry including `pck_url` pointing to Gitea raw endpoint.
|
||||
4. **Export patch PCK** — `godot --headless --export-pack "Windows Desktop" build/patch.pck`. No export templates needed — `--export-pack` only packs resources, not binaries. Output ~10-15MB.
|
||||
5. **Push to patches branch** — Force-pushes `patch.pck` + `version.json` to the `patches` branch of the repo.
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
# Check manifest
|
||||
curl -s "https://git.klud.top/danchie/tekton/raw/branch/patches/version.json"
|
||||
# Expected: latest_version matches your patch number
|
||||
|
||||
# Check pck exists
|
||||
curl -s -o /dev/null -w "%{http_code} %{size_download}B" \
|
||||
"https://git.klud.top/danchie/tekton/raw/branch/patches/patch.pck"
|
||||
# Expected: HTTP 200, size ~10-15MB
|
||||
```
|
||||
|
||||
### How players receive patches
|
||||
|
||||
1. Game boots → `GameUpdateManager` fetches `version.json` from Gitea raw endpoint.
|
||||
2. Compares `latest_version` against local version.
|
||||
3. If remote is newer → downloads `patch.pck` to `user://patch.pck`.
|
||||
4. Mounts with `ProjectSettings.load_resource_pack("user://patch.pck")`.
|
||||
5. All files in `patch.pck` override base `res://` files in memory.
|
||||
6. No files are overwritten on disk (safe rollback by deleting `patch.pck`).
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Full Binary Release (platform updates)
|
||||
|
||||
Use when: engine upgrade, native plugin change, export template update, platform-specific build fix, or any change that needs a new `.exe`/`.app`.
|
||||
|
||||
### Step-by-step
|
||||
|
||||
**1. Ensure changelog is written**
|
||||
|
||||
Same as patch step 1 — `CHANGELOG_DRAFT.md` must have `## [NEXT]` entries. The CI auto-extracts them for the release body.
|
||||
|
||||
**2. Commit and tag**
|
||||
|
||||
```bash
|
||||
# Commit all changes
|
||||
git add -A
|
||||
git commit -m "chore: bump to v2.4.3"
|
||||
|
||||
# Push to experimental
|
||||
git push origin experimental
|
||||
|
||||
# Create and push tag
|
||||
git tag v2.4.3 experimental
|
||||
git push origin v2.4.3
|
||||
```
|
||||
|
||||
**IMPORTANT:** Tag must match `v` + version format (e.g. `v2.4.3`). The CI is triggered by `v*` tags.
|
||||
|
||||
### What the CI does (ci.yml)
|
||||
|
||||
1. **Install tools** — `apt-get install curl unzip zip` (zip was missing in early runs — make sure it's present).
|
||||
2. **Checkout** — Full clone from tag (shallow not used — needs full history for changelog extraction, though `--depth 1` works too).
|
||||
3. **Setup Godot with templates** — Caches both Godot binary (140MB) and export templates (1.3GB) in `/cache/`. Templates downloaded once per runner lifetime.
|
||||
4. **Export 3 platforms:**
|
||||
- **Windows** — `godot --headless --export-release "Windows Desktop"` → zipped with `zip`.
|
||||
- **Linux/X11** — Same pattern.
|
||||
- **macOS** — Export to `.zip` directly (Godot's macOS export produces a zip).
|
||||
- **Note:** Steam DLLs copied into Windows build from `addons/godotsteam/`.
|
||||
- **Note:** `|| true` on export commands masks Godot errors (e.g. GodotSteam plugin warnings). Real failures (missing `zip`) will surface.
|
||||
5. **Extract changelog** — Parses `CHANGELOG_DRAFT.md` for the `## [version]` section matching the tag. Writes to `$CHANGELOG_BODY` env var.
|
||||
6. **Create/Update Gitea Release** — Checks if release exists for tag. Creates new one with changelog as body if missing. Updates draft release if re-run.
|
||||
7. **Upload assets** — Each `.zip` uploaded as release asset via multipart POST.
|
||||
8. **Publish** — Sets `draft:false` to make release public.
|
||||
|
||||
### Verification
|
||||
|
||||
Check the release page:
|
||||
|
||||
```
|
||||
https://git.klud.top/danchie/tekton/releases/tag/v2.4.3
|
||||
```
|
||||
|
||||
Expected: 3 assets (Windows, Linux, macOS) with correct sizes, changelog body populated, release marked as published (not draft).
|
||||
|
||||
### Cleaning duplicate assets
|
||||
|
||||
If a tag was force-pushed, multiple CI runs may upload duplicate assets to the same release:
|
||||
|
||||
```bash
|
||||
# List assets
|
||||
curl "https://git.klud.top/api/v1/repos/danchie/tekton/releases/tags/v2.4.3" \
|
||||
-H "Authorization: token $TEKTON_RELEASE_TOKEN" | jq '.assets[] | "\(.id): \(.name) \(.size/1024/1024)MiB"'
|
||||
|
||||
# Delete old duplicates (keep latest 3: Windows, Linux, macOS)
|
||||
RELEASE_ID=<id>
|
||||
curl -X DELETE "http://52.74.133.55:3000/api/v1/repos/danchie/tekton/releases/$RELEASE_ID/assets/$ASSET_ID" \
|
||||
-H "Authorization: token $TEKTON_RELEASE_TOKEN"
|
||||
```
|
||||
|
||||
### Cancelling stuck or duplicate runs
|
||||
|
||||
Gitea API cannot cancel in-progress runs. Wait for completion, then delete:
|
||||
|
||||
```bash
|
||||
# List runs for a tag
|
||||
curl "http://52.74.133.55:3000/api/v1/repos/danchie/tekton/actions/runs?page=1&limit=10" \
|
||||
-H "Authorization: token $TEKTON_RELEASE_TOKEN" | jq '.workflow_runs[] | "\(.id): \(.status) \(.conclusion) \(.display_title)"'
|
||||
|
||||
# Delete completed run
|
||||
curl -X DELETE "http://52.74.133.55:3000/api/v1/repos/danchie/tekton/actions/runs/$RUN_ID" \
|
||||
-H "Authorization: token $TEKTON_RELEASE_TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Agent-Automated Release
|
||||
|
||||
Agent (Hermes) can execute the full release flow from a single user request:
|
||||
|
||||
### Scenario: "Ship v2.4.3"
|
||||
|
||||
Agent actions:
|
||||
1. Read `CHANGELOG_DRAFT.md` — verify `[NEXT]` has entries.
|
||||
2. Check `project.godot` current version.
|
||||
3. Commit changelog to `experimental`.
|
||||
4. Create tag `v2.4.3` → push to trigger `ci.yml`.
|
||||
5. Wait for CI completion (poll every 30s, up to 30 min).
|
||||
6. If CI fails:
|
||||
- Read job logs for failure reason.
|
||||
- Fix the workflow file, commit, force-push tag.
|
||||
- Clean up duplicate assets after re-run.
|
||||
7. Verify release page has 3 assets with correct sizes.
|
||||
8. If patch deploy also needed:
|
||||
- Trigger `deploy_patch.yml` dispatch.
|
||||
- Verify `patch.pck` is served and version.json updated.
|
||||
|
||||
### Scenario: "Quick hot patch"
|
||||
|
||||
Agent actions:
|
||||
1. Check if `[NEXT]` has entries in `CHANGELOG_DRAFT.md`.
|
||||
2. If empty, ask user for changelog notes.
|
||||
3. Commit `CHANGELOG_DRAFT.md` to `experimental`.
|
||||
4. Dispatch `deploy_patch.yml` workflow.
|
||||
5. Verify patch files on Gitea raw endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `zip: command not found` in CI
|
||||
|
||||
Root cause: `ubuntu-latest` container doesn't have `zip` pre-installed. The install step must include `zip`:
|
||||
|
||||
```yaml
|
||||
- name: Install tools
|
||||
run: apt-get update -qq && apt-get install -y -qq curl unzip zip
|
||||
```
|
||||
|
||||
### Godot export fails silently (`|| true`)
|
||||
|
||||
The `|| true` on export commands means a failed Godot export still shows step as success. Check:
|
||||
- Is `godot_4.7` cached at `/cache/`?
|
||||
- Does the export preset name match exactly? E.g. `"Windows Desktop"` must match `export_presets.cfg`.
|
||||
- Is `addons/godotsteam/libgodotsteam*` present? Missing DLLs cause Godot to exit 1.
|
||||
|
||||
### Runner container can't clone repo
|
||||
|
||||
Runner uses HTTP auth with `god` username and `TEKTON_RELEASE_TOKEN` as password. If token is revoked:
|
||||
1. Generate new token from Gitea → Settings → Applications.
|
||||
2. Update secret `TEKTON_RELEASE_TOKEN` in repo Settings → Actions → Secrets.
|
||||
3. Restart runner: `docker compose -f /home/dev/gitea/docker-compose.yml restart runner`.
|
||||
|
||||
### Runner shows "permission denied" for Docker socket
|
||||
|
||||
User `dev` doesn't have Docker socket access. Commands that touch Docker must be run via `sudo` or by the root user on the VPS. The local agent can only:
|
||||
- Restart runner via systemd: `systemctl --user restart docker-runner` (if running as user service).
|
||||
- No Docker CLI commands from agent terminal.
|
||||
|
||||
### Release has duplicate assets
|
||||
|
||||
Each CI run uploads assets as new entries. To clean:
|
||||
- Get release ID from API.
|
||||
- Delete old asset IDs keeping only latest (highest IDs) for each platform.
|
||||
- Use jq or manual curl loop (see "Cleaning duplicate assets" above).
|
||||
|
||||
### Tag force-push creates redundant CI runs
|
||||
|
||||
Each push to a tag triggers `ci.yml`. Force-pushing a tag to a new commit creates another run:
|
||||
- Previous runs keep running (can't cancel via API).
|
||||
- Wait for all to finish, then delete stale ones.
|
||||
- The last run to publish sets the release state.
|
||||
|
||||
Best practice: Delete old release before force-pushing tag, or at minimum delete stale completed runs after.
|
||||
|
||||
### Patch manifest not updating
|
||||
|
||||
`generate_version_json.py --skip-changelog` only bumps version and writes `version.json`. If the version didn't change (e.g. `--skip-changelog` with no `[NEXT]` entries), the script exits with code 0 but doesn't write anything. Verify `assets/data/version.json` has the new version after CI run.
|
||||
|
||||
### `gitea-pages` (raw.klud.top) returns 404
|
||||
|
||||
gitea-pages container uses a Gitea token to read files. If the token is dead:
|
||||
- Switch to Gitea native raw endpoint: `https://git.klud.top/danchie/tekton/raw/branch/patches/...`
|
||||
- Update `MANIFEST_URL` in `generate_version_json.py` and `VERSION_MANIFEST_URL` in `game_update_manager.gd`.
|
||||
- Retire gitea-pages container entirely (not needed, Gitea has built-in raw serving).
|
||||
|
||||
---
|
||||
|
||||
## File Reference
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `.gitea/workflows/deploy_patch.yml` | Patch deploy CI — generates pck + pushes to patches branch |
|
||||
| `.gitea/workflows/ci.yml` | Full binary release CI — exports 3 platforms + creates release |
|
||||
| `tools/generate_version_json.py` | Version bumping + changelog → version.json conversion |
|
||||
| `CHANGELOG_DRAFT.md` | Human-readable changelog draft (source of truth for release notes) |
|
||||
| `assets/data/version.json` | Machine-readable manifest served to players (auto-generated) |
|
||||
| `scripts/managers/game_update_manager.gd` | Client-side update checker (reads version.json → downloads patch.pck) |
|
||||
| `project.godot` | Godot project file (config/version = source of truth for version number) |
|
||||
| `export_presets.cfg` | Export configuration for all platforms |
|
||||
| `/home/dev/gitea/docker-compose.yml` | Runner container composition (cache volume mount: `/home/dev/godot-cache:/cache`) |
|
||||
|
||||
---
|
||||
|
||||
## Key Gotchas
|
||||
|
||||
- **`zip` must be in install step** — missing zip kills Windows/Linux export. Added in run 141 — do not remove.
|
||||
- **Tag format is `vX.Y.Z`** — `ci.yml` trigger is `v*`. A tag without `v` prefix won't build.
|
||||
- **Force-push tag = new CI run** — Always expect a new run on force-push. Old run keeps running.
|
||||
- **Changelog extracted from tag version** — `## [X.Y.Z]` section in `CHANGELOG_DRAFT.md`. If section doesn't exist, release body is empty.
|
||||
- **Patch deploy skips changelog clearing** — `--skip-changelog` means `version.json` is written but `CHANGELOG_DRAFT.md` is NOT modified. Only the full `ci.yml` pipeline clears it.
|
||||
- **Cache is per-runner-host, not per-run** — Godot binary (140MB) and templates (1.3GB) download once on fresh runner container, then persist via `/cache` volume. Running `docker compose down` + `up` reuses cache if volume isn't deleted.
|
||||
- **`|| true` masks Godot export errors** — If export fails silently, check the `2>&1 | tail -5` output in CI logs. Error messages like "Cannot call method 'queue_free' on a null value" from GodotSteam are non-fatal (cosmetic plugin warnings).
|
||||
@@ -0,0 +1,257 @@
|
||||
# Skin Creation Workflow
|
||||
|
||||
<a id="top"></a>
|
||||
|
||||
How to author a new character skin, register it in the game's shop/gacha catalog, and ship it to players.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Each skin is defined in two places that must stay in sync:
|
||||
|
||||
| Layer | File | What it stores |
|
||||
|---|---|---|
|
||||
| **Client (visual)** | `scripts/managers/skin_manager.gd` | Mesh slots, material paths, override/overlay mode |
|
||||
| **Server (shop)** | `server/nakama/lua/economy.lua` | Item ID, name, category, price (gold/star) |
|
||||
|
||||
The item `id` in `economy.lua` must match the dictionary key in `skin_manager.gd` `SKIN_CATALOG` exactly -- the Godot client looks up the item ID from the wallet/inventory and applies the matching skin data.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create the Skin Material
|
||||
|
||||
Open the **Skin Shader Generator** at `res://scenes/tools/skin_shader_generator.tscn`.
|
||||
|
||||
1. Run the scene (F6).
|
||||
2. Import your base albedo and mask textures (PNG with color/alpha channels).
|
||||
3. Use the UI to visualize UV overlays and adjust color channels (Red, Green, Blue, Alpha).
|
||||
4. Export the configured material as a `.tres` file into `assets/materials/skins/` or a subfolder:
|
||||
- `assets/characters/skins/hat/`
|
||||
- `assets/characters/skins/clothing/`
|
||||
- `assets/characters/skins/gloves/`
|
||||
|
||||
**Material path conventions (Oldpop character):**
|
||||
|
||||
| Category | Example path |
|
||||
|---|---|
|
||||
| hat | `res://assets/characters/skins/hat/oldpop_mat_hat_blue.tres` |
|
||||
| costume/clothing | `res://assets/characters/skins/clothing/oldpop_mat_cloth_red_pant.tres` |
|
||||
| glove | `res://assets/characters/skins/gloves/oldpop_mat_gloves_blue.tres` |
|
||||
| accessory | `res://assets/characters/skins/accessory/` |
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Register the Skin in SkinManager (Client)
|
||||
|
||||
Open `res://scripts/managers/skin_manager.gd` and add a new entry inside `SKIN_CATALOG` (between `[BEGIN_SKIN_CATALOG]` and `[END_SKIN_CATALOG]` markers).
|
||||
|
||||
### Entry format
|
||||
|
||||
```gdscript
|
||||
"item_id": {
|
||||
"category": "head", # head | costume | glove | accessory
|
||||
"character": "Oldpop", # node name under CharacterRoot
|
||||
"slots": [
|
||||
{
|
||||
"mesh": "oldpop-hat1", # MeshInstance3D child name
|
||||
"mode": "override", # "override" | "overlay"
|
||||
"material": "res://path/to/material.tres"
|
||||
},
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Slot modes
|
||||
|
||||
- **`override`** -- `set_surface_override_material(0, mat)`. Replaces the base material entirely. Preserves the outline shader (`next_pass`) automatically.
|
||||
- **`overlay`** -- `material_overlay = mat`. Transparent layer on top of the base material. Good for costume/pant patterns.
|
||||
|
||||
### Multi-slot skins (costume example)
|
||||
|
||||
Costumes typically touch 3 meshes:
|
||||
|
||||
```gdscript
|
||||
"oldpop-grey-pant": {
|
||||
"category": "costume",
|
||||
"character": "Oldpop",
|
||||
"slots": [
|
||||
{ "mesh": "oldpop-body", "mode": "overlay", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_grey_pant.tres" },
|
||||
{ "mesh": "oldpop-bottom1", "mode": "override", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_grey_pant.tres" },
|
||||
{ "mesh": "oldpop-bottom2", "mode": "override", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_grey_pant.tres" },
|
||||
]
|
||||
},
|
||||
```
|
||||
|
||||
### Tips
|
||||
|
||||
- Leave `"material"` as `""` if the `.tres` file is not ready yet. The slot is skipped gracefully.
|
||||
- Use the **Skin Catalog Editor** (`res://scenes/tools/skin_catalog_editor.tscn`) to avoid manual edits. Click **Save & Generate** to rewrite both `skin_manager.gd` and `economy.lua`.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Register the Skin in Economy (Server)
|
||||
|
||||
Open `server/nakama/lua/economy.lua` and add a new entry to `SHOP_CATALOG_DEFS`.
|
||||
|
||||
### Catalog entry format
|
||||
|
||||
```lua
|
||||
{ id = "oldpop-blue-hat", name = "Oldpop Blue Hat", category = "head", gold = 100, star = 0, rarity = "Common", character = "Oldpop" },
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | string | Must match the `SKIN_CATALOG` key in `skin_manager.gd` exactly |
|
||||
| `name` | string | Display name shown in shop |
|
||||
| `category` | string | `head` / `costume` / `glove` / `accessory` |
|
||||
| `gold` | number | Gold coin price (0 = not sold for gold) |
|
||||
| `star` | number | Star gem price (0 = not sold for stars) |
|
||||
| `rarity` | string | `"Common"` / `"Uncommon"` / `"Rare"` -- cosmetic label only |
|
||||
| `character` | string | Character this skin belongs to (e.g. `"Oldpop"`) |
|
||||
|
||||
### Existing catalog (12 items)
|
||||
|
||||
```
|
||||
oldpop-blue-hat head 100 gold Common Oldpop
|
||||
oldpop-green-hat head 100 gold Common Oldpop
|
||||
oldpop-red-hat head 100 gold Common Oldpop
|
||||
oldpop-yellow-hat head 100 gold Common Oldpop
|
||||
oldpop-og-pant costume 0 gold Common Oldpop (free)
|
||||
oldpop-grey-pant costume 150 gold Common Oldpop
|
||||
oldpop-red-pant costume 150 gold Common Oldpop
|
||||
oldpop-yellow-pant costume 150 gold Common Oldpop
|
||||
oldpop-blue-gloves glove 75 gold Common Oldpop
|
||||
oldpop-green-gloves glove 75 gold Common Oldpop
|
||||
oldpop-red-gloves glove 75 gold Common Oldpop
|
||||
oldpop-yellow-gloves glove 75 gold Common Oldpop
|
||||
```
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## Step 4 (Optional): Add Skin as Gacha Prize
|
||||
|
||||
Gacha-only skins are registered in `server/nakama/lua/gacha.lua` inside `GACHA_DATA.real_prize_catalog`.
|
||||
|
||||
### Existing gacha skins (4 items)
|
||||
|
||||
```lua
|
||||
skin_gacha_rainbow_suit = { name = "Rainbow Suit", category = "costume", rarity = "real_prize", character = "" }
|
||||
skin_gacha_dragon_hat = { name = "Dragon Hat", category = "head", rarity = "real_prize", character = "" }
|
||||
skin_gacha_phantom_gloves = { name = "Phantom Gloves", category = "glove", rarity = "real_prize", character = "" }
|
||||
skin_gacha_neon_acc = { name = "Neon Accessory", category = "accessory", rarity = "real_prize", character = "" }
|
||||
```
|
||||
|
||||
Gacha skins also need a `skin_manager.gd` entry (same step 2 format) and a `skin_catalog_editor` entry so `SkinManager.apply_loadout()` can render them. The server catalog is optional -- gacha skins are not sold in the shop directly.
|
||||
|
||||
The gacha pulls these IDs from `GACHA_DATA.pools.real_prize`, so add your item_id there too.
|
||||
|
||||
```lua
|
||||
pools = {
|
||||
common = {"frag_common"},
|
||||
uncommon = {"frag_uncommon"},
|
||||
rare = {"frag_rare"},
|
||||
real_prize = {
|
||||
"skin_gacha_rainbow_suit",
|
||||
"skin_gacha_dragon_hat",
|
||||
"skin_gacha_phantom_gloves",
|
||||
"skin_gacha_neon_acc",
|
||||
-- add new skin here
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Deploy
|
||||
|
||||
### Hot patch (content only) -- recommended for skins
|
||||
|
||||
1. Commit changes to `experimental` branch:
|
||||
- `scripts/managers/skin_manager.gd`
|
||||
- `server/nakama/lua/economy.lua` (if shop item)
|
||||
- `server/nakama/lua/gacha.lua` (if gacha prize)
|
||||
- Material `.tres` files
|
||||
|
||||
2. Push to `experimental`.
|
||||
|
||||
3. Trigger `deploy_patch.yml` via Gitea UI workflow dispatch.
|
||||
- CI runs `--export-pack` to build `patch.pck`.
|
||||
- CI force-pushes `patch.pck` + `version.json` to `patches` branch.
|
||||
- Existing players auto-download on next boot via `GameUpdateManager`.
|
||||
|
||||
### Full binary release (if engine/templates changed)
|
||||
|
||||
Tag a version (e.g. `v2.5.0`) and push. CI builds all platform binaries and uploads to the release.
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## Full Flow Diagram
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ 1. Create Material │
|
||||
│ skin_shader_generator │
|
||||
│ ────────────────── │
|
||||
│ Export .tres file │
|
||||
└────────┬─────────────┘
|
||||
v
|
||||
┌──────────────────────────────┐
|
||||
│ 2. Register in SkinManager │
|
||||
│ skin_manager.gd │
|
||||
│ ────────────────── │
|
||||
│ Add SKIN_CATALOG entry │
|
||||
│ (mesh slots + material) │
|
||||
└────────┬─────────────────────┘
|
||||
v
|
||||
┌──────────────────────────────┐
|
||||
│ 3. Register in Economy │
|
||||
│ economy.lua │
|
||||
│ ────────────────── │
|
||||
│ Add SHOP_CATALOG_DEFS │
|
||||
│ (price, name, category) │
|
||||
└────────┬─────────────────────┘
|
||||
v (optional)
|
||||
┌──────────────────────────────┐
|
||||
│ 4. Gacha Prize │
|
||||
│ gacha.lua │
|
||||
│ ────────────────── │
|
||||
│ real_prize_catalog + pools │
|
||||
└────────┬─────────────────────┘
|
||||
v
|
||||
┌──────────────────────┐
|
||||
│ 5. Git Push & CI │
|
||||
│ deploy_patch.yml │
|
||||
│ ────────────────── │
|
||||
│ patch.pck → players │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| Skin visible in editor but not in-game | Material path wrong or `.tres` not exported | Verify `res://` path in SKIN_CATALOG, run `--export-pack` |
|
||||
| Skin purchase fails: "NotEnoughFunds" | Wallet balance insufficient | Check gold/star prices in economy.lua |
|
||||
| Skin visible on all characters | `character` field wrong | Set correct character node name |
|
||||
| Skin purchase fails: "ItemNotFound" | Item ID not in SHOP_CATALOG_DEFS | Add entry matching SKIN_CATALOG key |
|
||||
| Player downloads patch but skin missing | econmy.lua change didn't reach server | Nakama hot-reload: restart Nakama container or wait for next restart |
|
||||
| Outline shader lost on skin | next_pass not preserved | SkinManager preserves it automatically -- verify with latest `skin_manager.gd` |
|
||||
|
||||
[Back to top](#top)
|
||||