Compare commits

..

No commits in common. "luneraria/m1" and "main" have entirely different histories.

103 changed files with 9 additions and 7350 deletions

View file

@ -1,158 +0,0 @@
"""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

@ -1,144 +0,0 @@
"""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

@ -1,40 +0,0 @@
[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

@ -1,40 +0,0 @@
[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

@ -1,40 +0,0 @@
[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

Binary file not shown.

View file

@ -1,45 +0,0 @@
[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

@ -1,19 +0,0 @@
{
"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

@ -1,17 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1,43 +0,0 @@
[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

@ -1,6 +0,0 @@
[gd_resource type="Resource" script_class="LuneBalance" load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/luneraria/core/lune_balance.gd" id="1"]
[resource]
script = ExtResource("1")

View file

@ -1,65 +0,0 @@
# 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.

View file

@ -1,26 +0,0 @@
# Architektur- und Migrationsentscheidungen
Stand: 27. September 2026 (M0).
## Bestand (geprüft)
- **Werkzeuge:** Godot 4.7.2 (Fedora-Build; offizielle 4.7.2-Exportvorlagen unter `assets-source/phase6/export-templates`), Blender 5.2.2 LTS, Forward+ mit OpenGL-Kompatibilitätsmodus. Kein Engine-Update.
- **Repository:** Es gab kein Git. Angelegt wurde ein lokales Repository mit dem Baseline-Commit `c2b4476` auf `main` (unveränderter Stand 0.15.0, Phase 20A); die Arbeit läuft auf `luneraria/m1`. Screenshots und die Projekt-Doku außerhalb des Projektordners sind in `../backups/vor-luneraria-2026-09-27-docs-und-screenshots.tar.gz` gesichert. Keine uncommitteten fremden Änderungen; `../.mimocode` gehört zu einem fremden Werkzeug und bleibt unberührt. Ein Stand 20B existiert nicht. Keine CLAUDE.md.
- **Spiel:** eine Szene `scenes/main.tscn`, sieben Autoloads (`AssetLibrary`, `DataCatalog`, `GameState`, `EconomyManager`, `WaveManager`, `Preferences`, `Soundscape`), alle Oberflächen im Code. Kampf in `scripts/combat/`: Verteidiger gegen Angreifer je Lane, die Feindschaft hängt an `is_attacker`, die Lane an `team_id`.
- **Baseline-Tests** (`logs/m0-baseline/`): 209 · 33 · 463 · 206 · 1.665 bestanden.
## Entscheidungen
1. **Neuer Modus neben dem Legacy-Modus.** Die Saumwacht-Regeln (zwei Bretter, 8/20 Wellen, Kern-HP-Entscheid, Arbeiter/Funken) widersprechen den neuen Produktregeln. Sie werden nicht umgeschrieben, sondern als **Legacy-Modus** archiviert: `scenes/main.tscn` und die zugehörigen Tests bleiben lauffähig. Die neue Hauptszene `scenes/luneraria.tscn` startet Champions of Luneraria. Die bisherigen QA-Schalter (`--smoke-test`, `--faction-test`, `--expedition-test`) leitet sie in die Legacy-Szene um. So vermischen sich die Wirtschaftsmodelle nicht (Arbeitsannahme E), und die technischen Regressionen bleiben erhalten.
2. **Neue autoritative Simulation** in `scripts/luneraria/core/` als reine Datenklassen (`RefCounted`) ohne Szenenbaum. Der alte Kampf ist an zwei Bretter und `is_attacker` gebunden. Übernommen werden Einheitenwerte (`DataCatalog`), Schadensmatrix, Fähigkeitsregeln (auf Teamfeindschaft umgeschrieben), Modelle und die Animationsanbindung (`SkeletalFigure.update_combat`, `AnimatedFigure.pose`).
- `LuneBalance` (Resource) enthält alle Zahlen.
- `LuneMap` enthält Lane-Polylinien, 16 Türme und 2 Kerne, Aufmarschflächen, Sperrlogik und die Grotte. Die Karte entsteht per Konstruktion spiegelsymmetrisch zur Diagonale x = z: Team 1 ist das Spiegelbild von Team 0.
- `LuneCombat` rechnet einen festen Schritt in zwei Phasen, die Ergebnisse sind reihenfolgeunabhängig.
- `LuneMatch` verwaltet Teams, Spieler, Gold, Besitz mit eindeutigen IDs, Befehle, Runden, Abrechnung, Sieg und Ereignisprotokoll.
- `LuneWaves` erzeugt datengetriebene, unbegrenzte Wellen mit Mengengrenze.
- `LuneBot` nutzt dieselbe Befehlsschnittstelle wie der Mensch.
3. **Autoritative Matchinstanz von Anfang an.** Oberfläche und Bots senden nur Befehle (`LuneMatch.submit`), die beim Phasenwechsel atomar geprüft werden. Darstellung und Tests lesen denselben Zustand. Zufall kommt nur aus einer `RandomNumberGenerator`-Instanz mit Seed (Wellenrotation, Bots). Das ist die Grundlage für M4 (Host/Join), ohne Bit-Identität über Plattformen zu behaupten.
4. **Darstellung** in `scripts/luneraria/view/`: Kartenaufbau aus Bestandsmaterialien (Kluft-Shader, Kerne, Felsen, Bäume), neu erzeugte Türme (Blender-Generator), eine Einheitenansicht pro Kampfinstanz mit Positionsinterpolation zwischen den Simulationsschritten. Sichtbarkeit und Kamera beeinflussen die Simulation nicht.
5. **Oberfläche** in `scripts/luneraria/ui/` mit dem gemeinsamen `SaumwachtTheme` und seinen Bauteilen. Titel und Menü werden auf Champions of Luneraria umgestellt. Die Eingabeabschirmung aus 20A bleibt.
6. **Benutzereinstellungen:** Der Projektname wird zu „Champions of Luneraria“, dadurch ändert sich der `user://`-Pfad. `Preferences` übernimmt beim ersten Start einmalig die alte `preferences.cfg` aus „Saumwacht · Glutpakt“ und protokolliert das. Fehlt sie, gelten die Standardwerte.
7. **Tests:** Neue Tests liegen unter `tests/luneraria/` und `tools/luneraria/`, die Legacy-Tests bleiben unverändert und werden als „Legacy“ geführt. Neue Referenzen werden erst nach fachlicher Prüfung festgeschrieben.

View file

@ -1,71 +0,0 @@
# Asset-Wiederverwendungsmatrix
Erzeugt mit `python3 tools/luneraria/build_asset_matrix.py` aus den Laufzeitmanifesten. „Volk/Bund“ ist die Datenzuordnung. Die sieben Zielvölker (Menschen, Elfen, Zwerge, Trolle, Feen, Orks, Biestmenschen) haben noch **keine eigenen Modelle**; bestehende Wesen bleiben als eigene Bünde Teil der Welt. Als „vorläufig“ markierte Rollen sind Zuordnungen vorhandener Modelle, keine neu erstellten Völker.
| ID | Name | Pfad | Quelle | Rig / Clips | Dreiecke | bisherige Rolle | neue Rolle | Volk / Bund | Materialzustand | Eingriff |
| --- | --- | --- | --- | --- | ---: | --- | --- | --- | --- | --- |
| core_team0 | Saumkern · Team 0 | res://assets/models/core_team0.glb | assets-source/phase4/blend/core_team0.blend | starre Gelenkgruppen | 36652 | Saumkern Team 0 | Ancient Blau (SW) | — | HD, starre Teile | keiner |
| core_team1 | Saumkern · Team 1 | res://assets/models/core_team1.glb | assets-source/phase4/blend/core_team1.blend | starre Gelenkgruppen | 36848 | Saumkern Team 1 | Ancient Rot (NO) | — | HD, starre Teile | keiner |
| e01 | Splitterling | res://assets/models/enemy_e01.glb | assets-source/phase4/blend/e01.blend | starre Gelenkgruppen | 19228 | Wellengegner | Monsterwelle (teamgebunden, rotierend) | Riftbrut (neutral, pro Team eingefärbt) | HD, starre Teile | Teamkennzeichnung |
| e02 | Schalenläufer | res://assets/models/enemy_e02.glb | assets-source/phase4/blend/e02.blend | starre Gelenkgruppen | 20180 | Wellengegner | Monsterwelle | Riftbrut | HD, starre Teile | Teamkennzeichnung |
| e03 | Nebelrest | res://assets/models/enemy_e03.glb | assets-source/phase4/blend/e03.blend | starre Gelenkgruppen | 35898 | Wellengegner | Monsterwelle | Riftbrut | HD, starre Teile | Teamkennzeichnung |
| e04 | Fadenschütze | res://assets/models/enemy_e04.glb | assets-source/phase4/blend/e04.blend | starre Gelenkgruppen | 51050 | Wellengegner | Monsterwelle | Riftbrut | HD, starre Teile | Teamkennzeichnung |
| e05 | Bruchstampfer | res://assets/models/enemy_e05.glb | assets-source/phase4/blend/e05.blend | starre Gelenkgruppen | 62798 | Wellengegner | Monsterwelle (späte Variante) | Riftbrut | HD, starre Teile | Teamkennzeichnung |
| e06 | Knotenrufer | res://assets/models/enemy_e06.glb | assets-source/phase4/blend/e06.blend | starre Gelenkgruppen | 48336 | Wellengegner | Monsterwelle (Unterstützer) | Riftbrut | HD, starre Teile | Teamkennzeichnung |
| e07 | Bruchhirsch | res://assets/models/enemy_e07.glb | assets-source/phase4/blend/e07.blend | starre Gelenkgruppen | 33800 | Boss Welle 5 | Reserve (Wellen-Elite ab höheren Indizes) | Riftbrut | HD, starre Teile | offen |
| e08 | Riegelkoloss | res://assets/models/enemy_e08.glb | assets-source/phase4/blend/e08.blend | starre Gelenkgruppen | 81240 | Boss Welle 8 | Reserve (Wellen-Elite) | Riftbrut | HD, starre Teile | offen |
| e09 | Maskenrichter | res://assets/models/enemy_e09.glb | assets-source/phase4/blend/e09.blend | starre Gelenkgruppen | 57310 | Boss Welle 15 | Reserve | Riftbrut | HD, starre Teile | offen |
| e10 | Schlundkrone | res://assets/models/enemy_e10.glb | assets-source/phase4/blend/e10.blend | starre Gelenkgruppen | 64694 | Boss Welle 20 | M2: neutraler Grottenboss und entsandter Boss | Riftbrut / Grotte | HD, starre Teile | Bossdarstellung, Skalierung |
| g01 | Kohlenbeißer | res://assets/models/unit_g01.glb | assets-source/phase4/blend/g01.blend | starre Gelenkgruppen | 79780 | Glutpakt Nahkampf | Truppe Nahkampf | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| g02 | Schlackenklaue | res://assets/models/unit_g02.glb | assets-source/phase4/blend/g02.blend | starre Gelenkgruppen | 75624 | Glutpakt Nahkampf+ | Truppenaufwertung | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| g03 | Schlackenwacht | res://assets/models/unit_g03.glb | assets-source/phase4/blend/g03.blend | starre Gelenkgruppen | 67232 | Glutpakt Tank | Truppe Front | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| g04 | Basaltbastion | res://assets/characters/basaltbastion.glb | assets-source/phase15/basaltbastion.blend | 17 Knochen · 9 Clips | 17824 | Glutpakt Tank+ | Truppenaufwertung | Glutkonstrukt / Glutpakt | gewichtetes Skelett (phase15) | keiner |
| g05 | Funkenlanze | res://assets/characters/funkenlanze.glb | assets-source/phase18/funkenlanze.blend | 26 Knochen · 9 Clips | 24036 | Glutpakt Fernkampf | Truppe Fernkampf; M2 zusätzlich Held „Funkenlanze“ (vorläufig, mit Heldenmarkierung) | Glutkonstrukt / Glutpakt | gewichtetes Skelett (phase18) | Heldenkennzeichnung in der Darstellung |
| g06 | Glasballiste | res://assets/characters/glasballiste.glb | assets-source/phase19/glasballiste.blend | 20 Knochen · 9 Clips | 15784 | Glutpakt Fernkampf+ | Truppenaufwertung (Belagerung) | Glutkonstrukt / Glutpakt | gewichtetes Skelett (phase19) | keiner |
| g07 | Aschenwerfer | res://assets/models/unit_g07.glb | assets-source/phase4/blend/g07.blend | starre Gelenkgruppen | 81492 | Glutpakt Fläche | Truppe Fläche | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| g08 | Kraterrufer | res://assets/models/unit_g08.glb | assets-source/phase4/blend/g08.blend | starre Gelenkgruppen | 77130 | Glutpakt Fläche+ | Truppenaufwertung | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| g09 | Herdhüter | res://assets/models/unit_g09.glb | assets-source/phase4/blend/g09.blend | starre Gelenkgruppen | 84568 | Glutpakt Unterstützung | Truppe Unterstützung | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| g10 | Sonnenesse | res://assets/models/unit_g10.glb | assets-source/phase4/blend/g10.blend | starre Gelenkgruppen | 96706 | Glutpakt Unterstützung+ | Truppenaufwertung | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| m01 | Kesselwicht | res://assets/models/unit_m01.glb | assets-source/phase4/blend/m01.blend | starre Gelenkgruppen | 1284 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m02 | Druckhüter | res://assets/models/unit_m02.glb | assets-source/phase4/blend/m02.blend | starre Gelenkgruppen | 1360 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m03 | Scherschreiter | res://assets/models/unit_m03.glb | assets-source/phase4/blend/m03.blend | starre Gelenkgruppen | 932 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m04 | Sechsklingler | res://assets/models/unit_m04.glb | assets-source/phase4/blend/m04.blend | starre Gelenkgruppen | 1398 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m05 | Bolzenknecht | res://assets/models/unit_m05.glb | assets-source/phase4/blend/m05.blend | starre Gelenkgruppen | 1148 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m06 | Linienmeister | res://assets/models/unit_m06.glb | assets-source/phase4/blend/m06.blend | starre Gelenkgruppen | 1432 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m07 | Klangkolben | res://assets/models/unit_m07.glb | assets-source/phase4/blend/m07.blend | starre Gelenkgruppen | 740 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m08 | Resonanzramme | res://assets/models/unit_m08.glb | assets-source/phase4/blend/m08.blend | starre Gelenkgruppen | 1316 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m09 | Taktspindel | res://assets/models/unit_m09.glb | assets-source/phase4/blend/m09.blend | starre Gelenkgruppen | 1160 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| m10 | Taktprälat | res://assets/models/unit_m10.glb | assets-source/phase4/blend/m10.blend | starre Gelenkgruppen | 946 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nietenkonvent (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| map_lane_segment | Lane-Segment | res://assets/models/map_lane_segment.glb | assets-source/phase4/blend/map_lane_segment.blend | starre Gelenkgruppen | 10700 | Lane-Kachel alt | nicht verwendet; Lanes neu erzeugt | — | HD, starre Teile | keiner |
| n01 | Saummaske | res://assets/models/unit_n01.glb | assets-source/phase4/blend/n01.blend | starre Gelenkgruppen | 1132 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n02 | Grenzmaske | res://assets/models/unit_n02.glb | assets-source/phase4/blend/n02.blend | starre Gelenkgruppen | 1156 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n03 | Fadentänzer | res://assets/models/unit_n03.glb | assets-source/phase4/blend/n03.blend | starre Gelenkgruppen | 934 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n04 | Saumzertrenner | res://assets/models/unit_n04.glb | assets-source/phase4/blend/n04.blend | starre Gelenkgruppen | 1360 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n05 | Fernflüsterer | res://assets/models/unit_n05.glb | assets-source/phase4/blend/n05.blend | starre Gelenkgruppen | 1118 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n06 | Fernrufer | res://assets/models/unit_n06.glb | assets-source/phase4/blend/n06.blend | starre Gelenkgruppen | 1498 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n07 | Glockenschlepper | res://assets/models/unit_n07.glb | assets-source/phase4/blend/n07.blend | starre Gelenkgruppen | 1274 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n08 | Hohlgeläut | res://assets/models/unit_n08.glb | assets-source/phase4/blend/n08.blend | starre Gelenkgruppen | 1482 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n09 | Maskenkantor | res://assets/models/unit_n09.glb | assets-source/phase4/blend/n09.blend | starre Gelenkgruppen | 1314 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n10 | Vielstimmenkantor | res://assets/models/unit_n10.glb | assets-source/phase4/blend/n10.blend | starre Gelenkgruppen | 1734 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| s01 | Nadelspäher | res://assets/models/unit_s01.glb | assets-source/phase4/blend/s01.blend | starre Gelenkgruppen | 10340 | Söldner | Reserve Angriffschampion | Söldner | HD, starre Teile | offen |
| s02 | Rostschieber | res://assets/models/unit_s02.glb | assets-source/phase4/blend/s02.blend | starre Gelenkgruppen | 12040 | Söldner | M2: Angriffschampion Schutz | Söldner | HD, starre Teile | keiner |
| s03 | Dunstsänger | res://assets/models/unit_s03.glb | assets-source/phase4/blend/s03.blend | starre Gelenkgruppen | 7832 | Söldner | M2: Angriffschampion Druck auf Unterstützer | Söldner | HD, starre Teile | keiner |
| s04 | Keilbrecher | res://assets/models/unit_s04.glb | assets-source/phase4/blend/s04.blend | starre Gelenkgruppen | 10458 | Söldner | M2: Angriffschampion Belagerung | Söldner | HD, starre Teile | keiner |
| s05 | Risszehrer | res://assets/models/unit_s05.glb | assets-source/phase4/blend/s05.blend | starre Gelenkgruppen | 10440 | Söldner | Reserve Angriffschampion | Söldner | HD, starre Teile | offen |
| w01 | Dornhopser | res://assets/models/unit_w01.glb | assets-source/phase4/blend/w01.blend | starre Gelenkgruppen | 61376 | Wurzelbund Nahkampf | Truppe Nahkampf | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w02 | Rankenschnapper | res://assets/models/unit_w02.glb | assets-source/phase4/blend/w02.blend | starre Gelenkgruppen | 78020 | Wurzelbund Nahkampf+ | Truppenaufwertung | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w03 | Korkrücken | res://assets/models/unit_w03.glb | assets-source/phase4/blend/w03.blend | starre Gelenkgruppen | 84786 | Wurzelbund Tank | Truppe Front | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w04 | Ringborke | res://assets/characters/ringborke.glb | assets-source/phase11/ringborke.blend | 27 Knochen · 9 Clips | 118064 | Wurzelbund Tank+ | Truppenaufwertung; M2 zusätzlich Held „Ringborke“ (vorläufig) | Wurzelwesen / Wurzelbund | gewichtetes Skelett (phase11) | Heldenkennzeichnung |
| w05 | Samenschleuder | res://assets/models/unit_w05.glb | assets-source/phase4/blend/w05.blend | starre Gelenkgruppen | 50536 | Wurzelbund Fernkampf | Truppe Fernkampf | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w06 | Zapfenkrone | res://assets/models/unit_w06.glb | assets-source/phase4/blend/w06.blend | starre Gelenkgruppen | 62588 | Wurzelbund Fernkampf+ | Truppenaufwertung | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w07 | Sporenkelch | res://assets/models/unit_w07.glb | assets-source/phase4/blend/w07.blend | starre Gelenkgruppen | 69164 | Wurzelbund Fläche | Truppe Fläche | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w08 | Pilzkathedrale | res://assets/models/unit_w08.glb | assets-source/phase4/blend/w08.blend | starre Gelenkgruppen | 103748 | Wurzelbund Fläche+ | Truppenaufwertung | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w09 | Tauhüter | res://assets/models/unit_w09.glb | assets-source/phase4/blend/w09.blend | starre Gelenkgruppen | 52040 | Wurzelbund Heiler | Truppe Unterstützung | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w10 | Quellgeflecht | res://assets/characters/quellgeflecht.glb | assets-source/phase16/quellgeflecht.blend | 17 Knochen · 9 Clips | 14276 | Wurzelbund Heiler+ | Truppenaufwertung; M2 zusätzlich Held „Quellgeflecht“ (vorläufig) | Wurzelwesen / Wurzelbund | gewichtetes Skelett (phase16) | Heldenkennzeichnung |
| worker | Funkenleser | res://assets/characters/arbeiter.glb | assets-source/phase12/arbeiter.blend | 27 Knochen · 10 Clips | 7184 | Arbeiter/Funken | nicht im neuen Modus (Arbeitsannahme E); Modell bleibt erhalten | — | gewichtetes Skelett (phase12) | keiner |
| menu-stage | Menü-Diorama | res://assets/environment/menu-stage.glb | assets-source/phase20/build_menu_stage.py | statisch | 48660 | Hauptmenü Phase 20A | Hauptmenü (Titel angepasst) | — | Kluft-Shader | keiner |
| verdant-rift | Grüne Kluft (2-Bretter-Arena) | res://assets/environment/verdant-rift.glb | assets-source/phase10/verdant-rift.blend | statisch | — | Spielfeld Saumwacht | Legacy-Modus; Materialien/Shader für die neue Karte wiederverwendet | — | triplanare Kluft-Shader | keine neue Nutzung als Karte |
| compare-rock / compare-tree | Fels, Baum | res://assets/compare/ | assets-source/phase14/build_compare_props.py | statisch | 360 / 852 | Vergleichsszene | Kartendekoration Dschungel | — | einfach | keiner |
| Türme | — | (fehlt) | — | — | — | — | 16 Lanetürme/Basistürme | — | — | neu: Blender-Generator (M1) |
| Bossgrotte | — | (fehlt) | — | — | — | — | Grottenbau nahe Zentrum | — | — | neu (M2) |
| Emblem/Theme | Saumwacht-Theme | scripts/ui/theme/, assets/ui/ | Code / SVG | — | — | Menü 20A | Theme weiterverwendet, Titel/Emblem Luneraria | — | Vektor | Titelschrift/Emblem ergänzen |

View file

@ -1,28 +0,0 @@
# 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

@ -1,49 +0,0 @@
# 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,56 +0,0 @@
# Champions of Luneraria · Regelwerk
Stand: 27. September 2026. Drei Ebenen, getrennt gehalten:
1. **Verbindliche Produktregeln** aus dem Masterauftrag. Änderungen nur per ausdrücklicher Entscheidung des Auftraggebers.
2. **Arbeitsannahmen A–F** aus dem Masterauftrag, als anpassbare Designentscheidungen formuliert. Es sind keine Nutzerzitate.
3. **Eigene Detailentscheidungen** dieser Umsetzung, anpassbar und mit Konfigurationsort.
## 1 · Verbindliche Produktregeln
- Titel: **Champions of Luneraria**.
- Zwei Teams mit je drei eigenständigen Spielerrollen.
- Drei zusammenhängende Lanes: Top, Mid, Bottom.
- Kämpfe laufen automatisch. Es gibt weder direkte Heldensteuerung noch manuelles Last-Hitting.
- Die Partie endet ausschließlich durch Zerstörung eines Ancient-Kerns. Es gibt keine maximale Rundenzahl, keinen Sieg nach einer Wellengrenze, keine Entscheidung nach Kern-HP und keinen Sieger per Zeitlimit.
- Werden beide Kerne im selben Simulationsschritt zerstört, ist das ein Unentschieden, unabhängig von Array-, Render- oder Netzwerkreihenfolge. Ein manueller Testabbruch oder technischer Abbruch ist kein Kern-Sieg.
- Monsterwellen werden mit jeder Welle stärker.
- Je Team und Lane zwei Lanetürme; zusätzliche Türme schützen den Ancient.
- Normale Truppen werden dauerhaft gekauft und aufgewertet; sie haben kein Levelsystem und keine Items.
- Helden werden gekauft, sammeln in der Partie Erfahrung und entwickeln sich über Items und Talente.
- Angriffschampions werden mit Gold als einmalige Verstärkung einer Angriffswelle gekauft.
- Besiegte Monster und Angriffschampions geben Gold.
- Eine gemeinsame Bossgrotte bindet Einheiten, die dafür ihre Lane verlassen.
- Nach erfolgreicher Eroberung verstärkt der Boss einmalig den Angriff des **siegreichen** Teams in der folgenden Runde, gegen das **gegnerische** Team. Er läuft zum gegnerischen Ancient.
- Der entsandte Boss gibt dem verteidigenden Team bei seinem Tod deutlich mehr Gold als ein normaler Gegner.
- Nach einem Bosssieg ist die Grotte drei vollständige Runden gesperrt. Beispiel: Sieg in Runde 6, Einsatz in Runde 7, Sperre 7–9, wieder bekämpfbar ab 10.
- Vorhandene Assets werden bevorzugt erhalten. Inhaltsziel: Elfen, Trolle, Zwerge, Menschen, Feen, Orks, Biestmenschen; bestehende Wesen und Bünde bleiben Teil der Welt.
- Ziel ist taktische Wiederspielbarkeit.
## 2 · Arbeitsannahmen (Masterauftrag, anpassbar)
- **A** Dauerhafte Truppen und Helden rücken mit den eigenen Monsterwellen über ihre Lane vor und greifen danach zulässige gegnerische Gebäude an.
- **B** Jeder Spieler hat eigenes Gold und eine eigene Armee. Seine Lane ist die anfängliche Zuständigkeit; Unterstützung anderer Lanes ist möglich. Das Team ist unabhängig von Volk und Bund.
- **C** Ein aktiver Held pro Spieler mit drei Itemplätzen; mehrere Helden zur Wahl. Normale Truppen bleiben relevant.
- **D** Genau zwei gemeinsame Basistürme pro Team: acht Türme je Team, sechzehn auf der Karte, plus zwei Ancients.
- **E** Die alte Arbeiter- und Funkenökonomie läuft im neuen Modus nicht. Gold finanziert Armee, Aufwertungen, Helden, Items und Angriffschampions.
- **F** Erster Modus: echte Sechs-Spieler-Simulation mit einem Menschen und fünf Bots. Netzwerk folgt als eigener Meilenstein.
## 3 · Detailentscheidungen dieser Umsetzung
Alle Zahlen stehen zentral in `data/luneraria/balance_default.tres` (Klasse `LuneBalance`).
| Thema | Entscheidung |
| --- | --- |
| Runde | Planung → Befehle werden atomar übernommen → automatischer Kampf → Abrechnung → nächste Planung. Die Wellennummer steigt unbegrenzt. |
| Simulation | Feste Schrittweite 1/20 s. Jeder Schritt läuft in zwei Phasen: erst werden alle Treffer, Heilungen und Effekte aus dem Zustand zu Schrittbeginn berechnet, dann gleichzeitig angewendet. Todesfälle eines Schritts gelten gemeinsam. So entsteht kein Vorteil aus der Verarbeitungsreihenfolge. |
| Aufmarsch | Armeen und Wellen starten jede Runde an festen, symmetrischen Aufmarschflächen ihrer Lane. Formationsplätze sind Offsets vor/quer zur Laufrichtung. Keine Front-Teleports. |
| Vormarsch | Ohne Ziel folgen Einheiten ihrer Lane. Sie kommen nicht an einem lebenden gegnerischen Turm ihrer Lane vorbei. Am Laneende greifen sie das nächste zulässige Basisgebäude an. |
| Gebäudesperren | Der innere Turm ist erst nach dem äußeren verwundbar. Die Basistürme sind verwundbar, sobald eine Lane des Teams ganz durchbrochen ist. Der Ancient ist erst verwundbar, wenn beide Basistürme gefallen sind. Die Sperre gilt für jeden Schaden (Fernkampf, Fläche, Fähigkeiten, Boss). Gesperrte Gebäude schießen weiter. |
| Kampfende | Eine Kampfphase endet, wenn keine beweglichen Einheiten mehr leben, wenn ein Kern fällt oder wenn das Zeitfenster abläuft (Standard 180 s). Das Zeitfenster beendet nur die Kampfphase, nie die Partie. Übrige temporäre Einheiten verschwinden ohne Belohnung, dauerhafte kehren zurück. Es gibt keinen Kernschaden durch den Abbruch. |
| Rückkehr | Dauerhafte Truppen und Helden kehren nach jeder Kampfphase vollständig geheilt zurück, auch wenn sie im Kampf gefallen sind. Schilde, Brand, Verlangsamungen, Stärkungen und Abklingzeiten werden auf Startwerte zurückgesetzt. Aufwertungen, Erfahrung, Items und Talente bleiben. |
| Gebäude | Türme und Kerne behalten Schaden; zerstörte Gebäude kehren nicht zurück. Gebäude skalieren nicht mit der Welle. |
| Belohnung | Pro gegnerischem Todesereignis genau eine Belohnung, egal wer tötet. Lane-Killgold geht zur Hälfte an den zuständigen Lane-Spieler des tötenden Teams und zur Hälfte gleichmäßig an alle drei. Der Rest der Ganzzahlteilung geht in stabiler Slotreihenfolge. Gebäude- und Boss-Kopfgelder werden gleichmäßig im Team verteilt. Dauerhafte Truppen und Helden geben kein Kopfgeld. |
| Laneverlust | Kein Spieler scheidet aus. Er baut weiter auf, verteidigt und unterstützt. |
| Pause | Offline darf die Planung auf den Menschen warten; Pause und Tempo 1×/2×/4× gelten nur lokal. |
| QA-Grenze | Testläufe dürfen eine Rundenobergrenze haben. Eine dort nicht beendete Partie wird als „Testlimit erreicht / Partie nicht abgeschlossen“ protokolliert, nie als Sieg oder Unentschieden. |

View file

@ -1,51 +0,0 @@
# STATUS · Champions of Luneraria
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** |
| 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.
## Nächster Schritt
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

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

View file

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

View file

@ -1,250 +0,0 @@
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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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:90)
[4] _ready (res://scripts/ui/main_menu.gd:62)
[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/Saumwacht · Glutpakt/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

@ -1,8 +0,0 @@
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/Saumwacht · Glutpakt/phase10-verification.json
EXIT 0

View file

@ -1,5 +0,0 @@
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/Saumwacht · Glutpakt/phase8-smoke
EXIT 0

View file

@ -1,4 +0,0 @@
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

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

View file

@ -1,8 +0,0 @@
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

@ -1,8 +0,0 @@
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

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

View file

@ -1,250 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,5 +0,0 @@
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

@ -1,4 +0,0 @@
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

@ -1,5 +0,0 @@
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

@ -1,9 +0,0 @@
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

@ -1,106 +0,0 @@
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

@ -1,15 +0,0 @@
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

@ -1,20 +0,0 @@
{
"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

@ -1,20 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 649 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

View file

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

View file

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

View file

@ -1,6 +0,0 @@
[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,22 +14,8 @@ func _ready() -> void:
read_settings() read_settings()
apply() 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: func read_settings() -> void:
var config := ConfigFile.new() 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: if config.load(storage_path) != OK:
return return
volume = clampf(float(config.get_value("audio", "volume", .65)), 0.0, 1.0) volume = clampf(float(config.get_value("audio", "volume", .65)), 0.0, 1.0)

View file

@ -1,69 +0,0 @@
class_name LuneBalance
extends Resource
## Every tunable number of Champions of Luneraria. The default instance lives in
## data/luneraria/balance_default.tres; tests may load variants, bots never get extras.
@export_group("Simulation")
## Fixed combat step in seconds.
@export var sim_step: float = 0.05
## Ends only the current combat phase, never the match (see REGELWERK).
@export var combat_time_limit: float = 180.0
## Lane marching speed relative to the catalogue speed of a unit.
@export var march_speed_factor: float = 1.6
@export var aggro_radius: float = 7.0
## Targets further away than this are dropped and re-acquired.
@export var leash_radius: float = 10.0
@export var lane_half_width: float = 2.6
@export_group("Economy")
@export var start_gold: int = 220
@export var base_income: int = 70
@export var income_per_round: int = 4
@export var income_growth_cap: int = 80
@export var supply_cap: int = 12
@export_range(0.0, 1.0) var sell_refund: float = 0.75
## Share of lane kill gold for the responsible lane player; the rest splits evenly in the team.
@export_range(0.0, 1.0) var lane_share: float = 0.5
@export_group("Waves")
@export var wave_base_count: int = 3
## One more monster per lane every N rounds, up to wave_max_count.
@export var wave_count_every: int = 3
@export var wave_max_count: int = 7
@export var wave_hp_growth: float = 0.11
@export var wave_damage_growth: float = 0.07
@export var wave_growth_quadratic: float = 0.0015
@export var monster_bounty_scale: float = 2.0
@export var bounty_growth: float = 0.04
## Monster types in rotation order; later indices unlock later (see LuneWaves).
@export var monster_pool: PackedStringArray = PackedStringArray(["e01", "e02", "e04", "e03", "e06", "e05"])
@export_group("Buildings")
@export var tower_outer_hp: float = 6000.0
@export var tower_inner_hp: float = 7500.0
@export var base_tower_hp: float = 9000.0
@export var core_hp: float = 12000.0
@export var tower_damage: float = 90.0
@export var base_tower_damage: float = 120.0
## Must exceed the longest unit reach (6.5 m + building radius) so towers cannot be out-ranged.
@export var tower_range: float = 8.6
@export var tower_attack_interval: float = 1.0
@export var building_radius: float = 1.3
@export var bounty_outer_tower: int = 150
@export var bounty_inner_tower: int = 210
@export var bounty_base_tower: int = 270
@export_group("Map")
## Inner / outer tower position as a fraction of the lane length from the own base.
@export var inner_tower_fraction: float = 0.17
@export var outer_tower_fraction: float = 0.35
@export var tower_side_offset: float = 3.4
static func default_balance() -> LuneBalance:
var path := "res://data/luneraria/balance_default.tres"
if ResourceLoader.exists(path):
return load(path) as LuneBalance
return LuneBalance.new()
func round_income(round_number: int) -> int:
return base_income + mini(income_growth_cap, income_per_round * maxi(0, round_number - 1))

View file

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

View file

@ -1,83 +0,0 @@
class_name LuneBot
extends RefCounted
## Plans through LuneMatch.submit exactly like the human: same costs, same caps, same phase.
## Reads only public state (committed buildings, round) and its own draft, never other drafts.
const PROFILES: Dictionary = {
"ausgewogen": {"front": .30, "melee": .25, "ranged": .25, "splash": .10, "support": .10, "upgrade_from": 6, "support_lane": 0.0},
"verteidigung": {"front": .40, "melee": .15, "ranged": .20, "splash": .10, "support": .15, "upgrade_from": 5, "support_lane": 0.0},
"druck": {"front": .20, "melee": .35, "ranged": .25, "splash": .15, "support": .05, "upgrade_from": 8, "support_lane": 0.0},
"fernkampf": {"front": .25, "melee": .10, "ranged": .40, "splash": .15, "support": .10, "upgrade_from": 6, "support_lane": 0.0},
"teamhilfe": {"front": .30, "melee": .20, "ranged": .25, "splash": .10, "support": .15, "upgrade_from": 6, "support_lane": .35},
}
static func profile_for(match_state: LuneMatch, slot: int) -> String:
var names: Array = PROFILES.keys()
return names[(slot * 7 + match_state.seed_value) % names.size()]
static func plan(match_state: LuneMatch, slot: int, profile_name: String = "") -> void:
var name: String = profile_name if not profile_name.is_empty() else profile_for(match_state, slot)
var profile: Dictionary = PROFILES[name]
var player: Dictionary = match_state.players[slot]
var ids: Array[String] = match_state.base_ids(player.bund)
var roles: Dictionary = {}
for id: String in ids:
roles[match_state.role_of(id)] = id
for guard: int in range(40):
var draft: Dictionary = player.draft
var roster: Array = draft.roster
var acted: bool = false
if roster.size() >= int(profile.upgrade_from) or roster.size() >= match_state.balance.supply_cap:
acted = _try_upgrade(match_state, slot, roster)
if not acted and roster.size() < match_state.balance.supply_cap:
var role: String = _needed_role(match_state, roster, profile)
var lane: String = player.lane
if float(profile.support_lane) > 0.0 and match_state.rng.randf() < float(profile.support_lane):
lane = _weakest_lane(match_state, int(player.team))
acted = match_state.submit(slot, {"type": "buy", "type_id": roles[role], "lane": lane}).ok
if not acted and roster.size() < int(profile.upgrade_from):
acted = _try_upgrade(match_state, slot, roster)
if not acted:
break
match_state.set_ready(slot)
static func _needed_role(match_state: LuneMatch, roster: Array, profile: Dictionary) -> String:
var counts: Dictionary = {}
for entry: Dictionary in roster:
var role: String = match_state.role_of(entry.type_id)
counts[role] = int(counts.get(role, 0)) + 1
var best: String = "front"
var best_gap: float = -INF
for role: String in LuneMatch.ROW_ORDER:
var gap: float = float(profile[role]) * float(roster.size() + 1) - float(counts.get(role, 0))
if gap > best_gap + 0.0001:
best = role
best_gap = gap
return best
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 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
return match_state.submit(slot, {"type": "upgrade", "uid": best_uid}).ok
static func _weakest_lane(match_state: LuneMatch, team: int) -> String:
var weakest: String = "mid"
var fewest: int = 99
for lane: String in LuneMap.LANES:
var towers: int = match_state.towers_alive(team, lane)
if towers < fewest:
fewest = towers
weakest = lane
return weakest

View file

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

View file

@ -1,525 +0,0 @@
class_name LuneCombat
extends RefCounted
## One automatic combat phase on all three lanes. Every step first gathers targets,
## movement, hits, heals and effects from the state at the start of the step, then applies
## them together, so processing order never decides who strikes first. Deaths of a step
## resolve together; two Ancients falling in the same step is a draw.
var map: LuneMap
var balance: LuneBalance
var round_number: int = 1
var units: Array[LuneUnit] = []
var buildings: Dictionary = {}
var elapsed: float = 0.0
var steps: int = 0
var finished: bool = false
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
balance = config
round_number = number
# ---------------------------------------------------------------- setup
func add_building(definition: Dictionary, hp: float) -> LuneUnit:
var unit := LuneUnit.new()
unit.id = _next_id
_next_id += 1
unit.kind = "core" if definition.tier == "core" else "tower"
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}
unit.position = definition.position
unit.previous_position = unit.position
unit.max_hp = float(definition.max_hp)
unit.hp = clampf(hp, 0.0, unit.max_hp)
unit.alive = unit.hp > 0.0
units.append(unit)
buildings[unit.building_id] = unit
return unit
func spawn(kind: String, team: int, lane: String, definition: Dictionary, progress: float, lateral: float, slot: int = -1, uid: String = "") -> LuneUnit:
var unit := LuneUnit.new()
unit.id = _next_id
_next_id += 1
unit.kind = kind
unit.team = team
unit.lane = lane
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)
unit.previous_position = unit.position
var forward: Vector2 = map.lane_forward(team, lane, progress)
unit.facing = atan2(forward.x, forward.y)
unit.max_hp = float(definition.get("max_hp", 1.0))
unit.hp = unit.max_hp
_start_ability(unit)
units.append(unit)
return unit
func alive_map() -> Dictionary:
var result: Dictionary = {}
for id: String in buildings:
result[id] = (buildings[id] as LuneUnit).alive
return result
func building_attackable(unit: LuneUnit, gates: Dictionary) -> bool:
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:
if finished:
return
var dt: float = balance.sim_step
elapsed += dt
steps += 1
_pending_damage.clear()
_pending_heal.clear()
_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"):
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]
var next: Vector2 = plan[0]
var heading: Vector2 = next - unit.position
unit.position = next
if heading.length_squared() > 0.000001:
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])
for entry: Array in _pending_shields:
(entry[0] as LuneUnit).grant_shield(entry[1], entry[2])
for entry: Array in _pending_effects:
(entry[0] as LuneUnit).apply_effect(entry[1], entry[2], entry[3], entry[4])
for entry: Array in _pending_dots:
(entry[0] as LuneUnit).apply_dot(entry[1], entry[2], entry[3], entry[4])
for unit: LuneUnit in living:
if _pending_damage.has(unit.id):
unit.take(_pending_damage[unit.id])
for unit: LuneUnit in living:
if unit.hp <= 0.0 and unit.alive:
unit.alive = false
unit.death_time = elapsed
unit.target = null
_record_death(unit)
_lap("resolve")
_check_end()
func _check_end() -> void:
if not destroyed_cores.is_empty():
finished = true
end_reason = "core"
return
var mobile_alive: bool = false
for unit: LuneUnit in units:
if unit.alive and unit.is_mobile():
mobile_alive = true
break
if not mobile_alive:
finished = true
end_reason = "cleared"
elif elapsed >= balance.combat_time_limit - 0.000001:
finished = true
end_reason = "timeout"
func _record_death(unit: LuneUnit) -> void:
_event_serial += 1
var event: Dictionary = {"serial": _event_serial, "step": steps, "time": elapsed, "team": unit.team, "lane": unit.lane,
"zone": unit.zone, "kind": unit.kind, "unit_id": unit.id, "def_id": str(unit.def.get("id", "")), "bounty": 0,
"owner_slot": unit.owner_slot, "persistent_uid": unit.persistent_uid}
if unit.kind in ["monster", "champion", "boss"]:
event.bounty = int(unit.def.get("bounty", 0))
elif unit.is_building():
event.bounty = int(unit.def.get("bounty", 0))
event.building_id = unit.building_id
if unit.kind == "core" and not destroyed_cores.has(unit.team):
destroyed_cores.append(unit.team)
events.append(event)
# ---------------------------------------------------------------- targeting and movement
func _hostile(unit: LuneUnit, other: LuneUnit) -> bool:
return other.alive and other.team != unit.team and other.zone == unit.zone
func _choose_target(unit: LuneUnit, living: Array[LuneUnit], gates: Dictionary) -> LuneUnit:
var current: LuneUnit = unit.target
if current != null and _hostile(unit, current) and unit.position.distance_to(current.position) <= balance.leash_radius:
if not current.is_building() or building_attackable(current, gates):
if unit.kind != "tower" or unit.position.distance_to(current.position) <= unit.attack_range():
return current
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 _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)):
best = other
best_distance = d
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 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:
best = other
best_distance = d
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:
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
## directly, so lateral formation offsets cannot stall a unit on the inside of a bend.
func _plan_move(unit: LuneUnit, living: Array[LuneUnit], dt: float) -> Array:
var step_length: float = unit.speed() * balance.march_speed_factor * unit.movement_factor() * dt
var goal: Vector2 = unit.position
var travel: float = 0.0
var marched: float = unit.progress
if unit.target != null:
var reach: float = unit.attack_range() + (balance.building_radius if unit.target.is_building() else 0.0)
var gap: float = unit.position.distance_to(unit.target.position) - reach
if gap > 0.0:
goal = unit.target.position
travel = minf(step_length, gap + 0.02)
elif unit.zone == "lane":
var limit: float = _march_limit(unit)
var anchor: Vector2 = map.lane_point(unit.team, unit.lane, unit.progress, unit.lateral)
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))
elif unit.progress < limit - 0.01:
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 off > 0.05:
goal = anchor
travel = minf(step_length, unit.position.distance_to(anchor))
var next: Vector2 = unit.position
if travel > 0.0 and goal.distance_squared_to(unit.position) > 0.000001:
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 _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
next.x = clampf(next.x, -LuneMap.HALF_EXTENT, LuneMap.HALF_EXTENT)
next.y = clampf(next.y, -LuneMap.HALF_EXTENT, LuneMap.HALF_EXTENT)
return [next, marched]
func _update_progress(unit: LuneUnit, marched: float) -> void:
var path: LuneMap.LanePath = map.lanes[unit.lane]
var limit: float = _march_limit(unit)
if unit.target != null:
# After a chase the unit continues from where it now stands, never further back.
var blue: Vector2 = unit.position if unit.team == 0 else LuneMap.mirror(unit.position)
marched = maxf(marched, minf(path.project(blue), limit))
unit.progress = maxf(unit.progress, minf(marched, maxf(limit, unit.progress)))
if unit.progress >= path.length - 0.6:
unit.reached_base = true
# ---------------------------------------------------------------- attacks
func _attack(unit: LuneUnit, living: Array[LuneUnit], gates: Dictionary, dt: float) -> void:
unit.in_range = false
var target: LuneUnit = unit.target
if target == null or not target.alive or unit.kind == "core":
unit.attack_progress = 0.0
return
var reach: float = unit.attack_range() + (balance.building_radius if target.is_building() else 0.0) + 0.035
if unit.position.distance_to(target.position) > reach:
unit.attack_progress = 0.0
return
if target.is_building() and not building_attackable(target, gates):
unit.attack_progress = 0.0
return
unit.in_range = true
var interval: float = balance.tower_attack_interval if unit.kind == "tower" else 1.0
unit.attack_progress += dt * unit.attack_speed_factor() / interval
while unit.attack_progress >= 1.0 - 0.000001:
unit.attack_progress = maxf(0.0, unit.attack_progress - 1.0)
unit.hit_count += 1
unit.attack_serial += 1
var damage: float = float(unit.def.get("damage", 0.0)) * (1.0 + unit.damage_bonus())
if unit.hit_count % 4 == 0:
if unit.ability() == "g05":
damage *= 1.5
elif unit.ability() == "g06":
damage *= 1.75
if target.is_building():
damage *= float(unit.def.get("siege_factor", 1.0))
_queue_damage(target, target.mitigated(damage, int(unit.def.get("attack_type", 0))), gates)
_on_hit(unit, target, living, gates)
func _queue_damage(target: LuneUnit, amount: float, gates: Dictionary) -> void:
if amount <= 0.0 or not target.alive:
return
# The building gate applies to every source: ranged, splash, DoT and abilities.
if target.is_building() and not building_attackable(target, gates):
return
_pending_damage[target.id] = float(_pending_damage.get(target.id, 0.0)) + amount
func _queue_heal(target: LuneUnit, amount: float) -> void:
_pending_heal[target.id] = float(_pending_heal.get(target.id, 0.0)) + amount
# ---------------------------------------------------------------- abilities (ported from the Saumwacht rules)
func _start_ability(unit: LuneUnit) -> void:
unit.ability_ready = 6.0
match unit.ability():
"w03", "w04": unit.ability_ready = 5.0
"w09", "w10": unit.ability_ready = 4.0
"s02": unit.grant_shield(unit.max_hp * 0.15)
"e08": unit.grant_shield(unit.max_hp * 0.20)
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] = []
# 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:
continue
if allied and other.is_building():
continue
if other.position.distance_to(center) <= radius + 0.000001:
result.append(other)
result.sort_custom(func(a: LuneUnit, b: LuneUnit) -> bool:
if health_priority and not is_equal_approx(a.hp / a.max_hp, b.hp / b.max_hp):
return a.hp / a.max_hp < b.hp / b.max_hp
var ad: float = a.position.distance_to(center)
var bd: float = b.position.distance_to(center)
return a.id < b.id if is_equal_approx(ad, bd) else ad < bd)
if result.size() > maximum:
result.resize(maximum)
return result
func _ability_step(unit: LuneUnit, living: Array[LuneUnit]) -> void:
if unit.combat_time + 0.000001 < unit.ability_ready:
return
var ability: String = unit.ability()
var success: bool = false
match ability:
"w03", "w04":
if unit.hp < unit.max_hp:
_queue_heal(unit, 100.0 if ability == "w04" else 35.0)
success = true
"w09", "w10":
var upgraded: bool = ability == "w10"
for ally: LuneUnit in _nearby(unit, living, true, unit.position, 4.5 if upgraded else 4.0, 3 if upgraded else 1, null, true):
if ally.hp < ally.max_hp:
_queue_heal(ally, 80.0 if upgraded else 65.0)
success = true
"g09", "g10":
var upgraded: bool = ability == "g10"
var allies: Array[LuneUnit] = _nearby(unit, living, true, unit.position, 4.0 if upgraded else 3.0, 4 if upgraded else 3, null, true)
for ally: LuneUnit in allies:
_pending_effects.append([ally, "hearth_warmth", "damage", 0.15 if upgraded else 0.10, 4.0])
if upgraded:
_queue_heal(ally, 45.0)
success = not allies.is_empty()
"e06":
for ally: LuneUnit in _nearby(unit, living, true, unit.position, 4.0, 100, null, true):
if ally.hp < ally.max_hp:
_queue_heal(ally, 30.0 * float(unit.def.get("hp_factor", 1.0)))
success = true
break
"s03", "e09":
var victims: Array[LuneUnit] = _nearby(unit, living, false, unit.position, 6.0 if ability == "e09" else 3.0, 3 if ability == "e09" else 4)
for victim: LuneUnit in victims:
_pending_effects.append([victim, "dissonance", "attack_slow", 0.15, 3.0])
success = not victims.is_empty()
"s05":
var victims: Array[LuneUnit] = _nearby(unit, living, false, unit.position, 5.0, 1)
for victim: LuneUnit in victims:
_pending_effects.append([victim, "rift_wound", "healing_reduction", 0.30, 4.0])
success = not victims.is_empty()
if success:
unit.ability_ready = unit.combat_time + (5.0 if ability in ["w03", "w04"] else (4.0 if ability in ["w09", "w10"] else 6.0))
unit.ability_serial += 1
func _on_hit(unit: LuneUnit, primary: LuneUnit, living: Array[LuneUnit], gates: Dictionary) -> void:
var ability: String = unit.ability()
var hits: int = unit.hit_count
match ability:
"w01", "w02":
if hits % 3 == 0:
_pending_effects.append([primary, "root_grasp", "movement_slow", 0.30 if ability == "w02" else 0.20, 3.0 if ability == "w02" else 2.0])
if ability == "w02":
_pending_effects.append([primary, "root_grasp_attack", "attack_slow", 0.10, 3.0])
"w05", "w06":
if hits % 4 == 0:
_pending_dots.append([primary, "seed_burn", 10.0 if ability == "w06" else 3.0, 4.0, int(unit.def.attack_type)])
"w07", "w08":
if hits % 3 == 0:
_splash(unit, primary, living, gates, 0.80 if ability == "w08" else 0.60, 2.5 if ability == "w08" else 2.0, 4 if ability == "w08" else 3)
"g01", "g02":
if hits % 3 == 0:
_pending_dots.append([primary, "burn", 12.0 if ability == "g02" else 4.0, 3.0, int(unit.def.attack_type)])
"g07": _splash(unit, primary, living, gates, 0.30, 1.5, 3)
"g08": _splash(unit, primary, living, gates, 0.40, 2.0, 4)
"e05":
if hits % 4 == 0: _splash(unit, primary, living, gates, 0.40, 1.5, 2)
"e07":
if hits % 4 == 0: _splash(unit, primary, living, gates, 0.60, 2.0, 3)
"e10":
if hits % 4 == 0: _splash(unit, primary, living, gates, 0.60, 2.5, 4)
"s04":
if hits % 4 == 0: _splash(unit, primary, living, gates, 0.50, 2.0, 3)
func _splash(unit: LuneUnit, primary: LuneUnit, living: Array[LuneUnit], gates: Dictionary, factor: float, radius: float, maximum: int) -> void:
for victim: LuneUnit in _nearby(unit, living, false, primary.position, radius, maximum, primary):
_queue_damage(victim, victim.mitigated(float(unit.def.damage) * factor, int(unit.def.attack_type)), gates)
# ---------------------------------------------------------------- queries for views and tests
func mobile_count(team: int = -1) -> int:
var count: int = 0
for unit: LuneUnit in units:
if unit.alive and unit.is_mobile() and (team < 0 or unit.team == team):
count += 1
return count

