saumwacht-godot/scripts/luneraria/lune_game.gd
Martin Stickelbroeck f53859a298 M2: Helden, Items, Talente, Angriffschampions und Bossgrotte
Drei Helden mit Nähe-/Teilnahme-XP, Talenten und drei Itemplätzen,
drei Angriffschampions mit Buchung n -> n+1, Bossgrotte mit Streitfall,
ruhendem Boss, idempotenter Eroberung, einmaliger Entsendung für das
Siegerteam, hohem Kopfgeld und Sperre bis Sieg+4. HUD-Reiter, Porträt
aus echtem Modell, Grotten-Asset, Bots für alle Systeme. Regeln M2 73/73,
M1 91/91, App M2 15/15 und M1 38/38, Bossszenarien dokumentiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:33:11 +02:00

238 lines
9 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 = {}
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:
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)
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
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()