Top windet sich über das Hochland, Mid quert die Kluft über die Mittelplattform, Bottom führt durch das Tiefland über die Deltabrücke; Top und Bottom sind exakt gleich lang, alles bleibt spiegelsymmetrisch. LuneTerrain liefert ein exakt gespiegeltes Höhenfeld, auf dem Figuren stehen. Bossgrotte als Terrasse auf der Achse zwischen Top und Mid, Aufmarschbereiche je Lane, perspektivische Kamera mit Sprungtasten (F2–F4, B, G, H, O, R, Q/E). Bestandsassets wiederverwendet, neue Formen als Blockout für G2/G4. Geprüft: verify_g1 443/443, Regeln 91/91, M2 73/73, verify_m1 38/38, verify_m2 14/14, Netz 43/43, Loopback 12/12, Balance 40 Partien (Seitentausch 20/20), Legacy 209/463. Vorher/Nachher-Aufnahmen und Messungen in G1-KARTE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
357 lines
13 KiB
GDScript
357 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
|
||
_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)
|
||
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
|
||
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()
|