Files

1787 lines
70 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends Node3D
const ShipModel = preload("res://scripts/ship_model.gd")
const HudSurface = preload("res://scripts/hud_surface.gd")
const HudAnchor = preload("res://scripts/hud_anchor.gd")
const ShortcutWheel = preload("res://scripts/shortcut_wheel.gd")
const CombatEffects = preload("res://scripts/combat_effects.gd")
const SpaceEnvironment = preload("res://scripts/space_environment.gd")
const RenameKeyboard = preload("res://scripts/rename_keyboard.gd")
const ControllerHelp = preload("res://scripts/controller_help.gd")
const ControllerPanel = preload("res://scripts/controller_panel.gd")
const PoseFilter = preload("res://scripts/pose_filter.gd")
const EnemyHullBar = preload("res://scripts/enemy_hull_bar.gd")
const UiAssets = preload("res://scripts/ftl_ui_assets.gd")
const TargetLocks = preload("res://scripts/target_locks.gd")
var xr_active := false
var origin: XROrigin3D
var camera: XRCamera3D
var left_hand: XRController3D
var right_hand: XRController3D
var player_ship: Node3D
var enemy_ship: Node3D
var nav_panel: Node3D
var hud_surface: Node3D
var hud_anchor := HudAnchor.new()
var gameplay_hud := false
const GAMEPLAY_HUD_RECT := Rect2(0, 0, 872, 510)
const GAMEPLAY_HUD_SIZE := Vector2(1.10, 1.10 * 510.0 / 872.0)
var combat_effects: Node3D
var target_locks: Node3D
var hud_state: Dictionary = {}
var seen_shots: Dictionary = {}
var right_laser: MeshInstance3D
var hover_marker: MeshInstance3D
var hazard_root: Node3D
var space_environment: Node3D
var battle := true
var paused := false
var hazard := "clear"
var crew_selected := false
var weapon_selected := true
var trigger_was_down := false
var pause_was_down := false
var panel_toggle_was_down := false
var hand_screen_hidden := false
var cancel_was_down := false
var last_command := "Select a crew marker or target an enemy room"
var elapsed := 0.0
var state_poll_elapsed := 0.0
var state_signature := ""
var bridge_connected := false
var hull_current := 30
var hull_max := 30
var shield_level := 2
var reactor_power := 8
var tabletop_root: Node3D
var map_surface: Node3D
var crew_id_selected := ""
var crew_ship_selected := "player"
var held_surface: Node3D
var held_pixel := Vector2i.ZERO
var beam_drag := false
var inspect_was_down := false
var tactical_was_down := false
var grip_last_position := Vector3.ZERO
var gripping := false
var bridge_session := ""
var hover_elapsed := 0.0
var demo_mode := false
var recenter_was_down := false
var last_hover_time := 0.0
var shift_down := false
var control_down := false
var nav_page := "navigation"
var nav_buttons: Array[Area3D] = []
var nav_title: Label3D
var help_label: Node3D
var pause_label: Label3D
var rename_keyboard: Node3D
var keyboard_press := false
var target_room_label: Label3D
var pointed_room := -1
var pointed_ship := ""
var panel_theme: Node3D
var enemy_hull_bar: Node3D
var panel_filter := PoseFilter.new()
var ray_filter := PoseFilter.new()
var pointer_transform := Transform3D.IDENTITY
var power_remove_was_down := false
var power_previous_was_down := false
var power_next_was_down := false
var power_hover_key := ""
var power_page_was_down := false
var tracking_label: Label3D
var diagnostics_elapsed := 0.0
var diagnostics_signature := ""
var wheel: Node3D
var wheel_category := 0
var wheel_was_down := false
var wheel_committed := false
var wheel_dialog_id := ""
var page_left_was_down := false
var page_right_was_down := false
var station_was_down := false
var event_surface: Node3D
var event_was_open := false
var pending_page := ""
var world_surface: Node3D
var initial_table_placed := false
var grabbed_crew: Node3D
var crew_preview: Node3D
var grab_distance := 0.0
var grab_ship := ""
var grab_crew_id := ""
var grab_shift_down := false
var grab_room := -1
var grab_label: Label3D
var world_panel_kind := ""
var world_panel_crop_pending := false
var world_panel_open_age := 0.0
var world_panel_opened_at := 0.0
var world_panel_open_frame_count := 0
const HAND_PAGES := ["navigation", "tactical", "shortcuts", "power", "jump", "help"]
func _ready() -> void:
demo_mode = "--demo" in OS.get_cmdline_user_args()
_create_stage()
_create_rig()
_create_ships()
enemy_hull_bar = EnemyHullBar.new()
add_child(enemy_hull_bar)
enemy_hull_bar.build()
ray_filter.position_deadband = 0.0008
ray_filter.angle_deadband = deg_to_rad(0.07)
ray_filter.time_constant = 0.045
_create_hud()
_create_navigation()
_create_pointer()
_create_event_panel()
_create_world_panel()
_create_grab_preview()
_create_rename_keyboard()
wheel = ShortcutWheel.new()
add_child(wheel)
target_locks = TargetLocks.new()
add_child(target_locks)
_set_hazard("clear")
var interface := XRServer.find_interface("OpenXR")
if "--desktop" not in OS.get_cmdline_user_args() and interface != null and (interface.is_initialized() or interface.initialize()):
get_viewport().use_xr = true
xr_active = true
camera.position = Vector3.ZERO
origin.position = Vector3(0.0, 0.0, 1.3)
else:
if "--require-vr" in OS.get_cmdline_user_args():
push_error("OpenXR headset unavailable. Start SteamVR and connect the headset and controllers, then retry.")
get_tree().quit(2)
return
camera.position = Vector3(0.0, 2.5, 3.0)
camera.look_at(Vector3(0.0, 1.0, 0.0), Vector3.UP)
nav_panel.visible = false
initial_table_placed = true
_poll_bridge_state()
_update_hud()
if "--demo-shot" in OS.get_cmdline_user_args():
call_deferred("_demo_shot", "laser", "player")
func _create_stage() -> void:
var world := WorldEnvironment.new()
var environment := Environment.new()
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color(0.007, 0.012, 0.027)
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.ambient_light_color = Color(0.43, 0.51, 0.67)
environment.ambient_light_energy = 0.7
world.environment = environment
add_child(world)
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-45.0, -20.0, 0.0)
light.light_energy = 1.4
add_child(light)
tabletop_root = Node3D.new()
tabletop_root.name = "FloatingEncounter"
add_child(tabletop_root)
tabletop_root.position.y = 0.7
tabletop_root.scale = Vector3.ONE * 0.35
space_environment = SpaceEnvironment.new()
add_child(space_environment)
space_environment.build()
func _create_rig() -> void:
origin = XROrigin3D.new()
origin.name = "PlayerOrigin"
add_child(origin)
camera = XRCamera3D.new()
camera.name = "HeadCamera"
camera.current = true
origin.add_child(camera)
left_hand = XRController3D.new()
left_hand.name = "LeftController"
left_hand.tracker = "left_hand"
origin.add_child(left_hand)
right_hand = XRController3D.new()
right_hand.name = "RightController"
right_hand.tracker = "right_hand"
# OpenXR actions end in _pose, but Godot renames their tracker poses.
right_hand.pose = "aim"
origin.add_child(right_hand)
func _create_ships() -> void:
player_ship = ShipModel.new()
player_ship.name = "PlayerShip"
tabletop_root.add_child(player_ship)
player_ship.build("kestral", false)
enemy_ship = ShipModel.new()
enemy_ship.name = "EnemyShip"
tabletop_root.add_child(enemy_ship)
enemy_ship.build("rebel_long", true)
combat_effects = CombatEffects.new()
add_child(combat_effects)
_update_ship_positions()
func _update_ship_positions() -> void:
# Keep the player's placement stable when a second ship enters the encounter.
player_ship.position = Vector3(0.0, 0.84, 0.0)
player_ship.rotation.y = -PI / 2.0 if int(player_ship.layout_data.get("vertical", 0)) != 0 else 0.0
enemy_ship.position = Vector3(5.0, 0.84, 0.0)
# Kestrel's bow is local +X; Rebel long's bow is local -Z.
enemy_ship.rotation.y = PI / 2.0 if int(enemy_ship.layout_data.get("vertical", 1)) != 0 else PI
enemy_ship.visible = battle
func _create_hud() -> void:
hud_surface = HudSurface.new()
hud_surface.render_order = 100
add_child(hud_surface)
hud_surface.canvas.use_original_frame = not demo_mode
pause_label = Label3D.new()
pause_label.font = UiAssets.font("header")
pause_label.outline_size = 0
pause_label.position = Vector3(0, HudSurface.SURFACE_SIZE.y * 0.5 + 0.025, 0.015)
pause_label.font_size = 32
pause_label.pixel_size = 0.0008
pause_label.no_depth_test = true
pause_label.render_priority = 101
pause_label.modulate = Color("c6f3da")
hud_surface.add_child(pause_label)
_sync_hud_layout()
_position_hud(0.0, true)
func _gameplay_hud_mode() -> bool:
if bool(hud_state.get("end_screen", false)):
return false
if not bool(hud_state.get("ready", demo_mode)):
return false
# In-run native windows have a separate world surface. A simultaneous
# native menu flag must not expand the HUD into a second copy of that window.
if str(hud_state.get("ui_mode", "")) == "menu" and not bool(hud_state.get("panel_open", false)):
return false
# Desktop preview still needs the complete screen for its Tactical/map view;
# in VR those views have their own hand/world surfaces with native coordinates.
return xr_active or not (hud_state.get("map_open", false) or hud_state.get("tactical", false))
func _sync_hud_layout() -> void:
if hud_surface == null:
return
var next_mode := _gameplay_hud_mode()
if gameplay_hud != next_mode:
gameplay_hud = next_mode
hud_anchor.reset()
if gameplay_hud:
hud_surface.set_crop(GAMEPLAY_HUD_RECT, GAMEPLAY_HUD_SIZE)
else:
hud_surface.set_crop(Rect2(0, 0, 1280, 720), HudSurface.SURFACE_SIZE)
pause_label.position = Vector3(0, hud_surface.surface_size.y * 0.5 + 0.025, 0.015)
func _player_hud_local_bounds() -> AABB:
var bounds := AABB(player_ship.shield_center - player_ship.shield_radii, player_ship.shield_radii * 2.0)
# Keep the envelope even when shields are depleted, preventing the HUD from
# dropping through crew/weapons or jumping when shields recharge.
for rect: Rect2 in player_ship.room_bounds.values():
bounds = bounds.expand(Vector3(rect.position.x, -0.16, rect.position.y))
bounds = bounds.expand(Vector3(rect.end.x, 0.35, rect.end.y))
var image: Dictionary = player_ship.layout_data.get("image_rect", {})
if not image.is_empty():
var first: Vector2 = (Vector2(float(image.get("x", 0)), float(image.get("y", 0))) / 35.0 - player_ship.layout_center) * ShipModel.TILE
var last: Vector2 = first + Vector2(float(image.get("w", 0)), float(image.get("h", 0))) / 35.0 * ShipModel.TILE
bounds = bounds.expand(Vector3(first.x, -0.16, first.y))
bounds = bounds.expand(Vector3(last.x, 0.35, last.y))
return bounds
func _position_hud(delta: float, snap := false) -> void:
if hud_surface == null or camera == null:
return
var viewer := camera.get_camera_transform()
if gameplay_hud:
hud_surface.global_transform = hud_anchor.update(player_ship.global_transform, _player_hud_local_bounds(), viewer, GAMEPLAY_HUD_SIZE, delta, snap)
else:
# Preserve the old full native menu framing; the HUD node itself can remain
# outside the camera so gameplay can clamp its head-relative pose above
# the ship when necessary.
hud_surface.global_transform = viewer * Transform3D(Basis.IDENTITY.scaled(Vector3.ONE * 0.64), Vector3(0, -0.16, -1.8))
func _create_navigation() -> void:
nav_panel = Node3D.new()
nav_panel.name = "ControllerNavigation"
left_hand.add_child(nav_panel)
nav_panel.top_level = true
nav_panel.position = Vector3(0.0, 0.08, -0.11)
nav_panel.scale = Vector3.ONE * 0.6
var base := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = Vector3(0.92, 0.72, 0.016)
base.mesh = mesh
base.material_override = _material(Color(0.035, 0.075, 0.075))
base.material_override.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
base.material_override.no_depth_test = true
base.material_override.render_priority = 120
nav_panel.add_child(base)
base.visible = false
panel_theme = ControllerPanel.new()
nav_panel.add_child(panel_theme)
for i in range(14):
var button := Area3D.new()
button.position = Vector3(-0.22 if i % 2 == 0 else 0.22, 0.235 - floori(i / 2.0) * 0.08, 0.025)
button.set_meta("kind", "navigation")
nav_panel.add_child(button)
nav_buttons.append(button)
var shape := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(0.40, 0.067, 0.04)
shape.shape = box
button.add_child(shape)
var label := Label3D.new()
label.font = UiAssets.font("body")
label.outline_size = 0
label.font_size = 21
label.pixel_size = 0.0011
label.position.z = 0.027
label.no_depth_test = true
label.render_priority = 123
label.modulate = Color(0.73, 0.98, 0.81)
button.add_child(label)
var plate := MeshInstance3D.new()
var tile := BoxMesh.new()
tile.size = Vector3(0.405, 0.07, 0.006)
plate.mesh = tile
plate.position.z = 0.015
plate.material_override = _emissive(Color(0.12, 0.23, 0.21), 0.25)
plate.material_override.no_depth_test = true
plate.material_override.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
plate.material_override.render_priority = 122
button.add_child(plate)
label.visible = false
plate.visible = false
nav_title = Label3D.new()
nav_title.font = UiAssets.font("header")
nav_title.outline_size = 0
nav_title.position = Vector3(0, 0.32, 0.03)
nav_title.font_size = 23
nav_title.pixel_size = 0.001
nav_title.no_depth_test = true
nav_title.render_priority = 123
nav_panel.add_child(nav_title)
nav_title.visible = false
var legend := Label3D.new()
legend.font = UiAssets.font("body")
legend.outline_size = 0
legend.position = Vector3(0, -0.395, 0.03)
legend.font_size = 18
legend.pixel_size = 0.001
legend.no_depth_test = true
legend.render_priority = 123
legend.text = "Dpad ◀ / ▶: screen ▼: tactical ▲: power\nR Pause: pause L View: hide screen R bumper: 1–8 wheel"
nav_panel.add_child(legend)
legend.visible = false
help_label = ControllerHelp.new()
help_label.position.z = 0.045
nav_panel.add_child(help_label)
map_surface = HudSurface.new()
map_surface.frame_file = "screen_frame.png"
map_surface.render_order = 122
map_surface.scale = Vector3.ONE * 0.3
map_surface.position = Vector3(0.0, 0.03, 0.045)
nav_panel.add_child(map_surface)
map_surface.visible = false
tracking_label = Label3D.new()
tracking_label.font = UiAssets.font("body")
tracking_label.outline_size = 0
tracking_label.position = Vector3(0.28, -0.34, -1.4)
tracking_label.font_size = 24
tracking_label.pixel_size = 0.0008
tracking_label.no_depth_test = true
tracking_label.render_priority = 101
camera.add_child(tracking_label)
_refresh_navigation()
func _create_pointer() -> void:
right_laser = MeshInstance3D.new()
var beam := CylinderMesh.new()
beam.top_radius = 0.0008
beam.bottom_radius = 0.0008
beam.height = 1.0
right_laser.mesh = beam
right_laser.material_override = _emissive(Color(0.32, 1.0, 0.72), 2.0)
right_laser.material_override.no_depth_test = true
right_laser.material_override.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
right_laser.material_override.render_priority = 127
right_laser.visible = false
add_child(right_laser)
hover_marker = MeshInstance3D.new()
var ball := SphereMesh.new()
ball.radius = 0.0025
ball.height = 0.005
hover_marker.mesh = ball
hover_marker.material_override = _emissive(Color(0.45, 1.0, 0.72), 2.0)
hover_marker.material_override.no_depth_test = true
hover_marker.material_override.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
hover_marker.material_override.render_priority = 127
hover_marker.visible = false
add_child(hover_marker)
func _create_event_panel() -> void:
event_surface = HudSurface.new()
event_surface.name = "EncounterDialog"
event_surface.frame_file = "event_frame.png"
event_surface.source_rect = Rect2(160, 138, 800, 390)
event_surface.surface_size = Vector2(0.98, 0.588)
event_surface.render_order = 40
add_child(event_surface)
event_surface.visible = false
func _game_actions_available() -> bool:
return (rename_keyboard == null or not rename_keyboard.visible) and hud_state.get("ready", false) and (hud_state.get("ui_mode", "") == "game" or hud_state.get("tactical", false)) and not hud_state.get("blocking_ui", false) and not hud_state.get("map_open", false) and not hud_state.get("panel_open", false) and not hud_state.get("event_open", false) and not hud_state.get("transition", false)
func _world_actions_available() -> bool:
return (rename_keyboard == null or not rename_keyboard.visible) and (demo_mode or (hud_state.get("ready", false) and (hud_state.get("ui_mode", "") == "game" or hud_state.get("tactical", false)) and not hud_state.get("blocking_ui", false) and not hud_state.get("map_open", false) and not hud_state.get("panel_open", false) and not hud_state.get("event_open", false) and not hud_state.get("transition", false)))
func _create_world_panel() -> void:
world_surface = HudSurface.new()
world_surface.name = "ShipAndStorePanel"
world_surface.frame_file = "panel_frame.png"
world_surface.surface_size = Vector2(1.02, 0.57375)
world_surface.render_order = 45
add_child(world_surface)
world_surface.visible = false
func _create_grab_preview() -> void:
grab_label = Label3D.new()
grab_label.font = UiAssets.font("body")
grab_label.outline_size = 0
grab_label.font_size = 22
grab_label.pixel_size = 0.00065
grab_label.no_depth_test = true
grab_label.render_priority = 126
grab_label.modulate = Color(0.6, 1.0, 0.72)
add_child(grab_label)
grab_label.visible = false
target_room_label = Label3D.new()
target_room_label.font = UiAssets.font("body")
target_room_label.outline_size = 0
target_room_label.font_size = 24
target_room_label.pixel_size = 0.00065
target_room_label.no_depth_test = true
target_room_label.render_priority = 126
target_room_label.modulate = Color("ffe4ad")
add_child(target_room_label)
target_room_label.visible = false
func _create_rename_keyboard() -> void:
rename_keyboard = RenameKeyboard.new()
rename_keyboard.name = "NativeRenameKeyboard"
add_child(rename_keyboard)
func _face_front(node: Node3D) -> void:
var toward := camera.get_camera_transform().origin - node.global_position
if toward.length_squared() < 0.00001:
return
var up := Vector3.UP if absf(toward.normalized().dot(Vector3.UP)) < 0.98 else camera.get_camera_transform().basis.y
# The quads' visible face is +Z. A parent-space Y rotation after look_at
# would reverse the vertical component and tip the wheel toward the floor.
node.global_basis = Basis.looking_at(toward, up, true).scaled(node.global_basis.get_scale())
func _place_initial_table() -> void:
if initial_table_placed:
return
var tracker := XRServer.get_tracker("head") as XRPositionalTracker
if tracker == null or not tracker.has_pose("default") or not tracker.get_pose("default").has_tracking_data:
return
_recenter()
initial_table_placed = true
func _position_world_panels() -> void:
# These remain attached to the encounter, not to the head or controller.
var head := camera.get_camera_transform().origin
# Wait for this opening's native capture before freezing its bounds. An old
# cached window must not determine the next window's crop after a slow frame.
if world_surface.visible and world_panel_crop_pending and world_panel_open_age >= 0.25 and _world_panel_has_fresh_frame():
# Store drop boxes and tooltips are separate native components. Retain a
# fixed full-canvas envelope so hover content cannot escape the first crop
# or resize the pointer mapping while an item is being dragged.
var rect: Rect2i = Rect2i(0, 0, 1280, 720) if world_panel_kind in ["buy", "sell"] else world_surface.canvas.native_used_rect()
if rect.size.x > 0 and rect.size.y > 0:
world_surface.set_crop(Rect2(rect), Vector2(1.02, 1.02 * float(rect.size.y) / float(rect.size.x)))
world_panel_crop_pending = false
var anchor := player_ship.global_position
anchor.y += 0.70
var horizontal := head - anchor
horizontal.y = 0.0
if horizontal.length_squared() > 0.001:
anchor += horizontal.normalized() * 0.18
for panel in [event_surface, world_surface]:
if panel.visible:
panel.global_position = anchor
_face_front(panel)
if map_surface.visible and hud_state.get("map_open", false):
map_surface.global_position = _pilot_map_anchor()
_face_front(map_surface)
if enemy_hull_bar != null:
enemy_hull_bar.global_position = enemy_ship.global_position + Vector3(0, 0.52, 0)
enemy_hull_bar.face_viewer(head)
if rename_keyboard.visible:
var view := camera.get_camera_transform()
rename_keyboard.global_position = view.origin - view.basis.z * 0.72 - view.basis.y * 0.22
_face_front(rename_keyboard)
func _world_panel_has_fresh_frame() -> bool:
if world_surface.canvas.frame_image == null:
return false
if world_surface.canvas.capture_time_unix > 0.0:
return world_surface.canvas.capture_time_unix >= world_panel_opened_at
# Legacy PNG fixtures have no transport timestamp, so require a new image.
return world_surface.canvas.received_frames > world_panel_open_frame_count
func _pilot_map_anchor() -> Vector3:
# The native room role follows the actual installed piloting system, even
# on layouts whose cockpit is away from the nose. Distances stay in metres
# when the player resizes or turns the encounter.
var cockpit := player_ship.global_position
for id in player_ship.room_nodes:
if str(player_ship.room_nodes[id].get_meta("system_role", "")) == "pilot":
cockpit = player_ship.to_global(player_ship.room_points[id])
break
var local_bow := Vector3.FORWARD if int(player_ship.layout_data.get("vertical", 0)) != 0 else Vector3.RIGHT
var bow: Vector3 = (player_ship.global_basis * local_bow).normalized()
var up: Vector3 = player_ship.global_basis.y.normalized()
return cockpit + up * 0.62 + bow * 0.24
func _cycle_hand_page(direction: int) -> void:
var current_page := pending_page if pending_page != "" else nav_page
var index := HAND_PAGES.find(current_page)
_open_hand_page(HAND_PAGES[posmod(maxi(0, index) + direction, HAND_PAGES.size())])
func _open_hand_page(page: String) -> void:
nav_page = page
if pending_page != "":
pending_page = page
_refresh_navigation()
return
# Close the native surface before opening another one. Wait for its state
# acknowledgment rather than issuing two Escape/toggle commands together.
if hud_state.get("map_open", false) and page != "jump":
pending_page = page
_command("navigate", {"screen": "menu"})
elif hud_state.get("tactical", false) and page != "tactical":
pending_page = page
_command("navigate", {"screen": "tactical"})
elif page == "jump" and not hud_state.get("map_open", false) and _game_actions_available() and hud_state.get("navigation", {}).get("jump", false):
_command("navigate", {"screen": "jump"})
elif page == "tactical" and not hud_state.get("tactical", false) and _game_actions_available():
_command("navigate", {"screen": "tactical"})
_refresh_navigation()
func _toggle_tactical() -> void:
if rename_keyboard.visible or not hud_state.get("ready", demo_mode) or hud_state.get("ui_mode", "") == "menu":
return
_open_hand_page("navigation" if hud_state.get("tactical", false) else "tactical")
func _toggle_power_page() -> void:
if rename_keyboard.visible or not hud_state.get("ready", demo_mode) or hud_state.get("ui_mode", "") == "menu":
return
_open_hand_page("navigation" if nav_page == "power" else "power")
func _change_system_power(direction: int) -> void:
if nav_page != "power" or not _game_actions_available():
return
var row: Dictionary = panel_theme.selected_system()
if row.is_empty() or int(row.get("id", -1)) < 0:
return
_command("system_power", {"system_id": int(row.id), "system_key": str(row.key),
"direction": direction, "modifiers": {"shift": false, "control": false}})
func _update_power_controls(remove: bool, previous: bool, next: bool) -> void:
if nav_page == "power" and _game_actions_available() and not beam_drag:
if previous and not power_previous_was_down:
panel_theme.step_selection(-1)
if next and not power_next_was_down:
panel_theme.step_selection(1)
if remove and not power_remove_was_down:
_change_system_power(-1)
power_remove_was_down = remove
power_previous_was_down = previous
power_next_was_down = next
func _update_wheel() -> void:
var down := right_hand.get_is_active() and right_hand.is_button_pressed("wheel_button")
if down and not wheel_was_down:
# Categories apply to this hold only; every new opening starts on equipment.
wheel_category = 0
wheel_committed = false
wheel_dialog_id = str(hud_state.get("dialog", {}).get("id", ""))
wheel.visible = not rename_keyboard.visible and (hud_state.get("event_open", false) or _game_actions_available())
_refresh_wheel()
if wheel.visible:
if not hud_state.get("event_open", false) and not _game_actions_available():
wheel_committed = true
wheel.visible = false
return
wheel.global_position = _pointer_ray().from + Vector3(0, 0.17, -0.1)
_face_front(wheel)
wheel.choose(right_hand.get_vector2("primary"))
if wheel_dialog_id != str(hud_state.get("dialog", {}).get("id", "")):
wheel_committed = true
wheel.visible = false
if not down and wheel_was_down:
if wheel.visible:
_commit_wheel()
wheel.visible = false
wheel_category = 0
wheel_was_down = down
func _refresh_wheel() -> void:
var entries: Array = []
if hud_state.get("event_open", false):
for choice in hud_state.get("dialog", {}).get("choices", []):
entries.append({"label": "Choice %d" % (int(choice.get("index", 0)) + 1), "choice": choice.get("index", 0), "enabled": choice.get("enabled", true)})
wheel.set_entries(entries, "EVENT\nCHOICES")
return
var player: Dictionary = hud_state.get("player", {})
if wheel_category == 0:
entries = ShortcutWheel.equipment_entries(player, int(hud_state.get("drone_slots", 0)))
wheel.set_entries(entries, "WEAPONS\nDRONES")
elif wheel_category == 1:
var systems: Dictionary = player.get("systems", {})
for item in [["SHIELD", "shields", 97], ["ENGINE", "engines", 115], ["OXYGEN", "oxygen", 102], ["MED/CLONE", "medbay", 100], ["WEAPONS", "weapons", 119], ["DRONES", "drones", 101], ["TELEPORT", "teleporter", 103], ["CLOAK", "cloaking", 104]]:
entries.append({"label": item[0], "key": item[2], "enabled": systems.get(item[1], false) or (item[1] == "medbay" and systems.get("clonebay", false))})
wheel.set_entries(entries, "SYSTEM\nPOWER")
else:
var systems: Dictionary = player.get("systems", {})
for item in [["CLOAK", "cloaking", 99], ["SEND", "teleporter", 116], ["RETURN", "teleporter", 114], ["HACK", "hacking", 110], ["MIND", "mind", 109], ["BATTERY", "battery", 98], ["AUTOFIRE", "weapons", 118], ["ALL CREW", "crew", 113]]:
entries.append({"label": item[0], "key": item[2], "enabled": item[1] == "crew" or systems.get(item[1], false)})
wheel.set_entries(entries, "SYSTEM\nACTIONS")
func _commit_wheel() -> void:
if rename_keyboard.visible or (not hud_state.get("event_open", false) and not _game_actions_available()):
wheel_committed = true
wheel.visible = false
return
if wheel_committed or wheel.selected < 0 or wheel.selected >= wheel.entries.size():
return
var entry: Dictionary = wheel.entries[wheel.selected]
if not entry.get("enabled", true):
return
wheel_committed = true
if entry.has("choice"):
_command("event_choice", {"index": entry.choice, "dialog_id": wheel_dialog_id})
else:
_command("key", {"key": entry.key})
wheel.visible = false
func _sync_jump_heading() -> void:
var local_bow := Vector3.FORWARD if int(player_ship.layout_data.get("vertical", 0)) != 0 else Vector3.RIGHT
space_environment.set_travel_direction(player_ship.global_basis * local_bow)
func _process(delta: float) -> void:
if world_surface.visible:
world_panel_open_age += delta
if not paused:
elapsed += delta
state_poll_elapsed += delta
if state_poll_elapsed >= 0.05:
state_poll_elapsed = 0.0
_poll_bridge_state()
combat_effects.simulation_paused = paused
player_ship.simulation_paused = paused
enemy_ship.simulation_paused = paused
space_environment.simulation_paused = paused
_sync_jump_heading()
var viewer := camera.get_camera_transform().origin
player_ship.set_viewer_position(viewer)
enemy_ship.set_viewer_position(viewer)
if xr_active:
_place_initial_table()
_resolve_controller_pose(right_hand)
_resolve_controller_pose(left_hand)
_update_stable_ray(delta)
shift_down = _pressed(right_hand, "grip")
control_down = right_hand.is_button_pressed("primary_click")
_update_input_diagnostics(delta)
_face_navigation_to_headset(delta)
_position_world_panels()
_update_pointer()
var ray := _pointer_ray()
_update_crew_grab(ray.from, ray.direction)
_update_wheel()
if wheel.visible:
right_laser.visible = false
hover_marker.visible = false
var trigger_down := right_hand.get_is_active() and _pressed(right_hand, "trigger")
if trigger_down and not trigger_was_down and wheel.visible:
_commit_wheel()
elif trigger_down and not trigger_was_down:
_select_from_ray(ray.from, ray.direction)
if not trigger_down and trigger_was_down:
_release_pointer(ray.from, ray.direction)
trigger_was_down = trigger_down
_update_pause_panel_buttons()
var cancel_down := right_hand.is_button_pressed("by_button")
if cancel_down and not cancel_was_down:
if wheel.visible:
wheel_committed = true
wheel.visible = false
else:
_secondary_click(ray.from, ray.direction)
cancel_was_down = cancel_down
var inspect_down := right_hand.is_button_pressed("ax_button")
if inspect_down and not inspect_was_down:
if wheel.visible and not hud_state.get("event_open", false):
wheel_category = (wheel_category + 1) % 3
_refresh_wheel()
else:
enemy_ship.inspect_rooms = not enemy_ship.inspect_rooms
enemy_ship.apply_live(enemy_ship.live_data)
inspect_was_down = inspect_down
var frame := _is_frame_hand(left_hand)
var tactical_down := left_hand.is_button_pressed("tactical_button") if frame else left_hand.is_button_pressed("ax_button") and nav_page != "power"
if tactical_down and not tactical_was_down:
_toggle_tactical()
tactical_was_down = tactical_down
var power_page_down := left_hand.is_button_pressed("by_button") and (frame or nav_page != "power")
if power_page_down and not power_page_was_down:
_toggle_power_page()
power_page_was_down = power_page_down
var page_left := left_hand.is_button_pressed("page_left")
var page_right := left_hand.is_button_pressed("page_right")
if page_left and not page_left_was_down:
_cycle_hand_page(-1)
if page_right and not page_right_was_down:
_cycle_hand_page(1)
page_left_was_down = page_left
page_right_was_down = page_right
var stations := _pressed(left_hand, "trigger")
if stations and not station_was_down and _game_actions_available():
if nav_page == "power":
_change_system_power(1)
else:
_command("key", {"key": 47 if shift_down else 13})
station_was_down = stations
_update_power_controls(left_hand.is_button_pressed("power_remove"), right_hand.is_button_pressed("power_previous") if frame else left_hand.is_button_pressed("ax_button"), right_hand.is_button_pressed("power_next") if frame else left_hand.is_button_pressed("by_button"))
var grip_down := left_hand.get_is_active() and _pressed(left_hand, "grip")
if grip_down and gripping:
tabletop_root.global_position += left_hand.global_position - grip_last_position
gripping = grip_down
grip_last_position = left_hand.global_position
var stick := left_hand.get_vector2("primary")
if stick.length() > 0.2:
tabletop_root.rotate_y(-stick.x * delta)
tabletop_root.scale = Vector3.ONE * clampf(tabletop_root.scale.x + stick.y * delta * 0.35, 0.2, 1.5)
var recenter_down := left_hand.is_button_pressed("primary_click")
if recenter_down and not recenter_was_down:
_recenter()
recenter_was_down = recenter_down
hover_elapsed += delta
if hover_elapsed >= 0.12 and not wheel.visible:
hover_elapsed = 0.0
_hover_ui(ray.from, ray.direction)
_position_hud(delta)
func _face_navigation_to_headset(delta: float = 1.0 / 90.0) -> void:
if not left_hand.get_is_active() or hand_screen_hidden or bool(hud_state.get("end_screen", false)):
_set_navigation_visible(false)
panel_filter.reset()
return
_set_navigation_visible(true)
var point := left_hand.to_global(Vector3(0, 0.08, -0.11))
var toward := camera.get_camera_transform().origin - point
if toward.length_squared() > 0.00001:
var up := Vector3.UP if absf(toward.normalized().dot(Vector3.UP)) < 0.98 else camera.get_camera_transform().basis.y
var target := Transform3D(Basis.looking_at(toward, up, true).scaled(Vector3.ONE * 0.6), point)
nav_panel.global_transform = panel_filter.update(target, delta)
func _set_navigation_visible(shown: bool) -> void:
if nav_panel.visible == shown:
return
nav_panel.visible = shown
for button in nav_buttons:
var enabled: bool = shown and button.visible
button.collision_layer = 1 if enabled else 0
button.get_child(0).set_deferred("disabled", not enabled)
func _update_pause_panel_buttons() -> void:
# Frame's right Menu is its Pause button; left View only hides the hand UI.
var pause_down := right_hand.is_button_pressed("menu_button")
if pause_down and not pause_was_down:
_toggle_pause()
pause_was_down = pause_down
var panel_down := left_hand.is_button_pressed("menu_button")
if panel_down and not panel_toggle_was_down:
hand_screen_hidden = not hand_screen_hidden
_face_navigation_to_headset()
panel_toggle_was_down = panel_down
func _is_frame_hand(hand: XRController3D) -> bool:
var tracker := XRServer.get_tracker(hand.tracker) as XRControllerTracker
return tracker != null and "frame_controller" in str(tracker.profile)
func _update_stable_ray(delta: float) -> void:
if not right_hand.get_is_active():
ray_filter.reset()
return
pointer_transform = ray_filter.update(right_hand.global_transform, delta)
func _pointer_ray() -> Dictionary:
var pose := pointer_transform if ray_filter.initialized else right_hand.global_transform
return {"from": pose.origin, "direction": -pose.basis.z.normalized()}
func _update_pointer() -> void:
if not right_hand.get_is_active():
right_laser.visible = false
hover_marker.visible = false
return
var ray := _pointer_ray()
var start: Vector3 = ray.from
var direction: Vector3 = ray.direction
var hit := {}
var keyboard_hit: Dictionary = rename_keyboard.ray_hit(start, direction)
var hand_hit := {} if rename_keyboard.visible else _hand_panel_hit(start, direction)
var surface_hit := {} if rename_keyboard.visible else _surface_hit(start, direction)
# A left-hand power press in this frame must use the card under the visible
# right-hand ray, rather than waiting for the slower native hover interval.
var hover_key := str(hand_hit.get("system_key", "")) if nav_page == "power" and not wheel.visible else ""
if hover_key != power_hover_key:
power_hover_key = hover_key
if hover_key != "":
panel_theme.select_system(hover_key)
if not keyboard_hit.is_empty():
hit = keyboard_hit
elif not hand_hit.is_empty():
hit = hand_hit
elif surface_hit.is_empty() and _world_actions_available():
if grabbed_crew != null:
hit = _room_hit(start, direction, grab_ship, 0.012)
elif not _active_target().is_empty():
hit = _target_room_hit(start, direction)
if hit.is_empty():
var query := PhysicsRayQueryParameters3D.create(start, start + direction * 8.0)
query.collide_with_areas = true
query.collide_with_bodies = false
hit = get_world_3d().direct_space_state.intersect_ray(query)
var end := start + direction * 4.0
if not hit.is_empty():
end = hit.get("room_position", hit["position"])
hover_marker.global_position = end
hover_marker.visible = true
else:
hover_marker.visible = false
if not surface_hit.is_empty():
var pixel: Vector2i = surface_hit["pixel"]
var surface: Node3D = surface_hit["surface"]
end = surface.pixel_world(pixel)
hover_marker.global_position = end
hover_marker.visible = true
_update_target_feedback(hit if surface_hit.is_empty() and hand_hit.is_empty() and keyboard_hit.is_empty() else {})
# A thin cylinder is visible in stereo; a one-pixel line can vanish at distance.
var length := start.distance_to(end)
right_laser.global_position = (start + end) * 0.5
if length > 0.001:
var toward := (end - start).normalized()
var up := Vector3.UP if absf(toward.dot(Vector3.UP)) < 0.99 else Vector3.RIGHT
var orientation := Basis.looking_at(toward, up) * Basis(Vector3.RIGHT, PI / 2.0)
right_laser.global_basis = Basis(orientation.x, orientation.y * length, orientation.z)
right_laser.visible = true
func _unhandled_input(event: InputEvent) -> void:
if not xr_active and event is InputEventWithModifiers:
shift_down = event.shift_pressed
control_down = event.ctrl_pressed
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed and not xr_active:
_select_from_ray(camera.project_ray_origin(event.position), camera.project_ray_normal(event.position))
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not event.pressed and not xr_active:
_release_pointer(camera.project_ray_origin(event.position), camera.project_ray_normal(event.position))
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT and event.pressed and not xr_active:
_secondary_click(camera.project_ray_origin(event.position), camera.project_ray_normal(event.position))
if event is InputEventMouseMotion and not xr_active:
_hover_ui(camera.project_ray_origin(event.position), camera.project_ray_normal(event.position))
if event is InputEventKey and event.pressed and (not event.echo or rename_keyboard.visible):
if rename_keyboard.visible:
var data := {"entry_id": rename_keyboard.entry.get("id", "")}
match event.keycode:
KEY_ENTER, KEY_KP_ENTER: data.op = "confirm"
KEY_ESCAPE: data.op = "cancel"
KEY_BACKSPACE: data.op = "backspace"
_:
if event.unicode < 32:
return
data.op = "insert"
data.text = String.chr(event.unicode)
_command("text_input", data)
return
if not demo_mode:
if event.keycode == KEY_SPACE:
_toggle_pause()
elif event.keycode == KEY_ESCAPE:
_command("key", {"key": 27})
elif event.keycode >= KEY_1 and event.keycode <= KEY_8:
_command("key", {"key": 49 + event.keycode - KEY_1})
elif event.keycode == KEY_E:
enemy_ship.inspect_rooms = not enemy_ship.inspect_rooms
enemy_ship.apply_live(enemy_ship.live_data)
elif event.keycode == KEY_R:
_recenter()
elif event.keycode == KEY_J:
_command("navigate", {"screen": "jump"})
elif event.keycode == KEY_F8:
_command("key", {"key": 289})
return
match event.keycode:
KEY_SPACE:
_toggle_pause()
KEY_F:
battle = not battle
_update_ship_positions()
_update_hud()
KEY_0:
_set_hazard("clear")
KEY_1:
_set_hazard("asteroid")
KEY_2:
_set_hazard("sun")
KEY_3:
_set_hazard("storm")
KEY_4:
_set_hazard("nebula")
KEY_5:
_set_hazard("pulsar")
KEY_ESCAPE:
crew_selected = false
_update_hud()
KEY_T:
_demo_shot("laser", "player")
KEY_Y:
_demo_shot("laser", "enemy")
KEY_M:
_demo_shot("missile", "player")
KEY_I:
_demo_shot("ion", "player")
KEY_B:
_demo_shot("beam", "player")
KEY_O:
_demo_shot("bomb", "player")
func _select_from_ray(from: Vector3, direction: Vector3) -> void:
keyboard_press = false
var keyboard_hit: Dictionary = rename_keyboard.ray_hit(from, direction)
if not keyboard_hit.is_empty():
keyboard_press = true
var data: Dictionary = rename_keyboard.activate(keyboard_hit)
if not data.is_empty():
_command("text_input", data)
return
if rename_keyboard.visible:
return
var hand_hit := _hand_panel_hit(from, direction)
if not hand_hit.is_empty() and not hand_hit.has("surface"):
if hand_hit.has("system_key"):
panel_theme.select_system(str(hand_hit.system_key))
elif hand_hit.has("button"):
_navigation_action(str(hand_hit.button.get_meta("action")))
return
var surface_hit := _surface_hit(from, direction)
if not surface_hit.is_empty():
held_surface = surface_hit["surface"]
held_pixel = surface_hit["pixel"]
_command("ui_mouse", {"x": held_pixel.x, "y": held_pixel.y, "phase": "down"})
return
if not bridge_connected and not demo_mode:
return
if _world_actions_available() and not _active_target().is_empty():
var room_hit := _target_room_hit(from, direction)
if not room_hit.is_empty():
beam_drag = str(_active_target().kind) == "weapon" and _selected_weapon_is_beam()
_send_target_hit(room_hit, "down" if beam_drag else "click")
return
var query := PhysicsRayQueryParameters3D.create(from, from + direction.normalized() * 12.0)
query.collide_with_areas = true
query.collide_with_bodies = false
var hit := get_world_3d().direct_space_state.intersect_ray(query)
if hit.is_empty():
return
var target: Object = hit["collider"]
if not target.has_meta("kind"):
return
if str(target.get_meta("kind")) != "navigation" and not _world_actions_available():
return
match str(target.get_meta("kind")):
"crew":
if not bool(target.get_meta("controllable", true)):
return
crew_selected = true
crew_id_selected = str(target.get_meta("crew_id"))
crew_ship_selected = str(target.get_meta("ship"))
_command("select_crew", {"crew_id": crew_id_selected})
_begin_crew_grab(target as Node3D, from)
last_command = "Hold trigger to grab; release over a room to order crew"
"door":
if str(target.get_meta("ship", "")) == "player":
_command("door_toggle", {"door_id": int(target.get_meta("door_id"))})
"room":
var ship := str(target.get_meta("ship"))
var room_id := int(target.get_meta("room_id"))
if ship == crew_ship_selected and crew_selected:
_command("move_crew", {"crew_id": crew_id_selected, "room_id": room_id})
elif not _active_target().is_empty() and ship in _active_target().get("ships", []):
beam_drag = str(_active_target().kind) == "weapon" and _selected_weapon_is_beam()
_command("target_room", {"room_id": room_id, "ship": ship, "phase": "down" if beam_drag else "click"})
else:
last_command = "%s room %d" % [ship.capitalize(), room_id]
"navigation":
_navigation_action(str(target.get_meta("action")))
_update_hud()
func _command(action: String, data: Dictionary) -> void:
data = data.duplicate(true)
data["modifiers"] = {"shift": false, "control": false} if action == "text_input" else data.get("modifiers", {"shift": shift_down, "control": control_down})
last_command = "%s %s" % [action, JSON.stringify(data)]
if demo_mode:
return
var command := {"action": action, "data": data, "time_unix": Time.get_unix_time_from_system()}
var output_path := _local_data_path("commands.jsonl")
var output := FileAccess.open(output_path, FileAccess.READ_WRITE if FileAccess.file_exists(output_path) else FileAccess.WRITE)
if output != null:
output.seek_end()
output.store_line(JSON.stringify(command))
if action != "ui_move":
print("FTL_TABLETOP_COMMAND ", JSON.stringify(command))
func _toggle_pause() -> void:
if rename_keyboard.visible or bool(hud_state.get("end_screen", false)):
return
if demo_mode:
paused = not paused
else:
_command("key", {"key": 32})
_update_hud()
func _update_hud() -> void:
if hud_surface == null:
return
var state := hud_state.duplicate(true)
var player_paused := bool(hud_state.get("paused", paused if demo_mode else false)) and not bool(hud_state.get("end_screen", false))
state.merge({"hull": hull_current, "hull_max": hull_max, "shield": shield_level,
"reactor": reactor_power, "combat": battle, "paused": player_paused}, true)
hud_surface.set_state(state)
# A single live-state cue is shared by both eyes and every native view.
# Frozen event/store simulation is distinct from the player's pause toggle.
pause_label.text = "PAUSED" if player_paused else ""
pause_label.visible = bool(state.get("ready", demo_mode)) and not rename_keyboard.visible and player_paused
player_ship.set_shields(shield_level, int(state.get("super_shield", 0)), bool(state.get("player", {}).get("shield_shutdown", false)))
enemy_ship.set_shields(int(state.get("enemy_shield", 2)), int(state.get("enemy_super_shield", 0)), bool(state.get("enemy", {}).get("shield_shutdown", false)))
_sync_hud_layout()
_position_hud(0.0)
func _demo_shot(kind: String, source: String) -> void:
if not battle:
return
var receiver: Node3D = enemy_ship if source == "player" else player_ship
var outcome := "shield" if receiver.shield_charge > 0 and kind in ["laser", "ion"] else "hull"
_present_shot({"kind": kind, "source": source, "weapon_slot": 0, "target_room": 0,
"end_room": 4, "outcome": outcome, "duration": 0.9})
func _present_shot(event: Dictionary) -> void:
var source := str(event.get("source", ""))
if source == "environment":
var receiver: Node3D = enemy_ship if int(event.get("ship", 0)) == 1 else player_ship
combat_effects.spawn_shot(event, receiver, receiver)
return
if source not in ["player", "enemy"] or (not battle and not event.has("drone_id")):
return
if str(event.get("kind", "laser")) not in ["laser", "ion", "missile", "beam", "bomb"]:
return
combat_effects.spawn_shot(event, player_ship if source == "player" else enemy_ship,
enemy_ship if source == "player" else player_ship)
func _poll_bridge_state() -> void:
if demo_mode:
return
var path := _local_data_path("live_state.json")
if not FileAccess.file_exists(path):
bridge_connected = false
return
var signature := FileAccess.get_sha256(path)
bridge_connected = Time.get_unix_time_from_system() - float(hud_state.get("bridge_time_unix", 0)) < 2.0
if signature == state_signature:
return
var decoder := JSON.new()
if decoder.parse(FileAccess.get_file_as_string(path)) != OK or not decoder.data is Dictionary:
return
var parsed: Variant = decoder.data
state_signature = signature
bridge_connected = true
var state: Dictionary = parsed
bridge_connected = Time.get_unix_time_from_system() - float(state.get("bridge_time_unix", 0)) < 2.0
if not bridge_connected:
return
hud_state = state.duplicate(true)
if _apply_end_screen(state):
return
combat_effects.visible = true
_apply_text_entry(state.get("text_entry", {}))
space_environment.apply_state(state)
var event_open := bool(state.get("event_open", false))
event_surface.visible = event_open
var world_was_open: bool = world_surface.visible
world_surface.visible = bool(state.get("panel_open", false))
var panel_kind := str(state.get("panel_kind", "window"))
if world_surface.visible and (not world_was_open or panel_kind != world_panel_kind):
world_panel_kind = panel_kind
world_panel_crop_pending = true
world_panel_open_age = 0.0
world_panel_opened_at = Time.get_unix_time_from_system()
world_panel_open_frame_count = world_surface.canvas.received_frames
if grabbed_crew != null and not _world_actions_available():
_end_crew_grab()
event_was_open = event_open
if event_open:
event_surface.set_crop(Rect2(313 if state.get("dialog", {}).get("centered", true) else 163, 138, 650, 390), Vector2(0.98, 0.588))
_face_front(event_surface)
if pending_page != "" and not state.get("map_open", false) and not state.get("tactical", false):
var next_page := pending_page
pending_page = ""
_open_hand_page(next_page)
var session := str(state.get("bridge_session", ""))
var first_state := bridge_session == "" or session != bridge_session
if session != bridge_session:
seen_shots.clear()
bridge_session = session
weapon_selected = int(state.get("weapon_selected", -1)) >= 0
crew_selected = false
for side in ["player", "enemy"]:
for crew in state.get(side, {}).get("crew", []):
if crew.get("selected", false) and crew.get("controllable", false):
crew_selected = true
crew_id_selected = str(crew["id"])
crew_ship_selected = side
break
player_ship.visible = bool(state.get("ready", false))
for side in ["player", "enemy"]:
if state.has(side):
var ship: Node3D = player_ship if side == "player" else enemy_ship
var data: Dictionary = state[side]
if str(data.get("asset_key", "")) != ship.ship_name:
ship.build(str(data["asset_key"]), side == "enemy")
if side == "enemy":
pointed_room = -1
_update_ship_positions()
ship.apply_live(data)
if enemy_hull_bar != null:
enemy_hull_bar.set_state(state.get("enemy", {}) if state.get("combat", false) else {})
var map_on_controller := (bool(state.get("map_open", false)) or bool(state.get("tactical", false))) and xr_active
map_surface.visible = map_on_controller
hud_surface.visible = true
hud_surface.canvas.frame_file = "screen_frame.png" if not xr_active and (state.get("map_open", false) or state.get("tactical", false)) else "hud_frame.png"
map_surface.set_state(state)
_refresh_navigation()
var new_battle := bool(state.get("combat", battle))
if new_battle != battle:
battle = new_battle
_update_ship_positions()
target_locks.apply_snapshot(state, player_ship, enemy_ship)
var new_hazard := str(state.get("hazard", hazard))
if new_hazard in ["clear", "asteroid", "sun", "storm", "nebula", "pulsar"] and new_hazard != hazard:
_set_hazard(new_hazard)
paused = bool(state.get("paused", paused)) or bool(state.get("frozen", false))
hull_current = int(state.get("hull", hull_current))
hull_max = int(state.get("hull_max", hull_max))
shield_level = int(state.get("shield", shield_level))
reactor_power = int(state.get("reactor", reactor_power))
last_command = "Encounter state updated"
_update_hud()
if world_surface != null and rename_keyboard != null:
_position_world_panels()
for event in state.get("shots", []):
if not event is Dictionary or not event.has("id"):
continue
var id := str(event["id"])
if not seen_shots.has(id):
seen_shots[id] = true
if first_state:
var active := false
for projectile in state.get("projectiles", []):
if str(projectile["id"]) == str(event.get("projectile_id", "")):
active = true
if not active or event.get("phase", "fire") == "impact":
continue
if event.get("phase", "fire") == "impact":
combat_effects.resolve_live(event, enemy_ship if int(event.get("ship", 0)) == 1 else player_ship)
else:
_present_shot(event)
combat_effects.update_live_projectiles(state.get("projectiles", []))
func _apply_end_screen(state: Dictionary) -> bool:
if not bool(state.get("end_screen", false)):
return false
# A finished run owns the full native canvas. Stale combat/window state can
# survive its first snapshot; none of those surfaces should cover this page.
for surface in [world_surface, event_surface, map_surface, player_ship, enemy_ship, combat_effects]:
surface.visible = false
_set_navigation_visible(false)
rename_keyboard.set_entry({"active": false})
wheel.visible = false
wheel_committed = true
beam_drag = false
held_surface = null
pending_page = ""
_end_crew_grab()
target_locks.clear()
enemy_hull_bar.set_state({})
paused = false
hud_surface.visible = true
hud_surface.canvas.frame_file = "screen_frame.png"
_update_hud()
return true
func _apply_text_entry(entry: Dictionary) -> void:
rename_keyboard.set_entry(entry)
if rename_keyboard.visible:
# Native text entry can begin while a previously opened wheel is held.
# Its eventual release must not send a gameplay shortcut into the name.
wheel.visible = false
wheel_committed = true
beam_drag = false
if grabbed_crew != null:
_end_crew_grab()
func _surface_hit(from: Vector3, direction: Vector3) -> Dictionary:
if rename_keyboard.visible:
return {}
var hand_hit := _hand_panel_hit(from, direction)
if not hand_hit.is_empty():
return hand_hit if hand_hit.has("surface") else {}
var closest := {}
var closest_distance := INF
for surface in [hud_surface, world_surface, event_surface, map_surface]:
var pixel: Variant = surface.ray_pixel(from, direction)
if pixel != null:
# Hand UI has already won. HUD pixels win over world panels.
if surface == hud_surface:
return {"surface": surface, "pixel": pixel}
var point: Vector3 = surface.pixel_world(pixel)
var distance := from.distance_to(point)
if distance < closest_distance:
closest_distance = distance
closest = {"surface": surface, "pixel": pixel}
var query := PhysicsRayQueryParameters3D.create(from, from + direction * 12.0)
query.collide_with_areas = true
query.collide_with_bodies = false
var hit := get_world_3d().direct_space_state.intersect_ray(query)
if not hit.is_empty() and hit["collider"].get_meta("kind", "") == "navigation" and from.distance_to(hit["position"]) < closest_distance:
return {}
return closest
func _hand_panel_hit(from: Vector3, direction: Vector3) -> Dictionary:
if not nav_panel.is_visible_in_tree():
return {}
# The native Tactical quad is in front of the themed panel. Pick each
# actual display plane; a shared plane shifts oblique controller rays.
if map_surface.get_parent() == nav_panel:
var native_pixel: Variant = map_surface.ray_pixel(from, direction)
if native_pixel != null:
return {"position": map_surface.pixel_world(native_pixel), "surface": map_surface, "pixel": native_pixel}
var start := nav_panel.to_local(from)
var ray := nav_panel.to_local(from + direction) - start
if absf(ray.z) < 0.00001:
return {}
var distance := (0.012 - start.z) / ray.z
if distance <= 0.0:
return {}
var point := start + distance * ray
if absf(point.x) > 0.46 or absf(point.y) > 0.36:
return {}
var result := {"position": nav_panel.to_global(point)}
if nav_page == "power":
var power_hit: Dictionary = panel_theme.ray_hit(from, direction)
if not power_hit.is_empty():
return power_hit
return result
for button in nav_buttons:
if button.visible and Rect2(Vector2(button.position.x - 0.20, button.position.y - 0.0335), Vector2(0.40, 0.067)).has_point(Vector2(point.x, point.y)):
result.button = button
return result
return result
func _update_target_feedback(hit: Dictionary) -> void:
var target := _active_target()
var id := int(hit.get("room_id", -1)) if not target.is_empty() and grabbed_crew == null else -1
var side := str(hit.get("ship", "")) if id >= 0 else ""
if id != pointed_room or side != pointed_ship:
player_ship.set_drop_target(id if side == "player" else -1)
enemy_ship.set_drop_target(id if side == "enemy" else -1)
pointed_room = id
pointed_ship = side
target_room_label.visible = id >= 0
if id >= 0:
var ship: Node3D = player_ship if side == "player" else enemy_ship
var role := str(ship.room_nodes[id].get_meta("system_role", ""))
var kind := str(target.get("kind", "weapon"))
target_room_label.text = ("TARGET" if kind == "weapon" else kind.to_upper()) + " • " + (role.to_upper() if role != "" else "ROOM")
target_room_label.global_position = hit.position + Vector3(0, 0.045, 0)
_face_front(target_room_label)
func _pressed(hand: XRController3D, action: String) -> bool:
return hand.is_button_pressed(action + "_click") or hand.get_float(action) > 0.65
func _resolve_controller_pose(hand: XRController3D) -> void:
var tracker := XRServer.get_tracker(hand.tracker) as XRPositionalTracker
if tracker == null:
return
for name in ["aim", "default", "grip"]:
if tracker.has_pose(name) and tracker.get_pose(name).has_tracking_data:
hand.pose = name
return
func _update_input_diagnostics(delta: float) -> void:
diagnostics_elapsed += delta
var status := ""
if not right_hand.get_is_active():
status = "RIGHT CONTROLLER NOT TRACKED — check SteamVR controller status"
elif shift_down or control_down:
status = ("SHIFT " if shift_down else "") + ("CTRL" if control_down else "")
if status != tracking_label.text:
tracking_label.text = status
if diagnostics_elapsed < 1.0:
return
diagnostics_elapsed = 0.0
var hands := {}
for hand in [left_hand, right_hand]:
var tracker := XRServer.get_tracker(hand.tracker) as XRPositionalTracker
hands[str(hand.tracker)] = {"tracked": hand.get_is_active(), "pose": str(hand.pose),
"profile": str(tracker.profile) if tracker != null else "missing",
"trigger": hand.get_float("trigger"), "trigger_click": hand.is_button_pressed("trigger_click"),
"grip": hand.get_float("grip"), "grip_click": hand.is_button_pressed("grip_click")}
var info := {"hands": hands, "ray_visible": right_laser.visible, "shift": shift_down,
"control": control_down, "time_unix": Time.get_unix_time_from_system()}
var file := FileAccess.open(_local_data_path("xr_input.json"), FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify(info))
var signature := JSON.stringify(hands)
if signature != diagnostics_signature:
print("FTLVR_XR_INPUT ", signature)
diagnostics_signature = signature
func _navigation_action(action: String) -> void:
if action.begins_with("page:"):
_open_hand_page(action.trim_prefix("page:"))
elif action.begins_with("key:"):
_command("key", {"key": int(action.trim_prefix("key:"))})
elif action == "recenter":
_recenter()
else:
_command("navigate", {"screen": action})
func _navigation_entries() -> Array:
var entries: Array = []
var game := _game_actions_available()
if nav_page == "power":
return []
if map_surface.visible:
return [["CLOSE MAP" if hud_state.get("map_open", false) else "CLOSE TACTICAL",
"menu" if hud_state.get("map_open", false) else "tactical"], ["HELP", "page:help"]]
if nav_page == "help":
return [["BACK", "page:navigation"]]
if nav_page == "navigation":
if game:
for name in ["jump", "ship", "store"]:
if hud_state.get("navigation", {}).get(name, false):
entries.append([name.to_upper(), name])
entries.append(["SHORTCUTS", "page:shortcuts"])
entries.append(["SYSTEM POWER", "page:power"])
entries.append(["TACTICAL VIEW", "tactical"])
entries.append(["MENU / BACK", "menu"])
entries.append(["CONTROLS / HELP", "page:help"])
entries.append(["RECENTER", "recenter"])
return entries
if not game:
return [["BACK", "page:navigation"]]
if nav_page == "jump":
return [["OPEN JUMP MAP", "jump"], ["BACK", "page:navigation"]] if hud_state.get("navigation", {}).get("jump", false) else [["JUMP UNAVAILABLE", "page:navigation"]]
if nav_page == "shortcuts":
return [["CREW / DOORS", "page:crew"], ["WEAPONS / DRONES", "page:weapons"],
["SYSTEM POWER", "page:power"], ["SYSTEM ACTIONS", "page:systems"],
["BACK", "page:navigation"], ["CONTROLS / HELP", "page:help"]]
if nav_page == "crew":
entries = [["ALL CREW (Q)", "key:113"], ["SAVE STATIONS (/)", "key:47"],
["RETURN (ENTER)", "key:13"], ["OPEN DOORS (Z)", "key:122"], ["CLOSE DOORS (X)", "key:120"]]
elif nav_page == "weapons":
for i in range(hud_state.get("player", {}).get("weapons", []).size()):
entries.append(["WEAPON %d (%d)" % [i + 1, i + 1], "key:%d" % (49 + i)])
for i in range(int(hud_state.get("drone_slots", 0))):
entries.append(["DRONE %d (%d)" % [i + 1, (i + 5) % 10], "key:%d" % (48 + ((i + 5) % 10))])
entries.append(["AUTOFIRE (V)", "key:118"])
else:
var systems: Dictionary = hud_state.get("player", {}).get("systems", {})
var keys := {"cloaking":[["CLOAK (C)",99]],"teleporter":[["SEND (T)",116],["RETURN (R)",114]],
"hacking":[["HACK (N)",110]],"mind":[["MIND CONTROL (M)",109]],"battery":[["BATTERY (B)",98]]}
for name in keys:
if systems.get(name, false):
for entry in keys[name]:
entries.append([entry[0], "key:%d" % entry[1]])
entries.append(["BACK", "page:shortcuts"])
entries.append(["CONTROLS / HELP", "page:help"])
return entries
func _refresh_navigation() -> void:
if map_surface == null:
return
map_surface.visible = xr_active and not bool(hud_state.get("end_screen", false)) and nav_page != "help" and (hud_state.get("map_open", false) or hud_state.get("tactical", false))
if hud_state.get("map_open", false):
if map_surface.get_parent() != self:
map_surface.reparent(self, false)
map_surface.scale = Vector3.ONE
map_surface.set_render_order(45)
map_surface.set_crop(Rect2(335, 80, 760, 590), Vector2(1.02, 1.02 * 590.0 / 760.0))
else:
if map_surface.get_parent() != nav_panel:
map_surface.reparent(nav_panel, false)
map_surface.position = Vector3(0, 0.03, 0.045)
map_surface.rotation = Vector3.ZERO
map_surface.scale = Vector3.ONE * 0.3
map_surface.set_render_order(122)
map_surface.set_crop(Rect2(0, 0, 1280, 720), HudSurface.SURFACE_SIZE)
help_label.visible = nav_page == "help"
nav_title.text = ("JUMP MAP" if hud_state.get("map_open", false) else "TACTICAL VIEW") if map_surface.visible else nav_page.to_upper()
var heading: String = nav_title.text
if bool(hud_state.get("paused", false)):
nav_title.text += " • PAUSED"
var entries := _navigation_entries()
var themed_buttons: Array = []
for i in range(nav_buttons.size()):
var button := nav_buttons[i]
button.visible = i < entries.size()
var enabled: bool = button.visible and nav_panel.is_visible_in_tree()
button.collision_layer = 1 if enabled else 0
button.get_child(0).set_deferred("disabled", not enabled)
if button.visible:
button.set_meta("action", entries[i][1])
button.get_child(1).text = entries[i][0]
button.position.y = -0.29 if (map_surface.visible and map_surface.get_parent() == nav_panel) or help_label.visible else 0.235 - floori(i / 2.0) * 0.08
themed_buttons.append({"label": entries[i][0], "position": Vector2(button.position.x, button.position.y), "enabled": true})
if nav_page == "power":
panel_theme.set_power(hud_state.get("player", {}), bool(hud_state.get("paused", false)))
else:
panel_theme.set_navigation(heading, themed_buttons, bool(hud_state.get("paused", false)))
if world_surface != null and rename_keyboard != null:
_position_world_panels()
func _hover_ui(from: Vector3, direction: Vector3) -> void:
if keyboard_press or rename_keyboard.visible:
return
if nav_page == "power" and _game_actions_available():
# Local card hover is handled before power buttons every frame. A
# stationary ray must not undo a system selected with X/Y.
return
var now := Time.get_ticks_msec() / 1000.0
if now - last_hover_time < 0.1:
return
last_hover_time = now
if beam_drag:
var beam_hit := _beam_deck_hit(from, direction, "enemy")
if not beam_hit.is_empty() and _surface_hit(from, direction).is_empty() and _hand_panel_hit(from, direction).is_empty():
_send_target_hit(beam_hit, "move")
return
var hit := _surface_hit(from, direction)
if not hit.is_empty():
var pixel: Vector2i = hit["pixel"]
_command("ui_move", {"x": pixel.x, "y": pixel.y})
func _release_pointer(from: Vector3, direction: Vector3) -> void:
if keyboard_press:
keyboard_press = false
return
if grabbed_crew != null:
_update_crew_grab(from, direction)
if grab_room >= 0:
# The initial selection may still be queued in FTL. If the bridge
# repeats that selection, retain the press-time Shift grouping.
_command("move_crew", {"crew_id": grab_crew_id, "room_id": grab_room,
"modifiers": {"shift": grab_shift_down, "control": control_down}})
_end_crew_grab()
return
if held_surface != null:
var pixel: Variant = held_surface.ray_pixel(from, direction)
if pixel != null:
held_pixel = pixel
_command("ui_mouse", {"x": held_pixel.x, "y": held_pixel.y, "phase": "up"})
held_surface = null
if beam_drag:
var hit := _beam_deck_hit(from, direction, "enemy")
if not hit.is_empty() and _surface_hit(from, direction).is_empty() and _hand_panel_hit(from, direction).is_empty() and rename_keyboard.ray_hit(from, direction).is_empty():
_send_target_hit(hit, "up")
else:
_command("cancel", {})
beam_drag = false
func _secondary_click(from: Vector3, direction: Vector3) -> void:
if rename_keyboard.visible:
_command("text_input", {"entry_id": rename_keyboard.entry.get("id", ""), "op": "cancel"})
return
_end_crew_grab()
var hit := _surface_hit(from, direction)
if not hit.is_empty():
var pixel: Vector2i = hit["pixel"]
_command("ui_mouse", {"x": pixel.x, "y": pixel.y, "button": "right"})
else:
_command("cancel", {})
crew_selected = false
crew_id_selected = ""
func _selected_weapon_is_beam() -> bool:
var slot := int(hud_state.get("weapon_selected", -1))
var weapons: Array = hud_state.get("player", {}).get("weapons", [])
return slot >= 0 and slot < weapons.size() and str(weapons[slot].get("kind", "")).to_lower() == "beam"
func _active_target() -> Dictionary:
var targeting: Dictionary = hud_state.get("targeting", {})
if targeting.get("active", false):
return targeting
if not hud_state.has("targeting") and weapon_selected and battle:
return {"active": true, "kind": "weapon", "ships": ["enemy"]}
return {}
func _target_room_hit(from: Vector3, direction: Vector3) -> Dictionary:
var closest := {}
var free_beam := str(_active_target().get("kind", "")) == "weapon" and _selected_weapon_is_beam()
for side in _active_target().get("ships", ["enemy"]):
var hit := _beam_deck_hit(from, direction, str(side)) if free_beam else _room_hit(from, direction, str(side), 0.012)
if not hit.is_empty() and (closest.is_empty() or hit.distance < closest.distance):
closest = hit
return closest
func _send_target_hit(hit: Dictionary, phase: String) -> void:
var data := {"room_id": int(hit.get("room_id", -1)), "ship": str(hit.ship), "phase": phase}
if beam_drag and hit.has("pixel"):
data.point = hit.pixel.duplicate()
_command("target_room", data)
func _beam_deck_hit(from: Vector3, direction: Vector3, side: String) -> Dictionary:
var ship: Node3D = player_ship if side == "player" else enemy_ship
if not ship.is_visible_in_tree() or direction.length_squared() < 0.000001:
return {}
var local_from: Vector3 = ship.to_local(from)
var local_direction: Vector3 = ship.global_basis.inverse() * direction.normalized()
if absf(local_direction.y) < 0.000001:
return {}
var distance := (0.0775 - local_from.y) / local_direction.y
if distance < 0.0 or distance > 20.0:
return {}
var point := local_from + local_direction * distance
var deck_point := Vector2(point.x, point.z)
var bounds := Rect2()
var has_bounds := false
for rect: Rect2 in ship.room_bounds.values():
bounds = bounds.merge(rect) if has_bounds else rect
has_bounds = true
var image: Dictionary = ship.layout_data.get("image_rect", {})
if not image.is_empty():
var first: Vector2 = (Vector2(float(image.get("x", 0)), float(image.get("y", 0))) / 35.0 - ship.layout_center) * ShipModel.TILE
var size := Vector2(float(image.get("w", 0)), float(image.get("h", 0))) / 35.0 * ShipModel.TILE
var rect := Rect2(first, size)
bounds = bounds.merge(rect) if has_bounds else rect
has_bounds = true
if not has_bounds or not bounds.has_point(deck_point):
return {}
var room_id := -1
for id in ship.room_bounds:
if ship.room_bounds[id].has_point(deck_point):
room_id = int(id)
break
var native: Vector2 = deck_point / ShipModel.TILE + ship.layout_center + Vector2(float(ship.layout_data.get("x_offset", 0)), float(ship.layout_data.get("y_offset", 0)))
return {"position": ship.to_global(point), "room_position": ship.to_global(point), "distance": distance,
"room_id": room_id, "ship": side, "pixel": {"x": native.x * 35.0, "y": native.y * 35.0}}
func _begin_crew_grab(crew: Node3D, from: Vector3) -> void:
_end_crew_grab()
grabbed_crew = crew
grab_ship = str(crew.get_meta("ship", "player"))
grab_crew_id = str(crew.get_meta("crew_id"))
grab_shift_down = shift_down
grab_distance = clampf(from.distance_to(crew.global_position), 0.08, 3.0)
# This is a visual preview. The native crew remains at its real position and
# walks normally after the drop order; moving the hand never teleports it.
crew_preview = crew.duplicate() as Node3D
crew_preview.set_process(false)
crew_preview.set_physics_process(false)
_disable_preview_collisions(crew_preview)
add_child(crew_preview)
crew_preview.global_transform = crew.global_transform
grab_label.visible = true
grab_label.text = "GRAB • release over a room\nB: cancel"
func _disable_preview_collisions(node: Node) -> void:
if node is CollisionObject3D:
node.collision_layer = 0
node.collision_mask = 0
if node is CollisionShape3D:
node.disabled = true
for child in node.get_children():
_disable_preview_collisions(child)
func _room_hit(from: Vector3, direction: Vector3, side: String = "", margin: float = 0.0) -> Dictionary:
var closest := {}
for ship in [player_ship, enemy_ship]:
if not ship.is_visible_in_tree() or (side != "" and side != ("enemy" if ship == enemy_ship else "player")):
continue
var hit: Dictionary = ship.room_ray_hit(from, direction, margin)
if not hit.is_empty() and (closest.is_empty() or hit.distance < closest.distance):
closest = hit
return closest
func _update_crew_grab(from: Vector3, direction: Vector3) -> void:
if grabbed_crew == null:
return
if not is_instance_valid(grabbed_crew):
_end_crew_grab()
return
grab_room = -1
var hit := _room_hit(from, direction, grab_ship, 0.012)
var drop_ship: Node3D = player_ship if grab_ship == "player" else enemy_ship
var point := from + direction * grab_distance
if not hit.is_empty() and _surface_hit(from, direction).is_empty() and _hand_panel_hit(from, direction).is_empty() and rename_keyboard.ray_hit(from, direction).is_empty():
grab_room = int(hit["collider"].get_meta("room_id"))
point = hit.get("room_position", hit["position"]) + drop_ship.global_basis.y.normalized() * 0.025
var role := str(drop_ship.room_nodes[grab_room].get_meta("system_role", ""))
grab_label.text = "DROP • %s\nRelease trigger • B: cancel" % (role.to_upper() if role != "" else "ROOM")
else:
grab_label.text = "GRAB • aim at a room on this ship\nRelease to keep selection • B: cancel"
if drop_ship.has_method("set_drop_target"):
drop_ship.set_drop_target(grab_room)
crew_preview.global_position = point
grab_label.global_position = point + Vector3(0, 0.08, 0)
_face_front(grab_label)
func _end_crew_grab() -> void:
for ship in [player_ship, enemy_ship]:
if ship != null and ship.has_method("set_drop_target"):
ship.set_drop_target(-1)
if is_instance_valid(crew_preview):
crew_preview.queue_free()
crew_preview = null
grabbed_crew = null
grab_crew_id = ""
grab_room = -1
if grab_label != null:
grab_label.visible = false
func _recenter() -> void:
tabletop_root.transform = Transform3D.IDENTITY
tabletop_root.scale = Vector3.ONE * 0.35
if xr_active:
var head := camera.get_camera_transform().origin
# Local/head-relative XR spaces can put the head at Y=0. There is no
# virtual floor, so keep the ship below the head in either reference space.
var forward := -camera.get_camera_transform().basis.z
forward.y = 0.0
if forward.length_squared() > 0.001:
forward = forward.normalized()
tabletop_root.global_position = head + forward * 0.85 - Vector3(0, 0.95, 0)
tabletop_root.rotation.y = atan2(-forward.x, -forward.z)
else:
tabletop_root.global_position = head + Vector3(0, -0.95, -0.85)
else:
tabletop_root.position.y = 0.7
hud_anchor.reset()
_position_hud(0.0, true)
func _local_data_path(file_name: String) -> String:
if OS.has_feature("editor"):
return ProjectSettings.globalize_path("res://local_game_data/%s" % file_name)
return OS.get_executable_path().get_base_dir().path_join("local_game_data").path_join(file_name)
func _set_hazard(kind: String) -> void:
hazard = kind
space_environment.set_hazard(kind)
if hud_surface != null:
_update_hud()
func _material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
if color.a < 1.0:
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
return mat
func _emissive(color: Color, strength: float) -> StandardMaterial3D:
var mat := _material(color)
mat.emission_enabled = true
mat.emission = color
mat.emission_energy_multiplier = strength
return mat