View file

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

View file

@ -1,168 +0,0 @@
class_name LuneMap
extends RefCounted
## Map data in ground coordinates Vector2(x, z): Blau (team 0) in the south-west,
## Rot (team 1) in the north-east. Rot is the mirror image of Blau across x = z
## (mirror(v) = Vector2(v.y, v.x)), Bottom is Top mirrored across the Mid line.
## Both hold by construction, so strecken, towers and spawns are functionally equal.
const LANES: Array[String] = ["top", "mid", "bot"]
const LANE_NAMES: Dictionary = {"top": "Top", "mid": "Mid", "bot": "Bottom"}
# First halves from the Blau end to the centre line; the Rot half is mirrored.
const TOP_HALF: Array[Vector2] = [Vector2(-24, 22), Vector2(-26, 6), Vector2(-26.5, -12), Vector2(-25, -25)]
const MID_HALF: Array[Vector2] = [Vector2(-21, 21), Vector2(-11, 10), Vector2(0, 0)]
const CORE_BLUE := Vector2(-30, 30)
const BASE_TOWERS_BLUE: Array[Vector2] = [Vector2(-28.8, 24.6), Vector2(-24.6, 28.8)]
## Neutral boss grotto, north-west of Mid and on the symmetry axis (M2).
const GROTTO := Vector2(-11.5, -11.5)
const HALF_EXTENT: float = 36.0
var balance: LuneBalance
var lanes: Dictionary = {}
var buildings: Array[Dictionary] = []
class LanePath:
extends RefCounted
var points: PackedVector2Array
var cumulative: PackedFloat32Array
var length: float = 0.0
func _init(source: Array[Vector2]) -> void:
points = PackedVector2Array(source)
cumulative.append(0.0)
for i: int in range(1, points.size()):
length += points[i].distance_to(points[i - 1])
cumulative.append(length)
func point_at(s: float) -> Vector2:
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 span: float = maxf(0.0001, cumulative[i] - cumulative[i - 1])
return points[i - 1].lerp(points[i], clampf((d - cumulative[i - 1]) / span, 0.0, 1.0))
return points[points.size() - 1]
func tangent_at(s: float) -> Vector2:
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:
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
for i: int in range(1, points.size()):
var a: Vector2 = points[i - 1]
var b: Vector2 = points[i]
var ab: Vector2 = b - a
var t: float = clampf((position - a).dot(ab) / maxf(0.0001, ab.length_squared()), 0.0, 1.0)
var d: float = position.distance_squared_to(a + ab * t)
if d < best:
best = d
best_s = cumulative[i - 1] + ab.length() * t
return best_s
static func mirror(v: Vector2) -> Vector2:
return Vector2(v.y, v.x)
static func mirror_mid(v: Vector2) -> Vector2:
return Vector2(-v.y, -v.x)
static func full_lane(half: Array[Vector2]) -> Array[Vector2]:
var result: Array[Vector2] = half.duplicate()
for i: int in range(half.size() - 2, -1, -1):
result.append(mirror(half[i]))
return result
func _init(config: LuneBalance = null) -> void:
balance = config if config != null else LuneBalance.default_balance()
var bot_half: Array[Vector2] = []
for point: Vector2 in TOP_HALF:
bot_half.append(mirror_mid(point))
lanes["top"] = LanePath.new(full_lane(TOP_HALF))
lanes["mid"] = LanePath.new(full_lane(MID_HALF))
lanes["bot"] = LanePath.new(full_lane(bot_half))
_build_buildings()
## Lane coordinate s for a team's own marching progress p (0 at its own base).
func s_for(team: int, lane: String, progress: float) -> float:
var path: LanePath = lanes[lane]
return progress if team == 0 else path.length - progress
func progress_for(team: int, lane: String, s: float) -> float:
var path: LanePath = lanes[lane]
return s if team == 0 else path.length - s
## 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 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]
var tangent: Vector2 = path.tangent_at(clampf(progress, 0.0, path.length))
return tangent if team == 0 else mirror(tangent)
func core_position(team: int) -> Vector2:
return CORE_BLUE if team == 0 else mirror(CORE_BLUE)
func _build_buildings() -> void:
buildings.clear()
for team: int in range(2):
for lane: String in LANES:
var path: LanePath = lanes[lane]
for tier: String in ["inner", "outer"]:
var fraction: float = balance.inner_tower_fraction if tier == "inner" else balance.outer_tower_fraction
var progress: float = path.length * fraction
buildings.append({"id": "%s_%s_%s" % ["blau" if team == 0 else "rot", lane, tier], "team": team, "lane": lane,
"tier": tier, "progress": progress, "position": lane_point(team, lane, progress, balance.tower_side_offset),
"max_hp": balance.tower_inner_hp if tier == "inner" else balance.tower_outer_hp,
"damage": balance.tower_damage, "range": balance.tower_range,
"bounty": balance.bounty_inner_tower if tier == "inner" else balance.bounty_outer_tower})
for index: int in range(2):
var point: Vector2 = BASE_TOWERS_BLUE[index]
buildings.append({"id": "%s_base_%d" % ["blau" if team == 0 else "rot", index + 1], "team": team, "lane": "base",
"tier": "base", "progress": -1.0, "position": point if team == 0 else mirror(point),
"max_hp": balance.base_tower_hp, "damage": balance.base_tower_damage, "range": balance.tower_range,
"bounty": balance.bounty_base_tower})
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:
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:
var prefix: String = "blau" if int(definition.team) == 0 else "rot"
match str(definition.tier):
"outer":
return true
"inner":
return not alive.get("%s_%s_outer" % [prefix, definition.lane], false)
"base":
for lane: String in LANES:
if not alive.get("%s_%s_outer" % [prefix, lane], false) and not alive.get("%s_%s_inner" % [prefix, lane], false):
return true
return false
"core":
return not alive.get(prefix + "_base_1", false) and not alive.get(prefix + "_base_2", false)
return false

