Add native room locks, jump stars and refined drones

This commit is contained in:
ElHombreAlex committed 2026-10-03 21:31:59 +02:00
1 parent 46102cabf7
commit e857f4eee9
23 files changed
+1349 -108

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
extends SceneTree
const DroneModel = preload("res://scripts/voxel_drone.gd")
const ShipModel = preload("res://scripts/ship_model.gd")
const CombatEffects = preload("res://scripts/combat_effects.gd")
const UiAssets = preload("res://scripts/ftl_ui_assets.gd")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1600, 1000)
var args := OS.get_cmdline_user_args()
var firing_capture := args.has("--shots")
var output := "res://local_game_data/drone_shots_preview.png" if firing_capture else "res://local_game_data/drone_gallery.png"
for argument in args:
if argument.begins_with("--output="):
output = argument.trim_prefix("--output=")
var world := Node3D.new()
root.add_child(world)
var environment := WorldEnvironment.new()
var settings := Environment.new()
settings.background_mode = Environment.BG_COLOR
settings.background_color = Color("101b27")
settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
settings.ambient_light_color = Color("d4e7f2")
settings.ambient_light_energy = 0.58
environment.environment = settings
world.add_child(environment)
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-42, -25, 0)
light.light_energy = 1.6
world.add_child(light)
var camera := Camera3D.new()
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
world.add_child(camera)
if firing_capture:
_shots(world, camera)
else:
_gallery(world, camera)
for i in range(12):
await process_frame
await RenderingServer.frame_post_draw
var path := ProjectSettings.globalize_path(output)
var result := root.get_texture().get_image().save_png(path)
print("DRONE_CAPTURE ", "shots" if firing_capture else "gallery", " result=", result, " path=", path)
quit(result)
func _gallery(world: Node3D, camera: Camera3D) -> void:
camera.size = 3.4
camera.position = Vector3(0.4, 4.3, 5.0)
camera.look_at(Vector3(0, 0.15, 0))
var entries := [
["combat", "COMBAT_1", "COMBAT I"], ["combat", "COMBAT_2", "COMBAT II"],
["beam", "BEAM_1", "BEAM I"], ["beam", "BEAM_2", "BEAM II"],
["defense", "DEFENSE_1", "DEFENSE I"], ["defense", "DEFENSE_2", "DEFENSE II"],
["anti_drone", "ANTI_DRONE", "ANTI DRONE"], ["hacking", "HACKING", "HACKING"],
["shield", "SHIELD", "SHIELD"], ["boarding", "BOARDER", "BOARDING"],
["repair", "REPAIR", "REPAIR"], ["battle", "BATTLE", "BATTLE"],
["ion_boarder", "ION_BOARDER", "ION BOARDER"]]
for i in range(entries.size()):
var entry: Array = entries[i]
var drone := DroneModel.new()
world.add_child(drone)
var exterior: bool = entry[0] not in ["repair", "battle", "ion_boarder"]
drone.build({"kind": entry[0], "species": entry[0], "name": entry[1],
"is_space": exterior, "deployed": true, "powered": true})
drone.position = Vector3((i % 5 - 2) * 0.87, 0.22, (i / 5 - 1) * 1.25)
drone.scale = Vector3.ONE * 2.9
drone.rotation.y = -0.65 if exterior else PI + 0.15
_label(world, entry[2], drone.position + Vector3(0, -0.19, 0.30), 33)
_label(world, "ORIGINAL VOXEL DRONES", Vector3(0, 0.35, -2.15), 43)
func _shots(world: Node3D, camera: Camera3D) -> void:
# Explicit native-schema fixtures make the muzzle/trajectory capture
# repeatable. They do not modify game state or claim native collision hits.
var ship := ShipModel.new()
world.add_child(ship)
ship.build("kestral", false)
ship.set_process(false)
ship.apply_live({"rooms": [], "crew": [], "drones": [
{"id": "capture-combat", "name": "COMBAT_2", "kind": "combat", "is_space": true,
"deployed": true, "powered": true, "x": 470, "y": 105, "angle": 180},
{"id": "capture-beam", "name": "BEAM_2", "kind": "beam", "is_space": true,
"deployed": true, "powered": true, "x": 470, "y": 240, "angle": 180}]})
ship.set_shields(0)
var effects := CombatEffects.new()
world.add_child(effects)
effects.set_process(false)
effects.spawn_shot({"kind": "laser", "drone_id": "capture-combat", "projectile_id": "capture-laser",
"origin_space": 0, "target_space": 0, "origin_point": {"x": 470, "y": 105},
"target": {"x": 235, "y": 105}, "outcome": "pending"}, ship, ship)
effects.spawn_shot({"kind": "beam", "drone_id": "capture-beam", "projectile_id": "capture-beam",
"origin_space": 0, "target_space": 0, "origin_point": {"x": 470, "y": 240},
"target": {"x": 235, "y": 240}, "end_point": {"x": 235, "y": 265}, "outcome": "pending"}, ship, ship)
effects.update_live_projectiles([{"id": "capture-laser", "progress": 0.22},
{"id": "capture-beam", "progress": 0.5, "beam_point": {"x": 235, "y": 252}}])
effects._process(0.1)
var center: Vector3 = ship.pixel_point({"x": 365, "y": 175}, 0.22)
camera.size = 1.6
camera.position = center + Vector3(0.05, 1.9, 1.7)
camera.look_at(center)
_label(world, "COMBAT II / NATIVE LASER", ship.pixel_point({"x": 430, "y": 60}, 0.38), 24)
_label(world, "BEAM II / NATIVE SWEEP", ship.pixel_point({"x": 430, "y": 300}, 0.8), 24, Color("1b3447"))
print("DRONE_SHOT_GEOMETRY muzzle_laser=", effects.shots[0]["start"],
" muzzle_beam=", effects.shots[1]["start"], " impacts=", effects.impacts.size())
func _label(world: Node3D, text: String, location: Vector3, size: int, color: Color = Color("d7e5e7")) -> void:
var label := Label3D.new()
label.text = text
label.font = UiAssets.font("body")
label.font_size = size
label.pixel_size = 0.0023
label.modulate = color
label.outline_modulate = Color("101b27")
label.outline_size = 2
label.no_depth_test = true
label.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.position = location
world.add_child(label)
+56
View File
@@ -0,0 +1,56 @@
extends SceneTree
func _initialize() -> void:
call_deferred("_capture")
func _capture() -> void:
var args := OS.get_cmdline_user_args()
var output_dir := args[0] if not args.is_empty() else ProjectSettings.globalize_path("res://local_game_data")
DirAccess.make_dir_recursive_absolute(output_dir)
var stage := Node3D.new()
root.add_child(stage)
var world := WorldEnvironment.new()
var environment := Environment.new()
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color(0.002, 0.003, 0.008)
world.environment = environment
stage.add_child(world)
var camera := Camera3D.new()
stage.add_child(camera)
camera.position = Vector3(0.0, 1.8, 2.0)
camera.look_at(Vector3(0.0, 1.8, -1.0))
camera.current = true
var space: Node3D = load("res://scripts/space_environment.gd").new()
stage.add_child(space)
space.build()
space.set_process(false)
space.set_travel_direction(Vector3.RIGHT)
var label := Label.new()
label.position = Vector2(28.0, 24.0)
label.add_theme_font_size_override("font_size", 22)
root.add_child(label)
space.apply_state({"hazard": "clear", "jumping": false})
label.text = "NORMAL: STARS SURROUND THE SHIP"
await _save_frame(output_dir.path_join("jump_normal.png"))
space.apply_state({"hazard": "sun", "jumping": true, "paused": true, "frozen": true})
space._process(0.4)
label.text = "NATIVE JUMP: HORIZONTAL SHIP TRAVEL >"
await _save_frame(output_dir.path_join("jump_stretched.png"))
space.set_travel_direction(Vector3(1.0, 0.0, -1.0))
label.text = "NATIVE JUMP: SHIP BOW TURNED 45 DEGREES"
await _save_frame(output_dir.path_join("jump_heading.png"))
space.apply_state({"hazard": "clear", "jumping": true, "event_open": true, "paused": true})
label.text = "ARRIVAL DIALOG: STARS RESTORED"
await _save_frame(output_dir.path_join("jump_arrived.png"))
print("JUMP_CAPTURE PASS ", output_dir)
quit()
func _save_frame(path: String) -> void:
await process_frame
await RenderingServer.frame_post_draw
var error := root.get_texture().get_image().save_png(path)
if error != OK:
push_error("Could not save jump capture: %s" % path)
+3 -1
View File
@@ -199,7 +199,9 @@ def main() -> None:
name.startswith(("img/statusUI/", "img/systemUI/", "img/icons/", "img/combatUI/"))
or name in {"img/box_subsystems4.png", "img/box_weapons_bottom4.png",
"img/box_weapons_bottom_label.png", "img/box_weapons_autofire_base.png",
"img/Text_pause1.png", "img/Text_pause2.png"})]
"img/Text_pause1.png", "img/Text_pause2.png"}
or name in {f"img/misc/crosshairs_placed{slot}{variant}.png"
for slot in range(1,5) for variant in ("", "_yellow")})]
for name in ui_paths:
destination = args.output / "ui" / name
destination.parent.mkdir(parents=True, exist_ok=True)
+36 -1
View File
@@ -1,4 +1,4 @@
"""Remove only edge-connected cleared black pixels, retaining black UI ink."""
"""Keep native UI ink while making the cleared world transparent."""
import numpy as np
@@ -40,3 +40,38 @@ def clear_world_alpha(pixels):
if edge[root(i)]:
result[y, x0:x1, 3] = 0
return result
def clear_supplemental_hud_alpha(pixels):
"""Correct native warning fringes in the independently cleared HUD pass.
FTL paints faint warning-text shadows with nonzero RGB and fractional
alpha. The legacy screen conversion makes those almost-black pixels
opaque. Use their actual coverage in the warning band only; framed HUD
controls still need the contour fill to retain their original black ink.
Full screens and native windows do not use this supplemental conversion.
"""
result = clear_world_alpha(pixels)
# At the native 1280x720 HUD coordinates, warnings sit between the top
# controls and the weapon bar, to the right of crew and left of target UI.
# They shift horizontally when an enemy arrives, so cover both positions.
height, width = pixels.shape[:2]
left, right = round(400 * width / 1280), round(872 * width / 1280)
top, bottom = round(160 * height / 720), round(360 * height / 720)
source = pixels[top:bottom,left:right]
warning = result[top:bottom,left:right]
if not source.size:
return result
# RGB already includes FTL's warning blink/fade. Do not multiply faded red
# glyphs by their raw FBO alpha a second time: only recover coverage for
# the almost-black texture fringe, relative to the current glyph peak.
colors = source[:, :, :3].astype(np.int16)
reddish = (colors[:, :, 0] > colors[:, :, 1] * 2) & (colors[:, :, 0] > colors[:, :, 2] * 2)
glyphs = reddish & (colors[:, :, 0] > 8)
if not np.any(glyphs):
return result
brightness = np.max(colors, axis=2)
fringe_limit = max(1.0, float(colors[:, :, 0][glyphs].max()) * 0.35)
faint_coverage = (source[:, :, 3] <= 8) & (brightness <= fringe_limit) & reddish
warning[:, :, 3][faint_coverage] = source[:, :, 3][faint_coverage]
return result
+4 -2
View File
@@ -15,7 +15,7 @@ from pathlib import Path
import frida
from PIL import Image
from extract_ftl import Pkg, build_ship
from hud_alpha import clear_world_alpha
from hud_alpha import clear_world_alpha, clear_supplemental_hud_alpha
def atomic_json(path, value):
@@ -102,6 +102,8 @@ def decode_state_line(line):
for room in result[side]['rooms']:
for field in ('fire_tiles','breach_tiles'):
if not isinstance(room.get(field),list): room[field]=[]
for weapon in result[side]['weapons']:
if isinstance(weapon,dict) and 'targets' in weapon and not isinstance(weapon['targets'],list): weapon['targets']=[]
if isinstance(result.get('dialog'),dict) and not isinstance(result['dialog'].get('choices'),list):
result['dialog']['choices']=[]
# vCrewList ownership/current-ship membership can differ during boarding.
@@ -445,7 +447,7 @@ def main():
panel_pixels=None
if payload['type']=='hud_frame':
if full_screen_capture(snapshot): continue
last_hud_image=Image.fromarray(remove_enemy_hud(clear_world_alpha(pixels)))
last_hud_image=Image.fromarray(remove_enemy_hud(clear_supplemental_hud_alpha(pixels)))
publisher.publish('hud_frame',last_hud_image,captured_at)
with state_lock: current['capture_full_screen']=False
continue
+48 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
from PIL import Image
import numpy as np
from hud_alpha import clear_world_alpha
from hud_alpha import clear_world_alpha, clear_supplemental_hud_alpha
from run_bridge import (decode_state_line, translate_command, full_screen_capture,
remove_enemy_hud, native_window_frame, remove_pause_overlay,
FRAME_HEADER, raw_frame_bytes, FramePublisher)
@@ -213,12 +213,59 @@ class BridgeTests(unittest.TestCase):
self.assertEqual(result[9,12,3],255)
self.assertTrue(np.array_equal(result[:,:,:3],pixels[:,:,:3]))
def test_supplemental_warning_keeps_text_and_native_faint_shadow_coverage(self):
pixels=np.zeros((720,1280,4),dtype=np.uint8)
# Native warning glyphs with a barely visible red-black texture fringe.
pixels[210:270,675:785]=[1,0,0,2]
pixels[215:223,690:770]=[86,18,10,43]
pixels[235:243,683:777]=[86,18,10,43]
pixels[255:263,683:777]=[86,18,10,43]
result=clear_supplemental_hud_alpha(pixels)
self.assertEqual(result[212,680,3],2,'Warning shadow must retain its faint native alpha')
self.assertEqual(result[218,700,3],255,'Native warning text must retain its existing visibility')
self.assertTrue(np.array_equal(result[:,:,:3],pixels[:,:,:3]))
def test_supplemental_warning_fade_and_scaled_native_capture(self):
pixels=np.zeros((720,1280,4),dtype=np.uint8)
pixels[210:270,675:785]=[1,0,0,1]
pixels[216:222,690:770]=[20,4,2,1]
for sample in (pixels,pixels[::2,::2]):
result=clear_supplemental_hud_alpha(sample)
scale=pixels.shape[0]//sample.shape[0]
self.assertEqual(result[212//scale,680//scale,3],1)
self.assertEqual(result[218//scale,700//scale,3],255,'Faded warning glyph RGB must not be multiplied by raw alpha twice')
self.assertTrue(np.array_equal(result[:,:,:3],sample[:,:,:3]))
def test_supplemental_warning_conversion_preserves_controls_and_normal_frames(self):
pixels=np.zeros((720,1280,4),dtype=np.uint8)
pixels[15:65,15:200,:3]=[125,180,190]
pixels[25:55,25:190,:3]=0
pixels[620:690,295:700,:3]=[125,180,190]
pixels[632:678,307:688,:3]=0
pixels[212:222,345:355]=[1,0,0,2]
pixels[190:200,610:760]=[1,0,0,2]
pixels[195:198,640:690]=[150,180,190,255]
expected=clear_world_alpha(pixels)
result=clear_supplemental_hud_alpha(pixels)
self.assertTrue(np.array_equal(result,expected),'A normal HUD must keep the legacy control alpha')
self.assertEqual(result[40,70,3],255,'Black ink inside top controls must remain opaque')
self.assertEqual(result[650,640,3],255,'Black weapon panel ink must remain opaque')
def test_empty_lua_vectors_become_arrays(self):
state=decode_state_line('FTLVR_STATE '+json.dumps({'protocol':2,'source':'hyperspace',
'shots':{},'projectiles':{},'player':{'rooms':{},'crew':{},'weapons':{}}}))
self.assertEqual(state['player']['crew'],[])
self.assertEqual(state['shots'],[])
def test_native_target_points_survive_empty_lua_vectors(self):
targets=[{'x':102,'y':134},{'x':177,'y':134}]
raw={'protocol':2,'source':'hyperspace','player':{'weapons':[
{'slot':0,'targets':{},'target_ship':1},
{'slot':1,'targets':targets,'target_ship':1}]}}
state=decode_state_line('FTLVR_STATE '+json.dumps(raw))
self.assertEqual(state['player']['weapons'][0]['targets'],[])
self.assertEqual(state['player']['weapons'][1]['targets'],targets)
def test_native_room_target_includes_enemy_screen_offset(self):
state={'ready':True,'ui_mode':'game','combat':True,'weapon_selected':1,
'enemy_origin':{'x':953,'y':105},
+62
View File
@@ -192,6 +192,68 @@ func _run() -> void:
effects.update_live_projectiles([{"id": "paused-beam", "progress": 0.4}])
effects.resolve_live({"projectile_id": "paused-beam", "outcome": "beam", "target": beam_point}, enemy)
_check(effects.shots.size() == 1, "A paused beam tile hit must retain the native continuing sweep")
effects.update_live_projectiles([])
effects.simulation_paused = false
effects._process(0.3)
# A player-owned drone is physically modeled in the receiver's native space.
# Attach a fire record to that exact muzzle before testing orbital motion and
# independent pause versus encounter transforms.
enemy._apply_drones([{"id": "88001", "name": "COMBAT_2", "kind": "combat", "is_space": true,
"powered": true, "deployed": true, "x": 90, "y": 130, "angle": 35.0}])
var actual_drone: Node3D = enemy.drone_nodes["88001"]
var native_drone_event := {"kind": "laser", "outcome": "pending", "projectile_id": "drone-native",
"drone_id": "88001", "origin_space": 1, "origin_point": drone_origin,
"target_space": 1, "target": drone_target}
var muzzle: Vector3 = actual_drone.muzzle_position("drone-native")
effects.spawn_shot(native_drone_event, player, enemy)
effects.update_live_projectiles([{"id": "drone-native", "progress": 0.25}])
effects._process(0.1)
var drone_shot: Dictionary = effects.shots.back()
_check(Vector3(drone_shot["start"]).is_equal_approx(muzzle), "Native drone fire must originate at the visible muzzle in its current render space")
_check(drone_shot["node"].mesh.get_aabb().size.z < 0.1, "Drone bullets must remain sized for their small native emitters")
actual_drone.position += Vector3(0.2, 0, 0.1)
effects._process(0.1)
_check(Vector3(drone_shot["start"]).is_equal_approx(muzzle), "Later orbital drone movement must not move a bullet's original launch point")
var launch_local: Vector3 = enemy.to_local(muzzle)
var paused_progress: float = drone_shot["live_progress"]
effects.simulation_paused = true
enemy.position += Vector3(0.3, 0.1, -0.2)
enemy.rotation += Vector3(0.15, 0.2, 0.08)
enemy.scale = Vector3.ONE * 0.7
effects._process(0.5)
_check(Vector3(drone_shot["start"]).is_equal_approx(enemy.to_global(launch_local)), "Paused drone shots must follow encounter position, tilt and scale without advancing their native flight")
_check(is_equal_approx(drone_shot["live_progress"], paused_progress), "Repositioning a paused encounter must preserve native projectile progress")
_check(is_equal_approx(drone_shot["node"].global_basis.x.length(), enemy.global_basis.x.length()), "An orbiting drone's projectile size must follow its physical render space rather than its owner ship")
effects.update_live_projectiles([])
effects.simulation_paused = false
var beam_event := native_drone_event.duplicate()
beam_event.merge({"kind": "beam", "projectile_id": "drone-beam", "end_point": {"x": 200, "y": 130}}, true)
effects.spawn_shot(beam_event, player, enemy)
effects.update_live_projectiles([{"id": "drone-beam", "progress": 0.5, "beam_point": drone_target}])
actual_drone.position += Vector3(0.1, 0, -0.05)
effects._process(0.1)
var drone_beam: Dictionary = effects.shots.back()
_check(Vector3(drone_beam["start"]).is_equal_approx(actual_drone.muzzle_position("drone-beam")), "A continuing native drone beam must remain attached to its actual visible emitter")
_check(drone_beam["node"].get_node_or_null("BeamCore") != null and drone_beam["node"].mesh is ImmediateMesh, "A native beam drone must render a sweep, never an invented laser bullet")
var drone_beam_core: MeshInstance3D = drone_beam["node"].get_node("BeamCore")
_check(drone_beam_core.global_position.is_equal_approx((drone_beam["start"] + enemy.to_global(enemy.pixel_point(drone_target))) * 0.5), "The native beam's exact live endpoint and muzzle must bound its visible geometry")
effects.update_live_projectiles([{"id": "drone-beam", "progress": 1.0, "beam_point": drone_target}])
effects._process(0.1)
_check(effects.shots.size() == 1, "Native progress at one must not delete a still-present beam or invent its outcome")
effects.update_live_projectiles([])
var fallback_event := native_drone_event.duplicate()
fallback_event.merge({"drone_id": "-1", "projectile_id": "negative-native"}, true)
enemy.drone_nodes["-1"] = actual_drone
effects.spawn_shot(fallback_event, player, enemy)
_check(Vector3(effects.shots.back()["start"]).is_equal_approx(enemy.to_global(enemy.pixel_point(drone_origin, 0.3))), "An ambiguous negative native drone id must use its real native point at drone height")
enemy.drone_nodes.erase("-1")
effects.update_live_projectiles([{"id": "negative-native", "progress": 0.3}])
effects.update_live_projectiles([])
var first_bullet: MeshInstance3D = effects._projectile_visual("laser", Color.ORANGE, true)
var second_bullet: MeshInstance3D = effects._projectile_visual("laser", Color.ORANGE, true)
_check(first_bullet.mesh == second_bullet.mesh and first_bullet.get_child(0).mesh == second_bullet.get_child(0).mesh, "Repeated drone shots must reuse both core and trail geometry")
first_bullet.free()
second_bullet.free()
print("COMBAT_TESTS ", "PASS" if failures == 0 else "FAIL: %d" % failures)
scene.queue_free()
await process_frame
+66
View File
@@ -0,0 +1,66 @@
extends SceneTree
const DroneModel = preload("res://scripts/voxel_drone.gd")
var failures := 0
func _initialize() -> void:
call_deferred("_run")
func _check(condition: bool, message: String) -> void:
if not condition:
failures += 1
push_error(message)
func _run() -> void:
for family in ["combat", "beam", "defense"]:
var first := DroneModel.new()
var second := DroneModel.new()
root.add_child(first)
root.add_child(second)
first.build({"name": family.to_upper() + "_1", "kind": family, "deployed": true})
second.build({"name": family.to_upper() + "_2", "kind": family, "deployed": true})
_check(first.body.mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX] != second.body.mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX], "Every native %s Mk II must have distinct armor or emitter geometry" % family)
first.queue_free()
second.queue_free()
var idle := DroneModel.new()
var active := DroneModel.new()
root.add_child(idle)
root.add_child(active)
var data := {"name": "COMBAT_2", "kind": "combat", "deployed": true, "powered": true}
idle.build(data)
active.build(data)
_check(idle.thruster.mesh == active.thruster.mesh and idle.emitter.mesh == active.emitter.mesh,
"Matching drone glow surfaces must reuse geometry as well as their armored body")
var idle_material := idle.emitter.material_override
active.present_fire()
_check(idle.emitter.material_override == idle_material and is_equal_approx(idle_material.emission_energy_multiplier, 0.25),
"A native fire flash must not brighten another drone through a shared mutable material")
_check(is_equal_approx(active.emitter.material_override.emission_energy_multiplier, 1.5), "An actual native fire record must visibly brighten its own emitter")
active.animate(0.04)
_check(active.body.position.x < 0.0, "An actual native shot must produce a short recoil pose")
active.animate(0.25)
_check(is_zero_approx(active.body.position.x) and active.emitter.material_override == idle_material,
"A completed native fire flash must return to the shared idle pose and glow")
var changes := {"count": 0}
var track_change := func(): changes["count"] += 1
for part in [active.body, active.thruster, active.emitter]:
part.mesh.changed.connect(track_change)
for i in range(90):
active.animate(1.0 / 90.0)
_check(changes["count"] == 0, "Drone animation must update transforms without rebuilding shared mesh geometry")
for part in [active.body, active.thruster, active.emitter]:
part.mesh.changed.disconnect(track_change)
active.rotation = Vector3(0.2, -1.0, 0.1)
active.scale = Vector3.ONE * 0.6
var local_muzzle: Vector3 = active.to_local(active.muzzle_position("42"))
_check(is_equal_approx(local_muzzle.x, 0.135) and is_equal_approx(local_muzzle.y, 0.003) and is_equal_approx(absf(local_muzzle.z), 0.018),
"The native firing muzzle must retain the model's local barrel point through tilt and scale")
idle.queue_free()
active.queue_free()
print("DRONE_TESTS ", "PASS" if failures == 0 else "FAIL: %d" % failures,
" mk2_families=3 shared_geometry=body+exhaust+emitter independent_glow=true immutable_meshes=true")
await process_frame
quit(0 if failures == 0 else 1)
+49
View File
@@ -30,6 +30,13 @@ func _run() -> void:
if child is MultiMeshInstance3D:
meshes += 1
_check(meshes == 3, "Starfield must batch its geometry into three draw calls")
var star_nodes: Array[MultiMeshInstance3D] = []
var star_meshes: Array[MultiMesh] = []
for child in space.get_children():
if child is MultiMeshInstance3D:
star_nodes.append(child)
star_meshes.append(child.multimesh)
_check(child.material_override == space.star_material, "Star shells must share one persistent stretch material")
for kind in ["asteroid", "sun", "storm", "nebula", "pulsar", "clear"]:
space.apply_state({"hazard": kind, "paused": false})
_check(space.hazard == kind, "Environment must follow the native hazard")
@@ -46,6 +53,48 @@ func _run() -> void:
space.apply_state({"hazard": "asteroid", "paused": false})
space._process(0.5)
_check(rock.position != location, "Unpaused asteroid field must have parallax motion")
# A charged drive, the map, and pending startup events are not departures.
for stationary in [
{"hazard": "clear", "navigation": {"jump": true}, "map_open": true},
{"hazard": "clear", "transition": true, "event_pending": true, "paused": true},
{"hazard": "clear", "event_open": true, "dialog": {"id": "startup"}},
]:
space.apply_state(stationary)
space._process(0.4)
_check(not space.jump_active and space.jump_stretch == 0.0, "Stationary native UI must not fake a jump")
space.set_travel_direction(Vector3(0.0, 7.0, -3.0))
_check(space.travel_direction.is_equal_approx(Vector3.FORWARD), "Travel follows the ship bow projected onto the horizontal plane")
space.set_travel_direction(Vector3.UP)
_check(space.travel_direction.is_equal_approx(Vector3.FORWARD), "An invalid vertical heading must preserve the last travel direction")
space.apply_state({"hazard": "asteroid", "jumping": true, "paused": true, "frozen": true})
var departure_rock: Node3D = space.rocks[0]["node"]
var departure_location := departure_rock.position
space._process(0.1)
_check(space.jump_active and space.jump_stretch > 0.0, "Native travel must stretch stars even while simulation is paused")
_check(not space.hazard_root.visible, "Old beacon hazards must be hidden during travel")
_check(departure_rock.position == departure_location, "Hidden old beacon hazards must not advance during travel")
space._process(0.4)
_check(space.jump_stretch == 1.0, "A sustained native jump must reach full star stretch")
space.set_travel_direction(Vector3(-4.0, 0.0, 0.0))
_check(Vector3(space.star_material.get_shader_parameter("travel_direction")).is_equal_approx(Vector3.LEFT), "Streaks must follow the supplied ship heading, including reversed travel")
space.apply_state({"hazard": "sun", "jumping": true, "event_open": true, "paused": true})
_check(not space.jump_active and space.jump_stretch == 0.0, "Arrival dialog must restore stars immediately")
_check(space.hazard_root.visible and space.hazard == "sun", "Arrival must show the new beacon's native hazard")
space.apply_state({"hazard": "sun", "jumping": true, "event_open": false})
space._process(0.3)
_check(space.jump_stretch == 0.0, "Closing an arrival dialog must not restart a lingering native jump animation")
space.apply_state({"hazard": "clear", "jumping": false})
space.apply_state({"hazard": "clear", "jumping": true})
space._process(0.4)
_check(space.jump_stretch == 1.0, "The next real departure must animate after the previous arrival")
space.apply_state({"hazard": "nebula", "jumping": false, "event_open": false})
_check(space.jump_stretch == 0.0 and space.hazard_root.visible, "A beacon without an arrival dialog must also end native travel")
space.apply_state({"jumping": true, "ready": false, "ui_mode": "menu"})
space._process(0.4)
_check(space.jump_stretch == 0.0, "A native menu snapshot must not show travel")
for i in range(star_nodes.size()):
_check(star_nodes[i].multimesh == star_meshes[i], "Jump transitions must preserve the star MultiMesh resources")
_check(star_nodes[i].material_override == space.star_material, "Jump transitions must preserve the shared star material")
print("ENVIRONMENT_TESTS ", "PASS" if failures == 0 else "FAIL: %d" % failures)
space.queue_free()
await process_frame
+17
View File
@@ -18,6 +18,12 @@ func run() -> void:
XRServer.add_tracker(tracker)
scene._resolve_controller_pose(scene.right_hand)
check(scene.right_hand.pose == "aim" and scene.right_hand.get_is_active(), "Right hand must use the active aim tracker pose")
var initial_ship_pose: Transform3D = scene.player_ship.transform
scene.player_ship.rotation.y = PI / 2
scene._sync_jump_heading()
check(scene.space_environment.travel_direction.is_equal_approx(Vector3.FORWARD), "Jump streaks must follow the ship's transformed bow rather than the headset")
scene.player_ship.transform = initial_ship_pose
scene._sync_jump_heading()
scene._update_pointer()
check(scene.right_laser.visible and scene.right_laser.mesh is CylinderMesh, "Tracked hand must draw a stereo-visible ray")
check(scene.right_laser.material_override.no_depth_test, "Ray must remain visible over the native menu")
@@ -132,6 +138,17 @@ func run() -> void:
scene._cycle_hand_page(1)
check(scene.pending_page == "shortcuts" and scene.commands.back().data.screen == "tactical","Changing screen must first close the native tactical view")
scene.hud_state.tactical = false
scene.wheel_category = 2
scene.wheel_was_down = false
tracker.set_input("wheel_button", true)
scene._update_wheel()
check(scene.wheel_category == 0 and scene.wheel.heading == "WEAPONS\nDRONES", "Each new wheel hold must return to equipment rather than retaining a system category")
scene.wheel_category = 1
scene._update_wheel()
check(scene.wheel_category == 1, "A held wheel must retain a manually chosen category")
tracker.set_input("wheel_button", false)
scene._update_wheel()
check(scene.wheel_category == 0, "Closing the wheel must restore its equipment default")
scene._refresh_wheel()
check(scene.wheel.entries[0].enabled and not scene.wheel.entries[1].enabled and scene.wheel.entries[4].enabled,"Wheel must show only equipped weapon/drone slots as available")
scene.wheel.choose(Vector2(0,1))
+220
View File
@@ -0,0 +1,220 @@
extends SceneTree
const ShipModel = preload("res://scripts/ship_model.gd")
const TargetLocks = preload("res://scripts/target_locks.gd")
const UiAssets = preload("res://scripts/ftl_ui_assets.gd")
var failures := 0
func _initialize() -> void:
call_deferred("_run")
func _check(condition: bool, message: String) -> void:
if not condition:
failures += 1
push_error(message)
func _run() -> void:
var encounter := Node3D.new()
root.add_child(encounter)
var player := ShipModel.new()
encounter.add_child(player)
player.build("__target_locks_fixture__", false)
player.set_process(false)
var enemy := ShipModel.new()
encounter.add_child(enemy)
enemy.build("__target_locks_fixture__", true)
enemy.set_process(false)
enemy.layout_data.x_offset = 2
enemy.layout_data.y_offset = 1
var renderer := TargetLocks.new()
root.add_child(renderer)
var laser := {"slot": 0, "kind": "laser", "target_ship": 1, "autofire": false,
"targets": [{"x": 105, "y": 70}, {"x": 175, "y": 140}]}
var beam := {"slot": 1, "kind": "beam", "beam_length": 100, "target_ship": 1, "autofire": true,
"targets": [{"x": 245, "y": 140}, {"x": 105, "y": 70}]}
var flak := {"slot": 2, "kind": "flak", "target_ship": 1, "target_radius": 30,
"targets": [{"x": 175, "y": 70}]}
var state := {"ready": true, "combat": true, "paused": true, "weapon_selected": -1,
"targeting": {"active": false}, "player": {"weapons": [laser, beam, flak]},
"enemy": {"rooms": [{"id": 0, "visible": false}], "weapons": [laser]}}
var original := JSON.stringify(state)
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.size() == 3, "Native locks must persist after the armed cursor closes, including during pause")
_check(JSON.stringify(state) == original, "Target lock presentation must never mutate the game snapshot")
var local_target: Vector3 = enemy.pixel_point(laser.targets[0], TargetLocks.HEIGHT)
_check(renderer.locks[0].end.is_equal_approx(local_target), "Native target conversion must remove layout offsets")
_check(Vector2(local_target.x, local_target.z).is_equal_approx(enemy.room_bounds[0].get_center()), "The lock must remain centered on the native room")
_check(renderer.locks[0].node.get_child_count() <= 2, "Charged weapon extra points must not invent reticles absent from the native aiming renderer")
_check(renderer.locks[1].start.is_equal_approx(enemy.pixel_point(beam.targets[1], TargetLocks.HEIGHT))
and renderer.locks[1].end.is_equal_approx(enemy.pixel_point(beam.targets[0], TargetLocks.HEIGHT)), "Beam direction must use native targets[1] to targets[0]")
var radius_mesh: MeshInstance3D = renderer.locks[2].node.get_node("FlakRadius")
_check(is_equal_approx(radius_mesh.mesh.top_radius, 30.0 * ShipModel.TILE / 35.0), "Flak circle must use the actual native spread radius")
_check(renderer.locks[1].node.get_meta("autofire") and renderer.locks[1].node.get_node("WeaponSlot").modulate == TargetLocks.AUTOFIRE,
"Autofire must use the native yellow cue")
var retained_id: int = renderer.locks[0].node.get_instance_id()
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks[0].node.get_instance_id() == retained_id, "Identical snapshots must retain existing lock geometry")
encounter.position = Vector3(1.7, 0.4, -1.2)
encounter.rotation_degrees = Vector3(17, 39, -12)
encounter.scale = Vector3(0.65, 1.2, 0.83)
enemy.position = Vector3(0.23, 0.17, -0.48)
enemy.rotation_degrees = Vector3(-8, -23, 14)
renderer.refresh_transforms()
_check(renderer.locks[0].node.to_global(renderer.locks[0].end).is_equal_approx(enemy.to_global(local_target)),
"Locks must follow moving, rotated and nonuniformly scaled ships without waiting for a new snapshot")
_check(renderer.locks[1].node.global_transform.is_equal_approx(enemy.global_transform), "Beam endpoint geometry must follow the same ship transform")
enemy.hide()
renderer.refresh_transforms()
_check(not renderer.locks[0].node.visible, "Hidden ships must hide their locks immediately")
enemy.show()
renderer.refresh_transforms()
_check(renderer.locks[0].node.visible, "Visible native locks must return with their ship")
laser.targets = [{"x": 175, "y": 140}]
laser.autofire = true
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks[0].node.get_instance_id() != retained_id and renderer.locks[0].node.get_meta("autofire"),
"Native retargeting and autofire colour changes must replace stale geometry immediately")
laser.targets = []
renderer.apply_snapshot(state, player, enemy)
_check(not renderer.locks.has(0) and renderer.locks.size() == 2, "Clearing one native target must remove only that weapon's lock")
state.player.weapons = []
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.is_empty(), "Enemy weapon intent must never create a lock on either ship")
var self_bomb := {"slot": 0, "kind": "bomb", "target_ship": 0, "targets": [{"x": 35, "y": 35}]}
state.player.weapons = [self_bomb]
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks[0].receiver == player, "A player-owned bomb must honor its actual friendly target ship")
state.combat = false
state.player.weapons = [self_bomb, beam]
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.size() == 1 and renderer.locks.has(0) and renderer.locks[0].receiver == player,
"A native self-target bomb must retain its lock without an enemy, while enemy targets disappear")
state.combat = true
state.player.weapons = [self_bomb]
self_bomb.target_ship = -1
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.is_empty(), "Invalid native target ship IDs must clear previous locks")
state.player.weapons = [beam]
beam.targets = [{"x": 245, "y": 140}]
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.is_empty(), "A partially placed real beam must not show a fabricated second endpoint")
beam.beam_length = 1
beam.targets = [{"x": 241, "y": 136}]
renderer.apply_snapshot(state, player, enemy)
_check(not renderer.locks[1].beam and renderer.locks[1].end.is_equal_approx(enemy.pixel_point({"x": 227.5, "y": 122.5}, TargetLocks.HEIGHT)),
"Pinpoint beams must use the native tile center and a placed reticle")
beam.targets = [{"x": NAN, "y": 140}]
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.is_empty(), "Nonfinite native coordinates must not reach render geometry")
beam.targets = [{"x": 245, "y": 140}]
for gate in ["combat", "ready"]:
state[gate] = false
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.is_empty(), "An ended encounter or inactive game must remove every lock")
state[gate] = true
state.transition = true
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.is_empty(), "Jump transition must remove old encounter locks")
state.transition = false
state.enemy.destroyed = true
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.is_empty(), "Native enemy destruction must remove its locks")
state.enemy.destroyed = false
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.size() == 1, "Current native target state must recreate a valid lock")
enemy.queue_free()
await process_frame
renderer.refresh_transforms()
_check(renderer.locks.is_empty(), "Freed receivers must remove locks without accessing their transforms")
# Exercise the source-only fallback even when owned slot 1–4 art is present.
var fallback := Node3D.new()
root.add_child(fallback)
renderer._make_reticle(fallback, Vector3.ZERO, 8, false)
renderer._finish_lines(fallback)
_check(fallback.get_node("LockLines").mesh.get_surface_count() == 1 and fallback.get_child_count() == 2,
"Procedural reticles must batch all strokes into one surface and retain the weapon slot cue")
fallback.queue_free()
encounter.queue_free()
renderer.queue_free()
await process_frame
if failures == 0 and OS.get_cmdline_user_args().has("--capture"):
await _capture_fixture()
print("TARGET_LOCK_TESTS ", "PASS" if failures == 0 else "FAIL %d" % failures)
quit(0 if failures == 0 else 1)
func _capture_fixture() -> void:
# Renderer-only oracle: no native process, game commands, input or save writes.
root.size = Vector2i(1400, 900)
var world := WorldEnvironment.new()
var environment := Environment.new()
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color("101923")
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.ambient_light_color = Color("d4e7f2")
environment.ambient_light_energy = 0.85
world.environment = environment
root.add_child(world)
var camera := Camera3D.new()
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
camera.size = 1.45
camera.position = Vector3(0, 2.0, 1.35)
root.add_child(camera)
camera.look_at(Vector3.ZERO)
var enemy := ShipModel.new()
root.add_child(enemy)
enemy.build("__target_locks_fixture__", true)
enemy.set_process(false)
enemy.inspect_rooms = true
var rooms: Array = []
for id in range(6):
rooms.append({"id": id, "visible": id != 2})
enemy.apply_live({"rooms": rooms})
var renderer := TargetLocks.new()
root.add_child(renderer)
var state := {"ready": true, "combat": true, "player": {"weapons": [
{"slot": 0, "kind": "laser", "target_ship": 1, "targets": [{"x": 35, "y": 35}]},
{"slot": 1, "kind": "laser", "autofire": true, "target_ship": 1, "targets": [{"x": 105, "y": 35}]},
{"slot": 2, "kind": "flak", "target_ship": 1, "target_radius": 32, "targets": [{"x": 175, "y": 35}]},
{"slot": 3, "kind": "beam", "beam_length": 140, "target_ship": 1, "targets": [{"x": 175, "y": 105}, {"x": 35, "y": 105}]}]},
"enemy": {"rooms": rooms}}
renderer.apply_snapshot(state, null, enemy)
var title := _caption("NATIVE WEAPON TARGET LOCKS", Vector2(40, 32), 34)
var footer := _caption("Red: single volley / Yellow: autofire / Circle: flak spread / Beam: actual sweep direction", Vector2(40, 837), 22)
await _capture("target_locks_fixture.png")
enemy.position = Vector3(0.12, 0.03, 0.05)
enemy.rotation_degrees = Vector3(13, 31, -9)
enemy.scale = Vector3(1.2, 0.8, 1.08)
renderer.refresh_transforms()
camera.size = 1.75
title.text = "LOCKS FOLLOW THE MOVED AND SCALED SHIP"
footer.text = "Room locks and both beam endpoints remain attached during pause. Sensor fog reveals no crew or system details."
await _capture("target_locks_transformed_fixture.png")
state.player.weapons = []
renderer.apply_snapshot(state, null, enemy)
title.text = "NATIVE TARGET CLEAR REMOVES ALL LOCKS"
footer.text = "Weapon target lists are authoritative; cleared targets disappear immediately."
await _capture("target_locks_cleared_fixture.png")
func _capture(filename: String) -> void:
for i in range(12):
await process_frame
await RenderingServer.frame_post_draw
var path := ProjectSettings.globalize_path("res://local_game_data/" + filename)
root.get_texture().get_image().save_png(path)
print("TARGET_LOCK_CAPTURE ", path)
func _caption(text: String, point: Vector2, size: int) -> Label:
var label := Label.new()
label.text = text
label.position = point
label.add_theme_font_override("font", UiAssets.font("body"))
label.add_theme_font_size_override("font_size", size)
label.modulate = Color("fff2c4")
root.add_child(label)
return label