Compare commits
131 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8822a03f53 | |||
| 6809018ef1 | |||
| 43a3b888da | |||
| 181ba6ae02 | |||
| 4200a8b358 | |||
| 1e917e196b | |||
| eb4da62f99 | |||
| 3fb45f0ef2 | |||
| 1b1260f371 | |||
| 9731615c34 | |||
| 6ad91b895c | |||
| 44bfcb1871 | |||
| 3639fb3132 | |||
| da4e1420ee | |||
| 80f24bfd01 | |||
| 54bc763dff | |||
| 87f78b0226 | |||
| bd1bdc417a | |||
| 7ccdd3063a | |||
| 67473dddcd | |||
| d76bf4da19 | |||
| 4df1caa279 | |||
| 02747cc266 | |||
| 187ec90440 | |||
| fb52c4fd08 | |||
| bc3901da68 | |||
| 7bd8124199 | |||
| 855311e731 | |||
| 50c6a31c25 | |||
| dba865ae4b | |||
| 260b7a38da | |||
| 682f1f89f7 | |||
| 587a15a52e | |||
| e59cf158e5 | |||
| af85048deb | |||
| d6cd3293ae | |||
| f51c49fcfa | |||
| 3596922696 | |||
| 1526f3a636 | |||
| b6e146e4b2 | |||
| 59ca5a4c6e | |||
| b422e41a0f | |||
| bd44ecba14 | |||
| bae326e14c | |||
| 140db9df91 | |||
| 2dff83bae3 | |||
| 1a8821e3c8 | |||
| 019dfca14f | |||
| 7be50e3313 | |||
| 29de1c5c3a | |||
| 92c10fa657 | |||
| 8357643072 | |||
| 736ebce0d0 | |||
| b60480798f | |||
| 77f7511a4f | |||
| 8be962248d | |||
| 7790670559 | |||
| c4eab8db92 | |||
| 4d754ae48e | |||
| e2a77b055f | |||
| 5966fa9044 | |||
| d1d03df2e6 | |||
| 4dda636ed0 | |||
| 945c498a31 | |||
| be0acb1862 | |||
| 8118f3074b | |||
| 4276a89a6a | |||
| 5803f47069 | |||
| e33b91d976 | |||
| 1c3c57dbd0 | |||
| fdc43bd5f1 | |||
| 406ff3457e | |||
| 3f7acf857c | |||
| 6648b2bba5 | |||
| bfcbd0486f | |||
| f70b5b2ee5 | |||
| 2fa7b1f475 | |||
| 7f753cb1b8 | |||
| 1be947f9ef | |||
| 66adcff145 | |||
| 88197ffe8f | |||
| 38d97a3051 | |||
| 174d3c2630 | |||
| b86e4053bd | |||
| 07f655815f | |||
| 2ffcd9cd3f | |||
| aeccc78143 | |||
| 1c64dd4967 | |||
| 4dd6c7c8af | |||
| e90b394f03 | |||
| 33b0c3f1fb | |||
| 2b88fc476e | |||
| 0ecc16d963 | |||
| 4ec71b87b9 | |||
| 8f1a1110e2 | |||
| 0f7531008f | |||
| f5a7162285 | |||
| 61dedcf73e | |||
| 580b95635f | |||
| a81d29c882 | |||
| 4236c4792b | |||
| cf8fd5a6fd | |||
| fc9a39de93 | |||
| 36fd406925 | |||
| ddfe5dfaad | |||
| dcaaf96d81 | |||
| 803405aba4 | |||
| ea6958c11e | |||
| 61ee81a974 | |||
| 62f12e55db | |||
| 4c0af03573 | |||
| 689c51e179 | |||
| 5cfbcd8efd | |||
| 27461d9b5e | |||
| a5dc5cb3b6 | |||
| ea5dd63cd7 | |||
| 5ebd60b0bb | |||
| b2a64a8933 | |||
| 9fcea730b9 | |||
| 5b3fc10649 | |||
| 6018396be2 | |||
| ee6513b8ad | |||
| 37a311b0c5 | |||
| 11e812cb6b | |||
| 5097cdd9f4 | |||
| 2224c34aa8 | |||
| 2c5f6fcb04 | |||
| 7cd41dc920 | |||
| c5d2eb6bd2 | |||
| b650572426 | |||
| 7d84dd5ec8 |
@@ -0,0 +1,39 @@
|
|||||||
|
name: checkpoint
|
||||||
|
|
||||||
|
# Runs the full checkpoint suite (migrations, backend pytest, Godot smoke
|
||||||
|
# tests, multiplayer, fresh-clone boot) on every push and PR.
|
||||||
|
#
|
||||||
|
# Runs on a `host`-label act_runner on the AGP box: Docker, ~/godot/godot,
|
||||||
|
# and Python 3 are already there. Deliberately uses no JavaScript actions
|
||||||
|
# (actions/checkout etc.) so the host needs no Node install -- checkout is a
|
||||||
|
# plain git clone from the local Gitea. See docs/ci.md.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checkpoint:
|
||||||
|
runs-on: host
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
rm -rf repo && mkdir repo && cd repo
|
||||||
|
git init -q
|
||||||
|
git remote add origin "http://x:${{ github.token }}@localhost:3000/${{ github.repository }}.git"
|
||||||
|
git fetch -q --depth 1 origin "${{ github.sha }}"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Python env
|
||||||
|
working-directory: repo
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install --quiet --upgrade pip
|
||||||
|
.venv/bin/pip install --quiet -r backend/requirements.txt
|
||||||
|
|
||||||
|
- name: Checkpoint suite
|
||||||
|
working-directory: repo
|
||||||
|
run: .venv/bin/python scripts/run_checkpoint_tests.py --postgres-port 25690 --backend-port 18690
|
||||||
@@ -11,3 +11,4 @@ Thumbs.db
|
|||||||
|
|
||||||
# Godot UID files (regenerated from path)
|
# Godot UID files (regenerated from path)
|
||||||
*.uid
|
*.uid
|
||||||
|
assets/build_info.json
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="scene"
|
||||||
|
importer_version=1
|
||||||
|
type="PackedScene"
|
||||||
|
uid="uid://bm01jsuomi50i"
|
||||||
|
path="res://.godot/imported/accessory_belt_01.glb-a2b6be72b4422067d290c84868489d2d.scn"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/avatars/accessory_belt_01.glb"
|
||||||
|
dest_files=["res://.godot/imported/accessory_belt_01.glb-a2b6be72b4422067d290c84868489d2d.scn"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
nodes/root_type=""
|
||||||
|
nodes/root_name=""
|
||||||
|
nodes/root_script=null
|
||||||
|
nodes/apply_root_scale=true
|
||||||
|
nodes/root_scale=1.0
|
||||||
|
nodes/import_as_skeleton_bones=false
|
||||||
|
nodes/use_name_suffixes=true
|
||||||
|
nodes/use_node_type_suffixes=true
|
||||||
|
meshes/ensure_tangents=true
|
||||||
|
meshes/generate_lods=true
|
||||||
|
meshes/create_shadow_meshes=true
|
||||||
|
meshes/light_baking=1
|
||||||
|
meshes/lightmap_texel_size=0.2
|
||||||
|
meshes/force_disable_compression=false
|
||||||
|
skins/use_named_skins=true
|
||||||
|
animation/import=true
|
||||||
|
animation/fps=30
|
||||||
|
animation/trimming=false
|
||||||
|
animation/remove_immutable_tracks=true
|
||||||
|
animation/import_rest_as_RESET=false
|
||||||
|
import_script/path=""
|
||||||
|
materials/extract=0
|
||||||
|
materials/extract_format=0
|
||||||
|
materials/extract_path=""
|
||||||
|
_subresources={}
|
||||||
|
gltf/naming_version=2
|
||||||
|
gltf/embedded_image_handling=1
|
||||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="scene"
|
||||||
|
importer_version=1
|
||||||
|
type="PackedScene"
|
||||||
|
uid="uid://dvou487niwnjt"
|
||||||
|
path="res://.godot/imported/accessory_necklace_01.glb-75d713dbb1ff9579d6f0bf8b7ddde1e2.scn"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/avatars/accessory_necklace_01.glb"
|
||||||
|
dest_files=["res://.godot/imported/accessory_necklace_01.glb-75d713dbb1ff9579d6f0bf8b7ddde1e2.scn"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
nodes/root_type=""
|
||||||
|
nodes/root_name=""
|
||||||
|
nodes/root_script=null
|
||||||
|
nodes/apply_root_scale=true
|
||||||
|
nodes/root_scale=1.0
|
||||||
|
nodes/import_as_skeleton_bones=false
|
||||||
|
nodes/use_name_suffixes=true
|
||||||
|
nodes/use_node_type_suffixes=true
|
||||||
|
meshes/ensure_tangents=true
|
||||||
|
meshes/generate_lods=true
|
||||||
|
meshes/create_shadow_meshes=true
|
||||||
|
meshes/light_baking=1
|
||||||
|
meshes/lightmap_texel_size=0.2
|
||||||
|
meshes/force_disable_compression=false
|
||||||
|
skins/use_named_skins=true
|
||||||
|
animation/import=true
|
||||||
|
animation/fps=30
|
||||||
|
animation/trimming=false
|
||||||
|
animation/remove_immutable_tracks=true
|
||||||
|
animation/import_rest_as_RESET=false
|
||||||
|
import_script/path=""
|
||||||
|
materials/extract=0
|
||||||
|
materials/extract_format=0
|
||||||
|
materials/extract_path=""
|
||||||
|
_subresources={}
|
||||||
|
gltf/naming_version=2
|
||||||
|
gltf/embedded_image_handling=1
|
||||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="scene"
|
||||||
|
importer_version=1
|
||||||
|
type="PackedScene"
|
||||||
|
uid="uid://dknbtq33pssf4"
|
||||||
|
path="res://.godot/imported/back_backpack_01.glb-d0ccc39aed8e86403d649c8d07008591.scn"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/avatars/back_backpack_01.glb"
|
||||||
|
dest_files=["res://.godot/imported/back_backpack_01.glb-d0ccc39aed8e86403d649c8d07008591.scn"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
nodes/root_type=""
|
||||||
|
nodes/root_name=""
|
||||||
|
nodes/root_script=null
|
||||||
|
nodes/apply_root_scale=true
|
||||||
|
nodes/root_scale=1.0
|
||||||
|
nodes/import_as_skeleton_bones=false
|
||||||
|
nodes/use_name_suffixes=true
|
||||||
|
nodes/use_node_type_suffixes=true
|
||||||
|
meshes/ensure_tangents=true
|
||||||
|
meshes/generate_lods=true
|
||||||
|
meshes/create_shadow_meshes=true
|
||||||
|
meshes/light_baking=1
|
||||||
|
meshes/lightmap_texel_size=0.2
|
||||||
|
meshes/force_disable_compression=false
|
||||||
|
skins/use_named_skins=true
|
||||||
|
animation/import=true
|
||||||
|
animation/fps=30
|
||||||
|
animation/trimming=false
|
||||||
|
animation/remove_immutable_tracks=true
|
||||||
|
animation/import_rest_as_RESET=false
|
||||||
|
import_script/path=""
|
||||||
|
materials/extract=0
|
||||||
|
materials/extract_format=0
|
||||||
|
materials/extract_path=""
|
||||||
|
_subresources={}
|
||||||
|
gltf/naming_version=2
|
||||||
|
gltf/embedded_image_handling=1
|
||||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="scene"
|
||||||
|
importer_version=1
|
||||||
|
type="PackedScene"
|
||||||
|
uid="uid://brfp6g6pcbbw7"
|
||||||
|
path="res://.godot/imported/base_human_male_01.glb-c917b85c8f8ab5d1974812a6891f052f.scn"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/avatars/base_human_male_01.glb"
|
||||||
|
dest_files=["res://.godot/imported/base_human_male_01.glb-c917b85c8f8ab5d1974812a6891f052f.scn"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
nodes/root_type=""
|
||||||
|
nodes/root_name=""
|
||||||
|
nodes/root_script=null
|
||||||
|
nodes/apply_root_scale=true
|
||||||
|
nodes/root_scale=1.0
|
||||||
|
nodes/import_as_skeleton_bones=false
|
||||||
|
nodes/use_name_suffixes=true
|
||||||
|
nodes/use_node_type_suffixes=true
|
||||||
|
meshes/ensure_tangents=true
|
||||||
|
meshes/generate_lods=true
|
||||||
|
meshes/create_shadow_meshes=true
|
||||||
|
meshes/light_baking=1
|
||||||
|
meshes/lightmap_texel_size=0.2
|
||||||
|
meshes/force_disable_compression=false
|
||||||
|
skins/use_named_skins=true
|
||||||
|
animation/import=true
|
||||||
|
animation/fps=30
|
||||||
|
animation/trimming=false
|
||||||
|
animation/remove_immutable_tracks=true
|
||||||
|
animation/import_rest_as_RESET=false
|
||||||
|
import_script/path=""
|
||||||
|
materials/extract=0
|
||||||
|
materials/extract_format=0
|
||||||
|
materials/extract_path=""
|
||||||
|
_subresources={}
|
||||||
|
gltf/naming_version=2
|
||||||
|
gltf/embedded_image_handling=1
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"assets": {
|
||||||
|
"base_human_male_01": {
|
||||||
|
"type": "base_body",
|
||||||
|
"slot": "base_body",
|
||||||
|
"path": "res://assets/avatars/base_human_male_01.glb",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"display_name": "Human Male Base"
|
||||||
|
},
|
||||||
|
"head_cyber_01": {
|
||||||
|
"type": "attachment",
|
||||||
|
"slot": "head",
|
||||||
|
"path": "res://assets/avatars/head_cyber_01.glb",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"display_name": "Cyber Head",
|
||||||
|
"mount_bone": "Head"
|
||||||
|
},
|
||||||
|
"hair_mohawk_01": {
|
||||||
|
"type": "attachment",
|
||||||
|
"slot": "hair",
|
||||||
|
"path": "res://assets/avatars/hair_mohawk_01.glb",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"display_name": "Mohawk",
|
||||||
|
"mount_bone": "HeadTop"
|
||||||
|
},
|
||||||
|
"hair_ponytail_01": {
|
||||||
|
"type": "attachment",
|
||||||
|
"slot": "hair",
|
||||||
|
"path": "res://assets/avatars/hair_ponytail_01.glb",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"display_name": "Ponytail",
|
||||||
|
"mount_bone": "HeadTop"
|
||||||
|
},
|
||||||
|
"face_glasses_01": {
|
||||||
|
"type": "attachment",
|
||||||
|
"slot": "face",
|
||||||
|
"path": "res://assets/avatars/face_glasses_01.glb",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"display_name": "Glasses",
|
||||||
|
"mount_bone": "Jaw"
|
||||||
|
},
|
||||||
|
"chest_bandit_01": {
|
||||||
|
"type": "attachment",
|
||||||
|
"slot": "chest",
|
||||||
|
"path": "res://assets/avatars/chest_bandit_01.glb",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"display_name": "Bandit Shirt",
|
||||||
|
"mount_bone": "Chest"
|
||||||
|
},
|
||||||
|
"back_backpack_01": {
|
||||||
|
"type": "attachment",
|
||||||
|
"slot": "back",
|
||||||
|
"path": "res://assets/avatars/back_backpack_01.glb",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"display_name": "Backpack",
|
||||||
|
"mount_bone": "Chest"
|
||||||
|
},
|
||||||
|
"pants_cargo_01": {
|
||||||
|
"type": "attachment",
|
||||||
|
"slot": "pants",
|
||||||
|
"path": "res://assets/avatars/pants_cargo_01.glb",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"display_name": "Cargo Pants",
|
||||||
|
"mount_bone": "Hips"
|
||||||
|
},
|
||||||
|
"left_hand_glove_01": {
|
||||||
|
"type": "attachment",
|
||||||
|
"slot": "left_hand",
|
||||||
|
"path": "res://assets/avatars/left_hand_glove_01.glb",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"display_name": "Left Glove",
|
||||||
|
"mount_bone": "LeftHand"
|
||||||
|
},
|
||||||
|
"right_hand_glove_01": {
|
||||||
|
"type": "attachment",
|
||||||
|
"slot": "right_hand",
|
||||||
|
"path": "res://assets/avatars/right_hand_glove_01.glb",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"display_name": "Right Glove",
|
||||||
|
"mount_bone": "RightHand"
|
||||||
|
},
|
||||||
|
"left_foot_boot_01": {
|
||||||
|
"type": "attachment",
|
||||||
|
"slot": "left_foot",
|
||||||
|
"path": "res://assets/avatars/left_foot_boot_01.glb",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"display_name": "Left Boot",
|
||||||
|
"mount_bone": "LeftFoot"
|
||||||
|
},
|
||||||
|
"right_foot_boot_01": {
|
||||||
|
"type": "attachment",
|
||||||
|
"slot": "right_foot",
|
||||||
|
"path": "res://assets/avatars/right_foot_boot_01.glb",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"display_name": "Right Boot",
|
||||||
|
"mount_bone": "RightFoot"
|
||||||
|
},
|
||||||
|
"accessory_belt_01": {
|
||||||
|
"type": "attachment",
|
||||||
|
"slot": "accessory",
|
||||||
|
"path": "res://assets/avatars/accessory_belt_01.glb",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"display_name": "Belt",
|
||||||
|
"mount_bone": "Root"
|
||||||
|
},
|
||||||
|
"accessory_necklace_01": {
|
||||||
|
"type": "attachment",
|
||||||
|
"slot": "accessory",
|
||||||
|
"path": "res://assets/avatars/accessory_necklace_01.glb",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"display_name": "Necklace",
|
||||||
|
"mount_bone": "Root"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="scene"
|
||||||
|
importer_version=1
|
||||||
|
type="PackedScene"
|
||||||
|
uid="uid://bva2etlpa8act"
|
||||||
|
path="res://.godot/imported/chest_bandit_01.glb-90867999feb79e02c469ef45d970f0e1.scn"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/avatars/chest_bandit_01.glb"
|
||||||
|
dest_files=["res://.godot/imported/chest_bandit_01.glb-90867999feb79e02c469ef45d970f0e1.scn"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
nodes/root_type=""
|
||||||
|
nodes/root_name=""
|
||||||
|
nodes/root_script=null
|
||||||
|
nodes/apply_root_scale=true
|
||||||
|
nodes/root_scale=1.0
|
||||||
|
nodes/import_as_skeleton_bones=false
|
||||||
|
nodes/use_name_suffixes=true
|
||||||
|
nodes/use_node_type_suffixes=true
|
||||||
|
meshes/ensure_tangents=true
|
||||||
|
meshes/generate_lods=true
|
||||||
|
meshes/create_shadow_meshes=true
|
||||||
|
meshes/light_baking=1
|
||||||
|
meshes/lightmap_texel_size=0.2
|
||||||
|
meshes/force_disable_compression=false
|
||||||
|
skins/use_named_skins=true
|
||||||
|
animation/import=true
|
||||||
|
animation/fps=30
|
||||||
|
animation/trimming=false
|
||||||
|
animation/remove_immutable_tracks=true
|
||||||
|
animation/import_rest_as_RESET=false
|
||||||
|
import_script/path=""
|
||||||
|
materials/extract=0
|
||||||
|
materials/extract_format=0
|
||||||
|
materials/extract_path=""
|
||||||
|
_subresources={}
|
||||||
|
gltf/naming_version=2
|
||||||
|
gltf/embedded_image_handling=1
|
||||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="scene"
|
||||||
|
importer_version=1
|
||||||
|
type="PackedScene"
|
||||||
|
uid="uid://ctwcxf552y325"
|
||||||
|
path="res://.godot/imported/face_glasses_01.glb-fe06ebb12e6caf265dce06de37a74fc5.scn"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/avatars/face_glasses_01.glb"
|
||||||
|
dest_files=["res://.godot/imported/face_glasses_01.glb-fe06ebb12e6caf265dce06de37a74fc5.scn"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
nodes/root_type=""
|
||||||
|
nodes/root_name=""
|
||||||
|
nodes/root_script=null
|
||||||
|
nodes/apply_root_scale=true
|
||||||
|
nodes/root_scale=1.0
|
||||||
|
nodes/import_as_skeleton_bones=false
|
||||||
|
nodes/use_name_suffixes=true
|
||||||
|
nodes/use_node_type_suffixes=true
|
||||||
|
meshes/ensure_tangents=true
|
||||||
|
meshes/generate_lods=true
|
||||||
|
meshes/create_shadow_meshes=true
|
||||||
|
meshes/light_baking=1
|
||||||
|
meshes/lightmap_texel_size=0.2
|
||||||
|
meshes/force_disable_compression=false
|
||||||
|
skins/use_named_skins=true
|
||||||
|
animation/import=true
|
||||||
|
animation/fps=30
|
||||||
|
animation/trimming=false
|
||||||
|
animation/remove_immutable_tracks=true
|
||||||
|
animation/import_rest_as_RESET=false
|
||||||
|
import_script/path=""
|
||||||
|
materials/extract=0
|
||||||
|
materials/extract_format=0
|
||||||
|
materials/extract_path=""
|
||||||
|
_subresources={}
|
||||||
|
gltf/naming_version=2
|
||||||
|
gltf/embedded_image_handling=1
|
||||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="scene"
|
||||||
|
importer_version=1
|
||||||
|
type="PackedScene"
|
||||||
|
uid="uid://j4dc6l3l0j6f"
|
||||||
|
path="res://.godot/imported/hair_mohawk_01.glb-f2a6883730b5d02facb56d1dbb0e371e.scn"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/avatars/hair_mohawk_01.glb"
|
||||||
|
dest_files=["res://.godot/imported/hair_mohawk_01.glb-f2a6883730b5d02facb56d1dbb0e371e.scn"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
nodes/root_type=""
|
||||||
|
nodes/root_name=""
|
||||||
|
nodes/root_script=null
|
||||||
|
nodes/apply_root_scale=true
|
||||||
|
nodes/root_scale=1.0
|
||||||
|
nodes/import_as_skeleton_bones=false
|
||||||
|
nodes/use_name_suffixes=true
|
||||||
|
nodes/use_node_type_suffixes=true
|
||||||
|
meshes/ensure_tangents=true
|
||||||
|
meshes/generate_lods=true
|
||||||
|
meshes/create_shadow_meshes=true
|
||||||
|
meshes/light_baking=1
|
||||||
|
meshes/lightmap_texel_size=0.2
|
||||||
|
meshes/force_disable_compression=false
|
||||||
|
skins/use_named_skins=true
|
||||||
|
animation/import=true
|
||||||
|
animation/fps=30
|
||||||
|
animation/trimming=false
|
||||||
|
animation/remove_immutable_tracks=true
|
||||||
|
animation/import_rest_as_RESET=false
|
||||||
|
import_script/path=""
|
||||||
|
materials/extract=0
|
||||||
|
materials/extract_format=0
|
||||||
|
materials/extract_path=""
|
||||||
|
_subresources={}
|
||||||
|
gltf/naming_version=2
|
||||||
|
gltf/embedded_image_handling=1
|
||||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="scene"
|
||||||
|
importer_version=1
|
||||||
|
type="PackedScene"
|
||||||
|
uid="uid://dp4vqp2trrank"
|
||||||
|
path="res://.godot/imported/hair_ponytail_01.glb-cdf5913fc63278b1dd362cb640c2ecb4.scn"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/avatars/hair_ponytail_01.glb"
|
||||||
|
dest_files=["res://.godot/imported/hair_ponytail_01.glb-cdf5913fc63278b1dd362cb640c2ecb4.scn"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
nodes/root_type=""
|
||||||
|
nodes/root_name=""
|
||||||
|
nodes/root_script=null
|
||||||
|
nodes/apply_root_scale=true
|
||||||
|
nodes/root_scale=1.0
|
||||||
|
nodes/import_as_skeleton_bones=false
|
||||||
|
nodes/use_name_suffixes=true
|
||||||
|
nodes/use_node_type_suffixes=true
|
||||||
|
meshes/ensure_tangents=true
|
||||||
|
meshes/generate_lods=true
|
||||||
|
meshes/create_shadow_meshes=true
|
||||||
|
meshes/light_baking=1
|
||||||
|
meshes/lightmap_texel_size=0.2
|
||||||
|
meshes/force_disable_compression=false
|
||||||
|
skins/use_named_skins=true
|
||||||
|
animation/import=true
|
||||||
|
animation/fps=30
|
||||||
|
animation/trimming=false
|
||||||
|
animation/remove_immutable_tracks=true
|
||||||
|
animation/import_rest_as_RESET=false
|
||||||
|
import_script/path=""
|
||||||
|
materials/extract=0
|
||||||
|
materials/extract_format=0
|
||||||
|
materials/extract_path=""
|
||||||
|
_subresources={}
|
||||||
|
gltf/naming_version=2
|
||||||
|
gltf/embedded_image_handling=1
|
||||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="scene"
|
||||||
|
importer_version=1
|
||||||
|
type="PackedScene"
|
||||||
|
uid="uid://bkp35fnns36xb"
|
||||||
|
path="res://.godot/imported/head_cyber_01.glb-8081a99d6b31c121d208dd2f9629df5c.scn"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/avatars/head_cyber_01.glb"
|
||||||
|
dest_files=["res://.godot/imported/head_cyber_01.glb-8081a99d6b31c121d208dd2f9629df5c.scn"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
nodes/root_type=""
|
||||||
|
nodes/root_name=""
|
||||||
|
nodes/root_script=null
|
||||||
|
nodes/apply_root_scale=true
|
||||||
|
nodes/root_scale=1.0
|
||||||
|
nodes/import_as_skeleton_bones=false
|
||||||
|
nodes/use_name_suffixes=true
|
||||||
|
nodes/use_node_type_suffixes=true
|
||||||
|
meshes/ensure_tangents=true
|
||||||
|
meshes/generate_lods=true
|
||||||
|
meshes/create_shadow_meshes=true
|
||||||
|
meshes/light_baking=1
|
||||||
|
meshes/lightmap_texel_size=0.2
|
||||||
|
meshes/force_disable_compression=false
|
||||||
|
skins/use_named_skins=true
|
||||||
|
animation/import=true
|
||||||
|
animation/fps=30
|
||||||
|
animation/trimming=false
|
||||||
|
animation/remove_immutable_tracks=true
|
||||||
|
animation/import_rest_as_RESET=false
|
||||||
|
import_script/path=""
|
||||||
|
materials/extract=0
|
||||||
|
materials/extract_format=0
|
||||||
|
materials/extract_path=""
|
||||||
|
_subresources={}
|
||||||
|
gltf/naming_version=2
|
||||||
|
gltf/embedded_image_handling=1
|
||||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="scene"
|
||||||
|
importer_version=1
|
||||||
|
type="PackedScene"
|
||||||
|
uid="uid://bgfjpdu8kg0aj"
|
||||||
|
path="res://.godot/imported/left_foot_boot_01.glb-cab36c9e44befe0a3ff50ee651f2ff67.scn"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/avatars/left_foot_boot_01.glb"
|
||||||
|
dest_files=["res://.godot/imported/left_foot_boot_01.glb-cab36c9e44befe0a3ff50ee651f2ff67.scn"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
nodes/root_type=""
|
||||||
|
nodes/root_name=""
|
||||||
|
nodes/root_script=null
|
||||||
|
nodes/apply_root_scale=true
|
||||||
|
nodes/root_scale=1.0
|
||||||
|
nodes/import_as_skeleton_bones=false
|
||||||
|
nodes/use_name_suffixes=true
|
||||||
|
nodes/use_node_type_suffixes=true
|
||||||
|
meshes/ensure_tangents=true
|
||||||
|
meshes/generate_lods=true
|
||||||
|
meshes/create_shadow_meshes=true
|
||||||
|
meshes/light_baking=1
|
||||||
|
meshes/lightmap_texel_size=0.2
|
||||||
|
meshes/force_disable_compression=false
|
||||||
|
skins/use_named_skins=true
|
||||||
|
animation/import=true
|
||||||
|
animation/fps=30
|
||||||
|
animation/trimming=false
|
||||||
|
animation/remove_immutable_tracks=true
|
||||||
|
animation/import_rest_as_RESET=false
|
||||||
|
import_script/path=""
|
||||||
|
materials/extract=0
|
||||||
|
materials/extract_format=0
|
||||||
|
materials/extract_path=""
|
||||||
|
_subresources={}
|
||||||
|
gltf/naming_version=2
|
||||||
|
gltf/embedded_image_handling=1
|
||||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="scene"
|
||||||
|
importer_version=1
|
||||||
|
type="PackedScene"
|
||||||
|
uid="uid://dtq7tgio1iymd"
|
||||||
|
path="res://.godot/imported/left_hand_glove_01.glb-4b237bea3299c6604547afa353a7b602.scn"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/avatars/left_hand_glove_01.glb"
|
||||||
|
dest_files=["res://.godot/imported/left_hand_glove_01.glb-4b237bea3299c6604547afa353a7b602.scn"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
nodes/root_type=""
|
||||||
|
nodes/root_name=""
|
||||||
|
nodes/root_script=null
|
||||||
|
nodes/apply_root_scale=true
|
||||||
|
nodes/root_scale=1.0
|
||||||
|
nodes/import_as_skeleton_bones=false
|
||||||
|
nodes/use_name_suffixes=true
|
||||||
|
nodes/use_node_type_suffixes=true
|
||||||
|
meshes/ensure_tangents=true
|
||||||
|
meshes/generate_lods=true
|
||||||
|
meshes/create_shadow_meshes=true
|
||||||
|
meshes/light_baking=1
|
||||||
|
meshes/lightmap_texel_size=0.2
|
||||||
|
meshes/force_disable_compression=false
|
||||||
|
skins/use_named_skins=true
|
||||||
|
animation/import=true
|
||||||
|
animation/fps=30
|
||||||
|
animation/trimming=false
|
||||||
|
animation/remove_immutable_tracks=true
|
||||||
|
animation/import_rest_as_RESET=false
|
||||||
|
import_script/path=""
|
||||||
|
materials/extract=0
|
||||||
|
materials/extract_format=0
|
||||||
|
materials/extract_path=""
|
||||||
|
_subresources={}
|
||||||
|
gltf/naming_version=2
|
||||||
|
gltf/embedded_image_handling=1
|
||||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="scene"
|
||||||
|
importer_version=1
|
||||||
|
type="PackedScene"
|
||||||
|
uid="uid://yo204q1m6hs3"
|
||||||
|
path="res://.godot/imported/pants_cargo_01.glb-00f3e3b72fd692344cbb8bfd2b8449f9.scn"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/avatars/pants_cargo_01.glb"
|
||||||
|
dest_files=["res://.godot/imported/pants_cargo_01.glb-00f3e3b72fd692344cbb8bfd2b8449f9.scn"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
nodes/root_type=""
|
||||||
|
nodes/root_name=""
|
||||||
|
nodes/root_script=null
|
||||||
|
nodes/apply_root_scale=true
|
||||||
|
nodes/root_scale=1.0
|
||||||
|
nodes/import_as_skeleton_bones=false
|
||||||
|
nodes/use_name_suffixes=true
|
||||||
|
nodes/use_node_type_suffixes=true
|
||||||
|
meshes/ensure_tangents=true
|
||||||
|
meshes/generate_lods=true
|
||||||
|
meshes/create_shadow_meshes=true
|
||||||
|
meshes/light_baking=1
|
||||||
|
meshes/lightmap_texel_size=0.2
|
||||||
|
meshes/force_disable_compression=false
|
||||||
|
skins/use_named_skins=true
|
||||||
|
animation/import=true
|
||||||
|
animation/fps=30
|
||||||
|
animation/trimming=false
|
||||||
|
animation/remove_immutable_tracks=true
|
||||||
|
animation/import_rest_as_RESET=false
|
||||||
|
import_script/path=""
|
||||||
|
materials/extract=0
|
||||||
|
materials/extract_format=0
|
||||||
|
materials/extract_path=""
|
||||||
|
_subresources={}
|
||||||
|
gltf/naming_version=2
|
||||||
|
gltf/embedded_image_handling=1
|
||||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="scene"
|
||||||
|
importer_version=1
|
||||||
|
type="PackedScene"
|
||||||
|
uid="uid://30ij0eapm6ti"
|
||||||
|
path="res://.godot/imported/right_foot_boot_01.glb-ffff27e561e5632596b7ba89fee5b5ea.scn"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/avatars/right_foot_boot_01.glb"
|
||||||
|
dest_files=["res://.godot/imported/right_foot_boot_01.glb-ffff27e561e5632596b7ba89fee5b5ea.scn"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
nodes/root_type=""
|
||||||
|
nodes/root_name=""
|
||||||
|
nodes/root_script=null
|
||||||
|
nodes/apply_root_scale=true
|
||||||
|
nodes/root_scale=1.0
|
||||||
|
nodes/import_as_skeleton_bones=false
|
||||||
|
nodes/use_name_suffixes=true
|
||||||
|
nodes/use_node_type_suffixes=true
|
||||||
|
meshes/ensure_tangents=true
|
||||||
|
meshes/generate_lods=true
|
||||||
|
meshes/create_shadow_meshes=true
|
||||||
|
meshes/light_baking=1
|
||||||
|
meshes/lightmap_texel_size=0.2
|
||||||
|
meshes/force_disable_compression=false
|
||||||
|
skins/use_named_skins=true
|
||||||
|
animation/import=true
|
||||||
|
animation/fps=30
|
||||||
|
animation/trimming=false
|
||||||
|
animation/remove_immutable_tracks=true
|
||||||
|
animation/import_rest_as_RESET=false
|
||||||
|
import_script/path=""
|
||||||
|
materials/extract=0
|
||||||
|
materials/extract_format=0
|
||||||
|
materials/extract_path=""
|
||||||
|
_subresources={}
|
||||||
|
gltf/naming_version=2
|
||||||
|
gltf/embedded_image_handling=1
|
||||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="scene"
|
||||||
|
importer_version=1
|
||||||
|
type="PackedScene"
|
||||||
|
uid="uid://bcrtxtp47dv8h"
|
||||||
|
path="res://.godot/imported/right_hand_glove_01.glb-a59875b630ac413350c3bdf6acc84bdf.scn"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/avatars/right_hand_glove_01.glb"
|
||||||
|
dest_files=["res://.godot/imported/right_hand_glove_01.glb-a59875b630ac413350c3bdf6acc84bdf.scn"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
nodes/root_type=""
|
||||||
|
nodes/root_name=""
|
||||||
|
nodes/root_script=null
|
||||||
|
nodes/apply_root_scale=true
|
||||||
|
nodes/root_scale=1.0
|
||||||
|
nodes/import_as_skeleton_bones=false
|
||||||
|
nodes/use_name_suffixes=true
|
||||||
|
nodes/use_node_type_suffixes=true
|
||||||
|
meshes/ensure_tangents=true
|
||||||
|
meshes/generate_lods=true
|
||||||
|
meshes/create_shadow_meshes=true
|
||||||
|
meshes/light_baking=1
|
||||||
|
meshes/lightmap_texel_size=0.2
|
||||||
|
meshes/force_disable_compression=false
|
||||||
|
skins/use_named_skins=true
|
||||||
|
animation/import=true
|
||||||
|
animation/fps=30
|
||||||
|
animation/trimming=false
|
||||||
|
animation/remove_immutable_tracks=true
|
||||||
|
animation/import_rest_as_RESET=false
|
||||||
|
import_script/path=""
|
||||||
|
materials/extract=0
|
||||||
|
materials/extract_format=0
|
||||||
|
materials/extract_path=""
|
||||||
|
_subresources={}
|
||||||
|
gltf/naming_version=2
|
||||||
|
gltf/embedded_image_handling=1
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"content_flags": {
|
||||||
|
"violence": "none",
|
||||||
|
"mature": false
|
||||||
|
},
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"trigger": {"type": "on_interact"},
|
||||||
|
"conditions": [],
|
||||||
|
"actions": [
|
||||||
|
{"verb": "give_item", "item_id": "gold_coin", "count": 3},
|
||||||
|
{"verb": "play_sound", "sound_id": "chest_open"},
|
||||||
|
{"verb": "rotate", "target": "self", "degrees": 90, "axis": "y", "duration": 0.4}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"templates": {
|
||||||
|
"small_crate": {
|
||||||
|
"asset_id": "props/crate_small.glb",
|
||||||
|
"display_name": "Small Crate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
from app.models import user, session, world, asset # noqa: F401
|
from app.models import user, session, world, asset, avatar_loadout # noqa: F401
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# access to the values within the .ini file in use.
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""add avatar_loadouts table
|
||||||
|
|
||||||
|
Revision ID: 0004_add_avatar_loadouts_table
|
||||||
|
Revises: 0003_add_refresh_tokens_table
|
||||||
|
Create Date: 2026-09-05 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0004_add_avatar_loadouts_table"
|
||||||
|
down_revision: Union[str, None] = "0003_add_refresh_tokens_table"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create the avatar_loadouts table (one row per user)."""
|
||||||
|
op.create_table(
|
||||||
|
"avatar_loadouts",
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
sa.Column("data", postgresql.JSONB(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop the avatar_loadouts table."""
|
||||||
|
op.drop_table("avatar_loadouts")
|
||||||
@@ -75,11 +75,36 @@ class Settings(BaseSettings):
|
|||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
|
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
|
||||||
|
|
||||||
|
# Per-IP auth rate limiting (app/rate_limit.py). Disabled by the
|
||||||
|
# checkpoint smoke suite, which runs every test from one client IP.
|
||||||
|
RATE_LIMIT_ENABLED: bool = True
|
||||||
|
|
||||||
# File uploads
|
# File uploads
|
||||||
UPLOAD_DIR: str = "uploads"
|
UPLOAD_DIR: str = "uploads"
|
||||||
MAX_UPLOAD_SIZE_MB: int = 100
|
MAX_UPLOAD_SIZE_MB: int = 100
|
||||||
GLTF_MAX_FILE_SIZE_MB: int = 200
|
GLTF_MAX_FILE_SIZE_MB: int = 200
|
||||||
|
|
||||||
|
# ── Game-server orchestration ──────────────────────────────────────────
|
||||||
|
# The join flow (POST /api/worlds/{id}/join) ensures a headless Godot
|
||||||
|
# dedicated-server process exists for a world and returns its address.
|
||||||
|
# Disabled by default so nothing tries to spawn Godot in environments
|
||||||
|
# that don't have it (plain pytest, CI without the engine); the
|
||||||
|
# checkpoint/playtest launcher turns it on.
|
||||||
|
GAME_SERVERS_ENABLED: bool = False
|
||||||
|
# Path to the Godot 4 binary. "godot" resolves via PATH.
|
||||||
|
GODOT_BIN: str = str(Path.home() / "godot" / "godot")
|
||||||
|
# Godot project directory (the game repo root -- backend/ is a subdir).
|
||||||
|
GAME_PROJECT_DIR: str = str(Path(__file__).resolve().parents[2])
|
||||||
|
# The host clients should connect to for ENet. Defaults to the LAN IP the
|
||||||
|
# launcher bakes into the exe; overridable for local testing.
|
||||||
|
GAME_SERVER_HOST: str = "127.0.0.1"
|
||||||
|
# Port range for per-world dedicated servers.
|
||||||
|
GAME_SERVER_PORT_MIN: int = 27000
|
||||||
|
GAME_SERVER_PORT_MAX: int = 27099
|
||||||
|
# Kill a world's server this long after its last join with no active
|
||||||
|
# players reported.
|
||||||
|
GAME_SERVER_IDLE_TIMEOUT_SECONDS: int = 300
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
|
|||||||
+7
-3
@@ -6,15 +6,18 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.routers import auth, users, worlds, assets
|
from app.routers import auth, users, worlds, assets, avatar
|
||||||
|
|
||||||
|
|
||||||
|
from app.services.game_servers import supervisor
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan: startup and shutdown events."""
|
"""Application lifespan: startup and shutdown events."""
|
||||||
# Startup: initialize database, connect to Redis, etc.
|
supervisor.start()
|
||||||
yield
|
yield
|
||||||
# Shutdown: close connections, cleanup
|
await supervisor.shutdown()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -38,6 +41,7 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
|||||||
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||||
app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"])
|
app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"])
|
||||||
app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
|
app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
|
||||||
|
app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ from app.models.user import User
|
|||||||
from app.models.session import Session
|
from app.models.session import Session
|
||||||
from app.models.world import World
|
from app.models.world import World
|
||||||
from app.models.asset import Asset
|
from app.models.asset import Asset
|
||||||
|
from app.models.avatar_loadout import AvatarLoadout
|
||||||
|
|
||||||
__all__ = ["User", "Session", "World", "Asset"]
|
__all__ = ["User", "Session", "World", "Asset", "AvatarLoadout"]
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from sqlalchemy import Column, Integer, ForeignKey, DateTime
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
# The default a brand-new account gets before they touch the customization
|
||||||
|
# screen. Kept in sync with assets/avatars/catalog.json (the base_body id) and
|
||||||
|
# with the client's AvatarLoadout.to_json() shape.
|
||||||
|
DEFAULT_LOADOUT: dict = {
|
||||||
|
"base_body": "base_human_male_01",
|
||||||
|
"slots": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarLoadout(Base):
|
||||||
|
"""A user's equipped avatar composition.
|
||||||
|
|
||||||
|
One row per user. The whole loadout is stored as a single JSON document
|
||||||
|
matching the client's ``AvatarLoadout.to_json()`` shape
|
||||||
|
(``{"base_body": str, "slots": {...}, "skin_tone"?: {...}}``) rather than
|
||||||
|
exploded into columns -- the server has no copy of the asset catalog, so
|
||||||
|
deep validation (does this asset exist, is it valid for this slot) stays
|
||||||
|
on the client. The server only enforces the envelope: valid JSON object
|
||||||
|
with a non-empty ``base_body``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "avatar_loadouts"
|
||||||
|
|
||||||
|
user_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
data = Column(JSONB, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="avatar_loadout")
|
||||||
@@ -24,6 +24,12 @@ class User(Base):
|
|||||||
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
|
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
|
||||||
worlds = relationship("World", back_populates="owner")
|
worlds = relationship("World", back_populates="owner")
|
||||||
assets = relationship("Asset", back_populates="owner")
|
assets = relationship("Asset", back_populates="owner")
|
||||||
|
avatar_loadout = relationship(
|
||||||
|
"AvatarLoadout",
|
||||||
|
back_populates="user",
|
||||||
|
uselist=False,
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_adult(self) -> bool:
|
def is_adult(self) -> bool:
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Lightweight in-process rate limiting.
|
||||||
|
|
||||||
|
The backend runs single-worker (required by the game-server supervisor), so a
|
||||||
|
process-local sliding-window counter is sufficient and needs no Redis. Keyed
|
||||||
|
by client IP. Used to blunt credential brute-force on the auth endpoints.
|
||||||
|
|
||||||
|
If the backend is ever scaled to multiple workers/hosts this must move to a
|
||||||
|
shared store (Redis) -- flagged here rather than silently under-enforcing.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request, status
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class SlidingWindowLimiter:
|
||||||
|
def __init__(self, max_hits: int, window_seconds: float) -> None:
|
||||||
|
self.max_hits = max_hits
|
||||||
|
self.window = window_seconds
|
||||||
|
self._hits: dict[str, deque[float]] = defaultdict(deque)
|
||||||
|
|
||||||
|
def check(self, key: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
q = self._hits[key]
|
||||||
|
cutoff = now - self.window
|
||||||
|
while q and q[0] < cutoff:
|
||||||
|
q.popleft()
|
||||||
|
if len(q) >= self.max_hits:
|
||||||
|
retry = max(1, int(q[0] + self.window - now))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Too many attempts. Try again shortly.",
|
||||||
|
headers={"Retry-After": str(retry)},
|
||||||
|
)
|
||||||
|
q.append(now)
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._hits.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _client_key(request: Request) -> str:
|
||||||
|
# X-Forwarded-For first hop when behind the reverse proxy, else the socket.
|
||||||
|
fwd = request.headers.get("x-forwarded-for", "")
|
||||||
|
if fwd:
|
||||||
|
return fwd.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# 10 auth attempts / minute / IP -- generous for a human, painful for a
|
||||||
|
# brute-forcer.
|
||||||
|
_auth_limiter = SlidingWindowLimiter(max_hits=10, window_seconds=60)
|
||||||
|
|
||||||
|
|
||||||
|
async def auth_rate_limit(request: Request) -> None:
|
||||||
|
"""FastAPI dependency: rate-limit auth attempts per client IP.
|
||||||
|
|
||||||
|
A no-op when ``RATE_LIMIT_ENABLED`` is false -- the checkpoint smoke
|
||||||
|
suite runs every test against one backend from one client IP, so the
|
||||||
|
per-IP cap would trip mid-suite. The dedicated ``test_rate_limit``
|
||||||
|
module exercises the real behaviour regardless of this flag.
|
||||||
|
"""
|
||||||
|
if not settings.RATE_LIMIT_ENABLED:
|
||||||
|
return
|
||||||
|
_auth_limiter.check(f"auth:{_client_key(request)}")
|
||||||
|
|
||||||
|
|
||||||
|
def reset_all() -> None:
|
||||||
|
"""Test hook."""
|
||||||
|
_auth_limiter.reset()
|
||||||
+31
-23
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from pydantic import BaseModel, EmailStr, field_validator
|
from pydantic import BaseModel, EmailStr
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -16,11 +16,15 @@ from app.database import get_db
|
|||||||
from app.dependencies import CurrentUser
|
from app.dependencies import CurrentUser
|
||||||
from app.models.session import RefreshToken
|
from app.models.session import RefreshToken
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.rate_limit import auth_rate_limit
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
## Minimum age to register. This platform is adults-only.
|
||||||
|
MIN_AGE = 18
|
||||||
|
|
||||||
|
|
||||||
# --- Pydantic schemas ---
|
# --- Pydantic schemas ---
|
||||||
|
|
||||||
@@ -39,24 +43,19 @@ class RegisterRequest(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str
|
||||||
date_of_birth: str # ISO format date string (YYYY-MM-DD)
|
# Real date of birth -- the server computes age from it and rejects
|
||||||
|
# anyone under MIN_AGE. Still self-attested (no document check yet), but
|
||||||
@field_validator("date_of_birth")
|
# a concrete, stored, legally-meaningful data point rather than a
|
||||||
@classmethod
|
# throwaway checkbox, and the seam for a future hard ID-verification
|
||||||
def validate_date_of_birth(cls, v: str) -> str:
|
# pass. See docs/architecture/content_moderation.md 5.2.
|
||||||
"""Validate that the date string is a valid date in YYYY-MM-DD format."""
|
date_of_birth: date
|
||||||
try:
|
|
||||||
datetime.strptime(v, "%Y-%m-%d")
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError("date_of_birth must be in YYYY-MM-DD format")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
email: str
|
email: str
|
||||||
date_of_birth: str | None = None
|
date_of_birth: date | None = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
|
|
||||||
@@ -118,6 +117,10 @@ def hash_password(password: str) -> str:
|
|||||||
def calculate_age(birth_date: date, today: date | None = None) -> int:
|
def calculate_age(birth_date: date, today: date | None = None) -> int:
|
||||||
"""Calculate age in years from a birth date.
|
"""Calculate age in years from a birth date.
|
||||||
|
|
||||||
|
Used by /register (PC3 Phase 3) to compute age from the submitted
|
||||||
|
date_of_birth and enforce the 18+ gate. See
|
||||||
|
docs/architecture/content_moderation.md 5.2.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
birth_date: The person's date of birth.
|
birth_date: The person's date of birth.
|
||||||
today: The reference date. Defaults to today's date (UTC).
|
today: The reference date. Defaults to today's date (UTC).
|
||||||
@@ -151,20 +154,25 @@ def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
|
|||||||
|
|
||||||
# --- Endpoints ---
|
# --- Endpoints ---
|
||||||
|
|
||||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(auth_rate_limit)])
|
||||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||||
"""Register a new user and return access + refresh tokens.
|
"""Register a new user and return access + refresh tokens.
|
||||||
|
|
||||||
Requires date_of_birth and rejects users under 18.
|
Requires a date of birth; the server computes age and rejects anyone
|
||||||
|
under MIN_AGE (18). See docs/architecture/content_moderation.md for the
|
||||||
|
verification design and its self-attestation limitation.
|
||||||
"""
|
"""
|
||||||
# Parse and validate date_of_birth
|
age = calculate_age(req.date_of_birth)
|
||||||
dob = datetime.strptime(req.date_of_birth, "%Y-%m-%d").date()
|
if age < 0 or req.date_of_birth > date.today():
|
||||||
age = calculate_age(dob)
|
|
||||||
|
|
||||||
if age < 18:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Must be 18 or older to register (provided date of birth: {req.date_of_birth})",
|
detail="Invalid date of birth",
|
||||||
|
)
|
||||||
|
if age < MIN_AGE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"You must be at least {MIN_AGE} to use this platform",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if username already exists
|
# Check if username already exists
|
||||||
@@ -187,7 +195,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
username=req.username,
|
username=req.username,
|
||||||
email=req.email,
|
email=req.email,
|
||||||
hashed_password=hash_password(req.password),
|
hashed_password=hash_password(req.password),
|
||||||
date_of_birth=dob,
|
date_of_birth=req.date_of_birth,
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush() # flush to get user.id before creating refresh token
|
await db.flush() # flush to get user.id before creating refresh token
|
||||||
@@ -207,7 +215,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse, dependencies=[Depends(auth_rate_limit)])
|
||||||
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
||||||
"""Authenticate user and return access + refresh tokens.
|
"""Authenticate user and return access + refresh tokens.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Avatar router - the current user's persisted avatar loadout.
|
||||||
|
|
||||||
|
The loadout is a single JSON document matching the client's
|
||||||
|
``AvatarLoadout.to_json()`` shape. The server has no copy of the asset
|
||||||
|
catalog, so it only validates the envelope (JSON object, non-empty
|
||||||
|
``base_body``); which assets are valid for which slot is the client's job.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import CurrentUser
|
||||||
|
from app.models.avatar_loadout import AvatarLoadout, DEFAULT_LOADOUT
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
|
||||||
|
# A loadout document is small; reject anything absurd outright.
|
||||||
|
MAX_LOADOUT_BYTES = 16 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class LoadoutResponse(BaseModel):
|
||||||
|
data: dict[str, Any]
|
||||||
|
is_default: bool = Field(
|
||||||
|
description="True when the user has never saved a loadout and this is "
|
||||||
|
"the server default."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LoadoutUpdate(BaseModel):
|
||||||
|
data: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_envelope(data: dict[str, Any]) -> None:
|
||||||
|
base_body = data.get("base_body")
|
||||||
|
if not isinstance(base_body, str) or not base_body.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="loadout.data must have a non-empty string 'base_body'",
|
||||||
|
)
|
||||||
|
slots = data.get("slots", {})
|
||||||
|
if not isinstance(slots, dict):
|
||||||
|
raise HTTPException(status_code=422, detail="loadout.data.slots must be an object")
|
||||||
|
if len(str(data)) > MAX_LOADOUT_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="loadout document too large")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/loadout", response_model=LoadoutResponse)
|
||||||
|
async def get_loadout(current_user: CurrentUser, db: DbDep):
|
||||||
|
"""Return the current user's saved loadout, or the server default if they
|
||||||
|
have never saved one."""
|
||||||
|
row = await db.get(AvatarLoadout, current_user.id)
|
||||||
|
if row is None:
|
||||||
|
return LoadoutResponse(data=dict(DEFAULT_LOADOUT), is_default=True)
|
||||||
|
return LoadoutResponse(data=row.data, is_default=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/loadout", response_model=LoadoutResponse)
|
||||||
|
async def put_loadout(payload: LoadoutUpdate, current_user: CurrentUser, db: DbDep):
|
||||||
|
"""Replace the current user's loadout."""
|
||||||
|
_validate_envelope(payload.data)
|
||||||
|
|
||||||
|
row = await db.get(AvatarLoadout, current_user.id)
|
||||||
|
if row is None:
|
||||||
|
row = AvatarLoadout(user_id=current_user.id, data=payload.data)
|
||||||
|
db.add(row)
|
||||||
|
else:
|
||||||
|
row.data = payload.data
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(row)
|
||||||
|
return LoadoutResponse(data=row.data, is_default=False)
|
||||||
@@ -12,6 +12,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.dependencies import CurrentUser
|
from app.dependencies import CurrentUser
|
||||||
from app.models.world import World, WorldVisibility
|
from app.models.world import World, WorldVisibility
|
||||||
|
from app.services.game_servers import supervisor
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -106,6 +107,40 @@ async def get_world(
|
|||||||
return world
|
return world
|
||||||
|
|
||||||
|
|
||||||
|
class JoinResponse(BaseModel):
|
||||||
|
"""Address of the dedicated server hosting this world."""
|
||||||
|
world_id: int
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{world_id}/join", response_model=JoinResponse)
|
||||||
|
async def join_world(
|
||||||
|
world_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbDep,
|
||||||
|
):
|
||||||
|
"""Ensure a dedicated game server is running for this world and return its
|
||||||
|
ENet address for the client to connect to.
|
||||||
|
|
||||||
|
503 if game-server orchestration isn't enabled on this backend (e.g. a
|
||||||
|
plain dev/test instance with no Godot binary).
|
||||||
|
"""
|
||||||
|
result = await db.execute(select(World).where(World.id == world_id))
|
||||||
|
world = result.scalar_one_or_none()
|
||||||
|
if not world:
|
||||||
|
raise HTTPException(status_code=404, detail="World not found")
|
||||||
|
if world.visibility != WorldVisibility.PUBLIC and world.owner_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=404, detail="World not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = await supervisor.ensure_server(str(world_id))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc))
|
||||||
|
|
||||||
|
return JoinResponse(world_id=world_id, host=info["host"], port=info["port"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=WorldResponse, status_code=201)
|
@router.post("/", response_model=WorldResponse, status_code=201)
|
||||||
async def create_world(
|
async def create_world(
|
||||||
world_data: WorldCreate,
|
world_data: WorldCreate,
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""Game-server orchestration.
|
||||||
|
|
||||||
|
On ``POST /api/worlds/{id}/join`` the backend ensures a headless Godot
|
||||||
|
dedicated-server process is running for that world and returns its address.
|
||||||
|
One process per world, keyed by ``world_id``. A background reaper kills a
|
||||||
|
world's server once its status file reports zero players for
|
||||||
|
``GAME_SERVER_IDLE_TIMEOUT_SECONDS``.
|
||||||
|
|
||||||
|
This lives in-process (a module-level singleton) rather than as a separate
|
||||||
|
service, so the backend MUST run single-worker -- which it does
|
||||||
|
(``uvicorn app.main:app`` with no ``--workers``). The Godot binary and
|
||||||
|
project are on the host (see ``settings.GODOT_BIN`` / ``GAME_PROJECT_DIR``);
|
||||||
|
the backend spawns them as subprocesses.
|
||||||
|
|
||||||
|
Design notes / limits (PC3):
|
||||||
|
- All worlds currently load the same test level (per-world content is M2/M7).
|
||||||
|
``world_id`` still scopes a distinct server process + session.
|
||||||
|
- Port allocation is a linear scan of the configured range.
|
||||||
|
- No crash-restart: if a server process dies, the next join respawns it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _WorldServer:
|
||||||
|
world_id: str
|
||||||
|
port: int
|
||||||
|
proc: subprocess.Popen
|
||||||
|
status_path: Path
|
||||||
|
started_at: float = field(default_factory=time.time)
|
||||||
|
last_join: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads(self.status_path.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def player_count(self) -> int:
|
||||||
|
return int(self.status().get("players", 0))
|
||||||
|
|
||||||
|
def alive(self) -> bool:
|
||||||
|
return self.proc.poll() is None
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if self.alive():
|
||||||
|
self.proc.terminate()
|
||||||
|
try:
|
||||||
|
self.proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.proc.kill()
|
||||||
|
self.status_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class GameServerSupervisor:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._servers: dict[str, _WorldServer] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._reaper: asyncio.Task | None = None
|
||||||
|
self._runtime_dir = Path(tempfile.gettempdir()) / "agp-game-servers"
|
||||||
|
self._runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# ── lifecycle ────────────────────────────────────────────────────────
|
||||||
|
def start(self) -> None:
|
||||||
|
if not settings.GAME_SERVERS_ENABLED:
|
||||||
|
return
|
||||||
|
if self._reaper is None or self._reaper.done():
|
||||||
|
self._reaper = asyncio.create_task(self._reap_loop())
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
if self._reaper:
|
||||||
|
self._reaper.cancel()
|
||||||
|
async with self._lock:
|
||||||
|
for srv in list(self._servers.values()):
|
||||||
|
srv.stop()
|
||||||
|
self._servers.clear()
|
||||||
|
|
||||||
|
# ── join ─────────────────────────────────────────────────────────────
|
||||||
|
async def ensure_server(self, world_id: str) -> dict:
|
||||||
|
"""Return ``{"host", "port", "world_id"}`` for the world's dedicated
|
||||||
|
server, spawning it if needed. Raises RuntimeError if orchestration
|
||||||
|
is disabled or no port/engine is available."""
|
||||||
|
if not settings.GAME_SERVERS_ENABLED:
|
||||||
|
raise RuntimeError("game servers are not enabled on this backend")
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
srv = self._servers.get(world_id)
|
||||||
|
if srv is not None and not srv.alive():
|
||||||
|
srv.stop()
|
||||||
|
srv = None
|
||||||
|
del self._servers[world_id]
|
||||||
|
|
||||||
|
if srv is None:
|
||||||
|
srv = self._spawn(world_id)
|
||||||
|
self._servers[world_id] = srv
|
||||||
|
# Give the engine a moment to bind the ENet port before we
|
||||||
|
# hand the address to a client.
|
||||||
|
await self._await_port(srv.port)
|
||||||
|
|
||||||
|
srv.last_join = time.time()
|
||||||
|
return {"host": settings.GAME_SERVER_HOST, "port": srv.port, "world_id": world_id}
|
||||||
|
|
||||||
|
# ── internals ────────────────────────────────────────────────────────
|
||||||
|
def _spawn(self, world_id: str) -> _WorldServer:
|
||||||
|
port = self._free_port()
|
||||||
|
status_path = self._runtime_dir / f"world_{world_id}_{port}.json"
|
||||||
|
status_path.unlink(missing_ok=True)
|
||||||
|
cmd = [
|
||||||
|
settings.GODOT_BIN, "--headless",
|
||||||
|
"--path", settings.GAME_PROJECT_DIR,
|
||||||
|
"res://scenes/server.tscn", "--",
|
||||||
|
"--port", str(port),
|
||||||
|
"--world", str(world_id),
|
||||||
|
"--status-file", str(status_path),
|
||||||
|
]
|
||||||
|
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
return _WorldServer(world_id=world_id, port=port, proc=proc, status_path=status_path)
|
||||||
|
|
||||||
|
def _free_port(self) -> int:
|
||||||
|
used = {s.port for s in self._servers.values()}
|
||||||
|
for p in range(settings.GAME_SERVER_PORT_MIN, settings.GAME_SERVER_PORT_MAX + 1):
|
||||||
|
if p in used:
|
||||||
|
continue
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||||
|
try:
|
||||||
|
s.bind(("0.0.0.0", p))
|
||||||
|
return p
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
raise RuntimeError("no free game-server port in the configured range")
|
||||||
|
|
||||||
|
async def _await_port(self, port: int, timeout: float = 8.0) -> None:
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
# ENet is UDP; a bind failure means something (our server) has it.
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||||
|
try:
|
||||||
|
s.bind(("0.0.0.0", port))
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
|
async def _reap_loop(self) -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
async with self._lock:
|
||||||
|
now = time.time()
|
||||||
|
for wid, srv in list(self._servers.items()):
|
||||||
|
if not srv.alive():
|
||||||
|
srv.stop()
|
||||||
|
del self._servers[wid]
|
||||||
|
continue
|
||||||
|
idle_for = now - srv.last_join
|
||||||
|
if idle_for > settings.GAME_SERVER_IDLE_TIMEOUT_SECONDS and srv.player_count() == 0:
|
||||||
|
srv.stop()
|
||||||
|
del self._servers[wid]
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def debug_state(self) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"world_id": s.world_id, "port": s.port, "alive": s.alive(),
|
||||||
|
"players": s.player_count(), "uptime_s": round(time.time() - s.started_at),
|
||||||
|
}
|
||||||
|
for s in self._servers.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
supervisor = GameServerSupervisor()
|
||||||
+150
-14
@@ -1,38 +1,174 @@
|
|||||||
"""Shared pytest fixtures.
|
"""Shared pytest fixtures + hermetic-DB bootstrap.
|
||||||
|
|
||||||
The auth integration tests hit the *real* Postgres database (per
|
The integration tests hit a real Postgres (Postgres-specific types: JSONB,
|
||||||
``DATABASE_URL``), so they persist data between runs. Without cleanup a
|
native ENUMs). To make ``cd backend && pytest`` work from a clean checkout
|
||||||
second run of the suite fails with 409 conflicts because the users it
|
with no manual setup, ``pytest_configure`` below brings up an ephemeral
|
||||||
registered on the first run already exist. The session-scoped fixture below
|
Postgres via docker compose and runs the migrations IF ``DATABASE_URL`` is
|
||||||
truncates all application tables once before the first test, making the suite
|
not already pointing at a reachable database. When it is (dev loop, or the
|
||||||
re-runnable.
|
checkpoint runner which stands the DB up itself), that path is skipped and
|
||||||
|
the existing database is reused.
|
||||||
|
|
||||||
Truncation order doesn't strictly matter because ``CASCADE`` follows foreign
|
If Docker isn't available and no DB is reachable, the whole DB-backed suite
|
||||||
keys, but the tables are listed explicitly for clarity.
|
is skipped with a clear message rather than erroring out 60+ times.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
TEST_TABLES = ("assets", "worlds", "sessions", "refresh_tokens", "users")
|
TEST_TABLES = ("avatar_loadouts", "assets", "worlds", "sessions", "refresh_tokens", "users")
|
||||||
|
|
||||||
|
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
_COMPOSE_PROJECT = "agp-pytest"
|
||||||
|
_EPHEMERAL_PG_PORT = 25432
|
||||||
|
|
||||||
|
# Set by _ensure_database() when it starts a container it owns; torn down in
|
||||||
|
# pytest_unconfigure.
|
||||||
|
_started_container = False
|
||||||
|
_db_unavailable_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _port_open(host: str, port: int, timeout: float = 1.0) -> bool:
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=timeout):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _db_reachable(url: str) -> bool:
|
||||||
|
parts = urlsplit(url)
|
||||||
|
return _port_open(parts.hostname or "localhost", parts.port or 5432)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(cmd, cwd=BACKEND_DIR, capture_output=True, text=True, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
global _started_container, _db_unavailable_reason
|
||||||
|
|
||||||
|
if _db_reachable(settings.DATABASE_URL):
|
||||||
|
return # dev loop / checkpoint runner already provided one
|
||||||
|
|
||||||
|
if not _run(["docker", "--version"]).returncode == 0:
|
||||||
|
_db_unavailable_reason = (
|
||||||
|
"no reachable DATABASE_URL and docker is unavailable -- run the "
|
||||||
|
"checkpoint suite (scripts/run_checkpoint_tests.py) or start Postgres "
|
||||||
|
"manually"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT
|
||||||
|
env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT)
|
||||||
|
env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT)
|
||||||
|
up = _run(["docker", "compose", "up", "-d", "postgres"], env=env)
|
||||||
|
if up.returncode != 0:
|
||||||
|
_db_unavailable_reason = f"docker compose up postgres failed:\n{up.stderr[-800:]}"
|
||||||
|
return
|
||||||
|
_started_container = True
|
||||||
|
|
||||||
|
for _ in range(30):
|
||||||
|
r = _run(
|
||||||
|
["docker", "compose", "exec", "-T", "postgres",
|
||||||
|
"pg_isready", "-U", "agp_user", "-d", "agp_db"],
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if r.returncode == 0:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
_db_unavailable_reason = "ephemeral Postgres never became ready"
|
||||||
|
return
|
||||||
|
|
||||||
|
url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{_EPHEMERAL_PG_PORT}/agp_db"
|
||||||
|
os.environ["DATABASE_URL"] = url
|
||||||
|
settings.DATABASE_URL = url
|
||||||
|
|
||||||
|
mig = _run([sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||||
|
env={**os.environ, "DATABASE_URL": url})
|
||||||
|
if mig.returncode != 0:
|
||||||
|
_db_unavailable_reason = f"alembic upgrade head failed:\n{mig.stderr[-800:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_unconfigure(config):
|
||||||
|
if not _started_container:
|
||||||
|
return
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT
|
||||||
|
env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT)
|
||||||
|
env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT)
|
||||||
|
_run(["docker", "compose", "down", "-v"], env=env)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
def _reset_database():
|
def _reset_database():
|
||||||
"""Truncate all application tables once per test session."""
|
"""Truncate all application tables once per test session."""
|
||||||
|
if _db_unavailable_reason:
|
||||||
|
pytest.skip(_db_unavailable_reason, allow_module_level=True)
|
||||||
|
|
||||||
async def _truncate() -> None:
|
async def _truncate() -> None:
|
||||||
engine = create_async_engine(settings.DATABASE_URL)
|
engine = create_async_engine(settings.DATABASE_URL)
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.execute(
|
await conn.execute(text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE"))
|
||||||
text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE")
|
|
||||||
)
|
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
asyncio.run(_truncate())
|
asyncio.run(_truncate())
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_rate_limiter():
|
||||||
|
"""Every TestClient request looks like the same client IP, so the
|
||||||
|
per-IP auth limiter would trip mid-suite. Clear it between tests; the
|
||||||
|
dedicated rate-limit test disables this to exercise the real behaviour.
|
||||||
|
"""
|
||||||
|
from app import rate_limit
|
||||||
|
rate_limit.reset_all()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _override_db():
|
||||||
|
"""Override get_db so the app uses a fresh session per test.
|
||||||
|
|
||||||
|
Deliberately does NOT reuse app.database's shared engine: FastAPI's
|
||||||
|
(sync) TestClient runs each request through anyio's blocking-portal
|
||||||
|
machinery, which can hand different requests different event loops.
|
||||||
|
asyncpg connections are bound to the loop they were created on, so
|
||||||
|
reusing one from the shared pool across requests raises "attached to a
|
||||||
|
different loop" the moment a test makes more than one request. NullPool
|
||||||
|
sidesteps it: every checkout is a brand-new connection.
|
||||||
|
"""
|
||||||
|
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||||
|
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async def _get_db():
|
||||||
|
async_session = test_session_factory()
|
||||||
|
try:
|
||||||
|
yield async_session
|
||||||
|
await async_session.commit()
|
||||||
|
except Exception:
|
||||||
|
await async_session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await async_session.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _get_db
|
||||||
|
yield
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|||||||
@@ -3,92 +3,102 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.routers.auth import calculate_age
|
from app.main import app
|
||||||
|
from app.routers.auth import calculate_age, MIN_AGE, RegisterRequest
|
||||||
|
|
||||||
|
|
||||||
class TestCalculateAge:
|
class TestCalculateAge:
|
||||||
"""Unit tests for the calculate_age helper."""
|
"""Unit tests for the calculate_age helper."""
|
||||||
|
|
||||||
def test_exactly_18(self):
|
def test_exactly_18(self):
|
||||||
"""A person born exactly 18 years ago today is 18."""
|
today = date(2026, 6, 15)
|
||||||
today = date(2025, 6, 15)
|
|
||||||
dob = today.replace(year=today.year - 18)
|
dob = today.replace(year=today.year - 18)
|
||||||
assert calculate_age(dob, today=today) == 18
|
assert calculate_age(dob, today=today) == 18
|
||||||
|
|
||||||
def test_just_turned_18(self):
|
def test_just_turned_18(self):
|
||||||
"""A person who turned 18 yesterday is 18."""
|
today = date(2026, 6, 15)
|
||||||
today = date(2025, 6, 15)
|
|
||||||
dob = today.replace(year=today.year - 18) - timedelta(days=1)
|
dob = today.replace(year=today.year - 18) - timedelta(days=1)
|
||||||
assert calculate_age(dob, today=today) == 18
|
assert calculate_age(dob, today=today) == 18
|
||||||
|
|
||||||
def test_one_day_short_of_18(self):
|
def test_one_day_short_of_18(self):
|
||||||
"""A person born one day after their 18th birthday is 17."""
|
today = date(2026, 6, 15)
|
||||||
today = date(2025, 6, 15)
|
|
||||||
dob = today.replace(year=today.year - 18) + timedelta(days=1)
|
dob = today.replace(year=today.year - 18) + timedelta(days=1)
|
||||||
assert calculate_age(dob, today=today) == 17
|
assert calculate_age(dob, today=today) == 17
|
||||||
|
|
||||||
def test_17_years_old(self):
|
def test_leap_day_birthday_before(self):
|
||||||
"""A person who is 17 years old today is 17."""
|
assert calculate_age(date(2000, 2, 29), today=date(2024, 2, 28)) == 23
|
||||||
today = date(2025, 6, 15)
|
|
||||||
dob = date(2008, 6, 15)
|
|
||||||
assert calculate_age(dob, today=today) == 17
|
|
||||||
|
|
||||||
def test_65_years_old(self):
|
def test_leap_day_birthday_after(self):
|
||||||
"""A person who is 65 is correctly calculated."""
|
assert calculate_age(date(2000, 2, 29), today=date(2024, 3, 1)) == 24
|
||||||
today = date(2025, 6, 15)
|
|
||||||
dob = today.replace(year=today.year - 65)
|
|
||||||
assert calculate_age(dob, today=today) == 65
|
|
||||||
|
|
||||||
def test_newborn(self):
|
|
||||||
"""A newborn is 0."""
|
|
||||||
today = date(2025, 6, 15)
|
|
||||||
dob = today
|
|
||||||
assert calculate_age(dob, today=today) == 0
|
|
||||||
|
|
||||||
def test_birthday_not_yet_this_year(self):
|
|
||||||
"""Person born Dec 31, 2000 — on Jan 1, 2025 they are still 24."""
|
|
||||||
dob = date(2000, 12, 31)
|
|
||||||
assert calculate_age(dob, today=date(2025, 1, 1)) == 24
|
|
||||||
|
|
||||||
def test_birthday_today(self):
|
|
||||||
"""Person born Dec 31, 2000 — on Dec 31, 2024 they turn 24."""
|
|
||||||
dob = date(2000, 12, 31)
|
|
||||||
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
|
|
||||||
|
|
||||||
|
|
||||||
class TestRegisterRequestDateValidation:
|
class TestRegisterRequestDob:
|
||||||
"""Tests for the RegisterRequest date_of_birth field validator."""
|
def test_dob_parsed_from_iso_string(self):
|
||||||
|
req = RegisterRequest(username="u", email="u@example.com", password="p",
|
||||||
|
date_of_birth="1990-01-01")
|
||||||
|
assert req.date_of_birth == date(1990, 1, 1)
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
def test_dob_required(self):
|
||||||
"dob",
|
with pytest.raises(Exception):
|
||||||
[
|
RegisterRequest(username="u", email="u@example.com", password="p")
|
||||||
"2000-01-01",
|
|
||||||
"1990-12-31",
|
|
||||||
"2004-02-29", # leap day
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_valid_dates(self, dob):
|
|
||||||
"""Valid YYYY-MM-DD dates pass validation."""
|
|
||||||
from app.routers.auth import RegisterRequest
|
|
||||||
|
|
||||||
req = RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
|
def test_bad_dob_string_rejected(self):
|
||||||
assert req.date_of_birth == dob
|
with pytest.raises(Exception):
|
||||||
|
RegisterRequest(username="u", email="u@example.com", password="p",
|
||||||
|
date_of_birth="not-a-date")
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"dob",
|
|
||||||
[
|
|
||||||
"01-01-2000",
|
|
||||||
"2000/01/01",
|
|
||||||
"2000-13-01",
|
|
||||||
"2000-00-10",
|
|
||||||
"not-a-date",
|
|
||||||
"",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_invalid_dates_rejected(self, dob):
|
|
||||||
"""Invalid dates are rejected by the validator."""
|
|
||||||
from app.routers.auth import RegisterRequest
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
class TestRegisterAgeGate:
|
||||||
RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
|
"""End-to-end: the /register endpoint computes age from the DOB."""
|
||||||
|
|
||||||
|
def _dob_for_age(self, years: int) -> str:
|
||||||
|
return (date.today().replace(year=date.today().year - years)).isoformat()
|
||||||
|
|
||||||
|
def test_adult_accepted(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.post("/api/auth/register", json={
|
||||||
|
"username": "gate_adult", "email": "gate_adult@e.com",
|
||||||
|
"password": "secret123", "date_of_birth": self._dob_for_age(25),
|
||||||
|
})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
|
||||||
|
def test_minor_rejected(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.post("/api/auth/register", json={
|
||||||
|
"username": "gate_minor", "email": "gate_minor@e.com",
|
||||||
|
"password": "secret123", "date_of_birth": self._dob_for_age(15),
|
||||||
|
})
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert str(MIN_AGE) in r.json()["detail"]
|
||||||
|
|
||||||
|
def test_exactly_18_accepted(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.post("/api/auth/register", json={
|
||||||
|
"username": "gate_18", "email": "gate_18@e.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"date_of_birth": (date.today().replace(year=date.today().year - 18) - timedelta(days=1)).isoformat(),
|
||||||
|
})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
|
||||||
|
def test_future_dob_rejected(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
r = client.post("/api/auth/register", json={
|
||||||
|
"username": "gate_future", "email": "gate_future@e.com",
|
||||||
|
"password": "secret123", "date_of_birth": "2999-01-01",
|
||||||
|
})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
def test_dob_is_stored(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
dob = self._dob_for_age(30)
|
||||||
|
reg = client.post("/api/auth/register", json={
|
||||||
|
"username": "gate_stored", "email": "gate_stored@e.com",
|
||||||
|
"password": "secret123", "date_of_birth": dob,
|
||||||
|
})
|
||||||
|
assert reg.status_code == 201
|
||||||
|
me = client.get("/api/auth/me", headers={
|
||||||
|
"Authorization": f"Bearer {reg.json()['access_token']}"
|
||||||
|
})
|
||||||
|
assert me.json()["date_of_birth"] == dob
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class TestRegister:
|
|||||||
"username": "newuser",
|
"username": "newuser",
|
||||||
"email": "new@example.com",
|
"email": "new@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "2000-01-01",
|
"date_of_birth": "1990-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
@@ -81,20 +81,23 @@ class TestRegister:
|
|||||||
assert "access_token" in data
|
assert "access_token" in data
|
||||||
assert data["token_type"] == "bearer"
|
assert data["token_type"] == "bearer"
|
||||||
|
|
||||||
def test_register_rejects_underage(self):
|
def test_register_rejects_under_18(self):
|
||||||
"""Users under 18 get a 400 with a detail message."""
|
"""A DOB that computes to under 18 gets a 403 (see test_auth_age_gate
|
||||||
|
for the age-math coverage)."""
|
||||||
|
from datetime import date
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
minor_dob = date.today().replace(year=date.today().year - 14).isoformat()
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/api/auth/register",
|
"/api/auth/register",
|
||||||
json={
|
json={
|
||||||
"username": "kid",
|
"username": "kid",
|
||||||
"email": "kid@example.com",
|
"email": "kid@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "2010-06-15", # ~15 years old
|
"date_of_birth": minor_dob,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 403
|
||||||
assert "18 or older" in resp.json()["detail"]
|
assert "at least 18" in resp.json()["detail"]
|
||||||
|
|
||||||
def test_register_rejects_duplicate_username(self):
|
def test_register_rejects_duplicate_username(self):
|
||||||
"""Registering the same username twice returns 409."""
|
"""Registering the same username twice returns 409."""
|
||||||
@@ -106,7 +109,7 @@ class TestRegister:
|
|||||||
"username": "duplicate",
|
"username": "duplicate",
|
||||||
"email": "dup1@example.com",
|
"email": "dup1@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "2000-01-01",
|
"date_of_birth": "1990-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Second registration with same username, different email
|
# Second registration with same username, different email
|
||||||
@@ -116,7 +119,7 @@ class TestRegister:
|
|||||||
"username": "duplicate",
|
"username": "duplicate",
|
||||||
"email": "dup2@example.com",
|
"email": "dup2@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "2000-01-01",
|
"date_of_birth": "1990-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 409
|
assert resp.status_code == 409
|
||||||
@@ -131,7 +134,7 @@ class TestRegister:
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"email": "same@example.com",
|
"email": "same@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "2000-01-01",
|
"date_of_birth": "1990-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
@@ -140,14 +143,14 @@ class TestRegister:
|
|||||||
"username": "user2",
|
"username": "user2",
|
||||||
"email": "same@example.com",
|
"email": "same@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "2000-01-01",
|
"date_of_birth": "1990-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 409
|
assert resp.status_code == 409
|
||||||
assert "Email already registered" in resp.json()["detail"]
|
assert "Email already registered" in resp.json()["detail"]
|
||||||
|
|
||||||
def test_register_invalid_date_format(self):
|
def test_register_missing_dob(self):
|
||||||
"""Invalid date format returns 422 validation error."""
|
"""Missing the required date_of_birth field returns 422."""
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/api/auth/register",
|
"/api/auth/register",
|
||||||
@@ -155,25 +158,10 @@ class TestRegister:
|
|||||||
"username": "user",
|
"username": "user",
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "01-01-2000", # wrong format
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
def test_register_exactly_18(self):
|
|
||||||
"""A user who just turned 18 can register."""
|
|
||||||
client = TestClient(app)
|
|
||||||
resp = client.post(
|
|
||||||
"/api/auth/register",
|
|
||||||
json={
|
|
||||||
"username": "just18",
|
|
||||||
"email": "just18@example.com",
|
|
||||||
"password": "secret123",
|
|
||||||
"date_of_birth": "2007-06-15",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 201
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Login endpoint
|
# Login endpoint
|
||||||
@@ -193,7 +181,7 @@ class TestLogin:
|
|||||||
"username": username,
|
"username": username,
|
||||||
"email": email,
|
"email": email,
|
||||||
"password": password,
|
"password": password,
|
||||||
"date_of_birth": "2000-01-01",
|
"date_of_birth": "1990-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ def _register_user(client: TestClient, username: str) -> str:
|
|||||||
"username": username,
|
"username": username,
|
||||||
"email": f"{username}@example.com",
|
"email": f"{username}@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "2000-01-01",
|
"date_of_birth": "1990-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Integration tests for GET/PUT /api/avatar/loadout.
|
||||||
|
|
||||||
|
Hits the real app against the configured Postgres (same contract as the
|
||||||
|
other integration tests -- migrations applied first).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.models.avatar_loadout import DEFAULT_LOADOUT
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers(client: TestClient, username: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": username,
|
||||||
|
"email": f"{username}@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"date_of_birth": "1990-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetLoadout:
|
||||||
|
def test_new_user_gets_the_default(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
headers = _auth_headers(client, "av_default")
|
||||||
|
resp = client.get("/api/avatar/loadout", headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["is_default"] is True
|
||||||
|
assert body["data"] == DEFAULT_LOADOUT
|
||||||
|
|
||||||
|
def test_requires_auth(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
assert client.get("/api/avatar/loadout").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestPutLoadout:
|
||||||
|
def test_save_then_read_back(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
headers = _auth_headers(client, "av_save")
|
||||||
|
loadout = {
|
||||||
|
"base_body": "base_human_male_01",
|
||||||
|
"slots": {"hair": "hair_mohawk_01", "accessory": ["accessory_belt_01"]},
|
||||||
|
"skin_tone": {"r": 0.8, "g": 0.6, "b": 0.5, "a": 1.0},
|
||||||
|
}
|
||||||
|
put = client.put("/api/avatar/loadout", headers=headers, json={"data": loadout})
|
||||||
|
assert put.status_code == 200, put.text
|
||||||
|
assert put.json()["is_default"] is False
|
||||||
|
|
||||||
|
got = client.get("/api/avatar/loadout", headers=headers)
|
||||||
|
assert got.status_code == 200
|
||||||
|
assert got.json()["is_default"] is False
|
||||||
|
assert got.json()["data"] == loadout
|
||||||
|
|
||||||
|
def test_put_is_idempotent_replace(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
headers = _auth_headers(client, "av_replace")
|
||||||
|
client.put(
|
||||||
|
"/api/avatar/loadout", headers=headers,
|
||||||
|
json={"data": {"base_body": "base_human_male_01", "slots": {"hair": "hair_mohawk_01"}}},
|
||||||
|
)
|
||||||
|
second = {"base_body": "base_human_male_01", "slots": {"chest": "chest_bandit_01"}}
|
||||||
|
client.put("/api/avatar/loadout", headers=headers, json={"data": second})
|
||||||
|
got = client.get("/api/avatar/loadout", headers=headers)
|
||||||
|
assert got.json()["data"] == second
|
||||||
|
|
||||||
|
def test_rejects_missing_base_body(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
headers = _auth_headers(client, "av_nobase")
|
||||||
|
resp = client.put("/api/avatar/loadout", headers=headers, json={"data": {"slots": {}}})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_rejects_non_object_slots(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
headers = _auth_headers(client, "av_badslots")
|
||||||
|
resp = client.put(
|
||||||
|
"/api/avatar/loadout", headers=headers,
|
||||||
|
json={"data": {"base_body": "base_human_male_01", "slots": "nope"}},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_requires_auth(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.put("/api/avatar/loadout", json={"data": {"base_body": "x", "slots": {}}})
|
||||||
|
assert resp.status_code == 401
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""The auth endpoints are rate-limited per client IP."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import rate_limit
|
||||||
|
from app.config import settings
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_limiter():
|
||||||
|
# This module tests the limiter itself: force it on (the checkpoint
|
||||||
|
# runner disables it suite-wide via RATE_LIMIT_ENABLED) and start each
|
||||||
|
# test from an empty window.
|
||||||
|
prev = settings.RATE_LIMIT_ENABLED
|
||||||
|
settings.RATE_LIMIT_ENABLED = True
|
||||||
|
rate_limit.reset_all()
|
||||||
|
yield
|
||||||
|
rate_limit.reset_all()
|
||||||
|
settings.RATE_LIMIT_ENABLED = prev
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_is_limited_after_the_threshold():
|
||||||
|
client = TestClient(app)
|
||||||
|
codes = []
|
||||||
|
for i in range(15):
|
||||||
|
r = client.post("/api/auth/login", data={"username": f"nobody{i}", "password": "x"})
|
||||||
|
codes.append(r.status_code)
|
||||||
|
# First 10 get through to the (failing) auth check; the rest are 429.
|
||||||
|
assert codes[:10] == [401] * 10
|
||||||
|
assert 429 in codes[10:]
|
||||||
|
assert codes[-1] == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_shares_the_same_bucket():
|
||||||
|
client = TestClient(app)
|
||||||
|
# Burn the budget on login...
|
||||||
|
for i in range(10):
|
||||||
|
client.post("/api/auth/login", data={"username": f"x{i}", "password": "y"})
|
||||||
|
# ...register from the same IP is now blocked too.
|
||||||
|
r = client.post("/api/auth/register", json={
|
||||||
|
"username": "rl_user", "email": "rl@e.com",
|
||||||
|
"password": "secret123", "date_of_birth": "1990-01-01",
|
||||||
|
})
|
||||||
|
assert r.status_code == 429
|
||||||
|
assert "Retry-After" in r.headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_limiter_window_is_per_key():
|
||||||
|
limiter = rate_limit.SlidingWindowLimiter(max_hits=2, window_seconds=60)
|
||||||
|
limiter.check("a")
|
||||||
|
limiter.check("a")
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
limiter.check("a")
|
||||||
|
limiter.check("b") # different key, unaffected
|
||||||
@@ -59,7 +59,7 @@ class TestRefresh:
|
|||||||
"username": username,
|
"username": username,
|
||||||
"email": email,
|
"email": email,
|
||||||
"password": password,
|
"password": password,
|
||||||
"date_of_birth": "2000-01-01",
|
"date_of_birth": "1990-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
@@ -143,7 +143,7 @@ class TestRefresh:
|
|||||||
"username": "refep_loginrefuser",
|
"username": "refep_loginrefuser",
|
||||||
"email": "loginref@example.com",
|
"email": "loginref@example.com",
|
||||||
"password": "secret123",
|
"password": "secret123",
|
||||||
"date_of_birth": "2000-01-01",
|
"date_of_birth": "1990-01-01",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Login to get a refresh token
|
# Login to get a refresh token
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Tests for POST /api/worlds/{id}/join.
|
||||||
|
|
||||||
|
The happy path spawns a real Godot process, so it's covered by a manual /
|
||||||
|
checkpoint integration test, not here. These cover the envelope: auth,
|
||||||
|
visibility, and the 503 when orchestration is disabled (the default).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(client: TestClient, name: str) -> tuple[dict, int]:
|
||||||
|
r = client.post("/api/auth/register", json={
|
||||||
|
"username": name, "email": f"{name}@e.com", "password": "secret123", "date_of_birth": "1990-01-01",
|
||||||
|
})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
headers = {"Authorization": f"Bearer {r.json()['access_token']}"}
|
||||||
|
me = client.get("/api/users/me", headers=headers)
|
||||||
|
return headers, me.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_world(client: TestClient, headers: dict, visibility: str = "public") -> int:
|
||||||
|
r = client.post("/api/worlds/", headers=headers, json={"name": "W", "visibility": visibility})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestJoinWorld:
|
||||||
|
def test_requires_auth(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
assert client.post("/api/worlds/1/join").status_code == 401
|
||||||
|
|
||||||
|
def test_unknown_world_is_404(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
headers, _ = _auth(client, "join_404")
|
||||||
|
assert client.post("/api/worlds/999999/join", headers=headers).status_code == 404
|
||||||
|
|
||||||
|
def test_private_world_of_another_user_is_404(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
owner_headers, _ = _auth(client, "join_owner")
|
||||||
|
wid = _make_world(client, owner_headers, "private")
|
||||||
|
other_headers, _ = _auth(client, "join_other")
|
||||||
|
assert client.post(f"/api/worlds/{wid}/join", headers=other_headers).status_code == 404
|
||||||
|
|
||||||
|
def test_disabled_orchestration_returns_503(self):
|
||||||
|
# GAME_SERVERS_ENABLED defaults to False in tests.
|
||||||
|
client = TestClient(app)
|
||||||
|
headers, _ = _auth(client, "join_503")
|
||||||
|
wid = _make_world(client, headers, "public")
|
||||||
|
r = client.post(f"/api/worlds/{wid}/join", headers=headers)
|
||||||
|
assert r.status_code == 503
|
||||||
|
assert "not enabled" in r.json()["detail"]
|
||||||
@@ -449,6 +449,37 @@ The catalog is a JSON manifest mapping asset ids to metadata and resource paths:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 9a. Loadout Persistence (implemented, PC3 Phase 1)
|
||||||
|
|
||||||
|
**Storage.** One row per user in `avatar_loadouts` (`user_id` PK/FK,
|
||||||
|
`data` JSONB, timestamps). The entire loadout is stored as a single JSON
|
||||||
|
document in the exact shape the client's `AvatarLoadout.to_json()` emits
|
||||||
|
(`{"base_body": str, "slots": {...}, "skin_tone"?: {r,g,b,a}}`) rather than
|
||||||
|
exploded into columns.
|
||||||
|
|
||||||
|
**Why one JSON blob, not columns.** The server has no copy of the asset
|
||||||
|
catalog (`assets/avatars/catalog.json` ships with the client). It therefore
|
||||||
|
cannot meaningfully validate *which* asset belongs in *which* slot, whether
|
||||||
|
an asset exists, or slot-exclusivity rules — that validation stays on the
|
||||||
|
client (`AvatarLoadout.validate()` against the loaded `AvatarCatalog`). The
|
||||||
|
server enforces only the envelope: a JSON object with a non-empty string
|
||||||
|
`base_body`, an object `slots`, and a sane size cap. Keeping the schema
|
||||||
|
opaque to the server also means catalog/loadout-shape changes don't need a
|
||||||
|
migration.
|
||||||
|
|
||||||
|
**Endpoints** (`/api/avatar`, bearer auth):
|
||||||
|
|
||||||
|
| Method | Path | Behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/loadout` | The user's saved loadout, or the server default (`{"base_body": "base_human_male_01", "slots": {}}`, `is_default: true`) if they have never saved one. |
|
||||||
|
| `PUT` | `/loadout` | Replace the user's loadout (full document, not a patch). |
|
||||||
|
|
||||||
|
**Defaults.** A brand-new account has no row; `GET` returns
|
||||||
|
`models.avatar_loadout.DEFAULT_LOADOUT` with `is_default: true` so the client
|
||||||
|
can distinguish "never customised" from "customised back to the base look".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 10. Summary
|
## 10. Summary
|
||||||
|
|
||||||
| Concern | Decision |
|
| Concern | Decision |
|
||||||
|
|||||||
@@ -227,18 +227,33 @@ An account has an **age verification status**:
|
|||||||
|
|
||||||
### 5.2 Tier 1 — Self-Declaration Flow
|
### 5.2 Tier 1 — Self-Declaration Flow
|
||||||
|
|
||||||
|
> **Implemented, PC3 Phase 3.** `POST /api/auth/register` now takes a real
|
||||||
|
> `date_of_birth` (ISO `YYYY-MM-DD`), not the throwaway `over_18` boolean it
|
||||||
|
> had before. The server computes age (`auth.calculate_age`) and rejects
|
||||||
|
> anyone under `auth.MIN_AGE` (18) with a **403** — this platform is
|
||||||
|
> adults-only, so there is no "minor, restricted" tier here (the 13+ rows
|
||||||
|
> below are the general design, not the current gate). A future-in-the-past
|
||||||
|
> or nonsensical date is a **400**. The DOB is stored on the user row and
|
||||||
|
> surfaced only back to that user (`/api/auth/me`), never to other players.
|
||||||
|
>
|
||||||
|
> **Limitation, stated plainly:** this is still self-attested — a determined
|
||||||
|
> user can enter a false DOB. It is a concrete, stored, legally-meaningful
|
||||||
|
> data point (matching how most platforms gate before adding hard checks)
|
||||||
|
> and the seam for Tier 2. Encryption-at-rest for the DOB column and the
|
||||||
|
> Tier 2 ID-check hook below are **not yet implemented**.
|
||||||
|
|
||||||
```
|
```
|
||||||
User submits { date_of_birth }
|
User submits { date_of_birth }
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Server validates format & plausibility (age 13–120, not in future)
|
Server validates format & plausibility (real date, not in future)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Compute age from DOB
|
Compute age from DOB
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
age >= 18 ──► status = self_declared (adult)
|
age >= 18 ──► status = self_declared (adult) ← the current gate
|
||||||
age >= 13 ──► status = self_declared (minor, restricted)
|
age >= 13 ──► status = self_declared (minor, restricted) ← design only
|
||||||
age < 13 ──► status = rejected (below minimum platform age)
|
age < 13 ──► status = rejected (below minimum platform age)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -237,6 +237,62 @@ snapshot_state:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 7a. Server Orchestration (implemented, PC3 Phase 2)
|
||||||
|
|
||||||
|
**One dedicated server process per world**, spawned on demand.
|
||||||
|
|
||||||
|
```
|
||||||
|
client backend (single worker) host
|
||||||
|
│ POST /api/worlds/{id}/join (bearer JWT) │
|
||||||
|
│ ───────────────────────────▶ GameServerSupervisor │
|
||||||
|
│ ensure_server(world_id): │
|
||||||
|
│ if none running for world ─── spawn:
|
||||||
|
│ godot --headless
|
||||||
|
│ res://scenes/server.tscn --
|
||||||
|
│ --port <p> --world <id>
|
||||||
|
│ --status-file <path>
|
||||||
|
│ ◀─────────────────────────── { host, port, world_id } │
|
||||||
|
│ │
|
||||||
|
│ ENet connect host:port ───────────────────────────────────▶ dedicated server
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Supervisor** (`backend/app/services/game_servers.py`): in-process singleton,
|
||||||
|
so the backend MUST run single-worker. Keys servers by `world_id`. Allocates
|
||||||
|
ports from `GAME_SERVER_PORT_MIN..MAX`. A background reaper kills a world's
|
||||||
|
server once its **status file** (`{players, tick, updated}`, rewritten by the
|
||||||
|
server ~1×/s) reports `players == 0` for `GAME_SERVER_IDLE_TIMEOUT_SECONDS`.
|
||||||
|
No crash-restart — the next join respawns.
|
||||||
|
- **Enable flag:** `GAME_SERVERS_ENABLED` (default off). The checkpoint/playtest
|
||||||
|
launcher (`pm/checkpoint_ops.py`) turns it on, passes `GODOT_BIN`,
|
||||||
|
`GAME_PROJECT_DIR`, and `GAME_SERVER_HOST` (the LAN IP baked into the `.exe`).
|
||||||
|
A backend with it off returns **503** from `/join`, and the client falls back
|
||||||
|
to local single-player.
|
||||||
|
- **Per-world content:** all worlds currently load the same test level (M2/M7
|
||||||
|
work). `world_id` still scopes a distinct process + session.
|
||||||
|
|
||||||
|
### 7b. Client play path
|
||||||
|
|
||||||
|
`world_list.gd` → `POST /join` → `Session.begin(world_id, host, port)` →
|
||||||
|
load `game.tscn`. `game.gd` sees `Session.active` and connects a
|
||||||
|
`NetworkClient`:
|
||||||
|
|
||||||
|
- **Local player** keeps predicting with its own collision-aware
|
||||||
|
`CharacterBody3D` (`player.gd`) — that *is* the prediction. Its input frame
|
||||||
|
(`player.net_input`) is forwarded to the server every physics tick.
|
||||||
|
- **Correction backstop:** if the local position drifts from the server's by
|
||||||
|
more than `CORRECTION_THRESHOLD` (1.5 m — generous; catches teleport / a wall
|
||||||
|
walked through / real desync, not prediction noise) it snaps.
|
||||||
|
- **Remote players:** rendered from snapshots, position lerped. *Appearance sync
|
||||||
|
is a follow-up* — remotes currently show as labelled capsules; the server
|
||||||
|
does not yet replicate avatar loadouts.
|
||||||
|
- **Disconnect** → status message → back to the world list.
|
||||||
|
|
||||||
|
The `NetworkClient`'s own flat-plane prediction + input-replay reconciliation
|
||||||
|
(§5) is exercised by `smoke_test_network_client.gd`; the collision-aware game
|
||||||
|
path above uses the lighter `send_input()` + drift-snap instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 8. Security & Anti-Cheat
|
## 8. Security & Anti-Cheat
|
||||||
|
|
||||||
- **No client-authoritative gameplay values.** Clients only send *inputs* and
|
- **No client-authoritative gameplay values.** Clients only send *inputs* and
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# PC2 Test Level & Player Spawn — Design Note
|
||||||
|
|
||||||
|
> **Status:** Decided (PC2)
|
||||||
|
> **Owner:** Architecture
|
||||||
|
> **Consumers:** the two PC2 implementation tasks — (1) build the test level,
|
||||||
|
> (2) build the player & spawn it. Both must be able to follow this note
|
||||||
|
> without further design decisions.
|
||||||
|
|
||||||
|
## 1. Decision: static scene, not chunk streaming
|
||||||
|
|
||||||
|
**The PC2 playable test level is a single, hand-authored, static Godot 3D
|
||||||
|
scene.** It is **not** built through the chunk streaming system
|
||||||
|
(`scripts/chunk_stream_manager.gd`, `chunk_scene.gd`, `chunk.gd`,
|
||||||
|
`chunk_data_decoder.gd`).
|
||||||
|
|
||||||
|
This is a deliberate decision, not a default:
|
||||||
|
|
||||||
|
- **The streaming system has never been exercised with real content.** It is a
|
||||||
|
faithful port of the `docs/architecture/world.md` format spec, but nothing in
|
||||||
|
the project produces or serves chunk payloads for it to consume. `scenes/main.tscn`
|
||||||
|
wires `chunk_stream_manager.gd` onto a bare `Node3D` with no `player` reference
|
||||||
|
and no `_server_rpc`, so at runtime it does nothing. There is no server-side
|
||||||
|
chunk authoring/streaming endpoint to integrate against.
|
||||||
|
- **PC2 scope is single-player, local-only, no networking.** The value of chunk
|
||||||
|
streaming is server-authoritative streaming around a moving player across a
|
||||||
|
network boundary. None of those preconditions exist in PC2, so exercising the
|
||||||
|
pipeline here would mean building a chunk *authoring + serving* path first —
|
||||||
|
clearly out of checkpoint scope.
|
||||||
|
- **A static scene is the lowest-risk way to get "spawn a player in a real 3D
|
||||||
|
level and walk around" working**, which is the actual PC2 outcome being tested.
|
||||||
|
|
||||||
|
Chunk streaming is **not removed and not broken by this decision** — it stays as
|
||||||
|
an optional, later milestone. The static level is the PC2 proof of the *player*
|
||||||
|
and *camera/physics* half of the problem; chunk streaming remains the later proof
|
||||||
|
of the *world data* half.
|
||||||
|
|
||||||
|
## 2. Where things live
|
||||||
|
|
||||||
|
| Artifact | Path | Owned by |
|
||||||
|
|---|---|---|
|
||||||
|
| Test level scene | `scenes/worlds/test_level.tscn` | level task |
|
||||||
|
| Player scene | `scenes/player.tscn` | player task |
|
||||||
|
| Player controller script | `scripts/player.gd` | player task |
|
||||||
|
| Game composition scene | `scenes/game.tscn` | player task |
|
||||||
|
|
||||||
|
- **`scenes/worlds/`** is the convention for all level scenes (it does not exist
|
||||||
|
yet; the level task creates it). Future levels follow `scenes/worlds/<name>.tscn`.
|
||||||
|
- `scenes/player.tscn` is **reusable** across levels — it must not hard-code any
|
||||||
|
reference to `test_level`; it discovers the spawn point at runtime (see §3).
|
||||||
|
|
||||||
|
## 3. Player spawn convention
|
||||||
|
|
||||||
|
The level scene **must** contain a `Marker3D` named exactly **`PlayerSpawn`**.
|
||||||
|
That node's world transform is the spawn point.
|
||||||
|
|
||||||
|
- `Marker3D` is a plain `Node3D` that shows a cross gizmo in the editor and
|
||||||
|
carries no gameplay behavior — it is the established Godot idiom for "position
|
||||||
|
hint." (Confirmed present in Godot 4.6.2.)
|
||||||
|
- The marker's **position** is authoritative; its rotation is ignored by the
|
||||||
|
player (spawn yaw is the player's concern, default forward is fine).
|
||||||
|
- Coordinate system: **Y-up, units = meters, 1 unit = 1 m.** The marker must sit
|
||||||
|
at least **1.0 m above the ground's top surface** (recommended `y = 1.0` where
|
||||||
|
the ground top is `y = 0`) so the player capsule does not spawn intersecting
|
||||||
|
the floor and get popped out by the physics solver.
|
||||||
|
- There is exactly **one** `PlayerSpawn` per level. (If a level ever needs
|
||||||
|
multiple spawns, that is a later convention — not PC2.)
|
||||||
|
|
||||||
|
**Lookup rule (implemented by `player.gd`, not by the level):**
|
||||||
|
|
||||||
|
```
|
||||||
|
# in player.gd _ready()
|
||||||
|
var spawn := get_tree().current_scene.find_child("PlayerSpawn", true, false)
|
||||||
|
if spawn is Node3D:
|
||||||
|
global_position = spawn.global_position
|
||||||
|
else:
|
||||||
|
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
|
||||||
|
global_position = Vector3(0, 1.0, 0) # fallback, do not crash
|
||||||
|
```
|
||||||
|
|
||||||
|
The lookup runs from the current scene root so it finds the marker whether it is
|
||||||
|
a direct child of the level instance or nested under a `Level` wrapper. The
|
||||||
|
fallback keeps the player runnable in a scene that forgot its marker, but a
|
||||||
|
**missing `PlayerSpawn` is a level bug** and the level task's smoke test must fail
|
||||||
|
on it (see §6).
|
||||||
|
|
||||||
|
## 4. Composition (who puts level + player together)
|
||||||
|
|
||||||
|
`scenes/game.tscn` is the PC2 playable entry point:
|
||||||
|
|
||||||
|
```
|
||||||
|
Game (Node3D)
|
||||||
|
├── Level (instance of res://scenes/worlds/test_level.tscn)
|
||||||
|
└── Player (instance of res://scenes/player.tscn)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Game` is a plain `Node3D` with **no script** — composition is done by scene
|
||||||
|
instantiation, not code, so there is nothing to keep in sync.
|
||||||
|
- The **level scene is self-contained and has zero dependency on the player** —
|
||||||
|
it loads and is smoke-testable on its own. The level task does not instance or
|
||||||
|
reference `player.tscn`.
|
||||||
|
- The **player scene is self-contained and has zero dependency on any specific
|
||||||
|
level** — it only requires that the *current scene* contains a `PlayerSpawn`.
|
||||||
|
- Ownership of `game.tscn` goes to the **player task**, which depends on the
|
||||||
|
level task having landed `test_level.tscn` first (chain with `depends_on`).
|
||||||
|
|
||||||
|
## 5. Level content requirements (level task)
|
||||||
|
|
||||||
|
`scenes/worlds/test_level.tscn`, root node `TestLevel` (`Node3D`). Required
|
||||||
|
children:
|
||||||
|
|
||||||
|
| Node | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `Ground` | `StaticBody3D` | `BoxShape3D` (e.g. `40 × 1 × 40`) centered at `(0, -0.5, 0)` so the **top face is `y = 0`**; matching `MeshInstance3D` (`BoxMesh`) so it is visible and collidable. Use a `StaticBody3D` rather than a bare `PlaneMesh` so collision is explicit and reliable. |
|
||||||
|
| `Props` | `Node3D` | 3–5 simple props as `StaticBody3D` + `MeshInstance3D` using built-in primitives only (`BoxMesh`, `CylinderMesh`, `SphereMesh`). `assets/` is empty and PC2 must not require imported content. Positioned on the ground surface; scale freely. |
|
||||||
|
| `Sun` | `DirectionalLight3D` | Pitch it downward (~ -45°), enable shadows if the compatibility renderer allows; shadows are optional. |
|
||||||
|
| `WorldEnvironment` | `WorldEnvironment` | Ambient light and/or a simple background color. Keep it plain — the project uses `gl_compatibility`. |
|
||||||
|
| `PlayerSpawn` | `Marker3D` | Per §3. Recommended `(0, 1.0, 0)`. |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- **Do not** add any `script` to the level. It is pure content. Any per-level
|
||||||
|
behavior belongs to the player (or a future game-mode script), not the level.
|
||||||
|
- Keep the whole level on the XZ plane around the origin; nothing off at extreme
|
||||||
|
coordinates, no thin walls the capsule can tunnel through.
|
||||||
|
|
||||||
|
## 6. Player contract (player task)
|
||||||
|
|
||||||
|
`scenes/player.tscn`, root `Player` (`CharacterBody3D`) + `scripts/player.gd`.
|
||||||
|
|
||||||
|
Minimum for PC2:
|
||||||
|
|
||||||
|
- **Body:** `CharacterBody3D` with a `CollisionShape3D` using `CapsuleShape3D`
|
||||||
|
(~`radius 0.3`, ~`height 1.8`) and a matching `CapsuleMesh` for visibility.
|
||||||
|
- **Camera:** a `Camera3D` child. First-person (camera at capsule head height) is
|
||||||
|
the lowest-risk choice; third-person is fine too but is more work.
|
||||||
|
- **Spawn:** per §3 — read `PlayerSpawn` from the current scene, fall back
|
||||||
|
gracefully.
|
||||||
|
- **Movement:** gravity + `move_and_slide()` so the player stands on `Ground`.
|
||||||
|
WASD/arrow locomotion is the hard requirement. Mouse-look is recommended but
|
||||||
|
may be split into its own task if the controller grows.
|
||||||
|
- **No networking, no avatar UI, no chunk code** — PC2 is local-only. Do not add
|
||||||
|
`MultiplayerAPI`, `ChunkStreamManager`, or any avatar customization here.
|
||||||
|
|
||||||
|
The player must work when instanced under `Game` alongside a level; it must not
|
||||||
|
assume it is the only node in the scene.
|
||||||
|
|
||||||
|
## 7. Entry point / how a human plays it
|
||||||
|
|
||||||
|
- `scenes/game.tscn` is the PC2 playable scene. For playtesting, load it directly
|
||||||
|
(e.g. `godot --path . scenes/game.tscn`), or temporarily set
|
||||||
|
`application/run/main_scene` to it.
|
||||||
|
- **Wiring "Play → Join a world" in the existing `world_list` UI is out of scope
|
||||||
|
for this note** and out of scope for the level/player tasks. The current menu
|
||||||
|
flow ends at browse-worlds (PC1). If a checkpoint wants a clickable path into
|
||||||
|
the test level, that navigation wiring is its own small task — do not fold it
|
||||||
|
into level or player.
|
||||||
|
|
||||||
|
## 8. Validation (both tasks must run this before "done")
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# one-time, first checkout only — builds the class/import cache
|
||||||
|
godot --headless --path . --import
|
||||||
|
|
||||||
|
# then, to catch parse/load errors:
|
||||||
|
godot --headless --path . --quit
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Level task:** additionally load `test_level.tscn` headlessly and assert it
|
||||||
|
(a) loads without error and (b) contains a `Marker3D` named `PlayerSpawn`
|
||||||
|
positioned ≥ 1.0 m above the ground top. A smoke script following the existing
|
||||||
|
`scripts/smoke_test_*.gd` pattern is the right vehicle.
|
||||||
|
- **Player task:** a smoke test that instantiates `game.tscn`, waits a frame,
|
||||||
|
and asserts the player's global position equals the `PlayerSpawn` position
|
||||||
|
(within a small epsilon). This is the check that proves the spawn convention
|
||||||
|
actually wired through — not just "the scene loads."
|
||||||
|
|
||||||
|
## 9. Explicitly out of scope for PC2
|
||||||
|
|
||||||
|
- Networking / multiplayer / prediction (single-player, local-only).
|
||||||
|
- Chunk streaming integration and server-side chunk authoring/serving.
|
||||||
|
- Avatar customization UI and glTF avatar loading.
|
||||||
|
- Join-a-world navigation from the world list.
|
||||||
|
- Any content moderation / age-gating logic in the level.
|
||||||
|
|
||||||
|
## 10. Summary
|
||||||
|
|
||||||
|
| Concern | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Level construction | Static hand-authored Godot 3D scene (chunk streaming deferred, not removed) |
|
||||||
|
| Level location | `scenes/worlds/test_level.tscn` |
|
||||||
|
| Spawn convention | `Marker3D` named `PlayerSpawn`, ≥ 1.0 m above ground, looked up by the player |
|
||||||
|
| Player | `scenes/player.tscn` (`CharacterBody3D`) + `scripts/player.gd`, reusable across levels |
|
||||||
|
| Composition | `scenes/game.tscn` instances `Level` + `Player`; no script |
|
||||||
|
| Entry point | Load `scenes/game.tscn` directly; UI join-wiring deferred |
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Package Checkpoint 3 (PC3)
|
||||||
|
|
||||||
|
> **Status: partial.** PC3's planned scope was M4-avatars + M5-multiplayer +
|
||||||
|
> Foundation Hardening + M6–M10 (see `docs/build-plan-v1.md`). As of
|
||||||
|
> 2026-09-05, **M4, M5, and most of Foundation Hardening are done and
|
||||||
|
> checkpoint-verified**; M6–M10 have not been started. This doc records what
|
||||||
|
> is real. Whether to call this "PC3" (redefined scope, M6–M10 → PC4) or keep
|
||||||
|
> PC3 open is a user decision.
|
||||||
|
|
||||||
|
Built by Claude directly (fleet offline), branch `pc3` → `main`.
|
||||||
|
|
||||||
|
## What's real and verified (`scripts/run_checkpoint_tests.py`, all green)
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| Alembic migrations, fresh DB | PASS |
|
||||||
|
| Backend `pytest` | 77/77 PASS (hermetic — auto-starts an ephemeral Postgres) |
|
||||||
|
| Godot smoke tests (~16 files) | ALL PASS |
|
||||||
|
| `smoke_test_network_client` (real dedicated server) | 4/4 PASS |
|
||||||
|
| 2-player multiplayer (`run_multiplayer_2p_test.sh`) | PASS |
|
||||||
|
| Fresh-clone boot (`verify_fresh_clone.sh`) | PASS |
|
||||||
|
| Linux + Windows exports | Build as real ELF64 / PE32+ binaries |
|
||||||
|
|
||||||
|
### M4 — Avatars in the game
|
||||||
|
|
||||||
|
- Placeholder avatar pack (14 GLBs, rigged 24-bone base body + attachments)
|
||||||
|
generated by `scripts/tools/gen_placeholder_avatars.gd`. Real art is a
|
||||||
|
content swap — catalog paths are stable.
|
||||||
|
- The avatar **loader was fixed** — it had never run against a real skeleton
|
||||||
|
(`Skeleton3D.has_bone()` isn't a Godot 4 method; the bone-copy dropped the
|
||||||
|
skin binding; attachments were parsed then discarded).
|
||||||
|
- `avatar_visualizer.gd` **rewritten** — the old one referenced a loader API
|
||||||
|
that never existed and didn't compile.
|
||||||
|
- Persistence: `avatar_loadouts` table + `GET/PUT /api/avatar/loadout`
|
||||||
|
(loadout stored as one JSON doc; server validates the envelope only).
|
||||||
|
- Wired into gameplay: the in-world player renders as the assembled avatar
|
||||||
|
(not a capsule); the loadout is fetched at login; "Customize Avatar" from
|
||||||
|
the main menu saves through to the backend.
|
||||||
|
- **Follow-up:** camera is still first-person, so the local player doesn't
|
||||||
|
see their own avatar much (third-person is a polish item); remote players'
|
||||||
|
avatar appearance is not replicated yet (they show as labelled capsules).
|
||||||
|
|
||||||
|
### M5 — Real multiplayer
|
||||||
|
|
||||||
|
- `server.gd` **actually runs a sim now** — loads the world level, spawns a
|
||||||
|
server player per peer, processes each peer's input queue in seq order at a
|
||||||
|
fixed 30 Hz, broadcasts full snapshots with a per-peer `last_seq`. (The
|
||||||
|
input queue was a no-op before.)
|
||||||
|
- `network_client.gd` reconciliation **replays in-flight inputs** from the
|
||||||
|
authoritative state (drops acked ones), snaps only on real drift.
|
||||||
|
- **Server orchestration** (`backend/app/services/game_servers.py`): one
|
||||||
|
headless Godot dedicated-server process per world, spawned on demand by
|
||||||
|
`POST /api/worlds/{id}/join`, port-allocated, with a status-file idle
|
||||||
|
reaper. Off by default; the playtest launcher (`checkpoint_ops.py`)
|
||||||
|
enables it.
|
||||||
|
- **Client play path**: world browser → `/join` → `Session` → `game.tscn`
|
||||||
|
connects → local player predicts with collision, input forwarded, server
|
||||||
|
position is a drift backstop, remote players render from snapshots,
|
||||||
|
disconnect returns to the world list. Falls back to local single-player
|
||||||
|
when the backend has no orchestration.
|
||||||
|
- **Verified**: two real client processes each `register → /join → play`,
|
||||||
|
hosted on an orchestrated server, see and move relative to each other
|
||||||
|
(`run_game_multiplayer_test.sh`).
|
||||||
|
|
||||||
|
### Foundation Hardening
|
||||||
|
|
||||||
|
- **Hermetic backend tests** — `cd backend && pytest` works from a clean
|
||||||
|
checkout (auto ephemeral Postgres + migrations, torn down after); skips
|
||||||
|
cleanly with a message if there's no Docker.
|
||||||
|
- **Fresh-clone boot check** — `verify_fresh_clone.sh` exports a pristine
|
||||||
|
tree, imports from scratch, boots headless, fails on any script error.
|
||||||
|
Also fixed the root cause: a stale `.godot/global_script_class_cache.cfg`
|
||||||
|
was committed to the repo.
|
||||||
|
- **Real age verification** — `/register` takes a date of birth (not a
|
||||||
|
checkbox), server computes age, 403 under 18. Still self-attested; the
|
||||||
|
limitation and the ID-check hook are documented
|
||||||
|
(`content_moderation.md` 5.2).
|
||||||
|
- **Auth rate limiting** — 10 attempts / min / IP on `/register` + `/login`.
|
||||||
|
- **CI workflow** — `.forgejo/workflows/checkpoint.yml` written, **not
|
||||||
|
running yet**: needs the operator to enable Forgejo Actions + register a
|
||||||
|
`host` runner (one-time; see `docs/ci.md`). Interim gate is
|
||||||
|
`run_checkpoint_tests.py` by hand / the dashboard.
|
||||||
|
|
||||||
|
## Not in PC3 (planned but not started)
|
||||||
|
|
||||||
|
- **M2** server-side world persistence
|
||||||
|
- **M6** scripting sandbox
|
||||||
|
- **M7** in-game editor
|
||||||
|
- **M8** social / chat
|
||||||
|
- **M9** economy
|
||||||
|
- **M10** discovery
|
||||||
|
|
||||||
|
## Playtest
|
||||||
|
|
||||||
|
Same as PC2 — grab the exe from the dashboard's `/checkpoints`, backend
|
||||||
|
pointed at it. New: with multiple people in the same world you now actually
|
||||||
|
see each other move.
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
# CI
|
||||||
|
|
||||||
|
`.gitea/workflows/checkpoint.yml` runs the full checkpoint suite
|
||||||
|
(`scripts/run_checkpoint_tests.py`: migrations on a fresh DB, backend
|
||||||
|
`pytest`, all Godot smoke tests, the 2-player multiplayer test, and the
|
||||||
|
fresh-clone boot check) on **every push to `main` and every PR**.
|
||||||
|
|
||||||
|
## Status: live
|
||||||
|
|
||||||
|
Set up 2026-09-05:
|
||||||
|
|
||||||
|
- Gitea Actions enabled (`[actions] ENABLED = true` in
|
||||||
|
`/DATA/AppData/forgejo/gitea/conf/app.ini`, container restarted; a
|
||||||
|
timestamped `.bak` is next to it).
|
||||||
|
- `act_runner` v0.2.13 registered as **`agp-host`** with label **`host`**
|
||||||
|
(host execution mode — jobs run directly on the AGP box, which already
|
||||||
|
has Docker, `~/godot/godot`, and Python). Lives in `~/act-runner/`.
|
||||||
|
- Green on the first successful run (`d76bf4d`).
|
||||||
|
|
||||||
|
The workflow uses **no JavaScript actions** (no `actions/checkout`), so the
|
||||||
|
host needs no Node: checkout is a shallow `git fetch` from the local Gitea
|
||||||
|
using the injected `${{ github.token }}`, and the suite runs in a venv the
|
||||||
|
job builds (host Python is PEP-668 externally-managed).
|
||||||
|
|
||||||
|
## Make the runner permanent (one command, needs sudo)
|
||||||
|
|
||||||
|
The runner currently runs as a plain background process — it won't survive a
|
||||||
|
reboot. A unit file is ready at `~/act-runner/act-runner.service`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp ~/act-runner/act-runner.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
pkill -f 'act_runner daemon' # stop the temporary background one
|
||||||
|
sudo systemctl enable --now act-runner
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interim / local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/run_checkpoint_tests.py
|
||||||
|
```
|
||||||
|
|
||||||
|
— also what `/checkpoint/<PC>` on the dashboard runs before a playtest build.
|
||||||
|
|
||||||
|
## Security note
|
||||||
|
|
||||||
|
A host-mode runner executes whatever a workflow says, directly on the box,
|
||||||
|
as `daddy` (in the `docker` group). Fine for a single-maintainer private
|
||||||
|
repo; revisit if the repo gets outside contributors (switch to a
|
||||||
|
docker-mode runner or an isolated VM).
|
||||||
@@ -7,26 +7,30 @@ to know what "run the tests" actually checked.
|
|||||||
|
|
||||||
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
|
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
|
||||||
|
|
||||||
Last verified against `main`: 2026-08-20 — full PC1 checkpoint run,
|
Last verified against `main`: 2026-08-21 — migrations + pytest + all 11
|
||||||
migrations + pytest + all 6 Godot smoke tests + both platform exports, all
|
Godot smoke tests, all clean. PC1's real playtest-found bugs (login form
|
||||||
clean. Two real bugs found and fixed via real playtesting this same day
|
unreachable, 1px world cards, Play → Back losing the session) are covered
|
||||||
(not caught by the checkpoint suite as it stood before today): the main
|
by the smoke tests below so they can't silently regress. PC2 (basic
|
||||||
menu had no way to actually open the login/register form, and the world
|
playable world + character + movement) landed 2026-08-20/21 — see
|
||||||
list's cards rendered at ~1px wide once real data existed. Both are now
|
`docs/checkpoints/` once a PC2 record exists, and the three new smoke
|
||||||
covered by the smoke tests below so they can't silently regress.
|
tests below (`smoke_test_test_level.gd`, `smoke_test_player.gd`,
|
||||||
|
`smoke_test_enter_world.gd`) for what's actually verified. The avatar
|
||||||
|
loader/customization system (`smoke_test_avatar_system.gd`) also landed
|
||||||
|
2026-08-21 — real glTF-based loading with graceful failure on missing
|
||||||
|
assets, verified since `assets/avatars/*.glb` model files don't exist yet.
|
||||||
|
|
||||||
## Backend — `backend/tests/` (pytest)
|
## Backend — `backend/tests/` (pytest)
|
||||||
|
|
||||||
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
|
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
|
||||||
reachable Postgres — see the runner script).
|
reachable Postgres — see the runner script).
|
||||||
|
|
||||||
**Current total: 68 tests, all passing on a clean `main` checkout.**
|
**Current total: 61 tests, all passing on a clean `main` checkout.**
|
||||||
|
|
||||||
| File | Tests | What it covers |
|
| File | Tests | What it covers |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `test_auth_age_gate.py` | 17 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year) + `RegisterRequest` date-of-birth validation (parametrized valid/invalid date rejection). Backs the 18+ registration gate (PR #23). |
|
| `test_auth_age_gate.py` | 11 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year — kept as a tested utility for a future real-DOB pass even though `/register` no longer calls it) + `RegisterRequest.over_18` field tests (true accepted, false accepted, missing rejected). 2026-08-20: the register flow was simplified from collecting/validating a date of birth to a plain client-side "I am 18 or older" confirmation, so the old date-validation parametrized cases were replaced. Backs the 18+ registration gate (PR #23, revised PC1). |
|
||||||
| `test_gltf_metadata.py` | 21 | glTF/GLB binary + JSON parsing: magic-byte detection, chunk-padding handling, mesh/animation/texture-reference metadata extraction, malformed-input rejection. Backs the asset-import pipeline (PR #27). |
|
| `test_gltf_metadata.py` | 21 | glTF/GLB binary + JSON parsing: magic-byte detection, chunk-padding handling, mesh/animation/texture-reference metadata extraction, malformed-input rejection. Backs the asset-import pipeline (PR #27). |
|
||||||
| `test_auth_endpoints.py` | 11 | **Integration tests** for `POST /api/auth/register` and `POST /api/auth/login` against the real FastAPI app + real Postgres. Covers: successful register (201), duplicate username (409), duplicate email (409), underage rejection (400), invalid date format (422), exact-18 registration, successful login with correct credentials (200), wrong password (401), nonexistent user (401), form-encoded login (OAuth2), and JWT validity of returned tokens. |
|
| `test_auth_endpoints.py` | 10 | **Integration tests** for `POST /api/auth/register` and `POST /api/auth/login` against the real FastAPI app + real Postgres. Covers: successful register (201), duplicate username (409), duplicate email (409), `over_18=false` rejection (400), missing `over_18` field (422), successful login with correct credentials (200), wrong password (401), nonexistent user (401), form-encoded login (OAuth2), and JWT validity of returned tokens. |
|
||||||
| `test_refresh_endpoint.py` | 8 | **Integration tests** for `POST /api/auth/refresh`. Covers: successful refresh (200, new access token, same refresh token returned), refresh produces different access tokens on repeated calls, invalid refresh token (401), empty body (422), missing field (422), refresh token from login also works, returned access token is valid JWT decodable with public key, `access_token_expires_minutes` in response. |
|
| `test_refresh_endpoint.py` | 8 | **Integration tests** for `POST /api/auth/refresh`. Covers: successful refresh (200, new access token, same refresh token returned), refresh produces different access tokens on repeated calls, invalid refresh token (401), empty body (422), missing field (422), refresh token from login also works, returned access token is valid JWT decodable with public key, `access_token_expires_minutes` in response. |
|
||||||
| `test_refresh_token_jti.py` | 6 | Unit tests for refresh token uniqueness (jti claim) and model column length. Covers: two calls with same sub produce different tokens (jti uniqueness), jti is valid UUID, rapid repeated calls all produce unique jtis, jti survives decode, `RefreshToken.token` column is `String(512)`, column type is `String`. |
|
| `test_refresh_token_jti.py` | 6 | Unit tests for refresh token uniqueness (jti claim) and model column length. Covers: two calls with same sub produce different tokens (jti uniqueness), jti is valid UUID, rapid repeated calls all produce unique jtis, jti survives decode, `RefreshToken.token` column is `String(512)`, column type is `String`. |
|
||||||
| `test_auth_expired_token.py` | 5 | **Integration tests** for expired-token rejection across auth endpoints. Covers: expired access JWT rejected by `GET /api/auth/me` (401), garbage access token rejected by `/me` (401), missing bearer token returns 401, expired refresh token (DB `expires_at` in past) rejected by `/refresh` (401), expired refresh JWT (past `exp` claim) rejected before DB lookup (401). |
|
| `test_auth_expired_token.py` | 5 | **Integration tests** for expired-token rejection across auth endpoints. Covers: expired access JWT rejected by `GET /api/auth/me` (401), garbage access token rejected by `/me` (401), missing bearer token returns 401, expired refresh token (DB `expires_at` in past) rejected by `/refresh` (401), expired refresh JWT (past `exp` claim) rejected before DB lookup (401). |
|
||||||
@@ -52,7 +56,10 @@ these yet):
|
|||||||
|
|
||||||
## Godot client — `scripts/smoke_test_*.gd`
|
## Godot client — `scripts/smoke_test_*.gd`
|
||||||
|
|
||||||
**6 smoke tests merged into `main`.** Each needs a live backend —
|
**12 smoke tests merged into `main`.** Ten need a live backend, one
|
||||||
|
(`smoke_test_network_client.gd`) needs a live dedicated server instead,
|
||||||
|
and one (`smoke_test_avatar_system.gd`) needs neither — it only exercises
|
||||||
|
local catalog/loadout/loader logic. Backend-dependent tests read
|
||||||
`AGP_API_BASE_URL` env var, falling back to the
|
`AGP_API_BASE_URL` env var, falling back to the
|
||||||
`application/agp/api_base_url` project setting (default
|
`application/agp/api_base_url` project setting (default
|
||||||
`http://127.0.0.1:8000`) if unset.
|
`http://127.0.0.1:8000`) if unset.
|
||||||
@@ -60,11 +67,17 @@ these yet):
|
|||||||
| File | What it covers |
|
| File | What it covers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `smoke_test_api.gd` | Exercises `ApiClient.health_check()` against a live backend. Verifies the real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, result-dictionary contract). Exits 1 on failure. |
|
| `smoke_test_api.gd` | Exercises `ApiClient.health_check()` against a live backend. Verifies the real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, result-dictionary contract). Exits 1 on failure. |
|
||||||
| `smoke_test_auth.gd` | Tests the full auth flow via `ApiClient` autoload: register unique user (201), duplicate username (409), under-18 registration (400), correct login (200), wrong password (401), nonexistent user (401). |
|
| `smoke_test_auth.gd` | Tests the full auth flow via `ApiClient` autoload: register unique user (201), duplicate username (409), `over_18=false` registration (400), correct login (200), wrong password (401), nonexistent user (401). |
|
||||||
| `smoke_test_e2e.gd` | End-to-end flow: health check → register → login → worlds list. Verifies the full request chain with a fresh user, including token storage and `get_worlds()` with auth header. |
|
| `smoke_test_e2e.gd` | End-to-end flow: health check → register → login → worlds list. Verifies the full request chain with a fresh user, including token storage and `get_worlds()` with auth header. |
|
||||||
| `smoke_test_world_list.gd` | Registers a user, **seeds a real world via `POST /api/worlds/`** (without this, `run_checkpoint_tests.py`'s always-fresh DB means the populated-card render path never actually runs in CI, only the empty-list path would), instantiates `world_list.tscn` as a real scene, waits for `_refresh_worlds()` to complete, confirms it reached the populated (not empty) state, and asserts **each rendered card's real computed width is >= 200px**. That last check is the one that matters: 2026-08-20, a real bug shipped where every card existed in the tree with a non-zero child count and no crash, but rendered at 1.0px wide (`ScrollContainer`'s `size_flags_horizontal`/`vertical` used the raw integer `4` = `Control.SIZE_SHRINK_CENTER` instead of `3` = `SIZE_EXPAND_FILL`) — invisible on screen, and a child-count-only check would never have caught it. Confirmed by deliberately reintroducing the bug: card width check correctly fails at 25px. |
|
| `smoke_test_world_list.gd` | Registers a user, **seeds a real world via `POST /api/worlds/`** (without this, `run_checkpoint_tests.py`'s always-fresh DB means the populated-card render path never actually runs in CI, only the empty-list path would), instantiates `world_list.tscn` as a real scene, waits for `_refresh_worlds()` to complete, confirms it reached the populated (not empty) state, and asserts **each rendered card's real computed width is >= 200px**. That last check is the one that matters: 2026-08-20, a real bug shipped where every card existed in the tree with a non-zero child count and no crash, but rendered at 1.0px wide (`ScrollContainer`'s `size_flags_horizontal`/`vertical` used the raw integer `4` = `Control.SIZE_SHRINK_CENTER` instead of `3` = `SIZE_EXPAND_FILL`) — invisible on screen, and a child-count-only check would never have caught it. Confirmed by deliberately reintroducing the bug: card width check correctly fails at 25px. |
|
||||||
| `smoke_test_main_menu_ui.gd` | Instantiates `main_menu.tscn`, confirms the auth panel starts hidden, and confirms a **real signal emission** on LoginButton/RegisterButton (not calling the handler method directly) actually opens it. This is the check that would have caught 2026-08-20's real bug: the auth panel and its logic were fully correct, but nothing in the scene was ever wired to open it, so Login/Register were completely unreachable from the UI. |
|
| `smoke_test_main_menu_ui.gd` | Instantiates `main_menu.tscn`, confirms the auth panel starts hidden, and confirms a **real signal emission** on LoginButton/RegisterButton (not calling the handler method directly) actually opens it. This is the check that would have caught 2026-08-20's real bug: the auth panel and its logic were fully correct, but nothing in the scene was ever wired to open it, so Login/Register were completely unreachable from the UI. |
|
||||||
| `smoke_test_main_menu_full_flow.gd` | Full click-through: open the register form, fill it exactly as a user would, submit, confirm Play actually becomes enabled, the welcome message shows, and the auth buttons hide themselves. Calls the submit handler directly (awaited) rather than via `auth_button.pressed.emit()` — see the note at the top of the file: a synthetic `.emit()` on an `await`-using handler doesn't get driven forward reliably in `--script`/SceneTree headless mode the way real GUI input event dispatch does, confirmed by comparing direct-call vs `.emit()` behavior. Not a bug in the actual click-driven flow, just a headless-test-harness quirk to route around when writing tests. |
|
| `smoke_test_main_menu_full_flow.gd` | Full click-through: open the register form, fill it exactly as a user would, submit, confirm Play actually becomes enabled, the welcome message shows, and the auth buttons hide themselves. Calls the submit handler directly (awaited) rather than via `auth_button.pressed.emit()` — see the note at the top of the file: a synthetic `.emit()` on an `await`-using handler doesn't get driven forward reliably in `--script`/SceneTree headless mode the way real GUI input event dispatch does, confirmed by comparing direct-call vs `.emit()` behavior. Not a bug in the actual click-driven flow, just a headless-test-harness quirk to route around when writing tests. |
|
||||||
|
| `smoke_test_main_menu_back.gd` | Regression test for Play → Back logging the user out even with a valid token still held. Registers a user, loads `main_menu.tscn` (asserts it restores the authenticated state via a real `auth_state_ready` signal wait, not a frame-count guess), clicks Play → `world_list.tscn`, clicks Back → `main_menu.tscn`, and asserts the menu is still authenticated. Checks `WelcomeLabel.is_visible_in_tree()`, not just `.visible` — the label lives inside AuthPanel, so `.visible` alone is a false positive if the panel itself is hidden (a real bug briefly present: `_show_authenticated()` hid AuthPanel while claiming the welcome label was visible, which rendered nothing). |
|
||||||
|
| `smoke_test_test_level.gd` | Loads `scenes/worlds/test_level.tscn` directly (the level is player-agnostic and self-contained per `docs/architecture/pc2_test_level.md` §4) and confirms it has real content: 5 props, a `DirectionalLight3D` ("Sun"), and a `WorldEnvironment` — not an empty void. |
|
||||||
|
| `smoke_test_player.gd` | Loads `scenes/game.tscn` and confirms the player exists as a real `CharacterBody3D` with a `CollisionShape3D`+shape, a `CapsuleMesh`, and spawns at the level's `PlayerSpawn` marker position. Also asserts `JUMP_VELOCITY` is positive and a `Camera3D` exists and is `current` — both were real bugs found 2026-08-20/21 (jump velocity was negative, pushing the player down instead of up; no camera existed at all, so the game was unplayable even though the character could technically move). Structural checks only — real movement/jump *direction* correctness was verified separately via a throwaway headless physics simulation during development, not by an automated test that's part of this suite. |
|
||||||
|
| `smoke_test_enter_world.gd` | The full PC2 path end to end: register → seed a real world → load `world_list.tscn` → click the real "Enter World" button on a real card (found by walking the card's children, not by index, so it doesn't silently pass if the card layout changes) → confirms `scenes/game.tscn` actually becomes `current_scene` with the player spawned at the level's `PlayerSpawn` marker → sends a real `KEY_ESCAPE` input event (not a direct method call) and confirms it returns to `world_list.tscn`. This is the test that proves "browse → enter → walk around → leave" actually works as one continuous flow, not just that each piece works in isolation. |
|
||||||
|
| `smoke_test_network_client.gd` | **Real two-process integration test** for `scripts/network_client.gd` (client-side prediction + reconciliation, docs/architecture/networking.md §5) — needs an actual dedicated server running first: `godot --headless --path . scenes/server.tscn &`, *not* the usual HTTP backend. Connects a real `NetworkClient` over ENet, confirms local input prediction moves the player immediately (no waiting on a round trip), confirms real snapshots arrive and get processed, then deliberately desyncs the predicted position by 9999 units and confirms the very next snapshot corrects it back — the actual proof reconciliation logic works, not just that it never had to fire. Known simplification, stated in the script's own docstring: the server's snapshot doesn't currently echo back a per-peer "last input seq processed," so reconciliation corrects against the *most recent* prediction rather than true tick-exact rollback-and-replay (docs/architecture/networking.md §5.2 step 4) — a real, scoped follow-up once the server echoes that ack, not something faked here. |
|
||||||
|
| `smoke_test_avatar_system.gd` | Confirms `AvatarSystem` is a real autoload, the committed `assets/avatars/catalog.json` actually parses, `get_assets_for_slot("base_body")` returns real entries, an `AvatarLoadout` can set `base_body` and equip a real catalog accessory, the loadout round-trips through `to_json()`/`load_loadout_from_json()`, and — since `assets/avatars/*.glb` model files don't exist yet — that `AvatarLoader.load_base_body()` against a real catalog entry with a missing file fails *gracefully*: returns `null` and emits `load_error` rather than crashing. Visual rendering itself can't be verified until real assets exist. |
|
||||||
|
|
||||||
Run (once, to build the class cache): `godot --headless --path . --import`,
|
Run (once, to build the class cache): `godot --headless --path . --import`,
|
||||||
then for each smoke test: `godot --headless --path . --script
|
then for each smoke test: `godot --headless --path . --script
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@ advanced_options=false
|
|||||||
dedicated_server=false
|
dedicated_server=false
|
||||||
custom_features=""
|
custom_features=""
|
||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter=""
|
include_filter="*.json"
|
||||||
exclude_filter=""
|
exclude_filter=""
|
||||||
export_path="export/linux/agp-game.x86_64"
|
export_path="export/linux/agp-game.x86_64"
|
||||||
encryption_include_filters=""
|
encryption_include_filters=""
|
||||||
@@ -38,7 +38,7 @@ advanced_options=false
|
|||||||
dedicated_server=false
|
dedicated_server=false
|
||||||
custom_features=""
|
custom_features=""
|
||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter=""
|
include_filter="*.json"
|
||||||
exclude_filter=""
|
exclude_filter=""
|
||||||
export_path="export/windows/agp-game.exe"
|
export_path="export/windows/agp-game.exe"
|
||||||
encryption_include_filters=""
|
encryption_include_filters=""
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ agp/api_base_url="http://127.0.0.1:8000"
|
|||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
ApiClient="*res://scripts/api_client.gd"
|
ApiClient="*res://scripts/api_client.gd"
|
||||||
|
AvatarSystem="*res://scripts/avatar/avatar_system.gd"
|
||||||
|
Session="*res://scripts/session.gd"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
@@ -21,6 +23,40 @@ window/size/viewport_width=1920
|
|||||||
window/size/viewport_height=1080
|
window/size/viewport_height=1080
|
||||||
window/stretch/mode="canvas_items"
|
window/stretch/mode="canvas_items"
|
||||||
|
|
||||||
|
[input]
|
||||||
|
|
||||||
|
move_forward={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
move_left={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
move_right={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
move_down={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
interact={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
[physics]
|
||||||
|
|
||||||
|
; Fixed 30 Hz sim -- the multiplayer server, client prediction, and snapshot
|
||||||
|
; rate all key off this. See docs/architecture/networking.md 3.1.
|
||||||
|
common/physics_ticks_per_second=30
|
||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
renderer/rendering_method="gl_compatibility"
|
renderer/rendering_method="gl_compatibility"
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://avatar_customization"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/avatar_customization_screen.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="AvatarCustomization" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
script = ExtResource("1")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://c0avpre7dev0rig"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/tools/avatar_preview.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="AvatarPreview" type="Node3D"]
|
||||||
|
script = ExtResource("1")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://bqs8xk2rig0dev"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/tools/screenshot_rig.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="ScreenshotRig" type="Node"]
|
||||||
|
script = ExtResource("1")
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[gd_scene load_steps=4 format=3 uid="uid://game_scene_pc2"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/game.gd" id="0_game"]
|
||||||
|
[ext_resource type="PackedScene" uid="uid://c49a36fc7db_test_level" path="res://scenes/worlds/test_level.tscn" id="1_level"]
|
||||||
|
[ext_resource type="PackedScene" uid="uid://player_scene_001" path="res://scenes/player.tscn" id="2_player"]
|
||||||
|
|
||||||
|
[node name="Game" type="Node3D"]
|
||||||
|
script = ExtResource("0_game")
|
||||||
|
|
||||||
|
[node name="Level" parent="." instance=ExtResource("1_level")]
|
||||||
|
|
||||||
|
[node name="Player" parent="." instance=ExtResource("2_player")]
|
||||||
@@ -83,11 +83,11 @@ visible = false
|
|||||||
placeholder_text = "Email"
|
placeholder_text = "Email"
|
||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
[node name="DOBEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
[node name="DobEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
visible = false
|
visible = false
|
||||||
placeholder_text = "Date of Birth (YYYY-MM-DD)"
|
|
||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
|
placeholder_text = "Date of birth (YYYY-MM-DD)"
|
||||||
|
|
||||||
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
@@ -112,6 +112,12 @@ layout_mode = 1
|
|||||||
text = "Play"
|
text = "Play"
|
||||||
size_flags_horizontal = 4
|
size_flags_horizontal = 4
|
||||||
|
|
||||||
|
[node name="CustomizeButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 1
|
||||||
|
text = "Customize Avatar"
|
||||||
|
size_flags_horizontal = 4
|
||||||
|
disabled = true
|
||||||
|
|
||||||
[node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
[node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
text = "Quit"
|
text = "Quit"
|
||||||
@@ -120,6 +126,7 @@ size_flags_horizontal = 4
|
|||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton" to="." method="_on_login_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton" to="." method="_on_login_button_pressed"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
|
||||||
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/CustomizeButton" to="." method="_on_customize_button_pressed"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
|
||||||
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
|
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
[gd_scene load_steps=5 format=3 uid="uid://player_scene_001"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/player.gd" id="1_player"]
|
||||||
|
[ext_resource type="Script" path="res://scripts/scripting/player_inventory.gd" id="2_inventory"]
|
||||||
|
|
||||||
|
[sub_resource type="CapsuleMesh" id="CapsuleMesh1"]
|
||||||
|
radius = 0.35
|
||||||
|
height = 1.6
|
||||||
|
|
||||||
|
[sub_resource type="CapsuleShape3D" id="CapsuleShape1"]
|
||||||
|
radius = 0.35
|
||||||
|
height = 1.6
|
||||||
|
|
||||||
|
[node name="Player" type="CharacterBody3D"]
|
||||||
|
collision_layer = 2
|
||||||
|
collision_mask = 1
|
||||||
|
script = ExtResource("1_player")
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||||
|
shape = SubResource("CapsuleShape1")
|
||||||
|
|
||||||
|
[node name="CapsuleMesh" type="MeshInstance3D" parent="."]
|
||||||
|
mesh = SubResource("CapsuleMesh1")
|
||||||
|
|
||||||
|
[node name="CameraPivot" type="Node3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2, 0)
|
||||||
|
|
||||||
|
[node name="SpringArm3D" type="SpringArm3D" parent="CameraPivot"]
|
||||||
|
spring_length = 3.5
|
||||||
|
margin = 0.2
|
||||||
|
|
||||||
|
[node name="Camera3D" type="Camera3D" parent="CameraPivot/SpringArm3D"]
|
||||||
|
current = true
|
||||||
|
|
||||||
|
[node name="Inventory" type="Node" parent="."]
|
||||||
|
script = ExtResource("2_inventory")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/server.gd" id="1_server"]
|
||||||
|
|
||||||
|
[node name="Server" type="Node"]
|
||||||
|
script = ExtResource("1_server")
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/player_spawner.gd" id="1"]
|
||||||
|
|
||||||
|
[sub_resource type="CapsuleShape3D" id="CapsuleShape_server_player"]
|
||||||
|
radius = 0.35
|
||||||
|
height = 1.6
|
||||||
|
|
||||||
|
[node name="Player" type="CharacterBody3D"]
|
||||||
|
collision_layer = 2
|
||||||
|
collision_mask = 1
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||||
|
shape = SubResource("CapsuleShape_server_player")
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://c49a36fc7db_test_level"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/scripting/script_interpreter.gd" id="1_scripted_chest"]
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_ground"]
|
||||||
|
size = Vector3(40, 1, 40)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_ground"]
|
||||||
|
size = Vector3(40, 1, 40)
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_prop1"]
|
||||||
|
size = Vector3(2, 3, 2)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_prop1"]
|
||||||
|
size = Vector3(2, 3, 2)
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_prop2"]
|
||||||
|
size = Vector3(3, 1, 3)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_prop2"]
|
||||||
|
size = Vector3(3, 1, 3)
|
||||||
|
|
||||||
|
[sub_resource type="CylinderShape3D" id="CylinderShape3D_prop3"]
|
||||||
|
top_radius = 1.0
|
||||||
|
height = 4.0
|
||||||
|
|
||||||
|
[sub_resource type="CylinderMesh" id="CylinderMesh_prop3"]
|
||||||
|
top_radius = 1.0
|
||||||
|
bottom_radius = 1.0
|
||||||
|
height = 4.0
|
||||||
|
subdivisions_top_radius = 8
|
||||||
|
|
||||||
|
[sub_resource type="SphereShape3D" id="SphereShape3D_prop4"]
|
||||||
|
radius = 1.0
|
||||||
|
|
||||||
|
[sub_resource type="SphereMesh" id="SphereMesh_prop4"]
|
||||||
|
radius = 1.0
|
||||||
|
subdivisions_ring = 12
|
||||||
|
subdivisions_height = 12
|
||||||
|
|
||||||
|
[sub_resource type="SphereShape3D" id="SphereShape3D_prop5"]
|
||||||
|
radius = 0.5
|
||||||
|
|
||||||
|
[sub_resource type="SphereMesh" id="SphereMesh_prop5"]
|
||||||
|
radius = 0.5
|
||||||
|
subdivisions_ring = 8
|
||||||
|
subdivisions_height = 8
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_chest"]
|
||||||
|
size = Vector3(1, 0.8, 0.6)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_chest"]
|
||||||
|
size = Vector3(1, 0.8, 0.6)
|
||||||
|
|
||||||
|
[sub_resource type="Environment" id="WorldEnvironment_env"]
|
||||||
|
background_mode = 1
|
||||||
|
background_color = Color(0.45, 0.62, 0.82, 1)
|
||||||
|
ambient_light_source = 2
|
||||||
|
ambient_light_color = Color(0.7, 0.75, 0.82, 1)
|
||||||
|
ambient_light_energy = 0.8
|
||||||
|
|
||||||
|
[node name="TestLevel" type="Node3D"]
|
||||||
|
|
||||||
|
[node name="Ground" type="StaticBody3D" parent="."]
|
||||||
|
position = Vector3(0, -0.5, 0)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Ground"]
|
||||||
|
shape = SubResource("BoxShape3D_ground")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Ground"]
|
||||||
|
mesh = SubResource("BoxMesh_ground")
|
||||||
|
|
||||||
|
[node name="Props" type="Node3D" parent="."]
|
||||||
|
|
||||||
|
[node name="Prop1_Box" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(8, 1.5, 8)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop1_Box"]
|
||||||
|
shape = SubResource("BoxShape3D_prop1")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop1_Box"]
|
||||||
|
mesh = SubResource("BoxMesh_prop1")
|
||||||
|
|
||||||
|
[node name="Prop2_Pedestal" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(-8, 0.5, 6)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop2_Pedestal"]
|
||||||
|
shape = SubResource("BoxShape3D_prop2")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop2_Pedestal"]
|
||||||
|
mesh = SubResource("BoxMesh_prop2")
|
||||||
|
|
||||||
|
[node name="Prop3_Column" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(6, 2.0, -8)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop3_Column"]
|
||||||
|
shape = SubResource("CylinderShape3D_prop3")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop3_Column"]
|
||||||
|
mesh = SubResource("CylinderMesh_prop3")
|
||||||
|
|
||||||
|
[node name="Prop4_Sphere" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(-6, 1.0, -6)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop4_Sphere"]
|
||||||
|
shape = SubResource("SphereShape3D_prop4")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop4_Sphere"]
|
||||||
|
mesh = SubResource("SphereMesh_prop4")
|
||||||
|
|
||||||
|
[node name="Prop5_SmallSphere" type="StaticBody3D" parent="Props"]
|
||||||
|
position = Vector3(0, 0.5, 12)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop5_SmallSphere"]
|
||||||
|
shape = SubResource("SphereShape3D_prop5")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop5_SmallSphere"]
|
||||||
|
mesh = SubResource("SphereMesh_prop5")
|
||||||
|
|
||||||
|
[node name="ScriptedChest" type="StaticBody3D" parent="."]
|
||||||
|
position = Vector3(0, 1.0, 1.5)
|
||||||
|
script = ExtResource("1_scripted_chest")
|
||||||
|
behavior_path = "res://assets/scripting/behaviors/demo_chest.json"
|
||||||
|
interact_radius = 2.5
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="ScriptedChest"]
|
||||||
|
shape = SubResource("BoxShape3D_chest")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="ScriptedChest"]
|
||||||
|
mesh = SubResource("BoxMesh_chest")
|
||||||
|
|
||||||
|
[node name="Sun" type="DirectionalLight3D" parent="."]
|
||||||
|
rotation = Vector3(-0.95, -0.5, 0)
|
||||||
|
light_energy = 1.3
|
||||||
|
shadow_enabled = true
|
||||||
|
|
||||||
|
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||||
|
environment = SubResource("WorldEnvironment_env")
|
||||||
|
|
||||||
|
[node name="PlayerSpawn" type="Marker3D" parent="."]
|
||||||
|
position = Vector3(0, 1.5, 0)
|
||||||
+25
-1
@@ -178,7 +178,8 @@ func login(username: String, password: String) -> Dictionary:
|
|||||||
|
|
||||||
|
|
||||||
## Register a new user. On success stores the returned access token.
|
## Register a new user. On success stores the returned access token.
|
||||||
## @param date_of_birth ISO date string "YYYY-MM-DD".
|
## @param date_of_birth ISO "YYYY-MM-DD". The backend computes age from it
|
||||||
|
## and rejects under-18s (403).
|
||||||
func register(
|
func register(
|
||||||
username: String,
|
username: String,
|
||||||
email: String,
|
email: String,
|
||||||
@@ -221,6 +222,29 @@ func get_me() -> Dictionary:
|
|||||||
return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
|
return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
|
||||||
|
|
||||||
|
|
||||||
|
## Fetch the current user's saved avatar loadout (requires auth).
|
||||||
|
## On success, result.data is {"data": <loadout dict>, "is_default": bool} --
|
||||||
|
## `is_default` true means the user has never customised and this is the
|
||||||
|
## server default.
|
||||||
|
func get_avatar_loadout() -> Dictionary:
|
||||||
|
return await request(HTTPClient.METHOD_GET, "/api/avatar/loadout", "", "application/json", true)
|
||||||
|
|
||||||
|
|
||||||
|
## Ask the backend for a dedicated server to host [param world_id] and return
|
||||||
|
## its address. On success result.data is {"world_id", "host", "port"}.
|
||||||
|
## 503 if this backend has no game-server orchestration.
|
||||||
|
func join_world(world_id: int) -> Dictionary:
|
||||||
|
return await request(HTTPClient.METHOD_POST, "/api/worlds/%d/join" % world_id, "", "application/json", true)
|
||||||
|
|
||||||
|
|
||||||
|
## Save (replace) the current user's avatar loadout (requires auth).
|
||||||
|
## @param loadout A dict in the AvatarLoadout.to_json() shape
|
||||||
|
## ({"base_body": String, "slots": {...}, "skin_tone"?: {...}}).
|
||||||
|
func save_avatar_loadout(loadout: Dictionary) -> Dictionary:
|
||||||
|
var body := JSON.stringify({"data": loadout})
|
||||||
|
return await request(HTTPClient.METHOD_PUT, "/api/avatar/loadout", body, "application/json", true)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Internals
|
# Internals
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
## AvatarCatalog - Asset catalog management.
|
||||||
|
##
|
||||||
|
## Manages the mapping of asset IDs to their metadata and resource paths.
|
||||||
|
## Loads the catalog manifest from disk and provides lookup by ID and slot.
|
||||||
|
##
|
||||||
|
## The catalog is loaded from `res://assets/avatars/catalog.json` which follows
|
||||||
|
## the format specified in avatar.md §7.2.
|
||||||
|
|
||||||
|
class_name AvatarCatalog
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
|
||||||
|
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
|
||||||
|
|
||||||
|
|
||||||
|
## Emitted when the catalog is loaded successfully.
|
||||||
|
signal catalog_loaded
|
||||||
|
|
||||||
|
## Emitted when the catalog fails to load.
|
||||||
|
signal catalog_load_failed(error_message: String)
|
||||||
|
|
||||||
|
## Catalog manifest structure:
|
||||||
|
## { "assets": { "id": { "type", "slot", "path", "version", ... } } }
|
||||||
|
var _catalog: Dictionary = {}
|
||||||
|
var _loaded: bool = false
|
||||||
|
var _catalog_path: String = "res://assets/avatars/catalog.json"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Load the catalog from disk. Call this before any lookups.
|
||||||
|
func load_catalog(path: String = _catalog_path) -> Error:
|
||||||
|
_catalog_path = path
|
||||||
|
_catalog = {}
|
||||||
|
|
||||||
|
var file = FileAccess.open(path, FileAccess.READ)
|
||||||
|
if file == null:
|
||||||
|
var err = FileAccess.get_open_error()
|
||||||
|
catalog_load_failed.emit("Cannot open catalog: %s (error %d)" % [path, err])
|
||||||
|
return err
|
||||||
|
|
||||||
|
var json_text: String = file.get_as_text()
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
var result: Variant = JSON.parse_string(json_text)
|
||||||
|
if result == null or not result is Dictionary:
|
||||||
|
catalog_load_failed.emit("Invalid JSON in catalog: %s" % path)
|
||||||
|
return ERR_PARSE_ERROR
|
||||||
|
|
||||||
|
var json: Dictionary = result as Dictionary
|
||||||
|
|
||||||
|
if not json.has("assets"):
|
||||||
|
catalog_load_failed.emit("Catalog missing 'assets' key")
|
||||||
|
return ERR_INVALID_DATA
|
||||||
|
|
||||||
|
_catalog = json["assets"]
|
||||||
|
_loaded = true
|
||||||
|
catalog_loaded.emit()
|
||||||
|
return OK
|
||||||
|
|
||||||
|
|
||||||
|
## Check if the catalog has been loaded.
|
||||||
|
func is_loaded() -> bool:
|
||||||
|
return _loaded
|
||||||
|
|
||||||
|
|
||||||
|
## Resolve an asset by its ID.
|
||||||
|
## Returns a Dictionary with keys: type, slot, path, version, etc.
|
||||||
|
## Returns null if the asset ID is not found.
|
||||||
|
func resolve(id: String) -> Dictionary:
|
||||||
|
if not _loaded:
|
||||||
|
return {}
|
||||||
|
return _catalog.get(id, {})
|
||||||
|
|
||||||
|
|
||||||
|
## Check if an asset ID exists in the catalog.
|
||||||
|
func has_asset(id: String) -> bool:
|
||||||
|
if not _loaded:
|
||||||
|
return false
|
||||||
|
return _catalog.has(id)
|
||||||
|
|
||||||
|
|
||||||
|
## Get all assets that belong to a given slot.
|
||||||
|
## Returns an array of asset ID strings.
|
||||||
|
func get_assets_by_slot(slot: String) -> Array[String]:
|
||||||
|
if not _loaded:
|
||||||
|
return []
|
||||||
|
var result: Array[String] = []
|
||||||
|
for id in _catalog:
|
||||||
|
var asset: Dictionary = _catalog[id]
|
||||||
|
if asset.get("slot") == slot:
|
||||||
|
result.append(id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
## Get all asset IDs in the catalog.
|
||||||
|
func get_all_ids() -> Array[String]:
|
||||||
|
if not _loaded:
|
||||||
|
return []
|
||||||
|
return _catalog.keys()
|
||||||
|
|
||||||
|
|
||||||
|
## Validate a loadout against the catalog.
|
||||||
|
## Returns a Dictionary with "valid" (bool) and "errors" (Array[String]).
|
||||||
|
func validate_loadout(loadout: AvatarLoadout) -> Dictionary:
|
||||||
|
var errors: Array[String] = []
|
||||||
|
|
||||||
|
# Check base_body
|
||||||
|
if loadout.base_body.is_empty():
|
||||||
|
errors.append("Loadout missing base_body")
|
||||||
|
elif not has_asset(loadout.base_body):
|
||||||
|
errors.append("Unknown base_body asset: %s" % loadout.base_body)
|
||||||
|
else:
|
||||||
|
var base: Dictionary = resolve(loadout.base_body)
|
||||||
|
if base.get("type") != "base_body":
|
||||||
|
errors.append("base_body asset '%s' is not type 'base_body'" % loadout.base_body)
|
||||||
|
|
||||||
|
# Check each slot
|
||||||
|
for slot_id in loadout.slots:
|
||||||
|
var slot_value = loadout.slots[slot_id]
|
||||||
|
|
||||||
|
if slot_value == null:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if slot_id == "accessory":
|
||||||
|
# Accessory is an array
|
||||||
|
if not slot_value is Array:
|
||||||
|
errors.append("Slot '%s' must be an array" % slot_id)
|
||||||
|
continue
|
||||||
|
for item_id in slot_value:
|
||||||
|
if not has_asset(item_id):
|
||||||
|
errors.append("Unknown accessory asset: %s" % item_id)
|
||||||
|
else:
|
||||||
|
var asset: Dictionary = resolve(item_id)
|
||||||
|
if asset.get("slot") != slot_id:
|
||||||
|
errors.append("Accessory '%s' slot '%s' does not match slot '%s'" % [
|
||||||
|
item_id, asset.get("slot"), slot_id
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
# Exclusive slot - single value
|
||||||
|
if not slot_value is String:
|
||||||
|
errors.append("Slot '%s' must be a string or null" % slot_id)
|
||||||
|
continue
|
||||||
|
if not has_asset(slot_value):
|
||||||
|
errors.append("Unknown asset in slot '%s': %s" % [slot_id, slot_value])
|
||||||
|
else:
|
||||||
|
var asset: Dictionary = resolve(slot_value)
|
||||||
|
if asset.get("slot") != slot_id:
|
||||||
|
errors.append("Asset '%s' slot '%s' does not match slot '%s'" % [
|
||||||
|
slot_value, asset.get("slot"), slot_id
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": errors.is_empty(),
|
||||||
|
"errors": errors
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
## Compute a hash of the loadout for change detection.
|
||||||
|
## Returns a 32-bit hash value (GDScript has no fixed-width uint type — int
|
||||||
|
## is 64-bit signed, the value is just constrained to 32 bits of range).
|
||||||
|
static func compute_loadout_hash(loadout: AvatarLoadout) -> int:
|
||||||
|
var hasher = HashingContext.new()
|
||||||
|
hasher.start(HashingContext.HASH_SHA256)
|
||||||
|
|
||||||
|
hasher.update(loadout.base_body.to_utf8_buffer())
|
||||||
|
for slot_id in loadout.slots:
|
||||||
|
var slot_value = loadout.slots[slot_id]
|
||||||
|
hasher.update(slot_id.to_utf8_buffer())
|
||||||
|
if slot_value is Array:
|
||||||
|
for item in slot_value:
|
||||||
|
hasher.update(item.to_utf8_buffer())
|
||||||
|
elif slot_value is String:
|
||||||
|
hasher.update(slot_value.to_utf8_buffer())
|
||||||
|
|
||||||
|
var hash_bytes = hasher.finish()
|
||||||
|
return hash_bytes[0] | (hash_bytes[1] << 8) | (hash_bytes[2] << 16) | (hash_bytes[3] << 24)
|
||||||
|
|
||||||
|
|
||||||
|
## Get the catalog path.
|
||||||
|
func get_catalog_path() -> String:
|
||||||
|
return _catalog_path
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ceyugve7rjxqs
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
## AvatarCustomizationUI
|
||||||
|
##
|
||||||
|
## The in-game avatar customization screen. Lets the player:
|
||||||
|
## - See a live preview of their avatar.
|
||||||
|
## - Browse and equip/unequip items per slot.
|
||||||
|
## - Adjust skin tone.
|
||||||
|
## - Save / load loadouts.
|
||||||
|
##
|
||||||
|
## The UI is built programmatically so it works without a pre-authored .tscn.
|
||||||
|
## It is a Control node that can be added to any scene.
|
||||||
|
|
||||||
|
class_name AvatarCustomizationUI
|
||||||
|
extends Control
|
||||||
|
|
||||||
|
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
|
||||||
|
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
|
||||||
|
const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd")
|
||||||
|
const AvatarLoader := preload("res://scripts/avatar/avatar_loader.gd")
|
||||||
|
const AvatarVisualizer := preload("res://scripts/avatar/avatar_visualizer.gd")
|
||||||
|
const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signals
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Emitted when the player closes the customization screen.
|
||||||
|
signal closed
|
||||||
|
|
||||||
|
## Emitted when the loadout changes (after equip/unequip/skin-tone).
|
||||||
|
signal loadout_changed(loadout: AvatarLoadout)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Exports / config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Path to the asset catalog manifest.
|
||||||
|
@export var catalog_path: String = "res://assets/avatars/catalog.json"
|
||||||
|
|
||||||
|
## The default base body to use when none is equipped.
|
||||||
|
@export var default_base_body: String = "base_human_male_01"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# State
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## The current loadout being edited.
|
||||||
|
var loadout: AvatarLoadout = null
|
||||||
|
|
||||||
|
## The AvatarSystem singleton (set in _ready).
|
||||||
|
var _system: Node = null
|
||||||
|
|
||||||
|
## The catalog (owned by AvatarSystem).
|
||||||
|
var _catalog: AvatarCatalog = null
|
||||||
|
|
||||||
|
## The loader (owned by AvatarSystem).
|
||||||
|
var _loader: AvatarLoader = null
|
||||||
|
|
||||||
|
## The visualizer that renders the preview.
|
||||||
|
var _visualizer: AvatarVisualizer = null
|
||||||
|
|
||||||
|
## The preview viewport container.
|
||||||
|
var _preview_container: SubViewportContainer = null
|
||||||
|
|
||||||
|
## The preview viewport.
|
||||||
|
var _preview_viewport: SubViewport = null
|
||||||
|
|
||||||
|
## The preview camera.
|
||||||
|
var _preview_camera: Camera3D = null
|
||||||
|
|
||||||
|
## The preview avatar root.
|
||||||
|
var _preview_root: Node3D = null
|
||||||
|
|
||||||
|
## Currently selected slot id.
|
||||||
|
var _selected_slot: String = ""
|
||||||
|
|
||||||
|
## Skin: a base colour picked from a swatch, plus a shade multiplier from the
|
||||||
|
## slider (0.5 = the swatch as-is, lower = darker, higher = lighter). The
|
||||||
|
## first swatch is the placeholder default yellow.
|
||||||
|
const SKIN_SWATCHES: Array[Color] = [
|
||||||
|
Color(0.96, 0.80, 0.19), # yellow (default)
|
||||||
|
Color(0.95, 0.78, 0.66), # pale
|
||||||
|
Color(0.83, 0.62, 0.46), # tan
|
||||||
|
Color(0.55, 0.38, 0.27), # brown
|
||||||
|
Color(0.33, 0.21, 0.15), # deep brown
|
||||||
|
Color(0.62, 0.62, 0.64), # grey
|
||||||
|
Color(0.78, 0.24, 0.24), # red
|
||||||
|
Color(0.30, 0.62, 0.34), # green
|
||||||
|
Color(0.26, 0.46, 0.76), # blue
|
||||||
|
Color(0.62, 0.36, 0.72), # purple
|
||||||
|
]
|
||||||
|
var _skin_base: Color = SKIN_SWATCHES[0]
|
||||||
|
|
||||||
|
## Turntable: the preview avatar's Y rotation (radians), whether the player is
|
||||||
|
## currently dragging it, and how long since they last touched it (drives the
|
||||||
|
## slow idle auto-spin). Starts facing the camera.
|
||||||
|
var _turntable_yaw: float = PI
|
||||||
|
var _turntable_dragging: bool = false
|
||||||
|
var _turntable_idle: float = 0.0
|
||||||
|
const _TURNTABLE_IDLE_SPIN_DELAY := 2.5
|
||||||
|
const _TURNTABLE_IDLE_SPIN_SPEED := 0.5
|
||||||
|
const _TURNTABLE_DRAG_SPEED := 0.01
|
||||||
|
|
||||||
|
## UI node references.
|
||||||
|
var _slot_list: VBoxContainer = null
|
||||||
|
var _item_list: VBoxContainer = null
|
||||||
|
var _slot_label: Label = null
|
||||||
|
var _item_label: Label = null
|
||||||
|
var _skin_tone_slider: HSlider = null
|
||||||
|
var _status_label: Label = null
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# Grab the AvatarSystem autoload.
|
||||||
|
_system = get_node_or_null("/root/AvatarSystem")
|
||||||
|
if _system == null:
|
||||||
|
push_error("AvatarCustomizationUI: AvatarSystem autoload not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
_catalog = _system.catalog
|
||||||
|
_loader = _system.loader
|
||||||
|
|
||||||
|
# Load the catalog if not already loaded.
|
||||||
|
if _catalog != null and not _catalog.is_loaded():
|
||||||
|
_catalog.load_catalog(catalog_path)
|
||||||
|
|
||||||
|
# Build the UI.
|
||||||
|
_build_ui()
|
||||||
|
|
||||||
|
# Seed from the player's live loadout (set at login) if there is one, so
|
||||||
|
# the screen opens showing what they're actually wearing; otherwise start
|
||||||
|
# from a bare default.
|
||||||
|
if _system != null and _system.current_loadout != null:
|
||||||
|
loadout = AvatarLoadout.from_json(_system.current_loadout.to_json())
|
||||||
|
if loadout == null or not loadout.validate():
|
||||||
|
loadout = AvatarLoadout.new()
|
||||||
|
loadout.base_body = default_base_body
|
||||||
|
|
||||||
|
# Initialize the visualizer.
|
||||||
|
_visualizer = AvatarVisualizer.new()
|
||||||
|
|
||||||
|
# Populate the slot list.
|
||||||
|
_populate_slots()
|
||||||
|
|
||||||
|
# Select the first slot.
|
||||||
|
if AvatarSlotRegistry.SLOT_IDS.size() > 0:
|
||||||
|
_select_slot(AvatarSlotRegistry.SLOT_IDS[0])
|
||||||
|
|
||||||
|
# Refresh the preview.
|
||||||
|
_refresh_preview()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# UI construction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Build the entire UI tree programmatically.
|
||||||
|
func _build_ui() -> void:
|
||||||
|
# Root layout: horizontal split (preview | controls).
|
||||||
|
var root_hbox := HBoxContainer.new()
|
||||||
|
root_hbox.name = "RootLayout"
|
||||||
|
root_hbox.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
root_hbox.add_theme_constant_override("separation", 16)
|
||||||
|
add_child(root_hbox)
|
||||||
|
|
||||||
|
# --- Left: preview (takes all the leftover width) ---
|
||||||
|
var preview_panel := PanelContainer.new()
|
||||||
|
preview_panel.name = "PreviewPanel"
|
||||||
|
preview_panel.custom_minimum_size = Vector2(420, 0)
|
||||||
|
preview_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
root_hbox.add_child(preview_panel)
|
||||||
|
|
||||||
|
var preview_margin := MarginContainer.new()
|
||||||
|
preview_margin.add_theme_constant_override("margin_left", 8)
|
||||||
|
preview_margin.add_theme_constant_override("margin_right", 8)
|
||||||
|
preview_margin.add_theme_constant_override("margin_top", 8)
|
||||||
|
preview_margin.add_theme_constant_override("margin_bottom", 8)
|
||||||
|
preview_panel.add_child(preview_margin)
|
||||||
|
|
||||||
|
var preview_vbox := VBoxContainer.new()
|
||||||
|
preview_vbox.add_theme_constant_override("separation", 6)
|
||||||
|
preview_margin.add_child(preview_vbox)
|
||||||
|
|
||||||
|
_preview_container = SubViewportContainer.new()
|
||||||
|
_preview_container.name = "PreviewViewportContainer"
|
||||||
|
_preview_container.stretch = true
|
||||||
|
_preview_container.custom_minimum_size = Vector2(400, 520)
|
||||||
|
_preview_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_preview_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
_preview_container.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
_preview_container.gui_input.connect(_on_preview_gui_input)
|
||||||
|
preview_vbox.add_child(_preview_container)
|
||||||
|
|
||||||
|
var rotate_hint := Label.new()
|
||||||
|
rotate_hint.text = "drag to rotate"
|
||||||
|
rotate_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
rotate_hint.add_theme_color_override("font_color", Color(1, 1, 1, 0.5))
|
||||||
|
rotate_hint.add_theme_font_size_override("font_size", 12)
|
||||||
|
preview_vbox.add_child(rotate_hint)
|
||||||
|
|
||||||
|
_preview_viewport = SubViewport.new()
|
||||||
|
_preview_viewport.name = "PreviewViewport"
|
||||||
|
_preview_viewport.size = Vector2i(400, 520)
|
||||||
|
_preview_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||||
|
_preview_viewport.transparent_bg = true
|
||||||
|
# Its own 3D world -- a SubViewport that shares the (Control-only) main
|
||||||
|
# world has no scenario/environment set up and renders nothing.
|
||||||
|
_preview_viewport.own_world_3d = true
|
||||||
|
_preview_container.add_child(_preview_viewport)
|
||||||
|
|
||||||
|
# Environment: ambient fill so the model reads even from a bad light angle.
|
||||||
|
var world_env := WorldEnvironment.new()
|
||||||
|
world_env.name = "PreviewEnv"
|
||||||
|
var env := Environment.new()
|
||||||
|
env.background_mode = Environment.BG_COLOR
|
||||||
|
env.background_color = Color(0.10, 0.11, 0.14)
|
||||||
|
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||||
|
env.ambient_light_color = Color(0.62, 0.64, 0.7)
|
||||||
|
env.ambient_light_energy = 0.7
|
||||||
|
world_env.environment = env
|
||||||
|
_preview_viewport.add_child(world_env)
|
||||||
|
|
||||||
|
# Camera for the preview -- framed to fit the full ~2 m avatar.
|
||||||
|
_preview_camera = Camera3D.new()
|
||||||
|
_preview_camera.name = "PreviewCamera"
|
||||||
|
# look_at needs a global transform, which only exists once in the tree.
|
||||||
|
_preview_camera.look_at_from_position(
|
||||||
|
Vector3(0, 1.05, 3.5), Vector3(0, 0.98, 0), Vector3.UP)
|
||||||
|
_preview_camera.current = true
|
||||||
|
_preview_viewport.add_child(_preview_camera)
|
||||||
|
|
||||||
|
# Avatar root inside the viewport.
|
||||||
|
_preview_root = Node3D.new()
|
||||||
|
_preview_root.name = "AvatarPreviewRoot"
|
||||||
|
_preview_viewport.add_child(_preview_root)
|
||||||
|
|
||||||
|
# Key + fill lights so the avatar is clearly visible.
|
||||||
|
var key := DirectionalLight3D.new()
|
||||||
|
key.name = "PreviewKeyLight"
|
||||||
|
key.rotation_degrees = Vector3(-35, 35, 0)
|
||||||
|
key.light_energy = 1.0
|
||||||
|
_preview_viewport.add_child(key)
|
||||||
|
var fill := DirectionalLight3D.new()
|
||||||
|
fill.name = "PreviewFillLight"
|
||||||
|
fill.rotation_degrees = Vector3(-20, -140, 0)
|
||||||
|
fill.light_energy = 0.35
|
||||||
|
_preview_viewport.add_child(fill)
|
||||||
|
|
||||||
|
# --- Right: controls ---
|
||||||
|
var controls_panel := PanelContainer.new()
|
||||||
|
controls_panel.name = "ControlsPanel"
|
||||||
|
controls_panel.custom_minimum_size = Vector2(520, 0)
|
||||||
|
root_hbox.add_child(controls_panel)
|
||||||
|
|
||||||
|
var controls_margin := MarginContainer.new()
|
||||||
|
controls_margin.add_theme_constant_override("margin_left", 12)
|
||||||
|
controls_margin.add_theme_constant_override("margin_right", 12)
|
||||||
|
controls_margin.add_theme_constant_override("margin_top", 12)
|
||||||
|
controls_margin.add_theme_constant_override("margin_bottom", 12)
|
||||||
|
controls_panel.add_child(controls_margin)
|
||||||
|
|
||||||
|
var controls_vbox := VBoxContainer.new()
|
||||||
|
controls_vbox.name = "Controls"
|
||||||
|
controls_vbox.add_theme_constant_override("separation", 8)
|
||||||
|
controls_margin.add_child(controls_vbox)
|
||||||
|
|
||||||
|
# Title.
|
||||||
|
var title := Label.new()
|
||||||
|
title.text = "Avatar Customization"
|
||||||
|
title.add_theme_font_size_override("font_size", 24)
|
||||||
|
controls_vbox.add_child(title)
|
||||||
|
|
||||||
|
# Slot selector.
|
||||||
|
var slot_header := Label.new()
|
||||||
|
slot_header.text = "Slots"
|
||||||
|
slot_header.add_theme_font_size_override("font_size", 16)
|
||||||
|
controls_vbox.add_child(slot_header)
|
||||||
|
|
||||||
|
var slot_scroll := ScrollContainer.new()
|
||||||
|
slot_scroll.custom_minimum_size = Vector2(0, 160)
|
||||||
|
controls_vbox.add_child(slot_scroll)
|
||||||
|
|
||||||
|
_slot_list = VBoxContainer.new()
|
||||||
|
_slot_list.name = "SlotList"
|
||||||
|
_slot_list.add_theme_constant_override("separation", 4)
|
||||||
|
slot_scroll.add_child(_slot_list)
|
||||||
|
|
||||||
|
# Item selector for the selected slot.
|
||||||
|
var item_header := Label.new()
|
||||||
|
item_header.text = "Items"
|
||||||
|
item_header.add_theme_font_size_override("font_size", 16)
|
||||||
|
controls_vbox.add_child(item_header)
|
||||||
|
|
||||||
|
var item_scroll := ScrollContainer.new()
|
||||||
|
item_scroll.custom_minimum_size = Vector2(0, 200)
|
||||||
|
controls_vbox.add_child(item_scroll)
|
||||||
|
|
||||||
|
_item_list = VBoxContainer.new()
|
||||||
|
_item_list.name = "ItemList"
|
||||||
|
_item_list.add_theme_constant_override("separation", 4)
|
||||||
|
item_scroll.add_child(_item_list)
|
||||||
|
|
||||||
|
# Skin colour: a row of preset swatches + a shade slider that darkens or
|
||||||
|
# lightens the picked colour. The first swatch is the default yellow.
|
||||||
|
var skin_header := Label.new()
|
||||||
|
skin_header.text = "Skin Colour"
|
||||||
|
skin_header.add_theme_font_size_override("font_size", 16)
|
||||||
|
controls_vbox.add_child(skin_header)
|
||||||
|
|
||||||
|
var swatch_grid := GridContainer.new()
|
||||||
|
swatch_grid.columns = 5
|
||||||
|
swatch_grid.add_theme_constant_override("h_separation", 4)
|
||||||
|
swatch_grid.add_theme_constant_override("v_separation", 4)
|
||||||
|
controls_vbox.add_child(swatch_grid)
|
||||||
|
for c in SKIN_SWATCHES:
|
||||||
|
swatch_grid.add_child(_make_swatch(c))
|
||||||
|
|
||||||
|
var shade_label := Label.new()
|
||||||
|
shade_label.text = "Shade"
|
||||||
|
shade_label.add_theme_font_size_override("font_size", 13)
|
||||||
|
controls_vbox.add_child(shade_label)
|
||||||
|
|
||||||
|
_skin_tone_slider = HSlider.new()
|
||||||
|
_skin_tone_slider.name = "ShadeSlider"
|
||||||
|
_skin_tone_slider.min_value = 0.0
|
||||||
|
_skin_tone_slider.max_value = 1.0
|
||||||
|
_skin_tone_slider.step = 0.01
|
||||||
|
_skin_tone_slider.value = 0.5
|
||||||
|
_skin_tone_slider.custom_minimum_size = Vector2(0, 24)
|
||||||
|
_skin_tone_slider.value_changed.connect(_on_skin_tone_changed)
|
||||||
|
controls_vbox.add_child(_skin_tone_slider)
|
||||||
|
|
||||||
|
# Status / error label.
|
||||||
|
_status_label = Label.new()
|
||||||
|
_status_label.name = "StatusLabel"
|
||||||
|
_status_label.text = ""
|
||||||
|
_status_label.add_theme_color_override("font_color", Color(1, 0.4, 0.4))
|
||||||
|
_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
controls_vbox.add_child(_status_label)
|
||||||
|
|
||||||
|
# Action buttons.
|
||||||
|
var button_row := HBoxContainer.new()
|
||||||
|
button_row.add_theme_constant_override("separation", 8)
|
||||||
|
controls_vbox.add_child(button_row)
|
||||||
|
|
||||||
|
var save_btn := Button.new()
|
||||||
|
save_btn.text = "Save Loadout"
|
||||||
|
save_btn.pressed.connect(_on_save_pressed)
|
||||||
|
button_row.add_child(save_btn)
|
||||||
|
|
||||||
|
var load_btn := Button.new()
|
||||||
|
load_btn.text = "Load Loadout"
|
||||||
|
load_btn.pressed.connect(_on_load_pressed)
|
||||||
|
button_row.add_child(load_btn)
|
||||||
|
|
||||||
|
var close_btn := Button.new()
|
||||||
|
close_btn.text = "Close"
|
||||||
|
close_btn.pressed.connect(_on_close_pressed)
|
||||||
|
button_row.add_child(close_btn)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Slot / item population
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Populate the slot list with buttons for each slot.
|
||||||
|
func _populate_slots() -> void:
|
||||||
|
if _slot_list == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Clear existing children.
|
||||||
|
for child in _slot_list.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
|
||||||
|
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||||
|
var btn := Button.new()
|
||||||
|
btn.name = "Slot_%s" % slot_id
|
||||||
|
btn.text = slot_id
|
||||||
|
btn.toggle_mode = true
|
||||||
|
btn.pressed.connect(_on_slot_button_pressed.bind(slot_id))
|
||||||
|
_slot_list.add_child(btn)
|
||||||
|
|
||||||
|
|
||||||
|
## Populate the item list for the currently selected slot.
|
||||||
|
func _populate_items() -> void:
|
||||||
|
if _item_list == null or _selected_slot.is_empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Clear existing children.
|
||||||
|
for child in _item_list.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
|
||||||
|
if _catalog == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get assets for this slot.
|
||||||
|
var assets: Array[String] = _catalog.get_assets_by_slot(_selected_slot)
|
||||||
|
|
||||||
|
# "None" button to unequip.
|
||||||
|
var none_btn := Button.new()
|
||||||
|
none_btn.text = "(none)"
|
||||||
|
none_btn.pressed.connect(_on_equip_pressed.bind(""))
|
||||||
|
_item_list.add_child(none_btn)
|
||||||
|
|
||||||
|
for asset_id in assets:
|
||||||
|
var btn := Button.new()
|
||||||
|
btn.text = asset_id
|
||||||
|
btn.pressed.connect(_on_equip_pressed.bind(asset_id))
|
||||||
|
_item_list.add_child(btn)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Slot selection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Handle a slot button press.
|
||||||
|
func _on_slot_button_pressed(slot_id: String) -> void:
|
||||||
|
_select_slot(slot_id)
|
||||||
|
|
||||||
|
|
||||||
|
## Select a slot and refresh the item list.
|
||||||
|
func _select_slot(slot_id: String) -> void:
|
||||||
|
_selected_slot = slot_id
|
||||||
|
|
||||||
|
# Update button toggle states.
|
||||||
|
if _slot_list != null:
|
||||||
|
for child in _slot_list.get_children():
|
||||||
|
if child is Button:
|
||||||
|
var btn := child as Button
|
||||||
|
btn.button_pressed = (btn.name == "Slot_%s" % slot_id)
|
||||||
|
|
||||||
|
_populate_items()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Equip / unequip
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Handle an equip/unequip action for the selected slot.
|
||||||
|
func _on_equip_pressed(asset_id: String) -> void:
|
||||||
|
if loadout == null or _selected_slot.is_empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
if _selected_slot == "accessory":
|
||||||
|
if asset_id.is_empty():
|
||||||
|
# Clear all accessories.
|
||||||
|
loadout.slots["accessory"] = []
|
||||||
|
else:
|
||||||
|
loadout.add_accessory(asset_id)
|
||||||
|
else:
|
||||||
|
loadout.set_equipment(_selected_slot, asset_id)
|
||||||
|
|
||||||
|
_refresh_preview()
|
||||||
|
loadout_changed.emit(loadout)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Turntable (drag to rotate the preview, slow auto-spin when idle)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if _preview_root == null:
|
||||||
|
return
|
||||||
|
if _turntable_dragging:
|
||||||
|
_turntable_idle = 0.0
|
||||||
|
else:
|
||||||
|
_turntable_idle += delta
|
||||||
|
if _turntable_idle > _TURNTABLE_IDLE_SPIN_DELAY:
|
||||||
|
_turntable_yaw += delta * _TURNTABLE_IDLE_SPIN_SPEED
|
||||||
|
_preview_root.rotation.y = _turntable_yaw
|
||||||
|
|
||||||
|
|
||||||
|
func _on_preview_gui_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
|
||||||
|
_turntable_dragging = event.pressed
|
||||||
|
_turntable_idle = 0.0
|
||||||
|
elif event is InputEventMouseMotion and _turntable_dragging:
|
||||||
|
_turntable_yaw -= event.relative.x * _TURNTABLE_DRAG_SPEED
|
||||||
|
_turntable_idle = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Preview refresh
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Rebuild the avatar preview from the current loadout.
|
||||||
|
func _refresh_preview() -> void:
|
||||||
|
if _visualizer == null or _preview_root == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Clear the preview root.
|
||||||
|
for child in _preview_root.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
|
||||||
|
# Validate the loadout.
|
||||||
|
if loadout == null or loadout.base_body.is_empty():
|
||||||
|
_status_label.text = "No base body selected."
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build the render tree.
|
||||||
|
_visualizer.load_loadout(loadout, _preview_root)
|
||||||
|
|
||||||
|
# Show any validation errors.
|
||||||
|
if not loadout.validation_errors.is_empty():
|
||||||
|
_status_label.text = "\n".join(loadout.validation_errors)
|
||||||
|
else:
|
||||||
|
_status_label.text = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Skin tone
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Build one clickable colour swatch.
|
||||||
|
func _make_swatch(color: Color) -> Button:
|
||||||
|
var b := Button.new()
|
||||||
|
b.custom_minimum_size = Vector2(40, 30)
|
||||||
|
b.tooltip_text = "#" + color.to_html(false)
|
||||||
|
for state in ["normal", "hover", "pressed", "focus", "disabled"]:
|
||||||
|
var sb := StyleBoxFlat.new()
|
||||||
|
sb.bg_color = color
|
||||||
|
sb.set_corner_radius_all(3)
|
||||||
|
if state == "hover" or state == "pressed" or state == "focus":
|
||||||
|
sb.set_border_width_all(2)
|
||||||
|
sb.border_color = Color.WHITE
|
||||||
|
b.add_theme_stylebox_override(state, sb)
|
||||||
|
b.pressed.connect(_on_skin_swatch_pressed.bind(color))
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
func _on_skin_swatch_pressed(color: Color) -> void:
|
||||||
|
_skin_base = color
|
||||||
|
_apply_skin_colour()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_skin_tone_changed(_value: float) -> void:
|
||||||
|
_apply_skin_colour()
|
||||||
|
|
||||||
|
|
||||||
|
## Combine the picked swatch with the shade slider and push it to the loadout.
|
||||||
|
func _apply_skin_colour() -> void:
|
||||||
|
if loadout == null:
|
||||||
|
return
|
||||||
|
var shade: float = _skin_tone_slider.value if _skin_tone_slider != null else 0.5
|
||||||
|
var col := _skin_base
|
||||||
|
if shade < 0.5:
|
||||||
|
col = _skin_base.lerp(Color.BLACK, (0.5 - shade) * 1.7)
|
||||||
|
elif shade > 0.5:
|
||||||
|
col = _skin_base.lerp(Color.WHITE, (shade - 0.5) * 1.3)
|
||||||
|
col.a = 1.0
|
||||||
|
loadout.skin_tone = col
|
||||||
|
_refresh_preview()
|
||||||
|
loadout_changed.emit(loadout)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Save / load
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _on_save_pressed() -> void:
|
||||||
|
if loadout == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Local cache (offline fallback / fast reopen).
|
||||||
|
var json := loadout.to_json()
|
||||||
|
var file := FileAccess.open("user://loadout.json", FileAccess.WRITE)
|
||||||
|
if file:
|
||||||
|
file.store_string(json)
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
# Apply to the live in-world avatar immediately.
|
||||||
|
if _system != null:
|
||||||
|
_system.set_loadout_from_dict(JSON.parse_string(json))
|
||||||
|
|
||||||
|
# Persist to the backend if we're signed in.
|
||||||
|
var api := get_node_or_null("/root/ApiClient")
|
||||||
|
if api != null and api.has_token():
|
||||||
|
_status_label.text = "Saving…"
|
||||||
|
var res: Dictionary = await api.save_avatar_loadout(JSON.parse_string(json))
|
||||||
|
_status_label.text = "Saved." if res.get("ok", false) else "Saved locally (backend: %s)" % res.get("detail", "error")
|
||||||
|
else:
|
||||||
|
_status_label.text = "Saved locally (not signed in)."
|
||||||
|
|
||||||
|
|
||||||
|
func _on_load_pressed() -> void:
|
||||||
|
if not FileAccess.file_exists("user://loadout.json"):
|
||||||
|
_status_label.text = "No saved loadout found."
|
||||||
|
return
|
||||||
|
|
||||||
|
var file := FileAccess.open("user://loadout.json", FileAccess.READ)
|
||||||
|
if file == null:
|
||||||
|
_status_label.text = "Failed to open saved loadout."
|
||||||
|
return
|
||||||
|
|
||||||
|
var json := file.get_as_text()
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
var loaded := AvatarLoadout.from_json(json)
|
||||||
|
if not loaded.validation_errors.is_empty():
|
||||||
|
_status_label.text = "Saved loadout invalid: %s" % "\n".join(loaded.validation_errors)
|
||||||
|
return
|
||||||
|
|
||||||
|
loadout = loaded
|
||||||
|
_refresh_preview()
|
||||||
|
loadout_changed.emit(loadout)
|
||||||
|
_status_label.text = "Loadout loaded."
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Close
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _on_close_pressed() -> void:
|
||||||
|
closed.emit()
|
||||||
|
queue_free()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://crqtef5rifgrv
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
## AvatarLoader - glTF/GLB import, skeleton merge, and skin re-targeting.
|
||||||
|
##
|
||||||
|
## Loads base body and attachment GLB assets, extracts skeletons and meshes,
|
||||||
|
## and re-targets attachment skins to the canonical skeleton by bone name.
|
||||||
|
##
|
||||||
|
## Per the avatar architecture spec (§6.2, §6.3):
|
||||||
|
## - The base body carries the canonical skeleton.
|
||||||
|
## - Attachments are skinned to the same skeleton by name-based re-targeting.
|
||||||
|
## - Static accessories are parented to their mount bone.
|
||||||
|
## - Body-part slots hide the corresponding base mesh.
|
||||||
|
|
||||||
|
class_name AvatarLoader
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
|
||||||
|
const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Maximum number of attachments allowed in the accessory slot.
|
||||||
|
const MAX_ACCESSORY_COUNT := 8
|
||||||
|
|
||||||
|
## The skin albedo baked into the placeholder base body by
|
||||||
|
## gen_placeholder_avatars.gd. apply_skin_tone() finds the mesh surfaces
|
||||||
|
## painted this colour (head + arms) and retints them.
|
||||||
|
const PLACEHOLDER_SKIN_ALBEDO := Color(0.96, 0.80, 0.19)
|
||||||
|
|
||||||
|
## Skeleton bone names that form the canonical rig (§3.1).
|
||||||
|
const CANONICAL_BONES := [
|
||||||
|
"Root",
|
||||||
|
"Hips",
|
||||||
|
"Spine",
|
||||||
|
"Chest",
|
||||||
|
"Neck",
|
||||||
|
"Head",
|
||||||
|
"HeadTop",
|
||||||
|
"Jaw",
|
||||||
|
"LeftEye",
|
||||||
|
"RightEye",
|
||||||
|
"LeftShoulder",
|
||||||
|
"LeftUpperArm",
|
||||||
|
"LeftLowerArm",
|
||||||
|
"LeftHand",
|
||||||
|
"RightShoulder",
|
||||||
|
"RightUpperArm",
|
||||||
|
"RightLowerArm",
|
||||||
|
"RightHand",
|
||||||
|
"LeftUpperLeg",
|
||||||
|
"LeftLowerLeg",
|
||||||
|
"LeftFoot",
|
||||||
|
"RightUpperLeg",
|
||||||
|
"RightLowerLeg",
|
||||||
|
"RightFoot",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signals
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Emitted when a base body has been loaded successfully.
|
||||||
|
signal base_body_loaded(avatar_node: Node3D)
|
||||||
|
|
||||||
|
## Emitted when an attachment has been loaded and merged.
|
||||||
|
signal attachment_loaded(slot_id: String, attachment_node: Node3D)
|
||||||
|
|
||||||
|
## Emitted on error during loading.
|
||||||
|
signal load_error(error_msg: String)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# State
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## The fully assembled avatar node (Skeleton3D + meshes + attachments).
|
||||||
|
var avatar_node: Node3D = null
|
||||||
|
|
||||||
|
## The Skeleton3D belonging to the avatar.
|
||||||
|
var skeleton: Skeleton3D = null
|
||||||
|
|
||||||
|
## Mapping of bone name -> bone index in the skeleton.
|
||||||
|
var _bone_index_map: Dictionary = {}
|
||||||
|
|
||||||
|
## Loaded base body meshes, keyed by slot id.
|
||||||
|
var _base_meshes: Dictionary = {}
|
||||||
|
|
||||||
|
## Loaded attachment nodes, keyed by slot id.
|
||||||
|
var _attachments: Dictionary = {}
|
||||||
|
|
||||||
|
## Whether the base body has been loaded.
|
||||||
|
var is_base_loaded: bool = false
|
||||||
|
|
||||||
|
## The avatar catalog reference (set by AvatarSystem).
|
||||||
|
var catalog: AvatarCatalog = null
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Initialize the loader with a reference to the avatar catalog.
|
||||||
|
func init_loader(cat: AvatarCatalog) -> void:
|
||||||
|
catalog = cat
|
||||||
|
|
||||||
|
|
||||||
|
## Load the base body asset identified by [param base_body_id].
|
||||||
|
## Returns the AvatarNode3D on success, or null on failure.
|
||||||
|
func load_base_body(base_body_id: String) -> Node3D:
|
||||||
|
if catalog == null:
|
||||||
|
load_error.emit("AvatarCatalog not set on AvatarLoader")
|
||||||
|
return null
|
||||||
|
|
||||||
|
var asset_meta = catalog.resolve(base_body_id)
|
||||||
|
if asset_meta == null:
|
||||||
|
load_error.emit("Unknown base_body asset: %s" % base_body_id)
|
||||||
|
return null
|
||||||
|
|
||||||
|
if asset_meta.type != "base_body":
|
||||||
|
load_error.emit("Asset '%s' is not a base_body, got '%s'" % [
|
||||||
|
base_body_id, asset_meta.type])
|
||||||
|
return null
|
||||||
|
|
||||||
|
var path: String = asset_meta.path
|
||||||
|
avatar_node = _instance_glb_as_avatar(path)
|
||||||
|
if avatar_node == null:
|
||||||
|
load_error.emit("Failed to load base body scene: %s" % path)
|
||||||
|
return null
|
||||||
|
|
||||||
|
skeleton = avatar_node.get_node_or_null("Skeleton3D") as Skeleton3D
|
||||||
|
if skeleton == null:
|
||||||
|
load_error.emit("No Skeleton3D found in base body: %s" % path)
|
||||||
|
avatar_node.queue_free()
|
||||||
|
return null
|
||||||
|
|
||||||
|
# Build bone index map for fast lookups.
|
||||||
|
_bone_index_map.clear()
|
||||||
|
for i in skeleton.get_bone_count():
|
||||||
|
var bone_name: String = skeleton.get_bone_name(i)
|
||||||
|
if not bone_name.is_empty():
|
||||||
|
_bone_index_map[bone_name] = i
|
||||||
|
|
||||||
|
# Extract base body meshes and store them.
|
||||||
|
_extract_base_meshes(avatar_node)
|
||||||
|
|
||||||
|
is_base_loaded = true
|
||||||
|
base_body_loaded.emit(avatar_node)
|
||||||
|
return avatar_node
|
||||||
|
|
||||||
|
|
||||||
|
## Load and merge an attachment into the avatar.
|
||||||
|
## [param slot_id] is the slot to equip into.
|
||||||
|
## [param asset_id] is the catalog id of the attachment.
|
||||||
|
## Returns the attachment Node3D on success, or null on failure.
|
||||||
|
func load_attachment(slot_id: String, asset_id: String) -> Node3D:
|
||||||
|
if not is_base_loaded:
|
||||||
|
load_error.emit("Cannot load attachment: base body not loaded")
|
||||||
|
return null
|
||||||
|
|
||||||
|
if catalog == null:
|
||||||
|
load_error.emit("AvatarCatalog not set on AvatarLoader")
|
||||||
|
return null
|
||||||
|
|
||||||
|
var asset_meta = catalog.resolve(asset_id)
|
||||||
|
if asset_meta == null:
|
||||||
|
load_error.emit("Unknown attachment asset: %s" % asset_id)
|
||||||
|
return null
|
||||||
|
|
||||||
|
if asset_meta.type != "attachment":
|
||||||
|
load_error.emit("Asset '%s' is not an attachment, got '%s'" % [
|
||||||
|
asset_id, asset_meta.type])
|
||||||
|
return null
|
||||||
|
|
||||||
|
# Check slot exclusivity for accessory slot.
|
||||||
|
if slot_id == "accessory":
|
||||||
|
var current_accessories: Array = _attachments.get("accessory", [])
|
||||||
|
if current_accessories.size() >= MAX_ACCESSORY_COUNT:
|
||||||
|
load_error.emit("Accessory slot full (%d/%d)" % [
|
||||||
|
MAX_ACCESSORY_COUNT, MAX_ACCESSORY_COUNT])
|
||||||
|
return null
|
||||||
|
|
||||||
|
var path: String = asset_meta.path
|
||||||
|
var attachment_node = _instance_glb_as_attachment(path, slot_id)
|
||||||
|
if attachment_node == null:
|
||||||
|
load_error.emit("Failed to load attachment scene: %s" % path)
|
||||||
|
return null
|
||||||
|
|
||||||
|
# If the attachment carries its own skin, retarget it to the canonical
|
||||||
|
# skeleton; static props (the common case) are unaffected.
|
||||||
|
_retarget_skin(attachment_node)
|
||||||
|
|
||||||
|
# Mount to the appropriate bone via a BoneAttachment3D so it follows the
|
||||||
|
# skeleton. Falls back to a plain skeleton child if the mount bone is
|
||||||
|
# missing (a bad catalog entry) rather than dropping the attachment.
|
||||||
|
var mount_bone: String = asset_meta.get("mount_bone", "")
|
||||||
|
if mount_bone.is_empty():
|
||||||
|
mount_bone = _slot_to_mount_bone(slot_id)
|
||||||
|
var mount_node: Node3D
|
||||||
|
if not mount_bone.is_empty() and skeleton.find_bone(mount_bone) != -1:
|
||||||
|
var ba := BoneAttachment3D.new()
|
||||||
|
ba.name = "Mount_%s" % slot_id if slot_id != "accessory" else "Mount_accessory_%s" % asset_id
|
||||||
|
ba.bone_name = mount_bone
|
||||||
|
skeleton.add_child(ba)
|
||||||
|
ba.add_child(attachment_node)
|
||||||
|
mount_node = ba
|
||||||
|
else:
|
||||||
|
skeleton.add_child(attachment_node)
|
||||||
|
mount_node = attachment_node
|
||||||
|
|
||||||
|
# Store the top-level mount node so unload can free the whole unit.
|
||||||
|
if slot_id == "accessory":
|
||||||
|
var list: Array = _attachments.get("accessory", [])
|
||||||
|
list.append(mount_node)
|
||||||
|
_attachments["accessory"] = list
|
||||||
|
else:
|
||||||
|
if _attachments.has(slot_id) and is_instance_valid(_attachments[slot_id]):
|
||||||
|
_attachments[slot_id].queue_free()
|
||||||
|
_attachments[slot_id] = mount_node
|
||||||
|
|
||||||
|
attachment_loaded.emit(slot_id, attachment_node)
|
||||||
|
return attachment_node
|
||||||
|
|
||||||
|
|
||||||
|
## Remove an attachment from the given slot.
|
||||||
|
func unload_attachment(slot_id: String) -> void:
|
||||||
|
if slot_id == "accessory":
|
||||||
|
for node in _attachments.get("accessory", []):
|
||||||
|
if is_instance_valid(node):
|
||||||
|
node.queue_free()
|
||||||
|
_attachments.erase("accessory")
|
||||||
|
else:
|
||||||
|
var node = _attachments.get(slot_id)
|
||||||
|
if is_instance_valid(node):
|
||||||
|
node.queue_free()
|
||||||
|
_attachments.erase(slot_id)
|
||||||
|
|
||||||
|
|
||||||
|
## Hide or show the base mesh for a given body-part slot.
|
||||||
|
func set_base_mesh_visible(slot_id: String, visible: bool) -> void:
|
||||||
|
var mesh = _base_meshes.get(slot_id)
|
||||||
|
if mesh and mesh is Node3D:
|
||||||
|
mesh.visible = visible
|
||||||
|
|
||||||
|
|
||||||
|
## Retint the base body's skin surfaces (the ones the generator painted
|
||||||
|
## PLACEHOLDER_SKIN_ALBEDO -- head + arms) to [param color]. A no-op if the
|
||||||
|
## colour is unset (transparent). Real segmented bodies will do this per mesh.
|
||||||
|
func apply_skin_tone(color: Color) -> void:
|
||||||
|
if color.a <= 0.0:
|
||||||
|
return
|
||||||
|
if avatar_node == null or not is_instance_valid(avatar_node):
|
||||||
|
return
|
||||||
|
var skel := avatar_node.get_node_or_null("Skeleton3D") as Skeleton3D
|
||||||
|
if skel == null:
|
||||||
|
return
|
||||||
|
for child in skel.get_children():
|
||||||
|
if not (child is MeshInstance3D):
|
||||||
|
continue
|
||||||
|
var mi := child as MeshInstance3D
|
||||||
|
if mi.mesh == null:
|
||||||
|
continue
|
||||||
|
for s in mi.mesh.get_surface_count():
|
||||||
|
var mat := mi.get_active_material(s)
|
||||||
|
if mat is BaseMaterial3D and \
|
||||||
|
(mat as BaseMaterial3D).albedo_color.is_equal_approx(PLACEHOLDER_SKIN_ALBEDO):
|
||||||
|
var tinted := StandardMaterial3D.new()
|
||||||
|
tinted.albedo_color = color
|
||||||
|
tinted.roughness = 0.9
|
||||||
|
mi.set_surface_override_material(s, tinted)
|
||||||
|
|
||||||
|
|
||||||
|
## Get the current loadout as a dictionary (for serialization).
|
||||||
|
func get_current_loadout() -> Dictionary:
|
||||||
|
var loadout: Dictionary = {
|
||||||
|
"base_body": "",
|
||||||
|
"slots": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine base body id from catalog.
|
||||||
|
if is_base_loaded and catalog != null:
|
||||||
|
# Walk through catalog to find which base_body loaded.
|
||||||
|
for id in catalog.get_all_ids():
|
||||||
|
var meta = catalog.resolve(id)
|
||||||
|
if meta and meta.type == "base_body":
|
||||||
|
loadout.base_body = id
|
||||||
|
break
|
||||||
|
|
||||||
|
# Collect equipped attachments.
|
||||||
|
for slot_id in _attachments:
|
||||||
|
if slot_id == "accessory":
|
||||||
|
loadout.slots[slot_id] = _attachments[slot_id] as Array
|
||||||
|
else:
|
||||||
|
var node = _attachments[slot_id]
|
||||||
|
if node is Node3D:
|
||||||
|
# Find the asset id by matching the node's name.
|
||||||
|
var aid: String = node.name
|
||||||
|
loadout.slots[slot_id] = aid
|
||||||
|
|
||||||
|
return loadout
|
||||||
|
|
||||||
|
|
||||||
|
## Reset the avatar to an unloaded state.
|
||||||
|
func reset() -> void:
|
||||||
|
if avatar_node and avatar_node is Node:
|
||||||
|
avatar_node.queue_free()
|
||||||
|
|
||||||
|
avatar_node = null
|
||||||
|
skeleton = null
|
||||||
|
_bone_index_map.clear()
|
||||||
|
_base_meshes.clear()
|
||||||
|
_attachments.clear()
|
||||||
|
is_base_loaded = false
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal: GLB Parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Instantiate a base-body .glb (a Godot-imported PackedScene) into an avatar
|
||||||
|
## structure:
|
||||||
|
## Avatar (Node3D)
|
||||||
|
## Skeleton3D -- the canonical rig + skin-bound body meshes
|
||||||
|
## BodyMeshes (Node3D) -- unused container, kept for compatibility
|
||||||
|
## Attachments (Node3D)-- equipped attachment nodes (added by load_attachment)
|
||||||
|
##
|
||||||
|
## Uses load().instantiate() rather than the runtime GLTFDocument path: the
|
||||||
|
## imported .scn is the one that actually ends up in an exported build, with
|
||||||
|
## the skeleton + skin set up by Godot's import pipeline. (The raw-bytes
|
||||||
|
## GLTFDocument path -- for future user-uploaded models -- misbehaved in a
|
||||||
|
## release build: no Skeleton3D produced, so the player fell back to a capsule.)
|
||||||
|
func _instance_glb_as_avatar(path: String) -> Node3D:
|
||||||
|
var packed = load(path)
|
||||||
|
if not (packed is PackedScene):
|
||||||
|
push_error("Base body is not an importable scene: %s" % path)
|
||||||
|
return null
|
||||||
|
var scene_root: Node = packed.instantiate()
|
||||||
|
if not (scene_root is Node3D):
|
||||||
|
push_error("Base body scene root is not a Node3D: %s" % path)
|
||||||
|
scene_root.free()
|
||||||
|
return null
|
||||||
|
|
||||||
|
var src_skel := _find_first_skeleton(scene_root)
|
||||||
|
if src_skel == null:
|
||||||
|
push_error("No Skeleton3D in base body scene: %s" % path)
|
||||||
|
scene_root.free()
|
||||||
|
return null
|
||||||
|
|
||||||
|
var avatar := Node3D.new()
|
||||||
|
avatar.name = "Avatar"
|
||||||
|
|
||||||
|
src_skel.get_parent().remove_child(src_skel)
|
||||||
|
src_skel.name = "Skeleton3D"
|
||||||
|
src_skel.transform = Transform3D.IDENTITY
|
||||||
|
_clear_owner_recursive(src_skel)
|
||||||
|
avatar.add_child(src_skel)
|
||||||
|
scene_root.free()
|
||||||
|
|
||||||
|
var body_meshes := Node3D.new()
|
||||||
|
body_meshes.name = "BodyMeshes"
|
||||||
|
avatar.add_child(body_meshes)
|
||||||
|
|
||||||
|
var attachments_container := Node3D.new()
|
||||||
|
attachments_container.name = "Attachments"
|
||||||
|
avatar.add_child(attachments_container)
|
||||||
|
|
||||||
|
return avatar
|
||||||
|
|
||||||
|
|
||||||
|
## Depth-first search for the first Skeleton3D under [param node].
|
||||||
|
func _find_first_skeleton(node: Node) -> Skeleton3D:
|
||||||
|
if node is Skeleton3D:
|
||||||
|
return node as Skeleton3D
|
||||||
|
for child in node.get_children():
|
||||||
|
var found := _find_first_skeleton(child)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
## Instantiate an attachment .glb (imported PackedScene) into a single named
|
||||||
|
## Node3D the caller can mount to a bone as one unit.
|
||||||
|
func _instance_glb_as_attachment(path: String, slot_id: String) -> Node3D:
|
||||||
|
var packed = load(path)
|
||||||
|
if not (packed is PackedScene):
|
||||||
|
push_error("Attachment is not an importable scene: %s" % path)
|
||||||
|
return null
|
||||||
|
var scene_root: Node = packed.instantiate()
|
||||||
|
if not (scene_root is Node3D):
|
||||||
|
push_error("Attachment scene root is not a Node3D: %s" % path)
|
||||||
|
scene_root.free()
|
||||||
|
return null
|
||||||
|
|
||||||
|
var attachment := Node3D.new()
|
||||||
|
attachment.name = "%s_%s" % [slot_id, path.get_file().get_basename()]
|
||||||
|
for child in scene_root.get_children().duplicate():
|
||||||
|
scene_root.remove_child(child)
|
||||||
|
_clear_owner_recursive(child)
|
||||||
|
attachment.add_child(child)
|
||||||
|
scene_root.free()
|
||||||
|
return attachment
|
||||||
|
|
||||||
|
|
||||||
|
## generate_scene() stamps every node with the GLB root as `owner`; that
|
||||||
|
## becomes inconsistent (and noisy) once we reparent the node elsewhere.
|
||||||
|
func _clear_owner_recursive(node: Node) -> void:
|
||||||
|
node.owner = null
|
||||||
|
for child in node.get_children():
|
||||||
|
_clear_owner_recursive(child)
|
||||||
|
|
||||||
|
|
||||||
|
## Index the base body's skinned meshes by slot so equipping a body-part
|
||||||
|
## attachment can hide the corresponding base mesh. generate_scene() parents
|
||||||
|
## skinned meshes under the Skeleton3D, so scan there.
|
||||||
|
func _extract_base_meshes(avatar: Node3D) -> void:
|
||||||
|
var skel := avatar.get_node_or_null("Skeleton3D") as Skeleton3D
|
||||||
|
if skel == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
for child in skel.get_children():
|
||||||
|
if child is MeshInstance3D:
|
||||||
|
# The single "Body" placeholder mesh is registered only under
|
||||||
|
# "torso" -- mapping it to every body-part slot would make it
|
||||||
|
# vanish the moment any one body-part item is equipped. A real
|
||||||
|
# multi-mesh body registers each mesh under its own slot.
|
||||||
|
if child.name == "Body":
|
||||||
|
_base_meshes["torso"] = child
|
||||||
|
else:
|
||||||
|
var slot_id := _mesh_name_to_slot(child.name)
|
||||||
|
if slot_id != "":
|
||||||
|
_base_meshes[slot_id] = child
|
||||||
|
|
||||||
|
|
||||||
|
## Map a mesh name to a slot id based on naming conventions (§4.3).
|
||||||
|
func _mesh_name_to_slot(mesh_name: String) -> String:
|
||||||
|
if mesh_name == "Body":
|
||||||
|
# Generic body mesh - covers torso, legs, arms.
|
||||||
|
return "torso"
|
||||||
|
if mesh_name == "head_Mesh":
|
||||||
|
return "head"
|
||||||
|
if mesh_name == "torso_Mesh":
|
||||||
|
return "torso"
|
||||||
|
if mesh_name == "legs_Mesh":
|
||||||
|
return "legs"
|
||||||
|
if mesh_name == "left_arm_Mesh":
|
||||||
|
return "left_arm"
|
||||||
|
if mesh_name == "right_arm_Mesh":
|
||||||
|
return "right_arm"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal: Skin Retargeting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Retarget the skin weights of an attachment node to the canonical skeleton.
|
||||||
|
## Walks all MeshInstance3D children and remaps joint indices by bone name.
|
||||||
|
func _retarget_skin(attachment_node: Node3D) -> void:
|
||||||
|
if skeleton == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
for child in attachment_node.get_children():
|
||||||
|
if child is MeshInstance3D:
|
||||||
|
var mesh_inst := child as MeshInstance3D
|
||||||
|
var mesh := mesh_inst.mesh
|
||||||
|
if mesh == null:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Note: MeshInstance3D.mesh is always a Mesh, never a MultiMesh —
|
||||||
|
# MultiMesh skinning goes through MultiMeshInstance3D.multimesh,
|
||||||
|
# a completely separate node/property, not reachable here.
|
||||||
|
if mesh is ArrayMesh:
|
||||||
|
var arr_mesh := mesh as ArrayMesh
|
||||||
|
var surface_count := arr_mesh.get_surface_count()
|
||||||
|
|
||||||
|
for surface_idx in surface_count:
|
||||||
|
var arrays := arr_mesh.surface_get_arrays(surface_idx)
|
||||||
|
# Static props (the common attachment) carry no bone/weight
|
||||||
|
# arrays at all -- nothing to retarget.
|
||||||
|
if arrays[Mesh.ARRAY_BONES] == null or arrays[Mesh.ARRAY_WEIGHTS] == null:
|
||||||
|
continue
|
||||||
|
var joints_array: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
|
||||||
|
var weights_array: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
|
||||||
|
|
||||||
|
if joints_array.is_empty():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Remap joint indices by bone name.
|
||||||
|
var new_joints := PackedInt32Array()
|
||||||
|
for i in range(0, joints_array.size(), 4):
|
||||||
|
for j in 4:
|
||||||
|
var old_joint := joints_array[i + j]
|
||||||
|
if old_joint < 0:
|
||||||
|
new_joints.append(0)
|
||||||
|
else:
|
||||||
|
var bone_name := skeleton.get_bone_name(old_joint)
|
||||||
|
var new_idx: int = _bone_index_map.get(bone_name, 0)
|
||||||
|
new_joints.append(new_idx)
|
||||||
|
|
||||||
|
arrays[Mesh.ARRAY_BONES] = new_joints
|
||||||
|
arr_mesh.surface_set_arrays(surface_idx, arrays)
|
||||||
|
|
||||||
|
|
||||||
|
## Parent an attachment node to its mount bone in the skeleton.
|
||||||
|
func _parent_attachment(attachment_node: Node3D, slot_id: String,
|
||||||
|
asset_meta: Dictionary) -> void:
|
||||||
|
|
||||||
|
if skeleton == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
var mount_bone: String = asset_meta.get("mount_bone", "")
|
||||||
|
if mount_bone.is_empty():
|
||||||
|
# Try to infer from slot.
|
||||||
|
mount_bone = _slot_to_mount_bone(slot_id)
|
||||||
|
|
||||||
|
if mount_bone.is_empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
var bone_idx := skeleton.find_bone(mount_bone)
|
||||||
|
if bone_idx < 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get the world transform of the bone at the time of attachment.
|
||||||
|
var bone_pose := skeleton.get_bone_pose(bone_idx)
|
||||||
|
var bone_transform := skeleton.global_transform * bone_pose
|
||||||
|
|
||||||
|
# Position the attachment at the bone's location.
|
||||||
|
attachment_node.global_transform = bone_transform
|
||||||
|
|
||||||
|
|
||||||
|
## Convert a slot id to its canonical mount bone name (§3.2).
|
||||||
|
func _slot_to_mount_bone(slot_id: String) -> String:
|
||||||
|
match slot_id:
|
||||||
|
"head": return "Head"
|
||||||
|
"hair": return "HeadTop"
|
||||||
|
"face": return "Jaw"
|
||||||
|
"torso", "chest", "back": return "Chest"
|
||||||
|
"left_arm": return "LeftUpperArm"
|
||||||
|
"right_arm": return "RightUpperArm"
|
||||||
|
"left_hand": return "LeftHand"
|
||||||
|
"right_hand": return "RightHand"
|
||||||
|
"legs": return "Hips"
|
||||||
|
"pants": return "Hips"
|
||||||
|
"left_foot": return "LeftFoot"
|
||||||
|
"right_foot": return "RightFoot"
|
||||||
|
"accessory": return "Root"
|
||||||
|
_: return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal: File Loading
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Load a file from the given path as a PackedByteArray.
|
||||||
|
func _load_file(path: String) -> PackedByteArray:
|
||||||
|
var file := FileAccess.open(path, FileAccess.READ)
|
||||||
|
if file == null:
|
||||||
|
return PackedByteArray()
|
||||||
|
var data := file.get_buffer(file.get_length())
|
||||||
|
file.close()
|
||||||
|
return data
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://br6x7f83dkrw2
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
## AvatarLoadout - Serializable loadout model + validation.
|
||||||
|
##
|
||||||
|
## Represents a complete avatar composition: a base body plus equipped items
|
||||||
|
## per slot. Validates slot exclusivity and references against a catalog.
|
||||||
|
##
|
||||||
|
## See docs/architecture/avatar.md §5 for the loadout JSON format.
|
||||||
|
|
||||||
|
class_name AvatarLoadout
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# Sibling avatar scripts are referenced via preload rather than by their
|
||||||
|
# global class_name so this module parses correctly from a bare clone (a
|
||||||
|
# headless `godot <scene>` run does not rebuild the global class registry;
|
||||||
|
# only an editor scan / `--import` does). See docs/architecture/avatar.md.
|
||||||
|
const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd")
|
||||||
|
# Self-reference for the static factory below -- referring to our own
|
||||||
|
# class_name doesn't resolve during a cold-cache compile (see note above).
|
||||||
|
const _Self := preload("res://scripts/avatar/avatar_loadout.gd")
|
||||||
|
|
||||||
|
|
||||||
|
## Maximum number of accessories allowed in the accessory slot.
|
||||||
|
const MAX_ACCESSORIES: int = 8
|
||||||
|
|
||||||
|
## The base body asset ID.
|
||||||
|
var base_body: String = ""
|
||||||
|
|
||||||
|
## Equipped items per slot. Keys are slot IDs from AvatarSlotRegistry.
|
||||||
|
## Exclusive slots hold a single asset ID (or null).
|
||||||
|
## The "accessory" slot holds an array of asset IDs.
|
||||||
|
var slots: Dictionary = {}
|
||||||
|
|
||||||
|
## Validation errors found during the last validate() call.
|
||||||
|
var validation_errors: Array[String] = []
|
||||||
|
|
||||||
|
## Skin tone color override (default: transparent = use asset colors).
|
||||||
|
var skin_tone: Color = Color.TRANSPARENT
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inner classes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Represents a single equipped item in a slot.
|
||||||
|
## Used by consumers to iterate over equipped items.
|
||||||
|
class SlotItem:
|
||||||
|
## The slot this item belongs to.
|
||||||
|
var slot_id: String
|
||||||
|
## The catalog asset ID of the equipped item.
|
||||||
|
var asset_id: String
|
||||||
|
|
||||||
|
func _init(p_slot_id: String, p_asset_id: String) -> void:
|
||||||
|
slot_id = p_slot_id
|
||||||
|
asset_id = p_asset_id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
validation_errors = []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Serialization
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Create a loadout from a JSON string.
|
||||||
|
static func from_json(json_string: String) -> AvatarLoadout:
|
||||||
|
var loadout := _Self.new()
|
||||||
|
var data: Variant = JSON.parse_string(json_string)
|
||||||
|
if not (data is Dictionary):
|
||||||
|
loadout.validation_errors.append("Loadout JSON root is not an object")
|
||||||
|
return loadout
|
||||||
|
|
||||||
|
var root: Dictionary = data as Dictionary
|
||||||
|
|
||||||
|
# Parse base_body.
|
||||||
|
if root.has("base_body"):
|
||||||
|
var bb = root["base_body"]
|
||||||
|
if typeof(bb) == TYPE_STRING:
|
||||||
|
loadout.base_body = bb as String
|
||||||
|
else:
|
||||||
|
loadout.validation_errors.append("base_body must be a string")
|
||||||
|
|
||||||
|
# Parse slots.
|
||||||
|
if root.has("slots"):
|
||||||
|
var slots_data = root["slots"]
|
||||||
|
if typeof(slots_data) == TYPE_DICTIONARY:
|
||||||
|
var parsed_slots: Dictionary = slots_data as Dictionary
|
||||||
|
for slot_id in parsed_slots:
|
||||||
|
if slot_id is String:
|
||||||
|
loadout.slots[slot_id] = parsed_slots[slot_id]
|
||||||
|
else:
|
||||||
|
loadout.validation_errors.append("slots must be an object")
|
||||||
|
else:
|
||||||
|
loadout.validation_errors.append("Missing 'slots' in loadout JSON")
|
||||||
|
|
||||||
|
# Parse skin_tone.
|
||||||
|
if root.has("skin_tone"):
|
||||||
|
var st = root["skin_tone"]
|
||||||
|
if typeof(st) == TYPE_DICTIONARY:
|
||||||
|
var tone: Dictionary = st as Dictionary
|
||||||
|
loadout.skin_tone = Color(
|
||||||
|
float(tone.get("r", 0.0)),
|
||||||
|
float(tone.get("g", 0.0)),
|
||||||
|
float(tone.get("b", 0.0)),
|
||||||
|
float(tone.get("a", 1.0))
|
||||||
|
)
|
||||||
|
|
||||||
|
return loadout
|
||||||
|
|
||||||
|
|
||||||
|
## Serialize this loadout to a JSON string.
|
||||||
|
func to_json() -> String:
|
||||||
|
var data := {
|
||||||
|
"base_body": base_body,
|
||||||
|
"slots": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||||
|
if slot_id == "accessory":
|
||||||
|
data["slots"][slot_id] = slots.get(slot_id, [])
|
||||||
|
else:
|
||||||
|
data["slots"][slot_id] = slots.get(slot_id, null)
|
||||||
|
|
||||||
|
# Include skin_tone if set.
|
||||||
|
if skin_tone != Color.TRANSPARENT:
|
||||||
|
data["skin_tone"] = {
|
||||||
|
"r": skin_tone.r,
|
||||||
|
"g": skin_tone.g,
|
||||||
|
"b": skin_tone.b,
|
||||||
|
"a": skin_tone.a,
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(data, " ")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Validate this loadout against the slot registry.
|
||||||
|
## Returns true if valid; populates validation_errors if not.
|
||||||
|
func validate() -> bool:
|
||||||
|
validation_errors.clear()
|
||||||
|
|
||||||
|
# Check base_body.
|
||||||
|
if base_body.is_empty():
|
||||||
|
validation_errors.append("base_body is required")
|
||||||
|
|
||||||
|
# Check each known slot.
|
||||||
|
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||||
|
var slot_def = AvatarSlotRegistry.get_slot(slot_id)
|
||||||
|
if slot_def == null:
|
||||||
|
validation_errors.append("Unknown slot: %s" % slot_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
var value = slots.get(slot_id)
|
||||||
|
|
||||||
|
if slot_def.exclusive:
|
||||||
|
# Exclusive slots: must be null or a string.
|
||||||
|
if value != null and typeof(value) != TYPE_STRING:
|
||||||
|
validation_errors.append(
|
||||||
|
"Slot '%s' must be null or a string, got %s" % [
|
||||||
|
slot_id, typeof(value)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Non-exclusive (accessory): must be null or an array.
|
||||||
|
if value != null and typeof(value) != TYPE_ARRAY:
|
||||||
|
validation_errors.append(
|
||||||
|
"Slot '%s' must be null or an array, got %s" % [
|
||||||
|
slot_id, typeof(value)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
elif value is Array:
|
||||||
|
if slot_id == "accessory" and (value as Array).size() > MAX_ACCESSORIES:
|
||||||
|
validation_errors.append(
|
||||||
|
"Accessory count (%d) exceeds maximum (%d)" % [
|
||||||
|
(value as Array).size(), MAX_ACCESSORIES
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for duplicate accessories.
|
||||||
|
if slots.has("accessory") and slots["accessory"] is Array:
|
||||||
|
var accessories := slots["accessory"] as Array
|
||||||
|
var seen := {}
|
||||||
|
for item in accessories:
|
||||||
|
if item is String:
|
||||||
|
if seen.has(item):
|
||||||
|
validation_errors.append("Duplicate accessory: %s" % item)
|
||||||
|
seen[item] = true
|
||||||
|
|
||||||
|
return validation_errors.is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
## Convenience alias for validate().
|
||||||
|
func is_valid() -> bool:
|
||||||
|
return validate()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Accessors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Check if a specific slot is equipped with a non-null item.
|
||||||
|
func is_equipped(slot_id: String) -> bool:
|
||||||
|
if not AvatarSlotRegistry.has_slot(slot_id):
|
||||||
|
return false
|
||||||
|
|
||||||
|
var value = slots.get(slot_id)
|
||||||
|
if value == null:
|
||||||
|
return false
|
||||||
|
|
||||||
|
if typeof(value) == TYPE_STRING:
|
||||||
|
return not (value as String).is_empty()
|
||||||
|
elif typeof(value) == TYPE_ARRAY:
|
||||||
|
return (value as Array).size() > 0
|
||||||
|
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
## Get the equipped asset ID for an exclusive slot.
|
||||||
|
## Returns empty string if not equipped or slot is not exclusive.
|
||||||
|
func get_equipment(slot_id: String) -> String:
|
||||||
|
var value = slots.get(slot_id)
|
||||||
|
if value is String:
|
||||||
|
return value
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
## Set equipment for an exclusive slot.
|
||||||
|
func set_equipment(slot_id: String, asset_id: String) -> void:
|
||||||
|
if not AvatarSlotRegistry.has_slot(slot_id):
|
||||||
|
push_error("Unknown slot: %s" % slot_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
var slot_def = AvatarSlotRegistry.get_slot(slot_id)
|
||||||
|
if slot_def and not slot_def.exclusive:
|
||||||
|
push_error("Slot '%s' is not exclusive" % slot_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
slots[slot_id] = null if asset_id.is_empty() else asset_id
|
||||||
|
|
||||||
|
|
||||||
|
## Add an accessory to the accessory slot.
|
||||||
|
func add_accessory(asset_id: String) -> bool:
|
||||||
|
var accessories := slots.get("accessory", []) as Array
|
||||||
|
if accessories.size() >= MAX_ACCESSORIES:
|
||||||
|
return false
|
||||||
|
if asset_id.is_empty():
|
||||||
|
return false
|
||||||
|
if accessories.has(asset_id):
|
||||||
|
return false
|
||||||
|
accessories.append(asset_id)
|
||||||
|
slots["accessory"] = accessories
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
## Remove an accessory from the accessory slot.
|
||||||
|
func remove_accessory(asset_id: String) -> bool:
|
||||||
|
var accessories := slots.get("accessory", []) as Array
|
||||||
|
if accessories.has(asset_id):
|
||||||
|
accessories.erase(asset_id)
|
||||||
|
slots["accessory"] = accessories
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
## Iterate over equipped items as SlotItem objects.
|
||||||
|
## Returns an array of SlotItem for each equipped slot.
|
||||||
|
func get_equipped_items() -> Array[SlotItem]:
|
||||||
|
var items: Array[SlotItem] = []
|
||||||
|
|
||||||
|
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||||
|
var value = slots.get(slot_id)
|
||||||
|
if value == null:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if typeof(value) == TYPE_STRING:
|
||||||
|
var asset_id := value as String
|
||||||
|
if not asset_id.is_empty():
|
||||||
|
items.append(SlotItem.new(slot_id, asset_id))
|
||||||
|
elif typeof(value) == TYPE_ARRAY:
|
||||||
|
for item_id in value:
|
||||||
|
if item_id is String and not (item_id as String).is_empty():
|
||||||
|
items.append(SlotItem.new(slot_id, item_id))
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hashing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Compute a hash of this loadout for change detection.
|
||||||
|
## Mirrors the server's loadout_hash concept.
|
||||||
|
func compute_hash() -> int:
|
||||||
|
var hasher := HashingContext.new()
|
||||||
|
hasher.start(HashingContext.HASH_SHA256)
|
||||||
|
hasher.update(base_body.to_utf8_buffer())
|
||||||
|
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||||
|
hasher.update(slot_id.to_utf8_buffer())
|
||||||
|
var value = slots.get(slot_id)
|
||||||
|
if value is String:
|
||||||
|
hasher.update((value as String).to_utf8_buffer())
|
||||||
|
elif value is Array:
|
||||||
|
var arr_str = JSON.stringify(value)
|
||||||
|
hasher.update(arr_str.to_utf8_buffer())
|
||||||
|
else:
|
||||||
|
hasher.update("null".to_utf8_buffer())
|
||||||
|
var digest = hasher.finish()
|
||||||
|
# Return first 4 bytes as int for compact storage.
|
||||||
|
return digest[0] | (digest[1] << 8) | (digest[2] << 16) | (digest[3] << 24)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dxdtebl4upeny
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
## AvatarSlotRegistry - Static registry of slot definitions.
|
||||||
|
##
|
||||||
|
## Each slot maps to one or more bones and defines what can be equipped there.
|
||||||
|
## This is the single source of truth for slot IDs, categories, mount bones,
|
||||||
|
## exclusivity, and body-part vs attachment classification.
|
||||||
|
##
|
||||||
|
## See docs/architecture/avatar.md §3.2 for the full slot specification.
|
||||||
|
class_name AvatarSlotRegistry extends RefCounted
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Slot definitions (mirrors §3.2 of the avatar spec)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## All known slot IDs, in canonical order.
|
||||||
|
const SLOT_IDS: PackedStringArray = [
|
||||||
|
"head",
|
||||||
|
"hair",
|
||||||
|
"face",
|
||||||
|
"torso",
|
||||||
|
"chest",
|
||||||
|
"back",
|
||||||
|
"left_arm",
|
||||||
|
"right_arm",
|
||||||
|
"left_hand",
|
||||||
|
"right_hand",
|
||||||
|
"legs",
|
||||||
|
"pants",
|
||||||
|
"left_foot",
|
||||||
|
"right_foot",
|
||||||
|
"accessory",
|
||||||
|
]
|
||||||
|
|
||||||
|
## Body-part slots (exclusive, replace base mesh).
|
||||||
|
const BODY_PART_SLOTS: PackedStringArray = [
|
||||||
|
"head", "torso", "legs", "left_arm", "right_arm",
|
||||||
|
]
|
||||||
|
|
||||||
|
## Attachment slots (layered on top of base mesh).
|
||||||
|
const ATTACHMENT_SLOTS: PackedStringArray = [
|
||||||
|
"hair", "face", "chest", "back",
|
||||||
|
"left_hand", "right_hand", "pants",
|
||||||
|
"left_foot", "right_foot", "accessory",
|
||||||
|
]
|
||||||
|
|
||||||
|
## Slots that are multi-slot (multiple accessories can coexist).
|
||||||
|
const MULTI_SLOT_IDS: PackedStringArray = ["accessory"]
|
||||||
|
|
||||||
|
|
||||||
|
## Metadata for a single slot.
|
||||||
|
class SlotDefinition:
|
||||||
|
## The slot ID string (e.g. "head", "hair").
|
||||||
|
var slot_key: String
|
||||||
|
## "body_part" or "attachment".
|
||||||
|
var category: String
|
||||||
|
## Primary mount bone name(s).
|
||||||
|
var mount_bones: PackedStringArray
|
||||||
|
## Whether at most one item can occupy this slot.
|
||||||
|
var exclusive: bool
|
||||||
|
## Whether this slot replaces a base mesh.
|
||||||
|
var is_body_part: bool
|
||||||
|
|
||||||
|
func _init(p_slot_key: String, p_category: String, p_mount_bones: PackedStringArray,
|
||||||
|
p_exclusive: bool, p_is_body_part: bool) -> void:
|
||||||
|
slot_key = p_slot_key
|
||||||
|
category = p_category
|
||||||
|
mount_bones = p_mount_bones
|
||||||
|
exclusive = p_exclusive
|
||||||
|
is_body_part = p_is_body_part
|
||||||
|
|
||||||
|
|
||||||
|
# Slot metadata lookup tables (indexed by SLOT_IDS position). Static: every
|
||||||
|
# caller uses AvatarSlotRegistry.method(...) directly on the class, never on
|
||||||
|
# an instance, so this data has to be built once at class-load time via
|
||||||
|
# _static_init() rather than an instance _init() that's never invoked.
|
||||||
|
# Each entry: { category, mount_bones, exclusive, is_body_part }
|
||||||
|
static var _slot_data: Dictionary = {}
|
||||||
|
static var _slot_definitions: Array[SlotDefinition] = []
|
||||||
|
|
||||||
|
|
||||||
|
static func _static_init() -> void:
|
||||||
|
_build_registry()
|
||||||
|
|
||||||
|
|
||||||
|
static func _build_registry() -> void:
|
||||||
|
_slot_data = {
|
||||||
|
"head": {
|
||||||
|
"category": "body_part",
|
||||||
|
"mount_bones": ["Head"],
|
||||||
|
"exclusive": true,
|
||||||
|
"is_body_part": true,
|
||||||
|
},
|
||||||
|
"hair": {
|
||||||
|
"category": "attachment",
|
||||||
|
"mount_bones": ["HeadTop"],
|
||||||
|
"exclusive": true,
|
||||||
|
"is_body_part": false,
|
||||||
|
},
|
||||||
|
"face": {
|
||||||
|
"category": "attachment",
|
||||||
|
"mount_bones": ["Jaw"],
|
||||||
|
"exclusive": true,
|
||||||
|
"is_body_part": false,
|
||||||
|
},
|
||||||
|
"torso": {
|
||||||
|
"category": "body_part",
|
||||||
|
"mount_bones": ["Chest"],
|
||||||
|
"exclusive": true,
|
||||||
|
"is_body_part": true,
|
||||||
|
},
|
||||||
|
"chest": {
|
||||||
|
"category": "attachment",
|
||||||
|
"mount_bones": ["Chest"],
|
||||||
|
"exclusive": true,
|
||||||
|
"is_body_part": false,
|
||||||
|
},
|
||||||
|
"back": {
|
||||||
|
"category": "attachment",
|
||||||
|
"mount_bones": ["Chest"],
|
||||||
|
"exclusive": true,
|
||||||
|
"is_body_part": false,
|
||||||
|
},
|
||||||
|
"left_arm": {
|
||||||
|
"category": "body_part",
|
||||||
|
"mount_bones": ["LeftUpperArm"],
|
||||||
|
"exclusive": true,
|
||||||
|
"is_body_part": true,
|
||||||
|
},
|
||||||
|
"right_arm": {
|
||||||
|
"category": "body_part",
|
||||||
|
"mount_bones": ["RightUpperArm"],
|
||||||
|
"exclusive": true,
|
||||||
|
"is_body_part": true,
|
||||||
|
},
|
||||||
|
"left_hand": {
|
||||||
|
"category": "attachment",
|
||||||
|
"mount_bones": ["LeftHand"],
|
||||||
|
"exclusive": true,
|
||||||
|
"is_body_part": false,
|
||||||
|
},
|
||||||
|
"right_hand": {
|
||||||
|
"category": "attachment",
|
||||||
|
"mount_bones": ["RightHand"],
|
||||||
|
"exclusive": true,
|
||||||
|
"is_body_part": false,
|
||||||
|
},
|
||||||
|
"legs": {
|
||||||
|
"category": "body_part",
|
||||||
|
"mount_bones": ["Hips"],
|
||||||
|
"exclusive": true,
|
||||||
|
"is_body_part": true,
|
||||||
|
},
|
||||||
|
"pants": {
|
||||||
|
"category": "attachment",
|
||||||
|
"mount_bones": ["Hips"],
|
||||||
|
"exclusive": true,
|
||||||
|
"is_body_part": false,
|
||||||
|
},
|
||||||
|
"left_foot": {
|
||||||
|
"category": "attachment",
|
||||||
|
"mount_bones": ["LeftFoot"],
|
||||||
|
"exclusive": true,
|
||||||
|
"is_body_part": false,
|
||||||
|
},
|
||||||
|
"right_foot": {
|
||||||
|
"category": "attachment",
|
||||||
|
"mount_bones": ["RightFoot"],
|
||||||
|
"exclusive": true,
|
||||||
|
"is_body_part": false,
|
||||||
|
},
|
||||||
|
"accessory": {
|
||||||
|
"category": "attachment",
|
||||||
|
"mount_bones": ["Root"],
|
||||||
|
"exclusive": false,
|
||||||
|
"is_body_part": false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build SlotDefinition objects.
|
||||||
|
for slot_id in SLOT_IDS:
|
||||||
|
var data = _slot_data[slot_id]
|
||||||
|
var def := SlotDefinition.new(
|
||||||
|
slot_id,
|
||||||
|
data["category"],
|
||||||
|
PackedStringArray(data["mount_bones"]),
|
||||||
|
data["exclusive"],
|
||||||
|
data["is_body_part"],
|
||||||
|
)
|
||||||
|
_slot_definitions.append(def)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Returns the list of all known slot IDs.
|
||||||
|
static func get_slot_ids() -> PackedStringArray:
|
||||||
|
return SLOT_IDS
|
||||||
|
|
||||||
|
|
||||||
|
## Returns true if *slot_id* is a known slot.
|
||||||
|
static func has_slot(slot_id: String) -> bool:
|
||||||
|
return slot_id in SLOT_IDS
|
||||||
|
|
||||||
|
|
||||||
|
## Returns the SlotDefinition for a slot, or null if unknown.
|
||||||
|
static func get_slot(slot_id: String) -> SlotDefinition:
|
||||||
|
if not has_slot(slot_id):
|
||||||
|
return null
|
||||||
|
for def in _slot_definitions:
|
||||||
|
if def.slot_key == slot_id:
|
||||||
|
return def
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
## Returns all SlotDefinition objects in canonical order.
|
||||||
|
static func get_slot_definitions() -> Array[SlotDefinition]:
|
||||||
|
return _slot_definitions
|
||||||
|
|
||||||
|
|
||||||
|
## Returns the category for a slot ("body_part" or "attachment").
|
||||||
|
static func get_category(slot_id: String) -> String:
|
||||||
|
if not has_slot(slot_id):
|
||||||
|
push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id)
|
||||||
|
return ""
|
||||||
|
return _slot_data[slot_id]["category"]
|
||||||
|
|
||||||
|
|
||||||
|
## Returns the primary mount bone(s) for a slot.
|
||||||
|
static func get_mount_bones(slot_id: String) -> PackedStringArray:
|
||||||
|
if not has_slot(slot_id):
|
||||||
|
push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id)
|
||||||
|
return PackedStringArray()
|
||||||
|
return PackedStringArray(_slot_data[slot_id]["mount_bones"])
|
||||||
|
|
||||||
|
|
||||||
|
## Returns whether the slot is exclusive (at most one item).
|
||||||
|
static func is_exclusive(slot_id: String) -> bool:
|
||||||
|
if not has_slot(slot_id):
|
||||||
|
push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id)
|
||||||
|
return true
|
||||||
|
return _slot_data[slot_id]["exclusive"]
|
||||||
|
|
||||||
|
|
||||||
|
## Returns whether the slot is a body-part slot (replaces base mesh).
|
||||||
|
static func is_body_part_slot(slot_id: String) -> bool:
|
||||||
|
if not has_slot(slot_id):
|
||||||
|
push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id)
|
||||||
|
return false
|
||||||
|
return _slot_data[slot_id]["is_body_part"]
|
||||||
|
|
||||||
|
|
||||||
|
## Returns whether the slot is a multi-slot (e.g. accessory).
|
||||||
|
static func is_multi_slot(slot_id: String) -> bool:
|
||||||
|
return slot_id in MULTI_SLOT_IDS
|
||||||
|
|
||||||
|
|
||||||
|
## Returns the list of body-part slot IDs.
|
||||||
|
static func get_body_part_slots() -> PackedStringArray:
|
||||||
|
return BODY_PART_SLOTS
|
||||||
|
|
||||||
|
|
||||||
|
## Returns the list of attachment slot IDs.
|
||||||
|
static func get_attachment_slots() -> PackedStringArray:
|
||||||
|
return ATTACHMENT_SLOTS
|
||||||
|
|
||||||
|
|
||||||
|
## Validates that an asset's declared slot matches the slot it is placed in.
|
||||||
|
static func validate_slot_assignment(asset_slot: String, slot_id: String) -> bool:
|
||||||
|
return asset_slot == slot_id
|
||||||
|
|
||||||
|
|
||||||
|
## Returns the canonical order index for a slot (for serialization order).
|
||||||
|
static func get_slot_index(slot_id: String) -> int:
|
||||||
|
return SLOT_IDS.find(slot_id)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://do873d38pqhwa
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
## AvatarSystem - Global avatar orchestrator (autoload singleton).
|
||||||
|
##
|
||||||
|
## Owns the AvatarCatalog, AvatarLoader, and the current player's AvatarLoadout.
|
||||||
|
## Provides a single entry point for the rest of the game to:
|
||||||
|
## - Load the asset catalog.
|
||||||
|
## - Load a player's avatar from a loadout.
|
||||||
|
## - Equip / unequip items and persist the resulting loadout.
|
||||||
|
##
|
||||||
|
## Registered as an autoload singleton named "AvatarSystem" in project.godot.
|
||||||
|
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
# Sibling avatar scripts are referenced via preload rather than by their global
|
||||||
|
# class_name so this autoload parses correctly from a bare clone -- a headless
|
||||||
|
# `godot <scene>` run does not rebuild the global class registry (only an editor
|
||||||
|
# scan / `--import` does), and this script is parsed at startup before any such
|
||||||
|
# scan could have happened. See docs/architecture/avatar.md.
|
||||||
|
const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd")
|
||||||
|
const AvatarLoader := preload("res://scripts/avatar/avatar_loader.gd")
|
||||||
|
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
|
||||||
|
const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signals
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Emitted when the catalog has finished loading.
|
||||||
|
signal catalog_loaded
|
||||||
|
|
||||||
|
## Emitted when the catalog fails to load.
|
||||||
|
signal catalog_load_failed(error_message: String)
|
||||||
|
|
||||||
|
## Emitted when the avatar has been fully assembled and is ready to render.
|
||||||
|
signal avatar_ready(avatar_node: Node3D)
|
||||||
|
|
||||||
|
## Emitted when the loadout changes (after equip/unequip).
|
||||||
|
signal loadout_changed(loadout: AvatarLoadout)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# State
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## The asset catalog (id -> metadata + path).
|
||||||
|
var catalog: AvatarCatalog = null
|
||||||
|
|
||||||
|
## The loader used to import GLB assets and assemble the avatar.
|
||||||
|
var loader: AvatarLoader = null
|
||||||
|
|
||||||
|
## The current player's loadout.
|
||||||
|
var current_loadout: AvatarLoadout = null
|
||||||
|
|
||||||
|
## The assembled avatar node (null until avatar_ready).
|
||||||
|
var avatar_node: Node3D = null
|
||||||
|
|
||||||
|
## Whether the catalog has been loaded.
|
||||||
|
var is_catalog_loaded: bool = false
|
||||||
|
|
||||||
|
## Whether an avatar is currently assembled.
|
||||||
|
var is_avatar_ready: bool = false
|
||||||
|
|
||||||
|
## Default catalog path.
|
||||||
|
const DEFAULT_CATALOG_PATH := "res://assets/avatars/catalog.json"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
catalog = AvatarCatalog.new()
|
||||||
|
loader = AvatarLoader.new()
|
||||||
|
loader.init_loader(catalog)
|
||||||
|
add_child(loader)
|
||||||
|
|
||||||
|
|
||||||
|
## Load the asset catalog. Call this once at startup (or on demand).
|
||||||
|
func load_catalog(path: String = DEFAULT_CATALOG_PATH) -> Error:
|
||||||
|
var err := catalog.load_catalog(path)
|
||||||
|
if err == OK:
|
||||||
|
is_catalog_loaded = true
|
||||||
|
catalog_loaded.emit()
|
||||||
|
else:
|
||||||
|
catalog_load_failed.emit("Failed to load catalog: %s" % err)
|
||||||
|
return err
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Avatar assembly
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Load and assemble an avatar from a loadout.
|
||||||
|
## Returns the assembled avatar Node3D on success, or null on failure.
|
||||||
|
func load_avatar(loadout: AvatarLoadout) -> Node3D:
|
||||||
|
if not is_catalog_loaded:
|
||||||
|
push_error("AvatarSystem: catalog not loaded; call load_catalog() first")
|
||||||
|
return null
|
||||||
|
|
||||||
|
if loadout == null:
|
||||||
|
push_error("AvatarSystem: loadout is null")
|
||||||
|
return null
|
||||||
|
|
||||||
|
# Validate the loadout against the catalog.
|
||||||
|
var validation := catalog.validate_loadout(loadout)
|
||||||
|
if not validation.valid:
|
||||||
|
push_error("AvatarSystem: invalid loadout: %s" % str(validation.errors))
|
||||||
|
return null
|
||||||
|
|
||||||
|
current_loadout = loadout
|
||||||
|
|
||||||
|
# Reset any previous avatar.
|
||||||
|
loader.reset()
|
||||||
|
|
||||||
|
# Load the base body.
|
||||||
|
var base_body_id := loadout.base_body
|
||||||
|
var base_node := loader.load_base_body(base_body_id)
|
||||||
|
if base_node == null:
|
||||||
|
push_error("AvatarSystem: failed to load base body '%s'" % base_body_id)
|
||||||
|
return null
|
||||||
|
|
||||||
|
# Load each equipped attachment.
|
||||||
|
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||||
|
var slot_value = loadout.slots.get(slot_id)
|
||||||
|
|
||||||
|
if slot_id == "accessory":
|
||||||
|
# Accessory is an array of asset ids.
|
||||||
|
if slot_value is Array:
|
||||||
|
for asset_id in slot_value:
|
||||||
|
if asset_id is String and not (asset_id as String).is_empty():
|
||||||
|
loader.load_attachment(slot_id, asset_id)
|
||||||
|
else:
|
||||||
|
# Exclusive slot: single asset id or null.
|
||||||
|
if slot_value is String and not (slot_value as String).is_empty():
|
||||||
|
loader.load_attachment(slot_id, slot_value)
|
||||||
|
|
||||||
|
# Hide base meshes for equipped body-part slots.
|
||||||
|
for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS:
|
||||||
|
var slot_value = loadout.slots.get(slot_id)
|
||||||
|
if slot_value is String and not (slot_value as String).is_empty():
|
||||||
|
loader.set_base_mesh_visible(slot_id, false)
|
||||||
|
|
||||||
|
avatar_node = loader.avatar_node
|
||||||
|
is_avatar_ready = avatar_node != null
|
||||||
|
if is_avatar_ready:
|
||||||
|
avatar_ready.emit(avatar_node)
|
||||||
|
|
||||||
|
return avatar_node
|
||||||
|
|
||||||
|
|
||||||
|
## Equip an item into a slot and rebuild the avatar.
|
||||||
|
## Returns true on success.
|
||||||
|
func equip(slot_id: String, asset_id: String) -> bool:
|
||||||
|
if current_loadout == null:
|
||||||
|
push_error("AvatarSystem: no loadout loaded")
|
||||||
|
return false
|
||||||
|
|
||||||
|
if not AvatarSlotRegistry.has_slot(slot_id):
|
||||||
|
push_error("AvatarSystem: unknown slot '%s'" % slot_id)
|
||||||
|
return false
|
||||||
|
|
||||||
|
if not catalog.has_asset(asset_id):
|
||||||
|
push_error("AvatarSystem: unknown asset '%s'" % asset_id)
|
||||||
|
return false
|
||||||
|
|
||||||
|
var asset := catalog.resolve(asset_id)
|
||||||
|
if asset.get("slot") != slot_id:
|
||||||
|
push_error("AvatarSystem: asset '%s' is for slot '%s', not '%s'" % [
|
||||||
|
asset_id, asset.get("slot"), slot_id])
|
||||||
|
return false
|
||||||
|
|
||||||
|
if slot_id == "accessory":
|
||||||
|
if not current_loadout.add_accessory(asset_id):
|
||||||
|
push_error("AvatarSystem: could not add accessory '%s'" % asset_id)
|
||||||
|
return false
|
||||||
|
else:
|
||||||
|
current_loadout.set_equipment(slot_id, asset_id)
|
||||||
|
|
||||||
|
loadout_changed.emit(current_loadout)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
## Unequip an item from a slot and rebuild the avatar.
|
||||||
|
## Returns true on success.
|
||||||
|
func unequip(slot_id: String, asset_id: String = "") -> bool:
|
||||||
|
if current_loadout == null:
|
||||||
|
push_error("AvatarSystem: no loadout loaded")
|
||||||
|
return false
|
||||||
|
|
||||||
|
if not AvatarSlotRegistry.has_slot(slot_id):
|
||||||
|
push_error("AvatarSystem: unknown slot '%s'" % slot_id)
|
||||||
|
return false
|
||||||
|
|
||||||
|
if slot_id == "accessory":
|
||||||
|
if asset_id.is_empty():
|
||||||
|
push_error("AvatarSystem: accessory unequip requires an asset id")
|
||||||
|
return false
|
||||||
|
if not current_loadout.remove_accessory(asset_id):
|
||||||
|
push_error("AvatarSystem: accessory '%s' not equipped" % asset_id)
|
||||||
|
return false
|
||||||
|
else:
|
||||||
|
current_loadout.set_equipment(slot_id, "")
|
||||||
|
|
||||||
|
loadout_changed.emit(current_loadout)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
## Serialize the current loadout to JSON.
|
||||||
|
func save_loadout() -> String:
|
||||||
|
if current_loadout == null:
|
||||||
|
return ""
|
||||||
|
return current_loadout.to_json()
|
||||||
|
|
||||||
|
|
||||||
|
## Load a loadout from a JSON string.
|
||||||
|
func load_loadout_from_json(json_string: String) -> AvatarLoadout:
|
||||||
|
var loadout := AvatarLoadout.from_json(json_string)
|
||||||
|
if not loadout.validate():
|
||||||
|
push_error("AvatarSystem: invalid loadout JSON: %s" % str(loadout.validation_errors))
|
||||||
|
return null
|
||||||
|
return loadout
|
||||||
|
|
||||||
|
|
||||||
|
## Get the list of available assets for a given slot.
|
||||||
|
func get_assets_for_slot(slot_id: String) -> Array[String]:
|
||||||
|
return catalog.get_assets_by_slot(slot_id)
|
||||||
|
|
||||||
|
|
||||||
|
## Get the display name for an asset id.
|
||||||
|
func get_asset_display_name(asset_id: String) -> String:
|
||||||
|
var asset := catalog.resolve(asset_id)
|
||||||
|
return asset.get("display_name", asset_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Local-player convenience API
|
||||||
|
#
|
||||||
|
# The rest of the game (the main menu, the in-world player) only needs three
|
||||||
|
# things: load the catalog once, hand us the player's loadout (as the dict the
|
||||||
|
# backend returns), and ask for an assembled avatar node to drop into a scene.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## True once a loadout has been handed to us (from the backend or a default).
|
||||||
|
func has_loadout() -> bool:
|
||||||
|
return current_loadout != null
|
||||||
|
|
||||||
|
|
||||||
|
## Ensure the catalog is loaded (idempotent). Safe to await repeatedly.
|
||||||
|
func ensure_catalog() -> bool:
|
||||||
|
if is_catalog_loaded:
|
||||||
|
return true
|
||||||
|
return load_catalog() == OK
|
||||||
|
|
||||||
|
|
||||||
|
## Set the current player's loadout from the dict shape the backend's
|
||||||
|
## GET /api/avatar/loadout returns (i.e. AvatarLoadout.to_json() parsed).
|
||||||
|
## Returns true if it validated and was stored.
|
||||||
|
func set_loadout_from_dict(loadout_dict: Dictionary) -> bool:
|
||||||
|
var lo := AvatarLoadout.from_json(JSON.stringify(loadout_dict))
|
||||||
|
if not lo.validate():
|
||||||
|
push_warning("AvatarSystem: rejecting invalid loadout dict: %s"
|
||||||
|
% str(lo.validation_errors))
|
||||||
|
return false
|
||||||
|
current_loadout = lo
|
||||||
|
loadout_changed.emit(current_loadout)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
## Assemble a fresh avatar Node3D from the current loadout, ready to parent
|
||||||
|
## into a scene (e.g. onto the in-world player). Returns null if there's no
|
||||||
|
## loadout or the catalog isn't loaded or assembly failed.
|
||||||
|
func build_current_avatar() -> Node3D:
|
||||||
|
# Self-sufficient: load the catalog if needed, and fall back to a bare
|
||||||
|
# default loadout when login didn't prime one -- the player should always
|
||||||
|
# get *an* avatar, never a placeholder capsule, as long as the assets exist.
|
||||||
|
if not ensure_catalog():
|
||||||
|
push_warning("AvatarSystem: catalog unavailable; cannot build avatar")
|
||||||
|
return null
|
||||||
|
if current_loadout == null:
|
||||||
|
var bases := get_assets_for_slot("base_body")
|
||||||
|
if bases.is_empty():
|
||||||
|
return null
|
||||||
|
set_loadout_from_dict({"base_body": bases[0], "slots": {}})
|
||||||
|
return assemble_avatar(current_loadout)
|
||||||
|
|
||||||
|
|
||||||
|
## Assemble a standalone avatar node from [param loadout] using a throwaway
|
||||||
|
## loader -- unlike load_avatar(), this touches no shared state, so it's safe
|
||||||
|
## to call many times (local player + one per remote peer). Returns null only
|
||||||
|
## if the assets are genuinely missing.
|
||||||
|
func assemble_avatar(loadout) -> Node3D:
|
||||||
|
if loadout == null or not is_catalog_loaded:
|
||||||
|
return null
|
||||||
|
var validation := catalog.validate_loadout(loadout)
|
||||||
|
if not validation.valid:
|
||||||
|
push_warning("AvatarSystem: invalid loadout, skipping: %s" % str(validation.errors))
|
||||||
|
return null
|
||||||
|
|
||||||
|
var l = AvatarLoader.new()
|
||||||
|
l.init_loader(catalog)
|
||||||
|
var base := l.load_base_body(loadout.base_body)
|
||||||
|
if base == null:
|
||||||
|
l.free()
|
||||||
|
return null
|
||||||
|
|
||||||
|
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||||
|
var v = loadout.slots.get(slot_id)
|
||||||
|
if slot_id == "accessory":
|
||||||
|
if v is Array:
|
||||||
|
for aid in v:
|
||||||
|
if aid is String and not (aid as String).is_empty():
|
||||||
|
l.load_attachment(slot_id, aid)
|
||||||
|
elif v is String and not (v as String).is_empty():
|
||||||
|
l.load_attachment(slot_id, v)
|
||||||
|
|
||||||
|
for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS:
|
||||||
|
var v = loadout.slots.get(slot_id)
|
||||||
|
if v is String and not (v as String).is_empty():
|
||||||
|
l.set_base_mesh_visible(slot_id, false)
|
||||||
|
|
||||||
|
l.apply_skin_tone(loadout.skin_tone)
|
||||||
|
|
||||||
|
l.free()
|
||||||
|
return base
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cfbvgfisbxbk
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
## AvatarVisualizer
|
||||||
|
##
|
||||||
|
## Renders an AvatarLoadout into a 3D node tree for previews (the customization
|
||||||
|
## screen). A thin wrapper over AvatarLoader -- it owns its own loader instance
|
||||||
|
## and catalog reference so building a preview never disturbs AvatarSystem's
|
||||||
|
## live in-world avatar state.
|
||||||
|
##
|
||||||
|
## Usage:
|
||||||
|
## var viz := AvatarVisualizer.new()
|
||||||
|
## viz.load_loadout(loadout, preview_root_node3d) # preview_root gets the avatar as a child
|
||||||
|
## ...
|
||||||
|
## viz.clear()
|
||||||
|
|
||||||
|
class_name AvatarVisualizer
|
||||||
|
extends Object
|
||||||
|
|
||||||
|
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
|
||||||
|
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
|
||||||
|
const AvatarLoader := preload("res://scripts/avatar/avatar_loader.gd")
|
||||||
|
const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd")
|
||||||
|
const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd")
|
||||||
|
|
||||||
|
## Emitted when the visualizer has finished building the render tree.
|
||||||
|
signal build_complete
|
||||||
|
## Emitted when the visualizer has been cleared.
|
||||||
|
signal cleared
|
||||||
|
|
||||||
|
## The node the assembled avatar is parented under.
|
||||||
|
var root: Node3D = null
|
||||||
|
|
||||||
|
## The assembled avatar node (child of `root`).
|
||||||
|
var avatar_node: Node3D = null
|
||||||
|
|
||||||
|
## The loadout currently rendered.
|
||||||
|
var current_loadout: AvatarLoadout = null
|
||||||
|
|
||||||
|
var _loader = null
|
||||||
|
var _catalog = null
|
||||||
|
|
||||||
|
|
||||||
|
## Build the avatar for [param loadout] and parent it under [param root_node]
|
||||||
|
## (or the previously-set `root`). Replaces any previously-built avatar.
|
||||||
|
func load_loadout(loadout, root_node: Node3D = null) -> void:
|
||||||
|
if root_node != null:
|
||||||
|
root = root_node
|
||||||
|
if root == null or loadout == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
clear()
|
||||||
|
current_loadout = loadout
|
||||||
|
|
||||||
|
if _catalog == null:
|
||||||
|
var sys = root.get_node_or_null("/root/AvatarSystem")
|
||||||
|
if sys != null:
|
||||||
|
_catalog = sys.catalog
|
||||||
|
if _catalog == null:
|
||||||
|
_catalog = AvatarCatalog.new()
|
||||||
|
_catalog.load_catalog("res://assets/avatars/catalog.json")
|
||||||
|
|
||||||
|
_loader = AvatarLoader.new()
|
||||||
|
root.add_child(_loader)
|
||||||
|
_loader.init_loader(_catalog)
|
||||||
|
|
||||||
|
var base: Node3D = _loader.load_base_body(loadout.base_body)
|
||||||
|
if base == null:
|
||||||
|
return
|
||||||
|
root.add_child(base)
|
||||||
|
avatar_node = base
|
||||||
|
|
||||||
|
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||||
|
var value = loadout.slots.get(slot_id)
|
||||||
|
if slot_id == "accessory":
|
||||||
|
if value is Array:
|
||||||
|
for aid in value:
|
||||||
|
if aid is String and not (aid as String).is_empty():
|
||||||
|
_loader.load_attachment(slot_id, aid)
|
||||||
|
elif value is String and not (value as String).is_empty():
|
||||||
|
_loader.load_attachment(slot_id, value)
|
||||||
|
|
||||||
|
for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS:
|
||||||
|
var value = loadout.slots.get(slot_id)
|
||||||
|
if value is String and not (value as String).is_empty():
|
||||||
|
_loader.set_base_mesh_visible(slot_id, false)
|
||||||
|
|
||||||
|
_loader.apply_skin_tone(loadout.skin_tone)
|
||||||
|
|
||||||
|
build_complete.emit()
|
||||||
|
|
||||||
|
|
||||||
|
## Tear down the current preview avatar.
|
||||||
|
func clear() -> void:
|
||||||
|
if avatar_node != null and is_instance_valid(avatar_node):
|
||||||
|
avatar_node.queue_free()
|
||||||
|
avatar_node = null
|
||||||
|
if _loader != null and is_instance_valid(_loader):
|
||||||
|
_loader.queue_free()
|
||||||
|
_loader = null
|
||||||
|
current_loadout = null
|
||||||
|
cleared.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func get_root() -> Node3D:
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
func get_all_mesh_instances() -> Array[MeshInstance3D]:
|
||||||
|
var out: Array[MeshInstance3D] = []
|
||||||
|
if avatar_node:
|
||||||
|
_collect_meshes(avatar_node, out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func _collect_meshes(node: Node, out: Array[MeshInstance3D]) -> void:
|
||||||
|
if node is MeshInstance3D:
|
||||||
|
out.append(node)
|
||||||
|
for child in node.get_children():
|
||||||
|
_collect_meshes(child, out)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cvr5mmbv2worn
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
extends Node
|
||||||
|
## Procedural locomotion for the placeholder blocky avatar.
|
||||||
|
##
|
||||||
|
## There are no animation clips yet -- this just swings the upper arm/leg bones
|
||||||
|
## around their local X by a sine wave whose phase advances with distance
|
||||||
|
## travelled, bends the knees on the rear half of the stride, and eases the
|
||||||
|
## whole thing back to the rest pose when the character stops.
|
||||||
|
##
|
||||||
|
## The owner sets `speed` (horizontal m/s) every frame; setup() is given the
|
||||||
|
## avatar's Skeleton3D. Bones are addressed by canonical name, so this works
|
||||||
|
## for the local player and for snapshot-driven remote players alike.
|
||||||
|
|
||||||
|
const STRIDE := 0.42 ## peak hip swing, radians (~24 deg)
|
||||||
|
const ARM_SWING := 0.32 ## peak shoulder swing, radians
|
||||||
|
const KNEE := 0.5 ## peak knee bend, radians
|
||||||
|
const CYCLE := 1.9 ## phase (radians) advanced per metre travelled
|
||||||
|
const BLEND := 12.0 ## ease rate toward the target pose
|
||||||
|
const MOVE_EPS := 0.35 ## m/s below which we treat the avatar as idle
|
||||||
|
|
||||||
|
var speed: float = 0.0
|
||||||
|
|
||||||
|
var _skel: Skeleton3D = null
|
||||||
|
var _idx := {} # bone name -> skeleton index
|
||||||
|
var _phase := 0.0
|
||||||
|
var _amount := 0.0 # 0 = idle, 1 = full walk
|
||||||
|
|
||||||
|
const _BONES := [
|
||||||
|
"LeftUpperLeg", "RightUpperLeg", "LeftLowerLeg", "RightLowerLeg",
|
||||||
|
"LeftUpperArm", "RightUpperArm",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
func setup(skeleton: Skeleton3D) -> void:
|
||||||
|
_skel = skeleton
|
||||||
|
_idx.clear()
|
||||||
|
if skeleton == null:
|
||||||
|
return
|
||||||
|
for b in _BONES:
|
||||||
|
var i := skeleton.find_bone(b)
|
||||||
|
if i != -1:
|
||||||
|
_idx[b] = i
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if not is_instance_valid(_skel) or _idx.is_empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
var moving := speed > MOVE_EPS
|
||||||
|
_amount = lerpf(_amount, 1.0 if moving else 0.0, clampf(BLEND * delta, 0.0, 1.0))
|
||||||
|
_phase += maxf(speed, 0.0) * delta * CYCLE
|
||||||
|
if _phase > TAU:
|
||||||
|
_phase -= TAU
|
||||||
|
|
||||||
|
var s := sin(_phase)
|
||||||
|
var swing := s * STRIDE * _amount
|
||||||
|
var arm := s * ARM_SWING * _amount
|
||||||
|
|
||||||
|
_pitch("LeftUpperLeg", swing)
|
||||||
|
_pitch("RightUpperLeg", -swing)
|
||||||
|
_pitch("LeftUpperArm", -arm)
|
||||||
|
_pitch("RightUpperArm", arm)
|
||||||
|
# Knee folds only while that leg is swinging back (negative pitch phase).
|
||||||
|
_pitch("LeftLowerLeg", maxf(0.0, -s) * KNEE * _amount)
|
||||||
|
_pitch("RightLowerLeg", maxf(0.0, s) * KNEE * _amount)
|
||||||
|
|
||||||
|
|
||||||
|
func _pitch(bone: String, angle: float) -> void:
|
||||||
|
if not _idx.has(bone):
|
||||||
|
return
|
||||||
|
var i: int = _idx[bone]
|
||||||
|
var rest_q := _skel.get_bone_rest(i).basis.get_rotation_quaternion()
|
||||||
|
_skel.set_bone_pose_rotation(i, rest_q * Quaternion(Vector3.RIGHT, angle))
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
extends Control
|
||||||
|
## Full-screen host for the AvatarCustomizationUI.
|
||||||
|
##
|
||||||
|
## The UI itself (scripts/avatar/avatar_customization_ui.gd) builds its own
|
||||||
|
## layout programmatically and emits `closed` when the player backs out; this
|
||||||
|
## thin wrapper is what makes it a navigable scene reachable from the main menu.
|
||||||
|
|
||||||
|
const AvatarCustomizationUI := preload("res://scripts/avatar/avatar_customization_ui.gd")
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
var ui := AvatarCustomizationUI.new()
|
||||||
|
ui.name = "AvatarCustomizationUI"
|
||||||
|
ui.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
add_child(ui)
|
||||||
|
ui.closed.connect(_on_closed)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_closed() -> void:
|
||||||
|
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
|
||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
extends Node3D
|
||||||
|
## Root controller for scenes/game.tscn.
|
||||||
|
##
|
||||||
|
## Local single-player by default (walk around the test level). When
|
||||||
|
## Session.active (set by world_list.gd after POST /api/worlds/{id}/join),
|
||||||
|
## connects a NetworkClient to the world's dedicated server and runs
|
||||||
|
## networked: the local Player keeps predicting with its own collision-aware
|
||||||
|
## movement, its input is forwarded to the server each physics frame, the
|
||||||
|
## server's authoritative position is a correction backstop, and other
|
||||||
|
## players are rendered from snapshots.
|
||||||
|
##
|
||||||
|
## Appearance sync is a follow-up -- remote players show as labelled capsules,
|
||||||
|
## not their own avatars (the server doesn't replicate loadouts yet).
|
||||||
|
|
||||||
|
const REMOTE_LERP := 12.0
|
||||||
|
## Snap the local player to the server position past this drift (metres).
|
||||||
|
## Tight enough that the client always ends up where the server actually
|
||||||
|
## spawned it (which is spread out, not the fixed marker player.gd uses).
|
||||||
|
const CORRECTION_THRESHOLD := 0.6
|
||||||
|
|
||||||
|
@onready var _player: Player = $Player
|
||||||
|
|
||||||
|
var _net: NetworkClient = null
|
||||||
|
var _networked := false
|
||||||
|
var _leaving := false # a scene change is already in flight
|
||||||
|
var _aligned := false # snapped to the server spawn yet?
|
||||||
|
var _remotes: Dictionary = {} # peer_id -> Node3D
|
||||||
|
var _remote_prev: Dictionary = {} # peer_id -> Vector3 (last snapshot position)
|
||||||
|
var _status_label: Label = null
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_show_build_stamp()
|
||||||
|
if Session.active:
|
||||||
|
_setup_networked()
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
# ESC leaves the world back to the browser. Handled here (the scene root
|
||||||
|
# that owns the connection) rather than in player.gd so the teardown is
|
||||||
|
# a single ordered path -- doing it from two places raced _on_server_lost
|
||||||
|
# against the scene change and looked like a crash.
|
||||||
|
if event.is_action_pressed("ui_cancel"):
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
_leave_world()
|
||||||
|
|
||||||
|
|
||||||
|
func _leave_world() -> void:
|
||||||
|
if _leaving:
|
||||||
|
return
|
||||||
|
_leaving = true
|
||||||
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
|
_teardown_net()
|
||||||
|
Session.clear()
|
||||||
|
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
## Close the ENet connection and stop it from calling us back mid-teardown.
|
||||||
|
func _teardown_net() -> void:
|
||||||
|
if _net == null or not is_instance_valid(_net):
|
||||||
|
return
|
||||||
|
if _net.server_disconnected.is_connected(_on_server_lost):
|
||||||
|
_net.server_disconnected.disconnect(_on_server_lost)
|
||||||
|
if _net.connection_failed.is_connected(_on_server_lost):
|
||||||
|
_net.connection_failed.disconnect(_on_server_lost)
|
||||||
|
_net.disconnect_from_server()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
static func _build_label() -> String:
|
||||||
|
var f := FileAccess.open("res://assets/build_info.json", FileAccess.READ)
|
||||||
|
if f == null:
|
||||||
|
return "dev"
|
||||||
|
var d = JSON.parse_string(f.get_as_text())
|
||||||
|
if d is Dictionary:
|
||||||
|
return "%s %s" % [d.get("checkpoint", "?"), d.get("commit", "?")]
|
||||||
|
return "dev"
|
||||||
|
|
||||||
|
|
||||||
|
func _show_build_stamp() -> void:
|
||||||
|
var layer := CanvasLayer.new()
|
||||||
|
layer.layer = 128
|
||||||
|
add_child(layer)
|
||||||
|
var lbl := Label.new()
|
||||||
|
lbl.text = "build %s" % _build_label()
|
||||||
|
lbl.add_theme_color_override("font_color", Color(1, 1, 0.35))
|
||||||
|
lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 1))
|
||||||
|
lbl.add_theme_constant_override("outline_size", 6)
|
||||||
|
lbl.add_theme_font_size_override("font_size", 20)
|
||||||
|
# Full-width strip pinned to the TOP-left. (A bare preset + .position on a
|
||||||
|
# zero-size Label collapses the rect to negative width and nothing draws --
|
||||||
|
# which is why this never showed; explicit offsets fix it.)
|
||||||
|
lbl.set_anchors_preset(Control.PRESET_TOP_WIDE)
|
||||||
|
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||||
|
lbl.offset_left = 12
|
||||||
|
lbl.offset_right = -12
|
||||||
|
lbl.offset_top = 8
|
||||||
|
lbl.offset_bottom = 40
|
||||||
|
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
layer.add_child(lbl)
|
||||||
|
|
||||||
|
|
||||||
|
func _exit_tree() -> void:
|
||||||
|
# Backstop for any exit path that didn't go through _leave_world (e.g. the
|
||||||
|
# app quitting): close the ENet connection so the server fires
|
||||||
|
# peer_disconnected and drops our player instead of leaving a ghost.
|
||||||
|
_teardown_net()
|
||||||
|
|
||||||
|
|
||||||
|
func _setup_networked() -> void:
|
||||||
|
_show_status("Connecting to %s…" % Session.host)
|
||||||
|
_net = NetworkClient.new()
|
||||||
|
_net.name = "NetworkClient"
|
||||||
|
add_child(_net)
|
||||||
|
_net.server_disconnected.connect(_on_server_lost)
|
||||||
|
_net.connection_failed.connect(_on_server_lost)
|
||||||
|
|
||||||
|
var ok: bool = await _net.connect_to_server(Session.host, Session.port)
|
||||||
|
if not ok:
|
||||||
|
_on_server_lost()
|
||||||
|
return
|
||||||
|
_networked = true
|
||||||
|
_hide_status()
|
||||||
|
# Physics-process AFTER the player so net_input reflects this frame.
|
||||||
|
process_physics_priority = 10
|
||||||
|
|
||||||
|
|
||||||
|
func _physics_process(_delta: float) -> void:
|
||||||
|
if not _networked or _net == null or not _net.is_connected_to_server():
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Forward the local player's input to the server.
|
||||||
|
var inp: Dictionary = _player.net_input
|
||||||
|
_net.send_input(inp["move_x"], inp["move_z"], inp["jump"], inp["look_yaw"])
|
||||||
|
|
||||||
|
# 2. Server-authoritative position.
|
||||||
|
var own: Dictionary = _net.get_own_server_state()
|
||||||
|
if own.has("position"):
|
||||||
|
var server_pos: Vector3 = own["position"]
|
||||||
|
if not _aligned:
|
||||||
|
# First snapshot: hard-snap to where the server actually spawned
|
||||||
|
# us (player.gd starts everyone on the fixed marker, but the
|
||||||
|
# server spreads spawns out).
|
||||||
|
_player.apply_server_correction(server_pos)
|
||||||
|
_aligned = true
|
||||||
|
elif _player.global_position.distance_to(server_pos) > CORRECTION_THRESHOLD:
|
||||||
|
_player.apply_server_correction(server_pos)
|
||||||
|
|
||||||
|
# 3. Remote players.
|
||||||
|
_sync_remotes(_net.get_remote_states(), _delta)
|
||||||
|
|
||||||
|
|
||||||
|
func _sync_remotes(states: Dictionary, delta: float) -> void:
|
||||||
|
for pid in states:
|
||||||
|
var st: Dictionary = states[pid]
|
||||||
|
var node: Node3D = _remotes.get(pid)
|
||||||
|
if node == null:
|
||||||
|
node = _make_remote(pid)
|
||||||
|
_remotes[pid] = node
|
||||||
|
add_child(node)
|
||||||
|
node.global_position = st.get("position", Vector3.ZERO)
|
||||||
|
var target: Vector3 = st.get("position", node.global_position)
|
||||||
|
node.global_position = node.global_position.lerp(target, clampf(REMOTE_LERP * delta, 0.0, 1.0))
|
||||||
|
node.rotation.y = st.get("rotation_y", node.rotation.y)
|
||||||
|
|
||||||
|
# Drive the remote's walk cycle from how far its authoritative
|
||||||
|
# position moved this snapshot.
|
||||||
|
var prev: Vector3 = _remote_prev.get(pid, target)
|
||||||
|
var anim := node.get_node_or_null("LimbAnim")
|
||||||
|
if anim != null and delta > 0.0:
|
||||||
|
anim.set("speed", (target - prev).length() / delta)
|
||||||
|
_remote_prev[pid] = target
|
||||||
|
|
||||||
|
for pid in _remotes.keys():
|
||||||
|
if not states.has(pid):
|
||||||
|
_remotes[pid].queue_free()
|
||||||
|
_remotes.erase(pid)
|
||||||
|
_remote_prev.erase(pid)
|
||||||
|
|
||||||
|
|
||||||
|
func _make_remote(pid: int) -> Node3D:
|
||||||
|
var root := Node3D.new()
|
||||||
|
root.name = "Remote_%d" % pid
|
||||||
|
|
||||||
|
# Other players get the assembled avatar too (everyone shares the default
|
||||||
|
# look for now -- per-player loadout replication is the follow-up), tinted
|
||||||
|
# by peer id so they're still tellable apart, plus a name tag.
|
||||||
|
var tint := Color.from_hsv(fmod(float(pid) * 0.61803, 1.0), 0.55, 0.95)
|
||||||
|
var avatars := get_node_or_null("/root/AvatarSystem")
|
||||||
|
var visual: Node3D = null
|
||||||
|
if avatars and avatars.has_method("build_current_avatar"):
|
||||||
|
visual = avatars.build_current_avatar()
|
||||||
|
if visual != null:
|
||||||
|
visual.position = Vector3(0, -0.85, 0) # feet -> ground (root is at hip height from server)
|
||||||
|
for mi in _all_mesh_instances(visual):
|
||||||
|
var m := StandardMaterial3D.new()
|
||||||
|
m.albedo_color = tint
|
||||||
|
mi.material_override = m
|
||||||
|
root.add_child(visual)
|
||||||
|
var skel := visual.find_child("Skeleton3D", true, false)
|
||||||
|
if skel is Skeleton3D:
|
||||||
|
var anim := preload("res://scripts/avatar/limb_animator.gd").new()
|
||||||
|
anim.name = "LimbAnim"
|
||||||
|
root.add_child(anim)
|
||||||
|
anim.call("setup", skel)
|
||||||
|
else:
|
||||||
|
var body := MeshInstance3D.new()
|
||||||
|
var capsule := CapsuleMesh.new()
|
||||||
|
capsule.radius = 0.35
|
||||||
|
capsule.height = 1.7
|
||||||
|
body.mesh = capsule
|
||||||
|
body.position = Vector3(0, 0.85, 0)
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_color = tint
|
||||||
|
body.material_override = mat
|
||||||
|
root.add_child(body)
|
||||||
|
|
||||||
|
var tag := Label3D.new()
|
||||||
|
tag.text = "Player %d" % pid
|
||||||
|
tag.position = Vector3(0, 2.1, 0)
|
||||||
|
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||||
|
tag.no_depth_test = true
|
||||||
|
tag.pixel_size = 0.004
|
||||||
|
root.add_child(tag)
|
||||||
|
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
func _all_mesh_instances(node: Node, out: Array = []) -> Array:
|
||||||
|
if node is MeshInstance3D:
|
||||||
|
out.append(node)
|
||||||
|
for c in node.get_children():
|
||||||
|
_all_mesh_instances(c, out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func _on_server_lost() -> void:
|
||||||
|
if _leaving:
|
||||||
|
return
|
||||||
|
_leaving = true
|
||||||
|
_networked = false
|
||||||
|
_show_status("Disconnected from the server. Returning to worlds…")
|
||||||
|
await get_tree().create_timer(1.5).timeout
|
||||||
|
Session.clear()
|
||||||
|
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── tiny on-screen status ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func _show_status(text: String) -> void:
|
||||||
|
if _status_label == null:
|
||||||
|
var layer := CanvasLayer.new()
|
||||||
|
layer.name = "StatusLayer"
|
||||||
|
add_child(layer)
|
||||||
|
_status_label = Label.new()
|
||||||
|
_status_label.position = Vector2(16, 16)
|
||||||
|
_status_label.add_theme_font_size_override("font_size", 18)
|
||||||
|
layer.add_child(_status_label)
|
||||||
|
_status_label.text = text
|
||||||
|
_status_label.visible = true
|
||||||
|
|
||||||
|
|
||||||
|
func _hide_status() -> void:
|
||||||
|
if _status_label:
|
||||||
|
_status_label.visible = false
|
||||||
+175
-38
@@ -6,6 +6,24 @@ extends Control
|
|||||||
## The Login and Register buttons on the AuthButtonsRow directly set the panel's
|
## 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
|
## mode, so the user can switch between Login and Register at any time while
|
||||||
## the panel is open.
|
## 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.
|
||||||
|
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
# ─── UI references ────────────────────────────────────────────────────────────
|
# ─── UI references ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -22,6 +40,7 @@ var _auth_button: Button
|
|||||||
var _status_label: Label
|
var _status_label: Label
|
||||||
var _welcome_label: Label
|
var _welcome_label: Label
|
||||||
var _play_button: Button
|
var _play_button: Button
|
||||||
|
var _customize_button: Button
|
||||||
var _quit_button: Button
|
var _quit_button: Button
|
||||||
|
|
||||||
var _is_register_mode: bool = false
|
var _is_register_mode: bool = false
|
||||||
@@ -30,7 +49,37 @@ var _is_authenticating: bool = false
|
|||||||
|
|
||||||
# ─── Lifecycle ────────────────────────────────────────────────────────────────
|
# ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
static func _build_label() -> String:
|
||||||
|
var f := FileAccess.open("res://assets/build_info.json", FileAccess.READ)
|
||||||
|
if f == null:
|
||||||
|
return "dev"
|
||||||
|
var d = JSON.parse_string(f.get_as_text())
|
||||||
|
if d is Dictionary:
|
||||||
|
return "%s %s" % [d.get("checkpoint", "?"), d.get("commit", "?")]
|
||||||
|
return "dev"
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
var v := Label.new()
|
||||||
|
v.text = "build %s" % _build_label()
|
||||||
|
v.add_theme_color_override("font_color", Color(1, 1, 1, 0.7))
|
||||||
|
v.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
|
||||||
|
v.add_theme_constant_override("outline_size", 4)
|
||||||
|
v.add_theme_font_size_override("font_size", 12)
|
||||||
|
# Full-width strip pinned to the bottom, text right-aligned. A bare
|
||||||
|
# BOTTOM_RIGHT preset + .position on a zero-size Label collapses the rect
|
||||||
|
# and nothing renders -- which is why this never showed up.
|
||||||
|
v.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
|
||||||
|
v.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||||
|
v.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
|
||||||
|
v.offset_left = 8
|
||||||
|
v.offset_right = -10
|
||||||
|
v.offset_top = -28
|
||||||
|
v.offset_bottom = -6
|
||||||
|
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
add_child(v)
|
||||||
|
|
||||||
# Cache node references.
|
# Cache node references.
|
||||||
_auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow
|
_auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow
|
||||||
_login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton
|
_login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton
|
||||||
@@ -40,20 +89,82 @@ func _ready() -> void:
|
|||||||
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
|
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
|
||||||
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
|
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
|
||||||
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
|
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
|
||||||
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit
|
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DobEdit
|
||||||
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
|
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
|
||||||
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
||||||
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
|
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
|
||||||
_play_button = $CenterContainer/VBoxContainer/PlayButton
|
_play_button = $CenterContainer/VBoxContainer/PlayButton
|
||||||
|
_customize_button = $CenterContainer/VBoxContainer/CustomizeButton
|
||||||
_quit_button = $CenterContainer/VBoxContainer/QuitButton
|
_quit_button = $CenterContainer/VBoxContainer/QuitButton
|
||||||
|
|
||||||
# Initial state.
|
# 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
|
||||||
|
_customize_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)
|
||||||
|
await _prime_avatar()
|
||||||
|
auth_state_ready.emit()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Token missing or invalid — fall through to logged-out state.
|
||||||
|
_reset_to_logged_out()
|
||||||
|
auth_state_ready.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
|
||||||
|
_dob_edit.visible = false
|
||||||
|
_auth_button.visible = false
|
||||||
|
_auth_mode_label.visible = false
|
||||||
|
_play_button.disabled = false
|
||||||
|
_customize_button.disabled = false
|
||||||
|
_auth_buttons_row.visible = false
|
||||||
|
print("Restored authenticated session for: ", username)
|
||||||
|
|
||||||
|
|
||||||
|
## 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
|
_auth_panel.visible = false
|
||||||
_status_label.visible = false
|
_status_label.visible = false
|
||||||
_welcome_label.visible = false
|
_welcome_label.visible = false
|
||||||
_email_edit.visible = false
|
_email_edit.visible = false
|
||||||
_dob_edit.visible = false
|
_dob_edit.visible = false
|
||||||
|
_dob_edit.text = ""
|
||||||
_play_button.disabled = true # Gate play behind successful auth.
|
_play_button.disabled = true # Gate play behind successful auth.
|
||||||
|
_customize_button.disabled = true
|
||||||
_update_auth_mode_ui()
|
_update_auth_mode_ui()
|
||||||
|
|
||||||
|
|
||||||
@@ -67,10 +178,29 @@ func _on_play_button_pressed() -> void:
|
|||||||
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
|
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_customize_button_pressed() -> void:
|
||||||
|
if _customize_button.disabled:
|
||||||
|
return
|
||||||
|
get_tree().change_scene_to_file("res://scenes/avatar_customization.tscn")
|
||||||
|
|
||||||
|
|
||||||
func _on_quit_button_pressed() -> void:
|
func _on_quit_button_pressed() -> void:
|
||||||
get_tree().quit()
|
get_tree().quit()
|
||||||
|
|
||||||
|
|
||||||
|
## Loose client-side check -- YYYY-MM-DD, a real calendar date, not in the
|
||||||
|
## future. The backend does the authoritative age computation.
|
||||||
|
func _is_valid_dob(dob: String) -> bool:
|
||||||
|
var m := RegEx.new()
|
||||||
|
m.compile("^\\d{4}-\\d{2}-\\d{2}$")
|
||||||
|
if m.search(dob) == null:
|
||||||
|
return false
|
||||||
|
var parts := dob.split("-")
|
||||||
|
var d := {"year": int(parts[0]), "month": int(parts[1]), "day": int(parts[2]), "hour": 0, "minute": 0, "second": 0}
|
||||||
|
var unix := Time.get_unix_time_from_datetime_dict(d)
|
||||||
|
return unix > 0 and unix < Time.get_unix_time_from_system()
|
||||||
|
|
||||||
|
|
||||||
# ─── Auth panel toggle ────────────────────────────────────────────────────────
|
# ─── Auth panel toggle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _on_login_button_pressed() -> void:
|
func _on_login_button_pressed() -> void:
|
||||||
@@ -125,17 +255,13 @@ func _on_auth_button_pressed() -> void:
|
|||||||
|
|
||||||
if _is_register_mode:
|
if _is_register_mode:
|
||||||
var email := _email_edit.text.strip_edges()
|
var email := _email_edit.text.strip_edges()
|
||||||
var dob := _dob_edit.text.strip_edges()
|
|
||||||
|
|
||||||
if email == "":
|
if email == "":
|
||||||
_show_status("Email is required.")
|
_show_status("Email is required.")
|
||||||
return
|
return
|
||||||
if dob == "":
|
var dob := _dob_edit.text.strip_edges()
|
||||||
_show_status("Date of birth is required.")
|
if not _is_valid_dob(dob):
|
||||||
return
|
_show_status("Enter your date of birth as YYYY-MM-DD.")
|
||||||
# Basic YYYY-MM-DD format check.
|
|
||||||
if not _is_valid_date(dob):
|
|
||||||
_show_status("Date of birth must be in YYYY-MM-DD format.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
_auth_button.disabled = true
|
_auth_button.disabled = true
|
||||||
@@ -144,7 +270,7 @@ func _on_auth_button_pressed() -> void:
|
|||||||
_clear_status()
|
_clear_status()
|
||||||
|
|
||||||
var result := await ApiClient.register(username, email, password, dob)
|
var result := await ApiClient.register(username, email, password, dob)
|
||||||
_on_auth_complete(result)
|
await _on_auth_complete(result)
|
||||||
else:
|
else:
|
||||||
_auth_button.disabled = true
|
_auth_button.disabled = true
|
||||||
_auth_button.text = "Logging in..."
|
_auth_button.text = "Logging in..."
|
||||||
@@ -152,7 +278,7 @@ func _on_auth_button_pressed() -> void:
|
|||||||
_clear_status()
|
_clear_status()
|
||||||
|
|
||||||
var result := await ApiClient.login(username, password)
|
var result := await ApiClient.login(username, password)
|
||||||
_on_auth_complete(result)
|
await _on_auth_complete(result)
|
||||||
|
|
||||||
|
|
||||||
func _on_auth_complete(result: Dictionary) -> void:
|
func _on_auth_complete(result: Dictionary) -> void:
|
||||||
@@ -162,20 +288,54 @@ func _on_auth_complete(result: Dictionary) -> void:
|
|||||||
_is_authenticating = false
|
_is_authenticating = false
|
||||||
|
|
||||||
if result.ok:
|
if result.ok:
|
||||||
# Success — show welcome, enable Play, hide the now-redundant
|
# Update the UI to the authenticated state first, synchronously, so
|
||||||
# Login/Register entry points (the auth panel itself stays open,
|
# the player sees "Welcome" + an enabled Play immediately -- then
|
||||||
# showing the welcome message in place of the form).
|
# prime the avatar (a catalog load + a loadout fetch) in the
|
||||||
|
# background. A slow or failing prime must never gate getting in.
|
||||||
|
_username_edit.visible = false
|
||||||
|
_password_edit.visible = false
|
||||||
|
_email_edit.visible = false
|
||||||
|
_dob_edit.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.text = "Welcome, %s!" % _username_edit.text.strip_edges()
|
||||||
_welcome_label.visible = true
|
_welcome_label.visible = true
|
||||||
_status_label.visible = false
|
|
||||||
_play_button.disabled = false
|
_play_button.disabled = false
|
||||||
|
_customize_button.disabled = false
|
||||||
_auth_buttons_row.visible = false
|
_auth_buttons_row.visible = false
|
||||||
print("Auth successful. Token stored.")
|
print("Auth successful. Token stored.")
|
||||||
|
await _prime_avatar()
|
||||||
else:
|
else:
|
||||||
# Failure — show backend error detail.
|
# Failure — show backend error detail.
|
||||||
_show_status(result.detail)
|
_show_status(result.detail)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Avatar priming ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
## After a successful auth, load the asset catalog and the player's saved
|
||||||
|
## loadout so the in-world player renders as their avatar. Best-effort: a
|
||||||
|
## failure here just means the player falls back to the default capsule, it
|
||||||
|
## never blocks getting into the game.
|
||||||
|
func _prime_avatar() -> void:
|
||||||
|
if not Engine.has_singleton("AvatarSystem") and get_node_or_null("/root/AvatarSystem") == null:
|
||||||
|
return
|
||||||
|
var avatars = get_node_or_null("/root/AvatarSystem")
|
||||||
|
if avatars == null:
|
||||||
|
return
|
||||||
|
if not await avatars.ensure_catalog():
|
||||||
|
push_warning("main_menu: avatar catalog failed to load; using fallback look")
|
||||||
|
return
|
||||||
|
var res: Dictionary = await ApiClient.get_avatar_loadout()
|
||||||
|
if res.ok and res.data is Dictionary and (res.data as Dictionary).has("data"):
|
||||||
|
avatars.set_loadout_from_dict((res.data as Dictionary)["data"])
|
||||||
|
else:
|
||||||
|
# No saved loadout reachable -- fall back to the catalog's first base body.
|
||||||
|
var bases: Array = avatars.get_assets_for_slot("base_body")
|
||||||
|
if bases.size() > 0:
|
||||||
|
avatars.set_loadout_from_dict({"base_body": bases[0], "slots": {}})
|
||||||
|
|
||||||
|
|
||||||
# ─── Status helpers ───────────────────────────────────────────────────────────
|
# ─── Status helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _show_status(message: String) -> void:
|
func _show_status(message: String) -> void:
|
||||||
@@ -186,26 +346,3 @@ func _show_status(message: String) -> void:
|
|||||||
func _clear_status() -> void:
|
func _clear_status() -> void:
|
||||||
_status_label.text = ""
|
_status_label.text = ""
|
||||||
_status_label.visible = false
|
_status_label.visible = false
|
||||||
|
|
||||||
|
|
||||||
# ─── Validation ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func _is_valid_date(date_str: String) -> bool:
|
|
||||||
"""Check if a string looks like a valid YYYY-MM-DD date."""
|
|
||||||
if date_str.length() != 10:
|
|
||||||
return false
|
|
||||||
var parts := date_str.split("-")
|
|
||||||
if parts.size() != 3:
|
|
||||||
return false
|
|
||||||
var year := parts[0].to_int()
|
|
||||||
var month := parts[1].to_int()
|
|
||||||
var day := parts[2].to_int()
|
|
||||||
if month < 1 or month > 12:
|
|
||||||
return false
|
|
||||||
if day < 1 or day > 31:
|
|
||||||
return false
|
|
||||||
# Rough month-day validation.
|
|
||||||
var days_in_month := [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
||||||
if day > days_in_month[month]:
|
|
||||||
return false
|
|
||||||
return true
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## One client process for the full networked game-flow test
|
||||||
|
## (scripts/run_game_multiplayer_test.sh): register -> POST /api/worlds/{id}/join
|
||||||
|
## -> Session.begin -> load scenes/game.tscn -> run networked.
|
||||||
|
##
|
||||||
|
## godot --headless --path . --script res://scripts/mp_game_flow_client.gd -- \
|
||||||
|
## --role a|b --world 1
|
||||||
|
##
|
||||||
|
## role b: joins, holds still, prints RESULT b in_world and stays ~40 s.
|
||||||
|
## role a: joins, moves forward, prints RESULT a remotes=<n> moved=<bool>;
|
||||||
|
## exits 0 iff it saw a remote player node AND moved.
|
||||||
|
|
||||||
|
var role := "b"
|
||||||
|
var world_id := 1
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var args := OS.get_cmdline_user_args()
|
||||||
|
for i in args.size():
|
||||||
|
if args[i] == "--role" and i + 1 < args.size():
|
||||||
|
role = args[i + 1]
|
||||||
|
elif args[i] == "--world" and i + 1 < args.size():
|
||||||
|
world_id = int(args[i + 1])
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
var api := root.get_node("ApiClient")
|
||||||
|
var session := root.get_node("Session")
|
||||||
|
|
||||||
|
var uname := "mpflow_%s_%d" % [role, int(Time.get_unix_time_from_system() * 1000) % 1000000]
|
||||||
|
var reg: Dictionary = await api.register(uname, uname + "@e.com", "secret123", "1990-01-01")
|
||||||
|
if not reg.ok:
|
||||||
|
print("RESULT %s register_failed %s" % [role, reg.detail])
|
||||||
|
quit(1); return
|
||||||
|
|
||||||
|
var join: Dictionary = await api.join_world(world_id)
|
||||||
|
if not (join.ok and join.data is Dictionary):
|
||||||
|
print("RESULT %s join_failed status=%d %s" % [role, join.status, join.detail])
|
||||||
|
quit(1); return
|
||||||
|
var d: Dictionary = join.data
|
||||||
|
session.begin(world_id, str(d["host"]), int(d["port"]))
|
||||||
|
|
||||||
|
change_scene_to_file("res://scenes/game.tscn")
|
||||||
|
# Wait for the scene + NetworkClient connect (game.gd awaits internally).
|
||||||
|
for i in 180:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var game := current_scene
|
||||||
|
var netc = game.get_node_or_null("NetworkClient") if game else null
|
||||||
|
if netc == null or not netc.is_connected_to_server():
|
||||||
|
print("RESULT %s not_connected" % role)
|
||||||
|
quit(1); return
|
||||||
|
|
||||||
|
if role == "b":
|
||||||
|
print("RESULT b in_world")
|
||||||
|
for i in 1200:
|
||||||
|
await process_frame
|
||||||
|
quit(0); return
|
||||||
|
|
||||||
|
# role a: hold still briefly (confirm we don't fall through the floor),
|
||||||
|
# then move and look for a remote node.
|
||||||
|
var player = game.get_node("Player")
|
||||||
|
var min_y := 1000.0
|
||||||
|
for i in 30:
|
||||||
|
await process_frame
|
||||||
|
min_y = minf(min_y, player.global_position.y)
|
||||||
|
var on_floor: bool = min_y > -3.0
|
||||||
|
|
||||||
|
var start: Vector3 = player.global_position
|
||||||
|
var max_remotes := 0
|
||||||
|
for i in 120:
|
||||||
|
player._input_direction = Vector2(0, 1) # fake held forward
|
||||||
|
await process_frame
|
||||||
|
var n := 0
|
||||||
|
for c in game.get_children():
|
||||||
|
if c.name.begins_with("Remote_"):
|
||||||
|
n += 1
|
||||||
|
max_remotes = maxi(max_remotes, n)
|
||||||
|
var moved: bool = player.global_position.distance_to(start) > 1.0
|
||||||
|
print("RESULT a remotes=%d moved=%s on_floor=%s" % [max_remotes, str(moved), str(on_floor)])
|
||||||
|
quit(0 if (moved and max_remotes >= 1 and on_floor) else 1)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## One client process for the 2-player multiplayer test
|
||||||
|
## (scripts/run_multiplayer_2p_test.sh). Two real OS processes = real network.
|
||||||
|
##
|
||||||
|
## godot --headless --path . --script res://scripts/mp_test_client.gd -- --role a --port 7777
|
||||||
|
##
|
||||||
|
## role "a": moves forward for ~2 s, then prints
|
||||||
|
## RESULT a remotes=<n> moved=<bool> and exits 0 iff it both moved
|
||||||
|
## and saw >=1 remote player.
|
||||||
|
## role "b": connects, holds still for ~4 s, prints RESULT b connected exits 0.
|
||||||
|
|
||||||
|
var role := "b"
|
||||||
|
var port := 7777
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var args := OS.get_cmdline_user_args()
|
||||||
|
for i in args.size():
|
||||||
|
if args[i] == "--role" and i + 1 < args.size():
|
||||||
|
role = args[i + 1]
|
||||||
|
elif args[i] == "--port" and i + 1 < args.size():
|
||||||
|
port = int(args[i + 1])
|
||||||
|
_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", port)
|
||||||
|
if not ok:
|
||||||
|
print("RESULT %s connect_failed" % role)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
for i in 15:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
if role == "b":
|
||||||
|
# Hold still and stay connected long enough for A to boot, connect,
|
||||||
|
# and observe us (A's Godot startup alone is a couple of seconds).
|
||||||
|
print("RESULT b connected")
|
||||||
|
for i in 1200:
|
||||||
|
await process_frame
|
||||||
|
net.disconnect_from_server()
|
||||||
|
quit(0)
|
||||||
|
return
|
||||||
|
|
||||||
|
# role a: move forward, watch for a remote and our own displacement, and
|
||||||
|
# confirm the server keeps us on the ground (not falling through it).
|
||||||
|
var start: Vector3 = net.position
|
||||||
|
var max_remotes := 0
|
||||||
|
var min_server_y := 1000.0
|
||||||
|
for i in 90:
|
||||||
|
net.predict_and_send(1.0 / 30.0, 0.0, -1.0, false, 0.0)
|
||||||
|
await process_frame
|
||||||
|
max_remotes = maxi(max_remotes, net.get_remote_states().size())
|
||||||
|
var st: Dictionary = net.get_own_server_state()
|
||||||
|
if st.has("position"):
|
||||||
|
min_server_y = minf(min_server_y, st["position"].y)
|
||||||
|
var moved: bool = net.position.distance_to(start) > 1.0
|
||||||
|
var on_floor: bool = min_server_y > -2.0
|
||||||
|
print("RESULT a remotes=%d moved=%s on_floor=%s (min_y=%.2f)" % [max_remotes, str(moved), str(on_floor), min_server_y])
|
||||||
|
net.disconnect_from_server()
|
||||||
|
quit(0 if (moved and max_remotes >= 1 and on_floor) else 1)
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
class_name NetworkClient
|
||||||
|
extends Node
|
||||||
|
## Client-side multiplayer: ENet connection, local-player input prediction +
|
||||||
|
## server reconciliation (with input replay), and remote-player state for the
|
||||||
|
## game to render.
|
||||||
|
##
|
||||||
|
## RPC path-matching: Godot resolves an rpc_id() call's target by matching the
|
||||||
|
## sending node's NodePath on the receiver. scripts/server.gd is the SceneTree
|
||||||
|
## root named "Server"; it spawns each peer's PlayerNode as a child named
|
||||||
|
## "Player_<peer_id>". So the client needs local nodes at /root/Server (for the
|
||||||
|
## server-initiated _receive_snapshot) and /root/Server/Player_<own_id> (the
|
||||||
|
## target of rpc_request_input). connect_to_server() builds that once the peer
|
||||||
|
## id is known.
|
||||||
|
##
|
||||||
|
## Usage:
|
||||||
|
## var net := NetworkClient.new()
|
||||||
|
## get_tree().root.add_child(net)
|
||||||
|
## if await net.connect_to_server(host, port):
|
||||||
|
## # each physics frame, from the local player controller:
|
||||||
|
## net.predict_and_send(delta, move_x, move_z, jump, body_yaw)
|
||||||
|
## # read net.position / net.get_remote_states() to render
|
||||||
|
|
||||||
|
# Movement constants -- MUST match scripts/server.gd and player_spawner.gd.
|
||||||
|
const WALK_SPEED := 5.0
|
||||||
|
const JUMP_VELOCITY := 4.5
|
||||||
|
const GRAVITY := 9.8
|
||||||
|
|
||||||
|
## Predicted vs authoritative drift beyond this (metres) snaps rather than
|
||||||
|
## trusting the prediction.
|
||||||
|
const RECONCILE_ERROR_THRESHOLD := 0.35
|
||||||
|
|
||||||
|
## Ring buffer cap. 120 frames at 30 Hz = 4 s of history, well past any
|
||||||
|
## realistic LAN RTT.
|
||||||
|
const PREDICTION_BUFFER_SIZE := 120
|
||||||
|
|
||||||
|
signal reconciled(correction_distance: float)
|
||||||
|
signal connected_to_server
|
||||||
|
signal connection_failed
|
||||||
|
signal server_disconnected
|
||||||
|
|
||||||
|
var peer: ENetMultiplayerPeer
|
||||||
|
var my_peer_id: int = 0
|
||||||
|
var _server_root: Node
|
||||||
|
var _player_proxy: Node
|
||||||
|
|
||||||
|
var _seq: int = 0
|
||||||
|
## Ring buffer of {seq, move_x, move_z, jump, delta, position, velocity},
|
||||||
|
## oldest first. Entries are dropped as the server acks their seq.
|
||||||
|
var _prediction_buffer: Array[Dictionary] = []
|
||||||
|
|
||||||
|
## Local predicted player state.
|
||||||
|
var position: Vector3 = Vector3.ZERO
|
||||||
|
var velocity: Vector3 = Vector3.ZERO
|
||||||
|
var _on_floor: bool = true
|
||||||
|
|
||||||
|
## peer_id -> latest authoritative state dict from the most recent snapshot,
|
||||||
|
## for every peer that isn't us. The game reads this to render other players.
|
||||||
|
var _remote_states: Dictionary = {}
|
||||||
|
## Our own latest authoritative state from the most recent snapshot.
|
||||||
|
var _own_state: Dictionary = {}
|
||||||
|
var _last_snapshot_tick: int = -1
|
||||||
|
|
||||||
|
|
||||||
|
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.server_disconnected.connect(func(): server_disconnected.emit())
|
||||||
|
|
||||||
|
for _i in 300: # ~5 s at 60 fps
|
||||||
|
await get_tree().process_frame
|
||||||
|
match peer.get_connection_status():
|
||||||
|
MultiplayerPeer.CONNECTION_CONNECTED:
|
||||||
|
_on_connected()
|
||||||
|
return true
|
||||||
|
MultiplayerPeer.CONNECTION_DISCONNECTED:
|
||||||
|
connection_failed.emit()
|
||||||
|
return false
|
||||||
|
connection_failed.emit()
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _on_connected() -> void:
|
||||||
|
my_peer_id = multiplayer.get_unique_id()
|
||||||
|
|
||||||
|
_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_peer_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_buffer.clear()
|
||||||
|
_remote_states.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func is_connected_to_server() -> bool:
|
||||||
|
return peer != null and peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Prediction -- call every physics frame from the local player controller.
|
||||||
|
# Applies input locally immediately (zero perceived latency) and sends it.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func predict_and_send(delta: float, move_x: float, move_z: float, jump: bool, look_yaw: float = 0.0) -> void:
|
||||||
|
_apply_movement(delta, move_x, move_z, jump)
|
||||||
|
|
||||||
|
_seq += 1
|
||||||
|
_prediction_buffer.append({
|
||||||
|
"seq": _seq,
|
||||||
|
"move_x": move_x, "move_z": move_z, "jump": jump,
|
||||||
|
"delta": delta,
|
||||||
|
"position": position, "velocity": velocity,
|
||||||
|
})
|
||||||
|
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",
|
||||||
|
{"move_x": move_x, "move_z": move_z, "jump": jump, "look_yaw": look_yaw}, _seq)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_movement(delta: float, move_x: float, move_z: float, jump: bool) -> void:
|
||||||
|
if not _on_floor:
|
||||||
|
velocity.y -= GRAVITY * delta
|
||||||
|
elif jump:
|
||||||
|
velocity.y = JUMP_VELOCITY
|
||||||
|
|
||||||
|
var dir := Vector3(move_x, 0.0, move_z)
|
||||||
|
if dir.length() > 0.01:
|
||||||
|
dir = dir.normalized()
|
||||||
|
velocity.x = dir.x * WALK_SPEED
|
||||||
|
velocity.z = dir.z * WALK_SPEED
|
||||||
|
else:
|
||||||
|
velocity.x = 0.0
|
||||||
|
velocity.z = 0.0
|
||||||
|
|
||||||
|
position += velocity * delta
|
||||||
|
# Headless prediction has no collision shape; treat the spawn plane as the
|
||||||
|
# floor. A render-side controller drives the real CharacterBody3D and reads
|
||||||
|
# `position` back for reconciliation only.
|
||||||
|
if position.y <= 0.0:
|
||||||
|
position.y = 0.0
|
||||||
|
velocity.y = 0.0
|
||||||
|
_on_floor = true
|
||||||
|
else:
|
||||||
|
_on_floor = false
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reconciliation -- _ReceiverProxy calls this when a snapshot arrives.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _handle_snapshot(snap: Dictionary) -> void:
|
||||||
|
var snap_tick: int = snap.get("tick", 0)
|
||||||
|
if snap_tick <= _last_snapshot_tick:
|
||||||
|
return # Stale / out-of-order (unreliable channel).
|
||||||
|
_last_snapshot_tick = snap_tick
|
||||||
|
|
||||||
|
var players: Dictionary = snap.get("players", {})
|
||||||
|
|
||||||
|
# --- Remote players ---
|
||||||
|
_remote_states.clear()
|
||||||
|
for key in players:
|
||||||
|
var pid := int(key)
|
||||||
|
if pid == my_peer_id:
|
||||||
|
continue
|
||||||
|
_remote_states[pid] = players[key]
|
||||||
|
|
||||||
|
# --- Local player: reconcile ---
|
||||||
|
var mine: Variant = players.get(my_peer_id, players.get(str(my_peer_id), null))
|
||||||
|
if not (mine is Dictionary):
|
||||||
|
return
|
||||||
|
_own_state = mine
|
||||||
|
var auth_pos: Vector3 = mine.get("position", position)
|
||||||
|
var auth_vel: Vector3 = mine.get("velocity", velocity)
|
||||||
|
var acked_seq: int = int(mine.get("last_seq", 0))
|
||||||
|
|
||||||
|
# Drop inputs the server has already applied.
|
||||||
|
while not _prediction_buffer.is_empty() and _prediction_buffer[0]["seq"] <= acked_seq:
|
||||||
|
_prediction_buffer.pop_front()
|
||||||
|
|
||||||
|
# Re-simulate the still-in-flight inputs from the authoritative state.
|
||||||
|
var replay_pos := auth_pos
|
||||||
|
var replay_vel := auth_vel
|
||||||
|
var replay_floor := replay_pos.y <= 0.0
|
||||||
|
for entry in _prediction_buffer:
|
||||||
|
var d: float = entry["delta"]
|
||||||
|
if not replay_floor:
|
||||||
|
replay_vel.y -= GRAVITY * d
|
||||||
|
elif entry["jump"]:
|
||||||
|
replay_vel.y = JUMP_VELOCITY
|
||||||
|
var dir := Vector3(entry["move_x"], 0.0, entry["move_z"])
|
||||||
|
if dir.length() > 0.01:
|
||||||
|
dir = dir.normalized()
|
||||||
|
replay_vel.x = dir.x * WALK_SPEED
|
||||||
|
replay_vel.z = dir.z * WALK_SPEED
|
||||||
|
else:
|
||||||
|
replay_vel.x = 0.0
|
||||||
|
replay_vel.z = 0.0
|
||||||
|
replay_pos += replay_vel * d
|
||||||
|
if replay_pos.y <= 0.0:
|
||||||
|
replay_pos.y = 0.0
|
||||||
|
replay_vel.y = 0.0
|
||||||
|
replay_floor = true
|
||||||
|
else:
|
||||||
|
replay_floor = false
|
||||||
|
|
||||||
|
var error := position.distance_to(replay_pos)
|
||||||
|
if error > RECONCILE_ERROR_THRESHOLD:
|
||||||
|
position = replay_pos
|
||||||
|
velocity = replay_vel
|
||||||
|
_on_floor = replay_floor
|
||||||
|
reconciled.emit(error)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Remote-player state for rendering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## peer_id -> {position, rotation_y, velocity, on_floor, last_seq} from the most
|
||||||
|
## recent snapshot. Read this each frame to spawn/update remote avatars.
|
||||||
|
func get_remote_states() -> Dictionary:
|
||||||
|
return _remote_states
|
||||||
|
|
||||||
|
|
||||||
|
## Our own latest authoritative state from the most recent snapshot, or {}.
|
||||||
|
func get_own_server_state() -> Dictionary:
|
||||||
|
return _own_state
|
||||||
|
|
||||||
|
|
||||||
|
## Send an input frame to the server WITHOUT running NetworkClient's own
|
||||||
|
## flat-plane prediction -- for callers that predict locally with their own
|
||||||
|
## collision-aware CharacterBody3D (scripts/game.gd) and only want the server
|
||||||
|
## as an authority/correction backstop.
|
||||||
|
func send_input(move_x: float, move_z: float, jump: bool, look_yaw: float) -> void:
|
||||||
|
_seq += 1
|
||||||
|
if _player_proxy and is_instance_valid(_player_proxy):
|
||||||
|
_player_proxy.rpc_id(1, "rpc_request_input",
|
||||||
|
{"move_x": move_x, "move_z": move_z, "jump": jump, "look_yaw": look_yaw}, _seq)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
extends Node
|
||||||
|
## Client-side proxy for the server's PlayerNode (scripts/player_spawner.gd),
|
||||||
|
## attached at runtime as "Player_<own_peer_id>" under the /root/Server proxy.
|
||||||
|
##
|
||||||
|
## rpc_request_input is the only RPC the client sends. Godot resolves the RPC
|
||||||
|
## transfer config from the LOCAL node's declaration and verifies both peers
|
||||||
|
## declare the same @rpc set at this path, so it must match player_spawner.gd.
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func rpc_request_input(_input: Dictionary, _seq: int) -> void:
|
||||||
|
pass # Real implementation is on the server.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
extends Node
|
||||||
|
## Client-side proxy node, attached at runtime as a Node named "Server" at the
|
||||||
|
## SceneTree root by NetworkClient. Exists purely so Godot's RPC path-matching
|
||||||
|
## finds a receiver for _receive_snapshot (the server calls it on its own root
|
||||||
|
## "Server" node, so the client needs a node at /root/Server declaring the same
|
||||||
|
## @rpc set). Not a real server.
|
||||||
|
|
||||||
|
var owner_client: Node = null
|
||||||
|
|
||||||
|
|
||||||
|
## Must match scripts/server.gd's @rpc annotation on _receive_snapshot exactly.
|
||||||
|
@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)
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
class_name Player
|
||||||
|
extends CharacterBody3D
|
||||||
|
## The player character controller.
|
||||||
|
##
|
||||||
|
## Roblox-style third-person controller:
|
||||||
|
##
|
||||||
|
## * Classic camera (default): the mouse cursor is free; hold a mouse button
|
||||||
|
## and drag to orbit the camera 360 in yaw (pitch clamped). Right button
|
||||||
|
## also turns the character to face the camera while held. Scroll wheel
|
||||||
|
## zooms; zoom all the way in -> first person.
|
||||||
|
## * Shift Lock (toggle Left Shift): mouse locks to a centre crosshair, the
|
||||||
|
## camera moves over the right shoulder, and the character's facing is
|
||||||
|
## locked to the camera -- WASD strafes / backpedals.
|
||||||
|
##
|
||||||
|
## Movement is always camera-relative (W = away from the camera). Outside the
|
||||||
|
## camera-facing modes the body auto-rotates toward its movement direction.
|
||||||
|
## Spawn point comes from the current scene's `PlayerSpawn` Marker3D.
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Movement / camera 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
|
||||||
|
|
||||||
|
## Vertical offset applied to the assembled avatar so its feet (origin y=0)
|
||||||
|
## line up with the bottom of the player's collision capsule.
|
||||||
|
const AVATAR_FEET_OFFSET: float = -0.8
|
||||||
|
|
||||||
|
## Camera pitch limits (radians): near top-down to looking up from below.
|
||||||
|
## Yaw is deliberately unclamped -- full 360 orbit.
|
||||||
|
const PITCH_MIN: float = -1.35
|
||||||
|
const PITCH_MAX: float = 1.2
|
||||||
|
|
||||||
|
## Camera pivot height above the player origin, third-person vs first-person.
|
||||||
|
const PIVOT_Y_TP: float = 1.2
|
||||||
|
const PIVOT_Y_FP: float = 0.65
|
||||||
|
|
||||||
|
## Zoom (spring length) range, scroll step, and the distance at or below which
|
||||||
|
## we switch to first person.
|
||||||
|
const ZOOM_MIN: float = 0.4
|
||||||
|
const ZOOM_MAX: float = 9.0
|
||||||
|
const ZOOM_STEP: float = 0.6
|
||||||
|
const FIRST_PERSON_ZOOM: float = 0.6
|
||||||
|
const ZOOM_DEFAULT: float = 3.5
|
||||||
|
|
||||||
|
## How far right the camera sits in Shift Lock (metres).
|
||||||
|
const SHOULDER_OFFSET: float = 0.85
|
||||||
|
|
||||||
|
## How fast the body swings toward its target facing.
|
||||||
|
const BODY_TURN_SPEED: float = 12.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# State
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var _input_direction := Vector2.ZERO
|
||||||
|
var _camera_yaw: float = 0.0 # unclamped, wraps
|
||||||
|
var _camera_pitch: float = -0.12
|
||||||
|
var _zoom: float = ZOOM_DEFAULT
|
||||||
|
var _shift_lock: bool = false
|
||||||
|
var _mouse_look_held: bool = false # LMB or RMB held (classic drag)
|
||||||
|
var _rmb_held: bool = false # RMB specifically (turns the body)
|
||||||
|
var _spawn_position: Vector3 = Vector3(0, 1.5, 0)
|
||||||
|
|
||||||
|
@onready var _camera: Camera3D = $CameraPivot/SpringArm3D/Camera3D
|
||||||
|
@onready var _camera_pivot: Node3D = $CameraPivot
|
||||||
|
@onready var _spring_arm: SpringArm3D = $CameraPivot/SpringArm3D
|
||||||
|
@onready var _capsule_mesh: MeshInstance3D = $CapsuleMesh
|
||||||
|
|
||||||
|
var _avatar_view: Node3D = null
|
||||||
|
var _limb_anim: Node = null
|
||||||
|
var _crosshair: CanvasLayer = null
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _ready
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# Per §3 of pc2_test_level.md: look up PlayerSpawn from the current scene
|
||||||
|
# root (recursive), guarding the null current_scene of headless runs.
|
||||||
|
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
|
||||||
|
rotation.y = (spawn as Node3D).rotation.y
|
||||||
|
else:
|
||||||
|
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
|
||||||
|
global_position = Vector3(0, 1.0, 0)
|
||||||
|
_spawn_position = global_position
|
||||||
|
|
||||||
|
# The spring arm must not collide with the player's own capsule (same
|
||||||
|
# layer) or the camera gets yanked against the player's head.
|
||||||
|
_spring_arm.add_excluded_object(self)
|
||||||
|
|
||||||
|
# The camera pivot orbits the player freely -- it can't inherit the body's
|
||||||
|
# yaw, so detach it and drive its world transform ourselves each frame.
|
||||||
|
_camera_pivot.top_level = true
|
||||||
|
_camera_yaw = rotation.y
|
||||||
|
|
||||||
|
_build_crosshair()
|
||||||
|
_refresh_mouse_mode()
|
||||||
|
_update_camera()
|
||||||
|
_apply_avatar()
|
||||||
|
|
||||||
|
|
||||||
|
func _build_crosshair() -> void:
|
||||||
|
if DisplayServer.get_name() == "headless":
|
||||||
|
return
|
||||||
|
_crosshair = CanvasLayer.new()
|
||||||
|
_crosshair.layer = 64
|
||||||
|
var l := Label.new()
|
||||||
|
l.text = "+"
|
||||||
|
l.add_theme_font_size_override("font_size", 22)
|
||||||
|
l.add_theme_color_override("font_color", Color(1, 1, 1, 0.85))
|
||||||
|
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8))
|
||||||
|
l.add_theme_constant_override("outline_size", 4)
|
||||||
|
l.set_anchors_preset(Control.PRESET_CENTER)
|
||||||
|
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
l.offset_left = -12
|
||||||
|
l.offset_right = 12
|
||||||
|
l.offset_top = -16
|
||||||
|
l.offset_bottom = 16
|
||||||
|
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_crosshair.add_child(l)
|
||||||
|
add_child(_crosshair)
|
||||||
|
_crosshair.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
## Show the player's assembled avatar and hide the placeholder capsule.
|
||||||
|
func _apply_avatar() -> void:
|
||||||
|
var avatars := get_node_or_null("/root/AvatarSystem")
|
||||||
|
if avatars == null or not avatars.has_method("build_current_avatar"):
|
||||||
|
return
|
||||||
|
var avatar: Node3D = avatars.build_current_avatar()
|
||||||
|
if avatar == null:
|
||||||
|
push_warning("player.gd: no avatar to show; keeping the placeholder capsule")
|
||||||
|
return
|
||||||
|
avatar.position = Vector3(0.0, AVATAR_FEET_OFFSET, 0.0)
|
||||||
|
add_child(avatar)
|
||||||
|
_avatar_view = avatar
|
||||||
|
if _capsule_mesh:
|
||||||
|
_capsule_mesh.visible = false
|
||||||
|
|
||||||
|
# Procedural walk cycle -- no clips yet, just swing the limb bones with speed.
|
||||||
|
var skel := avatar.find_child("Skeleton3D", true, false)
|
||||||
|
if skel is Skeleton3D:
|
||||||
|
_limb_anim = preload("res://scripts/avatar/limb_animator.gd").new()
|
||||||
|
add_child(_limb_anim)
|
||||||
|
_limb_anim.call("setup", skel)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Input
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _is_first_person() -> bool:
|
||||||
|
return _zoom <= FIRST_PERSON_ZOOM
|
||||||
|
|
||||||
|
|
||||||
|
## Mouse should be captured (hidden, locked) whenever the camera is in an
|
||||||
|
## aim-style mode or the player is mid-drag.
|
||||||
|
func _want_mouse_captured() -> bool:
|
||||||
|
return _shift_lock or _is_first_person() or _mouse_look_held
|
||||||
|
|
||||||
|
|
||||||
|
## Body facing is slaved to the camera in Shift Lock, first person, or while
|
||||||
|
## the right mouse button is held.
|
||||||
|
func _want_face_camera() -> bool:
|
||||||
|
return _shift_lock or _is_first_person() or _rmb_held
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_mouse_mode() -> void:
|
||||||
|
if DisplayServer.get_name() == "headless":
|
||||||
|
return
|
||||||
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _want_mouse_captured() \
|
||||||
|
else Input.MOUSE_MODE_VISIBLE
|
||||||
|
if _crosshair != null:
|
||||||
|
_crosshair.visible = _shift_lock or _is_first_person()
|
||||||
|
|
||||||
|
|
||||||
|
func _set_zoom(v: float) -> void:
|
||||||
|
var was_fp := _is_first_person()
|
||||||
|
_zoom = clampf(v, ZOOM_MIN, ZOOM_MAX)
|
||||||
|
if _is_first_person() != was_fp:
|
||||||
|
_refresh_mouse_mode()
|
||||||
|
_update_camera()
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventKey:
|
||||||
|
var key := event as InputEventKey
|
||||||
|
if key.pressed and not key.echo:
|
||||||
|
if key.keycode == KEY_SHIFT:
|
||||||
|
_shift_lock = not _shift_lock
|
||||||
|
_refresh_mouse_mode()
|
||||||
|
_update_camera()
|
||||||
|
else:
|
||||||
|
_update_input_direction(key)
|
||||||
|
elif not key.pressed:
|
||||||
|
_clear_input_direction(key)
|
||||||
|
# ESC (leave the world) is handled by game.gd, which owns the connection.
|
||||||
|
|
||||||
|
elif event is InputEventMouseButton:
|
||||||
|
var mb := event as InputEventMouseButton
|
||||||
|
match mb.button_index:
|
||||||
|
MOUSE_BUTTON_WHEEL_UP:
|
||||||
|
if mb.pressed:
|
||||||
|
_set_zoom(_zoom - ZOOM_STEP)
|
||||||
|
MOUSE_BUTTON_WHEEL_DOWN:
|
||||||
|
if mb.pressed:
|
||||||
|
_set_zoom(_zoom + ZOOM_STEP)
|
||||||
|
MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT:
|
||||||
|
_mouse_look_held = Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) \
|
||||||
|
or Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT)
|
||||||
|
_rmb_held = Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT)
|
||||||
|
_refresh_mouse_mode()
|
||||||
|
|
||||||
|
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:
|
||||||
|
_camera_yaw = wrapf(_camera_yaw - event.relative.x * MOUSE_SENSITIVITY, -PI, PI)
|
||||||
|
_camera_pitch = clampf(_camera_pitch - event.relative.y * MOUSE_SENSITIVITY,
|
||||||
|
PITCH_MIN, PITCH_MAX)
|
||||||
|
_update_camera()
|
||||||
|
|
||||||
|
|
||||||
|
## Place the orbit pivot at the player's head and point it per yaw/pitch.
|
||||||
|
## Applies the first-person / shoulder framing. Called from _ready, on input,
|
||||||
|
## and every physics frame (the player moves under it).
|
||||||
|
func _update_camera() -> void:
|
||||||
|
if not is_instance_valid(_camera_pivot):
|
||||||
|
return
|
||||||
|
var fp := _is_first_person()
|
||||||
|
_camera_pivot.global_position = global_position \
|
||||||
|
+ Vector3(0.0, PIVOT_Y_FP if fp else PIVOT_Y_TP, 0.0)
|
||||||
|
_camera_pivot.global_rotation = Vector3(_camera_pitch, _camera_yaw, 0.0)
|
||||||
|
|
||||||
|
_spring_arm.spring_length = 0.0 if fp else _zoom
|
||||||
|
_spring_arm.position.x = SHOULDER_OFFSET if (_shift_lock and not fp) else 0.0
|
||||||
|
|
||||||
|
# Hide our own avatar in first person so the head doesn't fill the view.
|
||||||
|
if _avatar_view != null and is_instance_valid(_avatar_view):
|
||||||
|
_avatar_view.visible = not fp
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Physics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Input frame forwarded to the server by scripts/game.gd each physics tick:
|
||||||
|
## world-space move direction (yaw-transformed, normalized), jump edge, body yaw.
|
||||||
|
var net_input: Dictionary = {"move_x": 0.0, "move_z": 0.0, "jump": false, "look_yaw": 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
if not is_on_floor():
|
||||||
|
velocity.y -= GRAVITY * delta
|
||||||
|
|
||||||
|
var jumped := false
|
||||||
|
if is_on_floor() and Input.is_action_just_pressed("ui_accept"):
|
||||||
|
velocity.y = JUMP_VELOCITY
|
||||||
|
jumped = true
|
||||||
|
|
||||||
|
# Camera-relative movement. A node looks down its local -Z, so forward is
|
||||||
|
# -cam_basis.z and W (input.y = +1) heads away from the camera.
|
||||||
|
var cam_basis := Basis.from_euler(Vector3(0.0, _camera_yaw, 0.0))
|
||||||
|
var direction := (_input_direction.x * cam_basis.x
|
||||||
|
- _input_direction.y * cam_basis.z).normalized()
|
||||||
|
velocity.x = direction.x * WALK_SPEED
|
||||||
|
velocity.z = direction.z * WALK_SPEED
|
||||||
|
|
||||||
|
# Body facing: locked to the camera in aim modes, otherwise turns toward
|
||||||
|
# the movement direction.
|
||||||
|
if _want_face_camera():
|
||||||
|
rotation.y = _camera_yaw
|
||||||
|
elif direction.length_squared() > 0.01:
|
||||||
|
var target_yaw := atan2(-direction.x, -direction.z)
|
||||||
|
rotation.y = lerp_angle(rotation.y, target_yaw,
|
||||||
|
clampf(BODY_TURN_SPEED * delta, 0.0, 1.0))
|
||||||
|
|
||||||
|
move_and_slide()
|
||||||
|
_update_camera()
|
||||||
|
|
||||||
|
if _limb_anim != null:
|
||||||
|
_limb_anim.set("speed", Vector2(velocity.x, velocity.z).length())
|
||||||
|
|
||||||
|
# Safety net -- never fall forever.
|
||||||
|
if global_position.y < -10.0:
|
||||||
|
global_position = _spawn_position
|
||||||
|
velocity = Vector3.ZERO
|
||||||
|
|
||||||
|
net_input = {
|
||||||
|
"move_x": direction.x, "move_z": direction.z,
|
||||||
|
"jump": jumped, "look_yaw": rotation.y,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
## Server-authoritative correction from scripts/game.gd when the local
|
||||||
|
## predicted position has drifted too far.
|
||||||
|
func apply_server_correction(pos: Vector3) -> void:
|
||||||
|
global_position = pos
|
||||||
|
velocity.x = 0.0
|
||||||
|
velocity.z = 0.0
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
class_name PlayerNode
|
||||||
|
extends CharacterBody3D
|
||||||
|
## Server-authoritative player entity.
|
||||||
|
##
|
||||||
|
## One per connected peer, spawned as a child of the Server node. Movement runs
|
||||||
|
## in _physics_process at the project's fixed physics rate (30 Hz, matching
|
||||||
|
## scripts/server.gd's snapshot rate and the client's prediction step).
|
||||||
|
|
||||||
|
## Movement constants -- mirror scripts/server.gd (the source of truth) and
|
||||||
|
## scripts/network_client.gd (the client's prediction).
|
||||||
|
const WALK_SPEED := 5.0
|
||||||
|
const JUMP_VELOCITY := 4.5
|
||||||
|
const GRAVITY := 9.8
|
||||||
|
|
||||||
|
var peer_id: int = -1
|
||||||
|
|
||||||
|
## Where the server spawned this player (set by server.gd). Used as the
|
||||||
|
## respawn point if the player somehow ends up below the world.
|
||||||
|
var spawn_position: Vector3 = Vector3(0, 1.5, 0)
|
||||||
|
|
||||||
|
## Highest input seq applied authoritatively so far (echoed in snapshots so
|
||||||
|
## the client can drop acked inputs and replay the rest).
|
||||||
|
var last_applied_seq: int = 0
|
||||||
|
|
||||||
|
## Queue of {seq:int, move_x:float, move_z:float, jump:bool, look_yaw:float}
|
||||||
|
## appended by rpc_request_input, drained each physics frame.
|
||||||
|
var _input_queue: Array[Dictionary] = []
|
||||||
|
var _look_yaw: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
if _input_queue.is_empty():
|
||||||
|
_integrate(0.0, 0.0, false, delta)
|
||||||
|
else:
|
||||||
|
# Reliable ENet preserves order; sort defensively anyway.
|
||||||
|
_input_queue.sort_custom(func(a, b): return a["seq"] < b["seq"])
|
||||||
|
for input in _input_queue:
|
||||||
|
_look_yaw = input.get("look_yaw", _look_yaw)
|
||||||
|
_integrate(input.get("move_x", 0.0), input.get("move_z", 0.0), input.get("jump", false), delta)
|
||||||
|
last_applied_seq = input["seq"]
|
||||||
|
_input_queue.clear()
|
||||||
|
|
||||||
|
rotation.y = _look_yaw
|
||||||
|
|
||||||
|
# Safety net: if a player ends up below the world (physics shove, tunnel
|
||||||
|
# through the floor), put them back on their spawn instead of falling
|
||||||
|
# forever.
|
||||||
|
if global_position.y < -10.0:
|
||||||
|
global_position = spawn_position
|
||||||
|
velocity = Vector3.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
func _integrate(move_x: float, move_z: float, jump: bool, delta: float) -> void:
|
||||||
|
if not is_on_floor():
|
||||||
|
velocity.y -= GRAVITY * delta
|
||||||
|
elif jump:
|
||||||
|
velocity.y = JUMP_VELOCITY
|
||||||
|
|
||||||
|
var dir := Vector3(move_x, 0.0, move_z)
|
||||||
|
if dir.length() > 0.01:
|
||||||
|
dir = dir.normalized()
|
||||||
|
velocity.x = dir.x * WALK_SPEED
|
||||||
|
velocity.z = dir.z * WALK_SPEED
|
||||||
|
else:
|
||||||
|
velocity.x = 0.0
|
||||||
|
velocity.z = 0.0
|
||||||
|
|
||||||
|
move_and_slide()
|
||||||
|
if is_on_floor() and velocity.y < 0.0:
|
||||||
|
velocity.y = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func get_server_state() -> Dictionary:
|
||||||
|
return {
|
||||||
|
"position": global_position,
|
||||||
|
"rotation_y": rotation.y,
|
||||||
|
"velocity": velocity,
|
||||||
|
"on_floor": is_on_floor(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func rpc_request_input(input: Dictionary, seq: int) -> void:
|
||||||
|
var d := input.duplicate()
|
||||||
|
d["seq"] = seq
|
||||||
|
_input_queue.append(d)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://br27owjh2f5xd
|
||||||
@@ -90,6 +90,10 @@ def main():
|
|||||||
db_url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{args.postgres_port}/agp_db"
|
db_url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{args.postgres_port}/agp_db"
|
||||||
backend_env = os.environ.copy()
|
backend_env = os.environ.copy()
|
||||||
backend_env["DATABASE_URL"] = db_url
|
backend_env["DATABASE_URL"] = db_url
|
||||||
|
# Every smoke test registers users against this one backend from one
|
||||||
|
# client IP; the per-IP auth rate limit would trip mid-suite.
|
||||||
|
# test_rate_limit.py exercises the limiter itself regardless.
|
||||||
|
backend_env["RATE_LIMIT_ENABLED"] = "false"
|
||||||
|
|
||||||
print("\n=== Backend: alembic upgrade head ===")
|
print("\n=== Backend: alembic upgrade head ===")
|
||||||
r = run([sys.executable, "-m", "alembic", "upgrade", "head"], cwd=BACKEND, env=backend_env)
|
r = run([sys.executable, "-m", "alembic", "upgrade", "head"], cwd=BACKEND, env=backend_env)
|
||||||
@@ -133,10 +137,77 @@ def main():
|
|||||||
for test in smoke_tests:
|
for test in smoke_tests:
|
||||||
rel = os.path.relpath(test, ROOT)
|
rel = os.path.relpath(test, ROOT)
|
||||||
print(f"\n--- {rel} ---")
|
print(f"\n--- {rel} ---")
|
||||||
r = run([godot_bin, "--headless", "--path", str(ROOT), "--script", f"res://{rel}"], env=godot_env)
|
# smoke_test_network_client.gd needs a separate dedicated
|
||||||
if r.returncode != 0:
|
# server process listening on ENet 7777 -- stand one up
|
||||||
godot_ok = False
|
# just for that test and tear it down after.
|
||||||
|
game_server = None
|
||||||
|
if os.path.basename(rel) == "smoke_test_network_client.gd":
|
||||||
|
print("(starting scenes/server.tscn for the network client test)")
|
||||||
|
game_server = subprocess.Popen(
|
||||||
|
[godot_bin, "--headless", "--path", str(ROOT), "res://scenes/server.tscn"],
|
||||||
|
env=godot_env,
|
||||||
|
)
|
||||||
|
time.sleep(3)
|
||||||
|
try:
|
||||||
|
r = run([godot_bin, "--headless", "--path", str(ROOT), "--script", f"res://{rel}"], env=godot_env)
|
||||||
|
if r.returncode != 0:
|
||||||
|
godot_ok = False
|
||||||
|
finally:
|
||||||
|
if game_server is not None:
|
||||||
|
game_server.terminate()
|
||||||
|
try:
|
||||||
|
game_server.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
game_server.kill()
|
||||||
results["godot smoke tests"] = godot_ok
|
results["godot smoke tests"] = godot_ok
|
||||||
|
|
||||||
|
# 2-player multiplayer test (its own shell harness: server + 2
|
||||||
|
# real client processes).
|
||||||
|
mp_script = ROOT / "scripts" / "run_multiplayer_2p_test.sh"
|
||||||
|
if mp_script.exists():
|
||||||
|
print("\n--- scripts/run_multiplayer_2p_test.sh ---")
|
||||||
|
mp_env = os.environ.copy()
|
||||||
|
mp_env["GODOT_BIN"] = godot_bin
|
||||||
|
r = run(["bash", str(mp_script)], env=mp_env)
|
||||||
|
results["multiplayer 2p test"] = r.returncode == 0
|
||||||
|
|
||||||
|
# Fresh-clone boot: a from-scratch tree must import + boot with
|
||||||
|
# zero script errors (no reliance on a committed .godot cache).
|
||||||
|
fc_script = ROOT / "scripts" / "verify_fresh_clone.sh"
|
||||||
|
if fc_script.exists():
|
||||||
|
print("\n--- scripts/verify_fresh_clone.sh ---")
|
||||||
|
fc_env = os.environ.copy()
|
||||||
|
fc_env["GODOT_BIN"] = godot_bin
|
||||||
|
r = run(["bash", str(fc_script)], env=fc_env)
|
||||||
|
results["fresh-clone boot"] = r.returncode == 0
|
||||||
|
|
||||||
|
# Export packaging: the game reads some files (catalog.json)
|
||||||
|
# via raw FileAccess, which `all_resources` export doesn't
|
||||||
|
# pack unless include_filter covers them. Passes from source,
|
||||||
|
# bare capsule in the .exe. This guards that.
|
||||||
|
ec_script = ROOT / "scripts" / "verify_export_contents.gd"
|
||||||
|
if ec_script.exists():
|
||||||
|
print("\n--- scripts/verify_export_contents.gd ---")
|
||||||
|
r = run([godot_bin, "--headless", "--path", str(ROOT),
|
||||||
|
"--script", "res://scripts/verify_export_contents.gd"], env=godot_env)
|
||||||
|
results["export packaging"] = r.returncode == 0
|
||||||
|
|
||||||
|
# Run a key smoke against the EXPORTED pack, not just source --
|
||||||
|
# release-build resources differ (e.g. the runtime GLTFDocument
|
||||||
|
# path produced no skeleton in a real export; load().instantiate()
|
||||||
|
# of the imported .scn does). This runs the avatar smoke on
|
||||||
|
# exactly what ships.
|
||||||
|
exported_pck = ROOT / "export" / "_ci_check.pck"
|
||||||
|
exported_pck.parent.mkdir(exist_ok=True)
|
||||||
|
ep = run([godot_bin, "--headless", "--path", str(ROOT),
|
||||||
|
"--export-pack", "Linux", str(exported_pck)], env=godot_env)
|
||||||
|
if ep.returncode == 0 and exported_pck.exists():
|
||||||
|
r = run([godot_bin, "--headless", "--main-pack", str(exported_pck),
|
||||||
|
"--script", "res://scripts/smoke_test_avatar_system.gd"], env=godot_env)
|
||||||
|
results["avatar smoke (exported pack)"] = r.returncode == 0
|
||||||
|
exported_pck.unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
results["avatar smoke (exported pack)"] = False
|
||||||
finally:
|
finally:
|
||||||
server_proc.terminate()
|
server_proc.terminate()
|
||||||
try:
|
try:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user