Mondherz-Ancients (Saumkern-Kristall als Herz, Sichelklingen, Ringe, Krone, Teamwappen, Energieadern) mit den Stufen stabil/instabil/kritisch, kurzem Zusammenbruch und bleibender Ruine; der Sieg wartet nie auf die Grafik. Turmfamilie aus einem Blender-Generator: Grenzwacht (Tower 1), Bastion (Tower 2), Kernwacht (Basisturm in der Formensprache des Ancients), mit Schadensstufen, Einsturz, Ruine und sichtbarem Angriff (Zielring, Aufladen, Bolzen, Einschlag) - der Schaden bleibt vollständig in der Simulation. Bossgrotte Mondschlund mit Tor, Treppe in die Tiefe, Altar, Säulen, Felsen und fünf Zuständen; der Generator hält Abstand zur Top-Lane, die Höhle braucht eine kleine Geländesenke (nur Darstellung). Superboss Schlundkrone mit 24 Knochen und 11 Clips (inkl. Brüllen und Sieg), Adern in Teamfarbe, dasselbe Modell für Grotte und Lane. Gemeinsame LN-Materialfamilie (Stein, Metall, Kristall, Rune, Stoff, Mondwasser, Kreaturenhaut), Occlusion-Fade ohne dithernde Schatten. Keine Regeländerung. Geprüft: verify_g2 245/245, verify_g1 443/443, Regeln 91/91 und 73/73, Stress 17/17, Balance 40 Partien identisch mit G1, verify_m1 38/38, verify_m2 14/14, Netz 43/43 und Loopback 12/12, Legacy 209 und 463. 21 Ansichten vorher/nachher (1920x1080), Videos Turmkampf und Bossbegegnung, Leistung G1/G2 auf demselben Mac (+0,5 ms in vollen Kampfansichten). Pakete 0.17.0-g2 für macOS (Universal, ad-hoc signiert), Windows x64/ARM64 und Linux; macOS nativ und unter Rosetta, beide Windows-Pakete in Windows 11 ARM (Parallels, OpenGL-Fallback) mit --lune-smoke bestanden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
351 lines
13 KiB
GDScript
351 lines
13 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)
|
||
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
|
||
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 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()
|