Files
dungeoncraft/src/particles.rs
T
2026-11-05 14:33:39 +01:00

444 lines
19 KiB
Rust

// Particles: trails that fall to the ground behind projectiles, explosions of fireballs and
// cannonballs, bursts of the spells, debris and coins, scorch marks, and arrows that stay stuck
// in walls and the ground for five minutes. Purely visual: never saved, with their own random
// numbers (the game's random sequence stays untouched).
use crate::game::*;
use crate::map::*;
pub const STUCK_MAX: usize = 400; // arrows and scorch marks
pub const STUCK_LIFE: f32 = 300.0; // seconds an arrow stays
const PARTS_MAX: usize = 900;
const GRAVITY: f32 = 6.0;
const LIQ_H: f32 = -0.3; // river surface, matching the depth used in render3d.rs
// particle kinds
pub const EMBER: u8 = 0; // glowing, falls
pub const SMOKE: u8 = 1; // rises and grows
pub const SPARK: u8 = 2; // glowing, falls fast, bounces once
pub const DEBRIS: u8 = 3; // solid chunk, falls and rests a moment
pub const MOTE: u8 = 4; // glowing, floats up slowly
pub const COIN: u8 = 5; // solid gold, bounces
pub const ARROW: u8 = 6; // stuck arrow (vx, vy, vh = its direction)
pub const SCORCH: u8 = 7; // dark mark on the floor
pub const FLASH: u8 = 8; // expanding fireball
pub const RING: u8 = 9; // shock wave
pub const SPLASH: u8 = 10; // liquid droplets, bounce on the river surface
// projectile styles (Fx::style)
pub const ST_PLAIN: u8 = 0;
pub const ST_ARROW: u8 = 1;
pub const ST_CANNON: u8 = 2;
pub const ST_FIRE: u8 = 3;
pub const ST_SHADOW: u8 = 4;
pub const ST_HOLY: u8 = 5;
pub const ST_ARCANE: u8 = 6;
pub const ST_RUNE: u8 = 7;
pub const ST_MAGIC: u8 = 8;
pub const ST_ARROW_WALL: u8 = 9; // an arrow that ends in a wall
#[derive(Clone, Copy, Debug)]
pub struct Part {
pub kind: u8,
pub x: f32,
pub y: f32,
pub h: f32,
pub vx: f32,
pub vy: f32,
pub vh: f32,
pub t: f32,
pub max: f32,
pub col: u32,
pub size: f32,
}
pub fn vrng() -> Rng {
Rng(0x2545_f491)
}
// colour as 0xAABBGGRR (like Fx::col)
pub const fn abgr(r: u8, g: u8, b: u8) -> u32 {
0xff00_0000 | (b as u32) << 16 | (g as u32) << 8 | r as u32
}
// position of a projectile at progress p (cannonballs and fireballs fly in an arc)
pub fn fx_pos(f: &Fx, p: f32) -> (f32, f32, f32) {
let p = p.clamp(0.0, 1.0);
let x = f.x + (f.x2 - f.x) * p;
let y = f.y + (f.y2 - f.y) * p;
let dist = ((f.x2 - f.x).powi(2) + (f.y2 - f.y).powi(2)).sqrt();
let arc = match f.style {
ST_CANNON => 0.22,
ST_FIRE => 0.12,
ST_ARROW | ST_ARROW_WALL => 0.04,
_ => 0.0,
} * dist;
let h = f.h + (f.h2 - f.h) * p + arc * (p * std::f32::consts::PI).sin();
(x, y, h)
}
impl Game {
pub(crate) fn part(&mut self, kind: u8, (x, y, h): (f32, f32, f32), (vx, vy, vh): (f32, f32, f32), max: f32, col: u32, size: f32) {
if self.parts.len() < PARTS_MAX {
self.parts.push(Part { kind, x, y, h, vx, vy, vh, t: 0.0, max, col, size });
}
}
fn stick(&mut self, p: Part) {
if self.stuck.len() >= STUCK_MAX {
self.stuck.remove(0);
}
self.stuck.push(p);
}
// a random direction with the given speed (upwards bias `up`)
fn burst_vel(&mut self, speed: f32, up: f32) -> (f32, f32, f32) {
let a = self.vrng.f() * std::f32::consts::TAU;
let s = speed * (0.4 + 0.6 * self.vrng.f());
(a.cos() * s, a.sin() * s, up * (0.5 + self.vrng.f()))
}
fn burst(&mut self, kind: u8, at: (f32, f32, f32), n: usize, speed: f32, up: f32, max: f32, col: u32, size: f32) {
for _ in 0..n {
let v = self.burst_vel(speed, up);
let m = max * (0.6 + 0.6 * self.vrng.f());
let sz = size * (0.7 + 0.6 * self.vrng.f());
self.part(kind, at, v, m, col, sz);
}
}
// particles that come with a new effect (spells, hits, digging, gold, ...)
pub(crate) fn fx_particles(&mut self, kind: u8, x: f32, y: f32, h: f32, col: u32) {
match kind {
// lightning: a shower of sparks, smoke and a burnt spot
1 => {
self.burst(SPARK, (x, y, 0.1), 40, 3.5, 3.5, 1.0, abgr(255, 250, 200), 0.05);
self.burst(SMOKE, (x, y, 0.2), 6, 0.4, 0.6, 2.0, abgr(90, 90, 100), 0.25);
self.part(FLASH, (x, y, 0.3), (0.0, 0.0, 0.0), 0.35, abgr(255, 255, 220), 1.0);
self.stick(Part { kind: SCORCH, x, y, h: 0.0, vx: 0.0, vy: 0.0, vh: 0.0, t: 0.0, max: 20.0, col: 0, size: 0.8 });
}
// hit: a few sparks of the effect's colour
2 => self.burst(SPARK, (x, y, h.max(0.3)), 5, 1.5, 1.5, 0.5, col, 0.035),
3 => self.burst(SMOKE, (x, y, 0.3), 4, 0.3, 0.5, 1.6, abgr(80, 80, 90), 0.2),
// healing: green motes rising in a spiral, sparkles falling
4 => {
for k in 0..12 {
let a = k as f32 * 0.52;
self.part(MOTE, (x + a.cos() * 0.3, y + a.sin() * 0.3, 0.1 + k as f32 * 0.03), (-a.sin() * 0.3, a.cos() * 0.3, 0.9), 1.3, abgr(120, 255, 140), 0.05);
}
self.burst(EMBER, (x, y, 0.9), 6, 0.6, 0.5, 1.0, abgr(200, 255, 200), 0.03);
}
// gold: coins pop up and fall
5 => self.burst(COIN, (x, y, 0.4), 5, 1.2, 2.5, 1.6, abgr(255, 210, 60), 0.06),
// digging: earth chunks
6 => self.burst(DEBRIS, (x, y, 0.5), 4, 1.2, 1.5, 1.4, abgr(110, 80, 56), 0.06),
// level up, spells, specials: sparkles in the effect's colour shoot up and rain down
7 => self.burst(EMBER, (x, y, 0.5), 18, 1.6, 3.0, 1.4, col, 0.04),
// claiming: a puff of dust
8 => self.burst(SMOKE, (x, y, 0.1), 2, 0.3, 0.2, 0.8, abgr(120, 60, 60), 0.12),
_ => {}
}
}
// advances projectiles (trails, impacts) and particles
pub(crate) fn update_particles(&mut self, dt: f32) {
// trails and impacts of the projectiles
let mut trails: Vec<(u8, (f32, f32, f32), f32)> = Vec::new();
let mut impacts: Vec<Fx> = Vec::new();
for f in self.fx.iter() {
if f.kind != 0 {
continue;
}
let p0 = f.t / f.max;
let p1 = (f.t + dt) / f.max;
if p0 < 1.0 {
trails.push((f.style, fx_pos(f, p1.min(1.0)), f.size));
}
if p0 < 1.0 && p1 >= 1.0 {
impacts.push(*f);
}
}
for (style, at, size) in trails {
let (rate, kind, col, psize, life) = match style {
ST_FIRE => (70.0, EMBER, abgr(255, 150, 40), 0.045, 0.9),
ST_SHADOW => (45.0, EMBER, abgr(200, 70, 255), 0.035, 1.0),
ST_HOLY => (35.0, MOTE, abgr(255, 240, 150), 0.03, 0.8),
ST_ARCANE => (40.0, EMBER, abgr(255, 170, 90), 0.03, 0.9),
ST_RUNE => (45.0, SPARK, abgr(90, 200, 255), 0.03, 0.7),
ST_MAGIC => (45.0, EMBER, abgr(255, 90, 220), 0.035, 0.9),
ST_CANNON => (30.0, SMOKE, abgr(70, 70, 76), 0.1, 0.9),
ST_ARROW | ST_ARROW_WALL => (0.0, EMBER, 0, 0.0, 0.0),
_ => (12.0, EMBER, abgr(180, 210, 230), 0.03, 0.6),
};
let n = rate * dt * size;
let mut count = n.floor() as usize;
if self.vrng.f() < n.fract() {
count += 1;
}
for _ in 0..count {
let v = (self.vrng.f() * 0.6 - 0.3, self.vrng.f() * 0.6 - 0.3, if kind == SMOKE { 0.3 } else { 0.2 });
let j = (self.vrng.f() * 0.06 - 0.03, self.vrng.f() * 0.06 - 0.03);
let m = life * (0.7 + 0.6 * self.vrng.f());
self.part(kind, (at.0 + j.0, at.1 + j.1, at.2), v, m, col, psize * size.sqrt());
}
if style == ST_FIRE && self.vrng.f() < 12.0 * dt {
self.part(SMOKE, at, (0.0, 0.0, 0.4), 1.2, abgr(60, 50, 50), 0.12);
}
}
for f in impacts {
self.impact(&f);
}
self.spawn_lava_particles(dt);
// particles
for p in self.parts.iter_mut() {
p.t += dt;
match p.kind {
SMOKE => {
p.x += p.vx * dt;
p.y += p.vy * dt;
p.h += p.vh * dt;
p.vx *= 1.0 - dt;
p.vy *= 1.0 - dt;
p.size += dt * 0.35;
}
MOTE => {
p.x += p.vx * dt;
p.y += p.vy * dt;
p.h += p.vh * dt;
}
FLASH | RING => {}
SPLASH => {
p.vh -= GRAVITY * 0.75 * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.h += p.vh * dt;
if p.h <= LIQ_H {
p.h = LIQ_H;
if p.vh < -0.6 {
p.vh = -p.vh * 0.30;
p.vx *= 0.55;
p.vy *= 0.55;
} else {
p.vh = 0.0;
p.vx *= 0.65;
p.vy *= 0.65;
}
}
}
_ => {
// falling: embers drift down slower than sparks and debris
let g = if p.kind == EMBER { GRAVITY * 0.35 } else { GRAVITY };
p.vh -= g * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.h += p.vh * dt;
if p.h <= 0.02 {
p.h = 0.02;
if matches!(p.kind, SPARK | COIN) && p.vh < -1.0 {
p.vh = -p.vh * 0.35;
p.vx *= 0.5;
p.vy *= 0.5;
} else {
p.vh = 0.0;
p.vx *= 0.8;
p.vy *= 0.8;
}
}
}
}
}
self.parts.retain(|p| p.t < p.max);
// stuck arrows fall out when their wall is dug away; everything fades in the end
let map = &self.map;
for p in self.stuck.iter_mut() {
p.t += dt;
if p.kind == ARROW && p.h > 0.1 {
let (tx, ty) = ((p.x + p.vx * 0.08) as i32, (p.y + p.vy * 0.08) as i32);
if !map.get(tx, ty).map_or(false, |t| t.solid()) {
p.t = p.max;
}
}
}
self.stuck.retain(|p| p.t < p.max);
}
fn impact(&mut self, f: &Fx) {
let size = f.size;
let (x, y) = (f.x2, f.y2);
match f.style {
ST_FIRE => {
// the fireball explodes on the ground
let at = (x, y, 0.15);
self.part(FLASH, at, (0.0, 0.0, 0.0), 0.45, abgr(255, 170, 60), 0.9 * size);
self.part(RING, (x, y, 0.05), (0.0, 0.0, 0.0), 0.5, abgr(255, 120, 40), 1.4 * size);
self.burst(EMBER, at, (24.0 * size) as usize, 2.8 * size, 3.0, 1.2, abgr(255, 140, 40), 0.05);
self.burst(SPARK, at, 10, 3.5, 2.5, 0.7, abgr(255, 230, 120), 0.035);
self.burst(SMOKE, (x, y, 0.45), 8, 0.6, 0.7, 2.2, abgr(70, 60, 60), 0.20);
self.stick(Part { kind: SCORCH, x, y, h: 0.0, vx: 0.0, vy: 0.0, vh: 0.0, t: 0.0, max: 25.0, col: 0, size: 0.7 * size });
}
ST_CANNON => {
// the ball explodes: flash, stones and dirt flying, smoke
let at = (x, y, 0.2);
self.part(FLASH, at, (0.0, 0.0, 0.0), 0.3, abgr(255, 200, 120), 0.7 * size);
self.part(RING, (x, y, 0.05), (0.0, 0.0, 0.0), 0.4, abgr(220, 200, 170), 1.6);
self.burst(DEBRIS, at, 16, 3.0, 3.5, 1.6, abgr(84, 70, 64), 0.07);
self.burst(DEBRIS, at, 6, 2.5, 3.0, 1.4, abgr(120, 92, 70), 0.05);
self.burst(SPARK, at, 12, 4.0, 2.5, 0.6, abgr(255, 200, 100), 0.03);
self.burst(SMOKE, (x, y, 0.45), 10, 0.8, 0.6, 2.4, abgr(80, 80, 86), 0.23);
self.stick(Part { kind: SCORCH, x, y, h: 0.0, vx: 0.0, vy: 0.0, vh: 0.0, t: 0.0, max: 20.0, col: 0, size: 0.6 });
}
ST_ARROW | ST_ARROW_WALL => {
if let Some(kind) = self.liquid_kind_at(x, y) {
self.liquid_impact(kind, x, y, size);
return;
}
let (dx, dy) = (f.x2 - f.x, f.y2 - f.y);
let l = (dx * dx + dy * dy).sqrt().max(0.01);
let (dx, dy) = (dx / l, dy / l);
if f.style == ST_ARROW_WALL {
self.burst(DEBRIS, (x, y, f.h2), 3, 0.8, 1.0, 0.8, abgr(110, 90, 80), 0.04);
let h = f.h2 + self.vrng.f() * 0.2;
self.stick(Part { kind: ARROW, x, y, h, vx: dx, vy: dy, vh: -0.08, t: 0.0, max: STUCK_LIFE, col: 0, size: 1.0 });
} else if self.vrng.f() < 0.35 {
// glanced off: stuck in the floor next to the target
let (ox, oy) = (self.vrng.f() * 0.7 - 0.35, self.vrng.f() * 0.7 - 0.35);
let tilt = -0.6 - self.vrng.f() * 0.6;
let n = (1.0 + tilt * tilt).sqrt();
self.stick(Part { kind: ARROW, x: x + ox, y: y + oy, h: 0.04, vx: dx / n, vy: dy / n, vh: tilt / n, t: 0.0, max: STUCK_LIFE, col: 0, size: 1.0 });
}
}
ST_SHADOW => self.burst(EMBER, (x, y, f.h2), 14, 1.6, 1.5, 0.9, abgr(190, 70, 255), 0.04),
ST_HOLY => self.burst(MOTE, (x, y, f.h2), 12, 0.8, 0.8, 0.8, abgr(255, 240, 160), 0.035),
ST_ARCANE => self.burst(EMBER, (x, y, f.h2), 14, 1.8, 1.5, 0.8, abgr(255, 180, 100), 0.035),
ST_RUNE => {
self.burst(SPARK, (x, y, f.h2), 16, 2.5, 2.0, 0.7, abgr(110, 210, 255), 0.035);
self.part(FLASH, (x, y, f.h2), (0.0, 0.0, 0.0), 0.2, abgr(150, 220, 255), 0.4);
}
ST_MAGIC => self.burst(EMBER, (x, y, f.h2), 14, 1.8, 1.5, 0.9, abgr(255, 100, 220), 0.04),
_ => self.burst(SPARK, (x, y, f.h2), 6, 1.5, 1.2, 0.5, f.col, 0.03),
}
}
fn liquid_kind_at(&self, x: f32, y: f32) -> Option<Kind> {
self.map.get(x as i32, y as i32).filter(|t| t.liquid()).map(|t| t.kind)
}
fn liquid_impact(&mut self, kind: Kind, x: f32, y: f32, size: f32) {
let at = (x, y, LIQ_H + 0.04);
match kind {
Kind::Lava => {
self.burst(SPLASH, at, 8.max((10.0 * size) as usize), 1.3, 1.9, 0.38, abgr(255, 110, 25), 0.045);
self.burst(MOTE, at, 4, 0.7, 1.2, 0.7, abgr(255, 150, 45), 0.035);
self.part(FLASH, at, (0.0, 0.0, 0.0), 0.12, abgr(255, 155, 45), 0.18 * size.max(0.7));
// short burning arrow smoke: small, quickly fading
self.part(SMOKE, (x, y, LIQ_H + 0.10), (0.0, 0.0, 0.70), 0.65, abgr(115, 55, 25), 0.09);
self.burst(SMOKE, (x, y, LIQ_H + 0.18), 1, 0.1, 0.35, 0.45, abgr(85, 42, 20), 0.06);
}
_ => {
self.burst(SPLASH, at, 10.max((12.0 * size) as usize), 1.2, 1.8, 0.42, abgr(140, 220, 255), 0.04);
self.part(RING, (x, y, LIQ_H), (0.0, 0.0, 0.0), 0.25, abgr(150, 225, 255), 0.35 * size.max(0.7));
self.part(SMOKE, (x, y, LIQ_H + 0.08), (0.0, 0.0, 0.35), 0.45, abgr(180, 210, 235), 0.07);
}
}
}
fn spawn_lava_particles(&mut self, dt: f32) {
if self.parts.len() >= PARTS_MAX {
return;
}
let rate = 7.0;
let mut count = (rate * dt).floor() as usize;
if self.vrng.f() < (rate * dt).fract() {
count += 1;
}
if count == 0 {
return;
}
let max_x = self.map.w as i32 - 1;
let max_y = self.map.h as i32 - 1;
if max_x <= 1 || max_y <= 1 {
return;
}
for _ in 0..count {
if self.parts.len() >= PARTS_MAX {
break;
}
let mut spot = None;
for _ in 0..16 {
let x = self.vrng.range(1, max_x - 1);
let y = self.vrng.range(1, max_y - 1);
let i = idx(x, y);
if self.map.shown(i) && self.map.t[i].kind == Kind::Lava {
spot = Some((x as f32 + 0.5, y as f32 + 0.5));
break;
}
}
if let Some((cx, cy)) = spot {
let at = (cx, cy, LIQ_H + 0.03);
self.burst(MOTE, at, 1, 0.15, 0.40, 1.40, abgr(255, 115, 30), 0.035);
let spark = self.vrng.f() < 0.25;
if spark {
self.burst(SPLASH, at, 1, 0.70, 1.10, 0.38, abgr(255, 170, 45), 0.028);
}
}
}
}
// the last projectile gets a style
pub(crate) fn style_last_fx(&mut self, style: u8) {
if let Some(f) = self.fx.last_mut() {
f.style = style;
}
}
// where an arrow flying from (x, y) along (dx, dy) ends: the first wall within `max`
pub(crate) fn arrow_end(&self, x: f32, y: f32, dx: f32, dy: f32, max: f32) -> (f32, f32, bool) {
let mut d = 0.0;
while d < max {
d += 0.1;
let (px, py) = (x + dx * d, y + dy * d);
if self.map.get(px as i32, py as i32).map_or(true, |t| t.solid()) {
let back = (d - 0.12).max(0.0);
return (x + dx * back, y + dy * back, true);
}
}
(x + dx * max, y + dy * max, false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn projectiles_trail_and_explode_and_arrows_stick() {
let mut g = Game::new(3, 64, 64);
let (cx, cy) = g.map.core;
let (x, y) = (cx as f32 + 1.5, cy as f32 - 1.5);
g.shot(x, y, 0.5, x + 3.0, y, 0.45, 0.3, 0xffffffff);
g.style_last_fx(ST_FIRE);
let (ex, ey, wall) = g.arrow_end(x, y, -1.0, 0.0, 20.0);
assert!(wall);
g.shot(x, y, 0.5, ex, ey, 0.45, 0.3, 0xffffffff);
g.style_last_fx(ST_ARROW_WALL);
let mut trail = 0;
for _ in 0..4 {
g.update(0.05);
trail = trail.max(g.parts.len());
}
assert!(trail > 3, "fire trail: {}", trail);
for _ in 0..6 {
g.update(0.05);
}
assert!(g.parts.iter().any(|p| p.kind == FLASH) || g.stuck.iter().any(|p| p.kind == SCORCH), "explosion");
assert!(g.stuck.iter().any(|p| p.kind == ARROW), "arrow stuck");
// particles fall to the ground and vanish
for _ in 0..200 {
g.update(0.05);
}
assert!(g.parts.iter().all(|p| p.h >= 0.0));
assert!(g.parts.len() < 5);
assert!(g.stuck.iter().any(|p| p.kind == ARROW), "arrows stay for minutes");
}
}