saumwacht-godot/scripts/luneraria/lune_game.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

351 lines
13 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.
## With a LuneSession every command, ready signal and undo goes through the session. The
## host simulates and streams snapshots; a client only displays the host's combat.
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 session: Node
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
var _bound_combat: LuneCombat
func start(setup_options: Dictionary) -> void:
options = setup_options
name = "LuneGame"
human_slot = int(options.get("human_slot", 1))
if session != null and is_instance_valid(session) and session.match_state != null:
match_state = session.match_state
else:
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
camera.ground = func(p: Vector2) -> float: return world.height_at(p)
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(_send({"type": "undo"}) if _net() else 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 _net():
session.session_message.connect(func(text: String, is_error: bool) -> void: hud.notify(text, is_error))
session.state_received.connect(_rebuild_preview)
if human_slot >= 0:
focus_lane(match_state.players[human_slot].lane, true)
else:
camera.overview(true)
if match_state.phase == LuneMatch.Phase.PLANNING:
_enter_planning()
## True when a network session (host or client) owns the match.
func _net() -> bool:
return session != null and is_instance_valid(session) and session.is_networked()
func _is_client() -> bool:
return _net() and not session.is_authority()
func _send(command: Dictionary) -> Dictionary:
return session.submit(command) if _net() else match_state.submit(human_slot, command)
func _own_team() -> int:
return int(match_state.players[maxi(0, human_slot)].team)
func focus_lane(lane: String, instant: bool = false) -> void:
var path: LuneMap.LanePath = match_state.map.lanes[lane]
# Planning looks at the own deployment and inner 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(_own_team(), lane, path.length * fraction)
camera.look_at_point(Vector3(point.x, 0, point.y), 24.0, instant)
func focus_base(instant: bool = false) -> void:
var core: Vector2 = match_state.map.core_position(_own_team())
camera.look_at_point(Vector3(core.x, 0, core.y), 26.0, instant)
func focus_grotto(instant: bool = false) -> void:
var center: Vector2 = match_state.map.grotto_center()
camera.look_at_point(Vector3(center.x, 0, center.y), 22.0, instant)
## The own hero: its planning preview or, in combat, the live unit.
func own_hero_unit() -> LuneUnit:
if human_slot < 0:
return null
var source: LuneCombat = match_state.combat if match_state.phase == LuneMatch.Phase.COMBAT else _preview
if source == null:
return null
for unit: LuneUnit in source.units:
if unit.kind == "hero" and unit.owner_slot == human_slot and unit.alive:
return unit
return null
func focus_hero(instant: bool = false) -> bool:
var unit: LuneUnit = own_hero_unit()
if unit == null:
hud.notify("Kein Held im Feld – im Reiter „Held“ anwerben.", true)
return false
camera.look_at_point(Vector3(unit.position.x, 0, unit.position.y), 16.0, instant)
return true
func reset_camera(instant: bool = false) -> void:
var lane: String = str(match_state.players[maxi(0, human_slot)].lane)
var point: Vector2 = match_state.map.lane_point(_own_team(), lane, match_state.map.lanes[lane].length * .2)
camera.reset_view(Vector3(point.x, 0, point.y), 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(_send(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 = _send({"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 _net():
session.ready_up()
return
if human_slot >= 0:
match_state.set_ready(human_slot)
if match_state.all_ready():
match_state.begin_combat()
func _enter_planning() -> void:
if _net():
session.plan_bot_seats()
else:
for slot: int in range(6):
if slot != human_slot:
LuneBot.plan(match_state, slot)
_rebuild_preview()
if human_slot < 0 and not _net():
match_state.begin_combat()
## Combat views bind to the combat object of this phase (on a client: the host's mirror).
func _enter_combat() -> void:
_clear_views()
_preview = null
world.show_deployment(false, _own_team(), {}, false)
_bound_combat = match_state.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:
_update_deployment()
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 = {}
var entries: Array = []
for entry: Dictionary in player.draft.roster:
entries.append([DataCatalog.get_unit(entry.type_id), str(entry.lane), str(entry.uid), "troop"])
var hero: Dictionary = player.draft.hero
if not hero.is_empty():
entries.append([LuneContent.hero_definition(hero), str(hero.lane), str(hero.uid), "hero"])
for entry: Array in entries:
var lane: String = entry[1]
var index: int = int(per_lane.get(lane, 0))
per_lane[lane] = index + 1
var unit: LuneUnit
if lane == LuneMatch.GROTTO:
# Grotto orders preview at the team's grotto entrance.
var at: Vector2 = match_state.map.grotto_entry(int(player.team)) + Vector2(float(index % 3) - 1.0, float(index / 3)) * 1.1
unit = _preview.spawn_at(entry[3], int(player.team), "grotto", entry[0], at, human_slot, entry[2])
else:
unit = _preview.spawn(entry[3], int(player.team), lane, entry[0], maxf(.4, 7.2 - float(index / 4) * 1.5), (float(index % 4) - 1.5) * 1.2, human_slot, entry[2])
_add_view(unit)
## Planning shows the own formation areas; lanes that hold own units are highlighted.
func _update_deployment() -> void:
var planning: bool = match_state.phase == LuneMatch.Phase.PLANNING and human_slot >= 0
var used: Dictionary = {}
if human_slot >= 0:
var draft: Dictionary = match_state.players[human_slot].draft
for entry: Dictionary in draft.roster:
used[str(entry.lane)] = int(used.get(str(entry.lane), 0)) + 1
if not (draft.hero as Dictionary).is_empty():
used[str(draft.hero.lane)] = int(used.get(str(draft.hero.lane), 0)) + 1
world.show_deployment(planning, _own_team(), used, match_state.grotto_available())
func _add_view(unit: LuneUnit) -> void:
var view: Node3D = UnitView.new()
view.terrain = world.terrain
view.ground = world.height_at
_units_root.add_child(view)
view.bind(unit)
_views[unit.id] = view
func _cycle_speed() -> void:
if _is_client():
hud.notify("Tempo und Pause bestimmt der Host.", true)
return
speed_index = (speed_index + 1) % SPEEDS.size()
hud.set_speed_label(SPEEDS[speed_index], paused)
func _toggle_pause() -> void:
if _is_client():
hud.notify("Tempo und Pause bestimmt der Host.", true)
return
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
_track_phase()
if match_state.phase == LuneMatch.Phase.COMBAT and not paused and not _is_client():
_accumulator += minf(delta, .1) * SPEEDS[speed_index]
while _accumulator >= step and match_state.phase == LuneMatch.Phase.COMBAT:
match_state.step_combat()
_accumulator -= step
if _net():
session.host_tick(delta)
_track_phase()
if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null:
if match_state.combat != _bound_combat:
_enter_combat()
for unit: LuneUnit in match_state.combat.units:
if unit.is_mobile() and not _views.has(unit.id):
_add_view(unit)
var alpha: float = 1.0
if match_state.phase == LuneMatch.Phase.COMBAT:
alpha = session.frame_alpha() if _is_client() else _accumulator / step
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)
world.tower_fx.camera = camera
world.sync_strategic(match_state, _views, _time, delta, sim_delta if match_state.phase == LuneMatch.Phase.COMBAT else delta)
world.sync_grotto(match_state, _views, delta)
hud.minimap.camera_focus = camera.focus
hud.minimap.camera_span = camera.zoom
hud.minimap.camera_footprint = camera.footprint()
world.fit_shadows(camera.position.distance_to(camera.focus))
## Reacts to phase changes, whether they come from the local simulation or the host.
func _track_phase() -> void:
if match_state.phase == _last_phase:
return
var previous: int = _last_phase
_last_phase = match_state.phase
if match_state.phase == LuneMatch.Phase.COMBAT:
_enter_combat()
elif previous == LuneMatch.Phase.COMBAT:
if _net() and not _is_client():
session.host_after_combat()
_after_combat()
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_F2, KEY_F3, KEY_F4:
# F1 stays the options key; F2–F4 jump to Top, Mid and Bottom.
_lane_cycle = event.keycode - KEY_F2
focus_lane(LuneMap.LANES[_lane_cycle])
KEY_B:
focus_base()
KEY_G:
focus_grotto()
KEY_H:
focus_hero()
KEY_R:
reset_camera()
KEY_Q:
camera.rotate_step(-1)
KEY_E:
camera.rotate_step(1)
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()