mirror of
https://github.com/ElHombreAlex/FTL-Faster-Than-Light-VR.git
synced 2026-10-04 22: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
@@ -1,5 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## HUD, wheel and launch corrections — source update, 2026-10-03
|
||||
|
||||
- Gameplay HUD follows the headset at a fixed readable size. It clamps above the transformed hull/shield only when its movement would intersect the ship; full menus remain head-relative.
|
||||
- Weapon slots survive an empty native drone-equipment collection. The bridge normalizes optional lists and the wheel accepts empty/malformed collections safely.
|
||||
- Remove all room-floor health/status bars and counters. Native room-role icons, permission-aware condition colors, fog, fire and breach effects remain.
|
||||
- Launch wrappers retain failures on screen and forward command-line options. Setup finds a real Python interpreter, rejects the Windows Store alias and keeps runtime configuration, dependencies and extracted assets local and ignored by Git.
|
||||
|
||||
This source passed 38 Python tests and nine Godot suites in the prepared folder. The desktop and VR wrappers passed combined launch/exit in desktop smoke mode, including the GitHub Desktop checkout's FTL-VR alias. VR preflight passed; a copied campaign verified equipped weapon entries in the rendered wheel without changing the original campaign hashes. Physical headset comfort and stereo performance remain to test. See [Project status](docs/STATUS.md) for evidence and limits. Earlier test counts and native checks below refer to their respective revisions.
|
||||
|
||||
## Gameplay refinements — preceding source update, 2026-10-03
|
||||
|
||||
- Compact original status/crew HUD above the ship; the lower strip is replaced by dedicated Power/Shortcuts/wheel access while full native menus remain available.
|
||||
- Dpad Up toggles System Power, with a direct System Power button on Navigation.
|
||||
- Reactor free power excludes environmental cap loss; unavailable bars and battery availability are displayed separately.
|
||||
- Wheel slots show localized equipped names, weapon-family silhouettes and actual ammo-cost hints, with bounded names and unchanged-content rendering.
|
||||
- Native permission-aware room condition colors and detailed health bars supplement system role icons.
|
||||
- Native evasion produces a short MISS cue and pass-by trajectory; breach holes use shallow black crosses with oxygen-dependent air wisps.
|
||||
|
||||
This revision passed 27 Python tests and nine Godot suites. Focused native storm power, localized equipment/ammo, enemy damage/repair, breach tiles and forced missed-event checks passed. Combined production Vulkan smoke passed with clean logs, no bridge errors, backed-up campaign hashes preserved and no remaining owned game/client processes; physical headset checks remained pending. Historical results below belong to previous revisions.
|
||||
|
||||
## 0.1.0-alpha.1 — prepared source snapshot, 2026-10-03
|
||||
|
||||
The first standalone community source package. This records the current prototype; it does not claim a completed public release.
|
||||
|
||||
+5
-2
@@ -3,7 +3,10 @@ setlocal
|
||||
cd /d "%~dp0"
|
||||
if not exist ".venv\Scripts\python.exe" (
|
||||
echo Run SETUP.cmd first. This check does not install or launch anything.
|
||||
call tools\show_launcher_error.cmd
|
||||
exit /b 1
|
||||
)
|
||||
".venv\Scripts\python.exe" tools\setup_environment.py --check %*
|
||||
exit /b
|
||||
".venv\Scripts\python.exe" -u tools\setup_environment.py --check %*
|
||||
set "FTLVR_EXIT=%errorlevel%"
|
||||
if not "%FTLVR_EXIT%"=="0" call tools\show_launcher_error.cmd
|
||||
exit /b %FTLVR_EXIT%
|
||||
@@ -11,11 +11,11 @@ An experimental OpenXR mod for **FTL: Faster Than Light**. Walk around a miniatu
|
||||
## What it does
|
||||
|
||||
- Floating player and enemy ships with opposing bows, room interiors, visible shields and movable encounter placement.
|
||||
- Original headset-following HUD, with a compact controller screen for navigation, Tactical, shortcuts, system power and illustrated Help.
|
||||
- Compact original HUD following the headset, with clearance above the ship when needed; upper status and crew roster, controller navigation, Tactical, shortcuts, system power and illustrated Help.
|
||||
- Crew selection and drag-to-room orders, door controls, weapon/beam targeting and native system targeting.
|
||||
- Contextual Jump/Ship/Store actions, a cockpit-anchored sector map, floating event dialogs and complete native shop/ship panels.
|
||||
- An eight-slot action wheel, Shift/Ctrl shortcuts and a QWERTY VR keyboard for renaming crew and ships.
|
||||
- Original procedural voxel crew, weapons and drones, weapon charge indicators, room fire and breach effects.
|
||||
- An eight-slot action wheel with localized equipped names, family/ammo hints, Shift/Ctrl shortcuts and a QWERTY VR keyboard for renaming crew and ships.
|
||||
- Original procedural voxel crew, weapons and drones, weapon charge indicators, room-condition feedback, cross-shaped breaches and native miss cues.
|
||||
- Surrounding stars and native-state-driven asteroid, sun, nebula/storm and pulsar environments.
|
||||
- A separate game copy and VR save prefix, plus a backup before each normal launch.
|
||||
|
||||
@@ -66,11 +66,11 @@ The [controls guide](docs/CONTROLS.md) explains crew movement, room targeting, t
|
||||
|
||||
## Current state
|
||||
|
||||
The maintainer has played substantial portions of runs on Steam Frame and confirmed that earlier revisions are playable. The latest revision improves Tactical delivery, native-font menus, crew orientation, doors, drones, the enemy hull bar and map placement.
|
||||
The maintainer has played substantial portions of runs on Steam Frame and confirmed that earlier revisions are playable. The compact gameplay HUD now follows the headset first and stops above the ship only when it would intersect the hull or shield. Equipped weapon entries remain available on the wheel when no drones are installed. Room floors retain role icons and condition colors; health/status bars and counters are removed. System Power, storm-aware free power, native miss feedback and cross-shaped breaches remain available.
|
||||
|
||||
Core game input, capture, native state and graceful launch/exit have been verified in an isolated game. **Complete campaign coverage and measured stereo headset performance remain open.** Hacking target acceptance is verified; drone attachment/effect completion still needs encounter testing. The renderer presents hazards and some effects approximately while the original game remains authoritative.
|
||||
|
||||
Latest recorded checks: **27 Python tests and seven Godot suites passed**. Tactical delivery reached about **28–29 updates/second in desktop scene checks**; this is texture delivery, not headset FPS. A clean installation on a different machine has not yet been tested. See [STATUS.md](docs/STATUS.md) for the full distinction between implemented, checked and still to test.
|
||||
The **current source** passed 38 Python tests and nine Godot suites in the prepared source folder. Desktop and VR wrappers passed combined launch/exit in desktop smoke mode, including the GitHub Desktop checkout's FTL-VR alias, with clean client logs and no bridge errors; a separate copied campaign also verified native weapon entries in the rendered wheel. VR preflight passed, while physical Steam Frame comfort and stereo performance remain unmeasured for these changes. Campaign hashes and game executable fingerprints were preserved. Historical Tactical delivery reached about **28–29 updates/second in desktop scene checks**; this is texture delivery, not headset FPS. A clean installation on a different machine has not yet been tested. See [STATUS.md](docs/STATUS.md) for details.
|
||||
|
||||
## Repository layout
|
||||
|
||||
|
||||
+5
-4
@@ -3,9 +3,10 @@ setlocal
|
||||
cd /d "%~dp0"
|
||||
if not exist ".venv\Scripts\python.exe" (
|
||||
echo Run SETUP.cmd and complete docs\INSTALLATION.md first.
|
||||
call tools\show_launcher_error.cmd
|
||||
exit /b 1
|
||||
)
|
||||
".venv\Scripts\python.exe" tools\setup_environment.py --check --desktop
|
||||
if errorlevel 1 exit /b 1
|
||||
".venv\Scripts\python.exe" tools\launch.py --desktop
|
||||
exit /b
|
||||
".venv\Scripts\python.exe" -u tools\launch.py --desktop %*
|
||||
set "FTLVR_EXIT=%errorlevel%"
|
||||
if not "%FTLVR_EXIT%"=="0" call tools\show_launcher_error.cmd
|
||||
exit /b %FTLVR_EXIT%
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
call "%~dp0RUN-DESKTOP.cmd" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
call "%~dp0RUN-VR.cmd" %*
|
||||
exit /b %errorlevel%
|
||||
+6
-5
@@ -2,10 +2,11 @@
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
if not exist ".venv\Scripts\python.exe" (
|
||||
echo Run SETUP.cmd and complete docs\INSTALLATION.md first.
|
||||
echo This checkout is not configured. Run SETUP.cmd and complete docs\INSTALLATION.md first.
|
||||
call tools\show_launcher_error.cmd
|
||||
exit /b 1
|
||||
)
|
||||
".venv\Scripts\python.exe" tools\setup_environment.py --check
|
||||
if errorlevel 1 exit /b 1
|
||||
".venv\Scripts\python.exe" tools\launch.py
|
||||
exit /b
|
||||
".venv\Scripts\python.exe" -u tools\launch.py %*
|
||||
set "FTLVR_EXIT=%errorlevel%"
|
||||
if not "%FTLVR_EXIT%"=="0" call tools\show_launcher_error.cmd
|
||||
exit /b %FTLVR_EXIT%
|
||||
@@ -1,20 +1,34 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
if exist ".venv\Scripts\python.exe" (
|
||||
".venv\Scripts\python.exe" tools\setup_environment.py %*
|
||||
exit /b
|
||||
)
|
||||
if exist ".venv\Scripts\python.exe" goto environment
|
||||
if defined FTLVR_PYTHON goto configured_python
|
||||
where py >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
py -3 tools\setup_environment.py %*
|
||||
exit /b
|
||||
)
|
||||
where python >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
python tools\setup_environment.py %*
|
||||
exit /b
|
||||
py -3 -u tools\setup_environment.py %*
|
||||
goto result
|
||||
)
|
||||
for /f "delims=" %%P in ('where python 2^>nul') do if /i not "%%P"=="%LOCALAPPDATA%\Microsoft\WindowsApps\python.exe" if not defined FTLVR_PYTHON set "FTLVR_PYTHON=%%P"
|
||||
if defined FTLVR_PYTHON goto configured_python
|
||||
echo Python was not found. Install Python 3.10 or newer from python.org, including its launcher.
|
||||
echo The Microsoft Store alias is not a Python installation.
|
||||
echo See docs\INSTALLATION.md. No game files have been changed.
|
||||
call tools\show_launcher_error.cmd
|
||||
exit /b 1
|
||||
|
||||
:environment
|
||||
".venv\Scripts\python.exe" -u tools\setup_environment.py %*
|
||||
goto result
|
||||
|
||||
:configured_python
|
||||
if not exist "%FTLVR_PYTHON%" (
|
||||
echo FTLVR_PYTHON does not point to a Python executable.
|
||||
call tools\show_launcher_error.cmd
|
||||
exit /b 1
|
||||
)
|
||||
"%FTLVR_PYTHON%" -u tools\setup_environment.py %*
|
||||
|
||||
:result
|
||||
set "FTLVR_EXIT=%errorlevel%"
|
||||
if not "%FTLVR_EXIT%"=="0" call tools\show_launcher_error.cmd
|
||||
exit /b %FTLVR_EXIT%
|
||||
+42
-3
@@ -54,6 +54,12 @@ end
|
||||
local function point(p) return {x=p.x, y=p.y} end
|
||||
local function distance(a,b) return math.sqrt((a.x-b.x)^2+(a.y-b.y)^2) end
|
||||
|
||||
local function blueprint_title(blueprint, short)
|
||||
return attempt(short and 'blueprint_short_title' or 'blueprint_title', function()
|
||||
return short and blueprint:GetNameShort() or blueprint:GetNameLong()
|
||||
end, blueprint.name)
|
||||
end
|
||||
|
||||
local function drone_snapshot(drone, slot, is_space)
|
||||
local id=tostring(drone.selfId)
|
||||
if drone.selfId<0 then
|
||||
@@ -101,8 +107,12 @@ local function ship_snapshot(ship, gui)
|
||||
local graph = Hyperspace.ShipGraph.GetShipInfo(ship.iShipId)
|
||||
local result = {layout=ship.myBlueprint.layoutFile, image=ship.myBlueprint.imgFile,
|
||||
hull=ship.ship.hullIntegrity.first, hull_max=ship.ship.hullIntegrity.second,
|
||||
destroyed=ship.bDestroyed, rooms={}, crew={}, weapons={}, doors={}, drones={},
|
||||
destroyed=ship.bDestroyed, rooms={}, crew={}, weapons={}, doors={}, drones={},drone_equipment={},
|
||||
systems={}, system_power={}, system_status={}, room_systems={}}
|
||||
-- Native sensor_4 permits enemy power bars; sensor_2 only reveals rooms.
|
||||
-- Condition colours remain public, just as the native target icons do.
|
||||
local viewer=Hyperspace.ships.player
|
||||
local detailed=ship.iShipId==0 or (viewer and viewer:GetSystemPower(7)>=4)
|
||||
for id=0,15 do
|
||||
local name=system_names[id]
|
||||
if ship:HasSystem(id) then
|
||||
@@ -114,6 +124,9 @@ local function ship_snapshot(ship, gui)
|
||||
effective_power=system:GetEffectivePower(),health=system.healthState.first,
|
||||
max_health=system.healthState.second,
|
||||
damaged=system.healthState.second-system.healthState.first,
|
||||
health_visible=true,health_detail_visible=detailed or
|
||||
(system.iHackEffect>0 and system.bUnderAttack),
|
||||
repair_progress=math.min(1,math.max(0,system.fRepairOverTime/100)),
|
||||
ionized=system.iLockCount>0,locked=system:GetLocked(),hacked=system.iHackEffect,
|
||||
powerable=system:GetNeedsPower(),power_cap=system:GetPowerCap(),
|
||||
battery_power=system.iBatteryPower,bonus_power=system.iBonusPower,
|
||||
@@ -123,9 +136,19 @@ local function ship_snapshot(ship, gui)
|
||||
end
|
||||
end
|
||||
local reactor=Hyperspace.PowerManager.GetPowerManager(ship.iShipId)
|
||||
result.reactor={available=reactor:GetAvailablePower(),total=reactor:GetMaxPower(),
|
||||
local installed=reactor.currentPower.second
|
||||
local total=math.max(0,reactor:GetMaxPower())
|
||||
local raw_available=reactor:GetAvailablePower()
|
||||
local cap_loss=math.max(0,installed-total)
|
||||
-- GetAvailablePower is installed minus allocated, before environmental
|
||||
-- limits. Use the same subtraction as native RenderPowerBar; battery
|
||||
-- remains separate so free reactor bars cannot be counted twice.
|
||||
local usable_available=math.min(total,math.max(0,raw_available-cap_loss))
|
||||
result.reactor={available=usable_available,total=total,
|
||||
usable_available=usable_available,usable_total=total,raw_available=raw_available,
|
||||
cap_loss=cap_loss,storm_loss=Hyperspace.App.world.space.bStorm and cap_loss or 0,
|
||||
used=reactor.currentPower.first,installed=reactor.currentPower.second,
|
||||
battery_available=reactor.batteryPower.second-reactor.batteryPower.first,
|
||||
battery_available=math.max(0,reactor.batteryPower.second-reactor.batteryPower.first),
|
||||
battery_total=reactor.batteryPower.second}
|
||||
local breaches=ship.ship:GetHullBreaches(true)
|
||||
local shield = ship:GetShieldPower()
|
||||
@@ -206,6 +229,9 @@ local function ship_snapshot(ship, gui)
|
||||
local drones=ship:GetDroneList()
|
||||
for i=0,drones:size()-1 do
|
||||
local drone=drones[i]
|
||||
result.drone_equipment[#result.drone_equipment+1]={slot=i,name=drone.blueprint.name,
|
||||
title=blueprint_title(drone.blueprint,false),short_title=blueprint_title(drone.blueprint,true),
|
||||
kind=drone.blueprint.typeName,powered=drone.powered,deployed=drone.deployed}
|
||||
-- Hyperspace casts Drone* to its real subclass. Ordinary flying
|
||||
-- equipment drones must be included here as well as special vectors.
|
||||
if drone.type==2 or drone.type==3 then
|
||||
@@ -225,6 +251,8 @@ local function ship_snapshot(ship, gui)
|
||||
if kind=='burst' then kind='flak' end
|
||||
if weapon.blueprint.damage.iIonDamage>0 then kind='ion' end
|
||||
result.weapons[#result.weapons+1] = {slot=i, name=weapon.blueprint.name,
|
||||
title=blueprint_title(weapon.blueprint,false),short_title=blueprint_title(weapon.blueprint,true),
|
||||
ammo_cost=weapon.blueprint.missiles,
|
||||
kind=kind, powered=weapon.powered, autofire=weapon.autoFiring, charge=weapon.cooldown.first,
|
||||
cooldown=weapon.cooldown.second, mount=point(weapon.mount.position),
|
||||
charge_fraction=weapon.cooldown.second>0 and math.min(1,math.max(0,weapon.cooldown.first/weapon.cooldown.second)) or 1,
|
||||
@@ -440,6 +468,17 @@ script.on_internal_event(Defines.InternalEvents.DRONE_FIRE, function(projectile,
|
||||
return Defines.Chain.CONTINUE
|
||||
end)
|
||||
|
||||
-- A missed projectile can leave native space between the 10 Hz snapshots.
|
||||
-- Observe the real flag after every native update and retain the result once.
|
||||
script.on_internal_event(Defines.InternalEvents.PROJECTILE_UPDATE_POST,function(projectile)
|
||||
local record=projectile_ids[projectile.selfId]
|
||||
if record and projectile.missed and not record.miss_reported then
|
||||
record.miss_reported=true
|
||||
impact(projectile,'miss',projectile.targetId,projectile.target)
|
||||
end
|
||||
return Defines.Chain.CONTINUE
|
||||
end)
|
||||
|
||||
script.on_internal_event(Defines.InternalEvents.SHIELD_COLLISION, function(ship, projectile, damage, response)
|
||||
-- Actual collision responses are retained; the renderer never decides damage.
|
||||
if projectile and response.collision_type==2 then
|
||||
|
||||
@@ -44,13 +44,17 @@ Godot uses this state to place and animate visual actors. Native positions, curr
|
||||
|
||||
Projectiles originate from native weapon/drone locations and follow native shot information. Rendering does not determine hits or damage. Pause freezes presentation clocks while keeping actual native paused projectiles visible and removing stale shots. Environmental animation conveys the current hazard; exact hazard timing remains a polish/verification area.
|
||||
|
||||
The native missed flag and a post-update event feed evasion feedback so brief missed shots can produce a single MISS cue even between snapshot deliveries. Room condition colors respect native information permissions; role icons and hazards remain, without floor health/status bars or counters. Reactor snapshots distinguish raw availability from usable availability after native capacity/environmental limits; battery availability is separate. Equipped localized weapon/drone metadata is distinct from deployed drone actors, keeping wheel labels tied to actual shortcut slots. Optional collections are normalized by the bridge and parsed defensively by the wheel: Lua may encode an empty table as `{}` rather than `[]`.
|
||||
|
||||
## Original HUD and floating windows
|
||||
|
||||
The game renders its original HUD into a separate native framebuffer. This retains native fonts, localization, power widgets, tooltips and values even when the normal backbuffer displays Tactical, the map or a window. The bridge removes unwanted world/enemy HUD pixels for the headset layer and preserves complete native window content for floating panels.
|
||||
The game renders its original HUD into a separate native framebuffer. This retains native fonts, localization and values even when the normal backbuffer displays Tactical, the map or a window. The bridge removes unwanted world/enemy HUD pixels and preserves complete native window content for floating panels. Gameplay crops the upper native status and crew region to a fixed-size headset-relative panel, omitting the lower strip; Power/Shortcuts/the wheel supply those actions. Main menus and desktop Tactical/map views retain full framing.
|
||||
|
||||
[hud_anchor.gd](../scripts/hud_anchor.gd) gives the headset-relative pose priority and keeps fixed metre dimensions for readable text. Collision checks use the transformed hull/shield envelope and the panel's swept movement. Only a potential intersection engages the above-ship clamp; looking down cannot pass the HUD through the ship. The panel returns to headset-relative placement when clearance permits. Pause is attached above the same HUD panel.
|
||||
|
||||
Transport uses atomic raw RGBA files with an `FVR1` header, dimensions, sequence and timestamp. Tactical is captured at a target **30 Hz / 960×540**; map/windows retain **1280×720**. The independent HUD targets **10 Hz / 1280×720**. Native input coordinates remain **1280×720** regardless of capture resolution. PNG previews update at about 1 Hz and are diagnostics rather than the live transport.
|
||||
|
||||
The client reuses textures, reads only fresh frames and renders UI subviewports on changes. HUD/panel crops and alpha-aware pointing preserve native control coordinates. The hand panel receives priority over the headset HUD where they overlap. Rooms use floor intersections for targeting and crew drops, avoiding doors or miniatures blocking the intended destination.
|
||||
The client reuses textures, reads only fresh frames and renders UI subviewports on changes. HUD/panel crops and alpha-aware pointing preserve native control coordinates. The hand panel receives priority over the gameplay HUD where they overlap. Rooms use floor intersections for targeting and crew drops, avoiding doors or miniatures blocking the intended destination.
|
||||
|
||||
## Local assets and generated data
|
||||
|
||||
|
||||
+7
-5
@@ -21,7 +21,7 @@ Open **Controls / Help** on the left panel for an illustrated controller diagram
|
||||
| Right stick direction | Select a wheel slot; release bumper to confirm |
|
||||
| Left Dpad Down | Open or close Tactical |
|
||||
| Left View | Pause or resume |
|
||||
| Left Dpad Up | Open or close Shortcuts |
|
||||
| Left Dpad Up | Open or close System Power |
|
||||
| Left Dpad Left / Right | Previous / next hand-screen page |
|
||||
| Left grip, held + hand motion | Move the encounter |
|
||||
| Left stick horizontal / vertical | Rotate / resize the encounter |
|
||||
@@ -29,7 +29,7 @@ Open **Controls / Help** on the left panel for an illustrated controller diagram
|
||||
| Left trigger | Return crew to stations, except on System Power |
|
||||
| Right grip + left trigger | Save stations, except on System Power |
|
||||
|
||||
The page order is **Navigation → Tactical → Shortcuts → System Power → Jump → Help**. Point at an available panel button with the right controller and pull the right trigger. Jump, Ship and Store are offered according to native game availability.
|
||||
The page order is **Navigation → Tactical → Shortcuts → System Power → Jump → Help**. Navigation includes a direct **SYSTEM POWER** button as well as Shortcuts. Point at an available panel button with the right controller and pull the right trigger. Jump, Ship and Store are offered according to native game availability.
|
||||
|
||||
## System Power page
|
||||
|
||||
@@ -42,7 +42,7 @@ This page changes the contextual controls below. **Steam Frame X/Y are on the ri
|
||||
| Left trigger | Add one native power step to the selected system |
|
||||
| Left bumper | Remove one native power step |
|
||||
|
||||
The game decides whether a change is allowed. One engine step changes one power bar; one shield step normally changes two. Damage, ion locks, capacity, bonus power and battery power follow the actual game state. The reactor column shows available power. A stationary pointer does not continuously override X/Y selection.
|
||||
The game decides whether a change is allowed. One engine step changes one power bar; one shield step normally changes two. Damage, ion locks, capacity, bonus power and battery power follow the actual game state. The reactor column shows **usable free reactor power after environmental limits**: storm/capped bars are marked separately, and battery availability remains a separate counter. A stationary pointer does not continuously override X/Y selection.
|
||||
|
||||
## Crew, doors and room targeting
|
||||
|
||||
@@ -59,7 +59,7 @@ Enemy inspection reveals room roles; it does not reveal crew or hazards that FTL
|
||||
|
||||
Hold the **right bumper**, move the right stick toward a slot and release the bumper to activate it. Returning the stick to the center cancels. Right trigger confirms immediately; B cancels. Right A changes the category.
|
||||
|
||||
The wheel starts with weapon slots 1–4 and drone slots 5–8. Other categories provide system power and system actions. Uninstalled or unavailable entries cannot activate. When a choice event is open, the wheel becomes a choice selector for entries 1–8; original dialog controls remain pointable.
|
||||
The wheel starts with weapon slots 1–4 and drone slots 5–8. Equipped weapons/drones show their native localized names; weapon-family silhouettes and ammo captions distinguish similar equipment. Long names are clipped inside their sector, with a wider selected-name caption below. Numbers remain the original native shortcuts. Other categories provide system power and system actions. Uninstalled or unavailable entries cannot activate. When a choice event is open, the wheel becomes a choice selector for entries 1–8; original dialog controls remain pointable.
|
||||
|
||||
Shift and Ctrl also apply when confirming relevant wheel or shortcut commands.
|
||||
|
||||
@@ -82,7 +82,9 @@ These are native key equivalents shown on buttons, not extra physical controller
|
||||
|
||||
**Jump** opens the actual sector map above and ahead of the installed piloting room. Point at a beacon using the right controller and use the original confirmation controls. The map follows the ship's cockpit and orientation. **Close Map** returns to the encounter.
|
||||
|
||||
Events, Store (including BUY/SELL), upgrades, crew and equipment windows float above the player ship. Their original localized controls stay interactive. Main menus remain 2D. The original HUD follows the headset; the hand panel has visual and pointing priority where it overlaps the HUD.
|
||||
Events, Store (including BUY/SELL), upgrades, crew and equipment windows float above the player ship. Their original localized controls stay interactive. Main menus remain complete, head-relative 2D views.
|
||||
|
||||
Gameplay uses a **compact HUD anchored to the headset**, retaining native upper status and the crew roster while removing the lower weapons/reactor/system/subsystem strip. Use Power, Shortcuts or the wheel for those lower-strip actions. The HUD keeps a fixed readable size and follows the headset whenever there is room. If that movement would carry the panel into the ship or shield, it stops above the ship until clearance permits headset-relative placement again. A raised or oversized table can therefore raise the panel during overlap. The hand panel retains visual and pointing priority where it overlaps the HUD. Desktop Tactical/map inspection keeps the complete native view.
|
||||
|
||||
To rename a ship or crew member, click its original name-edit field. A floating **QWERTY keyboard** appears:
|
||||
|
||||
|
||||
+7
-3
@@ -29,13 +29,13 @@ After installing the documented Python requirements, run from the repository roo
|
||||
python -m unittest discover -s tools -p "test_*.py"
|
||||
```
|
||||
|
||||
The last development revision passed **27 Python tests**. They exercise synthetic archive/layout/font extraction, bridge schema/input translation, native-pixel filtering and raw frame publication. They do not launch FTL. Temporary fixture files are created by the tests.
|
||||
The current prepared source passed **38 Python tests**, including empty native drone-list and portable-launcher regressions. The tests exercise synthetic archive/layout/font extraction, bridge schema/input translation, native-pixel filtering, raw frame publication and launcher preflight/error handling. They do not launch FTL. Temporary fixture files are created by the tests. Godot suites and focused native checks are recorded in [STATUS](STATUS.md); physical headset coverage remains separate.
|
||||
|
||||
## Godot checks
|
||||
|
||||
Use a **separate development checkout or disposable local-data folder** with the required extracted assets. Some suites write screenshots/test frames under `local_game_data/`; do not run them over an active production bridge or package those outputs. Create the local output folder before graphical suites if it is missing.
|
||||
|
||||
The seven suites below passed in the previous configured development environment. A fresh source checkout lacks game assets; model/input/font checks may fail until local extraction is complete.
|
||||
The nine suites below passed for the current prepared source in the configured development environment, including the latest HUD/wheel/bar corrections. See [STATUS](STATUS.md) for evidence and limits. A fresh source checkout lacks game assets; model/input/font checks may fail until local extraction is complete.
|
||||
|
||||
```powershell
|
||||
godot --headless --xr-mode off --path . --script tools/test_combat.gd -- --demo
|
||||
@@ -45,9 +45,11 @@ godot --headless --xr-mode off --path . --script tools/test_environment.gd -- --
|
||||
godot --xr-mode off --path . --script tools/test_ui.gd -- --desktop
|
||||
godot --xr-mode off --path . --script tools/test_controller_ui.gd -- --demo --desktop
|
||||
godot --xr-mode off --path . --script tools/test_frame_transport.gd -- --desktop
|
||||
godot --headless --xr-mode off --path . --script tools/test_hud_layout.gd -- --desktop
|
||||
godot --headless --xr-mode off --path . --script tools/test_damage_visuals.gd -- --demo --desktop
|
||||
```
|
||||
|
||||
The UI/controller/transport suites use actual GPU rendering, so keep their graphical mode. Headless runs of them are not equivalent verification. These suites cover effects, room/beam picking, controller context/bindings, model/task distinctions, environmental presentation, original local fonts, UI priority/crops and the raw frame protocol.
|
||||
The UI/controller/transport suites use actual GPU rendering, so keep their graphical mode. Headless runs of them are not equivalent verification. These suites cover effects, room/beam picking, controller context/bindings, model/task distinctions, environmental presentation, original local fonts, UI priority/crops and the raw frame protocol. HUD-layout checks cover headset-relative placement, swept collision prevention and transformed ship/shield clearance. Controller checks include native empty/malformed equipment collections. Damage-visual checks cover permission-aware room colors, absence of floor status bars, native miss presentation and oxygen-dependent breach effects.
|
||||
|
||||
No test command above drives a native game or modifies its saves. Separate **live bridge checks** do run the game and can advance a run or change equipment/state.
|
||||
|
||||
@@ -64,6 +66,8 @@ python tools/launch.py --smoke
|
||||
|
||||
When adding a system or command, compare the visible native outcome and snapshot: installed-system availability, native power step, target room/ship, actual crew position, resource change or event transition. A target being accepted does not establish that its eventual effect completed. Hacking attachment/effects, native beam/bomb encounter coverage and full campaigns remain areas for further verification.
|
||||
|
||||
A preceding native missed-event check used a private projectile and forced `Evasion.MISS` to exercise the actual flag/event path. It is integration evidence, not a measurement of random evasion probabilities. Enemy room condition colors respect the native visibility rules; floor health/status bars are no longer rendered. Do not infer permission to reveal other hidden state from public icon color or crew-room fog alone.
|
||||
|
||||
The preparer, resolver and launcher reject unknown executable fingerprints. To support another executable, identify a known owned build, implement and validate a complete matching route and keep the rejection path. Do not disable fingerprint checks or reuse addresses from a different build.
|
||||
|
||||
## Profiling and visual inspection
|
||||
|
||||
@@ -62,6 +62,15 @@ This creates `.venv`, installs `requirements.txt` there and copies the example c
|
||||
|
||||
You can inspect its actions first with `SETUP.cmd --dry-run`. If the Windows `python` command opens the Microsoft Store, install Python from python.org with the Python launcher, reopen the terminal and retry.
|
||||
|
||||
The scripts keep setup and launch failures visible when double-clicked. `SETUP.cmd` ignores the Microsoft Store Python alias. If you already have a real Python installation without a launcher or PATH entry, select it for this terminal session:
|
||||
|
||||
```powershell
|
||||
$env:FTLVR_PYTHON = "C:\Tools\Python312\python.exe"
|
||||
.\SETUP.cmd
|
||||
```
|
||||
|
||||
Each checkout needs its own `.venv` and `local_game_data/` configuration/assets. Cloning the repository or copying the source does not copy that ignored local setup. You may reuse the same verified isolated lab and Godot installation in the new configuration, then rerun extraction for the new checkout. Close the previous client before launching another checkout against that lab.
|
||||
|
||||
## 3. Prepare and activate the isolated game
|
||||
|
||||
Check that the unpacked Hyperspace release contains:
|
||||
@@ -146,6 +155,8 @@ Checks verify dependencies, local extraction, the recorded original archive, the
|
||||
.\RUN-DESKTOP.cmd
|
||||
```
|
||||
|
||||
`RUN-FTL-VR.cmd` and `RUN-FTL-DESKTOP.cmd` are aliases for these launchers. Options are forwarded, so `RUN-DESKTOP.cmd --check` checks the same paths and assets used by a normal launch without starting the game. For scripted checks, set `$env:FTLVR_NO_PAUSE = "1"` to disable the pause after an error.
|
||||
|
||||
The launcher backs up the separate VR save files and lab settings, starts the isolated FTL bridge, waits for fresh live state/capture and starts Godot. It can take up to roughly one minute to establish the bridge; failure details appear in the local logs.
|
||||
|
||||
Closing the client requests a graceful native save/exit and stops the bridge. Pre-launch VR backups are in the lab's `save-backups/` folder. Allow shutdown to finish before starting another session. Logs are in `local_game_data/logs/`. See [troubleshooting](TROUBLESHOOTING.md) and [controls](CONTROLS.md).
|
||||
|
||||
+30
-8
@@ -1,6 +1,6 @@
|
||||
# Project status
|
||||
|
||||
**Prepared source snapshot:** `0.1.0-alpha.1`, 2026-10-03.
|
||||
**Prepared source snapshot:** `0.1.0-alpha.1`, 2026-10-03, with subsequent gameplay refinements described below.
|
||||
|
||||
This is a playable experimental client connected to the real game. It is suitable for technical enthusiasts who can follow the source setup instructions and help test supported versions. A broad public release and a portable compiled installer are still future work.
|
||||
|
||||
@@ -10,21 +10,42 @@ This is a playable experimental client connected to the real game. It is suitabl
|
||||
| --- | --- |
|
||||
| Campaign | FTL runs its normal campaign, events, difficulty, combat rules, audio and saves |
|
||||
| Ships | Dynamic local layouts, extruded hull presentation, rooms, weapon mounts, opposing bows in combat and stable movable encounter placement |
|
||||
| HUD | Original native HUD follows the headset; native enemy HUD region is removed in gameplay; hand screen and pointer draw above it |
|
||||
| HUD | Compact original status/crew panel follows the headset first at a fixed readable size; lower strip removed; clamps above the transformed hull/shield only when its movement would intersect the ship; full main menus remain head-relative |
|
||||
| Navigation | Available actions only; actual sector map appears above/ahead of the pilot room and faces the viewer |
|
||||
| Menus/dialogs | Main menus remain 2D; native shop/ship windows and event choices float in the encounter; BUY/SELL panels are retained |
|
||||
| Crew | Native positions/tasks/selection, animated procedural race models, grab preview and native move orders; occupied crew face the viewer |
|
||||
| Targeting | Weapon and beam room targeting, plus native mind-control, teleporter and hacking target routing |
|
||||
| Systems | Installed-system shortcuts, modifier support and a System Power page using native allocation rules |
|
||||
| Systems | Dpad Up and a Navigation button open System Power; native capped reactor availability, storm loss and battery availability remain separate |
|
||||
| Doors | Open/closed, locked, damaged/hacked state and thin tier-dependent armor driven by native strength |
|
||||
| Combat visuals | Shields/super shields, weapon charge strips/pips, native firing/impact/projectile paths and enemy hull bar using locally imported vanilla art |
|
||||
| Combat visuals | Shields/super shields, weapon charge strips/pips, native firing/impact/projectile paths, native miss cues/pass-by presentation and enemy hull bar using locally imported vanilla art |
|
||||
| Drones | Distinct procedural space/interior families with deployment/power state and placement by native render space |
|
||||
| Hazards | Room fire/breach tiles and surrounding asteroid, sun, nebula/storm and pulsar presentations |
|
||||
| Input | Steam Frame actions, generic fallback profiles, controller infographic, action wheel and QWERTY rename keyboard |
|
||||
| Hazards | Room fire and cross-shaped breach tiles, oxygen-dependent air wisps and surrounding asteroid, sun, nebula/storm and pulsar presentations |
|
||||
| Room feedback | Native role icons and permission-aware condition colors; no floor health/status bars or counters; normal sensor fog still applies |
|
||||
| Input | Steam Frame actions, generic fallback profiles, controller infographic, equipment-name/family/ammo action wheel and QWERTY rename keyboard |
|
||||
| Lifecycle | Isolated game copy, fingerprinted hooks, separate `hs_ftlvr_` saves, pre-launch backup and graceful save/exit |
|
||||
|
||||
## Verification recorded during development
|
||||
|
||||
### Latest HUD, wheel and launch corrections
|
||||
|
||||
The gameplay HUD uses a headset-relative pose whenever it has room. Collision checks include the transformed hull/shield envelope and the panel's swept movement, so looking down cannot carry it through the ship. The wheel now handles empty native drone lists without losing equipped weapon entries. Room-floor bars and counters are removed. Launcher corrections expose failures and keep runtime files local.
|
||||
|
||||
The current source passed **38 Python tests and nine Godot suites** in the prepared `FTL-Tabletop-VR` folder. The suites include native empty-drone-list normalization, defensive wheel parsing, headset-relative HUD placement/collision checks and removal of room-floor bars. UI, controller and frame-transport suites used actual GPU rendering on the RTX 4060 Ti.
|
||||
|
||||
Actual `RUN-DESKTOP.cmd --smoke` and `RUN-VR.cmd --smoke` launched the isolated FTL process and Godot client, then exited successfully with clean client logs and no bridge errors. The GitHub Desktop checkout's `RUN-FTL-VR.cmd --smoke` alias passed the same combined launch/exit check. These smoke tests use desktop rendering; VR preflight passed separately. A copied save profile opened the current campaign, paused it and supplied actual Artemis Missiles and Burst Laser Mark II entries to a GPU-rendered wheel check; the drone-equipment collection was normalized to a list and bridge errors remained empty.
|
||||
|
||||
All four original campaign hashes remained unchanged; the original save prefix/settings were restored and Steam/lab executable fingerprints remained unchanged. These checks establish the desktop-wrapper and native wheel paths. Physical Steam Frame comfort, stereo performance and clean-machine setup remain unverified for these changes.
|
||||
|
||||
### Preceding gameplay refinements
|
||||
|
||||
This revision introduced a compact spatial gameplay HUD retaining native status/crew controls, direct Power access, localized equipment wheel metadata, storm-aware free-power presentation, native miss feedback, cross-shaped breaches and per-room system condition presentation. Enemy system condition colors follow native information permissions. Detailed floor bars introduced in that revision have since been removed.
|
||||
|
||||
That revision passed **27 Python tests and nine Godot suites**, including HUD-layout and damage-visual checks. Headless and Vulkan HUD checks covered crop/picking, fixed-size placement and transformed-envelope clearance; damage checks covered condition visibility, misses and breaches. Controller UI checks included localized equipment/ammo, bounded names, unchanged-content redraws and capped power/battery separation.
|
||||
|
||||
Actual native ion-storm state was checked: **8 installed / 4 usable reactor bars, 4 raw available / 0 usable free**. Native equipped Burst Laser/Artemis titles and ammo metadata, enemy system damage/repair and breach tiles were checked. The real missed flag/event path was verified using a private native projectile and forced `Evasion.MISS`; this verifies integration, not random evasion rates.
|
||||
|
||||
Production combined Vulkan launch/save/exit smoke passed: exit 0, clean client log, no bridge errors and a disconnected bridge. All four campaign file hashes match the fresh pre-test backup; source and packed Lua match, QA fixtures are absent, both original/lab executable hashes remain unchanged and final process inventory shows no owned FTL/Godot processes. Physical Steam Frame comfort and complete campaigns remain open.
|
||||
|
||||
| Check | Evidence and limits |
|
||||
| --- | --- |
|
||||
| Real-game startup | Main menu, hangar, new game, event choices and combined client/bridge startup checked |
|
||||
@@ -35,9 +56,9 @@ This is a playable experimental client connected to the real game. It is suitabl
|
||||
| Hacking | Enemy room targeting accepted; attachment/effect completion remains unverified |
|
||||
| UI | Native HUD during Tactical/map/store/event/ship modes; enemy-region transparency; BUY/SELL content; rename input and original fonts checked across development revisions |
|
||||
| Drones | Native equipment/special/global lists deduplicated; actual combat and interior battle-drone state inspected |
|
||||
| Automated tests | Latest source revision passed 27 Python tests and seven Godot suites before this packaging task |
|
||||
| Automated tests | Current prepared source passed 38 Python tests and nine Godot suites; UI/controller/transport checks used actual GPU rendering |
|
||||
| Desktop graphics | Vulkan rendering and model/UI inspection checked on RTX 4060 Ti 8 GB |
|
||||
| Save handling | Normal combined launch/exit and preservation of backed-up campaign data checked |
|
||||
| Save handling | Current desktop-wrapper launch/exit and copied-profile native checks preserved all four original campaign hashes |
|
||||
| Steam Frame play | Maintainer confirmed playability and substantial partial runs; latest changes need further hardware coverage |
|
||||
|
||||
The automated suite uses synthetic fixtures for many edge cases. A passing test does not establish that every race, layout, weapon, drone, hazard or boss encounter has been exercised in a campaign.
|
||||
@@ -65,6 +86,7 @@ The automated suite uses synthetic fixtures for many edge cases. A passing test
|
||||
- GOG PKG extraction works; the complete GOG executable/loader route is not verified.
|
||||
- Other headsets/controller profiles, Linux/macOS and FTL overhaul combinations are not verified.
|
||||
- Native game rules remain authoritative. VR hazard animation and some projectile/drone presentations are approximations, rather than complete reproductions of their 2D appearance or timing.
|
||||
- HUD follows the headset until a collision would occur; a raised or oversized table can then push the panel above the ship. Retest that transition physically when moving/resizing the encounter.
|
||||
- A source setup is supplied; compiled client distribution, export packaging and an end-user installer are not yet validated.
|
||||
- Python uses pinned Frida/Capstone and minimum Pillow/NumPy versions. Other combinations need testing.
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ Start with `CHECK-SETUP.cmd`, or `CHECK-SETUP.cmd --desktop` if testing without
|
||||
|
||||
| Symptom | What to check |
|
||||
|---|---|
|
||||
| Double-clicking the launcher does nothing | Failure output now stays open. Read the error above the prompt. Every checkout needs its own `.venv`, `local_game_data/launcher.json` and locally extracted assets; a Git clone contains source only. Complete Installation for that checkout. |
|
||||
| `python` opens Microsoft Store, or Python cannot be found | Install Python 3.10+ from python.org with its launcher, reopen your terminal and rerun `SETUP.cmd`. |
|
||||
| Missing `frida`, `PIL`, `numpy` or `capstone` | Run `SETUP.cmd` and use the repository's `.venv` Python. Installing into another Python environment will not satisfy the launch wrappers. |
|
||||
| Package installation fails | Check the pip output and internet connectivity. Python 3.12 is the development baseline. Setup does not download game dependencies. |
|
||||
@@ -19,6 +20,8 @@ Start with `CHECK-SETUP.cmd`, or `CHECK-SETUP.cmd --desktop` if testing without
|
||||
| Resolver reports a missing/ambiguous signature | Use the matching win32/1.6.9 signature directory from Hyperspace source, with all neighboring `.zhl` files. Do not use other people's hook addresses. |
|
||||
| Recorded original installation unavailable | The bridge still reads the original `ftl.dat` for newly encountered ships. Restore that location or rebuild the lab/config against the new installation. |
|
||||
|
||||
To use a real Python installation outside PATH, set `FTLVR_PYTHON` to its executable before running `SETUP.cmd` (see Installation). These scripts do not use the Microsoft Store alias. Set `FTLVR_NO_PAUSE=1` only when calling the launchers from an automated script that captures their output; otherwise failures remain open so they can be read.
|
||||
|
||||
## Launch and VR
|
||||
|
||||
| Symptom | What to check |
|
||||
|
||||
+117
-20
@@ -1,9 +1,15 @@
|
||||
extends Node3D
|
||||
|
||||
const NATIVE_PRESENTATION_GRACE_MS := 300
|
||||
const UiAssets = preload("res://scripts/ftl_ui_assets.gd")
|
||||
const MISS_CUE_DURATION := 1.0
|
||||
const MAX_MISS_CUES := 24
|
||||
static var impact_meshes: Dictionary = {}
|
||||
static var glow_materials: Dictionary = {}
|
||||
|
||||
var shots: Array[Dictionary] = []
|
||||
var impacts: Array[Dictionary] = []
|
||||
var misses: Array[Dictionary] = []
|
||||
var simulation_paused := false
|
||||
var unit_beam_mesh: CylinderMesh
|
||||
|
||||
@@ -25,7 +31,7 @@ func spawn_shot(event: Dictionary, sender: Node3D, receiver: Node3D) -> void:
|
||||
if outcome == "shield":
|
||||
end = receiver.shield_intercept(start, end)
|
||||
elif outcome == "miss":
|
||||
end += Vector3(0.0, 0.5, 1.0)
|
||||
end = _miss_endpoint(start, end, receiver)
|
||||
var color := Color(0.3, 0.75, 1.0) if kind == "ion" else Color(1.0, 0.35, 0.12)
|
||||
if kind == "missile":
|
||||
color = Color(0.9, 0.9, 0.85)
|
||||
@@ -48,7 +54,7 @@ func spawn_shot(event: Dictionary, sender: Node3D, receiver: Node3D) -> void:
|
||||
"projectile_id": str(event.get("projectile_id", "")), "sender": sender,
|
||||
"created_ms": Time.get_ticks_msec(), "native_present": false,
|
||||
"visual_scale": maxf(0.1, sender.global_basis.get_scale().abs().x),
|
||||
"live_progress": 0.0, "wanted_progress": 0.0, "missed": false,
|
||||
"live_progress": 0.0, "wanted_progress": 0.0, "missed": false, "miss_feedback": false,
|
||||
"end_point": event.get("end_point", {}),
|
||||
"origin_point": event.get("origin_point", {}), "origin_ship": origin_ship,
|
||||
"target_point": event.get("target", {}), "beam_point": {},
|
||||
@@ -57,6 +63,24 @@ func spawn_shot(event: Dictionary, sender: Node3D, receiver: Node3D) -> void:
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Miss labels remain attached to the encounter if the table is moved during
|
||||
# pause; only their presentation lifetime and drift freeze with simulation.
|
||||
for i in range(misses.size() - 1, -1, -1):
|
||||
var cue: Dictionary = misses[i]
|
||||
if not is_instance_valid(cue["receiver"]) or not is_instance_valid(cue["node"]):
|
||||
if is_instance_valid(cue["node"]):
|
||||
cue["node"].queue_free()
|
||||
misses.remove_at(i)
|
||||
continue
|
||||
if not simulation_paused:
|
||||
cue["time"] += delta
|
||||
var age := clampf(float(cue["time"]) / MISS_CUE_DURATION, 0.0, 1.0)
|
||||
var label: Label3D = cue["node"]
|
||||
label.global_position = cue["receiver"].to_global(Vector3(cue["local_point"]) + Vector3.UP * (0.28 + age * 0.12))
|
||||
label.modulate.a = minf(1.0, (1.0 - age) * 3.0)
|
||||
if age >= 1.0:
|
||||
label.queue_free()
|
||||
misses.remove_at(i)
|
||||
if simulation_paused:
|
||||
return
|
||||
for i in range(impacts.size() - 1, -1, -1):
|
||||
@@ -83,7 +107,7 @@ func _process(delta: float) -> void:
|
||||
if not shot["target_point"].is_empty():
|
||||
shot["end"] = shot["receiver"].to_global(shot["receiver"].pixel_point(shot["target_point"]))
|
||||
if shot["missed"]:
|
||||
shot["end"] += Vector3(0.0, 0.25, 0.5)
|
||||
shot["end"] = _miss_endpoint(shot["start"], shot["end"], shot["receiver"])
|
||||
if not shot["end_point"].is_empty():
|
||||
shot["beam_end"] = shot["receiver"].to_global(shot["receiver"].pixel_point(shot["end_point"]))
|
||||
var progress := clampf(float(shot["time"]) / float(shot["duration"]), 0.0, 1.0)
|
||||
@@ -119,6 +143,8 @@ func _process(delta: float) -> void:
|
||||
shot["receiver"].flash_shield()
|
||||
if shot["outcome"] not in ["miss", "pending"]:
|
||||
_impact(shot["beam_end"] if shot["kind"] == "beam" else shot["end"], shot["kind"], shot["outcome"] == "shield")
|
||||
elif shot["outcome"] == "miss":
|
||||
_show_shot_miss(shot)
|
||||
node.queue_free()
|
||||
shots.remove_at(i)
|
||||
|
||||
@@ -130,9 +156,17 @@ func resolve_live(event: Dictionary, receiver: Node3D) -> void:
|
||||
_impact(point, "beam")
|
||||
return
|
||||
var kind := str(event.get("kind", "laser"))
|
||||
var miss_already_shown := false
|
||||
for i in range(shots.size() - 1, -1, -1):
|
||||
if shots[i]["projectile_id"] == projectile_id:
|
||||
if event.get("outcome", "") == "miss":
|
||||
# Evasion is reported at the native collision decision. The shot
|
||||
# may still be flying; native snapshots decide when it disappears.
|
||||
shots[i]["missed"] = true
|
||||
_show_shot_miss(shots[i])
|
||||
return
|
||||
kind = str(shots[i]["kind"])
|
||||
miss_already_shown = bool(shots[i].get("miss_feedback", false))
|
||||
if not event.has("target"):
|
||||
point = shots[i]["end"]
|
||||
if event.get("outcome", "") == "shield":
|
||||
@@ -143,6 +177,8 @@ func resolve_live(event: Dictionary, receiver: Node3D) -> void:
|
||||
receiver.flash_shield()
|
||||
if event.get("outcome", "") in ["shield", "hull"]:
|
||||
_impact(point, kind, event.get("outcome", "") == "shield")
|
||||
elif event.get("outcome", "") == "miss" and not miss_already_shown:
|
||||
_miss_feedback(point, receiver, projectile_id)
|
||||
|
||||
|
||||
func update_live_projectiles(projectiles: Array) -> void:
|
||||
@@ -157,35 +193,43 @@ func update_live_projectiles(projectiles: Array) -> void:
|
||||
var projectile: Dictionary = present[shot["projectile_id"]]
|
||||
shot["native_present"] = true
|
||||
shot["wanted_progress"] = float(projectile.get("progress", 0))
|
||||
shot["missed"] = bool(projectile.get("missed", false))
|
||||
shot["missed"] = shot["missed"] or bool(projectile.get("missed", false))
|
||||
if shot["missed"]:
|
||||
_show_shot_miss(shot)
|
||||
shot["beam_point"] = projectile.get("beam_point", {})
|
||||
elif shot["native_present"] or Time.get_ticks_msec() - int(shot["created_ms"]) >= NATIVE_PRESENTATION_GRACE_MS:
|
||||
elif shot["native_present"] or shot["missed"] or Time.get_ticks_msec() - int(shot["created_ms"]) >= NATIVE_PRESENTATION_GRACE_MS:
|
||||
# Native absence ends a known projectile immediately. Newly delivered
|
||||
# fire records get a short presentation grace for the next snapshot;
|
||||
# its monotonic clock keeps working while gameplay is paused.
|
||||
# fire records without an outcome get a short snapshot grace. A native
|
||||
# miss followed by absence has already completed its real lifecycle;
|
||||
# the grace clock keeps working while gameplay is paused.
|
||||
shot["node"].queue_free()
|
||||
shots.remove_at(i)
|
||||
|
||||
|
||||
func _impact(point: Vector3, kind: String = "laser", shield: bool = false) -> void:
|
||||
var flash := MeshInstance3D.new()
|
||||
var sphere := SphereMesh.new()
|
||||
sphere.radius = 0.025 if kind not in ["missile", "bomb"] else 0.04
|
||||
sphere.height = sphere.radius * 2.0
|
||||
sphere.radial_segments = 10
|
||||
sphere.rings = 5
|
||||
flash.mesh = sphere
|
||||
var sphere_key := "large" if kind in ["missile", "bomb"] else "small"
|
||||
if not impact_meshes.has(sphere_key):
|
||||
var sphere := SphereMesh.new()
|
||||
sphere.radius = 0.04 if sphere_key == "large" else 0.025
|
||||
sphere.height = sphere.radius * 2.0
|
||||
sphere.radial_segments = 10
|
||||
sphere.rings = 5
|
||||
impact_meshes[sphere_key] = sphere
|
||||
flash.mesh = impact_meshes[sphere_key]
|
||||
var color := Color(0.32, 0.73, 1.0) if shield or kind == "ion" else Color(1.0, 0.78, 0.28)
|
||||
flash.material_override = _glow(color)
|
||||
flash.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
var ring := MeshInstance3D.new()
|
||||
ring.name = "ImpactRing"
|
||||
var torus := TorusMesh.new()
|
||||
torus.inner_radius = 0.024
|
||||
torus.outer_radius = 0.028
|
||||
torus.rings = 16
|
||||
torus.ring_segments = 6
|
||||
ring.mesh = torus
|
||||
if not impact_meshes.has("ring"):
|
||||
var torus := TorusMesh.new()
|
||||
torus.inner_radius = 0.024
|
||||
torus.outer_radius = 0.028
|
||||
torus.rings = 16
|
||||
torus.ring_segments = 6
|
||||
impact_meshes["ring"] = torus
|
||||
ring.mesh = impact_meshes["ring"]
|
||||
ring.material_override = _glow(Color(color, 0.45), true)
|
||||
ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
flash.add_child(ring)
|
||||
@@ -198,7 +242,56 @@ func _impact(point: Vector3, kind: String = "laser", shield: bool = false) -> vo
|
||||
flash.add_child(spark)
|
||||
add_child(flash)
|
||||
flash.global_position = point
|
||||
impacts.append({"node": flash, "time": 0.0})
|
||||
impacts.append({"node": flash, "time": 0.0, "outcome": "shield" if shield else "hull", "kind": kind})
|
||||
|
||||
|
||||
func _miss_endpoint(start: Vector3, target: Vector3, receiver: Node3D) -> Vector3:
|
||||
var direction := (target - start).normalized()
|
||||
var up := receiver.global_basis.y.normalized()
|
||||
var tangent := direction.cross(up).normalized()
|
||||
var scale := maxf(0.1, receiver.global_basis.get_scale().abs().x)
|
||||
# This is an outcome illustration, never collision detection. The native
|
||||
# evasion flag allows a clear passing trajectory without creating a hit.
|
||||
return target + (tangent * 0.30 + up * 0.22 + direction * 1.1) * scale
|
||||
|
||||
|
||||
func _show_shot_miss(shot: Dictionary) -> void:
|
||||
if shot["miss_feedback"]:
|
||||
return
|
||||
shot["miss_feedback"] = true
|
||||
var receiver: Node3D = shot["receiver"]
|
||||
var point: Vector3 = receiver.room_target(shot["room"])
|
||||
if not shot["target_point"].is_empty():
|
||||
point = receiver.to_global(receiver.pixel_point(shot["target_point"]))
|
||||
_miss_feedback(point, receiver, shot["projectile_id"])
|
||||
|
||||
|
||||
func _miss_feedback(point: Vector3, receiver: Node3D, projectile_id: String) -> void:
|
||||
# An impact record and its 10 Hz missed flag can arrive in either order.
|
||||
# Keep one cue per native projectile while the short feedback is visible.
|
||||
for cue in misses:
|
||||
if not projectile_id.is_empty() and cue["projectile_id"] == projectile_id:
|
||||
return
|
||||
if misses.size() >= MAX_MISS_CUES:
|
||||
misses[0]["node"].queue_free()
|
||||
misses.pop_front()
|
||||
var label := Label3D.new()
|
||||
label.name = "NativeMiss"
|
||||
label.text = "MISS"
|
||||
label.font = UiAssets.font("body")
|
||||
label.font_size = 50
|
||||
label.pixel_size = 0.0028 * maxf(0.1, receiver.global_basis.get_scale().abs().x)
|
||||
label.modulate = Color("fff2c4")
|
||||
label.outline_modulate = Color("1b2532")
|
||||
label.outline_size = 7
|
||||
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
label.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
|
||||
label.no_depth_test = false
|
||||
add_child(label)
|
||||
var local_point: Vector3 = receiver.to_local(point)
|
||||
label.global_position = receiver.to_global(local_point + Vector3.UP * 0.28)
|
||||
misses.append({"node": label, "receiver": receiver, "local_point": local_point,
|
||||
"time": 0.0, "projectile_id": projectile_id})
|
||||
|
||||
|
||||
func _projectile_visual(kind: String, color: Color) -> MeshInstance3D:
|
||||
@@ -293,6 +386,9 @@ func _box(size: Vector3, color: Color, luminous: bool = true) -> MeshInstance3D:
|
||||
|
||||
|
||||
func _glow(color: Color, additive: bool = false) -> StandardMaterial3D:
|
||||
var key := color.to_html() + ":" + str(additive)
|
||||
if glow_materials.has(key):
|
||||
return glow_materials[key]
|
||||
var material := StandardMaterial3D.new()
|
||||
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
material.albedo_color = color
|
||||
@@ -301,6 +397,7 @@ func _glow(color: Color, additive: bool = false) -> StandardMaterial3D:
|
||||
if additive:
|
||||
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
|
||||
glow_materials[key] = material
|
||||
return material
|
||||
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ class HelpCanvas extends Control:
|
||||
_label("DPAD / VIEW", Vector2(28, 369), 19, MUTED)
|
||||
_leader(Vector2(312, 252), Vector2(431, 366), 434)
|
||||
_badge("UP", Vector2(28, 394), 49)
|
||||
_label("Shortcuts", Vector2(91, 394), 20)
|
||||
_label("System Power", Vector2(91, 394), 20)
|
||||
_badge("VIEW",Vector2(296,394),72)
|
||||
_label("Pause",Vector2(380,394),20)
|
||||
_badge("L / R", Vector2(28, 425), 72)
|
||||
|
||||
@@ -140,6 +140,20 @@ func selected_system() -> Dictionary:
|
||||
return {}
|
||||
|
||||
|
||||
func reactor_display() -> Dictionary:
|
||||
# The bridge supplies FTL's capped reactor calculation. GetAvailablePower
|
||||
# alone includes bars disabled by a storm; never show those as free units
|
||||
# or mix separately counted battery/Zoltan power into the reactor total.
|
||||
var total := clampi(int(reactor.get("usable_total", reactor.get("total", reactor.get("max", 0)))), 0, 50)
|
||||
var installed := clampi(int(reactor.get("installed", total)), total, 50)
|
||||
return {"total":total, "installed":installed,
|
||||
"free":clampi(int(reactor.get("usable_available", reactor.get("available", reactor.get("current", 0)))), 0, total),
|
||||
"cap_loss":clampi(int(reactor.get("cap_loss", installed - total)), 0, installed),
|
||||
"storm_loss":clampi(int(reactor.get("storm_loss", 0)), 0, installed),
|
||||
"battery_free":maxi(0, int(reactor.get("battery_available", 0))),
|
||||
"battery_total":maxi(0, int(reactor.get("battery_total", 0)))}
|
||||
|
||||
|
||||
func power_card_rect(index: int) -> Rect2:
|
||||
var count := maxi(1,power_rows.size())
|
||||
var rows_per_column := ceili(count / 2.0)
|
||||
@@ -204,25 +218,33 @@ class PanelCanvas extends Control:
|
||||
var rect := Rect2(center - Vector2(250,41.875),Vector2(500,83.75))
|
||||
UI.button(self,rect,str(item.get("label","")),item.get("enabled",true))
|
||||
if low_buttons:
|
||||
UI.centered_text(self,"DPAD L/R: SCREEN DOWN: TACTICAL VIEW: PAUSE",Vector2(575,877),22,UI.MUTED)
|
||||
UI.centered_text(self,"UP: POWER L/R: SCREEN DOWN: TACTICAL VIEW: PAUSE",Vector2(575,877),22,UI.MUTED)
|
||||
else:
|
||||
draw_line(Vector2(38,829),Vector2(1112,829),UI.MUTED,1)
|
||||
UI.centered_text(self,"DPAD L/R: SCREEN DOWN: TACTICAL VIEW: PAUSE",Vector2(575,850),24,UI.MUTED)
|
||||
UI.centered_text(self,"UP: POWER L/R: SCREEN DOWN: TACTICAL VIEW: PAUSE",Vector2(575,850),24,UI.MUTED)
|
||||
UI.centered_text(self,"LEFT TRIGGER: STATIONS RIGHT BUMPER: ACTION WHEEL",Vector2(575,877),22,UI.INK)
|
||||
|
||||
|
||||
func _draw_power() -> void:
|
||||
UI.text(self,"REACTOR",Vector2(31,108),27,UI.INK)
|
||||
var total := clampi(int(panel.reactor.get("total",panel.reactor.get("max",0))),0,50)
|
||||
var available := clampi(int(panel.reactor.get("available",panel.reactor.get("current",0))),0,total)
|
||||
var power: Dictionary = panel.reactor_display()
|
||||
var total := int(power.total)
|
||||
var available := int(power.free)
|
||||
var installed := int(power.installed)
|
||||
if int(power.cap_loss) > 0:
|
||||
UI.centered_text(self,"STORM -%d" % int(power.storm_loss) if int(power.storm_loss) > 0 else "LIMIT -%d" % int(power.cap_loss),Vector2(94,134),18,ION_POWER)
|
||||
UI.frame(self,Rect2(41,145,103,610),UI.PANEL,UI.EDGE,9)
|
||||
var pitch := minf(20,562.0 / maxi(1,total))
|
||||
for cell in range(total):
|
||||
var pitch := minf(20,562.0 / maxi(1,installed))
|
||||
for cell in range(installed):
|
||||
var rect := Rect2(57,731 - cell * pitch,71,pitch - 3)
|
||||
draw_rect(rect,UI.GREEN if cell < available else UI.DARK)
|
||||
draw_rect(rect,UI.EDGE,false,1)
|
||||
draw_rect(rect,ION_POWER if cell >= total else UI.EDGE,false,1)
|
||||
if cell >= total: draw_line(rect.position,rect.end,ION_POWER,1)
|
||||
UI.centered_text(self,"%d/%d" % [available,total],Vector2(94,779),32,UI.GREEN)
|
||||
UI.centered_text(self,"FREE",Vector2(94,809),22,UI.MUTED)
|
||||
if int(power.battery_total) > 0:
|
||||
UI.centered_text(self,"BATTERY",Vector2(94,839),18,BATTERY)
|
||||
UI.centered_text(self,"%d/%d" % [int(power.battery_free),int(power.battery_total)],Vector2(94,865),23,BATTERY)
|
||||
for i in range(panel.power_rows.size()):
|
||||
var row: Dictionary = panel.power_rows[i]
|
||||
var rect: Rect2 = panel.power_card_rect(i)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
extends RefCounted
|
||||
|
||||
# Follow the headset first. Only the panel's path toward the ship engages the
|
||||
# world-height stop, so a distant or side-on table never drags the HUD around.
|
||||
const CLEARANCE := 0.14
|
||||
const HEAD_OFFSET := Vector3(0.0, -0.12, -1.30)
|
||||
const POSITION_TIME := 0.12
|
||||
const PANEL_THICKNESS := 0.008
|
||||
var initialized := false
|
||||
var blocked := false
|
||||
var lift := 0.0
|
||||
var pose := Transform3D.IDENTITY
|
||||
|
||||
|
||||
func reset() -> void:
|
||||
initialized = false
|
||||
blocked = false
|
||||
lift = 0.0
|
||||
|
||||
|
||||
static func transformed_bounds(local: AABB, transform: Transform3D) -> AABB:
|
||||
var result := AABB(transform * local.position, Vector3.ZERO)
|
||||
for x in [0.0, 1.0]:
|
||||
for y in [0.0, 1.0]:
|
||||
for z in [0.0, 1.0]:
|
||||
result = result.expand(transform * (local.position + local.size * Vector3(x, y, z)))
|
||||
return result
|
||||
|
||||
|
||||
static func panel_bounds(transform: Transform3D, dimensions: Vector2) -> AABB:
|
||||
var half := transform.basis.x.abs() * dimensions.x * 0.5 + transform.basis.y.abs() * dimensions.y * 0.5
|
||||
return AABB(transform.origin - half, half * 2.0)
|
||||
|
||||
|
||||
static func _boxes_intersect(first: Transform3D, first_box: AABB, second: Transform3D, second_box: AABB, margin := 0.0) -> bool:
|
||||
# Separate the oriented boxes on face normals and edge cross products. A
|
||||
# world AABB alone can stop the HUD beside a rotated ship with empty space.
|
||||
var first_axes: Array[Vector3] = [first.basis.x.normalized(), first.basis.y.normalized(), first.basis.z.normalized()]
|
||||
var second_axes: Array[Vector3] = [second.basis.x.normalized(), second.basis.y.normalized(), second.basis.z.normalized()]
|
||||
var first_half := first_box.size * Vector3(first.basis.x.length(), first.basis.y.length(), first.basis.z.length()) * 0.5
|
||||
var second_half := second_box.size * Vector3(second.basis.x.length(), second.basis.y.length(), second.basis.z.length()) * 0.5 + Vector3.ONE * margin
|
||||
var separation := second * second_box.get_center() - first * first_box.get_center()
|
||||
var axes: Array[Vector3] = []
|
||||
axes.append_array(first_axes)
|
||||
axes.append_array(second_axes)
|
||||
for first_axis in first_axes:
|
||||
for second_axis in second_axes:
|
||||
var cross := first_axis.cross(second_axis)
|
||||
if cross.length_squared() > 0.000001:
|
||||
axes.append(cross.normalized())
|
||||
for axis in axes:
|
||||
var first_radius := 0.0
|
||||
var second_radius := 0.0
|
||||
for index in range(3):
|
||||
first_radius += absf(axis.dot(first_axes[index])) * first_half[index]
|
||||
second_radius += absf(axis.dot(second_axes[index])) * second_half[index]
|
||||
if absf(axis.dot(separation)) > first_radius + second_radius:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func overlaps_ship(panel: Transform3D, dimensions: Vector2, ship_transform: Transform3D, local_bounds: AABB, margin := 0.0) -> bool:
|
||||
var half := Vector3(dimensions.x * 0.5, dimensions.y * 0.5, PANEL_THICKNESS)
|
||||
return _boxes_intersect(panel, AABB(-half, half * 2.0), ship_transform, local_bounds, margin)
|
||||
|
||||
|
||||
static func _facing(point: Vector3, viewer: Transform3D) -> Basis:
|
||||
var toward := viewer.origin - point
|
||||
if toward.length_squared() < 0.0001:
|
||||
toward = viewer.basis.z
|
||||
var up := viewer.basis.y.normalized()
|
||||
if absf(toward.normalized().dot(up)) > 0.98:
|
||||
up = viewer.basis.z.normalized()
|
||||
return Basis.looking_at(toward, up, true)
|
||||
|
||||
|
||||
static func _clear_position(point: Vector3, basis: Basis, dimensions: Vector2, ship: AABB) -> Vector3:
|
||||
var half := basis.x.abs() * dimensions.x * 0.5 + basis.y.abs() * dimensions.y * 0.5
|
||||
point.y = maxf(point.y, ship.end.y + CLEARANCE + half.y + PANEL_THICKNESS)
|
||||
return point
|
||||
|
||||
|
||||
func update(ship_transform: Transform3D, local_bounds: AABB, viewer: Transform3D, dimensions: Vector2, delta: float, snap := false) -> Transform3D:
|
||||
var desired := viewer * Transform3D(Basis.IDENTITY, HEAD_OFFSET)
|
||||
var path_length := viewer.origin.distance_to(desired.origin)
|
||||
var path := Transform3D(Basis.looking_at(desired.origin - viewer.origin, viewer.basis.y), (viewer.origin + desired.origin) * 0.5)
|
||||
var path_box := AABB(Vector3(0, 0, -path_length * 0.5), Vector3(0, 0, path_length))
|
||||
# The full panel handles edge contact. Its center's viewing path also stops
|
||||
# a large downward head motion tunnelling the panel entirely below the hull.
|
||||
# Sweeping its full width all the way to the face would unnecessarily clamp
|
||||
# it when looking away from a table close behind the viewer.
|
||||
blocked = overlaps_ship(desired, dimensions, ship_transform, local_bounds, CLEARANCE) or _boxes_intersect(path, path_box, ship_transform, local_bounds, CLEARANCE)
|
||||
var target_lift := 0.0
|
||||
var ship := transformed_bounds(local_bounds, ship_transform)
|
||||
if blocked:
|
||||
var target := desired.origin
|
||||
var target_basis := desired.basis
|
||||
for iteration in range(6):
|
||||
target = _clear_position(target, target_basis, dimensions, ship)
|
||||
target_basis = _facing(target, viewer)
|
||||
target_lift = maxf(0.0, target.y - desired.origin.y)
|
||||
# Head motion itself is immediate. Smooth only release of the clearance
|
||||
# offset; raising it immediately prevents a fast grab crossing the panel.
|
||||
if not initialized or snap or target_lift >= lift:
|
||||
lift = target_lift
|
||||
else:
|
||||
var amount := 1.0 - exp(-maxf(delta, 0.0) / POSITION_TIME)
|
||||
lift = lerpf(lift, target_lift, amount)
|
||||
if lift < 0.0001:
|
||||
lift = 0.0
|
||||
pose = desired
|
||||
pose.origin.y += lift
|
||||
if lift > 0.0:
|
||||
pose.basis = _facing(pose.origin, viewer)
|
||||
# A raised ship can meet the releasing panel even after the desired head
|
||||
# pose has left its path. Enforce clearance for that actual pose as well.
|
||||
if blocked or overlaps_ship(pose, dimensions, ship_transform, local_bounds, CLEARANCE):
|
||||
for iteration in range(6):
|
||||
pose.origin = _clear_position(pose.origin, pose.basis, dimensions, ship)
|
||||
pose.basis = _facing(pose.origin, viewer)
|
||||
lift = maxf(0.0, pose.origin.y - desired.origin.y)
|
||||
initialized = true
|
||||
return pose
|
||||
@@ -144,6 +144,10 @@ func _draw() -> void:
|
||||
var transport_rect := Rect2(source_rect.position * original_frame.get_size() / NATIVE_SIZE, source_rect.size * original_frame.get_size() / NATIVE_SIZE)
|
||||
draw_texture_rect_region(original_frame, Rect2(Vector2.ZERO, size), transport_rect)
|
||||
return
|
||||
# Demo/fallback art uses native coordinates just like the live texture. The
|
||||
# viewport clips the cropped lower controls instead of shrinking them into it.
|
||||
var crop_scale := size / source_rect.size
|
||||
draw_set_transform(-source_rect.position * crop_scale, 0.0, crop_scale)
|
||||
_art("statusUI/top_hull.png", Vector2(5, 0))
|
||||
_art("statusUI/top_hull_label.png", Vector2(5, 0))
|
||||
_text("HULL", Vector2(12, 22), 18, Color(0.04, 0.12, 0.13))
|
||||
|
||||
+87
-17
@@ -2,6 +2,7 @@ extends Node3D
|
||||
|
||||
const ShipModel = preload("res://scripts/ship_model.gd")
|
||||
const HudSurface = preload("res://scripts/hud_surface.gd")
|
||||
const HudAnchor = preload("res://scripts/hud_anchor.gd")
|
||||
const ShortcutWheel = preload("res://scripts/shortcut_wheel.gd")
|
||||
const CombatEffects = preload("res://scripts/combat_effects.gd")
|
||||
const SpaceEnvironment = preload("res://scripts/space_environment.gd")
|
||||
@@ -21,6 +22,10 @@ var player_ship: Node3D
|
||||
var enemy_ship: Node3D
|
||||
var nav_panel: Node3D
|
||||
var hud_surface: Node3D
|
||||
var hud_anchor := HudAnchor.new()
|
||||
var gameplay_hud := false
|
||||
const GAMEPLAY_HUD_RECT := Rect2(0, 0, 872, 510)
|
||||
const GAMEPLAY_HUD_SIZE := Vector2(1.10, 1.10 * 510.0 / 872.0)
|
||||
var combat_effects: Node3D
|
||||
var hud_state: Dictionary = {}
|
||||
var seen_shots: Dictionary = {}
|
||||
@@ -82,7 +87,7 @@ var power_remove_was_down := false
|
||||
var power_previous_was_down := false
|
||||
var power_next_was_down := false
|
||||
var power_hover_key := ""
|
||||
var shortcuts_was_down := false
|
||||
var power_page_was_down := false
|
||||
var tracking_label: Label3D
|
||||
var diagnostics_elapsed := 0.0
|
||||
var diagnostics_signature := ""
|
||||
@@ -227,21 +232,72 @@ func _update_ship_positions() -> void:
|
||||
|
||||
func _create_hud() -> void:
|
||||
hud_surface = HudSurface.new()
|
||||
hud_surface.position = Vector3(0.0, -0.16, -1.8)
|
||||
hud_surface.scale = Vector3.ONE * 0.64
|
||||
hud_surface.render_order = 100
|
||||
camera.add_child(hud_surface)
|
||||
add_child(hud_surface)
|
||||
hud_surface.canvas.use_original_frame = not demo_mode
|
||||
pause_label = Label3D.new()
|
||||
pause_label.font = UiAssets.font("header")
|
||||
pause_label.outline_size = 0
|
||||
pause_label.position = Vector3(0, 0.16, -1.75)
|
||||
pause_label.position = Vector3(0, HudSurface.SURFACE_SIZE.y * 0.5 + 0.025, 0.015)
|
||||
pause_label.font_size = 32
|
||||
pause_label.pixel_size = 0.0008
|
||||
pause_label.no_depth_test = true
|
||||
pause_label.render_priority = 101
|
||||
pause_label.modulate = Color("c6f3da")
|
||||
camera.add_child(pause_label)
|
||||
hud_surface.add_child(pause_label)
|
||||
_sync_hud_layout()
|
||||
_position_hud(0.0, true)
|
||||
|
||||
|
||||
func _gameplay_hud_mode() -> bool:
|
||||
if not bool(hud_state.get("ready", demo_mode)) or str(hud_state.get("ui_mode", "")) == "menu":
|
||||
return false
|
||||
# Desktop preview still needs the complete screen for its Tactical/map view;
|
||||
# in VR those views have their own hand/world surfaces with native coordinates.
|
||||
return xr_active or not (hud_state.get("map_open", false) or hud_state.get("tactical", false))
|
||||
|
||||
|
||||
func _sync_hud_layout() -> void:
|
||||
if hud_surface == null:
|
||||
return
|
||||
var next_mode := _gameplay_hud_mode()
|
||||
if gameplay_hud != next_mode:
|
||||
gameplay_hud = next_mode
|
||||
hud_anchor.reset()
|
||||
if gameplay_hud:
|
||||
hud_surface.set_crop(GAMEPLAY_HUD_RECT, GAMEPLAY_HUD_SIZE)
|
||||
else:
|
||||
hud_surface.set_crop(Rect2(0, 0, 1280, 720), HudSurface.SURFACE_SIZE)
|
||||
pause_label.position = Vector3(0, hud_surface.surface_size.y * 0.5 + 0.025, 0.015)
|
||||
|
||||
|
||||
func _player_hud_local_bounds() -> AABB:
|
||||
var bounds := AABB(player_ship.shield_center - player_ship.shield_radii, player_ship.shield_radii * 2.0)
|
||||
# Keep the envelope even when shields are depleted, preventing the HUD from
|
||||
# dropping through crew/weapons or jumping when shields recharge.
|
||||
for rect: Rect2 in player_ship.room_bounds.values():
|
||||
bounds = bounds.expand(Vector3(rect.position.x, -0.16, rect.position.y))
|
||||
bounds = bounds.expand(Vector3(rect.end.x, 0.35, rect.end.y))
|
||||
var image: Dictionary = player_ship.layout_data.get("image_rect", {})
|
||||
if not image.is_empty():
|
||||
var first: Vector2 = (Vector2(float(image.get("x", 0)), float(image.get("y", 0))) / 35.0 - player_ship.layout_center) * ShipModel.TILE
|
||||
var last: Vector2 = first + Vector2(float(image.get("w", 0)), float(image.get("h", 0))) / 35.0 * ShipModel.TILE
|
||||
bounds = bounds.expand(Vector3(first.x, -0.16, first.y))
|
||||
bounds = bounds.expand(Vector3(last.x, 0.35, last.y))
|
||||
return bounds
|
||||
|
||||
|
||||
func _position_hud(delta: float, snap := false) -> void:
|
||||
if hud_surface == null or camera == null:
|
||||
return
|
||||
var viewer := camera.get_camera_transform()
|
||||
if gameplay_hud:
|
||||
hud_surface.global_transform = hud_anchor.update(player_ship.global_transform, _player_hud_local_bounds(), viewer, GAMEPLAY_HUD_SIZE, delta, snap)
|
||||
else:
|
||||
# Preserve the old full native menu framing; the HUD node itself can remain
|
||||
# outside the camera so gameplay can clamp its head-relative pose above
|
||||
# the ship when necessary.
|
||||
hud_surface.global_transform = viewer * Transform3D(Basis.IDENTITY.scaled(Vector3.ONE * 0.64), Vector3(0, -0.16, -1.8))
|
||||
|
||||
|
||||
func _create_navigation() -> void:
|
||||
@@ -314,7 +370,7 @@ func _create_navigation() -> void:
|
||||
legend.pixel_size = 0.001
|
||||
legend.no_depth_test = true
|
||||
legend.render_priority = 123
|
||||
legend.text = "Dpad ◀ / ▶: screen ▼: pause ▲: shortcuts\nL trigger: stations R bumper: 1–8 wheel"
|
||||
legend.text = "Dpad ◀ / ▶: screen ▼: tactical ▲: power\nView: pause R bumper: 1–8 wheel"
|
||||
nav_panel.add_child(legend)
|
||||
legend.visible = false
|
||||
help_label = ControllerHelp.new()
|
||||
@@ -330,7 +386,7 @@ func _create_navigation() -> void:
|
||||
tracking_label = Label3D.new()
|
||||
tracking_label.font = UiAssets.font("body")
|
||||
tracking_label.outline_size = 0
|
||||
tracking_label.position = Vector3(0, -0.86, -1.8)
|
||||
tracking_label.position = Vector3(0.28, -0.34, -1.4)
|
||||
tracking_label.font_size = 24
|
||||
tracking_label.pixel_size = 0.0008
|
||||
tracking_label.no_depth_test = true
|
||||
@@ -535,6 +591,12 @@ func _toggle_tactical() -> void:
|
||||
_open_hand_page("navigation" if hud_state.get("tactical", false) else "tactical")
|
||||
|
||||
|
||||
func _toggle_power_page() -> void:
|
||||
if rename_keyboard.visible or not hud_state.get("ready", demo_mode) or hud_state.get("ui_mode", "") == "menu":
|
||||
return
|
||||
_open_hand_page("navigation" if nav_page == "power" else "power")
|
||||
|
||||
|
||||
func _change_system_power(direction: int) -> void:
|
||||
if nav_page != "power" or not _game_actions_available():
|
||||
return
|
||||
@@ -566,6 +628,10 @@ func _update_wheel() -> void:
|
||||
wheel.visible = not rename_keyboard.visible and (hud_state.get("event_open", false) or _game_actions_available())
|
||||
_refresh_wheel()
|
||||
if wheel.visible:
|
||||
if not hud_state.get("event_open", false) and not _game_actions_available():
|
||||
wheel_committed = true
|
||||
wheel.visible = false
|
||||
return
|
||||
wheel.global_position = _pointer_ray().from + Vector3(0, 0.17, -0.1)
|
||||
_face_front(wheel)
|
||||
wheel.choose(right_hand.get_vector2("primary"))
|
||||
@@ -588,9 +654,7 @@ func _refresh_wheel() -> void:
|
||||
return
|
||||
var player: Dictionary = hud_state.get("player", {})
|
||||
if wheel_category == 0:
|
||||
for i in range(8):
|
||||
entries.append({"label": "W%d" % (i + 1) if i < 4 else "D%d" % (i - 3), "key": 49 + i,
|
||||
"enabled": i < player.get("weapons", []).size() if i < 4 else i - 4 < int(hud_state.get("drone_slots", 0))})
|
||||
entries = ShortcutWheel.equipment_entries(player, int(hud_state.get("drone_slots", 0)))
|
||||
wheel.set_entries(entries, "WEAPONS\nDRONES")
|
||||
elif wheel_category == 1:
|
||||
var systems: Dictionary = player.get("systems", {})
|
||||
@@ -605,7 +669,7 @@ func _refresh_wheel() -> void:
|
||||
|
||||
|
||||
func _commit_wheel() -> void:
|
||||
if rename_keyboard.visible:
|
||||
if rename_keyboard.visible or (not hud_state.get("event_open", false) and not _game_actions_available()):
|
||||
wheel_committed = true
|
||||
wheel.visible = false
|
||||
return
|
||||
@@ -689,10 +753,10 @@ func _process(delta: float) -> void:
|
||||
if tactical_down and not tactical_was_down:
|
||||
_toggle_tactical()
|
||||
tactical_was_down = tactical_down
|
||||
var shortcuts_down := left_hand.is_button_pressed("by_button") and (frame or nav_page != "power")
|
||||
if shortcuts_down and not shortcuts_was_down:
|
||||
_open_hand_page("shortcuts" if nav_page != "shortcuts" else "navigation")
|
||||
shortcuts_was_down = shortcuts_down
|
||||
var power_page_down := left_hand.is_button_pressed("by_button") and (frame or nav_page != "power")
|
||||
if power_page_down and not power_page_was_down:
|
||||
_toggle_power_page()
|
||||
power_page_was_down = power_page_down
|
||||
var page_left := left_hand.is_button_pressed("page_left")
|
||||
var page_right := left_hand.is_button_pressed("page_right")
|
||||
if page_left and not page_left_was_down:
|
||||
@@ -726,6 +790,7 @@ func _process(delta: float) -> void:
|
||||
if hover_elapsed >= 0.12 and not wheel.visible:
|
||||
hover_elapsed = 0.0
|
||||
_hover_ui(ray.from, ray.direction)
|
||||
_position_hud(delta)
|
||||
|
||||
|
||||
func _face_navigation_to_headset(delta: float = 1.0 / 90.0) -> void:
|
||||
@@ -1007,6 +1072,8 @@ func _update_hud() -> void:
|
||||
pause_label.visible = bool(state.get("ready", demo_mode)) and not rename_keyboard.visible and player_paused
|
||||
player_ship.set_shields(shield_level, int(state.get("super_shield", 0)))
|
||||
enemy_ship.set_shields(int(state.get("enemy_shield", 2)), int(state.get("enemy_super_shield", 0)))
|
||||
_sync_hud_layout()
|
||||
_position_hud(0.0)
|
||||
|
||||
|
||||
func _demo_shot(kind: String, source: String) -> void:
|
||||
@@ -1308,6 +1375,7 @@ func _navigation_entries() -> Array:
|
||||
if hud_state.get("navigation", {}).get(name, false):
|
||||
entries.append([name.to_upper(), name])
|
||||
entries.append(["SHORTCUTS", "page:shortcuts"])
|
||||
entries.append(["SYSTEM POWER", "page:power"])
|
||||
entries.append(["TACTICAL VIEW", "tactical"])
|
||||
entries.append(["MENU / BACK", "menu"])
|
||||
entries.append(["CONTROLS / HELP", "page:help"])
|
||||
@@ -1389,7 +1457,7 @@ func _refresh_navigation() -> void:
|
||||
func _hover_ui(from: Vector3, direction: Vector3) -> void:
|
||||
if keyboard_press or rename_keyboard.visible:
|
||||
return
|
||||
if nav_page == "power":
|
||||
if nav_page == "power" and _game_actions_available():
|
||||
# Local card hover is handled before power buttons every frame. A
|
||||
# stationary ray must not undo a system selected with X/Y.
|
||||
return
|
||||
@@ -1565,6 +1633,8 @@ func _recenter() -> void:
|
||||
tabletop_root.global_position = head + Vector3(0, -0.95, -0.85)
|
||||
else:
|
||||
tabletop_root.position.y = 0.7
|
||||
hud_anchor.reset()
|
||||
_position_hud(0.0, true)
|
||||
|
||||
|
||||
func _local_data_path(file_name: String) -> String:
|
||||
|
||||
+34
-18
@@ -9,6 +9,8 @@ var breaches: MultiMeshInstance3D
|
||||
var air_streams: MultiMeshInstance3D
|
||||
var fire_points: Array[Vector3] = []
|
||||
var breach_points: Array[Vector3] = []
|
||||
var breach_air_points: Array[Vector3] = []
|
||||
var breach_air_strengths: Array[float] = []
|
||||
var animation_time := 0.0
|
||||
var native_signature := ""
|
||||
|
||||
@@ -23,17 +25,18 @@ func build() -> void:
|
||||
flame_inner = _batch("FireInner", [
|
||||
Voxels.block(Vector3(0, 0.019, 0), Vector3(0.038, 0.037, 0.039), Color("ffde80")),
|
||||
Voxels.block(Vector3(-0.004, 0.045, 0), Vector3(0.019, 0.028, 0.022), Color("fff1b3"))], true)
|
||||
var breach_blocks := [Voxels.block(Vector3(0, 0.0, 0), Vector3(0.085, 0.003, 0.085), Color("090f17"))]
|
||||
for i in range(8):
|
||||
var angle := i * TAU / 8.0
|
||||
var radius := 0.046 if i % 2 == 0 else 0.049
|
||||
breach_blocks.append(Voxels.block(Vector3(cos(angle) * radius, 0.003, sin(angle) * radius),
|
||||
Vector3(0.025, 0.011, 0.021), Color("9a8b77") if i % 2 == 0 else Color("566772")))
|
||||
# Vanilla's breach is a small black cross at the damaged tile. A shallow
|
||||
# steel lip gives it depth without replacing its familiar silhouette.
|
||||
var breach_blocks := [
|
||||
Voxels.block(Vector3(0, 0, 0), Vector3(0.121, 0.002, 0.045), Color("71818a")),
|
||||
Voxels.block(Vector3(0, 0, 0), Vector3(0.045, 0.002, 0.121), Color("71818a")),
|
||||
Voxels.block(Vector3(0, 0.002, 0), Vector3(0.111, 0.003, 0.035), Color("020407")),
|
||||
Voxels.block(Vector3(0, 0.002, 0), Vector3(0.035, 0.003, 0.111), Color("020407"))]
|
||||
breaches = _batch("HullBreaches", breach_blocks, false)
|
||||
air_streams = _batch("BreachAir", [
|
||||
Voxels.block(Vector3(-0.018, 0.029, 0), Vector3(0.004, 0.040, 0.004), Color("6c9caa")),
|
||||
Voxels.block(Vector3(0.008, 0.044, -0.010), Vector3(0.004, 0.035, 0.004), Color("9ac4cc")),
|
||||
Voxels.block(Vector3(0.016, 0.063, 0.014), Vector3(0.004, 0.029, 0.004), Color("6c9caa"))], true)
|
||||
Voxels.block(Vector3(-0.018, 0.044, 0), Vector3(0.004, 0.063, 0.004), Color("a7d0d9")),
|
||||
Voxels.block(Vector3(0.008, 0.061, -0.010), Vector3(0.003, 0.050, 0.003), Color("e0edf0")),
|
||||
Voxels.block(Vector3(0.016, 0.080, 0.014), Vector3(0.003, 0.040, 0.003), Color("a7d0d9"))], true)
|
||||
|
||||
|
||||
func _batch(node_name: String, blocks: Array, emissive: bool) -> MultiMeshInstance3D:
|
||||
@@ -44,12 +47,15 @@ func _batch(node_name: String, blocks: Array, emissive: bool) -> MultiMeshInstan
|
||||
material.emission_enabled = emissive
|
||||
material.emission = Color("ffab60") if node_name.begins_with("Fire") else Color("527a8b")
|
||||
material.emission_energy_multiplier = 0.22
|
||||
if node_name == "BreachAir":
|
||||
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
shared_visuals[node_name] = {"mesh": source.mesh, "material": material}
|
||||
source.free()
|
||||
var batch := MultiMeshInstance3D.new()
|
||||
batch.name = node_name
|
||||
batch.multimesh = MultiMesh.new()
|
||||
batch.multimesh.transform_format = MultiMesh.TRANSFORM_3D
|
||||
batch.multimesh.use_colors = node_name == "BreachAir"
|
||||
batch.multimesh.mesh = shared_visuals[node_name].mesh
|
||||
batch.material_override = shared_visuals[node_name].material
|
||||
batch.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
@@ -60,6 +66,8 @@ func _batch(node_name: String, blocks: Array, emissive: bool) -> MultiMeshInstan
|
||||
func set_live(ship: Node3D, data: Dictionary) -> void:
|
||||
var fires: Array[Vector3] = []
|
||||
var holes: Array[Vector3] = []
|
||||
var air: Array[Vector3] = []
|
||||
var air_strength: Array[float] = []
|
||||
for room in data.get("rooms", []):
|
||||
if bool(ship.enemy) and (not bool(ship.inspect_rooms) or not bool(room.get("visible", false))):
|
||||
continue
|
||||
@@ -68,24 +76,31 @@ func set_live(ship: Node3D, data: Dictionary) -> void:
|
||||
fires.append(ship.pixel_point(point, 0.082))
|
||||
for point in room.get("breach_tiles", []):
|
||||
if holes.size() < MAX_HAZARDS:
|
||||
holes.append(ship.pixel_point(point, 0.082))
|
||||
var signature := str(fires) + ":" + str(holes)
|
||||
var location: Vector3 = ship.pixel_point(point, 0.082)
|
||||
holes.append(location)
|
||||
var oxygen := clampf(float(room.get("oxygen", 100.0)) / 100.0, 0.0, 1.0)
|
||||
if oxygen > 0.0:
|
||||
air.append(location)
|
||||
air_strength.append(oxygen)
|
||||
var signature := str(fires) + ":" + str(holes) + ":" + str(air) + ":" + str(air_strength)
|
||||
if signature == native_signature:
|
||||
return
|
||||
native_signature = signature
|
||||
fire_points = fires
|
||||
breach_points = holes
|
||||
flame_outer.multimesh.instance_count = fires.size()
|
||||
flame_inner.multimesh.instance_count = fires.size()
|
||||
breaches.multimesh.instance_count = holes.size()
|
||||
air_streams.multimesh.instance_count = holes.size()
|
||||
breach_air_points = air
|
||||
breach_air_strengths = air_strength
|
||||
for entry in [[flame_outer, fires.size()], [flame_inner, fires.size()], [breaches, holes.size()], [air_streams, air.size()]]:
|
||||
var batch: MultiMeshInstance3D = entry[0]
|
||||
if batch.multimesh.instance_count != entry[1]:
|
||||
batch.multimesh.instance_count = entry[1]
|
||||
for i in range(holes.size()):
|
||||
breaches.multimesh.set_instance_transform(i, Transform3D(Basis().rotated(Vector3.UP, (i % 4) * PI / 2.0), holes[i]))
|
||||
_render_animation()
|
||||
|
||||
|
||||
func animate(delta: float) -> void:
|
||||
if fire_points.is_empty() and breach_points.is_empty():
|
||||
if fire_points.is_empty() and breach_air_points.is_empty():
|
||||
return
|
||||
animation_time += delta
|
||||
_render_animation()
|
||||
@@ -98,6 +113,7 @@ func _render_animation() -> void:
|
||||
outer = Basis(outer.x * (0.92 + sin(phase) * 0.07), outer.y * (0.87 + sin(phase + 0.8) * 0.16), outer.z)
|
||||
flame_outer.multimesh.set_instance_transform(i, Transform3D(outer, fire_points[i]))
|
||||
flame_inner.multimesh.set_instance_transform(i, Transform3D(Basis().scaled(Vector3(1.0, 0.88 + sin(phase + 1.7) * 0.18, 1.0)), fire_points[i]))
|
||||
for i in range(breach_points.size()):
|
||||
for i in range(breach_air_points.size()):
|
||||
var phase := fposmod(animation_time * 0.7 + i * 0.19, 1.0)
|
||||
air_streams.multimesh.set_instance_transform(i, Transform3D(Basis().scaled(Vector3(1.0, 0.6 + phase * 0.6, 1.0)), breach_points[i] + Vector3.UP * phase * 0.025))
|
||||
air_streams.multimesh.set_instance_transform(i, Transform3D(Basis().scaled(Vector3(1.0, 0.6 + phase * 0.6, 1.0)), breach_air_points[i] + Vector3.UP * phase * 0.035))
|
||||
air_streams.multimesh.set_instance_color(i, Color(1, 1, 1, (1.0 - phase) * minf(0.72, breach_air_strengths[i])))
|
||||
@@ -308,6 +308,24 @@ func _set_room_role(area: Area3D, role: String) -> void:
|
||||
area.add_child(label)
|
||||
|
||||
|
||||
func _set_room_condition(area: Area3D, row: Dictionary, allowed: bool) -> float:
|
||||
# Damage stays in the installed-system icon and floor colour. Native
|
||||
# state changes update these cues immediately, including during pause.
|
||||
var maximum := clampi(int(row.get("max_health", 0)), 0, 32)
|
||||
var health := clampi(int(row.get("health", maximum)), 0, maximum)
|
||||
var has_health := row.has("health") and row.has("max_health") and maximum > 0
|
||||
var shown := allowed and has_health and not str(area.get_meta("system_role", "")).is_empty()
|
||||
var fraction := float(maximum - health) / maximum if shown else 0.0
|
||||
var color := Color("f26043") if health == 0 else Color("ffad44")
|
||||
var icon: MeshInstance3D = area.get_node_or_null("SystemIcon")
|
||||
var role: Label3D = area.get_node_or_null("SystemRole")
|
||||
var role_color := color if fraction > 0.0 else Color("fff2c4") if enemy else Color("223844")
|
||||
if icon != null:
|
||||
icon.material_override.albedo_color = role_color
|
||||
if role != null:
|
||||
role.modulate = role_color
|
||||
return fraction
|
||||
|
||||
func set_drop_target(room_id: int = -1) -> void:
|
||||
if drop_target_room == room_id:
|
||||
return
|
||||
@@ -650,6 +668,10 @@ func pixel_point(point: Dictionary, height: float = 0.18) -> Vector3:
|
||||
|
||||
func apply_live(data: Dictionary) -> void:
|
||||
live_data = data
|
||||
var system_rows: Dictionary = {}
|
||||
for row in data.get("system_status", []):
|
||||
if row is Dictionary and row.has("room_id"):
|
||||
system_rows[int(row["room_id"])] = row
|
||||
room_visibility.clear()
|
||||
for room in data.get("rooms", []):
|
||||
room_visibility[int(room["id"])] = bool(room.get("visible", false))
|
||||
@@ -696,11 +718,25 @@ func apply_live(data: Dictionary) -> void:
|
||||
visual.visible = not enemy or inspect_rooms
|
||||
# Installed systems remain useful targeting information in vanilla
|
||||
# FTL when enemy crew / oxygen are obscured by the sensor fog.
|
||||
var row: Dictionary = system_rows.get(id, {})
|
||||
# Health is private information unless the native snapshot explicitly
|
||||
# reveals it. Old snapshots may reveal only a room's visible interior.
|
||||
var health_allowed := not enemy or bool(row.get("health_visible", room.get("visible", false)))
|
||||
var detail_allowed := not enemy or bool(row.get("health_detail_visible", room.get("visible", false)))
|
||||
var damage_fraction := _set_room_condition(area, row, health_allowed)
|
||||
var floor_mesh: MeshInstance3D = area.get_node("Floor")
|
||||
var oxygen := float(room.get("oxygen", 100)) / 100.0
|
||||
var tint := Color(0.9, 0.22, 0.08) if int(room.get("fires", 0)) > 0 else Color(0.65, 0.7, 0.73).lerp(Color(0.95, 0.38, 0.4), 1.0 - oxygen)
|
||||
if enemy and not bool(room.get("visible", false)):
|
||||
tint = Color(0.12, 0.14, 0.17)
|
||||
if damage_fraction > 0.0:
|
||||
# Public orange/red icons disclose partial/total damage, not exact
|
||||
# levels. Keep that coarse presentation until native details unlock.
|
||||
var severity := damage_fraction if detail_allowed else 1.0 if damage_fraction >= 1.0 else 0.5
|
||||
var strength := 0.4 + severity * 0.45
|
||||
if enemy and not bool(room.get("visible", false)):
|
||||
strength = 0.22 + severity * 0.12
|
||||
tint = tint.lerp(Color("8e2924") if damage_fraction >= 1.0 else Color("c98937"), strength)
|
||||
floor_mesh.set_meta("normal_tint", tint)
|
||||
if id == drop_target_room:
|
||||
tint = Color(0.35, 0.95, 0.71)
|
||||
|
||||
+136
-17
@@ -11,6 +11,81 @@ var heading := "ACTIONS"
|
||||
var viewport: SubViewport
|
||||
var canvas: Control
|
||||
var content_revision := 0
|
||||
var display_rows: Array = []
|
||||
const LABEL_WIDTH := 136.0
|
||||
const LABEL_SIZE := 23
|
||||
|
||||
|
||||
static func equipment_entries(player: Dictionary, drone_slots: int) -> Array:
|
||||
# Slot keys remain the native 1–8 shortcuts. Only equipment metadata is
|
||||
# retained: positions, charge clocks and moving special actors cannot make
|
||||
# the held wheel redraw on each state snapshot.
|
||||
var result: Array = []
|
||||
var weapons: Array = _equipment_rows(player.get("weapons", []))
|
||||
var drones: Array = _equipment_rows(player.get("drone_equipment", player.get("drones", [])))
|
||||
var has_drone_equipment := player.has("drone_equipment")
|
||||
for slot in range(8):
|
||||
var weapon_slot := slot < 4
|
||||
var index := slot if weapon_slot else slot - 4
|
||||
var equipment: Dictionary = {}
|
||||
var source: Array = weapons if weapon_slot else drones
|
||||
for i in range(source.size()):
|
||||
if not source[i] is Dictionary:
|
||||
continue
|
||||
var item: Dictionary = source[i]
|
||||
var native_slot := int(item.get("slot", i)) if weapon_slot or has_drone_equipment else int(item.get("equipment_slot", item.get("slot", -1)))
|
||||
if native_slot == index:
|
||||
equipment = item
|
||||
break
|
||||
var enabled := not equipment.is_empty() if weapon_slot else index < drone_slots
|
||||
var family := str(equipment.get("kind", "drone" if not weapon_slot else "laser")).to_lower()
|
||||
family = {"missiles":"missile", "burst":"flak"}.get(family, family)
|
||||
var name := str(equipment.get("title", equipment.get("short_title", ""))).strip_edges()
|
||||
if name == "":
|
||||
name = str(equipment.get("name", "")).replace("_", " ").strip_edges()
|
||||
if name == "":
|
||||
name = "DRONE %d" % (index + 1) if enabled and not weapon_slot else "EMPTY"
|
||||
var missile_cost := int(equipment.get("ammo_cost", equipment.get("missile_cost", equipment.get("missiles", 1 if family in ["missile", "bomb"] else 0))))
|
||||
var detail := "%d MISSILE%s" % [missile_cost, "S" if missile_cost != 1 else ""] if weapon_slot and missile_cost > 0 else family.to_upper() if enabled else "NO EQUIPMENT"
|
||||
result.append({"label":name,"key":49 + slot,"enabled":enabled,
|
||||
"icon_kind":family if enabled else "empty", "detail":detail,
|
||||
"equipment":true,"weapon":weapon_slot})
|
||||
return result
|
||||
|
||||
|
||||
static func _equipment_rows(value: Variant) -> Array:
|
||||
# Empty native Lua tables serialize as {}. Treat a missing equipment list
|
||||
# as empty so a ship without drones still exposes its weapon shortcuts.
|
||||
return value if value is Array else []
|
||||
|
||||
|
||||
static func bounded_lines(value: String, width: float = LABEL_WIDTH, size_px: int = LABEL_SIZE, limit: int = 3) -> Array[String]:
|
||||
# Fit using the actual locally loaded glyph metrics, including long words
|
||||
# and translations. Fixed character counts cannot bound native font ink.
|
||||
var words := value.replace("\n", " ").split(" ", false)
|
||||
var output: Array[String] = []
|
||||
var line := ""
|
||||
for word in words:
|
||||
var next := str(word) if line == "" else line + " " + str(word)
|
||||
if UI.text_size(next, size_px).x <= width:
|
||||
line = next
|
||||
continue
|
||||
if line != "":
|
||||
output.append(line)
|
||||
line = ""
|
||||
for character in str(word):
|
||||
if line != "" and UI.text_size(line + character, size_px).x > width:
|
||||
output.append(line)
|
||||
line = ""
|
||||
line += character
|
||||
if line != "": output.append(line)
|
||||
if output.size() > limit:
|
||||
output.resize(limit)
|
||||
var last := output[limit - 1]
|
||||
while last != "" and UI.text_size(last + "...", size_px).x > width:
|
||||
last = last.left(last.length() - 1)
|
||||
output[limit - 1] = last + "..."
|
||||
return output
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -46,6 +121,10 @@ func set_entries(value: Array, title: String) -> void:
|
||||
return
|
||||
entries = value.duplicate(true)
|
||||
heading = title
|
||||
display_rows.clear()
|
||||
for entry in entries:
|
||||
display_rows.append({"lines":bounded_lines(str(entry.get("label", "")),LABEL_WIDTH,LABEL_SIZE,2 if entry.get("equipment",false) else 3),
|
||||
"detail":bounded_lines(str(entry.get("detail", "")), LABEL_WIDTH, 18, 1) if entry.get("enabled",true) else []})
|
||||
_redraw()
|
||||
|
||||
|
||||
@@ -68,6 +147,43 @@ func _redraw() -> void:
|
||||
class WheelCanvas extends Control:
|
||||
var wheel: Node3D
|
||||
const CENTER := Vector2(360,353)
|
||||
var sectors: Array[PackedVector2Array] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
for i in range(8): sectors.append(_sector(i * TAU / 8.0))
|
||||
|
||||
|
||||
func _equipment_icon(kind: String, center: Vector2, color: Color) -> void:
|
||||
# Original vector silhouettes: no extracted sprite, new viewport or
|
||||
# mesh allocation. Shape + ammo caption distinguishes similar names.
|
||||
match kind:
|
||||
"beam":
|
||||
draw_line(center + Vector2(-24,8), center + Vector2(-13,-8), color, 4)
|
||||
draw_line(center + Vector2(-13,-8), center + Vector2(24,-8), color, 3)
|
||||
draw_line(center + Vector2(-13,-3), center + Vector2(24,-3), color, 1)
|
||||
"ion":
|
||||
draw_arc(center, 10, 0, TAU, 16, color, 3)
|
||||
draw_line(center + Vector2(-23,0), center + Vector2(-13,0), color, 3)
|
||||
draw_line(center + Vector2(13,0), center + Vector2(23,0), color, 3)
|
||||
"missile":
|
||||
draw_colored_polygon(PackedVector2Array([center + Vector2(-21,-6),center + Vector2(10,-6),center + Vector2(22,0),center + Vector2(10,6),center + Vector2(-21,6)]),color)
|
||||
draw_line(center + Vector2(-11,-11),center + Vector2(-11,11),color,3)
|
||||
"bomb":
|
||||
draw_circle(center + Vector2(-2,2),10,color)
|
||||
draw_line(center + Vector2(3,-7),center + Vector2(13,-13),color,3)
|
||||
draw_line(center + Vector2(13,-13),center + Vector2(20,-9),color,2)
|
||||
"flak":
|
||||
for offset in [Vector2(-17,3),Vector2(-3,-8),Vector2(11,5),Vector2(22,-7)]: draw_rect(Rect2(center + offset - Vector2(3,3),Vector2(6,6)),color)
|
||||
"laser":
|
||||
draw_rect(Rect2(center + Vector2(-23,-5),Vector2(20,10)),color)
|
||||
draw_line(center + Vector2(-3,0),center + Vector2(23,0),color,3)
|
||||
"empty":
|
||||
draw_line(center + Vector2(-12,0),center + Vector2(12,0),color,2)
|
||||
_:
|
||||
draw_rect(Rect2(center - Vector2(10,7),Vector2(20,14)),color,false,2)
|
||||
draw_line(center + Vector2(-23,0),center + Vector2(-10,0),color,3)
|
||||
draw_line(center + Vector2(10,0),center + Vector2(23,0),color,3)
|
||||
|
||||
|
||||
func _sector(angle: float) -> PackedVector2Array:
|
||||
@@ -87,28 +203,31 @@ class WheelCanvas extends Control:
|
||||
var enabled: bool = i < wheel.entries.size() and bool(wheel.entries[i].get("enabled",true))
|
||||
var active: bool = wheel.selected == i and enabled
|
||||
var angle := i * TAU / 8.0
|
||||
var outline := _sector(angle)
|
||||
var outline := sectors[i]
|
||||
draw_colored_polygon(outline,UI.SELECTED if active else UI.PANEL if enabled else UI.DARK)
|
||||
outline.append(outline[0])
|
||||
draw_polyline(outline,UI.GOLD if active else UI.EDGE if enabled else UI.MUTED,1.5,false)
|
||||
draw_line(outline[outline.size() - 1],outline[0],UI.GOLD if active else UI.EDGE if enabled else UI.MUTED,1.5)
|
||||
var center := CENTER + Vector2(sin(angle),-cos(angle)) * 225
|
||||
UI.centered_text(self,str(i + 1),center + Vector2(0,-22),38,UI.GOLD if active else UI.GREEN if enabled else UI.MUTED,"header")
|
||||
var label := str(wheel.entries[i].get("label","")) if i < wheel.entries.size() else "-"
|
||||
var lines := label.replace("\n"," ").split(" ",false)
|
||||
var current := ""
|
||||
var wrapped: Array[String] = []
|
||||
for word in lines:
|
||||
if current.length() + word.length() + 1 > 12 and current != "":
|
||||
wrapped.append(current)
|
||||
current = ""
|
||||
current = word if current == "" else current + " " + word
|
||||
if current != "": wrapped.append(current)
|
||||
for line in range(mini(3,wrapped.size())):
|
||||
UI.centered_text(self,wrapped[line],center + Vector2(0,11 + line * 21),24,UI.INK if enabled else UI.MUTED)
|
||||
var entry: Dictionary = wheel.entries[i] if i < wheel.entries.size() else {}
|
||||
var equipment := bool(entry.get("equipment", false))
|
||||
UI.centered_text(self,str(i + 1),center + Vector2(0,-55 if equipment else -22),38,UI.GOLD if active else UI.GREEN if enabled else UI.MUTED,"header")
|
||||
if equipment: _equipment_icon(str(entry.get("icon_kind", "empty")),center + Vector2(0,-20),UI.GOLD if active else UI.GREEN if enabled else UI.MUTED)
|
||||
var row: Dictionary = wheel.display_rows[i] if i < wheel.display_rows.size() else {"lines":["-"],"detail":[]}
|
||||
var wrapped: Array = row.lines
|
||||
for line in range(wrapped.size()):
|
||||
UI.centered_text(self,str(wrapped[line]),center + Vector2(0,9 + line * 22),LABEL_SIZE,UI.INK if enabled else UI.MUTED)
|
||||
if equipment and not row.detail.is_empty():
|
||||
UI.centered_text(self,str(row.detail[0]),center + Vector2(0,56),18,UI.GOLD if enabled else UI.MUTED)
|
||||
UI.frame(self,Rect2(CENTER - Vector2(102,56),Vector2(204,112)),UI.DARK,UI.EDGE,17)
|
||||
var titles := str(wheel.heading).split("\n")
|
||||
for i in range(titles.size()):
|
||||
UI.centered_text(self,titles[i],CENTER + Vector2(0,(i - (titles.size() - 1) * .5) * 29),25,UI.INK,"header")
|
||||
UI.frame(self,Rect2(38,700,644,88),UI.DARK,UI.EDGE,12)
|
||||
UI.centered_text(self,"STICK: CHOOSE RELEASE BUMPER: CONFIRM",Vector2(360,727),24,UI.INK)
|
||||
UI.centered_text(self,"A: CATEGORY B / CENTER: CANCEL",Vector2(360,760),24,UI.MUTED)
|
||||
var selected: Dictionary = wheel.entries[wheel.selected] if wheel.selected >= 0 and wheel.selected < wheel.entries.size() else {}
|
||||
var caption := str(selected.get("label", "STICK: CHOOSE RELEASE BUMPER: CONFIRM"))
|
||||
if selected.get("equipment",false) and selected.get("enabled",true): caption += " / " + str(selected.get("detail",""))
|
||||
var caption_lines: Array = wheel.bounded_lines(caption,608,23,1)
|
||||
UI.centered_text(self,str(caption_lines[0]) if not caption_lines.is_empty() else "",Vector2(360,716),23,UI.INK)
|
||||
UI.centered_text(self,"A: CATEGORY B / CENTER: CANCEL TRIGGER: CONFIRM",Vector2(360,745),20,UI.MUTED)
|
||||
var hint := "SHIFT: DEPOWER CTRL: REVERSE AUTOFIRE" if bool(selected.get("weapon", false)) else "SHIFT: DEPOWER RELEASE BUMPER: CONFIRM" if bool(selected.get("equipment", false)) else "RELEASE BUMPER: CONFIRM"
|
||||
UI.centered_text(self,hint,Vector2(360,772),20,UI.GOLD)
|
||||
@@ -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