mirror of
https://github.com/ElHombreAlex/FTL-Faster-Than-Light-VR.git
synced 2026-10-06 03:00:06 +02:00
Update gameplay feedback and repair portable VR launchers
This commit is contained in:
1 parent
35cdf71054
commit
6fffde9dc3
36 files changed
+1498
-176
No files matched your search
@@ -0,0 +1,136 @@
|
||||
extends SceneTree
|
||||
|
||||
const ShipModel = preload("res://scripts/ship_model.gd")
|
||||
const Effects = preload("res://scripts/combat_effects.gd")
|
||||
const UiAssets = preload("res://scripts/ftl_ui_assets.gd")
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
# Presentation fixtures only. No bridge, game process, 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.65
|
||||
world.environment = environment
|
||||
root.add_child(world)
|
||||
var light := DirectionalLight3D.new()
|
||||
light.rotation_degrees = Vector3(-50, -25, 0)
|
||||
light.light_energy = 1.2
|
||||
root.add_child(light)
|
||||
var camera := Camera3D.new()
|
||||
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
camera.size = 1.15
|
||||
camera.position = Vector3(0.1, 1.8, 1.1)
|
||||
root.add_child(camera)
|
||||
camera.look_at(Vector3.ZERO)
|
||||
var ship := ShipModel.new()
|
||||
root.add_child(ship)
|
||||
ship.build("__damage_fixture__", true)
|
||||
ship.inspect_rooms = true
|
||||
ship.set_process(false)
|
||||
var rooms: Array = []
|
||||
for id in range(6):
|
||||
rooms.append({"id": id, "visible": id != 3, "oxygen": 0 if id == 5 else 100,
|
||||
"breach_tiles": [{"x": 175, "y": 35 if id == 4 else 105}] if id >= 4 else []})
|
||||
ship.apply_live({"rooms": rooms, "room_systems": {"0": "shields", "1": "weapons", "2": "engines", "3": "oxygen"},
|
||||
"system_status": [{"room_id": 0, "health": 4, "max_health": 4, "health_visible": true, "health_detail_visible": true},
|
||||
{"room_id": 1, "health": 2, "max_health": 4, "repair_progress": 0.55, "health_visible": true, "health_detail_visible": true},
|
||||
{"room_id": 2, "health": 0, "max_health": 2, "health_visible": true, "health_detail_visible": true},
|
||||
{"room_id": 3, "health": 1, "max_health": 3, "health_visible": true, "health_detail_visible": false}]})
|
||||
ship.room_hazards.animate(0.38)
|
||||
var labels := Node3D.new()
|
||||
root.add_child(labels)
|
||||
for item in [["FULL", 0], ["REPAIRING", 1], ["DISABLED", 2], ["SENSOR FOG", 3], ["AIR ESCAPING", 4], ["VACUUM", 5]]:
|
||||
var area: Area3D = ship.room_nodes[item[1]]
|
||||
_world_label(labels, item[0], area.position + Vector3(0, 0.11, 0.0), 0.0012)
|
||||
var title := _caption("SYSTEM CONDITION / NATIVE REPAIR / BREACHES", Vector2(46, 35), 34)
|
||||
var footer := _caption("Native damage changes room and utility colours. Repaired systems clear them; fog hides breach locations.", Vector2(46, 840), 23)
|
||||
await _capture("damage_rooms_fixture.png")
|
||||
labels.hide()
|
||||
title.text = "VANILLA CROSS BREACH / OXYGEN-DRIVEN AIR"
|
||||
footer.text = "The opening stays at the native tile center. Escaping air stops when the room has no oxygen."
|
||||
camera.size = 0.52
|
||||
camera.position = ship.room_nodes[4].position + Vector3(0.20, 0.6, 0.6)
|
||||
camera.look_at(ship.room_nodes[4].position)
|
||||
await _capture("breach_cross_fixture.png")
|
||||
ship.queue_free()
|
||||
labels.queue_free()
|
||||
await process_frame
|
||||
var sender := ShipModel.new()
|
||||
root.add_child(sender)
|
||||
sender.build("__damage_fixture__", false)
|
||||
sender.set_process(false)
|
||||
sender.hide()
|
||||
sender.position = Vector3(-1.5, 0, 0)
|
||||
sender.scale = Vector3.ONE * 0.30
|
||||
var effects := Effects.new()
|
||||
root.add_child(effects)
|
||||
effects.set_process(false)
|
||||
var targets: Array[Node3D] = []
|
||||
for i in range(3):
|
||||
var receiver := ShipModel.new()
|
||||
root.add_child(receiver)
|
||||
receiver.build("__damage_fixture__", true)
|
||||
receiver.set_process(false)
|
||||
receiver.scale = Vector3.ONE * 0.30
|
||||
receiver.position = Vector3((i - 1) * 0.76, 0, 0)
|
||||
receiver.inspect_rooms = true
|
||||
receiver.apply_live({"rooms": [{"id": 0, "visible": true}], "shield": 2 if i == 1 else 0})
|
||||
targets.append(receiver)
|
||||
_world_label(root, ["HULL HIT", "SHIELD HIT", "EVADED"][i], receiver.position + Vector3(0, 0.40, 0), 0.0018)
|
||||
var point: Dictionary = {"x": 70, "y": 70}
|
||||
effects.resolve_live({"projectile_id": "fixture-hull", "outcome": "hull", "target": point}, targets[0])
|
||||
effects.resolve_live({"projectile_id": "fixture-shield", "outcome": "shield", "target": point}, targets[1])
|
||||
effects.spawn_shot({"kind": "missile", "outcome": "pending", "projectile_id": "fixture-miss", "target": point}, sender, targets[2])
|
||||
effects.update_live_projectiles([{"id": "fixture-miss", "progress": 0.93, "missed": true}])
|
||||
effects._process(0.06)
|
||||
camera.size = 1.45
|
||||
camera.position = Vector3(0.30, 2.3, 2.2)
|
||||
camera.look_at(Vector3(0, 0.1, 0))
|
||||
title.text = "ACTUAL OUTCOMES / DISTINCT FEEDBACK"
|
||||
footer.text = "Orange hull impact / blue shield absorption / passing missile and MISS. Evasion never creates an impact."
|
||||
await _capture("miss_outcomes_fixture.png")
|
||||
print("DAMAGE_VISUAL_CAPTURE PASS")
|
||||
quit()
|
||||
|
||||
|
||||
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("DAMAGE_VISUAL_CAPTURE ", path)
|
||||
|
||||
|
||||
func _world_label(parent: Node, text: String, point: Vector3, pixel_size: float) -> void:
|
||||
var label := Label3D.new()
|
||||
label.text = text
|
||||
label.font = UiAssets.font("body")
|
||||
label.font_size = 32
|
||||
label.pixel_size = pixel_size
|
||||
label.modulate = Color("fff2c4")
|
||||
label.outline_size = 4
|
||||
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
label.no_depth_test = true
|
||||
label.position = point
|
||||
parent.add_child(label)
|
||||
|
||||
|
||||
func _caption(text: String, point: Vector2, font_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", font_size)
|
||||
label.modulate = Color("fff2c4")
|
||||
root.add_child(label)
|
||||
return label
|
||||
+39
-6
@@ -16,21 +16,54 @@ from lab_settings import configure
|
||||
PROJECT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load_config(path):
|
||||
if not path.is_file():
|
||||
raise ValueError('Missing local_game_data/launcher.json. Run SETUP.cmd, then follow docs/INSTALLATION.md.')
|
||||
value = json.loads(path.read_text(encoding='utf-8-sig'))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError('launcher.json must contain a JSON object with lab, hooks and godot paths.')
|
||||
return value
|
||||
|
||||
|
||||
def preflight(config, desktop=False):
|
||||
if os.name != 'nt':
|
||||
raise ValueError('The live game bridge currently supports Windows only.')
|
||||
for name in ('lab', 'godot', 'hooks'):
|
||||
if not Path(config[name]).exists(): raise ValueError(f'Missing {name}: {config[name]}')
|
||||
for module in ('frida', 'PIL', 'numpy'):
|
||||
if not isinstance(config.get(name), str) or not config[name].strip():
|
||||
raise ValueError(f'launcher.json needs a nonempty {name} path.')
|
||||
if not Path(config[name]).is_absolute():
|
||||
raise ValueError(f'Use an absolute path for {name} in launcher.json.')
|
||||
valid = Path(config[name]).is_dir() if name == 'lab' else Path(config[name]).is_file()
|
||||
if not valid: raise ValueError(f'Missing {name}: {config[name]}')
|
||||
for module in ('frida', 'PIL', 'numpy', 'capstone'):
|
||||
if importlib.util.find_spec(module) is None: raise ValueError(f'Missing Python dependency: {module}')
|
||||
lab = Path(config['lab'])
|
||||
marker = json.loads((lab/'ftlvr-lab.json').read_text())
|
||||
hooks = json.loads(Path(config['hooks']).read_text())
|
||||
if not (lab/'ftlvr-lab.json').is_file():
|
||||
raise ValueError('The configured lab has no ftlvr-lab.json marker. Complete isolated lab preparation.')
|
||||
marker = json.loads((lab/'ftlvr-lab.json').read_text(encoding='utf-8-sig'))
|
||||
if not isinstance(marker, dict):
|
||||
raise ValueError('The lab marker is malformed. Use a correctly prepared isolated lab.')
|
||||
source = marker.get('source_game')
|
||||
if not isinstance(source, str) or not (Path(source)/'ftl.dat').is_file():
|
||||
raise ValueError('The owned original installation recorded by the lab is unavailable. Restore that path.')
|
||||
if marker.get('save_prefix') != 'ftlvr':
|
||||
raise ValueError('The normal launcher requires an isolated lab with the ftlvr save prefix.')
|
||||
manifest_path = PROJECT/'local_game_data/manifest.json'
|
||||
if not manifest_path.is_file():
|
||||
raise ValueError('Local game assets are missing. Run tools/extract_ftl.py on your owned original ftl.dat.')
|
||||
manifest = json.loads(manifest_path.read_text(encoding='utf-8-sig'))
|
||||
if not isinstance(manifest, dict) or not all(manifest.get(name) for name in ('ships', 'fonts', 'ui_assets')):
|
||||
raise ValueError('Local extraction is incomplete. Run tools/extract_ftl.py again.')
|
||||
hooks = json.loads(Path(config['hooks']).read_text(encoding='utf-8-sig'))
|
||||
if not isinstance(hooks, dict):
|
||||
raise ValueError('hooks.json must contain resolved local executable hook data.')
|
||||
if not all(name in hooks.get('rvas',{}) for name in ('GetShiftState','GetCtrlState','ForceAutofireFlag')):
|
||||
raise ValueError('Regenerate lab hooks.json with tools/resolve_hooks.py for controller modifiers')
|
||||
if not all(name in hooks.get('rvas',{}) for name in ('OnTextInput','OnTextEvent','TextInputOnRender','TextInputStart')):
|
||||
raise ValueError('Regenerate lab hooks.json with tools/resolve_hooks.py for native renaming')
|
||||
if not all(name in hooks.get('rvas',{}) for name in ('CommandGuiRenderStatic','CommandGuiRenderPause','TabbedWindowOnRender','ChoiceBoxOnRender','MouseControlOnRender','StarMapOnRender')):
|
||||
raise ValueError('Regenerate lab hooks.json with tools/resolve_hooks.py for live native HUD capture')
|
||||
if not marker.get('activated') or hashlib.sha1((lab/'FTLGame.exe').read_bytes()).hexdigest() != hooks['sha1']:
|
||||
if not marker.get('activated') or not (lab/'FTLGame.exe').is_file() or hashlib.sha1((lab/'FTLGame.exe').read_bytes()).hexdigest() != hooks.get('sha1'):
|
||||
raise ValueError('Lab is not activated or its executable fingerprint changed')
|
||||
if not (lab/'Hyperspace.dll').exists(): raise ValueError('Hyperspace is missing')
|
||||
status = PROJECT/'local_game_data/bridge_status.json'
|
||||
@@ -56,7 +89,7 @@ def main():
|
||||
parser.add_argument('--smoke',action='store_true',help='Desktop launch and graceful exit after 3 seconds')
|
||||
args=parser.parse_args()
|
||||
if args.smoke: args.desktop=True
|
||||
config=json.loads(args.config.read_text())
|
||||
config=load_config(args.config)
|
||||
print(json.dumps(preflight(config,args.desktop),indent=2),flush=True)
|
||||
if args.check: return
|
||||
local=PROJECT/'local_game_data'; local.mkdir(exist_ok=True)
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ def decode_state_line(line):
|
||||
result['projectiles'] = []
|
||||
for side in ('player','enemy'):
|
||||
if isinstance(result.get(side),dict):
|
||||
for field in ('rooms','crew','weapons','doors','drones','system_status'):
|
||||
for field in ('rooms','crew','weapons','doors','drones','drone_equipment','system_status'):
|
||||
if not isinstance(result[side].get(field),list): result[side][field]=[]
|
||||
for room in result[side]['rooms']:
|
||||
for field in ('fire_tiles','breach_tiles'):
|
||||
|
||||
@@ -7,7 +7,6 @@ copies, patches, launches or attaches to FTL. --check and --dry-run are read-onl
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -61,46 +60,11 @@ def setup(dry_run: bool = False) -> None:
|
||||
|
||||
def check(desktop: bool = False) -> None:
|
||||
require_platform()
|
||||
missing = [name for name in ("frida", "PIL", "numpy", "capstone")
|
||||
if importlib.util.find_spec(name) is None]
|
||||
if missing:
|
||||
raise ValueError("Missing Python packages: " + ", ".join(missing) + ". Run SETUP.cmd first.")
|
||||
if not CONFIG.is_file():
|
||||
raise ValueError("Missing local_game_data/launcher.json. Run SETUP.cmd and edit its paths.")
|
||||
config = json.loads(CONFIG.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError("launcher.json must contain a JSON object with lab, hooks and godot paths.")
|
||||
for key in ("lab", "hooks", "godot"):
|
||||
if not isinstance(config.get(key), str) or not config[key].strip():
|
||||
raise ValueError(f"launcher.json needs a nonempty {key} path.")
|
||||
if not Path(config[key]).is_absolute():
|
||||
raise ValueError(f"Use an absolute path for {key} in launcher.json.")
|
||||
lab = Path(config["lab"])
|
||||
if not lab.is_dir():
|
||||
raise ValueError("The configured lab directory does not exist. Complete isolated lab preparation.")
|
||||
for key in ("hooks", "godot"):
|
||||
if not Path(config[key]).is_file():
|
||||
raise ValueError(f"The configured {key} file does not exist: {config[key]}")
|
||||
if not (lab / "ftlvr-lab.json").is_file():
|
||||
raise ValueError("The configured lab has no ftlvr-lab.json marker. Complete isolated lab preparation.")
|
||||
marker = json.loads((lab / "ftlvr-lab.json").read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(marker, dict):
|
||||
raise ValueError("The lab marker is malformed. Use a correctly prepared isolated lab.")
|
||||
source = marker.get("source_game")
|
||||
if not isinstance(source, str) or not (Path(source) / "ftl.dat").is_file():
|
||||
raise ValueError("The owned original installation recorded by the lab is unavailable. Restore that path.")
|
||||
if not (LOCAL / "manifest.json").is_file():
|
||||
raise ValueError("Local game assets are missing. Run tools/extract_ftl.py on your owned ftl.dat.")
|
||||
manifest = json.loads((LOCAL / "manifest.json").read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError("The local asset manifest is malformed. Run tools/extract_ftl.py again.")
|
||||
if not manifest.get("ships") or not manifest.get("fonts") or not manifest.get("ui_assets"):
|
||||
raise ValueError("Local extraction is incomplete. Run tools/extract_ftl.py again.")
|
||||
# The production launcher's own checks include executable fingerprints,
|
||||
# required hook families, dependencies, loader and the OpenXR runtime.
|
||||
sys.dont_write_bytecode = True
|
||||
from launch import preflight
|
||||
result = preflight(config, desktop=desktop)
|
||||
from launch import load_config, preflight
|
||||
result = preflight(load_config(CONFIG), desktop=desktop)
|
||||
print(json.dumps(result, indent=2))
|
||||
print("Setup checks passed. No game was launched or modified.")
|
||||
print("This checks readiness; it does not measure headset performance.")
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
echo.
|
||||
echo Launch stopped. Read the error above and docs\INSTALLATION.md or docs\TROUBLESHOOTING.md.
|
||||
echo.
|
||||
if not "%FTLVR_NO_PAUSE%"=="1" pause
|
||||
exit /b 0
|
||||
@@ -13,6 +13,15 @@ from run_bridge import (decode_state_line, translate_command, full_screen_captur
|
||||
|
||||
|
||||
class BridgeTests(unittest.TestCase):
|
||||
def test_empty_native_drone_equipment_does_not_erase_weapons(self):
|
||||
weapons=[{'slot':0,'name':'MISSILES_2','title':'Artemis Missiles','ammo_cost':1},
|
||||
{'slot':1,'name':'LASER_BURST_3','title':'Burst Laser Mark II','ammo_cost':0}]
|
||||
raw={'protocol':2,'source':'hyperspace','ready':True,
|
||||
'player':{'weapons':weapons,'drone_equipment':{},'crew':{},'drones':{}}}
|
||||
normalized=decode_state_line('FTLVR_STATE '+json.dumps(raw))
|
||||
self.assertEqual(normalized['player']['drone_equipment'],[])
|
||||
self.assertEqual(normalized['player']['weapons'],weapons)
|
||||
|
||||
def test_raw_transport_keeps_dimensions_rgba_orientation_and_timestamp(self):
|
||||
image=Image.new('RGBA',(960,540),(10,20,30,40))
|
||||
image.putpixel((0,0),(1,2,3,4));image.putpixel((0,539),(5,6,7,8))
|
||||
|
||||
+35
-1
@@ -26,7 +26,7 @@ func _run() -> void:
|
||||
effects.set_process(false)
|
||||
_check(player.global_basis.x.normalized().dot(Vector3.RIGHT) > 0.99, "Player bow must face enemy")
|
||||
_check((-enemy.global_basis.z).normalized().dot(Vector3.LEFT) > 0.99, "Enemy bow must face player")
|
||||
_check(scene.hud_surface.get_parent() == scene.camera, "HUD must follow the tracked headset")
|
||||
_check(scene.hud_surface.get_parent() == scene, "Gameplay HUD must support spatial placement without inheriting head motion")
|
||||
for ship in [player, enemy]:
|
||||
_check(ship.weapon_origin(0).is_equal_approx(ship.to_global(ship.mount_points[0])), "Mount must use the ship world transform")
|
||||
_check(ship.room_target(0).is_equal_approx(ship.to_global(ship.room_points[0])), "Target room must use the ship world transform")
|
||||
@@ -95,11 +95,45 @@ func _run() -> void:
|
||||
effects._process(0.3)
|
||||
scene._present_shot({"source": "player", "kind": "laser", "outcome": "pending", "projectile_id": "live-miss", "target_room": 0})
|
||||
effects.update_live_projectiles([{"id": "live-miss", "progress": 0.7, "missed": true}])
|
||||
var miss_count: int = effects.misses.size()
|
||||
_check(miss_count > 0 and effects.misses.back()["node"].text == "MISS", "A native evasion must visibly say MISS without an impact")
|
||||
effects.update_live_projectiles([{"id": "live-miss", "progress": 0.7, "missed": true}])
|
||||
effects.resolve_live({"projectile_id": "live-miss", "outcome": "miss", "target": {"x": 80, "y": 170}}, enemy)
|
||||
_check(effects.misses.size() == miss_count and effects.shots.size() == 1, "Miss snapshots and native outcomes must produce one cue and preserve a still-live passing projectile")
|
||||
effects._process(0.1)
|
||||
_check(Vector3(effects.shots.back()["end"]).distance_to(enemy.room_target(0)) > 0.4, "Native miss trajectory must survive endpoint updates")
|
||||
_check(effects.impacts.is_empty() and enemy.shield_flash > 0.0, "A miss must not add a hull impact or reset an existing shield state")
|
||||
effects.simulation_paused = true
|
||||
var miss_age: float = effects.misses.back()["time"]
|
||||
effects._process(0.5)
|
||||
_check(effects.misses.back()["time"] == miss_age, "Native pause must freeze evasion feedback")
|
||||
effects.simulation_paused = false
|
||||
effects.update_live_projectiles([])
|
||||
effects._process(0.3)
|
||||
effects.update_live_projectiles([])
|
||||
effects._process(1.1)
|
||||
_check(effects.misses.is_empty(), "Evasion feedback must clear after its short presentation lifetime")
|
||||
effects.resolve_live({"projectile_id": "already-dead-miss", "outcome": "miss", "target": {"x": 80, "y": 170}}, enemy)
|
||||
_check(effects.misses.size() == 1 and effects.impacts.is_empty(), "Native misses that vanish between snapshots must still display feedback without invented damage")
|
||||
effects.resolve_live({"projectile_id": "actual-hull", "outcome": "hull", "target": {"x": 80, "y": 170}}, enemy)
|
||||
effects.resolve_live({"projectile_id": "actual-shield", "outcome": "shield", "target": {"x": 80, "y": 170}}, enemy)
|
||||
_check(effects.impacts[-2]["outcome"] == "hull" and effects.impacts[-1]["outcome"] == "shield", "Hull and shield feedback must retain distinct actual native outcomes")
|
||||
_check(effects.impacts[-2]["node"].material_override.albedo_color != effects.impacts[-1]["node"].material_override.albedo_color, "Hull and shield flashes must use visibly different colors")
|
||||
effects._process(1.1)
|
||||
# A projectile can fire, miss, and die between native snapshots. Its real
|
||||
# outcome must end the unseen flight immediately, even while paused.
|
||||
effects.simulation_paused = true
|
||||
effects.spawn_shot({"kind": "laser", "outcome": "pending", "projectile_id": "gap-miss"}, player, enemy)
|
||||
effects.resolve_live({"projectile_id": "gap-miss", "outcome": "miss", "target": {"x": 80, "y": 170}}, enemy)
|
||||
effects.update_live_projectiles([])
|
||||
_check(effects.shots.is_empty() and effects.misses.size() == 1 and effects.impacts.is_empty(), "Native missed fire absent from its first snapshot must clear immediately with one MISS cue and no invented hit")
|
||||
effects.spawn_shot({"kind": "laser", "outcome": "pending", "projectile_id": "fresh-live-miss"}, player, enemy)
|
||||
effects.resolve_live({"projectile_id": "fresh-live-miss", "outcome": "miss", "target": {"x": 80, "y": 170}}, enemy)
|
||||
effects.update_live_projectiles([{"id": "fresh-live-miss", "progress": 0.75, "missed": true}])
|
||||
_check(effects.shots.size() == 1 and effects.shots[0]["projectile_id"] == "fresh-live-miss", "A native miss before its first snapshot must preserve the projectile when the same snapshot still reports it alive")
|
||||
effects.update_live_projectiles([])
|
||||
effects.simulation_paused = false
|
||||
effects._process(1.1)
|
||||
scene._present_shot({"source": "player", "kind": "beam", "outcome": "pending", "projectile_id": "live-beam", "target_room": 0, "end_point": {"x": 100, "y": 180}})
|
||||
var beam_point := {"x": 80, "y": 170}
|
||||
effects.update_live_projectiles([{"id": "live-beam", "progress": 0.65, "beam_point": beam_point}])
|
||||
|
||||
@@ -118,8 +118,62 @@ func run() -> void:
|
||||
panel.set_power(source_player)
|
||||
await rendered()
|
||||
check(powered_count.get_data() != panel.viewport.get_texture().get_image().get_region(Rect2i(537,206,50,15)).get_data(),"The visible power count must show native effective 1/1 instead of raw allocated 0/1")
|
||||
var storm_player := source_player.duplicate(true)
|
||||
storm_player.reactor = {"raw_available":4,"available":0,"usable_available":0,"usable_total":8,"total":8,"installed":12,"cap_loss":4,"storm_loss":4,"battery_available":2,"battery_total":2}
|
||||
panel.set_power(storm_player)
|
||||
var reactor_view: Dictionary = panel.reactor_display()
|
||||
check(reactor_view.free == 0 and reactor_view.total == 8 and reactor_view.installed == 12 and reactor_view.storm_loss == 4,"Storm-disabled reactor bars must not be shown as usable free power")
|
||||
check(reactor_view.battery_free == 2 and reactor_view.free == 0,"Battery availability must remain distinct from free reactor bars")
|
||||
await rendered()
|
||||
panel.viewport.get_texture().get_image().save_png("res://local_game_data/controller-system-power-storm.png")
|
||||
panel.set_power(source_player)
|
||||
var wheel: Node3D = load("res://scripts/shortcut_wheel.gd").new()
|
||||
root.add_child(wheel)
|
||||
var equipment_player := {"weapons":[{"slot":0,"name":"LASER_BURST_2","title":"Laser à impulsions II","kind":"laser","charge":1.0},{"slot":1,"name":"MISSILES_1","title":"Artemis","kind":"missile","missile_cost":1}],"drones":[{"slot":-1,"equipment_slot":0,"title":"Combat I","kind":"combat","x":4},{"slot":-1,"title":"Special actor","kind":"hacking"}]}
|
||||
var equipment: Array = wheel.equipment_entries(equipment_player,1)
|
||||
check(equipment.size() == 8 and equipment[0].label == "Laser à impulsions II" and equipment[1].detail == "1 MISSILE","Wheel slots must expose localized equipped names and actual ammo costs")
|
||||
check(equipment[4].label == "Combat I" and equipment[4].key == 53 and not equipment[2].enabled and not equipment[5].enabled,"Equipment slots must preserve native keys and omit special-drone actors/empty slots")
|
||||
var equipment_schema := equipment_player.duplicate(true)
|
||||
equipment_schema.weapons[1].ammo_cost = 2
|
||||
equipment_schema.drone_equipment = [{"slot":0,"title":"Defense II","kind":"defense"}]
|
||||
var native_equipment: Array = wheel.equipment_entries(equipment_schema,1)
|
||||
check(native_equipment[1].detail == "2 MISSILES" and native_equipment[4].label == "Defense II","Authoritative ammo_cost and separate installed-drone metadata must take priority over actor fallbacks")
|
||||
var no_drones := equipment_player.duplicate(true)
|
||||
no_drones.drone_equipment = {}
|
||||
var empty_native_table: Array = wheel.equipment_entries(no_drones,0)
|
||||
check(empty_native_table[0].enabled and empty_native_table[0].label == "Laser à impulsions II" and empty_native_table[1].enabled,"A native empty Lua drone table must not suppress equipped weapons or fail the wheel")
|
||||
check(not empty_native_table[4].enabled and empty_native_table[4].label == "EMPTY","An explicit empty installed-drone table must not fall back to moving special actors")
|
||||
no_drones.weapons = {}
|
||||
no_drones.drone_equipment = null
|
||||
check(not wheel.equipment_entries(no_drones,0)[0].enabled,"Empty or temporarily unavailable native equipment must produce empty slots without a typed-array error")
|
||||
var native_path := "res://local_game_data/live_state.json"
|
||||
var recorded_wheel: Array = []
|
||||
if FileAccess.file_exists(native_path):
|
||||
var native_state: Variant = JSON.parse_string(FileAccess.get_file_as_string(native_path))
|
||||
if native_state is Dictionary and native_state.get("player", {}).get("weapons", []) is Array:
|
||||
var live_weapons: Array = native_state.player.weapons
|
||||
var live_wheel: Array = wheel.equipment_entries(native_state.player,int(native_state.get("drone_slots",0)))
|
||||
recorded_wheel = live_wheel
|
||||
for i in range(mini(4,live_weapons.size())):
|
||||
check(live_wheel[i].enabled and live_wheel[i].label == live_weapons[i].get("title",live_weapons[i].get("name","")),"The recorded native player equipment must appear in the wheel")
|
||||
wheel.set_entries(equipment,"WEAPONS\nDRONES")
|
||||
revision = wheel.content_revision
|
||||
equipment_player.weapons[0].charge = 8.5
|
||||
equipment_player.drones[0].x = 15
|
||||
wheel.set_entries(wheel.equipment_entries(equipment_player,1),"WEAPONS\nDRONES")
|
||||
check(wheel.content_revision == revision,"Changing charge/positions must not redraw unchanged wheel equipment labels")
|
||||
var long_name := "TrèsLongNomSansEspacePourUneArmeModifiée " + "With more translated words to clip"
|
||||
var bounded: Array = wheel.bounded_lines(long_name)
|
||||
check(bounded.size() <= 3 and str(bounded.back()).ends_with("..."),"Long localized equipment names must be clipped to bounded sector lines")
|
||||
for line in bounded: check(load("res://scripts/ftl_ui_theme.gd").text_size(str(line),23).x <= 136.01,"Every wheel name line must fit its measured native-font width")
|
||||
wheel.choose(Vector2(0,1))
|
||||
wheel.visible = true
|
||||
await rendered()
|
||||
wheel.viewport.get_texture().get_image().save_png("res://local_game_data/controller-action-wheel-equipment.png")
|
||||
if not recorded_wheel.is_empty():
|
||||
wheel.set_entries(recorded_wheel,"WEAPONS\nDRONES")
|
||||
await rendered()
|
||||
wheel.viewport.get_texture().get_image().save_png("res://local_game_data/controller-action-wheel-native.png")
|
||||
wheel.set_entries([{"label":"BURST LASER II","enabled":true},{"label":"ARTEMIS","enabled":true},{"label":"EMPTY","enabled":false},{"label":"EMPTY","enabled":false},{"label":"COMBAT DRONE","enabled":true},{"label":"BEAM DRONE","enabled":true}],"WEAPONS\nDRONES")
|
||||
wheel.choose(Vector2(0,1))
|
||||
revision = wheel.content_revision
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
extends SceneTree
|
||||
|
||||
const ShipModel = preload("res://scripts/ship_model.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 ship := ShipModel.new()
|
||||
root.add_child(ship)
|
||||
ship.build("__damage_fixture__", true)
|
||||
ship.set_process(false)
|
||||
ship.inspect_rooms = true
|
||||
var id: int = ship.room_nodes.keys()[0]
|
||||
var room := {"id": id, "visible": true, "oxygen": 100.0}
|
||||
var system := {"room_id": id, "health": 4, "max_health": 4}
|
||||
var state := {"rooms": [room], "room_systems": {str(id): "shields"}, "system_status": [system]}
|
||||
var original_state := JSON.stringify(state)
|
||||
ship.apply_live(state)
|
||||
_check(JSON.stringify(state) == original_state, "Presentation must not mutate the native game snapshot")
|
||||
var area: Area3D = ship.room_nodes[id]
|
||||
var floor: MeshInstance3D = area.get_node("Floor")
|
||||
var healthy_tint: Color = floor.material_override.albedo_color
|
||||
_check(not area.has_node("SystemCondition"), "Healthy rooms must not contain system health bars or numbers")
|
||||
system.health = 2
|
||||
ship.apply_live(state)
|
||||
var damaged_tint: Color = floor.material_override.albedo_color
|
||||
_check(damaged_tint != healthy_tint, "Authoritative damage must still change the room colour after removing health bars")
|
||||
_check(area.get_node("SystemRole").modulate == Color("ffad44"), "Partial damage must give the installed utility an amber cue")
|
||||
system.repair_progress = 0.35
|
||||
ship.apply_live(state)
|
||||
_check(not area.has_node("SystemCondition"), "Partial repairs must not recreate floor bars or repair counters")
|
||||
system.health = 0
|
||||
ship.apply_live(state)
|
||||
_check(floor.material_override.albedo_color != damaged_tint and area.get_node("SystemRole").modulate == Color("f26043"), "Destroyed systems must have a distinct red room and utility cue")
|
||||
ship.simulation_paused = true
|
||||
system.health = 3
|
||||
ship.apply_live(state)
|
||||
_check(floor.material_override.albedo_color != damaged_tint and area.get_node("SystemRole").modulate == Color("ffad44"), "Native repairs must update damage colour even during pause")
|
||||
system.health = 4
|
||||
ship.apply_live(state)
|
||||
_check(floor.material_override.albedo_color == healthy_tint and area.get_node("SystemRole").modulate == Color("fff2c4"), "Full native repair must immediately clear every damage colour")
|
||||
system.health_detail_visible = false
|
||||
ship.apply_live(state)
|
||||
_check(not area.has_node("SystemCondition"), "Revealed or hidden exact health must never create floor bars")
|
||||
system.erase("health_detail_visible")
|
||||
room.visible = false
|
||||
system.health = 1
|
||||
ship.apply_live(state)
|
||||
_check(area.get_node("SystemRole").modulate == Color("fff2c4"), "Sensor fog must not reveal private health through icon colour")
|
||||
_check(floor.material_override.albedo_color == Color(0.12, 0.14, 0.17), "Undisclosed enemy damage must retain the native fog floor tint")
|
||||
system.health_visible = true
|
||||
ship.apply_live(state)
|
||||
_check(area.get_node("SystemRole").modulate == Color("ffad44"), "Native public damage may tint a hidden utility without revealing exact health")
|
||||
var coarse_tint: Color = floor.material_override.albedo_color
|
||||
system.health = 3
|
||||
ship.apply_live(state)
|
||||
_check(floor.material_override.albedo_color == coarse_tint, "Coarse public condition colours must not leak exact remaining enemy system levels")
|
||||
system.health = 1
|
||||
system.health_detail_visible = true
|
||||
ship.apply_live(state)
|
||||
_check(not area.has_node("SystemCondition"), "Even explicitly revealed enemy health must not recreate removed bars")
|
||||
ship.inspect_rooms = false
|
||||
ship.apply_live(state)
|
||||
_check(not area.get_node("SystemRole").visible, "Closed enemy hull must hide the system utility label")
|
||||
ship.inspect_rooms = true
|
||||
room.visible = true
|
||||
var tile := {"x": 35.0, "y": 35.0}
|
||||
room.breach_tiles = [tile]
|
||||
ship.apply_live(state)
|
||||
var hazards: Node3D = ship.room_hazards
|
||||
_check(hazards.breach_points.size() == 1 and hazards.breach_points[0].is_equal_approx(ship.pixel_point(tile, 0.082)), "Breach cross must use the actual native tile center")
|
||||
_check(hazards.air_streams.multimesh.instance_count == 1, "An oxygenated native breach must emit escaping air")
|
||||
var breach_mesh: Mesh = hazards.breaches.multimesh.mesh
|
||||
var arrays: Array = breach_mesh.surface_get_arrays(0)
|
||||
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
||||
var colors: PackedColorArray = arrays[Mesh.ARRAY_COLOR]
|
||||
var black_vertices := 0
|
||||
for i in range(vertices.size()):
|
||||
if colors[i].is_equal_approx(Color("020407")):
|
||||
black_vertices += 1
|
||||
_check(absf(vertices[i].x) <= 0.01751 or absf(vertices[i].z) <= 0.01751, "Black breach geometry must have a cross silhouette, with empty diagonal corners")
|
||||
_check(black_vertices == 72 and breach_mesh.get_aabb().size.x < ShipModel.TILE and breach_mesh.get_aabb().size.z < ShipModel.TILE, "Breach opening must fit a native floor tile and use only two crossed black prisms")
|
||||
var paused_time: float = hazards.animation_time
|
||||
ship._process(0.5)
|
||||
_check(hazards.animation_time == paused_time, "Paused breach air must not advance animation")
|
||||
room.oxygen = 0.0
|
||||
ship.apply_live(state)
|
||||
_check(hazards.breach_points.size() == 1 and hazards.air_streams.multimesh.instance_count == 0, "A vacuum room must retain its breach but stop escaping air")
|
||||
room.visible = false
|
||||
ship.apply_live(state)
|
||||
_check(hazards.breach_points.is_empty() and hazards.air_streams.multimesh.instance_count == 0, "System damage visibility must not reveal private breach locations under fog")
|
||||
room.visible = true
|
||||
room.breach_tiles = []
|
||||
ship.apply_live(state)
|
||||
_check(hazards.breaches.multimesh.instance_count == 0 and hazards.air_streams.multimesh.instance_count == 0, "Native breach repair must remove hole and air immediately during pause")
|
||||
var other := ShipModel.new()
|
||||
root.add_child(other)
|
||||
other.build("__damage_fixture__", false)
|
||||
other.set_process(false)
|
||||
other.apply_live(state)
|
||||
_check(not other.room_nodes[id].has_node("SystemCondition"), "Player rooms must also omit system health bars")
|
||||
_check(other.room_hazards.breaches.multimesh.mesh == breach_mesh, "Breach crosses across ships must reuse one shared mesh")
|
||||
state.system_status = []
|
||||
ship.apply_live(state)
|
||||
_check(area.get_node("SystemRole").modulate == Color("fff2c4") and not area.has_node("SystemCondition"), "Missing native health must clear stale damage colour without showing counters")
|
||||
print("DAMAGE_VISUAL_TESTS ", "PASS" if failures == 0 else "FAIL %d" % failures)
|
||||
ship.queue_free()
|
||||
other.queue_free()
|
||||
await process_frame
|
||||
quit(0 if failures == 0 else 1)
|
||||
@@ -0,0 +1,172 @@
|
||||
extends SceneTree
|
||||
|
||||
const Anchor = preload("res://scripts/hud_anchor.gd")
|
||||
var failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
|
||||
func check(value: bool, message: String) -> void:
|
||||
if not value:
|
||||
failures += 1
|
||||
push_error(message)
|
||||
|
||||
|
||||
func _check_clearance(scene: Node3D, message: String) -> void:
|
||||
var ship := Anchor.transformed_bounds(scene._player_hud_local_bounds(), scene.player_ship.global_transform)
|
||||
var panel := Anchor.panel_bounds(scene.hud_surface.global_transform, scene.hud_surface.surface_size)
|
||||
check(not Anchor.overlaps_ship(scene.hud_surface.global_transform, scene.hud_surface.surface_size, scene.player_ship.global_transform, scene._player_hud_local_bounds(), Anchor.CLEARANCE - 0.001), message)
|
||||
if scene.hud_anchor.blocked:
|
||||
check(panel.position.y >= ship.end.y + Anchor.CLEARANCE - 0.001, "A blocked HUD must stop entirely above the transformed ship/shield volume")
|
||||
|
||||
|
||||
func run() -> void:
|
||||
var scene := load("res://tools/input_harness.gd").new() as Node3D
|
||||
root.add_child(scene)
|
||||
await process_frame
|
||||
scene.set_process(false)
|
||||
scene.hud_surface.set_process(false)
|
||||
scene.demo_mode = false
|
||||
scene.xr_active = true
|
||||
scene.origin.transform = Transform3D.IDENTITY
|
||||
scene.camera.transform = Transform3D(Basis.IDENTITY, Vector3(0, 1.7, 1.3))
|
||||
scene.hud_state = {"ready": true, "ui_mode": "game", "paused": true}
|
||||
scene._recenter()
|
||||
scene._update_hud()
|
||||
scene._position_hud(0.0, true)
|
||||
check(scene.hud_surface.get_parent() == scene and scene.gameplay_hud, "Gameplay HUD must permit collision-clamped headset-relative placement")
|
||||
check(scene.hud_surface.source_rect == Rect2(0, 0, 872, 510), "Gameplay must retain upper status and complete crew roster while excluding the entire bottom HUD")
|
||||
check(is_equal_approx(scene.hud_surface.global_basis.x.length(), 1.0) and is_equal_approx(scene.hud_surface.surface_size.x, 1.10), "HUD readability must use fixed world metres, independent of tabletop scale")
|
||||
check(scene.pause_label.get_parent() == scene.hud_surface and scene.pause_label.visible and scene.pause_label.position.y > scene.hud_surface.surface_size.y / 2, "Pause must remain visible above the compact HUD after the lower UI is removed")
|
||||
check(scene.tracking_label.get_parent() == scene.camera and scene.tracking_label.position.x > 0 and scene.tracking_label.position.y > -.5, "Modifier/controller status must stay accessible at the lower right of the view")
|
||||
_check_clearance(scene, "Initial HUD must clear the hull and shields")
|
||||
check(scene.hud_surface.global_position.z < scene.camera.global_position.z, "Initial HUD should remain in front of the headset")
|
||||
# Move the table out of the viewer's path. The head-relative HUD must follow
|
||||
# position, pitch, yaw and roll instead of staying anchored to that table.
|
||||
scene.tabletop_root.position = Vector3(8, 4, 8)
|
||||
scene.hud_anchor.reset()
|
||||
scene._position_hud(0.0, true)
|
||||
check(not scene.hud_anchor.blocked and scene.hud_surface.global_position.is_equal_approx(scene.camera.get_camera_transform() * Anchor.HEAD_OFFSET), "Empty space must use the headset-relative pose even if a distant ship is higher")
|
||||
scene.camera.position += Vector3(.02, .01, .01)
|
||||
scene.camera.rotation = Vector3(-.2, .25, .12)
|
||||
scene._position_hud(1.0 / 90)
|
||||
check(scene.hud_surface.global_position.is_equal_approx(scene.camera.get_camera_transform() * Anchor.HEAD_OFFSET), "Unobstructed HUD must follow headset motion immediately")
|
||||
check(scene.hud_surface.global_basis.is_equal_approx(scene.camera.get_camera_transform().basis), "Unobstructed HUD rotation must preserve headset-relative framing")
|
||||
scene.camera.rotation = Vector3.ZERO
|
||||
scene._recenter()
|
||||
scene.camera.rotation.x = -.85
|
||||
scene._position_hud(0.0, true)
|
||||
check(scene.hud_anchor.blocked and scene.hud_anchor.lift > 0, "Looking down toward the ship must engage the HUD height stop")
|
||||
_check_clearance(scene, "Looking down must not tunnel the HUD through the ship")
|
||||
var clearance_lift: float = scene.hud_anchor.lift
|
||||
scene.camera.rotation.y = PI
|
||||
scene._position_hud(1.0 / 90)
|
||||
check(not scene.hud_anchor.blocked and scene.hud_anchor.lift > 0 and scene.hud_anchor.lift < clearance_lift, "Turning away must release the height stop smoothly")
|
||||
for frame in range(180):
|
||||
scene._position_hud(1.0 / 90)
|
||||
check(scene.hud_anchor.lift == 0 and scene.hud_surface.global_position.is_equal_approx(scene.camera.get_camera_transform() * Anchor.HEAD_OFFSET), "Once clear, the HUD must return fully to its headset anchor")
|
||||
scene.camera.rotation = Vector3.ZERO
|
||||
scene._recenter()
|
||||
for scale_value in [.2, .35, .8, 1.5]:
|
||||
for rotation_value in [Vector3.ZERO, Vector3(0, 1.2, 0), Vector3(.4, .7, .3)]:
|
||||
scene.tabletop_root.basis = Basis.from_euler(rotation_value).scaled(Vector3.ONE * scale_value)
|
||||
for height in [-.6, .0, .8]:
|
||||
scene.tabletop_root.position += Vector3(.3, height, -.1)
|
||||
for frame in range(10):
|
||||
scene._position_hud(1.0 / 90)
|
||||
_check_clearance(scene, "Scaled/rotated/grabbed ship must never intersect the HUD during smoothing")
|
||||
check(scene.hud_surface.global_transform.is_finite(), "Extreme placement fallback must stay finite")
|
||||
# Cropping changes geometry only, preserving every retained native pixel.
|
||||
var image := Image.create(1280, 720, false, Image.FORMAT_RGBA8)
|
||||
image.fill(Color.WHITE)
|
||||
scene.hud_surface.canvas.frame_image = image
|
||||
for native in [Vector2i(35, 30), Vector2i(580, 50), Vector2i(50, 470)]:
|
||||
var world: Vector3 = scene.hud_surface.pixel_world(native)
|
||||
var normal: Vector3 = scene.hud_surface.global_basis.z.normalized()
|
||||
check(scene.hud_surface.ray_pixel(world + normal, -normal) == native, "Top/roster ray picks must preserve original native coordinates under rotation")
|
||||
var removed_world: Vector3 = scene.hud_surface.pixel_world(Vector2i(400, 670))
|
||||
check(scene.hud_surface.ray_pixel(removed_world + scene.hud_surface.global_basis.z, -scene.hud_surface.global_basis.z) == null, "Removed lower HUD must not intercept pointing at the world")
|
||||
var event_crop: Rect2 = scene.event_surface.source_rect
|
||||
var panel_crop: Rect2 = scene.world_surface.source_rect
|
||||
scene.hud_state.event_open = true
|
||||
scene.hud_state.panel_open = true
|
||||
scene._sync_hud_layout()
|
||||
check(scene.gameplay_hud and scene.event_surface.source_rect == event_crop and scene.world_surface.source_rect == panel_crop, "Gameplay top crop must not alter full native dialog/store/ship panel content")
|
||||
scene.hud_state = {"ready": false, "ui_mode": "menu"}
|
||||
scene._sync_hud_layout()
|
||||
scene._position_hud(0.0)
|
||||
check(not scene.gameplay_hud and scene.hud_surface.source_rect == Rect2(0, 0, 1280, 720), "Main menus must retain the entire original native screen")
|
||||
var expected: Vector3 = scene.camera.get_camera_transform() * Vector3(0, -.16, -1.8)
|
||||
check(scene.hud_surface.global_position.is_equal_approx(expected), "Full main menus must retain the original viewer-relative readable framing")
|
||||
scene.xr_active = false
|
||||
for mode in ["map_open", "tactical"]:
|
||||
scene.hud_state = {"ready": true, "ui_mode": "game", mode: true}
|
||||
scene._sync_hud_layout()
|
||||
check(not scene.gameplay_hud and scene.hud_surface.source_rect.size == Vector2(1280, 720), "Desktop native map/Tactical display must preserve full content and picking")
|
||||
scene.xr_active = true
|
||||
scene.hud_state = {"ready": true, "ui_mode": "game", "tactical": true}
|
||||
scene._sync_hud_layout()
|
||||
check(scene.gameplay_hud, "VR Tactical screen must retain the headset HUD while its hand screen shows full Tactical content")
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await _capture(scene)
|
||||
scene.queue_free()
|
||||
print("HUD_LAYOUT_TESTS ", "PASS" if failures == 0 else "FAIL %d" % failures)
|
||||
quit(0 if failures == 0 else 1)
|
||||
|
||||
|
||||
func rendered() -> void:
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
|
||||
|
||||
func _capture(scene: Node3D) -> void:
|
||||
scene.camera.rotation.x = -.28
|
||||
scene._recenter()
|
||||
scene._position_hud(0.0, true)
|
||||
scene.pause_label.visible = false
|
||||
scene.hud_surface.canvas.use_original_frame = false
|
||||
# Color bands make a removed lower HUD an objective GPU check, and retained
|
||||
# roster/native positions independently test the crop rather than its math.
|
||||
var image := Image.create(1280, 720, false, Image.FORMAT_RGBA8)
|
||||
image.fill(Color.TRANSPARENT)
|
||||
image.fill_rect(Rect2i(0, 0, 872, 100), Color.GREEN)
|
||||
image.fill_rect(Rect2i(0, 150, 100, 340), Color.BLUE)
|
||||
image.fill_rect(Rect2i(0, 550, 1280, 170), Color.RED)
|
||||
scene.hud_surface.canvas._accept_frame(image, "hud-layout-fixture")
|
||||
await rendered()
|
||||
var output: Image = scene.hud_surface.viewport.get_texture().get_image()
|
||||
check(output.get_pixel(600, 50).g > .8 and output.get_pixel(30, 650).b > .8, "GPU top crop must preserve both upper status and the long crew roster")
|
||||
var red_pixels := 0
|
||||
for y in range(0, output.get_height(), 5):
|
||||
for x in range(0, output.get_width(), 5):
|
||||
var pixel := output.get_pixel(x, y)
|
||||
if pixel.r > .8 and pixel.g < .2 and pixel.b < .2: red_pixels += 1
|
||||
check(red_pixels == 0, "No lower HUD pixels may leak into the cropped GPU texture")
|
||||
scene.hud_state = {"ready": false, "ui_mode": "menu"}
|
||||
scene._sync_hud_layout()
|
||||
await rendered()
|
||||
output = scene.hud_surface.viewport.get_texture().get_image()
|
||||
check(output.get_pixel(600, 650).r > .8, "Full native menus must retain their lower content after returning from gameplay crop")
|
||||
scene.hud_state = {"ready": true, "ui_mode": "game"}
|
||||
scene._sync_hud_layout()
|
||||
scene._position_hud(0.0, true)
|
||||
# Prefer an immutable native QA snapshot so a concurrent bridge frame cannot
|
||||
# change the fixture between its load and visual inspection.
|
||||
var native_path := "res://local_game_data/speed-tactical-return-hud_frame.png"
|
||||
if FileAccess.file_exists(native_path):
|
||||
var native := Image.new()
|
||||
native.load_png_from_buffer(FileAccess.get_file_as_bytes(native_path))
|
||||
scene.hud_surface.canvas._accept_frame(native, "hud-layout-owned-native")
|
||||
await rendered()
|
||||
scene.hud_surface.viewport.get_texture().get_image().save_png("res://local_game_data/hud-top-spatial-native.png")
|
||||
root.get_texture().get_image().save_png("res://local_game_data/hud-top-spatial-scene.png")
|
||||
# The fallback/demo uses the same crop; bottom weapon/reactor rows stay out.
|
||||
scene.hud_surface.canvas.original_frame = null
|
||||
scene.hud_surface.canvas.set_state({"crew": [{"name": "Roster test", "health": 100}], "paused": false})
|
||||
scene.hud_surface.canvas.request_redraw()
|
||||
await rendered()
|
||||
output = scene.hud_surface.viewport.get_texture().get_image()
|
||||
output.save_png("res://local_game_data/hud-top-spatial-demo.png")
|
||||
check(output.get_pixel(30, 690).a < .1, "Fallback/demo bottom power/reactor must also disappear under the native crop")
|
||||
+34
-2
@@ -102,7 +102,10 @@ func run() -> void:
|
||||
check(scene.map_surface.ray_pixel(scene.map_surface.to_global(Vector3(0,0,1)), -scene.map_surface.global_basis.z) != null,"World jump map must stay usable when hand tracking is hidden")
|
||||
var map: OpenXRActionMap = load("res://openxr_action_map.tres")
|
||||
check(map.find_interaction_profile("/interaction_profiles/valve/frame_controller_valve") != null,"Explicit Frame profile must ship with the client")
|
||||
check(is_equal_approx(scene.nav_panel.scale.x, 0.6) and is_equal_approx(scene.hud_surface.scale.x, 0.64) and scene.hud_surface.position.y < -0.1,"HUD must be lower and narrower while retaining the hand panel size")
|
||||
scene.hud_state = {"ready":true,"ui_mode":"game"}
|
||||
scene._sync_hud_layout()
|
||||
scene._position_hud(0.0, true)
|
||||
check(is_equal_approx(scene.nav_panel.scale.x, 0.6) and scene.hud_surface.get_parent()==scene and scene.hud_surface.source_rect==scene.GAMEPLAY_HUD_RECT,"Gameplay HUD must retain native upper/crew pixels, exclude the bottom strip and use independent world placement")
|
||||
check(scene.hud_surface.render_order == 100 and scene.panel_theme.get_child(1).material_override.render_priority > scene.pause_label.render_priority and scene.tracking_label.render_priority < 120,"Hand screen must render over all HUD layers, including pause and modifier labels")
|
||||
check(scene.help_label.get_script().resource_path.ends_with("controller_help.gd"),"Help must use a controller infographic instead of plain text")
|
||||
check(scene.hover_marker.mesh.radius <= 0.003 and scene.right_laser.mesh.top_radius <= 0.001,"Pointer endpoint must support precise native UI selection")
|
||||
@@ -110,8 +113,10 @@ func run() -> void:
|
||||
scene._face_front(scene.wheel)
|
||||
var to_head: Vector3 = (scene.camera.get_camera_transform().origin-scene.wheel.global_position).normalized()
|
||||
check(scene.wheel.global_basis.z.dot(to_head) > 0.999 and scene.wheel.global_basis.z.y > 0,"Wheel face must tilt up toward the real viewer rather than the ground")
|
||||
scene.hud_state = {"ready":true,"ui_mode":"game","navigation":{"jump":true},"player":{"weapons":[{}],"systems":{"shields":true}},"drone_slots":1}
|
||||
scene.hud_state = {"ready":true,"ui_mode":"game","navigation":{"jump":true},"player":{"weapons":[{"slot":0,"name":"LASER_BURST_1"}],"systems":{"shields":true}},"drone_slots":1}
|
||||
scene.nav_page = "navigation"
|
||||
scene._refresh_navigation()
|
||||
check(scene._navigation_entries().has(["SYSTEM POWER", "page:power"]),"System Power must have a direct Navigation entry")
|
||||
scene._cycle_hand_page(1)
|
||||
check(scene.nav_page == "tactical" and scene.commands.back().data.screen == "tactical","Dpad right must cycle into tactical")
|
||||
scene.hud_state.tactical = true
|
||||
@@ -133,6 +138,26 @@ func run() -> void:
|
||||
var command_count: int = scene.commands.size()
|
||||
scene._commit_wheel()
|
||||
check(scene.commands.back().action == "event_choice" and scene.commands.size() == command_count,"Event wheel must commit once")
|
||||
var saved_state: Dictionary = scene.hud_state.duplicate(true)
|
||||
scene.hud_state = {"ready":true,"ui_mode":"game"}
|
||||
scene.pending_page = ""
|
||||
scene.nav_page = "navigation"
|
||||
scene._toggle_power_page()
|
||||
check(scene.nav_page=="power","Dpad Up action must open System Power")
|
||||
scene._toggle_power_page()
|
||||
check(scene.nav_page=="navigation","Dpad Up action must close System Power")
|
||||
scene.hud_state.ui_mode = "menu"
|
||||
scene._toggle_power_page()
|
||||
check(scene.nav_page=="navigation","Dpad Up must not open unusable Power controls in a main menu")
|
||||
scene.hud_state = {"ready":true,"ui_mode":"screen","map_open":true}
|
||||
scene.wheel_committed = false
|
||||
scene.wheel.visible = true
|
||||
scene.wheel.set_entries([{"key":49,"enabled":true}],"WEAPONS")
|
||||
scene.wheel.selected = 0
|
||||
command_count = scene.commands.size()
|
||||
scene._commit_wheel()
|
||||
check(scene.commands.size()==command_count and not scene.wheel.visible,"Opening a native map must cancel equipment wheel commits")
|
||||
scene.hud_state = saved_state
|
||||
scene.event_surface.visible = true
|
||||
scene.event_surface.canvas.frame_image = image
|
||||
scene.event_surface.source_rect = Rect2(313,138,650,390)
|
||||
@@ -160,6 +185,13 @@ func run() -> void:
|
||||
scene._select_from_ray(Vector3(0,0,1),Vector3.FORWARD)
|
||||
check(scene.held_surface == null,"Blank hand panel pixels must block clicks into the HUD behind them")
|
||||
scene.nav_panel.visible = false
|
||||
scene.nav_page = "power"
|
||||
scene.hud_state = {"ready":true,"ui_mode":"screen","panel_open":true,"blocking_ui":true}
|
||||
scene.last_hover_time = -1.0
|
||||
command_count = scene.commands.size()
|
||||
scene._hover_ui(Vector3(0,0,1),Vector3.FORWARD)
|
||||
check(scene.commands.size()==command_count+1 and scene.commands.back().action=="ui_move","Native window hover must remain available while System Power is selected")
|
||||
scene.nav_page = "navigation"
|
||||
var window_image := Image.create(1280,720,false,Image.FORMAT_RGBA8)
|
||||
var body_image := Image.create(650,520,false,Image.FORMAT_RGBA8)
|
||||
body_image.fill(Color.WHITE)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Read-only launch readiness and Windows wrapper regression checks; no FTL needed."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import launch
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "The live launcher is Windows only")
|
||||
class LaunchSetupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name) / "FTL checkout with spaces"
|
||||
self.root.mkdir()
|
||||
self.lab = self.root / "separate lab"
|
||||
self.lab.mkdir()
|
||||
self.source = self.root / "owned original"
|
||||
self.source.mkdir()
|
||||
(self.source / "ftl.dat").write_bytes(b"synthetic archive placeholder")
|
||||
(self.lab / "FTLGame.exe").write_bytes(b"synthetic executable placeholder")
|
||||
(self.lab / "Hyperspace.dll").write_bytes(b"synthetic loader placeholder")
|
||||
self.marker = {"source_game": str(self.source), "activated": True, "save_prefix": "ftlvr"}
|
||||
self.write_marker()
|
||||
names = ("GetShiftState", "GetCtrlState", "ForceAutofireFlag", "OnTextInput",
|
||||
"OnTextEvent", "TextInputOnRender", "TextInputStart", "CommandGuiRenderStatic",
|
||||
"CommandGuiRenderPause", "TabbedWindowOnRender", "ChoiceBoxOnRender",
|
||||
"MouseControlOnRender", "StarMapOnRender")
|
||||
self.hooks = self.lab / "hooks.json"
|
||||
self.hooks.write_text(json.dumps({"rvas": dict.fromkeys(names, 1),
|
||||
"sha1": hashlib.sha1((self.lab / "FTLGame.exe").read_bytes()).hexdigest()}))
|
||||
self.godot = self.root / "godot.exe"
|
||||
self.godot.write_bytes(b"synthetic renderer placeholder")
|
||||
self.local = self.root / "local_game_data"
|
||||
self.local.mkdir()
|
||||
self.manifest = self.local / "manifest.json"
|
||||
self.manifest.write_text(json.dumps({"ships": ["test"], "fonts": 1, "ui_assets": 1}))
|
||||
self.config = {"lab": str(self.lab), "hooks": str(self.hooks), "godot": str(self.godot)}
|
||||
self.project_patch = patch.object(launch, "PROJECT", self.root)
|
||||
self.project_patch.start()
|
||||
self.packages_patch = patch.object(launch.importlib.util, "find_spec", return_value=object())
|
||||
self.packages_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.packages_patch.stop()
|
||||
self.project_patch.stop()
|
||||
self.temporary.cleanup()
|
||||
|
||||
def write_marker(self):
|
||||
(self.lab / "ftlvr-lab.json").write_text(json.dumps(self.marker))
|
||||
|
||||
def test_shared_preflight_accepts_complete_desktop_setup(self):
|
||||
self.assertEqual(launch.preflight(self.config, desktop=True)["mode"], "desktop")
|
||||
|
||||
def test_missing_configuration_explains_setup(self):
|
||||
with self.assertRaisesRegex(ValueError, "Run SETUP.cmd"):
|
||||
launch.load_config(self.local / "launcher.json")
|
||||
|
||||
def test_config_accepts_utf8_bom(self):
|
||||
path = self.local / "launcher.json"
|
||||
path.write_text(json.dumps(self.config), encoding="utf-8-sig")
|
||||
self.assertEqual(launch.load_config(path), self.config)
|
||||
|
||||
def test_blank_or_relative_configuration_is_rejected(self):
|
||||
for path, message in (("", "nonempty"), ("godot.exe", "absolute")):
|
||||
with self.subTest(path=path), self.assertRaisesRegex(ValueError, message):
|
||||
launch.preflight(dict(self.config, godot=path), desktop=True)
|
||||
|
||||
def test_game_and_renderer_need_correct_path_types(self):
|
||||
with self.assertRaisesRegex(ValueError, "Missing godot"):
|
||||
launch.preflight(dict(self.config, godot=str(self.root)), desktop=True)
|
||||
|
||||
def test_missing_or_partial_assets_prevent_game_launch(self):
|
||||
self.manifest.unlink()
|
||||
with self.assertRaisesRegex(ValueError, "Local game assets are missing"):
|
||||
launch.preflight(self.config, desktop=True)
|
||||
self.manifest.write_text(json.dumps({"ships": ["test"], "fonts": 0, "ui_assets": 1}))
|
||||
with self.assertRaisesRegex(ValueError, "extraction is incomplete"):
|
||||
launch.preflight(self.config, desktop=True)
|
||||
|
||||
def test_qa_save_prefix_is_rejected_by_normal_launcher(self):
|
||||
self.marker["save_prefix"] = "testvr"
|
||||
self.write_marker()
|
||||
with self.assertRaisesRegex(ValueError, "ftlvr save prefix"):
|
||||
launch.preflight(self.config, desktop=True)
|
||||
|
||||
def test_click_wrappers_report_unconfigured_checkout_without_hanging(self):
|
||||
checkout = self.root / "unconfigured clone"
|
||||
checkout.mkdir()
|
||||
(checkout / "tools").mkdir()
|
||||
actual_project = Path(__file__).resolve().parents[1]
|
||||
helper = actual_project / "tools/show_launcher_error.cmd"
|
||||
shutil.copyfile(helper, checkout / "tools/show_launcher_error.cmd")
|
||||
for name in ("RUN-VR.cmd", "RUN-DESKTOP.cmd", "CHECK-SETUP.cmd"):
|
||||
shutil.copyfile(actual_project / name, checkout / name)
|
||||
environment = dict(os.environ, FTLVR_NO_PAUSE="1")
|
||||
result = subprocess.run([os.environ.get("COMSPEC", "cmd.exe"), "/d", "/c", name],
|
||||
cwd=checkout, env=environment, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 1, name)
|
||||
self.assertIn("SETUP.cmd", result.stdout, name)
|
||||
self.assertIn("Launch stopped", result.stdout, name)
|
||||
|
||||
def test_setup_rejects_microsoft_store_alias(self):
|
||||
checkout = self.root / "store alias clone"
|
||||
checkout.mkdir()
|
||||
(checkout / "tools").mkdir()
|
||||
actual_project = Path(__file__).resolve().parents[1]
|
||||
for name in ("SETUP.cmd", "tools/show_launcher_error.cmd"):
|
||||
shutil.copyfile(actual_project / name, checkout / name)
|
||||
alias = checkout / "fake LocalAppData/Microsoft/WindowsApps"
|
||||
alias.mkdir(parents=True)
|
||||
(alias / "python.exe").touch()
|
||||
environment = dict(os.environ, FTLVR_NO_PAUSE="1", LOCALAPPDATA=str(checkout / "fake LocalAppData"),
|
||||
PATH=str(Path(os.environ["SystemRoot"]) / "System32") + os.pathsep + str(alias))
|
||||
environment.pop("FTLVR_PYTHON", None)
|
||||
result = subprocess.run([os.environ.get("COMSPEC", "cmd.exe"), "/d", "/c", "SETUP.cmd"],
|
||||
cwd=checkout, env=environment, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("Microsoft Store alias is not a Python installation", result.stdout)
|
||||
|
||||
def test_setup_accepts_explicit_python_and_forwards_dry_run(self):
|
||||
checkout = self.root / "explicit Python clone"
|
||||
checkout.mkdir()
|
||||
(checkout / "tools").mkdir()
|
||||
actual_project = Path(__file__).resolve().parents[1]
|
||||
for name in ("SETUP.cmd", "tools/setup_environment.py", "tools/show_launcher_error.cmd"):
|
||||
shutil.copyfile(actual_project / name, checkout / name)
|
||||
environment = dict(os.environ, FTLVR_NO_PAUSE="1", FTLVR_PYTHON=sys.executable)
|
||||
result = subprocess.run([os.environ.get("COMSPEC", "cmd.exe"), "/d", "/c", "SETUP.cmd --dry-run"],
|
||||
cwd=checkout, env=environment, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("Dry run complete", result.stdout)
|
||||
self.assertFalse((checkout / ".venv").exists())
|
||||
self.assertFalse((checkout / "local_game_data").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user