Add separate Iron Coast mission, weapon and enemy mods with SD HD artwork and audio
No files matched your search
@@ -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 (22,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 examples/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,67 @@
|
||||
{
|
||||
"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": 22000,
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"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,21 @@
|
||||
// Built-in beam projectiles handle the normal volley and collision rules.
|
||||
// Callbacks add deterministic visuals, audio and serializable weapon counters.
|
||||
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" },
|
||||
#{ op: "effect", x: event.x, y: event.y - 3 }
|
||||
];
|
||||
#{ 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 examples/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 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,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,29 @@
|
||||
# Iron Coast — Mission 1: Breakwater
|
||||
|
||||
Enable this package and its six dependencies in the in-game Mods screen:
|
||||
`iron_coast_scout`, `iron_coast_turret`, `iron_coast_boss`, `pulse_cannon`,
|
||||
`seeker_swarm`, and `ion_lance`. Each package is independent and version 1.0.0.
|
||||
Choose **Iron Coast 1 — Breakwater** in the mission list.
|
||||
|
||||
Disable at least two harbor turrets and destroy the command ship to complete
|
||||
the raid. Scout waves arrive during the approach; two supply depots release
|
||||
repair kits. The mission grants a trial Pulse Cannon and three field repairs.
|
||||
Repairs restore 25 shield when selected and used with the special button.
|
||||
Victory awards 5,000 credits in addition to ordinary destruction rewards.
|
||||
|
||||
This mission uses explicit script completion. Its duration is a catalog hint;
|
||||
waiting out the timer cannot win. The boss stays in the firing corridor, and
|
||||
the two central turrets can be hit with starter forward guns. Equipment and
|
||||
mission progress remain subject to the normal mod-save compatibility checks.
|
||||
|
||||
The generated masters, packaged SD/HD PNGs and synthesized WAVs are included
|
||||
in Git with their definitions, scripts, generator, prompts and validation
|
||||
report. Original game data and the original style-reference PNGs remain
|
||||
outside Git. The three
|
||||
`art/*-source-hd.png` inputs were generated with the built-in imagegen tool
|
||||
using the original terrain and military sprite as style/scale references.
|
||||
Run `python3 examples/mods/iron_coast_mission_1/build_assets.py --palette
|
||||
data/misc/PALETTE_DAT` to regenerate the exact
|
||||
packaged crops, SD palette conversion, HD footprints and synthesized WAVs.
|
||||
See `prompts.md`, `asset-report.json`, and `docs/iron-coast.md` for provenance
|
||||
and validation limits.
|
||||
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 576 B |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 1.1 KiB |