View file

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

View file

@ -1,334 +0,0 @@
class_name LuneMatch
extends RefCounted
## Authoritative match: six player slots in two teams, persistent ownership, planning
## drafts that are validated and committed atomically, combat, settlement and victory.
## A match ends only when an Ancient falls. There is no round limit and no HP decision.
enum Phase { PLANNING, COMBAT, OVER }
const ONGOING := -2
const DRAW := -1
const BOT_NAMES: Array[String] = ["Eichrufer", "Aschenzunge", "Quellwächter", "Glutfaust", "Moosauge", "Basaltherz", "Rindenkrone", "Funkenschritt"]
const ROLES: Dictionary = {"g01": "melee", "g03": "front", "g05": "ranged", "g07": "splash", "g09": "support",
"w01": "melee", "w03": "front", "w05": "ranged", "w07": "splash", "w09": "support"}
const ROW_ORDER: Array[String] = ["front", "melee", "splash", "ranged", "support"]
var balance: LuneBalance
var map: LuneMap
var rng := RandomNumberGenerator.new()
var seed_value: int = 0
var round_number: int = 1
var phase: int = Phase.PLANNING
var winner: int = ONGOING
var end_note: String = ""
var players: Array[Dictionary] = []
var building_hp: Dictionary = {}
var combat: LuneCombat
var last_settlement: Dictionary = {}
var history: Array[Dictionary] = []
## 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()
map = LuneMap.new(balance)
## options: seed, human_slot (-1 for six bots), bunds (Array of six), names.
func setup(options: Dictionary = {}) -> void:
seed_value = int(options.get("seed", 1))
rng.seed = seed_value
round_number = 1
phase = Phase.PLANNING
winner = ONGOING
end_note = ""
history.clear()
last_settlement = {}
_reward_rotation = [0, 0]
players.clear()
var human: int = int(options.get("human_slot", 1))
var bunds: Array = options.get("bunds", [])
for slot: int in range(6):
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, "next_seq": 1}
player.draft = _snapshot(player)
player.undo = []
players.append(player)
building_hp.clear()
for definition: Dictionary in map.buildings:
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), "next_seq": int(player.next_seq)}
func slot_of(team: int, lane: String) -> int:
return team * 3 + LuneMap.LANES.find(lane)
func base_ids(bund: String) -> Array[String]:
return DataCatalog.faction_ids(bund)
# ---------------------------------------------------------------- planning commands
func submit(slot: int, command: Dictionary) -> Dictionary:
if phase != Phase.PLANNING:
return _result(false, "Befehle sind nur in der Planung möglich.")
if slot < 0 or slot >= players.size():
return _result(false, "Unbekannter Spieler.")
var player: Dictionary = players[slot]
var draft: Dictionary = player.draft
var before: Dictionary = draft.duplicate(true)
var result: Dictionary = _apply(player, draft, command)
if result.ok:
(player.undo as Array).append(before)
return result
func undo(slot: int) -> Dictionary:
var player: Dictionary = players[slot]
if phase != Phase.PLANNING or (player.undo as Array).is_empty():
return _result(false, "Nichts rückgängig zu machen.")
player.draft = (player.undo as Array).pop_back()
return _result(true, "Letzte Planung zurückgenommen.")
func _find(roster: Array, uid: String) -> int:
for index: int in range(roster.size()):
if str(roster[index].uid) == uid:
return index
return -1
func _apply(player: Dictionary, draft: Dictionary, command: Dictionary) -> Dictionary:
var roster: Array = draft.roster
match str(command.get("type", "")):
"buy":
var type_id: String = str(command.get("type_id", ""))
if not base_ids(player.bund).has(type_id):
return _result(false, "Diese Truppe gehört nicht zu deinem Bund.")
if roster.size() >= balance.supply_cap:
return _result(false, "Versorgungsgrenze erreicht (%d Truppen)." % balance.supply_cap)
var cost: int = int(DataCatalog.get_unit(type_id).cost)
if int(draft.gold) < cost:
return _result(false, "Es fehlen %d Gold." % (cost - int(draft.gold)))
var lane: String = str(command.get("lane", player.lane))
if not LuneMap.LANES.has(lane):
return _result(false, "Unbekannte Lane.")
draft.gold = int(draft.gold) - cost
# 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", "")))
if index < 0:
return _result(false, "Diese Truppe gehört dir nicht.")
var entry: Dictionary = roster[index]
var upgrade_id: String = str(DataCatalog.get_unit(entry.type_id).get("upgrade_id", ""))
if upgrade_id.is_empty():
return _result(false, "Bereits vollständig aufgewertet.")
var cost: int = int(DataCatalog.get_unit(upgrade_id).cost)
if int(draft.gold) < cost:
return _result(false, "Es fehlen %d Gold." % (cost - int(draft.gold)))
draft.gold = int(draft.gold) - cost
entry.type_id = upgrade_id
entry.invested = int(entry.invested) + cost
return _result(true, "Zu %s aufgewertet." % DataCatalog.get_unit(upgrade_id).display_name)
"sell":
var index: int = _find(roster, str(command.get("uid", "")))
if index < 0:
return _result(false, "Diese Truppe gehört dir nicht.")
var entry: Dictionary = roster[index]
# A purchase from this planning is cancelled at full price; committed troops sell at the refund rate.
var refund: int = int(entry.invested) if bool(entry.get("fresh", false)) else floori(float(entry.invested) * balance.sell_refund)
draft.gold = int(draft.gold) + refund
roster.remove_at(index)
return _result(true, "Für %d Gold zurückgegeben." % refund)
"assign":
var index: int = _find(roster, str(command.get("uid", "")))
var lane: String = str(command.get("lane", ""))
if index < 0 or not LuneMap.LANES.has(lane):
return _result(false, "Ungültige Zuweisung.")
roster[index].lane = lane
return _result(true, "Auf %s eingeteilt." % LuneMap.LANE_NAMES[lane])
return _result(false, "Unbekannter Befehl.")
func _result(ok: bool, message: String) -> Dictionary:
return {"ok": ok, "message": message}
func set_ready(slot: int, value: bool = true) -> void:
if phase == Phase.PLANNING:
players[slot].ready = value
func all_ready() -> bool:
for player: Dictionary in players:
if not bool(player.ready):
return false
return true
# ---------------------------------------------------------------- phase changes
## Validates every draft again and commits all of them at once, then starts combat.
func begin_combat() -> bool:
if phase != Phase.PLANNING:
return false
for player: Dictionary in players:
if not _draft_valid(player):
player.draft = _snapshot(player)
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")
player.undo = []
combat = LuneCombat.new(map, balance, round_number)
for definition: Dictionary in map.buildings:
if float(building_hp[definition.id]) > 0.0:
combat.add_building(definition, float(building_hp[definition.id]))
_spawn_waves()
_spawn_armies()
phase = Phase.COMBAT
return true
func _draft_valid(player: Dictionary) -> bool:
var draft: Dictionary = player.draft
if int(draft.gold) < 0 or (draft.roster as Array).size() > balance.supply_cap:
return false
var allowed: Array[String] = base_ids(player.bund)
for entry: Dictionary in draft.roster:
var id: String = str(entry.type_id)
var base: String = id
for candidate: String in allowed:
if str(DataCatalog.get_unit(candidate).get("upgrade_id", "")) == id:
base = candidate
if not allowed.has(base) or not LuneMap.LANES.has(str(entry.lane)):
return false
return true
func _spawn_waves() -> void:
var wave: Array[Dictionary] = LuneWaves.build(balance, round_number)
for team: int in range(2):
for lane: String in LuneMap.LANES:
for index: int in range(wave.size()):
var row: int = index / 3
var lateral: float = (float(index % 3) - 1.0) * 1.3
combat.spawn("monster", team, lane, wave[index].duplicate(true), 11.0 - float(row) * 1.4, lateral)
func _spawn_armies() -> void:
for team: int in range(2):
for lane: String in LuneMap.LANES:
# The lane owner's troops form the first rows; supporting players follow behind.
var owner: int = slot_of(team, lane)
var ordered: Array = []
for slot: int in [owner] + [team * 3, team * 3 + 1, team * 3 + 2].filter(func(s: int) -> bool: return s != owner):
var entries: Array = (players[slot].roster as Array).filter(func(e: Dictionary) -> bool: return e.lane == lane)
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 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()):
var slot: int = ordered[index][0]
var entry: Dictionary = ordered[index][1]
var row: int = index / 4
var lateral: float = (float(index % 4) - 1.5) * 1.2
var definition: Dictionary = DataCatalog.get_unit(entry.type_id)
combat.spawn("troop", team, lane, definition, maxf(0.4, 7.2 - float(row) * 1.5), lateral, slot, str(entry.uid))
func role_of(type_id: String) -> String:
var id: String = type_id
for base: String in ROLES:
if base == id or str(DataCatalog.get_unit(base).get("upgrade_id", "")) == id:
return ROLES[base]
return "melee"
func step_combat() -> void:
if phase != Phase.COMBAT or combat == null:
return
combat.step()
if combat.finished:
_settle()
## Runs the current combat phase to its end (tests, bots-only simulations).
func finish_combat() -> void:
while phase == Phase.COMBAT:
step_combat()
func _settle() -> void:
var gains: Array[int] = [0, 0, 0, 0, 0, 0]
var lane_kills: Dictionary = {}
var buildings_lost: Array[String] = []
for event: Dictionary in combat.events:
var dying: int = int(event.team)
var killer: int = 1 - dying
var bounty: int = int(event.bounty)
if event.kind in ["monster", "champion", "boss"] and bounty > 0 and event.zone == "lane":
var owner: int = slot_of(killer, str(event.lane))
var lane_part: int = int(floor(float(bounty) * balance.lane_share))
gains[owner] += lane_part
_split(gains, killer, bounty - lane_part)
lane_kills[event.lane] = int(lane_kills.get(event.lane, 0)) + 1
elif event.kind in ["tower", "core"]:
buildings_lost.append(str(event.building_id))
_split(gains, killer, bounty)
for unit: LuneUnit in combat.units:
if unit.is_building():
building_hp[unit.building_id] = unit.hp if unit.alive else 0.0
var income: int = balance.round_income(round_number)
for player: Dictionary in players:
var total: int = gains[player.slot] + (0 if not combat.destroyed_cores.is_empty() else income)
player.gold = int(player.gold) + total
player.earned = int(player.earned) + total
last_settlement = {"round": round_number, "gains": gains, "income": income, "buildings_lost": buildings_lost,
"end_reason": combat.end_reason, "combat_time": combat.elapsed, "lane_kills": lane_kills}
history.append({"round": round_number, "end_reason": combat.end_reason, "combat_time": combat.elapsed,
"buildings": _alive_count(), "gold": players.map(func(p: Dictionary) -> int: return int(p.gold)),
"lost": buildings_lost.duplicate()})
if not combat.destroyed_cores.is_empty():
winner = DRAW if combat.destroyed_cores.size() >= 2 else 1 - combat.destroyed_cores[0]
end_note = "Beide Ancients fielen im selben Schritt." if winner == DRAW else "Ancient von %s zerstört." % ("Blau" if combat.destroyed_cores[0] == 0 else "Rot")
phase = Phase.OVER
return
round_number += 1
phase = Phase.PLANNING
for player: Dictionary in players:
player.draft = _snapshot(player)
player.undo = []
player.ready = not bool(player.is_human)
## Distributes an amount evenly over a team; the remainder rotates so no slot is favoured.
func _split(gains: Array[int], team: int, amount: int) -> void:
if amount <= 0:
return
var share: int = amount / 3
var rest: int = amount % 3
for offset: int in range(3):
gains[team * 3 + offset] += share
for index: int in range(rest):
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]
for id: String in building_hp:
if float(building_hp[id]) > 0.0:
counts[0 if id.begins_with("blau") else 1] += 1
return counts
func building_alive(id: String) -> bool:
return float(building_hp.get(id, 0.0)) > 0.0
## 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"
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"])
func gold_of(slot: int) -> int:
return int(players[slot].draft.gold) if phase == Phase.PLANNING else int(players[slot].gold)

