Compare commits

...

5 Commits

Author SHA1 Message Date
Rijad Zuzo 4ff8858b19 feat(characters): add modular woodland traveler kit 2026-09-05 22:54:54 +02:00
Rijad Zuzo 70ee6feddf chore(godot): finalize sky import and project settings 2026-09-05 20:40:33 +02:00
Rijad Zuzo ec16e2f2ca feat(environment): add painterly skies and living meadows 2026-09-05 20:27:09 +02:00
Rijad Zuzo 7899d36f03 feat(foliage): add recovering grass trails and bush contact 2026-09-05 19:30:46 +02:00
Rijad Zuzo da3bed67a0 feat(art): add storybook meadow and woodland villagers 2026-09-05 18:56:44 +02:00
172 changed files with 6144 additions and 585 deletions
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+116
View File
@@ -0,0 +1,116 @@
# Woodland character kit
Original Blender geometry inspired by the supplied cat-traveler reference:
broad cheeks, relaxed almond eyes, whiskers, layered clothing, rolled cuffs,
boots, a scarf, and a travel pack with a bedroll. The reference image is not
embedded in the assets. Player and NPC visuals use this kit in the main game.
## Try and configure
Run `tools/CharacterWorkshop.tscn` in Godot 4.7 (F6), or:
```bash
/Applications/Godot.app/Contents/MacOS/Godot --path "$PWD" tools/CharacterWorkshop.tscn
```
Choose one of six presets, change the parts and proportions, drag the model to
turn it, scroll over the model to zoom, and enable the walking preview. **Save
profile** writes a `CharacterAppearanceProfile` `.tres` to the chosen file.
Assign that resource to `PlayerVisual.appearance` or `NpcVisual.appearance` in
the Inspector. The player defaults to `profiles/reference_cat.tres`. NPCs with
no authored override derive their appearance from their stable simulation ID.
The workshop is a separate authoring scene; it does not modify a game save.
| Control | Available values |
| --- | --- |
| Head and tail | Cat, fox, rabbit, badger, otter |
| Ears | Species default, or any of six ear pairs including lop ears |
| Height | 0.80–1.15 times the base height |
| Body fullness | 0–1 Blender `Stout` shape key |
| Head size / leg length / ear length | 0.88–1.12 / 0.85–1.15 / 0.65–1.20 |
| Clothing | Open vest, long coat, work tunic with apron |
| Arms | Relaxed or holding pack straps |
| Accessories | Scarf, spectacles, beret, brimmed hat, satchel, travel pack |
| Palette | Fur, muzzle, outfit, shirt, scarf, trousers, leather |
The workshop exposes the four main palette colors. All seven channels are
editable on the profile resource. Profession tint remains a separate, subtle
overlay driven by the actual NPC profession.
## Blender source and rebuilding
Open `art/characters/woodland_character_kit.blend`. The visible collection is an
assembled traveler; the hidden library meshes contain the editable building
blocks. Unhide a library object to edit it. Torso and wearable meshes have
`Basis` and `Stout` shape keys. Preview objects are copies of library objects.
```bash
/Applications/Blender.app/Contents/MacOS/Blender --background \
--python tools/art/build_character_kit.py
/Applications/Godot.app/Contents/MacOS/Godot --headless --editor --path "$PWD" --import
```
The reproducible Blender 5.1 generator rebuilds the source, GLB, and triangle
manifest. Regenerating replaces manual edits to those generated files. Commit
changes to the generator when they should survive a rebuild. Godot only imports
the portable GLB; `.gdignore` excludes the Blender authoring directory.
## Mesh and animation contract
`woodland_parts.glb` contains 31 named parts, each with one opaque surface. The
reference cat uses 10 surfaces and 20,070 base triangles before Godot's generated
LODs. The GLB is approximately 4.6 MiB. `mesh_budget.json` records each part's
triangle count, origin and shape-key support. Accessories are optional; only
selected meshes enter the live scene.
- `AnimalAppearance` caches the imported mesh library once. Characters share
`ArrayMesh` resources and have their own shader material and blend-shape
weights. No per-frame mesh rebuilding or CPU deformation is performed.
- Godot Y is up, +Z is forward. The existing body, head, arm, leg and tail node
paths remain animation sockets. Ear/glasses attachments follow the head;
scarf and bag attachments follow the torso. `Hair` is the optional hat socket.
- Fullness deforms the torso and worn gear together. Shoulder/hip spacing follows
it. Leg length raises the upper body while keeping the soles on the ground.
Gait uses configured rest heights; the hiker pose has restrained arm swing.
- Animation remains the existing rigid-part procedural gait. The kit does not
add a skeletal animation library, facial rig, cloth simulation, or physics
bodies. Changes to these appearance controls are cosmetic: collision,
navigation, interaction reach, authoritative positions, and saves retain
their existing contracts. Very unusual mixed parts may need art adjustment.
- `UV2.x` stores a discrete palette role (0 fixed vertex paint, 1 fur, 2 muzzle,
3 outfit, 4 linen, 5 scarf, 6 trousers, 7 leather, 8 fur markings, 9 outfit seams).
Preserve both UV layers and vertex colors on export. Do not regenerate UV2
lightmap coordinates. `character_cel.gdshader` supplies soft cel bands with
no texture sampling, transparency, outline pass, or screen-space effect.
## Verification and review
`tests/character_appearance_test.gd` checks the real imported geometry, palette
roles, independent morph weights, shared meshes, attachment reuse, all six
presets, extreme proportions, stable IDs/RNG isolation, gait rest heights, and
NPC cue clearance. Existing lifecycle scenarios cover profession, inventory,
death, and save/load behavior. Run the full repository quality gate before a
commit.
```bash
./tools/quality.sh
/Applications/Godot.app/Contents/MacOS/Godot --path "$PWD" \
--rendering-driver metal --script tools/capture_character_kit.gd
/Applications/Godot.app/Contents/MacOS/Godot --path "$PWD" \
--rendering-method gl_compatibility --script tools/capture_character_kit.gd
/Applications/Godot.app/Contents/MacOS/Godot --path "$PWD" \
--rendering-driver metal --script tools/capture_character_kit.gd -- --world
```
The `docs/baselines/character_*.png` images are actual Godot renders. They show
the traveler, rear pack detail, preset lineup, workshop and main-world placement.
These local Apple M1 Max checks do not establish a frame-time budget for a
large crowd or a weaker GPU.
The workshop uses filmic tonemapping with a separately calibrated Compatibility
exposure to preserve cream-fabric highlights. This only stages the authoring
preview; the main world retains its existing day/night environment. Metal and
Compatibility images are separate visual reviews, not a pixel-parity claim.
The full Godot 4.7 gate passes 61 scenario runs (including 111 character checks)
and 182 GUT tests / 2,279 assertions. The native workshop controls and profile
export were also exercised directly.
+312
View File
@@ -0,0 +1,312 @@
{
"TorsoVest": {
"triangles": 2914,
"surfaces": 1,
"pivot": [
0,
1.05,
0
],
"stout_shape": true
},
"TorsoCoat": {
"triangles": 3074,
"surfaces": 1,
"pivot": [
0,
1.05,
0
],
"stout_shape": true
},
"TorsoTunic": {
"triangles": 1894,
"surfaces": 1,
"pivot": [
0,
1.05,
0
],
"stout_shape": true
},
"ArmRelaxedLeft": {
"triangles": 996,
"surfaces": 1,
"pivot": [
-0.44,
1.3,
0
],
"stout_shape": false
},
"ArmHikerLeft": {
"triangles": 2144,
"surfaces": 1,
"pivot": [
-0.44,
1.3,
0
],
"stout_shape": false
},
"LegLeft": {
"triangles": 1074,
"surfaces": 1,
"pivot": [
-0.2,
0.61,
0
],
"stout_shape": false
},
"ArmRelaxedRight": {
"triangles": 996,
"surfaces": 1,
"pivot": [
0.44,
1.3,
0
],
"stout_shape": false
},
"ArmHikerRight": {
"triangles": 2144,
"surfaces": 1,
"pivot": [
0.44,
1.3,
0
],
"stout_shape": false
},
"LegRight": {
"triangles": 1074,
"surfaces": 1,
"pivot": [
0.2,
0.61,
0
],
"stout_shape": false
},
"HeadCat": {
"triangles": 6798,
"surfaces": 1,
"pivot": [
0,
1.68,
0
],
"stout_shape": false
},
"EarsCat": {
"triangles": 202,
"surfaces": 1,
"pivot": [
0,
2.2,
0
],
"stout_shape": false
},
"TailCat": {
"triangles": 584,
"surfaces": 1,
"pivot": [
0,
0.8,
-0.22
],
"stout_shape": false
},
"HeadFox": {
"triangles": 6798,
"surfaces": 1,
"pivot": [
0,
1.68,
0
],
"stout_shape": false
},
"EarsFox": {
"triangles": 202,
"surfaces": 1,
"pivot": [
0,
2.2,
0
],
"stout_shape": false
},
"TailFox": {
"triangles": 636,
"surfaces": 1,
"pivot": [
0,
0.8,
-0.22
],
"stout_shape": false
},
"HeadRabbit": {
"triangles": 6684,
"surfaces": 1,
"pivot": [
0,
1.68,
0
],
"stout_shape": false
},
"EarsRabbit": {
"triangles": 328,
"surfaces": 1,
"pivot": [
0,
2.2,
0
],
"stout_shape": false
},
"TailRabbit": {
"triangles": 440,
"surfaces": 1,
"pivot": [
0,
0.8,
-0.22
],
"stout_shape": false
},
"HeadBadger": {
"triangles": 6684,
"surfaces": 1,
"pivot": [
0,
1.68,
0
],
"stout_shape": false
},
"EarsBadger": {
"triangles": 1456,
"surfaces": 1,
"pivot": [
0,
2.2,
0
],
"stout_shape": false
},
"TailBadger": {
"triangles": 248,
"surfaces": 1,
"pivot": [
0,
0.8,
-0.22
],
"stout_shape": false
},
"HeadOtter": {
"triangles": 6684,
"surfaces": 1,
"pivot": [
0,
1.68,
0
],
"stout_shape": false
},
"EarsOtter": {
"triangles": 1456,
"surfaces": 1,
"pivot": [
0,
2.2,
0
],
"stout_shape": false
},
"TailOtter": {
"triangles": 360,
"surfaces": 1,
"pivot": [
0,
0.8,
-0.22
],
"stout_shape": false
},
"EarsLop": {
"triangles": 328,
"surfaces": 1,
"pivot": [
0,
2.2,
0
],
"stout_shape": false
},
"Scarf": {
"triangles": 396,
"surfaces": 1,
"pivot": [
0,
1.05,
0
],
"stout_shape": true
},
"BagTravel": {
"triangles": 2740,
"surfaces": 1,
"pivot": [
0,
1.05,
0
],
"stout_shape": true
},
"BagSatchel": {
"triangles": 640,
"surfaces": 1,
"pivot": [
0,
1.05,
0
],
"stout_shape": true
},
"HatBeret": {
"triangles": 880,
"surfaces": 1,
"pivot": [
0,
1.9,
0
],
"stout_shape": false
},
"HatBrim": {
"triangles": 976,
"surfaces": 1,
"pivot": [
0,
1.9,
0
],
"stout_shape": false
},
"Spectacles": {
"triangles": 644,
"surfaces": 1,
"pivot": [
0,
1.68,
0
],
"stout_shape": false
}
}
@@ -0,0 +1,17 @@
[gd_resource type="Resource" script_class="CharacterAppearanceProfile" load_steps=2 format=3]
[ext_resource type="Script" path="res://world/presentation/CharacterAppearanceProfile.gd" id="1"]
[resource]
script = ExtResource("1")
species = 3
height = 0.98
body_fullness = 1.0
leg_length = 0.9
outfit = 2
arm_pose = 0
bag = 0
hat = 1
scarf = false
fur_color = Color(0.79, 0.77, 0.71, 1)
outfit_color = Color(0.47, 0.5, 0.35, 1)
@@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="CharacterAppearanceProfile" load_steps=2 format=3]
[ext_resource type="Script" path="res://world/presentation/CharacterAppearanceProfile.gd" id="1"]
[resource]
script = ExtResource("1")
species = 1
height = 1.12
body_fullness = 0.15
leg_length = 1.12
outfit = 1
arm_pose = 0
hat = 2
bag = 1
fur_color = Color(0.72, 0.43, 0.24, 1)
outfit_color = Color(0.42, 0.49, 0.33, 1)
@@ -0,0 +1,17 @@
[gd_resource type="Resource" script_class="CharacterAppearanceProfile" load_steps=2 format=3]
[ext_resource type="Script" path="res://world/presentation/CharacterAppearanceProfile.gd" id="1"]
[resource]
script = ExtResource("1")
species = 2
ears = 4
height = 0.92
body_fullness = 0.8
ear_length = 1.12
outfit = 2
arm_pose = 0
bag = 0
scarf = false
fur_color = Color(0.80, 0.78, 0.73, 1)
outfit_color = Color(0.6, 0.4, 0.28, 1)
@@ -0,0 +1,15 @@
[gd_resource type="Resource" script_class="CharacterAppearanceProfile" load_steps=2 format=3]
[ext_resource type="Script" path="res://world/presentation/CharacterAppearanceProfile.gd" id="1"]
[resource]
script = ExtResource("1")
species = 4
height = 1.06
body_fullness = 0.42
head_size = 0.94
outfit = 0
arm_pose = 0
bag = 1
fur_color = Color(0.51, 0.42, 0.33, 1)
outfit_color = Color(0.41, 0.43, 0.54, 1)
@@ -0,0 +1,17 @@
[gd_resource type="Resource" script_class="CharacterAppearanceProfile" load_steps=2 format=3]
[ext_resource type="Script" path="res://world/presentation/CharacterAppearanceProfile.gd" id="1"]
[resource]
script = ExtResource("1")
species = 2
height = 0.84
body_fullness = 0.18
head_size = 1.08
leg_length = 0.92
outfit = 1
arm_pose = 0
bag = 1
spectacles = true
fur_color = Color(0.76, 0.72, 0.64, 1)
outfit_color = Color(0.57, 0.37, 0.31, 1)
@@ -0,0 +1,6 @@
[gd_resource type="Resource" script_class="CharacterAppearanceProfile" load_steps=2 format=3]
[ext_resource type="Script" path="res://world/presentation/CharacterAppearanceProfile.gd" id="1"]
[resource]
script = ExtResource("1")
Binary file not shown.
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://b4p2tfex61t8o"
path="res://.godot/imported/woodland_parts.glb-1394516718c8f9a3ee6122be9d99f45f.scn"
[deps]
source_file="res://assets/characters/woodland_parts.glb"
dest_files=["res://.godot/imported/woodland_parts.glb-1394516718c8f9a3ee6122be9d99f45f.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
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
gltf/texture_map_mode=1
+21
View File
@@ -0,0 +1,21 @@
# Jajce meadow flowers
Original mesh assets built for The Steward. Four small clumps give authored
meadow drifts a cream, rose, butter-yellow and lavender palette. Each contains
three stems, folded leaves and opaque petal geometry with painted vertex colors.
There are no external textures, dependencies, collision shapes or gameplay IDs.
Editable sources live in `art/meadow/`, outside Godot's import tree. Rebuild with
Blender 5.1 or later:
```sh
Blender --background --python tools/art/build_meadow_flowers.py
```
`mesh_budget.json` records exported triangle/surface/file-size counts. Runtime
`PainterlyMeadow` uses one MultiMesh per species and its own soft-lit wind shader;
the GLB material preserves the original palette when opened in other tools.
Godot 4.7 Compatibility requires explicit white MultiMesh instance colors when
the custom instance buffer is enabled. `PainterlyMeadow` supplies that neutral
color so both Compatibility and Forward+ preserve the painted GLB palette.
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://cnoywbu65c0hg"
path="res://.godot/imported/cream_daisy.glb-7a51f417838affb7d915e8a81cdabcc9.scn"
[deps]
source_file="res://assets/meadow/cream_daisy.glb"
dest_files=["res://.godot/imported/cream_daisy.glb-7a51f417838affb7d915e8a81cdabcc9.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
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
gltf/texture_map_mode=1
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dur42glc0ybm3"
path="res://.godot/imported/lavender_spire.glb-ca4666e5e14c84270d4621d49918d5e7.scn"
[deps]
source_file="res://assets/meadow/lavender_spire.glb"
dest_files=["res://.godot/imported/lavender_spire.glb-ca4666e5e14c84270d4621d49918d5e7.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
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
gltf/texture_map_mode=1
+26
View File
@@ -0,0 +1,26 @@
{
"cream_daisy": {
"triangles": 180,
"surfaces": 1,
"flower_stems": 3,
"bytes": 19896
},
"pink_cosmos": {
"triangles": 171,
"surfaces": 1,
"flower_stems": 3,
"bytes": 18888
},
"yellow_buttercup": {
"triangles": 153,
"surfaces": 1,
"flower_stems": 3,
"bytes": 16840
},
"lavender_spire": {
"triangles": 360,
"surfaces": 1,
"flower_stems": 3,
"bytes": 40444
}
}
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dqksrghyqlfec"
path="res://.godot/imported/pink_cosmos.glb-f2a2c19fa8aeebec56761be5c75e2426.scn"
[deps]
source_file="res://assets/meadow/pink_cosmos.glb"
dest_files=["res://.godot/imported/pink_cosmos.glb-f2a2c19fa8aeebec56761be5c75e2426.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
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
gltf/texture_map_mode=1
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://urkk7ybwrnv7"
path="res://.godot/imported/yellow_buttercup.glb-88c3511b4d2c884beab5a1c921cdc3cb.scn"
[deps]
source_file="res://assets/meadow/yellow_buttercup.glb"
dest_files=["res://.godot/imported/yellow_buttercup.glb-88c3511b4d2c884beab5a1c921cdc3cb.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
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
gltf/texture_map_mode=1
+59
View File
@@ -0,0 +1,59 @@
# Painterly valley assets
Original broadleaf and evergreen meshes created in Blender for The Steward.
Scalloped crowns, painted vertex variation, crooked forks and root flares replace
the primitive tree silhouettes. Editable sources are in `art/painterly/`;
`.gdignore` keeps Blender outside Godot's runtime import dependencies. The two
portable GLBs use opaque geometry without textures or alpha cards.
## Rebuild and mesh budget
From the project root, using Blender 5.1 and Godot 4.7:
```sh
"$BLENDER_BIN" --background --python tools/art/build_painterly_trees.py
"$GODOT_BIN" --headless --editor --path "$PWD" --import
```
Use the isolated Godot profile configured by `tools/quality.sh` for headless
validation. Runtime consumes the GLBs directly; no mesh-extraction step is
needed. Preserve their `.import` files when updating exports.
| Geometry | Triangles | Surfaces |
| --- | ---: | ---: |
| Shared broadleaf crown | 1,236 | 1 |
| Joined broadleaf trunk, forks and roots | 406 | 1 |
| Complete broadleaf: four crowns plus trunk | 5,350 | 5 |
| Complete conifer: boughs plus trunk | 1,580 | 2 |
`tree_mesh_budget.json` records these export counts. The broadleaf GLB is
45,676 bytes; the conifer GLB is 40,836 bytes and stands 3.10 m before placement
scaling. A separate temporary rebuild with Blender 5.1.2 produced byte-identical
copies of both GLBs and the budget JSON. This establishes generator
reproducibility with that Blender version, not identical `.blend` serialization
across versions.
`StylizedTree` shares imported meshes and isolates crown tint/wind materials.
Its four direct `Canopy*` children retain the existing thinning and stump
presentation contract. `PainterlyConifer` shares imported bough/trunk meshes.
Both use the same world-direction breeze and explicit painted bark colors;
neither adds simulation state, collision or per-frame GDScript work.
## Painted sky provenance
`painted_sky.png` is an original 1774 × 887 image generated with the built-in
ImageGen tool, rather than a CLI generator. The prompt asks for a painted
cumulus panorama with warm ivory cloud tops, cool turquoise cloud shadows and
a clear blue summer atmosphere. Film references informed the color and mood;
no external film image was copied into the texture. The exact generation
prompt is recorded in [sky_prompt.md](sky_prompt.md).
`storybook_sky.gdshader` maps the panorama's horizon at 69% of its height onto
the upper hemisphere, repeats the painted band twice around the azimuth to
retain cloud proportions, blends its seam/edge into haze, and follows the existing
day/night palette. The image supplies the sky backdrop only; it does not paint
terrain or create volumetric clouds.
Final renderer QA and the complete project quality gate belong to the
integrated environment review. See the [presentation guide](../../docs/FEATURE_PRESENTATION_PERFORMANCE.md)
for current verification evidence and remaining hardware limits.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+41
View File
@@ -0,0 +1,41 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dmkubk6dhhyis"
path.s3tc="res://.godot/imported/painted_sky.png-9888802278aff2f83effdab2649f1f05.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
[deps]
source_file="res://assets/painterly/painted_sky.png"
dest_files=["res://.godot/imported/painted_sky.png-9888802278aff2f83effdab2649f1f05.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://clcvatvbyixma"
path="res://.godot/imported/painterly_conifer.glb-596d48094a6a072cfc85374d478c7bbe.scn"
[deps]
source_file="res://assets/painterly/painterly_conifer.glb"
dest_files=["res://.godot/imported/painterly_conifer.glb-596d48094a6a072cfc85374d478c7bbe.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
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
gltf/texture_map_mode=1
Binary file not shown.
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://blu5r5vupqvpd"
path="res://.godot/imported/painterly_tree.glb-12cb20acdabe08bb0fe06cba12958067.scn"
[deps]
source_file="res://assets/painterly/painterly_tree.glb"
dest_files=["res://.godot/imported/painterly_tree.glb-12cb20acdabe08bb0fe06cba12958067.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
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
gltf/texture_map_mode=1
+35
View File
@@ -0,0 +1,35 @@
# Painted sky provenance
Created for this project with the built-in imagegen tool on 2026-09-05.
Original output: 1774 × 887 PNG, copied without pixel editing to
`assets/painterly/painted_sky.png`. The sky shader samples the upper portion
and blends its horizon into the simulation-driven day/night palette.
No film frame or downloaded artwork is used as a runtime texture.
## Generation prompt
Use case: stylized-concept. Asset type: original 360-degree panoramic sky
texture for a Godot game, 2:1 equirectangular landscape, ideally 3072x1536.
Create ONLY SKY, absolutely no ground, hills, buildings, ocean, objects, text
or sun disk. Art direction: exquisitely hand-painted Japanese animation
background, Studio Ghibli inspired summer pastoral fantasy, gouache brush
texture, saturated clear turquoise-blue atmosphere, towering luminous cumulus
clouds with creamy warm sunlit edges, intricate scalloped billowing forms,
pale cyan and slate blue softly painted shadow volumes. Composition is a full
spherical sky environment texture: upper half of image shows the upward
hemisphere, zenith at top is mostly deep clean blue, large beautiful cumulus
towers occupy the upper middle band between y=15% and y=44%, and the horizon
at exactly image vertical halfway is uniform pale atmospheric aqua. Lower
half is entirely an unobtrusive smooth pale aqua color gradient, with NO
landscape. Cloud coverage about 40% of upper hemisphere, generous varied blue
gaps, asymmetrical naturally spaced cloud masses. Left and right edges must
wrap seamlessly in color and shape, keep thin strips at far left/right clear
sky to avoid seam. No photographic cloud noise, no 3D render or vector
outlines, no flat cartoon circles. Painterly refined layered brushwork like a
cinematic animated film background, not a flat gradient. Use only original
cloud shapes; no copied film imagery.
The returned image does not exactly meet the requested resolution or horizon
position. The runtime shader uses its actual dimensions and remaps the painted
horizon at 69% image height. Blender mesh assets are deterministic rebuilds;
this generated image is a pinned art asset, not a deterministic generator output.
+24
View File
@@ -0,0 +1,24 @@
{
"Canopy": {
"triangles": 1236,
"surfaces": 1,
"vertices": 712
},
"Trunk": {
"triangles": 406,
"surfaces": 1,
"vertices": 221
},
"runtime_tree": {
"triangles": 5350,
"surfaces": 5,
"shared_meshes": 2
},
"glb_bytes": 45676,
"conifer": {
"triangles": 1580,
"surfaces": 2,
"height_m": 3.1,
"glb_bytes": 40836
}
}
+83
View File
@@ -0,0 +1,83 @@
# Woodland storybook art
Original Blender models for The Steward's warm, painted animation direction.
The runtime cast is a fox traveler, rabbit gardener, bespectacled badger and
otter in oversized coats, scarves, boots and satchels. These are original
characters, not models of existing film characters.
`art/storybook/` contains the editable Blender 5.1 sources. Its `.gdignore`
keeps Blender out of Godot's runtime import dependency chain. This directory
contains portable GLBs and the extracted grass mesh consumed by Terrain3D.
## Rebuild
From the project root, with Blender and Godot 4.7 installed:
```sh
"$BLENDER_BIN" --background --python tools/art/build_storybook_assets.py
"$GODOT_BIN" --headless --editor --path "$PWD" --import
"$GODOT_BIN" --headless --path "$PWD" --script tools/import_storybook_grass.gd
```
On macOS the binaries used for this pass were
`/Applications/Blender.app/Contents/MacOS/Blender` and
`/Applications/Godot.app/Contents/MacOS/Godot`. Headless CI should use the
isolated profile configured by `tools/quality.sh`.
## Mesh and material contract
- The seven-blade meadow tuft has **35 triangles**, one surface, rooted Y=0,
and UV.y=0 at each root / 1 at each tip. Wind and actor bending run on the
GPU. The material is opaque and double-sided, with no alpha cards, texture
lookups, per-blade nodes, shadow casting, or outline pass.
- Terrain3D owns the existing nine camera-local emitter cells: 10,404 tuft
slots on High, 5,184 on Balanced, hidden and stopped on Low. Terrain masks
and distance fading further reduce visible grass. Four scene-derived path
clearings collapse blades around the walking strips. Changing density does
not alter the terrain, navigation, resource state or collision.
- Each animal has eight mesh surfaces, **7,884–10,050 triangles** total, and
vertex colors. `mesh_budget.json` is emitted by the Blender generator.
Vertex alpha is a clothing tint mask; the cel shader never writes ALPHA.
- `AnimalAppearance` derives NPC species from stable ID modulo four; the
player is a fox. It shares imported meshes, creates an isolated material
per actor and tints clothing from the existing profession definition.
`Body`, `Head`, `Hair`, four limbs and `Tail` preserve the visual controllers'
animation slots. Shoulder/hip pivots support the existing velocity-driven
gait. Ears, face details, boots and scarves are joined into those surfaces.
- Inventory sacks, carried logs, profession tools, task glyphs, trust reactions,
death and player sword cues remain controlled by their existing state paths.
Animal species here is appearance only: no animal simulation classification,
RNG calls, saved fields, migration or gameplay mechanics are introduced.
## Review captures
```sh
"$GODOT_BIN" --path "$PWD" --script tools/capture_storybook.gd -- --cast
"$GODOT_BIN" --path "$PWD" --script tools/capture_storybook.gd
```
These require a real renderer. The first writes `storybook_cast.png`; the
second captures the running main scene at a controlled daytime presentation,
then writes gameplay/valley PNGs and a small profile sample to `docs/baselines/`.
It pauses simulation only in the capture process and does not save anything.
The gameplay camera defaults remain unchanged. The matched environment camera
can also be captured with `tools/capture_jajce_lookdev.gd -- --hide-debug-labels
--output=res://docs/baselines/storybook_after.png`.
Profile samples are local wall-frame latency at a static camera with six NPC
visuals, including vsync. They are not isolated GPU timings or weak-PC proof.
The full macOS quality gate passed on Godot 4.7, including all scenarios,
Compatibility profile checks and 180 GUT tests / 2,258 assertions. Native
Metal captures were visually reviewed. The character shader also rendered on
Compatibility, whose color/tonemapping differs substantially from Metal; the
approved color reference is `storybook_cast.png` (Metal).
Native Metal shutdown reports one `ParticlesShaderRD` / material Shader RID
retained at exit. The identical diagnostic was reproduced by importing and
running untouched commit `6aae41d` in a separate temporary directory. The
existing headless gate allowlist was not changed. Import/startup diagnostics
and the baseline comparison are in ignored `logs/quality/storybook-*.log`.
Shader interfaces follow the official [Godot 4.7 spatial shader contract](https://docs.godotengine.org/en/4.7/tutorials/shaders/shader_reference/spatial_shader.html).
The exporter uses Blender's [glTF export API](https://docs.blender.org/api/main/bpy.ops.export_scene.html).
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://buehm0sth6etc"
path="res://.godot/imported/badger.glb-4a20c61fae6d6e8d68c151cba8d056d5.scn"
[deps]
source_file="res://assets/storybook/badger.glb"
dest_files=["res://.godot/imported/badger.glb-4a20c61fae6d6e8d68c151cba8d056d5.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
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
gltf/texture_map_mode=1
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://cbne2ol0h0uku"
path="res://.godot/imported/fox.glb-972d78ae35ada569a710c17f00c10a3e.scn"
[deps]
source_file="res://assets/storybook/fox.glb"
dest_files=["res://.godot/imported/fox.glb-972d78ae35ada569a710c17f00c10a3e.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
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
gltf/texture_map_mode=1
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bd6mrof50g4wn"
path="res://.godot/imported/meadow_grass.glb-0321cd60906212db637f52f1f1493ea1.scn"
[deps]
source_file="res://assets/storybook/meadow_grass.glb"
dest_files=["res://.godot/imported/meadow_grass.glb-0321cd60906212db637f52f1f1493ea1.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
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
gltf/texture_map_mode=1
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
{
"fox": {
"triangles": 7884,
"surfaces": 8,
"bytes": 271636
},
"rabbit": {
"triangles": 9060,
"surfaces": 8,
"bytes": 314636
},
"badger": {
"triangles": 10050,
"surfaces": 8,
"bytes": 345484
},
"otter": {
"triangles": 9088,
"surfaces": 8,
"bytes": 314108
},
"meadow_grass": {
"triangles": 35,
"surfaces": 1,
"bytes": 3648
}
}
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://6f01q2ru2sq4"
path="res://.godot/imported/otter.glb-92d54495ed52927d77a0a5213ac92330.scn"
[deps]
source_file="res://assets/storybook/otter.glb"
dest_files=["res://.godot/imported/otter.glb-92d54495ed52927d77a0a5213ac92330.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
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
gltf/texture_map_mode=1
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bywpllx00b6aq"
path="res://.godot/imported/rabbit.glb-ed7bf640394b68863b9dc6437e0f1808.scn"
[deps]
source_file="res://assets/storybook/rabbit.glb"
dest_files=["res://.godot/imported/rabbit.glb-ed7bf640394b68863b9dc6437e0f1808.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
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
gltf/texture_map_mode=1
+1
View File
@@ -38,6 +38,7 @@ when extending the game.
| Add caravans, routes, scheduled work, or regional saves | [Regional simulation and persistence](FEATURE_REGIONAL_SIMULATION_AND_PERSISTENCE.md) | `simulation/regional/`, `simulation/state/*StateRecord.gd` | | Add caravans, routes, scheduled work, or regional saves | [Regional simulation and persistence](FEATURE_REGIONAL_SIMULATION_AND_PERSISTENCE.md) | `simulation/regional/`, `simulation/state/*StateRecord.gd` |
| Add an enemy or animal using an existing behavior | [Entity families](FEATURE_ENTITIES_COMBAT_ANIMALS.md) | `CombatantFactory.gd`, `AnimalFactory.gd`, shared visual roots | | Add an enemy or animal using an existing behavior | [Entity families](FEATURE_ENTITIES_COMBAT_ANIMALS.md) | `CombatantFactory.gd`, `AnimalFactory.gd`, shared visual roots |
| Change rendering or support weaker PCs | [Presentation and performance](FEATURE_PRESENTATION_PERFORMANCE.md) | `world/jajce/jajce_world.gd`, `world/presentation/` | | Change rendering or support weaker PCs | [Presentation and performance](FEATURE_PRESENTATION_PERFORMANCE.md) | `world/jajce/jajce_world.gd`, `world/presentation/` |
| Author humanoid animal characters or combine clothing and body shapes | [Modular character kit](../assets/characters/README.md) | `tools/CharacterWorkshop.tscn`, `CharacterAppearanceProfile.gd`, `tools/art/build_character_kit.py` |
| Change a saved field or migration | [Save schema and migrations](FEATURE_SAVE_SCHEMA_AND_MIGRATIONS.md) | `SimulationStateRecord.gd`, nested state records, `SaveSlotStore.gd` | | Change a saved field or migration | [Save schema and migrations](FEATURE_SAVE_SCHEMA_AND_MIGRATIONS.md) | `SimulationStateRecord.gd`, nested state records, `SaveSlotStore.gd` |
| Add a regression, benchmark, or run the gate | [Testing and benchmarks](FEATURE_TESTING_AND_BENCHMARKS.md) | `tests/`, `tools/`, `docs/benchmarks/` | | Add a regression, benchmark, or run the gate | [Testing and benchmarks](FEATURE_TESTING_AND_BENCHMARKS.md) | `tests/`, `tools/`, `docs/benchmarks/` |
+159 -4
View File
@@ -38,19 +38,92 @@ spawn is a next integration.
`JajceWorld` owns reversible High, Balanced, and Low presentation profiles with `JajceWorld` owns reversible High, Balanced, and Low presentation profiles with
instance-local mutable environment/grass resources and viewport-scale ownership. instance-local mutable environment/grass resources and viewport-scale ownership.
| Profile | 3D scale | Shadow distance | Volumetric/glow | Grass | | Profile | 3D scale / MSAA | Shadow distance | Volumetric/glow | Grass |
| --- | ---: | ---: | --- | --- | | --- | ---: | ---: | --- | --- |
| High | authored | authored (currently 180 m) | authored when renderer supports it | authored density/interactors | | High | authored / authored 4× | authored (currently 180 m) | authored when renderer supports it | authored density/interactors |
| Balanced (default) | 0.85 cap | 120 m cap | authored ordinary lookdev; unsupported volumetric disabled | reduced spacing/interactors/update rate | | Balanced (default) | 0.85 cap / 2× cap | 120 m cap | authored ordinary lookdev; unsupported volumetric disabled | reduced spacing/interactors/update rate |
| Low | 0.70 cap | 80 m, orthogonal | volumetric/glow/adjustment disabled; ordinary fog retained | hidden/stopped, emitters off, shader interactors zero | | Low | 0.70 cap / off | 80 m, orthogonal | volumetric/glow/adjustment disabled; ordinary fog retained | hidden/stopped, emitters off, shader interactors zero |
UI remains at native resolution. High/Low switches restore captured authored UI remains at native resolution. High/Low switches restore captured authored
values; viewport scale restoration is guarded by the current quality owner so a values; viewport scale restoration is guarded by the current quality owner so a
second world cannot clobber the active owner. Compatibility/mobile feature second world cannot clobber the active owner. Compatibility/mobile feature
selection never forces unsupported volumetric fog on. selection never forces unsupported volumetric fog on.
## Grass trails and bush contact
`GrassInteractionController` samples the existing `grass_interactors` group:
player, NPCs, hostile creatures and animals. The player keeps one slot; other
visible actors are ranked by camera distance within the loaded actor container.
High updates at 0.08 seconds with eight actors; Balanced uses 0.12 seconds and
four. Low stops updates and immediately releases both grass and bushes.
`FoliageTrailMap` maintains a disposable 128×128 RGBA float texture covering a
64 m square around the camera. Swept footprints part grass to either side and
lower its tips; the field recovers over eight seconds after contact. Sparse
history is clipped to the moving window. The 256 KiB texture is reused with
[`ImageTexture.update`](https://docs.godotengine.org/en/stable/classes/class_imagetexture.html#class-imagetexture-method-update),
at most 3.125 MiB/s of texture data at the High update rate. The vertex shader
adds one field lookup per grass vertex; there are no blade collision bodies,
per-blade scripts, render targets or added grass draw calls.
`StylizedBerryPatch` shares the field and contact positions between its leaf
and berry MultiMeshes. Both bend in world space around the planted bush root,
including under rotated/scaled parents, and spring back faster than grass.
The five village berry placeholders now use this same amount-aware presentation
as the existing riverbank bush. Contact does not change berries, resource
amounts, navigation, targets or collision geometry.
The controller clears history after `SimulationManager.state_restored`, on
quality changes and on world disposal. Missing/recreated actors, teleports over
3 m between samples and update gaps over 0.5 seconds start a new footprint rather
than a connecting trail. Actor foot height gates deformation so bridge traffic
does not flatten grass below. Trails are visual only and never enter saves.
The exported actor, primary actor, foliage and simulation paths can be rebound
when placing the controller in another loaded scene container.
`tests/foliage_interaction_test.gd` checks sweep direction, recovery, teleport
rejection, camera movement, world isolation, restore/quality reset, the primary
actor slot and shared leaf/fruit bindings. Run `tools/capture_foliage.gd` with
the native renderer for before/contact/trail/recovery images and controller
timing in `docs/baselines/foliage_*`. That capture freezes simulation and wind, and moves
the player presentation through the actual meadow, checking the state checksum
is unchanged. CPU update timing is not a GPU or weak-PC frame-rate guarantee.
Native Metal and Compatibility both render the effect; existing particle-shader
shutdown warnings (and four Compatibility texture leaks) also reproduce in the
untouched pre-art baseline. The headless quality gate retains its exact allowlist.
## Runtime hot paths already bounded ## Runtime hot paths already bounded
The woodland storybook art pass adds portable Blender animal meshes and an
opaque 35-triangle, seven-blade grass tuft. The existing camera-local grass
field and quality controls own its instance budget; four loaded path strips
provide disposable grass-clearance data after material isolation. Animal
appearance is derived from stable NPC IDs, shares mesh resources, and retains
profession tint, velocity-driven motion, inventory cues and death through the
existing presentation controllers. No saved state or navigation changes.
Characters and buildings use soft cel bands; tree crowns retain the shared
breeze with painted color patches. A painted cloud panorama, cooler
ambient fill and warm sunlight follow the simulation day/night clock. The
cloud shader adds no volumetric pass, outline pass or screen-space postprocess.
See [the asset contract and rebuild commands](../assets/storybook/README.md).
The current humanoid cast uses the [modular woodland character kit](../assets/characters/README.md):
31 Blender parts, five animal heads, six authored examples and independently
configurable proportions, outfits and accessories. `CharacterAppearanceProfile`
resources are cosmetic. Meshes are shared; `Stout` morph weights and palette
materials are per actor. The reference traveler has 20,070 base triangles across
10 opaque surfaces; Godot imports LODs. The standalone
`tools/CharacterWorkshop.tscn` previews and saves profiles. Existing motion,
profession/inventory/death cues, and shader grass interaction remain owned by
their existing controllers. The appearance scenario verifies imported parts,
independent shape weights, stable identity, extreme proportions and cue clearance.
`docs/baselines/storybook_*.png` records the native Metal review. The accompanying
`storybook_render_metrics.json` records a bounded local 1600×900 static-camera
sample on Apple M1 Max with six NPCs and High/Balanced/Low settings. Wall-frame
latency includes vsync and does not close the weak-PC evidence gap below.
- Player interaction and villager inspection probes are throttled/event-driven; - Player interaction and villager inspection probes are throttled/event-driven;
they no longer rebuild all candidate data every rendered frame. they no longer rebuild all candidate data every rendered frame.
- `LoadedResourceSpatialIndex` bounds finite-resource discovery. - `LoadedResourceSpatialIndex` bounds finite-resource discovery.
@@ -99,3 +172,85 @@ ledger, not brittle cross-machine CI thresholds.
See [the benchmark ledger](benchmarks/README.md), [regional contract](REGIONAL_SIMULATION.md), See [the benchmark ledger](benchmarks/README.md), [regional contract](REGIONAL_SIMULATION.md),
and [the build-in-public visual plan](BUILD_IN_PUBLIC_PLAN.md). and [the build-in-public visual plan](BUILD_IN_PUBLIC_PLAN.md).
## Painterly environment pass — September 2026
The reference study highlighted flat cloud bands, primitive tree silhouettes,
sparse ground cover, opaque green water, and a sun arc offset from daytime
brightness. The current pass addresses those through shared presentation assets:
- The WorldEnvironment uses cool blue ambient fill, warm sunlight, restrained
glow and ordinary aerial fog. The solar arc now peaks at noon and the warm
transitions overlap dawn/dusk. An original painted cloud panorama supplies
layered cumulus shapes; a static sky shader remaps the artwork, blends its
seam/horizon, and tints it from the simulation clock. It adds no TIME-driven
cubemap updates or volumetric cloud pass. Generation provenance and the exact
prompt are in [the asset guide](../assets/painterly/README.md).
- Original Blender broadleaf crowns/trunks and layered conifers replace the
primitive foliage. Seventeen decorative edge trees form a woodland backdrop.
The four village tree placeholders now use `HarvestableTreePresentation`,
retaining their existing IDs, amounts, interaction points and resource count.
Full/low/depleted state and restored crowns still derive from real amounts.
- `PainterlyMeadow` creates at most 1,680 decorative flower clumps in four
MultiMesh batches, with cream daisies, pink cosmos, buttercups and lavender.
Local seeded drifts follow terrain and exclude houses, paths, work sites and
resource approach points. High/Balanced/Low expose at most 1,680/1,092/536
clumps, with shorter distance fading in cheaper profiles. Flowers share the
prevailing breeze; the existing grass and berry contact field remains intact.
Explicit white instance colors preserve the painted GLB palette when custom
instance data is enabled in Compatibility; native review caught and verified
that renderer-specific regression.
Flowers have no collision, harvesting, actor-contact deformation or saved
state. See [mesh sources and budgets](../assets/meadow/README.md).
- The blue/turquoise river follows actual centerline terrain heights, keeps
level cross-sections, and clips dry edges at the shoreline. The pool and its
outlet meet without overlapping coplanar transparent sheets. Broken foam,
subtle flowing strokes and painted reflection patches replace the hard ring
and featureless surface. These reflections are stylized procedural patches
plus ordinary sky specular response, not exact reflected scene geometry,
screen-space reflections, refraction or depth sampling. Water obeys scene
lighting at night and adds no per-frame scripts.
- MSAA follows the existing viewport ownership rules: High preserves authored
4×, Balanced caps at 2×, and Low disables it. Closing an inactive world cannot
overwrite the active world's antialiasing or an external viewport override.
The editable Blender sources live under `art/painterly/` and `art/meadow/`,
protected by `.gdignore`; runtime consumes portable GLBs. The broadleaf is
5,350 triangles / five surfaces and the conifer is 1,580 / two. All flower
clumps together are capped at 362,880 triangles / four surfaces before
quality and distance reduction. No scene collision, navigation bake, terrain
heightmap, simulation rule or save schema was changed.
`tools/capture_painterly_environment.gd -- --label=after` captures the running
main scene with paused simulation, fixed daytime presentation and hidden HUD,
including the normal gameplay camera, valley, lower village view and night.
The valley/near-village `painterly_before_*` and `painterly_after_*`
images are matched cameras; `after_gameplay` fixes the normal rig at its default
yaw/zoom with mouse input disabled, and `after_vista` is an additional review angle.
The tool also records static-camera profile samples in
`painterly_after_metrics.json`; wall-frame latency includes vsync and is not
isolated GPU time or weak-PC proof. Runtime camera defaults are unchanged.
Remaining visual work is primarily authored terrain/shoreline detail and the
blockout-like building/fortress forms. This pass improves atmosphere and
foliage without treating those assets as finished film-quality environments.
Validation for this pass: the full macOS Godot 4.7 gate passed, including all
headless scenarios, the Compatibility quality scenario, and 182 GUT tests /
2,279 assertions. Main-scene headless startup passed the gate's existing exact
diagnostic allowlist. Native Metal daytime/gameplay/valley/night images were
reviewed at 1600×900, with a separate native Compatibility capture confirming
colored petals after the instance-buffer fix. The presentation-quality scenario
also passed with native Compatibility, including white instance-color readback
(the headless dummy renderer cannot provide that readback).
The static M1 Max sample reports approximately 16.7 ms
median wall-frame time in all three profiles; see its JSON for p95, draw calls
and primitives. This includes vsync and does not establish GPU headroom.
Native renderer shutdown still reports the particle shader/material RID
retention previously documented in the storybook/foliage pass; the headless
allowlist was not expanded. Compatibility has different color/tonemapping and
its existing particle/texture shutdown diagnostics. Metal is the color
reference for the saved art-review images.
The native multi-world quality scenario additionally logged a transient macOS
OpenGL cubemap-unloadable diagnostic; the final main-scene captures did not.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 KiB

@@ -0,0 +1,13 @@
{
"active_trail_texels": 145,
"controller_update_p50_ms": 0.307,
"controller_update_p95_ms": 0.338,
"draw_calls": 582.0,
"note": "CPU timing includes scanning, field painting and material uploads; not isolated GPU frame time.",
"profile": "High",
"renderer": "metal",
"resolution": "1600x900",
"simulation_checksum_unchanged": true,
"trail_texture_bytes": 262144,
"workload": "Actual Jajce meadow; 8 nearest actors, player presentation follows an 11 m sweep"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,53 @@
{
"device": "Apple M1 Max (Apple7)",
"renderer": "metal",
"resolution": "1600x900",
"samples": [
{
"active_npcs": 6,
"draw_calls": 825.0,
"meadow": {
"flower_clump_count": 1680,
"multimesh_batch_count": 4,
"visible_flower_clump_count": 1680
},
"msaa_enum": 2,
"primitives": 1070038.0,
"profile": "high",
"render_scale": 1.0,
"wall_frame_p50_ms": 16.649,
"wall_frame_p95_ms": 17.327
},
{
"active_npcs": 6,
"draw_calls": 710.0,
"meadow": {
"flower_clump_count": 1680,
"multimesh_batch_count": 4,
"visible_flower_clump_count": 1092
},
"msaa_enum": 1,
"primitives": 810830.0,
"profile": "balanced",
"render_scale": 0.850000023841858,
"wall_frame_p50_ms": 16.672,
"wall_frame_p95_ms": 17.06
},
{
"active_npcs": 6,
"draw_calls": 657.0,
"meadow": {
"flower_clump_count": 1680,
"multimesh_batch_count": 4,
"visible_flower_clump_count": 536
},
"msaa_enum": 0,
"primitives": 645010.0,
"profile": "low",
"render_scale": 0.699999988079071,
"wall_frame_p50_ms": 16.676,
"wall_frame_p95_ms": 17.069
}
],
"workload": "Paused main scene, static valley camera; wall time includes vsync"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

@@ -0,0 +1,53 @@
{
"device": "Apple M1 Max",
"renderer": "opengl3",
"resolution": "1600x900",
"samples": [
{
"active_npcs": 6,
"draw_calls": 1304.0,
"meadow": {
"flower_clump_count": 1680,
"multimesh_batch_count": 4,
"visible_flower_clump_count": 1680
},
"msaa_enum": 2,
"primitives": 1070038.0,
"profile": "high",
"render_scale": 1.0,
"wall_frame_p50_ms": 22.166,
"wall_frame_p95_ms": 23.467
},
{
"active_npcs": 6,
"draw_calls": 1166.0,
"meadow": {
"flower_clump_count": 1680,
"multimesh_batch_count": 4,
"visible_flower_clump_count": 1092
},
"msaa_enum": 1,
"primitives": 810758.0,
"profile": "balanced",
"render_scale": 0.850000023841858,
"wall_frame_p50_ms": 17.903,
"wall_frame_p95_ms": 26.363
},
{
"active_npcs": 6,
"draw_calls": 1084.0,
"meadow": {
"flower_clump_count": 1680,
"multimesh_batch_count": 4,
"visible_flower_clump_count": 536
},
"msaa_enum": 0,
"primitives": 645010.0,
"profile": "low",
"render_scale": 0.699999988079071,
"wall_frame_p50_ms": 13.533,
"wall_frame_p95_ms": 16.623
}
],
"workload": "Paused main scene, static valley camera; wall time includes vsync"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

@@ -0,0 +1,34 @@
{
"renderer": "metal",
"resolution": "1600x900",
"samples": [
{
"active_npcs": 6,
"draw_calls": 702.0,
"frame_wall_p50_ms": 16.646,
"frame_wall_p95_ms": 16.903,
"objects": 1114.0,
"primitives": 684656.0,
"profile": "high"
},
{
"active_npcs": 6,
"draw_calls": 613.0,
"frame_wall_p50_ms": 16.665,
"frame_wall_p95_ms": 16.93,
"objects": 1031.0,
"primitives": 486192.0,
"profile": "balanced"
},
{
"active_npcs": 6,
"draw_calls": 592.0,
"frame_wall_p50_ms": 16.659,
"frame_wall_p95_ms": 16.989,
"objects": 999.0,
"primitives": 377596.0,
"profile": "low"
}
],
"workload": "Paused static valley; wall-frame latency includes vsync, not isolated GPU time"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+8 -108
View File
@@ -1,72 +1,7 @@
[gd_scene load_steps=17 format=3] [gd_scene load_steps=6 format=3]
[ext_resource type="Script" path="res://player/player_visual.gd" id="1_visual"] [ext_resource type="Script" path="res://player/player_visual.gd" id="1_visual"]
[sub_resource type="StandardMaterial3D" id="Material_tunic"]
albedo_color = Color(0.18, 0.46, 0.48, 1)
roughness = 0.84
[sub_resource type="CylinderMesh" id="Mesh_body"]
material = SubResource("Material_tunic")
top_radius = 0.28
bottom_radius = 0.42
height = 0.9
[sub_resource type="StandardMaterial3D" id="Material_skin"]
albedo_color = Color(0.84, 0.61, 0.39, 1)
roughness = 0.88
[sub_resource type="SphereMesh" id="Mesh_head"]
material = SubResource("Material_skin")
radius = 0.29
height = 0.56
[sub_resource type="StandardMaterial3D" id="Material_hair"]
albedo_color = Color(0.2, 0.105, 0.05, 1)
roughness = 0.92
[sub_resource type="CylinderMesh" id="Mesh_hair"]
material = SubResource("Material_hair")
top_radius = 0.24
bottom_radius = 0.29
height = 0.18
[sub_resource type="CylinderMesh" id="Mesh_arm"]
material = SubResource("Material_tunic")
top_radius = 0.095
bottom_radius = 0.11
height = 0.58
[sub_resource type="SphereMesh" id="Mesh_hand"]
material = SubResource("Material_skin")
radius = 0.12
height = 0.22
[sub_resource type="StandardMaterial3D" id="Material_pants"]
albedo_color = Color(0.15, 0.21, 0.24, 1)
roughness = 0.9
[sub_resource type="CapsuleMesh" id="Mesh_leg"]
material = SubResource("Material_pants")
radius = 0.115
height = 0.62
[sub_resource type="StandardMaterial3D" id="Material_boot"]
albedo_color = Color(0.13, 0.075, 0.04, 1)
roughness = 0.95
[sub_resource type="BoxMesh" id="Mesh_foot"]
material = SubResource("Material_boot")
size = Vector3(0.23, 0.15, 0.36)
[sub_resource type="StandardMaterial3D" id="Material_accent"]
albedo_color = Color(0.86, 0.4, 0.18, 1)
roughness = 0.82
[sub_resource type="BoxMesh" id="Mesh_scarf"]
material = SubResource("Material_accent")
size = Vector3(0.48, 0.1, 0.38)
[sub_resource type="StandardMaterial3D" id="Material_sword"] [sub_resource type="StandardMaterial3D" id="Material_sword"]
albedo_color = Color(0.72, 0.74, 0.78, 1) albedo_color = Color(0.72, 0.74, 0.78, 1)
roughness = 0.32 roughness = 0.32
@@ -85,67 +20,29 @@ top_radius = 0.045
bottom_radius = 0.045 bottom_radius = 0.045
height = 0.16 height = 0.16
[sub_resource type="SphereMesh" id="Mesh_satchel"]
material = SubResource("Material_accent")
radius = 0.22
height = 0.34
[node name="PlayerVisual" type="Node3D"] [node name="PlayerVisual" type="Node3D"]
script = ExtResource("1_visual") script = ExtResource("1_visual")
[node name="Body" type="MeshInstance3D" parent="."] [node name="Body" type="MeshInstance3D" parent="."]
position = Vector3(0, 1.05, 0) position = Vector3(0, 1.05, 0)
mesh = SubResource("Mesh_body")
[node name="Scarf" type="MeshInstance3D" parent="."]
position = Vector3(0, 1.42, 0.02)
mesh = SubResource("Mesh_scarf")
[node name="Head" type="MeshInstance3D" parent="."] [node name="Head" type="MeshInstance3D" parent="."]
position = Vector3(0, 1.68, 0) position = Vector3(0, 1.68, 0)
mesh = SubResource("Mesh_head")
[node name="Hair" type="MeshInstance3D" parent="."] [node name="Hair" type="MeshInstance3D" parent="."]
position = Vector3(0, 1.9, -0.015) position = Vector3(0, 1.9, -0.015)
mesh = SubResource("Mesh_hair")
[node name="ArmLeft" type="MeshInstance3D" parent="."] [node name="ArmLeft" type="MeshInstance3D" parent="."]
position = Vector3(-0.39, 1.08, 0) position = Vector3(-0.39, 1.3, 0)
rotation = Vector3(0, 0, -0.139626)
mesh = SubResource("Mesh_arm")
[node name="Hand" type="MeshInstance3D" parent="ArmLeft"]
position = Vector3(0, -0.35, 0)
mesh = SubResource("Mesh_hand")
[node name="ArmRight" type="MeshInstance3D" parent="."] [node name="ArmRight" type="MeshInstance3D" parent="."]
position = Vector3(0.39, 1.08, 0) position = Vector3(0.39, 1.3, 0)
rotation = Vector3(0, 0, 0.139626)
mesh = SubResource("Mesh_arm")
[node name="Hand" type="MeshInstance3D" parent="ArmRight"]
position = Vector3(0, -0.35, 0)
mesh = SubResource("Mesh_hand")
[node name="LegLeft" type="MeshInstance3D" parent="."] [node name="LegLeft" type="MeshInstance3D" parent="."]
position = Vector3(-0.18, 0.43, 0) position = Vector3(-0.18, 0.61, 0)
mesh = SubResource("Mesh_leg")
[node name="Foot" type="MeshInstance3D" parent="LegLeft"]
position = Vector3(0, -0.28, 0.09)
mesh = SubResource("Mesh_foot")
[node name="LegRight" type="MeshInstance3D" parent="."] [node name="LegRight" type="MeshInstance3D" parent="."]
position = Vector3(0.18, 0.43, 0) position = Vector3(0.18, 0.61, 0)
mesh = SubResource("Mesh_leg")
[node name="Foot" type="MeshInstance3D" parent="LegRight"]
position = Vector3(0, -0.28, 0.09)
mesh = SubResource("Mesh_foot")
[node name="Satchel" type="MeshInstance3D" parent="."]
position = Vector3(-0.43, 0.87, -0.04)
mesh = SubResource("Mesh_satchel")
[node name="Sword" type="Node3D" parent="."] [node name="Sword" type="Node3D" parent="."]
position = Vector3(0.52, 1.22, 0.12) position = Vector3(0.52, 1.22, 0.12)
@@ -158,3 +55,6 @@ mesh = SubResource("Mesh_blade")
[node name="Grip" type="MeshInstance3D" parent="Sword"] [node name="Grip" type="MeshInstance3D" parent="Sword"]
position = Vector3(0, -0.05, 0) position = Vector3(0, -0.05, 0)
mesh = SubResource("Mesh_grip") mesh = SubResource("Mesh_grip")
[node name="Tail" type="MeshInstance3D" parent="."]
position = Vector3(0, 0.8, -0.22)
+48 -34
View File
@@ -4,11 +4,12 @@ signal arrived_at_target(sim_id: int)
signal navigation_failed(sim_id: int) signal navigation_failed(sim_id: int)
signal position_changed(sim_id: int, active_position: Vector3) signal position_changed(sim_id: int, active_position: Vector3)
const TRUST_REACTION_BASE_POSITION := Vector3(0.0, 2.72, 0.0) const TRUST_REACTION_BASE_POSITION := Vector3(0.0, 3.25, 0.0)
const TRUST_REACTION_START_SCALE := Vector3(0.24, 0.24, 0.24) const TRUST_REACTION_START_SCALE := Vector3(0.24, 0.24, 0.24)
const CONCERN_BASE_POSITION := Vector3(-0.4, 2.72, 0.0) const CONCERN_BASE_POSITION := Vector3(-0.4, 3.25, 0.0)
@export var debug_logs := false @export var debug_logs := false
@export var appearance: CharacterAppearanceProfile
@export var move_speed := 3.0 @export var move_speed := 3.0
@export var rotation_speed := 8.0 @export var rotation_speed := 8.0
@@ -58,6 +59,9 @@ var walk_phase := 0.0
var glyph_phase := 0.0 var glyph_phase := 0.0
var concern_phase := 0.0 var concern_phase := 0.0
var trust_reaction_tween: Tween var trust_reaction_tween: Tween
var rest_heights := Vector3(1.05, 1.68, 1.9)
var arm_swing := 1.0
var cue_lift := 0.0
func debug_log(message: String) -> void: func debug_log(message: String) -> void:
@@ -76,11 +80,13 @@ func _process(delta: float) -> void:
return return
glyph_phase = fmod(glyph_phase + delta * 2.2, TAU) glyph_phase = fmod(glyph_phase + delta * 2.2, TAU)
if task_glyph_root.visible: if task_glyph_root.visible:
task_glyph_root.position.y = 2.35 + sin(glyph_phase) * 0.045 task_glyph_root.position.y = 2.9 + cue_lift + sin(glyph_phase) * 0.045
task_glyph_root.rotation.y = glyph_phase * 0.35 task_glyph_root.rotation.y = glyph_phase * 0.35
if opportunity_concern_root.visible: if opportunity_concern_root.visible:
concern_phase = fmod(concern_phase + delta * 1.65, TAU) concern_phase = fmod(concern_phase + delta * 1.65, TAU)
opportunity_concern_root.position.y = (CONCERN_BASE_POSITION.y + sin(concern_phase) * 0.045) opportunity_concern_root.position.y = (
CONCERN_BASE_POSITION.y + cue_lift + sin(concern_phase) * 0.045
)
opportunity_concern_root.rotation.y = sin(concern_phase * 0.7) * 0.16 opportunity_concern_root.rotation.y = sin(concern_phase * 0.7) * 0.16
var concern_scale := 0.96 + sin(concern_phase) * 0.04 var concern_scale := 0.96 + sin(concern_phase) * 0.04
opportunity_concern_root.scale = Vector3.ONE * concern_scale opportunity_concern_root.scale = Vector3.ONE * concern_scale
@@ -92,20 +98,24 @@ func _process(delta: float) -> void:
var swing := sin(walk_phase) var swing := sin(walk_phase)
left_leg.rotation_degrees.x = lerpf(left_leg.rotation_degrees.x, swing * 28.0, blend) left_leg.rotation_degrees.x = lerpf(left_leg.rotation_degrees.x, swing * 28.0, blend)
right_leg.rotation_degrees.x = lerpf(right_leg.rotation_degrees.x, -swing * 28.0, blend) right_leg.rotation_degrees.x = lerpf(right_leg.rotation_degrees.x, -swing * 28.0, blend)
left_arm.rotation_degrees.x = lerpf(left_arm.rotation_degrees.x, -swing * 20.0, blend) left_arm.rotation_degrees.x = lerpf(
right_arm.rotation_degrees.x = lerpf(right_arm.rotation_degrees.x, swing * 20.0, blend) left_arm.rotation_degrees.x, -swing * 20.0 * arm_swing, blend
)
right_arm.rotation_degrees.x = lerpf(
right_arm.rotation_degrees.x, swing * 20.0 * arm_swing, blend
)
var step_bob := absf(sin(walk_phase * 2.0)) * 0.035 var step_bob := absf(sin(walk_phase * 2.0)) * 0.035
body_mesh.position.y = lerpf(body_mesh.position.y, 1.05 + step_bob, blend) body_mesh.position.y = lerpf(body_mesh.position.y, rest_heights.x + step_bob, blend)
head_mesh.position.y = lerpf(head_mesh.position.y, 1.68 + step_bob, blend) head_mesh.position.y = lerpf(head_mesh.position.y, rest_heights.y + step_bob, blend)
hair_mesh.position.y = lerpf(hair_mesh.position.y, 1.9 + step_bob, blend) hair_mesh.position.y = lerpf(hair_mesh.position.y, rest_heights.z + step_bob, blend)
else: else:
left_leg.rotation_degrees.x = lerpf(left_leg.rotation_degrees.x, 0.0, blend) left_leg.rotation_degrees.x = lerpf(left_leg.rotation_degrees.x, 0.0, blend)
right_leg.rotation_degrees.x = lerpf(right_leg.rotation_degrees.x, 0.0, blend) right_leg.rotation_degrees.x = lerpf(right_leg.rotation_degrees.x, 0.0, blend)
left_arm.rotation_degrees.x = lerpf(left_arm.rotation_degrees.x, 0.0, blend) left_arm.rotation_degrees.x = lerpf(left_arm.rotation_degrees.x, 0.0, blend)
right_arm.rotation_degrees.x = lerpf(right_arm.rotation_degrees.x, 0.0, blend) right_arm.rotation_degrees.x = lerpf(right_arm.rotation_degrees.x, 0.0, blend)
body_mesh.position.y = lerpf(body_mesh.position.y, 1.05, blend) body_mesh.position.y = lerpf(body_mesh.position.y, rest_heights.x, blend)
head_mesh.position.y = lerpf(head_mesh.position.y, 1.68, blend) head_mesh.position.y = lerpf(head_mesh.position.y, rest_heights.y, blend)
hair_mesh.position.y = lerpf(hair_mesh.position.y, 1.9, blend) hair_mesh.position.y = lerpf(hair_mesh.position.y, rest_heights.z, blend)
func setup_from_sim(npc: SimNPC) -> void: func setup_from_sim(npc: SimNPC) -> void:
@@ -123,16 +133,12 @@ func setup_from_sim(npc: SimNPC) -> void:
func _apply_personal_details() -> void: func _apply_personal_details() -> void:
var hair_colors := [ AnimalAppearance.apply_to(self, sim_id, true, appearance)
Color(0.16, 0.09, 0.055, 1.0), rest_heights = AnimalAppearance.rest_heights(self, true)
Color(0.29, 0.16, 0.075, 1.0), arm_swing = get_meta("arm_swing", 1.0)
Color(0.09, 0.065, 0.05, 1.0), cue_lift = maxf(0.0, float(get_meta("character_top", 2.6)) - 2.6)
Color(0.42, 0.27, 0.12, 1.0), task_glyph_root.position.y = 2.9 + cue_lift
] profession_label.position.y = 2.8 + cue_lift
var hair_material := StandardMaterial3D.new()
hair_material.albedo_color = hair_colors[maxi(sim_id, 0) % hair_colors.size()]
hair_material.roughness = 0.9
hair_mesh.material_override = hair_material
func _apply_profession_presentation() -> void: func _apply_profession_presentation() -> void:
@@ -142,23 +148,31 @@ func _apply_profession_presentation() -> void:
profession_prop.visible = false profession_prop.visible = false
return return
var body_material := StandardMaterial3D.new() AnimalAppearance.set_profession_color(self, definition.visual_color)
body_material.albedo_color = definition.visual_color
body_material.roughness = 0.82
body_mesh.material_override = body_material
left_arm.material_override = body_material
right_arm.material_override = body_material
var prop_material := StandardMaterial3D.new() var prop_material := StandardMaterial3D.new()
prop_material.albedo_color = definition.prop_color prop_material.albedo_color = definition.prop_color
prop_material.roughness = 0.78 prop_material.roughness = 0.78
profession_prop.material_override = prop_material profession_prop.material_override = prop_material
profession_prop.mesh = _create_profession_prop_mesh() profession_prop.mesh = _create_profession_prop_mesh()
_fit_carried_props()
profession_prop.visible = profession_prop.mesh != null profession_prop.visible = profession_prop.mesh != null
profession_label.text = "%s\n%s" % [npc_name, definition.display_name] profession_label.text = "%s\n%s" % [npc_name, definition.display_name]
profession_label.visible = debug_overlay_visible profession_label.visible = debug_overlay_visible
func _fit_carried_props() -> void:
var profile := get_meta("character_appearance") as CharacterAppearanceProfile
var lift := Vector3.UP * 0.61 * (profile.leg_length - 1.0) * profile.height
var reach := 0.60 + 0.10 * profile.body_fullness
profession_prop.position.x = signf(profession_prop.position.x) * reach
profession_prop.position.z = 0.20
profession_prop.position = profession_prop.position * profile.height + lift
profession_prop.scale = Vector3.ONE * profile.height
carried_food_visual.position = Vector3(reach, 0.70, 0.32) * profile.height + lift
carried_wood_visual.position = Vector3(-reach, 0.88, 0.18) * profile.height + lift
func _create_profession_prop_mesh() -> PrimitiveMesh: func _create_profession_prop_mesh() -> PrimitiveMesh:
profession_prop.position = Vector3(0.48, 0.85, 0.0) profession_prop.position = Vector3(0.48, 0.85, 0.0)
profession_prop.rotation_degrees = Vector3.ZERO profession_prop.rotation_degrees = Vector3.ZERO
@@ -218,7 +232,7 @@ func set_opportunity_concern_visible(is_visible: bool) -> void:
if opportunity_concern_root == null: if opportunity_concern_root == null:
return return
opportunity_concern_root.visible = is_visible and not is_dead_visual opportunity_concern_root.visible = is_visible and not is_dead_visual
opportunity_concern_root.position = CONCERN_BASE_POSITION opportunity_concern_root.position = CONCERN_BASE_POSITION + Vector3.UP * cue_lift
opportunity_concern_root.rotation = Vector3.ZERO opportunity_concern_root.rotation = Vector3.ZERO
opportunity_concern_root.scale = Vector3.ONE opportunity_concern_root.scale = Vector3.ONE
if is_visible: if is_visible:
@@ -236,7 +250,7 @@ func play_trust_gain_reaction() -> void:
return return
_stop_trust_gain_reaction() _stop_trust_gain_reaction()
trust_reaction_root.visible = true trust_reaction_root.visible = true
trust_reaction_root.position = TRUST_REACTION_BASE_POSITION trust_reaction_root.position = TRUST_REACTION_BASE_POSITION + Vector3.UP * cue_lift
trust_reaction_root.rotation = Vector3.ZERO trust_reaction_root.rotation = Vector3.ZERO
trust_reaction_root.scale = TRUST_REACTION_START_SCALE trust_reaction_root.scale = TRUST_REACTION_START_SCALE
_set_trust_reaction_transparency(1.0) _set_trust_reaction_transparency(1.0)
@@ -248,14 +262,14 @@ func play_trust_gain_reaction() -> void:
trust_reaction_tween.tween_property( trust_reaction_tween.tween_property(
trust_reaction_root, trust_reaction_root,
"position", "position",
TRUST_REACTION_BASE_POSITION + Vector3(0.0, 0.16, 0.0), TRUST_REACTION_BASE_POSITION + Vector3(0.0, 0.16 + cue_lift, 0.0),
0.68 0.68
) )
trust_reaction_tween.parallel().tween_property(trust_reaction_root, "rotation:y", 0.32, 0.68) trust_reaction_tween.parallel().tween_property(trust_reaction_root, "rotation:y", 0.32, 0.68)
trust_reaction_tween.tween_property( trust_reaction_tween.tween_property(
trust_reaction_root, trust_reaction_root,
"position", "position",
TRUST_REACTION_BASE_POSITION + Vector3(0.0, 0.44, 0.0), TRUST_REACTION_BASE_POSITION + Vector3(0.0, 0.44 + cue_lift, 0.0),
0.55 0.55
) )
trust_reaction_tween.parallel().tween_property( trust_reaction_tween.parallel().tween_property(
@@ -272,7 +286,7 @@ func _set_trust_reaction_transparency(value: float) -> void:
func _finish_trust_gain_reaction() -> void: func _finish_trust_gain_reaction() -> void:
trust_reaction_root.visible = false trust_reaction_root.visible = false
trust_reaction_root.position = TRUST_REACTION_BASE_POSITION trust_reaction_root.position = TRUST_REACTION_BASE_POSITION + Vector3.UP * cue_lift
trust_reaction_root.rotation = Vector3.ZERO trust_reaction_root.rotation = Vector3.ZERO
trust_reaction_root.scale = TRUST_REACTION_START_SCALE trust_reaction_root.scale = TRUST_REACTION_START_SCALE
_set_trust_reaction_transparency(1.0) _set_trust_reaction_transparency(1.0)
@@ -286,7 +300,7 @@ func _stop_trust_gain_reaction() -> void:
if trust_reaction_root == null: if trust_reaction_root == null:
return return
trust_reaction_root.visible = false trust_reaction_root.visible = false
trust_reaction_root.position = TRUST_REACTION_BASE_POSITION trust_reaction_root.position = TRUST_REACTION_BASE_POSITION + Vector3.UP * cue_lift
trust_reaction_root.rotation = Vector3.ZERO trust_reaction_root.rotation = Vector3.ZERO
trust_reaction_root.scale = TRUST_REACTION_START_SCALE trust_reaction_root.scale = TRUST_REACTION_START_SCALE
_set_trust_reaction_transparency(1.0) _set_trust_reaction_transparency(1.0)
+12 -103
View File
@@ -1,70 +1,9 @@
[gd_scene load_steps=24 format=3 uid="uid://dhxxyprqflotq"] [gd_scene load_steps=13 format=3 uid="uid://dhxxyprqflotq"]
[ext_resource type="Script" uid="uid://bha8uf40p3sa0" path="res://player/npc/NpcVisual.gd" id="1_ixfoq"] [ext_resource type="Script" uid="uid://bha8uf40p3sa0" path="res://player/npc/NpcVisual.gd" id="1_ixfoq"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_d844k"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_d844k"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_d844k"]
albedo_color = Color(0.46, 0.58, 0.33, 1)
roughness = 0.82
[sub_resource type="CylinderMesh" id="CylinderMesh_body"]
material = SubResource("StandardMaterial3D_d844k")
top_radius = 0.28
bottom_radius = 0.42
height = 0.9
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_skin"]
albedo_color = Color(0.84, 0.61, 0.39, 1)
roughness = 0.88
[sub_resource type="SphereMesh" id="SphereMesh_head"]
material = SubResource("StandardMaterial3D_skin")
radius = 0.29
height = 0.56
[sub_resource type="CylinderMesh" id="CylinderMesh_hair"]
top_radius = 0.24
bottom_radius = 0.29
height = 0.18
[sub_resource type="CylinderMesh" id="CylinderMesh_arm"]
material = SubResource("StandardMaterial3D_d844k")
top_radius = 0.095
bottom_radius = 0.11
height = 0.58
[sub_resource type="SphereMesh" id="SphereMesh_hand"]
material = SubResource("StandardMaterial3D_skin")
radius = 0.12
height = 0.22
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_pants"]
albedo_color = Color(0.16, 0.22, 0.25, 1)
roughness = 0.9
[sub_resource type="CapsuleMesh" id="CapsuleMesh_leg"]
material = SubResource("StandardMaterial3D_pants")
radius = 0.115
height = 0.62
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_boot"]
albedo_color = Color(0.13, 0.075, 0.04, 1)
roughness = 0.95
[sub_resource type="BoxMesh" id="BoxMesh_foot"]
material = SubResource("StandardMaterial3D_boot")
size = Vector3(0.23, 0.15, 0.36)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_eye"]
albedo_color = Color(0.06, 0.045, 0.035, 1)
roughness = 0.8
[sub_resource type="SphereMesh" id="SphereMesh_eye"]
material = SubResource("StandardMaterial3D_eye")
radius = 0.035
height = 0.06
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_food"] [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_food"]
albedo_color = Color(0.38, 0.22, 0.08, 1) albedo_color = Color(0.38, 0.22, 0.08, 1)
roughness = 0.9 roughness = 0.9
@@ -139,62 +78,29 @@ shape = SubResource("CapsuleShape3D_d844k")
[node name="MeshInstance3D" type="MeshInstance3D" parent="."] [node name="MeshInstance3D" type="MeshInstance3D" parent="."]
position = Vector3(0, 1.05, 0) position = Vector3(0, 1.05, 0)
mesh = SubResource("CylinderMesh_body")
[node name="Head" type="MeshInstance3D" parent="."] [node name="Head" type="MeshInstance3D" parent="."]
position = Vector3(0, 1.68, 0) position = Vector3(0, 1.68, 0)
mesh = SubResource("SphereMesh_head")
[node name="Hair" type="MeshInstance3D" parent="."] [node name="Hair" type="MeshInstance3D" parent="."]
position = Vector3(0, 1.9, -0.015) position = Vector3(0, 1.9, -0.015)
mesh = SubResource("CylinderMesh_hair")
[node name="EyeLeft" type="MeshInstance3D" parent="."]
position = Vector3(-0.095, 1.72, 0.274)
mesh = SubResource("SphereMesh_eye")
[node name="EyeRight" type="MeshInstance3D" parent="."]
position = Vector3(0.095, 1.72, 0.274)
mesh = SubResource("SphereMesh_eye")
[node name="ArmLeft" type="MeshInstance3D" parent="."] [node name="ArmLeft" type="MeshInstance3D" parent="."]
position = Vector3(-0.39, 1.08, 0) position = Vector3(-0.39, 1.3, 0)
rotation = Vector3(0, 0, -0.139626)
mesh = SubResource("CylinderMesh_arm")
[node name="Hand" type="MeshInstance3D" parent="ArmLeft"]
position = Vector3(0, -0.35, 0)
mesh = SubResource("SphereMesh_hand")
[node name="ArmRight" type="MeshInstance3D" parent="."] [node name="ArmRight" type="MeshInstance3D" parent="."]
position = Vector3(0.39, 1.08, 0) position = Vector3(0.39, 1.3, 0)
rotation = Vector3(0, 0, 0.139626)
mesh = SubResource("CylinderMesh_arm")
[node name="Hand" type="MeshInstance3D" parent="ArmRight"]
position = Vector3(0, -0.35, 0)
mesh = SubResource("SphereMesh_hand")
[node name="LegLeft" type="MeshInstance3D" parent="."] [node name="LegLeft" type="MeshInstance3D" parent="."]
position = Vector3(-0.18, 0.43, 0) position = Vector3(-0.18, 0.61, 0)
mesh = SubResource("CapsuleMesh_leg")
[node name="Foot" type="MeshInstance3D" parent="LegLeft"]
position = Vector3(0, -0.28, 0.09)
mesh = SubResource("BoxMesh_foot")
[node name="LegRight" type="MeshInstance3D" parent="."] [node name="LegRight" type="MeshInstance3D" parent="."]
position = Vector3(0.18, 0.43, 0) position = Vector3(0.18, 0.61, 0)
mesh = SubResource("CapsuleMesh_leg")
[node name="Foot" type="MeshInstance3D" parent="LegRight"]
position = Vector3(0, -0.28, 0.09)
mesh = SubResource("BoxMesh_foot")
[node name="ProfessionProp" type="MeshInstance3D" parent="."] [node name="ProfessionProp" type="MeshInstance3D" parent="."]
[node name="ProfessionLabel" type="Label3D" parent="."] [node name="ProfessionLabel" type="Label3D" parent="."]
position = Vector3(0, 2.05, 0) position = Vector3(0, 2.8, 0)
billboard = 1 billboard = 1
no_depth_test = true no_depth_test = true
text = "Villager" text = "Villager"
@@ -223,13 +129,13 @@ mesh = SubResource("CylinderMesh_wood")
[node name="TaskGlyphRoot" type="Node3D" parent="."] [node name="TaskGlyphRoot" type="Node3D" parent="."]
visible = false visible = false
position = Vector3(0, 2.35, 0) position = Vector3(0, 2.9, 0)
[node name="TaskGlyph" type="MeshInstance3D" parent="TaskGlyphRoot"] [node name="TaskGlyph" type="MeshInstance3D" parent="TaskGlyphRoot"]
[node name="OpportunityConcernRoot" type="Node3D" parent="."] [node name="OpportunityConcernRoot" type="Node3D" parent="."]
visible = false visible = false
position = Vector3(-0.4, 2.72, 0) position = Vector3(-0.4, 3.25, 0)
[node name="EmptyBowl" type="MeshInstance3D" parent="OpportunityConcernRoot"] [node name="EmptyBowl" type="MeshInstance3D" parent="OpportunityConcernRoot"]
rotation_degrees = Vector3(72, 0, 0) rotation_degrees = Vector3(72, 0, 0)
@@ -250,7 +156,7 @@ mesh = SubResource("SphereMesh_concern")
[node name="TrustReactionRoot" type="Node3D" parent="."] [node name="TrustReactionRoot" type="Node3D" parent="."]
visible = false visible = false
position = Vector3(0, 2.72, 0) position = Vector3(0, 3.25, 0)
scale = Vector3(0.24, 0.24, 0.24) scale = Vector3(0.24, 0.24, 0.24)
[node name="Halo" type="MeshInstance3D" parent="TrustReactionRoot"] [node name="Halo" type="MeshInstance3D" parent="TrustReactionRoot"]
@@ -278,3 +184,6 @@ path_desired_distance = 0.3
target_desired_distance = 0.5 target_desired_distance = 0.5
height = 1.7 height = 1.7
radius = 0.4 radius = 0.4
[node name="Tail" type="MeshInstance3D" parent="."]
position = Vector3(0, 0.8, -0.22)
+21 -8
View File
@@ -1,5 +1,9 @@
extends Node3D extends Node3D
@export var appearance: CharacterAppearanceProfile = preload(
"res://assets/characters/profiles/reference_cat.tres"
)
@onready var body_mesh: MeshInstance3D = $Body @onready var body_mesh: MeshInstance3D = $Body
@onready var head_mesh: MeshInstance3D = $Head @onready var head_mesh: MeshInstance3D = $Head
@onready var hair_mesh: MeshInstance3D = $Hair @onready var hair_mesh: MeshInstance3D = $Hair
@@ -10,10 +14,15 @@ extends Node3D
var player_body: CharacterBody3D var player_body: CharacterBody3D
var walk_phase := 0.0 var walk_phase := 0.0
var rest_heights := Vector3(1.05, 1.68, 1.9)
var arm_swing := 1.0
func _ready() -> void: func _ready() -> void:
player_body = get_parent() as CharacterBody3D player_body = get_parent() as CharacterBody3D
AnimalAppearance.apply_to(self, 0, false, appearance)
rest_heights = AnimalAppearance.rest_heights(self)
arm_swing = get_meta("arm_swing", 1.0)
func _process(delta: float) -> void: func _process(delta: float) -> void:
@@ -26,18 +35,22 @@ func _process(delta: float) -> void:
var swing := sin(walk_phase) var swing := sin(walk_phase)
left_leg.rotation_degrees.x = lerpf(left_leg.rotation_degrees.x, swing * 30.0, blend) left_leg.rotation_degrees.x = lerpf(left_leg.rotation_degrees.x, swing * 30.0, blend)
right_leg.rotation_degrees.x = lerpf(right_leg.rotation_degrees.x, -swing * 30.0, blend) right_leg.rotation_degrees.x = lerpf(right_leg.rotation_degrees.x, -swing * 30.0, blend)
left_arm.rotation_degrees.x = lerpf(left_arm.rotation_degrees.x, -swing * 22.0, blend) left_arm.rotation_degrees.x = lerpf(
right_arm.rotation_degrees.x = lerpf(right_arm.rotation_degrees.x, swing * 22.0, blend) left_arm.rotation_degrees.x, -swing * 22.0 * arm_swing, blend
)
right_arm.rotation_degrees.x = lerpf(
right_arm.rotation_degrees.x, swing * 22.0 * arm_swing, blend
)
var step_bob := absf(sin(walk_phase * 2.0)) * 0.035 var step_bob := absf(sin(walk_phase * 2.0)) * 0.035
body_mesh.position.y = lerpf(body_mesh.position.y, 1.05 + step_bob, blend) body_mesh.position.y = lerpf(body_mesh.position.y, rest_heights.x + step_bob, blend)
head_mesh.position.y = lerpf(head_mesh.position.y, 1.68 + step_bob, blend) head_mesh.position.y = lerpf(head_mesh.position.y, rest_heights.y + step_bob, blend)
hair_mesh.position.y = lerpf(hair_mesh.position.y, 1.9 + step_bob, blend) hair_mesh.position.y = lerpf(hair_mesh.position.y, rest_heights.z + step_bob, blend)
return return
left_leg.rotation_degrees.x = lerpf(left_leg.rotation_degrees.x, 0.0, blend) left_leg.rotation_degrees.x = lerpf(left_leg.rotation_degrees.x, 0.0, blend)
right_leg.rotation_degrees.x = lerpf(right_leg.rotation_degrees.x, 0.0, blend) right_leg.rotation_degrees.x = lerpf(right_leg.rotation_degrees.x, 0.0, blend)
left_arm.rotation_degrees.x = lerpf(left_arm.rotation_degrees.x, 0.0, blend) left_arm.rotation_degrees.x = lerpf(left_arm.rotation_degrees.x, 0.0, blend)
right_arm.rotation_degrees.x = lerpf(right_arm.rotation_degrees.x, 0.0, blend) right_arm.rotation_degrees.x = lerpf(right_arm.rotation_degrees.x, 0.0, blend)
body_mesh.position.y = lerpf(body_mesh.position.y, 1.05, blend) body_mesh.position.y = lerpf(body_mesh.position.y, rest_heights.x, blend)
head_mesh.position.y = lerpf(head_mesh.position.y, 1.68, blend) head_mesh.position.y = lerpf(head_mesh.position.y, rest_heights.y, blend)
hair_mesh.position.y = lerpf(hair_mesh.position.y, 1.9, blend) hair_mesh.position.y = lerpf(hair_mesh.position.y, rest_heights.z, blend)
+5 -1
View File
@@ -54,7 +54,7 @@ move_right={
"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) "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)
] ]
} }
interact={ interact={
"deadzone": 0.2, "deadzone": 0.2,
"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) "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)
] ]
@@ -69,3 +69,7 @@ dash={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
] ]
} }
[rendering]
anti_aliasing/quality/msaa_3d=2
+196
View File
@@ -0,0 +1,196 @@
extends SceneTree
## Imported geometry, independent morphology, stable identities, and real animation sockets.
const PLAYER := preload("res://player/PlayerVisual.tscn")
const NPC := preload("res://player/npc/NpcVisual.tscn")
const PROFILE_DIR := "res://assets/characters/profiles/"
const PRESETS := [
"reference_cat",
"fox_rambler",
"rabbit_scholar",
"badger_baker",
"otter_courier",
"lop_gardener"
]
var failures: Array[String] = []
var checks := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var first := PLAYER.instantiate() as Node3D
var second := PLAYER.instantiate() as Node3D
root.add_child(first)
root.add_child(second)
first.set_process(false)
second.set_process(false)
var lean := CharacterAppearanceProfile.new()
lean.body_fullness = 0.0
var stout := CharacterAppearanceProfile.new()
stout.body_fullness = 1.0
AnimalAppearance.apply_to(first, 0, false, lean)
AnimalAppearance.apply_to(second, 0, false, stout)
for path in ["Body", "Body/Bag", "Body/Scarf"]:
var a := first.get_node(path) as MeshInstance3D
var b := second.get_node(path) as MeshInstance3D
_check(a.mesh == b.mesh, path + " must share imported geometry")
var shape := a.find_blend_shape_by_name("Stout")
_check(shape >= 0, path + " must retain its Blender Stout shape")
if shape >= 0:
_check(
is_zero_approx(a.get_blend_shape_value(shape)),
"Lean shape weight must remain independent"
)
_check(
is_equal_approx(b.get_blend_shape_value(shape), 1.0),
"Stout shape weight must remain independent"
)
_check(
first.get_node("Body").material_override != second.get_node("Body").material_override,
"Palettes must be per actor"
)
var body := first.get_node("Body") as MeshInstance3D
var arrays := body.mesh.surface_get_arrays(0)
var roles: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV2]
_check(not roles.is_empty(), "Palette roles must survive GLB import in UV2")
var coat_vertices := 0
for role in roles:
if is_equal_approx(role.x, 3.0):
coat_vertices += 1
_check(coat_vertices > 0, "Coat must have an independently recolorable palette role")
var child_count := first.find_children("*", "MeshInstance3D", true, false).size()
for preset in PRESETS:
var profile: CharacterAppearanceProfile = load(PROFILE_DIR + preset + ".tres")
AnimalAppearance.apply_to(first, 0, false, profile)
_check(
first.find_children("*", "MeshInstance3D", true, false).size() == child_count,
"Changing profiles must reuse attachment nodes"
)
_check(
first.get_node("Head/Ears").get_parent() == first.get_node("Head"),
"Ears must follow the head"
)
_check(first.get_node("Body/Bag").get_parent() == body, "Pack must follow the torso")
var triangles := 0
var draws := 0
for mesh_node: MeshInstance3D in first.find_children("*", "MeshInstance3D", true, false):
if mesh_node.mesh == null or mesh_node.name in ["Blade", "Grip"]:
continue
_check(
mesh_node.mesh.get_surface_count() == 1,
"Each character part must remain one surface"
)
triangles += mesh_node.mesh.surface_get_array_index_len(0) / 3
draws += 1
_check(
triangles < 23000 and draws <= 12,
"Selected character must stay within geometry and surface budgets"
)
for extreme in [0, 1]:
var profile := CharacterAppearanceProfile.new()
profile.height = 0.80 if extreme == 0 else 1.15
profile.leg_length = 0.85 if extreme == 0 else 1.15
profile.head_size = 0.88 if extreme == 0 else 1.12
profile.body_fullness = float(extreme)
profile.ear_length = 0.65 if extreme == 0 else 1.20
profile.species = 2
AnimalAppearance.apply_to(first, 0, false, profile)
for side in ["Left", "Right"]:
var leg := first.get_node("Leg" + side) as MeshInstance3D
var bounds := leg.transform * leg.get_aabb()
_check(
absf(bounds.position.y) < 0.03, "Leg-length variation must keep feet on the ground"
)
_check(
first.get_meta("character_top") > first.get_node("Head").position.y,
"Visible height must include ears"
)
# An ID lookup must neither consume global random state nor change its result after another lookup.
seed(8123)
var expected := randf()
seed(8123)
var identity := CharacterAppearanceProfile.for_identity(731)
for value in 30:
CharacterAppearanceProfile.for_identity(value)
var rebuilt := CharacterAppearanceProfile.for_identity(731)
_check(
(
identity.height == rebuilt.height
and identity.body_fullness == rebuilt.body_fullness
and identity.species == rebuilt.species
),
"Identity must rebuild deterministically"
)
_check(randf() == expected, "Appearance must not consume global RNG")
first.free()
second.free()
_test_animation_and_cues()
await process_frame
if failures.is_empty():
print("[TEST] PASS character_appearance (", checks, " checks)")
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func _test_animation_and_cues() -> void:
var profile := CharacterAppearanceProfile.new()
profile.species = 2
profile.height = 1.15
profile.head_size = 1.12
profile.ear_length = 1.2
profile.leg_length = 1.15
var parent := CharacterBody3D.new()
root.add_child(parent)
var player := PLAYER.instantiate() as Node3D
player.appearance = profile
parent.add_child(player)
player.set_process(false)
parent.velocity = Vector3(2, 0, 0)
var heights := AnimalAppearance.rest_heights(player)
for frame in 30:
player.call("_process", 1.0 / 60.0)
_check(
player.get_node("Head").position.y >= heights.y,
"Player gait must retain configured head height"
)
_check(
player.get_node("Body").position.y >= heights.x,
"Player gait must retain configured torso height"
)
var npc := NPC.instantiate() as Node3D
npc.appearance = profile
root.add_child(npc)
npc.set_physics_process(false)
npc.set_process(false)
npc.call("_apply_personal_details")
var cue_height: float = npc.get_node("TaskGlyphRoot").position.y
npc.call("_apply_personal_details")
_check(
is_equal_approx(cue_height, npc.get_node("TaskGlyphRoot").position.y),
"Reapplying appearance must not accumulate cue lift"
)
_check(
cue_height > float(npc.get_meta("character_top")) + 0.2,
"NPC cues must clear the tallest supported ears"
)
npc.velocity = Vector3(2, 0, 0)
for frame in 30:
npc.call("_process", 1.0 / 60.0)
_check(
npc.get_node("Head").position.y >= heights.y, "NPC gait must retain configured head height"
)
parent.free()
npc.free()
func _check(condition: bool, message: String) -> void:
checks += 1
if not condition:
failures.append(message)
+1
View File
@@ -0,0 +1 @@
uid://ba4wrqkojd2pa
+210
View File
@@ -0,0 +1,210 @@
extends SceneTree
const Controller := preload("res://world/jajce/GrassInteractionController.gd")
var failures: Array[String] = []
class RestoreSource:
extends Node
signal state_restored
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_check_trail_lifecycle()
await _check_loaded_binding()
if failures.is_empty():
print("Foliage interaction checks passed")
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func _check_trail_lifecycle() -> void:
var field := FoliageTrailMap.new()
var positions: Dictionary[int, Vector3] = {1: Vector3(0.25, 0, 0.25)}
field.advance(0.08, Vector3.ZERO, positions)
positions[1] = Vector3(2.25, 0, 0.25)
field.advance(0.08, Vector3.ZERO, positions)
_check(_sample(field, Vector2(1.25, 0.25)).b > 0.9, "Walking should leave a continuous trail")
_check(
_sample(field, Vector2(1.25, 0.75)).g > 0 and _sample(field, Vector2(1.25, -0.25)).g < 0,
"The two sides of a trail should bend away from the travelled line"
)
positions[1] = Vector3(18.25, 0, 0.25)
field.advance(0.08, Vector3.ZERO, positions)
_check(_sample(field, Vector2(10.25, 0.25)).b == 0, "Teleports must not paint connecting lines")
_check(
_sample(field, Vector2(18.25, 0.25)).b > 0.9,
"Teleported actors still press their destination"
)
field.advance(0.08, Vector3(1, 0, 1), {})
_check(
_sample(field, Vector2(1.25, 0.25)).b > 0.8,
"Camera movement must keep trails in world space"
)
field.advance(4.0, Vector3.ZERO, {})
var recovering := _sample(field, Vector2(1.25, 0.25)).b
_check(recovering > 0.1 and recovering < 0.6, "Abandoned trails should gradually recover")
field.advance(4.1, Vector3.ZERO, {})
_check(
(field.get("_cells") as Dictionary).is_empty(),
"Recovered trails should release sparse history"
)
positions[1] = Vector3(0.25, 6, 0.25)
field.advance(0.08, Vector3.ZERO, positions)
var elevated := _sample(field, Vector2(0.25, 0.25))
_check(
is_equal_approx(elevated.a / elevated.b, 6.0),
"Foot height must reach the shader for bridge rejection"
)
field.advance(0.08, Vector3(300, 0, 300), {})
_check((field.get("_cells") as Dictionary).is_empty(), "Off-window trails must be discarded")
positions[1] = Vector3(2.25, 0, 0.25)
field.advance(0.08, Vector3.ZERO, positions)
_check(
_sample(field, Vector2(1.25, 0.25)).b == 0,
"Re-entering actors must not connect to stale positions"
)
field.clear()
_check(_sample(field, Vector2(2.25, 0.25)).b == 0, "Reset must clear the uploaded field")
_check(
(field.get("_previous_positions") as Dictionary).is_empty(),
"Reset must discard actor history"
)
func _check_loaded_binding() -> void:
var stage := Node3D.new()
root.add_child(stage)
var camera := Camera3D.new()
stage.add_child(camera)
camera.current = true
var simulation := RestoreSource.new()
simulation.name = "SimulationManager"
stage.add_child(simulation)
var world := Node3D.new()
stage.add_child(world)
var terrain := Node3D.new()
world.add_child(terrain)
var patch := (
load("res://world/jajce/StylizedBerryPatch.tscn").instantiate() as StylizedBerryPatch
)
world.add_child(patch)
patch.scale = Vector3(1.2, 0.8, 1.1)
patch.rotation.y = 0.6
var controller := Controller.new()
controller.grass_material = (
load("res://world/jajce/materials/cozy_grass_material.tres").duplicate(true)
)
terrain.add_child(controller)
var foreign := Node3D.new()
root.add_child(foreign)
foreign.add_to_group("grass_interactors")
var actors: Array[Node3D] = []
for index in 12:
var actor := Node3D.new()
stage.add_child(actor)
actor.position.x = 1.0 + index
actor.add_to_group("grass_interactors")
actors.append(actor)
await process_frame
controller.set_process(false)
controller.call("_refresh_interactors", 0.08)
var material := controller.grass_material
var positions: PackedVector3Array = material.get_shader_parameter("interactor_positions")
_check(
int(material.get_shader_parameter("interactor_count")) == 8,
"Contact work must respect the eight-actor cap"
)
_check(
positions[0] == actors[0].position,
"A closer actor in another scene container must be rejected"
)
_check(
bool(material.get_shader_parameter("foliage_trails_enabled")),
"Loaded grass must enable its trail field"
)
var leaves := patch.get_node("Leaves") as MultiMeshInstance3D
var berries := patch.get_node("Berries") as MultiMeshInstance3D
var leaf_material := leaves.multimesh.mesh.surface_get_material(0) as ShaderMaterial
var berry_material := berries.multimesh.mesh.surface_get_material(0) as ShaderMaterial
_check(
(
(
leaf_material.get_shader_parameter("foliage_trail_map")
== material.get_shader_parameter("foliage_trail_map")
)
and (
berry_material.get_shader_parameter("foliage_trail_map")
== material.get_shader_parameter("foliage_trail_map")
)
),
"Bush leaves, berries and meadow must use the same world-local footprint field"
)
var visible_berries := patch.get_visible_berry_count()
actors[0].hide()
controller.primary_interactor_path = controller.get_path_to(actors.back())
controller.max_interactors = 4
controller.call("_refresh_interactors", 0.12)
positions = material.get_shader_parameter("interactor_positions")
_check(
(
int(material.get_shader_parameter("interactor_count")) == 4
and positions[0].x == 12
and positions[1].x == 2
),
"Balanced must reserve the primary actor slot, cap actors and ignore hidden visuals"
)
simulation.state_restored.emit()
_check(
not bool(material.get_shader_parameter("foliage_trails_enabled")),
"Successful restores must clear visual trails immediately"
)
controller.set_interaction_enabled(false)
_check(not controller.is_processing(), "Low quality must stop interaction updates")
_check(
not bool(leaf_material.get_shader_parameter("foliage_trails_enabled")),
"Low quality must release bushes as well as grass"
)
_check(
int(material.get_shader_parameter("interactor_count")) == 0,
"Low quality must clear direct contacts"
)
_check(
patch.get_visible_berry_count() == visible_berries,
"Contact bending must not change resource presentation amounts"
)
controller.set_interaction_enabled(true)
controller.call("_refresh_interactors", 0.08)
_check(
bool(berry_material.get_shader_parameter("foliage_trails_enabled")),
"Bush interaction must resume after Low quality"
)
var other_field := FoliageTrailMap.new()
_check(
other_field.texture != material.get_shader_parameter("foliage_trail_map"),
"Each world must own a distinct trail texture"
)
foreign.free()
stage.free()
await process_frame
func _sample(field: FoliageTrailMap, position: Vector2) -> Color:
var image := field.get("_image") as Image
var pixel := Vector2i(
(position - Vector2(field.world_rect.x, field.world_rect.y)) / field.CELL_SIZE
)
return image.get_pixelv(pixel)
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)

Some files were not shown because too many files have changed in this diff Show More