M1: spielbarer Drei-Lane-Kern mit Karte, HUD, Menü und Abnahme

Hauptszene scenes/luneraria.tscn, Saumwacht bleibt über --legacy und die
alten QA-Schalter erreichbar. Karte aus Bestandsassets plus neuem Turm,
HUD mit Planung, Lanes und Minimap, Menü umbenannt, Einstellungen werden
aus dem alten Benutzerordner übernommen. Simulation 6,6x schneller
(Raster, Caches, faire Staffelung) und exakt spiegelsymmetrisch.
Regeln 91/91, echte Anwendung 38/38, Legacy unverändert grün.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Martin Stickelbroeck 2026-09-27 21:37:23 +02:00
parent b565546c37
commit c27a0097c3
80 changed files with 5216 additions and 51 deletions

View file

@ -0,0 +1,158 @@
"""Boss grotto for Champions of Luneraria: a rock island in the river with a crescent of
cliffs, a cave mouth and violet crystals. Symmetric across the map diagonal, so both
entrances (towards Blau and towards Rot) are equal. Stone uses ENV_* material names.
Run: ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_grotto.py
Local Godot frame: +x/+z are the map axes; the map diagonal (x = z) is the mirror line.
"""
from pathlib import Path
import bpy, bmesh, json, hashlib, struct
from math import sin, cos, pi, sqrt
from mathutils import Vector, noise
P = Path(__file__).resolve().parents[2]
SRC = Path(__file__).resolve().parent
OUT = P / 'assets/luneraria'
OUT.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.preferences.filepaths.save_version = 0
parts = []
def G(x, y, z):
return Vector((x, -z, y))
def material(name, color, rough=.9, metal=0.0, emission=0.0):
m = bpy.data.materials.new(name)
m.diffuse_color = (*color, 1)
m.use_nodes = True
bs = m.node_tree.nodes.get('Principled BSDF')
bs.inputs['Base Color'].default_value = (*color, 1)
bs.inputs['Roughness'].default_value = rough
bs.inputs['Metallic'].default_value = metal
if emission:
bs.inputs['Emission Color'].default_value = (*color, 1)
bs.inputs['Emission Strength'].default_value = emission
return m
M = {
'cliff': material('ENV_cliff', (.133, .153, .138)),
'soil': material('ENV_soil', (.10, .15, .065), .97),
'limestone': material('ENV_limestone', (.283, .246, .178)),
'dark': material('GR_Shadow', (.02, .02, .03), 1.0),
'crystal': material('GR_Crystal', (.62, .38, 1.0), .15, 0, 3.0),
}
def obj(name, vs, fs, kind, smooth=False):
me = bpy.data.meshes.new(name)
me.from_pydata([tuple(v) for v in vs], [], fs)
me.update()
me.materials.append(M[kind])
for p in me.polygons:
p.use_smooth = smooth
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
parts.append(ob)
return ob
def island(name, radius, top, bottom, seed, kind='soil'):
sides, levels = 64, 6
vs, fs = [], []
for li in range(levels):
y = top - (top - bottom) * li / (levels - 1)
for k in range(sides):
a = 2 * pi * k / sides
# Radius depends on |angle - 45 deg| so the island is symmetric across x = z.
m = abs(((a - pi / 4) + pi) % (2 * pi) - pi)
r = radius * (1 + .08 * noise.noise(Vector((cos(m) * 2, li * .5, seed)))) * (1 - .06 * li / levels)
vs.append(G(cos(a) * r, y, sin(a) * r))
for li in range(levels - 1):
for k in range(sides):
a, b = li * sides + k, li * sides + (k + 1) % sides
fs.append((a, a + sides, b + sides, b))
fs += [tuple(range(sides)), tuple(reversed(range((levels - 1) * sides, levels * sides)))]
return obj(name, vs, fs, kind)
def boulder(name, center, size, seed, kind='cliff'):
bm = bmesh.new()
bmesh.ops.create_icosphere(bm, subdivisions=2, radius=1.0)
c = G(*center)
for v in bm.verts:
d = v.co.normalized()
r = 1 + .32 * noise.noise(d * 1.8 + Vector((seed, seed * .5, seed * .3)))
gx, gy, gz = d.x * r * size[0], d.z * r * size[1], -d.y * r * size[2]
if gy < -.25 * size[1]:
gy = -.25 * size[1]
v.co = c + Vector((gx, -gz, gy))
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
me.materials.append(M[kind])
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
parts.append(ob)
return ob
def gem(name, center, height, radius, tilt, kind='crystal'):
cx, cy, cz = center
tip = G(cx + tilt[0], cy + height, cz + tilt[1])
base = [G(cx + cos(2 * pi * k / 6) * radius, cy + height * .25, cz + sin(2 * pi * k / 6) * radius) for k in range(6)]
root = G(cx, cy - .1, cz)
vs = [tip] + base + [root]
fs = [(0, 1 + (k + 1) % 6, 1 + k) for k in range(6)] + [(7, 1 + k, 1 + (k + 1) % 6) for k in range(6)]
return obj(name, vs, fs, kind)
def mirror(p):
return (p[2], p[1], p[0])
island('Island', 7.2, .35, -1.2, 3)
# Crescent of cliffs on the far side from Mid (towards -x-z), open towards both entrances.
ring = []
for k in range(9):
a = pi + pi / 4 + (k - 4) * .30
ring.append((cos(a) * 5.6, 0.0, sin(a) * 5.6, 1.5 + (1.2 if abs(k - 4) <= 1 else .5 * (4 - abs(k - 4)) / 4)))
for i, (x, y, z, h) in enumerate(ring):
boulder('Cliff_%d' % i, (x, y + h * .45, z), (1.6, h, 1.4), 10 + min(i, 8 - i))
# Cave mouth: dark recess under an arch in the middle of the crescent.
a = pi + pi / 4
boulder('Cave_Shadow', (cos(a) * 4.6, .8, sin(a) * 4.6), (1.4, 1.1, .7), 31, 'dark')
boulder('Arch_Top', (cos(a) * 4.8, 3.1, sin(a) * 4.8), (2.2, .8, 1.3), 32)
# Violet crystals, mirrored pairs so neither entrance looks different.
for i, (x, z, h, r) in enumerate([(-2.2, -3.6, 1.8, .32), (-3.9, -1.2, 1.4, .28), (-1.0, -4.3, 1.1, .22), (-3.2, -3.2, 2.3, .4)]):
for p in {(x, 0.3, z), mirror((x, 0.3, z))}:
gem('Crystal_%d_%d' % (i, int(p[0] * 10)), p, h, r, (.12 * (1 if p[0] < p[2] else -1), .08))
# Entrance stones: symmetric pair of limestone steps facing Blau (+x) and Rot (+z)... see manifest.
for p in [(4.6, .18, 1.2), mirror((4.6, .18, 1.2))]:
boulder('Step_%d' % int(p[0] * 10), p, (1.1, .25, .8), 40, 'limestone')
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()
grotto = bpy.context.object
grotto.name = 'BossGrotto'
bm = bmesh.new()
bm.from_mesh(grotto.data)
bmesh.ops.triangulate(bm, faces=list(bm.faces))
bm.to_mesh(grotto.data)
bm.free()
bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'grotto.blend'))
bpy.ops.export_scene.gltf(filepath=str(OUT / 'grotto.glb'), export_format='GLB', use_selection=True,
export_materials='EXPORT', export_yup=True, export_animations=False)
raw = (OUT / 'grotto.glb').read_bytes()
doc = json.loads(raw[20:20 + struct.unpack_from('<I', raw, 12)[0]])
triangles = sum(doc['accessors'][q['indices']]['count'] // 3 for m in doc['meshes'] for q in m['primitives'])
(OUT / 'grotto_manifest.json').write_text(json.dumps({'assets': [{
'id': 'boss_grotto', 'resource_path': 'res://assets/luneraria/grotto.glb', 'source': 'assets-source/luneraria/grotto.blend',
'triangles': triangles, 'sha256': hashlib.sha256(raw).hexdigest(),
'materials': [m['name'] for m in doc['materials']]}]}, indent=2) + '\n')
print('GROTTO_BUILD', triangles, 'triangles', flush=True)

View file

@ -0,0 +1,144 @@
"""Lane tower for Champions of Luneraria: stone plinth, tapered shaft with brass bands,
crenellated platform and a hovering crystal. The crystal material (TW_Crystal) is tinted
per team at runtime; stone surfaces use the Kluft material names (ENV_*).
Run: ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_tower.py
Godot coordinates are written through G(x, y, z) (x right, y up, z towards the viewer).
"""
from pathlib import Path
import bpy, bmesh, json, hashlib, struct
from math import sin, cos, pi
from mathutils import Vector, noise
P = Path(__file__).resolve().parents[2]
SRC = Path(__file__).resolve().parent
OUT = P / 'assets/luneraria'
OUT.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.preferences.filepaths.save_version = 0
parts = []
def G(x, y, z):
return Vector((x, -z, y))
def material(name, color, rough=.9, metal=0.0, emission=0.0):
m = bpy.data.materials.new(name)
m.diffuse_color = (*color, 1)
m.use_nodes = True
bs = m.node_tree.nodes.get('Principled BSDF')
bs.inputs['Base Color'].default_value = (*color, 1)
bs.inputs['Roughness'].default_value = rough
bs.inputs['Metallic'].default_value = metal
if emission:
bs.inputs['Emission Color'].default_value = (*color, 1)
bs.inputs['Emission Strength'].default_value = emission
return m
M = {
'stone': material('ENV_limestone', (.283, .246, .178)),
'dark': material('ENV_cliff', (.133, .153, .138)),
'paving': material('ENV_paving', (.162, .171, .136), .92),
'brass': material('ENV_brass', (.515, .366, .153), .45, .7),
'crystal': material('TW_Crystal', (.55, .8, 1.0), .15, 0, 2.5),
}
def obj(name, vs, fs, kind, smooth=False):
me = bpy.data.meshes.new(name)
me.from_pydata([tuple(v) for v in vs], [], fs)
me.update()
me.materials.append(M[kind])
for p in me.polygons:
p.use_smooth = smooth
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
parts.append(ob)
return ob
def lathe(name, profile, sides, kind, center=(0, 0, 0), rotation=0.0, jitter=0.0, seed=0):
cx, cy, cz = center
vs, fs = [], []
for j, (r, y) in enumerate(profile):
for k in range(sides):
a = 2 * pi * k / sides + rotation
rr = r * (1 + jitter * noise.noise(Vector((cos(a) * 3, y * 2, sin(a) * 3 + seed))))
vs.append(G(cx + cos(a) * rr, cy + y, cz + sin(a) * rr))
n = len(profile)
for j in range(n - 1):
for k in range(sides):
a, b = j * sides + k, j * sides + (k + 1) % sides
fs.append((a, b, b + sides, a + sides))
fs += [tuple(reversed(range(sides))), tuple(range((n - 1) * sides, n * sides))]
return obj(name, vs, fs, kind)
def box(name, center, size, yaw, kind):
cx, cy, cz = center
hx, hy, hz = size[0] / 2, size[1] / 2, size[2] / 2
vs = []
for dy in (-1, 1):
for dz in (-1, 1):
for dx in (-1, 1):
x, z = dx * hx, dz * hz
vs.append(G(cx + x * cos(yaw) - z * sin(yaw), cy + dy * hy, cz + x * sin(yaw) + z * cos(yaw)))
return obj(name, vs, [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)], kind)
# Plinth: two stepped octagons of dark stone with a paving top.
lathe('Plinth', [(1.65, 0.0), (1.65, .28), (1.45, .34), (1.45, .52), (1.3, .58)], 8, 'dark', rotation=pi / 8, jitter=.03, seed=1)
lathe('Plinth_Top', [(1.28, .57), (1.28, .62)], 8, 'paving', rotation=pi / 8)
# Shaft: tapered, slightly irregular limestone blocks.
shaft = [(1.02, .6), (.98, 1.4), (.9, 2.4), (.84, 3.2), (.9, 3.4)]
lathe('Shaft', shaft, 10, 'stone', rotation=pi / 10, jitter=.025, seed=2)
for h in (1.05, 2.25, 3.25):
r = .99 - (h - .6) * .05
lathe('Band_%d' % int(h * 10), [(r + .06, h - .07), (r + .06, h + .07)], 10, 'brass', rotation=pi / 10)
# Platform with crenellations.
lathe('Platform', [(.95, 3.35), (1.25, 3.6), (1.25, 3.85), (1.1, 3.9)], 10, 'stone', rotation=pi / 10, jitter=.02, seed=3)
for k in range(10):
a = 2 * pi * (k + .5) / 10
box('Merlon_%d' % k, (cos(a) * 1.08, 4.08, sin(a) * 1.08), (.42, .42, .26), -a + pi / 2, 'stone')
# Crystal cradle: four brass prongs and a hovering bipyramid crystal.
for k in range(4):
a = pi / 4 + k * pi / 2
pts = [(cos(a) * .55, 3.9, sin(a) * .55), (cos(a) * .42, 4.55, sin(a) * .42), (cos(a) * .22, 5.0, sin(a) * .22)]
for i in range(2):
p0, p1 = Vector(pts[i]), Vector(pts[i + 1])
mid = (p0 + p1) / 2
d = p1 - p0
box('Prong_%d_%d' % (k, i), tuple(mid), (.09, d.length + .05, .09), 0, 'brass')
crystal = [G(0, 5.95, 0)] + [G(cos(2 * pi * k / 6) * .36, 5.05, sin(2 * pi * k / 6) * .36) for k in range(6)] + [G(0, 4.45, 0)]
cf = [(0, 1 + k, 1 + (k + 1) % 6) for k in range(6)] + [(7, 1 + (k + 1) % 6, 1 + k) for k in range(6)]
obj('Crystal', crystal, cf, 'crystal')
# A small door and window slits keep the scale readable from the game camera.
box('Door', (0, .95, 1.0), (.5, .75, .12), 0, 'dark')
for k, h in enumerate((1.7, 2.7)):
box('Slit_%d' % k, (0, h, .93 - (h - 1.7) * .05), (.14, .42, .1), 0, 'dark')
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()
tower = bpy.context.object
tower.name = 'LaneTower'
bm = bmesh.new()
bm.from_mesh(tower.data)
bmesh.ops.triangulate(bm, faces=list(bm.faces))
bm.to_mesh(tower.data)
bm.free()
bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'tower.blend'))
bpy.ops.export_scene.gltf(filepath=str(OUT / 'tower.glb'), export_format='GLB', use_selection=True,
export_materials='EXPORT', export_yup=True, export_animations=False)
raw = (OUT / 'tower.glb').read_bytes()
doc = json.loads(raw[20:20 + struct.unpack_from('<I', raw, 12)[0]])
triangles = sum(doc['accessors'][q['indices']]['count'] // 3 for m in doc['meshes'] for q in m['primitives'])
(P / 'assets/luneraria/tower_manifest.json').write_text(json.dumps({'assets': [{
'id': 'lane_tower', 'resource_path': 'res://assets/luneraria/tower.glb', 'source': 'assets-source/luneraria/tower.blend',
'triangles': triangles, 'height': 5.95, 'sha256': hashlib.sha256(raw).hexdigest(),
'materials': [m['name'] for m in doc['materials']]}]}, indent=2) + '\n')
print('TOWER_BUILD', triangles, 'triangles', flush=True)

Binary file not shown.

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cdyqe8uxp766t"
path="res://.godot/imported/ChatGPT-Bild 27. Sept. 2026, 19_46_52-1.png-2d037606185a25aabd1841674c9a8676.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ChatGPT-Bild 27. Sept. 2026, 19_46_52-1.png"
dest_files=["res://.godot/imported/ChatGPT-Bild 27. Sept. 2026, 19_46_52-1.png-2d037606185a25aabd1841674c9a8676.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cm5ok6wwo5k4c"
path="res://.godot/imported/ChatGPT-Bild 27. Sept. 2026, 19_46_52-2.png-ad15328ef82598f8d29650086961c8e2.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ChatGPT-Bild 27. Sept. 2026, 19_46_52-2.png"
dest_files=["res://.godot/imported/ChatGPT-Bild 27. Sept. 2026, 19_46_52-2.png-ad15328ef82598f8d29650086961c8e2.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bjtulyxjl22dy"
path="res://.godot/imported/ChatGPT-Bild 27. Sept. 2026, 19_46_53-3.png-7e556551f7b3efde4bca6a6f73eb8860.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ChatGPT-Bild 27. Sept. 2026, 19_46_53-3.png"
dest_files=["res://.godot/imported/ChatGPT-Bild 27. Sept. 2026, 19_46_53-3.png-7e556551f7b3efde4bca6a6f73eb8860.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

BIN
assets/luneraria/tower.glb Normal file

Binary file not shown.

View file

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

View file

@ -0,0 +1,19 @@
{
"assets": [
{
"id": "lane_tower",
"resource_path": "res://assets/luneraria/tower.glb",
"source": "assets-source/luneraria/tower.blend",
"triangles": 648,
"height": 5.95,
"sha256": "52de81cafa3712efb78413d28d16fe112330d27af0e66a8c9b31fde7d6dd1442",
"materials": [
"ENV_cliff",
"ENV_paving",
"ENV_limestone",
"ENV_brass",
"TW_Crystal"
]
}
]
}

View file

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="stone" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1f2733"/><stop offset="1" stop-color="#0c1016"/></linearGradient>
<linearGradient id="moon" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fbf1d4"/><stop offset="1" stop-color="#c9a562"/></linearGradient>
<linearGradient id="gem" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d8f0ff"/><stop offset=".55" stop-color="#5aa8ff"/><stop offset="1" stop-color="#27508a"/></linearGradient>
<radialGradient id="halo" cx=".5" cy=".46" r=".5"><stop offset="0" stop-color="#9fd0ff" stop-opacity=".45"/><stop offset="1" stop-color="#9fd0ff" stop-opacity="0"/></radialGradient>
</defs>
<circle cx="256" cy="256" r="232" fill="url(#stone)" stroke="#c99a58" stroke-width="12"/>
<circle cx="256" cy="256" r="204" fill="none" stroke="#6b4f2e" stroke-width="4"/>
<circle cx="256" cy="236" r="160" fill="url(#halo)"/>
<path d="M300 70 A186 186 0 1 0 300 442 A214 214 0 0 1 300 70 Z" fill="url(#moon)" stroke="#fff4d8" stroke-width="4"/>
<path d="M256 150 L300 236 L256 360 L212 236 Z" fill="url(#gem)" stroke="#eaf6ff" stroke-width="5" stroke-linejoin="round"/>
<path d="M212 236 L256 262 L300 236 M256 150 L256 360" fill="none" stroke="#eaf6ff" stroke-width="2.5" opacity=".8"/>
<path d="M256 70 l10 22 l22 10 l-22 10 l-10 22 l-10 -22 l-22 -10 l22 -10 Z" fill="#f6e2b0"/>
<path d="M120 400 Q190 372 256 400 T392 400" fill="none" stroke="#6fd3b4" stroke-width="7" stroke-linecap="round" opacity=".85"/>
<path d="M150 432 L178 404 M362 432 L334 404" stroke="#c99a58" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dq03newjr6neo"
path="res://.godot/imported/luneraria.svg-12b34911b283c50c0fc77d4b9fb43e87.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/emblems/luneraria.svg"
dest_files=["res://.godot/imported/luneraria.svg-12b34911b283c50c0fc77d4b9fb43e87.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -0,0 +1,65 @@
# Installation, Start, Steuerung und Export
## Start aus dem Quellprojekt (Fedora 44)
```bash
cd saumwacht-godot
godot --path . # Champions of Luneraria (Hauptszene scenes/luneraria.tscn)
godot --path . -- --legacy # archivierter Saumwacht-Modus (scenes/main.tscn)
```
Voraussetzung ist Godot 4.7.2. Blender wird nur zum Neuerzeugen von Assets gebraucht, nicht zum Spielen.
## Eine Partie
1. **Neue Partie** → Bund (Glutpakt oder Wurzelbund) und deine Lane (Top, Mid, Bottom) wählen → **Partie beginnen**. Du spielst in Team Blau (Südwest) mit zwei Bot-Verbündeten gegen drei Bots in Team Rot.
2. **Planung:** Truppen kaufen (Knöpfe oder 1–5), mit T/M/B einer Lane zuweisen, mit ↑ aufwerten, mit × zurückgeben. Käufe aus derselben Planung werden voll erstattet, ältere mit 75 %. **Startaufstellung** kauft Front, Nahkampf und Fernkampf. **Bereit** (Leertaste) übernimmt alle Befehle verbindlich.
3. **Kampf:** Er läuft automatisch. Deine Truppen rücken mit der eigenen Monsterwelle über ihre Lane vor. Leertaste oder P pausiert, Tempo 1×/2×/4×.
4. **Abrechnung:** Getötete Monster und Gebäude bringen Gold, dazu kommt das Rundeneinkommen. Deine Truppen kehren geheilt zurück. Türme und Kerne behalten ihren Schaden.
5. Die Partie endet erst, wenn ein Ancient fällt. Es gibt kein Rundenlimit.
## Steuerung
| Eingabe | Wirkung |
| --- | --- |
| 1–5 | Truppe kaufen (Planung) |
| Leertaste | Bereit / im Kampf Pause |
| P | Pause |
| WASD, Pfeiltasten, mittlere Maustaste ziehen | Kamera verschieben |
| Mausrad | Zoom |
| Tab / Lane-Knöpfe links | Kamera auf die nächste Lane |
| O | Kartenübersicht |
| Minimap klicken/ziehen | Kamera springt dorthin |
| F1 | Optionen und Steuerung |
| M / F11 | Ton / Vollbild |
## Prüfungen
```bash
godot --headless --path . res://tests/luneraria/test_lune_core.tscn # Regeln (M1: 91)
godot --path . --resolution 1920x1080 res://tools/luneraria/verify_m1.tscn # echte Bedienung, volle Partie
godot --headless --path . res://tools/luneraria/sim_match.tscn -- --seeds=1,2,3 --limit=80
godot --path . --resolution 1920x1080 res://tools/luneraria/lune_benchmark.tscn
godot --path . --resolution 1920x1080 res://tools/luneraria/lune_review.tscn # Aufnahmen
# Legacy (Saumwacht) unverändert:
godot --headless --path . res://tests/test_runner.tscn
godot --headless --path . -- --smoke-test
godot --headless --path . -- --faction-test
godot --headless --path . -- --expedition-test
```
## Assets neu erzeugen
```bash
ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_tower.py
python3 tools/luneraria/build_asset_matrix.py
godot --headless --editor --import --path .
```
## Export
Die Pakete für Champions of Luneraria kommen mit M4. Bis dahin baut `tools/build_demo.py` weiterhin die Saumwacht-Demo 0.15.0.
## 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.

28
docs/luneraria/BALANCE.md Normal file
View file

@ -0,0 +1,28 @@
# Balancekonfiguration und Auswertung
Alle Werte stehen in `scripts/luneraria/core/lune_balance.gd`. Die aktive Standardinstanz ist `data/luneraria/balance_default.tres`, die derzeit alle Klassenwerte übernimmt.
## Startwerte (M1) und Begründung
| Wert | Stand | Begründung |
| --- | ---: | --- |
| Startgold | 220 | Wie im Saumwacht-Einstieg; reicht für die Startaufstellung (180/185) plus Rest. |
| Grundeinkommen | 70 + 4 je Runde, Obergrenze +80 | Etwa eine Truppe pro Runde; der Zuwachs hält mit den Wellen mit, ohne endlos zu steigen. |
| Versorgungsgrenze | 12 Truppen je Spieler | Begrenzt aktive Einheiten; später investiert man in Aufwertungen statt in Masse. |
| Verkauf | 75 % (in derselben Planung 100 %) | Aus Saumwacht übernommen; die Rücknahme im selben Planungsschritt ist ein Bedienkomfort. |
| Wellen | 3 Monster je Lane und Team, +1 alle 3 Runden, max. 7 | Pro Runde höchstens 42 Monster auf der Karte. |
| Wellenstärke | HP ×(1 + 0,11·r + 0,0015·r²), Schaden ×(1 + 0,07·r + 0,0015·r²) | Stetig und quadratisch leicht zunehmend, ohne Obergrenze; bei r = 10⁶ endlich (Test). |
| Monsterkopfgeld | Katalogwert ×2 ×(1 + 0,04·r) | Monster sind die Hauptgoldquelle; wächst langsamer als die Stärke. |
| Türme | außen 6.000 / innen 7.500 / Basis 9.000 HP, 90 / 120 Schaden, Reichweite 8,6 m | Eine Welle allein kommt nicht durch. Die Reichweite liegt über der größten Einheitenreichweite (6,5 m + Gebäuderadius), sonst wären Türme ungestraft auszuschießen. |
| Ancient | 12.000 HP | Braucht eine voll durchbrochene Basis und mehrere Runden Druck. |
| Gebäudekopfgeld | 150 / 210 / 270 | Belohnt Durchbrüche gleichmäßig im Team. |
| Kampffenster | 180 s | In 91 Bot-Kampfphasen (8 Seeds) dauerte die längste 130 s, im Mittel 68 s; keine erreichte das Fenster. Es greift nur bei festgefahrenen Kämpfen. |
| Marschtempo | Katalogtempo ×1,6 | Eine Lane (~95 m) ist in ~30 s durchquerbar, damit Angriffe die Basis innerhalb eines Kampfes erreichen. |
## Auswertung M1 (reine Bot-Partien, 1 Mensch = Bot)
[sim-8-seeds.json](logs/m1/sim-8-seeds.json): acht Seeds mit gemischten Bünden und fünf Bot-Profilen (ausgewogen, verteidigung, druck, fernkampf, teamhilfe). Die Ergebnisse stehen in [STATUS.md](STATUS.md).
- Alle acht Partien enden regulär durch einen Ancient-Fall, nach 8–15 Wellen (Blau 5, Rot 3). 83 Kampfphasen enden, weil alle beweglichen Einheiten gefallen sind, 8 durch einen Kernfall, keine durch das Zeitfenster.
- **Exakter Spiegel** (sechsmal derselbe Bund, dasselbe deterministische Profil): Die Partie bleibt über 60 Runden Bit für Bit symmetrisch. Sie wird aber nicht entschieden, weil sich gleich starke Armeen und Wellen gegenseitig aufheben. Das ist der erwartbare Grenzfall identischen Spiels. Kein Timer oder Gebäudeverfall erzwingt ein Ende. Heldenwachstum, Angriffschampions und Boss (M2) liefern die beabsichtigten Asymmetrien.
- Kleine Botstichproben belegen keine ausgewogene Spielerfahrung. Die Bünde und Profile sind noch nicht getrennt ausgewertet (M3).

View file

@ -0,0 +1,49 @@
# 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.
Legende: ✅ automatisiert geprüft · 👁 in echten Aufnahmen visuell geprüft · ⏳ offen (Meilenstein) · — nicht zutreffend.
## Abnahmepunkte aus dem Masterauftrag (§15)
| # | Anforderung | Stand | Beleg |
| --- | --- | --- | --- |
| 1 | Zwei Teams, sechs Eigentümerrollen, drei Lanes, 16 Türme, 2 Kerne | ✅ M1 | `test_lune_core` · structure |
| 2 | Turmreihenfolge, Basisschutz, Kernsieg; gleichzeitige Kernzerstörung ohne Reihenfolgevorteil | ✅ M1 | `test_lune_core` · gates, victory_and_draw, mirror_fairness |
| 3 | Fortsetzung nach Welle 20, hohe Indizes ohne Final-Wave-Logik, Fehler oder Objektwachstum | ✅ M1 (Wellen bis 10⁶, Runde 21; Menge ≤ 7 je Lane) | `test_lune_core` · endless_waves |
| 4 | Gold exakt einmal, Besitzprüfung, keine Reset-/Despawn-/Beschwörungsfarmen | ✅ M1 (Monster, Gebäude, Truppentod ohne Kopfgeld); Beschwörungen gibt es noch nicht | `test_lune_core` · rewards, commands |
| 5 | Heilung und Persistenz nach der Runde; neue Partie ohne übernommene Werte | ✅ M1 Truppen · ⏳ M2 Helden | `test_lune_core` · persistence; `verify_m1` |
| 6 | Truppen ohne Heldeninventar; Talente, drei Itemplätze, Stapeln | ⏳ M2 | — |
| 7 | Verstärkungskauf n → Einsatz n+1, richtige Lane und richtiges Team | ⏳ M2 | — |
| 8 | Grottenabzug schwächt die Lane für die ganze Kampfphase | ⏳ M2 | — |
| 9 | Beide Expeditionen, Scheitern, Erfolg, genau eine Entsendung, Kopfgeld, Verfügbarkeit ab Runde 10 | ⏳ M2 | — |
| 10 | Laneverlust, Phasenende, Pause, Neustart, Rückkehr ins Menü | ✅ M1 (Pause, Phasenende, Menü, Neustart, Rematch) · ⏳ Laneverlust gezielt | `verify_m1`, `test_lune_core` · victory (timeout) |
| 11 | Bots ohne Sonderressourcen und ohne Sicht auf private Befehle | ✅ M1 Ressourcen · Bots lesen nur den eigenen Entwurf und veröffentlichte Gebäudestände (Code-Durchsicht, `LuneBot`) | `test_lune_core` · commands |
| 12 | Netzwerk: doppelte, veraltete und fremde Befehle; synchroner Phasenwechsel, Disconnect | ⏳ M4 | — |
## Echte Anwendung (M1)
`tools/luneraria/verify_m1.tscn`, 38 Prüfungen mit echten Maus- und Tastaturereignissen:
- Titel und Projektname, Lane- und Bundwahl per Klick, Start.
- Eingabeschutz während der Überblendung: Mausrad, 1 und Leertaste erreichen das Spiel nicht.
- Kauf, Startaufstellung, Rückgängig, Laneeinteilung und die Leertaste übernehmen die Planung; im Kampf wird nichts gekauft.
- Truppen kämpfen auf ihrer Lane; Pause hält die Kampfuhr an; Tempo schaltet um.
- Abrechnung und Rückkehr der Truppen.
- HUD innerhalb des Fensters bei 1280 × 720 und 1920 × 1080.
- Menü, neue Partie ohne Altlasten, eine vollständige Partie bis zum Ancient-Fall mit Live-Aufnahme der Belagerung, Ergebnisbildschirm, Rematch.
- Vier Menü-/Partiewechsel ohne wachsende Objektzahl.
Nur die Kampfuhr wird dabei beschleunigt (mehr feste Schritte pro Bild), die Regeln bleiben unverändert.
## 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`.
## Plattformen
| Plattform | Stand |
| --- | --- |
| Fedora 44, Forward+ | ✅ Quellprojekt (Tests, Aufnahmen, Leistung) |
| Fedora 44, Kompatibilitätsmodus (OpenGL) | ⏳ M4 (für Saumwacht 0.15.0 geprüft, für Luneraria noch nicht) |
| Windows 11 | ⏳ nicht geprüft (keine Hardware). Ein exportiertes Paket oder ein Wine-Lauf wäre kein nativer Test. |

View file

@ -1,19 +1,51 @@
# STATUS · Champions of Luneraria
Stand: 27. September 2026.
Stand: 27. September 2026 · Branch `luneraria/m1` · Version im Projekt 0.15.0 (Saumwacht-Demo), Luneraria-Paket erst mit M4.
| Meilenstein | Zustand |
| --- | --- |
| M0 · Migration vorbereiten | **abgeschlossen**: Bestand, Sicherung (Git-Baseline `c2b4476` + Archiv), Regelwerk, Architektur, Assetmatrix, Baseline-Tests grün |
| M1 · Spielbarer Drei-Lane-Kern | in Arbeit |
| M2 · Mechanikdurchlauf | offen |
| M0 · Migration vorbereiten | **abgeschlossen** |
| M1 · Spielbarer Drei-Lane-Kern | **abgeschlossen** (siehe unten) |
| M2 · Mechanikdurchlauf | in Arbeit |
| M3 · Qualität und Balance | offen |
| M4 · Netzwerk und Pakete | offen |
## M1 · abgeschlossen
**Implementiert**
- Autoritative Partie mit sechs Spielerrollen (ein Mensch, fünf Bots), drei spiegelsymmetrischen Lanes, 16 Türmen und zwei Ancients.
- Gebäudesperren für jede Schadensquelle.
- Vorrückende Armeen mit eigenen, unbegrenzt wachsenden Monsterwellen.
- Planung mit atomarer Übernahme, Besitz-IDs, Einmal-Belohnung mit verlustfreier Restverteilung, Rückkehr und Heilung der Truppen, persistenter Gebäudeschaden.
- Partieende nur durch Kernfall; bei gleichzeitigem Kernfall Unentschieden.
- Bots über dieselbe Befehlsschnittstelle.
- 3D-Karte aus Bestandsassets plus neuem Turm (Blender), Einheiten mit den vorhandenen Modellen und Animationen.
- HUD mit Planung, Laneübersicht, Minimap, Ergebnis; Titelmenü auf Champions of Luneraria umgestellt.
- Übernahme der alten Benutzereinstellungen. Legacy-Modus bleibt unter `--legacy` und den alten QA-Schaltern erreichbar.
**Automatisiert geprüft**
- Regeln 91/91, darunter der exakte Spiegeltest über 12 Runden.
- Echte Anwendung 38/38, mit einer vollständigen Partie bis zum Ancient-Fall.
- Acht Bot-Seeds enden regulär nach 8–15 Wellen.
- Legacy 209 · 33 · 463 · 206 · 1.665 · 68 unverändert.
**Visuell geprüft:** Menü, Planung (720p/1080p), Kampf Mid/Top, Übersicht, Belagerung eines Ancients, Ergebnis (`screenshots/`).
**Leistung** (RTX 5060 Ti, Ryzen 5 5600X, Forward+, VSync aus, 2×): 114 bewegliche Einheiten und 18 Gebäude. 1080p: 7,6 ms/Bild (p95 10,5 ms), GPU 3,1 ms, ~3.170 Draw Calls, Simulationsschritt 2,4 ms (vor der Optimierung 15,8 ms). Es gibt keine allgemeine FPS-Zusage.
**Ungetestet:** Windows 11, Kompatibilitätsmodus für Luneraria, menschliche Spieltests.
## Bekannt
- Ein exakt gespiegeltes Match bleibt unentschieden, wird also nicht beendet (Testlimit). Das ist ein Balancebefund für M3.
- Die Figuren der Monsterwellen sind nur über Teamring und Lichtfarbe dem Team zugeordnet.
- Bäume und Felsen stammen aus dem Vergleichspack und sind einfach; das Kartenbild ist funktional, noch nicht endgültig (M3).
- Beim Beenden meldet Godot eine noch belegte Ressource (Hintergrundladen der Menübühne); ohne Folgen im Spiel.
## Blockiert
- Nativer Windows-11-Test: keine Windows-Hardware in dieser Umgebung.
- Nativer Windows-11-Test: keine Windows-Hardware.
## Nächster Schritt
M1: `LuneBalance`, `LuneMap`, `LuneCombat`, `LuneMatch` und Bots, dann Kartendarstellung, HUD und ein reproduzierbar simulierter Sieg.
M2: Helden (3) mit Erfahrung, Talenten und drei Itemplätzen (6 Items), Angriffschampions (3) mit Buchung n → n+1, Bossgrotte mit Streitfall, Sperre und Entsendung, dazu die HUD-Erweiterung und Bot-Strategien.

View file

@ -0,0 +1,4 @@
{
"checks": 91,
"failures": []
}

View file

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

View file

@ -0,0 +1,8 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
Vulkan 1.4.351 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce RTX 5060 Ti
LUNE_BENCH {"buildings":18,"cpu":"AMD Ryzen 5 5600X 6-Core Processor","draw_calls_mean":3175.86281588448,"engine":"4.7.2-stable (fedora)","frame_ms_mean":7.22646841155234,"frame_ms_p95":10.294,"frames":1108,"gpu":"NVIDIA GeForce RTX 5060 Ti","gpu_ms_mean":2.09521750809914,"mobile_units_at_start":114,"mobile_units_mean_measured":108.60559566787,"renderer":"forward_plus","scene_build_ms":198.002,"sim_speed":"2x (40 steps/s)","sim_step_ms":2.37125,"static_mem_mb":120.703545570374,"video_mem_mb":658.710235595703,"window":"(1280, 720)"}
WARNING: 24 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)

View file

@ -0,0 +1,8 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
Vulkan 1.4.351 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce RTX 5060 Ti
LUNE_BENCH {"buildings":18,"cpu":"AMD Ryzen 5 5600X 6-Core Processor","draw_calls_mean":3172.21948912015,"engine":"4.7.2-stable (fedora)","frame_ms_mean":7.56970955534533,"frame_ms_p95":10.505,"frames":1057,"gpu":"NVIDIA GeForce RTX 5060 Ti","gpu_ms_mean":3.06410596431599,"mobile_units_at_start":114,"mobile_units_mean_measured":108.593188268685,"renderer":"forward_plus","scene_build_ms":221.213,"sim_speed":"2x (40 steps/s)","sim_step_ms":2.403405,"static_mem_mb":120.915734291077,"video_mem_mb":765.283081054688,"window":"(1920, 1080)"}
WARNING: 24 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)

View file

@ -0,0 +1,4 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
ABILITY_EVENT_PASS: 33 checks; 0 failures
EXIT 0

View file

@ -0,0 +1,250 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
ERROR: Attempting to initialize the wrong RID
at: get_or_null (./core/templates/rid_owner.h:240)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "mem" is null.
at: initialize_rid (./core/templates/rid_owner.h:284)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_set_blend_shape_count (servers/rendering/dummy/storage/mesh_storage.h:74)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_set_blend_shape_mode (servers/rendering/dummy/storage/mesh_storage.h:110)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Attempting to use an uninitialized RID
at: get_or_null (./core/templates/rid_owner.h:247)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
ERROR: Parameter "m" is null.
at: mesh_set_blend_shape_mode (servers/rendering/dummy/storage/mesh_storage.h:110)
GDScript backtrace (most recent call first):
[0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110)
[1] _place_core (res://scripts/presentation/menu_stage.gd:172)
[2] _ready (res://scripts/presentation/menu_stage.gd:61)
[3] _build_stage (res://scripts/ui/main_menu.gd:97)
[4] _ready (res://scripts/ui/main_menu.gd:69)
[5] _make_overlay (res://scripts/ui/hud.gd:492)
[6] _ready (res://scripts/ui/hud.gd:112)
EXPEDITION_MATCH: Standard gegen Standard; wave 20; cores [669, 669]; outcome -1
EXPEDITION_MATCH: Standard gegen Lernmodus; wave 10; cores [2038, 0]; outcome 0
EXPEDITION_MATCH: Leeres Baufeld gegen Standard; wave 7; cores [0, 3000]; outcome 1
EXPEDITION_PASS: 206 checks; 0 failures; report /home/martin/.local/share/godot/app_userdata/Champions of Luneraria/phase8-verification.json
WARNING: 6 ObjectDB instances were leaked at exit (run with `--verbose` for details).
at: cleanup (core/object/object.cpp:2536)
ERROR: 1 RID allocations of type 'N13RendererDummy9DummyMeshE' were leaked at exit.
EXIT 0

View file

@ -0,0 +1,8 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
FACTION_MATCH: Wurzelbund gegen Glutpakt wave 20 cores [1244, 1709]
FACTION_MATCH: Glutpakt gegen Wurzelbund wave 20 cores [1709, 1244]
FACTION_MATCH: Wurzelbund Spiegel wave 20 cores [1244, 1244]
FACTION_MATCH: Wurzelbund Kurzpartie wave 8 cores [3000, 3000]
FACTION_PASS: 1665 checks; 0 failures; /home/martin/.local/share/godot/app_userdata/Champions of Luneraria/phase10-verification.json
EXIT 0

View file

@ -0,0 +1,5 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
EXPORT_SMOKE_PASS: 463 checks; 0 failures; engine 4.7.2-stable (fedora); cores [3000, 1035]
SMOKE_CAPTURES: /home/martin/.local/share/godot/app_userdata/Champions of Luneraria/phase8-smoke
EXIT 0

View file

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

View file

@ -0,0 +1,5 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
Vulkan 1.4.351 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce RTX 5060 Ti
MENU_VERIFY_PASS: 68 checks; 0 failures
EXIT 0

View file

@ -0,0 +1,9 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
LUNE_FULL_MATCH: seed 2 -> Blau after 8 rounds
LUNE_CORE_PASS: 91 checks; 0 failures
WARNING: 10 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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,106 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
R01 cleared 46.7s buildings [9, 9] lost [] gold [123, 173, 123, 128, 123, 128]
R02 cleared 53.7s buildings [9, 9] lost [] gold [140, 105, 115, 125, 125, 150]
R03 cleared 55.5s buildings [9, 9] lost [] gold [160, 170, 150, 125, 160, 175]
R04 cleared 69.1s buildings [8, 9] lost ["blau_top_outer"] gold [182, 142, 132, 182, 172, 257]
R05 cleared 74.3s buildings [8, 9] lost [] gold [178, 183, 163, 133, 153, 153]
R06 cleared 70.1s buildings [7, 8] lost ["rot_mid_outer", "blau_top_inner"] gold [203, 223, 183, 243, 253, 203]
R07 cleared 73.3s buildings [7, 7] lost ["rot_mid_inner"] gold [270, 235, 270, 170, 195, 205]
R08 cleared 79.9s buildings [6, 7] lost ["blau_base_1"] gold [245, 185, 165, 275, 245, 280]
R09 core 86.9s buildings [4, 7] lost ["blau_base_2", "blau_ancient"] gold [57, 106, 66, 171, 176, 176]
SIM_MATCH seed=1 Sieg Rot after 9 rounds (Ancient von Blau zerstört.) in 7091 ms
R01 cleared 46.7s buildings [9, 9] lost [] gold [128, 123, 128, 173, 128, 123]
R02 cleared 53.7s buildings [9, 9] lost [] gold [125, 125, 150, 105, 125, 115]
R03 cleared 54.7s buildings [9, 9] lost [] gold [125, 160, 175, 165, 165, 115]
R04 cleared 73.1s buildings [9, 8] lost ["rot_top_outer"] gold [182, 172, 257, 137, 157, 187]
R05 cleared 81.9s buildings [8, 7] lost ["blau_mid_outer", "rot_top_inner"] gold [203, 223, 223, 218, 183, 193]
R06 cleared 67.3s buildings [8, 7] lost [] gold [158, 178, 133, 138, 153, 133]
R07 cleared 74.7s buildings [7, 6] lost ["blau_mid_inner", "rot_base_1"] gold [260, 285, 310, 225, 235, 255]
R08 core 92.9s buildings [7, 3] lost ["rot_bot_outer", "rot_base_2", "rot_ancient"] gold [207, 197, 217, 79, 105, 64]
SIM_MATCH seed=2 Sieg Blau after 8 rounds (Ancient von Rot zerstört.) in 6499 ms
R01 cleared 41.9s buildings [9, 9] lost [] gold [173, 128, 123, 128, 123, 173]
R02 cleared 46.7s buildings [9, 9] lost [] gold [105, 125, 115, 135, 140, 105]
R03 cleared 59.8s buildings [9, 9] lost [] gold [165, 165, 115, 175, 160, 170]
R04 cleared 61.4s buildings [9, 9] lost [] gold [137, 157, 187, 137, 182, 142]
R05 cleared 54.9s buildings [9, 9] lost [] gold [168, 133, 143, 133, 178, 183]
R06 cleared 57.9s buildings [9, 9] lost [] gold [163, 173, 173, 173, 153, 173]
R07 cleared 58.3s buildings [9, 9] lost [] gold [180, 185, 225, 185, 205, 185]
R08 cleared 61.9s buildings [8, 9] lost ["blau_bot_outer"] gold [200, 200, 155, 250, 255, 250]
R09 cleared 71.3s buildings [8, 9] lost [] gold [213, 218, 188, 198, 168, 198]
R10 cleared 74.2s buildings [7, 9] lost ["blau_bot_inner"] gold [215, 250, 215, 300, 265, 300]
R11 cleared 83.7s buildings [5, 9] lost ["blau_top_outer", "blau_base_2"] gold [228, 218, 218, 348, 358, 413]
R12 core 86.9s buildings [1, 9] lost ["blau_mid_outer", "blau_top_inner", "blau_base_1", "blau_ancient"] gold [101, 106, 133, 348, 318, 323]
SIM_MATCH seed=3 Sieg Rot after 12 rounds (Ancient von Blau zerstört.) in 9910 ms
R01 cleared 44.6s buildings [9, 9] lost [] gold [128, 123, 173, 123, 128, 123]
R02 cleared 46.5s buildings [9, 9] lost [] gold [135, 140, 105, 115, 125, 125]
R03 cleared 55.1s buildings [9, 9] lost [] gold [175, 160, 170, 150, 125, 160]
R04 cleared 63.1s buildings [9, 9] lost [] gold [137, 182, 142, 132, 132, 122]
R05 cleared 62.3s buildings [9, 9] lost [] gold [133, 178, 183, 163, 173, 153]
R06 cleared 59.7s buildings [9, 9] lost [] gold [173, 153, 173, 133, 213, 148]
R07 cleared 72.1s buildings [9, 8] lost ["rot_bot_outer"] gold [235, 255, 235, 160, 190, 165]
R08 cleared 62.5s buildings [8, 8] lost ["blau_top_outer"] gold [180, 180, 180, 205, 210, 215]
R09 cleared 74.8s buildings [8, 7] lost ["rot_bot_inner"] gold [268, 263, 268, 238, 223, 178]
R10 cleared 80.3s buildings [8, 7] lost [] gold [235, 205, 235, 210, 265, 185]
R11 core 117.5s buildings [7, 4] lost ["blau_mid_outer", "rot_base_2", "rot_base_1", "rot_ancient"] gold [288, 348, 303, 223, 178, 178]
SIM_MATCH seed=4 Sieg Blau after 11 rounds (Ancient von Rot zerstört.) in 9299 ms
R01 cleared 42.1s buildings [9, 9] lost [] gold [123, 128, 123, 128, 173, 128]
R02 cleared 50.3s buildings [9, 9] lost [] gold [115, 125, 125, 150, 105, 125]
R03 cleared 55.1s buildings [9, 9] lost [] gold [150, 125, 160, 175, 165, 165]
R04 cleared 66.9s buildings [9, 9] lost [] gold [132, 132, 122, 207, 137, 157]
R05 cleared 60.3s buildings [9, 9] lost [] gold [163, 173, 153, 198, 168, 133]
R06 cleared 61.9s buildings [9, 9] lost [] gold [133, 213, 148, 178, 163, 173]
R07 cleared 87.3s buildings [9, 8] lost ["rot_top_outer"] gold [210, 240, 215, 180, 180, 185]
R08 cleared 89.3s buildings [9, 8] lost [] gold [205, 210, 165, 165, 200, 200]
R09 cleared 80.3s buildings [8, 7] lost ["blau_bot_outer", "rot_top_inner"] gold [308, 293, 303, 218, 263, 268]
R10 cleared 84.1s buildings [8, 7] lost [] gold [220, 195, 195, 185, 190, 185]
R11 cleared 84.8s buildings [7, 6] lost ["blau_bot_inner", "rot_base_1"] gold [363, 343, 358, 323, 313, 308]
R12 cleared 83.7s buildings [7, 6] lost [] gold [267, 237, 237, 232, 272, 232]
R13 cleared 95.7s buildings [6, 5] lost ["rot_mid_outer", "blau_base_2"] gold [303, 338, 333, 388, 393, 393]
R14 cleared 82.1s buildings [6, 5] lost [] gold [390, 295, 305, 310, 320, 375]
R15 core 130.3s buildings [6, 3] lost ["rot_base_2", "rot_ancient"] gold [411, 511, 291, 216, 311, 351]
SIM_MATCH seed=5 Sieg Blau after 15 rounds (Ancient von Rot zerstört.) in 14879 ms
R01 cleared 48.3s buildings [9, 9] lost [] gold [128, 173, 128, 123, 128, 123]
R02 cleared 52.5s buildings [9, 9] lost [] gold [150, 105, 125, 115, 135, 140]
R03 cleared 60.7s buildings [9, 9] lost [] gold [175, 165, 165, 115, 175, 160]
R04 cleared 64.5s buildings [9, 9] lost [] gold [207, 137, 157, 187, 137, 182]
R05 cleared 59.1s buildings [9, 9] lost [] gold [198, 168, 133, 143, 133, 178]
R06 cleared 58.5s buildings [9, 8] lost ["rot_mid_outer"] gold [228, 213, 223, 173, 173, 153]
R07 cleared 72.7s buildings [8, 8] lost ["blau_top_outer"] gold [230, 155, 165, 275, 235, 255]
R08 cleared 86.1s buildings [7, 7] lost ["rot_mid_inner", "blau_top_inner"] gold [285, 245, 250, 230, 250, 250]
R09 cleared 72.4s buildings [7, 6] lost ["rot_bot_outer"] gold [218, 248, 273, 243, 203, 203]
R10 cleared 74.8s buildings [7, 6] lost [] gold [195, 220, 215, 270, 245, 250]
R11 cleared 92.9s buildings [7, 5] lost ["rot_bot_inner"] gold [288, 343, 278, 283, 218, 278]
R12 core 89.1s buildings [7, 2] lost ["rot_base_2", "rot_base_1", "rot_ancient"] gold [328, 288, 348, 108, 163, 87]
SIM_MATCH seed=6 Sieg Blau after 12 rounds (Ancient von Rot zerstört.) in 10244 ms
R01 cleared 41.3s buildings [9, 9] lost [] gold [123, 128, 123, 173, 123, 128]
R02 cleared 51.5s buildings [9, 9] lost [] gold [115, 135, 140, 105, 115, 125]
R03 cleared 52.5s buildings [9, 9] lost [] gold [115, 175, 160, 170, 150, 125]
R04 cleared 57.7s buildings [9, 9] lost [] gold [187, 137, 182, 142, 132, 132]
R05 cleared 57.5s buildings [9, 9] lost [] gold [143, 133, 178, 183, 163, 173]
R06 cleared 61.7s buildings [9, 9] lost [] gold [173, 173, 153, 173, 133, 213]
R07 cleared 61.1s buildings [9, 9] lost [] gold [225, 185, 205, 185, 160, 190]
R08 cleared 57.5s buildings [9, 9] lost [] gold [155, 200, 205, 200, 155, 160]
R09 cleared 72.9s buildings [9, 9] lost [] gold [188, 218, 193, 218, 188, 173]
R10 cleared 72.1s buildings [8, 9] lost ["blau_bot_outer"] gold [215, 250, 240, 280, 285, 265]
R11 cleared 84.4s buildings [5, 9] lost ["blau_mid_outer", "blau_top_outer", "blau_bot_inner"] gold [218, 218, 238, 398, 453, 408]
R12 core 98.9s buildings [0, 9] lost ["blau_mid_inner", "blau_top_inner", "blau_base_2", "blau_base_1", "blau_ancient"] gold [156, 101, 123, 463, 563, 528]
SIM_MATCH seed=7 Sieg Rot after 12 rounds (Ancient von Blau zerstört.) in 10269 ms
R01 cleared 41.9s buildings [9, 9] lost [] gold [173, 123, 128, 123, 128, 173]
R02 cleared 47.5s buildings [9, 9] lost [] gold [105, 115, 125, 125, 150, 105]
R03 cleared 55.5s buildings [9, 9] lost [] gold [170, 150, 125, 160, 175, 165]
R04 cleared 55.1s buildings [9, 9] lost [] gold [142, 132, 132, 122, 207, 137]
R05 cleared 54.5s buildings [9, 9] lost [] gold [183, 163, 173, 153, 198, 168]
R06 cleared 57.7s buildings [9, 9] lost [] gold [173, 133, 213, 148, 178, 163]
R07 cleared 58.7s buildings [9, 9] lost [] gold [185, 160, 190, 165, 180, 180]
R08 cleared 54.9s buildings [9, 9] lost [] gold [200, 155, 160, 165, 165, 200]
R09 cleared 66.5s buildings [9, 8] lost ["rot_top_outer"] gold [268, 238, 223, 178, 168, 213]
R10 cleared 87.5s buildings [9, 7] lost ["rot_top_inner"] gold [280, 280, 335, 210, 200, 215]
R11 cleared 87.7s buildings [9, 6] lost ["rot_bot_outer"] gold [323, 403, 303, 238, 218, 228]
R12 core 103.0s buildings [9, 1] lost ["rot_base_1", "rot_mid_outer", "rot_bot_inner", "rot_base_2", "rot_ancient"] gold [418, 493, 458, 143, 113, 118]
SIM_MATCH seed=8 Sieg Blau after 12 rounds (Ancient von Rot zerstört.) in 10274 ms
WARNING: 6 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

View file

@ -0,0 +1,15 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
Vulkan 1.4.351 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce RTX 5060 Ti
M1_MENU_READY_MS 733
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 Rot zerstört. after 10 waves
M1_VERIFY_PASS: 38 checks; 0 failures
EXIT 0

View file

@ -0,0 +1,20 @@
{
"buildings": 18,
"cpu": "AMD Ryzen 5 5600X 6-Core Processor",
"draw_calls_mean": 3175.86281588448,
"engine": "4.7.2-stable (fedora)",
"frame_ms_mean": 7.22646841155234,
"frame_ms_p95": 10.294,
"frames": 1108,
"gpu": "NVIDIA GeForce RTX 5060 Ti",
"gpu_ms_mean": 2.09521750809914,
"mobile_units_at_start": 114,
"mobile_units_mean_measured": 108.60559566787,
"renderer": "forward_plus",
"scene_build_ms": 198.002,
"sim_speed": "2x (40 steps/s)",
"sim_step_ms": 2.37125,
"static_mem_mb": 120.703545570374,
"video_mem_mb": 658.710235595703,
"window": "(1280, 720)"
}

View file

@ -0,0 +1,20 @@
{
"buildings": 18,
"cpu": "AMD Ryzen 5 5600X 6-Core Processor",
"draw_calls_mean": 3172.21948912015,
"engine": "4.7.2-stable (fedora)",
"frame_ms_mean": 7.56970955534533,
"frame_ms_p95": 10.505,
"frames": 1057,
"gpu": "NVIDIA GeForce RTX 5060 Ti",
"gpu_ms_mean": 3.06410596431599,
"mobile_units_at_start": 114,
"mobile_units_mean_measured": 108.593188268685,
"renderer": "forward_plus",
"scene_build_ms": 221.213,
"sim_speed": "2x (40 steps/s)",
"sim_step_ms": 2.403405,
"static_mem_mb": 120.915734291077,
"video_mem_mb": 765.283081054688,
"window": "(1920, 1080)"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View file

@ -2,7 +2,7 @@
"checks": 68,
"engine": "4.7.2-stable (fedora)",
"failures": [],
"formations_ready_ms": 895,
"menu_instantiate_ms": 515,
"formations_ready_ms": 1089,
"menu_instantiate_ms": 571,
"renderer": "forward_plus"
}

View file

@ -14,10 +14,10 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application]
config/name="Saumwacht · Glutpakt"
config/description="Glutpakt und Wurzelbund · 20 Wellen gegen die Rivalen-KI"
config/name="Champions of Luneraria"
config/description="3 gegen 3 auf drei Lanes · Autobattler · Partie endet mit dem Fall eines Ancients"
config/version="0.15.0"
run/main_scene="res://scenes/main.tscn"
run/main_scene="res://scenes/luneraria.tscn"
config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://assets/icons/resources/res_core.png"

6
scenes/luneraria.tscn Normal file
View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/luneraria/lune_main.gd" id="1"]
[node name="Luneraria" type="Node"]
script = ExtResource("1")

View file

@ -14,8 +14,22 @@ func _ready() -> void:
read_settings()
apply()
## The project was renamed from "Saumwacht · Glutpakt"; its user folder changed with it.
const LEGACY_FOLDER := "Saumwacht · Glutpakt"
var migrated_from: String = ""
func _legacy_path() -> String:
var own: String = ProjectSettings.globalize_path("user://")
var parent: String = own.trim_suffix("/").get_base_dir()
return parent.path_join(LEGACY_FOLDER).path_join("preferences.cfg")
func read_settings() -> void:
var config := ConfigFile.new()
if storage_path == "user://preferences.cfg" and not FileAccess.file_exists(storage_path) and FileAccess.file_exists(_legacy_path()):
# One-time copy of the old options; the old file stays untouched.
if config.load(_legacy_path()) == OK and config.save(storage_path) == OK:
migrated_from = _legacy_path()
print("PREFERENCES: übernommen aus ", migrated_from)
if config.load(storage_path) != OK:
return
volume = clampf(float(config.get_value("audio", "volume", .65)), 0.0, 1.0)

View file

@ -57,14 +57,16 @@ static func _needed_role(match_state: LuneMatch, roster: Array, profile: Diction
static func _try_upgrade(match_state: LuneMatch, slot: int, roster: Array) -> bool:
var best_uid: String = ""
var best_seq: int = 1 << 30
var best_cost: int = 1 << 30
for entry: Dictionary in roster:
var upgrade: String = str(DataCatalog.get_unit(entry.type_id).get("upgrade_id", ""))
if upgrade.is_empty():
continue
var cost: int = int(DataCatalog.get_unit(upgrade).cost)
if cost < best_cost or (cost == best_cost and str(entry.uid) < best_uid):
if cost < best_cost or (cost == best_cost and int(entry.seq) < best_seq):
best_cost = cost
best_seq = int(entry.seq)
best_uid = str(entry.uid)
if best_uid.is_empty():
return false

View file

@ -17,12 +17,30 @@ var end_reason: String = ""
var destroyed_cores: Array[int] = []
var events: Array[Dictionary] = []
var _next_id: int = 1
var _team_count: Array[int] = [0, 0]
## Units without a target look for one every RETARGET steps, staggered by team_index.
const RETARGET: int = 4
var _event_serial: int = 0
var _pending_damage: Dictionary = {}
var _pending_heal: Dictionary = {}
var _pending_effects: Array = []
var _pending_dots: Array = []
var _pending_shields: Array = []
# Per-step caches: building gates, march limits and a spatial grid of mobile units.
var _attackable: Dictionary = {}
var _limits: Dictionary = {}
var _grid: Dictionary = {}
const CELL: float = 4.0
## Optional section timings (microseconds) for performance work; off by default.
var profiling: bool = false
var profile: Dictionary = {}
var _mark: int = 0
func _lap(section: String) -> void:
if profiling:
var now: int = Time.get_ticks_usec()
profile[section] = int(profile.get(section, 0)) + now - _mark
_mark = now
func _init(match_map: LuneMap, config: LuneBalance, number: int) -> void:
map = match_map
@ -38,6 +56,8 @@ func add_building(definition: Dictionary, hp: float) -> LuneUnit:
unit.team = int(definition.team)
unit.lane = str(definition.lane)
unit.building_id = str(definition.id)
unit.team_index = _team_count[unit.team]
_team_count[unit.team] += 1
unit.def = {"id": definition.id, "display_name": definition.id, "max_hp": definition.max_hp, "damage": definition.damage,
"attack_range": definition.range, "attack_type": 1, "armor_type": 1, "speed": 0.0, "ability": "", "bounty": definition.bounty,
"tier": definition.tier}
@ -60,6 +80,8 @@ func spawn(kind: String, team: int, lane: String, definition: Dictionary, progre
unit.def = definition
unit.owner_slot = slot
unit.persistent_uid = uid
unit.team_index = _team_count[team]
_team_count[team] += 1
unit.progress = progress
unit.lateral = lateral
unit.position = map.lane_point(team, lane, progress, lateral)
@ -79,7 +101,51 @@ func alive_map() -> Dictionary:
return result
func building_attackable(unit: LuneUnit, gates: Dictionary) -> bool:
return unit.alive and LuneMap.is_attackable(map.building_def(unit.building_id), gates)
if not unit.alive:
return false
if _attackable.has(unit.building_id):
return _attackable[unit.building_id]
return LuneMap.is_attackable(map.building_def(unit.building_id), gates)
func _prepare_caches(living: Array[LuneUnit], gates: Dictionary) -> void:
_attackable.clear()
for id: String in buildings:
_attackable[id] = LuneMap.is_attackable(map.building_def(id), gates)
_limits.clear()
for team: int in range(2):
for lane: String in LuneMap.LANES:
var path: LuneMap.LanePath = map.lanes[lane]
var limit: float = path.length
for id: String in buildings:
var building: LuneUnit = buildings[id]
if building.alive and building.kind == "tower" and building.team != team and building.lane == lane:
limit = minf(limit, path.length - float(map.building_def(id).progress) - 1.5)
_limits[team * 3 + LuneMap.LANES.find(lane)] = limit
_grid = {0: {}, 1: {}}
for unit: LuneUnit in living:
if unit.is_mobile():
var cells: Dictionary = _grid[unit.team]
var key := Vector2i(floori(unit.position.x / CELL), floori(unit.position.y / CELL))
if not cells.has(key):
cells[key] = []
(cells[key] as Array).append(unit)
## Mobile units of one team (or both with team = -1) near a point; the caller checks distance.
func _near_mobile(center: Vector2, radius: float, team: int = -1) -> Array:
var result: Array = []
var reach: int = ceili(radius / CELL)
var cx: int = floori(center.x / CELL)
var cy: int = floori(center.y / CELL)
for side: int in ([0, 1] if team < 0 else [team]):
var cells: Dictionary = _grid[side]
if cells.is_empty():
continue
for dx: int in range(-reach, reach + 1):
for dy: int in range(-reach, reach + 1):
var bucket: Variant = cells.get(Vector2i(cx + dx, cy + dy))
if bucket != null:
result.append_array(bucket)
return result
# ---------------------------------------------------------------- step
func step() -> void:
@ -93,26 +159,35 @@ func step() -> void:
_pending_effects.clear()
_pending_dots.clear()
_pending_shields.clear()
_mark = Time.get_ticks_usec()
var gates: Dictionary = alive_map()
var living: Array[LuneUnit] = []
for unit: LuneUnit in units:
if unit.alive:
living.append(unit)
unit.previous_position = unit.position
_prepare_caches(living, gates)
_lap("caches")
for unit: LuneUnit in living:
var dot: float = unit.advance_status(dt)
if dot > 0.0:
_queue_damage(unit, dot, gates)
_lap("status")
for unit: LuneUnit in living:
if unit.is_mobile():
_ability_step(unit, living)
_lap("abilities")
for unit: LuneUnit in living:
if unit.is_mobile() or (unit.kind == "tower"):
unit.target = _choose_target(unit, living, gates)
var keep: bool = unit.target != null and unit.target.alive
if keep or (steps + unit.team_index) % RETARGET == 0:
unit.target = _choose_target(unit, living, gates)
_lap("targets")
var moves: Dictionary = {}
for unit: LuneUnit in living:
if unit.is_mobile():
moves[unit.id] = _plan_move(unit, living, dt)
_lap("plan_move")
for unit: LuneUnit in living:
if moves.has(unit.id):
var plan: Array = moves[unit.id]
@ -123,8 +198,10 @@ func step() -> void:
unit.facing = atan2(heading.x, heading.y)
if unit.zone == "lane":
_update_progress(unit, float(plan[1]))
_lap("apply_move")
for unit: LuneUnit in living:
_attack(unit, living, gates, dt)
_lap("attacks")
for unit: LuneUnit in living:
if _pending_heal.has(unit.id):
unit.heal(_pending_heal[unit.id])
@ -143,6 +220,7 @@ func step() -> void:
unit.death_time = elapsed
unit.target = null
_record_death(unit)
_lap("resolve")
_check_end()
func _check_end() -> void:
@ -189,8 +267,8 @@ func _choose_target(unit: LuneUnit, living: Array[LuneUnit], gates: Dictionary)
var radius: float = unit.attack_range() if unit.kind == "tower" else balance.aggro_radius
var best: LuneUnit = null
var best_distance: float = INF
for other: LuneUnit in living:
if not _hostile(unit, other) or not other.is_mobile():
for other: LuneUnit in _near_mobile(unit.position, radius, 1 - unit.team):
if not _hostile(unit, other):
continue
var d: float = unit.position.distance_to(other.position)
if d <= radius and (d < best_distance - 0.0001 or (absf(d - best_distance) <= 0.0001 and other.id < best.id)):
@ -199,8 +277,9 @@ func _choose_target(unit: LuneUnit, living: Array[LuneUnit], gates: Dictionary)
if best != null or unit.kind == "tower":
return best
# Buildings: nearby attackable ones; at the end of a lane the whole base is in reach.
for other: LuneUnit in living:
if not other.is_building() or not _hostile(unit, other) or not building_attackable(other, gates):
for id: String in buildings:
var other: LuneUnit = buildings[id]
if not _hostile(unit, other) or not building_attackable(other, gates):
continue
var d: float = unit.position.distance_to(other.position) - balance.building_radius
if (d <= radius or unit.reached_base) and d < best_distance:
@ -209,14 +288,15 @@ func _choose_target(unit: LuneUnit, living: Array[LuneUnit], gates: Dictionary)
return best
func _march_limit(unit: LuneUnit) -> float:
var key: int = unit.team * 3 + LuneMap.LANES.find(unit.lane)
if _limits.has(key):
return _limits[key]
var path: LuneMap.LanePath = map.lanes[unit.lane]
var limit: float = path.length
for id: String in buildings:
var building: LuneUnit = buildings[id]
if building.alive and building.kind == "tower" and building.team != unit.team and building.lane == unit.lane:
var definition: Dictionary = map.building_def(id)
var mine: float = path.length - float(definition.progress)
limit = minf(limit, mine - 1.5)
limit = minf(limit, path.length - float(map.building_def(id).progress) - 1.5)
return limit
## Returns [next position, marching progress]. Marching advances the lane parameter
@ -235,7 +315,8 @@ func _plan_move(unit: LuneUnit, living: Array[LuneUnit], dt: float) -> Array:
elif unit.zone == "lane":
var limit: float = _march_limit(unit)
var anchor: Vector2 = map.lane_point(unit.team, unit.lane, unit.progress, unit.lateral)
if unit.position.distance_to(anchor) > 1.2:
var off: float = unit.position.distance_to(anchor)
if off > 1.2:
# Rejoin the formation line after a chase before advancing again.
goal = anchor
travel = minf(step_length, unit.position.distance_to(anchor))
@ -243,7 +324,7 @@ func _plan_move(unit: LuneUnit, living: Array[LuneUnit], dt: float) -> Array:
marched = minf(unit.progress + step_length, limit)
goal = map.lane_point(unit.team, unit.lane, marched, unit.lateral)
travel = unit.position.distance_to(goal)
elif unit.position.distance_to(anchor) > 0.05:
elif off > 0.05:
goal = anchor
travel = minf(step_length, unit.position.distance_to(anchor))
var next: Vector2 = unit.position
@ -251,14 +332,17 @@ func _plan_move(unit: LuneUnit, living: Array[LuneUnit], dt: float) -> Array:
next += (goal - unit.position).normalized() * minf(travel, unit.position.distance_to(goal))
# Separation from allies so bodies do not stack; opponents may still close to melee range.
var push := Vector2.ZERO
for other: LuneUnit in living:
if other == unit or not other.is_mobile() or other.team != unit.team or other.zone != unit.zone:
for other: LuneUnit in _near_mobile(unit.position, 1.1, unit.team):
if other == unit or other.zone != unit.zone:
continue
var offset: Vector2 = unit.position - other.position
var distance: float = offset.length()
if distance < 1.1:
if distance < 0.0001:
# Mirrored teams sidestep in mirrored directions.
offset = Vector2.RIGHT if unit.id < other.id else Vector2.LEFT
if unit.team == 1:
offset = LuneMap.mirror(offset)
distance = 0.001
push += offset.normalized() * (1.1 - distance) * 2.0
next += push.limit_length(0.9) * dt
@ -331,7 +415,12 @@ func _start_ability(unit: LuneUnit) -> void:
func _nearby(unit: LuneUnit, living: Array[LuneUnit], allied: bool, center: Vector2, radius: float, maximum: int, excluded: LuneUnit = null, health_priority: bool = false) -> Array[LuneUnit]:
var result: Array[LuneUnit] = []
for other: LuneUnit in living:
# Buildings can be splash victims; they are few, so check them directly.
var pool: Array = _near_mobile(center, radius, unit.team if allied else 1 - unit.team)
if not allied:
for id: String in buildings:
pool.append(buildings[id])
for other: LuneUnit in pool:
if not other.alive or other == excluded or other.zone != unit.zone:
continue
if (other.team == unit.team) != allied:

View file

@ -48,6 +48,17 @@ class LanePath:
return (points[i] - points[i - 1]).normalized()
return Vector2.RIGHT
## Point and unit tangent at s in one pass.
func sample(s: float) -> Array:
var d: float = clampf(s, 0.0, length)
for i: int in range(1, points.size()):
if d <= cumulative[i] or i == points.size() - 1:
var a: Vector2 = points[i - 1]
var b: Vector2 = points[i]
var span: float = maxf(0.0001, cumulative[i] - cumulative[i - 1])
return [a.lerp(b, clampf((d - cumulative[i - 1]) / span, 0.0, 1.0)), (b - a) / span]
return [points[points.size() - 1], Vector2.RIGHT]
func project(position: Vector2) -> float:
var best: float = INF
var best_s: float = 0.0
@ -96,10 +107,10 @@ func progress_for(team: int, lane: String, s: float) -> float:
## Blau frame point, mirrored for Rot so both sides are exact images.
func lane_point(team: int, lane: String, progress: float, lateral: float = 0.0) -> Vector2:
var path: LanePath = lanes[lane]
var s: float = clampf(progress, 0.0, path.length)
var tangent: Vector2 = path.tangent_at(s)
var blue: Vector2 = path.point_at(s) + Vector2(-tangent.y, tangent.x) * lateral
return blue if team == 0 else mirror(blue)
var sampled: Array = path.sample(progress)
var tangent: Vector2 = sampled[1]
var blue: Vector2 = sampled[0] + Vector2(-tangent.y, tangent.x) * lateral
return blue if team == 0 else Vector2(blue.y, blue.x)
func lane_forward(team: int, lane: String, progress: float) -> Vector2:
var path: LanePath = lanes[lane]
@ -131,11 +142,13 @@ func _build_buildings() -> void:
buildings.append({"id": "%s_ancient" % ("blau" if team == 0 else "rot"), "team": team, "lane": "base", "tier": "core",
"progress": -1.0, "position": core_position(team), "max_hp": balance.core_hp, "damage": 0.0, "range": 0.0, "bounty": 0})
var _index: Dictionary = {}
func building_def(id: String) -> Dictionary:
for entry: Dictionary in buildings:
if entry.id == id:
return entry
return {}
if _index.is_empty():
for entry: Dictionary in buildings:
_index[entry.id] = entry
return _index.get(id, {})
## Gate rule shared by every damage source. alive maps building id -> bool.
static func is_attackable(definition: Dictionary, alive: Dictionary) -> bool:

View file

@ -25,8 +25,8 @@ var building_hp: Dictionary = {}
var combat: LuneCombat
var last_settlement: Dictionary = {}
var history: Array[Dictionary] = []
var _next_uid: int = 1
var _reward_rotation: int = 0
## Remainder rotation per team so both teams follow the same distribution pattern.
var _reward_rotation: Array[int] = [0, 0]
func _init(config: LuneBalance = null) -> void:
balance = config if config != null else LuneBalance.default_balance()
@ -42,8 +42,7 @@ func setup(options: Dictionary = {}) -> void:
end_note = ""
history.clear()
last_settlement = {}
_next_uid = 1
_reward_rotation = 0
_reward_rotation = [0, 0]
players.clear()
var human: int = int(options.get("human_slot", 1))
var bunds: Array = options.get("bunds", [])
@ -51,7 +50,7 @@ func setup(options: Dictionary = {}) -> void:
var bund: String = str(bunds[slot]) if slot < bunds.size() else ("glutpakt" if (slot + seed_value) % 2 == 0 else "wurzelbund")
var player := {"slot": slot, "team": slot / 3, "lane": LuneMap.LANES[slot % 3], "is_human": slot == human,
"name": "Du" if slot == human else BOT_NAMES[(slot + seed_value) % BOT_NAMES.size()], "bund": bund,
"gold": balance.start_gold, "roster": [], "ready": slot != human, "earned": 0, "spent": 0}
"gold": balance.start_gold, "roster": [], "ready": slot != human, "earned": 0, "spent": 0, "next_seq": 1}
player.draft = _snapshot(player)
player.undo = []
players.append(player)
@ -60,7 +59,7 @@ func setup(options: Dictionary = {}) -> void:
building_hp[definition.id] = float(definition.max_hp)
func _snapshot(player: Dictionary) -> Dictionary:
return {"gold": int(player.gold), "roster": (player.roster as Array).duplicate(true)}
return {"gold": int(player.gold), "roster": (player.roster as Array).duplicate(true), "next_seq": int(player.next_seq)}
func slot_of(team: int, lane: String) -> int:
return team * 3 + LuneMap.LANES.find(lane)
@ -111,8 +110,10 @@ func _apply(player: Dictionary, draft: Dictionary, command: Dictionary) -> Dicti
if not LuneMap.LANES.has(lane):
return _result(false, "Unbekannte Lane.")
draft.gold = int(draft.gold) - cost
roster.append({"uid": "p%d-%d" % [player.slot, _next_uid], "type_id": type_id, "lane": lane, "invested": cost, "fresh": true})
_next_uid += 1
# Per-player numbering keeps both teams' ids in the same order; seq sorts numerically.
var seq: int = int(draft.get("next_seq", player.next_seq))
draft.next_seq = seq + 1
roster.append({"uid": "p%d-%d" % [player.slot, seq], "seq": seq, "type_id": type_id, "lane": lane, "invested": cost, "fresh": true})
return _result(true, "%s gekauft." % DataCatalog.get_unit(type_id).display_name)
"upgrade":
var index: int = _find(roster, str(command.get("uid", "")))
@ -172,6 +173,7 @@ func begin_combat() -> bool:
for player: Dictionary in players:
player.spent = int(player.spent) + maxi(0, int(player.gold) - int(player.draft.gold))
player.gold = int(player.draft.gold)
player.next_seq = int(player.draft.get("next_seq", player.next_seq))
player.roster = (player.draft.roster as Array).duplicate(true)
for entry: Dictionary in player.roster:
entry.erase("fresh")
@ -220,7 +222,7 @@ func _spawn_armies() -> void:
entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
var ra: int = ROW_ORDER.find(role_of(a.type_id))
var rb: int = ROW_ORDER.find(role_of(b.type_id))
return str(a.uid) < str(b.uid) if ra == rb else ra < rb)
return int(a.seq) < int(b.seq) if ra == rb else ra < rb)
for entry: Dictionary in entries:
ordered.append([slot, entry])
for index: int in range(ordered.size()):
@ -301,8 +303,8 @@ func _split(gains: Array[int], team: int, amount: int) -> void:
for offset: int in range(3):
gains[team * 3 + offset] += share
for index: int in range(rest):
gains[team * 3 + (_reward_rotation + index) % 3] += 1
_reward_rotation = (_reward_rotation + rest) % 3
gains[team * 3 + (_reward_rotation[team] + index) % 3] += 1
_reward_rotation[team] = (_reward_rotation[team] + rest) % 3
func _alive_count() -> Array[int]:
var counts: Array[int] = [0, 0]
@ -314,9 +316,16 @@ func _alive_count() -> Array[int]:
func building_alive(id: String) -> bool:
return float(building_hp.get(id, 0.0)) > 0.0
func towers_alive(team: int, lane: String) -> int:
## Live state for displays: during combat the combat instance is authoritative.
func building_alive_now(id: String) -> bool:
if phase == Phase.COMBAT and combat != null and combat.buildings.has(id):
return (combat.buildings[id] as LuneUnit).alive
return building_alive(id)
func towers_alive(team: int, lane: String, live: bool = false) -> int:
var prefix: String = "blau" if team == 0 else "rot"
return int(building_alive("%s_%s_outer" % [prefix, lane])) + int(building_alive("%s_%s_inner" % [prefix, lane]))
var probe: Callable = building_alive_now if live else building_alive
return int(probe.call("%s_%s_outer" % [prefix, lane])) + int(probe.call("%s_%s_inner" % [prefix, lane]))
func core_hp(team: int) -> float:
return float(building_hp["blau_ancient" if team == 0 else "rot_ancient"])

View file

@ -7,6 +7,8 @@ const DAMAGE_MATRIX = [[1.30, 0.75, 1.00], [1.00, 1.30, 0.75], [0.75, 1.00, 1.30
const MOBILE_KINDS: Array[String] = ["troop", "hero", "monster", "champion", "boss"]
var id: int = 0
## Spawn index within the own team; mirrored units share it (used for fair staggering).
var team_index: int = 0
var kind: String = "troop"
var team: int = 0
var owner_slot: int = -1

View file

@ -0,0 +1,224 @@
extends Node3D
## One Champions of Luneraria match on screen: authoritative LuneMatch, the 3D world,
## unit views and the HUD. Offline, planning waits for the human; pause and speed are local.
signal exit_requested
signal rematch_requested
const World = preload("res://scripts/luneraria/view/lune_world.gd")
const UnitView = preload("res://scripts/luneraria/view/lune_unit_view.gd")
const CameraRig = preload("res://scripts/luneraria/view/lune_camera.gd")
const Hud = preload("res://scripts/luneraria/ui/lune_hud.gd")
const SPEEDS: Array[int] = [1, 2, 4]
var match_state: LuneMatch
var world: Node3D
var camera: Camera3D
var hud: Control
var human_slot: int = 1
var speed_index: int = 0
var paused: bool = false
var options: Dictionary = {}
var _units_root: Node3D
var _views: Dictionary = {}
var _accumulator: float = 0.0
var _last_phase: int = -1
var _preview: LuneCombat
var _time: float = 0.0
var _lane_cycle: int = 0
func start(setup_options: Dictionary) -> void:
options = setup_options
name = "LuneGame"
human_slot = int(options.get("human_slot", 1))
match_state = LuneMatch.new()
match_state.setup(options)
world = World.new()
add_child(world)
world.build(match_state.map)
_units_root = Node3D.new()
_units_root.name = "Units"
add_child(_units_root)
camera = CameraRig.new()
camera.reduced_motion = Preferences.reduced_motion
add_child(camera)
var layer := CanvasLayer.new()
add_child(layer)
hud = Hud.new()
hud.match_state = match_state
hud.human_slot = maxi(0, human_slot)
layer.add_child(hud)
hud.command_requested.connect(_on_command)
hud.undo_requested.connect(func() -> void: _feedback(match_state.undo(human_slot)))
hud.ready_requested.connect(_on_ready)
hud.starter_requested.connect(_starter)
hud.speed_requested.connect(func(_v: int) -> void: _cycle_speed())
hud.pause_requested.connect(_toggle_pause)
hud.lane_focus_requested.connect(focus_lane)
hud.map_focus_requested.connect(func(point: Vector3) -> void: camera.look_at_point(point))
hud.menu_requested.connect(func() -> void: exit_requested.emit())
hud.rematch_requested.connect(func() -> void: rematch_requested.emit())
if human_slot >= 0:
focus_lane(match_state.players[human_slot].lane, true)
else:
camera.overview(true)
_enter_planning()
func focus_lane(lane: String, instant: bool = false) -> void:
var team: int = int(match_state.players[maxi(0, human_slot)].team)
var path: LuneMap.LanePath = match_state.map.lanes[lane]
# Planning looks at the own deployment and outer tower, combat at the contested middle.
var fraction: float = .2 if match_state.phase == LuneMatch.Phase.PLANNING else .42
var point: Vector2 = match_state.map.lane_point(team, lane, path.length * fraction)
camera.look_at_point(Vector3(point.x, 0, point.y), 24.0, instant)
func _feedback(result: Dictionary) -> void:
hud.notify(str(result.message), not bool(result.ok))
Soundscape.play("place" if result.ok else "error")
_rebuild_preview()
func _on_command(command: Dictionary) -> void:
_feedback(match_state.submit(human_slot, command))
func _starter() -> void:
var player: Dictionary = match_state.players[human_slot]
var ids: Array[String] = match_state.base_ids(player.bund)
for index: int in [1, 0, 2]:
var result: Dictionary = match_state.submit(human_slot, {"type": "buy", "type_id": ids[index]})
if not result.ok:
_feedback(result)
return
_feedback({"ok": true, "message": "Startaufstellung: Front, Nahkampf und Fernkampf gekauft."})
func _on_ready() -> void:
if match_state.phase != LuneMatch.Phase.PLANNING:
return
if human_slot >= 0:
match_state.set_ready(human_slot)
if match_state.all_ready():
_start_combat()
func _enter_planning() -> void:
for slot: int in range(6):
if slot != human_slot:
LuneBot.plan(match_state, slot)
_rebuild_preview()
if human_slot < 0:
_start_combat()
func _start_combat() -> void:
_clear_views()
_preview = null
match_state.begin_combat()
_accumulator = 0.0
Soundscape.play("wave")
func _clear_views() -> void:
for view: Node in _units_root.get_children():
view.free()
_views.clear()
## Shows the human player's current draft army in its formation during planning.
func _rebuild_preview() -> void:
if match_state.phase != LuneMatch.Phase.PLANNING or human_slot < 0:
return
_clear_views()
_preview = LuneCombat.new(match_state.map, match_state.balance, match_state.round_number)
var player: Dictionary = match_state.players[human_slot]
var per_lane: Dictionary = {}
for entry: Dictionary in player.draft.roster:
var lane: String = str(entry.lane)
var index: int = int(per_lane.get(lane, 0))
per_lane[lane] = index + 1
var unit: LuneUnit = _preview.spawn("troop", int(player.team), lane, DataCatalog.get_unit(entry.type_id), maxf(.4, 7.2 - float(index / 4) * 1.5), (float(index % 4) - 1.5) * 1.2, human_slot, str(entry.uid))
_add_view(unit)
func _add_view(unit: LuneUnit) -> void:
var view: Node3D = UnitView.new()
_units_root.add_child(view)
view.bind(unit)
_views[unit.id] = view
func _cycle_speed() -> void:
speed_index = (speed_index + 1) % SPEEDS.size()
hud.set_speed_label(SPEEDS[speed_index], paused)
func _toggle_pause() -> void:
if match_state.phase == LuneMatch.Phase.COMBAT:
paused = not paused
hud.set_speed_label(SPEEDS[speed_index], paused)
func _process(delta: float) -> void:
_time += delta
var step: float = match_state.balance.sim_step
if match_state.phase == LuneMatch.Phase.COMBAT and not paused:
_accumulator += minf(delta, .1) * SPEEDS[speed_index]
while _accumulator >= step and match_state.phase == LuneMatch.Phase.COMBAT:
match_state.step_combat()
_accumulator -= step
if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null:
for unit: LuneUnit in match_state.combat.units:
if unit.is_mobile() and not _views.has(unit.id):
_add_view(unit)
if match_state.phase != _last_phase:
var previous: int = _last_phase
_last_phase = match_state.phase
if previous == LuneMatch.Phase.COMBAT:
_after_combat()
var alpha: float = _accumulator / step if match_state.phase == LuneMatch.Phase.COMBAT else 1.0
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)
hud.minimap.camera_focus = camera.focus
hud.minimap.camera_span = camera.zoom
func _after_combat() -> void:
if match_state.phase == LuneMatch.Phase.OVER:
paused = false
var won: bool = match_state.winner == int(match_state.players[maxi(0, human_slot)].team)
Soundscape.play("victory" if won else ("settle" if match_state.winner == LuneMatch.DRAW else "defeat"))
camera.look_at_point(Vector3(match_state.map.core_position(0 if match_state.combat.destroyed_cores.has(0) else 1).x, 0, match_state.map.core_position(0 if match_state.combat.destroyed_cores.has(0) else 1).y), 26.0)
return
Soundscape.play("settle")
_enter_planning()
var input_blocked: Callable = func() -> bool: return false
func _unhandled_input(event: InputEvent) -> void:
if not input_blocked.call() and camera.handle_world_input(event):
get_viewport().set_input_as_handled()
## Game keys run before the GUI so Space/Tab keep their meaning even when a button has focus.
func _input(event: InputEvent) -> void:
if input_blocked.call() or not (event is InputEventKey and event.pressed and not event.echo):
return
var handled: bool = true
match event.keycode:
KEY_SPACE:
if match_state.phase == LuneMatch.Phase.PLANNING:
_on_ready()
else:
_toggle_pause()
KEY_P:
_toggle_pause()
KEY_TAB:
_lane_cycle = (_lane_cycle + 1) % 3
focus_lane(LuneMap.LANES[_lane_cycle])
KEY_O:
camera.overview()
KEY_1, KEY_2, KEY_3, KEY_4, KEY_5:
if match_state.phase == LuneMatch.Phase.PLANNING and human_slot >= 0:
var ids: Array[String] = match_state.base_ids(match_state.players[human_slot].bund)
_on_command({"type": "buy", "type_id": ids[event.keycode - KEY_1]})
_:
handled = false
if handled:
get_viewport().set_input_as_handled()

View file

@ -0,0 +1 @@
uid://60nhdgkc0ww4

View file

@ -0,0 +1,92 @@
extends Node
## Entry scene of Champions of Luneraria: title menu, match, return, rematch.
## Legacy QA switches (--smoke-test, --faction-test, --expedition-test) open the archived
## Saumwacht scene unchanged, so its technical regressions keep running.
const Game = preload("res://scripts/luneraria/lune_game.gd")
const Menu = preload("res://scripts/luneraria/ui/lune_menu.gd")
const LEGACY_SWITCHES: Array[String] = ["--smoke-test", "--faction-test", "--expedition-test", "--ringborke", "--legacy"]
var menu: MainMenu
var game: Node3D
var settings: Control
var _layer: CanvasLayer
var _last_options: Dictionary = {}
func _ready() -> void:
for arg: String in OS.get_cmdline_user_args():
if arg in LEGACY_SWITCHES:
get_tree().change_scene_to_file.call_deferred("res://scenes/main.tscn")
return
DisplayServer.window_set_title("Champions of Luneraria")
get_tree().auto_accept_quit = false
get_tree().root.close_requested.connect(Soundscape.quit_game)
_layer = CanvasLayer.new()
_layer.layer = 10
add_child(_layer)
menu = Menu.new()
_layer.add_child(menu)
menu.start_requested.connect(func() -> void: start_match({"seed": int(Time.get_unix_time_from_system()) % 100000}))
menu.quit_requested.connect(func() -> void: Soundscape.quit_game())
menu.settings_requested.connect(toggle_settings)
settings = LuneSettings.new()
_layer.add_child(settings)
settings.visible = false
settings.closed.connect(toggle_settings)
menu.enter()
func start_match(options: Dictionary) -> void:
var own_team_slots: Array[int] = [0, 1, 2]
var human: int = own_team_slots[LuneMap.LANES.find(menu.lane)] if menu.lane in LuneMap.LANES else 1
var bunds: Array = []
var rng := RandomNumberGenerator.new()
rng.seed = int(options.get("seed", 1))
for slot: int in range(6):
bunds.append(menu.bund if slot == human else DataCatalog.FACTIONS[rng.randi() % DataCatalog.FACTIONS.size()])
_last_options = {"seed": int(options.get("seed", 1)), "human_slot": human, "bunds": bunds}
_launch(_last_options)
func _launch(options: Dictionary) -> void:
# The rematch button lives inside the old match; detach it now, free it after the signal.
if is_instance_valid(game):
remove_child(game)
game.queue_free()
game = Game.new()
add_child(game)
game.input_blocked = func() -> bool: return menu.visible or settings.visible
game.start(options)
game.exit_requested.connect(return_to_menu)
game.rematch_requested.connect(func() -> void:
_last_options.seed = int(_last_options.seed) + 1
_launch(_last_options))
game.hud.settings_requested.connect(toggle_settings)
menu.leave()
func return_to_menu() -> void:
if is_instance_valid(game):
remove_child(game)
game.queue_free()
game = null
menu._locked = false
menu.pending_screen = MainMenu.Screen.TITLE
menu.enter()
func toggle_settings() -> void:
settings.visible = not settings.visible
if is_instance_valid(game):
game.paused = settings.visible or game.paused
if settings.visible:
settings.sync()
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo:
if event.keycode == KEY_F1 or (event.keycode == KEY_ESCAPE and settings.visible):
toggle_settings()
get_viewport().set_input_as_handled()
elif event.keycode in [KEY_M, KEY_F11]:
if event.keycode == KEY_M:
Preferences.muted = not Preferences.muted
else:
Preferences.fullscreen = not Preferences.fullscreen
Preferences.commit()
get_viewport().set_input_as_handled()

View file

@ -0,0 +1 @@
uid://ciohupnds7xrb

View file

@ -0,0 +1,424 @@
extends Control
## Champions of Luneraria HUD. Presentation only: it reads LuneMatch and sends the human
## player's commands through the owning LuneGame; UI state never creates ownership.
signal command_requested(command: Dictionary)
signal undo_requested
signal ready_requested
signal starter_requested
signal speed_requested(value: int)
signal pause_requested
signal lane_focus_requested(lane: String)
signal map_focus_requested(point: Vector3)
signal menu_requested
signal rematch_requested
signal settings_requested
const Minimap = preload("res://scripts/luneraria/ui/lune_minimap.gd")
const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55")]
const TEAM_NAMES: Array[String] = ["Blau", "Rot"]
const ROLE_NAMES: Dictionary = {"front": "Front", "melee": "Nahkampf", "ranged": "Fernkampf", "splash": "Fläche", "support": "Unterstützung"}
var match_state: LuneMatch
var human_slot: int = 1
var minimap: Control
var _phase: Label
var _round: Label
var _core_bars: Array[ProgressBar] = []
var _core_labels: Array[Label] = []
var _gold: Label
var _income: Label
var _identity: Label
var _pause: Button
var _speed: Button
var _lane_rows: Dictionary = {}
var _team_list: Label
var _plan_panel: PanelContainer
var _shop: GridContainer
var _shop_buttons: Dictionary = {}
var _roster: VBoxContainer
var _roster_title: Label
var _ready_button: Button
var _starter: Button
var _undo: Button
var _combat_panel: PanelContainer
var _combat_info: Label
var _report: Label
var _toast: Label
var _toast_left: float = 0.0
var _over: Control
var _over_title: Label
var _over_body: Label
var _roster_signature: String = "@"
func _ready() -> void:
theme = SaumwachtTheme.shared()
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
_top_bar()
_lane_panel()
_plan_side()
_bottom()
_game_over()
func _panel(variation: String = "CaptionPanel") -> PanelContainer:
var panel := PanelContainer.new()
panel.theme_type_variation = variation
panel.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(panel)
return panel
func _label(text_value: String, variation: String, parent: Node, size: int = 0) -> Label:
var label := Label.new()
label.text = text_value
label.theme_type_variation = variation
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
if size > 0:
label.add_theme_font_size_override("font_size", size)
parent.add_child(label)
return label
func _button(text_value: String, parent: Node, action: Callable, variation: String = "Button") -> Button:
var button := Button.new()
button.text = text_value
button.theme_type_variation = variation
button.focus_mode = Control.FOCUS_ALL
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
button.pressed.connect(action)
parent.add_child(button)
return button
func _bar(color: Color, parent: Node) -> ProgressBar:
var bar := ProgressBar.new()
bar.show_percentage = false
bar.custom_minimum_size = Vector2(190, 10)
var back := StyleBoxFlat.new()
back.bg_color = Color(0, 0, 0, .55)
back.border_color = Color("5c4a33")
back.set_border_width_all(1)
var fill := StyleBoxFlat.new()
fill.bg_color = color
bar.add_theme_stylebox_override("background", back)
bar.add_theme_stylebox_override("fill", fill)
parent.add_child(bar)
return bar
func _top_bar() -> void:
var top := _panel()
top.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
top.offset_left = 16
top.offset_right = -16
top.offset_top = 12
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 22)
top.add_child(row)
var title := VBoxContainer.new()
title.add_theme_constant_override("separation", 0)
row.add_child(title)
_label("CHAMPIONS OF LUNERARIA", "EyebrowLabel", title)
_phase = _label("PLANUNG", "SectionLabel", title, 20)
_round = _label("Welle 1", "MutedLabel", title)
for team: int in range(2):
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 3)
row.add_child(box)
_core_labels.append(_label("Ancient %s" % TEAM_NAMES[team], "MutedLabel", box, 15))
_core_bars.append(_bar(TEAM_COLORS[team], box))
var economy := VBoxContainer.new()
economy.add_theme_constant_override("separation", 0)
row.add_child(economy)
_gold = _label("0 Gold", "CardTitleLabel", economy, 24)
_income = _label("", "MutedLabel", economy, 15)
_identity = _label("", "MutedLabel", row, 15)
_identity.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_identity.size_flags_vertical = Control.SIZE_SHRINK_CENTER
_pause = _button("Pause · P", row, func() -> void: pause_requested.emit())
_speed = _button("1×", row, func() -> void: speed_requested.emit(0))
_button("Optionen · F1", row, func() -> void: settings_requested.emit())
_button("Menü", row, func() -> void: menu_requested.emit(), "QuietButton")
func _lane_panel() -> void:
var panel := _panel()
panel.position = Vector2(16, 112)
panel.custom_minimum_size = Vector2(300, 0)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 6)
panel.add_child(column)
_label("LANES · TÜRME", "SectionLabel", column)
for lane: String in LuneMap.LANES:
var button := _button("", column, func() -> void: lane_focus_requested.emit(lane), "ChoiceButton")
button.custom_minimum_size = Vector2(0, 52)
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.tooltip_text = "Kamera auf %s" % LuneMap.LANE_NAMES[lane]
_lane_rows[lane] = button
_team_list = _label("", "MutedLabel", column, 14)
func _plan_side() -> void:
_plan_panel = _panel("MenuPanel")
_plan_panel.anchor_left = 1.0
_plan_panel.anchor_right = 1.0
_plan_panel.anchor_bottom = 1.0
_plan_panel.offset_left = -440
_plan_panel.offset_right = -16
_plan_panel.offset_top = 112
_plan_panel.offset_bottom = -270
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 8)
_plan_panel.add_child(column)
_label("TRUPPEN KAUFEN · 1–5", "SectionLabel", column)
_shop = GridContainer.new()
_shop.columns = 5
_shop.add_theme_constant_override("h_separation", 6)
column.add_child(_shop)
_roster_title = _label("DEINE ARMEE", "SectionLabel", column)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
column.add_child(scroll)
_roster = VBoxContainer.new()
_roster.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_roster.add_theme_constant_override("separation", 4)
scroll.add_child(_roster)
var actions := HBoxContainer.new()
column.add_child(actions)
_starter = _button("Startaufstellung", actions, func() -> void: starter_requested.emit())
_starter.tooltip_text = "Kauft Front, Nahkampf und Fernkampf deines Bundes für deine Lane."
_undo = _button("Rückgängig", actions, func() -> void: undo_requested.emit())
_ready_button = _button("BEREIT · KAMPF ›", column, func() -> void: ready_requested.emit(), "PrimaryButton")
_ready_button.custom_minimum_size.y = 58
_ready_button.tooltip_text = "Leertaste · Befehle werden jetzt verbindlich übernommen."
_combat_panel = _panel()
_combat_panel.anchor_left = 1.0
_combat_panel.anchor_right = 1.0
_combat_panel.offset_left = -440
_combat_panel.offset_right = -16
_combat_panel.offset_top = 112
_combat_info = _label("", "Label", _combat_panel, 16)
func _bottom() -> void:
var report_panel := _panel()
report_panel.anchor_top = 1.0
report_panel.anchor_bottom = 1.0
report_panel.offset_left = 16
report_panel.offset_top = -120
report_panel.offset_bottom = -16
report_panel.offset_right = 760
var column := VBoxContainer.new()
report_panel.add_child(column)
_report = _label("Planung: Kaufe Truppen für deine Lane und drücke Bereit.", "Label", column, 16)
_report.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_toast = _label("", "MutedLabel", column, 15)
var map_panel := _panel()
map_panel.anchor_left = 1.0
map_panel.anchor_right = 1.0
map_panel.anchor_top = 1.0
map_panel.anchor_bottom = 1.0
map_panel.offset_left = -270
map_panel.offset_right = -16
map_panel.offset_top = -258
map_panel.offset_bottom = -16
minimap = Minimap.new()
map_panel.add_child(minimap)
minimap.focus_requested.connect(func(point: Vector3) -> void: map_focus_requested.emit(point))
func _game_over() -> void:
_over = ColorRect.new()
(_over as ColorRect).color = Color(.02, .03, .04, .82)
_over.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_over.mouse_filter = Control.MOUSE_FILTER_STOP
_over.visible = false
add_child(_over)
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_over.add_child(center)
var panel := PanelContainer.new()
panel.theme_type_variation = "MenuPanel"
panel.custom_minimum_size = Vector2(620, 0)
center.add_child(panel)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 14)
panel.add_child(column)
_over_title = _label("", "TitleLabel", column, 60)
_over_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_over_body = _label("", "BodyLabel", column)
_over_body.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_over_body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 12)
column.add_child(row)
_button("NEUE PARTIE", row, func() -> void: rematch_requested.emit(), "PrimaryButton")
_button("Hauptmenü", row, func() -> void: menu_requested.emit())
# ---------------------------------------------------------------- refresh
func notify(message: String, error: bool = false) -> void:
_toast.text = message
_toast.add_theme_color_override("font_color", Color("f0a888") if error else Color("9fe0c8"))
_toast_left = 5.0
func _process(delta: float) -> void:
_toast_left -= delta
if _toast_left <= 0.0:
_toast.text = "WASD / mittlere Maustaste: Kamera · Mausrad: Zoom · Tab: Lanes · Minimap klicken"
_toast.remove_theme_color_override("font_color")
if match_state != null:
refresh()
func refresh() -> void:
var player: Dictionary = match_state.players[human_slot]
var planning: bool = match_state.phase == LuneMatch.Phase.PLANNING
var combat: bool = match_state.phase == LuneMatch.Phase.COMBAT
var phase_name: String = "PLANUNG" if planning else ("KAMPF" if combat else "PARTIE BEENDET")
_phase.text = phase_name
_round.text = "Welle %d%s" % [match_state.round_number, (" · %02d:%02d" % [int(match_state.combat.elapsed) / 60, int(match_state.combat.elapsed) % 60]) if combat else ""]
for team: int in range(2):
var hp: float = match_state.core_hp(team)
if combat and match_state.combat.buildings.has("%s_ancient" % ("blau" if team == 0 else "rot")):
hp = (match_state.combat.buildings["%s_ancient" % ("blau" if team == 0 else "rot")] as LuneUnit).hp
_core_bars[team].max_value = match_state.balance.core_hp
_core_bars[team].value = hp
_core_labels[team].text = "Ancient %s · %d" % [TEAM_NAMES[team], ceili(hp)]
_gold.text = "%d Gold" % match_state.gold_of(human_slot)
_income.text = "+%d pro Runde · Armee %d / %d" % [match_state.balance.round_income(match_state.round_number), (player.draft.roster as Array).size() if planning else (player.roster as Array).size(), match_state.balance.supply_cap]
_identity.text = "%s · %s-Lane · %s" % [TEAM_NAMES[int(player.team)], LuneMap.LANE_NAMES[player.lane], DataCatalog.FACTION_NAMES[player.bund]]
_pause.visible = combat
_speed.visible = combat
for lane: String in LuneMap.LANES:
var button: Button = _lane_rows[lane]
var own: String = "●".repeat(match_state.towers_alive(0, lane, true)) + "○".repeat(2 - match_state.towers_alive(0, lane, true))
var rival: String = "●".repeat(match_state.towers_alive(1, lane, true)) + "○".repeat(2 - match_state.towers_alive(1, lane, true))
var blue_owner: String = match_state.players[match_state.slot_of(0, lane)].name
var red_owner: String = match_state.players[match_state.slot_of(1, lane)].name
button.text = "%s Blau %s Rot %s\n%s · %s" % [LuneMap.LANE_NAMES[lane].to_upper(), own, rival, blue_owner, red_owner]
var team_lines: PackedStringArray = []
for team: int in range(2):
var names: PackedStringArray = []
for slot: int in range(team * 3, team * 3 + 3):
names.append(match_state.players[slot].name)
team_lines.append("%s: %s · Basistürme %d/2" % [TEAM_NAMES[team], ", ".join(names), int(match_state.building_alive_now("%s_base_1" % ("blau" if team == 0 else "rot"))) + int(match_state.building_alive_now("%s_base_2" % ("blau" if team == 0 else "rot")))])
_team_list.text = "\n".join(team_lines)
_plan_panel.visible = planning
_combat_panel.visible = combat
if planning:
_refresh_plan(player)
elif combat:
var lines: PackedStringArray = ["KAMPF LÄUFT · automatisch"]
for lane: String in LuneMap.LANES:
var counts: Array[int] = [0, 0]
for unit: LuneUnit in match_state.combat.units:
if unit.alive and unit.is_mobile() and unit.lane == lane:
counts[unit.team] += 1
lines.append("%s Blau %d · Rot %d Einheiten" % [LuneMap.LANE_NAMES[lane], counts[0], counts[1]])
lines.append("Leertaste/P: Pause · Tempo 1×/2×/4×")
_combat_info.text = "\n".join(lines)
if combat:
_report.text = "Welle %d läuft. Armeen und Monsterwellen rücken über alle drei Lanes vor; Türme fallen in fester Reihenfolge." % match_state.round_number
elif planning and match_state.last_settlement.is_empty():
_report.text = "Planung: Kaufe Truppen für deine Lane, teile sie auf Top, Mid oder Bottom ein und drücke Bereit."
if not match_state.last_settlement.is_empty() and planning:
var s: Dictionary = match_state.last_settlement
var lost: PackedStringArray = []
for id: String in s.buildings_lost:
lost.append(_building_name(id))
_report.text = "Welle %d abgeschlossen (%s, %ds) · dein Ertrag +%d Gold (Kills %d, Einkommen %d)%s" % [s.round, _reason(s.end_reason), int(s.combat_time), int(s.gains[human_slot]) + int(s.income), int(s.gains[human_slot]), int(s.income), ("\nGefallen: " + ", ".join(lost)) if not lost.is_empty() else ""]
_over.visible = match_state.phase == LuneMatch.Phase.OVER
if _over.visible:
var own_team: int = int(player.team)
_over_title.text = "UNENTSCHIEDEN" if match_state.winner == LuneMatch.DRAW else ("SIEG" if match_state.winner == own_team else "NIEDERLAGE")
_over_body.text = "%s\nNach %d Wellen." % [match_state.end_note, match_state.round_number]
minimap.match_state = match_state
minimap.queue_redraw()
func _reason(reason: String) -> String:
return {"cleared": "alle Einheiten gefallen", "timeout": "Zeitfenster abgelaufen", "core": "Ancient zerstört"}.get(reason, reason)
func _building_name(id: String) -> String:
var parts: PackedStringArray = id.split("_")
var team: String = "Blau" if parts[0] == "blau" else "Rot"
if parts[1] == "ancient":
return "Ancient " + team
if parts[1] == "base":
return "Basisturm %s %s" % [team, parts[2]]
return "%s %s-%s" % [team, LuneMap.LANE_NAMES[parts[1]], "Außenturm" if parts[2] == "outer" else "Innenturm"]
func _refresh_plan(player: Dictionary) -> void:
var draft: Dictionary = player.draft
var ids: Array[String] = match_state.base_ids(player.bund)
if _shop_buttons.is_empty() or not _shop_buttons.has(ids[0]):
for child: Node in _shop.get_children():
child.free()
_shop_buttons.clear()
for index: int in range(ids.size()):
var id: String = ids[index]
var data: Dictionary = DataCatalog.get_unit(id)
var button := Button.new()
button.icon = AssetLibrary.icon(id)
button.expand_icon = true
button.custom_minimum_size = Vector2(76, 96)
button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
button.vertical_icon_alignment = VERTICAL_ALIGNMENT_TOP
button.text = "%d G" % int(data.cost)
button.focus_mode = Control.FOCUS_ALL
button.tooltip_text = "%s [%d]\n%s · %d HP · %d Schaden/s · %.1f m\n%s" % [data.display_name, index + 1, ROLE_NAMES[match_state.role_of(id)], data.max_hp, data.damage, data.attack_range, data.ability_description]
button.pressed.connect(func() -> void: command_requested.emit({"type": "buy", "type_id": id}))
_shop.add_child(button)
_shop_buttons[id] = button
for id: String in _shop_buttons:
var cost: int = int(DataCatalog.get_unit(id).cost)
(_shop_buttons[id] as Button).disabled = int(draft.gold) < cost or (draft.roster as Array).size() >= match_state.balance.supply_cap
_undo.disabled = (player.undo as Array).is_empty()
_starter.disabled = not (draft.roster as Array).is_empty()
_ready_button.disabled = bool(player.ready)
_ready_button.text = "BEREIT · KAMPF ›" if not player.ready else "WARTE AUF ANDERE …"
var signature: String = JSON.stringify(draft)
if signature == _roster_signature:
return
_roster_signature = signature
for child: Node in _roster.get_children():
child.free()
_roster_title.text = "DEINE ARMEE · %d / %d" % [(draft.roster as Array).size(), match_state.balance.supply_cap]
if (draft.roster as Array).is_empty():
_label("Noch keine Truppen. Kaufe oben oder nutze die Startaufstellung.", "MutedLabel", _roster, 15).autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
for entry: Dictionary in draft.roster:
var data: Dictionary = DataCatalog.get_unit(entry.type_id)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 6)
_roster.add_child(row)
var icon := TextureRect.new()
icon.texture = AssetLibrary.icon(entry.type_id)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon.custom_minimum_size = Vector2(40, 40)
row.add_child(icon)
var name_label := _label(str(data.display_name), "Label", row, 15)
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
name_label.clip_text = true
for lane: String in LuneMap.LANES:
var lane_button := Button.new()
lane_button.text = LuneMap.LANE_NAMES[lane].left(1)
lane_button.toggle_mode = true
lane_button.button_pressed = entry.lane == lane
lane_button.theme_type_variation = "ChoiceButton"
lane_button.custom_minimum_size = Vector2(34, 34)
lane_button.tooltip_text = "Auf %s einteilen" % LuneMap.LANE_NAMES[lane]
var uid: String = str(entry.uid)
lane_button.pressed.connect(func() -> void: command_requested.emit({"type": "assign", "uid": uid, "lane": lane}))
row.add_child(lane_button)
var upgrade_id: String = str(data.get("upgrade_id", ""))
var uid_value: String = str(entry.uid)
var up := Button.new()
up.text = "↑ %d" % int(DataCatalog.get_unit(upgrade_id).cost) if not upgrade_id.is_empty() else "max"
up.disabled = upgrade_id.is_empty() or int(draft.gold) < int(DataCatalog.get_unit(upgrade_id).get("cost", 0))
up.tooltip_text = ("Aufwerten zu %s" % DataCatalog.get_unit(upgrade_id).display_name) if not upgrade_id.is_empty() else "Vollständig aufgewertet"
up.pressed.connect(func() -> void: command_requested.emit({"type": "upgrade", "uid": uid_value}))
row.add_child(up)
var sell := Button.new()
var refund: int = int(entry.invested) if bool(entry.get("fresh", false)) else floori(float(entry.invested) * match_state.balance.sell_refund)
sell.text = "×"
sell.tooltip_text = "Zurückgeben für %d Gold" % refund
sell.pressed.connect(func() -> void: command_requested.emit({"type": "sell", "uid": uid_value}))
row.add_child(sell)
func set_speed_label(value: int, paused: bool) -> void:
_speed.text = "%d×" % value
_pause.text = "Weiter · P" if paused else "Pause · P"

View file

@ -0,0 +1 @@
uid://d0qt7wmu3odob

View file

@ -0,0 +1,81 @@
extends MainMenu
## The Phase 20A menu shell retitled for Champions of Luneraria. Match options here are
## the new mode's options (own Bund, lane, seed); they never touch the legacy GameState.
var bund: String = "wurzelbund"
var lane: String = "mid"
var lane_choice: ChoiceGroup
func _ready() -> void:
super._ready()
title_label.text = "CHAMPIONS"
subtitle_label.text = "OF LUNERARIA"
subtitle_label.theme_type_variation = "TitleLabel"
subtitle_label.add_theme_font_size_override("font_size", 54)
title_label.add_theme_font_size_override("font_size", 64)
var tagline := Label.new()
tagline.text = "DREI LANES · 3 GEGEN 3 · AUTOBATTLER"
tagline.theme_type_variation = "SubtitleLabel"
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" % str(ProjectSettings.get_setting("application/config/version", ""))
_title_caption.text = "Glutpakt und Wurzelbund an den Ufern der Grünen Kluft"
_retarget_setup()
func _retarget_setup() -> void:
# Replace the Saumwacht-only options (match length, rival, AI level, timed build).
faction_choice.item_selected.disconnect(faction_choice.item_selected.get_connections()[0].callable)
faction_choice.item_selected.connect(func(index: int) -> void:
bund = DataCatalog.FACTIONS[index]
sync())
var body: VBoxContainer = mode_choice.get_parent()
var at: int = mode_choice.get_index() - 1
for node: Node in [mode_choice, length_summary, _advanced_button.get_parent(), _advanced]:
node.visible = false
body.get_child(at).visible = false
var heading := Label.new()
heading.text = "DEINE LANE"
heading.theme_type_variation = "SectionLabel"
body.add_child(heading)
body.move_child(heading, at)
lane_choice = ChoiceGroup.new(true)
lane_choice.add_option("Top", "oberer Korridor")
lane_choice.add_option("Mid", "direkter Weg")
lane_choice.add_option("Bottom", "unterer Korridor")
body.add_child(lane_choice)
body.move_child(lane_choice, at + 1)
lane_choice.item_selected.connect(func(index: int) -> void:
lane = LuneMap.LANES[index]
sync())
var rules := Label.new()
rules.theme_type_variation = "MutedLabel"
rules.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
rules.text = "Team Blau (Südwest) mit zwei Bot-Verbündeten gegen drei Bots in Team Rot. Kein Rundenlimit: Es gewinnt, wer den gegnerischen Ancient zerstört."
body.add_child(rules)
body.move_child(rules, at + 2)
func sync() -> void:
super.sync()
if lane_choice == null:
return
faction_choice.select(DataCatalog.FACTIONS.find(bund))
lane_choice.select(LuneMap.LANES.find(lane))
_style_heading.text = "SPIELWEISE · " + DataCatalog.FACTION_NAMES[bund].to_upper()
var lines: PackedStringArray = []
for fact: String in FactionChoice.PROFILES[bund].facts:
lines.append("• " + fact)
_style_facts.text = "\n".join(lines)
_timed_hint.text = "Planung wartet auf dich · Leertaste startet den Kampf"
var names: PackedStringArray = []
for entry: Array in Stage.SETUPS[bund].figures:
names.append(str(DataCatalog.get_unit(entry[0]).display_name))
_setup_caption.text = "IM BILD " + " · ".join(names)
if screen == Screen.SETUP and _active and stage.current_shot() != bund:
stage.set_shot_named(bund, reduced_motion)
func _setup_shot() -> String:
return bund
func _on_title_primary() -> void:
show_screen(Screen.SETUP)

View file

@ -0,0 +1 @@
uid://dfkwcwigd7gci

View file

@ -0,0 +1,66 @@
extends Control
## Minimap drawn from the authoritative match state. Click or drag to move the camera.
signal focus_requested(point: Vector3)
const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55")]
var match_state: LuneMatch
var camera_focus := Vector3.ZERO
var camera_span: float = 34.0
func _ready() -> void:
custom_minimum_size = Vector2(236, 236)
mouse_filter = Control.MOUSE_FILTER_STOP
focus_mode = Control.FOCUS_NONE
tooltip_text = "Minimap · Klicken oder ziehen, um die Kamera zu bewegen"
func to_map(v: Vector2) -> Vector2:
var extent: float = LuneMap.HALF_EXTENT + 4.0
return (v + Vector2(extent, extent)) / (extent * 2.0) * size
func to_world(p: Vector2) -> Vector3:
var extent: float = LuneMap.HALF_EXTENT + 4.0
var v: Vector2 = p / size * extent * 2.0 - Vector2(extent, extent)
return Vector3(v.x, 0, v.y)
func _gui_input(event: InputEvent) -> void:
if (event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT) or (event is InputEventMouseMotion and event.button_mask & MOUSE_BUTTON_MASK_LEFT):
focus_requested.emit(to_world(event.position))
accept_event()
func _draw() -> void:
draw_rect(Rect2(Vector2.ZERO, size), Color(.04, .06, .06, .92))
draw_rect(Rect2(Vector2.ZERO, size), Color("5c4a33"), false, 1.5)
if match_state == null:
return
var a: Vector2 = to_map(Vector2(-40, -40))
var b: Vector2 = to_map(Vector2(40, 40))
draw_line(a, b, Color(.12, .38, .42, .8), 7.0)
for lane: String in LuneMap.LANES:
var path: LuneMap.LanePath = match_state.map.lanes[lane]
var points := PackedVector2Array()
for p: Vector2 in path.points:
points.append(to_map(p))
draw_polyline(points, Color(.62, .56, .42), 4.0, true)
var grotto: Vector2 = to_map(LuneMap.GROTTO)
draw_circle(grotto, 5.0, Color(.55, .35, .75, .85))
var hp: Dictionary = {}
if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null:
for unit: LuneUnit in match_state.combat.units:
if unit.is_mobile() and unit.alive:
draw_circle(to_map(unit.position), 1.8 if unit.kind == "monster" else 2.4, TEAM_COLORS[unit.team].lightened(.2 if unit.kind == "monster" else 0.0))
for definition: Dictionary in match_state.map.buildings:
var alive: bool = match_state.building_alive(definition.id)
if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null and match_state.combat.buildings.has(definition.id):
alive = (match_state.combat.buildings[definition.id] as LuneUnit).alive
var at: Vector2 = to_map(definition.position)
var radius: float = 6.5 if definition.tier == "core" else 4.0
var color: Color = TEAM_COLORS[int(definition.team)]
if alive:
draw_circle(at, radius, color)
draw_arc(at, radius, 0, TAU, 16, Color(1, 1, 1, .6), 1.0)
else:
draw_arc(at, radius, 0, TAU, 16, color.darkened(.3), 1.5)
draw_line(at - Vector2(radius, radius) * .6, at + Vector2(radius, radius) * .6, color.darkened(.3), 1.5)
var center: Vector2 = to_map(Vector2(camera_focus.x, camera_focus.z))
var span: float = camera_span / ((LuneMap.HALF_EXTENT + 4.0) * 2.0) * size.x
draw_rect(Rect2(center - Vector2(span * .9, span * .55), Vector2(span * 1.8, span * 1.1)), Color(1, 1, 1, .55), false, 1.2)

View file

@ -0,0 +1 @@
uid://cxf4d7s5ts31s

View file

@ -0,0 +1,68 @@
class_name LuneSettings
extends ColorRect
## Options and controls for Champions of Luneraria; the same Preferences as before.
signal closed
var _volume: HSlider
var _checks: Dictionary = {}
func _ready() -> void:
theme = SaumwachtTheme.shared()
color = Color(.02, .03, .04, .9)
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(center)
var panel := PanelContainer.new()
panel.theme_type_variation = "MenuPanel"
panel.custom_minimum_size.x = 760
center.add_child(panel)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 12)
panel.add_child(column)
var heading := _label("Optionen & Steuerung", "HeadingLabel", column)
heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_label("Ein laufender Kampf pausiert, solange dieses Fenster geöffnet ist.", "MutedLabel", column).horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_label("LAUTSTÄRKE", "SectionLabel", column)
_volume = HSlider.new()
_volume.max_value = 100
_volume.custom_minimum_size.y = 28
column.add_child(_volume)
_volume.value_changed.connect(func(value: float) -> void:
Preferences.volume = value / 100.0
Preferences.commit())
var grid := GridContainer.new()
grid.columns = 2
grid.add_theme_constant_override("h_separation", 28)
column.add_child(grid)
for entry: Array in [["muted", "Ton stumm [M]"], ["fullscreen", "Vollbild [F11]"], ["reduced_motion", "Bewegung reduzieren"]]:
var check := CheckButton.new()
check.text = entry[1]
grid.add_child(check)
var key: String = entry[0]
check.toggled.connect(func(value: bool) -> void:
Preferences.set(key, value)
Preferences.commit())
_checks[key] = check
column.add_child(HSeparator.new())
_label("STEUERUNG", "SectionLabel", column)
_label("Planung: 1–5 Truppe kaufen · Lane-Knöpfe T/M/B teilen ein · Leertaste Bereit\nKampf: Leertaste oder P Pause · Tempo 1×/2×/4× · Kämpfe laufen automatisch\nKamera: WASD / Pfeile / mittlere Maustaste · Mausrad Zoom · Tab nächste Lane · O Übersicht\nMinimap anklicken springt dorthin · F1 Optionen · M Ton · F11 Vollbild", "Label", column)
var back := Button.new()
back.text = "ZURÜCK [Esc]"
back.theme_type_variation = "PrimaryButton"
back.pressed.connect(func() -> void: closed.emit())
column.add_child(back)
func _label(text_value: String, variation: String, parent: Node) -> Label:
var label := Label.new()
label.text = text_value
label.theme_type_variation = variation
parent.add_child(label)
return label
func sync() -> void:
_volume.set_value_no_signal(Preferences.volume * 100.0)
for key: String in _checks:
(_checks[key] as CheckButton).set_pressed_no_signal(bool(Preferences.get(key)))

