Add scripted lightning weapon mods with interpolated anchored effects

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

No files matched your search

+7
View File
@@ -92,6 +92,8 @@ pub enum WeaponBehavior {
Spread,
Bomb,
EnergyGrab,
/// Fire through on_fire only, without an automatic engine projectile.
Scripted,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
@@ -423,6 +425,11 @@ impl Registry {
};
for w in self.weapons.values() {
label(&w.name, &w.script)?;
ensure!(
!matches!(w.behavior, WeaponBehavior::Scripted) || w.script.is_some(),
"scripted weapon {} requires a script",
w.id
);
ensure!(
w.damage > 0
&& w.damage <= 100000
+3 -1
View File
@@ -3337,6 +3337,7 @@ impl Host {
self.effects(canvas, &world, false, alpha)?;
self.secondary_effects(canvas, &world, 2, alpha)?;
self.projectiles(canvas, &world, true, alpha)?;
crate::render::draw_world_lightning_arcs(canvas, &world, &self.palette, alpha);
self.hud(canvas, &world)?;
Ok(())
}
@@ -3958,11 +3959,12 @@ fn shop_info(
WeaponBehavior::Spread => "Spread",
WeaponBehavior::Bomb => "Blast",
WeaponBehavior::EnergyGrab => "Disables guns",
WeaponBehavior::Scripted => "Custom discharge",
};
stats.push(format!(
"{}{}",
behavior,
if weapon.script.is_some() {
if weapon.script.is_some() && !matches!(weapon.behavior, WeaponBehavior::Scripted) {
" / scripted"
} else {
""
+20
View File
@@ -570,6 +570,23 @@ pub enum Command {
x: i32,
y: i32,
},
/// Cosmetic lightning; pair with damage commands for gameplay effects.
Arc {
x: i32,
y: i32,
to_x: i32,
to_y: i32,
#[serde(default = "arc_ticks")]
#[schemars(range(min = 1, max = 12))]
ticks: u32,
/// Follow this entity's movement; zero is the player. Endpoints remain
/// absolute coordinates at creation, preserving their initial offset.
#[serde(default)]
anchor: Option<u64>,
/// Optional independent anchor for the destination endpoint.
#[serde(default)]
to_anchor: Option<u64>,
},
Sound {
asset: u32,
},
@@ -584,6 +601,9 @@ pub enum Command {
Complete,
Fail,
}
fn arc_ticks() -> u32 {
3
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct HookResult {
+350
View File
@@ -347,6 +347,356 @@ impl Canvas {
out
}
}
/// Render cosmetic arcs on either the SD or HD indexed canvas. Geometry depends
/// only on the arc's coordinates, seed and age, never gameplay RNG or the
/// number of frames drawn. Ordinary palette entries avoid animated terrain hues.
pub fn draw_lightning_arcs(
canvas: &mut Canvas,
arcs: &[crate::simulation::LightningArc],
palette: &[[u8; 3]; 256],
) {
draw_lightning_arcs_at(canvas, arcs, palette, |arc| {
[
[arc.x as f32, arc.y as f32],
[arc.to_x as f32, arc.to_y as f32],
]
});
}
/// Follow attached ships at the same interpolated positions as their sprites.
/// Detached endpoints keep their last simulation position. Rendering never
/// changes arc lifetime, anchors, saved state or either gameplay RNG.
pub fn draw_world_lightning_arcs(
canvas: &mut Canvas,
world: &crate::simulation::World,
palette: &[[u8; 3]; 256],
alpha: f32,
) {
let endpoint = |anchor: Option<crate::simulation::LightningAnchor>, x: i32, y: i32| {
let Some(anchor) = anchor else {
return [x as f32, y as f32];
};
let position = if anchor.entity == 0 {
Some((
world.player.px,
world.player.py,
world.player.x,
world.player.y,
))
} else {
world
.enemies
.iter()
.find(|enemy| enemy.entity == anchor.entity)
.map(|enemy| (enemy.px, enemy.py, enemy.x, enemy.y))
};
position.map_or([x as f32, y as f32], |(px, py, current_x, current_y)| {
[
(px as f32 + (current_x as f32 - px as f32) * alpha + anchor.offset[0] as f32)
.clamp(-32768.0, 32768.0),
(py as f32 + (current_y as f32 - py as f32) * alpha + anchor.offset[1] as f32)
.clamp(-32768.0, 32768.0),
]
})
};
draw_lightning_arcs_at(canvas, &world.lightning_arcs, palette, |arc| {
[
endpoint(arc.anchor, arc.x, arc.y),
endpoint(arc.to_anchor, arc.to_x, arc.to_y),
]
});
}
fn draw_lightning_arcs_at(
canvas: &mut Canvas,
arcs: &[crate::simulation::LightningArc],
palette: &[[u8; 3]; 256],
endpoints: impl Fn(&crate::simulation::LightningArc) -> [[f32; 2]; 2],
) {
if !arcs.iter().any(|arc| arc.age < arc.ticks) || canvas.scale == 0 {
return;
}
// Keep a light field until all channels have been drawn. Taking the maximum
// avoids bright beads at every segment join, and quantizes the glow only
// once rather than losing its falloff to repeated palette rounding.
let mut light = vec![0u8; canvas.image.pixels.len()];
let falloff: [u8; 513] = std::array::from_fn(|i| {
let distance_squared = i as f32 / 32.0;
(255.0 * (0.86 * (-distance_squared / 0.55).exp() + 0.14 * (-distance_squared / 4.5).exp()))
.round() as u8
});
for arc in arcs.iter().filter(|arc| arc.age < arc.ticks) {
let [from, to] = endpoints(arc);
let direction = [to[0] - from[0], to[1] - from[1]];
let length = direction[0].hypot(direction[1]);
let mut noise = (arc.x as u32).wrapping_mul(0x9e37_79b9)
^ (arc.y as u32).wrapping_mul(0x85eb_ca6b)
^ (arc.to_x as u32).wrapping_mul(0xc2b2_ae35)
^ (arc.to_y as u32).rotate_left(11)
^ arc.age.wrapping_mul(0x27d4_eb2d)
^ arc.seed;
let depth = ((length / 2.5).max(1.0).log2().ceil() as u32).clamp(2, 7);
let mut points = vec![from];
lightning_path(
from,
to,
(length * 0.17).clamp(1.5, 18.0),
depth,
&mut noise,
&mut points,
);
let pulse = match arc.age % 3 {
0 => 1.0,
1 => 0.74,
_ => 0.92,
} * (1.0 - 0.22 * arc.age as f32 / arc.ticks as f32);
let mut last_energy = pulse;
for (i, segment) in points.windows(2).enumerate() {
let energy = if i + 2 == points.len() {
pulse
} else {
pulse * (0.86 + lightning_noise(&mut noise) * 0.14)
};
lightning_channel(
canvas,
&mut light,
&falloff,
segment,
[last_energy, energy],
1.0,
);
last_energy = energy;
}
// Fine, dying forks follow the discharge direction. They are cosmetic;
// the mod's explicit damage commands still decide which ships are hit.
if length > 4.0 {
let forward = [direction[0] / length, direction[1] / length];
for _ in 0..((length / 30.0) as usize).clamp(1, 5) {
let index = 1 + (lightning_noise(&mut noise) * (points.len() - 2) as f32) as usize;
let root = points[index];
let side = if lightning_noise(&mut noise) < 0.5 {
-1.0
} else {
1.0
};
let spread = side * (0.45 + lightning_noise(&mut noise) * 0.8);
let reach = (length * (0.2 + lightning_noise(&mut noise) * 0.3)).clamp(4.0, 28.0);
let tip = [
root[0] + (forward[0] - forward[1] * spread) * reach,
root[1] + (forward[1] + forward[0] * spread) * reach,
];
let mut fork = vec![root];
lightning_path(root, tip, reach * 0.2, 3, &mut noise, &mut fork);
for (i, segment) in fork.windows(2).enumerate() {
let taper =
|n: usize| pulse * 0.9 * (1.0 - n as f32 / (fork.len() - 1) as f32).sqrt();
lightning_channel(
canvas,
&mut light,
&falloff,
segment,
[taper(i), taper(i + 1)],
0.45,
);
}
}
}
}
// Add blue light to the existing scene, with a near-white hot core. Cache
// only the palette pairs actually used; avoid animated terrain entries.
LIGHTNING_COLORS.with(|colors| {
let mut colors = colors.borrow_mut();
let colors = colors.get_or_insert_with(|| LightningColors::new(palette));
if colors.palette != *palette {
*colors = LightningColors::new(palette);
}
for (pixel, energy) in canvas.image.pixels.iter_mut().zip(light) {
if energy >= 8 {
*pixel = colors.add_light(*pixel, usize::from(energy) / 8);
}
}
});
}
// A palette-keyed per-thread cache avoids repeating nearest-color searches
// during every flicker frame. Cached values are pure, so draw order/history
// cannot change the image; tests and previews may use different palettes.
std::thread_local! {
static LIGHTNING_COLORS: std::cell::RefCell<Option<LightningColors>> = const {
std::cell::RefCell::new(None)
};
}
struct LightningColors {
palette: [[u8; 3]; 256],
brightness: [u32; 256],
emission: [[f32; 3]; 32],
blend: [[u16; 257]; 32],
}
impl LightningColors {
fn new(palette: &[[u8; 3]; 256]) -> Self {
Self {
palette: *palette,
brightness: palette
.map(|rgb| 3 * u32::from(rgb[0]) + 6 * u32::from(rgb[1]) + u32::from(rgb[2])),
emission: std::array::from_fn(|level| {
let n = level as f32 / 31.0;
[230.0 * n.powf(2.5), 255.0 * n.powf(0.8), 255.0 * n.sqrt()]
}),
blend: [[u16::MAX; 257]; 32],
}
}
fn add_light(&mut self, pixel: u16, level: usize) -> u16 {
let background = usize::from(pixel.min(TRANSPARENT));
let result = &mut self.blend[level][background];
if *result == u16::MAX {
let (rgb, brightness) = if background < 256 {
(self.palette[background], self.brightness[background])
} else {
([0; 3], 0)
};
let target: [i32; 3] = std::array::from_fn(|i| {
(f32::from(rgb[i]) + self.emission[level][i])
.min(255.0)
.round() as i32
});
*result = self.palette[..240]
.iter()
.enumerate()
.filter(|(index, _)| self.brightness[*index] >= brightness)
.min_by_key(|(_, candidate)| {
(0..3)
.map(|i| (i32::from(candidate[i]) - target[i]).pow(2))
.sum::<i32>()
})
.map_or(pixel, |(index, _)| index as u16);
}
*result
}
}
// A local cosmetic hash stream: never touch the simulation or script RNG.
fn lightning_noise(state: &mut u32) -> f32 {
*state = state.wrapping_add(0x9e37_79b9);
let mut hash = *state;
hash = (hash ^ (hash >> 16)).wrapping_mul(0x85eb_ca6b);
hash = (hash ^ (hash >> 13)).wrapping_mul(0xc2b2_ae35);
hash ^= hash >> 16;
(hash >> 8) as f32 / 16_777_216.0
}
fn lightning_path(
from: [f32; 2],
to: [f32; 2],
displacement: f32,
depth: u32,
noise: &mut u32,
points: &mut Vec<[f32; 2]>,
) {
if depth == 0 {
points.push(to);
return;
}
let dx = to[0] - from[0];
let dy = to[1] - from[1];
let length = dx.hypot(dy).max(0.001);
let along = 0.35 + lightning_noise(noise) * 0.3;
let bend = (lightning_noise(noise) * 2.0 - 1.0) * displacement;
let mid = [
from[0] + dx * along - dy / length * bend,
from[1] + dy * along + dx / length * bend,
];
lightning_path(from, mid, displacement * 0.6, depth - 1, noise, points);
lightning_path(mid, to, displacement * 0.6, depth - 1, noise, points);
}
fn lightning_channel(
canvas: &Canvas,
light: &mut [u8],
falloff: &[u8; 513],
segment: &[[f32; 2]],
energy: [f32; 2],
width: f32,
) {
let scale = canvas.scale as f32;
let [x, y] = segment[0].map(|n| n * scale);
let [to_x, to_y] = segment[1].map(|n| n * scale);
let dx = to_x - x;
let dy = to_y - y;
// Clip before rasterizing: valid API coordinates can be well offscreen.
// Beyond 2.7 logical pixels the falloff is below the first visible level.
let pad = 2.7 * scale * width;
let mut start: f32 = 0.0;
let mut end: f32 = 1.0;
for (direction, distance) in [
(-dx, x + pad),
(dx, canvas.image.width as f32 - 1.0 + pad - x),
(-dy, y + pad),
(dy, canvas.image.height as f32 - 1.0 + pad - y),
] {
if direction == 0.0 {
if distance < 0.0 {
return;
}
} else if direction < 0.0 {
start = start.max(distance / direction);
} else {
end = end.min(distance / direction);
}
if start > end {
return;
}
}
let bounds = [
[
(x + dx * start).min(x + dx * end),
(x + dx * start).max(x + dx * end),
],
[
(y + dy * start).min(y + dy * end),
(y + dy * start).max(y + dy * end),
],
];
let major_x = dx.abs() >= dy.abs();
let (major, minor, major_origin, minor_origin, major_delta, minor_delta) = if major_x {
(0, 1, x, y, dx, dy)
} else {
(1, 0, y, x, dy, dx)
};
let size = [canvas.image.width as i32, canvas.image.height as i32];
let first = ((bounds[major][0] - pad).floor() as i32).max(0);
let last = ((bounds[major][1] + pad).ceil() as i32).min(size[major] - 1);
let length_squared = (dx * dx + dy * dy).max(0.000_001);
// Walk a narrow strip along the major axis instead of visiting the entire
// diagonal bounding rectangle. Work is bounded by the visible canvas.
for a in first..=last {
let t = if major_delta.abs() > 0.001 {
((a as f32 - major_origin) / major_delta).clamp(start, end)
} else {
start
};
let center = minor_origin + minor_delta * t;
let minor_pad = pad * std::f32::consts::SQRT_2;
let low = ((center - minor_pad).floor() as i32).max(0);
let high = ((center + minor_pad).ceil() as i32).min(size[minor] - 1);
for b in low..=high {
let (column, row) = if major_x { (a, b) } else { (b, a) };
let px = column as f32 - x;
let py = row as f32 - y;
let along = ((px * dx + py * dy) / length_squared).clamp(0.0, 1.0);
let distance_squared = ((px - dx * along).powi(2) + (py - dy * along).powi(2))
/ (scale * scale * width * width);
let lookup = (distance_squared * 32.0).round() as usize;
if let Some(brightness) = falloff.get(lookup) {
let strength = energy[0] + (energy[1] - energy[0]) * along;
let brightness = (f32::from(*brightness) * strength).round() as u8;
let pixel = &mut light[row as usize * canvas.image.width + column as usize];
*pixel = (*pixel).max(brightness);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
+156 -1
View File
@@ -358,6 +358,28 @@ fn visual_effect(existing: usize, effects: &mut Vec<VisualEffect>, effect: Visua
effects.push(effect);
}
}
/// Presentation-only lightning, with a lifetime measured in simulation ticks.
/// The queue is excluded from saved gameplay state and the deterministic digest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LightningArc {
pub x: i32,
pub y: i32,
pub to_x: i32,
pub to_y: i32,
pub ticks: u32,
pub age: u32,
/// Cosmetic variation between discharges, without advancing gameplay RNG.
pub seed: u32,
pub anchor: Option<LightningAnchor>,
pub to_anchor: Option<LightningAnchor>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LightningAnchor {
pub entity: u64,
/// Endpoint displacement from the entity's top-left origin at creation.
pub offset: [i32; 2],
}
pub const MAX_LIGHTNING_ARCS: usize = 256;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum EffectKind {
GroundBlast,
@@ -513,6 +535,8 @@ pub struct World {
#[serde(skip)]
pub visual_effects: Vec<VisualEffect>,
#[serde(skip)]
pub lightning_arcs: Vec<LightningArc>,
#[serde(skip)]
terrain_spark_delay: u32,
#[serde(skip)]
terrain_flare_delay: u32,
@@ -607,6 +631,7 @@ impl World {
pickups: vec![],
effects: vec![],
visual_effects: vec![],
lightning_arcs: vec![],
terrain_spark_delay: 0,
terrain_flare_delay: 0,
terrain_health: m.layout.iter().map(|i| m.tiles[*i].health).collect(),
@@ -1327,6 +1352,8 @@ impl World {
}
}
self.events.extend(events);
// Capture the final position before retiring an anchor's enemy.
self.update_lightning_arcs();
self.enemies.retain(|e| !retired.contains(&e.entity));
if self.legacy_transition
&& mission.auto_complete
@@ -1349,6 +1376,9 @@ impl World {
self.event(definition,"on_fire",serde_json::json!({"entity":entity,"weapon":id,"x":x,"y":y,"hostile":true,"tick":self.tick}));
}
}
if matches!(registry.weapons[&id].behavior, WeaponBehavior::Scripted) {
continue;
}
if let Some((kind, entity, speed, gun)) = legacy.filter(|(kind, _, _, _)| *kind >= 0) {
self.enemy_projectile(&id, x, y, kind, entity, speed, gun)?;
continue;
@@ -1625,6 +1655,10 @@ impl World {
}
self.visual_effects
.retain(|effect| effect.age <= effect.kind.artwork().1);
for arc in &mut self.lightning_arcs {
arc.age += 1;
}
self.lightning_arcs.retain(|arc| arc.age < arc.ticks);
if self.difficulty < 3 {
if equipment_used {
self.shield_recharge_ticks = 0;
@@ -1720,6 +1754,7 @@ impl World {
}
self.previous_shield = self.player.shield;
self.sync_retained_inventory();
self.update_lightning_arcs();
self.event(self.mission.clone(),"on_tick",serde_json::json!({"tick":self.tick,"player_x":self.player.x,"player_y":self.player.y,"enemies":self.enemies.len()}));
if self.outcome != Outcome::Playing {
self.event(
@@ -1856,6 +1891,14 @@ impl World {
return Ok(());
}
self.cooldowns.insert(id.clone(), w.cooldown);
if matches!(w.behavior, WeaponBehavior::Scripted) {
self.event(
id.clone(),
"on_fire",
serde_json::json!({"entity":0,"hostile":false,"x":self.player.x+16,"y":self.player.y,"tick":self.tick}),
);
return Ok(());
}
if let Some(index) = w.legacy {
if let Some(legacy) = crate::legacy_tables::weapons()
.into_iter()
@@ -2839,6 +2882,14 @@ impl World {
let owner = &registry.owners[&event.content];
let mut e = event.event;
e["entities"] = serde_json::to_value(&self.enemies)?;
for (snapshot, enemy) in e["entities"]
.as_array_mut()
.expect("enemy snapshot array")
.iter_mut()
.zip(&self.enemies)
{
snapshot["ground"] = registry.enemies[&enemy.definition].ground.into();
}
e["player"] = serde_json::to_value(&self.player)?;
e["objectives"] = serde_json::to_value(&self.objectives)?;
e["projectiles"] = serde_json::to_value(&self.projectiles)?;
@@ -2847,7 +2898,10 @@ impl World {
e["tick"] = self.tick.into();
}
let commands = scripts.invoke(owner, path, event.hook, &e, self.rng.seed)?;
self.apply_commands(&commands, registry)?;
self.apply_commands(&commands, registry).map_err(|error| {
let context = format!("{owner}/{path}::{}: {error}", event.hook);
error.context(context)
})?;
}
}
}
@@ -2857,10 +2911,77 @@ impl World {
let mut candidate = self.clone();
for command in commands {
candidate.command(command, registry)?;
if matches!(command, Command::Move { .. } | Command::Despawn { .. }) {
candidate.update_lightning_arcs();
}
}
*self = candidate;
Ok(())
}
fn lightning_anchor(
&self,
entity: Option<u64>,
x: i32,
y: i32,
) -> Result<Option<LightningAnchor>> {
let Some(entity) = entity else {
return Ok(None);
};
let [origin_x, origin_y] = if entity == 0 {
[self.player.x, self.player.y]
} else {
let enemy = self
.enemies
.iter()
.find(|enemy| enemy.entity == entity)
.context("arc anchor does not exist")?;
[enemy.x, enemy.y]
};
Ok(Some(LightningAnchor {
entity,
offset: [
x.checked_sub(origin_x)
.context("arc anchor offset overflow")?,
y.checked_sub(origin_y)
.context("arc anchor offset overflow")?,
],
}))
}
fn update_lightning_arcs(&mut self) {
let player = [self.player.x, self.player.y];
let enemies = &self.enemies;
for arc in &mut self.lightning_arcs {
for (anchor, x, y) in [
(&mut arc.anchor, &mut arc.x, &mut arc.y),
(&mut arc.to_anchor, &mut arc.to_x, &mut arc.to_y),
] {
let Some(attachment) = *anchor else {
continue;
};
let origin = if attachment.entity == 0 {
Some(player)
} else {
enemies
.iter()
.find(|enemy| enemy.entity == attachment.entity)
.map(|enemy| [enemy.x, enemy.y])
};
if let Some([origin_x, origin_y]) = origin {
// An offscreen anchor cannot expand the cosmetic API's
// coordinate bounds or make movement fail gameplay.
*x = origin_x
.saturating_add(attachment.offset[0])
.clamp(-32768, 32768);
*y = origin_y
.saturating_add(attachment.offset[1])
.clamp(-32768, 32768);
} else {
// Retain the last visible position when its ship disappears.
*anchor = None;
}
}
}
}
fn command(&mut self, c: &Command, registry: &Registry) -> Result<()> {
match c {
Command::Spawn { enemy, x, y } => {
@@ -3057,6 +3178,40 @@ impl World {
ensure!(self.effects.len() < 4096, "too many effects");
self.effects.push(Effect::new(EffectKind::AirBlast, *x, *y));
}
Command::Arc {
x,
y,
to_x,
to_y,
ticks,
anchor,
to_anchor,
} => {
ensure!(
[x, y, to_x, to_y].iter().all(|n| n.unsigned_abs() <= 32768),
"arc position exceeds world bounds"
);
ensure!((1..=12).contains(ticks), "arc lifetime must be 1–12 ticks");
ensure!(
self.lightning_arcs.len() < MAX_LIGHTNING_ARCS,
"too many lightning arcs"
);
let anchor = self.lightning_anchor(*anchor, *x, *y)?;
let to_anchor = self.lightning_anchor(*to_anchor, *to_x, *to_y)?;
self.lightning_arcs.push(LightningArc {
x: *x,
y: *y,
to_x: *to_x,
to_y: *to_y,
ticks: *ticks,
age: 0,
seed: (self.tick as u32).wrapping_mul(0x1656_67b1)
^ ((self.tick >> 32) as u32)
^ (self.lightning_arcs.len() as u32).wrapping_mul(0x9e37_79b9),
anchor,
to_anchor,
});
}
Command::Sound { asset } => {
self.sounds.push(*asset);
self.sound_events.push(SoundEvent {