Add separate Iron Coast mission, weapon and enemy mods with SD HD artwork and audio

This commit is contained in:
raptorreplica committed 2026-10-10 12:20:24 +11:00
1 parent dbb1d4596b
commit 967ba69ee8
573 files changed
+6515

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# Iron Coast Scout
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_scout:unit`. Iron Coast mission 1 supplies placements and completion objectives; this package does not add a mission or override base content.
Airborne interceptor weaves left/right every 24 ticks, advances 1 pixel/tick, aims at the pilot and fires a 2 damage shell.
Logical collision canvas `24×24`, top-left anchor `(0,0)`, health `14`, reward `75` 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_scout: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 examples/mods --validate-mods iron_coast_scout`. 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,29 @@
{
"logical_size": [
24,
24
],
"hd_scale": 8,
"origin": [
0,
0
],
"frames": 1,
"frame_order": [
"idle"
],
"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": "e2b6a0df185bda2e368e05a02af883cb99f5960cd379ad9454003449e1321503",
"sd_opaque_bounds": [
3,
2,
22,
22
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,23 @@
{
"generator": "build_assets.py deterministic chirp/noise synthesis; no recordings",
"kind": "scout",
"seed": 19,
"files": {
"audio/shot.wav": {
"sample_rate": 22050,
"channels": 1,
"bits": 16,
"samples": 2425,
"peak": 25558,
"sha256": "094e3398d598762ac1d1eed70c7e3177e56f525c726385945e5071f4e215a00f"
},
"audio/death.wav": {
"sample_rate": 22050,
"channels": 1,
"bits": 16,
"samples": 7497,
"peak": 25558,
"sha256": "066c916daecaa06358ea2fe99b482b9ce115e0f9a63296664063a738dca5212e"
}
}
}
Binary file not shown.
Binary file not shown.
@@ -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 source PNGs stay in ignored art/. See prompts/ and provenance.json.
Use --audio-only to regenerate sound without Pillow or private 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,60 @@
{
"weapons": [
{
"id": "iron_coast_scout:projectile",
"name": "Iron Coast Scout cannon shell",
"damage": 2,
"speed": 3,
"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_scout:unit",
"name": "Iron Coast Scout",
"health": 14,
"speed": 0,
"reward": 75,
"ground": false,
"boss": false,
"cooldown": 54,
"art": {
"png": "art/unit-sd.png",
"hd_png": "art/unit-hd.png",
"logical_width": 24,
"logical_height": 24,
"frames": 1
},
"script": "scripts/ai.rhai",
"weapon": "iron_coast_scout:projectile"
}
],
"sounds": [
{
"id": "iron_coast_scout:shot",
"name": "Iron Coast Scout cannon",
"file": "audio/shot.wav",
"volume": 75,
"pan": 127,
"pitch": 128,
"priority": 2
},
{
"id": "iron_coast_scout:death",
"name": "Iron Coast Scout destruction",
"file": "audio/death.wav",
"volume": 100,
"pan": 127,
"pitch": 128,
"priority": 3
}
]
}
+7
View File
@@ -0,0 +1,7 @@
id = "iron_coast_scout"
name = "Iron Coast Scout"
version = "1.0.0"
api_version = 1
description = "An agile weaving interceptor with amber cannon fire. 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,6 @@
Use case: style-transfer
Asset type: transparent HD sprite for the Iron Coast Scout enemy, a top-down vertical shoot-em-up.
Input image: the supplied original 24×24 Raptor SHIP39G1_PIC sprite is the source of truth for the silhouette and orientation, enlarged with nearest-neighbor only.
Primary request: create one polished HD remaster of this small agile military science-fiction fighter, preserving the exact overhead view, V-shaped swept wings at the top and narrow central nose pointing straight DOWN. Naval steel/slate armor, subtle olive panels, restrained tiny amber engine vents and red service markings, crisp machined panel seams and readable shape. This is an enemy fighter facing down toward the player. Match the utilitarian 1990s Raptor design language while adding clean HD surface detail.
Composition: centered on a square transparent canvas. Keep original proportions and symmetric straight-flight pose. Ship fills approximately 92% of canvas width and 92% of height, with a small equal transparent margin; no part clipped. No perspective, no rotation, no banking. No extra external parts or trails. Directional lighting gently from upper left, dark edges, no cast shadow outside ship.
Constraints: one single isolated sprite, genuine transparent background, no lettering, logos, watermarks, environment, ground plane, drop shadow, border or checkerboard painted into pixels. Preserve alpha transparency and the original silhouette; do not put the ship in a photograph or poster.
@@ -0,0 +1,30 @@
{
"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": "10344",
"catalog_path": "sprites/SHIP39G1_PIC",
"sha256": "7cb09e925ea7786b331d576c360aad09b5e18f35d0258ec78ab5ef2e50c2590e",
"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-3dc36f34-2cec-45f6-8262-ea3a5e577396.png",
"sha256": "e2b6a0df185bda2e368e05a02af883cb99f5960cd379ad9454003449e1321503"
},
{
"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,40 @@
fn is_alive(event) {
for unit in event.entities {
if unit.entity == event.entity && unit.health > 0 { return true; }
}
false
}
// Velocity changes are committed after the callback completes.
fn on_spawn(event, state, random) {
if !is_alive(event) { return #{ commands: [], state: state }; }
#{ commands: [#{ op: "velocity", entity: event.entity, vx: 1, vy: 1 }], state: state }
}
fn on_tick(event, state, random) {
if event.health <= 0 || !is_alive(event) { return #{ commands: [], state: state }; }
let sideways = if ((event.tick + event.entity * 7) / 24) % 2 == 0 { 1 } else { -1 };
if event.x <= 24 { sideways = 1; }
if event.x >= 272 { sideways = -1; }
#{ commands: [#{ op: "velocity", entity: event.entity, vx: sideways, vy: 1 }], state: state }
}
fn on_fire(event, state, random) {
let shots = if state.contains("shots") { state.shots + 1 } else { 1 };
state.shots = shots;
#{ commands: [#{ op: "play_sound", sound: "iron_coast_scout:shot" }], 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) {
state.destroyed = if state.contains("destroyed") { state.destroyed + 1 } else { 1 };
let defeated = if event.objectives.contains("iron_coast_scout:defeated") { event.objectives["iron_coast_scout:defeated"] + 1 } else { 1 };
#{ commands: [
#{ op: "objective", key: "iron_coast_scout:defeated", value: defeated },
#{ op: "play_sound", sound: "iron_coast_scout:death" }
], state: state }
}