Merge main into agent/896fbb97de1d
# Conflicts: # .godot/editor/filesystem_cache10 # .godot/editor/project_metadata.cfg # .godot/global_script_class_cache.cfg # .godot/uid_cache.bin # project.godot # scenes/main.tscn # scenes/main_menu.tscn # scripts/main_menu.gd # scripts/main_menu.gd.uid
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
extends Node
|
||||
## Central HTTP client singleton (autoload name: "ApiClient").
|
||||
##
|
||||
## Every network call in the game flows through this class. Callers use the
|
||||
## high-level helpers ([method login], [method register], [method get_worlds],
|
||||
## [method health_check], [method get_me]) or the generic [method request]
|
||||
## primitive. All results conform to the result-dictionary contract below.
|
||||
##
|
||||
## [b]Result Dictionary Contract[/b]
|
||||
## Every public method returns (after `await`) a Dictionary with this shape:
|
||||
##
|
||||
## {
|
||||
## "ok": bool, # true for HTTP 2xx, false otherwise
|
||||
## "status": int, # HTTP status code (0 on network failure)
|
||||
## "data": Variant, # parsed JSON body on success (null otherwise)
|
||||
## "detail": String, # human-readable message
|
||||
## }
|
||||
##
|
||||
## Network failures (timeout / DNS / refused connection) return `status == 0`
|
||||
## with a `detail` describing the transport failure. Non-2xx responses return
|
||||
## the real HTTP status and the backend's `detail` field from the JSON error
|
||||
## body when present. No caller can crash on a failed request.
|
||||
##
|
||||
## [b]Token storage[/b]
|
||||
## JWTs are kept in memory. [method set_token] is the single seam for future
|
||||
## disk persistence (override it to save to `user://`).
|
||||
##
|
||||
## [b]Usage[/b]
|
||||
## var result: Dictionary = await ApiClient.login("alice", "secret")
|
||||
## if result.ok:
|
||||
## print("Logged in!")
|
||||
## else:
|
||||
## print("Error: ", result.detail)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token storage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _access_token: String = ""
|
||||
var _token_type: String = "Bearer"
|
||||
|
||||
## Full Authorization header value, e.g. "Bearer eyJ...". Empty if no token.
|
||||
var bearer_token: String:
|
||||
get:
|
||||
return _token_type + " " + _access_token if _access_token != "" else ""
|
||||
|
||||
|
||||
## Whether an access token is currently stored.
|
||||
func has_token() -> bool:
|
||||
return _access_token != ""
|
||||
|
||||
|
||||
## Set the access token (called after login / register / token refresh).
|
||||
## This is the persistence seam: override to also write to disk.
|
||||
func set_token(access_token: String, token_type: String = "Bearer") -> void:
|
||||
_access_token = access_token
|
||||
_token_type = token_type
|
||||
|
||||
|
||||
## Clear stored tokens.
|
||||
func clear_token() -> void:
|
||||
_access_token = ""
|
||||
_token_type = "Bearer"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _base_url: String = "http://127.0.0.1:8000"
|
||||
|
||||
## Request timeout in seconds. 0 disables the timeout.
|
||||
var timeout_seconds: float = 10.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Environment variable override (e.g. for headless smoke tests against a
|
||||
# backend on a non-default port). Falls back to the ProjectSetting, then
|
||||
# to the documented default.
|
||||
var env_url := OS.get_environment("AGP_API_BASE_URL")
|
||||
if env_url != "":
|
||||
_base_url = env_url.rstrip("/")
|
||||
else:
|
||||
_base_url = str(ProjectSettings.get_setting(
|
||||
"application/agp/api_base_url",
|
||||
"http://127.0.0.1:8000"
|
||||
)).rstrip("/")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core request primitive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Generic request primitive. Awaitable via `await`.
|
||||
##
|
||||
## Uses a dedicated HTTPRequest node per call so concurrent requests can never
|
||||
## cross-talk, and so the result maps 1:1 to the awaiting caller.
|
||||
##
|
||||
## @param method HTTPClient.Method enum value (GET/POST/PUT/PATCH/DELETE).
|
||||
## @param path URL path, e.g. "/api/health".
|
||||
## @param body Request body string (JSON or form-encoded).
|
||||
## @param content_type "application/json" or "application/x-www-form-urlencoded".
|
||||
## @param require_auth If true, attach the stored bearer token when present.
|
||||
## @return Result dictionary conforming to the contract.
|
||||
func request(
|
||||
method: HTTPClient.Method,
|
||||
path: String,
|
||||
body: String = "",
|
||||
content_type: String = "application/json",
|
||||
require_auth: bool = false
|
||||
) -> Dictionary:
|
||||
var http := HTTPRequest.new()
|
||||
http.timeout = timeout_seconds
|
||||
http.max_redirects = 8
|
||||
http.accept_gzip = true
|
||||
add_child(http)
|
||||
|
||||
var headers := PackedStringArray()
|
||||
headers.append("Accept: application/json")
|
||||
if content_type != "":
|
||||
headers.append("Content-Type: " + content_type)
|
||||
if require_auth and has_token():
|
||||
headers.append("Authorization: " + bearer_token)
|
||||
|
||||
var url := _base_url + path
|
||||
var err := http.request(url, headers, method, body)
|
||||
if err != OK:
|
||||
http.queue_free()
|
||||
return _make_result(false, 0, null, "Failed to start request: " + _error_string(err))
|
||||
|
||||
# request_completed emits (result, response_code, headers, body).
|
||||
var args: Array = await http.request_completed
|
||||
var result_code: int = args[0]
|
||||
var response_code: int = args[1]
|
||||
var response_body: PackedByteArray = args[3]
|
||||
http.queue_free()
|
||||
|
||||
# Transport-level failure (timeout / DNS / refused / TLS, etc.).
|
||||
if result_code != HTTPRequest.RESULT_SUCCESS:
|
||||
return _make_result(false, 0, null, _transport_error_detail(result_code))
|
||||
|
||||
# Server accepted the connection but returned no HTTP status line.
|
||||
if response_code == 0:
|
||||
return _make_result(false, 0, null, "No response received from server.")
|
||||
|
||||
var json_string := response_body.get_string_from_utf8()
|
||||
var ok := response_code >= 200 and response_code < 300
|
||||
|
||||
if ok:
|
||||
var data: Variant = _parse_json(json_string)
|
||||
return _make_result(true, response_code, data, "OK")
|
||||
|
||||
# Non-2xx: surface the backend's "detail" message when available.
|
||||
var detail := "HTTP error %d" % response_code
|
||||
var parsed: Variant = _parse_json(json_string)
|
||||
if parsed is Dictionary and (parsed as Dictionary).has("detail"):
|
||||
detail = str((parsed as Dictionary)["detail"])
|
||||
return _make_result(false, response_code, null, detail)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# High-level helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Log in with username/password (OAuth2PasswordRequestForm).
|
||||
## On success stores the returned access token.
|
||||
func login(username: String, password: String) -> Dictionary:
|
||||
var form := "username=" + username.uri_encode() + "&password=" + password.uri_encode()
|
||||
var result := await request(
|
||||
HTTPClient.METHOD_POST,
|
||||
"/api/auth/login",
|
||||
form,
|
||||
"application/x-www-form-urlencoded",
|
||||
false
|
||||
)
|
||||
_store_token_from_result(result)
|
||||
return result
|
||||
|
||||
|
||||
## Register a new user. On success stores the returned access token.
|
||||
## @param over_18 Must be true; the backend rejects registration otherwise.
|
||||
func register(
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
over_18: bool
|
||||
) -> Dictionary:
|
||||
var payload := {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"over_18": over_18,
|
||||
}
|
||||
var result := await request(
|
||||
HTTPClient.METHOD_POST,
|
||||
"/api/auth/register",
|
||||
JSON.stringify(payload),
|
||||
"application/json",
|
||||
false
|
||||
)
|
||||
_store_token_from_result(result)
|
||||
return result
|
||||
|
||||
|
||||
## List worlds visible to the current user (requires auth).
|
||||
## @param visibility Optional filter: "public", "private", "friends_only".
|
||||
func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dictionary:
|
||||
var path := "/api/worlds/?skip=%d&limit=%d" % [skip, limit]
|
||||
if visibility != "":
|
||||
path += "&visibility=" + visibility.uri_encode()
|
||||
return await request(HTTPClient.METHOD_GET, path, "", "application/json", true)
|
||||
|
||||
|
||||
## Ping the backend health endpoint (no auth).
|
||||
func health_check() -> Dictionary:
|
||||
return await request(HTTPClient.METHOD_GET, "/api/health", "", "application/json", false)
|
||||
|
||||
|
||||
## Fetch the current user's profile from the stored JWT (requires auth).
|
||||
func get_me() -> Dictionary:
|
||||
return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _store_token_from_result(result: Dictionary) -> void:
|
||||
if result.ok and result.data is Dictionary:
|
||||
var token: String = str((result.data as Dictionary).get("access_token", ""))
|
||||
if token != "":
|
||||
set_token(token)
|
||||
|
||||
|
||||
func _parse_json(text: String) -> Variant:
|
||||
if text == "":
|
||||
return null
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if parsed == null:
|
||||
# null is ambiguous (valid JSON "null" vs parse failure). Fall back to
|
||||
# the raw string so callers never lose the body entirely.
|
||||
return text if text != "null" else null
|
||||
return parsed
|
||||
|
||||
|
||||
func _make_result(ok: bool, status: int, data: Variant, detail: String) -> Dictionary:
|
||||
return {
|
||||
"ok": ok,
|
||||
"status": status,
|
||||
"data": data,
|
||||
"detail": detail,
|
||||
}
|
||||
|
||||
|
||||
func _transport_error_detail(result_code: int) -> String:
|
||||
match result_code:
|
||||
HTTPRequest.RESULT_CANT_CONNECT:
|
||||
return "Could not connect to server. Is it running?"
|
||||
HTTPRequest.RESULT_CANT_RESOLVE:
|
||||
return "Could not resolve server hostname."
|
||||
HTTPRequest.RESULT_CONNECTION_ERROR:
|
||||
return "Connection error."
|
||||
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
|
||||
return "TLS handshake failed."
|
||||
HTTPRequest.RESULT_NO_RESPONSE:
|
||||
return "No response from server."
|
||||
HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED:
|
||||
return "Response body too large."
|
||||
HTTPRequest.RESULT_BODY_DECOMPRESS_FAILED:
|
||||
return "Failed to decompress response body."
|
||||
HTTPRequest.RESULT_REQUEST_FAILED:
|
||||
return "Request failed."
|
||||
HTTPRequest.RESULT_TIMEOUT:
|
||||
return "Request timed out."
|
||||
_:
|
||||
return "Network error (code %d)." % result_code
|
||||
|
||||
|
||||
func _error_string(err_code: int) -> String:
|
||||
match err_code:
|
||||
OK:
|
||||
return "OK"
|
||||
ERR_UNCONFIGURED:
|
||||
return "HTTPRequest not configured (not in tree)."
|
||||
ERR_BUSY:
|
||||
return "HTTPRequest is busy with a previous request."
|
||||
ERR_INVALID_PARAMETER:
|
||||
return "Invalid URL format."
|
||||
ERR_CANT_CONNECT:
|
||||
return "Cannot connect to host."
|
||||
_:
|
||||
return "Unknown error (%d)." % err_code
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjd0uhll7iylj
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Headless CI export for Linux and Windows.
|
||||
#
|
||||
# Requires:
|
||||
# - Godot 4.6.2 (the exact version this project + export_presets.cfg were
|
||||
# built against; a different Godot minor version's export templates are
|
||||
# not guaranteed compatible)
|
||||
# - The matching export templates installed at
|
||||
# ~/.local/share/godot/export_templates/4.6.2.stable/
|
||||
# (download: https://godotengine.org/download/archive/4.6.2-stable/,
|
||||
# "Export templates" -- NOT the same as the editor download)
|
||||
# - godot on PATH (or set GODOT_BIN below)
|
||||
#
|
||||
# Usage: scripts/build.sh
|
||||
# Output: export/linux/agp-game.x86_64, export/windows/agp-game.exe
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
GODOT_BIN="${GODOT_BIN:-godot}"
|
||||
if ! command -v "$GODOT_BIN" >/dev/null 2>&1; then
|
||||
if [ -x "$HOME/godot/godot" ]; then
|
||||
GODOT_BIN="$HOME/godot/godot"
|
||||
else
|
||||
echo "godot binary not found (checked PATH and ~/godot/godot)." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Godot version: $("$GODOT_BIN" --version)"
|
||||
|
||||
mkdir -p export/linux export/windows
|
||||
|
||||
echo "=== Exporting Linux/X11 ==="
|
||||
"$GODOT_BIN" --headless --path . --export-release "Linux" export/linux/agp-game.x86_64
|
||||
|
||||
echo "=== Exporting Windows Desktop ==="
|
||||
"$GODOT_BIN" --headless --path . --export-release "Windows Desktop" export/windows/agp-game.exe
|
||||
|
||||
echo ""
|
||||
echo "Build artifacts:"
|
||||
ls -la export/linux export/windows
|
||||
@@ -0,0 +1,105 @@
|
||||
class_name Chunk
|
||||
extends Node3D
|
||||
## A single world chunk. Holds the chunk coordinate, bounds, and the
|
||||
## visual/gameplay content for that region of the world.
|
||||
##
|
||||
## Chunks are the atomic unit of world geometry, gameplay objects, and
|
||||
## persistence (see docs/architecture/world.md). The server loads and saves
|
||||
## whole chunks; clients stream the chunks around them.
|
||||
|
||||
## Default chunk dimensions in world units (tiles). Matches the constants in
|
||||
## docs/architecture/world.md section 2.2.
|
||||
const CHUNK_SIZE_X: int = 32
|
||||
const CHUNK_SIZE_Y: int = 32
|
||||
const CHUNK_SIZE_Z: int = 1
|
||||
|
||||
## World-space origin of chunk coordinate (0,0).
|
||||
const CHUNK_ORIGIN := Vector3(0.0, 0.0, 0.0)
|
||||
|
||||
## Chunk coordinate (cx, cy, cz) in chunk space.
|
||||
var coord := Vector3i.ZERO
|
||||
|
||||
## The canonical chunk key: "{world_id}:{room_id}:{cx}:{cy}:{cz}".
|
||||
var key: String = ""
|
||||
|
||||
## World and room identifiers this chunk belongs to.
|
||||
var world_id: String = ""
|
||||
var room_id: String = ""
|
||||
|
||||
## Format version of the chunk payload (currently 1).
|
||||
var format_version: int = 1
|
||||
|
||||
## Revision counter, incremented on every write. Used for optimistic
|
||||
## concurrency and change detection.
|
||||
var revision: int = 0
|
||||
|
||||
## Whether this chunk has been modified in memory but not yet flushed.
|
||||
var dirty: bool = false
|
||||
|
||||
## Container for static objects (props, geometry) in this chunk.
|
||||
var objects: Array[Node3D] = []
|
||||
|
||||
## Container for dynamic object templates (spawnable entities).
|
||||
var dynamic_templates: Array[Node3D] = []
|
||||
|
||||
## The world-space bounding box of this chunk.
|
||||
var bounds: AABB
|
||||
|
||||
|
||||
func _init(p_coord: Vector3i = Vector3i.ZERO, p_world_id: String = "", p_room_id: String = "") -> void:
|
||||
coord = p_coord
|
||||
world_id = p_world_id
|
||||
room_id = p_room_id
|
||||
key = build_key(world_id, room_id, coord)
|
||||
bounds = compute_bounds(coord)
|
||||
|
||||
|
||||
## Build the canonical chunk key from its components.
|
||||
static func build_key(p_world_id: String, p_room_id: String, p_coord: Vector3i) -> String:
|
||||
return "%s:%s:%d:%d:%d" % [p_world_id, p_room_id, p_coord.x, p_coord.y, p_coord.z]
|
||||
|
||||
|
||||
## Compute the world-space bounding box for a chunk coordinate.
|
||||
static func compute_bounds(p_coord: Vector3i) -> AABB:
|
||||
var min_pos := Vector3(
|
||||
CHUNK_ORIGIN.x + p_coord.x * CHUNK_SIZE_X,
|
||||
CHUNK_ORIGIN.y + p_coord.y * CHUNK_SIZE_Y,
|
||||
CHUNK_ORIGIN.z + p_coord.z * CHUNK_SIZE_Z
|
||||
)
|
||||
return AABB(min_pos, Vector3(CHUNK_SIZE_X, CHUNK_SIZE_Y, CHUNK_SIZE_Z))
|
||||
|
||||
|
||||
## Compute the chunk coordinate that contains a world-space position.
|
||||
static func coord_for_position(p_pos: Vector3) -> Vector3i:
|
||||
return Vector3i(
|
||||
int(floor((p_pos.x - CHUNK_ORIGIN.x) / CHUNK_SIZE_X)),
|
||||
int(floor((p_pos.y - CHUNK_ORIGIN.y) / CHUNK_SIZE_Y)),
|
||||
int(floor((p_pos.z - CHUNK_ORIGIN.z) / CHUNK_SIZE_Z))
|
||||
)
|
||||
|
||||
|
||||
## Add a static object to this chunk.
|
||||
func add_object(p_object: Node3D) -> void:
|
||||
objects.append(p_object)
|
||||
add_child(p_object)
|
||||
|
||||
|
||||
## Add a dynamic object template to this chunk.
|
||||
func add_dynamic_template(p_template: Node3D) -> void:
|
||||
dynamic_templates.append(p_template)
|
||||
add_child(p_template)
|
||||
|
||||
|
||||
## Remove all objects from this chunk.
|
||||
func clear_objects() -> void:
|
||||
for obj in objects:
|
||||
if is_instance_valid(obj):
|
||||
obj.queue_free()
|
||||
objects.clear()
|
||||
dynamic_templates.clear()
|
||||
|
||||
|
||||
## Mark this chunk as dirty (modified but not yet persisted).
|
||||
func mark_dirty() -> void:
|
||||
dirty = true
|
||||
revision += 1
|
||||
@@ -0,0 +1 @@
|
||||
uid://dc6j7kqjitc6g
|
||||
@@ -0,0 +1,667 @@
|
||||
class_name ChunkDataDecoder
|
||||
extends RefCounted
|
||||
## Decodes chunk binary payloads (and JSON) into logical chunk data.
|
||||
##
|
||||
## Implements the binary format spec from docs/architecture/world.md §3.6.
|
||||
## Both the Godot client and Python backend use this class to ensure
|
||||
## identical encoding/decoding behavior.
|
||||
##
|
||||
## ## Binary Format Overview
|
||||
##
|
||||
## ```
|
||||
## Header (48 bytes):
|
||||
## [0:4] magic "AGPC"
|
||||
## [4:6] version (uint16 LE)
|
||||
## [6:8] header_len (uint16 LE)
|
||||
## [8:12] flags (uint32 LE)
|
||||
## [12:16] payload_len (uint32 LE)
|
||||
## [16:48] checksum (SHA-256 hex, 32 bytes)
|
||||
##
|
||||
## Payload Body (sections):
|
||||
## [section_id:1][section_len:4][data:section_len]
|
||||
## ```
|
||||
|
||||
|
||||
## Content flags parsed from a chunk.
|
||||
class ContentFlags:
|
||||
## Severity levels: 0=none, 1=mild, 2=moderate, 3=severe
|
||||
var violence: int = 0
|
||||
var sexual: int = 0
|
||||
var language: int = 0
|
||||
var substance: int = 0
|
||||
var gambling: int = 0
|
||||
var horror: int = 0
|
||||
var hate: int = 0
|
||||
var custom: int = 0
|
||||
var mature: bool = false
|
||||
var sources: Array[String] = []
|
||||
|
||||
func is_mature() -> bool:
|
||||
return mature
|
||||
|
||||
func has_severe_content() -> bool:
|
||||
return (violence >= 3 or sexual >= 3 or language >= 3 or
|
||||
substance >= 3 or gambling >= 3 or horror >= 3 or
|
||||
hate >= 3 or custom >= 3)
|
||||
|
||||
func get_max_severity() -> int:
|
||||
return maxi(
|
||||
violence, maxi(sexual, maxi(language, maxi(substance,
|
||||
maxi(gambling, maxi(horror, maxi(hate, custom)))))))
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"categories": {
|
||||
"violence": violence,
|
||||
"sexual": sexual,
|
||||
"language": language,
|
||||
"substance": substance,
|
||||
"gambling": gambling,
|
||||
"horror": horror,
|
||||
"hate": hate,
|
||||
"custom": custom,
|
||||
},
|
||||
"mature": mature,
|
||||
"sources": sources,
|
||||
}
|
||||
|
||||
func from_dict(d: Dictionary) -> void:
|
||||
if d.has("categories"):
|
||||
violence = d["categories"].get("violence", 0)
|
||||
sexual = d["categories"].get("sexual", 0)
|
||||
language = d["categories"].get("language", 0)
|
||||
substance = d["categories"].get("substance", 0)
|
||||
gambling = d["categories"].get("gambling", 0)
|
||||
horror = d["categories"].get("horror", 0)
|
||||
hate = d["categories"].get("hate", 0)
|
||||
custom = d["categories"].get("custom", 0)
|
||||
mature = d.get("mature", false)
|
||||
sources = d.get("sources", [])
|
||||
|
||||
|
||||
## A tile layer definition (palette + dense tile indices).
|
||||
class TileLayerData:
|
||||
var name: String = ""
|
||||
var layer_id: int = 0
|
||||
var width: int = 0
|
||||
var height: int = 0
|
||||
var depth: int = 1
|
||||
var palette: Array[TileDef] = []
|
||||
var tiles: PackedInt32Array = PackedInt32Array()
|
||||
|
||||
func tile_count() -> int:
|
||||
return width * height * depth
|
||||
|
||||
func get_tile(x: int, y: int, z: int = 0) -> int:
|
||||
if x < 0 or x >= width or y < 0 or y >= height or z < 0 or z >= depth:
|
||||
return 0 # Out of bounds returns empty tile
|
||||
var idx := (z * height + y) * width + x
|
||||
if idx < tiles.size():
|
||||
return tiles[idx]
|
||||
return 0
|
||||
|
||||
func get_tile_def(tile_id: int) -> TileDef:
|
||||
for def in palette:
|
||||
if def.id == tile_id:
|
||||
return def
|
||||
return null
|
||||
|
||||
|
||||
## A world object parsed from chunk data.
|
||||
class WorldObjectData:
|
||||
var id: String = ""
|
||||
var type: String = ""
|
||||
var asset_id: String = ""
|
||||
var position: Vector3 = Vector3.ZERO
|
||||
var rotation: Vector4 = Vector4(0, 0, 0, 1)
|
||||
var scale: Vector3 = Vector3.ONE
|
||||
var properties: Dictionary = {}
|
||||
var dynamic: bool = false
|
||||
var flags: ContentFlags = ContentFlags.new()
|
||||
|
||||
|
||||
## The complete decoded chunk payload.
|
||||
class ChunkPayload:
|
||||
var version: int = 1
|
||||
var key: String = ""
|
||||
var world_id: String = ""
|
||||
var room_id: String = ""
|
||||
var coord: Vector3i = Vector3i.ZERO
|
||||
var bounds: AABB = AABB()
|
||||
var tile_layers: Array[TileLayerData] = []
|
||||
var objects: Array[WorldObjectData] = []
|
||||
var flags: ContentFlags = ContentFlags.new()
|
||||
var created_at: int = 0 # Unix timestamp
|
||||
var updated_at: int = 0 # Unix timestamp
|
||||
var revision: int = 0
|
||||
var author: String = ""
|
||||
var checksum: String = ""
|
||||
|
||||
|
||||
# ───────────────── Binary Decoding ─────────────────
|
||||
|
||||
static var MAGIC := PackedByteArray([0x41, 0x47, 0x50, 0x43]) # "AGPC"
|
||||
const HEADER_SIZE: int = 48
|
||||
const SECTION_META: int = 0x01
|
||||
const SECTION_TILE: int = 0x02
|
||||
const SECTION_OBJECT: int = 0x03
|
||||
const SECTION_FLAGS: int = 0x04
|
||||
const SECTION_REVISION_META: int = 0x05
|
||||
|
||||
|
||||
## Decode a binary chunk payload into a ChunkPayload.
|
||||
## Returns null if the data is invalid or version is unsupported.
|
||||
static func decode_binary(data: PackedByteArray) -> ChunkPayload:
|
||||
if data.size() < HEADER_SIZE:
|
||||
push_error("ChunkDataDecoder: Data too short for header (%d bytes)" % data.size())
|
||||
return null
|
||||
|
||||
# Verify magic
|
||||
var magic := PackedByteArray(data.slice(0, 4))
|
||||
if magic != MAGIC:
|
||||
push_error("ChunkDataDecoder: Invalid magic bytes")
|
||||
return null
|
||||
|
||||
# Read header fields
|
||||
var version := _read_uint16(data, 4)
|
||||
if version != 1:
|
||||
push_error("ChunkDataDecoder: Unsupported chunk version %d" % version)
|
||||
return null
|
||||
|
||||
var header_len := _read_uint16(data, 6)
|
||||
var flags := _read_uint32(data, 8)
|
||||
var payload_len := _read_uint32(data, 12)
|
||||
var checksum := _read_hex_string(data, 16, 32)
|
||||
|
||||
# Verify payload length
|
||||
if data.size() < HEADER_SIZE + payload_len:
|
||||
push_error("ChunkDataDecoder: Payload too short")
|
||||
return null
|
||||
|
||||
var payload := data.slice(HEADER_SIZE, HEADER_SIZE + payload_len)
|
||||
|
||||
# Verify checksum
|
||||
var computed_checksum := _sha256_hex(payload)
|
||||
if computed_checksum != checksum:
|
||||
push_error("ChunkDataDecoder: Checksum mismatch")
|
||||
# Continue anyway for resilience
|
||||
|
||||
var result := ChunkPayload.new()
|
||||
result.version = version
|
||||
result.checksum = checksum
|
||||
|
||||
# Parse sections
|
||||
var offset := 0
|
||||
while offset < payload.size():
|
||||
if offset + 5 > payload.size():
|
||||
break
|
||||
|
||||
var section_id := payload[offset]
|
||||
var section_len := _read_uint32(payload, offset + 1)
|
||||
|
||||
if offset + 5 + section_len > payload.size():
|
||||
break
|
||||
|
||||
var section_data := payload.slice(offset + 5, offset + 5 + section_len)
|
||||
|
||||
match section_id:
|
||||
SECTION_META:
|
||||
_parse_metadata_section(section_data, result)
|
||||
SECTION_TILE:
|
||||
_parse_tile_section(section_data, result)
|
||||
SECTION_OBJECT:
|
||||
_parse_object_section(section_data, result)
|
||||
SECTION_FLAGS:
|
||||
_parse_flags_section(section_data, result.flags)
|
||||
SECTION_REVISION_META:
|
||||
_parse_revision_meta_section(section_data, result)
|
||||
|
||||
offset += 5 + section_len
|
||||
|
||||
return result
|
||||
|
||||
|
||||
## Encode a ChunkPayload into binary format.
|
||||
static func encode_binary(payload: ChunkPayload) -> PackedByteArray:
|
||||
var sections := PackedByteArray()
|
||||
|
||||
# Build metadata section
|
||||
sections.append_array(_build_metadata_section(payload))
|
||||
# Build tile sections
|
||||
for layer in payload.tile_layers:
|
||||
sections.append_array(_build_tile_section(layer))
|
||||
# Build object sections
|
||||
for obj in payload.objects:
|
||||
sections.append_array(_build_object_section(obj))
|
||||
# Build flags section
|
||||
sections.append_array(_build_flags_section(payload.flags))
|
||||
# Build revision meta section
|
||||
sections.append_array(_build_revision_meta_section(payload))
|
||||
|
||||
var payload_bytes := PackedByteArray(sections)
|
||||
var checksum := _sha256_hex(payload_bytes)
|
||||
|
||||
# Build header
|
||||
var header := PackedByteArray()
|
||||
header.append_array(MAGIC)
|
||||
header.append_array(_write_uint16(1)) # version
|
||||
header.append_array(_write_uint16(0)) # header_len (after this field)
|
||||
header.append_array(_write_uint32(0)) # flags
|
||||
header.append_array(_write_uint32(payload_bytes.size()))
|
||||
header.append_array(_hex_to_bytes(checksum))
|
||||
|
||||
return header + payload_bytes
|
||||
|
||||
|
||||
# ───────────────── Section Parsers ─────────────────
|
||||
|
||||
static func _parse_metadata_section(data: PackedByteArray, result: ChunkPayload) -> void:
|
||||
var offset := 0
|
||||
|
||||
# world_id (length-prefixed string)
|
||||
var world_id_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
result.world_id = data.slice(offset, offset + world_id_len).get_string_from_utf8()
|
||||
offset += world_id_len
|
||||
|
||||
# room_id (length-prefixed string)
|
||||
var room_id_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
result.room_id = data.slice(offset, offset + room_id_len).get_string_from_utf8()
|
||||
offset += room_id_len
|
||||
|
||||
# coord (3 × int32)
|
||||
result.coord = Vector3i(
|
||||
_read_int32(data, offset),
|
||||
_read_int32(data, offset + 4),
|
||||
_read_int32(data, offset + 8)
|
||||
)
|
||||
offset += 12
|
||||
|
||||
# bounds (6 × float32)
|
||||
var min_pos := Vector3(
|
||||
_read_float32(data, offset),
|
||||
_read_float32(data, offset + 4),
|
||||
_read_float32(data, offset + 8)
|
||||
)
|
||||
var max_pos := Vector3(
|
||||
_read_float32(data, offset + 12),
|
||||
_read_float32(data, offset + 16),
|
||||
_read_float32(data, offset + 20)
|
||||
)
|
||||
result.bounds = AABB(min_pos, max_pos - min_pos)
|
||||
|
||||
|
||||
static func _parse_tile_section(data: PackedByteArray, result: ChunkPayload) -> void:
|
||||
var layer := TileLayerData.new()
|
||||
var offset := 0
|
||||
|
||||
# name (length-prefixed string)
|
||||
var name_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
layer.name = data.slice(offset, offset + name_len).get_string_from_utf8()
|
||||
offset += name_len
|
||||
|
||||
# layer_id (uint16)
|
||||
layer.layer_id = _read_uint16(data, offset)
|
||||
offset += 2
|
||||
|
||||
# dimensions (3 × uint16)
|
||||
layer.width = _read_uint16(data, offset)
|
||||
offset += 2
|
||||
layer.height = _read_uint16(data, offset)
|
||||
offset += 2
|
||||
layer.depth = _read_uint16(data, offset)
|
||||
offset += 2
|
||||
|
||||
# palette
|
||||
var palette_count := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
for _i in range(palette_count):
|
||||
var tile_def := TileDef.new()
|
||||
tile_def.id = _read_uint16(data, offset)
|
||||
offset += 2
|
||||
|
||||
var asset_id_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
tile_def.asset_id = data.slice(offset, offset + asset_id_len).get_string_from_utf8()
|
||||
offset += asset_id_len
|
||||
|
||||
tile_def.solid = data[offset] != 0
|
||||
offset += 1
|
||||
|
||||
tile_def.flags = _read_uint32(data, offset)
|
||||
offset += 4
|
||||
|
||||
tile_def.variants = data[offset]
|
||||
offset += 1
|
||||
|
||||
layer.palette.append(tile_def)
|
||||
|
||||
# tiles
|
||||
var tile_count := _read_uint32(data, offset)
|
||||
offset += 4
|
||||
for i in range(tile_count):
|
||||
layer.tiles.append(_read_uint16(data, offset))
|
||||
offset += 2
|
||||
|
||||
result.tile_layers.append(layer)
|
||||
|
||||
|
||||
static func _parse_object_section(data: PackedByteArray, result: ChunkPayload) -> void:
|
||||
var obj := WorldObjectData.new()
|
||||
var offset := 0
|
||||
|
||||
# id (length-prefixed string)
|
||||
var id_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
obj.id = data.slice(offset, offset + id_len).get_string_from_utf8()
|
||||
offset += id_len
|
||||
|
||||
# type (length-prefixed string)
|
||||
var type_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
obj.type = data.slice(offset, offset + type_len).get_string_from_utf8()
|
||||
offset += type_len
|
||||
|
||||
# asset_id (length-prefixed string)
|
||||
var asset_id_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
obj.asset_id = data.slice(offset, offset + asset_id_len).get_string_from_utf8()
|
||||
offset += asset_id_len
|
||||
|
||||
# position (3 × float32)
|
||||
obj.position = Vector3(
|
||||
_read_float32(data, offset),
|
||||
_read_float32(data, offset + 4),
|
||||
_read_float32(data, offset + 8)
|
||||
)
|
||||
offset += 12
|
||||
|
||||
# rotation (4 × float32)
|
||||
obj.rotation = Vector4(
|
||||
_read_float32(data, offset),
|
||||
_read_float32(data, offset + 4),
|
||||
_read_float32(data, offset + 8),
|
||||
_read_float32(data, offset + 12)
|
||||
)
|
||||
offset += 16
|
||||
|
||||
# scale (3 × float32)
|
||||
obj.scale = Vector3(
|
||||
_read_float32(data, offset),
|
||||
_read_float32(data, offset + 4),
|
||||
_read_float32(data, offset + 8)
|
||||
)
|
||||
offset += 12
|
||||
|
||||
# dynamic (uint8)
|
||||
obj.dynamic = data[offset] != 0
|
||||
offset += 1
|
||||
|
||||
# properties (length-prefixed JSON string)
|
||||
var props_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
var props_str := data.slice(offset, offset + props_len).get_string_from_utf8()
|
||||
offset += props_len
|
||||
obj.properties = JSON.parse_string(props_str) or {}
|
||||
|
||||
# flags (ContentFlags)
|
||||
obj.flags.violence = data[offset]
|
||||
offset += 1
|
||||
obj.flags.sexual = data[offset]
|
||||
offset += 1
|
||||
obj.flags.language = data[offset]
|
||||
offset += 1
|
||||
obj.flags.substance = data[offset]
|
||||
offset += 1
|
||||
obj.flags.gambling = data[offset]
|
||||
offset += 1
|
||||
obj.flags.horror = data[offset]
|
||||
offset += 1
|
||||
obj.flags.hate = data[offset]
|
||||
offset += 1
|
||||
obj.flags.custom = data[offset]
|
||||
offset += 1
|
||||
obj.flags.mature = data[offset] != 0
|
||||
offset += 1
|
||||
|
||||
var source_count := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
for _i in range(source_count):
|
||||
var src_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
obj.flags.sources.append(data.slice(offset, offset + src_len).get_string_from_utf8())
|
||||
offset += src_len
|
||||
|
||||
result.objects.append(obj)
|
||||
|
||||
|
||||
static func _parse_flags_section(data: PackedByteArray, flags: ContentFlags) -> void:
|
||||
flags.violence = data[0]
|
||||
flags.sexual = data[1]
|
||||
flags.language = data[2]
|
||||
flags.substance = data[3]
|
||||
flags.gambling = data[4]
|
||||
flags.horror = data[5]
|
||||
flags.hate = data[6]
|
||||
flags.custom = data[7]
|
||||
flags.mature = data[8] != 0
|
||||
|
||||
var offset := 9
|
||||
var source_count := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
for _i in range(source_count):
|
||||
var src_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
flags.sources.append(data.slice(offset, offset + src_len).get_string_from_utf8())
|
||||
offset += src_len
|
||||
|
||||
|
||||
static func _parse_revision_meta_section(data: PackedByteArray, result: ChunkPayload) -> void:
|
||||
var offset := 0
|
||||
# created_at (int64 unix)
|
||||
result.created_at = _read_int64(data, offset)
|
||||
offset += 8
|
||||
# updated_at (int64 unix)
|
||||
result.updated_at = _read_int64(data, offset)
|
||||
offset += 8
|
||||
# revision (uint32)
|
||||
result.revision = _read_uint32(data, offset)
|
||||
offset += 4
|
||||
# author (length-prefixed string)
|
||||
var author_len := _read_uint16(data, offset)
|
||||
offset += 2
|
||||
result.author = data.slice(offset, offset + author_len).get_string_from_utf8()
|
||||
|
||||
|
||||
# ───────────────── Section Builders ─────────────────
|
||||
|
||||
static func _build_metadata_section(payload: ChunkPayload) -> PackedByteArray:
|
||||
var data := PackedByteArray()
|
||||
data.append_array(_write_string(payload.world_id))
|
||||
data.append_array(_write_string(payload.room_id))
|
||||
data.append_array(_write_int32(payload.coord.x))
|
||||
data.append_array(_write_int32(payload.coord.y))
|
||||
data.append_array(_write_int32(payload.coord.z))
|
||||
data.append_array(_write_float32(payload.bounds.position.x))
|
||||
data.append_array(_write_float32(payload.bounds.position.y))
|
||||
data.append_array(_write_float32(payload.bounds.position.z))
|
||||
data.append_array(_write_float32(payload.bounds.size.x))
|
||||
data.append_array(_write_float32(payload.bounds.size.y))
|
||||
data.append_array(_write_float32(payload.bounds.size.z))
|
||||
return data
|
||||
|
||||
|
||||
static func _build_tile_section(layer: TileLayerData) -> PackedByteArray:
|
||||
var data := PackedByteArray()
|
||||
data.append_array(_write_string(layer.name))
|
||||
data.append_array(_write_uint16(layer.layer_id))
|
||||
data.append_array(_write_uint16(layer.width))
|
||||
data.append_array(_write_uint16(layer.height))
|
||||
data.append_array(_write_uint16(layer.depth))
|
||||
data.append_array(_write_uint16(layer.palette.size()))
|
||||
|
||||
for tile_def in layer.palette:
|
||||
data.append_array(_write_uint16(tile_def.id))
|
||||
data.append_array(_write_string(tile_def.asset_id))
|
||||
data.append(tile_def.solid as int)
|
||||
data.append_array(_write_uint32(tile_def.flags))
|
||||
data.append(tile_def.variants)
|
||||
|
||||
data.append_array(_write_uint32(layer.tiles.size()))
|
||||
for tile_id in layer.tiles:
|
||||
data.append_array(_write_uint16(tile_id))
|
||||
|
||||
return _wrap_section(data, SECTION_TILE)
|
||||
|
||||
|
||||
static func _build_object_section(obj: WorldObjectData) -> PackedByteArray:
|
||||
var data := PackedByteArray()
|
||||
data.append_array(_write_string(obj.id))
|
||||
data.append_array(_write_string(obj.type))
|
||||
data.append_array(_write_string(obj.asset_id))
|
||||
data.append_array(_write_float32(obj.position.x))
|
||||
data.append_array(_write_float32(obj.position.y))
|
||||
data.append_array(_write_float32(obj.position.z))
|
||||
data.append_array(_write_float32(obj.rotation.x))
|
||||
data.append_array(_write_float32(obj.rotation.y))
|
||||
data.append_array(_write_float32(obj.rotation.z))
|
||||
data.append_array(_write_float32(obj.rotation.w))
|
||||
data.append_array(_write_float32(obj.scale.x))
|
||||
data.append_array(_write_float32(obj.scale.y))
|
||||
data.append_array(_write_float32(obj.scale.z))
|
||||
data.append(obj.dynamic as int)
|
||||
|
||||
var props_json := JSON.stringify(obj.properties, " ")
|
||||
data.append_array(_write_string(props_json))
|
||||
|
||||
data.append(obj.flags.violence)
|
||||
data.append(obj.flags.sexual)
|
||||
data.append(obj.flags.language)
|
||||
data.append(obj.flags.substance)
|
||||
data.append(obj.flags.gambling)
|
||||
data.append(obj.flags.horror)
|
||||
data.append(obj.flags.hate)
|
||||
data.append(obj.flags.custom)
|
||||
data.append(obj.flags.mature as int)
|
||||
data.append_array(_write_uint16(obj.flags.sources.size()))
|
||||
for src in obj.flags.sources:
|
||||
data.append_array(_write_string(src))
|
||||
|
||||
return _wrap_section(data, SECTION_OBJECT)
|
||||
|
||||
|
||||
static func _build_flags_section(flags: ContentFlags) -> PackedByteArray:
|
||||
var data := PackedByteArray()
|
||||
data.append(flags.violence)
|
||||
data.append(flags.sexual)
|
||||
data.append(flags.language)
|
||||
data.append(flags.substance)
|
||||
data.append(flags.gambling)
|
||||
data.append(flags.horror)
|
||||
data.append(flags.hate)
|
||||
data.append(flags.custom)
|
||||
data.append(flags.mature as int)
|
||||
data.append_array(_write_uint16(flags.sources.size()))
|
||||
for src in flags.sources:
|
||||
data.append_array(_write_string(src))
|
||||
return _wrap_section(data, SECTION_FLAGS)
|
||||
|
||||
|
||||
static func _build_revision_meta_section(payload: ChunkPayload) -> PackedByteArray:
|
||||
var data := PackedByteArray()
|
||||
data.append_array(_write_int64(payload.created_at))
|
||||
data.append_array(_write_int64(payload.updated_at))
|
||||
data.append_array(_write_uint32(payload.revision))
|
||||
data.append_array(_write_string(payload.author))
|
||||
return _wrap_section(data, SECTION_REVISION_META)
|
||||
|
||||
|
||||
static func _wrap_section(data: PackedByteArray, section_id: int) -> PackedByteArray:
|
||||
var wrapped := PackedByteArray()
|
||||
wrapped.append(section_id)
|
||||
wrapped.append_array(_write_uint32(data.size()))
|
||||
wrapped.append_array(data)
|
||||
return wrapped
|
||||
|
||||
|
||||
# ───────────────── Binary Helpers ─────────────────
|
||||
|
||||
static func _read_uint16(data: PackedByteArray, offset: int) -> int:
|
||||
return data[offset] | (data[offset + 1] << 8)
|
||||
|
||||
static func _read_uint32(data: PackedByteArray, offset: int) -> int:
|
||||
return (data[offset] | (data[offset + 1] << 8) |
|
||||
(data[offset + 2] << 16) | (data[offset + 3] << 24))
|
||||
|
||||
static func _read_int32(data: PackedByteArray, offset: int) -> int:
|
||||
var val := _read_uint32(data, offset)
|
||||
if val >= 0x80000000:
|
||||
return val - 0x100000000
|
||||
return val
|
||||
|
||||
static func _read_int64(data: PackedByteArray, offset: int) -> int:
|
||||
var lo := _read_uint32(data, offset)
|
||||
var hi := _read_uint32(data, offset + 4)
|
||||
return hi * 0x100000000 + lo
|
||||
|
||||
static func _read_float32(data: PackedByteArray, offset: int) -> float:
|
||||
var bits := _read_uint32(data, offset)
|
||||
return bits as float # GDScript float is 64-bit; bits repr works for IEEE 754
|
||||
|
||||
static func _read_hex_string(data: PackedByteArray, offset: int, length: int) -> String:
|
||||
var hex_chars := "0123456789abcdef"
|
||||
var result := ""
|
||||
for i in range(length):
|
||||
var byte_val := data[offset + i]
|
||||
result += hex_chars[byte_val >> 4]
|
||||
result += hex_chars[byte_val & 0x0F]
|
||||
return result
|
||||
|
||||
|
||||
static func _write_uint16(val: int) -> PackedByteArray:
|
||||
return PackedByteArray([(val & 0xFF), (val >> 8) & 0xFF])
|
||||
|
||||
static func _write_uint32(val: int) -> PackedByteArray:
|
||||
return PackedByteArray([
|
||||
val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF
|
||||
])
|
||||
|
||||
static func _write_int32(val: int) -> PackedByteArray:
|
||||
return _write_uint32(val)
|
||||
|
||||
static func _write_int64(val: int) -> PackedByteArray:
|
||||
var lo := val & 0xFFFFFFFF
|
||||
var hi := (val >> 32) & 0xFFFFFFFF
|
||||
return _write_uint32(lo) + _write_uint32(hi)
|
||||
|
||||
static func _write_float32(val: float) -> PackedByteArray:
|
||||
var bits := val as int
|
||||
return _write_uint32(bits)
|
||||
|
||||
static func _write_string(s: String) -> PackedByteArray:
|
||||
var bytes := s.to_utf8_buffer()
|
||||
return _write_uint16(bytes.size()) + bytes
|
||||
|
||||
static func _hex_to_bytes(hex_str: String) -> PackedByteArray:
|
||||
var result := PackedByteArray()
|
||||
for i in range(0, hex_str.length(), 2):
|
||||
var byte_val := ("0x" + hex_str.substr(i, 2)).hex_to_int()
|
||||
result.append(byte_val)
|
||||
return result
|
||||
|
||||
|
||||
# ───────────────── SHA-256 ─────────────────
|
||||
|
||||
static func _sha256_hex(data: PackedByteArray) -> String:
|
||||
var ctx := HashingContext.new()
|
||||
ctx.start(HashingContext.HASH_SHA256)
|
||||
ctx.update(data)
|
||||
var hash := ctx.finish()
|
||||
var hex_chars := "0123456789abcdef"
|
||||
var result := ""
|
||||
for byte_val in hash:
|
||||
result += hex_chars[(byte_val >> 4) & 0x0F]
|
||||
result += hex_chars[byte_val & 0x0F]
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://bv445xrd6f5tk
|
||||
@@ -0,0 +1,170 @@
|
||||
class_name ChunkScene
|
||||
extends Node3D
|
||||
## A visual representation of a single loaded world chunk.
|
||||
##
|
||||
## This node is instantiated by [ChunkStreamManager] when a chunk is loaded.
|
||||
## It holds the tile layers, world objects, and metadata for one chunk of the
|
||||
## world. The scene is responsible for rendering the chunk's visual content
|
||||
## and exposing its gameplay objects to the rest of the game.
|
||||
##
|
||||
## ## Structure
|
||||
##
|
||||
## ```
|
||||
## ChunkScene (Node3D)
|
||||
## ├── TileLayer (Node3D) # one per tile layer (ground, walls, decor, ...)
|
||||
## ├── StaticObject (Node3D) # props, geometry, interactables
|
||||
## └── DynamicObject (Node3D) # spawnable entities (templates)
|
||||
## ```
|
||||
##
|
||||
## ## Lifecycle
|
||||
##
|
||||
## 1. [ChunkStreamManager] requests chunk data from the server.
|
||||
## 2. Server responds with binary payload.
|
||||
## 3. [ChunkStreamManager] decodes the payload and creates a [ChunkScene].
|
||||
## 4. [ChunkScene] populates itself with tile layers and objects.
|
||||
## 5. When the player moves out of range, [ChunkStreamManager] calls
|
||||
## [method ChunkScene.queue_free] to unload it.
|
||||
|
||||
## The chunk coordinate this scene represents.
|
||||
var coord: Vector3i = Vector3i.ZERO
|
||||
|
||||
## The canonical chunk key.
|
||||
var key: String = ""
|
||||
|
||||
## World-space bounding box of this chunk.
|
||||
var bounds: AABB = AABB()
|
||||
|
||||
## Format version of the chunk data.
|
||||
var format_version: int = 1
|
||||
|
||||
## Revision counter from the server.
|
||||
var revision: int = 0
|
||||
|
||||
## List of tile layers in this chunk.
|
||||
var tile_layers: Array[TileLayer] = []
|
||||
|
||||
## List of world objects (static + dynamic) in this chunk.
|
||||
var world_objects: Array[WorldObject] = []
|
||||
|
||||
## Content moderation flags for this chunk.
|
||||
var content_flags: ChunkDataDecoder.ContentFlags = ChunkDataDecoder.ContentFlags.new()
|
||||
|
||||
## Whether this chunk has been modified locally (not yet synced to server).
|
||||
var is_dirty: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Position this chunk scene at its world-space origin
|
||||
global_position = bounds.position
|
||||
# Set up collision shape for the chunk bounds
|
||||
_setup_collision()
|
||||
|
||||
|
||||
func _setup_collision() -> void:
|
||||
# Add a collision shape for the chunk's bounding box
|
||||
var collision := CollisionShape3D.new()
|
||||
var shape := BoxShape3D.new()
|
||||
shape.size = bounds.size
|
||||
collision.shape = shape
|
||||
collision.name = "ChunkBounds"
|
||||
add_child(collision)
|
||||
|
||||
|
||||
## Add a tile layer to this chunk scene.
|
||||
func add_tile_layer(layer: TileLayer) -> void:
|
||||
tile_layers.append(layer)
|
||||
var layer_node := TileLayerNode.new()
|
||||
layer_node.tile_layer = layer
|
||||
layer_node.chunk_coord = coord
|
||||
add_child(layer_node)
|
||||
|
||||
|
||||
## Add a world object to this chunk scene.
|
||||
func add_object(obj: WorldObject) -> void:
|
||||
world_objects.append(obj)
|
||||
if obj.is_dynamic:
|
||||
_add_dynamic_object(obj)
|
||||
else:
|
||||
_add_static_object(obj)
|
||||
|
||||
|
||||
func _add_static_object(obj: WorldObject) -> void:
|
||||
var static_obj := StaticObjectNode.new()
|
||||
static_obj.world_object = obj
|
||||
static_obj.position = obj.position
|
||||
static_obj.quaternion = Quaternion(obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w)
|
||||
static_obj.scale = obj.scale
|
||||
static_obj.name = "static_%s" % obj.id
|
||||
add_child(static_obj)
|
||||
|
||||
|
||||
func _add_dynamic_object(obj: WorldObject) -> void:
|
||||
var dynamic_obj := DynamicObjectNode.new()
|
||||
dynamic_obj.world_object = obj
|
||||
dynamic_obj.position = obj.position
|
||||
dynamic_obj.quaternion = Quaternion(obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w)
|
||||
dynamic_obj.scale = obj.scale
|
||||
dynamic_obj.name = "dynamic_%s" % obj.id
|
||||
add_child(dynamic_obj)
|
||||
|
||||
|
||||
## Get a world object by its ID.
|
||||
func get_object_by_id(object_id: String) -> WorldObject:
|
||||
for obj in world_objects:
|
||||
if obj.id == object_id:
|
||||
return obj
|
||||
return null
|
||||
|
||||
|
||||
## Get all static objects in this chunk.
|
||||
func get_static_objects() -> Array[WorldObject]:
|
||||
var result: Array[WorldObject] = []
|
||||
for obj in world_objects:
|
||||
if not obj.is_dynamic:
|
||||
result.append(obj)
|
||||
return result
|
||||
|
||||
|
||||
## Get all dynamic object templates in this chunk.
|
||||
func get_dynamic_templates() -> Array[WorldObject]:
|
||||
var result: Array[WorldObject] = []
|
||||
for obj in world_objects:
|
||||
if obj.is_dynamic:
|
||||
result.append(obj)
|
||||
return result
|
||||
|
||||
|
||||
## Mark this chunk as dirty (locally modified).
|
||||
func mark_dirty() -> void:
|
||||
is_dirty = true
|
||||
|
||||
|
||||
## Clear all objects and tile layers from this chunk.
|
||||
func clear() -> void:
|
||||
for child in get_children():
|
||||
if child is TileLayerNode or child is StaticObjectNode or child is DynamicObjectNode:
|
||||
child.queue_free()
|
||||
tile_layers.clear()
|
||||
world_objects.clear()
|
||||
|
||||
|
||||
## Get the world-space position of a tile within this chunk.
|
||||
func get_tile_world_position(tile_x: int, tile_y: int, tile_z: int = 0) -> Vector3:
|
||||
return bounds.position + Vector3(
|
||||
tile_x * 1.0, # Tile size is 1 world unit
|
||||
tile_y * 1.0,
|
||||
tile_z * 1.0
|
||||
)
|
||||
|
||||
|
||||
## Check if a world-space position is within this chunk's bounds.
|
||||
func contains_position(pos: Vector3) -> bool:
|
||||
return bounds.has_point(pos)
|
||||
|
||||
|
||||
## Check if a chunk coordinate is adjacent to this chunk.
|
||||
func is_adjacent(other_coord: Vector3i) -> bool:
|
||||
var dx: int = abs(coord.x - other_coord.x)
|
||||
var dy: int = abs(coord.y - other_coord.y)
|
||||
var dz: int = abs(coord.z - other_coord.z)
|
||||
return dx <= 1 and dy <= 1 and dz <= 1 and (dx + dy + dz) > 0
|
||||
@@ -0,0 +1 @@
|
||||
uid://d4gh4h5iey6xb
|
||||
@@ -0,0 +1,358 @@
|
||||
class_name ChunkStreamManager
|
||||
extends Node
|
||||
## Manages loading and unloading of world chunks based on player position.
|
||||
##
|
||||
## This is the central orchestrator for the chunk streaming system. It monitors
|
||||
## the player's position, determines which chunks should be loaded, requests
|
||||
## chunk data from the server, instantiates ChunkScene nodes, and unloads
|
||||
## chunks that fall outside the streaming radius.
|
||||
##
|
||||
## ## Design
|
||||
##
|
||||
## - **Streaming radius** defines how many chunks around the player are kept
|
||||
## loaded. Chunks beyond this radius are unloaded to save memory and CPU.
|
||||
## - **Load priority** ensures chunks closer to the player are loaded first,
|
||||
## preventing visual pop-in at the edge of the view.
|
||||
## - **Server communication** uses the RPCs defined in docs/architecture/world.md
|
||||
## (§5.3): `request_chunk`, `chunk_data`, `chunk_invalidated`.
|
||||
##
|
||||
## ## Usage
|
||||
##
|
||||
## Add this node to your scene tree and set the `player` reference:
|
||||
## ```gdscript
|
||||
## var stream_manager: ChunkStreamManager = $ChunkStreamManager
|
||||
## stream_manager.player = $Player
|
||||
## stream_manager.streaming_radius = 4 # chunks
|
||||
## ```
|
||||
|
||||
## Number of chunks (radius) to keep loaded around the player.
|
||||
## A radius of 2 means a 5x5 grid of chunks centered on the player.
|
||||
## Higher values increase memory usage but reduce streaming pop-in.
|
||||
@export var streaming_radius: int = 3
|
||||
|
||||
## Maximum number of chunks to load per frame. Prevents frame spikes during
|
||||
## rapid movement or when entering a new area.
|
||||
@export var max_loads_per_frame: int = 4
|
||||
|
||||
## Maximum number of chunks to unload per frame.
|
||||
@export var max_unloads_per_frame: int = 4
|
||||
|
||||
## Distance (in chunks) from the streaming radius edge before a chunk is
|
||||
## considered for unloading. Prevents rapid load/unload cycling.
|
||||
@export var unload_margin: int = 1
|
||||
|
||||
## Emitted when a chunk finishes loading.
|
||||
signal chunk_loaded(chunk_coord: Vector3i, chunk_scene: Node)
|
||||
|
||||
## Emitted when a chunk is unloaded.
|
||||
signal chunk_unloaded(chunk_coord: Vector3i)
|
||||
|
||||
## Emitted when a chunk fails to load (e.g., server error).
|
||||
signal chunk_load_failed(chunk_coord: Vector3i, error: String)
|
||||
|
||||
## Emitted when the player moves far enough to require a streaming update.
|
||||
signal streaming_updated
|
||||
|
||||
## Reference to the player node. Must be set before the manager starts.
|
||||
var player: Node3D = null
|
||||
|
||||
## The current player chunk coordinate.
|
||||
var _player_chunk_coord: Vector3i = Vector3i.ZERO
|
||||
|
||||
## Map of loaded chunk coordinates to their scene nodes.
|
||||
var _loaded_chunks: Dictionary = {}
|
||||
|
||||
## Queue of chunk coordinates pending load, sorted by distance from player.
|
||||
var _load_queue: Array[Vector3i] = []
|
||||
|
||||
## Queue of chunk coordinates pending unload.
|
||||
var _unload_queue: Array[Vector3i] = []
|
||||
|
||||
## Whether streaming is currently active (player is set and in game).
|
||||
var is_active: bool = false
|
||||
|
||||
## The server RPC interface for chunk data. Set by the networking layer.
|
||||
var _server_rpc: Object = null
|
||||
|
||||
## Internal: track which chunks are currently being loaded (to avoid duplicates).
|
||||
var _loading_in_progress: Dictionary = {}
|
||||
|
||||
## Internal: time accumulator for throttled streaming updates.
|
||||
var _update_accumulator: float = 0.0
|
||||
|
||||
## Internal: interval between streaming updates (seconds).
|
||||
const UPDATE_INTERVAL: float = 0.1
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Streaming is driven from _process (main thread) so we can safely
|
||||
# manipulate the scene tree. No background thread is used.
|
||||
set_process(true)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not is_active or player == null:
|
||||
return
|
||||
|
||||
_update_accumulator += delta
|
||||
if _update_accumulator < UPDATE_INTERVAL:
|
||||
return
|
||||
_update_accumulator = 0.0
|
||||
|
||||
_update_streaming()
|
||||
_process_load_queue()
|
||||
_process_unload_queue()
|
||||
streaming_updated.emit()
|
||||
|
||||
|
||||
func _update_streaming() -> void:
|
||||
if player == null or not is_inside_tree():
|
||||
return
|
||||
|
||||
var current_coord: Vector3i = Chunk.coord_for_position(player.global_position)
|
||||
if current_coord == _player_chunk_coord:
|
||||
return
|
||||
|
||||
_player_chunk_coord = current_coord
|
||||
_rebuild_queues()
|
||||
|
||||
|
||||
func _rebuild_queues() -> void:
|
||||
var target_set: Dictionary = _compute_target_chunks()
|
||||
|
||||
# Determine which chunks need to be loaded
|
||||
var to_load: Array[Vector3i] = []
|
||||
for coord in target_set:
|
||||
if not _loaded_chunks.has(coord) and not _loading_in_progress.has(coord):
|
||||
to_load.append(coord)
|
||||
|
||||
# Sort by distance from player (closest first)
|
||||
to_load.sort_custom(_compare_distance)
|
||||
_load_queue = to_load
|
||||
|
||||
# Determine which chunks need to be unloaded
|
||||
var to_unload: Array[Vector3i] = []
|
||||
for coord in _loaded_chunks:
|
||||
if not target_set.has(coord):
|
||||
to_unload.append(coord)
|
||||
|
||||
to_unload.sort_custom(_compare_distance)
|
||||
_unload_queue = to_unload
|
||||
|
||||
|
||||
func _compute_target_chunks() -> Dictionary:
|
||||
"""Compute the set of chunk coordinates that should be loaded."""
|
||||
var target: Dictionary = {}
|
||||
var half: int = streaming_radius
|
||||
for dx in range(-half, half + 1):
|
||||
for dy in range(-half, half + 1):
|
||||
for dz in range(-half, half + 1):
|
||||
var coord: Vector3i = Vector3i(
|
||||
_player_chunk_coord.x + dx,
|
||||
_player_chunk_coord.y + dy,
|
||||
_player_chunk_coord.z + dz
|
||||
)
|
||||
target[coord] = coord
|
||||
return target
|
||||
|
||||
|
||||
func _compare_distance(a: Vector3i, b: Vector3i) -> bool:
|
||||
"""Compare chunk coordinates by distance from player chunk."""
|
||||
var dist_a: int = _chunk_distance_squared(_player_chunk_coord, a)
|
||||
var dist_b: int = _chunk_distance_squared(_player_chunk_coord, b)
|
||||
return dist_a < dist_b
|
||||
|
||||
|
||||
func _chunk_distance_squared(a: Vector3i, b: Vector3i) -> int:
|
||||
var dx: int = a.x - b.x
|
||||
var dy: int = a.y - b.y
|
||||
var dz: int = a.z - b.z
|
||||
return dx * dx + dy * dy + dz * dz
|
||||
|
||||
|
||||
func _process_load_queue() -> void:
|
||||
var count: int = 0
|
||||
while count < max_loads_per_frame and not _load_queue.is_empty():
|
||||
var coord: Vector3i = _load_queue.pop_front()
|
||||
_load_chunk(coord)
|
||||
count += 1
|
||||
|
||||
|
||||
func _process_unload_queue() -> void:
|
||||
var count: int = 0
|
||||
while count < max_unloads_per_frame and not _unload_queue.is_empty():
|
||||
var coord: Vector3i = _unload_queue.pop_front()
|
||||
_unload_chunk(coord)
|
||||
count += 1
|
||||
|
||||
|
||||
func _load_chunk(coord: Vector3i) -> void:
|
||||
if _loading_in_progress.has(coord):
|
||||
return
|
||||
|
||||
_loading_in_progress[coord] = true
|
||||
|
||||
# Request chunk data from server via RPC
|
||||
if _server_rpc != null and _server_rpc.has_method("request_chunk"):
|
||||
_server_rpc.request_chunk(Chunk.build_key(
|
||||
"", "", coord
|
||||
))
|
||||
else:
|
||||
# Fallback: try to load from local cache or create empty chunk
|
||||
_instantiate_chunk_scene(coord)
|
||||
|
||||
|
||||
func _on_chunk_data_received(key: String, binary_data: PackedByteArray) -> void:
|
||||
"""Called by the networking layer when chunk data arrives from server."""
|
||||
var coord: Vector3i = _parse_coord_from_key(key)
|
||||
if coord == null:
|
||||
return
|
||||
|
||||
_loading_in_progress.erase(coord)
|
||||
|
||||
# Decode the binary chunk data
|
||||
var chunk_data: ChunkDataDecoder.ChunkPayload = ChunkDataDecoder.decode_binary(binary_data)
|
||||
if chunk_data == null:
|
||||
chunk_load_failed.emit(coord, "Failed to decode chunk data")
|
||||
return
|
||||
|
||||
# Instantiate the chunk scene with the decoded data
|
||||
_instantiate_chunk_scene_with_data(coord, chunk_data)
|
||||
|
||||
|
||||
func _on_chunk_load_error(coord: Vector3i, error_msg: String) -> void:
|
||||
"""Called when the server reports a chunk load error."""
|
||||
_loading_in_progress.erase(coord)
|
||||
chunk_load_failed.emit(coord, error_msg)
|
||||
|
||||
|
||||
func _instantiate_chunk_scene(coord: Vector3i) -> void:
|
||||
"""Create an empty chunk scene for the given coordinate."""
|
||||
var chunk_scene: ChunkScene = ChunkScene.new()
|
||||
chunk_scene.coord = coord
|
||||
chunk_scene.key = Chunk.build_key("", "", coord)
|
||||
chunk_scene.bounds = Chunk.compute_bounds(coord)
|
||||
_add_loaded_chunk(coord, chunk_scene)
|
||||
|
||||
|
||||
func _instantiate_chunk_scene_with_data(coord: Vector3i, data: ChunkDataDecoder.ChunkPayload) -> void:
|
||||
"""Create a chunk scene populated with server data."""
|
||||
var chunk_scene: ChunkScene = ChunkScene.new()
|
||||
chunk_scene.coord = coord
|
||||
chunk_scene.key = data.key
|
||||
chunk_scene.bounds = data.bounds
|
||||
chunk_scene.format_version = data.format_version
|
||||
chunk_scene.revision = data.revision
|
||||
|
||||
# Add tiles
|
||||
for layer in data.tile_layers:
|
||||
var tile_layer: TileLayer = TileLayer.new()
|
||||
tile_layer.name = layer.name
|
||||
tile_layer.layer_id = layer.layer_id
|
||||
tile_layer.width = layer.width
|
||||
tile_layer.height = layer.height
|
||||
tile_layer.depth = layer.depth
|
||||
tile_layer.palette = layer.palette
|
||||
tile_layer.tiles = layer.tiles
|
||||
chunk_scene.add_tile_layer(tile_layer)
|
||||
|
||||
# Add objects
|
||||
for obj_data in data.objects:
|
||||
var world_obj: WorldObject = WorldObject.new()
|
||||
world_obj.id = obj_data.id
|
||||
world_obj.type = obj_data.type
|
||||
world_obj.asset_id = obj_data.asset_id
|
||||
world_obj.position = obj_data.position
|
||||
world_obj.rotation = obj_data.rotation
|
||||
world_obj.scale = obj_data.scale
|
||||
world_obj.properties = obj_data.properties
|
||||
world_obj.is_dynamic = obj_data.dynamic
|
||||
world_obj.flags = obj_data.flags
|
||||
chunk_scene.add_object(world_obj)
|
||||
|
||||
# Add content flags
|
||||
chunk_scene.content_flags = data.flags
|
||||
|
||||
_add_loaded_chunk(coord, chunk_scene)
|
||||
|
||||
|
||||
func _add_loaded_chunk(coord: Vector3i, chunk_scene: Node) -> void:
|
||||
_loaded_chunks[coord] = chunk_scene
|
||||
add_child(chunk_scene)
|
||||
_loading_in_progress.erase(coord)
|
||||
chunk_loaded.emit(coord, chunk_scene)
|
||||
|
||||
|
||||
func _unload_chunk(coord: Vector3i) -> void:
|
||||
if not _loaded_chunks.has(coord):
|
||||
return
|
||||
|
||||
var chunk_scene: Node = _loaded_chunks[coord]
|
||||
if is_instance_valid(chunk_scene):
|
||||
chunk_scene.queue_free()
|
||||
|
||||
_loaded_chunks.erase(coord)
|
||||
chunk_unloaded.emit(coord)
|
||||
|
||||
|
||||
func _on_player_position_changed() -> void:
|
||||
"""Call this when the player position changes significantly."""
|
||||
if player != null:
|
||||
_update_streaming()
|
||||
|
||||
|
||||
func set_server_rpc(rpc_interface: Object) -> void:
|
||||
"""Set the server RPC interface for chunk requests."""
|
||||
_server_rpc = rpc_interface
|
||||
|
||||
|
||||
func get_loaded_chunk(coord: Vector3i) -> Node:
|
||||
"""Get a loaded chunk scene by coordinate, or null if not loaded."""
|
||||
return _loaded_chunks.get(coord, null)
|
||||
|
||||
|
||||
func is_chunk_loaded(coord: Vector3i) -> bool:
|
||||
"""Check if a chunk is currently loaded."""
|
||||
return _loaded_chunks.has(coord)
|
||||
|
||||
|
||||
func get_loaded_chunk_count() -> int:
|
||||
"""Get the number of currently loaded chunks."""
|
||||
return _loaded_chunks.size()
|
||||
|
||||
|
||||
func get_all_loaded_coords() -> Array[Vector3i]:
|
||||
"""Get all currently loaded chunk coordinates."""
|
||||
var coords: Array[Vector3i] = []
|
||||
for key in _loaded_chunks:
|
||||
coords.append(key)
|
||||
return coords
|
||||
|
||||
|
||||
func force_reload_chunk(coord: Vector3i) -> void:
|
||||
"""Force reload a specific chunk (e.g., after server update)."""
|
||||
if _loaded_chunks.has(coord):
|
||||
_unload_chunk(coord)
|
||||
if not _loading_in_progress.has(coord):
|
||||
_load_chunk(coord)
|
||||
|
||||
|
||||
func clear_all_chunks() -> void:
|
||||
"""Unload all chunks immediately."""
|
||||
var coords: Array[Vector3i] = get_all_loaded_coords()
|
||||
for coord in coords:
|
||||
_unload_chunk(coord)
|
||||
_load_queue.clear()
|
||||
_unload_queue.clear()
|
||||
|
||||
|
||||
func _parse_coord_from_key(key: String) -> Variant:
|
||||
"""Parse chunk coordinate from a canonical chunk key."""
|
||||
var parts: Array[String] = key.split(":")
|
||||
if parts.size() < 5:
|
||||
return null
|
||||
return Vector3i(
|
||||
int(parts[2]),
|
||||
int(parts[3]),
|
||||
int(parts[4])
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://8yokqwe23c23
|
||||
@@ -0,0 +1,68 @@
|
||||
class_name DynamicObjectNode
|
||||
extends Node3D
|
||||
## A visual representation of a dynamic (spawnable) world object template.
|
||||
##
|
||||
## This node is instantiated by [ChunkScene] for each dynamic [WorldObject].
|
||||
## Dynamic objects are stored as *templates* in the chunk; their runtime state
|
||||
## (position, health, inventory) is managed by the entity system, not by this
|
||||
## node. This node renders the template's visual representation so the object
|
||||
## is visible in the world.
|
||||
##
|
||||
## ## Structure
|
||||
##
|
||||
## ```
|
||||
## DynamicObjectNode (Node3D)
|
||||
## └── MeshInstance3D # the object's visual mesh
|
||||
## ```
|
||||
|
||||
## The world object data this node represents.
|
||||
var world_object: WorldObject = null
|
||||
|
||||
## The loaded mesh instance (or placeholder).
|
||||
var _mesh_instance: MeshInstance3D = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if world_object == null:
|
||||
return
|
||||
_load_mesh()
|
||||
|
||||
|
||||
## Load and attach the object's mesh.
|
||||
func _load_mesh() -> void:
|
||||
_mesh_instance = MeshInstance3D.new()
|
||||
_mesh_instance.name = "mesh"
|
||||
|
||||
var mesh := _load_asset_mesh(world_object.asset_id)
|
||||
if mesh == null:
|
||||
mesh = _make_placeholder_mesh()
|
||||
|
||||
_mesh_instance.mesh = mesh
|
||||
add_child(_mesh_instance)
|
||||
|
||||
|
||||
## Attempt to load a mesh from the asset path.
|
||||
func _load_asset_mesh(asset_id: String) -> Mesh:
|
||||
if asset_id.is_empty():
|
||||
return null
|
||||
var path := "res://assets/%s" % asset_id
|
||||
if ResourceLoader.exists(path):
|
||||
var res := load(path)
|
||||
if res is Mesh:
|
||||
return res
|
||||
return null
|
||||
|
||||
|
||||
## Create a simple placeholder sphere mesh when the real asset is unavailable.
|
||||
func _make_placeholder_mesh() -> Mesh:
|
||||
var mesh := SphereMesh.new()
|
||||
mesh.radius = 0.5
|
||||
mesh.height = 1.0
|
||||
return mesh
|
||||
|
||||
|
||||
## Get the world object's display name.
|
||||
func get_display_name() -> String:
|
||||
if world_object == null:
|
||||
return ""
|
||||
return world_object.get_display_name()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dyjqcstdnr4u1
|
||||
+247
-43
@@ -1,58 +1,262 @@
|
||||
## MainMenu - Main menu scene with navigation to avatar customization.
|
||||
##
|
||||
## Provides buttons for:
|
||||
## - Customize Avatar (opens avatar customization UI)
|
||||
## - Play
|
||||
## - Quit
|
||||
|
||||
extends Control
|
||||
## Main menu with login/register UI wired to the backend auth API.
|
||||
##
|
||||
## The AuthPanel is hidden by default. When the user clicks the LoginButton or
|
||||
## RegisterButton (AuthButtonsRow, above the panel), the auth panel slides in.
|
||||
## The Login and Register buttons on the AuthButtonsRow directly set the panel's
|
||||
## mode, so the user can switch between Login and Register at any time while
|
||||
## the panel is open.
|
||||
##
|
||||
## On successful auth the token is stored via ApiClient, AuthButtonsRow hides,
|
||||
## the welcome label shows the username, and the Play button becomes enabled.
|
||||
## The auth form fields are hidden, leaving only the welcome message visible.
|
||||
##
|
||||
## When returning from another scene (e.g. world_list), _ready() checks whether
|
||||
## ApiClient already holds a valid token. If so, it restores the authenticated
|
||||
## UI state (Play enabled, welcome message visible) instead of forcing the user
|
||||
## back to the login form.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UI References
|
||||
# ---------------------------------------------------------------------------
|
||||
## Emitted once _ready()'s session check (has_token() + the awaited
|
||||
## get_me() call) has actually resolved, whichever way -- lets a test (or
|
||||
## anything else) know the UI state is trustworthy now, rather than
|
||||
## guessing from a frame count or from UI properties that are ambiguous
|
||||
## mid-await (e.g. AuthButtonsRow.visible is true both before the check
|
||||
## resolves AND in a genuine logged-out end state).
|
||||
signal auth_state_ready
|
||||
|
||||
@onready var customize_button: Button = $CenterContainer/VBoxContainer/CustomizeButton
|
||||
@onready var play_button: Button = $CenterContainer/VBoxContainer/PlayButton
|
||||
@onready var quit_button: Button = $CenterContainer/VBoxContainer/QuitButton
|
||||
# ─── UI references ────────────────────────────────────────────────────────────
|
||||
|
||||
var _auth_buttons_row: HBoxContainer
|
||||
var _login_button: Button
|
||||
var _register_button: Button
|
||||
var _auth_panel: PanelContainer
|
||||
var _auth_mode_label: Label
|
||||
var _username_edit: LineEdit
|
||||
var _password_edit: LineEdit
|
||||
var _email_edit: LineEdit
|
||||
var _over_18_check: CheckBox
|
||||
var _auth_button: Button
|
||||
var _status_label: Label
|
||||
var _welcome_label: Label
|
||||
var _play_button: Button
|
||||
var _quit_button: Button
|
||||
|
||||
var _is_register_mode: bool = false
|
||||
var _is_authenticating: bool = false
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Emitted when the player requests to customize their avatar.
|
||||
signal open_customization
|
||||
|
||||
## Emitted when the player requests to start the game.
|
||||
signal start_game
|
||||
|
||||
## Emitted when the player requests to quit.
|
||||
signal quit_requested
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
# ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
func _ready() -> void:
|
||||
customize_button.pressed.connect(_on_customize_pressed)
|
||||
play_button.pressed.connect(_on_play_pressed)
|
||||
quit_button.pressed.connect(_on_quit_pressed)
|
||||
# Cache node references.
|
||||
_auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow
|
||||
_login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton
|
||||
_register_button = $CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton
|
||||
_auth_panel = $CenterContainer/VBoxContainer/AuthPanel
|
||||
_auth_mode_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel
|
||||
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
|
||||
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
|
||||
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
|
||||
_over_18_check = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check
|
||||
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
|
||||
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
||||
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
|
||||
_play_button = $CenterContainer/VBoxContainer/PlayButton
|
||||
_quit_button = $CenterContainer/VBoxContainer/QuitButton
|
||||
|
||||
# Default to Play being gated. When a token exists, the async session
|
||||
# check below may take several frames (a real get_me() round-trip);
|
||||
# keeping the button disabled until validation succeeds prevents a fast
|
||||
# click from navigating to world_list with an expired/invalid token.
|
||||
_play_button.disabled = true
|
||||
|
||||
# Check whether a valid session already exists (e.g. user logged in,
|
||||
# navigated to world_list, then clicked Back). If so, restore the
|
||||
# authenticated UI state instead of forcing the login form again.
|
||||
if ApiClient.has_token():
|
||||
var me_result: Dictionary = await ApiClient.get_me()
|
||||
if me_result.ok and me_result.data is Dictionary:
|
||||
var username: String = str((me_result.data as Dictionary).get("username", ""))
|
||||
if username != "":
|
||||
_show_authenticated(username)
|
||||
auth_state_ready.emit()
|
||||
return
|
||||
|
||||
# Token missing or invalid — fall through to logged-out state.
|
||||
_reset_to_logged_out()
|
||||
auth_state_ready.emit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Button Handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _on_customize_pressed() -> void:
|
||||
open_customization.emit()
|
||||
## Restore the UI to the authenticated state (Play enabled, welcome message
|
||||
## visible, auth buttons hidden). AuthPanel itself stays visible -- the
|
||||
## welcome label lives inside it, so hiding the panel hides the label too
|
||||
## even when the label's own `visible` is true (real bug, 2026-08-20: a
|
||||
## review caught the panel being hidden here while the label claimed to be
|
||||
## visible, which is a no-op UI-wise since Control.visible doesn't override
|
||||
## an ancestor's hidden state).
|
||||
func _show_authenticated(username: String) -> void:
|
||||
_auth_panel.visible = true
|
||||
_status_label.visible = false
|
||||
_welcome_label.text = "Welcome, %s!" % username
|
||||
_welcome_label.visible = true
|
||||
# Hide the same full set of form fields _on_auth_complete() hides right
|
||||
# after a fresh login/register -- this path (session restore on scene
|
||||
# reload, e.g. after Play -> Back) used to only hide EmailEdit/
|
||||
# Over18Check, leaving UsernameEdit/PasswordEdit/AuthButton/
|
||||
# AuthModeLabel visible on top of the welcome message. Real bug: Play
|
||||
# was enabled and the welcome label was correct, so it wasn't caught by
|
||||
# checks for those alone -- the login fields being back on screen is
|
||||
# what a user actually sees as "it's making me log in again."
|
||||
_username_edit.visible = false
|
||||
_password_edit.visible = false
|
||||
_email_edit.visible = false
|
||||
_over_18_check.visible = false
|
||||
_auth_button.visible = false
|
||||
_auth_mode_label.visible = false
|
||||
_play_button.disabled = false
|
||||
_auth_buttons_row.visible = false
|
||||
print("Restored authenticated session for: ", username)
|
||||
|
||||
|
||||
func _on_play_pressed() -> void:
|
||||
start_game.emit()
|
||||
## Reset the UI to the logged-out state (Play disabled, auth panel hidden,
|
||||
## login/register buttons visible).
|
||||
func _reset_to_logged_out() -> void:
|
||||
_auth_panel.visible = false
|
||||
_status_label.visible = false
|
||||
_welcome_label.visible = false
|
||||
_email_edit.visible = false
|
||||
_over_18_check.visible = false
|
||||
_over_18_check.button_pressed = false
|
||||
_play_button.disabled = true # Gate play behind successful auth.
|
||||
_update_auth_mode_ui()
|
||||
|
||||
|
||||
func _on_quit_pressed() -> void:
|
||||
quit_requested.emit()
|
||||
# ─── Play / Quit ──────────────────────────────────────────────────────────────
|
||||
|
||||
func _on_play_button_pressed() -> void:
|
||||
if _play_button.disabled:
|
||||
return
|
||||
# Browse worlds is the first real screen past login -- actual gameplay
|
||||
# (joining a world) doesn't exist yet, browsing does.
|
||||
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
|
||||
|
||||
|
||||
func _on_quit_button_pressed() -> void:
|
||||
get_tree().quit()
|
||||
|
||||
|
||||
# ─── Auth panel toggle ────────────────────────────────────────────────────────
|
||||
|
||||
func _on_login_button_pressed() -> void:
|
||||
"""Show the auth panel in login mode, called from AuthButtonsRow/LoginButton."""
|
||||
_auth_panel.visible = true
|
||||
_is_register_mode = false
|
||||
_update_auth_mode_ui()
|
||||
_clear_status()
|
||||
_username_edit.grab_focus()
|
||||
|
||||
|
||||
func _on_register_button_pressed() -> void:
|
||||
"""Show the auth panel in register mode, called from AuthButtonsRow/RegisterButton."""
|
||||
_auth_panel.visible = true
|
||||
_is_register_mode = true
|
||||
_update_auth_mode_ui()
|
||||
_clear_status()
|
||||
_username_edit.grab_focus()
|
||||
|
||||
|
||||
func _update_auth_mode_ui() -> void:
|
||||
"""Update labels and field visibility based on login vs register mode."""
|
||||
if _is_register_mode:
|
||||
_auth_mode_label.text = "Register"
|
||||
_auth_button.text = "Register"
|
||||
_email_edit.visible = true
|
||||
_over_18_check.visible = true
|
||||
else:
|
||||
_auth_mode_label.text = "Login"
|
||||
_auth_button.text = "Login"
|
||||
_email_edit.visible = false
|
||||
_over_18_check.visible = false
|
||||
|
||||
|
||||
# ─── Auth submission ──────────────────────────────────────────────────────────
|
||||
|
||||
func _on_auth_button_pressed() -> void:
|
||||
"""Handle login or register button press."""
|
||||
if _is_authenticating:
|
||||
return
|
||||
|
||||
# Validate inputs.
|
||||
var username := _username_edit.text.strip_edges()
|
||||
var password := _password_edit.text
|
||||
|
||||
if username == "":
|
||||
_show_status("Username is required.")
|
||||
return
|
||||
if password == "":
|
||||
_show_status("Password is required.")
|
||||
return
|
||||
|
||||
if _is_register_mode:
|
||||
var email := _email_edit.text.strip_edges()
|
||||
|
||||
if email == "":
|
||||
_show_status("Email is required.")
|
||||
return
|
||||
if not _over_18_check.button_pressed:
|
||||
_show_status("You must confirm you are 18 or older to register.")
|
||||
return
|
||||
|
||||
_auth_button.disabled = true
|
||||
_auth_button.text = "Registering..."
|
||||
_is_authenticating = true
|
||||
_clear_status()
|
||||
|
||||
var result := await ApiClient.register(username, email, password, true)
|
||||
_on_auth_complete(result)
|
||||
else:
|
||||
_auth_button.disabled = true
|
||||
_auth_button.text = "Logging in..."
|
||||
_is_authenticating = true
|
||||
_clear_status()
|
||||
|
||||
var result := await ApiClient.login(username, password)
|
||||
_on_auth_complete(result)
|
||||
|
||||
|
||||
func _on_auth_complete(result: Dictionary) -> void:
|
||||
"""Handle the result of a login or register call."""
|
||||
_auth_button.disabled = false
|
||||
_auth_button.text = "Register" if _is_register_mode else "Login"
|
||||
_is_authenticating = false
|
||||
|
||||
if result.ok:
|
||||
# Success — hide the auth form fields, show only the welcome label.
|
||||
_username_edit.visible = false
|
||||
_password_edit.visible = false
|
||||
_email_edit.visible = false
|
||||
_over_18_check.visible = false
|
||||
_auth_button.visible = false
|
||||
_auth_mode_label.visible = false
|
||||
_status_label.visible = false
|
||||
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
|
||||
_welcome_label.visible = true
|
||||
_play_button.disabled = false
|
||||
_auth_buttons_row.visible = false
|
||||
print("Auth successful. Token stored.")
|
||||
else:
|
||||
# Failure — show backend error detail.
|
||||
_show_status(result.detail)
|
||||
|
||||
|
||||
# ─── Status helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
func _show_status(message: String) -> void:
|
||||
_status_label.text = message
|
||||
_status_label.visible = true
|
||||
|
||||
|
||||
func _clear_status() -> void:
|
||||
_status_label.text = ""
|
||||
_status_label.visible = false
|
||||
|
||||
@@ -1 +1 @@
|
||||
uid://cy8xl74x4qddx
|
||||
uid://bgnc3iamfgf6k
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
class_name NetworkClient
|
||||
extends Node
|
||||
## Client-side networked player: input prediction + server reconciliation.
|
||||
##
|
||||
## Implements docs/architecture/networking.md §5 (Prediction & Reconciliation)
|
||||
## for the *local* player only -- remote-entity interpolation (§5.3) and full
|
||||
## delta-compressed multi-entity snapshots (§6) are explicitly out of scope
|
||||
## for this first pass; the server already only ever has one player in PC2's
|
||||
## single-peer testing, and this task's own description scopes it to "the
|
||||
## player controller built in the dependency task" (singular).
|
||||
##
|
||||
## RPC path-matching, the part that makes this actually work:
|
||||
## Godot's high-level rpc_id() resolves the *receiving* node by matching the
|
||||
## *sending* node's NodePath on the caller's side. The server (scripts/
|
||||
## server.gd) is the SceneTree root node named "Server" when the dedicated
|
||||
## server runs scenes/server.tscn as its main scene, and it spawns each
|
||||
## connected player as a *child* of itself named "Player_<peer_id>"
|
||||
## (scripts/server.gd's _on_peer_connected). For an RPC sent by the client to
|
||||
## actually land on the right node, the client needs a LOCAL node tree at the
|
||||
## exact same paths -- /root/Server for _receive_snapshot (called by the
|
||||
## server on itself), and /root/Server/Player_<own_peer_id> for
|
||||
## rpc_request_input (a real method on the server's PlayerNode). Those local
|
||||
## nodes don't need to run any server logic -- they only need to exist at the
|
||||
## right path so Godot's RPC routing finds them. connect_to_server() builds
|
||||
## that structure once the peer id is known (only available after the ENet
|
||||
## handshake completes).
|
||||
##
|
||||
## Usage: instantiate directly (not an autoload -- this only exists for the
|
||||
## lifetime of an actual multiplayer session, not on every client launch):
|
||||
## var net := NetworkClient.new()
|
||||
## get_tree().root.add_child(net)
|
||||
## var ok := await net.connect_to_server("127.0.0.1", 7777)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Movement constants -- MUST match scripts/player_spawner.gd exactly, per
|
||||
# docs/architecture/networking.md §5.1 ("the same physics constants as the
|
||||
# server"). A mismatch here doesn't break anything outright, it just makes
|
||||
# reconciliation corrections more frequent/visible.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const WALK_SPEED: float = 5.0
|
||||
const JUMP_VELOCITY: float = 4.5
|
||||
const GRAVITY: float = 9.8
|
||||
|
||||
## How far predicted and authoritative position can drift before snapping
|
||||
## rather than trusting the prediction was "close enough." Per §5.2.
|
||||
const RECONCILE_ERROR_THRESHOLD: float = 0.5
|
||||
|
||||
## How many past predicted states to keep for reconciliation replay. Real
|
||||
## sizing per §5.4 is RTT-adaptive; a fixed generous buffer is the
|
||||
## appropriately-scoped first pass here -- 120 ticks at a 30Hz send rate is
|
||||
## 4 seconds of history, comfortably more than any realistic LAN/local RTT.
|
||||
const PREDICTION_BUFFER_SIZE: int = 120
|
||||
|
||||
signal reconciled(correction_distance: float)
|
||||
signal connected_to_server
|
||||
signal connection_failed
|
||||
|
||||
|
||||
var peer: ENetMultiplayerPeer
|
||||
var _server_root: Node # local /root/Server proxy
|
||||
var _player_proxy: Node # local /root/Server/Player_<peer_id> proxy
|
||||
|
||||
var _seq: int = 0
|
||||
## Ring buffer of {seq, input, position, velocity} predicted at send time,
|
||||
## oldest first. Trimmed as the server acknowledges ticks via snapshots.
|
||||
var _prediction_buffer: Array[Dictionary] = []
|
||||
|
||||
var position: Vector3 = Vector3.ZERO
|
||||
var velocity: Vector3 = Vector3.ZERO
|
||||
var _on_floor: bool = false
|
||||
|
||||
|
||||
func connect_to_server(host: String, port: int) -> bool:
|
||||
peer = ENetMultiplayerPeer.new()
|
||||
var err := peer.create_client(host, port)
|
||||
if err != OK:
|
||||
push_error("NetworkClient: create_client failed: %d" % err)
|
||||
connection_failed.emit()
|
||||
return false
|
||||
|
||||
multiplayer.multiplayer_peer = peer
|
||||
multiplayer.connected_to_server.connect(_on_connected)
|
||||
multiplayer.connection_failed.connect(func():
|
||||
connection_failed.emit()
|
||||
)
|
||||
|
||||
for i in 300: # ~5s at 60fps
|
||||
await get_tree().process_frame
|
||||
var status := peer.get_connection_status()
|
||||
if status == MultiplayerPeer.CONNECTION_CONNECTED:
|
||||
return true
|
||||
elif status == MultiplayerPeer.CONNECTION_DISCONNECTED:
|
||||
connection_failed.emit()
|
||||
return false
|
||||
connection_failed.emit()
|
||||
return false
|
||||
|
||||
|
||||
func _on_connected() -> void:
|
||||
var my_id := multiplayer.get_unique_id()
|
||||
|
||||
# Build the local proxy tree at the exact paths the server's own RPC
|
||||
# calls target -- see the class docstring for why this has to exist.
|
||||
_server_root = Node.new()
|
||||
_server_root.name = "Server"
|
||||
_server_root.set_script(preload("res://scripts/network_receiver_proxy.gd"))
|
||||
get_tree().root.add_child(_server_root)
|
||||
_server_root.set("owner_client", self)
|
||||
|
||||
_player_proxy = Node.new()
|
||||
_player_proxy.name = "Player_%d" % my_id
|
||||
_player_proxy.set_script(preload("res://scripts/network_player_proxy.gd"))
|
||||
_server_root.add_child(_player_proxy)
|
||||
|
||||
connected_to_server.emit()
|
||||
|
||||
|
||||
func disconnect_from_server() -> void:
|
||||
if peer:
|
||||
peer.close()
|
||||
if _server_root and is_instance_valid(_server_root):
|
||||
_server_root.queue_free()
|
||||
multiplayer.multiplayer_peer = null
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prediction -- called every physics frame by whatever drives input (a real
|
||||
# controller, or a test harness). Applies input locally immediately (so it
|
||||
# renders with zero perceived latency) and sends it to the server, per §5.1.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func predict_and_send(delta: float, move_x: float, move_z: float, jump: bool) -> void:
|
||||
_apply_movement(delta, move_x, move_z, jump)
|
||||
|
||||
_seq += 1
|
||||
var record := {
|
||||
"seq": _seq,
|
||||
"input": {"move_x": move_x, "move_z": move_z, "jump": jump},
|
||||
"position": position,
|
||||
"velocity": velocity,
|
||||
}
|
||||
_prediction_buffer.append(record)
|
||||
if _prediction_buffer.size() > PREDICTION_BUFFER_SIZE:
|
||||
_prediction_buffer.pop_front()
|
||||
|
||||
if _player_proxy and is_instance_valid(_player_proxy):
|
||||
_player_proxy.rpc_id(1, "rpc_request_input", record["input"], _seq)
|
||||
|
||||
|
||||
func _apply_movement(delta: float, move_x: float, move_z: float, jump: bool) -> void:
|
||||
# Mirrors scripts/player_spawner.gd's _process_input + gravity/jump,
|
||||
# intentionally without its ground-friction smoothing -- friction is a
|
||||
# server-authoritative-feel detail, not something worth risking a
|
||||
# prediction/server mismatch over in this first pass. Position still
|
||||
# reconciles against the server's real (frictioned) result every
|
||||
# snapshot, so this doesn't silently drift.
|
||||
if not _on_floor:
|
||||
velocity.y -= GRAVITY * delta
|
||||
|
||||
var direction := Vector3(move_x, 0.0, move_z)
|
||||
if direction.length() > 0.01:
|
||||
direction = direction.normalized()
|
||||
velocity.x = direction.x * WALK_SPEED
|
||||
velocity.z = direction.z * WALK_SPEED
|
||||
else:
|
||||
velocity.x = 0.0
|
||||
velocity.z = 0.0
|
||||
|
||||
if jump and _on_floor:
|
||||
velocity.y = JUMP_VELOCITY
|
||||
|
||||
position += velocity * delta
|
||||
# Headless prediction has no collision shape of its own to test
|
||||
# is_on_floor() against -- treat "at or below the spawn-plane ground"
|
||||
# as grounded. A real render-side controller would swap this for the
|
||||
# actual CharacterBody3D.is_on_floor() of whatever visual node this
|
||||
# drives; this class only owns the *networking* half.
|
||||
if position.y <= 0.0:
|
||||
position.y = 0.0
|
||||
velocity.y = 0.0
|
||||
_on_floor = true
|
||||
else:
|
||||
_on_floor = false
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reconciliation -- called by _ReceiverScript when a snapshot arrives.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _handle_snapshot(snapshot_data: Dictionary) -> void:
|
||||
var my_id := multiplayer.get_unique_id()
|
||||
var players: Dictionary = snapshot_data.get("players", {})
|
||||
# Server keys player_states by peer_id -- Dictionary keys from JSON-ish
|
||||
# RPC payloads can arrive as String or int depending on the transport
|
||||
# encoding, so check both rather than assuming.
|
||||
var my_state: Variant = players.get(my_id, players.get(str(my_id), null))
|
||||
if my_state == null or not (my_state is Dictionary):
|
||||
return
|
||||
|
||||
var authoritative_pos: Vector3 = (my_state as Dictionary).get("position", position)
|
||||
|
||||
# Real limitation, noted explicitly rather than silently faked: the
|
||||
# server's snapshot_state (scripts/server.gd's _broadcast_snapshot) does
|
||||
# not currently echo back "last input seq processed for this peer" --
|
||||
# its `tick` field is the server's own internal simulation tick counter,
|
||||
# a completely different clock from this client's per-input `_seq`. True
|
||||
# tick-correlated rollback (docs/architecture/networking.md §5.2 step 4,
|
||||
# "re-simulate any inputs that occurred after the server's tick") needs
|
||||
# that ack echoed back per-peer -- a real, scoped follow-up, not
|
||||
# something to fake here. Until then, reconcile against the MOST RECENT
|
||||
# prediction (not a tick-matched one): still a real error measurement
|
||||
# and a real correction, just without precise replay-from-exact-tick.
|
||||
if _prediction_buffer.is_empty():
|
||||
return
|
||||
var latest_entry: Dictionary = _prediction_buffer[-1]
|
||||
|
||||
var error: float = (latest_entry["position"] as Vector3).distance_to(authoritative_pos)
|
||||
if error <= RECONCILE_ERROR_THRESHOLD:
|
||||
return # Close enough -- prediction was correct, nothing to do.
|
||||
|
||||
# Correct: snap to the authoritative position. Without a real ack-seq
|
||||
# from the server there's no reliable "replay inputs after this point"
|
||||
# window to recompute, so the buffer is cleared and prediction resumes
|
||||
# fresh from the corrected position on the next input -- still visibly
|
||||
# responsive (no waiting for a round trip before the *next* input
|
||||
# applies), just without this pass replaying in-flight inputs.
|
||||
position = authoritative_pos
|
||||
velocity = Vector3.ZERO
|
||||
_prediction_buffer.clear()
|
||||
|
||||
reconciled.emit(error)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
extends Node
|
||||
## Client-side proxy for the server's real PlayerNode (scripts/
|
||||
## player_spawner.gd), attached at runtime to a Node named "Player_<peer_id>"
|
||||
## under the /root/Server proxy by NetworkClient._on_connected().
|
||||
##
|
||||
## rpc_request_input is the only one of these actually CALLED from this
|
||||
## side -- the real implementations all live on the server. But two things
|
||||
## force every @rpc method on the real PlayerNode to be mirrored here too,
|
||||
## not just the one this class uses:
|
||||
## 1. Godot's rpc_id() looks up the RPC transfer config (mode/reliability)
|
||||
## from the LOCAL script before it will even send a call, so the method
|
||||
## being called has to be declared locally with a matching annotation.
|
||||
## 2. Godot's multiplayer layer also verifies an "RPC node checksum" --
|
||||
## both peers must declare the *same set* of @rpc methods at a matching
|
||||
## path, or every RPC at that path starts failing, not just the one
|
||||
## that's actually missing. So even rpc_request_interact/attack/
|
||||
## set_position/set_rotation, which this class never calls, have to be
|
||||
## declared here with matching annotations or rpc_request_input itself
|
||||
## breaks too.
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func rpc_request_input(_input_state: Dictionary, _seq: int) -> void:
|
||||
pass # Never invoked locally -- see the note above.
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func rpc_request_interact(_target_id: int) -> void:
|
||||
pass
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func rpc_request_attack(_target_id: int, _aim_dir: Vector3) -> void:
|
||||
pass
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func rpc_set_position(_pos: Vector3) -> void:
|
||||
pass
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func rpc_set_rotation(_rot: Vector3) -> void:
|
||||
pass
|
||||
@@ -0,0 +1,38 @@
|
||||
extends Node
|
||||
## Client-side proxy node, attached at runtime to a Node named "Server" at
|
||||
## the SceneTree root by NetworkClient.connect_to_server(). Exists purely so
|
||||
## Godot's RPC path-matching finds a receiver for _receive_snapshot -- see
|
||||
## network_client.gd's class docstring for the full explanation of why this
|
||||
## has to exist at this exact path. Not a real server in any sense.
|
||||
##
|
||||
## Godot's multiplayer layer verifies an "RPC node checksum" -- both peers
|
||||
## must declare the *same set* of @rpc methods at a matching path, or every
|
||||
## RPC at that path (not just the mismatched one) starts failing with
|
||||
## "rpc node checksum failed". So every @rpc method scripts/server.gd
|
||||
## declares on its own root "Server" node has to be mirrored here too, even
|
||||
## the ones this proxy never actually needs to receive.
|
||||
|
||||
var owner_client: Node = null
|
||||
|
||||
|
||||
## Must match scripts/server.gd's own @rpc annotation on _receive_snapshot
|
||||
## exactly -- Godot enforces the receiving method's declared mode/transfer
|
||||
## config, a mismatch here would silently drop the call.
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _receive_snapshot(snapshot_data: Dictionary) -> void:
|
||||
if owner_client and is_instance_valid(owner_client):
|
||||
owner_client._handle_snapshot(snapshot_data)
|
||||
|
||||
|
||||
## Mirrors scripts/server.gd's _kick -- never actually invoked on this side
|
||||
## (the server calls it on a Player_<id> node's own rpc_id target, not this
|
||||
## root proxy), declared only so the RPC checksum for this path matches.
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _kick(_reason: String) -> void:
|
||||
pass
|
||||
|
||||
|
||||
## Mirrors scripts/server.gd's _disconnect -- same reasoning as _kick above.
|
||||
@rpc("call_remote", "reliable")
|
||||
func _disconnect(_reason: String) -> void:
|
||||
pass
|
||||
@@ -0,0 +1,146 @@
|
||||
class_name Player
|
||||
extends CharacterBody3D
|
||||
## The player character controller.
|
||||
##
|
||||
## A simple CharacterBody3D with WASD/arrow locomotion, gravity, jump, and a
|
||||
## first-person Camera3D with mouse-look. Reads the spawn point from the
|
||||
## current scene's `PlayerSpawn` Marker3D (see docs/architecture/pc2_test_level.md §3).
|
||||
##
|
||||
## No networking, no chunk code, no avatar UI — PC2 is local-only.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Movement constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const WALK_SPEED: float = 5.0
|
||||
const JUMP_VELOCITY: float = 4.5
|
||||
const GRAVITY: float = 9.8
|
||||
|
||||
## Mouse-look sensitivity (radians per pixel of relative motion).
|
||||
const MOUSE_SENSITIVITY: float = 0.003
|
||||
|
||||
## Camera pitch is clamped so the player can't flip past looking straight
|
||||
## up/down (which would otherwise let mouse-look invert on itself).
|
||||
const PITCH_LIMIT: float = deg_to_rad(89.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _input_direction := Vector2.ZERO
|
||||
var _camera_pitch: float = 0.0
|
||||
|
||||
@onready var _camera: Camera3D = $Camera3D
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _ready — spawn at PlayerSpawn marker, capture the mouse for look
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
# Per §3 of pc2_test_level.md: look up PlayerSpawn from the current
|
||||
# scene root. The lookup walks the full subtree (recursive=true) so
|
||||
# it finds the marker whether it is a direct child of the level or
|
||||
# nested deeper.
|
||||
#
|
||||
# current_scene is null in headless --script mode and in any scene
|
||||
# before the main scene is fully swapped in, so guard it rather than
|
||||
# blindly dereferencing.
|
||||
var current_scene: Node = get_tree().current_scene
|
||||
var spawn: Node = null
|
||||
if current_scene != null:
|
||||
spawn = current_scene.find_child("PlayerSpawn", true, false)
|
||||
|
||||
if spawn is Node3D:
|
||||
global_position = (spawn as Node3D).global_position
|
||||
# Align player forward with spawn yaw (optional polish).
|
||||
var spawn_yaw := (spawn as Node3D).rotation.y
|
||||
rotation.y = spawn_yaw
|
||||
else:
|
||||
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
|
||||
global_position = Vector3(0, 1.0, 0)
|
||||
|
||||
# Capture the mouse for FPS-style look. Headless/test runs have no real
|
||||
# window to capture input for, so guard against that rather than error.
|
||||
if DisplayServer.get_name() != "headless":
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _unhandled_input — capture keyboard / mouse for movement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey:
|
||||
var key := event as InputEventKey
|
||||
if key.pressed and not key.echo:
|
||||
_update_input_direction(key)
|
||||
elif not key.pressed:
|
||||
_clear_input_direction(key)
|
||||
# Escape exits the level back to the world browse list, mirroring
|
||||
# the Back button pattern already used in world_list.gd/main_menu.gd.
|
||||
if key.pressed and key.keycode == KEY_ESCAPE:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
|
||||
elif event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||
_handle_mouse_look(event as InputEventMouseMotion)
|
||||
|
||||
|
||||
func _update_input_direction(key: InputEventKey) -> void:
|
||||
match key.keycode:
|
||||
KEY_W, KEY_UP:
|
||||
_input_direction.y += 1.0
|
||||
KEY_S, KEY_DOWN:
|
||||
_input_direction.y -= 1.0
|
||||
KEY_A, KEY_LEFT:
|
||||
_input_direction.x -= 1.0
|
||||
KEY_D, KEY_RIGHT:
|
||||
_input_direction.x += 1.0
|
||||
|
||||
|
||||
func _clear_input_direction(key: InputEventKey) -> void:
|
||||
match key.keycode:
|
||||
KEY_W, KEY_UP:
|
||||
_input_direction.y -= 1.0
|
||||
KEY_S, KEY_DOWN:
|
||||
_input_direction.y += 1.0
|
||||
KEY_A, KEY_LEFT:
|
||||
_input_direction.x += 1.0
|
||||
KEY_D, KEY_RIGHT:
|
||||
_input_direction.x -= 1.0
|
||||
|
||||
|
||||
func _handle_mouse_look(event: InputEventMouseMotion) -> void:
|
||||
# Yaw rotates the whole body (so movement direction turns with the
|
||||
# look direction); pitch only tilts the camera, so the capsule itself
|
||||
# never tips over.
|
||||
rotation.y -= event.relative.x * MOUSE_SENSITIVITY
|
||||
_camera_pitch = clampf(_camera_pitch - event.relative.y * MOUSE_SENSITIVITY, -PITCH_LIMIT, PITCH_LIMIT)
|
||||
_camera.rotation.x = _camera_pitch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Physics — gravity, jump, move_and_slide
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
# Apply gravity.
|
||||
if not is_on_floor():
|
||||
velocity.y -= GRAVITY * delta
|
||||
|
||||
# Jump.
|
||||
if is_on_floor() and Input.is_action_just_pressed("ui_accept"):
|
||||
velocity.y = JUMP_VELOCITY
|
||||
|
||||
# Build movement direction from input. Godot's local "forward" is -Z, not
|
||||
# +Z, so W (input.y = +1) must use -basis.z -- using +basis.z here would
|
||||
# move the player backward when pressing forward (confirmed by reading
|
||||
# Camera3D/Node3D's own convention: a node looks down its local -Z).
|
||||
var direction := (_input_direction.x * transform.basis.x -
|
||||
_input_direction.y * transform.basis.z).normalized()
|
||||
velocity.x = direction.x * WALK_SPEED
|
||||
velocity.z = direction.z * WALK_SPEED
|
||||
|
||||
move_and_slide()
|
||||
@@ -0,0 +1,232 @@
|
||||
class_name PlayerNode
|
||||
extends CharacterBody3D
|
||||
## PlayerNode - Server-side player entity.
|
||||
##
|
||||
## Represents a connected player on the server. Handles movement,
|
||||
## input processing, and state synchronization.
|
||||
|
||||
# --- Signals ---
|
||||
signal health_changed(new_health: int)
|
||||
signal player_died(player_node: PlayerNode)
|
||||
|
||||
|
||||
# --- Constants ---
|
||||
const WALK_SPEED := 5.0
|
||||
const SPRINT_MULTIPLIER := 1.6
|
||||
const JUMP_VELOCITY := 4.5
|
||||
const GRAVITY := 9.8
|
||||
const MAX_FALL_SPEED := 20.0
|
||||
const FRICTION := 8.0
|
||||
const AIR_CONTROL := 0.3
|
||||
|
||||
# --- Configurable ---
|
||||
@export var walk_speed: float = WALK_SPEED
|
||||
@export var sprint_multiplier: float = SPRINT_MULTIPLIER
|
||||
@export var jump_velocity: float = JUMP_VELOCITY
|
||||
@export var max_fall_speed: float = MAX_FALL_SPEED
|
||||
|
||||
# --- State ---
|
||||
@export var max_health: int = 100
|
||||
var current_health: int = 0
|
||||
var is_alive: bool = true
|
||||
var is_sprinting: bool = false
|
||||
var peer_id: int = -1
|
||||
var player_name: String = ""
|
||||
var entity_id: int = 0
|
||||
|
||||
# --- Input buffer ---
|
||||
var _input_queue: Array[Dictionary] = []
|
||||
var _last_processed_seq: int = -1
|
||||
|
||||
# --- Movement ---
|
||||
var _velocity: Vector3 = Vector3.ZERO
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
current_health = max_health
|
||||
velocity = _velocity
|
||||
# Start alive
|
||||
is_alive = true
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if not is_alive:
|
||||
return
|
||||
|
||||
# Process input from queue
|
||||
_process_input(delta)
|
||||
|
||||
# Apply gravity
|
||||
if not is_on_floor():
|
||||
velocity.y -= GRAVITY * delta
|
||||
velocity.y = clampf(velocity.y, -max_fall_speed, max_fall_speed)
|
||||
|
||||
# Apply friction on ground
|
||||
if is_on_floor():
|
||||
var move_dir := Vector2(velocity.x, velocity.z)
|
||||
if move_dir.length() > 0.01:
|
||||
var friction_factor := clampf(FRICTION * delta, 0.0, 1.0)
|
||||
velocity.x = move_dir.normalized().x * move_dir.length() * (1.0 - friction_factor)
|
||||
velocity.z = move_dir.normalized().y * move_dir.length() * (1.0 - friction_factor)
|
||||
else:
|
||||
velocity.x = 0.0
|
||||
velocity.z = 0.0
|
||||
|
||||
# Move and slide
|
||||
velocity.y = clampf(velocity.y, -max_fall_speed, max_fall_speed)
|
||||
move_and_slide()
|
||||
|
||||
# Update visual position (for remote clients via RPC)
|
||||
_update_visual_state()
|
||||
|
||||
|
||||
func _process_input(delta: float) -> void:
|
||||
"""Process buffered input from the server's input queue."""
|
||||
if _input_queue.is_empty():
|
||||
return
|
||||
|
||||
var input_data: Dictionary = _input_queue.pop_front()
|
||||
var input_state: Dictionary = input_data.get("input_state", {})
|
||||
|
||||
# Calculate movement direction
|
||||
var direction := Vector3.ZERO
|
||||
direction.x = input_state.get("move_x", 0.0)
|
||||
direction.z = input_state.get("move_z", 0.0)
|
||||
|
||||
# Sprint check
|
||||
is_sprinting = input_state.get("sprint", false)
|
||||
|
||||
if direction.length() > 0.01:
|
||||
direction = direction.normalized()
|
||||
var speed = walk_speed * sprint_multiplier if is_sprinting else walk_speed
|
||||
velocity.x = direction.x * speed
|
||||
velocity.z = direction.z * speed
|
||||
else:
|
||||
# On ground, apply friction (handled in physics_process)
|
||||
if not is_on_floor():
|
||||
velocity.x *= (1.0 - AIR_CONTROL * delta * 5.0)
|
||||
velocity.z *= (1.0 - AIR_CONTROL * delta * 5.0)
|
||||
|
||||
# Jump
|
||||
if input_state.get("jump", false) and is_on_floor():
|
||||
velocity.y = jump_velocity
|
||||
|
||||
|
||||
func take_damage(amount: int, source_id: int) -> Dictionary:
|
||||
"""Take damage and return result dict."""
|
||||
if not is_alive:
|
||||
return {"success": false, "reason": "already_dead"}
|
||||
|
||||
current_health = max(0, current_health - amount)
|
||||
health_changed.emit(current_health)
|
||||
|
||||
if current_health <= 0:
|
||||
die()
|
||||
return {"success": true, "health": 0, "dead": true}
|
||||
|
||||
return {"success": true, "health": current_health}
|
||||
|
||||
|
||||
func die() -> void:
|
||||
"""Handle player death."""
|
||||
is_alive = false
|
||||
velocity = Vector3.ZERO
|
||||
player_died.emit(self)
|
||||
|
||||
# Respawn after delay
|
||||
await get_tree().create_timer(3.0).timeout
|
||||
respawn()
|
||||
|
||||
|
||||
func respawn() -> void:
|
||||
"""Respawn the player at spawn point."""
|
||||
current_health = max_health
|
||||
is_alive = true
|
||||
velocity = Vector3.ZERO
|
||||
health_changed.emit(current_health)
|
||||
|
||||
# Move to spawn point (default: origin)
|
||||
position = Vector3.ZERO
|
||||
|
||||
|
||||
func set_input_queue(inputs: Array[Dictionary]) -> void:
|
||||
"""Set the input queue from the server."""
|
||||
_input_queue = inputs
|
||||
|
||||
|
||||
func get_state() -> Dictionary:
|
||||
"""Get current player state for replication."""
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"peer_id": peer_id,
|
||||
"position": position,
|
||||
"rotation": rotation,
|
||||
"health": current_health,
|
||||
"max_health": max_health,
|
||||
"is_alive": is_alive,
|
||||
"is_sprinting": is_sprinting,
|
||||
}
|
||||
|
||||
|
||||
func get_server_state() -> Dictionary:
|
||||
"""Get current player state for server snapshot broadcast."""
|
||||
return get_state()
|
||||
|
||||
|
||||
func process_server_input(tick: int) -> void:
|
||||
"""Called by server tick to process buffered input."""
|
||||
# This is called from the server's _tick loop.
|
||||
# The input queue is populated by RPCs from clients.
|
||||
pass
|
||||
|
||||
|
||||
func _update_visual_state() -> void:
|
||||
"""Update visual state (for remote interpolation)."""
|
||||
# In a full implementation, this would update a remote player's
|
||||
# visual representation. For now, the node itself is the authority.
|
||||
pass
|
||||
|
||||
|
||||
# --- RPCs ---
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func rpc_request_input(input_state: Dictionary, seq: int) -> void:
|
||||
"""Receive input from client."""
|
||||
if not is_alive:
|
||||
return
|
||||
# Store for processing in _physics_process
|
||||
_input_queue.append({
|
||||
"input_state": input_state,
|
||||
"seq": seq,
|
||||
"timestamp": Time.get_ticks_msec(),
|
||||
})
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func rpc_request_interact(target_id: int) -> void:
|
||||
"""Handle interaction request from client."""
|
||||
if not is_alive:
|
||||
return
|
||||
# TODO: Implement interaction logic
|
||||
print("Player %d interacted with entity %d" % [peer_id, target_id])
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func rpc_request_attack(target_id: int, aim_dir: Vector3) -> void:
|
||||
"""Handle attack request from client."""
|
||||
if not is_alive:
|
||||
return
|
||||
# TODO: Implement attack logic
|
||||
print("Player %d attacked entity %d with aim %s" % [peer_id, target_id, aim_dir])
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func rpc_set_position(pos: Vector3) -> void:
|
||||
"""Server authoritative position update."""
|
||||
position = pos
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func rpc_set_rotation(rot: Vector3) -> void:
|
||||
"""Server authoritative rotation update."""
|
||||
rotation = rot
|
||||
@@ -0,0 +1 @@
|
||||
uid://br27owjh2f5xd
|
||||
Executable
+170
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the full automated test inventory for a Package Checkpoint review.
|
||||
|
||||
See docs/testing/test_inventory.md for what each suite actually covers (and
|
||||
doesn't) -- keep that doc in sync as tests are added, this script just runs
|
||||
whatever's there.
|
||||
|
||||
Usage:
|
||||
python scripts/run_checkpoint_tests.py [--keep-postgres] [--postgres-port PORT]
|
||||
|
||||
Exits 0 if everything that ran passed, 1 otherwise. Suites with nothing to
|
||||
run (e.g. no scripts/smoke_test_*.gd yet, or no Godot binary found) are
|
||||
reported as SKIPPED, not counted as a failure.
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
BACKEND = ROOT / "backend"
|
||||
|
||||
|
||||
def run(cmd, cwd=None, env=None, capture=False):
|
||||
print(f"$ {' '.join(cmd)}" + (f" (cwd={cwd})" if cwd else ""))
|
||||
return subprocess.run(cmd, cwd=cwd, env=env, capture_output=capture, text=True)
|
||||
|
||||
|
||||
def find_godot():
|
||||
for candidate in ("godot", str(Path.home() / "godot" / "godot")):
|
||||
if shutil.which(candidate) or Path(candidate).is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def wait_for_http(url: str, timeout_s: int = 20) -> bool:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2) as resp:
|
||||
if resp.status == 200:
|
||||
return True
|
||||
except (urllib.error.URLError, ConnectionError, OSError):
|
||||
pass
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--keep-postgres", action="store_true",
|
||||
help="Don't tear down the checkpoint Postgres container afterward")
|
||||
parser.add_argument("--postgres-port", default=os.environ.get("CHECKPOINT_POSTGRES_PORT", "5432"),
|
||||
help="Host port for the checkpoint's own Postgres container (default 5432 -- "
|
||||
"override if that's already in use on this machine)")
|
||||
parser.add_argument("--backend-port", default=os.environ.get("CHECKPOINT_BACKEND_PORT", "18080"),
|
||||
help="Port to run the backend server on for the Godot smoke tests")
|
||||
args = parser.parse_args()
|
||||
|
||||
results = {}
|
||||
|
||||
# --- Backend: bring up Postgres ---
|
||||
compose_env = os.environ.copy()
|
||||
compose_env["COMPOSE_PROJECT_NAME"] = "agp-checkpoint"
|
||||
compose_env["POSTGRES_HOST_PORT"] = args.postgres_port
|
||||
compose_env["POSTGRES_PORT"] = args.postgres_port # some branches use this name instead, see tools/definitions.py
|
||||
print("=== Backend: bringing up Postgres ===")
|
||||
run(["docker", "compose", "up", "-d", "postgres"], cwd=BACKEND, env=compose_env)
|
||||
|
||||
print("Waiting for Postgres to be ready...")
|
||||
pg_ready = False
|
||||
for _ in range(20):
|
||||
r = run(["docker", "compose", "exec", "-T", "postgres", "pg_isready", "-U", "agp_user", "-d", "agp_db"],
|
||||
cwd=BACKEND, env=compose_env, capture=True)
|
||||
if r.returncode == 0:
|
||||
pg_ready = True
|
||||
break
|
||||
time.sleep(1)
|
||||
if not pg_ready:
|
||||
print("Postgres never became ready -- aborting.")
|
||||
sys.exit(1)
|
||||
|
||||
db_url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{args.postgres_port}/agp_db"
|
||||
backend_env = os.environ.copy()
|
||||
backend_env["DATABASE_URL"] = db_url
|
||||
|
||||
print("\n=== Backend: alembic upgrade head ===")
|
||||
r = run([sys.executable, "-m", "alembic", "upgrade", "head"], cwd=BACKEND, env=backend_env)
|
||||
results["migrations (fresh DB)"] = r.returncode == 0
|
||||
|
||||
print("\n=== Backend: pytest ===")
|
||||
r = run([sys.executable, "-m", "pytest", "-v"], cwd=BACKEND, env=backend_env)
|
||||
results["backend pytest"] = r.returncode == 0
|
||||
|
||||
# --- Godot: headless smoke tests (need a live backend server) ---
|
||||
godot_bin = find_godot()
|
||||
smoke_tests = sorted(glob.glob(str(ROOT / "scripts" / "smoke_test_*.gd")))
|
||||
|
||||
if not godot_bin:
|
||||
print("\n=== Godot: binary not found (checked PATH and ~/godot/godot), skipping smoke tests ===")
|
||||
results["godot smoke tests"] = None
|
||||
elif not smoke_tests:
|
||||
print("\n=== Godot: no scripts/smoke_test_*.gd found yet, nothing to run ===")
|
||||
results["godot smoke tests"] = None
|
||||
else:
|
||||
print(f"\n=== Backend: starting server on :{args.backend_port} for Godot smoke tests ===")
|
||||
server_proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", args.backend_port],
|
||||
cwd=BACKEND, env=backend_env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
base_url = f"http://127.0.0.1:{args.backend_port}"
|
||||
if not wait_for_http(f"{base_url}/api/health"):
|
||||
print("Backend server never became healthy -- skipping Godot smoke tests.")
|
||||
results["godot smoke tests"] = False
|
||||
else:
|
||||
godot_env = os.environ.copy()
|
||||
godot_env["AGP_API_BASE_URL"] = base_url
|
||||
|
||||
print("=== Godot: import pass (builds the class cache) ===")
|
||||
run([godot_bin, "--headless", "--path", str(ROOT), "--import"], env=godot_env)
|
||||
run([godot_bin, "--headless", "--path", str(ROOT), "--quit"], env=godot_env)
|
||||
|
||||
godot_ok = True
|
||||
for test in smoke_tests:
|
||||
rel = os.path.relpath(test, ROOT)
|
||||
print(f"\n--- {rel} ---")
|
||||
r = run([godot_bin, "--headless", "--path", str(ROOT), "--script", f"res://{rel}"], env=godot_env)
|
||||
if r.returncode != 0:
|
||||
godot_ok = False
|
||||
results["godot smoke tests"] = godot_ok
|
||||
finally:
|
||||
server_proc.terminate()
|
||||
try:
|
||||
server_proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
server_proc.kill()
|
||||
|
||||
if not args.keep_postgres:
|
||||
print("\n=== Backend: tearing down checkpoint Postgres ===")
|
||||
run(["docker", "compose", "down", "-v", "postgres"], cwd=BACKEND, env=compose_env)
|
||||
|
||||
# --- Summary ---
|
||||
print("\n" + "=" * 50)
|
||||
print("SUMMARY")
|
||||
print("=" * 50)
|
||||
overall_ok = True
|
||||
for name, ok in results.items():
|
||||
if ok is None:
|
||||
print(f" {name}: SKIPPED")
|
||||
elif ok:
|
||||
print(f" {name}: PASS")
|
||||
else:
|
||||
print(f" {name}: FAIL")
|
||||
overall_ok = False
|
||||
|
||||
print("\nSee docs/testing/test_inventory.md for what each suite actually covers (and doesn't).")
|
||||
sys.exit(0 if overall_ok else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,174 @@
|
||||
## ServerMain - Dedicated server root node.
|
||||
## Accepts ENet connections, spawns Player nodes for each peer,
|
||||
## and runs the simulation tick loop at 20 Hz.
|
||||
extends Node
|
||||
|
||||
|
||||
## Maximum number of concurrent players.
|
||||
const MAX_PLAYERS := 16
|
||||
|
||||
## Server tick rate (Hz).
|
||||
const TICK_RATE := 20
|
||||
|
||||
## Tick duration in seconds.
|
||||
const TICK_DURATION := 1.0 / TICK_RATE
|
||||
|
||||
## ENet server port.
|
||||
const SERVER_PORT := 7777
|
||||
|
||||
## Network peer used for ENet communication.
|
||||
var network_peer: ENetMultiplayerPeer
|
||||
|
||||
## Map of peer_id -> Player node.
|
||||
var players: Dictionary = {}
|
||||
|
||||
## Current tick counter (server-authoritative).
|
||||
var tick: int = 0
|
||||
|
||||
## Whether the server is running.
|
||||
var is_running: bool = false
|
||||
|
||||
## Accumulated real time for fixed timestep simulation.
|
||||
var accumulator: float = 0.0
|
||||
|
||||
## Player scene resource (loaded once).
|
||||
var _player_scene: PackedScene
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Preload the player scene once.
|
||||
_player_scene = preload("res://scenes/server_player.tscn")
|
||||
|
||||
# Create and assign the ENet server peer.
|
||||
network_peer = _create_server_peer()
|
||||
if network_peer:
|
||||
multiplayer.multiplayer_peer = network_peer
|
||||
is_running = true
|
||||
print("[Server] Started on port %d" % SERVER_PORT)
|
||||
else:
|
||||
printerr("[Server] Failed to create ENet peer.")
|
||||
get_tree().quit(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _create_server_peer() -> ENetMultiplayerPeer:
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var error := peer.create_server(SERVER_PORT, MAX_PLAYERS)
|
||||
if error != OK:
|
||||
printerr("[Server] create_server error: %d" % error)
|
||||
return null
|
||||
return peer
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixed-timestep simulation loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not is_running:
|
||||
return
|
||||
|
||||
# Fixed timestep accumulator.
|
||||
accumulator += delta
|
||||
while accumulator >= TICK_DURATION:
|
||||
accumulator -= TICK_DURATION
|
||||
_tick()
|
||||
|
||||
|
||||
func _tick() -> void:
|
||||
"""Main server simulation tick (called at TICK_RATE)."""
|
||||
tick += 1
|
||||
|
||||
# Process player inputs and update state.
|
||||
for peer_id in players:
|
||||
var player: Node3D = players[peer_id]
|
||||
if player and player.has_method("process_server_input"):
|
||||
player.process_server_input(tick)
|
||||
|
||||
# Broadcast snapshot to all clients.
|
||||
_broadcast_snapshot()
|
||||
|
||||
|
||||
func _broadcast_snapshot() -> void:
|
||||
"""Broadcast the current authoritative state to all connected clients."""
|
||||
var snapshot_data := {
|
||||
"tick": tick,
|
||||
"players": {},
|
||||
}
|
||||
|
||||
for peer_id in players:
|
||||
var player: Node3D = players[peer_id]
|
||||
if player and player.has_method("get_server_state"):
|
||||
snapshot_data.players[peer_id] = player.get_server_state()
|
||||
|
||||
# Send snapshot to all peers except the server itself (peer_id == 1).
|
||||
var peer_ids: Array = multiplayer.get_peers()
|
||||
for pid in peer_ids:
|
||||
if pid == 1: # 1 is the server itself
|
||||
continue
|
||||
rpc_id(pid, "_receive_snapshot", snapshot_data)
|
||||
|
||||
|
||||
# Remote: clients receive snapshots.
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _receive_snapshot(snapshot_data: Dictionary) -> void:
|
||||
pass # Handled by client scene.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _on_peer_connected(peer_id: int) -> void:
|
||||
print("[Server] Peer %d connected." % peer_id)
|
||||
|
||||
if players.size() >= MAX_PLAYERS:
|
||||
print("[Server] Max players reached. Disconnecting peer %d." % peer_id)
|
||||
rpc_id(peer_id, "_kick", "Server is full.")
|
||||
return
|
||||
|
||||
# Spawn a Player node for this peer.
|
||||
var player: Node3D = _player_scene.instantiate()
|
||||
player.name = "Player_%d" % peer_id
|
||||
player.peer_id = peer_id
|
||||
add_child(player)
|
||||
players[peer_id] = player
|
||||
|
||||
print("[Server] Spawned player node for peer %d." % peer_id)
|
||||
|
||||
|
||||
func _on_peer_disconnected(peer_id: int) -> void:
|
||||
print("[Server] Peer %d disconnected." % peer_id)
|
||||
if players.has(peer_id):
|
||||
var player: Node3D = players[peer_id]
|
||||
if player.is_inside_tree():
|
||||
player.queue_free()
|
||||
players.erase(peer_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RPCs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _kick(reason: String) -> void:
|
||||
print("[Server] Kicking caller: %s" % reason)
|
||||
rpc_id(multiplayer.get_remote_sender_id(), "_disconnect", reason)
|
||||
|
||||
|
||||
@rpc("call_remote", "reliable")
|
||||
func _disconnect(reason: String) -> void:
|
||||
print("[Server] Disconnecting: %s" % reason)
|
||||
get_tree().quit(0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal connections
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _enter_tree() -> void:
|
||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwjv826lva2gi
|
||||
@@ -0,0 +1,46 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for the ApiClient autoload.
|
||||
##
|
||||
## Run with:
|
||||
## godot --headless --path . --script res://scripts/smoke_test_api.gd
|
||||
##
|
||||
## Requires the backend to be running (default base URL
|
||||
## `application/agp/api_base_url` -> http://127.0.0.1:8000).
|
||||
##
|
||||
## Calls the real autoload's `health_check()` (which exercises the generic
|
||||
## `request()` primitive) and exits 0 on a `status == "ok"` response, 1
|
||||
## otherwise. This script is intentionally NOT wired into any game scene.
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
# _initialize() fires before the SceneTree has processed its first frame,
|
||||
# so a freshly add_child()'d HTTPRequest node isn't reliably considered
|
||||
# "inside tree" yet (request_raw fails with !is_inside_tree()). One frame
|
||||
# of delay is enough; this only matters for --script mode, not a real
|
||||
# running game.
|
||||
await process_frame
|
||||
|
||||
# Godot's --script/SceneTree runner doesn't resolve autoload singletons
|
||||
# as bare global identifiers at compile time (the node exists at
|
||||
# runtime -- root.has_node("ApiClient") is true -- but referencing it as
|
||||
# a plain identifier fails with "Identifier not found: ApiClient" during
|
||||
# script compilation). get_node sidesteps compile-time resolution
|
||||
# entirely. This only affects --script mode; a real game scene resolves
|
||||
# the bare autoload name normally.
|
||||
var api_client := root.get_node("ApiClient")
|
||||
print("SMOKE: calling ApiClient.health_check() ...")
|
||||
var result: Dictionary = await api_client.health_check()
|
||||
print("SMOKE: result = ", result)
|
||||
|
||||
if result.ok \
|
||||
and result.data is Dictionary \
|
||||
and str((result.data as Dictionary).get("status", "")) == "ok":
|
||||
print("SMOKE RESULT: PASS (status=ok)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("SMOKE RESULT: FAIL")
|
||||
quit(1)
|
||||
@@ -0,0 +1,132 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for the AuthPanel login/register flow.
|
||||
##
|
||||
## Run with:
|
||||
## godot --headless --path . --script res://scripts/smoke_test_auth.gd
|
||||
##
|
||||
## Requires the backend to be running (default base URL
|
||||
## `application/agp/api_base_url` -> http://127.0.0.1:8000).
|
||||
##
|
||||
## Tests:
|
||||
## 1. Register a unique user → 201 + token stored.
|
||||
## 2. Register the same user again → 409 "Username already registered".
|
||||
## 3. Register with over_18=false → 400 "Must be 18 or older".
|
||||
## 4. Login with correct credentials → 200 + token stored.
|
||||
## 5. Login with wrong password → 401 "Incorrect username or password".
|
||||
## 6. Login with non-existent user → 401 "Incorrect username or password".
|
||||
##
|
||||
## Exits 0 on full pass, 1 on any failure.
|
||||
|
||||
const API_BASE := "http://127.0.0.1:8000"
|
||||
const TEST_USERNAME := "smoke_test_user_%d"
|
||||
const TEST_EMAIL := "smoke_%d@test.example.com"
|
||||
const TEST_PASSWORD := "SmokeTest123!"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
# Wait for autoloads to be ready.
|
||||
await process_frame
|
||||
|
||||
var api := root.get_node("ApiClient") as ApiClient
|
||||
assert(api != null, "ApiClient autoload not found")
|
||||
|
||||
var passed: int = 0
|
||||
var failed: int = 0
|
||||
|
||||
# --- Test 1: Register a unique user ---
|
||||
randomize()
|
||||
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||
var username := "smoke_%d" % unique_id
|
||||
var email := "smoke_%d@test.example.com" % unique_id
|
||||
|
||||
print("\n=== Test 1: Register unique user ===")
|
||||
var result := await api.register(username, email, TEST_PASSWORD, true)
|
||||
if _check("register returns ok", result.ok) \
|
||||
and _check("register status is 201", result.status == 201) \
|
||||
and _check("register stores token", api.has_token()):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
print(" detail: ", result.detail)
|
||||
|
||||
# --- Test 2: Register same user again (duplicate) ---
|
||||
print("\n=== Test 2: Duplicate username ===")
|
||||
result = await api.register(username, email, TEST_PASSWORD, true)
|
||||
if _check("duplicate register not ok", not result.ok) \
|
||||
and _check("duplicate status is 409", result.status == 409) \
|
||||
and _check("duplicate detail mentions username",
|
||||
"username" in result.detail.to_lower()):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
print(" detail: ", result.detail)
|
||||
|
||||
# --- Test 3: Register with over_18=false ---
|
||||
print("\n=== Test 3: over_18=false registration ===")
|
||||
var child_username := "child_%d" % unique_id
|
||||
var child_email := "child_%d@test.example.com" % unique_id
|
||||
result = await api.register(child_username, child_email, TEST_PASSWORD, false)
|
||||
if _check("over_18=false register not ok", not result.ok) \
|
||||
and _check("over_18=false status is 400", result.status == 400) \
|
||||
and _check("over_18=false detail mentions age",
|
||||
"18" in result.detail):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
print(" detail: ", result.detail)
|
||||
|
||||
# --- Test 4: Login with correct credentials ---
|
||||
print("\n=== Test 4: Correct login ===")
|
||||
result = await api.login(username, TEST_PASSWORD)
|
||||
if _check("login returns ok", result.ok) \
|
||||
and _check("login status is 200", result.status == 200) \
|
||||
and _check("login stores token", api.has_token()):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
print(" detail: ", result.detail)
|
||||
|
||||
# --- Test 5: Login with wrong password ---
|
||||
print("\n=== Test 5: Wrong password ===")
|
||||
result = await api.login(username, "WrongPassword!")
|
||||
if _check("wrong pwd login not ok", not result.ok) \
|
||||
and _check("wrong pwd status is 401", result.status == 401) \
|
||||
and _check("wrong pwd detail",
|
||||
"Incorrect" in result.detail or "incorrect" in result.detail):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
print(" detail: ", result.detail)
|
||||
|
||||
# --- Test 6: Login with non-existent user ---
|
||||
print("\n=== Test 6: Non-existent user ===")
|
||||
result = await api.login("nonexistent_user_xyz", TEST_PASSWORD)
|
||||
if _check("missing user login not ok", not result.ok) \
|
||||
and _check("missing user status is 401", result.status == 401) \
|
||||
and _check("missing user detail",
|
||||
"Incorrect" in result.detail or "incorrect" in result.detail):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
print(" detail: ", result.detail)
|
||||
|
||||
# --- Summary ---
|
||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||
if failed > 0:
|
||||
quit(1)
|
||||
else:
|
||||
print("SMOKE RESULT: ALL PASS")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _check(description: String, condition: bool) -> bool:
|
||||
if condition:
|
||||
print(" PASS: ", description)
|
||||
return true
|
||||
else:
|
||||
printerr(" FAIL: ", description)
|
||||
return false
|
||||
@@ -0,0 +1,147 @@
|
||||
extends SceneTree
|
||||
## End-to-end headless smoke test: register -> login -> fetch worlds list.
|
||||
##
|
||||
## Run with:
|
||||
## godot --headless --path . --script res://scripts/smoke_test_e2e.gd
|
||||
##
|
||||
## Requires the backend to be running (default base URL
|
||||
## `application/agp/api_base_url` -> http://127.0.0.1:8000, overridable via
|
||||
## the AGP_API_BASE_URL environment variable).
|
||||
##
|
||||
## Steps:
|
||||
## 1. Health check GET /api/health -> 200, {"status": "ok"}
|
||||
## 2. Register POST /api/auth/register -> 201, access_token present
|
||||
## 3. Login POST /api/auth/login -> 200, access_token present
|
||||
## 4. Worlds list GET /api/worlds -> 200, JSON array
|
||||
##
|
||||
## Exits 0 if all steps pass, 1 otherwise. If the backend is unreachable the
|
||||
## health check fails with a clear message and the script exits 1 immediately.
|
||||
|
||||
# Fixed credentials from the task spec. The username gets a unique numeric
|
||||
# suffix so the test stays re-runnable (a second run would otherwise hit
|
||||
# "Username already registered" on step 2).
|
||||
const TEST_USERNAME_BASE := "smoke_test_user"
|
||||
const TEST_EMAIL := "smoke@test.com"
|
||||
const TEST_PASSWORD := "TestPass123"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
# _initialize() fires before the first frame; wait one frame so freshly
|
||||
# created HTTPRequest nodes are reliably inside the tree.
|
||||
await process_frame
|
||||
|
||||
# In --script mode the autoload is reachable via get_node(), not as a
|
||||
# bare identifier (bare identifiers fail to resolve at compile time here).
|
||||
var api := root.get_node("ApiClient")
|
||||
assert(api != null, "ApiClient autoload not found in the scene tree")
|
||||
|
||||
var unique_id := int(Time.get_unix_time_from_system() * 1000)
|
||||
var username := "%s_%d" % [TEST_USERNAME_BASE, unique_id]
|
||||
var email := "smoke_%d@test.com" % unique_id
|
||||
|
||||
var results := {}
|
||||
|
||||
# --- Step 1: Health check -------------------------------------------------
|
||||
print("\n=== Step 1: Health check (GET /api/health) ===")
|
||||
var health: Dictionary = await api.health_check()
|
||||
var health_ok: bool = (
|
||||
health.ok
|
||||
and health.status == 200
|
||||
and health.data is Dictionary
|
||||
and str((health.data as Dictionary).get("status", "")) == "ok"
|
||||
)
|
||||
_record(results, "health", health_ok, "status=%d data=%s" % [
|
||||
health.status, health.data
|
||||
])
|
||||
|
||||
if not health.ok or health.status == 0:
|
||||
printerr("\nERROR: Backend is not reachable at the configured base URL.")
|
||||
printerr(" detail: %s" % health.detail)
|
||||
printerr(" Start the backend first (see backend/README), then re-run:")
|
||||
printerr(" godot --headless --path . --script res://scripts/smoke_test_e2e.gd")
|
||||
_summarize(results)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# --- Step 2: Register -----------------------------------------------------
|
||||
print("\n=== Step 2: Register (POST /api/auth/register) ===")
|
||||
var reg: Dictionary = await api.register(username, email, TEST_PASSWORD, true)
|
||||
var reg_token := _extract_token(reg)
|
||||
var reg_ok: bool = (
|
||||
reg.ok
|
||||
and reg.status == 201
|
||||
and reg_token != ""
|
||||
)
|
||||
_record(results, "register", reg_ok, "status=%d token=%s detail=%s" % [
|
||||
reg.status, "yes" if reg_token != "" else "no", reg.detail
|
||||
])
|
||||
|
||||
# --- Step 3: Login --------------------------------------------------------
|
||||
print("\n=== Step 3: Login (POST /api/auth/login) ===")
|
||||
var login: Dictionary = await api.login(username, TEST_PASSWORD)
|
||||
var login_token := _extract_token(login)
|
||||
var login_ok: bool = (
|
||||
login.ok
|
||||
and login.status == 200
|
||||
and login_token != ""
|
||||
)
|
||||
_record(results, "login", login_ok, "status=%d token=%s detail=%s" % [
|
||||
login.status, "yes" if login_token != "" else "no", login.detail
|
||||
])
|
||||
|
||||
# --- Step 4: Worlds list ---------------------------------------------------
|
||||
print("\n=== Step 4: Worlds list (GET /api/worlds) ===")
|
||||
# login/register already stored the token; make the auth header explicit by
|
||||
# re-applying the extracted token so step 4 is independent of storage order.
|
||||
if login_token != "":
|
||||
api.set_token(login_token)
|
||||
var worlds: Dictionary = await api.get_worlds()
|
||||
var worlds_ok: bool = (
|
||||
worlds.ok
|
||||
and worlds.status == 200
|
||||
and worlds.data is Array
|
||||
)
|
||||
_record(results, "worlds", worlds_ok, "status=%d is_array=%s count=%d detail=%s" % [
|
||||
worlds.status,
|
||||
"yes" if (worlds.data is Array) else "no",
|
||||
(worlds.data as Array).size() if (worlds.data is Array) else -1,
|
||||
worlds.detail,
|
||||
])
|
||||
|
||||
# --- Summary ---------------------------------------------------------------
|
||||
_summarize(results)
|
||||
|
||||
|
||||
func _extract_token(result: Dictionary) -> String:
|
||||
if result.ok and result.data is Dictionary:
|
||||
return str((result.data as Dictionary).get("access_token", ""))
|
||||
return ""
|
||||
|
||||
|
||||
func _record(results: Dictionary, name: String, ok: bool, detail: String) -> void:
|
||||
results[name] = {"ok": ok, "detail": detail}
|
||||
if ok:
|
||||
print(" PASS: %s" % detail)
|
||||
else:
|
||||
printerr(" FAIL: %s" % detail)
|
||||
|
||||
|
||||
func _summarize(results: Dictionary) -> void:
|
||||
var all_pass := true
|
||||
print("\n=== Smoke test summary ===")
|
||||
for step in ["health", "register", "login", "worlds"]:
|
||||
var entry: Dictionary = results.get(step, {"ok": false, "detail": "not run"})
|
||||
if not entry["ok"]:
|
||||
all_pass = false
|
||||
print(" %-8s : %s" % [step, "PASS" if entry["ok"] else "FAIL"])
|
||||
|
||||
if all_pass:
|
||||
print("SMOKE RESULT: ALL PASS (4/4)")
|
||||
quit(0)
|
||||
else:
|
||||
printerr("SMOKE RESULT: FAIL")
|
||||
quit(1)
|
||||
@@ -0,0 +1,146 @@
|
||||
extends SceneTree
|
||||
## Regression/smoke test for the PC2 "Enter World" flow end to end.
|
||||
##
|
||||
## Real user path: register -> land on world_list.tscn -> click "Enter World"
|
||||
## on a real card -> the playable level (scenes/game.tscn) actually loads
|
||||
## with the player spawned in it -> pressing Escape returns to world_list.
|
||||
##
|
||||
## Run with:
|
||||
## godot --headless --path . --script res://scripts/smoke_test_enter_world.gd
|
||||
##
|
||||
## Requires the backend to be running (default base URL
|
||||
## `application/agp/api_base_url` -> http://127.0.0.1:8000, overridable via
|
||||
## AGP_API_BASE_URL) and at least one real world to already exist.
|
||||
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
var api := root.get_node("ApiClient")
|
||||
if api == null:
|
||||
printerr("FAIL: ApiClient autoload not found")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# 1. Register a real user and seed a world so the browse list has a
|
||||
# real card to click, same pattern as smoke_test_world_list.gd.
|
||||
randomize()
|
||||
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||
var username := "enterworld_%d" % unique_id
|
||||
var email := "enterworld_%d@test.example.com" % unique_id
|
||||
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", true)
|
||||
if not _check("setup: register succeeded", reg_result.ok):
|
||||
print(" detail: ", reg_result.detail)
|
||||
_finish()
|
||||
return
|
||||
|
||||
var world_body := JSON.stringify({
|
||||
"name": "EnterWorld Smoke Test World %d" % unique_id,
|
||||
"description": "Seeded by smoke_test_enter_world.gd",
|
||||
"visibility": "public",
|
||||
})
|
||||
var world_result: Dictionary = await api.request(
|
||||
HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true
|
||||
)
|
||||
if not _check("setup: world created", world_result.ok):
|
||||
print(" detail: ", world_result.detail)
|
||||
_finish()
|
||||
return
|
||||
|
||||
# 2. Load world_list.tscn as a real scene and let it fetch worlds.
|
||||
var packed: PackedScene = load("res://scenes/world_list.tscn")
|
||||
var instance: Control = packed.instantiate()
|
||||
root.add_child(instance)
|
||||
# _ready() kicks off an async _refresh_worlds() -- wait real frames for
|
||||
# the HTTP round trip rather than guessing a fixed count.
|
||||
var world_list_node: VBoxContainer = instance.get_node("VBoxContainer/ScrollContainer/WorldList")
|
||||
var settled := false
|
||||
for i in 120:
|
||||
await process_frame
|
||||
if world_list_node.get_child_count() > 0:
|
||||
settled = true
|
||||
break
|
||||
if not _check("world list populated with at least one card", settled):
|
||||
_finish()
|
||||
return
|
||||
|
||||
# 3. Click "Enter World" on the first real card -- find the button by
|
||||
# walking the card's children rather than assuming an index, so this
|
||||
# doesn't silently pass if _create_world_card's structure changes.
|
||||
var card: Control = world_list_node.get_child(0)
|
||||
var enter_button: Button = _find_button(card, "Enter World")
|
||||
if not _check("Enter World button exists on the card", enter_button != null):
|
||||
_finish()
|
||||
return
|
||||
|
||||
enter_button.pressed.emit()
|
||||
await _wait_for_scene_change(instance, 60)
|
||||
|
||||
var game_scene := current_scene
|
||||
if not _check("game.tscn became current_scene after Enter World", game_scene != null and game_scene != instance):
|
||||
_finish()
|
||||
return
|
||||
|
||||
# 4. Verify the player actually exists and spawned at the level's
|
||||
# PlayerSpawn marker -- not just that *some* scene loaded.
|
||||
var player: Node = game_scene.get_node_or_null("Player")
|
||||
_check("Player node exists in the loaded game scene", player is CharacterBody3D)
|
||||
|
||||
var spawn_marker: Node = game_scene.get_node_or_null("Level/PlayerSpawn")
|
||||
_check("PlayerSpawn marker exists in the loaded level", spawn_marker is Marker3D)
|
||||
|
||||
if player is CharacterBody3D and spawn_marker is Marker3D:
|
||||
var dist := (player as CharacterBody3D).global_position.distance_to((spawn_marker as Marker3D).global_position)
|
||||
_check("player spawned at (or very near) the PlayerSpawn marker", dist < 0.5)
|
||||
|
||||
# 5. Escape should return to world_list.tscn (real key event, not a
|
||||
# direct method call -- proves the actual input binding works).
|
||||
var esc_event := InputEventKey.new()
|
||||
esc_event.keycode = KEY_ESCAPE
|
||||
esc_event.pressed = true
|
||||
Input.parse_input_event(esc_event)
|
||||
await _wait_for_scene_change(game_scene, 60)
|
||||
|
||||
var back_to_menu := current_scene
|
||||
_check("Escape returned to world_list.tscn", back_to_menu != null and back_to_menu.scene_file_path == "res://scenes/world_list.tscn")
|
||||
|
||||
_finish()
|
||||
|
||||
|
||||
func _find_button(node: Node, text: String) -> Button:
|
||||
if node is Button and (node as Button).text == text:
|
||||
return node
|
||||
for child in node.get_children():
|
||||
var found := _find_button(child, text)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
func _wait_for_scene_change(previous_scene: Node, max_frames: int) -> void:
|
||||
for i in max_frames:
|
||||
await process_frame
|
||||
if current_scene != previous_scene and current_scene != null:
|
||||
return
|
||||
|
||||
|
||||
func _check(description: String, condition: bool) -> bool:
|
||||
if condition:
|
||||
print(" PASS: ", description)
|
||||
passed += 1
|
||||
else:
|
||||
printerr(" FAIL: ", description)
|
||||
failed += 1
|
||||
return condition
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||
quit(0 if failed == 0 else 1)
|
||||
@@ -0,0 +1,170 @@
|
||||
extends SceneTree
|
||||
## Regression test for the Play -> Back navigation bug.
|
||||
##
|
||||
## The reported bug: after logging in and clicking Play, the user lands on
|
||||
## world_list.tscn; clicking Back returns to main_menu.tscn, but the menu
|
||||
## reset to a fully logged-out state (Play disabled, login form showing)
|
||||
## even though ApiClient still held a valid token. main_menu.gd's _ready()
|
||||
## used to unconditionally reset the UI on every scene load.
|
||||
##
|
||||
## This test drives the real navigation flow (change_scene_to_file) against
|
||||
## a live backend:
|
||||
## 1. Register a unique user so ApiClient holds a valid token.
|
||||
## 2. Load main_menu.tscn and assert it restores the authenticated state.
|
||||
## 3. Click Play -> world_list.tscn.
|
||||
## 4. Click Back -> main_menu.tscn.
|
||||
## 5. Assert the menu is STILL authenticated (Play enabled, welcome label
|
||||
## actually visible in the tree, login/register buttons hidden).
|
||||
##
|
||||
## Run with:
|
||||
## godot --headless --path . --script res://scripts/smoke_test_main_menu_back.gd
|
||||
##
|
||||
## Requires the backend to be running (default base URL
|
||||
## `application/agp/api_base_url` -> http://127.0.0.1:8000).
|
||||
|
||||
const USERNAME := "backnav_%d"
|
||||
const EMAIL := "backnav_%d@test.example.com"
|
||||
const PASSWORD := "BackNav123!"
|
||||
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
var api := root.get_node("ApiClient")
|
||||
if api == null:
|
||||
printerr("FAIL: ApiClient autoload not found")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# 1. Establish a real, valid session exactly the way a user would.
|
||||
randomize()
|
||||
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||
var username := USERNAME % unique_id
|
||||
var email := EMAIL % unique_id
|
||||
print("\n=== Setup: register ", username, " ===")
|
||||
var register_result: Dictionary = await api.register(username, email, PASSWORD, true)
|
||||
if not _check("register succeeds", register_result.ok):
|
||||
print(" detail: ", register_result.detail)
|
||||
_finish()
|
||||
return
|
||||
_check("ApiClient stores token after register", api.has_token())
|
||||
|
||||
# 2. First visit to the main menu: should already show the authenticated
|
||||
# state (no login form, Play enabled).
|
||||
change_scene_to_file("res://scenes/main_menu.tscn")
|
||||
# change_scene_to_file defers the actual swap -- a couple frames is
|
||||
# plenty for the scene object itself to exist (unlike the real
|
||||
# get_me() network round-trip afterward, which is what auth_state_ready
|
||||
# actually waits for below).
|
||||
for i in 5:
|
||||
await process_frame
|
||||
if current_scene is Control:
|
||||
break
|
||||
|
||||
var menu: Control = current_scene
|
||||
if menu == null or not menu is Control:
|
||||
printerr("FAIL: main_menu.tscn did not become current_scene")
|
||||
_finish()
|
||||
return
|
||||
await menu.auth_state_ready
|
||||
_check("first main menu visit restores authenticated state", _is_authenticated(menu))
|
||||
|
||||
# 3. Play -> world_list.tscn (real button press).
|
||||
var play_button: Button = menu.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||
play_button.pressed.emit()
|
||||
await _wait_for_scene_change(menu)
|
||||
|
||||
var world_list_scene := current_scene
|
||||
if world_list_scene == null or world_list_scene == menu:
|
||||
printerr("FAIL: world_list.tscn did not become current_scene after Play")
|
||||
_finish()
|
||||
return
|
||||
|
||||
# 4. Back -> main_menu.tscn (real button press).
|
||||
var back_button: Button = world_list_scene.get_node("VBoxContainer/HeaderHBox/BackButton")
|
||||
back_button.pressed.emit()
|
||||
await _wait_for_scene_change(world_list_scene)
|
||||
|
||||
menu = current_scene
|
||||
if menu == null or not menu is Control:
|
||||
printerr("FAIL: main_menu.tscn did not become current_scene after Back")
|
||||
_finish()
|
||||
return
|
||||
|
||||
# The new main_menu instance's own _ready() is *also* awaiting a real
|
||||
# get_me() call -- _wait_for_scene_change only confirms the scene
|
||||
# object exists, not that its authenticated-state fields have been
|
||||
# set yet. Wait for the real signal, not a guess.
|
||||
await menu.auth_state_ready
|
||||
|
||||
# 5. THE regression assertion: still logged in after returning.
|
||||
_check("main menu stays authenticated after Play -> Back", _is_authenticated(menu))
|
||||
|
||||
_finish()
|
||||
|
||||
|
||||
func _is_authenticated(menu: Control) -> bool:
|
||||
var play_button: Button = menu.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||
var welcome_label: Label = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
|
||||
var auth_buttons_row: HBoxContainer = menu.get_node("CenterContainer/VBoxContainer/AuthButtonsRow")
|
||||
var auth_panel: PanelContainer = menu.get_node("CenterContainer/VBoxContainer/AuthPanel")
|
||||
var username_edit: LineEdit = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit")
|
||||
var password_edit: LineEdit = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit")
|
||||
var auth_button: Button = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton")
|
||||
var auth_mode_label: Label = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel")
|
||||
|
||||
_check("Play enabled", not play_button.disabled)
|
||||
# WelcomeLabel lives inside AuthPanel, so `.visible` alone is a
|
||||
# false-positive: the label can have visible=true while its parent is
|
||||
# hidden and it renders as nothing. `is_visible_in_tree()` is the
|
||||
# check that proves the user can actually see the welcome message.
|
||||
_check("Welcome label visible in tree", welcome_label.is_visible_in_tree())
|
||||
_check("Auth buttons row hidden", not auth_buttons_row.visible)
|
||||
_check("Auth panel visible (it contains the welcome label)", auth_panel.visible)
|
||||
# Real bug, 2026-08-20: the session-restore path (this one, after
|
||||
# Play -> Back) hid EmailEdit/Over18Check but never the rest of the
|
||||
# login form -- Play enabled + welcome label visible were both still
|
||||
# true, so a check that stopped there passed while the user was
|
||||
# looking at the login fields again on top of the welcome message.
|
||||
_check("Username field hidden", not username_edit.visible)
|
||||
_check("Password field hidden", not password_edit.visible)
|
||||
_check("Auth button hidden", not auth_button.visible)
|
||||
_check("Auth mode label hidden", not auth_mode_label.visible)
|
||||
|
||||
return (not play_button.disabled) \
|
||||
and welcome_label.is_visible_in_tree() \
|
||||
and not auth_buttons_row.visible \
|
||||
and auth_panel.visible \
|
||||
and not username_edit.visible \
|
||||
and not password_edit.visible \
|
||||
and not auth_button.visible \
|
||||
and not auth_mode_label.visible
|
||||
|
||||
|
||||
func _wait_for_scene_change(previous_scene: Node) -> void:
|
||||
for i in 20:
|
||||
await process_frame
|
||||
if current_scene != previous_scene and current_scene != null:
|
||||
return
|
||||
|
||||
|
||||
func _check(description: String, condition: bool) -> bool:
|
||||
if condition:
|
||||
print(" PASS: ", description)
|
||||
passed += 1
|
||||
else:
|
||||
printerr(" FAIL: ", description)
|
||||
failed += 1
|
||||
return condition
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||
quit(0 if failed == 0 else 1)
|
||||
@@ -0,0 +1,86 @@
|
||||
extends SceneTree
|
||||
## Full click-through simulation of the real user flow: press Register,
|
||||
## fill the form exactly as a human would, submit, confirm Play actually
|
||||
## becomes clickable. Also verifies that after successful auth the auth
|
||||
## form fields are hidden, leaving only the welcome message visible.
|
||||
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
var packed: PackedScene = load("res://scenes/main_menu.tscn")
|
||||
var instance: Control = packed.instantiate()
|
||||
root.add_child(instance)
|
||||
await process_frame
|
||||
|
||||
var register_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton")
|
||||
var auth_buttons_row: HBoxContainer = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow")
|
||||
var username_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit")
|
||||
var password_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit")
|
||||
var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit")
|
||||
var over_18_check: CheckBox = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check")
|
||||
var auth_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton")
|
||||
var auth_mode_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel")
|
||||
var welcome_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
|
||||
var play_button: Button = instance.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||
|
||||
_check("Play starts disabled", play_button.disabled)
|
||||
|
||||
# Click Register -- a real signal emission is fine here since
|
||||
# _on_register_button_pressed() is synchronous (no await), same as a
|
||||
# real click's GUI event dispatch would drive it.
|
||||
register_button.pressed.emit()
|
||||
await process_frame
|
||||
|
||||
_check("auth panel opened in register mode", instance.get_node("CenterContainer/VBoxContainer/AuthPanel").visible)
|
||||
|
||||
# Fill the form exactly as a user would type into it.
|
||||
var unique_id := int(Time.get_unix_time_from_system() * 1000)
|
||||
username_edit.text = "mainmenu_e2e_%d" % unique_id
|
||||
password_edit.text = "TestPass123!"
|
||||
email_edit.text = "mainmenu_e2e_%d@test.example.com" % unique_id
|
||||
over_18_check.button_pressed = true
|
||||
|
||||
# Call the submit handler directly (awaited) rather than via
|
||||
# auth_button.pressed.emit() -- a synthetic .emit() in --script/
|
||||
# SceneTree headless mode doesn't drive an await-using handler's
|
||||
# coroutine forward the same way real GUI input event dispatch does
|
||||
# (confirmed via a direct-call vs emit() comparison: the direct call
|
||||
# always resolves, the synthetic emit sometimes doesn't advance past
|
||||
# its own await point even after 50 real process_frame waits). This
|
||||
# is a headless-mode test-harness limitation, not a bug in the actual
|
||||
# click-driven flow.
|
||||
await instance._on_auth_button_pressed()
|
||||
|
||||
_check("Play became enabled after real register submission", not play_button.disabled)
|
||||
_check("Welcome label visible in tree", welcome_label.is_visible_in_tree())
|
||||
_check("Auth buttons row hid itself after success", not auth_buttons_row.visible)
|
||||
|
||||
# Verify the auth form fields are hidden after successful auth,
|
||||
# leaving only the welcome message visible within the panel.
|
||||
_check("Username edit hidden after auth", not username_edit.visible)
|
||||
_check("Password edit hidden after auth", not password_edit.visible)
|
||||
_check("Email edit hidden after auth", not email_edit.visible)
|
||||
_check("Over18 checkbox hidden after auth", not over_18_check.visible)
|
||||
_check("Auth button hidden after auth", not auth_button.visible)
|
||||
_check("Auth mode label hidden after auth", not auth_mode_label.visible)
|
||||
_check("Welcome label contains username", welcome_label.text.begins_with("Welcome, mainmenu_e2e_"))
|
||||
|
||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||
quit(0 if failed == 0 else 1)
|
||||
|
||||
|
||||
func _check(description: String, condition: bool) -> void:
|
||||
if condition:
|
||||
print(" PASS: ", description)
|
||||
passed += 1
|
||||
else:
|
||||
printerr(" FAIL: ", description)
|
||||
failed += 1
|
||||
@@ -0,0 +1,126 @@
|
||||
extends SceneTree
|
||||
## Verifies the main menu's Login/Register buttons correctly open the auth
|
||||
## panel and toggle between Login and Register modes via signal emission.
|
||||
##
|
||||
## Key tests:
|
||||
## - AuthPanel starts hidden.
|
||||
## - LoginButton press opens the panel in Login mode.
|
||||
## - RegisterButton press opens the panel in Register mode.
|
||||
## - Clicking Login while in Register mode switches back to Login mode.
|
||||
## - Clicking Register while in Login mode switches to Register mode.
|
||||
## - The ModeToggle CheckButton no longer exists in the scene.
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
|
||||
var packed: PackedScene = load("res://scenes/main_menu.tscn")
|
||||
var instance: Control = packed.instantiate()
|
||||
root.add_child(instance)
|
||||
await process_frame
|
||||
|
||||
var login_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton")
|
||||
var register_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton")
|
||||
var auth_panel: PanelContainer = instance.get_node("CenterContainer/VBoxContainer/AuthPanel")
|
||||
var auth_mode_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel")
|
||||
var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit")
|
||||
var over_18_check: CheckBox = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check")
|
||||
|
||||
# --- Test 1: auth panel starts hidden ---
|
||||
if _check("auth panel starts hidden", not auth_panel.visible):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# --- Test 2: LoginButton press opens panel in Login mode ---
|
||||
login_button.pressed.emit()
|
||||
await process_frame
|
||||
|
||||
if _check("auth panel visible after LoginButton press", auth_panel.visible):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
if _check("Login mode label after LoginButton press", auth_mode_label.text == "Login"):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
if _check("Email field hidden in Login mode", not email_edit.visible):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
if _check("Over18 checkbox hidden in Login mode", not over_18_check.visible):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# --- Test 3: RegisterButton press switches to Register mode ---
|
||||
register_button.pressed.emit()
|
||||
await process_frame
|
||||
|
||||
if _check("auth panel still visible after RegisterButton press", auth_panel.visible):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
if _check("Register mode label after RegisterButton press", auth_mode_label.text == "Register"):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
if _check("Email field visible in Register mode", email_edit.visible):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
if _check("Over18 checkbox visible in Register mode", over_18_check.visible):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# --- Test 4: LoginButton press switches back to Login mode ---
|
||||
login_button.pressed.emit()
|
||||
await process_frame
|
||||
|
||||
if _check("Login mode label after second LoginButton press", auth_mode_label.text == "Login"):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
if _check("Email field hidden after switching back to Login", not email_edit.visible):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
if _check("Over18 checkbox hidden after switching back to Login", not over_18_check.visible):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# --- Test 5: ModeToggle CheckButton no longer exists ---
|
||||
var mode_toggle_path = "CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle"
|
||||
var has_mode_toggle = instance.has_node(mode_toggle_path)
|
||||
if _check("ModeToggle CheckButton removed from scene", not has_mode_toggle):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||
quit(0 if failed == 0 else 1)
|
||||
|
||||
|
||||
func _check(description: String, condition: bool) -> bool:
|
||||
if condition:
|
||||
print(" PASS: ", description)
|
||||
else:
|
||||
printerr(" FAIL: ", description)
|
||||
return condition
|
||||
@@ -0,0 +1,114 @@
|
||||
extends SceneTree
|
||||
## Real integration test for NetworkClient (client-side prediction +
|
||||
## reconciliation) against an actual dedicated server process.
|
||||
##
|
||||
## This does NOT boot its own server -- run scenes/server.tscn as a separate
|
||||
## process first (see docs/architecture/networking.md's ENet/port setup):
|
||||
## godot --headless --path . scenes/server.tscn &
|
||||
## then, from the same project directory:
|
||||
## godot --headless --path . --script res://scripts/smoke_test_network_client.gd
|
||||
##
|
||||
## Verifies: the client can actually connect over ENet, local prediction
|
||||
## moves the player immediately on input (no waiting for the server),
|
||||
## server snapshots actually arrive and update the client's authoritative
|
||||
## view, and a deliberately-desynced predicted position gets corrected by
|
||||
## reconciliation.
|
||||
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
var net := NetworkClient.new()
|
||||
root.add_child(net)
|
||||
|
||||
var ok: bool = await net.connect_to_server("127.0.0.1", 7777)
|
||||
if not _check("connected to the real dedicated server", ok):
|
||||
printerr(" Is `godot --headless --path . scenes/server.tscn` running on port 7777?")
|
||||
_finish()
|
||||
return
|
||||
|
||||
# Give the server a few frames to process the connection and spawn our
|
||||
# player node before we start sending input.
|
||||
for i in 10:
|
||||
await process_frame
|
||||
|
||||
# 1. Prediction: pressing "forward" should move the local position
|
||||
# immediately, without waiting for any server round trip.
|
||||
var pos_before: Vector3 = net.position
|
||||
for i in 10:
|
||||
net.predict_and_send(1.0 / 30.0, 0.0, -1.0, false) # move -Z (forward)
|
||||
await process_frame
|
||||
var pos_after_predict: Vector3 = net.position
|
||||
_check("local prediction moved the player without waiting for the server",
|
||||
pos_after_predict.distance_to(pos_before) > 0.1)
|
||||
|
||||
# 2. Snapshots actually arrive from the real server and get processed --
|
||||
# wait for at least one reconciliation-eligible snapshot by giving it
|
||||
# real time (server broadcasts at its own tick rate).
|
||||
var got_snapshot := false
|
||||
net.reconciled.connect(func(_err): got_snapshot = true)
|
||||
for i in 60:
|
||||
net.predict_and_send(1.0 / 30.0, 0.0, -1.0, false)
|
||||
await process_frame
|
||||
# Reconciliation only actually fires on a real mismatch -- absence of a
|
||||
# large mismatch is a PASS outcome too (means prediction tracked the
|
||||
# server closely), so this alone isn't proof snapshots arrived. Check
|
||||
# that directly instead:
|
||||
_check("prediction buffer has entries (input was actually sent)", net._prediction_buffer.size() > 0)
|
||||
|
||||
# 3. Deliberately desync prediction from reality, then confirm the next
|
||||
# snapshot corrects it -- the real proof reconciliation logic works,
|
||||
# not just that it never had to fire.
|
||||
net.position = Vector3(9999, 9999, 9999)
|
||||
var desynced_pos := net.position
|
||||
# GDScript closures capture local value-type vars (bool/float) by value,
|
||||
# not by reference -- assigning to them *inside* the lambda doesn't
|
||||
# mutate the outer scope's copy. A single-element Array is the standard
|
||||
# workaround (Arrays are reference types, so mutating its contents from
|
||||
# inside the closure is actually visible outside it). Confirmed this was
|
||||
# the real bug here, not the reconciliation logic itself: debug logging
|
||||
# showed net.position correctly snapping back from (9999,9999,9999) to
|
||||
# ~(0,0,0) within a single snapshot, while a bool-based version of this
|
||||
# same check never observed it.
|
||||
var reconcile_state := [false, -1.0] # [reconciled_this_round, correction_seen]
|
||||
var reconcile_handler := func(err): reconcile_state[0] = true; reconcile_state[1] = err
|
||||
net.reconciled.connect(reconcile_handler)
|
||||
for i in 60:
|
||||
# Keep predicting (with no movement input) so the prediction buffer's
|
||||
# latest entry actually reflects the desynced position -- without
|
||||
# this, the buffer either empties out or keeps stale pre-desync
|
||||
# entries, and _handle_snapshot has nothing real to compare against.
|
||||
net.predict_and_send(1.0 / 30.0, 0.0, 0.0, false)
|
||||
await process_frame
|
||||
if reconcile_state[0]:
|
||||
break
|
||||
net.reconciled.disconnect(reconcile_handler)
|
||||
_check("a deliberately desynced position gets corrected by the next snapshot",
|
||||
reconcile_state[0] and net.position.distance_to(desynced_pos) > 100.0)
|
||||
if reconcile_state[0]:
|
||||
print(" correction distance was ~", reconcile_state[1])
|
||||
|
||||
net.disconnect_from_server()
|
||||
_finish()
|
||||
|
||||
|
||||
func _check(description: String, condition: bool) -> bool:
|
||||
if condition:
|
||||
print(" PASS: ", description)
|
||||
passed += 1
|
||||
else:
|
||||
printerr(" FAIL: ", description)
|
||||
failed += 1
|
||||
return condition
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||
quit(0 if failed == 0 else 1)
|
||||
@@ -0,0 +1,93 @@
|
||||
extends SceneTree
|
||||
## Smoke test for the player character scene and its spawn behavior.
|
||||
##
|
||||
## Run with:
|
||||
## godot --headless --path . --script res://scripts/smoke_test_player.gd
|
||||
##
|
||||
## Loads the test level, verifies the player is present, spawned at the
|
||||
## PlayerSpawn marker, and has the expected CharacterBody3D structure.
|
||||
|
||||
var _passed := 0
|
||||
var _failed := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
print("SMOKE: starting player smoke test")
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
print("SMOKE: loading game scene")
|
||||
|
||||
# Load scenes/game.tscn -- the real PC2 composition point (Level + Player
|
||||
# as siblings, per docs/architecture/pc2_test_level.md §4), not the level
|
||||
# scene directly. Use the same SceneTree API as the real game so
|
||||
# `current_scene` is populated before the scene is added and its
|
||||
# _ready() callbacks fire. That ordering is what the player's spawn
|
||||
# lookup (get_tree().current_scene) depends on.
|
||||
var packed: PackedScene = load("res://scenes/game.tscn")
|
||||
var result := change_scene_to_packed(packed)
|
||||
if result != OK:
|
||||
printerr("SMOKE: failed to change scene, error ", result)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
await scene_changed
|
||||
var game: Node3D = current_scene as Node3D
|
||||
print("SMOKE: game scene ready")
|
||||
|
||||
# 1. Verify the player node exists in the scene tree.
|
||||
var player: Node = game.get_node("Player")
|
||||
_check("Player node exists in test level", player != null)
|
||||
_check("Player is a CharacterBody3D", player is CharacterBody3D)
|
||||
|
||||
# 2. Verify the player has the expected child structure.
|
||||
var collision: Node = player.get_node("CollisionShape3D")
|
||||
_check("Player has CollisionShape3D", collision != null)
|
||||
_check("CollisionShape3D has a shape",
|
||||
collision is CollisionShape3D and (collision as CollisionShape3D).shape != null)
|
||||
|
||||
var mesh: Node = player.get_node("CapsuleMesh")
|
||||
_check("Player has CapsuleMesh", mesh != null)
|
||||
_check("CapsuleMesh has a mesh",
|
||||
mesh is MeshInstance3D and (mesh as MeshInstance3D).mesh != null)
|
||||
|
||||
# 3. Verify the player spawned at the PlayerSpawn marker position.
|
||||
var spawn_marker: Marker3D = game.get_node("Level/PlayerSpawn")
|
||||
_check("PlayerSpawn marker exists", spawn_marker != null)
|
||||
|
||||
# The player's _ready() should have moved it to the spawn marker's
|
||||
# global position. Compare global positions directly (not local) since
|
||||
# Player and Level sit at different depths under Game.
|
||||
var expected_pos: Vector3 = spawn_marker.global_position
|
||||
_check("Player spawned at PlayerSpawn position",
|
||||
player.global_position == expected_pos)
|
||||
|
||||
# 4. Verify the player script class_name is correct and has the
|
||||
# movement constants defined.
|
||||
var script: GDScript = player.get_script() as GDScript
|
||||
_check("Player has a script attached", script != null)
|
||||
var constants: Dictionary = script.get_script_constant_map()
|
||||
_check("Player has WALK_SPEED constant", constants.has("WALK_SPEED"))
|
||||
_check("Player has GRAVITY constant", constants.has("GRAVITY"))
|
||||
_check("Player has JUMP_VELOCITY constant", constants.has("JUMP_VELOCITY"))
|
||||
_check("JUMP_VELOCITY is positive (upward)", float(constants.get("JUMP_VELOCITY", -1.0)) > 0.0)
|
||||
|
||||
# 5. Verify the player has a Camera3D so the game is actually viewable
|
||||
# from the player's perspective, not just a walking capsule with no view.
|
||||
var camera: Node = player.get_node_or_null("Camera3D")
|
||||
_check("Player has a Camera3D", camera is Camera3D)
|
||||
_check("Camera3D is current", camera is Camera3D and (camera as Camera3D).current)
|
||||
|
||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [_passed, _passed + _failed])
|
||||
quit(0 if _failed == 0 else 1)
|
||||
|
||||
|
||||
func _check(description: String, condition: bool) -> void:
|
||||
if condition:
|
||||
print(" PASS: ", description)
|
||||
_passed += 1
|
||||
else:
|
||||
printerr(" FAIL: ", description)
|
||||
_failed += 1
|
||||
@@ -0,0 +1,125 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for the PC2 test level scene.
|
||||
##
|
||||
## Run with:
|
||||
## godot --headless --path . --script res://scripts/smoke_test_test_level.gd
|
||||
##
|
||||
## Loads the test level scene, asserts:
|
||||
## (a) it loads without error,
|
||||
## (b) it contains a Marker3D named "PlayerSpawn",
|
||||
## (c) PlayerSpawn is positioned >= 1.0 m above the ground top surface.
|
||||
## Exits 0 on success, 1 on failure.
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var level_path: String = "res://scenes/worlds/test_level.tscn"
|
||||
|
||||
# Load the scene file
|
||||
var level_resource: PackedScene = load(level_path)
|
||||
if level_resource == null:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 could not load ", level_path)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("SMOKE: test_level.tscn loaded successfully")
|
||||
|
||||
# Change the running scene to our test level
|
||||
var err: int = change_scene_to_packed(level_resource)
|
||||
if err != OK:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 change_scene_to_packed returned ", err)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# Wait for the scene_changed signal so current_scene is set
|
||||
await scene_changed
|
||||
|
||||
var level_instance: Node3D = current_scene as Node3D
|
||||
if level_instance == null:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 current_scene is not Node3D")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# Give the tree one frame to fully initialize
|
||||
await process_frame
|
||||
|
||||
# (b) Find the PlayerSpawn marker
|
||||
var spawn_marker: Node3D = level_instance.find_child("PlayerSpawn", true, false) as Node3D
|
||||
if spawn_marker == null:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 no Node3D named 'PlayerSpawn' found in test level")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("SMOKE: found PlayerSpawn at ", spawn_marker.global_position)
|
||||
|
||||
# (c) Verify PlayerSpawn is >= 1.0 m above ground top
|
||||
# The ground top surface is at y = 0 (Ground StaticBody3D centered at y = -0.5
|
||||
# with height 1.0, so top face is at y = 0).
|
||||
var spawn_y: float = spawn_marker.global_position.y
|
||||
if spawn_y < 1.0:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 PlayerSpawn y = ", spawn_y,
|
||||
" is below the required minimum of 1.0 m above ground top (y=0)")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("SMOKE: PlayerSpawn y = ", spawn_y, " >= 1.0 m \u2014 PASS")
|
||||
|
||||
# Additional check: verify Ground StaticBody3D exists
|
||||
var ground: StaticBody3D = level_instance.find_child("Ground", true, false) as StaticBody3D
|
||||
if ground == null:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 no StaticBody3D named 'Ground' found")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("SMOKE: found Ground StaticBody3D")
|
||||
|
||||
# Verify Ground top face is at y = 0
|
||||
var ground_shape: CollisionShape3D = ground.find_child("CollisionShape3D", true, false) as CollisionShape3D
|
||||
if ground_shape != null and ground_shape.shape is BoxShape3D:
|
||||
var box_shape: BoxShape3D = ground_shape.shape as BoxShape3D
|
||||
var ground_top: float = ground.global_position.y + box_shape.size.y / 2.0
|
||||
if abs(ground_top - 0.0) > 0.01:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 Ground top face at y = ", ground_top, " expected ~0.0")
|
||||
quit(1)
|
||||
return
|
||||
print("SMOKE: Ground top face at y = ", ground_top, " \u2014 PASS")
|
||||
|
||||
# Verify props exist (3-5 StaticBody3D children under Props node)
|
||||
var props_node: Node3D = level_instance.find_child("Props", true, false) as Node3D
|
||||
if props_node != null:
|
||||
var prop_count: int = 0
|
||||
for child in props_node.get_children():
|
||||
if child is StaticBody3D:
|
||||
prop_count += 1
|
||||
if prop_count < 3:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 only ", prop_count, " props found, need >= 3")
|
||||
quit(1)
|
||||
return
|
||||
print("SMOKE: found ", prop_count, " props \u2014 PASS")
|
||||
else:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 no 'Props' Node3D found")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# Verify lighting exists
|
||||
var sun: DirectionalLight3D = level_instance.find_child("Sun", true, false) as DirectionalLight3D
|
||||
if sun == null:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 no DirectionalLight3D named 'Sun' found")
|
||||
quit(1)
|
||||
return
|
||||
print("SMOKE: found Sun DirectionalLight3D")
|
||||
|
||||
# Verify WorldEnvironment exists
|
||||
var env: WorldEnvironment = level_instance.find_child("WorldEnvironment", true, false) as WorldEnvironment
|
||||
if env == null:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 no WorldEnvironment named 'WorldEnvironment' found")
|
||||
quit(1)
|
||||
return
|
||||
print("SMOKE: found WorldEnvironment")
|
||||
|
||||
# All checks passed
|
||||
print("SMOKE RESULT: PASS")
|
||||
quit(0)
|
||||
@@ -0,0 +1,168 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for the world list screen.
|
||||
##
|
||||
## Run with:
|
||||
## godot --headless --path . --script res://scripts/smoke_test_world_list.gd
|
||||
##
|
||||
## Requires the backend to be running (default base URL
|
||||
## `application/agp/api_base_url` -> http://127.0.0.1:8000, override with
|
||||
## AGP_API_BASE_URL).
|
||||
##
|
||||
## Unlike a pure API-level test, this actually instantiates scenes/
|
||||
## world_list.tscn as a real scene and adds it to the tree -- catches
|
||||
## @onready node-path bugs (a node reference pointing at the wrong path
|
||||
## errors right here, not just "looks right" from reading the script) that
|
||||
## an API-only test would miss entirely.
|
||||
##
|
||||
## Tests:
|
||||
## 1. Register + log in a fresh user (so get_worlds() has a valid token).
|
||||
## 2. POST a real world via the API so the populated-card path actually
|
||||
## gets exercised -- run_checkpoint_tests.py always starts from a
|
||||
## genuinely empty DB, so without this the card-rendering code below
|
||||
## would never run in CI at all, only the empty-list path would.
|
||||
## 3. Instantiate world_list.tscn, add to tree -- @onready resolution
|
||||
## must not error.
|
||||
## 4. Wait for _refresh_worlds()'s awaited request to complete.
|
||||
## 5. Confirm it actually reached the populated (not empty) state, and
|
||||
## that the rendered card has a real width -- not just non-zero
|
||||
## child_count. 2026-08-20: a real bug shipped where every card
|
||||
## technically existed in the tree (child_count > 0, no error, no
|
||||
## crash) but rendered at 1.0px wide because size_flags_horizontal/
|
||||
## vertical used the raw integer 4 (Control.SIZE_SHRINK_CENTER)
|
||||
## instead of 3 (Control.SIZE_EXPAND_FILL) on ScrollContainer -- a
|
||||
## child-count check alone would never catch that, only a real size
|
||||
## assertion does.
|
||||
##
|
||||
## Exits 0 on full pass, 1 on any failure.
|
||||
|
||||
const WORLD_LIST_SCENE := "res://scenes/world_list.tscn"
|
||||
const MIN_SANE_CARD_WIDTH := 200.0 # a real screen is at minimum a few hundred px wide
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
var api := root.get_node("ApiClient")
|
||||
assert(api != null, "ApiClient autoload not found")
|
||||
|
||||
var passed: int = 0
|
||||
var failed: int = 0
|
||||
|
||||
# --- Setup: register + log in so the world list has a valid token ---
|
||||
print("\n=== Setup: register + login ===")
|
||||
randomize()
|
||||
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||
var username := "worldlist_smoke_%d" % unique_id
|
||||
var email := "worldlist_smoke_%d@test.example.com" % unique_id
|
||||
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", true)
|
||||
if not _check("setup: register succeeded", reg_result.ok):
|
||||
print(" detail: ", reg_result.detail)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# --- Seed a real world so the populated-card path is actually exercised ---
|
||||
print("\n=== Setup: seed a real world ===")
|
||||
var world_body := JSON.stringify({
|
||||
"name": "Smoke Test World %d" % unique_id,
|
||||
"description": "Seeded by smoke_test_world_list.gd to exercise the populated-card render path.",
|
||||
"visibility": "public",
|
||||
})
|
||||
var create_result: Dictionary = await api.request(HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true)
|
||||
if not _check("setup: world created", create_result.ok):
|
||||
print(" detail: ", create_result.detail)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# --- Test 1: Instantiate the real scene ---
|
||||
print("\n=== Test 1: Instantiate world_list.tscn ===")
|
||||
var packed_scene: PackedScene = load(WORLD_LIST_SCENE)
|
||||
if not _check("scene loads", packed_scene != null):
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var instance: Control = packed_scene.instantiate()
|
||||
if not _check("scene instantiates", instance != null):
|
||||
quit(1)
|
||||
return
|
||||
|
||||
root.add_child(instance)
|
||||
# One frame for _ready() (and its @onready resolution) to run. A wrong
|
||||
# node path in an @onready var errors during this, not silently.
|
||||
await process_frame
|
||||
|
||||
if _check("scene added to tree without error", is_instance_valid(instance)):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# --- Test 2: Wait for the world fetch to resolve, check a real end state ---
|
||||
print("\n=== Test 2: World fetch reaches a real end state ===")
|
||||
# _refresh_worlds() runs from _ready(); give its awaited HTTP call time
|
||||
# to complete rather than a fixed sleep -- poll for either end state.
|
||||
var world_list_node: VBoxContainer = instance.get_node("VBoxContainer/ScrollContainer/WorldList")
|
||||
var empty_label: Label = instance.get_node("VBoxContainer/EmptyLabel")
|
||||
var error_label: Label = instance.get_node("VBoxContainer/ErrorLabel")
|
||||
|
||||
var settled := false
|
||||
for i in range(50): # up to ~5s at 100ms/frame-ish via process_frame waits
|
||||
await process_frame
|
||||
if world_list_node.visible or empty_label.visible or error_label.visible:
|
||||
settled = true
|
||||
break
|
||||
|
||||
if _check("reached a real end state (cards, empty, or error) not stuck loading", settled):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
print(" world_list.visible=%s empty_label.visible=%s error_label.visible=%s" % [
|
||||
world_list_node.visible, empty_label.visible, error_label.visible
|
||||
])
|
||||
|
||||
if error_label.visible:
|
||||
printerr(" Note: ended in error state: ", error_label.text)
|
||||
elif empty_label.visible:
|
||||
# We just seeded a world -- ending up empty means GET /api/worlds
|
||||
# isn't actually seeing what POST /api/worlds/ just created.
|
||||
_check("ended in populated state, not empty (a world was just seeded)", false)
|
||||
failed += 1
|
||||
elif world_list_node.visible:
|
||||
var card_count := world_list_node.get_child_count()
|
||||
print(" Note: rendered %d world card(s)" % card_count)
|
||||
|
||||
if _check("at least one card rendered", card_count > 0):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# The actual bug this test exists to catch: a card can be present
|
||||
# in the tree, not crash, and still be visually nothing because
|
||||
# it rendered at ~1px wide. Check every card's real computed size,
|
||||
# not just that it exists.
|
||||
for i in range(card_count):
|
||||
var card: Control = world_list_node.get_child(i)
|
||||
var wide_enough := card.size.x >= MIN_SANE_CARD_WIDTH
|
||||
if _check("card %d has a real width (%.0fpx >= %.0fpx)" % [i, card.size.x, MIN_SANE_CARD_WIDTH], wide_enough):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# --- Summary ---
|
||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||
if failed > 0:
|
||||
quit(1)
|
||||
else:
|
||||
print("SMOKE RESULT: ALL PASS")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _check(description: String, condition: bool) -> bool:
|
||||
if condition:
|
||||
print(" PASS: ", description)
|
||||
return true
|
||||
else:
|
||||
printerr(" FAIL: ", description)
|
||||
return false
|
||||
@@ -0,0 +1,67 @@
|
||||
class_name StaticObjectNode
|
||||
extends Node3D
|
||||
## A visual representation of a static world object within a chunk.
|
||||
##
|
||||
## This node is instantiated by [ChunkScene] for each static (non-dynamic)
|
||||
## [WorldObject]. It renders the object's mesh (referenced by `asset_id`) at
|
||||
## the object's transform. If the asset is not yet loaded, a placeholder mesh
|
||||
## is shown.
|
||||
##
|
||||
## ## Structure
|
||||
##
|
||||
## ```
|
||||
## StaticObjectNode (Node3D)
|
||||
## └── MeshInstance3D # the object's visual mesh
|
||||
## ```
|
||||
|
||||
## The world object data this node represents.
|
||||
var world_object: WorldObject = null
|
||||
|
||||
## The loaded mesh instance (or placeholder).
|
||||
var _mesh_instance: MeshInstance3D = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if world_object == null:
|
||||
return
|
||||
_load_mesh()
|
||||
|
||||
|
||||
## Load and attach the object's mesh.
|
||||
func _load_mesh() -> void:
|
||||
_mesh_instance = MeshInstance3D.new()
|
||||
_mesh_instance.name = "mesh"
|
||||
|
||||
# Try to load the asset by its id (path). Fall back to a placeholder.
|
||||
var mesh := _load_asset_mesh(world_object.asset_id)
|
||||
if mesh == null:
|
||||
mesh = _make_placeholder_mesh()
|
||||
|
||||
_mesh_instance.mesh = mesh
|
||||
add_child(_mesh_instance)
|
||||
|
||||
|
||||
## Attempt to load a mesh from the asset path.
|
||||
func _load_asset_mesh(asset_id: String) -> Mesh:
|
||||
if asset_id.is_empty():
|
||||
return null
|
||||
var path := "res://assets/%s" % asset_id
|
||||
if ResourceLoader.exists(path):
|
||||
var res := load(path)
|
||||
if res is Mesh:
|
||||
return res
|
||||
return null
|
||||
|
||||
|
||||
## Create a simple placeholder box mesh when the real asset is unavailable.
|
||||
func _make_placeholder_mesh() -> Mesh:
|
||||
var mesh := BoxMesh.new()
|
||||
mesh.size = Vector3(1.0, 1.0, 1.0)
|
||||
return mesh
|
||||
|
||||
|
||||
## Get the world object's display name.
|
||||
func get_display_name() -> String:
|
||||
if world_object == null:
|
||||
return ""
|
||||
return world_object.get_display_name()
|
||||
@@ -0,0 +1 @@
|
||||
uid://chf6mruev1kc4
|
||||
@@ -0,0 +1,42 @@
|
||||
class_name TileDef
|
||||
extends RefCounted
|
||||
## A single tile definition (palette entry) within a tile layer.
|
||||
##
|
||||
## Maps a tile ID to its asset reference and gameplay flags. See
|
||||
## docs/architecture/world.md §3.2.
|
||||
|
||||
## Tile ID (index into the layer palette). 0 is reserved for empty/air.
|
||||
var id: int = 0
|
||||
|
||||
## Reference to the tile/terrain asset.
|
||||
var asset_id: String = ""
|
||||
|
||||
## Whether this tile has collision.
|
||||
var solid: bool = false
|
||||
|
||||
## Bitmask flags: bit 0 = walkable, bit 1 = climbable, bit 2 = liquid.
|
||||
var flags: int = 0
|
||||
|
||||
## Number of visual variants for this tile.
|
||||
var variants: int = 1
|
||||
|
||||
|
||||
func _init(p_id: int = 0, p_asset_id: String = "", p_solid: bool = false,
|
||||
p_flags: int = 0, p_variants: int = 1) -> void:
|
||||
id = p_id
|
||||
asset_id = p_asset_id
|
||||
solid = p_solid
|
||||
flags = p_flags
|
||||
variants = p_variants
|
||||
|
||||
|
||||
func is_walkable() -> bool:
|
||||
return (flags & 0x01) != 0
|
||||
|
||||
|
||||
func is_climbable() -> bool:
|
||||
return (flags & 0x02) != 0
|
||||
|
||||
|
||||
func is_liquid() -> bool:
|
||||
return (flags & 0x04) != 0
|
||||
@@ -0,0 +1 @@
|
||||
uid://bavmpbmthilbx
|
||||
@@ -0,0 +1,83 @@
|
||||
class_name TileLayer
|
||||
extends RefCounted
|
||||
## A single tile layer within a chunk.
|
||||
##
|
||||
## Holds the dense grid of tile IDs, the palette mapping tile IDs to tile
|
||||
## definitions, and the layer metadata. This is the runtime representation of
|
||||
## the `TileLayer` from the chunk data format (see docs/architecture/world.md
|
||||
## §3.2).
|
||||
|
||||
## Layer name (e.g. "ground", "walls", "decor").
|
||||
var name: String = ""
|
||||
|
||||
## Numeric layer id, unique within the chunk.
|
||||
var layer_id: int = 0
|
||||
|
||||
## Grid dimensions in tiles.
|
||||
var width: int = 0
|
||||
var height: int = 0
|
||||
var depth: int = 1
|
||||
|
||||
## Palette mapping tile IDs to tile definitions (shared per layer).
|
||||
var palette: Array[TileDef] = []
|
||||
|
||||
## Dense, row-major tile IDs. Length = width * height * depth.
|
||||
var tiles: PackedInt32Array = PackedInt32Array()
|
||||
|
||||
|
||||
func _init(
|
||||
p_layer_name: String = "",
|
||||
p_layer_id: int = 0,
|
||||
p_width: int = 0,
|
||||
p_height: int = 0,
|
||||
p_depth: int = 1
|
||||
) -> void:
|
||||
name = p_layer_name
|
||||
layer_id = p_layer_id
|
||||
width = p_width
|
||||
height = p_height
|
||||
depth = p_depth
|
||||
|
||||
|
||||
## Total number of tiles in this layer.
|
||||
func tile_count() -> int:
|
||||
return width * height * depth
|
||||
|
||||
|
||||
## Get the tile ID at (x, y, z). Returns 0 (empty) for out-of-bounds.
|
||||
func get_tile(x: int, y: int, z: int = 0) -> int:
|
||||
if x < 0 or x >= width or y < 0 or y >= height or z < 0 or z >= depth:
|
||||
return 0
|
||||
var idx := (z * height + y) * width + x
|
||||
if idx < tiles.size():
|
||||
return tiles[idx]
|
||||
return 0
|
||||
|
||||
|
||||
## Set the tile ID at (x, y, z). No-op if out of bounds.
|
||||
func set_tile(x: int, y: int, z: int, tile_id: int) -> void:
|
||||
if x < 0 or x >= width or y < 0 or y >= height or z < 0 or z >= depth:
|
||||
return
|
||||
var idx := (z * height + y) * width + x
|
||||
if idx < tiles.size():
|
||||
tiles[idx] = tile_id
|
||||
|
||||
|
||||
## Get the tile definition for a tile ID, or null if not in the palette.
|
||||
func get_tile_def(tile_id: int) -> TileDef:
|
||||
for def in palette:
|
||||
if def.id == tile_id:
|
||||
return def
|
||||
return null
|
||||
|
||||
|
||||
## Whether the tile at (x, y, z) is solid (collision).
|
||||
func is_solid(x: int, y: int, z: int = 0) -> bool:
|
||||
var def := get_tile_def(get_tile(x, y, z))
|
||||
return def != null and def.solid
|
||||
|
||||
|
||||
## Whether the tile at (x, y, z) is walkable.
|
||||
func is_walkable(x: int, y: int, z: int = 0) -> bool:
|
||||
var def := get_tile_def(get_tile(x, y, z))
|
||||
return def != null and def.is_walkable()
|
||||
@@ -0,0 +1 @@
|
||||
uid://came2ygke3ajs
|
||||
@@ -0,0 +1,112 @@
|
||||
class_name TileLayerNode
|
||||
extends Node3D
|
||||
## A visual representation of a single tile layer within a chunk.
|
||||
##
|
||||
## This node is instantiated by [ChunkScene] for each tile layer in a chunk.
|
||||
## It renders the layer's tiles as instanced meshes (one per tile definition
|
||||
## in the palette) using [MultiMeshInstance3D] for efficiency.
|
||||
##
|
||||
## ## Structure
|
||||
##
|
||||
## ```
|
||||
## TileLayerNode (Node3D)
|
||||
## └── MultiMeshInstance3D # one per palette entry (tile definition)
|
||||
## ```
|
||||
|
||||
## The tile layer data this node renders.
|
||||
var tile_layer: TileLayer = null
|
||||
|
||||
## The chunk coordinate this layer belongs to (for world positioning).
|
||||
var chunk_coord: Vector3i = Vector3i.ZERO
|
||||
|
||||
## Tile size in world units (each tile is 1x1x1).
|
||||
const TILE_SIZE: float = 1.0
|
||||
|
||||
## Cache of MultiMeshInstance3D nodes, keyed by tile definition id.
|
||||
var _mesh_instances: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if tile_layer == null:
|
||||
return
|
||||
_build_meshes()
|
||||
|
||||
|
||||
## Build the MultiMesh instances for each tile definition in the palette.
|
||||
func _build_meshes() -> void:
|
||||
for tile_def in tile_layer.palette:
|
||||
if tile_def.id == 0:
|
||||
continue # Tile 0 is "empty/air", no mesh needed
|
||||
_create_mesh_for_tile(tile_def)
|
||||
|
||||
|
||||
func _create_mesh_for_tile(tile_def: TileDef) -> void:
|
||||
# Count how many cells use this tile definition
|
||||
var count := 0
|
||||
for tile_id in tile_layer.tiles:
|
||||
if tile_id == tile_def.id:
|
||||
count += 1
|
||||
|
||||
if count == 0:
|
||||
return
|
||||
|
||||
var multi_mesh := MultiMesh.new()
|
||||
multi_mesh.transform_format = MultiMesh.TRANSFORM_3D
|
||||
multi_mesh.mesh = _make_unit_cube_mesh()
|
||||
multi_mesh.instance_count = count
|
||||
|
||||
var instance_idx := 0
|
||||
for z in range(tile_layer.depth):
|
||||
for y in range(tile_layer.height):
|
||||
for x in range(tile_layer.width):
|
||||
var tile_id := tile_layer.get_tile(x, y, z)
|
||||
if tile_id != tile_def.id:
|
||||
continue
|
||||
var world_pos := _tile_to_world(x, y, z)
|
||||
var xform := Transform3D(Basis(), world_pos)
|
||||
multi_mesh.set_instance_transform(instance_idx, xform)
|
||||
instance_idx += 1
|
||||
|
||||
var mesh_instance := MultiMeshInstance3D.new()
|
||||
mesh_instance.multimesh = multi_mesh
|
||||
mesh_instance.name = "tiles_%d" % tile_def.id
|
||||
add_child(mesh_instance)
|
||||
_mesh_instances[tile_def.id] = mesh_instance
|
||||
|
||||
|
||||
## Convert a tile grid coordinate to a world-space position (center of tile).
|
||||
func _tile_to_world(x: int, y: int, z: int) -> Vector3:
|
||||
var origin := Vector3(
|
||||
chunk_coord.x * tile_layer.width,
|
||||
chunk_coord.y * tile_layer.height,
|
||||
chunk_coord.z * tile_layer.depth
|
||||
)
|
||||
return origin + Vector3(
|
||||
x * TILE_SIZE + TILE_SIZE * 0.5,
|
||||
y * TILE_SIZE + TILE_SIZE * 0.5,
|
||||
z * TILE_SIZE + TILE_SIZE * 0.5
|
||||
)
|
||||
|
||||
|
||||
## Create a simple unit cube mesh for tile rendering.
|
||||
func _make_unit_cube_mesh() -> Mesh:
|
||||
var mesh := BoxMesh.new()
|
||||
mesh.size = Vector3(TILE_SIZE, TILE_SIZE, TILE_SIZE)
|
||||
return mesh
|
||||
|
||||
|
||||
## Get the tile definition at a grid position, or null if empty.
|
||||
func get_tile_def_at(x: int, y: int, z: int = 0) -> TileDef:
|
||||
if tile_layer == null:
|
||||
return null
|
||||
var tile_id := tile_layer.get_tile(x, y, z)
|
||||
if tile_id == 0:
|
||||
return null
|
||||
return tile_layer.get_tile_def(tile_id)
|
||||
|
||||
|
||||
## Clear all rendered meshes.
|
||||
func clear() -> void:
|
||||
for child in get_children():
|
||||
child.queue_free()
|
||||
_mesh_instances.clear()
|
||||
@@ -0,0 +1 @@
|
||||
uid://d1x05tq8h43gm
|
||||
@@ -0,0 +1,157 @@
|
||||
extends Control
|
||||
## World list screen — browses public worlds via GET /api/worlds.
|
||||
##
|
||||
## Displays a scrollable list of world cards (name + description).
|
||||
## Handles loading, empty list, errors, and refresh.
|
||||
|
||||
|
||||
# UI references
|
||||
@onready var scroll_container: ScrollContainer = $VBoxContainer/ScrollContainer
|
||||
@onready var world_list: VBoxContainer = $VBoxContainer/ScrollContainer/WorldList
|
||||
@onready var loading_label: Label = $VBoxContainer/LoadingLabel
|
||||
@onready var error_label: Label = $VBoxContainer/ErrorLabel
|
||||
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
|
||||
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
|
||||
@onready var empty_label: Label = $VBoxContainer/EmptyLabel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
refresh_button.disabled = false
|
||||
back_button.disabled = false
|
||||
loading_label.visible = false
|
||||
error_label.visible = false
|
||||
empty_label.visible = false
|
||||
world_list.visible = false
|
||||
_refresh_worlds()
|
||||
|
||||
|
||||
func _on_refresh_button_pressed() -> void:
|
||||
_refresh_worlds()
|
||||
|
||||
|
||||
func _on_back_button_pressed() -> void:
|
||||
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
|
||||
|
||||
|
||||
## Fetch the world list from the backend and update the UI.
|
||||
func _refresh_worlds() -> void:
|
||||
# Reset UI state
|
||||
loading_label.visible = true
|
||||
error_label.visible = false
|
||||
empty_label.visible = false
|
||||
world_list.visible = false
|
||||
refresh_button.disabled = true
|
||||
|
||||
# Clear previous world cards
|
||||
for child in world_list.get_children():
|
||||
child.queue_free()
|
||||
|
||||
var result := await ApiClient.get_worlds()
|
||||
|
||||
loading_label.visible = false
|
||||
refresh_button.disabled = false
|
||||
|
||||
if not result.ok:
|
||||
# Show error message (401, network failure, etc.)
|
||||
error_label.text = "Error: %s" % result.detail
|
||||
error_label.visible = true
|
||||
empty_label.visible = false
|
||||
world_list.visible = false
|
||||
return
|
||||
|
||||
var data = result.data
|
||||
if not data is Array:
|
||||
error_label.text = "Unexpected response format."
|
||||
error_label.visible = true
|
||||
empty_label.visible = false
|
||||
world_list.visible = false
|
||||
return
|
||||
|
||||
if data.is_empty():
|
||||
empty_label.text = "No worlds yet. Check back later!"
|
||||
empty_label.visible = true
|
||||
error_label.visible = false
|
||||
world_list.visible = false
|
||||
return
|
||||
|
||||
# Render world cards
|
||||
world_list.visible = true
|
||||
empty_label.visible = false
|
||||
error_label.visible = false
|
||||
|
||||
for world_dict in data:
|
||||
if not world_dict is Dictionary:
|
||||
continue
|
||||
var card := _create_world_card(world_dict)
|
||||
world_list.add_child(card)
|
||||
|
||||
|
||||
## Create a visual card for a single world entry.
|
||||
func _create_world_card(world_dict: Dictionary) -> Control:
|
||||
# PanelContainer for the background, not a ColorRect moved to child index
|
||||
# 0 of a VBoxContainer -- a VBoxContainer lays out ALL its children in a
|
||||
# vertical sequence, it has no concept of "float this one child behind
|
||||
# the others as an overlay". That version rendered as an empty dark bar
|
||||
# sitting above the text, not a background behind it.
|
||||
var panel := PanelContainer.new()
|
||||
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
panel.custom_minimum_size = Vector2(0, 80)
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.15, 0.15, 0.15, 1.0)
|
||||
style.content_margin_left = 12
|
||||
style.content_margin_right = 12
|
||||
style.content_margin_top = 8
|
||||
style.content_margin_bottom = 8
|
||||
panel.add_theme_stylebox_override("panel", style)
|
||||
|
||||
var card := VBoxContainer.new()
|
||||
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
card.add_theme_constant_override("separation", 4)
|
||||
panel.add_child(card)
|
||||
|
||||
var name_label := Label.new()
|
||||
name_label.text = world_dict.get("name", "Unnamed World")
|
||||
name_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
name_label.size_flags_horizontal = Control.SIZE_FILL
|
||||
name_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
name_label.add_theme_font_size_override("font_size", 18)
|
||||
|
||||
var desc_label := Label.new()
|
||||
var description: String = str(world_dict.get("description", ""))
|
||||
desc_label.text = description if description != "" else "No description."
|
||||
desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
desc_label.size_flags_horizontal = Control.SIZE_FILL
|
||||
desc_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
desc_label.add_theme_font_size_override("font_size", 14)
|
||||
desc_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7, 1.0))
|
||||
|
||||
var meta_label := Label.new()
|
||||
var visibility: String = str(world_dict.get("visibility", "public"))
|
||||
var created: String = str(world_dict.get("created_at", ""))
|
||||
meta_label.text = "Visibility: %s%s" % [visibility, " | Created: " + created if created != "" else ""]
|
||||
meta_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
meta_label.size_flags_horizontal = Control.SIZE_FILL
|
||||
meta_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
meta_label.add_theme_font_size_override("font_size", 12)
|
||||
meta_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1.0))
|
||||
|
||||
# PC2: the only thing a world actually loads into right now is the one
|
||||
# shared playable test level (scenes/game.tscn) -- there's no per-world
|
||||
# content yet, so "Enter" doesn't need the world's id for anything.
|
||||
# Real per-world level loading is a later concern once worlds actually
|
||||
# have their own content to load.
|
||||
var enter_button := Button.new()
|
||||
enter_button.text = "Enter World"
|
||||
enter_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
enter_button.pressed.connect(_on_enter_world_pressed)
|
||||
|
||||
card.add_child(name_label)
|
||||
card.add_child(desc_label)
|
||||
card.add_child(meta_label)
|
||||
card.add_child(enter_button)
|
||||
|
||||
return panel
|
||||
|
||||
|
||||
func _on_enter_world_pressed() -> void:
|
||||
get_tree().change_scene_to_file("res://scenes/game.tscn")
|
||||
@@ -0,0 +1,135 @@
|
||||
class_name WorldObject
|
||||
extends RefCounted
|
||||
## A world object (static or dynamic) within a chunk.
|
||||
##
|
||||
## This is the runtime representation of a WorldObject from the chunk data
|
||||
## format (see docs/architecture/world.md §3.3). It holds the object's
|
||||
## identity, transform, properties, and moderation flags.
|
||||
|
||||
## Stable object ID, unique within the chunk.
|
||||
var id: String = ""
|
||||
|
||||
## Object type (e.g. "prop", "interactable", "spawn_point", "door").
|
||||
var type: String = ""
|
||||
|
||||
## Reference to the object's mesh/glTF asset.
|
||||
var asset_id: String = ""
|
||||
|
||||
## World-space position.
|
||||
var position: Vector3 = Vector3.ZERO
|
||||
|
||||
## World-space rotation as a quaternion (x, y, z, w).
|
||||
var rotation: Vector4 = Vector4(0, 0, 0, 1)
|
||||
|
||||
## World-space scale.
|
||||
var scale: Vector3 = Vector3.ONE
|
||||
|
||||
## Type-specific key/value properties.
|
||||
var properties: Dictionary = {}
|
||||
|
||||
## Whether this is a spawnable dynamic entity (true) or static (false).
|
||||
var is_dynamic: bool = false
|
||||
|
||||
## Per-object content moderation flags.
|
||||
var flags: ChunkDataDecoder.ContentFlags = ChunkDataDecoder.ContentFlags.new()
|
||||
|
||||
## Whether this object has been modified locally.
|
||||
var is_dirty: bool = false
|
||||
|
||||
|
||||
func _init(
|
||||
p_id: String = "",
|
||||
p_type: String = "",
|
||||
p_asset_id: String = "",
|
||||
p_position: Vector3 = Vector3.ZERO,
|
||||
p_rotation: Vector4 = Vector4(0, 0, 0, 1),
|
||||
p_scale: Vector3 = Vector3.ONE,
|
||||
p_properties: Dictionary = {},
|
||||
p_dynamic: bool = false,
|
||||
p_flags: ChunkDataDecoder.ContentFlags = null
|
||||
) -> void:
|
||||
id = p_id
|
||||
type = p_type
|
||||
asset_id = p_asset_id
|
||||
position = p_position
|
||||
rotation = p_rotation
|
||||
scale = p_scale
|
||||
properties = p_properties
|
||||
is_dynamic = p_dynamic
|
||||
if p_flags != null:
|
||||
flags = p_flags
|
||||
|
||||
|
||||
## Get a property value by key.
|
||||
func get_property(key: String) -> Variant:
|
||||
return properties.get(key)
|
||||
|
||||
|
||||
## Set a property value.
|
||||
func set_property(key: String, value: Variant) -> void:
|
||||
properties[key] = value
|
||||
is_dirty = true
|
||||
|
||||
|
||||
## Check if this object is walkable (based on properties).
|
||||
func is_walkable() -> bool:
|
||||
return properties.get("walkable", true)
|
||||
|
||||
|
||||
## Check if this object is climbable (based on properties).
|
||||
func is_climbable() -> bool:
|
||||
return properties.get("climbable", false)
|
||||
|
||||
|
||||
## Check if this object is interactable.
|
||||
func is_interactable() -> bool:
|
||||
return type == "interactable" or properties.get("interactable", false)
|
||||
|
||||
|
||||
## Get the object's display name.
|
||||
func get_display_name() -> String:
|
||||
return properties.get("display_name", id)
|
||||
|
||||
|
||||
## Serialize this object to a dictionary (for networking).
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"id": id,
|
||||
"type": type,
|
||||
"asset_id": asset_id,
|
||||
"position": [position.x, position.y, position.z],
|
||||
"rotation": [rotation.x, rotation.y, rotation.z, rotation.w],
|
||||
"scale": [scale.x, scale.y, scale.z],
|
||||
"properties": properties,
|
||||
"dynamic": is_dynamic,
|
||||
}
|
||||
|
||||
|
||||
## Create a WorldObject from a dictionary (for networking).
|
||||
static func from_dict(d: Dictionary) -> WorldObject:
|
||||
var pos := Vector3(
|
||||
d.get("position", [0, 0, 0])[0],
|
||||
d.get("position", [0, 0, 0])[1],
|
||||
d.get("position", [0, 0, 0])[2]
|
||||
)
|
||||
var rot := Vector4(
|
||||
d.get("rotation", [0, 0, 0, 1])[0],
|
||||
d.get("rotation", [0, 0, 0, 1])[1],
|
||||
d.get("rotation", [0, 0, 0, 1])[2],
|
||||
d.get("rotation", [0, 0, 0, 1])[3]
|
||||
)
|
||||
var scl := Vector3(
|
||||
d.get("scale", [1, 1, 1])[0],
|
||||
d.get("scale", [1, 1, 1])[1],
|
||||
d.get("scale", [1, 1, 1])[2]
|
||||
)
|
||||
return WorldObject.new(
|
||||
d.get("id", ""),
|
||||
d.get("type", ""),
|
||||
d.get("asset_id", ""),
|
||||
pos,
|
||||
rot,
|
||||
scl,
|
||||
d.get("properties", {}),
|
||||
d.get("dynamic", false),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cxut2ghbsvobn
|
||||
Reference in New Issue
Block a user