diff --git a/.gitignore b/.gitignore index 2c727e3..4b8bb74 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ *.tmp docs/screenshots/ test-results/ +__pycache__/ +.DS_Store +override.cfg diff --git a/assets-source/luneraria/g2/ancient.blend b/assets-source/luneraria/g2/ancient.blend new file mode 100644 index 0000000..ff56848 Binary files /dev/null and b/assets-source/luneraria/g2/ancient.blend differ diff --git a/assets-source/luneraria/g2/bastion.blend b/assets-source/luneraria/g2/bastion.blend new file mode 100644 index 0000000..a0162b2 Binary files /dev/null and b/assets-source/luneraria/g2/bastion.blend differ diff --git a/assets-source/luneraria/g2/bossgrotte.blend b/assets-source/luneraria/g2/bossgrotte.blend new file mode 100644 index 0000000..f99f7af Binary files /dev/null and b/assets-source/luneraria/g2/bossgrotte.blend differ diff --git a/assets-source/luneraria/g2/build_ancient.py b/assets-source/luneraria/g2/build_ancient.py new file mode 100644 index 0000000..88d9f39 --- /dev/null +++ b/assets-source/luneraria/g2/build_ancient.py @@ -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() diff --git a/assets-source/luneraria/g2/build_bossgrotte.py b/assets-source/luneraria/g2/build_bossgrotte.py new file mode 100644 index 0000000..aeaf0a8 --- /dev/null +++ b/assets-source/luneraria/g2/build_bossgrotte.py @@ -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() diff --git a/assets-source/luneraria/g2/build_schlundkrone.py b/assets-source/luneraria/g2/build_schlundkrone.py new file mode 100644 index 0000000..83faec0 --- /dev/null +++ b/assets-source/luneraria/g2/build_schlundkrone.py @@ -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(' 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('.zip` und `…-Windows-x64-.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-.zip` +- `…-Windows-x64-.zip` +- `…-Windows-ARM64-.zip` +- `…-macOS-universal-.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. @@ -97,4 +152,4 @@ godot --headless --path . res://tools/luneraria/net_pair.tscn -- --role=client - ## 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. diff --git a/docs/luneraria/ARCHITEKTUR.md b/docs/luneraria/ARCHITEKTUR.md index 4fd32b0..909ca0d 100644 --- a/docs/luneraria/ARCHITEKTUR.md +++ b/docs/luneraria/ARCHITEKTUR.md @@ -5,7 +5,7 @@ Stand: 27. September 2026 (M0). ## 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. -- **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`. - **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_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. +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. diff --git a/docs/luneraria/G2-ASSETS.md b/docs/luneraria/G2-ASSETS.md new file mode 100644 index 0000000..0273b1a --- /dev/null +++ b/docs/luneraria/G2-ASSETS.md @@ -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`. diff --git a/docs/luneraria/G2-STRATEGISCHE-BAUWERKE.md b/docs/luneraria/G2-STRATEGISCHE-BAUWERKE.md new file mode 100644 index 0000000..333f90a --- /dev/null +++ b/docs/luneraria/G2-STRATEGISCHE-BAUWERKE.md @@ -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. diff --git a/docs/luneraria/QA-MATRIX.md b/docs/luneraria/QA-MATRIX.md index 6d5ba9b..c3e2a4f 100644 --- a/docs/luneraria/QA-MATRIX.md +++ b/docs/luneraria/QA-MATRIX.md @@ -1,6 +1,10 @@ # 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. @@ -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` | | 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) 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 | | --- | --- | -| 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) | -| 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 | diff --git a/docs/luneraria/STATUS.md b/docs/luneraria/STATUS.md index b7d2122..749782c 100644 --- a/docs/luneraria/STATUS.md +++ b/docs/luneraria/STATUS.md @@ -1,6 +1,8 @@ # 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 | | --- | --- | @@ -8,9 +10,10 @@ Stand: 29. September 2026 · Branches `luneraria/m1` (M0/M1), `luneraria/m2`, `l | M1 · Spielbarer Drei-Lane-Kern | **abgeschlossen** | | M2 · Mechanikdurchlauf | **abgeschlossen** | | 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)) | -| 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 @@ -91,8 +94,8 @@ Stand: 29. September 2026 · Branches `luneraria/m1` (M0/M1), `luneraria/m2`, `l ## Blockiert -- Nativer Windows-11-Test: keine Windows-Hardware. -- Kompatibilitätsmodus (OpenGL) für Luneraria: erst mit M4-Exporten gezielt prüfen. +- 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. +- Notarisierung des macOS-Pakets: kein Apple-Entwicklerkonto; die App ist ad-hoc signiert. ## 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. **Offen (eingefroren)** -- Kompatibilitätsmodus (OpenGL) für Luneraria. -- Nativer Windows-11-Test (blockiert: keine Hardware). -- Neubau der Demo-Pakete mit der umgebauten Sitzung. +- 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. *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. *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). - 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. @@ -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. +## 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 -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. diff --git a/docs/luneraria/logs/g2/balance-summary.txt b/docs/luneraria/logs/g2/balance-summary.txt new file mode 100644 index 0000000..6c5a001 --- /dev/null +++ b/docs/luneraria/logs/g2/balance-summary.txt @@ -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 diff --git a/docs/luneraria/logs/g2/balance.json b/docs/luneraria/logs/g2/balance.json new file mode 100644 index 0000000..c0f0b19 --- /dev/null +++ b/docs/luneraria/logs/g2/balance.json @@ -0,0 +1,1756 @@ +[ + { + "bunds": [ + "wurzelbund", + "glutpakt", + "wurzelbund", + "wurzelbund", + "wurzelbund", + "wurzelbund" + ], + "index": 0, + "matchup": false, + "normal": { + "boss": [], + "hero_levels": [ + 5, + 5, + 4, + 3, + 2, + 3 + ], + "lane_losses_at_end": [ + 1, + 1 + ], + "max_combat": 110.849999999996, + "result": "blau", + "rounds": 12, + "timeouts": 0, + "total_gold_curve": [ + 743, + 745, + 895, + 762, + 958, + 923, + 1005, + 1115, + 1338, + 1570, + 2436, + 1501 + ] + }, + "profiles": [ + "ausgewogen", + "druck", + "teamhilfe", + "teamhilfe", + "verteidigung", + "ausgewogen" + ], + "seed": 1000, + "swapped": { + "boss": [], + "hero_levels": [ + 3, + 2, + 3, + 5, + 5, + 4 + ], + "lane_losses_at_end": [ + 1, + 1 + ], + "max_combat": 110.849999999996, + "result": "rot", + "rounds": 12, + "timeouts": 0, + "total_gold_curve": [ + 743, + 745, + 895, + 762, + 958, + 923, + 1005, + 1115, + 1338, + 1570, + 2436, + 1501 + ] + } + }, + { + "bunds": [ + "wurzelbund", + "glutpakt", + "wurzelbund", + "wurzelbund", + "wurzelbund", + "glutpakt" + ], + "index": 1, + "matchup": false, + "normal": { + "boss": [ + "9:won:0" + ], + "hero_levels": [ + 2, + 2, + 3, + 3, + 3, + 3 + ], + "lane_losses_at_end": [ + 1, + 0 + ], + "max_combat": 100.049999999996, + "result": "rot", + "rounds": 9, + "timeouts": 0, + "total_gold_curve": [ + 843, + 750, + 970, + 812, + 1018, + 1143, + 1125, + 2044, + 1020 + ] + }, + "profiles": [ + "bossjagd", + "bossjagd", + "teamhilfe", + "fernkampf", + "teamhilfe", + "fernkampf" + ], + "seed": 1001, + "swapped": { + "boss": [ + "9:won:1" + ], + "hero_levels": [ + 3, + 3, + 3, + 2, + 2, + 3 + ], + "lane_losses_at_end": [ + 0, + 1 + ], + "max_combat": 100.049999999996, + "result": "blau", + "rounds": 9, + "timeouts": 0, + "total_gold_curve": [ + 843, + 750, + 970, + 812, + 1018, + 1143, + 1125, + 2044, + 1020 + ] + } + }, + { + "bunds": [ + "glutpakt", + "wurzelbund", + "wurzelbund", + "wurzelbund", + "glutpakt", + "glutpakt" + ], + "index": 2, + "matchup": false, + "normal": { + "boss": [ + "10:won:0", + "11:deployed:0" + ], + "hero_levels": [ + 3, + 0, + 2, + 2, + 3, + 3 + ], + "lane_losses_at_end": [ + 3, + 1 + ], + "max_combat": 107.549999999996, + "result": "rot", + "rounds": 11, + "timeouts": 0, + "total_gold_curve": [ + 753, + 780, + 920, + 892, + 968, + 963, + 1095, + 1390, + 1492, + 2324, + 2280 + ] + }, + "profiles": [ + "bossjagd", + "ausgewogen", + "teamhilfe", + "druck", + "ausgewogen", + "verteidigung" + ], + "seed": 1002, + "swapped": { + "boss": [ + "10:won:1", + "11:deployed:1" + ], + "hero_levels": [ + 2, + 3, + 3, + 3, + 0, + 2 + ], + "lane_losses_at_end": [ + 1, + 3 + ], + "max_combat": 107.549999999996, + "result": "blau", + "rounds": 11, + "timeouts": 0, + "total_gold_curve": [ + 753, + 780, + 920, + 892, + 968, + 963, + 1095, + 1390, + 1492, + 2324, + 2280 + ] + } + }, + { + "bunds": [ + "glutpakt", + "wurzelbund", + "wurzelbund", + "wurzelbund", + "wurzelbund", + "wurzelbund" + ], + "index": 3, + "matchup": false, + "normal": { + "boss": [], + "hero_levels": [ + 7, + 5, + 4, + 4, + 6, + 3 + ], + "lane_losses_at_end": [ + 1, + 2 + ], + "max_combat": 180.000000000007, + "result": "blau", + "rounds": 12, + "timeouts": 1, + "total_gold_curve": [ + 838, + 695, + 865, + 907, + 953, + 963, + 1140, + 1420, + 1496, + 2068, + 2361, + 702 + ] + }, + "profiles": [ + "fernkampf", + "druck", + "ausgewogen", + "druck", + "fernkampf", + "verteidigung" + ], + "seed": 1003, + "swapped": { + "boss": [], + "hero_levels": [ + 4, + 6, + 3, + 7, + 5, + 4 + ], + "lane_losses_at_end": [ + 2, + 1 + ], + "max_combat": 180.000000000007, + "result": "rot", + "rounds": 12, + "timeouts": 1, + "total_gold_curve": [ + 838, + 695, + 865, + 907, + 953, + 963, + 1140, + 1420, + 1496, + 2068, + 2361, + 702 + ] + } + }, + { + "bunds": [ + "wurzelbund", + "wurzelbund", + "wurzelbund", + "glutpakt", + "glutpakt", + "glutpakt" + ], + "index": 4, + "matchup": false, + "normal": { + "boss": [], + "hero_levels": [ + 5, + 4, + 4, + 3, + 6, + 6 + ], + "lane_losses_at_end": [ + 1, + 1 + ], + "max_combat": 101.099999999996, + "result": "blau", + "rounds": 11, + "timeouts": 0, + "total_gold_curve": [ + 848, + 725, + 975, + 827, + 978, + 1108, + 1315, + 1511, + 1722, + 1604, + 1447 + ] + }, + "profiles": [ + "ausgewogen", + "teamhilfe", + "fernkampf", + "ausgewogen", + "verteidigung", + "fernkampf" + ], + "seed": 1004, + "swapped": { + "boss": [], + "hero_levels": [ + 3, + 6, + 6, + 5, + 4, + 4 + ], + "lane_losses_at_end": [ + 1, + 1 + ], + "max_combat": 101.099999999996, + "result": "rot", + "rounds": 11, + "timeouts": 0, + "total_gold_curve": [ + 848, + 725, + 975, + 827, + 978, + 1108, + 1315, + 1511, + 1722, + 1604, + 1447 + ] + } + }, + { + "bunds": [ + "glutpakt", + "wurzelbund", + "glutpakt", + "wurzelbund", + "wurzelbund", + "glutpakt" + ], + "index": 5, + "matchup": false, + "normal": { + "boss": [], + "hero_levels": [ + 6, + 6, + 5, + 0, + 0, + 0 + ], + "lane_losses_at_end": [ + 0, + 3 + ], + "max_combat": 113.349999999996, + "result": "blau", + "rounds": 11, + "timeouts": 0, + "total_gold_curve": [ + 853, + 785, + 985, + 837, + 1008, + 1028, + 1115, + 1480, + 1823, + 1720, + 1615 + ] + }, + "profiles": [ + "teamhilfe", + "fernkampf", + "verteidigung", + "fernkampf", + "bossjagd", + "bossjagd" + ], + "seed": 1005, + "swapped": { + "boss": [], + "hero_levels": [ + 0, + 0, + 0, + 6, + 6, + 5 + ], + "lane_losses_at_end": [ + 3, + 0 + ], + "max_combat": 113.349999999996, + "result": "rot", + "rounds": 11, + "timeouts": 0, + "total_gold_curve": [ + 853, + 785, + 985, + 837, + 1008, + 1028, + 1115, + 1480, + 1823, + 1720, + 1615 + ] + } + }, + { + "bunds": [ + "glutpakt", + "wurzelbund", + "glutpakt", + "wurzelbund", + "wurzelbund", + "glutpakt" + ], + "index": 6, + "matchup": false, + "normal": { + "boss": [ + "8:won:0", + "9:deployed:0" + ], + "hero_levels": [ + 3, + 5, + 4, + 3, + 2, + 3 + ], + "lane_losses_at_end": [ + 2, + 1 + ], + "max_combat": 104.399999999996, + "result": "blau", + "rounds": 9, + "timeouts": 0, + "total_gold_curve": [ + 803, + 755, + 980, + 842, + 1098, + 958, + 1464, + 1969, + 1301 + ] + }, + "profiles": [ + "bossjagd", + "ausgewogen", + "teamhilfe", + "fernkampf", + "teamhilfe", + "ausgewogen" + ], + "seed": 1006, + "swapped": { + "boss": [ + "8:won:1", + "9:deployed:1" + ], + "hero_levels": [ + 3, + 2, + 3, + 3, + 5, + 4 + ], + "lane_losses_at_end": [ + 1, + 2 + ], + "max_combat": 104.399999999996, + "result": "rot", + "rounds": 9, + "timeouts": 0, + "total_gold_curve": [ + 803, + 755, + 980, + 842, + 1098, + 958, + 1464, + 1969, + 1301 + ] + } + }, + { + "bunds": [ + "wurzelbund", + "glutpakt", + "wurzelbund", + "wurzelbund", + "glutpakt", + "glutpakt" + ], + "index": 7, + "matchup": false, + "normal": { + "boss": [], + "hero_levels": [ + 4, + 7, + 4, + 0, + 2, + 0 + ], + "lane_losses_at_end": [ + 1, + 1 + ], + "max_combat": 95.2999999999967, + "result": "blau", + "rounds": 12, + "timeouts": 0, + "total_gold_curve": [ + 753, + 740, + 965, + 837, + 1028, + 878, + 1342, + 1427, + 1429, + 1411, + 1499, + 1816 + ] + }, + "profiles": [ + "ausgewogen", + "ausgewogen", + "ausgewogen", + "teamhilfe", + "teamhilfe", + "ausgewogen" + ], + "seed": 1007, + "swapped": { + "boss": [], + "hero_levels": [ + 0, + 2, + 0, + 4, + 7, + 4 + ], + "lane_losses_at_end": [ + 1, + 1 + ], + "max_combat": 95.2999999999967, + "result": "rot", + "rounds": 12, + "timeouts": 0, + "total_gold_curve": [ + 753, + 740, + 965, + 837, + 1028, + 878, + 1342, + 1427, + 1429, + 1411, + 1499, + 1816 + ] + } + }, + { + "bunds": [ + "wurzelbund", + "wurzelbund", + "wurzelbund", + "glutpakt", + "glutpakt", + "glutpakt" + ], + "index": 8, + "matchup": false, + "normal": { + "boss": [ + "12:won:0" + ], + "hero_levels": [ + 3, + 2, + 3, + 3, + 5, + 4 + ], + "lane_losses_at_end": [ + 2, + 0 + ], + "max_combat": 110.699999999996, + "result": "rot", + "rounds": 12, + "timeouts": 0, + "total_gold_curve": [ + 753, + 755, + 875, + 807, + 958, + 1073, + 1100, + 1050, + 1303, + 1310, + 2356, + 1749 + ] + }, + "profiles": [ + "teamhilfe", + "ausgewogen", + "bossjagd", + "druck", + "ausgewogen", + "druck" + ], + "seed": 1008, + "swapped": { + "boss": [ + "12:won:1" + ], + "hero_levels": [ + 3, + 5, + 4, + 3, + 2, + 3 + ], + "lane_losses_at_end": [ + 0, + 2 + ], + "max_combat": 110.699999999996, + "result": "blau", + "rounds": 12, + "timeouts": 0, + "total_gold_curve": [ + 753, + 755, + 875, + 807, + 958, + 1073, + 1100, + 1050, + 1303, + 1310, + 2356, + 1749 + ] + } + }, + { + "bunds": [ + "glutpakt", + "wurzelbund", + "glutpakt", + "glutpakt", + "wurzelbund", + "glutpakt" + ], + "index": 9, + "matchup": false, + "normal": { + "boss": [ + "11:won:1" + ], + "hero_levels": [ + 4, + 0, + 3, + 3, + 5, + 3 + ], + "lane_losses_at_end": [ + 2, + 1 + ], + "max_combat": 155.500000000001, + "result": "blau", + "rounds": 11, + "timeouts": 0, + "total_gold_curve": [ + 758, + 790, + 895, + 882, + 988, + 1028, + 1120, + 1235, + 1392, + 2109, + 1733 + ] + }, + "profiles": [ + "verteidigung", + "druck", + "druck", + "bossjagd", + "ausgewogen", + "teamhilfe" + ], + "seed": 1009, + "swapped": { + "boss": [ + "11:won:0" + ], + "hero_levels": [ + 3, + 5, + 3, + 4, + 0, + 3 + ], + "lane_losses_at_end": [ + 1, + 2 + ], + "max_combat": 155.500000000001, + "result": "rot", + "rounds": 11, + "timeouts": 0, + "total_gold_curve": [ + 758, + 790, + 895, + 882, + 988, + 1028, + 1120, + 1235, + 1392, + 2109, + 1733 + ] + } + }, + { + "bunds": [ + "glutpakt", + "wurzelbund", + "wurzelbund", + "wurzelbund", + "glutpakt", + "glutpakt" + ], + "index": 10, + "matchup": false, + "normal": { + "boss": [ + "12:won:1" + ], + "hero_levels": [ + 5, + 5, + 4, + 3, + 3, + 0 + ], + "lane_losses_at_end": [ + 0, + 2 + ], + "max_combat": 90.599999999997, + "result": "blau", + "rounds": 12, + "timeouts": 0, + "total_gold_curve": [ + 753, + 755, + 915, + 832, + 983, + 968, + 1040, + 1020, + 1148, + 1530, + 1788, + 1861 + ] + }, + "profiles": [ + "druck", + "ausgewogen", + "ausgewogen", + "teamhilfe", + "bossjagd", + "ausgewogen" + ], + "seed": 1010, + "swapped": { + "boss": [ + "12:won:0" + ], + "hero_levels": [ + 3, + 3, + 0, + 5, + 5, + 4 + ], + "lane_losses_at_end": [ + 2, + 0 + ], + "max_combat": 90.599999999997, + "result": "rot", + "rounds": 12, + "timeouts": 0, + "total_gold_curve": [ + 753, + 755, + 915, + 832, + 983, + 968, + 1040, + 1020, + 1148, + 1530, + 1788, + 1861 + ] + } + }, + { + "bunds": [ + "glutpakt", + "glutpakt", + "glutpakt", + "glutpakt", + "glutpakt", + "wurzelbund" + ], + "index": 11, + "matchup": false, + "normal": { + "boss": [ + "9:won:0", + "10:deployed:0" + ], + "hero_levels": [ + 2, + 5, + 5, + 4, + 3, + 2 + ], + "lane_losses_at_end": [ + 1, + 1 + ], + "max_combat": 102.549999999996, + "result": "rot", + "rounds": 10, + "timeouts": 0, + "total_gold_curve": [ + 853, + 770, + 1005, + 837, + 1028, + 1153, + 1275, + 1614, + 2209, + 1415 + ] + }, + "profiles": [ + "bossjagd", + "fernkampf", + "fernkampf", + "teamhilfe", + "verteidigung", + "teamhilfe" + ], + "seed": 1011, + "swapped": { + "boss": [ + "9:won:1", + "10:deployed:1" + ], + "hero_levels": [ + 4, + 3, + 2, + 2, + 5, + 5 + ], + "lane_losses_at_end": [ + 1, + 1 + ], + "max_combat": 102.549999999996, + "result": "blau", + "rounds": 10, + "timeouts": 0, + "total_gold_curve": [ + 853, + 770, + 1005, + 837, + 1028, + 1153, + 1275, + 1614, + 2209, + 1415 + ] + } + }, + { + "bunds": [ + "wurzelbund", + "glutpakt", + "glutpakt", + "glutpakt", + "glutpakt", + "glutpakt" + ], + "index": 12, + "matchup": false, + "normal": { + "boss": [], + "hero_levels": [ + 7, + 6, + 6, + 8, + 6, + 8 + ], + "lane_losses_at_end": [ + 3, + 1 + ], + "max_combat": 104.349999999996, + "result": "rot", + "rounds": 12, + "timeouts": 0, + "total_gold_curve": [ + 903, + 710, + 1020, + 852, + 933, + 1178, + 1230, + 1734, + 1640, + 1362, + 2161, + 2038 + ] + }, + "profiles": [ + "fernkampf", + "fernkampf", + "teamhilfe", + "teamhilfe", + "ausgewogen", + "fernkampf" + ], + "seed": 1012, + "swapped": { + "boss": [], + "hero_levels": [ + 8, + 6, + 8, + 7, + 6, + 6 + ], + "lane_losses_at_end": [ + 1, + 3 + ], + "max_combat": 104.349999999996, + "result": "blau", + "rounds": 12, + "timeouts": 0, + "total_gold_curve": [ + 903, + 710, + 1020, + 852, + 933, + 1178, + 1230, + 1734, + 1640, + 1362, + 2161, + 2038 + ] + } + }, + { + "bunds": [ + "wurzelbund", + "wurzelbund", + "wurzelbund", + "glutpakt", + "wurzelbund", + "glutpakt" + ], + "index": 13, + "matchup": false, + "normal": { + "boss": [], + "hero_levels": [ + 6, + 7, + 4, + 6, + 7, + 6 + ], + "lane_losses_at_end": [ + 2, + 0 + ], + "max_combat": 105.299999999996, + "result": "rot", + "rounds": 13, + "timeouts": 0, + "total_gold_curve": [ + 843, + 690, + 890, + 932, + 923, + 1003, + 1165, + 1165, + 1546, + 1918, + 1982, + 2013, + 1448 + ] + }, + "profiles": [ + "teamhilfe", + "druck", + "fernkampf", + "fernkampf", + "druck", + "ausgewogen" + ], + "seed": 1013, + "swapped": { + "boss": [], + "hero_levels": [ + 6, + 7, + 6, + 6, + 7, + 4 + ], + "lane_losses_at_end": [ + 0, + 2 + ], + "max_combat": 105.299999999996, + "result": "blau", + "rounds": 13, + "timeouts": 0, + "total_gold_curve": [ + 843, + 690, + 890, + 932, + 923, + 1003, + 1165, + 1165, + 1546, + 1918, + 1982, + 2013, + 1448 + ] + } + }, + { + "bunds": [ + "wurzelbund", + "glutpakt", + "wurzelbund", + "glutpakt", + "glutpakt", + "wurzelbund" + ], + "index": 14, + "matchup": false, + "normal": { + "boss": [ + "9:won:0", + "10:deployed:0" + ], + "hero_levels": [ + 3, + 0, + 0, + 2, + 3, + 2 + ], + "lane_losses_at_end": [ + 1, + 1 + ], + "max_combat": 119.799999999995, + "result": "blau", + "rounds": 10, + "timeouts": 0, + "total_gold_curve": [ + 803, + 810, + 920, + 847, + 1063, + 1098, + 1150, + 1300, + 1573, + 1404 + ] + }, + "profiles": [ + "bossjagd", + "druck", + "fernkampf", + "bossjagd", + "bossjagd", + "bossjagd" + ], + "seed": 1014, + "swapped": { + "boss": [ + "9:won:1", + "10:deployed:1" + ], + "hero_levels": [ + 2, + 3, + 2, + 3, + 0, + 0 + ], + "lane_losses_at_end": [ + 1, + 1 + ], + "max_combat": 119.799999999995, + "result": "rot", + "rounds": 10, + "timeouts": 0, + "total_gold_curve": [ + 803, + 810, + 920, + 847, + 1063, + 1098, + 1150, + 1300, + 1573, + 1404 + ] + } + }, + { + "bunds": [ + "glutpakt", + "wurzelbund", + "glutpakt", + "wurzelbund", + "glutpakt", + "glutpakt" + ], + "index": 15, + "matchup": false, + "normal": { + "boss": [], + "hero_levels": [ + 7, + 5, + 4, + 4, + 4, + 4 + ], + "lane_losses_at_end": [ + 0, + 1 + ], + "max_combat": 180.000000000007, + "result": "blau", + "rounds": 12, + "timeouts": 1, + "total_gold_curve": [ + 898, + 705, + 950, + 902, + 988, + 1023, + 1140, + 1335, + 1367, + 2474, + 1817, + 887 + ] + }, + "profiles": [ + "fernkampf", + "fernkampf", + "verteidigung", + "druck", + "fernkampf", + "ausgewogen" + ], + "seed": 1015, + "swapped": { + "boss": [], + "hero_levels": [ + 4, + 4, + 4, + 7, + 5, + 4 + ], + "lane_losses_at_end": [ + 1, + 0 + ], + "max_combat": 180.000000000007, + "result": "rot", + "rounds": 12, + "timeouts": 1, + "total_gold_curve": [ + 898, + 705, + 950, + 902, + 988, + 1023, + 1140, + 1335, + 1367, + 2474, + 1817, + 887 + ] + } + }, + { + "bunds": [ + "glutpakt", + "glutpakt", + "wurzelbund", + "wurzelbund", + "glutpakt", + "glutpakt" + ], + "index": 16, + "matchup": false, + "normal": { + "boss": [], + "hero_levels": [ + 5, + 3, + 0, + 6, + 3, + 6 + ], + "lane_losses_at_end": [ + 2, + 1 + ], + "max_combat": 131.749999999996, + "result": "rot", + "rounds": 13, + "timeouts": 0, + "total_gold_curve": [ + 758, + 825, + 980, + 812, + 928, + 978, + 1060, + 1100, + 1198, + 1545, + 1990, + 1766, + 2066 + ] + }, + "profiles": [ + "teamhilfe", + "verteidigung", + "verteidigung", + "teamhilfe", + "verteidigung", + "ausgewogen" + ], + "seed": 1016, + "swapped": { + "boss": [], + "hero_levels": [ + 6, + 3, + 6, + 5, + 3, + 0 + ], + "lane_losses_at_end": [ + 1, + 2 + ], + "max_combat": 131.749999999996, + "result": "blau", + "rounds": 13, + "timeouts": 0, + "total_gold_curve": [ + 758, + 825, + 980, + 812, + 928, + 978, + 1060, + 1100, + 1198, + 1545, + 1990, + 1766, + 2066 + ] + } + }, + { + "bunds": [ + "glutpakt", + "glutpakt", + "glutpakt", + "wurzelbund", + "glutpakt", + "wurzelbund" + ], + "index": 17, + "matchup": false, + "normal": { + "boss": [], + "hero_levels": [ + 5, + 3, + 4, + 0, + 0, + 0 + ], + "lane_losses_at_end": [ + 0, + 2 + ], + "max_combat": 121.599999999995, + "result": "blau", + "rounds": 10, + "timeouts": 0, + "total_gold_curve": [ + 893, + 720, + 940, + 882, + 1033, + 1053, + 1170, + 1495, + 1617, + 1533 + ] + }, + "profiles": [ + "fernkampf", + "verteidigung", + "fernkampf", + "druck", + "fernkampf", + "bossjagd" + ], + "seed": 1017, + "swapped": { + "boss": [], + "hero_levels": [ + 0, + 0, + 0, + 5, + 3, + 4 + ], + "lane_losses_at_end": [ + 2, + 0 + ], + "max_combat": 121.599999999995, + "result": "rot", + "rounds": 10, + "timeouts": 0, + "total_gold_curve": [ + 893, + 720, + 940, + 882, + 1033, + 1053, + 1170, + 1495, + 1617, + 1533 + ] + } + }, + { + "bunds": [ + "wurzelbund", + "glutpakt", + "wurzelbund", + "wurzelbund", + "glutpakt", + "glutpakt" + ], + "index": 18, + "matchup": false, + "normal": { + "boss": [ + "8:won:0", + "9:deployed:0" + ], + "hero_levels": [ + 2, + 3, + 4, + 2, + 2, + 5 + ], + "lane_losses_at_end": [ + 1, + 1 + ], + "max_combat": 102.349999999996, + "result": "blau", + "rounds": 9, + "timeouts": 0, + "total_gold_curve": [ + 753, + 780, + 880, + 867, + 898, + 1223, + 1215, + 1701, + 1564 + ] + }, + "profiles": [ + "verteidigung", + "ausgewogen", + "bossjagd", + "druck", + "teamhilfe", + "druck" + ], + "seed": 1018, + "swapped": { + "boss": [ + "8:won:1", + "9:deployed:1" + ], + "hero_levels": [ + 2, + 2, + 5, + 2, + 3, + 4 + ], + "lane_losses_at_end": [ + 1, + 1 + ], + "max_combat": 102.349999999996, + "result": "rot", + "rounds": 9, + "timeouts": 0, + "total_gold_curve": [ + 753, + 780, + 880, + 867, + 898, + 1223, + 1215, + 1701, + 1564 + ] + } + }, + { + "bunds": [ + "glutpakt", + "glutpakt", + "wurzelbund", + "glutpakt", + "wurzelbund", + "wurzelbund" + ], + "index": 19, + "matchup": false, + "normal": { + "boss": [ + "9:won:1", + "10:deployed:1" + ], + "hero_levels": [ + 7, + 3, + 4, + 4, + 5, + 3 + ], + "lane_losses_at_end": [ + 1, + 1 + ], + "max_combat": 127.249999999995, + "result": "rot", + "rounds": 10, + "timeouts": 0, + "total_gold_curve": [ + 798, + 770, + 930, + 787, + 958, + 1348, + 1080, + 1910, + 1473, + 1384 + ] + }, + "profiles": [ + "druck", + "teamhilfe", + "teamhilfe", + "fernkampf", + "verteidigung", + "bossjagd" + ], + "seed": 1019, + "swapped": { + "boss": [ + "9:won:0", + "10:deployed:0" + ], + "hero_levels": [ + 4, + 5, + 3, + 7, + 3, + 4 + ], + "lane_losses_at_end": [ + 1, + 1 + ], + "max_combat": 127.249999999995, + "result": "blau", + "rounds": 10, + "timeouts": 0, + "total_gold_curve": [ + 798, + 770, + 930, + 787, + 958, + 1348, + 1080, + 1910, + 1473, + 1384 + ] + } + } +] \ No newline at end of file diff --git a/docs/luneraria/logs/g2/cost-split.json b/docs/luneraria/logs/g2/cost-split.json new file mode 100644 index 0000000..6f59300 --- /dev/null +++ b/docs/luneraria/logs/g2/cost-split.json @@ -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)" +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/perf-g1-mac-run1-1920x1080.json b/docs/luneraria/logs/g2/perf-g1-mac-run1-1920x1080.json new file mode 100644 index 0000000..eb05b19 --- /dev/null +++ b/docs/luneraria/logs/g2/perf-g1-mac-run1-1920x1080.json @@ -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)" +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/perf-g1-mac-run2-1920x1080.json b/docs/luneraria/logs/g2/perf-g1-mac-run2-1920x1080.json new file mode 100644 index 0000000..b3d4cb4 --- /dev/null +++ b/docs/luneraria/logs/g2/perf-g1-mac-run2-1920x1080.json @@ -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)" +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/perf-g1-run1-1920x1080.json b/docs/luneraria/logs/g2/perf-g1-run1-1920x1080.json new file mode 100644 index 0000000..e71c5e7 --- /dev/null +++ b/docs/luneraria/logs/g2/perf-g1-run1-1920x1080.json @@ -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)" +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/perf-g1-run2-1920x1080.json b/docs/luneraria/logs/g2/perf-g1-run2-1920x1080.json new file mode 100644 index 0000000..717c418 --- /dev/null +++ b/docs/luneraria/logs/g2/perf-g1-run2-1920x1080.json @@ -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)" +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/perf-g2-mac-run1-1920x1080.json b/docs/luneraria/logs/g2/perf-g2-mac-run1-1920x1080.json new file mode 100644 index 0000000..f251d51 --- /dev/null +++ b/docs/luneraria/logs/g2/perf-g2-mac-run1-1920x1080.json @@ -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)" +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/perf-g2-mac-run2-1920x1080.json b/docs/luneraria/logs/g2/perf-g2-mac-run2-1920x1080.json new file mode 100644 index 0000000..7e0d7e8 --- /dev/null +++ b/docs/luneraria/logs/g2/perf-g2-mac-run2-1920x1080.json @@ -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)" +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/perf-vergleich-mac.json b/docs/luneraria/logs/g2/perf-vergleich-mac.json new file mode 100644 index 0000000..29cdea1 --- /dev/null +++ b/docs/luneraria/logs/g2/perf-vergleich-mac.json @@ -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 + } +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/smoke-macos-arm64.json b/docs/luneraria/logs/g2/smoke-macos-arm64.json new file mode 100644 index 0000000..8aacb83 --- /dev/null +++ b/docs/luneraria/logs/g2/smoke-macos-arm64.json @@ -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)" +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/smoke-macos-x86_64-rosetta.json b/docs/luneraria/logs/g2/smoke-macos-x86_64-rosetta.json new file mode 100644 index 0000000..fd5cb18 --- /dev/null +++ b/docs/luneraria/logs/g2/smoke-macos-x86_64-rosetta.json @@ -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)" +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/smoke-windows11-arm64.json b/docs/luneraria/logs/g2/smoke-windows11-arm64.json new file mode 100644 index 0000000..56fd09a --- /dev/null +++ b/docs/luneraria/logs/g2/smoke-windows11-arm64.json @@ -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)" +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/smoke-windows11-x64-emuliert.json b/docs/luneraria/logs/g2/smoke-windows11-x64-emuliert.json new file mode 100644 index 0000000..5f7bb68 --- /dev/null +++ b/docs/luneraria/logs/g2/smoke-windows11-x64-emuliert.json @@ -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)" +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/test-balance.log b/docs/luneraria/logs/g2/test-balance.log new file mode 100644 index 0000000..9f71cd6 --- /dev/null +++ b/docs/luneraria/logs/g2/test-balance.log @@ -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 diff --git a/docs/luneraria/logs/g2/test-core.log b/docs/luneraria/logs/g2/test-core.log new file mode 100644 index 0000000..74290a8 --- /dev/null +++ b/docs/luneraria/logs/g2/test-core.log @@ -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 diff --git a/docs/luneraria/logs/g2/test-legacy-runner.log b/docs/luneraria/logs/g2/test-legacy-runner.log new file mode 100644 index 0000000..de7beb9 --- /dev/null +++ b/docs/luneraria/logs/g2/test-legacy-runner.log @@ -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 diff --git a/docs/luneraria/logs/g2/test-legacy-smoke.log b/docs/luneraria/logs/g2/test-legacy-smoke.log new file mode 100644 index 0000000..d42b73f --- /dev/null +++ b/docs/luneraria/logs/g2/test-legacy-smoke.log @@ -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 diff --git a/docs/luneraria/logs/g2/test-m2.log b/docs/luneraria/logs/g2/test-m2.log new file mode 100644 index 0000000..8dc9f26 --- /dev/null +++ b/docs/luneraria/logs/g2/test-m2.log @@ -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 diff --git a/docs/luneraria/logs/g2/test-net-rounds.json b/docs/luneraria/logs/g2/test-net-rounds.json new file mode 100644 index 0000000..223b2d5 --- /dev/null +++ b/docs/luneraria/logs/g2/test-net-rounds.json @@ -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 + } +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/test-net-rounds.log b/docs/luneraria/logs/g2/test-net-rounds.log new file mode 100644 index 0000000..780ddc3 --- /dev/null +++ b/docs/luneraria/logs/g2/test-net-rounds.log @@ -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 diff --git a/docs/luneraria/logs/g2/test-net.log b/docs/luneraria/logs/g2/test-net.log new file mode 100644 index 0000000..6bd4253 --- /dev/null +++ b/docs/luneraria/logs/g2/test-net.log @@ -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 diff --git a/docs/luneraria/logs/g2/test-stress.json b/docs/luneraria/logs/g2/test-stress.json new file mode 100644 index 0000000..e1acc8f --- /dev/null +++ b/docs/luneraria/logs/g2/test-stress.json @@ -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 + } +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/test-stress.log b/docs/luneraria/logs/g2/test-stress.log new file mode 100644 index 0000000..8640835 --- /dev/null +++ b/docs/luneraria/logs/g2/test-stress.log @@ -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 diff --git a/docs/luneraria/logs/g2/test-verify-g1.json b/docs/luneraria/logs/g2/test-verify-g1.json new file mode 100644 index 0000000..34c3b48 --- /dev/null +++ b/docs/luneraria/logs/g2/test-verify-g1.json @@ -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": [] +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/test-verify-g1.log b/docs/luneraria/logs/g2/test-verify-g1.log new file mode 100644 index 0000000..62b57b2 --- /dev/null +++ b/docs/luneraria/logs/g2/test-verify-g1.log @@ -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 diff --git a/docs/luneraria/logs/g2/test-verify-g2.log b/docs/luneraria/logs/g2/test-verify-g2.log new file mode 100644 index 0000000..9b288c6 --- /dev/null +++ b/docs/luneraria/logs/g2/test-verify-g2.log @@ -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 diff --git a/docs/luneraria/logs/g2/test-verify-m1.json b/docs/luneraria/logs/g2/test-verify-m1.json new file mode 100644 index 0000000..9b0ea88 --- /dev/null +++ b/docs/luneraria/logs/g2/test-verify-m1.json @@ -0,0 +1,10 @@ +{ + "checks": 38, + "failures": [], + "object_counts": [ + 4533.0, + 4533.0, + 4533.0, + 4533.0 + ] +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/test-verify-m1.log b/docs/luneraria/logs/g2/test-verify-m1.log new file mode 100644 index 0000000..305dd84 --- /dev/null +++ b/docs/luneraria/logs/g2/test-verify-m1.log @@ -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 diff --git a/docs/luneraria/logs/g2/test-verify-m2.json b/docs/luneraria/logs/g2/test-verify-m2.json new file mode 100644 index 0000000..1eaf455 --- /dev/null +++ b/docs/luneraria/logs/g2/test-verify-m2.json @@ -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" + ] +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/test-verify-m2.log b/docs/luneraria/logs/g2/test-verify-m2.log new file mode 100644 index 0000000..49f8546 --- /dev/null +++ b/docs/luneraria/logs/g2/test-verify-m2.log @@ -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 diff --git a/docs/luneraria/logs/g2/verify-g2.json b/docs/luneraria/logs/g2/verify-g2.json new file mode 100644 index 0000000..ac17bb3 --- /dev/null +++ b/docs/luneraria/logs/g2/verify-g2.json @@ -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": [] +} \ No newline at end of file diff --git a/docs/luneraria/logs/g2/windows11-arm64-godot.log b/docs/luneraria/logs/g2/windows11-arm64-godot.log new file mode 100644 index 0000000..9ed5d2c --- /dev/null +++ b/docs/luneraria/logs/g2/windows11-arm64-godot.log @@ -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)"} diff --git a/docs/luneraria/logs/g2/windows11-x64-godot.log b/docs/luneraria/logs/g2/windows11-x64-godot.log new file mode 100644 index 0000000..ba0ded2 --- /dev/null +++ b/docs/luneraria/logs/g2/windows11-x64-godot.log @@ -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)"} diff --git a/docs/luneraria/screenshots/g2/.gdignore b/docs/luneraria/screenshots/g2/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/docs/luneraria/screenshots/g2/nachher/01-ancient-100.png b/docs/luneraria/screenshots/g2/nachher/01-ancient-100.png new file mode 100644 index 0000000..9adca84 Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/01-ancient-100.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/02-ancient-50.png b/docs/luneraria/screenshots/g2/nachher/02-ancient-50.png new file mode 100644 index 0000000..1f1ee4b Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/02-ancient-50.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/03-ancient-unter-30.png b/docs/luneraria/screenshots/g2/nachher/03-ancient-unter-30.png new file mode 100644 index 0000000..7136bb9 Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/03-ancient-unter-30.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/04-ancient-zerstoert.png b/docs/luneraria/screenshots/g2/nachher/04-ancient-zerstoert.png new file mode 100644 index 0000000..2da7157 Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/04-ancient-zerstoert.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/04a-ancient-zerstoerung.png b/docs/luneraria/screenshots/g2/nachher/04a-ancient-zerstoerung.png new file mode 100644 index 0000000..e101eaf Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/04a-ancient-zerstoerung.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/05-tower1-grenzwacht.png b/docs/luneraria/screenshots/g2/nachher/05-tower1-grenzwacht.png new file mode 100644 index 0000000..ac669fa Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/05-tower1-grenzwacht.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/06-tower2-bastion.png b/docs/luneraria/screenshots/g2/nachher/06-tower2-bastion.png new file mode 100644 index 0000000..ba1394d Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/06-tower2-bastion.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/07-basistuerme.png b/docs/luneraria/screenshots/g2/nachher/07-basistuerme.png new file mode 100644 index 0000000..4d0f408 Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/07-basistuerme.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/08-turm-zerstoert.png b/docs/luneraria/screenshots/g2/nachher/08-turm-zerstoert.png new file mode 100644 index 0000000..9809edf Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/08-turm-zerstoert.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/08a-turm-zerstoerung.png b/docs/luneraria/screenshots/g2/nachher/08a-turm-zerstoerung.png new file mode 100644 index 0000000..9e9d754 Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/08a-turm-zerstoerung.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/09-grotte-neutral-entsandt.png b/docs/luneraria/screenshots/g2/nachher/09-grotte-neutral-entsandt.png new file mode 100644 index 0000000..b0aef23 Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/09-grotte-neutral-entsandt.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/10-boss-verfuegbar.png b/docs/luneraria/screenshots/g2/nachher/10-boss-verfuegbar.png new file mode 100644 index 0000000..0d279b9 Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/10-boss-verfuegbar.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/11-bosskampf.png b/docs/luneraria/screenshots/g2/nachher/11-bosskampf.png new file mode 100644 index 0000000..9579ef9 Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/11-bosskampf.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/12-boss-lane-verstaerkung.png b/docs/luneraria/screenshots/g2/nachher/12-boss-lane-verstaerkung.png new file mode 100644 index 0000000..51ee73e Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/12-boss-lane-verstaerkung.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/13-kampf-vor-tower.png b/docs/luneraria/screenshots/g2/nachher/13-kampf-vor-tower.png new file mode 100644 index 0000000..fee64e8 Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/13-kampf-vor-tower.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/14-kampf-vor-ancient.png b/docs/luneraria/screenshots/g2/nachher/14-kampf-vor-ancient.png new file mode 100644 index 0000000..a34c90f Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/14-kampf-vor-ancient.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/15-gesamtkarte.png b/docs/luneraria/screenshots/g2/nachher/15-gesamtkarte.png new file mode 100644 index 0000000..8e16d65 Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/15-gesamtkarte.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/16-massstab.png b/docs/luneraria/screenshots/g2/nachher/16-massstab.png new file mode 100644 index 0000000..d5ae4cb Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/16-massstab.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/17-ancient-rot-100.png b/docs/luneraria/screenshots/g2/nachher/17-ancient-rot-100.png new file mode 100644 index 0000000..5cd9932 Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/17-ancient-rot-100.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/18a-grotte-cooldown.png b/docs/luneraria/screenshots/g2/nachher/18a-grotte-cooldown.png new file mode 100644 index 0000000..4c737e9 Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/18a-grotte-cooldown.png differ diff --git a/docs/luneraria/screenshots/g2/nachher/18b-grotte-bald-verfuegbar.png b/docs/luneraria/screenshots/g2/nachher/18b-grotte-bald-verfuegbar.png new file mode 100644 index 0000000..87385c3 Binary files /dev/null and b/docs/luneraria/screenshots/g2/nachher/18b-grotte-bald-verfuegbar.png differ diff --git a/docs/luneraria/screenshots/g2/plattformen/macos-arm64-grotte.png b/docs/luneraria/screenshots/g2/plattformen/macos-arm64-grotte.png new file mode 100644 index 0000000..f4492c8 Binary files /dev/null and b/docs/luneraria/screenshots/g2/plattformen/macos-arm64-grotte.png differ diff --git a/docs/luneraria/screenshots/g2/plattformen/macos-arm64-kampf.png b/docs/luneraria/screenshots/g2/plattformen/macos-arm64-kampf.png new file mode 100644 index 0000000..3c745c4 Binary files /dev/null and b/docs/luneraria/screenshots/g2/plattformen/macos-arm64-kampf.png differ diff --git a/docs/luneraria/screenshots/g2/plattformen/macos-x86_64-rosetta-ancient.png b/docs/luneraria/screenshots/g2/plattformen/macos-x86_64-rosetta-ancient.png new file mode 100644 index 0000000..150772c Binary files /dev/null and b/docs/luneraria/screenshots/g2/plattformen/macos-x86_64-rosetta-ancient.png differ diff --git a/docs/luneraria/screenshots/g2/plattformen/windows11-arm64-ancient.png b/docs/luneraria/screenshots/g2/plattformen/windows11-arm64-ancient.png new file mode 100644 index 0000000..e29fda1 Binary files /dev/null and b/docs/luneraria/screenshots/g2/plattformen/windows11-arm64-ancient.png differ diff --git a/docs/luneraria/screenshots/g2/plattformen/windows11-arm64-desktopfenster.png b/docs/luneraria/screenshots/g2/plattformen/windows11-arm64-desktopfenster.png new file mode 100644 index 0000000..25f38f3 Binary files /dev/null and b/docs/luneraria/screenshots/g2/plattformen/windows11-arm64-desktopfenster.png differ diff --git a/docs/luneraria/screenshots/g2/plattformen/windows11-arm64-grotte.png b/docs/luneraria/screenshots/g2/plattformen/windows11-arm64-grotte.png new file mode 100644 index 0000000..024038b Binary files /dev/null and b/docs/luneraria/screenshots/g2/plattformen/windows11-arm64-grotte.png differ diff --git a/docs/luneraria/screenshots/g2/plattformen/windows11-x64-desktopfenster.png b/docs/luneraria/screenshots/g2/plattformen/windows11-x64-desktopfenster.png new file mode 100644 index 0000000..99ddca4 Binary files /dev/null and b/docs/luneraria/screenshots/g2/plattformen/windows11-x64-desktopfenster.png differ diff --git a/docs/luneraria/screenshots/g2/plattformen/windows11-x64-kampf.png b/docs/luneraria/screenshots/g2/plattformen/windows11-x64-kampf.png new file mode 100644 index 0000000..6dc5792 Binary files /dev/null and b/docs/luneraria/screenshots/g2/plattformen/windows11-x64-kampf.png differ diff --git a/docs/luneraria/screenshots/g2/schlundkrone-clips.png b/docs/luneraria/screenshots/g2/schlundkrone-clips.png new file mode 100644 index 0000000..c9c833b Binary files /dev/null and b/docs/luneraria/screenshots/g2/schlundkrone-clips.png differ diff --git a/docs/luneraria/screenshots/g2/schlundkrone-massstab-mitte.png b/docs/luneraria/screenshots/g2/schlundkrone-massstab-mitte.png new file mode 100644 index 0000000..df79485 Binary files /dev/null and b/docs/luneraria/screenshots/g2/schlundkrone-massstab-mitte.png differ diff --git a/docs/luneraria/screenshots/g2/uebersicht-ancient-zustaende.png b/docs/luneraria/screenshots/g2/uebersicht-ancient-zustaende.png new file mode 100644 index 0000000..263f125 Binary files /dev/null and b/docs/luneraria/screenshots/g2/uebersicht-ancient-zustaende.png differ diff --git a/docs/luneraria/screenshots/g2/uebersicht-grotte-zustaende.png b/docs/luneraria/screenshots/g2/uebersicht-grotte-zustaende.png new file mode 100644 index 0000000..2189579 Binary files /dev/null and b/docs/luneraria/screenshots/g2/uebersicht-grotte-zustaende.png differ diff --git a/docs/luneraria/screenshots/g2/uebersicht-tuerme.png b/docs/luneraria/screenshots/g2/uebersicht-tuerme.png new file mode 100644 index 0000000..7937ef9 Binary files /dev/null and b/docs/luneraria/screenshots/g2/uebersicht-tuerme.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/01-ancient-100.png b/docs/luneraria/screenshots/g2/vorher/01-ancient-100.png new file mode 100644 index 0000000..68fec6c Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/01-ancient-100.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/02-ancient-50.png b/docs/luneraria/screenshots/g2/vorher/02-ancient-50.png new file mode 100644 index 0000000..a8d40e8 Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/02-ancient-50.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/03-ancient-unter-30.png b/docs/luneraria/screenshots/g2/vorher/03-ancient-unter-30.png new file mode 100644 index 0000000..ddddbfc Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/03-ancient-unter-30.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/04-ancient-zerstoert.png b/docs/luneraria/screenshots/g2/vorher/04-ancient-zerstoert.png new file mode 100644 index 0000000..2eba574 Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/04-ancient-zerstoert.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/04a-ancient-zerstoerung.png b/docs/luneraria/screenshots/g2/vorher/04a-ancient-zerstoerung.png new file mode 100644 index 0000000..ac4440e Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/04a-ancient-zerstoerung.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/05-tower1-grenzwacht.png b/docs/luneraria/screenshots/g2/vorher/05-tower1-grenzwacht.png new file mode 100644 index 0000000..eafa065 Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/05-tower1-grenzwacht.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/06-tower2-bastion.png b/docs/luneraria/screenshots/g2/vorher/06-tower2-bastion.png new file mode 100644 index 0000000..a4b89dc Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/06-tower2-bastion.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/07-basistuerme.png b/docs/luneraria/screenshots/g2/vorher/07-basistuerme.png new file mode 100644 index 0000000..364d042 Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/07-basistuerme.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/08-turm-zerstoert.png b/docs/luneraria/screenshots/g2/vorher/08-turm-zerstoert.png new file mode 100644 index 0000000..e8ce3a5 Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/08-turm-zerstoert.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/08a-turm-zerstoerung.png b/docs/luneraria/screenshots/g2/vorher/08a-turm-zerstoerung.png new file mode 100644 index 0000000..1d3fec8 Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/08a-turm-zerstoerung.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/09-grotte-neutral-entsandt.png b/docs/luneraria/screenshots/g2/vorher/09-grotte-neutral-entsandt.png new file mode 100644 index 0000000..8d30772 Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/09-grotte-neutral-entsandt.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/10-boss-verfuegbar.png b/docs/luneraria/screenshots/g2/vorher/10-boss-verfuegbar.png new file mode 100644 index 0000000..7ce6521 Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/10-boss-verfuegbar.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/11-bosskampf.png b/docs/luneraria/screenshots/g2/vorher/11-bosskampf.png new file mode 100644 index 0000000..babfcfd Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/11-bosskampf.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/12-boss-lane-verstaerkung.png b/docs/luneraria/screenshots/g2/vorher/12-boss-lane-verstaerkung.png new file mode 100644 index 0000000..76f67be Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/12-boss-lane-verstaerkung.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/13-kampf-vor-tower.png b/docs/luneraria/screenshots/g2/vorher/13-kampf-vor-tower.png new file mode 100644 index 0000000..93d4b5b Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/13-kampf-vor-tower.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/14-kampf-vor-ancient.png b/docs/luneraria/screenshots/g2/vorher/14-kampf-vor-ancient.png new file mode 100644 index 0000000..f0fa09b Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/14-kampf-vor-ancient.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/15-gesamtkarte.png b/docs/luneraria/screenshots/g2/vorher/15-gesamtkarte.png new file mode 100644 index 0000000..09738e7 Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/15-gesamtkarte.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/16-massstab.png b/docs/luneraria/screenshots/g2/vorher/16-massstab.png new file mode 100644 index 0000000..008f2e6 Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/16-massstab.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/17-ancient-rot-100.png b/docs/luneraria/screenshots/g2/vorher/17-ancient-rot-100.png new file mode 100644 index 0000000..a4f418c Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/17-ancient-rot-100.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/18a-grotte-cooldown.png b/docs/luneraria/screenshots/g2/vorher/18a-grotte-cooldown.png new file mode 100644 index 0000000..0a102b5 Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/18a-grotte-cooldown.png differ diff --git a/docs/luneraria/screenshots/g2/vorher/18b-grotte-bald-verfuegbar.png b/docs/luneraria/screenshots/g2/vorher/18b-grotte-bald-verfuegbar.png new file mode 100644 index 0000000..a1a3248 Binary files /dev/null and b/docs/luneraria/screenshots/g2/vorher/18b-grotte-bald-verfuegbar.png differ diff --git a/docs/luneraria/videos/.gdignore b/docs/luneraria/videos/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/docs/luneraria/videos/g2-bossbegegnung.mp4 b/docs/luneraria/videos/g2-bossbegegnung.mp4 new file mode 100644 index 0000000..191f9ce Binary files /dev/null and b/docs/luneraria/videos/g2-bossbegegnung.mp4 differ diff --git a/docs/luneraria/videos/g2-turmkampf.mp4 b/docs/luneraria/videos/g2-turmkampf.mp4 new file mode 100644 index 0000000..66fda16 Binary files /dev/null and b/docs/luneraria/videos/g2-turmkampf.mp4 differ diff --git a/export_presets.cfg b/export_presets.cfg index acad2db..22328c7 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -6,7 +6,7 @@ advanced_options=false dedicated_server=false custom_features="" export_filter="all_resources" -include_filter="assets/phase3_manifest.json,assets/phase4_manifest.json,assets/phase11_manifest.json,assets/phase12_manifest.json,assets/phase15_manifest.json,assets/phase16_manifest.json,assets/phase18_manifest.json,assets/phase19_manifest.json,assets/menu_stage_manifest.json,assets/audio/manifest.json,assets/icons/manifest.json,assets/luneraria/tower_manifest.json,assets/luneraria/grotto_manifest.json,assets/luneraria/map_props_manifest.json,assets/luneraria/peoples_manifest.json" +include_filter="assets/phase3_manifest.json,assets/phase4_manifest.json,assets/phase11_manifest.json,assets/phase12_manifest.json,assets/phase15_manifest.json,assets/phase16_manifest.json,assets/phase18_manifest.json,assets/phase19_manifest.json,assets/menu_stage_manifest.json,assets/audio/manifest.json,assets/icons/manifest.json,assets/luneraria/tower_manifest.json,assets/luneraria/grotto_manifest.json,assets/luneraria/map_props_manifest.json,assets/luneraria/peoples_manifest.json,assets/luneraria/g2/g2_manifest.json" exclude_filter="docs/*,tests/*,tools/*,assets/ui/ui_*,assets/maps/*,assets/materials/phase11/*" export_path="../dist/luneraria/linux/ChampionsOfLuneraria.x86_64" encryption_include_filters="" @@ -30,8 +30,8 @@ application/modify_resources=false application/company_name="" application/product_name="Champions of Luneraria" application/file_description="Champions of Luneraria Demo" -application/file_version="0.15.0.0" -application/product_version="0.15.0.0" +application/file_version="0.17.0.0" +application/product_version="0.17.0.0" [preset.1] name="Luneraria-Windows" @@ -41,7 +41,7 @@ advanced_options=false dedicated_server=false custom_features="" export_filter="all_resources" -include_filter="assets/phase3_manifest.json,assets/phase4_manifest.json,assets/phase11_manifest.json,assets/phase12_manifest.json,assets/phase15_manifest.json,assets/phase16_manifest.json,assets/phase18_manifest.json,assets/phase19_manifest.json,assets/menu_stage_manifest.json,assets/audio/manifest.json,assets/icons/manifest.json,assets/luneraria/tower_manifest.json,assets/luneraria/grotto_manifest.json,assets/luneraria/map_props_manifest.json,assets/luneraria/peoples_manifest.json" +include_filter="assets/phase3_manifest.json,assets/phase4_manifest.json,assets/phase11_manifest.json,assets/phase12_manifest.json,assets/phase15_manifest.json,assets/phase16_manifest.json,assets/phase18_manifest.json,assets/phase19_manifest.json,assets/menu_stage_manifest.json,assets/audio/manifest.json,assets/icons/manifest.json,assets/luneraria/tower_manifest.json,assets/luneraria/grotto_manifest.json,assets/luneraria/map_props_manifest.json,assets/luneraria/peoples_manifest.json,assets/luneraria/g2/g2_manifest.json" exclude_filter="docs/*,tests/*,tools/*,assets/ui/ui_*,assets/maps/*,assets/materials/phase11/*" export_path="../dist/luneraria/windows/ChampionsOfLuneraria.exe" encryption_include_filters="" @@ -65,5 +65,97 @@ application/modify_resources=false application/company_name="" application/product_name="Champions of Luneraria" application/file_description="Champions of Luneraria Demo" -application/file_version="0.15.0.0" -application/product_version="0.15.0.0" +application/file_version="0.17.0.0" +application/product_version="0.17.0.0" + +[preset.2] +name="Luneraria-macOS" +platform="macOS" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="assets/phase3_manifest.json,assets/phase4_manifest.json,assets/phase11_manifest.json,assets/phase12_manifest.json,assets/phase15_manifest.json,assets/phase16_manifest.json,assets/phase18_manifest.json,assets/phase19_manifest.json,assets/menu_stage_manifest.json,assets/audio/manifest.json,assets/icons/manifest.json,assets/luneraria/tower_manifest.json,assets/luneraria/grotto_manifest.json,assets/luneraria/map_props_manifest.json,assets/luneraria/peoples_manifest.json,assets/luneraria/g2/g2_manifest.json" +exclude_filter="docs/*,tests/*,tools/*,assets/ui/ui_*,assets/maps/*,assets/materials/phase11/*" +export_path="../dist/luneraria/macos/Champions of Luneraria.app" +encryption_include_filters="" +encryption_exclude_filters="" +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.2.options] +export/distribution_type=1 +binary_format/architecture="universal" +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=0 +application/icon="" +application/icon_interpolation=4 +application/bundle_identifier="de.stickelbroeck.luneraria" +application/signature="" +application/app_category="Strategy-games" +application/short_version="0.17.0" +application/version="0.17.0" +application/copyright="" +application/min_macos_version_x86_64="10.13" +application/min_macos_version_arm64="11.00" +display/high_res=true +codesign/codesign=1 +codesign/installer_identity="" +codesign/apple_team_id="" +codesign/identity="" +codesign/entitlements/custom_file="" +codesign/entitlements/allow_jit_code_execution=false +codesign/entitlements/allow_unsigned_executable_memory=false +codesign/entitlements/allow_dyld_environment_variables=false +codesign/entitlements/disable_library_validation=false +codesign/entitlements/audio_input=false +codesign/entitlements/camera=false +codesign/entitlements/location=false +codesign/entitlements/address_book=false +codesign/entitlements/calendars=false +codesign/entitlements/photos_library=false +codesign/entitlements/apple_events=false +codesign/entitlements/debugging=false +codesign/entitlements/app_sandbox/enabled=false +notarization/notarization=0 +texture_format/s3tc_bptc=true +texture_format/etc2_astc=true +privacy/network_volumes_usage_description="" + +[preset.3] +name="Luneraria-Windows-ARM64" +platform="Windows Desktop" +runnable=false +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="assets/phase3_manifest.json,assets/phase4_manifest.json,assets/phase11_manifest.json,assets/phase12_manifest.json,assets/phase15_manifest.json,assets/phase16_manifest.json,assets/phase18_manifest.json,assets/phase19_manifest.json,assets/menu_stage_manifest.json,assets/audio/manifest.json,assets/icons/manifest.json,assets/luneraria/tower_manifest.json,assets/luneraria/grotto_manifest.json,assets/luneraria/map_props_manifest.json,assets/luneraria/peoples_manifest.json,assets/luneraria/g2/g2_manifest.json" +exclude_filter="docs/*,tests/*,tools/*,assets/ui/ui_*,assets/maps/*,assets/materials/phase11/*" +export_path="../dist/luneraria/windows-arm64/ChampionsOfLuneraria.exe" +encryption_include_filters="" +encryption_exclude_filters="" +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.3.options] +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=0 +binary_format/embed_pck=true +binary_format/architecture="arm64" +texture_format/bptc=true +texture_format/s3tc=true +texture_format/etc2=false +texture_format/etc=false +codesign/enable=false +application/modify_resources=false +application/company_name="" +application/product_name="Champions of Luneraria" +application/file_description="Champions of Luneraria Demo" +application/file_version="0.17.0.0" +application/product_version="0.17.0.0" diff --git a/project.godot b/project.godot index a0a052b..6cde9a2 100644 --- a/project.godot +++ b/project.godot @@ -21,6 +21,10 @@ run/main_scene="res://scenes/luneraria.tscn" config/features=PackedStringArray("4.7", "Forward Plus") config/icon="res://assets/icons/resources/res_core.png" +[luneraria] + +config/version="0.17.0-g2" + [autoload] AssetLibrary="*res://scripts/presentation/asset_library.gd" @@ -44,6 +48,7 @@ window/size/min_height=720 [rendering] +textures/vram_compression/import_etc2_astc=true renderer/rendering_method.mobile="gl_compatibility" anti_aliasing/quality/msaa_3d=2 environment/defaults/default_clear_color=Color(0.055, 0.073, 0.088, 1) diff --git a/scripts/luneraria/lune_game.gd b/scripts/luneraria/lune_game.gd index 9b67239..1e55ea2 100644 --- a/scripts/luneraria/lune_game.gd +++ b/scripts/luneraria/lune_game.gd @@ -226,6 +226,7 @@ func _update_deployment() -> void: func _add_view(unit: LuneUnit) -> void: var view: Node3D = UnitView.new() view.terrain = world.terrain + view.ground = world.height_at _units_root.add_child(view) view.bind(unit) _views[unit.id] = view @@ -269,16 +270,9 @@ func _process(delta: float) -> void: var sim_delta: float = 0.0 if paused else delta * SPEEDS[speed_index] for view: Node3D in _views.values(): view.sync(alpha, sim_delta if match_state.phase == LuneMatch.Phase.COMBAT else delta) - var hp: Dictionary = match_state.building_hp.duplicate() - if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null: - for id: String in match_state.combat.buildings: - hp[id] = (match_state.combat.buildings[id] as LuneUnit).hp - var max_hp: Dictionary = {} - for definition: Dictionary in match_state.map.buildings: - max_hp[definition.id] = definition.max_hp - world.sync_buildings(hp, max_hp, _time, delta) - var claim: Dictionary = match_state.boss.claim - world.sync_grotto(match_state.grotto_available(), int(claim.team) if not claim.is_empty() else -1) + world.tower_fx.camera = camera + world.sync_strategic(match_state, _views, _time, delta, sim_delta if match_state.phase == LuneMatch.Phase.COMBAT else delta) + world.sync_grotto(match_state, _views, delta) hud.minimap.camera_focus = camera.focus hud.minimap.camera_span = camera.zoom hud.minimap.camera_footprint = camera.footprint() diff --git a/scripts/luneraria/lune_main.gd b/scripts/luneraria/lune_main.gd index 9b6bcbf..d498c1c 100644 --- a/scripts/luneraria/lune_main.gd +++ b/scripts/luneraria/lune_main.gd @@ -39,6 +39,12 @@ func _ready() -> void: settings.visible = false settings.closed.connect(toggle_settings) menu.enter() + # Platform smoke test of exported builds (macOS app, Windows): plays a short match alone. + for arg: String in OS.get_cmdline_user_args(): + if arg.begins_with("--lune-smoke"): + var smoke: Node = load("res://scripts/luneraria/lune_smoke.gd").new() + smoke.setup(self, arg.get_slice("=", 1) if arg.contains("=") else "") + add_child(smoke) func start_match(options: Dictionary) -> void: var own_team_slots: Array[int] = [0, 1, 2] diff --git a/scripts/luneraria/lune_smoke.gd b/scripts/luneraria/lune_smoke.gd new file mode 100644 index 0000000..3815f07 --- /dev/null +++ b/scripts/luneraria/lune_smoke.gd @@ -0,0 +1,67 @@ +extends Node +## Platform smoke test of an exported build (G2): starts an offline match, plays into the +## combat at 4x, photographs planning, combat, grotto and Ancient, writes a short report and +## quits. It only drives the normal game; nothing here changes rules or presentation. +## Run: -- --lune-smoke[=] (default folder: user://smoke) + +var main: Node +var folder: String = "user://smoke/" +var report: Dictionary = {} + +func setup(entry: Node, target: String) -> void: + main = entry + if not target.is_empty(): + folder = target if target.ends_with("/") else target + "/" + +func _ready() -> void: + call_deferred("run") + +func shot(label: String) -> void: + await RenderingServer.frame_post_draw + DirAccess.make_dir_recursive_absolute(folder if folder.begins_with("/") or folder.contains(":") else ProjectSettings.globalize_path(folder)) + var path: String = folder + label + ".png" + get_viewport().get_texture().get_image().save_png(path) + print("LUNE_SMOKE_SHOT ", ProjectSettings.globalize_path(path)) + +func wait(seconds: float) -> void: + await get_tree().create_timer(seconds).timeout + +func run() -> void: + var started: int = Time.get_ticks_msec() + await wait(1.5) + await shot("00-menue") + main.start_match({"seed": 7}) + await wait(2.0) + var game: Node3D = main.game + await shot("01-planung") + game.hud._starter.pressed.emit() + game._on_ready() + game.speed_index = 2 + await wait(8.0) + game.focus_lane("mid", true) + await wait(1.5) + await shot("02-kampf") + var fps_combat: float = Engine.get_frames_per_second() + game.focus_grotto(true) + await wait(1.5) + await shot("03-grotte") + game.focus_base(true) + await wait(1.5) + await shot("04-ancient") + report = {"engine": Engine.get_version_info().string, "os": OS.get_name(), "os_version": OS.get_version(), + "architecture": Engine.get_architecture_name(), "renderer": RenderingServer.get_current_rendering_method(), + "driver": RenderingServer.get_current_rendering_driver_name(), "adapter": RenderingServer.get_video_adapter_name(), + "window": get_window().size, "fps_in_combat": fps_combat, "fps_at_end": Engine.get_frames_per_second(), + "missing_assets": AssetLibrary.missing, "boss_model": AssetLibrary.has_model("schlundkrone"), + "tower_views": game.world.tower_views.size(), "ancient_views": game.world.ancient_views.size(), + "grotto_state": game.world.grotto_view.state_name(), "phase": game.match_state.phase, + "combat_seconds": game.match_state.combat.elapsed if game.match_state.combat != null else 0.0, + "seconds": (Time.get_ticks_msec() - started) / 1000.0} + var ok: bool = AssetLibrary.missing.is_empty() and bool(report.boss_model) and int(report.tower_views) == 16 and int(report.ancient_views) == 2 + report["result"] = "PASS" if ok else "FAIL" + var file := FileAccess.open(folder + "report.json", FileAccess.WRITE) + if file != null: + file.store_string(JSON.stringify(report, "\t")) + file.close() + print("LUNE_SMOKE_%s %s" % [report.result, JSON.stringify(report)]) + get_tree().quit(0 if ok else 1) diff --git a/scripts/luneraria/lune_smoke.gd.uid b/scripts/luneraria/lune_smoke.gd.uid new file mode 100644 index 0000000..5a6740a --- /dev/null +++ b/scripts/luneraria/lune_smoke.gd.uid @@ -0,0 +1 @@ +uid://c41d8fjthdgvv diff --git a/scripts/luneraria/ui/lune_menu.gd b/scripts/luneraria/ui/lune_menu.gd index e79b937..7f1bb75 100644 --- a/scripts/luneraria/ui/lune_menu.gd +++ b/scripts/luneraria/ui/lune_menu.gd @@ -26,7 +26,9 @@ func _ready() -> void: brand_names.add_child(tagline) lore_label.text = "Führe deine Armee über drei Lanes. Die Partie endet erst, wenn ein Ancient fällt." emblem_rect.texture = load("res://assets/ui/emblems/luneraria.svg") - version_label.text = "Prototyp %s · 1 Mensch + 5 Bots oder 6 über Direktverbindung" % str(ProjectSettings.get_setting("application/config/version", "")) + # Luneraria keeps its own version; application/config/version stays the Saumwacht demo's. + var version: String = str(ProjectSettings.get_setting("luneraria/config/version", ProjectSettings.get_setting("application/config/version", ""))) + version_label.text = "Prototyp %s · 1 Mensch + 5 Bots oder 6 über Direktverbindung" % version _title_caption.text = "Glutpakt und Wurzelbund an den Ufern der Grünen Kluft" _retarget_setup() diff --git a/scripts/luneraria/view/lune_ancient_view.gd b/scripts/luneraria/view/lune_ancient_view.gd new file mode 100644 index 0000000..952b897 --- /dev/null +++ b/scripts/luneraria/view/lune_ancient_view.gd @@ -0,0 +1,424 @@ +extends Node3D +## One Ancient (G2 "Mondherz"): the monumental heart of a base. Reads the authoritative HP +## and shows four states - stable, unstable (cracks, uneasy energy), critical (heavy cracks, +## leaking energy, a leaning blade, a fallen fragment, sparks and smoke) and the collapse into +## a lasting ruin. The collapse is presentation only: the match has already been decided and +## processed when it starts, and it never delays anything. + +const Materials = preload("res://scripts/luneraria/view/lune_materials.gd") +const Fx = preload("res://scripts/luneraria/view/lune_fx.gd") +enum State { STABLE, UNSTABLE, CRITICAL, COLLAPSING, FALLEN } +const SCENE_PATH := "res://assets/luneraria/g2/ancient.glb" +const UNSTABLE_BELOW: float = .605 +const CRITICAL_BELOW: float = .305 +const COLLAPSE_SECONDS: float = 2.8 +const CRYSTAL_Y: float = 4.7 +const BLADE_ANGLES: Array[float] = [0.0, PI * .5, PI, PI * 1.5] +## Dais steps (outer radius, top height above the plaza) - figures are lifted onto them. +const DAIS_STEPS: Array = [[4.0, .05], [3.4, .1], [2.8, .15]] +## Occlusion fade (G2): the blades, rings and heart above this height may thin out. +const FADE_FLOOR: float = 2.4 +const OCCLUDER_RADIUS: float = 3.2 +const TOP: float = 10.4 +static var _scene: PackedScene + +var building_id: String = "" +var team: int = 0 +var state: int = State.STABLE +var fraction: float = 1.0 +var fade_target: float = 0.0 +var seed_value: float = 0.0 +var light: OmniLight3D +var crystal: Node3D +var satellites: Array[Node3D] = [] +var fragments: Array[Node3D] = [] +var rings: Array[Node3D] = [] +var blades: Array[Node3D] = [] +var crown: Node3D +var crest: Node3D +var _parts: Dictionary = {} +var _rest: Dictionary = {} +var _instances: Array = [] +var _structure: Array = [] +var _crystals: Array = [] +var _cloth: Array = [] +var _ruin: Array = [] +var _sent: Dictionary = {} +var _motes: GPUParticles3D +var _sparks: GPUParticles3D +var _smoke: GPUParticles3D +var _dust: GPUParticles3D +var _burst: GPUParticles3D +var _flash: MeshInstance3D +var _bar: Node3D +var _bar_fill: MeshInstance3D +var _time: float = 0.0 +var _collapse_time: float = -1.0 +var _smoke_left: float = 0.0 +var _hit: float = 0.0 +var _fade: float = 0.0 +var _burst_fired: bool = false +var _fallen_fragment: int = 0 +## Energy leaking from the cracked heart (critical state): fast, irregular sparks upwards. +var _leak: GPUParticles3D +## Critical state: the inner ring has slipped and hangs tilted; one satellite sags (unstable). +var _ring_slipped: bool = false +var _sag: float = 0.0 + +func setup(definition: Dictionary, facing: Vector2) -> void: + building_id = str(definition.id) + team = int(definition.team) + name = building_id + seed_value = 3.0 + team * 5.0 + if _scene == null: + _scene = load(SCENE_PATH) + var model: Node3D = _scene.instantiate() + model.name = "Model" + add_child(model) + model.rotation.y = atan2(facing.x, facing.y) + for child: Node in model.get_children(): + _parts[str(child.name)] = child + _rest[str(child.name)] = (child as Node3D).transform + _instances = Materials.dress(model) + Materials.apply_team(_instances, team, seed_value) + crystal = _parts.get("Crystal") + crown = _parts.get("Crown") + crest = _parts.get("Crest_A" if team == 0 else "Crest_B") + (_parts["Crest_B" if team == 0 else "Crest_A"] as Node3D).visible = false + for index: int in range(6): + if _parts.has("Satellite_%d" % index): + satellites.append(_parts["Satellite_%d" % index]) + if _parts.has("Fragment_%d" % index): + fragments.append(_parts["Fragment_%d" % index]) + for key: String in ["Ring_Outer", "Ring_Inner"]: + rings.append(_parts[key]) + for index: int in range(4): + blades.append(_parts["Blade_%d" % index]) + for key: String in _parts: + var list: Array = _collect(_parts[key]) + if key == "Crystal" or key.begins_with("Satellite") or key.begins_with("Fragment"): + _crystals.append_array(list) + elif key.begins_with("Banner"): + _cloth.append_array(list) + elif key.begins_with("Ruin"): + _ruin.append_array(list) + else: + _structure.append_array(list) + Materials.apply_rune_tint(_structure, team) + # The glowing heart, satellites, fragments and the banners cast no sun shadow. + for instance: GeometryInstance3D in _crystals + _cloth: + instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + Materials.set_param(_structure, "crack_glow", 0.0) + (_parts["Ruin_Crystal"] as Node3D).visible = false + (_parts["Ruin_Rubble"] as Node3D).visible = false + light = OmniLight3D.new() + light.name = "HeartLight" + light.position = Vector3(0, CRYSTAL_Y, 0) + light.light_color = Materials.team_color(team, "light") + light.omni_range = 13.0 + light.omni_attenuation = 1.2 + light.light_energy = 2.2 + add_child(light) + var color: Color = Materials.team_color(team, "crystal") + _motes = Fx.motes(color, 22, 2.4, 4.0) + _motes.position = Vector3(0, 2.4, 0) + _motes.emitting = true + add_child(_motes) + _sparks = Fx.sparks(Materials.team_color(team, "light"), 26, 2.1, 3.0) + _sparks.position = Vector3(0, 3.2, 0) + add_child(_sparks) + _smoke = Fx.smoke(22, 1.4, .15, 2.4, 4.5) + _smoke.position = Vector3(0, 2.2, 0) + add_child(_smoke) + _leak = Fx.leak(color, 34, .9) + _leak.position = Vector3(0, CRYSTAL_Y - .6, 0) + add_child(_leak) + _dust = Fx.dust_burst(70, 2.6, 2.6) + add_child(_dust) + _burst = Fx.shard_burst(color, 90, 8.0) + add_child(_burst) + _flash = Fx.flash_mesh(Color(color.r * 2.0, color.g * 2.0, color.b * 2.0, 1.0), 9.0) + _flash.position = Vector3(0, CRYSTAL_Y, 0) + _flash.visible = false + add_child(_flash) + _build_bar(11.2) + Materials.set_param(_instances, "fade_floor", global_position.y + FADE_FLOOR) + +func _collect(node: Node) -> Array: + var result: Array = [] + if node is GeometryInstance3D: + result.append(node) + for child: Node in node.get_children(): + result.append_array(_collect(child)) + return result + +func _param(group: Array, group_key: String, parameter: String, value: float, epsilon: float = .01) -> void: + var key: String = group_key + ":" + parameter + if _sent.has(key) and absf(float(_sent[key]) - value) < epsilon: + return + _sent[key] = value + Materials.set_param(group, parameter, value) + +func _build_bar(height: float) -> void: + _bar = Node3D.new() + _bar.name = "HealthBar" + _bar.position.y = height + add_child(_bar) + var width: float = 3.4 + var back := MeshInstance3D.new() + var quad := QuadMesh.new() + quad.size = Vector2(width + .1, .26) + back.mesh = quad + var back_material := StandardMaterial3D.new() + back_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + back_material.albedo_color = Color(.03, .03, .05, .78) + back_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + back_material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED + back_material.no_depth_test = true + back.material_override = back_material + back.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _bar.add_child(back) + _bar_fill = MeshInstance3D.new() + var fill := QuadMesh.new() + fill.size = Vector2(width, .17) + fill.center_offset = Vector3(width * .5, 0, 0) + _bar_fill.mesh = fill + var fill_material := StandardMaterial3D.new() + fill_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + fill_material.albedo_color = Materials.team_color(team, "light").lightened(.15) + fill_material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED + fill_material.billboard_keep_scale = true + fill_material.no_depth_test = true + fill_material.render_priority = 1 + _bar_fill.material_override = fill_material + _bar_fill.position.x = -width * .5 + _bar_fill.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _bar.add_child(_bar_fill) + _bar.visible = false + +## Height of the dais or plaza surface above the plateau at a local ground distance. +static func surface_lift(distance: float) -> float: + var lift: float = 0.0 + for step: Array in DAIS_STEPS: + if distance < float(step[0]): + lift = float(step[1]) + return lift + +func is_standing() -> bool: + return state != State.COLLAPSING and state != State.FALLEN + +## World box of the part that can hide figures (above the fade floor); empty once fallen. +func occluder_box() -> AABB: + if not is_standing(): + return AABB() + return AABB(global_position + Vector3(-OCCLUDER_RADIUS, FADE_FLOOR, -OCCLUDER_RADIUS), + Vector3(OCCLUDER_RADIUS * 2.0, TOP - FADE_FLOOR, OCCLUDER_RADIUS * 2.0)) + +func current_fade() -> float: + return _fade + +## 0..1 energy of the heart for the floor veins (dims with damage, gone when fallen). +func vein_energy() -> float: + return {State.STABLE: 1.0, State.UNSTABLE: .75, State.CRITICAL: .5}.get(state, 0.0) * (1.0 + _hit * .8) + +func vein_flicker() -> float: + return {State.STABLE: 0.0, State.UNSTABLE: .25, State.CRITICAL: .7}.get(state, 1.0) + +func set_health(value: float, max_value: float, animate: bool = true) -> void: + var previous: float = fraction + fraction = clampf(value / maxf(1.0, max_value), 0.0, 1.0) + if fraction < previous - .0003 and value > 0.0: + _hit = 1.0 + var next: int = State.STABLE + if value <= 0.0: + if state == State.COLLAPSING or state == State.FALLEN: + next = state + else: + next = State.COLLAPSING if animate else State.FALLEN + elif fraction < CRITICAL_BELOW: + next = State.CRITICAL + elif fraction < UNSTABLE_BELOW: + next = State.UNSTABLE + if next != state: + _enter(next) + _bar.visible = value > 0.0 and fraction < .999 + _bar_fill.scale.x = maxf(.001, fraction) + +func _enter(next: int) -> void: + var from: int = state + state = next + var critical: bool = next == State.CRITICAL + _sparks.emitting = next == State.UNSTABLE or critical + _sparks.amount_ratio = .55 if next == State.UNSTABLE else 1.0 + _smoke.emitting = critical + _leak.emitting = critical + _ring_slipped = critical + _sag = {State.UNSTABLE: .6, State.CRITICAL: 1.0}.get(next, 0.0) + _motes.emitting = next in [State.STABLE, State.UNSTABLE, State.CRITICAL] + _motes.amount_ratio = {State.STABLE: .6, State.UNSTABLE: .8, State.CRITICAL: 1.0}.get(next, 0.0) + var damage: float = {State.STABLE: 0.0, State.UNSTABLE: .45, State.CRITICAL: .85}.get(next, .9) + _param(_structure, "structure", "damage", damage) + _param(_structure, "structure", "crack_glow", {State.UNSTABLE: .8, State.CRITICAL: 1.5}.get(next, 0.0)) + _param(_structure, "structure", "scorch", {State.STABLE: 0.0, State.UNSTABLE: .2, State.CRITICAL: .45}.get(next, .75)) + _param(_cloth, "cloth", "tatter", {State.STABLE: 0.0, State.UNSTABLE: .3, State.CRITICAL: .6}.get(next, .85)) + match next: + State.CRITICAL: + # Two blades have come loose (one leans far out), two fragments lie on the dais. + for spec: Array in [[0, -.3, -.1], [2, .14, -.04]]: + var blade: Node3D = blades[int(spec[0])] + var axis: Vector3 = _tangent(int(spec[0])) + var rest_blade: Transform3D = _rest[str(blade.name)] + blade.transform = Transform3D(Basis(axis, float(spec[1])) * rest_blade.basis, rest_blade.origin + Vector3(0, float(spec[2]), 0)) + _fallen_fragment = 2 + State.COLLAPSING: + _collapse_time = 0.0 + _burst_fired = false + State.FALLEN: + _collapse_time = -1.0 + _pose_fallen(1.0) + _leak.emitting = false + _smoke.emitting = from == State.COLLAPSING + _smoke.amount_ratio = 1.0 + _smoke_left = 25.0 if from == State.COLLAPSING else 0.0 + light.visible = false + _bar.visible = false + Materials.set_param(_ruin, "energy", .16) + Materials.set_param(_ruin, "flicker", .7) + Materials.set_param(_ruin, "fracture", .8) + Materials.set_param(_ruin, "damage", .7) + _param(_structure, "structure", "energy", .04) + _param(_structure, "structure", "flicker", 0.0) + +func _tangent(index: int) -> Vector3: + var a: float = BLADE_ANGLES[index] + return Vector3(-sin(a), 0, cos(a)) + +func process_view(delta: float, _sim_delta: float) -> void: + _time += delta + _hit = maxf(0.0, _hit - delta * 2.5) + var calm: float = {State.STABLE: 1.0, State.UNSTABLE: .85, State.CRITICAL: .7}.get(state, 0.0) + var unrest: float = {State.STABLE: 0.0, State.UNSTABLE: .35, State.CRITICAL: 1.0}.get(state, 0.0) + if is_standing(): + var jitter: float = sin(_time * 21.0) * sin(_time * 7.7) * .02 * unrest + crystal.position = (_rest["Crystal"] as Transform3D).origin + Vector3(jitter, sin(_time * 1.1) * .07 * calm, 0) + crystal.rotation.y += delta * (.18 + unrest * .25) + for index: int in range(satellites.size()): + var a: float = _time * (.42 + unrest * .2) + TAU * index / satellites.size() + var r: float = 1.25 + .1 * sin(_time * .9 + index) + var y: float = CRYSTAL_Y - 1.1 + .35 * sin(_time * .7 + index * 1.3) + if index == 0 and _sag > 0.0: + # One satellite has lost its hold: it sags and trembles. + y -= 1.1 * _sag + r += .35 * _sag + sin(_time * 13.0) * .03 * _sag + satellites[index].position = Vector3(cos(a) * r, y, sin(a) * r) + satellites[index].rotation.y = -a + for index: int in range(fragments.size()): + if index < _fallen_fragment: + fragments[index].position = _fragment_rest(index) + continue + var rest: Vector3 = (_rest[str(fragments[index].name)] as Transform3D).origin + var angle: float = atan2(rest.z, rest.x) + _time * .12 + var radius: float = Vector2(rest.x, rest.z).length() + fragments[index].position = Vector3(cos(angle) * radius, rest.y + sin(_time * .8 + index) * .18, sin(angle) * radius) + fragments[index].rotation.y += delta * .5 + var wobble: float = unrest * .08 + rings[0].rotation = Vector3(sin(_time * .6) * wobble, _time * .32, sin(_time * .9) * wobble) + if _ring_slipped: + # The inner ring has slipped from its bearing: tilted, grinding round in jerks. + var jerk: float = floorf(_time * 1.5) * .35 + smoothstep(0.0, .25, fmod(_time * 1.5, 1.0)) * .35 + rings[1].rotation = Vector3(.5 + sin(_time * 2.3) * .03, -jerk, .22) + else: + rings[1].rotation = Vector3(sin(_time * .8 + 1.0) * wobble * 1.6, -_time * .46, cos(_time * .7) * wobble * 1.6) + var flicker: float = {State.STABLE: 0.0, State.UNSTABLE: .25, State.CRITICAL: .85}.get(state, .9) + _param(_crystals, "crystal", "energy", calm * (1.0 + _hit * 1.4), .02) + _param(_crystals, "crystal", "flicker", flicker) + _param(_crystals, "crystal", "fracture", unrest * .8) + _param(_structure, "structure", "energy", .45 + calm * .55 + _hit * .5, .02) + _param(_structure, "structure", "flicker", flicker * .8) + var pulse: float = 1.0 + if flicker > 0.0: + pulse = lerpf(1.0, .5 + .5 * absf(sin(_time * 17.0 + seed_value) * sin(_time * 5.3)), flicker) + light.light_energy = (1.0 + calm * 1.3) * pulse * (1.0 + _hit * .8) + elif state == State.COLLAPSING: + _collapse_step(delta) + elif state == State.FALLEN and _smoke_left > 0.0: + _smoke_left -= delta + if _smoke_left <= 0.0: + _smoke.emitting = false + _fade = move_toward(_fade, fade_target, delta * 3.0) + _param(_instances, "all", "fade", _fade, .02) + +func _fragment_rest(index: int) -> Vector3: + var rest: Vector3 = (_rest[str(fragments[index].name)] as Transform3D).origin + var flat := Vector2(rest.x, rest.z).normalized() * 2.95 + return Vector3(flat.x, .32, flat.y) + +## Collapse (2.8 s): the heart overloads, implodes and bursts; the blades crumble into the +## dust, rings and crown fall onto the pedestal, fragments drop; the ruin remains. +func _collapse_step(delta: float) -> void: + _collapse_time += delta + var t: float = _collapse_time + var overload: float = smoothstep(0.0, .5, t) * (1.0 - smoothstep(.55, .75, t)) + if crystal.visible: + crystal.scale = Vector3.ONE * maxf(.05, 1.0 + overload * .14 - smoothstep(.5, .72, t) * .95) + crystal.rotation.y += delta * (1.0 + overload * 6.0) + Materials.set_param(_crystals, "energy", 1.0 + overload * 3.5) + Materials.set_param(_crystals, "flicker", 1.0) + light.light_energy = 2.2 + overload * 7.0 if t < .8 else maxf(0.0, 9.0 * (1.0 - (t - .8) / .9)) + for index: int in range(rings.size()): + rings[index].rotation.y += delta * (2.0 + overload * 8.0) * (1.0 if index == 0 else -1.0) + if t >= .7 and not _burst_fired: + _burst_fired = true + Fx.fire(_burst, crystal.global_position) + Fx.fire(_dust, global_position + Vector3(0, .4, 0)) + crystal.visible = false + for satellite: Node3D in satellites: + satellite.visible = false + (_parts["Ruin_Crystal"] as Node3D).visible = true + (_parts["Ruin_Rubble"] as Node3D).visible = true + _flash.visible = true + _smoke.emitting = true + if _flash.visible: + var k: float = clampf((t - .7) / .55, 0.0, 1.0) + _flash.scale = Vector3.ONE * lerpf(.4, 1.6, k) + _flash.transparency = k + if k >= 1.0: + _flash.visible = false + if t >= .7: + _pose_fallen(smoothstep(.7, 2.2, t)) + if t >= COLLAPSE_SECONDS: + _enter(State.FALLEN) + +## Interpolates the falling parts from where they stand to their resting place (k = 0..1). +func _pose_fallen(k: float) -> void: + var fall: float = k * k + for index: int in range(blades.size()): + var blade: Node3D = blades[index] + var rest: Transform3D = _rest[str(blade.name)] + var axis: Vector3 = _tangent(index) + var out := Vector3(cos(BLADE_ANGLES[index]), 0, sin(BLADE_ANGLES[index])) + blade.transform = Transform3D(Basis(axis, -.45 * fall) * rest.basis, rest.origin + out * .8 * fall + Vector3(0, -2.6 * fall, 0)) + blade.scale = Vector3.ONE * lerpf(1.0, .02, smoothstep(.45, .9, k)) + blade.visible = k < .9 + var landings: Array = [[Vector3(.25, 1.42, -.1), Vector3(.34, 0, .12)], [Vector3(-.3, 1.5, .15), Vector3(-.28, 0, -.42)]] + for index: int in range(rings.size()): + var rest: Transform3D = _rest[str(rings[index].name)] + var land: Array = landings[index] + rings[index].position = rest.origin.lerp(land[0], fall) + rings[index].rotation = (rings[index].rotation as Vector3).lerp(land[1], k) + for node: Node3D in [crown, crest]: + var rest: Transform3D = _rest[str(node.name)] + node.position = rest.origin.lerp(Vector3(.2, 1.62, .1), fall) + node.rotation = Vector3(.45, .3, .22) * k + for index: int in range(fragments.size()): + var start: Vector3 = (_rest[str(fragments[index].name)] as Transform3D).origin + fragments[index].position = start.lerp(_fragment_rest(index), fall) + fragments[index].rotation = Vector3(1.2, float(index), .4) * k + if k >= 1.0: + crystal.visible = false + for satellite: Node3D in satellites: + satellite.visible = false + (_parts["Ruin_Crystal"] as Node3D).visible = true + (_parts["Ruin_Rubble"] as Node3D).visible = true + Materials.set_param(_crystals, "energy", .12) + Materials.set_param(_crystals, "flicker", .8) diff --git a/scripts/luneraria/view/lune_ancient_view.gd.uid b/scripts/luneraria/view/lune_ancient_view.gd.uid new file mode 100644 index 0000000..d6a9c5a --- /dev/null +++ b/scripts/luneraria/view/lune_ancient_view.gd.uid @@ -0,0 +1 @@ +uid://ctsbo0r6aqgcg diff --git a/scripts/luneraria/view/lune_fx.gd b/scripts/luneraria/view/lune_fx.gd new file mode 100644 index 0000000..1662c33 --- /dev/null +++ b/scripts/luneraria/view/lune_fx.gd @@ -0,0 +1,242 @@ +extends RefCounted +## Particle and glow building blocks for the strategic buildings (G2). Every effect is a +## small, bounded GPUParticles3D (no per-frame physics debris): sparks, smoke, dust bursts, +## crystal shard bursts and rising motes, plus soft sprite materials. Effects are purely +## visual and never feed back into the simulation. + +static var _soft: Texture2D +static var _cache: Dictionary = {} + +## Radial soft dot used by smoke, dust, motes and flashes. +static func soft_texture() -> Texture2D: + if _soft == null: + var gradient := Gradient.new() + gradient.set_color(0, Color(1, 1, 1, 1)) + gradient.set_color(1, Color(1, 1, 1, 0)) + gradient.add_point(.35, Color(1, 1, 1, .55)) + var texture := GradientTexture2D.new() + texture.gradient = gradient + texture.fill = GradientTexture2D.FILL_RADIAL + texture.fill_from = Vector2(.5, .5) + texture.fill_to = Vector2(1.0, .5) + texture.width = 64 + texture.height = 64 + _soft = texture + return _soft + +## Billboard sprite material: additive glow (sparks, motes, flashes) or alpha (smoke, dust). +static func sprite_material(color: Color, additive: bool, energy: float = 1.0) -> StandardMaterial3D: + var key: String = "%s:%s:%.2f" % [color.to_html(), additive, energy] + if _cache.has(key): + return _cache[key] + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD if additive else BaseMaterial3D.BLEND_MODE_MIX + material.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES + material.vertex_color_use_as_albedo = true + material.albedo_texture = soft_texture() + material.albedo_color = Color(color.r * energy, color.g * energy, color.b * energy, color.a) + material.no_depth_test = false + material.disable_receive_shadows = true + _cache[key] = material + return material + +static func _quad(size: float, material: Material) -> QuadMesh: + var quad := QuadMesh.new() + quad.size = Vector2(size, size) + quad.material = material + return quad + +static func _ramp(stops: Array) -> GradientTexture1D: + var gradient := Gradient.new() + gradient.offsets = PackedFloat32Array(stops.map(func(s: Array) -> float: return s[0])) + gradient.colors = PackedColorArray(stops.map(func(s: Array) -> Color: return s[1])) + var texture := GradientTexture1D.new() + texture.gradient = gradient + return texture + +static func _curve(points: Array) -> CurveTexture: + var curve := Curve.new() + for p: Vector2 in points: + curve.add_point(p) + var texture := CurveTexture.new() + texture.curve = curve + return texture + +static func _base(name: String, amount: int, lifetime: float, box: AABB) -> GPUParticles3D: + var particles := GPUParticles3D.new() + particles.name = name + particles.amount = amount + particles.lifetime = lifetime + particles.visibility_aabb = box + particles.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + particles.local_coords = false + particles.emitting = false + return particles + +## Continuous sparks falling from damaged masonry and metal. +static func sparks(color: Color, amount: int = 14, radius: float = 1.0, height: float = 2.0) -> GPUParticles3D: + var particles := _base("Sparks", amount, .9, AABB(Vector3(-4, -1, -4), Vector3(8, 9, 8))) + var process := ParticleProcessMaterial.new() + process.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_RING + process.emission_ring_axis = Vector3.UP + process.emission_ring_radius = radius + process.emission_ring_inner_radius = radius * .6 + process.emission_ring_height = height + process.direction = Vector3(0, 1, 0) + process.spread = 60.0 + process.initial_velocity_min = .6 + process.initial_velocity_max = 2.2 + process.gravity = Vector3(0, -5.5, 0) + process.scale_min = .5 + process.scale_max = 1.0 + process.color_ramp = _ramp([[0.0, Color(1, 1, 1, 1)], [.5, Color(1, .8, .5, .9)], [1.0, Color(1, .4, .2, 0)]]) + particles.process_material = process + particles.draw_pass_1 = _quad(.07, sprite_material(color, true, 3.0)) + return particles + +## Thin rising smoke (damage) or a heavier plume (ruin, destruction). +static func smoke(amount: int = 10, radius: float = .8, dark: float = .22, size: float = 1.1, lifetime: float = 3.2) -> GPUParticles3D: + var particles := _base("Smoke", amount, lifetime, AABB(Vector3(-5, -1, -5), Vector3(10, 14, 10))) + var process := ParticleProcessMaterial.new() + process.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE + process.emission_sphere_radius = radius + process.direction = Vector3(0, 1, 0) + process.spread = 18.0 + process.initial_velocity_min = .35 + process.initial_velocity_max = .8 + process.gravity = Vector3(.18, .25, .08) + process.damping_min = .2 + process.damping_max = .5 + process.scale_min = .7 + process.scale_max = 1.3 + process.scale_curve = _curve([Vector2(0, .35), Vector2(1, 1.6)]) + process.angle_min = 0.0 + process.angle_max = 360.0 + process.color_ramp = _ramp([[0.0, Color(dark, dark, dark * 1.05, 0)], [.15, Color(dark, dark, dark * 1.05, .42)], [1.0, Color(dark * 1.3, dark * 1.3, dark * 1.35, 0)]]) + particles.process_material = process + particles.draw_pass_1 = _quad(size, sprite_material(Color(1, 1, 1, 1), false)) + return particles + +## One-shot dust and rubble burst at the foot of a collapsing structure. +static func dust_burst(amount: int = 36, radius: float = 1.4, size: float = 1.6, color: Color = Color(.62, .58, .52)) -> GPUParticles3D: + var particles := _base("DustBurst", amount, 1.9, AABB(Vector3(-7, -1, -7), Vector3(14, 10, 14))) + particles.one_shot = true + particles.explosiveness = .85 + var process := ParticleProcessMaterial.new() + process.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_RING + process.emission_ring_axis = Vector3.UP + process.emission_ring_radius = radius + process.emission_ring_inner_radius = radius * .3 + process.emission_ring_height = .6 + process.direction = Vector3(0, .35, 0) + process.spread = 80.0 + process.initial_velocity_min = 1.2 + process.initial_velocity_max = 3.4 + process.gravity = Vector3(0, -.6, 0) + process.damping_min = 1.6 + process.damping_max = 2.6 + process.scale_curve = _curve([Vector2(0, .4), Vector2(1, 1.8)]) + process.angle_max = 360.0 + process.color_ramp = _ramp([[0.0, Color(color.r, color.g, color.b, .75)], [1.0, Color(color.r, color.g, color.b, 0)]]) + particles.process_material = process + particles.draw_pass_1 = _quad(size, sprite_material(Color(1, 1, 1, 1), false)) + return particles + +## One-shot burst of glowing crystal fragments (crystal failure, Ancient collapse). +static func shard_burst(color: Color, amount: int = 40, speed: float = 5.0) -> GPUParticles3D: + var particles := _base("ShardBurst", amount, 1.3, AABB(Vector3(-9, -2, -9), Vector3(18, 14, 18))) + particles.one_shot = true + particles.explosiveness = .95 + var process := ParticleProcessMaterial.new() + process.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE + process.emission_sphere_radius = .4 + process.direction = Vector3(0, 1, 0) + process.spread = 180.0 + process.initial_velocity_min = speed * .4 + process.initial_velocity_max = speed + process.gravity = Vector3(0, -7.0, 0) + process.scale_min = .5 + process.scale_max = 1.2 + process.color_ramp = _ramp([[0.0, Color(1, 1, 1, 1)], [.6, Color(1, 1, 1, .8)], [1.0, Color(1, 1, 1, 0)]]) + particles.process_material = process + var mesh := PrismMesh.new() + mesh.size = Vector3(.06, .16, .06) + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.vertex_color_use_as_albedo = true + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.albedo_color = Color(color.r * 1.35, color.g * 1.35, color.b * 1.35) + mesh.material = material + particles.draw_pass_1 = mesh + return particles + +## Slow rising motes of energy around crystals (ambient; few particles). +static func motes(color: Color, amount: int = 18, radius: float = 2.0, height: float = 3.0, lifetime: float = 4.0) -> GPUParticles3D: + var particles := _base("Motes", amount, lifetime, AABB(Vector3(-6, -1, -6), Vector3(12, 14, 12))) + var process := ParticleProcessMaterial.new() + process.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_RING + process.emission_ring_axis = Vector3.UP + process.emission_ring_radius = radius + process.emission_ring_inner_radius = radius * .3 + process.emission_ring_height = height + process.direction = Vector3(0, 1, 0) + process.spread = 25.0 + process.initial_velocity_min = .15 + process.initial_velocity_max = .45 + process.gravity = Vector3(0, .08, 0) + process.tangential_accel_min = .2 + process.tangential_accel_max = .5 + process.scale_min = .6 + process.scale_max = 1.2 + process.color_ramp = _ramp([[0.0, Color(1, 1, 1, 0)], [.2, Color(1, 1, 1, 1)], [1.0, Color(1, 1, 1, 0)]]) + particles.process_material = process + particles.draw_pass_1 = _quad(.09, sprite_material(color, true, 2.2)) + return particles + +## Energy escaping from a cracked crystal: fast, bright sparks shooting upwards in bursts. +static func leak(color: Color, amount: int = 30, radius: float = .8) -> GPUParticles3D: + var particles := _base("Leak", amount, 1.1, AABB(Vector3(-5, -2, -5), Vector3(10, 14, 10))) + particles.explosiveness = .35 + particles.randomness = .6 + var process := ParticleProcessMaterial.new() + process.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE + process.emission_sphere_radius = radius + process.direction = Vector3(0, 1, 0) + process.spread = 35.0 + process.initial_velocity_min = 2.2 + process.initial_velocity_max = 4.6 + process.gravity = Vector3(0, -1.2, 0) + process.damping_min = .8 + process.damping_max = 1.6 + process.scale_min = .6 + process.scale_max = 1.3 + process.color_ramp = _ramp([[0.0, Color(1, 1, 1, 1)], [.6, Color(1, 1, 1, .7)], [1.0, Color(1, 1, 1, 0)]]) + particles.process_material = process + particles.draw_pass_1 = _quad(.1, sprite_material(color, true, 3.2)) + return particles + +## Additive billboard flash (impact, overload); animate its scale and transparency. +static func flash_mesh(color: Color, size: float = 1.0) -> MeshInstance3D: + var flash := MeshInstance3D.new() + var quad := QuadMesh.new() + quad.size = Vector2(size, size) + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD + material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED + material.albedo_texture = soft_texture() + material.albedo_color = color + material.disable_receive_shadows = true + quad.material = material + flash.mesh = quad + flash.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + return flash + +## Restarts a one-shot effect at a position. +static func fire(particles: GPUParticles3D, at: Vector3) -> void: + particles.global_position = at + particles.restart() + particles.emitting = true diff --git a/scripts/luneraria/view/lune_fx.gd.uid b/scripts/luneraria/view/lune_fx.gd.uid new file mode 100644 index 0000000..7a57e26 --- /dev/null +++ b/scripts/luneraria/view/lune_fx.gd.uid @@ -0,0 +1 @@ +uid://ducbaqvu3imd8 diff --git a/scripts/luneraria/view/lune_grotto_view.gd b/scripts/luneraria/view/lune_grotto_view.gd new file mode 100644 index 0000000..3068df1 --- /dev/null +++ b/scripts/luneraria/view/lune_grotto_view.gd @@ -0,0 +1,305 @@ +extends Node3D +## The boss grotto "Mondschlund" (G2): the Blender sanctuary on the G1 terrace, its five +## states and the resting boss. It reads only public match state (round, boss claim and lock +## round, the grotto boss of the running combat) and never writes into the simulation. +## AVAILABLE the boss rests on its dais, runes, source and cave light are awake +## FIGHT expeditions are inside: stronger light, faster pulse; the boss is a real unit +## DEPLOYED ("nicht verfügbar") the boss fights on a lane for the claiming team: dimmed, +## the runes keep a faint glow in that team's colour +## COOLDOWN exhausted energy after the boss fell: dark, cracked source, no boss +## SOON the last locked round: first energy returns, runes flicker awake +## The cooldown rules themselves stay in LuneMatch; this only mirrors them. + +const Materials = preload("res://scripts/luneraria/view/lune_materials.gd") +const Fx = preload("res://scripts/luneraria/view/lune_fx.gd") +enum State { AVAILABLE, FIGHT, DEPLOYED, COOLDOWN, SOON } +const STATE_NAMES: Array[String] = ["verfuegbar", "kampf", "nicht_verfuegbar", "cooldown", "bald_verfuegbar"] +const SCENE_PATH := "res://assets/luneraria/g2/bossgrotte.glb" +const BOSS_MODEL := "schlundkrone" +const FALLBACK_BOSS := "e10" +## Local boss point and the source crystal cluster on the altar (see build_bossgrotte.py). +const BOSS_LOCAL := Vector3(-1.5, 0, -1.5) +const SOURCE_LOCAL := Vector3(-3.13, 1.5, -3.13) +const CAVE_LOCAL := Vector3(-7.46, .5, -7.46) +## Floor and dais heights above the terrace (figures stand on them). +const FLOOR_LIFT: float = .088 +const DAIS: Array = [[1.64, .124], [2.24, .065]] +## Visual targets per state: runes, crystals, moon water, cave light, source light, motes. +const LOOK: Dictionary = { + State.AVAILABLE: {"runes": 1.0, "rune_flicker": 0.0, "crystal": 1.0, "crystal_flicker": 0.0, "fracture": 0.0, "water": 1.0, + "cave": 2.0, "source": 1.2, "motes": .8}, + State.FIGHT: {"runes": 1.45, "rune_flicker": .12, "crystal": 1.45, "crystal_flicker": .2, "fracture": 0.0, "water": 1.3, + "cave": 2.4, "source": 1.6, "motes": 1.0}, + State.DEPLOYED: {"runes": .28, "rune_flicker": 0.0, "crystal": .22, "crystal_flicker": .25, "fracture": .5, "water": .22, + "cave": .55, "source": .35, "motes": 0.0}, + State.COOLDOWN: {"runes": .05, "rune_flicker": .5, "crystal": .07, "crystal_flicker": .7, "fracture": .9, "water": .05, + "cave": .22, "source": 0.0, "motes": 0.0}, + State.SOON: {"runes": .5, "rune_flicker": .45, "crystal": .45, "crystal_flicker": .35, "fracture": .3, "water": .4, + "cave": 1.2, "source": .8, "motes": .3}, +} + +var state: int = State.AVAILABLE +## Team whose colour the runes carry while the boss is deployed (-1 = neutral). +var claim_team: int = -1 +var fade_target: float = 0.0 +var _fade: float = 0.0 +var boss_figure: Node3D +var boss_visible: bool = false +var cave_light: OmniLight3D +var source_light: OmniLight3D +var _parts: Dictionary = {} +var _instances: Array = [] +var _runes: Array = [] +var _crystals: Array = [] +var _water: Array = [] +var _stone: Array = [] +var _look: Dictionary = {} +## The back of the sanctuary (gate, cave, rocks) is what can hide Top-lane figures; only it fades. +var _back: Array = [] +var _sent: Dictionary = {} +var _motes: GPUParticles3D +var _sparks: GPUParticles3D +var _burst: GPUParticles3D +var _dust: GPUParticles3D +var _flash: MeshInstance3D +var _flash_time: float = -1.0 +var _time: float = 0.0 +var _pulse: float = 0.0 +var _boss_awake: bool = false +var _ready_once: bool = false +var _victory_played: bool = false + +func setup() -> void: + name = "BossGrotto" + var model: Node3D = (load(SCENE_PATH) as PackedScene).instantiate() + model.name = "Model" + add_child(model) + for child: Node in model.get_children(): + _parts[str(child.name)] = child + _instances = Materials.dress(model) + Materials.apply_team(_instances, 2, 4.2) + for key: String in _parts: + var list: Array = _collect(_parts[key]) + if key in ["Gate", "Rocks", "Cave", "CaveGlow"]: + _back.append_array(list) + match key: + "Runes": + _runes.append_array(list) + "Source", "PillarCrystals", "CaveGlow": + _crystals.append_array(list) + "Channel": + _water.append_array(list) + "Cave": + pass + _: + _stone.append_array(list) + Materials.apply_rune_tint(_runes, 2) + # The terrace sits 1.7 m up; the dither fade starts above head height of figures on it. + Materials.set_param(_instances, "fade_floor", position.y + 2.2) + for group: Array in [_stone, _crystals, _water]: + for instance: GeometryInstance3D in group: + instance.set_instance_shader_parameter("tint", Materials.team_color(2, "crystal")) + cave_light = OmniLight3D.new() + cave_light.name = "CaveLight" + cave_light.position = CAVE_LOCAL + cave_light.light_color = Materials.team_color(2, "light") + cave_light.omni_range = 7.5 + cave_light.omni_attenuation = 1.6 + add_child(cave_light) + source_light = OmniLight3D.new() + source_light.name = "SourceLight" + source_light.position = SOURCE_LOCAL + Vector3(0, .6, 0) + source_light.light_color = Materials.team_color(2, "light").lightened(.4) + source_light.omni_range = 6.5 + source_light.omni_attenuation = 1.5 + add_child(source_light) + var violet: Color = Materials.team_color(2, "crystal") + _motes = Fx.motes(violet, 26, 2.2, 3.2) + _motes.position = BOSS_LOCAL + Vector3(0, .4, 0) + add_child(_motes) + _sparks = Fx.sparks(Materials.team_color(2, "light"), 18, 2.6, 1.4) + _sparks.position = BOSS_LOCAL + Vector3(0, .5, 0) + add_child(_sparks) + _burst = Fx.shard_burst(violet, 60, 6.0) + add_child(_burst) + _dust = Fx.dust_burst(40, 1.6, 1.8, Color(.5, .47, .52)) + add_child(_dust) + _flash = Fx.flash_mesh(Color(violet.r * 2.0, violet.g * 2.0, violet.b * 2.0, 1.0), 6.0) + _flash.position = SOURCE_LOCAL + _flash.visible = false + add_child(_flash) + _make_boss() + _look = (LOOK[State.AVAILABLE] as Dictionary).duplicate() + _apply_look(0.0, true) + +func _collect(node: Node) -> Array: + var result: Array = [] + if node is GeometryInstance3D: + result.append(node) + for child: Node in node.get_children(): + result.append_array(_collect(child)) + return result + +## The boss resting on its dais while the grotto is open: the same figure the fight uses. +func _make_boss() -> void: + var id: String = BOSS_MODEL if AssetLibrary.has_model(BOSS_MODEL) else FALLBACK_BOSS + boss_figure = AssetLibrary.instantiate_fighter(id) + if boss_figure == null: + return + boss_figure.name = "RestingBoss" + boss_figure.scale = Vector3.ONE * 1.7 + boss_figure.position = BOSS_LOCAL + Vector3(0, DAIS[0][1], 0) + # It faces the arena and the two stairs (towards Mid). + boss_figure.rotation.y = atan2(LuneMap.AXIS.x, LuneMap.AXIS.y) + add_child(boss_figure) + if AssetLibrary.has_model(BOSS_MODEL): + _tint_boss(boss_figure, 2) + +## Crystal glow of the boss figure follows the team it fights for (neutral violet in the grotto). +static func _tint_boss(figure: Node3D, team: int) -> void: + for instance: GeometryInstance3D in Materials.dress(figure): + instance.set_instance_shader_parameter("tint", Materials.team_color(team, "crystal")) + instance.set_instance_shader_parameter("seed", 1.7) + # Resting: the veins glow at half strength until someone enters the grotto. + instance.set_instance_shader_parameter("energy", .55) + +## Height of the grotto floor or boss dais above the terrace at a local ground distance. +static func surface_lift(local: Vector2) -> float: + var d: float = local.distance_to(Vector2(BOSS_LOCAL.x, BOSS_LOCAL.z)) + for step: Array in DAIS: + if d < float(step[0]): + return float(step[1]) + return FLOOR_LIFT if local.length() < LuneTerrain.ARENA_RADIUS - .2 else 0.0 + +## The state from the public match state (static so tests can ask without a scene). +static func state_for(match_state: LuneMatch) -> int: + var combat: LuneCombat = match_state.combat if match_state.phase == LuneMatch.Phase.COMBAT else null + if combat != null and combat.grotto_boss != null: + if combat.grotto_boss.alive: + return State.FIGHT + return State.COOLDOWN + var claim: Dictionary = match_state.boss.claim + if not claim.is_empty(): + return State.DEPLOYED + var next: int = int(match_state.boss.next_available_round) + if match_state.round_number >= next: + return State.AVAILABLE + if match_state.round_number == next - 1: + return State.SOON + return State.COOLDOWN + +func state_name() -> String: + return STATE_NAMES[state] + +## World box of the gate and its rocks (the part that can hide Top-lane figures behind it). +func occluder_box() -> AABB: + var box := AABB() + var first: bool = true + for local: Vector3 in [Vector3(-9.6, 2.2, -2.2), Vector3(-2.2, 2.2, -9.6), Vector3(-5.1, 7.4, -9.8), Vector3(-9.8, 7.4, -5.1), + Vector3(-4.2, 2.2, -4.2), Vector3(-8.6, 7.4, -8.6)]: + var point: Vector3 = to_global(local) + box = AABB(point, Vector3.ZERO) if first else box.expand(point) + first = false + return box + +func current_fade() -> float: + return _fade + +## Called every frame with the match and the unit views (to find the fighting boss). +func sync(match_state: LuneMatch, views: Dictionary, delta: float) -> void: + _time += delta + var next: int = state_for(match_state) + var claim: Dictionary = match_state.boss.claim + var team: int = int(claim.team) if not claim.is_empty() else -1 + if next != state or team != claim_team: + _enter(next, team) + var combat: LuneCombat = match_state.combat if match_state.phase == LuneMatch.Phase.COMBAT else null + var fighter: LuneUnit = combat.grotto_boss if combat != null else null + # The resting figure stands in whenever no boss unit is on the dais. + boss_visible = state == State.AVAILABLE and fighter == null + if boss_figure != null: + boss_figure.visible = boss_visible + if boss_visible: + _animate_resting(delta) + if fighter != null and fighter.alive and not fighter.dormant and not _boss_awake: + _boss_awake = true + _wake(views.get(fighter.id)) + elif fighter == null or fighter.dormant: + _boss_awake = false + # The expeditions failed and the boss still stands: it claims its grotto (victory clip). + if fighter != null and fighter.alive and combat.grotto_resolved and combat.grotto_outcome == "failed" and not _victory_played: + _victory_played = true + var view: Node3D = views.get(fighter.id) + if view != null and view.model != null and view.model.has_method("play_event"): + view.model.play_event("victory") + elif fighter == null: + _victory_played = false + _pulse = maxf(0.0, _pulse - delta * 1.6) + _apply_look(delta) + _ready_once = true + +func _enter(next: int, team: int) -> void: + var previous: int = state + state = next + claim_team = team + var tint: Color = Materials.team_color(team, "rune") if next == State.DEPLOYED and team >= 0 else Materials.team_color(2, "rune") + Materials.set_param(_runes, "tint", tint) + _motes.emitting = float(LOOK[next].motes) > 0.0 + _motes.amount_ratio = maxf(.05, float(LOOK[next].motes)) + _sparks.emitting = next == State.FIGHT + if _ready_once and previous == State.FIGHT and next == State.COOLDOWN: + # The boss has fallen: the source overloads once and the energy collapses. + Fx.fire(_burst, to_global(SOURCE_LOCAL)) + Fx.fire(_dust, to_global(BOSS_LOCAL)) + _flash.visible = true + _flash_time = 0.0 + +## The boss wakes when one expedition remains: roar clip on its figure and a pulse of light. +func _wake(view: Node3D) -> void: + _pulse = 1.0 + if view != null and view.model != null and view.model.has_method("play_event"): + view.model.play_event("roar") + +func _animate_resting(_delta: float) -> void: + if boss_figure.has_method("preview"): + var length: float = boss_figure.clip_length("idle") if boss_figure.has_method("clip_length") else 3.0 + boss_figure.preview("idle", fmod(_time, maxf(.1, length))) + elif boss_figure.has_method("pose"): + boss_figure.pose(_time, false, 0.0, 0.0, 0.0) + +func _param(group: Array, key: String, parameter: String, value: float, epsilon: float = .01) -> void: + var id: String = key + ":" + parameter + if _sent.has(id) and absf(float(_sent[id]) - value) < epsilon: + return + _sent[id] = value + Materials.set_param(group, parameter, value) + +## Eases every visual channel towards the state's targets (1.2 s), plus the fight pulse. +func _apply_look(delta: float, instant: bool = false) -> void: + var target: Dictionary = LOOK[state] + for key: String in target: + _look[key] = float(target[key]) if instant else move_toward(float(_look[key]), float(target[key]), delta * maxf(.8, absf(float(target[key]) - float(_look[key])) / 1.2)) + var beat: float = 0.0 + if state == State.FIGHT: + beat = .25 * (.5 + .5 * sin(_time * 5.5)) + var boost: float = 1.0 + _pulse * 1.3 + beat + _param(_runes, "runes", "energy", float(_look.runes) * boost, .02) + _param(_runes, "runes", "flicker", float(_look.rune_flicker)) + _param(_crystals, "crystals", "energy", float(_look.crystal) * boost, .02) + _param(_crystals, "crystals", "flicker", float(_look.crystal_flicker)) + _param(_crystals, "crystals", "fracture", float(_look.fracture)) + _param(_water, "water", "energy", float(_look.water) * (1.0 + _pulse * .6), .02) + cave_light.light_energy = float(_look.cave) * (1.0 + _pulse * .8 + beat * .6) + source_light.light_energy = float(_look.source) * boost + cave_light.visible = cave_light.light_energy > .02 + source_light.visible = source_light.light_energy > .02 + if _flash_time >= 0.0: + _flash_time += delta + var k: float = clampf(_flash_time / .6, 0.0, 1.0) + _flash.scale = Vector3.ONE * lerpf(.3, 1.5, k) + _flash.transparency = k + if k >= 1.0: + _flash.visible = false + _flash_time = -1.0 + _fade = move_toward(_fade, fade_target, delta * 3.0) + _param(_back, "back", "fade", _fade, .02) diff --git a/scripts/luneraria/view/lune_grotto_view.gd.uid b/scripts/luneraria/view/lune_grotto_view.gd.uid new file mode 100644 index 0000000..403bccb --- /dev/null +++ b/scripts/luneraria/view/lune_grotto_view.gd.uid @@ -0,0 +1 @@ +uid://cue205dqngkov diff --git a/scripts/luneraria/view/lune_materials.gd b/scripts/luneraria/view/lune_materials.gd new file mode 100644 index 0000000..082083e --- /dev/null +++ b/scripts/luneraria/view/lune_materials.gd @@ -0,0 +1,146 @@ +extends RefCounted +## Shared material library of Champions of Luneraria (first set in G2; meant for G4/G5 too). +## GLBs from the G2 Blender generators name their surfaces LN_*; `dress` swaps them for one +## shared ShaderMaterial per name. Everything that differs per building or per team (team +## colours, energy, damage, occlusion fade, rune family, banner silhouette) is a per-instance +## shader parameter with a fixed slot (see the instance_index hints in the shaders), so all +## buildings share the same few materials. + +const StoneShader = preload("res://scripts/luneraria/view/materials/lune_stone.gdshader") +const MetalShader = preload("res://scripts/luneraria/view/materials/lune_metal.gdshader") +const CrystalShader = preload("res://scripts/luneraria/view/materials/lune_crystal.gdshader") +const RuneShader = preload("res://scripts/luneraria/view/materials/lune_rune.gdshader") +const ClothShader = preload("res://scripts/luneraria/view/materials/lune_cloth.gdshader") +const WaterGlowShader = preload("res://scripts/luneraria/view/materials/lune_water_glow.gdshader") +const HideShader = preload("res://scripts/luneraria/view/materials/lune_hide.gdshader") +const Limestone = preload("res://assets/materials/phase10/limestone-albedo.png") +const NEUTRAL: int = 2 + +## Team identity beyond colour: cool team (Blau) and warm team (Rot) differ in crystal and +## rune hue, metal (silver-steel / bronze), rune family, banner silhouette and emblem. +## Index 2 is the neutral grotto. +const TEAM_STYLE: Array[Dictionary] = [ + {"crystal": Color("78c6ff"), "rune": Color("86d2ff"), "metal": Color(.80, .85, .92), "cloth": Color("2a5aa8"), + "emblem": Color("e8eef8"), "light": Color("8cc8ff"), "pattern": 0.0, "shape": 0.0, "name": "kühl · Mondsichel · Silber"}, + {"crystal": Color("ff7d45"), "rune": Color("ffae5a"), "metal": Color(.92, .64, .34), "cloth": Color("9a2c20"), + "emblem": Color("f4d58c"), "light": Color("ffae72"), "pattern": 1.0, "shape": 1.0, "name": "warm · Sonnenscheibe · Bronze"}, + {"crystal": Color("b784ff"), "rune": Color("c29cff"), "metal": Color(.70, .62, .50), "cloth": Color("45305f"), + "emblem": Color("d9c8f0"), "light": Color("b37cff"), "pattern": 2.0, "shape": 0.0, "name": "neutral · Grotte"}, +] + +static var _cache: Dictionary = {} + +static func _shader_material(shader: Shader, params: Dictionary) -> ShaderMaterial: + var material := ShaderMaterial.new() + material.shader = shader + for key: String in params: + material.set_shader_parameter(key, params[key]) + return material + +## The shared material for an LN_* surface name, or null for foreign materials. +static func get_material(key: String) -> Material: + if _cache.has(key): + return _cache[key] + var material: Material = null + match key: + "LN_Stone_Pale": + material = _shader_material(StoneShader, {"base_color": Color(.72, .68, .61), "stone_map": Limestone, "lichen": .22}) + "LN_Stone_Dark": + material = _shader_material(StoneShader, {"base_color": Color(.30, .30, .33), "stone_map": Limestone, "lichen": .12, + "course_height": .34, "block_length": .66, "roughness_base": .9}) + "LN_Stone_Grotto": + material = _shader_material(StoneShader, {"base_color": Color(.43, .43, .46), "stone_map": Limestone, "lichen": .3, + "course_height": .5, "block_length": .9, "tint_variation": .07}) + "LN_Rock": + material = _shader_material(StoneShader, {"base_color": Color(.38, .38, .40), "stone_map": Limestone, "lichen": .45, + "ashlar": false, "texture_scale": .55, "tint_variation": .05, "roughness_base": .93}) + "LN_Metal_Team": + material = _shader_material(MetalShader, {"base_color": Color(.92, .92, .92), "roughness_base": .32, "wear": .6}) + "LN_Metal_Iron": + material = _shader_material(MetalShader, {"base_color": Color(.24, .24, .26), "roughness_base": .52, "metallic_value": .8, "wear": .8}) + "LN_Metal_Bronze": + material = _shader_material(MetalShader, {"base_color": Color(.66, .47, .24), "roughness_base": .42, "patina": .7}) + "LN_Crystal_Team", "LN_Crystal_Grotto": + material = _shader_material(CrystalShader, {}) + "LN_Rune_Team", "LN_Rune_Grotto": + material = _shader_material(RuneShader, {}) + "LN_Cloth_Team": + material = _shader_material(ClothShader, {}) + "LN_Water_Glow": + material = _shader_material(WaterGlowShader, {}) + # The Schlundkrone is skinned: its surfaces read their patterns from rest positions. + "LN_Boss_Hide": + material = _shader_material(HideShader, {}) + "LN_Boss_Bone": + material = _shader_material(HideShader, {"base_color": Color(.74, .71, .64), "plate_color": Color(.83, .80, .72), + "vein_amount": .25, "roughness_base": .62}) + "LN_Boss_Bronze": + material = _shader_material(HideShader, {"base_color": Color(.42, .31, .18), "plate_color": Color(.52, .38, .21), + "vein_amount": 0.0, "roughness_base": .42, "metallic_value": .85, "patina": .75}) + "LN_Crystal_Boss": + material = _shader_material(CrystalShader, {"heart_radius": .02, "depth_scale": .3, "vein_scale": 3.6}) + "LN_Wood": + var wood := StandardMaterial3D.new() + wood.albedo_color = Color(.33, .23, .15) + wood.roughness = .82 + wood.vertex_color_use_as_albedo = true + material = wood + "LN_Cave": + var cave := StandardMaterial3D.new() + cave.albedo_color = Color(.02, .015, .03) + cave.roughness = 1.0 + material = cave + if material != null: + material.resource_name = key + _cache[key] = material + return material + +## Swaps LN_* surface materials below root for the shared ones. Returns every geometry +## instance that carries at least one LN_* surface (the ones that take instance parameters). +static func dress(root: Node) -> Array[GeometryInstance3D]: + var result: Array[GeometryInstance3D] = [] + _dress(root, result) + return result + +static func _dress(node: Node, result: Array[GeometryInstance3D]) -> void: + if node is MeshInstance3D and (node as MeshInstance3D).mesh != null: + var instance := node as MeshInstance3D + var dressed: bool = false + for index: int in range(instance.mesh.get_surface_count()): + var source: Material = instance.mesh.surface_get_material(index) + if source == null or not source.resource_name.begins_with("LN_"): + continue + var shared: Material = get_material(source.resource_name) + if shared != null: + instance.set_surface_override_material(index, shared) + dressed = true + if dressed: + result.append(instance) + for child: Node in node.get_children(): + _dress(child, result) + +## Team colours and families on a set of instances. seed decorrelates buildings. +static func apply_team(instances: Array, team: int, seed_value: float) -> void: + var style: Dictionary = TEAM_STYLE[clampi(team, 0, 2)] + for instance: GeometryInstance3D in instances: + instance.set_instance_shader_parameter("tint", style.crystal) + instance.set_instance_shader_parameter("metal_tint", style.metal) + instance.set_instance_shader_parameter("pattern", style.pattern) + instance.set_instance_shader_parameter("cloth_color", style.cloth) + instance.set_instance_shader_parameter("emblem_color", style.emblem) + instance.set_instance_shader_parameter("shape", style.shape) + instance.set_instance_shader_parameter("seed", seed_value) + +## Rune surfaces use the rune hue; crystals the crystal hue. Both share the `tint` slot, so a +## mesh that only carries runes gets the rune hue. +static func apply_rune_tint(instances: Array, team: int) -> void: + var style: Dictionary = TEAM_STYLE[clampi(team, 0, 2)] + for instance: GeometryInstance3D in instances: + instance.set_instance_shader_parameter("tint", style.rune) + +static func set_param(instances: Array, parameter: String, value: Variant) -> void: + for instance: GeometryInstance3D in instances: + instance.set_instance_shader_parameter(parameter, value) + +static func team_color(team: int, key: String) -> Color: + return TEAM_STYLE[clampi(team, 0, 2)][key] diff --git a/scripts/luneraria/view/lune_materials.gd.uid b/scripts/luneraria/view/lune_materials.gd.uid new file mode 100644 index 0000000..6e4376e --- /dev/null +++ b/scripts/luneraria/view/lune_materials.gd.uid @@ -0,0 +1 @@ +uid://d1ascs8cpsdkb diff --git a/scripts/luneraria/view/lune_terrain.gd b/scripts/luneraria/view/lune_terrain.gd index 5b72ba3..0e6454b 100644 --- a/scripts/luneraria/view/lune_terrain.gd +++ b/scripts/luneraria/view/lune_terrain.gd @@ -24,6 +24,9 @@ const VALLEY: float = 0.18 const LOWLAND: float = -0.1 const ARENA_HEIGHT: float = 1.7 const ARENA_RADIUS: float = 7.8 +## Cave notch behind the grotto gate in the grotto's axis frame: u from, u to, half width, +## depth below the terrace (G2, matches build_bossgrotte.py). +const CAVE_NOTCH := Vector4(-11.5, -8.5, 1.8, 1.05) const PLATFORM_HEIGHT: float = 0.62 const PLATFORM_RADIUS: float = 6.6 const FORD_HEIGHT: float = -0.16 @@ -194,9 +197,21 @@ func _region(p: Vector2) -> float: func _arena_blend(p: Vector2, height: float) -> float: var d: float = p.distance_to(LuneMap.GROTTO) - if d > ARENA_RADIUS + 3.5: + if d > ARENA_RADIUS + 5.0: return height - return lerpf(ARENA_HEIGHT, height, smoothstep(ARENA_RADIUS, ARENA_RADIUS + 3.5, d)) + return _cave_notch(p, lerpf(ARENA_HEIGHT, height, smoothstep(ARENA_RADIUS, ARENA_RADIUS + 3.5, d))) + +## G2: the cave behind the grotto gate reaches about a metre below the terrace, so its stairs +## can lead down (presentation only; the rock shell of the grotto hides the notch walls and no +## figure ever stands there). Symmetric about the axis like everything else. +func _cave_notch(p: Vector2, height: float) -> float: + var u: float = axis_u(p) - axis_u(LuneMap.GROTTO) + var a: float = axis_a(p) + if u < CAVE_NOTCH.x - .6 or u > CAVE_NOTCH.y + .6 or a > CAVE_NOTCH.z + .6: + return height + var inside: float = smoothstep(CAVE_NOTCH.x - .4, CAVE_NOTCH.x, u) * (1.0 - smoothstep(CAVE_NOTCH.y, CAVE_NOTCH.y + .4, u)) \ + * (1.0 - smoothstep(CAVE_NOTCH.z, CAVE_NOTCH.z + .4, a)) + return lerpf(height, minf(height, ARENA_HEIGHT - CAVE_NOTCH.w), inside) ## Lane surface height: the smoothed ground along the lane, lifted onto platform and ford. func _lane_profile(lane: String) -> PackedFloat32Array: diff --git a/scripts/luneraria/view/lune_tower_fx.gd b/scripts/luneraria/view/lune_tower_fx.gd new file mode 100644 index 0000000..9fc0b02 --- /dev/null +++ b/scripts/luneraria/view/lune_tower_fx.gd @@ -0,0 +1,241 @@ +extends Node3D +## Tower attack presentation (G2): target lock marker, crystal bolt in flight and the impact. +## Bolts are requested by LuneTowerView when the rules' attack cycle is about to complete and +## are timed to land on the rules' hit; they only follow the target's view. All objects are +## pooled; nothing here can cause, cancel or duplicate damage or gold. + +const Materials = preload("res://scripts/luneraria/view/lune_materials.gd") +const Fx = preload("res://scripts/luneraria/view/lune_fx.gd") +const POOL: int = 24 + +class Bolt: + var node: Node3D + var core: MeshInstance3D + var trail: MeshInstance3D + var from: Vector3 + var target: Node3D + var last_aim: Vector3 + var elapsed: float = 0.0 + var flight: float = .25 + var arc: float = .7 + var team: int = 0 + var heavy: bool = false + var active: bool = false + +var camera: Camera3D +var _bolts: Array[Bolt] = [] +var _impacts: Array = [] +var _sparks: Array[GPUParticles3D] = [] +var _next_spark: int = 0 +var _markers: Dictionary = {} +var _materials: Dictionary = {} +var _ring_texture: GradientTexture2D +## Counters for tests and the benchmark (purely informative). +var launched: int = 0 +var landed: int = 0 + +func _ready() -> void: + name = "TowerFx" + +func _material(team: int, kind: String) -> StandardMaterial3D: + var key: String = "%d:%s" % [team, kind] + if _materials.has(key): + return _materials[key] + var color: Color = Materials.team_color(team, "crystal") + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.disable_receive_shadows = true + match kind: + "core": + material.albedo_color = Color(color.r * 2.6 + .4, color.g * 2.6 + .4, color.b * 2.6 + .4) + "trail": + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD + material.albedo_texture = Fx.soft_texture() + material.albedo_color = Color(color.r * 1.6, color.g * 1.6, color.b * 1.6, .85) + "flash": + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD + material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED + material.albedo_texture = Fx.soft_texture() + material.albedo_color = Color(color.r * 2.0, color.g * 2.0, color.b * 2.0, 1.0) + "marker": + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD + material.albedo_texture = _ring() + material.albedo_color = Color(color.r * 1.4, color.g * 1.4, color.b * 1.4, .9) + material.cull_mode = BaseMaterial3D.CULL_DISABLED + _materials[key] = material + return material + +func _ring() -> GradientTexture2D: + if _ring_texture == null: + var gradient := Gradient.new() + gradient.offsets = PackedFloat32Array([0.0, .62, .78, .86, .93, 1.0]) + gradient.colors = PackedColorArray([Color(1, 1, 1, 0), Color(1, 1, 1, 0.05), Color(1, 1, 1, .35), Color(1, 1, 1, 1), Color(1, 1, 1, .2), Color(1, 1, 1, 0)]) + _ring_texture = GradientTexture2D.new() + _ring_texture.gradient = gradient + _ring_texture.fill = GradientTexture2D.FILL_RADIAL + _ring_texture.fill_from = Vector2(.5, .5) + _ring_texture.fill_to = Vector2(1.0, .5) + _ring_texture.width = 128 + _ring_texture.height = 128 + return _ring_texture + +func _new_bolt() -> Bolt: + var bolt := Bolt.new() + bolt.node = Node3D.new() + bolt.node.name = "Bolt" + add_child(bolt.node) + bolt.core = MeshInstance3D.new() + var prism := PrismMesh.new() + prism.size = Vector3(.16, .5, .16) + bolt.core.mesh = prism + bolt.core.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + bolt.node.add_child(bolt.core) + bolt.trail = MeshInstance3D.new() + var quad := QuadMesh.new() + quad.size = Vector2(1, 1) + quad.center_offset = Vector3(-.5, 0, 0) + bolt.trail.mesh = quad + bolt.trail.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(bolt.trail) + bolt.node.visible = false + bolt.trail.visible = false + return bolt + +func _free_bolt() -> Bolt: + for bolt: Bolt in _bolts: + if not bolt.active: + return bolt + if _bolts.size() < POOL: + var bolt: Bolt = _new_bolt() + _bolts.append(bolt) + return bolt + return null + +## Starts a bolt from `from` that lands on the target view after `flight` simulation seconds. +func launch(from: Vector3, target: Node3D, flight: float, team: int, heavy: bool = false) -> void: + var bolt: Bolt = _free_bolt() + if bolt == null or target == null: + return + bolt.active = true + bolt.from = from + bolt.target = target + bolt.last_aim = _aim(target) + bolt.elapsed = 0.0 + bolt.flight = maxf(.05, flight) + bolt.arc = clampf(from.distance_to(bolt.last_aim) * .08, .15, .9) + bolt.team = team + bolt.heavy = heavy + bolt.core.material_override = _material(team, "core") + bolt.trail.material_override = _material(team, "trail") + bolt.core.scale = Vector3.ONE * (1.35 if heavy else 1.0) + bolt.node.visible = true + bolt.trail.visible = true + bolt.node.global_position = from + launched += 1 + +func _aim(target: Node3D) -> Vector3: + if target != null and is_instance_valid(target): + if target.has_method("aim_point"): + return target.aim_point() + return target.global_position + Vector3(0, 1.0, 0) + return Vector3.ZERO + +func _path(bolt: Bolt, t: float) -> Vector3: + var eased: float = t * t * (3.0 - 2.0 * t) * .35 + t * .65 + return bolt.from.lerp(bolt.last_aim, eased) + Vector3(0, sin(PI * t) * bolt.arc, 0) + +## Advances bolts (simulation time, so pause and speed apply) and impact flashes (real time). +func process_fx(delta: float, sim_delta: float) -> void: + for bolt: Bolt in _bolts: + if not bolt.active: + continue + if bolt.target != null and is_instance_valid(bolt.target): + bolt.last_aim = _aim(bolt.target) + bolt.elapsed += sim_delta + var t: float = clampf(bolt.elapsed / bolt.flight, 0.0, 1.0) + var at: Vector3 = _path(bolt, t) + var behind: Vector3 = _path(bolt, maxf(0.0, t - .22)) + var direction: Vector3 = at - behind + if direction.length() > .001: + bolt.node.global_transform = Transform3D(Basis.looking_at(direction.normalized(), Vector3.UP) * Basis(Vector3.RIGHT, -PI * .5), at) + _stretch(bolt.trail, behind, at, .32 if bolt.heavy else .24) + if t >= 1.0: + bolt.active = false + bolt.node.visible = false + bolt.trail.visible = false + _impact(bolt.last_aim, bolt.team, bolt.heavy) + landed += 1 + for index: int in range(_impacts.size() - 1, -1, -1): + var entry: Array = _impacts[index] + var flash: MeshInstance3D = entry[0] + entry[1] = float(entry[1]) + delta + var k: float = float(entry[1]) / .22 + if k >= 1.0: + flash.queue_free() + _impacts.remove_at(index) + continue + flash.scale = Vector3.ONE * lerpf(.35, 1.3, k) * float(entry[2]) + flash.transparency = k + +## Orients a quad as a ribbon from a to b that faces the camera. +func _stretch(trail: MeshInstance3D, a: Vector3, b: Vector3, width: float) -> void: + var axis: Vector3 = b - a + var length: float = axis.length() + if length < .01 or camera == null: + trail.visible = false + return + trail.visible = true + var x: Vector3 = axis / length + var to_camera: Vector3 = (camera.global_position - b).normalized() + var z: Vector3 = (to_camera - x * to_camera.dot(x)) + if z.length() < .001: + z = Vector3.UP + z = z.normalized() + var y: Vector3 = z.cross(x) + trail.global_transform = Transform3D(Basis(x * length, y * width, z), b) + +func _impact(at: Vector3, team: int, heavy: bool) -> void: + var flash: MeshInstance3D = Fx.flash_mesh(Color.WHITE, 1.0) + flash.material_override = _material(team, "flash") + add_child(flash) + flash.global_position = at + _impacts.append([flash, 0.0, 1.35 if heavy else 1.0]) + if _sparks.size() < 8: + var sparks: GPUParticles3D = Fx.shard_burst(Materials.team_color(team, "crystal"), 10, 3.2) + sparks.lifetime = .6 + add_child(sparks) + _sparks.append(sparks) + var emitter: GPUParticles3D = _sparks[_next_spark % _sparks.size()] + _next_spark += 1 + Fx.fire(emitter, at) + +## Lock-on ring under the current target of a tower; tightens as the shot charges. +func mark(tower_id: String, target: Node3D, charge: float, team: int) -> void: + var marker: MeshInstance3D = _markers.get(tower_id) + if target == null or not is_instance_valid(target) or charge <= .02: + if marker != null: + marker.visible = false + return + if marker == null: + marker = MeshInstance3D.new() + marker.name = "Lock_" + tower_id + var quad := QuadMesh.new() + quad.size = Vector2(2, 2) + quad.orientation = PlaneMesh.FACE_Y + marker.mesh = quad + marker.material_override = _material(team, "marker") + marker.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(marker) + _markers[tower_id] = marker + marker.visible = true + var feet: Vector3 = target.global_position + Vector3(0, .07, 0) + var size: float = lerpf(1.25, .72, clampf(charge, 0.0, 1.0)) * (target.call("footprint") if target.has_method("footprint") else 1.0) + marker.global_transform = Transform3D(Basis(Vector3.UP, Time.get_ticks_msec() * .0018) * Basis.from_scale(Vector3(size, 1, size)), feet) + marker.transparency = 1.0 - clampf(.35 + charge * .65, 0.0, 1.0) + +func clear_markers() -> void: + for id: String in _markers: + (_markers[id] as MeshInstance3D).visible = false diff --git a/scripts/luneraria/view/lune_tower_fx.gd.uid b/scripts/luneraria/view/lune_tower_fx.gd.uid new file mode 100644 index 0000000..b08579c --- /dev/null +++ b/scripts/luneraria/view/lune_tower_fx.gd.uid @@ -0,0 +1 @@ +uid://b10e0si7vqef4 diff --git a/scripts/luneraria/view/lune_tower_view.gd b/scripts/luneraria/view/lune_tower_view.gd new file mode 100644 index 0000000..e839563 --- /dev/null +++ b/scripts/luneraria/view/lune_tower_view.gd @@ -0,0 +1,377 @@ +extends Node3D +## One lane or base tower (G2). Reads the authoritative building HP and, during combat, the +## tower's LuneUnit (target, attack progress, attack serial) and turns them into visuals: +## four damage states, a short destruction sequence into a lasting ruin, the charging crystal +## and the launch of the tower bolt (flown by LuneTowerFx). Nothing here writes back into the +## simulation; the rules have applied the damage by the time a bolt lands. + +const Materials = preload("res://scripts/luneraria/view/lune_materials.gd") +const Fx = preload("res://scripts/luneraria/view/lune_fx.gd") +enum State { INTACT, LIGHT, HEAVY, DESTROYING, RUIN } +const ASSETS: Dictionary = { + "outer": {"path": "res://assets/luneraria/g2/grenzwacht.glb", "top": 6.3, "crystal": 5.2, "light_range": 7.0}, + "inner": {"path": "res://assets/luneraria/g2/bastion.glb", "top": 7.4, "crystal": 6.2, "light_range": 8.0}, + "base": {"path": "res://assets/luneraria/g2/kernwacht.glb", "top": 8.6, "crystal": 5.25, "light_range": 9.0}, +} +## Hit point fractions where the look changes (the same bands as the Ancient). +const LIGHT_BELOW: float = .605 +const HEAVY_BELOW: float = .305 +const DESTROY_SECONDS: float = 1.7 +## Occlusion fade (G2): dithering starts this far above the foot; half width of the box that +## can hide figures behind the tower. +const FADE_FLOOR: float = 2.0 +const OCCLUDER_RADIUS: Dictionary = {"outer": 1.35, "inner": 1.7, "base": 1.8} +## A bolt leaves when the attack cycle has this much left, so it lands on the rules' hit. +const LAUNCH_AT: float = .74 +static var _scenes: Dictionary = {} + +var building_id: String = "" +var team: int = 0 +var tier: String = "outer" +var state: int = State.INTACT +var fraction: float = 1.0 +## Occlusion fade the world asks for (0 opaque .. 1 mostly dithered away above fade_floor). +var fade_target: float = 0.0 +var seed_value: float = 0.0 +var crystal: Node3D +var ring: Node3D +var shards: Array[Node3D] = [] +var light: OmniLight3D +var _parts: Dictionary = {} +var _instances: Array = [] +var _structure: Array = [] +var _crystals: Array = [] +var _cloth: Array = [] +var _ruin: Array = [] +var _sent: Dictionary = {} +var _sparks: GPUParticles3D +var _smoke: GPUParticles3D +var _dust: GPUParticles3D +var _burst: GPUParticles3D +var _bar: Node3D +var _bar_fill: MeshInstance3D +var _rest: Dictionary = {} +var _time: float = 0.0 +var _destroy_time: float = -1.0 +var _dust_fired: bool = false +var _ruin_smoke_left: float = 0.0 +var _hit_kick: float = 0.0 +var _charge: float = 0.0 +var _fade: float = 0.0 +var _crystal_rest := Vector3.ZERO +var _last_serial: int = -1 +var _launched_for: int = -1 +var _upper_axis := Vector3.RIGHT +var _energy_boost: float = 0.0 + +func setup(definition: Dictionary, facing: Vector2) -> void: + building_id = str(definition.id) + team = int(definition.team) + tier = str(definition.tier) + name = building_id + seed_value = float(absi(hash(building_id)) % 997) / 97.0 + var spec: Dictionary = ASSETS[tier] + if not _scenes.has(tier): + _scenes[tier] = load(spec.path) + var model: Node3D = (_scenes[tier] as PackedScene).instantiate() + model.name = "Model" + add_child(model) + # Local +z of the model faces the lane (base towers: away from their Ancient). + model.rotation.y = atan2(facing.x, facing.y) + for child: Node in model.get_children(): + _parts[str(child.name)] = child + _rest[str(child.name)] = (child as Node3D).transform + _instances = Materials.dress(model) + Materials.apply_team(_instances, team, seed_value) + crystal = _parts.get("Crystal") + ring = _parts.get("Ring") + for index: int in range(4): + if _parts.has("Shard_%d" % index): + shards.append(_parts["Shard_%d" % index]) + if crystal != null: + _crystal_rest = crystal.position + for key: String in _parts: + var list: Array = _collect(_parts[key]) + if key == "Crystal" or key.begins_with("Shard"): + _crystals.append_array(list) + elif key.begins_with("Pennant") or key.begins_with("Banner"): + _cloth.append_array(list) + elif key == "Ruin": + _ruin.append_array(list) + else: + _structure.append_array(list) + Materials.apply_rune_tint(_structure + _ruin, team) + # Glowing crystals, thin rings, shards and cloth cast no sun shadow (saves shadow-pass draws; + # their shadows were only a few pixels from the game camera). + for instance: GeometryInstance3D in _crystals + _cloth: + instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + if ring != null: + for instance: GeometryInstance3D in _collect(ring): + instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + # The pennant belongs to the upper part, so it falls with it. + if _parts.has("Upper") and _parts.has("Pennant"): + var cloth: Node3D = _parts["Pennant"] + var upper: Node3D = _parts["Upper"] + var local: Transform3D = upper.transform.affine_inverse() * cloth.transform + cloth.get_parent().remove_child(cloth) + upper.add_child(cloth) + cloth.transform = local + # The upper part tips sideways relative to the lane. + _upper_axis = Vector3(facing.x, 0, facing.y).normalized().rotated(Vector3.UP, PI * .5 + (seed_value - 5.0) * .08) + _set_visible("Upper_Damaged", false) + _set_visible("Debris", false) + _set_visible("Ruin", false) + light = OmniLight3D.new() + light.name = "TowerLight" + light.position = Vector3(0, float(spec.crystal), 0) + light.light_color = Materials.team_color(team, "light") + light.omni_range = float(spec.light_range) + light.omni_attenuation = 1.4 + light.light_energy = 1.3 + add_child(light) + _sparks = Fx.sparks(Materials.team_color(team, "light"), 16, 1.0, 1.5) + _sparks.position = Vector3(0, float(spec.crystal) * .55, 0) + add_child(_sparks) + _smoke = Fx.smoke(8, .7, .24, 1.2) + _smoke.position = Vector3(0, float(spec.crystal) * .6, 0) + add_child(_smoke) + _dust = Fx.dust_burst(34 if tier != "base" else 44, 1.5, 1.7) + add_child(_dust) + _burst = Fx.shard_burst(Materials.team_color(team, "crystal"), 26, 4.5) + add_child(_burst) + _build_bar(float(spec.top) + .6) + # The occlusion fade only thins out what stands above head height of the figures around. + Materials.set_param(_instances, "fade_floor", global_position.y + FADE_FLOOR) + +func _collect(node: Node) -> Array: + var result: Array = [] + if node is GeometryInstance3D: + result.append(node) + for child: Node in node.get_children(): + result.append_array(_collect(child)) + return result + +func _set_visible(key: String, value: bool) -> void: + if _parts.has(key): + (_parts[key] as Node3D).visible = value + +## Sets an instance parameter on a group only when it really changes (saves server calls). +func _param(group: Array, group_key: String, parameter: String, value: float, epsilon: float = .01) -> void: + var key: String = group_key + ":" + parameter + if _sent.has(key) and absf(float(_sent[key]) - value) < epsilon: + return + _sent[key] = value + Materials.set_param(group, parameter, value) + +func _build_bar(height: float) -> void: + _bar = Node3D.new() + _bar.name = "HealthBar" + _bar.position.y = height + add_child(_bar) + var width: float = 2.2 if tier != "base" else 2.6 + var back := MeshInstance3D.new() + var quad := QuadMesh.new() + quad.size = Vector2(width + .08, .2) + back.mesh = quad + var back_material := StandardMaterial3D.new() + back_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + back_material.albedo_color = Color(.03, .03, .05, .75) + back_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + back_material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED + back_material.no_depth_test = true + back.material_override = back_material + back.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _bar.add_child(back) + _bar_fill = MeshInstance3D.new() + var fill := QuadMesh.new() + fill.size = Vector2(width, .13) + fill.center_offset = Vector3(width * .5, 0, 0) + _bar_fill.mesh = fill + var fill_material := StandardMaterial3D.new() + fill_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + fill_material.albedo_color = Materials.team_color(team, "light").lightened(.1) + fill_material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED + fill_material.billboard_keep_scale = true + fill_material.no_depth_test = true + fill_material.render_priority = 1 + _bar_fill.material_override = fill_material + _bar_fill.position.x = -width * .5 + _bar_fill.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _bar.add_child(_bar_fill) + _bar.visible = false + +## World-space point the bolt leaves from (crystal tip). +func muzzle() -> Vector3: + if crystal != null and crystal.is_inside_tree(): + return crystal.global_position + Vector3(0, .35, 0) + return global_position + Vector3(0, float(ASSETS[tier].crystal), 0) + +func is_standing() -> bool: + return state != State.DESTROYING and state != State.RUIN + +## World box of the part that can hide figures (above the fade floor); empty for ruins. +func occluder_box() -> AABB: + if not is_standing(): + return AABB() + var r: float = float(OCCLUDER_RADIUS[tier]) + var top: float = float(ASSETS[tier].top) + return AABB(global_position + Vector3(-r, FADE_FLOOR, -r), Vector3(r * 2.0, top - FADE_FLOOR, r * 2.0)) + +func current_fade() -> float: + return _fade + +## Authoritative HP -> visual state. `animate` is false while the view is (re)built, so a +## tower that is already down appears as a ruin without replaying its collapse. +func set_health(value: float, max_value: float, animate: bool = true) -> void: + var previous: float = fraction + fraction = clampf(value / maxf(1.0, max_value), 0.0, 1.0) + if fraction < previous - .0005 and value > 0.0: + _hit_kick = 1.0 + var next: int = State.INTACT + if value <= 0.0: + if state == State.RUIN or state == State.DESTROYING: + next = state + else: + next = State.DESTROYING if animate else State.RUIN + elif fraction < HEAVY_BELOW: + next = State.HEAVY + elif fraction < LIGHT_BELOW: + next = State.LIGHT + if next != state: + _enter(next) + _bar.visible = value > 0.0 and fraction < .999 + _bar_fill.scale.x = maxf(.001, fraction) + +func _enter(next: int) -> void: + var from: int = state + state = next + var damaged_top: bool = next == State.HEAVY or (next == State.DESTROYING and from == State.HEAVY) + _set_visible("Upper", next != State.RUIN and not damaged_top) + _set_visible("Upper_Damaged", next != State.RUIN and damaged_top) + _set_visible("Debris", next == State.HEAVY or next == State.DESTROYING) + _set_visible("Lower", next != State.RUIN) + _set_visible("Ruin", next == State.RUIN) + for key: String in _parts: + if key == "Crystal" or key == "Ring" or key.begins_with("Shard") or key.begins_with("Banner"): + (_parts[key] as Node3D).visible = next != State.RUIN + _sparks.emitting = next == State.HEAVY + _smoke.emitting = next == State.LIGHT or next == State.HEAVY + _smoke.amount_ratio = .35 if next == State.LIGHT else 1.0 + match next: + State.DESTROYING: + _destroy_time = 0.0 + _dust_fired = false + Fx.fire(_burst, muzzle()) + State.RUIN: + _destroy_time = -1.0 + _ruin_smoke_left = 18.0 if from == State.DESTROYING else 0.0 + _smoke.emitting = _ruin_smoke_left > 0.0 + _smoke.amount_ratio = .6 + light.visible = false + _bar.visible = false + Materials.set_param(_ruin, "energy", .14) + Materials.set_param(_ruin, "flicker", .6) + Materials.set_param(_ruin, "damage", .8) + Materials.set_param(_ruin, "scorch", .7) + for part: String in ["Upper", "Upper_Damaged"]: + if _parts.has(part): + (_parts[part] as Node3D).transform = _rest[part] + if crystal != null: + crystal.scale = Vector3.ONE + var damage: float = {State.INTACT: 0.0, State.LIGHT: .42, State.HEAVY: .82}.get(next, .9) + _param(_structure, "structure", "damage", damage) + _param(_structure, "structure", "scorch", {State.INTACT: 0.0, State.LIGHT: .22}.get(next, .55)) + _param(_cloth, "cloth", "tatter", {State.INTACT: 0.0, State.LIGHT: .35, State.HEAVY: .7}.get(next, .8)) + +## Combat hook with the tower's own LuneUnit (null outside combat) and its target's view. +## Returns a bolt request [from, target_view, flight_seconds] when a shot leaves this frame. +func update_attack(unit: LuneUnit, target_view: Node3D, interval: float) -> Array: + if unit == null or not unit.alive or not is_standing(): + _charge = move_toward(_charge, 0.0, .08) + return [] + var request: Array = [] + var aiming: bool = unit.in_range and unit.target != null and unit.target.alive + _charge = unit.attack_progress if aiming else move_toward(_charge, 0.0, .1) + var cycle: int = unit.attack_serial + if aiming and target_view != null and unit.attack_progress >= LAUNCH_AT and _launched_for != cycle: + _launched_for = cycle + request = [muzzle(), target_view, maxf(.06, (1.0 - unit.attack_progress) * interval)] + elif cycle != _last_serial and _last_serial >= 0 and _launched_for != cycle - 1 and target_view != null: + # A hit without a predicted launch (retarget, big time step): a quick bolt now. + request = [muzzle(), target_view, .08] + if cycle != _last_serial and _last_serial >= 0: + _energy_boost = 1.0 + _last_serial = cycle + return request + +func charge() -> float: + return _charge + +func process_view(delta: float, sim_delta: float) -> void: + _time += delta + var calm: float = {State.INTACT: 1.0, State.LIGHT: .85, State.HEAVY: .6}.get(state, 0.0) + _energy_boost = maxf(0.0, _energy_boost - sim_delta * 4.0) + if crystal != null and crystal.visible and state != State.DESTROYING: + var bob: float = sin(_time * 1.3 + seed_value) * .05 * calm + if state == State.HEAVY: + bob += sin(_time * 17.0) * .012 + crystal.position = _crystal_rest + Vector3(0, bob, 0) + crystal.rotation.y += delta * (.35 + _charge * 2.6) + if ring != null and ring.visible: + ring.rotation.y += delta * (.5 + _charge * 1.5) * (1.0 if state != State.HEAVY else .55) + ring.rotation.x = sin(_time * .7 + seed_value) * .08 + (.22 if state == State.HEAVY else 0.0) + for index: int in range(shards.size()): + var a: float = _time * .6 + TAU * index / shards.size() + shards[index].position = _crystal_rest + Vector3(cos(a) * 1.02, .12 + sin(_time * 1.1 + index) * .08, sin(a) * 1.02) + shards[index].rotation.y = a + var flicker: float = {State.INTACT: 0.0, State.LIGHT: .18, State.HEAVY: .6}.get(state, .8) + if state != State.DESTROYING and state != State.RUIN: + _param(_crystals, "crystal", "energy", calm * (1.0 + _charge * .9 + _energy_boost * 1.6), .02) + _param(_crystals, "crystal", "flicker", flicker) + _param(_crystals, "crystal", "fracture", 0.0 if state == State.INTACT else (.4 if state == State.LIGHT else .9)) + _param(_structure, "structure", "energy", .35 + calm * .65 + _charge * .5, .03) + _param(_structure, "structure", "flicker", flicker) + if light.visible and state != State.DESTROYING: + var wobble: float = 1.0 + if flicker > 0.0: + wobble = lerpf(1.0, .45 + .55 * absf(sin(_time * 23.0 + seed_value) * sin(_time * 7.3)), flicker) + light.light_energy = (.55 + 1.0 * calm) * wobble * (1.0 + _charge * .5 + _energy_boost * 1.2) + # A hit on the building: a short shudder of the upper part. + if _hit_kick > 0.0 and is_standing(): + _hit_kick = maxf(0.0, _hit_kick - delta * 5.0) + var part: String = "Upper_Damaged" if state == State.HEAVY else "Upper" + if _parts.has(part): + (_parts[part] as Node3D).transform = (_rest[part] as Transform3D).translated_local(Vector3(sin(_time * 60.0) * .03 * _hit_kick, 0, 0)) + if state == State.DESTROYING: + _destroy_step(delta) + elif state == State.RUIN and _ruin_smoke_left > 0.0: + _ruin_smoke_left -= delta + if _ruin_smoke_left <= 0.0: + _smoke.emitting = false + _fade = move_toward(_fade, fade_target, delta * 3.0) + _param(_instances, "all", "fade", _fade, .02) + +## Collapse: the crystal overloads and fails, the upper part tips and sinks behind a dust +## burst, then the ruin replaces the tower. Short and bounded; the rules finished long ago. +func _destroy_step(delta: float) -> void: + _destroy_time += delta + var t: float = _destroy_time + var part: String = "Upper_Damaged" if _parts.has("Upper_Damaged") and (_parts["Upper_Damaged"] as Node3D).visible else "Upper" + if _parts.has(part): + var rest: Transform3D = _rest[part] + var fall: float = smoothstep(.15, 1.05, t) + var tilt: float = fall * fall * .95 + var drop: float = fall * fall * 2.4 + (_parts[part] as Node3D).transform = Transform3D(Basis(_upper_axis, tilt) * rest.basis, + rest.origin + Vector3(0, -drop, 0) + _upper_axis.cross(Vector3.UP) * fall * .6) + if crystal != null: + var overload: float = clampf(1.0 - t / .35, 0.0, 1.0) + Materials.set_param(_crystals, "energy", 3.0 * overload + .05) + Materials.set_param(_crystals, "flicker", 1.0) + crystal.scale = Vector3.ONE * maxf(.01, 1.0 + overload * .25 - smoothstep(.25, .5, t)) + light.light_energy = maxf(0.0, 4.0 * (1.0 - t / .5)) + if t >= .45 and not _dust_fired: + _dust_fired = true + Fx.fire(_dust, global_position + Vector3(0, .3, 0)) + if t >= DESTROY_SECONDS * .62: + _enter(State.RUIN) diff --git a/scripts/luneraria/view/lune_tower_view.gd.uid b/scripts/luneraria/view/lune_tower_view.gd.uid new file mode 100644 index 0000000..1a62e80 --- /dev/null +++ b/scripts/luneraria/view/lune_tower_view.gd.uid @@ -0,0 +1 @@ +uid://ci0qkrt61lxdr diff --git a/scripts/luneraria/view/lune_unit_view.gd b/scripts/luneraria/view/lune_unit_view.gd index 286a390..66bd53d 100644 --- a/scripts/luneraria/view/lune_unit_view.gd +++ b/scripts/luneraria/view/lune_unit_view.gd @@ -4,10 +4,16 @@ extends Node3D const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55"), Color("b37cff")] const HERO_GOLD := Color("f2c96b") +const Materials = preload("res://scripts/luneraria/view/lune_materials.gd") +## G2: every boss unit (grotto and deployed) shows the Schlundkrone; its veins and crystals +## take the colour of the team it fights for. +const BOSS_MODEL := "schlundkrone" var unit: LuneUnit ## Ground the figure stands on (lane decks, terrain, grotto terrace); flat without it. var terrain: LuneTerrain +## Optional world height (adds plaza and dais steps); preferred over the bare terrain. +var ground: Callable var model: Node3D var skeletal: bool = false var _fill: MeshInstance3D @@ -20,6 +26,12 @@ var _ability_flash: float = 0.0 var _time: float = 0.0 var _last_visual: Vector3 var _height: float = 1.6 +## Boss look (G2): dressed LN_* surfaces, vein energy that follows sleep, fight and death. +var _boss_surfaces: Array[GeometryInstance3D] = [] +var _tag: Label3D +var _boss_energy: float = 1.0 +var _sent_energy: float = -1.0 +var _sent_flash: float = -1.0 static func _flat(color: Color, energy: float = 0.0) -> StandardMaterial3D: var material := StandardMaterial3D.new() @@ -35,10 +47,18 @@ func bind(source: LuneUnit) -> void: unit = source name = "Unit_%d" % unit.id var id: String = str(unit.def.get("id", "")) + if unit.kind == "boss" and AssetLibrary.has_model(BOSS_MODEL): + id = BOSS_MODEL model = AssetLibrary.instantiate_fighter(id) if model == null: model = EnemyVisualsFallback.make(TEAM_COLORS[unit.team]) add_child(model) + if id == BOSS_MODEL: + _boss_surfaces = Materials.dress(model) + for surface: GeometryInstance3D in _boss_surfaces: + surface.set_instance_shader_parameter("tint", Materials.team_color(unit.team, "crystal")) + surface.set_instance_shader_parameter("seed", 1.7 + unit.team) + _boss_energy = .45 if unit.dormant else 1.0 skeletal = model.has_method("update_combat") if model.has_method("get_aabb"): _height = maxf(1.0, model.get_aabb().end.y) @@ -103,6 +123,7 @@ func bind(source: LuneUnit) -> void: title = "%s · Stufe %d" % [title, int(unit.def.get("level", 1))] tag.text = title add_child(tag) + _tag = tag _last_attack = unit.attack_serial _last_ability = unit.ability_serial position = Vector3(unit.position.x, _ground(unit.position), unit.position.y) @@ -131,15 +152,45 @@ func sync(alpha: float, delta: float) -> void: model.update_combat(delta, moved, unit.in_range and unit.alive, unit.attack_progress, unit.hit_count, _ability_flash, unit.hit_flash, unit.alive) elif model.has_method("pose"): model.pose(_time + float(unit.id % 17) * .23, moved > .002, _attack_flash, _ability_flash, unit.hit_flash) + if not _boss_surfaces.is_empty(): + _sync_boss(delta) var fraction: float = clampf(unit.hp / maxf(1.0, unit.max_hp), 0.0, 1.0) _fill.scale.x = maxf(.001, fraction) _bars.visible = unit.alive and fraction < .999 _ring.visible = unit.alive + if _tag != null: + # Fallen skeletal figures stay in their death pose, but without their name. + _tag.visible = unit.alive # Rigid figures have no death clip; skeletal ones stay down in their death pose. if not skeletal: visible = unit.alive +## Veins sleep while the grotto boss is dormant, burn while it fights, flash on hits and go +## out over its death clip. +func _sync_boss(delta: float) -> void: + var goal: float = 0.0 if not unit.alive else (.45 if unit.dormant else 1.0) + _boss_energy = move_toward(_boss_energy, goal, delta * (.6 if not unit.alive else 1.5)) + var flash: float = clampf(unit.hit_flash * 2.2, 0.0, .45) if unit.alive else 0.0 + if absf(_boss_energy - _sent_energy) > .01: + _sent_energy = _boss_energy + for surface: GeometryInstance3D in _boss_surfaces: + surface.set_instance_shader_parameter("energy", _boss_energy) + if absf(flash - _sent_flash) > .02: + _sent_flash = flash + for surface: GeometryInstance3D in _boss_surfaces: + surface.set_instance_shader_parameter("damage", flash) + +## Where tower bolts and effects hit this figure (upper body, not the feet). +func aim_point() -> Vector3: + return global_position + Vector3(0, _height * .55, 0) + +## Relative ground footprint for lock-on markers (bigger figures get bigger rings). +func footprint() -> float: + return clampf(float(unit.def.get("view_scale", 1.0)) if unit != null else 1.0, .8, 2.2) + func _ground(at: Vector2) -> float: + if ground.is_valid(): + return float(ground.call(at)) + .03 return terrain.walk_height(at) + .03 if terrain != null else 0.0 class EnemyVisualsFallback: diff --git a/scripts/luneraria/view/lune_world.gd b/scripts/luneraria/view/lune_world.gd index 1c583b6..e151f20 100644 --- a/scripts/luneraria/view/lune_world.gd +++ b/scripts/luneraria/view/lune_world.gd @@ -2,23 +2,29 @@ extends Node3D ## 3D presentation of LuneMap (G1 layout). The ground is LuneTerrain's height field: ## the north-western highland carries Top, the Kluft river runs on the symmetry axis ## from the grotto spring under the central Mid platform into the south-eastern delta -## that Bottom crosses on a bridge. Existing assets are reused: the lane tower, the -## Saumkern Ancients, the grotto rock arc, map props (trees, rocks, curbs, bridge, -## banners) and the Kluft surface shader. New shapes are simple blockout primitives -## that G2/G4 replace. Purely visual; the simulation never reads anything from here. +## that Bottom crosses on a bridge. Map props (trees, rocks, curbs, bridge) and the Kluft +## surface shader are reused; the strategic objects are the G2 views (towers, Ancients, +## grotto, tower effects, occlusion fade). Bases, walls, landmarks and the platform are +## still blockout primitives for G4. Purely visual; the simulation never reads anything here. const Lighting = preload("res://scripts/presentation/lighting.gd") const Sanctuary = preload("res://scripts/presentation/sanctuary.gd") const RiverShader = preload("res://scripts/presentation/river.gdshader") const GroundShader = preload("res://scripts/luneraria/view/lune_terrain.gdshader") const PadShader = preload("res://scripts/luneraria/view/lune_pad.gdshader") +const TowerView = preload("res://scripts/luneraria/view/lune_tower_view.gd") +const AncientView = preload("res://scripts/luneraria/view/lune_ancient_view.gd") +const GrottoView = preload("res://scripts/luneraria/view/lune_grotto_view.gd") +const VeinShader = preload("res://scripts/luneraria/view/materials/lune_vein.gdshader") +const Materials = preload("res://scripts/luneraria/view/lune_materials.gd") +## Top of the base plaza above the plateau (G1 disc) - figures are lifted onto it. +const PLAZA_LIFT: float = .08 +const PLAZA_RADIUS: float = 8.4 +const TowerFx = preload("res://scripts/luneraria/view/lune_tower_fx.gd") const Stone = preload("res://assets/materials/phase10/limestone-albedo.png") -const TOWER := "res://assets/luneraria/tower.glb" -const GROTTO_SCENE := "res://assets/luneraria/grotto.glb" const PROPS := "res://assets/luneraria/map_props.glb" const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55")] const TEAM_NAMES: Array[String] = ["Blau", "Rot"] -const GROTTO_COLOR := Color("b37cff") ## Visible paving half width per lane; Mid is the broad monumental route. const PAVING: Dictionary = {"top": 3.0, "mid": 3.9, "bot": 3.0} ## Lane identity: old grey flagstones on Top, pale limestone on Mid, packed earth on Bottom. @@ -29,10 +35,24 @@ var map: LuneMap var terrain: LuneTerrain var sun: DirectionalLight3D var building_nodes: Dictionary = {} -var grotto_light: OmniLight3D -var _grotto_runes: StandardMaterial3D +## G2: tower views by building id, the tower attack effects and whether HP was seen once. +var tower_views: Dictionary = {} +var ancient_views: Dictionary = {} +## team -> [[vein MeshInstance3D, base tower id or ""], ...] +var _veins: Dictionary = {} +var tower_fx: Node3D +var _synced_once: bool = false +## Occlusion fade (G2): how far a building thins out above its fade floor while figures or the +## camera focus are behind it. It never vanishes; the silhouette stays readable. Slim lane +## towers only give way for heroes, champions and the boss; the Ancient, the base towers and +## the grotto gate for every figure and for the camera focus. +## Main-thread cost of sync_strategic (microseconds, smoothed); read by the benchmark. +var strategic_usec: float = 0.0 +const OCCLUSION_FADE: float = .55 +const OCCLUSION_FADE_SLIM: float = .45 +var _occlusion_frame: int = 0 +var grotto_view: Node3D var _materials: Dictionary = {} -var _crystal_materials: Array[StandardMaterial3D] = [] var _props: Dictionary = {} ## "team:lane" -> deployment pad, team -> grotto entrance ring var _pads: Dictionary = {} @@ -55,23 +75,32 @@ func build(match_map: LuneMap) -> void: _base(team) _landmarks() _vegetation() - for team: int in range(2): - var crystal := StandardMaterial3D.new() - crystal.albedo_color = TEAM_COLORS[team] - crystal.emission_enabled = true - crystal.emission = TEAM_COLORS[team] - crystal.emission_energy_multiplier = 2.2 - crystal.roughness = .15 - _crystal_materials.append(crystal) + tower_fx = TowerFx.new() + add_child(tower_fx) for definition: Dictionary in map.buildings: - _building(definition) + if definition.tier == "core": + _ancient(definition) + else: + _tower(definition) + for team: int in range(2): + _build_veins(team) _grotto() _deployment_pads() # ---------------------------------------------------------------- helpers -## Height a unit or building stands on (lane decks instead of the river bed). +## Height a unit or building stands on (lane decks instead of the river bed), including the +## base plaza and the Ancient's dais steps, which are presentation only (G2). func height_at(p: Vector2) -> float: - return terrain.walk_height(p) + return terrain.walk_height(p) + surface_lift(p) + +func surface_lift(p: Vector2) -> float: + for team: int in range(2): + var d: float = p.distance_to(map.core_position(team)) + if d < PLAZA_RADIUS: + return PLAZA_LIFT + AncientView.surface_lift(d) + if p.distance_to(LuneMap.GROTTO) < LuneTerrain.ARENA_RADIUS: + return GrottoView.surface_lift(p - LuneMap.GROTTO) + return 0.0 func to3(v: Vector2, lift: float = 0.0) -> Vector3: return Vector3(v.x, terrain.walk_height(v) + lift, v.y) @@ -592,8 +621,6 @@ func _bank_clear(p: Vector2) -> bool: return float(lane[0]) > float(LuneTerrain.CORRIDOR.get(lane[1], 3.6)) + .8 # ---------------------------------------------------------------- props -var _vertex_colored: StandardMaterial3D - ## Prop meshes from the Blender generator, with the Kluft shaders applied per surface. func _load_props() -> void: var scene: Node3D = (load(PROPS) as PackedScene).instantiate() @@ -607,96 +634,17 @@ func _load_props() -> void: _props[str(child.name)] = mesh scene.free() -func _dress(node: Node) -> void: - if node is MeshInstance3D: - for index: int in range(node.mesh.get_surface_count()): - var source := node.mesh.surface_get_material(index) as StandardMaterial3D - if source != null and source.resource_name.begins_with("ENV_") and not source.resource_name.contains("brass"): - node.set_surface_override_material(index, surface(source.resource_name, source.albedo_color)) - elif source != null and source.resource_name.begins_with("CV_"): - # Compare-pack props carry their colours as vertex colours. - if _vertex_colored == null: - _vertex_colored = StandardMaterial3D.new() - _vertex_colored.vertex_color_use_as_albedo = true - _vertex_colored.roughness = .86 - node.set_surface_override_material(index, _vertex_colored) - for child: Node in node.get_children(): - _dress(child) - # ---------------------------------------------------------------- grotto -## Blockout of the boss grotto: the terrace arena in front of the escarpment, the rock -## arc as cave mouth, a ring of pillars around the back, cliffs for the silhouette, -## one stair per team up to its entrance and the spring falling towards Mid. +## The boss grotto "Mondschlund" (G2 Blender asset) on the G1 terrace in front of the +## escarpment: arena, boss dais, altar with the Mondquelle, monumental cave gate, old columns +## and cliff masses. The G1 stairs (one per team) and the spring stay; the grotto view owns +## lights, states and the resting boss. func _grotto() -> void: var center: Vector2 = LuneMap.GROTTO - var y: float = LuneTerrain.ARENA_HEIGHT - var arena := MeshInstance3D.new() - arena.name = "GrottoArena" - var disc := CylinderMesh.new() - disc.top_radius = LuneTerrain.ARENA_RADIUS - .25 - disc.bottom_radius = LuneTerrain.ARENA_RADIUS - disc.height = .1 - disc.radial_segments = 56 - arena.mesh = disc - arena.position = Vector3(center.x, y + .02, center.y) - arena.material_override = surface("ENV_paving_arena", Color(.4, .37, .42)) - add_child(arena) - _grotto_runes = _glow(GROTTO_COLOR, 1.0) - var runes := MeshInstance3D.new() - var ring := TorusMesh.new() - ring.inner_radius = 4.3 - ring.outer_radius = 4.6 - ring.rings = 64 - runes.mesh = ring - runes.scale = Vector3(1, .12, 1) - runes.position = Vector3(center.x, y + .09, center.y) - runes.material_override = _grotto_runes - runes.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF - add_child(runes) - var cave := MeshInstance3D.new() - cave.name = "BossGrotto" - cave.mesh = _cave_mesh() - cave.scale = Vector3.ONE * 1.05 - cave.position = Vector3(center.x, y - .15, center.y) - Vector3(LuneMap.AXIS.x, 0, LuneMap.AXIS.y) * 1.2 - _dress(cave) - add_child(cave) - var pillars: Array[Transform3D] = [] - var caps: Array[Transform3D] = [] - # Pillars around the back half; the front towards Mid stays open for stairs and spring. - var back: float = atan2(-LuneMap.AXIS.y, -LuneMap.AXIS.x) - for index: int in range(11): - var angle: float = back + deg_to_rad(-120.0 + 24.0 * index) - var at: Vector2 = center + Vector2(cos(angle), sin(angle)) * (LuneTerrain.ARENA_RADIUS - .15) - pillars.append(Transform3D(Basis.IDENTITY, Vector3(at.x, y + 1.6, at.y))) - caps.append(Transform3D(Basis(Vector3.UP, angle), Vector3(at.x, y + 3.45, at.y))) - var pillar := CylinderMesh.new() - pillar.top_radius = .36 - pillar.bottom_radius = .46 - pillar.height = 3.2 - pillar.radial_segments = 10 - _multi(pillar, surface("ENV_limestone_grotto", Color(.46, .43, .47)), pillars, "GrottoPillars") - var cap := SphereMesh.new() - cap.radius = .34 - cap.height = .9 - cap.radial_segments = 6 - cap.rings = 2 - _multi(cap, _grotto_runes, caps, "GrottoCrystals", false) - # Cliffs behind the arena give the grotto its silhouette from the game camera; any - # piece that would reach into a lane corridor is left out. - var cliffs: Array[Transform3D] = [] - for step: int in range(-1, 2): - var angle: float = back + step * deg_to_rad(38.0) - var at: Vector2 = center + Vector2(cos(angle), sin(angle)) * 9.6 - var inward: Vector2 = (center - at).normalized() - if _clear_of_lanes(at, 5.2): - cliffs.append(Transform3D(_yaw_basis(_facing(inward), Vector3(.62, 2.2 - absf(step) * .35, 1.1)), _ground3(at, -1.3))) - _prop_multi("EdgeCliff", cliffs) - var rocks: Array[Transform3D] = [] - for spec: Vector3 in [Vector3(-9.0, 5.8, 2.2), Vector3(-10.8, 0.0, 2.6), Vector3(-9.0, -5.8, 2.2), Vector3(-4.5, 8.8, 1.6), Vector3(-4.5, -8.8, 1.6)]: - var at: Vector2 = center + LuneMap.AXIS * spec.x + LuneMap.BLUE_SIDE * spec.y - if _clear_of_lanes(at, spec.z * 1.4): - rocks.append(Transform3D(_yaw_basis(spec.y * .4, Vector3.ONE * spec.z), _ground3(at, -.3))) - _prop_multi("Rock_1", rocks) + grotto_view = GrottoView.new() + grotto_view.position = Vector3(center.x, LuneTerrain.ARENA_HEIGHT, center.y) + add_child(grotto_view) + grotto_view.setup() # One stair per team from the valley up to its entrance. var steps: Array[Transform3D] = [] for team: int in range(2): @@ -708,45 +656,16 @@ func _grotto() -> void: var stair := BoxMesh.new() stair.size = Vector3(3.0, .34, .78) _multi(stair, surface("ENV_limestone_stair", Color(.5, .47, .44)), steps, "GrottoStairs") - grotto_light = OmniLight3D.new() - grotto_light.position = Vector3(center.x, y + 2.6, center.y) - Vector3(LuneMap.AXIS.x, 0, LuneMap.AXIS.y) * 3.0 - grotto_light.light_color = GROTTO_COLOR - grotto_light.light_energy = 2.4 - grotto_light.omni_range = 12.0 - add_child(grotto_light) ## True if a prop of the given radius at p stays outside every lane corridor. func _clear_of_lanes(p: Vector2, radius: float) -> bool: var lane: Array = terrain.nearest_lane(p) return float(lane[0]) > float(LuneTerrain.CORRIDOR.get(lane[1], 3.6)) + radius -## The M2 grotto model without its floor disc and loose boulders (the G1 arena has its -## own floor): only the rock arc, the dark cave and the crystals remain. -func _cave_mesh() -> ArrayMesh: - var scene: Node3D = (load(GROTTO_SCENE) as PackedScene).instantiate() - var source: Mesh = (scene.get_node("BossGrotto") as MeshInstance3D).mesh - var mesh := ArrayMesh.new() - for index: int in range(source.get_surface_count()): - var material: Material = source.surface_get_material(index) - var key: String = material.resource_name if material != null else "" - if key in ["ENV_soil", "ENV_limestone"]: - continue - mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, source.surface_get_arrays(index)) - mesh.surface_set_material(mesh.get_surface_count() - 1, material) - scene.free() - return mesh - -## Grotto glow: violet when open, dim when locked, team colour while a claim is pending. -func sync_grotto(available: bool, claim_team: int) -> void: - if grotto_light == null: - return - var color: Color = TEAM_COLORS[claim_team] if claim_team >= 0 else GROTTO_COLOR - var lit: bool = available or claim_team >= 0 - grotto_light.light_color = color - grotto_light.light_energy = 2.4 if lit else .5 - _grotto_runes.emission = color - _grotto_runes.albedo_color = color - _grotto_runes.emission_energy_multiplier = 1.2 if lit else .15 +## Grotto states from the public match state (see LuneGrottoView). +func sync_grotto(state: LuneMatch, views: Dictionary, delta: float) -> void: + if grotto_view != null: + grotto_view.sync(state, views, delta) # ---------------------------------------------------------------- deployment areas ## One pad per team and lane where the army forms up (rows of four, front row ahead), @@ -814,94 +733,183 @@ func show_deployment(visible_now: bool, team: int, used: Dictionary, grotto_open (_grotto_pads[side] as MeshInstance3D).visible = visible_now and grotto_open and side == team # ---------------------------------------------------------------- buildings -func _building(definition: Dictionary) -> void: - var root := Node3D.new() - root.name = str(definition.id) - root.position = to3(definition.position) - add_child(root) +## G2 tower: Grenzwacht (outer), Bastion (inner) or Kernwacht (base) with damage states. +## Lane towers turn their front (+z) to the lane; base towers face away from their Ancient. +func _tower(definition: Dictionary) -> void: var team: int = int(definition.team) - if definition.tier == "core": - var core: Node3D = AssetLibrary.instantiate_fighter("core_team%d" % team) - if core != null: - core.scale = Vector3.ONE * 1.35 - root.add_child(core) - if core.has_method("trigger_activate"): - core.trigger_activate() - root.set_meta("core", core) + var at: Vector2 = definition.position + var facing: Vector2 + if definition.tier == "base": + facing = (at - map.core_position(team)).normalized() else: - var tower: Node3D = (load(TOWER) as PackedScene).instantiate() - tower.scale = Vector3.ONE * (1.18 if definition.tier == "base" else 1.0) - _dress(tower) - _tint_crystal(tower, _crystal_materials[team]) - # Towers face the lane they guard. - var toward: Vector2 = map.core_position(1 - team) - Vector2(definition.position) - tower.rotation.y = atan2(toward.x, toward.y) - root.add_child(tower) - root.set_meta("tower", tower) - if definition.tier == "core": - for side: float in [-1.0, 1.0]: - # Team banners flank each Ancient. - var banner := MeshInstance3D.new() - banner.mesh = _props["Banner"] - var cloth := StandardMaterial3D.new() - cloth.albedo_color = TEAM_COLORS[team] - cloth.cull_mode = BaseMaterial3D.CULL_DISABLED - cloth.roughness = .8 - for index: int in range(banner.mesh.get_surface_count()): - var source: Material = banner.mesh.surface_get_material(index) - if source != null and source.resource_name == "PR_Banner": - banner.set_surface_override_material(index, cloth) - var toward: Vector2 = (map.core_position(1 - team) - map.core_position(team)).normalized() - var across := Vector2(-toward.y, toward.x) * side * 3.6 - banner.position = Vector3(across.x, 0, across.y) + Vector3(toward.x, 0, toward.y) * 2.4 - banner.rotation.y = atan2(toward.x, toward.y) - root.add_child(banner) - var light := OmniLight3D.new() - light.position = Vector3(0, 5.2 if definition.tier != "core" else 2.4, 0) - light.light_color = TEAM_COLORS[team] - light.light_energy = 1.2 - light.omni_range = 6.0 - root.add_child(light) - root.set_meta("light", light) - var bar := Label3D.new() - bar.billboard = BaseMaterial3D.BILLBOARD_ENABLED - bar.no_depth_test = true - bar.font_size = 34 - bar.outline_size = 8 - bar.pixel_size = .01 - bar.modulate = TEAM_COLORS[team].lightened(.25) - bar.position = Vector3(0, 7.4 if definition.tier != "core" else 4.4, 0) - root.add_child(bar) - root.set_meta("bar", bar) - building_nodes[definition.id] = root + facing = (map.lane_point(team, str(definition.lane), float(definition.progress)) - at).normalized() + var view: Node3D = TowerView.new() + view.position = to3(at) + add_child(view) + view.setup(definition, facing) + tower_views[definition.id] = view + building_nodes[definition.id] = view -func _tint_crystal(node: Node, crystal: StandardMaterial3D) -> void: - if node is MeshInstance3D: - for index: int in range(node.mesh.get_surface_count()): - var source: Material = node.mesh.surface_get_material(index) - if source != null and source.resource_name == "TW_Crystal": - node.set_surface_override_material(index, crystal) - for child: Node in node.get_children(): - _tint_crystal(child, crystal) +## G2 Ancient ("Mondherz") on the plaza, facing the map centre. +func _ancient(definition: Dictionary) -> void: + var at: Vector2 = definition.position + var view: Node3D = AncientView.new() + view.position = to3(at, PLAZA_LIFT) + add_child(view) + view.setup(definition, (-at).normalized()) + ancient_views[definition.id] = view + building_nodes[definition.id] = view + +## Energy veins carved into the plaza: from the Ancient's pedestal to both base towers and +## out towards the three lane gates. Their glow follows the Ancient and the tower they feed. +func _build_veins(team: int) -> void: + var core: Vector2 = map.core_position(team) + var targets: Array = [] + for definition: Dictionary in map.buildings: + if int(definition.team) == team and definition.tier == "base": + targets.append([Vector2(definition.position), str(definition.id), 1.9]) + for lane: String in LuneMap.LANES: + var gate: Vector2 = map.lane_point(team, lane, 0.0) + targets.append([core + (gate - core).normalized() * (PLAZA_RADIUS - .3), "", 0.0]) + var list: Array = [] + var index: int = 0 + for target: Array in targets: + var goal: Vector2 = target[0] + var direction: Vector2 = (goal - core).normalized() + var normal := Vector2(-direction.y, direction.x) + var start: Vector2 = core + direction * 2.05 + var finish: Vector2 = goal - direction * float(target[2]) + var length: float = start.distance_to(finish) + var tool := SurfaceTool.new() + tool.begin(Mesh.PRIMITIVE_TRIANGLES) + var steps: int = maxi(4, int(length / .25)) + var previous: Array = [] + for k: int in range(steps + 1): + var t: float = float(k) / steps + var wave: float = sin(t * 7.0 + index * 1.7) * .22 * sin(PI * t) + var at: Vector2 = start.lerp(finish, t) + normal * wave + var width: float = lerpf(.24, .14, t) + var y: float = terrain.walk_height(at) + surface_lift(at) + .012 + var left: Vector2 = at + normal * width + var right: Vector2 = at - normal * width + var section: Array = [[Vector3(left.x, y, left.y), Vector2(t * length, 0.0)], [Vector3(right.x, y, right.y), Vector2(t * length, 1.0)]] + if not previous.is_empty(): + for corner: Array in [previous[0], section[1], section[0], previous[0], previous[1], section[1]]: + tool.set_uv(corner[1]) + tool.set_normal(Vector3.UP) + tool.add_vertex(corner[0]) + previous = section + var vein := MeshInstance3D.new() + vein.name = "Vein_%d_%d" % [team, index] + vein.mesh = tool.commit() + var material := ShaderMaterial.new() + material.shader = VeinShader + vein.material_override = material + vein.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(vein) + vein.set_instance_shader_parameter("tint", Materials.team_color(team, "crystal")) + vein.set_instance_shader_parameter("seed", float(index) * .37) + list.append([vein, target[1]]) + index += 1 + _veins[team] = list + +## G2: all strategic objects from the authoritative match state. `views` maps unit ids to +## unit views (bolt targets); sim_delta is the simulation time of this frame (0 when paused). +func sync_strategic(state: LuneMatch, views: Dictionary, time: float, delta: float, sim_delta: float) -> void: + var started: int = Time.get_ticks_usec() + var hp: Dictionary = state.building_hp.duplicate() + var combat: LuneCombat = state.combat if state.phase == LuneMatch.Phase.COMBAT else null + if combat != null: + for id: String in combat.buildings: + hp[id] = (combat.buildings[id] as LuneUnit).hp + var max_hp: Dictionary = {} + for definition: Dictionary in map.buildings: + max_hp[definition.id] = definition.max_hp + var interval: float = state.balance.tower_attack_interval + for id: String in tower_views: + var view: Node3D = tower_views[id] + view.set_health(float(hp.get(id, 0.0)), float(max_hp[id]), _synced_once) + var unit: LuneUnit = combat.buildings.get(id) if combat != null else null + var target_view: Node3D = null + if unit != null and unit.target != null: + target_view = views.get(unit.target.id) + var request: Array = view.update_attack(unit, target_view, interval) + if not request.is_empty(): + tower_fx.launch(request[0], request[1], request[2], view.team, view.tier == "base") + tower_fx.mark(id, target_view if unit != null and unit.in_range else null, view.charge(), view.team) + view.process_view(delta, sim_delta) + tower_fx.process_fx(delta, sim_delta) + for id: String in ancient_views: + var ancient: Node3D = ancient_views[id] + ancient.set_health(float(hp.get(id, 0.0)), float(max_hp[id]), _synced_once) + ancient.process_view(delta, sim_delta) + for team: int in _veins: + var ancient: Node3D = ancient_views["blau_ancient" if team == 0 else "rot_ancient"] + for entry: Array in _veins[team]: + var feeds: String = entry[1] + var alive: bool = feeds.is_empty() or float(hp.get(feeds, 0.0)) > 0.0 + var vein: MeshInstance3D = entry[0] + vein.set_instance_shader_parameter("energy", ancient.vein_energy() * (1.0 if alive else .08)) + vein.set_instance_shader_parameter("flicker", ancient.vein_flicker() if alive else .9) + _synced_once = true + _occlusion_frame += 1 + if _occlusion_frame % 2 == 0: + update_occlusion(tower_fx.camera, views) + strategic_usec = lerpf(strategic_usec, float(Time.get_ticks_usec() - started), .1) + +## Buildings, ancients and the grotto gate thin out (dithered, above head height) while a +## living figure or the camera focus lies behind them on screen. Purely visual. +func update_occlusion(camera: Camera3D, views: Dictionary) -> void: + if camera == null or not camera.is_inside_tree(): + return + var screen: Rect2 = camera.get_viewport().get_visible_rect() + var eye: Vector3 = camera.global_position + var forward: Vector3 = -camera.global_basis.z + forward.y = 0.0 + forward = forward.normalized() if forward.length() > .001 else Vector3.FORWARD + # [screen point, ground depth along the view, important] + var points: Array = [] + for view: Node3D in views.values(): + if not view.visible or view.unit == null or not view.unit.alive: + continue + var at: Vector3 = view.aim_point() + if not camera.is_position_behind(at): + points.append([camera.unproject_position(at), (at - eye).dot(forward), view.unit.kind in ["hero", "champion", "boss"]]) + var important: Array = points.filter(func(entry: Array) -> bool: return bool(entry[2])) + # The camera focus counts for the big buildings only; slim towers never fade for the ground. + var focus: Vector3 = camera.focus + points.append([camera.unproject_position(focus), (focus - eye).dot(forward), true]) + for building: Node3D in tower_views.values(): + var slim: bool = building.tier != "base" + var hidden: bool = _hides_something(building.occluder_box(), camera, screen, eye, forward, important if slim else points) + building.fade_target = (OCCLUSION_FADE_SLIM if slim else OCCLUSION_FADE) if hidden else 0.0 + for building: Node3D in ancient_views.values(): + building.fade_target = OCCLUSION_FADE if _hides_something(building.occluder_box(), camera, screen, eye, forward, points) else 0.0 + if grotto_view != null: + grotto_view.fade_target = OCCLUSION_FADE if _hides_something(grotto_view.occluder_box(), camera, screen, eye, forward, points) else 0.0 + +## True if a point lies inside the building's screen rectangle and further along the view +## (on the ground plane) than the building's far side. +func _hides_something(box: AABB, camera: Camera3D, screen: Rect2, eye: Vector3, forward: Vector3, points: Array) -> bool: + if box.size == Vector3.ZERO: + return false + var rect := Rect2() + var first: bool = true + for index: int in range(8): + var corner: Vector3 = box.get_endpoint(index) + if camera.is_position_behind(corner): + continue + var at: Vector2 = camera.unproject_position(corner) + rect = Rect2(at, Vector2.ZERO) if first else rect.expand(at) + first = false + if first or not rect.intersects(screen): + return false + var center: Vector3 = box.get_center() + var reach: float = maxf(box.size.x, box.size.z) * .5 + var depth: float = (center - eye).dot(forward) + reach * .6 + rect = rect.grow_individual(-rect.size.x * .08, 0.0, -rect.size.x * .08, 0.0) + for entry: Array in points: + if float(entry[1]) > depth and rect.has_point(entry[0]): + return true + return false -## Health text and destroyed state from the authoritative building HP. -func sync_buildings(hp: Dictionary, max_hp: Dictionary, time: float, delta: float) -> void: - for id: String in building_nodes: - var root: Node3D = building_nodes[id] - var value: float = float(hp.get(id, 0.0)) - var bar: Label3D = root.get_meta("bar") - var alive: bool = value > 0.0 - bar.visible = alive and value < float(max_hp[id]) - .5 - bar.text = "%d%%" % ceili(value / float(max_hp[id]) * 100.0) - (root.get_meta("light") as OmniLight3D).visible = alive - if root.has_meta("tower"): - var tower: Node3D = root.get_meta("tower") - tower.position.y = move_toward(tower.position.y, 0.0 if alive else -3.2, delta * 2.0) - tower.rotation.z = move_toward(tower.rotation.z, 0.0 if alive else .22, delta * .3) - elif root.has_meta("core"): - var core: Node3D = root.get_meta("core") - if core.has_method("set_core_health"): - core.set_core_health(value / float(max_hp[id])) - if core.has_method("pose"): - core.pose(time, false, 0, 0, 0, delta) - core.visible = alive diff --git a/scripts/luneraria/view/materials/lune_cloth.gdshader b/scripts/luneraria/view/materials/lune_cloth.gdshader new file mode 100644 index 0000000..020cd98 --- /dev/null +++ b/scripts/luneraria/view/materials/lune_cloth.gdshader @@ -0,0 +1,67 @@ +shader_type spatial; +render_mode diffuse_burley, specular_schlick_ggx, cull_disabled; +// Luneraria CLOTH: team banners and pennants. The mesh is a plain rectangle (UV v = 0 at the +// pole, 1 at the hem); silhouette and emblem come from the team: shape 0 is the cool team's +// swallowtail with a crescent, shape 1 the warm team's flame-tongue hem with a sun disc. +// Wind waves grow towards the hem; `tatter` tears holes for damaged buildings. + +uniform float wind_strength = .12; +uniform float wind_speed = 1.6; + +instance uniform vec4 cloth_color : source_color, instance_index(12) = vec4(.2, .4, .8, 1.0); +instance uniform vec4 emblem_color : source_color, instance_index(13) = vec4(.92, .9, .8, 1.0); +instance uniform float shape : instance_index(14) = 0.0; +instance uniform float tatter : instance_index(15) = 0.0; +instance uniform float fade : instance_index(1) = 0.0; +instance uniform float fade_floor : instance_index(2) = 2.2; +instance uniform float seed : instance_index(3) = 0.0; + +varying vec3 world_pos; + +#include "res://scripts/luneraria/view/materials/lune_common.gdshaderinc" + +void vertex() { + float hang = UV.y; + float t = TIME * wind_speed + seed * 3.0; + float wave = sin(t + UV.y * 4.0 + UV.x * 1.5) * .6 + sin(t * 1.7 + UV.y * 7.0) * .4; + VERTEX += NORMAL * wave * wind_strength * hang * hang; + VERTEX.y += cos(t * .8 + UV.x * 2.0) * .02 * hang; + world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; +} + +void fragment() { + if (PROJECTION_MATRIX[3][3] < .5 && faded(fade, fade_floor, world_pos.y, FRAGCOORD.xy)) discard; + vec2 uv = UV; + // Team silhouette. + if (shape < .5) { + // Swallowtail: a V cut from the hem. + float cut = .72 + .28 * abs(uv.x - .5) * 2.0; + if (uv.y > cut) discard; + } else { + // Three flame tongues. + float tongues = .8 + .2 * pow(max(0.0, cos((uv.x - .5) * 3.0 * 6.28318)), 2.0) - .05 * abs(uv.x - .5); + if (uv.y > tongues) discard; + } + if (tatter > .01 && noise3(vec3(uv * vec2(9.0, 14.0), seed)) < tatter * .45 * smoothstep(.3, 1.0, uv.y)) discard; + vec2 e = (uv - vec2(.5, .38)) * vec2(1.0, 1.35); + float emblem; + if (shape < .5) { + // Crescent moon: a disc minus an offset disc, and a small star. + float outer = length(e) - .2; + float inner = length(e - vec2(.09, .04)) - .17; + emblem = step(max(outer, -inner), 0.0); + emblem = max(emblem, step(length(e - vec2(.12, -.12)), .03)); + } else { + // Sun: disc and rays. + float a = atan(e.y, e.x); + float r = length(e); + emblem = step(r, .11); + emblem = max(emblem, step(r, .2 + .05 * step(0.0, cos(a * 8.0))) * step(.14, r)); + } + float trim = step(uv.y, .06) + step(abs(uv.x - .5), .5) * (step(.46, abs(uv.x - .5))); + vec3 color = mix(cloth_color.rgb, emblem_color.rgb, clamp(emblem + trim * .8, 0.0, 1.0)); + float weave = noise3(vec3(uv * vec2(80.0, 120.0), seed)) * .08; + ALBEDO = color * (.92 + weave) * (1.0 - tatter * .25); + ROUGHNESS = .88; + BACKLIGHT = color * .35; +} diff --git a/scripts/luneraria/view/materials/lune_cloth.gdshader.uid b/scripts/luneraria/view/materials/lune_cloth.gdshader.uid new file mode 100644 index 0000000..8b8f6b6 --- /dev/null +++ b/scripts/luneraria/view/materials/lune_cloth.gdshader.uid @@ -0,0 +1 @@ +uid://b5ibj83uf0rwc diff --git a/scripts/luneraria/view/materials/lune_common.gdshaderinc b/scripts/luneraria/view/materials/lune_common.gdshaderinc new file mode 100644 index 0000000..ca4950a --- /dev/null +++ b/scripts/luneraria/view/materials/lune_common.gdshaderinc @@ -0,0 +1,24 @@ +// Shared helpers of the Luneraria material family (G2): hashes, value noise, an ordered 4x4 +// Bayer threshold for the occlusion fade, and a bump helper from a scalar height. + +float hash13(vec3 p) { p = fract(p * .1031); p += dot(p, p.zyx + 31.32); return fract((p.x + p.y) * p.z); } +float hash12(vec2 p) { vec3 q = fract(vec3(p.xyx) * .1031); q += dot(q, q.yzx + 33.33); return fract((q.x + q.y) * q.z); } +float noise3(vec3 p) { + vec3 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(mix(hash13(i), hash13(i + vec3(1, 0, 0)), f.x), mix(hash13(i + vec3(0, 1, 0)), hash13(i + vec3(1, 1, 0)), f.x), f.y), + mix(mix(hash13(i + vec3(0, 0, 1)), hash13(i + vec3(1, 0, 1)), f.x), mix(hash13(i + vec3(0, 1, 1)), hash13(i + vec3(1, 1, 1)), f.x), f.y), f.z); +} +float fbm3(vec3 p) { return noise3(p) * .55 + noise3(p * 2.03 + 7.1) * .3 + noise3(p * 4.11 + 3.3) * .15; } + +const float BAYER[16] = float[16](0., 8., 2., 10., 12., 4., 14., 6., 3., 11., 1., 9., 15., 7., 13., 5.); +float bayer4(vec2 frag) { + ivec2 i = ivec2(mod(floor(frag), 4.0)); + return (BAYER[i.x + i.y * 4] + .5) / 16.0; +} +// True when a fragment above the fade floor should be dropped for the occlusion fade. Callers +// skip it in the directional shadow pass (orthographic projection), so shadows never dither. +bool faded(float amount, float floor_y, float world_y, vec2 frag) { + if (amount <= .001 || world_y <= floor_y) return false; + return bayer4(frag) < amount * smoothstep(floor_y, floor_y + .8, world_y); +} diff --git a/scripts/luneraria/view/materials/lune_common.gdshaderinc.uid b/scripts/luneraria/view/materials/lune_common.gdshaderinc.uid new file mode 100644 index 0000000..2c8c0d2 --- /dev/null +++ b/scripts/luneraria/view/materials/lune_common.gdshaderinc.uid @@ -0,0 +1 @@ +uid://dek1yt7kn2t5h diff --git a/scripts/luneraria/view/materials/lune_crystal.gdshader b/scripts/luneraria/view/materials/lune_crystal.gdshader new file mode 100644 index 0000000..d004a7a --- /dev/null +++ b/scripts/luneraria/view/materials/lune_crystal.gdshader @@ -0,0 +1,76 @@ +shader_type spatial; +render_mode diffuse_burley, specular_schlick_ggx; +// Luneraria CRYSTAL: opaque faceted crystal with depth. A dark tinted body, inner veins and +// star specks sampled at several depths along the view ray (parallax layers) so the crystal +// reads as deep rather than as flat transparent plastic, a bright heart towards the object +// centre, a fresnel rim and controlled emission. `energy` is the state channel (stable, +// unstable, failing), `flicker` adds irregular pulsing, `fracture` dark cracks. + +uniform float depth_scale = .55; +uniform float vein_scale = 2.6; +uniform float heart_radius = .45; +uniform float rim_power = 2.5; + +instance uniform vec4 tint : source_color, instance_index(4) = vec4(.45, .75, 1.0, 1.0); +instance uniform float energy : instance_index(5) = 1.0; +instance uniform float flicker : instance_index(6) = 0.0; +instance uniform float fracture : instance_index(11) = 0.0; +instance uniform float fade : instance_index(1) = 0.0; +instance uniform float fade_floor : instance_index(2) = 2.2; +instance uniform float seed : instance_index(3) = 0.0; + +varying vec3 local_pos; +varying vec3 local_view; +varying vec3 world_pos; + +#include "res://scripts/luneraria/view/materials/lune_common.gdshaderinc" + +void vertex() { + local_pos = VERTEX; + world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + vec3 camera_local = (inverse(MODEL_MATRIX) * vec4(CAMERA_POSITION_WORLD, 1.0)).xyz; + local_view = VERTEX - camera_local; +} + +void fragment() { + if (PROJECTION_MATRIX[3][3] < .5 && faded(fade, fade_floor, world_pos.y, FRAGCOORD.xy)) discard; + vec3 ray = normalize(local_view); + float inner = 0.0; + float stars = 0.0; + // Three layers deeper and deeper inside the crystal. + for (int i = 1; i <= 3; i++) { + float d = depth_scale * float(i) / 3.0; + vec3 q = local_pos + ray * d; + float v = fbm3(q * vein_scale + seed); + inner += smoothstep(.52, .78, v) * (1.0 - float(i) * .22); + float s = hash13(floor(q * 14.0 + seed)); + stars += step(.9965, s) * (1.0 - float(i) * .25); + } + float heart = exp(-length(local_pos.xz) / max(.05, heart_radius) * 1.6) * (1.0 - smoothstep(0.0, 1.0, abs(local_pos.y) * .5)); + float fres = pow(1.0 - clamp(dot(normalize(NORMAL), normalize(VIEW)), 0.0, 1.0), rim_power); + float t = TIME * (1.3 + flicker * 5.0) + seed * 6.0; + float pulse = .85 + .15 * sin(t); + if (flicker > 0.0) { + float stutter = step(.5, noise3(vec3(t * 1.7, seed, 0.0))); + pulse *= mix(1.0, .35 + .65 * stutter, flicker); + } + float crack = 0.0; + if (fracture > .01) { + float c = abs(noise3(local_pos * 6.0 + seed) - .5); + crack = (1.0 - smoothstep(.0, .045 * fracture, c)) * step(.3, noise3(local_pos * 2.0 + seed + 5.0)); + } + // Flat facets: each face gets its own brightness from its normal, so the cut reads. + vec3 facet_n = normalize(cross(dFdx(local_pos), dFdy(local_pos))); + float facet = .55 + .45 * hash13(floor(facet_n * 3.0 + 7.0)); + vec3 body = tint.rgb * .12; + ALBEDO = body * (1.0 - crack * .8); + ROUGHNESS = .05; + METALLIC = 0.0; + SPECULAR = .95; + float glow = (.12 + inner * .5 * facet + heart * 1.05 + fres * 1.1) * pulse * mix(.7, 1.15, facet); + // Hot parts brighten towards a lighter version of the hue, not towards white. + vec3 hot = mix(tint.rgb, tint.rgb * .6 + vec3(.4), .22 * heart + .12 * fres); + EMISSION = hot * glow * energy * (1.0 - crack * .9) + tint.rgb * stars * .9 * energy; + RIM = .3; + RIM_TINT = .6; +} diff --git a/scripts/luneraria/view/materials/lune_crystal.gdshader.uid b/scripts/luneraria/view/materials/lune_crystal.gdshader.uid new file mode 100644 index 0000000..f692034 --- /dev/null +++ b/scripts/luneraria/view/materials/lune_crystal.gdshader.uid @@ -0,0 +1 @@ +uid://d07u4o1xsv6mw diff --git a/scripts/luneraria/view/materials/lune_hide.gdshader b/scripts/luneraria/view/materials/lune_hide.gdshader new file mode 100644 index 0000000..202f823 --- /dev/null +++ b/scripts/luneraria/view/materials/lune_hide.gdshader @@ -0,0 +1,66 @@ +shader_type spatial; +render_mode diffuse_burley, specular_schlick_ggx; +// Luneraria HIDE: the stone body of the Schlundkrone (G2) - dark basalt plates, pale moonstone +// horns and claws, old bronze shackles. The mesh is skinned, so every pattern is taken from +// the rest position that the generator stores in UV (x, z) and UV2 (y); nothing swims when +// the limbs move. Thin lunar veins run through the basalt and glow in the colour of the team +// the boss fights for (violet in the grotto); `energy` dims them (sleep, death), `damage` is +// a short hit flash, `flicker` makes them stutter. + +uniform vec4 base_color : source_color = vec4(.21, .22, .26, 1.0); +uniform vec4 plate_color : source_color = vec4(.31, .32, .37, 1.0); +uniform float roughness_base = .84; +uniform float metallic_value = 0.0; +uniform float vein_amount = 1.0; +uniform float vein_scale = 3.1; +uniform float patina = 0.0; +uniform vec4 patina_color : source_color = vec4(.30, .46, .40, 1.0); + +instance uniform vec4 tint : source_color, instance_index(4) = vec4(.72, .52, 1.0, 1.0); +instance uniform float energy : instance_index(5) = 1.0; +instance uniform float flicker : instance_index(6) = 0.0; +instance uniform float damage : instance_index(0) = 0.0; +instance uniform float fade : instance_index(1) = 0.0; +instance uniform float fade_floor : instance_index(2) = 2.2; +instance uniform float seed : instance_index(3) = 0.0; + +varying vec3 rest; +varying vec3 world_pos; + +#include "res://scripts/luneraria/view/materials/lune_common.gdshaderinc" + +void vertex() { + rest = vec3(UV.x, UV2.x, UV.y); + world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; +} + +void fragment() { + if (PROJECTION_MATRIX[3][3] < .5 && faded(fade, fade_floor, world_pos.y, FRAGCOORD.xy)) discard; + float occlusion = COLOR.r; + float large = fbm3(rest * 1.4 + seed); + float fine = fbm3(rest * 7.5 + seed * 2.0); + vec3 color = mix(base_color.rgb, plate_color.rgb, smoothstep(.35, .75, large)); + color *= mix(.84, 1.1, fine); + float cavity = 1.0 - smoothstep(.45, .95, occlusion); + color = mix(color, patina_color.rgb, patina * cavity * smoothstep(.35, .7, noise3(rest * 5.0 + seed))); + // Lunar veins: a ridged noise network, strongest in the crevices of the basalt. + float ridge = 1.0 - abs(noise3(rest * vein_scale + seed * 3.0) * 2.0 - 1.0); + // Only the deepest ridges glow, and only where the broad noise lets them: sparse veins. + float vein = smoothstep(.962, .99, ridge) * smoothstep(.38, .62, large) * vein_amount; + float t = TIME * 1.4 + seed * 5.0; + float live = energy * (.8 + .2 * sin(t + rest.y * 3.0)); + if (flicker > 0.0) live *= mix(1.0, .3 + .7 * step(.45, noise3(vec3(t * 1.9, seed, 3.0))), flicker); + ALBEDO = mix(color, tint.rgb * .25, vein * .5) * mix(.5, 1.0, occlusion); + ROUGHNESS = clamp(roughness_base + (fine - .5) * .12 + cavity * .08, .2, .98); + METALLIC = metallic_value * (1.0 - patina * cavity * .6); + SPECULAR = .4; + AO = occlusion; + AO_LIGHT_AFFECT = .3; + EMISSION = tint.rgb * vein * live * 2.2 + tint.rgb * damage * (.06 + vein * 1.6); + float height = fine * .5 + large * .3 - vein * .6; + vec3 dpdx = dFdx(VERTEX), dpdy = dFdy(VERTEX); + vec3 rx = cross(dpdy, NORMAL), ry = cross(NORMAL, dpdx); + float det = dot(dpdx, rx); + vec3 grad = dFdx(height) * rx + dFdy(height) * ry; + NORMAL = normalize(abs(det) * NORMAL - sign(det) * grad * .02); +} diff --git a/scripts/luneraria/view/materials/lune_hide.gdshader.uid b/scripts/luneraria/view/materials/lune_hide.gdshader.uid new file mode 100644 index 0000000..5c6957f --- /dev/null +++ b/scripts/luneraria/view/materials/lune_hide.gdshader.uid @@ -0,0 +1 @@ +uid://nis3l0cvn041 diff --git a/scripts/luneraria/view/materials/lune_metal.gdshader b/scripts/luneraria/view/materials/lune_metal.gdshader new file mode 100644 index 0000000..8f666aa --- /dev/null +++ b/scripts/luneraria/view/materials/lune_metal.gdshader @@ -0,0 +1,54 @@ +shader_type spatial; +render_mode diffuse_burley, specular_schlick_ggx; +// Luneraria METAL: team metal (silver-steel for the cool team, bronze-gold for the warm team), +// iron and old bronze. Clearly metallic but never a mirror: roughness varies with hammered +// noise and wear, occlusion from COLOR darkens and roughens cavities, bronze grows a +// verdigris patina there, raised edges stay polished. Same occlusion fade as stone. + +uniform vec4 base_color : source_color = vec4(.74, .76, .80, 1.0); +uniform float metallic_value = .92; +uniform float roughness_base = .34; +uniform float wear = .5; +uniform float patina = 0.0; +uniform vec4 patina_color : source_color = vec4(.30, .46, .40, 1.0); + +// Per building: team metal colour (alpha unused), damage soot, occlusion fade. +instance uniform vec4 metal_tint : source_color, instance_index(7) = vec4(1.0); +instance uniform float damage : instance_index(0) = 0.0; +instance uniform float fade : instance_index(1) = 0.0; +instance uniform float fade_floor : instance_index(2) = 2.2; +instance uniform float seed : instance_index(3) = 0.0; + +varying vec3 local_pos; +varying vec3 world_pos; + +#include "res://scripts/luneraria/view/materials/lune_common.gdshaderinc" + +void vertex() { + local_pos = VERTEX; + world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; +} + +void fragment() { + if (PROJECTION_MATRIX[3][3] < .5 && faded(fade, fade_floor, world_pos.y, FRAGCOORD.xy)) discard; + float occlusion = COLOR.r; + float hammer = fbm3(local_pos * 9.0 + seed); + float streak = noise3(vec3(local_pos.x * 2.0, local_pos.y * 14.0, local_pos.z * 2.0) + seed); + float cavity = 1.0 - smoothstep(.45, .95, occlusion); + vec3 color = base_color.rgb * metal_tint.rgb; + color *= mix(.86, 1.08, hammer) * mix(.95, 1.03, streak); + // Patina in cavities (bronze), soot where damaged. + color = mix(color, patina_color.rgb, patina * cavity * smoothstep(.35, .7, noise3(local_pos * 4.0 + seed))); + color *= 1.0 - damage * .35 * smoothstep(.4, .8, noise3(local_pos * 2.5 + seed * 2.0)); + ALBEDO = color * mix(.6, 1.0, occlusion); + METALLIC = metallic_value * (1.0 - patina * cavity * .7); + ROUGHNESS = clamp(roughness_base + (hammer - .5) * .18 + cavity * wear * .3 + damage * .2, .12, .9); + AO = occlusion; + AO_LIGHT_AFFECT = .25; + float height = hammer * .5 + streak * .2; + vec3 dpdx = dFdx(VERTEX), dpdy = dFdy(VERTEX); + vec3 rx = cross(dpdy, NORMAL), ry = cross(NORMAL, dpdx); + float det = dot(dpdx, rx); + vec3 grad = dFdx(height) * rx + dFdy(height) * ry; + NORMAL = normalize(abs(det) * NORMAL - sign(det) * grad * .01); +} diff --git a/scripts/luneraria/view/materials/lune_metal.gdshader.uid b/scripts/luneraria/view/materials/lune_metal.gdshader.uid new file mode 100644 index 0000000..b3782c0 --- /dev/null +++ b/scripts/luneraria/view/materials/lune_metal.gdshader.uid @@ -0,0 +1 @@ +uid://b3rp3tl0hcjbw diff --git a/scripts/luneraria/view/materials/lune_rune.gdshader b/scripts/luneraria/view/materials/lune_rune.gdshader new file mode 100644 index 0000000..38ad814 --- /dev/null +++ b/scripts/luneraria/view/materials/lune_rune.gdshader @@ -0,0 +1,105 @@ +shader_type spatial; +render_mode diffuse_burley, specular_schlick_ggx; +// Luneraria RUNE: carved glyph friezes and panels. One glyph per UV cell (u), drawn from +// strokes chosen by the cell hash; the carrier is dark inlay stone. The glyph family is the +// team's (0 lunar arcs and dots, 1 solar rays and wedges, 2 old grotto spirals), so the two +// teams differ in their runes, not only in colour. `energy` dims or wakes them; a slow wave +// runs along the frieze. + +uniform vec4 carrier_color : source_color = vec4(.16, .16, .19, 1.0); +uniform float stroke = .075; + +instance uniform vec4 tint : source_color, instance_index(4) = vec4(.5, .78, 1.0, 1.0); +instance uniform float energy : instance_index(5) = 1.0; +instance uniform float pattern : instance_index(10) = 0.0; +instance uniform float flicker : instance_index(6) = 0.0; +instance uniform float damage : instance_index(0) = 0.0; +instance uniform float fade : instance_index(1) = 0.0; +instance uniform float fade_floor : instance_index(2) = 2.2; +instance uniform float seed : instance_index(3) = 0.0; + +varying vec3 world_pos; +varying vec3 local_pos; + +#include "res://scripts/luneraria/view/materials/lune_common.gdshaderinc" + +void vertex() { + world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + local_pos = VERTEX; +} + +float seg(vec2 p, vec2 a, vec2 b) { + vec2 pa = p - a, ba = b - a; + float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-5), 0.0, 1.0); + return length(pa - ba * h); +} + +float arc(vec2 p, vec2 c, float r, float a0, float a1) { + vec2 d = p - c; + float a = atan(d.y, d.x); + float mid = (a0 + a1) * .5; + float half_span = (a1 - a0) * .5; + float off = abs(mod(a - mid + 3.14159, 6.28318) - 3.14159); + if (off > half_span) { + vec2 e0 = c + r * vec2(cos(a0), sin(a0)); + vec2 e1 = c + r * vec2(cos(a1), sin(a1)); + return min(length(p - e0), length(p - e1)); + } + return abs(length(d) - r); +} + +// Distance to the strokes of glyph `id` inside the unit cell q in [0,1]^2. +float glyph(vec2 q, float id, float family) { + float d = 1.0; + float h1 = fract(id * 7.13), h2 = fract(id * 3.71), h3 = fract(id * 5.37); + vec2 c = vec2(.5, .5); + d = min(d, seg(q, vec2(.5, .12), vec2(.5, .88)) + step(.35, h3) * 1.0); + if (family < .5) { + // Lunar: crescents, circles and dots. + d = min(d, arc(q, vec2(.5 + (h1 - .5) * .2, .55), .26, -1.2 + h2, 2.2 + h2)); + d = min(d, abs(length(q - vec2(.5, .26)) - .1 - h1 * .05) + step(.5, h2) * 1.0); + d = min(d, length(q - vec2(.5 + (h2 - .5) * .5, .82)) - .04); + d = min(d, seg(q, vec2(.28, .4 + h1 * .2), vec2(.72, .4 + h1 * .2)) + step(.6, h1) * 1.0); + } else if (family < 1.5) { + // Solar: rays, wedges and a disc. + d = min(d, abs(length(q - vec2(.5, .6)) - .14)); + for (int k = 0; k < 5; k++) { + float a = 3.14159 * (.1 + .2 * float(k)) + (h1 - .5) * .3; + vec2 dir = vec2(cos(a), sin(a)); + d = min(d, seg(q, vec2(.5, .6) + dir * .2, vec2(.5, .6) + dir * (.3 + .08 * step(.5, fract(h2 * float(k + 1) * 3.1))))); + } + d = min(d, seg(q, vec2(.3, .15), vec2(.5, .34)) + step(.4, h2) * 1.0); + d = min(d, seg(q, vec2(.7, .15), vec2(.5, .34)) + step(.4, h2) * 1.0); + } else { + // Old grotto spirals and hooks. + d = min(d, arc(q, c, .28, 0.0 + h1 * 3.0, 4.2 + h1 * 3.0)); + d = min(d, arc(q, c, .14, 1.0 + h2 * 3.0, 5.0 + h2 * 3.0)); + d = min(d, seg(q, vec2(.2, .8 - h3 * .2), vec2(.8, .2 + h3 * .2)) + step(.5, h3) * 1.0); + } + return d; +} + +void fragment() { + if (PROJECTION_MATRIX[3][3] < .5 && faded(fade, fade_floor, world_pos.y, FRAGCOORD.xy)) discard; + float occlusion = COLOR.r; + vec2 uv = UV; + float cell = floor(uv.x); + vec2 q = vec2(fract(uv.x), clamp(uv.y, 0.0, 1.0)); + q = (q - .5) * vec2(1.0, 1.1) + .5; + float id = hash12(vec2(cell, seed * 13.0 + pattern)); + float d = glyph(q, id, pattern); + float line = 1.0 - smoothstep(stroke * .55, stroke, d); + // Border lines along the frieze. + float border = 1.0 - smoothstep(.02, .045, min(abs(uv.y - .04), abs(uv.y - .96))); + float mark = max(line, border * .7); + float wave = .7 + .3 * sin(uv.x * .9 - TIME * 1.6 + seed * 4.0); + float live = energy; + if (flicker > 0.0) live *= mix(1.0, step(.45, noise3(vec3(TIME * 3.0 + cell, seed, 0.0))), flicker); + // Damage breaks glyph segments. + float broken = damage > .01 ? step(damage * .8, fract(id * 11.3 + floor(q.y * 3.0) * .37)) : 1.0; + vec3 carrier = carrier_color.rgb * mix(.8, 1.1, noise3(world_pos * 3.0)) * mix(.6, 1.0, occlusion); + ALBEDO = mix(carrier, tint.rgb * .25, mark * .6); + ROUGHNESS = mix(.8, .35, mark); + METALLIC = 0.0; + EMISSION = tint.rgb * mark * broken * live * wave * 2.2; +} diff --git a/scripts/luneraria/view/materials/lune_rune.gdshader.uid b/scripts/luneraria/view/materials/lune_rune.gdshader.uid new file mode 100644 index 0000000..b51a22b --- /dev/null +++ b/scripts/luneraria/view/materials/lune_rune.gdshader.uid @@ -0,0 +1 @@ +uid://dagbiqqklrj7u diff --git a/scripts/luneraria/view/materials/lune_stone.gdshader b/scripts/luneraria/view/materials/lune_stone.gdshader new file mode 100644 index 0000000..8f0683a --- /dev/null +++ b/scripts/luneraria/view/materials/lune_stone.gdshader @@ -0,0 +1,135 @@ +shader_type spatial; +render_mode diffuse_burley, specular_schlick_ggx; +// Luneraria STONE: moonstone ashlar and basalt for the strategic buildings (G2), meant to be +// reused by G4/G5. Limestone texture sampled triplanar in object space, procedural block +// courses with per-block tint, baked occlusion from COLOR (vertex colour Col), bump from the +// texture and the joints, lichen on up-facing ledges, damage cracks that can leak team energy, +// and a dithered occlusion fade above a height so units behind a building stay readable. + +uniform vec4 base_color : source_color = vec4(.78, .74, .66, 1.0); +uniform sampler2D stone_map : source_color, filter_linear_mipmap_anisotropic, repeat_enable; +uniform float course_height = .42; +uniform float block_length = .78; +uniform float joint_depth = .55; +uniform float tint_variation = .09; +uniform float lichen = .25; +uniform float roughness_base = .86; +uniform float texture_scale = .9; +uniform bool ashlar = true; + +// Per building (instance) state. +instance uniform float damage : instance_index(0) = 0.0; +instance uniform vec4 tint : source_color, instance_index(4) = vec4(.5, .75, 1.0, 1.0); +instance uniform float crack_glow : instance_index(8) = 0.0; +instance uniform float fade : instance_index(1) = 0.0; +instance uniform float fade_floor : instance_index(2) = 2.2; +instance uniform float seed : instance_index(3) = 0.0; +instance uniform float scorch : instance_index(9) = 0.0; + +varying vec3 local_pos; +varying vec3 local_normal; +varying vec3 world_pos; + +#include "res://scripts/luneraria/view/materials/lune_common.gdshaderinc" + +// Distance to the nearest Voronoi edge in 2D (for cracks) and the cell id. +vec2 voronoi_edge(vec2 x) { + vec2 n = floor(x), f = fract(x); + vec2 mg, mr; + float md = 8.0; + for (int j = -1; j <= 1; j++) for (int i = -1; i <= 1; i++) { + vec2 g = vec2(float(i), float(j)); + vec2 o = vec2(hash12(n + g), hash12(n + g + 17.3)); + vec2 r = g + o - f; + float d = dot(r, r); + if (d < md) { md = d; mr = r; mg = g; } + } + md = 8.0; + for (int j = -2; j <= 2; j++) for (int i = -2; i <= 2; i++) { + vec2 g = mg + vec2(float(i), float(j)); + vec2 o = vec2(hash12(n + g), hash12(n + g + 17.3)); + vec2 r = g + o - f; + if (dot(mr - r, mr - r) > .00001) md = min(md, dot(.5 * (mr + r), normalize(r - mr))); + } + return vec2(md, hash12(n + mg)); +} + +void vertex() { + local_pos = VERTEX; + local_normal = NORMAL; + world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; +} + +// Block pattern on the face that dominates the normal: returns joint mask (1 in a joint) and a block id. +vec2 blocks(vec3 p, vec3 n) { + vec3 w = abs(n); + float row = floor(p.y / course_height); + float along; + if (w.y > .72) { + // Horizontal faces: paving slabs. + vec2 cell = p.xz / vec2(block_length * .9, block_length * .7); + cell.x += .5 * mod(floor(cell.y), 2.0); + vec2 f = abs(fract(cell) - .5); + float joint = smoothstep(.43, .49, max(f.x, f.y)); + return vec2(joint, hash12(floor(cell) + seed)); + } + // Vertical faces: courses; run along the tangent (angle around the local axis for towers). + float radius = length(p.xz); + along = radius > .15 ? atan(p.z, p.x) * radius : (w.x > w.z ? p.z : p.x); + float shifted = along / block_length + .5 * mod(row, 2.0) + hash12(vec2(row, seed)) * .3; + float fy = abs(fract(p.y / course_height) - .5); + float fx = abs(fract(shifted) - .5); + float joint = max(smoothstep(.44, .49, fy), smoothstep(.465, .495, fx)); + return vec2(joint, hash12(vec2(floor(shifted), row) + seed * 1.7)); +} + +void fragment() { + float occlusion = COLOR.r; + // Occlusion fade: screen-door dithering above the fade floor (world height). + if (PROJECTION_MATRIX[3][3] < .5 && faded(fade, fade_floor, world_pos.y, FRAGCOORD.xy)) discard; + vec3 n = normalize(local_normal); + vec3 w = pow(abs(n), vec3(4.0)); + w /= max(dot(w, vec3(1.0)), .0001); + vec3 p = local_pos * texture_scale; + vec3 tex = texture(stone_map, p.zy).rgb * w.x + texture(stone_map, p.xz).rgb * w.y + texture(stone_map, p.xy).rgb * w.z; + float lum = dot(tex, vec3(.333)); + vec2 b = ashlar ? blocks(local_pos, n) : vec2(0.0, hash13(floor(local_pos * 1.3) + seed)); + float joint = b.x * joint_depth; + float block_tint = (b.y - .5) * 2.0 * tint_variation; + float broad = noise3(local_pos * .9 + seed); + vec3 color = base_color.rgb * (.72 + lum * .62) * (1.0 + block_tint) * mix(.92, 1.06, broad); + // Warm/cool drift per block keeps large faces from looking like plastic. + color *= mix(vec3(1.02, 1.0, .96), vec3(.97, .99, 1.03), b.y); + color *= 1.0 - joint * .55; + // Lichen and dust gather on ledges that face up. + float up = smoothstep(.55, .95, n.y); + float moss = up * lichen * smoothstep(.45, .75, noise3(local_pos * 2.3 + seed * 3.1)); + color = mix(color, vec3(.33, .37, .22) * (.7 + lum * .5), moss * .55); + // Soot from fire and damage. + color *= 1.0 - scorch * smoothstep(.3, .8, noise3(local_pos * 1.7 + 4.0)) * .55; + // Cracks: Voronoi edges in the dominant plane, growing with damage. + float crack = 0.0; + if (damage > .01) { + vec2 cp = (w.y > .6 ? local_pos.xz : (w.x > w.z ? local_pos.zy : local_pos.xy)) * 1.35 + seed * 3.7; + vec2 v = voronoi_edge(cp); + float width = mix(.006, .032, damage) * (.5 + noise3(local_pos * 3.0 + seed)); + // Only some Voronoi edges crack; more of them as damage grows. + float reach = step(1.0 - damage * (w.y > .6 ? .45 : .75), fract(v.y * 7.13 + seed)) * smoothstep(.35, .6, noise3(local_pos * .8 + seed * 2.0) + damage * .3); + crack = (1.0 - smoothstep(width * .4, width, v.x)) * reach; + color *= 1.0 - crack * .62; + color *= 1.0 - damage * .1; + } + ALBEDO = color * mix(.55, 1.0, occlusion); + ROUGHNESS = clamp(roughness_base + (1.0 - lum) * .08 + joint * .08 - moss * .05, .45, .98); + SPECULAR = .35; + AO = occlusion; + AO_LIGHT_AFFECT = .35; + EMISSION = tint.rgb * crack * crack_glow * (.6 + .4 * sin(TIME * 3.0 + local_pos.y * 2.0)); + // Bump from texture luminance and the joints. + float height = lum * .6 - joint * .9 - crack * .8; + vec3 dpdx = dFdx(VERTEX), dpdy = dFdy(VERTEX); + vec3 rx = cross(dpdy, NORMAL), ry = cross(NORMAL, dpdx); + float det = dot(dpdx, rx); + vec3 grad = dFdx(height) * rx + dFdy(height) * ry; + NORMAL = normalize(abs(det) * NORMAL - sign(det) * grad * .022); +} diff --git a/scripts/luneraria/view/materials/lune_stone.gdshader.uid b/scripts/luneraria/view/materials/lune_stone.gdshader.uid new file mode 100644 index 0000000..05bea36 --- /dev/null +++ b/scripts/luneraria/view/materials/lune_stone.gdshader.uid @@ -0,0 +1 @@ +uid://cm853i18d4cjo diff --git a/scripts/luneraria/view/materials/lune_vein.gdshader b/scripts/luneraria/view/materials/lune_vein.gdshader new file mode 100644 index 0000000..9168642 --- /dev/null +++ b/scripts/luneraria/view/materials/lune_vein.gdshader @@ -0,0 +1,26 @@ +shader_type spatial; +render_mode diffuse_burley, specular_schlick_ggx; +// Luneraria energy vein: a carved channel in the plaza floor through which the Ancient's +// energy flows to its base towers and gates. Pulses travel along UV.x towards the heart +// (u = 0 at the Ancient); `energy` follows the Ancient's state, `flicker` its instability. + +instance uniform vec4 tint : source_color, instance_index(4) = vec4(.5, .78, 1.0, 1.0); +instance uniform float energy : instance_index(5) = 1.0; +instance uniform float flicker : instance_index(6) = 0.0; +instance uniform float seed : instance_index(3) = 0.0; + +#include "res://scripts/luneraria/view/materials/lune_common.gdshaderinc" + +void fragment() { + float across = abs(UV.y - .5) * 2.0; + float core = 1.0 - smoothstep(.15, .7, across); + float lip = smoothstep(.72, .95, across); + float flow = fract(UV.x * .35 + TIME * .45 + seed); + float pulse = smoothstep(.0, .12, flow) * (1.0 - smoothstep(.12, .45, flow)); + float live = energy; + if (flicker > 0.0) live *= mix(1.0, step(.4, noise3(vec3(TIME * 4.0, UV.x * .3, seed))), flicker); + vec3 carved = vec3(.08, .08, .1) + vec3(.3, .29, .27) * lip; + ALBEDO = mix(carved, tint.rgb * .2, core * .6); + ROUGHNESS = mix(.85, .3, core); + EMISSION = tint.rgb * core * live * (.55 + pulse * 1.6); +} diff --git a/scripts/luneraria/view/materials/lune_vein.gdshader.uid b/scripts/luneraria/view/materials/lune_vein.gdshader.uid new file mode 100644 index 0000000..518506c --- /dev/null +++ b/scripts/luneraria/view/materials/lune_vein.gdshader.uid @@ -0,0 +1 @@ +uid://ckfic8hnr3e0q diff --git a/scripts/luneraria/view/materials/lune_water_glow.gdshader b/scripts/luneraria/view/materials/lune_water_glow.gdshader new file mode 100644 index 0000000..b73e736 --- /dev/null +++ b/scripts/luneraria/view/materials/lune_water_glow.gdshader @@ -0,0 +1,42 @@ +shader_type spatial; +render_mode diffuse_burley, specular_schlick_ggx; +// Luneraria MOON WATER: the glowing water of the grotto channel and the light that fills the +// depth of the cave mouth (G2). A dark, glossy surface with slow flowing ripples whose crests +// carry the tint; `energy` is the state channel (bright when the grotto is awake, almost dark +// when it is exhausted), `flicker` lets it stutter. + +uniform float flow_speed = .35; +uniform float ripple_scale = 2.2; + +instance uniform vec4 tint : source_color, instance_index(4) = vec4(.7, .5, 1.0, 1.0); +instance uniform float energy : instance_index(5) = 1.0; +instance uniform float flicker : instance_index(6) = 0.0; +instance uniform float fade : instance_index(1) = 0.0; +instance uniform float fade_floor : instance_index(2) = 2.2; +instance uniform float seed : instance_index(3) = 0.0; + +varying vec3 world_pos; +varying vec3 local_pos; + +#include "res://scripts/luneraria/view/materials/lune_common.gdshaderinc" + +void vertex() { + world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + local_pos = VERTEX; +} + +void fragment() { + if (PROJECTION_MATRIX[3][3] < .5 && faded(fade, fade_floor, world_pos.y, FRAGCOORD.xy)) discard; + float t = TIME * flow_speed; + vec3 p = local_pos * ripple_scale; + float ripple = fbm3(p + vec3(t, t * .6, -t * .8) + seed); + float crest = smoothstep(.55, .8, ripple); + float live = energy; + if (flicker > 0.0) live *= mix(1.0, .4 + .6 * step(.4, noise3(vec3(TIME * 2.3, seed, 1.0))), flicker); + ALBEDO = tint.rgb * .04 + vec3(.02, .02, .03); + ROUGHNESS = .08; + METALLIC = 0.0; + SPECULAR = .8; + // Mostly dark water; only the moving crests catch the moonlight. + EMISSION = tint.rgb * (.04 + crest * .7 + ripple * .06) * live; +} diff --git a/scripts/luneraria/view/materials/lune_water_glow.gdshader.uid b/scripts/luneraria/view/materials/lune_water_glow.gdshader.uid new file mode 100644 index 0000000..ef81aa2 --- /dev/null +++ b/scripts/luneraria/view/materials/lune_water_glow.gdshader.uid @@ -0,0 +1 @@ +uid://du8l51yp5f5tv diff --git a/scripts/presentation/asset_library.gd b/scripts/presentation/asset_library.gd index 73c67c3..f079210 100644 --- a/scripts/presentation/asset_library.gd +++ b/scripts/presentation/asset_library.gd @@ -34,10 +34,16 @@ func _ready() -> void: _models[str(entry.id)] = entry for entry: Dictionary in _read_manifest("res://assets/luneraria/peoples_manifest.json"): _models[str(entry.id)] = entry + # G2: strategic buildings, grotto and the Schlundkrone (the only skeletal entry there). + for entry: Dictionary in _read_manifest("res://assets/luneraria/g2/g2_manifest.json"): + _models[str(entry.id)] = entry for entry: Dictionary in _read_manifest("res://assets/phase3_manifest.json"): _icons[str(entry.id)] = str(entry.resource_path) _icons["worker"] = _icons.get("worker_funkenleser", "") +func has_model(id: String) -> bool: + return _models.has(id) + func _read_manifest(path: String) -> Array: if not FileAccess.file_exists(path): return [] @@ -105,6 +111,8 @@ func instantiate_fighter(id: String) -> Node3D: figure.setup(source,box) if _models[id].has("attack_cycle"): figure.attack_cycle.assign(_models[id].attack_cycle) + if _models[id].has("stride"): + figure.stride = float(_models[id].stride) return figure if not (id.begins_with("g") or id.begins_with("e") or id.begins_with("w") or id.begins_with("core_team")) or not _models.has(id): return instantiate_model(id) diff --git a/scripts/presentation/skeletal_figure.gd b/scripts/presentation/skeletal_figure.gd index b18a491..40ea601 100644 --- a/scripts/presentation/skeletal_figure.gd +++ b/scripts/presentation/skeletal_figure.gd @@ -5,6 +5,8 @@ var player: AnimationPlayer var bounds: AABB var clips: Dictionary = {} var attack_cycle: Array[String] = ["attack_a", "attack_b"] +## Ground covered by one walk cycle (metres); big figures such as the Schlundkrone step wider. +var stride: float = .52 var current_clip: String = "" var dead: bool = false var _clock: float = 0.0 @@ -103,8 +105,8 @@ func update_combat(delta: float, distance: float, in_range: bool, progress: floa elif moving: if not _moving: _clock = 0.0 - # One stride travels 0.52 metres. Separation and slow effects affect cadence. - _walk_clock += distance / .52 * clip_length("walk") + # One stride travels `stride` metres. Separation and slow effects affect cadence. + _walk_clock += distance / stride * clip_length("walk") if _clock < clip_length("start"): _sample("start",_clock,delta) else: @@ -136,6 +138,15 @@ func update_combat(delta: float, distance: float, in_range: bool, progress: floa _last_hit = hit _moving = moving +## Plays a one-off clip (e.g. the boss roar) as an event outside of attacks; ignored when +## the figure has no such clip or is dead. Purely visual. +func play_event(clip: String) -> void: + if dead or not clips.has(clip): + return + _event_left = clip_length(clip) + _clock = 0.0 + _sample(clip, 0.0, 0.0) + func _overlay(clip: String, at: float, weight: float, bones: Array) -> void: # Only selected joints react while the primary swing keeps its contact time. if not is_inside_tree() or not clips.has(clip): diff --git a/tools/luneraria/build_luneraria_demo.py b/tools/luneraria/build_luneraria_demo.py index b0cdb21..abc59d7 100644 --- a/tools/luneraria/build_luneraria_demo.py +++ b/tools/luneraria/build_luneraria_demo.py @@ -1,85 +1,118 @@ #!/usr/bin/env python3 -"""Build unpackable Champions of Luneraria demos for Linux and Windows. +"""Build unpackable Champions of Luneraria demos for Linux, Windows (x64 and ARM64) and macOS. -Run: python3 tools/luneraria/build_luneraria_demo.py [--check-only] +Run: python3 tools/luneraria/build_luneraria_demo.py [--check-only] [--only macos,windows,...] + (on macOS without an accepted Xcode licence: /opt/homebrew/bin/python3.12 ...) +Godot: `godot` from PATH or the GODOT environment variable (4.7.2 with its export templates). Output: ../dist/luneraria/ChampionsOfLuneraria-Linux-x64-.zip ../dist/luneraria/ChampionsOfLuneraria-Windows-x64-.zip -Old Saumwacht releases under ../dist/Saumwacht-*.zip stay untouched. + ../dist/luneraria/ChampionsOfLuneraria-Windows-ARM64-.zip + ../dist/luneraria/ChampionsOfLuneraria-macOS-universal-.zip (app bundle, ad-hoc signed) +Old Saumwacht releases live in ../_archiv/saumwacht-dist and stay untouched. """ from pathlib import Path -import argparse, zipfile, subprocess, shutil, sys +import argparse, hashlib, json, os, re, zipfile, subprocess, sys PROJECT = Path(__file__).resolve().parents[2] DIST = PROJECT.parent / "dist" / "luneraria" -VERSION = "0.16.0-m4" +# One source of truth: [luneraria] config/version in project.godot (also shown in the menu). +_match = re.search(r'^\[luneraria\][^\[]*?^config/version="([^"]+)"', (PROJECT / "project.godot").read_text(encoding="utf-8"), re.M | re.S) +VERSION = _match.group(1) if _match else "0.0.0-unbekannt" +GODOT = os.environ.get("GODOT", "godot") INTRO = f"""CHAMPIONS OF LUNERARIA · PROTOTYP {VERSION} START + macOS: Das ZIP entpacken und "Champions of Luneraria" starten. Die App ist nur ad-hoc + signiert (nicht notarisiert): beim ersten Start Rechtsklick > Öffnen, oder im + Terminal: xattr -dr com.apple.quarantine "Champions of Luneraria.app" + Universal: Apple Silicon und Intel, macOS 11 oder neuer. + Windows: ChampionsOfLuneraria.exe (x64; auf Windows 11 ARM das ARM64-Paket nutzen) Linux: ./ChampionsOfLuneraria.x86_64 - Windows: ChampionsOfLuneraria.exe - Vulkan-Probleme: --rendering-driver opengl3 + Grafikprobleme: --rendering-driver opengl3 (Windows/Linux) Godot und Blender werden zum Spielen nicht benötigt. Das ganze ZIP zuerst entpacken. INHALT Drei Lanes, zwei Teams à drei Rollen, Partieende nur durch einen zerstörten Ancient. Planung → automatischer Kampf → Abrechnung. Helden, Items, Angriffschampions und - eine Bossgrotte mit entsandtem Boss. + die Bossgrotte „Mondschlund“ mit der Schlundkrone, die als Verstärkung entsandt wird. + G2: monumentale Ancients mit Zustandsstufen, Türme mit Angriff, Schaden und Ruinen. -NETZWERK (Direktverbindung, experimentell) +NETZWERK (Direktverbindung, experimentell, pausiert) Im Setup: Hosten oder Beitreten mit Adresse und Port (Standard 24571). Der Host ist autoritativ. Freie Plätze werden von Bots gespielt. - Ein Disconnect übernimmt ein Bot; er ist kein Kernverlust. Kein Matchmaking, - keine Hostmigration. Getestet: Protokollchecks und ein lokaler Zwei-Prozess- - Loopback – keine Internetpartie auf sechs Rechnern. -PLATTFORM - Geprüft: Fedora 44, Forward+ (Quellprojekt). Windows-Export vorhanden; - ein nativer Windows-11-Test steht aus (keine Hardware in dieser Umgebung). +PLATTFORMEN + Entwickelt und geprüft auf Fedora 44 (bis G1) und macOS 27 / Apple M5 (G2). + Windows 11 (ARM, Parallels) siehe docs/luneraria/G2-STRATEGISCHE-BAUWERKE.md. QUELLEN - https://github.com/ (lokal) · Doku im Quellrepo unter docs/luneraria/ + Doku im Quellrepo unter docs/luneraria/ """ +# preset, folder, binary (None = direct zip export), zip stem PRESETS = [ - ("Luneraria-Linux", "linux", "ChampionsOfLuneraria.x86_64", - "ChampionsOfLuneraria-Linux-x64"), - ("Luneraria-Windows", "windows", "ChampionsOfLuneraria.exe", - "ChampionsOfLuneraria-Windows-x64"), + ("Luneraria-Linux", "linux", "ChampionsOfLuneraria.x86_64", "ChampionsOfLuneraria-Linux-x64"), + ("Luneraria-Windows", "windows", "ChampionsOfLuneraria.exe", "ChampionsOfLuneraria-Windows-x64"), + ("Luneraria-Windows-ARM64", "windows-arm64", "ChampionsOfLuneraria.exe", "ChampionsOfLuneraria-Windows-ARM64"), + ("Luneraria-macOS", "macos", None, "ChampionsOfLuneraria-macOS-universal"), ] -def build(preset: str, folder: str, binary: str) -> Path | None: - out_dir = DIST / folder - out_dir.mkdir(parents=True, exist_ok=True) - cmd = ["godot", "--headless", "--path", str(PROJECT), "--export-release", preset, - str(out_dir / binary)] - print("+", " ".join(cmd)) +def run_export(preset: str, target: Path) -> bool: + cmd = [GODOT, "--headless", "--path", str(PROJECT), "--export-release", preset, str(target)] + print("+", " ".join(cmd), flush=True) result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: + if result.returncode != 0 or not target.exists(): print(result.stdout[-2000:]) print(result.stderr[-2000:], file=sys.stderr) - return None - return out_dir / binary + return False + return True -def pack(folder: str, binary: str, zip_stem: str) -> Path: +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1 << 20), b""): + digest.update(block) + return digest.hexdigest() + + +def build_folder(preset: str, folder: str, binary: str, stem: str) -> Path | None: out_dir = DIST / folder - zip_path = DIST / f"{zip_stem}-{VERSION}.zip" - readme = out_dir / "LIESMICH.txt" - readme.write_text(INTRO, encoding="utf-8") + out_dir.mkdir(parents=True, exist_ok=True) + if not run_export(preset, out_dir / binary): + return None + (out_dir / "LIESMICH.txt").write_text(INTRO, encoding="utf-8") + zip_path = DIST / f"{stem}-{VERSION}.zip" with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: for path in sorted(out_dir.iterdir()): if path.suffix == ".zip": continue - zf.write(path, path.name) - print("PACK", zip_path) + info = zipfile.ZipInfo.from_file(path, path.name) + if path.name == binary: + info.external_attr = 0o755 << 16 + info.compress_type = zipfile.ZIP_DEFLATED + zf.writestr(info, path.read_bytes()) + return zip_path + + +def build_macos(preset: str, stem: str) -> Path | None: + # Godot writes the app bundle straight into the zip (permissions and ad-hoc signature intact). + zip_path = DIST / f"{stem}-{VERSION}.zip" + if zip_path.exists(): + zip_path.unlink() + if not run_export(preset, zip_path): + return None + with zipfile.ZipFile(zip_path, "a", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("LIESMICH.txt", INTRO) return zip_path def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--check-only", action="store_true") + parser.add_argument("--only", default="", help="comma separated folders: linux,windows,windows-arm64,macos") args = parser.parse_args() DIST.mkdir(parents=True, exist_ok=True) # Fail early on missing templates / manifests instead of shipping a broken zip. @@ -88,6 +121,7 @@ def main() -> int: PROJECT / "assets-source/phase6/export-templates/windows_release_x86_64.exe", PROJECT / "assets/luneraria/peoples_manifest.json", PROJECT / "assets/luneraria/map_props_manifest.json", + PROJECT / "assets/luneraria/g2/g2_manifest.json", PROJECT / "scenes/luneraria.tscn", ] missing = [str(p) for p in required if not p.exists()] @@ -97,14 +131,24 @@ def main() -> int: if args.check_only: print("CHECK_OK", VERSION) return 0 + only = [x for x in args.only.split(",") if x] ok = True + built = {} for preset, folder, binary, stem in PRESETS: - artifact = build(preset, folder, binary) - if artifact is None or not artifact.exists(): + if only and folder not in only: + continue + artifact = build_macos(preset, stem) if binary is None else build_folder(preset, folder, binary, stem) + if artifact is None: print("EXPORT_FAIL", preset) ok = False continue - pack(folder, binary, stem) + built[artifact.name] = {"preset": preset, "bytes": artifact.stat().st_size, "sha256": sha256(artifact)} + print("PACK", artifact, artifact.stat().st_size, "bytes", flush=True) + manifest = DIST / "build-manifest.json" + data = json.loads(manifest.read_text()) if manifest.exists() else {} + data.setdefault("builds", {}).update(built) + data["version"] = VERSION + manifest.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n") return 0 if ok else 1 diff --git a/tools/luneraria/contact_sheet.gd b/tools/luneraria/contact_sheet.gd new file mode 100644 index 0000000..833de27 --- /dev/null +++ b/tools/luneraria/contact_sheet.gd @@ -0,0 +1,46 @@ +extends SceneTree +## Tiles screenshots into one contact sheet (optionally cropped and scaled), for reviews and docs. +## Run: godot --headless --path . -s res://tools/luneraria/contact_sheet.gd -- --out=sheet.png --cols=5 \ +## [--crop=x,y,w,h] [--scale=0.5] a.png b.png ... +func _init() -> void: + var out: String = "sheet.png" + var cols: int = 4 + var crop := Rect2i() + var scale: float = 1.0 + var files: Array[String] = [] + for arg: String in OS.get_cmdline_user_args(): + if arg.begins_with("--out="): + out = arg.get_slice("=", 1) + elif arg.begins_with("--cols="): + cols = int(arg.get_slice("=", 1)) + elif arg.begins_with("--scale="): + scale = float(arg.get_slice("=", 1)) + elif arg.begins_with("--crop="): + var v: PackedStringArray = arg.get_slice("=", 1).split(",") + crop = Rect2i(int(v[0]), int(v[1]), int(v[2]), int(v[3])) + else: + files.append(arg) + var tiles: Array[Image] = [] + for path: String in files: + var image := Image.load_from_file(path) + if image == null: + continue + if crop.size.x > 0: + image = image.get_region(crop) + if scale != 1.0: + image.resize(int(image.get_width() * scale), int(image.get_height() * scale), Image.INTERPOLATE_LANCZOS) + tiles.append(image) + if tiles.is_empty(): + quit(1) + return + var w: int = tiles[0].get_width() + var h: int = tiles[0].get_height() + var rows: int = ceili(float(tiles.size()) / cols) + var sheet := Image.create(w * mini(cols, tiles.size()), h * rows, false, Image.FORMAT_RGB8) + for i: int in range(tiles.size()): + var tile: Image = tiles[i] + tile.convert(Image.FORMAT_RGB8) + sheet.blit_rect(tile, Rect2i(0, 0, w, h), Vector2i((i % cols) * w, (i / cols) * h)) + sheet.save_png(out) + print("SHEET ", out, " ", sheet.get_size()) + quit() diff --git a/tools/luneraria/contact_sheet.gd.uid b/tools/luneraria/contact_sheet.gd.uid new file mode 100644 index 0000000..6e519da --- /dev/null +++ b/tools/luneraria/contact_sheet.gd.uid @@ -0,0 +1 @@ +uid://bg54re0c83p6f diff --git a/tools/luneraria/dump_grotto_site.gd b/tools/luneraria/dump_grotto_site.gd new file mode 100644 index 0000000..51592fc --- /dev/null +++ b/tools/luneraria/dump_grotto_site.gd @@ -0,0 +1,41 @@ +extends SceneTree +## Writes the grotto site of the G1 map for the Blender generators (G2): lane centre lines +## with their corridor half widths near the grotto, and the terrain height field around it +## in the grotto's local frame (origin at the arena centre on the terrace, Godot axes). +## Run: godot --headless --path . -s res://tools/luneraria/dump_grotto_site.gd +const OUT := "res://assets-source/luneraria/g2/grotto_site.json" +const HALF: float = 18.0 +const STEP: float = .5 + +func _init() -> void: + var map := LuneMap.new() + var terrain := LuneTerrain.for_map(map) + var center: Vector2 = LuneMap.GROTTO + var lanes: Array = [] + for lane: String in LuneMap.LANES: + var path: LuneMap.LanePath = map.lanes[lane] + var points: Array = [] + var s: float = 0.0 + while s <= path.length: + for team: int in range(2): + var p: Vector2 = map.lane_point(team, lane, s) - center + if absf(p.x) < HALF + 6.0 and absf(p.y) < HALF + 6.0: + points.append([snappedf(p.x, .001), snappedf(p.y, .001)]) + s += .5 + lanes.append({"lane": lane, "corridor": float(LuneTerrain.CORRIDOR.get(lane, 3.6)), "points": points}) + var heights: Array = [] + var count: int = int(HALF * 2.0 / STEP) + 1 + for j: int in range(count): + var row: Array = [] + for i: int in range(count): + var p := center + Vector2(-HALF + i * STEP, -HALF + j * STEP) + row.append(snappedf(terrain.height_at(p) - LuneTerrain.ARENA_HEIGHT, .001)) + heights.append(row) + var data := {"note": "local frame: origin = LuneMap.GROTTO at ARENA_HEIGHT; x,z = map axes; heights relative to the terrace", + "grotto": [center.x, center.y], "arena_height": LuneTerrain.ARENA_HEIGHT, "arena_radius": LuneTerrain.ARENA_RADIUS, + "lanes": lanes, "height_grid": {"origin": [-HALF, -HALF], "step": STEP, "count": count, "rows_along": "z", "values": heights}} + var file := FileAccess.open(OUT, FileAccess.WRITE) + file.store_string(JSON.stringify(data)) + file.close() + print("GROTTO_SITE written: ", ProjectSettings.globalize_path(OUT)) + quit() diff --git a/tools/luneraria/dump_grotto_site.gd.uid b/tools/luneraria/dump_grotto_site.gd.uid new file mode 100644 index 0000000..6fb670a --- /dev/null +++ b/tools/luneraria/dump_grotto_site.gd.uid @@ -0,0 +1 @@ +uid://grtuujlt7byw diff --git a/tools/luneraria/encode_video.py b/tools/luneraria/encode_video.py new file mode 100644 index 0000000..156bfe8 --- /dev/null +++ b/tools/luneraria/encode_video.py @@ -0,0 +1,45 @@ +"""Encodes a PNG frame sequence (Godot movie writer output) into an H.264 MP4 with Blender's +bundled FFmpeg, so no separate FFmpeg installation is needed. + +Run: blender -b --python-exit-code 1 --python tools/luneraria/encode_video.py -- [fps] [scale] + scale (default 1.0) shrinks the output, e.g. 0.5 for 960x540. +""" +import sys +from pathlib import Path +import bpy + +args = sys.argv[sys.argv.index('--') + 1:] +frames_dir, out = Path(args[0]), Path(args[1]) +fps = int(args[2]) if len(args) > 2 else 30 +scale = float(args[3]) if len(args) > 3 else 1.0 +files = sorted(p for p in frames_dir.iterdir() if p.suffix.lower() == '.png') +if not files: + raise SystemExit('ENCODE_FAILED: no frames in %s' % frames_dir) + +bpy.ops.wm.read_factory_settings(use_empty=True) +scene = bpy.context.scene +scene.sequence_editor_create() +strips = scene.sequence_editor.strips if hasattr(scene.sequence_editor, 'strips') else scene.sequence_editor.sequences +strip = strips.new_image(name='frames', filepath=str(files[0]), channel=1, frame_start=1) +for f in files[1:]: + strip.elements.append(f.name) +image = bpy.data.images.load(str(files[0])) +width, height = image.size +scene.render.resolution_x = int(width * scale) // 2 * 2 +scene.render.resolution_y = int(height * scale) // 2 * 2 +scene.render.resolution_percentage = 100 +scene.render.fps = fps +scene.frame_start = 1 +scene.frame_end = len(files) +scene.render.image_settings.media_type = 'VIDEO' if hasattr(scene.render.image_settings, 'media_type') else scene.render.image_settings.file_format +scene.render.image_settings.file_format = 'FFMPEG' +scene.render.ffmpeg.format = 'MPEG4' +scene.render.ffmpeg.codec = 'H264' +scene.render.ffmpeg.constant_rate_factor = 'MEDIUM' +scene.render.ffmpeg.ffmpeg_preset = 'GOOD' +scene.render.ffmpeg.audio_codec = 'NONE' +scene.render.use_sequencer = True +scene.render.filepath = str(out) +out.parent.mkdir(parents=True, exist_ok=True) +bpy.ops.render.render(animation=True) +print('ENCODE_DONE', out, len(files), 'frames', '%dx%d' % (scene.render.resolution_x, scene.render.resolution_y), '%d fps' % fps, flush=True) diff --git a/tools/luneraria/g2_boss_sheet.gd b/tools/luneraria/g2_boss_sheet.gd new file mode 100644 index 0000000..f03c036 --- /dev/null +++ b/tools/luneraria/g2_boss_sheet.gd @@ -0,0 +1,65 @@ +extends Node +## Look-dev sheet for the Schlundkrone (G2): the boss in several clips next to a troop, a hero +## and an attack champion on the Mid platform, seen from the real game camera. Iteration aid. +## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/g2_boss_sheet.tscn -- --out=/tmp/boss/ [--zoom=12] [--yaw=0] +const Game = preload("res://scripts/luneraria/lune_game.gd") +const UnitView = preload("res://scripts/luneraria/view/lune_unit_view.gd") +const POSES: Array = [["idle", 1.0], ["walk", .4], ["attack_a", .7], ["attack_a", .92], ["attack_b", .92], ["hit", .08], + ["ability", .95], ["roar", 1.0], ["victory", .5], ["death", 2.2]] + +var out: String = "/tmp/boss/" +var zoom: float = 12.0 +var yaw_steps: int = 0 +var game: Node3D + +func _ready() -> void: + for arg: String in OS.get_cmdline_user_args(): + if arg.begins_with("--out="): + out = arg.get_slice("=", 1) + if not out.ends_with("/"): + out += "/" + elif arg.begins_with("--zoom="): + zoom = float(arg.get_slice("=", 1)) + elif arg.begins_with("--yaw="): + yaw_steps = int(arg.get_slice("=", 1)) + call_deferred("run") + +func run() -> void: + Soundscape.enabled = false + game = Game.new() + add_child(game) + game.start({"seed": 4, "human_slot": 1}) + await get_tree().process_frame + game.hud.visible = false + var state: LuneMatch = game.match_state + var display := LuneCombat.new(state.map, state.balance, 7) + var hero := LuneContent.hero_definition({"hero_id": "held_ringborke", "xp": 0, "items": [], "talents": []}) + var entries: Array = [["troop", DataCatalog.get_unit("w01"), Vector2(-3.4, 1.0)], ["hero", hero, Vector2(-2.2, 1.0)], + ["champion", LuneContent.champion_definition("ch_belagerung", 7, state.balance), Vector2(-.8, 1.0)], + ["boss", LuneContent.boss_definition(7, state.balance, true), Vector2(1.8, .6)]] + var boss_view: Node3D + for entry: Array in entries: + var unit: LuneUnit = display.spawn_at(entry[0], 0 if entry[0] != "boss" else 2, "lane", entry[1], entry[2], -1, "sheet") + unit.facing = 0.0 + var view: Node3D = UnitView.new() + view.terrain = game.world.terrain + view.ground = game.world.height_at + add_child(view) + view.bind(unit) + view.rotation.y = 0.0 + if entry[0] == "boss": + boss_view = view + game.camera._goal_yaw = yaw_steps * PI * .25 + game.camera.look_at_point(Vector3(0, 0, 0.6), zoom, true) + DirAccess.make_dir_recursive_absolute(out) + for pose: Array in POSES: + var clip: String = pose[0] + for i: int in range(6): + if boss_view.model.has_method("preview"): + boss_view.model.preview(clip, float(pose[1])) + await get_tree().process_frame + await RenderingServer.frame_post_draw + var label: String = "%s-%03d" % [clip, int(float(pose[1]) * 100)] + get_viewport().get_texture().get_image().save_png(out + label + ".png") + print("SHEET ", label) + get_tree().quit() diff --git a/tools/luneraria/g2_boss_sheet.gd.uid b/tools/luneraria/g2_boss_sheet.gd.uid new file mode 100644 index 0000000..21a9ef2 --- /dev/null +++ b/tools/luneraria/g2_boss_sheet.gd.uid @@ -0,0 +1 @@ +uid://der5bg4xoo8sk diff --git a/tools/luneraria/g2_boss_sheet.tscn b/tools/luneraria/g2_boss_sheet.tscn new file mode 100644 index 0000000..7838954 --- /dev/null +++ b/tools/luneraria/g2_boss_sheet.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://tools/luneraria/g2_boss_sheet.gd" id="1"] + +[node name="G2BossSheet" type="Node"] +script = ExtResource("1") diff --git a/tools/luneraria/g2_cost_split.gd b/tools/luneraria/g2_cost_split.gd new file mode 100644 index 0000000..3015fc7 --- /dev/null +++ b/tools/luneraria/g2_cost_split.gd @@ -0,0 +1,86 @@ +extends Node +## Where does a frame go? (G2 performance analysis.) The benchmark fight of round 13 in the +## lane view, paused so every variant renders the same scene; each group is hidden or +## simplified for four seconds and the frame time is taken again. On a GPU-bound machine the +## differences are the render cost of the group. VSync may cap the result (120 Hz = 8.33 ms). +## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/g2_cost_split.tscn -- --out=res://docs/luneraria/logs/g2/cost-split.json +const Game = preload("res://scripts/luneraria/lune_game.gd") + +var out: String = "res://docs/luneraria/logs/g2/cost-split.json" +var results: Array = [] + +func _ready() -> void: + for arg: String in OS.get_cmdline_user_args(): + if arg.begins_with("--out="): + out = arg.get_slice("=", 1) + call_deferred("run") + +func measure(label: String) -> void: + for i: int in 20: + await get_tree().process_frame + var t0: int = Time.get_ticks_usec() + var count: int = 0 + var end: int = Time.get_ticks_msec() + 4000 + while Time.get_ticks_msec() < end: + await RenderingServer.frame_post_draw + count += 1 + var entry := {"variant": label, "frame_ms": (Time.get_ticks_usec() - t0) / 1000.0 / count, + "draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)} + results.append(entry) + print("COST %-28s %.2f ms/frame, %d draw calls" % [label, entry.frame_ms, entry.draw_calls]) + +func run() -> void: + Soundscape.enabled = false + DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED) + var game: Node3D = Game.new() + add_child(game) + game.start({"seed": 5, "human_slot": 0}) + var state: LuneMatch = game.match_state + state.round_number = 13 + for slot: int in range(6): + state.players[slot].draft.gold = 100000 + state.players[slot].draft.roster.clear() + LuneBot.plan(state, slot, "ausgewogen") + game._on_ready() + game.camera.look_at_point(Vector3(-10, 0, 10), 34.0, true) + for i: int in 60: + await get_tree().process_frame + game.paused = true + var world: Node3D = game.world + await measure("everything") + for view: Node3D in world.tower_views.values(): + view.visible = false + await measure("towers hidden") + for view: Node3D in world.tower_views.values(): + view.visible = true + var plain := StandardMaterial3D.new() + plain.albedo_color = Color(.7, .68, .62) + var saved: Array = [] + for view: Node3D in world.tower_views.values(): + for instance: GeometryInstance3D in view._instances: + saved.append([instance, instance.material_override]) + instance.material_override = plain + await measure("towers with a plain material") + for entry: Array in saved: + (entry[0] as GeometryInstance3D).material_override = entry[1] + for view: Node3D in world.ancient_views.values(): + view.visible = false + await measure("Ancients hidden") + for view: Node3D in world.ancient_views.values(): + view.visible = true + world.grotto_view.visible = false + await measure("grotto hidden") + world.grotto_view.visible = true + game._units_root.visible = false + await measure("figures hidden") + game._units_root.visible = true + world.sun.shadow_enabled = false + await measure("sun shadows off") + world.sun.shadow_enabled = true + var report := {"window": get_window().size, "gpu": RenderingServer.get_video_adapter_name(), "engine": Engine.get_version_info().string, + "scene": "round 13, six full armies, lane view (-10, 10) zoom 34, combat paused", "results": results} + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(out.get_base_dir())) + var file := FileAccess.open(out, FileAccess.WRITE) + file.store_string(JSON.stringify(report, "\t")) + file.close() + get_tree().quit() diff --git a/tools/luneraria/g2_cost_split.gd.uid b/tools/luneraria/g2_cost_split.gd.uid new file mode 100644 index 0000000..5629326 --- /dev/null +++ b/tools/luneraria/g2_cost_split.gd.uid @@ -0,0 +1 @@ +uid://7x1ay83pme7h diff --git a/tools/luneraria/g2_cost_split.tscn b/tools/luneraria/g2_cost_split.tscn new file mode 100644 index 0000000..9037d8b --- /dev/null +++ b/tools/luneraria/g2_cost_split.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://tools/luneraria/g2_cost_split.gd" id="1"] + +[node name="G2CostSplit" type="Node"] +script = ExtResource("1") diff --git a/tools/luneraria/g2_probe.gd b/tools/luneraria/g2_probe.gd new file mode 100644 index 0000000..5daef84 --- /dev/null +++ b/tools/luneraria/g2_probe.gd @@ -0,0 +1,79 @@ +extends Node +## Quick look-dev captures from the real game camera (G2 iteration aid, not an acceptance +## tool). Every shot is "name@x,z,zoom[,yaw_steps]" in world metres; shots are separated by ";". +## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/g2_probe.tscn -- --out=/tmp/probe/ \ +## --shots="grotto@-13.5,-13.5,20;gate@-18,-18,16,2" [--hud=0] [--round=10] [--wait=1.5] +## [--boss-claim=0|1] [--hp=id:fraction,...] [--combat=seconds] +const Game = preload("res://scripts/luneraria/lune_game.gd") + +var out: String = "/tmp/probe/" +var shots: Array = [] +var hud: bool = true +var round_number: int = -1 +var wait_seconds: float = 1.2 +var claim: int = -1 +var hp: Dictionary = {} +var combat_seconds: float = -1.0 +var game: Node3D + +func _ready() -> void: + for arg: String in OS.get_cmdline_user_args(): + var value: String = arg.get_slice("=", 1) + if arg.begins_with("--out="): + out = value if value.ends_with("/") else value + "/" + elif arg.begins_with("--shots="): + for spec: String in value.split(";", false): + var name: String = spec.get_slice("@", 0) + var numbers: PackedFloat64Array = [] + for part: String in spec.get_slice("@", 1).split(","): + numbers.append(float(part)) + shots.append([name, numbers]) + elif arg.begins_with("--hud="): + hud = value != "0" + elif arg.begins_with("--round="): + round_number = int(value) + elif arg.begins_with("--wait="): + wait_seconds = float(value) + elif arg.begins_with("--boss-claim="): + claim = int(value) + elif arg.begins_with("--combat="): + combat_seconds = float(value) + elif arg.begins_with("--hp="): + for pair: String in value.split(",", false): + hp[pair.get_slice(":", 0)] = float(pair.get_slice(":", 1)) + call_deferred("run") + +func run() -> void: + Soundscape.enabled = false + game = Game.new() + add_child(game) + game.start({"seed": 4, "human_slot": 1, "bunds": ["wurzelbund", "wurzelbund", "glutpakt", "glutpakt", "wurzelbund", "glutpakt"]}) + await get_tree().process_frame + var state: LuneMatch = game.match_state + if round_number > 0: + state.round_number = round_number + if claim >= 0: + state.boss.claim = {"team": claim, "won_round": state.round_number - 1, "deploy_round": state.round_number, "chooser_slot": claim * 3, "lane": "top"} + state.boss.next_available_round = state.round_number + 3 + for id: String in hp: + state.building_hp[id] = float(state.map.building_def(id).max_hp) * float(hp[id]) + if combat_seconds >= 0.0: + game._on_ready() + await get_tree().process_frame + while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < combat_seconds: + for i: int in range(20): + state.step_combat() + await get_tree().process_frame + game.paused = true + game.hud.visible = hud + for shot: Array in shots: + var numbers: PackedFloat64Array = shot[1] + if numbers.size() > 3: + game.camera._goal_yaw = numbers[3] * PI * .25 + game.camera.look_at_point(Vector3(numbers[0], 0, numbers[1]), numbers[2], true) + await get_tree().create_timer(wait_seconds).timeout + await RenderingServer.frame_post_draw + DirAccess.make_dir_recursive_absolute(out) + get_viewport().get_texture().get_image().save_png(out + str(shot[0]) + ".png") + print("PROBE ", shot[0]) + get_tree().quit() diff --git a/tools/luneraria/g2_probe.gd.uid b/tools/luneraria/g2_probe.gd.uid new file mode 100644 index 0000000..9b65093 --- /dev/null +++ b/tools/luneraria/g2_probe.gd.uid @@ -0,0 +1 @@ +uid://yqi1yk57r8ka diff --git a/tools/luneraria/g2_probe.tscn b/tools/luneraria/g2_probe.tscn new file mode 100644 index 0000000..635bfcf --- /dev/null +++ b/tools/luneraria/g2_probe.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://tools/luneraria/g2_probe.gd" id="1"] + +[node name="G2Probe" type="Node"] +script = ExtResource("1") diff --git a/tools/luneraria/g2_video.gd b/tools/luneraria/g2_video.gd new file mode 100644 index 0000000..c3d2272 --- /dev/null +++ b/tools/luneraria/g2_video.gd @@ -0,0 +1,142 @@ +extends Node +## G2 video scenes from the real game: a tower fight and a boss encounter. Meant for Godot's +## movie writer, which runs the engine in lockstep, so every frame is captured: +## godot --path . --resolution 1920x1080 --fixed-fps 30 --write-movie /tmp/g2video/turm/frame.png \ +## res://tools/luneraria/g2_video.tscn -- --scene=turm +## (--scene=boss for the grotto; --stills= saves a few PNG stills instead of a movie) +## The fights are ordinary LuneGame combats with capture fixtures (round, armies, gold), exactly +## like g2_views; the rules are the ones of the build, the game clock runs at 1x. +const Game = preload("res://scripts/luneraria/lune_game.gd") + +var scene_name: String = "turm" +var stills: String = "" +var game: Node3D + +func _ready() -> void: + for arg: String in OS.get_cmdline_user_args(): + if arg.begins_with("--scene="): + scene_name = arg.get_slice("=", 1) + elif arg.begins_with("--stills="): + stills = arg.get_slice("=", 1) + if not stills.ends_with("/"): + stills += "/" + call_deferred("run") + +func fresh(seed: int = 4) -> LuneMatch: + game = Game.new() + add_child(game) + game.start({"seed": seed, "human_slot": 1, "bunds": ["wurzelbund", "wurzelbund", "glutpakt", "glutpakt", "wurzelbund", "glutpakt"]}) + await get_tree().process_frame + return game.match_state + +func armies(state: LuneMatch, slots: Array, round_number: int, lanes_only: bool = true) -> void: + state.round_number = round_number + for slot: int in range(6): + state.players[slot].draft.roster.clear() + state.players[slot].draft.gold = 100000 if slots.has(slot) else 0 + if slots.has(slot): + LuneBot.plan(state, slot, "ausgewogen") + if lanes_only: + for entry: Dictionary in state.players[slot].draft.roster: + if entry.lane == LuneMatch.GROTTO: + entry.lane = state.players[slot].lane + var hero: Dictionary = state.players[slot].draft.hero + if not hero.is_empty() and hero.lane == LuneMatch.GROTTO: + hero.lane = state.players[slot].lane + +## Runs the combat quickly (more fixed steps per frame) until `done` holds, then pauses. +func fast_forward(state: LuneMatch, done: Callable, limit: float) -> void: + game.paused = false + while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < limit and not done.call(): + for i: int in range(20): + if state.phase != LuneMatch.Phase.COMBAT or done.call(): + break + state.step_combat() + await get_tree().process_frame + game.paused = true + +## A cut: simulates until `done` holds within one single frame, so the movie jumps there. +func jump(state: LuneMatch, done: Callable, limit: float) -> void: + while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < limit and not done.call(): + state.step_combat() + print("G2_VIDEO_CUT at combat %.1f s" % state.combat.elapsed) + +func look(at: Vector2, zoom: float, yaw_steps: int = 0) -> void: + game.camera._goal_yaw = yaw_steps * PI * .25 + game.camera.look_at_point(Vector3(at.x, 0, at.y), zoom, true) + +## Real-time playback at 1x (the movie writer captures every frame). +func play(seconds: float, still_every: float = 0.0, label: String = "") -> void: + game.paused = false + game.speed_index = 0 + var elapsed: float = 0.0 + var next_still: float = still_every + while elapsed < seconds: + await get_tree().process_frame + elapsed += get_process_delta_time() + if not stills.is_empty() and still_every > 0.0 and elapsed >= next_still: + next_still += still_every + await RenderingServer.frame_post_draw + DirAccess.make_dir_recursive_absolute(stills) + get_viewport().get_texture().get_image().save_png(stills + "%s-%05.1f.png" % [label, elapsed]) + +func run() -> void: + Soundscape.enabled = false + match scene_name: + "turm": + await tower_fight() + "boss": + await boss_encounter() + print("G2_VIDEO_DONE ", scene_name) + get_tree().quit() + +## Blau's Mid army with its wave reaches Rot's outer Mid tower (Grenzwacht): lock-on, charge, +## bolts, hits, damage states and finally the fall of the tower. +func tower_fight() -> void: + var state: LuneMatch = await fresh(4) + armies(state, [0, 1, 2], 13) + game._on_ready() + await get_tree().process_frame + var tower: LuneUnit = state.combat.buildings["rot_mid_outer"] + await fast_forward(state, func() -> bool: return tower.target != null and tower.in_range, 90.0) + var at: Vector2 = tower.position + look(at + (state.map.core_position(0) - at).normalized() * 3.5, 17.0) + await play(12.0, 2.0, "turm") + # Cut to heavy damage, then the collapse and the ruin in real time. + await jump(state, func() -> bool: return tower.hp < tower.max_hp * .34 or not tower.alive, 170.0) + await play(10.0, 1.0, "turm-fall") + +## Both teams send expeditions into the Mondschlund: the boss rests, wakes with its roar once +## one expedition is left, fights, and falls (or wins). +func boss_encounter() -> void: + var state: LuneMatch = await fresh(4) + state.round_number = 6 + for slot: int in [0, 1, 3]: + state.players[slot].draft.gold = 2400 + LuneBot.plan(state, slot, "ausgewogen") + for entry: Dictionary in state.players[slot].draft.roster: + entry.lane = LuneMatch.GROTTO if slot == 1 or slot == 3 else entry.lane + var hero: Dictionary = state.players[slot].draft.hero + if not hero.is_empty() and (slot == 1 or slot == 3): + hero.lane = LuneMatch.GROTTO + look(state.map.grotto_center() + Vector2(1.5, 1.5), 19.0) + await play(3.0, 1.0, "boss-ruhe") + game._on_ready() + await get_tree().process_frame + # The expeditions fight each other first; the cut lands just before the boss wakes. + await play(4.0, 1.0, "boss-expedition") + var combat: LuneCombat = state.combat + await jump(state, func() -> bool: return combat.grotto_boss == null or not combat.grotto_boss.dormant \ + or _last_expedition(combat) , 150.0) + await play(10.0, 1.0, "boss-erwacht") + await jump(state, func() -> bool: return combat.grotto_boss == null or combat.grotto_boss.hp < combat.grotto_boss.max_hp * .2 \ + or combat.grotto_resolved, 170.0) + await play(9.0, 1.0, "boss-ende") + +## True shortly before the boss wakes: only one team still has units in the grotto. +func _last_expedition(combat: LuneCombat) -> bool: + var teams: Dictionary = {} + for unit: LuneUnit in combat.units: + if unit.alive and unit.zone == "grotto" and unit.team != LuneCombat.NEUTRAL: + teams[unit.team] = true + return teams.size() <= 1 diff --git a/tools/luneraria/g2_video.gd.uid b/tools/luneraria/g2_video.gd.uid new file mode 100644 index 0000000..7526e31 --- /dev/null +++ b/tools/luneraria/g2_video.gd.uid @@ -0,0 +1 @@ +uid://cuohw6smevjse diff --git a/tools/luneraria/g2_video.tscn b/tools/luneraria/g2_video.tscn new file mode 100644 index 0000000..3765c81 --- /dev/null +++ b/tools/luneraria/g2_video.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://tools/luneraria/g2_video.gd" id="1"] + +[node name="G2Video" type="Node"] +script = ExtResource("1") diff --git a/tools/luneraria/g2_views.gd b/tools/luneraria/g2_views.gd new file mode 100644 index 0000000..d02acfb --- /dev/null +++ b/tools/luneraria/g2_views.gd @@ -0,0 +1,311 @@ +extends Node +## G2 acceptance captures: Ancients, towers, boss grotto and boss, always from the real game +## camera with the real HUD at the running window size (use 1920x1080). +## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/g2_views.tscn -- --out=res://docs/luneraria/screenshots/g2/nachher +## Every view is a real LuneGame. Fixtures only set public match state before or between +## phases (building HP, fixture gold, grotto round, a boss claim), exactly like the G1 views; +## the rules that run are the ones of the build. Only public APIs are used, so the same +## script photographs the G1 state ("vorher") and the G2 state ("nachher"). +const Game = preload("res://scripts/luneraria/lune_game.gd") + +var out: String = "res://docs/luneraria/screenshots/g2/nachher/" +var only: Array[String] = [] +var shots: Array[String] = [] +var game: Node3D + +func _ready() -> void: + for arg: String in OS.get_cmdline_user_args(): + if arg.begins_with("--out="): + out = arg.substr(6) + if not out.ends_with("/"): + out += "/" + elif arg.begins_with("--only="): + only.assign(arg.substr(7).split(",")) + call_deferred("run") + +func wanted(label: String) -> bool: + return only.is_empty() or only.any(func(prefix: String) -> bool: return label.begins_with(prefix)) + +func shot(label: String, frames: int = 14) -> void: + for i: int in frames: + await get_tree().process_frame + await RenderingServer.frame_post_draw + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(out)) + get_viewport().get_texture().get_image().save_png(out + label + ".png") + shots.append(label) + print("G2_VIEW ", label) + +func wait(seconds: float) -> void: + await get_tree().create_timer(seconds).timeout + +func fresh(seed: int = 4, slot: int = 1) -> LuneMatch: + if game != null and is_instance_valid(game): + game.queue_free() + await get_tree().process_frame + await get_tree().process_frame + game = Game.new() + add_child(game) + game.start({"seed": seed, "human_slot": slot, "bunds": ["wurzelbund", "wurzelbund", "glutpakt", "glutpakt", "wurzelbund", "glutpakt"]}) + await get_tree().process_frame + return game.match_state + +func look(at: Vector2, zoom: float, yaw_steps: int = 0) -> void: + # Q/E steps of 45 degrees, as a player would turn the camera. + game.camera._goal_yaw = yaw_steps * PI * .25 + game.camera.look_at_point(Vector3(at.x, 0, at.y), zoom, true) + +func building(id: String) -> Dictionary: + return game.match_state.map.building_def(id) + +## Runs the combat clock quickly (more fixed steps per frame, rules unchanged), then holds it. +func advance_combat(seconds: float, hold: bool = true) -> void: + var state: LuneMatch = game.match_state + game.paused = false + while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < seconds: + for i: int in range(20): + if state.phase != LuneMatch.Phase.COMBAT or state.combat.elapsed >= seconds: + break + state.step_combat() + await get_tree().process_frame + game.paused = hold + +## Advances in real time (normal game clock) so animations and effects are live in the shot. +func live(seconds: float) -> void: + game.paused = false + game.speed_index = 0 + await wait(seconds) + +## Round-13 armies at the supply cap for the given slots (capture fixture gold only). +func armies(state: LuneMatch, slots: Array, round_number: int = 13) -> void: + state.round_number = round_number + for slot: int in range(6): + state.players[slot].draft.roster.clear() + state.players[slot].draft.gold = 100000 if slots.has(slot) else 0 + if slots.has(slot): + LuneBot.plan(state, slot, "ausgewogen") + # The bot planner may send troops to the grotto; captures keep them on the lanes. + for entry: Dictionary in state.players[slot].draft.roster: + if entry.lane == LuneMatch.GROTTO: + entry.lane = state.players[slot].lane + var hero: Dictionary = state.players[slot].draft.hero + if not hero.is_empty() and hero.lane == LuneMatch.GROTTO: + hero.lane = state.players[slot].lane + +func run() -> void: + Soundscape.enabled = false + await ancient_views() + await tower_views() + await grotto_views() + await boss_lane_view() + await tower_fight_view() + await ancient_fight_view() + await scale_view() + if game != null and is_instance_valid(game): + game.queue_free() + await get_tree().process_frame + print("G2_VIEWS_DONE ", shots.size()) + get_tree().quit() + +# ---------------------------------------------------------------- Ancients +func ancient_views() -> void: + if not (wanted("01") or wanted("02") or wanted("03") or wanted("04") or wanted("15") or wanted("17")): + return + var state: LuneMatch = await fresh() + var blue: Vector2 = state.map.core_position(0) + var red: Vector2 = state.map.core_position(1) + if wanted("15"): + game.camera.overview(true) + await shot("15-gesamtkarte", 24) + # The whole sanctuary (Ancient about 10 m, both base towers behind it), camera turned + # one step (Q) so the Ancient stands in front of its towers; Rot mirrored (three steps). + var blue_view: Vector2 = blue + Vector2(1.0, -1.0) + look(blue_view, 28.0, -1) + await live(1.0) + if wanted("01"): + await shot("01-ancient-100") + if wanted("17"): + look(red + Vector2(-1.0, 1.0), 28.0, 3) + await live(.6) + await shot("17-ancient-rot-100") + look(blue_view, 28.0, -1) + state.building_hp["blau_ancient"] = float(building("blau_ancient").max_hp) * .5 + await live(1.6) + if wanted("02"): + await shot("02-ancient-50") + state.building_hp["blau_ancient"] = float(building("blau_ancient").max_hp) * .22 + await live(1.6) + if wanted("03"): + await shot("03-ancient-unter-30") + state.building_hp["blau_ancient"] = 0.0 + await live(.9) + if wanted("04"): + await shot("04a-ancient-zerstoerung", 2) + await live(4.0) + if wanted("04"): + await shot("04-ancient-zerstoert") + +# ---------------------------------------------------------------- towers +func tower_views() -> void: + if not (wanted("05") or wanted("06") or wanted("07") or wanted("08")): + return + var state: LuneMatch = await fresh() + var outer: Vector2 = building("rot_mid_outer").position + var inner: Vector2 = building("rot_mid_inner").position + look(outer + Vector2(0, -2.4), 14.0) + await live(1.0) + if wanted("05"): + await shot("05-tower1-grenzwacht") + look(inner + Vector2(0, -2.8), 14.0) + await live(.6) + if wanted("06"): + await shot("06-tower2-bastion") + # Base towers as an attacker sees them: from the Mid side, flanking the Ancient. + var core: Vector2 = state.map.core_position(1) + var base_a: Vector2 = building("rot_base_1").position + var base_b: Vector2 = building("rot_base_2").position + look((base_a + base_b) * .5 * .5 + core * .5, 24.0, -1) + await live(.6) + if wanted("07"): + await shot("07-basistuerme") + # A tower that fell in this session: the ruin after its destruction sequence. + look(outer + Vector2(0, -2.4), 14.0) + await live(.4) + state.building_hp["rot_mid_outer"] = 0.0 + await live(.7) + if wanted("08"): + await shot("08a-turm-zerstoerung", 2) + await live(3.5) + if wanted("08"): + await shot("08-turm-zerstoert") + +# ---------------------------------------------------------------- grotto +func grotto_views() -> void: + if not (wanted("09") or wanted("10") or wanted("11") or wanted("18")): + return + var state: LuneMatch = await fresh() + var center: Vector2 = state.map.grotto_center() + # Locked grotto: the boss has left with a claim (deploy round), then cooldown rounds. + state.round_number = 7 + state.boss.next_available_round = 10 + state.boss.claim = {"team": 1, "won_round": 6, "deploy_round": 7, "chooser_slot": 3, "lane": "top"} + look(center, 20.0) + await live(1.4) + if wanted("09"): + await shot("09-grotte-neutral-entsandt") + state.boss.claim = {} + state.round_number = 8 + await live(1.4) + if wanted("18"): + await shot("18a-grotte-cooldown") + state.round_number = 9 + await live(1.4) + if wanted("18"): + await shot("18b-grotte-bald-verfuegbar") + state.round_number = 10 + await live(1.6) + if wanted("10"): + await shot("10-boss-verfuegbar") + if not wanted("11"): + return + # Boss fight: both Blau and Rot send expeditions (round 6 armies), fight, the boss wakes. + state = await fresh(4) + state.round_number = 6 + for slot: int in [0, 1, 3]: + state.players[slot].draft.gold = 2400 + LuneBot.plan(state, slot, "ausgewogen") + for entry: Dictionary in state.players[slot].draft.roster: + entry.lane = LuneMatch.GROTTO if slot == 1 or slot == 3 else entry.lane + var hero: Dictionary = state.players[slot].draft.hero + if not hero.is_empty() and (slot == 1 or slot == 3): + hero.lane = LuneMatch.GROTTO + game._on_ready() + await get_tree().process_frame + look(state.map.grotto_center(), 16.0) + var boss_awake: bool = false + while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < 60.0: + await advance_combat(state.combat.elapsed + .5, false) + if state.combat.grotto_boss != null and not state.combat.grotto_boss.dormant: + boss_awake = true + break + await advance_combat(state.combat.elapsed + 2.5) + await live(1.0) + await shot("11-bosskampf" if boss_awake else "11-bosskampf-expeditionen") + +# ---------------------------------------------------------------- boss on a lane +func boss_lane_view() -> void: + if not wanted("12"): + return + var state: LuneMatch = await fresh(4) + armies(state, [0, 1, 2, 3, 4, 5], 7) + state.boss.claim = {"team": 0, "won_round": 6, "deploy_round": 7, "chooser_slot": 1, "lane": "mid"} + state.boss.next_available_round = 10 + game._on_ready() + await get_tree().process_frame + await advance_combat(15.0) + var boss: LuneUnit = null + for unit: LuneUnit in state.combat.units: + if unit.kind == "boss" and unit.alive and unit.zone == "lane": + boss = unit + if boss != null: + look(boss.position + Vector2(1.0, 1.0), 20.0) + await live(1.2) + await shot("12-boss-lane-verstaerkung") + +# ---------------------------------------------------------------- fights at buildings +func tower_fight_view() -> void: + if not wanted("13"): + return + var state: LuneMatch = await fresh(4) + armies(state, [0, 1, 2], 13) + game._on_ready() + await get_tree().process_frame + var tower: LuneUnit = state.combat.buildings["rot_mid_outer"] + # Until Blau's Mid army stands at the Rot outer tower and the tower has a target. + while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < 90.0: + await advance_combat(state.combat.elapsed + .5, false) + if tower.target != null and tower.alive and tower.hp < tower.max_hp * .97: + break + var at: Vector2 = tower.position + look(at + (state.map.core_position(0) - at).normalized() * 3.0, 17.0) + await live(1.3) + await shot("13-kampf-vor-tower", 1) + +func ancient_fight_view() -> void: + if not wanted("14"): + return + var state: LuneMatch = await fresh(4) + for id: String in ["blau_mid_outer", "blau_mid_inner", "blau_base_1", "blau_base_2"]: + state.building_hp[id] = 0.0 + armies(state, [3, 4, 5], 13) + game._on_ready() + await get_tree().process_frame + var core: LuneUnit = state.combat.buildings["blau_ancient"] + while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < 120.0: + await advance_combat(state.combat.elapsed + .5, false) + if core.hp < core.max_hp * .9: + break + # From the attackers' side (three Q steps), so the fight stands in front of the Ancient. + look(state.map.core_position(0) + Vector2(1.5, -1.5), 28.0, 3) + await live(1.3) + await shot("14-kampf-vor-ancient", 1) + +# ---------------------------------------------------------------- scale comparison +## Hero, troops, attack champion and the deployed boss in a row in front of the Blau Ancient +## and its base tower, all in the same world scale. The row runs along the screen's x axis +## (camera yaw 0). The figures belong to a display-only combat that never runs. +func scale_view() -> void: + if not wanted("16"): + return + var state: LuneMatch = await fresh(4) + var display := LuneCombat.new(state.map, state.balance, 7) + var hero := LuneContent.hero_definition({"hero_id": "held_ringborke", "xp": 0, "items": [], "talents": []}) + var entries: Array = [["troop", DataCatalog.get_unit("w01")], ["troop", DataCatalog.get_unit("g05")], ["hero", hero], + ["champion", LuneContent.champion_definition("ch_belagerung", 7, state.balance)], ["boss", LuneContent.boss_definition(7, state.balance, true)]] + var xs: Array[float] = [-34.4, -32.4, -30.2, -27.8, -24.6] + for index: int in range(entries.size()): + var unit: LuneUnit = display.spawn_at(entries[index][0], 0, "lane", entries[index][1], Vector2(xs[index], 35.2), -1, "scale-%d" % index) + unit.facing = 0.0 + game._add_view(unit) + look(Vector2(-27.6, 31.6), 20.0) + await live(1.2) + await shot("16-massstab") diff --git a/tools/luneraria/g2_views.gd.uid b/tools/luneraria/g2_views.gd.uid new file mode 100644 index 0000000..f1df8f6 --- /dev/null +++ b/tools/luneraria/g2_views.gd.uid @@ -0,0 +1 @@ +uid://dfynaudcddq6n diff --git a/tools/luneraria/g2_views.tscn b/tools/luneraria/g2_views.tscn new file mode 100644 index 0000000..5b61d3f --- /dev/null +++ b/tools/luneraria/g2_views.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/luneraria/g2_views.gd" id="1"] + +[node name="G2Views" type="Node"] +script = ExtResource("1") diff --git a/tools/luneraria/lune_benchmark.gd b/tools/luneraria/lune_benchmark.gd index c92a3e6..848ef2e 100644 --- a/tools/luneraria/lune_benchmark.gd +++ b/tools/luneraria/lune_benchmark.gd @@ -5,6 +5,8 @@ extends Node ## a lane view (Mid, Blau side, grotto in the background) and the whole-map overview, ## where every lane, tower, base and the grotto are drawn at once. VSync off; the ## figures apply to this machine only. +## G2 adds a base view (own Ancient and base towers) and a grotto view, plus a census of +## lights, shadow casters and particles per phase. ## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/lune_benchmark.tscn -- --out=res://docs/luneraria/logs/g1/perf.json const Game = preload("res://scripts/luneraria/lune_game.gd") const PHASE_SECONDS: float = 6.0 @@ -35,9 +37,108 @@ func mean(values: Array) -> float: total += float(value) return total / maxf(1.0, values.size()) +## Lights, shadow casters and particles that are active in the scene right now (G2 addition). +func census(root: Node) -> Dictionary: + var result := {"lights": 0, "shadow_lights": 0, "shadow_caster_nodes": 0, "shadow_caster_instances": 0, + "particle_systems": 0, "particles_max": 0} + for node: Node in root.find_children("*", "", true, false): + if node is Light3D and (node as Light3D).is_visible_in_tree(): + result.lights += 1 + if (node as Light3D).shadow_enabled: + result.shadow_lights += 1 + elif node is GPUParticles3D and (node as GPUParticles3D).is_visible_in_tree() and (node as GPUParticles3D).emitting: + result.particle_systems += 1 + result.particles_max += (node as GPUParticles3D).amount + elif node is CPUParticles3D and (node as CPUParticles3D).is_visible_in_tree() and (node as CPUParticles3D).emitting: + result.particle_systems += 1 + result.particles_max += (node as CPUParticles3D).amount + elif node is GeometryInstance3D and (node as GeometryInstance3D).is_visible_in_tree() and (node as GeometryInstance3D).cast_shadow != GeometryInstance3D.SHADOW_CASTING_SETTING_OFF: + result.shadow_caster_nodes += 1 + var instances: int = 1 + if node is MultiMeshInstance3D and (node as MultiMeshInstance3D).multimesh != null: + var multimesh: MultiMesh = (node as MultiMeshInstance3D).multimesh + instances = multimesh.instance_count if multimesh.visible_instance_count < 0 else multimesh.visible_instance_count + result.shadow_caster_instances += instances + return result + +## Resident memory of the whole process (Linux /proc, macOS ps), -1 elsewhere. +static func resident_mb() -> float: + var output: Array = [] + if OS.get_name() == "macOS": + if OS.execute("ps", ["-o", "rss=", "-p", str(OS.get_process_id())], output) != 0 or output.is_empty(): + return -1.0 + return float(str(output[0]).strip_edges()) / 1024.0 + if OS.get_name() != "Linux" or OS.execute("cat", ["/proc/%d/status" % OS.get_process_id()], output) != 0 or output.is_empty(): + return -1.0 + for line: String in str(output[0]).split("\n"): + if line.begins_with("VmRSS:"): + return float(line.split(":")[1].strip_edges().split(" ")[0]) / 1024.0 + return -1.0 + +func median(values: Array[float]) -> float: + if values.is_empty(): + return -1.0 + var sorted: Array[float] = values.duplicate() + sorted.sort() + return sorted[sorted.size() / 2] + +## GPU load on macOS: Godot 4.7.2 gets no GPU timestamps from Metal (always 0), so a thread +## samples the accelerator statistics (ioreg: device/renderer utilisation, GPU memory in use) +## while a phase runs. GPU time per frame ~ utilisation x frame time (system-wide figure). +class GpuSampler: + var thread := Thread.new() + var running: bool = false + var samples: Array = [] + var mutex := Mutex.new() + + func start() -> void: + if OS.get_name() != "macOS": + return + running = true + thread.start(_loop) + + func _loop() -> void: + while running: + var output: Array = [] + if OS.execute("ioreg", ["-r", "-d", "1", "-w", "0", "-c", "IOAccelerator"], output) == 0 and not output.is_empty(): + var text: String = str(output[0]) + var sample: Dictionary = {} + for key: String in ["Device Utilization %", "Renderer Utilization %", "Tiler Utilization %", "In use system memory"]: + var at: int = text.find('"%s"=' % key) + if at >= 0: + var rest: String = text.substr(at + key.length() + 3, 24) + sample[key] = float(rest.get_slice(",", 0).get_slice("}", 0)) + mutex.lock() + samples.append(sample) + mutex.unlock() + OS.delay_msec(400) + + func stop() -> Dictionary: + if not running: + return {} + running = false + thread.wait_to_finish() + var result: Dictionary = {} + for key: String in ["Device Utilization %", "Renderer Utilization %", "Tiler Utilization %"]: + var total: float = 0.0 + for sample: Dictionary in samples: + total += float(sample.get(key, 0.0)) + result[key] = total / maxf(1.0, samples.size()) + var memory: float = 0.0 + for sample: Dictionary in samples: + memory = maxf(memory, float(sample.get("In use system memory", 0.0))) + result["gpu_memory_in_use_mb"] = memory / 1048576.0 + result["samples"] = samples.size() + return result + func measure(game: Node3D, state: LuneMatch) -> Dictionary: for i: int in 30: await get_tree().process_frame + var sampler := GpuSampler.new() + sampler.start() + var process_ms: Array[float] = [] + var setup_ms: Array[float] = [] + var strategic_ms: Array[float] = [] var frames: Array[float] = [] var cpu: Array[float] = [] var gpu: Array[float] = [] @@ -45,6 +146,7 @@ func measure(game: Node3D, state: LuneMatch) -> Dictionary: var primitives: Array[float] = [] var objects: Array[float] = [] var mobile: Array[int] = [] + var scene: Dictionary = {} var last: int = Time.get_ticks_usec() var end: int = Time.get_ticks_msec() + int(PHASE_SECONDS * 1000.0) while Time.get_ticks_msec() < end and state.phase == LuneMatch.Phase.COMBAT: @@ -59,10 +161,27 @@ func measure(game: Node3D, state: LuneMatch) -> Dictionary: primitives.append(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME)) objects.append(Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME)) mobile.append(state.combat.mobile_count()) + process_ms.append(Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0) + setup_ms.append(RenderingServer.get_frame_setup_time_cpu()) + if game.world.get("strategic_usec") != null: + strategic_ms.append(float(game.world.strategic_usec) / 1000.0) + if frames.size() % 60 == 30: + var sample: Dictionary = census(game) + for key: String in sample: + scene[key] = maxi(int(scene.get(key, 0)), int(sample[key])) + var gpu_load: Dictionary = sampler.stop() + var frame_mean: float = mean(frames) frames.sort() - return {"frames": frames.size(), "frame_ms_mean": mean(frames), "frame_ms_p95": frames[int(frames.size() * .95)] if not frames.is_empty() else -1.0, + var result := {"frames": frames.size(), "frame_ms_mean": frame_mean, "frame_ms_p95": frames[int(frames.size() * .95)] if not frames.is_empty() else -1.0, "cpu_frame_ms_mean": mean(cpu), "gpu_ms_mean": mean(gpu), "draw_calls_mean": mean(draws), "primitives_mean": mean(primitives), - "objects_mean": mean(objects), "mobile_units_mean": mean(mobile), "camera_zoom": game.camera.zoom} + "objects_mean": mean(objects), "mobile_units_mean": mean(mobile), "camera_zoom": game.camera.zoom, "scene_max": scene, + "process_ms_median": median(process_ms), "render_setup_ms_median": median(setup_ms)} + if not strategic_ms.is_empty(): + result["strategic_view_ms_median"] = median(strategic_ms) + if not gpu_load.is_empty(): + result["gpu_load"] = gpu_load + result["gpu_ms_estimate"] = frame_mean * float(gpu_load["Device Utilization %"]) / 100.0 + return result func run() -> void: Soundscape.enabled = false @@ -85,6 +204,13 @@ func run() -> void: var lane_view: Dictionary = await measure(game, state) game.camera.overview(true) var overview: Dictionary = await measure(game, state) + # G2: the strategic buildings up close (own base with Ancient and base towers, the grotto). + var core: Vector2 = state.map.core_position(0) + game.camera.look_at_point(Vector3(core.x, 0, core.y), 26.0, true) + var base_view: Dictionary = await measure(game, state) + var grotto: Vector2 = state.map.grotto_center() + game.camera.look_at_point(Vector3(grotto.x, 0, grotto.y), 22.0, true) + var grotto_view: Dictionary = await measure(game, state) # Pure simulation cost of one fixed step at this occupation, measured separately. var probe := LuneMatch.new() probe.setup({"seed": 5, "human_slot": -1}) @@ -101,10 +227,12 @@ func run() -> void: var report := {"window": get_window().size, "renderer": RenderingServer.get_current_rendering_method(), "gpu": RenderingServer.get_video_adapter_name(), "cpu": OS.get_processor_name(), "engine": Engine.get_version_info().string, "scene_build_ms": build_ms, "mobile_units_at_start": start_units, "buildings": 18, "sim_step_ms": step_ms, "sim_speed": "2x (40 steps/s)", - "lane_view": lane_view, "overview": overview, + "lane_view": lane_view, "overview": overview, "base_view": base_view, "grotto_view": grotto_view, "video_mem_mb": Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / 1048576.0, "texture_mem_mb": Performance.get_monitor(Performance.RENDER_TEXTURE_MEM_USED) / 1048576.0, - "static_mem_mb": Performance.get_monitor(Performance.MEMORY_STATIC) / 1048576.0} + "static_mem_mb": Performance.get_monitor(Performance.MEMORY_STATIC) / 1048576.0, + "buffer_mem_mb": Performance.get_monitor(Performance.RENDER_BUFFER_MEM_USED) / 1048576.0, + "process_rss_mb": resident_mb()} var path: String = out if not out.is_empty() else "res://docs/luneraria/logs/perf-%dx%d.json" % [get_window().size.x, get_window().size.y] DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(path.get_base_dir())) var file := FileAccess.open(path, FileAccess.WRITE) diff --git a/tools/luneraria/run_g2_tests.sh b/tools/luneraria/run_g2_tests.sh new file mode 100644 index 0000000..75848d8 --- /dev/null +++ b/tools/luneraria/run_g2_tests.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# G2 regression and acceptance in one go: rules (M1-M3), network (regression only), G1 map, +# G2 acceptance, the real app (M1/M2), stress, balance and the archived Saumwacht mode. +# Every log lands in docs/luneraria/logs/g2/test-*.log. Tools that write into earlier +# milestone folders (M1/M2 acceptance, M4 loopback, milestone screenshots) are copied to the +# G2 folder and the milestone files are restored, so M1-M4 evidence stays as committed. +# Run: sh tools/luneraria/run_g2_tests.sh (GODOT=... PYTHON=... to override the tools) +set -u +cd "$(dirname "$0")/../.." || exit 1 +LOG=docs/luneraria/logs/g2 +mkdir -p "$LOG" +GODOT=${GODOT:-godot} +PYTHON=${PYTHON:-python3} +FAILED=0 + +run() { + name=$1 + shift + started=$(date +%s) + "$@" > "$LOG/test-$name.log" 2>&1 + code=$? + echo "exit $code after $(( $(date +%s) - started )) s" >> "$LOG/test-$name.log" + [ $code -eq 0 ] || FAILED=1 + printf '%-14s exit %s %s\n' "$name" "$code" "$(grep -E 'PASS|FAIL|checks|passed|failed' "$LOG/test-$name.log" | tail -1)" +} + +run core "$GODOT" --headless --path . res://tests/luneraria/test_lune_core.tscn +run m2 "$GODOT" --headless --path . res://tests/luneraria/test_lune_m2.tscn +run net "$GODOT" --headless --path . res://tests/luneraria/test_lune_net.tscn +run net-rounds "$PYTHON" tools/luneraria/net_rounds.py --godot "$GODOT" +cp docs/luneraria/logs/m4/net-rounds.json "$LOG/test-net-rounds.json" 2>/dev/null +run verify-g1 "$GODOT" --path . --resolution 1920x1080 res://tools/luneraria/verify_g1.tscn +cp docs/luneraria/logs/g1/verify-g1.json "$LOG/test-verify-g1.json" 2>/dev/null +run verify-g2 "$GODOT" --path . --resolution 1920x1080 res://tools/luneraria/verify_g2.tscn +run verify-m1 "$GODOT" --path . --resolution 1920x1080 res://tools/luneraria/verify_m1.tscn +cp docs/luneraria/logs/m1-acceptance.json "$LOG/test-verify-m1.json" 2>/dev/null +run verify-m2 "$GODOT" --path . --resolution 1920x1080 res://tools/luneraria/verify_m2.tscn +cp docs/luneraria/logs/m2-acceptance.json "$LOG/test-verify-m2.json" 2>/dev/null +run stress "$GODOT" --headless --path . res://tools/luneraria/stress_run.tscn +cp docs/luneraria/logs/m3/stress.json "$LOG/test-stress.json" 2>/dev/null +run balance "$GODOT" --headless --path . res://tools/luneraria/balance_run.tscn -- --compositions=20 --limit=60 --out=res://docs/luneraria/logs/g2/balance.json +run legacy-runner "$GODOT" --headless --path . res://tests/test_runner.tscn +run legacy-smoke "$GODOT" --headless --path . -- --smoke-test +# Milestone evidence stays as committed (the G2 copies are above). +git checkout -- docs/luneraria/logs/m1-acceptance.json docs/luneraria/logs/m2-acceptance.json docs/luneraria/logs/m4 docs/luneraria/logs/m3/stress.json \ + docs/luneraria/logs/g1/verify-g1.json docs/luneraria/screenshots/*.png 2>/dev/null +[ $FAILED -eq 0 ] && echo "G2_TESTS_OK" || echo "G2_TESTS_WITH_FAILURES" +exit $FAILED diff --git a/tools/luneraria/verify_g2.gd b/tools/luneraria/verify_g2.gd new file mode 100644 index 0000000..9fd8990 --- /dev/null +++ b/tools/luneraria/verify_g2.gd @@ -0,0 +1,615 @@ +extends Node +## G2 acceptance: Ancients, towers, the Mondschlund grotto and the Schlundkrone in the real game. +## Part A assets and Blender pipeline (manifest, files, hashes, sources, clips, materials) +## Part B views, team identity, lights and particle budget in a running LuneGame +## Part C building states from the authoritative HP (towers, Ancients) and grotto states +## Part D tower attacks: lock marker and bolts follow the rules' targets, one bolt per hit +## Part E rules through the presentation: tower order, base towers gate the Ancient, +## ruins do not block the march, the fall of an Ancient ends the match at once +## Part F boss: grotto reachable, fight, roar, claim, dispatch on all three lanes +## Part G presentation never feeds back: the same match with and without views ends equal +## Part H complete bot matches (headless rules and one with the full game on screen) +## Part I occlusion fade and the scale hierarchy +## Run (window needed): godot --path . --resolution 1920x1080 res://tools/luneraria/verify_g2.tscn +const OUT := "res://docs/luneraria/logs/g2/verify-g2.json" +const Game = preload("res://scripts/luneraria/lune_game.gd") +const Materials = preload("res://scripts/luneraria/view/lune_materials.gd") +const TowerView = preload("res://scripts/luneraria/view/lune_tower_view.gd") +const AncientView = preload("res://scripts/luneraria/view/lune_ancient_view.gd") +const GrottoView = preload("res://scripts/luneraria/view/lune_grotto_view.gd") +const BOSS_CLIPS: Array[String] = ["idle", "walk", "attack_a", "attack_b", "hit", "ability", "death", "roar", "victory", "start", "stop"] +const BUNDS: Array = ["wurzelbund", "wurzelbund", "glutpakt", "glutpakt", "wurzelbund", "glutpakt"] + +var checks: int = 0 +var failures: Array[String] = [] +var facts: Dictionary = {} +var parts: Dictionary = {} +var _part: String = "" +var game: Node3D + +func check(ok: bool, label: String) -> void: + checks += 1 + parts[_part] = int(parts.get(_part, 0)) + 1 + if not ok: + failures.append("[%s] %s" % [_part, label]) + printerr("G2_FAIL: [", _part, "] ", label) + +func part(name: String) -> void: + _part = name + print("G2_PART ", name) + +func _ready() -> void: + call_deferred("run") + +func run() -> void: + Soundscape.enabled = false + var started: int = Time.get_ticks_msec() + part("A assets") + _assets() + part("B views") + await _views() + part("C states") + await _states() + part("D tower attacks") + await _tower_attacks() + part("E rules") + await _rules() + part("F boss") + await _boss() + part("G no feedback") + await _no_feedback() + part("H bot matches") + await _bot_matches() + part("I occlusion and scale") + await _occlusion_and_scale() + await _free_game() + facts.seconds = (Time.get_ticks_msec() - started) / 1000.0 + facts.checks_per_part = parts + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUT.get_base_dir())) + var file := FileAccess.open(OUT, FileAccess.WRITE) + file.store_string(JSON.stringify({"checks": checks, "failures": failures, "facts": facts}, "\t")) + file.close() + print("G2_VERIFY_%s: %d checks; %d failures" % ["PASS" if failures.is_empty() else "FAIL", checks, failures.size()]) + get_tree().quit(0 if failures.is_empty() else 1) + +# ---------------------------------------------------------------- helpers +func frames(count: int = 1) -> void: + for i: int in count: + await get_tree().process_frame + +func _free_game() -> void: + if game != null and is_instance_valid(game): + game.queue_free() + await frames(2) + game = null + +func fresh(seed: int = 4, slot: int = 1) -> LuneMatch: + await _free_game() + game = Game.new() + add_child(game) + game.start({"seed": seed, "human_slot": slot, "bunds": BUNDS}) + await frames(2) + return game.match_state + +## Steps the combat quickly (more fixed steps per frame, rules unchanged) until done() or limit. +func advance(state: LuneMatch, done: Callable, limit: float, per_frame: int = 20) -> void: + game.paused = true + while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < limit and not done.call(): + for i: int in range(per_frame): + if state.phase != LuneMatch.Phase.COMBAT or done.call(): + break + state.step_combat() + await frames() + +func armies(state: LuneMatch, slots: Array, round_number: int) -> void: + state.round_number = round_number + for slot: int in range(6): + state.players[slot].draft.roster.clear() + state.players[slot].draft.gold = 100000 if slots.has(slot) else 0 + if slots.has(slot): + LuneBot.plan(state, slot, "ausgewogen") + for entry: Dictionary in state.players[slot].draft.roster: + if entry.lane == LuneMatch.GROTTO: + entry.lane = state.players[slot].lane + var hero: Dictionary = state.players[slot].draft.hero + if not hero.is_empty() and hero.lane == LuneMatch.GROTTO: + hero.lane = state.players[slot].lane + +func start_combat(state: LuneMatch) -> void: + for slot: int in range(6): + state.players[slot].ready = true + state.begin_combat() + await frames(2) + +func view_of(unit: LuneUnit) -> Node3D: + return game._views.get(unit.id) if unit != null else null + +func sha256_of(path: String) -> String: + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + context.update(FileAccess.get_file_as_bytes(path)) + return context.finish().hex_encode() + +# ---------------------------------------------------------------- A: assets +func _assets() -> void: + var manifest: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://assets/luneraria/g2/g2_manifest.json")) + check(manifest is Dictionary, "the G2 manifest parses") + var entries: Dictionary = {} + for entry: Dictionary in (manifest as Dictionary).get("assets", []): + entries[str(entry.id)] = entry + facts.assets = entries.keys() + for id: String in ["ancient", "grenzwacht", "bastion", "kernwacht", "bossgrotte", "schlundkrone"]: + check(entries.has(id), "manifest lists " + id) + if not entries.has(id): + continue + var entry: Dictionary = entries[id] + var glb: String = str(entry.resource_path) + check(ResourceLoader.exists(glb) and load(glb) is PackedScene, id + " GLB imports as a scene") + check(sha256_of(glb) == str(entry.sha256), id + " GLB matches the manifest hash (reproducible export)") + check(FileAccess.file_exists("res://" + str(entry.source)), id + " keeps its .blend source") + check(FileAccess.file_exists("res://" + str(entry.generator)), id + " keeps its generator script") + check(int(entry.triangles) > 1000 and int(entry.triangles) < 60000, "%s has a sane triangle budget (%d)" % [id, int(entry.triangles)]) + var low: Array = entry.bounds_godot.min + if id != "bossgrotte": + check(absf(float(low[1])) < 1.05, "%s pivot sits at the ground (min y %.2f)" % [id, float(low[1])]) + for key: String in entry.get("materials", []): + if key.begins_with("LN_"): + check(Materials.get_material(key) != null, "%s material %s has a runtime material" % [id, key]) + # The boss: skeletal, all clips, contact timing, stride. + var boss: Dictionary = entries.get("schlundkrone", {}) + check(bool(boss.get("skeletal", false)), "Schlundkrone is a skeletal model") + var clips: Array = boss.get("clips", []) + for clip: String in BOSS_CLIPS: + check(clips.has(clip), "Schlundkrone has the %s clip" % clip) + check(float(boss.get("contact_seconds", 0.0)) == .9, "boss attacks connect at 0.90 s like every SkeletalFigure") + var figure: Node3D = AssetLibrary.instantiate_fighter("schlundkrone") + check(figure != null and figure.has_method("update_combat"), "the boss instantiates as SkeletalFigure") + if figure != null: + add_child(figure) + for clip: String in BOSS_CLIPS: + check(figure.clips.has(clip), "imported boss has the %s animation" % clip) + check(figure.clip_length("attack_a") >= 1.25 and figure.clip_length("attack_b") >= 1.25, "attack clips cover contact and recovery") + check(is_equal_approx(figure.stride, 2.0), "boss walk cadence uses its own stride (%.2f m)" % figure.stride) + check(figure.skeleton != null and figure.skeleton.find_bone("Head") >= 0 and figure.skeleton.find_bone("Forearm.L") >= 0 \ + and figure.skeleton.find_bone("Spine") >= 0, "boss skeleton has the overlay bones") + facts.boss_bones = figure.skeleton.get_bone_count() if figure.skeleton != null else 0 + figure.queue_free() + # The grotto asset keeps every lane corridor clear (checked by its generator, recorded here). + var grotto: Dictionary = entries.get("bossgrotte", {}) + check(float(grotto.get("lane_clearance_min", 0.0)) >= .5, "grotto generator enforces a lane clearance") + +# ---------------------------------------------------------------- B: views +func _views() -> void: + var state: LuneMatch = await fresh() + var world: Node3D = game.world + var tiers: Dictionary = {} + for view: Node3D in world.tower_views.values(): + tiers[view.tier] = int(tiers.get(view.tier, 0)) + 1 + facts.tower_views = tiers + check(world.tower_views.size() == 16 and int(tiers.get("outer", 0)) == 6 and int(tiers.get("inner", 0)) == 6 and int(tiers.get("base", 0)) == 4, + "16 towers: 6 Grenzwacht, 6 Bastion, 4 Kernwacht") + check(world.ancient_views.size() == 2, "two Ancients (Mondherz)") + check(world.grotto_view != null and world.grotto_view.boss_figure != null, "the grotto view stands with its resting boss") + for definition: Dictionary in state.map.buildings: + var view: Node3D = world.building_nodes.get(definition.id) + var at: Vector2 = definition.position + check(view != null and Vector2(view.global_position.x, view.global_position.z).distance_to(at) < .001, + "%s view stands exactly at its rules position" % definition.id) + check(is_equal_approx(state.balance.building_radius, 1.3), "the rules' building radius is unchanged (1.3 m)") + # Team identity beyond colour: rune family, banner silhouette and metal differ per team. + var blue: Node3D = world.tower_views["blau_mid_outer"] + var red: Node3D = world.tower_views["rot_mid_outer"] + var blue_instance: GeometryInstance3D = blue._instances[0] + var red_instance: GeometryInstance3D = red._instances[0] + check(float(blue_instance.get_instance_shader_parameter("pattern")) != float(red_instance.get_instance_shader_parameter("pattern")), + "teams carry different rune families") + check(float(blue_instance.get_instance_shader_parameter("shape")) != float(red_instance.get_instance_shader_parameter("shape")), + "teams carry different banner silhouettes") + check(Materials.team_color(0, "metal") != Materials.team_color(1, "metal"), "teams carry different metals") + # Light and particle budget of the strategic objects. + var lights: int = 0 + var shadow_lights: int = 0 + var particles: int = 0 + var particle_max: int = 0 + for node: Node in world.find_children("*", "", true, false): + if node is OmniLight3D or node is SpotLight3D: + lights += 1 + if (node as Light3D).shadow_enabled: + shadow_lights += 1 + elif node is GPUParticles3D: + particles += 1 + particle_max = maxi(particle_max, (node as GPUParticles3D).amount) + facts.local_lights = lights + facts.particle_systems = particles + check(lights <= 22, "local lights stay few (%d: one per building, two in the grotto)" % lights) + check(shadow_lights == 0, "no local light casts shadows") + check(particle_max <= 100, "every particle system is bounded (largest %d)" % particle_max) + +# ---------------------------------------------------------------- C: states +func _states() -> void: + var state: LuneMatch = await fresh() + var world: Node3D = game.world + var tower: Node3D = world.tower_views["rot_mid_outer"] + var ancient: Node3D = world.ancient_views["blau_ancient"] + var cases: Array = [[1.0, TowerView.State.INTACT, AncientView.State.STABLE], [.61, TowerView.State.INTACT, AncientView.State.STABLE], + [.5, TowerView.State.LIGHT, AncientView.State.UNSTABLE], [.31, TowerView.State.LIGHT, AncientView.State.UNSTABLE], + [.2, TowerView.State.HEAVY, AncientView.State.CRITICAL], [.01, TowerView.State.HEAVY, AncientView.State.CRITICAL]] + for case: Array in cases: + state.building_hp["rot_mid_outer"] = float(state.map.building_def("rot_mid_outer").max_hp) * float(case[0]) + state.building_hp["blau_ancient"] = float(state.map.building_def("blau_ancient").max_hp) * float(case[0]) + await frames(2) + check(tower.state == case[1], "tower at %d %% HP shows state %d (got %d)" % [int(float(case[0]) * 100), case[1], tower.state]) + check(ancient.state == case[2], "Ancient at %d %% HP shows state %d (got %d)" % [int(float(case[0]) * 100), case[2], ancient.state]) + check(tower._parts["Upper_Damaged"].visible and not tower._parts["Upper"].visible, "heavy damage swaps in the broken upper part") + check(ancient._leak.emitting and ancient._smoke.emitting and ancient._ring_slipped, "a critical Ancient leaks energy, smokes and its ring slips") + # Destruction: a short sequence, then a lasting ruin; the view is never removed. + state.building_hp["rot_mid_outer"] = 0.0 + state.building_hp["blau_ancient"] = 0.0 + await frames(2) + check(tower.state == TowerView.State.DESTROYING, "a tower at 0 HP plays its destruction") + check(ancient.state == AncientView.State.COLLAPSING, "an Ancient at 0 HP plays its collapse") + await get_tree().create_timer(3.4).timeout + check(tower.state == TowerView.State.RUIN and tower._parts["Ruin"].visible and not tower._parts["Lower"].visible, "the tower remains as a ruin") + check(ancient.state == AncientView.State.FALLEN and ancient._parts["Ruin_Crystal"].visible, "the Ancient remains as a fallen ruin") + check(is_instance_valid(tower) and tower.is_inside_tree() and is_instance_valid(ancient), "destroyed buildings do not vanish") + check(not tower.light.visible and not ancient.light.visible, "ruins have no light") + # Grotto states from the public boss state. + var grotto: Node3D = world.grotto_view + var expected: Array = [[1, 1, {}, GrottoView.State.AVAILABLE], [7, 10, {"team": 1, "won_round": 6, "deploy_round": 7, "chooser_slot": 3, "lane": "top"}, GrottoView.State.DEPLOYED], + [8, 10, {}, GrottoView.State.COOLDOWN], [9, 10, {}, GrottoView.State.SOON], [10, 10, {}, GrottoView.State.AVAILABLE]] + for case: Array in expected: + state.round_number = int(case[0]) + state.boss.next_available_round = int(case[1]) + state.boss.claim = case[2] + await frames(3) + check(grotto.state == case[3], "grotto in round %d (next %d) is '%s' (got '%s')" % [case[0], case[1], GrottoView.STATE_NAMES[case[3]], grotto.state_name()]) + check(grotto.boss_visible == (case[3] == GrottoView.State.AVAILABLE), "resting boss shows only while the grotto is available (%s)" % grotto.state_name()) + check(grotto.claim_team == -1, "no claim colour without a claim") + state.boss.claim = {"team": 0, "won_round": 6, "deploy_round": 7, "chooser_slot": 0, "lane": "mid"} + state.round_number = 7 + await frames(3) + check(grotto.claim_team == 0, "while deployed the grotto runes carry the claiming team") + +# ---------------------------------------------------------------- D: tower attacks +func _tower_attacks() -> void: + var state: LuneMatch = await fresh() + armies(state, [0, 1, 2], 13) + await start_combat(state) + var fx: Node3D = game.world.tower_fx + var tower: LuneUnit = state.combat.buildings["rot_mid_outer"] + await advance(state, func() -> bool: return tower.target != null and tower.in_range, 90.0) + check(tower.target != null, "Rot's outer Mid tower acquires a target") + var hits_before: Dictionary = {} + for id: String in state.combat.buildings: + hits_before[id] = (state.combat.buildings[id] as LuneUnit).attack_serial + var launched_before: int = fx.launched + var marker_ok: bool = true + var marker_seen: int = 0 + var bolt_ok: bool = true + var bolts_seen: int = 0 + var rule_ok: bool = true + game.paused = false + var end_time: float = state.combat.elapsed + 12.0 + while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < end_time: + await frames() + for id: String in state.combat.buildings: + var unit: LuneUnit = state.combat.buildings[id] + if unit.kind != "tower" or not unit.alive or unit.target == null or not unit.in_range: + continue + # Rules sanity: a tower only shoots hostile, living figures within its range. + rule_ok = rule_ok and unit.target.team != unit.team and unit.position.distance_to(unit.target.position) <= unit.attack_range() + .05 + var marker: MeshInstance3D = fx._markers.get(id) + var target_view: Node3D = view_of(unit.target) + if marker != null and marker.visible and target_view != null: + marker_seen += 1 + var d: float = Vector2(marker.global_position.x, marker.global_position.z).distance_to(Vector2(target_view.global_position.x, target_view.global_position.z)) + marker_ok = marker_ok and d < .6 + for bolt: Object in fx._bolts: + if bolt.active and bolt.target != null and is_instance_valid(bolt.target): + bolts_seen += 1 + bolt_ok = bolt_ok and bolt.target.get("unit") != null + game.paused = true + var hits: int = 0 + for id: String in state.combat.buildings: + var unit: LuneUnit = state.combat.buildings[id] + if unit.kind == "tower": + hits += unit.attack_serial - int(hits_before[id]) + var launched: int = fx.launched - launched_before + facts.tower_hits_12s = hits + facts.tower_bolts_12s = launched + check(rule_ok, "towers only target hostile figures in range") + check(marker_seen > 0 and marker_ok, "the lock marker sits under the rules' target (%d samples)" % marker_seen) + check(bolts_seen > 0 and bolt_ok, "bolts fly at unit views (%d samples)" % bolts_seen) + check(hits > 0 and launched <= hits + 16 and launched >= int(hits * .7), "about one bolt per tower hit (%d hits, %d bolts)" % [hits, launched]) + check(fx.landed <= fx.launched, "no bolt lands twice") + +# ---------------------------------------------------------------- E: rules through the presentation +func _rules() -> void: + # Tower order and base gate: a Rot army against a Blau lane, fast forward, check the gates. + var state: LuneMatch = await fresh() + for id: String in ["blau_mid_outer"]: + state.building_hp[id] = 0.0 + armies(state, [3, 4, 5], 16) + await start_combat(state) + # Lambdas capture locals by value, so results go into a dictionary. + var flags := {"order_ok": true, "core_safe": true, "gated_steps": 0} + var core_start: float = (state.combat.buildings["blau_ancient"] as LuneUnit).hp + await advance(state, func() -> bool: + var gates: Dictionary = state.combat.alive_map() + for id: String in state.combat.buildings: + var unit: LuneUnit = state.combat.buildings[id] + if unit.alive and not LuneMap.is_attackable(state.map.building_def(id), gates): + flags.gated_steps = int(flags.gated_steps) + 1 + # A gated building must not lose health while its gate is closed. + if unit.hp < float(state.building_hp.get(id, unit.max_hp)) - .001: + flags.order_ok = false + var core: LuneUnit = state.combat.buildings["blau_ancient"] + if bool(gates.get("blau_base_1", false)) or bool(gates.get("blau_base_2", false)): + flags.core_safe = bool(flags.core_safe) and core.hp >= core_start - .001 + return false, 60.0) + check(bool(flags.order_ok) and int(flags.gated_steps) > 0, "no gated tower takes damage before its gate opens (outer → inner → base)") + check(bool(flags.core_safe), "the Ancient takes no damage while a base tower stands") + # Ruins do not block: with Rot's outer Mid tower destroyed, Blau's Mid army marches past it. + state = await fresh() + state.building_hp["rot_mid_outer"] = 0.0 + armies(state, [0, 1, 2], 10) + await start_combat(state) + var ruin_progress: float = state.map.lanes["mid"].length - float(state.map.building_def("rot_mid_outer").progress) + var march := {"passed": false} + await advance(state, func() -> bool: + for unit: LuneUnit in state.combat.units: + if unit.alive and unit.team == 0 and unit.lane == "mid" and unit.is_mobile() and unit.progress > ruin_progress + 2.0: + march.passed = true + return bool(march.passed), 120.0) + check(bool(march.passed), "figures march past the ruin of a destroyed tower") + await get_tree().create_timer(TowerView.DESTROY_SECONDS).timeout + check(game.world.tower_views["rot_mid_outer"].state == TowerView.State.RUIN, "the destroyed tower is shown as a ruin, not removed") + # The fall of an Ancient ends the match at once; the collapse only starts then. + state = await fresh() + for id: String in ["rot_mid_outer", "rot_mid_inner", "rot_base_1", "rot_base_2"]: + state.building_hp[id] = 0.0 + state.building_hp["rot_ancient"] = 60.0 + armies(state, [0, 1, 2], 16) + await start_combat(state) + var ancient: Node3D = game.world.ancient_views["rot_ancient"] + var collapse_before_end: bool = false + game.paused = true + while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < 150.0: + state.step_combat() + if state.phase == LuneMatch.Phase.COMBAT and ancient.state == AncientView.State.COLLAPSING: + collapse_before_end = true + if state.combat.elapsed > 0.0 and int(state.combat.steps) % 20 == 0: + await frames() + check(state.phase == LuneMatch.Phase.OVER and state.winner == 0, "destroying Rot's Ancient ends the match for Blau") + check(not collapse_before_end, "the collapse never starts before the rules decided") + await frames(2) + check(ancient.state == AncientView.State.COLLAPSING, "the collapse plays after the victory is already decided") + check(state.phase == LuneMatch.Phase.OVER, "the match stays over while the collapse plays") + +# ---------------------------------------------------------------- F: boss +func _boss() -> void: + var state: LuneMatch = await fresh() + var world: Node3D = game.world + var map: LuneMap = state.map + # Reachable: entries and boss point on the arena floor, the path between them free of columns. + for team: int in range(2): + var entry: Vector2 = map.grotto_entry(team) + check(entry.distance_to(map.grotto_center()) < LuneTerrain.ARENA_RADIUS - 1.0, "grotto entry %d lies inside the arena" % team) + check(absf(world.height_at(entry) - (LuneTerrain.ARENA_HEIGHT + GrottoView.FLOOR_LIFT)) < .02, "grotto entry %d stands on the floor" % team) + var clear: bool = true + for k: int in range(11): + var p: Vector2 = entry.lerp(map.grotto_boss_point(), k / 10.0) - map.grotto_center() + clear = clear and p.length() < 6.3 + check(clear, "the way from entry %d to the boss stays inside the column ring" % team) + check(world.height_at(map.grotto_boss_point()) > LuneTerrain.ARENA_HEIGHT + .1, "the boss stands on its dais") + # Fight: only Blau sends an expedition, so the boss wakes at once and roars. + state.round_number = 6 + for slot: int in range(6): + state.players[slot].draft.roster.clear() + state.players[slot].draft.gold = 0 + state.players[1].draft.gold = 9000 + LuneBot.plan(state, 1, "ausgewogen") + for entry: Dictionary in state.players[1].draft.roster: + entry.lane = LuneMatch.GROTTO + if not (state.players[1].draft.hero as Dictionary).is_empty(): + state.players[1].draft.hero.lane = LuneMatch.GROTTO + await start_combat(state) + var combat: LuneCombat = state.combat + check(combat.grotto_boss != null, "an expedition wakes the grotto boss") + var boss_view: Node3D = view_of(combat.grotto_boss) + check(boss_view != null and boss_view.model != null and boss_view.model.has_method("update_combat"), "the grotto boss is shown as the Schlundkrone") + check(world.grotto_view.state == GrottoView.State.FIGHT and not world.grotto_view.boss_visible, "the grotto is in its fight state; the resting figure gives way") + var seen := {"awake": false, "roar": false} + await advance(state, func() -> bool: + if not combat.grotto_boss.dormant: + seen.awake = true + if boss_view.model.current_clip == "roar": + seen.roar = true + return combat.grotto_resolved, 150.0, 4) + check(bool(seen.awake) and bool(seen.roar), "the boss wakes and roars") + facts.boss_outcome = combat.grotto_outcome + check(combat.grotto_outcome == "success", "the expedition defeats the boss") + check(boss_view.model.dead, "the boss plays its death") + await frames(2) + check(world.grotto_view.state == GrottoView.State.COOLDOWN, "the grotto falls into its exhausted state") + await advance(state, func() -> bool: return false, 200.0) + check(state.phase == LuneMatch.Phase.PLANNING and not (state.boss.claim as Dictionary).is_empty() and int(state.boss.claim.team) == 0, + "Blau holds the claim after the round") + await frames(3) + check(world.grotto_view.state == GrottoView.State.DEPLOYED and world.grotto_view.claim_team == 0, "the grotto shows the deployed boss for Blau") + # Dispatch on every lane: the boss marches, meets a tower, is shot and stands on the ground. + var lanes_ok: Dictionary = {} + for lane: String in LuneMap.LANES: + state = await fresh() + armies(state, [0, 1, 2, 3, 4, 5], 7) + state.boss.claim = {"team": 0, "won_round": 6, "deploy_round": 7, "chooser_slot": 1, "lane": lane} + state.boss.next_available_round = 10 + await start_combat(state) + var boss: LuneUnit = null + for unit: LuneUnit in state.combat.units: + if unit.kind == "boss" and unit.zone == "lane": + boss = unit + check(boss != null and boss.lane == lane and boss.team == 0, "the claimed boss spawns on %s for Blau" % lane) + if boss == null: + continue + var view: Node3D = view_of(boss) + check(view != null and view.model.has_method("update_combat"), "the %s boss is the Schlundkrone" % lane) + var tint: Color = view._boss_surfaces[0].get_instance_shader_parameter("tint") if not view._boss_surfaces.is_empty() else Color.BLACK + check(tint.is_equal_approx(Materials.team_color(0, "crystal")), "the %s boss glows in Blau's colour" % lane) + var start_progress: float = boss.progress + var probe := {"shot_at": false, "grounded": true, "frames": 0} + await advance(state, func() -> bool: + for id: String in state.combat.buildings: + var tower: LuneUnit = state.combat.buildings[id] + if tower.kind == "tower" and tower.target == boss: + probe.shot_at = true + if view != null and boss.alive and view.is_inside_tree(): + probe.frames = int(probe.frames) + 1 + var expected: float = game.world.height_at(Vector2(view.position.x, view.position.z)) + .03 + probe.grounded = bool(probe.grounded) and absf(view.position.y - expected) < .06 + return bool(probe.shot_at) and boss.progress > start_progress + 8.0 or not boss.alive, 120.0, 10) + check(boss.progress > start_progress + 3.0 or not boss.alive, "the %s boss advances along its lane (%.1f m)" % [lane, boss.progress - start_progress]) + check(bool(probe.shot_at), "towers on %s target the boss" % lane) + check(bool(probe.grounded), "the %s boss stands on the ground, bridge or platform" % lane) + lanes_ok[lane] = bool(probe.shot_at) and bool(probe.grounded) + facts.boss_lanes = lanes_ok + +# ---------------------------------------------------------------- G: presentation never feeds back +func _signature(state: LuneMatch) -> String: + var parts_list: Array = [state.round_number, state.phase, state.winner] + for id: String in state.building_hp: + parts_list.append("%s=%.4f" % [id, float(state.building_hp[id])]) + for player: Dictionary in state.players: + parts_list.append("%d:%d:%d" % [int(player.slot), int(player.gold), int(player.earned)]) + parts_list.append(JSON.stringify(state.boss)) + return "|".join(parts_list.map(func(v: Variant) -> String: return str(v))) + +func _no_feedback() -> void: + # Same seed, six bots: pure rules versus the full game with views, bolts and states. + var pure := LuneMatch.new() + pure.setup({"seed": 11, "human_slot": -1, "bunds": BUNDS}) + for round_index: int in range(4): + for slot: int in range(6): + LuneBot.plan(pure, slot) + pure.begin_combat() + pure.finish_combat() + await _free_game() + game = Game.new() + add_child(game) + game.start({"seed": 11, "human_slot": -1, "bunds": BUNDS}) + var shown: LuneMatch = game.match_state + await frames(2) + var launched: int = 0 + while shown.round_number <= 4 and shown.phase != LuneMatch.Phase.OVER: + if shown.phase == LuneMatch.Phase.COMBAT: + for i: int in range(24): + if shown.phase != LuneMatch.Phase.COMBAT: + break + shown.step_combat() + await frames() + if shown.round_number > 4 and shown.phase == LuneMatch.Phase.PLANNING: + break + launched = game.world.tower_fx.launched + facts.no_feedback_bolts = launched + check(pure.history.size() >= 4 and shown.history.size() >= 4, "both runs play four rounds") + var equal: bool = true + for index: int in range(mini(4, mini(pure.history.size(), shown.history.size()))): + equal = equal and JSON.stringify(pure.history[index]) == JSON.stringify(shown.history[index]) + check(equal, "rounds 1-4 are identical with and without the presentation (gold, buildings, boss)") + check(launched > 0, "the shown run really drew tower bolts (%d)" % launched) + +# ---------------------------------------------------------------- H: bot matches +func _bot_matches() -> void: + var results: Array = [] + for seed: int in [21, 22, 23, 24]: + var state := LuneMatch.new() + state.setup({"seed": seed, "human_slot": -1}) + while state.phase != LuneMatch.Phase.OVER and state.round_number <= 40: + for slot: int in range(6): + LuneBot.plan(state, slot) + state.begin_combat() + state.finish_combat() + results.append({"seed": seed, "rounds": state.round_number, "winner": state.winner}) + check(state.phase == LuneMatch.Phase.OVER and state.winner != LuneMatch.ONGOING, "bot match seed %d ends with a fallen Ancient (round %d)" % [seed, state.round_number]) + facts.bot_matches = results + # One complete match with the full game on screen (bots only, fast clock). + await _free_game() + game = Game.new() + add_child(game) + game.start({"seed": 22, "human_slot": -1}) + var shown: LuneMatch = game.match_state + await frames(2) + var errors_before: int = failures.size() + while shown.phase != LuneMatch.Phase.OVER and shown.round_number <= 40: + if shown.phase == LuneMatch.Phase.COMBAT: + for i: int in range(40): + if shown.phase != LuneMatch.Phase.COMBAT: + break + shown.step_combat() + await frames() + var fallen: int = 0 + for view: Node3D in game.world.tower_views.values(): + if view.state == TowerView.State.RUIN or view.state == TowerView.State.DESTROYING: + fallen += 1 + facts.shown_match = {"rounds": shown.round_number, "winner": shown.winner, "fallen_towers_shown": fallen} + check(shown.phase == LuneMatch.Phase.OVER and shown.winner == int(results[1].winner) and shown.round_number == int(results[1].rounds), + "the shown match ends exactly like the headless one (round %d, winner %d)" % [shown.round_number, shown.winner]) + var lost: int = 0 + for id: String in shown.building_hp: + if float(shown.building_hp[id]) <= 0.0 and not id.ends_with("ancient"): + lost += 1 + check(fallen == lost, "every destroyed tower is shown as destroyed (%d/%d)" % [fallen, lost]) + check(failures.size() == errors_before, "no check failed during the shown match") + +# ---------------------------------------------------------------- I: occlusion and scale +func _occlusion_and_scale() -> void: + var state: LuneMatch = await fresh() + var world: Node3D = game.world + var camera: Camera3D = game.camera + camera.reduced_motion = true + var ancient: Node3D = world.ancient_views["blau_ancient"] + # The northern base tower stands behind the Ancient from the default camera. + var behind: Vector2 = state.map.building_def("blau_base_1").position + camera.look_at_point(Vector3(behind.x, 0, behind.y), 20.0, true) + await frames(8) + check(ancient.fade_target > 0.0, "the Ancient thins out while the camera looks at what lies behind it") + await get_tree().create_timer(.5).timeout + check(ancient.current_fade() > .3 and ancient.current_fade() <= .61, "the fade is partial, never invisible (%.2f)" % ancient.current_fade()) + camera.look_at_point(Vector3(-30.0, 0, 36.0), 20.0, true) + await frames(8) + check(ancient.fade_target == 0.0, "the Ancient is solid again when nothing lies behind it") + # The grotto gate gives way to the Top lane behind it. + # North of the gate (Top lane on the highland): from the default camera the gate stands in between. + var gate: Vector2 = LuneMap.GROTTO - LuneMap.AXIS * 8.95 + var top_behind := Vector2(gate.x, gate.y - 5.5) + camera.look_at_point(Vector3(top_behind.x, 0, top_behind.y), 20.0, true) + await frames(8) + check(world.grotto_view.fade_target > 0.0, "the grotto gate thins out for the Top lane behind it") + camera.look_at_point(Vector3(LuneMap.GROTTO.x + 6.0, 0, LuneMap.GROTTO.y + 6.0), 20.0, true) + await frames(8) + check(world.grotto_view.fade_target == 0.0, "the grotto is solid when the camera looks at its arena") + # Scale hierarchy at view scale: troop < hero < champion < boss < Grenzwacht < Bastion < Kernwacht < Ancient. + var manifest: Dictionary = {} + for entry: Dictionary in JSON.parse_string(FileAccess.get_file_as_string("res://assets/luneraria/g2/g2_manifest.json")).assets: + manifest[str(entry.id)] = entry + var hero_def: Dictionary = LuneContent.hero_definition({"hero_id": "held_ringborke", "xp": 0, "items": [], "talents": []}) + var champion: Dictionary = LuneContent.champion_definition("ch_belagerung", 7, state.balance) + var boss_def: Dictionary = LuneContent.boss_definition(7, state.balance, true) + var heights: Dictionary = { + "troop": AssetLibrary.model_height("w01"), + "hero": AssetLibrary.model_height(str(hero_def.id)) * float(hero_def.view_scale), + "champion": AssetLibrary.model_height(str(champion.id)) * float(champion.view_scale), + "boss": float(manifest.schlundkrone.bounds_godot.max[1]) * float(boss_def.view_scale), + "grenzwacht": float(manifest.grenzwacht.bounds_godot.max[1]), + "bastion": float(manifest.bastion.bounds_godot.max[1]), + "kernwacht": float(manifest.kernwacht.bounds_godot.max[1]), + "ancient": float(manifest.ancient.bounds_godot.max[1])} + facts.heights_m = heights + # Units among themselves may differ (a hero is bigger than a siege champion); above them + # the order is strict: boss < Tower 1 < Tower 2 < base tower < Ancient. + var pairs: Array = [["troop", "hero"], ["troop", "champion"], ["hero", "boss"], ["champion", "boss"], ["boss", "grenzwacht"], + ["grenzwacht", "bastion"], ["bastion", "kernwacht"], ["kernwacht", "ancient"]] + for pair: Array in pairs: + check(float(heights[pair[0]]) < float(heights[pair[1]]), "%s (%.2f m) is smaller than %s (%.2f m)" % [pair[0], + float(heights[pair[0]]), pair[1], float(heights[pair[1]])]) + check(float(heights.boss) > float(heights.champion) * 1.4, "the boss clearly towers over every unit") diff --git a/tools/luneraria/verify_g2.gd.uid b/tools/luneraria/verify_g2.gd.uid new file mode 100644 index 0000000..a21361b --- /dev/null +++ b/tools/luneraria/verify_g2.gd.uid @@ -0,0 +1 @@ +uid://bnmg17mm75qgj diff --git a/tools/luneraria/verify_g2.tscn b/tools/luneraria/verify_g2.tscn new file mode 100644 index 0000000..ae3a52c --- /dev/null +++ b/tools/luneraria/verify_g2.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://tools/luneraria/verify_g2.gd" id="1"] + +[node name="VerifyG2" type="Node"] +script = ExtResource("1")