View file

@ -0,0 +1 @@
uid://bky12ooi60og8

View file

@ -0,0 +1,76 @@
extends Camera3D
## Overview camera: orthographic three-quarter view over the whole map, pan, zoom and
## lane focus. Blau's base sits bottom-left, Rot's top-right, as on the minimap.
var focus := Vector3.ZERO
var yaw: float = 0.0
var pitch: float = deg_to_rad(-56.0)
var zoom: float = 34.0
var reduced_motion: bool = false
var _goal_focus := Vector3.ZERO
var _goal_zoom: float = 34.0
var _dragging: bool = false
func _ready() -> void:
projection = PROJECTION_ORTHOGONAL
near = .1
far = 400.0
current = true
_goal_focus = focus
_apply()
func look_at_point(point: Vector3, new_zoom: float = -1.0, instant: bool = false) -> void:
_goal_focus = Vector3(clampf(point.x, -40, 40), 0, clampf(point.z, -40, 40))
if new_zoom > 0.0:
_goal_zoom = new_zoom
if instant or reduced_motion:
focus = _goal_focus
zoom = _goal_zoom
_apply()
func overview(instant: bool = false) -> void:
look_at_point(Vector3.ZERO, 46.0, instant)
func _apply() -> void:
size = zoom
var back := Vector3(0, 0, 1).rotated(Vector3.RIGHT, pitch).rotated(Vector3.UP, yaw)
position = focus + back * 80.0
look_at(focus, Vector3.UP)
func _process(delta: float) -> void:
var pan := Vector2(Input.get_axis("ui_left", "ui_right"), Input.get_axis("ui_up", "ui_down"))
if Input.is_physical_key_pressed(KEY_A): pan.x -= 1
if Input.is_physical_key_pressed(KEY_D): pan.x += 1
if Input.is_physical_key_pressed(KEY_W): pan.y -= 1
if Input.is_physical_key_pressed(KEY_S): pan.y += 1
if pan != Vector2.ZERO and not _text_focus():
var right := Vector3(cos(yaw), 0, -sin(yaw))
var forward := Vector3(-sin(yaw), 0, -cos(yaw))
_goal_focus += (right * pan.x - forward * pan.y) * zoom * .9 * delta
var t: float = 1.0 if reduced_motion else minf(1.0, delta * 7.0)
focus = focus.lerp(_goal_focus, t)
zoom = lerpf(zoom, _goal_zoom, t)
_apply()
func _text_focus() -> bool:
var owner: Control = get_viewport().gui_get_focus_owner()
return owner is LineEdit or owner is TextEdit
## World input only; the HUD consumes its own clicks before they reach here.
func handle_world_input(event: InputEvent) -> bool:
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
_goal_zoom = maxf(12.0, _goal_zoom - 2.5)
return true
if event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
_goal_zoom = minf(60.0, _goal_zoom + 2.5)
return true
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_MIDDLE:
_dragging = event.pressed
return true
if event is InputEventMouseMotion and _dragging:
var right := Vector3(cos(yaw), 0, -sin(yaw))
var forward := Vector3(-sin(yaw), 0, -cos(yaw))
_goal_focus -= (right * event.relative.x - forward * event.relative.y) * zoom / 700.0
return true
return false

