saumwacht-godot/assets-source/luneraria/g2/build_ancient.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

290 lines
15 KiB
Python

"""G2 Ancient of Champions of Luneraria: the "Mondherz" (moon heart) sanctuary.
The heart is the existing Saumkern crystal (phase 4, assets-source/phase4/blend/core_team0.blend:
Core_CrystalMain and its five satellites), appended here and scaled up. Around it:
- a wide, very low dais (three steps, 12 cm in total, so figures standing on it keep their
feet above the floor) with a rune circle,
- an octagonal basalt and moonstone pedestal with rune panels (radius 2.0 m at the floor:
melee attackers stand 2.3 m from the centre and never clip into it),
- four crescent blades rising from the pedestal, bowing out over the heads of the
attackers and meeting above the crystal (split into Lower and Upper so the upper halves
can fall when the Ancient is destroyed),
- two counter-rotating armillary rings with rune bands and six floating fragments,
- a crown per team: Crest_A (cool team) two crescent horns around a moon orb,
Crest_B (warm team) a ring of sun rays around a sun orb,
- two banner poles behind the sanctuary, banners take the team silhouette in the shader,
- ruin pieces: shattered crystal and blade rubble shown after the collapse.
The hitbox and every gameplay distance stay those of LuneMap (building radius 1.3 m); both
teams use the same geometry apart from the crest.
Local frame: +z faces the enemy (map centre), -z the base corner. Pivot at the dais centre.
Run: ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/g2/build_ancient.py
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bpy
import lune_bpy as L
from lune_bpy import V, pi, sin, cos
from mathutils import noise, Matrix
OCT = pi / 8
SAUMKERN = L.P / 'assets-source/phase4/blend/core_team0.blend'
CRYSTAL_Y = 4.7
CRYSTAL_SCALE = 2.3
# Blades on the local axes: from the usual camera the crystal shows between two blades.
BLADE_ANGLES = (0.0, pi / 2, pi, 3 * pi / 2)
BLADE_SPLIT = 3.4
def octa(name, profile, mat, **kw):
return L.lathe(name, profile, 8, mat, rotation=OCT, **kw)
def blade_points(angle, lower=True, upper=True):
d = V((cos(angle), 0, sin(angle)))
local = [(1.36, 1.18), (1.92, 2.3), (2.36, 3.4), (2.46, 4.6), (2.12, 6.2), (1.3, 7.7), (.52, 8.62)]
widths = [.66, .62, .56, .5, .42, .3, .18]
depths = [.52, .5, .46, .42, .36, .28, .18]
if lower and not upper:
sel = slice(0, 3)
elif upper and not lower:
sel = slice(2, 7)
else:
sel = slice(0, 7)
return [d * r + V((0, y, 0)) for r, y in local[sel]], widths[sel], depths[sel]
def blade(name, angle, part):
t = V((-sin(angle), 0, cos(angle)))
points, widths, depths = blade_points(angle, part != 'upper', part != 'lower')
return L.sweep(name, points, widths, depths, 'LN_Stone_Pale', up_hint=tuple(t), steps=16 if part != 'full' else 30, lens=.28)
def blade_trim(name, angle, part, offset, mat, width, depth_scale):
"""Metal cladding along the outer edge (offset > 0) or a rune vein along the inner edge
(offset < 0) of a blade; the offset follows the in-plane normal of the curve."""
t = V((-sin(angle), 0, cos(angle)))
points, widths, depths = blade_points(angle, part != 'upper', part != 'lower')
shifted = []
for i, p in enumerate(points):
a = points[max(0, i - 1)]
b = points[min(len(points) - 1, i + 1)]
tangent = (b - a).normalized()
outward = tangent.cross(t).normalized()
shifted.append(p + outward * offset * widths[i] * .5)
return L.sweep(name, shifted, [width] * len(points), [x * depth_scale for x in depths], mat, up_hint=tuple(t),
steps=16, lens=.1, uv_cells=.34)
def append_saumkern():
names = ['Core_CrystalMain'] + ['Core_CrystalSatellite%d' % i for i in range(5)]
with bpy.data.libraries.load(str(SAUMKERN), link=False) as (source, target):
target.objects = [n for n in names if n in source.objects]
result = []
for ob in target.objects:
bpy.context.collection.objects.link(ob)
world = ob.matrix_world.copy()
ob.parent = None
ob.data = ob.data.copy()
# Bake the object transform, then scale around the Saumkern crystal centre.
ob.data.transform(world)
ob.matrix_world = Matrix.Identity(4)
ob.modifiers.clear()
ob.hide_viewport = False
ob.hide_render = False
ob.hide_select = False
ob.hide_set(False)
ob.data.transform(Matrix.Translation(V((0, 0, -1.32))))
ob.data.transform(Matrix.Scale(CRYSTAL_SCALE, 4))
ob.data.transform(Matrix.Translation(V((0, 0, CRYSTAL_Y))))
ob.data.materials.clear()
ob.data.materials.append(L.material('LN_Crystal_Team'))
for poly in ob.data.polygons:
poly.material_index = 0
result.append(ob)
return result
def crest_a():
"""Cool team: two crescent horns rising from the crown ring around a small moon orb."""
parts = []
for i, a in enumerate((0.0, pi)):
d = V((cos(a), 0, sin(a)))
pts = [d * .62 + V((0, 8.72, 0)), d * 1.0 + V((0, 9.3, 0)), d * .82 + V((0, 9.95, 0)), d * .22 + V((0, 10.35, 0))]
parts.append(L.sweep('CrestA_Horn_%d' % i, pts, [.3, .26, .16, .04], [.2, .18, .12, .03], 'LN_Metal_Team',
up_hint=(0, 0, 1), steps=18, lens=.35))
inlay = [p + V((0, 0, .1)) for p in pts]
parts.append(L.sweep('CrestA_Vein_%d' % i, inlay, [.08, .07, .05, .02], [.05, .05, .04, .02], 'LN_Rune_Team',
up_hint=(0, 0, 1), steps=18, uv_cells=.3))
parts.append(L.lathe('CrestA_Orb', [(0.0, 9.28), (.2, 9.36), (.28, 9.55), (.2, 9.74), (0.0, 9.82)], 16, 'LN_Crystal_Team', smooth=True))
return parts
def crest_b():
"""Warm team: a crown of nine sun rays around a sun orb."""
parts = []
for k in range(9):
a = 2 * pi * k / 9
d = V((cos(a), 0, sin(a)))
length = 1.15 if k % 2 == 0 else .82
pts = [d * .5 + V((0, 8.74, 0)), d * (.5 + length * .45) + V((0, 9.05 + length * .32, 0)), d * (.5 + length * .7) + V((0, 9.2 + length * .62, 0))]
t = V((-sin(a), 0, cos(a)))
parts.append(L.sweep('CrestB_Ray_%d' % k, pts, [.22, .14, .02], [.12, .09, .02], 'LN_Metal_Team', up_hint=tuple(t), steps=10, lens=.3))
parts.append(L.lathe('CrestB_Orb', [(0.0, 9.0), (.26, 9.08), (.34, 9.3), (.26, 9.52), (0.0, 9.6)], 18, 'LN_Crystal_Team', smooth=True))
parts.append(L.ring('CrestB_Halo', (0, 9.3, 0), .5, .06, .08, 'LN_Metal_Team', sides=32, axis_tilt=(pi / 2, 0)))
return parts
def armillary(name, radius, tilt, sides=56):
metal = L.ring(name + '_Metal', (0, 0, 0), radius, .13, .11, 'LN_Metal_Team', sides=sides, uv_cell=None)
runes = L.band(name + '_Runes', (0, -.035, 0), radius + .068, 0.0, .07, 'LN_Rune_Team', sides=sides, uv_cell=.3)
ob = L.join([metal, runes], name)
ob.data.transform(Matrix.Rotation(tilt[0], 4, 'X') @ Matrix.Rotation(tilt[1], 4, 'Y'))
ob.location = L.G(0, CRYSTAL_Y, 0)
return ob
def fragment(name, index):
a = 2 * pi * index / 6 + .3
r = 3.05
y = 3.3 + (index % 3) * .8
c = (cos(a) * r, y, sin(a) * r)
size = .17 + .05 * (index % 2)
return L.crystal(name, c, size, size * 2.6, size * 1.8, 'LN_Crystal_Team', sides=5, tilt=(.3 * noise.noise(V((index, 0, 0))), a), seed=index * 3.7)
def banner_pole(name, angle, radius):
d = V((cos(angle), 0, sin(angle)))
t = V((-sin(angle), 0, cos(angle)))
base = d * radius
parts = [
L.lathe(name + '_Foot', [(.36, -.05), (.36, .3), (.26, .38), (.2, .5)], 8, 'LN_Stone_Dark', center=(base.x, 0, base.z)),
L.lathe(name + '_Shaft', [(.1, .45), (.085, 6.2)], 10, 'LN_Metal_Iron', center=(base.x, 0, base.z)),
L.box(name + '_Bar', (base.x, 5.95, base.z), (.08, .08, 1.5), 'LN_Metal_Team', yaw=-angle, bev=.012),
L.crystal(name + '_Tip', (base.x, 6.45, base.z), .09, .3, .1, 'LN_Crystal_Team', sides=5, seed=angle),
]
return parts, base, t
def build():
L.reset()
parts = {}
# Dais: three shallow steps and the rune circle.
# Step tops at 5, 10 and 15 cm; the Godot world lifts figures standing on them (DAIS_STEPS).
parts['Dais'] = [
L.lathe('Dais_0', [(4.0, -.06), (4.0, .05), (3.93, .055)], 64, 'LN_Stone_Dark'),
L.lathe('Dais_1', [(3.9, .05), (3.4, .055), (3.4, .1), (3.33, .105)], 64, 'LN_Stone_Pale'),
L.lathe('Dais_2', [(3.3, .1), (2.8, .105), (2.8, .15), (2.73, .155)], 64, 'LN_Stone_Dark'),
L.disc_grid('Dais_Top', (0, 0, 0), 2.72, 'LN_Stone_Pale', rings=3, sides=64, y=.154),
]
rune_circle = L.lathe('Dais_Runes', [(3.26, .107), (3.02, .107)], 64, 'LN_Rune_Team', cap_top=False, cap_bottom=False, uv_band=.4)
parts['Dais'].append(rune_circle)
# Pedestal: basalt foot, moonstone body with rune panels, cap and the crystal seat.
parts['Pedestal'] = [
octa('Ped_Foot', [(2.0, .1), (2.0, .44), (1.86, .5)], 'LN_Stone_Dark', jitter=.01, seed=2),
octa('Ped_Body', [(1.74, .48), (1.66, .78), (1.56, 1.04), (1.5, 1.1)], 'LN_Stone_Pale'),
octa('Ped_Cap', [(1.5, 1.08), (1.54, 1.16), (1.42, 1.26), (1.3, 1.3)], 'LN_Stone_Dark'),
L.lathe('Ped_Seat', [(1.02, 1.28), (1.12, 1.42), (.9, 1.56), (.7, 1.6)], 32, 'LN_Metal_Team'),
octa('Ped_Runes', [(1.732, .58), (1.672, .98)], 'LN_Rune_Team', cap_top=False, cap_bottom=False, uv_band=.42),
]
for k in range(8):
a = 2 * pi * k / 8
parts['Pedestal'].append(L.box('Ped_Rib_%d' % k, (cos(a) * 1.64, .8, sin(a) * 1.64), (.2, .62, .16), 'LN_Stone_Dark', yaw=-a + pi / 2, bev=.02))
# Blades: Lower halves stay, Upper halves are separate objects that can fall.
lowers, uppers = [], {}
for i, a in enumerate(BLADE_ANGLES):
lowers.append(blade('Blade_%d_LowerStone' % i, a, 'lower'))
lowers.append(blade_trim('Blade_%d_LowerClad' % i, a, 'lower', 1.02, 'LN_Metal_Team', .1, 1.08))
lowers.append(blade_trim('Blade_%d_LowerVein' % i, a, 'lower', -1.0, 'LN_Rune_Team', .07, .6))
uppers[i] = [blade('Blade_%d_UpperStone' % i, a, 'upper'),
blade_trim('Blade_%d_UpperClad' % i, a, 'upper', 1.02, 'LN_Metal_Team', .1, 1.08),
blade_trim('Blade_%d_UpperVein' % i, a, 'upper', -1.0, 'LN_Rune_Team', .07, .6)]
crown = [L.ring('Crown_Ring', (0, 8.62, 0), .62, .16, .22, 'LN_Metal_Team', sides=40, uv_cell=.28),
L.band('Crown_Runes', (0, 8.55, 0), .71, 0.0, .12, 'LN_Rune_Team', sides=40, uv_cell=.28)]
heart = append_saumkern()
rings = {'Ring_Outer': armillary('Ring_Outer', 1.86, (.2, .0)), 'Ring_Inner': armillary('Ring_Inner', 1.46, (-.34, .6))}
fragments = [fragment('Fragment_%d' % i, i) for i in range(6)]
poles, cloth = [], []
for side, key in ((1, 'Banner_L'), (-1, 'Banner_R')):
angle = -pi / 2 + side * .95
pole_parts, base, t = banner_pole('Pole_%s' % key[-1], angle, 4.6)
poles += pole_parts
top = base + V((0, 5.9, 0)) + V((cos(angle), 0, sin(angle))) * .12
cloth.append(L.banner(key, 1.15, 2.9, 'LN_Cloth_Team', top=tuple(top), segments=(6, 14), yaw=-(angle + pi / 2)))
# Ruin pieces: shattered heart on the seat and blade rubble on the dais.
ruin_crystal = []
for k in range(9):
a = 2 * pi * k / 9 + .4
r = .35 + .5 * ((k * .37) % 1.0)
size = .16 + .1 * ((k * .53) % 1.0)
ruin_crystal.append(L.crystal('RuinCrystal_%d' % k, (cos(a) * r, 1.55 + size * .4, sin(a) * r), size, size * 2.2, size * .8,
'LN_Crystal_Team', sides=5, tilt=(1.0 + .5 * ((k * .29) % 1.0), a), seed=k))
for k in range(5):
a = 2 * pi * k / 5 + 1.1
r = 2.3 + .4 * ((k * .61) % 1.0)
size = .2 + .08 * ((k * .7) % 1.0)
ruin_crystal.append(L.crystal('RuinCrystalFar_%d' % k, (cos(a) * r, .17 + size * .3, sin(a) * r), size, size * 2.0, size * .6,
'LN_Crystal_Team', sides=5, tilt=(1.4, a), seed=k + 20))
ruin_rubble = []
# Broken caps on the blade stumps, so the cut at the split height reads as a fracture.
for i, a in enumerate(BLADE_ANGLES):
d = V((cos(a), 0, sin(a)))
for j in range(3):
at = d * (2.28 + .12 * (j - 1)) + V((0, BLADE_SPLIT - .05 + .08 * j, 0)) + V((-sin(a), 0, cos(a))) * (.12 * (j - 1))
ruin_rubble.append(L.chunk('Cap_%d_%d' % (i, j), tuple(at), (.3, .22, .24), 'LN_Stone_Pale', seed=i * 5.0 + j, yaw=a + j, tilt=.4 * (j - 1), sink=0.0))
for k in range(18):
a = 2 * pi * ((k * .618) % 1.0)
r = 2.1 + 2.0 * ((k * .377) % 1.0)
s = .28 + .3 * ((k * .733) % 1.0)
ruin_rubble.append(L.chunk('Rubble_%d' % k, (cos(a) * r, .1 + s * .2, sin(a) * r), (s * .9, s * .45, s * .7),
'LN_Stone_Pale' if k % 3 else 'LN_Metal_Team', seed=k * 1.7, yaw=a * 2.3, tilt=.2))
# Join into the scene objects the Godot view drives.
objects = []
dais = L.join(parts['Dais'], 'Dais')
pedestal = L.join(parts['Pedestal'] + lowers + poles, 'Pedestal')
objects += [dais, pedestal]
upper_obs = []
for i, a in enumerate(BLADE_ANGLES):
d = V((cos(a), 0, sin(a)))
pivot = d * 2.36 + V((0, BLADE_SPLIT, 0))
upper_obs.append(L.join(uppers[i], 'Blade_%d' % i, origin=tuple(pivot)))
crown_ob = L.join(crown, 'Crown', origin=(0, 8.62, 0))
crest_a_ob = L.join(crest_a(), 'Crest_A', origin=(0, 8.62, 0))
crest_b_ob = L.join(crest_b(), 'Crest_B', origin=(0, 8.62, 0))
heart_main = L.join([heart[0]], 'Crystal', origin=(0, CRYSTAL_Y, 0))
satellites = []
for i, ob in enumerate(heart[1:]):
c = sum((ob.matrix_world @ v.co for v in ob.data.vertices), V()) / len(ob.data.vertices)
satellites.append(L.join([ob], 'Satellite_%d' % i, origin=L.to_godot(c)))
ring_obs = []
for key, ob in rings.items():
ob.name = key
ring_obs.append(L.join([ob], key, origin=(0, CRYSTAL_Y, 0)))
frag_obs = []
for ob in fragments:
c = sum((ob.matrix_world @ v.co for v in ob.data.vertices), V()) / len(ob.data.vertices)
frag_obs.append(L.join([ob], ob.name, origin=L.to_godot(c)))
cloth_obs = [L.join([ob], ob.name) for ob in cloth]
ruin_c = L.join(ruin_crystal, 'Ruin_Crystal')
ruin_r = L.join(ruin_rubble, 'Ruin_Rubble')
structure = [dais, pedestal] + upper_obs + [crown_ob, crest_a_ob, crest_b_ob]
energy = [heart_main] + satellites + ring_obs + frag_obs
L.finalize(structure + energy, occluders=structure + ring_obs)
L.finalize([ruin_c, ruin_r], occluders=[dais, pedestal, ruin_c, ruin_r])
L.finalize(cloth_obs, ao=False)
everything = structure + energy + cloth_obs + [ruin_c, ruin_r]
for ob in everything:
L.triangulate(ob)
doc, raw = L.export(everything, 'ancient.glb', 'ancient.blend')
entry = L.record('ancient', 'ancient.glb', 'ancient.blend', 'build_ancient.py', structure + energy, doc, raw,
title='Mondherz (Ancient)', crystal_height=CRYSTAL_Y, blade_split=BLADE_SPLIT,
heart_source='assets-source/phase4/blend/core_team0.blend (Core_CrystalMain, Core_CrystalSatellite0-4), scale %.1f' % CRYSTAL_SCALE,
triangles_intact=L.triangles(structure + energy + cloth_obs) - L.triangles([crest_b_ob]),
triangles_ruin=L.triangles([ruin_c, ruin_r]))
L.write_manifest([entry])
print('ANCIENT_BUILD', entry['triangles'], 'triangles', 'height %.2f' % entry['bounds_godot']['max'][1],
'radius %.2f' % max(abs(entry['bounds_godot']['min'][0]), entry['bounds_godot']['max'][0]), flush=True)
build()