"""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()