View file

@ -0,0 +1 @@
uid://doa7ve4i37a5q

View file

@ -0,0 +1,134 @@
extends Node3D
## Visual twin of one LuneUnit. Reads the authoritative state and drives the existing
## figure animations from real combat events; it never writes back into the simulation.
const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55")]
var unit: LuneUnit
var model: Node3D
var skeletal: bool = false
var _fill: MeshInstance3D
var _bars: Node3D
var _ring: MeshInstance3D
var _last_attack: int = 0
var _last_ability: int = 0
var _attack_flash: float = 0.0
var _ability_flash: float = 0.0
var _time: float = 0.0
var _last_visual: Vector3
var _height: float = 1.6
static func _flat(color: Color, energy: float = 0.0) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.albedo_color = color
if energy > 0.0:
material.emission_enabled = true
material.emission = color
material.emission_energy_multiplier = energy
return material
func bind(source: LuneUnit) -> void:
unit = source
name = "Unit_%d" % unit.id
var id: String = str(unit.def.get("id", ""))
model = AssetLibrary.instantiate_fighter(id)
if model == null:
model = EnemyVisualsFallback.make(TEAM_COLORS[unit.team])
add_child(model)
skeletal = model.has_method("update_combat")
if model.has_method("get_aabb"):
_height = maxf(1.0, model.get_aabb().end.y)
var scale_factor: float = float(unit.def.get("view_scale", 1.0))
model.scale = Vector3.ONE * scale_factor
_height *= scale_factor
_ring = MeshInstance3D.new()
var torus := TorusMesh.new()
torus.inner_radius = .42 * scale_factor
torus.outer_radius = .52 * scale_factor
torus.rings = 20
torus.ring_segments = 4
_ring.mesh = torus
_ring.material_override = _flat(TEAM_COLORS[unit.team], 1.4 if unit.kind != "monster" else .6)
_ring.scale = Vector3(1, .25, 1)
_ring.position.y = .05
_ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(_ring)
_bars = Node3D.new()
_bars.position.y = _height + .25
add_child(_bars)
var back := MeshInstance3D.new()
var quad := QuadMesh.new()
quad.size = Vector2(.9, .1)
back.mesh = quad
var back_material := _flat(Color(0, 0, 0, .7))
back_material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
back_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
back_material.no_depth_test = true
back.material_override = back_material
back.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_bars.add_child(back)
_fill = MeshInstance3D.new()
var fill_quad := QuadMesh.new()
fill_quad.size = Vector2(.86, .07)
fill_quad.center_offset = Vector3(.43, 0, 0)
_fill.mesh = fill_quad
var fill_material := _flat(TEAM_COLORS[unit.team].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
_fill.material_override = fill_material
_fill.position = Vector3(-.43, 0, 0)
_fill.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_bars.add_child(_fill)
_last_attack = unit.attack_serial
_last_ability = unit.ability_serial
position = Vector3(unit.position.x, 0, unit.position.y)
rotation.y = unit.facing
_last_visual = position
func sync(alpha: float, delta: float) -> void:
if unit == null:
return
_time += delta
var at: Vector2 = unit.previous_position.lerp(unit.position, clampf(alpha, 0.0, 1.0))
position = Vector3(at.x, 0, at.y)
rotation.y = lerp_angle(rotation.y, unit.facing, minf(1.0, delta * 12.0))
var moved: float = position.distance_to(_last_visual)
_last_visual = position
if unit.attack_serial != _last_attack:
_last_attack = unit.attack_serial
_attack_flash = .28
if unit.ability_serial != _last_ability:
_last_ability = unit.ability_serial
_ability_flash = .5
_attack_flash = maxf(0.0, _attack_flash - delta)
_ability_flash = maxf(0.0, _ability_flash - delta)
if skeletal:
model.rotation.y = 0.0
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)
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
# Rigid figures have no death clip; skeletal ones stay down in their death pose.
if not skeletal:
visible = unit.alive
class EnemyVisualsFallback:
static func make(color: Color) -> Node3D:
var body := MeshInstance3D.new()
var capsule := CapsuleMesh.new()
capsule.radius = .35
capsule.height = 1.4
body.mesh = capsule
body.position.y = .7
var material := StandardMaterial3D.new()
material.albedo_color = color
body.material_override = material
var root := Node3D.new()
root.add_child(body)
return root

