Make RustRaptor the repository root and track its playable bundle
No files matched your search
@@ -0,0 +1,15 @@
|
||||
# Aurora Field Trials
|
||||
|
||||
This API v1 example adds a scripted pulse cannon, a repair pickup, weaving drones, a guardian, a mission and a campaign. It references the player's installed original sprite catalog; no game artwork is included.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
cargo run --no-default-features -- --mods mods --validate-mods aurora
|
||||
cargo run --no-default-features -- --mods mods --enabled aurora \
|
||||
--headless aurora:field_trials --ticks 700
|
||||
```
|
||||
|
||||
To play, start the Rust engine with `--mods mods`, enable Aurora in **Mods**, apply the selection at the main menu, create a separate pilot and choose **Aurora Field Trials**. The mission equips its cannon on the first simulation step. Keep existing pilots' required mod sets available.
|
||||
|
||||
Content IDs intentionally separate the inventory item `aurora:pulse_cannon` from its projectile `aurora:pulse_projectile`. The scripts demonstrate `on_fire`, `on_hit`, `on_spawn`, `on_tick`, `on_death`, `on_pickup`, `on_use`, `on_mission_start` and `on_mission_end`. See `docs/modding-api.md` for the complete API and compatibility rules.
|
||||
@@ -0,0 +1,247 @@
|
||||
{
|
||||
"weapons": [
|
||||
{
|
||||
"id": "aurora:pulse_projectile",
|
||||
"name": "Aurora Pulse",
|
||||
"damage": 3,
|
||||
"speed": 8,
|
||||
"cooldown": 9,
|
||||
"behavior": "straight",
|
||||
"target": "all",
|
||||
"offsets": [
|
||||
[
|
||||
0,
|
||||
-4
|
||||
]
|
||||
],
|
||||
"art": {
|
||||
"asset": 65999,
|
||||
"logical_width": 8,
|
||||
"logical_height": 8,
|
||||
"frames": 2
|
||||
},
|
||||
"script": "scripts/weapon.rhai"
|
||||
},
|
||||
{
|
||||
"id": "aurora:drone_projectile",
|
||||
"name": "Drone Pulse",
|
||||
"damage": 3,
|
||||
"speed": 3,
|
||||
"cooldown": 32,
|
||||
"behavior": "straight",
|
||||
"target": "all",
|
||||
"art": {
|
||||
"asset": 65999,
|
||||
"logical_width": 8,
|
||||
"logical_height": 8,
|
||||
"frames": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"id": "aurora:pulse_cannon",
|
||||
"name": "Aurora Pulse Cannon",
|
||||
"price": 4000,
|
||||
"max_count": 1,
|
||||
"weapon": "aurora:pulse_projectile",
|
||||
"art": {
|
||||
"asset": 66225,
|
||||
"logical_width": 16,
|
||||
"logical_height": 16
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "aurora:repair_cell",
|
||||
"name": "Aurora Repair Cell",
|
||||
"price": 100,
|
||||
"max_count": 10,
|
||||
"consumable": true,
|
||||
"shield": 12,
|
||||
"art": {
|
||||
"asset": 66225,
|
||||
"logical_width": 16,
|
||||
"logical_height": 16
|
||||
},
|
||||
"script": "scripts/cell.rhai",
|
||||
"special": true
|
||||
}
|
||||
],
|
||||
"enemies": [
|
||||
{
|
||||
"id": "aurora:weaver",
|
||||
"name": "Aurora Weaver",
|
||||
"health": 16,
|
||||
"speed": 2,
|
||||
"reward": 100,
|
||||
"cooldown": 32,
|
||||
"weapon": "aurora:drone_projectile",
|
||||
"drop": "aurora:repair_cell",
|
||||
"art": {
|
||||
"asset": 66296,
|
||||
"logical_width": 32,
|
||||
"logical_height": 24
|
||||
},
|
||||
"script": "scripts/drone.rhai"
|
||||
},
|
||||
{
|
||||
"id": "aurora:guardian",
|
||||
"name": "Aurora Guardian",
|
||||
"health": 160,
|
||||
"speed": 1,
|
||||
"reward": 1500,
|
||||
"boss": true,
|
||||
"cooldown": 20,
|
||||
"weapon": "aurora:drone_projectile",
|
||||
"drop": "aurora:repair_cell",
|
||||
"path": [
|
||||
[
|
||||
0,
|
||||
60
|
||||
],
|
||||
[
|
||||
40,
|
||||
60
|
||||
],
|
||||
[
|
||||
-40,
|
||||
60
|
||||
]
|
||||
],
|
||||
"repeat": true,
|
||||
"art": {
|
||||
"asset": 66310,
|
||||
"logical_width": 80,
|
||||
"logical_height": 40
|
||||
},
|
||||
"script": "scripts/drone.rhai"
|
||||
}
|
||||
],
|
||||
"missions": [
|
||||
{
|
||||
"id": "aurora:field_trials",
|
||||
"name": "Aurora Field Trials",
|
||||
"duration": 650,
|
||||
"spawns": [
|
||||
{
|
||||
"tick": 30,
|
||||
"enemy": "aurora:weaver",
|
||||
"x": 70,
|
||||
"y": -24
|
||||
},
|
||||
{
|
||||
"tick": 60,
|
||||
"enemy": "aurora:weaver",
|
||||
"x": 200,
|
||||
"y": -24
|
||||
},
|
||||
{
|
||||
"tick": 110,
|
||||
"enemy": "aurora:weaver",
|
||||
"x": 140,
|
||||
"y": -24
|
||||
},
|
||||
{
|
||||
"tick": 150,
|
||||
"enemy": "aurora:weaver",
|
||||
"x": 90,
|
||||
"y": -24
|
||||
},
|
||||
{
|
||||
"tick": 180,
|
||||
"enemy": "aurora:weaver",
|
||||
"x": 190,
|
||||
"y": -24
|
||||
},
|
||||
{
|
||||
"tick": 230,
|
||||
"enemy": "aurora:weaver",
|
||||
"x": 150,
|
||||
"y": -24
|
||||
},
|
||||
{
|
||||
"tick": 300,
|
||||
"enemy": "aurora:guardian",
|
||||
"x": 120,
|
||||
"y": -40
|
||||
}
|
||||
],
|
||||
"tiles": [
|
||||
{
|
||||
"art": {
|
||||
"asset": 66400,
|
||||
"logical_width": 32,
|
||||
"logical_height": 32
|
||||
}
|
||||
}
|
||||
],
|
||||
"columns": 9,
|
||||
"layout": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"script": "scripts/mission.rhai"
|
||||
}
|
||||
],
|
||||
"campaigns": [
|
||||
{
|
||||
"id": "aurora:trials",
|
||||
"name": "Aurora Trials",
|
||||
"missions": [
|
||||
"aurora:field_trials"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
id = "aurora"
|
||||
name = "Aurora Field Trials"
|
||||
version = "1.0.0"
|
||||
api_version = 1
|
||||
description = "A short mission with a scripted pulse cannon, weaving drones and repair cells. Uses your installed original artwork."
|
||||
content = "content.json"
|
||||
scripts = ["scripts/weapon.rhai", "scripts/drone.rhai", "scripts/cell.rhai", "scripts/mission.rhai"]
|
||||
@@ -0,0 +1,11 @@
|
||||
fn on_pickup(event, state, random) {
|
||||
let recovered = if state.contains("recovered") { state.recovered + 1 } else { 1 };
|
||||
#{ commands: [#{ op: "credits", amount: 25 }], state: #{ recovered: recovered } }
|
||||
}
|
||||
|
||||
// A selected repair cell can also be consumed with the special-action button.
|
||||
fn on_use(event, state, random) {
|
||||
let used = if state.contains("used") { state.used + 1 } else { 1 };
|
||||
state.used = used;
|
||||
#{ commands: [#{ op: "objective", key: "aurora:repairs_used", value: used }], state: state }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
fn on_spawn(event, state, random) {
|
||||
#{ commands: [], state: state }
|
||||
}
|
||||
|
||||
fn on_tick(event, state, random) {
|
||||
// on_tick may be queued on the same step as on_death; do not command a retired entity.
|
||||
if event.health <= 0 { return #{ commands: [], state: state }; }
|
||||
let sideways = if (event.tick / 24) % 2 == 0 { 1 } else { -1 };
|
||||
#{ commands: [#{ op: "velocity", entity: event.entity, vx: sideways, vy: 1 }], state: state }
|
||||
}
|
||||
|
||||
fn on_death(event, state, random) {
|
||||
let destroyed = if state.contains("destroyed") { state.destroyed + 1 } else { 1 };
|
||||
#{ commands: [#{ op: "objective", key: "aurora:destroyed", value: destroyed }],
|
||||
state: #{ destroyed: destroyed } }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
fn on_mission_start(event, state, random) {
|
||||
#{ commands: [
|
||||
#{ op: "give_item", item: "aurora:pulse_cannon", count: 1 },
|
||||
#{ op: "objective", key: "aurora:destroyed", value: 0 }
|
||||
], state: state }
|
||||
}
|
||||
|
||||
fn on_mission_end(event, state, random) {
|
||||
#{ commands: [#{ op: "objective", key: "aurora:trial_finished", value: 1 }], state: state }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// The normal centre projectile is emitted by the engine. Add two angled pulses.
|
||||
fn on_fire(event, state, random) {
|
||||
let shots = if state.contains("shots") { state.shots + 1 } else { 1 };
|
||||
let spread = 1 + random[0] % 2;
|
||||
#{ commands: [
|
||||
#{ op: "projectile", weapon: "aurora:pulse_projectile", x: event.x - 6, y: event.y, vx: -spread, vy: -8 },
|
||||
#{ op: "projectile", weapon: "aurora:pulse_projectile", x: event.x + 6, y: event.y, vx: spread, vy: -8 }
|
||||
], state: #{ shots: shots } }
|
||||
}
|
||||
|
||||
fn on_hit(event, state, random) {
|
||||
#{ commands: [#{ op: "effect", x: event.x, y: event.y }], state: state }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# ion_lance artwork provenance
|
||||
|
||||
Generated locally with the built-in image generation tool and the imagegen skill. Original catalog IDs `0x102b1` (gray gun inventory icon) and `0x101cf` (8×8 projectile) were decoded and inspected as style/scale references. Original pixels are not included in the mod or Git. The prompt uses these references to create a distinct naval industrial weapon rather than copy the original.
|
||||
|
||||
Generated master images, SD/HD sprites and sound WAVs are tracked with the package, as requested. Original game pixels and decoded reference images remain outside Git. The generated source is converted into 16×16/64×64 equipment canvases and a 4×32 logical projectile canvas plus its 4× HD counterpart. Transparent margins remain inside the logical canvas. Sound synthesis is deterministic, PCM16 mono 22,050 Hz; `generate_audio.py` is the complete reproduction source.
|
||||
|
||||
Prompts and generated-source SHA256 hashes are recorded in `art-provenance.json` after output verification. Palette conversion occurs in the runtime loader; gameplay dimensions do not depend on HD artwork.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Ion Lance 1.0.0
|
||||
|
||||
Enable **Ion Lance** in Mods and apply at the main menu. This independent API 1 example has no dependencies. Buy its equipment in the shop (425,000 credits, maximum one) or collect its pickup. Choose it in Inventory or cycle special weapons during a mission. The equipped weapon fires with Fire or Special; it consumes no ammunition.
|
||||
|
||||
Projectile: 8 damage, 12 logical pixels per step, 14 step cooldown, `beam`, air and ground targets. The narrow beam pierces every enemy in its column for two steps (16 total damage per enemy per trigger). Rhai stores bounded burst/hit counters and emits local firing/impact sounds and effects.
|
||||
|
||||
`art/equipment-sd.png` is a 16×16 logical icon, `art/equipment-hd.png` its detailed 4× variant. Projectile canvases are 4×32 SD and 16×128 HD. Switch HD artwork in Options. The generated master images, SD/HD sprites, WAV files, provenance and reproduction inputs are tracked in Git. Original game assets and decoded reference images remain outside version control. `python3 generate_audio.py` reproduces both sound files.
|
||||
|
||||
Validate locally with `cargo run --no-default-features -- --mods mods --validate-mods ion_lance`. Read the shared [API documentation](../../docs/modding-api.md) for save pins, dependencies and sandbox limits.
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"package": "ion_lance",
|
||||
"tool": "built-in image_gen",
|
||||
"skill": "/home/quintine/.codex/skills/.system/imagegen/SKILL.md",
|
||||
"reference_role": "Original catalog sprites inspected as style and scale references, not copied as mod artwork",
|
||||
"reference_assets": [
|
||||
"0x102b1",
|
||||
"0x101cf"
|
||||
],
|
||||
"sources": [
|
||||
{
|
||||
"kind": "equipment",
|
||||
"path": "/home/quintine/.codex/generated_images/01a12340-dd1f-71e1-869a-0c9f61a82318/exec-2c5afac0-cc57-42f9-a0ca-f01125ba186a.png",
|
||||
"prompt": "Use case: stylized-concept. Asset type: HD sprite for equipment shop/inventory in 1990s military science-fiction vertical shooter. Input image is an ORIGINAL STYLE REFERENCE ONLY, not an edit target. Generate a distinct Ion Lance: compact single long railgun barrel pointing exactly UP from an armored gray rectangular gun pod, two subtle electric-blue induction rails, red service panel. Top-down orthographic overhead view, precise thin symmetrical military silhouette. Isolated single object centered on genuinely transparent 1024x1024 square canvas, silhouette central 70% with12% transparentmargin. Match reference muted gunmetal, chunky readability, upper-left highlight. Restrained panel/machined detail forHD. No text, labels, outside glow/shadows, frame, border, extraobjects. Displaylogical16x16SDand64x64HD, so silhouette must survive reduction.",
|
||||
"sha256": "145215af5159eb196fc18c8c4f9f0e1c318ff552b9a83e4d8fc0d354309da4f5",
|
||||
"local_path": "art/equipment-generated-source.png"
|
||||
},
|
||||
{
|
||||
"kind": "projectile",
|
||||
"path": "/home/quintine/.codex/generated_images/01a12340-dd1f-71e1-869a-0c9f61a82318/exec-da141e14-efd2-4a36-a56f-bbc33225674f.png",
|
||||
"prompt": "Use case: stylized-concept. Asset type: ONE isolated ion beam pulse projectile sprite for a military science-fiction vertical shooter. Original input is STYLE reference only for readable chunky projectile palette. Generate a straight very narrow vertical electric blue/white ion lance beam traveling exactly UP: continuous thin pale core, bounded cobalt/cyan stepped edges, brightest pointed upper tip, slightly tapered bottom tail. No gun, no spacecraft, no extraneous light halo. Orthographic view, perfectly straight, centered, width:height1:8. Genuine transparent square canvas with generous sides and15%transparent top/bottom margin. Pixel-inspired military science-fiction HD detail, minimal colors, reference's crispbounded silhouette. No text, labels, shadows, frame, multiple objects. Intendedlogicalcanvas4x32 and16x128HD.",
|
||||
"sha256": "1a1c19b40354554658a4b72073ed7a7aec9a2bfb185c20d9654a29e2eef75614",
|
||||
"local_path": "art/projectile-generated-source.png"
|
||||
}
|
||||
],
|
||||
"conversion": "prepare_art.rs crops to alpha>=128 silhouette, fits with transparent margins, preserves aspect and reduces to logical SD/4xHD. SD nearest sampling alpha128; HD Lanczos3 alpha64. Pixel values quantized at runtime.",
|
||||
"outputs": {
|
||||
"art/equipment-generated-source.png": {
|
||||
"sha256": "145215af5159eb196fc18c8c4f9f0e1c318ff552b9a83e4d8fc0d354309da4f5",
|
||||
"bytes": 324909
|
||||
},
|
||||
"art/equipment-sd.png": {
|
||||
"sha256": "bb366d1604bd54079cb5e4c23f7e14aa9da1c9f494e66be1f9f3b870e0adec40",
|
||||
"bytes": 338
|
||||
},
|
||||
"art/equipment-hd.png": {
|
||||
"sha256": "66aa9a088453059518d4f76d961518edc577729018262d270bc6c3d806f665e4",
|
||||
"bytes": 2451
|
||||
},
|
||||
"art/projectile-generated-source.png": {
|
||||
"sha256": "1a1c19b40354554658a4b72073ed7a7aec9a2bfb185c20d9654a29e2eef75614",
|
||||
"bytes": 162173
|
||||
},
|
||||
"art/projectile-sd.png": {
|
||||
"sha256": "d8d2376739d39c343e9afc242b48956fcc8e48bc09fbc626360f8b3c4a52b730",
|
||||
"bytes": 308
|
||||
},
|
||||
"art/projectile-hd.png": {
|
||||
"sha256": "671cae899e2c5c9fd180663255915faaefe14267ddc46bd33ccd04dc51ba0dd4",
|
||||
"bytes": 1688
|
||||
}
|
||||
},
|
||||
"sounds": {
|
||||
"audio/fire.wav": {
|
||||
"sha256": "c155d02643f92f4e06373987d97728052617fca36d8ee9e712199078e89480a6",
|
||||
"bytes": 5776,
|
||||
"synthesis": "generate_audio.py PCM16 mono22050Hz"
|
||||
},
|
||||
"audio/impact.wav": {
|
||||
"sha256": "19b9be1a9fc960faa616a497535f5489c8d3021fc60f324aca9489c1be4a5ab6",
|
||||
"bytes": 7540,
|
||||
"synthesis": "generate_audio.py PCM16 mono22050Hz"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 338 B |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 308 B |
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"weapons": [
|
||||
{
|
||||
"id": "ion_lance:projectile",
|
||||
"name": "Ion Lance Projectile",
|
||||
"damage": 8,
|
||||
"speed": 12,
|
||||
"cooldown": 14,
|
||||
"behavior": "beam",
|
||||
"target": "all",
|
||||
"offsets": [
|
||||
[
|
||||
0,
|
||||
-4
|
||||
]
|
||||
],
|
||||
"art": {
|
||||
"png": "art/projectile-sd.png",
|
||||
"hd_png": "art/projectile-hd.png",
|
||||
"logical_width": 4,
|
||||
"logical_height": 32,
|
||||
"nearest": true
|
||||
},
|
||||
"script": "scripts/weapon.rhai"
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"id": "ion_lance:equipment",
|
||||
"name": "Ion Lance",
|
||||
"price": 425000,
|
||||
"max_count": 1,
|
||||
"initial_count": 1,
|
||||
"weapon": "ion_lance: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",
|
||||
"description": "Selectable reusable special. A brief beam hits every air or ground enemy in the column ahead of your ship. Aim the column, then use Fire or Special while selected. No ammunition is consumed."
|
||||
}
|
||||
],
|
||||
"sounds": [
|
||||
{
|
||||
"id": "ion_lance:fire",
|
||||
"name": "Ion Lance Fire",
|
||||
"file": "audio/fire.wav",
|
||||
"volume": 92,
|
||||
"pan": 127,
|
||||
"pitch": 128,
|
||||
"priority": 1
|
||||
},
|
||||
{
|
||||
"id": "ion_lance:impact",
|
||||
"name": "Ion Lance Impact",
|
||||
"file": "audio/impact.wav",
|
||||
"volume": 78,
|
||||
"pan": 127,
|
||||
"pitch": 128,
|
||||
"priority": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reproduce local procedural weapon audio; WAV outputs are tracked with the package."""
|
||||
import math
|
||||
import struct
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGE = "ion_lance"
|
||||
RATE = 22050
|
||||
|
||||
def render(kind):
|
||||
duration = 0.13 if kind == "fire" else 0.17
|
||||
count = int(RATE * duration)
|
||||
phase = 0.0
|
||||
seed = 0xA7C51D3
|
||||
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
|
||||
if PACKAGE == "pulse_cannon":
|
||||
frequency = (1350 * (1 - t) + 190) if kind == "fire" else 130
|
||||
tone = math.sin(phase) * 0.64 + noise * 0.22
|
||||
elif PACKAGE == "seeker_swarm":
|
||||
frequency = 330 + 850 * t if kind == "fire" else 95
|
||||
tone = noise * 0.48 + math.sin(phase) * 0.35
|
||||
else:
|
||||
frequency = 1850 + 680 * t if kind == "fire" else 680 * (1 - t) + 110
|
||||
tone = math.sin(phase) * 0.52 + math.sin(phase * 1.997) * 0.2 + noise * 0.06
|
||||
phase += 2 * math.pi * frequency / RATE
|
||||
attack = min(1.0, n / (RATE * 0.003))
|
||||
envelope = attack * (1 - t) ** (3 if kind == "impact" else 2)
|
||||
samples.append(round(max(-1, min(1, 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"))
|
||||
@@ -0,0 +1,7 @@
|
||||
id = "ion_lance"
|
||||
name = "Ion Lance"
|
||||
version = "1.0.0"
|
||||
api_version = 1
|
||||
description = "Naval science-fiction ion lance: independent weapon, equipment, SD/HD artwork and local firing/impact audio."
|
||||
content = "content.json"
|
||||
scripts = ["scripts/weapon.rhai", "scripts/equipment.rhai"]
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Build game-ready logical and HD PNG canvases from the inspected generated sources.
|
||||
//! Compile with the project's image dependency, then pass equipment source, shot source.
|
||||
use image::{Rgba, RgbaImage, imageops};
|
||||
use std::{env, fs, path::Path};
|
||||
const PACKAGE: &str = "ion_lance";
|
||||
const SHOT: (u32, u32) = (4, 32);
|
||||
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(),
|
||||
4,
|
||||
"usage: prepare_art EQUIPMENT_SOURCE SHOT_SOURCE PACKAGE_DIRECTORY"
|
||||
);
|
||||
let output = Path::new(&args[3]).join("art");
|
||||
fs::create_dir_all(&output).unwrap();
|
||||
for (kind, path, logical) in [
|
||||
("equipment", &args[1], (16, 16)),
|
||||
("projectile", &args[2], SHOT),
|
||||
] {
|
||||
let source = image::open(path).unwrap().into_rgba8();
|
||||
fs::copy(path, output.join(format!("{kind}-generated-source.png"))).unwrap();
|
||||
for (mode, scale) in [("sd", 1), ("hd", 4)] {
|
||||
let image = canvas(&source, logical.0 * scale, logical.1 * scale, scale == 4);
|
||||
let path = output.join(format!("{kind}-{mode}.png"));
|
||||
image.save(&path).unwrap();
|
||||
println!(
|
||||
"{PACKAGE}: {}×{} {}",
|
||||
image.width(),
|
||||
image.height(),
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Recovery is recorded separately from the weapon firing state.
|
||||
fn on_pickup(event, state, random) {
|
||||
let recovered = if state.contains("recovered") { state.recovered % 100000 + 1 } else { 1 };
|
||||
#{ commands: [#{ op: "objective", key: "ion_lance:recovered", value: recovered }],
|
||||
state: #{ recovered: recovered } }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Built-in beam projectiles handle the normal volley and collision rules.
|
||||
// Firing callbacks add audio and counters; impact callbacks add hit visuals.
|
||||
fn on_fire(event, state, random) {
|
||||
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: "ion_lance:fire" }
|
||||
];
|
||||
#{ commands: commands, state: #{ bursts: bursts, hits: hits } }
|
||||
}
|
||||
|
||||
fn on_hit(event, state, random) {
|
||||
let bursts = if state.contains("bursts") { state.bursts } else { 0 };
|
||||
let hits = if state.contains("hits") { state.hits % 100000 + 1 } else { 1 };
|
||||
let commands = [
|
||||
#{ op: "play_sound", sound: "ion_lance:impact" },
|
||||
#{ op: "effect", x: event.x, y: event.y }
|
||||
];
|
||||
#{ commands: commands, state: #{ bursts: bursts, hits: hits } }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Iron Coast Command Cruiser
|
||||
|
||||
Independent version 1.0.0 enemy package for mod API 1. Enable/disable it from the in-game Mods screen and use it in any mission through `iron_coast_boss:unit`. Iron Coast mission 1 supplies placements and completion objectives; this package does not add a mission or override base content.
|
||||
|
||||
Airborne command cruiser enters at 2 pixels/tick, settles at y24 and patrols x80..144 indefinitely. It fires a fan of three 3 damage shells every 54 ticks. A standard pilot can destroy it with starter guns; it stays in their firing corridor.
|
||||
|
||||
Logical collision canvas `96×64`, top-left anchor `(0,0)`, health `160`, reward `1800` credits, firing interval `54` simulation ticks. The engine halves boss health on difficulties 0/1; difficulty 2/3 uses the declared health. SD mode uses `art/unit-sd.png`; enable HD art in Settings for `art/unit-hd.png`. The HD canvas is 8× logical resolution and bypasses automatic ScaleNx. Projectile artwork has an 8×8 logical canvas and an independent 64×64 HD version.
|
||||
|
||||
The boss uses two registered frames (idle/amber charged); the scout and turret use stable single frames. All supplied variants stay on the same canvas and anchor. `art-qa.json` records transparency bounds and, for the boss, animation silhouette overlap.
|
||||
|
||||
Enemy deaths increment the current mission objective `iron_coast_boss:defeated`. Persistent script `destroyed`, `hits` and `shots` counters are statistics; current-mission objectives reset when the mission restarts. Callbacks guard retired entities and use deterministic simulation data. No wall clock or unseeded random input enters the scripts.
|
||||
|
||||
Generated PNG masters, SD/HD strips, previews and WAV sounds are included in Git. Original reference images under `art/source/` are excluded. Exact built-in imagegen prompts are in `prompts/`, with source IDs and generated-image provenance in `provenance.json`.
|
||||
|
||||
To rebuild derived artwork from supplied generated masters and regenerate audio, install Pillow and run `python3 build_assets.py`. To regenerate only audio with the Python standard library, run `python3 build_assets.py --audio-only`. WAVs are original procedural mono 16-bit PCM at 22050 Hz; `audio-recipe.json` records deterministic seeds, sizes and hashes. The art pipeline resizes and assembles supplied images; it does not redraw them.
|
||||
|
||||
Validate from the repository root: `cargo run -- --mods mods --validate-mods iron_coast_boss`. Synthetic behavior and save/replay tests: `cargo test --test enemies_mods`. Verify shipped artwork locally: `cargo test --test enemies_mods shipped_enemy_assets_are_valid_and_aligned`.
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"logical_size": [
|
||||
96,
|
||||
64
|
||||
],
|
||||
"hd_scale": 8,
|
||||
"origin": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"frames": 2,
|
||||
"frame_order": [
|
||||
"idle",
|
||||
"charged"
|
||||
],
|
||||
"alpha": "Preserved RGBA; runtime uses alpha>=128 indexed transparency",
|
||||
"registration": "Common full-canvas resize and equal padding for every frame",
|
||||
"sources": [
|
||||
{
|
||||
"source": "art/unit-generated-v1.png",
|
||||
"source_sha256": "87d644a5d584ad82e285241e3a5cdc58e5c61fa851415a4d6660e0ae83ec5e85",
|
||||
"sd_opaque_bounds": [
|
||||
25,
|
||||
3,
|
||||
71,
|
||||
61
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "art/unit-charge-generated-v1.png",
|
||||
"source_sha256": "cc8401140982260b4675a0a28fd209b8ed8d636e4cd4fa98a9cdf2fed37b1bff",
|
||||
"sd_opaque_bounds": [
|
||||
25,
|
||||
3,
|
||||
71,
|
||||
61
|
||||
]
|
||||
}
|
||||
],
|
||||
"animation_silhouette_overlap": 0.9985000050675504
|
||||
}
|
||||
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 385 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 190 B |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 438 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"generator": "build_assets.py deterministic chirp/noise synthesis; no recordings",
|
||||
"kind": "boss",
|
||||
"seed": 83,
|
||||
"files": {
|
||||
"audio/shot.wav": {
|
||||
"sample_rate": 22050,
|
||||
"channels": 1,
|
||||
"bits": 16,
|
||||
"samples": 5292,
|
||||
"peak": 25558,
|
||||
"sha256": "32d2e5ea26a9333f8163f7653c9b5a7dc00cf16ac9956f4abfbf32802ca459a7"
|
||||
},
|
||||
"audio/death.wav": {
|
||||
"sample_rate": 22050,
|
||||
"channels": 1,
|
||||
"bits": 16,
|
||||
"samples": 19845,
|
||||
"peak": 25558,
|
||||
"sha256": "3bf364c2fa21dbaca81af03e9d2e19552ede430912e1bd4fd967da0611a0c438"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert saved imagegen artwork and synthesize reproducible package-local WAVs.
|
||||
|
||||
This script resizes/atlases supplied art; it never redraws source or generated art.
|
||||
Generated masters, SD/HD artwork and WAVs are tracked in Git. See prompts/ and provenance.json.
|
||||
Use --audio-only to regenerate sound without Pillow or processing artwork.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import random
|
||||
import struct
|
||||
import wave
|
||||
|
||||
|
||||
def sounds(root, kind):
|
||||
rate = 22050
|
||||
values = {
|
||||
"scout": (0.11, 1400, 380, 0.34, 230, 19),
|
||||
"turret": (0.18, 900, 160, 0.52, 150, 37),
|
||||
"boss": (0.24, 500, 80, 0.90, 75, 83),
|
||||
}
|
||||
shot_length, start, end, death_length, bass, seed = values[kind]
|
||||
(root / "audio").mkdir(exist_ok=True)
|
||||
stats = {}
|
||||
for name, duration in [("shot", shot_length), ("death", death_length)]:
|
||||
rng = random.Random(seed + (100 if name == "death" else 0))
|
||||
count = int(duration * rate)
|
||||
phase = low_noise = 0.0
|
||||
samples = []
|
||||
for index in range(count):
|
||||
t = index / rate
|
||||
position = index / max(1, count - 1)
|
||||
noise = rng.uniform(-1, 1)
|
||||
low_noise = low_noise * 0.86 + noise * 0.14
|
||||
if name == "shot":
|
||||
frequency = end + (start - end) * (1 - position) ** 2
|
||||
phase += 2 * math.pi * frequency / rate
|
||||
value = (0.60 * math.sin(phase) + 0.15 * math.sin(2.31 * phase)
|
||||
+ 0.16 * noise + 0.16 * math.sin(2 * math.pi * bass * t))
|
||||
envelope = min(1, t / 0.002) * math.exp(-position * 6.0)
|
||||
else:
|
||||
frequency = bass * (1.0 - position * 0.65)
|
||||
phase += 2 * math.pi * frequency / rate
|
||||
value = 0.62 * low_noise + 0.19 * noise + 0.28 * math.sin(phase)
|
||||
envelope = min(1, t / 0.003) * math.exp(-position * 4.7)
|
||||
# Five milliseconds at the tail avoid discontinuities at mixer retirement.
|
||||
envelope *= min(1, (count - 1 - index) / (rate * 0.005))
|
||||
samples.append(value * max(0, envelope))
|
||||
peak = max(abs(value) for value in samples)
|
||||
pcm = [round(value / peak * 0.78 * 32767) for value in samples]
|
||||
path = root / "audio" / (name + ".wav")
|
||||
with wave.open(str(path), "wb") as output:
|
||||
output.setnchannels(1)
|
||||
output.setsampwidth(2)
|
||||
output.setframerate(rate)
|
||||
output.writeframes(struct.pack("<" + "h" * len(pcm), *pcm))
|
||||
stats[str(path.relative_to(root))] = {
|
||||
"sample_rate": rate, "channels": 1, "bits": 16,
|
||||
"samples": count, "peak": max(abs(value) for value in pcm),
|
||||
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
}
|
||||
(root / "audio-recipe.json").write_text(json.dumps({
|
||||
"generator": "build_assets.py deterministic chirp/noise synthesis; no recordings",
|
||||
"kind": kind, "seed": seed, "files": stats,
|
||||
}, indent=2) + "\n")
|
||||
|
||||
|
||||
def artwork(root, kind):
|
||||
from PIL import Image
|
||||
dimensions = {"scout": (24, 24), "turret": (32, 32), "boss": (96, 64)}
|
||||
width, height = dimensions[kind]
|
||||
paths = [root / "art/unit-generated-v1.png"]
|
||||
if kind == "boss":
|
||||
paths.append(root / "art/unit-charge-generated-v1.png")
|
||||
hd_frames, sd_frames = [], []
|
||||
stats = []
|
||||
for path in paths:
|
||||
source = Image.open(path).convert("RGBA")
|
||||
if source.width * height != source.height * width:
|
||||
raise ValueError(f"{path}: source canvas aspect differs from logical canvas")
|
||||
# Identical canvas resize and padding for all frames retain their registration.
|
||||
hd = Image.new("RGBA", (width * 8, height * 8), (0, 0, 0, 0))
|
||||
pad_x, pad_y = width // 3, height // 3
|
||||
resized = source.resize((hd.width - 2 * pad_x, hd.height - 2 * pad_y), Image.Resampling.LANCZOS)
|
||||
hd.alpha_composite(resized, (pad_x, pad_y))
|
||||
sd = hd.resize((width, height), Image.Resampling.BOX)
|
||||
for image in [hd, sd]:
|
||||
bbox = image.getchannel("A").point(lambda value: 255 if value >= 128 else 0).getbbox()
|
||||
if bbox is None or bbox[0] == 0 or bbox[1] == 0 or bbox[2] == image.width or bbox[3] == image.height:
|
||||
raise ValueError(f"{path}: opaque silhouette touches logical canvas edge")
|
||||
hd_frames.append(hd)
|
||||
sd_frames.append(sd)
|
||||
stats.append({"source": str(path.relative_to(root)),
|
||||
"source_sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
"sd_opaque_bounds": list(sd.getchannel("A").point(lambda value: 255 if value >= 128 else 0).getbbox())})
|
||||
for name, frames in [("unit-hd.png", hd_frames), ("unit-sd.png", sd_frames)]:
|
||||
atlas = Image.new("RGBA", (frames[0].width * len(frames), frames[0].height), (0, 0, 0, 0))
|
||||
for index, frame in enumerate(frames):
|
||||
atlas.alpha_composite(frame, (index * frame.width, 0))
|
||||
atlas.save(root / "art" / name)
|
||||
# Shared visual ammunition language; each package includes its own local copy.
|
||||
source = Image.open(root / "art/projectile-generated-v1.png").convert("RGBA")
|
||||
hd = source.resize((64, 64), Image.Resampling.LANCZOS)
|
||||
hd.save(root / "art/projectile-hd.png")
|
||||
hd.resize((8, 8), Image.Resampling.BOX).save(root / "art/projectile-sd.png")
|
||||
metadata = {"logical_size": [width, height], "hd_scale": 8, "origin": [0, 0],
|
||||
"frames": len(paths), "frame_order": ["idle", "charged"][:len(paths)],
|
||||
"alpha": "Preserved RGBA; runtime uses alpha>=128 indexed transparency",
|
||||
"registration": "Common full-canvas resize and equal padding for every frame",
|
||||
"sources": stats}
|
||||
if len(hd_frames) == 2:
|
||||
masks = [f.getchannel("A").point(lambda value: 255 if value >= 128 else 0) for f in hd_frames]
|
||||
pairs = list(zip(masks[0].get_flattened_data(), masks[1].get_flattened_data()))
|
||||
metadata["animation_silhouette_overlap"] = sum(bool(a and b) for a, b in pairs) / sum(bool(a or b) for a, b in pairs)
|
||||
if metadata["animation_silhouette_overlap"] < 0.99:
|
||||
raise ValueError("generated animation changes the silhouette beyond accepted registration tolerance")
|
||||
(root / "art-qa.json").write_text(json.dumps(metadata, indent=2) + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--audio-only", action="store_true")
|
||||
args = parser.parse_args()
|
||||
root = Path(__file__).resolve().parent
|
||||
kind = root.name.removeprefix("iron_coast_")
|
||||
sounds(root, kind)
|
||||
if not args.audio_only:
|
||||
artwork(root, kind)
|
||||
print(f"Generated {root.name} package assets")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"weapons": [
|
||||
{
|
||||
"id": "iron_coast_boss:projectile",
|
||||
"name": "Iron Coast Command Cruiser cannon shell",
|
||||
"damage": 3,
|
||||
"speed": 4,
|
||||
"cooldown": 54,
|
||||
"behavior": "straight",
|
||||
"target": "all",
|
||||
"art": {
|
||||
"png": "art/projectile-sd.png",
|
||||
"hd_png": "art/projectile-hd.png",
|
||||
"logical_width": 8,
|
||||
"logical_height": 8
|
||||
}
|
||||
}
|
||||
],
|
||||
"enemies": [
|
||||
{
|
||||
"id": "iron_coast_boss:unit",
|
||||
"name": "Iron Coast Command Cruiser",
|
||||
"health": 160,
|
||||
"speed": 0,
|
||||
"reward": 1800,
|
||||
"ground": false,
|
||||
"boss": true,
|
||||
"cooldown": 54,
|
||||
"art": {
|
||||
"png": "art/unit-sd.png",
|
||||
"hd_png": "art/unit-hd.png",
|
||||
"logical_width": 96,
|
||||
"logical_height": 64,
|
||||
"frames": 2
|
||||
},
|
||||
"script": "scripts/ai.rhai"
|
||||
}
|
||||
],
|
||||
"sounds": [
|
||||
{
|
||||
"id": "iron_coast_boss:shot",
|
||||
"name": "Iron Coast Command Cruiser cannon",
|
||||
"file": "audio/shot.wav",
|
||||
"volume": 95,
|
||||
"pan": 127,
|
||||
"pitch": 128,
|
||||
"priority": 2
|
||||
},
|
||||
{
|
||||
"id": "iron_coast_boss:death",
|
||||
"name": "Iron Coast Command Cruiser destruction",
|
||||
"file": "audio/death.wav",
|
||||
"volume": 120,
|
||||
"pan": 127,
|
||||
"pitch": 128,
|
||||
"priority": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
id = "iron_coast_boss"
|
||||
name = "Iron Coast Command Cruiser"
|
||||
version = "1.0.0"
|
||||
api_version = 1
|
||||
description = "A persistent patrol boss with a three-shell cannon fan. Includes SD and HD artwork, deterministic Rhai AI and generated sound effects."
|
||||
content = "content.json"
|
||||
scripts = ["scripts/ai.rhai"]
|
||||
@@ -0,0 +1,6 @@
|
||||
Use case: style-transfer
|
||||
Asset type: transparent HD projectile sprite for hostile cannon fire in a top-down military science-fiction shoot-em-up.
|
||||
Input: original Raptor ESHOT_BLK 8×8 amber projectile, enlarged nearest-neighbor, is a reference for color and readability.
|
||||
Primary request: a single small compact circular amber/orange energy shell viewed directly overhead, bright pale-yellow center fading to orange metal/plasma rim, dark crisp outer edge. Match the naval steel/amber Raptor remaster style. It will be reduced to an 8×8 logical square, so keep a simple readable round footprint and avoid microdetails. This is one ammunition projectile, no weapon or spacecraft.
|
||||
Composition: perfectly centered isolated circular sprite on square canvas, visible orb occupies about62% width/height with equal large transparent margins. Keep all glow contained inside the circular orb, no external haze, shadow, trail or rays. Do not stretch it.
|
||||
Constraints: true transparent background, no extra objects, no checkerboard or black background baked into pixels, no text, logos, watermarks, perspective or decorative framing.
|
||||
@@ -0,0 +1,3 @@
|
||||
Use case: precise-object-edit
|
||||
Asset type: second animation frame of the Iron Coast Command Cruiser boss HD sprite.
|
||||
Input image is the completed sprite edit target. Create a charged-weapons variant changing ONLY the existing small amber lamps and the two central circular side cannon wells: slightly brighten those lamps and illuminate inside each cannon well with a compact amber/orange core. Keep every hull panel, prong, silhouette edge, x/y anchor, exact 3:2 image dimensions, and camera view pixel-aligned with the input. No new external glow, muzzle flash, particles, smoke or shadow. Preserve the exact transparent background and empty side margins. Add a thin transparent margin around top/bottom if necessary to avoid edge clipping, but do not shift or reshape the vessel. Do not render a background, text, logos or watermarks. Single isolated overhead sprite facing DOWN.
|
||||
@@ -0,0 +1,6 @@
|
||||
Use case: style-transfer
|
||||
Asset type: transparent HD enemy boss sprite for a top-down vertical shoot-em-up, Iron Coast Command Cruiser.
|
||||
Input image: original Raptor SHIP32G1_PIC (96×64 logical canvas, enlarged nearest-neighbor) is the source of truth for silhouette, proportions, center anchoring and DOWNWARD orientation.
|
||||
Primary request: regenerate the pictured large military science-fiction coastal command cruiser as clean HD game art. Preserve the original very specific shape: tall central armored hull oriented vertically nose DOWN, twin small stern engine towers at the TOP, wide lateral blocky wing/weapon platforms across the upper-middle, a central bridge/reactor block, and two prongs flanking the narrow lower bow. Keep all sections recognizable and in the same positions. Heavy naval steel/slate plates, subtle olive and brown industrial accents, bolted hatches, narrow amber status lamps, small red service panels. Original utilitarian Raptor overhead sprite style with sharper surface detail. It is one aircraft/hover cruiser rather than a ship floating in water.
|
||||
Composition: a WIDE 3:2 transparent canvas corresponding exactly to96×64 logical pixels. Full perfectly top-down view without perspective. Cruiser centered on x48,y32. Keep original transparent side margins: actual vessel about55% of canvas width and94% of canvas height, with no clipping or cropped tips. Bow points downward. Do not rotate or enlarge the vessel to fill the side margins. Gentle light from upper-left, crisp silhouette, no external shadow.
|
||||
Constraints: one isolated single sprite, true transparent background. No sea, terrain, floor, wake, exhaust trails, motion blur, text, logos, watermark, smoke, UI, borders or painted checkerboard. Preserve the attached silhouette and exact canvas proportions.
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"api_version": 1,
|
||||
"mod_version": "1.0.0",
|
||||
"tool": "built-in image_gen.imagegen via imagegen skill",
|
||||
"skill": "/home/quintine/.codex/skills/.system/imagegen/SKILL.md",
|
||||
"generated_at": "2026-10-10",
|
||||
"source_reference": {
|
||||
"asset_id_hex": "10335",
|
||||
"catalog_path": "sprites/SHIP32G1_PIC",
|
||||
"sha256": "640ed8f1d45ead01337ce2ed95a1be1b44f88fe0cfecdc61e98537093d1e9324",
|
||||
"role": "Original silhouette/orientation/style reference inspected before imagegen. Original reference pixels excluded from Git under art/source/."
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"file": "art/unit-generated-v1.png",
|
||||
"prompt": "prompts/unit-hd-v1.txt",
|
||||
"tool_output": "exec-3e7092b2-f469-4d4d-9eee-c7d760172125.png",
|
||||
"sha256": "87d644a5d584ad82e285241e3a5cdc58e5c61fa851415a4d6660e0ae83ec5e85"
|
||||
},
|
||||
{
|
||||
"file": "art/unit-charge-generated-v1.png",
|
||||
"prompt": "prompts/unit-charge-v1.txt",
|
||||
"tool_output": "exec-7a5f11a2-bb90-4007-81c1-865e2058f912.png",
|
||||
"sha256": "cc8401140982260b4675a0a28fd209b8ed8d636e4cd4fa98a9cdf2fed37b1bff"
|
||||
},
|
||||
{
|
||||
"file": "art/projectile-generated-v1.png",
|
||||
"prompt": "prompts/projectile-hd-v1.txt",
|
||||
"tool_output": "exec-96076703-18b2-4aad-9bce-4eaceef4d8bd.png",
|
||||
"note": "Common generated amber projectile copied into each independent package.",
|
||||
"sha256": "386709d83ebf8a9e6c5119c785afc77152ae934780b3b0afcf8ede510622bc20"
|
||||
}
|
||||
],
|
||||
"pipeline": "build_assets.py: common full-canvas resize/padding, alpha-preserving RGBA SD/8xHD strips; no bitmap redraw",
|
||||
"version_control": "Generated masters, shipped SD/HD artwork, previews and synthesized WAVs included in Git per user request. Original catalog reference pixels excluded."
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
fn is_alive(event) {
|
||||
for unit in event.entities {
|
||||
if unit.entity == event.entity && unit.health > 0 { return true; }
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// Per-entity directions permit multiple cruisers without sharing a patrol cursor.
|
||||
fn on_spawn(event, state, random) {
|
||||
if !is_alive(event) { return #{ commands: [], state: state }; }
|
||||
if !state.contains("directions") { state.directions = #{}; }
|
||||
state.directions[event.entity.to_string()] = 1;
|
||||
#{ commands: [#{ op: "velocity", entity: event.entity, vx: 0, vy: 2 }], state: state }
|
||||
}
|
||||
|
||||
fn on_tick(event, state, random) {
|
||||
if event.health <= 0 || !is_alive(event) { return #{ commands: [], state: state }; }
|
||||
if event.y < 24 {
|
||||
return #{ commands: [#{ op: "velocity", entity: event.entity, vx: 0, vy: 2 }], state: state };
|
||||
}
|
||||
let key = event.entity.to_string();
|
||||
if !state.contains("directions") { state.directions = #{}; }
|
||||
let direction = if state.directions.contains(key) { state.directions[key] } else { 1 };
|
||||
if event.x >= 144 { direction = -1; }
|
||||
if event.x <= 80 { direction = 1; }
|
||||
state.directions[key] = direction;
|
||||
let commands = [
|
||||
#{ op: "move", entity: event.entity, x: event.x, y: 24 },
|
||||
#{ op: "velocity", entity: event.entity, vx: direction, vy: 0 }
|
||||
];
|
||||
if event.tick % 54 == 0 {
|
||||
commands.push(#{ op: "projectile", weapon: "iron_coast_boss:projectile", x: event.x + 35, y: 64, vx: -2, vy: 4, hostile: true });
|
||||
commands.push(#{ op: "projectile", weapon: "iron_coast_boss:projectile", x: event.x + 48, y: 64, vx: 0, vy: 4, hostile: true });
|
||||
commands.push(#{ op: "projectile", weapon: "iron_coast_boss:projectile", x: event.x + 61, y: 64, vx: 2, vy: 4, hostile: true });
|
||||
commands.push(#{ op: "play_sound", sound: "iron_coast_boss:shot" });
|
||||
state.shots = if state.contains("shots") { state.shots + 1 } else { 1 };
|
||||
}
|
||||
#{ commands: commands, state: state }
|
||||
}
|
||||
|
||||
fn on_hit(event, state, random) {
|
||||
state.hits = if state.contains("hits") { state.hits + 1 } else { 1 };
|
||||
#{ commands: [], state: state }
|
||||
}
|
||||
|
||||
fn on_death(event, state, random) {
|
||||
if state.contains("directions") { state.directions.remove(event.entity.to_string()); }
|
||||
state.destroyed = if state.contains("destroyed") { state.destroyed + 1 } else { 1 };
|
||||
let defeated = if event.objectives.contains("iron_coast_boss:defeated") { event.objectives["iron_coast_boss:defeated"] + 1 } else { 1 };
|
||||
#{ commands: [
|
||||
#{ op: "objective", key: "iron_coast_boss:defeated", value: defeated },
|
||||
#{ op: "effect", x: event.x + 35, y: event.y + 36 },
|
||||
#{ op: "effect", x: event.x + 61, y: event.y + 36 },
|
||||
#{ op: "play_sound", sound: "iron_coast_boss:death" }
|
||||
], state: state }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# Iron Coast: Relay Warden
|
||||
|
||||
Standalone API 1 boss package used by the Tidal Relay mission. It has no dependencies and never decides when a mission finishes. Enable the mission to select this package automatically through its dependency.
|
||||
|
||||
- Enemy: `iron_coast_boss_2:unit`, health 160, reward 1,800 credits, airborne boss.
|
||||
- Projectile: `iron_coast_boss_2:projectile`, damage 3, speed 4, 8×8 logical canvas.
|
||||
- Completion observation: `iron_coast_boss_2:defeated` increases once for each actual death. Duplicate notifications are harmless; despawning or time passing does not count.
|
||||
- Art: one complete static frame on a 96×64 logical canvas. SD and HD preserve the same origin, full silhouette and true alpha. HD is 768×512; gameplay collision dimensions remain 96×64.
|
||||
- Audio: `shot`, `warning` and `death` namespaced sound IDs, each a generated, non-silent 22,050 Hz, mono, 16-bit PCM WAV.
|
||||
|
||||
The Warden enters at two logical pixels per tick, stops at y=24, and patrols x=80…144. Each entity tracks its own arrival timer and direction. An audible radar charge starts 12 ticks before each 54-tick volley. Odd volleys fire two parallel outer lanes; even volleys fire two diverging inner lanes. The opening below its central radar dish remains readable. Each queued spawn registers its life even if a projectile has already killed it before callback dispatch; that early kill still counts when the engine delivers its actual death. Timers and directions are removed when the aircraft dies, and dead units cannot emit movement, warnings or shots. Difficulty scaling comes from the engine, including its existing half-health rule on the two lower difficulties.
|
||||
|
||||
## Asset sources
|
||||
|
||||
The two masters were generated using the built-in imagegen tool from the saved prompts, with the existing generated Iron Coast cruiser inspected for material and color style. The Warden and cyan radar pulse have new silhouettes; no original game art or recordings are copied into this package. `provenance.json`, `art-qa.json` and `audio-recipe.json` record inputs, processing, alpha bounds and hashes. All PNGs, WAVs and prompts are intended for Git.
|
||||
|
||||
Recreate the SD/HD output and audio from the saved masters with:
|
||||
|
||||
```sh
|
||||
python3 RustRaptor/mods/iron_coast_boss_2/build_assets.py
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
The package contains a small Rust validation program that uses the game's public modding and deterministic simulation API. It validates package artwork and WAV decoding, both telegraphs and alternating volleys, separate entity timers, actual death rewards/objectives, repeated and dead callbacks, cleanup, a real lethal projectile during the spawn step, and reuse of an entity ID for a new life. It requires no original game assets.
|
||||
|
||||
```sh
|
||||
CARGO_TARGET_DIR=RustRaptor/target cargo run --offline \
|
||||
--manifest-path RustRaptor/mods/iron_coast_boss_2/validation/Cargo.toml \
|
||||
--bin validate-boss
|
||||
```
|
||||
|
||||
Its saved result is `validation/report.json`. Tidal Relay's validation also exercises this boss in the complete mission with the real original content registry.
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"logical_unit": [
|
||||
96,
|
||||
64
|
||||
],
|
||||
"logical_projectile": [
|
||||
8,
|
||||
8
|
||||
],
|
||||
"hd_scale": 8,
|
||||
"frames": 1,
|
||||
"alpha": "True RGBA; runtime palette mapping uses alpha >= 128",
|
||||
"conversion": "Full source canvas Lanczos HD, BOX SD; no sprite redraw or catalog copies",
|
||||
"files": [
|
||||
{
|
||||
"file": "art/unit-sd.png",
|
||||
"size": [
|
||||
96,
|
||||
64
|
||||
],
|
||||
"opaque_bounds": [
|
||||
3,
|
||||
3,
|
||||
93,
|
||||
62
|
||||
],
|
||||
"source": "art/unit-generated-v1.png",
|
||||
"alpha_min_max": [
|
||||
0,
|
||||
254
|
||||
],
|
||||
"sha256": "0d41d6bf34f7812b684c724aa44c9477187dfbe7a38faa958f0d3887d7e9770d"
|
||||
},
|
||||
{
|
||||
"file": "art/unit-hd.png",
|
||||
"size": [
|
||||
768,
|
||||
512
|
||||
],
|
||||
"opaque_bounds": [
|
||||
20,
|
||||
21,
|
||||
748,
|
||||
498
|
||||
],
|
||||
"source": "art/unit-generated-v1.png",
|
||||
"alpha_min_max": [
|
||||
0,
|
||||
255
|
||||
],
|
||||
"sha256": "272e1a206d40e06ddc54a0c9cb763db9a776647d3392c2a063cc551cfab939cb"
|
||||
},
|
||||
{
|
||||
"file": "art/projectile-sd.png",
|
||||
"size": [
|
||||
8,
|
||||
8
|
||||
],
|
||||
"opaque_bounds": [
|
||||
3,
|
||||
2,
|
||||
5,
|
||||
5
|
||||
],
|
||||
"source": "art/projectile-generated-v1.png",
|
||||
"alpha_min_max": [
|
||||
0,
|
||||
225
|
||||
],
|
||||
"sha256": "f871add4de5a44d6dff953d12369bcff56c2a1f65bdffc861d962b8d1b6616e4"
|
||||
},
|
||||
{
|
||||
"file": "art/projectile-hd.png",
|
||||
"size": [
|
||||
64,
|
||||
64
|
||||
],
|
||||
"opaque_bounds": [
|
||||
24,
|
||||
13,
|
||||
40,
|
||||
49
|
||||
],
|
||||
"source": "art/projectile-generated-v1.png",
|
||||
"alpha_min_max": [
|
||||
0,
|
||||
255
|
||||
],
|
||||
"sha256": "abd22235128301f609feebcc73bc3910ef248b678002cea88cf655efd739ffb6"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 135 B |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 363 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"generator": "build_assets.py deterministic chirp/noise synthesis; no recordings",
|
||||
"files": [
|
||||
{
|
||||
"file": "audio/shot.wav",
|
||||
"sample_rate": 22050,
|
||||
"channels": 1,
|
||||
"bits": 16,
|
||||
"samples": 4630,
|
||||
"duration": 0.21,
|
||||
"peak": 25558,
|
||||
"rms": 4171.3,
|
||||
"seed": 212,
|
||||
"sha256": "67c6b5b2dd9748584f9db50c3c251cc441218a3600b40f5c96407c2da5928d62"
|
||||
},
|
||||
{
|
||||
"file": "audio/warning.wav",
|
||||
"sample_rate": 22050,
|
||||
"channels": 1,
|
||||
"bits": 16,
|
||||
"samples": 7938,
|
||||
"duration": 0.36,
|
||||
"peak": 25558,
|
||||
"rms": 8556.4,
|
||||
"seed": 222,
|
||||
"sha256": "915cf9a4e73f7d3a9b89e036640211e8f052bc490637c5244566f3c35f69080e"
|
||||
},
|
||||
{
|
||||
"file": "audio/death.wav",
|
||||
"sample_rate": 22050,
|
||||
"channels": 1,
|
||||
"bits": 16,
|
||||
"samples": 20947,
|
||||
"duration": 0.95,
|
||||
"peak": 25558,
|
||||
"rms": 3815.13,
|
||||
"seed": 232,
|
||||
"sha256": "0b836bfcf01aea73587dcbb5a26460dbf6153acc03732fe0c047401a62ad2dfa"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resize saved generated masters and synthesize reproducible package WAVs.
|
||||
|
||||
No original assets are copied. Canvas resampling retains the master's true alpha;
|
||||
art is never redrawn. Run from any directory with Python 3 and Pillow installed.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import struct
|
||||
import wave
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
RATE = 22050
|
||||
|
||||
|
||||
def sha(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def artwork():
|
||||
stats = []
|
||||
for name, size, scale in [('unit', (96, 64), 8), ('projectile', (8, 8), 8)]:
|
||||
path = ROOT / 'art' / (name + '-generated-v1.png')
|
||||
original = Image.open(path).convert('RGBA')
|
||||
assert original.getchannel('A').getextrema()[0] == 0
|
||||
if name == 'unit':
|
||||
assert original.width * size[1] == original.height * size[0]
|
||||
hd = original.resize((size[0] * scale, size[1] * scale), Image.Resampling.LANCZOS)
|
||||
else:
|
||||
# Keep the complete square alpha canvas; this preserves every source pixel.
|
||||
side = max(original.size)
|
||||
canvas = Image.new('RGBA', (side, side), (0, 0, 0, 0))
|
||||
canvas.alpha_composite(original, ((side-original.width)//2, (side-original.height)//2))
|
||||
hd = canvas.resize((size[0] * scale, size[1] * scale), Image.Resampling.LANCZOS)
|
||||
sd = hd.resize(size, Image.Resampling.BOX)
|
||||
for mode, sprite in [('sd', sd), ('hd', hd)]:
|
||||
alpha = sprite.getchannel('A')
|
||||
mask = alpha.point(lambda a: 255 if a >= 128 else 0)
|
||||
bounds = mask.getbbox()
|
||||
assert bounds and bounds[0] > 0 and bounds[1] > 0
|
||||
assert bounds[2] < sprite.width and bounds[3] < sprite.height
|
||||
out = ROOT / 'art' / (name + '-' + mode + '.png')
|
||||
sprite.save(out)
|
||||
stats.append({'file': str(out.relative_to(ROOT)), 'size': list(sprite.size),
|
||||
'opaque_bounds': list(bounds), 'source': str(path.relative_to(ROOT)),
|
||||
'alpha_min_max': list(alpha.getextrema()), 'sha256': sha(out)})
|
||||
(ROOT / 'art-qa.json').write_text(json.dumps({
|
||||
'logical_unit': [96, 64], 'logical_projectile': [8, 8], 'hd_scale': 8,
|
||||
'frames': 1, 'alpha': 'True RGBA; runtime palette mapping uses alpha >= 128',
|
||||
'conversion': 'Full source canvas Lanczos HD, BOX SD; no sprite redraw or catalog copies',
|
||||
'files': stats}, indent=2) + '\n')
|
||||
|
||||
|
||||
def audio():
|
||||
stats = []
|
||||
for name, duration, seed in [('shot', 0.21, 212), ('warning', 0.36, 222), ('death', 0.95, 232)]:
|
||||
rng = random.Random(seed)
|
||||
count = int(duration * RATE)
|
||||
phase = 0.0
|
||||
low_noise = 0.0
|
||||
signal = []
|
||||
for n in range(count):
|
||||
t = n / RATE
|
||||
p = n / max(1, count-1)
|
||||
noise = rng.uniform(-1, 1)
|
||||
low_noise = low_noise * .86 + noise * .14
|
||||
if name == 'warning':
|
||||
# Two brief rising radar pips separated by a clear gap.
|
||||
local = t % .18
|
||||
frequency = 1450 + 900 * min(1, local / .11)
|
||||
phase += 2 * math.pi * frequency / RATE
|
||||
envelope = (math.sin(math.pi * min(1, local / .12)) ** 2 if local < .12 else 0)
|
||||
value = .68 * math.sin(phase) + .15 * math.sin(phase * 2)
|
||||
elif name == 'shot':
|
||||
phase += 2 * math.pi * (170 + 710 * (1-p)**2) / RATE
|
||||
value = .60*math.sin(phase) + .22*math.sin(phase*1.52) + .12*noise
|
||||
envelope = min(1, t / .003) * math.exp(-p*6.0)
|
||||
else:
|
||||
phase += 2 * math.pi * (80 - 55*p) / RATE
|
||||
value = .65*low_noise + .20*noise + .26*math.sin(phase)
|
||||
envelope = min(1, t / .004) * math.exp(-p*4.5)
|
||||
envelope *= min(1, (count-1-n) / (RATE*.005))
|
||||
signal.append(value * max(0, envelope))
|
||||
maximum = max(abs(x) for x in signal)
|
||||
pcm = [round(v / maximum * 0.78 * 32767) for v in signal]
|
||||
path = ROOT / 'audio' / (name + '.wav')
|
||||
with wave.open(str(path), 'wb') as wav:
|
||||
wav.setnchannels(1); wav.setsampwidth(2); wav.setframerate(RATE)
|
||||
wav.writeframes(struct.pack('<' + 'h'*len(pcm), *pcm))
|
||||
stats.append({'file': str(path.relative_to(ROOT)), 'sample_rate': RATE,
|
||||
'channels': 1, 'bits': 16, 'samples': count, 'duration': duration,
|
||||
'peak': max(abs(x) for x in pcm),
|
||||
'rms': round(math.sqrt(sum(x*x for x in pcm)/len(pcm)),2),
|
||||
'seed': seed, 'sha256': sha(path)})
|
||||
(ROOT / 'audio-recipe.json').write_text(json.dumps({
|
||||
'generator': 'build_assets.py deterministic chirp/noise synthesis; no recordings',
|
||||
'files': stats}, indent=2) + '\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
artwork()
|
||||
audio()
|
||||
print('Generated Relay Warden SD/HD art and three non-silent WAVs')
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"weapons": [
|
||||
{
|
||||
"id": "iron_coast_boss_2:projectile",
|
||||
"name": "Relay Warden radar pulse",
|
||||
"damage": 3,
|
||||
"speed": 4,
|
||||
"cooldown": 54,
|
||||
"behavior": "straight",
|
||||
"target": "all",
|
||||
"art": {
|
||||
"png": "art/projectile-sd.png",
|
||||
"hd_png": "art/projectile-hd.png",
|
||||
"logical_width": 8,
|
||||
"logical_height": 8
|
||||
}
|
||||
}
|
||||
],
|
||||
"enemies": [
|
||||
{
|
||||
"id": "iron_coast_boss_2:unit",
|
||||
"name": "Relay Warden",
|
||||
"health": 160,
|
||||
"speed": 0,
|
||||
"reward": 1800,
|
||||
"ground": false,
|
||||
"boss": true,
|
||||
"cooldown": 54,
|
||||
"art": {
|
||||
"png": "art/unit-sd.png",
|
||||
"hd_png": "art/unit-hd.png",
|
||||
"logical_width": 96,
|
||||
"logical_height": 64,
|
||||
"frames": 1
|
||||
},
|
||||
"script": "scripts/ai.rhai"
|
||||
}
|
||||
],
|
||||
"sounds": [
|
||||
{ "id": "iron_coast_boss_2:shot", "name": "Relay Warden pulse volley", "file": "audio/shot.wav", "volume": 95, "pan": 127, "pitch": 128, "priority": 2 },
|
||||
{ "id": "iron_coast_boss_2:warning", "name": "Relay Warden radar charge", "file": "audio/warning.wav", "volume": 86, "pan": 127, "pitch": 128, "priority": 2 },
|
||||
{ "id": "iron_coast_boss_2:death", "name": "Relay Warden destruction", "file": "audio/death.wav", "volume": 120, "pan": 127, "pitch": 128, "priority": 4 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
id = "iron_coast_boss_2"
|
||||
name = "Iron Coast: Relay Warden"
|
||||
version = "1.0.0"
|
||||
api_version = 1
|
||||
description = "A broad radar-aircraft boss for Tidal Relay, with an audible warning and alternating two-lane pulse volleys. Includes transparent SD/HD art and generated sounds."
|
||||
content = "content.json"
|
||||
scripts = ["scripts/ai.rhai"]
|
||||
@@ -0,0 +1,5 @@
|
||||
Use case: stylized-concept
|
||||
Asset type: one transparent projectile sprite for Relay Warden radar-aircraft boss in Iron Coast top-down shooter.
|
||||
Primary request: small compact cyan radar pulse, a vertically pointed metallic dart with dark gunmetal center and bright cyan electrical collar arranged around it. Directly overhead, dart nose points DOWN. Flat orthographic view, narrow central dart inside a short cyan oval electrical sheath. Restrained bright cyan body, white small central core, dark gunmetal stub fins; the silhouette stays compact and readable at 8x8 logical pixels. No long trails. This is different from a fireball or explosion.
|
||||
Composition: one whole projectile centered in a square transparent canvas, with 18% blank margin all sides.
|
||||
Constraints: genuinely transparent RGBA background; no text, no logo, no scene, no checkerboard artwork, no exterior shadow, no second projectile. Preserve crisp self-contained alpha silhouette.
|
||||
@@ -0,0 +1,7 @@
|
||||
Use case: stylized-concept
|
||||
Asset type: genuinely transparent HD boss sprite for top-down 2D vertical scrolling shooter; Relay Warden, new Iron Coast electronic warfare boss.
|
||||
Input reference image: inspected Iron Coast Command Cruiser is a STYLE reference only; create a DIFFERENT silhouette, a broad aircraft with wingspan rather than a narrow cruiser.
|
||||
Primary request: one large broad industrial radar aircraft, strict orthographic view directly above, pointed nose facing DOWN the image toward bottom edge. Strong symmetrical swept armored wing spanning horizontally, gunmetal grey metal plates, tiny dark red markings, a prominent circular radar dish centered on the upper fuselage, two orange engine exhaust slots facing up, two downward-facing cannon muzzles integrated into wing roots. Radar dish is a substantial physical armored circular fixture, not glowing energy. Heavy believable 1990s Raptor-inspired industrial aircraft design with crisp edge detail and restrained textures.
|
||||
Composition: single whole sprite centered, exact full canvas aspect 3:2, aircraft fills roughly 82% canvas width and 82% canvas height with clear transparent margin on every side, nose down, whole wings and tail included. Flat overhead lighting from upper left; no perspective, no horizon.
|
||||
Palette: gunmetal and slate grey, restrained dark red panels, amber indicators.
|
||||
Constraints: true RGBA transparent background, no shadow outside aircraft, no text, no labels, no logo, no watermark, no scene, no additional sprites, no tile grid. No white/matte fringe. Ship should remain readable when reduced to 96 by 64 pixels.
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"api_version": 1,
|
||||
"mod_version": "1.0.0",
|
||||
"tool": "built-in image_gen.imagegen via imagegen skill",
|
||||
"skill": "/home/quintine/.codex/skills/.system/imagegen/SKILL.md",
|
||||
"generated_at": "2026-10-10",
|
||||
"style_reference": {
|
||||
"path": "../iron_coast_boss/art/unit-hd.png",
|
||||
"role": "Inspected generated Iron Coast boss artwork used only as material/style reference; new broad radar aircraft silhouette and unique projectile generated independently",
|
||||
"original_catalog_pixels_copied": false
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"file": "art/unit-generated-v1.png",
|
||||
"prompt": "prompts/unit-hd-v1.txt",
|
||||
"tool_output": "exec-28f23852-b44b-4461-89d2-eb72c6e150a0.png",
|
||||
"sha256": "ef00f35f6df18d5590e7c64168fa071daf31b116dca36f86542f1852865ce5ab"
|
||||
},
|
||||
{
|
||||
"file": "art/projectile-generated-v1.png",
|
||||
"prompt": "prompts/projectile-hd-v1.txt",
|
||||
"tool_output": "exec-7200b663-a79e-4899-a062-3d06a7bae851.png",
|
||||
"sha256": "51fe35c238b299fbcac748278e6799c6e4bfe30439471ad064d8a696ca97b147"
|
||||
}
|
||||
],
|
||||
"pipeline": "build_assets.py: full-canvas alpha-preserving Lanczos HD / BOX SD, deterministic 22050Hz 16-bit mono sound synthesis",
|
||||
"version_control": "All generated masters, shipped SD/HD PNGs, WAVs, prompts and provenance tracked in Git per user request"
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// All cursors are per entity. Arrival and attack timers use simulation callbacks.
|
||||
fn is_alive(event) {
|
||||
for unit in event.entities {
|
||||
if unit.entity == event.entity && unit.health > 0 { return true; }
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn on_spawn(event, state, random) {
|
||||
// Register the life before checking current liveness: the engine can kill a
|
||||
// queued spawn during advance, before it dispatches this callback. Its later
|
||||
// actual death still needs one objective update, without movement or audio.
|
||||
if !state.contains("directions") { state.directions = #{}; }
|
||||
if !state.contains("ages") { state.ages = #{}; }
|
||||
let key = event.entity.to_string();
|
||||
state.directions[key] = 1;
|
||||
state.ages[key] = 0;
|
||||
if !is_alive(event) { return #{ commands: [], state: state }; }
|
||||
#{ commands: [#{ op: "velocity", entity: event.entity, vx: 0, vy: 2 }], state: state }
|
||||
}
|
||||
|
||||
fn on_tick(event, state, random) {
|
||||
if event.health <= 0 || !is_alive(event) { return #{ commands: [], state: state }; }
|
||||
if event.y < 24 {
|
||||
return #{ commands: [#{ op: "velocity", entity: event.entity, vx: 0, vy: 2 }], state: state };
|
||||
}
|
||||
let key = event.entity.to_string();
|
||||
// An untracked unit cannot acquire another entity's attack timer.
|
||||
if !state.contains("ages") || !state.ages.contains(key) {
|
||||
return #{ commands: [], state: state };
|
||||
}
|
||||
let direction = state.directions[key];
|
||||
if event.x >= 144 { direction = -1; }
|
||||
if event.x <= 80 { direction = 1; }
|
||||
state.directions[key] = direction;
|
||||
let age = state.ages[key] + 1;
|
||||
state.ages[key] = age;
|
||||
let commands = [
|
||||
#{ op: "move", entity: event.entity, x: event.x, y: 24 },
|
||||
#{ op: "velocity", entity: event.entity, vx: direction, vy: 0 }
|
||||
];
|
||||
// Twelve ticks of warning precede the shot: about half a second at 70/3 Hz.
|
||||
if age % 54 == 42 {
|
||||
commands.push(#{ op: "play_sound", sound: "iron_coast_boss_2:warning" });
|
||||
}
|
||||
if age % 54 == 0 {
|
||||
// Alternate parallel outer lanes with a diverging inner fork.
|
||||
// Both leave a readable opening directly under the central radar dish.
|
||||
if (age / 54) % 2 == 1 {
|
||||
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_2:projectile", x: event.x + 24, y: 75, vx: 0, vy: 4, hostile: true });
|
||||
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_2:projectile", x: event.x + 72, y: 75, vx: 0, vy: 4, hostile: true });
|
||||
} else {
|
||||
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_2:projectile", x: event.x + 36, y: 75, vx: -1, vy: 4, hostile: true });
|
||||
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_2:projectile", x: event.x + 60, y: 75, vx: 1, vy: 4, hostile: true });
|
||||
}
|
||||
commands.push(#{ op: "play_sound", sound: "iron_coast_boss_2:shot" });
|
||||
state.shots = if state.contains("shots") { state.shots + 1 } else { 1 };
|
||||
}
|
||||
#{ commands: commands, state: state }
|
||||
}
|
||||
|
||||
fn on_hit(event, state, random) {
|
||||
state.hits = if state.contains("hits") { state.hits + 1 } else { 1 };
|
||||
#{ commands: [], state: state }
|
||||
}
|
||||
|
||||
fn on_death(event, state, random) {
|
||||
let key = event.entity.to_string();
|
||||
// A death callback must refer to a spawned unit that is absent from live queries.
|
||||
// Removing its cursor also makes repeated death notifications harmless.
|
||||
if is_alive(event) || !state.contains("ages") || !state.ages.contains(key) {
|
||||
return #{ commands: [], state: state };
|
||||
}
|
||||
state.ages.remove(key);
|
||||
state.directions.remove(key);
|
||||
state.destroyed = if state.contains("destroyed") { state.destroyed + 1 } else { 1 };
|
||||
let defeated = if event.objectives.contains("iron_coast_boss_2:defeated") { event.objectives["iron_coast_boss_2:defeated"] + 1 } else { 1 };
|
||||
#{ commands: [
|
||||
#{ op: "objective", key: "iron_coast_boss_2:defeated", value: defeated },
|
||||
#{ op: "effect", x: event.x + 32, y: event.y + 38 },
|
||||
#{ op: "effect", x: event.x + 64, y: event.y + 38 },
|
||||
#{ op: "play_sound", sound: "iron_coast_boss_2:death" }
|
||||
], state: state }
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"const-random",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide 0.9.1",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iron-coast-boss-2-validation"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"raptor-replica",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "no-std-compat"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
|
||||
dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "raptor-replica"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"image",
|
||||
"rhai",
|
||||
"schemars",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rhai"
|
||||
version = "1.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0334639972c0ea5a3fd366aa36116754a11431b619fec3ed559b3f73bcbcebf5"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bitflags",
|
||||
"no-std-compat",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"rhai_codegen",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"smartstring",
|
||||
"thin-vec",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rhai_codegen"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3cd3a7535e50bf36857e7be7bec276d334e8c2dfa469c2201226fd01638ea5ca"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"schemars_derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive_internals"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smartstring"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"static_assertions",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thin-vec"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6a4b9ba8738cb4a4f399d37e266becfd475e75eb73425b87a05a2f2039ba63e"
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.62"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.62"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "iron-coast-boss-2-validation"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
raptor-replica = { path = "../../..", default-features = false }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
|
||||
[[bin]]
|
||||
name = "validate-boss"
|
||||
path = "validate.rs"
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"actual_cues": [
|
||||
"iron_coast_boss_2:shot",
|
||||
"iron_coast_boss_2:warning"
|
||||
],
|
||||
"actual_engine_tick": 271,
|
||||
"dead_or_repeated_callbacks": "no commands",
|
||||
"death_objective": 1,
|
||||
"first_volley": [
|
||||
[
|
||||
136,
|
||||
75,
|
||||
0,
|
||||
4,
|
||||
true,
|
||||
"iron_coast_boss_2:projectile"
|
||||
],
|
||||
[
|
||||
184,
|
||||
75,
|
||||
0,
|
||||
4,
|
||||
true,
|
||||
"iron_coast_boss_2:projectile"
|
||||
]
|
||||
],
|
||||
"mod": "iron_coast_boss_2",
|
||||
"outcome": "Playing",
|
||||
"package_validation": "passed; PNGs, sound decoding, manifest and script",
|
||||
"per_entity_state_cleanup": "passed",
|
||||
"queued_spawn_step_lethal_projectile": "passed",
|
||||
"reused_entity_id_new_life": "counts once",
|
||||
"reward": 1800,
|
||||
"second_volley": [
|
||||
[
|
||||
148,
|
||||
75,
|
||||
-1,
|
||||
4,
|
||||
true,
|
||||
"iron_coast_boss_2:projectile"
|
||||
],
|
||||
[
|
||||
172,
|
||||
75,
|
||||
1,
|
||||
4,
|
||||
true,
|
||||
"iron_coast_boss_2:projectile"
|
||||
]
|
||||
],
|
||||
"volley_ages": [
|
||||
54,
|
||||
108
|
||||
],
|
||||
"warning_ages": [
|
||||
42,
|
||||
96
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Standalone mod acceptance without original copyrighted game data.
|
||||
use anyhow::{Result, ensure};
|
||||
use raptor_replica::{
|
||||
content::{ContentId, ContentPack, Registry},
|
||||
modding::{self, ActiveMods, Command, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, Outcome, Projectile, World},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
const OWNER: &str = "iron_coast_boss_2";
|
||||
const SCRIPT: &str = "scripts/ai.rhai";
|
||||
const STATE: &str = "iron_coast_boss_2/scripts/ai.rhai";
|
||||
const OBJECTIVE: &str = "iron_coast_boss_2:defeated";
|
||||
|
||||
fn event(entity: u64, health: i32, x: i32, y: i32) -> Value {
|
||||
json!({"entity":entity,"health":health,"x":x,"y":y,"tick":900,
|
||||
"objectives":{},"entities":[{"entity":entity,"health":health,"x":x,"y":y}]})
|
||||
}
|
||||
fn invoke(runtime: &mut ScriptRuntime, hook: &str, event: Value) -> Result<Vec<Command>> {
|
||||
runtime.begin_tick();
|
||||
runtime.invoke(OWNER, SCRIPT, hook, &event, 31)
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let mods = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()?;
|
||||
let mut base = Registry::default();
|
||||
let content: ContentPack = serde_json::from_value(json!({
|
||||
"weapons":[{"id":"base:projectile_forward_guns","name":"Forward guns","damage":1000,"speed":8,"cooldown":2,"behavior":"straight","target":"all"}],
|
||||
"items":[{"id":"base:forward_guns","name":"Forward guns","price":0,"weapon":"base:projectile_forward_guns"},
|
||||
{"id":"base:energy","name":"Shield","price":0,"max_count":100}],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Boss test","duration":10000,"auto_complete":false,
|
||||
"spawns":[{"tick":1,"enemy":"iron_coast_boss_2:unit","x":112,"y":-64}]}]
|
||||
}))?;
|
||||
base.apply(content, "base")?;
|
||||
let discovery = modding::discover(&mods);
|
||||
let mut active = ActiveMods::build(&base, &discovery, &[OWNER.into()], None)?;
|
||||
let def = &active.registry.enemies[&ContentId::from("iron_coast_boss_2:unit")];
|
||||
ensure!(def.health == 160 && def.reward == 1800 && def.boss && def.speed == 0);
|
||||
let rt = &mut active.scripts;
|
||||
let mut cues = Vec::new();
|
||||
invoke(rt, "on_spawn", event(1, 160, 112, 24))?;
|
||||
let mut first = Vec::new();
|
||||
let mut second = Vec::new();
|
||||
for age in 1..=108 {
|
||||
let commands = invoke(rt, "on_tick", event(1, 160, 112, 24))?;
|
||||
for command in &commands {
|
||||
if let Command::PlaySound { sound } = command {
|
||||
cues.push((age, sound.clone()));
|
||||
}
|
||||
}
|
||||
let shots: Vec<_> = commands
|
||||
.iter()
|
||||
.filter_map(|command| {
|
||||
if let Command::Projectile {
|
||||
x,
|
||||
y,
|
||||
vx,
|
||||
vy,
|
||||
hostile,
|
||||
weapon,
|
||||
} = command
|
||||
{
|
||||
Some((*x, *y, *vx, *vy, *hostile, weapon.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if age == 54 {
|
||||
first = shots;
|
||||
} else if age == 108 {
|
||||
second = shots;
|
||||
} else {
|
||||
ensure!(shots.is_empty(), "unexpected volley at age {age}");
|
||||
}
|
||||
}
|
||||
ensure!(first.len() == 2 && second.len() == 2 && first != second);
|
||||
ensure!(first.iter().all(|p| p.2 == 0 && p.3 == 4 && p.4));
|
||||
ensure!(second[0].2 == -1 && second[1].2 == 1);
|
||||
ensure!(
|
||||
cues == vec![
|
||||
(42, format!("{OWNER}:warning")),
|
||||
(54, format!("{OWNER}:shot")),
|
||||
(96, format!("{OWNER}:warning")),
|
||||
(108, format!("{OWNER}:shot"))
|
||||
]
|
||||
);
|
||||
invoke(rt, "on_spawn", event(2, 160, 112, 24))?;
|
||||
ensure!(rt.states[STATE]["ages"]["2"] == 0);
|
||||
ensure!(
|
||||
invoke(rt, "on_death", event(1, 160, 112, 24))?.is_empty(),
|
||||
"living unit cannot register a death"
|
||||
);
|
||||
ensure!(
|
||||
invoke(rt, "on_tick", event(1, 0, 112, 24))?.is_empty(),
|
||||
"dead unit cannot fire or warn"
|
||||
);
|
||||
let mut death = event(1, 0, 112, 24);
|
||||
death["entities"] = json!([{"entity":2,"health":160}]);
|
||||
let commands = invoke(rt, "on_death", death.clone())?;
|
||||
ensure!(
|
||||
commands
|
||||
.iter()
|
||||
.any(|c| matches!(c,Command::Objective{key,value} if key==OBJECTIVE && *value==1))
|
||||
);
|
||||
ensure!(commands.iter().all(|c| !matches!(c, Command::Complete)));
|
||||
ensure!(
|
||||
invoke(rt, "on_death", death)?.is_empty(),
|
||||
"duplicate death cannot increment objective"
|
||||
);
|
||||
ensure!(rt.states[STATE]["ages"].get("1").is_none());
|
||||
ensure!(
|
||||
rt.states[STATE]["ages"]["2"] == 0,
|
||||
"another entity retains independent cursor"
|
||||
);
|
||||
let mut death2 = event(2, 0, 112, 24);
|
||||
death2["entities"] = json!([]);
|
||||
death2["objectives"] = json!({OBJECTIVE:1});
|
||||
let commands = invoke(rt, "on_death", death2)?;
|
||||
ensure!(
|
||||
commands
|
||||
.iter()
|
||||
.any(|c| matches!(c,Command::Objective{key,value} if key==OBJECTIVE && *value==2))
|
||||
);
|
||||
ensure!(rt.states[STATE]["ages"].as_object().unwrap().is_empty());
|
||||
ensure!(
|
||||
rt.states[STATE]["directions"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// Run actual engine hooks and command application; no scripted mission completion.
|
||||
active = ActiveMods::build(&base, &discovery, &[OWNER.into()], None)?;
|
||||
let pilot = Pilot::default();
|
||||
let mut world = World::new(&pilot.mission, &pilot, &active.registry)?;
|
||||
let mut actual_cues = std::collections::BTreeSet::new();
|
||||
for _ in 0..150 {
|
||||
world.step(Input::default(), &active.registry, &mut active.scripts)?;
|
||||
for cue in &world.mod_sounds {
|
||||
actual_cues.insert(cue.0.clone());
|
||||
}
|
||||
}
|
||||
ensure!(world.enemies.len() == 1 && world.enemies[0].y == 24);
|
||||
ensure!(world.enemies[0].x >= 79 && world.enemies[0].x <= 145);
|
||||
ensure!(
|
||||
actual_cues.contains("iron_coast_boss_2:warning")
|
||||
&& actual_cues.contains("iron_coast_boss_2:shot")
|
||||
);
|
||||
let initial_credits = world.player.credits;
|
||||
world.enemies[0].health = 0;
|
||||
world.step(Input::default(), &active.registry, &mut active.scripts)?;
|
||||
ensure!(world.objectives[OBJECTIVE] == 1 && world.enemies.is_empty());
|
||||
ensure!(world.player.credits == initial_credits + 1800);
|
||||
ensure!(
|
||||
world.outcome == Outcome::Playing,
|
||||
"boss never completes the mission itself"
|
||||
);
|
||||
let shots = active.scripts.states[STATE]["shots"].clone();
|
||||
for _ in 0..120 {
|
||||
world.step(Input::default(), &active.registry, &mut active.scripts)?;
|
||||
}
|
||||
ensure!(world.objectives[OBJECTIVE] == 1 && active.scripts.states[STATE]["shots"] == shots);
|
||||
ensure!(
|
||||
active.scripts.states[STATE]["ages"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
// A projectile can kill a scheduled spawn during advance, before on_spawn dispatch.
|
||||
// Use an actual stationary projectile inside the arriving boss silhouette.
|
||||
let mut early_active = ActiveMods::build(&base, &discovery, &[OWNER.into()], None)?;
|
||||
let mut early_world = World::new(&pilot.mission, &pilot, &early_active.registry)?;
|
||||
early_world.step(
|
||||
Input::default(),
|
||||
&early_active.registry,
|
||||
&mut early_active.scripts,
|
||||
)?;
|
||||
early_world.projectiles.push(Projectile {
|
||||
entity: 9000,
|
||||
weapon: ContentId::from("base:projectile_forward_guns"),
|
||||
x: 160,
|
||||
y: -40,
|
||||
px: 160,
|
||||
py: -40,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
hostile: false,
|
||||
age: 0,
|
||||
legacy: None,
|
||||
legacy_enemy: None,
|
||||
});
|
||||
early_world.step(
|
||||
Input::default(),
|
||||
&early_active.registry,
|
||||
&mut early_active.scripts,
|
||||
)?;
|
||||
ensure!(
|
||||
early_world.enemies.len() == 1 && early_world.enemies[0].health <= 0,
|
||||
"lethal projectile must kill the boss in its spawn step"
|
||||
);
|
||||
early_world.step(
|
||||
Input::default(),
|
||||
&early_active.registry,
|
||||
&mut early_active.scripts,
|
||||
)?;
|
||||
ensure!(
|
||||
early_world.objectives.get(OBJECTIVE) == Some(&1),
|
||||
"queued spawn-step kill must increment its actual-death objective"
|
||||
);
|
||||
ensure!(
|
||||
early_active.scripts.states[STATE]["ages"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
// Reusing an entity ID in a new mission must count its new life, while another
|
||||
// death notification for that life must stay harmless.
|
||||
let rt = &mut early_active.scripts;
|
||||
invoke(rt, "on_spawn", event(1, 160, 112, 24))?;
|
||||
let mut reused = event(1, 0, 112, 24);
|
||||
reused["entities"] = json!([]);
|
||||
reused["objectives"] = json!({OBJECTIVE: 1});
|
||||
let reused_commands = invoke(rt, "on_death", reused.clone())?;
|
||||
ensure!(reused_commands.iter().any(|c| matches!(c,
|
||||
Command::Objective { key, value } if key == OBJECTIVE && *value == 2)));
|
||||
ensure!(invoke(rt, "on_death", reused)?.is_empty());
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"mod":OWNER,"package_validation":"passed; PNGs, sound decoding, manifest and script",
|
||||
"warning_ages":[42,96],"volley_ages":[54,108],"first_volley":first,"second_volley":second,
|
||||
"actual_engine_tick":world.tick,"death_objective":world.objectives[OBJECTIVE],
|
||||
"reward":world.player.credits-initial_credits,"outcome":format!("{:?}",world.outcome),
|
||||
"per_entity_state_cleanup":"passed","dead_or_repeated_callbacks":"no commands",
|
||||
"actual_cues":actual_cues,
|
||||
"queued_spawn_step_lethal_projectile":"passed",
|
||||
"reused_entity_id_new_life":"counts once"
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Saltbreaker
|
||||
|
||||
Standalone API 1 mod, version 1.0.0, with no dependencies. Saltworks Convoy requires this package and references `iron_coast_boss_3:unit` and the death objective `iron_coast_boss_3:defeated`.
|
||||
|
||||
The armored twin-fan escort enters vertically, then patrols between x=80 and x=144 at y=24. Its logical canvas remains 96×64 in SD and HD. It has 160 health and pays 1,800 credits when destroyed. Every 54 simulation steps after arrival, one nose cannon fires two three-damage shells. The cannons alternate and each warning sounds nine steps before firing. Shells move four pixels vertically, with a second shell angled one pixel toward the center. The boss never completes a mission or pays a separate scripted victory reward.
|
||||
|
||||
Every entity retains its own direction, clock and cannon side in serializable script state. Dead or absent entities cannot move or fire. A duplicate death callback cannot count twice. A new spawn resets its lifecycle even when a lethal collision precedes its queued callback, so restored state and reused entity IDs cannot suppress replay objectives. Mission-start objective reset and victory gating belong to the mission mod.
|
||||
|
||||
Generated master images, aligned SD/HD PNGs and synthesized mono 16-bit 22,050 Hz WAVs are included. `prompts/` contains the exact built-in image generation prompts, `provenance.json` records the sources, and `build_assets.py` reproduces derived artwork and audio. Use `--audio-only` to regenerate sounds without Pillow. `art-qa.json` records logical dimensions and clipping checks; `audio-recipe.json` records non-silent PCM peaks and hashes.
|
||||
|
||||
Validate content with `raptor-rust --data data --mods RustRaptor/mods --validate-mods iron_coast_boss_3` from the repository root. `validate.rs` is a package-local acceptance probe compiled against the RustRaptor library and serde_json. It checks cannon alternation, warning timing, independent entities, restoration, patrol bounds and death objectives. A real World regression kills the boss through collision before queued callbacks, restores script checkpoints and repeats three lifecycles with the same entity ID; each must count and pay exactly once without firing after death.
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"unit": {
|
||||
"logical_size": [
|
||||
96,
|
||||
64
|
||||
],
|
||||
"hd_size": [
|
||||
768,
|
||||
512
|
||||
],
|
||||
"hd_scale": 8,
|
||||
"sd_opaque_bounds": [
|
||||
6,
|
||||
4,
|
||||
90,
|
||||
58
|
||||
],
|
||||
"hd_opaque_bounds": [
|
||||
49,
|
||||
36,
|
||||
719,
|
||||
466
|
||||
],
|
||||
"source": "art/unit-generated-v1.png",
|
||||
"source_size": [
|
||||
1536,
|
||||
1024
|
||||
],
|
||||
"source_sha256": "6359953cbc845441f8e075488aaf0b9c0281f02938b895d43affdade668d8bef",
|
||||
"frames": 1,
|
||||
"origin": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"alpha": "Generated RGBA preserved; indexed threshold alpha>=128"
|
||||
},
|
||||
"projectile": {
|
||||
"logical_size": [
|
||||
8,
|
||||
8
|
||||
],
|
||||
"hd_size": [
|
||||
64,
|
||||
64
|
||||
],
|
||||
"hd_scale": 8,
|
||||
"sd_opaque_bounds": [
|
||||
3,
|
||||
2,
|
||||
5,
|
||||
6
|
||||
],
|
||||
"hd_opaque_bounds": [
|
||||
23,
|
||||
11,
|
||||
41,
|
||||
53
|
||||
],
|
||||
"source": "art/projectile-generated-v1.png",
|
||||
"source_size": [
|
||||
1254,
|
||||
1254
|
||||
],
|
||||
"source_sha256": "6b8e12409793991fc374386acf8d5cb05fcdaad86257c1a24cbad1119f6e6df2",
|
||||
"frames": 1,
|
||||
"origin": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"alpha": "Generated RGBA preserved; indexed threshold alpha>=128"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 556 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 178 B |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"generator": "build_assets.py seeded chirp/noise synthesis; no recordings",
|
||||
"files": {
|
||||
"audio/shot.wav": {
|
||||
"sample_rate": 22050,
|
||||
"channels": 1,
|
||||
"bits": 16,
|
||||
"samples": 4851,
|
||||
"seed": 303,
|
||||
"duration_seconds": 0.22,
|
||||
"peak": 25558,
|
||||
"sha256": "f3b0e9f304a6fa5beb25d6b2cf3378e35711af2825756aafd12d02fc4eace3c6"
|
||||
},
|
||||
"audio/warning.wav": {
|
||||
"sample_rate": 22050,
|
||||
"channels": 1,
|
||||
"bits": 16,
|
||||
"samples": 3749,
|
||||
"seed": 304,
|
||||
"duration_seconds": 0.17002267573696145,
|
||||
"peak": 17694,
|
||||
"sha256": "906abb992bee7ea65e171d9d3e4e59bf6d75fdb7aa0df512347a3ac3025b645b"
|
||||
},
|
||||
"audio/death.wav": {
|
||||
"sample_rate": 22050,
|
||||
"channels": 1,
|
||||
"bits": 16,
|
||||
"samples": 20948,
|
||||
"seed": 305,
|
||||
"duration_seconds": 0.9500226757369614,
|
||||
"peak": 25558,
|
||||
"sha256": "9762116be5e60f038a4f30c1f3e5d96e29088783d416473577c85981dc263a4e"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resize saved imagegen masters and reproduce seeded PCM effects.
|
||||
|
||||
Generated alpha is preserved. No image is painted or reconstructed here.
|
||||
Run with --audio-only to regenerate WAVs without Pillow.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import random
|
||||
import struct
|
||||
import wave
|
||||
|
||||
|
||||
def synthesize(root):
|
||||
rate = 22050
|
||||
reports = {}
|
||||
for name, duration, seed in [("shot", 0.22, 303), ("warning", 0.17, 304), ("death", 0.95, 305)]:
|
||||
rng = random.Random(seed)
|
||||
count = round(duration * rate)
|
||||
phase = smooth_noise = 0.0
|
||||
samples = []
|
||||
for index in range(count):
|
||||
t = index / rate
|
||||
u = index / max(1, count - 1)
|
||||
noise = rng.uniform(-1, 1)
|
||||
smooth_noise = smooth_noise * 0.88 + noise * 0.12
|
||||
if name == "shot":
|
||||
phase += 2 * math.pi * (95 + 520 * (1-u)**2) / rate
|
||||
value = 0.6 * math.sin(phase) + 0.24 * noise + 0.14 * math.sin(2.21 * phase)
|
||||
envelope = math.exp(-6*u)
|
||||
elif name == "warning":
|
||||
phase += 2 * math.pi * (640 + 740*u) / rate
|
||||
value = 0.6 * math.sin(phase) + 0.12 * math.sin(2*phase)
|
||||
envelope = math.sin(math.pi*u)**1.5 * (0.3 + 0.7 * (0.5 + 0.5*math.sin(2*math.pi*18*t)))
|
||||
else:
|
||||
phase += 2 * math.pi * (90 - 65*u) / rate
|
||||
value = 0.65 * smooth_noise + 0.17 * noise + 0.31 * math.sin(phase)
|
||||
envelope = math.exp(-4.9*u)
|
||||
envelope *= min(1, t/0.003) * min(1, (count-1-index)/(rate*0.005))
|
||||
samples.append(value * max(0, envelope))
|
||||
peak = max(abs(v) for v in samples)
|
||||
pcm = [round(v / peak * (0.54 if name == "warning" else 0.78) * 32767) for v in samples]
|
||||
path = root / "audio" / f"{name}.wav"
|
||||
with wave.open(str(path), "wb") as output:
|
||||
output.setnchannels(1)
|
||||
output.setsampwidth(2)
|
||||
output.setframerate(rate)
|
||||
output.writeframes(struct.pack("<" + "h" * count, *pcm))
|
||||
reports[str(path.relative_to(root))] = {
|
||||
"sample_rate": rate, "channels": 1, "bits": 16, "samples": count,
|
||||
"seed": seed, "duration_seconds": count/rate,
|
||||
"peak": max(abs(v) for v in pcm),
|
||||
"sha256": hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
}
|
||||
(root / "audio-recipe.json").write_text(json.dumps({"generator": "build_assets.py seeded chirp/noise synthesis; no recordings", "files": reports}, indent=2) + "\n")
|
||||
|
||||
|
||||
def artwork(root):
|
||||
from PIL import Image, ImageOps
|
||||
reports = {}
|
||||
for name, logical, scale in [("unit", (96,64), 8), ("projectile", (8,8), 8)]:
|
||||
path = root / "art" / f"{name}-generated-v1.png"
|
||||
source = Image.open(path).convert("RGBA")
|
||||
if source.getchannel("A").getextrema()[0] != 0:
|
||||
raise ValueError(f"{path}: missing true transparent pixels")
|
||||
size = tuple(v*scale for v in logical)
|
||||
if name == "unit":
|
||||
hd = Image.new("RGBA", size, (0,0,0,0))
|
||||
fitted = ImageOps.contain(source, (size[0]-64, size[1]-42), Image.Resampling.LANCZOS)
|
||||
hd.alpha_composite(fitted, ((size[0]-fitted.width)//2, (size[1]-fitted.height)//2))
|
||||
else:
|
||||
hd = source.resize(size, Image.Resampling.LANCZOS)
|
||||
sd = hd.resize(logical, Image.Resampling.BOX)
|
||||
bounds = []
|
||||
for image in (sd,hd):
|
||||
bbox = image.getchannel("A").point(lambda v:255 if v>=128 else 0).getbbox()
|
||||
if bbox is None or bbox[0] <= 0 or bbox[1] <= 0 or bbox[2] >= image.width or bbox[3] >= image.height:
|
||||
raise ValueError(f"{name}: silhouette clips or is missing: {bbox}")
|
||||
bounds.append(list(bbox))
|
||||
sd.save(root / "art" / f"{name}-sd.png")
|
||||
hd.save(root / "art" / f"{name}-hd.png")
|
||||
reports[name] = {"logical_size": list(logical), "hd_size": list(size), "hd_scale": scale,
|
||||
"sd_opaque_bounds": bounds[0], "hd_opaque_bounds": bounds[1],
|
||||
"source": str(path.relative_to(root)), "source_size": list(source.size),
|
||||
"source_sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
"frames": 1, "origin": [0,0], "alpha": "Generated RGBA preserved; indexed threshold alpha>=128"}
|
||||
(root / "art-qa.json").write_text(json.dumps(reports, indent=2) + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--audio-only", action="store_true")
|
||||
args = parser.parse_args()
|
||||
root = Path(__file__).resolve().parent
|
||||
synthesize(root)
|
||||
if not args.audio_only:
|
||||
artwork(root)
|
||||
print("Saltbreaker assets validated and generated")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"weapons": [{
|
||||
"id": "iron_coast_boss_3:projectile", "name": "Saltbreaker incendiary shell",
|
||||
"damage": 3, "speed": 4, "cooldown": 54, "behavior": "straight", "target": "all",
|
||||
"art": { "png": "art/projectile-sd.png", "hd_png": "art/projectile-hd.png", "logical_width": 8, "logical_height": 8 }
|
||||
}],
|
||||
"enemies": [{
|
||||
"id": "iron_coast_boss_3:unit", "name": "Saltbreaker armored escort VTOL",
|
||||
"health": 160, "speed": 0, "reward": 1800, "ground": false, "boss": true, "cooldown": 54,
|
||||
"art": { "png": "art/unit-sd.png", "hd_png": "art/unit-hd.png", "logical_width": 96, "logical_height": 64, "frames": 1 },
|
||||
"script": "scripts/ai.rhai"
|
||||
}],
|
||||
"sounds": [
|
||||
{ "id": "iron_coast_boss_3:shot", "name": "Saltbreaker cannon report", "file": "audio/shot.wav", "volume": 95, "pan": 127, "pitch": 128, "priority": 2 },
|
||||
{ "id": "iron_coast_boss_3:warning", "name": "Saltbreaker cannon charging warning", "file": "audio/warning.wav", "volume": 83, "pan": 127, "pitch": 128, "priority": 2 },
|
||||
{ "id": "iron_coast_boss_3:death", "name": "Saltbreaker turbine destruction", "file": "audio/death.wav", "volume": 120, "pan": 127, "pitch": 128, "priority": 4 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
id = "iron_coast_boss_3"
|
||||
name = "Iron Coast Saltbreaker"
|
||||
version = "1.0.0"
|
||||
api_version = 1
|
||||
description = "An armored convoy-escort VTOL boss with alternating, audible side-cannon warnings, SD and HD artwork, and deterministic serializable AI."
|
||||
content = "content.json"
|
||||
scripts = ["scripts/ai.rhai"]
|
||||
@@ -0,0 +1 @@
|
||||
Use case: stylized-concept. Asset type: a single transparent production game projectile sprite for an industrial top-down vertical shoot-em-up. Exactly one small Saltbreaker boss cannon shell, straight top-down overhead orthographic view. Compact oval gunmetal projectile with a narrow amber incendiary slit and tiny red bands, nose pointing DOWN; no fins. Isolated centered on a square transparent canvas, occupies the central 60% height and 40% width. Crisp outlined high-definition painted sprite compatible with downsampling to 8x8 pixels; thick visible larger detail, restrained charcoal silver amber military palette. True transparent background and generous clear margins. No text, no watermark, no glow aura, no cast shadow, no explosion, no trail, no smoke, no surrounding scene. Exactly one projectile.
|
||||
@@ -0,0 +1 @@
|
||||
Use case: stylized-concept. Asset type: production transparent HD game sprite for a top-down 1990s Raptor-inspired vertical shoot-em-up. Use the reference only for the industrial gray metal, olive plates, restrained rust-red accents and crisp painted/pixel-compatible detail; do not copy its silhouette or multiple frames. Generate ONE distinct enemy boss called Saltbreaker: a heavy armored convoy-escort VTOL craft with a broad low-profile fuselage, two large circular ducted lift fans set into the short side wings, blunt armored nose pointing straight DOWN, and a cannon pod on each side of the nose. Canvas landscape 3:2, aircraft occupies 86% width and 80% height, completely visible and centered. Exact overhead orthographic view, bilateral symmetric static unbanked sprite, no perspective. Gunmetal and olive military industrial armor, small red warning strips, warm amber small windows, dark fan rotors, metallic rim highlights; no blue neon. Crisp clean edges and purposeful larger details remain readable when downsampled to 96x64 logical pixels. True transparent background, transparent margin all sides, no cast ground shadow, no grid, no surface, no smoke, no words, no labels, no border, no glowing aura, no additional objects. Exactly one craft, no sheet.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"asset_authoring": "Built-in image_gen tool, transparent_background=true; no CLI fallback",
|
||||
"original_game_art": "No original game assets copied into this package",
|
||||
"artworks": [
|
||||
{"master": "art/unit-generated-v1.png", "prompt": "prompts/unit-hd-v1.txt", "reference": "../iron_coast_boss/art/unit-hd.png", "reference_role": "Industrial materials and palette only; independently generated twin-fan silhouette", "output": ["art/unit-hd.png", "art/unit-sd.png"]},
|
||||
{"master": "art/projectile-generated-v1.png", "prompt": "prompts/projectile-hd-v1.txt", "reference": null, "output": ["art/projectile-hd.png", "art/projectile-sd.png"]}
|
||||
],
|
||||
"derivation": "build_assets.py applies only aspect-preserving fit, transparent padding and SD/HD resampling; original generated alpha is retained",
|
||||
"sounds": "Original deterministic procedural chirps and seeded noise; build_assets.py --audio-only; no recordings or external audio assets",
|
||||
"validation": ["art-qa.json logical and HD bounds", "audio-recipe.json PCM peaks and hashes", "validate.rs callback acceptance probe"]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Each boss has its own direction, cannon clock and alternating barrel.
|
||||
// No wall-clock time or global tick modulo affects restored behavior.
|
||||
fn is_alive(event) {
|
||||
for unit in event.entities {
|
||||
if unit.entity == event.entity && unit.health > 0 { return true; }
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn on_spawn(event, state, random) {
|
||||
if !state.contains("units") { state.units = #{}; }
|
||||
if !state.contains("dead") { state.dead = #{}; }
|
||||
let key = event.entity.to_string();
|
||||
state.units[key] = #{direction: 1, clock: 0, side: 0};
|
||||
state.dead.remove(key);
|
||||
// IDs restart on replay. Even a spawn killed during advance must clear
|
||||
// its previous life's death guard before the queued death callback runs.
|
||||
if !is_alive(event) { return #{commands: [], state: state}; }
|
||||
#{commands: [#{op: "velocity", entity: event.entity, vx: 0, vy: 2}], state: state}
|
||||
}
|
||||
|
||||
fn on_tick(event, state, random) {
|
||||
if event.health <= 0 || !is_alive(event) { return #{commands: [], state: state}; }
|
||||
if event.y < 24 {
|
||||
return #{commands: [#{op: "velocity", entity: event.entity, vx: 0, vy: 2}], state: state};
|
||||
}
|
||||
let key = event.entity.to_string();
|
||||
if !state.contains("units") { state.units = #{}; }
|
||||
let unit = if state.units.contains(key) { state.units[key] } else {#{direction: 1, clock: 0, side: 0}};
|
||||
if event.x >= 144 { unit.direction = -1; }
|
||||
if event.x <= 80 { unit.direction = 1; }
|
||||
unit.clock = (unit.clock + 1) % 54;
|
||||
let commands = [
|
||||
#{op: "move", entity: event.entity, x: event.x, y: 24},
|
||||
#{op: "velocity", entity: event.entity, vx: unit.direction, vy: 0}
|
||||
];
|
||||
// Audible warning lasts nine simulation steps before each side fires.
|
||||
if unit.clock == 45 {
|
||||
commands.push(#{op: "play_sound", sound: "iron_coast_boss_3:warning"});
|
||||
}
|
||||
if unit.clock == 0 {
|
||||
let muzzle = if unit.side == 0 {35} else {61};
|
||||
let inward = if unit.side == 0 {1} else {-1};
|
||||
commands.push(#{op: "projectile", weapon: "iron_coast_boss_3:projectile", x: event.x + muzzle, y: event.y + 54, vx: 0, vy: 4, hostile: true});
|
||||
commands.push(#{op: "projectile", weapon: "iron_coast_boss_3:projectile", x: event.x + muzzle, y: event.y + 54, vx: inward, vy: 4, hostile: true});
|
||||
commands.push(#{op: "play_sound", sound: "iron_coast_boss_3:shot"});
|
||||
unit.side = 1 - unit.side;
|
||||
state.shots = if state.contains("shots") {state.shots + 1} else {1};
|
||||
}
|
||||
state.units[key] = unit;
|
||||
#{commands: commands, state: state}
|
||||
}
|
||||
|
||||
fn on_hit(event, state, random) {
|
||||
state.hits = if state.contains("hits") {state.hits + 1} else {1};
|
||||
#{commands: [], state: state}
|
||||
}
|
||||
|
||||
fn on_death(event, state, random) {
|
||||
let key = event.entity.to_string();
|
||||
if !state.contains("dead") { state.dead = #{}; }
|
||||
if state.dead.contains(key) { return #{commands: [], state: state}; }
|
||||
state.dead[key] = true;
|
||||
if state.contains("units") { state.units.remove(key); }
|
||||
state.destroyed = if state.contains("destroyed") {state.destroyed + 1} else {1};
|
||||
let defeated = if event.objectives.contains("iron_coast_boss_3:defeated") {event.objectives["iron_coast_boss_3:defeated"] + 1} else {1};
|
||||
#{commands: [
|
||||
#{op: "objective", key: "iron_coast_boss_3:defeated", value: defeated},
|
||||
#{op: "effect", x: event.x + 35, y: event.y + 48},
|
||||
#{op: "effect", x: event.x + 61, y: event.y + 48},
|
||||
#{op: "play_sound", sound: "iron_coast_boss_3:death"}
|
||||
], state: state}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Package-local callback acceptance probe; compile against raptor_replica and serde_json.
|
||||
use raptor_replica::{
|
||||
content::{ContentPack, Registry},
|
||||
modding::{self, ActiveMods, Command, ScriptRuntime},
|
||||
persistence::Pilot,
|
||||
simulation::{Input, World},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
const OWNER: &str = "iron_coast_boss_3";
|
||||
const SCRIPT: &str = "scripts/ai.rhai";
|
||||
|
||||
fn runtime(path: &Path) -> ScriptRuntime {
|
||||
let mut result = ScriptRuntime::new();
|
||||
result
|
||||
.add(
|
||||
OWNER,
|
||||
SCRIPT,
|
||||
&std::fs::read_to_string(path.join(SCRIPT)).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
result
|
||||
}
|
||||
|
||||
fn event(entity: u64, x: i32, y: i32, health: i32) -> Value {
|
||||
json!({"entity":entity, "x":x, "y":y, "health":health, "tick":999,
|
||||
"entities":[{"entity":entity,"health":health}],"objectives":{}})
|
||||
}
|
||||
|
||||
fn call(runtime: &mut ScriptRuntime, hook: &str, event: Value) -> Vec<Command> {
|
||||
runtime.invoke(OWNER, SCRIPT, hook, &event, 303).unwrap()
|
||||
}
|
||||
|
||||
fn queued_spawn_replay(package: &Path) {
|
||||
let mut base = Registry::default();
|
||||
let content: ContentPack = serde_json::from_value(json!({
|
||||
"weapons":[{"id":"base:projectile_forward_guns","name":"Forward Guns","damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all"}],
|
||||
"items":[{"id":"base:forward_guns","name":"Forward Guns","price":0,"weapon":"base:projectile_forward_guns"},
|
||||
{"id":"base:energy","name":"Shield","price":0,"max_count":100}],
|
||||
"missions":[{"id":"base:sector_1_wave_1","name":"Spawn lifecycle collision regression","duration":500,"auto_complete":false,
|
||||
"spawns":[{"tick":0,"enemy":"iron_coast_boss_3:unit","x":112,"y":120}]}]
|
||||
})).unwrap();
|
||||
base.apply(content, "base").unwrap();
|
||||
let discovery = modding::discover(package.parent().unwrap());
|
||||
let mut active = ActiveMods::build(&base, &discovery, &[OWNER.to_string()], None).unwrap();
|
||||
// A real collision during advance kills this fragile boss before dispatch.
|
||||
// Its on_spawn callback is queued before on_death, and both see no live boss.
|
||||
active
|
||||
.registry
|
||||
.enemies
|
||||
.get_mut(&"iron_coast_boss_3:unit".into())
|
||||
.unwrap()
|
||||
.health = 1;
|
||||
let mut pilot = Pilot::default();
|
||||
pilot.difficulty = 2;
|
||||
pilot.credits = 0;
|
||||
let mut reused_id = None;
|
||||
for replay in 0..3 {
|
||||
let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap();
|
||||
world
|
||||
.step(
|
||||
Input {
|
||||
absolute: Some([144, 160]),
|
||||
..Input::default()
|
||||
},
|
||||
&active.registry,
|
||||
&mut active.scripts,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
world.enemies.is_empty(),
|
||||
"collision must remove the boss before dispatch"
|
||||
);
|
||||
assert_eq!(
|
||||
world.objectives.get("iron_coast_boss_3:defeated"),
|
||||
Some(&1),
|
||||
"replay {replay} must count its new lifecycle"
|
||||
);
|
||||
assert_eq!(
|
||||
world.player.credits, 1800,
|
||||
"exactly one engine reward per lifecycle"
|
||||
);
|
||||
let checkpoints = active.scripts.checkpoints();
|
||||
let state = &checkpoints.0["iron_coast_boss_3/scripts/ai.rhai"];
|
||||
let ids = state["dead"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(ids.len(), 1);
|
||||
if let Some(ref previous) = reused_id {
|
||||
assert_eq!(previous, ids[0]);
|
||||
} else {
|
||||
reused_id = Some(ids[0].clone());
|
||||
}
|
||||
assert!(state["units"].as_object().unwrap().is_empty());
|
||||
assert!(world.projectiles.is_empty(), "dead spawn must not shoot");
|
||||
assert_eq!(world.mod_sounds, vec!["iron_coast_boss_3:death".into()]);
|
||||
world
|
||||
.step(Input::default(), &active.registry, &mut active.scripts)
|
||||
.unwrap();
|
||||
assert_eq!(world.player.credits, 1800);
|
||||
assert_eq!(world.objectives.get("iron_coast_boss_3:defeated"), Some(&1));
|
||||
// Reconstruct the runtime from saved state exactly as a pilot load does.
|
||||
let mut restored = runtime(package);
|
||||
restored.restore(checkpoints);
|
||||
active.scripts = restored;
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("pass package directory");
|
||||
let path = Path::new(&path);
|
||||
let mut ai = runtime(path);
|
||||
call(&mut ai, "on_spawn", event(1, 112, -64, 160));
|
||||
for _ in 0..20 {
|
||||
let commands = call(&mut ai, "on_tick", event(1, 112, -64, 160));
|
||||
assert!(
|
||||
!commands
|
||||
.iter()
|
||||
.any(|c| matches!(c, Command::Projectile { .. } | Command::PlaySound { .. }))
|
||||
);
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
let mut volleys = Vec::new();
|
||||
for tick in 1..=108 {
|
||||
let commands = call(&mut ai, "on_tick", event(1, 112, 24, 160));
|
||||
if commands
|
||||
.iter()
|
||||
.any(|c| matches!(c,Command::PlaySound{sound} if sound=="iron_coast_boss_3:warning"))
|
||||
{
|
||||
warnings.push(tick);
|
||||
}
|
||||
let shells = commands
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
Command::Projectile {
|
||||
weapon,
|
||||
x,
|
||||
y,
|
||||
vx,
|
||||
vy,
|
||||
hostile,
|
||||
} => {
|
||||
assert_eq!(weapon, "iron_coast_boss_3:projectile");
|
||||
assert_eq!((*y, *vy, *hostile), (78, 4, true));
|
||||
Some((*x, *vx))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !shells.is_empty() {
|
||||
volleys.push((tick, shells));
|
||||
}
|
||||
}
|
||||
assert_eq!(warnings, vec![45, 99]);
|
||||
assert_eq!(
|
||||
volleys,
|
||||
vec![
|
||||
(54, vec![(147, 0), (147, 1)]),
|
||||
(108, vec![(173, 0), (173, -1)])
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
call(&mut ai, "on_tick", event(1, 144, 24, 160))
|
||||
.iter()
|
||||
.any(|c| matches!(c, Command::Velocity { vx: -1, vy: 0, .. }))
|
||||
);
|
||||
assert!(
|
||||
call(&mut ai, "on_tick", event(1, 80, 24, 160))
|
||||
.iter()
|
||||
.any(|c| matches!(c, Command::Velocity { vx: 1, vy: 0, .. }))
|
||||
);
|
||||
// A second boss starts a separate clock and cannot inherit the first gun phase.
|
||||
call(&mut ai, "on_spawn", event(2, 112, 24, 160));
|
||||
assert!(
|
||||
!call(&mut ai, "on_tick", event(2, 112, 24, 160))
|
||||
.iter()
|
||||
.any(|c| matches!(c, Command::Projectile { .. }))
|
||||
);
|
||||
let mut restored = runtime(path);
|
||||
restored.restore(ai.checkpoints());
|
||||
for _ in 0..111 {
|
||||
assert_eq!(
|
||||
serde_json::to_value(call(&mut ai, "on_tick", event(1, 112, 24, 160))).unwrap(),
|
||||
serde_json::to_value(call(&mut restored, "on_tick", event(1, 112, 24, 160))).unwrap()
|
||||
);
|
||||
}
|
||||
// A dead or absent entity cannot move or fire. Death reports once and never wins a mission itself.
|
||||
assert!(call(&mut ai, "on_tick", event(1, 112, 24, 0)).is_empty());
|
||||
let mut missing = event(1, 112, 24, 160);
|
||||
missing["entities"] = json!([]);
|
||||
assert!(call(&mut ai, "on_tick", missing).is_empty());
|
||||
let death = call(&mut ai, "on_death", event(1, 112, 24, 0));
|
||||
assert!(
|
||||
death.iter().any(
|
||||
|c| matches!(c,Command::Objective{key,value:1} if key=="iron_coast_boss_3:defeated")
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
!death
|
||||
.iter()
|
||||
.any(|c| matches!(c, Command::Complete | Command::Credits { .. }))
|
||||
);
|
||||
assert!(call(&mut ai, "on_death", event(1, 112, 24, 0)).is_empty());
|
||||
let mut second = event(2, 112, 24, 0);
|
||||
second["objectives"] = json!({"iron_coast_boss_3:defeated":1});
|
||||
assert!(
|
||||
call(&mut ai, "on_death", second).iter().any(
|
||||
|c| matches!(c,Command::Objective{key,value:2} if key=="iron_coast_boss_3:defeated")
|
||||
)
|
||||
);
|
||||
queued_spawn_replay(path);
|
||||
println!(
|
||||
"Saltbreaker: arrival/patrol, two-shell alternating volleys, nine-step warnings, independent entities, checkpoint replay, dead-unit safety and exact death objectives passed"
|
||||
);
|
||||
println!(
|
||||
"Real World lethal collision before queued spawn: three restored-state replays reuse the same entity ID and each count and reward exactly once"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Stormwall Arsenal
|
||||
|
||||
An independent Iron Coast boss mod for mission 4. The wide aerial arsenal carries two massive downward-facing batteries, red storm-warning stripes and four rear engines. It has its own true transparent SD/HD boss and shell artwork, alarm, cannon and destruction sounds.
|
||||
|
||||
Enable `iron_coast_boss_4` to register the boss; the Stormwall mission selects it as a dependency. This package has no dependencies, overrides or mission definitions.
|
||||
|
||||
| Definition | ID |
|
||||
| --- | --- |
|
||||
| Enemy | `iron_coast_boss_4:unit` |
|
||||
| Projectile | `iron_coast_boss_4:projectile` |
|
||||
| Death objective | `iron_coast_boss_4:defeated` |
|
||||
| Sounds | `iron_coast_boss_4:warning`, `iron_coast_boss_4:shot`, `iron_coast_boss_4:death` |
|
||||
|
||||
The 96×64 boss enters at two logical pixels per simulation tick, holds y=24 and patrols x=80–144. Every 54 ticks it pauses for 12 ticks with a two-note arming alarm, then fires three 3-damage shells at speed 4. The third shell alternates between batteries. Each boss stores its own direction and attack age, so concurrent bosses remain independent. Ordinary difficulty scaling applies to its 160 health; destruction awards 1,800 credits.
|
||||
|
||||
Actual death increments `iron_coast_boss_4:defeated` once and retires that entity's live state. A dead boss never keeps firing, and a fresh sortie safely resets reused entity IDs even when a lethal projectile hits before its queued spawn callback runs. The script does not declare mission success: the mission combines the death objective with its other targets.
|
||||
|
||||
`art/unit-sd.png` and `art/unit-hd.png` share a 96×64 logical canvas (HD is 384×256). `art/projectile-sd.png` and `art/projectile-hd.png` share an 8×8 logical canvas (HD is 64×64). Both use one static frame, consistent registration and preserved alpha. `art/preview.png` shows both modes. The first shell master is retained for provenance but is not loaded; v2 gives a readable SD core.
|
||||
|
||||
The built-in image generation tool supplied the artwork. Exact prompts are in `prompts/`, tool results and processing details in `provenance.json`, and image dimensions, bounds and hashes in `art-qa.json`. The package includes generated masters, runtime art and WAVs for Git tracking. Original catalog reference pixels are excluded.
|
||||
|
||||
`python3 build_assets.py` reproduces the SD/HD packaging and deterministic 22,050 Hz mono PCM16 sounds from saved masters. `audio-recipe.json` records seeds, nonzero peaks/RMS and hashes; no recordings or external sound downloads are used.
|
||||
|
||||
After building RustRaptor in release mode, run `python3 validate.py` for six tests through the real simulation and Rhai callbacks: warning timing and alternate batteries, independent multi-boss state, once-only destruction, saved state/RNG determinism, fresh sorties, lethal spawn-step hits with restored death state, duplicate death callbacks and persistent patrol bounds. Results are recorded in `acceptance-report.json`. The normal Rust mod validation CLI also validates this package independently.
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"validation": "Package-local actual Rust simulation and Rhai, release library; python3 validate.py",
|
||||
"tests": {
|
||||
"warning_precedes_three_shells_and_batteries_alternate": "passed",
|
||||
"two_bosses_have_independent_state_and_death_counts_once": "passed",
|
||||
"saved_script_state_and_rng_reproduce_identical_future": "passed",
|
||||
"a_new_sortie_clears_reused_entity_death_guards": "passed",
|
||||
"live_boss_enters_and_persists_in_safe_bounds_without_auto_completion": "passed",
|
||||
"restored_dead_guard_does_not_hide_a_lethal_hit_on_the_reused_spawn_step": "passed"
|
||||
},
|
||||
"independent_cli": "Validated 1 mods; 0 overrides",
|
||||
"test_difficulty": 3,
|
||||
"full_definition_health": 160,
|
||||
"original_difficulty_scaling": "Boss health is halved by the engine on difficulties 0 and 1, as for existing bosses.",
|
||||
"sound_peak": 25558,
|
||||
"sounds_non_silent": true,
|
||||
"art_preview": "art/preview.png",
|
||||
"sd_boss_opaque_bounds": [
|
||||
3,
|
||||
6,
|
||||
93,
|
||||
54
|
||||
],
|
||||
"sd_shell_opaque_bounds": [
|
||||
3,
|
||||
2,
|
||||
5,
|
||||
6
|
||||
],
|
||||
"safe_patrol_bounds": [
|
||||
80,
|
||||
145
|
||||
],
|
||||
"persistent_y": 24,
|
||||
"death_objective": "iron_coast_boss_4:defeated",
|
||||
"mission_completion": "Never completes a mission itself",
|
||||
"queued_spawn_regression": "Reproduced against old callback: missing death objective when restored dead flag survives a lethal spawn-step hit. Fixed lifecycle reset before survival guard; no dead commands; once-only objective/reward; duplicate callback safely ignored.",
|
||||
"script_sha256": "f3cc133f6cf858d7fa4e3a6c7e424ad81aa5a66abfb64407572622314fc58f00"
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"unit": {
|
||||
"sd": {
|
||||
"dimensions": [
|
||||
96,
|
||||
64
|
||||
],
|
||||
"opaque_bounds": [
|
||||
3,
|
||||
6,
|
||||
93,
|
||||
54
|
||||
],
|
||||
"sha256": "2188403b5d39f8e8458514cf6c77c1089cd0eced658f9359747a637a62931c5d"
|
||||
},
|
||||
"hd": {
|
||||
"dimensions": [
|
||||
384,
|
||||
256
|
||||
],
|
||||
"opaque_bounds": [
|
||||
12,
|
||||
25,
|
||||
372,
|
||||
218
|
||||
],
|
||||
"sha256": "293b7f87531c1c23433cf53e539a8ab4d710803c0bd171af65680982b89af47d"
|
||||
},
|
||||
"source_sha256": "0d812437a71b65c34c467f892d918e70903c9f407ad2e6dc5e5d08d9b32828ec",
|
||||
"logical_dimensions": [
|
||||
96,
|
||||
64
|
||||
],
|
||||
"frames": 1
|
||||
},
|
||||
"projectile": {
|
||||
"sd": {
|
||||
"dimensions": [
|
||||
8,
|
||||
8
|
||||
],
|
||||
"opaque_bounds": [
|
||||
3,
|
||||
2,
|
||||
5,
|
||||
6
|
||||
],
|
||||
"sha256": "bcfc46259ff65192b72013abcb182d965f7fb242b9cd796ad4e1940665ebed33"
|
||||
},
|
||||
"hd": {
|
||||
"dimensions": [
|
||||
64,
|
||||
64
|
||||
],
|
||||
"opaque_bounds": [
|
||||
24,
|
||||
12,
|
||||
40,
|
||||
52
|
||||
],
|
||||
"sha256": "3d7b6a7ffd9fcbc4d86433621574a74a773945ead6d7ed1038cfa3185fc4b0cb"
|
||||
},
|
||||
"source_sha256": "1f12188394c367ce531f79bf158140ac9a866b4ebb29131bd5f2b5009fa25a16",
|
||||
"logical_dimensions": [
|
||||
8,
|
||||
8
|
||||
],
|
||||
"frames": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 815 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 169 B |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 9.4 KiB |