mirror of
https://github.com/ElHombreAlex/FTL-Faster-Than-Light-VR.git
synced 2026-10-06 03:00:06 +02:00
Fix Flagship visuals, beam aiming, results and Frame controls
This commit is contained in:
1 parent
e857f4eee9
commit
cf9cfca866
27 files changed
+1084
-96
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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)))
|
||||
|
||||
|
||||
Reference in new issue
Block a user