View file

@ -0,0 +1 @@
uid://ctolavjaa60ir

View file

@ -0,0 +1,303 @@
extends Node3D
## 3D presentation of LuneMap built from existing assets: Kluft terrain shaders, the
## Saumkern models as Ancients, compare-pack rocks and trees, and the new lane tower.
## Purely visual; the simulation never reads anything from this node.
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 TOWER := "res://assets/luneraria/tower.glb"
const ROCK := "res://assets/compare/compare-rock.glb"
const TREE := "res://assets/compare/compare-tree.glb"
const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55")]
const TEAM_NAMES: Array[String] = ["Blau", "Rot"]
var map: LuneMap
var building_nodes: Dictionary = {}
var _terrain: Dictionary = {}
var _crystal_materials: Array[StandardMaterial3D] = []
func build(match_map: LuneMap) -> void:
map = match_map
name = "LuneWorld"
_environment()
_ground()
_river()
for lane: String in LuneMap.LANES:
_lane_ribbon(map.lanes[lane])
for team: int in range(2):
_base_plaza(team)
_decoration()
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)
for definition: Dictionary in map.buildings:
_building(definition)
func terrain(key: String, color: Color) -> ShaderMaterial:
if not _terrain.has(key):
var source := StandardMaterial3D.new()
source.resource_name = key
source.albedo_color = color
_terrain[key] = Sanctuary.terrain_material(source)
return _terrain[key]
func to3(v: Vector2, y: float = 0.0) -> Vector3:
return Vector3(v.x, y, v.y)
func _environment() -> void:
var environment := WorldEnvironment.new()
var settings := Environment.new()
Lighting.configure(settings)
# The overview camera sits ~80 m away; distance fog would wash out the whole board.
settings.fog_enabled = false
environment.environment = settings
add_child(environment)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-55, -30, 0)
sun.light_color = Color("ffe2bc")
sun.light_energy = 1.7
sun.shadow_enabled = true
sun.directional_shadow_max_distance = 90.0
sun.shadow_blur = 1.8
add_child(sun)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-35, 150, 0)
fill.light_color = Color("a8c4e8")
fill.light_energy = .45
add_child(fill)
func _ground() -> void:
var ground := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = Vector2(LuneMap.HALF_EXTENT * 2.0 + 30.0, LuneMap.HALF_EXTENT * 2.0 + 30.0)
plane.subdivide_width = 32
plane.subdivide_depth = 32
ground.mesh = plane
ground.material_override = terrain("ENV_soil", Color(.10, .15, .065))
ground.name = "Ground"
add_child(ground)
func _river() -> void:
# The river follows the symmetry axis x = z and passes under all three lanes.
var tool := SurfaceTool.new()
tool.begin(Mesh.PRIMITIVE_TRIANGLES)
var a := Vector2(-40, -40)
var b := Vector2(40, 40)
var side := Vector2(1, -1).normalized() * 3.2
var steps: int = 40
for i: int in range(steps):
# Godot front faces wind clockwise seen from above.
for quad: Array in [[i, -1], [i + 1, 1], [i + 1, -1], [i, -1], [i, 1], [i + 1, 1]]:
var t: float = float(quad[0]) / steps
var wobble: float = sin(t * 18.0) * .6
var p: Vector2 = a.lerp(b, t) + side * (float(quad[1]) + wobble * .15)
tool.set_normal(Vector3.UP)
tool.add_vertex(to3(p, .012))
var river := MeshInstance3D.new()
river.mesh = tool.commit()
var water := ShaderMaterial.new()
water.shader = RiverShader
river.material_override = water
river.name = "River"
add_child(river)
func _lane_ribbon(path: LuneMap.LanePath) -> void:
var tool := SurfaceTool.new()
tool.begin(Mesh.PRIMITIVE_TRIANGLES)
var steps: int = int(ceil(path.length / .8))
var half: float = map.balance.lane_half_width + .6
var previous_left := Vector3.ZERO
var previous_right := Vector3.ZERO
for i: int in range(steps + 1):
var s: float = path.length * float(i) / steps
var point: Vector2 = path.point_at(s)
var tangent: Vector2 = (path.point_at(minf(path.length, s + .5)) - path.point_at(maxf(0.0, s - .5))).normalized()
var normal := Vector2(-tangent.y, tangent.x)
var left: Vector3 = to3(point + normal * half, .03)
var right: Vector3 = to3(point - normal * half, .03)
if i > 0:
for vertex: Vector3 in [previous_left, right, left, previous_left, previous_right, right]:
tool.set_normal(Vector3.UP)
tool.set_uv(Vector2(vertex.x, vertex.z) * .25)
tool.add_vertex(vertex)
previous_left = left
previous_right = right
var ribbon := MeshInstance3D.new()
ribbon.mesh = tool.commit()
ribbon.material_override = terrain("ENV_paving", Color(.19, .185, .15))
ribbon.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(ribbon)
func _base_plaza(team: int) -> void:
var plaza := MeshInstance3D.new()
var disc := CylinderMesh.new()
disc.top_radius = 9.5
disc.bottom_radius = 9.8
disc.height = .12
disc.radial_segments = 48
plaza.mesh = disc
plaza.material_override = terrain("ENV_limestone", Color(.25, .225, .17))
plaza.position = to3(map.core_position(team), .02)
add_child(plaza)
var rim := MeshInstance3D.new()
var ring := TorusMesh.new()
ring.inner_radius = 9.35
ring.outer_radius = 9.6
ring.rings = 64
rim.mesh = ring
var glow := StandardMaterial3D.new()
glow.albedo_color = TEAM_COLORS[team]
glow.emission_enabled = true
glow.emission = TEAM_COLORS[team]
glow.emission_energy_multiplier = 1.2
rim.material_override = glow
rim.position = to3(map.core_position(team), .1)
rim.scale = Vector3(1, .3, 1)
add_child(rim)
func _far_from_lanes(point: Vector2, margin: float) -> bool:
for lane: String in LuneMap.LANES:
var path: LuneMap.LanePath = map.lanes[lane]
if path.point_at(path.project(point)).distance_to(point) < margin:
return false
for team: int in range(2):
if map.core_position(team).distance_to(point) < 12.0:
return false
if absf(point.x - point.y) < 5.0:
return false
return true
func _decoration() -> void:
# Scatter one half deterministically and mirror it, so decoration never favours a team.
var rng := RandomNumberGenerator.new()
rng.seed = 90210
var tree: PackedScene = load(TREE)
var rock: PackedScene = load(ROCK)
var placed: Array[Vector2] = []
var tries: int = 0
while placed.size() < 70 and tries < 4000:
tries += 1
var point := Vector2(rng.randf_range(-LuneMap.HALF_EXTENT - 4, LuneMap.HALF_EXTENT + 4), rng.randf_range(-LuneMap.HALF_EXTENT - 4, LuneMap.HALF_EXTENT + 4))
if point.y < point.x or not _far_from_lanes(point, 5.2):
continue
var clear: bool = true
for other: Vector2 in placed:
if other.distance_to(point) < 2.6:
clear = false
break
if not clear:
continue
placed.append(point)
var is_tree: bool = rng.randf() < .62
var size: float = rng.randf_range(1.1, 1.9) if is_tree else rng.randf_range(.8, 1.6)
var yaw: float = rng.randf() * TAU
for mirrored: bool in [false, true]:
var at: Vector2 = LuneMap.mirror(point) if mirrored else point
var holder := Node3D.new()
holder.position = to3(at)
holder.rotation.y = yaw if not mirrored else -yaw
holder.scale = Vector3.ONE * size
var prop: Node3D = (tree if is_tree else rock).instantiate()
# The compare-pack props were exported with their height along -Z; stand them up.
prop.rotation.x = PI * .5
_dress(prop)
holder.add_child(prop)
add_child(holder)
var _vertex_colored: StandardMaterial3D
func _dress(node: Node) -> void:
if node is MeshInstance3D:
for surface: int in range(node.mesh.get_surface_count()):
var source := node.mesh.surface_get_material(surface) as StandardMaterial3D
if source != null and source.resource_name.begins_with("ENV_") and not source.resource_name.contains("brass"):
node.set_surface_override_material(surface, terrain(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(surface, _vertex_colored)
for child: Node in node.get_children():
_dress(child)
func _building(definition: Dictionary) -> void:
var root := Node3D.new()
root.name = str(definition.id)
root.position = to3(definition.position)
add_child(root)
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)
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)
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
func _tint_crystal(node: Node, crystal: StandardMaterial3D) -> void:
if node is MeshInstance3D:
for surface: int in range(node.mesh.get_surface_count()):
var source: Material = node.mesh.surface_get_material(surface)
if source != null and source.resource_name == "TW_Crystal":
node.set_surface_override_material(surface, crystal)
for child: Node in node.get_children():
_tint_crystal(child, crystal)
## 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

