Compare commits
103 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 | |||
| c4eab8db92 | |||
| 4d754ae48e | |||
| d1d03df2e6 | |||
| 4dda636ed0 | |||
| 4276a89a6a | |||
| 1c3c57dbd0 | |||
| bfcbd0486f | |||
| 1be947f9ef | |||
| 88197ffe8f | |||
| 38d97a3051 | |||
| 174d3c2630 | |||
| b86e4053bd | |||
| 07f655815f | |||
| 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)
|
||||
*.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.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
|
||||
# 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_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
|
||||
UPLOAD_DIR: str = "uploads"
|
||||
MAX_UPLOAD_SIZE_MB: int = 100
|
||||
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(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
|
||||
+7
-3
@@ -6,15 +6,18 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
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
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: startup and shutdown events."""
|
||||
# Startup: initialize database, connect to Redis, etc.
|
||||
supervisor.start()
|
||||
yield
|
||||
# Shutdown: close connections, cleanup
|
||||
await supervisor.shutdown()
|
||||
|
||||
|
||||
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(worlds.router, prefix="/api/worlds", tags=["worlds"])
|
||||
app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
|
||||
app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -2,5 +2,6 @@ from app.models.user import User
|
||||
from app.models.session import Session
|
||||
from app.models.world import World
|
||||
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")
|
||||
worlds = relationship("World", back_populates="owner")
|
||||
assets = relationship("Asset", back_populates="owner")
|
||||
avatar_loadout = relationship(
|
||||
"AvatarLoadout",
|
||||
back_populates="user",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
@property
|
||||
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()
|
||||
+29
-17
@@ -16,11 +16,15 @@ from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.session import RefreshToken
|
||||
from app.models.user import User
|
||||
from app.rate_limit import auth_rate_limit
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
## Minimum age to register. This platform is adults-only.
|
||||
MIN_AGE = 18
|
||||
|
||||
|
||||
# --- Pydantic schemas ---
|
||||
|
||||
@@ -39,14 +43,19 @@ class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
over_18: bool # Simple boolean confirmation instead of date_of_birth
|
||||
# Real date of birth -- the server computes age from it and rejects
|
||||
# anyone under MIN_AGE. Still self-attested (no document check yet), but
|
||||
# a concrete, stored, legally-meaningful data point rather than a
|
||||
# throwaway checkbox, and the seam for a future hard ID-verification
|
||||
# pass. See docs/architecture/content_moderation.md 5.2.
|
||||
date_of_birth: date
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
date_of_birth: str | None = None
|
||||
date_of_birth: date | None = None
|
||||
is_active: bool
|
||||
created_at: datetime | None = None
|
||||
|
||||
@@ -108,15 +117,9 @@ def hash_password(password: str) -> str:
|
||||
def calculate_age(birth_date: date, today: date | None = None) -> int:
|
||||
"""Calculate age in years from a birth date.
|
||||
|
||||
Not called by /register anymore (that endpoint now just trusts the
|
||||
client's over_18 boolean, see RegisterRequest) -- kept as a real,
|
||||
tested utility since date-of-birth storage/verification is coming
|
||||
back in a future pass (real age math, not just a client-side claim,
|
||||
the user explicitly wants to revisit this design). Removing it broke
|
||||
backend/tests/test_auth_age_gate.py's collection entirely (ImportError
|
||||
on `from app.routers.auth import calculate_age`) for zero benefit --
|
||||
restoring it is the lower-risk path over rewriting or deleting 17
|
||||
tests for logic this codebase will need again.
|
||||
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:
|
||||
birth_date: The person's date of birth.
|
||||
@@ -151,16 +154,25 @@ def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
|
||||
|
||||
# --- 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)):
|
||||
"""Register a new user and return access + refresh tokens.
|
||||
|
||||
Requires over_18 boolean confirmation; rejects users who are not 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.
|
||||
"""
|
||||
if not req.over_18:
|
||||
age = calculate_age(req.date_of_birth)
|
||||
if age < 0 or req.date_of_birth > date.today():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Must be 18 or older to register",
|
||||
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
|
||||
@@ -183,7 +195,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
username=req.username,
|
||||
email=req.email,
|
||||
hashed_password=hash_password(req.password),
|
||||
date_of_birth=None, # No longer collecting DOB
|
||||
date_of_birth=req.date_of_birth,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush() # flush to get user.id before creating refresh token
|
||||
@@ -203,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)):
|
||||
"""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.dependencies import CurrentUser
|
||||
from app.models.world import World, WorldVisibility
|
||||
from app.services.game_servers import supervisor
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -106,6 +107,40 @@ async def get_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)
|
||||
async def create_world(
|
||||
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
|
||||
``DATABASE_URL``), so they persist data between runs. Without cleanup a
|
||||
second run of the suite fails with 409 conflicts because the users it
|
||||
registered on the first run already exist. The session-scoped fixture below
|
||||
truncates all application tables once before the first test, making the suite
|
||||
re-runnable.
|
||||
The integration tests hit a real Postgres (Postgres-specific types: JSONB,
|
||||
native ENUMs). To make ``cd backend && pytest`` work from a clean checkout
|
||||
with no manual setup, ``pytest_configure`` below brings up an ephemeral
|
||||
Postgres via docker compose and runs the migrations IF ``DATABASE_URL`` is
|
||||
not already pointing at a reachable database. When it is (dev loop, or the
|
||||
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
|
||||
keys, but the tables are listed explicitly for clarity.
|
||||
If Docker isn't available and no DB is reachable, the whole DB-backed suite
|
||||
is skipped with a clear message rather than erroring out 60+ times.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import pytest
|
||||
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.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)
|
||||
def _reset_database():
|
||||
"""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:
|
||||
engine = create_async_engine(settings.DATABASE_URL)
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE")
|
||||
)
|
||||
await conn.execute(text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE"))
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(_truncate())
|
||||
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
|
||||
|
||||
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:
|
||||
"""Unit tests for the calculate_age helper."""
|
||||
|
||||
def test_exactly_18(self):
|
||||
"""A person born exactly 18 years ago today is 18."""
|
||||
today = date(2025, 6, 15)
|
||||
today = date(2026, 6, 15)
|
||||
dob = today.replace(year=today.year - 18)
|
||||
assert calculate_age(dob, today=today) == 18
|
||||
|
||||
def test_just_turned_18(self):
|
||||
"""A person who turned 18 yesterday is 18."""
|
||||
today = date(2025, 6, 15)
|
||||
today = date(2026, 6, 15)
|
||||
dob = today.replace(year=today.year - 18) - timedelta(days=1)
|
||||
assert calculate_age(dob, today=today) == 18
|
||||
|
||||
def test_one_day_short_of_18(self):
|
||||
"""A person born one day after their 18th birthday is 17."""
|
||||
today = date(2025, 6, 15)
|
||||
today = date(2026, 6, 15)
|
||||
dob = today.replace(year=today.year - 18) + timedelta(days=1)
|
||||
assert calculate_age(dob, today=today) == 17
|
||||
|
||||
def test_17_years_old(self):
|
||||
"""A person who is 17 years old today is 17."""
|
||||
today = date(2025, 6, 15)
|
||||
dob = date(2008, 6, 15)
|
||||
assert calculate_age(dob, today=today) == 17
|
||||
def test_leap_day_birthday_before(self):
|
||||
assert calculate_age(date(2000, 2, 29), today=date(2024, 2, 28)) == 23
|
||||
|
||||
def test_65_years_old(self):
|
||||
"""A person who is 65 is correctly calculated."""
|
||||
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
|
||||
def test_leap_day_birthday_after(self):
|
||||
assert calculate_age(date(2000, 2, 29), today=date(2024, 3, 1)) == 24
|
||||
|
||||
|
||||
class TestRegisterRequestOver18Field:
|
||||
"""Tests for RegisterRequest's over_18 field.
|
||||
class TestRegisterRequestDob:
|
||||
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)
|
||||
|
||||
Replaces TestRegisterRequestDateValidation -- date_of_birth and its
|
||||
field_validator were removed from RegisterRequest 2026-08-20 (interim
|
||||
simplification: a plain client-side over_18 confirmation instead of
|
||||
collecting/validating an actual date of birth; see calculate_age's
|
||||
own docstring in app/routers/auth.py for why that function is still
|
||||
here even though this endpoint no longer calls it). There's no date
|
||||
format left to validate, so the equivalent coverage is just: the
|
||||
field exists, is a real bool, and is required."""
|
||||
|
||||
def test_over_18_true_accepted(self):
|
||||
from app.routers.auth import RegisterRequest
|
||||
|
||||
req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=True)
|
||||
assert req.over_18 is True
|
||||
|
||||
def test_over_18_false_accepted(self):
|
||||
"""False is a valid value at the schema level -- the endpoint,
|
||||
not the schema, is what rejects it (see
|
||||
TestRegister::test_register_rejects_not_over_18 in
|
||||
test_auth_endpoints.py)."""
|
||||
from app.routers.auth import RegisterRequest
|
||||
|
||||
req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=False)
|
||||
assert req.over_18 is False
|
||||
|
||||
def test_over_18_missing_rejected(self):
|
||||
"""over_18 has no default -- omitting it entirely is a validation error."""
|
||||
from app.routers.auth import RegisterRequest
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
def test_dob_required(self):
|
||||
with pytest.raises(Exception):
|
||||
RegisterRequest(username="u", email="u@example.com", password="p")
|
||||
|
||||
def test_bad_dob_string_rejected(self):
|
||||
with pytest.raises(Exception):
|
||||
RegisterRequest(username="u", email="u@example.com", password="p",
|
||||
date_of_birth="not-a-date")
|
||||
|
||||
|
||||
class TestRegisterAgeGate:
|
||||
"""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",
|
||||
"email": "new@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
@@ -81,26 +81,23 @@ class TestRegister:
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
def test_register_rejects_not_over_18(self):
|
||||
"""over_18=false gets a 400 with a detail message.
|
||||
|
||||
The interim design (2026-08-20) trusts a client-side boolean
|
||||
confirmation instead of collecting/validating an actual date of
|
||||
birth -- this only checks that the flag is respected, not real
|
||||
age math (there's no date to verify against anymore). Real DOB
|
||||
collection/verification may come back in a future pass."""
|
||||
def test_register_rejects_under_18(self):
|
||||
"""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)
|
||||
minor_dob = date.today().replace(year=date.today().year - 14).isoformat()
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "kid",
|
||||
"email": "kid@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": False,
|
||||
"date_of_birth": minor_dob,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "18 or older" in resp.json()["detail"]
|
||||
assert resp.status_code == 403
|
||||
assert "at least 18" in resp.json()["detail"]
|
||||
|
||||
def test_register_rejects_duplicate_username(self):
|
||||
"""Registering the same username twice returns 409."""
|
||||
@@ -112,7 +109,7 @@ class TestRegister:
|
||||
"username": "duplicate",
|
||||
"email": "dup1@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
# Second registration with same username, different email
|
||||
@@ -122,7 +119,7 @@ class TestRegister:
|
||||
"username": "duplicate",
|
||||
"email": "dup2@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
@@ -137,7 +134,7 @@ class TestRegister:
|
||||
"username": "user1",
|
||||
"email": "same@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
resp = client.post(
|
||||
@@ -146,19 +143,14 @@ class TestRegister:
|
||||
"username": "user2",
|
||||
"email": "same@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert "Email already registered" in resp.json()["detail"]
|
||||
|
||||
def test_register_missing_over_18(self):
|
||||
"""Missing the required over_18 field returns 422.
|
||||
|
||||
Replaces the old test_register_invalid_date_format -- there's no
|
||||
date format to get wrong anymore (no date field at all), so the
|
||||
closest equivalent "malformed request" case is the required
|
||||
over_18 boolean being absent entirely."""
|
||||
def test_register_missing_dob(self):
|
||||
"""Missing the required date_of_birth field returns 422."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
@@ -189,7 +181,7 @@ class TestLogin:
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"over_18": True,
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
@@ -93,7 +93,7 @@ def _register_user(client: TestClient, username: str) -> str:
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
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,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"over_18": True,
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
@@ -143,7 +143,7 @@ class TestRefresh:
|
||||
"username": "refep_loginrefuser",
|
||||
"email": "loginref@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
# 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
|
||||
|
||||
| Concern | Decision |
|
||||
|
||||
@@ -227,18 +227,33 @@ An account has an **age verification status**:
|
||||
|
||||
### 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 }
|
||||
│
|
||||
▼
|
||||
Server validates format & plausibility (age 13–120, not in future)
|
||||
Server validates format & plausibility (real date, not in future)
|
||||
│
|
||||
▼
|
||||
Compute age from DOB
|
||||
│
|
||||
▼
|
||||
age >= 18 ──► status = self_declared (adult)
|
||||
age >= 13 ──► status = self_declared (minor, restricted)
|
||||
age >= 18 ──► status = self_declared (adult) ← the current gate
|
||||
age >= 13 ──► status = self_declared (minor, restricted) ← design only
|
||||
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
|
||||
|
||||
- **No client-authoritative gameplay values.** Clients only send *inputs* and
|
||||
|
||||
@@ -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,14 +7,17 @@ to know what "run the tests" actually checked.
|
||||
|
||||
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
|
||||
|
||||
Last verified against `main`: 2026-08-20 — full PC1 checkpoint run,
|
||||
migrations + pytest + all 7 Godot smoke tests + both platform exports, all
|
||||
clean. Real bugs found and fixed via real playtesting this same day (not
|
||||
caught by the checkpoint suite as it stood before today): the main menu
|
||||
had no way to actually open the login/register form, the world list's
|
||||
cards rendered at ~1px wide once real data existed, and navigating Play →
|
||||
Back logged the user out despite a still-valid session. All three are now
|
||||
covered by the smoke tests below so they can't silently regress.
|
||||
Last verified against `main`: 2026-08-21 — migrations + pytest + all 11
|
||||
Godot smoke tests, all clean. PC1's real playtest-found bugs (login form
|
||||
unreachable, 1px world cards, Play → Back losing the session) are covered
|
||||
by the smoke tests below so they can't silently regress. PC2 (basic
|
||||
playable world + character + movement) landed 2026-08-20/21 — see
|
||||
`docs/checkpoints/` once a PC2 record exists, and the three new smoke
|
||||
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)
|
||||
|
||||
@@ -53,7 +56,10 @@ these yet):
|
||||
|
||||
## Godot client — `scripts/smoke_test_*.gd`
|
||||
|
||||
**7 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
|
||||
`application/agp/api_base_url` project setting (default
|
||||
`http://127.0.0.1:8000`) if unset.
|
||||
@@ -67,6 +73,11 @@ these yet):
|
||||
| `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_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`,
|
||||
then for each smoke test: `godot --headless --path . --script
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
include_filter="*.json"
|
||||
exclude_filter=""
|
||||
export_path="export/linux/agp-game.x86_64"
|
||||
encryption_include_filters=""
|
||||
@@ -38,7 +38,7 @@ advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
include_filter="*.json"
|
||||
exclude_filter=""
|
||||
export_path="export/windows/agp-game.exe"
|
||||
encryption_include_filters=""
|
||||
|
||||
@@ -14,6 +14,8 @@ agp/api_base_url="http://127.0.0.1:8000"
|
||||
[autoload]
|
||||
|
||||
ApiClient="*res://scripts/api_client.gd"
|
||||
AvatarSystem="*res://scripts/avatar/avatar_system.gd"
|
||||
Session="*res://scripts/session.gd"
|
||||
|
||||
[display]
|
||||
|
||||
@@ -21,6 +23,40 @@ window/size/viewport_width=1920
|
||||
window/size/viewport_height=1080
|
||||
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]
|
||||
|
||||
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")
|
||||
+3
-1
@@ -1,9 +1,11 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://game_scene_pc2"]
|
||||
[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")]
|
||||
|
||||
|
||||
@@ -83,11 +83,11 @@ visible = false
|
||||
placeholder_text = "Email"
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="Over18Check" type="CheckBox" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
[node name="DobEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
visible = false
|
||||
text = "I am 18 or older"
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Date of birth (YYYY-MM-DD)"
|
||||
|
||||
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
@@ -112,6 +112,12 @@ layout_mode = 1
|
||||
text = "Play"
|
||||
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"]
|
||||
layout_mode = 1
|
||||
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/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/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/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"]
|
||||
|
||||
+15
-3
@@ -1,6 +1,7 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://player_scene_001"]
|
||||
[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
|
||||
@@ -11,6 +12,8 @@ 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="."]
|
||||
@@ -19,6 +22,15 @@ shape = SubResource("CapsuleShape1")
|
||||
[node name="CapsuleMesh" type="MeshInstance3D" parent="."]
|
||||
mesh = SubResource("CapsuleMesh1")
|
||||
|
||||
[node name="Camera3D" type="Camera3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0)
|
||||
[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")
|
||||
@@ -1,4 +1,6 @@
|
||||
[gd_scene load_steps=1 format=3 uid="uid://c49a36fc7db_test_level"]
|
||||
[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)
|
||||
@@ -44,12 +46,18 @@ 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.4, 0.6, 0.8, 1)
|
||||
ambient_light_source = 1
|
||||
ambient_light_energy = 0.4
|
||||
ambient_light_sky_energy = 1.0
|
||||
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"]
|
||||
|
||||
@@ -109,8 +117,21 @@ 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.785398, 0, 0)
|
||||
rotation = Vector3(-0.95, -0.5, 0)
|
||||
light_energy = 1.3
|
||||
shadow_enabled = true
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||
|
||||
+27
-3
@@ -178,18 +178,19 @@ func login(username: String, password: String) -> Dictionary:
|
||||
|
||||
|
||||
## Register a new user. On success stores the returned access token.
|
||||
## @param over_18 Must be true; the backend rejects registration otherwise.
|
||||
## @param date_of_birth ISO "YYYY-MM-DD". The backend computes age from it
|
||||
## and rejects under-18s (403).
|
||||
func register(
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
over_18: bool
|
||||
date_of_birth: String
|
||||
) -> Dictionary:
|
||||
var payload := {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"over_18": over_18,
|
||||
"date_of_birth": date_of_birth,
|
||||
}
|
||||
var result := await request(
|
||||
HTTPClient.METHOD_POST,
|
||||
@@ -221,6 +222,29 @@ func get_me() -> Dictionary:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
+100
-14
@@ -35,11 +35,12 @@ var _auth_mode_label: Label
|
||||
var _username_edit: LineEdit
|
||||
var _password_edit: LineEdit
|
||||
var _email_edit: LineEdit
|
||||
var _over_18_check: CheckBox
|
||||
var _dob_edit: LineEdit
|
||||
var _auth_button: Button
|
||||
var _status_label: Label
|
||||
var _welcome_label: Label
|
||||
var _play_button: Button
|
||||
var _customize_button: Button
|
||||
var _quit_button: Button
|
||||
|
||||
var _is_register_mode: bool = false
|
||||
@@ -48,7 +49,37 @@ var _is_authenticating: bool = false
|
||||
|
||||
# ─── 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:
|
||||
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.
|
||||
_auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow
|
||||
_login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton
|
||||
@@ -58,11 +89,12 @@ func _ready() -> void:
|
||||
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
|
||||
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
|
||||
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
|
||||
_over_18_check = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check
|
||||
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DobEdit
|
||||
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
|
||||
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
||||
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
|
||||
_play_button = $CenterContainer/VBoxContainer/PlayButton
|
||||
_customize_button = $CenterContainer/VBoxContainer/CustomizeButton
|
||||
_quit_button = $CenterContainer/VBoxContainer/QuitButton
|
||||
|
||||
# Default to Play being gated. When a token exists, the async session
|
||||
@@ -70,6 +102,7 @@ func _ready() -> void:
|
||||
# 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
|
||||
@@ -80,6 +113,7 @@ func _ready() -> void:
|
||||
var username: String = str((me_result.data as Dictionary).get("username", ""))
|
||||
if username != "":
|
||||
_show_authenticated(username)
|
||||
await _prime_avatar()
|
||||
auth_state_ready.emit()
|
||||
return
|
||||
|
||||
@@ -111,10 +145,11 @@ func _show_authenticated(username: String) -> void:
|
||||
_username_edit.visible = false
|
||||
_password_edit.visible = false
|
||||
_email_edit.visible = false
|
||||
_over_18_check.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)
|
||||
|
||||
@@ -126,9 +161,10 @@ func _reset_to_logged_out() -> void:
|
||||
_status_label.visible = false
|
||||
_welcome_label.visible = false
|
||||
_email_edit.visible = false
|
||||
_over_18_check.visible = false
|
||||
_over_18_check.button_pressed = false
|
||||
_dob_edit.visible = false
|
||||
_dob_edit.text = ""
|
||||
_play_button.disabled = true # Gate play behind successful auth.
|
||||
_customize_button.disabled = true
|
||||
_update_auth_mode_ui()
|
||||
|
||||
|
||||
@@ -142,10 +178,29 @@ func _on_play_button_pressed() -> void:
|
||||
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:
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
func _on_login_button_pressed() -> void:
|
||||
@@ -172,12 +227,12 @@ func _update_auth_mode_ui() -> void:
|
||||
_auth_mode_label.text = "Register"
|
||||
_auth_button.text = "Register"
|
||||
_email_edit.visible = true
|
||||
_over_18_check.visible = true
|
||||
_dob_edit.visible = true
|
||||
else:
|
||||
_auth_mode_label.text = "Login"
|
||||
_auth_button.text = "Login"
|
||||
_email_edit.visible = false
|
||||
_over_18_check.visible = false
|
||||
_dob_edit.visible = false
|
||||
|
||||
|
||||
# ─── Auth submission ──────────────────────────────────────────────────────────
|
||||
@@ -204,8 +259,9 @@ func _on_auth_button_pressed() -> void:
|
||||
if email == "":
|
||||
_show_status("Email is required.")
|
||||
return
|
||||
if not _over_18_check.button_pressed:
|
||||
_show_status("You must confirm you are 18 or older to register.")
|
||||
var dob := _dob_edit.text.strip_edges()
|
||||
if not _is_valid_dob(dob):
|
||||
_show_status("Enter your date of birth as YYYY-MM-DD.")
|
||||
return
|
||||
|
||||
_auth_button.disabled = true
|
||||
@@ -213,8 +269,8 @@ func _on_auth_button_pressed() -> void:
|
||||
_is_authenticating = true
|
||||
_clear_status()
|
||||
|
||||
var result := await ApiClient.register(username, email, password, true)
|
||||
_on_auth_complete(result)
|
||||
var result := await ApiClient.register(username, email, password, dob)
|
||||
await _on_auth_complete(result)
|
||||
else:
|
||||
_auth_button.disabled = true
|
||||
_auth_button.text = "Logging in..."
|
||||
@@ -222,7 +278,7 @@ func _on_auth_button_pressed() -> void:
|
||||
_clear_status()
|
||||
|
||||
var result := await ApiClient.login(username, password)
|
||||
_on_auth_complete(result)
|
||||
await _on_auth_complete(result)
|
||||
|
||||
|
||||
func _on_auth_complete(result: Dictionary) -> void:
|
||||
@@ -232,24 +288,54 @@ func _on_auth_complete(result: Dictionary) -> void:
|
||||
_is_authenticating = false
|
||||
|
||||
if result.ok:
|
||||
# Success — hide the auth form fields, show only the welcome label.
|
||||
# Update the UI to the authenticated state first, synchronously, so
|
||||
# the player sees "Welcome" + an enabled Play immediately -- then
|
||||
# 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
|
||||
_over_18_check.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.visible = true
|
||||
_play_button.disabled = false
|
||||
_customize_button.disabled = false
|
||||
_auth_buttons_row.visible = false
|
||||
print("Auth successful. Token stored.")
|
||||
await _prime_avatar()
|
||||
else:
|
||||
# Failure — show backend error 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 ───────────────────────────────────────────────────────────
|
||||
|
||||
func _show_status(message: String) -> void:
|
||||
|
||||
@@ -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)
|
||||
+244
-51
@@ -2,15 +2,23 @@ class_name Player
|
||||
extends CharacterBody3D
|
||||
## The player character controller.
|
||||
##
|
||||
## A simple CharacterBody3D with WASD/arrow locomotion, gravity, jump, and a
|
||||
## first-person Camera3D with mouse-look. Reads the spawn point from the
|
||||
## current scene's `PlayerSpawn` Marker3D (see docs/architecture/pc2_test_level.md §3).
|
||||
## Roblox-style third-person controller:
|
||||
##
|
||||
## No networking, no chunk code, no avatar UI — PC2 is local-only.
|
||||
## * 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 constants
|
||||
# Movement / camera constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const WALK_SPEED: float = 5.0
|
||||
@@ -20,34 +28,64 @@ const GRAVITY: float = 9.8
|
||||
## Mouse-look sensitivity (radians per pixel of relative motion).
|
||||
const MOUSE_SENSITIVITY: float = 0.003
|
||||
|
||||
## Camera pitch is clamped so the player can't flip past looking straight
|
||||
## up/down (which would otherwise let mouse-look invert on itself).
|
||||
const PITCH_LIMIT: float = deg_to_rad(89.0)
|
||||
## 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input state
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _input_direction := Vector2.ZERO
|
||||
var _camera_pitch: float = 0.0
|
||||
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 = $Camera3D
|
||||
@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 — spawn at PlayerSpawn marker, capture the mouse for look
|
||||
# _ready
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
# Per §3 of pc2_test_level.md: look up PlayerSpawn from the current
|
||||
# scene root. The lookup walks the full subtree (recursive=true) so
|
||||
# it finds the marker whether it is a direct child of the level or
|
||||
# nested deeper.
|
||||
#
|
||||
# current_scene is null in headless --script mode and in any scene
|
||||
# before the main scene is fully swapped in, so guard it rather than
|
||||
# blindly dereferencing.
|
||||
# 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:
|
||||
@@ -55,37 +93,140 @@ func _ready() -> void:
|
||||
|
||||
if spawn is Node3D:
|
||||
global_position = (spawn as Node3D).global_position
|
||||
# Align player forward with spawn yaw (optional polish).
|
||||
var spawn_yaw := (spawn as Node3D).rotation.y
|
||||
rotation.y = spawn_yaw
|
||||
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
|
||||
|
||||
# Capture the mouse for FPS-style look. Headless/test runs have no real
|
||||
# window to capture input for, so guard against that rather than error.
|
||||
if DisplayServer.get_name() != "headless":
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
# 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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _unhandled_input — capture keyboard / mouse for movement
|
||||
# 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:
|
||||
_update_input_direction(key)
|
||||
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)
|
||||
# Release the mouse so the player can get back to a menu/cursor.
|
||||
if key.pressed and key.keycode == KEY_ESCAPE:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
elif event is InputEventMouseButton and event.pressed:
|
||||
# Clicking back into the viewport re-captures the mouse.
|
||||
if Input.mouse_mode == Input.MOUSE_MODE_VISIBLE and DisplayServer.get_name() != "headless":
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
# 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)
|
||||
|
||||
@@ -115,34 +256,86 @@ func _clear_input_direction(key: InputEventKey) -> void:
|
||||
|
||||
|
||||
func _handle_mouse_look(event: InputEventMouseMotion) -> void:
|
||||
# Yaw rotates the whole body (so movement direction turns with the
|
||||
# look direction); pitch only tilts the camera, so the capsule itself
|
||||
# never tips over.
|
||||
rotation.y -= event.relative.x * MOUSE_SENSITIVITY
|
||||
_camera_pitch = clampf(_camera_pitch - event.relative.y * MOUSE_SENSITIVITY, -PITCH_LIMIT, PITCH_LIMIT)
|
||||
_camera.rotation.x = _camera_pitch
|
||||
_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 — gravity, jump, move_and_slide
|
||||
# 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:
|
||||
# Apply gravity.
|
||||
if not is_on_floor():
|
||||
velocity.y -= GRAVITY * delta
|
||||
|
||||
# Jump.
|
||||
var jumped := false
|
||||
if is_on_floor() and Input.is_action_just_pressed("ui_accept"):
|
||||
velocity.y = JUMP_VELOCITY
|
||||
jumped = true
|
||||
|
||||
# Build movement direction from input. Godot's local "forward" is -Z, not
|
||||
# +Z, so W (input.y = +1) must use -basis.z -- using +basis.z here would
|
||||
# move the player backward when pressing forward (confirmed by reading
|
||||
# Camera3D/Node3D's own convention: a node looks down its local -Z).
|
||||
var direction := (_input_direction.x * transform.basis.x -
|
||||
_input_direction.y * transform.basis.z).normalized()
|
||||
# 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"
|
||||
backend_env = os.environ.copy()
|
||||
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 ===")
|
||||
r = run([sys.executable, "-m", "alembic", "upgrade", "head"], cwd=BACKEND, env=backend_env)
|
||||
@@ -133,10 +137,77 @@ def main():
|
||||
for test in smoke_tests:
|
||||
rel = os.path.relpath(test, ROOT)
|
||||
print(f"\n--- {rel} ---")
|
||||
r = run([godot_bin, "--headless", "--path", str(ROOT), "--script", f"res://{rel}"], env=godot_env)
|
||||
if r.returncode != 0:
|
||||
godot_ok = False
|
||||
# smoke_test_network_client.gd needs a separate dedicated
|
||||
# server process listening on ENet 7777 -- stand one up
|
||||
# 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
|
||||
|
||||
# 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:
|
||||
server_proc.terminate()
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Full networked game flow, two real client processes:
|
||||
# register -> POST /api/worlds/{id}/join -> Session -> game.tscn -> networked.
|
||||
# Client A must see client B rendered as a remote player and its own movement
|
||||
# must round-trip. Requires a backend with GAME_SERVERS_ENABLED and the Godot
|
||||
# binary reachable from it.
|
||||
#
|
||||
# Usage: scripts/run_game_multiplayer_test.sh <backend_base_url> <world_id>
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
BASE_URL="${1:?usage: run_game_multiplayer_test.sh <backend_url> <world_id>}"
|
||||
WORLD_ID="${2:?usage: run_game_multiplayer_test.sh <backend_url> <world_id>}"
|
||||
GODOT_BIN="${GODOT_BIN:-godot}"
|
||||
command -v "$GODOT_BIN" >/dev/null 2>&1 || GODOT_BIN="$HOME/godot/godot"
|
||||
|
||||
export AGP_API_BASE_URL="$BASE_URL"
|
||||
|
||||
cleanup() { kill "${B_PID:-}" 2>/dev/null; wait 2>/dev/null; }
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "=== client B: join world $WORLD_ID and hold ==="
|
||||
"$GODOT_BIN" --headless --path . --script res://scripts/mp_game_flow_client.gd -- \
|
||||
--role b --world "$WORLD_ID" >/tmp/mpflow_b.log 2>&1 &
|
||||
B_PID=$!
|
||||
for _ in $(seq 1 40); do grep -q 'RESULT b in_world' /tmp/mpflow_b.log && break; sleep 1; done
|
||||
grep -q 'RESULT b in_world' /tmp/mpflow_b.log || { echo "B never entered the world:"; tail -5 /tmp/mpflow_b.log; echo "=== SMOKE RESULT: FAIL ==="; exit 1; }
|
||||
|
||||
echo "=== client A: join world $WORLD_ID, move, look for B ==="
|
||||
"$GODOT_BIN" --headless --path . --script res://scripts/mp_game_flow_client.gd -- \
|
||||
--role a --world "$WORLD_ID" 2>&1 | tee /tmp/mpflow_a.log
|
||||
A_LINE=$(grep '^RESULT a' /tmp/mpflow_a.log || true)
|
||||
|
||||
echo
|
||||
echo "A: ${A_LINE:-<none>}"
|
||||
echo "B: $(grep '^RESULT b' /tmp/mpflow_b.log || echo '<none>')"
|
||||
|
||||
if echo "$A_LINE" | grep -q 'moved=true' && echo "$A_LINE" | grep -qE 'remotes=[1-9]'; then
|
||||
echo "=== SMOKE RESULT: PASS ==="
|
||||
exit 0
|
||||
fi
|
||||
echo "=== SMOKE RESULT: FAIL ==="
|
||||
exit 1
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user