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>
224 lines
8.3 KiB
GDScript
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()
|