View file

@ -0,0 +1 @@
uid://i4b3g0pv7e0x

View file

@ -50,6 +50,13 @@ var _advanced_button: Button
var _advanced_summary: Label
var _timed_hint: Label
var _title_caption: Label
## Exposed so a derived menu (Champions of Luneraria) can retitle the shell.
var title_label: Label
var subtitle_label: Label
var lore_label: Label
var emblem_rect: TextureRect
var version_label: Label
var brand_names: VBoxContainer
var _style_heading: Label
var _style_facts: Label
var _setup_caption: Label
@ -178,14 +185,16 @@ func _build_title() -> void:
var brand := HBoxContainer.new()
brand.add_theme_constant_override("separation", 22)
column.add_child(brand)
_emblem("res://assets/ui/emblems/saumwacht.svg", 112, brand)
emblem_rect = _emblem("res://assets/ui/emblems/saumwacht.svg", 112, brand)
var names := VBoxContainer.new()
names.alignment = BoxContainer.ALIGNMENT_CENTER
names.add_theme_constant_override("separation", 0)
brand.add_child(names)
_label("SAUMWACHT", "TitleLabel", names)
_label("DIE GRÜNE KLUFT", "SubtitleLabel", names)
brand_names = names
title_label = _label("SAUMWACHT", "TitleLabel", names)
subtitle_label = _label("DIE GRÜNE KLUFT", "SubtitleLabel", names)
var lore := _label("Stelle deine Wächter auf, schütze den Saumkern und überstehe den Rivalen.", "LoreLabel", column)
lore_label = lore
lore.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
lore.custom_minimum_size.x = 560
var gap := Control.new()
@ -208,6 +217,7 @@ func _build_title() -> void:
var quit := _button("Spiel beenden", "QuietButton", column, func() -> void: quit_requested.emit())
quit.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
var version := _label("Demo %s · Einzelspieler" % str(ProjectSettings.get_setting("application/config/version", "")), "MutedLabel", _title_screen)
version_label = version
version.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
version.offset_left = -360
version.offset_right = -40
@ -470,7 +480,7 @@ func show_screen(next: int, instant: bool = false) -> void:
var from: Control = _title_screen if screen == Screen.TITLE else (_setup_screen if screen == Screen.SETUP else null)
var to: Control = _title_screen if next == Screen.TITLE else _setup_screen
screen = next
stage.set_shot_named("title" if next == Screen.TITLE else GameState.factions[0], instant or reduced_motion)
stage.set_shot_named("title" if next == Screen.TITLE else _setup_shot(), instant or reduced_motion)
_title_scrim.visible = next == Screen.TITLE
_setup_scrim.visible = next == Screen.SETUP
if _fade != null and _fade.is_valid():
@ -496,6 +506,10 @@ func show_screen(next: int, instant: bool = false) -> void:
from.modulate.a = 1.0)
_focus_default.call_deferred()
## Stage shot for the setup screen; derived menus pick their own Bund source.
func _setup_shot() -> String:
return GameState.factions[0]
func _focus_default() -> void:
if not is_visible_in_tree():
return

