saumwacht-godot/scripts/luneraria/lune_game.gd
Martin Stickelbroeck c15533831a M4: Spiel läuft vollständig über die autoritative Sitzung; M4 pausiert
Das echte Spiel umging die Sitzung bisher bei Bereitschaft, Startaufstellung
und Rückgängig, ließ Bots den Gast-Sitz planen und den Gast selbst simulieren.
Jetzt rechnet nur der Host; Gäste zeigen den Kampf aus Momentaufnahmen.
Dazu Protokollversion, Wiedereinstieg über den Anzeigenamen, verbindliches
Bereit, Entwurfs-Privatsphäre und Bot-Planung einmal pro Runde.

Geprüft: test_lune_net 43/43, net_rounds.py 12/12 (5 Runden, Wiedereinstieg,
gleiche Prüfsummen), Regeln 91/91, M2 73/73, verify_m1 38/38, verify_m2 14/14.
Offen und eingefroren: OpenGL-Modus, nativer Windows-11-Test, Demo-Neubau.

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

288 lines
11 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.
## 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
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 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(_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
_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:
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)
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:
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)
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)
var claim: Dictionary = match_state.boss.claim
world.sync_grotto(match_state.grotto_available(), int(claim.team) if not claim.is_empty() else -1)
hud.minimap.camera_focus = camera.focus
hud.minimap.camera_span = camera.zoom
## 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_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()