saumwacht-godot/assets-source/phase14/build_compare_props.py
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:36 +02:00

297 lines
11 KiB
Python

"""Portable compare props: rock, tree and arena tile for the Godot/Unreal stage.
Blender Z up, -Y forward. Same style family as the Verdant Rift sanctuary.
"""
from pathlib import Path
import bpy, bmesh, math, random, json, hashlib
from mathutils import Vector
from math import sin, cos, pi
P = Path(__file__).resolve().parents[2]
OUT = P / 'assets/compare'
OUT.mkdir(parents=True, exist_ok=True)
SRC = Path(__file__).resolve().parent
random.seed(77)
bpy.ops.wm.read_factory_settings(use_empty=True)
def material(name, color, rough=.85):
m = bpy.data.materials.new('CV_' + name)
m.diffuse_color = (*color, 1)
m.use_nodes = True
b = m.node_tree.nodes.get('Principled BSDF')
b.inputs['Base Color'].default_value = (*color, 1)
b.inputs['Roughness'].default_value = rough
n = m.node_tree.nodes
l = m.node_tree.links
v = n.new('ShaderNodeVertexColor')
v.layer_name = 'SurfaceColor'
l.new(v.outputs['Color'], b.inputs['Base Color'])
return m
M = {
'cliff': material('Cliff', (.42, .40, .38), .92),
'limestone': material('Limestone', (.62, .58, .50), .88),
'bark': material('Bark', (.22, .14, .08), .9),
'leaf': material('Leaf', (.28, .42, .12), .78),
'leaf2': material('LeafLight', (.40, .55, .18), .75),
'paving': material('Paving', (.38, .36, .34), .82),
}
def mesh(name, verts, faces, kind, uv=None, colors=None, smooth=True):
me = bpy.data.meshes.new(name)
me.from_pydata(verts, [], faces)
me.update()
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
me.materials.append(M[kind])
for f in me.polygons:
f.use_smooth = smooth
tex = me.uv_layers.new(name='UVMap')
col = me.color_attributes.new(name='SurfaceColor', type='FLOAT_COLOR', domain='CORNER')
base = M[kind].diffuse_color[:3]
for f in me.polygons:
for li in f.loop_indices:
i = me.loops[li].vertex_index
v = verts[i]
tex.data[li].uv = uv[i] if uv else (v[0] * .5, (v[1] + v[2]) * .5)
fac = .90 + .10 * sin(v[0] * 13 + v[2] * 7) + .04 * cos(v[1] * 29)
c = colors[i] if colors else tuple(max(.01, x * fac) for x in base)
col.data[li].color = (*c, 1)
return ob
def finish(ob):
bm = bmesh.new()
bm.from_mesh(ob.data)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=.0001)
bmesh.ops.triangulate(bm, faces=list(bm.faces))
bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
if bm.calc_volume(signed=True) < 0:
bmesh.ops.reverse_faces(bm, faces=list(bm.faces))
bm.to_mesh(ob.data)
bm.free()
ob.data.update()
def export(ob, name, record_id, role):
bpy.ops.object.select_all(action='DESELECT')
ob.select_set(True)
bpy.context.view_layer.objects.active = ob
path = OUT / (name + '.glb')
bpy.ops.export_scene.gltf(
filepath=str(path), export_format='GLB', use_selection=True,
export_animations=False, export_materials='EXPORT', export_yup=True)
raw = path.read_bytes()
import struct
doc = json.loads(raw[20:20 + struct.unpack_from('<I', raw, 12)[0]])
triangles = sum(doc['accessors'][q['indices']]['count'] // 3 for m in doc['meshes'] for q in m['primitives'])
mins = [min(v.co[i] for v in ob.data.vertices) for i in range(3)]
maxs = [max(v.co[i] for v in ob.data.vertices) for i in range(3)]
return {
'id': record_id,
'role': role,
'resource_path': 'res://assets/compare/' + name + '.glb',
'triangles': triangles,
'bounds_godot': {
'min': [mins[0], mins[2], -maxs[1]],
'max': [maxs[0], maxs[2], -mins[1]],
},
'sha256': hashlib.sha256(raw).hexdigest(),
'source': 'assets-source/phase14/build_compare_props.py',
}
def rock():
# Layered sanctuary boulder with eroded ledges.
rng = random.Random(11)
vs, fs = [], []
rings = 7
sides = 18
for j in range(rings):
t = j / (rings - 1)
r = (.95 - .55 * abs(t - .45) * 1.6) * (1 + .12 * sin(t * 9))
y = -0.15 + t * 1.35
for k in range(sides):
a = 2 * pi * k / sides
wobble = 1 + .16 * sin(a * 3 + j) + .08 * sin(a * 7 - j * 2)
vs.append((r * wobble * cos(a) * 1.15, y + .04 * sin(a * 5), r * wobble * sin(a) * .85))
for j in range(rings - 1):
for k in range(sides):
a = j * sides + k
b = j * sides + (k + 1) % sides
fs.append((a, b, b + sides, a + sides))
vs.append((0, -.15, 0))
vs.append((0, 1.35, 0))
bot, top = len(vs) - 2, len(vs) - 1
for k in range(sides):
fs.append((bot, (k + 1) % sides, k))
a = (rings - 1) * sides + k
b = (rings - 1) * sides + (k + 1) % sides
fs.append((top, a, b))
ob = mesh('CompareRock', vs, fs, 'cliff', smooth=True)
# Limestone ledges
for j in range(3):
lv, lf = [], []
y = .25 + j * .38
rr = .72 - j * .08
for k in range(sides):
a = 2 * pi * k / sides
w = 1 + .1 * sin(a * 4 + j)
lv.append((rr * w * cos(a) * 1.1, y, rr * w * sin(a) * .8))
lv.append((rr * w * cos(a) * 1.1, y + .08, rr * w * sin(a) * .8))
for k in range(sides):
a = k * 2
b = ((k + 1) % sides) * 2
lf.append((a, b, b + 1, a + 1))
ledge = mesh('CompareRock_Ledge%d' % j, lv, lf, 'limestone', smooth=True)
finish(ledge)
ledge.select_set(True)
ob.select_set(True)
bpy.context.view_layer.objects.active = ob
bpy.ops.object.join()
ob = bpy.context.object
ob.name = 'CompareRock'
finish(ob)
return ob
def tree():
# Compact sanctuary tree: tapered trunk, branch stubs, leaf clusters.
rng = random.Random(19)
parts = []
# Trunk
tv, tf = [], []
sides, steps = 10, 8
for j in range(steps + 1):
t = j / steps
y = t * 2.4
r = .22 * (1 - t * .55) * (1 + .08 * sin(t * 8))
for k in range(sides):
a = 2 * pi * k / sides
tv.append((r * cos(a) * (1 + .1 * sin(a * 3 + t * 5)), y, r * sin(a)))
for j in range(steps):
for k in range(sides):
a = j * sides + k
b = j * sides + (k + 1) % sides
tf.append((a, b, b + sides, a + sides))
tv.append((0, 0, 0))
tv.append((0, 2.4, 0))
bot, top = len(tv) - 2, len(tv) - 1
for k in range(sides):
tf.append((bot, (k + 1) % sides, k))
a = steps * sides + k
b = steps * sides + (k + 1) % sides
tf.append((top, a, b))
trunk = mesh('CompareTree_Trunk', tv, tf, 'bark')
finish(trunk)
parts.append(trunk)
# Branches + leaf clusters
for j in range(6):
a = j * 2.3
y0 = 1.1 + (j % 3) * .35
length = .7 + .2 * (j % 2)
bv, bf = [], []
for s in range(5):
t = s / 4
r = .07 * (1 - t * .7)
x = cos(a) * length * t
z = sin(a) * length * t
y = y0 + t * .35
for k in range(6):
aa = 2 * pi * k / 6
bv.append((x + r * cos(aa), y + r * sin(aa), z + r * cos(aa) * .5))
for s in range(4):
for k in range(6):
i0 = s * 6 + k
i1 = s * 6 + (k + 1) % 6
bf.append((i0, i1, i1 + 6, i0 + 6))
branch = mesh('CompareTree_Branch%d' % j, bv, bf, 'bark')
finish(branch)
parts.append(branch)
# Leaf sphere cluster at branch tip
cx, cy, cz = cos(a) * length, y0 + .35, sin(a) * length
lv, lf = [], []
lv.append((cx, cy + .28, cz))
for ring in range(1, 5):
th = pi * ring / 5
for k in range(8):
aa = 2 * pi * k / 8
f = 1 + .12 * sin(aa * 3 + ring)
lv.append((cx + .32 * f * sin(th) * cos(aa), cy + .28 * cos(th) * .8, cz + .32 * f * sin(th) * sin(aa)))
lv.append((cx, cy - .28, cz))
end = len(lv) - 1
for k in range(8):
lf.append((0, 1 + k, 1 + (k + 1) % 8))
for ring in range(3):
for k in range(8):
a0 = 1 + ring * 8 + k
b0 = 1 + ring * 8 + (k + 1) % 8
lf.append((a0, a0 + 8, b0 + 8, b0))
for k in range(8):
lf.append((end, end - 1 - k, end - 1 - (k + 1) % 8))
leaf = mesh('CompareTree_Leaf%d' % j, lv, lf, 'leaf' if j % 2 else 'leaf2')
finish(leaf)
parts.append(leaf)
bpy.ops.object.select_all(action='DESELECT')
for p in parts:
p.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.join()
ob = bpy.context.object
ob.name = 'CompareTree'
finish(ob)
return ob
def arena_tile():
vs = [(-2, 0, -2), (2, 0, -2), (2, 0, 2), (-2, 0, 2),
(-2, -.18, -2), (2, -.18, -2), (2, -.18, 2), (-2, -.18, 2)]
fs = [(0, 1, 2, 3), (4, 7, 6, 5), (0, 4, 5, 1), (1, 5, 6, 2), (2, 6, 7, 3), (3, 7, 4, 0)]
ob = mesh('CompareArenaTile', vs, fs, 'paving', smooth=False)
finish(ob)
return ob
records = []
ob = rock()
records.append(export(ob, 'compare-rock', 'compare_rock', 'prop_rock'))
ob = tree()
records.append(export(ob, 'compare-tree', 'compare_tree', 'prop_tree'))
ob = arena_tile()
records.append(export(ob, 'compare-arena-tile', 'compare_arena_tile', 'prop_arena'))
# Also re-export the three hero assets with stable compare IDs (copies of existing GLBs).
import shutil
for src_name, rec_id, role in [
('characters/ringborke.glb', 'compare_ringborke', 'figure_pattern'),
('characters/arbeiter.glb', 'compare_arbeiter', 'figure_worker'),
('models/core_team0.glb', 'compare_core', 'prop_core'),
]:
src = P / 'assets' / src_name
dst_name = src_name.split('/')[-1].replace('.glb', '') + '-compare.glb'
dst = OUT / dst_name
shutil.copy2(src, dst)
raw = dst.read_bytes()
records.append({
'id': rec_id,
'role': role,
'resource_path': 'res://assets/compare/' + dst_name,
'source_asset': 'res://assets/' + src_name,
'sha256': hashlib.sha256(raw).hexdigest(),
})
manifest = {
'scene': 'godot_unreal_compare',
'camera': {
'projection': 'orthogonal',
'game_size': 4.6,
'game_position': [0.0, 7.2, 6.8],
'game_look_at': [0.0, 0.5, 0.0],
'game_yaw': 0.2,
'close_size': 2.1,
'close_position': [1.3, 1.5, 1.9],
'close_look_at': [0.0, 0.9, 0.15],
},
'lighting': {
'key': {'rotation_degrees': [-40, -30, 0], 'color': '#ffd4a0', 'energy': 1.55},
'fill': {'rotation_degrees': [-32, 148, 0], 'color': '#a8c4e8', 'energy': 0.48},
'ambient': '#a8b4c8',
'tonemap': 'ACES',
},
'assets': records,
}
(SRC / 'compare-manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
(P / 'assets/compare/compare-manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
print('COMPARE_PROPS', [(r['id'], r.get('triangles')) for r in records], flush=True)