View file

@ -28,6 +28,7 @@ func run() -> void:
_test_rewards()
_test_persistence_and_restart()
_test_full_match()
_test_mirror_fairness()
var report := {"checks": checks, "failures": failures}
var file := FileAccess.open("res://docs/luneraria/logs/core-tests.json", FileAccess.WRITE)
file.store_string(JSON.stringify(report, "\t"))
@ -295,3 +296,26 @@ func _test_full_match() -> void:
var cores_down: int = int(state.core_hp(0) <= 0.0) + int(state.core_hp(1) <= 0.0)
check(cores_down >= 1, "the result follows an actually destroyed Ancient")
print("LUNE_FULL_MATCH: seed 2 -> %s after %d rounds" % ["draw" if state.winner == LuneMatch.DRAW else ("Blau" if state.winner == 0 else "Rot"), state.history.size()])
func _test_mirror_fairness() -> void:
# Identical Bund and deterministic bot profile on all six slots: the match must stay an
# exact mirror image. Any drift would reveal an order- or id-dependent team advantage.
for bund: String in ["glutpakt", "wurzelbund"]:
var state := LuneMatch.new()
state.setup({"seed": 3, "human_slot": -1, "bunds": [bund, bund, bund, bund, bund, bund]})
var mirrored: bool = true
for round_index: int in range(12):
for slot: int in range(6):
LuneBot.plan(state, slot, "verteidigung")
state.begin_combat()
state.finish_combat()
for definition: Dictionary in state.map.buildings:
if definition.team == 0:
var twin: String = str(definition.id).replace("blau", "rot")
mirrored = mirrored and is_equal_approx(float(state.building_hp[definition.id]), float(state.building_hp[twin]))
for slot: int in range(3):
mirrored = mirrored and int(state.players[slot].gold) == int(state.players[slot + 3].gold)
if state.phase == LuneMatch.Phase.OVER:
break
check(mirrored, "%s mirror match stays exactly symmetric for 12 rounds (buildings and gold)" % bund)
check(state.phase != LuneMatch.Phase.OVER or state.winner == LuneMatch.DRAW, "%s mirror match never produces a one-sided winner" % bund)

