saumwacht-godot/assets-source/luneraria/g2/build_bossgrotte.py
Martin Stickelbroeck 9f62e30377 G2: Ancients, Türme, Bossgrotte und Superboss; macOS- und Windows-ARM64-Pakete
Mondherz-Ancients (Saumkern-Kristall als Herz, Sichelklingen, Ringe, Krone,
Teamwappen, Energieadern) mit den Stufen stabil/instabil/kritisch, kurzem
Zusammenbruch und bleibender Ruine; der Sieg wartet nie auf die Grafik.
Turmfamilie aus einem Blender-Generator: Grenzwacht (Tower 1), Bastion
(Tower 2), Kernwacht (Basisturm in der Formensprache des Ancients), mit
Schadensstufen, Einsturz, Ruine und sichtbarem Angriff (Zielring, Aufladen,
Bolzen, Einschlag) - der Schaden bleibt vollständig in der Simulation.
Bossgrotte Mondschlund mit Tor, Treppe in die Tiefe, Altar, Säulen, Felsen
und fünf Zuständen; der Generator hält Abstand zur Top-Lane, die Höhle
braucht eine kleine Geländesenke (nur Darstellung). Superboss Schlundkrone
mit 24 Knochen und 11 Clips (inkl. Brüllen und Sieg), Adern in Teamfarbe,
dasselbe Modell für Grotte und Lane. Gemeinsame LN-Materialfamilie (Stein,
Metall, Kristall, Rune, Stoff, Mondwasser, Kreaturenhaut), Occlusion-Fade
ohne dithernde Schatten. Keine Regeländerung.

Geprüft: verify_g2 245/245, verify_g1 443/443, Regeln 91/91 und 73/73,
Stress 17/17, Balance 40 Partien identisch mit G1, verify_m1 38/38,
verify_m2 14/14, Netz 43/43 und Loopback 12/12, Legacy 209 und 463.
21 Ansichten vorher/nachher (1920x1080), Videos Turmkampf und Bossbegegnung,
Leistung G1/G2 auf demselben Mac (+0,5 ms in vollen Kampfansichten).
Pakete 0.17.0-g2 für macOS (Universal, ad-hoc signiert), Windows x64/ARM64
und Linux; macOS nativ und unter Rosetta, beide Windows-Pakete in Windows 11
ARM (Parallels, OpenGL-Fallback) mit --lune-smoke bestanden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:12:49 +02:00

386 lines
20 KiB
Python

