saumwacht-godot/scripts/luneraria/lune_game.gd
Martin Stickelbroeck 8b17563814 G1: Drei-Lane-Layout mit Höhenfeld, Grotte, Aufmarschbereichen und MOBA-Kamera
Top windet sich über das Hochland, Mid quert die Kluft über die Mittelplattform,
Bottom führt durch das Tiefland über die Deltabrücke; Top und Bottom sind exakt
gleich lang, alles bleibt spiegelsymmetrisch. LuneTerrain liefert ein exakt
gespiegeltes Höhenfeld, auf dem Figuren stehen. Bossgrotte als Terrasse auf der
Achse zwischen Top und Mid, Aufmarschbereiche je Lane, perspektivische Kamera
mit Sprungtasten (F2–F4, B, G, H, O, R, Q/E). Bestandsassets wiederverwendet,
neue Formen als Blockout für G2/G4.

Geprüft: verify_g1 443/443, Regeln 91/91, M2 73/73, verify_m1 38/38,
verify_m2 14/14, Netz 43/43, Loopback 12/12, Balance 40 Partien (Seitentausch
20/20), Legacy 209/463. Vorher/Nachher-Aufnahmen und Messungen in G1-KARTE.md.

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

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