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