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>
158 lines
6.5 KiB
Python
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)
|