View file

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

View file

@ -1,163 +0,0 @@
class_name LuneUnit
extends RefCounted
## One combat instance. Persistent ownership stays in LuneMatch; this object lives for
## one combat phase. Damage, heals and effects are queued and applied by LuneCombat.
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
var persistent_uid: String = ""
var lane: String = "mid"
var zone: String = "lane"
var def: Dictionary = {}
var building_id: String = ""
var position: Vector2 = Vector2.ZERO
var previous_position: Vector2 = Vector2.ZERO
var facing: float = 0.0
var hp: float = 1.0
var max_hp: float = 1.0
var alive: bool = true
var target: LuneUnit = null
var progress: float = 0.0
var lateral: float = 0.0
var attack_progress: float = 0.0
var hit_count: int = 0
var combat_time: float = 0.0
var shield: float = 0.0
var shield_remaining: float = INF
var effects: Dictionary = {}
var dots: Dictionary = {}
var ability_ready: float = 6.0
var reached_base: bool = false
# Presentation-only signals; never read by the rules.
var attack_serial: int = 0
var ability_serial: int = 0
var hit_flash: float = 0.0
var in_range: bool = false
var death_time: float = -1.0
func is_building() -> bool:
return kind == "tower" or kind == "core"
func is_mobile() -> bool:
return kind in MOBILE_KINDS
func attack_range() -> float:
return float(def.get("attack_range", 1.0))
func speed() -> float:
return float(def.get("speed", 2.0))
func armor_type() -> int:
return clampi(int(def.get("armor_type", 1 if is_building() else 0)), 0, 2)
func ability() -> String:
return str(def.get("ability", ""))
func is_boss() -> bool:
return kind == "boss" or bool(def.get("is_boss", false))
func mitigated(raw: float, attack_type: int) -> float:
var value: float = maxf(0.0, raw) * float(DAMAGE_MATRIX[clampi(attack_type, 0, 2)][armor_type()])
return value * (1.0 - damage_reduction())
## Applies an already mitigated amount through the shield.
func take(amount: float) -> void:
if not alive or amount <= 0.0:
return
var absorbed: float = minf(shield, amount)
shield -= absorbed
hp = maxf(0.0, hp - (amount - absorbed))
hit_flash = .18
func heal(amount: float) -> void:
if alive and not is_building():
hp = minf(max_hp, hp + maxf(0.0, amount) * (1.0 - strongest_effect("healing_reduction")))
func grant_shield(amount: float, duration: float = INF) -> void:
if alive and amount > shield:
shield = amount
shield_remaining = duration
func apply_effect(family: String, effect_kind: String, amount: float, duration: float) -> void:
# Buildings ignore crowd control and buffs; their gates are handled elsewhere.
if not alive or is_building():
return
if effects.has(family):
effects[family].amount = maxf(float(effects[family].amount), amount)
effects[family].remaining = maxf(float(effects[family].remaining), duration)
else:
effects[family] = {"kind": effect_kind, "amount": amount, "remaining": duration}
func apply_dot(family: String, damage: float, duration: float, attack_type: int) -> void:
if not alive or is_building():
return
if dots.has(family):
var effect: Dictionary = dots[family]
if damage > float(effect.damage):
effect.damage = damage
effect.attack_type = attack_type
effect.remaining = maxf(float(effect.remaining), duration)
else:
dots[family] = {"damage": damage, "remaining": duration, "next_tick": 1.0, "attack_type": attack_type}
func strongest_effect(effect_kind: String) -> float:
var amount: float = 0.0
for effect: Dictionary in effects.values():
if str(effect.kind) == effect_kind:
amount = maxf(amount, float(effect.amount))
return amount
func damage_bonus() -> float:
var result: float = 0.0
for effect: Dictionary in effects.values():
if str(effect.kind) == "damage":
result += float(effect.amount)
return minf(0.30, result) + float(def.get("damage_bonus", 0.0))
func damage_reduction() -> float:
var result: float = strongest_effect("reduction")
match ability():
"g03":
if combat_time < 8.0 - 0.000001:
result = maxf(result, 0.15)
"g04":
result = maxf(result, 0.25 if combat_time < 10.0 - 0.000001 else 0.10)
return minf(0.35, result + float(def.get("reduction_bonus", 0.0)))
func attack_speed_factor() -> float:
var slowing: float = strongest_effect("attack_slow") * (0.5 if is_boss() else 1.0)
return 1.0 + clampf(strongest_effect("attack_speed") - slowing, -0.25, 0.25)
func movement_factor() -> float:
return 1.0 - minf(0.35, strongest_effect("movement_slow") * (0.5 if is_boss() else 1.0))
## Status timers; DoT damage is returned so the caller can queue it with all other damage.
func advance_status(delta: float) -> float:
combat_time += delta
hit_flash = maxf(0.0, hit_flash - delta)
for family: String in effects.keys():
effects[family].remaining -= delta
if float(effects[family].remaining) <= 0.000001:
effects.erase(family)
if shield_remaining != INF:
shield_remaining -= delta
if shield_remaining <= 0.000001:
shield = 0.0
var dot_damage: float = 0.0
for family: String in dots.keys():
var effect: Dictionary = dots[family]
effect.next_tick -= minf(delta, maxf(0.0, float(effect.remaining)))
while float(effect.next_tick) <= 0.000001:
dot_damage += mitigated(float(effect.damage), int(effect.attack_type))
effect.next_tick += 1.0
effect.remaining -= delta
if float(effect.remaining) <= 0.000001:
dots.erase(family)
return dot_damage

