saumwacht-godot/assets-source/luneraria/build_map_props.py
Martin Stickelbroeck faf0fbdef0 M3: Balance, Kartenprops, sieben Volksrepräsentanten und Abnahme
Bots planen Items/Champions vor der Armee und helfen deterministisch,
damit Spiegelmatches fair bleiben. Boss und Profile neu abgestimmt;
Balance-, Stress- und Volks-Werkzeuge sowie Kartenprops ergänzt.
2026-09-28 00:41:05 +02:00

248 lines
11 KiB
Python

"""Map dressing for Champions of Luneraria: river bridge, curb stone, trees, rocks, banner
and edge cliffs, exported as separate named meshes in one GLB. Godot instances them with
MultiMeshes. Stone, bark and leaves use ENV_* names so the Kluft shaders (with wind on
foliage) apply at runtime.
Run: ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_map_props.py
Godot frame per prop: y up, prop origin at ground level; the bridge spans local +x.
"""
from pathlib import Path
import bpy, bmesh, json, hashlib, struct
from math import sin, cos, pi
from mathutils import Vector, noise
P = Path(__file__).resolve().parents[2]
SRC = Path(__file__).resolve().parent
OUT = P / 'assets/luneraria'
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.preferences.filepaths.save_version = 0
def G(x, y, z):
return Vector((x, -z, y))
def material(name, color, rough=.9, metal=0.0, emission=0.0):
m = bpy.data.materials.new(name)
m.diffuse_color = (*color, 1)
m.use_nodes = True
bs = m.node_tree.nodes.get('Principled BSDF')
bs.inputs['Base Color'].default_value = (*color, 1)
bs.inputs['Roughness'].default_value = rough
bs.inputs['Metallic'].default_value = metal
if emission:
bs.inputs['Emission Color'].default_value = (*color, 1)
bs.inputs['Emission Strength'].default_value = emission
return m
M = {
'limestone': material('ENV_limestone', (.283, .246, .178)),
'paving': material('ENV_paving', (.19, .185, .15), .92),
'cliff': material('ENV_cliff', (.133, .153, .138)),
'bark': material('ENV_bark', (.087, .056, .032), .93),
'leaf_dark': material('ENV_leaf_dark', (.031, .07, .04), .89),
'leaf_mid': material('ENV_leaf_mid', (.084, .144, .054), .85),
'leaf_light': material('ENV_leaf_light', (.258, .305, .091), .79),
'soil': material('ENV_soil', (.10, .15, .065), .97),
'brass': material('ENV_brass', (.515, .366, .153), .45, .7),
'cloth': material('PR_Banner', (.9, .9, .9), .8),
}
groups = {}
def add(group, name, vs, fs, kind, smooth=False):
me = bpy.data.meshes.new(name)
me.from_pydata([tuple(v) for v in vs], [], fs)
me.update()
me.materials.append(M[kind])
for p in me.polygons:
p.use_smooth = smooth
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
groups.setdefault(group, []).append(ob)
return ob
def box(group, name, c, size, kind, yaw=0.0):
cx, cy, cz = c
hx, hy, hz = size[0] / 2, size[1] / 2, size[2] / 2
vs = []
for dy in (-1, 1):
for dz in (-1, 1):
for dx in (-1, 1):
x, z = dx * hx, dz * hz
vs.append(G(cx + x * cos(yaw) - z * sin(yaw), cy + dy * hy, cz + x * sin(yaw) + z * cos(yaw)))
return add(group, name, vs, [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)], kind)
def lathe(group, name, profile, sides, kind, c=(0, 0, 0), jitter=0.0, seed=0.0, smooth=False):
cx, cy, cz = c
vs, fs = [], []
for r, y in profile:
for k in range(sides):
a = 2 * pi * k / sides
rr = r * (1 + jitter * noise.noise(Vector((cos(a) * 2, y * 1.3, sin(a) * 2 + seed))))
vs.append(G(cx + cos(a) * rr, cy + y, cz + sin(a) * rr))
n = len(profile)
for j in range(n - 1):
for k in range(sides):
a, b = j * sides + k, j * sides + (k + 1) % sides
fs.append((a, b, b + sides, a + sides))
fs += [tuple(reversed(range(sides))), tuple(range((n - 1) * sides, n * sides))]
return add(group, name, vs, fs, kind, smooth)
def blob(group, name, c, size, kind, seed, subdiv=2, amp=.3, floor=None, smooth=False):
bm = bmesh.new()
bmesh.ops.create_icosphere(bm, subdivisions=subdiv, radius=1.0)
cc = G(*c)
for v in bm.verts:
d = v.co.normalized()
r = 1 + amp * noise.noise(d * 1.7 + Vector((seed, seed * .7, seed * .3)))
gx, gy, gz = d.x * r * size[0], d.z * r * size[1], -d.y * r * size[2]
if floor is not None and gy < floor * size[1]:
gy = floor * size[1]
v.co = cc + Vector((gx, -gz, gy))
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
me.materials.append(M[kind])
for p in me.polygons:
p.use_smooth = smooth
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
groups.setdefault(group, []).append(ob)
return ob
def tube(group, name, points, radii, kind, sides=8, steps=10, seed=0.0, gnarl=0.0):
pts = [G(*p) for p in points]
vs, fs = [], []
for j in range(steps + 1):
t = j / steps
seg = min(len(pts) - 2, int(t * (len(pts) - 1)))
u = t * (len(pts) - 1) - seg
p = pts[seg].lerp(pts[seg + 1], u)
tangent = (pts[seg + 1] - pts[seg]).normalized()
ref = Vector((0, 0, 1)) if abs(tangent.z) < .9 else Vector((1, 0, 0))
a1 = tangent.cross(ref).normalized()
a2 = tangent.cross(a1)
rs = min(len(radii) - 2, int(t * (len(radii) - 1)))
ru = t * (len(radii) - 1) - rs
radius = radii[rs] * (1 - ru) + radii[rs + 1] * ru
for k in range(sides):
ang = 2 * pi * k / sides
w = 1 + gnarl * noise.noise(Vector((cos(ang), t * 5, sin(ang) + seed)))
vs.append(p + (a1 * cos(ang) + a2 * sin(ang)) * radius * w)
for j in range(steps):
for k in range(sides):
a, b = j * sides + k, j * sides + (k + 1) % sides
fs.append((a, b, b + sides, a + sides))
fs += [tuple(reversed(range(sides))), tuple(range(steps * sides, (steps + 1) * sides))]
return add(group, name, vs, fs, kind, True)
# ---------------------------------------------------------------- bridge (spans local x, 11 m long, 7 m wide)
# The simulation is flat: the deck sits at ground level so units walk on it, not inside it.
L, Wd = 11.0, 7.0
for i in range(12):
x = -L / 2 + (i + .5) * L / 12
box('Bridge', 'Deck_%d' % i, (x, .05, 0), (L / 12 + .02, .1, Wd), 'paving')
for s_ in (-1, 1):
box('Bridge', 'Parapet_%d_%d' % (i, s_), (x, .42, s_ * (Wd / 2 - .22)), (L / 12 + .03, .62, .38), 'limestone')
for x in (-L / 2 + .4, -1.8, 1.8, L / 2 - .4):
for s_ in (-1, 1):
lathe('Bridge', 'Post_%d_%d' % (int(x * 10), s_), [(.32, 0), (.32, 1.05), (.26, 1.15), (.2, 1.3)], 6, 'limestone', (x, 0, s_ * (Wd / 2 - .22)))
lathe('Bridge', 'Cap_%d_%d' % (int(x * 10), s_), [(.22, 0), (.22, .1), (.14, .16)], 6, 'brass', (x, 1.3, s_ * (Wd / 2 - .22)))
for x in (-2.6, 2.6):
for s_ in (-1, 1):
box('Bridge', 'Pier_%d_%d' % (int(x), s_), (x, -.6, s_ * (Wd / 2 + .15)), (1.1, 1.4, .5), 'limestone')
# ---------------------------------------------------------------- curb stone (placed along lane edges)
box('Curb', 'Curb', (0, .12, 0), (1.3, .26, .42), 'limestone')
# ---------------------------------------------------------------- trees
for v, (h, seed) in enumerate([(3.8, 1.0), (4.6, 2.0), (3.2, 3.0)]):
group = 'Pine_%d' % v
tube(group, 'Trunk', [(0, 0, 0), (0, h * .55, 0), (0, h, 0)], [.24, .17, .08], 'bark', 8, 6, seed, .1)
for k in range(5):
y = h * (.28 + k * .15)
r = (1.35 - k * .22) * (1 + .08 * v)
lathe(group, 'Cone_%d' % k, [(r, y), (r * .75, y + .35), (.05, y + h * .26)], 9, ('leaf_dark', 'leaf_mid')[k % 2], (0, 0, 0), .12, seed + k)
for v, (h, seed) in enumerate([(3.4, 4.0), (4.0, 5.0), (2.8, 6.0)]):
group = 'Oak_%d' % v
tube(group, 'Trunk', [(0, 0, 0), (.15, h * .5, .05), (-.1, h * .85, 0)], [.34, .24, .15], 'bark', 9, 8, seed, .15)
for k in range(3):
ang = k * 2.1 + seed
tube(group, 'Branch_%d' % k, [(0, h * .55, 0), (cos(ang) * .9, h * .78, sin(ang) * .9)], [.13, .06], 'bark', 6, 4, seed + k, .1)
for k in range(9):
ang = k * 2.4 + seed
rr = .55 + .5 * ((k * .618) % 1.0)
s = .95 - .2 * ((k * .37) % 1.0)
blob(group, 'Leaves_%d' % k, (cos(ang) * rr, h * .88 + .5 * ((k * .53) % 1.0), sin(ang) * rr), (s, s * .78, s),
('leaf_mid', 'leaf_light', 'leaf_dark')[k % 3], seed * 10 + k, 2, .28)
for k in range(4):
ang = k * 1.57 + seed
tube(group, 'Root_%d' % k, [(0, .25, 0), (cos(ang) * .7, .05, sin(ang) * .7)], [.14, .05], 'bark', 6, 4, seed + k)
# ---------------------------------------------------------------- rocks and shrubs
for v, (size, seed) in enumerate([((1.2, .8, 1.0), 7.0), ((1.8, 1.1, 1.3), 8.0), ((.8, .5, .7), 9.0)]):
group = 'Rock_%d' % v
blob(group, 'Rock', (0, size[1] * .3, 0), size, 'cliff', seed, 2, .34, -.35)
blob(group, 'Moss', (size[0] * .15, size[1] * .82, 0), (size[0] * .55, size[1] * .25, size[2] * .5), 'leaf_dark', seed + 1, 1, .3, -.2)
for k in range(2):
blob(group, 'Pebble_%d' % k, (size[0] * (.9 + .3 * k), .1, size[2] * (.5 - k)), (.28, .18, .24), 'limestone', seed + 3 + k, 1, .3, -.3)
for v in range(2):
blob('Shrub_%d' % v, 'Shrub', (0, .3, 0), (.8 + .3 * v, .55, .7 + .2 * v), ('leaf_mid', 'leaf_dark')[v], 11 + v, 2, .35, -.3)
# ---------------------------------------------------------------- banner (team colour at runtime via PR_Banner)
lathe('Banner', 'Pole', [(.07, 0), (.07, 4.2), (.1, 4.3), (.02, 4.5)], 6, 'brass')
vs, fs = [], []
for j in range(6):
for i in range(4):
x = .08 + i * .35
y = 4.1 - j * .45
z = .08 * sin(i * 1.3 + j * .6)
vs.append(G(x, y - (.25 * (i / 3) if j == 5 else 0), z))
for j in range(5):
for i in range(3):
a = j * 4 + i
fs.append((a, a + 1, a + 5, a + 4))
add('Banner', 'Cloth', vs, fs, 'cloth', True)
# ---------------------------------------------------------------- edge cliffs (a 12 m wall segment, local x)
for i in range(7):
x = -6 + i * 2
h = 3.4 + 1.6 * noise.noise(Vector((i * .7, 3.1, 0)))
blob('EdgeCliff', 'Block_%d' % i, (x, h * .45, 0), (1.6, h * .6, 1.8), 'cliff', 20 + i, 2, .3, -.5)
blob('EdgeCliff', 'Grass_%d' % i, (x, h * .85, -.3), (1.2, .35, 1.3), 'leaf_mid', 30 + i, 1, .3, -.2)
# Join each group into one mesh object per prop and triangulate.
exported = []
for group, objects in groups.items():
bpy.ops.object.select_all(action='DESELECT')
for ob in objects:
ob.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
bpy.ops.object.join()
joined = bpy.context.object
joined.name = group
bm = bmesh.new()
bm.from_mesh(joined.data)
bmesh.ops.triangulate(bm, faces=list(bm.faces))
bm.to_mesh(joined.data)
bm.free()
exported.append(joined)
bpy.ops.object.select_all(action='DESELECT')
for ob in exported:
ob.select_set(True)
bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'map_props.blend'))
bpy.ops.export_scene.gltf(filepath=str(OUT / 'map_props.glb'), export_format='GLB', use_selection=True,
export_materials='EXPORT', export_yup=True, export_animations=False)
raw = (OUT / 'map_props.glb').read_bytes()
doc = json.loads(raw[20:20 + struct.unpack_from('<I', raw, 12)[0]])
counts = {m['name']: sum(doc['accessors'][q['indices']]['count'] // 3 for q in m['primitives']) for m in doc['meshes']}
(OUT / 'map_props_manifest.json').write_text(json.dumps({'assets': [{'id': 'map_props', 'resource_path': 'res://assets/luneraria/map_props.glb',
'source': 'assets-source/luneraria/map_props.blend', 'props': counts, 'sha256': hashlib.sha256(raw).hexdigest()}]}, indent=2) + '\n')
print('MAP_PROPS_BUILD', counts, flush=True)