Compare commits

...

1 Commits

Author SHA1 Message Date
Rijad Zuzo 4ff8858b19 feat(characters): add modular woodland traveler kit 2026-09-05 22:54:54 +02:00
39 changed files with 2037 additions and 50 deletions
View File
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.801.15 times the base height |
| Body fullness | 01 Blender `Stout` shape key |
| Head size / leg length / ear length | 0.881.12 / 0.851.15 / 0.651.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
+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 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/` |
| 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` |
| Add a regression, benchmark, or run the gate | [Testing and benchmarks](FEATURE_TESTING_AND_BENCHMARKS.md) | `tests/`, `tools/`, `docs/benchmarks/` |
+11
View File
@@ -108,6 +108,17 @@ 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
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

+45 -17
View File
@@ -9,6 +9,7 @@ const TRUST_REACTION_START_SCALE := Vector3(0.24, 0.24, 0.24)
const CONCERN_BASE_POSITION := Vector3(-0.4, 3.25, 0.0)
@export var debug_logs := false
@export var appearance: CharacterAppearanceProfile
@export var move_speed := 3.0
@export var rotation_speed := 8.0
@@ -58,6 +59,9 @@ var walk_phase := 0.0
var glyph_phase := 0.0
var concern_phase := 0.0
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:
@@ -76,11 +80,13 @@ func _process(delta: float) -> void:
return
glyph_phase = fmod(glyph_phase + delta * 2.2, TAU)
if task_glyph_root.visible:
task_glyph_root.position.y = 2.9 + 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
if opportunity_concern_root.visible:
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
var concern_scale := 0.96 + sin(concern_phase) * 0.04
opportunity_concern_root.scale = Vector3.ONE * concern_scale
@@ -92,20 +98,24 @@ func _process(delta: float) -> void:
var swing := sin(walk_phase)
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)
left_arm.rotation_degrees.x = lerpf(left_arm.rotation_degrees.x, -swing * 20.0, blend)
right_arm.rotation_degrees.x = lerpf(right_arm.rotation_degrees.x, swing * 20.0, blend)
left_arm.rotation_degrees.x = lerpf(
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
body_mesh.position.y = lerpf(body_mesh.position.y, 1.05 + step_bob, blend)
head_mesh.position.y = lerpf(head_mesh.position.y, 1.68 + step_bob, blend)
hair_mesh.position.y = lerpf(hair_mesh.position.y, 1.9 + 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, rest_heights.y + step_bob, blend)
hair_mesh.position.y = lerpf(hair_mesh.position.y, rest_heights.z + step_bob, blend)
else:
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)
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)
body_mesh.position.y = lerpf(body_mesh.position.y, 1.05, blend)
head_mesh.position.y = lerpf(head_mesh.position.y, 1.68, blend)
hair_mesh.position.y = lerpf(hair_mesh.position.y, 1.9, blend)
body_mesh.position.y = lerpf(body_mesh.position.y, rest_heights.x, blend)
head_mesh.position.y = lerpf(head_mesh.position.y, rest_heights.y, blend)
hair_mesh.position.y = lerpf(hair_mesh.position.y, rest_heights.z, blend)
func setup_from_sim(npc: SimNPC) -> void:
@@ -123,7 +133,12 @@ func setup_from_sim(npc: SimNPC) -> void:
func _apply_personal_details() -> void:
AnimalAppearance.apply_to(self, sim_id, true)
AnimalAppearance.apply_to(self, sim_id, true, appearance)
rest_heights = AnimalAppearance.rest_heights(self, true)
arm_swing = get_meta("arm_swing", 1.0)
cue_lift = maxf(0.0, float(get_meta("character_top", 2.6)) - 2.6)
task_glyph_root.position.y = 2.9 + cue_lift
profession_label.position.y = 2.8 + cue_lift
func _apply_profession_presentation() -> void:
@@ -140,11 +155,24 @@ func _apply_profession_presentation() -> void:
prop_material.roughness = 0.78
profession_prop.material_override = prop_material
profession_prop.mesh = _create_profession_prop_mesh()
_fit_carried_props()
profession_prop.visible = profession_prop.mesh != null
profession_label.text = "%s\n%s" % [npc_name, definition.display_name]
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:
profession_prop.position = Vector3(0.48, 0.85, 0.0)
profession_prop.rotation_degrees = Vector3.ZERO
@@ -204,7 +232,7 @@ func set_opportunity_concern_visible(is_visible: bool) -> void:
if opportunity_concern_root == null:
return
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.scale = Vector3.ONE
if is_visible:
@@ -222,7 +250,7 @@ func play_trust_gain_reaction() -> void:
return
_stop_trust_gain_reaction()
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.scale = TRUST_REACTION_START_SCALE
_set_trust_reaction_transparency(1.0)
@@ -234,14 +262,14 @@ func play_trust_gain_reaction() -> void:
trust_reaction_tween.tween_property(
trust_reaction_root,
"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
)
trust_reaction_tween.parallel().tween_property(trust_reaction_root, "rotation:y", 0.32, 0.68)
trust_reaction_tween.tween_property(
trust_reaction_root,
"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
)
trust_reaction_tween.parallel().tween_property(
@@ -258,7 +286,7 @@ func _set_trust_reaction_transparency(value: float) -> void:
func _finish_trust_gain_reaction() -> void:
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.scale = TRUST_REACTION_START_SCALE
_set_trust_reaction_transparency(1.0)
@@ -272,7 +300,7 @@ func _stop_trust_gain_reaction() -> void:
if trust_reaction_root == null:
return
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.scale = TRUST_REACTION_START_SCALE
_set_trust_reaction_transparency(1.0)
+21 -9
View File
@@ -1,5 +1,9 @@
extends Node3D
@export var appearance: CharacterAppearanceProfile = preload(
"res://assets/characters/profiles/reference_cat.tres"
)
@onready var body_mesh: MeshInstance3D = $Body
@onready var head_mesh: MeshInstance3D = $Head
@onready var hair_mesh: MeshInstance3D = $Hair
@@ -10,11 +14,15 @@ extends Node3D
var player_body: CharacterBody3D
var walk_phase := 0.0
var rest_heights := Vector3(1.05, 1.68, 1.9)
var arm_swing := 1.0
func _ready() -> void:
player_body = get_parent() as CharacterBody3D
AnimalAppearance.apply_to(self, 0)
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:
@@ -27,18 +35,22 @@ func _process(delta: float) -> void:
var swing := sin(walk_phase)
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)
left_arm.rotation_degrees.x = lerpf(left_arm.rotation_degrees.x, -swing * 22.0, blend)
right_arm.rotation_degrees.x = lerpf(right_arm.rotation_degrees.x, swing * 22.0, blend)
left_arm.rotation_degrees.x = lerpf(
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
body_mesh.position.y = lerpf(body_mesh.position.y, 1.05 + step_bob, blend)
head_mesh.position.y = lerpf(head_mesh.position.y, 1.68 + step_bob, blend)
hair_mesh.position.y = lerpf(hair_mesh.position.y, 1.9 + 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, rest_heights.y + step_bob, blend)
hair_mesh.position.y = lerpf(hair_mesh.position.y, rest_heights.z + step_bob, blend)
return
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)
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)
body_mesh.position.y = lerpf(body_mesh.position.y, 1.05, blend)
head_mesh.position.y = lerpf(head_mesh.position.y, 1.68, blend)
hair_mesh.position.y = lerpf(hair_mesh.position.y, 1.9, blend)
body_mesh.position.y = lerpf(body_mesh.position.y, rest_heights.x, blend)
head_mesh.position.y = lerpf(head_mesh.position.y, rest_heights.y, blend)
hair_mesh.position.y = lerpf(hair_mesh.position.y, rest_heights.z, blend)
+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
+2 -2
View File
@@ -535,9 +535,9 @@ func _run() -> void:
(
main_scene.has_node("Player/Visual/Body")
and main_scene.has_node("Player/Visual/Head")
and main_scene.get_node("Player/Visual").get_meta("animal_species") == &"fox"
and main_scene.get_node("Player/Visual").get_meta("animal_species") == &"cat"
),
"Player should use the same readable multi-part visual language as villagers"
"Player should use the reference cat in the shared modular visual language"
)
_check(
(
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/character_workshop.gd" id="1"]
[node name="CharacterWorkshop" type="Node3D"]
script = ExtResource("1")
+533
View File
@@ -0,0 +1,533 @@
"""Blender 5.1: sculpt the modular woodland traveler kit and editable preview.
Run Blender --background --python tools/art/build_character_kit.py.
Coordinates are Godot Y-up, facing +Z. Every mesh has an explicit socket origin.
UV2.x carries a palette role; vertex color is the original Blender preview paint.
The Stout shape key changes the lower torso, including clothing and bag straps.
"""
import json
import math
from pathlib import Path
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "art/characters"
OUTPUT = ROOT / "assets/characters"
SOURCE.mkdir(parents=True, exist_ok=True)
OUTPUT.mkdir(parents=True, exist_ok=True)
(SOURCE / ".gdignore").write_text("")
PALETTE = {
"fur": (1, "c78a4e"), "muzzle": (2, "eee4d1"),
"coat": (3, "496a9d"), "linen": (4, "efe6d4"),
"scarf": (5, "7295bf"), "trousers": (6, "7995b6"),
"leather": (7, "6f4937"), "fur_shadow": (8, "805032"),
"coat_shadow": (9, "304766"), "ink": (0, "3e352d"),
"pink": (0, "bd8674"), "brass": (0, "b99c70"),
"ivory": (0, "f9f1dc"), "iris": (0, "6d8999"),
}
PIECES = {}
LIBRARY = {}
PIVOTS = {}
def coord(point):
return Vector((point[0], -point[2], point[1]))
def linear(hex_color):
channels = [int(hex_color[i:i + 2], 16) / 255 for i in (0, 2, 4)]
return tuple(v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in channels)
def paint(obj, part, role):
role_id, color = PALETTE[role]
rgb = linear(color)
attr = obj.data.color_attributes.new(name="Color", type="FLOAT_COLOR", domain="CORNER")
obj.data.uv_layers.new(name="UVMap")
role_uv = obj.data.uv_layers.new(name="PaletteRole")
for poly in obj.data.polygons:
poly.use_smooth = True
for index in poly.loop_indices:
attr.data[index].color = (*rgb, 1.0)
role_uv.data[index].uv = (role_id, 0)
obj.data.materials.append(bpy.data.materials["TravelerPalette"])
PIECES.setdefault(part, []).append(obj)
return obj
def mesh_object(part, vertices, faces, role):
mesh = bpy.data.meshes.new(part + "_surface")
mesh.from_pydata([coord(v) for v in vertices], [], faces)
mesh.update()
obj = bpy.data.objects.new(part + "_detail", mesh)
bpy.context.collection.objects.link(obj)
return paint(obj, part, role)
def ellipsoid(part, center, radius, role, segments=20, rings=12):
bpy.ops.mesh.primitive_uv_sphere_add(segments=segments, ring_count=rings, location=coord(center))
obj = bpy.context.object
obj.scale = (radius[0], radius[2], radius[1])
return paint(obj, part, role)
def rounded_box(part, center, size, radius, role, tilt=0):
bpy.ops.mesh.primitive_cube_add(size=1, location=coord(center))
obj = bpy.context.object
obj.scale = (size[0], size[2], size[1])
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
bevel = obj.modifiers.new("Tailored rounded corners", "BEVEL")
bevel.width = radius
bevel.segments = 3
bpy.ops.object.modifier_apply(modifier=bevel.name)
obj.rotation_euler.z = math.radians(tilt)
return paint(obj, part, role)
def tube(part, points, radii, role, sides=8, flatten=1.0):
verts, faces = [], []
for i, point in enumerate(points):
p = Vector(point)
tangent = Vector(points[min(i + 1, len(points) - 1)]) - Vector(points[max(0, i - 1)])
tangent.normalize()
side = tangent.cross(Vector((0, 0, 1)))
if side.length < 0.01:
side = tangent.cross(Vector((1, 0, 0)))
side.normalize()
other = tangent.cross(side).normalized()
radius = radii[i] if isinstance(radii, (list, tuple)) else radii
for j in range(sides):
a = math.tau * j / sides
verts.append(p + radius * (math.cos(a) * side + math.sin(a) * other * flatten))
if i:
for j in range(sides):
a, b = (i - 1) * sides + j, (i - 1) * sides + (j + 1) % sides
faces.append((a, b, b + sides, a + sides))
faces.extend([tuple(reversed(range(sides))), tuple(range(len(verts) - sides, len(verts)))])
return mesh_object(part, verts, faces, role)
def loft(part, rows, role, segments=28, start=0, span=math.tau):
"""Tailored cross sections: (height, half width, half depth, center x, center z)."""
verts, faces = [], []
closed = abs(span - math.tau) < 0.001
count = segments if closed else segments + 1
for y, rx, rz, cx, cz in rows:
for j in range(count):
angle = start + span * j / segments
verts.append((cx + rx * math.cos(angle), y, cz + rz * math.sin(angle)))
for row in range(len(rows) - 1):
for j in range(segments):
a, b = row * count + j, row * count + (j + 1) % count
faces.append((a, a + count, b + count, b))
if closed:
faces.extend([tuple(range(count)), tuple(reversed(range(len(verts) - count, len(verts))))])
return mesh_object(part, verts, faces, role)
def finish_part(name, pivot, stout=False):
pieces = PIECES.pop(name)
bpy.ops.object.select_all(action="DESELECT")
for obj in pieces:
obj.select_set(True)
bpy.context.view_layer.objects.active = pieces[0]
bpy.ops.object.join()
obj = bpy.context.object
obj.name = name
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
bpy.context.scene.cursor.location = coord(pivot)
bpy.ops.object.origin_set(type="ORIGIN_CURSOR")
obj.data.materials.clear()
obj.data.materials.append(bpy.data.materials["TravelerPalette"])
for face in obj.data.polygons:
face.material_index = 0
if stout:
obj.shape_key_add(name="Basis")
key = obj.shape_key_add(name="Stout")
for point in key.data:
world_y = point.co.z + pivot[1]
belly = math.exp(-((world_y - 0.97) / 0.42) ** 2)
world_x = point.co.x + pivot[0]
world_z = -point.co.y + pivot[2]
point.co.x += world_x * (0.13 + 0.32 * belly)
point.co.y -= world_z * (0.14 + 0.42 * belly)
key.value = 0.0
obj.data.calc_loop_triangles()
LIBRARY[name] = obj
PIVOTS[name] = pivot
return obj
def torso(style):
part = "Torso" + style
rows = [(0.66, .32, .24, 0, 0), (.75, .39, .29, 0, 0),
(.90, .405, .31, 0, .01), (1.10, .385, .30, 0, .01),
(1.30, .325, .26, 0, 0), (1.43, .27, .20, 0, 0), (1.51, .18, .15, 0, 0)]
loft(part, rows, "linen")
loft(part, [(1.44, .17, .14, 0, 0), (1.63, .18, .15, 0, 0),
(1.73, .21, .16, 0, 0)], "fur", 24)
for y, z in [(1.03, .325), (1.15, .310), (1.27, .279)]:
ellipsoid(part, (0, y, z), (.014, .014, .011), "leather", 10, 6)
tube(part, [(-.043, .94, .327), (-.043, 1.14, .309), (-.043, 1.36, .235)], .004, "muzzle", 5)
outer = [(y, rx + .025, rz + .022, cx, cz) for y, rx, rz, cx, cz in rows]
if style == "Coat":
outer = [(0.45, .355, .26, 0, -.01), (.57, .415, .30, 0, -.01)] + outer
if style == "Tunic":
loft(part, [(y, rx + .018, rz + .018, cx, cz) for y, rx, rz, cx, cz in rows[:-1]], "coat")
rounded_box(part, (0, .91, .365), (.40, .42, .028), .025, "linen")
tube(part, [(-.19, 1.13, .315), (0, 1.15, .338), (.19, 1.13, .315)], .012, "coat_shadow", 6)
else:
gap = .31
loft(part, outer, "coat", start=math.pi / 2 + gap, span=math.tau - 2 * gap)
for sign in (-1, 1):
edge = [(sign * rx * math.sin(gap), y, cz + rz * math.cos(gap)) for y, rx, rz, cx, cz in outer]
tube(part, edge, .012, "coat_shadow", 6)
rounded_box(part, (sign * .275, .835, .272), (.19, .17, .039), .027, "coat", tilt=sign * 17)
tube(part, [(sign * .18, .91, .315), (sign * .27, .914, .308), (sign * .36, .907, .259)], .006, "coat_shadow", 5)
for y, z in [(.79, .310), (.97, .331), (1.16, .309)]:
ellipsoid(part, (.15, y, z), (.025, .026, .013), "ivory", 12, 8)
ellipsoid(part, (.15, y, z + .012), (.009, .008, .004), "coat_shadow", 8, 4)
# A stitched waistband and buckle sit over the trouser waist.
loft(part, [(.68, .343, .252, 0, .0), (.727, .369, .277, 0, .0)], "leather")
rounded_box(part, (0, .703, .291), (.115, .060, .022), .010, "brass")
rounded_box(part, (0, .703, .307), (.076, .035, .008), .004, "leather")
for sign in (-1, 1):
mesh_object(part, [(sign * .05, 1.47, .17), (sign * .20, 1.46, .17),
(sign * .24, 1.32, .235), (sign * .11, 1.38, .25)],
[(0, 1, 2, 3)] if sign > 0 else [(3, 2, 1, 0)], "linen")
finish_part(part, (0, 1.05, 0), True)
def arm(side, bent):
sign = -1 if side == "Left" else 1
x = sign * .44
name = "Arm" + ("Hiker" if bent else "Relaxed") + side
if bent:
points = [(x, 1.35, 0), (x + sign * .12, 1.18, .07),
(x + sign * .10, 1.05, .20), (x - sign * .035, 1.14, .32)]
hand = (x - sign * .055, 1.20, .33)
tube(name, points, [.135, .17, .165, .12], "linen", 18)
ellipsoid(name, points[0], (.14, .145, .14), "linen", 20, 12)
ellipsoid(name, points[2], (.165, .14, .15), "linen")
ellipsoid(name, points[-1], (.135, .082, .13), "muzzle")
else:
loft(name, [(y, rx, rz, x + sign * offset, z) for y, rx, rz, offset, z in [
(.78, .12, .13, .025, .06), (.88, .135, .15, .035, .03),
(1.03, .17, .16, .045, .005), (1.21, .16, .145, .025, 0),
(1.35, .12, .12, 0, 0), (1.41, .055, .07, -.025, 0)]], "linen", 20)
loft(name, [(.78, .133, .141, x + sign * .025, .06),
(.84, .143, .15, x + sign * .028, .052)], "muzzle", 20)
hand = (x + sign * .03, .694, .065)
ellipsoid(name, hand, (.12, .12, .105), "fur")
ellipsoid(name, (hand[0] - sign * .105, hand[1] + .023, hand[2] + .035), (.051, .066, .062), "fur", 14, 8)
for index in (-1, 0, 1):
tube(name, [(hand[0] + index * .042, hand[1] - .041, hand[2] + .096),
(hand[0] + index * .042, hand[1] - .070, hand[2] + .08)], .004, "fur_shadow", 5)
finish_part(name, (x, 1.30, 0))
def leg(side):
x = -.20 if side == "Left" else .20
name = "Leg" + side
loft(name, [(.20, .115, .13, x, .01), (.32, .157, .15, x, 0),
(.43, .17, .16, x, -.01), (.59, .18, .17, x, -.01),
(.75, .18, .165, x, 0)], "trousers", 20)
tube(name, [(x + .15, .35, .06), (x + .16, .47, .07), (x + .15, .66, .07)], .005, "coat_shadow", 5)
rounded_box(name, (x, .035, .095), (.335, .056, .57), .025, "ink")
ellipsoid(name, (x, .129, .12), (.167, .105, .284), "leather")
loft(name, [(.13, .142, .15, x, -.01), (.24, .137, .136, x, -.03),
(.33, .155, .143, x, -.03)], "leather", 20)
loft(name, [(.294, .163, .151, x, -.03), (.34, .161, .148, x, -.03)], "fur_shadow", 20)
tube(name, [(x - .075, .21, .24), (x, .228, .26), (x + .075, .21, .24)], .008, "brass", 6)
finish_part(name, (x, .61, 0))
def head(species):
name = "Head" + species
width = {"Cat": .49, "Fox": .45, "Rabbit": .445, "Badger": .51, "Otter": .485}[species]
depth = .34
verts, faces = [], []
rings, segments = 20, 40
for row in range(rings + 1):
phi = math.pi * row / rings
vertical = math.cos(phi)
cheek = 1.0 + .14 * math.exp(-((vertical + .22) / .49) ** 2)
for j in range(segments):
angle = math.tau * j / segments
verts.append((width * math.sin(phi) * math.cos(angle) * cheek,
1.93 + .435 * vertical,
depth * math.sin(phi) * math.sin(angle)))
for row in range(rings):
for j in range(segments):
a, b = row * segments + j, row * segments + (j + 1) % segments
faces.append((b, b + segments, a + segments, a))
obj = mesh_object(name, verts, faces, "fur")
colors = obj.data.color_attributes["Color"]
uv = obj.data.uv_layers["PaletteRole"]
for polygon in obj.data.polygons:
center = sum((Vector(verts[i]) for i in polygon.vertices), Vector()) / len(polygon.vertices)
role = "fur"
if center.z > .03 and center.y < 1.985 - .18 * math.exp(-(center.x / .15) ** 2):
role = "muzzle"
if species == "Badger" and center.z > .08 and .105 < abs(center.x) < .32 and center.y > 1.80:
role = "fur_shadow"
for index in polygon.loop_indices:
colors.data[index].color = (*linear(PALETTE[role][1]), 1)
uv.data[index].uv = (PALETTE[role][0], 0)
muzzle_depth = .20 if species == "Fox" else .11
muzzle_z = .335 if species == "Fox" else .315
for sign in (-1, 1):
ellipsoid(name, (sign * .105, 1.843, muzzle_z), (.15, .105, muzzle_depth), "muzzle", 20, 12)
nose_z = muzzle_z + muzzle_depth * .91
rounded_box(name, (0, 1.927, nose_z), (.096, .054, .055), .016, "fur_shadow" if species != "Rabbit" else "pink")
tube(name, [(0, 1.90, nose_z + .024), (0, 1.854, nose_z + .007)], .007, "ink", 6)
for sign in (-1, 1):
tube(name, [(0, 1.854, nose_z + .007), (sign * .065, 1.825, nose_z - .005),
(sign * .135, 1.839, nose_z - .040), (sign * .17, 1.877, nose_z - .064)], [.006, .007, .006, .002], "ink", 6)
eye_x = sign * .213
# Almond eyes with lids and a visible iris, seated into the face.
ellipsoid(name, (eye_x, 2.077, .301), (.109, .055, .043), "fur_shadow", 24, 12)
ellipsoid(name, (eye_x, 2.075, .322), (.098, .041, .030), "ivory", 24, 12)
ellipsoid(name, (eye_x - sign * .011, 2.075, .350), (.035, .039, .011), "iris", 16, 10)
ellipsoid(name, (eye_x - sign * .011, 2.076, .359), (.016, .031, .008), "ink", 12, 8)
ellipsoid(name, (eye_x - sign * .022, 2.094, .365), (.009, .011, .005), "ivory", 8, 6)
tube(name, [(eye_x - .101, 2.076, .318), (eye_x - .04, 2.105, .35),
(eye_x + .035, 2.102, .355), (eye_x + .102, 2.073, .312)], [.005, .012, .012, .003], "fur_shadow", 7)
tube(name, [(eye_x - .079, 2.226 - sign * .013, .27),
(eye_x, 2.242, .288), (eye_x + .067, 2.213 + sign * .007, .259)], [.009, .020, .007], "fur_shadow", 8)
for j in range(3):
y = 1.86 + j * .033
tube(name, [(sign * .26, y, .285), (sign * .47, y + (j - 1) * .025, .28),
(sign * (.64 - j * .015), y + (j - 1) * .055, .24)], [.005, .0035, .001], "fur_shadow", 5)
for x, y in [(0.20, 1.92), (.265, 1.88), (.22, 1.84)]:
ellipsoid(name, (sign * x, y, .339), (.007, .007, .005), "fur_shadow", 8, 4)
if species in ("Cat", "Fox"):
for sign in (-1, 0, 1):
tube(name, [(sign * .115, 2.327, .10), (sign * .112, 2.296, .185),
(sign * .09, 2.248, .248)], [.019, .024, .003], "fur_shadow", 7)
# Small swept fur tufts keep the head silhouette organic.
for sign in (-1, 1):
tube(name, [(sign * .41, 1.88, .06), (sign * .53, 1.91, .04),
(sign * .57, 1.94, .02)], [.075, .035, .001], "fur", 8)
finish_part(name, (0, 1.68, 0))
def ears(style):
name = "Ears" + style
for sign in (-1, 1):
if style in ("Cat", "Fox"):
height = .38 if style == "Cat" else .46
x = sign * .34
tube(name, [(x, 2.205, -.02), (x + sign * .05, 2.38, -.025),
(x + sign * .092, 2.205 + height, -.04)], [.165, .105, .004], "fur", 12, .43)
mesh_object(name, [(x - sign * .104, 2.22, .082), (x + sign * .127, 2.245, .070),
(x + sign * .081, 2.205 + height - .051, -.015)],
[(0, 1, 2)] if sign > 0 else [(2, 1, 0)], "muzzle")
tube(name, [(x, 2.25, .105), (x + sign * .029, 2.31, .075),
(x + sign * .053, 2.365, .035)], [.028, .020, .002], "pink", 6)
elif style in ("Rabbit", "Lop"):
x = sign * .25
points = [(x, 2.23, -.025), (x + sign * .02, 2.45, -.04),
(x + sign * .07, 2.73, -.09), (x + sign * .10, 2.87, -.12)]
if style == "Lop":
points = [(x, 2.23, -.025), (x + sign * .12, 2.49, -.04),
(x + sign * .25, 2.51, -.03), (x + sign * .31, 2.24, .015)]
tube(name, points, [.105, .123, .103, .008], "fur", 14)
tube(name, [(p[0], p[1], p[2] + .085) for p in points[1:]], [.060, .055, .006], "pink", 10)
else:
x = sign * .39
radius = .145 if style == "Badger" else .12
ellipsoid(name, (x, 2.265, -.015), (radius, radius * 1.05, .09), "fur", 20, 12)
ellipsoid(name, (x, 2.27, .066), (radius * .62, radius * .63, .02), "pink", 16, 10)
finish_part(name, (0, 2.20, 0))
def tail(species):
name = "Tail" + species
if species == "Rabbit":
ellipsoid(name, (0, .70, -.38), (.19, .19, .18), "muzzle")
else:
points = [(0, .80, -.22), (.17, .58, -.47), (.43, .62, -.62),
(.66, .91, -.61), (.71, 1.21, -.48), (.63, 1.43, -.37)]
radii = [.065, .09, .125, .14, .12, .002]
if species == "Fox":
radii = [.095, .17, .24, .25, .19, .002]
if species == "Badger":
points, radii = [(0, .8, -.2), (.08, .56, -.43), (.15, .52, -.63)], [.07, .14, .003]
if species == "Otter":
points, radii = [(0, .8, -.2), (.05, .48, -.51), (.29, .24, -.80), (.51, .23, -.83)], [.13, .14, .10, .003]
# Subdivide the sweep with Catmull-Rom interpolation for a soft, curling silhouette.
smooth_points, smooth_radii = [], []
for i in range(len(points) - 1):
p0, p1 = Vector(points[max(i - 1, 0)]), Vector(points[i])
p2, p3 = Vector(points[i + 1]), Vector(points[min(i + 2, len(points) - 1)])
for step in range(4):
t = step / 4
smooth_points.append(.5 * ((2 * p1) + (-p0 + p2) * t +
(2*p0 - 5*p1 + 4*p2 - p3)*t*t + (-p0 + 3*p1 - 3*p2 + p3)*t*t*t))
smooth_radii.append(radii[i] * (1 - t) + radii[i + 1] * t)
smooth_points.append(Vector(points[-1]))
smooth_radii.append(radii[-1])
tube(name, smooth_points, smooth_radii, "fur", 14)
if species == "Fox":
tube(name, points[-2:], [radii[-2] * 1.01, .002], "muzzle", 14)
finish_part(name, (0, .8, -.22))
def accessories():
name = "Scarf"
tube(name, [(-.19, 1.48, -.1), (-.23, 1.43, .06), (-.15, 1.41, .19),
(0, 1.395, .24), (.18, 1.43, .13), (.20, 1.48, -.10)], .072, "scarf", 12)
rounded_box(name, (-.17, 1.31, .269), (.145, .21, .062), .032, "scarf", tilt=-12)
mesh_object(name, [(-.22, 1.35, .30), (-.10, 1.32, .32), (-.11, .68, .35),
(-.20, .74, .355), (-.25, .67, .347), (-.27, 1.09, .35)], [(0, 5, 4, 3, 2, 1)], "scarf")
for x in (-.23, -.20, -.17, -.14):
tube(name, [(x, .73, .35), (x - .01, .62, .36)], [.008, .003], "scarf", 5)
finish_part(name, (0, 1.05, 0), True)
name = "BagTravel"
rounded_box(name, (0, 1.24, -.46), (.67, 1.04, .39), .12, "leather")
rounded_box(name, (0, 1.63, -.58), (.72, .32, .25), .085, "fur_shadow")
for sign in (-1, 1):
tube(name, [(sign * .23, 1.71, -.52), (sign * .29, 1.54, -.18),
(sign * .275, 1.36, .20), (sign * .32, 1.05, .292),
(sign * .37, .81, .15), (sign * .29, .76, -.37)], .032, "leather", 8)
rounded_box(name, (sign * .27, 1.16, .311), (.081, .10, .031), .014, "brass")
rounded_box(name, (sign * .345, 1.10, -.44), (.145, .35, .255), .040, "leather")
tube(name, [(sign * .21, .85, -.672), (sign * .21, 1.3, -.677),
(sign * .21, 1.67, -.714)], .021, "fur_shadow", 7)
tube(name, [(-.43, 1.88, -.50), (.43, 1.88, -.50)], .215, "scarf", 28)
for sign in (-1, 1):
points = [(sign * .434, 1.88 + .18 * (1 - t / 34) * math.sin(t * .42),
-.50 + .18 * (1 - t / 34) * math.cos(t * .42)) for t in range(33)]
tube(name, points, .008, "coat_shadow", 5)
ring = [(sign * .27, 1.88 + .223 * math.sin(t * math.tau / 24),
-.50 + .223 * math.cos(t * math.tau / 24)) for t in range(25)]
tube(name, ring, .018, "leather", 6)
finish_part(name, (0, 1.05, 0), True)
name = "BagSatchel"
rounded_box(name, (.46, .80, .045), (.28, .35, .25), .07, "leather")
rounded_box(name, (.47, .90, .16), (.29, .19, .07), .025, "fur_shadow")
rounded_box(name, (.48, .85, .206), (.052, .076, .013), .008, "brass")
tube(name, [(-.27, 1.46, -.05), (-.23, 1.40, .20), (.03, 1.15, .33),
(.30, .90, .30), (.46, .88, .03)], .027, "leather", 8)
finish_part(name, (0, 1.05, 0), True)
name = "HatBeret"
ellipsoid(name, (.035, 2.32, -.02), (.44, .155, .325), "coat", 28, 14)
loft(name, [(2.255, .385, .277, 0, -.02), (2.31, .392, .285, 0, -.02)], "coat_shadow")
tube(name, [(0, 2.445, -.01), (.027, 2.48, -.012), (.051, 2.479, -.025)], .021, "coat_shadow")
finish_part(name, (0, 1.9, 0))
name = "HatBrim"
ellipsoid(name, (0, 2.285, -.025), (.60, .045, .415), "leather", 36, 10)
loft(name, [(2.29, .39, .28, 0, -.025), (2.43, .365, .26, 0, -.035),
(2.55, .28, .215, -.035, -.035), (2.59, .21, .17, -.04, -.035)], "leather")
loft(name, [(2.31, .396, .285, 0, -.025), (2.367, .386, .277, 0, -.03)], "scarf")
finish_part(name, (0, 1.9, 0))
name = "Spectacles"
for sign in (-1, 1):
points = [(sign * .213 + .127 * math.cos(t * math.tau / 28),
2.073 + .099 * math.sin(t * math.tau / 28), .375) for t in range(29)]
tube(name, points, .008, "brass", 5)
tube(name, [(sign * .335, 2.08, .375), (sign * .46, 2.10, .16)], .008, "leather", 6)
tube(name, [(-.086, 2.081, .375), (0, 2.109, .402), (.086, 2.081, .375)], .009, "brass", 6)
finish_part(name, (0, 1.68, 0))
def export_and_preview():
bpy.ops.object.select_all(action="DESELECT")
for obj in LIBRARY.values():
obj.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(OUTPUT / "woodland_parts.glb"), export_format="GLB",
use_selection=True, export_yup=True, export_animations=False,
export_morph=True, export_morph_normal=True,
export_vertex_color="ACTIVE", export_materials="EXPORT",
export_cameras=False, export_extras=False)
budget = {}
for name, obj in LIBRARY.items():
budget[name] = {"triangles": len(obj.data.loop_triangles), "surfaces": 1,
"pivot": PIVOTS[name], "stout_shape": obj.data.shape_keys is not None}
(OUTPUT / "mesh_budget.json").write_text(json.dumps(budget, indent=2) + "\n")
# The saved file opens on the reference traveler; the complete kit stays editable.
preview = bpy.data.collections.new("01 Reference traveler - assembled example")
bpy.context.scene.collection.children.link(preview)
selected = ["TorsoVest", "HeadCat", "EarsCat", "ArmHikerLeft", "ArmHikerRight",
"LegLeft", "LegRight", "TailCat", "Scarf", "BagTravel"]
for name, source in LIBRARY.items():
source.hide_render = True
source.hide_set(True)
if name not in selected:
continue
copy = source.copy()
copy.data = source.data.copy()
copy.name = "Preview_" + name
preview.objects.link(copy)
copy.hide_render = False
copy.hide_set(False)
if copy.data.shape_keys:
copy.data.shape_keys.key_blocks["Stout"].value = .68
if name.startswith("Arm"):
copy.location.x += (-1 if "Left" in name else 1) * .068
copy.scale.x = 1.1
if name.startswith("Leg"):
copy.location.x += (-1 if "Left" in name else 1) * .034
copy.scale.x = 1.14
scene = bpy.context.scene
scene.world.color = (.32, .36, .40)
scene.render.engine = "CYCLES"
scene.cycles.samples = 32
for position, energy, size in [((3, 5, 4), 450, 5), ((-3, 3, 1), 220, 4), ((1, 4, -3), 500, 3)]:
bpy.ops.object.light_add(type="AREA", location=coord(position))
light = bpy.context.object
light.data.energy = energy
light.data.shape = "DISK"
light.data.size = size
light.rotation_euler = (coord((0, 1.25, 0)) - light.location).to_track_quat('-Z', 'Y').to_euler()
bpy.ops.object.camera_add(location=coord((3.2, 2.6, 5.8)))
camera = bpy.context.object
camera.rotation_euler = (coord((0, 1.3, 0)) - camera.location).to_track_quat('-Z', 'Y').to_euler()
camera.data.type = "ORTHO"
camera.data.ortho_scale = 3.6
scene.camera = camera
scene.render.resolution_x = 1000
scene.render.resolution_y = 1100
scene.render.resolution_percentage = 100
for screen in bpy.data.screens:
for area in screen.areas:
if area.type == "VIEW_3D":
area.spaces.active.shading.type = "MATERIAL"
area.spaces.active.region_3d.view_distance = 4.0
area.spaces.active.region_3d.view_location = coord((0, 1.3, 0))
bpy.context.preferences.filepaths.save_version = 0
bpy.ops.wm.save_as_mainfile(filepath=str(SOURCE / "woodland_character_kit.blend"), compress=True)
print("CHARACTER KIT", len(LIBRARY), "parts", sum(v["triangles"] for v in budget.values()), "library triangles")
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
material = bpy.data.materials.new("TravelerPalette")
material.use_nodes = True
tree = material.node_tree
shader = tree.nodes.get("Principled BSDF")
shader.inputs["Roughness"].default_value = .9
vertex = tree.nodes.new("ShaderNodeVertexColor")
vertex.layer_name = "Color"
tree.links.new(vertex.outputs["Color"], shader.inputs["Base Color"])
for style in ("Vest", "Coat", "Tunic"):
torso(style)
for side in ("Left", "Right"):
arm(side, False)
arm(side, True)
leg(side)
for species in ("Cat", "Fox", "Rabbit", "Badger", "Otter"):
head(species)
ears(species)
tail(species)
ears("Lop")
accessories()
export_and_preview()
+100
View File
@@ -0,0 +1,100 @@
extends SceneTree
## Native-renderer proof of the same modular meshes and profiles used in gameplay.
const OUTPUT := "res://docs/baselines/"
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1600, 1000)
if OS.get_cmdline_user_args().has("--world"):
await _capture_world()
quit()
return
var workshop := load("res://tools/CharacterWorkshop.tscn").instantiate() as Node3D
root.add_child(workshop)
for frame in 30:
await process_frame
await _save("character_workshop")
workshop.ui.hide()
workshop.set_process(false)
var actor: Node3D = workshop.actor
actor.position.x = 0
actor.rotation.y = 0
var camera: Camera3D = workshop.camera
camera.position = Vector3(3.1, 2.4, 6.0)
camera.look_at(Vector3(0, 1.3, 0))
camera.size = 3.15
await _save("character_traveler")
camera.position = Vector3(-3.5, 2.8, -6.0)
camera.look_at(Vector3(0, 1.3, 0))
await _save("character_traveler_back")
actor.hide()
for index in workshop.PRESETS.size():
var person := load("res://player/PlayerVisual.tscn").instantiate() as Node3D
workshop.add_child(person)
person.set_process(false)
person.get_node("Sword").hide()
var profile: CharacterAppearanceProfile = load(
workshop.PROFILE_DIR + workshop.PRESETS[index] + ".tres"
)
AnimalAppearance.apply_to(person, 0, false, profile)
person.position.x = (float(index) - 2.5) * 1.7
person.rotation.y = -0.2
camera.position = Vector3(0, 2.8, 13.0)
camera.look_at(Vector3(0, 1.35, 0))
camera.size = 7.2
await _save("character_variations")
workshop.queue_free()
await process_frame
quit()
func _capture_world() -> void:
var main := load("res://main.tscn").instantiate() as Node3D
main.get_node("SimulationManager").set("debug_logs", false)
root.add_child(main)
await process_frame
main.get_node("SimulationManager").set_process(false)
var cycle := main.get_node("JajceWorld/DayNightCycle")
cycle.set_process(false)
cycle.call("_apply_light_rotation", 0.40)
cycle.call("_apply_environment", 0.40)
var demo := main.get_node("DemoController")
if demo.get("debug_overlay_visible"):
demo.call("toggle_debug_overlay")
for child in main.get_children():
if child is CanvasLayer:
child.hide()
var rig := main.get_node("CameraRig") as Node3D
rig.set_physics_process(false)
rig.set_process_unhandled_input(false)
var player := main.get_node("Player") as CharacterBody3D
for frame in 30:
await process_frame
player.set_physics_process(false)
var camera := root.get_camera_3d()
camera.reparent(main)
camera.global_position = player.global_position + Vector3(7.5, 5.5, 10.5)
camera.look_at(player.global_position + Vector3(0, 1.25, 0))
camera.fov = 48.0
await _save("character_gameplay")
main.queue_free()
await process_frame
func _save(name_stem: String) -> void:
for frame in 10:
await process_frame
await RenderingServer.frame_post_draw
var suffix := (
"_compatibility"
if RenderingServer.get_current_rendering_method() == "gl_compatibility"
else ""
)
var path := OUTPUT + name_stem + suffix + ".png"
assert(root.get_texture().get_image().save_png(path) == OK)
print("[TOOL] Saved " + path)
+1
View File
@@ -0,0 +1 @@
uid://clp5w5h4p00pe
+269
View File
@@ -0,0 +1,269 @@
extends Node3D
## Run CharacterWorkshop.tscn to combine parts, inspect motion, and save authored profiles.
const PRESETS: Array[String] = [
"reference_cat",
"fox_rambler",
"rabbit_scholar",
"badger_baker",
"otter_courier",
"lop_gardener"
]
const PROFILE_DIR := "res://assets/characters/profiles/"
var profile: CharacterAppearanceProfile
var actor: Node3D
var camera: Camera3D
var controls: VBoxContainer
var ui: CanvasLayer
var walk := false
var phase := 0.0
var turn := -0.32
var dragging := false
var status: Label
func _ready() -> void:
_build_stage()
_build_ui()
_select_preset(0)
func _process(delta: float) -> void:
actor.rotation.y = turn
phase += delta * 5.0
var swing := sin(phase) if walk else 0.0
for side in ["Left", "Right"]:
var direction := -1.0 if side == "Left" else 1.0
actor.get_node("Leg" + side).rotation.x = swing * direction * 0.44
actor.get_node("Arm" + side).rotation.x = (
-swing * direction * 0.30 * float(actor.get_meta("arm_swing", 1.0))
)
var rest := AnimalAppearance.rest_heights(actor)
var bob := absf(sin(phase * 2.0)) * 0.035 if walk else 0.0
actor.get_node("Body").position.y = rest.x + bob
actor.get_node("Head").position.y = rest.y + bob
actor.get_node("Hair").position.y = rest.z + bob
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
dragging = event.pressed
if event is InputEventMouseMotion and dragging:
turn += event.relative.x * 0.008
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
camera.size = maxf(2.8, camera.size - 0.2)
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
camera.size = minf(6.0, camera.size + 0.2)
func _build_stage() -> void:
var environment := WorldEnvironment.new()
var env := Environment.new()
environment.environment = env
env.background_mode = Environment.BG_COLOR
env.background_color = Color("e2e4d8")
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color("d0ddd8")
env.ambient_light_energy = 0.38
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
# Calibrated for this preview stage to retain cream fabric highlights on GLES.
if RenderingServer.get_current_rendering_method() == "gl_compatibility":
env.tonemap_exposure = 0.25
add_child(environment)
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-38, -32, 0)
light.light_color = Color("fff6e8")
light.light_energy = 1.1
light.shadow_enabled = true
add_child(light)
var floor_mesh := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = Vector2(200, 200)
var material := StandardMaterial3D.new()
material.albedo_color = Color("d0d6c3")
material.roughness = 1.0
plane.material = material
floor_mesh.mesh = plane
add_child(floor_mesh)
actor = load("res://player/PlayerVisual.tscn").instantiate()
add_child(actor)
actor.set_process(false)
actor.get_node("Sword").hide()
actor.position.x = 0.80
camera = Camera3D.new()
add_child(camera)
camera.position = Vector3(0, 2.45, 7)
camera.look_at(Vector3(0, 1.36, 0))
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
camera.size = 3.9
camera.current = true
get_viewport().msaa_3d = Viewport.MSAA_4X
func _build_ui() -> void:
ui = CanvasLayer.new()
add_child(ui)
var panel := PanelContainer.new()
panel.set_anchors_and_offsets_preset(Control.PRESET_LEFT_WIDE)
panel.offset_right = 320
var theme := Theme.new()
theme.default_font_size = 15
theme.set_color("font_color", "Label", Color("37433a"))
theme.set_color("font_color", "CheckButton", Color("37433a"))
theme.set_color("font_pressed_color", "CheckButton", Color("37433a"))
theme.set_color("font_hover_color", "CheckButton", Color("37433a"))
theme.set_color("font_hover_pressed_color", "CheckButton", Color("37433a"))
for type in ["Button", "OptionButton"]:
for state in ["normal", "hover", "pressed", "focus"]:
var button_style := StyleBoxFlat.new()
button_style.bg_color = Color("d6dccd") if state == "hover" else Color("e2e4da")
button_style.content_margin_left = 8
button_style.content_margin_right = 12
button_style.content_margin_top = 6
button_style.content_margin_bottom = 6
theme.set_stylebox(state, type, button_style)
for state in ["font_color", "font_hover_color", "font_pressed_color", "font_focus_color"]:
theme.set_color(state, type, Color("37433a"))
panel.theme = theme
var paper := StyleBoxFlat.new()
paper.bg_color = Color("f2f0e7")
paper.content_margin_left = 24
paper.content_margin_right = 24
paper.content_margin_top = 24
paper.content_margin_bottom = 20
panel.add_theme_stylebox_override("panel", paper)
ui.add_child(panel)
var scroll := ScrollContainer.new()
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
panel.add_child(scroll)
controls = VBoxContainer.new()
controls.size_flags_horizontal = Control.SIZE_EXPAND_FILL
controls.add_theme_constant_override("separation", 10)
scroll.add_child(controls)
func _select_preset(index: int) -> void:
profile = load(PROFILE_DIR + PRESETS[index] + ".tres").duplicate()
_rebuild_controls(index)
_apply()
func _rebuild_controls(selected: int) -> void:
for child in controls.get_children():
controls.remove_child(child)
child.queue_free()
_label("THE WARDROBE", 23)
_label("Woodland travelers", 16)
var presets := OptionButton.new()
for preset in PRESETS:
presets.add_item(preset.replace("_", " ").capitalize())
presets.selected = selected
presets.item_selected.connect(_select_preset)
controls.add_child(presets)
_select("Species", "species", ["Cat", "Fox", "Rabbit", "Badger", "Otter"])
_slider("Body fullness", "body_fullness", 0.0, 1.0)
_slider("Height", "height", 0.80, 1.15)
_slider("Head size", "head_size", 0.88, 1.12)
_slider("Leg length", "leg_length", 0.85, 1.15)
_slider("Ear length", "ear_length", 0.65, 1.20)
_select("Ears", "ears", ["Species default", "Cat", "Fox", "Rabbit", "Lop", "Badger", "Otter"])
_select("Clothes", "outfit", ["Vest", "Long coat", "Work tunic"])
_select("Arms", "arm_pose", ["Relaxed", "Holding pack straps"])
_select("Bag", "bag", ["None", "Satchel", "Travel pack"])
_select("Hat", "hat", ["None", "Beret", "Brimmed hat"])
for field in ["scarf", "spectacles"]:
var toggle := CheckButton.new()
toggle.text = field.capitalize()
toggle.button_pressed = profile.get(field)
toggle.toggled.connect(func(value: bool) -> void: _change(field, value))
controls.add_child(toggle)
for field in ["fur_color", "outfit_color", "scarf_color", "trousers_color"]:
var row := HBoxContainer.new()
controls.add_child(row)
var label := Label.new()
label.text = field.trim_suffix("_color").capitalize()
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(label)
var color := ColorPickerButton.new()
color.custom_minimum_size = Vector2(72, 28)
color.edit_alpha = false
color.color = profile.get(field)
color.color_changed.connect(func(value: Color) -> void: _change(field, value))
row.add_child(color)
var motion := CheckButton.new()
motion.text = "Walk preview"
motion.button_pressed = walk
motion.toggled.connect(func(value: bool) -> void: walk = value)
controls.add_child(motion)
var save := Button.new()
save.text = "Save profile…"
save.pressed.connect(_save_profile)
controls.add_child(save)
status = _label("Drag to turn · scroll to zoom", 13)
func _label(text: String, size: int) -> Label:
var label := Label.new()
label.text = text
label.add_theme_font_size_override("font_size", size)
controls.add_child(label)
return label
func _select(title: String, field: String, options: Array) -> void:
var row := HBoxContainer.new()
controls.add_child(row)
var label := Label.new()
label.text = title
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(label)
var select := OptionButton.new()
for option: String in options:
select.add_item(option)
select.selected = profile.get(field)
select.item_selected.connect(func(value: int) -> void: _change(field, value))
row.add_child(select)
func _slider(title: String, field: String, minimum: float, maximum: float) -> void:
var label := _label(title + " %.2f" % float(profile.get(field)), 14)
var slider := HSlider.new()
slider.min_value = minimum
slider.max_value = maximum
slider.step = 0.01
slider.value = profile.get(field)
slider.value_changed.connect(
func(value: float) -> void:
label.text = title + " %.2f" % value
_change(field, value)
)
controls.add_child(slider)
func _change(field: String, value: Variant) -> void:
profile.set(field, value)
_apply()
func _apply() -> void:
AnimalAppearance.apply_to(actor, 0, false, profile)
func _save_profile() -> void:
var dialog := FileDialog.new()
dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
dialog.access = FileDialog.ACCESS_FILESYSTEM
dialog.filters = PackedStringArray(["*.tres ; Character appearance"])
dialog.current_dir = ProjectSettings.globalize_path(PROFILE_DIR)
dialog.current_file = "custom_traveler.tres"
dialog.file_selected.connect(
func(path: String) -> void:
var error := ResourceSaver.save(profile, path)
status.text = "Profile saved." if error == OK else "Could not save profile."
dialog.queue_free()
)
dialog.canceled.connect(dialog.queue_free)
ui.add_child(dialog)
dialog.popup_centered(Vector2i(720, 500))
+1
View File
@@ -0,0 +1 @@
uid://bqd173qe4h8c1
+145 -22
View File
@@ -1,34 +1,89 @@
class_name AnimalAppearance
extends RefCounted
## Disposable woodland identities, rebuilt from stable NPC IDs. No simulation RNG.
## Shared Blender meshes, per-character palettes and shape weights; no simulation RNG.
const CEL_SHADER := preload("res://world/presentation/materials/storybook_cel.gdshader")
const SPECIES: Array[StringName] = [&"fox", &"rabbit", &"badger", &"otter"]
const MODELS: Array[PackedScene] = [
preload("res://assets/storybook/fox.glb"),
preload("res://assets/storybook/rabbit.glb"),
preload("res://assets/storybook/badger.glb"),
preload("res://assets/storybook/otter.glb"),
]
const CEL_SHADER := preload("res://world/presentation/materials/character_cel.gdshader")
const KIT := preload("res://assets/characters/woodland_parts.glb")
const PARTS: Array[StringName] = [
&"Body", &"Head", &"Hair", &"ArmLeft", &"ArmRight", &"LegLeft", &"LegRight", &"Tail"
]
static var _meshes: Dictionary[String, Mesh] = {}
static func apply_to(root: Node3D, identity: int, npc_body := false) -> void:
var index := posmod(identity, MODELS.size())
var source := MODELS[index].instantiate()
var material := ShaderMaterial.new()
material.shader = CEL_SHADER
root.set_meta("animal_species", SPECIES[index])
for part in PARTS:
var source_mesh := source.find_child(String(part), true, false) as MeshInstance3D
var target_name := "MeshInstance3D" if npc_body and part == &"Body" else String(part)
static func apply_to(
root: Node3D, identity: int, npc_body := false, authored: CharacterAppearanceProfile = null
) -> void:
_load_kit()
var profile := (
authored if authored != null else CharacterAppearanceProfile.for_identity(identity)
)
profile = profile.normalized_copy()
var material := _palette(profile)
root.set_meta("animal_species", CharacterAppearanceProfile.SPECIES[profile.species])
root.set_meta("character_appearance", profile)
var species := CharacterAppearanceProfile.PART_SPECIES[profile.species]
var arm := "Hiker" if profile.arm_pose == 1 else "Relaxed"
var choices := [
"Torso" + CharacterAppearanceProfile.OUTFITS[profile.outfit],
"Head" + species,
"Hat" + CharacterAppearanceProfile.HATS[profile.hat] if profile.hat > 0 else "",
"Arm" + arm + "Left",
"Arm" + arm + "Right",
"LegLeft",
"LegRight",
"Tail" + species,
]
var height := profile.height
var lift := 0.61 * (profile.leg_length - 1.0) * height
var shoulder := 0.44 + 0.10 * profile.body_fullness
var hip := 0.20 + 0.05 * profile.body_fullness
var pivots := [
Vector3(0, 1.05, 0),
Vector3(0, 1.68, 0),
Vector3(0, 1.68 + 0.22 * profile.head_size, 0),
Vector3(-shoulder, 1.30, 0),
Vector3(shoulder, 1.30, 0),
Vector3(-hip, 0.61, 0),
Vector3(hip, 0.61, 0),
Vector3(0, 0.80, -0.22),
]
for index in PARTS.size():
var target_name := "MeshInstance3D" if npc_body and index == 0 else String(PARTS[index])
var target := root.get_node(target_name) as MeshInstance3D
assert(source_mesh != null, "Missing Blender animation part: " + String(part))
target.mesh = source_mesh.mesh
target.material_override = material
source.free()
_set_part(target, choices[index], material, profile.body_fullness)
target.position = pivots[index] * height + Vector3.UP * lift
target.rotation = Vector3.ZERO
target.scale = Vector3.ONE * height
if index == 1 or index == 2:
target.scale *= profile.head_size
elif index == 3 or index == 4:
target.scale.x *= 1.0 + 0.15 * profile.body_fullness
elif index == 5 or index == 6:
target.scale.x *= 1.0 + 0.20 * profile.body_fullness
target.scale.y *= profile.leg_length
target.set_meta("rest_height", target.position.y)
var body := root.get_node("MeshInstance3D" if npc_body else "Body") as MeshInstance3D
_child_part(body, "Scarf", "Scarf" if profile.scarf else "", material, profile.body_fullness)
_child_part(
body,
"Bag",
"Bag" + CharacterAppearanceProfile.BAGS[profile.bag] if profile.bag > 0 else "",
material,
profile.body_fullness
)
var head := root.get_node("Head") as MeshInstance3D
var ears := _child_part(head, "Ears", "Ears" + profile.ear_part(), material)
ears.position.y = 0.52
ears.scale.y = profile.ear_length
_child_part(head, "Spectacles", "Spectacles" if profile.spectacles else "", material)
# Derived from imported bounds, including elongated ears and hats.
var top := 0.0
for part in PARTS:
var path := "MeshInstance3D" if npc_body and part == &"Body" else String(part)
top = maxf(top, _top(root.get_node(path), Transform3D.IDENTITY))
root.set_meta("character_top", top)
root.set_meta("arm_swing", 0.23 if profile.arm_pose == 1 else 1.0)
static func set_profession_color(root: Node3D, color: Color) -> void:
@@ -36,3 +91,71 @@ static func set_profession_color(root: Node3D, color: Color) -> void:
var material := body.material_override as ShaderMaterial
material.set_shader_parameter("coat_color", color)
material.set_shader_parameter("profession_tint", 0.28)
static func rest_heights(root: Node3D, npc_body := false) -> Vector3:
return Vector3(
root.get_node("MeshInstance3D" if npc_body else "Body").get_meta("rest_height"),
root.get_node("Head").get_meta("rest_height"),
root.get_node("Hair").get_meta("rest_height")
)
static func _load_kit() -> void:
if not _meshes.is_empty():
return
var source := KIT.instantiate()
for child in source.find_children("*", "MeshInstance3D", true, false):
_meshes[String(child.name)] = (child as MeshInstance3D).mesh
source.free()
static func _set_part(
target: MeshInstance3D, part: String, material: ShaderMaterial, fullness := 0.0
) -> void:
assert(part.is_empty() or _meshes.has(part), "Missing Blender character part: " + part)
target.mesh = _meshes.get(part)
target.visible = target.mesh != null
target.material_override = material
if target.mesh != null:
var shape := target.find_blend_shape_by_name("Stout")
if shape >= 0:
target.set_blend_shape_value(shape, fullness)
static func _child_part(
parent: MeshInstance3D,
node_name: String,
part: String,
material: ShaderMaterial,
fullness := 0.0
) -> MeshInstance3D:
var target := parent.get_node_or_null(node_name) as MeshInstance3D
if target == null:
target = MeshInstance3D.new()
target.name = node_name
parent.add_child(target)
_set_part(target, part, material, fullness)
target.transform = Transform3D.IDENTITY
return target
static func _palette(profile: CharacterAppearanceProfile) -> ShaderMaterial:
var material := ShaderMaterial.new()
material.shader = CEL_SHADER
for channel in ["fur", "muzzle", "outfit", "linen", "scarf", "trousers", "leather"]:
var parameter: String = channel + "_color"
material.set_shader_parameter(parameter, profile.get(parameter))
return material
static func _top(node: Node3D, parent_transform: Transform3D) -> float:
var transform := parent_transform * node.transform
var top := 0.0
if node is MeshInstance3D and node.mesh != null and node.visible:
var bounds: AABB = transform * node.get_aabb()
top = bounds.end.y
for child in node.get_children():
if child is Node3D:
top = maxf(top, _top(child, transform))
return top
@@ -0,0 +1,85 @@
@tool
class_name CharacterAppearanceProfile
extends Resource
## Authored, cosmetic building blocks. Never part of authoritative simulation saves.
const SPECIES: Array[StringName] = [&"cat", &"fox", &"rabbit", &"badger", &"otter"]
const PART_SPECIES: Array[String] = ["Cat", "Fox", "Rabbit", "Badger", "Otter"]
const OUTFITS: Array[String] = ["Vest", "Coat", "Tunic"]
const EARS: Array[String] = ["", "Cat", "Fox", "Rabbit", "Lop", "Badger", "Otter"]
const HATS: Array[String] = ["", "Beret", "Brim"]
const BAGS: Array[String] = ["", "Satchel", "Travel"]
@export_enum("Cat", "Fox", "Rabbit", "Badger", "Otter") var species := 0
@export_group("Proportions")
@export_range(0.80, 1.15, 0.01) var height := 1.0
@export_range(0.0, 1.0, 0.01) var body_fullness := 0.68
@export_range(0.88, 1.12, 0.01) var head_size := 1.0
@export_range(0.85, 1.15, 0.01) var leg_length := 1.0
@export_range(0.65, 1.20, 0.01) var ear_length := 1.0
@export_group("Building blocks")
@export_enum("Vest", "Long coat", "Work tunic") var outfit := 0
@export_enum("Relaxed", "Holding pack straps") var arm_pose := 1
@export_enum("Species default", "Cat", "Fox", "Rabbit", "Lop", "Badger", "Otter") var ears := 0
@export_enum("None", "Beret", "Brimmed hat") var hat := 0
@export_enum("None", "Satchel", "Travel pack") var bag := 2
@export var scarf := true
@export var spectacles := false
@export_group("Palette")
@export var fur_color := Color("c78a4e")
@export var muzzle_color := Color("eee4d1")
@export var outfit_color := Color("496a9d")
@export var linen_color := Color("efe6d4")
@export var scarf_color := Color("7295bf")
@export var trousers_color := Color("7995b6")
@export var leather_color := Color("6f4937")
static func for_identity(identity: int) -> CharacterAppearanceProfile:
var result := CharacterAppearanceProfile.new()
if identity == 0:
return result
# Integer arithmetic is stable across rebuilds and never consumes an RNG stream.
var seed_value := posmod(identity, 100003)
result.species = seed_value % SPECIES.size()
result.height = 0.85 + float((seed_value * 17) % 29) / 100.0
result.body_fullness = float((seed_value * 37) % 101) / 100.0
result.head_size = 0.94 + float((seed_value * 7) % 15) / 100.0
result.leg_length = 0.90 + float((seed_value * 11) % 22) / 100.0
result.ear_length = 0.80 + float((seed_value * 13) % 36) / 100.0
result.outfit = (seed_value / 3) % OUTFITS.size()
result.arm_pose = 0
result.bag = 1 if seed_value % 3 == 0 else 0
result.hat = 1 if seed_value % 4 == 0 else 0
result.scarf = seed_value % 3 != 1
result.spectacles = seed_value % 7 == 0
if result.species == 2 and seed_value % 2 == 0:
result.ears = 4
var furs := [
Color("c78a4e"), Color("b77949"), Color("c4b7a4"), Color("c9c4b5"), Color("826c55")
]
var coats := [Color("496a9d"), Color("798362"), Color("986347"), Color("686b84")]
result.fur_color = furs[result.species]
result.outfit_color = coats[(seed_value / 5) % coats.size()]
result.scarf_color = coats[(seed_value / 7 + 1) % coats.size()].lightened(0.2)
return result
func normalized_copy() -> CharacterAppearanceProfile:
var result := duplicate() as CharacterAppearanceProfile
result.species = clampi(species, 0, SPECIES.size() - 1)
result.height = clampf(height, 0.80, 1.15)
result.body_fullness = clampf(body_fullness, 0.0, 1.0)
result.head_size = clampf(head_size, 0.88, 1.12)
result.leg_length = clampf(leg_length, 0.85, 1.15)
result.ear_length = clampf(ear_length, 0.65, 1.20)
result.outfit = clampi(outfit, 0, OUTFITS.size() - 1)
result.arm_pose = clampi(arm_pose, 0, 1)
result.ears = clampi(ears, 0, EARS.size() - 1)
result.hat = clampi(hat, 0, HATS.size() - 1)
result.bag = clampi(bag, 0, BAGS.size() - 1)
return result
func ear_part() -> String:
return PART_SPECIES[species] if ears == 0 else EARS[ears]
@@ -0,0 +1 @@
uid://dnrvywwi3t8d1
@@ -0,0 +1,57 @@
shader_type spatial;
render_mode specular_disabled;
// Blender UV2.x is a discrete palette role. Every part is one opaque surface.
uniform vec4 fur_color : source_color = vec4(0.78, 0.54, 0.31, 1.0);
uniform vec4 muzzle_color : source_color = vec4(0.93, 0.89, 0.82, 1.0);
uniform vec4 outfit_color : source_color = vec4(0.29, 0.42, 0.62, 1.0);
uniform vec4 linen_color : source_color = vec4(0.94, 0.90, 0.83, 1.0);
uniform vec4 scarf_color : source_color = vec4(0.45, 0.58, 0.75, 1.0);
uniform vec4 trousers_color : source_color = vec4(0.47, 0.58, 0.71, 1.0);
uniform vec4 leather_color : source_color = vec4(0.44, 0.29, 0.22, 1.0);
uniform vec4 coat_color : source_color = vec4(1.0);
uniform float profession_tint : hint_range(0.0, 1.0) = 0.0;
vec3 paint_to_srgb(vec3 linear_color) {
return mix(12.92 * linear_color, 1.055 * pow(max(linear_color, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055,
step(vec3(0.0031308), linear_color));
}
vec3 paint_to_linear(vec3 srgb_color) {
return mix(srgb_color / 12.92, pow((srgb_color + 0.055) / 1.055, vec3(2.4)),
step(vec3(0.04045), srgb_color));
}
void fragment() {
int role = int(round(UV2.x));
vec3 coat = mix(outfit_color.rgb, coat_color.rgb, profession_tint);
// glTF vertex paint is linear; Compatibility expects fragment ALBEDO in sRGB.
vec3 paint = OUTPUT_IS_SRGB ? paint_to_srgb(COLOR.rgb) : COLOR.rgb;
if (role == 1) { paint = fur_color.rgb; }
else if (role == 2) { paint = muzzle_color.rgb; }
else if (role == 3) { paint = coat; }
else if (role == 4) { paint = linen_color.rgb; }
else if (role == 5) { paint = scarf_color.rgb; }
else if (role == 6) { paint = trousers_color.rgb; }
else if (role == 7) { paint = leather_color.rgb; }
else if (role == 8) {
paint = OUTPUT_IS_SRGB
? paint_to_srgb(paint_to_linear(fur_color.rgb) * vec3(0.42, 0.36, 0.31))
: fur_color.rgb * vec3(0.42, 0.36, 0.31);
}
else if (role == 9) {
paint = OUTPUT_IS_SRGB ? paint_to_srgb(paint_to_linear(coat) * 0.45) : coat * 0.45;
}
ALBEDO = paint;
ROUGHNESS = 1.0;
}
void light() {
float facing = dot(NORMAL, LIGHT);
float mid = smoothstep(-0.10, 0.01, facing);
float bright = smoothstep(0.48, 0.55, facing);
vec3 tone = mix(vec3(0.25, 0.30, 0.37), vec3(0.65, 0.68, 0.71), mid);
tone = mix(tone, vec3(1.0, 0.97, 0.88), bright);
// Godot multiplies the accumulated diffuse light by ALBEDO after light().
DIFFUSE_LIGHT += tone * LIGHT_COLOR * ATTENUATION / PI;
}
@@ -0,0 +1 @@
uid://hkr8f00r8f2d