diff --git a/art/characters/.gdignore b/art/characters/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/art/characters/woodland_character_kit.blend b/art/characters/woodland_character_kit.blend new file mode 100644 index 0000000..1c3037e Binary files /dev/null and b/art/characters/woodland_character_kit.blend differ diff --git a/assets/characters/README.md b/assets/characters/README.md new file mode 100644 index 0000000..b3f66d0 --- /dev/null +++ b/assets/characters/README.md @@ -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. diff --git a/assets/characters/mesh_budget.json b/assets/characters/mesh_budget.json new file mode 100644 index 0000000..b32ae19 --- /dev/null +++ b/assets/characters/mesh_budget.json @@ -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 + } +} diff --git a/assets/characters/profiles/badger_baker.tres b/assets/characters/profiles/badger_baker.tres new file mode 100644 index 0000000..3765370 --- /dev/null +++ b/assets/characters/profiles/badger_baker.tres @@ -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) diff --git a/assets/characters/profiles/fox_rambler.tres b/assets/characters/profiles/fox_rambler.tres new file mode 100644 index 0000000..8bcf5be --- /dev/null +++ b/assets/characters/profiles/fox_rambler.tres @@ -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) diff --git a/assets/characters/profiles/lop_gardener.tres b/assets/characters/profiles/lop_gardener.tres new file mode 100644 index 0000000..a516f2d --- /dev/null +++ b/assets/characters/profiles/lop_gardener.tres @@ -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) diff --git a/assets/characters/profiles/otter_courier.tres b/assets/characters/profiles/otter_courier.tres new file mode 100644 index 0000000..f94da69 --- /dev/null +++ b/assets/characters/profiles/otter_courier.tres @@ -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) diff --git a/assets/characters/profiles/rabbit_scholar.tres b/assets/characters/profiles/rabbit_scholar.tres new file mode 100644 index 0000000..fa8ae62 --- /dev/null +++ b/assets/characters/profiles/rabbit_scholar.tres @@ -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) diff --git a/assets/characters/profiles/reference_cat.tres b/assets/characters/profiles/reference_cat.tres new file mode 100644 index 0000000..5374037 --- /dev/null +++ b/assets/characters/profiles/reference_cat.tres @@ -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") diff --git a/assets/characters/woodland_parts.glb b/assets/characters/woodland_parts.glb new file mode 100644 index 0000000..c618bd0 Binary files /dev/null and b/assets/characters/woodland_parts.glb differ diff --git a/assets/characters/woodland_parts.glb.import b/assets/characters/woodland_parts.glb.import new file mode 100644 index 0000000..3607d40 --- /dev/null +++ b/assets/characters/woodland_parts.glb.import @@ -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 diff --git a/docs/DEVELOPER_INDEX.md b/docs/DEVELOPER_INDEX.md index 7b50288..000adfc 100644 --- a/docs/DEVELOPER_INDEX.md +++ b/docs/DEVELOPER_INDEX.md @@ -38,6 +38,7 @@ when extending the game. | Add caravans, routes, scheduled work, or regional saves | [Regional simulation and persistence](FEATURE_REGIONAL_SIMULATION_AND_PERSISTENCE.md) | `simulation/regional/`, `simulation/state/*StateRecord.gd` | | Add an enemy or animal using an existing behavior | [Entity families](FEATURE_ENTITIES_COMBAT_ANIMALS.md) | `CombatantFactory.gd`, `AnimalFactory.gd`, shared visual roots | | Change rendering or support weaker PCs | [Presentation and performance](FEATURE_PRESENTATION_PERFORMANCE.md) | `world/jajce/jajce_world.gd`, `world/presentation/` | +| Author humanoid animal characters or combine clothing and body shapes | [Modular character kit](../assets/characters/README.md) | `tools/CharacterWorkshop.tscn`, `CharacterAppearanceProfile.gd`, `tools/art/build_character_kit.py` | | Change a saved field or migration | [Save schema and migrations](FEATURE_SAVE_SCHEMA_AND_MIGRATIONS.md) | `SimulationStateRecord.gd`, nested state records, `SaveSlotStore.gd` | | Add a regression, benchmark, or run the gate | [Testing and benchmarks](FEATURE_TESTING_AND_BENCHMARKS.md) | `tests/`, `tools/`, `docs/benchmarks/` | diff --git a/docs/FEATURE_PRESENTATION_PERFORMANCE.md b/docs/FEATURE_PRESENTATION_PERFORMANCE.md index 192f13c..ada0eff 100644 --- a/docs/FEATURE_PRESENTATION_PERFORMANCE.md +++ b/docs/FEATURE_PRESENTATION_PERFORMANCE.md @@ -108,6 +108,17 @@ ambient fill and warm sunlight follow the simulation day/night clock. The cloud shader adds no volumetric pass, outline pass or screen-space postprocess. See [the asset contract and rebuild commands](../assets/storybook/README.md). +The current humanoid cast uses the [modular woodland character kit](../assets/characters/README.md): +31 Blender parts, five animal heads, six authored examples and independently +configurable proportions, outfits and accessories. `CharacterAppearanceProfile` +resources are cosmetic. Meshes are shared; `Stout` morph weights and palette +materials are per actor. The reference traveler has 20,070 base triangles across +10 opaque surfaces; Godot imports LODs. The standalone +`tools/CharacterWorkshop.tscn` previews and saves profiles. Existing motion, +profession/inventory/death cues, and shader grass interaction remain owned by +their existing controllers. The appearance scenario verifies imported parts, +independent shape weights, stable identity, extreme proportions and cue clearance. + `docs/baselines/storybook_*.png` records the native Metal review. The accompanying `storybook_render_metrics.json` records a bounded local 1600×900 static-camera sample on Apple M1 Max with six NPCs and High/Balanced/Low settings. Wall-frame diff --git a/docs/baselines/character_gameplay.png b/docs/baselines/character_gameplay.png new file mode 100644 index 0000000..8b1b585 Binary files /dev/null and b/docs/baselines/character_gameplay.png differ diff --git a/docs/baselines/character_traveler.png b/docs/baselines/character_traveler.png new file mode 100644 index 0000000..61b7441 Binary files /dev/null and b/docs/baselines/character_traveler.png differ diff --git a/docs/baselines/character_traveler_back.png b/docs/baselines/character_traveler_back.png new file mode 100644 index 0000000..fe18807 Binary files /dev/null and b/docs/baselines/character_traveler_back.png differ diff --git a/docs/baselines/character_traveler_back_compatibility.png b/docs/baselines/character_traveler_back_compatibility.png new file mode 100644 index 0000000..57afb53 Binary files /dev/null and b/docs/baselines/character_traveler_back_compatibility.png differ diff --git a/docs/baselines/character_traveler_compatibility.png b/docs/baselines/character_traveler_compatibility.png new file mode 100644 index 0000000..292b305 Binary files /dev/null and b/docs/baselines/character_traveler_compatibility.png differ diff --git a/docs/baselines/character_variations.png b/docs/baselines/character_variations.png new file mode 100644 index 0000000..4e9b0d4 Binary files /dev/null and b/docs/baselines/character_variations.png differ diff --git a/docs/baselines/character_variations_compatibility.png b/docs/baselines/character_variations_compatibility.png new file mode 100644 index 0000000..138c28b Binary files /dev/null and b/docs/baselines/character_variations_compatibility.png differ diff --git a/docs/baselines/character_workshop.png b/docs/baselines/character_workshop.png new file mode 100644 index 0000000..0f990ae Binary files /dev/null and b/docs/baselines/character_workshop.png differ diff --git a/docs/baselines/character_workshop_compatibility.png b/docs/baselines/character_workshop_compatibility.png new file mode 100644 index 0000000..94ac5d8 Binary files /dev/null and b/docs/baselines/character_workshop_compatibility.png differ diff --git a/player/npc/NpcVisual.gd b/player/npc/NpcVisual.gd index 7bf851c..0c05309 100644 --- a/player/npc/NpcVisual.gd +++ b/player/npc/NpcVisual.gd @@ -9,6 +9,7 @@ const TRUST_REACTION_START_SCALE := Vector3(0.24, 0.24, 0.24) const CONCERN_BASE_POSITION := Vector3(-0.4, 3.25, 0.0) @export var debug_logs := false +@export var appearance: CharacterAppearanceProfile @export var move_speed := 3.0 @export var rotation_speed := 8.0 @@ -58,6 +59,9 @@ var walk_phase := 0.0 var glyph_phase := 0.0 var concern_phase := 0.0 var trust_reaction_tween: Tween +var rest_heights := Vector3(1.05, 1.68, 1.9) +var arm_swing := 1.0 +var cue_lift := 0.0 func debug_log(message: String) -> void: @@ -76,11 +80,13 @@ func _process(delta: float) -> void: return glyph_phase = fmod(glyph_phase + delta * 2.2, TAU) if task_glyph_root.visible: - task_glyph_root.position.y = 2.9 + sin(glyph_phase) * 0.045 + task_glyph_root.position.y = 2.9 + cue_lift + sin(glyph_phase) * 0.045 task_glyph_root.rotation.y = glyph_phase * 0.35 if opportunity_concern_root.visible: concern_phase = fmod(concern_phase + delta * 1.65, TAU) - opportunity_concern_root.position.y = (CONCERN_BASE_POSITION.y + sin(concern_phase) * 0.045) + opportunity_concern_root.position.y = ( + CONCERN_BASE_POSITION.y + cue_lift + sin(concern_phase) * 0.045 + ) opportunity_concern_root.rotation.y = sin(concern_phase * 0.7) * 0.16 var concern_scale := 0.96 + sin(concern_phase) * 0.04 opportunity_concern_root.scale = Vector3.ONE * concern_scale @@ -92,20 +98,24 @@ func _process(delta: float) -> void: var swing := sin(walk_phase) left_leg.rotation_degrees.x = lerpf(left_leg.rotation_degrees.x, swing * 28.0, blend) right_leg.rotation_degrees.x = lerpf(right_leg.rotation_degrees.x, -swing * 28.0, blend) - left_arm.rotation_degrees.x = lerpf(left_arm.rotation_degrees.x, -swing * 20.0, blend) - right_arm.rotation_degrees.x = lerpf(right_arm.rotation_degrees.x, swing * 20.0, blend) + left_arm.rotation_degrees.x = lerpf( + left_arm.rotation_degrees.x, -swing * 20.0 * arm_swing, blend + ) + right_arm.rotation_degrees.x = lerpf( + right_arm.rotation_degrees.x, swing * 20.0 * arm_swing, blend + ) var step_bob := absf(sin(walk_phase * 2.0)) * 0.035 - body_mesh.position.y = lerpf(body_mesh.position.y, 1.05 + step_bob, blend) - head_mesh.position.y = lerpf(head_mesh.position.y, 1.68 + step_bob, blend) - hair_mesh.position.y = lerpf(hair_mesh.position.y, 1.9 + step_bob, blend) + body_mesh.position.y = lerpf(body_mesh.position.y, rest_heights.x + step_bob, blend) + head_mesh.position.y = lerpf(head_mesh.position.y, rest_heights.y + step_bob, blend) + hair_mesh.position.y = lerpf(hair_mesh.position.y, rest_heights.z + step_bob, blend) else: left_leg.rotation_degrees.x = lerpf(left_leg.rotation_degrees.x, 0.0, blend) right_leg.rotation_degrees.x = lerpf(right_leg.rotation_degrees.x, 0.0, blend) left_arm.rotation_degrees.x = lerpf(left_arm.rotation_degrees.x, 0.0, blend) right_arm.rotation_degrees.x = lerpf(right_arm.rotation_degrees.x, 0.0, blend) - body_mesh.position.y = lerpf(body_mesh.position.y, 1.05, blend) - head_mesh.position.y = lerpf(head_mesh.position.y, 1.68, blend) - hair_mesh.position.y = lerpf(hair_mesh.position.y, 1.9, blend) + body_mesh.position.y = lerpf(body_mesh.position.y, rest_heights.x, blend) + head_mesh.position.y = lerpf(head_mesh.position.y, rest_heights.y, blend) + hair_mesh.position.y = lerpf(hair_mesh.position.y, rest_heights.z, blend) func setup_from_sim(npc: SimNPC) -> void: @@ -123,7 +133,12 @@ func setup_from_sim(npc: SimNPC) -> void: func _apply_personal_details() -> void: - AnimalAppearance.apply_to(self, sim_id, true) + AnimalAppearance.apply_to(self, sim_id, true, appearance) + rest_heights = AnimalAppearance.rest_heights(self, true) + arm_swing = get_meta("arm_swing", 1.0) + cue_lift = maxf(0.0, float(get_meta("character_top", 2.6)) - 2.6) + task_glyph_root.position.y = 2.9 + cue_lift + profession_label.position.y = 2.8 + cue_lift func _apply_profession_presentation() -> void: @@ -140,11 +155,24 @@ func _apply_profession_presentation() -> void: prop_material.roughness = 0.78 profession_prop.material_override = prop_material profession_prop.mesh = _create_profession_prop_mesh() + _fit_carried_props() profession_prop.visible = profession_prop.mesh != null profession_label.text = "%s\n%s" % [npc_name, definition.display_name] profession_label.visible = debug_overlay_visible +func _fit_carried_props() -> void: + var profile := get_meta("character_appearance") as CharacterAppearanceProfile + var lift := Vector3.UP * 0.61 * (profile.leg_length - 1.0) * profile.height + var reach := 0.60 + 0.10 * profile.body_fullness + profession_prop.position.x = signf(profession_prop.position.x) * reach + profession_prop.position.z = 0.20 + profession_prop.position = profession_prop.position * profile.height + lift + profession_prop.scale = Vector3.ONE * profile.height + carried_food_visual.position = Vector3(reach, 0.70, 0.32) * profile.height + lift + carried_wood_visual.position = Vector3(-reach, 0.88, 0.18) * profile.height + lift + + func _create_profession_prop_mesh() -> PrimitiveMesh: profession_prop.position = Vector3(0.48, 0.85, 0.0) profession_prop.rotation_degrees = Vector3.ZERO @@ -204,7 +232,7 @@ func set_opportunity_concern_visible(is_visible: bool) -> void: if opportunity_concern_root == null: return opportunity_concern_root.visible = is_visible and not is_dead_visual - opportunity_concern_root.position = CONCERN_BASE_POSITION + opportunity_concern_root.position = CONCERN_BASE_POSITION + Vector3.UP * cue_lift opportunity_concern_root.rotation = Vector3.ZERO opportunity_concern_root.scale = Vector3.ONE if is_visible: @@ -222,7 +250,7 @@ func play_trust_gain_reaction() -> void: return _stop_trust_gain_reaction() trust_reaction_root.visible = true - trust_reaction_root.position = TRUST_REACTION_BASE_POSITION + trust_reaction_root.position = TRUST_REACTION_BASE_POSITION + Vector3.UP * cue_lift trust_reaction_root.rotation = Vector3.ZERO trust_reaction_root.scale = TRUST_REACTION_START_SCALE _set_trust_reaction_transparency(1.0) @@ -234,14 +262,14 @@ func play_trust_gain_reaction() -> void: trust_reaction_tween.tween_property( trust_reaction_root, "position", - TRUST_REACTION_BASE_POSITION + Vector3(0.0, 0.16, 0.0), + TRUST_REACTION_BASE_POSITION + Vector3(0.0, 0.16 + cue_lift, 0.0), 0.68 ) trust_reaction_tween.parallel().tween_property(trust_reaction_root, "rotation:y", 0.32, 0.68) trust_reaction_tween.tween_property( trust_reaction_root, "position", - TRUST_REACTION_BASE_POSITION + Vector3(0.0, 0.44, 0.0), + TRUST_REACTION_BASE_POSITION + Vector3(0.0, 0.44 + cue_lift, 0.0), 0.55 ) trust_reaction_tween.parallel().tween_property( @@ -258,7 +286,7 @@ func _set_trust_reaction_transparency(value: float) -> void: func _finish_trust_gain_reaction() -> void: trust_reaction_root.visible = false - trust_reaction_root.position = TRUST_REACTION_BASE_POSITION + trust_reaction_root.position = TRUST_REACTION_BASE_POSITION + Vector3.UP * cue_lift trust_reaction_root.rotation = Vector3.ZERO trust_reaction_root.scale = TRUST_REACTION_START_SCALE _set_trust_reaction_transparency(1.0) @@ -272,7 +300,7 @@ func _stop_trust_gain_reaction() -> void: if trust_reaction_root == null: return trust_reaction_root.visible = false - trust_reaction_root.position = TRUST_REACTION_BASE_POSITION + trust_reaction_root.position = TRUST_REACTION_BASE_POSITION + Vector3.UP * cue_lift trust_reaction_root.rotation = Vector3.ZERO trust_reaction_root.scale = TRUST_REACTION_START_SCALE _set_trust_reaction_transparency(1.0) diff --git a/player/player_visual.gd b/player/player_visual.gd index 4720417..7c88898 100644 --- a/player/player_visual.gd +++ b/player/player_visual.gd @@ -1,5 +1,9 @@ extends Node3D +@export var appearance: CharacterAppearanceProfile = preload( + "res://assets/characters/profiles/reference_cat.tres" +) + @onready var body_mesh: MeshInstance3D = $Body @onready var head_mesh: MeshInstance3D = $Head @onready var hair_mesh: MeshInstance3D = $Hair @@ -10,11 +14,15 @@ extends Node3D var player_body: CharacterBody3D var walk_phase := 0.0 +var rest_heights := Vector3(1.05, 1.68, 1.9) +var arm_swing := 1.0 func _ready() -> void: player_body = get_parent() as CharacterBody3D - AnimalAppearance.apply_to(self, 0) + AnimalAppearance.apply_to(self, 0, false, appearance) + rest_heights = AnimalAppearance.rest_heights(self) + arm_swing = get_meta("arm_swing", 1.0) func _process(delta: float) -> void: @@ -27,18 +35,22 @@ func _process(delta: float) -> void: var swing := sin(walk_phase) left_leg.rotation_degrees.x = lerpf(left_leg.rotation_degrees.x, swing * 30.0, blend) right_leg.rotation_degrees.x = lerpf(right_leg.rotation_degrees.x, -swing * 30.0, blend) - left_arm.rotation_degrees.x = lerpf(left_arm.rotation_degrees.x, -swing * 22.0, blend) - right_arm.rotation_degrees.x = lerpf(right_arm.rotation_degrees.x, swing * 22.0, blend) + left_arm.rotation_degrees.x = lerpf( + left_arm.rotation_degrees.x, -swing * 22.0 * arm_swing, blend + ) + right_arm.rotation_degrees.x = lerpf( + right_arm.rotation_degrees.x, swing * 22.0 * arm_swing, blend + ) var step_bob := absf(sin(walk_phase * 2.0)) * 0.035 - body_mesh.position.y = lerpf(body_mesh.position.y, 1.05 + step_bob, blend) - head_mesh.position.y = lerpf(head_mesh.position.y, 1.68 + step_bob, blend) - hair_mesh.position.y = lerpf(hair_mesh.position.y, 1.9 + step_bob, blend) + body_mesh.position.y = lerpf(body_mesh.position.y, rest_heights.x + step_bob, blend) + head_mesh.position.y = lerpf(head_mesh.position.y, rest_heights.y + step_bob, blend) + hair_mesh.position.y = lerpf(hair_mesh.position.y, rest_heights.z + step_bob, blend) return left_leg.rotation_degrees.x = lerpf(left_leg.rotation_degrees.x, 0.0, blend) right_leg.rotation_degrees.x = lerpf(right_leg.rotation_degrees.x, 0.0, blend) left_arm.rotation_degrees.x = lerpf(left_arm.rotation_degrees.x, 0.0, blend) right_arm.rotation_degrees.x = lerpf(right_arm.rotation_degrees.x, 0.0, blend) - body_mesh.position.y = lerpf(body_mesh.position.y, 1.05, blend) - head_mesh.position.y = lerpf(head_mesh.position.y, 1.68, blend) - hair_mesh.position.y = lerpf(hair_mesh.position.y, 1.9, blend) + body_mesh.position.y = lerpf(body_mesh.position.y, rest_heights.x, blend) + head_mesh.position.y = lerpf(head_mesh.position.y, rest_heights.y, blend) + hair_mesh.position.y = lerpf(hair_mesh.position.y, rest_heights.z, blend) diff --git a/tests/character_appearance_test.gd b/tests/character_appearance_test.gd new file mode 100644 index 0000000..bf8c16e --- /dev/null +++ b/tests/character_appearance_test.gd @@ -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) diff --git a/tests/character_appearance_test.gd.uid b/tests/character_appearance_test.gd.uid new file mode 100644 index 0000000..298baca --- /dev/null +++ b/tests/character_appearance_test.gd.uid @@ -0,0 +1 @@ +uid://ba4wrqkojd2pa diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index 4bf4c5d..607f8db 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -535,9 +535,9 @@ func _run() -> void: ( main_scene.has_node("Player/Visual/Body") and main_scene.has_node("Player/Visual/Head") - and main_scene.get_node("Player/Visual").get_meta("animal_species") == &"fox" + and main_scene.get_node("Player/Visual").get_meta("animal_species") == &"cat" ), - "Player should use the same readable multi-part visual language as villagers" + "Player should use the reference cat in the shared modular visual language" ) _check( ( diff --git a/tools/CharacterWorkshop.tscn b/tools/CharacterWorkshop.tscn new file mode 100644 index 0000000..c7bb188 --- /dev/null +++ b/tools/CharacterWorkshop.tscn @@ -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") diff --git a/tools/art/build_character_kit.py b/tools/art/build_character_kit.py new file mode 100644 index 0000000..3459a0b --- /dev/null +++ b/tools/art/build_character_kit.py @@ -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() diff --git a/tools/capture_character_kit.gd b/tools/capture_character_kit.gd new file mode 100644 index 0000000..d186cde --- /dev/null +++ b/tools/capture_character_kit.gd @@ -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) diff --git a/tools/capture_character_kit.gd.uid b/tools/capture_character_kit.gd.uid new file mode 100644 index 0000000..75adfd9 --- /dev/null +++ b/tools/capture_character_kit.gd.uid @@ -0,0 +1 @@ +uid://clp5w5h4p00pe diff --git a/tools/character_workshop.gd b/tools/character_workshop.gd new file mode 100644 index 0000000..3cb8659 --- /dev/null +++ b/tools/character_workshop.gd @@ -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)) diff --git a/tools/character_workshop.gd.uid b/tools/character_workshop.gd.uid new file mode 100644 index 0000000..d5f7e2e --- /dev/null +++ b/tools/character_workshop.gd.uid @@ -0,0 +1 @@ +uid://bqd173qe4h8c1 diff --git a/world/presentation/AnimalAppearance.gd b/world/presentation/AnimalAppearance.gd index 01015a9..1f4d00d 100644 --- a/world/presentation/AnimalAppearance.gd +++ b/world/presentation/AnimalAppearance.gd @@ -1,34 +1,89 @@ class_name AnimalAppearance extends RefCounted -## Disposable woodland identities, rebuilt from stable NPC IDs. No simulation RNG. +## Shared Blender meshes, per-character palettes and shape weights; no simulation RNG. -const CEL_SHADER := preload("res://world/presentation/materials/storybook_cel.gdshader") -const SPECIES: Array[StringName] = [&"fox", &"rabbit", &"badger", &"otter"] -const MODELS: Array[PackedScene] = [ - preload("res://assets/storybook/fox.glb"), - preload("res://assets/storybook/rabbit.glb"), - preload("res://assets/storybook/badger.glb"), - preload("res://assets/storybook/otter.glb"), -] +const CEL_SHADER := preload("res://world/presentation/materials/character_cel.gdshader") +const KIT := preload("res://assets/characters/woodland_parts.glb") const PARTS: Array[StringName] = [ &"Body", &"Head", &"Hair", &"ArmLeft", &"ArmRight", &"LegLeft", &"LegRight", &"Tail" ] +static var _meshes: Dictionary[String, Mesh] = {} -static func apply_to(root: Node3D, identity: int, npc_body := false) -> void: - var index := posmod(identity, MODELS.size()) - var source := MODELS[index].instantiate() - var material := ShaderMaterial.new() - material.shader = CEL_SHADER - root.set_meta("animal_species", SPECIES[index]) - for part in PARTS: - var source_mesh := source.find_child(String(part), true, false) as MeshInstance3D - var target_name := "MeshInstance3D" if npc_body and part == &"Body" else String(part) + +static func apply_to( + root: Node3D, identity: int, npc_body := false, authored: CharacterAppearanceProfile = null +) -> void: + _load_kit() + var profile := ( + authored if authored != null else CharacterAppearanceProfile.for_identity(identity) + ) + profile = profile.normalized_copy() + var material := _palette(profile) + root.set_meta("animal_species", CharacterAppearanceProfile.SPECIES[profile.species]) + root.set_meta("character_appearance", profile) + var species := CharacterAppearanceProfile.PART_SPECIES[profile.species] + var arm := "Hiker" if profile.arm_pose == 1 else "Relaxed" + var choices := [ + "Torso" + CharacterAppearanceProfile.OUTFITS[profile.outfit], + "Head" + species, + "Hat" + CharacterAppearanceProfile.HATS[profile.hat] if profile.hat > 0 else "", + "Arm" + arm + "Left", + "Arm" + arm + "Right", + "LegLeft", + "LegRight", + "Tail" + species, + ] + var height := profile.height + var lift := 0.61 * (profile.leg_length - 1.0) * height + var shoulder := 0.44 + 0.10 * profile.body_fullness + var hip := 0.20 + 0.05 * profile.body_fullness + var pivots := [ + Vector3(0, 1.05, 0), + Vector3(0, 1.68, 0), + Vector3(0, 1.68 + 0.22 * profile.head_size, 0), + Vector3(-shoulder, 1.30, 0), + Vector3(shoulder, 1.30, 0), + Vector3(-hip, 0.61, 0), + Vector3(hip, 0.61, 0), + Vector3(0, 0.80, -0.22), + ] + for index in PARTS.size(): + var target_name := "MeshInstance3D" if npc_body and index == 0 else String(PARTS[index]) var target := root.get_node(target_name) as MeshInstance3D - assert(source_mesh != null, "Missing Blender animation part: " + String(part)) - target.mesh = source_mesh.mesh - target.material_override = material - source.free() + _set_part(target, choices[index], material, profile.body_fullness) + target.position = pivots[index] * height + Vector3.UP * lift + target.rotation = Vector3.ZERO + target.scale = Vector3.ONE * height + if index == 1 or index == 2: + target.scale *= profile.head_size + elif index == 3 or index == 4: + target.scale.x *= 1.0 + 0.15 * profile.body_fullness + elif index == 5 or index == 6: + target.scale.x *= 1.0 + 0.20 * profile.body_fullness + target.scale.y *= profile.leg_length + target.set_meta("rest_height", target.position.y) + var body := root.get_node("MeshInstance3D" if npc_body else "Body") as MeshInstance3D + _child_part(body, "Scarf", "Scarf" if profile.scarf else "", material, profile.body_fullness) + _child_part( + body, + "Bag", + "Bag" + CharacterAppearanceProfile.BAGS[profile.bag] if profile.bag > 0 else "", + material, + profile.body_fullness + ) + var head := root.get_node("Head") as MeshInstance3D + var ears := _child_part(head, "Ears", "Ears" + profile.ear_part(), material) + ears.position.y = 0.52 + ears.scale.y = profile.ear_length + _child_part(head, "Spectacles", "Spectacles" if profile.spectacles else "", material) + # Derived from imported bounds, including elongated ears and hats. + var top := 0.0 + for part in PARTS: + var path := "MeshInstance3D" if npc_body and part == &"Body" else String(part) + top = maxf(top, _top(root.get_node(path), Transform3D.IDENTITY)) + root.set_meta("character_top", top) + root.set_meta("arm_swing", 0.23 if profile.arm_pose == 1 else 1.0) static func set_profession_color(root: Node3D, color: Color) -> void: @@ -36,3 +91,71 @@ static func set_profession_color(root: Node3D, color: Color) -> void: var material := body.material_override as ShaderMaterial material.set_shader_parameter("coat_color", color) material.set_shader_parameter("profession_tint", 0.28) + + +static func rest_heights(root: Node3D, npc_body := false) -> Vector3: + return Vector3( + root.get_node("MeshInstance3D" if npc_body else "Body").get_meta("rest_height"), + root.get_node("Head").get_meta("rest_height"), + root.get_node("Hair").get_meta("rest_height") + ) + + +static func _load_kit() -> void: + if not _meshes.is_empty(): + return + var source := KIT.instantiate() + for child in source.find_children("*", "MeshInstance3D", true, false): + _meshes[String(child.name)] = (child as MeshInstance3D).mesh + source.free() + + +static func _set_part( + target: MeshInstance3D, part: String, material: ShaderMaterial, fullness := 0.0 +) -> void: + assert(part.is_empty() or _meshes.has(part), "Missing Blender character part: " + part) + target.mesh = _meshes.get(part) + target.visible = target.mesh != null + target.material_override = material + if target.mesh != null: + var shape := target.find_blend_shape_by_name("Stout") + if shape >= 0: + target.set_blend_shape_value(shape, fullness) + + +static func _child_part( + parent: MeshInstance3D, + node_name: String, + part: String, + material: ShaderMaterial, + fullness := 0.0 +) -> MeshInstance3D: + var target := parent.get_node_or_null(node_name) as MeshInstance3D + if target == null: + target = MeshInstance3D.new() + target.name = node_name + parent.add_child(target) + _set_part(target, part, material, fullness) + target.transform = Transform3D.IDENTITY + return target + + +static func _palette(profile: CharacterAppearanceProfile) -> ShaderMaterial: + var material := ShaderMaterial.new() + material.shader = CEL_SHADER + for channel in ["fur", "muzzle", "outfit", "linen", "scarf", "trousers", "leather"]: + var parameter: String = channel + "_color" + material.set_shader_parameter(parameter, profile.get(parameter)) + return material + + +static func _top(node: Node3D, parent_transform: Transform3D) -> float: + var transform := parent_transform * node.transform + var top := 0.0 + if node is MeshInstance3D and node.mesh != null and node.visible: + var bounds: AABB = transform * node.get_aabb() + top = bounds.end.y + for child in node.get_children(): + if child is Node3D: + top = maxf(top, _top(child, transform)) + return top diff --git a/world/presentation/CharacterAppearanceProfile.gd b/world/presentation/CharacterAppearanceProfile.gd new file mode 100644 index 0000000..73afc83 --- /dev/null +++ b/world/presentation/CharacterAppearanceProfile.gd @@ -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] diff --git a/world/presentation/CharacterAppearanceProfile.gd.uid b/world/presentation/CharacterAppearanceProfile.gd.uid new file mode 100644 index 0000000..138fd0f --- /dev/null +++ b/world/presentation/CharacterAppearanceProfile.gd.uid @@ -0,0 +1 @@ +uid://dnrvywwi3t8d1 diff --git a/world/presentation/materials/character_cel.gdshader b/world/presentation/materials/character_cel.gdshader new file mode 100644 index 0000000..758ddf5 --- /dev/null +++ b/world/presentation/materials/character_cel.gdshader @@ -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; +} diff --git a/world/presentation/materials/character_cel.gdshader.uid b/world/presentation/materials/character_cel.gdshader.uid new file mode 100644 index 0000000..a0599c6 --- /dev/null +++ b/world/presentation/materials/character_cel.gdshader.uid @@ -0,0 +1 @@ +uid://hkr8f00r8f2d