saumwacht-godot/tools/luneraria/g2_views.gd
Martin Stickelbroeck 9f62e30377 G2: Ancients, Türme, Bossgrotte und Superboss; macOS- und Windows-ARM64-Pakete
Mondherz-Ancients (Saumkern-Kristall als Herz, Sichelklingen, Ringe, Krone,
Teamwappen, Energieadern) mit den Stufen stabil/instabil/kritisch, kurzem
Zusammenbruch und bleibender Ruine; der Sieg wartet nie auf die Grafik.
Turmfamilie aus einem Blender-Generator: Grenzwacht (Tower 1), Bastion
(Tower 2), Kernwacht (Basisturm in der Formensprache des Ancients), mit
Schadensstufen, Einsturz, Ruine und sichtbarem Angriff (Zielring, Aufladen,
Bolzen, Einschlag) - der Schaden bleibt vollständig in der Simulation.
Bossgrotte Mondschlund mit Tor, Treppe in die Tiefe, Altar, Säulen, Felsen
und fünf Zuständen; der Generator hält Abstand zur Top-Lane, die Höhle
braucht eine kleine Geländesenke (nur Darstellung). Superboss Schlundkrone
mit 24 Knochen und 11 Clips (inkl. Brüllen und Sieg), Adern in Teamfarbe,
dasselbe Modell für Grotte und Lane. Gemeinsame LN-Materialfamilie (Stein,
Metall, Kristall, Rune, Stoff, Mondwasser, Kreaturenhaut), Occlusion-Fade
ohne dithernde Schatten. Keine Regeländerung.

Geprüft: verify_g2 245/245, verify_g1 443/443, Regeln 91/91 und 73/73,
Stress 17/17, Balance 40 Partien identisch mit G1, verify_m1 38/38,
verify_m2 14/14, Netz 43/43 und Loopback 12/12, Legacy 209 und 463.
21 Ansichten vorher/nachher (1920x1080), Videos Turmkampf und Bossbegegnung,
Leistung G1/G2 auf demselben Mac (+0,5 ms in vollen Kampfansichten).
Pakete 0.17.0-g2 für macOS (Universal, ad-hoc signiert), Windows x64/ARM64
und Linux; macOS nativ und unter Rosetta, beide Windows-Pakete in Windows 11
ARM (Parallels, OpenGL-Fallback) mit --lune-smoke bestanden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:12:49 +02:00

311 lines
12 KiB
GDScript

