saumwacht-godot/assets-source/luneraria/build_grotto.py
Martin Stickelbroeck c27a0097c3 M1: spielbarer Drei-Lane-Kern mit Karte, HUD, Menü und Abnahme
Hauptszene scenes/luneraria.tscn, Saumwacht bleibt über --legacy und die
alten QA-Schalter erreichbar. Karte aus Bestandsassets plus neuem Turm,
HUD mit Planung, Lanes und Minimap, Menü umbenannt, Einstellungen werden
aus dem alten Benutzerordner übernommen. Simulation 6,6x schneller
(Raster, Caches, faire Staffelung) und exakt spiegelsymmetrisch.
Regeln 91/91, echte Anwendung 38/38, Legacy unverändert grün.

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

158 lines
6.5 KiB
Python

"""Boss grotto for Champions of Luneraria: a rock island in the river with a crescent of
cliffs, a cave mouth and violet crystals. Symmetric across the map diagonal, so both
entrances (towards Blau and towards Rot) are equal. Stone uses ENV_* material names.
Run: ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_grotto.py
Local Godot frame: +x/+z are the map axes; the map diagonal (x = z) is the mirror line.
"""
from pathlib import Path
import bpy, bmesh, json, hashlib, struct
from math import sin, cos, pi, sqrt
from mathutils import Vector, noise
P = Path(__file__).resolve().parents[2]
SRC = Path(__file__).resolve().parent
OUT = P / 'assets/luneraria'
OUT.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.preferences.filepaths.save_version = 0
parts = []
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 = {
'cliff': material('ENV_cliff', (.133, .153, .138)),
'soil': material('ENV_soil', (.10, .15, .065), .97),
'limestone': material('ENV_limestone', (.283, .246, .178)),
'dark': material('GR_Shadow', (.02, .02, .03), 1.0),
'crystal': material('GR_Crystal', (.62, .38, 1.0), .15, 0, 3.0),
}
def obj(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)
parts.append(ob)
return ob
def island(name, radius, top, bottom, seed, kind='soil'):
sides, levels = 64, 6
vs, fs = [], []
for li in range(levels):
y = top - (top - bottom) * li / (levels - 1)
for k in range(sides):
a = 2 * pi * k / sides
# Radius depends on |angle - 45 deg| so the island is symmetric across x = z.
m = abs(((a - pi / 4) + pi) % (2 * pi) - pi)
r = radius * (1 + .08 * noise.noise(Vector((cos(m) * 2, li * .5, seed)))) * (1 - .06 * li / levels)
vs.append(G(cos(a) * r, y, sin(a) * r))
for li in range(levels - 1):
for k in range(sides):
a, b = li * sides + k, li * sides + (k + 1) % sides
fs.append((a, a + sides, b + sides, b))
fs += [tuple(range(sides)), tuple(reversed(range((levels - 1) * sides, levels * sides)))]
return obj(name, vs, fs, kind)
def boulder(name, center, size, seed, kind='cliff'):
bm = bmesh.new()
bmesh.ops.create_icosphere(bm, subdivisions=2, radius=1.0)
c = G(*center)
for v in bm.verts:
d = v.co.normalized()
r = 1 + .32 * noise.noise(d * 1.8 + Vector((seed, seed * .5, seed * .3)))
gx, gy, gz = d.x * r * size[0], d.z * r * size[1], -d.y * r * size[2]
if gy < -.25 * size[1]:
gy = -.25 * size[1]
v.co = c + Vector((gx, -gz, gy))
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
me.materials.append(M[kind])
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
parts.append(ob)
return ob
def gem(name, center, height, radius, tilt, kind='crystal'):
cx, cy, cz = center
tip = G(cx + tilt[0], cy + height, cz + tilt[1])
base = [G(cx + cos(2 * pi * k / 6) * radius, cy + height * .25, cz + sin(2 * pi * k / 6) * radius) for k in range(6)]
root = G(cx, cy - .1, cz)
vs = [tip] + base + [root]
fs = [(0, 1 + (k + 1) % 6, 1 + k) for k in range(6)] + [(7, 1 + k, 1 + (k + 1) % 6) for k in range(6)]
return obj(name, vs, fs, kind)
def mirror(p):
return (p[2], p[1], p[0])
island('Island', 7.2, .35, -1.2, 3)
# Crescent of cliffs on the far side from Mid (towards -x-z), open towards both entrances.
ring = []
for k in range(9):
a = pi + pi / 4 + (k - 4) * .30
ring.append((cos(a) * 5.6, 0.0, sin(a) * 5.6, 1.5 + (1.2 if abs(k - 4) <= 1 else .5 * (4 - abs(k - 4)) / 4)))
for i, (x, y, z, h) in enumerate(ring):
boulder('Cliff_%d' % i, (x, y + h * .45, z), (1.6, h, 1.4), 10 + min(i, 8 - i))
# Cave mouth: dark recess under an arch in the middle of the crescent.
a = pi + pi / 4
boulder('Cave_Shadow', (cos(a) * 4.6, .8, sin(a) * 4.6), (1.4, 1.1, .7), 31, 'dark')
boulder('Arch_Top', (cos(a) * 4.8, 3.1, sin(a) * 4.8), (2.2, .8, 1.3), 32)
# Violet crystals, mirrored pairs so neither entrance looks different.
for i, (x, z, h, r) in enumerate([(-2.2, -3.6, 1.8, .32), (-3.9, -1.2, 1.4, .28), (-1.0, -4.3, 1.1, .22), (-3.2, -3.2, 2.3, .4)]):
for p in {(x, 0.3, z), mirror((x, 0.3, z))}:
gem('Crystal_%d_%d' % (i, int(p[0] * 10)), p, h, r, (.12 * (1 if p[0] < p[2] else -1), .08))
# Entrance stones: symmetric pair of limestone steps facing Blau (+x) and Rot (+z)... see manifest.
for p in [(4.6, .18, 1.2), mirror((4.6, .18, 1.2))]:
boulder('Step_%d' % int(p[0] * 10), p, (1.1, .25, .8), 40, 'limestone')
bpy.ops.object.select_all(action='DESELECT')
for ob in parts:
ob.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.join()
grotto = bpy.context.object
grotto.name = 'BossGrotto'
bm = bmesh.new()
bm.from_mesh(grotto.data)
bmesh.ops.triangulate(bm, faces=list(bm.faces))
bm.to_mesh(grotto.data)
bm.free()
bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'grotto.blend'))
bpy.ops.export_scene.gltf(filepath=str(OUT / 'grotto.glb'), export_format='GLB', use_selection=True,
export_materials='EXPORT', export_yup=True, export_animations=False)
raw = (OUT / 'grotto.glb').read_bytes()
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'])
(OUT / 'grotto_manifest.json').write_text(json.dumps({'assets': [{
'id': 'boss_grotto', 'resource_path': 'res://assets/luneraria/grotto.glb', 'source': 'assets-source/luneraria/grotto.blend',
'triangles': triangles, 'sha256': hashlib.sha256(raw).hexdigest(),
'materials': [m['name'] for m in doc['materials']]}]}, indent=2) + '\n')
print('GROTTO_BUILD', triangles, 'triangles', flush=True)