View file

@ -0,0 +1 @@
uid://bko3fb3fg67th

View file

@ -0,0 +1,74 @@
extends Node
## Frame and simulation cost in a real match scene at representative maximum occupation:
## six armies at the supply cap and a round-13 wave (synthetic fixture, gold granted to the
## fixture only). VSync off; figures apply to this machine only.
## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/lune_benchmark.tscn
const Game = preload("res://scripts/luneraria/lune_game.gd")
func _ready() -> void:
call_deferred("run")
func run() -> void:
Soundscape.enabled = false
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
var t0: int = Time.get_ticks_usec()
var game: Node3D = Game.new()
add_child(game)
game.start({"seed": 5, "human_slot": 0})
var build_ms: float = (Time.get_ticks_usec() - t0) / 1000.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 in 30: await get_tree().process_frame
RenderingServer.viewport_set_measure_render_time(get_viewport().get_viewport_rid(), true)
var frames: Array[float] = []
var gpu: Array[float] = []
var sim: Array[float] = []
var draws: Array[float] = []
var mobile: Array[int] = []
var last: int = Time.get_ticks_usec()
var end: int = Time.get_ticks_msec() + 8000
game.speed_index = 1
while Time.get_ticks_msec() < end and state.phase == LuneMatch.Phase.COMBAT:
var s0: int = Time.get_ticks_usec()
var before: int = state.combat.steps
await RenderingServer.frame_post_draw
var now: int = Time.get_ticks_usec()
frames.append((now - last) / 1000.0)
last = now
gpu.append(RenderingServer.viewport_get_measured_render_time_gpu(get_viewport().get_viewport_rid()))
draws.append(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))
mobile.append(state.combat.mobile_count())
# Pure simulation cost of one fixed step at this occupation, measured separately.
var probe := LuneMatch.new()
probe.setup({"seed": 5, "human_slot": -1})
probe.round_number = 13
for slot: int in range(6):
probe.players[slot].draft.gold = 100000
LuneBot.plan(probe, slot, "ausgewogen")
probe.begin_combat()
var start_units: int = probe.combat.mobile_count()
var s1: int = Time.get_ticks_usec()
for i in 200:
probe.step_combat()
var step_ms: float = (Time.get_ticks_usec() - s1) / 1000.0 / 200.0
frames.sort()
var mean := func(v: Array) -> float:
var t: float = 0.0
for x in v: t += float(x)
return t / maxf(1, v.size())
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, "mobile_units_mean_measured": mean.call(mobile), "buildings": 18,
"frames": frames.size(), "frame_ms_mean": mean.call(frames), "frame_ms_p95": frames[int(frames.size() * .95)] if frames.size() > 0 else -1,
"gpu_ms_mean": mean.call(gpu), "draw_calls_mean": mean.call(draws), "sim_step_ms": step_ms, "sim_speed": "2x (40 steps/s)",
"video_mem_mb": Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / 1048576.0,
"static_mem_mb": Performance.get_monitor(Performance.MEMORY_STATIC) / 1048576.0}
var file := FileAccess.open("res://docs/luneraria/logs/perf-%dx%d.json" % [get_window().size.x, get_window().size.y], FileAccess.WRITE)
file.store_string(JSON.stringify(report, "\t"))
file.close()
print("LUNE_BENCH ", JSON.stringify(report))
get_tree().quit()

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/luneraria/lune_benchmark.gd" id="1"]
[node name="LuneBench" type="Node"]
script = ExtResource("1")