"""G2 boss grotto of Champions of Luneraria: the "Mondschlund", an old Lunarian sanctuary cut
into the highland escarpment. Replaces the G1 blockout on the same spot (LuneMap.GROTTO,
terrace radius 7.8 m, 1.7 m high); the boss point and both entrances keep their M2 offsets.
Local frame: origin at the arena centre on the terrace (Godot y = 0 is the terrace top),
+x/+z are the map axes, the map diagonal x = z is the mirror line. u runs along the axis
towards Mid, a sideways towards the Blau side; the gate sits at the back (-u), where the
escarpment rises towards the Top lane.
Parts:
Floor arena paving and dark rim, plus the two moon-water troughs before the gate
Runes the great rune circle, the glyph ring round the dais, gate and altar glyphs
Platform low two-step boss dais at the boss point (figures are lifted onto it)
Altar crescent altar behind the dais that cradles the Mondquelle
Source the Mondquelle crystal cluster (the grotto's energy source, state driven)
Gate the monumental entrance: two horned guardian pylons, deep arch with a bronze
crescent keystone, threshold, stairs that lead down into the dark
Cave the dark hollow behind the gate (walls, stair treads, well rim)
CaveGlow light from inside: the glowing mist at the back and the moon well below,
plus crystals on the cave walls (state driven)
Channel the moon water in the troughs (state driven)
Pillars old columns along the sides of the arena: intact, broken, fallen
PillarCrystals small crystals on the intact capitals (state driven)
Rocks faceted cliff masses that frame the gate and merge into the escarpment
Everything is symmetric across the local diagonal, so neither team is favoured. Rocks are
placed on the real terrain (grotto_site.json, written by tools/luneraria/dump_grotto_site.gd)
and every rock and the gate are checked against the lane corridors (at least CLEAR metres).
The lower lying cave needs a small notch in the escarpment behind the gate (LuneTerrain,
presentation only, hidden by the rock shell).
Run: ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/g2/build_bossgrotte.py
"""
import sys, json
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lune_bpy as L
from lune_bpy import V, pi, sin, cos, sqrt
from math import atan2
from mathutils import Matrix
AXIS = V((.70710678, 0, .70710678))
BACK = -AXIS
SIDE = V((-.70710678, 0, .70710678))
BOSS = V((-1.5, 0, -1.5))
ARENA = 7.8
GATE_U = -8.95
PYLON_A = 2.6
CLEAR = .6
SITE = json.loads((L.SRC / 'grotto_site.json').read_text())
_problems = []
def at(u, a, y=0.0):
"""Point in the axis frame: u along the axis (+ towards Mid), a to the Blau side."""
return AXIS * u + SIDE * a + V((0, y, 0))
def yaw_to(direction):
"""Yaw (about Godot y) that turns local +z towards `direction`."""
return atan2(direction.x, direction.z)
def ground(p):
"""Terrain height (relative to the terrace) under a local point, bilinear."""
grid = SITE['height_grid']
ox, oz = grid['origin']
step, n, values = grid['step'], grid['count'], grid['values']
fx = max(0.0, min(n - 1.001, (p.x - ox) / step))
fz = max(0.0, min(n - 1.001, (p.z - oz) / step))
i, j = int(fx), int(fz)
tx, tz = fx - i, fz - j
a = values[j][i] * (1 - tx) + values[j][i + 1] * tx
b = values[j + 1][i] * (1 - tx) + values[j + 1][i + 1] * tx
return a * (1 - tz) + b * tz
def clearance(p):
best = 1e9
for lane in SITE['lanes']:
for x, z in lane['points']:
best = min(best, sqrt((p.x - x) ** 2 + (p.z - z) ** 2) - lane['corridor'])
return best
def check_clear(name, center, rx, rz, yaw=0.0):
"""Samples the footprint ellipse; records a problem if it reaches into a lane corridor."""
worst = 1e9
for k in range(16):
t = 2 * pi * k / 16
local = V((cos(t) * rx, 0, sin(t) * rz))
worst = min(worst, clearance(V(center) + Matrix.Rotation(yaw, 3, 'Y') @ local))
if worst < CLEAR:
_problems.append('%s reaches %.2f m into/near a lane corridor' % (name, CLEAR - worst))
return worst
# ---------------------------------------------------------------- floor and runes
def floor_parts():
parts = [L.lathe('Floor_Rim', [(ARENA - .02, -.4), (ARENA - .02, .07), (ARENA - .22, .1)], 72, 'LN_Stone_Dark', jitter=.008, seed=2),
L.disc_grid('Floor_Top', (0, 0, 0), ARENA - .2, 'LN_Stone_Grotto', rings=8, sides=72, y=.088)]
runes = [L.lathe('Rune_Circle', [(4.72, .104), (4.36, .104)], 72, 'LN_Rune_Grotto', cap_top=False, cap_bottom=False, uv_band=.46),
L.lathe('Rune_Inner', [(2.62, .106), (2.46, .106)], 48, 'LN_Rune_Grotto', cap_top=False, cap_bottom=False,
center=tuple(BOSS), uv_band=.36)]
return parts, runes
def platform_parts():
c = tuple(BOSS)
return [L.lathe('Plat_0', [(2.3, -.02), (2.3, .06), (2.24, .065)], 48, 'LN_Stone_Dark', center=c),
L.lathe('Plat_1', [(2.2, .06), (1.7, .065), (1.7, .12), (1.64, .125)], 48, 'LN_Stone_Grotto', center=c),
L.disc_grid('Plat_Top', c, 1.62, 'LN_Stone_Grotto', rings=2, sides=48, y=.124)]
def altar_parts():
"""A crescent slab behind the boss, facing the arena, with a cradle for the Mondquelle."""
base = BOSS + BACK * 2.3
parts = [L.lathe('Altar_Foot', [(1.1, -.05), (1.1, .35), (.95, .45), (.9, .6)], 8, 'LN_Stone_Dark', center=tuple(base), rotation=pi / 8)]
pts = []
for k in range(9):
ang = pi * (.12 + .76 * k / 8.0)
pts.append(base + SIDE * cos(ang) * 1.5 + V((0, .6 + sin(ang) * 2.5, 0)))
parts.append(L.sweep('Altar_Crescent', pts, [.55, .62, .66, .68, .7, .68, .66, .62, .55], [.5, .52, .55, .56, .56, .56, .55, .52, .5],
'LN_Stone_Grotto', up_hint=tuple(AXIS), steps=30, lens=.2))
inner = []
for k in range(9):
ang = pi * (.18 + .64 * k / 8.0)
inner.append(base + SIDE * cos(ang) * 1.14 + V((0, .6 + sin(ang) * 2.1, 0)) + AXIS * .26)
runes = [L.sweep('Altar_Glyphs', inner, [.14] * 9, [.06] * 9, 'LN_Rune_Grotto', up_hint=tuple(AXIS), steps=30, uv_cells=.34)]
source = []
for k in range(7):
a = 2 * pi * k / 7
r = .08 + .3 * (k % 3) / 2.0
height = 1.3 - .25 * (k % 3)
c = base + SIDE * cos(a) * r + BACK * sin(a) * r * .5 + V((0, .7, 0))
source.append(L.crystal('Source_%d' % k, tuple(c), .16 + .06 * (k % 2), height, .2, 'LN_Crystal_Grotto', sides=6,
tilt=(.25 * sin(a), .25 * cos(a)), seed=k))
source.append(L.crystal('Source_Main', tuple(base + V((0, .8, 0))), .32, 1.9, .3, 'LN_Crystal_Grotto', sides=6, seed=11))
return parts, runes, source
# ---------------------------------------------------------------- gate
def pylon(name, center, facing, height=6.1, width=1.34):
"""Guardian pylon: dark foot, four tapering drums, capital and two crescent horns that
curl towards the arch."""
c = V(center)
yaw = yaw_to(facing)
t = V((facing.z, 0, -facing.x))
parts = [L.box(name + '_Foot', tuple(c + V((0, .1, 0))), (width * 1.36, 1.2, width * 1.36), 'LN_Stone_Dark', yaw=yaw, bev=.05, segments=2)]
y = .7
for k in range(4):
h = (height - 1.7) / 4
w = width * (1 - .055 * k)
parts.append(L.box('%s_Drum_%d' % (name, k), tuple(c + V((0, y + h / 2, 0))), (w, h - .05, w), 'LN_Stone_Grotto', yaw=yaw, bev=.06, segments=2))
y += h
parts.append(L.box(name + '_Cap', tuple(c + V((0, y + .25, 0))), (width * 1.28, .5, width * 1.28), 'LN_Stone_Dark', yaw=yaw, bev=.05, segments=2))
top = y + .5
# t runs towards -SIDE: +1 turns the horn tips towards the gate's middle for the Blau-side pylon.
inward = 1 if (c - AXIS * c.dot(AXIS)).dot(SIDE) > 0 else -1
for s in (-1, 1):
base = c + t * s * .38 + V((0, top - .1, 0))
curl = inward * .35
pts = [base, base + t * s * .45 + V((0, .7, 0)), base + t * (s * .2 + curl) + V((0, 1.35, 0)), base + t * (curl * 1.6) + V((0, 1.6, 0))]
parts.append(L.sweep('%s_Horn_%d' % (name, s), pts, [.4, .32, .2, .04], [.34, .28, .18, .04], 'LN_Stone_Grotto',
up_hint=tuple(facing), steps=14, lens=.3))
runes = [L.panel(name + '_Glyphs', tuple(c + facing * (width * .5) + V((0, 2.9, 0))), width * .52, 2.9, yaw, 'LN_Rune_Grotto',
depth=.02, uv_cells=(1, 5))]
return parts, runes, top
def gate_parts():
"""Monumental entrance at the back of the arena, facing it (towards +axis)."""
face = AXIS
parts, runes = [], []
tops = []
for s in (-1, 1):
p, r, top = pylon('Pylon_%d' % s, tuple(at(GATE_U, s * PYLON_A)), face)
parts += p
runes += r
tops.append(top)
check_clear('Pylon_%d' % s, at(GATE_U, s * PYLON_A), 1.0, 1.0)
# Deep arch between the pylons (three rings in depth) with a crescent keystone.
spring = 4.2
radius = PYLON_A - .55
for d, depth in ((.35, .5), (-.25, .5), (-.85, .5)):
arch = [at(GATE_U + d, cos(pi * k / 12) * radius, spring + sin(pi * k / 12) * radius * .78) for k in range(13)]
parts.append(L.sweep('Gate_Arch_%d' % int(d * 100), arch, [.72] * 5, [depth] * 5, 'LN_Stone_Grotto' if d > 0 else 'LN_Stone_Dark',
up_hint=tuple(face), steps=26, lens=.08))
front = [at(GATE_U + .62, cos(pi * k / 12) * radius, spring + sin(pi * k / 12) * radius * .78) for k in range(13)]
runes.append(L.sweep('Gate_ArchGlyphs', front, [.2] * 5, [.04] * 5, 'LN_Rune_Grotto', up_hint=tuple(face), steps=26, uv_cells=.42))
apex = spring + radius * .78
key = [at(GATE_U + .7, -.62, apex + .1), at(GATE_U + .7, -.78, apex + .78), at(GATE_U + .7, 0, apex + 1.45),
at(GATE_U + .7, .78, apex + .78), at(GATE_U + .7, .62, apex + .1)]
parts.append(L.sweep('Gate_Keystone', key, [.34, .44, .5, .44, .34], [.42] * 5, 'LN_Metal_Bronze', up_hint=tuple(face), steps=16, lens=.3))
# Lintel block that ties the arch into the rock above.
parts.append(L.box('Gate_Lintel', tuple(at(GATE_U - .2, 0, apex + .55)), (PYLON_A * 2 + .8, .7, 1.5), 'LN_Stone_Dark',
yaw=yaw_to(AXIS), bev=.06, segments=2))
# Threshold at the arena rim and the stairs down into the dark (the lower lying cave).
parts.append(L.box('Gate_Threshold', tuple(at(GATE_U + .95, 0, .02)), ((PYLON_A - .6) * 2, .16, 1.0), 'LN_Stone_Dark', yaw=yaw_to(AXIS), bev=.03))
treads = []
for k in range(5):
u = GATE_U + .4 - k * .42
top = .02 - (k + 1) * .19
treads.append(L.box('Cave_Step_%d' % k, tuple(at(u, 0, top - .5)), ((PYLON_A - .72) * 2, 1.0, .44), 'LN_Stone_Dark', yaw=yaw_to(AXIS), bev=.02))
return parts, runes, treads, apex
def cave_parts():
"""The dark hollow behind the gate: side and back walls, the well rim at the bottom and the
light from inside (glowing mist and moon well), crystals on the walls."""
walls = []
inner = PYLON_A - .66
for s in (-1, 1):
walls.append(L.box('Cave_Wall_%d' % s, tuple(at(GATE_U - 1.35, s * (inner + .2), 1.4)), (.4, 4.6, 2.8), 'LN_Cave',
yaw=yaw_to(AXIS), bev=0.0))
walls.append(L.box('Cave_Back', tuple(at(GATE_U - 2.75, 0, 1.4)), (inner * 2 + .8, 4.6, .4), 'LN_Cave', yaw=yaw_to(AXIS), bev=0.0))
walls.append(L.box('Cave_Floor', tuple(at(GATE_U - 2.05, 0, -1.25)), (inner * 2 + .4, .4, 1.6), 'LN_Cave', yaw=yaw_to(AXIS), bev=0.0))
walls.append(L.lathe('Cave_WellRim', [(1.02, -1.08), (1.02, -.92), (.86, -.9), (.86, -1.02)], 20, 'LN_Stone_Dark',
center=tuple(at(GATE_U - 2.05, 0, 0))))
glow = [L.panel('Cave_Mist', tuple(at(GATE_U - 2.5, 0, .9)), inner * 2 - .1, 3.6, yaw_to(AXIS), 'LN_Water_Glow', depth=0.0),
L.disc_grid('Cave_Well', tuple(at(GATE_U - 2.05, 0, 0)), .86, 'LN_Water_Glow', rings=2, sides=20, y=-.97)]
for k in range(6):
s = -1 if k % 2 else 1
u = GATE_U - .9 - (k // 2) * .6
c = at(u, s * (inner - .05), .1 + (k // 2) * .55)
glow.append(L.crystal('Cave_Crystal_%d' % k, tuple(c), .14 + .04 * (k % 3), .7 + .2 * (k % 2), .18, 'LN_Crystal_Grotto',
sides=5, tilt=(-.5 * s, .6 * s), seed=k + 30))
return walls, glow
# ---------------------------------------------------------------- moon water troughs
def channel_parts():
"""Two raised troughs of moon water that flank the gate along the back rim."""
parts, water = [], []
for s in (-1, 1):
pts = []
for k in range(8):
ang = pi + pi / 4 + s * (.34 + .5 * k / 7)
pts.append(V((cos(ang) * 6.65, 0, sin(ang) * 6.65)))
parts.append(L.sweep('Channel_Lip_%d' % s, [p + V((0, .16, 0)) for p in pts], [1.0] * 3, [.2] * 3, 'LN_Stone_Dark',
up_hint=(0, 1, 0), steps=18, lens=0.0))
water.append(L.sweep('Channel_Water_%d' % s, [p + V((0, .27, 0)) for p in pts], [.72] * 3, [.02] * 3, 'LN_Water_Glow',
up_hint=(0, 1, 0), steps=18))
return parts, water
# ---------------------------------------------------------------- columns
def column(name, base, height, broken=False, seed=0.0):
parts = [L.lathe(name + '_Base', [(.62, -.05), (.62, .22), (.5, .3), (.48, .42)], 12, 'LN_Stone_Dark', center=tuple(base), rotation=seed)]
top = height if not broken else height * (.42 + .2 * ((seed * 7.3) % 1.0))
y, k = .4, 0
while y < top - .05:
h = min(.9, top - y)
r = .42 - .02 * k
parts.append(L.lathe('%s_Drum_%d' % (name, k), [(r, y), (r * .99, y + h - .03)], 14, 'LN_Stone_Grotto', center=tuple(base),
rotation=seed + k * .3, jitter=.02 if not broken else .05, seed=seed + k))
y += h
k += 1
crystal = None
if broken:
parts.append(L.chunk(name + '_Break', tuple(base + V((0, top + .05, 0))), (.4, .2, .38), 'LN_Stone_Grotto', seed=seed, sink=0.0))
else:
parts.append(L.lathe(name + '_Capital', [(.4, top), (.62, top + .28), (.66, top + .42), (.5, top + .48)], 12, 'LN_Stone_Dark',
center=tuple(base), rotation=seed))
out = V((base.x, 0, base.z)).normalized()
t = V((out.z, 0, -out.x))
for s in (-1, 1):
pts = [base + t * s * .3 + V((0, top + .44, 0)), base + t * s * .58 + V((0, top + .9, 0)), base + t * s * .22 + V((0, top + 1.25, 0))]
parts.append(L.sweep('%s_Horn_%d' % (name, s), pts, [.16, .12, .03], [.14, .1, .03], 'LN_Stone_Grotto', up_hint=tuple(out), steps=10, lens=.3))
crystal = L.crystal(name + '_Crystal', tuple(base + V((0, top + .95, 0))), .15, .38, .22, 'LN_Crystal_Grotto', sides=5, seed=seed)
return parts, crystal
def pillar_parts():
"""Columns along both sides of the arena rim; the sector before the gate stays open so the
entrance is seen from the game camera. Angles are measured from the back direction."""
parts, crystals = [], []
layout = [(62, 4.7, False), (84, 3.9, True), (106, 4.5, False), (128, 3.6, True)]
back = atan2(BACK.z, BACK.x)
index = 0
for degrees, height, broken in layout:
for s in (-1, 1):
ang = back + s * degrees * pi / 180
p = V((cos(ang) * 7.2, 0, sin(ang) * 7.2))
part, crystal = column('Column_%d' % index, p, height, broken, index * 1.37)
parts += part
if crystal is not None:
crystals.append(crystal)
index += 1
# A fallen column on each side, lying along the rim near the front.
for s in (-1, 1):
ang = back + s * 146 * pi / 180
p = V((cos(ang) * 7.0, .42, sin(ang) * 7.0))
d = V((-sin(ang), 0, cos(ang)))
for k in range(3):
c = p + d * (k - 1) * .92 * s
ob = L.lathe('Fallen_%d_%d' % (s, k), [(.4, -.44), (.39, .44)], 14, 'LN_Stone_Grotto', jitter=.03, seed=k + s)
ob.data.transform(Matrix.Rotation(pi / 2, 4, 'X'))
ob.data.transform(Matrix.Rotation(-ang, 4, 'Z'))
ob.location = L.G(c.x, c.y - k * .03, c.z)
parts.append(ob)
return parts, crystals
# ---------------------------------------------------------------- cliff rocks
def rock_parts(apex):
"""Faceted cliff masses in the map's low-poly rock language that frame the gate, crown the
arch and step down along the escarpment. Each rock stands on the real terrain."""
parts = []
# The Top lane wraps round the grotto on three sides, so the rocks stay in the lens between
# the arena and its corridor. (u, a, radius across, height above the foot, radius along
# the axis, lean, seed); every rock is mirrored.
specs = [(GATE_U - .45, 3.95, 1.2, 5.3, 1.1, .06, 1.0), (GATE_U - 2.05, 2.25, 1.0, 5.8, .8, .0, 2.0),
(GATE_U + 1.05, 5.55, 1.15, 3.5, 1.05, .1, 4.0), (-4.6, 7.55, .9, 2.3, .9, .14, 5.0)]
index = 0
for u, a, rx, height, rz, lean, seed in specs:
for s in (-1, 1):
c = at(u, s * a)
foot = min(ground(c + V((dx, 0, dz))) for dx in (-rx * .7, 0, rx * .7) for dz in (-rz * .7, 0, rz * .7))
yaw = yaw_to(AXIS) + s * .12 * seed
check_clear('Rock_%d' % index, c, rx * 1.15, rz * 1.15, yaw)
ry = height * .56
parts.append(L.cliff('Rock_%d' % index, tuple(c + V((0, foot - .45 + ry * .9, 0))), (rx, ry, rz), 'LN_Rock',
seed=seed * 2.3 + (7 if s < 0 else 0), yaw=yaw, lean=(lean * s, lean * .5), flat_bottom=foot - .5,
cuts=9, amp=.2))
index += 1
# The crown above the arch: a flat slab of rock that the lintel sinks into, and the rock
# that closes the cave at the back.
crown = at(GATE_U - .85, 0, apex + 1.0)
check_clear('Rock_Crown', crown, 3.35, 1.45, yaw_to(AXIS))
parts.append(L.cliff('Rock_Crown', tuple(crown), (3.3, 1.05, 1.4), 'LN_Rock', seed=11.0, yaw=yaw_to(AXIS), flat_bottom=apex + .2))
back = at(GATE_U - 2.35, 0)
foot = ground(back)
check_clear('Rock_Back', back, 1.9, .75, yaw_to(AXIS))
parts.append(L.cliff('Rock_Back', tuple(back + V((0, foot + 2.4, 0))), (1.95, 3.0, .7), 'LN_Rock', seed=13.0, yaw=yaw_to(AXIS),
flat_bottom=foot - .4))
return parts
def build():
L.reset()
floor, runes = floor_parts()
platform = platform_parts()
altar, altar_runes, source = altar_parts()
gate, gate_runes, treads, apex = gate_parts()
cave, glow = cave_parts()
channel, water = channel_parts()
pillars, pillar_crystals = pillar_parts()
rocks = rock_parts(apex)
if _problems:
raise SystemExit('GROTTO_CLEARANCE_FAILED: ' + '; '.join(_problems))
runes += altar_runes + gate_runes
objects = {
'Floor': L.join(floor + channel, 'Floor'),
'Platform': L.join(platform, 'Platform'),
'Altar': L.join(altar, 'Altar'),
'Gate': L.join(gate, 'Gate'),
'Pillars': L.join(pillars, 'Pillars'),
'Rocks': L.join(rocks, 'Rocks'),
'Cave': L.join(cave + treads, 'Cave'),
'Runes': L.join(runes, 'Runes'),
'Source': L.join(source, 'Source', origin=tuple(BOSS + BACK * 2.3 + V((0, 1.4, 0)))),
'PillarCrystals': L.join(pillar_crystals, 'PillarCrystals'),
'CaveGlow': L.join(glow, 'CaveGlow'),
'Channel': L.join(water, 'Channel'),
}
solid = [objects[k] for k in ('Floor', 'Platform', 'Altar', 'Gate', 'Pillars', 'Rocks', 'Cave', 'Runes')]
built = [ob for ob in solid if ob.name != 'Rocks']
L.finalize(built + [objects['Source'], objects['PillarCrystals']], occluders=solid, reach=3.0, ground=-1.3)
# Rocks keep hard facets like the map's low-poly rocks.
L.finalize([objects['Rocks']], sharp_angle=14.0, occluders=solid, reach=3.0, ground=-1.3)
L.finalize([objects['CaveGlow'], objects['Channel']], ao=False)
everything = list(objects.values())
for ob in everything:
L.triangulate(ob)
doc, raw = L.export(everything, 'bossgrotte.glb', 'bossgrotte.blend')
entry = L.record('bossgrotte', 'bossgrotte.glb', 'bossgrotte.blend', 'build_bossgrotte.py', everything, doc, raw,
title='Mondschlund (Bossgrotte)', boss_point=[-1.5, 0.0, -1.5], arena_radius=ARENA,
gate=[round(x, 3) for x in at(GATE_U, 0)], lane_clearance_min=CLEAR,
cave_notch={'u': [GATE_U - 3.0, GATE_U + .5], 'half_width': PYLON_A - .66, 'depth': 1.0},
note='symmetric across the local diagonal x = z; the Godot view places it at LuneMap.GROTTO on the terrace')
L.write_manifest([entry])
print('GROTTO_BUILD', entry['triangles'], 'triangles', 'bounds', entry['bounds_godot'], flush=True)
build()