feat(characters): add modular woodland traveler kit

This commit is contained in:
Rijad Zuzo
2026-09-05 22:54:54 +02:00
parent 70ee6feddf
commit 4ff8858b19
39 changed files with 2037 additions and 50 deletions
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/character_workshop.gd" id="1"]
[node name="CharacterWorkshop" type="Node3D"]
script = ExtResource("1")
+533
View File
@@ -0,0 +1,533 @@
"""Blender 5.1: sculpt the modular woodland traveler kit and editable preview.
Run Blender --background --python tools/art/build_character_kit.py.
Coordinates are Godot Y-up, facing +Z. Every mesh has an explicit socket origin.
UV2.x carries a palette role; vertex color is the original Blender preview paint.
The Stout shape key changes the lower torso, including clothing and bag straps.
"""
import json
import math
from pathlib import Path
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "art/characters"
OUTPUT = ROOT / "assets/characters"
SOURCE.mkdir(parents=True, exist_ok=True)
OUTPUT.mkdir(parents=True, exist_ok=True)
(SOURCE / ".gdignore").write_text("")
PALETTE = {
"fur": (1, "c78a4e"), "muzzle": (2, "eee4d1"),
"coat": (3, "496a9d"), "linen": (4, "efe6d4"),
"scarf": (5, "7295bf"), "trousers": (6, "7995b6"),
"leather": (7, "6f4937"), "fur_shadow": (8, "805032"),
"coat_shadow": (9, "304766"), "ink": (0, "3e352d"),
"pink": (0, "bd8674"), "brass": (0, "b99c70"),
"ivory": (0, "f9f1dc"), "iris": (0, "6d8999"),
}
PIECES = {}
LIBRARY = {}
PIVOTS = {}
def coord(point):
return Vector((point[0], -point[2], point[1]))
def linear(hex_color):
channels = [int(hex_color[i:i + 2], 16) / 255 for i in (0, 2, 4)]
return tuple(v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in channels)
def paint(obj, part, role):
role_id, color = PALETTE[role]
rgb = linear(color)
attr = obj.data.color_attributes.new(name="Color", type="FLOAT_COLOR", domain="CORNER")
obj.data.uv_layers.new(name="UVMap")
role_uv = obj.data.uv_layers.new(name="PaletteRole")
for poly in obj.data.polygons:
poly.use_smooth = True
for index in poly.loop_indices:
attr.data[index].color = (*rgb, 1.0)
role_uv.data[index].uv = (role_id, 0)
obj.data.materials.append(bpy.data.materials["TravelerPalette"])
PIECES.setdefault(part, []).append(obj)
return obj
def mesh_object(part, vertices, faces, role):
mesh = bpy.data.meshes.new(part + "_surface")
mesh.from_pydata([coord(v) for v in vertices], [], faces)
mesh.update()
obj = bpy.data.objects.new(part + "_detail", mesh)
bpy.context.collection.objects.link(obj)
return paint(obj, part, role)
def ellipsoid(part, center, radius, role, segments=20, rings=12):
bpy.ops.mesh.primitive_uv_sphere_add(segments=segments, ring_count=rings, location=coord(center))
obj = bpy.context.object
obj.scale = (radius[0], radius[2], radius[1])
return paint(obj, part, role)
def rounded_box(part, center, size, radius, role, tilt=0):
bpy.ops.mesh.primitive_cube_add(size=1, location=coord(center))
obj = bpy.context.object
obj.scale = (size[0], size[2], size[1])
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
bevel = obj.modifiers.new("Tailored rounded corners", "BEVEL")
bevel.width = radius
bevel.segments = 3
bpy.ops.object.modifier_apply(modifier=bevel.name)
obj.rotation_euler.z = math.radians(tilt)
return paint(obj, part, role)
def tube(part, points, radii, role, sides=8, flatten=1.0):
verts, faces = [], []
for i, point in enumerate(points):
p = Vector(point)
tangent = Vector(points[min(i + 1, len(points) - 1)]) - Vector(points[max(0, i - 1)])
tangent.normalize()
side = tangent.cross(Vector((0, 0, 1)))
if side.length < 0.01:
side = tangent.cross(Vector((1, 0, 0)))
side.normalize()
other = tangent.cross(side).normalized()
radius = radii[i] if isinstance(radii, (list, tuple)) else radii
for j in range(sides):
a = math.tau * j / sides
verts.append(p + radius * (math.cos(a) * side + math.sin(a) * other * flatten))
if i:
for j in range(sides):
a, b = (i - 1) * sides + j, (i - 1) * sides + (j + 1) % sides
faces.append((a, b, b + sides, a + sides))
faces.extend([tuple(reversed(range(sides))), tuple(range(len(verts) - sides, len(verts)))])
return mesh_object(part, verts, faces, role)
def loft(part, rows, role, segments=28, start=0, span=math.tau):
"""Tailored cross sections: (height, half width, half depth, center x, center z)."""
verts, faces = [], []
closed = abs(span - math.tau) < 0.001
count = segments if closed else segments + 1
for y, rx, rz, cx, cz in rows:
for j in range(count):
angle = start + span * j / segments
verts.append((cx + rx * math.cos(angle), y, cz + rz * math.sin(angle)))
for row in range(len(rows) - 1):
for j in range(segments):
a, b = row * count + j, row * count + (j + 1) % count
faces.append((a, a + count, b + count, b))
if closed:
faces.extend([tuple(range(count)), tuple(reversed(range(len(verts) - count, len(verts))))])
return mesh_object(part, verts, faces, role)
def finish_part(name, pivot, stout=False):
pieces = PIECES.pop(name)
bpy.ops.object.select_all(action="DESELECT")
for obj in pieces:
obj.select_set(True)
bpy.context.view_layer.objects.active = pieces[0]
bpy.ops.object.join()
obj = bpy.context.object
obj.name = name
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
bpy.context.scene.cursor.location = coord(pivot)
bpy.ops.object.origin_set(type="ORIGIN_CURSOR")
obj.data.materials.clear()
obj.data.materials.append(bpy.data.materials["TravelerPalette"])
for face in obj.data.polygons:
face.material_index = 0
if stout:
obj.shape_key_add(name="Basis")
key = obj.shape_key_add(name="Stout")
for point in key.data:
world_y = point.co.z + pivot[1]
belly = math.exp(-((world_y - 0.97) / 0.42) ** 2)
world_x = point.co.x + pivot[0]
world_z = -point.co.y + pivot[2]
point.co.x += world_x * (0.13 + 0.32 * belly)
point.co.y -= world_z * (0.14 + 0.42 * belly)
key.value = 0.0
obj.data.calc_loop_triangles()
LIBRARY[name] = obj
PIVOTS[name] = pivot
return obj
def torso(style):
part = "Torso" + style
rows = [(0.66, .32, .24, 0, 0), (.75, .39, .29, 0, 0),
(.90, .405, .31, 0, .01), (1.10, .385, .30, 0, .01),
(1.30, .325, .26, 0, 0), (1.43, .27, .20, 0, 0), (1.51, .18, .15, 0, 0)]
loft(part, rows, "linen")
loft(part, [(1.44, .17, .14, 0, 0), (1.63, .18, .15, 0, 0),
(1.73, .21, .16, 0, 0)], "fur", 24)
for y, z in [(1.03, .325), (1.15, .310), (1.27, .279)]:
ellipsoid(part, (0, y, z), (.014, .014, .011), "leather", 10, 6)
tube(part, [(-.043, .94, .327), (-.043, 1.14, .309), (-.043, 1.36, .235)], .004, "muzzle", 5)
outer = [(y, rx + .025, rz + .022, cx, cz) for y, rx, rz, cx, cz in rows]
if style == "Coat":
outer = [(0.45, .355, .26, 0, -.01), (.57, .415, .30, 0, -.01)] + outer
if style == "Tunic":
loft(part, [(y, rx + .018, rz + .018, cx, cz) for y, rx, rz, cx, cz in rows[:-1]], "coat")
rounded_box(part, (0, .91, .365), (.40, .42, .028), .025, "linen")
tube(part, [(-.19, 1.13, .315), (0, 1.15, .338), (.19, 1.13, .315)], .012, "coat_shadow", 6)
else:
gap = .31
loft(part, outer, "coat", start=math.pi / 2 + gap, span=math.tau - 2 * gap)
for sign in (-1, 1):
edge = [(sign * rx * math.sin(gap), y, cz + rz * math.cos(gap)) for y, rx, rz, cx, cz in outer]
tube(part, edge, .012, "coat_shadow", 6)
rounded_box(part, (sign * .275, .835, .272), (.19, .17, .039), .027, "coat", tilt=sign * 17)
tube(part, [(sign * .18, .91, .315), (sign * .27, .914, .308), (sign * .36, .907, .259)], .006, "coat_shadow", 5)
for y, z in [(.79, .310), (.97, .331), (1.16, .309)]:
ellipsoid(part, (.15, y, z), (.025, .026, .013), "ivory", 12, 8)
ellipsoid(part, (.15, y, z + .012), (.009, .008, .004), "coat_shadow", 8, 4)
# A stitched waistband and buckle sit over the trouser waist.
loft(part, [(.68, .343, .252, 0, .0), (.727, .369, .277, 0, .0)], "leather")
rounded_box(part, (0, .703, .291), (.115, .060, .022), .010, "brass")
rounded_box(part, (0, .703, .307), (.076, .035, .008), .004, "leather")
for sign in (-1, 1):
mesh_object(part, [(sign * .05, 1.47, .17), (sign * .20, 1.46, .17),
(sign * .24, 1.32, .235), (sign * .11, 1.38, .25)],
[(0, 1, 2, 3)] if sign > 0 else [(3, 2, 1, 0)], "linen")
finish_part(part, (0, 1.05, 0), True)
def arm(side, bent):
sign = -1 if side == "Left" else 1
x = sign * .44
name = "Arm" + ("Hiker" if bent else "Relaxed") + side
if bent:
points = [(x, 1.35, 0), (x + sign * .12, 1.18, .07),
(x + sign * .10, 1.05, .20), (x - sign * .035, 1.14, .32)]
hand = (x - sign * .055, 1.20, .33)
tube(name, points, [.135, .17, .165, .12], "linen", 18)
ellipsoid(name, points[0], (.14, .145, .14), "linen", 20, 12)
ellipsoid(name, points[2], (.165, .14, .15), "linen")
ellipsoid(name, points[-1], (.135, .082, .13), "muzzle")
else:
loft(name, [(y, rx, rz, x + sign * offset, z) for y, rx, rz, offset, z in [
(.78, .12, .13, .025, .06), (.88, .135, .15, .035, .03),
(1.03, .17, .16, .045, .005), (1.21, .16, .145, .025, 0),
(1.35, .12, .12, 0, 0), (1.41, .055, .07, -.025, 0)]], "linen", 20)
loft(name, [(.78, .133, .141, x + sign * .025, .06),
(.84, .143, .15, x + sign * .028, .052)], "muzzle", 20)
hand = (x + sign * .03, .694, .065)
ellipsoid(name, hand, (.12, .12, .105), "fur")
ellipsoid(name, (hand[0] - sign * .105, hand[1] + .023, hand[2] + .035), (.051, .066, .062), "fur", 14, 8)
for index in (-1, 0, 1):
tube(name, [(hand[0] + index * .042, hand[1] - .041, hand[2] + .096),
(hand[0] + index * .042, hand[1] - .070, hand[2] + .08)], .004, "fur_shadow", 5)
finish_part(name, (x, 1.30, 0))
def leg(side):
x = -.20 if side == "Left" else .20
name = "Leg" + side
loft(name, [(.20, .115, .13, x, .01), (.32, .157, .15, x, 0),
(.43, .17, .16, x, -.01), (.59, .18, .17, x, -.01),
(.75, .18, .165, x, 0)], "trousers", 20)
tube(name, [(x + .15, .35, .06), (x + .16, .47, .07), (x + .15, .66, .07)], .005, "coat_shadow", 5)
rounded_box(name, (x, .035, .095), (.335, .056, .57), .025, "ink")
ellipsoid(name, (x, .129, .12), (.167, .105, .284), "leather")
loft(name, [(.13, .142, .15, x, -.01), (.24, .137, .136, x, -.03),
(.33, .155, .143, x, -.03)], "leather", 20)
loft(name, [(.294, .163, .151, x, -.03), (.34, .161, .148, x, -.03)], "fur_shadow", 20)
tube(name, [(x - .075, .21, .24), (x, .228, .26), (x + .075, .21, .24)], .008, "brass", 6)
finish_part(name, (x, .61, 0))
def head(species):
name = "Head" + species
width = {"Cat": .49, "Fox": .45, "Rabbit": .445, "Badger": .51, "Otter": .485}[species]
depth = .34
verts, faces = [], []
rings, segments = 20, 40
for row in range(rings + 1):
phi = math.pi * row / rings
vertical = math.cos(phi)
cheek = 1.0 + .14 * math.exp(-((vertical + .22) / .49) ** 2)
for j in range(segments):
angle = math.tau * j / segments
verts.append((width * math.sin(phi) * math.cos(angle) * cheek,
1.93 + .435 * vertical,
depth * math.sin(phi) * math.sin(angle)))
for row in range(rings):
for j in range(segments):
a, b = row * segments + j, row * segments + (j + 1) % segments
faces.append((b, b + segments, a + segments, a))
obj = mesh_object(name, verts, faces, "fur")
colors = obj.data.color_attributes["Color"]
uv = obj.data.uv_layers["PaletteRole"]
for polygon in obj.data.polygons:
center = sum((Vector(verts[i]) for i in polygon.vertices), Vector()) / len(polygon.vertices)
role = "fur"
if center.z > .03 and center.y < 1.985 - .18 * math.exp(-(center.x / .15) ** 2):
role = "muzzle"
if species == "Badger" and center.z > .08 and .105 < abs(center.x) < .32 and center.y > 1.80:
role = "fur_shadow"
for index in polygon.loop_indices:
colors.data[index].color = (*linear(PALETTE[role][1]), 1)
uv.data[index].uv = (PALETTE[role][0], 0)
muzzle_depth = .20 if species == "Fox" else .11
muzzle_z = .335 if species == "Fox" else .315
for sign in (-1, 1):
ellipsoid(name, (sign * .105, 1.843, muzzle_z), (.15, .105, muzzle_depth), "muzzle", 20, 12)
nose_z = muzzle_z + muzzle_depth * .91
rounded_box(name, (0, 1.927, nose_z), (.096, .054, .055), .016, "fur_shadow" if species != "Rabbit" else "pink")
tube(name, [(0, 1.90, nose_z + .024), (0, 1.854, nose_z + .007)], .007, "ink", 6)
for sign in (-1, 1):
tube(name, [(0, 1.854, nose_z + .007), (sign * .065, 1.825, nose_z - .005),
(sign * .135, 1.839, nose_z - .040), (sign * .17, 1.877, nose_z - .064)], [.006, .007, .006, .002], "ink", 6)
eye_x = sign * .213
# Almond eyes with lids and a visible iris, seated into the face.
ellipsoid(name, (eye_x, 2.077, .301), (.109, .055, .043), "fur_shadow", 24, 12)
ellipsoid(name, (eye_x, 2.075, .322), (.098, .041, .030), "ivory", 24, 12)
ellipsoid(name, (eye_x - sign * .011, 2.075, .350), (.035, .039, .011), "iris", 16, 10)
ellipsoid(name, (eye_x - sign * .011, 2.076, .359), (.016, .031, .008), "ink", 12, 8)
ellipsoid(name, (eye_x - sign * .022, 2.094, .365), (.009, .011, .005), "ivory", 8, 6)
tube(name, [(eye_x - .101, 2.076, .318), (eye_x - .04, 2.105, .35),
(eye_x + .035, 2.102, .355), (eye_x + .102, 2.073, .312)], [.005, .012, .012, .003], "fur_shadow", 7)
tube(name, [(eye_x - .079, 2.226 - sign * .013, .27),
(eye_x, 2.242, .288), (eye_x + .067, 2.213 + sign * .007, .259)], [.009, .020, .007], "fur_shadow", 8)
for j in range(3):
y = 1.86 + j * .033
tube(name, [(sign * .26, y, .285), (sign * .47, y + (j - 1) * .025, .28),
(sign * (.64 - j * .015), y + (j - 1) * .055, .24)], [.005, .0035, .001], "fur_shadow", 5)
for x, y in [(0.20, 1.92), (.265, 1.88), (.22, 1.84)]:
ellipsoid(name, (sign * x, y, .339), (.007, .007, .005), "fur_shadow", 8, 4)
if species in ("Cat", "Fox"):
for sign in (-1, 0, 1):
tube(name, [(sign * .115, 2.327, .10), (sign * .112, 2.296, .185),
(sign * .09, 2.248, .248)], [.019, .024, .003], "fur_shadow", 7)
# Small swept fur tufts keep the head silhouette organic.
for sign in (-1, 1):
tube(name, [(sign * .41, 1.88, .06), (sign * .53, 1.91, .04),
(sign * .57, 1.94, .02)], [.075, .035, .001], "fur", 8)
finish_part(name, (0, 1.68, 0))
def ears(style):
name = "Ears" + style
for sign in (-1, 1):
if style in ("Cat", "Fox"):
height = .38 if style == "Cat" else .46
x = sign * .34
tube(name, [(x, 2.205, -.02), (x + sign * .05, 2.38, -.025),
(x + sign * .092, 2.205 + height, -.04)], [.165, .105, .004], "fur", 12, .43)
mesh_object(name, [(x - sign * .104, 2.22, .082), (x + sign * .127, 2.245, .070),
(x + sign * .081, 2.205 + height - .051, -.015)],
[(0, 1, 2)] if sign > 0 else [(2, 1, 0)], "muzzle")
tube(name, [(x, 2.25, .105), (x + sign * .029, 2.31, .075),
(x + sign * .053, 2.365, .035)], [.028, .020, .002], "pink", 6)
elif style in ("Rabbit", "Lop"):
x = sign * .25
points = [(x, 2.23, -.025), (x + sign * .02, 2.45, -.04),
(x + sign * .07, 2.73, -.09), (x + sign * .10, 2.87, -.12)]
if style == "Lop":
points = [(x, 2.23, -.025), (x + sign * .12, 2.49, -.04),
(x + sign * .25, 2.51, -.03), (x + sign * .31, 2.24, .015)]
tube(name, points, [.105, .123, .103, .008], "fur", 14)
tube(name, [(p[0], p[1], p[2] + .085) for p in points[1:]], [.060, .055, .006], "pink", 10)
else:
x = sign * .39
radius = .145 if style == "Badger" else .12
ellipsoid(name, (x, 2.265, -.015), (radius, radius * 1.05, .09), "fur", 20, 12)
ellipsoid(name, (x, 2.27, .066), (radius * .62, radius * .63, .02), "pink", 16, 10)
finish_part(name, (0, 2.20, 0))
def tail(species):
name = "Tail" + species
if species == "Rabbit":
ellipsoid(name, (0, .70, -.38), (.19, .19, .18), "muzzle")
else:
points = [(0, .80, -.22), (.17, .58, -.47), (.43, .62, -.62),
(.66, .91, -.61), (.71, 1.21, -.48), (.63, 1.43, -.37)]
radii = [.065, .09, .125, .14, .12, .002]
if species == "Fox":
radii = [.095, .17, .24, .25, .19, .002]
if species == "Badger":
points, radii = [(0, .8, -.2), (.08, .56, -.43), (.15, .52, -.63)], [.07, .14, .003]
if species == "Otter":
points, radii = [(0, .8, -.2), (.05, .48, -.51), (.29, .24, -.80), (.51, .23, -.83)], [.13, .14, .10, .003]
# Subdivide the sweep with Catmull-Rom interpolation for a soft, curling silhouette.
smooth_points, smooth_radii = [], []
for i in range(len(points) - 1):
p0, p1 = Vector(points[max(i - 1, 0)]), Vector(points[i])
p2, p3 = Vector(points[i + 1]), Vector(points[min(i + 2, len(points) - 1)])
for step in range(4):
t = step / 4
smooth_points.append(.5 * ((2 * p1) + (-p0 + p2) * t +
(2*p0 - 5*p1 + 4*p2 - p3)*t*t + (-p0 + 3*p1 - 3*p2 + p3)*t*t*t))
smooth_radii.append(radii[i] * (1 - t) + radii[i + 1] * t)
smooth_points.append(Vector(points[-1]))
smooth_radii.append(radii[-1])
tube(name, smooth_points, smooth_radii, "fur", 14)
if species == "Fox":
tube(name, points[-2:], [radii[-2] * 1.01, .002], "muzzle", 14)
finish_part(name, (0, .8, -.22))
def accessories():
name = "Scarf"
tube(name, [(-.19, 1.48, -.1), (-.23, 1.43, .06), (-.15, 1.41, .19),
(0, 1.395, .24), (.18, 1.43, .13), (.20, 1.48, -.10)], .072, "scarf", 12)
rounded_box(name, (-.17, 1.31, .269), (.145, .21, .062), .032, "scarf", tilt=-12)
mesh_object(name, [(-.22, 1.35, .30), (-.10, 1.32, .32), (-.11, .68, .35),
(-.20, .74, .355), (-.25, .67, .347), (-.27, 1.09, .35)], [(0, 5, 4, 3, 2, 1)], "scarf")
for x in (-.23, -.20, -.17, -.14):
tube(name, [(x, .73, .35), (x - .01, .62, .36)], [.008, .003], "scarf", 5)
finish_part(name, (0, 1.05, 0), True)
name = "BagTravel"
rounded_box(name, (0, 1.24, -.46), (.67, 1.04, .39), .12, "leather")
rounded_box(name, (0, 1.63, -.58), (.72, .32, .25), .085, "fur_shadow")
for sign in (-1, 1):
tube(name, [(sign * .23, 1.71, -.52), (sign * .29, 1.54, -.18),
(sign * .275, 1.36, .20), (sign * .32, 1.05, .292),
(sign * .37, .81, .15), (sign * .29, .76, -.37)], .032, "leather", 8)
rounded_box(name, (sign * .27, 1.16, .311), (.081, .10, .031), .014, "brass")
rounded_box(name, (sign * .345, 1.10, -.44), (.145, .35, .255), .040, "leather")
tube(name, [(sign * .21, .85, -.672), (sign * .21, 1.3, -.677),
(sign * .21, 1.67, -.714)], .021, "fur_shadow", 7)
tube(name, [(-.43, 1.88, -.50), (.43, 1.88, -.50)], .215, "scarf", 28)
for sign in (-1, 1):
points = [(sign * .434, 1.88 + .18 * (1 - t / 34) * math.sin(t * .42),
-.50 + .18 * (1 - t / 34) * math.cos(t * .42)) for t in range(33)]
tube(name, points, .008, "coat_shadow", 5)
ring = [(sign * .27, 1.88 + .223 * math.sin(t * math.tau / 24),
-.50 + .223 * math.cos(t * math.tau / 24)) for t in range(25)]
tube(name, ring, .018, "leather", 6)
finish_part(name, (0, 1.05, 0), True)
name = "BagSatchel"
rounded_box(name, (.46, .80, .045), (.28, .35, .25), .07, "leather")
rounded_box(name, (.47, .90, .16), (.29, .19, .07), .025, "fur_shadow")
rounded_box(name, (.48, .85, .206), (.052, .076, .013), .008, "brass")
tube(name, [(-.27, 1.46, -.05), (-.23, 1.40, .20), (.03, 1.15, .33),
(.30, .90, .30), (.46, .88, .03)], .027, "leather", 8)
finish_part(name, (0, 1.05, 0), True)
name = "HatBeret"
ellipsoid(name, (.035, 2.32, -.02), (.44, .155, .325), "coat", 28, 14)
loft(name, [(2.255, .385, .277, 0, -.02), (2.31, .392, .285, 0, -.02)], "coat_shadow")
tube(name, [(0, 2.445, -.01), (.027, 2.48, -.012), (.051, 2.479, -.025)], .021, "coat_shadow")
finish_part(name, (0, 1.9, 0))
name = "HatBrim"
ellipsoid(name, (0, 2.285, -.025), (.60, .045, .415), "leather", 36, 10)
loft(name, [(2.29, .39, .28, 0, -.025), (2.43, .365, .26, 0, -.035),
(2.55, .28, .215, -.035, -.035), (2.59, .21, .17, -.04, -.035)], "leather")
loft(name, [(2.31, .396, .285, 0, -.025), (2.367, .386, .277, 0, -.03)], "scarf")
finish_part(name, (0, 1.9, 0))
name = "Spectacles"
for sign in (-1, 1):
points = [(sign * .213 + .127 * math.cos(t * math.tau / 28),
2.073 + .099 * math.sin(t * math.tau / 28), .375) for t in range(29)]
tube(name, points, .008, "brass", 5)
tube(name, [(sign * .335, 2.08, .375), (sign * .46, 2.10, .16)], .008, "leather", 6)
tube(name, [(-.086, 2.081, .375), (0, 2.109, .402), (.086, 2.081, .375)], .009, "brass", 6)
finish_part(name, (0, 1.68, 0))
def export_and_preview():
bpy.ops.object.select_all(action="DESELECT")
for obj in LIBRARY.values():
obj.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(OUTPUT / "woodland_parts.glb"), export_format="GLB",
use_selection=True, export_yup=True, export_animations=False,
export_morph=True, export_morph_normal=True,
export_vertex_color="ACTIVE", export_materials="EXPORT",
export_cameras=False, export_extras=False)
budget = {}
for name, obj in LIBRARY.items():
budget[name] = {"triangles": len(obj.data.loop_triangles), "surfaces": 1,
"pivot": PIVOTS[name], "stout_shape": obj.data.shape_keys is not None}
(OUTPUT / "mesh_budget.json").write_text(json.dumps(budget, indent=2) + "\n")
# The saved file opens on the reference traveler; the complete kit stays editable.
preview = bpy.data.collections.new("01 Reference traveler - assembled example")
bpy.context.scene.collection.children.link(preview)
selected = ["TorsoVest", "HeadCat", "EarsCat", "ArmHikerLeft", "ArmHikerRight",
"LegLeft", "LegRight", "TailCat", "Scarf", "BagTravel"]
for name, source in LIBRARY.items():
source.hide_render = True
source.hide_set(True)
if name not in selected:
continue
copy = source.copy()
copy.data = source.data.copy()
copy.name = "Preview_" + name
preview.objects.link(copy)
copy.hide_render = False
copy.hide_set(False)
if copy.data.shape_keys:
copy.data.shape_keys.key_blocks["Stout"].value = .68
if name.startswith("Arm"):
copy.location.x += (-1 if "Left" in name else 1) * .068
copy.scale.x = 1.1
if name.startswith("Leg"):
copy.location.x += (-1 if "Left" in name else 1) * .034
copy.scale.x = 1.14
scene = bpy.context.scene
scene.world.color = (.32, .36, .40)
scene.render.engine = "CYCLES"
scene.cycles.samples = 32
for position, energy, size in [((3, 5, 4), 450, 5), ((-3, 3, 1), 220, 4), ((1, 4, -3), 500, 3)]:
bpy.ops.object.light_add(type="AREA", location=coord(position))
light = bpy.context.object
light.data.energy = energy
light.data.shape = "DISK"
light.data.size = size
light.rotation_euler = (coord((0, 1.25, 0)) - light.location).to_track_quat('-Z', 'Y').to_euler()
bpy.ops.object.camera_add(location=coord((3.2, 2.6, 5.8)))
camera = bpy.context.object
camera.rotation_euler = (coord((0, 1.3, 0)) - camera.location).to_track_quat('-Z', 'Y').to_euler()
camera.data.type = "ORTHO"
camera.data.ortho_scale = 3.6
scene.camera = camera
scene.render.resolution_x = 1000
scene.render.resolution_y = 1100
scene.render.resolution_percentage = 100
for screen in bpy.data.screens:
for area in screen.areas:
if area.type == "VIEW_3D":
area.spaces.active.shading.type = "MATERIAL"
area.spaces.active.region_3d.view_distance = 4.0
area.spaces.active.region_3d.view_location = coord((0, 1.3, 0))
bpy.context.preferences.filepaths.save_version = 0
bpy.ops.wm.save_as_mainfile(filepath=str(SOURCE / "woodland_character_kit.blend"), compress=True)
print("CHARACTER KIT", len(LIBRARY), "parts", sum(v["triangles"] for v in budget.values()), "library triangles")
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
material = bpy.data.materials.new("TravelerPalette")
material.use_nodes = True
tree = material.node_tree
shader = tree.nodes.get("Principled BSDF")
shader.inputs["Roughness"].default_value = .9
vertex = tree.nodes.new("ShaderNodeVertexColor")
vertex.layer_name = "Color"
tree.links.new(vertex.outputs["Color"], shader.inputs["Base Color"])
for style in ("Vest", "Coat", "Tunic"):
torso(style)
for side in ("Left", "Right"):
arm(side, False)
arm(side, True)
leg(side)
for species in ("Cat", "Fox", "Rabbit", "Badger", "Otter"):
head(species)
ears(species)
tail(species)
ears("Lop")
accessories()
export_and_preview()
+100
View File
@@ -0,0 +1,100 @@
extends SceneTree
## Native-renderer proof of the same modular meshes and profiles used in gameplay.
const OUTPUT := "res://docs/baselines/"
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1600, 1000)
if OS.get_cmdline_user_args().has("--world"):
await _capture_world()
quit()
return
var workshop := load("res://tools/CharacterWorkshop.tscn").instantiate() as Node3D
root.add_child(workshop)
for frame in 30:
await process_frame
await _save("character_workshop")
workshop.ui.hide()
workshop.set_process(false)
var actor: Node3D = workshop.actor
actor.position.x = 0
actor.rotation.y = 0
var camera: Camera3D = workshop.camera
camera.position = Vector3(3.1, 2.4, 6.0)
camera.look_at(Vector3(0, 1.3, 0))
camera.size = 3.15
await _save("character_traveler")
camera.position = Vector3(-3.5, 2.8, -6.0)
camera.look_at(Vector3(0, 1.3, 0))
await _save("character_traveler_back")
actor.hide()
for index in workshop.PRESETS.size():
var person := load("res://player/PlayerVisual.tscn").instantiate() as Node3D
workshop.add_child(person)
person.set_process(false)
person.get_node("Sword").hide()
var profile: CharacterAppearanceProfile = load(
workshop.PROFILE_DIR + workshop.PRESETS[index] + ".tres"
)
AnimalAppearance.apply_to(person, 0, false, profile)
person.position.x = (float(index) - 2.5) * 1.7
person.rotation.y = -0.2
camera.position = Vector3(0, 2.8, 13.0)
camera.look_at(Vector3(0, 1.35, 0))
camera.size = 7.2
await _save("character_variations")
workshop.queue_free()
await process_frame
quit()
func _capture_world() -> void:
var main := load("res://main.tscn").instantiate() as Node3D
main.get_node("SimulationManager").set("debug_logs", false)
root.add_child(main)
await process_frame
main.get_node("SimulationManager").set_process(false)
var cycle := main.get_node("JajceWorld/DayNightCycle")
cycle.set_process(false)
cycle.call("_apply_light_rotation", 0.40)
cycle.call("_apply_environment", 0.40)
var demo := main.get_node("DemoController")
if demo.get("debug_overlay_visible"):
demo.call("toggle_debug_overlay")
for child in main.get_children():
if child is CanvasLayer:
child.hide()
var rig := main.get_node("CameraRig") as Node3D
rig.set_physics_process(false)
rig.set_process_unhandled_input(false)
var player := main.get_node("Player") as CharacterBody3D
for frame in 30:
await process_frame
player.set_physics_process(false)
var camera := root.get_camera_3d()
camera.reparent(main)
camera.global_position = player.global_position + Vector3(7.5, 5.5, 10.5)
camera.look_at(player.global_position + Vector3(0, 1.25, 0))
camera.fov = 48.0
await _save("character_gameplay")
main.queue_free()
await process_frame
func _save(name_stem: String) -> void:
for frame in 10:
await process_frame
await RenderingServer.frame_post_draw
var suffix := (
"_compatibility"
if RenderingServer.get_current_rendering_method() == "gl_compatibility"
else ""
)
var path := OUTPUT + name_stem + suffix + ".png"
assert(root.get_texture().get_image().save_png(path) == OK)
print("[TOOL] Saved " + path)
+1
View File
@@ -0,0 +1 @@
uid://clp5w5h4p00pe
+269
View File
@@ -0,0 +1,269 @@
extends Node3D
## Run CharacterWorkshop.tscn to combine parts, inspect motion, and save authored profiles.
const PRESETS: Array[String] = [
"reference_cat",
"fox_rambler",
"rabbit_scholar",
"badger_baker",
"otter_courier",
"lop_gardener"
]
const PROFILE_DIR := "res://assets/characters/profiles/"
var profile: CharacterAppearanceProfile
var actor: Node3D
var camera: Camera3D
var controls: VBoxContainer
var ui: CanvasLayer
var walk := false
var phase := 0.0
var turn := -0.32
var dragging := false
var status: Label
func _ready() -> void:
_build_stage()
_build_ui()
_select_preset(0)
func _process(delta: float) -> void:
actor.rotation.y = turn
phase += delta * 5.0
var swing := sin(phase) if walk else 0.0
for side in ["Left", "Right"]:
var direction := -1.0 if side == "Left" else 1.0
actor.get_node("Leg" + side).rotation.x = swing * direction * 0.44
actor.get_node("Arm" + side).rotation.x = (
-swing * direction * 0.30 * float(actor.get_meta("arm_swing", 1.0))
)
var rest := AnimalAppearance.rest_heights(actor)
var bob := absf(sin(phase * 2.0)) * 0.035 if walk else 0.0
actor.get_node("Body").position.y = rest.x + bob
actor.get_node("Head").position.y = rest.y + bob
actor.get_node("Hair").position.y = rest.z + bob
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
dragging = event.pressed
if event is InputEventMouseMotion and dragging:
turn += event.relative.x * 0.008
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
camera.size = maxf(2.8, camera.size - 0.2)
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
camera.size = minf(6.0, camera.size + 0.2)
func _build_stage() -> void:
var environment := WorldEnvironment.new()
var env := Environment.new()
environment.environment = env
env.background_mode = Environment.BG_COLOR
env.background_color = Color("e2e4d8")
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color("d0ddd8")
env.ambient_light_energy = 0.38
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
# Calibrated for this preview stage to retain cream fabric highlights on GLES.
if RenderingServer.get_current_rendering_method() == "gl_compatibility":
env.tonemap_exposure = 0.25
add_child(environment)
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-38, -32, 0)
light.light_color = Color("fff6e8")
light.light_energy = 1.1
light.shadow_enabled = true
add_child(light)
var floor_mesh := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = Vector2(200, 200)
var material := StandardMaterial3D.new()
material.albedo_color = Color("d0d6c3")
material.roughness = 1.0
plane.material = material
floor_mesh.mesh = plane
add_child(floor_mesh)
actor = load("res://player/PlayerVisual.tscn").instantiate()
add_child(actor)
actor.set_process(false)
actor.get_node("Sword").hide()
actor.position.x = 0.80
camera = Camera3D.new()
add_child(camera)
camera.position = Vector3(0, 2.45, 7)
camera.look_at(Vector3(0, 1.36, 0))
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
camera.size = 3.9
camera.current = true
get_viewport().msaa_3d = Viewport.MSAA_4X
func _build_ui() -> void:
ui = CanvasLayer.new()
add_child(ui)
var panel := PanelContainer.new()
panel.set_anchors_and_offsets_preset(Control.PRESET_LEFT_WIDE)
panel.offset_right = 320
var theme := Theme.new()
theme.default_font_size = 15
theme.set_color("font_color", "Label", Color("37433a"))
theme.set_color("font_color", "CheckButton", Color("37433a"))
theme.set_color("font_pressed_color", "CheckButton", Color("37433a"))
theme.set_color("font_hover_color", "CheckButton", Color("37433a"))
theme.set_color("font_hover_pressed_color", "CheckButton", Color("37433a"))
for type in ["Button", "OptionButton"]:
for state in ["normal", "hover", "pressed", "focus"]:
var button_style := StyleBoxFlat.new()
button_style.bg_color = Color("d6dccd") if state == "hover" else Color("e2e4da")
button_style.content_margin_left = 8
button_style.content_margin_right = 12
button_style.content_margin_top = 6
button_style.content_margin_bottom = 6
theme.set_stylebox(state, type, button_style)
for state in ["font_color", "font_hover_color", "font_pressed_color", "font_focus_color"]:
theme.set_color(state, type, Color("37433a"))
panel.theme = theme
var paper := StyleBoxFlat.new()
paper.bg_color = Color("f2f0e7")
paper.content_margin_left = 24
paper.content_margin_right = 24
paper.content_margin_top = 24
paper.content_margin_bottom = 20
panel.add_theme_stylebox_override("panel", paper)
ui.add_child(panel)
var scroll := ScrollContainer.new()
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
panel.add_child(scroll)
controls = VBoxContainer.new()
controls.size_flags_horizontal = Control.SIZE_EXPAND_FILL
controls.add_theme_constant_override("separation", 10)
scroll.add_child(controls)
func _select_preset(index: int) -> void:
profile = load(PROFILE_DIR + PRESETS[index] + ".tres").duplicate()
_rebuild_controls(index)
_apply()
func _rebuild_controls(selected: int) -> void:
for child in controls.get_children():
controls.remove_child(child)
child.queue_free()
_label("THE WARDROBE", 23)
_label("Woodland travelers", 16)
var presets := OptionButton.new()
for preset in PRESETS:
presets.add_item(preset.replace("_", " ").capitalize())
presets.selected = selected
presets.item_selected.connect(_select_preset)
controls.add_child(presets)
_select("Species", "species", ["Cat", "Fox", "Rabbit", "Badger", "Otter"])
_slider("Body fullness", "body_fullness", 0.0, 1.0)
_slider("Height", "height", 0.80, 1.15)
_slider("Head size", "head_size", 0.88, 1.12)
_slider("Leg length", "leg_length", 0.85, 1.15)
_slider("Ear length", "ear_length", 0.65, 1.20)
_select("Ears", "ears", ["Species default", "Cat", "Fox", "Rabbit", "Lop", "Badger", "Otter"])
_select("Clothes", "outfit", ["Vest", "Long coat", "Work tunic"])
_select("Arms", "arm_pose", ["Relaxed", "Holding pack straps"])
_select("Bag", "bag", ["None", "Satchel", "Travel pack"])
_select("Hat", "hat", ["None", "Beret", "Brimmed hat"])
for field in ["scarf", "spectacles"]:
var toggle := CheckButton.new()
toggle.text = field.capitalize()
toggle.button_pressed = profile.get(field)
toggle.toggled.connect(func(value: bool) -> void: _change(field, value))
controls.add_child(toggle)
for field in ["fur_color", "outfit_color", "scarf_color", "trousers_color"]:
var row := HBoxContainer.new()
controls.add_child(row)
var label := Label.new()
label.text = field.trim_suffix("_color").capitalize()
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(label)
var color := ColorPickerButton.new()
color.custom_minimum_size = Vector2(72, 28)
color.edit_alpha = false
color.color = profile.get(field)
color.color_changed.connect(func(value: Color) -> void: _change(field, value))
row.add_child(color)
var motion := CheckButton.new()
motion.text = "Walk preview"
motion.button_pressed = walk
motion.toggled.connect(func(value: bool) -> void: walk = value)
controls.add_child(motion)
var save := Button.new()
save.text = "Save profile…"
save.pressed.connect(_save_profile)
controls.add_child(save)
status = _label("Drag to turn · scroll to zoom", 13)
func _label(text: String, size: int) -> Label:
var label := Label.new()
label.text = text
label.add_theme_font_size_override("font_size", size)
controls.add_child(label)
return label
func _select(title: String, field: String, options: Array) -> void:
var row := HBoxContainer.new()
controls.add_child(row)
var label := Label.new()
label.text = title
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(label)
var select := OptionButton.new()
for option: String in options:
select.add_item(option)
select.selected = profile.get(field)
select.item_selected.connect(func(value: int) -> void: _change(field, value))
row.add_child(select)
func _slider(title: String, field: String, minimum: float, maximum: float) -> void:
var label := _label(title + " %.2f" % float(profile.get(field)), 14)
var slider := HSlider.new()
slider.min_value = minimum
slider.max_value = maximum
slider.step = 0.01
slider.value = profile.get(field)
slider.value_changed.connect(
func(value: float) -> void:
label.text = title + " %.2f" % value
_change(field, value)
)
controls.add_child(slider)
func _change(field: String, value: Variant) -> void:
profile.set(field, value)
_apply()
func _apply() -> void:
AnimalAppearance.apply_to(actor, 0, false, profile)
func _save_profile() -> void:
var dialog := FileDialog.new()
dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
dialog.access = FileDialog.ACCESS_FILESYSTEM
dialog.filters = PackedStringArray(["*.tres ; Character appearance"])
dialog.current_dir = ProjectSettings.globalize_path(PROFILE_DIR)
dialog.current_file = "custom_traveler.tres"
dialog.file_selected.connect(
func(path: String) -> void:
var error := ResourceSaver.save(profile, path)
status.text = "Profile saved." if error == OK else "Could not save profile."
dialog.queue_free()
)
dialog.canceled.connect(dialog.queue_free)
ui.add_child(dialog)
dialog.popup_centered(Vector2i(720, 500))
+1
View File
@@ -0,0 +1 @@
uid://bqd173qe4h8c1