Compare commits
No commits in common. "luneraria/m1" and "main" have entirely different histories.
luneraria/
...
main
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -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 |
|
|
@ -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
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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 |
|
|
||||||
|
|
@ -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).
|
|
||||||
|
|
@ -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. |
|
|
||||||
|
|
@ -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. |
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
{
|
|
||||||
"checks": 91,
|
|
||||||
"failures": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
|
|
||||||
|
|
||||||
ABILITY_EVENT_PASS: 33 checks; 0 failures
|
|
||||||
EXIT 0
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
{
|
|
||||||
"checks": 38,
|
|
||||||
"failures": [],
|
|
||||||
"object_counts": [
|
|
||||||
3505.0,
|
|
||||||
3505.0,
|
|
||||||
3505.0,
|
|
||||||
3505.0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
|
|
||||||
|
|
||||||
ABILITY_EVENT_PASS: 33 checks; 0 failures
|
|
||||||
EXIT 0
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)"
|
|
||||||
}
|
|
||||||
|
|
@ -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)"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 649 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 592 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://daf3k13ys862
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://dy31vyfmvjk0s
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://rj0ekj8y2rsa
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://bqnx0kaccktd5
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://dgn4ftbspq233
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://3658nsy6hfo
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://c1fsheercsrib
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://60nhdgkc0ww4
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://ciohupnds7xrb
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://d0qt7wmu3odob
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://dfkwcwigd7gci
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://cxf4d7s5ts31s
|
|
||||||
|
|
@ -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)))
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://bky12ooi60og8
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://doa7ve4i37a5q
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://ctolavjaa60ir
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://i4b3g0pv7e0x
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://bko3fb3fg67th
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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')
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
uid://2s0skfdjcwno
|
|
||||||