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) hud.session = session if _net() else null 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.camera_requested.connect(focus_target) 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) session.state_received.connect(hud.mark_dirty) session.lobby_changed.connect(hud.mark_dirty) 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) func focus_enemy_base(instant: bool = false) -> void: var core: Vector2 = match_state.map.core_position(1 - _own_team()) camera.look_at_point(Vector3(core.x, 0, core.y), 26.0, instant) ## Camera jumps requested by the HUD (boss card, event feed, banners). func focus_target(target: String) -> void: match target: "grotto": focus_grotto() "base": focus_base() "enemy_base": focus_enemy_base() "hero": focus_hero() _: if LuneMap.LANES.has(target): focus_lane(target) ## 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 view.ground = world.height_at _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) world.tower_fx.camera = camera world.sync_strategic(match_state, _views, _time, delta, sim_delta if match_state.phase == LuneMatch.Phase.COMBAT else delta) world.sync_grotto(match_state, _views, delta) 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 input_blocked.call(): return if camera.handle_world_input(event): get_viewport().set_input_as_handled() elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: # Left click in the field selects an own unit (details in the HUD); it never orders anything. var picked: LuneUnit = pick_own_unit(event.position) if picked != null and picked.kind == "hero": hud.open_hero() elif picked != null: hud.select_unit(picked.persistent_uid) elif not hud.selected_uid.is_empty(): hud.clear_selection() get_viewport().set_input_as_handled() ## The own troop or hero whose figure is nearest to a screen point (within 34 px), or null. func pick_own_unit(screen: Vector2) -> LuneUnit: if human_slot < 0: return null var best: LuneUnit = null var best_distance: float = 34.0 for view: Node3D in _views.values(): var unit: LuneUnit = view.unit if unit == null or not unit.alive or unit.owner_slot != human_slot or not (unit.kind in ["troop", "hero"]) or not view.visible: continue var at: Vector3 = view.aim_point() if camera.is_position_behind(at): continue var distance: float = camera.unproject_position(at).distance_to(screen) if distance < best_distance: best_distance = distance best = unit return best ## 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: # A Space meant as pause at the very end of a combat must not skip the planning. if not hud.ready_blocked(): _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: if bool(match_state.players[human_slot].ready): # After Ready the plan is binding (the host refuses); the keys do nothing. hud.notify("Bereit gemeldet – die Planung ist verbindlich.", true) else: 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()