View file

@ -1 +0,0 @@
uid://3658nsy6hfo

View file

@ -1,38 +0,0 @@
class_name LuneWaves
extends RefCounted
## Endless monster waves. Both teams receive the identical composition per lane, so the
## waves never favour a side. Count is capped; strength grows through stats and later types.
const MELEE: Array[String] = ["e01", "e02", "e05"]
static func strength(balance: LuneBalance, round_number: int, growth: float) -> float:
var r: float = float(maxi(1, round_number) - 1)
return 1.0 + growth * r + balance.wave_growth_quadratic * r * r
static func monster_count(balance: LuneBalance, round_number: int) -> int:
var extra: int = (maxi(1, round_number) - 1) / maxi(1, balance.wave_count_every)
return mini(balance.wave_max_count, balance.wave_base_count + extra)
static func unlocked_types(balance: LuneBalance, round_number: int) -> PackedStringArray:
var pool: PackedStringArray = balance.monster_pool
var available: int = clampi(1 + (maxi(1, round_number) - 1) / 2, 1, pool.size())
return pool.slice(0, available)
## Definitions for one lane of one team, melee first so they lead the march.
static func build(balance: LuneBalance, round_number: int) -> Array[Dictionary]:
var types: PackedStringArray = unlocked_types(balance, round_number)
var hp_factor: float = strength(balance, round_number, balance.wave_hp_growth)
var damage_factor: float = strength(balance, round_number, balance.wave_damage_growth)
var bounty_factor: float = balance.monster_bounty_scale * (1.0 + balance.bounty_growth * float(maxi(1, round_number) - 1))
var melee: Array[Dictionary] = []
var ranged: Array[Dictionary] = []
for index: int in range(monster_count(balance, round_number)):
var type_id: String = types[(index + round_number) % types.size()]
var source: Dictionary = DataCatalog.ENEMIES[type_id]
var monster: Dictionary = source.duplicate(true)
monster.max_hp = float(source.max_hp) * hp_factor
monster.damage = float(source.damage) * damage_factor
monster.hp_factor = hp_factor
monster.bounty = int(clampf(roundf(float(source.bounty) * bounty_factor), 1.0, 1.0e12))
(melee if type_id in MELEE else ranged).append(monster)
return melee + ranged

