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>
This commit is contained in:
Martin Stickelbroeck 2026-09-29 14:12:49 +02:00
parent 8b17563814
commit 9f62e30377
200 changed files with 11480 additions and 323 deletions

3
.gitignore vendored
View file

@ -2,3 +2,6 @@
*.tmp *.tmp
docs/screenshots/ docs/screenshots/
test-results/ test-results/
__pycache__/
.DS_Store
override.cfg

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,290 @@
"""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()

View file

@ -0,0 +1,386 @@
"""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()

View file

@ -0,0 +1,498 @@
"""The Schlundkrone, superboss of Champions of Luneraria (G2): an ancient crystal-bound titan
of Luneraria that the old Lunarians chained in the Mondschlund.
Design (the silhouette carries it, not the glow):
* a hunched, broad basalt titan that walks on its knuckles: huge shoulder masses, very long
forearms and short pillar legs - no other unit stands like this;
* the "Krone": a broken crescent halo of crystal shards that floats behind its low head;
* the "Schlund": its chest opens like a maw (two plates with moonstone teeth) around a
glowing heart crystal;
* crescent horns, a crystal cluster that grows from the left shoulder only, broken bronze
shackles of the old Lunarians on both wrists;
* lunar veins in the basalt glow in the colour of the team it fights for (violet in the
grotto), so the same model reads as neutral boss and as lane reinforcement.
The footprint stays compact (the simulation keeps attackers 1.2-1.5 m from its centre).
Rig: rigid stone parts weighted 1.0 to 22 bones (Root, Hips, Spine, Chest, Neck, Head, Jaw,
Crown, Maw.L/R, Shoulder/UpperArm/Forearm/Hand.L/R, Thigh/Shin/Foot.L/R). The bone names used
by SkeletalFigure's overlays (Spine, Head, Forearm.L/R) exist. Clips at 30 fps: idle, start,
walk, stop, attack_a, attack_b (contact at 0.90 s as SkeletalFigure expects), hit, ability,
death, roar (the grotto boss wakes), victory (claim). Animations are presentation only; the
rules decide every hit.
Rest positions are written to UV (x, z) and UV2 (y) so the runtime hide shader does not swim
on the skinned mesh. Model units are metres at view scale 1; the game shows it at 1.7.
Run: ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/g2/build_schlundkrone.py
Godot +z is the front (glTF +Z forward, Blender -Y), .L sits at +x.
"""
import sys, json, hashlib, struct
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
import bpy, bmesh
from mathutils import Matrix, Quaternion
FPS = 30
CONTACT = .90
STRIDE = 2.0
VIEW_SCALE = 1.7
CLIPS = {'idle': (3.0, True), 'start': (.4, False), 'walk': (1.6, True), 'stop': (.4, False), 'attack_a': (1.3, False),
'attack_b': (1.3, False), 'hit': (.5, False), 'ability': (1.3, False), 'death': (2.2, False), 'roar': (2.0, False),
'victory': (2.0, False)}
SIDES = (('L', 1), ('R', -1))
# Bones in Godot coordinates: head, tail, parent.
BONES = {'Root': ((0, 0, 0), (0, .3, 0), None),
'Hips': ((0, .98, -.12), (0, 1.2, -.06), 'Root'),
'Spine': ((0, 1.2, -.06), (0, 1.4, .04), 'Hips'),
'Chest': ((0, 1.4, .04), (0, 1.84, .2), 'Spine'),
'Neck': ((0, 1.7, .3), (0, 1.72, .46), 'Chest'),
'Head': ((0, 1.72, .46), (0, 1.75, .78), 'Neck'),
'Jaw': ((0, 1.64, .52), (0, 1.58, .78), 'Head'),
'Crown': ((0, 2.1, -.16), (0, 2.42, -.16), 'Chest')}
for _side, _sx in SIDES:
BONES['Maw.' + _side] = ((_sx * .28, 1.5, .45), (_sx * .28, 1.22, .47), 'Chest')
BONES['Shoulder.' + _side] = ((_sx * .18, 1.78, .08), (_sx * .6, 1.84, .06), 'Chest')
BONES['UpperArm.' + _side] = ((_sx * .6, 1.84, .06), (_sx * .82, 1.22, .22), 'Shoulder.' + _side)
BONES['Forearm.' + _side] = ((_sx * .82, 1.22, .22), (_sx * .84, .5, .42), 'UpperArm.' + _side)
BONES['Hand.' + _side] = ((_sx * .84, .5, .42), (_sx * .84, .16, .5), 'Forearm.' + _side)
BONES['Thigh.' + _side] = ((_sx * .3, .98, -.12), (_sx * .38, .52, .04), 'Hips')
BONES['Shin.' + _side] = ((_sx * .38, .52, .04), (_sx * .38, .14, -.08), 'Thigh.' + _side)
BONES['Foot.' + _side] = ((_sx * .38, .14, -.08), (_sx * .38, .06, .22), 'Shin.' + _side)
parts = []
def add(ob, bone):
vg = ob.vertex_groups.new(name=bone)
vg.add(list(range(len(ob.data.vertices))), 1.0, 'REPLACE')
parts.append(ob)
return ob
def orient(ob, pivot, direction):
"""Turns an object built upright at the origin (Godot +y) towards `direction` and moves it
to `pivot` (Godot coordinates)."""
d = L.G(*direction).normalized()
rot = V((0, 0, 1)).rotation_difference(d).to_matrix().to_4x4()
ob.data.transform(Matrix.Translation(L.G(*pivot)) @ rot)
ob.data.update()
return ob
def rock(name, a, b, r, bone, mat='LN_Boss_Hide', seed=0.0, squash=1.0, subdiv=2, cuts=7, amp=.16):
"""Faceted stone segment between Godot points a and b."""
a, b = V(a), V(b)
half = (b - a).length * .5
ob = L.cliff(name, (0, 0, 0), (r, half * 1.08, r * squash), mat, seed=seed, subdiv=subdiv, cuts=cuts, amp=amp)
return add(orient(ob, tuple((a + b) * .5), tuple(b - a)), bone)
def mass(name, center, radii, bone, mat='LN_Boss_Hide', seed=0.0, lean=(0.0, 0.0), yaw=0.0, subdiv=2, cuts=7, amp=.16):
return add(L.cliff(name, center, radii, mat, seed=seed, yaw=yaw, lean=lean, subdiv=subdiv, cuts=cuts, amp=amp), bone)
def spike(name, base, direction, radius, length, bone, mat='LN_Crystal_Boss', sides=6, seed=0):
ob = L.crystal(name, (0, 0, 0), radius, length, length * .22, mat, sides=sides, seed=seed)
return add(orient(ob, base, direction), bone)
def claw(name, points, width, bone, mat='LN_Boss_Bone', steps=10):
n = len(points)
widths = [width * (1 - .85 * k / (n - 1)) for k in range(n)]
return add(L.sweep(name, points, widths, [w * .85 for w in widths], mat, up_hint=(0, 1, 0), steps=steps, lens=.35), bone)
# ================================================================ body
def build_body():
# Pelvis, belly and the leaning chest block, the back hump.
mass('Pelvis', (0, .98, -.1), (.42, .26, .32), 'Hips', seed=1.0)
mass('Loin', (0, .84, .14), (.26, .18, .1), 'Hips', seed=2.0)
mass('Belly', (0, 1.2, .02), (.38, .25, .3), 'Spine', seed=3.0)
mass('ChestMass', (0, 1.6, .12), (.6, .36, .42), 'Chest', seed=4.0, lean=(.3, 0))
mass('Hump', (0, 1.88, -.16), (.5, .28, .36), 'Chest', seed=5.0, lean=(-.2, 0))
for side, sx in SIDES:
mass('Flank.' + side, (sx * .3, 1.72, -.3), (.3, .26, .26), 'Chest', seed=6.0 + sx)
# The Schlund: two chest plates with moonstone teeth that open around the heart.
mass('MawPlate.' + side, (sx * .16, 1.36, .5), (.15, .21, .075), 'Maw.' + side, seed=8.0 + sx, subdiv=1, cuts=5)
for k in range(4):
y = 1.23 + k * .085
claw('Tooth.%s.%d' % (side, k), [(sx * .045, y, .55), (-sx * .015, y - .02, .57), (-sx * .05, y - .04, .565)], .05, 'Maw.' + side, steps=5)
# The heart inside the Schlund and the dark hollow around it.
mass('Hollow', (0, 1.37, .38), (.2, .24, .1), 'Chest', mat='LN_Boss_Hide', seed=9.0, subdiv=1, cuts=4)
spike('Heart', (0, 1.26, .43), (0, 1, .12), .11, .26, 'Chest', seed=3)
# Neck, skull, brow, eyes, crescent horns and the jaw with fangs.
mass('NeckMass', (0, 1.72, .36), (.17, .14, .15), 'Neck', seed=10.0)
mass('Skull', (0, 1.76, .62), (.19, .16, .22), 'Head', seed=11.0, lean=(.15, 0))
mass('Brow', (0, 1.83, .77), (.17, .05, .06), 'Head', mat='LN_Boss_Bone', seed=12.0, subdiv=1, cuts=4)
for side, sx in SIDES:
spike('Eye.' + side, (sx * .075, 1.775, .795), (sx * .3, .2, 1), .032, .05, 'Head', sides=5, seed=4 + sx)
claw('Horn.' + side, [(sx * .13, 1.83, .58), (sx * .34, 2.02, .52), (sx * .43, 2.2, .66), (sx * .32, 2.22, .92)], .16, 'Head', steps=14)
claw('Fang.' + side, [(sx * .08, 1.6, .74), (sx * .08, 1.69, .77), (sx * .07, 1.74, .78)], .05, 'Jaw', steps=5)
mass('JawMass', (0, 1.62, .66), (.15, .06, .16), 'Jaw', seed=13.0, subdiv=1, cuts=5)
# Shoulders: huge pauldron masses; crystals grow from the left one only (and a few from
# the right), so the silhouette is asymmetric and unmistakable.
for side, sx in SIDES:
mass('Pauldron.' + side, (sx * .64, 1.93, .02), (.37, .29, .37), 'Shoulder.' + side, seed=14.0 + sx, lean=(0, -sx * .25))
mass('PauldronRim.' + side, (sx * .8, 1.78, .08), (.2, .16, .3), 'Shoulder.' + side, mat='LN_Boss_Bone', seed=15.0 + sx,
subdiv=1, cuts=5)
for k, (base, direction, length) in enumerate([((.7, 2.1, .0), (.75, .75, -.15), .78), ((.86, 1.98, .12), (1, .35, .15), .5),
((.62, 2.08, -.2), (.55, .6, -.6), .55)]):
spike('ShoulderCrystal.L.%d' % k, base, direction, .09 - .012 * k, length, 'Shoulder.L', seed=20 + k)
spike('ShoulderCrystal.R.0', (-.74, 2.06, -.06), (-.8, .7, -.2), .06, .34, 'Shoulder.R', seed=30)
# Arms: upper arm, the massive forearm with crystal spurs and a bronze shackle, the fist
# with moonstone claws that the titan walks on.
for side, sx in SIDES:
sh, el, wr, fi = [V(p) for p in (BONES['UpperArm.' + side][0], BONES['Forearm.' + side][0], BONES['Hand.' + side][0],
BONES['Hand.' + side][1])]
rock('UpperArm.' + side, sh + V((0, .06, 0)), el, .2, 'UpperArm.' + side, seed=50 + sx)
mass('Elbow.' + side, tuple(el + V((sx * .04, 0, -.05))), (.17, .15, .17), 'Forearm.' + side, seed=52 + sx, subdiv=1, cuts=5)
rock('Forearm.' + side, el, wr + V((0, -.02, 0)), .25, 'Forearm.' + side, seed=54 + sx, squash=.92)
along = (wr - el).normalized()
out = V((sx, 0, -.35)).normalized()
for k in range(3):
base = el + (wr - el) * (.25 + .25 * k) + out * .2
spike('ArmSpur.%s.%d' % (side, k), tuple(base), tuple((out * 1.0 - along * .6 + V((0, .3, 0))).normalized()), .05,
.32 - .06 * k, 'Forearm.' + side, sides=5, seed=60 + k + (3 if sx < 0 else 0))
cuff = L.lathe('Shackle.' + side, [(.255, -.07), (.27, -.055), (.27, .055), (.255, .07), (.235, .07), (.235, -.07)], 10,
'LN_Boss_Bronze', cap_top=False, cap_bottom=False)
add(orient(cuff, tuple(wr + (el - wr).normalized() * .14), tuple(wr - el)), 'Forearm.' + side)
link = L.lathe('ShackleLink.' + side, [(.07, -.03), (.07, .03), (.045, .03), (.045, -.03)], 8, 'LN_Boss_Bronze', cap_top=False,
cap_bottom=False)
add(orient(link, tuple(wr + (el - wr).normalized() * .14 + V((sx * .3, -.1, -.05))), (1, .2, 0)), 'Forearm.' + side)
mass('Fist.' + side, tuple(wr + V((0, -.17, .05))), (.2, .19, .22), 'Hand.' + side, seed=70 + sx)
for k in range(3):
x = sx * (.1 - .1 * k)
claw('Claw.%s.%d' % (side, k), [tuple(fi + V((x, .02, .08))), tuple(fi + V((x * 1.1, -.08, .2))), tuple(fi + V((x * 1.1, -.15, .24)))],
.075, 'Hand.' + side, steps=6)
# Legs: short stone pillars, knee plates, broad feet with moonstone toes.
for side, sx in SIDES:
hip, knee, ankle, toe = [V(p) for p in (BONES['Thigh.' + side][0], BONES['Shin.' + side][0], BONES['Foot.' + side][0],
BONES['Foot.' + side][1])]
rock('Thigh.' + side, hip, knee, .22, 'Thigh.' + side, seed=80 + sx)
mass('Knee.' + side, tuple(knee + V((sx * .02, .02, .12))), (.15, .13, .1), 'Thigh.' + side, mat='LN_Boss_Bone', seed=82 + sx,
subdiv=1, cuts=5)
rock('Shin.' + side, knee, ankle, .19, 'Shin.' + side, seed=84 + sx)
mass('FootMass.' + side, (sx * .38, .1, .06), (.2, .1, .27), 'Foot.' + side, seed=86 + sx, subdiv=1, cuts=5)
for k in range(3):
x = sx * (.38 + (k - 1) * .11)
claw('Toe.%s.%d' % (side, k), [(x, .08, .26), (x, .05, .37), (x, .01, .42)], .07, 'Foot.' + side, steps=5)
# ================================================================ crown
def build_crown():
"""A broken crescent halo of crystal shards behind the head, bound by an old bronze band."""
center = V((0, 2.1, -.16))
radius = .66
band = []
for k in range(15):
a = pi * (.1 + .8 * k / 14)
band.append(center + V((cos(a) * (radius - .05), sin(a) * (radius - .05), 0)))
add(L.sweep('CrownBand', band, [.07] * 5, [.05] * 5, 'LN_Boss_Bronze', up_hint=(0, 0, 1), steps=28), 'Crown')
# One shard is missing (the broken crown), the others grow longer towards the apex.
for k, a in enumerate([.13, .27, .42, .5, .58, .73]):
ang = pi * a
direction = V((cos(ang), sin(ang), -.08))
length = .3 + .26 * (1 - abs(a - .5) * 2.4)
base = center + V((cos(ang) * radius, sin(ang) * radius, 0))
spike('CrownShard.%d' % k, tuple(base), tuple(direction), .07 + .025 * (1 - abs(a - .5) * 2), length, 'Crown', seed=90 + k)
# ================================================================ rig
def build_rig():
bpy.ops.object.armature_add(enter_editmode=True)
rig = bpy.context.object
rig.name = 'SchlundkroneRig'
ed = rig.data.edit_bones
ed.remove(ed[0])
for name, (head, tail, parent) in BONES.items():
b = ed.new(name)
b.head, b.tail = L.G(*head), L.G(*tail)
if parent:
b.parent = ed[parent]
bpy.ops.object.mode_set(mode='OBJECT')
return rig
def pose_rotation(pb, r):
"""Rotation r = (x, y, z) about the armature axes (Blender), turned into the bone's frame."""
q = Quaternion((0, 0, 1), r[2]) @ Quaternion((0, 1, 0), r[1]) @ Quaternion((1, 0, 0), r[0])
basis = pb.bone.matrix_local.to_quaternion()
return basis.inverted() @ q @ basis
def curve(t, keys):
for (a, x), (b, y) in zip(keys, keys[1:]):
if t <= b:
u = max(0.0, (t - a) / (b - a))
u = u * u * (3 - 2 * u)
return x * (1 - u) + y * u
return keys[-1][1]
# ================================================================ clips
def animate(name, t, dur):
"""Returns rotations (armature axes: x pitch, y roll, z yaw), locations (Blender axes, z up,
+y backwards) and scales per bone. Upward bones lean forward with +x; hanging limbs swing
forward with -x; the left arm spreads outward with -y, the right arm with +y."""
rs, locs, scl = {}, {}, {}
def rot(b, x=0.0, y=0.0, z=0.0):
o = rs.get(b, (0, 0, 0))
rs[b] = (o[0] + x, o[1] + y, o[2] + z)
def loc(b, x=0.0, y=0.0, z=0.0):
o = locs.get(b, (0, 0, 0))
locs[b] = (o[0] + x, o[1] + y, o[2] + z)
def spread(side, amount):
rot('UpperArm.' + side, 0, -amount if side == 'L' else amount, 0)
def maw(amount):
rot('Maw.L', 0, 0, .95 * amount)
rot('Maw.R', 0, 0, -.95 * amount)
# The crown always hovers a little behind the head.
hover = sin(2 * pi * t / 1.5)
loc('Crown', 0, 0, .035 * hover)
rot('Crown', 0, .03 * sin(2 * pi * t / 3.0), 0)
if name == 'idle':
b = sin(2 * pi * t / dur)
rot('Chest', .03 * b)
rot('Spine', -.012 * b)
rot('Head', -.02 * b, 0, .14 * sin(2 * pi * t / dur + .6))
rot('Neck', 0, 0, .05 * sin(2 * pi * t / dur + .6))
rot('UpperArm.L', .035 * b)
rot('UpperArm.R', .035 * sin(2 * pi * t / dur + 1.1))
maw(.05 + .05 * b)
rot('Jaw', .04 + .04 * b)
loc('Hips', 0, 0, -.012 * b)
elif name in ('walk', 'start', 'stop'):
fade = 1.0 if name == 'walk' else (sin(pi * .5 * t / dur) if name == 'start' else cos(pi * .5 * t / dur))
ph = 2 * pi * t / CLIPS['walk'][0]
for side, sx in SIDES:
q = ph + (0 if side == 'L' else pi)
thigh = -.36 * sin(q) * fade
knee = (.5 * max(0.0, cos(q)) + .05) * fade
rot('Thigh.' + side, thigh)
rot('Shin.' + side, knee)
rot('Foot.' + side, -(thigh + knee) * .6)
# Knuckle walk: the arm swings against its leg, the forearm folds on the swing.
rot('UpperArm.' + side, .32 * sin(q) * fade)
rot('Forearm.' + side, -.22 * max(0.0, -sin(q)) * fade)
loc('Hips', 0, 0, -.05 * (1 - cos(2 * ph)) * .5 * fade)
rot('Hips', 0, 0, .06 * sin(ph) * fade)
rot('Spine', .04 * fade, .04 * sin(ph) * fade, 0)
rot('Chest', 0, 0, -.06 * sin(ph) * fade)
rot('Head', 0, 0, .05 * sin(ph) * fade)
loc('Crown', 0, 0, .03 * sin(2 * ph - .8) * fade)
rot('Crown', 0, .05 * sin(ph - .6) * fade, 0)
elif name == 'attack_a':
# Double fist smash: both arms rise overhead, then slam down at contact (0.90 s).
arm = curve(t, [(0, 0), (.55, -2.3), (.8, -2.45), (.9, -.95), (1.05, -.95), (1.3, 0)])
fore = curve(t, [(0, 0), (.55, -.6), (.8, -.7), (.9, -.1), (1.05, -.1), (1.3, 0)])
chest = curve(t, [(0, 0), (.55, -.28), (.8, -.32), (.9, .42), (1.05, .42), (1.3, 0)])
crouch = curve(t, [(0, 0), (.55, .04), (.85, 0), (.9, -.11), (1.05, -.11), (1.3, 0)])
for side, sx in SIDES:
rot('UpperArm.' + side, arm)
rot('Forearm.' + side, fore)
spread(side, -.12 * min(0.0, arm) / 2.3)
rot('Thigh.' + side, -1.2 * crouch)
rot('Shin.' + side, 2.0 * crouch)
rot('Foot.' + side, -.8 * crouch)
rot('Chest', chest)
rot('Spine', chest * .45)
rot('Head', -chest * .5)
loc('Hips', 0, 0, crouch)
maw(.35 * max(0.0, -chest) / .32)
loc('Crown', 0, 0, .1 * max(0.0, -chest) / .32)
elif name == 'attack_b':
# Backhand sweep with the right arm: winds up across the body, strikes at 0.90 s.
wind = curve(t, [(0, 0), (.6, 1), (.82, 1.05), (.9, -1), (1.05, -1.05), (1.3, 0)])
rot('Chest', 0, 0, .45 * wind)
rot('Spine', 0, 0, .2 * wind)
rot('Hips', 0, 0, .08 * wind)
rot('Head', 0, 0, -.3 * wind)
# Raised to shoulder height, the right arm swings across the body (yaw), then out.
rot('UpperArm.R', -1.0 * min(1.0, abs(wind) * 1.6), 0, .9 * max(0.0, wind) - 1.0 * max(0.0, -wind))
rot('Forearm.R', -.55 * max(0.0, wind) - .15 * max(0.0, -wind))
rot('UpperArm.L', .2 * wind)
rot('Chest', .1 * abs(wind))
elif name == 'hit':
q = curve(t, [(0, 0), (.066667, 1), (.2, .4), (.5, 0)])
rot('Chest', -.18 * q)
rot('Head', -.2 * q, 0, .1 * q)
loc('Hips', 0, .05 * q, 0)
loc('Crown', 0, .04 * q, .06 * q)
rot('UpperArm.L', .15 * q)
rot('UpperArm.R', .15 * q)
maw(-.04 * q)
elif name == 'ability':
# "Mondschrei": the Schlund tears open, arms spread, the crown rises and flares.
o = curve(t, [(0, 0), (.5, 1), (.9, 1.15), (1.1, 1), (1.3, 0)])
maw(.9 * o)
spread('L', .7 * o)
spread('R', .7 * o)
rot('UpperArm.L', -.5 * o)
rot('UpperArm.R', -.5 * o)
rot('Chest', -.3 * o)
rot('Head', -.4 * o)
rot('Jaw', .5 * o)
loc('Crown', 0, 0, .18 * o)
scl['Crown'] = tuple([1 + .25 * o] * 3)
loc('Hips', 0, 0, .05 * o)
elif name == 'death':
stagger = curve(t, [(0, 0), (.35, 1), (.6, .6), (1.0, 0)])
buckle = curve(t, [(0, 0), (.5, 0), (1.1, 1), (2.2, 1)])
fall = curve(t, [(0, 0), (.9, 0), (1.6, 1), (1.8, .95), (2.2, 1)])
crown = curve(t, [(0, 0), (.3, 0), (1.25, 1), (1.4, .96), (2.2, 1)])
rot('Chest', -.25 * stagger + .2 * fall)
loc('Hips', 0, .1 * stagger, -.42 * buckle)
for side, sx in SIDES:
rot('Thigh.' + side, -.9 * buckle)
rot('Shin.' + side, 1.5 * buckle)
rot('Foot.' + side, -.6 * buckle)
rot('UpperArm.' + side, -.6 * fall + .2 * stagger)
spread(side, .25 * fall)
rot('Root', 1.1 * fall)
loc('Root', 0, 0, -.08 * fall)
rot('Head', .3 * fall, 0, .3 * fall)
maw(-.05 * fall)
# The crown breaks away and drops behind the titan.
loc('Crown', 0, .55 * crown, -1.55 * crown)
rot('Crown', 1.1 * crown, .6 * crown, 0)
elif name == 'roar':
rear = curve(t, [(0, 0), (.6, 1), (1.4, 1), (1.6, -.3), (1.75, -.3), (2.0, 0)])
up = max(0.0, rear)
rot('Spine', -.35 * up + .2 * max(0.0, -rear))
rot('Chest', -.25 * rear)
for side, sx in SIDES:
rot('UpperArm.' + side, -1.4 * up - .8 * max(0.0, -rear) / .3)
spread(side, .6 * up)
rot('Forearm.' + side, -.4 * up)
rot('Head', -.5 * up)
rot('Jaw', .6 * up)
maw(.8 * up)
loc('Crown', 0, 0, .2 * up)
scl['Crown'] = tuple([1 + .3 * up] * 3)
loc('Hips', 0, 0, .08 * up)
elif name == 'victory':
def pulse(center):
return max(0.0, 1 - abs(t - center) / .22)
left = pulse(.5) + pulse(1.3)
right = pulse(.9)
for side, beat in (('L', left), ('R', right)):
# Fist to the chest: raise forward, fold the forearm, turn inwards (yaw).
rot('UpperArm.' + side, -1.15 * beat, 0, (-.55 if side == 'L' else .55) * beat)
rot('Forearm.' + side, -1.3 * beat)
rot('Head', -.3 * curve(t, [(0, 0), (.3, 1), (1.7, 1), (2.0, 0)]))
rot('Jaw', .4 * curve(t, [(0, 0), (.3, 1), (1.7, 1), (2.0, 0)]))
loc('Crown', 0, 0, .1 * (left + right))
rot('Chest', -.12 * curve(t, [(0, 0), (.3, 1), (1.7, 1), (2.0, 0)]))
return rs, locs, scl
def bake(rig):
for name, (dur, loop) in CLIPS.items():
rig.animation_data_clear()
rig.animation_data_create()
action = bpy.data.actions.new(name)
action.use_fake_user = True
rig.animation_data.action = action
for frame in range(round(dur * FPS) + 1):
rs, locs, scl = animate(name, frame / FPS, dur)
for pb in rig.pose.bones:
pb.rotation_mode = 'QUATERNION'
pb.rotation_quaternion = pose_rotation(pb, rs.get(pb.name, (0, 0, 0)))
pb.location = pb.bone.matrix_local.to_3x3().inverted() @ V(locs.get(pb.name, (0, 0, 0)))
pb.scale = scl.get(pb.name, (1, 1, 1))
pb.keyframe_insert('rotation_quaternion', frame=frame)
pb.keyframe_insert('location', frame=frame)
if pb.name == 'Crown':
pb.keyframe_insert('scale', frame=frame)
action['loop'] = loop
action['contact_seconds'] = CONTACT if name in ('attack_a', 'attack_b') else -1
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for fc in bag.fcurves:
for key in fc.keyframe_points:
key.interpolation = 'LINEAR'
rig.animation_data_clear()
rig.animation_data_create()
rig.animation_data.action = bpy.data.actions['idle']
bpy.context.scene.render.fps = FPS
bpy.context.scene.frame_set(0)
def rest_uvs(ob):
"""UV = rest (x, z), UV2 = (rest y, 0) in Godot coordinates for the hide shader."""
me = ob.data
while me.uv_layers:
me.uv_layers.remove(me.uv_layers[0])
first = me.uv_layers.new(name='UVMap')
second = me.uv_layers.new(name='UVRest')
for loop in me.loops:
g = L.to_godot(me.vertices[loop.vertex_index].co)
first.data[loop.index].uv = (g[0], g[2])
second.data[loop.index].uv = (g[1], 0.0)
def build():
L.reset()
build_body()
build_crown()
rocks = [ob for ob in parts if not any(m.name.startswith(('LN_Crystal', 'LN_Boss_Bronze')) for m in ob.data.materials)]
rest = [ob for ob in parts if ob not in rocks]
L.finalize(rocks, sharp_angle=16.0, occluders=parts, reach=.45, ground=0.0, rays=20)
L.finalize(rest, occluders=parts, reach=.45, ground=0.0, rays=20)
for ob in parts:
L.triangulate(ob)
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()
body = bpy.context.view_layer.objects.active
body.name = body.data.name = 'SchlundkroneBody'
rest_uvs(body)
rig = build_rig()
mod = body.modifiers.new('Rig', 'ARMATURE')
mod.object = rig
body.parent = rig
bake(rig)
source_count = len(parts)
bpy.ops.wm.save_as_mainfile(filepath=str(L.SRC / 'schlundkrone.blend'))
bpy.ops.object.select_all(action='DESELECT')
body.select_set(True)
rig.select_set(True)
bpy.context.view_layer.objects.active = rig
path = L.OUT / 'schlundkrone.glb'
bpy.ops.export_scene.gltf(filepath=str(path), export_format='GLB', use_selection=True, export_animations=True,
export_animation_mode='ACTIONS', export_force_sampling=True, export_frame_range=False,
export_anim_slide_to_zero=True, export_skins=True, export_all_influences=False,
export_def_bones=True, export_anim_single_armature=True, export_materials='EXPORT', export_yup=True,
export_vertex_color='ACTIVE', export_texcoords=True, export_normals=True)
raw = path.read_bytes()
doc = json.loads(raw[20:20 + struct.unpack_from('<I', raw, 12)[0]])
bounds = L.bounds_godot([body])
clips = sorted(a['name'] for a in doc.get('animations', []))
entry = {'id': 'schlundkrone', 'title': 'Schlundkrone (Superboss)', 'resource_path': 'res://assets/luneraria/g2/schlundkrone.glb',
'source': 'assets-source/luneraria/g2/schlundkrone.blend', 'generator': 'assets-source/luneraria/g2/build_schlundkrone.py',
'triangles': L.glb_triangles(doc), 'source_parts': source_count, 'skeletal': True, 'bones': len(BONES),
'bone_names': list(BONES), 'clips': clips, 'clip_seconds': {k: v[0] for k, v in CLIPS.items()},
'attack_cycle': ['attack_a', 'attack_b'], 'contact_seconds': CONTACT, 'stride': STRIDE, 'view_scale': VIEW_SCALE,
'materials': sorted(m['name'] for m in doc.get('materials', [])), 'skins': len(doc.get('skins', [])),
'bounds_godot': bounds, 'pivot': 'ground centre (0, 0, 0), faces +z', 'units': 'metres at view scale 1',
'sha256': hashlib.sha256(raw).hexdigest()}
L.write_manifest([entry])
missing = [c for c in CLIPS if c not in clips]
if missing:
raise SystemExit('SCHLUNDKRONE_MISSING_CLIPS: %s' % missing)
print('SCHLUNDKRONE_BUILD', entry['triangles'], 'triangles', len(BONES), 'bones', 'clips', clips, 'height %.2f' % bounds['max'][1],
flush=True)
build()

View file

