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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:37:23 +02:00

224 lines
8.3 KiB
GDScript

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()