extends Node
## G2 acceptance captures: Ancients, towers, boss grotto and boss, always from the real game
## camera with the real HUD at the running window size (use 1920x1080).
## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/g2_views.tscn -- --out=res://docs/luneraria/screenshots/g2/nachher
## Every view is a real LuneGame. Fixtures only set public match state before or between
## phases (building HP, fixture gold, grotto round, a boss claim), exactly like the G1 views;
## the rules that run are the ones of the build. Only public APIs are used, so the same
## script photographs the G1 state ("vorher") and the G2 state ("nachher").
const Game = preload("res://scripts/luneraria/lune_game.gd")
var out: String = "res://docs/luneraria/screenshots/g2/nachher/"
var only: Array[String] = []
var shots: Array[String] = []
var game: Node3D
func _ready() -> void:
for arg: String in OS.get_cmdline_user_args():
if arg.begins_with("--out="):
out = arg.substr(6)
if not out.ends_with("/"):
out += "/"
elif arg.begins_with("--only="):
only.assign(arg.substr(7).split(","))
call_deferred("run")
func wanted(label: String) -> bool:
return only.is_empty() or only.any(func(prefix: String) -> bool: return label.begins_with(prefix))
func shot(label: String, frames: int = 14) -> void:
for i: int in frames:
await get_tree().process_frame
await RenderingServer.frame_post_draw
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(out))
get_viewport().get_texture().get_image().save_png(out + label + ".png")
shots.append(label)
print("G2_VIEW ", label)
func wait(seconds: float) -> void:
await get_tree().create_timer(seconds).timeout
func fresh(seed: int = 4, slot: int = 1) -> LuneMatch:
if game != null and is_instance_valid(game):
game.queue_free()
await get_tree().process_frame
await get_tree().process_frame
game = Game.new()
add_child(game)
game.start({"seed": seed, "human_slot": slot, "bunds": ["wurzelbund", "wurzelbund", "glutpakt", "glutpakt", "wurzelbund", "glutpakt"]})
await get_tree().process_frame
return game.match_state
func look(at: Vector2, zoom: float, yaw_steps: int = 0) -> void:
# Q/E steps of 45 degrees, as a player would turn the camera.
game.camera._goal_yaw = yaw_steps * PI * .25
game.camera.look_at_point(Vector3(at.x, 0, at.y), zoom, true)
func building(id: String) -> Dictionary:
return game.match_state.map.building_def(id)
## Runs the combat clock quickly (more fixed steps per frame, rules unchanged), then holds it.
func advance_combat(seconds: float, hold: bool = true) -> void:
var state: LuneMatch = game.match_state
game.paused = false
while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < seconds:
for i: int in range(20):
if state.phase != LuneMatch.Phase.COMBAT or state.combat.elapsed >= seconds:
break
state.step_combat()
await get_tree().process_frame
game.paused = hold
## Advances in real time (normal game clock) so animations and effects are live in the shot.
func live(seconds: float) -> void:
game.paused = false
game.speed_index = 0
await wait(seconds)
## Round-13 armies at the supply cap for the given slots (capture fixture gold only).
func armies(state: LuneMatch, slots: Array, round_number: int = 13) -> void:
state.round_number = round_number
for slot: int in range(6):
state.players[slot].draft.roster.clear()
state.players[slot].draft.gold = 100000 if slots.has(slot) else 0
if slots.has(slot):
LuneBot.plan(state, slot, "ausgewogen")
# The bot planner may send troops to the grotto; captures keep them on the lanes.
for entry: Dictionary in state.players[slot].draft.roster:
if entry.lane == LuneMatch.GROTTO:
entry.lane = state.players[slot].lane
var hero: Dictionary = state.players[slot].draft.hero
if not hero.is_empty() and hero.lane == LuneMatch.GROTTO:
hero.lane = state.players[slot].lane
func run() -> void:
Soundscape.enabled = false
await ancient_views()
await tower_views()
await grotto_views()
await boss_lane_view()
await tower_fight_view()
await ancient_fight_view()
await scale_view()
if game != null and is_instance_valid(game):
game.queue_free()
await get_tree().process_frame
print("G2_VIEWS_DONE ", shots.size())
get_tree().quit()
# ---------------------------------------------------------------- Ancients
func ancient_views() -> void:
if not (wanted("01") or wanted("02") or wanted("03") or wanted("04") or wanted("15") or wanted("17")):
return
var state: LuneMatch = await fresh()
var blue: Vector2 = state.map.core_position(0)
var red: Vector2 = state.map.core_position(1)
if wanted("15"):
game.camera.overview(true)
await shot("15-gesamtkarte", 24)
# The whole sanctuary (Ancient about 10 m, both base towers behind it), camera turned
# one step (Q) so the Ancient stands in front of its towers; Rot mirrored (three steps).
var blue_view: Vector2 = blue + Vector2(1.0, -1.0)
look(blue_view, 28.0, -1)
await live(1.0)
if wanted("01"):
await shot("01-ancient-100")
if wanted("17"):
look(red + Vector2(-1.0, 1.0), 28.0, 3)
await live(.6)
await shot("17-ancient-rot-100")
look(blue_view, 28.0, -1)
state.building_hp["blau_ancient"] = float(building("blau_ancient").max_hp) * .5
await live(1.6)
if wanted("02"):
await shot("02-ancient-50")
state.building_hp["blau_ancient"] = float(building("blau_ancient").max_hp) * .22
await live(1.6)
if wanted("03"):
await shot("03-ancient-unter-30")
state.building_hp["blau_ancient"] = 0.0
await live(.9)
if wanted("04"):
await shot("04a-ancient-zerstoerung", 2)
await live(4.0)
if wanted("04"):
await shot("04-ancient-zerstoert")
# ---------------------------------------------------------------- towers
func tower_views() -> void:
if not (wanted("05") or wanted("06") or wanted("07") or wanted("08")):
return
var state: LuneMatch = await fresh()
var outer: Vector2 = building("rot_mid_outer").position
var inner: Vector2 = building("rot_mid_inner").position
look(outer + Vector2(0, -2.4), 14.0)
await live(1.0)
if wanted("05"):
await shot("05-tower1-grenzwacht")
look(inner + Vector2(0, -2.8), 14.0)
await live(.6)
if wanted("06"):
await shot("06-tower2-bastion")
# Base towers as an attacker sees them: from the Mid side, flanking the Ancient.
var core: Vector2 = state.map.core_position(1)
var base_a: Vector2 = building("rot_base_1").position
var base_b: Vector2 = building("rot_base_2").position
look((base_a + base_b) * .5 * .5 + core * .5, 24.0, -1)
await live(.6)
if wanted("07"):
await shot("07-basistuerme")
# A tower that fell in this session: the ruin after its destruction sequence.
look(outer + Vector2(0, -2.4), 14.0)
await live(.4)
state.building_hp["rot_mid_outer"] = 0.0
await live(.7)
if wanted("08"):
await shot("08a-turm-zerstoerung", 2)
await live(3.5)
if wanted("08"):
await shot("08-turm-zerstoert")
# ---------------------------------------------------------------- grotto
func grotto_views() -> void:
if not (wanted("09") or wanted("10") or wanted("11") or wanted("18")):
return
var state: LuneMatch = await fresh()
var center: Vector2 = state.map.grotto_center()
# Locked grotto: the boss has left with a claim (deploy round), then cooldown rounds.
state.round_number = 7
state.boss.next_available_round = 10
state.boss.claim = {"team": 1, "won_round": 6, "deploy_round": 7, "chooser_slot": 3, "lane": "top"}
look(center, 20.0)
await live(1.4)
if wanted("09"):
await shot("09-grotte-neutral-entsandt")
state.boss.claim = {}
state.round_number = 8
await live(1.4)
if wanted("18"):
await shot("18a-grotte-cooldown")
state.round_number = 9
await live(1.4)
if wanted("18"):
await shot("18b-grotte-bald-verfuegbar")
state.round_number = 10
await live(1.6)
if wanted("10"):
await shot("10-boss-verfuegbar")
if not wanted("11"):
return
# Boss fight: both Blau and Rot send expeditions (round 6 armies), fight, the boss wakes.
state = await fresh(4)
state.round_number = 6
for slot: int in [0, 1, 3]:
state.players[slot].draft.gold = 2400
LuneBot.plan(state, slot, "ausgewogen")
for entry: Dictionary in state.players[slot].draft.roster:
entry.lane = LuneMatch.GROTTO if slot == 1 or slot == 3 else entry.lane
var hero: Dictionary = state.players[slot].draft.hero
if not hero.is_empty() and (slot == 1 or slot == 3):
hero.lane = LuneMatch.GROTTO
game._on_ready()
await get_tree().process_frame
look(state.map.grotto_center(), 16.0)
var boss_awake: bool = false
while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < 60.0:
await advance_combat(state.combat.elapsed + .5, false)
if state.combat.grotto_boss != null and not state.combat.grotto_boss.dormant:
boss_awake = true
break
await advance_combat(state.combat.elapsed + 2.5)
await live(1.0)
await shot("11-bosskampf" if boss_awake else "11-bosskampf-expeditionen")
# ---------------------------------------------------------------- boss on a lane
func boss_lane_view() -> void:
if not wanted("12"):
return
var state: LuneMatch = await fresh(4)
armies(state, [0, 1, 2, 3, 4, 5], 7)
state.boss.claim = {"team": 0, "won_round": 6, "deploy_round": 7, "chooser_slot": 1, "lane": "mid"}
state.boss.next_available_round = 10
game._on_ready()
await get_tree().process_frame
await advance_combat(15.0)
var boss: LuneUnit = null
for unit: LuneUnit in state.combat.units:
if unit.kind == "boss" and unit.alive and unit.zone == "lane":
boss = unit
if boss != null:
look(boss.position + Vector2(1.0, 1.0), 20.0)
await live(1.2)
await shot("12-boss-lane-verstaerkung")
# ---------------------------------------------------------------- fights at buildings
func tower_fight_view() -> void:
if not wanted("13"):
return
var state: LuneMatch = await fresh(4)
armies(state, [0, 1, 2], 13)
game._on_ready()
await get_tree().process_frame
var tower: LuneUnit = state.combat.buildings["rot_mid_outer"]
# Until Blau's Mid army stands at the Rot outer tower and the tower has a target.
while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < 90.0:
await advance_combat(state.combat.elapsed + .5, false)
if tower.target != null and tower.alive and tower.hp < tower.max_hp * .97:
break
var at: Vector2 = tower.position
look(at + (state.map.core_position(0) - at).normalized() * 3.0, 17.0)
await live(1.3)
await shot("13-kampf-vor-tower", 1)
func ancient_fight_view() -> void:
if not wanted("14"):
return
var state: LuneMatch = await fresh(4)
for id: String in ["blau_mid_outer", "blau_mid_inner", "blau_base_1", "blau_base_2"]:
state.building_hp[id] = 0.0
armies(state, [3, 4, 5], 13)
game._on_ready()
await get_tree().process_frame
var core: LuneUnit = state.combat.buildings["blau_ancient"]
while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < 120.0:
await advance_combat(state.combat.elapsed + .5, false)
if core.hp < core.max_hp * .9:
break
# From the attackers' side (three Q steps), so the fight stands in front of the Ancient.
look(state.map.core_position(0) + Vector2(1.5, -1.5), 28.0, 3)
await live(1.3)
await shot("14-kampf-vor-ancient", 1)
# ---------------------------------------------------------------- scale comparison
## Hero, troops, attack champion and the deployed boss in a row in front of the Blau Ancient
## and its base tower, all in the same world scale. The row runs along the screen's x axis
## (camera yaw 0). The figures belong to a display-only combat that never runs.
func scale_view() -> void:
if not wanted("16"):
return
var state: LuneMatch = await fresh(4)
var display := LuneCombat.new(state.map, state.balance, 7)
var hero := LuneContent.hero_definition({"hero_id": "held_ringborke", "xp": 0, "items": [], "talents": []})
var entries: Array = [["troop", DataCatalog.get_unit("w01")], ["troop", DataCatalog.get_unit("g05")], ["hero", hero],
["champion", LuneContent.champion_definition("ch_belagerung", 7, state.balance)], ["boss", LuneContent.boss_definition(7, state.balance, true)]]
var xs: Array[float] = [-34.4, -32.4, -30.2, -27.8, -24.6]
for index: int in range(entries.size()):
var unit: LuneUnit = display.spawn_at(entries[index][0], 0, "lane", entries[index][1], Vector2(xs[index], 35.2), -1, "scale-%d" % index)
unit.facing = 0.0
game._add_view(unit)
look(Vector2(-27.6, 31.6), 20.0)
await live(1.2)
await shot("16-massstab")