refactor: enhance test framework with automated resource tracking and scripted error capture capabilities
This commit is contained in:
@@ -1,160 +0,0 @@
|
||||
# SKILLS.md — AI Agent Workflow Guide for Tekton Dash
|
||||
|
||||
This document tells AI agents how to work on Tekton Dash tasks end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## 1. Task Source: Notion MCP
|
||||
|
||||
All tasks live on the **"TektonDash - Armageddon PR Tasks"** Notion board.
|
||||
|
||||
https://www.notion.so/danchiego-game/36433be43b29800c8422ed5bdd65671b?v=36633be43b29803891cd000c6f6e5c5f
|
||||
|
||||
### Finding Tasks
|
||||
|
||||
Should always start with this to find tasks, find the highest priority task that is not done, second is In Progress, then To Do.
|
||||
|
||||
example, query for "Gauntlet"
|
||||
```
|
||||
Use: mcp_notion-mcp-server_API-post-search
|
||||
query: "[Gauntlet]" or task name
|
||||
filter: {"property": "object", "value": "page"}
|
||||
```
|
||||
|
||||
### Reading a Task
|
||||
|
||||
Each task page has these properties:
|
||||
|
||||
| Property | Type | Purpose |
|
||||
|---|---|---|
|
||||
| **Name** | title | Task title, e.g. `[Gauntlet] #1 Game Mode Registration` |
|
||||
| **Status** | select | `To Do` → `In Progress` → `Done` |
|
||||
| **Priority** | select | `P0` (critical) / `P1` / `P2` / `P3` |
|
||||
| **Effort** | select | `S - Small` / `M - Medium` / `L - Large` / `XL - Epic` |
|
||||
| **Sprint** | select | `Alpha` / `Beta` / `Release` |
|
||||
| **ProjectType** | select | `CORE` / `CLIENT` / `SERVER` / `INFRA` |
|
||||
| **Description** | rich_text | Full task description — **read this to understand what to do** |
|
||||
| **Acceptance** | checkbox | Check when task is verified complete |
|
||||
| **DueDate** | date | Optional deadline |
|
||||
| **UnitTest** | date | Optional test completion date |
|
||||
|
||||
### Task Lifecycle
|
||||
|
||||
```
|
||||
To Do → In Progress → Done
|
||||
```
|
||||
|
||||
1. **Pick up task**: Set `Status` → `In Progress`
|
||||
2. **Do the work**: Read `Description`, implement the changes
|
||||
3. **Write unit tests**: Follow pattern in `tests/` directory
|
||||
4. **Mark complete**: Set `Status` → `Done`, check `Acceptance` ✅
|
||||
5. **Update changelog**: Add entry to `CHANGELOG_DRAFT.md` (consumer language)
|
||||
6. **Bump version**: Update `project.godot` + `export_presets.cfg`
|
||||
|
||||
```
|
||||
Use: mcp_notion-mcp-server_API-patch-page
|
||||
page_id: "<task_page_id>"
|
||||
properties: {"Status": {"select": {"name": "Done"}}, "Acceptance": {"checkbox": true}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Code Structure
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `scripts/game_mode.gd` | GameMode enum + helpers (add new modes here) |
|
||||
| `scripts/managers/` | All game mode managers (lobby, stop_n_go, portal, gauntlet) |
|
||||
| `scenes/main.gd` | Central orchestrator — init, setup, game start routing |
|
||||
| `tests/` | GUT unit tests — one file per task/feature |
|
||||
|
||||
### Adding a New Game Mode
|
||||
|
||||
1. Add enum to `scripts/game_mode.gd` → update `from_string()`, `mode_to_string()`, `get_all_modes()`, `is_restricted()`
|
||||
2. Add mode name to `LobbyManager.available_game_modes` in `lobby_manager.gd`
|
||||
3. Add arena name to `_update_available_areas()` in `lobby_manager.gd`
|
||||
4. Add manager var + init branch in `main.gd` `_init_managers()`
|
||||
5. Add setup branch in `_setup_host_game()` and `_setup_client_game()`
|
||||
6. Add start branch in `_start_game()`
|
||||
7. Add background in `_apply_arena_background()`
|
||||
|
||||
---
|
||||
|
||||
## 3. Unit Testing
|
||||
|
||||
### Pattern
|
||||
|
||||
All tests extend `GutTest` and live in `tests/`. Naming: `test_<feature>.gd`
|
||||
|
||||
```gdscript
|
||||
extends GutTest
|
||||
|
||||
func before_all():
|
||||
gut.p("=== Feature Tests [Task ID] ===")
|
||||
|
||||
func test_something():
|
||||
assert_eq(actual, expected, "Description")
|
||||
|
||||
func after_all():
|
||||
gut.p("=== Feature Tests Complete ===")
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```cmd
|
||||
run_tests.cmd # all tests
|
||||
run_tests.cmd test_gauntlet_registration # specific test
|
||||
```
|
||||
|
||||
Reports saved to `test_reports/` with timestamps.
|
||||
|
||||
---
|
||||
|
||||
## 4. Version Bumping
|
||||
|
||||
**Before bumping, check git for existing uncommitted version changes:**
|
||||
|
||||
```cmd
|
||||
git diff --cached -- project.godot CHANGELOG_DRAFT.md
|
||||
git diff -- project.godot CHANGELOG_DRAFT.md
|
||||
```
|
||||
|
||||
### If version changes already exist (staged or unstaged):
|
||||
→ **APPEND** your changelog bullet to the existing version block in `CHANGELOG_DRAFT.md`
|
||||
→ **DO NOT** bump `project.godot` or `export_presets.cfg` — you're joining an in-progress batch
|
||||
|
||||
### If NO version changes exist (clean state):
|
||||
→ **BUMP** version (increment patch: `2.3.5` → `2.3.6`)
|
||||
→ **UPDATE** all locations below
|
||||
|
||||
Version appears in **4 locations** — all must match:
|
||||
|
||||
| File | Field |
|
||||
|---|---|
|
||||
| `CHANGELOG_DRAFT.md` | `## [X.Y.Z] — YYYY-MM-DD` header |
|
||||
| `project.godot` | `config/version="X.Y.Z"` |
|
||||
| `export_presets.cfg` | `application/file_version` and `application/product_version` (per preset) |
|
||||
| `export_presets.cfg` | `export_path` filenames containing version |
|
||||
| `export_presets.cfg` | `version/name` (Android preset) |
|
||||
|
||||
### Changelog Style
|
||||
|
||||
Entries are **consumer-facing** (readable by players). No internal jargon.
|
||||
|
||||
```markdown
|
||||
## [2.3.6] — 2026-05-22
|
||||
- Added new game mode: Candy Cannon Survival
|
||||
```
|
||||
|
||||
**Bad:** "Added GAUNTLET = 3 to GameMode.Mode enum"
|
||||
**Good:** "Added new game mode: Candy Cannon Survival"
|
||||
|
||||
---
|
||||
|
||||
## 5. Key Conventions
|
||||
|
||||
- **Caveman Mode**: Be terse. No filler. Execute first, talk second.
|
||||
- **Read before edit**: Always check whole files before modifying `.gd`, `.tscn`, `.tres`, `.res` files.
|
||||
- **Notion status flow**: `To Do` → `In Progress` → `Done` (never skip steps).
|
||||
- **Test everything**: Every completed task gets a `test_<feature>.gd` in `tests/`.
|
||||
- **GUT framework**: Tests use the Godot Unit Test (GUT) addon at `addons/gut/`.
|
||||
@@ -70,6 +70,8 @@ func get_logs(params: Dictionary) -> Dictionary:
|
||||
var offset: int = maxi(0, int(params.get("offset", 0)))
|
||||
var source: String = str(params.get("source", "plugin"))
|
||||
var include_details: bool = bool(params.get("include_details", false))
|
||||
var has_since_cursor := params.has("since_cursor") and params.get("since_cursor") != null
|
||||
var since_cursor: int = maxi(0, int(params.get("since_cursor", 0)))
|
||||
if not source in VALID_LOG_SOURCES:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
@@ -82,7 +84,7 @@ func get_logs(params: Dictionary) -> Dictionary:
|
||||
"game":
|
||||
return _get_game_logs(count, offset, include_details)
|
||||
"editor":
|
||||
return _get_editor_logs(count, offset, include_details)
|
||||
return _get_editor_logs(count, offset, include_details, has_since_cursor, since_cursor)
|
||||
"all":
|
||||
return _get_all_logs(count, offset, include_details)
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable")
|
||||
@@ -134,15 +136,18 @@ func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionar
|
||||
}
|
||||
|
||||
|
||||
func _get_editor_logs(count: int, offset: int, include_details: bool) -> Dictionary:
|
||||
func _get_editor_logs(count: int, offset: int, include_details: bool, has_since_cursor: bool = false, since_cursor: int = 0) -> Dictionary:
|
||||
## Editor-process script errors (parse errors, @tool runtime errors,
|
||||
## EditorPlugin errors, push_error/push_warning). Captured by
|
||||
## editor_logger.gd via OS.add_logger and gated on Godot 4.5+; on older
|
||||
## engines the buffer can be null. Godot also sends GDScript reload
|
||||
## warnings/errors straight to the Debugger dock's Errors tab; those do
|
||||
## not flow through OS.add_logger, so merge the visible tree rows here.
|
||||
if has_since_cursor:
|
||||
return _get_editor_logs_since(count, since_cursor, include_details)
|
||||
var all_entries := _collect_editor_log_entries()
|
||||
var page := _entries_for_response(_slice_entries(all_entries, offset, count), include_details)
|
||||
var appended_total := _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
|
||||
return {
|
||||
"data": {
|
||||
"source": "editor",
|
||||
@@ -151,6 +156,45 @@ func _get_editor_logs(count: int, offset: int, include_details: bool) -> Diction
|
||||
"returned_count": page.size(),
|
||||
"offset": offset,
|
||||
"dropped_count": _editor_log_buffer.dropped_count() if _editor_log_buffer != null else 0,
|
||||
"next_cursor": appended_total,
|
||||
"appended_total": appended_total,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func _get_editor_logs_since(count: int, since_cursor: int, include_details: bool) -> Dictionary:
|
||||
## Cursor reads are defined over the monotonic editor logger ring only.
|
||||
## Visible Debugger Errors-tab rows are live UI state, not ring entries,
|
||||
## so regular offset reads still merge them while since_cursor polling
|
||||
## reports only Logger-backed entries.
|
||||
var captured := {
|
||||
"cursor": since_cursor,
|
||||
"oldest_cursor": 0,
|
||||
"next_cursor": 0,
|
||||
"appended_total": 0,
|
||||
"truncated": false,
|
||||
"has_more": false,
|
||||
"entries": [],
|
||||
}
|
||||
var dropped := 0
|
||||
if _editor_log_buffer != null:
|
||||
captured = _editor_log_buffer.get_since(since_cursor, count)
|
||||
dropped = _editor_log_buffer.dropped_count()
|
||||
var page := _entries_for_response(captured.get("entries", []), include_details)
|
||||
return {
|
||||
"data": {
|
||||
"source": "editor",
|
||||
"lines": page,
|
||||
"total_count": int(captured.get("appended_total", 0)),
|
||||
"returned_count": page.size(),
|
||||
"offset": 0,
|
||||
"dropped_count": dropped,
|
||||
"cursor": int(captured.get("cursor", since_cursor)),
|
||||
"oldest_cursor": int(captured.get("oldest_cursor", 0)),
|
||||
"next_cursor": int(captured.get("next_cursor", 0)),
|
||||
"appended_total": int(captured.get("appended_total", 0)),
|
||||
"truncated": bool(captured.get("truncated", false)),
|
||||
"has_more": bool(captured.get("has_more", false)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,6 +175,7 @@ func _create_event(event_type: String, params: Dictionary):
|
||||
ev.alt_pressed = params.get("alt", false)
|
||||
ev.shift_pressed = params.get("shift", false)
|
||||
ev.meta_pressed = params.get("meta", false)
|
||||
ev.device = -1
|
||||
return ev
|
||||
"mouse_button":
|
||||
if not params.has("button"):
|
||||
@@ -186,6 +187,7 @@ func _create_event(event_type: String, params: Dictionary):
|
||||
"mouse_button button must be > 0 (got %d). Use 1=left, 2=right, 3=middle, 4=wheel up, 5=wheel down." % button)
|
||||
var ev := InputEventMouseButton.new()
|
||||
ev.button_index = button
|
||||
ev.device = -1
|
||||
return ev
|
||||
"joy_button":
|
||||
if not params.has("button"):
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
extends RefCounted
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
const DiagnosticsCapture := preload("res://addons/godot_ai/utils/diagnostics_capture.gd")
|
||||
const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd")
|
||||
|
||||
## Handles script creation, reading, attaching, detaching, and symbol inspection.
|
||||
|
||||
@@ -50,6 +52,17 @@ func create_script(params: Dictionary) -> Dictionary:
|
||||
file.store_string(content)
|
||||
file.close()
|
||||
|
||||
var data := {
|
||||
"path": path,
|
||||
"size": content.length(),
|
||||
"committed": true,
|
||||
"import_settled": existed_before,
|
||||
"import_settle": "already_known" if existed_before else "not_waited",
|
||||
"undoable": false,
|
||||
"reason": "File system operations cannot be undone via editor undo",
|
||||
}
|
||||
_attach_gdscript_diagnostics(data, path, content)
|
||||
|
||||
# Register just this file with the editor instead of a full recursive
|
||||
# scan(). A scan() per write stacks `update_scripts_classes` /
|
||||
# `update_script_paths_documentation` WorkerThreadPool tasks under concurrent
|
||||
@@ -61,15 +74,6 @@ func create_script(params: Dictionary) -> Dictionary:
|
||||
if efs != null:
|
||||
efs.update_file(path)
|
||||
|
||||
var data := {
|
||||
"path": path,
|
||||
"size": content.length(),
|
||||
"committed": true,
|
||||
"import_settled": existed_before,
|
||||
"import_settle": "already_known" if existed_before else "not_waited",
|
||||
"undoable": false,
|
||||
"reason": "File system operations cannot be undone via editor undo",
|
||||
}
|
||||
# `.gd.uid` is the sidecar Godot generates on scan; list both so the caller
|
||||
# can rm the full set in one go.
|
||||
McpResourceIO.attach_cleanup_hint(data, existed_before, [path, path + ".uid"])
|
||||
@@ -165,6 +169,99 @@ func read_script(params: Dictionary) -> Dictionary:
|
||||
}
|
||||
|
||||
|
||||
func _attach_gdscript_diagnostics(data: Dictionary, path: String, content: String) -> void:
|
||||
var validation := _validate_gdscript_source(content)
|
||||
var diagnostics: Array = []
|
||||
var diagnostics_detail := "none"
|
||||
var diagnostics_status := "checked"
|
||||
|
||||
if not validation.get("ok", true):
|
||||
var capture := _capture_gdscript_load_diagnostics(path)
|
||||
diagnostics = capture.get("diagnostics", [])
|
||||
diagnostics_detail = capture.get("diagnostics_detail", "none")
|
||||
diagnostics_status = capture.get("diagnostics_status", "checked")
|
||||
if not validation.get("ok", true) and diagnostics.is_empty():
|
||||
diagnostics.append(_fallback_gdscript_diagnostic(path, validation.get("error_code", FAILED), content))
|
||||
diagnostics_detail = "fallback"
|
||||
data["diagnostics"] = diagnostics
|
||||
data["diagnostics_detail"] = diagnostics_detail
|
||||
data["diagnostics_scope"] = "this_file"
|
||||
data["diagnostics_status"] = diagnostics_status
|
||||
|
||||
|
||||
static func _validate_gdscript_source(content: String) -> Dictionary:
|
||||
var script := GDScript.new()
|
||||
script.source_code = content
|
||||
## Keep validation off the live cached resource: assigning resource_path to
|
||||
## this ephemeral Script can collide with loaded instances. reload() still
|
||||
## performs normal GDScript analysis, including static initializer work, so
|
||||
## this check is intentionally scoped to `.gd` writes where the editor would
|
||||
## compile the file on scan anyway.
|
||||
var err := script.reload()
|
||||
return {
|
||||
"ok": err == OK,
|
||||
"error_code": err,
|
||||
}
|
||||
|
||||
|
||||
static func _capture_gdscript_load_diagnostics(path: String) -> Dictionary:
|
||||
if not (ClassDB.class_exists("Logger") and OS.has_method("add_logger") and OS.has_method("remove_logger")):
|
||||
return _empty_diagnostics_capture()
|
||||
var logger_script := LoggerLoader.build(LoggerLoader.VALIDATION_LOGGER_PATH)
|
||||
if logger_script == null:
|
||||
return _empty_diagnostics_capture()
|
||||
var buffer := McpEditorLogBuffer.new()
|
||||
var logger = logger_script.new(buffer)
|
||||
var capture := DiagnosticsCapture.capture_this_file(buffer, path, func() -> Dictionary:
|
||||
OS.call("add_logger", logger)
|
||||
# ResourceLoader.load() reports parse failure instead of throwing, and
|
||||
# a failed GDScript parse does not execute user code; remove immediately
|
||||
# after the synchronous load to keep the private capture window tiny.
|
||||
ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
|
||||
OS.call("remove_logger", logger)
|
||||
return {}
|
||||
)
|
||||
return capture
|
||||
|
||||
|
||||
static func _empty_diagnostics_capture() -> Dictionary:
|
||||
return {
|
||||
"diagnostics": [],
|
||||
"diagnostics_detail": "none",
|
||||
"diagnostics_scope": "this_file",
|
||||
"diagnostics_status": "checked",
|
||||
}
|
||||
|
||||
|
||||
static func _fallback_gdscript_diagnostic(path: String, error_code: int, content: String) -> Dictionary:
|
||||
var line := _fallback_gdscript_error_line(content)
|
||||
return {
|
||||
"source": "editor",
|
||||
"level": "error",
|
||||
"text": "GDScript reload failed with error code %d." % error_code,
|
||||
"path": path,
|
||||
"line": line,
|
||||
"function": "GDScript::reload",
|
||||
"details": {
|
||||
"code": "gdscript_reload_failed",
|
||||
"error_code": error_code,
|
||||
"fallback_line": true,
|
||||
"source": {
|
||||
"path": path,
|
||||
"line": line,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
static func _fallback_gdscript_error_line(content: String) -> int:
|
||||
var lines := content.split("\n")
|
||||
for i in range(lines.size() - 1, -1, -1):
|
||||
if not str(lines[i]).strip_edges().is_empty():
|
||||
return i + 1
|
||||
return 1
|
||||
|
||||
|
||||
func patch_script(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var old_text: String = params.get("old_text", "")
|
||||
@@ -214,21 +311,22 @@ func patch_script(params: Dictionary) -> Dictionary:
|
||||
write.store_string(new_content)
|
||||
write.close()
|
||||
|
||||
var data := {
|
||||
"path": path,
|
||||
"replacements": replacements,
|
||||
"size": new_content.length(),
|
||||
"old_size": content.length(),
|
||||
"undoable": false,
|
||||
"reason": "File system operations cannot be undone via editor undo",
|
||||
}
|
||||
_attach_gdscript_diagnostics(data, path, new_content)
|
||||
|
||||
# Single-file register, not a full scan() — see create_script (dsarno/godot#6).
|
||||
var efs := EditorInterface.get_resource_filesystem()
|
||||
if efs != null:
|
||||
efs.update_file(path)
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"path": path,
|
||||
"replacements": replacements,
|
||||
"size": new_content.length(),
|
||||
"old_size": content.length(),
|
||||
"undoable": false,
|
||||
"reason": "File system operations cannot be undone via editor undo",
|
||||
}
|
||||
}
|
||||
return {"data": data}
|
||||
|
||||
|
||||
func attach_script(params: Dictionary) -> Dictionary:
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
name="Godot AI"
|
||||
description="MCP server and AI tools for Godot"
|
||||
author="Godot AI"
|
||||
version="2.7.3"
|
||||
version="2.7.6"
|
||||
script="plugin.gd"
|
||||
|
||||
@@ -1092,7 +1092,17 @@ func _evaluate_strong_port_occupant_proof(port: int, live: Dictionary = {}) -> D
|
||||
var record_version := str(record.get("version", ""))
|
||||
|
||||
if record_pid > 1 and record_pid != OS.get_process_id():
|
||||
if listener_pids.has(record_pid) and _pid_alive_for_proof(record_pid):
|
||||
## Brand-verify the recorded PID before trusting it as a kill target.
|
||||
## A recorded PID can outlive the server it named and be recycled by
|
||||
## the kernel for an unrelated process that happens to bind the same
|
||||
## port — without the cmdline brand gate (the same one the
|
||||
## `pidfile_listener` branch enforces) that process could be killed.
|
||||
## See #525.
|
||||
if (
|
||||
listener_pids.has(record_pid)
|
||||
and _pid_alive_for_proof(record_pid)
|
||||
and _pid_cmdline_is_godot_ai_for_proof(record_pid)
|
||||
):
|
||||
return {"proof": "managed_record", "pids": [record_pid]}
|
||||
|
||||
var legacy_targets := _legacy_pidfile_kill_targets(port, listener_pids)
|
||||
|
||||
@@ -430,7 +430,15 @@ func _current_scene_root() -> Node:
|
||||
return null
|
||||
var scene_root := tree.current_scene
|
||||
if scene_root == null and Engine.is_editor_hint():
|
||||
scene_root = EditorInterface.get_edited_scene_root()
|
||||
# Look the editor singleton up by name rather than referencing the bare
|
||||
# `EditorInterface` identifier: that identifier is compiled out of export
|
||||
# templates, so the GDScript parser rejects it ("Identifier
|
||||
# "EditorInterface" not declared in the current scope") in an exported
|
||||
# build even though `Engine.is_editor_hint()` would never run it there.
|
||||
# That parse failure stops this autoload from loading in every export.
|
||||
var editor := Engine.get_singleton(&"EditorInterface")
|
||||
if editor:
|
||||
scene_root = editor.get_edited_scene_root()
|
||||
return scene_root
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ extends RefCounted
|
||||
|
||||
const EDITOR_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/editor_logger.gd"
|
||||
const GAME_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/game_logger.gd"
|
||||
const VALIDATION_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/validation_logger.gd"
|
||||
|
||||
|
||||
## Compile a `.gdignore`'d logger script from its on-disk source. Returns the
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
@tool
|
||||
extends Logger
|
||||
|
||||
## Short-lived Logger used only for per-write validation loads.
|
||||
##
|
||||
## Unlike editor_logger.gd this deliberately has no addon feedback-loop filter:
|
||||
## the caller attaches it around one ResourceLoader.load() call, reads its
|
||||
## private buffer, and immediately removes it. The shared editor logger should
|
||||
## still drop these validation-load errors so logs_read(source="editor") stays
|
||||
## clean.
|
||||
|
||||
const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd")
|
||||
|
||||
var _buffer
|
||||
|
||||
|
||||
func _init(buffer = null) -> void:
|
||||
_buffer = buffer
|
||||
|
||||
|
||||
func _log_error(
|
||||
function: String,
|
||||
file: String,
|
||||
line: int,
|
||||
code: String,
|
||||
rationale: String,
|
||||
_editor_notify: bool,
|
||||
error_type: int,
|
||||
script_backtraces: Array,
|
||||
) -> void:
|
||||
if _buffer == null:
|
||||
return
|
||||
var resolved := _LogBacktrace.resolve_error(
|
||||
function,
|
||||
file,
|
||||
line,
|
||||
code,
|
||||
rationale,
|
||||
error_type,
|
||||
script_backtraces,
|
||||
)
|
||||
var details: Dictionary = resolved.get("details", {})
|
||||
_buffer.append(resolved.level, resolved.message, resolved.path, resolved.line, resolved.function, details)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
@tool
|
||||
extends Logger
|
||||
|
||||
## Captures GDScript runtime errors emitted while a test is running.
|
||||
##
|
||||
## Deliberately no class_name: this script is compiled dynamically by
|
||||
## script_error_capture_loader.gd only when Logger exists. It lives in a
|
||||
## `.gdignore`d folder so older Godot editor scans never parse `extends Logger`.
|
||||
##
|
||||
## Only ERROR_TYPE_SCRIPT is captured. push_error(), push_warning(), and
|
||||
## engine-internal ERR_FAIL_* checks are often valid negative-path assertions and
|
||||
## should not abort the test.
|
||||
|
||||
var _mutex := Mutex.new()
|
||||
var _capturing := false
|
||||
var _errors := PackedStringArray()
|
||||
|
||||
|
||||
func begin_capture() -> void:
|
||||
_mutex.lock()
|
||||
_capturing = true
|
||||
_errors.clear()
|
||||
_mutex.unlock()
|
||||
|
||||
|
||||
func end_capture() -> PackedStringArray:
|
||||
_mutex.lock()
|
||||
var captured := _errors.duplicate()
|
||||
_capturing = false
|
||||
_errors.clear()
|
||||
_mutex.unlock()
|
||||
return captured
|
||||
|
||||
|
||||
func _log_error(
|
||||
function: String,
|
||||
file: String,
|
||||
line: int,
|
||||
code: String,
|
||||
rationale: String,
|
||||
_editor_notify: bool,
|
||||
error_type: int,
|
||||
_script_backtraces: Array,
|
||||
) -> void:
|
||||
if error_type != ERROR_TYPE_SCRIPT:
|
||||
return
|
||||
_mutex.lock()
|
||||
if _capturing:
|
||||
var text := rationale if not rationale.is_empty() else code
|
||||
_errors.append("%s (%s:%d in %s)" % [text, file, line, function])
|
||||
_mutex.unlock()
|
||||
@@ -0,0 +1,26 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## Builds the Logger-based test script-error capture lazily.
|
||||
##
|
||||
## Logger exists only on newer Godot versions. The capture implementation lives
|
||||
## under a `.gdignore`d folder and is compiled from source only after the runner
|
||||
## verifies the Logger API is present, so older editor scans do not parse an
|
||||
## `extends Logger` file and emit red startup errors.
|
||||
|
||||
const SCRIPT_ERROR_CAPTURE_PATH := "res://addons/godot_ai/testing/loggers/script_error_capture.gd"
|
||||
|
||||
|
||||
static func build() -> Object:
|
||||
if not ClassDB.class_exists("Logger") or not OS.has_method("add_logger"):
|
||||
return null
|
||||
if not FileAccess.file_exists(SCRIPT_ERROR_CAPTURE_PATH):
|
||||
return null
|
||||
var source := FileAccess.get_file_as_string(SCRIPT_ERROR_CAPTURE_PATH)
|
||||
if source.is_empty():
|
||||
return null
|
||||
var script := GDScript.new()
|
||||
script.source_code = source
|
||||
if script.reload() != OK:
|
||||
return null
|
||||
return script.new()
|
||||
@@ -0,0 +1 @@
|
||||
uid://pmtc07go8ty4
|
||||
@@ -5,11 +5,25 @@ extends RefCounted
|
||||
## Lightweight test runner for MCP plugin tests. Discovers test_* methods
|
||||
## on McpTestSuite instances, runs them, and collects structured results.
|
||||
|
||||
const ScriptErrorCaptureLoader := preload("res://addons/godot_ai/testing/script_error_capture_loader.gd")
|
||||
|
||||
var _results: Array[Dictionary] = []
|
||||
var _last_run_ms: int = 0
|
||||
var _script_error_capture: Object = null
|
||||
var _capture_registered := false
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_PREDELETE and _capture_registered and _script_error_capture != null:
|
||||
OS.call("remove_logger", _script_error_capture)
|
||||
_capture_registered = false
|
||||
|
||||
|
||||
func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filter: String = "") -> void:
|
||||
var owns_capture := not _capture_registered
|
||||
if owns_capture:
|
||||
_register_capture()
|
||||
|
||||
var name := suite.suite_name()
|
||||
var methods := _get_test_methods(suite)
|
||||
var exclusions := _parse_exclusions(exclude_test_filter)
|
||||
@@ -29,9 +43,12 @@ func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filte
|
||||
continue
|
||||
|
||||
suite._reset()
|
||||
_begin_script_error_capture()
|
||||
suite.setup()
|
||||
suite.call(method_name)
|
||||
suite.teardown()
|
||||
var script_errors := suite._unexpected_script_errors(_end_script_error_capture())
|
||||
suite._free_tracked()
|
||||
|
||||
## Issue #19 defence: free any `_McpTest*` nodes the test created, even
|
||||
## nested ones. If the scene gets auto-saved mid-test while one of these
|
||||
@@ -39,10 +56,23 @@ func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filte
|
||||
## with a "missing dependency" error. Runs after every test, not just at
|
||||
## suite boundaries, so a test that fails mid-flow can't leave a trap
|
||||
## for the next test or for scene autosave.
|
||||
var scene_root_for_cleanup := EditorInterface.get_edited_scene_root()
|
||||
var scene_root_for_cleanup := _edited_scene_root()
|
||||
if scene_root_for_cleanup != null and scene_root_for_cleanup.is_inside_tree():
|
||||
_free_mcp_test_nodes_recursive(scene_root_for_cleanup)
|
||||
|
||||
if not script_errors.is_empty():
|
||||
var abort_message := "Aborted by SCRIPT ERROR: %s" % "; ".join(script_errors)
|
||||
if suite._failed:
|
||||
abort_message += " (after assertion failure: %s)" % suite._message
|
||||
_results.append({
|
||||
"suite": name,
|
||||
"test": method_name,
|
||||
"passed": false,
|
||||
"message": abort_message,
|
||||
"assertion_count": suite._assertion_count,
|
||||
})
|
||||
continue
|
||||
|
||||
if suite._skipped:
|
||||
_results.append({
|
||||
"suite": name,
|
||||
@@ -70,6 +100,9 @@ func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filte
|
||||
"assertion_count": suite._assertion_count,
|
||||
})
|
||||
|
||||
if owns_capture:
|
||||
_unregister_capture()
|
||||
|
||||
|
||||
func run_suites(suites: Array, suite_filter: String = "", test_filter: String = "", ctx: Dictionary = {}, verbose: bool = false, exclude_test_filter: String = "") -> Dictionary:
|
||||
_results.clear()
|
||||
@@ -83,12 +116,17 @@ func run_suites(suites: Array, suite_filter: String = "", test_filter: String =
|
||||
var _prev_console_echo := McpLogBuffer.console_echo
|
||||
McpLogBuffer.console_echo = false
|
||||
|
||||
## If a prior run was interrupted after registering the logger but before
|
||||
## normal teardown, remove that stale registration before starting fresh.
|
||||
_unregister_capture()
|
||||
_register_capture()
|
||||
|
||||
for suite: McpTestSuite in suites:
|
||||
if not suite_filter.is_empty() and suite.suite_name() != suite_filter:
|
||||
continue
|
||||
|
||||
## Snapshot scene children before the suite so we can clean up leaks.
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
var scene_root := _edited_scene_root()
|
||||
var before_children: Array[Node] = []
|
||||
if scene_root != null:
|
||||
before_children = _get_children_snapshot(scene_root)
|
||||
@@ -119,6 +157,7 @@ func run_suites(suites: Array, suite_filter: String = "", test_filter: String =
|
||||
else:
|
||||
run_suite(suite, test_filter, exclude_test_filter)
|
||||
suite.suite_teardown()
|
||||
suite._free_tracked()
|
||||
|
||||
## Remove any nodes the suite left behind (failed undo, missing cleanup).
|
||||
if scene_root != null and scene_root.is_inside_tree():
|
||||
@@ -126,9 +165,48 @@ func run_suites(suites: Array, suite_filter: String = "", test_filter: String =
|
||||
|
||||
_last_run_ms = Time.get_ticks_msec() - start
|
||||
McpLogBuffer.console_echo = _prev_console_echo
|
||||
_unregister_capture()
|
||||
return get_results(verbose)
|
||||
|
||||
|
||||
func _register_capture() -> void:
|
||||
if _capture_registered:
|
||||
return
|
||||
if _script_error_capture == null:
|
||||
_script_error_capture = ScriptErrorCaptureLoader.build()
|
||||
if _script_error_capture == null:
|
||||
return
|
||||
OS.call("add_logger", _script_error_capture)
|
||||
_capture_registered = true
|
||||
|
||||
|
||||
func _unregister_capture() -> void:
|
||||
if not _capture_registered:
|
||||
return
|
||||
if _script_error_capture == null:
|
||||
_capture_registered = false
|
||||
return
|
||||
OS.call("remove_logger", _script_error_capture)
|
||||
_capture_registered = false
|
||||
|
||||
|
||||
func _begin_script_error_capture() -> void:
|
||||
if _script_error_capture != null and _capture_registered:
|
||||
_script_error_capture.call("begin_capture")
|
||||
|
||||
|
||||
func _end_script_error_capture() -> PackedStringArray:
|
||||
if _script_error_capture == null or not _capture_registered:
|
||||
return PackedStringArray()
|
||||
return _script_error_capture.call("end_capture") as PackedStringArray
|
||||
|
||||
|
||||
static func _edited_scene_root() -> Node:
|
||||
if not Engine.is_editor_hint():
|
||||
return null
|
||||
return EditorInterface.get_edited_scene_root()
|
||||
|
||||
|
||||
func get_results(verbose: bool = false) -> Dictionary:
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
|
||||
@@ -31,6 +31,34 @@ func suite_teardown() -> void:
|
||||
pass
|
||||
|
||||
|
||||
# ----- tracked allocations (freed by the runner after each test) -----
|
||||
|
||||
var _tracked_objects: Array[Object] = []
|
||||
|
||||
|
||||
## Register a manually-managed Object (plain Object or out-of-tree Node) so
|
||||
## the runner frees it after the current test. RefCounted instances are
|
||||
## accepted but ignored because they manage their own lifetime.
|
||||
func track(obj: Object) -> Object:
|
||||
if obj != null and not obj is RefCounted:
|
||||
_tracked_objects.append(obj)
|
||||
return obj
|
||||
|
||||
|
||||
## Free everything registered via track(). Called by the runner after each
|
||||
## test's teardown() and again after suite_teardown().
|
||||
func _free_tracked() -> void:
|
||||
for obj in _tracked_objects:
|
||||
if not is_instance_valid(obj) or (obj is Node and obj.is_queued_for_deletion()):
|
||||
continue
|
||||
if obj is Node:
|
||||
var parent := (obj as Node).get_parent()
|
||||
if parent != null:
|
||||
parent.remove_child(obj)
|
||||
obj.free()
|
||||
_tracked_objects.clear()
|
||||
|
||||
|
||||
# ----- assertion state (managed by McpTestRunner) -----
|
||||
|
||||
var _failed: bool = false
|
||||
@@ -38,6 +66,7 @@ var _message: String = ""
|
||||
var _assertion_count: int = 0
|
||||
var _skipped: bool = false
|
||||
var _skip_reason: String = ""
|
||||
var _expected_script_error_substrings: Array[String] = []
|
||||
|
||||
# ----- suite-level state (managed by McpTestRunner) -----
|
||||
|
||||
@@ -53,6 +82,7 @@ func _reset() -> void:
|
||||
_assertion_count = 0
|
||||
_skipped = false
|
||||
_skip_reason = ""
|
||||
_expected_script_error_substrings.clear()
|
||||
|
||||
|
||||
func _reset_suite_state() -> void:
|
||||
@@ -121,6 +151,29 @@ func skip_on_godot_lt(min_version: String, reason: String = "") -> bool:
|
||||
return false
|
||||
|
||||
|
||||
## Allow one captured SCRIPT ERROR whose text contains `substring`.
|
||||
## Use only for negative-path tests that intentionally compile or execute
|
||||
## invalid GDScript and assert on the resulting diagnostics.
|
||||
func expect_script_error_containing(substring: String) -> void:
|
||||
_expected_script_error_substrings.append(substring)
|
||||
|
||||
|
||||
func _unexpected_script_errors(captured: PackedStringArray) -> PackedStringArray:
|
||||
var unexpected := PackedStringArray()
|
||||
var remaining := _expected_script_error_substrings.duplicate()
|
||||
for error in captured:
|
||||
var matched_index := -1
|
||||
for i in range(remaining.size()):
|
||||
if error.find(remaining[i]) != -1:
|
||||
matched_index = i
|
||||
break
|
||||
if matched_index == -1:
|
||||
unexpected.append(error)
|
||||
else:
|
||||
remaining.remove_at(matched_index)
|
||||
return unexpected
|
||||
|
||||
|
||||
## Trigger an undo against whichever history (scene or global) holds the most
|
||||
## recent action. `EditorUndoRedoManager` in Godot 4.x doesn't expose `.undo()`
|
||||
## directly — you resolve the history's underlying UndoRedo and call it there.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
@tool
|
||||
class_name McpDiagnosticsCapture
|
||||
extends RefCounted
|
||||
|
||||
## Small helper for scoped validation-log capture windows. Callers snapshot a
|
||||
## private log cursor, perform a deliberate validation action, then only report
|
||||
## new diagnostics whose original source location is the target file.
|
||||
|
||||
|
||||
static func capture_this_file(log_buffer: McpEditorLogBuffer, target_path: String, action: Callable) -> Dictionary:
|
||||
var cursor := 0
|
||||
if log_buffer != null:
|
||||
cursor = log_buffer.appended_total()
|
||||
|
||||
var action_result = action.call()
|
||||
var diagnostics: Array[Dictionary] = []
|
||||
var truncated := false
|
||||
|
||||
if log_buffer != null:
|
||||
var captured: Dictionary = log_buffer.get_since(cursor)
|
||||
truncated = captured.get("truncated", false)
|
||||
diagnostics = _diagnostics_for_target(captured.get("entries", []), target_path)
|
||||
|
||||
return {
|
||||
"action": action_result if action_result is Dictionary else {},
|
||||
"diagnostics": diagnostics,
|
||||
"diagnostics_detail": "log_capture" if not diagnostics.is_empty() else "none",
|
||||
"diagnostics_scope": "this_file",
|
||||
"diagnostics_status": "partial" if truncated else "checked",
|
||||
}
|
||||
|
||||
|
||||
static func _diagnostics_for_target(entries: Array, target_path: String) -> Array[Dictionary]:
|
||||
var out: Array[Dictionary] = []
|
||||
for raw_entry in entries:
|
||||
if not raw_entry is Dictionary:
|
||||
continue
|
||||
var entry: Dictionary = raw_entry
|
||||
if not _entry_matches_target(entry, target_path):
|
||||
continue
|
||||
out.append(_normalize_entry(entry, target_path))
|
||||
return out
|
||||
|
||||
|
||||
static func _entry_matches_target(entry: Dictionary, target_path: String) -> bool:
|
||||
var source := _source_location(entry)
|
||||
return str(source.get("path", "")) == target_path
|
||||
|
||||
|
||||
static func _normalize_entry(entry: Dictionary, target_path: String) -> Dictionary:
|
||||
var normalized := entry.duplicate(true)
|
||||
var source := _source_location(entry)
|
||||
normalized["path"] = str(source.get("path", target_path))
|
||||
normalized["line"] = int(source.get("line", normalized.get("line", 0)))
|
||||
normalized["function"] = str(source.get("function", normalized.get("function", "")))
|
||||
if normalized.has("details") and normalized.details is Dictionary:
|
||||
normalized["details"] = normalized.details.duplicate(true)
|
||||
return normalized
|
||||
|
||||
|
||||
static func _source_location(entry: Dictionary) -> Dictionary:
|
||||
if entry.get("details") is Dictionary:
|
||||
var details: Dictionary = entry.details
|
||||
if details.get("source") is Dictionary:
|
||||
return details.source
|
||||
return {}
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3npxxpuobbc2
|
||||
@@ -34,6 +34,21 @@ const UPDATE_TEMP_DIR := "user://godot_ai_update/"
|
||||
const UPDATE_TEMP_ZIP := "user://godot_ai_update/update.zip"
|
||||
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
|
||||
|
||||
## Hosts the self-update download is allowed to come from. The download URL
|
||||
## is taken verbatim from the GitHub Releases API's `browser_download_url`,
|
||||
## so before fetching we pin it to https on a GitHub-owned host — a tampered
|
||||
## or unexpected API response can't then point the in-editor updater at an
|
||||
## arbitrary origin. (HTTPRequest follows the github.com -> githubusercontent
|
||||
## redirect internally; this validates the entry point. Release-side checksum
|
||||
## / provenance verification of the downloaded bytes remains tracked in #523.)
|
||||
const _TRUSTED_DOWNLOAD_HOSTS := [
|
||||
"github.com",
|
||||
"www.github.com",
|
||||
"api.github.com",
|
||||
"objects.githubusercontent.com",
|
||||
"release-assets.githubusercontent.com",
|
||||
]
|
||||
|
||||
## Emitted after `check_for_updates()` resolves a newer remote version.
|
||||
## Payload mirrors the Dictionary returned by `parse_releases_response`:
|
||||
## {has_update, version, forced, label_text, download_url}
|
||||
@@ -53,7 +68,12 @@ var _dock
|
||||
|
||||
var _http_request: HTTPRequest
|
||||
var _download_request: HTTPRequest
|
||||
var _verify_request: HTTPRequest
|
||||
var _latest_download_url: String = ""
|
||||
## URL of the `godot-ai-plugin.zip.sha256` sidecar asset, when the release
|
||||
## ships one. Used to verify the downloaded archive's integrity before extract
|
||||
## (#523). Empty for older releases published without a checksum sidecar.
|
||||
var _latest_checksum_url: String = ""
|
||||
|
||||
## Set for the duration of `_install_zip` — extract-overwrite of plugin
|
||||
## scripts on disk would crash any worker mid-`GDScriptFunction::call`
|
||||
@@ -101,6 +121,7 @@ func cancel_check() -> void:
|
||||
## flips so a fresh check paints over a clean banner.
|
||||
func clear_pending_download() -> void:
|
||||
_latest_download_url = ""
|
||||
_latest_checksum_url = ""
|
||||
|
||||
|
||||
## True when the running Godot can self-update in place. Godot < 4.4 takes
|
||||
@@ -166,6 +187,22 @@ func start_install() -> void:
|
||||
OS.shell_open(RELEASES_PAGE)
|
||||
return
|
||||
|
||||
## Pin the resolved asset URL to https on a GitHub host before fetching.
|
||||
## Fall back to the release page (a user-driven browser download) rather
|
||||
## than pulling an executable plugin payload from an unexpected origin.
|
||||
## See #523.
|
||||
if not _is_trusted_download_url(_latest_download_url):
|
||||
push_error(
|
||||
"MCP | refusing self-update download from untrusted URL: %s"
|
||||
% _latest_download_url
|
||||
)
|
||||
OS.shell_open(RELEASES_PAGE)
|
||||
install_state_changed.emit({
|
||||
"button_text": "Update via download page",
|
||||
"button_disabled": false,
|
||||
})
|
||||
return
|
||||
|
||||
install_state_changed.emit({
|
||||
"button_text": "Downloading...",
|
||||
"button_disabled": true,
|
||||
@@ -212,6 +249,7 @@ func is_install_in_flight() -> bool:
|
||||
## forced: bool ## mode_override() == "user" (banner-only hint)
|
||||
## label_text: String ## "Update available: vX.Y.Z" + " (forced)"
|
||||
## download_url: String ## matching `godot-ai-plugin.zip` asset URL
|
||||
## checksum_url: String ## `godot-ai-plugin.zip.sha256` asset URL ("" if absent)
|
||||
##
|
||||
## Static so tests drive it without instancing the manager.
|
||||
static func parse_releases_response(
|
||||
@@ -223,6 +261,7 @@ static func parse_releases_response(
|
||||
"forced": false,
|
||||
"label_text": "",
|
||||
"download_url": "",
|
||||
"checksum_url": "",
|
||||
}
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
|
||||
return out
|
||||
@@ -239,12 +278,15 @@ static func parse_releases_response(
|
||||
return out
|
||||
|
||||
var url := ""
|
||||
var checksum_url := ""
|
||||
var assets: Array = json.get("assets", [])
|
||||
for asset in assets:
|
||||
var asset_dict: Dictionary = asset
|
||||
if String(asset_dict.get("name", "")) == "godot-ai-plugin.zip":
|
||||
var asset_name := String(asset_dict.get("name", ""))
|
||||
if asset_name == "godot-ai-plugin.zip":
|
||||
url = String(asset_dict.get("browser_download_url", ""))
|
||||
break
|
||||
elif asset_name == "godot-ai-plugin.zip.sha256":
|
||||
checksum_url = String(asset_dict.get("browser_download_url", ""))
|
||||
|
||||
var forced := ClientConfigurator.mode_override() == "user"
|
||||
var label_text := "Update available: v%s" % remote_version
|
||||
@@ -258,9 +300,35 @@ static func parse_releases_response(
|
||||
out["forced"] = forced
|
||||
out["label_text"] = label_text
|
||||
out["download_url"] = url
|
||||
out["checksum_url"] = checksum_url
|
||||
return out
|
||||
|
||||
|
||||
## True only for an `https://` URL whose host is one of
|
||||
## `_TRUSTED_DOWNLOAD_HOSTS`. Parses the authority by hand (GDScript has no
|
||||
## URL parser): strips userinfo via the LAST `@` so a spoof like
|
||||
## `https://github.com@evil.com/...` resolves to `evil.com` (rejected), and
|
||||
## strips any `:port`. Static so the guard is unit-testable without
|
||||
## instancing the manager.
|
||||
static func _is_trusted_download_url(url: String) -> bool:
|
||||
const SCHEME := "https://"
|
||||
if not url.begins_with(SCHEME):
|
||||
return false
|
||||
var rest := url.substr(SCHEME.length())
|
||||
var authority := rest
|
||||
var slash := rest.find("/")
|
||||
if slash >= 0:
|
||||
authority = rest.substr(0, slash)
|
||||
## Host is everything after the LAST '@' (userinfo precedes it).
|
||||
var at := authority.rfind("@")
|
||||
if at >= 0:
|
||||
authority = authority.substr(at + 1)
|
||||
var colon := authority.find(":")
|
||||
if colon >= 0:
|
||||
authority = authority.substr(0, colon)
|
||||
return authority.to_lower() in _TRUSTED_DOWNLOAD_HOSTS
|
||||
|
||||
|
||||
static func _is_newer(remote: String, local: String) -> bool:
|
||||
var r := remote.split(".")
|
||||
var l := local.split(".")
|
||||
@@ -286,6 +354,7 @@ func _on_update_check_completed(
|
||||
if not bool(parsed.get("has_update", false)):
|
||||
return
|
||||
_latest_download_url = String(parsed.get("download_url", ""))
|
||||
_latest_checksum_url = String(parsed.get("checksum_url", ""))
|
||||
update_check_completed.emit(parsed)
|
||||
## On engines that can't self-update (Godot < 4.4, #475), surface the
|
||||
## full manual-update guidance AND relabel the button up-front — before
|
||||
@@ -315,9 +384,117 @@ func _on_download_completed(
|
||||
})
|
||||
return
|
||||
|
||||
# Deferred so the HTTPRequest callback returns before the next step starts.
|
||||
_verify_then_install.call_deferred()
|
||||
|
||||
|
||||
# ---- Integrity verification (#523) -------------------------------------
|
||||
|
||||
## Gate the extract on a SHA-256 match against the release's checksum sidecar.
|
||||
## TLS + host pinning already constrain where the bytes came from; this
|
||||
## verifies the bytes themselves so a tampered asset (or a compromised CDN
|
||||
## object) can't be installed over live plugin code. Releases published
|
||||
## without a `.sha256` sidecar (older versions) install without this check —
|
||||
## verify-if-present rather than hard-fail, so existing releases stay
|
||||
## updatable; the host pin still applies to the download itself.
|
||||
func _verify_then_install() -> void:
|
||||
if _latest_checksum_url.is_empty():
|
||||
print("MCP | no checksum published for this release; skipping integrity verification")
|
||||
install_state_changed.emit({"button_text": "Installing..."})
|
||||
_install_zip()
|
||||
return
|
||||
|
||||
## A present-but-untrusted checksum URL is a tamper signal, not a
|
||||
## backward-compat case — refuse rather than silently skip.
|
||||
if not _is_trusted_download_url(_latest_checksum_url):
|
||||
_fail_verification("checksum URL is not a trusted GitHub host")
|
||||
return
|
||||
|
||||
install_state_changed.emit({"button_text": "Verifying..."})
|
||||
if _verify_request != null:
|
||||
_verify_request.queue_free()
|
||||
_verify_request = HTTPRequest.new()
|
||||
_verify_request.max_redirects = 10
|
||||
_verify_request.request_completed.connect(_on_checksum_completed)
|
||||
add_child(_verify_request)
|
||||
var err := _verify_request.request(_latest_checksum_url)
|
||||
if err != OK:
|
||||
_verify_request.queue_free()
|
||||
_verify_request = null
|
||||
_fail_verification("could not request checksum (error %d)" % err)
|
||||
|
||||
|
||||
func _on_checksum_completed(
|
||||
result: int,
|
||||
response_code: int,
|
||||
_headers: PackedStringArray,
|
||||
body: PackedByteArray
|
||||
) -> void:
|
||||
if _verify_request != null:
|
||||
_verify_request.queue_free()
|
||||
_verify_request = null
|
||||
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
|
||||
_fail_verification("checksum download failed (result=%d code=%d)" % [result, response_code])
|
||||
return
|
||||
|
||||
var expected := _parse_sha256_digest(body.get_string_from_utf8())
|
||||
if expected.is_empty():
|
||||
_fail_verification("malformed checksum file")
|
||||
return
|
||||
|
||||
var zip_path := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)
|
||||
var actual := FileAccess.get_sha256(zip_path).to_lower()
|
||||
if actual.is_empty():
|
||||
_fail_verification("could not hash the downloaded archive")
|
||||
return
|
||||
if actual != expected:
|
||||
_fail_verification(
|
||||
"checksum mismatch (expected %s…, got %s…)"
|
||||
% [expected.substr(0, 12), actual.substr(0, 12)]
|
||||
)
|
||||
return
|
||||
|
||||
print("MCP | self-update checksum verified (sha256 %s)" % actual)
|
||||
install_state_changed.emit({"button_text": "Installing..."})
|
||||
# Deferred so the HTTPRequest callback returns before the extract starts.
|
||||
_install_zip.call_deferred()
|
||||
_install_zip()
|
||||
|
||||
|
||||
## Surface an integrity-check failure and drop the staged zip so the bad
|
||||
## bytes can never reach the extract path. Keeps the button enabled for retry.
|
||||
func _fail_verification(reason: String) -> void:
|
||||
push_error(
|
||||
"MCP | self-update integrity check failed: %s. The download was not installed."
|
||||
% reason
|
||||
)
|
||||
print("MCP | self-update aborted (integrity): %s" % reason)
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP))
|
||||
install_state_changed.emit({
|
||||
"button_text": "Verification failed — retry",
|
||||
"button_disabled": false,
|
||||
})
|
||||
|
||||
|
||||
## Extract the hex digest from a `sha256sum`-style file ("<hex> <name>") or a
|
||||
## bare digest line. Returns lowercase 64-char hex, or "" if the content isn't
|
||||
## a valid SHA-256 digest. Static so it's unit-testable. See #523.
|
||||
static func _parse_sha256_digest(text: String) -> String:
|
||||
var trimmed := text.strip_edges()
|
||||
if trimmed.is_empty():
|
||||
return ""
|
||||
## First whitespace-delimited token; `sha256sum` separates digest and
|
||||
## filename with two spaces, so allow_empty=false collapses the run.
|
||||
var tokens := trimmed.split(" ", false)
|
||||
if tokens.is_empty():
|
||||
return ""
|
||||
var digest := String(tokens[0]).strip_edges().to_lower()
|
||||
if digest.length() != 64:
|
||||
return ""
|
||||
for i in digest.length():
|
||||
var c := digest[i]
|
||||
if not ((c >= "0" and c <= "9") or (c >= "a" and c <= "f")):
|
||||
return ""
|
||||
return digest
|
||||
|
||||
|
||||
# ---- Install orchestration ---------------------------------------------
|
||||
@@ -382,16 +559,43 @@ func _install_zip_inline(version: Dictionary) -> void:
|
||||
for file_path in files:
|
||||
if not file_path.begins_with("addons/godot_ai/"):
|
||||
continue
|
||||
## Skip zip dir entries; parent dirs are created from each validated
|
||||
## file's base dir below — the same shape the runner uses. Creating a
|
||||
## dir from an unvalidated entry would itself be a traversal hole.
|
||||
if file_path.ends_with("/"):
|
||||
DirAccess.make_dir_recursive_absolute(install_base.path_join(file_path))
|
||||
else:
|
||||
var dir := file_path.get_base_dir()
|
||||
DirAccess.make_dir_recursive_absolute(install_base.path_join(dir))
|
||||
var content := reader.read_file(file_path)
|
||||
var f := FileAccess.open(install_base.path_join(file_path), FileAccess.WRITE)
|
||||
if f != null:
|
||||
f.store_buffer(content)
|
||||
f.close()
|
||||
continue
|
||||
## Reject path-traversal / absolute / backslash entries BEFORE any
|
||||
## path_join + write. The modern runner enforces this via
|
||||
## `update_reload_runner.gd::_is_safe_zip_addon_file`; the pre-4.4
|
||||
## inline path used to gate only on the `addons/godot_ai/` prefix, so
|
||||
## `addons/godot_ai/../../evil.gd` escaped the addon dir. This guard
|
||||
## closes that gap so the weaker path runs the same checks. See #522.
|
||||
if not _is_safe_zip_addon_file(file_path):
|
||||
_abort_inline_install(reader, "unsafe zip path: %s" % file_path)
|
||||
return
|
||||
var dir := file_path.get_base_dir()
|
||||
DirAccess.make_dir_recursive_absolute(install_base.path_join(dir))
|
||||
var content := reader.read_file(file_path)
|
||||
var target := install_base.path_join(file_path)
|
||||
var f := FileAccess.open(target, FileAccess.WRITE)
|
||||
## Unlike the runner (tmp+rename+per-file backup+rollback), this pre-4.4
|
||||
## path writes directly over live files and can't roll back. It used to
|
||||
## skip a null open and ignore store_buffer errors silently, leaving a
|
||||
## partially-overwritten addons tree while still telling the user to
|
||||
## restart onto it. Check both error surfaces and abort loudly instead.
|
||||
## See #524.
|
||||
if f == null:
|
||||
_abort_inline_install(
|
||||
reader,
|
||||
"could not open %s for write (error %d)" % [target, FileAccess.get_open_error()],
|
||||
)
|
||||
return
|
||||
f.store_buffer(content)
|
||||
var write_error := f.get_error()
|
||||
f.close()
|
||||
if write_error != OK:
|
||||
_abort_inline_install(reader, "write error %d for %s" % [write_error, target])
|
||||
return
|
||||
|
||||
reader.close()
|
||||
|
||||
@@ -428,6 +632,47 @@ func _install_zip_inline(version: Dictionary) -> void:
|
||||
})
|
||||
|
||||
|
||||
## Abort the inline (pre-4.4) extract on a path-safety or write failure.
|
||||
## Closes the ZIP reader, drops the in-flight gate so dock spawn paths
|
||||
## un-block, and surfaces the failure loudly: this path has no rollback, so
|
||||
## the addons tree may be partially overwritten and the user must reinstall
|
||||
## from the download page rather than relaunch onto a half-written plugin.
|
||||
## See #522 / #524.
|
||||
func _abort_inline_install(reader: ZIPReader, reason: String) -> void:
|
||||
reader.close()
|
||||
_install_in_flight = false
|
||||
push_error(
|
||||
"MCP | self-update extract failed: %s. addons/godot_ai/ may be"
|
||||
% reason
|
||||
+ " partially updated — reinstall the plugin from the download page"
|
||||
+ " before relaunching."
|
||||
)
|
||||
print("MCP | self-update extract aborted: %s" % reason)
|
||||
install_state_changed.emit({
|
||||
"button_text": "Extract failed — reinstall",
|
||||
"button_disabled": false,
|
||||
})
|
||||
|
||||
|
||||
## Mirror of `update_reload_runner.gd::_is_safe_zip_addon_file`. Rejects any
|
||||
## entry that could escape `addons/godot_ai/` — absolute paths, backslashes,
|
||||
## and `.`/`..`/empty path segments — before it reaches a `path_join` + write
|
||||
## on the inline (pre-4.4) extract path, which has no rollback. Static so the
|
||||
## guard is unit-testable without instancing the manager. See #522.
|
||||
static func _is_safe_zip_addon_file(file_path: String) -> bool:
|
||||
if file_path.is_absolute_path() or file_path.contains("\\"):
|
||||
return false
|
||||
if not file_path.begins_with("addons/godot_ai/"):
|
||||
return false
|
||||
var rel_path := file_path.trim_prefix("addons/godot_ai/")
|
||||
if rel_path.is_empty() or rel_path.ends_with("/"):
|
||||
return false
|
||||
for segment in rel_path.split("/", true):
|
||||
if segment.is_empty() or segment == "." or segment == "..":
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _on_filesystem_scanned_for_update() -> void:
|
||||
install_state_changed.emit({"button_text": "Reloading..."})
|
||||
_reload_after_update.call_deferred()
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
/* Build fancy Adaptive Card report with icons and status */
|
||||
function getTitle(item) {
|
||||
if (item.json.Name && item.json.Name.title && Array.isArray(item.json.Name.title) && item.json.Name.title.length > 0) {
|
||||
return item.json.Name.title[0].plain_text;
|
||||
}
|
||||
if (item.json.title && Array.isArray(item.json.title) && item.json.title.length > 0) {
|
||||
return item.json.title[0].plain_text;
|
||||
}
|
||||
return "Untitled";
|
||||
}
|
||||
|
||||
const items = $input.all();
|
||||
const taskBlocks = [];
|
||||
|
||||
if (items.length === 0) {
|
||||
taskBlocks.push({
|
||||
type: 'TextBlock',
|
||||
text: 'No tasks were completed today. 💤',
|
||||
color: 'warning',
|
||||
weight: 'Bolder'
|
||||
});
|
||||
} else {
|
||||
items.forEach((item, index) => {
|
||||
const projectType = item.json.ProjectType || '';
|
||||
const priority = item.json.Priority || '';
|
||||
const taskName = getTitle(item);
|
||||
const description = item.json.Description || 'No description provided.';
|
||||
const status = item.json.Status || '';
|
||||
|
||||
const prefix = [projectType, priority].filter(Boolean).join('-');
|
||||
const title = prefix ? `[${prefix}]: ${taskName}` : taskName;
|
||||
|
||||
// Determine status icon and color
|
||||
let statusIcon = 'https://cdn-icons-png.flaticon.com/512/190/190411.png';
|
||||
let statusColor = 'Good';
|
||||
let statusText = 'DONE';
|
||||
|
||||
if (status === 'In Progress') {
|
||||
statusIcon = 'https://cdn-icons-png.flaticon.com/512/992/992697.png';
|
||||
statusColor = 'Accent';
|
||||
statusText = 'IN PROGRESS';
|
||||
} else if (status === 'Pending' || status === '') {
|
||||
statusIcon = 'https://cdn-icons-png.flaticon.com/512/1828/1828645.png';
|
||||
statusColor = 'Default';
|
||||
statusText = 'PENDING';
|
||||
}
|
||||
|
||||
taskBlocks.push({
|
||||
type: 'ColumnSet',
|
||||
spacing: 'Medium',
|
||||
separator: true,
|
||||
columns: [
|
||||
{
|
||||
type: 'Column',
|
||||
width: 'auto',
|
||||
verticalContentAlignment: 'Center',
|
||||
items: [
|
||||
{
|
||||
type: 'Image',
|
||||
url: statusIcon,
|
||||
width: '24px',
|
||||
altText: statusText
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'Column',
|
||||
width: 'stretch',
|
||||
items: [
|
||||
{
|
||||
type: 'TextBlock',
|
||||
text: title,
|
||||
size: 'Medium',
|
||||
weight: 'Bolder',
|
||||
color: statusColor,
|
||||
wrap: true
|
||||
},
|
||||
{
|
||||
type: 'TextBlock',
|
||||
text: description,
|
||||
isSubtle: true,
|
||||
size: 'Small',
|
||||
wrap: true,
|
||||
spacing: 'None'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return [{
|
||||
json: {
|
||||
type: 'message',
|
||||
attachments: [{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
content: {
|
||||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||
type: 'AdaptiveCard',
|
||||
version: '1.4',
|
||||
body: [
|
||||
{
|
||||
type: 'ColumnSet',
|
||||
style: 'emphasis',
|
||||
columns: [
|
||||
{
|
||||
type: 'Column',
|
||||
width: 'auto',
|
||||
items: [
|
||||
{
|
||||
type: 'Image',
|
||||
url: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/godot.png',
|
||||
width: '40px',
|
||||
altText: 'Godot Logo'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'Column',
|
||||
width: 'stretch',
|
||||
verticalContentAlignment: 'Center',
|
||||
items: [
|
||||
{
|
||||
type: 'TextBlock',
|
||||
text: 'ADT Report : Tekton Dash',
|
||||
weight: 'Bolder',
|
||||
size: 'Large',
|
||||
color: 'Accent'
|
||||
},
|
||||
{
|
||||
type: 'TextBlock',
|
||||
text: new Date().toLocaleDateString(),
|
||||
isSubtle: true,
|
||||
size: 'Small',
|
||||
spacing: 'None'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
...taskBlocks,
|
||||
{
|
||||
type: 'ActionSet',
|
||||
actions: [
|
||||
{
|
||||
type: 'Action.OpenUrl',
|
||||
title: 'Open PR Game Dev',
|
||||
url: 'https://www.notion.so/danchiego-game/36433be43b29800c8422ed5bdd65671b?v=36433be43b2980de8635000c0a910a0d',
|
||||
style: 'positive'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}]
|
||||
}
|
||||
}];
|
||||
@@ -1,154 +0,0 @@
|
||||
function getTitle(item) {
|
||||
if (item.json.Name && item.json.Name.title && Array.isArray(item.json.Name.title) && item.json.Name.title.length > 0) {
|
||||
return item.json.Name.title[0].plain_text;
|
||||
}
|
||||
if (item.json.title && Array.isArray(item.json.title) && item.json.title.length > 0) {
|
||||
return item.json.title[0].plain_text;
|
||||
}
|
||||
return "Untitled";
|
||||
}
|
||||
|
||||
const items = $input.all();
|
||||
const taskBlocks = [];
|
||||
|
||||
if (items.length === 0) {
|
||||
taskBlocks.push({
|
||||
type: "TextBlock",
|
||||
text: "No new tasks were completed today.",
|
||||
color: "warning",
|
||||
weight: "Bolder"
|
||||
});
|
||||
} else {
|
||||
items.forEach(item => {
|
||||
const projectType = item.json.ProjectType || "";
|
||||
const priority = item.json.Priority || "";
|
||||
const taskName = getTitle(item);
|
||||
const description = item.json.Description || "No description provided.";
|
||||
const status = item.json.Status || "";
|
||||
|
||||
let statusBadge = "PENDING";
|
||||
let badgeColor = "Default";
|
||||
|
||||
if (status === "In Progress") {
|
||||
statusBadge = "IN PROGRESS";
|
||||
badgeColor = "Accent";
|
||||
} else if (status === "Done") {
|
||||
statusBadge = "DONE";
|
||||
badgeColor = "Good";
|
||||
}
|
||||
|
||||
const prefix = [projectType, priority].filter(Boolean).join("-");
|
||||
const title = prefix ? `[${prefix}]: ${taskName}` : taskName;
|
||||
|
||||
taskBlocks.push({
|
||||
type: "ColumnSet",
|
||||
spacing: "Medium",
|
||||
separator: true,
|
||||
columns: [
|
||||
{
|
||||
type: "Column",
|
||||
width: "auto",
|
||||
verticalContentAlignment: "Center",
|
||||
items: [
|
||||
{
|
||||
type: "TextBlock",
|
||||
text: statusBadge,
|
||||
size: "Small",
|
||||
weight: "Bolder",
|
||||
color: badgeColor,
|
||||
wrap: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "Column",
|
||||
width: "stretch",
|
||||
items: [
|
||||
{
|
||||
type: "TextBlock",
|
||||
text: title,
|
||||
size: "Medium",
|
||||
weight: "Bolder",
|
||||
color: badgeColor,
|
||||
wrap: true
|
||||
},
|
||||
{
|
||||
type: "TextBlock",
|
||||
text: description,
|
||||
isSubtle: true,
|
||||
size: "Small",
|
||||
wrap: true,
|
||||
spacing: "None"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return [{
|
||||
json: {
|
||||
type: "message",
|
||||
attachments: [{
|
||||
contentType: "application/vnd.microsoft.card.adaptive",
|
||||
content: {
|
||||
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
type: "AdaptiveCard",
|
||||
version: "1.4",
|
||||
body: [
|
||||
{
|
||||
type: "ColumnSet",
|
||||
columns: [
|
||||
{
|
||||
type: "Column",
|
||||
width: "auto",
|
||||
items: [
|
||||
{
|
||||
type: "Image",
|
||||
url: "https://godotengine.org/assets/press/godot-logo.svg",
|
||||
size: "Small",
|
||||
altText: "Godot Logo"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "Column",
|
||||
width: "stretch",
|
||||
verticalContentAlignment: "Center",
|
||||
items: [
|
||||
{
|
||||
type: "TextBlock",
|
||||
text: "ADT Report : Tekton Dash",
|
||||
weight: "Bolder",
|
||||
size: "Large",
|
||||
color: "Accent"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "TextBlock",
|
||||
text: new Date().toLocaleDateString(),
|
||||
isSubtle: true,
|
||||
size: "Small",
|
||||
spacing: "None"
|
||||
},
|
||||
...taskBlocks,
|
||||
{
|
||||
type: "ActionSet",
|
||||
actions: [
|
||||
{
|
||||
type: "Action.OpenUrl",
|
||||
title: "Open PR Game Dev",
|
||||
url: "https://www.notion.so/danchiego-game/36433be43b29800c8422ed5bdd65671b?v=36433be43b2980de8635000c0a910a0d",
|
||||
style: "positive"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}]
|
||||
}
|
||||
}];
|
||||
@@ -17,6 +17,13 @@ albedo_color = Color(0.8, 0.2, 0.5, 1)
|
||||
material = SubResource("StandardMaterial3D_zone")
|
||||
size = Vector3(3, 1, 3)
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_86fyc"]
|
||||
albedo_color = Color(0.4973, 0.6, 0.12599999, 1)
|
||||
|
||||
[sub_resource type="PlaneMesh" id="PlaneMesh_ugtui"]
|
||||
material = SubResource("StandardMaterial3D_86fyc")
|
||||
size = Vector2(50, 50)
|
||||
|
||||
[node name="Gauntlet" type="Node3D" unique_id=1063002869]
|
||||
|
||||
[node name="PlaceholderFloor" type="MeshInstance3D" parent="." unique_id=932640085]
|
||||
@@ -30,3 +37,7 @@ visible = false
|
||||
mesh = SubResource("BoxMesh_cannon")
|
||||
|
||||
[node name="Gauntlet 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")
|
||||
|
||||
+5
-5
@@ -4,12 +4,12 @@
|
||||
[ext_resource type="Theme" uid="uid://da337sh5qxi0s" path="res://assets/themes/ui_theme.tres" id="2_theme"]
|
||||
[ext_resource type="Texture2D" uid="uid://jqvv6s55mlsk" path="res://assets/graphics/gui/BG.png" id="3_iulku"]
|
||||
[ext_resource type="Texture2D" uid="uid://2d1ks5pmblc7" path="res://assets/graphics/main_menu/bg_back.png" id="3_q60fs"]
|
||||
[ext_resource type="PackedScene" uid="uid://ejeamn0pyey4" path="res://assets/characters/Bob.glb" id="4_bob"]
|
||||
[ext_resource type="PackedScene" uid="uid://d4cul3w3wem5w" path="res://assets/characters/Gatot.glb" id="4_gatot"]
|
||||
[ext_resource type="PackedScene" uid="uid://1vk0mjnwkngi" path="res://assets/characters/Masbro.glb" id="4_masbro"]
|
||||
[ext_resource type="PackedScene" uid="uid://5qdk1umx2rjf" path="res://assets/characters/Bob.glb" id="4_bob"]
|
||||
[ext_resource type="PackedScene" uid="uid://bfujakntxa0v6" path="res://assets/characters/Gatot.glb" id="4_gatot"]
|
||||
[ext_resource type="PackedScene" uid="uid://cfjx66gthp1c5" path="res://assets/characters/Masbro.glb" id="4_masbro"]
|
||||
[ext_resource type="Texture2D" uid="uid://dvp0as6yyudco" path="res://assets/graphics/main_menu/bg_illust.png" id="4_nqcc7"]
|
||||
[ext_resource type="PackedScene" uid="uid://bmln7v6v5kvxg" path="res://assets/characters/Oldpop.glb" id="4_oldpop"]
|
||||
[ext_resource type="AnimationLibrary" uid="uid://c3pyopnwibckj" path="res://assets/characters/animations/animation-pack.res" id="5_animlib"]
|
||||
[ext_resource type="PackedScene" uid="uid://cxvbrdybeglt5" path="res://assets/characters/Oldpop.glb" id="4_oldpop"]
|
||||
[ext_resource type="AnimationLibrary" path="res://assets/characters/animations/animation-pack.res" id="5_animlib"]
|
||||
[ext_resource type="FontFile" uid="uid://xnjx058n4tsw" path="res://assets/fonts/Nougat-ExtraBlack.ttf" id="5_pc087"]
|
||||
[ext_resource type="Texture2D" uid="uid://brhn1dhp1gm13" path="res://assets/graphics/character_selection/sc_characters/sc_copper.png" id="10_dyhay"]
|
||||
[ext_resource type="Texture2D" uid="uid://c8xwpkvvwa7a4" path="res://assets/graphics/gui/mainmenu/chat.png" id="12_dfnwm"]
|
||||
|
||||
+9
-9
@@ -252,8 +252,8 @@ func _init_managers():
|
||||
add_child(portal_mode_manager)
|
||||
portal_mode_manager.initialize(self , $EnhancedGridMap)
|
||||
|
||||
# Gauntlet manager for Candy Cannon Survival mode
|
||||
if LobbyManager.game_mode == "Candy Cannon Survival":
|
||||
# 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)
|
||||
@@ -621,7 +621,7 @@ func _setup_host_game():
|
||||
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 Cannon Survival" and gauntlet_manager:
|
||||
elif LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager:
|
||||
gauntlet_manager._setup_arena()
|
||||
else:
|
||||
# Randomize grid first to ensure Floor 0 is walkable for pre-calculation
|
||||
@@ -729,8 +729,8 @@ func _setup_client_game():
|
||||
if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
|
||||
portal_mode_manager.setup_arena_locally()
|
||||
|
||||
# Initialize arena locally for Candy Cannon Survival
|
||||
if LobbyManager.game_mode == "Candy Cannon Survival" and gauntlet_manager:
|
||||
# Initialize arena locally for Candy Pump Survival
|
||||
if LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager:
|
||||
gauntlet_manager._apply_arena_setup()
|
||||
|
||||
# Ensure local player setup (UI, controls) is verified
|
||||
@@ -829,12 +829,12 @@ func _start_game():
|
||||
stop_n_go_manager.spawn_initial_powerups() # Ensure power-ups exist before 1,2,3 Go
|
||||
|
||||
# Gauntlet: Spawn mission tiles across 20x20 arena BEFORE countdown
|
||||
if LobbyManager.game_mode == "Candy Cannon Survival" and gauntlet_manager:
|
||||
if LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager:
|
||||
gauntlet_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 Cannon Survival":
|
||||
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors" and LobbyManager.game_mode != "Candy Pump Survival":
|
||||
spawn_static_tektons()
|
||||
|
||||
# Tekton Doors: Randomize connections BEFORE countdown so colors show
|
||||
@@ -873,7 +873,7 @@ func _start_game():
|
||||
if goals_cycle_manager:
|
||||
var match_duration = LobbyManager.get_match_duration()
|
||||
goals_cycle_manager.start_match(float(match_duration))
|
||||
elif LobbyManager.game_mode == "Candy Cannon Survival":
|
||||
elif LobbyManager.game_mode == "Candy Pump Survival":
|
||||
if gauntlet_manager:
|
||||
gauntlet_manager.start_game_mode()
|
||||
|
||||
@@ -1860,7 +1860,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 Cannon Survival":
|
||||
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors" and LobbyManager.game_mode != "Candy Pump Survival":
|
||||
# 60% Chance for Common (7-10), 40% for PowerUp
|
||||
if randf() <= 0.6:
|
||||
return [7, 8, 9, 10].pick_random()
|
||||
|
||||
+151
-151
@@ -1,17 +1,18 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://biio8efqysivs"]
|
||||
[gd_scene format=3 uid="uid://biio8efqysivs"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://ic8fg0o0p0i4" path="res://scripts/ui/admin_panel.gd" id="1"]
|
||||
[ext_resource type="PackedScene" uid="uid://dp12345678" path="res://scenes/ui/date_picker.tscn" id="2_dp"]
|
||||
|
||||
[node name="AdminPanel" type="Panel"]
|
||||
[node name="AdminPanel" type="Panel" unique_id=1215317796]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1")
|
||||
metadata/_edit_vertical_guides_ = [72.0]
|
||||
|
||||
[node name="BG" type="ColorRect" parent="."]
|
||||
[node name="BG" type="ColorRect" parent="." unique_id=1804706969]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -20,7 +21,7 @@ grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
color = Color(0.1, 0.1, 0.12, 1)
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="."]
|
||||
[node name="Margin" type="MarginContainer" parent="." unique_id=455016900]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -32,52 +33,53 @@ theme_override_constants/margin_top = 16
|
||||
theme_override_constants/margin_right = 24
|
||||
theme_override_constants/margin_bottom = 16
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="Margin"]
|
||||
[node name="VBox" type="VBoxContainer" parent="Margin" unique_id=2140901986]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Header" type="HBoxContainer" parent="Margin/VBox"]
|
||||
[node name="Header" type="HBoxContainer" parent="Margin/VBox" unique_id=2066510654]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="Title" type="Label" parent="Margin/VBox/Header"]
|
||||
[node name="Title" type="Label" parent="Margin/VBox/Header" unique_id=1489422413]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "SERVER ADMIN PANEL"
|
||||
|
||||
[node name="CountLabel" type="Label" parent="Margin/VBox/Header"]
|
||||
[node name="CountLabel" type="Label" parent="Margin/VBox/Header" unique_id=73628023]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "0 items"
|
||||
|
||||
[node name="RefreshBtn" type="Button" parent="Margin/VBox/Header"]
|
||||
[node name="RefreshBtn" type="Button" parent="Margin/VBox/Header" unique_id=1328255917]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(90, 32)
|
||||
layout_mode = 2
|
||||
text = "Refresh"
|
||||
|
||||
[node name="CloseBtn" type="Button" parent="Margin/VBox/Header"]
|
||||
[node name="CloseBtn" type="Button" parent="Margin/VBox/Header" unique_id=1357556455]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(32, 32)
|
||||
layout_mode = 2
|
||||
text = "X"
|
||||
|
||||
[node name="Sep" type="HSeparator" parent="Margin/VBox"]
|
||||
[node name="Sep" type="HSeparator" parent="Margin/VBox" unique_id=1215687357]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Tabs" type="TabContainer" parent="Margin/VBox"]
|
||||
[node name="Tabs" type="TabContainer" parent="Margin/VBox" unique_id=786391934]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
current_tab = 0
|
||||
current_tab = 7
|
||||
|
||||
[node name="Users" type="VBoxContainer" parent="Margin/VBox/Tabs"]
|
||||
[node name="Users" type="VBoxContainer" parent="Margin/VBox/Tabs" unique_id=888669929]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
metadata/_tab_index = 0
|
||||
|
||||
[node name="UserTree" type="Tree" parent="Margin/VBox/Tabs/Users"]
|
||||
[node name="UserTree" type="Tree" parent="Margin/VBox/Tabs/Users" unique_id=1451007875]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
@@ -87,63 +89,62 @@ allow_reselect = true
|
||||
hide_root = true
|
||||
select_mode = 1
|
||||
|
||||
[node name="UserActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Users"]
|
||||
[node name="UserActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Users" unique_id=327018008]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="SelectAllBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar"]
|
||||
[node name="SelectAllBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar" unique_id=572327166]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Select All"
|
||||
|
||||
[node name="DeselectBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar"]
|
||||
[node name="DeselectBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar" unique_id=1569831836]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Deselect All"
|
||||
|
||||
[node name="Spacer" type="Control" parent="Margin/VBox/Tabs/Users/UserActionBar"]
|
||||
[node name="Spacer" type="Control" parent="Margin/VBox/Tabs/Users/UserActionBar" unique_id=708964742]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="SelectedLabel" type="Label" parent="Margin/VBox/Tabs/Users/UserActionBar"]
|
||||
[node name="SelectedLabel" type="Label" parent="Margin/VBox/Tabs/Users/UserActionBar" unique_id=1896049587]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "0 selected"
|
||||
|
||||
[node name="HistoryBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar"]
|
||||
[node name="HistoryBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar" unique_id=2045123211]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(80, 36)
|
||||
layout_mode = 2
|
||||
text = "HISTORY"
|
||||
|
||||
|
||||
[node name="BanBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar"]
|
||||
[node name="BanBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar" unique_id=1870479743]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(80, 36)
|
||||
layout_mode = 2
|
||||
text = "BAN"
|
||||
|
||||
[node name="UnbanBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar"]
|
||||
[node name="UnbanBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar" unique_id=222463017]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(80, 36)
|
||||
layout_mode = 2
|
||||
text = "UNBAN"
|
||||
|
||||
[node name="DeleteBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar"]
|
||||
[node name="DeleteBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar" unique_id=313663234]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(80, 36)
|
||||
layout_mode = 2
|
||||
text = "DELETE"
|
||||
|
||||
[node name="Leaderboards" type="VBoxContainer" parent="Margin/VBox/Tabs"]
|
||||
[node name="Leaderboards" type="VBoxContainer" parent="Margin/VBox/Tabs" unique_id=102020095]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
metadata/_tab_index = 1
|
||||
|
||||
[node name="LeaderboardTree" type="Tree" parent="Margin/VBox/Tabs/Leaderboards"]
|
||||
[node name="LeaderboardTree" type="Tree" parent="Margin/VBox/Tabs/Leaderboards" unique_id=1966740510]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
@@ -152,48 +153,48 @@ column_titles_visible = true
|
||||
allow_reselect = true
|
||||
hide_root = true
|
||||
|
||||
[node name="LBActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Leaderboards"]
|
||||
[node name="LBActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Leaderboards" unique_id=72512512]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
alignment = 2
|
||||
|
||||
[node name="SyncLeaderboardBtn" type="Button" parent="Margin/VBox/Tabs/Leaderboards/LBActionBar"]
|
||||
[node name="SyncLeaderboardBtn" type="Button" parent="Margin/VBox/Tabs/Leaderboards/LBActionBar" unique_id=1234854116]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(160, 36)
|
||||
layout_mode = 2
|
||||
text = "Sync with Storage"
|
||||
|
||||
[node name="ResetLBBtn" type="Button" parent="Margin/VBox/Tabs/Leaderboards/LBActionBar"]
|
||||
[node name="ResetLBBtn" type="Button" parent="Margin/VBox/Tabs/Leaderboards/LBActionBar" unique_id=895290771]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
custom_minimum_size = Vector2(120, 36)
|
||||
layout_mode = 2
|
||||
text = "Reset All Scores"
|
||||
|
||||
[node name="Daily Rewards" type="VBoxContainer" parent="Margin/VBox/Tabs"]
|
||||
[node name="Daily Rewards" type="VBoxContainer" parent="Margin/VBox/Tabs" unique_id=1077500751]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
metadata/_tab_index = 2
|
||||
|
||||
[node name="MonthHBox" type="HBoxContainer" parent="Margin/VBox/Tabs/Daily Rewards"]
|
||||
[node name="MonthHBox" type="HBoxContainer" parent="Margin/VBox/Tabs/Daily Rewards" unique_id=1428330077]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="Label" type="Label" parent="Margin/VBox/Tabs/Daily Rewards/MonthHBox"]
|
||||
[node name="Label" type="Label" parent="Margin/VBox/Tabs/Daily Rewards/MonthHBox" unique_id=545398145]
|
||||
layout_mode = 2
|
||||
text = "Target Month:"
|
||||
|
||||
[node name="MonthOptionBtn" type="OptionButton" parent="Margin/VBox/Tabs/Daily Rewards/MonthHBox"]
|
||||
[node name="MonthOptionBtn" type="OptionButton" parent="Margin/VBox/Tabs/Daily Rewards/MonthHBox" unique_id=837393990]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(150, 0)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="DaysScroll" type="ScrollContainer" parent="Margin/VBox/Tabs/Daily Rewards"]
|
||||
[node name="DaysScroll" type="ScrollContainer" parent="Margin/VBox/Tabs/Daily Rewards" unique_id=550036125]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="DaysGrid" type="GridContainer" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll"]
|
||||
[node name="DaysGrid" type="GridContainer" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll" unique_id=737222522]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
@@ -202,38 +203,37 @@ theme_override_constants/h_separation = 10
|
||||
theme_override_constants/v_separation = 10
|
||||
columns = 6
|
||||
|
||||
[node name="DayConfigTemplate" type="PanelContainer" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid"]
|
||||
[node name="DayConfigTemplate" type="PanelContainer" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid" unique_id=931392759]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="ColorRect" type="ColorRect" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate"]
|
||||
[node name="ColorRect" type="ColorRect" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate" unique_id=1136593241]
|
||||
layout_mode = 2
|
||||
color = Color(0.12, 0.12, 0.12, 1)
|
||||
|
||||
[node name="Border" type="ReferenceRect" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate"]
|
||||
[node name="Border" type="ReferenceRect" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate" unique_id=533194914]
|
||||
layout_mode = 2
|
||||
border_color = Color(0.25, 0.25, 0.25, 1)
|
||||
border_width = 1.0
|
||||
editor_only = false
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate"]
|
||||
[node name="Margin" type="MarginContainer" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate" unique_id=1878869939]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 4
|
||||
theme_override_constants/margin_top = 4
|
||||
theme_override_constants/margin_right = 4
|
||||
theme_override_constants/margin_bottom = 4
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate/Margin"]
|
||||
[node name="VBox" type="VBoxContainer" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate/Margin" unique_id=809832816]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="DayLabel" type="Label" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate/Margin/VBox"]
|
||||
[node name="DayLabel" type="Label" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate/Margin/VBox" unique_id=572039382]
|
||||
layout_mode = 2
|
||||
text = "Day 1"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="TypeOptionBtn" type="OptionButton" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate/Margin/VBox"]
|
||||
[node name="TypeOptionBtn" type="OptionButton" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate/Margin/VBox" unique_id=1462823824]
|
||||
layout_mode = 2
|
||||
alignment = 1
|
||||
item_count = 5
|
||||
@@ -248,123 +248,122 @@ popup/item_3/id = 3
|
||||
popup/item_4/text = "frag_rare"
|
||||
popup/item_4/id = 4
|
||||
|
||||
[node name="AmountSpinBox" type="SpinBox" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate/Margin/VBox"]
|
||||
[node name="AmountSpinBox" type="SpinBox" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate/Margin/VBox" unique_id=1768492868]
|
||||
layout_mode = 2
|
||||
max_value = 10000.0
|
||||
alignment = 1
|
||||
|
||||
[node name="DRActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Daily Rewards"]
|
||||
[node name="DRActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Daily Rewards" unique_id=1558168965]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
alignment = 2
|
||||
|
||||
[node name="LoadDRConfigBtn" type="Button" parent="Margin/VBox/Tabs/Daily Rewards/DRActionBar"]
|
||||
[node name="LoadDRConfigBtn" type="Button" parent="Margin/VBox/Tabs/Daily Rewards/DRActionBar" unique_id=2139474519]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(160, 36)
|
||||
layout_mode = 2
|
||||
text = "Reload Config"
|
||||
|
||||
[node name="SaveDRConfigBtn" type="Button" parent="Margin/VBox/Tabs/Daily Rewards/DRActionBar"]
|
||||
[node name="SaveDRConfigBtn" type="Button" parent="Margin/VBox/Tabs/Daily Rewards/DRActionBar" unique_id=1692637953]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(160, 36)
|
||||
layout_mode = 2
|
||||
text = "Save Config"
|
||||
|
||||
[node name="Announcements" type="VBoxContainer" parent="Margin/VBox/Tabs"]
|
||||
[node name="Announcements" type="VBoxContainer" parent="Margin/VBox/Tabs" unique_id=905445194]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
metadata/_tab_index = 3
|
||||
|
||||
[node name="TargetHBox" type="HBoxContainer" parent="Margin/VBox/Tabs/Announcements"]
|
||||
[node name="TargetHBox" type="HBoxContainer" parent="Margin/VBox/Tabs/Announcements" unique_id=1418326252]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Label" type="Label" parent="Margin/VBox/Tabs/Announcements/TargetHBox"]
|
||||
[node name="Label" type="Label" parent="Margin/VBox/Tabs/Announcements/TargetHBox" unique_id=763223589]
|
||||
layout_mode = 2
|
||||
text = "Target:"
|
||||
|
||||
[node name="TargetUserEdit" type="LineEdit" parent="Margin/VBox/Tabs/Announcements/TargetHBox"]
|
||||
[node name="TargetUserEdit" type="LineEdit" parent="Margin/VBox/Tabs/Announcements/TargetHBox" unique_id=97068317]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Username or User ID (empty = ALL)"
|
||||
|
||||
[node name="FindUserBtn" type="Button" parent="Margin/VBox/Tabs/Announcements/TargetHBox"]
|
||||
[node name="FindUserBtn" type="Button" parent="Margin/VBox/Tabs/Announcements/TargetHBox" unique_id=967759165]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(80, 0)
|
||||
layout_mode = 2
|
||||
text = "Find"
|
||||
|
||||
[node name="ResolvedIdLabel" type="Label" parent="Margin/VBox/Tabs/Announcements/TargetHBox"]
|
||||
[node name="ResolvedIdLabel" type="Label" parent="Margin/VBox/Tabs/Announcements/TargetHBox" unique_id=1229281906]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.4, 0.9, 0.4, 1)
|
||||
text = ""
|
||||
|
||||
[node name="TitleEdit" type="LineEdit" parent="Margin/VBox/Tabs/Announcements"]
|
||||
[node name="TitleEdit" type="LineEdit" parent="Margin/VBox/Tabs/Announcements" unique_id=1221923610]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
placeholder_text = "Message Title"
|
||||
|
||||
[node name="ContentEdit" type="TextEdit" parent="Margin/VBox/Tabs/Announcements"]
|
||||
[node name="ContentEdit" type="TextEdit" parent="Margin/VBox/Tabs/Announcements" unique_id=535034535]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 150)
|
||||
layout_mode = 2
|
||||
placeholder_text = "Message Content"
|
||||
|
||||
[node name="ScheduleHBox" type="HBoxContainer" parent="Margin/VBox/Tabs/Announcements"]
|
||||
[node name="ScheduleHBox" type="HBoxContainer" parent="Margin/VBox/Tabs/Announcements" unique_id=1506301658]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="LabelStart" type="Label" parent="Margin/VBox/Tabs/Announcements/ScheduleHBox"]
|
||||
[node name="LabelStart" type="Label" parent="Margin/VBox/Tabs/Announcements/ScheduleHBox" unique_id=1634148723]
|
||||
layout_mode = 2
|
||||
text = "Start:"
|
||||
|
||||
[node name="StartDatePicker" parent="Margin/VBox/Tabs/Announcements/ScheduleHBox" instance=ExtResource("2_dp")]
|
||||
[node name="StartDatePicker" parent="Margin/VBox/Tabs/Announcements/ScheduleHBox" unique_id=1650084476 instance=ExtResource("2_dp")]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="LabelEnd" type="Label" parent="Margin/VBox/Tabs/Announcements/ScheduleHBox"]
|
||||
[node name="LabelEnd" type="Label" parent="Margin/VBox/Tabs/Announcements/ScheduleHBox" unique_id=2022036343]
|
||||
layout_mode = 2
|
||||
text = "End:"
|
||||
|
||||
[node name="EndDatePicker" parent="Margin/VBox/Tabs/Announcements/ScheduleHBox" instance=ExtResource("2_dp")]
|
||||
[node name="EndDatePicker" parent="Margin/VBox/Tabs/Announcements/ScheduleHBox" unique_id=250724993 instance=ExtResource("2_dp")]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="RewardsHeader" type="HBoxContainer" parent="Margin/VBox/Tabs/Announcements"]
|
||||
[node name="RewardsHeader" type="HBoxContainer" parent="Margin/VBox/Tabs/Announcements" unique_id=1717375008]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" type="Label" parent="Margin/VBox/Tabs/Announcements/RewardsHeader"]
|
||||
[node name="Label" type="Label" parent="Margin/VBox/Tabs/Announcements/RewardsHeader" unique_id=1897885246]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Attached Rewards"
|
||||
|
||||
[node name="AddRewardBtn" type="Button" parent="Margin/VBox/Tabs/Announcements/RewardsHeader"]
|
||||
[node name="AddRewardBtn" type="Button" parent="Margin/VBox/Tabs/Announcements/RewardsHeader" unique_id=222205115]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(100, 30)
|
||||
layout_mode = 2
|
||||
text = "+ ADD"
|
||||
|
||||
[node name="RewardsList" type="VBoxContainer" parent="Margin/VBox/Tabs/Announcements"]
|
||||
[node name="RewardsList" type="VBoxContainer" parent="Margin/VBox/Tabs/Announcements" unique_id=1911576777]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="RewardRowTemplate" type="HBoxContainer" parent="Margin/VBox/Tabs/Announcements"]
|
||||
[node name="RewardRowTemplate" type="HBoxContainer" parent="Margin/VBox/Tabs/Announcements" unique_id=549284803]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="TypeOption" type="OptionButton" parent="Margin/VBox/Tabs/Announcements/RewardRowTemplate"]
|
||||
[node name="TypeOption" type="OptionButton" parent="Margin/VBox/Tabs/Announcements/RewardRowTemplate" unique_id=544162634]
|
||||
custom_minimum_size = Vector2(100, 0)
|
||||
layout_mode = 2
|
||||
item_count = 4
|
||||
selected = 0
|
||||
item_count = 4
|
||||
popup/item_0/text = "star"
|
||||
popup/item_0/id = 0
|
||||
popup/item_1/text = "gold"
|
||||
@@ -374,34 +373,34 @@ popup/item_2/id = 2
|
||||
popup/item_3/text = "skin"
|
||||
popup/item_3/id = 3
|
||||
|
||||
[node name="IdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Announcements/RewardRowTemplate"]
|
||||
[node name="IdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Announcements/RewardRowTemplate" unique_id=1011603098]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Item/Skin ID (Leave empty for Star/Gold)"
|
||||
|
||||
[node name="AmountSpin" type="SpinBox" parent="Margin/VBox/Tabs/Announcements/RewardRowTemplate"]
|
||||
[node name="AmountSpin" type="SpinBox" parent="Margin/VBox/Tabs/Announcements/RewardRowTemplate" unique_id=1485263050]
|
||||
layout_mode = 2
|
||||
max_value = 100000.0
|
||||
value = 1.0
|
||||
|
||||
[node name="RemoveBtn" type="Button" parent="Margin/VBox/Tabs/Announcements/RewardRowTemplate"]
|
||||
[node name="RemoveBtn" type="Button" parent="Margin/VBox/Tabs/Announcements/RewardRowTemplate" unique_id=370893501]
|
||||
layout_mode = 2
|
||||
text = "X"
|
||||
theme_override_colors/font_color = Color(1, 0.3, 0.3, 1)
|
||||
text = "X"
|
||||
|
||||
[node name="SendMailBtn" type="Button" parent="Margin/VBox/Tabs/Announcements"]
|
||||
[node name="SendMailBtn" type="Button" parent="Margin/VBox/Tabs/Announcements" unique_id=84552601]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
text = "SEND ANNOUNCEMENT"
|
||||
|
||||
[node name="Mail Manager" type="VBoxContainer" parent="Margin/VBox/Tabs"]
|
||||
[node name="Mail Manager" type="VBoxContainer" parent="Margin/VBox/Tabs" unique_id=1187054862]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
metadata/_tab_index = 4
|
||||
|
||||
[node name="MailTree" type="Tree" parent="Margin/VBox/Tabs/Mail Manager"]
|
||||
[node name="MailTree" type="Tree" parent="Margin/VBox/Tabs/Mail Manager" unique_id=1947023585]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
@@ -411,248 +410,243 @@ allow_reselect = true
|
||||
hide_root = true
|
||||
select_mode = 1
|
||||
|
||||
[node name="MailActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Mail Manager"]
|
||||
[node name="MailActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Mail Manager" unique_id=2069104938]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="RefreshMailBtn" type="Button" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar"]
|
||||
[node name="RefreshMailBtn" type="Button" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar" unique_id=888045352]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Refresh"
|
||||
|
||||
[node name="Spacer" type="Control" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar"]
|
||||
[node name="Spacer" type="Control" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar" unique_id=1229119376]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="EditMailBtn" type="Button" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar"]
|
||||
[node name="EditMailBtn" type="Button" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar" unique_id=2119864672]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Edit"
|
||||
|
||||
[node name="EndMailBtn" type="Button" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar"]
|
||||
[node name="EndMailBtn" type="Button" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar" unique_id=740259358]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(120, 36)
|
||||
layout_mode = 2
|
||||
text = "End Now"
|
||||
|
||||
[node name="DeleteMailServerBtn" type="Button" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar"]
|
||||
[node name="DeleteMailServerBtn" type="Button" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar" unique_id=480113439]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(120, 36)
|
||||
layout_mode = 2
|
||||
text = "Delete"
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="Margin/VBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Shop" type="VBoxContainer" parent="Margin/VBox/Tabs"]
|
||||
[node name="Shop" type="VBoxContainer" parent="Margin/VBox/Tabs" unique_id=1663746802]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
metadata/_tab_index = 5
|
||||
|
||||
[node name="HeaderLbl" type="Label" parent="Margin/VBox/Tabs/Shop"]
|
||||
[node name="HeaderLbl" type="Label" parent="Margin/VBox/Tabs/Shop" unique_id=629314273]
|
||||
layout_mode = 2
|
||||
text = "Featured Banner Slots (Event / Special)"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
text = "Featured Banner Slots (Event / Special)"
|
||||
|
||||
[node name="InfoLbl" type="Label" parent="Margin/VBox/Tabs/Shop"]
|
||||
[node name="InfoLbl" type="Label" parent="Margin/VBox/Tabs/Shop" unique_id=1265660269]
|
||||
layout_mode = 2
|
||||
autowrap_mode = 3
|
||||
text = "Each slot shows a cosmetic item as a special event banner in the Shop sidebar. Leave Item ID blank to hide the slot."
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="SlotsVBox" type="VBoxContainer" parent="Margin/VBox/Tabs/Shop"]
|
||||
[node name="SlotsVBox" type="VBoxContainer" parent="Margin/VBox/Tabs/Shop" unique_id=1496005796]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="Slot1" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop/SlotsVBox"]
|
||||
[node name="Slot1" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop/SlotsVBox" unique_id=2145460867]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="SlotLbl" type="Label" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot1"]
|
||||
[node name="SlotLbl" type="Label" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot1" unique_id=1907194640]
|
||||
custom_minimum_size = Vector2(60, 0)
|
||||
layout_mode = 2
|
||||
text = "Slot 1:"
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="ItemIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot1"]
|
||||
[node name="ItemIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot1" unique_id=1455276821]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Item ID (e.g. oldpop-blue-hat)"
|
||||
|
||||
[node name="LabelEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot1"]
|
||||
[node name="LabelEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot1" unique_id=806604635]
|
||||
custom_minimum_size = Vector2(160, 0)
|
||||
layout_mode = 2
|
||||
placeholder_text = "Event label (e.g. LIMITED!)"
|
||||
|
||||
[node name="Slot2" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop/SlotsVBox"]
|
||||
[node name="Slot2" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop/SlotsVBox" unique_id=1493005856]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="SlotLbl" type="Label" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot2"]
|
||||
[node name="SlotLbl" type="Label" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot2" unique_id=573101771]
|
||||
custom_minimum_size = Vector2(60, 0)
|
||||
layout_mode = 2
|
||||
text = "Slot 2:"
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="ItemIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot2"]
|
||||
[node name="ItemIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot2" unique_id=1748503110]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Item ID (e.g. oldpop-red-hat)"
|
||||
|
||||
[node name="LabelEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot2"]
|
||||
[node name="LabelEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot2" unique_id=1504360312]
|
||||
custom_minimum_size = Vector2(160, 0)
|
||||
layout_mode = 2
|
||||
placeholder_text = "Event label"
|
||||
|
||||
[node name="Slot3" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop/SlotsVBox"]
|
||||
[node name="Slot3" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop/SlotsVBox" unique_id=868105669]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="SlotLbl" type="Label" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot3"]
|
||||
[node name="SlotLbl" type="Label" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot3" unique_id=564565213]
|
||||
custom_minimum_size = Vector2(60, 0)
|
||||
layout_mode = 2
|
||||
text = "Slot 3:"
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="ItemIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot3"]
|
||||
[node name="ItemIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot3" unique_id=956708716]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Item ID (e.g. oldpop-yellow-hat)"
|
||||
|
||||
[node name="LabelEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot3"]
|
||||
[node name="LabelEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot3" unique_id=715444993]
|
||||
custom_minimum_size = Vector2(160, 0)
|
||||
layout_mode = 2
|
||||
placeholder_text = "Event label"
|
||||
|
||||
[node name="ShopActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop"]
|
||||
[node name="ShopActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop" unique_id=2071973475]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
alignment = 2
|
||||
|
||||
[node name="LoadBannersBtn" type="Button" parent="Margin/VBox/Tabs/Shop/ShopActionBar"]
|
||||
[node name="LoadBannersBtn" type="Button" parent="Margin/VBox/Tabs/Shop/ShopActionBar" unique_id=429010019]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(140, 36)
|
||||
layout_mode = 2
|
||||
text = "Load Current"
|
||||
|
||||
[node name="SaveBannersBtn" type="Button" parent="Margin/VBox/Tabs/Shop/ShopActionBar"]
|
||||
[node name="SaveBannersBtn" type="Button" parent="Margin/VBox/Tabs/Shop/ShopActionBar" unique_id=583339120]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(160, 36)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Lobby Chat" type="VBoxContainer" parent="Margin/VBox/Tabs"]
|
||||
[node name="Lobby Chat" type="VBoxContainer" parent="Margin/VBox/Tabs" unique_id=707175209]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
metadata/_tab_index = 6
|
||||
|
||||
[node name="PrefixRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat"]
|
||||
[node name="PrefixRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat" unique_id=1979823156]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Label" type="Label" parent="Margin/VBox/Tabs/Lobby Chat/PrefixRow"]
|
||||
layout_mode = 2
|
||||
[node name="Label" type="Label" parent="Margin/VBox/Tabs/Lobby Chat/PrefixRow" unique_id=1610505794]
|
||||
custom_minimum_size = Vector2(220, 0)
|
||||
layout_mode = 2
|
||||
text = "System Prefix:"
|
||||
|
||||
[node name="PrefixEdit" type="LineEdit" parent="Margin/VBox/Tabs/Lobby Chat/PrefixRow"]
|
||||
[node name="PrefixEdit" type="LineEdit" parent="Margin/VBox/Tabs/Lobby Chat/PrefixRow" unique_id=1229354911]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "[SERVER]"
|
||||
|
||||
[node name="MaxMsgRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat"]
|
||||
[node name="MaxMsgRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat" unique_id=1333148469]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Label" type="Label" parent="Margin/VBox/Tabs/Lobby Chat/MaxMsgRow"]
|
||||
layout_mode = 2
|
||||
[node name="Label" type="Label" parent="Margin/VBox/Tabs/Lobby Chat/MaxMsgRow" unique_id=1345725816]
|
||||
custom_minimum_size = Vector2(220, 0)
|
||||
layout_mode = 2
|
||||
text = "Max messages loaded:"
|
||||
|
||||
[node name="MaxMsgSpin" type="SpinBox" parent="Margin/VBox/Tabs/Lobby Chat/MaxMsgRow"]
|
||||
[node name="MaxMsgSpin" type="SpinBox" parent="Margin/VBox/Tabs/Lobby Chat/MaxMsgRow" unique_id=1271445635]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(120, 0)
|
||||
layout_mode = 2
|
||||
min_value = 10.0
|
||||
max_value = 200.0
|
||||
step = 10.0
|
||||
value = 50.0
|
||||
|
||||
[node name="MaxAgeRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat"]
|
||||
[node name="MaxAgeRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat" unique_id=1691464635]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Label" type="Label" parent="Margin/VBox/Tabs/Lobby Chat/MaxAgeRow"]
|
||||
layout_mode = 2
|
||||
[node name="Label" type="Label" parent="Margin/VBox/Tabs/Lobby Chat/MaxAgeRow" unique_id=2146595304]
|
||||
custom_minimum_size = Vector2(220, 0)
|
||||
layout_mode = 2
|
||||
text = "Delete messages older than (days):"
|
||||
|
||||
[node name="MaxAgeSpin" type="SpinBox" parent="Margin/VBox/Tabs/Lobby Chat/MaxAgeRow"]
|
||||
[node name="MaxAgeSpin" type="SpinBox" parent="Margin/VBox/Tabs/Lobby Chat/MaxAgeRow" unique_id=1658809503]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(120, 0)
|
||||
min_value = 0.0
|
||||
max_value = 365.0
|
||||
step = 1.0
|
||||
value = 0.0
|
||||
layout_mode = 2
|
||||
tooltip_text = "0 = don't auto-delete, use manual purge only"
|
||||
max_value = 365.0
|
||||
|
||||
[node name="ChatActions" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat"]
|
||||
[node name="ChatActions" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat" unique_id=1452586888]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="WipeChatBtn" type="Button" parent="Margin/VBox/Tabs/Lobby Chat/ChatActions"]
|
||||
[node name="WipeChatBtn" type="Button" parent="Margin/VBox/Tabs/Lobby Chat/ChatActions" unique_id=500711479]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(140, 36)
|
||||
layout_mode = 2
|
||||
text = "Wipe Chat"
|
||||
|
||||
[node name="PurgeOldBtn" type="Button" parent="Margin/VBox/Tabs/Lobby Chat/ChatActions"]
|
||||
[node name="PurgeOldBtn" type="Button" parent="Margin/VBox/Tabs/Lobby Chat/ChatActions" unique_id=190131015]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(140, 36)
|
||||
layout_mode = 2
|
||||
text = "Purge Old"
|
||||
|
||||
[node name="SaveConfigBtn" type="Button" parent="Margin/VBox/Tabs/Lobby Chat/ChatActions"]
|
||||
[node name="SaveConfigBtn" type="Button" parent="Margin/VBox/Tabs/Lobby Chat/ChatActions" unique_id=157935610]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(140, 36)
|
||||
layout_mode = 2
|
||||
text = "Save Config"
|
||||
|
||||
[node name="ChatStatusLabel" type="Label" parent="Margin/VBox/Tabs/Lobby Chat"]
|
||||
[node name="ChatStatusLabel" type="Label" parent="Margin/VBox/Tabs/Lobby Chat" unique_id=1210803106]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = ""
|
||||
|
||||
[node name="Chat Storage" type="VBoxContainer" parent="Margin/VBox/Tabs"]
|
||||
[node name="Chat Storage" type="VBoxContainer" parent="Margin/VBox/Tabs" unique_id=181772524]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
metadata/_tab_index = 7
|
||||
|
||||
[node name="ChannelIdRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Chat Storage" unique_id=242863643]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="ChannelIdRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Chat Storage"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Label" type="Label" parent="Margin/VBox/Tabs/Chat Storage/ChannelIdRow"]
|
||||
[node name="Label" type="Label" parent="Margin/VBox/Tabs/Chat Storage/ChannelIdRow" unique_id=1799844455]
|
||||
layout_mode = 2
|
||||
text = "Channel ID:"
|
||||
|
||||
[node name="ChannelIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Chat Storage/ChannelIdRow"]
|
||||
[node name="ChannelIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Chat Storage/ChannelIdRow" unique_id=984574932]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Enter channel ID..."
|
||||
text = "social_global"
|
||||
placeholder_text = "social_global"
|
||||
|
||||
[node name="LoadMessagesBtn" type="Button" parent="Margin/VBox/Tabs/Chat Storage/ChannelIdRow"]
|
||||
[node name="LoadMessagesBtn" type="Button" parent="Margin/VBox/Tabs/Chat Storage/ChannelIdRow" unique_id=1775485497]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(100, 0)
|
||||
layout_mode = 2
|
||||
text = "Load"
|
||||
|
||||
[node name="ChatTree" type="Tree" parent="Margin/VBox/Tabs/Chat Storage"]
|
||||
[node name="ChatTree" type="Tree" parent="Margin/VBox/Tabs/Chat Storage" unique_id=412639164]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
@@ -662,32 +656,37 @@ allow_reselect = true
|
||||
hide_root = true
|
||||
select_mode = 1
|
||||
|
||||
[node name="ChatStorageActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Chat Storage"]
|
||||
[node name="ChatStorageActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Chat Storage" unique_id=738888311]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="RefreshChatBtn" type="Button" parent="Margin/VBox/Tabs/Chat Storage/ChatStorageActionBar"]
|
||||
[node name="RefreshChatBtn" type="Button" parent="Margin/VBox/Tabs/Chat Storage/ChatStorageActionBar" unique_id=1962559221]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Refresh"
|
||||
text = "Load More"
|
||||
|
||||
[node name="Spacer" type="Control" parent="Margin/VBox/Tabs/Chat Storage/ChatStorageActionBar"]
|
||||
[node name="Spacer" type="Control" parent="Margin/VBox/Tabs/Chat Storage/ChatStorageActionBar" unique_id=1699988791]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="DeleteSelectedBtn" type="Button" parent="Margin/VBox/Tabs/Chat Storage/ChatStorageActionBar"]
|
||||
[node name="DeleteSelectedBtn" type="Button" parent="Margin/VBox/Tabs/Chat Storage/ChatStorageActionBar" unique_id=232174518]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(120, 36)
|
||||
layout_mode = 2
|
||||
text = "Delete Selected"
|
||||
|
||||
[node name="HistoryDialog" type="AcceptDialog" parent="."]
|
||||
[node name="StatusLabel" type="Label" parent="Margin/VBox" unique_id=895343638]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="HistoryDialog" type="AcceptDialog" parent="." unique_id=1324499735]
|
||||
unique_name_in_owner = true
|
||||
title = "User History"
|
||||
size = Vector2i(700, 500)
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="HistoryDialog"]
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="HistoryDialog" unique_id=1236659461]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
@@ -698,10 +697,11 @@ offset_bottom = -49.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="HistoryText" type="RichTextLabel" parent="HistoryDialog/ScrollContainer"]
|
||||
[node name="HistoryText" type="RichTextLabel" parent="HistoryDialog/ScrollContainer" unique_id=1203037388]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
focus_mode = 2
|
||||
bbcode_enabled = true
|
||||
selection_enabled = true
|
||||
|
||||
+97
-125
@@ -1,27 +1,11 @@
|
||||
[gd_scene format=3 uid="uid://gacha_panel_001"]
|
||||
[gd_scene format=3 uid="uid://bjs7jhl7a85rh"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/ui/gacha_panel.gd" id="1"]
|
||||
[ext_resource type="Script" uid="uid://clkxaudy5hxfj" path="res://scripts/ui/gacha_panel.gd" id="1"]
|
||||
[ext_resource type="Theme" uid="uid://cxab3xxy00" path="res://assets/themes/GUI_Tekton.tres" id="2"]
|
||||
[ext_resource type="FontFile" uid="uid://xnjx058n4tsw" path="res://assets/fonts/Nougat-ExtraBlack.ttf" id="3_font"]
|
||||
[ext_resource type="Texture2D" uid="uid://jqvv6s55mlsk" path="res://assets/graphics/gui/BG.png" id="4_bg"]
|
||||
[ext_resource type="Texture2D" uid="uid://b5pp08fke7ptd" path="res://assets/graphics/gui/lobby/gold.png" id="5_gold"]
|
||||
[ext_resource type="Texture2D" uid="uid://d0ouvm3x8h42c" path="res://assets/graphics/gui/lobby/star.png" id="6_star"]
|
||||
[ext_resource type="Texture2D" uid="uid://b6is65v4h87u8" path="res://assets/graphics/gui/lobby/star.png" id="tex_star"]
|
||||
[ext_resource type="Texture2D" uid="uid://be5i65v4h87u7" path="res://assets/graphics/gui/lobby/gold.png" id="tex_gold"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_OuterPanel"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 12.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 12.0
|
||||
bg_color = Color(0.14117648, 0.16862746, 0.19215687, 1)
|
||||
corner_radius_top_left = 12
|
||||
corner_radius_top_right = 12
|
||||
corner_radius_bottom_right = 12
|
||||
corner_radius_bottom_left = 12
|
||||
shadow_color = Color(0, 0, 0, 0.3529412)
|
||||
shadow_size = 4
|
||||
shadow_offset = Vector2(-2, 2)
|
||||
[ext_resource type="Texture2D" uid="uid://b5pp08fke7ptd" path="res://assets/graphics/gui/lobby/gold.png" id="tex_gold"]
|
||||
[ext_resource type="Texture2D" uid="uid://d0ouvm3x8h42c" path="res://assets/graphics/gui/lobby/star.png" id="tex_star"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_InnerDark"]
|
||||
bg_color = Color(0.1, 0.1, 0.1, 1)
|
||||
@@ -30,14 +14,6 @@ corner_radius_top_right = 6
|
||||
corner_radius_bottom_right = 6
|
||||
corner_radius_bottom_left = 6
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_gd4oi"]
|
||||
bg_color = Color(0, 0, 0, 0.48235294)
|
||||
border_color = Color(0.92941177, 0.91764706, 0.8862745, 1)
|
||||
corner_radius_top_left = 3
|
||||
corner_radius_top_right = 3
|
||||
corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_tab_inactive"]
|
||||
content_margin_left = 16.0
|
||||
content_margin_top = 14.0
|
||||
@@ -60,28 +36,29 @@ corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_banner_tab_inactive"]
|
||||
bg_color = Color(0.33, 0.62, 0.78, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_OuterPanel"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 12.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 12.0
|
||||
bg_color = Color(0.14117648, 0.16862746, 0.19215687, 1)
|
||||
corner_radius_top_left = 12
|
||||
corner_radius_top_right = 12
|
||||
corner_radius_bottom_right = 12
|
||||
corner_radius_bottom_left = 12
|
||||
shadow_color = Color(0, 0, 0, 0.3529412)
|
||||
shadow_size = 4
|
||||
shadow_offset = Vector2(-2, 2)
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_banner_tab_active"]
|
||||
bg_color = Color(0.1, 0.19, 0.27, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_gd4oi"]
|
||||
bg_color = Color(0, 0, 0, 0.48235294)
|
||||
border_color = Color(0.92941177, 0.91764706, 0.8862745, 1)
|
||||
corner_radius_top_left = 3
|
||||
corner_radius_top_right = 3
|
||||
corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_BtnDark"]
|
||||
bg_color = Color(0.15, 0.15, 0.15, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[node name="GachaPanel" type="Control"]
|
||||
[node name="GachaPanel" type="Control" unique_id=1349292500]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -91,7 +68,7 @@ grow_vertical = 2
|
||||
theme = ExtResource("2")
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Background" type="ColorRect" parent="."]
|
||||
[node name="Background" type="ColorRect" parent="." unique_id=1532983361]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
@@ -101,7 +78,7 @@ grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
color = Color(0.0627451, 0.0745098, 0.101961, 1)
|
||||
|
||||
[node name="BackgroundTexture" type="TextureRect" parent="."]
|
||||
[node name="BackgroundTexture" type="TextureRect" parent="." unique_id=1663610089]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -111,7 +88,7 @@ grow_vertical = 2
|
||||
texture = ExtResource("4_bg")
|
||||
expand_mode = 2
|
||||
|
||||
[node name="MainMargin" type="MarginContainer" parent="."]
|
||||
[node name="MainMargin" type="MarginContainer" parent="." unique_id=139511059]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -123,50 +100,50 @@ theme_override_constants/margin_top = 20
|
||||
theme_override_constants/margin_right = 28
|
||||
theme_override_constants/margin_bottom = 20
|
||||
|
||||
[node name="MainVBox" type="VBoxContainer" parent="MainMargin"]
|
||||
[node name="MainVBox" type="VBoxContainer" parent="MainMargin" unique_id=691618538]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 14
|
||||
|
||||
[node name="TopBar" type="HBoxContainer" parent="MainMargin/MainVBox"]
|
||||
[node name="TopBar" type="HBoxContainer" parent="MainMargin/MainVBox" unique_id=1625524615]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="BackBtn" type="Button" parent="MainMargin/MainVBox/TopBar"]
|
||||
[node name="BackBtn" type="Button" parent="MainMargin/MainVBox/TopBar" unique_id=1391183972]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(44, 44)
|
||||
layout_mode = 2
|
||||
text = "←"
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="MainMargin/MainVBox/TopBar"]
|
||||
[node name="TitleLabel" type="Label" parent="MainMargin/MainVBox/TopBar" unique_id=1690585510]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(1.0, 0.85, 0.25, 1)
|
||||
theme_override_colors/font_color = Color(1, 0.85, 0.25, 1)
|
||||
theme_override_fonts/font = ExtResource("3_font")
|
||||
theme_override_font_sizes/font_size = 26
|
||||
text = "✨ Gacha"
|
||||
|
||||
[node name="CurrencyRow" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar"]
|
||||
[node name="CurrencyRow" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar" unique_id=460847703]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
alignment = 2
|
||||
|
||||
[node name="StarPanel" type="PanelContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow"]
|
||||
[node name="StarPanel" type="PanelContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow" unique_id=1836425268]
|
||||
custom_minimum_size = Vector2(100, 0)
|
||||
layout_mode = 2
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_InnerDark")
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel"]
|
||||
[node name="Margin" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel" unique_id=1930556005]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 6
|
||||
theme_override_constants/margin_top = 4
|
||||
theme_override_constants/margin_right = 6
|
||||
theme_override_constants/margin_bottom = 4
|
||||
|
||||
[node name="HBox" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel/Margin"]
|
||||
[node name="HBox" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel/Margin" unique_id=187167010]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="Icon" type="TextureRect" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel/Margin/HBox"]
|
||||
[node name="Icon" type="TextureRect" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel/Margin/HBox" unique_id=1225334011]
|
||||
custom_minimum_size = Vector2(20, 20)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
@@ -174,7 +151,7 @@ texture = ExtResource("tex_star")
|
||||
expand_mode = 1
|
||||
stretch_mode = 5
|
||||
|
||||
[node name="StarLabel" type="Label" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel/Margin/HBox"]
|
||||
[node name="StarLabel" type="Label" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel/Margin/HBox" unique_id=522439690]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
@@ -183,23 +160,23 @@ theme_override_font_sizes/font_size = 14
|
||||
text = "0"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="GoldPanel" type="PanelContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow"]
|
||||
[node name="GoldPanel" type="PanelContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow" unique_id=35144569]
|
||||
custom_minimum_size = Vector2(100, 0)
|
||||
layout_mode = 2
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_InnerDark")
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel"]
|
||||
[node name="Margin" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel" unique_id=1920706991]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 6
|
||||
theme_override_constants/margin_top = 4
|
||||
theme_override_constants/margin_right = 6
|
||||
theme_override_constants/margin_bottom = 4
|
||||
|
||||
[node name="HBox" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel/Margin"]
|
||||
[node name="HBox" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel/Margin" unique_id=1860851243]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="Icon" type="TextureRect" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel/Margin/HBox"]
|
||||
[node name="Icon" type="TextureRect" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel/Margin/HBox" unique_id=1342503990]
|
||||
custom_minimum_size = Vector2(20, 20)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
@@ -207,7 +184,7 @@ texture = ExtResource("tex_gold")
|
||||
expand_mode = 1
|
||||
stretch_mode = 5
|
||||
|
||||
[node name="GoldLabel" type="Label" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel/Margin/HBox"]
|
||||
[node name="GoldLabel" type="Label" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel/Margin/HBox" unique_id=1096459165]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
@@ -216,86 +193,86 @@ theme_override_font_sizes/font_size = 14
|
||||
text = "0"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="CraftBtn" type="Button" parent="MainMargin/MainVBox/TopBar"]
|
||||
[node name="CraftBtn" type="Button" parent="MainMargin/MainVBox/TopBar" unique_id=922059235]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(130, 40)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.4, 1.0, 0.7, 1)
|
||||
theme_override_colors/font_color = Color(0.4, 1, 0.7, 1)
|
||||
theme_override_fonts/font = ExtResource("3_font")
|
||||
theme_override_font_sizes/font_size = 14
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
|
||||
text = "🧩 Fragment Craft"
|
||||
|
||||
[node name="BannerTabs" type="HBoxContainer" parent="MainMargin/MainVBox"]
|
||||
[node name="BannerTabs" type="HBoxContainer" parent="MainMargin/MainVBox" unique_id=36917097]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
alignment = 1
|
||||
|
||||
[node name="StarTabBtn" type="Button" parent="MainMargin/MainVBox/BannerTabs"]
|
||||
[node name="StarTabBtn" type="Button" parent="MainMargin/MainVBox/BannerTabs" unique_id=267357394]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(130, 38)
|
||||
layout_mode = 2
|
||||
theme_override_fonts/font = ExtResource("3_font")
|
||||
theme_override_font_sizes/font_size = 14
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
|
||||
text = "✦ Star Banner"
|
||||
|
||||
[node name="GoldTabBtn" type="Button" parent="MainMargin/MainVBox/BannerTabs"]
|
||||
[node name="GoldTabBtn" type="Button" parent="MainMargin/MainVBox/BannerTabs" unique_id=1043151664]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(130, 38)
|
||||
layout_mode = 2
|
||||
theme_override_fonts/font = ExtResource("3_font")
|
||||
theme_override_font_sizes/font_size = 14
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
|
||||
text = "▤ Gold Banner"
|
||||
|
||||
[node name="ContentHBox" type="HBoxContainer" parent="MainMargin/MainVBox"]
|
||||
[node name="ContentHBox" type="HBoxContainer" parent="MainMargin/MainVBox" unique_id=1498567570]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 20
|
||||
|
||||
[node name="LeftPanel" type="PanelContainer" parent="MainMargin/MainVBox/ContentHBox"]
|
||||
[node name="LeftPanel" type="PanelContainer" parent="MainMargin/MainVBox/ContentHBox" unique_id=1542327161]
|
||||
custom_minimum_size = Vector2(320, 0)
|
||||
layout_mode = 2
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel")
|
||||
|
||||
[node name="LeftMargin" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel"]
|
||||
[node name="LeftMargin" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel" unique_id=443861949]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 18
|
||||
theme_override_constants/margin_top = 18
|
||||
theme_override_constants/margin_right = 18
|
||||
theme_override_constants/margin_bottom = 18
|
||||
|
||||
[node name="LeftVBox" type="VBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin"]
|
||||
[node name="LeftVBox" type="VBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin" unique_id=1376947326]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 14
|
||||
|
||||
[node name="BannerLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox"]
|
||||
[node name="BannerLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox" unique_id=128145824]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(1.0, 0.85, 0.25, 1)
|
||||
theme_override_colors/font_color = Color(1, 0.85, 0.25, 1)
|
||||
theme_override_fonts/font = ExtResource("3_font")
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "Star Banner"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="BalanceRow" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox"]
|
||||
[node name="BalanceRow" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox" unique_id=908965342]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 6
|
||||
alignment = 1
|
||||
|
||||
[node name="GoldPanel" type="Panel" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow"]
|
||||
[node name="GoldPanel" type="Panel" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow" unique_id=1862665063]
|
||||
custom_minimum_size = Vector2(100, 30)
|
||||
layout_mode = 2
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_gd4oi")
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel"]
|
||||
[node name="MarginContainer" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel" unique_id=375107954]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -307,17 +284,16 @@ theme_override_constants/margin_top = 3
|
||||
theme_override_constants/margin_right = 6
|
||||
theme_override_constants/margin_bottom = 3
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel/MarginContainer"]
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel/MarginContainer" unique_id=21246450]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="TextureRect" type="TextureRect" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel/MarginContainer/HBoxContainer"]
|
||||
[node name="TextureRect" type="TextureRect" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel/MarginContainer/HBoxContainer" unique_id=65278254]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 0
|
||||
size_flags_vertical = 4
|
||||
texture = ExtResource("5_gold")
|
||||
texture = ExtResource("tex_gold")
|
||||
|
||||
[node name="GoldLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel/MarginContainer/HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
[node name="GoldLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel/MarginContainer/HBoxContainer" unique_id=1159494172]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_fonts/font = ExtResource("3_font")
|
||||
@@ -325,12 +301,12 @@ theme_override_font_sizes/font_size = 18
|
||||
text = "0"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="StarPanel" type="Panel" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow"]
|
||||
[node name="StarPanel" type="Panel" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow" unique_id=193442837]
|
||||
custom_minimum_size = Vector2(100, 30)
|
||||
layout_mode = 2
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_gd4oi")
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel"]
|
||||
[node name="MarginContainer" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel" unique_id=506401407]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -342,17 +318,16 @@ theme_override_constants/margin_top = 3
|
||||
theme_override_constants/margin_right = 6
|
||||
theme_override_constants/margin_bottom = 3
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel/MarginContainer"]
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel/MarginContainer" unique_id=51102528]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="TextureRect" type="TextureRect" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel/MarginContainer/HBoxContainer"]
|
||||
[node name="TextureRect" type="TextureRect" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel/MarginContainer/HBoxContainer" unique_id=1476183321]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 0
|
||||
size_flags_vertical = 4
|
||||
texture = ExtResource("6_star")
|
||||
texture = ExtResource("tex_star")
|
||||
|
||||
[node name="StarLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel/MarginContainer/HBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
[node name="StarLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel/MarginContainer/HBoxContainer" unique_id=1357103504]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_fonts/font = ExtResource("3_font")
|
||||
@@ -360,7 +335,7 @@ theme_override_font_sizes/font_size = 18
|
||||
text = "0"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="BalanceLbl" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow"]
|
||||
[node name="BalanceLbl" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow" unique_id=1997926367]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.7, 0.7, 0.7, 1)
|
||||
@@ -368,7 +343,7 @@ theme_override_font_sizes/font_size = 13
|
||||
text = "Balance:"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="BalanceLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow"]
|
||||
[node name="BalanceLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow" unique_id=1743519745]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
@@ -378,7 +353,7 @@ theme_override_font_sizes/font_size = 18
|
||||
text = "✦ 0"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="PityLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox"]
|
||||
[node name="PityLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox" unique_id=379092911]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.6, 0.6, 0.9, 1)
|
||||
@@ -386,14 +361,14 @@ theme_override_font_sizes/font_size = 13
|
||||
text = "Pity: 0 / 90"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="HSep1" type="HSeparator" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox"]
|
||||
[node name="HSep1" type="HSeparator" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox" unique_id=2009144054]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Pull1Row" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox"]
|
||||
[node name="Pull1Row" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox" unique_id=1253450919]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Pull1Btn" type="Button" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull1Row"]
|
||||
[node name="Pull1Btn" type="Button" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull1Row" unique_id=1313297675]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 44)
|
||||
layout_mode = 2
|
||||
@@ -401,11 +376,11 @@ size_flags_horizontal = 3
|
||||
theme_override_fonts/font = ExtResource("3_font")
|
||||
theme_override_font_sizes/font_size = 16
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
|
||||
text = "1× Pull"
|
||||
|
||||
[node name="Cost1Label" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull1Row"]
|
||||
[node name="Cost1Label" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull1Row" unique_id=151347195]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.9, 0.75, 0.2, 1)
|
||||
@@ -413,11 +388,11 @@ theme_override_fonts/font = ExtResource("3_font")
|
||||
theme_override_font_sizes/font_size = 14
|
||||
text = "✦ 160"
|
||||
|
||||
[node name="Pull10Row" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox"]
|
||||
[node name="Pull10Row" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox" unique_id=1677571152]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Pull10Btn" type="Button" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull10Row"]
|
||||
[node name="Pull10Btn" type="Button" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull10Row" unique_id=1681522273]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 44)
|
||||
layout_mode = 2
|
||||
@@ -425,11 +400,11 @@ size_flags_horizontal = 3
|
||||
theme_override_fonts/font = ExtResource("3_font")
|
||||
theme_override_font_sizes/font_size = 16
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
|
||||
text = "10× Pull"
|
||||
|
||||
[node name="Cost10Label" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull10Row"]
|
||||
[node name="Cost10Label" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull10Row" unique_id=191013010]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.9, 0.75, 0.2, 1)
|
||||
@@ -437,41 +412,37 @@ theme_override_fonts/font = ExtResource("3_font")
|
||||
theme_override_font_sizes/font_size = 14
|
||||
text = "✦ 1440"
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox"]
|
||||
[node name="StatusLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox" unique_id=447296799]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(1.0, 1.0, 1.0, 1)
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_font_sizes/font_size = 13
|
||||
horizontal_alignment = 1
|
||||
text = ""
|
||||
|
||||
[node name="RightPanel" type="PanelContainer" parent="MainMargin/MainVBox/ContentHBox"]
|
||||
[node name="RightPanel" type="PanelContainer" parent="MainMargin/MainVBox/ContentHBox" unique_id=208396625]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel")
|
||||
|
||||
[node name="RightMargin" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/RightPanel"]
|
||||
[node name="RightMargin" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/RightPanel" unique_id=988364101]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 16
|
||||
theme_override_constants/margin_top = 16
|
||||
theme_override_constants/margin_right = 16
|
||||
theme_override_constants/margin_bottom = 16
|
||||
|
||||
[node name="RatesLabel" type="RichTextLabel" parent="MainMargin/MainVBox/ContentHBox/RightPanel/RightMargin"]
|
||||
[node name="RatesLabel" type="RichTextLabel" parent="MainMargin/MainVBox/ContentHBox/RightPanel/RightMargin" unique_id=436846225]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
theme_override_colors/default_color = Color(0.75, 0.78, 0.82, 1)
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = ""
|
||||
fit_content = true
|
||||
|
||||
[node name="ResultPanel" type="PanelContainer" parent="."]
|
||||
[node name="ResultPanel" type="PanelContainer" parent="." unique_id=1303462967]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel")
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
@@ -483,12 +454,13 @@ offset_right = 450.0
|
||||
offset_bottom = 260.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel")
|
||||
|
||||
[node name="ResultVBox" type="VBoxContainer" parent="ResultPanel"]
|
||||
[node name="ResultVBox" type="VBoxContainer" parent="ResultPanel" unique_id=893629253]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="ResultTitle" type="Label" parent="ResultPanel/ResultVBox"]
|
||||
[node name="ResultTitle" type="Label" parent="ResultPanel/ResultVBox" unique_id=1336286642]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(1, 0.9, 0.3, 1)
|
||||
theme_override_fonts/font = ExtResource("3_font")
|
||||
@@ -496,12 +468,12 @@ theme_override_font_sizes/font_size = 20
|
||||
text = "Pull Results"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="ResultPanel/ResultVBox"]
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="ResultPanel/ResultVBox" unique_id=411371159]
|
||||
custom_minimum_size = Vector2(0, 160)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
custom_minimum_size = Vector2(0, 160)
|
||||
|
||||
[node name="ResultGrid" type="GridContainer" parent="ResultPanel/ResultVBox/ScrollContainer"]
|
||||
[node name="ResultGrid" type="GridContainer" parent="ResultPanel/ResultVBox/ScrollContainer" unique_id=1917152748]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
@@ -509,12 +481,12 @@ theme_override_constants/h_separation = 8
|
||||
theme_override_constants/v_separation = 8
|
||||
columns = 5
|
||||
|
||||
[node name="CloseResultBtn" type="Button" parent="ResultPanel/ResultVBox"]
|
||||
[node name="CloseResultBtn" type="Button" parent="ResultPanel/ResultVBox" unique_id=1215289477]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 38)
|
||||
layout_mode = 2
|
||||
theme_override_fonts/font = ExtResource("3_font")
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
|
||||
text = "Close"
|
||||
|
||||
Binary file not shown.
@@ -16,7 +16,7 @@ static func from_string(mode: String) -> Mode:
|
||||
return Mode.STOP_N_GO
|
||||
"Tekton Doors":
|
||||
return Mode.TEKTON_DOORS
|
||||
"Candy Cannon Survival":
|
||||
"Candy Pump Survival":
|
||||
return Mode.GAUNTLET
|
||||
_:
|
||||
return Mode.FREEMODE
|
||||
@@ -30,7 +30,7 @@ static func mode_to_string(mode: Mode) -> String:
|
||||
Mode.TEKTON_DOORS:
|
||||
return "Tekton Doors"
|
||||
Mode.GAUNTLET:
|
||||
return "Candy Cannon Survival"
|
||||
return "Candy Pump Survival"
|
||||
_:
|
||||
return "Freemode"
|
||||
|
||||
@@ -38,4 +38,4 @@ static func is_restricted(mode: Mode) -> bool:
|
||||
return mode == Mode.STOP_N_GO or mode == Mode.TEKTON_DOORS or mode == Mode.GAUNTLET
|
||||
|
||||
static func get_all_modes() -> Array[String]:
|
||||
return ["Freemode", "Stop n Go", "Tekton Doors", "Candy Cannon Survival"]
|
||||
return ["Freemode", "Stop n Go", "Tekton Doors", "Candy Pump Survival"]
|
||||
|
||||
@@ -12,11 +12,12 @@ 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 }
|
||||
|
||||
# Bounds Definitions { min_x, max_x, min_z, max_z }
|
||||
var bounds_freemode = { "min_x": 3.0, "max_x": 11.0, "min_z": 13.0, "max_z": 22.5 }
|
||||
var bounds_stop_n_go = { "min_x": 3.0, "max_x": 19.5, "min_z": 13.0, "max_z": 19.5 }
|
||||
var bounds_doors = { "min_x": 7.0, "max_x": 7.0, "min_z": 25.8, "max_z": 25.8 } # Static overlook
|
||||
var bounds_gauntlet = { "min_x": 0.0, "max_x": 20.0, "min_z": 0.0, "max_z": 20.0 } # 20x20 arena
|
||||
|
||||
func initialize(p_camera: Camera3D, _p_shake_manager: Node):
|
||||
camera = p_camera
|
||||
@@ -29,38 +30,38 @@ func set_player(p_player: Node3D):
|
||||
func _physics_process(delta):
|
||||
if not player or not camera or not is_instance_valid(player):
|
||||
return
|
||||
|
||||
|
||||
var target_pos = _calculate_target_position()
|
||||
|
||||
|
||||
# Smoothly interpolate to target
|
||||
camera.position = camera.position.lerp(target_pos, smooth_speed * delta)
|
||||
|
||||
func _calculate_target_position() -> Vector3:
|
||||
var player_pos = player.global_position
|
||||
|
||||
var mode = LobbyManager.get_game_mode()
|
||||
|
||||
# Initial target based on player position + offsets
|
||||
var target_x = player_pos.x
|
||||
var target_y = default_y
|
||||
var target_z = player_pos.z + z_offset
|
||||
|
||||
|
||||
# Apply Mode-Specific Clamping
|
||||
var mode = LobbyManager.get_game_mode()
|
||||
var bounds = bounds_freemode # Default
|
||||
|
||||
if mode == GameMode.Mode.STOP_N_GO:
|
||||
|
||||
if mode == GameMode.Mode.GAUNTLET:
|
||||
bounds = bounds_gauntlet
|
||||
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
|
||||
elif mode == GameMode.Mode.GAUNTLET:
|
||||
bounds = bounds_gauntlet
|
||||
|
||||
|
||||
# Clamp X and Z
|
||||
target_x = clamp(target_x, bounds.min_x, bounds.max_x)
|
||||
target_z = clamp(target_z, bounds.min_z, bounds.max_z)
|
||||
|
||||
|
||||
# Special case for Setup C in Freemode (Lower Y at bottom edges)
|
||||
if mode == GameMode.Mode.FREEMODE and target_z > 21.0:
|
||||
target_y = 19.22636
|
||||
|
||||
|
||||
return Vector3(target_x, target_y, target_z)
|
||||
|
||||
+1027
-252
@@ -1,11 +1,11 @@
|
||||
extends Node
|
||||
class_name GauntletManager
|
||||
|
||||
# GauntletManager - Handles Candy Cannon Survival (Gauntlet) game mode
|
||||
# GauntletManager - Handles Candy Pump Survival (Gauntlet) game mode
|
||||
# Pattern: StopNGoManager + PortalModeManager
|
||||
|
||||
signal phase_changed(phase_index: int, phase_name: String)
|
||||
signal cannon_fired(targets: Array)
|
||||
signal growth_tick(cells: Array)
|
||||
signal player_trapped(player_id: int)
|
||||
signal cleanser_granted(player_id: int)
|
||||
|
||||
@@ -24,6 +24,20 @@ 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
|
||||
CLEANSED, # Recently cleaned by Cleanser (temp protection)
|
||||
}
|
||||
|
||||
# Cells temporarily protected after a Cleanser pass (Vector2i -> time remaining).
|
||||
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
|
||||
@@ -39,23 +53,61 @@ var elapsed_time: float = 0.0
|
||||
var is_active: bool = false
|
||||
|
||||
# =============================================================================
|
||||
# Cannon State
|
||||
# Growth State (v2 ground-growth model — replaces cannon volley)
|
||||
# =============================================================================
|
||||
|
||||
var cannon_timer: float = 0.0
|
||||
var cannon_interval: float = 5.0 # seconds between volleys
|
||||
var volley_size: int = 5
|
||||
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 last_targeted_player_id: int = -1
|
||||
var telegraphed_cells: Dictionary = {} # Vector2i → time remaining (still passable)
|
||||
var _last_tick_cells: Array = [] # cells selected last tick (for repetition penalty)
|
||||
|
||||
# Phase-specific cannon parameters
|
||||
var phase_configs: Array = [
|
||||
# Phase 0 (Open Arena): slow, small volleys
|
||||
{"interval": 5.0, "volley": 5, "telegraph_time": 1.2},
|
||||
# Phase 1 (Route Pressure): faster, bigger volleys
|
||||
{"interval": 4.0, "volley": 8, "telegraph_time": 1.0},
|
||||
# Phase 2 (Survival Endgame): rapid fire, huge volleys
|
||||
{"interval": 3.0, "volley": 12, "telegraph_time": 0.8},
|
||||
# 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}},
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
@@ -108,7 +160,10 @@ const CLEANSER_ACTIVATION_DELAY: float = 0.3
|
||||
# Trapped Players
|
||||
# =============================================================================
|
||||
|
||||
var trapped_players: Dictionary = {} # player_id → true
|
||||
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
|
||||
@@ -126,8 +181,10 @@ var slowmo_overlay: ColorRect = null
|
||||
|
||||
var main_scene: Node = null
|
||||
var gridmap: Node = null
|
||||
var candy_cannon_scene: PackedScene = preload("res://scenes/candy_cannon.tscn")
|
||||
var cannon_instance: Node3D = 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
|
||||
@@ -177,11 +234,24 @@ func _process(delta: float) -> void:
|
||||
|
||||
# Server only logic
|
||||
if multiplayer.is_server():
|
||||
# Cannon timer
|
||||
cannon_timer -= delta
|
||||
if cannon_timer <= 0.0:
|
||||
_fire_volley()
|
||||
cannon_timer = cannon_interval
|
||||
# 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")
|
||||
@@ -255,11 +325,18 @@ func _check_phase_transition() -> void:
|
||||
|
||||
func _start_phase(phase: Phase) -> void:
|
||||
current_phase = phase
|
||||
var config = phase_configs[int(phase)]
|
||||
cannon_interval = config["interval"]
|
||||
volley_size = config["volley"]
|
||||
cannon_timer = cannon_interval
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -271,11 +348,11 @@ func _start_phase(phase: Phase) -> void:
|
||||
func _phase_to_string(phase: Phase) -> String:
|
||||
match phase:
|
||||
Phase.OPEN_ARENA:
|
||||
return "Open Arena"
|
||||
return "Outer Pressure"
|
||||
Phase.ROUTE_PRESSURE:
|
||||
return "Route Pressure"
|
||||
return "Middle Pressure"
|
||||
Phase.SURVIVAL_ENDGAME:
|
||||
return "Survival!"
|
||||
return "Inner Survival"
|
||||
_:
|
||||
return "Unknown"
|
||||
|
||||
@@ -284,9 +361,6 @@ func sync_phase(phase_index: int, phase_name: String) -> void:
|
||||
if not is_active:
|
||||
activate_client_side()
|
||||
current_phase = phase_index as Phase
|
||||
var config = phase_configs[phase_index]
|
||||
cannon_interval = config["interval"]
|
||||
volley_size = config["volley"]
|
||||
_update_hud_phase(phase_name)
|
||||
|
||||
# =============================================================================
|
||||
@@ -337,7 +411,7 @@ func _apply_arena_setup() -> void:
|
||||
for z in range(ARENA_ROWS):
|
||||
var pos = Vector2i(x, z)
|
||||
|
||||
# Center 3x3 block: NPC obstacle (Candy Cannon)
|
||||
# Center 3x3 block: NPC obstacle (Candy Pump)
|
||||
if _is_npc_zone(pos):
|
||||
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_OBSTACLE)
|
||||
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||||
@@ -357,13 +431,13 @@ func _apply_arena_setup() -> void:
|
||||
gridmap.update_grid_data()
|
||||
gridmap.initialize_astar()
|
||||
|
||||
if not cannon_instance and main_scene:
|
||||
cannon_instance = candy_cannon_scene.instantiate()
|
||||
cannon_instance.name = "CandyCannon"
|
||||
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
|
||||
cannon_instance.position = Vector3(cx, 0, cz)
|
||||
main_scene.add_child(cannon_instance)
|
||||
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
|
||||
@@ -433,7 +507,7 @@ func _spawn_mission_tiles() -> void:
|
||||
for z in range(ARENA_ROWS):
|
||||
var pos = Vector2i(x, z)
|
||||
|
||||
# Skip NPC cannon zone (center 3x3)
|
||||
# Skip NPC pump zone (center 3x3)
|
||||
if _is_npc_zone(pos):
|
||||
continue
|
||||
|
||||
@@ -463,234 +537,400 @@ func _spawn_mission_tiles() -> void:
|
||||
print("[Gauntlet] Spawned %d mission tiles across %dx%d arena" % [tiles_spawned, ARENA_COLUMNS, ARENA_ROWS])
|
||||
|
||||
# =============================================================================
|
||||
# Cannon Logic (Server Only)
|
||||
# Growth Logic (Server Only) — v2 ground-growth, replaces cannon volley
|
||||
# =============================================================================
|
||||
|
||||
func _fire_volley() -> void:
|
||||
"""Select target cells, highlight, telegraph, then apply sticky after delay."""
|
||||
func _process_growth_tick() -> void:
|
||||
"""One growth tick: score SAFE cells, weight-select, path-check, telegraph."""
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
|
||||
var targets = _select_targets()
|
||||
if targets.is_empty():
|
||||
return
|
||||
|
||||
var config = phase_configs[int(current_phase)]
|
||||
var telegraph_time = config["telegraph_time"]
|
||||
var highlight_time: float = 0.8 # Floor highlight duration before telegraph
|
||||
|
||||
# Highlight phase — show pulsing floor warning BEFORE telegraph
|
||||
if _can_rpc():
|
||||
rpc("sync_telegraph_highlight", targets)
|
||||
await get_tree().create_timer(highlight_time).timeout
|
||||
|
||||
# Telegraph phase — show warning overlay
|
||||
if _can_rpc():
|
||||
rpc("sync_telegraph", targets)
|
||||
|
||||
# Shoot projectiles visually with 0.1s offset between shots
|
||||
if cannon_instance and cannon_instance.has_method("spawn_projectile_rpc") and cannon_instance.can_rpc():
|
||||
var cs = gridmap.cell_size
|
||||
for i in range(targets.size()):
|
||||
var target = targets[i]
|
||||
var target_pos = Vector3(target.x * cs.x + cs.x / 2.0, 0, target.y * cs.z + cs.z / 2.0)
|
||||
# Stagger shots: 0.1s offset per projectile
|
||||
await get_tree().create_timer(i * 0.1).timeout
|
||||
cannon_instance.rpc("spawn_projectile_rpc", target_pos, telegraph_time)
|
||||
|
||||
# Wait remaining telegraph duration, then apply impact
|
||||
var remaining_time = telegraph_time - (targets.size() - 1) * 0.1
|
||||
if remaining_time > 0:
|
||||
await get_tree().create_timer(remaining_time).timeout
|
||||
|
||||
if _can_rpc():
|
||||
rpc("sync_impact", targets)
|
||||
|
||||
emit_signal("cannon_fired", targets)
|
||||
|
||||
func _select_targets() -> Array:
|
||||
"""Pick target cells for this volley based on current phase weights."""
|
||||
var targets: Array = []
|
||||
var all_players = get_tree().get_nodes_in_group("Players")
|
||||
|
||||
# Collect all valid walkable positions (excluding NPC zone and existing sticky)
|
||||
var valid_positions: Array = []
|
||||
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)
|
||||
if _is_npc_zone(pos):
|
||||
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
|
||||
if sticky_cells.has(pos):
|
||||
continue
|
||||
valid_positions.append(pos)
|
||||
|
||||
if valid_positions.is_empty():
|
||||
return targets
|
||||
|
||||
# Simple targeting: mix of random + player-adjacent
|
||||
var remaining = volley_size
|
||||
|
||||
# 40% of volley near players
|
||||
var player_targets = int(remaining * 0.4)
|
||||
for i in range(player_targets):
|
||||
if all_players.is_empty():
|
||||
break
|
||||
# Pick a random player
|
||||
var player = all_players[randi() % all_players.size()]
|
||||
var player_pos = player.current_position if player.get("current_position") else Vector2i(10, 10)
|
||||
|
||||
# Pick a cell near them (within 3 tiles)
|
||||
var nearby = _get_nearby_valid_cells(player_pos, 3, valid_positions)
|
||||
if not nearby.is_empty():
|
||||
var target = nearby[randi() % nearby.size()]
|
||||
if target not in targets:
|
||||
targets.append(target)
|
||||
remaining -= 1
|
||||
|
||||
# Remaining: random scatter
|
||||
valid_positions.shuffle()
|
||||
for pos in valid_positions:
|
||||
if remaining <= 0:
|
||||
break
|
||||
if pos not in targets:
|
||||
targets.append(pos)
|
||||
remaining -= 1
|
||||
|
||||
return targets
|
||||
candidates.append({"pos": pos, "score": _calculate_candidate_score(pos, player_cells)})
|
||||
return candidates
|
||||
|
||||
func _get_nearby_valid_cells(center: Vector2i, radius: int, valid: Array) -> Array:
|
||||
var result: Array = []
|
||||
for pos in valid:
|
||||
if abs(pos.x - center.x) <= radius and abs(pos.y - center.y) <= radius:
|
||||
result.append(pos)
|
||||
return result
|
||||
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
|
||||
|
||||
# =============================================================================
|
||||
# Telegraph & Impact (RPCs)
|
||||
# =============================================================================
|
||||
# Growth Telegraph & Apply (RPCs) — v2
|
||||
# =============================================================================
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_telegraph_highlight(targets: Array) -> void:
|
||||
"""Show pulsing floor highlight on target cells BEFORE the telegraph drop."""
|
||||
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
|
||||
|
||||
# Create programmatic highlight overlays (pulsing circles on floor)
|
||||
for target in targets:
|
||||
var pos = target as Vector2i
|
||||
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)
|
||||
|
||||
# Create a flat pulsing indicator mesh
|
||||
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 mat = StandardMaterial3D.new()
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.albedo_color = Color(1.0, 0.3, 0.5, 0.4) # Pink warning glow
|
||||
mat.emission_enabled = true
|
||||
mat.emission = Color(1.0, 0.3, 0.5)
|
||||
mat.emission_energy_multiplier = 2.0
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mesh_inst.material_override = mat
|
||||
|
||||
# Add to scene tree under main
|
||||
var main = get_node_or_null("/root/Main")
|
||||
if main:
|
||||
main.add_child(mesh_inst)
|
||||
# Pulse animation
|
||||
var tween = create_tween().set_loops()
|
||||
tween.tween_method(func(a): mat.albedo_color.a = a, 0.4, 0.1, 0.2)
|
||||
tween.tween_method(func(a): mat.albedo_color.a = a, 0.1, 0.4, 0.2)
|
||||
# Auto-remove after highlight duration
|
||||
var remove_timer = get_tree().create_timer(0.8)
|
||||
remove_timer.timeout.connect(func():
|
||||
if is_instance_valid(mesh_inst):
|
||||
mesh_inst.queue_free()
|
||||
)
|
||||
|
||||
# Play warning sound
|
||||
if SfxManager:
|
||||
SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile")
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_telegraph(targets: Array) -> void:
|
||||
"""Show warning overlay on target cells with multi-stage animation."""
|
||||
if not gridmap: return
|
||||
|
||||
# Place telegraph tiles
|
||||
for target in targets:
|
||||
var pos = target as Vector2i
|
||||
for cell in cells:
|
||||
var pos = cell as Vector2i
|
||||
# Telegraph overlay tile on Layer 2 (still passable).
|
||||
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH)
|
||||
|
||||
# Animate telegraph with Tween (build-up phase)
|
||||
_animate_telegraph(targets)
|
||||
_spawn_telegraph_highlight(pos)
|
||||
|
||||
func _animate_telegraph(targets: Array) -> void:
|
||||
"""Tween animation for telegraph: fade in, flash, then transition to sticky."""
|
||||
var config = phase_configs[int(current_phase)]
|
||||
var telegraph_time = config["telegraph_time"]
|
||||
var build_up_time = telegraph_time * 0.8 # 80% for build-up
|
||||
var flash_time = telegraph_time * 0.2 # 20% for flash
|
||||
|
||||
# Create tween for visual feedback
|
||||
var tween = create_tween()
|
||||
tween.set_parallel(true)
|
||||
|
||||
# Phase 1: Fade in (alpha 0 -> 1) during build-up
|
||||
# Note: GridMap tiles don't support alpha directly, so we use modulation
|
||||
# We'll animate the gridmap overlay opacity conceptually
|
||||
for target in targets:
|
||||
var pos = target as Vector2i
|
||||
# Tween the cell brightness by swapping between telegraph variants
|
||||
tween.tween_callback(_flash_telegraph.bind(targets, 0)).set_delay(0.0)
|
||||
tween.tween_callback(_flash_telegraph.bind(targets, 1)).set_delay(0.4)
|
||||
tween.tween_callback(_flash_telegraph.bind(targets, 0)).set_delay(0.8)
|
||||
|
||||
# Audio: rising pitch during build-up
|
||||
# Audio: warning pulse
|
||||
if SfxManager:
|
||||
SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile")
|
||||
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
|
||||
func _flash_telegraph(targets: Array, brightness: int) -> void:
|
||||
"""Flicker telegraph tiles between normal and bright."""
|
||||
if not gridmap: return
|
||||
# Toggle visual feedback - in full implementation would modify material/overlay
|
||||
# For now, this provides the timing structure for the animation
|
||||
pass
|
||||
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_impact(targets: Array) -> void:
|
||||
"""Apply sticky cells at target positions."""
|
||||
func sync_growth_apply(cells: Array) -> void:
|
||||
"""Convert telegraphed cells to permanent sticky candy."""
|
||||
if not gridmap: return
|
||||
for target in targets:
|
||||
var pos = target as Vector2i
|
||||
# Replace telegraph with sticky on Layer 2
|
||||
for cell in cells:
|
||||
var pos = cell as Vector2i
|
||||
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: impact splat sound
|
||||
|
||||
# Audio: sticky splat
|
||||
if SfxManager:
|
||||
SfxManager.rpc("play_rpc", "tile_scatter") if _can_rpc() else SfxManager.play("tile_scatter")
|
||||
|
||||
# Spawn candy splash particles at impact locations
|
||||
_spawn_impact_particles(targets)
|
||||
|
||||
# Check if any player is now trapped
|
||||
|
||||
_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(
|
||||
@@ -698,7 +938,7 @@ func _spawn_impact_particles(targets: Array) -> void:
|
||||
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
|
||||
@@ -706,7 +946,7 @@ func _spawn_impact_particles(targets: Array) -> void:
|
||||
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
|
||||
@@ -718,12 +958,12 @@ func _spawn_impact_particles(targets: Array) -> void:
|
||||
material.gravity = Vector3(0, -9.8, 0)
|
||||
material.scale_min = 0.1
|
||||
material.scale_max = 0.3
|
||||
|
||||
|
||||
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):
|
||||
@@ -736,33 +976,564 @@ func _spawn_impact_particles(targets: Array) -> void:
|
||||
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)."""
|
||||
if _is_npc_zone(pos) or _is_boundary(pos):
|
||||
return CellState.BLOCKED
|
||||
if sticky_cells.has(pos):
|
||||
return CellState.STICKY
|
||||
if cleansed_cells.has(pos):
|
||||
return CellState.CLEANSED
|
||||
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 _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 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
|
||||
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?"""
|
||||
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
|
||||
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-cleanser."""
|
||||
var t := _camp_time_for_region(_region_of(pos))
|
||||
if t > 10.0:
|
||||
# Stronger only if a nearby player actually holds a cleanser.
|
||||
if _any_cleanser_holder_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 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
|
||||
cells.append(c)
|
||||
return cells
|
||||
|
||||
func _any_cleanser_holder_near(pos: Vector2i) -> bool:
|
||||
"""True if a player holding a Cleanser charge is within the camping region."""
|
||||
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:
|
||||
continue
|
||||
if player_cleansers.get(pid, 0) <= 0:
|
||||
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 is_cleanser_active(pid):
|
||||
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
|
||||
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")
|
||||
|
||||
@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
|
||||
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_STICKY)
|
||||
# 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
|
||||
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) and not trapped_players.has(player.get("peer_id") if "peer_id" in player else -1):
|
||||
_trap_player(player)
|
||||
if is_sticky_cell(pos):
|
||||
var pid = player.get("peer_id") if "peer_id" in player else -1
|
||||
if pid != -1 and is_cleanser_active(pid):
|
||||
continue # cleansing 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:
|
||||
"""Used by Cleanser power-up to remove a sticky cell."""
|
||||
sticky_cells.erase(pos)
|
||||
mark_cleansed(pos) # temporary regrowth protection (v2)
|
||||
if gridmap:
|
||||
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1)
|
||||
|
||||
@@ -816,13 +1587,7 @@ func _try_use_cleanser() -> void:
|
||||
elif multiplayer.is_server():
|
||||
if _can_rpc():
|
||||
rpc("sync_cleanser_count", local_pid, 0)
|
||||
|
||||
# Trigger slow-mo for dramatic effect
|
||||
if multiplayer.is_server():
|
||||
trigger_slowmo()
|
||||
else:
|
||||
rpc("rpc_trigger_slowmo")
|
||||
|
||||
|
||||
NotificationManager.send_message(local_player, "Cleanser Active! (5 cells)", NotificationManager.MessageType.POWERUP)
|
||||
|
||||
func deactivate_cleanser(player_id: int) -> void:
|
||||
@@ -844,6 +1609,16 @@ func use_cleanser_cell(player_id: int) -> bool:
|
||||
return false
|
||||
return true
|
||||
|
||||
func notify_movement_stopped(player_id: int, pos: Vector2i) -> void:
|
||||
"""Cleanser also ends when the player comes to rest on a safe (non-sticky)
|
||||
cell — they're clear of the candy, so immunity is no longer needed (#072).
|
||||
Called from PlayerMovementManager when a move chain settles. Gauntlet-only;
|
||||
a no-op when the player has no active cleanser."""
|
||||
if not cleanser_active.has(player_id):
|
||||
return
|
||||
if not is_sticky_cell(pos):
|
||||
deactivate_cleanser(player_id)
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func rpc_activate_cleanser(pid: int) -> void:
|
||||
"""RPC for clients to activate cleanser on server."""
|
||||
@@ -979,10 +1754,10 @@ func _update_hud_phase(phase_name: String) -> void:
|
||||
if phase_label:
|
||||
var icon = "🍬"
|
||||
match phase_name:
|
||||
"Route Pressure":
|
||||
"Middle Pressure":
|
||||
icon = "⚠️"
|
||||
phase_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2)) # Warning gold
|
||||
"Survival!":
|
||||
"Inner Survival":
|
||||
icon = "💀"
|
||||
phase_label.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3)) # Danger red
|
||||
_:
|
||||
@@ -1091,7 +1866,7 @@ func _respawn_mission_tiles() -> void:
|
||||
|
||||
# Shuffle and place tiles
|
||||
empty_cells.shuffle()
|
||||
var tiles_to_place = min(empty_cells.size(), 20) # Limit respawn count
|
||||
var tiles_to_place = min(empty_cells.size(), 6) # Light refill — avoid flooding the board while players collect
|
||||
|
||||
for i in range(tiles_to_place):
|
||||
var pos = empty_cells[i]
|
||||
|
||||
@@ -33,8 +33,8 @@ signal doors_required_goals_changed(goals: int)
|
||||
|
||||
# Gauntlet settings signals
|
||||
signal gauntlet_round_duration_changed(duration: int)
|
||||
signal gauntlet_cannon_interval_changed(interval: int)
|
||||
signal gauntlet_volley_size_changed(size: int)
|
||||
signal gauntlet_growth_interval_changed(interval: float)
|
||||
signal gauntlet_cells_per_tick_changed(cells: Dictionary)
|
||||
|
||||
# Room data structure
|
||||
var current_room: Dictionary = {}
|
||||
@@ -81,8 +81,12 @@ var doors_required_goals: int = 8
|
||||
|
||||
# Gauntlet settings
|
||||
var gauntlet_round_duration: int = 180
|
||||
var gauntlet_cannon_interval: int = 5
|
||||
var gauntlet_volley_size: int = 5
|
||||
var gauntlet_growth_interval: float = 3.0 # seconds between growth ticks
|
||||
var gauntlet_cells_per_tick: Dictionary = {
|
||||
"phase1": [4, 6],
|
||||
"phase2": [6, 8],
|
||||
"phase3": [8, 10],
|
||||
}
|
||||
|
||||
# Rematch tracking
|
||||
var rematch_votes: Array = [] # [player_id, ...]
|
||||
@@ -90,7 +94,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 Cannon Survival"]
|
||||
var available_game_modes: Array[String] = ["Freemode", "Stop n Go", "Candy Pump 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
|
||||
@@ -145,7 +149,7 @@ func _update_available_areas(mode: String) -> void:
|
||||
available_areas = ["Freemode Arena", "Classic", "Colloseum"]
|
||||
"Stop n Go":
|
||||
available_areas = ["Stop N Go Arena"]
|
||||
"Candy Cannon Survival":
|
||||
"Candy Pump Survival":
|
||||
available_areas = ["Gauntlet Arena"]
|
||||
_:
|
||||
available_areas = ["Classic"]
|
||||
@@ -562,23 +566,23 @@ func sync_gauntlet_round_duration(duration: int) -> void:
|
||||
gauntlet_round_duration = duration
|
||||
emit_signal("gauntlet_round_duration_changed", duration)
|
||||
|
||||
func set_gauntlet_cannon_interval(interval: int) -> void:
|
||||
gauntlet_cannon_interval = interval
|
||||
if is_host: rpc("sync_gauntlet_cannon_interval", interval)
|
||||
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_cannon_interval(interval: int) -> void:
|
||||
gauntlet_cannon_interval = interval
|
||||
emit_signal("gauntlet_cannon_interval_changed", interval)
|
||||
func sync_gauntlet_growth_interval(interval: float) -> void:
|
||||
gauntlet_growth_interval = interval
|
||||
emit_signal("gauntlet_growth_interval_changed", interval)
|
||||
|
||||
func set_gauntlet_volley_size(size: int) -> void:
|
||||
gauntlet_volley_size = size
|
||||
if is_host: rpc("sync_gauntlet_volley_size", size)
|
||||
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_volley_size(size: int) -> void:
|
||||
gauntlet_volley_size = size
|
||||
emit_signal("gauntlet_volley_size_changed", size)
|
||||
func sync_gauntlet_cells_per_tick(cells: Dictionary) -> void:
|
||||
gauntlet_cells_per_tick = cells
|
||||
emit_signal("gauntlet_cells_per_tick_changed", cells)
|
||||
|
||||
# =============================================================================
|
||||
# Character Selection
|
||||
@@ -738,8 +742,8 @@ func set_game_mode(mode: String) -> void:
|
||||
set_area("Stop n Go Area")
|
||||
elif mode == "Tekton Doors" and "Tekton Doors Area" in available_areas:
|
||||
set_area("Tekton Doors Area")
|
||||
elif mode == "Gauntlet" and "Candy Pump Arena" in available_areas:
|
||||
set_area("Candy Pump Arena")
|
||||
elif mode == "Candy Pump Survival" and "Gauntlet Arena" in available_areas:
|
||||
set_area("Gauntlet Arena")
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_game_mode(mode: String) -> void:
|
||||
@@ -754,8 +758,8 @@ func sync_game_mode(mode: String) -> void:
|
||||
selected_area = "Stop n Go Area"
|
||||
elif mode == "Tekton Doors" and "Tekton Doors Area" in available_areas:
|
||||
selected_area = "Tekton Doors Area"
|
||||
elif mode == "Gauntlet" and "Candy Pump Arena" in available_areas:
|
||||
selected_area = "Candy Pump Arena"
|
||||
elif mode == "Candy Pump Survival" and "Gauntlet Arena" in available_areas:
|
||||
selected_area = "Gauntlet Arena"
|
||||
elif selected_area not in available_areas:
|
||||
selected_area = available_areas[0]
|
||||
|
||||
@@ -786,8 +790,8 @@ func start_game(force: bool = false) -> void:
|
||||
rpc("sync_doors_required_goals", doors_required_goals)
|
||||
# Sync gauntlet settings
|
||||
rpc("sync_gauntlet_round_duration", gauntlet_round_duration)
|
||||
rpc("sync_gauntlet_cannon_interval", gauntlet_cannon_interval)
|
||||
rpc("sync_gauntlet_volley_size", gauntlet_volley_size)
|
||||
rpc("sync_gauntlet_growth_interval", gauntlet_growth_interval)
|
||||
rpc("sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick)
|
||||
# Sync game mode
|
||||
rpc("sync_game_mode", game_mode)
|
||||
|
||||
@@ -864,8 +868,8 @@ func request_room_info(requester_id: int, requester_name: String, requester_char
|
||||
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_cannon_interval", gauntlet_cannon_interval)
|
||||
rpc_id(requester_id, "sync_gauntlet_volley_size", gauntlet_volley_size)
|
||||
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_game_mode", game_mode)
|
||||
rpc_id(requester_id, "sync_area", selected_area)
|
||||
|
||||
|
||||
@@ -29,10 +29,20 @@ func fetch_mails() -> void:
|
||||
|
||||
var payload = result.get("data", {})
|
||||
if payload and payload is Dictionary:
|
||||
mails = payload.get("mails", [])
|
||||
var raw_mails = payload.get("mails", [])
|
||||
if typeof(raw_mails) == TYPE_ARRAY:
|
||||
mails = raw_mails
|
||||
elif typeof(raw_mails) == TYPE_DICTIONARY:
|
||||
mails = raw_mails.values()
|
||||
else:
|
||||
mails = []
|
||||
var state = payload.get("state", {})
|
||||
claimed_ids = state.get("claimed_ids", [])
|
||||
read_ids = state.get("read_ids", [])
|
||||
if typeof(state) != TYPE_DICTIONARY:
|
||||
state = {}
|
||||
var raw_claimed_ids = state.get("claimed_ids", [])
|
||||
var raw_read_ids = state.get("read_ids", [])
|
||||
claimed_ids = raw_claimed_ids if typeof(raw_claimed_ids) == TYPE_ARRAY else []
|
||||
read_ids = raw_read_ids if typeof(raw_read_ids) == TYPE_ARRAY else []
|
||||
|
||||
# Sort by date descending
|
||||
mails.sort_custom(func(a, b):
|
||||
|
||||
@@ -135,13 +135,8 @@ func simple_move_to(grid_position: Vector2i) -> bool:
|
||||
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
|
||||
|
||||
# Check if currently trapped
|
||||
if gm and gm.is_active:
|
||||
var pid = player.get("peer_id") if "peer_id" in player else -1
|
||||
if pid != -1 and gm.trapped_players.has(pid):
|
||||
print("[Move] Failed: Player is trapped in a sticky cell")
|
||||
return false
|
||||
|
||||
# Sticky no longer hard-traps — players are slowed instead and can move freely.
|
||||
|
||||
# Check for Tekton interaction (Charged Strike Mode)
|
||||
# If moving into a Tekton's space while Charged, trigger knock
|
||||
@@ -154,19 +149,19 @@ func simple_move_to(grid_position: Vector2i) -> bool:
|
||||
player.knock_tekton()
|
||||
return false # Don't move into the tile, just knock
|
||||
|
||||
# If moving into a sticky cell, trigger trap (unless cleanser active)
|
||||
# If moving into a sticky cell: slow the player (unless cleanser active,
|
||||
# which clears the cell instead). Sticky no longer hard-traps.
|
||||
if gm and gm.is_active and gm.is_sticky_cell(grid_position):
|
||||
var pid = player.get("peer_id") if "peer_id" in player else -1
|
||||
if pid != -1 and gm.is_cleanser_active(pid):
|
||||
# Cleanser immunity: clear sticky cell, use one cell, don't trap
|
||||
# Cleanser immunity: clear sticky cell, use one cell, don't slow
|
||||
gm.clear_sticky_cell(grid_position)
|
||||
gm.use_cleanser_cell(pid)
|
||||
print("[Move] Cleanser cleared sticky cell at %s (%d cells left)" % [grid_position, gm.cleanser_cells_left.get(pid, 0)])
|
||||
else:
|
||||
print("[Move] Player stepping into sticky cell at %s" % grid_position)
|
||||
movement_queue.clear()
|
||||
print("[Move] Player stepping into sticky cell at %s — slowed" % grid_position)
|
||||
if player.is_multiplayer_authority() or multiplayer.is_server():
|
||||
gm._trap_player(player)
|
||||
gm.apply_sticky_slow(player)
|
||||
|
||||
rotate_towards_target(grid_position)
|
||||
|
||||
@@ -348,9 +343,9 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
|
||||
gm_sticky.use_cleanser_cell(push_pid)
|
||||
print("[Move] Cleanser cleared push-into-sticky at %s" % pushed_to_pos)
|
||||
else:
|
||||
print("[Move] Player pushed into sticky cell at %s" % pushed_to_pos)
|
||||
print("[Move] Player pushed into sticky cell at %s — slowed" % pushed_to_pos)
|
||||
if multiplayer.is_server() or other_player.is_multiplayer_authority():
|
||||
gm_sticky._trap_player(other_player)
|
||||
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
|
||||
@@ -397,7 +392,7 @@ func set_speed_multiplier(multiplier: float):
|
||||
func _on_movement_finished():
|
||||
if not movement_queue.is_empty():
|
||||
var next_target = movement_queue.pop_front()
|
||||
# Use a small delay or call_deferred to avoid recursion issues,
|
||||
# Use a small delay or call_deferred to avoid recursion issues,
|
||||
# but keep it snappy by executing immediately if possible.
|
||||
if not simple_move_to(next_target):
|
||||
# If next move failed, clear queue and signal finished
|
||||
@@ -406,6 +401,17 @@ func _on_movement_finished():
|
||||
emit_signal("movement_finished")
|
||||
else:
|
||||
current_move_direction = Vector2i.ZERO
|
||||
# Gauntlet (#072): a Cleanser ends early once the player rests on a safe
|
||||
# cell. Gated on gm.is_active so other game modes are never affected.
|
||||
var gm = null
|
||||
var main_node = player.get_tree().root.get_node_or_null("Main")
|
||||
if main_node and main_node.get("gauntlet_manager"):
|
||||
gm = main_node.gauntlet_manager
|
||||
if gm and gm.is_active and player.get("current_position") != null:
|
||||
var mpid = player.get("peer_id") if "peer_id" in player else -1
|
||||
if mpid != -1 and gm.is_cleanser_active(mpid):
|
||||
if multiplayer.is_server() or player.is_multiplayer_authority():
|
||||
gm.notify_movement_stopped(mpid, player.current_position)
|
||||
emit_signal("movement_finished")
|
||||
|
||||
func move_to_clicked_position(grid_position: Vector2i) -> bool:
|
||||
|
||||
@@ -24,10 +24,10 @@ const SCHEMA = {
|
||||
"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 Cannon Survival": {
|
||||
"Candy Pump Survival": {
|
||||
"match_duration": {"type": TYPE_INT, "default": 180, "min": 60, "max": 600},
|
||||
"gauntlet_cannon_interval": {"type": TYPE_FLOAT, "default": 5.0, "min": 2.0, "max": 10.0},
|
||||
"gauntlet_volley_size": {"type": TYPE_INT, "default": 5, "min": 3, "max": 15}
|
||||
"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]}}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+285
-61
@@ -197,14 +197,15 @@ func _setup_columns() -> void:
|
||||
_mail_root = mail_tree.create_item()
|
||||
|
||||
# Chat Storage
|
||||
chat_tree.set_column_title(0, "Sender")
|
||||
chat_tree.set_column_title(1, "Content")
|
||||
chat_tree.set_column_title(2, "Date")
|
||||
chat_tree.set_column_title(3, "ID")
|
||||
chat_tree.set_column_custom_minimum_width(0, 100)
|
||||
chat_tree.set_column_expand(1, true)
|
||||
chat_tree.set_column_custom_minimum_width(2, 120)
|
||||
chat_tree.set_column_custom_minimum_width(3, 100)
|
||||
chat_tree.set_column_title(0, "Select")
|
||||
chat_tree.set_column_title(1, "Sender")
|
||||
chat_tree.set_column_title(2, "Content")
|
||||
chat_tree.set_column_title(3, "Date / ID")
|
||||
chat_tree.set_column_custom_minimum_width(0, 70)
|
||||
chat_tree.set_column_expand(0, false)
|
||||
chat_tree.set_column_custom_minimum_width(1, 100)
|
||||
chat_tree.set_column_expand(2, true)
|
||||
chat_tree.set_column_custom_minimum_width(3, 180)
|
||||
_chat_tree_root = chat_tree.create_item()
|
||||
|
||||
func _connect_signals() -> void:
|
||||
@@ -256,8 +257,8 @@ func _connect_signals() -> void:
|
||||
|
||||
# Chat Storage actions
|
||||
load_messages_btn.pressed.connect(_on_load_chat_messages)
|
||||
refresh_chat_btn.pressed.connect(_on_load_chat_messages)
|
||||
delete_selected_btn.pressed.connect(_on_delete_chat_message)
|
||||
refresh_chat_btn.pressed.connect(_on_load_more_chat_messages)
|
||||
delete_selected_btn.pressed.connect(_on_delete_selected_chat_messages)
|
||||
|
||||
# =============================================================================
|
||||
# Core Panel Logic
|
||||
@@ -281,6 +282,8 @@ func _on_tab_changed(tab_index: int) -> void:
|
||||
await _load_leaderboard()
|
||||
elif tab_index == 2:
|
||||
await _load_daily_rewards_config()
|
||||
elif tab_index == 3:
|
||||
_update_announcement_count()
|
||||
elif tab_index == 4:
|
||||
await _load_mail()
|
||||
elif tab_index == 5:
|
||||
@@ -371,12 +374,15 @@ func _on_user_tree_button_clicked(item: TreeItem, _col: int, _id: int, _mouse: i
|
||||
func _show_edit_user_dialog(user: Dictionary) -> void:
|
||||
var uid: String = user.get("user_id", "")
|
||||
var uname: String = user.get("username", "")
|
||||
var display_name: String = user.get("display_name", uname)
|
||||
var role: String = user.get("role", "player")
|
||||
var banned: bool = user.get("banned", false)
|
||||
var detail := await _rpc("admin_get_user_detail", {"user_id": uid})
|
||||
var detail_user: Dictionary = detail.get("user", {}) if not detail.has("error") else {}
|
||||
|
||||
var dialog := AcceptDialog.new()
|
||||
dialog.title = "Edit User: " + uname
|
||||
dialog.min_size = Vector2i(380, 260)
|
||||
dialog.min_size = Vector2i(460, 420)
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 8)
|
||||
|
||||
@@ -385,6 +391,25 @@ func _show_edit_user_dialog(user: Dictionary) -> void:
|
||||
id_lbl.add_theme_color_override("font_color", CLR_DIM)
|
||||
vbox.add_child(id_lbl)
|
||||
|
||||
var email_lbl := Label.new()
|
||||
var email = detail_user.get("email", "")
|
||||
var verified = detail_user.get("email_verified", false)
|
||||
email_lbl.text = "Email: %s (%s)" % [email if not str(email).is_empty() else "none", "verified" if verified else "unverified"]
|
||||
email_lbl.add_theme_color_override("font_color", CLR_DIM)
|
||||
vbox.add_child(email_lbl)
|
||||
|
||||
var name_grid := GridContainer.new()
|
||||
name_grid.columns = 2
|
||||
name_grid.add_theme_constant_override("h_separation", 8)
|
||||
name_grid.add_theme_constant_override("v_separation", 8)
|
||||
var username_lbl := Label.new(); username_lbl.text = "Username:"; name_grid.add_child(username_lbl)
|
||||
var username_input := LineEdit.new(); username_input.text = detail_user.get("username", uname); name_grid.add_child(username_input)
|
||||
var display_lbl := Label.new(); display_lbl.text = "Display Name:"; name_grid.add_child(display_lbl)
|
||||
var display_input := LineEdit.new(); display_input.text = detail_user.get("display_name", display_name); name_grid.add_child(display_input)
|
||||
var password_lbl := Label.new(); password_lbl.text = "New Password:"; name_grid.add_child(password_lbl)
|
||||
var password_input := LineEdit.new(); password_input.placeholder_text = "Leave empty to keep"; password_input.secret = true; name_grid.add_child(password_input)
|
||||
vbox.add_child(name_grid)
|
||||
|
||||
var role_hbox := HBoxContainer.new()
|
||||
var role_lbl := Label.new()
|
||||
role_lbl.text = "Role: "
|
||||
@@ -419,18 +444,31 @@ func _show_edit_user_dialog(user: Dictionary) -> void:
|
||||
|
||||
save_btn.pressed.connect(func():
|
||||
var new_role: String = roles[role_option.selected]
|
||||
await _save_user_edit(uid, uname, new_role, ban_check.button_pressed, reason_input.text)
|
||||
await _save_user_edit(uid, username_input.text.strip_edges(), display_input.text.strip_edges(), password_input.text, new_role, ban_check.button_pressed, reason_input.text)
|
||||
dialog.queue_free()
|
||||
)
|
||||
|
||||
func _save_user_edit(uid: String, uname: String, new_role: String, new_banned: bool, reason: String) -> void:
|
||||
func _save_user_edit(uid: String, uname: String, display_name: String, new_password: String, new_role: String, new_banned: bool, reason: String) -> void:
|
||||
_set_status("Saving...")
|
||||
var identity_res := await _rpc("admin_update_user_identity", {
|
||||
"user_id": uid,
|
||||
"username": uname,
|
||||
"display_name": display_name
|
||||
})
|
||||
if identity_res.has("error"):
|
||||
_set_status("Identity save failed: " + str(identity_res.error), CLR_STATUS_ERR)
|
||||
return
|
||||
if not new_password.strip_edges().is_empty():
|
||||
var password_res := await _rpc("admin_set_user_password", {"user_id": uid, "password": new_password})
|
||||
if password_res.has("error"):
|
||||
_set_status("Password save failed: " + str(password_res.error), CLR_STATUS_ERR)
|
||||
return
|
||||
await _rpc("admin_set_user_role", {"user_id": uid, "role": new_role})
|
||||
if new_banned:
|
||||
await _rpc("admin_ban_player", {"user_id": uid, "reason": reason, "duration_hours": 0})
|
||||
else:
|
||||
await _rpc("admin_unban_player", {"user_id": uid})
|
||||
_set_status("Saved: " + uname, CLR_STATUS_OK)
|
||||
_set_status("Saved: " + display_name, CLR_STATUS_OK)
|
||||
await _load_users()
|
||||
|
||||
# =============================================================================
|
||||
@@ -529,19 +567,47 @@ func _on_history_pressed() -> void:
|
||||
return
|
||||
|
||||
var uid = selected_data[0].get("user_id", "")
|
||||
_set_status("Fetching history for user...", CLR_STATUS_OK)
|
||||
_set_status("Fetching user details...", CLR_STATUS_OK)
|
||||
var detail_res = await _rpc("admin_get_user_detail", {"user_id": uid})
|
||||
var res = await _rpc("admin_get_user_history", {"user_id": uid})
|
||||
|
||||
if res.has("error"):
|
||||
_set_status("Failed to get history: " + str(res.error), CLR_STATUS_ERR)
|
||||
if detail_res.has("error") and res.has("error"):
|
||||
_set_status("Failed to get user details: " + str(detail_res.error), CLR_STATUS_ERR)
|
||||
return
|
||||
|
||||
_set_status("History loaded.", CLR_STATUS_OK)
|
||||
_set_status("User details loaded.", CLR_STATUS_OK)
|
||||
|
||||
var h = res.get("history", {})
|
||||
var text = "[b]=== USER HISTORY ===[/b]\n"
|
||||
var h = res.get("history", {}) if not res.has("error") else {}
|
||||
var details = detail_res if not detail_res.has("error") else {}
|
||||
var detail_user: Dictionary = details.get("user", {})
|
||||
var text = "[b]=== USER DETAIL ===[/b]\n"
|
||||
text += "User ID: " + uid + "\n\n"
|
||||
text += "[b]-- Account --[/b]\n"
|
||||
text += "Username: %s\n" % detail_user.get("username", "")
|
||||
text += "Display Name: %s\n" % detail_user.get("display_name", "")
|
||||
text += "Email: %s (%s)\n" % [detail_user.get("email", "none"), "verified" if detail_user.get("email_verified", false) else "unverified"]
|
||||
text += "Created: %s\n" % str(detail_user.get("create_time", ""))
|
||||
text += "Wallet: %s\n" % str(detail_user.get("wallet", {}))
|
||||
text += "Subscription: %s\n\n" % str(details.get("subscription", {}))
|
||||
|
||||
text += "[b]-- Friends --[/b]\n"
|
||||
var friends = details.get("friends", [])
|
||||
if friends.is_empty():
|
||||
text += "No friends found.\n"
|
||||
else:
|
||||
for f in friends:
|
||||
text += "- %s (%s) state=%s\n" % [f.get("username", ""), f.get("user_id", ""), str(f.get("state", ""))]
|
||||
text += "\n"
|
||||
|
||||
text += "[b]-- Purchase History / Receipts --[/b]\n"
|
||||
var purchases = details.get("purchases", [])
|
||||
if purchases.is_empty():
|
||||
text += "No purchases found.\n"
|
||||
else:
|
||||
for p in purchases:
|
||||
text += "- %s: %s\n" % [p.get("key", ""), str(p.get("value", {}))]
|
||||
text += "\n"
|
||||
|
||||
# Logins
|
||||
text += "[b]-- Recent Logins --[/b]\n"
|
||||
var logins = h.get("logins", [])
|
||||
@@ -575,6 +641,17 @@ func _on_history_pressed() -> void:
|
||||
else:
|
||||
for m in matches:
|
||||
text += "- " + str(m) + "\n"
|
||||
|
||||
text += "\n[b]-- Storage Objects --[/b]\n"
|
||||
var storage = details.get("storage", {})
|
||||
if storage.is_empty():
|
||||
text += "No storage objects found.\n"
|
||||
else:
|
||||
for collection in storage.keys():
|
||||
var objects = storage[collection]
|
||||
text += "\n[b]%s[/b] (%d)\n" % [collection, objects.size()]
|
||||
for obj in objects:
|
||||
text += "- %s: %s\n" % [obj.get("key", ""), str(obj.get("value", {}))]
|
||||
|
||||
history_text.text = text
|
||||
history_dialog.popup_centered()
|
||||
@@ -614,7 +691,15 @@ func _load_leaderboard() -> void:
|
||||
return
|
||||
|
||||
var raw_lb = res.get("leaderboard", [])
|
||||
lb_data = raw_lb if typeof(raw_lb) == TYPE_ARRAY else []
|
||||
if typeof(raw_lb) == TYPE_ARRAY:
|
||||
lb_data = raw_lb
|
||||
elif typeof(raw_lb) == TYPE_DICTIONARY:
|
||||
lb_data = raw_lb.values()
|
||||
else:
|
||||
lb_data = []
|
||||
|
||||
if lb_data.is_empty():
|
||||
lb_data = await _fetch_native_leaderboard_for_admin()
|
||||
count_label.text = "%d records" % lb_data.size()
|
||||
lb_data.sort_custom(func(a, b): return a.get("high_score", 0) > b.get("high_score", 0))
|
||||
|
||||
@@ -631,6 +716,37 @@ func _load_leaderboard() -> void:
|
||||
rank += 1
|
||||
_set_status("")
|
||||
|
||||
func _fetch_native_leaderboard_for_admin() -> Array:
|
||||
var result = await NakamaManager.client.list_leaderboard_records_async(
|
||||
NakamaManager.session,
|
||||
"global_high_score",
|
||||
[],
|
||||
null,
|
||||
100
|
||||
)
|
||||
if result.is_exception():
|
||||
push_warning("[AdminPanel] Native leaderboard load failed: " + result.get_exception().message)
|
||||
return []
|
||||
|
||||
var data: Array = []
|
||||
for record in result.records:
|
||||
var meta: Dictionary = {}
|
||||
if record.metadata and not record.metadata.is_empty():
|
||||
var parsed = JSON.parse_string(record.metadata)
|
||||
if parsed is Dictionary:
|
||||
meta = parsed
|
||||
data.append({
|
||||
"user_id": record.owner_id,
|
||||
"username": record.username,
|
||||
"display_name": record.username if (record.username and not record.username.is_empty()) else "Unknown",
|
||||
"avatar_url": meta.get("avatar_url", ""),
|
||||
"loadout_character": meta.get("loadout_character", "Copper"),
|
||||
"high_score": int(record.score),
|
||||
"games_played": int(meta.get("games_played", 0)),
|
||||
"games_won": int(meta.get("games_won", 0))
|
||||
})
|
||||
return data
|
||||
|
||||
func _on_lb_tree_button_clicked(item: TreeItem, _col: int, _id: int, _mouse: int) -> void:
|
||||
_show_edit_score_dialog(item.get_metadata(0))
|
||||
|
||||
@@ -729,6 +845,7 @@ func _load_daily_rewards_config() -> void:
|
||||
month_option_btn.select(0)
|
||||
_build_dr_grid()
|
||||
|
||||
_update_daily_reward_count()
|
||||
_set_status("Config Loaded", CLR_STATUS_OK)
|
||||
|
||||
func _on_dr_month_selected(index: int) -> void:
|
||||
@@ -737,6 +854,7 @@ func _on_dr_month_selected(index: int) -> void:
|
||||
|
||||
_current_dr_month = month_option_btn.get_item_metadata(index)
|
||||
_build_dr_grid()
|
||||
_update_daily_reward_count()
|
||||
|
||||
func _save_current_grid_to_dict() -> void:
|
||||
if _current_dr_month.is_empty(): return
|
||||
@@ -784,6 +902,11 @@ func _build_dr_grid() -> void:
|
||||
spin.value = int(rdata)
|
||||
opt.select(0)
|
||||
|
||||
func _update_daily_reward_count() -> void:
|
||||
var rewards = _daily_reward_config_data.get(_current_dr_month, [])
|
||||
var count: int = rewards.size() if typeof(rewards) == TYPE_ARRAY else 0
|
||||
count_label.text = "%d reward days" % count
|
||||
|
||||
func _save_daily_rewards_config() -> void:
|
||||
_save_current_grid_to_dict()
|
||||
_set_status("Saving config...")
|
||||
@@ -802,7 +925,15 @@ func _on_add_reward_pressed() -> void:
|
||||
row.visible = true
|
||||
rewards_list.add_child(row)
|
||||
var remove_btn = row.get_node("RemoveBtn") as Button
|
||||
remove_btn.pressed.connect(func(): row.queue_free())
|
||||
remove_btn.pressed.connect(func(): row.queue_free(); _update_announcement_count())
|
||||
_update_announcement_count()
|
||||
|
||||
func _update_announcement_count() -> void:
|
||||
var count := 0
|
||||
for child in rewards_list.get_children():
|
||||
if child.visible:
|
||||
count += 1
|
||||
count_label.text = "%d rewards attached" % count
|
||||
|
||||
func _on_find_user() -> void:
|
||||
var input = target_user_edit.text.strip_edges()
|
||||
@@ -927,6 +1058,7 @@ func _on_send_mail() -> void:
|
||||
end_date_edit.clear_date()
|
||||
for child in rewards_list.get_children():
|
||||
if child.visible: child.queue_free()
|
||||
_update_announcement_count()
|
||||
|
||||
# =============================================================================
|
||||
# TAB 5: MAIL MANAGER
|
||||
@@ -1245,6 +1377,7 @@ func _save_featured_banners() -> void:
|
||||
# =============================================================================
|
||||
func _load_chat_config() -> void:
|
||||
chat_status_label.text = "Loading config..."
|
||||
count_label.text = "chat config"
|
||||
var res := await _rpc("admin_get_chat_config", {})
|
||||
if res.has("error"):
|
||||
chat_status_label.text = "Failed: " + str(res.error)
|
||||
@@ -1344,18 +1477,19 @@ func _on_save_chat_config() -> void:
|
||||
func _on_load_chat_messages() -> void:
|
||||
var channel_id := chat_channel_id_edit.text.strip_edges()
|
||||
if channel_id.is_empty():
|
||||
_set_status("Enter a Channel ID first.", CLR_STATUS_ERR)
|
||||
return
|
||||
# Default to the global lobby room rather than erroring out.
|
||||
channel_id = "social_global"
|
||||
chat_channel_id_edit.text = channel_id
|
||||
|
||||
# Auto-resolve "social_global" to the actual Nakama Channel ID if the admin is in the lobby
|
||||
# Best-effort: resolve "social_global" to the real hashed Nakama Channel ID so
|
||||
# the admin sees it in the UI. If resolution fails (not in lobby / socket
|
||||
# down), fall through with the room name — the server resolves it
|
||||
# authoritatively via nk.channel_id_build.
|
||||
if channel_id == "social_global":
|
||||
var lobby = get_tree().get_first_node_in_group("Lobby")
|
||||
if lobby and lobby.get("chat") and lobby.chat.get("_chat_channel"):
|
||||
channel_id = lobby.chat._chat_channel.id
|
||||
chat_channel_id_edit.text = channel_id # Update UI so admin sees the real ID
|
||||
else:
|
||||
_set_status("Cannot resolve social_global. Join chat first.", CLR_STATUS_ERR)
|
||||
return
|
||||
var resolved := await _resolve_global_chat_channel_id()
|
||||
if not resolved.is_empty():
|
||||
channel_id = resolved
|
||||
chat_channel_id_edit.text = channel_id # show the admin the real ID
|
||||
|
||||
_chat_channel_id = channel_id
|
||||
_chat_cursor = ""
|
||||
@@ -1364,6 +1498,35 @@ func _on_load_chat_messages() -> void:
|
||||
|
||||
await _fetch_chat_messages_batch()
|
||||
|
||||
func _resolve_global_chat_channel_id() -> String:
|
||||
# Nakama Room channel IDs are deterministically hashed from the type and room name.
|
||||
# For type=2 (Room) and name="social_global", the ID format is always:
|
||||
# "2." + uri_encoded_room_name + "." # no domain needed for rooms.
|
||||
# But Nakama's format often just uses "2.RoomName." - let's ensure we try the exact determinism if socket fails.
|
||||
|
||||
var lobby = get_tree().get_first_node_in_group("Lobby")
|
||||
if lobby and lobby.get("chat") and lobby.chat.get("_chat_channel"):
|
||||
return lobby.chat._chat_channel.id
|
||||
|
||||
var socket = NakamaManager.socket
|
||||
if socket and socket.is_connected_to_host():
|
||||
var result = await socket.join_chat_async("social_global", NakamaSocket.ChannelType.Room, true, false)
|
||||
if not result.is_exception():
|
||||
return result.id
|
||||
|
||||
# Fallback if no socket or join failed: construct the exact ID the Web UI expects.
|
||||
# Type 2 (Room), Name "social_global"
|
||||
return "2." + "social_global".uri_encode() + "."
|
||||
|
||||
func _on_load_more_chat_messages() -> void:
|
||||
if _chat_channel_id.is_empty():
|
||||
await _on_load_chat_messages()
|
||||
return
|
||||
if _chat_cursor.is_empty():
|
||||
_set_status("No more messages to load.", CLR_STATUS_OK)
|
||||
return
|
||||
await _fetch_chat_messages_batch()
|
||||
|
||||
func _fetch_chat_messages_batch() -> void:
|
||||
_set_status("Loading messages...")
|
||||
var payload := {
|
||||
@@ -1380,19 +1543,42 @@ func _fetch_chat_messages_batch() -> void:
|
||||
|
||||
var msgs = res.get("messages", [])
|
||||
var next_cursor = res.get("next_cursor", "")
|
||||
if (typeof(msgs) == TYPE_DICTIONARY and msgs.is_empty()) or (typeof(msgs) == TYPE_ARRAY and msgs.is_empty()):
|
||||
var fallback = await NakamaManager.client.list_channel_messages_async(NakamaManager.session, _chat_channel_id, 50, false, _chat_cursor)
|
||||
if not fallback.is_exception():
|
||||
msgs = fallback.messages if fallback.messages else []
|
||||
next_cursor = fallback.next_cursor
|
||||
else:
|
||||
_set_status("Failed: " + fallback.get_exception().message, CLR_STATUS_ERR)
|
||||
return
|
||||
if typeof(msgs) == TYPE_DICTIONARY:
|
||||
msgs = msgs.values()
|
||||
elif typeof(msgs) != TYPE_ARRAY:
|
||||
msgs = []
|
||||
var added_count := 0
|
||||
|
||||
for msg in msgs:
|
||||
for raw_msg in msgs:
|
||||
var msg := _normalize_chat_storage_message(raw_msg)
|
||||
if msg.is_empty():
|
||||
continue
|
||||
_chat_messages_data.append(msg)
|
||||
var item := _chat_tree_root.create_child()
|
||||
item.set_text(0, msg.get("username", msg.get("sender_id", "?").substr(0, 8)))
|
||||
item.set_text(1, msg.get("content", ""))
|
||||
item.set_text(2, msg.get("create_time", "").substr(0, 19).replace("T", " "))
|
||||
item.set_cell_mode(0, TreeItem.CELL_MODE_CHECK)
|
||||
item.set_editable(0, true)
|
||||
item.set_text(1, msg.get("username", msg.get("sender_id", "?").substr(0, 8)))
|
||||
item.set_text(2, _format_chat_storage_content(msg.get("content", "")))
|
||||
item.set_text(3, msg.get("create_time", "").substr(0, 19).replace("T", " "))
|
||||
var mid = msg.get("message_id", "")
|
||||
item.set_text(3, mid)
|
||||
item.set_tooltip_text(3, mid)
|
||||
item.set_metadata(0, msg)
|
||||
added_count += 1
|
||||
|
||||
count_label.text = "%d messages loaded" % _chat_messages_data.size()
|
||||
chat_tree.queue_redraw()
|
||||
|
||||
if added_count == 0:
|
||||
_set_status("No stored messages returned for channel: " + _chat_channel_id, CLR_STATUS_ERR)
|
||||
return
|
||||
|
||||
if not next_cursor.is_empty():
|
||||
_chat_cursor = next_cursor
|
||||
@@ -1401,37 +1587,75 @@ func _fetch_chat_messages_batch() -> void:
|
||||
_chat_cursor = ""
|
||||
_set_status("All messages loaded.", CLR_STATUS_OK)
|
||||
|
||||
func _on_delete_chat_message() -> void:
|
||||
var item = chat_tree.get_selected()
|
||||
if not item:
|
||||
_set_status("Select a message to delete.", CLR_STATUS_ERR)
|
||||
return
|
||||
func _format_chat_storage_content(content) -> String:
|
||||
if typeof(content) == TYPE_DICTIONARY:
|
||||
return str(content.get("msg", content))
|
||||
|
||||
var msg = item.get_metadata(0)
|
||||
if not msg:
|
||||
return
|
||||
var text := str(content)
|
||||
var parsed = JSON.parse_string(text)
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
return str(parsed.get("msg", text))
|
||||
return text
|
||||
|
||||
var msg_id = msg.get("message_id", "")
|
||||
if msg_id.is_empty():
|
||||
func _normalize_chat_storage_message(raw_msg) -> Dictionary:
|
||||
if typeof(raw_msg) == TYPE_DICTIONARY:
|
||||
return raw_msg
|
||||
if typeof(raw_msg) != TYPE_OBJECT:
|
||||
return {}
|
||||
return {
|
||||
"message_id": raw_msg.message_id,
|
||||
"sender_id": raw_msg.sender_id,
|
||||
"username": raw_msg.username,
|
||||
"content": raw_msg.content,
|
||||
"create_time": raw_msg.create_time,
|
||||
"update_time": raw_msg.update_time,
|
||||
"channel_id": raw_msg.channel_id
|
||||
}
|
||||
|
||||
func _on_delete_selected_chat_messages() -> void:
|
||||
var items := _get_checked_chat_items()
|
||||
if items.is_empty():
|
||||
var selected = chat_tree.get_selected()
|
||||
if selected:
|
||||
items.append(selected)
|
||||
if items.is_empty():
|
||||
_set_status("Select one or more messages to delete.", CLR_STATUS_ERR)
|
||||
return
|
||||
|
||||
var confirm := ConfirmationDialog.new()
|
||||
confirm.title = "Delete Message?"
|
||||
confirm.dialog_text = "Permanently delete message from " + msg.get("username", "?") + "?"
|
||||
confirm.title = "Delete %d Message(s)?" % items.size()
|
||||
confirm.dialog_text = "Permanently delete selected chat messages?"
|
||||
add_child(confirm)
|
||||
confirm.popup_centered()
|
||||
confirm.confirmed.connect(func():
|
||||
_set_status("Deleting message...")
|
||||
var res = await _rpc("admin_delete_channel_message", {
|
||||
"channel_id": _chat_channel_id,
|
||||
"message_id": msg_id
|
||||
})
|
||||
if res.get("success", false):
|
||||
_set_status("Message deleted!", CLR_STATUS_OK)
|
||||
chat_tree.get_root().remove_child(item)
|
||||
item.free()
|
||||
count_label.text = "%d messages loaded" % _chat_messages_data.size()
|
||||
else:
|
||||
_set_status("Failed: " + str(res.get("error", "")), CLR_STATUS_ERR)
|
||||
_set_status("Deleting %d message(s)..." % items.size())
|
||||
var deleted := 0
|
||||
for item in items:
|
||||
var msg = item.get_metadata(0)
|
||||
if typeof(msg) != TYPE_DICTIONARY:
|
||||
continue
|
||||
var msg_id = msg.get("message_id", "")
|
||||
if msg_id.is_empty():
|
||||
continue
|
||||
var res = await _rpc("admin_delete_channel_message", {
|
||||
"channel_id": _chat_channel_id,
|
||||
"message_id": msg_id
|
||||
})
|
||||
if res.get("success", false):
|
||||
deleted += 1
|
||||
_chat_messages_data.erase(msg)
|
||||
_chat_tree_root.remove_child(item)
|
||||
item.free()
|
||||
count_label.text = "%d messages loaded" % _chat_messages_data.size()
|
||||
_set_status("Deleted %d message(s)" % deleted, CLR_STATUS_OK if deleted > 0 else CLR_STATUS_ERR)
|
||||
confirm.queue_free()
|
||||
)
|
||||
|
||||
func _get_checked_chat_items() -> Array:
|
||||
var items: Array = []
|
||||
var child = _chat_tree_root.get_first_child() if _chat_tree_root else null
|
||||
while child:
|
||||
if child.is_checked(0):
|
||||
items.append(child)
|
||||
child = child.get_next()
|
||||
return items
|
||||
|
||||
+124
-1
@@ -227,6 +227,7 @@ function admin.rpc_admin_clear_global_chat(context, payload)
|
||||
if channelId == "" then
|
||||
error("channel_id is required. Pass the channel ID from the client.")
|
||||
end
|
||||
channelId = utils.resolve_channel_id(channelId)
|
||||
|
||||
local deleted = 0
|
||||
local cursor = ""
|
||||
@@ -319,6 +320,121 @@ function admin.rpc_admin_delete_users(context, payload)
|
||||
return nk.json_encode({ success = true, deleted = deleted, failed = failed })
|
||||
end
|
||||
|
||||
function admin.rpc_admin_get_user_detail(context, payload)
|
||||
utils.require_admin(context)
|
||||
local request = nk.json_decode(payload or "{}")
|
||||
local userId = request.user_id or ""
|
||||
if userId == "" then error("user_id is required") end
|
||||
|
||||
local account = nk.account_get_id(userId)
|
||||
local metadata = {}
|
||||
if account.user.metadata then
|
||||
local s, m = pcall(nk.json_decode, account.user.metadata)
|
||||
if s and m then metadata = m end
|
||||
end
|
||||
|
||||
local friends = {}
|
||||
local okFriends, friendResult = pcall(nk.friends_list, userId, nil, 100, "")
|
||||
if okFriends and friendResult and friendResult.friends then
|
||||
for _, f in pairs(friendResult.friends) do
|
||||
table.insert(friends, {
|
||||
user_id = f.user.id,
|
||||
username = f.user.username,
|
||||
display_name = f.user.display_name,
|
||||
state = f.state
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local purchaseHistory = {}
|
||||
local okReceipts, receiptResult = pcall(nk.storage_list, userId, "receipts", 100, "")
|
||||
if okReceipts and receiptResult and receiptResult.objects then
|
||||
for _, obj in pairs(receiptResult.objects) do
|
||||
table.insert(purchaseHistory, { key = obj.key, value = obj.value, update_time = obj.update_time })
|
||||
end
|
||||
end
|
||||
|
||||
local collections = request.collections or {"profiles", "stats", "inventory", "receipts", "history", "matches", "inbox"}
|
||||
local storage = {}
|
||||
for _, collection in ipairs(collections) do
|
||||
local okStorage, storageResult = pcall(nk.storage_list, userId, collection, 100, "")
|
||||
storage[collection] = {}
|
||||
if okStorage and storageResult and storageResult.objects then
|
||||
for _, obj in pairs(storageResult.objects) do
|
||||
table.insert(storage[collection], { key = obj.key, value = obj.value, version = obj.version, update_time = obj.update_time })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local walletLedger = {}
|
||||
local okLedger, ledgerResult = pcall(nk.wallet_ledger_list, userId, 50)
|
||||
if okLedger and ledgerResult then walletLedger = ledgerResult.items or {} end
|
||||
|
||||
return nk.json_encode({
|
||||
user = {
|
||||
user_id = account.user.id,
|
||||
username = account.user.username or "",
|
||||
display_name = account.user.display_name or "",
|
||||
avatar_url = account.user.avatar_url or "",
|
||||
lang_tag = account.user.lang_tag or "",
|
||||
location = account.user.location or "",
|
||||
timezone = account.user.timezone or "",
|
||||
create_time = account.user.create_time or "",
|
||||
update_time = account.user.update_time or "",
|
||||
metadata = metadata,
|
||||
wallet = account.wallet or {},
|
||||
email = account.email or "",
|
||||
email_verified = account.email_verified or false
|
||||
},
|
||||
friends = friends,
|
||||
purchases = purchaseHistory,
|
||||
wallet_ledger = walletLedger,
|
||||
storage = storage,
|
||||
subscription = metadata.subscription or metadata.subscriptions or {}
|
||||
})
|
||||
end
|
||||
|
||||
function admin.rpc_admin_update_user_identity(context, payload)
|
||||
utils.require_admin(context)
|
||||
local request = nk.json_decode(payload or "{}")
|
||||
local userId = request.user_id or ""
|
||||
if userId == "" then error("user_id is required") end
|
||||
|
||||
local account = nk.account_get_id(userId)
|
||||
local metadata = {}
|
||||
if account.user.metadata then
|
||||
local s, m = pcall(nk.json_decode, account.user.metadata)
|
||||
if s and m then metadata = m end
|
||||
end
|
||||
if request.metadata and type(request.metadata) == "table" then
|
||||
for k, v in pairs(request.metadata) do metadata[k] = v end
|
||||
end
|
||||
|
||||
nk.account_update_id(
|
||||
userId,
|
||||
request.username or account.user.username,
|
||||
request.display_name or account.user.display_name,
|
||||
request.timezone or account.user.timezone,
|
||||
request.location or account.user.location,
|
||||
request.lang_tag or account.user.lang_tag,
|
||||
request.avatar_url or account.user.avatar_url,
|
||||
nk.json_encode(metadata)
|
||||
)
|
||||
return nk.json_encode({ success = true })
|
||||
end
|
||||
|
||||
function admin.rpc_admin_set_user_password(context, payload)
|
||||
utils.require_admin(context)
|
||||
local request = nk.json_decode(payload or "{}")
|
||||
local userId = request.user_id or ""
|
||||
local password = request.password or ""
|
||||
if userId == "" or password == "" then error("user_id and password are required") end
|
||||
local account = nk.account_get_id(userId)
|
||||
if not account.email or account.email == "" then error("User has no email credential") end
|
||||
nk.account_update_id(userId, nil, nil, nil, nil, nil, nil, nil, account.email, password)
|
||||
return nk.json_encode({ success = true })
|
||||
end
|
||||
|
||||
function admin.rpc_admin_get_player_list(context, payload)
|
||||
local request = nk.json_decode(payload)
|
||||
utils.require_admin_or_host(context, request.match_id)
|
||||
@@ -386,6 +502,7 @@ function admin.rpc_admin_purge_old_messages(context, payload)
|
||||
if channelId == "" then
|
||||
error("channel_id is required")
|
||||
end
|
||||
channelId = utils.resolve_channel_id(channelId)
|
||||
if maxAgeDays <= 0 then
|
||||
error("max_age_days must be > 0")
|
||||
end
|
||||
@@ -441,6 +558,7 @@ function admin.rpc_admin_list_channel_messages(context, payload)
|
||||
error("channel_id is required")
|
||||
end
|
||||
|
||||
channelId = utils.resolve_channel_id(channelId)
|
||||
local status, result = pcall(nk.channel_messages_list, channelId, limit, forward, cursor)
|
||||
if not status then
|
||||
error("Failed to list messages: " .. tostring(result))
|
||||
@@ -448,7 +566,7 @@ function admin.rpc_admin_list_channel_messages(context, payload)
|
||||
|
||||
local msgs = {}
|
||||
if result and result.messages then
|
||||
for _, msg in ipairs(result.messages) do
|
||||
for _, msg in pairs(result.messages) do
|
||||
table.insert(msgs, {
|
||||
message_id = msg.message_id,
|
||||
sender_id = msg.sender_id,
|
||||
@@ -463,6 +581,7 @@ function admin.rpc_admin_list_channel_messages(context, payload)
|
||||
|
||||
return nk.json_encode({
|
||||
messages = msgs,
|
||||
channel_id = channelId,
|
||||
next_cursor = result.next_cursor or "",
|
||||
cache_cursor = result.cache_cursor or ""
|
||||
})
|
||||
@@ -478,6 +597,7 @@ function admin.rpc_admin_delete_channel_message(context, payload)
|
||||
error("channel_id and message_id are required")
|
||||
end
|
||||
|
||||
channelId = utils.resolve_channel_id(channelId)
|
||||
local status, err = pcall(nk.channel_message_remove, channelId, messageId)
|
||||
if not status then
|
||||
error("Failed to delete message: " .. tostring(err))
|
||||
@@ -500,6 +620,9 @@ nk.register_rpc(admin.rpc_admin_topup_gold, "admin_topup_gold")
|
||||
nk.register_rpc(admin.rpc_admin_clear_global_chat, "admin_clear_global_chat")
|
||||
nk.register_rpc(admin.rpc_admin_list_users, "admin_list_users")
|
||||
nk.register_rpc(admin.rpc_admin_delete_users, "admin_delete_users")
|
||||
nk.register_rpc(admin.rpc_admin_get_user_detail, "admin_get_user_detail")
|
||||
nk.register_rpc(admin.rpc_admin_update_user_identity, "admin_update_user_identity")
|
||||
nk.register_rpc(admin.rpc_admin_set_user_password, "admin_set_user_password")
|
||||
nk.register_rpc(admin.rpc_admin_get_chat_config, "admin_get_chat_config")
|
||||
nk.register_rpc(admin.rpc_admin_set_chat_config, "admin_set_chat_config")
|
||||
nk.register_rpc(admin.rpc_admin_purge_old_messages, "admin_purge_old_messages")
|
||||
|
||||
@@ -14,7 +14,7 @@ function leaderboard.rpc_get_leaderboard_stats(context, payload)
|
||||
local leaderboardData = {}
|
||||
local ownerRecords = records_or_err.records or {}
|
||||
|
||||
for _, record in ipairs(ownerRecords) do
|
||||
for _, record in pairs(ownerRecords) do
|
||||
local metadata = {}
|
||||
if record.metadata then
|
||||
local s, m = pcall(nk.json_decode, record.metadata)
|
||||
|
||||
@@ -50,4 +50,34 @@ function utils.require_admin_or_host(context, match_id)
|
||||
end
|
||||
end
|
||||
|
||||
-- Channel type constants for nk.channel_id_build (Nakama Lua runtime).
|
||||
-- NOTE: these differ from the Godot client's NakamaSocket.ChannelType enum.
|
||||
utils.CHANNEL_TYPE_ROOM = 1
|
||||
utils.CHANNEL_TYPE_DIRECT = 2
|
||||
utils.CHANNEL_TYPE_GROUP = 3
|
||||
|
||||
-- Resolve a chat channel identifier for admin chat RPCs.
|
||||
--
|
||||
-- The client may send either an already-hashed Nakama channel ID, or a friendly
|
||||
-- room name (e.g. "social_global"). A raw room name is NOT a valid channel ID for
|
||||
-- nk.channel_messages_list, so we build the canonical hashed ID via the
|
||||
-- authoritative nk.channel_id_build API (system user as sender → global room).
|
||||
-- Returns the resolved channel ID, or the original value if it already looks
|
||||
-- hashed / can't be built.
|
||||
function utils.resolve_channel_id(channel_id)
|
||||
if not channel_id or channel_id == "" then
|
||||
return ""
|
||||
end
|
||||
-- A hashed Nakama channel ID contains a '.' separator; a plain room name does
|
||||
-- not. Only treat dot-free values as room names needing resolution.
|
||||
if string.find(channel_id, "%.") then
|
||||
return channel_id
|
||||
end
|
||||
local status, built = pcall(nk.channel_id_build, "", channel_id, utils.CHANNEL_TYPE_ROOM)
|
||||
if status and built and built ~= "" then
|
||||
return built
|
||||
end
|
||||
return channel_id
|
||||
end
|
||||
|
||||
return utils
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import re
|
||||
|
||||
with open("scripts/managers/gauntlet_manager.gd", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
new_process = """func _process(delta: float) -> void:
|
||||
if not is_active:
|
||||
return
|
||||
|
||||
elapsed_time += delta
|
||||
|
||||
# Phase escalation
|
||||
_check_phase_transition()
|
||||
|
||||
# Cannon timer (server only)
|
||||
if multiplayer.is_server():
|
||||
cannon_timer -= delta
|
||||
if cannon_timer <= 0.0:
|
||||
_fire_volley()
|
||||
cannon_timer = cannon_interval
|
||||
|
||||
# Smack mechanic update
|
||||
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()
|
||||
|
||||
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 _can_rpc():
|
||||
player.rpc("sync_modulate", Color.PINK)
|
||||
else:
|
||||
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 _can_rpc():
|
||||
player.rpc("sync_modulate", Color.WHITE)
|
||||
else:
|
||||
player.sync_modulate(Color.WHITE)"""
|
||||
|
||||
content = re.sub(r"func _process\(delta: float\) -> void:.*?(?=\n# =+)", new_process, content, flags=re.DOTALL)
|
||||
|
||||
with open("scripts/managers/gauntlet_manager.gd", "w") as f:
|
||||
f.write(content)
|
||||
@@ -0,0 +1,24 @@
|
||||
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
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://b7ihsm80fbyb5
|
||||
@@ -0,0 +1,9 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://ca04jq87bj3ap
|
||||
@@ -0,0 +1,163 @@
|
||||
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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bkte51v8tyoii
|
||||
@@ -1,41 +0,0 @@
|
||||
extends GutTest
|
||||
|
||||
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 #4 Cannon Timer] ===")
|
||||
|
||||
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 test_cannon_timer_initialization():
|
||||
assert_eq(gauntlet_manager.cannon_timer, 0.0, "Timer should start at 0.0 before phase starts")
|
||||
|
||||
# Manually start phase to setup interval
|
||||
gauntlet_manager.current_phase = 0 # GauntletManager.Phase.OPEN_ARENA
|
||||
var config = gauntlet_manager.phase_configs[0]
|
||||
gauntlet_manager.cannon_interval = config["interval"]
|
||||
gauntlet_manager.cannon_timer = gauntlet_manager.cannon_interval
|
||||
|
||||
assert_eq(gauntlet_manager.cannon_timer, 5.0, "Timer should initialize to Phase 1 interval (5.0)")
|
||||
|
||||
func test_volley_size_configuration():
|
||||
assert_eq(gauntlet_manager.phase_configs[0]["volley"], 5, "Phase 1 volley size should be 5")
|
||||
|
||||
func after_each():
|
||||
if main_mock:
|
||||
main_mock.queue_free()
|
||||
|
||||
func after_all():
|
||||
gut.p("=== Feature Tests Complete ===")
|
||||
@@ -1 +0,0 @@
|
||||
uid://ct0psnc84v1sy
|
||||
@@ -0,0 +1,115 @@
|
||||
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, but inventory caps at 1 (not consumed).
|
||||
for i in range(4):
|
||||
manager._on_goal_count_updated(7, i + 1)
|
||||
assert_eq(manager.player_cleansers.get(7, 0), 1, "Inventory never exceeds 1")
|
||||
|
||||
# =============================================================================
|
||||
# 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_ends_cleanser():
|
||||
manager.cleanser_active[8] = true
|
||||
manager.cleanser_cells_left[8] = 3
|
||||
manager.notify_movement_stopped(8, Vector2i(5, 5)) # safe cell
|
||||
assert_false(manager.is_cleanser_active(8), "Cleanser ends on safe-cell stop")
|
||||
|
||||
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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://b1bay8n1h65u3
|
||||
@@ -0,0 +1,159 @@
|
||||
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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://btbxtdhagjdba
|
||||
@@ -0,0 +1,119 @@
|
||||
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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://4cttae74ja3t
|
||||
@@ -29,15 +29,15 @@ func test_all_modes_in_enum():
|
||||
# String Conversion Tests
|
||||
# =============================================================================
|
||||
|
||||
# Test 3: from_string recognizes "Candy Cannon Survival"
|
||||
# Test 3: from_string recognizes "Candy Pump Survival"
|
||||
func test_from_string_candy_cannon():
|
||||
var result = GameMode.from_string("Candy Cannon Survival")
|
||||
assert_eq(result, GameMode.Mode.GAUNTLET, "from_string should parse 'Candy Cannon Survival' as GAUNTLET")
|
||||
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 Cannon Survival" for 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 Cannon Survival", "mode_to_string should return 'Candy Cannon Survival'")
|
||||
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():
|
||||
@@ -61,10 +61,10 @@ func test_unknown_string_defaults_freemode():
|
||||
# get_all_modes Tests
|
||||
# =============================================================================
|
||||
|
||||
# Test 8: get_all_modes includes "Candy Cannon Survival"
|
||||
# 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 Cannon Survival", "get_all_modes should include 'Candy Cannon Survival'")
|
||||
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():
|
||||
@@ -77,7 +77,7 @@ func test_get_all_modes_order():
|
||||
assert_eq(modes[0], "Freemode", "First mode should be Freemode")
|
||||
assert_eq(modes[1], "Stop n Go", "Second mode should be Stop n Go")
|
||||
assert_eq(modes[2], "Tekton Doors", "Third mode should be Tekton Doors")
|
||||
assert_eq(modes[3], "Candy Cannon Survival", "Fourth mode should be Candy Cannon Survival")
|
||||
assert_eq(modes[3], "Candy Pump Survival", "Fourth mode should be Candy Pump Survival")
|
||||
|
||||
# =============================================================================
|
||||
# is_restricted Tests
|
||||
@@ -103,10 +103,10 @@ func test_all_restricted_modes():
|
||||
# LobbyManager Integration Tests
|
||||
# =============================================================================
|
||||
|
||||
# Test 14: Lobby available_game_modes includes "Candy Cannon Survival"
|
||||
# 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 Cannon Survival", "LobbyManager.available_game_modes should include 'Candy Cannon Survival'")
|
||||
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():
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://tugcu571care
|
||||
@@ -0,0 +1,208 @@
|
||||
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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://csco4t66gq5et
|
||||
@@ -126,13 +126,13 @@ func test_initial_phase_is_open_arena():
|
||||
assert_eq(manager.current_phase, GauntletManager.Phase.OPEN_ARENA, "Should start in Open Arena")
|
||||
|
||||
func test_phase_to_string_open_arena():
|
||||
assert_eq(manager._phase_to_string(GauntletManager.Phase.OPEN_ARENA), "Open Arena")
|
||||
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), "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), "Survival!")
|
||||
assert_eq(manager._phase_to_string(GauntletManager.Phase.SURVIVAL_ENDGAME), "Inner Survival")
|
||||
|
||||
# =============================================================================
|
||||
# Match Timer Integration
|
||||
|
||||
Reference in New Issue
Block a user