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