Add native room locks, jump stars and refined drones

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

No files matched your search

+11
View File
@@ -1,5 +1,16 @@
# Changelog
## Targeting, jump and drone refinements — source update, 2026-10-03
- Display the player's native placed weapon targets as persistent numbered room locks, red for a single volley and yellow for autofire. Preserve real beam endpoints/direction and native flak radius; keep marks attached during pause and encounter transforms, and clear them when native targets or the encounter end.
- Stretch surrounding stars along the player ship's bow only while FTL reports an actual jump. Restore normal stars on arrival and arrival-dialog opening.
- Reset each action-wheel opening to the weapons/drones category; event dialogs retain their choice wheel.
- Refine procedural drone armor and family silhouettes, distinguish Combat/Beam/Defense Mk II variants, and reuse immutable meshes/materials. Native drone bullets leave visible muzzles in their current render space; their launch point stays fixed as the drone moves, while continuing beams remain connected to the emitter.
- Make the native intruder warning's backdrop transparent within its HUD region, retaining the warning glyphs, faint shadow/fringe coverage and neighboring controls.
- Import eight owner-local placed-target PNGs for slots 1–4 and their yellow/autofire variants. Existing installations must rerun [asset extraction](docs/INSTALLATION.md#5-extract-local-presentation-assets) with the current extractor against their original owned archive.
Verification: 44 Python tests, eleven Godot suites and inspected Vulkan target/jump/drone fixtures pass. Twelve actual native warning blink captures preserve glyphs and ordinary HUD ink while correcting the dark fringes. A fresh copied profile verifies actual room targeting/clearing/autofire and a real connected-beacon jump through its arrival dialog. Production saves and executable fingerprints are preserved. These checks make no new physical Steam Frame or stereo-performance claim.
## HUD clearance and menu correction — source update, 2026-10-03
- Reduce the HUD's collision clearance above the ship/shield from 14 cm to 8 cm. Headset-following priority is unchanged.
+11 -5
View File
@@ -12,11 +12,11 @@ An experimental OpenXR mod for **FTL: Faster Than Light**. Walk around a miniatu
- Floating player and enemy ships with opposing bows, room interiors, visible shields and movable encounter placement.
- Compact original HUD following the headset, with 8 cm 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.
- Crew selection and drag-to-room orders, door controls, weapon/beam targeting, persistent numbered native weapon locks with autofire colors/beam direction/flak spread, and native system targeting.
- Contextual Jump/Ship/Store actions, a cockpit-anchored sector map, floating event dialogs and complete native shop/ship/Options panels displayed only above the ship during a run.
- 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.
- An eight-slot action wheel that opens on weapons/drones each time, 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 distinct drone families/Mk II variants, native drone muzzle shots, weapon charge indicators, room-condition feedback, cross-shaped breaches and native miss cues.
- Surrounding stars that stretch along the ship's bow during an actual native jump, plus 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.
## Start here
@@ -66,11 +66,17 @@ The [controls guide](docs/CONTROLS.md) explains crew movement, room targeting, t
## Current state
The latest refinement displays the player's real numbered weapon target marks on rooms, using red for a single volley and yellow for native autofire. Beam locks retain both native endpoints and direction; flak shows its native spread radius. Locks stay attached while the encounter is moved or paused and clear when native targets or the encounter end. Jump stars follow the player's ship bow and return to normal on arrival, including when the arrival dialog opens. Each wheel opening defaults to weapons/drones. Procedural drone models now distinguish Combat, Beam and Defense Mk II hardware; native shots leave their visible muzzles. A scoped transparency mask preserves the native intruder warning's lettering and shadow/fringe while removing its backdrop.
**Existing installations must rerun [local asset extraction](docs/INSTALLATION.md#5-extract-local-presentation-assets) with the updated `tools/extract_ftl.py` against the original owned archive.** It now imports eight placed-target reticle PNGs: slots 1–4 in normal and yellow/autofire variants. These game assets remain local and are not distributed with the source.
The maintainer has played substantial portions of runs on Steam Frame and confirmed that earlier revisions are playable. The compact gameplay HUD follows the headset first and stops above the ship only when it would intersect the hull or shield. Its collision clearance is now 8 cm, bringing it 6 cm closer. In-run Ship, pause-menu and Options windows appear only above the ship, without a duplicate on the headset HUD. 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.
The **current source** passed 40 Python tests and focused Vulkan HUD/input checks. Native before/after captures verified that Ship, pause-menu and Options windows remain complete above the ship while their duplicate HUD pixels are absent. Both local folders passed their VR-wrapper desktop smoke launches with clean client logs and no bridge errors; setup and VR readiness checks passed separately. Campaign files remained unchanged. **Existing installations must refresh their local hooks** using [Installation step 4](docs/INSTALLATION.md#4-resolve-the-local-executable-hooks).
The current source passed **44 Python tests and eleven Godot suites**, with inspected Vulkan target/jump/drone captures. A disposable copied run verified actual normal/cleared/autofire targets and a real connected-beacon jump through its arrival dialog. Twelve native intruder-warning blink captures verified transparency without losing glyphs or ordinary HUD ink. Both prepared launch folders passed setup and desktop Vulkan launch/exit checks; all four original campaign hashes and both game executable fingerprints are preserved. These checks do not establish physical Steam Frame comfort or stereo performance.
The preceding HUD clearance/menu revision passed 40 Python tests and focused Vulkan HUD/input checks. Its native before/after captures verified that Ship, pause-menu and Options windows remain complete above the ship while their duplicate HUD pixels are absent. Both local folders passed their VR-wrapper desktop smoke launches with clean client logs and no bridge errors; setup and VR readiness checks passed separately. Campaign files remained unchanged. **Existing installations must also refresh their local hooks** using [Installation step 4](docs/INSTALLATION.md#4-resolve-the-local-executable-hooks) if they have not applied that revision.
The preceding revision passed nine Godot suites and combined desktop launch/exit checks. Physical Steam Frame comfort and stereo performance remain to test for these changes. 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.
+10
View File
@@ -261,6 +261,15 @@ local function ship_snapshot(ship, gui)
num_shots=weapon.numShots,shots_fired=weapon.shotsFiredAtTarget,queued_shots=weapon.queuedProjectiles:size(),
sub_charge=weapon.subCooldown.first,sub_cooldown=weapon.subCooldown.second,
muzzle=point(weapon.weaponVisual.fireLocation), firing=weapon.weaponVisual.bFiring}
-- Display the player's real placed aiming marks, never an enemy's intent.
if ship.iShipId==0 then
local row=result.weapons[#result.weapons]
row.targets={}
for n=0,weapon.targets:size()-1 do row.targets[#row.targets+1]=point(weapon.targets[n]) end
row.target_ship=weapon.targetId
row.target_radius=weapon.radius
row.beam_length=weapon.blueprint.length
end
end
return result
end
@@ -275,6 +284,7 @@ local function snapshot()
if app.menu.bOpen or not state.ready then state.ui_mode='menu' end
state.ship_builder_open=app.menu.shipBuilder.bOpen
state.event_pending = gui.event_pause and not gui.choiceBoxOpen
state.jumping = player and player.bJumping or false
state.transition = state.event_pending or (player and player.bJumping or false)
if state.ui_mode~='menu' and gui.menu_pause and not gui.choiceBoxOpen and not state.transition then state.ui_mode='screen' end
state.event_open = gui.choiceBoxOpen
+11
View File
@@ -33,6 +33,7 @@ The normal launcher coordinates both programs. Commands call the verified game p
| [tools/extract_ftl.py](../tools/extract_ftl.py) | Owner-local layouts, hull images, UI icons/artwork and bitmap fonts |
| [scripts/ship_model.gd](../scripts/ship_model.gd), `voxel_*.gd` | Volumetric hull presentation, doors, crew, weapons and drones |
| [scripts/combat_effects.gd](../scripts/combat_effects.gd), [scripts/space_environment.gd](../scripts/space_environment.gd) | Native-driven shot presentation and surrounding space/hazards |
| [scripts/target_locks.gd](../scripts/target_locks.gd) | Player-owned native placed target marks, weapon slot/autofire cues, beam direction and flak radius |
| `scripts/hud_*.gd`, `controller_*.gd`, `ftl_ui_*.gd` | Original captured HUD, themed hand screens, Help infographic and local font decoding |
| [scripts/shortcut_wheel.gd](../scripts/shortcut_wheel.gd), [scripts/rename_keyboard.gd](../scripts/rename_keyboard.gd) | Contextual shortcuts and native rename field interaction |
@@ -44,6 +45,12 @@ 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.
Placed weapon locks use the player's current native `targets`, `target_ship`, `autofire`, beam length and flak radius, independently of the currently armed cursor. The renderer does not infer locks from projectile history or expose enemy weapon intent. Native beam targets supply both endpoints and their sweep direction; incomplete endpoints do not create a fabricated beam lock. Room marks inherit the receiver's transform through pause, movement, tilt and scale. Clearing native targets or leaving the encounter removes their marks.
Space drones use native IDs and current render space to locate the procedural model's visible muzzle. Bullet launch points stay fixed in that space as the drone moves; a continuing native beam remains attached to its emitter. Ambiguous negative drone IDs use the native origin point at drone height. Native shot kind, live progress, presence and actual collision outcome control presentation. Model bodies, exhaust, emitters and tools share immutable geometry; emission state uses cached materials without changing another drone's glow. Combat, Beam and Defense Mk II variants have distinct hardware.
Jump stretch starts from the native ship `jumping` flag and follows the transformed player ship bow projected into the horizontal world plane. Opening the map or charging FTL does not start travel. Arrival/dialog state resets the sky, including the interval before the native jump flag finishes clearing. Native jump rendering can continue while combat simulation is paused. The old beacon's surrounding hazard presentation is hidden during travel.
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
@@ -56,12 +63,16 @@ Transport uses atomic raw RGBA files with an `FVR1` header, dimensions, sequence
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.
The native intruder warning receives a transparency mask scoped to its HUD region. It preserves warning glyph RGB/opacity, faint shadow/fringe coverage and neighboring controls while removing the dark backdrop. Each normal action-wheel opening resets to weapons/drones; event-choice context continues to use the native dialog identity.
## Local assets and generated data
`local_game_data/` is intentionally absent from this source package and ignored by Git. Each player extracts required content from their owned `ftl.dat`. The bridge can produce layout/image variants for encountered ships from that same local archive. The original soundtrack plays through FTL itself.
Hull artwork, system icons, fonts and captured game pixels are owner-local. Crew, weapon and drone geometry is created by the project's procedural model code. Shared immutable meshes/materials, cached icons/shields and batched room-hazard transforms reduce scene overhead.
The local extractor also imports eight native placed reticles: `img/misc/crosshairs_placed1.png` through `crosshairs_placed4.png` and their `_yellow` autofire variants. Existing installations must rerun the current extractor against their original owned archive. A procedural numbered/color fallback preserves targeting cues when those local images are absent; the source package contains no extracted reticle artwork.
During a live session local files include state, frame streams, the command queue, bridge status, tracking diagnostics and logs. These are transient operational files; do not commit them or include a live local-data folder in a source release.
## Isolation and version gates
+7 -1
View File
@@ -55,11 +55,13 @@ The game decides whether a change is allowed. One engine step changes one power
Enemy inspection reveals room roles; it does not reveal crew or hazards that FTL's sensors hide.
After a weapon target is placed, its native numbered mark stays on the room even when the targeting cursor closes. The number identifies the weapon slot: red marks indicate a single volley and yellow marks native autofire. Beam marks show both endpoints and the real sweep direction; flak marks include the native spread area. These marks remain attached while paused or while the table is moved, and disappear when the game clears the target or the encounter ends. They display your placed targets without revealing enemy weapon intent or hidden crew/system information.
## Shortcut wheel and event choices
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. 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.
Every normal wheel opening starts with weapon slots 1–4 and drone slots 5–8. Changing category with right A applies to that opening; the next opening returns to weapons/drones. 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,10 +84,14 @@ 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.
During an actual native jump, surrounding stars stretch along your ship's bow. Turning your head does not change the travel direction. Arrival restores normal stars, including when the arrival dialog opens. Opening the map or charging FTL alone does not start the stretch.
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 8 cm 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. Ship, shop, pause-menu and Options windows opened during a run appear only above the ship; point at that floating panel to use them. Initial/hangar menus and desktop Tactical/map inspection keep the complete native view.
The native intruder warning keeps its lettering and subtle shadow/fringe on a transparent backdrop; neighboring HUD controls remain visible and pointable.
To rename a ship or crew member, click its original name-edit field. A floating **QWERTY keyboard** appears:
- Right trigger selects keys; Shift changes case.
+7 -2
View File
@@ -29,16 +29,18 @@ After installing the documented Python requirements, run from the repository roo
python -m unittest discover -s tools -p "test_*.py"
```
The current source passed **40 Python tests**, including empty native drone-list, portable-launcher and modal-HUD hook/preflight 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.
The current suite passed **44 Python tests**, including empty native drone-list, portable-launcher and modal-HUD hook/preflight 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. Final Godot and fresh native-game checks for the latest targeting/jump/drone batch are pending; earlier results and focused drone fixture 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 nine suites below passed for the preceding HUD/wheel/bar revision in the configured development environment. The latest clearance/menu correction reran focused Vulkan HUD-layout and input checks, plus native before/after capture checks. 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.
The commands below list eleven suites, including the new target-lock and drone resource/variant checks. The preceding HUD/wheel/bar revision passed nine suites; the following clearance/menu correction reran focused Vulkan HUD-layout and input checks plus native before/after capture checks. The latest batch's final combined Godot run and fresh native proof are pending. 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. Existing installations should also rerun [asset extraction](INSTALLATION.md#5-extract-local-presentation-assets) to import the eight placed reticle PNGs.
```powershell
godot --headless --xr-mode off --path . --script tools/test_combat.gd -- --demo
godot --headless --xr-mode off --path . --script tools/test_target_locks.gd
godot --headless --xr-mode off --path . --script tools/test_drones.gd
godot --headless --xr-mode off --path . --script tools/test_input.gd -- --demo --desktop
godot --headless --xr-mode off --path . --script tools/test_models.gd -- --demo --desktop
godot --headless --xr-mode off --path . --script tools/test_environment.gd -- --demo --desktop
@@ -51,6 +53,8 @@ godot --headless --xr-mode off --path . --script tools/test_damage_visuals.gd --
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.
Target-lock checks cover numbered player-owned native targets, autofire color, beam endpoint direction, pinpoint beams, flak radius, transformed/pause attachment, receiver visibility and cleanup. Drone checks cover distinct Mk II hardware, shared immutable body/exhaust/emitter geometry and independent emission state. Combat checks include native drone muzzle/render-space placement, fixed bullet launch points, continuing beams and authoritative progress/pause/outcome handling. Jump checks use native `jumping` and arrival-dialog fixtures; the action-wheel context checks include resetting each opening to weapons/drones.
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.
## Native integration checks
@@ -87,6 +91,7 @@ Additional helpers:
|---|---|
| `tools/benchmark_effects.gd` | Renderer effect benchmark; desktop results only |
| `tools/capture_models.gd` | Local model/weapon gallery |
| `tools/capture_drones.gd` | Procedural drone gallery; `--shots` renders explicit native-schema laser/beam fixtures |
| `tools/capture_ship_details.gd` | Explicit visual fixture for door tiers, fire/breach effects and hull bar |
| `tools/capture_environment.gd` | Environmental presentation inspection |
| `tools/capture_native_models.gd` | Inspect models from an owner-local native snapshot |
+22 -8
View File
@@ -14,23 +14,37 @@ This is a playable experimental client connected to the real game. It is suitabl
| Navigation | Available actions only; actual sector map appears above/ahead of the pilot room and faces the viewer |
| Menus/dialogs | Main menus remain 2D; in-run native shop/ship, pause-menu and Options windows appear only above the ship; 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 |
| Targeting | Weapon and beam room targeting; persistent player-owned numbered native locks with red/yellow autofire colors, actual beam endpoints/direction and flak spread radius; native mind-control, teleporter and hacking target routing |
| 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, 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 and cross-shaped breach tiles, oxygen-dependent air wisps and surrounding asteroid, sun, nebula/storm and pulsar presentations |
| Drones | Distinct procedural space/interior families and Combat/Beam/Defense Mk II hardware; deployment/power state, native render-space placement and visible muzzle shots with native progress/outcomes |
| Hazards | Room fire and cross-shaped breach tiles, oxygen-dependent air wisps, surrounding asteroid/sun/nebula/storm/pulsar presentations and native jump star stretch along the ship bow with arrival reset |
| 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 |
| Input | Steam Frame actions, generic fallback profiles, controller infographic, equipment-name/family/ammo action wheel defaulting to weapons/drones on every opening, 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 clearance and menu correction
### Latest targeting, jump and drone refinements
The source now renders the player's native placed target state as numbered room locks, retaining autofire color, both beam endpoints and native flak radius. Marks remain attached through pause and encounter placement changes; missing targets, destroyed receivers and ended encounters remove them. Only player-owned weapon targets supply these marks. Jump stretch follows the transformed player ship bow and the native `jumping` flag; map opening/FTL charging do not start it. Arrival dialogs reset the sky even while a native jump flag is still clearing. Every wheel opening defaults to weapons/drones, with event choices retaining their separate context.
Procedural drone geometry has distinct family silhouettes and Combat/Beam/Defense Mk II variants. Positive native drone IDs attach shots to the visible muzzle in the drone's current render space; an ambiguous negative ID falls back to its native point at drone height. Bullets preserve their launch point while the drone continues orbiting, and continuing beam sweeps remain attached to the emitter. Native projectile presence, progress, pause and outcomes remain authoritative. The HUD's intruder-warning mask is scoped to its warning region and preserves glyphs, faint shadow/fringe coverage and neighboring controls while making the backdrop transparent.
The combined Python suite passed **44 tests** and **eleven Godot suites** passed, including graphical Vulkan UI/controller/frame checks. Target-lock, jump-star and drone gallery/laser/beam fixtures were rendered and inspected on the RTX 4060 Ti. Drone fixtures produced no invented impacts. Twelve actual native intruder-warning blink captures retained all 1,458 glyph pixels while correcting faint shadow coverage; the normal native HUD frame was unchanged.
A fresh disposable copied profile verified actual normal targeting, native target clearing and autofire. An ordinary click on a connected beacon produced native `jumping=true` through snapshot sequences 96–134, then the arrival dialog at sequence 135; fuel decreased by one. The bridge exited cleanly with no errors. Production archive/settings were restored, and all four campaign hashes plus both executable fingerprints remain unchanged. These are native/desktop checks. Physical Steam Frame comfort and stereo performance remain to test; earlier native evidence below belongs to its recorded revision.
Both standalone folders pass setup checks and their desktop Vulkan launch/exit wrappers; VR preflight passes. The development copy also passes its desktop launch/exit after refreshing an older local extraction manifest. The matching 125-file source-only package passes publication checks with zero failures or warnings. Runtime dependencies, game assets, saves and private QA fixtures remain excluded, and license notices are unchanged.
Existing installations must rerun [Installation step 5](INSTALLATION.md#5-extract-local-presentation-assets) with the current `tools/extract_ftl.py` against their original owned archive. It imports the eight normal/yellow placed reticle PNGs for weapon slots 1–4 into ignored local data. Game artwork is not included in this source release.
### Preceding HUD clearance and menu correction
HUD collision clearance is **8 cm**, reduced by 6 cm while preserving headset-following priority and the ship/shield intersection guard. Separate native pause-menu and Options draw methods are suppressed only during the supplemental HUD pass; the original capture still supplies their complete floating panels. In-run panel ownership also takes priority over a simultaneous native menu flag. Initial and hangar menus keep their full headset screen.
The current source passed **40 Python tests** and focused **Vulkan HUD-layout/input checks** on the RTX 4060 Ti. Native before/after captures from a separate QA profile verified Ship, pause-menu and Options panels: duplicate HUD pixels were absent after the fix and world-panel content was retained. These are desktop/native capture checks; physical Steam Frame comfort and stereo performance still need the maintainer's playtest.
That revision passed **40 Python tests** and focused **Vulkan HUD-layout/input checks** on the RTX 4060 Ti. Native before/after captures from a separate QA profile verified Ship, pause-menu and Options panels: duplicate HUD pixels were absent after the fix and world-panel content was retained. These are desktop/native capture checks; physical Steam Frame comfort and stereo performance still need the maintainer's playtest.
The updated prepared folder passed `RUN-VR.cmd --smoke`, and the GitHub Desktop checkout passed `RUN-FTL-VR.cmd --smoke`: Vulkan desktop rendering, clean client logs, no bridge errors and disconnected bridges after graceful exit. Both folders passed `CHECK-SETUP.cmd`; VR launch `--check` passed separately. Production archive/settings were restored, hooks refreshed and all four campaign hashes verified again after both launches. Private QA fixtures are absent. The matching 120-file source package passed publication checks with no failures or warnings. These checks do not exercise the physical headset.
@@ -66,9 +80,9 @@ Production combined Vulkan launch/save/exit smoke passed: exit 0, clean client l
| 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 | Current source passed 40 Python tests and focused Vulkan HUD/input checks; the preceding revision passed nine Godot suites with GPU UI/controller/transport checks |
| Automated tests | Current 44 Python tests and eleven Godot suites pass; native normal/cleared/autofire targets, jump/arrival and warning blink captures pass; previous revision results are recorded above |
| Desktop graphics | Vulkan rendering and model/UI inspection checked on RTX 4060 Ti 8 GB |
| Save handling | Current desktop-wrapper launch/exit and copied-profile native checks preserved all four original campaign hashes |
| Save handling | Fresh copied-profile native checks and final launch/exit smokes preserve all four campaign hashes; production prefix/archive/settings and both executable fingerprints are verified |
| 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.
+95 -53
View File
@@ -6,6 +6,8 @@ const MISS_CUE_DURATION := 1.0
const MAX_MISS_CUES := 24
static var impact_meshes: Dictionary = {}
static var glow_materials: Dictionary = {}
static var projectile_meshes: Dictionary = {}
static var solid_materials: Dictionary = {}
var shots: Array[Dictionary] = []
var impacts: Array[Dictionary] = []
@@ -22,8 +24,13 @@ func spawn_shot(event: Dictionary, sender: Node3D, receiver: Node3D) -> void:
if event.has("target_space") and int(event["target_space"]) == int(sender.get("enemy")):
receiver = sender
var start: Vector3 = sender.weapon_origin(int(event.get("weapon_slot", 0)))
if event.has("origin_point"):
start = origin_ship.to_global(origin_ship.pixel_point(event["origin_point"]))
var drone_id := str(event.get("drone_id", ""))
var drone: Node3D = _drone(origin_ship, drone_id)
if drone != null:
start = drone.muzzle_position(str(event.get("projectile_id", "")))
drone.present_fire()
elif event.has("origin_point"):
start = origin_ship.to_global(origin_ship.pixel_point(event["origin_point"], 0.3 if not drone_id.is_empty() else 0.18))
var end: Vector3 = receiver.room_target(int(event.get("target_room", 0)))
if event.has("target") and not event["target"].is_empty():
end = receiver.to_global(receiver.pixel_point(event["target"]))
@@ -37,7 +44,10 @@ func spawn_shot(event: Dictionary, sender: Node3D, receiver: Node3D) -> void:
color = Color(0.9, 0.9, 0.85)
elif kind == "asteroid":
color = Color(0.4, 0.42, 0.45)
var node := _projectile_visual(kind, color)
var drone_family := str(drone.family) if drone != null else ""
if kind == "laser" and drone_family in ["defense", "anti_drone"]:
color = Color("98e5aa") if drone_family == "defense" else Color("a9dfe9")
var node := _projectile_visual(kind, color, not drone_id.is_empty())
add_child(node)
node.global_position = start
var duration := maxf(0.01, float(event.get("duration", 0.7)))
@@ -53,10 +63,11 @@ func spawn_shot(event: Dictionary, sender: Node3D, receiver: Node3D) -> void:
shots.append({"node": node, "start": start, "end": end, "beam_end": beam_end,
"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),
"visual_scale": maxf(0.1, (origin_ship if not drone_id.is_empty() else sender).global_basis.get_scale().abs().x),
"live_progress": 0.0, "wanted_progress": 0.0, "missed": false, "miss_feedback": false,
"end_point": event.get("end_point", {}),
"origin_point": event.get("origin_point", {}), "origin_ship": origin_ship,
"drone_id": drone_id, "launch_point": origin_ship.to_local(start),
"target_point": event.get("target", {}), "beam_point": {},
"slot": int(event.get("weapon_slot", 0)), "room": int(event.get("target_room", 0)),
"time": 0.0, "duration": duration, "kind": kind, "outcome": outcome, "receiver": receiver})
@@ -81,10 +92,9 @@ func _process(delta: float) -> void:
if age >= 1.0:
label.queue_free()
misses.remove_at(i)
if simulation_paused:
return
for i in range(impacts.size() - 1, -1, -1):
impacts[i]["time"] += delta
if not simulation_paused:
impacts[i]["time"] += delta
var flash: MeshInstance3D = impacts[i]["node"]
var age := float(impacts[i]["time"]) / 0.22
flash.scale = Vector3.ONE * maxf(0.01, 1.0 - age)
@@ -96,11 +106,18 @@ func _process(delta: float) -> void:
impacts.remove_at(i)
for i in range(shots.size() - 1, -1, -1):
var shot: Dictionary = shots[i]
shot["time"] += delta
if not simulation_paused:
shot["time"] += delta
# Recompute endpoints after the user moves/rotates/scales the encounter.
shot["visual_scale"] = maxf(0.1, shot["sender"].global_basis.get_scale().abs().x)
shot["start"] = shot["sender"].weapon_origin(shot["slot"])
if not shot["origin_point"].is_empty():
shot["visual_scale"] = maxf(0.1, (shot["origin_ship"] if not shot["drone_id"].is_empty() else shot["sender"]).global_basis.get_scale().abs().x)
if not shot["drone_id"].is_empty():
# Moving an orbiting drone cannot drag the launch point of a bullet.
# A continuing beam is the one native effect attached to its emitter.
var drone: Node3D = _drone(shot["origin_ship"], shot["drone_id"])
shot["start"] = drone.muzzle_position(shot["projectile_id"]) if shot["kind"] == "beam" and drone != null else shot["origin_ship"].to_global(shot["launch_point"])
else:
shot["start"] = shot["sender"].weapon_origin(shot["slot"])
if shot["drone_id"].is_empty() and not shot["origin_point"].is_empty():
shot["start"] = shot["origin_ship"].to_global(shot["origin_ship"].pixel_point(shot["origin_point"]))
if shot["outcome"] == "pending":
shot["end"] = shot["receiver"].room_target(shot["room"])
@@ -112,10 +129,9 @@ func _process(delta: float) -> void:
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)
if shot["outcome"] == "pending":
shot["live_progress"] = lerpf(shot["live_progress"], shot["wanted_progress"], minf(1.0, delta * 20.0))
if not simulation_paused:
shot["live_progress"] = lerpf(shot["live_progress"], shot["wanted_progress"], minf(1.0, delta * 20.0))
progress = shot["live_progress"]
if shot["time"] > 20.0:
progress = 1.0
var node: MeshInstance3D = shot["node"]
if shot["kind"] == "beam":
var beam_target: Vector3 = Vector3(shot["end"]).lerp(shot["beam_end"], progress)
@@ -130,15 +146,15 @@ func _process(delta: float) -> void:
node.position = Vector3.ZERO
# Keep the precise native sweep line, then surround it with real 3D
# cylinders so its width remains visible in stereo at oblique angles.
_segment(node.get_node("BeamCore"), shot["start"], beam_target, 0.012 * shot["visual_scale"])
_segment(node.get_node("BeamGlow"), shot["start"], beam_target, 0.032 * shot["visual_scale"])
_segment(node.get_node("BeamCore"), shot["start"], beam_target, (0.007 if not shot["drone_id"].is_empty() else 0.012) * shot["visual_scale"])
_segment(node.get_node("BeamGlow"), shot["start"], beam_target, (0.018 if not shot["drone_id"].is_empty() else 0.032) * shot["visual_scale"])
elif shot["kind"] != "bomb":
node.global_position = Vector3(shot["start"]).lerp(shot["end"], progress)
var direction: Vector3 = Vector3(shot["end"]) - Vector3(shot["start"])
if direction.length_squared() > 0.000001:
var up := Vector3.RIGHT if absf(direction.normalized().dot(Vector3.UP)) > 0.98 else Vector3.UP
node.global_basis = Basis.looking_at(direction.normalized(), up).scaled(Vector3.ONE * shot["visual_scale"])
if progress >= 1.0:
if progress >= 1.0 and shot["outcome"] != "pending":
if shot["outcome"] == "shield":
shot["receiver"].flash_shield()
if shot["outcome"] not in ["miss", "pending"]:
@@ -294,7 +310,16 @@ func _miss_feedback(point: Vector3, receiver: Node3D, projectile_id: String) ->
"time": 0.0, "projectile_id": projectile_id})
func _projectile_visual(kind: String, color: Color) -> MeshInstance3D:
func _drone(ship: Node3D, drone_id: String) -> Node3D:
if drone_id.is_empty() or (drone_id.is_valid_int() and drone_id.to_int() < 0):
return null
var nodes: Variant = ship.get("drone_nodes")
if nodes is Dictionary and nodes.has(drone_id) and is_instance_valid(nodes[drone_id]):
return nodes[drone_id]
return null
func _projectile_visual(kind: String, color: Color, drone_shot: bool = false) -> MeshInstance3D:
var node := MeshInstance3D.new()
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
node.material_override = _glow(color)
@@ -315,17 +340,17 @@ func _projectile_visual(kind: String, color: Color) -> MeshInstance3D:
cylinder.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
node.add_child(cylinder)
"missile":
var body := BoxMesh.new()
body.size = Vector3(0.075, 0.065, 0.19)
node.mesh = body
node.mesh = _box_mesh(Vector3(0.075, 0.065, 0.19))
node.material_override = _solid(Color(0.72, 0.76, 0.79))
var nose := MeshInstance3D.new()
var pyramid := CylinderMesh.new()
pyramid.top_radius = 0.0
pyramid.bottom_radius = 0.052
pyramid.height = 0.072
pyramid.radial_segments = 4
nose.mesh = pyramid
if not projectile_meshes.has("missile_nose"):
var pyramid := CylinderMesh.new()
pyramid.top_radius = 0.0
pyramid.bottom_radius = 0.052
pyramid.height = 0.072
pyramid.radial_segments = 4
projectile_meshes["missile_nose"] = pyramid
nose.mesh = projectile_meshes["missile_nose"]
nose.position.z = -0.128
nose.rotation.x = -PI / 2.0
nose.material_override = _solid(Color(0.73, 0.17, 0.08))
@@ -338,53 +363,66 @@ func _projectile_visual(kind: String, color: Color) -> MeshInstance3D:
exhaust.position.z = 0.145
node.add_child(exhaust)
"ion":
var sphere := SphereMesh.new()
sphere.radius = 0.052
sphere.height = 0.104
sphere.radial_segments = 10
sphere.rings = 5
node.mesh = sphere
var ion_radius := 0.032 if drone_shot else 0.052
var ion_key := "drone_ion" if drone_shot else "ion"
if not projectile_meshes.has(ion_key):
var sphere := SphereMesh.new()
sphere.radius = ion_radius
sphere.height = ion_radius * 2.0
sphere.radial_segments = 8
sphere.rings = 4
projectile_meshes[ion_key] = sphere
node.mesh = projectile_meshes[ion_key]
for axis in [0, 1]:
var ring := MeshInstance3D.new()
var torus := TorusMesh.new()
torus.inner_radius = 0.066
torus.outer_radius = 0.078
torus.rings = 12
torus.ring_segments = 4
ring.mesh = torus
if not projectile_meshes.has(ion_key + "_ring"):
var torus := TorusMesh.new()
torus.inner_radius = ion_radius * 1.27
torus.outer_radius = ion_radius * 1.5
torus.rings = 12
torus.ring_segments = 4
projectile_meshes[ion_key + "_ring"] = torus
ring.mesh = projectile_meshes[ion_key + "_ring"]
ring.rotation.x = PI / 2.0 if axis == 0 else 0.0
ring.material_override = _glow(Color(0.15, 0.6, 1.0, 0.6), true)
ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
node.add_child(ring)
"asteroid":
var sphere := SphereMesh.new()
sphere.radius = 0.2
sphere.height = 0.34
sphere.radial_segments = 7
sphere.rings = 4
node.mesh = sphere
if not projectile_meshes.has("asteroid"):
var sphere := SphereMesh.new()
sphere.radius = 0.2
sphere.height = 0.34
sphere.radial_segments = 7
sphere.rings = 4
projectile_meshes["asteroid"] = sphere
node.mesh = projectile_meshes["asteroid"]
node.material_override = _solid(color)
_:
var core := BoxMesh.new()
core.size = Vector3(0.028, 0.028, 0.26)
node.mesh = core
node.mesh = _box_mesh(Vector3(0.020, 0.020, 0.09) if drone_shot else Vector3(0.028, 0.028, 0.26))
node.material_override = _glow(Color(1.0, 0.93, 0.72))
var trail := _box(Vector3(0.066, 0.066, 0.39), Color(color, 0.27))
trail.position.z = 0.07
var trail := _box(Vector3(0.042, 0.042, 0.16) if drone_shot else Vector3(0.066, 0.066, 0.39), Color(color, 0.27))
trail.position.z = 0.038 if drone_shot else 0.07
node.add_child(trail)
return node
func _box(size: Vector3, color: Color, luminous: bool = true) -> MeshInstance3D:
var node := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = size
node.mesh = mesh
node.mesh = _box_mesh(size)
node.material_override = _glow(color, color.a < 1.0) if luminous else _solid(color)
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
return node
func _box_mesh(size: Vector3) -> BoxMesh:
var key := "box:" + str(size)
if not projectile_meshes.has(key):
var mesh := BoxMesh.new()
mesh.size = size
projectile_meshes[key] = mesh
return projectile_meshes[key]
func _glow(color: Color, additive: bool = false) -> StandardMaterial3D:
var key := color.to_html() + ":" + str(additive)
if glow_materials.has(key):
@@ -402,9 +440,13 @@ func _glow(color: Color, additive: bool = false) -> StandardMaterial3D:
func _solid(color: Color) -> StandardMaterial3D:
var key := color.to_html()
if solid_materials.has(key):
return solid_materials[key]
var material := StandardMaterial3D.new()
material.albedo_color = color
material.roughness = 0.82
solid_materials[key] = material
return material
+14
View File
@@ -12,6 +12,7 @@ const ControllerPanel = preload("res://scripts/controller_panel.gd")
const PoseFilter = preload("res://scripts/pose_filter.gd")
const EnemyHullBar = preload("res://scripts/enemy_hull_bar.gd")
const UiAssets = preload("res://scripts/ftl_ui_assets.gd")
const TargetLocks = preload("res://scripts/target_locks.gd")
var xr_active := false
var origin: XROrigin3D
@@ -27,6 +28,7 @@ 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 target_locks: Node3D
var hud_state: Dictionary = {}
var seen_shots: Dictionary = {}
var right_laser: MeshInstance3D
@@ -140,6 +142,8 @@ func _ready() -> void:
_create_rename_keyboard()
wheel = ShortcutWheel.new()
add_child(wheel)
target_locks = TargetLocks.new()
add_child(target_locks)
_set_hazard("clear")
var interface := XRServer.find_interface("OpenXR")
if "--desktop" not in OS.get_cmdline_user_args() and interface != null and (interface.is_initialized() or interface.initialize()):
@@ -627,6 +631,8 @@ func _update_power_controls(remove: bool, previous: bool, next: bool) -> void:
func _update_wheel() -> void:
var down := right_hand.get_is_active() and right_hand.is_button_pressed("wheel_button")
if down and not wheel_was_down:
# Categories apply to this hold only; every new opening starts on equipment.
wheel_category = 0
wheel_committed = false
wheel_dialog_id = str(hud_state.get("dialog", {}).get("id", ""))
wheel.visible = not rename_keyboard.visible and (hud_state.get("event_open", false) or _game_actions_available())
@@ -646,6 +652,7 @@ func _update_wheel() -> void:
if wheel.visible:
_commit_wheel()
wheel.visible = false
wheel_category = 0
wheel_was_down = down
@@ -690,6 +697,11 @@ func _commit_wheel() -> void:
wheel.visible = false
func _sync_jump_heading() -> void:
var local_bow := Vector3.FORWARD if int(player_ship.layout_data.get("vertical", 0)) != 0 else Vector3.RIGHT
space_environment.set_travel_direction(player_ship.global_basis * local_bow)
func _process(delta: float) -> void:
if world_surface.visible:
world_panel_open_age += delta
@@ -703,6 +715,7 @@ func _process(delta: float) -> void:
player_ship.simulation_paused = paused
enemy_ship.simulation_paused = paused
space_environment.simulation_paused = paused
_sync_jump_heading()
var viewer := camera.get_camera_transform().origin
player_ship.set_viewer_position(viewer)
enemy_ship.set_viewer_position(viewer)
@@ -1185,6 +1198,7 @@ func _poll_bridge_state() -> void:
if new_battle != battle:
battle = new_battle
_update_ship_positions()
target_locks.apply_snapshot(state, player_ship, enemy_ship)
var new_hazard := str(state.get("hazard", hazard))
if new_hazard in ["clear", "asteroid", "sun", "storm", "nebula", "pulsar"] and new_hazard != hazard:
_set_hazard(new_hazard)
+79 -9
View File
@@ -1,11 +1,13 @@
extends Node3D
# Stationary surroundings: three draw calls cover the entire sky, with a sparse
# nearer shell for stereo parallax. No captured 2D background is used here.
# Three draw calls cover the entire sky, with a sparse nearer shell for stereo
# parallax. Native FTL travel stretches those same stars along the ship's bow.
const STAR_COUNT := 1600
const NEAR_STAR_COUNT := 96
const GALAXY_COUNT := 420
const VALID_HAZARDS := ["clear", "asteroid", "sun", "storm", "nebula", "pulsar"]
const JUMP_STRETCH_RATE := 4.0
const STAR_MAX_STRETCH := 55.0
var star_positions := PackedVector3Array()
var hazard := "clear"
@@ -19,6 +21,25 @@ var storm_arcs: Array[MeshInstance3D] = []
var built := false
var cloud_material: ShaderMaterial
var body_material: ShaderMaterial
var star_material: ShaderMaterial
var travel_direction := Vector3.RIGHT
var jump_active := false
var jump_stretch := 0.0
var _arrival_during_jump := false
const STAR_SHADER := """
shader_type spatial;
render_mode unshaded;
uniform vec3 travel_direction = vec3(1.0, 0.0, 0.0);
uniform float jump_stretch : hint_range(0.0, 1.0) = 0.0;
uniform float max_stretch = 55.0;
void vertex() {
float amount = smoothstep(0.0, 1.0, jump_stretch);
float along = dot(VERTEX, travel_direction);
VERTEX += travel_direction * along * amount * (max_stretch - 1.0);
}
void fragment() { ALBEDO = COLOR.rgb; }
"""
const CLOUD_SHADER := """
shader_type spatial;
@@ -92,6 +113,11 @@ func build() -> void:
return
built = true
name = "SpaceEnvironment"
star_material = ShaderMaterial.new()
star_material.shader = Shader.new()
star_material.shader.code = STAR_SHADER
star_material.set_shader_parameter("max_stretch", STAR_MAX_STRETCH)
_update_travel_direction()
var rng := RandomNumberGenerator.new()
rng.seed = 26011002
_star_shell("DistantStars360", STAR_COUNT, 30.0, 58.0, rng, false)
@@ -107,11 +133,51 @@ func apply_state(state: Dictionary) -> void:
if not built:
build()
simulation_paused = bool(state.get("paused", false))
# transition also covers pending events, so only the native ship jump flag
# starts travel. Opening the map or charging FTL cannot animate the sky.
var native_jumping := bool(state.get("jumping", false))
if not native_jumping:
_arrival_during_jump = false
if bool(state.get("event_open", false)):
_arrival_during_jump = native_jumping
jump_active = native_jumping and not _arrival_during_jump and \
not bool(state.get("event_open", false)) and bool(state.get("ready", true)) and \
str(state.get("ui_mode", "game")) != "menu"
if not jump_active:
_set_jump_stretch(0.0)
# The old beacon's sun/asteroids/clouds do not travel with the ship.
hazard_root.visible = not jump_active
var next := str(state.get("hazard", "clear"))
if next in VALID_HAZARDS and next != hazard:
_set_hazard(next)
func set_travel_direction(world_direction: Vector3) -> void:
# Main supplies the transformed player ship bow; headset turns never change
# this direction. FTL's tabletop travel stays horizontal in the room.
var horizontal := Vector3(world_direction.x, 0.0, world_direction.z)
if horizontal.length_squared() < 0.0001 or not horizontal.is_finite():
return
var direction := horizontal.normalized()
if direction.is_equal_approx(travel_direction):
return
travel_direction = direction
_update_travel_direction()
func _update_travel_direction() -> void:
if star_material != null:
var local_direction := global_basis.inverse() * travel_direction
star_material.set_shader_parameter("travel_direction", local_direction.normalized())
func _set_jump_stretch(amount: float) -> void:
if is_equal_approx(jump_stretch, amount):
return
jump_stretch = amount
star_material.set_shader_parameter("jump_stretch", jump_stretch)
func set_hazard(kind: String) -> void:
if not built:
build()
@@ -131,7 +197,9 @@ func _star_shell(node_name: String, count: int, near_radius: float, far_radius:
stars.use_colors = true
stars.mesh = sphere
stars.instance_count = count
stars.custom_aabb = AABB(Vector3.ONE * -far_radius, Vector3.ONE * far_radius * 2.0)
# Vertex shader streaks extend beyond the unmodified MultiMesh bounds.
var bound := far_radius + STAR_MAX_STRETCH * 0.05
stars.custom_aabb = AABB(Vector3.ONE * -bound, Vector3.ONE * bound * 2.0)
for i in range(count):
var y := rng.randf_range(-1.0, 1.0)
var angle := rng.randf_range(0.0, TAU)
@@ -156,11 +224,7 @@ func _star_shell(node_name: String, count: int, near_radius: float, far_radius:
node.name = node_name
node.multimesh = stars
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.vertex_color_use_as_albedo = true
material.albedo_color = Color.WHITE
node.material_override = material
node.material_override = star_material
add_child(node)
@@ -318,7 +382,13 @@ func _build_nebula(kind: String) -> void:
func _process(delta: float) -> void:
if simulation_paused or not built:
if not built:
return
# Native jump rendering advances while combat simulation is paused. This
# presentation follows that travel even when paused/frozen is reported.
if jump_active:
_set_jump_stretch(move_toward(jump_stretch, 1.0, maxf(0.0, delta) * JUMP_STRETCH_RATE))
if simulation_paused or jump_active:
return
elapsed += delta
for rock in rocks:
+260
View File
@@ -0,0 +1,260 @@
extends Node3D
# Mirror WeaponControl::RenderAimingWeapon: player-owned weapon.targets are the
# authority, including while the armed cursor is closed. Never use lastTargets
# or enemy weapon targeting, and never infer locks from projectile history.
const UiAssets = preload("res://scripts/ftl_ui_assets.gd")
const NATIVE_PIXEL := 0.18 / 35.0
const HEIGHT := 0.195
const NORMAL := Color("ff4b19")
const AUTOFIRE := Color("ffdf00")
const INK := Color("080b10")
var locks: Dictionary = {}
func apply_snapshot(state: Dictionary, player_ship: Node3D, enemy_ship: Node3D) -> void:
if not bool(state.get("ready", false)) or bool(state.get("transition", false)):
clear()
return
var player: Variant = state.get("player", {})
if not player is Dictionary or bool(player.get("destroyed", false)):
clear()
return
var weapons: Variant = player.get("weapons", [])
if not weapons is Array:
clear()
return
var keep: Dictionary = {}
for i in range(weapons.size()):
var weapon: Variant = weapons[i]
if not weapon is Dictionary:
continue
var slot := int(weapon.get("slot", i))
var side := int(weapon.get("target_ship", -1))
# Native self-aiming remains valid without an enemy (for healing bombs).
# Only targets on the enemy require an active encounter.
if side == 1 and not bool(state.get("combat", false)):
continue
var receiver := player_ship if side == 0 else enemy_ship if side == 1 else null
var receiver_state: Variant = state.get("player" if side == 0 else "enemy", {})
if slot < 0 or keep.has(slot) or not is_instance_valid(receiver) or not receiver.has_method("pixel_point"):
continue
if not receiver_state is Dictionary or receiver_state.is_empty() or bool(receiver_state.get("destroyed", false)):
continue
var targets: Variant = weapon.get("targets", [])
if not targets is Array or targets.is_empty() or not _valid_point(targets[0]):
continue
var beam := str(weapon.get("kind", "")) == "beam"
var pinpoint := beam and int(weapon.get("beam_length", 2)) == 1
if beam and not pinpoint and (targets.size() < 2 or not _valid_point(targets[1])):
continue
var points: Array[Dictionary] = [targets[0]]
if beam and not pinpoint:
points.append(targets[1])
var autofire := bool(weapon.get("autofire", false))
var radius := maxf(0.0, float(weapon.get("target_radius", 0))) if not beam else 0.0
var layout: Dictionary = receiver.get("layout_data")
var signature := JSON.stringify([points, beam, pinpoint, autofire, radius, receiver.get_instance_id(), receiver.get("layout_center"), layout.get("x_offset", 0), layout.get("y_offset", 0)])
if locks.has(slot) and locks[slot].signature != signature:
_remove(slot)
if not locks.has(slot):
var node := Node3D.new()
node.name = "WeaponLock%d" % (slot + 1)
node.set_meta("weapon_slot", slot)
node.set_meta("target_ship", side)
node.set_meta("autofire", autofire)
add_child(node)
var color := AUTOFIRE if autofire else NORMAL
var anchor: Vector3 = receiver.pixel_point(points[0], HEIGHT)
if pinpoint:
# Hyperspace uses the native tile center for length-one beams.
anchor = receiver.pixel_point({"x": floorf(float(points[0].x) / 35.0) * 35.0 + 17.5,
"y": floorf(float(points[0].y) / 35.0) * 35.0 + 17.5}, HEIGHT)
var start := anchor
if beam and not pinpoint:
# Native targets[1] is the sweep start, targets[0] is its end.
start = receiver.pixel_point(points[1], HEIGHT)
_make_beam(node, start, anchor, color, slot)
else:
_make_reticle(node, anchor, slot, autofire)
if radius > 0.0:
_make_radius(node, anchor, radius * NATIVE_PIXEL, color)
_finish_lines(node)
locks[slot] = {"node": node, "receiver": receiver, "signature": signature,
"start": start, "end": anchor, "beam": beam and not pinpoint, "radius": radius * NATIVE_PIXEL}
keep[slot] = true
for slot in locks.keys():
if not keep.has(slot):
_remove(slot)
refresh_transforms()
func _valid_point(point: Variant) -> bool:
if not point is Dictionary or not point.has("x") or not point.has("y"):
return false
return (point.x is int or point.x is float) and (point.y is int or point.y is float) and is_finite(float(point.x)) and is_finite(float(point.y))
func refresh_transforms() -> void:
for slot in locks.keys():
var lock: Dictionary = locks[slot]
if not is_instance_valid(lock.receiver) or not is_instance_valid(lock.node):
_remove(slot)
continue
var receiver: Node3D = lock.receiver
var node: Node3D = lock.node
node.global_transform = receiver.global_transform
node.visible = receiver.is_visible_in_tree()
func _process(_delta: float) -> void:
refresh_transforms()
func clear() -> void:
for slot in locks.keys():
_remove(slot)
func _remove(slot: int) -> void:
if not locks.has(slot):
return
var node: Node3D = locks[slot].node
if is_instance_valid(node):
node.hide()
remove_child(node)
node.queue_free()
locks.erase(slot)
func _make_reticle(parent: Node3D, point: Vector3, slot: int, autofire: bool) -> void:
var suffix := "_yellow" if autofire else ""
var path := "img/misc/crosshairs_placed%d%s.png" % [slot + 1, suffix]
var texture: Texture2D = UiAssets.texture(path)
if texture != null:
var quad := QuadMesh.new()
quad.size = texture.get_size() * NATIVE_PIXEL
var mesh := MeshInstance3D.new()
mesh.name = "NativeReticle"
mesh.mesh = quad
mesh.position = point
mesh.rotation_degrees.x = -90.0
var material := _material(Color.WHITE)
material.albedo_texture = texture
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
material.cull_mode = BaseMaterial3D.CULL_DISABLED
mesh.material_override = material
parent.add_child(mesh)
return
# Owner-local assets are optional in source-only installs. Preserve the native
# circle, four crosshair ticks, slot number and autofire colour procedurally.
var color := AUTOFIRE if autofire else NORMAL
var ring_radius := 16.0 * NATIVE_PIXEL
for i in range(32):
var a := float(i) * TAU / 32.0
var b := float(i + 1) * TAU / 32.0
_line(parent, point + Vector3(cos(a), 0, sin(a)) * ring_radius,
point + Vector3(cos(b), 0, sin(b)) * ring_radius, color, 1.7 * NATIVE_PIXEL)
for direction in [Vector3.RIGHT, Vector3.LEFT, Vector3.FORWARD, Vector3.BACK]:
_line(parent, point + direction * 10.0 * NATIVE_PIXEL, point + direction * 22.0 * NATIVE_PIXEL, color, 1.7 * NATIVE_PIXEL)
_slot_label(parent, slot, point + Vector3(0, 0.007, 23.0 * NATIVE_PIXEL), color)
func _make_beam(parent: Node3D, start: Vector3, end: Vector3, color: Color, slot: int) -> void:
_line(parent, start, end, color, 1.8 * NATIVE_PIXEL)
var direction := (end - start).normalized()
if start.distance_to(end) > 0.002:
var across := Vector3(-direction.z, 0, direction.x)
var tip := start.lerp(end, 0.72)
var length := minf(8.0 * NATIVE_PIXEL, start.distance_to(end) * 0.20)
_line(parent, tip, tip - direction * length + across * length * 0.55, color, 1.8 * NATIVE_PIXEL)
_line(parent, tip, tip - direction * length - across * length * 0.55, color, 1.8 * NATIVE_PIXEL)
for point in [start, end]:
_line(parent, point - Vector3.RIGHT * 5.0 * NATIVE_PIXEL, point + Vector3.RIGHT * 5.0 * NATIVE_PIXEL, color, 1.8 * NATIVE_PIXEL)
_line(parent, point - Vector3.BACK * 5.0 * NATIVE_PIXEL, point + Vector3.BACK * 5.0 * NATIVE_PIXEL, color, 1.8 * NATIVE_PIXEL)
_slot_label(parent, slot, start + Vector3(0, 0.009, 11.0 * NATIVE_PIXEL), color)
func _make_radius(parent: Node3D, point: Vector3, radius: float, color: Color) -> void:
var disk := CylinderMesh.new()
disk.top_radius = radius
disk.bottom_radius = radius
disk.height = 0.001
disk.radial_segments = 48
var mesh := MeshInstance3D.new()
mesh.name = "FlakRadius"
mesh.mesh = disk
mesh.position = point - Vector3.UP * 0.007
var material := _material(Color(color, 0.17))
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.render_priority = 7
mesh.material_override = material
parent.add_child(mesh)
func _slot_label(parent: Node3D, slot: int, point: Vector3, color: Color) -> void:
var label := Label3D.new()
label.name = "WeaponSlot"
label.text = str(slot + 1)
label.font = UiAssets.font("body")
label.font_size = 14
label.pixel_size = NATIVE_PIXEL * 0.75
label.modulate = color
label.outline_modulate = INK
label.outline_size = 3
label.no_depth_test = true
label.render_priority = 10
label.position = point
label.rotation_degrees.x = -90.0
parent.add_child(label)
func _line(parent: Node3D, start: Vector3, end: Vector3, color: Color, width: float) -> void:
if start.distance_to(end) < 0.00001:
return
var lines: Array = parent.get_meta("lines", [])
lines.append([start, end, color, width])
parent.set_meta("lines", lines)
func _finish_lines(parent: Node3D) -> void:
var lines: Array = parent.get_meta("lines", [])
if lines.is_empty():
return
# One surface for all endpoint, arrow and fallback-reticle strokes.
var geometry := ImmediateMesh.new()
geometry.surface_begin(Mesh.PRIMITIVE_TRIANGLES)
for outline in [true, false]:
for line: Array in lines:
var start: Vector3 = line[0]
var end: Vector3 = line[1]
var direction := (end - start).normalized()
var width: float = line[3]
var across := Vector3(-direction.z, 0, direction.x) * width * (1.0 if outline else 0.5)
var offset := Vector3.UP * (0.0 if outline else 0.001)
if outline:
start -= direction * width * 0.5
end += direction * width * 0.5
geometry.surface_set_color(INK if outline else line[2])
for vertex in [start - across, end - across, end + across, start - across, end + across, start + across]:
geometry.surface_add_vertex(vertex + offset)
geometry.surface_end()
var mesh := MeshInstance3D.new()
mesh.name = "LockLines"
mesh.mesh = geometry
var material := _material(Color.WHITE)
material.vertex_color_use_as_albedo = true
material.cull_mode = BaseMaterial3D.CULL_DISABLED
mesh.material_override = material
parent.add_child(mesh)
parent.remove_meta("lines")
func _material(color: Color) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.albedo_color = color
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.no_depth_test = true
material.render_priority = 8
return material
+135 -25
View File
@@ -7,7 +7,11 @@ const RIM := Color("d3dcda")
const DARK := Color("354b59")
const BLACK := Color("182832")
const BRASS := Color("c6ac75")
const SLATE := Color("718b9a")
const RED := Color("b75847")
static var body_meshes: Dictionary = {}
static var detail_meshes: Dictionary = {}
static var glow_materials: Dictionary = {}
static var body_material: StandardMaterial3D
var drone_name := ""
var kind := "combat"
@@ -20,6 +24,9 @@ var tool: MeshInstance3D
var live_data: Dictionary = {}
var animation_time := 0.0
var firing := false
var mark_two := false
var fire_flash := 0.0
var emitter_color := Color.WHITE
static func model_family(data: Dictionary) -> String:
@@ -52,7 +59,9 @@ func build(data: Dictionary) -> void:
is_space = bool(data.get("is_space", true))
family = model_family(data)
animation_time = 0.0
var mark_two := drone_name.ends_with("_2") and family in ["combat", "defense"]
firing = false
fire_flash = 0.0
mark_two = drone_name.ends_with("_2") and family in ["combat", "beam", "defense"]
var cache_key := family + ("_2" if mark_two else "")
if not body_meshes.has(cache_key):
var source := Voxels.make(_body_blocks(mark_two))
@@ -75,6 +84,8 @@ func build(data: Dictionary) -> void:
exhaust = [_b(-6.6, -1.3, 0, 2.0, 1.0, 2.4, glow)]
elif family == "hacking":
exhaust = [_b(-5.4, 0, 0, 2.0, 1.2, 2.0, glow)]
elif family == "anti_drone":
exhaust = [_b(-8.4, -0.5, -4.8, 2.4, 1.2, 1.8, glow), _b(-8.4, -0.5, 4.8, 2.4, 1.2, 1.8, glow)]
else:
for side in [-1.0, 1.0]:
exhaust.append(_b(-8.4, -0.5, side * 5.4, 2.4, 1.2, 1.8, glow))
@@ -84,10 +95,13 @@ func build(data: Dictionary) -> void:
for z in ([-1.8, 1.8] if mark_two else [0.0]): lights.append(_b(13.2, 0.3, z, 0.6, 1.3, 1.3, glow))
"beam":
glow = Color("ffdc76")
lights = [_b(13.0, 0, 0, 0.6, 1.0, 3.7, glow)]
"defense", "anti_drone":
lights = [_b(13.0, 0, 0, 0.6, 1.0, 5.4 if mark_two else 3.7, glow)]
"defense":
glow = Color("98e5aa")
for z in ([-1.5, 1.5] if mark_two else [0.0]): lights.append(_b(10.7, 2.4, z, 0.6, 1.3, 1.3, glow))
"anti_drone":
glow = Color("a9dfe9")
lights = [_b(12.4, 1.1, -3.0, 0.7, 1.0, 1.0, glow), _b(12.4, 1.1, 3.0, 0.7, 1.0, 1.0, glow)]
"hacking":
glow = Color("b591e0")
lights = [_b(0, 2.6, 0, 2.6, 0.4, 2.6, glow)]
@@ -99,10 +113,11 @@ func build(data: Dictionary) -> void:
else:
glow = Color("ef736a") if family == "battle" else Color("7fdafa")
lights = [_b(0, 9.0, -3.0, 2.8, 0.7, 0.3, glow)]
thruster = _glow_mesh(exhaust, Color("69d8fa"))
thruster = _glow_mesh(cache_key + ":exhaust", exhaust, Color("69d8fa"))
thruster.name = "Exhaust"
add_child(thruster)
emitter = _glow_mesh(lights, glow)
emitter_color = glow
emitter = _glow_mesh(cache_key + ":emitter", lights, glow)
emitter.name = "Emitter"
add_child(emitter)
tool = null
@@ -114,13 +129,32 @@ func build(data: Dictionary) -> void:
else:
tool_blocks = [_b(0, -1.2, 0, 1.8, 3.0, 2.0, DARK), _b(0, -2.4, -1.0, 2.7, 1.0, 3.0, STEEL),
_b(-1.0, -2.9, -2.0, 0.6, 1.1, 1.7, RIM), _b(1.0, -2.9, -2.0, 0.6, 1.1, 1.7, RIM)]
tool = Voxels.make(tool_blocks)
tool = _cached_part(cache_key + ":tool", tool_blocks)
tool.name = "Tool"
tool.position = Vector3(5.2, 6.3, 0) * UNIT
add_child(tool)
set_live(data)
func muzzle_position(projectile_id: String = "") -> Vector3:
# Models face +X like native drone angles. Resolve through the model's real
# world transform, including the encounter tilt, scale and native orbit.
var point := Vector3(13.5, 0.3, 0)
match family:
"beam": point = Vector3(13.3, 0, 0)
"defense": point = Vector3(11.1, 2.4, 0)
"anti_drone": point = Vector3(12.8, 1.1, -3.0)
if mark_two and family in ["combat", "defense"]:
point.z = (-1.0 if projectile_id.hash() % 2 == 0 else 1.0) * (1.8 if family == "combat" else 1.5)
return to_global(point * UNIT)
func present_fire() -> void:
# This is called only for an actual native DRONE_FIRE record.
fire_flash = 0.13
emitter.material_override = _glow_material(emitter_color, 1.5)
func _body_blocks(mark_two: bool) -> Array:
var blocks: Array = []
if not is_space:
@@ -142,6 +176,12 @@ func _body_blocks(mark_two: bool) -> Array:
blocks.append(_b(side * 2.7, 1.0, 0, 1.8, 2.0, 5.5, DARK))
for z in [-1.8, 0.0, 1.8]: blocks.append(_b(side * 3.7, 1.0, z, 0.3, 1.3, 0.7, RIM))
blocks.append(_b(-5.1, 3.7, -0.7, 1.4, 1.2, 2.8, STEEL))
# Small inset plates and stripe pixels retain the FTL steel/brass palette.
blocks.append(_b(0, 6.7, -2.65, 3.5, 0.55, 0.35, RIM))
blocks.append(_b(0, 4.1, -2.65, 1.4, 0.6, 0.35, BRASS if not armored else RED))
for side in [-1.0, 1.0]:
blocks.append(_b(side * 1.7, 9.5, -2.95, 0.45, 0.4, 0.25, RIM))
blocks.append(_b(side * 2.4, 6.0, 2.6, 0.65, 2.4, 0.45, SLATE))
if family == "ion_boarder":
for side in [-1.0, 1.0]:
blocks.append(_b(side * 2.0, 11.2, 0, 0.8, 2.0, 2.3, Color("6aa8c5")))
@@ -151,19 +191,27 @@ func _body_blocks(mark_two: bool) -> Array:
"hacking":
blocks = [_b(0, 0, 0, 8.5, 3.7, 8.5, DARK), _b(0, 1.7, 0, 5.8, 1.5, 5.8, STEEL),
_b(0, 2.5, 0, 3.5, 0.6, 3.5, BLACK), _b(-4.5, 0, 0, 2.2, 2.8, 2.8, STEEL)]
for side in [-1.0, 1.0]:
blocks.append(_b(side * 2.8, 2.5, 0, 0.7, 0.7, 4.8, BRASS))
blocks.append(_b(0, 2.5, side * 2.8, 4.8, 0.7, 0.7, RIM))
for side in [-1.0, 1.0]:
for end in [-1.0, 1.0]:
blocks.append(_b(side * 4.8, -0.5, end * 4.2, 2.0, 1.3, 3.2, STEEL))
blocks.append(_b(side * 5.2, -2.5, end * 5.4, 1.1, 3.2, 1.1, BRASS))
blocks.append(_b(side * 4.7, -4.0, end * 5.4, 2.1, 0.7, 1.3, DARK))
blocks.append(_b(side * 5.1, -2.5, end * 6.0, 0.8, 2.1, 0.3, RIM))
"shield":
blocks = [_b(0, -0.6, 0, 6.0, 3.0, 6.0, DARK), _b(0, 1.0, 0, 3.0, 1.0, 3.0, STEEL)]
# Stepped, open quadrants read as a compact generator ring in stereo.
blocks = [_b(0, -0.6, 0, 6.0, 3.0, 6.0, DARK), _b(0, 1.0, 0, 3.0, 1.0, 3.0, STEEL),
_b(0, 1.7, 0, 1.7, 0.4, 1.7, BRASS)]
for side in [-1.0, 1.0]:
blocks.append(_b(0, 0, side * 6.2, 7.2, 2.0, 4.2, STEEL))
blocks.append(_b(side * 6.2, 0, 0, 4.2, 2.0, 7.2, STEEL))
blocks.append(_b(0, 1.1, side * 6.2, 5.0, 0.5, 2.8, DARK))
blocks.append(_b(side * 6.2, 1.1, 0, 2.8, 0.5, 5.0, DARK))
blocks.append(_b(side * 4.7, -1.0, side * 4.7, 2.8, 0.8, 2.8, BRASS))
for other in [-1.0, 1.0]:
blocks.append(_b(side * 4.7, -1.0, other * 4.7, 2.8, 0.8, 2.8, BRASS))
blocks.append(_b(side * 4.6, 0.2, other * 4.6, 1.5, 1.3, 1.5, DARK))
"boarding":
blocks = [_b(-1, 0, 0, 13.0, 5.0, 7.0, STEEL), _b(4.7, 0, 0, 5.0, 4.0, 6.0, DARK),
_b(7.7, 0, 0, 1.2, 3.0, 4.0, BRASS), _b(-6.0, 0, 0, 2.0, 6.0, 8.5, DARK),
@@ -172,23 +220,50 @@ func _body_blocks(mark_two: bool) -> Array:
blocks.append(_b(-2.0, -0.5, side * 4.6, 8.0, 1.0, 2.4, DARK))
blocks.append(_b(-6.6, 0, side * 2.5, 2.5, 2.5, 2.4, STEEL))
blocks.append(_b(3.5, 0, side * 3.4, 0.7, 4.0, 1.0, BRASS))
blocks.append(_b(-1, 1.0, side * 3.65, 8.7, 1.0, 0.4, RED))
for x in [-4.0, -1.5, 1.0]:
blocks.append(_b(x, 2.95, side * 2.1, 0.7, 0.5, 1.0, DARK))
blocks.append(_b(6.6, 2.1, 0, 1.0, 0.5, 4.7, RIM))
"anti_drone":
# Narrow angular interceptor, distinct from the defense turret.
blocks = [_b(-2.0, -0.7, 0, 11.0, 2.8, 5.5, DARK), _b(-2.7, 0.8, 0, 8.5, 2.2, 5.0, STEEL),
_b(-3.0, 2.1, 0, 5.0, 0.5, 3.5, RIM), _b(2.0, 0.4, 0, 4.0, 1.3, 3.0, BRASS)]
for side in [-1.0, 1.0]:
blocks.append(_b(-3.4, -0.2, side * 4.8, 9.0, 2.0, 2.2, STEEL))
blocks.append(_b(-7.7, -0.2, side * 4.8, 1.0, 1.6, 1.8, BLACK))
blocks.append(_b(5.5, 1.1, side * 3.0, 12.3, 1.4, 1.3, DARK))
blocks.append(_b(9.0, 1.1, side * 3.0, 2.0, 2.0, 1.9, STEEL))
blocks.append(_b(11.8, 1.1, side * 3.0, 1.0, 2.0, 2.0, RIM))
blocks.append(_b(-1.0, 2.0, side * 4.8, 1.3, 0.6, 1.7, BRASS))
_:
blocks = [_b(-1.5, -1.4, 0, 12.0, 2.0, 8.0, DARK), _b(-1.0, 0, 0, 11.0, 3.8, 7.0, STEEL),
_b(-2.2, 2.2, 0, 6.0, 1.1, 5.0, RIM), _b(-6.5, 0, 0, 2.0, 3.0, 6.5, DARK)]
# Pixel-sized steps and inset dark seams give the armor an FTL sprite
# silhouette instead of one uniform cuboid, with one batched body mesh.
blocks = [_b(-1.8, -1.4, 0, 10.8, 1.6, 7.0, DARK), _b(-1.5, -0.2, 0, 11.0, 2.6, 7.0, SLATE),
_b(-2.0, 1.1, 0, 8.8, 1.4, 6.4, STEEL), _b(-2.4, 2.1, 0, 6.0, 0.6, 5.0, RIM),
_b(-6.5, 0, 0, 2.0, 3.0, 6.5, DARK), _b(3.5, 0.4, 0, 2.0, 1.5, 5.1, STEEL),
_b(-0.8, 2.5, 0, 1.0, 0.4, 3.4, DARK), _b(-4.4, 2.5, 0, 0.6, 0.4, 3.6, BRASS)]
for side in [-1.0, 1.0]:
blocks.append(_b(-2.8, -0.7, side * 4.4, 7.6, 1.1, 3.0, DARK))
blocks.append(_b(-4.5, -0.3, side * 5.4, 6.4, 2.6, 3.0, STEEL))
blocks.append(_b(-7.4, -0.3, side * 5.4, 1.0, 2.2, 2.6, BLACK))
blocks.append(_b(-2.5, 1.2, side * 5.4, 1.0, 0.5, 2.8, BRASS))
blocks.append(_b(-4.4, 1.2, side * 5.4, 1.5, 0.5, 2.2, RIM))
blocks.append(_b(0.6, -0.6, side * 4.3, 1.5, 0.8, 1.1, STEEL))
for x in [-4.2, -2.8, -1.4]: blocks.append(_b(x, 1.5, side * 2.6, 0.5, 0.5, 1.1, DARK))
match family:
"beam":
blocks.append(_b(5.4, 0, 0, 10.0, 2.0, 4.5, DARK))
blocks.append(_b(5.4, 0, 0, 10.0, 2.0, 6.0 if mark_two else 4.5, DARK))
for side in [-1.0, 1.0]:
blocks.append(_b(9.0, 0, side * 2.9, 7.0, 2.5, 1.3, BRASS))
blocks.append(_b(12.0, 0, side * 2.9, 1.0, 3.2, 1.8, RIM))
var rail_z: float = side * (3.7 if mark_two else 2.9)
blocks.append(_b(9.0, 0, rail_z, 7.0, 2.5, 1.3, BRASS))
blocks.append(_b(12.0, 0, rail_z, 1.0, 3.2, 1.8, RIM))
blocks.append(_b(7.2, 1.5, rail_z, 1.0, 0.6, 1.8, STEEL))
blocks.append(_b(3.1, 1.4, 0, 2.5, 0.8, 4.0, BRASS))
"defense", "anti_drone":
blocks.append(_b(12.4, 0, 0, 0.64, 1.8, 6.0 if mark_two else 4.5, BLACK))
if mark_two:
blocks.append(_b(1.2, 3.0, 0, 3.0, 1.0, 4.6, STEEL))
blocks.append(_b(1.2, 3.7, 0, 2.0, 0.4, 3.6, BRASS))
"defense":
blocks.append(_b(1.3, 2.0, 0, 5.8, 2.0, 5.8, DARK))
blocks.append(_b(1.3, 3.1, 0, 4.2, 0.6, 4.2, STEEL))
for z in ([-1.5, 1.5] if mark_two else [0.0]):
@@ -197,14 +272,21 @@ func _body_blocks(mark_two: bool) -> Array:
for side in [-1.0, 1.0]:
blocks.append(_b(-1.5, 0, side * 8.0, 9.0, 0.8, 2.0, BRASS))
blocks.append(_b(-4.7, 2.0, side * 5.4, 0.5, 4.0, 0.5, DARK))
if family == "anti_drone":
blocks.append(_b(4.5, 1.7, -4.2, 7.0, 1.0, 1.0, BRASS))
blocks.append(_b(4.5, 1.7, 4.2, 7.0, 1.0, 1.0, BRASS))
blocks.append(_b(-4.7, 4.2, side * 5.4, 1.5, 0.5, 1.5, RIM))
if mark_two:
blocks.append(_b(1.3, 4.0, 0, 3.0, 1.0, 3.0, RIM))
blocks.append(_b(1.3, 4.65, 0, 2.0, 0.3, 2.0, SLATE))
_:
for z in ([-1.8, 1.8] if mark_two else [0.0]):
blocks.append(_b(7.3, 0.3, z, 10.5, 2.0, 2.0, DARK))
blocks.append(_b(11.7, 0.3, z, 1.4, 3.0, 3.0, RIM))
blocks.append(_b(12.6, 0.3, z, 0.64, 2.0, 2.0, BLACK))
blocks.append(_b(4.0, 0.7, 0, 1.2, 4.0, 6.0, BRASS))
for side in [-1.0, 1.0]:
blocks.append(_b(6.3, 1.6, side * (1.8 if mark_two else 0.85), 3.7, 0.6, 0.6, STEEL))
if mark_two:
blocks.append(_b(-2.0, 2.8, 0, 5.8, 0.8, 5.4, DARK))
blocks.append(_b(-2.0, 3.4, 0, 4.8, 0.4, 4.4, STEEL))
return blocks
@@ -212,15 +294,21 @@ func set_live(data: Dictionary) -> void:
live_data = data
visible = not bool(data.get("dead", false)) and (bool(data.get("deployed", false)) or not is_space)
var powered := bool(data.get("powered", true))
firing = bool(data.get("firing", false)) if is_space else bool(data.get("fighting", false))
var native_firing := bool(data.get("firing", false)) if is_space else bool(data.get("fighting", false))
if native_firing and not firing and is_space:
present_fire()
firing = native_firing
thruster.visible = is_space and visible and powered
emitter.visible = visible and powered
var material: StandardMaterial3D = emitter.material_override
material.emission_energy_multiplier = 1.5 if firing else 0.25
emitter.material_override = _glow_material(emitter_color, 1.5 if firing or fire_flash > 0.0 else 0.25)
func animate(delta: float) -> void:
animation_time += delta
fire_flash = maxf(0.0, fire_flash - delta)
if is_space:
body.position.x = -0.008 * (fire_flash / 0.13)
emitter.material_override = _glow_material(emitter_color, 1.5 if firing or fire_flash > 0.0 else 0.25)
if thruster.visible:
thruster.scale.x = 0.88 + sin(animation_time * 28.0) * 0.12
if tool != null:
@@ -231,16 +319,38 @@ func animate(delta: float) -> void:
tool.rotation.x = 0.55 + sin(animation_time * 11.0) * 0.35
func _glow_mesh(blocks: Array, color: Color) -> MeshInstance3D:
func _cached_part(key: String, blocks: Array) -> MeshInstance3D:
if not detail_meshes.has(key):
var source := Voxels.make(blocks)
detail_meshes[key] = source.mesh
source.free()
var node := MeshInstance3D.new()
node.mesh = detail_meshes[key]
node.material_override = body_material
return node
func _glow_mesh(key: String, blocks: Array, color: Color) -> MeshInstance3D:
# Interior robots have no exhaust; keep a harmless hidden mesh for the
# shared animation interface rather than creating an empty surface.
if blocks.is_empty(): blocks = [_b(0, 0, 0, 0.1, 0.1, 0.1, color)]
var node := Voxels.make(blocks)
var material: StandardMaterial3D = node.material_override
var node := _cached_part(key, blocks)
node.material_override = _glow_material(color, 1.5)
return node
func _glow_material(color: Color, energy: float) -> StandardMaterial3D:
var key := color.to_html() + ":" + str(energy)
if glow_materials.has(key):
return glow_materials[key]
var material := StandardMaterial3D.new()
material.vertex_color_use_as_albedo = true
material.roughness = 0.88
material.emission_enabled = true
material.emission = color
material.emission_energy_multiplier = 1.5
return node
material.emission_energy_multiplier = energy
glow_materials[key] = material
return material
func _b(x: float, y: float, z: float, w: float, h: float, d: float, color: Color) -> Array:
+126
View File
@@ -0,0 +1,126 @@
extends SceneTree
const DroneModel = preload("res://scripts/voxel_drone.gd")
const ShipModel = preload("res://scripts/ship_model.gd")
const CombatEffects = preload("res://scripts/combat_effects.gd")
const UiAssets = preload("res://scripts/ftl_ui_assets.gd")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1600, 1000)
var args := OS.get_cmdline_user_args()
var firing_capture := args.has("--shots")
var output := "res://local_game_data/drone_shots_preview.png" if firing_capture else "res://local_game_data/drone_gallery.png"
for argument in args:
if argument.begins_with("--output="):
output = argument.trim_prefix("--output=")
var world := Node3D.new()
root.add_child(world)
var environment := WorldEnvironment.new()
var settings := Environment.new()
settings.background_mode = Environment.BG_COLOR
settings.background_color = Color("101b27")
settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
settings.ambient_light_color = Color("d4e7f2")
settings.ambient_light_energy = 0.58
environment.environment = settings
world.add_child(environment)
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-42, -25, 0)
light.light_energy = 1.6
world.add_child(light)
var camera := Camera3D.new()
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
world.add_child(camera)
if firing_capture:
_shots(world, camera)
else:
_gallery(world, camera)
for i in range(12):
await process_frame
await RenderingServer.frame_post_draw
var path := ProjectSettings.globalize_path(output)
var result := root.get_texture().get_image().save_png(path)
print("DRONE_CAPTURE ", "shots" if firing_capture else "gallery", " result=", result, " path=", path)
quit(result)
func _gallery(world: Node3D, camera: Camera3D) -> void:
camera.size = 3.4
camera.position = Vector3(0.4, 4.3, 5.0)
camera.look_at(Vector3(0, 0.15, 0))
var entries := [
["combat", "COMBAT_1", "COMBAT I"], ["combat", "COMBAT_2", "COMBAT II"],
["beam", "BEAM_1", "BEAM I"], ["beam", "BEAM_2", "BEAM II"],
["defense", "DEFENSE_1", "DEFENSE I"], ["defense", "DEFENSE_2", "DEFENSE II"],
["anti_drone", "ANTI_DRONE", "ANTI DRONE"], ["hacking", "HACKING", "HACKING"],
["shield", "SHIELD", "SHIELD"], ["boarding", "BOARDER", "BOARDING"],
["repair", "REPAIR", "REPAIR"], ["battle", "BATTLE", "BATTLE"],
["ion_boarder", "ION_BOARDER", "ION BOARDER"]]
for i in range(entries.size()):
var entry: Array = entries[i]
var drone := DroneModel.new()
world.add_child(drone)
var exterior: bool = entry[0] not in ["repair", "battle", "ion_boarder"]
drone.build({"kind": entry[0], "species": entry[0], "name": entry[1],
"is_space": exterior, "deployed": true, "powered": true})
drone.position = Vector3((i % 5 - 2) * 0.87, 0.22, (i / 5 - 1) * 1.25)
drone.scale = Vector3.ONE * 2.9
drone.rotation.y = -0.65 if exterior else PI + 0.15
_label(world, entry[2], drone.position + Vector3(0, -0.19, 0.30), 33)
_label(world, "ORIGINAL VOXEL DRONES", Vector3(0, 0.35, -2.15), 43)
func _shots(world: Node3D, camera: Camera3D) -> void:
# Explicit native-schema fixtures make the muzzle/trajectory capture
# repeatable. They do not modify game state or claim native collision hits.
var ship := ShipModel.new()
world.add_child(ship)
ship.build("kestral", false)
ship.set_process(false)
ship.apply_live({"rooms": [], "crew": [], "drones": [
{"id": "capture-combat", "name": "COMBAT_2", "kind": "combat", "is_space": true,
"deployed": true, "powered": true, "x": 470, "y": 105, "angle": 180},
{"id": "capture-beam", "name": "BEAM_2", "kind": "beam", "is_space": true,
"deployed": true, "powered": true, "x": 470, "y": 240, "angle": 180}]})
ship.set_shields(0)
var effects := CombatEffects.new()
world.add_child(effects)
effects.set_process(false)
effects.spawn_shot({"kind": "laser", "drone_id": "capture-combat", "projectile_id": "capture-laser",
"origin_space": 0, "target_space": 0, "origin_point": {"x": 470, "y": 105},
"target": {"x": 235, "y": 105}, "outcome": "pending"}, ship, ship)
effects.spawn_shot({"kind": "beam", "drone_id": "capture-beam", "projectile_id": "capture-beam",
"origin_space": 0, "target_space": 0, "origin_point": {"x": 470, "y": 240},
"target": {"x": 235, "y": 240}, "end_point": {"x": 235, "y": 265}, "outcome": "pending"}, ship, ship)
effects.update_live_projectiles([{"id": "capture-laser", "progress": 0.22},
{"id": "capture-beam", "progress": 0.5, "beam_point": {"x": 235, "y": 252}}])
effects._process(0.1)
var center: Vector3 = ship.pixel_point({"x": 365, "y": 175}, 0.22)
camera.size = 1.6
camera.position = center + Vector3(0.05, 1.9, 1.7)
camera.look_at(center)
_label(world, "COMBAT II / NATIVE LASER", ship.pixel_point({"x": 430, "y": 60}, 0.38), 24)
_label(world, "BEAM II / NATIVE SWEEP", ship.pixel_point({"x": 430, "y": 300}, 0.8), 24, Color("1b3447"))
print("DRONE_SHOT_GEOMETRY muzzle_laser=", effects.shots[0]["start"],
" muzzle_beam=", effects.shots[1]["start"], " impacts=", effects.impacts.size())
func _label(world: Node3D, text: String, location: Vector3, size: int, color: Color = Color("d7e5e7")) -> void:
var label := Label3D.new()
label.text = text
label.font = UiAssets.font("body")
label.font_size = size
label.pixel_size = 0.0023
label.modulate = color
label.outline_modulate = Color("101b27")
label.outline_size = 2
label.no_depth_test = true
label.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.position = location
world.add_child(label)
+56
View File
@@ -0,0 +1,56 @@
extends SceneTree
func _initialize() -> void:
call_deferred("_capture")
func _capture() -> void:
var args := OS.get_cmdline_user_args()
var output_dir := args[0] if not args.is_empty() else ProjectSettings.globalize_path("res://local_game_data")
DirAccess.make_dir_recursive_absolute(output_dir)
var stage := Node3D.new()
root.add_child(stage)
var world := WorldEnvironment.new()
var environment := Environment.new()
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color(0.002, 0.003, 0.008)
world.environment = environment
stage.add_child(world)
var camera := Camera3D.new()
stage.add_child(camera)
camera.position = Vector3(0.0, 1.8, 2.0)
camera.look_at(Vector3(0.0, 1.8, -1.0))
camera.current = true
var space: Node3D = load("res://scripts/space_environment.gd").new()
stage.add_child(space)
space.build()
space.set_process(false)
space.set_travel_direction(Vector3.RIGHT)
var label := Label.new()
label.position = Vector2(28.0, 24.0)
label.add_theme_font_size_override("font_size", 22)
root.add_child(label)
space.apply_state({"hazard": "clear", "jumping": false})
label.text = "NORMAL: STARS SURROUND THE SHIP"
await _save_frame(output_dir.path_join("jump_normal.png"))
space.apply_state({"hazard": "sun", "jumping": true, "paused": true, "frozen": true})
space._process(0.4)
label.text = "NATIVE JUMP: HORIZONTAL SHIP TRAVEL >"
await _save_frame(output_dir.path_join("jump_stretched.png"))
space.set_travel_direction(Vector3(1.0, 0.0, -1.0))
label.text = "NATIVE JUMP: SHIP BOW TURNED 45 DEGREES"
await _save_frame(output_dir.path_join("jump_heading.png"))
space.apply_state({"hazard": "clear", "jumping": true, "event_open": true, "paused": true})
label.text = "ARRIVAL DIALOG: STARS RESTORED"
await _save_frame(output_dir.path_join("jump_arrived.png"))
print("JUMP_CAPTURE PASS ", output_dir)
quit()
func _save_frame(path: String) -> void:
await process_frame
await RenderingServer.frame_post_draw
var error := root.get_texture().get_image().save_png(path)
if error != OK:
push_error("Could not save jump capture: %s" % path)
+3 -1
View File
@@ -199,7 +199,9 @@ def main() -> None:
name.startswith(("img/statusUI/", "img/systemUI/", "img/icons/", "img/combatUI/"))
or name in {"img/box_subsystems4.png", "img/box_weapons_bottom4.png",
"img/box_weapons_bottom_label.png", "img/box_weapons_autofire_base.png",
"img/Text_pause1.png", "img/Text_pause2.png"})]
"img/Text_pause1.png", "img/Text_pause2.png"}
or name in {f"img/misc/crosshairs_placed{slot}{variant}.png"
for slot in range(1,5) for variant in ("", "_yellow")})]
for name in ui_paths:
destination = args.output / "ui" / name
destination.parent.mkdir(parents=True, exist_ok=True)
+36 -1
View File
@@ -1,4 +1,4 @@
"""Remove only edge-connected cleared black pixels, retaining black UI ink."""
"""Keep native UI ink while making the cleared world transparent."""
import numpy as np
@@ -40,3 +40,38 @@ def clear_world_alpha(pixels):
if edge[root(i)]:
result[y, x0:x1, 3] = 0
return result
def clear_supplemental_hud_alpha(pixels):
"""Correct native warning fringes in the independently cleared HUD pass.
FTL paints faint warning-text shadows with nonzero RGB and fractional
alpha. The legacy screen conversion makes those almost-black pixels
opaque. Use their actual coverage in the warning band only; framed HUD
controls still need the contour fill to retain their original black ink.
Full screens and native windows do not use this supplemental conversion.
"""
result = clear_world_alpha(pixels)
# At the native 1280x720 HUD coordinates, warnings sit between the top
# controls and the weapon bar, to the right of crew and left of target UI.
# They shift horizontally when an enemy arrives, so cover both positions.
height, width = pixels.shape[:2]
left, right = round(400 * width / 1280), round(872 * width / 1280)
top, bottom = round(160 * height / 720), round(360 * height / 720)
source = pixels[top:bottom,left:right]
warning = result[top:bottom,left:right]
if not source.size:
return result
# RGB already includes FTL's warning blink/fade. Do not multiply faded red
# glyphs by their raw FBO alpha a second time: only recover coverage for
# the almost-black texture fringe, relative to the current glyph peak.
colors = source[:, :, :3].astype(np.int16)
reddish = (colors[:, :, 0] > colors[:, :, 1] * 2) & (colors[:, :, 0] > colors[:, :, 2] * 2)
glyphs = reddish & (colors[:, :, 0] > 8)
if not np.any(glyphs):
return result
brightness = np.max(colors, axis=2)
fringe_limit = max(1.0, float(colors[:, :, 0][glyphs].max()) * 0.35)
faint_coverage = (source[:, :, 3] <= 8) & (brightness <= fringe_limit) & reddish
warning[:, :, 3][faint_coverage] = source[:, :, 3][faint_coverage]
return result
+4 -2
View File
@@ -15,7 +15,7 @@ from pathlib import Path
import frida
from PIL import Image
from extract_ftl import Pkg, build_ship
from hud_alpha import clear_world_alpha
from hud_alpha import clear_world_alpha, clear_supplemental_hud_alpha
def atomic_json(path, value):
@@ -102,6 +102,8 @@ def decode_state_line(line):
for room in result[side]['rooms']:
for field in ('fire_tiles','breach_tiles'):
if not isinstance(room.get(field),list): room[field]=[]
for weapon in result[side]['weapons']:
if isinstance(weapon,dict) and 'targets' in weapon and not isinstance(weapon['targets'],list): weapon['targets']=[]
if isinstance(result.get('dialog'),dict) and not isinstance(result['dialog'].get('choices'),list):
result['dialog']['choices']=[]
# vCrewList ownership/current-ship membership can differ during boarding.
@@ -445,7 +447,7 @@ def main():
panel_pixels=None
if payload['type']=='hud_frame':
if full_screen_capture(snapshot): continue
last_hud_image=Image.fromarray(remove_enemy_hud(clear_world_alpha(pixels)))
last_hud_image=Image.fromarray(remove_enemy_hud(clear_supplemental_hud_alpha(pixels)))
publisher.publish('hud_frame',last_hud_image,captured_at)
with state_lock: current['capture_full_screen']=False
continue
+48 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
from PIL import Image
import numpy as np
from hud_alpha import clear_world_alpha
from hud_alpha import clear_world_alpha, clear_supplemental_hud_alpha
from run_bridge import (decode_state_line, translate_command, full_screen_capture,
remove_enemy_hud, native_window_frame, remove_pause_overlay,
FRAME_HEADER, raw_frame_bytes, FramePublisher)
@@ -213,12 +213,59 @@ class BridgeTests(unittest.TestCase):
self.assertEqual(result[9,12,3],255)
self.assertTrue(np.array_equal(result[:,:,:3],pixels[:,:,:3]))
def test_supplemental_warning_keeps_text_and_native_faint_shadow_coverage(self):
pixels=np.zeros((720,1280,4),dtype=np.uint8)
# Native warning glyphs with a barely visible red-black texture fringe.
pixels[210:270,675:785]=[1,0,0,2]
pixels[215:223,690:770]=[86,18,10,43]
pixels[235:243,683:777]=[86,18,10,43]
pixels[255:263,683:777]=[86,18,10,43]
result=clear_supplemental_hud_alpha(pixels)
self.assertEqual(result[212,680,3],2,'Warning shadow must retain its faint native alpha')
self.assertEqual(result[218,700,3],255,'Native warning text must retain its existing visibility')
self.assertTrue(np.array_equal(result[:,:,:3],pixels[:,:,:3]))
def test_supplemental_warning_fade_and_scaled_native_capture(self):
pixels=np.zeros((720,1280,4),dtype=np.uint8)
pixels[210:270,675:785]=[1,0,0,1]
pixels[216:222,690:770]=[20,4,2,1]
for sample in (pixels,pixels[::2,::2]):
result=clear_supplemental_hud_alpha(sample)
scale=pixels.shape[0]//sample.shape[0]
self.assertEqual(result[212//scale,680//scale,3],1)
self.assertEqual(result[218//scale,700//scale,3],255,'Faded warning glyph RGB must not be multiplied by raw alpha twice')
self.assertTrue(np.array_equal(result[:,:,:3],sample[:,:,:3]))
def test_supplemental_warning_conversion_preserves_controls_and_normal_frames(self):
pixels=np.zeros((720,1280,4),dtype=np.uint8)
pixels[15:65,15:200,:3]=[125,180,190]
pixels[25:55,25:190,:3]=0
pixels[620:690,295:700,:3]=[125,180,190]
pixels[632:678,307:688,:3]=0
pixels[212:222,345:355]=[1,0,0,2]
pixels[190:200,610:760]=[1,0,0,2]
pixels[195:198,640:690]=[150,180,190,255]
expected=clear_world_alpha(pixels)
result=clear_supplemental_hud_alpha(pixels)
self.assertTrue(np.array_equal(result,expected),'A normal HUD must keep the legacy control alpha')
self.assertEqual(result[40,70,3],255,'Black ink inside top controls must remain opaque')
self.assertEqual(result[650,640,3],255,'Black weapon panel ink must remain opaque')
def test_empty_lua_vectors_become_arrays(self):
state=decode_state_line('FTLVR_STATE '+json.dumps({'protocol':2,'source':'hyperspace',
'shots':{},'projectiles':{},'player':{'rooms':{},'crew':{},'weapons':{}}}))
self.assertEqual(state['player']['crew'],[])
self.assertEqual(state['shots'],[])
def test_native_target_points_survive_empty_lua_vectors(self):
targets=[{'x':102,'y':134},{'x':177,'y':134}]
raw={'protocol':2,'source':'hyperspace','player':{'weapons':[
{'slot':0,'targets':{},'target_ship':1},
{'slot':1,'targets':targets,'target_ship':1}]}}
state=decode_state_line('FTLVR_STATE '+json.dumps(raw))
self.assertEqual(state['player']['weapons'][0]['targets'],[])
self.assertEqual(state['player']['weapons'][1]['targets'],targets)
def test_native_room_target_includes_enemy_screen_offset(self):
state={'ready':True,'ui_mode':'game','combat':True,'weapon_selected':1,
'enemy_origin':{'x':953,'y':105},
+62
View File
@@ -192,6 +192,68 @@ func _run() -> void:
effects.update_live_projectiles([{"id": "paused-beam", "progress": 0.4}])
effects.resolve_live({"projectile_id": "paused-beam", "outcome": "beam", "target": beam_point}, enemy)
_check(effects.shots.size() == 1, "A paused beam tile hit must retain the native continuing sweep")
effects.update_live_projectiles([])
effects.simulation_paused = false
effects._process(0.3)
# A player-owned drone is physically modeled in the receiver's native space.
# Attach a fire record to that exact muzzle before testing orbital motion and
# independent pause versus encounter transforms.
enemy._apply_drones([{"id": "88001", "name": "COMBAT_2", "kind": "combat", "is_space": true,
"powered": true, "deployed": true, "x": 90, "y": 130, "angle": 35.0}])
var actual_drone: Node3D = enemy.drone_nodes["88001"]
var native_drone_event := {"kind": "laser", "outcome": "pending", "projectile_id": "drone-native",
"drone_id": "88001", "origin_space": 1, "origin_point": drone_origin,
"target_space": 1, "target": drone_target}
var muzzle: Vector3 = actual_drone.muzzle_position("drone-native")
effects.spawn_shot(native_drone_event, player, enemy)
effects.update_live_projectiles([{"id": "drone-native", "progress": 0.25}])
effects._process(0.1)
var drone_shot: Dictionary = effects.shots.back()
_check(Vector3(drone_shot["start"]).is_equal_approx(muzzle), "Native drone fire must originate at the visible muzzle in its current render space")
_check(drone_shot["node"].mesh.get_aabb().size.z < 0.1, "Drone bullets must remain sized for their small native emitters")
actual_drone.position += Vector3(0.2, 0, 0.1)
effects._process(0.1)
_check(Vector3(drone_shot["start"]).is_equal_approx(muzzle), "Later orbital drone movement must not move a bullet's original launch point")
var launch_local: Vector3 = enemy.to_local(muzzle)
var paused_progress: float = drone_shot["live_progress"]
effects.simulation_paused = true
enemy.position += Vector3(0.3, 0.1, -0.2)
enemy.rotation += Vector3(0.15, 0.2, 0.08)
enemy.scale = Vector3.ONE * 0.7
effects._process(0.5)
_check(Vector3(drone_shot["start"]).is_equal_approx(enemy.to_global(launch_local)), "Paused drone shots must follow encounter position, tilt and scale without advancing their native flight")
_check(is_equal_approx(drone_shot["live_progress"], paused_progress), "Repositioning a paused encounter must preserve native projectile progress")
_check(is_equal_approx(drone_shot["node"].global_basis.x.length(), enemy.global_basis.x.length()), "An orbiting drone's projectile size must follow its physical render space rather than its owner ship")
effects.update_live_projectiles([])
effects.simulation_paused = false
var beam_event := native_drone_event.duplicate()
beam_event.merge({"kind": "beam", "projectile_id": "drone-beam", "end_point": {"x": 200, "y": 130}}, true)
effects.spawn_shot(beam_event, player, enemy)
effects.update_live_projectiles([{"id": "drone-beam", "progress": 0.5, "beam_point": drone_target}])
actual_drone.position += Vector3(0.1, 0, -0.05)
effects._process(0.1)
var drone_beam: Dictionary = effects.shots.back()
_check(Vector3(drone_beam["start"]).is_equal_approx(actual_drone.muzzle_position("drone-beam")), "A continuing native drone beam must remain attached to its actual visible emitter")
_check(drone_beam["node"].get_node_or_null("BeamCore") != null and drone_beam["node"].mesh is ImmediateMesh, "A native beam drone must render a sweep, never an invented laser bullet")
var drone_beam_core: MeshInstance3D = drone_beam["node"].get_node("BeamCore")
_check(drone_beam_core.global_position.is_equal_approx((drone_beam["start"] + enemy.to_global(enemy.pixel_point(drone_target))) * 0.5), "The native beam's exact live endpoint and muzzle must bound its visible geometry")
effects.update_live_projectiles([{"id": "drone-beam", "progress": 1.0, "beam_point": drone_target}])
effects._process(0.1)
_check(effects.shots.size() == 1, "Native progress at one must not delete a still-present beam or invent its outcome")
effects.update_live_projectiles([])
var fallback_event := native_drone_event.duplicate()
fallback_event.merge({"drone_id": "-1", "projectile_id": "negative-native"}, true)
enemy.drone_nodes["-1"] = actual_drone
effects.spawn_shot(fallback_event, player, enemy)
_check(Vector3(effects.shots.back()["start"]).is_equal_approx(enemy.to_global(enemy.pixel_point(drone_origin, 0.3))), "An ambiguous negative native drone id must use its real native point at drone height")
enemy.drone_nodes.erase("-1")
effects.update_live_projectiles([{"id": "negative-native", "progress": 0.3}])
effects.update_live_projectiles([])
var first_bullet: MeshInstance3D = effects._projectile_visual("laser", Color.ORANGE, true)
var second_bullet: MeshInstance3D = effects._projectile_visual("laser", Color.ORANGE, true)
_check(first_bullet.mesh == second_bullet.mesh and first_bullet.get_child(0).mesh == second_bullet.get_child(0).mesh, "Repeated drone shots must reuse both core and trail geometry")
first_bullet.free()
second_bullet.free()
print("COMBAT_TESTS ", "PASS" if failures == 0 else "FAIL: %d" % failures)
scene.queue_free()
await process_frame
+66
View File
@@ -0,0 +1,66 @@
extends SceneTree
const DroneModel = preload("res://scripts/voxel_drone.gd")
var failures := 0
func _initialize() -> void:
call_deferred("_run")
func _check(condition: bool, message: String) -> void:
if not condition:
failures += 1
push_error(message)
func _run() -> void:
for family in ["combat", "beam", "defense"]:
var first := DroneModel.new()
var second := DroneModel.new()
root.add_child(first)
root.add_child(second)
first.build({"name": family.to_upper() + "_1", "kind": family, "deployed": true})
second.build({"name": family.to_upper() + "_2", "kind": family, "deployed": true})
_check(first.body.mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX] != second.body.mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX], "Every native %s Mk II must have distinct armor or emitter geometry" % family)
first.queue_free()
second.queue_free()
var idle := DroneModel.new()
var active := DroneModel.new()
root.add_child(idle)
root.add_child(active)
var data := {"name": "COMBAT_2", "kind": "combat", "deployed": true, "powered": true}
idle.build(data)
active.build(data)
_check(idle.thruster.mesh == active.thruster.mesh and idle.emitter.mesh == active.emitter.mesh,
"Matching drone glow surfaces must reuse geometry as well as their armored body")
var idle_material := idle.emitter.material_override
active.present_fire()
_check(idle.emitter.material_override == idle_material and is_equal_approx(idle_material.emission_energy_multiplier, 0.25),
"A native fire flash must not brighten another drone through a shared mutable material")
_check(is_equal_approx(active.emitter.material_override.emission_energy_multiplier, 1.5), "An actual native fire record must visibly brighten its own emitter")
active.animate(0.04)
_check(active.body.position.x < 0.0, "An actual native shot must produce a short recoil pose")
active.animate(0.25)
_check(is_zero_approx(active.body.position.x) and active.emitter.material_override == idle_material,
"A completed native fire flash must return to the shared idle pose and glow")
var changes := {"count": 0}
var track_change := func(): changes["count"] += 1
for part in [active.body, active.thruster, active.emitter]:
part.mesh.changed.connect(track_change)
for i in range(90):
active.animate(1.0 / 90.0)
_check(changes["count"] == 0, "Drone animation must update transforms without rebuilding shared mesh geometry")
for part in [active.body, active.thruster, active.emitter]:
part.mesh.changed.disconnect(track_change)
active.rotation = Vector3(0.2, -1.0, 0.1)
active.scale = Vector3.ONE * 0.6
var local_muzzle: Vector3 = active.to_local(active.muzzle_position("42"))
_check(is_equal_approx(local_muzzle.x, 0.135) and is_equal_approx(local_muzzle.y, 0.003) and is_equal_approx(absf(local_muzzle.z), 0.018),
"The native firing muzzle must retain the model's local barrel point through tilt and scale")
idle.queue_free()
active.queue_free()
print("DRONE_TESTS ", "PASS" if failures == 0 else "FAIL: %d" % failures,
" mk2_families=3 shared_geometry=body+exhaust+emitter independent_glow=true immutable_meshes=true")
await process_frame
quit(0 if failures == 0 else 1)
+49
View File
@@ -30,6 +30,13 @@ func _run() -> void:
if child is MultiMeshInstance3D:
meshes += 1
_check(meshes == 3, "Starfield must batch its geometry into three draw calls")
var star_nodes: Array[MultiMeshInstance3D] = []
var star_meshes: Array[MultiMesh] = []
for child in space.get_children():
if child is MultiMeshInstance3D:
star_nodes.append(child)
star_meshes.append(child.multimesh)
_check(child.material_override == space.star_material, "Star shells must share one persistent stretch material")
for kind in ["asteroid", "sun", "storm", "nebula", "pulsar", "clear"]:
space.apply_state({"hazard": kind, "paused": false})
_check(space.hazard == kind, "Environment must follow the native hazard")
@@ -46,6 +53,48 @@ func _run() -> void:
space.apply_state({"hazard": "asteroid", "paused": false})
space._process(0.5)
_check(rock.position != location, "Unpaused asteroid field must have parallax motion")
# A charged drive, the map, and pending startup events are not departures.
for stationary in [
{"hazard": "clear", "navigation": {"jump": true}, "map_open": true},
{"hazard": "clear", "transition": true, "event_pending": true, "paused": true},
{"hazard": "clear", "event_open": true, "dialog": {"id": "startup"}},
]:
space.apply_state(stationary)
space._process(0.4)
_check(not space.jump_active and space.jump_stretch == 0.0, "Stationary native UI must not fake a jump")
space.set_travel_direction(Vector3(0.0, 7.0, -3.0))
_check(space.travel_direction.is_equal_approx(Vector3.FORWARD), "Travel follows the ship bow projected onto the horizontal plane")
space.set_travel_direction(Vector3.UP)
_check(space.travel_direction.is_equal_approx(Vector3.FORWARD), "An invalid vertical heading must preserve the last travel direction")
space.apply_state({"hazard": "asteroid", "jumping": true, "paused": true, "frozen": true})
var departure_rock: Node3D = space.rocks[0]["node"]
var departure_location := departure_rock.position
space._process(0.1)
_check(space.jump_active and space.jump_stretch > 0.0, "Native travel must stretch stars even while simulation is paused")
_check(not space.hazard_root.visible, "Old beacon hazards must be hidden during travel")
_check(departure_rock.position == departure_location, "Hidden old beacon hazards must not advance during travel")
space._process(0.4)
_check(space.jump_stretch == 1.0, "A sustained native jump must reach full star stretch")
space.set_travel_direction(Vector3(-4.0, 0.0, 0.0))
_check(Vector3(space.star_material.get_shader_parameter("travel_direction")).is_equal_approx(Vector3.LEFT), "Streaks must follow the supplied ship heading, including reversed travel")
space.apply_state({"hazard": "sun", "jumping": true, "event_open": true, "paused": true})
_check(not space.jump_active and space.jump_stretch == 0.0, "Arrival dialog must restore stars immediately")
_check(space.hazard_root.visible and space.hazard == "sun", "Arrival must show the new beacon's native hazard")
space.apply_state({"hazard": "sun", "jumping": true, "event_open": false})
space._process(0.3)
_check(space.jump_stretch == 0.0, "Closing an arrival dialog must not restart a lingering native jump animation")
space.apply_state({"hazard": "clear", "jumping": false})
space.apply_state({"hazard": "clear", "jumping": true})
space._process(0.4)
_check(space.jump_stretch == 1.0, "The next real departure must animate after the previous arrival")
space.apply_state({"hazard": "nebula", "jumping": false, "event_open": false})
_check(space.jump_stretch == 0.0 and space.hazard_root.visible, "A beacon without an arrival dialog must also end native travel")
space.apply_state({"jumping": true, "ready": false, "ui_mode": "menu"})
space._process(0.4)
_check(space.jump_stretch == 0.0, "A native menu snapshot must not show travel")
for i in range(star_nodes.size()):
_check(star_nodes[i].multimesh == star_meshes[i], "Jump transitions must preserve the star MultiMesh resources")
_check(star_nodes[i].material_override == space.star_material, "Jump transitions must preserve the shared star material")
print("ENVIRONMENT_TESTS ", "PASS" if failures == 0 else "FAIL: %d" % failures)
space.queue_free()
await process_frame
+17
View File
@@ -18,6 +18,12 @@ func run() -> void:
XRServer.add_tracker(tracker)
scene._resolve_controller_pose(scene.right_hand)
check(scene.right_hand.pose == "aim" and scene.right_hand.get_is_active(), "Right hand must use the active aim tracker pose")
var initial_ship_pose: Transform3D = scene.player_ship.transform
scene.player_ship.rotation.y = PI / 2
scene._sync_jump_heading()
check(scene.space_environment.travel_direction.is_equal_approx(Vector3.FORWARD), "Jump streaks must follow the ship's transformed bow rather than the headset")
scene.player_ship.transform = initial_ship_pose
scene._sync_jump_heading()
scene._update_pointer()
check(scene.right_laser.visible and scene.right_laser.mesh is CylinderMesh, "Tracked hand must draw a stereo-visible ray")
check(scene.right_laser.material_override.no_depth_test, "Ray must remain visible over the native menu")
@@ -132,6 +138,17 @@ func run() -> void:
scene._cycle_hand_page(1)
check(scene.pending_page == "shortcuts" and scene.commands.back().data.screen == "tactical","Changing screen must first close the native tactical view")
scene.hud_state.tactical = false
scene.wheel_category = 2
scene.wheel_was_down = false
tracker.set_input("wheel_button", true)
scene._update_wheel()
check(scene.wheel_category == 0 and scene.wheel.heading == "WEAPONS\nDRONES", "Each new wheel hold must return to equipment rather than retaining a system category")
scene.wheel_category = 1
scene._update_wheel()
check(scene.wheel_category == 1, "A held wheel must retain a manually chosen category")
tracker.set_input("wheel_button", false)
scene._update_wheel()
check(scene.wheel_category == 0, "Closing the wheel must restore its equipment default")
scene._refresh_wheel()
check(scene.wheel.entries[0].enabled and not scene.wheel.entries[1].enabled and scene.wheel.entries[4].enabled,"Wheel must show only equipped weapon/drone slots as available")
scene.wheel.choose(Vector2(0,1))
+220
View File
@@ -0,0 +1,220 @@
extends SceneTree
const ShipModel = preload("res://scripts/ship_model.gd")
const TargetLocks = preload("res://scripts/target_locks.gd")
const UiAssets = preload("res://scripts/ftl_ui_assets.gd")
var failures := 0
func _initialize() -> void:
call_deferred("_run")
func _check(condition: bool, message: String) -> void:
if not condition:
failures += 1
push_error(message)
func _run() -> void:
var encounter := Node3D.new()
root.add_child(encounter)
var player := ShipModel.new()
encounter.add_child(player)
player.build("__target_locks_fixture__", false)
player.set_process(false)
var enemy := ShipModel.new()
encounter.add_child(enemy)
enemy.build("__target_locks_fixture__", true)
enemy.set_process(false)
enemy.layout_data.x_offset = 2
enemy.layout_data.y_offset = 1
var renderer := TargetLocks.new()
root.add_child(renderer)
var laser := {"slot": 0, "kind": "laser", "target_ship": 1, "autofire": false,
"targets": [{"x": 105, "y": 70}, {"x": 175, "y": 140}]}
var beam := {"slot": 1, "kind": "beam", "beam_length": 100, "target_ship": 1, "autofire": true,
"targets": [{"x": 245, "y": 140}, {"x": 105, "y": 70}]}
var flak := {"slot": 2, "kind": "flak", "target_ship": 1, "target_radius": 30,
"targets": [{"x": 175, "y": 70}]}
var state := {"ready": true, "combat": true, "paused": true, "weapon_selected": -1,
"targeting": {"active": false}, "player": {"weapons": [laser, beam, flak]},
"enemy": {"rooms": [{"id": 0, "visible": false}], "weapons": [laser]}}
var original := JSON.stringify(state)
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.size() == 3, "Native locks must persist after the armed cursor closes, including during pause")
_check(JSON.stringify(state) == original, "Target lock presentation must never mutate the game snapshot")
var local_target: Vector3 = enemy.pixel_point(laser.targets[0], TargetLocks.HEIGHT)
_check(renderer.locks[0].end.is_equal_approx(local_target), "Native target conversion must remove layout offsets")
_check(Vector2(local_target.x, local_target.z).is_equal_approx(enemy.room_bounds[0].get_center()), "The lock must remain centered on the native room")
_check(renderer.locks[0].node.get_child_count() <= 2, "Charged weapon extra points must not invent reticles absent from the native aiming renderer")
_check(renderer.locks[1].start.is_equal_approx(enemy.pixel_point(beam.targets[1], TargetLocks.HEIGHT))
and renderer.locks[1].end.is_equal_approx(enemy.pixel_point(beam.targets[0], TargetLocks.HEIGHT)), "Beam direction must use native targets[1] to targets[0]")
var radius_mesh: MeshInstance3D = renderer.locks[2].node.get_node("FlakRadius")
_check(is_equal_approx(radius_mesh.mesh.top_radius, 30.0 * ShipModel.TILE / 35.0), "Flak circle must use the actual native spread radius")
_check(renderer.locks[1].node.get_meta("autofire") and renderer.locks[1].node.get_node("WeaponSlot").modulate == TargetLocks.AUTOFIRE,
"Autofire must use the native yellow cue")
var retained_id: int = renderer.locks[0].node.get_instance_id()
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks[0].node.get_instance_id() == retained_id, "Identical snapshots must retain existing lock geometry")
encounter.position = Vector3(1.7, 0.4, -1.2)
encounter.rotation_degrees = Vector3(17, 39, -12)
encounter.scale = Vector3(0.65, 1.2, 0.83)
enemy.position = Vector3(0.23, 0.17, -0.48)
enemy.rotation_degrees = Vector3(-8, -23, 14)
renderer.refresh_transforms()
_check(renderer.locks[0].node.to_global(renderer.locks[0].end).is_equal_approx(enemy.to_global(local_target)),
"Locks must follow moving, rotated and nonuniformly scaled ships without waiting for a new snapshot")
_check(renderer.locks[1].node.global_transform.is_equal_approx(enemy.global_transform), "Beam endpoint geometry must follow the same ship transform")
enemy.hide()
renderer.refresh_transforms()
_check(not renderer.locks[0].node.visible, "Hidden ships must hide their locks immediately")
enemy.show()
renderer.refresh_transforms()
_check(renderer.locks[0].node.visible, "Visible native locks must return with their ship")
laser.targets = [{"x": 175, "y": 140}]
laser.autofire = true
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks[0].node.get_instance_id() != retained_id and renderer.locks[0].node.get_meta("autofire"),
"Native retargeting and autofire colour changes must replace stale geometry immediately")
laser.targets = []
renderer.apply_snapshot(state, player, enemy)
_check(not renderer.locks.has(0) and renderer.locks.size() == 2, "Clearing one native target must remove only that weapon's lock")
state.player.weapons = []
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.is_empty(), "Enemy weapon intent must never create a lock on either ship")
var self_bomb := {"slot": 0, "kind": "bomb", "target_ship": 0, "targets": [{"x": 35, "y": 35}]}
state.player.weapons = [self_bomb]
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks[0].receiver == player, "A player-owned bomb must honor its actual friendly target ship")
state.combat = false
state.player.weapons = [self_bomb, beam]
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.size() == 1 and renderer.locks.has(0) and renderer.locks[0].receiver == player,
"A native self-target bomb must retain its lock without an enemy, while enemy targets disappear")
state.combat = true
state.player.weapons = [self_bomb]
self_bomb.target_ship = -1
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.is_empty(), "Invalid native target ship IDs must clear previous locks")
state.player.weapons = [beam]
beam.targets = [{"x": 245, "y": 140}]
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.is_empty(), "A partially placed real beam must not show a fabricated second endpoint")
beam.beam_length = 1
beam.targets = [{"x": 241, "y": 136}]
renderer.apply_snapshot(state, player, enemy)
_check(not renderer.locks[1].beam and renderer.locks[1].end.is_equal_approx(enemy.pixel_point({"x": 227.5, "y": 122.5}, TargetLocks.HEIGHT)),
"Pinpoint beams must use the native tile center and a placed reticle")
beam.targets = [{"x": NAN, "y": 140}]
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.is_empty(), "Nonfinite native coordinates must not reach render geometry")
beam.targets = [{"x": 245, "y": 140}]
for gate in ["combat", "ready"]:
state[gate] = false
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.is_empty(), "An ended encounter or inactive game must remove every lock")
state[gate] = true
state.transition = true
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.is_empty(), "Jump transition must remove old encounter locks")
state.transition = false
state.enemy.destroyed = true
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.is_empty(), "Native enemy destruction must remove its locks")
state.enemy.destroyed = false
renderer.apply_snapshot(state, player, enemy)
_check(renderer.locks.size() == 1, "Current native target state must recreate a valid lock")
enemy.queue_free()
await process_frame
renderer.refresh_transforms()
_check(renderer.locks.is_empty(), "Freed receivers must remove locks without accessing their transforms")
# Exercise the source-only fallback even when owned slot 1–4 art is present.
var fallback := Node3D.new()
root.add_child(fallback)
renderer._make_reticle(fallback, Vector3.ZERO, 8, false)
renderer._finish_lines(fallback)
_check(fallback.get_node("LockLines").mesh.get_surface_count() == 1 and fallback.get_child_count() == 2,
"Procedural reticles must batch all strokes into one surface and retain the weapon slot cue")
fallback.queue_free()
encounter.queue_free()
renderer.queue_free()
await process_frame
if failures == 0 and OS.get_cmdline_user_args().has("--capture"):
await _capture_fixture()
print("TARGET_LOCK_TESTS ", "PASS" if failures == 0 else "FAIL %d" % failures)
quit(0 if failures == 0 else 1)
func _capture_fixture() -> void:
# Renderer-only oracle: no native process, game commands, input or save writes.
root.size = Vector2i(1400, 900)
var world := WorldEnvironment.new()
var environment := Environment.new()
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color("101923")
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.ambient_light_color = Color("d4e7f2")
environment.ambient_light_energy = 0.85
world.environment = environment
root.add_child(world)
var camera := Camera3D.new()
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
camera.size = 1.45
camera.position = Vector3(0, 2.0, 1.35)
root.add_child(camera)
camera.look_at(Vector3.ZERO)
var enemy := ShipModel.new()
root.add_child(enemy)
enemy.build("__target_locks_fixture__", true)
enemy.set_process(false)
enemy.inspect_rooms = true
var rooms: Array = []
for id in range(6):
rooms.append({"id": id, "visible": id != 2})
enemy.apply_live({"rooms": rooms})
var renderer := TargetLocks.new()
root.add_child(renderer)
var state := {"ready": true, "combat": true, "player": {"weapons": [
{"slot": 0, "kind": "laser", "target_ship": 1, "targets": [{"x": 35, "y": 35}]},
{"slot": 1, "kind": "laser", "autofire": true, "target_ship": 1, "targets": [{"x": 105, "y": 35}]},
{"slot": 2, "kind": "flak", "target_ship": 1, "target_radius": 32, "targets": [{"x": 175, "y": 35}]},
{"slot": 3, "kind": "beam", "beam_length": 140, "target_ship": 1, "targets": [{"x": 175, "y": 105}, {"x": 35, "y": 105}]}]},
"enemy": {"rooms": rooms}}
renderer.apply_snapshot(state, null, enemy)
var title := _caption("NATIVE WEAPON TARGET LOCKS", Vector2(40, 32), 34)
var footer := _caption("Red: single volley / Yellow: autofire / Circle: flak spread / Beam: actual sweep direction", Vector2(40, 837), 22)
await _capture("target_locks_fixture.png")
enemy.position = Vector3(0.12, 0.03, 0.05)
enemy.rotation_degrees = Vector3(13, 31, -9)
enemy.scale = Vector3(1.2, 0.8, 1.08)
renderer.refresh_transforms()
camera.size = 1.75
title.text = "LOCKS FOLLOW THE MOVED AND SCALED SHIP"
footer.text = "Room locks and both beam endpoints remain attached during pause. Sensor fog reveals no crew or system details."
await _capture("target_locks_transformed_fixture.png")
state.player.weapons = []
renderer.apply_snapshot(state, null, enemy)
title.text = "NATIVE TARGET CLEAR REMOVES ALL LOCKS"
footer.text = "Weapon target lists are authoritative; cleared targets disappear immediately."
await _capture("target_locks_cleared_fixture.png")
func _capture(filename: String) -> void:
for i in range(12):
await process_frame
await RenderingServer.frame_post_draw
var path := ProjectSettings.globalize_path("res://local_game_data/" + filename)
root.get_texture().get_image().save_png(path)
print("TARGET_LOCK_CAPTURE ", path)
func _caption(text: String, point: Vector2, size: int) -> Label:
var label := Label.new()
label.text = text
label.position = point
label.add_theme_font_override("font", UiAssets.font("body"))
label.add_theme_font_size_override("font_size", size)
label.modulate = Color("fff2c4")
root.add_child(label)
return label