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>
297 lines
11 KiB
Python
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)
|