ccc3e20902
Smoke: one rule by engine, no heat on trails, no trail on P1-SUN. Searchlights: two lamps a target, off with an empty sky. Halos fade before their reach and their sum is limited. Launch sites load chunks through the server thread, go off-map as a last resort, and reach 4000 blocks. Air defence: ranges x1.5, shots grow with urgency, claims released, /airdef lastchance, long shots on a big radar's tracks. Build: rust-toolchain.toml, airdef.py check, generator manifest and --check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
307 lines
12 KiB
Rust
307 lines
12 KiB
Rust
//! Smoke for the HD renderer (`render.set-smoke`): exhaust trails of drones and missiles that leave
|
|
//! a clear tunnel in fog and cloud, smoke columns over fires, and the smoke of blasts. The game
|
|
//! derives everything from a segment's age, so the columns and blasts are a pure function of the
|
|
//! tick (nothing grows twice when a frame is shown again); trails are the only state, and they are
|
|
//! cut back when the tick goes backwards.
|
|
use crate::sim::{Class, Sim};
|
|
use glam::DVec3;
|
|
use horizon_mod_sdk::render::SmokeSegment;
|
|
use std::collections::HashMap;
|
|
|
|
const MAX_TRAIL: usize = 2400;
|
|
const MAX_TOTAL: usize = 4000;
|
|
|
|
struct Trail {
|
|
seg: SmokeSegment,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct Smoke {
|
|
trails: Vec<Trail>,
|
|
/// Where each flying thing last left a segment: (kind, id) -> (position, tick).
|
|
last: HashMap<(u8, u32), (DVec3, u64)>,
|
|
last_tick: u64,
|
|
next: u32,
|
|
/// The renderer draws the smoke (the answer of the last call).
|
|
pub hd: bool,
|
|
}
|
|
|
|
fn seg(id: u32, a: DVec3, b: DVec3, born: u64, life: u32) -> SmokeSegment {
|
|
SmokeSegment {
|
|
id,
|
|
ax: a.x,
|
|
ay: a.y,
|
|
az: a.z,
|
|
bx: b.x,
|
|
by: b.y,
|
|
bz: b.z,
|
|
born: born as f64,
|
|
life: life as f32,
|
|
radius_start: 1.0,
|
|
radius_end: 6.0,
|
|
density: 0.5,
|
|
heat: 0.0,
|
|
soot: 0.4,
|
|
rise: 0.2,
|
|
clear: 0.0,
|
|
}
|
|
}
|
|
|
|
|
|
/// The column over one fire up to `tick`: a puff every 15 ticks (90 for the weaker fires) that
|
|
/// rises, spreads and thins. A pure function of the tick.
|
|
fn fire_column(out: &mut Vec<SmokeSegment>, fire_id: u32, at: DVec3, start: u64, size: f32, shown: bool, tick: u64, level: &dyn Fn(u64) -> (f32, f32)) {
|
|
// Only the strongest fires get a full column; the rest a thin puff now and then.
|
|
let step = if shown { 15 } else { 90 };
|
|
let first = start.max(tick.saturating_sub(520));
|
|
let mut k = first.div_ceil(step) * step;
|
|
while k <= tick && out.len() < MAX_TOTAL {
|
|
let (flame, smoke) = level(k);
|
|
if smoke > 0.05 && k >= start {
|
|
let id = 0x4000_0000 | (fire_id.wrapping_mul(131) ^ (k / step) as u32) & 0x3fff_ffff;
|
|
let at = at + DVec3::Y * 0.6;
|
|
let mut s = seg(id, at, at, k, 540);
|
|
s.radius_start = 1.0 * size;
|
|
s.radius_end = 8.0 * size + 2.0;
|
|
s.density = 2.6 * smoke * size.sqrt() * if shown { 1.0 } else { 0.5 };
|
|
s.heat = flame * 0.6;
|
|
s.soot = 0.85;
|
|
s.rise = 2.6;
|
|
out.push(s);
|
|
}
|
|
k += step;
|
|
}
|
|
}
|
|
|
|
/// Blasts: a billow of puffs going up, for 900 ticks after each.
|
|
fn blast_billows(out: &mut Vec<SmokeSegment>, blasts: &[crate::sim::Blast], tick: u64) {
|
|
for b in blasts {
|
|
let age = tick.saturating_sub(b.start);
|
|
if age > 900 {
|
|
continue;
|
|
}
|
|
for j in 0..6u64 {
|
|
let born = b.start + j * 3;
|
|
if born > tick {
|
|
break;
|
|
}
|
|
let h = b.size as f64 * (0.2 + 0.5 * j as f64);
|
|
let at = b.at + DVec3::Y * h;
|
|
let mut s = seg(0x5000_0000 | (b.id.wrapping_mul(7) + j as u32) & 0x0fff_ffff, at, at, born, 800);
|
|
s.radius_start = b.size * 0.6;
|
|
s.radius_end = b.size * 3.2 + 4.0;
|
|
s.density = 2.4 / (1.0 + j as f32 * 0.25);
|
|
s.heat = if j < 3 { 1.0 } else { 0.4 };
|
|
s.soot = 0.55;
|
|
s.rise = 4.0 - 0.3 * j as f32;
|
|
out.push(s);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// How one kind of trail looks. One rule by engine: a propeller leaves a faint short grey haze,
|
|
/// a jet or a cruise missile a thin grey line, a rocket (supersonic, ballistic, interceptor) a white
|
|
/// line as thick as the missile is big. No trail has heat: the flame at the nozzle is separate.
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct Look {
|
|
r0: f32,
|
|
r1: f32,
|
|
density: f32,
|
|
soot: f32,
|
|
clear: f32,
|
|
life: f32,
|
|
}
|
|
|
|
/// A rocket's white line, thicker for a bigger missile (`scale`: Pantsir 0.85, S-500 1.7).
|
|
fn rocket(scale: f32, density: f32, life: f32) -> Look {
|
|
let scale = scale.min(2.0);
|
|
Look { r0: 0.25 * scale, r1: 1.2 + 1.6 * scale, density, soot: 0.02, clear: 0.8 + 1.2 * scale, life: life + 250.0 * scale }
|
|
}
|
|
|
|
fn drone_look(class: Class, scale: f32) -> Look {
|
|
match class {
|
|
Class::Drone => Look { r0: 0.4, r1: 1.8, density: 0.15, soot: 0.3, clear: 0.3, life: 250.0 },
|
|
Class::Jet => Look { r0: 0.5, r1: 2.5, density: 0.5, soot: 0.25, clear: 1.5, life: 500.0 },
|
|
Class::Cruise => Look { r0: 0.6, r1: 3.0, density: 0.6, soot: 0.3, clear: 2.0, life: 600.0 },
|
|
Class::Supersonic => rocket(scale, 0.9, 400.0),
|
|
Class::Ballistic => rocket(scale, 1.1, 500.0),
|
|
}
|
|
}
|
|
|
|
/// `None`: no motor smoke at all (the electric P1-SUN).
|
|
fn missile_look(kind: crate::sim::MissileKind) -> Option<Look> {
|
|
kind.trail_scale().map(|k| rocket(k, 0.8, 250.0))
|
|
}
|
|
|
|
fn style(s: &mut SmokeSegment, l: Look) {
|
|
s.radius_start = l.r0;
|
|
s.radius_end = l.r1;
|
|
s.density = l.density;
|
|
s.heat = 0.0;
|
|
s.soot = l.soot;
|
|
s.clear = l.clear;
|
|
s.life = l.life;
|
|
}
|
|
|
|
/// Points between a replay trail segment's ends (a segment every `TRAIL_GRID * STEP` ticks).
|
|
const TRAIL_GRID: usize = 4;
|
|
|
|
/// The smoke a recording shows at its tick `t`: trails behind everything that flew, columns over
|
|
/// the fires its events lit, billows over its blasts. A pure function of `t` (nothing is kept
|
|
/// between calls, so a seek, a pause or a cut shows exactly what playing through would), and it
|
|
/// touches no live state. Trail segments sit on a fixed grid of each track's points.
|
|
pub fn replay_smoke(rec: &crate::sim::record::Recording, t: f64) -> Vec<SmokeSegment> {
|
|
use crate::sim::record::{STEP, What};
|
|
let tick = t.max(0.0) as u64;
|
|
let mut out: Vec<SmokeSegment> = Vec::new();
|
|
let stride = (TRAIL_GRID as u32 * STEP) as f64;
|
|
for (n, tr) in rec.tracks.iter().enumerate() {
|
|
let look = match tr.what {
|
|
What::Drone(kind) => {
|
|
let spec = kind.spec();
|
|
Some(drone_look(spec.class, spec.trail_scale()))
|
|
}
|
|
What::Missile(kind, _) => missile_look(kind),
|
|
};
|
|
let Some(look) = look else { continue };
|
|
let life = look.life as f64;
|
|
// The segments whose end point has been reached and whose life is not over.
|
|
let lo = (((t - life - tr.first as f64) / stride).floor().max(0.0)) as usize;
|
|
let hi = (((t - tr.first as f64) / stride).floor().max(0.0)) as usize;
|
|
for g in lo.max(1)..=hi {
|
|
let (Some(a), Some(b)) = (tr.sample(rec.origin, (g - 1) * TRAIL_GRID), tr.sample(rec.origin, g * TRAIL_GRID)) else { break };
|
|
let born = tr.first as u64 + (g * TRAIL_GRID) as u64 * STEP as u64;
|
|
if born > tick || a.distance(b) >= 400.0 {
|
|
continue;
|
|
}
|
|
let mut s = seg(0x2000_0000 | ((n as u32).wrapping_mul(7919) ^ g as u32) & 0x0fff_ffff, a, b, born, 1200);
|
|
style(&mut s, look);
|
|
if (tick as f64) < born as f64 + s.life as f64 {
|
|
out.push(s);
|
|
}
|
|
}
|
|
}
|
|
if out.len() > MAX_TRAIL {
|
|
out.sort_by_key(|s| s.born as u64);
|
|
let cut = out.len() - MAX_TRAIL;
|
|
out.drain(..cut);
|
|
}
|
|
// Fires, the strongest dozen in full (as the live ones are).
|
|
let mut fires = crate::sim::record::replay_fires(rec, t);
|
|
fires.sort_by(|a, b| b.size.total_cmp(&a.size).then(a.start.cmp(&b.start)));
|
|
for (rank, f) in fires.iter().enumerate() {
|
|
let life = f.life as f64;
|
|
fire_column(&mut out, 0x1000_0000 | f.key as u32, f.at, f.start as u64, f.size, rank < crate::sim::fires::MAX, tick, &|k| crate::sim::fires::level(k.saturating_sub(f.start as u64) as f64, life));
|
|
}
|
|
// Blasts keep their billows long after the fireball is out.
|
|
let blasts: Vec<crate::sim::Blast> = rec
|
|
.events
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, e)| e.at as u64 <= tick)
|
|
.map(|(k, e)| {
|
|
let (size, life) = crate::sim::record::blast_spec(e.kind);
|
|
crate::sim::Blast { id: 0x4000_0000 | k as u32, at: e.pos, size, start: e.at as u64, life }
|
|
})
|
|
.collect();
|
|
blast_billows(&mut out, &blasts, tick);
|
|
out.truncate(MAX_TOTAL);
|
|
out
|
|
}
|
|
|
|
impl Smoke {
|
|
fn trail(&mut self, key: (u8, u32), at: DVec3, tick: u64, look: Look) {
|
|
let Some(&(from, when)) = self.last.get(&key) else {
|
|
self.last.insert(key, (at, tick));
|
|
return;
|
|
};
|
|
if at.distance(from) < 5.0 && tick - when < 10 {
|
|
return;
|
|
}
|
|
// A jump (a teleport, a respawn) is no trail.
|
|
if at.distance(from) < 400.0 {
|
|
self.next = self.next.wrapping_add(1);
|
|
let mut s = seg(self.next, from, at, tick, 1200);
|
|
style(&mut s, look);
|
|
self.trails.push(Trail { seg: s });
|
|
}
|
|
self.last.insert(key, (at, tick));
|
|
}
|
|
|
|
/// Follows the simulation one tick: new trail segments for what flies, old ones dropped.
|
|
pub fn update(&mut self, sim: &Sim) {
|
|
let tick = sim.tick;
|
|
if tick < self.last_tick {
|
|
self.trails.retain(|t| t.seg.born as u64 <= tick);
|
|
self.last.clear();
|
|
}
|
|
self.last_tick = tick;
|
|
for d in &sim.drones {
|
|
let spec = d.kind.spec();
|
|
self.trail((0, d.id), d.pos, tick, drone_look(spec.class, spec.trail_scale()));
|
|
}
|
|
for m in &sim.missiles {
|
|
if let Some(look) = missile_look(m.kind) {
|
|
self.trail((1, m.id), m.pos, tick, look);
|
|
}
|
|
}
|
|
// Forget what is gone.
|
|
if tick % 100 == 0 {
|
|
let alive: std::collections::HashSet<(u8, u32)> = sim
|
|
.drones
|
|
.iter()
|
|
.map(|d| (0, d.id))
|
|
.chain(sim.missiles.iter().map(|m| (1, m.id)))
|
|
.collect();
|
|
self.last.retain(|k, _| alive.contains(k));
|
|
}
|
|
self.trails.retain(|t| tick < t.seg.born as u64 + t.seg.life as u64);
|
|
if self.trails.len() > MAX_TRAIL {
|
|
let cut = self.trails.len() - MAX_TRAIL;
|
|
self.trails.drain(..cut);
|
|
}
|
|
}
|
|
|
|
/// Every segment alive at the simulation's tick: trails, fire columns, blast smoke.
|
|
pub fn segments(&self, sim: &Sim) -> Vec<SmokeSegment> {
|
|
let tick = sim.tick;
|
|
let mut out: Vec<SmokeSegment> = self.trails.iter().map(|t| t.seg.clone()).collect();
|
|
// A column over each fire: a puff every 15 ticks that rises, spreads and thins.
|
|
for f in &sim.fires.list {
|
|
if f.quiet && f.size < 1.0 {
|
|
continue;
|
|
}
|
|
fire_column(&mut out, f.id, f.at, f.start, f.size, f.shown, tick, &|k| f.level(k));
|
|
}
|
|
blast_billows(&mut out, &sim.blasts, tick);
|
|
out.truncate(MAX_TOTAL);
|
|
out
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::sim::{DroneKind, MissileKind};
|
|
|
|
#[test]
|
|
fn trails_follow_one_rule() {
|
|
assert!(missile_look(MissileKind::P1sun).is_none());
|
|
let rocket_look = missile_look(MissileKind::M77n6).unwrap();
|
|
let pantsir = missile_look(MissileKind::M57e6).unwrap();
|
|
assert!(pantsir.r1 < rocket_look.r1);
|
|
let mut s = seg(0, DVec3::ZERO, DVec3::ZERO, 0, 0);
|
|
for class in [Class::Drone, Class::Jet, Class::Cruise, Class::Supersonic, Class::Ballistic] {
|
|
style(&mut s, drone_look(class, 1.0));
|
|
assert_eq!(s.heat, 0.0);
|
|
}
|
|
for k in [MissileKind::Pac3, MissileKind::M57e6, MissileKind::M77n6, MissileKind::Amraam] {
|
|
style(&mut s, missile_look(k).unwrap());
|
|
assert_eq!(s.heat, 0.0);
|
|
}
|
|
let spec = DroneKind::Shahed.spec();
|
|
let shahed = drone_look(Class::Drone, spec.trail_scale());
|
|
assert!(shahed.density * 4.0 < rocket_look.density);
|
|
}
|
|
}
|