Files
airdef/airdef-mod/src/smoke.rs
T
chexit ccc3e20902
build / mod (push) Successful in 4m4s
release / windows (push) Successful in 23m13s
Playtest fixes: smoke by engine, searchlights, far launch sites that load their chunks, air defence balance, last chance and radar-cued shots, radars in smart raids, echo, Windows release build from a tag
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>
2026-10-30 23:44:14 +03:00

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);
}
}