View file

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

View file

@ -1,224 +0,0 @@
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

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

View file

@ -1,92 +0,0 @@
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

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

View file

@ -1,424 +0,0 @@
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

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

View file

@ -1,81 +0,0 @@
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

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

View file

@ -1,66 +0,0 @@
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

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

View file

@ -1,68 +0,0 @@
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

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

View file

@ -1,76 +0,0 @@
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

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

View file

@ -1,134 +0,0 @@
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

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

View file

@ -1,303 +0,0 @@
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

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

View file

@ -50,13 +50,6 @@ var _advanced_button: Button
var _advanced_summary: Label var _advanced_summary: Label
var _timed_hint: Label var _timed_hint: Label
var _title_caption: 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_heading: Label
var _style_facts: Label var _style_facts: Label
var _setup_caption: Label var _setup_caption: Label
@ -185,16 +178,14 @@ func _build_title() -> void:
var brand := HBoxContainer.new() var brand := HBoxContainer.new()
brand.add_theme_constant_override("separation", 22) brand.add_theme_constant_override("separation", 22)
column.add_child(brand) column.add_child(brand)
emblem_rect = _emblem("res://assets/ui/emblems/saumwacht.svg", 112, brand) _emblem("res://assets/ui/emblems/saumwacht.svg", 112, brand)
var names := VBoxContainer.new() var names := VBoxContainer.new()
names.alignment = BoxContainer.ALIGNMENT_CENTER names.alignment = BoxContainer.ALIGNMENT_CENTER
names.add_theme_constant_override("separation", 0) names.add_theme_constant_override("separation", 0)
brand.add_child(names) brand.add_child(names)
brand_names = names _label("SAUMWACHT", "TitleLabel", names)
title_label = _label("SAUMWACHT", "TitleLabel", names) _label("DIE GRÜNE KLUFT", "SubtitleLabel", 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) 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.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
lore.custom_minimum_size.x = 560 lore.custom_minimum_size.x = 560
var gap := Control.new() var gap := Control.new()
@ -217,7 +208,6 @@ func _build_title() -> void:
var quit := _button("Spiel beenden", "QuietButton", column, func() -> void: quit_requested.emit()) var quit := _button("Spiel beenden", "QuietButton", column, func() -> void: quit_requested.emit())
quit.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN quit.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
var version := _label("Demo %s · Einzelspieler" % str(ProjectSettings.get_setting("application/config/version", "")), "MutedLabel", _title_screen) 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.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
version.offset_left = -360 version.offset_left = -360
version.offset_right = -40 version.offset_right = -40
@ -480,7 +470,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 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 var to: Control = _title_screen if next == Screen.TITLE else _setup_screen
screen = next screen = next
stage.set_shot_named("title" if next == Screen.TITLE else _setup_shot(), instant or reduced_motion) stage.set_shot_named("title" if next == Screen.TITLE else GameState.factions[0], instant or reduced_motion)
_title_scrim.visible = next == Screen.TITLE _title_scrim.visible = next == Screen.TITLE
_setup_scrim.visible = next == Screen.SETUP _setup_scrim.visible = next == Screen.SETUP
if _fade != null and _fade.is_valid(): if _fade != null and _fade.is_valid():
@ -506,10 +496,6 @@ func show_screen(next: int, instant: bool = false) -> void:
from.modulate.a = 1.0) from.modulate.a = 1.0)
_focus_default.call_deferred() _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: func _focus_default() -> void:
if not is_visible_in_tree(): if not is_visible_in_tree():
return return

View file

