Nicht abgenommen: Nach dem ersten Spieltest wurde entschieden, Champions of Luneraria nach Unity 6 zu portieren. Dieser Stand ist die Referenz für das HUD im Unity-Projekt (../luneraria-unity). - HUD neu aus Komponenten mit eigenem Theme, Porträts aus den echten Modellen (einmal gerendert, zwischengespeichert), Lane-Übersicht mit deterministischer Bedrohung aus öffentlichem Zustand, Bossgrotte, nächste Welle, Ereignisse, Minimap mit zusammengefassten Armeen, Held/Items/Talente, Einheitendetails, Angriffschampions, Bereit-Sperre, UI-Größe, Team-Formen statt nur Farben - Privatsphäre über LuneHudModel: fremdes Gold und fremde Entwürfe erscheinen nie - Keine Regeländerung: Regeln 91/91, M2 73/73, Netz 43/43; verify_g3 715/715; verify_m1 38/38 und verify_m2 14/14 mit angepassten UI-Treibern - Spieltest bis Welle 8 über die echte Oberfläche, Befunde in G3-HUD.md - HELDEN-KONZEPT.md: vom Projektinhaber vorgegebene Helden (nicht umgesetzt) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
407 lines
15 KiB
GDScript
407 lines
15 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.
|
||
## 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()
|