View file

@ -0,0 +1,40 @@
extends Node
## Real-app captures: menu, planning, lane combat, overview, destroyed core and result.
## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/lune_review.tscn
var main: Node
var out := "res://docs/luneraria/screenshots/"
func _ready() -> void:
call_deferred("run")
func shot(name: String, frames: int = 12) -> void:
for i in frames: await get_tree().process_frame
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png(out + name + ".png")
print("LUNE_SHOT ", name)
func run() -> void:
Soundscape.enabled = false
main = load("res://scenes/luneraria.tscn").instantiate()
add_child(main)
var menu: MainMenu = main.menu
while not (menu.stage.is_ready("glutpakt") and menu.stage.is_ready("wurzelbund")):
await get_tree().process_frame
await shot("menu-title", 30)
menu.new_game_button.pressed.emit()
await get_tree().create_timer(1.8).timeout
await shot("menu-setup")
main.start_match({"seed": 4})
await get_tree().create_timer(.6).timeout
var game: Node3D = main.game
game.hud._starter.pressed.emit()
await shot("planning", 20)
game.camera.overview(true)
await shot("overview-planning", 20)
game._on_ready()
game.focus_lane("mid", true)
await get_tree().create_timer(9.0).timeout
await shot("combat-mid")
game.focus_lane("top", true)
await shot("combat-top")
game.camera.overview(true)
await shot("overview-combat")
print("LUNE_REVIEW_DONE")
get_tree().quit()

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/luneraria/lune_review.gd" id="1"]
[node name="LuneReview" type="Node"]
script = ExtResource("1")

View file

@ -0,0 +1,246 @@
extends Node
## M1 acceptance in the real application with real mouse and key events: menu, planning,
## commit, combat, pause/speed, settlement, return to menu, restart, a complete match to an
## Ancient's fall, result screen, rematch, window sizes and repeated menu/match cycles.
## Only the combat clock is fast-forwarded (more fixed steps per frame); rules are untouched.
## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/verify_m1.tscn
var main: Node
var checks: int = 0
var failures: Array[String] = []
var out := "res://docs/luneraria/screenshots/"
func check(ok: bool, label: String) -> void:
checks += 1
if not ok:
failures.append(label)
printerr("M1_FAIL: ", label)
func _ready() -> void:
call_deferred("run")
func frames(count: int = 3) -> void:
for i: int in range(count):
await get_tree().process_frame
func shot(name: String) -> void:
await frames(8)
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png(out + name + ".png")
print("M1_SHOT ", name)
func click(control: Control) -> void:
var at: Vector2 = control.get_global_rect().get_center()
var motion := InputEventMouseMotion.new()
motion.position = at
get_viewport().push_input(motion, true)
await get_tree().process_frame
for pressed: bool in [true, false]:
var event := InputEventMouseButton.new()
event.button_index = MOUSE_BUTTON_LEFT
event.position = at
event.pressed = pressed
get_viewport().push_input(event, true)
await get_tree().process_frame
func key(code: Key) -> void:
for pressed: bool in [true, false]:
var event := InputEventKey.new()
event.keycode = code
event.physical_keycode = code
event.pressed = pressed
get_viewport().push_input(event)
await get_tree().process_frame
var core_shot_taken: bool = false
func fast_combat(game: Node3D, steps_per_frame: int = 40, capture: String = "") -> void:
var captured: bool = capture.is_empty()
while game.match_state.phase == LuneMatch.Phase.COMBAT:
for i: int in range(steps_per_frame):
if game.match_state.phase != LuneMatch.Phase.COMBAT:
break
# Slow down near an Ancient's fall to capture the siege in the live combat view.
if not core_shot_taken and _weak_core(game) >= 0:
core_shot_taken = true
var team: int = _weak_core(game)
var at: Vector2 = game.match_state.map.core_position(team)
game.camera.look_at_point(Vector3(at.x, 0, at.y), 22.0, true)
await shot("core-siege")
game.match_state.step_combat()
await get_tree().process_frame
if not captured and game.match_state.phase == LuneMatch.Phase.COMBAT and game.match_state.combat.elapsed > 25.0:
captured = true
await shot(capture)
func _weak_core(game: Node3D) -> int:
var combat: LuneCombat = game.match_state.combat
for team: int in range(2):
var core: LuneUnit = combat.buildings["blau_ancient" if team == 0 else "rot_ancient"]
if core.alive and core.hp < core.max_hp * .3:
return team
return -1
func inside(control: Control, label: String) -> void:
var view := Rect2(Vector2.ZERO, get_viewport().get_visible_rect().size)
check(control.is_visible_in_tree() and view.encloses(control.get_global_rect()), label)
func run() -> void:
Soundscape.enabled = false
var started: int = Time.get_ticks_msec()
main = load("res://scenes/luneraria.tscn").instantiate()
add_child(main)
var menu: MainMenu = main.menu
check(ProjectSettings.get_setting("application/config/name") == "Champions of Luneraria", "project renamed to Champions of Luneraria")
check(menu.title_label.text == "CHAMPIONS" and menu.subtitle_label.text.contains("LUNERARIA"), "menu shows the new title")
while not (menu.stage.is_ready("glutpakt") and menu.stage.is_ready("wurzelbund")):
await get_tree().process_frame
print("M1_MENU_READY_MS ", Time.get_ticks_msec() - started)
await click(menu.new_game_button)
while menu.is_transitioning():
await get_tree().process_frame
await click(menu.lane_choice.get_button(0))
check(menu.lane == "top", "lane choice by click")
await click(menu.faction_choice.cards[0])
check(menu.bund == "glutpakt", "Bund choice by click")
await click(menu.start_button)
var game: Node3D = main.game
check(is_instance_valid(game) and game.human_slot == 0 and game.match_state.players[0].bund == "glutpakt", "start button launches a match for Blau Top with the chosen Bund")
var focus_before: Vector3 = game.camera._goal_focus
# Clicks and game keys during the fade must not reach the board.
var event := InputEventMouseButton.new()
event.button_index = MOUSE_BUTTON_WHEEL_UP
event.pressed = true
event.position = Vector2(900, 500)
get_viewport().push_input(event, true)
await key(KEY_1)
await key(KEY_SPACE)
check(game.match_state.players[0].draft.roster.is_empty() and game.match_state.phase == LuneMatch.Phase.PLANNING and is_equal_approx(game.camera._goal_zoom, 24.0), "no click or key reaches the game during the menu fade")
await get_tree().create_timer(.5).timeout
check(not menu.visible, "menu hides after the fade")
var state: LuneMatch = game.match_state
var hud: Control = game.hud
check(state.players.size() == 6 and state.players.filter(func(p: Dictionary) -> bool: return p.is_human).size() == 1, "one human and five bots")
check(state.round_number == 1 and state.phase == LuneMatch.Phase.PLANNING, "match opens in planning")
var gold0: int = state.gold_of(0)
await click(hud._starter)
check(state.players[0].draft.roster.size() == 3 and state.gold_of(0) == gold0 - 180, "Startaufstellung buys three Glutpakt troops for 180 gold")
await key(KEY_1)
check(state.players[0].draft.roster.size() == 3, "key 1 without enough gold is rejected")
await click(hud._undo)
check(state.players[0].draft.roster.size() == 2, "undo removes the last purchase")
await frames(4)
var lane_button: Button = null
for child: Node in hud._roster.get_children():
for grand: Node in child.get_children():
if grand is Button and grand.text == "M":
lane_button = grand
break
if lane_button != null:
break
await click(lane_button)
check(state.players[0].draft.roster.any(func(e: Dictionary) -> bool: return e.lane == "mid"), "lane assignment button moves a troop to Mid")
await click(hud._shop_buttons["g01"])
check(state.players[0].draft.roster.size() == 3, "shop click buys a troop")
for size: Vector2i in [Vector2i(1280, 720), Vector2i(1920, 1080)]:
get_window().size = size
await frames(6)
inside(hud._ready_button, "Bereit inside %dx%d" % [size.x, size.y])
inside(hud.minimap, "minimap inside %dx%d" % [size.x, size.y])
inside(hud._gold, "gold readout inside %dx%d" % [size.x, size.y])
await shot("planning-%dx%d" % [size.x, size.y])
var draft_uids: Array = state.players[0].draft.roster.map(func(e: Dictionary) -> String: return e.uid)
await key(KEY_SPACE)
check(state.phase == LuneMatch.Phase.COMBAT, "Space commits planning and starts combat")
check(state.players[0].roster.map(func(e: Dictionary) -> String: return e.uid) == draft_uids, "committed roster equals the draft")
var troops_on_top: int = 0
var troops_on_mid: int = 0
for unit: LuneUnit in state.combat.units:
if unit.owner_slot == 0:
troops_on_top += int(unit.lane == "top")
troops_on_mid += int(unit.lane == "mid")
check(troops_on_top == 2 and troops_on_mid == 1, "troops fight on their assigned lanes")
check(not state.submit(0, {"type": "buy", "type_id": "g01"}).ok, "no purchases during combat")
await get_tree().create_timer(1.0).timeout
await key(KEY_P)
var paused_time: float = state.combat.elapsed
await get_tree().create_timer(.5).timeout
check(game.paused and is_equal_approx(state.combat.elapsed, paused_time), "P pauses the combat clock")
await key(KEY_P)
await click(hud._speed)
check(game.SPEEDS[game.speed_index] == 2, "speed button switches to 2x")
game.focus_lane("mid", true)
await shot("combat-mid-live")
await fast_combat(game)
check(state.phase == LuneMatch.Phase.PLANNING and state.round_number == 2, "combat settles into planning round 2")
check(state.players[0].roster.size() == 3, "troops return after combat")
check(not state.last_settlement.is_empty() and int(state.last_settlement.income) > 0, "settlement pays income")
await frames(4)
check(hud._report.text.begins_with("Welle 1 abgeschlossen"), "HUD reports the settlement")
await shot("settlement")
# Menu and restart: a fresh match without previous progress.
await click(find_menu_button(hud))
await frames(4)
check(menu.visible and not is_instance_valid(game), "Menü returns to the title and frees the match")
await click(menu.new_game_button)
while menu.is_transitioning():
await get_tree().process_frame
await click(menu.start_button)
await get_tree().create_timer(.5).timeout
game = main.game
state = game.match_state
hud = game.hud
check(state.round_number == 1 and state.players[0].roster.is_empty() and state.gold_of(0) == state.balance.start_gold, "new match starts without previous progress")
# A complete match: the human's planning uses the same bot planner through the same commands.
var rounds: int = 0
var shots_taken: Dictionary = {}
while state.phase != LuneMatch.Phase.OVER and rounds < 60:
LuneBot.plan(state, game.human_slot, "druck")
game._on_ready()
if not shots_taken.has("lane") and state.round_number >= 3:
shots_taken.lane = true
game.focus_lane("top", true)
await fast_combat(game, 40, "combat-top")
elif not shots_taken.has("overview") and state.round_number >= 5:
shots_taken.overview = true
game.camera.overview(true)
await fast_combat(game, 40, "overview-combat")
else:
await fast_combat(game)
rounds += 1
check(state.phase == LuneMatch.Phase.OVER, "a complete match ends by an Ancient's fall (%d rounds)" % rounds)
var destroyed: Array = state.combat.destroyed_cores
check(destroyed.size() >= 1 and state.core_hp(destroyed[0]) <= 0.0, "the losing Ancient is actually destroyed")
check(core_shot_taken, "the siege of the falling Ancient was captured live")
await get_tree().create_timer(.8).timeout
await shot("result")
check(hud._over.visible and hud._over_title.text in ["SIEG", "NIEDERLAGE", "UNENTSCHIEDEN"], "result screen shows the outcome")
print("M1_MATCH_RESULT ", state.end_note, " after ", state.round_number, " waves")
var rematch: Button = null
for node: Node in hud._over.find_children("*", "Button", true, false):
if (node as Button).text == "NEUE PARTIE":
rematch = node
await click(rematch)
await frames(3)
check(is_instance_valid(main.game) and main.game != game and main.game.match_state.round_number == 1, "rematch starts a fresh match")
# Repeated menu/match cycles must not leak objects.
var objects: Array[float] = []
for cycle: int in range(4):
main.return_to_menu()
await frames(6)
main.start_match({"seed": 100 + cycle})
await get_tree().create_timer(.5).timeout
await frames(4)
objects.append(Performance.get_monitor(Performance.OBJECT_COUNT))
check(objects[3] <= objects[1] * 1.03 + 50, "no growing object count over menu/match cycles (%s)" % str(objects))
var report := {"checks": checks, "failures": failures, "object_counts": objects}
var file := FileAccess.open("res://docs/luneraria/logs/m1-acceptance.json", FileAccess.WRITE)
file.store_string(JSON.stringify(report, "\t"))
file.close()
print("M1_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)
func find_menu_button(hud: Control) -> Button:
for node: Node in hud.find_children("*", "Button", true, false):
if (node as Button).text == "Menü" and (node as Button).is_visible_in_tree():
return node
return null

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/luneraria/verify_m1.gd" id="1"]
[node name="VerifyM1" type="Node"]
script = ExtResource("1")