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>
290 lines
15 KiB
Python
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()
|