@ -0,0 +1,444 @@
"""G2 tower family of Champions of Luneraria (one generator, three GLBs).
Grenzwacht (Tower 1, outer lane tower): slender octagonal watchtower, open gallery, two
crescent horns cradling a medium crystal, few metal bands.
Bastion (Tower 2, inner lane tower): broad battered bastion with rear buttresses, shield
plates on the lane side, machicolated parapet, three horns, armillary ring and a large
crystal with satellite shards.
Kernwacht (base tower): no round shaft at all - a stout rune drum carrying two crescent
blades that meet in a lancet arch around the crystal, the same blade language as the
Ancient, so the base towers read as part of the Ancient's sanctuary.
All three share the octagonal plan, pale moonstone ashlar on a dark basalt plinth, team
metal (tinted at runtime), rune friezes and the crescent crystal cradle. Local frame: +z
faces the lane (attackers), -z is the outer side (buttresses, door, pennant pole).
Nodes per GLB (pivot at the ground centre unless noted):
Lower plinth and lower shaft (stays standing until the ruin replaces it)
Upper everything above the split height; pivot at (0, split, 0) so it can tip
Upper_Damaged heavy-damage variant of Upper (missing merlons, bent horn, loose plate)
Debris rubble at the foot, shown from heavy damage on
Crystal the energy crystal, pivot at its centre
Ring / Shard_n rotating armillary ring and orbiting shards (Bastion, Kernwacht)
Pennant / Banner_L / Banner_R cloth (team silhouette and emblem come from the shader)
Ruin the destroyed tower (stump, rubble, broken crystal), pivot at the ground
Run: ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/g2/build_towers.py
"""
import sys
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
from mathutils import noise, Matrix
OCT = pi / 8
def octa(name, profile, mat, **kw):
return L.lathe(name, profile, 8, mat, rotation=OCT, **kw)
def rivets(prefix, radius, y, count, mat, size=.07, rotation=OCT):
out = []
for k in range(count):
a = 2 * pi * k / count + rotation
out.append(L.box('%s_%d' % (prefix, k), (cos(a) * radius, y, sin(a) * radius), (size, size, size * .6), mat,
yaw=-a + pi / 2, bev=.012))
return out
def merlons(prefix, radius, y, count, size, mat, skip=(), jitter=0.0, seed=0):
out = []
for k in range(count):
if k in skip:
continue
a = 2 * pi * (k + .5) / count
tilt = jitter * noise.noise(V((k * 1.3, seed, 0)))
out.append(L.box('%s_%d' % (prefix, k), (cos(a) * radius, y + size[1] / 2, sin(a) * radius), size, mat,
yaw=-a + pi / 2, roll=tilt, bev=.03, segments=2))
return out
def horn(name, angle, r0, y0, r_mid, y_top, r_top, width, thick, mat, bent=0.0):
"""Crescent horn in the vertical plane at `angle`: rises from (r0, y0), bows out to r_mid
and curls back in to (r_top, y_top). Flat face shows sideways (tangent direction)."""
d = V((cos(angle), 0, sin(angle)))
t = V((-sin(angle), 0, cos(angle)))
h = y_top - y0
local = [(r0, y0), (r_mid, y0 + h * .38), (r_mid * .86 + r_top * .14, y0 + h * .72), (r_top, y_top)]
if bent:
local[-1] = (r_top + bent, y_top - bent * .6)
points = [d * r + V((0, y, 0)) for r, y in local]
return L.sweep(name, points, [width, width * .9, width * .6, width * .12], [thick, thick * .9, thick * .7, thick * .2], mat,
up_hint=tuple(t), steps=20, lens=.35)
def slits(prefix, radius, ys, angles, mat, size=(.13, .44)):
out = []
for j, y in enumerate(ys):
for k, a in enumerate(angles):
out.append(L.box('%s_%d_%d' % (prefix, j, k), (cos(a) * radius, y, sin(a) * radius), (size[0], size[1], .08), mat,
yaw=-a + pi / 2, bev=.0))
return out
def door(prefix, radius, mat_frame, mat_leaf, angle=-pi / 2, height=1.5, width=.76):
"""Arched door on a flat octagon face; `radius` is the face (apothem) distance."""
d = V((cos(angle), 0, sin(angle)))
t = V((-sin(angle), 0, cos(angle)))
c = d * radius
yaw = -angle + pi / 2
parts = [L.box(prefix + '_Leaf', (c.x, height / 2 + .55, c.z), (width, height, .1), mat_leaf, yaw=yaw, bev=.01)]
arch = []
for k in range(13):
a = pi * k / 12
arch.append(tuple(c + d * .06 + t * cos(a) * width * .56 + V((0, .55 + height - .12 + sin(a) * width * .42, 0))))
parts.append(L.sweep(prefix + '_Arch', arch, [.13] * 3, [.14] * 3, mat_frame, up_hint=tuple(d), steps=14, smooth=False))
for sx in (-1, 1):
post = c + d * .06 + t * sx * width * .56
parts.append(L.box('%s_Jamb_%d' % (prefix, sx), (post.x, .55 + (height - .12) / 2, post.z), (.14, height - .12, .14), mat_frame,
yaw=yaw, bev=.015))
return parts
def jagged_stump(name, radius_bottom, radius_top, y0, y_top, mat, seed, sides=8, bite=.55):
"""Octagonal shaft whose top ring is broken irregularly (ruin stump)."""
verts, faces = [], []
levels = 5
for j in range(levels + 1):
t = j / levels
for k in range(sides):
a = 2 * pi * k / sides + OCT
r = radius_bottom * (1 - t) + radius_top * t
y = y0 + (y_top - y0) * t
if j == levels:
y -= bite * (.5 + .5 * noise.noise(V((k * .9, seed, 1.7)))) + .25 * (1 if k % 3 == 0 else 0)
r *= .9 + .12 * noise.noise(V((k, seed, 3)))
elif j == levels - 1:
y -= bite * .25 * abs(noise.noise(V((k * .9, seed, 2.3))))
verts.append((cos(a) * r, y, sin(a) * r))
for j in range(levels):
for k in range(sides):
a0, b0 = j * sides + k, j * sides + (k + 1) % sides
faces.append((a0, b0, b0 + sides, a0 + sides))
faces.append(tuple(reversed(range(sides))))
faces.append(tuple(range(levels * sides, levels * sides + sides)))
ob = L.make(name, verts, faces, mat)
L._outward(ob)
return ob
def rubble(prefix, center, spread, count, size, mat, seed, y=0.0, outward_bias=None):
"""Scattered broken masonry chunks (ruin and damage debris), biased to one side."""
out = []
for k in range(count):
a = 2 * pi * ((k * .618 + seed * .13) % 1.0)
if outward_bias is not None:
a = outward_bias + (a - pi) * .55
r = spread * (.35 + .65 * ((k * .377 + seed * .21) % 1.0))
s = size * (.55 + .6 * ((k * .733 + seed) % 1.0))
cx = center[0] + cos(a) * r
cz = center[2] + sin(a) * r
out.append(L.chunk('%s_%d' % (prefix, k), (cx, y + s * .22, cz), (s * .75, s * .42, s * .58), mat, seed=seed * 3.1 + k,
yaw=a * 1.7 + seed, tilt=.3 * noise.noise(V((k, seed, 0)))))
return out
def shards(prefix, center, count, radius, mat, seed, spread=.9):
out = []
for k in range(count):
a = 2 * pi * ((k * .618 + seed * .07) % 1.0)
r = spread * (.3 + .7 * ((k * .41 + seed) % 1.0))
c = (center[0] + cos(a) * r, center[1] + radius * .3, center[2] + sin(a) * r)
out.append(L.crystal('%s_%d' % (prefix, k), c, radius * (.5 + .5 * ((k * .29) % 1.0)), radius * 1.6, radius * .5, mat,
sides=5, tilt=(1.1 + .4 * noise.noise(V((k, seed, 0))), a), seed=k + seed))
return out
# ================================================================ Grenzwacht (Tower 1)
def grenzwacht():
L.reset()
split = 2.2
lower = [
octa('Plinth', [(1.48, 0), (1.48, .26), (1.36, .32), (1.36, .46), (1.22, .52)], 'LN_Stone_Dark', jitter=.015, seed=1),
octa('ShaftLow', [(1.02, .5), (.99, 1.2), (.955, split + .02)], 'LN_Stone_Pale'),
octa('Course', [(1.07, .5), (1.07, .66), (1.02, .7)], 'LN_Stone_Dark', cap_bottom=False),
]
lower += slits('SlitLow', .905, [1.55], [0.0, pi], 'LN_Stone_Dark')
lower += door('Door', .9, 'LN_Stone_Dark', 'LN_Wood')
upper = [
octa('ShaftHigh', [(.955, split - .02), (.9, 2.9), (.85, 3.7), (.87, 3.74)], 'LN_Stone_Pale'),
octa('Band', [(.975, 2.34), (.975, 2.5)], 'LN_Metal_Team', cap_top=False, cap_bottom=False),
octa('Runes', [(.905, 3.02), (.89, 3.3)], 'LN_Rune_Team', cap_top=False, cap_bottom=False, uv_band=.34),
octa('Corbel', [(.86, 3.7), (1.18, 3.96), (1.23, 4.2), (1.15, 4.26)], 'LN_Stone_Pale'),
octa('Floor', [(1.0, 4.18), (1.0, 4.24)], 'LN_Stone_Dark'),
]
upper += rivets('Rivet', .99, 2.42, 8, 'LN_Metal_Iron')
upper += slits('SlitHigh', .84, [2.75], [0, pi / 2, pi, 3 * pi / 2], 'LN_Stone_Dark')
upper += merlons('Merlon', 1.07, 4.22, 8, (.36, .34, .24), 'LN_Stone_Pale')
horns = [horn('Horn_%d' % i, a, .6, 4.2, .82, 6.05, .24, .24, .15, 'LN_Metal_Team') for i, a in enumerate((0.0, pi))]
collar = L.ring('Collar', (0, 4.62, 0), .38, .07, .08, 'LN_Metal_Team', sides=24)
pole = L.box('PennantPole', (0, 5.0, -1.02), (.07, 1.7, .07), 'LN_Wood', bev=.01)
upper += horns + [collar, pole]
damaged = [
octa('D_ShaftHigh', [(.955, split - .02), (.9, 2.9), (.85, 3.7), (.87, 3.74)], 'LN_Stone_Pale'),
octa('D_Band', [(.975, 2.34), (.975, 2.5)], 'LN_Metal_Team', cap_top=False, cap_bottom=False),
octa('D_Runes', [(.905, 3.02), (.89, 3.3)], 'LN_Rune_Team', cap_top=False, cap_bottom=False, uv_band=.34),
octa('D_Corbel', [(.86, 3.7), (1.18, 3.96), (1.23, 4.2), (1.15, 4.26)], 'LN_Stone_Pale', jitter=.04, seed=5),
octa('D_Floor', [(1.0, 4.18), (1.0, 4.24)], 'LN_Stone_Dark'),
L.box('D_PennantPole', (0.12, 4.9, -1.0), (.07, 1.5, .07), 'LN_Wood', roll=.35, bev=.01),
horn('D_Horn_0', 0.0, .6, 4.2, .82, 6.05, .24, .24, .15, 'LN_Metal_Team'),
horn('D_Horn_1', pi, .6, 4.2, .86, 5.55, .62, .24, .15, 'LN_Metal_Team', bent=.35),
L.ring('D_Collar', (0, 4.6, 0), .38, .07, .08, 'LN_Metal_Team', sides=24, axis_tilt=(.18, .1)),
]
damaged += rivets('D_Rivet', .99, 2.42, 8, 'LN_Metal_Iron')
damaged += merlons('D_Merlon', 1.07, 4.22, 8, (.36, .34, .24), 'LN_Stone_Pale', skip=(1, 2, 5), jitter=.25, seed=3)
debris = rubble('Debris', (0, 0, 0), 1.9, 7, .34, 'LN_Stone_Pale', 3.0, outward_bias=-pi / 2)
crystal = L.crystal('Crystal', (0, 5.2, 0), .34, .64, .5, 'LN_Crystal_Team', sides=6, seed=2)
pennant = L.banner('Pennant', .62, 1.0, 'LN_Cloth_Team', top=(-.37, 5.72, -1.02), segments=(4, 8))
ruin = [
octa('R_Plinth', [(1.48, 0), (1.48, .26), (1.36, .32), (1.36, .46), (1.22, .52)], 'LN_Stone_Dark', jitter=.03, seed=1),
jagged_stump('R_Stump', 1.0, .96, .5, 1.95, 'LN_Stone_Pale', 4.0),
octa('R_Course', [(1.07, .5), (1.07, .66), (1.02, .7)], 'LN_Stone_Dark', cap_bottom=False),
horn('R_Horn', 2.4, .3, .05, .5, .5, 1.6, .24, .15, 'LN_Metal_Team'),
]
ruin += rubble('R_Rubble', (0, 0, 0), 2.4, 10, .62, 'LN_Stone_Pale', 7.0, outward_bias=-pi / 2)
ruin += rubble('R_Dark', (0, 0, 0), 2.2, 6, .5, 'LN_Stone_Dark', 17.0, outward_bias=-pi / 2)
ruin += rubble('R_Merlon', (0, 0, 0), 2.0, 4, .42, 'LN_Stone_Pale', 11.0, outward_bias=pi * .1)
ruin += shards('R_Shard', (0, .35, -.9), 5, .2, 'LN_Crystal_Team', 5.0)
return finish('grenzwacht', 'Grenzwacht', split, lower, upper, damaged, debris, [crystal], [pennant], ruin, 5.2, {})
# ================================================================ Bastion (Tower 2)
def buttress(name, angle, r_in, r_out, h_in, h_out, width, mat):
d = V((cos(angle), 0, sin(angle)))
t = V((-sin(angle), 0, cos(angle)))
verts = []
for r, h in ((r_in, h_in), (r_out, h_out)):
for s in (-1, 1):
base = d * r + t * s * width / 2
verts.append((base.x, 0.0, base.z))
verts.append((base.x, h, base.z))
# verts: 0 in-left-bottom, 1 in-left-top, 2 in-right-bottom, 3 in-right-top, 4..7 outer.
faces = [(0, 2, 6, 4), (1, 5, 7, 3), (0, 4, 5, 1), (2, 3, 7, 6), (0, 1, 3, 2), (4, 6, 7, 5)]
ob = L.make(name, verts, faces, mat)
L._outward(ob)
L.bevel(ob, .04, 1, 30.0)
return ob
def shield(name, angle, radius, y, mat, tilt=0.0, drop=0.0):
d = V((cos(angle), 0, sin(angle)))
outline = [(-.34, .42), (.34, .42), (.3, -.18), (0, -.52), (-.3, -.18)]
verts, faces = [], []
t = V((-sin(angle), 0, cos(angle)))
for depth in (0.0, .07):
for x, yy in outline:
p = d * (radius + depth) + t * x + V((0, y + yy - drop, 0))
verts.append(tuple(p))
n = len(outline)
faces = [(i, (i + 1) % n, n + (i + 1) % n, n + i) for i in range(n)] + [tuple(reversed(range(n))), tuple(range(n, 2 * n))]
ob = L.make(name, verts, faces, mat)
L._outward(ob)
L.bevel(ob, .025, 2, 30.0)
if tilt:
ob.rotation_euler = (0, tilt, 0)
return ob
def bastion():
L.reset()
split = 2.6
back = -pi / 2
lower = [
octa('Plinth', [(1.78, 0), (1.78, .34), (1.66, .4), (1.66, .62), (1.52, .7)], 'LN_Stone_Dark', jitter=.015, seed=2),
octa('Talus', [(1.5, .68), (1.43, 1.25), (1.31, 1.42)], 'LN_Stone_Dark', cap_bottom=False),
octa('ShaftLow', [(1.31, 1.4), (1.28, 2.0), (1.26, split + .02)], 'LN_Stone_Pale'),
octa('BandLow', [(1.35, 1.52), (1.35, 1.72)], 'LN_Metal_Team', cap_top=False, cap_bottom=False),
]
lower += rivets('RivetLow', 1.36, 1.62, 8, 'LN_Metal_Iron')
for i, a in enumerate((back - pi / 4, back, back + pi / 4)):
lower.append(buttress('Buttress_%d' % i, a, 1.1, 2.25, 2.7, .75, .5, 'LN_Stone_Dark'))
def upper_parts(prefix, broken=False):
parts = [
octa(prefix + 'ShaftHigh', [(1.26, split - .02), (1.22, 3.5), (1.18, 4.42)], 'LN_Stone_Pale'),
octa(prefix + 'BandHigh', [(1.29, 3.28), (1.29, 3.46)], 'LN_Metal_Team', cap_top=False, cap_bottom=False),
octa(prefix + 'Runes', [(1.23, 3.84), (1.21, 4.12)], 'LN_Rune_Team', cap_top=False, cap_bottom=False, uv_band=.36),
octa(prefix + 'Parapet', [(1.18, 4.4), (1.56, 4.62), (1.6, 5.0), (1.5, 5.06)], 'LN_Stone_Pale',
jitter=.05 if broken else 0.0, seed=8),
octa(prefix + 'Deck', [(1.36, 4.96), (1.36, 5.03)], 'LN_Stone_Dark'),
L.lathe(prefix + 'Plate', [(.62, 5.0), (.58, 5.18), (.44, 5.24)], 16, 'LN_Metal_Iron'),
]
parts += rivets(prefix + 'RivetHigh', 1.3, 3.37, 8, 'LN_Metal_Iron')
for k in range(8):
a = 2 * pi * (k + .5) / 8
parts.append(L.box('%sCorbel_%d' % (prefix, k), (cos(a) * 1.36, 4.48, sin(a) * 1.36), (.3, .28, .36), 'LN_Stone_Dark',
yaw=-a + pi / 2, bev=.03))
parts += merlons(prefix + 'Merlon', 1.42, 5.0, 8, (.5, .5, .3), 'LN_Stone_Pale',
skip=(0, 3, 4) if broken else (), jitter=.2 if broken else 0.0, seed=4)
lane = pi / 2
for i, a in enumerate((lane - pi / 4, lane, lane + pi / 4)):
loose = broken and i == 2
parts.append(shield('%sShield_%d' % (prefix, i), a, 1.17, 2.95, 'LN_Metal_Team', drop=.0 if not loose else .12))
for i, a in enumerate((pi / 6, pi / 6 + 2 * pi / 3, pi / 6 + 4 * pi / 3)):
if broken and i == 1:
parts.append(horn(prefix + 'Horn_%d' % i, a, .52, 5.0, .78, 6.2, .5, .26, .17, 'LN_Metal_Team', bent=.42))
else:
parts.append(horn(prefix + 'Horn_%d' % i, a, .52, 5.0, .82, 7.15, .2, .26, .17, 'LN_Metal_Team'))
parts.append(L.box(prefix + 'PennantPole', (.0, 5.9, -1.46), (.08, 1.9, .08), 'LN_Wood', roll=.3 if broken else 0.0, bev=.01))
return parts
upper = upper_parts('')
damaged = upper_parts('D_', True)
debris = rubble('Debris', (0, 0, 0), 2.3, 9, .42, 'LN_Stone_Pale', 5.0, outward_bias=-pi / 2)
debris += [shield('Debris_Shield', 1.2, 1.9, .12, 'LN_Metal_Team', drop=0.0)]
crystal = L.crystal('Crystal', (0, 6.2, 0), .48, .86, .66, 'LN_Crystal_Team', sides=6, seed=6)
ring = L.ring('Ring', (0, 6.2, 0), .82, .09, .1, 'LN_Metal_Team', sides=40, uv_cell=.3)
shard_objs = []
for i in range(3):
a = 2 * pi * i / 3
shard_objs.append(L.crystal('Shard_%d' % i, (cos(a) * 1.02, 6.35, sin(a) * 1.02), .11, .26, .18, 'LN_Crystal_Team', sides=5, seed=10 + i))
pennant = L.banner('Pennant', .7, 1.15, 'LN_Cloth_Team', top=(-.41, 6.8, -1.46), segments=(4, 8))
ruin = [
octa('R_Plinth', [(1.78, 0), (1.78, .34), (1.66, .4), (1.66, .62), (1.52, .7)], 'LN_Stone_Dark', jitter=.03, seed=2),
octa('R_Talus', [(1.5, .68), (1.43, 1.25), (1.31, 1.42)], 'LN_Stone_Dark', cap_bottom=False, jitter=.03),
jagged_stump('R_Stump', 1.3, 1.24, 1.4, 2.9, 'LN_Stone_Pale', 6.0, bite=.7),
buttress('R_Buttress_0', back - pi / 4, 1.1, 2.25, 2.1, .75, .5, 'LN_Stone_Dark'),
buttress('R_Buttress_1', back, 1.1, 2.25, 1.5, .6, .5, 'LN_Stone_Dark'),
shield('R_Shield', 2.1, 2.0, .14, 'LN_Metal_Team'),
horn('R_Horn', .5, .5, .05, .6, .45, 1.9, .26, .17, 'LN_Metal_Team'),
]
ruin += rubble('R_Rubble', (0, 0, 0), 2.7, 12, .7, 'LN_Stone_Pale', 9.0, outward_bias=-pi / 2)
ruin += rubble('R_Dark', (0, 0, 0), 2.4, 7, .6, 'LN_Stone_Dark', 19.0, outward_bias=-pi / 2)
ruin += rubble('R_Merlon', (0, 0, 0), 2.3, 5, .5, 'LN_Stone_Pale', 13.0, outward_bias=pi * .15)
ruin += shards('R_Shard', (0, .4, -1.0), 7, .24, 'LN_Crystal_Team', 8.0)
return finish('bastion', 'Bastion', split, lower, upper, damaged, debris, [crystal, ring] + shard_objs, [pennant], ruin, 6.2, {})
# ================================================================ Kernwacht (base tower)
BLADES = (pi / 2, pi / 2 + 2 * pi / 3, pi / 2 + 4 * pi / 3)
def blade(name, angle, mat, y0=2.62, broken=False, lower_only=False):
"""One of three crescent blades: from the drum up and out, curling in to the shared apex.
Lies in the vertical plane at `angle`; the broad face shows tangentially."""
d = V((cos(angle), 0, sin(angle)))
t = V((-sin(angle), 0, cos(angle)))
local = [(.72, y0), (1.3, 4.1), (1.42, 5.3), (1.04, 6.7), (.2, 7.66)]
if lower_only:
local = local[:3]
if broken:
local = [(.72, y0), (1.3, 4.1), (1.42, 5.3), (1.3, 5.9)]
widths = [.46, .44, .38, .28, .16][:len(local)]
depths = [.66, .6, .52, .42, .3][:len(local)]
return L.sweep(name, [d * r + V((0, y, 0)) for r, y in local], widths, depths, mat, up_hint=tuple(t), steps=26, lens=.25)
def edge_strip(name, angle, mat, broken=False):
d = V((cos(angle), 0, sin(angle)))
t = V((-sin(angle), 0, cos(angle)))
local = [(.96, 2.72), (1.56, 4.1), (1.66, 5.3), (1.24, 6.75), (.28, 7.8)]
if broken:
local = local[:3] + [(1.52, 5.92)]
return L.sweep(name, [d * r + V((0, y, 0)) for r, y in local], [.1] * len(local), [.46, .42, .36, .28, .2][:len(local)], mat,
up_hint=tuple(t), steps=26, lens=.1)
def kernwacht():
L.reset()
split = 2.7
lower = [
octa('Plinth', [(1.95, 0), (1.95, .3), (1.8, .36), (1.8, .62), (1.62, .7)], 'LN_Stone_Dark', jitter=.012, seed=3),
octa('Drum', [(1.28, .68), (1.24, 1.4), (1.12, 2.4), (1.06, split + .02)], 'LN_Stone_Pale'),
octa('DrumCap', [(1.14, 2.55), (1.2, 2.66), (1.1, split + .06)], 'LN_Stone_Dark'),
octa('Runes', [(1.272, 1.02), (1.192, 1.9)], 'LN_Rune_Team', cap_top=False, cap_bottom=False, uv_band=.46),
]
for k in range(8):
a = 2 * pi * k / 8
lower.append(L.box('Pilaster_%d' % k, (cos(a) * 1.19, 1.45, sin(a) * 1.19), (.2, 1.5, .14), 'LN_Stone_Dark', yaw=-a + pi / 2, bev=.02))
def upper_parts(prefix, broken=False):
parts = [
L.ring(prefix + 'Crown', (0, 7.5, 0), .42, .12, .2, 'LN_Metal_Team', sides=32, uv_cell=.25),
L.crystal(prefix + 'Apex', (0, 7.95, 0), .16, .38, .14, 'LN_Crystal_Team', sides=5, seed=4),
L.lathe(prefix + 'Seat', [(.6, split - .02), (.72, split + .12), (.5, split + .2)], 16, 'LN_Metal_Team'),
]
for i, a in enumerate(BLADES):
snapped = broken and i == 2
parts.append(blade('%sBlade_%d' % (prefix, i), a, 'LN_Stone_Pale', broken=snapped))
parts.append(edge_strip('%sEdge_%d' % (prefix, i), a, 'LN_Metal_Team', broken=snapped))
for i in (1, 2):
a = BLADES[i]
d = V((cos(a), 0, sin(a)))
t = V((-sin(a), 0, cos(a)))
at = d * 1.72 + V((0, 5.55, 0))
parts.append(L.box('%sBannerArm_%d' % (prefix, i), tuple(at), (.08, .08, .5), 'LN_Metal_Iron', yaw=-a, bev=.01))
return parts
upper = upper_parts('')
damaged = upper_parts('D_', True)
debris = rubble('Debris', (0, 0, 0), 2.4, 8, .4, 'LN_Stone_Pale', 2.0, outward_bias=-pi / 2)
crystal = L.crystal('Crystal', (0, 5.25, 0), .56, 1.0, .74, 'LN_Crystal_Team', sides=6, seed=9)
ring = L.ring('Ring', (0, 5.25, 0), .98, .1, .11, 'LN_Metal_Team', sides=40, uv_cell=.3)
banners = []
for i, key in ((1, 'Banner_L'), (2, 'Banner_R')):
a = BLADES[i]
d = V((cos(a), 0, sin(a)))
top = d * 1.98 + V((0, 5.5, 0))
banners.append(L.banner(key, .62, 1.6, 'LN_Cloth_Team', top=tuple(top), segments=(4, 10), yaw=-(a + pi / 2)))
ruin = [
octa('R_Plinth', [(1.95, 0), (1.95, .3), (1.8, .36), (1.8, .62), (1.62, .7)], 'LN_Stone_Dark', jitter=.03, seed=3),
jagged_stump('R_Drum', 1.28, 1.14, .68, 2.2, 'LN_Stone_Pale', 3.0, bite=.5),
octa('R_Runes', [(1.272, 1.02), (1.225, 1.6)], 'LN_Rune_Team', cap_top=False, cap_bottom=False, uv_band=.46),
]
fallen = blade('R_Blade', pi / 2, 'LN_Stone_Pale')
fallen.data.transform(Matrix.Rotation(pi / 2 - .12, 4, 'Y'))
fallen.location = L.G(-1.6, -2.3, -1.2)
ruin.append(fallen)
ruin += rubble('R_Rubble', (0, 0, 0), 2.8, 12, .66, 'LN_Stone_Pale', 5.0, outward_bias=-pi / 2)
ruin += rubble('R_Dark', (0, 0, 0), 2.4, 7, .58, 'LN_Stone_Dark', 15.0, outward_bias=-pi / 2)
ruin += shards('R_Shard', (0, .4, -1.0), 7, .26, 'LN_Crystal_Team', 3.0)
return finish('kernwacht', 'Kernwacht', split, lower, upper, damaged, debris, [crystal, ring], banners, ruin, 5.25, {})
# ================================================================ assembly
def finish(key, title, split, lower, upper, damaged, debris, energy, cloth, ruin, crystal_y, extra):
for ob in lower + upper + damaged + debris + ruin:
ob.data.update()
lower_ob = L.join(lower, 'Lower')
upper_ob = L.join(upper, 'Upper', origin=(0, split, 0))
damaged_ob = L.join(damaged, 'Upper_Damaged', origin=(0, split, 0))
debris_ob = L.join(debris, 'Debris')
ruin_ob = L.join(ruin, 'Ruin')
named = []
for ob in energy:
name = ob.name
c = sum((ob.matrix_world @ v.co for v in ob.data.vertices), V()) / len(ob.data.vertices)
g = L.to_godot(c)
if name.startswith('Ring'):
g = (0.0, crystal_y, 0.0)
named.append(L.join([ob], name, origin=g))
cloth_obs = [L.join([ob], ob.name) for ob in cloth]
intact = [lower_ob, upper_ob] + named
L.finalize(intact, occluders=intact)
L.finalize([damaged_ob], occluders=[lower_ob, damaged_ob] + named)
L.finalize([debris_ob], occluders=[lower_ob, debris_ob])
L.finalize([ruin_ob], occluders=[ruin_ob])
L.finalize(cloth_obs, ao=False)
objects = intact + [damaged_ob, debris_ob, ruin_ob] + cloth_obs
for ob in objects:
L.triangulate(ob)
doc, raw = L.export(objects, key + '.glb', key + '.blend')
entry = L.record(key, key + '.glb', key + '.blend', 'build_towers.py', [lower_ob, upper_ob] + named, doc, raw,
title=title, split_height=split, crystal_height=crystal_y,
triangles_intact=L.triangles(intact + cloth_obs), triangles_ruin=L.triangles([ruin_ob]), **extra)
print('TOWER_BUILD', key, entry['triangles'], 'triangles (intact %d, ruin %d)' % (entry['triangles_intact'], entry['triangles_ruin']),
'height %.2f' % entry['bounds_godot']['max'][1], flush=True)
return entry
only = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []
entries = []
for key, fn in (('grenzwacht', grenzwacht), ('bastion', bastion), ('kernwacht', kernwacht)):
if only and key not in only:
continue
entries.append(fn())
L.write_manifest(entries)

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -0,0 +1,662 @@
"""Shared Blender helpers for the G2 strategic buildings of Champions of Luneraria.
Every G2 generator (Ancient, towers, boss grotto, Schlundkrone) builds its geometry from
these primitives, so the pieces share one design language: bevelled hard-surface stone and
metal, faceted crystals, rune bands with UVs for the glyph shader and rock masses from
noise-displaced spheres. After building, `finalize` sets split normals by angle and bakes an
ambient-occlusion term into the vertex colour `Col` (exported as COLOR_0); the Godot shaders
multiply it into stone and metal and use it for wear.
Material names (LN_*) are the contract with scripts/luneraria/view/lune_materials.gd; the
Blender colours only serve the .blend preview. Godot coordinates are written through
G(x, y, z) (x right, y up, z towards the viewer); Blender is Z-up.
"""
from pathlib import Path
import bpy, bmesh, json, hashlib, struct
from math import sin, cos, pi, sqrt, atan2
from mathutils import Vector, Matrix, noise
from mathutils.bvhtree import BVHTree
P = Path(__file__).resolve().parents[3]
SRC = Path(__file__).resolve().parent
OUT = P / 'assets/luneraria/g2'
V = Vector
def reset():
_materials.clear()
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.preferences.filepaths.save_version = 0
bpy.context.scene.render.fps = 30
OUT.mkdir(parents=True, exist_ok=True)
def G(x, y, z):
return V((x, -z, y))
def to_godot(v):
return (v.x, v.z, -v.y)
def lin(c):
return tuple(x ** 2.2 for x in c)
# Display colour (sRGB), roughness, metallic, emission strength.
PALETTE = {
'LN_Stone_Pale': ((.80, .76, .68), .88, 0.0, 0.0),
'LN_Stone_Dark': ((.26, .26, .29), .9, 0.0, 0.0),
'LN_Stone_Grotto': ((.46, .43, .47), .92, 0.0, 0.0),
'LN_Rock': ((.40, .39, .37), .95, 0.0, 0.0),
'LN_Metal_Team': ((.74, .76, .80), .34, .9, 0.0),
'LN_Metal_Iron': ((.22, .22, .24), .55, .8, 0.0),
'LN_Metal_Bronze': ((.62, .44, .22), .42, .9, 0.0),
'LN_Crystal_Team': ((.55, .80, 1.0), .08, 0.0, 3.0),
'LN_Crystal_Grotto': ((.70, .50, 1.0), .08, 0.0, 3.0),
'LN_Rune_Team': ((.55, .80, 1.0), .5, 0.0, 2.0),
'LN_Rune_Grotto': ((.70, .50, 1.0), .5, 0.0, 2.0),
'LN_Cloth_Team': ((.20, .40, .80), .85, 0.0, 0.0),
'LN_Wood': ((.36, .25, .16), .8, 0.0, 0.0),
'LN_Cave': ((.03, .02, .05), 1.0, 0.0, 0.0),
'LN_Water_Glow': ((.45, .35, .80), .1, 0.0, 1.2),
'LN_Boss_Hide': ((.24, .25, .29), .84, 0.0, 0.0),
'LN_Boss_Bone': ((.80, .77, .69), .62, 0.0, 0.0),
'LN_Boss_Bronze': ((.60, .44, .24), .42, .85, 0.0),
'LN_Crystal_Boss': ((.70, .50, 1.0), .08, 0.0, 3.0),
}
_materials = {}
def material(name):
if name in _materials:
return _materials[name]
color, rough, metal, emission = PALETTE[name]
m = bpy.data.materials.new(name)
c = lin(color)
m.diffuse_color = (*c, 1)
m.use_nodes = True
nodes, links = m.node_tree.nodes, m.node_tree.links
bsdf = nodes.get('Principled BSDF')
bsdf.inputs['Roughness'].default_value = rough
bsdf.inputs['Metallic'].default_value = metal
# Baked occlusion (vertex colour Col) darkens the preview like it does in Godot.
attribute = nodes.new('ShaderNodeVertexColor')
attribute.layer_name = 'Col'
mix = nodes.new('ShaderNodeMix')
mix.data_type = 'RGBA'
mix.blend_type = 'MULTIPLY'
mix.inputs['Factor'].default_value = 1.0
mix.inputs['A'].default_value = (*c, 1)
links.new(attribute.outputs['Color'], mix.inputs['B'])
links.new(mix.outputs['Result'], bsdf.inputs['Base Color'])
if emission:
bsdf.inputs['Emission Color'].default_value = (*c, 1)
bsdf.inputs['Emission Strength'].default_value = emission
_materials[name] = m
return m
# ---------------------------------------------------------------- objects
def make(name, verts, faces, mat, uvs=None, smooth=False):
"""Object from Godot-space vertices (tuples or Vectors) and faces; uvs per vertex."""
me = bpy.data.meshes.new(name)
me.from_pydata([tuple(G(*v)) for v in verts], [], faces)
me.update()
me.materials.append(material(mat))
if uvs is not None:
layer = me.uv_layers.new(name='UVMap')
for poly in me.polygons:
for li in poly.loop_indices:
layer.data[li].uv = uvs[me.loops[li].vertex_index]
for poly in me.polygons:
poly.use_smooth = smooth
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
return ob
def bevel(ob, offset=.03, segments=2, angle=35.0, profile=.5):
"""Bevels every edge sharper than `angle` degrees (hard-surface highlight edges)."""
bm = bmesh.new()
bm.from_mesh(ob.data)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-6)
bm.normal_update()
limit = angle * pi / 180.0
edges = [e for e in bm.edges if len(e.link_faces) == 2 and e.calc_face_angle(0.0) > limit]
if edges:
bmesh.ops.bevel(bm, geom=edges, offset=offset, offset_type='OFFSET', segments=segments, profile=profile,
affect='EDGES', clamp_overlap=True)
bm.to_mesh(ob.data)
bm.free()
ob.data.update()
return ob
def join(objects, name, origin=(0, 0, 0)):
"""Joins objects into one named object whose origin sits at the Godot point `origin`."""
objects = [o for o in objects if o is not None]
bpy.ops.object.select_all(action='DESELECT')
for o in objects:
o.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
if len(objects) > 1:
bpy.ops.object.join()
ob = bpy.context.view_layer.objects.active
ob.name = name
ob.data.name = name
pivot = G(*origin)
ob.data.transform(Matrix.Translation(-pivot))
ob.location = pivot
return ob
# ---------------------------------------------------------------- primitives
def lathe(name, profile, sides, mat, center=(0, 0, 0), rotation=0.0, cap_top=True, cap_bottom=True, jitter=0.0, seed=0.0,
smooth=False, uv_band=None):
"""Revolves a (radius, height) profile. uv_band=(cell_length) gives UVs u along the
circumference in cells and v along the profile (0..1) for rune bands."""
cx, cy, cz = center
verts, faces, uvs = [], [], []
n = len(profile)
total = 0.0
lengths = [0.0]
for j in range(1, n):
total += ((profile[j][0] - profile[j - 1][0]) ** 2 + (profile[j][1] - profile[j - 1][1]) ** 2) ** .5
lengths.append(total)
for j, (r, y) in enumerate(profile):
for k in range(sides + (1 if uv_band else 0)):
a = 2 * pi * (k % sides) / sides + rotation
rr = r * (1 + jitter * noise.noise(V((cos(a) * 2.3, y * 1.7, sin(a) * 2.3 + seed)))) if jitter else r
verts.append((cx + cos(a) * rr, cy + y, cz + sin(a) * rr))
if uv_band:
circumference = 2 * pi * max(r, .01)
cells = max(1, round(circumference / uv_band))
uvs.append((cells * k / sides, lengths[j] / max(total, 1e-6)))
ring = sides + (1 if uv_band else 0)
for j in range(n - 1):
for k in range(sides):
a0, b0 = j * ring + k, j * ring + (k + 1) % ring if not uv_band else j * ring + k + 1
faces.append((a0, b0, b0 + ring, a0 + ring))
if cap_bottom:
faces.append(tuple(reversed(range(sides))))
if cap_top:
faces.append(tuple(range((n - 1) * ring, (n - 1) * ring + sides)))
ob = make(name, verts, faces, mat, uvs if uv_band else None, smooth)
# Faces must point outwards whatever the profile direction was.
bm = bmesh.new()
bm.from_mesh(ob.data)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-6)
bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
bm.to_mesh(ob.data)
bm.free()
return ob
def box(name, center, size, mat, yaw=0.0, pitch=0.0, roll=0.0, bev=.025, segments=1):
hx, hy, hz = size[0] / 2, size[1] / 2, size[2] / 2
rot = Matrix.Rotation(yaw, 3, 'Y') @ Matrix.Rotation(pitch, 3, 'X') @ Matrix.Rotation(roll, 3, 'Z')
c = V(center)
verts = []
for dy in (-1, 1):
for dz in (-1, 1):
for dx in (-1, 1):
verts.append(tuple(c + rot @ V((dx * hx, dy * hy, dz * hz))))
faces = [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)]
ob = make(name, verts, faces, mat)
_outward(ob)
if bev > 0:
bevel(ob, bev, segments, 30.0)
return ob
def _outward(ob):
bm = bmesh.new()
bm.from_mesh(ob.data)
bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
bm.to_mesh(ob.data)
bm.free()
def prism(name, outline, y0, y1, mat, bev=.02, segments=1, top_scale=1.0):
"""Extrudes a closed Godot-xz outline from y0 to y1 (top optionally scaled around its centre)."""
n = len(outline)
cx = sum(p[0] for p in outline) / n
cz = sum(p[1] for p in outline) / n
verts = [(x, y0, z) for x, z in outline]
verts += [(cx + (x - cx) * top_scale, y1, cz + (z - cz) * top_scale) for x, z in outline]
faces = [(i, (i + 1) % n, n + (i + 1) % n, n + i) for i in range(n)]
faces += [tuple(reversed(range(n))), tuple(range(n, 2 * n))]
ob = make(name, verts, faces, mat)
_outward(ob)
if bev > 0:
bevel(ob, bev, segments, 30.0)
return ob
def sweep(name, points, widths, thicknesses, mat, up_hint=(0, 1, 0), steps=24, lens=0.0, closed_ends=True, uv_cells=None,
smooth=True, twist=0.0):
"""Sweeps a rectangle (optionally lens-shaped) along a Catmull-Rom path through Godot points.
widths/thicknesses are sampled along the path. uv_cells gives rune UVs (u along, v across)."""
pts = [V(p) for p in points]
def cat(t):
seg = min(len(pts) - 2, int(t * (len(pts) - 1)))
u = t * (len(pts) - 1) - seg
a, b, c, d = [pts[max(0, min(len(pts) - 1, q))] for q in (seg - 1, seg, seg + 1, seg + 2)]
return .5 * ((2 * b) + (-a + c) * u + (2 * a - 5 * b + 4 * c - d) * u * u + (-a + 3 * b - 3 * c + d) * u * u * u)
def sample(values, t):
f = t * (len(values) - 1)
i = min(len(values) - 2, int(f))
return values[i] * (1 - (f - i)) + values[i + 1] * (f - i)
# Rectangle corners with optional lens bulge: 8 points around the section.
section = [(-1, -1), (0, -1 - lens), (1, -1), (1, 0), (1, 1), (0, 1 + lens), (-1, 1), (-1, 0)]
verts, faces, uvs = [], [], []
along = 0.0
previous = None
for j in range(steps + 1):
t = j / steps
p = cat(t)
tangent = (cat(min(1, t + .01)) - cat(max(0, t - .01))).normalized()
hint = V(up_hint)
side = tangent.cross(hint)
if side.length < 1e-4:
side = tangent.cross(V((1, 0, 0)))
side.normalize()
normal = side.cross(tangent).normalized()
if twist:
ang = twist * t
side, normal = side * cos(ang) + normal * sin(ang), normal * cos(ang) - side * sin(ang)
if previous is not None:
along += (p - previous).length
previous = p
w, th = sample(widths, t) * .5, sample(thicknesses, t) * .5
for k, (sx, sy) in enumerate(section):
verts.append(tuple(p + side * sx * w + normal * sy * th))
uvs.append(((along / uv_cells) if uv_cells else t, k / 8.0))
if j:
base = (j - 1) * 8
for k in range(8):
a0, b0 = base + k, base + (k + 1) % 8
faces.append((a0, b0, b0 + 8, a0 + 8))
if closed_ends:
faces.append(tuple(reversed(range(8))))
faces.append(tuple(range(steps * 8, steps * 8 + 8)))
ob = make(name, verts, faces, mat, uvs, smooth)
_outward(ob)
return ob
def crystal(name, center, radius, top, bottom, mat, sides=6, tilt=(0.0, 0.0), twist=0.0, seed=0, shoulder=.62):
"""Faceted crystal: a prism with a pointed crown and a shorter pointed foot. Facet radii
vary slightly per side so the highlights break up."""
c = V(center)
rot = Matrix.Rotation(tilt[0], 3, 'X') @ Matrix.Rotation(tilt[1], 3, 'Z')
verts = [tuple(c + rot @ V((0, top, 0)))]
rings = [(shoulder * top, 1.0), (-shoulder * bottom * .5, 1.0)]
for y, scale in rings:
for k in range(sides):
a = 2 * pi * k / sides + twist
rr = radius * scale * (1 + .12 * noise.noise(V((k * 1.7, y, seed))))
verts.append(tuple(c + rot @ V((cos(a) * rr, y, sin(a) * rr))))
verts.append(tuple(c + rot @ V((0, -bottom, 0))))
faces = [(0, 1 + (k + 1) % sides, 1 + k) for k in range(sides)]
for k in range(sides):
faces.append((1 + k, 1 + (k + 1) % sides, 1 + sides + (k + 1) % sides, 1 + sides + k))
last = 1 + 2 * sides
faces += [(last, 1 + sides + k, 1 + sides + (k + 1) % sides) for k in range(sides)]
ob = make(name, verts, faces, mat)
_outward(ob)
return ob
def blob(name, center, radii, mat, seed=0.0, subdiv=3, amp=.25, flatten_below=None, yaw=0.0, crag=0.0):
"""Rock mass: icosphere displaced by fractal noise (and optional terraced crags)."""
bm = bmesh.new()
bmesh.ops.create_icosphere(bm, subdivisions=subdiv, radius=1.0)
c = V(center)
rot = Matrix.Rotation(yaw, 3, 'Y')
for v in bm.verts:
d = v.co.normalized()
n = noise.fractal(d * 1.6 + V((seed, seed * .7, seed * .3)), 1.0, 2.0, 4)
r = 1.0 + amp * n
if crag:
r += crag * abs(noise.noise(d * 3.1 + V((seed * 1.3, 0, 0))))
p = V((d.x * radii[0] * r, d.y * radii[1] * r, d.z * radii[2] * r))
if flatten_below is not None and p.y < flatten_below:
p.y = flatten_below + (p.y - flatten_below) * .15
v.co = G(*(c + rot @ p))
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
me.materials.append(material(mat))
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
return ob
def chunk(name, center, size, mat, seed=0.0, yaw=0.0, tilt=0.0, sink=.25):
"""Broken masonry fragment: a low-poly displaced icosphere with a flat bed, irregular and
angular (rubble, debris, ruins). `sink` buries the bottom into the ground."""
bm = bmesh.new()
bmesh.ops.create_icosphere(bm, subdivisions=1, radius=1.0)
c = V(center)
rot = Matrix.Rotation(yaw, 3, 'Y') @ Matrix.Rotation(tilt, 3, 'X')
sx, sy, sz = size
for v in bm.verts:
d = v.co.normalized()
r = 1.0 + .28 * noise.noise(d * 2.1 + V((seed, seed * 1.9, seed * .4)))
p = V((d.x * sx * r, d.y * sy * r, d.z * sz * r))
# Flatten faces into cut planes so fragments look split, not rounded.
p.x = max(-sx * .82, min(sx * .82, p.x))
p.z = max(-sz * .82, min(sz * .82, p.z))
p.y = max(-sy * (1 - sink), min(sy * .9, p.y))
v.co = G(*(c + rot @ p))
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
me.materials.append(material(mat))
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
return ob
def cliff(name, center, radii, mat, seed=0.0, cuts=7, subdiv=2, amp=.16, yaw=0.0, lean=(0.0, 0.0), flat_bottom=None):
"""Faceted cliff rock in the low-poly language of the map rocks: a displaced icosphere cut
by a few random planes (split faces), leaning optionally. Flat shading comes from
finalize()'s angle split. flat_bottom (Godot y, absolute) flattens everything below it."""
bm = bmesh.new()
bmesh.ops.create_icosphere(bm, subdivisions=subdiv, radius=1.0)
rng = seed * 7.31
planes = []
for k in range(cuts):
# Cut normals biased sideways and up, so faces read as cleft rock, not as a gem.
a = 2 * pi * ((k * .618 + seed * .17) % 1.0)
tilt = .25 + .9 * ((k * .377 + seed * .29) % 1.0)
n = V((cos(a) * sin(tilt * 1.2), cos(tilt * 1.2) * .6, sin(a) * sin(tilt * 1.2))).normalized()
planes.append((n, .6 + .26 * ((k * .731 + rng) % 1.0)))
c = V(center)
rot = Matrix.Rotation(yaw, 3, 'Y') @ Matrix.Rotation(lean[0], 3, 'X') @ Matrix.Rotation(lean[1], 3, 'Z')
for v in bm.verts:
d = v.co.normalized()
r = 1.0 + amp * noise.fractal(d * 1.9 + V((seed, seed * .7, seed * .3)), 1.0, 2.0, 3)
q = d * r
for n, off in planes:
over = q.dot(n) - off
if over > 0:
q -= n * over
p = V((q.x * radii[0], q.y * radii[1], q.z * radii[2]))
w = c + rot @ p
if flat_bottom is not None and w.y < flat_bottom:
w.y = flat_bottom
v.co = G(*w)
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
me.materials.append(material(mat))
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
return ob
def ring(name, center, radius, width, thickness, mat, sides=48, axis_tilt=(0.0, 0.0), uv_cell=None, bev=.02):
"""Flat ring (rectangular section) around the Godot y axis, tilted by (x, z) angles.
The outer band carries rune UVs when uv_cell is given."""
c = V(center)
rot = Matrix.Rotation(axis_tilt[0], 3, 'X') @ Matrix.Rotation(axis_tilt[1], 3, 'Z')
section = [(radius - width / 2, -thickness / 2), (radius + width / 2, -thickness / 2),
(radius + width / 2, thickness / 2), (radius - width / 2, thickness / 2)]
verts, faces, uvs = [], [], []
cells = max(1, round(2 * pi * radius / uv_cell)) if uv_cell else 1
for k in range(sides + 1):
a = 2 * pi * k / sides
for s, (r, y) in enumerate(section):
verts.append(tuple(c + rot @ V((cos(a) * r, y, sin(a) * r))))
uvs.append((cells * k / sides, 0.0 if s in (0, 1) else 1.0))
for k in range(sides):
for s in range(4):
a0, b0 = k * 4 + s, k * 4 + (s + 1) % 4
faces.append((a0, b0, b0 + 4, a0 + 4))
ob = make(name, verts, faces, mat, uvs)
_outward(ob)
if bev > 0:
bevel(ob, bev, 1, 30.0)
return ob
def band(name, center, radius, y0, y1, mat, sides=48, uv_cell=.35, inset=0.0):
"""Vertical cylindrical band (rune frieze) with UVs: u in glyph cells, v across."""
c = V(center)
verts, faces, uvs = [], [], []
cells = max(1, round(2 * pi * radius / uv_cell))
for k in range(sides + 1):
a = 2 * pi * k / sides
for s, y in enumerate((y0, y1)):
verts.append((c.x + cos(a) * (radius + inset), c.y + y, c.z + sin(a) * (radius + inset)))
uvs.append((cells * k / sides, float(s)))
for k in range(sides):
faces.append((k * 2, k * 2 + 2, k * 2 + 3, k * 2 + 1))
ob = make(name, verts, faces, mat, uvs)
_outward(ob)
return ob
def panel(name, center, width, height, normal_yaw, mat, depth=.02, uv_cells=(1, 1)):
"""Thin rectangular inlay facing the Godot direction normal_yaw (for rune panels)."""
c = V(center)
right = V((cos(normal_yaw), 0, -sin(normal_yaw)))
out = V((sin(normal_yaw), 0, cos(normal_yaw)))
up = V((0, 1, 0))
verts, uvs = [], []
for sy in (-1, 1):
for sx in (-1, 1):
verts.append(tuple(c + right * sx * width / 2 + up * sy * height / 2 + out * depth))
uvs.append(((sx + 1) / 2 * uv_cells[0], (sy + 1) / 2 * uv_cells[1]))
faces = [(0, 1, 3, 2)]
ob = make(name, verts, faces, mat, uvs)
return ob
def disc_grid(name, center, radius, mat, rings=6, sides=48, y=0.0, uv_scale=None):
"""Flat disc (Godot y up) with polar UVs: u = angle cells, v = radius (for floor runes)."""
c = V(center)
verts, faces, uvs = [(c.x, c.y + y, c.z)], [], [(0.0, 0.0)]
for j in range(1, rings + 1):
r = radius * j / rings
for k in range(sides + 1):
a = 2 * pi * k / sides
verts.append((c.x + cos(a) * r, c.y + y, c.z + sin(a) * r))
uvs.append((k / sides * (uv_scale or 1.0), j / rings))
for k in range(sides):
faces.append((0, 1 + k + 1, 1 + k))
ring_n = sides + 1
for j in range(rings - 1):
for k in range(sides):
a0 = 1 + j * ring_n + k
faces.append((a0, a0 + 1, a0 + 1 + ring_n, a0 + ring_n))
ob = make(name, verts, faces, mat, uvs)
for poly in ob.data.polygons:
poly.use_smooth = True
# Faces up.
bm = bmesh.new()
bm.from_mesh(ob.data)
for f in bm.faces:
f.normal_update()
if f.normal.z < 0:
f.normal_flip()
bm.to_mesh(ob.data)
bm.free()
return ob
def banner(name, width, height, mat, shape='swallowtail', segments=(6, 12), top=(0, 0, 0), yaw=0.0):
"""Hanging cloth below the Godot point `top`: UV v runs 0 at the pole to 1 at the hem, so
the cloth shader weights the wind by v. shape: 'swallowtail' (Team A, cool) or 'flame'
(Team B, warm: three flame tongues)."""
nx, ny = segments
t = V(top)
rot = Matrix.Rotation(yaw, 3, 'Y')
verts, uvs, faces = [], [], []
for j in range(ny + 1):
v = j / ny
for i in range(nx + 1):
u = i / nx
x = (u - .5) * width
y = -v * height
if j == ny:
if shape == 'swallowtail':
y += height * .28 * (1 - abs(u - .5) * 2)
else:
y -= height * .12 * max(0.0, cos((u - .5) * 3 * 2 * pi)) ** 2
verts.append(tuple(t + rot @ V((x, y, 0))))
uvs.append((u, v))
for j in range(ny):
for i in range(nx):
a = j * (nx + 1) + i
faces.append((a, a + 1, a + nx + 2, a + nx + 1))
ob = make(name, verts, faces, mat, uvs, smooth=True)
return ob
# ---------------------------------------------------------------- finishing
def finalize(objects, sharp_angle=40.0, ao=True, rays=24, reach=2.2, ground=0.0, occluders=None, seed=7):
"""Split normals by angle, then bake ambient occlusion into the vertex colour Col.
Occlusion is traced against all `occluders` (default: the objects themselves) plus the
ground plane at Godot height `ground`, so feet of walls and inner corners darken."""
for ob in objects:
me = ob.data
bm = bmesh.new()
bm.from_mesh(me)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-6)
bm.to_mesh(me)
bm.free()
me.shade_smooth()
me.set_sharp_from_angle(angle=sharp_angle * pi / 180.0)
me.update()
if not ao:
for ob in objects:
_fill_color(ob, lambda i: 1.0)
return
sources = occluders if occluders is not None else objects
verts, polys = [], []
for ob in sources:
mw = ob.matrix_world
base = len(verts)
verts += [mw @ v.co for v in ob.data.vertices]
polys += [tuple(base + i for i in p.vertices) for p in ob.data.polygons]
tree = BVHTree.FromPolygons(verts, polys, epsilon=0.0)
# Fixed, well spread hemisphere directions (golden spiral), rotated per vertex.
dirs = []
for i in range(rays):
z = 1 - (i + .5) / rays
r = sqrt(max(0.0, 1 - z * z))
a = i * 2.39996323
dirs.append(V((cos(a) * r, sin(a) * r, z)))
ground_z = ground # Blender z == Godot y
for ob in objects:
mw = ob.matrix_world
nm = mw.to_3x3().inverted().transposed()
values = []
for v in ob.data.vertices:
p = mw @ v.co
n = (nm @ v.normal).normalized()
t = n.orthogonal().normalized()
b = n.cross(t)
hit = 0.0
origin = p + n * .012
for d in dirs:
w = t * d.x + b * d.y + n * d.z
blocked = False
if w.z < -1e-4 and origin.z >= ground_z:
dist = (origin.z - ground_z) / -w.z
blocked = dist < reach
if not blocked:
loc, _, _, dist = tree.ray_cast(origin, w, reach)
blocked = loc is not None
if blocked:
hit += d.z
weight = sum(d.z for d in dirs)
values.append(max(0.0, 1.0 - hit / weight))
_fill_color(ob, lambda i, values=values: .28 + .72 * values[i] ** .8)
def _fill_color(ob, fn):
me = ob.data
attr = me.color_attributes.get('Col') or me.color_attributes.new(name='Col', type='FLOAT_COLOR', domain='POINT')
for i, item in enumerate(attr.data):
a = fn(i)
item.color = (a, a, a, 1.0)
me.color_attributes.active_color = attr
me.color_attributes.render_color_index = me.color_attributes.find('Col')
def triangulate(ob):
bm = bmesh.new()
bm.from_mesh(ob.data)
bmesh.ops.triangulate(bm, faces=list(bm.faces))
bm.to_mesh(ob.data)
bm.free()
def triangles(objects):
total = 0
for ob in objects:
total += sum(len(p.vertices) - 2 for p in ob.data.polygons)
return total
def export(objects, glb_name, blend_name, extra_objects=()):
"""Saves the .blend and exports the given objects (plus armatures) as GLB. Returns the
parsed glTF JSON and the raw bytes for the manifest."""
bpy.ops.wm.save_as_mainfile(filepath=str(SRC / blend_name))
bpy.ops.object.select_all(action='DESELECT')
for ob in list(objects) + list(extra_objects):
ob.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
path = OUT / glb_name
bpy.ops.export_scene.gltf(filepath=str(path), export_format='GLB', use_selection=True, export_materials='EXPORT',
export_yup=True, export_animations=False, export_vertex_color='ACTIVE', export_normals=True,
export_apply=False)
raw = path.read_bytes()
doc = json.loads(raw[20:20 + struct.unpack_from('<I', raw, 12)[0]])
return doc, raw
def glb_triangles(doc):
return sum(doc['accessors'][q['indices']]['count'] // 3 for m in doc['meshes'] for q in m['primitives'])
def bounds_godot(objects):
lo = [1e9, 1e9, 1e9]
hi = [-1e9, -1e9, -1e9]
for ob in objects:
for v in ob.data.vertices:
g = to_godot(ob.matrix_world @ v.co)
for i in range(3):
lo[i] = min(lo[i], g[i])
hi[i] = max(hi[i], g[i])
return {'min': [round(x, 4) for x in lo], 'max': [round(x, 4) for x in hi]}
def record(asset_id, glb_name, blend_name, generator, objects, doc, raw, **extra):
nodes = sorted(n.get('name', '') for n in doc.get('nodes', []))
entry = {'id': asset_id, 'resource_path': 'res://assets/luneraria/g2/' + glb_name,
'source': 'assets-source/luneraria/g2/' + blend_name, 'generator': 'assets-source/luneraria/g2/' + generator,
'triangles': glb_triangles(doc), 'nodes': nodes, 'materials': sorted(m['name'] for m in doc.get('materials', [])),
'bounds_godot': bounds_godot(objects), 'pivot': 'ground centre (0, 0, 0)', 'units': 'metres (Godot 1 unit = 1 m)',
'sha256': hashlib.sha256(raw).hexdigest()}
entry.update(extra)
return entry
def write_manifest(entries):
"""Merges entries (by id) into assets/luneraria/g2/g2_manifest.json."""
path = OUT / 'g2_manifest.json'
data = json.loads(path.read_text()) if path.exists() else {'assets': []}
by_id = {e['id']: e for e in data['assets']}
for e in entries:
by_id[e['id']] = e
data['assets'] = [by_id[k] for k in sorted(by_id)]
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n')

Binary file not shown.

View file

@ -4,8 +4,9 @@ importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://qglgrg8yrddc" uid="uid://qglgrg8yrddc"
path.bptc="res://.godot/imported/ringborke_oak-height-4k.png-cfd49dae7a34a547b10e0dd2f64aea40.bptc.ctex" path.bptc="res://.godot/imported/ringborke_oak-height-4k.png-cfd49dae7a34a547b10e0dd2f64aea40.bptc.ctex"
path.astc="res://.godot/imported/ringborke_oak-height-4k.png-cfd49dae7a34a547b10e0dd2f64aea40.astc.ctex"
metadata={ metadata={
"imported_formats": ["s3tc_bptc"], "imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true "vram_texture": true
} }
generator_parameters={ generator_parameters={
@ -15,7 +16,7 @@ generator_parameters={
[deps] [deps]
source_file="res://assets/characters/ringborke_oak-height-4k.png" source_file="res://assets/characters/ringborke_oak-height-4k.png"
dest_files=["res://.godot/imported/ringborke_oak-height-4k.png-cfd49dae7a34a547b10e0dd2f64aea40.bptc.ctex"] dest_files=["res://.godot/imported/ringborke_oak-height-4k.png-cfd49dae7a34a547b10e0dd2f64aea40.bptc.ctex", "res://.godot/imported/ringborke_oak-height-4k.png-cfd49dae7a34a547b10e0dd2f64aea40.astc.ctex"]
[params] [params]

View file

@ -4,8 +4,9 @@ importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://varymvp2etyi" uid="uid://varymvp2etyi"
path.bptc="res://.godot/imported/ringborke_ringborke-cut-albedo-4k.png-73c85421d02040c31d6a8d7e1fd502e5.bptc.ctex" path.bptc="res://.godot/imported/ringborke_ringborke-cut-albedo-4k.png-73c85421d02040c31d6a8d7e1fd502e5.bptc.ctex"
path.astc="res://.godot/imported/ringborke_ringborke-cut-albedo-4k.png-73c85421d02040c31d6a8d7e1fd502e5.astc.ctex"
metadata={ metadata={
"imported_formats": ["s3tc_bptc"], "imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true "vram_texture": true
} }
generator_parameters={ generator_parameters={
@ -15,7 +16,7 @@ generator_parameters={
[deps] [deps]
source_file="res://assets/characters/ringborke_ringborke-cut-albedo-4k.png" source_file="res://assets/characters/ringborke_ringborke-cut-albedo-4k.png"
dest_files=["res://.godot/imported/ringborke_ringborke-cut-albedo-4k.png-73c85421d02040c31d6a8d7e1fd502e5.bptc.ctex"] dest_files=["res://.godot/imported/ringborke_ringborke-cut-albedo-4k.png-73c85421d02040c31d6a8d7e1fd502e5.bptc.ctex", "res://.godot/imported/ringborke_ringborke-cut-albedo-4k.png-73c85421d02040c31d6a8d7e1fd502e5.astc.ctex"]
[params] [params]

View file

@ -4,8 +4,9 @@ importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://bqy23d8gmsx38" uid="uid://bqy23d8gmsx38"
path.bptc="res://.godot/imported/ringborke_ringborke-cut-normal-4k.png-9bdfa6557e64b2117e348dcc9fc861c3.bptc.ctex" path.bptc="res://.godot/imported/ringborke_ringborke-cut-normal-4k.png-9bdfa6557e64b2117e348dcc9fc861c3.bptc.ctex"
path.astc="res://.godot/imported/ringborke_ringborke-cut-normal-4k.png-9bdfa6557e64b2117e348dcc9fc861c3.astc.ctex"
metadata={ metadata={
"imported_formats": ["s3tc_bptc"], "imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true "vram_texture": true
} }
generator_parameters={ generator_parameters={
@ -15,7 +16,7 @@ generator_parameters={
[deps] [deps]
source_file="res://assets/characters/ringborke_ringborke-cut-normal-4k.png" source_file="res://assets/characters/ringborke_ringborke-cut-normal-4k.png"
dest_files=["res://.godot/imported/ringborke_ringborke-cut-normal-4k.png-9bdfa6557e64b2117e348dcc9fc861c3.bptc.ctex"] dest_files=["res://.godot/imported/ringborke_ringborke-cut-normal-4k.png-9bdfa6557e64b2117e348dcc9fc861c3.bptc.ctex", "res://.godot/imported/ringborke_ringborke-cut-normal-4k.png-9bdfa6557e64b2117e348dcc9fc861c3.astc.ctex"]
[params] [params]

View file

@ -4,8 +4,9 @@ importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://b1phtpe3asudy" uid="uid://b1phtpe3asudy"
path.bptc="res://.godot/imported/ringborke_ringborke-cut-roughness-4k.png-d74b9087b174fb5af2fc65e3b578dce3.bptc.ctex" path.bptc="res://.godot/imported/ringborke_ringborke-cut-roughness-4k.png-d74b9087b174fb5af2fc65e3b578dce3.bptc.ctex"
path.astc="res://.godot/imported/ringborke_ringborke-cut-roughness-4k.png-d74b9087b174fb5af2fc65e3b578dce3.astc.ctex"
metadata={ metadata={
"imported_formats": ["s3tc_bptc"], "imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true "vram_texture": true
} }
generator_parameters={ generator_parameters={
@ -15,7 +16,7 @@ generator_parameters={
[deps] [deps]
source_file="res://assets/characters/ringborke_ringborke-cut-roughness-4k.png" source_file="res://assets/characters/ringborke_ringborke-cut-roughness-4k.png"
dest_files=["res://.godot/imported/ringborke_ringborke-cut-roughness-4k.png-d74b9087b174fb5af2fc65e3b578dce3.bptc.ctex"] dest_files=["res://.godot/imported/ringborke_ringborke-cut-roughness-4k.png-d74b9087b174fb5af2fc65e3b578dce3.bptc.ctex", "res://.godot/imported/ringborke_ringborke-cut-roughness-4k.png-d74b9087b174fb5af2fc65e3b578dce3.astc.ctex"]
[params] [params]

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://3q5tqaafok36"
path="res://.godot/imported/ancient.glb-9aa70e696eb559f0231a6e200afc4b17.scn"
[deps]
source_file="res://assets/luneraria/g2/ancient.glb"
dest_files=["res://.godot/imported/ancient.glb-9aa70e696eb559f0231a6e200afc4b17.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://b2rxost426mpt"
path="res://.godot/imported/bastion.glb-63ae45f3c7bd69ea38928675b5ca9526.scn"
[deps]
source_file="res://assets/luneraria/g2/bastion.glb"
dest_files=["res://.godot/imported/bastion.glb-63ae45f3c7bd69ea38928675b5ca9526.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://cyup5xtftwojd"
path="res://.godot/imported/bossgrotte.glb-90f1207b41dac974736a7c52b6ba987a.scn"
[deps]
source_file="res://assets/luneraria/g2/bossgrotte.glb"
dest_files=["res://.godot/imported/bossgrotte.glb-90f1207b41dac974736a7c52b6ba987a.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

View file

@ -0,0 +1,373 @@
{
"assets": [
{
"id": "ancient",
"resource_path": "res://assets/luneraria/g2/ancient.glb",
"source": "assets-source/luneraria/g2/ancient.blend",
"generator": "assets-source/luneraria/g2/build_ancient.py",
"triangles": 19032,
"nodes": [
"Banner_L",
"Banner_R",
"Blade_0",
"Blade_1",
"Blade_2",
"Blade_3",
"Crest_A",
"Crest_B",
"Crown",
"Crystal",
"Dais",
"Fragment_0",
"Fragment_1",
"Fragment_2",
"Fragment_3",
"Fragment_4",
"Fragment_5",
"Pedestal",
"Ring_Inner",
"Ring_Outer",
"Ruin_Crystal",
"Ruin_Rubble",
"Satellite_0",
"Satellite_1",
"Satellite_2",
"Satellite_3",
"Satellite_4"
],
"materials": [
"LN_Cloth_Team",
"LN_Crystal_Team",
"LN_Metal_Iron",
"LN_Metal_Team",
"LN_Rune_Team",
"LN_Stone_Dark",
"LN_Stone_Pale"
],
"bounds_godot": {
"min": [
-4.2035,
-0.9881,
-4.0
],
"max": [
4.2035,
10.3668,
4.0
]
},
"pivot": "ground centre (0, 0, 0)",
"units": "metres (Godot 1 unit = 1 m)",
"sha256": "a3d6ccaf1b9387e1df088f6375232ebc7f15402a80b6abd82885062926acc7dc",
"title": "Mondherz (Ancient)",
"crystal_height": 4.7,
"blade_split": 3.4,
"heart_source": "assets-source/phase4/blend/core_team0.blend (Core_CrystalMain, Core_CrystalSatellite0-4), scale 2.3",
"triangles_intact": 15984,
"triangles_ruin": 880
},
{
"id": "bastion",
"resource_path": "res://assets/luneraria/g2/bastion.glb",
"source": "assets-source/luneraria/g2/bastion.blend",
"generator": "assets-source/luneraria/g2/build_towers.py",
"triangles": 9364,
"nodes": [
"Crystal",
"Debris",
"Lower",
"Pennant",
"Ring",
"Ruin",
"Shard_0",
"Shard_1",
"Shard_2",
"Upper",
"Upper_Damaged"
],
"materials": [
"LN_Cloth_Team",
"LN_Crystal_Team",
"LN_Metal_Iron",
"LN_Metal_Team",
"LN_Rune_Team",
"LN_Stone_Dark",
"LN_Stone_Pale",
"LN_Wood"
],
"bounds_godot": {
"min": [
-1.7395,
0.0,
-2.25
],
"max": [
1.7395,
7.1605,
1.6437
]
},
"pivot": "ground centre (0, 0, 0)",
"units": "metres (Godot 1 unit = 1 m)",
"sha256": "80c13b7a5f54e76d07e83bfd74a98f6bc86b76033875f37f448dce416b5f678e",
"title": "Bastion",
"split_height": 2.6,
"crystal_height": 6.2,
"triangles_intact": 4718,
"triangles_ruin": 1382
},
{
"id": "bossgrotte",
"resource_path": "res://assets/luneraria/g2/bossgrotte.glb",
"source": "assets-source/luneraria/g2/bossgrotte.blend",
"generator": "assets-source/luneraria/g2/build_bossgrotte.py",
"triangles": 15182,
"nodes": [
"Altar",
"Cave",
"CaveGlow",
"Channel",
"Floor",
"Gate",
"PillarCrystals",
"Pillars",
"Platform",
"Rocks",
"Runes",
"Source"
],
"materials": [
"LN_Cave",
"LN_Crystal_Grotto",
"LN_Metal_Bronze",
"LN_Rock",
"LN_Rune_Grotto",
"LN_Stone_Dark",
"LN_Stone_Grotto",
"LN_Water_Glow"
],
"bounds_godot": {
"min": [
-10.5442,
-1.93,
-10.4646
],
"max": [
7.7775,
7.6927,
7.7976
]
},
"pivot": "ground centre (0, 0, 0)",
"units": "metres (Godot 1 unit = 1 m)",
"sha256": "63dab12607f6d2f26e12af70b8b561d13a853683b363160df76ec6392610d04c",
"title": "Mondschlund (Bossgrotte)",
"boss_point": [
-1.5,
0.0,
-1.5
],
"arena_radius": 7.8,
"gate": [
-6.329,
0.0,
-6.329
],
"lane_clearance_min": 0.6,
"cave_notch": {
"u": [
-11.95,
-8.45
],
"half_width": 1.94,
"depth": 1.0
},
"note": "symmetric across the local diagonal x = z; the Godot view places it at LuneMap.GROTTO on the terrace"
},
{
"id": "grenzwacht",
"resource_path": "res://assets/luneraria/g2/grenzwacht.glb",
"source": "assets-source/luneraria/g2/grenzwacht.blend",
"generator": "assets-source/luneraria/g2/build_towers.py",
"triangles": 6516,
"nodes": [
"Crystal",
"Debris",
"Lower",
"Pennant",
"Ruin",
"Upper",
"Upper_Damaged"
],
"materials": [
"LN_Cloth_Team",
"LN_Crystal_Team",
"LN_Metal_Iron",
"LN_Metal_Team",
"LN_Rune_Team",
"LN_Stone_Dark",
"LN_Stone_Pale",
"LN_Wood"
],
"bounds_godot": {
"min": [
-1.3717,
0.0,
-1.3735
],
"max": [
1.3752,
6.0601,
1.3678
]
},
"pivot": "ground centre (0, 0, 0)",
"units": "metres (Godot 1 unit = 1 m)",
"sha256": "f69a83b7a2c67202f41d5892984a2db8572206124ad366d2c91c9da653030536",
"title": "Grenzwacht",
"split_height": 2.2,
"crystal_height": 5.2,
"triangles_intact": 3174,
"triangles_ruin": 1038
},
{
"id": "kernwacht",
"resource_path": "res://assets/luneraria/g2/kernwacht.glb",
"source": "assets-source/luneraria/g2/kernwacht.blend",
"generator": "assets-source/luneraria/g2/build_towers.py",
"triangles": 9224,
"nodes": [
"Banner_L",
"Banner_R",
"Crystal",
"Debris",
"Lower",
"Ring",
"Ruin",
"Upper",
"Upper_Damaged"
],
"materials": [
"LN_Cloth_Team",
"LN_Crystal_Team",
"LN_Metal_Iron",
"LN_Metal_Team",
"LN_Rune_Team",
"LN_Stone_Dark",
"LN_Stone_Pale"
],
"bounds_godot": {
"min": [
-1.8045,
0.0,
-1.8017
],
"max": [
1.8139,
8.33,
1.806
]
},
"pivot": "ground centre (0, 0, 0)",
"units": "metres (Godot 1 unit = 1 m)",
"sha256": "7f7492d69d1782ee82f8e63c0b01c1bf6318f5ab685e3573200539843f056a07",
"title": "Kernwacht",
"split_height": 2.7,
"crystal_height": 5.25,
"triangles_intact": 4652,
"triangles_ruin": 1132
},
{
"id": "schlundkrone",
"title": "Schlundkrone (Superboss)",
"resource_path": "res://assets/luneraria/g2/schlundkrone.glb",
"source": "assets-source/luneraria/g2/schlundkrone.blend",
"generator": "assets-source/luneraria/g2/build_schlundkrone.py",
"triangles": 5712,
"source_parts": 82,
"skeletal": true,
"bones": 24,
"bone_names": [
"Root",
"Hips",
"Spine",
"Chest",
"Neck",
"Head",
"Jaw",
"Crown",
"Maw.L",
"Shoulder.L",
"UpperArm.L",
"Forearm.L",
"Hand.L",
"Thigh.L",
"Shin.L",
"Foot.L",
"Maw.R",
"Shoulder.R",
"UpperArm.R",
"Forearm.R",
"Hand.R",
"Thigh.R",
"Shin.R",
"Foot.R"
],
"clips": [
"ability",
"attack_a",
"attack_b",
"death",
"hit",
"idle",
"roar",
"start",
"stop",
"victory",
"walk"
],
"clip_seconds": {
"idle": 3.0,
"start": 0.4,
"walk": 1.6,
"stop": 0.4,
"attack_a": 1.3,
"attack_b": 1.3,
"hit": 0.5,
"ability": 1.3,
"death": 2.2,
"roar": 2.0,
"victory": 2.0
},
"attack_cycle": [
"attack_a",
"attack_b"
],
"contact_seconds": 0.9,
"stride": 2.0,
"view_scale": 1.7,
"materials": [
"LN_Boss_Bone",
"LN_Boss_Bronze",
"LN_Boss_Hide",
"LN_Crystal_Boss"
],
"skins": 1,
"bounds_godot": {
"min": [
-1.2307,
0.0053,
-0.5532
],
"max": [
1.3273,
3.3182,
0.9253
]
},
"pivot": "ground centre (0, 0, 0), faces +z",
"units": "metres at view scale 1",
"sha256": "20c7c0adafeae31563d328a3a5906c9ede13ab22a4b3ef153c0114a6f1ec755a"
}
]
}

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dqlur2vf8832t"
path="res://.godot/imported/grenzwacht.glb-db290edb90fa13ed9c8cb6d8db64d204.scn"
[deps]
source_file="res://assets/luneraria/g2/grenzwacht.glb"
dest_files=["res://.godot/imported/grenzwacht.glb-db290edb90fa13ed9c8cb6d8db64d204.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dvksl6pcbnhjg"
path="res://.godot/imported/kernwacht.glb-8e1c1ddb5290ff9b4d54a79fd6b715c0.scn"
[deps]
source_file="res://assets/luneraria/g2/kernwacht.glb"
dest_files=["res://.godot/imported/kernwacht.glb-8e1c1ddb5290ff9b4d54a79fd6b715c0.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://xmht20y5rn2h"
path="res://.godot/imported/schlundkrone.glb-38228c20a8f54dd8b14675a8d4d95fb0.scn"
[deps]
source_file="res://assets/luneraria/g2/schlundkrone.glb"
dest_files=["res://.godot/imported/schlundkrone.glb-38228c20a8f54dd8b14675a8d4d95fb0.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

View file

@ -4,15 +4,16 @@ importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://gb8k2gh1fatp" uid="uid://gb8k2gh1fatp"
path.bptc="res://.godot/imported/limestone-albedo.png-e2ef74eda1463a024457e6fa83926f32.bptc.ctex" path.bptc="res://.godot/imported/limestone-albedo.png-e2ef74eda1463a024457e6fa83926f32.bptc.ctex"
path.astc="res://.godot/imported/limestone-albedo.png-e2ef74eda1463a024457e6fa83926f32.astc.ctex"
metadata={ metadata={
"imported_formats": ["s3tc_bptc"], "imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true "vram_texture": true
} }
[deps] [deps]
source_file="res://assets/materials/phase10/limestone-albedo.png" source_file="res://assets/materials/phase10/limestone-albedo.png"
dest_files=["res://.godot/imported/limestone-albedo.png-e2ef74eda1463a024457e6fa83926f32.bptc.ctex"] dest_files=["res://.godot/imported/limestone-albedo.png-e2ef74eda1463a024457e6fa83926f32.bptc.ctex", "res://.godot/imported/limestone-albedo.png-e2ef74eda1463a024457e6fa83926f32.astc.ctex"]
[params] [params]

View file

@ -4,15 +4,16 @@ importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://c5j13kmnfjj6q" uid="uid://c5j13kmnfjj6q"
path.bptc="res://.godot/imported/oak-albedo-4k.png-9073c445a98157f52562d09fd5d5f9a7.bptc.ctex" path.bptc="res://.godot/imported/oak-albedo-4k.png-9073c445a98157f52562d09fd5d5f9a7.bptc.ctex"
path.astc="res://.godot/imported/oak-albedo-4k.png-9073c445a98157f52562d09fd5d5f9a7.astc.ctex"
metadata={ metadata={
"imported_formats": ["s3tc_bptc"], "imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true "vram_texture": true
} }
[deps] [deps]
source_file="res://assets/materials/phase10/oak-albedo-4k.png" source_file="res://assets/materials/phase10/oak-albedo-4k.png"
dest_files=["res://.godot/imported/oak-albedo-4k.png-9073c445a98157f52562d09fd5d5f9a7.bptc.ctex"] dest_files=["res://.godot/imported/oak-albedo-4k.png-9073c445a98157f52562d09fd5d5f9a7.bptc.ctex", "res://.godot/imported/oak-albedo-4k.png-9073c445a98157f52562d09fd5d5f9a7.astc.ctex"]
[params] [params]

View file

@ -4,15 +4,16 @@ importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://dbx5xdwy7c2y1" uid="uid://dbx5xdwy7c2y1"
path.bptc="res://.godot/imported/oak-height-4k.png-178f6878e8a21b5c0ef060bf738f5274.bptc.ctex" path.bptc="res://.godot/imported/oak-height-4k.png-178f6878e8a21b5c0ef060bf738f5274.bptc.ctex"
path.astc="res://.godot/imported/oak-height-4k.png-178f6878e8a21b5c0ef060bf738f5274.astc.ctex"
metadata={ metadata={
"imported_formats": ["s3tc_bptc"], "imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true "vram_texture": true
} }
[deps] [deps]
source_file="res://assets/materials/phase10/oak-height-4k.png" source_file="res://assets/materials/phase10/oak-height-4k.png"
dest_files=["res://.godot/imported/oak-height-4k.png-178f6878e8a21b5c0ef060bf738f5274.bptc.ctex"] dest_files=["res://.godot/imported/oak-height-4k.png-178f6878e8a21b5c0ef060bf738f5274.bptc.ctex", "res://.godot/imported/oak-height-4k.png-178f6878e8a21b5c0ef060bf738f5274.astc.ctex"]
[params] [params]

View file

@ -1,6 +1,19 @@
# Installation, Start, Steuerung und Export # Installation, Start, Steuerung und Export
## Start aus dem Quellprojekt (Fedora 44) ## Spielen ohne Quellprojekt
Die Pakete liegen unter `../dist/luneraria/` (Version 0.17.0-g2):
| Paket | Start |
| --- | --- |
| `ChampionsOfLuneraria-macOS-universal-0.17.0-g2.zip` | Entpacken, „Champions of Luneraria“ öffnen. Die App ist ad-hoc signiert, nicht notarisiert: beim ersten Start Rechtsklick → Öffnen (oder `xattr -dr com.apple.quarantine "Champions of Luneraria.app"`). Apple Silicon und Intel, ab macOS 11. |
| `ChampionsOfLuneraria-Windows-x64-0.17.0-g2.zip` | `ChampionsOfLuneraria.exe` |
| `ChampionsOfLuneraria-Windows-ARM64-0.17.0-g2.zip` | `ChampionsOfLuneraria.exe` auf Windows on ARM (z. B. Parallels auf dem Mac) |
| `ChampionsOfLuneraria-Linux-x64-0.17.0-g2.zip` | `./ChampionsOfLuneraria.x86_64` |
Ohne Vulkan/Direct3D 12 (etwa in einer VM) wechselt das Spiel selbst auf OpenGL 3 (Kompatibilitätsmodus).
## Start aus dem Quellprojekt
```bash ```bash
cd saumwacht-godot cd saumwacht-godot
@ -10,6 +23,14 @@ godot --path . -- --legacy # archivierter Saumwacht-Modus (scenes/main.tscn)
Voraussetzung ist Godot 4.7.2. Blender wird nur zum Neuerzeugen von Assets gebraucht, nicht zum Spielen. Voraussetzung ist Godot 4.7.2. Blender wird nur zum Neuerzeugen von Assets gebraucht, nicht zum Spielen.
**macOS (seit G2):**
- Godot 4.7.2 liegt unter `~/Applications/Godot-4.7.2.app`, Blender 5.2.2 LTS unter `~/Applications/Blender-5.2.2.app`. Beide sind über `~/.local/bin` als `godot` und `blender` aufrufbar.
- Die Programme unter `/Applications` sind älter (Godot 4.6.2, Blender 5.1.2) und nicht für dieses Projekt gedacht.
- Solange die Xcode-Lizenz nicht bestätigt ist, scheitern `/usr/bin/python3` und `brew install`. Deshalb `/opt/homebrew/bin/python3.12` für die Python-Werkzeuge nutzen (oder einmal `sudo xcodebuild -license accept`).
- Das Projekt importiert Texturen zusätzlich als ETC2/ASTC; das braucht der Export für Apple Silicon.
**Fedora 44 (bis G1):** unverändert, `godot` und `blender` aus dem Paketmanager bzw. als 4.7.2/5.2.2.
## Eine Partie ## Eine Partie
1. **Neue Partie** → Bund (Glutpakt oder Wurzelbund) und deine Lane (Top, Mid, Bottom) wählen → **Partie beginnen**. Du spielst in Team Blau (Südwest) mit zwei Bot-Verbündeten gegen drei Bots in Team Rot. Top führt über das Hochland, Mid über die Mittelplattform, Bottom durch das Tiefland über die Deltabrücke; die Bossgrotte liegt zwischen Top und Mid. 1. **Neue Partie** → Bund (Glutpakt oder Wurzelbund) und deine Lane (Top, Mid, Bottom) wählen → **Partie beginnen**. Du spielst in Team Blau (Südwest) mit zwei Bot-Verbündeten gegen drei Bots in Team Rot. Top führt über das Hochland, Mid über die Mittelplattform, Bottom durch das Tiefland über die Deltabrücke; die Bossgrotte liegt zwischen Top und Mid.
@ -58,6 +79,16 @@ godot --path . --resolution 1920x1080 res://tools/luneraria/lune_benchmark.tscn
godot --path . --resolution 1920x1080 res://tools/luneraria/lune_review.tscn # Aufnahmen godot --path . --resolution 1920x1080 res://tools/luneraria/lune_review.tscn # Aufnahmen
godot --path . --resolution 1920x1080 res://tools/luneraria/verify_g1.tscn # G1: Layout, Gelände, Kamera (443) godot --path . --resolution 1920x1080 res://tools/luneraria/verify_g1.tscn # G1: Layout, Gelände, Kamera (443)
godot --path . --resolution 1920x1080 res://tools/luneraria/g_views.tscn -- --out=res://docs/luneraria/screenshots/g1/nachher # Vergleichsansichten godot --path . --resolution 1920x1080 res://tools/luneraria/g_views.tscn -- --out=res://docs/luneraria/screenshots/g1/nachher # Vergleichsansichten
# G2: alles auf einmal (Regeln, Netz-Regression, G1, G2, echte Anwendung, Stress, Balance, Legacy):
sh tools/luneraria/run_g2_tests.sh # macOS: PYTHON=/opt/homebrew/bin/python3.12 sh tools/luneraria/run_g2_tests.sh
godot --path . --resolution 1920x1080 res://tools/luneraria/verify_g2.tscn # G2: Bauwerke, Grotte, Boss, Occlusion (245)
godot --path . --resolution 1920x1080 res://tools/luneraria/g2_views.tscn -- --out=res://docs/luneraria/screenshots/g2/nachher # Abnahmebilder
godot --path . --resolution 1920x1080 res://tools/luneraria/g2_cost_split.tscn # Kostenaufteilung eines Bildes
godot --path . --resolution 1920x1080 res://tools/luneraria/g2_boss_sheet.tscn -- --out=/tmp/boss/ # Boss-Clips neben Held und Truppe
godot --path . --resolution 1920x1080 res://tools/luneraria/g2_probe.tscn -- --out=/tmp/probe/ --shots="grotte@-13.5,-13.5,20" # freie Kamera-Probe
# Videos (1920 × 1080 erzwingt eine vorübergehende override.cfg mit window_width/height_override):
godot --path . --resolution 1920x1080 --fixed-fps 30 --write-movie /tmp/g2video/turm/frame.png res://tools/luneraria/g2_video.tscn -- --scene=turm
blender -b --python tools/luneraria/encode_video.py -- /tmp/g2video/turm docs/luneraria/videos/g2-turmkampf.mp4 30 1.0
# Legacy (Saumwacht) unverändert: # Legacy (Saumwacht) unverändert:
godot --headless --path . res://tests/test_runner.tscn godot --headless --path . res://tests/test_runner.tscn
godot --headless --path . -- --smoke-test godot --headless --path . -- --smoke-test
@ -74,6 +105,12 @@ ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luner
ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_grotto.py ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_grotto.py
ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_map_props.py ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_map_props.py
ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_peoples.py ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_peoples.py
# G2: strategische Bauwerke, Grotte, Boss (schreiben .blend, GLB und assets/luneraria/g2/g2_manifest.json)
godot --headless --path . -s res://tools/luneraria/dump_grotto_site.gd # Lane-Linien und Gelände um die Grotte für den Generator
ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/g2/build_towers.py
ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/g2/build_ancient.py
ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/g2/build_bossgrotte.py
ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/g2/build_schlundkrone.py
python3 tools/luneraria/build_asset_matrix.py python3 tools/luneraria/build_asset_matrix.py
godot --headless --editor --import --path . godot --headless --editor --import --path .
``` ```
@ -85,7 +122,25 @@ python3 tools/luneraria/build_luneraria_demo.py --check-only
python3 tools/luneraria/build_luneraria_demo.py python3 tools/luneraria/build_luneraria_demo.py
``` ```
Erzeugt `../dist/luneraria/ChampionsOfLuneraria-Linux-x64-<ver>.zip` und `…-Windows-x64-<ver>.zip` (Produktname Champions of Luneraria). Alte Saumwacht-Releases unter `../dist/Saumwacht-*.zip` bleiben unverändert. `tools/build_demo.py` baut weiterhin Saumwacht 0.15.0. Erzeugt unter `../dist/luneraria/`:
- `ChampionsOfLuneraria-Linux-x64-<ver>.zip`
- `…-Windows-x64-<ver>.zip`
- `…-Windows-ARM64-<ver>.zip`
- `…-macOS-universal-<ver>.zip`
- `build-manifest.json` mit Größen und SHA-256.
Die Version steht einmal in `project.godot` unter `[luneraria] config/version` und erscheint auch im Menü. Mit `--only macos,windows,...` baut das Skript einzelne Pakete; Godot kommt aus `PATH` oder der Umgebungsvariable `GODOT`.
Das macOS-Paket schreibt Godot direkt als ZIP: Universal, ad-hoc signiert mit Hardened Runtime, nicht notarisiert. Windows ARM64 nutzt die installierten 4.7.2-Templates; Linux und Windows x64 nutzen die Templates im Repo.
Alte Saumwacht-Releases liegen unter `../_archiv/saumwacht-dist/`. `tools/build_demo.py` (Saumwacht 0.15.0) passt seit M4 nicht mehr zu den umbenannten Presets.
**Plattform-Kurztest eines Pakets:** spielt eine kurze Partie, fotografiert Planung, Kampf, Grotte und Ancient und schreibt `report.json`.
```bash
"Champions of Luneraria.app/Contents/MacOS/Champions of Luneraria" -- --lune-smoke=/tmp/smoke/
ChampionsOfLuneraria.exe -- --lune-smoke=C:/Temp/smoke/
```
**Netzwerk (experimentell, pausiert):** Im Setup *Hosten* oder *Beitreten* mit Anzeigename, Adresse und Port (Standard 24571). Der Host ist autoritativ und rechnet den Kampf; Gäste sehen ihn über Momentaufnahmen. Freie Plätze sind Bots, bis jemand beitritt. Tempo, Pause und Revanche bestimmt der Host. „Bereit“ ist verbindlich. Ein Disconnect wird von einem Bot übernommen und ist kein Kernverlust; wer sich mit demselben Anzeigenamen wieder verbindet, bekommt seinen Platz zurück. Getestet sind Protokollchecks und ein lokaler Loopback über fünf Runden mit Wiedereinstieg – keine verifizierte Internetpartie auf sechs Rechnern, kein OpenGL- und kein Windows-11-Test. Kein Matchmaking, keine Hostmigration. **Netzwerk (experimentell, pausiert):** Im Setup *Hosten* oder *Beitreten* mit Anzeigename, Adresse und Port (Standard 24571). Der Host ist autoritativ und rechnet den Kampf; Gäste sehen ihn über Momentaufnahmen. Freie Plätze sind Bots, bis jemand beitritt. Tempo, Pause und Revanche bestimmt der Host. „Bereit“ ist verbindlich. Ein Disconnect wird von einem Bot übernommen und ist kein Kernverlust; wer sich mit demselben Anzeigenamen wieder verbindet, bekommt seinen Platz zurück. Getestet sind Protokollchecks und ein lokaler Loopback über fünf Runden mit Wiedereinstieg – keine verifizierte Internetpartie auf sechs Rechnern, kein OpenGL- und kein Windows-11-Test. Kein Matchmaking, keine Hostmigration.
@ -97,4 +152,4 @@ godot --headless --path . res://tools/luneraria/net_pair.tscn -- --role=client -
## Benutzereinstellungen ## Benutzereinstellungen
Der neue Projektname legt einen neuen Benutzerordner an (`~/.local/share/godot/app_userdata/Champions of Luneraria`). Beim ersten Start übernimmt `Preferences` einmalig die `preferences.cfg` aus „Saumwacht · Glutpakt“. Die alte Datei bleibt unverändert; fehlt sie, gelten die Standardwerte. Der neue Projektname legt einen neuen Benutzerordner an (Linux `~/.local/share/godot/app_userdata/Champions of Luneraria`, macOS `~/Library/Application Support/Godot/app_userdata/Champions of Luneraria`, Windows `%APPDATA%\Godot\app_userdata\Champions of Luneraria`). Beim ersten Start übernimmt `Preferences` einmalig die `preferences.cfg` aus „Saumwacht · Glutpakt“. Die alte Datei bleibt unverändert; fehlt sie, gelten die Standardwerte.

View file

@ -5,7 +5,7 @@ Stand: 27. September 2026 (M0).
## Bestand (geprüft) ## Bestand (geprüft)
- **Werkzeuge:** Godot 4.7.2 (Fedora-Build; offizielle 4.7.2-Exportvorlagen unter `assets-source/phase6/export-templates`), Blender 5.2.2 LTS, Forward+ mit OpenGL-Kompatibilitätsmodus. Kein Engine-Update. - **Werkzeuge:** Godot 4.7.2 (Fedora-Build; offizielle 4.7.2-Exportvorlagen unter `assets-source/phase6/export-templates`), Blender 5.2.2 LTS, Forward+ mit OpenGL-Kompatibilitätsmodus. Kein Engine-Update.
- **Repository:** Es gab kein Git. Angelegt wurde ein lokales Repository mit dem Baseline-Commit `c2b4476` auf `main` (unveränderter Stand 0.15.0, Phase 20A); die Arbeit läuft auf `luneraria/m1`. Screenshots und die Projekt-Doku außerhalb des Projektordners sind in `../backups/vor-luneraria-2026-09-27-docs-und-screenshots.tar.gz` gesichert. Keine uncommitteten fremden Änderungen; `../.mimocode` gehört zu einem fremden Werkzeug und bleibt unberührt. Ein Stand 20B existiert nicht. Keine CLAUDE.md. - **Repository:** Es gab kein Git. Angelegt wurde ein lokales Repository mit dem Baseline-Commit `c2b4476` auf `main` (unveränderter Stand 0.15.0, Phase 20A); die Arbeit läuft auf `luneraria/m1`. Screenshots und die Projekt-Doku außerhalb des Projektordners sind in `../backups/vor-luneraria-2026-09-27-docs-und-screenshots.tar.gz` gesichert (seit G2: `../_archiv/backups/`). Keine uncommitteten fremden Änderungen; `../.mimocode` gehört zu einem fremden Werkzeug und bleibt unberührt. Ein Stand 20B existiert nicht. Keine CLAUDE.md.
- **Spiel:** eine Szene `scenes/main.tscn`, sieben Autoloads (`AssetLibrary`, `DataCatalog`, `GameState`, `EconomyManager`, `WaveManager`, `Preferences`, `Soundscape`), alle Oberflächen im Code. Kampf in `scripts/combat/`: Verteidiger gegen Angreifer je Lane, die Feindschaft hängt an `is_attacker`, die Lane an `team_id`. - **Spiel:** eine Szene `scenes/main.tscn`, sieben Autoloads (`AssetLibrary`, `DataCatalog`, `GameState`, `EconomyManager`, `WaveManager`, `Preferences`, `Soundscape`), alle Oberflächen im Code. Kampf in `scripts/combat/`: Verteidiger gegen Angreifer je Lane, die Feindschaft hängt an `is_attacker`, die Lane an `team_id`.
- **Baseline-Tests** (`logs/m0-baseline/`): 209 · 33 · 463 · 206 · 1.665 bestanden. - **Baseline-Tests** (`logs/m0-baseline/`): 209 · 33 · 463 · 206 · 1.665 bestanden.
@ -32,3 +32,16 @@ Stand: 27. September 2026 (M0).
- `lune_world.gd` baut Gelände, Wasser, Lanebeläge, Plattform, Deltabrücke, Basen, Grotte, Landmarken, Vegetationscluster und Aufmarschflächen aus Bestandsassets und Blockout-Primitiven. - `lune_world.gd` baut Gelände, Wasser, Lanebeläge, Plattform, Deltabrücke, Basen, Grotte, Landmarken, Vegetationscluster und Aufmarschflächen aus Bestandsassets und Blockout-Primitiven.
- `lune_camera.gd` ist eine perspektivische Kamera, deren `zoom` die sichtbare Bodenhöhe bleibt; die Neigung hängt vom Zoom ab. - `lune_camera.gd` ist eine perspektivische Kamera, deren `zoom` die sichtbare Bodenhöhe bleibt; die Neigung hängt vom Zoom ab.
- Die Grottenpunkte behalten ihre M2-Abstände zum Grottenmittelpunkt. - Die Grottenpunkte behalten ihre M2-Abstände zum Grottenmittelpunkt.
11. **G2-Nachtrag (strategische Bauwerke):**
- **Views je Objekt:** Jedes strategische Objekt hat eine eigene View, die nur den autoritativen Zustand liest:
- `lune_tower_view.gd` (HP → Stufe, Angriffsfortschritt und Trefferzähler → Aufladen und Bolzenstart);
- `lune_ancient_view.gd`;
- `lune_grotto_view.gd` (fünf Zustände aus Runde, Claim, nächster Verfügbarkeit und dem Grottenboss des laufenden Kampfes).
Keine View schreibt in die Simulation.
- **Effekte:** `lune_tower_fx.gd` zeigt Zielring, Bolzen und Einschläge gepoolt; `lune_fx.gd` liefert begrenzte GPU-Partikelbausteine.
- **Welt:** `lune_world.gd` baut und synchronisiert alles in `sync_strategic` und `sync_grotto` und berechnet den Occlusion-Fade: Bildschirmrechteck des Gebäudes und Bodentiefe entlang der Blickrichtung, alle zwei Bilder.
- **Materialien:** `lune_materials.gd` ordnet `LN_*`-Oberflächen gemeinsamen ShaderMaterials zu (`view/materials/*.gdshader`). Alles Gebäude- oder Teamspezifische sind Instanzparameter mit festen Indizes (0–15). Der Fade wird im orthografischen Schattenpass übersprungen.
- **Assets:** Blender-Generatoren unter `assets-source/luneraria/g2/` mit gemeinsamem Baukasten `lune_bpy.py`. Sie schreiben `.blend`, GLB und `assets/luneraria/g2/g2_manifest.json`, das `AssetLibrary` zusätzlich liest (der Boss als skelettierte Figur).
- **Figuren:** `SkeletalFigure` kennt eine Schrittweite pro Modell und `play_event()` für Einmal-Clips (Brüllen, Sieg). `LuneUnitView` zeigt jede Boss-Einheit als Schlundkrone und färbt ihre Adern nach Team.
- **Gelände:** `LuneTerrain` hat eine kleine, symmetrische Senke hinter dem Grottentor (nur Darstellung). Die Grottenfläche und der Ancient-Dais heben Figuren an (`surface_lift`).
- **Plattform:** `lune_main.gd` startet mit `--lune-smoke` einen Plattform-Kurztest. `project.godot` importiert Texturen zusätzlich als ETC2/ASTC (Voraussetzung für den Apple-Silicon-Export) und führt die Luneraria-Version unter `[luneraria]`. Export-Presets: Linux, Windows x64, Windows ARM64, macOS Universal.

View file

@ -0,0 +1,63 @@
# G2 · Assetprüfung und Entscheidungsmatrix
Stand: 29. September 2026 · Branch `luneraria/g2` (von `luneraria/g1`, Commit `8b17563`).
Vor jeder neuen Modellierung wurde der Bestand geprüft: Kernkristalle, Türme, Podeste, Mauern, Basiselemente, Kristall-, Metall- und Steinmaterialien, Runen, Partikeleffekte und die vorhandenen Boss- und Monsterfiguren. Geprüft wurde aus der echten Spielkamera (G1-Vergleichsbilder `screenshots/g1/nachher`, G2-Vorher-Bilder `screenshots/g2/vorher`) und an den Quelldateien.
**Entscheidung:**
- **A** – unverändert wiederverwenden
- **B** – Material, Maßstab oder Licht überarbeiten
- **C** – modular erweitern
- **D** – neues Asset erforderlich
Funktionierende, hochwertige Assets bleiben erhalten; ersetzt wurde nur, was die G2-Anforderungen nicht tragen konnte (Zustände, Zerstörung, Silhouette aus der Spielkamera).
## Strategische Objekte
| Bestand | Pfad / Quelle | Befund in der Spielkamera | Entscheidung | G2-Ergebnis |
| --- | --- | --- | --- | --- |
| Saumkern Team 0/1 (Ancients bis G1) | `assets/models/core_team0.glb`, `…team1.glb` · `assets-source/phase4/blend/core_team*.blend` (36.652 / 36.848 Dreiecke, starre Gelenkgruppen) | Hochwertiger Kristall, aber aus der Spielkamera ein 3–4 m hoher Dekokristall. Kein Aufbau, der einen Ancient über einen Turm stellt; nur Aktivierung und Treffer, keine Zustandsstufen. | **C** | Der Kristallcluster (Core_CrystalMain und fünf Satelliten) ist das Herz des neuen **Mondherz** (`build_ancient.py` hängt ihn aus `core_team0.blend` an, Maßstab 2,3). Darum neu: Sockel, Podest, Klingen, Ringe, Krone, Wappen, Banner, Fragmente, Ruine. Die alten GLBs bleiben für den Legacy-Modus unverändert. |
| Laneturm M1 | `assets/luneraria/tower.glb` · `build_tower.py` (648 Dreiecke, ein Kristallmaterial) | Blockout-Qualität, eine Form für alle 16 Türme (Basistürme nur ×1,18), keine Teile für Schaden oder Zerstörung. Sank bei Zerstörung in den Boden. | **D** | Turmfamilie aus einem Generator: **Grenzwacht** (Tower 1), **Bastion** (Tower 2), **Kernwacht** (Basisturm), jeweils mit Lower/Upper/Upper_Damaged/Debris/Crystal/Ruin. `tower.glb` wird nicht mehr geladen (Datei bleibt als M1-Stand). |
| Bossgrotte M2 | `assets/luneraria/grotto.glb` · `build_grotto.py` (1.888 Dreiecke) | Felsbogen mit Höhlendunkel; in G1 nur noch Bogen und Kristalle über Blockout-Terrasse. Eingang aus der Spielkamera kaum als solcher lesbar. | **D** | **Mondschlund** (`build_bossgrotte.py`): Arena, Bossplattform, Altar mit Mondquelle, monumentales Tor mit Treppe in die Tiefe, Säulen, Mondwasserrinnen, Felsmassen; `grotto.glb` wird nicht mehr geladen. |
| G1-Grotten-Blockout | `lune_world.gd` (Arena-Scheibe, Runen-Torus, 11 Säulen, Klippen-Props, Treppen, Licht) | Platzhalter laut G1. | **D** (Treppen **A**) | Durch den Mondschlund ersetzt. Die zwei Teamtreppen und der Quell-Wasserfall bleiben unverändert. |
| Schlundkrone e10 (Grotten- und entsandter Boss bis G1) | `assets/models/enemy_e10.glb` · `phase4/blend/e10.blend` (64.694 Dreiecke, starr) | Heller Knochenschlund mit Zahnkranz, ×1,7 skaliert. Keine Todes-, Brüll- oder Siegesbewegung (starre Gruppen, „keine Todesanimation“). Wirkt als großer Wellengegner, nicht als uraltes Wesen der Welt. | **D** | Neuer **Schlundkrone**-Titan mit gewichtetem Skelett und 11 Clips (`build_schlundkrone.py`). Name, Schlund und „Krone“ bleiben als Designfaden erhalten. e10 bleibt für den Legacy-Modus. |
| Weitere Bosse e07 Bruchhirsch, e08 Riegelkoloss, e09 Maskenrichter | `phase4/blend/e07–e09.blend` (33.800–81.240 Dreiecke, starr) | Als Basis geprüft. Alle starr ohne Tod, Silhouetten aus der Saumwacht-Wellenlogik, keine lunarianische Identität. | — (verworfen) | Nicht verwendet. |
| Volksrepräsentant Troll „Steinhauer“ (p_troll) | `assets/luneraria/peoples/p_troll.glb` (22 Knochen, 9 Clips) | Als Rig-Basis geprüft: Er wäre nur ein großer Troll und damit eine weitere Einheit. | Konventionen **A**, Figur verworfen | Der Boss übernimmt die Pipeline-Konventionen des Rigs: Knochennamen für die Overlays (Spine, Head, Forearm.L/R), Clipnamen, Kontakt bei 0,90 s, 30 fps. |
| Kartenprops (Banner, Felsen, Klippen, Bordsteine, Brücke) | `assets/luneraria/map_props.glb` · `build_map_props.py` | Stimmig, MultiMesh, low-poly facettiert. | **A** (Banner an Bauwerken **C**) | Unverändert als Kartendekoration. Die alten Teambanner neben den Ancients ersetzt ein Stoffbanner je Bauwerk mit teamspezifischer Silhouette (LN_Cloth). Die facettierte Formensprache der Felsen übernimmt der neue `cliff()`-Baustein. |
| Basisplatz, Ringmauer, Torsäulen, Torkristalle (G1-Blockout) | `lune_world.gd` | Funktional, lesbar; G4-Thema. | **A** + **C** | Unverändert. Neu: Energieadern im Platz vom Ancient zu den Basistürmen und Toren, gestufter Sockel (Dais), Figuren werden auf Platz und Dais gehoben. |
| Ebene Turmsockel (Geländepads) | `LuneTerrain` | Passend. | **A** | Unverändert; jeder Turm bringt seinen eigenen Sockel mit. |
| Mittelplattform, Runensteine, Runenring | `lune_world.gd` | Blockout, Kartenthema (G4). | **A** | Unverändert. |
## Materialien
| Bestand | Befund | Entscheidung | G2-Ergebnis |
| --- | --- | --- | --- |
| Kalkstein-Textursatz Phase 10 (`assets/materials/phase10/limestone-*`) | Hochwertig, bereits für Gelände und Kluft genutzt. | **A** | Triplanar in **LN_Stone** (Mondstein-Quader, Basalt, Grottenstein, Fels). |
| Kluft-Oberflächenshader (`sanctuary.gdshader`, ENV_*) | Für Gelände, Wege und Props richtig. | **A** | Bleibt für Karte und Props; Bauwerke bekommen die neue LN-Familie. |
| Kristallmaterialien: TW_Crystal (M1-Turm, emissives Standardmaterial), `prismatic_core.gdshader` (Saumkern), GB_/FL_Crystal (Einheiten) | Flach bzw. an ein Modell gebunden. Keine Zustandskanäle (Energie, Flackern, Bruch), keine Teamfarbe pro Instanz. | **D** | **LN_Crystal**: dunkler Körper, Adern in drei Tiefenlagen (Parallaxe), Herz, Fresnel, kontrollierte Emission, Instanzparameter energy/flicker/fracture/tint. |
| Metall: `forged_material.gdshader` (Glutpakt-Einheiten) | Einheitenlook (Schmiede), nicht für Bauwerke. | **D** | **LN_Metal**: Teammetall (Silberstahl kühl, Bronze warm), Eisen, alte Bronze mit Patina in Hohlräumen; nie spiegelnd. |
| Runen: emissive Standardmaterialien (G1-Ringe) | Nur leuchtende Flächen, keine Glyphen. | **D** (G1-Ringe **A**) | **LN_Rune**: Glyphen aus der UV-Zelle, drei Runenfamilien (Blau: Mondsicheln, Rot: Sonnenstrahlen, Grotte: Spiralen), Energie, Flackern, Bruch. |
| Stoff | Bannerprop mit einfarbigem Material. | **D** | **LN_Cloth**: Wind im Vertexshader, Teamsilhouette (Schwalbenschwanz / Flammensaum) und Emblem, Zerreißen bei Schaden. |
| — | Mondwasser der Grotte, Kreaturenhaut des Bosses | **D** | **LN_Water_Glow**, **LN_Boss_Hide/Bone/Bronze** (Muster aus Ruhepositionen, damit auf dem gehäuteten Körper nichts schwimmt), **LN_Crystal_Boss**. |
| Holz | Nur Tür, Wimpelstange (Türme). | **A** (Standardmaterial) | LN_Wood als einfaches Standardmaterial; mehr ist an den Bauwerken nicht sinnvoll. |
## Effekte
| Bestand | Befund | Entscheidung | G2-Ergebnis |
| --- | --- | --- | --- |
| Partikel: Sanktuariums-Glimmer (`sanctuary.gd`), Menübühne (`menu_stage.gd`) | Menü/Legacy, großflächige Umgebungseffekte. | **A** (dort) | Unverändert. |
| Turmangriff bis G1 | Unsichtbares Schadensereignis, nur Lichtpuls. | **D** | Zielerfassungsring, Aufladen des Kristalls, Kristallbolzen mit Schweif, Einschlag mit Splittern; alles gepoolt (`lune_tower_fx.gd`). |
| Schaden/Zerstörung | Turm sank bei G1 ab und verschwand. | **D** | Gebündelte Effektbausteine (`lune_fx.gd`): Funken, Rauch, Staubstoß, Splitterstoß, Schwebeteilchen, Energieleck, Blitz; jeweils mit fester Obergrenze, keine physikalischen Trümmer pro Frame. |
## Neue Assets (G2)
| Asset | Generator | .blend | GLB | Dreiecke | Knoten / Rig |
| --- | --- | --- | --- | ---: | --- |
| Mondherz (Ancient) | `assets-source/luneraria/g2/build_ancient.py` | `ancient.blend` | `assets/luneraria/g2/ancient.glb` | 19.032 | 27 Teile (Kristall, Satelliten, Klingen, Ringe, Krone, Wappen A/B, Fragmente, Ruine) |
| Grenzwacht (Tower 1) | `…/g2/build_towers.py` | `grenzwacht.blend` | `…/g2/grenzwacht.glb` | 6.516 | Lower, Upper, Upper_Damaged, Debris, Crystal, Pennant, Ruin |
| Bastion (Tower 2) | `…/g2/build_towers.py` | `bastion.blend` | `…/g2/bastion.glb` | 9.364 | + Ring, Shard_0–2 |
| Kernwacht (Basisturm) | `…/g2/build_towers.py` | `kernwacht.blend` | `…/g2/kernwacht.glb` | 9.224 | + Banner_L/R, Ring |
| Mondschlund (Bossgrotte) | `…/g2/build_bossgrotte.py` | `bossgrotte.blend` | `…/g2/bossgrotte.glb` | 15.182 | 12 Teile (Boden, Plattform, Altar, Quelle, Tor, Höhle, Leuchten, Rinnen, Säulen, Säulenkristalle, Runen, Felsen) |
| Schlundkrone (Superboss) | `…/g2/build_schlundkrone.py` | `schlundkrone.blend` | `…/g2/schlundkrone.glb` | 5.712 | 24 Knochen, 11 Clips |
Gemeinsame Bausteine: `assets-source/luneraria/g2/lune_bpy.py` (Drehkörper, Quader mit Fase, Sweeps, Kristalle, facettierte Felsen, Ringe, Runenbänder, Banner, Ambient Occlusion in die Vertexfarbe). Laufzeitmaterialien: `scripts/luneraria/view/lune_materials.gd` und `scripts/luneraria/view/materials/*.gdshader`. Manifest mit Hash, Quelle, Generator, Knoten, Materialien und Maßen: `assets/luneraria/g2/g2_manifest.json`.

View file

@ -0,0 +1,366 @@
# G2 · Ancients, Türme und Bossgrotte
Stand: 29. September 2026 · Branch `luneraria/g2` (von `luneraria/g1`, Commit `8b17563`) · Demo 0.17.0-g2.
Assetprüfung und Entscheidungsmatrix: [G2-ASSETS.md](G2-ASSETS.md).
![Ancient-Zustände 100 % · 50 % · unter 30 % · zerstört](screenshots/g2/uebersicht-ancient-zustaende.png)
## Kurzfassung
- **Neue Bauwerke:** Die strategischen Objekte sind neu gebaut, jeweils mit reproduzierbarem Blender-Generator:
- zwei **Mondherz**-Ancients;
- eine dreistufige Turmfamilie: **Grenzwacht** (Tower 1), **Bastion** (Tower 2), **Kernwacht** (Basisturm);
- die Bossgrotte **Mondschlund**;
- der Superboss **Schlundkrone** mit Skelett und 11 Animationen.
- **Klare Zustände:** Alle Bauwerke haben Zustandsstufen, eine kurze Zerstörung und einen bleibenden Ruinenzustand. Die Grotte hat fünf Zustände.
- **Sichtbarer Turmangriff:** Zielerfassung, Aufladen, Bolzen, Einschlag. Der Schaden bleibt vollständig in der Simulation.
- **Gemeinsame Materialfamilie** `LN_*` für Stein, Metall, Kristall, Rune, Stoff, Mondwasser und Kreaturenhaut (auch für G4/G5).
- **Occlusion-Fade:** Große Gebäude werden oberhalb der Kopfhöhe dithernd durchsichtig, wenn Figuren oder der Kamerafokus dahinter liegen.
- **Regeln unverändert:** Die 40 Balancepartien ergeben exakt dieselben Werte wie in G1. Dieselbe Partie läuft mit und ohne Darstellung identisch.
- **Neue Plattformen:** macOS-Paket (Universal, ad-hoc signiert) und Windows ARM64; beide Windows-Pakete liefen in Windows 11 (Parallels, ARM).
## Ausgangszustand
- G1 (`8b17563`): spielerisches Drei-Lane-Layout, Blockout-Grotte, Saumkern-Ancients aus Saumwacht, der Laneturm aus M1 (648 Dreiecke) für alle 16 Türme. Bis auf die Ancients (Aktivierung, Treffer) gab es keine Zustände. Türme sanken bei Zerstörung in den Boden und verschwanden.
- G2 begann am 29. September unter Fedora 44 und wurde am selben Tag auf einem Mac (Apple M5, macOS 27) abgeschlossen. Unter Fedora entstanden bereits Turmfamilie, Mondherz, Materialshader und Turmeffekte. Auf dem Mac kamen hinzu: Bossgrotte, Superboss, Occlusion, Tests, Aufnahmen, Messungen, Plattformen und Doku.
- Die Vorher-Aufnahmen (`screenshots/g2/vorher`) stammen aus dem G1-Stand. Sie wurden auf demselben Mac mit demselben Aufnahmewerkzeug gemacht wie die Nachher-Aufnahmen.
## Wiederverwendete und neue Assets
Wiederverwendet sind:
- der Kristallcluster des Saumkerns als Herz des Ancients;
- Kalkstein-Textursatz, Kluft-Shader und Kartenprops;
- die G1-Basisbauteile (Plaza, Mauern, Tore) und die Grottentreppen.
Neu sind sechs GLBs: [Tabelle in G2-ASSETS.md](G2-ASSETS.md#neue-assets-g2).
| Asset | Dreiecke | Höhe (Spiel) | Besonderheit |
| --- | ---: | ---: | --- |
| Mondherz (Ancient) | 19.032 | 10,4 m | 27 bewegliche Teile, Wappen A/B, Ruinenteile |
| Grenzwacht (Tower 1) | 6.516 | 6,1 m | Upper/Lower getrennt, Schadensvariante, Ruine |
| Bastion (Tower 2) | 9.364 | 7,2 m | Ring und drei Satellitensplitter |
| Kernwacht (Basisturm) | 9.224 | 8,3 m | Klingenbogen in der Formensprache des Ancients |
| Mondschlund (Grotte) | 15.182 | 7,7 m | 12 Teile, prüft beim Bau den Lane-Abstand |
| Schlundkrone (Boss) | 5.712 | 5,6 m (×1,7) | 24 Knochen, 11 Clips, Adern in Teamfarbe |
Pipeline:
- Jeder Generator liegt unter `assets-source/luneraria/g2/` und schreibt `.blend`, GLB und Manifesteintrag: Hash, Quelle, Generator, Knoten, Materialien, Maße, Pivot am Boden. Ausgaben: `assets/luneraria/g2/`.
- **Reproduzierbarkeit:** Unter macOS (Blender 5.2.2) entstehen dieselben Dreiecke und Maße wie zuvor unter Fedora. Die Dateien sind aber nicht bytegleich (Gleitkomma in der Occlusion-Berechnung); `verify_g2` prüft, dass jedes GLB seinem Manifest-Hash entspricht.
- Der Import in Godot ist geprüft: Knoten, Clips, Skelett und Materialzuordnung (`verify_g2`, Teil A).
## Ancient: Mondherz
![Kernwacht-Basistürme vor dem Ancient aus Angreifersicht](screenshots/g2/nachher/07-basistuerme.png)
**Designsprache:** Ein lunarianisches Energieheiligtum statt eines Turms.
- Der Saumkern-Kristall (×2,3) schwebt als Herz über einem dreistufigen Dais mit Runenbändern.
- Vier Mondsichelklingen bilden eine offene Lanzette.
- Darüber: Krone und Teamwappen – Blau eine Mondsichel, Rot eine Sonnenscheibe.
- Zwei Armillarringe drehen sich gegenläufig, fünf Satelliten kreisen, sechs Fragmente schweben.
- Zwei Banner mit Teamsilhouette (Blau Schwalbenschwanz, Rot Flammensaum).
- Energieadern laufen im Platz zu den beiden Basistürmen und zu den drei Lanetoren. Stirbt ein Basisturm, erlischt seine Ader.
- Die Basistürme (Kernwacht) nehmen die Klingensprache auf, sodass Ancient und Basistürme als ein Heiligtum lesbar sind.
**Teamunterschied** bei identischer Geometrie, Hitbox und Position:
- Kristall- und Runenfarbe: kühl bzw. warm;
- Metall: Silberstahl bzw. Bronze;
- Runenfamilie: Mondglyphen bzw. Sonnenstrahlen;
- Bannersilhouette und Wappen.
**Zustände** aus der autoritativen HP (`lune_ancient_view.gd`):
| HP | Zustand | Darstellung |
| --- | --- | --- |
| 100–61 % | stabil | ruhige Rotation, gleichmäßiges Licht, wenige Schwebeteilchen |
| 60–31 % | instabil | leuchtende Risse im Stein, Energie flackert, erste Funken, ein Satellit sackt ab und zittert, Ringe eiern |
| 30–1 % | kritisch | starke Risse, Herz gebrochen und unregelmäßig pulsierend, Energieleck (Funkenstöße), Rauch, zwei Klingen lose (eine weit ausgekippt), innerer Ring aus dem Lager gerutscht (schief, ruckelt), zwei Fragmente liegen auf dem Dais, Banner zerrissen |
| 0 % | Zusammenbruch (2,8 s) → Ruine | Herz überlädt, implodiert, Lichtblitz und Splitterstoß, Klingen zerfallen im Staub, Ringe und Krone stürzen auf den Sockel, Fragmente fallen; danach Trümmer und gebrochener Kristall, Rauch für 25 s |
**Der Sieg wartet nicht auf die Grafik.** Die Regel entscheidet im selben Simulationsschritt, in dem der Ancient 0 HP erreicht; die Partie ist ab dann vorbei. Der Zusammenbruch beginnt erst danach. `verify_g2` prüft beides: kein Kollaps vor der Entscheidung, Partie bleibt beendet, während die Animation läuft.
## Türme
![Tower 1 · Tower 2 · Basistürme · Ruine](screenshots/g2/uebersicht-tuerme.png)
| | Grenzwacht (Tower 1) | Bastion (Tower 2) | Kernwacht (Basisturm) |
| --- | --- | --- | --- |
| Silhouette | schlanker Achteckturm, offene Galerie | breite geböschte Bastion mit Strebepfeilern | kein Rundschaft: Runentrommel, zwei Sichelklingen als Lanzettbogen |
| Schutz | wenig Metall | Schildplatten zur Lane, Pechnasen, mehr Stein und Metall | schwerer Sockel, Bronzebänder |
| Energie | mittlerer Kristall zwischen zwei Hörnern | großer Kristall, drei Hörner, Armillarring, drei Splitter | Kristall im Klingenbogen, Ring, zwei Banner |
| Höhe | 6,1 m | 7,2 m | 8,3 m |
Alle drei teilen:
- achteckigen Grundriss, Mondstein-Quader auf Basaltsockel;
- Teammetall, Runenfriese und die Kristallwiege.
Die Kernwacht ist kein skalierter Tower 2, sondern gehört zur Architektur des Ancients.
**Angriff** (`lune_tower_view.gd`, `lune_tower_fx.gd`):
1. **Zielerfassung:** Ein Ring unter dem Ziel der Regel zieht sich beim Aufladen zusammen.
2. **Aufladen:** Der Kristall dreht schneller und wird heller.
3. **Schuss:** Der Bolzen verlässt die Kristallspitze, sobald der Angriffszyklus der Regel zu 74 % voll ist. Er fliegt in Simulationszeit (Pause und Tempo wirken) und landet auf dem Treffer der Regel.
4. **Einschlag:** Blitz, Splitter, kurzes Zucken des getroffenen Gebäudes.
Die Bolzen sind gepoolt (maximal 24), haben die Teamfarbe und sind klein genug für große Kämpfe. Die Effekte lesen nur Ziel, Fortschritt und Trefferzähler der Regel; sie können keinen Schaden, kein Gold und kein Ziel erzeugen. Gemessen: 13 Treffer, 14 Bolzen in 12 s (ein Bolzen war vorausgeflogen).
**Schaden und Zerstörung:**
| Stufe | Darstellung |
| --- | --- |
| leicht (unter 60,5 %) | Risse, Ruß, dünner Rauch, Wimpel zerrissen |
| schwer (unter 30,5 %) | gebrochener Oberbau (fehlende Zinnen, verbogenes Horn, lose Platte), Schutt am Fuß, Funken, flackernder Kristall |
| zerstört (1,7 s) | Kristall überlädt und platzt (Splitterstoß), Oberbau kippt und sinkt, Staubstoß |
| Ruine | Stumpf, Schutt, gebrochener Kristall, Rauch für 18 s, kein Licht |
Trümmer sind vorgefertigte Teile, keine Physik pro Frame. Ruinen blockieren nichts; die Simulation kennt keine Gebäudekollision, und `verify_g2` prüft, dass Armeen an einer Ruine vorbeimarschieren.
## Bossarena: Mondschlund
![Grotte: verfügbar · Kampf · entsandt · Cooldown · bald verfügbar](screenshots/g2/uebersicht-grotte-zustaende.png)
**Aufbau** (Ort, Terrasse, Bosspunkt und Eingänge wie in G1):
- **Arena:** Grottenstein mit großem Runenkreis, zweistufige Bossplattform, dahinter ein Sichelaltar mit der Mondquelle.
- **Tor:** Das monumentale Tor steht in der Achse – zwei gehörnte Wächterpylonen, tiefer Bogen mit Bronze-Sichel, Treppe hinab ins Dunkel, leuchtender Nebel und ein Mondbrunnen.
- **Umgebung:** Alte Säulen stehen seitlich (intakt, gebrochen, umgestürzt), Mondwasser in zwei Rinnen, facettierte Felsmassen.
- **Sichtbarkeit:** Der Eingang ist schon aus der Lane-Kamera von Top und Mid lesbar.
**Geprüfter Befund aus G1:** Die Top-Lane läuft auf drei Seiten um die Grotte. Die erste Felsplanung ragte bis zu 2,8 m in ihren Korridor. Seitdem liest der Generator die echten Lane-Linien (`grotto_site.json` aus `tools/luneraria/dump_grotto_site.gd`) und bricht ab, wenn ein Fels oder das Tor näher als 0,6 m an einen Korridor reicht.
**Kleine Geländekorrektur (dokumentiert):** Die Höhle hinter dem Tor liegt 1,05 m unter der Terrasse.
- Umsetzung: `LuneTerrain._cave_notch`, 3 × 3,6 m, symmetrisch zur Achse.
- Grund: Ohne die Senke schnitt der ansteigende Hang die Höhle und ihre Treppe (Gebäudemaße).
- Wirkung auf das Spiel: keine. Die Senke ist reine Darstellung, wird von Felsen und Höhlenwänden verdeckt, und keine Figur steht dort.
- Prüfung: `verify_g1` (Spiegelung, Terrasse, Quelle) besteht unverändert.
**Zustände** (`lune_grotto_view.gd`, nur aus öffentlichem Zustand, Cooldown-Regeln unverändert):
| Zustand | Wann | Darstellung |
| --- | --- | --- |
| verfügbar | Runde ≥ nächste Verfügbarkeit | Boss ruht auf dem Dais (Adern halb aktiv), Runen, Mondquelle, Höhlenlicht und Mondwasser an, Schwebeteilchen |
| Kampf | Expedition in der Grotte | stärkeres, pulsierendes Licht, Funken; der ruhende Boss wird zur echten Einheit, die beim Erwachen brüllt |
| nicht verfügbar | Boss ist für ein Team entsandt | gedämpft, Runen schwach in der Farbe des erobernden Teams, kein Boss |
| Cooldown | Boss gefallen / Sperrrunden | erschöpft: Quelle dunkel und gebrochen, Runen fast aus, Mondwasser leer; beim Fall des Bosses ein einmaliger Energiekollaps (Blitz, Splitter, Staub) |
| bald verfügbar | letzte Sperrrunde | erste Energie: Runen flackern auf, Quelle glimmt, wenige Teilchen |
Das Licht der Grotte ist bewusst zurückgenommen: zwei Punktlichter ohne Schatten (Höhle, Quelle), damit Arena und Boss nicht violett überstrahlen.
## Superboss: Schlundkrone
![Schlundkrone: Ruhe, Gehen, Doppelschlag (Ausholen, Aufschlag), Rückhand, Treffer, Mondschrei, Brüllen, Sieg, Tod](screenshots/g2/schlundkrone-clips.png)
*Clipbogen aus `tools/luneraria/g2_boss_sheet.tscn`: Spielkamera, Mittelplattform. Größenvergleich mit Truppe, Held und Angriffschampion im selben Bild: [schlundkrone-massstab-mitte.png](screenshots/g2/schlundkrone-massstab-mitte.png).*
**Prüfung des Bestands:** Kein Monster taugte als Basis (siehe [G2-ASSETS.md](G2-ASSETS.md)).
- Das bisherige e10 „Schlundkrone“ ist ein starrer Wellenboss ohne Tod.
- e07–e09 sind starr.
- Der Troll-Repräsentant wäre nur eine große Einheit.
Neu gebaut, der Name blieb.
**Design:** ein uralter, kristallgebundener Titan Lunerarias, der von den Lunariern in den Mondschlund gekettet wurde. Die Silhouette trägt das Bild, nicht das Leuchten:
- **Haltung:** gebeugt, auf den Knöcheln gehend, mit riesigen Schultermassen, sehr langen Unterarmen und kurzen Säulenbeinen. Keine andere Figur steht so.
- **„Krone“:** ein gebrochener Halbmond-Nimbus aus Kristallsplittern, der hinter dem tief sitzenden Kopf schwebt; ein Splitter fehlt.
- **„Schlund“:** Die Brust öffnet sich wie ein Maul (zwei Platten mit Mondsteinzähnen) um einen leuchtenden Herzkristall.
- **Details:** Sichelhörner; ein Kristallcluster wächst nur aus der linken Schulter (asymmetrisch); gebrochene Bronzefesseln an beiden Handgelenken.
- **Teamfarbe:** Feine Mondadern im Basalt glühen in der Farbe des Teams, für das der Boss kämpft (violett in der Grotte, blau oder orange als Verstärkung). So funktioniert dasselbe Modell neutral und als Lane-Verstärkung.
- **Abgrenzung:** keine Roshan-/Baron-Kopie – kein gehörnter Pelzbrute, kein Wurm; Stein, Kristall und Mondmotive.
**Rig und Animation:**
- 24 Knochen, starre Steinteile mit Gewicht 1,0; die Overlay-Knochen von `SkeletalFigure` sind vorhanden.
- Clips (30 fps): `idle`, `start`, `walk`, `stop`, `attack_a` (beidhändiger Schmetterschlag), `attack_b` (Rückhand), `hit`, `ability` („Mondschrei“: Schlund öffnet sich, Krone steigt), `death` (Knie geben nach, Sturz nach vorn, die Krone bricht ab und fällt), `roar` (beim Erwachen in der Grotte), `victory` (wenn die Expeditionen scheitern).
- Treffer landen bei 0,90 s wie bei allen Figuren; die Regel entscheidet jeden Treffer, die Clips folgen nur ihren Ereignissen.
- Neu in `SkeletalFigure`: `stride` pro Modell (Boss 2,0 m pro Schrittzyklus statt 0,52 m) und `play_event()` für Brüllen und Sieg.
- Alle Muster der Haut stammen aus Ruhepositionen (UV/UV2), damit auf dem gehäuteten Körper nichts schwimmt.
**Als Angriffseinheit geprüft** (`verify_g2` Teil F):
- Auf allen drei Lanes erscheint der entsandte Boss für das richtige Team, rückt vor, wird von Türmen beschossen und steht auf Boden, Brücke und Plattform.
- Maßstab ×1,7, Fußabdruck kompakt (ca. 1,4 m Radius), damit Angreifer im Nahkampfabstand der Regel nicht im Körper stehen.
- In großen Kämpfen bleibt er an Krone, Silhouette und Namensschild erkennbar ([Boss als Lane-Verstärkung](screenshots/g2/nachher/12-boss-lane-verstaerkung.png)).
- Grotten- und Lane-Boss nutzen ein gemeinsames Modell; es gibt keine zweite Version.
## Maßstab
![Maßstab: Truppe, Held, Angriffschampion, Boss vor Ancient und Basisturm](screenshots/g2/nachher/16-massstab.png)
| Objekt | Höhe im Spiel |
| --- | ---: |
| Truppe (Dornhopser) | 1,35 m |
| Angriffschampion (Keilbrecher) | 1,82 m |
| Held (Ringborke ×1,25) | 2,32 m |
| Schlundkrone (×1,7, mit Krone) | 5,64 m |
| Grenzwacht | 6,06 m |
| Bastion | 7,16 m |
| Kernwacht | 8,33 m |
| Mondherz | 10,37 m |
Die Hierarchie Einheiten < Boss < Tower 1 < Tower 2 < Basisturm < Ancient prüft `verify_g2`.
## Materialsystem
`scripts/luneraria/view/lune_materials.gd` ersetzt jede `LN_*`-Oberfläche eines GLB durch ein gemeinsames Material. Alles, was je Gebäude oder Team abweicht, sind Instanzparameter mit festen Plätzen: Teamfarbe, Energie, Flackern, Bruch, Schaden, Ruß, Runenfamilie, Bannerform, Fade. So teilen sich alle Gebäude wenige Materialien.
| Familie | Shader | Eigenschaften |
| --- | --- | --- |
| **STONE** | `lune_stone.gdshader` | Kalkstein triplanar, prozedurale Quader mit Farbdrift je Block, Fugen und Relief, Flechten auf Oberseiten, Occlusion aus Vertexfarbe, Risse (Voronoi) mit optionalem Energieleuchten, Ruß; Varianten Mondstein, Basalt, Grottenstein, Fels |
| **METAL** | `lune_metal.gdshader` | Teammetall (Silberstahl / Bronze), Eisen, alte Bronze mit Patina in Hohlräumen; gehämmert, Kanten poliert, nie spiegelnd |
| **CRYSTAL** | `lune_crystal.gdshader` | dunkler Körper, Adern in drei Tiefen (Parallaxe), Herz, Fresnel, facettierte Helligkeit, kontrollierte Emission, Zustandskanäle |
| **RUNE** | `lune_rune.gdshader` | Glyphen je UV-Zelle, drei Familien (Mond, Sonne, Grotte), Energie, Flackern, Bruch |
| **CLOTH** | `lune_cloth.gdshader` | Wind, Teamsilhouette, Emblem, Zerreißen |
| **Mondwasser** | `lune_water_glow.gdshader` | dunkles Wasser, nur Wellenkämme leuchten |
| **Kreaturenhaut** | `lune_hide.gdshader` | Basalt/Mondstein/Bronze aus Ruhepositionen, Mondadern in Teamfarbe, Trefferblitz |
| WOOD | Standardmaterial | nur Tür und Wimpelstange |
Der Fade wird im Schattenpass übersprungen, damit Schatten nie dithern.
## Licht
- **Anzahl:** 16 Turmlichter (7–9 m Reichweite), 2 Ancient-Lichter (13 m) und 2 Grottenlichter, keines mit Schatten. Schatten wirft weiterhin nur die Sonne.
- **Zweck:** Das Licht zeigt Teamzugehörigkeit (Farbe), Zustand (Energie, Flackern, aus in der Ruine) und Orientierung (Grotteneingang).
- **Gegen die Lampenansammlung:** Leuchtende Kristalle, dünne Ringe, Splitter und Stoffe werfen keinen Schatten (weniger Schattenpass-Draws), und die Grottenlichter sind gedimmt.
## Kamera und Occlusion
- **Befund:** Aus der Standardkamera verdeckt der Ancient den nördlichen Basisturm und alles dahinter; das Grottentor verdeckt einen Abschnitt der Top-Lane.
- **Fade:** Große Gebäude werden oberhalb von Kopfhöhe (Ancient ab 2,4 m, Türme ab 2,0 m, Grotte ab 2,2 m über dem Boden) per Bayer-Dithering bis 55 % ausgedünnt. Auslöser ist eine lebende Figur oder der Kamerafokus hinter ihnen, auf dem Bildschirm und auf der Bodenebene gemessen.
- **Nie unsichtbar:** Die Silhouette bleibt stehen, Sockel und Schatten bleiben voll.
- **Schlanke Laneturme** geben nur für Helden, Champions und den Boss nach, damit sie im Turmkampf nicht flackern. Bei der Grotte blenden nur Tor, Höhle und Felsen aus, nicht Säulen und Altar.
- **Kamerawinkel:** Die Abnahmebilder zeigen Basistürme und den Kampf am Ancient aus der Angreiferrichtung (Q/E-Drehung), so wie ein Spieler die Kamera stellen würde.
## Keine Spielregeländerungen
Unverändert sind:
- Goldökonomie, Helden, Items, Wellen, Turmwerte, Sperrregeln und Ancient-Sieg;
- Bossregeln und Boss-Cooldown, Lanezuweisung, KI und Netzwerkprotokoll.
Neue Dateien und Änderungen gibt es nur in Darstellung, Werkzeugen, `SkeletalFigure` und `AssetLibrary` (Manifest, Schritt, Ereignisclip), in `LuneTerrain` (Höhlensenke, nur Darstellung) und `project.godot` (ETC2/ASTC-Import für Apple Silicon, eigener Versionseintrag). Belege:
- Alle Regeltests bestehen mit unveränderten Erwartungswerten.
- Die 40 Balancepartien liefern Wert für Wert dieselbe Zusammenfassung wie G1.
- Eine ganze Bot-Partie endet mit Grafik exakt wie ohne.
## Performance
Gleiche Szene wie G1: Runde 13, sechs volle Armeen, Tempo 2×, gleiche Kamerapositionen (`tools/luneraria/lune_benchmark.tscn`). Jeder Stand wurde zweimal gemessen, auf demselben Mac mit denselben Einstellungen (Apple M5, macOS 27, Godot 4.7.2 Forward+ über Metal, 1920 × 1080, ETC2/ASTC). G1 lief aus einem Arbeitsbaum auf `8b17563` mit demselben Messskript.
| Messung | G1 | G2 |
| --- | ---: | ---: |
| Lane-Ansicht: Bildzeit Mittel / p95 | 9,51 / 10,98 ms | 10,02 / 11,54 ms |
| Lane-Ansicht: CPU (Hauptthread) | 9,38 ms | 9,87 ms |
| Lane-Ansicht: Draw Calls / Objekte | 3.278 / 7.340 | 3.509 / 7.597 |
| Übersicht: Bildzeit Mittel / p95 | 9,48 / 10,29 ms | 9,95 / 10,74 ms |
| Übersicht: Draw Calls | 3.588 | 3.767 |
| Basis- und Grottenansicht | 8,33 ms (120-Hz-Grenze) | 8,33 ms (120-Hz-Grenze) |
| GPU-Last Basis / Grotte (ioreg) | 72 / 87 % | 76 / 90 % |
| Aktive Lichter / davon mit Schatten | 23 / 1 | 24 / 1 |
| Schattenwerfer (Knoten / Instanzen), Lane | 539 / 946 | 577 / 974 |
| Partikelsysteme aktiv / max. Teilchen | 0 / 0 | 2 / 44 |
| G2-Darstellungslogik je Bild | – | 0,25 ms |
| Szenenaufbau | 395 ms | 207 ms |
| Simulationsschritt | 1,8 ms | 1,9 ms |
| VRAM / Texturen | 819 / 411 MB | 825 / 399 MB |
| RAM statisch / Prozess (RSS) | 98 / 708 MB | 111 / 787 MB |
**Einordnung:**
- G2 kostet in den vollen Kampfansichten 0,5 ms (+5 %), sonst nichts Messbares.
- **Ursache** laut Kostenzerlegung (`tools/luneraria/g2_cost_split.tscn`, `logs/g2/cost-split.json`, pausierte Lane-Szene): Ohne Türme ist das Bild 0,54 ms schneller, mit schlichtem Material nur 0,07 ms. Es sind also die mehrteiligen Turmmodelle (mehr Draw Calls), nicht die Shader.
- **Größere Posten:** Figuren (2,4 ms) und Sonnenschatten (0,8 ms).
- **Maßnahme:** Nach der Analyse werfen Kristalle, Ringe, Splitter und Stoffe keinen Schatten mehr.
- **Keine pauschale Reduktion:** Qualität wurde nicht pauschal gesenkt. Mögliche spätere Schritte (G5/G6): Bauteile je Material zusammenfassen, Sichtbarkeitsbereiche für Kleinteile.
**Einschränkung der Messung auf dem Mac:**
- Godot 4.7.2 liefert unter Metal keine GPU-Zeitstempel (immer 0). Ersatzweise misst ein Thread die Auslastung der Grafikeinheit (`ioreg`, systemweit). Die Werte schwanken zwischen Läufen um bis zu ±15 Prozentpunkte.
- macOS begrenzt Fensterbilder auf die 120 Hz des Displays, auch mit VSync aus. Unter 8,33 ms ist die Bildzeit daher nicht messbar.
- Die G1-Referenz vom Fedora-PC (RTX 5060 Ti, `logs/g2/perf-g1-run*.json`) liegt bei 7,5 ms in der Lane-Ansicht bei 3,3 ms GPU. Die Zahlen beider Rechner sind nicht direkt vergleichbar.
## Tests
`sh tools/luneraria/run_g2_tests.sh` (Logs: `logs/g2/test-*.log`).
| Prüfung | Ergebnis |
| --- | --- |
| A · Regeln M1 `test_lune_core` (inkl. Spiegeltest) | 91/91 |
| A · Regeln M2 `test_lune_m2` | 73/73 |
| A · Stress M3 (Wellen bis 10⁶, Langlauf) | 17/17 |
| A · Balance M3-Methode, 40 Partien | Seitentausch 20/20, 9–13 Wellen, **identisch mit G1** |
| B · G1-Karte `verify_g1` | 443/443 |
| C–M · **G2-Abnahme `verify_g2`** | **245/245** |
| echte Anwendung `verify_m1` (1080p) | 38/38 |
| echte Anwendung `verify_m2` | 14/14 (Partie endete vor einer Bossentsendung, bekannte Variante) |
| Netzwerk (nur Regression) `test_lune_net` / Loopback `net_rounds.py` | 43/43 · 12/12 |
| Legacy Saumwacht | 209 · 463 |
`verify_g2` (echtes Spiel im Fenster, 78 s):
- **A · Assets (113):** Manifest, Hash je GLB, `.blend` und Generator vorhanden, Dreiecksbudget, Pivot am Boden, Laufzeitmaterial je `LN_*`-Material, Boss skelettiert mit allen 11 Clips, Kontaktzeit, Schrittweite, Overlay-Knochen.
- **B · Ansichten (28):** 6/6/4 Türme, 2 Ancients, Grotte mit ruhendem Boss, jede Ansicht exakt an ihrer Regelposition, Gebäuderadius der Regel unverändert, Teamunterschiede (Runenfamilie, Bannerform, Metall), höchstens 22 Lichter ohne Schatten, begrenzte Partikelsysteme.
- **C · Zustände (32):** Turm- und Ancient-Stufen an allen Grenzen (61/50/31/20/1 %), Schadensvariante, Energieleck, Zerstörung, Ruine bleibt stehen und hat kein Licht, alle fünf Grottenzustände samt Boss-Sichtbarkeit und Claim-Farbe.
- **D · Turmzielwahl und Angriff (6):** Türme zielen nur auf feindliche Figuren in Reichweite, der Zielring liegt unter dem Ziel der Regel, Bolzen fliegen zu Figuren, etwa ein Bolzen pro Treffer, kein Bolzen landet doppelt.
- **E · Regeln durch die Darstellung (8):** Keine gesperrte Stufe nimmt Schaden (Outer → Inner → Base); der Ancient nimmt keinen Schaden, solange ein Basisturm steht; Armeen marschieren an Ruinen vorbei; der Fall eines Ancients beendet die Partie sofort, der Kollaps folgt danach.
- **F · Boss (34):** Grotte erreichbar (Eingänge auf dem Boden, Weg innerhalb des Säulenrings, Boss auf dem Dais), Expedition weckt den Boss, er brüllt, fällt, spielt seinen Tod; die Grotte erschöpft, Blau erhält den Claim; Entsendung auf Top, Mid und Bottom mit Teamfarbe, Vorrücken, Turmbeschuss und Bodenkontakt.
- **G · Keine Doppelereignisse (3):** Vier Runden mit Seed 11, einmal nur Regeln, einmal mit voller Darstellung: jede Runde identisch (Gold, Gebäude, Boss).
- **H · Botpartien (7):** vier Seeds enden regulär nach 9–12 Wellen; eine ganze Partie auf dem Bildschirm endet exakt wie ohne, und jeder zerstörte Turm wird als zerstört gezeigt.
- **I · Occlusion und Maßstab (14):** Der Ancient dünnt aus, wenn der Fokus hinter ihm liegt (nie unsichtbar, Fade höchstens 0,55), und ist danach wieder voll; das Grottentor gibt der Top-Lane nach; Höhenordnung wie in der Tabelle „Maßstab“.
## Visuelle Prüfung
Echte Aufnahmen aus dem laufenden Spiel, 1920 × 1080, mit HUD. Nachher (G2) und vorher (G1) mit demselben Werkzeug `tools/luneraria/g2_views.tscn`, derselben Partie und denselben Kameras.
| Nr. | Ansicht | Nachher (G2) | Vorher (G1) |
| --- | --- | --- | --- |
| 1 | kompletter Ancient bei 100 % | [nachher](screenshots/g2/nachher/01-ancient-100.png) | [vorher](screenshots/g2/vorher/01-ancient-100.png) |
| 2 | Ancient bei ca. 50 % | [nachher](screenshots/g2/nachher/02-ancient-50.png) | [vorher](screenshots/g2/vorher/02-ancient-50.png) |
| 3 | Ancient unter 30 % | [nachher](screenshots/g2/nachher/03-ancient-unter-30.png) | [vorher](screenshots/g2/vorher/03-ancient-unter-30.png) |
| 4 | zerstörter Ancient (und Moment des Zusammenbruchs) | [nachher](screenshots/g2/nachher/04-ancient-zerstoert.png) · [Kollaps](screenshots/g2/nachher/04a-ancient-zerstoerung.png) | [vorher](screenshots/g2/vorher/04-ancient-zerstoert.png) |
| 5 | Tower 1 (Grenzwacht) | [nachher](screenshots/g2/nachher/05-tower1-grenzwacht.png) | [vorher](screenshots/g2/vorher/05-tower1-grenzwacht.png) |
| 6 | Tower 2 (Bastion) | [nachher](screenshots/g2/nachher/06-tower2-bastion.png) | [vorher](screenshots/g2/vorher/06-tower2-bastion.png) |
| 7 | Basistürme | [nachher](screenshots/g2/nachher/07-basistuerme.png) | [vorher](screenshots/g2/vorher/07-basistuerme.png) |
| 8 | zerstörter Turm (und Einsturz) | [nachher](screenshots/g2/nachher/08-turm-zerstoert.png) · [Einsturz](screenshots/g2/nachher/08a-turm-zerstoerung.png) | [vorher](screenshots/g2/vorher/08-turm-zerstoert.png) |
| 9 | Bossgrotte neutral (Boss entsandt) | [nachher](screenshots/g2/nachher/09-grotte-neutral-entsandt.png) | [vorher](screenshots/g2/vorher/09-grotte-neutral-entsandt.png) |
| 10 | Boss verfügbar | [nachher](screenshots/g2/nachher/10-boss-verfuegbar.png) | [vorher](screenshots/g2/vorher/10-boss-verfuegbar.png) |
| 11 | Bosskampf | [nachher](screenshots/g2/nachher/11-bosskampf.png) | [vorher](screenshots/g2/vorher/11-bosskampf.png) |
| 12 | Boss als Lane-Verstärkung | [nachher](screenshots/g2/nachher/12-boss-lane-verstaerkung.png) | [vorher](screenshots/g2/vorher/12-boss-lane-verstaerkung.png) |
| 13 | Kampf vor einem Tower | [nachher](screenshots/g2/nachher/13-kampf-vor-tower.png) | [vorher](screenshots/g2/vorher/13-kampf-vor-tower.png) |
| 14 | Kampf vor dem Ancient | [nachher](screenshots/g2/nachher/14-kampf-vor-ancient.png) | [vorher](screenshots/g2/vorher/14-kampf-vor-ancient.png) |
| 15 | Gesamtkarte | [nachher](screenshots/g2/nachher/15-gesamtkarte.png) | [vorher](screenshots/g2/vorher/15-gesamtkarte.png) |
| + | Maßstab, Ancient Rot, Grotte Cooldown / bald verfügbar | [16](screenshots/g2/nachher/16-massstab.png) · [17](screenshots/g2/nachher/17-ancient-rot-100.png) · [18a](screenshots/g2/nachher/18a-grotte-cooldown.png) · [18b](screenshots/g2/nachher/18b-grotte-bald-verfuegbar.png) | [16](screenshots/g2/vorher/16-massstab.png) · [17](screenshots/g2/vorher/17-ancient-rot-100.png) · [18a](screenshots/g2/vorher/18a-grotte-cooldown.png) · [18b](screenshots/g2/vorher/18b-grotte-bald-verfuegbar.png) |
**Videos** (1920 × 1080, 30 fps, H.264, echte Partie, Movie-Writer von Godot, Schnitte statt Zeitraffer):
- [g2-turmkampf.mp4](videos/g2-turmkampf.mp4), 22 s: Blaus Mid-Armee erreicht Rots Grenzwacht. Zielerfassung, Aufladen, Bolzen und Einschläge, Schnitt zu schwerem Schaden, dann Einsturz, Ruine und die Armee zieht vorbei.
- [g2-bossbegegnung.mp4](videos/g2-bossbegegnung.mp4), 26 s: Der Boss ruht, beide Expeditionen ziehen ein und kämpfen gegeneinander. Schnitt: Die Schlundkrone erwacht mit Gebrüll und kämpft; Schnitt zu ihrem Ende, die Grotte erschöpft.
Aufnahme und Kodierung: `tools/luneraria/g2_video.tscn` mit `--write-movie`, dann `tools/luneraria/encode_video.py` (FFmpeg aus Blender).
## Plattformen
| Paket | Test | Ergebnis |
| --- | --- | --- |
| macOS Universal 0.17.0-g2 (App ad-hoc signiert, nicht notarisiert) | nach dem Entpacken wie im Finder: Signatur gültig; Kurzpartie `--lune-smoke` | Apple Silicon: Metal, Forward+, 120 fps · Intel-Teil unter Rosetta: Vulkan/MoltenVK, Forward+, 118 fps · beide **PASS** |
| Windows ARM64 0.17.0-g2 (neu) | Windows 11 Pro 24H2 ARM in Parallels, Kurzpartie im Desktop | Vulkan/D3D12 fehlen in der VM → automatischer Wechsel auf OpenGL 3 (Kompatibilitätsmodus), 65–69 fps, **PASS** |
| Windows x64 0.17.0-g2 | dieselbe VM, x64-Emulation (Prism) | ebenfalls OpenGL-Fallback, 46–61 fps, **PASS** |
| Linux x64 0.17.0-g2 | nur exportiert | auf diesem Mac nicht startbar (x86_64) |
Die Prüfsummen der Pakete stehen in `../dist/luneraria/build-manifest.json`; in der VM stimmen die Hashes überein. Berichte: `logs/g2/smoke-*.json`, Godot-Logs der VM `logs/g2/windows11-*-godot.log`, Bilder: `screenshots/g2/plattformen/`, darunter das echte Desktopfenster unter Windows.
**Einordnung:**
- Das ist der erste Lauf unter Windows 11, aber kein Test auf x64-Hardware mit echtem Vulkan/D3D12-Treiber.
- Zugleich ist es der erste Lauf des Kompatibilitätsmodus mit Luneraria: Die G2-Shader funktionieren dort, einschließlich der Instanzparameter; das Bild ist ohne SSAO und Glow dunkler.
## Bekannte Probleme
- GPU-Zeit ist auf dem Mac nur geschätzt (Metal ohne Zeitstempel in Godot 4.7.2), und die 120-Hz-Grenze verdeckt Unterschiede in leichten Ansichten.
- Die Occlusion ist bewusst grob: Bildschirmrechteck und Bodentiefe, alle zwei Bilder. Einzelne Figuren am Rand eines Gebäudes können kurz ein Ausdünnen auslösen. Das Dithering ist bei MSAA 2× als feines Raster sichtbar.
- Die Grottenfelsen stehen wegen der Top-Lane dicht an der Arena; aus manchen gedrehten Kameras wirkt die Felskulisse knapp.
- Der Boss als Lane-Verstärkung ist zwischen vielen blauen Einheiten der eigenen Seite an Größe und Krone erkennbar, farblich aber nah an ihnen.
- Der Victory-Clip läuft nur, wenn eine Grottenexpedition scheitert; beim Lane-Boss gibt es keine eigene Sieg- oder Einmarschsequenz, bewusst ohne Rutschen während des Marschs.
- Die GLBs sind zwischen Linux und macOS nicht bytegleich (Geometrie gleich).
- Die Windows-Tests liefen nur in einer ARM-VM mit OpenGL-Fallback.
- `tools/build_demo.py` (Saumwacht 0.15.0) passt seit M4 nicht mehr zu den umbenannten Presets. Die Luneraria-Pakete baut `tools/luneraria/build_luneraria_demo.py`.
## Offene Aufgaben für G3 (und später)
- **G3 HUD:**
- Zustände der Gebäude und der Grotte (bald verfügbar, Cooldown, Claim) auch im HUD und auf der Minimap spiegeln;
- Lebensbalken der Gebäude ins neue HUD-Design überführen;
- Ankündigungen für Bossentsendung und Turmfall mit den neuen Effekten verbinden.
- **Performance (G5/G6):** Turmteile je Material zusammenfassen, Sichtbarkeitsbereiche für Kleinteile, Figuren-Kosten (2,4 ms) angehen.
- **G4 Umgebung:** Basisplatz, Ringmauer, Tore und Landmarken noch Blockout; Felsen und Säulen der Grotte könnten Kartenmaterial und Gelände stärker aufnehmen.
- **G5 Licht:** Gesamtbalance der 20 Punktlichter mit dem neuen Himmel; Glow-Stärke im Kompatibilitätsmodus.
- **Windows auf x64-Hardware** mit nativem Vulkan/D3D12 (M4-Punkt bleibt offen); Notarisierung für macOS, falls die App weitergegeben werden soll.

View file

@ -1,6 +1,10 @@
# QA-Matrix # QA-Matrix
Testumgebung aller Läufe: Fedora Linux 44 (KDE Plasma, XWayland), AMD Ryzen 5 5600X, NVIDIA GeForce RTX 5060 Ti (Vulkan 1.4.351), Godot 4.7.2 (Fedora-Build), Forward+. **Windows 11 ist nicht geprüft:** Es gibt keine Windows-Hardware in dieser Umgebung. Testumgebung bis G1: Fedora Linux 44 (KDE Plasma, XWayland), AMD Ryzen 5 5600X, NVIDIA GeForce RTX 5060 Ti (Vulkan 1.4.351), Godot 4.7.2 (Fedora-Build), Forward+.
Testumgebung ab G2: Mac mit Apple M5 (10 GPU-Kerne, 16 GB), macOS 27, Godot 4.7.2 (offizieller Build) mit Forward+ über Metal 4, Blender 5.2.2 LTS. Alle M1–G1-Tests wurden dort erneut ausgeführt.
Windows 11 lief ab G2 nur in einer Parallels-VM (ARM, OpenGL-Fallback), nicht auf x64-Hardware.
Legende: ✅ automatisiert geprüft · 👁 in echten Aufnahmen visuell geprüft · ⏳ offen (Meilenstein) · — nicht zutreffend. Legende: ✅ automatisiert geprüft · 👁 in echten Aufnahmen visuell geprüft · ⏳ offen (Meilenstein) · — nicht zutreffend.
@ -91,6 +95,30 @@ Weitere M2-Belege: `test_lune_m2` 73/73, darunter der Nachweis, dass die alte Ar
| Leistung vorher/nachher, gleiche Methode | ✅ | `logs/g1/perf-*-1920x1080.json` | | Leistung vorher/nachher, gleiche Methode | ✅ | `logs/g1/perf-*-1920x1080.json` |
| OpenGL, Windows 11, Spieltests | ⏳ offen | — | | OpenGL, Windows 11, Spieltests | ⏳ offen | — |
## G2 · Ancients, Türme und Bossgrotte
| Nachweis (Auftrag §25) | Stand | Beleg |
| --- | --- | --- |
| A · alle Regeltests M1–M3 ohne geänderte Erwartungswerte | ✅ 91/91 · 73/73 · Stress 17/17 · Balance 40 Partien identisch mit G1 | `logs/g2/test-*.log`, `logs/g2/balance-summary.txt` |
| B · G1-Kartentests | ✅ 443/443 | `logs/g2/test-verify-g1.json` |
| C · Turmzielwahl (nur feindliche Figuren in Reichweite, Zielring unter dem Regelziel, Bolzen zum Ziel, ein Bolzen pro Treffer) | ✅ | `verify_g2` D |
| D · Tower-Reihenfolge (keine gesperrte Stufe nimmt Schaden) | ✅ | `verify_g2` E |
| E · Basistürme sperren den Ancient | ✅ | `verify_g2` E |
| F · Ancient-Zerstörung beendet die Partie sofort; der Kollaps folgt erst danach | ✅ | `verify_g2` E |
| G · Bossgrotte erreichbar (Eingänge auf dem Boden, Weg frei, Dais) | ✅ · 👁 | `verify_g2` F, `screenshots/g2/nachher/10-*.png` |
| H · Boss bekämpfbar (Erwachen mit Brüllen, Tod, Erschöpfung der Grotte, Claim) | ✅ · 👁 Video | `verify_g2` F, `videos/g2-bossbegegnung.mp4` |
| I · Boss entsendbar (Claim → Einsatz für das richtige Team) | ✅ | `verify_g2` F |
| J · Boss auf allen drei Lanes (Vorrücken, Turmbeschuss, Bodenkontakt, Teamfarbe) | ✅ Top/Mid/Bottom | `verify_g2` F |
| K · zerstörte Türme blockieren nicht; Ruine bleibt stehen | ✅ · 👁 Video | `verify_g2` E, `videos/g2-turmkampf.mp4` |
| L · keine Gold-/Schadensdoppelereignisse durch Animation/VFX | ✅ vier Runden mit und ohne Darstellung identisch; kein Bolzen landet doppelt | `verify_g2` G, D |
| M · mehrere komplette Botpartien | ✅ vier Seeds headless, eine ganze Partie mit Grafik exakt gleich | `verify_g2` H |
| Netzwerk nur als Regression | ✅ 43/43 · Loopback 12/12 | `logs/g2/test-net*.log` |
| Assets und Blender-Pipeline (Hash, Quelle, Generator, Pivot, Clips, Materialien) | ✅ 113 Prüfungen | `verify_g2` A, `assets/luneraria/g2/g2_manifest.json` |
| Zustände Türme/Ancients/Grotte, Teamidentität, Lichtbudget, Occlusion, Maßstab | ✅ | `verify_g2` B, C, I |
| 15 Pflichtansichten + Zusatzansichten, vorher/nachher, 1920 × 1080 | 👁 21 + 21 | `screenshots/g2/nachher`, `screenshots/g2/vorher` |
| Videos Turmkampf und Bossbegegnung | 👁 | `videos/` |
| Leistung G1 ↔ G2, gleiche Methode, gleicher Rechner | ✅ +0,5 ms in vollen Kampfansichten, Ursache ermittelt | `logs/g2/perf-vergleich-mac.json`, `cost-split.json` |
## Legacy-Regressionen (Saumwacht) ## Legacy-Regressionen (Saumwacht)
Unverändert über die Umleitung der alten QA-Schalter: 209 · 33 · 463 · 206 · 1.665 · Menü 20A 68. Logs: `logs/m0-baseline/`, `logs/m1/legacy-*.log`. Unverändert über die Umleitung der alten QA-Schalter: 209 · 33 · 463 · 206 · 1.665 · Menü 20A 68. Logs: `logs/m0-baseline/`, `logs/m1/legacy-*.log`.
@ -99,6 +127,11 @@ Unverändert über die Umleitung der alten QA-Schalter: 209 · 33 · 463 · 206
| Plattform | Stand | | Plattform | Stand |
| --- | --- | | --- | --- |
| Fedora 44, Forward+ | ✅ Quellprojekt (Tests, Aufnahmen, Leistung, Balance, Stress) | | Fedora 44, Forward+ | ✅ Quellprojekt bis G1 (Tests, Aufnahmen, Leistung, Balance, Stress) |
| Fedora 44, Kompatibilitätsmodus (OpenGL) | ⏳ offen (für Saumwacht 0.15.0 geprüft, für Luneraria noch nicht; M4 pausiert) | | Fedora 44, Kompatibilitätsmodus (OpenGL) | ⏳ offen (für Saumwacht 0.15.0 geprüft, für Luneraria noch nicht; M4 pausiert) |
| Windows 11 | ⏳ nicht geprüft (keine Hardware). Ein exportiertes Paket oder ein Wine-Lauf wäre kein nativer Test. | | macOS 27, Apple M5, Forward+ (Metal) | ✅ Quellprojekt ab G2 (alle Tests, Aufnahmen, Videos, Leistung); Paket 0.17.0-g2 entpackt und mit `--lune-smoke` gespielt |
| macOS, Intel-Teil unter Rosetta (Vulkan/MoltenVK) | ✅ Paket 0.17.0-g2, `--lune-smoke` |
| Windows 11 Pro 24H2 ARM (Parallels), Paket ARM64 | ✅ `--lune-smoke`, automatisch OpenGL 3 (kein Vulkan/D3D12 in der VM), 65–69 fps |
| Windows 11 Pro 24H2 ARM (Parallels), Paket x64 unter Emulation | ✅ `--lune-smoke`, OpenGL 3, 46–61 fps |
| Windows 11 auf x64-Hardware | ⏳ nicht geprüft (keine Hardware) |
| Linux-Paket 0.17.0-g2 | exportiert, auf dem Mac nicht startbar |

View file

@ -1,6 +1,8 @@
# STATUS · Champions of Luneraria # STATUS · Champions of Luneraria
Stand: 29. September 2026 · Branches `luneraria/m1` (M0/M1), `luneraria/m2`, `luneraria/m3`, `luneraria/m4`, `luneraria/g1` · Luneraria-Demo 0.16.0-m4. Stand: 29. September 2026 · Branches `luneraria/m1` (M0/M1), `luneraria/m2`, `luneraria/m3`, `luneraria/m4`, `luneraria/g1`, `luneraria/g2` · Luneraria-Demo 0.17.0-g2 (macOS, Windows x64/ARM64, Linux).
Arbeitsrechner seit G2: Mac (Apple M5, macOS 27) mit Godot 4.7.2 und Blender 5.2.2 LTS; bis G1 Fedora 44 (RTX 5060 Ti). Werkzeugpfade und Plattformhinweise: [ANLEITUNG.md](ANLEITUNG.md).
| Meilenstein | Zustand | | Meilenstein | Zustand |
| --- | --- | | --- | --- |
@ -8,9 +10,10 @@ Stand: 29. September 2026 · Branches `luneraria/m1` (M0/M1), `luneraria/m2`, `l
| M1 · Spielbarer Drei-Lane-Kern | **abgeschlossen** | | M1 · Spielbarer Drei-Lane-Kern | **abgeschlossen** |
| M2 · Mechanikdurchlauf | **abgeschlossen** | | M2 · Mechanikdurchlauf | **abgeschlossen** |
| M3 · Qualität und Balance | **abgeschlossen** (siehe unten) | | M3 · Qualität und Balance | **abgeschlossen** (siehe unten) |
| M4 · Netzwerk und Pakete | **pausiert** (Host/Join, Mehr-Runden-Loopback, Wiedereinstieg; offen: OpenGL, Windows 11) | | M4 · Netzwerk und Pakete | **pausiert** (Host/Join, Mehr-Runden-Loopback, Wiedereinstieg; offen: Windows auf x64-Hardware, Netz über mehrere Rechner) |
| G1 · Map-Blockout und Drei-Lane-Layout | **abgeschlossen** ([G1-KARTE.md](G1-KARTE.md)) | | G1 · Map-Blockout und Drei-Lane-Layout | **abgeschlossen** ([G1-KARTE.md](G1-KARTE.md)) |
| G2–G6 · Ancients/Türme/Grotte, HUD, Umgebung, Licht, Einheiten | offen | | G2 · Ancients, Türme und Bossgrotte | **abgeschlossen** ([G2-STRATEGISCHE-BAUWERKE.md](G2-STRATEGISCHE-BAUWERKE.md), [G2-ASSETS.md](G2-ASSETS.md)) |
| G3–G6 · HUD, Umgebung, Licht, Einheiten | offen |
## M1 · abgeschlossen ## M1 · abgeschlossen
@ -91,8 +94,8 @@ Stand: 29. September 2026 · Branches `luneraria/m1` (M0/M1), `luneraria/m2`, `l
## Blockiert ## Blockiert
- Nativer Windows-11-Test: keine Windows-Hardware. - Windows auf x64-Hardware mit echtem Vulkan/D3D12: keine solche Hardware. Seit G2 laufen beide Windows-Pakete in Windows 11 ARM (Parallels) mit OpenGL-Fallback.
- Kompatibilitätsmodus (OpenGL) für Luneraria: erst mit M4-Exporten gezielt prüfen. - Notarisierung des macOS-Pakets: kein Apple-Entwicklerkonto; die App ist ad-hoc signiert.
## M4 · pausiert ## M4 · pausiert
@ -118,9 +121,9 @@ Auf Entscheidung des Auftraggebers ruht M4 seit dem 28. September 2026 zugunsten
**Visuell geprüft:** Host und Gast in zwei Fenstern mit Grafik, Kampf in Echtzeit. Gastsicht in Planung und Kampf von Runde 1 und 2 (`screenshots/m4/`). Runde 1: 872, Runde 2: 730 empfangene Momentaufnahmen, Prüfsummen gleich. **Visuell geprüft:** Host und Gast in zwei Fenstern mit Grafik, Kampf in Echtzeit. Gastsicht in Planung und Kampf von Runde 1 und 2 (`screenshots/m4/`). Runde 1: 872, Runde 2: 730 empfangene Momentaufnahmen, Prüfsummen gleich.
**Offen (eingefroren)** **Offen (eingefroren)**
- Kompatibilitätsmodus (OpenGL) für Luneraria. - Kompatibilitätsmodus (OpenGL) für Luneraria. *G2: in Windows 11 ARM (Parallels) automatisch aktiv und lauffähig; eine gezielte Sichtprüfung auf Desktop-Hardware steht aus.*
- Nativer Windows-11-Test (blockiert: keine Hardware). - Nativer Windows-11-Test. *G2: beide Windows-Pakete liefen in Windows 11 ARM in einer VM; x64-Hardware mit echtem Treiber fehlt weiter.*
- Neubau der Demo-Pakete mit der umgebauten Sitzung. - Neubau der Demo-Pakete mit der umgebauten Sitzung. *G2: erledigt mit 0.17.0-g2; Netzwerkfunktionen darin unverändert und nur als Regression geprüft.*
- Partie mit sechs Menschen auf mehreren Rechnern bzw. über das Internet (bisher nur lokaler Loopback). - Partie mit sechs Menschen auf mehreren Rechnern bzw. über das Internet (bisher nur lokaler Loopback).
- Wiedereinstieg ist nur an den Anzeigenamen gebunden (keine Konten); Übertragungsrate bei vielen Gästen über das Internet ist nicht gemessen. - Wiedereinstieg ist nur an den Anzeigenamen gebunden (keine Konten); Übertragungsrate bei vielen Gästen über das Internet ist nicht gemessen.
- Öffentliches Matchmaking, Accounts, Ranglisten: außerhalb des Umfangs. - Öffentliches Matchmaking, Accounts, Ranglisten: außerhalb des Umfangs.
@ -155,6 +158,44 @@ Details, Vorher/Nachher-Bilder und Messwerte: [G1-KARTE.md](G1-KARTE.md).
**Ungetestet:** OpenGL-Modus, Windows 11, schwächere GPUs, menschliche Spieltests des Layouts. **Ungetestet:** OpenGL-Modus, Windows 11, schwächere GPUs, menschliche Spieltests des Layouts.
## G2 · abgeschlossen
Details, Bilder, Videos und Messwerte: [G2-STRATEGISCHE-BAUWERKE.md](G2-STRATEGISCHE-BAUWERKE.md). Assetprüfung: [G2-ASSETS.md](G2-ASSETS.md).
**Implementiert**
- **Mondherz-Ancients** (Saumkern-Kristall als Herz, Sichelklingen, Ringe, Krone, Wappen, Fragmente, Dais, Banner, Energieadern zu Basistürmen und Toren):
- Stufen stabil, instabil und kritisch; danach ein 2,8 s langer Zusammenbruch und eine bleibende Ruine;
- der Sieg wartet nie auf die Grafik.
- **Turmfamilie** aus einem Generator: Grenzwacht (Tower 1), Bastion (Tower 2), Kernwacht (Basisturm in der Formensprache des Ancients).
- Schaden leicht und schwer, Einsturz und Ruine.
- Sichtbarer Angriff: Zielerfassung, Aufladen, Bolzen, Einschlag. Der Schaden bleibt in der Regel.
- **Bossgrotte Mondschlund:** Tor mit Treppe in die Tiefe, Altar mit Mondquelle, Säulen, Felsen, Mondwasser. Fünf Zustände: verfügbar, Kampf, nicht verfügbar (entsandt), Cooldown, bald verfügbar. Die Grotte hält beim Bau automatisch Abstand zur Top-Lane.
- **Superboss Schlundkrone:** neues Modell mit 24 Knochen und 11 Clips, darunter Brüllen beim Erwachen und Sieg. Adern in Teamfarbe; dasselbe Modell als Grotten- und Lane-Boss.
- **Materialfamilie `LN_*`:** Stein, Metall, Kristall, Rune, Stoff, Mondwasser, Kreaturenhaut. Pro Gebäude nur Instanzparameter; wiederverwendbar für G4/G5.
- **Occlusion-Fade** für Ancient, Basistürme und Grottentor (schlanke Türme nur für Helden, Champions und Boss); Schatten dithern nie.
- **Werkzeuge:** `verify_g2`, `run_g2_tests.sh`, Abnahmebilder, Videoaufnahme mit MP4-Kodierung über Blender, Kostenaufteilung, Boss-Clipbogen, Kamera-Probe, erweiterter Benchmark (GPU-Last unter macOS über `ioreg`).
- **Plattformen:**
- macOS-Paket (Universal, ad-hoc signiert) und Windows-ARM64-Paket;
- Plattform-Kurztest `--lune-smoke`;
- eigene Luneraria-Version im Menü.
**Automatisiert geprüft**
- `verify_g2` 245/245 (Assets, Ansichten, Zustände, Turmzielwahl, Turmreihenfolge, Basis-Sperre, Ancient-Ende, Grotte, Boss auf allen Lanes, Ruinen, keine Doppelereignisse, Botpartien, Occlusion, Maßstab).
- Regeln 91/91 und 73/73, Stress 17/17, `verify_g1` 443/443, `verify_m1` 38/38, `verify_m2` 14/14, Netz 43/43 und 12/12, Legacy 209 und 463.
- Balance 40 Partien: Ergebnis Wert für Wert identisch mit G1.
**Visuell geprüft:** 21 Ansichten nachher/vorher (1920 × 1080) aus der Spielkamera, zwei Videos (Turmkampf, Bossbegegnung), Plattformbilder macOS und Windows 11.
**Leistung** (Mac M5, 1080p): Die volle Kampfansicht braucht 10,0 statt 9,5 ms (+5 %). Ursache sind die mehrteiligen Turmmodelle (Draw Calls), nicht die Shader. Die Lichter bleiben ohne Schatten.
**Ungetestet**
- Windows auf x64-Hardware (nur Windows 11 ARM in Parallels mit OpenGL-Fallback);
- Linux-Paket (nur exportiert);
- schwächere GPUs;
- menschliche Spieltests der neuen Lesbarkeit.
## Nächster Schritt ## Nächster Schritt
G2 · Ancients, Türme und Bossgrotte: vorhandene Kristall- und Turmassets zu monumentalen Ancients (Zustandsstufen), einem modularen Turmsystem (Angriffsbereich, Treffer, Schaden, Zerstörung) und einer echten Grotte mit Zuständen ausbauen; Teamidentität über Banner, Runen und Architektur. Danach G3 (HUD). M4 ruht; beim Wiederaufnehmen zuerst OpenGL-Sichtprüfung, Windows-11-Protokoll auf fremder Hardware und Neubau der Demos. G3 · HUD: das neue Ingame-HUD. Gebäude- und Grottenzustände (bald verfügbar, Cooldown, Claim) auch im HUD und auf der Minimap spiegeln. Lebensbalken der Gebäude ins HUD-Design überführen. Ankündigungen mit den neuen Effekten verbinden.
M4 ruht weiter. Beim Wiederaufnehmen zuerst Windows auf x64-Hardware prüfen (der OpenGL-Fallback ist in der VM bereits gelaufen) und die Netzpartie über mehrere Rechner.

View file

@ -0,0 +1,6 @@
Partien 40 · Ergebnisse {'blau': 20, 'rot': 20} · Testlimit 0 · Zeitfenster-Kämpfe 4 · längster Kampf 180 s
Wellen bis Ende: min 9 · median 11 · max 13
Seitentausch: dieselbe Zusammensetzung gewinnt beide Male in 20/20 Paaren
Profile (Siege/Einsätze): {'ausgewogen': '30/48 (62 %)', 'bossjagd': '8/34 (24 %)', 'druck': '20/34 (59 %)', 'fernkampf': '26/48 (54 %)', 'teamhilfe': '18/48 (38 %)', 'verteidigung': '18/28 (64 %)'}
Bünde: {'glutpakt': '66/124 (53 %)', 'wurzelbund': '54/116 (47 %)'}
Boss: {'won': 20, 'deployed': 12} · Heldenstufen am Ende (Median): 4

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,43 @@
{
"engine": "4.7.2-stable (official)",
"gpu": "Apple M5 (Apple9)",
"results": [
{
"draw_calls": 4181.0,
"frame_ms": 10.7308927613941,
"variant": "everything"
},
{
"draw_calls": 4007.0,
"frame_ms": 10.1861577608142,
"variant": "towers hidden"
},
{
"draw_calls": 4184.0,
"frame_ms": 10.6550106382979,
"variant": "towers with a plain material"
},
{
"draw_calls": 4067.0,
"frame_ms": 11.0736519337017,
"variant": "Ancients hidden"
},
{
"draw_calls": 4132.0,
"frame_ms": 10.9000054495913,
"variant": "grotto hidden"
},
{
"draw_calls": 797.0,
"frame_ms": 8.3336,
"variant": "figures hidden"
},
{
"draw_calls": 2514.0,
"frame_ms": 9.91786386138614,
"variant": "sun shadows off"
}
],
"scene": "round 13, six full armies, lane view (-10, 10) zoom 34, combat paused",
"window": "(1920, 1080)"
}

View file

@ -0,0 +1,137 @@
{
"base_view": {
"camera_zoom": 26.0,
"cpu_frame_ms_mean": 8.20410277777778,
"draw_calls_mean": 793.4,
"frame_ms_mean": 8.33426111111112,
"frame_ms_p95": 9.07,
"frames": 720,
"gpu_load": {
"Device Utilization %": 72.9333333333333,
"Renderer Utilization %": 69.3333333333333,
"Tiler Utilization %": 31.4,
"gpu_memory_in_use_mb": 2069.40625,
"samples": 15
},
"gpu_ms_estimate": 6.07845443703704,
"gpu_ms_mean": 0.0,
"mobile_units_mean": 60.1319444444444,
"objects_mean": 2892.14722222222,
"primitives_mean": 2351347.26388889,
"process_ms_median": 9.765,
"render_setup_ms_median": 0.13,
"scene_max": {
"lights": 23,
"particle_systems": 0,
"particles_max": 0,
"shadow_caster_instances": 775,
"shadow_caster_nodes": 368,
"shadow_lights": 1
}
},
"buffer_mem_mb": 170.629276275635,
"buildings": 18,
"cpu": "Apple M5",
"engine": "4.7.2-stable (official)",
"gpu": "Apple M5 (Apple9)",
"grotto_view": {
"camera_zoom": 22.0,
"cpu_frame_ms_mean": 8.21077638888888,
"draw_calls_mean": 1544.86666666667,
"frame_ms_mean": 8.33355416666666,
"frame_ms_p95": 9.075,
"frames": 720,
"gpu_load": {
"Device Utilization %": 86.8,
"Renderer Utilization %": 85.1333333333333,
"Tiler Utilization %": 6.6,
"gpu_memory_in_use_mb": 2145.328125,
"samples": 15
},
"gpu_ms_estimate": 7.23352501666666,
"gpu_ms_mean": 0.0,
"mobile_units_mean": 31.1972222222222,
"objects_mean": 3799.31111111111,
"primitives_mean": 5039960.575,
"process_ms_median": 9.966,
"render_setup_ms_median": 0.125,
"scene_max": {
"lights": 23,
"particle_systems": 0,
"particles_max": 0,
"shadow_caster_instances": 666,
"shadow_caster_nodes": 259,
"shadow_lights": 1
}
},
"lane_view": {
"camera_zoom": 34.0,
"cpu_frame_ms_mean": 9.37060917721519,
"draw_calls_mean": 3277.73575949367,
"frame_ms_mean": 9.50233702531646,
"frame_ms_p95": 10.99,
"frames": 632,
"gpu_load": {
"Device Utilization %": 93.4,
"Renderer Utilization %": 93.2666666666667,
"Tiler Utilization %": 1.33333333333333,
"gpu_memory_in_use_mb": 2041.359375,
"samples": 15
},
"gpu_ms_estimate": 8.87518278164558,
"gpu_ms_mean": 0.0,
"mobile_units_mean": 117.460443037975,
"objects_mean": 7339.43670886076,
"primitives_mean": 16049036.7689873,
"process_ms_median": 11.034,
"render_setup_ms_median": 0.216,
"scene_max": {
"lights": 23,
"particle_systems": 0,
"particles_max": 0,
"shadow_caster_instances": 946,
"shadow_caster_nodes": 539,
"shadow_lights": 1
}
},
"mobile_units_at_start": 120,
"overview": {
"camera_zoom": 92.0,
"cpu_frame_ms_mean": 9.32390708661418,
"draw_calls_mean": 3588.19842519685,
"frame_ms_mean": 9.46169448818898,
"frame_ms_p95": 10.285,
"frames": 635,
"gpu_load": {
"Device Utilization %": 99.2666666666667,
"Renderer Utilization %": 99.2666666666667,
"Tiler Utilization %": 0.2,
"gpu_memory_in_use_mb": 2080.859375,
"samples": 15
},
"gpu_ms_estimate": 9.39230872860892,
"gpu_ms_mean": 0.0,
"mobile_units_mean": 95.6393700787402,
"objects_mean": 8119.78897637795,
"primitives_mean": 16745232.2362205,
"process_ms_median": 10.604,
"render_setup_ms_median": 0.19,
"scene_max": {
"lights": 23,
"particle_systems": 0,
"particles_max": 0,
"shadow_caster_instances": 883,
"shadow_caster_nodes": 476,
"shadow_lights": 1
}
},
"process_rss_mb": 707.6875,
"renderer": "forward_plus",
"scene_build_ms": 410.292,
"sim_speed": "2x (40 steps/s)",
"sim_step_ms": 1.682385,
"static_mem_mb": 97.9976396560669,
"texture_mem_mb": 410.491943359375,
"video_mem_mb": 819.3125,
"window": "(1920, 1080)"
}

View file

@ -0,0 +1,137 @@
{
"base_view": {
"camera_zoom": 26.0,
"cpu_frame_ms_mean": 8.2084854368932,
"draw_calls_mean": 792.780859916782,
"frame_ms_mean": 8.33413453536755,
"frame_ms_p95": 9.032,
"frames": 721,
"gpu_load": {
"Device Utilization %": 71.0666666666667,
"Renderer Utilization %": 68.2,
"Tiler Utilization %": 30.3333333333333,
"gpu_memory_in_use_mb": 2299.359375,
"samples": 15
},
"gpu_ms_estimate": 5.92279160980121,
"gpu_ms_mean": 0.0,
"mobile_units_mean": 60.0263522884882,
"objects_mean": 2891.3120665742,
"primitives_mean": 2347600.24965326,
"process_ms_median": 9.686,
"render_setup_ms_median": 0.127,
"scene_max": {
"lights": 23,
"particle_systems": 0,
"particles_max": 0,
"shadow_caster_instances": 775,
"shadow_caster_nodes": 368,
"shadow_lights": 1
}
},
"buffer_mem_mb": 170.629276275635,
"buildings": 18,
"cpu": "Apple M5",
"engine": "4.7.2-stable (official)",
"gpu": "Apple M5 (Apple9)",
"grotto_view": {
"camera_zoom": 22.0,
"cpu_frame_ms_mean": 8.21486527777779,
"draw_calls_mean": 1542.56944444444,
"frame_ms_mean": 8.3336013888889,
"frame_ms_p95": 8.917,
"frames": 720,
"gpu_load": {
"Device Utilization %": 86.5333333333333,
"Renderer Utilization %": 84.7333333333333,
"Tiler Utilization %": 1.66666666666667,
"gpu_memory_in_use_mb": 2341.9375,
"samples": 15
},
"gpu_ms_estimate": 7.21134306851853,
"gpu_ms_mean": 0.0,
"mobile_units_mean": 31.0888888888889,
"objects_mean": 3795.80138888889,
"primitives_mean": 5025679.72222222,
"process_ms_median": 9.583,
"render_setup_ms_median": 0.116,
"scene_max": {
"lights": 23,
"particle_systems": 0,
"particles_max": 0,
"shadow_caster_instances": 666,
"shadow_caster_nodes": 259,
"shadow_lights": 1
}
},
"lane_view": {
"camera_zoom": 34.0,
"cpu_frame_ms_mean": 9.38248019017433,
"draw_calls_mean": 3278.88114104596,
"frame_ms_mean": 9.51605388272584,
"frame_ms_p95": 10.979,
"frames": 631,
"gpu_load": {
"Device Utilization %": 95.5333333333333,
"Renderer Utilization %": 94.6666666666667,
"Tiler Utilization %": 10.3333333333333,
"gpu_memory_in_use_mb": 2329.953125,
"samples": 15
},
"gpu_ms_estimate": 9.09100347596408,
"gpu_ms_mean": 0.0,
"mobile_units_mean": 117.462757527734,
"objects_mean": 7341.16006339144,
"primitives_mean": 16053505.473851,
"process_ms_median": 12.017,
"render_setup_ms_median": 0.218,
"scene_max": {
"lights": 23,
"particle_systems": 0,
"particles_max": 0,
"shadow_caster_instances": 946,
"shadow_caster_nodes": 539,
"shadow_lights": 1
}
},
"mobile_units_at_start": 120,
"overview": {
"camera_zoom": 92.0,
"cpu_frame_ms_mean": 9.35740031645571,
"draw_calls_mean": 3587.85443037975,
"frame_ms_mean": 9.49554430379748,
"frame_ms_p95": 10.297,
"frames": 632,
"gpu_load": {
"Device Utilization %": 73.7333333333333,
"Renderer Utilization %": 73.7333333333333,
"Tiler Utilization %": 0.333333333333333,
"gpu_memory_in_use_mb": 2457.90625,
"samples": 15
},
"gpu_ms_estimate": 7.00138133333334,
"gpu_ms_mean": 0.0,
"mobile_units_mean": 95.6123417721519,
"objects_mean": 8118.93037974684,
"primitives_mean": 16742561.7405063,
"process_ms_median": 12.178,
"render_setup_ms_median": 0.189,
"scene_max": {
"lights": 23,
"particle_systems": 0,
"particles_max": 0,
"shadow_caster_instances": 883,
"shadow_caster_nodes": 476,
"shadow_lights": 1
}
},
"process_rss_mb": 707.828125,
"renderer": "forward_plus",
"scene_build_ms": 378.821,
"sim_speed": "2x (40 steps/s)",
"sim_step_ms": 1.8368,
"static_mem_mb": 97.9970102310181,
"texture_mem_mb": 410.491943359375,
"video_mem_mb": 819.4375,
"window": "(1920, 1080)"
}

View file

@ -0,0 +1,97 @@
{
"base_view": {
"camera_zoom": 26.0,
"cpu_frame_ms_mean": 5.11963254593176,
"draw_calls_mean": 803.182852143482,
"frame_ms_mean": 5.25195538057743,
"frame_ms_p95": 6.964,
"frames": 1143,
"gpu_ms_mean": 1.74873141030523,
"mobile_units_mean": 61.9448818897638,
"objects_mean": 2906.01749781277,
"primitives_mean": 2414072.20822397,
"scene_max": {
"lights": 23,
"particle_systems": 0,
"particles_max": 0,
"shadow_caster_instances": 795,
"shadow_caster_nodes": 388,
"shadow_lights": 1
}
},
"buffer_mem_mb": 170.633762359619,
"buildings": 18,
"cpu": "AMD Ryzen 5 5600X 6-Core Processor",
"engine": "4.7.2-stable (fedora)",
"gpu": "NVIDIA GeForce RTX 5060 Ti",
"grotto_view": {
"camera_zoom": 22.0,
"cpu_frame_ms_mean": 5.34039344262295,
"draw_calls_mean": 1589.03369763206,
"frame_ms_mean": 5.46844444444444,
"frame_ms_p95": 6.592,
"frames": 1098,
"gpu_ms_mean": 2.32957012666379,
"mobile_units_mean": 33.5828779599271,
"objects_mean": 3872.60928961749,
"primitives_mean": 5337568.49180328,
"scene_max": {
"lights": 23,
"particle_systems": 0,
"particles_max": 0,
"shadow_caster_instances": 673,
"shadow_caster_nodes": 266,
"shadow_lights": 1
}
},
"lane_view": {
"camera_zoom": 34.0,
"cpu_frame_ms_mean": 7.34135455680399,
"draw_calls_mean": 3329.1847690387,
"frame_ms_mean": 7.48964169787765,
"frame_ms_p95": 10.278,
"frames": 801,
"gpu_ms_mean": 3.33393383294009,
"mobile_units_mean": 117.686641697878,
"objects_mean": 7420.0074906367,
"primitives_mean": 16254371.9275905,
"scene_max": {
"lights": 23,
"particle_systems": 0,
"particles_max": 0,
"shadow_caster_instances": 946,
"shadow_caster_nodes": 539,
"shadow_lights": 1
}
},
"mobile_units_at_start": 120,
"overview": {
"camera_zoom": 92.0,
"cpu_frame_ms_mean": 7.56214120667523,
"draw_calls_mean": 3601.80359435173,
"frame_ms_mean": 7.7092567394095,
"frame_ms_p95": 10.489,
"frames": 779,
"gpu_ms_mean": 3.55910141324201,
"mobile_units_mean": 96.586649550706,
"objects_mean": 8154.83825417202,
"primitives_mean": 16827184.0898588,
"scene_max": {
"lights": 23,
"particle_systems": 0,
"particles_max": 0,
"shadow_caster_instances": 890,
"shadow_caster_nodes": 483,
"shadow_lights": 1
}
},
"process_rss_mb": 1419.484375,
"renderer": "forward_plus",
"scene_build_ms": 298.529,
"sim_speed": "2x (40 steps/s)",
"sim_step_ms": 2.78371,
"static_mem_mb": 128.257266044617,
"texture_mem_mb": 424.65576171875,
"video_mem_mb": 781.880187988281,
"window": "(1920, 1080)"
}

View file

@ -0,0 +1,97 @@
{
"base_view": {
"camera_zoom": 26.0,
"cpu_frame_ms_mean": 4.65926198083067,
"draw_calls_mean": 806.240415335463,
"frame_ms_mean": 4.79412939297125,
"frame_ms_p95": 6.5,
"frames": 1252,
"gpu_ms_mean": 1.75445367429203,
"mobile_units_mean": 62.4952076677316,
"objects_mean": 2910.42811501597,
"primitives_mean": 2433559.71405751,
"scene_max": {
"lights": 23,
"particle_systems": 0,
"particles_max": 0,
"shadow_caster_instances": 795,
"shadow_caster_nodes": 388,
"shadow_lights": 1
}
},
"buffer_mem_mb": 170.633762359619,
"buildings": 18,
"cpu": "AMD Ryzen 5 5600X 6-Core Processor",
"engine": "4.7.2-stable (fedora)",
"gpu": "NVIDIA GeForce RTX 5060 Ti",
"grotto_view": {
"camera_zoom": 22.0,
"cpu_frame_ms_mean": 4.8310347107438,
"draw_calls_mean": 1593.26446280992,
"frame_ms_mean": 4.96114214876032,
"frame_ms_p95": 6.17,
"frames": 1210,
"gpu_ms_mean": 2.33310661000654,
"mobile_units_mean": 33.8380165289256,
"objects_mean": 3880.46033057851,
"primitives_mean": 5367975.61818182,
"scene_max": {
"lights": 23,
"particle_systems": 0,
"particles_max": 0,
"shadow_caster_instances": 673,
"shadow_caster_nodes": 266,
"shadow_lights": 1
}
},
"lane_view": {
"camera_zoom": 34.0,
"cpu_frame_ms_mean": 7.04803481392557,
"draw_calls_mean": 3333.61584633854,
"frame_ms_mean": 7.20265666266507,
"frame_ms_p95": 9.946,
"frames": 833,
"gpu_ms_mean": 3.33436734478872,
"mobile_units_mean": 117.68787515006,
"objects_mean": 7423.41656662665,
"primitives_mean": 16257552.909964,
"scene_max": {
"lights": 23,
"particle_systems": 0,
"particles_max": 0,
"shadow_caster_instances": 946,
"shadow_caster_nodes": 539,
"shadow_lights": 1
}
},
"mobile_units_at_start": 120,
"overview": {
"camera_zoom": 92.0,
"cpu_frame_ms_mean": 7.03338875598086,
"draw_calls_mean": 3605.72846889952,
"frame_ms_mean": 7.17908133971292,
"frame_ms_p95": 9.698,
"frames": 836,
"gpu_ms_mean": 3.56152751485697,
"mobile_units_mean": 96.8433014354067,
"objects_mean": 8164.83373205742,
"primitives_mean": 16851006.6220096,
"scene_max": {
"lights": 23,
"particle_systems": 0,
"particles_max": 0,
"shadow_caster_instances": 890,
"shadow_caster_nodes": 483,
"shadow_lights": 1
}
},
"process_rss_mb": 1418.43359375,
"renderer": "forward_plus",
"scene_build_ms": 295.259,
"sim_speed": "2x (40 steps/s)",
"sim_step_ms": 2.754265,
"static_mem_mb": 128.256976127625,
"texture_mem_mb": 424.65576171875,
"video_mem_mb": 781.880187988281,
"window": "(1920, 1080)"
}

View file

@ -0,0 +1,141 @@
{
"base_view": {
"camera_zoom": 26.0,
"cpu_frame_ms_mean": 8.18614166666667,
"draw_calls_mean": 917.075,
"frame_ms_mean": 8.33348611111111,
"frame_ms_p95": 9.007,
"frames": 720,
"gpu_load": {
"Device Utilization %": 76.0666666666667,
"Renderer Utilization %": 73.0666666666667,
"Tiler Utilization %": 9.13333333333333,
"gpu_memory_in_use_mb": 2067.265625,
"samples": 15
},
"gpu_ms_estimate": 6.33900510185185,
"gpu_ms_mean": 0.0,
"mobile_units_mean": 60.0041666666667,
"objects_mean": 3018.08333333333,
"primitives_mean": 2268093.53055556,
"process_ms_median": 9.996,
"render_setup_ms_median": 0.148,
"scene_max": {
"lights": 24,
"particle_systems": 2,
"particles_max": 44,
"shadow_caster_instances": 803,
"shadow_caster_nodes": 406,
"shadow_lights": 1
},
"strategic_view_ms_median": 0.225896273668343
},
"buffer_mem_mb": 173.200626373291,
"buildings": 18,
"cpu": "Apple M5",
"engine": "4.7.2-stable (official)",
"gpu": "Apple M5 (Apple9)",
"grotto_view": {
"camera_zoom": 22.0,
"cpu_frame_ms_mean": 8.19134812760055,
"draw_calls_mean": 1632.33980582524,
"frame_ms_mean": 8.33299306518724,
"frame_ms_p95": 9.073,
"frames": 721,
"gpu_load": {
"Device Utilization %": 90.0666666666667,
"Renderer Utilization %": 89.8,
"Tiler Utilization %": 1.13333333333333,
"gpu_memory_in_use_mb": 2065.28125,
"samples": 15
},
"gpu_ms_estimate": 7.50524908737864,
"gpu_ms_mean": 0.0,
"mobile_units_mean": 31.1081830790569,
"objects_mean": 3886.38834951456,
"primitives_mean": 5096285.88626907,
"process_ms_median": 9.557,
"render_setup_ms_median": 0.139,
"scene_max": {
"lights": 24,
"particle_systems": 2,
"particles_max": 44,
"shadow_caster_instances": 694,
"shadow_caster_nodes": 297,
"shadow_lights": 1
},
"strategic_view_ms_median": 0.218992503325459
},
"lane_view": {
"camera_zoom": 34.0,
"cpu_frame_ms_mean": 9.83786023294509,
"draw_calls_mean": 3508.5224625624,
"frame_ms_mean": 9.98521630615639,
"frame_ms_p95": 11.499,
"frames": 601,
"gpu_load": {
"Device Utilization %": 92.2666666666667,
"Renderer Utilization %": 91.4,
"Tiler Utilization %": 0.466666666666667,
"gpu_memory_in_use_mb": 2082.640625,
"samples": 15
},
"gpu_ms_estimate": 9.21302624514697,
"gpu_ms_mean": 0.0,
"mobile_units_mean": 117.447587354409,
"objects_mean": 7596.82362728785,
"primitives_mean": 15955220.5690516,
"process_ms_median": 12.548,
"render_setup_ms_median": 0.248,
"scene_max": {
"lights": 24,
"particle_systems": 2,
"particles_max": 44,
"shadow_caster_instances": 974,
"shadow_caster_nodes": 577,
"shadow_lights": 1
},
"strategic_view_ms_median": 0.246624349227022
},
"mobile_units_at_start": 120,
"overview": {
"camera_zoom": 92.0,
"cpu_frame_ms_mean": 9.76409421487603,
"draw_calls_mean": 3767.08595041322,
"frame_ms_mean": 9.9180826446281,
"frame_ms_p95": 10.707,
"frames": 605,
"gpu_load": {
"Device Utilization %": 98.4,
"Renderer Utilization %": 98.4,
"Tiler Utilization %": 0.466666666666667,
"gpu_memory_in_use_mb": 2240.109375,
"samples": 15
},
"gpu_ms_estimate": 9.75939332231405,
"gpu_ms_mean": 0.0,
"mobile_units_mean": 95.5107438016529,
"objects_mean": 8439.13719008265,
"primitives_mean": 16528799.1933884,
"process_ms_median": 12.067,
"render_setup_ms_median": 0.2,
"scene_max": {
"lights": 24,
"particle_systems": 2,
"particles_max": 44,
"shadow_caster_instances": 911,
"shadow_caster_nodes": 514,
"shadow_lights": 1
},
"strategic_view_ms_median": 0.253885407647745
},
"process_rss_mb": 785.796875,
"renderer": "forward_plus",
"scene_build_ms": 224.063,
"sim_speed": "2x (40 steps/s)",
"sim_step_ms": 1.779845,
"static_mem_mb": 110.706508636475,
"texture_mem_mb": 399.263427734375,
"video_mem_mb": 821.0,
"window": "(1920, 1080)"
}

View file

@ -0,0 +1,141 @@
{
"base_view": {
"camera_zoom": 26.0,
"cpu_frame_ms_mean": 8.18212916666667,
"draw_calls_mean": 917.081944444444,
"frame_ms_mean": 8.33228194444444,
"frame_ms_p95": 9.005,
"frames": 720,
"gpu_load": {
"Device Utilization %": 76.0666666666667,
"Renderer Utilization %": 73.2666666666667,
"Tiler Utilization %": 19.8666666666667,
"gpu_memory_in_use_mb": 2284.34375,
"samples": 15
},
"gpu_ms_estimate": 6.33808913240741,
"gpu_ms_mean": 0.0,
"mobile_units_mean": 60.0041666666667,
"objects_mean": 3018.08333333333,
"primitives_mean": 2268093.53055556,
"process_ms_median": 9.947,
"render_setup_ms_median": 0.147,
"scene_max": {
"lights": 24,
"particle_systems": 2,
"particles_max": 44,
"shadow_caster_instances": 803,
"shadow_caster_nodes": 406,
"shadow_lights": 1
},
"strategic_view_ms_median": 0.227367466784306
},
"buffer_mem_mb": 173.200626373291,
"buildings": 18,
"cpu": "Apple M5",
"engine": "4.7.2-stable (official)",
"gpu": "Apple M5 (Apple9)",
"grotto_view": {
"camera_zoom": 22.0,
"cpu_frame_ms_mean": 8.19609166666666,
"draw_calls_mean": 1631.97361111111,
"frame_ms_mean": 8.33719027777778,
"frame_ms_p95": 9.045,
"frames": 720,
"gpu_load": {
"Device Utilization %": 90.0666666666667,
"Renderer Utilization %": 89.4,
"Tiler Utilization %": 2.06666666666667,
"gpu_memory_in_use_mb": 2305.640625,
"samples": 15
},
"gpu_ms_estimate": 7.50902937685186,
"gpu_ms_mean": 0.0,
"mobile_units_mean": 31.0888888888889,
"objects_mean": 3885.85972222222,
"primitives_mean": 5093941.47222222,
"process_ms_median": 10.001,
"render_setup_ms_median": 0.142,
"scene_max": {
"lights": 24,
"particle_systems": 2,
"particles_max": 44,
"shadow_caster_instances": 694,
"shadow_caster_nodes": 297,
"shadow_lights": 1
},
"strategic_view_ms_median": 0.221862275993223
},
"lane_view": {
"camera_zoom": 34.0,
"cpu_frame_ms_mean": 9.90605695142379,
"draw_calls_mean": 3508.73534338358,
"frame_ms_mean": 10.0520100502513,
"frame_ms_p95": 11.587,
"frames": 597,
"gpu_load": {
"Device Utilization %": 67.2,
"Renderer Utilization %": 66.4,
"Tiler Utilization %": 0.933333333333333,
"gpu_memory_in_use_mb": 2313.1875,
"samples": 15
},
"gpu_ms_estimate": 6.75495075376885,
"gpu_ms_mean": 0.0,
"mobile_units_mean": 117.450586264657,
"objects_mean": 7597.24958123953,
"primitives_mean": 15956382.2881072,
"process_ms_median": 11.902,
"render_setup_ms_median": 0.256,
"scene_max": {
"lights": 24,
"particle_systems": 2,
"particles_max": 44,
"shadow_caster_instances": 974,
"shadow_caster_nodes": 577,
"shadow_lights": 1
},
"strategic_view_ms_median": 0.249242852776041
},
"mobile_units_at_start": 120,
"overview": {
"camera_zoom": 92.0,
"cpu_frame_ms_mean": 9.82654817275748,
"draw_calls_mean": 3767.78571428571,
"frame_ms_mean": 9.98152491694352,
"frame_ms_p95": 10.778,
"frames": 602,
"gpu_load": {
"Device Utilization %": 92.6666666666667,
"Renderer Utilization %": 92.6666666666667,
"Tiler Utilization %": 0.6,
"gpu_memory_in_use_mb": 2295.828125,
"samples": 15
},
"gpu_ms_estimate": 9.24954642303432,
"gpu_ms_mean": 0.0,
"mobile_units_mean": 95.563122923588,
"objects_mean": 8440.82558139535,
"primitives_mean": 16532393.0631229,
"process_ms_median": 11.807,
"render_setup_ms_median": 0.212,
"scene_max": {
"lights": 24,
"particle_systems": 2,
"particles_max": 44,
"shadow_caster_instances": 911,
"shadow_caster_nodes": 514,
"shadow_lights": 1
},
"strategic_view_ms_median": 0.260997822861227
},
"process_rss_mb": 787.375,
"renderer": "forward_plus",
"scene_build_ms": 190.539,
"sim_speed": "2x (40 steps/s)",
"sim_step_ms": 1.931505,
"static_mem_mb": 110.706504821777,
"texture_mem_mb": 399.263427734375,
"video_mem_mb": 829.21875,
"window": "(1920, 1080)"
}

View file

@ -0,0 +1,174 @@
{
"g1": {
"lane_view": {
"frame_ms_mean": 9.50919545402115,
"frame_ms_p95": 10.9845,
"cpu_frame_ms_mean": 9.37654468369476,
"gpu_ms_estimate": 8.98309312880483,
"draw_calls_mean": 3278.308450269815,
"primitives_mean": 16051271.12141915,
"objects_mean": 7340.2983861261,
"process_ms_median": 11.525500000000001,
"render_setup_ms_median": 0.217,
"util": 94.46666666666665,
"strategic": 0,
"lights": 23,
"shadow_lights": 1,
"casters": 539,
"caster_inst": 946,
"psys": 0,
"pmax": 0
},
"overview": {
"frame_ms_mean": 9.478619395993231,
"frame_ms_p95": 10.291,
"cpu_frame_ms_mean": 9.340653701534945,
"gpu_ms_estimate": 8.19684503097113,
"draw_calls_mean": 3588.0264277882998,
"primitives_mean": 16743896.9883634,
"objects_mean": 8119.359678062396,
"process_ms_median": 11.391,
"render_setup_ms_median": 0.1895,
"util": 86.5,
"strategic": 0,
"lights": 23,
"shadow_lights": 1,
"casters": 476,
"caster_inst": 883,
"psys": 0,
"pmax": 0
},
"base_view": {
"frame_ms_mean": 8.334197823239336,
"frame_ms_p95": 9.051,
"cpu_frame_ms_mean": 8.206294107335491,
"gpu_ms_estimate": 6.000623023419125,
"draw_calls_mean": 793.090429958391,
"primitives_mean": 2349473.756771075,
"objects_mean": 2891.72964439821,
"process_ms_median": 9.7255,
"render_setup_ms_median": 0.1285,
"util": 72.0,
"strategic": 0,
"lights": 23,
"shadow_lights": 1,
"casters": 368,
"caster_inst": 775,
"psys": 0,
"pmax": 0
},
"grotto_view": {
"frame_ms_mean": 8.33357777777778,
"frame_ms_p95": 8.995999999999999,
"cpu_frame_ms_mean": 8.212820833333335,
"gpu_ms_estimate": 7.222434042592595,
"draw_calls_mean": 1543.718055555555,
"primitives_mean": 5032820.14861111,
"objects_mean": 3797.55625,
"process_ms_median": 9.7745,
"render_setup_ms_median": 0.1205,
"util": 86.66666666666666,
"strategic": 0,
"lights": 23,
"shadow_lights": 1,
"casters": 259,
"caster_inst": 666,
"psys": 0,
"pmax": 0
},
"build": 394.5565,
"step": 1.7595925000000001,
"vram": 819.375,
"tex": 410.491943359375,
"static": 97.9973249435425,
"rss": 707.7578125,
"buffer": 170.629276275635,
"gpu_mem": 2185.65625
},
"g2": {
"lane_view": {
"frame_ms_mean": 10.018613178203845,
"frame_ms_p95": 11.543,
"cpu_frame_ms_mean": 9.871958592184441,
"gpu_ms_estimate": 7.98398849945791,
"draw_calls_mean": 3508.62890297299,
"primitives_mean": 15955801.4285794,
"objects_mean": 7597.036604263691,
"process_ms_median": 12.225,
"render_setup_ms_median": 0.252,
"util": 79.73333333333335,
"strategic": 0.2479336010015315,
"lights": 24,
"shadow_lights": 1,
"casters": 577,
"caster_inst": 974,
"psys": 2,
"pmax": 44
},
"overview": {
"frame_ms_mean": 9.94980378078581,
"frame_ms_p95": 10.7425,
"cpu_frame_ms_mean": 9.795321193816754,
"gpu_ms_estimate": 9.504469872674186,
"draw_calls_mean": 3767.4358323494653,
"primitives_mean": 16530596.12825565,
"objects_mean": 8439.981385739,
"process_ms_median": 11.937000000000001,
"render_setup_ms_median": 0.20600000000000002,
"util": 95.53333333333336,
"strategic": 0.257441615254486,
"lights": 24,
"shadow_lights": 1,
"casters": 514,
"caster_inst": 911,
"psys": 2,
"pmax": 44
},
"base_view": {
"frame_ms_mean": 8.332884027777775,
"frame_ms_p95": 9.006,
"cpu_frame_ms_mean": 8.18413541666667,
"gpu_ms_estimate": 6.33854711712963,
"draw_calls_mean": 917.078472222222,
"primitives_mean": 2268093.53055556,
"objects_mean": 3018.08333333333,
"process_ms_median": 9.971499999999999,
"render_setup_ms_median": 0.1475,
"util": 76.0666666666667,
"strategic": 0.2266318702263245,
"lights": 24,
"shadow_lights": 1,
"casters": 406,
"caster_inst": 803,
"psys": 2,
"pmax": 44
},
"grotto_view": {
"frame_ms_mean": 8.33509167148251,
"frame_ms_p95": 9.059000000000001,
"cpu_frame_ms_mean": 8.193719897133604,
"gpu_ms_estimate": 7.50713923211525,
"draw_calls_mean": 1632.156708468175,
"primitives_mean": 5095113.679245645,
"objects_mean": 3886.12403586839,
"process_ms_median": 9.779,
"render_setup_ms_median": 0.1405,
"util": 90.0666666666667,
"strategic": 0.220427389659341,
"lights": 24,
"shadow_lights": 1,
"casters": 297,
"caster_inst": 694,
"psys": 2,
"pmax": 44
},
"build": 207.301,
"step": 1.855675,
"vram": 825.109375,
"tex": 399.263427734375,
"static": 110.706506729126,
"rss": 786.5859375,
"buffer": 173.200626373291,
"gpu_mem": 2197.9140625
}
}

View file

@ -0,0 +1,21 @@
{
"adapter": "Apple M5 (Apple9)",
"ancient_views": 2,
"architecture": "arm64",
"boss_model": true,
"combat_seconds": 44.8999999999996,
"driver": "metal",
"engine": "4.7.2-stable (official)",
"fps_at_end": 81.0,
"fps_in_combat": 120.0,
"grotto_state": "verfuegbar",
"missing_assets": [],
"os": "macOS",
"os_version": "27.0.0",
"phase": 0,
"renderer": "forward_plus",
"result": "PASS",
"seconds": 40.923,
"tower_views": 16,
"window": "(1920, 1080)"
}

View file

@ -0,0 +1,21 @@
{
"adapter": "Apple M5",
"ancient_views": 2,
"architecture": "x86_64",
"boss_model": true,
"combat_seconds": 44.8999999999996,
"driver": "vulkan",
"engine": "4.7.2-stable (official)",
"fps_at_end": 47.0,
"fps_in_combat": 118.0,
"grotto_state": "verfuegbar",
"missing_assets": [],
"os": "macOS",
"os_version": "27.0.0",
"phase": 0,
"renderer": "forward_plus",
"result": "PASS",
"seconds": 23.837,
"tower_views": 16,
"window": "(1920, 1080)"
}

View file

@ -0,0 +1,21 @@
{
"adapter": "Parallels using Apple M5 (Compat)",
"ancient_views": 2,
"architecture": "arm64",
"boss_model": true,
"combat_seconds": 44.8999999999996,
"driver": "opengl3",
"engine": "4.7.2-stable (official)",
"fps_at_end": 69.0,
"fps_in_combat": 65.0,
"grotto_state": "verfuegbar",
"missing_assets": [],
"os": "Windows",
"os_version": "10.0.26100",
"phase": 0,
"renderer": "gl_compatibility",
"result": "PASS",
"seconds": 24.859,
"tower_views": 16,
"window": "(1440, 857)"
}

View file

@ -0,0 +1,21 @@
{
"adapter": "Parallels using Apple M5 (Compat)",
"ancient_views": 2,
"architecture": "x86_64",
"boss_model": true,
"combat_seconds": 44.8999999999996,
"driver": "opengl3",
"engine": "4.7.2-stable (official)",
"fps_at_end": 61.0,
"fps_in_combat": 46.0,
"grotto_state": "verfuegbar",
"missing_assets": [],
"os": "Windows",
"os_version": "10.0.26100",
"phase": 0,
"renderer": "gl_compatibility",
"result": "PASS",
"seconds": 24.648,
"tower_views": 16,
"window": "(1440, 857)"
}

View file

@ -0,0 +1,27 @@
Godot Engine v4.7.2.stable.official.ed1daf0bf - https://godotengine.org
BALANCE 00 ["ausgewogen", "druck", "teamhilfe"] | ["teamhilfe", "verteidigung", "ausgewogen"] -> blau (12) / swapped rot (12)
BALANCE 01 ["bossjagd", "bossjagd", "teamhilfe"] | ["fernkampf", "teamhilfe", "fernkampf"] -> rot (9) / swapped blau (9)
BALANCE 02 ["bossjagd", "ausgewogen", "teamhilfe"] | ["druck", "ausgewogen", "verteidigung"] -> rot (11) / swapped blau (11)
BALANCE 03 ["fernkampf", "druck", "ausgewogen"] | ["druck", "fernkampf", "verteidigung"] -> blau (12) / swapped rot (12)
BALANCE 04 ["ausgewogen", "teamhilfe", "fernkampf"] | ["ausgewogen", "verteidigung", "fernkampf"] -> blau (11) / swapped rot (11)
BALANCE 05 ["teamhilfe", "fernkampf", "verteidigung"] | ["fernkampf", "bossjagd", "bossjagd"] -> blau (11) / swapped rot (11)
BALANCE 06 ["bossjagd", "ausgewogen", "teamhilfe"] | ["fernkampf", "teamhilfe", "ausgewogen"] -> blau (9) / swapped rot (9)
BALANCE 07 ["ausgewogen", "ausgewogen", "ausgewogen"] | ["teamhilfe", "teamhilfe", "ausgewogen"] -> blau (12) / swapped rot (12)
BALANCE 08 ["teamhilfe", "ausgewogen", "bossjagd"] | ["druck", "ausgewogen", "druck"] -> rot (12) / swapped blau (12)
BALANCE 09 ["verteidigung", "druck", "druck"] | ["bossjagd", "ausgewogen", "teamhilfe"] -> blau (11) / swapped rot (11)
BALANCE 10 ["druck", "ausgewogen", "ausgewogen"] | ["teamhilfe", "bossjagd", "ausgewogen"] -> blau (12) / swapped rot (12)
BALANCE 11 ["bossjagd", "fernkampf", "fernkampf"] | ["teamhilfe", "verteidigung", "teamhilfe"] -> rot (10) / swapped blau (10)
BALANCE 12 ["fernkampf", "fernkampf", "teamhilfe"] | ["teamhilfe", "ausgewogen", "fernkampf"] -> rot (12) / swapped blau (12)
BALANCE 13 ["teamhilfe", "druck", "fernkampf"] | ["fernkampf", "druck", "ausgewogen"] -> rot (13) / swapped blau (13)
BALANCE 14 ["bossjagd", "druck", "fernkampf"] | ["bossjagd", "bossjagd", "bossjagd"] -> blau (10) / swapped rot (10)
BALANCE 15 ["fernkampf", "fernkampf", "verteidigung"] | ["druck", "fernkampf", "ausgewogen"] -> blau (12) / swapped rot (12)
BALANCE 16 ["teamhilfe", "verteidigung", "verteidigung"] | ["teamhilfe", "verteidigung", "ausgewogen"] -> rot (13) / swapped blau (13)
BALANCE 17 ["fernkampf", "verteidigung", "fernkampf"] | ["druck", "fernkampf", "bossjagd"] -> blau (10) / swapped rot (10)
BALANCE 18 ["verteidigung", "ausgewogen", "bossjagd"] | ["druck", "teamhilfe", "druck"] -> blau (9) / swapped rot (9)
BALANCE 19 ["druck", "teamhilfe", "teamhilfe"] | ["fernkampf", "verteidigung", "bossjagd"] -> rot (10) / swapped blau (10)
WARNING: 38 ObjectDB instances were leaked at exit (run with `--verbose` for details).
at: cleanup (core/object/object.cpp:2536)
ERROR: 1 resources still in use at exit (run with --verbose for details).
at: clear (core/io/resource.cpp:822)
exit 0 after 344 s

View file

@ -0,0 +1,5 @@
Godot Engine v4.7.2.stable.official.ed1daf0bf - https://godotengine.org
LUNE_FULL_MATCH: seed 2 -> Rot after 9 rounds
LUNE_CORE_PASS: 91 checks; 0 failures
exit 0 after 21 s

View file

@ -0,0 +1,4 @@
Godot Engine v4.7.2.stable.official.ed1daf0bf - https://godotengine.org
PASS: 209 checks across economy, combat and the full match lifecycle.
exit 0 after 0 s

View file

@ -0,0 +1,5 @@
Godot Engine v4.7.2.stable.official.ed1daf0bf - https://godotengine.org
EXPORT_SMOKE_PASS: 463 checks; 0 failures; engine 4.7.2-stable (official); cores [3000, 1035]
SMOKE_CAPTURES: /Users/martin/Library/Application Support/Godot/app_userdata/Champions of Luneraria/phase8-smoke
exit 0 after 5 s

View file

@ -0,0 +1,8 @@
Godot Engine v4.7.2.stable.official.ed1daf0bf - https://godotengine.org
LUNE_M2_PASS: 73 checks; 0 failures
WARNING: 8 ObjectDB instances were leaked at exit (run with `--verbose` for details).
at: cleanup (core/object/object.cpp:2536)
ERROR: 1 resources still in use at exit (run with --verbose for details).
at: clear (core/io/resource.cpp:822)
exit 0 after 14 s

View file

@ -0,0 +1,75 @@
{
"rounds": 5,
"drop_round": 2,
"compared_rounds": [
1,
2,
3,
4,
5
],
"mismatches": [],
"host_sums": {
"1": "1392179697",
"2": "3342070386",
"3": "711640101",
"4": "346458639",
"5": "254645978"
},
"guest_sums": {
"1": "1392179697",
"2": "3342070386",
"3": "711640101",
"4": "346458639",
"5": "254645978"
},
"client_combats": [
{
"round": 1,
"frames": 4,
"local_steps": 0
},
{
"round": 2,
"frames": 26,
"local_steps": 0
},
{
"round": 3,
"frames": 39,
"local_steps": 0
},
{
"round": 4,
"frames": 51,
"local_steps": 0
},
{
"round": 5,
"frames": 9,
"local_steps": 0
}
],
"seats": {
"gast_a": [
1
],
"gast_b": [
1
]
},
"checks": {
"host_ok": true,
"guest_a_ok": true,
"guest_b_ok": true,
"spoof_rejected": true,
"rounds_compared": true,
"checksums_equal": true,
"client_never_simulates": true,
"client_receives_frames": true,
"rejoin_same_seat": true,
"bot_took_over": true,
"rejoin_announced": true,
"no_script_errors": true
}
}

View file

@ -0,0 +1,16 @@
{
"host_ok": true,
"guest_a_ok": true,
"guest_b_ok": true,
"spoof_rejected": true,
"rounds_compared": true,
"checksums_equal": true,
"client_never_simulates": true,
"client_receives_frames": true,
"rejoin_same_seat": true,
"bot_took_over": true,
"rejoin_announced": true,
"no_script_errors": true
}
NET_ROUNDS_PASS: 12/12
exit 0 after 16 s

View file

@ -0,0 +1,4 @@
Godot Engine v4.7.2.stable.official.ed1daf0bf - https://godotengine.org
LUNE_NET_PASS: 43 checks; 0 failures
exit 0 after 1 s

View file

@ -0,0 +1,79 @@
{
"checks": 17,
"failures": [],
"high_index": [
{
"end": "core",
"monster_hp": 12763.92,
"peak_mobile": 120,
"phase": "vorbei",
"steps": 2306,
"wall_ms": 1728,
"wave": 100
},
{
"end": "core",
"monster_hp": 771787.92,
"peak_mobile": 120,
"phase": "vorbei",
"steps": 1484,
"wall_ms": 1334,
"wave": 1000
},
{
"end": "core",
"monster_hp": 72514027.92,
"peak_mobile": 120,
"phase": "vorbei",
"steps": 1459,
"wall_ms": 1343,
"wave": 10000
},
{
"end": "core",
"monster_hp": 720051360427.92,
"peak_mobile": 120,
"phase": "vorbei",
"steps": 1456,
"wall_ms": 1328,
"wave": 1000000
}
],
"long_run": {
"final_gold": [
141844,
141844,
141844,
141894,
141894,
141894
],
"final_wave_monster_hp": 39840.0,
"memory_mb_every25": [
51.3685665130615,
52.5444202423096,
52.5169200897217,
52.5596561431885,
52.6210994720459,
52.5991535186768,
52.6706295013428,
52.7292499542236,
52.7079830169678
],
"objects_every25": [
1704.0,
1849.0,
1843.0,
1843.0,
1849.0,
1843.0,
1843.0,
1849.0,
1843.0
],
"result": "Testlimit erreicht / Partie nicht abgeschlossen",
"round_ms_first10": 583.8408,
"round_ms_last10": 1459.945,
"rounds": 200
}
}

View file

@ -0,0 +1,21 @@
Godot Engine v4.7.2.stable.official.ed1daf0bf - https://godotengine.org
STRESS_WAVE {"end":"core","monster_hp":12763.92,"peak_mobile":120,"phase":"vorbei","steps":2306,"wall_ms":1728,"wave":100}
STRESS_WAVE {"end":"core","monster_hp":771787.92,"peak_mobile":120,"phase":"vorbei","steps":1484,"wall_ms":1334,"wave":1000}
STRESS_WAVE {"end":"core","monster_hp":72514027.92,"peak_mobile":120,"phase":"vorbei","steps":1459,"wall_ms":1343,"wave":10000}
STRESS_WAVE {"end":"core","monster_hp":720051360427.92,"peak_mobile":120,"phase":"vorbei","steps":1456,"wall_ms":1328,"wave":1000000}
STRESS_PROGRESS round 26 · 1721 ms · 52.5 MB · 1849 objects
STRESS_PROGRESS round 51 · 1393 ms · 52.5 MB · 1843 objects
STRESS_PROGRESS round 76 · 1421 ms · 52.6 MB · 1843 objects
STRESS_PROGRESS round 101 · 1975 ms · 52.6 MB · 1849 objects
STRESS_PROGRESS round 126 · 1469 ms · 52.6 MB · 1843 objects
STRESS_PROGRESS round 151 · 1368 ms · 52.7 MB · 1843 objects
STRESS_PROGRESS round 176 · 1723 ms · 52.7 MB · 1849 objects
STRESS_PROGRESS round 201 · 1208 ms · 52.7 MB · 1843 objects
STRESS_LONG {"final_gold":[141844,141844,141844,141894,141894,141894],"final_wave_monster_hp":39840.0,"memory_mb_every25":[51.3685665130615,52.5444202423096,52.5169200897217,52.5596561431885,52.6210994720459,52.5991535186768,52.6706295013428,52.7292499542236,52.7079830169678],"objects_every25":[1704.0,1849.0,1843.0,1843.0,1849.0,1843.0,1843.0,1849.0,1843.0],"result":"Testlimit erreicht / Partie nicht abgeschlossen","round_ms_first10":583.8408,"round_ms_last10":1459.945,"rounds":200}
STRESS_PASS: 17 checks; 0 failures
WARNING: 4 ObjectDB instances were leaked at exit (run with `--verbose` for details).
at: cleanup (core/object/object.cpp:2536)
ERROR: 1 resources still in use at exit (run with --verbose for details).
at: clear (core/io/resource.cpp:822)
exit 0 after 303 s

View file

@ -0,0 +1,40 @@
{
"checks": 443,
"facts": {
"grotto_clearance_to_lanes": 3.0,
"lane_height_range": {
"max": {
"bot": 0.600000023841858,
"mid": 0.620000004768372,
"top": 3.29657602310181
},
"min": {
"bot": -0.159999996423721,
"mid": 0.114149838685989,
"top": 0.081579864025116
}
},
"lane_lengths": {
"bot": 96.82,
"mid": 61.16,
"top": 96.82
},
"overview": {
"distance": 150.459213256836,
"pitch_deg": -69.4643032570342,
"zoom": 92.0
},
"pitch_range_deg": [
-48.000003795596,
-69.9999992757703
],
"terrain_mirror_error": 0.0,
"unit_ground_error": 0.0000000750818887063076,
"water_points_under_lanes": {
"bot": 16,
"mid": 8,
"top": 0
}
},
"failures": []
}

View file

@ -0,0 +1,9 @@
Godot Engine v4.7.2.stable.official.ed1daf0bf - https://godotengine.org
Metal 4.0 - Forward+ - Using Device #0: Apple - Apple M5 (Apple9)
G1_VERIFY_PASS: 443 checks; 0 failures
WARNING: 14 ObjectDB instances were leaked at exit (run with `--verbose` for details).
at: cleanup (core/object/object.cpp:2536)
ERROR: 1 resources still in use at exit (run with --verbose for details).
at: clear (core/io/resource.cpp:822)
exit 0 after 7 s

View file

@ -0,0 +1,18 @@
Godot Engine v4.7.2.stable.official.ed1daf0bf - https://godotengine.org
Metal 4.0 - Forward+ - Using Device #0: Apple - Apple M5 (Apple9)
G2_PART A assets
G2_PART B views
G2_PART C states
G2_PART D tower attacks
G2_PART E rules
G2_PART F boss
G2_PART G no feedback
G2_PART H bot matches
G2_PART I occlusion and scale
G2_VERIFY_PASS: 245 checks; 0 failures
WARNING: 40 ObjectDB instances were leaked at exit (run with `--verbose` for details).
at: cleanup (core/object/object.cpp:2536)
ERROR: 1 resources still in use at exit (run with --verbose for details).
at: clear (core/io/resource.cpp:822)
exit 0 after 79 s

View file

@ -0,0 +1,10 @@
{
"checks": 38,
"failures": [],
"object_counts": [
4533.0,
4533.0,
4533.0,
4533.0
]
}

View file

@ -0,0 +1,19 @@
Godot Engine v4.7.2.stable.official.ed1daf0bf - https://godotengine.org
Metal 4.0 - Forward+ - Using Device #0: Apple - Apple M5 (Apple9)
M1_MENU_READY_MS 966
M1_SHOT planning-1280x720
M1_SHOT planning-1920x1080
M1_SHOT combat-mid-live
M1_SHOT settlement
M1_SHOT combat-top
M1_SHOT overview-combat
M1_SHOT core-siege
M1_SHOT result
M1_MATCH_RESULT Ancient von Blau zerstört. after 11 waves
M1_VERIFY_PASS: 38 checks; 0 failures
WARNING: 4 ObjectDB instances were leaked at exit (run with `--verbose` for details).
at: cleanup (core/object/object.cpp:2536)
ERROR: 1 resources still in use at exit (run with --verbose for details).
at: clear (core/io/resource.cpp:822)
exit 0 after 25 s

View file

@ -0,0 +1,19 @@
{
"boss_log": [
{
"chooser": 4,
"event": "won",
"round": 12,
"team": 1
}
],
"checks": 14,
"failures": [],
"rounds": 12,
"seen": [
"announce",
"hero_field",
"hero_items",
"grotto"
]
}

View file

@ -0,0 +1,15 @@
Godot Engine v4.7.2.stable.official.ed1daf0bf - https://godotengine.org
Metal 4.0 - Forward+ - Using Device #0: Apple - Apple M5 (Apple9)
M2APP_SHOT m2-hud-hero-round1
M2APP_SHOT m2-announcements
M2APP_SHOT m2-hero-field
M2APP_SHOT m2-hero-items
M2APP_SHOT m2-grotto-fight
M2APP_BOSS_LOG [{ "round": 12, "event": "won", "team": 1, "chooser": 4 }]
M2APP_VERIFY_PASS: 14 checks; 0 failures
WARNING: 4 ObjectDB instances were leaked at exit (run with `--verbose` for details).
at: cleanup (core/object/object.cpp:2536)
ERROR: 1 resources still in use at exit (run with --verbose for details).
at: clear (core/io/resource.cpp:822)
exit 0 after 21 s

View file

@ -0,0 +1,80 @@
{
"checks": 245,
"facts": {
"assets": [
"ancient",
"bastion",
"bossgrotte",
"grenzwacht",
"kernwacht",
"schlundkrone"
],
"boss_bones": 24,
"boss_lanes": {
"bot": true,
"mid": true,
"top": true
},
"boss_outcome": "success",
"bot_matches": [
{
"rounds": 11,
"seed": 21,
"winner": 1
},
{
"rounds": 12,
"seed": 22,
"winner": 1
},
{
"rounds": 9,
"seed": 23,
"winner": 0
},
{
"rounds": 11,
"seed": 24,
"winner": 0
}
],
"checks_per_part": {
"A assets": 113,
"B views": 28,
"C states": 32,
"D tower attacks": 6,
"E rules": 8,
"F boss": 34,
"G no feedback": 3,
"H bot matches": 7,
"I occlusion and scale": 14
},
"heights_m": {
"ancient": 10.3668,
"bastion": 7.1605,
"boss": 5.64094,
"champion": 1.81999981403351,
"grenzwacht": 6.0601,
"hero": 2.31619253754616,
"kernwacht": 8.33,
"troop": 1.34999990463257
},
"local_lights": 20,
"no_feedback_bolts": 74,
"particle_systems": 80,
"seconds": 77.589,
"shown_match": {
"fallen_towers_shown": 9,
"rounds": 12,
"winner": 1
},
"tower_bolts_12s": 14,
"tower_hits_12s": 13,
"tower_views": {
"base": 4,
"inner": 6,
"outer": 6
}
},
"failures": []
}

View file

@ -0,0 +1,43 @@
Godot Engine v4.7.2.stable.official.ed1daf0bf - https://godotengine.org
ERROR: Required Vulkan instance extension VK_KHR_surface not found.
at: _initialize_instance_extensions (drivers/vulkan/rendering_context_driver_vulkan.cpp:494)
ERROR: Condition "err != OK" is true. Returning: err
at: initialize (drivers/vulkan/rendering_context_driver_vulkan.cpp:915)
ERROR: No support for any of the suitable shader models (6.0-6.6) has been found.
at: _check_capabilities (drivers/d3d12/rendering_device_driver_d3d12.cpp:6155)
ERROR: Condition "err != OK" is true. Returning: ERR_CANT_CREATE
at: initialize (drivers/d3d12/rendering_device_driver_d3d12.cpp:6402)
ERROR: Failed to initialize driver for device.
at: initialize (servers/rendering/rendering_device.cpp:8376)
WARNING: Your video card drivers seem not to support Direct3D 12 or Vulkan, switching to OpenGL 3.
at: DisplayServerWindows (platform/windows/display_server_windows.cpp:8136)
OpenGL API 4.1 Metal - 91.7 - Compatibility - Using Device: Parallels and Apple - Parallels using Apple M5 (Compat)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
LUNE_SMOKE_SHOT C:/Users/martin/LuneTest/smoke-arm64/00-menue.png
LUNE_SMOKE_SHOT C:/Users/martin/LuneTest/smoke-arm64/01-planung.png
LUNE_SMOKE_SHOT C:/Users/martin/LuneTest/smoke-arm64/02-kampf.png
LUNE_SMOKE_SHOT C:/Users/martin/LuneTest/smoke-arm64/03-grotte.png
LUNE_SMOKE_SHOT C:/Users/martin/LuneTest/smoke-arm64/04-ancient.png
LUNE_SMOKE_PASS {"adapter":"Parallels using Apple M5 (Compat)","ancient_views":2,"architecture":"arm64","boss_model":true,"combat_seconds":44.8999999999996,"driver":"opengl3","engine":"4.7.2-stable (official)","fps_at_end":69.0,"fps_in_combat":65.0,"grotto_state":"verfuegbar","missing_assets":[],"os":"Windows","os_version":"10.0.26100","phase":0,"renderer":"gl_compatibility","result":"PASS","seconds":24.859,"tower_views":16,"window":"(1440, 857)"}

View file

@ -0,0 +1,43 @@
Godot Engine v4.7.2.stable.official.ed1daf0bf - https://godotengine.org
ERROR: Required Vulkan instance extension VK_KHR_surface not found.
at: _initialize_instance_extensions (drivers/vulkan/rendering_context_driver_vulkan.cpp:494)
ERROR: Condition "err != OK" is true. Returning: err
at: initialize (drivers/vulkan/rendering_context_driver_vulkan.cpp:915)
ERROR: No support for any of the suitable shader models (6.0-6.6) has been found.
at: _check_capabilities (drivers/d3d12/rendering_device_driver_d3d12.cpp:6154)
ERROR: Condition "err != OK" is true. Returning: ERR_CANT_CREATE
at: initialize (drivers/d3d12/rendering_device_driver_d3d12.cpp:6402)
ERROR: Failed to initialize driver for device.
at: initialize (servers/rendering/rendering_device.cpp:8376)
WARNING: Your video card drivers seem not to support Direct3D 12 or Vulkan, switching to OpenGL 3.
at: DisplayServerWindows (platform/windows/display_server_windows.cpp:8136)
OpenGL API 4.1 Metal - 91.7 - Compatibility - Using Device: Parallels and Apple - Parallels using Apple M5 (Compat)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
WARNING: Image format BPTC_RGBA not supported by hardware, converting to RGBA8.
at: _get_gl_image_and_format (drivers/gles3/storage/texture_storage.cpp:987)
LUNE_SMOKE_SHOT C:/Users/martin/LuneTest/smoke-x64/00-menue.png
LUNE_SMOKE_SHOT C:/Users/martin/LuneTest/smoke-x64/01-planung.png
LUNE_SMOKE_SHOT C:/Users/martin/LuneTest/smoke-x64/02-kampf.png
LUNE_SMOKE_SHOT C:/Users/martin/LuneTest/smoke-x64/03-grotte.png
LUNE_SMOKE_SHOT C:/Users/martin/LuneTest/smoke-x64/04-ancient.png
LUNE_SMOKE_PASS {"adapter":"Parallels using Apple M5 (Compat)","ancient_views":2,"architecture":"x86_64","boss_model":true,"combat_seconds":44.8999999999996,"driver":"opengl3","engine":"4.7.2-stable (official)","fps_at_end":61.0,"fps_in_combat":46.0,"grotto_state":"verfuegbar","missing_assets":[],"os":"Windows","os_version":"10.0.26100","phase":0,"renderer":"gl_compatibility","result":"PASS","seconds":24.648,"tower_views":16,"window":"(1440, 857)"}

View file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

Some files were not shown because too many files have changed in this diff Show more