feat(characters): add modular woodland traveler kit
@@ -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.
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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/` |
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
cloud shader adds no volumetric pass, outline pass or screen-space postprocess.
|
||||||
See [the asset contract and rebuild commands](../assets/storybook/README.md).
|
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
|
`docs/baselines/storybook_*.png` records the native Metal review. The accompanying
|
||||||
`storybook_render_metrics.json` records a bounded local 1600×900 static-camera
|
`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
|
sample on Apple M1 Max with six NPCs and High/Balanced/Low settings. Wall-frame
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 371 KiB |
|
After Width: | Height: | Size: 461 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 229 KiB |
@@ -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)
|
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.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
|
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,7 +133,12 @@ func setup_from_sim(npc: SimNPC) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _apply_personal_details() -> 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:
|
func _apply_profession_presentation() -> void:
|
||||||
@@ -140,11 +155,24 @@ func _apply_profession_presentation() -> void:
|
|||||||
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
|
||||||
@@ -204,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:
|
||||||
@@ -222,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)
|
||||||
@@ -234,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(
|
||||||
@@ -258,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)
|
||||||
@@ -272,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)
|
||||||
|
|||||||
@@ -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,11 +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)
|
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:
|
||||||
@@ -27,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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ba4wrqkojd2pa
|
||||||
@@ -535,9 +535,9 @@ func _run() -> void:
|
|||||||
(
|
(
|
||||||
main_scene.has_node("Player/Visual/Body")
|
main_scene.has_node("Player/Visual/Body")
|
||||||
and main_scene.has_node("Player/Visual/Head")
|
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(
|
_check(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://clp5w5h4p00pe
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bqd173qe4h8c1
|
||||||
@@ -1,34 +1,89 @@
|
|||||||
class_name AnimalAppearance
|
class_name AnimalAppearance
|
||||||
extends RefCounted
|
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 CEL_SHADER := preload("res://world/presentation/materials/character_cel.gdshader")
|
||||||
const SPECIES: Array[StringName] = [&"fox", &"rabbit", &"badger", &"otter"]
|
const KIT := preload("res://assets/characters/woodland_parts.glb")
|
||||||
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 PARTS: Array[StringName] = [
|
const PARTS: Array[StringName] = [
|
||||||
&"Body", &"Head", &"Hair", &"ArmLeft", &"ArmRight", &"LegLeft", &"LegRight", &"Tail"
|
&"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())
|
static func apply_to(
|
||||||
var source := MODELS[index].instantiate()
|
root: Node3D, identity: int, npc_body := false, authored: CharacterAppearanceProfile = null
|
||||||
var material := ShaderMaterial.new()
|
) -> void:
|
||||||
material.shader = CEL_SHADER
|
_load_kit()
|
||||||
root.set_meta("animal_species", SPECIES[index])
|
var profile := (
|
||||||
for part in PARTS:
|
authored if authored != null else CharacterAppearanceProfile.for_identity(identity)
|
||||||
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)
|
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
|
var target := root.get_node(target_name) as MeshInstance3D
|
||||||
assert(source_mesh != null, "Missing Blender animation part: " + String(part))
|
_set_part(target, choices[index], material, profile.body_fullness)
|
||||||
target.mesh = source_mesh.mesh
|
target.position = pivots[index] * height + Vector3.UP * lift
|
||||||
target.material_override = material
|
target.rotation = Vector3.ZERO
|
||||||
source.free()
|
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:
|
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
|
var material := body.material_override as ShaderMaterial
|
||||||
material.set_shader_parameter("coat_color", color)
|
material.set_shader_parameter("coat_color", color)
|
||||||
material.set_shader_parameter("profession_tint", 0.28)
|
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
|
||||||