@ -1,321 +0,0 @@
extends Node
## Rules tests for the Champions of Luneraria core (no rendering). Legacy Saumwacht tests
## stay in tests/test_runner.gd. Run: godot --headless --path . res://tests/luneraria/test_lune_core.tscn
var checks: int = 0
var failures: Array[String] = []
func check(ok: bool, label: String) -> void:
checks += 1
if not ok:
failures.append(label)
printerr("LUNE_FAIL: ", label)
func _ready() -> void:
call_deferred("run")
func new_match(seed: int = 7, human: int = -1) -> LuneMatch:
var state := LuneMatch.new()
state.setup({"seed": seed, "human_slot": human, "bunds": ["glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund"]})
return state
func run() -> void:
_test_structure()
_test_symmetry()
_test_gates()
_test_victory_and_draw()
_test_endless_waves()
_test_commands_and_ownership()
_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"))
file.close()
print("LUNE_CORE_%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 _test_structure() -> void:
var state := new_match()
check(state.players.size() == 6, "six player slots")
var teams: Dictionary = {}
var roles: Dictionary = {}
for player: Dictionary in state.players:
teams[player.team] = int(teams.get(player.team, 0)) + 1
roles["%d-%s" % [player.team, player.lane]] = true
check(teams.size() == 2 and teams[0] == 3 and teams[1] == 3, "exactly two teams of three")
check(roles.size() == 6, "six distinct owner roles (team x lane)")
check(LuneMap.LANES == ["top", "mid", "bot"] and state.map.lanes.size() == 3, "three lanes")
var towers: int = 0
var cores: int = 0
var per_team_lane: Dictionary = {}
var base_towers: Array[int] = [0, 0]
for definition: Dictionary in state.map.buildings:
if definition.tier == "core":
cores += 1
else:
towers += 1
if definition.tier == "base":
base_towers[definition.team] += 1
else:
per_team_lane["%d-%s" % [definition.team, definition.lane]] = int(per_team_lane.get("%d-%s" % [definition.team, definition.lane], 0)) + 1
check(towers == 16 and cores == 2, "sixteen towers and two Ancients")
check(per_team_lane.size() == 6 and per_team_lane.values().all(func(v: int) -> bool: return v == 2), "two lane towers per team and lane")
check(base_towers == [2, 2], "two base towers per team")
func _test_symmetry() -> void:
var map := LuneMap.new()
for lane: String in LuneMap.LANES:
var path: LuneMap.LanePath = map.lanes[lane]
var worst: float = 0.0
for i: int in range(21):
var s: float = path.length * float(i) / 20.0
worst = maxf(worst, LuneMap.mirror(path.point_at(s)).distance_to(path.point_at(path.length - s)))
check(worst < 0.001, "%s lane is its own mirror image (team fairness)" % lane)
check(absf(map.lanes["top"].length - map.lanes["bot"].length) < 0.001, "top and bottom lanes have equal length")
for definition: Dictionary in map.buildings:
if definition.team == 0:
var twin: Dictionary = map.building_def(str(definition.id).replace("blau", "rot"))
check(LuneMap.mirror(definition.position).distance_to(twin.position) < 0.001 and is_equal_approx(float(definition.max_hp), float(twin.max_hp)), "mirrored building " + str(definition.id))
for lane: String in LuneMap.LANES:
check(map.lane_point(1, lane, 7.0, 1.2).distance_to(LuneMap.mirror(map.lane_point(0, lane, 7.0, 1.2))) < 0.001, "mirrored spawn points on " + lane)
check(LuneMap.mirror(LuneMap.GROTTO).distance_to(LuneMap.GROTTO) < 0.001, "grotto equidistant for both teams")
var mid: LuneMap.LanePath = map.lanes["mid"]
var grotto_gap: float = mid.point_at(mid.project(LuneMap.GROTTO)).distance_to(LuneMap.GROTTO)
check(grotto_gap > 8.0, "grotto lies beside, not on, the mid lane (%.1f m)" % grotto_gap)
func _test_gates() -> void:
var map := LuneMap.new()
var alive: Dictionary = {}
for definition: Dictionary in map.buildings:
alive[definition.id] = true
var gate := func(id: String) -> bool: return LuneMap.is_attackable(map.building_def(id), alive)
check(gate.call("rot_top_outer") and not gate.call("rot_top_inner"), "outer before inner tower")
check(not gate.call("rot_base_1") and not gate.call("rot_ancient"), "base and Ancient protected at start")
alive["rot_top_outer"] = false
check(gate.call("rot_top_inner") and not gate.call("rot_base_1"), "inner opens after outer; base still closed")
alive["rot_mid_outer"] = false
alive["rot_bot_outer"] = false
check(not gate.call("rot_base_1"), "outer towers of all lanes do not open the base")
alive["rot_top_inner"] = false
check(gate.call("rot_base_1") and gate.call("rot_base_2") and not gate.call("rot_ancient"), "one broken lane opens the base, not the Ancient")
check(gate.call("rot_mid_inner") and gate.call("rot_bot_inner"), "towers of other lanes keep working and stay attackable in order")
alive["rot_base_1"] = false
check(not gate.call("rot_ancient"), "Ancient needs both base towers down")
alive["rot_base_2"] = false
check(gate.call("rot_ancient"), "Ancient vulnerable after both base towers")
# Gate applies to every damage source inside combat, including splash.
var combat := LuneCombat.new(map, LuneBalance.default_balance(), 1)
var inner: LuneUnit = combat.add_building(map.building_def("rot_top_inner"), 1000.0)
combat.add_building(map.building_def("rot_top_outer"), 1000.0)
var gates: Dictionary = combat.alive_map()
combat._queue_damage(inner, 500.0, gates)
check(not combat._pending_damage.has(inner.id), "locked inner tower ignores queued damage (ranged/splash/DoT)")
func _duel(core_hp_a: float, core_hp_b: float) -> LuneCombat:
var map := LuneMap.new()
var combat := LuneCombat.new(map, LuneBalance.default_balance(), 1)
for definition: Dictionary in map.buildings:
if definition.tier == "core":
combat.add_building(definition, core_hp_a if definition.team == 0 else core_hp_b)
else:
combat.add_building(definition, 0.0)
return combat
func _attacker(combat: LuneCombat, team: int, damage: float) -> LuneUnit:
var definition: Dictionary = DataCatalog.get_unit("g05").duplicate(true)
definition.damage = damage
var unit: LuneUnit = combat.spawn("troop", team, "mid", definition, 1.0, 0.0)
unit.position = combat.map.core_position(1 - team) + Vector2(2.5, 0).rotated(team)
unit.reached_base = true
return unit
func _test_victory_and_draw() -> void:
var combat := _duel(12000.0, 30.0)
_attacker(combat, 0, 200.0)
for i: int in range(200):
combat.step()
if combat.finished: break
check(combat.finished and combat.end_reason == "core" and combat.destroyed_cores == [1], "destroying an Ancient ends the combat with a core result")
var draw := _duel(30.0, 30.0)
_attacker(draw, 1, 200.0)
_attacker(draw, 0, 200.0)
for i: int in range(200):
draw.step()
if draw.finished: break
check(draw.destroyed_cores.size() == 2, "both Ancients in the same step are both recorded")
var state := new_match()
state.begin_combat()
state.combat.destroyed_cores = [0, 1]
state.combat.finished = true
state.combat.end_reason = "core"
state._settle()
check(state.phase == LuneMatch.Phase.OVER and state.winner == LuneMatch.DRAW, "simultaneous Ancient loss is a draw")
# Unit order must not decide: reversing the spawn order gives the same result.
var reversed := _duel(30.0, 30.0)
_attacker(reversed, 0, 200.0)
_attacker(reversed, 1, 200.0)
for i: int in range(200):
reversed.step()
if reversed.finished: break
check(reversed.destroyed_cores.size() == 2 and reversed.steps == draw.steps, "processing order gives no advantage")
var timeout := LuneMatch.new()
timeout.setup({"seed": 3, "human_slot": -1})
timeout.begin_combat()
timeout.combat.elapsed = timeout.balance.combat_time_limit
timeout.step_combat()
check(timeout.combat.end_reason == "timeout" and timeout.phase == LuneMatch.Phase.PLANNING and timeout.winner == LuneMatch.ONGOING, "combat time window ends the phase, never the match")
check(timeout.core_hp(0) == timeout.balance.core_hp and timeout.core_hp(1) == timeout.balance.core_hp, "time window causes no hidden core damage")
func _test_endless_waves() -> void:
var balance := LuneBalance.default_balance()
var previous: float = 0.0
for round_number: int in [1, 5, 8, 20, 21, 50, 200, 5000, 1000000]:
var wave: Array[Dictionary] = LuneWaves.build(balance, round_number)
var total: float = 0.0
var finite: bool = true
for monster: Dictionary in wave:
total += float(monster.max_hp) + float(monster.damage) * 10.0
finite = finite and is_finite(float(monster.max_hp)) and is_finite(float(monster.damage)) and int(monster.bounty) > 0
check(finite and wave.size() <= balance.wave_max_count and wave.size() >= 1, "wave %d finite and capped (%d monsters)" % [round_number, wave.size()])
check(total > previous, "wave %d stronger than the previous sample" % round_number)
previous = total
var state := new_match(11)
state.round_number = 21
state.begin_combat()
var monsters: int = 0
for unit: LuneUnit in state.combat.units:
if unit.kind == "monster":
monsters += 1
check(monsters == 6 * LuneWaves.monster_count(balance, 21), "round 21 spawns ordinary waves; no final wave logic")
state.finish_combat()
check(state.phase != LuneMatch.Phase.OVER or state.winner != LuneMatch.ONGOING, "no match end without a core result")
check(state.round_number == 22 or state.phase == LuneMatch.Phase.OVER, "round counter continues after 20")
func _test_commands_and_ownership() -> void:
var state := new_match(5, 1)
var gold: int = state.gold_of(1)
check(not state.submit(1, {"type": "buy", "type_id": "g01"}).ok, "buying another Bund's troop is rejected")
var bought: Dictionary = state.submit(1, {"type": "buy", "type_id": "w01"})
check(bought.ok and state.gold_of(1) == gold - 50, "buy costs exactly the catalogue price")
var uid: String = str(state.players[1].draft.roster[0].uid)
check(not state.submit(0, {"type": "upgrade", "uid": uid}).ok, "foreign unit id is rejected")
check(not state.submit(1, {"type": "upgrade", "uid": "p1-9999"}).ok, "unknown unit id is rejected")
check(state.players[1].roster.is_empty(), "draft purchases are not committed before the phase change")
check(state.submit(1, {"type": "assign", "uid": uid, "lane": "top"}).ok and not state.submit(1, {"type": "assign", "uid": uid, "lane": "grotte"}).ok, "lane assignment validated")
state.submit(1, {"type": "sell", "uid": uid})
check(state.gold_of(1) == gold, "cancelling a purchase in the same planning refunds fully")
state.submit(1, {"type": "buy", "type_id": "w01"})
state.players[1].draft.gold = -5
state.begin_combat()
check(state.players[1].roster.is_empty() and state.players[1].gold == gold, "invalid draft is rejected atomically at the phase change")
state.finish_combat()
var cap := new_match(6, 0)
cap.players[0].draft.gold = 100000
for i: int in range(cap.balance.supply_cap + 3):
cap.submit(0, {"type": "buy", "type_id": "g01"})
check(cap.players[0].draft.roster.size() == cap.balance.supply_cap, "supply cap enforced")
check(not cap.submit(0, {"type": "buy", "type_id": "g01"}).ok and cap.phase == LuneMatch.Phase.PLANNING, "no purchases beyond the cap")
cap.begin_combat()
check(not cap.submit(0, {"type": "buy", "type_id": "g03"}).ok, "no commands during combat")
var bots := new_match(9)
var before: Array = bots.players.map(func(p: Dictionary) -> int: return int(p.gold))
for slot: int in range(6):
LuneBot.plan(bots, slot)
var spent_ok: bool = true
for slot: int in range(6):
var invested: int = 0
for entry: Dictionary in bots.players[slot].draft.roster:
invested += int(entry.invested)
spent_ok = spent_ok and invested + int(bots.players[slot].draft.gold) == int(before[slot])
check(spent_ok, "bots spend exactly their own gold, no extra resources")
func _test_rewards() -> void:
var state := new_match(4)
state.begin_combat()
var combat: LuneCombat = state.combat
combat.events.clear()
combat.events.append({"serial": 1, "team": 1, "lane": "top", "zone": "lane", "kind": "monster", "bounty": 101})
combat.events.append({"serial": 2, "team": 1, "lane": "top", "zone": "lane", "kind": "monster", "bounty": 101})
combat.events.append({"serial": 3, "team": 0, "lane": "mid", "zone": "lane", "kind": "tower", "bounty": 151, "building_id": "blau_mid_outer"})
combat.events.append({"serial": 4, "team": 0, "lane": "bot", "zone": "lane", "kind": "troop", "bounty": 0})
combat.finished = true
combat.end_reason = "cleared"
var gold_before: Array = state.players.map(func(p: Dictionary) -> int: return int(p.gold))
state._settle()
var gains: Array = state.last_settlement.gains
check(gains[0] + gains[1] + gains[2] == 202, "each monster death pays exactly once, total 2 x 101 to Blau")
check(gains[0] >= 50 + 50 and gains[0] - gains[1] >= 90, "Top lane player of the killing team gets the lane share")
check(gains[3] + gains[4] + gains[5] == 151, "tower bounty exactly once to Rot")
check(abs(gains[3] - gains[4]) <= 1 and abs(gains[4] - gains[5]) <= 1, "tower bounty split evenly with integer remainder")
var income: int = state.last_settlement.income
var paid: bool = true
for slot: int in range(6):
paid = paid and int(state.players[slot].gold) == int(gold_before[slot]) + gains[slot] + income
check(paid, "settlement credits gains plus income, nothing else")
check(gains[3] + gains[4] + gains[5] + gains[0] + gains[1] + gains[2] == 353, "persistent troop deaths pay nothing")
func _test_persistence_and_restart() -> void:
var state := new_match(8, 0)
state.submit(0, {"type": "buy", "type_id": "g03"})
state.set_ready(0)
var uid: String = str(state.players[0].draft.roster[0].uid)
state.begin_combat()
state.combat.buildings["blau_mid_outer"].hp -= 1234.0
var fallen: LuneUnit = null
for unit: LuneUnit in state.combat.units:
if unit.persistent_uid == uid:
fallen = unit
fallen.hp = 0.0
fallen.alive = false
state.finish_combat()
check(state.phase == LuneMatch.Phase.PLANNING and state.round_number == 2, "round advances to planning")
check(state.players[0].roster.size() == 1 and state.players[0].roster[0].uid == uid, "fallen persistent troop returns to the roster")
check(is_equal_approx(state.building_hp["blau_mid_outer"], state.balance.tower_outer_hp - 1234.0) or state.building_hp["blau_mid_outer"] < state.balance.tower_outer_hp - 1234.0, "building damage persists between rounds")
state.begin_combat()
var healed: bool = false
for unit: LuneUnit in state.combat.units:
if unit.persistent_uid == uid:
healed = unit.hp == unit.max_hp and unit.effects.is_empty() and unit.dots.is_empty()
check(healed, "returning troop starts the next combat healed and without effects")
state.finish_combat()
var fresh := new_match(8, 0)
check(fresh.players[0].roster.is_empty() and fresh.round_number == 1 and fresh.building_hp["blau_mid_outer"] == fresh.balance.tower_outer_hp, "new match starts without previous progress")
func _test_full_match() -> void:
var state := new_match(2)
var limit: int = 80
while state.phase != LuneMatch.Phase.OVER and state.round_number <= limit:
for slot: int in range(6):
LuneBot.plan(state, slot)
state.begin_combat()
state.finish_combat()
check(state.phase == LuneMatch.Phase.OVER, "a six-bot match reaches a regular Ancient result (seed 2)")
check(state.winner in [0, 1, LuneMatch.DRAW], "result is Blau, Rot or draw")
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

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

View file

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

View file

@ -1,97 +0,0 @@
#!/usr/bin/env python3
"""Asset reuse matrix for Champions of Luneraria, generated from the runtime manifests.
Run: python3 tools/luneraria/build_asset_matrix.py -> docs/luneraria/ASSETMATRIX.md
Roles marked "vorläufig" are provisional assignments of existing models, not new peoples.
"""
from pathlib import Path
import json
P = Path(__file__).resolve().parents[2]
phase4 = {a['id']: a for a in json.loads((P / 'assets/phase4_manifest.json').read_text())['assets']}
skeletal = {}
for name in ['phase11', 'phase12', 'phase15', 'phase16', 'phase18', 'phase19']:
for a in json.loads((P / f'assets/{name}_manifest.json').read_text())['assets']:
skeletal[a['id']] = (a, name)
# id -> (bisherige Rolle, neue Rolle, Volk/Bund-Zuordnung, Eingriff)
ROLES = {
'g01': ('Glutpakt Nahkampf', 'Truppe Nahkampf', 'Glutkonstrukt / Glutpakt', 'keiner'),
'g02': ('Glutpakt Nahkampf+', 'Truppenaufwertung', 'Glutkonstrukt / Glutpakt', 'keiner'),
'g03': ('Glutpakt Tank', 'Truppe Front', 'Glutkonstrukt / Glutpakt', 'keiner'),
'g04': ('Glutpakt Tank+', 'Truppenaufwertung', 'Glutkonstrukt / Glutpakt', 'keiner'),
'g05': ('Glutpakt Fernkampf', 'Truppe Fernkampf; M2 zusätzlich Held „Funkenlanze“ (vorläufig, mit Heldenmarkierung)', 'Glutkonstrukt / Glutpakt', 'Heldenkennzeichnung in der Darstellung'),
'g06': ('Glutpakt Fernkampf+', 'Truppenaufwertung (Belagerung)', 'Glutkonstrukt / Glutpakt', 'keiner'),
'g07': ('Glutpakt Fläche', 'Truppe Fläche', 'Glutkonstrukt / Glutpakt', 'keiner'),
'g08': ('Glutpakt Fläche+', 'Truppenaufwertung', 'Glutkonstrukt / Glutpakt', 'keiner'),
'g09': ('Glutpakt Unterstützung', 'Truppe Unterstützung', 'Glutkonstrukt / Glutpakt', 'keiner'),
'g10': ('Glutpakt Unterstützung+', 'Truppenaufwertung', 'Glutkonstrukt / Glutpakt', 'keiner'),
'w01': ('Wurzelbund Nahkampf', 'Truppe Nahkampf', 'Wurzelwesen / Wurzelbund', 'keiner'),
'w02': ('Wurzelbund Nahkampf+', 'Truppenaufwertung', 'Wurzelwesen / Wurzelbund', 'keiner'),
'w03': ('Wurzelbund Tank', 'Truppe Front', 'Wurzelwesen / Wurzelbund', 'keiner'),
'w04': ('Wurzelbund Tank+', 'Truppenaufwertung; M2 zusätzlich Held „Ringborke“ (vorläufig)', 'Wurzelwesen / Wurzelbund', 'Heldenkennzeichnung'),
'w05': ('Wurzelbund Fernkampf', 'Truppe Fernkampf', 'Wurzelwesen / Wurzelbund', 'keiner'),
'w06': ('Wurzelbund Fernkampf+', 'Truppenaufwertung', 'Wurzelwesen / Wurzelbund', 'keiner'),
'w07': ('Wurzelbund Fläche', 'Truppe Fläche', 'Wurzelwesen / Wurzelbund', 'keiner'),
'w08': ('Wurzelbund Fläche+', 'Truppenaufwertung', 'Wurzelwesen / Wurzelbund', 'keiner'),
'w09': ('Wurzelbund Heiler', 'Truppe Unterstützung', 'Wurzelwesen / Wurzelbund', 'keiner'),
'w10': ('Wurzelbund Heiler+', 'Truppenaufwertung; M2 zusätzlich Held „Quellgeflecht“ (vorläufig)', 'Wurzelwesen / Wurzelbund', 'Heldenkennzeichnung'),
'e01': ('Wellengegner', 'Monsterwelle (teamgebunden, rotierend)', 'Riftbrut (neutral, pro Team eingefärbt)', 'Teamkennzeichnung'),
'e02': ('Wellengegner', 'Monsterwelle', 'Riftbrut', 'Teamkennzeichnung'),
'e03': ('Wellengegner', 'Monsterwelle', 'Riftbrut', 'Teamkennzeichnung'),
'e04': ('Wellengegner', 'Monsterwelle', 'Riftbrut', 'Teamkennzeichnung'),
'e05': ('Wellengegner', 'Monsterwelle (späte Variante)', 'Riftbrut', 'Teamkennzeichnung'),
'e06': ('Wellengegner', 'Monsterwelle (Unterstützer)', 'Riftbrut', 'Teamkennzeichnung'),
'e07': ('Boss Welle 5', 'Reserve (Wellen-Elite ab höheren Indizes)', 'Riftbrut', 'offen'),
'e08': ('Boss Welle 8', 'Reserve (Wellen-Elite)', 'Riftbrut', 'offen'),
'e09': ('Boss Welle 15', 'Reserve', 'Riftbrut', 'offen'),
'e10': ('Boss Welle 20', 'M2: neutraler Grottenboss und entsandter Boss', 'Riftbrut / Grotte', 'Bossdarstellung, Skalierung'),
's01': ('Söldner', 'Reserve Angriffschampion', 'Söldner', 'offen'),
's02': ('Söldner', 'M2: Angriffschampion Schutz', 'Söldner', 'keiner'),
's03': ('Söldner', 'M2: Angriffschampion Druck auf Unterstützer', 'Söldner', 'keiner'),
's04': ('Söldner', 'M2: Angriffschampion Belagerung', 'Söldner', 'keiner'),
's05': ('Söldner', 'Reserve Angriffschampion', 'Söldner', 'offen'),
'core_team0': ('Saumkern Team 0', 'Ancient Blau (SW)', '—', 'keiner'),
'core_team1': ('Saumkern Team 1', 'Ancient Rot (NO)', '—', 'keiner'),
'worker': ('Arbeiter/Funken', 'nicht im neuen Modus (Arbeitsannahme E); Modell bleibt erhalten', '—', 'keiner'),
'map_lane_segment': ('Lane-Kachel alt', 'nicht verwendet; Lanes neu erzeugt', '—', 'keiner'),
}
for prefix, people in (('m', 'Nietenkonvent (nicht spielbar)'), ('n', 'Nebelchor (nicht spielbar)')):
for i in range(1, 11):
ROLES[f'{prefix}{i:02d}'] = ('nicht spielbar, niedrige Detailstufe', 'Reserve für spätere Völker', people, 'Neuaufbau nötig vor Nutzung')
rows = []
for asset_id in sorted(set(phase4) | set(skeletal)):
base = phase4.get(asset_id, {})
path = base.get('resource_path', '')
source = str(base.get('blend_path', '')).replace(str(P) + '/', '')
tris = base.get('triangles', '')
rig = 'starre Gelenkgruppen'
material = 'HD, starre Teile' if base.get('detail_level') == 'high' else 'niedrige Detailstufe'
if asset_id in skeletal:
record, manifest = skeletal[asset_id]
path = record['resource_path']
source = record.get('source', source)
tris = record['triangles']
rig = '%d Knochen · %d Clips' % (record['bones'], len(record['clips']))
material = 'gewichtetes Skelett (%s)' % manifest
old, new, people, work = ROLES.get(asset_id, ('?', 'offen', '?', 'Prüfung nötig'))
rows.append((asset_id, base.get('name') or base.get('display_name') or skeletal.get(asset_id, ({},))[0].get('name', ''), path, source, rig, tris, old, new, people, material, work))
extra = [
('menu-stage', 'Menü-Diorama', 'res://assets/environment/menu-stage.glb', 'assets-source/phase20/build_menu_stage.py', 'statisch', '48660', 'Hauptmenü Phase 20A', 'Hauptmenü (Titel angepasst)', '—', 'Kluft-Shader', 'keiner'),
('verdant-rift', 'Grüne Kluft (2-Bretter-Arena)', 'res://assets/environment/verdant-rift.glb', 'assets-source/phase10/verdant-rift.blend', 'statisch', '—', 'Spielfeld Saumwacht', 'Legacy-Modus; Materialien/Shader für die neue Karte wiederverwendet', '—', 'triplanare Kluft-Shader', 'keine neue Nutzung als Karte'),
('compare-rock / compare-tree', 'Fels, Baum', 'res://assets/compare/', 'assets-source/phase14/build_compare_props.py', 'statisch', '360 / 852', 'Vergleichsszene', 'Kartendekoration Dschungel', '—', 'einfach', 'keiner'),
('Türme', '—', '(fehlt)', '—', '—', '—', '—', '16 Lanetürme/Basistürme', '—', '—', 'neu: Blender-Generator (M1)'),
('Bossgrotte', '—', '(fehlt)', '—', '—', '—', '—', 'Grottenbau nahe Zentrum', '—', '—', 'neu (M2)'),
('Emblem/Theme', 'Saumwacht-Theme', 'scripts/ui/theme/, assets/ui/', 'Code / SVG', '—', '—', 'Menü 20A', 'Theme weiterverwendet, Titel/Emblem Luneraria', '—', 'Vektor', 'Titelschrift/Emblem ergänzen'),
]
lines = ['# Asset-Wiederverwendungsmatrix', '',
'Erzeugt mit `python3 tools/luneraria/build_asset_matrix.py` aus den Laufzeitmanifesten. „Volk/Bund“ ist die Datenzuordnung. Die sieben Zielvölker (Menschen, Elfen, Zwerge, Trolle, Feen, Orks, Biestmenschen) haben noch **keine eigenen Modelle**; bestehende Wesen bleiben als eigene Bünde Teil der Welt. Als „vorläufig“ markierte Rollen sind Zuordnungen vorhandener Modelle, keine neu erstellten Völker.', '',
'| ID | Name | Pfad | Quelle | Rig / Clips | Dreiecke | bisherige Rolle | neue Rolle | Volk / Bund | Materialzustand | Eingriff |',
'| --- | --- | --- | --- | --- | ---: | --- | --- | --- | --- | --- |']
for row in rows + extra:
lines.append('| ' + ' | '.join(str(x) for x in row) + ' |')
(P / 'docs/luneraria').mkdir(parents=True, exist_ok=True)
(P / 'docs/luneraria/ASSETMATRIX.md').write_text('\n'.join(lines) + '\n')
print('ASSET_MATRIX', len(rows) + len(extra), 'rows')

View file

@ -1,74 +0,0 @@
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

@ -1,6 +0,0 @@
[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

@ -1,40 +0,0 @@
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

@ -1,6 +0,0 @@
[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

@ -1,48 +0,0 @@
extends Node
## Headless six-bot matches for balance and acceptance. A QA round limit is reported as
## "Testlimit erreicht / Partie nicht abgeschlossen", never as a result.
## Run: godot --headless --path . res://tools/luneraria/sim_match.tscn -- --seeds=1,2,3 --limit=60
func _ready() -> void:
call_deferred("run")
func run() -> void:
var seeds: Array = [1]
var limit: int = 60
var out: String = ""
for arg: String in OS.get_cmdline_user_args():
if arg.begins_with("--seeds="):
seeds = Array(arg.substr(8).split(",")).map(func(s: String) -> int: return int(s))
elif arg.begins_with("--limit="):
limit = int(arg.substr(8))
elif arg.begins_with("--out="):
out = arg.substr(6)
var reports: Array = []
for seed: int in seeds:
var started: int = Time.get_ticks_msec()
var match_state := LuneMatch.new()
match_state.setup({"seed": seed, "human_slot": -1})
while match_state.phase != LuneMatch.Phase.OVER and match_state.round_number <= limit:
for slot: int in range(6):
LuneBot.plan(match_state, slot)
match_state.begin_combat()
var combat: LuneCombat = match_state.combat
match_state.finish_combat()
if combat.end_reason == "timeout" and "--diagnose" in OS.get_cmdline_user_args():
for unit: LuneUnit in combat.units:
if unit.alive and unit.is_mobile():
print(" STUCK t%d %s %s %s pos=%s prog=%.1f base=%s target=%s hp=%.0f/%.0f" % [unit.team, unit.lane, unit.kind, unit.def.get("id", ""), str(unit.position.snapped(Vector2(.1, .1))), unit.progress, unit.reached_base, (unit.target.def.get("id", "") if unit.target != null else "-"), unit.hp, unit.max_hp])
var h: Dictionary = match_state.history[-1]
print("R%02d %s %.1fs buildings %s lost %s gold %s" % [h.round, h.end_reason, h.combat_time, str(h.buildings), str(h.lost), str(h.gold)])
var result: String = "Testlimit erreicht / Partie nicht abgeschlossen"
if match_state.phase == LuneMatch.Phase.OVER:
result = "Unentschieden" if match_state.winner == LuneMatch.DRAW else ("Sieg Blau" if match_state.winner == 0 else "Sieg Rot")
var report := {"seed": seed, "result": result, "rounds": match_state.history.size(), "note": match_state.end_note,
"profiles": range(6).map(func(s: int) -> String: return LuneBot.profile_for(match_state, s)),
"bunds": match_state.players.map(func(p: Dictionary) -> String: return p.bund),
"wall_ms": Time.get_ticks_msec() - started, "history": match_state.history}
reports.append(report)
print("SIM_MATCH seed=%d %s after %d rounds (%s) in %d ms" % [seed, result, report.rounds, report.note, report.wall_ms])
if not out.is_empty():
var file := FileAccess.open(out, FileAccess.WRITE)
file.store_string(JSON.stringify(reports, "\t"))
file.close()
get_tree().quit()

View file

@ -1 +0,0 @@
uid://2s0skfdjcwno

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