Add scripted lightning weapon mods with interpolated anchored effects
This commit is contained in:
1 parent
98465133aa
commit
c0a6fd3431
50 files changed
+3500
-65
No files matched your search
@@ -0,0 +1,10 @@
|
||||
# lightning_cannon artwork and sound provenance
|
||||
|
||||
The transparent equipment master was created with the built-in imagegen tool and the imagegen skill. No original game pixels or external reference artwork were used. `prepare_art.rs` fits the master onto transparent 16×16 SD and 64×64 HD canvases. Collision and targeting dimensions are independent of image resolution. Actual connecting lightning is drawn through the public `arc` command rather than projectile sprites.
|
||||
|
||||
Final prompt:
|
||||
|
||||
Use case: stylized-concept. Asset type: transparent inventory equipment sprite for a top-down 1994-inspired arcade aircraft shooter, supplied as a square master image for a 16x16 logical icon and 64x64 HD icon. Primary request: a high-powered Lightning Cannon weapon module. Single compact industrial gun assembly viewed directly from above, long forward-facing barrel at top, two sturdy brushed steel rails around a vivid cyan electrical coil, white-blue zigzag electricity confined between the rails, dark navy housing with small copper contacts. Hand-painted retro game sprite with strong readable silhouette, simple broad material planes and restrained crisp highlights. Center the entire object with a little transparent padding. Genuinely transparent background and no ground shadow. No text, letters, numbers, frame, UI, watermark, or surrounding scene. The gun occupies most of the canvas and should still read at tiny size.
|
||||
|
||||
The master, SD/HD images and sound WAVs are included in the package. SHA-256 hashes are recorded in `art-provenance.json`. `generate_audio.py` reproduces deterministic PCM16 mono sounds at 22,050 Hz. Palette conversion occurs in the runtime loader.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Lightning Cannon
|
||||
|
||||
An independent, reusable heavy lightning special for **825,000 credits**.
|
||||
Enable **Lightning Cannon** in the Mods menu, apply selections at the main menu,
|
||||
then buy it in the supply room. Select it in Inventory or cycle specials;
|
||||
**Fire or Special** discharges the cannon while selected. It consumes no ammo,
|
||||
and you can own one.
|
||||
|
||||
The bolt instantly hits the nearest visible air or ground target ahead of the
|
||||
muzzle, within 180 logical pixels and a 36-pixel horizontal aiming allowance.
|
||||
It deals **32 damage**, then jumps from the struck ship to the nearest unhit
|
||||
enemy within **48 pixels**, for **20, 12 and 8 damage**. A discharge hits at most
|
||||
four different enemies. Chaining can change direction and continues from a
|
||||
destroyed target's last position. An isolated target takes only the initial hit;
|
||||
off-axis targets require an adjacent chain target. Cooldown is **18 ticks**,
|
||||
about 0.77 seconds. A missed shot shows a bolt and firing sound without damage.
|
||||
|
||||
The 30-second controlled simulation measured **41.60 damage/s** on an isolated
|
||||
aligned target and **93.60 combined damage/s** across a four-target chain. The
|
||||
price places it above the original Pulse Cannon and below the Death Ray. See
|
||||
the package [balance report](balance-report.json) for the recorded observations.
|
||||
|
||||
This package uses the public mod API's `scripted` weapon behavior, read-only
|
||||
entity snapshots, `damage`, `arc` and `play_sound` commands. All acquisition,
|
||||
chaining and damage rules live in `scripts/weapon.rhai`. It has no dependencies
|
||||
or overrides. It coexists with the other independent weapons and Iron Coast.
|
||||
|
||||
The equipment icon ships in **16×16 SD** and **64×64 HD**, alongside the
|
||||
transparent source master and deterministic synthesized audio. Connecting bolts
|
||||
use the game's arc renderer at both scales. See [provenance](ART_PROVENANCE.md)
|
||||
for the exact built-in imagegen prompt, asset hashes and reproduction recipes.
|
||||
|
||||
From `RustRaptor/`:
|
||||
|
||||
```sh
|
||||
cargo run --locked --no-default-features -- --mods mods --validate-mods lightning_cannon
|
||||
cargo test --locked --no-default-features --test weapons_mods lightning_cannon
|
||||
```
|
||||
|
||||
See [weapon pricing and measurements](../../docs/weapon-balance.md),
|
||||
[the API](../../docs/modding-api.md) and
|
||||
[the AI mod checklist](../../docs/mod-authoring-checklist.md).
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"package": "lightning_cannon",
|
||||
"tool": "built-in imagegen",
|
||||
"use_case": "stylized-concept",
|
||||
"art": {
|
||||
"art/equipment-generated-source.png": {
|
||||
"sha256": "96bddda748c25a70dc3a4be85badb524fe29a77c35ec82bb3f50efe529876ba6",
|
||||
"bytes": 727812
|
||||
},
|
||||
"art/equipment-hd.png": {
|
||||
"sha256": "b17a0d4d4af5f049bc2b02abce6e57570d8217a29dc76f4bf43df3bc2e51c703",
|
||||
"bytes": 2974
|
||||
},
|
||||
"art/equipment-sd.png": {
|
||||
"sha256": "3e43886e382ee9d3014c8a50700602b0499356b6b01a42ba8da965f18af17855",
|
||||
"bytes": 420
|
||||
}
|
||||
},
|
||||
"sounds": {
|
||||
"audio/fire.wav": {
|
||||
"sha256": "139b19dc294cdb256618c381eba3d1f7d0f6b0f77e463c0fda9dd579a8a457d0",
|
||||
"bytes": 10628
|
||||
},
|
||||
"audio/impact.wav": {
|
||||
"sha256": "636543d96b04a8c52e5ade37a5bce1d3f77e3bb6889ff4ffd6675ad9938d6415",
|
||||
"bytes": 4894
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 711 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 420 B |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,204 @@
|
||||
{
|
||||
"difficulty": 2,
|
||||
"fire_steps": 700,
|
||||
"rate": "70/3 Hz",
|
||||
"target_health": 1000000,
|
||||
"target_size": [
|
||||
32,
|
||||
32
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"damage": [
|
||||
1248
|
||||
],
|
||||
"damage_per_second": [
|
||||
41.6
|
||||
],
|
||||
"item": "lightning_cannon:equipment",
|
||||
"price": 825000,
|
||||
"scene": "aligned_air",
|
||||
"seconds": 30.0,
|
||||
"ticks": 700,
|
||||
"with_forward_guns": false
|
||||
},
|
||||
{
|
||||
"damage": [
|
||||
1248
|
||||
],
|
||||
"damage_per_second": [
|
||||
41.6
|
||||
],
|
||||
"item": "lightning_cannon:equipment",
|
||||
"price": 825000,
|
||||
"scene": "aligned_ground",
|
||||
"seconds": 30.0,
|
||||
"ticks": 700,
|
||||
"with_forward_guns": false
|
||||
},
|
||||
{
|
||||
"damage": [
|
||||
0
|
||||
],
|
||||
"damage_per_second": [
|
||||
0.0
|
||||
],
|
||||
"item": "lightning_cannon:equipment",
|
||||
"price": 825000,
|
||||
"scene": "off_axis_air",
|
||||
"seconds": 30.0,
|
||||
"ticks": 700,
|
||||
"with_forward_guns": false
|
||||
},
|
||||
{
|
||||
"damage": [
|
||||
0
|
||||
],
|
||||
"damage_per_second": [
|
||||
0.0
|
||||
],
|
||||
"item": "lightning_cannon:equipment",
|
||||
"price": 825000,
|
||||
"scene": "off_axis_ground",
|
||||
"seconds": 30.0,
|
||||
"ticks": 700,
|
||||
"with_forward_guns": false
|
||||
},
|
||||
{
|
||||
"damage": [
|
||||
1248,
|
||||
0
|
||||
],
|
||||
"damage_per_second": [
|
||||
41.6,
|
||||
0.0
|
||||
],
|
||||
"item": "lightning_cannon:equipment",
|
||||
"price": 825000,
|
||||
"scene": "column",
|
||||
"seconds": 30.0,
|
||||
"ticks": 700,
|
||||
"with_forward_guns": false
|
||||
},
|
||||
{
|
||||
"damage": [
|
||||
780,
|
||||
1248,
|
||||
468
|
||||
],
|
||||
"damage_per_second": [
|
||||
26.0,
|
||||
41.6,
|
||||
15.6
|
||||
],
|
||||
"item": "lightning_cannon:equipment",
|
||||
"price": 825000,
|
||||
"scene": "cluster",
|
||||
"seconds": 30.0,
|
||||
"ticks": 700,
|
||||
"with_forward_guns": false
|
||||
},
|
||||
{
|
||||
"damage": [
|
||||
0
|
||||
],
|
||||
"damage_per_second": [
|
||||
0.0
|
||||
],
|
||||
"item": "lightning_cannon:equipment",
|
||||
"price": 825000,
|
||||
"scene": "close_air",
|
||||
"seconds": 30.0,
|
||||
"ticks": 700,
|
||||
"with_forward_guns": false
|
||||
},
|
||||
{
|
||||
"damage": [
|
||||
0
|
||||
],
|
||||
"damage_per_second": [
|
||||
0.0
|
||||
],
|
||||
"item": "lightning_cannon:equipment",
|
||||
"price": 825000,
|
||||
"scene": "close_ground",
|
||||
"seconds": 30.0,
|
||||
"ticks": 700,
|
||||
"with_forward_guns": false
|
||||
},
|
||||
{
|
||||
"damage": [
|
||||
1248,
|
||||
780,
|
||||
468,
|
||||
312,
|
||||
0
|
||||
],
|
||||
"damage_per_second": [
|
||||
41.6,
|
||||
26.0,
|
||||
15.6,
|
||||
10.4,
|
||||
0.0
|
||||
],
|
||||
"item": "lightning_cannon:equipment",
|
||||
"price": 825000,
|
||||
"scene": "chain",
|
||||
"seconds": 30.0,
|
||||
"ticks": 700,
|
||||
"with_forward_guns": false
|
||||
},
|
||||
{
|
||||
"damage": [
|
||||
0,
|
||||
0,
|
||||
1248,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"damage_per_second": [
|
||||
0.0,
|
||||
0.0,
|
||||
41.6,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"item": "lightning_cannon:equipment",
|
||||
"price": 825000,
|
||||
"scene": "close_cluster",
|
||||
"seconds": 30.0,
|
||||
"ticks": 700,
|
||||
"with_forward_guns": false
|
||||
},
|
||||
{
|
||||
"damage": [
|
||||
1942
|
||||
],
|
||||
"damage_per_second": [
|
||||
64.73333333333333
|
||||
],
|
||||
"item": "lightning_cannon:equipment",
|
||||
"price": 825000,
|
||||
"scene": "aligned_air",
|
||||
"seconds": 30.0,
|
||||
"ticks": 700,
|
||||
"with_forward_guns": true
|
||||
},
|
||||
{
|
||||
"damage": [
|
||||
0
|
||||
],
|
||||
"damage_per_second": [
|
||||
0.0
|
||||
],
|
||||
"item": "lightning_cannon:equipment",
|
||||
"price": 825000,
|
||||
"scene": "close_air",
|
||||
"seconds": 30.0,
|
||||
"ticks": 700,
|
||||
"with_forward_guns": true
|
||||
}
|
||||
],
|
||||
"method": "Real World::step and shipped scripts; 700 Fire steps (30 seconds); stationary 32x32 surviving targets; no player damage. Reproduce with tests/weapon_balance.rs.",
|
||||
"limitations": "Controlled fixture observations, not a claim of equal campaign difficulty. Original catalog and preview pixels remain outside Git."
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"weapons": [
|
||||
{
|
||||
"id": "lightning_cannon:projectile",
|
||||
"name": "Lightning Cannon Discharge",
|
||||
"damage": 32,
|
||||
"speed": 0,
|
||||
"cooldown": 18,
|
||||
"behavior": "scripted",
|
||||
"target": "all",
|
||||
"script": "scripts/weapon.rhai"
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"id": "lightning_cannon:equipment",
|
||||
"name": "Lightning Cannon",
|
||||
"description": "Selectable reusable special. Fires a powerful lightning bolt into the nearest air or ground target in a narrow lane ahead, then chains through up to three nearby enemies with decreasing damage. Select it in Inventory or cycle specials, then use Fire or Special. No ammunition is consumed.",
|
||||
"price": 825000,
|
||||
"max_count": 1,
|
||||
"initial_count": 1,
|
||||
"weapon": "lightning_cannon:projectile",
|
||||
"special": true,
|
||||
"consumable": false,
|
||||
"art": {
|
||||
"png": "art/equipment-sd.png",
|
||||
"hd_png": "art/equipment-hd.png",
|
||||
"logical_width": 16,
|
||||
"logical_height": 16,
|
||||
"nearest": true
|
||||
},
|
||||
"script": "scripts/equipment.rhai"
|
||||
}
|
||||
],
|
||||
"sounds": [
|
||||
{
|
||||
"id": "lightning_cannon:fire",
|
||||
"name": "Lightning Cannon Discharge",
|
||||
"file": "audio/fire.wav",
|
||||
"volume": 92,
|
||||
"pan": 127,
|
||||
"pitch": 128,
|
||||
"priority": 1
|
||||
},
|
||||
{
|
||||
"id": "lightning_cannon:impact",
|
||||
"name": "Lightning Cannon Contact",
|
||||
"file": "audio/impact.wav",
|
||||
"volume": 78,
|
||||
"pan": 127,
|
||||
"pitch": 128,
|
||||
"priority": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reproduce deterministic PCM16 mono electrical discharge sounds."""
|
||||
import math
|
||||
import struct
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGE = "lightning_cannon"
|
||||
RATE = 22050
|
||||
|
||||
def render(kind):
|
||||
heavy = PACKAGE == "lightning_cannon"
|
||||
duration = (0.24 if heavy else 0.09) if kind == "fire" else (0.11 if heavy else 0.06)
|
||||
count = int(RATE * duration)
|
||||
seed = 0x5A17C0DE
|
||||
phase = 0.0
|
||||
samples = []
|
||||
for n in range(count):
|
||||
t = n / count
|
||||
seed ^= (seed << 13) & 0xffffffff
|
||||
seed ^= seed >> 17
|
||||
seed ^= (seed << 5) & 0xffffffff
|
||||
seed &= 0xffffffff
|
||||
noise = seed / 2147483648.0 - 1.0
|
||||
frequency = (2100 - 1450 * t) if heavy else (3100 - 1650 * t)
|
||||
phase += 2 * math.pi * frequency / RATE
|
||||
crackle = noise * (0.45 + 0.25 * math.sin(2 * math.pi * 61 * n / RATE) ** 8)
|
||||
tone = math.sin(phase) * 0.24 + math.sin(phase * 1.503) * 0.15
|
||||
if kind == "impact":
|
||||
tone = math.sin(phase * 0.18) * 0.25
|
||||
attack = min(1.0, n / (RATE * 0.002))
|
||||
envelope = attack * (1 - t) ** (2.2 if kind == "fire" else 3)
|
||||
samples.append(round(max(-1, min(1, (crackle + tone) * envelope)) * 21000))
|
||||
return struct.pack("<" + "h" * len(samples), *samples)
|
||||
|
||||
if __name__ == "__main__":
|
||||
output = Path(__file__).resolve().parent / "audio"
|
||||
output.mkdir(exist_ok=True)
|
||||
for kind in ["fire", "impact"]:
|
||||
with wave.open(str(output / (kind + ".wav")), "wb") as wav:
|
||||
wav.setparams((1, 2, RATE, 0, "NONE", "not compressed"))
|
||||
wav.writeframes(render(kind))
|
||||
print(output / (kind + ".wav"))
|
||||
@@ -0,0 +1,7 @@
|
||||
id = "lightning_cannon"
|
||||
name = "Lightning Cannon"
|
||||
version = "1.0.0"
|
||||
api_version = 1
|
||||
description = "Selectable reusable special. Fires a powerful lightning bolt into the nearest air or ground target in a narrow lane ahead, then chains through up to three nearby enemies with decreasing damage. Select it in Inventory or cycle specials, then use Fire or Special. No ammunition is consumed."
|
||||
content = "content.json"
|
||||
scripts = ["scripts/weapon.rhai", "scripts/equipment.rhai"]
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Fit the transparent equipment master to the 16px SD and 64px HD canvases.
|
||||
use image::{Rgba, RgbaImage, imageops};
|
||||
use std::{env, fs, path::Path};
|
||||
fn canvas(source: &RgbaImage, width: u32, height: u32, hd: bool) -> RgbaImage {
|
||||
// Only the solid silhouette defines its fitting box; faint outer glows stay bounded.
|
||||
let mut bounds = (source.width(), source.height(), 0, 0);
|
||||
for (x, y, p) in source.enumerate_pixels() {
|
||||
if p[3] >= 128 {
|
||||
bounds.0 = bounds.0.min(x);
|
||||
bounds.1 = bounds.1.min(y);
|
||||
bounds.2 = bounds.2.max(x + 1);
|
||||
bounds.3 = bounds.3.max(y + 1);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
bounds.2 > bounds.0 && bounds.3 > bounds.1,
|
||||
"source has no opaque silhouette"
|
||||
);
|
||||
let cropped = imageops::crop_imm(
|
||||
source,
|
||||
bounds.0,
|
||||
bounds.1,
|
||||
bounds.2 - bounds.0,
|
||||
bounds.3 - bounds.1,
|
||||
)
|
||||
.to_image();
|
||||
let margin = if hd { 4 } else { 1 };
|
||||
let available = (
|
||||
width.saturating_sub(margin * 2).max(1),
|
||||
height.saturating_sub(margin * 2).max(1),
|
||||
);
|
||||
let factor = (available.0 as f64 / cropped.width() as f64)
|
||||
.min(available.1 as f64 / cropped.height() as f64);
|
||||
let size = (
|
||||
(cropped.width() as f64 * factor).round().max(1.) as u32,
|
||||
(cropped.height() as f64 * factor).round().max(1.) as u32,
|
||||
);
|
||||
let filter = if hd {
|
||||
imageops::FilterType::Lanczos3
|
||||
} else {
|
||||
imageops::FilterType::Nearest
|
||||
};
|
||||
let mut scaled = imageops::resize(&cropped, size.0, size.1, filter);
|
||||
for p in scaled.pixels_mut() {
|
||||
if p[3] < (if hd { 64 } else { 128 }) {
|
||||
*p = Rgba([0, 0, 0, 0]);
|
||||
} else {
|
||||
p[3] = 255;
|
||||
}
|
||||
}
|
||||
let mut out = RgbaImage::new(width, height);
|
||||
imageops::overlay(
|
||||
&mut out,
|
||||
&scaled,
|
||||
((width - size.0) / 2) as i64,
|
||||
((height - size.1) / 2) as i64,
|
||||
);
|
||||
out
|
||||
}
|
||||
fn main() {
|
||||
let args: Vec<_> = env::args_os().collect();
|
||||
assert_eq!(
|
||||
args.len(),
|
||||
3,
|
||||
"usage: prepare_art EQUIPMENT_SOURCE PACKAGE_DIRECTORY"
|
||||
);
|
||||
let output = Path::new(&args[2]).join("art");
|
||||
fs::create_dir_all(&output).unwrap();
|
||||
let source = image::open(&args[1]).unwrap().into_rgba8();
|
||||
fs::copy(&args[1], output.join("equipment-generated-source.png")).unwrap();
|
||||
for (mode, scale) in [("sd", 1), ("hd", 4)] {
|
||||
let sprite = canvas(&source, 16 * scale, 16 * scale, scale == 4);
|
||||
let path = output.join(format!("equipment-{mode}.png"));
|
||||
sprite.save(&path).unwrap();
|
||||
println!("{}x{} {}", sprite.width(), sprite.height(), path.display());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Pickups use the same reusable equipment as the shop.
|
||||
fn on_pickup(event, state, random) {
|
||||
let recovered = if state.contains("recovered") { state.recovered % 100000 + 1 } else { 1 };
|
||||
#{ commands: [#{ op: "objective", key: "lightning_cannon:recovered", value: recovered }],
|
||||
state: #{ recovered: recovered } }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// The scripted discharge uses only public snapshots and checked API commands.
|
||||
// A narrow forward acquisition lane preserves aiming; later hops may go sideways.
|
||||
fn on_fire(event, state, random) {
|
||||
if event.hostile {
|
||||
return #{ commands: [], state: state };
|
||||
}
|
||||
let bursts = if state.contains("bursts") { state.bursts % 100000 + 1 } else { 1 };
|
||||
let hits = if state.contains("hits") { state.hits } else { 0 };
|
||||
let commands = [#{ op: "play_sound", sound: "lightning_cannon:fire" }];
|
||||
let struck = [];
|
||||
let from_x = event.x;
|
||||
let from_y = event.y;
|
||||
let damages = [32, 20, 12, 8];
|
||||
|
||||
for hop in 0..4 {
|
||||
let selected = #{};
|
||||
let nearest = 1000000;
|
||||
for enemy in event.entities {
|
||||
let cx = enemy.x + enemy.width / 2;
|
||||
let cy = enemy.y + enemy.height / 2;
|
||||
if enemy.health <= 0 || struck.contains(enemy.entity)
|
||||
|| cx < 0 || cx >= 320 || cy < 0 || cy >= 200 {
|
||||
continue;
|
||||
}
|
||||
let dx = cx - from_x;
|
||||
let dy = cy - from_y;
|
||||
let distance = dx * dx + dy * dy;
|
||||
let reachable = if hop == 0 {
|
||||
abs(dx) <= 36 && dy < 0 && distance <= 180 * 180
|
||||
} else {
|
||||
distance <= 48 * 48
|
||||
};
|
||||
// Entity order provides a stable tie break for equally near targets.
|
||||
if reachable && distance < nearest {
|
||||
selected = #{ entity: enemy.entity, x: cx, y: cy };
|
||||
nearest = distance;
|
||||
}
|
||||
}
|
||||
if !selected.contains("entity") { break; }
|
||||
commands.push(#{ op: "arc", x: from_x, y: from_y,
|
||||
to_x: selected.x, to_y: selected.y, ticks: 4 });
|
||||
commands.push(#{ op: "damage", entity: selected.entity, amount: damages[hop] });
|
||||
struck.push(selected.entity);
|
||||
from_x = selected.x;
|
||||
from_y = selected.y;
|
||||
hits = hits % 100000 + 1;
|
||||
}
|
||||
if struck.len() > 0 {
|
||||
commands.push(#{ op: "play_sound", sound: "lightning_cannon:impact" });
|
||||
} else {
|
||||
// A missed shot remains visible without spawning a damaging carrier.
|
||||
commands.push(#{ op: "arc", x: event.x, y: event.y,
|
||||
to_x: event.x, to_y: event.y - 180, ticks: 3 });
|
||||
}
|
||||
#{ commands: commands, state: #{ bursts: bursts, hits: hits } }
|
||||
}
|
||||
Reference in new issue
Block a user