Add scripted lightning weapon mods with interpolated anchored effects

This commit is contained in:
raptorreplica committed 2026-10-11 16:45:22 +11:00
1 parent 98465133aa
commit c0a6fd3431
50 files changed
+3500 -65

No files matched your search

+6 -1
View File
@@ -28,6 +28,10 @@ folder exists. Enable **Iron Coast Campaign** to select all six mission mods,
their enemies, distinct bosses, weapons and original soundtrack automatically, their enemies, distinct bosses, weapons and original soundtrack automatically,
then apply at the main menu. Missions, bosses and the three weapons can also then apply at the main menu. Missions, bosses and the three weapons can also
be enabled individually; each mission selects its own dependencies. be enabled individually; each mission selects its own dependencies.
**Lightning Cannon** and **Lightning Shield** are two additional independent
weapon mods. Enable them in Mods and buy them in the supply room. The cannon
is a selected special that chains through nearby targets; the shield fires
with your primary guns while Fire is held. Both ship SD/HD icons and sounds.
For a user mods folder, pass `--mods DIR` or create For a user mods folder, pass `--mods DIR` or create
`$XDG_DATA_HOME/RaptorReplica/mods/`. Saves and configuration use separate XDG `$XDG_DATA_HOME/RaptorReplica/mods/`. Saves and configuration use separate XDG
`RaptorReplica` directories, with read-only legacy import. `RaptorReplica` directories, with read-only legacy import.
@@ -52,7 +56,7 @@ Existing untouched Rust default bindings migrate to these controls.
| Folder | Contents | | Folder | Contents |
| --- | --- | | --- | --- |
| `src/` | Engine, SDL host and validation CLI | | `src/` | Engine, SDL host and validation CLI |
| `mods/` | Aurora example; six Iron Coast missions, campaign assembly, music, three weapons, three shared enemies and five stage bosses | | `mods/` | Aurora example; six Iron Coast missions, campaign assembly, music, five independent weapons, three shared enemies and five stage bosses |
| `schemas/` | Version 1 mod API schemas | | `schemas/` | Version 1 mod API schemas |
| `tests/` | Portable fixtures and opt-in original-data checks | | `tests/` | Portable fixtures and opt-in original-data checks |
| `tools/` | Replay comparator, content-table generator and asset reference tools | | `tools/` | Replay comparator, content-table generator and asset reference tools |
@@ -77,6 +81,7 @@ cases; wider playtesting and complete base-game HD coverage remain work in progr
- [Build and validation](docs/rust-rewrite.md) - [Build and validation](docs/rust-rewrite.md)
- [Mod API](docs/modding-api.md) - [Mod API](docs/modding-api.md)
- [AI mod authoring checklist](docs/mod-authoring-checklist.md)
- [Iron Coast packages](docs/iron-coast.md) - [Iron Coast packages](docs/iron-coast.md)
- [Weapon prices and balance](docs/weapon-balance.md) - [Weapon prices and balance](docs/weapon-balance.md)
- [Sprite and shadow validation](docs/visual-validation.md) - [Sprite and shadow validation](docs/visual-validation.md)
+25 -7
View File
@@ -1,8 +1,8 @@
# Optional HD artwork # Optional HD artwork
The Rust Options screen includes **HD art**. It starts disabled. Enable it to use reviewed artwork from `../data/hd/hd.json`; each missing or rejected frame keeps the selected original / 2x / 3x / 4x crisp treatment. The scale setting still determines the indexed rendering resolution, and the HD toggle can be changed while paused. HD art is presentation only: simulation, collision sizes, banking positions and save content IDs stay unchanged. The Rust Options screen includes **HD art**. It starts disabled. Enable it to use reviewed artwork from the selected data directory's `hd/hd.json`; each missing or rejected frame keeps the selected original / 2x / 3x / 4x crisp treatment. The bundled runtime uses `data/`, while development artwork and review jobs live under `../data/`. The scale setting still determines the indexed rendering resolution, and the HD toggle can be changed while paused. HD art is presentation only: simulation, collision sizes, banking positions and save content IDs stay unchanged.
The local base-game pack currently supplies **215 reviewed replacement frames**. Complete additions cover all twenty-one light, dark and firing-flash player poses, thirteen vehicle families, four industrial bosses, seven ground-defense targets, a bonus crate and complete five- and fifteen-frame opening bunkers, nineteen equipment/pickup families, seven complete projectile families, the twelve-frame energy ring, nine blue-spark phases, four death-ray hit phases, fourteen lightning phases, ten platoon fragment phases, sixteen air-blast phases, four pickup glow masks, nine pilot portraits, both original title logos and sixteen canonical terrain tiles. The complete catalog backlog and generation status are listed in [hd-backlog.md](hd-backlog.md). The development base-game pack currently supplies **340 reviewed replacement frames in 122 complete sequences**. These include all twenty-one light, dark and firing-flash player poses, complete vehicle and boss families, ground defenses and opening bunkers, equipment and pickups, projectile and effect animations, nine pilot portraits, both title logos and sixty-four canonical terrain tiles. A bundled runtime snapshot may have an earlier count until the next packaging refresh; its manifest is the authoritative count for that build. The complete catalog backlog and generation status are listed in [hd-backlog.md](hd-backlog.md).
| Reviewed artwork | Original asset IDs | Supplied / complete frames | | Reviewed artwork | Original asset IDs | Supplied / complete frames |
| --- | --- | ---: | | --- | --- | ---: |
@@ -16,9 +16,20 @@ The local base-game pack currently supplies **215 reviewed replacement frames**.
| `SHIP07` two-state craft | `0x102fe`–`0x102ff` | 2 / 2 | | `SHIP07` two-state craft | `0x102fe`–`0x102ff` | 2 / 2 |
| `SHIP08` moving tread phases | `0x10300`–`0x10303` | 4 / 4 | | `SHIP08` moving tread phases | `0x10300`–`0x10303` | 4 / 4 |
| `SHIP09` hovercraft wake phases | `0x10304`–`0x10305` | 2 / 2 | | `SHIP09` hovercraft wake phases | `0x10304`–`0x10305` | 2 / 2 |
| `SHIP21` industrial platform wakes | `0x10317`–`0x10318` | 2 / 2 |
| `SHIP23` complete rotating craft | `0x1031f`–`0x10326` | 8 / 8 |
| `SHIP29`, `SHIP30`, `SHIP32`, `SHIP39` craft | `0x10332`, `0x10333`, `0x10335`, `0x10344` | 4 / 4 |
| `SHIP33` pod deployment | `0x10336`–`0x10339` | 4 / 4 |
| `SHIP34` orb light states | `0x1033a`–`0x1033b` | 2 / 2 |
| First-mission industrial boss `SHIP10G1_PIC` | `0x10306` | 1 / 1 | | First-mission industrial boss `SHIP10G1_PIC` | `0x10306` | 1 / 1 |
| Industrial bosses `SHIP12G1_PIC`, `SHIP17G1_PIC` | `0x10308`, `0x1030f` | 2 / 2 | | Industrial bosses `SHIP12G1_PIC`, `SHIP17G1_PIC` | `0x10308`, `0x1030f` | 2 / 2 |
| Industrial boss `SHIP11G1_PIC` | `0x10307` | 1 / 1 | | Industrial boss `SHIP11G1_PIC` | `0x10307` | 1 / 1 |
| Industrial boss `SHIP16G1_PIC` | `0x1030e` | 1 / 1 |
| `SHIP22` complete hatch phases | `0x10319`–`0x1031e` | 6 / 6 |
| Second-campaign static bosses | `0x200a1`, `0x200ac`, `0x200ad`, `0x200eb` | 4 / 4 |
| Second-campaign four-phase boss | `0x200a8`–`0x200ab` | 4 / 4 |
| Second-campaign six-phase boss | `0x200ce`–`0x200d3` | 6 / 6 |
| Duo emergence phases | `0x20104`–`0x2010b` | 8 / 8 |
| Ground-defense target `TARGT1G1_PIC` | `0x10138` | 1 / 1 | | Ground-defense target `TARGT1G1_PIC` | `0x10138` | 1 / 1 |
| Ground-defense targets `TARGT2G1_PIC`, `TARGT3G1_PIC` | `0x10139`, `0x1013a` | 2 / 2 | | Ground-defense targets `TARGT2G1_PIC`, `TARGT3G1_PIC` | `0x10139`, `0x1013a` | 2 / 2 |
| Ground-defense target `TARGT5G1_PIC` | `0x1013c` | 1 / 1 | | Ground-defense target `TARGT5G1_PIC` | `0x1013c` | 1 / 1 |
@@ -37,6 +48,7 @@ The local base-game pack currently supplies **215 reviewed replacement frames**.
| Ground Missile equipment | `0x102c0` | 1 / 1 | | Ground Missile equipment | `0x102c0` | 1 / 1 |
| Energy Grab equipment phases | `0x102c1`–`0x102c4` | 4 / 4 | | Energy Grab equipment phases | `0x102c1`–`0x102c4` | 4 / 4 |
| Pulse Cannon equipment phases | `0x102c6`–`0x102c7` | 2 / 2 | | Pulse Cannon equipment phases | `0x102c6`–`0x102c7` | 2 / 2 |
| Forward Laser equipment phases | `0x102c8`–`0x102cb` | 4 / 4 |
| Missile Pods equipment | `0x102be` | 1 / 1 | | Missile Pods equipment | `0x102be` | 1 / 1 |
| Mega Bomb equipment | `0x102c5` | 1 / 1 | | Mega Bomb equipment | `0x102c5` | 1 / 1 |
| Super Shield pickup | `0x102d0` | 1 / 1 | | Super Shield pickup | `0x102d0` | 1 / 1 |
@@ -48,6 +60,8 @@ The local base-game pack currently supplies **215 reviewed replacement frames**.
| Ground Missile 2 projectile | `0x101bf`–`0x101c0` | 2 / 2 | | Ground Missile 2 projectile | `0x101bf`–`0x101c0` | 2 / 2 |
| Forward Guns / MiniGun firing patterns | `0x101c3`–`0x101c6` | 4 / 4 | | Forward Guns / MiniGun firing patterns | `0x101c3`–`0x101c6` | 4 / 4 |
| Mine pulse | `0x101d1`–`0x101d2` | 2 / 2 | | Mine pulse | `0x101d1`–`0x101d2` | 2 / 2 |
| Pulse Cannon projectile phases | `0x10223`–`0x10224` | 2 / 2 |
| Enemy coconut phases | `0x1024d`–`0x10250` | 4 / 4 |
| Energy ring | `0x101ae`–`0x101b9` | 12 / 12 | | Energy ring | `0x101ae`–`0x101b9` | 12 / 12 |
| Blue sparks | `0x1019c`–`0x101a4` | 9 / 9 | | Blue sparks | `0x1019c`–`0x101a4` | 9 / 9 |
| Death-ray hits | `0x101f1`–`0x101f4` | 4 / 4 | | Death-ray hits | `0x101f1`–`0x101f4` | 4 / 4 |
@@ -55,9 +69,13 @@ The local base-game pack currently supplies **215 reviewed replacement frames**.
| Platoon fragments | `0x10213`–`0x1021c` | 10 / 10 | | Platoon fragments | `0x10213`–`0x1021c` | 10 / 10 |
| Air blast | `0x102a0`–`0x102af` | 16 / 16 | | Air blast | `0x102a0`–`0x102af` | 16 / 16 |
| Pickup glow light masks | `0x10225`–`0x10228` | 4 / 4 | | Pickup glow light masks | `0x10225`–`0x10228` | 4 / 4 |
| Muzzle flash and casing phases | `0x101c7`–`0x101ca` | 4 / 4 |
| Small air explosion | `0x101fc`–`0x10209` | 14 / 14 |
| Pilot portraits, small and retained large variants | `0x10111`–`0x10119` | 9 / 9 | | Pilot portraits, small and retained large variants | `0x10111`–`0x10119` | 9 / 9 |
| Original English and Chinese title logos | `0x10013`, `0x10014` | 2 / 2 | | Original English and Chinese title logos | `0x10013`, `0x10014` | 2 / 2 |
| Canonical terrain set 1, first shard | `0x10360`–`0x1036f` | 16 / 16 | | Canonical terrain set 1, first shard | `0x10360`–`0x1036f` | 16 / 16 |
| Canonical terrain set 2, corrected first shard | `0x2010d`–`0x2011c` | 16 / 16 |
| Canonical industrial terrain, first two shards | `0x300ed`–`0x3010c` | 32 / 32 |
Every registered frame passed the exact original source hash, logical canvas, aspect, visible alpha and path checks. Complete contact sheets were inspected at source size and the 4× presentation size; per-frame geometry metrics and review evidence remain with each family. Their logical collision sizes are unchanged. Small contours and interpreted metal detail differ. Missing artwork keeps the crisp original fallback. HD remains an opt-in preview while the remaining catalog and game screenshots are reviewed. Every registered frame passed the exact original source hash, logical canvas, aspect, visible alpha and path checks. Complete contact sheets were inspected at source size and the 4× presentation size; per-frame geometry metrics and review evidence remain with each family. Their logical collision sizes are unchanged. Small contours and interpreted metal detail differ. Missing artwork keeps the crisp original fallback. HD remains an opt-in preview while the remaining catalog and game screenshots are reviewed.
@@ -77,13 +95,13 @@ The additional fifteen-frame bunker preserves its door apertures, gun and blink
The two additional ground targets use subdued brown soil corrections; their machinery and original directions remain intact. The tiny gun sprites keep all four original gray/white patterns, and the mine keeps its pink then dark burgundy center with gold corners. These families have complete gallery review; it does not establish a placed gun or mine scene occurrence. The two additional ground targets use subdued brown soil corrections; their machinery and original directions remain intact. The tiny gun sprites keep all four original gray/white patterns, and the mine keeps its pink then dark burgundy center with gold corners. These families have complete gallery review; it does not establish a placed gun or mine scene occurrence.
The first sixteen canonical terrain replacements passed independent review of every source/logical/4× frame, all 48 supplied mission contexts and all 755 declared cardinal neighbor pairs: 170 HD/HD and 585 HD/original. Actual native scene checks exercised all 32 cached scroll phases and content-hash/load-order invalidation. None of these sixteen IDs has a declared destroyed partner; the separate native destruction-cache proof therefore adds no HD debris coverage. Mixed detail contrast remains until neighboring tiles are remastered. The next terrain family has a corrected treeless `0x20111` and passed independent source and complete neighborhood review. It and the first thirty-two industrial terrain candidates remain held for recurring grain lines at some composed tile boundaries; they add no active coverage yet. The first sixteen canonical terrain replacements passed independent review of every source/logical/4× frame, all 48 supplied mission contexts and all 755 declared cardinal neighbor pairs: 170 HD/HD and 585 HD/original. Actual native scene checks exercised all 32 cached scroll phases and content-hash/load-order invalidation. None of these sixteen IDs has a declared destroyed partner; the separate native destruction-cache proof therefore adds no HD debris coverage. The additional corrected terrain family preserves the treeless `0x20111`, and the thirty-two industrial tiles preserve source roof, wall and pipe geometry. Their final compositor review covers all 66 full pages and 1,706 cases: 1,379 neighbor pairs, 234 contexts and 93 state swaps. A texture-preserving edge/corner correction removes the earlier repeating grain ridges without moving source material boundaries. These forty-eight frames are now registered. Mixed HD/original detail contrast remains visible, and foreign destroyed partners still use original fallback; their appearances in state tests do not add HD debris coverage.
Four small pilot portraits (`0x10116`–`0x10119`) passed actual SDL main-menu SD/HD captures. Their faces, equipment and slanted ID-card backgrounds stay recognizable, and every surrounding UI pixel was identical. The five large portraits and both title logos have complete gallery review but no verified active UI route. The Chinese logo legitimately uses 478 original fire-class pixels; its HD output preserves that class and adds no water or blink labels. Four small pilot portraits (`0x10116`–`0x10119`) passed actual SDL main-menu SD/HD captures in the new 400×225 layout at logical position `(30,45)`. All eight captures and the comparison board were reviewed; their faces, equipment and slanted ID-card backgrounds stay recognizable, and every surrounding UI pixel was identical. The five large portraits and both title logos have complete gallery review but no verified active UI route. The Chinese logo legitimately uses 478 original fire-class pixels; its HD output preserves that class and adds no water or blink labels.
The newly reviewed effect families retain their complete phase ordering. Lightning branches are thinner and slightly cooler blue; the late AirBlast rim is thinner; PickupGlow uses a rounded interpreted contour rather than identical alpha. Actual `Canvas::shade` checks over original terrain confirm that pickup-mask holes preserve the background and mask RGB never paints foreground pixels. Other effects remain held where generation filled transparent holes, removed final fragments or made subdued smoke/embers too bright. A successfully decoded draft does not establish artistic approval. The newly reviewed effect families retain their complete phase ordering. Lightning branches are thinner and slightly cooler blue; the late AirBlast rim is thinner; PickupGlow uses a rounded interpreted contour rather than identical alpha. Actual `Canvas::shade` checks over original terrain confirm that pickup-mask holes preserve the background and mask RGB never paints foreground pixels. Other effects remain held where generation filled transparent holes, removed final fragments or made subdued smoke/embers too bright. A successfully decoded draft does not establish artistic approval.
Original pixels and original-derived base-game artwork remain outside Git under `../data/hd/`. The separate authored mod packages include their generated SD/HD PNGs, sounds and provenance in Git. The new authored menu backdrop has its own packaged rendering path and is not counted as an original catalog sprite replacement. Working originals, source references and generation/review drafts under `../data/` remain outside the repository. The bundled original catalog and approved HD runtime snapshot under this repository's `data/` are tracked in Git, as are the bundled game, libraries and runtime manifest. Authored mod packages include their generated SD/HD PNGs, sounds and provenance in Git. The new authored menu backdrop has its own packaged rendering path and is not counted as an original catalog sprite replacement.
The sources were decoded with `tools/hd_art_source.py`, inspected, and passed to the **built-in image_gen tool** using the imagegen skill. Sources, raw generations, prompts, contact sheets, frame metadata and review notes remain in local family folders (`enemies/`, `bosses/`, `effects/`, `equipment/`, `player-light/`, `player-dark/`, and `terrain/`). The superseded initial straight-player proof and its exact generation prompt remain saved locally at: The sources were decoded with `tools/hd_art_source.py`, inspected, and passed to the **built-in image_gen tool** using the imagegen skill. Sources, raw generations, prompts, contact sheets, frame metadata and review notes remain in local family folders (`enemies/`, `bosses/`, `effects/`, `equipment/`, `player-light/`, `player-dark/`, and `terrain/`). The superseded initial straight-player proof and its exact generation prompt remain saved locally at:
@@ -96,7 +114,7 @@ That proof PNG has real alpha transparency, is 1254×1254, and was copied unchan
## Pack format ## Pack format
The following is metadata only; artwork belongs in a separately distributed local pack. This example shows one entry from the complete seven-frame light family. Decimal IDs are shown alongside their hexadecimal meaning. The following shows pack metadata; artwork paths resolve within the selected pack. The bundled approved pack and its artwork are tracked in `data/hd/`. This example shows one entry from the complete seven-frame light family. Decimal IDs are shown alongside their hexadecimal meaning.
```json ```json
{ {
@@ -136,7 +154,7 @@ Original source locations also constrain palette classes: ordinary art uses indi
HD frames are resampled directly to the selected presentation canvas and quantized to the game palette. They bypass ScaleNx filtering, preserve transparent pixels and use the normal layer order. Indexed output allows palette effects to apply consistently; subtle RGB shades and alpha edges are reduced by the original palette and binary sprite transparency. The validated [terrain renderer](hd-terrain.md) overlays canonical, full-opaque HD tiles on the joined original layer. It shares HD edges and corners where source material classes agree, transitions HD/original boundaries over two logical pixels, and preserves water/fire/flash palette classes. Intact and destroyed IDs are independent targets. The renderer adds no remaster coverage by itself; each terrain family needs the same contextual review before registration. HD frames are resampled directly to the selected presentation canvas and quantized to the game palette. They bypass ScaleNx filtering, preserve transparent pixels and use the normal layer order. Indexed output allows palette effects to apply consistently; subtle RGB shades and alpha edges are reduced by the original palette and binary sprite transparency. The validated [terrain renderer](hd-terrain.md) overlays canonical, full-opaque HD tiles on the joined original layer. It shares HD edges and corners where source material classes agree, transitions HD/original boundaries over two logical pixels, and preserves water/fire/flash palette classes. Intact and destroyed IDs are independent targets. The renderer adds no remaster coverage by itself; each terrain family needs the same contextual review before registration.
The metadata-only [completion ledger](hd-completion.json) tracks every one of the 5,753 original picture IDs, including unused enemies, damaged variants, HUD graphics and unnamed terrain. It separates missing, in-progress, candidate, reviewed, registered and render-reachable stages. `tools/hd_job_queue.py` creates 674 disjoint picture jobs, plus 13 classic cinematic and four bitmap-font verification jobs. Original AGX artwork and sharp native glyphs have explicit preservation policies; they are never reported as AI remasters. The ignored `data/hd/jobs/queues/` files assign continuous enemy, boss, equipment/UI, effect/projectile and terrain work with exact source hashes, complete animation families and neighbor/state references. Exact-payload aliases may share reviewed artwork while retaining each ID's source binding. The metadata-only [completion ledger](hd-completion.json) tracks every one of the 5,753 original picture IDs, including unused enemies, damaged variants, HUD graphics and unnamed terrain. It separates missing, in-progress, candidate, reviewed, registered and render-reachable stages. `tools/hd_job_queue.py` creates 674 disjoint picture jobs, plus 13 classic cinematic and four bitmap-font verification jobs. Original AGX artwork and sharp native glyphs have explicit preservation policies; they are never reported as AI remasters. The external `../data/hd/jobs/queues/` files assign continuous enemy, boss, equipment/UI, effect/projectile and terrain work with exact source hashes, complete animation families and neighbor/state references. Exact-payload aliases may share reviewed artwork while retaining each ID's source binding.
Workers can validate an isolated proposal with `python3 tools/hd_candidate_review.py --candidate ../data/hd/jobs/JOB_ID/candidate.json`. The helper hard-links raw artwork into a temporary pack, links against the newest built Rust game library, and exports all scales 1–4 plus source/logical/4× contact sheets. It records raw artwork hashes and the library identity, warns when asset-loader source is newer than the build, and pages large reviews in groups of sixteen frames to avoid an unbounded giant image. Every page listed in `runtime-contact-sheets.json` requires inspection. It never edits the active pack or generated pixels. A successful gallery still needs coordinator visual approval and separate scene/context evidence. Workers can validate an isolated proposal with `python3 tools/hd_candidate_review.py --candidate ../data/hd/jobs/JOB_ID/candidate.json`. The helper hard-links raw artwork into a temporary pack, links against the newest built Rust game library, and exports all scales 1–4 plus source/logical/4× contact sheets. It records raw artwork hashes and the library identity, warns when asset-loader source is newer than the build, and pages large reviews in groups of sixteen frames to avoid an unbounded giant image. Every page listed in `runtime-contact-sheets.json` requires inspection. It never edits the active pack or generated pixels. A successful gallery still needs coordinator visual approval and separate scene/context evidence.
+4 -4
View File
@@ -47,20 +47,20 @@ Run the portable decoder, timing and cue checks from the repository root:
```sh ```sh
LD_LIBRARY_PATH="$HOME/.local/lib" cargo test --locked \ LD_LIBRARY_PATH="$HOME/.local/lib" cargo test --locked \
--manifest-path RustRaptor/Cargo.toml --lib cinematics::tests --lib cinematics::tests
``` ```
Run the original catalog integration checks locally: Run the original catalog integration checks locally:
```sh ```sh
LD_LIBRARY_PATH="$HOME/.local/lib" RAPTOR_TEST_DATA="$PWD/data" \ LD_LIBRARY_PATH="$HOME/.local/lib" RAPTOR_TEST_DATA="$PWD/data" \
cargo test --locked --manifest-path RustRaptor/Cargo.toml \ cargo test --locked \
--lib cinematics::tests -- --ignored --lib cinematics::tests -- --ignored
``` ```
The checks cover both card palettes, complete logo and introduction durations, The checks cover both card palettes, complete logo and introduction durations,
music and sound cue times, all original cinematic delta decoding, all sequence music and sound cue times, all original cinematic delta decoding, all sequence
rates and rendering-rate independence. Original assets and screenshot captures rates and rendering-rate independence. Original cinematic assets are included in tracked `data/`; local screenshot
remain outside version control. The retained native source supplies the frame captures remain outside the repository. The retained native source supplies the frame
rates and cue placement; these checks are not a frame-by-frame native video or rates and cue placement; these checks are not a frame-by-frame native video or
whole-sequence PCM comparison. whole-sequence PCM comparison.
+2 -2
View File
@@ -122,8 +122,8 @@ Original terrain asset `0x10360` and military sprite `0x102f6` were decoded and
inspected before generating the new art with the built-in imagegen skill. inspected before generating the new art with the built-in imagegen skill.
Mission, enemy and weapon packages include generated masters, packaged Mission, enemy and weapon packages include generated masters, packaged
SD/HD art, WAV files and prompt/provenance metadata in Git. The assembly owns SD/HD art, WAV files and prompt/provenance metadata in Git. The assembly owns
its SD/HD campaign portrait; the soundtrack package owns its MUS composition. Original style-reference images and original its SD/HD campaign portrait; the soundtrack package owns its MUS composition. Original style-reference images remain external; the packaged original
game data remain outside Git. Mission sources are a generated shoreline texture, game data is tracked in Git. Mission sources are a generated shoreline texture,
harbor briefing backdrop and transparent supply depot. The source images harbor briefing backdrop and transparent supply depot. The source images
remain unchanged; the build step normalizes logical footprints and derives remain unchanged; the build step normalizes logical footprints and derives
palette SD versions and HD counterparts. palette SD versions and HD counterparts.
+114
View File
@@ -0,0 +1,114 @@
# AI agent checklist for building a new mod
Use this checklist for new weapon, equipment, enemy, mission, campaign and music
packages. The user's current request takes precedence. Treat the checked-in
[mod API](modding-api.md), content types and shipped examples as the contract;
do not invent unsupported fields or callbacks.
## 1. Define the behavior
- [ ] Record the requested content, controls, target types and intended role.
- [ ] For equipment, decide whether it is always equipped and fires with Fire,
a selected special, a consumable, or a passive item. Always equipped does not
imply firing without input. State the controls clearly in the shop description.
- [ ] Specify range, cooldown, damage, target limits and interactions with other
equipment. Account for the 320×200 logical canvas and 70/3 simulation ticks
per second; HD artwork must not change gameplay geometry.
- [ ] Check existing API support. Use callbacks and checked commands for mod
behavior. If something is missing, describe a minimal reusable API extension
and coordinate its implementation with the engine agent when authorized.
## 2. Build an independent package
- [ ] Create `mods/<id>/mod.toml`, `content.json`, `scripts/`, `art/` and any
`audio/` or `music/` directories needed by the content.
- [ ] Use a unique lowercase package ID, version, `api_version = 1`, clear
name and description. Declare all script files in `manifest.scripts`.
- [ ] Namespace new definitions with the package ID. Keep inventory item and
weapon IDs distinct; keep IDs stable so saved inventory survives updates.
- [ ] Declare only real dependencies. Verify standalone packages can be enabled
alone. Make complete, explicit overrides only when intentionally required.
- [ ] Omit `legacy` on new content. Use relative paths without traversal or
symlinks, and stay inside the documented package and content limits.
- [ ] Keep equipment capacities and consumable flags consistent with its role.
Define pickup and shop behavior consistently; reusable gear must remain owned.
## 3. Implement scripts safely
- [ ] Use only documented event snapshots, callbacks and commands. Filter dead,
invisible or wrong-type targets and enforce the intended range and target cap.
- [ ] Use deterministic target selection and tie breaks. Avoid duplicate hits,
accidental repeated chaining, screen-wide damage and unnecessary commands.
- [ ] Keep callback work and state bounded. Use the supplied deterministic RNG
for gameplay randomness; do not depend on render rate, wall time or OS state.
- [ ] Handle initially empty state and older saved state. Verify scripts compile,
callbacks have three arguments and command failures roll back cleanly.
- [ ] Choose visual commands appropriate to the effect. Do not use explosion
effects for ordinary muzzle flashes or electrical arcs.
## 4. Ship SD, HD and audio assets
- [ ] Supply a real SD `png` and HD `hd_png` for every authored artwork reference.
Use the same logical dimensions, aspect ratio, frame count and alignment.
- [ ] Preserve actual alpha transparency and padding. Keep sprites legible at
their small SD size, with clear silhouettes and stable frame positions.
- [ ] Inspect both modes after palette conversion in the game renderer. Check
animation strips, shop icons, firing/hit feedback and moving scenes as needed.
- [ ] Ship valid local WAV or DMX effects and MUS scores where applicable.
Keep volumes and repetition comfortable and avoid playing hit sounds on misses.
- [ ] Track final assets, required source masters, prompts, provenance and
reproducible preparation/synthesis recipes with the package. Never commit
original game pixels, the extracted catalog or local reference captures.
## 5. Price and balance equipment
- [ ] Compare original and shipped gear in [weapon balance](weapon-balance.md).
Account for aiming difficulty, effective range, air/ground coverage, splash
or chain caps, recharge, consumption and damage per second.
- [ ] Measure damage through `World::step` with the real scripts over a fixed
duration. Use surviving stationary fixtures to avoid overkill skewing results.
- [ ] Include aligned, off-axis, ground and cluster cases where relevant.
Add a close-range scene for proximity gear, and test a combined loadout for
always-equipped weapons. Keep pilot health and fixture movement controlled.
- [ ] Choose a progression tier and justify the final price. Check purchase,
capacity, duplicate rejection and resale; purchases must not create money.
- [ ] Distinguish controlled measurements from campaign balance. Document
relevant tradeoffs and unresolved playtesting questions without claiming that
every loadout or mission is equally difficult.
## 6. Verify and document
- [ ] Add meaningful portable tests with synthetic images/audio when practical.
Test actual damage, target exclusions, range boundaries, cooldown, finite
target caps, inventory preservation and coexistence with shipped packages.
- [ ] Check deterministic continuation of script state/RNG. Exercise kills and
empty-target situations, not only the happy path with surviving targets.
- [ ] Run the package validation CLI against the original local catalog:
`cargo run --no-default-features -- --mods mods --validate-mods <id>`.
Also validate relevant combined packages. Do not alter the user's active mods.
- [ ] Run focused tests, then required regression checks. Regenerate schemas if
an API type changed, and build the desktop engine if rendering or controls changed.
- [ ] Add a package README with enabling instructions, price, controls, target
behavior, limits, validation commands and a link to artwork provenance.
- [ ] Update repository documentation and balance observations where relevant.
Inspect the final diff and preserve unrelated work.
- [ ] Report what was delivered, how to enable/use it, final prices and validation
results. State any material limitation or unperformed check accurately.
## Local commands
From `RustRaptor/`, replace IDs and test names for the package being authored:
```sh
cargo test --locked --no-default-features --test weapons_mods
cargo run --locked --no-default-features -- --mods mods \
--validate-mods lightning_cannon,lightning_shield
RAPTOR_BALANCE_REPORT="$PWD/.reference/weapon-balance.json" \
cargo test --locked --no-default-features --test weapon_balance \
-- --ignored --nocapture
cargo fmt --all -- --check
```
Use the appropriate test for enemies, missions or music. An unavailable original
catalog is a reason to report the local validation limit, not to bypass portable
tests or deliver broken package references.
+13 -6
View File
@@ -9,13 +9,13 @@ This is an experimental API for the Rust engine. It provides local content packa
Validate the package against your original game data: Validate the package against your original game data:
```sh ```sh
cargo run --no-default-features -- --data ../data --mods mods --validate-mods aurora cargo run --no-default-features -- --data data --mods mods --validate-mods aurora
``` ```
Run its mission without a window: Run its mission without a window:
```sh ```sh
cargo run --no-default-features -- --data ../data --mods mods \ cargo run --no-default-features -- --data data --mods mods \
--enabled aurora --headless aurora:field_trials --ticks 700 --enabled aurora --headless aurora:field_trials --ticks 700
``` ```
@@ -88,7 +88,7 @@ New definitions must belong to the package namespace. An existing definition may
| Definition | Properties and behavior | | Definition | Properties and behavior |
| --- | --- | | --- | --- |
| Weapon/projectile | Damage, integer speed, cooldown in simulation ticks, targeting (`air`, `ground`, `all`), behavior (`straight`, `homing`, `beam`, `spread`, `bomb`, `energy_grab`), firing offsets, optional animation and script. Scripts can emit additional projectiles with explicit velocity. | | Weapon/projectile | Damage, integer speed, cooldown in simulation ticks, targeting (`air`, `ground`, `all`), behavior (`straight`, `homing`, `beam`, `spread`, `bomb`, `energy_grab`, `scripted`), firing offsets, optional animation and script. Scripts can emit additional projectiles with explicit velocity. `scripted` requires a script and emits no automatic projectile. |
| Item | Price, maximum and initial count, optional weapon, shield/credit effects, consumable/special flags, artwork and script. `initial_count` controls how many units a purchase or pickup adds. | | Item | Price, maximum and initial count, optional weapon, shield/credit effects, consumable/special flags, artwork and script. `initial_count` controls how many units a purchase or pickup adds. |
| Enemy | Health, speed, rewards, air/ground and boss flags, firing cooldown, path points and repeat flag, weapon, dropped item, animation and script. Scripts can query entities and change enemy movement. | | Enemy | Health, speed, rewards, air/ground and boss flags, firing cooldown, path points and repeat flag, weapon, dropped item, animation and script. Scripts can query entities and change enemy movement. |
| Mission | Duration, `auto_complete` (default true), optional briefing artwork, ordered tick-based spawns, terrain tile definitions and row-major layout, column count, next mission, optional legacy music asset or namespaced `music_track`, and script. Scripts can manage objectives, spawn waves and decide success/failure. | | Mission | Duration, `auto_complete` (default true), optional briefing artwork, ordered tick-based spawns, terrain tile definitions and row-major layout, column count, next mission, optional legacy music asset or namespaced `music_track`, and script. Scripts can manage objectives, spawn waves and decide success/failure. |
@@ -124,7 +124,7 @@ An `art` object requires exactly one of `asset` (an original numeric catalog ID)
PNG animations are horizontal strips of equally sized frames. For example, four 64×64 frames use a 256×64 PNG and may declare logical dimensions of 16×16. The strip width must divide evenly by `frames`; supply consistent canvases and alignment across frames. Alpha below 128 is transparent. Opaque colors are mapped to ordinary entries 0–239 of the selected game palette. Plain PNGs carry no palette-animation labels, so their highlights cannot acquire reserved water, fire or blinking indices 240–255. Artwork already detailed enough for the selected rendering scale bypasses automatic sprite enlargement. Original asset animations use consecutive catalog IDs and retain their native palette labels. Terrain artwork and destroyed terrain replacements follow the same format. PNG animations are horizontal strips of equally sized frames. For example, four 64×64 frames use a 256×64 PNG and may declare logical dimensions of 16×16. The strip width must divide evenly by `frames`; supply consistent canvases and alignment across frames. Alpha below 128 is transparent. Opaque colors are mapped to ordinary entries 0–239 of the selected game palette. Plain PNGs carry no palette-animation labels, so their highlights cannot acquire reserved water, fire or blinking indices 240–255. Artwork already detailed enough for the selected rendering scale bypasses automatic sprite enlargement. Original asset animations use consecutive catalog IDs and retain their native palette labels. Terrain artwork and destroyed terrain replacements follow the same format.
Artwork dimensions and decoding allocation are bounded. PNGs may be up to 4096 pixels in either direction, with at most 4,194,304 pixels and a 64 MiB decoding allocation limit. Logical dimensions are separately validated and stay independent of physical PNG resolution. The extracted original catalog and local base-game remaster proofs stay outside version control; authored mod artwork and sounds are included in Git. Artwork dimensions and decoding allocation are bounded. PNGs may be up to 4096 pixels in either direction, with at most 4,194,304 pixels and a 64 MiB decoding allocation limit. Logical dimensions are separately validated and stay independent of physical PNG resolution. The packaged original catalog, approved runtime remasters and authored mod artwork/sounds are included in Git; working remaster proofs and source references remain external.
## Rhai callbacks ## Rhai callbacks
@@ -151,7 +151,7 @@ fn on_fire(event, state, random) {
| --- | --- | | --- | --- |
| `on_spawn` | Enemy: `entity`, `x`, `y`. | | `on_spawn` | Enemy: `entity`, `x`, `y`. |
| `on_tick` | Enemy: `entity`, `x`, `y`, `health`, `tick`, `player_x`, `player_y`. Mission: `tick`, `player_x`, `player_y`. | | `on_tick` | Enemy: `entity`, `x`, `y`, `health`, `tick`, `player_x`, `player_y`. Mission: `tick`, `player_x`, `player_y`. |
| `on_fire` | Weapon or firing enemy: `entity`, `x`, `y`, `hostile`, `tick`; enemy callbacks also include `weapon`. Player entity is zero. The normal shot has already been emitted. Enemy callbacks run for each emitted gun shot. | | `on_fire` | Weapon or firing enemy: `entity`, `x`, `y`, `hostile`, `tick`; enemy callbacks also include `weapon`. Player entity is zero. The normal shot has already been emitted, except for `scripted` weapons, whose script supplies all attacks. Enemy callbacks run for each gun discharge. |
| `on_hit` | Weapon/projectile and damaged enemy: `entity`, `projectile`, `damage`, `x`, `y`; enemy events also include `weapon` and remaining `health`. Scripted enemy damage uses `scripted = true` and omits projectile-specific fields. | | `on_hit` | Weapon/projectile and damaged enemy: `entity`, `projectile`, `damage`, `x`, `y`; enemy events also include `weapon` and remaining `health`. Scripted enemy damage uses `scripted = true` and omits projectile-specific fields. |
| `on_death` | Enemy: `entity`, `x`, `y`. The entity is retired; use queries to act on surviving entities. | | `on_death` | Enemy: `entity`, `x`, `y`. The entity is retired; use queries to act on surviving entities. |
| `on_pickup` | Item: pickup `entity`. The item and its standard effects have already been awarded. | | `on_pickup` | Item: pickup `entity`. The item and its standard effects have already been awarded. |
@@ -159,7 +159,9 @@ fn on_fire(event, state, random) {
| `on_mission_start` | Mission: `tick = 0`. Runs when the first simulation step dispatches events. | | `on_mission_start` | Mission: `tick = 0`. Runs when the first simulation step dispatches events. |
| `on_mission_end` | Mission: `success`, `tick`. | | `on_mission_end` | Mission: `success`, `tick`. |
Every dispatched event also includes `tick`, `player`, `entities`, `projectiles`, `pickups` and `objectives`. `player` is a snapshot including position, velocity, shields, credits, inventory and selected equipment. `entities` is the current enemy array; each entry includes `entity`, `definition`, position, health, animation and movement state. Projectiles and pickups likewise carry stable entity IDs. These are read-only snapshots: return commands to change the simulation. Later callbacks in a tick can observe commands committed by earlier callbacks. Events raised by commands, such as a spawned enemy, are dispatched on the following tick. Every dispatched event also includes `tick`, `player`, `entities`, `projectiles`, `pickups` and `objectives`. `player` is a snapshot including position, velocity, shields, credits, inventory and selected equipment. `entities` is the current enemy array; each entry includes `entity`, `definition`, position, `width`, `height`, health, animation, movement state and the definition's `ground` boolean. Filter on `ground` when an attack affects only airborne or ground targets. Projectiles and pickups likewise carry stable entity IDs. These are read-only snapshots: return commands to change the simulation. Later callbacks in a tick can observe commands committed by earlier callbacks. Events raised by commands, such as a spawned enemy, are dispatched in subsequent batches of the same tick, within the 8,192-event limit. Rejected command batches report the mod, script path and callback, and roll back that simulation step and script state.
Use `behavior: "scripted"` for hitscan, chain lightning, proximity zaps or other attacks implemented entirely with commands. Player selection, firing input and weapon cooldowns still apply. An owned item with `special: false` fires its weapon alongside other ordinary equipment when Fire is held; `special: true` makes it selectable. Neither player nor enemy scripted weapons receive a default engine projectile. A script may explicitly emit `projectile`, or pair `damage` with `arc` for an immediate visible discharge. `target` describes the weapon's capability for the shop and governs explicit projectiles; scripts must enforce the intended targets and range for their own `damage` commands.
`state` is JSON-serializable and shared by all callbacks in the same script file. It starts as an empty map, is keyed by `mod_id/script_path`, and is saved with the pilot. Avoid values dependent on wall time, filesystem access or rendering rate. `random` contains 32 deterministic nonnegative integers in the range 0–32,767. Each successful callback advances that script's saved RNG by exactly 32 values, whether or not the script reads them. Missing callbacks do not advance state or RNG. `state` is JSON-serializable and shared by all callbacks in the same script file. It starts as an empty map, is keyed by `mod_id/script_path`, and is saved with the pilot. Avoid values dependent on wall time, filesystem access or rendering rate. `random` contains 32 deterministic nonnegative integers in the range 0–32,767. Each successful callback advances that script's saved RNG by exactly 32 values, whether or not the script reads them. Missing callbacks do not advance state or RNG.
@@ -191,12 +193,17 @@ Use the `op` field shown below. All fields listed are required unless stated oth
| `credits` | signed `amount` | Adjust credits with saturation at valid limits. | | `credits` | signed `amount` | Adjust credits with saturation at valid limits. |
| `shield` | signed `amount` | Adjust ordinary shield, clamped to 0–100. | | `shield` | signed `amount` | Adjust ordinary shield, clamped to 0–100. |
| `effect` | `x`, `y` | Add an explosion effect. | | `effect` | `x`, `y` | Add an explosion effect. |
| `arc` | `x`, `y`, `to_x`, `to_y`, optional `ticks` (3), `anchor`, `to_anchor` | Draw cosmetic branching lightning with a hot white core, blue glow and flicker between two positions. Optional anchors are player (`0`) or existing enemy entity IDs. Endpoints must be within ±32,768; lifetime must be 1–12 simulation ticks; at most 256 arcs may be active. Pair with `damage` to attack. |
| `sound` | numeric `asset` | Queue an original digital sound asset for playback. | | `sound` | numeric `asset` | Queue an original digital sound asset for playback. |
| `play_sound` | namespaced `sound` ID | Queue a validated package-local sound. | | `play_sound` | namespaced `sound` ID | Queue a validated package-local sound. |
| `objective` | `key`, integer `value` | Set an objective counter. Use a namespaced key. | | `objective` | `key`, integer `value` | Set an objective counter. Use a namespaced key. |
| `complete` | none | Complete the mission. | | `complete` | none | Complete the mission. |
| `fail` | none | Fail the mission. | | `fail` | none | Fail the mission. |
Lightning arcs render in both SD and HD using irregular fractal channels, thin tapered forks and glow that brightens the scene. Geometry and brightness change with simulation age; each discharge receives a cosmetic seed derived from its creation tick and queue position so repeated shots vary. Drawing the same simulation state again gives the same image, and pausing freezes the effect. Arcs persist until their simulation lifetime expires. Forks are visual only and do not hit additional targets. Arcs use a separate cosmetic pool and consume no gameplay RNG, entity IDs or projectile capacity. The pool is excluded from saves and the gameplay digest. Invalid endpoints, lifetime or capacity reject the complete command batch and roll back the tick like other command failures.
`anchor` attaches the starting endpoint, and `to_anchor` independently attaches the destination. Supply absolute world coordinates at creation; each endpoint preserves its offset from the anchor's top-left `x/y` origin as the ship moves. Omitting an anchor keeps that endpoint fixed. Both anchors may be `0` to make a ring segment follow the player, or use `anchor: 0` and `to_anchor: enemy.entity` for a ship-to-ship bolt. Anchor IDs must exist when the command runs; a missing anchor rejects the batch. Endpoints update after simulation movement and API movement commands, and freeze at their last position when the anchored enemy disappears. Cosmetic coordinates clamp to ±32,768 when an attached ship moves beyond those bounds.
Rhai uses an explicit package allowlist for arithmetic, strings, collections, iteration and function helpers. The default language/time packages are excluded, so wall-clock timestamps and native `sleep` cannot be reached, including through function pointers. There are no exposed filesystem, network or OS interfaces. Dynamic `eval` and imports are disabled. Limits are 100,000 operations per callback and a shared 500,000-operation budget per simulation tick; 32 call levels; expression depths of 64/32; arrays of 4,096 entries; maps of 1,024 entries; strings of 16 KiB; 256 commands per callback; and 64 KiB of serialized state per script. See the [Rhai execution-limit API](https://docs.rs/rhai/latest/rhai/struct.Engine.html) for the underlying engine controls. Scripts cannot increase these limits. Rhai uses an explicit package allowlist for arithmetic, strings, collections, iteration and function helpers. The default language/time packages are excluded, so wall-clock timestamps and native `sleep` cannot be reached, including through function pointers. There are no exposed filesystem, network or OS interfaces. Dynamic `eval` and imports are disabled. Limits are 100,000 operations per callback and a shared 500,000-operation budget per simulation tick; 32 call levels; expression depths of 64/32; arrays of 4,096 entries; maps of 1,024 entries; strings of 16 KiB; 256 commands per callback; and 64 KiB of serialized state per script. See the [Rhai execution-limit API](https://docs.rs/rhai/latest/rhai/struct.Engine.html) for the underlying engine controls. Scripts cannot increase these limits.
## Saves and compatibility ## Saves and compatibility
+8 -8
View File
@@ -29,8 +29,8 @@ For development and tests:
export LD_LIBRARY_PATH="$HOME/.local/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" export LD_LIBRARY_PATH="$HOME/.local/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
cargo test --locked cargo test --locked
cargo test --locked --no-default-features cargo test --locked --no-default-features
RAPTOR_TEST_DATA="$PWD/../data" cargo test --locked RAPTOR_TEST_DATA="$PWD/data" cargo test --locked
RAPTOR_TEST_DATA="$PWD/../data" cargo test --locked -- --ignored RAPTOR_TEST_DATA="$PWD/data" cargo test --locked -- --ignored
``` ```
Portable fixtures use synthetic assets and the bundled authored mod art/audio. Original integration checks are opt-in and local. Native audio comparisons compile a small retained-source harness with g++ and SDL2 headers; pure Rust playback itself uses no C++ runtime. Portable fixtures use synthetic assets and the bundled authored mod art/audio. Original integration checks are opt-in and local. Native audio comparisons compile a small retained-source harness with g++ and SDL2 headers; pure Rust playback itself uses no C++ runtime.
@@ -62,12 +62,12 @@ migrate on load; custom combinations are preserved.
## Local asset and platform checks ## Local asset and platform checks
```sh ```sh
cargo run --locked --no-default-features -- --data ../data --verify-assets cargo run --locked --no-default-features -- --data data --verify-assets
cargo run --locked --no-default-features -- --data ../data --mods mods --validate-mods aurora cargo run --locked --no-default-features -- --data data --mods mods --validate-mods aurora
cargo run --locked --no-default-features -- --data ../data --mods mods \ cargo run --locked --no-default-features -- --data data --mods mods \
--enabled aurora --headless aurora:field_trials --ticks 700 --enabled aurora --headless aurora:field_trials --ticks 700
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy cargo run --locked -- \ SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy cargo run --locked -- \
--data ../data --profile-root ../.reference/smoke --smoke-frames 5 \ --data data --profile-root ../.reference/smoke --smoke-frames 5 \
--screenshot ../.reference/menu.png --screenshot ../.reference/menu.png
``` ```
@@ -85,7 +85,7 @@ cmake --build ../raptor/build --target raptor --parallel 8
RAPTOR_REFERENCE_TRACE="$PWD/../.reference/cpp-demo.jsonl" \ RAPTOR_REFERENCE_TRACE="$PWD/../.reference/cpp-demo.jsonl" \
RAPTOR_REFERENCE_TICKS=1889 \ RAPTOR_REFERENCE_TICKS=1889 \
../raptor/build/bin/raptor PLAY "$PWD/demos/DEMO1G1_REC") ../raptor/build/bin/raptor PLAY "$PWD/demos/DEMO1G1_REC")
cargo run --locked --no-default-features -- --data ../data \ cargo run --locked --no-default-features -- --data data \
--replay DEMO1G1_REC --ticks 1889 --trace ../.reference/rust-demo.jsonl --replay DEMO1G1_REC --ticks 1889 --trace ../.reference/rust-demo.jsonl
python3 tools/compare_replays.py ../.reference/cpp-demo.jsonl ../.reference/rust-demo.jsonl python3 tools/compare_replays.py ../.reference/cpp-demo.jsonl ../.reference/rust-demo.jsonl
``` ```
@@ -98,4 +98,4 @@ The Rust FM/DSP implementation has synthetic sample-by-sample native comparisons
The campaign checks cover all waves and difficulties with assistance, and first-mission tests win without replenishing pilot health or editing enemy state. Wider ordinary playtesting and more complete native state comparisons remain useful. Normal startup includes the original logos, fanfare and intro. Native timing, sound-marker resolution, scene stops and custom logo palettes are checked in [intro validation](intro-validation.md). The refined menu uses an authored hangar backdrop and has a [42-frame screenshot review](menu-validation.md). Base-game HD coverage is partial; bundled mods supply SD/HD art. See [display measurements](performance.md). The campaign checks cover all waves and difficulties with assistance, and first-mission tests win without replenishing pilot health or editing enemy state. Wider ordinary playtesting and more complete native state comparisons remain useful. Normal startup includes the original logos, fanfare and intro. Native timing, sound-marker resolution, scene stops and custom logo palettes are checked in [intro validation](intro-validation.md). The refined menu uses an authored hangar backdrop and has a [42-frame screenshot review](menu-validation.md). Base-game HD coverage is partial; bundled mods supply SD/HD art. See [display measurements](performance.md).
Scale2x/3x follows the [published algorithm](https://www.scale2x.it/algorithm); Scale4x applies Scale2x twice. WAV parsing follows the [RIFF chunk format](https://learn.microsoft.com/en-us/windows/win32/xaudio2/resource-interchange-file-format--riff-). Original catalog/reference assets remain outside version control. Authored mod artwork and sounds are tracked. Scale2x/3x follows the [published algorithm](https://www.scale2x.it/algorithm); Scale4x applies Scale2x twice. WAV parsing follows the [RIFF chunk format](https://learn.microsoft.com/en-us/windows/win32/xaudio2/resource-interchange-file-format--riff-). The playable catalog, approved runtime HD assets and authored mod artwork/sounds are tracked. Working source references and review captures remain outside the repository.
+57 -10
View File
@@ -1,6 +1,6 @@
# Shipped weapon prices and balance # Shipped weapon prices and balance
The three independent weapon mods use the original game's credit scale. Their The five independent weapon mods use the original game's credit scale. The first three
initial example prices of 6,500, 14,000 and 22,000 made reusable upgrades cheaper initial example prices of 6,500, 14,000 and 22,000 made reusable upgrades cheaper
than much weaker original equipment. The revised prices preserve their firing, than much weaker original equipment. The revised prices preserve their firing,
damage, scripts, sounds and artwork. damage, scripts, sounds and artwork.
@@ -10,17 +10,20 @@ damage, scripts, sounds and artwork.
| Pulse Cannon | 185,000 | Just above the 175,600-credit Micro Missile primary upgrade. It adds similar sustained damage and also adds a spread burst every third volley. | | Pulse Cannon | 185,000 | Just above the 175,600-credit Micro Missile primary upgrade. It adds similar sustained damage and also adds a spread burst every third volley. |
| Seeker Swarm | 285,000 | Above the 250,650-credit Mini Gun. Its three missiles track air and ground enemies and hits fragment into at most two nearby enemies. It remains below the 512,850-credit instant Turret. | | Seeker Swarm | 285,000 | Above the 250,650-credit Mini Gun. Its three missiles track air and ground enemies and hits fragment into at most two nearby enemies. It remains below the 512,850-credit instant Turret. |
| Ion Lance | 425,000 | Below the Turret and the 950,000-credit Death Ray. Its lower damage per target trades against immediate hits and the ability to pierce several enemies in one column. | | Ion Lance | 425,000 | Below the Turret and the 950,000-credit Death Ray. Its lower damage per target trades against immediate hits and the ability to pierce several enemies in one column. |
| Lightning Cannon | 825,000 | Above the original 725,000-credit Pulse Cannon and below the 950,000-credit Death Ray. Its strong immediate forward hit can chain to three nearby enemies, but it needs an initial target in its aiming lane and the chain has a strict 48-pixel reach. |
| Lightning Shield | 155,000 | A supplementary primary below the 175,600-credit Micro Missile. It adds only a little damage and reaches roughly 12 pixels beyond the pilot's hull; its value comes from covering close airborne threats in every direction while the other guns fire. |
Pulse Cannon is always equipped and fires alongside other primary guns. Seeker Pulse Cannon is always equipped and fires alongside other primary guns. Seeker
Swarm and Ion Lance occupy the selected special slot and fire with Fire or Swarm, Ion Lance and Lightning Cannon occupy the selected special slot and fire with Fire or
Special while selected. All three are reusable, consume no ammunition and have Special while selected. Lightning Shield is always equipped and pulses only while
Fire is held; it does not occupy the selected slot. All five are reusable, consume no ammunition and have
a maximum inventory count of one. Each equipment definition includes a shop a maximum inventory count of one. Each equipment definition includes a shop
description stating these controls and its target behavior. description stating these controls and its target behavior.
## Controlled damage observations ## Controlled damage observations
The local benchmark loads the original asset catalog and original registered The local benchmark loads the original asset catalog and original registered
weapons, plus all six shipped weapon/enemy packages. Every weapon runs through weapons, plus eight shipped weapon/enemy packages. Every weapon runs through
the real `World::step` simulation and real scripts at difficulty 2. Each scene the real `World::step` simulation and real scripts at difficulty 2. Each scene
holds Fire for 700 steps, exactly 30 seconds at 70/3 Hz, against 32×32 fixtures holds Fire for 700 steps, exactly 30 seconds at 70/3 Hz, against 32×32 fixtures
with 1,000,000 health. The pilot remains at the initial position with no other with 1,000,000 health. The pilot remains at the initial position with no other
@@ -44,6 +47,8 @@ cooldown.
| Mod Pulse Cannon | 185,000 | 24.00 | 24.00 | 0 | | Mod Pulse Cannon | 185,000 | 24.00 | 24.00 | 0 |
| Seeker Swarm | 285,000 | 23.40 | 23.40 | 23.40 | | Seeker Swarm | 285,000 | 23.40 | 23.40 | 23.40 |
| Ion Lance | 425,000 | 26.67 | 26.67 | 0 | | Ion Lance | 425,000 | 26.67 | 26.67 | 0 |
| Lightning Cannon | 825,000 | 41.60 | 41.60 | 0 |
| Lightning Shield | 155,000 | 0 | 0 | 0 |
The original forward laser's native beam damage pass reaches ground enemies The original forward laser's native beam damage pass reaches ground enemies
despite its historical air target metadata; the observations use its actual despite its historical air target metadata; the observations use its actual
@@ -66,13 +71,39 @@ in the same column: 53.33 combined. It delivered 80.00 combined across three
overlapping column targets. It dealt zero damage to targets outside its launch overlapping column targets. It dealt zero damage to targets outside its launch
column. It is a brief aimed beam, rather than a screen-wide attack. column. It is a brief aimed beam, rather than a screen-wide attack.
Lightning Cannon fires every 18 ticks and hits the first target for 32 damage.
In a chain scene with 40 pixels between target centers, it delivered 41.60,
26.00, 15.60 and 10.40 damage/s to four distinct targets, for 93.60 combined.
A fifth neighboring target took zero damage. Each hop chooses the nearest
living, previously unhit target within 48 pixels of the previous hit; it can
chain through air and ground targets and never revisits a target. It missed an
isolated target 76 pixels to the right of its narrow forward acquisition lane.
Its 825,000-credit price reflects better single-target output than the original
Pulse Cannon plus bounded cluster utility, while staying below the Death Ray's
80.00 aligned damage/s. Cluster performance alone does not guarantee campaign
performance: spaced enemies break the chain and weak targets reduce its useful
damage through overkill.
Lightning Shield cannot reach the normal distant fixtures. Against a stationary
aircraft with its hull 28 pixels from the pilot's center, it delivered **5.87
damage/s**. The equivalent ground fixture took zero damage. Against five close
aircraft it dealt 5.87 to each of the four closest targets, for 23.47 combined,
and zero to the fifth. The 28-pixel distance is measured to the nearest point on
the enemy hull, so it reaches only about 12 pixels beyond the pilot's own hull.
Its 155,000-credit price treats this as supplemental coverage near the ship,
below the Micro Missile and mod Pulse Cannon upgrades. Its short reach requires
dangerous proximity, and it provides no protection from collisions or bullets.
Firing alongside Forward Guns preserves its 5.87 close-target damage/s; selecting
another special never disables it. Its pulses consume no equipment.
## Economy and limits ## Economy and limits
Controlled first-mission runs starting with 100 energy and zero credits earned Controlled first-mission runs starting with 100 energy and zero credits earned
42,775 credits at difficulty 1 and 59,025 at difficulty 2 with starter guns. 42,775 credits at difficulty 1 and 59,025 at difficulty 2 with starter guns.
The in-game registration flow starts with 10,000 credits and 75 energy, matching The in-game registration flow starts with 10,000 credits and 75 energy, matching
the original hangar setup. Compared with the latter controlled gross the original hangar setup. Compared with the latter controlled gross
reward, these prices represent approximately 3.13, 4.83 and 7.20 such payouts. reward, the first three prices represent approximately 3.13, 4.83 and 7.20 such payouts.
Lightning Cannon costs about 13.98 and Lightning Shield about 2.63 such payouts.
Repairs and other purchases increase the saving time; later missions have Repairs and other purchases increase the saving time; later missions have
different rewards. This comparison sets a progression tier and does not promise different rewards. This comparison sets a progression tier and does not promise
an exact purchase mission. an exact purchase mission.
@@ -94,14 +125,30 @@ without granting a permanent shop upgrade or a repeatable resale reward.
Run the local-only test from the repository root with the original catalog: Run the local-only test from the repository root with the original catalog:
```sh ```sh
RAPTOR_BALANCE_REPORT="$PWD/.reference/weapon-balance.json" \ RAPTOR_TEST_DATA="$PWD/data" RAPTOR_BALANCE_REPORT="$PWD/.reference/weapon-balance.json" \
cargo test --release --manifest-path RustRaptor/Cargo.toml \ cargo test --release \
--no-default-features --test weapon_balance -- --ignored --nocapture --no-default-features --test weapon_balance -- --ignored --nocapture
``` ```
`RAPTOR_TEST_DATA` can override the catalog directory. The optional JSON report `RAPTOR_TEST_DATA` can override the catalog directory. The optional JSON report
records all 96 observations: fifteen weapons in six scenes, plus the three records all 120 observations: seventeen weapons in six scenes, the three original
primary upgrades combined with Forward Guns against air and ground. Assertions primary upgrade comparisons with Forward Guns, four additional proximity/chain
scenes for each lightning weapon, and four lightning/Forward Guns comparisons. Assertions
check all-target homing, column piercing, off-axis beam misses, target survival check all-target homing, column piercing, off-axis beam misses, target survival
and unchanged pilot shields. The test uses the Rust engine without SDL or C++. and unchanged pilot shields. The test uses the Rust engine without SDL or C++.
Original data and locally written reference reports stay outside Git. The bundled original data is tracked; locally written reference reports stay outside Git. The portable
weapon tests additionally check range boundaries, chain gaps, repeated-hit
exclusion, kills, cooldown, absence of input, removed equipment, combined loadouts
and deterministic continuation.
The opt-in visual proof uses the actual package icons, original palette and
public arc renderer in SD and HD. It writes local, ignored captures:
```sh
cargo test --locked --no-default-features --test weapons_mods \
lightning_mods_render_real_icons_and_discharge_arcs_in_sd_and_hd \
-- --ignored --nocapture
```
Both packages include a numeric `balance-report.json`; these observations do
not include original game artwork or catalog data.
+25
View File
@@ -0,0 +1,25 @@
# Instructions for AI agents authoring mods
Read [the mod authoring checklist](../docs/mod-authoring-checklist.md) and
[the version 1 API](../docs/modding-api.md) before creating or changing a mod.
Follow the user's requested behavior and controls, and preserve other agents'
work in the shared checkout.
- Implement gameplay through content definitions, Rhai callbacks and documented
commands. Keep a standalone mod independent unless its content needs a real
dependency. Do not select legacy engine behavior for new content.
- If the API lacks a required capability, define the smallest general addition
and coordinate engine work with the responsible agent when authorized. Keep
content-specific targeting, damage and tuning in the mod.
- Ship actual SD and HD PNG assets for every artwork reference, plus required
sounds/music. Keep dimensions, frame alignment and transparency consistent.
Store generation prompts, provenance and reproduction recipes with the mod.
- Price equipment against original and shipped equipment, using controlled
simulation observations as well as targeting, range, cooldown and loadout
utility. Do not copy cheap placeholder prices from an example.
- Finish independent activation, gameplay and coexistence tests, real package
validation where the original catalog is available, and a visual check of
both SD and HD. Document any checks that cannot run.
- Document enabling, controls, prices and behavior limits. Deliver complete
local packages without changing the user's pilot, selected mods or unrelated
content unless requested.
+10
View File
@@ -0,0 +1,10 @@
# lightning_cannon artwork and sound provenance
The transparent equipment master was created with the built-in imagegen tool and the imagegen skill. No original game pixels or external reference artwork were used. `prepare_art.rs` fits the master onto transparent 16×16 SD and 64×64 HD canvases. Collision and targeting dimensions are independent of image resolution. Actual connecting lightning is drawn through the public `arc` command rather than projectile sprites.
Final prompt:
Use case: stylized-concept. Asset type: transparent inventory equipment sprite for a top-down 1994-inspired arcade aircraft shooter, supplied as a square master image for a 16x16 logical icon and 64x64 HD icon. Primary request: a high-powered Lightning Cannon weapon module. Single compact industrial gun assembly viewed directly from above, long forward-facing barrel at top, two sturdy brushed steel rails around a vivid cyan electrical coil, white-blue zigzag electricity confined between the rails, dark navy housing with small copper contacts. Hand-painted retro game sprite with strong readable silhouette, simple broad material planes and restrained crisp highlights. Center the entire object with a little transparent padding. Genuinely transparent background and no ground shadow. No text, letters, numbers, frame, UI, watermark, or surrounding scene. The gun occupies most of the canvas and should still read at tiny size.
The master, SD/HD images and sound WAVs are included in the package. SHA-256 hashes are recorded in `art-provenance.json`. `generate_audio.py` reproduces deterministic PCM16 mono sounds at 22,050 Hz. Palette conversion occurs in the runtime loader.
+42
View File
@@ -0,0 +1,42 @@
# Lightning Cannon
An independent, reusable heavy lightning special for **825,000 credits**.
Enable **Lightning Cannon** in the Mods menu, apply selections at the main menu,
then buy it in the supply room. Select it in Inventory or cycle specials;
**Fire or Special** discharges the cannon while selected. It consumes no ammo,
and you can own one.
The bolt instantly hits the nearest visible air or ground target ahead of the
muzzle, within 180 logical pixels and a 36-pixel horizontal aiming allowance.
It deals **32 damage**, then jumps from the struck ship to the nearest unhit
enemy within **48 pixels**, for **20, 12 and 8 damage**. A discharge hits at most
four different enemies. Chaining can change direction and continues from a
destroyed target's last position. An isolated target takes only the initial hit;
off-axis targets require an adjacent chain target. Cooldown is **18 ticks**,
about 0.77 seconds. A missed shot shows a bolt and firing sound without damage.
The 30-second controlled simulation measured **41.60 damage/s** on an isolated
aligned target and **93.60 combined damage/s** across a four-target chain. The
price places it above the original Pulse Cannon and below the Death Ray. See
the package [balance report](balance-report.json) for the recorded observations.
This package uses the public mod API's `scripted` weapon behavior, read-only
entity snapshots, `damage`, `arc` and `play_sound` commands. All acquisition,
chaining and damage rules live in `scripts/weapon.rhai`. It has no dependencies
or overrides. It coexists with the other independent weapons and Iron Coast.
The equipment icon ships in **16×16 SD** and **64×64 HD**, alongside the
transparent source master and deterministic synthesized audio. Connecting bolts
use the game's arc renderer at both scales. See [provenance](ART_PROVENANCE.md)
for the exact built-in imagegen prompt, asset hashes and reproduction recipes.
From `RustRaptor/`:
```sh
cargo run --locked --no-default-features -- --mods mods --validate-mods lightning_cannon
cargo test --locked --no-default-features --test weapons_mods lightning_cannon
```
See [weapon pricing and measurements](../../docs/weapon-balance.md),
[the API](../../docs/modding-api.md) and
[the AI mod checklist](../../docs/mod-authoring-checklist.md).
+29
View File
@@ -0,0 +1,29 @@
{
"package": "lightning_cannon",
"tool": "built-in imagegen",
"use_case": "stylized-concept",
"art": {
"art/equipment-generated-source.png": {
"sha256": "96bddda748c25a70dc3a4be85badb524fe29a77c35ec82bb3f50efe529876ba6",
"bytes": 727812
},
"art/equipment-hd.png": {
"sha256": "b17a0d4d4af5f049bc2b02abce6e57570d8217a29dc76f4bf43df3bc2e51c703",
"bytes": 2974
},
"art/equipment-sd.png": {
"sha256": "3e43886e382ee9d3014c8a50700602b0499356b6b01a42ba8da965f18af17855",
"bytes": 420
}
},
"sounds": {
"audio/fire.wav": {
"sha256": "139b19dc294cdb256618c381eba3d1f7d0f6b0f77e463c0fda9dd579a8a457d0",
"bytes": 10628
},
"audio/impact.wav": {
"sha256": "636543d96b04a8c52e5ade37a5bce1d3f77e3bb6889ff4ffd6675ad9938d6415",
"bytes": 4894
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 B

Binary file not shown.
Binary file not shown.
+204
View File
@@ -0,0 +1,204 @@
{
"difficulty": 2,
"fire_steps": 700,
"rate": "70/3 Hz",
"target_health": 1000000,
"target_size": [
32,
32
],
"rows": [
{
"damage": [
1248
],
"damage_per_second": [
41.6
],
"item": "lightning_cannon:equipment",
"price": 825000,
"scene": "aligned_air",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
1248
],
"damage_per_second": [
41.6
],
"item": "lightning_cannon:equipment",
"price": 825000,
"scene": "aligned_ground",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
0
],
"damage_per_second": [
0.0
],
"item": "lightning_cannon:equipment",
"price": 825000,
"scene": "off_axis_air",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
0
],
"damage_per_second": [
0.0
],
"item": "lightning_cannon:equipment",
"price": 825000,
"scene": "off_axis_ground",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
1248,
0
],
"damage_per_second": [
41.6,
0.0
],
"item": "lightning_cannon:equipment",
"price": 825000,
"scene": "column",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
780,
1248,
468
],
"damage_per_second": [
26.0,
41.6,
15.6
],
"item": "lightning_cannon:equipment",
"price": 825000,
"scene": "cluster",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
0
],
"damage_per_second": [
0.0
],
"item": "lightning_cannon:equipment",
"price": 825000,
"scene": "close_air",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
0
],
"damage_per_second": [
0.0
],
"item": "lightning_cannon:equipment",
"price": 825000,
"scene": "close_ground",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
1248,
780,
468,
312,
0
],
"damage_per_second": [
41.6,
26.0,
15.6,
10.4,
0.0
],
"item": "lightning_cannon:equipment",
"price": 825000,
"scene": "chain",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
0,
0,
1248,
0,
0
],
"damage_per_second": [
0.0,
0.0,
41.6,
0.0,
0.0
],
"item": "lightning_cannon:equipment",
"price": 825000,
"scene": "close_cluster",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
1942
],
"damage_per_second": [
64.73333333333333
],
"item": "lightning_cannon:equipment",
"price": 825000,
"scene": "aligned_air",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": true
},
{
"damage": [
0
],
"damage_per_second": [
0.0
],
"item": "lightning_cannon:equipment",
"price": 825000,
"scene": "close_air",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": true
}
],
"method": "Real World::step and shipped scripts; 700 Fire steps (30 seconds); stationary 32x32 surviving targets; no player damage. Reproduce with tests/weapon_balance.rs.",
"limitations": "Controlled fixture observations, not a claim of equal campaign difficulty. Original catalog and preview pixels remain outside Git."
}
+55
View File
@@ -0,0 +1,55 @@
{
"weapons": [
{
"id": "lightning_cannon:projectile",
"name": "Lightning Cannon Discharge",
"damage": 32,
"speed": 0,
"cooldown": 18,
"behavior": "scripted",
"target": "all",
"script": "scripts/weapon.rhai"
}
],
"items": [
{
"id": "lightning_cannon:equipment",
"name": "Lightning Cannon",
"description": "Selectable reusable special. Fires a powerful lightning bolt into the nearest air or ground target in a narrow lane ahead, then chains through up to three nearby enemies with decreasing damage. Select it in Inventory or cycle specials, then use Fire or Special. No ammunition is consumed.",
"price": 825000,
"max_count": 1,
"initial_count": 1,
"weapon": "lightning_cannon:projectile",
"special": true,
"consumable": false,
"art": {
"png": "art/equipment-sd.png",
"hd_png": "art/equipment-hd.png",
"logical_width": 16,
"logical_height": 16,
"nearest": true
},
"script": "scripts/equipment.rhai"
}
],
"sounds": [
{
"id": "lightning_cannon:fire",
"name": "Lightning Cannon Discharge",
"file": "audio/fire.wav",
"volume": 92,
"pan": 127,
"pitch": 128,
"priority": 1
},
{
"id": "lightning_cannon:impact",
"name": "Lightning Cannon Contact",
"file": "audio/impact.wav",
"volume": 78,
"pan": 127,
"pitch": 128,
"priority": 2
}
]
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Reproduce deterministic PCM16 mono electrical discharge sounds."""
import math
import struct
import wave
from pathlib import Path
PACKAGE = "lightning_cannon"
RATE = 22050
def render(kind):
heavy = PACKAGE == "lightning_cannon"
duration = (0.24 if heavy else 0.09) if kind == "fire" else (0.11 if heavy else 0.06)
count = int(RATE * duration)
seed = 0x5A17C0DE
phase = 0.0
samples = []
for n in range(count):
t = n / count
seed ^= (seed << 13) & 0xffffffff
seed ^= seed >> 17
seed ^= (seed << 5) & 0xffffffff
seed &= 0xffffffff
noise = seed / 2147483648.0 - 1.0
frequency = (2100 - 1450 * t) if heavy else (3100 - 1650 * t)
phase += 2 * math.pi * frequency / RATE
crackle = noise * (0.45 + 0.25 * math.sin(2 * math.pi * 61 * n / RATE) ** 8)
tone = math.sin(phase) * 0.24 + math.sin(phase * 1.503) * 0.15
if kind == "impact":
tone = math.sin(phase * 0.18) * 0.25
attack = min(1.0, n / (RATE * 0.002))
envelope = attack * (1 - t) ** (2.2 if kind == "fire" else 3)
samples.append(round(max(-1, min(1, (crackle + tone) * envelope)) * 21000))
return struct.pack("<" + "h" * len(samples), *samples)
if __name__ == "__main__":
output = Path(__file__).resolve().parent / "audio"
output.mkdir(exist_ok=True)
for kind in ["fire", "impact"]:
with wave.open(str(output / (kind + ".wav")), "wb") as wav:
wav.setparams((1, 2, RATE, 0, "NONE", "not compressed"))
wav.writeframes(render(kind))
print(output / (kind + ".wav"))
+7
View File
@@ -0,0 +1,7 @@
id = "lightning_cannon"
name = "Lightning Cannon"
version = "1.0.0"
api_version = 1
description = "Selectable reusable special. Fires a powerful lightning bolt into the nearest air or ground target in a narrow lane ahead, then chains through up to three nearby enemies with decreasing damage. Select it in Inventory or cycle specials, then use Fire or Special. No ammunition is consumed."
content = "content.json"
scripts = ["scripts/weapon.rhai", "scripts/equipment.rhai"]
+77
View File
@@ -0,0 +1,77 @@
//! Fit the transparent equipment master to the 16px SD and 64px HD canvases.
use image::{Rgba, RgbaImage, imageops};
use std::{env, fs, path::Path};
fn canvas(source: &RgbaImage, width: u32, height: u32, hd: bool) -> RgbaImage {
// Only the solid silhouette defines its fitting box; faint outer glows stay bounded.
let mut bounds = (source.width(), source.height(), 0, 0);
for (x, y, p) in source.enumerate_pixels() {
if p[3] >= 128 {
bounds.0 = bounds.0.min(x);
bounds.1 = bounds.1.min(y);
bounds.2 = bounds.2.max(x + 1);
bounds.3 = bounds.3.max(y + 1);
}
}
assert!(
bounds.2 > bounds.0 && bounds.3 > bounds.1,
"source has no opaque silhouette"
);
let cropped = imageops::crop_imm(
source,
bounds.0,
bounds.1,
bounds.2 - bounds.0,
bounds.3 - bounds.1,
)
.to_image();
let margin = if hd { 4 } else { 1 };
let available = (
width.saturating_sub(margin * 2).max(1),
height.saturating_sub(margin * 2).max(1),
);
let factor = (available.0 as f64 / cropped.width() as f64)
.min(available.1 as f64 / cropped.height() as f64);
let size = (
(cropped.width() as f64 * factor).round().max(1.) as u32,
(cropped.height() as f64 * factor).round().max(1.) as u32,
);
let filter = if hd {
imageops::FilterType::Lanczos3
} else {
imageops::FilterType::Nearest
};
let mut scaled = imageops::resize(&cropped, size.0, size.1, filter);
for p in scaled.pixels_mut() {
if p[3] < (if hd { 64 } else { 128 }) {
*p = Rgba([0, 0, 0, 0]);
} else {
p[3] = 255;
}
}
let mut out = RgbaImage::new(width, height);
imageops::overlay(
&mut out,
&scaled,
((width - size.0) / 2) as i64,
((height - size.1) / 2) as i64,
);
out
}
fn main() {
let args: Vec<_> = env::args_os().collect();
assert_eq!(
args.len(),
3,
"usage: prepare_art EQUIPMENT_SOURCE PACKAGE_DIRECTORY"
);
let output = Path::new(&args[2]).join("art");
fs::create_dir_all(&output).unwrap();
let source = image::open(&args[1]).unwrap().into_rgba8();
fs::copy(&args[1], output.join("equipment-generated-source.png")).unwrap();
for (mode, scale) in [("sd", 1), ("hd", 4)] {
let sprite = canvas(&source, 16 * scale, 16 * scale, scale == 4);
let path = output.join(format!("equipment-{mode}.png"));
sprite.save(&path).unwrap();
println!("{}x{} {}", sprite.width(), sprite.height(), path.display());
}
}
@@ -0,0 +1,6 @@
// Pickups use the same reusable equipment as the shop.
fn on_pickup(event, state, random) {
let recovered = if state.contains("recovered") { state.recovered % 100000 + 1 } else { 1 };
#{ commands: [#{ op: "objective", key: "lightning_cannon:recovered", value: recovered }],
state: #{ recovered: recovered } }
}
+56
View File
@@ -0,0 +1,56 @@
// The scripted discharge uses only public snapshots and checked API commands.
// A narrow forward acquisition lane preserves aiming; later hops may go sideways.
fn on_fire(event, state, random) {
if event.hostile {
return #{ commands: [], state: state };
}
let bursts = if state.contains("bursts") { state.bursts % 100000 + 1 } else { 1 };
let hits = if state.contains("hits") { state.hits } else { 0 };
let commands = [#{ op: "play_sound", sound: "lightning_cannon:fire" }];
let struck = [];
let from_x = event.x;
let from_y = event.y;
let damages = [32, 20, 12, 8];
for hop in 0..4 {
let selected = #{};
let nearest = 1000000;
for enemy in event.entities {
let cx = enemy.x + enemy.width / 2;
let cy = enemy.y + enemy.height / 2;
if enemy.health <= 0 || struck.contains(enemy.entity)
|| cx < 0 || cx >= 320 || cy < 0 || cy >= 200 {
continue;
}
let dx = cx - from_x;
let dy = cy - from_y;
let distance = dx * dx + dy * dy;
let reachable = if hop == 0 {
abs(dx) <= 36 && dy < 0 && distance <= 180 * 180
} else {
distance <= 48 * 48
};
// Entity order provides a stable tie break for equally near targets.
if reachable && distance < nearest {
selected = #{ entity: enemy.entity, x: cx, y: cy };
nearest = distance;
}
}
if !selected.contains("entity") { break; }
commands.push(#{ op: "arc", x: from_x, y: from_y,
to_x: selected.x, to_y: selected.y, ticks: 4 });
commands.push(#{ op: "damage", entity: selected.entity, amount: damages[hop] });
struck.push(selected.entity);
from_x = selected.x;
from_y = selected.y;
hits = hits % 100000 + 1;
}
if struck.len() > 0 {
commands.push(#{ op: "play_sound", sound: "lightning_cannon:impact" });
} else {
// A missed shot remains visible without spawning a damaging carrier.
commands.push(#{ op: "arc", x: event.x, y: event.y,
to_x: event.x, to_y: event.y - 180, ticks: 3 });
}
#{ commands: commands, state: #{ bursts: bursts, hits: hits } }
}
+10
View File
@@ -0,0 +1,10 @@
# lightning_shield artwork and sound provenance
The transparent equipment master was created with the built-in imagegen tool and the imagegen skill. No original game pixels or external reference artwork were used. `prepare_art.rs` fits the master onto transparent 16×16 SD and 64×64 HD canvases. Collision and targeting dimensions are independent of image resolution. Actual connecting lightning is drawn through the public `arc` command rather than projectile sprites.
Final prompt:
Use case: stylized-concept. Asset type: transparent inventory equipment sprite for a top-down 1994-inspired arcade aircraft shooter, supplied as a square master image for a 16x16 logical icon and 64x64 HD icon. Primary request: a Lightning Shield proximity zap weapon module. Single compact circular industrial coil unit viewed directly from above, a vivid cyan ring enclosing a dark central steel capacitor, four small copper electrode terminals evenly spaced, tiny white-blue lightning arcs contained inside the ring. Dark navy and brushed steel housing. Hand-painted retro game sprite with strong readable silhouette, simple broad material planes and restrained crisp highlights. Center the entire object with a little transparent padding. Genuinely transparent background and no ground shadow. No text, letters, numbers, frame, UI, watermark, spaceship, or surrounding scene. The circular emitter occupies most of the canvas and should still read at tiny size.
The master, SD/HD images and sound WAVs are included in the package. SHA-256 hashes are recorded in `art-provenance.json`. `generate_audio.py` reproduces deterministic PCM16 mono sounds at 22,050 Hz. Palette conversion occurs in the runtime loader.
+48
View File
@@ -0,0 +1,48 @@
# Lightning Shield
An independent, reusable proximity weapon for **155,000 credits**. Enable
**Lightning Shield** in the Mods menu, apply selections at the main menu, then
buy it in the supply room. It is **always equipped** while owned and fires
alongside the other primary guns when you hold **Fire**. It needs no special
selection, consumes no ammo, and you can own one.
Each pulse deals **2 damage** to up to **four close airborne ships** in any
direction. Range is **28 logical pixels from your ship's center to the nearest
point of the enemy hull**: approximately 12 pixels beyond your 32-pixel hull.
It picks the nearest ships first, with a stable tie break. Ground structures
are excluded. The **8-tick cooldown** gives about 2.92 pulses per second and
5.87 measured damage per second against a stationary close ship.
The price places this short-range supplement below the Micro Missile primary
upgrade. See the [balance report](balance-report.json) for the 30-second
controlled observations, including ground exclusions and combined loadouts.
It runs only while Fire is held, and makes no sound when nothing is in range.
It supplements close combat; it does not absorb collision damage, restore energy
or block bullets. It continues working when you select a different special.
Firing charges a flickering electric ring just outside the hull. The short zaps
start from the edge facing each target, with a blue glow around the ship. The
ring follows your movement and fades within one recharge after releasing Fire;
it remains silent when no aircraft are close. The ring and its forks are cosmetic.
This package uses the public mod API's `scripted` weapon behavior, read-only
entity snapshots with `ground`, and `damage`, `arc` and `play_sound` commands.
All targeting and damage rules live in `scripts/weapon.rhai`. It has no
dependencies or overrides, and coexists with the other weapons and Iron Coast.
The equipment icon ships in **16×16 SD** and **64×64 HD**, alongside the
transparent source master and deterministic synthesized audio. The halo and
connecting bolts use anchored `arc` commands and the game's renderer at both
scales. See [provenance](ART_PROVENANCE.md)
for the exact built-in imagegen prompt, asset hashes and reproduction recipes.
From `RustRaptor/`:
```sh
cargo run --locked --no-default-features -- --mods mods --validate-mods lightning_shield
cargo test --locked --no-default-features --test weapons_mods lightning_shield
```
See [weapon pricing and measurements](../../docs/weapon-balance.md),
[the API](../../docs/modding-api.md) and
[the AI mod checklist](../../docs/mod-authoring-checklist.md).
+29
View File
@@ -0,0 +1,29 @@
{
"package": "lightning_shield",
"tool": "built-in imagegen",
"use_case": "stylized-concept",
"art": {
"art/equipment-generated-source.png": {
"sha256": "c0b52bc0ac198f3de77bf4ed562ab1ab4b46b9015c05852043d93ab34826c360",
"bytes": 1732192
},
"art/equipment-hd.png": {
"sha256": "62254d2663c27ffd27979276cbef06d14c9f8f972062a2cd7aa83f6e50c0b224",
"bytes": 8665
},
"art/equipment-sd.png": {
"sha256": "43801918f5d71ccd5d6798c684b945a0b90b6fe28541450d49adc6bb7eed2c17",
"bytes": 710
}
},
"sounds": {
"audio/fire.wav": {
"sha256": "21466ed125226870afe59a1cbaf1bc7448ae19258d362371e4f9c25768c4e462",
"bytes": 4012
},
"audio/impact.wav": {
"sha256": "74c57d492d87b2d4f5d34d52d2925da0bb1664b270ca407c685e63f83109f2db",
"bytes": 2690
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.
Binary file not shown.
+204
View File
@@ -0,0 +1,204 @@
{
"difficulty": 2,
"fire_steps": 700,
"rate": "70/3 Hz",
"target_health": 1000000,
"target_size": [
32,
32
],
"rows": [
{
"damage": [
0
],
"damage_per_second": [
0.0
],
"item": "lightning_shield:equipment",
"price": 155000,
"scene": "aligned_air",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
0
],
"damage_per_second": [
0.0
],
"item": "lightning_shield:equipment",
"price": 155000,
"scene": "aligned_ground",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
0
],
"damage_per_second": [
0.0
],
"item": "lightning_shield:equipment",
"price": 155000,
"scene": "off_axis_air",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
0
],
"damage_per_second": [
0.0
],
"item": "lightning_shield:equipment",
"price": 155000,
"scene": "off_axis_ground",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
0,
0
],
"damage_per_second": [
0.0,
0.0
],
"item": "lightning_shield:equipment",
"price": 155000,
"scene": "column",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
0,
0,
0
],
"damage_per_second": [
0.0,
0.0,
0.0
],
"item": "lightning_shield:equipment",
"price": 155000,
"scene": "cluster",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
176
],
"damage_per_second": [
5.866666666666666
],
"item": "lightning_shield:equipment",
"price": 155000,
"scene": "close_air",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
0
],
"damage_per_second": [
0.0
],
"item": "lightning_shield:equipment",
"price": 155000,
"scene": "close_ground",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
0,
0,
0,
0,
0
],
"damage_per_second": [
0.0,
0.0,
0.0,
0.0,
0.0
],
"item": "lightning_shield:equipment",
"price": 155000,
"scene": "chain",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
176,
176,
176,
176,
0
],
"damage_per_second": [
5.866666666666666,
5.866666666666666,
5.866666666666666,
5.866666666666666,
0.0
],
"item": "lightning_shield:equipment",
"price": 155000,
"scene": "close_cluster",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": false
},
{
"damage": [
694
],
"damage_per_second": [
23.133333333333333
],
"item": "lightning_shield:equipment",
"price": 155000,
"scene": "aligned_air",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": true
},
{
"damage": [
176
],
"damage_per_second": [
5.866666666666666
],
"item": "lightning_shield:equipment",
"price": 155000,
"scene": "close_air",
"seconds": 30.0,
"ticks": 700,
"with_forward_guns": true
}
],
"method": "Real World::step and shipped scripts; 700 Fire steps (30 seconds); stationary 32x32 surviving targets; no player damage. Reproduce with tests/weapon_balance.rs.",
"limitations": "Controlled fixture observations, not a claim of equal campaign difficulty. Original catalog and preview pixels remain outside Git."
}
+55
View File
@@ -0,0 +1,55 @@
{
"weapons": [
{
"id": "lightning_shield:projectile",
"name": "Lightning Shield Discharge",
"damage": 2,
"speed": 0,
"cooldown": 8,
"behavior": "scripted",
"target": "air",
"script": "scripts/weapon.rhai"
}
],
"items": [
{
"id": "lightning_shield:equipment",
"name": "Lightning Shield",
"description": "Always-equipped reusable proximity weapon. Hold Fire to charge a glowing electric ring that follows your hull and zaps up to four airborne ships within a very short range from its edge for 2 damage each. Fires alongside your other primary guns and selected special; no selection or ammunition needed. Does not absorb damage or stop enemy bullets.",
"price": 155000,
"max_count": 1,
"initial_count": 1,
"weapon": "lightning_shield:projectile",
"special": false,
"consumable": false,
"art": {
"png": "art/equipment-sd.png",
"hd_png": "art/equipment-hd.png",
"logical_width": 16,
"logical_height": 16,
"nearest": true
},
"script": "scripts/equipment.rhai"
}
],
"sounds": [
{
"id": "lightning_shield:fire",
"name": "Lightning Shield Discharge",
"file": "audio/fire.wav",
"volume": 68,
"pan": 127,
"pitch": 128,
"priority": 2
},
{
"id": "lightning_shield:impact",
"name": "Lightning Shield Contact",
"file": "audio/impact.wav",
"volume": 54,
"pan": 127,
"pitch": 128,
"priority": 2
}
]
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Reproduce deterministic PCM16 mono electrical discharge sounds."""
import math
import struct
import wave
from pathlib import Path
PACKAGE = "lightning_shield"
RATE = 22050
def render(kind):
heavy = PACKAGE == "lightning_cannon"
duration = (0.24 if heavy else 0.09) if kind == "fire" else (0.11 if heavy else 0.06)
count = int(RATE * duration)
seed = 0x5A17C0DE
phase = 0.0
samples = []
for n in range(count):
t = n / count
seed ^= (seed << 13) & 0xffffffff
seed ^= seed >> 17
seed ^= (seed << 5) & 0xffffffff
seed &= 0xffffffff
noise = seed / 2147483648.0 - 1.0
frequency = (2100 - 1450 * t) if heavy else (3100 - 1650 * t)
phase += 2 * math.pi * frequency / RATE
crackle = noise * (0.45 + 0.25 * math.sin(2 * math.pi * 61 * n / RATE) ** 8)
tone = math.sin(phase) * 0.24 + math.sin(phase * 1.503) * 0.15
if kind == "impact":
tone = math.sin(phase * 0.18) * 0.25
attack = min(1.0, n / (RATE * 0.002))
envelope = attack * (1 - t) ** (2.2 if kind == "fire" else 3)
samples.append(round(max(-1, min(1, (crackle + tone) * envelope)) * 21000))
return struct.pack("<" + "h" * len(samples), *samples)
if __name__ == "__main__":
output = Path(__file__).resolve().parent / "audio"
output.mkdir(exist_ok=True)
for kind in ["fire", "impact"]:
with wave.open(str(output / (kind + ".wav")), "wb") as wav:
wav.setparams((1, 2, RATE, 0, "NONE", "not compressed"))
wav.writeframes(render(kind))
print(output / (kind + ".wav"))
+7
View File
@@ -0,0 +1,7 @@
id = "lightning_shield"
name = "Lightning Shield"
version = "1.0.0"
api_version = 1
description = "Always-equipped reusable proximity weapon. Hold Fire to charge an electric ring around your hull and zap up to four nearby airborne ships from its edge for a little damage. Fires alongside your forward guns and selected special; no selection or ammunition needed. Does not absorb damage or stop enemy bullets."
content = "content.json"
scripts = ["scripts/weapon.rhai", "scripts/equipment.rhai"]
+77
View File
@@ -0,0 +1,77 @@
//! Fit the transparent equipment master to the 16px SD and 64px HD canvases.
use image::{Rgba, RgbaImage, imageops};
use std::{env, fs, path::Path};
fn canvas(source: &RgbaImage, width: u32, height: u32, hd: bool) -> RgbaImage {
// Only the solid silhouette defines its fitting box; faint outer glows stay bounded.
let mut bounds = (source.width(), source.height(), 0, 0);
for (x, y, p) in source.enumerate_pixels() {
if p[3] >= 128 {
bounds.0 = bounds.0.min(x);
bounds.1 = bounds.1.min(y);
bounds.2 = bounds.2.max(x + 1);
bounds.3 = bounds.3.max(y + 1);
}
}
assert!(
bounds.2 > bounds.0 && bounds.3 > bounds.1,
"source has no opaque silhouette"
);
let cropped = imageops::crop_imm(
source,
bounds.0,
bounds.1,
bounds.2 - bounds.0,
bounds.3 - bounds.1,
)
.to_image();
let margin = if hd { 4 } else { 1 };
let available = (
width.saturating_sub(margin * 2).max(1),
height.saturating_sub(margin * 2).max(1),
);
let factor = (available.0 as f64 / cropped.width() as f64)
.min(available.1 as f64 / cropped.height() as f64);
let size = (
(cropped.width() as f64 * factor).round().max(1.) as u32,
(cropped.height() as f64 * factor).round().max(1.) as u32,
);
let filter = if hd {
imageops::FilterType::Lanczos3
} else {
imageops::FilterType::Nearest
};
let mut scaled = imageops::resize(&cropped, size.0, size.1, filter);
for p in scaled.pixels_mut() {
if p[3] < (if hd { 64 } else { 128 }) {
*p = Rgba([0, 0, 0, 0]);
} else {
p[3] = 255;
}
}
let mut out = RgbaImage::new(width, height);
imageops::overlay(
&mut out,
&scaled,
((width - size.0) / 2) as i64,
((height - size.1) / 2) as i64,
);
out
}
fn main() {
let args: Vec<_> = env::args_os().collect();
assert_eq!(
args.len(),
3,
"usage: prepare_art EQUIPMENT_SOURCE PACKAGE_DIRECTORY"
);
let output = Path::new(&args[2]).join("art");
fs::create_dir_all(&output).unwrap();
let source = image::open(&args[1]).unwrap().into_rgba8();
fs::copy(&args[1], output.join("equipment-generated-source.png")).unwrap();
for (mode, scale) in [("sd", 1), ("hd", 4)] {
let sprite = canvas(&source, 16 * scale, 16 * scale, scale == 4);
let path = output.join(format!("equipment-{mode}.png"));
sprite.save(&path).unwrap();
println!("{}x{} {}", sprite.width(), sprite.height(), path.display());
}
}
@@ -0,0 +1,6 @@
// Pickups use the same reusable equipment as the shop.
fn on_pickup(event, state, random) {
let recovered = if state.contains("recovered") { state.recovered % 100000 + 1 } else { 1 };
#{ commands: [#{ op: "objective", key: "lightning_shield:recovered", value: recovered }],
state: #{ recovered: recovered } }
}
+70
View File
@@ -0,0 +1,70 @@
// This is an ordinary non-special weapon: it runs only while Fire is held.
// Measure range to the nearest point of an enemy hull, not its center, so large
// ships can be zapped at the edge too. The reach is only 12px past our 32px hull.
fn on_fire(event, state, random) {
if event.hostile {
return #{ commands: [], state: state };
}
let bursts = if state.contains("bursts") { state.bursts % 100000 + 1 } else { 1 };
let hits = if state.contains("hits") { state.hits } else { 0 };
let commands = [];
let struck = [];
let from_x = event.player.x + 16;
let from_y = event.player.y + 16;
// An electric halo just outside the hull follows the ship throughout the
// recharge. Both endpoints use the public arc anchoring API; it is visual
// only, so even an empty pulse still lights the ring without playing audio.
let ring = [[19, 0], [18, 7], [13, 13], [7, 18], [0, 19], [-7, 18],
[-13, 13], [-18, 7], [-19, 0], [-18, -7], [-13, -13],
[-7, -18], [0, -19], [7, -18], [13, -13], [18, -7]];
for i in 0..ring.len() {
let a = ring[i];
let b = ring[(i + 1) % ring.len()];
commands.push(#{ op: "arc", x: from_x + a[0], y: from_y + a[1],
to_x: from_x + b[0], to_y: from_y + b[1],
anchor: 0, to_anchor: 0, ticks: 8 });
}
// Bound each pulse's work and sound: at most four close airborne ships.
for target in 0..4 {
let selected = #{};
let nearest = 1000000;
for enemy in event.entities {
if enemy.health <= 0 || enemy.ground || struck.contains(enemy.entity) {
continue;
}
let x = max(enemy.x, min(from_x, enemy.x + enemy.width - 1));
let y = max(enemy.y, min(from_y, enemy.y + enemy.height - 1));
if x < 0 || x >= 320 || y < 0 || y >= 200 { continue; }
let dx = x - from_x;
let dy = y - from_y;
let distance = dx * dx + dy * dy;
if distance <= 28 * 28 && distance < nearest {
selected = #{ entity: enemy.entity, x: x, y: y };
nearest = distance;
}
}
if !selected.contains("entity") { break; }
// Start at the halo point facing this hull, not at the player's center.
let dx = selected.x - from_x;
let dy = selected.y - from_y;
let origin = ring[0];
let best = -1000000;
for point in ring {
let facing = point[0] * dx + point[1] * dy;
if facing > best { best = facing; origin = point; }
}
commands.push(#{ op: "arc", x: from_x + origin[0], y: from_y + origin[1],
to_x: selected.x, to_y: selected.y,
anchor: 0, to_anchor: selected.entity, ticks: 3 });
commands.push(#{ op: "damage", entity: selected.entity, amount: 2 });
struck.push(selected.entity);
hits = hits % 100000 + 1;
}
if struck.len() > 0 {
commands.push(#{ op: "play_sound", sound: "lightning_shield:fire" });
commands.push(#{ op: "play_sound", sound: "lightning_shield:impact" });
}
#{ commands: commands, state: #{ bursts: bursts, hits: hits } }
}
+19 -8
View File
@@ -583,14 +583,25 @@
"additionalProperties": false "additionalProperties": false
}, },
"WeaponBehavior": { "WeaponBehavior": {
"type": "string", "oneOf": [
"enum": [ {
"straight", "type": "string",
"homing", "enum": [
"beam", "straight",
"spread", "homing",
"bomb", "beam",
"energy_grab" "spread",
"bomb",
"energy_grab"
]
},
{
"description": "Fire through on_fire only, without an automatic engine projectile.",
"type": "string",
"enum": [
"scripted"
]
}
] ]
}, },
"WeaponDef": { "WeaponDef": {
+63
View File
@@ -319,6 +319,69 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
{
"description": "Cosmetic lightning; pair with damage commands for gameplay effects.",
"type": "object",
"required": [
"op",
"to_x",
"to_y",
"x",
"y"
],
"properties": {
"anchor": {
"description": "Follow this entity's movement; zero is the player. Endpoints remain absolute coordinates at creation, preserving their initial offset.",
"default": null,
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 0.0
},
"op": {
"type": "string",
"enum": [
"arc"
]
},
"ticks": {
"default": 3,
"type": "integer",
"format": "uint32",
"maximum": 12.0,
"minimum": 1.0
},
"to_anchor": {
"description": "Optional independent anchor for the destination endpoint.",
"default": null,
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 0.0
},
"to_x": {
"type": "integer",
"format": "int32"
},
"to_y": {
"type": "integer",
"format": "int32"
},
"x": {
"type": "integer",
"format": "int32"
},
"y": {
"type": "integer",
"format": "int32"
}
},
"additionalProperties": false
},
{ {
"type": "object", "type": "object",
"required": [ "required": [
+19 -8
View File
@@ -140,14 +140,25 @@
] ]
}, },
"WeaponBehavior": { "WeaponBehavior": {
"type": "string", "oneOf": [
"enum": [ {
"straight", "type": "string",
"homing", "enum": [
"beam", "straight",
"spread", "homing",
"bomb", "beam",
"energy_grab" "spread",
"bomb",
"energy_grab"
]
},
{
"description": "Fire through on_fire only, without an automatic engine projectile.",
"type": "string",
"enum": [
"scripted"
]
}
] ]
} }
} }
+7
View File
@@ -92,6 +92,8 @@ pub enum WeaponBehavior {
Spread, Spread,
Bomb, Bomb,
EnergyGrab, EnergyGrab,
/// Fire through on_fire only, without an automatic engine projectile.
Scripted,
} }
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -423,6 +425,11 @@ impl Registry {
}; };
for w in self.weapons.values() { for w in self.weapons.values() {
label(&w.name, &w.script)?; label(&w.name, &w.script)?;
ensure!(
!matches!(w.behavior, WeaponBehavior::Scripted) || w.script.is_some(),
"scripted weapon {} requires a script",
w.id
);
ensure!( ensure!(
w.damage > 0 w.damage > 0
&& w.damage <= 100000 && w.damage <= 100000
+3 -1
View File
@@ -3337,6 +3337,7 @@ impl Host {
self.effects(canvas, &world, false, alpha)?; self.effects(canvas, &world, false, alpha)?;
self.secondary_effects(canvas, &world, 2, alpha)?; self.secondary_effects(canvas, &world, 2, alpha)?;
self.projectiles(canvas, &world, true, alpha)?; self.projectiles(canvas, &world, true, alpha)?;
crate::render::draw_world_lightning_arcs(canvas, &world, &self.palette, alpha);
self.hud(canvas, &world)?; self.hud(canvas, &world)?;
Ok(()) Ok(())
} }
@@ -3958,11 +3959,12 @@ fn shop_info(
WeaponBehavior::Spread => "Spread", WeaponBehavior::Spread => "Spread",
WeaponBehavior::Bomb => "Blast", WeaponBehavior::Bomb => "Blast",
WeaponBehavior::EnergyGrab => "Disables guns", WeaponBehavior::EnergyGrab => "Disables guns",
WeaponBehavior::Scripted => "Custom discharge",
}; };
stats.push(format!( stats.push(format!(
"{}{}", "{}{}",
behavior, behavior,
if weapon.script.is_some() { if weapon.script.is_some() && !matches!(weapon.behavior, WeaponBehavior::Scripted) {
" / scripted" " / scripted"
} else { } else {
"" ""
+20
View File
@@ -570,6 +570,23 @@ pub enum Command {
x: i32, x: i32,
y: i32, y: i32,
}, },
/// Cosmetic lightning; pair with damage commands for gameplay effects.
Arc {
x: i32,
y: i32,
to_x: i32,
to_y: i32,
#[serde(default = "arc_ticks")]
#[schemars(range(min = 1, max = 12))]
ticks: u32,
/// Follow this entity's movement; zero is the player. Endpoints remain
/// absolute coordinates at creation, preserving their initial offset.
#[serde(default)]
anchor: Option<u64>,
/// Optional independent anchor for the destination endpoint.
#[serde(default)]
to_anchor: Option<u64>,
},
Sound { Sound {
asset: u32, asset: u32,
}, },
@@ -584,6 +601,9 @@ pub enum Command {
Complete, Complete,
Fail, Fail,
} }
fn arc_ticks() -> u32 {
3
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct HookResult { pub struct HookResult {
+350
View File
@@ -347,6 +347,356 @@ impl Canvas {
out out
} }
} }
/// Render cosmetic arcs on either the SD or HD indexed canvas. Geometry depends
/// only on the arc's coordinates, seed and age, never gameplay RNG or the
/// number of frames drawn. Ordinary palette entries avoid animated terrain hues.
pub fn draw_lightning_arcs(
canvas: &mut Canvas,
arcs: &[crate::simulation::LightningArc],
palette: &[[u8; 3]; 256],
) {
draw_lightning_arcs_at(canvas, arcs, palette, |arc| {
[
[arc.x as f32, arc.y as f32],
[arc.to_x as f32, arc.to_y as f32],
]
});
}
/// Follow attached ships at the same interpolated positions as their sprites.
/// Detached endpoints keep their last simulation position. Rendering never
/// changes arc lifetime, anchors, saved state or either gameplay RNG.
pub fn draw_world_lightning_arcs(
canvas: &mut Canvas,
world: &crate::simulation::World,
palette: &[[u8; 3]; 256],
alpha: f32,
) {
let endpoint = |anchor: Option<crate::simulation::LightningAnchor>, x: i32, y: i32| {
let Some(anchor) = anchor else {
return [x as f32, y as f32];
};
let position = if anchor.entity == 0 {
Some((
world.player.px,
world.player.py,
world.player.x,
world.player.y,
))
} else {
world
.enemies
.iter()
.find(|enemy| enemy.entity == anchor.entity)
.map(|enemy| (enemy.px, enemy.py, enemy.x, enemy.y))
};
position.map_or([x as f32, y as f32], |(px, py, current_x, current_y)| {
[
(px as f32 + (current_x as f32 - px as f32) * alpha + anchor.offset[0] as f32)
.clamp(-32768.0, 32768.0),
(py as f32 + (current_y as f32 - py as f32) * alpha + anchor.offset[1] as f32)
.clamp(-32768.0, 32768.0),
]
})
};
draw_lightning_arcs_at(canvas, &world.lightning_arcs, palette, |arc| {
[
endpoint(arc.anchor, arc.x, arc.y),
endpoint(arc.to_anchor, arc.to_x, arc.to_y),
]
});
}
fn draw_lightning_arcs_at(
canvas: &mut Canvas,
arcs: &[crate::simulation::LightningArc],
palette: &[[u8; 3]; 256],
endpoints: impl Fn(&crate::simulation::LightningArc) -> [[f32; 2]; 2],
) {
if !arcs.iter().any(|arc| arc.age < arc.ticks) || canvas.scale == 0 {
return;
}
// Keep a light field until all channels have been drawn. Taking the maximum
// avoids bright beads at every segment join, and quantizes the glow only
// once rather than losing its falloff to repeated palette rounding.
let mut light = vec![0u8; canvas.image.pixels.len()];
let falloff: [u8; 513] = std::array::from_fn(|i| {
let distance_squared = i as f32 / 32.0;
(255.0 * (0.86 * (-distance_squared / 0.55).exp() + 0.14 * (-distance_squared / 4.5).exp()))
.round() as u8
});
for arc in arcs.iter().filter(|arc| arc.age < arc.ticks) {
let [from, to] = endpoints(arc);
let direction = [to[0] - from[0], to[1] - from[1]];
let length = direction[0].hypot(direction[1]);
let mut noise = (arc.x as u32).wrapping_mul(0x9e37_79b9)
^ (arc.y as u32).wrapping_mul(0x85eb_ca6b)
^ (arc.to_x as u32).wrapping_mul(0xc2b2_ae35)
^ (arc.to_y as u32).rotate_left(11)
^ arc.age.wrapping_mul(0x27d4_eb2d)
^ arc.seed;
let depth = ((length / 2.5).max(1.0).log2().ceil() as u32).clamp(2, 7);
let mut points = vec![from];
lightning_path(
from,
to,
(length * 0.17).clamp(1.5, 18.0),
depth,
&mut noise,
&mut points,
);
let pulse = match arc.age % 3 {
0 => 1.0,
1 => 0.74,
_ => 0.92,
} * (1.0 - 0.22 * arc.age as f32 / arc.ticks as f32);
let mut last_energy = pulse;
for (i, segment) in points.windows(2).enumerate() {
let energy = if i + 2 == points.len() {
pulse
} else {
pulse * (0.86 + lightning_noise(&mut noise) * 0.14)
};
lightning_channel(
canvas,
&mut light,
&falloff,
segment,
[last_energy, energy],
1.0,
);
last_energy = energy;
}
// Fine, dying forks follow the discharge direction. They are cosmetic;
// the mod's explicit damage commands still decide which ships are hit.
if length > 4.0 {
let forward = [direction[0] / length, direction[1] / length];
for _ in 0..((length / 30.0) as usize).clamp(1, 5) {
let index = 1 + (lightning_noise(&mut noise) * (points.len() - 2) as f32) as usize;
let root = points[index];
let side = if lightning_noise(&mut noise) < 0.5 {
-1.0
} else {
1.0
};
let spread = side * (0.45 + lightning_noise(&mut noise) * 0.8);
let reach = (length * (0.2 + lightning_noise(&mut noise) * 0.3)).clamp(4.0, 28.0);
let tip = [
root[0] + (forward[0] - forward[1] * spread) * reach,
root[1] + (forward[1] + forward[0] * spread) * reach,
];
let mut fork = vec![root];
lightning_path(root, tip, reach * 0.2, 3, &mut noise, &mut fork);
for (i, segment) in fork.windows(2).enumerate() {
let taper =
|n: usize| pulse * 0.9 * (1.0 - n as f32 / (fork.len() - 1) as f32).sqrt();
lightning_channel(
canvas,
&mut light,
&falloff,
segment,
[taper(i), taper(i + 1)],
0.45,
);
}
}
}
}
// Add blue light to the existing scene, with a near-white hot core. Cache
// only the palette pairs actually used; avoid animated terrain entries.
LIGHTNING_COLORS.with(|colors| {
let mut colors = colors.borrow_mut();
let colors = colors.get_or_insert_with(|| LightningColors::new(palette));
if colors.palette != *palette {
*colors = LightningColors::new(palette);
}
for (pixel, energy) in canvas.image.pixels.iter_mut().zip(light) {
if energy >= 8 {
*pixel = colors.add_light(*pixel, usize::from(energy) / 8);
}
}
});
}
// A palette-keyed per-thread cache avoids repeating nearest-color searches
// during every flicker frame. Cached values are pure, so draw order/history
// cannot change the image; tests and previews may use different palettes.
std::thread_local! {
static LIGHTNING_COLORS: std::cell::RefCell<Option<LightningColors>> = const {
std::cell::RefCell::new(None)
};
}
struct LightningColors {
palette: [[u8; 3]; 256],
brightness: [u32; 256],
emission: [[f32; 3]; 32],
blend: [[u16; 257]; 32],
}
impl LightningColors {
fn new(palette: &[[u8; 3]; 256]) -> Self {
Self {
palette: *palette,
brightness: palette
.map(|rgb| 3 * u32::from(rgb[0]) + 6 * u32::from(rgb[1]) + u32::from(rgb[2])),
emission: std::array::from_fn(|level| {
let n = level as f32 / 31.0;
[230.0 * n.powf(2.5), 255.0 * n.powf(0.8), 255.0 * n.sqrt()]
}),
blend: [[u16::MAX; 257]; 32],
}
}
fn add_light(&mut self, pixel: u16, level: usize) -> u16 {
let background = usize::from(pixel.min(TRANSPARENT));
let result = &mut self.blend[level][background];
if *result == u16::MAX {
let (rgb, brightness) = if background < 256 {
(self.palette[background], self.brightness[background])
} else {
([0; 3], 0)
};
let target: [i32; 3] = std::array::from_fn(|i| {
(f32::from(rgb[i]) + self.emission[level][i])
.min(255.0)
.round() as i32
});
*result = self.palette[..240]
.iter()
.enumerate()
.filter(|(index, _)| self.brightness[*index] >= brightness)
.min_by_key(|(_, candidate)| {
(0..3)
.map(|i| (i32::from(candidate[i]) - target[i]).pow(2))
.sum::<i32>()
})
.map_or(pixel, |(index, _)| index as u16);
}
*result
}
}
// A local cosmetic hash stream: never touch the simulation or script RNG.
fn lightning_noise(state: &mut u32) -> f32 {
*state = state.wrapping_add(0x9e37_79b9);
let mut hash = *state;
hash = (hash ^ (hash >> 16)).wrapping_mul(0x85eb_ca6b);
hash = (hash ^ (hash >> 13)).wrapping_mul(0xc2b2_ae35);
hash ^= hash >> 16;
(hash >> 8) as f32 / 16_777_216.0
}
fn lightning_path(
from: [f32; 2],
to: [f32; 2],
displacement: f32,
depth: u32,
noise: &mut u32,
points: &mut Vec<[f32; 2]>,
) {
if depth == 0 {
points.push(to);
return;
}
let dx = to[0] - from[0];
let dy = to[1] - from[1];
let length = dx.hypot(dy).max(0.001);
let along = 0.35 + lightning_noise(noise) * 0.3;
let bend = (lightning_noise(noise) * 2.0 - 1.0) * displacement;
let mid = [
from[0] + dx * along - dy / length * bend,
from[1] + dy * along + dx / length * bend,
];
lightning_path(from, mid, displacement * 0.6, depth - 1, noise, points);
lightning_path(mid, to, displacement * 0.6, depth - 1, noise, points);
}
fn lightning_channel(
canvas: &Canvas,
light: &mut [u8],
falloff: &[u8; 513],
segment: &[[f32; 2]],
energy: [f32; 2],
width: f32,
) {
let scale = canvas.scale as f32;
let [x, y] = segment[0].map(|n| n * scale);
let [to_x, to_y] = segment[1].map(|n| n * scale);
let dx = to_x - x;
let dy = to_y - y;
// Clip before rasterizing: valid API coordinates can be well offscreen.
// Beyond 2.7 logical pixels the falloff is below the first visible level.
let pad = 2.7 * scale * width;
let mut start: f32 = 0.0;
let mut end: f32 = 1.0;
for (direction, distance) in [
(-dx, x + pad),
(dx, canvas.image.width as f32 - 1.0 + pad - x),
(-dy, y + pad),
(dy, canvas.image.height as f32 - 1.0 + pad - y),
] {
if direction == 0.0 {
if distance < 0.0 {
return;
}
} else if direction < 0.0 {
start = start.max(distance / direction);
} else {
end = end.min(distance / direction);
}
if start > end {
return;
}
}
let bounds = [
[
(x + dx * start).min(x + dx * end),
(x + dx * start).max(x + dx * end),
],
[
(y + dy * start).min(y + dy * end),
(y + dy * start).max(y + dy * end),
],
];
let major_x = dx.abs() >= dy.abs();
let (major, minor, major_origin, minor_origin, major_delta, minor_delta) = if major_x {
(0, 1, x, y, dx, dy)
} else {
(1, 0, y, x, dy, dx)
};
let size = [canvas.image.width as i32, canvas.image.height as i32];
let first = ((bounds[major][0] - pad).floor() as i32).max(0);
let last = ((bounds[major][1] + pad).ceil() as i32).min(size[major] - 1);
let length_squared = (dx * dx + dy * dy).max(0.000_001);
// Walk a narrow strip along the major axis instead of visiting the entire
// diagonal bounding rectangle. Work is bounded by the visible canvas.
for a in first..=last {
let t = if major_delta.abs() > 0.001 {
((a as f32 - major_origin) / major_delta).clamp(start, end)
} else {
start
};
let center = minor_origin + minor_delta * t;
let minor_pad = pad * std::f32::consts::SQRT_2;
let low = ((center - minor_pad).floor() as i32).max(0);
let high = ((center + minor_pad).ceil() as i32).min(size[minor] - 1);
for b in low..=high {
let (column, row) = if major_x { (a, b) } else { (b, a) };
let px = column as f32 - x;
let py = row as f32 - y;
let along = ((px * dx + py * dy) / length_squared).clamp(0.0, 1.0);
let distance_squared = ((px - dx * along).powi(2) + (py - dy * along).powi(2))
/ (scale * scale * width * width);
let lookup = (distance_squared * 32.0).round() as usize;
if let Some(brightness) = falloff.get(lookup) {
let strength = energy[0] + (energy[1] - energy[0]) * along;
let brightness = (f32::from(*brightness) * strength).round() as u8;
let pixel = &mut light[row as usize * canvas.image.width + column as usize];
*pixel = (*pixel).max(brightness);
}
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+156 -1
View File
@@ -358,6 +358,28 @@ fn visual_effect(existing: usize, effects: &mut Vec<VisualEffect>, effect: Visua
effects.push(effect); effects.push(effect);
} }
} }
/// Presentation-only lightning, with a lifetime measured in simulation ticks.
/// The queue is excluded from saved gameplay state and the deterministic digest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LightningArc {
pub x: i32,
pub y: i32,
pub to_x: i32,
pub to_y: i32,
pub ticks: u32,
pub age: u32,
/// Cosmetic variation between discharges, without advancing gameplay RNG.
pub seed: u32,
pub anchor: Option<LightningAnchor>,
pub to_anchor: Option<LightningAnchor>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LightningAnchor {
pub entity: u64,
/// Endpoint displacement from the entity's top-left origin at creation.
pub offset: [i32; 2],
}
pub const MAX_LIGHTNING_ARCS: usize = 256;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum EffectKind { pub enum EffectKind {
GroundBlast, GroundBlast,
@@ -513,6 +535,8 @@ pub struct World {
#[serde(skip)] #[serde(skip)]
pub visual_effects: Vec<VisualEffect>, pub visual_effects: Vec<VisualEffect>,
#[serde(skip)] #[serde(skip)]
pub lightning_arcs: Vec<LightningArc>,
#[serde(skip)]
terrain_spark_delay: u32, terrain_spark_delay: u32,
#[serde(skip)] #[serde(skip)]
terrain_flare_delay: u32, terrain_flare_delay: u32,
@@ -607,6 +631,7 @@ impl World {
pickups: vec![], pickups: vec![],
effects: vec![], effects: vec![],
visual_effects: vec![], visual_effects: vec![],
lightning_arcs: vec![],
terrain_spark_delay: 0, terrain_spark_delay: 0,
terrain_flare_delay: 0, terrain_flare_delay: 0,
terrain_health: m.layout.iter().map(|i| m.tiles[*i].health).collect(), terrain_health: m.layout.iter().map(|i| m.tiles[*i].health).collect(),
@@ -1327,6 +1352,8 @@ impl World {
} }
} }
self.events.extend(events); self.events.extend(events);
// Capture the final position before retiring an anchor's enemy.
self.update_lightning_arcs();
self.enemies.retain(|e| !retired.contains(&e.entity)); self.enemies.retain(|e| !retired.contains(&e.entity));
if self.legacy_transition if self.legacy_transition
&& mission.auto_complete && mission.auto_complete
@@ -1349,6 +1376,9 @@ impl World {
self.event(definition,"on_fire",serde_json::json!({"entity":entity,"weapon":id,"x":x,"y":y,"hostile":true,"tick":self.tick})); self.event(definition,"on_fire",serde_json::json!({"entity":entity,"weapon":id,"x":x,"y":y,"hostile":true,"tick":self.tick}));
} }
} }
if matches!(registry.weapons[&id].behavior, WeaponBehavior::Scripted) {
continue;
}
if let Some((kind, entity, speed, gun)) = legacy.filter(|(kind, _, _, _)| *kind >= 0) { if let Some((kind, entity, speed, gun)) = legacy.filter(|(kind, _, _, _)| *kind >= 0) {
self.enemy_projectile(&id, x, y, kind, entity, speed, gun)?; self.enemy_projectile(&id, x, y, kind, entity, speed, gun)?;
continue; continue;
@@ -1625,6 +1655,10 @@ impl World {
} }
self.visual_effects self.visual_effects
.retain(|effect| effect.age <= effect.kind.artwork().1); .retain(|effect| effect.age <= effect.kind.artwork().1);
for arc in &mut self.lightning_arcs {
arc.age += 1;
}
self.lightning_arcs.retain(|arc| arc.age < arc.ticks);
if self.difficulty < 3 { if self.difficulty < 3 {
if equipment_used { if equipment_used {
self.shield_recharge_ticks = 0; self.shield_recharge_ticks = 0;
@@ -1720,6 +1754,7 @@ impl World {
} }
self.previous_shield = self.player.shield; self.previous_shield = self.player.shield;
self.sync_retained_inventory(); self.sync_retained_inventory();
self.update_lightning_arcs();
self.event(self.mission.clone(),"on_tick",serde_json::json!({"tick":self.tick,"player_x":self.player.x,"player_y":self.player.y,"enemies":self.enemies.len()})); self.event(self.mission.clone(),"on_tick",serde_json::json!({"tick":self.tick,"player_x":self.player.x,"player_y":self.player.y,"enemies":self.enemies.len()}));
if self.outcome != Outcome::Playing { if self.outcome != Outcome::Playing {
self.event( self.event(
@@ -1856,6 +1891,14 @@ impl World {
return Ok(()); return Ok(());
} }
self.cooldowns.insert(id.clone(), w.cooldown); self.cooldowns.insert(id.clone(), w.cooldown);
if matches!(w.behavior, WeaponBehavior::Scripted) {
self.event(
id.clone(),
"on_fire",
serde_json::json!({"entity":0,"hostile":false,"x":self.player.x+16,"y":self.player.y,"tick":self.tick}),
);
return Ok(());
}
if let Some(index) = w.legacy { if let Some(index) = w.legacy {
if let Some(legacy) = crate::legacy_tables::weapons() if let Some(legacy) = crate::legacy_tables::weapons()
.into_iter() .into_iter()
@@ -2839,6 +2882,14 @@ impl World {
let owner = &registry.owners[&event.content]; let owner = &registry.owners[&event.content];
let mut e = event.event; let mut e = event.event;
e["entities"] = serde_json::to_value(&self.enemies)?; e["entities"] = serde_json::to_value(&self.enemies)?;
for (snapshot, enemy) in e["entities"]
.as_array_mut()
.expect("enemy snapshot array")
.iter_mut()
.zip(&self.enemies)
{
snapshot["ground"] = registry.enemies[&enemy.definition].ground.into();
}
e["player"] = serde_json::to_value(&self.player)?; e["player"] = serde_json::to_value(&self.player)?;
e["objectives"] = serde_json::to_value(&self.objectives)?; e["objectives"] = serde_json::to_value(&self.objectives)?;
e["projectiles"] = serde_json::to_value(&self.projectiles)?; e["projectiles"] = serde_json::to_value(&self.projectiles)?;
@@ -2847,7 +2898,10 @@ impl World {
e["tick"] = self.tick.into(); e["tick"] = self.tick.into();
} }
let commands = scripts.invoke(owner, path, event.hook, &e, self.rng.seed)?; let commands = scripts.invoke(owner, path, event.hook, &e, self.rng.seed)?;
self.apply_commands(&commands, registry)?; self.apply_commands(&commands, registry).map_err(|error| {
let context = format!("{owner}/{path}::{}: {error}", event.hook);
error.context(context)
})?;
} }
} }
} }
@@ -2857,10 +2911,77 @@ impl World {
let mut candidate = self.clone(); let mut candidate = self.clone();
for command in commands { for command in commands {
candidate.command(command, registry)?; candidate.command(command, registry)?;
if matches!(command, Command::Move { .. } | Command::Despawn { .. }) {
candidate.update_lightning_arcs();
}
} }
*self = candidate; *self = candidate;
Ok(()) Ok(())
} }
fn lightning_anchor(
&self,
entity: Option<u64>,
x: i32,
y: i32,
) -> Result<Option<LightningAnchor>> {
let Some(entity) = entity else {
return Ok(None);
};
let [origin_x, origin_y] = if entity == 0 {
[self.player.x, self.player.y]
} else {
let enemy = self
.enemies
.iter()
.find(|enemy| enemy.entity == entity)
.context("arc anchor does not exist")?;
[enemy.x, enemy.y]
};
Ok(Some(LightningAnchor {
entity,
offset: [
x.checked_sub(origin_x)
.context("arc anchor offset overflow")?,
y.checked_sub(origin_y)
.context("arc anchor offset overflow")?,
],
}))
}
fn update_lightning_arcs(&mut self) {
let player = [self.player.x, self.player.y];
let enemies = &self.enemies;
for arc in &mut self.lightning_arcs {
for (anchor, x, y) in [
(&mut arc.anchor, &mut arc.x, &mut arc.y),
(&mut arc.to_anchor, &mut arc.to_x, &mut arc.to_y),
] {
let Some(attachment) = *anchor else {
continue;
};
let origin = if attachment.entity == 0 {
Some(player)
} else {
enemies
.iter()
.find(|enemy| enemy.entity == attachment.entity)
.map(|enemy| [enemy.x, enemy.y])
};
if let Some([origin_x, origin_y]) = origin {
// An offscreen anchor cannot expand the cosmetic API's
// coordinate bounds or make movement fail gameplay.
*x = origin_x
.saturating_add(attachment.offset[0])
.clamp(-32768, 32768);
*y = origin_y
.saturating_add(attachment.offset[1])
.clamp(-32768, 32768);
} else {
// Retain the last visible position when its ship disappears.
*anchor = None;
}
}
}
}
fn command(&mut self, c: &Command, registry: &Registry) -> Result<()> { fn command(&mut self, c: &Command, registry: &Registry) -> Result<()> {
match c { match c {
Command::Spawn { enemy, x, y } => { Command::Spawn { enemy, x, y } => {
@@ -3057,6 +3178,40 @@ impl World {
ensure!(self.effects.len() < 4096, "too many effects"); ensure!(self.effects.len() < 4096, "too many effects");
self.effects.push(Effect::new(EffectKind::AirBlast, *x, *y)); self.effects.push(Effect::new(EffectKind::AirBlast, *x, *y));
} }
Command::Arc {
x,
y,
to_x,
to_y,
ticks,
anchor,
to_anchor,
} => {
ensure!(
[x, y, to_x, to_y].iter().all(|n| n.unsigned_abs() <= 32768),
"arc position exceeds world bounds"
);
ensure!((1..=12).contains(ticks), "arc lifetime must be 1–12 ticks");
ensure!(
self.lightning_arcs.len() < MAX_LIGHTNING_ARCS,
"too many lightning arcs"
);
let anchor = self.lightning_anchor(*anchor, *x, *y)?;
let to_anchor = self.lightning_anchor(*to_anchor, *to_x, *to_y)?;
self.lightning_arcs.push(LightningArc {
x: *x,
y: *y,
to_x: *to_x,
to_y: *to_y,
ticks: *ticks,
age: 0,
seed: (self.tick as u32).wrapping_mul(0x1656_67b1)
^ ((self.tick >> 32) as u32)
^ (self.lightning_arcs.len() as u32).wrapping_mul(0x9e37_79b9),
anchor,
to_anchor,
});
}
Command::Sound { asset } => { Command::Sound { asset } => {
self.sounds.push(*asset); self.sounds.push(*asset);
self.sound_events.push(SoundEvent { self.sound_events.push(SoundEvent {
+949
View File
@@ -0,0 +1,949 @@
use raptor_replica::{
content::{ContentId, ContentPack, Registry},
modding::{Command, ScriptRuntime},
persistence::Pilot,
render::{Canvas, draw_lightning_arcs, draw_world_lightning_arcs},
simulation::{Input, LightningArc, MAX_LIGHTNING_ARCS, Outcome, World},
};
use serde_json::json;
fn registry() -> Registry {
let mut registry = Registry::default();
let mut pack: ContentPack = serde_json::from_value(json!({
"weapons":[
{"id":"test:selected","name":"Selected","damage":20,"speed":0,"cooldown":4,
"behavior":"scripted","target":"all","script":"selected.rhai"},
{"id":"test:equipped","name":"Equipped","damage":2,"speed":0,"cooldown":2,
"behavior":"scripted","target":"air","script":"equipped.rhai"}
],
"items":[
{"id":"test:gun","name":"Gun","price":100,"weapon":"test:selected","special":true},
{"id":"test:shield","name":"Shield","price":100,"weapon":"test:equipped"}
],
"enemies":[
{"id":"test:air","name":"Air","health":100,"speed":0,"cooldown":2,"weapon":"test:selected"},
{"id":"test:ground","name":"Ground","health":100,"speed":0,"ground":true}
],
"missions":[{"id":"base:sector_1_wave_1","name":"Test","duration":1000}]
})).unwrap();
let missions = std::mem::take(&mut pack.missions);
registry
.apply(
ContentPack {
missions,
..ContentPack::default()
},
"base",
)
.unwrap();
registry.apply(pack, "test").unwrap();
registry.validate().unwrap();
registry
}
fn world(registry: &Registry) -> World {
let mut pilot = Pilot::default();
pilot.inventory.clear();
pilot.inventory.insert("base:energy".into(), 100);
pilot.inventory.insert("test:gun".into(), 1);
pilot.inventory.insert("test:shield".into(), 1);
pilot.selected = Some("test:gun".into());
World::new(&pilot.mission, &pilot, registry).unwrap()
}
fn arc(ticks: u32) -> Command {
Command::Arc {
x: 160,
y: 160,
to_x: 160,
to_y: 30,
ticks,
anchor: None,
to_anchor: None,
}
}
#[test]
fn scripted_selected_and_equipped_weapons_follow_input_and_cooldowns_without_projectiles() {
let registry = registry();
let mut world = world(&registry);
let mut runtime = ScriptRuntime::new();
for path in ["selected.rhai", "equipped.rhai"] {
runtime
.add(
"test",
path,
r#"fn on_fire(e,s,r) {
let count=if s.contains("count") {s.count+1} else {1};
#{commands:[],state:#{count:count,hostile:e.hostile,entity:e.entity}}
}"#,
)
.unwrap();
}
world
.step(Input::default(), &registry, &mut runtime)
.unwrap();
assert!(runtime.states.is_empty());
for _ in 0..9 {
world
.step(
Input {
fire: true,
..Input::default()
},
&registry,
&mut runtime,
)
.unwrap();
}
assert_eq!(runtime.states["test/selected.rhai"]["count"], 3);
assert_eq!(runtime.states["test/equipped.rhai"]["count"], 5);
assert_eq!(runtime.states["test/selected.rhai"]["entity"], 0);
assert_eq!(runtime.states["test/selected.rhai"]["hostile"], false);
assert!(world.projectiles.is_empty());
// Special input fires the selected weapon while ordinary equipment remains idle.
for _ in 0..4 {
world
.step(
Input {
special: true,
..Input::default()
},
&registry,
&mut runtime,
)
.unwrap();
}
assert_eq!(runtime.states["test/selected.rhai"]["count"], 4);
assert_eq!(runtime.states["test/equipped.rhai"]["count"], 5);
assert!(world.projectiles.is_empty());
}
#[test]
fn scripted_enemy_weapons_emit_fire_hooks_without_native_projectiles() {
let registry = registry();
let mut world = world(&registry);
let entity = world
.spawn(&ContentId::from("test:air"), 30, 30, &registry)
.unwrap();
let mut runtime = ScriptRuntime::new();
runtime
.add(
"test",
"selected.rhai",
r#"fn on_fire(e,s,r) {
#{commands:[],state:#{hostile:e.hostile,entity:e.entity}}
}"#,
)
.unwrap();
world
.step(Input::default(), &registry, &mut runtime)
.unwrap();
assert_eq!(runtime.states["test/selected.rhai"]["entity"], entity);
assert_eq!(runtime.states["test/selected.rhai"]["hostile"], true);
assert!(world.projectiles.is_empty());
}
#[test]
fn enemy_snapshots_expose_registry_ground_flags() {
let mut registry = registry();
registry
.missions
.get_mut(&ContentId::from("base:sector_1_wave_1"))
.unwrap()
.script = Some("queries.rhai".into());
let mut world = world(&registry);
world
.spawn(&ContentId::from("test:air"), 30, 30, &registry)
.unwrap();
world
.spawn(&ContentId::from("test:ground"), 90, 30, &registry)
.unwrap();
let mut runtime = ScriptRuntime::new();
runtime.add("test", "selected.rhai", "").unwrap();
runtime
.add(
"base",
"queries.rhai",
r#"fn on_mission_start(e,s,r) {
#{commands:[],state:#{air:e.entities[0].ground,ground:e.entities[1].ground}}
}"#,
)
.unwrap();
world
.step(Input::default(), &registry, &mut runtime)
.unwrap();
assert_eq!(
runtime.states["base/queries.rhai"],
json!({"air":false,"ground":true})
);
}
#[test]
fn scripted_behavior_requires_a_script_and_arc_defaults_to_three_ticks() {
let mut registry = registry();
registry
.weapons
.get_mut(&ContentId::from("test:selected"))
.unwrap()
.script = None;
assert!(
registry
.validate()
.unwrap_err()
.to_string()
.contains("requires a script")
);
let command: Command = serde_json::from_value(json!({
"op":"arc","x":0,"y":0,"to_x":20,"to_y":20
}))
.unwrap();
assert!(matches!(
command,
Command::Arc {
ticks: 3,
anchor: None,
to_anchor: None,
..
}
));
}
#[test]
fn arcs_validate_coordinates_lifetime_and_capacity_transactionally() {
let registry = registry();
let mut world = world(&registry);
let before = world.digest();
for bad in [
arc(0),
arc(13),
Command::Arc {
x: i32::MIN,
y: 0,
to_x: 0,
to_y: 0,
ticks: 3,
anchor: None,
to_anchor: None,
},
Command::Arc {
x: 0,
y: 32769,
to_x: 0,
to_y: 0,
ticks: 3,
anchor: None,
to_anchor: None,
},
Command::Arc {
x: 0,
y: 0,
to_x: -32769,
to_y: 0,
ticks: 3,
anchor: None,
to_anchor: None,
},
Command::Arc {
x: 0,
y: 0,
to_x: 0,
to_y: 32769,
ticks: 3,
anchor: None,
to_anchor: None,
},
] {
assert!(
world
.apply_commands(&[arc(3), Command::Credits { amount: 5 }, bad], &registry)
.is_err()
);
assert_eq!(world.digest(), before);
assert!(world.lightning_arcs.is_empty());
}
world
.apply_commands(
&[Command::Arc {
x: -32768,
y: 32768,
to_x: 32768,
to_y: -32768,
ticks: 12,
anchor: None,
to_anchor: None,
}],
&registry,
)
.unwrap();
world
.apply_commands(&vec![arc(3); MAX_LIGHTNING_ARCS - 1], &registry)
.unwrap();
assert!(
world
.apply_commands(&[Command::Credits { amount: 5 }, arc(3)], &registry)
.is_err()
);
assert_eq!(world.lightning_arcs.len(), MAX_LIGHTNING_ARCS);
assert_eq!(world.digest(), before);
}
#[test]
fn invalid_arc_hook_rolls_back_world_script_state_and_rng() {
let registry = registry();
let mut world = world(&registry);
let before = world.digest();
let mut runtime = ScriptRuntime::new();
runtime.add("test", "equipped.rhai", "").unwrap();
runtime
.add(
"test",
"selected.rhai",
r#"fn on_fire(e,s,r) {
#{commands:[#{op:"arc",x:0,y:0,to_x:10,to_y:10},
#{op:"credits",amount:5},
#{op:"arc",x:0,y:0,to_x:10,to_y:10,ticks:13}],
state:#{used:r[0]}}
}"#,
)
.unwrap();
let error = world
.step(
Input {
fire: true,
..Input::default()
},
&registry,
&mut runtime,
)
.unwrap_err();
assert!(error.to_string().contains("arc lifetime"), "{error:#}");
assert!(error.to_string().contains("test/selected.rhai::on_fire"));
assert_eq!(world.digest(), before);
assert!(world.lightning_arcs.is_empty());
assert!(runtime.states.is_empty());
assert!(runtime.rng.is_empty());
}
#[test]
fn arc_lifetime_uses_simulation_ticks_and_never_changes_gameplay_state() {
let registry = registry();
let mut world = world(&registry);
let mut plain = world.clone();
world.apply_commands(&[arc(1), arc(3)], &registry).unwrap();
assert_eq!(world.digest(), plain.digest());
assert!(
serde_json::to_value(&world)
.unwrap()
.get("lightning_arcs")
.is_none()
);
let mut runtime = ScriptRuntime::new();
let mut plain_runtime = ScriptRuntime::new();
for expected in [1, 1, 0] {
world
.step(Input::default(), &registry, &mut runtime)
.unwrap();
plain
.step(Input::default(), &registry, &mut plain_runtime)
.unwrap();
assert_eq!(world.lightning_arcs.len(), expected);
assert_eq!(world.digest(), plain.digest());
assert_eq!(world.rng, plain.rng);
}
world.apply_commands(&[arc(3)], &registry).unwrap();
world.outcome = Outcome::Complete;
world
.step(Input::default(), &registry, &mut runtime)
.unwrap();
assert_eq!(world.lightning_arcs[0].age, 0);
}
#[test]
fn successive_discharge_seeds_vary_without_using_gameplay_randomness() {
let registry = registry();
let mut world = world(&registry);
let rng = world.rng;
let mut replay = world.clone();
for _ in 0..3 {
let previous = world.lightning_arcs.last().map(|arc| arc.seed);
world.apply_commands(&[arc(3)], &registry).unwrap();
replay.apply_commands(&[arc(3)], &registry).unwrap();
assert_ne!(previous, Some(world.lightning_arcs.last().unwrap().seed));
assert_eq!(world.lightning_arcs, replay.lightning_arcs);
assert_eq!(world.rng, rng);
}
let first = world.lightning_arcs[0].seed;
world.lightning_arcs.clear();
world
.step(Input::default(), &registry, &mut ScriptRuntime::new())
.unwrap();
world.apply_commands(&[arc(3)], &registry).unwrap();
assert_ne!(first, world.lightning_arcs[0].seed);
}
#[test]
fn lightning_renderer_draws_visible_jagged_cyan_white_lines_at_all_scales_without_mutation() {
let mut palette = [[0; 3]; 256];
palette[1] = [24, 198, 255];
palette[2] = [230, 255, 255];
let arcs = vec![LightningArc {
x: 20,
y: 160,
to_x: 200,
to_y: 30,
ticks: 3,
age: 0,
seed: 0,
anchor: None,
to_anchor: None,
}];
for scale in [1, 2, 3, 4] {
let mut canvas = Canvas::new(scale);
draw_lightning_arcs(&mut canvas, &arcs, &palette);
assert!(canvas.image.pixels.contains(&1));
assert!(canvas.image.pixels.contains(&2));
assert_eq!(
canvas.image.pixels[160 * scale * canvas.image.width + 20 * scale],
2
);
assert_eq!(
canvas.image.pixels[30 * scale * canvas.image.width + 200 * scale],
2
);
let first = canvas.image.clone();
canvas.clear(0);
draw_lightning_arcs(&mut canvas, &arcs, &palette);
assert_eq!(canvas.image, first);
// Reusing a renderer cache across palettes must not reuse old indices.
let mut alternate = [[0; 3]; 256];
alternate[1] = palette[2];
canvas.clear(0);
draw_lightning_arcs(&mut canvas, &arcs, &alternate);
assert_eq!(
canvas.image.pixels[160 * scale * canvas.image.width + 20 * scale],
1
);
canvas.clear(0);
draw_lightning_arcs(&mut canvas, &arcs, &palette);
assert_eq!(canvas.image, first);
let mut later = arcs.clone();
later[0].age = 1;
canvas.clear(0);
draw_lightning_arcs(&mut canvas, &later, &palette);
assert_ne!(canvas.image, first);
later[0].age = 0;
later[0].seed = 12345;
canvas.clear(0);
draw_lightning_arcs(&mut canvas, &later, &palette);
assert_ne!(canvas.image, first);
}
assert_eq!(arcs[0].age, 0);
let mut canvas = Canvas::new(4);
let offscreen = [LightningArc {
x: -32768,
y: -32768,
to_x: 32768,
to_y: 32768,
ticks: 3,
age: 0,
seed: 0,
anchor: None,
to_anchor: None,
}];
draw_lightning_arcs(&mut canvas, &offscreen, &palette);
assert!(canvas.image.pixels.contains(&2));
canvas.clear(0);
let expired = [LightningArc {
age: 3,
..arcs[0].clone()
}];
draw_lightning_arcs(&mut canvas, &expired, &palette);
assert!(canvas.image.pixels.iter().all(|p| *p == 0));
}
#[test]
fn lightning_glow_has_falloff_lights_background_and_avoids_animated_colors() {
let mut palette = [[0; 3]; 256];
for (i, rgb) in palette[..240].iter_mut().enumerate() {
*rgb = [i as u8 / 2, i as u8, i as u8];
}
palette[239] = [255; 3];
palette[240..].fill([230, 255, 255]); // Deliberately perfect, but reserved.
let arcs = [LightningArc {
x: 80,
y: 160,
to_x: 160,
to_y: 20,
ticks: 3,
age: 0,
seed: 4,
anchor: None,
to_anchor: None,
}];
let brightness =
|rgb: [u8; 3]| 3 * u32::from(rgb[0]) + 6 * u32::from(rgb[1]) + u32::from(rgb[2]);
for scale in [1, 4] {
let mut canvas = Canvas::new(scale);
canvas.clear(40);
draw_lightning_arcs(&mut canvas, &arcs, &palette);
assert_eq!(canvas.image.pixels[0], 40);
let colors: std::collections::BTreeSet<_> = canvas.image.pixels.iter().copied().collect();
assert!(
colors.len() > 6,
"glow must have a gradient, not a flat outline"
);
assert!(colors.iter().all(|index| *index < 240));
assert!(
colors
.iter()
.all(|index| brightness(palette[*index as usize]) >= brightness(palette[40]))
);
canvas.clear(239);
draw_lightning_arcs(&mut canvas, &arcs, &palette);
assert!(canvas.image.pixels.iter().all(|index| *index == 239));
}
}
#[test]
fn lightning_handles_point_discharges_and_fully_offscreen_segments() {
let mut palette = [[0; 3]; 256];
palette[1] = [24, 198, 255];
palette[2] = [230, 255, 255];
let mut canvas = Canvas::new(4);
let point = LightningArc {
x: 0,
y: 100,
to_x: 0,
to_y: 100,
ticks: 3,
age: 0,
seed: 1,
anchor: None,
to_anchor: None,
};
draw_lightning_arcs(&mut canvas, &[point.clone()], &palette);
assert_eq!(canvas.image.pixels[400 * canvas.image.width], 2);
assert_eq!(canvas.image.pixels[0], 0);
canvas.clear(0);
let offscreen = LightningArc {
x: -32768,
y: -32768,
to_x: -32000,
to_y: -32000,
..point
};
draw_lightning_arcs(&mut canvas, &[offscreen], &palette);
assert!(canvas.image.pixels.iter().all(|pixel| *pixel == 0));
}
fn anchored_arc(anchor: Option<u64>, to_anchor: Option<u64>) -> Command {
Command::Arc {
x: 160,
y: 179,
to_x: 86,
to_y: 56,
ticks: 8,
anchor,
to_anchor,
}
}
#[test]
fn anchored_arcs_preserve_absolute_creation_positions_and_follow_both_ships() {
let registry = registry();
let mut world = world(&registry);
let entity = world
.spawn(&ContentId::from("test:ground"), 70, 40, &registry)
.unwrap();
let fixed = arc(8);
world
.apply_commands(
&[
anchored_arc(Some(0), Some(entity)),
anchored_arc(Some(0), Some(0)),
fixed,
Command::Velocity {
entity,
vx: 3,
vy: -2,
},
],
&registry,
)
.unwrap();
assert_eq!(
(world.lightning_arcs[0].x, world.lightning_arcs[0].y),
(160, 179)
);
assert_eq!(
(world.lightning_arcs[0].to_x, world.lightning_arcs[0].to_y),
(86, 56)
);
assert_eq!(world.lightning_arcs[0].anchor.unwrap().offset, [16, 19]);
assert_eq!(world.lightning_arcs[0].to_anchor.unwrap().offset, [16, 16]);
let mut runtime = ScriptRuntime::new();
world
.step(
Input {
absolute: Some([110, 130]),
..Input::default()
},
&registry,
&mut runtime,
)
.unwrap();
assert_eq!(
(world.lightning_arcs[0].x, world.lightning_arcs[0].y),
(126, 149)
);
assert_eq!(
(world.lightning_arcs[0].to_x, world.lightning_arcs[0].to_y),
(89, 54)
);
// Both ends of a ring segment retain their offset from the player origin.
assert_eq!(
(world.lightning_arcs[1].x, world.lightning_arcs[1].y),
(126, 149)
);
assert_eq!(
(world.lightning_arcs[1].to_x, world.lightning_arcs[1].to_y),
(52, 26)
);
// Existing unanchored commands remain fixed in world space.
assert_eq!(
(world.lightning_arcs[2].x, world.lightning_arcs[2].y),
(160, 160)
);
assert_eq!(
(world.lightning_arcs[2].to_x, world.lightning_arcs[2].to_y),
(160, 30)
);
assert!(world.lightning_arcs.iter().all(|arc| arc.age == 1));
}
#[test]
fn attached_lightning_renders_at_interpolated_ship_positions_without_mutating_world() {
let registry = registry();
let mut world = world(&registry);
let entity = world
.spawn(&ContentId::from("test:ground"), 70, 40, &registry)
.unwrap();
let point = |x, y, anchor| Command::Arc {
x,
y,
to_x: x,
to_y: y,
ticks: 8,
anchor,
to_anchor: anchor,
};
world
.apply_commands(
&[
point(160, 179, Some(0)),
point(86, 56, Some(entity)),
point(240, 80, None),
Command::Velocity {
entity,
vx: 4,
vy: 2,
},
],
&registry,
)
.unwrap();
world
.step(
Input {
absolute: Some([154, 156]),
..Input::default()
},
&registry,
&mut ScriptRuntime::new(),
)
.unwrap();
let before = world.digest();
let arcs_before = world.lightning_arcs.clone();
let mut palette = [[0; 3]; 256];
palette[1] = [24, 198, 255];
palette[2] = [230, 255, 255];
for scale in [1, 4] {
for (alpha, player_point, enemy_point) in [
(0.0, [160, 179], [86, 56]),
(0.5, [165, 177], [88, 57]),
(1.0, [170, 175], [90, 58]),
] {
let mut canvas = Canvas::new(scale);
draw_world_lightning_arcs(&mut canvas, &world, &palette, alpha);
// Point discharges have no directional noise: compare the complete
// image with fixed discharges at the independently expected sprite
// positions, including the endpoint that is not attached to a ship.
let mut expected_arcs = arcs_before.clone();
for (arc, [x, y]) in
expected_arcs
.iter_mut()
.zip([player_point, enemy_point, [240, 80]])
{
arc.x = x;
arc.y = y;
arc.to_x = x;
arc.to_y = y;
arc.anchor = None;
arc.to_anchor = None;
}
let mut expected = Canvas::new(scale);
draw_lightning_arcs(&mut expected, &expected_arcs, &palette);
assert_eq!(
canvas.image.pixels, expected.image.pixels,
"attached arc drift at scale {scale}, alpha {alpha}"
);
assert_eq!(world.digest(), before);
assert_eq!(world.lightning_arcs, arcs_before);
}
}
world
.apply_commands(&[Command::Despawn { entity }], &registry)
.unwrap();
let mut canvas = Canvas::new(4);
draw_world_lightning_arcs(&mut canvas, &world, &palette, 0.0);
let frozen = &world.lightning_arcs[1];
assert!(frozen.anchor.is_none() && frozen.to_anchor.is_none());
assert_ne!(
canvas.image.pixels[frozen.y as usize * 4 * canvas.image.width + frozen.x as usize * 4],
0
);
}
#[test]
fn moved_then_despawned_anchors_freeze_at_the_last_command_position() {
let registry = registry();
let mut world = world(&registry);
let entity = world
.spawn(&ContentId::from("test:ground"), 70, 40, &registry)
.unwrap();
world
.apply_commands(
&[
anchored_arc(Some(0), Some(entity)),
Command::Move {
entity,
x: 90,
y: 70,
},
Command::Despawn { entity },
],
&registry,
)
.unwrap();
assert_eq!(
(world.lightning_arcs[0].to_x, world.lightning_arcs[0].to_y),
(106, 86)
);
assert!(world.lightning_arcs[0].to_anchor.is_none());
let mut runtime = ScriptRuntime::new();
world
.step(
Input {
absolute: Some([120, 140]),
..Input::default()
},
&registry,
&mut runtime,
)
.unwrap();
assert_eq!(
(world.lightning_arcs[0].x, world.lightning_arcs[0].y),
(136, 159)
);
assert_eq!(
(world.lightning_arcs[0].to_x, world.lightning_arcs[0].to_y),
(106, 86)
);
}
#[test]
fn automatically_retired_enemy_anchors_freeze_at_their_final_simulation_position() {
let registry = registry();
let mut world = world(&registry);
let entity = world
.spawn(&ContentId::from("test:ground"), 70, 240, &registry)
.unwrap();
let command = serde_json::from_value(json!({
"op":"arc","x":160,"y":160,"to_x":86,"to_y":256,
"to_anchor":entity,"ticks":8
}))
.unwrap();
world
.apply_commands(
&[
command,
Command::Velocity {
entity,
vx: 0,
vy: 4,
},
],
&registry,
)
.unwrap();
let mut runtime = ScriptRuntime::new();
world
.step(Input::default(), &registry, &mut runtime)
.unwrap();
assert!(world.enemies.is_empty());
assert_eq!(
(world.lightning_arcs[0].to_x, world.lightning_arcs[0].to_y),
(86, 260)
);
assert!(world.lightning_arcs[0].to_anchor.is_none());
world
.step(Input::default(), &registry, &mut runtime)
.unwrap();
assert_eq!(
(world.lightning_arcs[0].to_x, world.lightning_arcs[0].to_y),
(86, 260)
);
}
#[test]
fn missing_anchors_reject_the_entire_batch_and_do_not_accept_projectile_ids() {
let registry = registry();
let mut world = world(&registry);
let before = world.digest();
for command in [
anchored_arc(Some(u64::MAX), None),
anchored_arc(Some(0), Some(999)),
] {
let error = world
.apply_commands(
&[
anchored_arc(Some(0), Some(0)),
Command::Credits { amount: 50 },
command,
],
&registry,
)
.unwrap_err();
assert!(error.to_string().contains("arc anchor does not exist"));
assert_eq!(world.digest(), before);
assert!(world.lightning_arcs.is_empty());
}
world
.apply_commands(
&[Command::Projectile {
weapon: "test:selected".into(),
x: 10,
y: 10,
vx: 0,
vy: 0,
hostile: false,
}],
&registry,
)
.unwrap();
let projectile = world.projectiles[0].entity;
assert!(
world
.apply_commands(&[anchored_arc(None, Some(projectile))], &registry)
.is_err()
);
assert!(
serde_json::from_value::<Command>(json!({
"op":"arc","x":0,"y":0,"to_x":1,"to_y":1,"anchor":-1
}))
.is_err()
);
}
#[test]
fn bad_anchor_callbacks_restore_already_moved_arcs_and_script_rng() {
let registry = registry();
let mut world = world(&registry);
world
.apply_commands(&[anchored_arc(Some(0), Some(0))], &registry)
.unwrap();
let before = world.digest();
let arcs_before = world.lightning_arcs.clone();
let mut runtime = ScriptRuntime::new();
runtime.add("test", "equipped.rhai", "").unwrap();
runtime
.add(
"test",
"selected.rhai",
r#"fn on_fire(e,s,r) {
#{commands:[#{op:"arc",x:e.x,y:e.y,to_x:10,to_y:10,anchor:0,to_anchor:999}],
state:#{used:r[0]}}
}"#,
)
.unwrap();
let error = world
.step(
Input {
fire: true,
absolute: Some([110, 130]),
..Input::default()
},
&registry,
&mut runtime,
)
.unwrap_err();
assert!(error.to_string().contains("arc anchor does not exist"));
assert!(error.to_string().contains("test/selected.rhai::on_fire"));
assert_eq!(world.digest(), before);
assert_eq!(world.lightning_arcs, arcs_before);
assert!(runtime.states.is_empty());
assert!(runtime.rng.is_empty());
}
#[test]
fn following_arcs_remain_cosmetic_and_clamp_extreme_endpoints_to_world_bounds() {
let registry = registry();
let mut world = world(&registry);
let mut plain = world.clone();
world
.apply_commands(
&[Command::Arc {
x: 32768,
y: -32768,
to_x: -32768,
to_y: 32768,
ticks: 8,
anchor: Some(0),
to_anchor: Some(0),
}],
&registry,
)
.unwrap();
assert_eq!(world.digest(), plain.digest());
assert!(
serde_json::to_value(&world)
.unwrap()
.get("lightning_arcs")
.is_none()
);
let mut runtime = ScriptRuntime::new();
let mut plain_runtime = ScriptRuntime::new();
for input in [
Input {
absolute: Some([282, 160]),
..Input::default()
},
Input {
absolute: Some([5, 0]),
..Input::default()
},
] {
world.step(input, &registry, &mut runtime).unwrap();
plain.step(input, &registry, &mut plain_runtime).unwrap();
assert_eq!(world.digest(), plain.digest());
assert_eq!(world.rng, plain.rng);
assert!(world.lightning_arcs.iter().all(|arc| {
[arc.x, arc.y, arc.to_x, arc.to_y]
.iter()
.all(|n| n.unsigned_abs() <= 32768)
}));
}
assert_eq!(world.lightning_arcs[0].y, -32768);
assert_eq!(world.lightning_arcs[0].to_x, -32768);
}
+68 -2
View File
@@ -10,17 +10,19 @@ use raptor_replica::{
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::{fs, path::Path}; use std::{fs, path::Path};
const COMBAT_MODS: [&str; 6] = [ const COMBAT_MODS: [&str; 8] = [
"pulse_cannon", "pulse_cannon",
"seeker_swarm", "seeker_swarm",
"ion_lance", "ion_lance",
"lightning_cannon",
"lightning_shield",
"iron_coast_scout", "iron_coast_scout",
"iron_coast_turret", "iron_coast_turret",
"iron_coast_boss", "iron_coast_boss",
]; ];
const TICKS: usize = 700; const TICKS: usize = 700;
const HEALTH: i32 = 1_000_000; const HEALTH: i32 = 1_000_000;
const WEAPONS: [&str; 15] = [ const WEAPONS: [&str; 17] = [
"base:forward_guns", "base:forward_guns",
"base:plasma_guns", "base:plasma_guns",
"base:micro_missile", "base:micro_missile",
@@ -36,6 +38,8 @@ const WEAPONS: [&str; 15] = [
"pulse_cannon:equipment", "pulse_cannon:equipment",
"seeker_swarm:equipment", "seeker_swarm:equipment",
"ion_lance:equipment", "ion_lance:equipment",
"lightning_cannon:equipment",
"lightning_shield:equipment",
]; ];
fn fixture(active: &mut ActiveMods) { fn fixture(active: &mut ActiveMods) {
let pack: ContentPack = serde_json::from_value(json!({ let pack: ContentPack = serde_json::from_value(json!({
@@ -188,6 +192,40 @@ fn original_and_mod_weapons_report_controlled_damage_locally() {
true, true,
)); ));
} }
for item in ["lightning_cannon:equipment", "lightning_shield:equipment"] {
for (scene, targets) in [
("close_air", vec![("air", 188, 160)]),
("close_ground", vec![("ground", 188, 160)]),
(
"chain",
vec![
("air", 144, 80),
("ground", 184, 80),
("air", 224, 80),
("air", 264, 80),
("air", 264, 120),
],
),
(
"close_cluster",
vec![
("air", 104, 160),
("air", 184, 160),
("air", 144, 120),
("air", 144, 196),
("air", 188, 160),
],
),
] {
rows.push(observe(&mut active, item, scene, &targets, false));
}
for (scene, targets) in [
("aligned_air", vec![("air", 144, 80)]),
("close_air", vec![("air", 188, 160)]),
] {
rows.push(observe(&mut active, item, scene, &targets, true));
}
}
let report = json!({"difficulty":2,"rate":"70/3 Hz","target_size":[32,32], let report = json!({"difficulty":2,"rate":"70/3 Hz","target_size":[32,32],
"target_health":HEALTH,"fire_steps":TICKS,"rows":rows}); "target_health":HEALTH,"fire_steps":TICKS,"rows":rows});
if let Some(path) = std::env::var_os("RAPTOR_BALANCE_REPORT") { if let Some(path) = std::env::var_os("RAPTOR_BALANCE_REPORT") {
@@ -222,4 +260,32 @@ fn original_and_mod_weapons_report_controlled_damage_locally() {
"ion beam should damage both column targets equally" "ion beam should damage both column targets equally"
); );
assert_eq!(row("ion_lance:equipment", "off_axis_air")["damage"][0], 0); assert_eq!(row("ion_lance:equipment", "off_axis_air")["damage"][0], 0);
assert_eq!(
row("lightning_cannon:equipment", "off_axis_air")["damage"][0],
0
);
assert_eq!(
row("lightning_cannon:equipment", "chain")["damage"],
json!([1248, 780, 468, 312, 0])
);
assert_eq!(
row("lightning_shield:equipment", "aligned_air")["damage"][0],
0
);
assert_eq!(
row("lightning_shield:equipment", "close_air")["damage"][0],
176
);
assert_eq!(
row("lightning_shield:equipment", "close_ground")["damage"][0],
0
);
let close = row("lightning_shield:equipment", "close_cluster")["damage"]
.as_array()
.unwrap();
assert_eq!(close.iter().map(|v| v.as_i64().unwrap()).sum::<i64>(), 704);
assert_eq!(
close[4], 0,
"a fifth ship stays outside the four-target pulse cap"
);
} }
+514 -7
View File
@@ -9,7 +9,14 @@ use raptor_replica::{
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::{fs, path::Path}; use std::{fs, path::Path};
const PACKAGES: [&str; 3] = ["pulse_cannon", "seeker_swarm", "ion_lance"]; const PROJECTILE_PACKAGES: [&str; 3] = ["pulse_cannon", "seeker_swarm", "ion_lance"];
const PACKAGES: [&str; 5] = [
"pulse_cannon",
"seeker_swarm",
"ion_lance",
"lightning_cannon",
"lightning_shield",
];
fn content(value: Value) -> ContentPack { fn content(value: Value) -> ContentPack {
serde_json::from_value(value).unwrap() serde_json::from_value(value).unwrap()
} }
@@ -64,6 +71,9 @@ fn install_synthetic(root: &Path, id: &str) {
.chain(definitions["items"].as_array().unwrap()) .chain(definitions["items"].as_array().unwrap())
{ {
let art = &entry["art"]; let art = &entry["art"];
if art.is_null() {
continue;
}
for (property, scale) in [("png", 1), ("hd_png", 4)] { for (property, scale) in [("png", 1), ("hd_png", 4)] {
let image = image::RgbaImage::from_pixel( let image = image::RgbaImage::from_pixel(
art["logical_width"].as_u64().unwrap() as u32 * scale, art["logical_width"].as_u64().unwrap() as u32 * scale,
@@ -114,7 +124,7 @@ fn state<'a>(active: &'a ActiveMods, id: &str) -> &'a Value {
} }
#[test] #[test]
fn each_weapon_package_is_independent_and_the_three_can_coexist() { fn each_weapon_package_is_independent_and_all_five_can_coexist() {
for id in PACKAGES { for id in PACKAGES {
let (_root, active) = activate(&[id]); let (_root, active) = activate(&[id]);
assert_eq!(active.pins.len(), 1); assert_eq!(active.pins.len(), 1);
@@ -135,9 +145,9 @@ fn each_weapon_package_is_independent_and_the_three_can_coexist() {
); );
} }
let (_root, all) = activate(&PACKAGES); let (_root, all) = activate(&PACKAGES);
assert_eq!(all.pins.len(), 3); assert_eq!(all.pins.len(), 5);
assert_eq!(all.registry.weapons.len(), 3); assert_eq!(all.registry.weapons.len(), 5);
assert_eq!(all.registry.sounds.len(), 6); assert_eq!(all.registry.sounds.len(), 10);
assert!(all.registry.overrides.is_empty()); assert!(all.registry.overrides.is_empty());
} }
@@ -190,7 +200,7 @@ fn pulse_cannon_emits_paired_rounds_and_every_third_burst_is_scripted() {
#[test] #[test]
fn continuous_firing_has_audio_and_projectiles_without_explosions_at_the_muzzle() { fn continuous_firing_has_audio_and_projectiles_without_explosions_at_the_muzzle() {
for id in PACKAGES { for id in PROJECTILE_PACKAGES {
let (_root, mut active) = activate(&[id]); let (_root, mut active) = activate(&[id]);
let mut world = world(id, &active.registry); let mut world = world(id, &active.registry);
let mut heard_fire = false; let mut heard_fire = false;
@@ -347,7 +357,7 @@ fn equipped_specials_and_primary_upgrade_fire_without_consuming_equipment() {
.states .states
.contains_key("seeker_swarm/scripts/weapon.rhai") .contains_key("seeker_swarm/scripts/weapon.rhai")
); );
for id in PACKAGES { for id in PROJECTILE_PACKAGES {
assert_eq!( assert_eq!(
world.player.inventory[&format!("{id}:equipment").as_str().into()], world.player.inventory[&format!("{id}:equipment").as_str().into()],
1 1
@@ -406,3 +416,500 @@ fn scripted_bursts_and_state_resume_deterministically() {
serde_json::to_value(&other.scripts.rng).unwrap() serde_json::to_value(&other.scripts.rng).unwrap()
); );
} }
fn fire_once(world: &mut World, active: &mut ActiveMods) {
world
.step(
Input {
fire: true,
..Input::default()
},
&active.registry,
&mut active.scripts,
)
.unwrap();
}
#[test]
fn lightning_cannon_chains_from_each_hit_with_falling_damage_and_a_four_target_cap() {
let (_root, mut active) = activate(&["lightning_cannon"]);
let mut world = world("lightning_cannon", &active.registry);
for (enemy, x, y) in [
("base:target", 144, 80),
("base:ground_target", 184, 80),
("base:target", 224, 80),
("base:target", 264, 80),
("base:target", 264, 120),
("base:target", 32, 80),
] {
world.spawn(&enemy.into(), x, y, &active.registry).unwrap();
}
fire_once(&mut world, &mut active);
assert_eq!(
world.enemies.iter().map(|e| e.health).collect::<Vec<_>>(),
[68, 80, 88, 92, 100, 100]
);
assert_eq!(state(&active, "lightning_cannon")["hits"], 4);
assert_eq!(state(&active, "lightning_cannon")["bursts"], 1);
assert!(world.projectiles.is_empty(), "no damaging carrier shot");
assert!(
world.effects.is_empty(),
"electrical arcs are not explosions"
);
assert!(world.mod_sounds.contains(&"lightning_cannon:fire".into()));
assert!(world.mod_sounds.contains(&"lightning_cannon:impact".into()));
assert_eq!(world.player.shield, 100);
}
#[test]
fn lightning_cannon_cannot_rehit_a_target_or_jump_across_a_gap() {
let (_root, mut active) = activate(&["lightning_cannon"]);
let mut world = world("lightning_cannon", &active.registry);
for x in [144, 184, 234] {
world
.spawn(&"base:target".into(), x, 80, &active.registry)
.unwrap();
}
// The initial victim dies, but its position still launches the next arc.
world.enemies[0].health = 10;
fire_once(&mut world, &mut active);
assert_eq!(
world.enemies.iter().map(|e| e.health).collect::<Vec<_>>(),
[-22, 80, 100]
);
assert_eq!(state(&active, "lightning_cannon")["hits"], 2);
}
#[test]
fn lightning_cannon_requires_forward_aim_and_respects_its_cooldown() {
let (_root, mut active) = activate(&["lightning_cannon"]);
let mut world = world("lightning_cannon", &active.registry);
for (x, y) in [(190, 80), (144, 196)] {
world
.spawn(&"base:target".into(), x, y, &active.registry)
.unwrap();
}
fire_once(&mut world, &mut active);
assert!(world.enemies.iter().all(|e| e.health == 100));
assert_eq!(state(&active, "lightning_cannon")["hits"], 0);
assert!(world.mod_sounds.contains(&"lightning_cannon:fire".into()));
assert!(!world.mod_sounds.contains(&"lightning_cannon:impact".into()));
let aligned = world
.spawn(&"base:target".into(), 144, 80, &active.registry)
.unwrap();
for _ in 0..17 {
fire_once(&mut world, &mut active);
}
assert_eq!(
world
.enemies
.iter()
.find(|e| e.entity == aligned)
.unwrap()
.health,
100
);
assert_eq!(state(&active, "lightning_cannon")["bursts"], 1);
fire_once(&mut world, &mut active);
assert_eq!(
world
.enemies
.iter()
.find(|e| e.entity == aligned)
.unwrap()
.health,
68
);
assert_eq!(state(&active, "lightning_cannon")["bursts"], 2);
}
#[test]
fn lightning_shield_zaps_close_aircraft_in_all_directions_only_while_firing() {
let (_root, mut active) = activate(&["lightning_shield"]);
let mut world = world("lightning_shield", &active.registry);
for (enemy, x, y) in [
("base:target", 104, 160),
("base:target", 184, 160),
("base:target", 144, 120),
("base:target", 144, 196),
("base:target", 192, 160),
("base:ground_target", 184, 160),
] {
world.spawn(&enemy.into(), x, y, &active.registry).unwrap();
}
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
assert!(world.enemies.iter().all(|e| e.health == 100));
assert!(
!active
.scripts
.states
.contains_key("lightning_shield/scripts/weapon.rhai")
);
assert!(world.player.selected.is_none());
assert!(world.lightning_arcs.is_empty());
fire_once(&mut world, &mut active);
assert_eq!(
world.enemies.iter().map(|e| e.health).collect::<Vec<_>>(),
[98, 98, 98, 98, 100, 100]
);
assert_eq!(state(&active, "lightning_shield")["hits"], 4);
assert!(world.mod_sounds.contains(&"lightning_shield:fire".into()));
assert_eq!(
world.player.inventory[&"lightning_shield:equipment".into()],
1
);
assert_eq!(world.player.shield, 100);
assert!(world.projectiles.is_empty());
assert!(world.effects.is_empty());
let center = (world.player.x + 16, world.player.y + 16);
let zaps: Vec<_> = world
.lightning_arcs
.iter()
.filter(|arc| arc.ticks == 3)
.collect();
assert_eq!(zaps.len(), 4);
for arc in zaps {
let radius_squared = (arc.x - center.0).pow(2) + (arc.y - center.1).pow(2);
assert!((18 * 18..=20 * 20).contains(&radius_squared));
assert_eq!(arc.anchor.as_ref().unwrap().entity, 0);
assert_ne!(arc.to_anchor.as_ref().unwrap().entity, 0);
}
for _ in 0..7 {
fire_once(&mut world, &mut active);
}
assert_eq!(state(&active, "lightning_shield")["bursts"], 1);
fire_once(&mut world, &mut active);
assert_eq!(state(&active, "lightning_shield")["bursts"], 2);
assert_eq!(state(&active, "lightning_shield")["hits"], 8);
}
#[test]
fn lightning_shield_has_a_strict_range_boundary_and_a_bounded_pulse() {
let (_root, mut active) = activate(&["lightning_shield"]);
let mut world = world("lightning_shield", &active.registry);
// Centers are far away, but the hull is exactly 28px from the pilot center.
world
.spawn(&"base:target".into(), 188, 160, &active.registry)
.unwrap();
world
.spawn(&"base:target".into(), 189, 160, &active.registry)
.unwrap();
fire_once(&mut world, &mut active);
assert_eq!(
world.enemies.iter().map(|e| e.health).collect::<Vec<_>>(),
[98, 100]
);
for _ in 0..4 {
world
.spawn(&"base:target".into(), 184, 160, &active.registry)
.unwrap();
}
for _ in 0..8 {
fire_once(&mut world, &mut active);
}
assert_eq!(state(&active, "lightning_shield")["hits"], 5);
assert_eq!(world.enemies[0].health, 98);
assert_eq!(world.enemies[1].health, 100);
}
#[test]
fn lightning_shield_is_quiet_when_no_aircraft_are_close_and_when_sold() {
let (_root, mut active) = activate(&["lightning_shield"]);
let mut world = world("lightning_shield", &active.registry);
world
.spawn(&"base:ground_target".into(), 184, 160, &active.registry)
.unwrap();
world
.spawn(&"base:target".into(), 144, 80, &active.registry)
.unwrap();
fire_once(&mut world, &mut active);
assert_eq!(state(&active, "lightning_shield")["hits"], 0);
assert!(world.mod_sounds.is_empty());
world
.player
.inventory
.remove(&"lightning_shield:equipment".into());
world
.spawn(&"base:target".into(), 184, 160, &active.registry)
.unwrap();
for _ in 0..10 {
fire_once(&mut world, &mut active);
}
assert!(world.enemies.iter().all(|e| e.health == 100));
assert_eq!(state(&active, "lightning_shield")["bursts"], 1);
assert!(world.lightning_arcs.is_empty());
}
#[test]
fn lightning_shield_halo_closes_around_hull_follows_movement_and_expires_after_firing() {
let (_root, mut active) = activate(&["lightning_shield"]);
let mut world = world("lightning_shield", &active.registry);
fire_once(&mut world, &mut active);
let ring = world.lightning_arcs.clone();
assert_eq!(ring.len(), 16);
assert!(world.mod_sounds.is_empty());
assert_eq!(state(&active, "lightning_shield")["hits"], 0);
let center = (world.player.x + 16, world.player.y + 16);
for (i, arc) in ring.iter().enumerate() {
assert_eq!(
(arc.to_x, arc.to_y),
(ring[(i + 1) % ring.len()].x, ring[(i + 1) % ring.len()].y)
);
let radius_squared = (arc.x - center.0).pow(2) + (arc.y - center.1).pow(2);
assert!((18 * 18..=20 * 20).contains(&radius_squared));
assert_eq!(arc.anchor.as_ref().unwrap().entity, 0);
assert_eq!(arc.to_anchor.as_ref().unwrap().entity, 0);
}
let old_position = (world.player.x, world.player.y);
world
.step(
Input {
dx: 1,
..Input::default()
},
&active.registry,
&mut active.scripts,
)
.unwrap();
let movement = (
world.player.x - old_position.0,
world.player.y - old_position.1,
);
assert_ne!(movement, (0, 0));
for (old, arc) in ring.iter().zip(&world.lightning_arcs) {
assert_eq!((arc.x - old.x, arc.y - old.y), movement);
assert_eq!((arc.to_x - old.to_x, arc.to_y - old.to_y), movement);
}
for _ in 0..8 {
world
.step(Input::default(), &active.registry, &mut active.scripts)
.unwrap();
}
assert!(world.lightning_arcs.is_empty());
assert_eq!(state(&active, "lightning_shield")["bursts"], 1);
}
#[test]
fn lightning_shield_fires_with_other_primaries_and_the_selected_cannon() {
let (_root, mut active) = activate(&PACKAGES);
let mut pilot = pilot("lightning_cannon", &active.registry);
pilot
.inventory
.insert("lightning_shield:equipment".into(), 1);
pilot.inventory.insert("pulse_cannon:equipment".into(), 1);
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
world
.spawn(&"base:target".into(), 144, 80, &active.registry)
.unwrap();
world
.spawn(&"base:target".into(), 184, 160, &active.registry)
.unwrap();
fire_once(&mut world, &mut active);
for id in ["lightning_cannon", "lightning_shield", "pulse_cannon"] {
assert_eq!(state(&active, id)["bursts"], 1);
assert_eq!(
world.player.inventory[&format!("{id}:equipment").as_str().into()],
1
);
}
assert_eq!(world.enemies[0].health, 68);
assert_eq!(world.enemies[1].health, 98);
assert_eq!(world.projectiles.len(), 2);
world.player.selected = Some("ion_lance:equipment".into());
world
.player
.inventory
.insert("ion_lance:equipment".into(), 1);
for _ in 0..8 {
fire_once(&mut world, &mut active);
}
assert_eq!(state(&active, "lightning_shield")["bursts"], 2);
assert_eq!(state(&active, "lightning_cannon")["bursts"], 1);
}
#[test]
fn lightning_loadout_state_and_damage_resume_deterministically() {
let ids = ["lightning_cannon", "lightning_shield"];
let (_root, mut active) = activate(&ids);
let mut pilot = pilot("lightning_cannon", &active.registry);
pilot
.inventory
.insert("lightning_shield:equipment".into(), 1);
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
for (x, y) in [(144, 80), (184, 80), (184, 160)] {
world
.spawn(&"base:target".into(), x, y, &active.registry)
.unwrap();
}
for _ in 0..10 {
fire_once(&mut world, &mut active);
}
let mut resumed = world.clone();
let (_other_root, mut other) = activate(&ids);
other.scripts.states = active.scripts.states.clone();
other.scripts.rng = active.scripts.rng.clone();
for tick in 0..24 {
let input = Input {
fire: tick % 3 != 0,
..Input::default()
};
world
.step(input, &active.registry, &mut active.scripts)
.unwrap();
resumed
.step(input, &other.registry, &mut other.scripts)
.unwrap();
}
assert_eq!(world.digest(), resumed.digest());
assert_eq!(active.scripts.states, other.scripts.states);
assert_eq!(
serde_json::to_value(&active.scripts.rng).unwrap(),
serde_json::to_value(&other.scripts.rng).unwrap()
);
}
#[test]
#[ignore = "requires original local palette/player artwork; writes ignored SD/HD visual proofs"]
fn lightning_mods_render_real_icons_and_discharge_arcs_in_sd_and_hd() {
use raptor_replica::{
assets::{Catalog, Sprite},
render::{BitmapFont, Canvas, draw_lightning_arcs, nearest},
};
let package = Path::new(env!("CARGO_MANIFEST_DIR"));
let data = std::env::var_os("RAPTOR_TEST_DATA")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| package.join("../data"));
let catalog = Catalog::open(data).unwrap();
let palette = catalog.palette().unwrap();
let font = BitmapFont::decode(&catalog.named("SYSTEM_FNT").unwrap()).unwrap();
let player = catalog
.sprite(catalog.id("LPLAYER_PIC").unwrap() + 3)
.unwrap();
let discovery = modding::discover(&package.join("mods"));
let output = package.join("../.reference/lightning-mods");
fs::create_dir_all(&output).unwrap();
for id in ["lightning_cannon", "lightning_shield"] {
let mut active =
ActiveMods::build(&base(), &discovery, &[id.to_string()], Some(&catalog)).unwrap();
let mut world = world(id, &active.registry);
let positions = if id == "lightning_cannon" {
vec![(144, 80), (184, 80), (224, 80), (264, 80)]
} else {
vec![(104, 160), (184, 160), (144, 120), (144, 196)]
};
for (x, y) in positions {
world
.spawn(&"base:target".into(), x, y, &active.registry)
.unwrap();
}
fire_once(&mut world, &mut active);
assert_eq!(
world.lightning_arcs.len(),
if id == "lightning_shield" { 20 } else { 4 }
);
let digest = world.digest();
let script_rng = serde_json::to_value(&active.scripts.rng).unwrap();
for (mode, scale) in [("sd", 1), ("hd", 4)] {
let mut canvas = Canvas::new(scale);
canvas.clear(0);
font.text(
&mut canvas,
8,
8,
&format!(
"{} / {mode}",
active.registry.items[&format!("{id}:equipment").as_str().into()].name
),
8,
);
let equipment = Sprite::png(
&package
.join("mods")
.join(id)
.join(format!("art/equipment-{mode}.png")),
&palette,
)
.unwrap();
assert!(
equipment
.pixels
.iter()
.all(|pixel| *pixel < 240 || *pixel == raptor_replica::assets::TRANSPARENT)
);
canvas.blit(&equipment, 8 * scale as i32, 28 * scale as i32);
let price = active.registry.items[&format!("{id}:equipment").as_str().into()].price;
font.text(&mut canvas, 8, 48, &format!("${price}"), 8);
let unit = Sprite::png(
&package.join(format!("mods/iron_coast_scout/art/unit-{mode}.png")),
&palette,
)
.unwrap();
// The scout's HD master is 8x; fit to the same 24px logical canvas
// as the game renderer rather than drawing the raw master size.
let mut fitted_unit = Sprite::blank(24 * scale, 24 * scale);
for y in 0..fitted_unit.height {
for x in 0..fitted_unit.width {
fitted_unit.pixels[y * fitted_unit.width + x] =
unit.pixels[(y * unit.height / fitted_unit.height) * unit.width
+ x * unit.width / fitted_unit.width];
}
}
for enemy in &world.enemies {
canvas.blit(
&fitted_unit,
(enemy.x + 4) * scale as i32,
(enemy.y + 4) * scale as i32,
);
}
canvas.blit(
&nearest(&player, scale),
world.player.x * scale as i32,
world.player.y * scale as i32,
);
let scene = canvas.image.clone();
// Show successive discharges at their actual firing cadence, not
// just a single still: the cosmetic seed changes between bursts.
let weapon = active.registry.items[&format!("{id}:equipment").as_str().into()]
.weapon
.as_ref()
.unwrap();
let cooldown = active.registry.weapons[weapon].cooldown;
for frame in 0..cooldown * 3 {
canvas.image = scene.clone();
let mut arcs = world.lightning_arcs.clone();
for arc in &mut arcs {
arc.age = frame % cooldown;
arc.seed = arc
.seed
.wrapping_add((frame / cooldown * cooldown).wrapping_mul(0x1656_67b1));
}
draw_lightning_arcs(&mut canvas, &arcs, &palette);
image::save_buffer(
output.join(format!("{id}-{mode}-{frame:02}.png")),
&canvas.rgba(&palette, false),
canvas.image.width as u32,
canvas.image.height as u32,
image::ColorType::Rgba8,
)
.unwrap();
if frame == 0 {
image::save_buffer(
output.join(format!("{id}-{mode}.png")),
&canvas.rgba(&palette, false),
canvas.image.width as u32,
canvas.image.height as u32,
image::ColorType::Rgba8,
)
.unwrap();
}
}
assert_eq!(world.digest(), digest);
assert_eq!(
serde_json::to_value(&active.scripts.rng).unwrap(),
script_rng
);
}
}
}