Fix Flagship visuals, beam aiming, results and Frame controls

This commit is contained in:
ElHombreAlex committed 2026-10-05 10:52:16 +02:00
1 parent e857f4eee9
commit cf9cfca866
27 files changed
+1084 -96

No files matched your search

+49 -8
View File
@@ -17,6 +17,11 @@ var unit_beam_mesh: CylinderMesh
func spawn_shot(event: Dictionary, sender: Node3D, receiver: Node3D) -> void:
var projectile_id := str(event.get("projectile_id", ""))
for shot in shots:
if not projectile_id.is_empty() and shot["projectile_id"] == projectile_id:
# Repeated fire delivery cannot restart an already visible native flight.
return
var kind := str(event.get("kind", "laser"))
var origin_ship := sender
if event.has("origin_space") and int(event["origin_space"]) == int(receiver.get("enemy")):
@@ -24,6 +29,8 @@ func spawn_shot(event: Dictionary, sender: Node3D, receiver: Node3D) -> void:
if event.has("target_space") and int(event["target_space"]) == int(sender.get("enemy")):
receiver = sender
var start: Vector3 = sender.weapon_origin(int(event.get("weapon_slot", 0)))
if event.has("artillery_slot"):
start = sender.artillery_origin(int(event["artillery_slot"]))
var drone_id := str(event.get("drone_id", ""))
var drone: Node3D = _drone(origin_ship, drone_id)
if drone != null:
@@ -61,15 +68,17 @@ func spawn_shot(event: Dictionary, sender: Node3D, receiver: Node3D) -> void:
node.visible = false
# Bombs appear at their destination rather than crossing space.
shots.append({"node": node, "start": start, "end": end, "beam_end": beam_end,
"projectile_id": str(event.get("projectile_id", "")), "sender": sender,
"projectile_id": projectile_id, "sender": sender,
"created_ms": Time.get_ticks_msec(), "native_present": false,
"visual_scale": maxf(0.1, (origin_ship if not drone_id.is_empty() else sender).global_basis.get_scale().abs().x),
"live_progress": 0.0, "wanted_progress": 0.0, "missed": false, "miss_feedback": false,
"target_end": end, "miss_origin_progress": 0.0, "miss_interval": 1.0,
"end_point": event.get("end_point", {}),
"origin_point": event.get("origin_point", {}), "origin_ship": origin_ship,
"drone_id": drone_id, "launch_point": origin_ship.to_local(start),
"target_point": event.get("target", {}), "beam_point": {},
"slot": int(event.get("weapon_slot", 0)), "room": int(event.get("target_room", 0)),
"slot": int(event.get("weapon_slot", 0)), "artillery_slot": int(event.get("artillery_slot", -1)),
"room": int(event.get("target_room", 0)),
"time": 0.0, "duration": duration, "kind": kind, "outcome": outcome, "receiver": receiver})
@@ -116,13 +125,14 @@ func _process(delta: float) -> void:
var drone: Node3D = _drone(shot["origin_ship"], shot["drone_id"])
shot["start"] = drone.muzzle_position(shot["projectile_id"]) if shot["kind"] == "beam" and drone != null else shot["origin_ship"].to_global(shot["launch_point"])
else:
shot["start"] = shot["sender"].weapon_origin(shot["slot"])
shot["start"] = shot["sender"].artillery_origin(shot["artillery_slot"]) if shot["artillery_slot"] >= 0 else shot["sender"].weapon_origin(shot["slot"])
if shot["drone_id"].is_empty() and not shot["origin_point"].is_empty():
shot["start"] = shot["origin_ship"].to_global(shot["origin_ship"].pixel_point(shot["origin_point"]))
if shot["outcome"] == "pending":
shot["end"] = shot["receiver"].room_target(shot["room"])
if not shot["target_point"].is_empty():
shot["end"] = shot["receiver"].to_global(shot["receiver"].pixel_point(shot["target_point"]))
shot["target_end"] = shot["end"]
if shot["missed"]:
shot["end"] = _miss_endpoint(shot["start"], shot["end"], shot["receiver"])
if not shot["end_point"].is_empty():
@@ -130,7 +140,8 @@ func _process(delta: float) -> void:
var progress := clampf(float(shot["time"]) / float(shot["duration"]), 0.0, 1.0)
if shot["outcome"] == "pending":
if not simulation_paused:
shot["live_progress"] = lerpf(shot["live_progress"], shot["wanted_progress"], minf(1.0, delta * 20.0))
var next_progress := lerpf(shot["live_progress"], shot["wanted_progress"], minf(1.0, delta * 20.0))
shot["live_progress"] = next_progress if shot["kind"] == "beam" else maxf(shot["live_progress"], next_progress)
progress = shot["live_progress"]
var node: MeshInstance3D = shot["node"]
if shot["kind"] == "beam":
@@ -149,8 +160,10 @@ func _process(delta: float) -> void:
_segment(node.get_node("BeamCore"), shot["start"], beam_target, (0.007 if not shot["drone_id"].is_empty() else 0.012) * shot["visual_scale"])
_segment(node.get_node("BeamGlow"), shot["start"], beam_target, (0.018 if not shot["drone_id"].is_empty() else 0.032) * shot["visual_scale"])
elif shot["kind"] != "bomb":
node.global_position = Vector3(shot["start"]).lerp(shot["end"], progress)
node.global_position = _flight_position(shot, progress)
var direction: Vector3 = Vector3(shot["end"]) - Vector3(shot["start"])
if shot["outcome"] == "pending" and shot["missed"]:
direction = Vector3(shot["end"]) - Vector3(shot["start"]).lerp(shot["target_end"], shot["miss_origin_progress"])
if direction.length_squared() > 0.000001:
var up := Vector3.RIGHT if absf(direction.normalized().dot(Vector3.UP)) > 0.98 else Vector3.UP
node.global_basis = Basis.looking_at(direction.normalized(), up).scaled(Vector3.ONE * shot["visual_scale"])
@@ -178,7 +191,7 @@ func resolve_live(event: Dictionary, receiver: Node3D) -> void:
if event.get("outcome", "") == "miss":
# Evasion is reported at the native collision decision. The shot
# may still be flying; native snapshots decide when it disappears.
shots[i]["missed"] = true
_mark_missed(shots[i])
_show_shot_miss(shots[i])
return
kind = str(shots[i]["kind"])
@@ -208,8 +221,15 @@ func update_live_projectiles(projectiles: Array) -> void:
if present.has(shot["projectile_id"]):
var projectile: Dictionary = present[shot["projectile_id"]]
shot["native_present"] = true
shot["wanted_progress"] = float(projectile.get("progress", 0))
shot["missed"] = shot["missed"] or bool(projectile.get("missed", false))
if bool(projectile.get("missed", false)):
_mark_missed(shot)
var native_progress := maxf(0.0, float(projectile.get("progress", 0)))
if shot["kind"] == "beam" or not shot["missed"]:
native_progress = minf(1.0, native_progress)
# Older bridges measured remaining radial distance to the target; that
# value decreases again after a miss flies past it. Out-of-order native
# snapshots must likewise never pull a projectile back toward its sender.
shot["wanted_progress"] = native_progress if shot["kind"] == "beam" else maxf(shot["wanted_progress"], native_progress)
if shot["missed"]:
_show_shot_miss(shot)
shot["beam_point"] = projectile.get("beam_point", {})
@@ -222,6 +242,27 @@ func update_live_projectiles(projectiles: Array) -> void:
shots.remove_at(i)
func _mark_missed(shot: Dictionary) -> void:
if shot["missed"]:
return
shot["missed"] = true
shot["miss_origin_progress"] = float(shot["live_progress"])
shot["miss_interval"] = maxf(0.25, 1.0 - float(shot["miss_origin_progress"]))
func _flight_position(shot: Dictionary, progress: float) -> Vector3:
if shot["outcome"] != "pending" or not shot["missed"]:
return Vector3(shot["start"]).lerp(shot["end"], progress)
# Begin the passing path at the exact already-presented point. Switching to
# a native miss cannot jump the bullet to a different segment or reset it.
# New native progress may exceed one after passing the target; extrapolate
# forward until native absence, which ends the visual without an impact.
var origin_progress := float(shot["miss_origin_progress"])
var origin: Vector3 = Vector3(shot["start"]).lerp(shot["target_end"], origin_progress)
var passing_progress := maxf(0.0, progress - origin_progress) / float(shot["miss_interval"])
return origin.lerp(shot["end"], passing_progress)
func _impact(point: Vector3, kind: String = "laser", shield: bool = false) -> void:
var flash := MeshInstance3D.new()
var sphere_key := "large" if kind in ["missile", "bomb"] else "small"
+5 -1
View File
@@ -88,6 +88,8 @@ class HelpCanvas extends Control:
_button(Vector2(center_x + 31, 264), "A")
_button(Vector2(center_x - 39,222),"X",14)
_button(Vector2(center_x + 5,216),"Y",14)
draw_rect(Rect2(center_x + 27, 183, 24, 16), MUTED, false, 2.0)
_label("PAUSE", Vector2(center_x + 15, 175), 15, MUTED)
draw_line(Vector2(center_x - 28, 127), Vector2(center_x + 10, 132), GREEN, 6.0, true)
else:
draw_line(Vector2(center_x - 28,127),Vector2(center_x + 10,132),GREEN,6.0,true)
@@ -133,11 +135,13 @@ class HelpCanvas extends Control:
_badge("UP", Vector2(28, 394), 49)
_label("System Power", Vector2(91, 394), 20)
_badge("VIEW",Vector2(296,394),72)
_label("Pause",Vector2(380,394),20)
_label("Hide / show",Vector2(380,394),17)
_badge("L / R", Vector2(28, 425), 72)
_label("Previous / next screen", Vector2(112, 425), 20)
_badge("DOWN", Vector2(28, 456), 80)
_label("Tactical screen toggle", Vector2(121, 456), 20)
_badge("PAUSE", Vector2(526, 369), 85)
_label("Pause / resume", Vector2(626, 369), 20)
_badge("A", Vector2(526, 394))
_label("Inspect rooms / wheel category", Vector2(568, 394), 20)
_badge("B", Vector2(526, 425))
+46 -8
View File
@@ -5,6 +5,8 @@ const WIDTH := 0.64
const HEIGHT := 0.10
const PIXELS := Vector2i(770,130)
const HULL_COLOR := Color("85ff79")
const FILL_RECT := Rect2(34, 0, 720, 130)
static var native_contour: Texture2D
var viewport: SubViewport
var canvas: Control
var plate: MeshInstance3D
@@ -64,14 +66,18 @@ func face_viewer(viewer: Vector3) -> void:
func _draw() -> void:
var fraction := clampf(float(hull) / maxf(hull_max, 1), 0.0, 1.0)
var frame := UI.ASSETS.texture("img/statusUI/top_hull_red.png" if fraction <= .33 else "img/statusUI/top_hull.png")
var mask := UI.ASSETS.texture("img/statusUI/top_hull_bar_mask.png")
var mask := _hull_contour()
var label := UI.ASSETS.texture("img/statusUI/top_hull_red_label.png" if fraction <= .33 else "img/statusUI/top_hull_label.png")
# Use the actual vanilla stepped contour and segmented mask. Only owner-local
# textures are read; no FTL imagery is embedded in the distributable mod.
# Keep the owner's native stepped contour, but one pip means one hit point.
# The player mask's fixed thirty separators are replaced by hull_max cells.
if frame != null and mask != null and label != null:
canvas.draw_texture_rect(mask,Rect2(34,0,720,130),false,Color("253b32"))
if fraction > 0:
canvas.draw_texture_rect_region(mask,Rect2(34,0,720 * fraction,130),Rect2(0,0,360 * fraction,65),HULL_COLOR if fraction > .33 else UI.RED)
var source_size := mask.get_size()
for i in range(hull_max):
var cell := hull_cell_rect(i)
var source := Rect2((cell.position - FILL_RECT.position) * source_size / FILL_RECT.size,
cell.size * source_size / FILL_RECT.size)
canvas.draw_texture_rect_region(mask, cell, source,
(HULL_COLOR if fraction > .33 else UI.RED) if i < hull else Color("253b32"))
canvas.draw_texture_rect(frame,Rect2(0,0,770,130),false)
canvas.draw_texture_rect(label,Rect2(18,0,104,56),false)
else:
@@ -79,7 +85,39 @@ func _draw() -> void:
var outline := PackedVector2Array([Vector2(16,80),Vector2(16,42),Vector2(255,42),Vector2(267,32),Vector2(496,32),Vector2(506,24),Vector2(755,24),Vector2(755,80),Vector2(16,80)])
canvas.draw_colored_polygon(outline,UI.DARK)
canvas.draw_polyline(outline,UI.EDGE,2)
for i in range(30):
canvas.draw_rect(Rect2(26 + i * 24,52,21,22),HULL_COLOR if float(i) / 30 < fraction else Color("253b32"))
for i in range(hull_max):
var cell := hull_cell_rect(i)
canvas.draw_rect(Rect2(cell.position.x,52,cell.size.x,22),
(HULL_COLOR if fraction > .33 else UI.RED) if i < hull else Color("253b32"))
UI.text(canvas,"HULL",Vector2(28,4),29,UI.DARK,"header")
UI.text(canvas,"%d / %d" % [hull,hull_max],Vector2(639,0),28,UI.INK)
func hull_cell_rect(index: int) -> Rect2:
var width := FILL_RECT.size.x / maxf(hull_max, 1)
var gap := minf(3.0, width * 0.18)
return Rect2(FILL_RECT.position + Vector2(index * width, 0), Vector2(width - gap, FILL_RECT.size.y))
static func _hull_contour() -> Texture2D:
if native_contour != null:
return native_contour
var mask := UI.ASSETS.texture("img/statusUI/top_hull_bar_mask.png")
if mask == null:
return null
var image := mask.get_image()
if image == null or image.is_empty():
return null
# Fill the mask's old gaps row by row, retaining its native alpha boundary.
# This is cached once and does not alter the shared owner-local texture.
for y in range(image.get_height()):
var left := image.get_width()
var right := -1
for x in range(image.get_width()):
if image.get_pixel(x, y).a > 0.5:
left = mini(left, x)
right = maxi(right, x)
for x in range(left, right + 1):
image.set_pixel(x, y, Color.WHITE)
native_contour = ImageTexture.create_from_image(image)
return native_contour
+1 -1
View File
@@ -2,7 +2,7 @@ extends RefCounted
# Follow the headset first. Only the panel's path toward the ship engages the
# world-height stop, so a distant or side-on table never drags the HUD around.
const CLEARANCE := 0.08
const CLEARANCE := 0.05
const HEAD_OFFSET := Vector3(0.0, -0.12, -1.30)
const POSITION_TIME := 0.12
const PANEL_THICKNESS := 0.008
+122 -20
View File
@@ -42,6 +42,8 @@ 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
@@ -254,6 +256,8 @@ func _create_hud() -> void:
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
@@ -378,7 +382,7 @@ func _create_navigation() -> void:
legend.pixel_size = 0.001
legend.no_depth_test = true
legend.render_priority = 123
legend.text = "Dpad ◀ / ▶: screen ▼: tactical ▲: power\nView: pause R bumper: 1–8 wheel"
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()
@@ -616,7 +620,7 @@ func _change_system_power(direction: int) -> void:
func _update_power_controls(remove: bool, previous: bool, next: bool) -> void:
if nav_page == "power" and _game_actions_available():
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:
@@ -744,10 +748,7 @@ func _process(delta: float) -> void:
if not trigger_down and trigger_was_down:
_release_pointer(ray.from, ray.direction)
trigger_was_down = trigger_down
var pause_down := left_hand.is_button_pressed("menu_button")
if pause_down and not pause_was_down:
_toggle_pause()
pause_was_down = pause_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:
@@ -811,11 +812,11 @@ func _process(delta: float) -> void:
func _face_navigation_to_headset(delta: float = 1.0 / 90.0) -> void:
if not left_hand.get_is_active():
nav_panel.visible = false
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
nav_panel.visible = true
_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:
@@ -824,6 +825,29 @@ func _face_navigation_to_headset(delta: float = 1.0 / 90.0) -> void:
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)
@@ -1008,7 +1032,7 @@ func _select_from_ray(from: Vector3, direction: Vector3) -> void:
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()
_command("target_room", {"room_id": int(room_hit.room_id), "ship": str(room_hit.ship), "phase": "down" if beam_drag else "click"})
_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
@@ -1066,7 +1090,7 @@ func _command(action: String, data: Dictionary) -> void:
func _toggle_pause() -> void:
if rename_keyboard.visible:
if rename_keyboard.visible or bool(hud_state.get("end_screen", false)):
return
if demo_mode:
paused = not paused
@@ -1079,7 +1103,7 @@ 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))
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)
@@ -1087,8 +1111,8 @@ func _update_hud() -> void:
# 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)))
enemy_ship.set_shields(int(state.get("enemy_shield", 2)), int(state.get("enemy_super_shield", 0)))
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)
@@ -1138,6 +1162,9 @@ func _poll_bridge_state() -> void:
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))
@@ -1231,6 +1258,30 @@ func _poll_bridge_state() -> void:
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:
@@ -1432,7 +1483,7 @@ func _navigation_entries() -> Array:
func _refresh_navigation() -> void:
if map_surface == null:
return
map_surface.visible = xr_active and nav_page != "help" and (hud_state.get("map_open", false) or hud_state.get("tactical", false))
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)
@@ -1457,8 +1508,9 @@ func _refresh_navigation() -> void:
for i in range(nav_buttons.size()):
var button := nav_buttons[i]
button.visible = i < entries.size()
button.collision_layer = 1 if button.visible else 0
button.get_child(0).set_deferred("disabled", not button.visible)
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]
@@ -1483,6 +1535,11 @@ func _hover_ui(from: Vector3, direction: Vector3) -> void:
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"]
@@ -1509,9 +1566,9 @@ func _release_pointer(from: Vector3, direction: Vector3) -> void:
_command("ui_mouse", {"x": held_pixel.x, "y": held_pixel.y, "phase": "up"})
held_surface = null
if beam_drag:
var hit := _room_hit(from, direction, "enemy", 0.012)
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():
_command("target_room", {"room_id": int(hit.room_id), "ship": "enemy", "phase": "up"})
_send_target_hit(hit, "up")
else:
_command("cancel", {})
beam_drag = false
@@ -1549,13 +1606,58 @@ func _active_target() -> Dictionary:
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 := _room_hit(from, direction, str(side), 0.012)
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
+131 -6
View File
@@ -26,6 +26,7 @@ var room_nodes: Dictionary = {}
var room_bounds: Dictionary = {}
var room_visibility: Dictionary = {}
var weapon_nodes: Array[Node3D] = []
var artillery_nodes: Dictionary = {}
var door_nodes: Dictionary = {}
var drone_nodes: Dictionary = {}
var drop_target_room := -1
@@ -36,10 +37,16 @@ var live_data: Dictionary = {}
var inspect_rooms := false
var demo_crew_marker: Area3D
var native_shield_geometry: Dictionary = {}
var shield_state_cache := Vector2i(-1, -1)
var shield_state_cache := Vector3i(-1, -1, -1)
var room_hazards: Node3D
var viewer_position := Vector3.ZERO
var viewer_position_valid := false
var cloaked := false
var cloak_strength := 0.0
var cloak_target := 0.0
var cloak_native_progress := false
var cloak_initialized := false
var cloak_materials: Dictionary = {}
func build(name: String, is_enemy: bool) -> void:
@@ -53,6 +60,7 @@ func build(name: String, is_enemy: bool) -> void:
room_visibility.clear()
crew_nodes.clear()
weapon_nodes.clear()
artillery_nodes.clear()
door_nodes.clear()
drone_nodes.clear()
drop_target_room = -1
@@ -61,7 +69,13 @@ func build(name: String, is_enemy: bool) -> void:
shield_fit_padding = 1.0
demo_crew_marker = null
native_shield_geometry.clear()
shield_state_cache = Vector2i(-1, -1)
shield_state_cache = Vector3i(-1, -1, -1)
cloak_materials.clear()
cloaked = false
cloak_strength = 0.0
cloak_target = 0.0
cloak_native_progress = false
cloak_initialized = false
ship_name = name
enemy = is_enemy
var path := _data_path("%s.json" % name)
@@ -111,6 +125,7 @@ func _make_hull(layout: Dictionary, center: Vector2) -> void:
var texture_path := _data_path("%s.png" % ship_name)
if rect.is_empty() or not FileAccess.file_exists(texture_path):
var block := MeshInstance3D.new()
block.name = "Hull"
block.mesh = BoxMesh.new()
block.scale = Vector3(1.8, 0.07, 1.15)
block.material_override = _material(Color(0.28, 0.32, 0.43) if enemy else Color(0.28, 0.36, 0.52))
@@ -123,6 +138,7 @@ func _make_hull(layout: Dictionary, center: Vector2) -> void:
var quad := QuadMesh.new()
quad.size = Vector2(float(rect["w"]) / 35.0 * TILE, float(rect["h"]) / 35.0 * TILE)
var mesh := MeshInstance3D.new()
mesh.name = "HullDeck"
mesh.mesh = quad
mesh.position = Vector3((float(rect["x"]) / 35.0 + float(rect["w"]) / 70.0 - center.x) * TILE,
0.005, (float(rect["y"]) / 35.0 + float(rect["h"]) / 70.0 - center.y) * TILE)
@@ -514,6 +530,7 @@ func _make_weapon_mounts(layout: Dictionary, center: Vector2) -> void:
var gun := WeaponModel.new()
gun.position = point
gun.rotation.y = 0.0 if str(mount.get("rotate", "false")) == "true" else PI / 2.0
gun.scale.z = -1.0 if str(mount.get("mirror", "false")) == "true" else 1.0
gun.build({"kind": "laser", "powered": false})
add_child(gun)
weapon_nodes.append(gun)
@@ -521,6 +538,51 @@ func _make_weapon_mounts(layout: Dictionary, center: Vector2) -> void:
mount_points.append(Vector3(0.5, 0.14, 0.0))
func _mount_point(mount: Dictionary) -> Vector3:
# WeaponMount.position is relative to shipImage, whereas crew/rooms are
# already in graph pixels. Apply the image rectangle exactly once.
var rect: Dictionary = layout_data.get("image_rect", {})
return Vector3((float(rect.get("x", 0)) + float(mount.get("x", 0))) / 35.0 * TILE - layout_center.x * TILE,
0.14, (float(rect.get("y", 0)) + float(mount.get("y", 0))) / 35.0 * TILE - layout_center.y * TILE)
func _apply_weapon_pose(weapon: Node3D, data: Dictionary) -> void:
var mount: Dictionary = data.get("mount", {})
if not mount.is_empty():
weapon.position = _mount_point(mount)
if data.has("mount_rotate"):
weapon.rotation.y = 0.0 if bool(data.mount_rotate) else PI / 2.0
if data.has("mount_mirror"):
# The model fires along +X; mirroring native art flips its cross-axis.
weapon.scale.z = -1.0 if bool(data.mount_mirror) else 1.0
func _apply_artillery(rows: Array) -> void:
var present: Dictionary = {}
for row in rows:
if not row is Dictionary:
continue
var slot := int(row.get("slot", 0))
present[slot] = true
if not artillery_nodes.has(slot):
var gun := WeaponModel.new()
gun.name = "Artillery%d" % slot
gun.build(row)
add_child(gun)
artillery_nodes[slot] = gun
var weapon: Node3D = artillery_nodes[slot]
if weapon.kind != WeaponModel.canonical_kind(str(row.get("kind", "laser"))) or weapon.weapon_name != str(row.get("name", "")):
weapon.build(row)
_apply_weapon_pose(weapon, row)
weapon.set_live(row)
for slot in artillery_nodes.keys():
if not present.has(slot):
var weapon: Node3D = artillery_nodes[slot]
remove_child(weapon)
weapon.queue_free()
artillery_nodes.erase(slot)
func _make_shield(layout: Dictionary) -> void:
var ellipse: Array = layout.get("ellipse", [350, 220, 0, 0])
shield_radii = Vector3(float(ellipse[0]) / 35.0 * TILE, 0.48, float(ellipse[1]) / 35.0 * TILE)
@@ -549,15 +611,15 @@ func _make_shield(layout: Dictionary) -> void:
set_shields(2)
func set_shields(charge: int, super_shield: int = 0) -> void:
func set_shields(charge: int, super_shield: int = 0, shutdown: bool = false) -> void:
shield_charge = maxi(charge, 0)
if shield_shell == null:
return
var current := Vector2i(shield_charge, super_shield)
var current := Vector3i(shield_charge, super_shield, int(shutdown))
if current == shield_state_cache:
return
shield_state_cache = current
shield_shell.visible = shield_charge > 0 or super_shield > 0
shield_shell.visible = not shutdown and (shield_charge > 0 or super_shield > 0)
var material: ShaderMaterial = shield_shell.material_override
material.set_shader_parameter("shield_color", Color(0.23, 0.95, 0.35) if super_shield > 0 else Color(0.18, 0.58, 1.0))
material.set_shader_parameter("strength", clampf(shield_charge / 4.0, 0.25, 1.0))
@@ -622,6 +684,13 @@ func set_viewer_position(world_position: Vector3) -> void:
func _process(delta: float) -> void:
if not cloak_native_progress and not is_equal_approx(cloak_strength, cloak_target):
# Older snapshots provide only the actual native cloak toggle. Ease that
# presentation transition; first observations are applied immediately.
cloak_strength = move_toward(cloak_strength, cloak_target, delta * 4.0)
_restore_cloak_materials()
if cloak_strength > 0.001:
_apply_cloak_materials(self)
if not simulation_paused:
shield_flash = maxf(0.0, shield_flash - delta * 3.0)
if shield_shell != null:
@@ -645,6 +714,8 @@ func _process(delta: float) -> void:
if not simulation_paused:
for weapon in weapon_nodes:
weapon.animate(delta)
for weapon in artillery_nodes.values():
weapon.animate(delta)
for drone in drone_nodes.values():
drone.animate(delta)
if is_instance_valid(room_hazards):
@@ -667,6 +738,9 @@ func pixel_point(point: Dictionary, height: float = 0.18) -> Vector3:
func apply_live(data: Dictionary) -> void:
# Native room/weapon condition updates operate on their original materials.
# Cloaking then applies isolated copies, including newly spawned actors.
_restore_cloak_materials()
live_data = data
var system_rows: Dictionary = {}
for row in data.get("system_status", []):
@@ -749,13 +823,58 @@ func apply_live(data: Dictionary) -> void:
var weapon: Node3D = weapon_nodes[slot]
if weapon.kind != WeaponModel.canonical_kind(str(weapons[slot].get("kind", "laser"))) or weapon.weapon_name != str(weapons[slot].get("name", "")):
weapon.build(weapons[slot])
_apply_weapon_pose(weapon, weapons[slot])
mount_points[slot] = weapon.position
weapon.set_live(weapons[slot])
_apply_artillery(data.get("artillery", []))
_apply_doors(data.get("doors", []))
_apply_drones(data.get("drones", []))
if is_instance_valid(room_hazards):
room_hazards.set_live(self, data)
_apply_shield_shape(data.get("shield_shape", {}))
set_shields(int(data.get("shield", 0)), int(data.get("super_shield", 0)))
set_shields(int(data.get("shield", 0)), int(data.get("super_shield", 0)), bool(data.get("shield_shutdown", false)))
cloaked = bool(data.get("cloaked", false))
cloak_target = 1.0 if cloaked else 0.0
cloak_native_progress = data.has("cloak_progress")
if cloak_native_progress:
cloak_strength = clampf(float(data.cloak_progress), 0.0, 1.0)
elif not cloak_initialized:
cloak_strength = cloak_target
cloak_initialized = true
if cloak_strength > 0.001:
_apply_cloak_materials(self)
func _restore_cloak_materials() -> void:
for id in cloak_materials.keys():
var entry: Dictionary = cloak_materials[id]
var node: GeometryInstance3D = entry.node.get_ref()
if not is_instance_valid(node) or not is_ancestor_of(node):
cloak_materials.erase(id)
elif node.material_override == entry.faded:
node.material_override = entry.source
func _apply_cloak_materials(node: Node) -> void:
# Material alpha works in Vulkan mobile and stereo. GeometryInstance3D's
# transparency property is limited to Forward+, so it cannot drive this.
if node is GeometryInstance3D and node.material_override is StandardMaterial3D and node.name != "SystemIcon":
var source: StandardMaterial3D = node.material_override
var id := node.get_instance_id()
if not cloak_materials.has(id) or cloak_materials[id].source != source:
cloak_materials[id] = {"node": weakref(node), "source": source, "faded": source.duplicate()}
var material: StandardMaterial3D = cloak_materials[id].faded
var hull_part := node.name in ["Hull", "HullDeck", "ExtrudedHull"]
var alpha := lerpf(1.0, 0.375 if hull_part else 0.5, cloak_strength)
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.albedo_color = source.albedo_color
material.albedo_color.a *= alpha
material.emission_enabled = source.emission_enabled
material.emission = source.emission
material.emission_energy_multiplier = source.emission_energy_multiplier * alpha
node.material_override = material
for child in node.get_children():
_apply_cloak_materials(child)
func _room_visible(id: int) -> bool:
@@ -805,6 +924,12 @@ func weapon_origin(slot: int) -> Vector3:
return to_global(mount_points[posmod(slot, mount_points.size())])
func artillery_origin(slot: int) -> Vector3:
if artillery_nodes.has(slot):
return artillery_nodes[slot].global_position
return global_position
func room_target(room_id: int) -> Vector3:
return to_global(room_points.get(room_id, Vector3(0.0, 0.18, 0.0)))