//! The simulation: drones, interceptors, air-defence systems and effects, //! one step a game tick. It knows nothing of the game: what it needs from //! the world comes in (`ground`, the player's position), what it wants to //! happen goes out as [`Out`] (models, particles, sounds, commands). //! //! Every kind of drone, missile and air-defence system is a row of numbers //! (`DroneKind::spec`, `MissileKind::spec`, `SamKind::spec`) plus how its //! parts are drawn (`Sim::draw_sam`). use glam::{DQuat, DVec3}; use std::collections::HashMap; mod damage; pub mod fires; mod raid; pub mod sites; pub mod record; mod sight; mod terrain; mod ew; pub mod lights; mod logistics; mod orders; pub mod outcome; mod units; mod why; pub mod world; #[cfg(test)] mod regress; #[cfg(test)] mod sensor_tests; pub use ew::EW_RADIUS; pub use damage::{Comps, Part, PartState}; pub use sight::{Ident, Track}; use damage::dice; use outcome::{Impact, Outcome}; pub use units::{Family, SamKind}; use units::Muzzle; pub use raid::{Approach, Aim, RaidOrder}; pub const TAU: f64 = std::f64::consts::TAU; const HALF_PI: f64 = std::f64::consts::FRAC_PI_2; // ---------------------------------------------------------------- output /// A model to draw this tick (the mod turns these into `render.set-models`). pub struct Model { pub id: u32, pub name: &'static str, pub at: DVec3, pub rotation: DQuat, pub scale: f32, pub glow: bool, } pub struct Particle { pub kind: &'static str, pub at: DVec3, pub spread: [f32; 3], pub velocity: DVec3, pub speed: f32, pub count: u32, pub color: u32, pub scale: f32, } /// A blast the post-processing shader draws as a fireball, flash and shock. #[derive(Clone, Copy)] pub struct Blast { /// Never reused while the simulation lives: the HD light keeps its identity through the blast's life. pub id: u32, pub at: DVec3, /// Fireball radius in blocks. pub size: f32, /// The tick it went off. pub start: u64, pub life: u32, } #[derive(Default)] pub struct Out { pub models: Vec, pub particles: Vec, pub sounds: Vec<(&'static str, DVec3, f32, f32)>, pub commands: Vec, /// Camera jolt (strength, seconds), the strongest this tick. pub shake: Option<(f32, f32)>, pub messages: Vec, } impl Out { fn model(&mut self, id: u32, name: &'static str, at: DVec3, rotation: DQuat, scale: f64, glow: bool) { self.models.push(Model { id, name, at, rotation, scale: scale as f32, glow }); } fn particles(&mut self, kind: &'static str, at: DVec3, spread: f32, velocity: DVec3, speed: f32, count: u32) { self.particles.push(Particle { kind, at, spread: [spread; 3], velocity, speed, count, color: 0, scale: 1.0 }); } fn dust(&mut self, at: DVec3, color: u32, scale: f32, spread: f32, count: u32) { self.particles.push(Particle { kind: "minecraft:dust", at, spread: [spread; 3], velocity: DVec3::ZERO, speed: 0.0, count, color, scale, }); } pub fn sound(&mut self, event: &'static str, at: DVec3, volume: f32, pitch: f32) { self.sounds.push((event, at, volume, pitch)); } fn jolt(&mut self, strength: f32, seconds: f32) { if self.shake.is_none_or(|(s, _)| s < strength) { self.shake = Some((strength.min(1.0), seconds)); } } } // ---------------------------------------------------------------- maths /// A small xorshift: the simulation's own randomness. pub struct Rng(u64); impl Rng { pub fn new(seed: u64) -> Self { Self(seed | 1) } /// The generator's state: `new(state())` continues the same sequence. pub fn state(&self) -> u64 { self.0 } pub fn next(&mut self) -> u64 { self.0 ^= self.0 << 13; self.0 ^= self.0 >> 7; self.0 ^= self.0 << 17; self.0 } /// 0..1 pub fn f(&mut self) -> f64 { (self.next() >> 11) as f64 / (1u64 << 53) as f64 } /// -1..1 pub fn s(&mut self) -> f64 { self.f() * 2.0 - 1.0 } pub fn chance(&mut self, p: f64) -> bool { self.f() < p } } /// The models' noses point north (-z): the rotation that points one along /// `dir`, rolled by `roll` (radians, right wing down positive). pub fn look(dir: DVec3, roll: f64) -> DQuat { let d = dir.normalize_or(DVec3::NEG_Z); DQuat::from_rotation_y(heading_of(d)) * DQuat::from_rotation_x(d.y.clamp(-1.0, 1.0).asin()) * DQuat::from_rotation_z(-roll) } /// The yaw `look` gives a direction (0 = north, radians). pub fn heading_of(d: DVec3) -> f64 { (-d.x).atan2(-d.z) } /// `from` turned towards `to` by at most `max` radians. fn turn(from: DVec3, to: DVec3, max: f64) -> DVec3 { let a = from.normalize_or(DVec3::NEG_Z); let b = to.normalize_or(a); let angle = a.angle_between(b); if angle <= max || angle < 1e-9 { return b; } let axis = a.cross(b); let axis = if axis.length_squared() < 1e-12 { a.any_orthonormal_vector() } else { axis.normalize() }; DQuat::from_axis_angle(axis, max) * a } pub fn horizontal(v: DVec3) -> DVec3 { DVec3::new(v.x, 0.0, v.z) } /// Turns `from` towards `to` (angles, radians) by at most `rate`. fn slew(from: f64, to: f64, rate: f64) -> f64 { from + angle_diff(from, to).clamp(-rate, rate) } /// One axis of a drive: speeds up towards `err` (the angle still to turn), brakes in time to stop on it, /// and returns the turn this tick. Arriving takes the last bit at once, so there is no overshoot or jitter. fn drive(vel: &mut f64, err: f64, speed: f64, accel: f64) -> f64 { // The fastest it may go and still stop within what is left. let fastest = (2.0 * accel * err.abs()).sqrt().min(speed); let want = fastest.copysign(err); *vel = (*vel + (want - *vel).clamp(-accel, accel)).clamp(-speed, speed); if vel.abs() >= err.abs() && vel.signum() == err.signum() { *vel = 0.0; return err; } *vel } /// Moves one system's claim from the drone it had to the one it has, in a count of claims per drone. fn retarget(counts: &mut HashMap, from: Option, to: Option) { if from == to { return; } if let Some(n) = from.and_then(|id| counts.get_mut(&id)) { *n = n.saturating_sub(1); } if let Some(id) = to { *counts.entry(id).or_insert(0) += 1; } } fn angle_diff(from: f64, to: f64) -> f64 { (to - from + std::f64::consts::PI).rem_euclid(TAU) - std::f64::consts::PI } // ---------------------------------------------------------------- the world /// Ground heights the game answered (`render.ground`), by column. #[derive(Default)] pub struct Ground { known: HashMap<(i32, i32), (i32, u64)>, wanted: Vec<(i32, i32)>, asked: std::collections::HashSet<(i32, i32)>, /// The tick of the last full clean-up (a big cache would otherwise be scanned on every answer). swept: u64, /// The tick of the last answer round: what the heights' ages are measured against. now: u64, /// The blocks themselves (issues #42, #43): what stops a flight. pub world: world::WorldCache, } /// The `when` of a height reported changed: due to be asked again. const STALE: u64 = u64::MAX; /// Ticks after which a land height used by the flights' look-ahead is asked again (#64), changes told of or not. const HEIGHT_TTL: u64 = 1200; /// Ticks a flight waits for an unanswered section, and for a chunk the game doesn't have while low over the land (#61). pub const HOLD_PENDING: u16 = 45; pub const HOLD_NO_CHUNK: u16 = 240; /// Ticks between full clean-ups of a big ground cache. const SWEEP_EVERY: u64 = 200; impl Ground { /// One above the highest solid block, if known; the column is asked for /// when it isn't (or is old). pub fn at(&mut self, p: DVec3, now: u64) -> Option { let key = (p.x.floor() as i32, p.z.floor() as i32); match self.known.get(&key) { Some(&(y, when)) => { // Refreshes of known columns take at most a quarter of the questions: the rest // stay free for columns never seen, or those would starve. if (when == STALE || now.saturating_sub(when) > 200) && self.wanted.len() < 64 && self.asked.insert(key) { self.wanted.push(key); } (y > -64).then_some(f64::from(y)) } None => { if self.wanted.len() < 256 && self.asked.insert(key) { self.wanted.push(key); } None } } } pub fn answer(&mut self, answers: Vec<(i32, i32, i32)>, now: u64) { self.now = now; for (x, z, y) in answers { self.known.insert((x, z), (y, now)); } self.age_heights(); if self.known.len() > 300_000 && now.saturating_sub(self.swept) >= SWEEP_EVERY { self.swept = now; self.known.retain(|_, (_, when)| now.saturating_sub(*when) < 2000); } } /// Heights over blocks that changed (the block cache's notes of changes, chunk replacements and a new world) /// are marked old (`when` = STALE): still used, asked again within the budget. #64. fn age_heights(&mut self) { let all = std::mem::take(&mut self.world.changed_all); let cols = std::mem::take(&mut self.world.changed_cols); if !all && cols.is_empty() { return; } if all || cols.len() > 24 { for (&(x, z), v) in self.known.iter_mut() { if all || cols.contains(&(x >> 4, z >> 4)) { v.1 = STALE; } } } else { for &(sx, sz) in &cols { for x in sx * 16..sx * 16 + 16 { for z in sz * 16..sz * 16 + 16 { if let Some(v) = self.known.get_mut(&(x, z)) { v.1 = STALE; } } } // The lattice columns of the flights' look-ahead (see `terrain`). for x in (sx * 16..sx * 16 + 16).step_by(8) { for z in (sz * 16..sz * 16 + 16).step_by(8) { if let Some(v) = self.known.get_mut(&(x + 4, z + 4)) { v.1 = STALE; } } } } } } /// Whether a known height is due to be asked again: reported old, or too old to trust. fn due(&self, when: u64) -> bool { when == STALE || self.now.saturating_sub(when) > HEIGHT_TTL } pub fn take_wanted(&mut self) -> Vec<(i32, i32)> { self.asked.clear(); std::mem::take(&mut self.wanted) } /// The first solid block on the way from `a` to `b` (the real blocks, not the height of the land). pub fn sweep(&mut self, a: DVec3, b: DVec3, skip_start: bool, now: u64) -> world::Sweep { self.world.sweep(a, b, skip_start, now) } /// The step of flight `key` from `a` to `b`: the first solid block by the occupancy, then (#63) by the /// block's exact collision shape, which the game answers a tick or two later (see `WorldCache::narrow`). pub fn trace(&mut self, key: u32, a: DVec3, b: DVec3, skip_start: bool, now: u64) -> world::Sweep { let s = self.world.sweep(a, b, skip_start, now); self.world.narrow(key, s, a, b, now) } /// What to do with a step that ended in blocks the game hasn't told of (#61): `Some(t)` cuts the step at `t` /// of the way (it holds there, the section is asked for urgently), `None` lets it go on. Unread is never /// taken for clear: a section not answered yet holds a flight for up to `HOLD_PENDING` ticks, a chunk the /// game doesn't have holds one that is low over the land (or inside it) for `HOLD_NO_CHUNK`; after that, and /// high over the land, it goes on (the game has nothing there to hit) until a step is clear again. pub fn unread(&mut self, sweep: &world::Sweep, hold: &mut u16, low: bool) -> Option { match *sweep { world::Sweep::Unknown { t, pending } => { let limit = if pending { HOLD_PENDING } else if low { HOLD_NO_CHUNK } else { 0 }; if *hold < limit { *hold += 1; self.world.stats.held += 1; Some((t - 1e-6).max(0.0)) } else { self.world.stats.passed_unknown += 1; None } } world::Sweep::Clear => { *hold = 0; None } world::Sweep::Hit { .. } => None, // The exact shape is being asked for: the flight holds at the face (a bounded wait, see `narrow`). world::Sweep::Wait { t } => { self.world.stats.held += 1; Some((t - 1e-6).max(0.0)) } } } /// Has the sections along a flight read before it gets there. pub fn ahead(&mut self, pos: DVec3, vel: DVec3, ticks: f64, now: u64) { self.world.look_ahead(pos, vel, ticks, now); } /// Whether `pos` is in the land by the column's height (`below`, as `at` gave it), given what the sweep /// of this step saw: over blocks known and open, the height only counts where its top block is known /// to be open, a fluid's surface (not a roof or a bridge deck above the flight, nor a block unread). pub fn ground_hit(&mut self, below: Option, pos: DVec3, offset: f64, sweep: &world::Sweep, now: u64) -> bool { let Some(g) = below else { return false }; if pos.y > g + offset { return false; } match sweep { world::Sweep::Clear => self.world.is_known_open(pos.x.floor() as i32, g.floor() as i32 - 1, pos.z.floor() as i32, now), _ => true, } } /// Explosions change the land: forget what was known round them. fn forget(&mut self, at: DVec3, radius: i32) { let (cx, cz) = (at.x.floor() as i32, at.z.floor() as i32); self.known.retain(|&(x, z), _| (x - cx).abs() > radius || (z - cz).abs() > radius); self.world.touched(at, f64::from(radius) + 2.0); } } // ---------------------------------------------------------------- drones /// What kind of target it is, for air defence's roles. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Class { /// Slow propeller drones. Drone, /// Jet drones. Jet, /// Subsonic cruise missiles. Cruise, /// Supersonic and hypersonic missiles. Supersonic, /// Ballistic and aeroballistic missiles. Ballistic, } impl Class { pub const ALL: [Class; 5] = [Class::Drone, Class::Jet, Class::Cruise, Class::Supersonic, Class::Ballistic]; pub fn bit(self) -> u8 { 1 << self as u8 } pub fn key(self) -> &'static str { match self { Class::Drone => "drone", Class::Jet => "jet", Class::Cruise => "cruise", Class::Supersonic => "supersonic", Class::Ballistic => "ballistic", } } pub fn name(self) -> &'static str { match self { Class::Drone => "дроны", Class::Jet => "реакт. дроны", Class::Cruise => "крылатые", Class::Supersonic => "сверхзвуковые", Class::Ballistic => "баллистика", } } /// A short mark for the summary. pub fn letter(self) -> &'static str { match self { Class::Drone => "Д", Class::Jet => "Г", Class::Cruise => "К", Class::Supersonic => "С", Class::Ballistic => "Б", } } pub fn from_key(key: &str) -> Option { Self::ALL.into_iter().find(|c| c.key() == key) } } /// Roles as a mask of [`Class::bit`]s, from `drone,jet,...` or `all`. pub fn parse_roles(text: &str) -> Option { if text == "all" { return Some(0x1F); } let mut mask = 0; for word in text.split(',') { mask |= Class::from_key(word.trim())?.bit(); } Some(mask) } pub fn roles_text(mask: u8) -> String { Class::ALL.iter().filter(|c| mask & c.bit() != 0).map(|c| c.letter()).collect::>().join("") } /// Ballistic flight's gravity, blocks a tick². const GRAVITY: f64 = 0.05; #[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] pub enum DroneKind { Shahed, Geran3, Lancet, Gerbera, Kh101, Kalibr, Kh22, Zircon, Iskander, Kinzhal, /// An IRBM: its bus releases six blocks high over the target. Oreshnik, /// One of the Oreshnik's blocks: it splits into six glowing submunitions. OreshnikBlock, OreshnikSub, } /// What a drone carries on its tail. #[derive(Clone, Copy)] enum Engine { /// A pusher propeller this far behind the centre (model blocks), this big. Prop(f64, f64), /// A jet's flame at (up, back) model blocks, this big. Jet(f64, f64, f64), /// A rocket motor's long flame this far behind the centre, this big. Rocket(f64, f64), } pub struct DroneSpec { pub name: &'static str, pub key: &'static str, model: &'static str, speed: f64, turn: f64, /// Height over the target it cruises at. cruise: f64, /// Height it keeps over the land it flies over. clearance: f64, /// Horizontal distance from the target where it dives. dive: f64, /// Primed TNT on impact. warhead: u32, scale: f64, engine: Engine, /// No warhead: it only draws fire. pub decoy: bool, /// How hard it is to hit (1 normal). stealth: f64, /// Its sound: event, pitch, every n ticks. sound: (&'static str, f32, u32), pub class: Class, /// Ballistic: launched this far away on a raid, flying an arc. pub ballistic: Option, } impl DroneKind { pub const ALL: [DroneKind; 13] = [ DroneKind::Shahed, DroneKind::Geran3, DroneKind::Lancet, DroneKind::Gerbera, DroneKind::Kh101, DroneKind::Kalibr, DroneKind::Kh22, DroneKind::Zircon, DroneKind::Iskander, DroneKind::Kinzhal, DroneKind::Oreshnik, DroneKind::OreshnikBlock, DroneKind::OreshnikSub, ]; /// The sound of a launch from the ground: a light catapult shot, a rocket booster, a heavy ballistic launch. pub fn launch_sound(self) -> &'static str { match self { DroneKind::Shahed | DroneKind::Geran3 | DroneKind::Lancet | DroneKind::Gerbera => "airdef:launch.s400_eject", DroneKind::Kh101 | DroneKind::Kalibr | DroneKind::Kh22 => "airdef:launch.patriot", DroneKind::Zircon => "airdef:launch.s400", DroneKind::Iskander | DroneKind::Kinzhal | DroneKind::Oreshnik | DroneKind::OreshnikBlock | DroneKind::OreshnikSub => { "airdef:launch.s500" } } } pub fn spec(self) -> &'static DroneSpec { match self { DroneKind::Shahed => &DroneSpec { name: "Шахед-136", key: "shahed", model: "airdef:shahed", speed: 0.6, turn: 0.035, cruise: 45.0, clearance: 18.0, dive: 32.0, warhead: 3, scale: 1.3, engine: Engine::Prop(1.34, 1.0), decoy: false, stealth: 1.0, sound: ("airdef:drone.shahed", 1.0, 48), class: Class::Drone, ballistic: None, }, DroneKind::Geran3 => &DroneSpec { name: "Герань-3", key: "geran3", model: "airdef:geran3", speed: 1.6, turn: 0.03, cruise: 60.0, clearance: 18.0, dive: 50.0, warhead: 2, scale: 1.15, engine: Engine::Jet(0.34, 1.55, 1.0), decoy: false, stealth: 1.1, sound: ("airdef:drone.geran3", 1.0, 40), class: Class::Jet, ballistic: None, }, DroneKind::Lancet => &DroneSpec { name: "Ланцет-3", key: "lancet", model: "airdef:lancet", speed: 1.1, turn: 0.07, cruise: 25.0, clearance: 10.0, dive: 38.0, warhead: 1, scale: 0.9, engine: Engine::Prop(0.93, 0.55), decoy: false, stealth: 1.4, sound: ("airdef:drone.lancet", 1.0, 32), class: Class::Drone, ballistic: None, }, DroneKind::Gerbera => &DroneSpec { name: "Гербера", key: "gerbera", model: "airdef:gerbera", speed: 0.5, turn: 0.04, cruise: 40.0, clearance: 15.0, dive: 30.0, warhead: 0, scale: 1.1, engine: Engine::Prop(1.03, 0.9), decoy: true, stealth: 0.9, sound: ("airdef:drone.shahed", 1.3, 40), class: Class::Drone, ballistic: None, }, DroneKind::Kh101 => &DroneSpec { name: "Х-101", key: "kh101", model: "airdef:kh101", speed: 2.4, turn: 0.025, cruise: 0.0, clearance: 12.0, dive: 26.0, warhead: 6, scale: 1.6, engine: Engine::Jet(-0.27, 1.4, 0.6), decoy: false, stealth: 1.5, sound: ("airdef:drone.cruise", 1.0, 50), class: Class::Cruise, ballistic: None, }, DroneKind::Kalibr => &DroneSpec { name: "Калибр", key: "kalibr", model: "airdef:kalibr", speed: 2.1, turn: 0.025, cruise: 0.0, clearance: 15.0, dive: 26.0, warhead: 5, scale: 1.5, engine: Engine::Jet(-0.2, 1.5, 0.6), decoy: false, stealth: 1.3, sound: ("airdef:drone.cruise", 1.2, 50), class: Class::Cruise, ballistic: None, }, DroneKind::Kh22 => &DroneSpec { name: "Х-22", key: "kh22", model: "airdef:kh22", speed: 4.0, turn: 0.03, cruise: 140.0, clearance: 40.0, dive: 150.0, warhead: 7, scale: 1.8, engine: Engine::Rocket(1.5, 1.3), decoy: false, stealth: 1.4, sound: ("airdef:drone.rocket", 0.8, 46), class: Class::Supersonic, ballistic: None, }, DroneKind::Zircon => &DroneSpec { name: "Циркон", key: "zircon", model: "airdef:zircon", speed: 5.5, turn: 0.025, cruise: 90.0, clearance: 30.0, dive: 110.0, warhead: 6, scale: 1.4, engine: Engine::Rocket(1.5, 1.1), decoy: false, stealth: 2.0, sound: ("airdef:drone.rocket", 1.35, 46), class: Class::Supersonic, ballistic: None, }, DroneKind::Iskander => &DroneSpec { name: "Искандер-М", key: "iskander", model: "airdef:iskander", speed: 5.0, turn: 0.04, cruise: 0.0, clearance: 0.0, dive: 0.0, warhead: 8, scale: 1.8, engine: Engine::Rocket(1.5, 1.6), decoy: false, stealth: 1.6, sound: ("airdef:drone.rocket", 0.7, 46), class: Class::Ballistic, ballistic: Some(1000.0), }, DroneKind::Kinzhal => &DroneSpec { name: "Кинжал", key: "kinzhal", model: "airdef:kinzhal", speed: 7.5, turn: 0.03, cruise: 0.0, clearance: 0.0, dive: 0.0, warhead: 8, scale: 1.7, engine: Engine::Rocket(1.5, 1.5), decoy: false, stealth: 2.2, sound: ("airdef:drone.rocket", 0.6, 46), class: Class::Ballistic, ballistic: Some(1500.0), }, DroneKind::Oreshnik => &DroneSpec { name: "Орешник", key: "oreshnik", model: "airdef:iskander", speed: 9.0, turn: 0.0, cruise: 0.0, clearance: 0.0, dive: 0.0, warhead: 0, scale: 2.6, engine: Engine::Rocket(1.5, 2.4), decoy: false, stealth: 3.0, sound: ("airdef:drone.rocket", 0.5, 46), class: Class::Ballistic, ballistic: Some(2600.0), }, DroneKind::OreshnikBlock => &DroneSpec { name: "Орешник: блок", key: "oreshnik_block", model: "airdef:reentry", speed: 9.0, turn: 0.0, cruise: 0.0, clearance: 0.0, dive: 0.0, warhead: 4, scale: 1.6, engine: Engine::Rocket(1.0, 1.0), decoy: false, stealth: 3.5, sound: ("airdef:drone.ballistic", 0.8, 9999), class: Class::Ballistic, ballistic: None, }, DroneKind::OreshnikSub => &DroneSpec { name: "Орешник: суббоеприпас", key: "oreshnik_sub", model: "airdef:reentry", speed: 9.0, turn: 0.05, cruise: 0.0, clearance: 0.0, dive: 0.0, warhead: 3, scale: 1.1, engine: Engine::Rocket(1.0, 1.0), decoy: false, stealth: 4.0, sound: ("airdef:drone.ballistic", 1.0, 9999), class: Class::Ballistic, ballistic: None, }, } } pub fn from_key(key: &str) -> Option { Self::ALL.into_iter().find(|k| k.spec().key == key) } /// Its radar cross-section, a Shahed's 0.3: radars see it from as far as its fourth root allows. pub fn rcs(self) -> f64 { match self { DroneKind::Shahed => 0.3, DroneKind::Geran3 => 0.4, DroneKind::Lancet => 0.08, // Corner reflectors make the decoy look as big as a Shahed. DroneKind::Gerbera => 0.4, DroneKind::Kh101 => 0.05, DroneKind::Kalibr => 0.15, DroneKind::Kh22 => 1.0, DroneKind::Zircon => 0.4, DroneKind::Iskander => 0.6, DroneKind::Kinzhal => 0.5, DroneKind::Oreshnik => 2.0, DroneKind::OreshnikBlock => 0.4, DroneKind::OreshnikSub => 0.08, } } } /// Height over the target where the Oreshnik's blocks release their submunitions. const BLOCK_SPLIT: f64 = 300.0; #[derive(PartialEq, Clone, Copy)] enum Flight { Cruise, Dive, /// An arc under gravity; the motor burns for the first ticks. Ballistic, /// Shot down: spinning down, burning. Falling, } #[derive(Clone)] pub struct Drone { pub id: u32, pub kind: DroneKind, pub pos: DVec3, pub vel: DVec3, target: DVec3, flight: Flight, roll: f64, spin: f64, age: u32, /// Interceptors on their way to it. pub engaged: u32, /// Points it flies by on its way (a planned raid), the next first. route: Vec, /// Hugs the land all the way, under the radars. low: bool, /// Its navigation is being jammed. pub jammed: bool, /// The approach sector it came from (the enemy keeps score). sector: u8, /// Sent by the smart enemy: it may be turned on systems that give themselves away. smart: bool, /// What the land ahead asks of its flight (see `terrain`). nav: Nav, /// Its navigation error (#65): `target` is the nominal aim plus this, kept apart so a new aim (a retarget) /// keeps the error this airframe was born with instead of becoming exact. err: DVec3, /// Ticks held at the edge of blocks the game has not told of (#61); reset by a clear step. hold: u16, } /// A drone's terrain following: refreshed every few ticks, used every one. #[derive(Clone, Copy, Debug)] struct Nav { /// The height it must be at now to clear the land ahead (unknown: -inf). need: f64, /// The height from which a straight dive on its target clears the land (unknown: -inf). dive: f64, } impl Default for Nav { fn default() -> Self { Nav { need: f64::NEG_INFINITY, dive: f64::NEG_INFINITY } } } impl Drone { pub fn alive(&self) -> bool { self.flight != Flight::Falling } /// How much a system wants to shoot it: fast and heavy first, decoys look real. fn threat(&self, from: DVec3) -> f64 { self.threat_at(from, self.pos) } /// The same for a target believed to be at `at` (a track's forecast). fn threat_at(&self, from: DVec3, at: DVec3) -> f64 { let s = self.kind.spec(); let warhead = if s.decoy { 3.0 } else { s.warhead as f64 }; (1.0 + warhead) * (1.0 + s.speed) / (1.0 + at.distance(from) * 0.02) } } // ---------------------------------------------------------------- interceptors #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum MissileKind { Pac3, M57e6, M48n6, M77n6, IrisT, Amraam, M9m317, M9m338, /// Not a missile: an interceptor drone, slow and cheap, that rams Shaheds. P1sun, } struct MissileSpec { model: &'static str, speed: f64, accel: f64, turn: f64, /// Ticks of straight flight before guidance. boost: u32, /// Thrown out of the tube by gas first; the motor lights this many ticks later. cold: u32, pk: f64, fuse: f64, life: u32, scale: f64, flame: f64, /// The nozzle, model units behind the centre (z). rear: f64, } impl MissileKind { /// The sound of its launch. pub fn launch_sound(self) -> &'static str { match self { MissileKind::Pac3 => "airdef:launch.patriot", MissileKind::M57e6 => "airdef:launch.pantsir", MissileKind::M48n6 => "airdef:launch.s400_eject", MissileKind::M77n6 => "airdef:launch.s500_eject", MissileKind::IrisT | MissileKind::M9m338 => "airdef:launch.s400_eject", MissileKind::Amraam | MissileKind::M9m317 => "airdef:launch.patriot", MissileKind::P1sun => "airdef:drone.lancet", } } fn spec(self) -> &'static MissileSpec { match self { MissileKind::Pac3 => &MissileSpec { model: "airdef:pac3", speed: 5.5, accel: 0.35, turn: 0.3, boost: 8, cold: 0, pk: 0.9, fuse: 4.0, life: 240, scale: 1.0, flame: 1.0, rear: 28.0, }, MissileKind::M57e6 => &MissileSpec { model: "airdef:m57e6", speed: 5.0, accel: 0.5, turn: 0.32, boost: 5, cold: 0, pk: 0.82, fuse: 3.0, life: 160, scale: 0.85, flame: 0.65, rear: 28.0, }, MissileKind::M48n6 => &MissileSpec { model: "airdef:m48n6", speed: 7.0, accel: 0.5, turn: 0.22, boost: 8, cold: 5, pk: 0.9, fuse: 5.0, life: 300, scale: 1.5, flame: 1.4, rear: 30.0, }, MissileKind::M77n6 => &MissileSpec { model: "airdef:m77n6", speed: 9.0, accel: 0.6, turn: 0.22, boost: 8, cold: 5, pk: 0.95, fuse: 6.0, life: 320, scale: 1.7, flame: 1.6, rear: 32.0, }, MissileKind::IrisT => &MissileSpec { model: "airdef:irist_missile", speed: 7.0, accel: 0.5, turn: 0.35, boost: 6, cold: 4, pk: 0.9, fuse: 4.0, life: 260, scale: 0.95, flame: 0.9, rear: 28.0, }, MissileKind::Amraam => &MissileSpec { model: "airdef:amraam_missile", speed: 6.0, accel: 0.45, turn: 0.3, boost: 6, cold: 0, pk: 0.85, fuse: 4.0, life: 260, scale: 0.9, flame: 0.9, rear: 28.0, }, MissileKind::M9m317 => &MissileSpec { model: "airdef:m48n6", speed: 6.5, accel: 0.5, turn: 0.25, boost: 8, cold: 0, pk: 0.85, fuse: 5.0, life: 300, scale: 1.1, flame: 1.2, rear: 32.0, }, MissileKind::M9m338 => &MissileSpec { model: "airdef:m57e6", speed: 5.5, accel: 0.6, turn: 0.4, boost: 4, cold: 4, pk: 0.9, fuse: 3.0, life: 160, scale: 1.0, flame: 0.8, rear: 28.0, }, MissileKind::P1sun => &MissileSpec { model: "airdef:p1sun", speed: 2.8, accel: 0.12, turn: 0.3, boost: 4, cold: 0, pk: 0.8, fuse: 3.0, life: 900, scale: 0.8, // Electric: no flame. flame: 0.0, rear: 10.0, }, } } } pub struct Missile { pub id: u32, pub kind: MissileKind, pub pos: DVec3, vel: DVec3, target: u32, /// The system that fired it. sam: u32, age: u32, /// Ticks held at the edge of blocks the game has not told of (#61). hold: u16, /// What it knows of its target (#66): the last fix, from the firing system's track (the data link) /// or from its own seeker. It steers on this, never on the target's true position. fix: Option, /// The tick its seeker last held the target (0: not yet). seen: u64, } /// A missile's knowledge of its target: where it was and how it went when measured, and when. #[derive(Clone, Copy, Debug)] pub struct Fix { pub pos: DVec3, pub vel: DVec3, pub tick: u64, /// Measured by its own seeker (else by the firing system's radar, passed on by the link). pub seeker: bool, } impl Fix { /// Where the target should be by now, carrying the measurement on. pub fn est(&self, now: u64) -> DVec3 { self.pos + self.vel * now.saturating_sub(self.tick).min(MAX_COAST) as f64 } } /// Ticks a fix is carried on by its velocity; a missile with nothing newer than this has lost its target. pub const MAX_COAST: u64 = 240; /// Ticks the seeker keeps a target it lost sight of (it looks again round the last place). const REACQUIRE: u64 = 12; impl MissileKind { /// How far its own seeker holds a target of average size (blocks); 0: command guidance only. pub fn seeker(self) -> f64 { match self { MissileKind::M57e6 => 0.0, MissileKind::Pac3 => 110.0, MissileKind::M48n6 | MissileKind::M77n6 => 120.0, MissileKind::IrisT | MissileKind::M9m317 => 90.0, MissileKind::Amraam => 130.0, MissileKind::M9m338 => 70.0, MissileKind::P1sun => 60.0, } } } // ---------------------------------------------------------------- air defence /// What a system is doing, for the monitor. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum SamState { Search, Track, Slew, Lock, Launch, Fire, Reload, Hold, Destroyed, /// A move order: packing up, on the march, deploying again. Packing, Moving, Deploying, /// Out of missiles, waiting for a reload truck (logistics on: a depot stands). Waiting, } impl SamState { pub fn name(self) -> &'static str { match self { SamState::Search => "ПОИСК", SamState::Track => "СОПРОВОЖДЕНИЕ", SamState::Slew => "ДОВОРОТ", SamState::Lock => "ЗАХВАТ", SamState::Launch => "ПУСК", SamState::Fire => "ОГОНЬ", SamState::Reload => "ПЕРЕЗАРЯДКА", SamState::Hold => "ОГОНЬ ЗАПРЕЩЁН", SamState::Destroyed => "УНИЧТОЖЕН", SamState::Packing => "СВЁРТЫВАНИЕ", SamState::Moving => "НА МАРШЕ", SamState::Deploying => "РАЗВЁРТЫВАНИЕ", SamState::Waiting => "ЖДЁТ БК", } } pub fn color(self) -> u32 { match self { SamState::Search => 0x60FF80, SamState::Track => 0xFFE060, SamState::Slew => 0xFFB040, SamState::Lock => 0xFF5040, SamState::Launch | SamState::Fire => 0xFFFFFF, SamState::Reload => 0x80A0FF, SamState::Hold => 0xA0A0A0, SamState::Destroyed => 0xFF2020, SamState::Packing | SamState::Moving | SamState::Deploying => 0xC0A0FF, SamState::Waiting => 0xFF9040, } } } pub struct Sam { pub state: SamState, /// Ticks the monitor keeps showing a launch. launched: u32, pub id: u32, pub kind: SamKind, /// The vehicle's centre on the ground. pub pos: DVec3, /// Where its nose points (radians, as `look`'s yaw). pub heading: f64, yaw: f64, pitch: f64, /// How fast the mount turns now (radians a tick); not saved, a loaded system starts at rest. yaw_vel: f64, pitch_vel: f64, pub missiles: u32, pub bursts: u32, cooldown: u32, gun_cooldown: u32, pub reloading: u32, /// What its missiles are after, and what its guns track. pub target: Option, pub gun_target: Option, dish: f64, recoil: f64, flash: u32, pub kills: u32, pub fired: u32, /// The classes of target it engages (`Class::bit`s). pub roles: u8, /// 1 whole, 0 destroyed: a damaged system turns slower, sees less and hits worse. pub health: f64, /// Ticks its wreck still burns. burn: u32, /// Its working parts (the hull is `health`). pub comps: Comps, /// Ticks its gun stays jammed. jam: u32, /// Where its radar beam points (radians), and the tracks it holds: its own radar's and the network's. pub(super) beam: f64, pub tracks: Vec, /// The ids of the tracks it holds this tick: its own radar's and the network's. pub seen: Vec, /// The drones its own radar (or optics) holds: guns need these. pub own: Vec, /// What it just shot down, where, and till when its camera stays on it. pub kill_view: Option<(u32, DVec3, u64)>, /// A move order's destination, and ticks left packing up or deploying. pub dest: Option, march: u32, /// A depot's reloads in stock; a reload truck's on board. pub cargo: u32, /// A reload truck's job: the system it is driving to, or a depot. job: Option, /// Out of ammunition, waiting for a reload (logistics on). pub empty: bool, /// An EW station: jamming, and its transmitter's heat (0 cold, 1 forced off). pub ew_on: bool, pub heat: f64, /// Overheated: off until it cools. pub ew_lock: bool, /// Ticks till a gun fires its next illumination flare. flare_cd: u32, /// A searchlight: lit up (night), how far its beam runs free along the axis (the land stops it), /// and the tick that was last cast. beam_on: bool, beam_free: f64, beam_cast: u64, /// Shots the land ruled out, by target: when it was judged and why (`terrain::MASKED`, `terrain::PATH_BLOCKED`). pub(super) terrain: HashMap, /// Targets whose land hasn't been answered yet, and since when it has been waited for. waits: HashMap, } impl Sam { pub fn new(id: u32, kind: SamKind, pos: DVec3, heading: f64) -> Self { let spec = kind.spec(); Self { id, kind, pos, heading, yaw: heading, pitch: spec.rest_pitch, yaw_vel: 0.0, pitch_vel: 0.0, missiles: spec.missile.as_ref().map_or(0, |m| m.magazine), bursts: spec.gun.as_ref().map_or(0, |g| g.bursts), cooldown: 40, gun_cooldown: 0, reloading: 0, target: None, gun_target: None, dish: 0.0, recoil: 0.0, flash: 0, kills: 0, fired: 0, roles: spec.roles, state: SamState::Search, launched: 0, health: 1.0, burn: 0, comps: Comps::default(), jam: 0, beam: id as f64 * 2.4, tracks: Vec::new(), seen: Vec::new(), own: Vec::new(), kill_view: None, dest: None, march: 0, cargo: match kind { SamKind::Depot => 30, SamKind::Tzm => 2, _ => 0, }, job: None, empty: false, ew_on: false, heat: 0.0, ew_lock: false, flare_cd: 0, beam_on: false, beam_free: 0.0, beam_cast: 0, terrain: HashMap::new(), waits: HashMap::new(), } } pub fn alive(&self) -> bool { self.health > 0.0 } /// Where its radar beam points now (radians clockwise from north). pub fn beam(&self) -> f64 { self.beam } /// How well it still works: turning, aiming, its radar's reach. /// Turns the mount towards `yaw` (and `pitch`, if it elevates) under its drive's limits; a damaged /// system has the less of both speed and acceleration. fn aim_towards(&mut self, yaw: f64, pitch: Option) { let d = self.kind.spec().drive(); let fit = self.drive_fit(); self.yaw += drive(&mut self.yaw_vel, angle_diff(self.yaw, yaw), d.yaw.0 * fit, d.yaw.1 * fit); if let Some(pitch) = pitch { self.pitch += drive(&mut self.pitch_vel, pitch - self.pitch, d.pitch.0 * fit, d.pitch.1 * fit); } } /// The mount stops where it is (a march, a deployment: the vehicle drives it directly). pub(super) fn halt_mount(&mut self) { self.yaw_vel = 0.0; self.pitch_vel = 0.0; } pub fn status(&self) -> String { let spec = self.kind.spec(); let mut text = spec.name.to_string(); if !self.alive() { return text + " УНИЧТОЖЕН"; } if self.moving() { return text + " " + &self.state.name().to_lowercase(); } match self.kind { SamKind::Depot => return format!("{text}: запас {} перезарядок", self.cargo), SamKind::Tzm => return format!("{text}: на борту {}/2{}", self.cargo, if self.job.is_some() { ", в пути" } else { "" }), SamKind::Ew => { let state = if self.ew_on { "ГЛУШИТ" } else if self.ew_lock { "остывает" } else { "готова" }; return format!("{text}: {state}, нагрев {:.0}%", self.heat * 100.0); } SamKind::Radar => { let broken = if self.comps.whole() { String::new() } else { format!(" [{}]", self.comps.worst(self.kind)) }; return format!("{text}: целей {}{broken}", self.own.len()); } SamKind::Light => return format!("{text}{}", if self.target.is_some() { ": в луче цель" } else { "" }), _ => {} } if self.empty && self.missiles == 0 { text += " ЖДЁТ БК"; } if self.health < 1.0 { text += &format!(" ({:.0}%)", self.health * 100.0); } if !self.comps.whole() { text += &format!(" [{}]", self.comps.worst(self.kind)); } if self.reloading > 0 { text += &format!(" перезарядка {}с", self.reloading.div_ceil(20)); } else { if let Some(m) = &spec.missile { text += &format!(" ракеты {}/{}", self.missiles, m.magazine); } if spec.gun.is_some() { text += &format!(" снаряды {}", self.bursts); } } text } } // ---------------------------------------------------------------- effects enum Fx { /// A glowing fireball growing then shrinking (when the shader can't draw it). Fireball { pos: DVec3, size: f64, age: u32, life: u32, spin: DQuat }, /// A falling piece of a drone. Debris { pos: DVec3, vel: DVec3, rot: DQuat, spin: DQuat, age: u32 }, /// A piece thrown off by a breakup or an air detonation: its own drag and life. Piece { pos: DVec3, vel: DVec3, rot: DQuat, piece: outcome::Piece, age: u32 }, /// A glowing red tracer round. Tracer { pos: DVec3, vel: DVec3, life: u32 }, } // ---------------------------------------------------------------- the simulation pub struct Sim { /// Roles set for a kind of system (`/airdef roles`), new ones included. pub roles: HashMap, pub drones: Vec, pub missiles: Vec, pub sams: Vec, pub targets: Vec, fx: Vec<(u32, Fx)>, /// Blasts for the post-processing shader. pub blasts: Vec, /// The next blast's id (separate from `next`, so the other units' ids stay as they were). blast_seq: u32, next: u32, /// The combat randomness: what is rolled for aim, hits, damage and raids. Drawing, sound and camera never /// spend it (#85). pub rng: Rng, /// The decorative randomness (flicker, pitch, spin, scatter of tracers and debris), and the ids of what is /// only drawn: neither moves with the graphics settings of the combat. vis: Rng, fx_next: u32, pub tick: u64, /// The game's tick number (the one `tick` is called with), for a recording to know the world's time by. pub engine_tick: u64, pub kills: u32, pub hits: u32, /// Drones that struck the land or a building short of their aim. pub crashes: u32, pub launched: u32, /// Explosions damage the land (primed TNT through commands). pub damage: bool, /// The seed: what falls to chance without the sequence (scan hits, damage to parts) is rolled from it. seed: u64, /// The air picture shows the true positions beside the tracks (a sandbox and analysis aid; changes no behaviour). pub truth: bool, /// The shader draws fireballs: no glowing cubes. pub shader: bool, /// Air defence holds fire. pub hold: bool, /// What the land hides round each system's radar, by system id. horizons: HashMap, /// Blasts to come: (tick, where, TNT, small) — a magazine cooking off. later: Vec<(u64, DVec3, u32, bool)>, /// A planned raid's drones waiting for their launch tick. pub queue: Vec<(u64, Drone)>, /// The raid's launch sites and their jobs (#100). pub sites: sites::Sites, /// A raid being planned. plan: Option, /// Each marked object's health, 0 to 100 (`targets`' order). pub target_health: Vec, /// The raid in progress, for its report. wave: Option, /// The raid being recorded, the last ones recorded, and how many have been. rec: Option, pub replays: Vec, recordings: u32, /// What went wrong for the air defence, counted for the raid report and the recordings. pub tally: record::Tally, /// What the enemy has learned. intel: ew::Intel, /// Night: guns need light (the mod sets it from the game's clock). pub night: bool, /// The long fires at crash and impact points (#52). pub fires: fires::Fires, /// The world clock's time of day in ticks (the mod sets it; recordings keep it). pub day_time: Option, /// Illumination flares in the sky, and the drones lit this tick. pub flares: Vec, pub lit: Vec, /// The renderer draws the searchlights' spot lights and beams (HD); else the beam models do. pub spot_hd: bool, } /// Ids: objects get multiples of 16, their parts the low bits. const PARTS: u32 = 16; /// Ids of what is only drawn (debris, fireball models, tracers) come from their own range and counter, so that /// the number of effects never moves the ids of the combat's objects (#85). const FX_IDS: u32 = 0x2000_0000; /// The next id for a drawn-only thing. fn fx_ident(counter: &mut u32) -> u32 { *counter = (*counter + 1) % 0x0100_0000; FX_IDS + *counter * PARTS } impl Sim { pub fn new(seed: u64) -> Self { Self { roles: HashMap::new(), drones: Vec::new(), missiles: Vec::new(), sams: Vec::new(), targets: Vec::new(), fx: Vec::new(), blasts: Vec::new(), blast_seq: 0, next: 1, rng: Rng::new(seed), vis: Rng::new(seed ^ 0x5DEE_CE66_D1CE_4E5B), fx_next: 0, tick: 0, engine_tick: 0, kills: 0, hits: 0, crashes: 0, launched: 0, damage: true, seed: seed.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1, truth: false, shader: false, hold: false, horizons: HashMap::new(), later: Vec::new(), queue: Vec::new(), sites: sites::Sites::default(), plan: None, target_health: Vec::new(), wave: None, rec: None, replays: Vec::new(), recordings: 0, tally: record::Tally::default(), intel: ew::Intel::default(), night: false, fires: fires::Fires::default(), day_time: None, flares: Vec::new(), lit: Vec::new(), spot_hd: false, } } /// A scenario's start: the combat generator, the dice of stateless rolls and the decorative generator all /// come from `state` alone, whatever was played before. The clock and the id counter go on (what is in the /// world keeps its ids), so a restart repeats the draws, not the whole fight. pub fn reseed(&mut self, state: u64) { self.rng = Rng::new(state); self.vis = Rng::new(state ^ 0x5DEE_CE66_D1CE_4E5B); self.seed = state.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1; } pub fn id(&mut self) -> u32 { self.next += 1; self.next * PARTS } /// What a system's camera follows: its guns' or missiles' target, else what its missiles in /// flight are after. pub fn watched(&self, sam: &Sam) -> Option<&Drone> { // A kill stays in view a few seconds: the wreck falling, before the next target. if let Some((id, _, until)) = sam.kill_view && self.tick < until { return self.drones.iter().find(|d| d.id == id); } let id = sam .gun_target .or(sam.target) .or_else(|| self.missiles.iter().find(|m| m.sam == sam.id).map(|m| m.target))?; self.drones.iter().find(|d| d.id == id) } /// Where a system's camera looks: what it watches, or where its last kill was while that /// stays in view (the wreck may be gone). pub fn focus(&self, sam: &Sam) -> Option { if let Some((_, at, until)) = sam.kill_view && self.tick < until { return Some(self.watched(sam).map_or(at, |d| d.pos)); } self.watched(sam).map(|d| d.pos) } /// Whether a system's camera is showing its kill. pub fn showing_kill(&self, sam: &Sam) -> bool { sam.kill_view.is_some_and(|(_, _, until)| self.tick < until) } pub fn nearest_target(&self, to: DVec3) -> Option { self.targets.iter().copied().min_by(|a, b| a.distance_squared(to).total_cmp(&b.distance_squared(to))) } fn add_drone(&mut self, kind: DroneKind, from: DVec3, dir: DVec3, target: DVec3) { // Its navigation error is drawn in `make_drone`, the one path of manual and raid launches (#65). let d = self.make_drone(kind, from, dir, target); self.drones.push(d); self.launched += 1; } /// Launches a drone from `from`, heading `dir`, for `target`. pub fn launch(&mut self, kind: DroneKind, from: DVec3, dir: DVec3, target: DVec3, out: &mut Out) { let dir = DVec3::new(dir.x, dir.y.max(0.35), dir.z).normalize_or(DVec3::Y); let from = if kind.spec().ballistic.is_some() { from + DVec3::Y * 2.0 } else { from }; self.add_drone(kind, from, dir, target); out.sound(kind.launch_sound(), from, 1.0, 1.3); out.particles("minecraft:large_smoke", from, 0.6, DVec3::ZERO, 0.05, 30); out.particles("minecraft:flame", from, 0.3, -dir * 0.2, 0.05, 12); } /// A raid: `count` drones from random bearings `dist` blocks out. pub fn raid(&mut self, kind: DroneKind, target: DVec3, count: u32, dist: f64, out: &mut Out) { let spec = kind.spec(); // A raid comes from one sector, spread over ±40°. let centre = self.rng.f() * TAU; for _ in 0..count { let a = centre + self.rng.s() * 0.7; let d = spec.ballistic.map_or(dist, |far| far.max(dist)) + self.rng.f() * 60.0; let height = if spec.ballistic.is_some() { 10.0 } else if spec.cruise > 0.0 { spec.cruise } else { 25.0 }; let from = target + DVec3::new(a.sin() * d, height + self.rng.s() * 8.0, a.cos() * d); let aim = target + DVec3::new(self.rng.s() * 5.0, 0.0, self.rng.s() * 5.0); self.add_drone(kind, from, horizontal(target - from), aim); } out.messages.push(format!("ВОЗДУШНАЯ ТРЕВОГА: {count} × {}", spec.name)); out.sound("minecraft:event.raid.horn", target, 64.0, 1.0); } pub fn clear(&mut self) { self.drones.clear(); self.missiles.clear(); self.fx.clear(); self.blasts.clear(); self.queue.clear(); self.sites.clear(); self.plan = None; self.wave = None; // Ammunition still to cook off, flares in the sky. self.later.clear(); self.flares.clear(); self.lit.clear(); self.fires.extinguish(); } /// One game tick. pub fn step(&mut self, ground: &mut Ground, player: Option, out: &mut Out) { self.tick += 1; self.plan_step(Some(&mut *ground), out); self.adapt(); self.release_queue(out); self.step_drones(ground, player, out); self.step_later(ground, player, out); self.step_moves(ground, out); self.step_supply(out); self.step_ew(out); self.update_sight(ground); self.step_lights(ground, out); self.step_sams(ground, out); self.step_missiles(ground, out); self.step_fx(ground, out); self.draw_targets(out); self.draw_sites(out); self.step_wrecks(out); self.step_fires(ground, out); self.report_wave(out); self.record(); let now = self.tick; self.blasts.retain(|b| now - b.start < b.life as u64); } // ------------------------------------------------ drones fn step_drones(&mut self, ground: &mut Ground, player: Option, out: &mut Out) { let now = self.tick; let mut boom: Vec<(DVec3, u32, bool)> = Vec::new(); let mut removed: Vec = Vec::new(); // Oreshnik pieces released this tick: kind, at, velocity, target, the parent's sector (what they teach the enemy). let mut released: Vec<(DroneKind, DVec3, DVec3, DVec3, u8)> = Vec::new(); // Warheads that reached their aim (for the replays): drone, kind, where, TNT; and splits. let mut impacts: Vec<(u32, DroneKind, DVec3, u32)> = Vec::new(); let mut splits: Vec<(u32, DVec3)> = Vec::new(); // Drones that struck the land or a building short of their aim: drone, kind, where, TNT. let mut crashes: Vec<(u32, DroneKind, DVec3, u32)> = Vec::new(); for d in &mut self.drones { d.age += 1; let spec = d.kind.spec(); let below = ground.at(d.pos, now); let from = d.pos; ground.ahead(d.pos, d.vel, (120.0 / d.vel.length().max(0.05)).max(20.0), now); // Near its aim it looks at the land there again: a platform or hilltop blown away // meanwhile, it goes for what is left below, not for the air where it stood. if d.flight != Flight::Falling && horizontal(d.target - d.pos).length() < 250.0 && let Some(g) = ground.at(d.target, now) && g < d.target.y - 1.5 { d.target.y = g; } if d.flight == Flight::Falling { d.vel.y -= 0.05; d.vel *= 0.985; d.spin += 0.35; d.pos += d.vel; let sweep = ground.trace(d.id, from, d.pos, d.age <= 3, now); if let Some((_, p)) = sweep.hit() { d.pos = p; } else if let Some(t) = ground.unread(&sweep, &mut d.hold, below.is_some_and(|g| from.y < g + 12.0)) { d.pos = from.lerp(d.pos, t); } if self.vis.chance(0.5) { out.particles("minecraft:large_smoke", d.pos, 0.2, DVec3::new(0.0, 0.05, 0.0), 0.02, 1); } out.particles("minecraft:flame", d.pos, 0.25, DVec3::ZERO, 0.02, 2); if sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.3, &sweep, now) || d.age > 600 { // The wreck's warhead may still go off where it lands. let warhead = if spec.decoy || !self.rng.chance(0.35) { 0 } else { spec.warhead.min(2) }; boom.push((d.pos, warhead, true)); removed.push(d.id); } continue; } if d.flight == Flight::Ballistic { // The motor burns for 3 s; then the arc, and the warhead steers in at the end. let burning = d.age < 60; match d.kind { // A block glides to its point high over the target; its submunitions fall from there // nearly straight down, braked by the air: a rain of fire for seconds. DroneKind::OreshnikBlock => d.vel = (d.target + DVec3::Y * BLOCK_SPLIT - d.pos).normalize_or(DVec3::NEG_Y) * 6.5, DroneKind::OreshnikSub => d.vel = (d.target - d.pos).normalize_or(DVec3::NEG_Y) * 3.2, _ => d.vel.y -= GRAVITY, } if d.vel.y < 0.0 && d.pos.distance(d.target) < 260.0 { let sp = d.vel.length(); d.vel = turn(d.vel, d.target - d.pos, spec.turn) * sp; } d.pos += d.vel; let sweep = ground.trace(d.id, from, d.pos, d.age <= 3, now); if let Some((_, p)) = sweep.hit() { d.pos = p; } else if let Some(t) = ground.unread(&sweep, &mut d.hold, below.is_some_and(|g| from.y < g + 12.0)) { d.pos = from.lerp(d.pos, t); } let speed = d.vel.length(); let landed = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now); let hit = d.pos.distance(d.target) < speed.max(2.0) || landed || (d.vel.y < 0.0 && d.pos.y < d.target.y - 1.0 && horizontal(d.target - d.pos).length() < speed * 2.0) || d.age > 4000; if hit { boom.push((d.pos, spec.warhead, false)); // Into the land or a building short of its aim: a crash, not a hit. if landed && d.pos.distance(d.target) >= speed.max(2.0) + 6.0 { crashes.push((d.id, d.kind, d.pos, spec.warhead)); removed.push(d.id); continue; } impacts.push((d.id, d.kind, d.pos, spec.warhead)); removed.push(d.id); self.hits += 1; continue; } // The bus releases its six blocks over the target, each block its six submunitions lower down. let split = match d.kind { DroneKind::Oreshnik => (d.vel.y < 0.0 && horizontal(d.target - d.pos).length() < 900.0) .then_some((DroneKind::OreshnikBlock, 45.0)), DroneKind::OreshnikBlock => (d.pos.distance(d.target + DVec3::Y * BLOCK_SPLIT) < 8.0) .then_some((DroneKind::OreshnikSub, 9.0)), _ => None, }; if let Some((piece, spread)) = split { let turn0 = self.rng.f() * TAU; for i in 0..6 { let a = turn0 + i as f64 * TAU / 6.0; let r = spread * (0.6 + 0.4 * self.rng.f()); let aim = d.target + DVec3::new(a.sin() * r, 0.0, a.cos() * r); // Straight down at the aim point, at the bus's speed. let v = (aim - d.pos).normalize_or(DVec3::NEG_Y) * 3.0; released.push((piece, d.pos + v.normalize_or_zero() * (i as f64 * 0.3), v, aim, d.sector)); } out.sound("airdef:boom.sonic", d.pos, 1.0, 0.7); splits.push((d.id, d.pos)); // Its pieces count as launched instead of it. self.launched += 5; out.particles("minecraft:firework", d.pos, 1.5, DVec3::ZERO, 0.3, 40); if d.kind == DroneKind::OreshnikBlock && self.rng.chance(0.5) { out.sound("airdef:drone.ballistic", d.pos, 1.0, 1.1); } removed.push(d.id); continue; } let dir = d.vel.normalize_or_zero(); if d.kind == DroneKind::OreshnikSub || d.kind == DroneKind::OreshnikBlock { out.particles("minecraft:flame", d.pos - dir * 1.5, 0.1, DVec3::ZERO, 0.0, 1); out.particles("minecraft:white_smoke", d.pos - dir * 3.0, 0.15, DVec3::ZERO, 0.0, 1); } else if burning { out.particles("minecraft:flame", d.pos - dir * 2.5 * spec.scale, 0.15, -dir * 0.2, 0.02, 3); out.particles("minecraft:cloud", d.pos - dir * 3.5 * spec.scale, 0.4, DVec3::ZERO, 0.01, 3); } else if d.vel.y < -2.0 { // Re-entry: the nose glows, a thin trail. out.particles("minecraft:small_flame", d.pos + dir * spec.scale, 0.1, DVec3::ZERO, 0.0, 1); out.particles("minecraft:white_smoke", d.pos - dir * spec.scale, 0.1, DVec3::ZERO, 0.0, 1); } let (event, pitch, every) = spec.sound; if burning && d.age % every == 0 { out.sound(event, d.pos, 1.0, pitch); } // The scream of the warhead coming down, once, and its shock wave. if d.vel.y < -1.0 && d.spin == 0.0 && d.pos.distance(d.target) < d.vel.length() * 80.0 { d.spin = 1.0; let p = if d.kind == DroneKind::Kinzhal { 0.85 } else { 1.0 }; out.sound("airdef:drone.ballistic", d.pos, 1.0, p); out.sound("airdef:boom.sonic", d.pos, 1.0, p); } continue; } // An interceptor coming: drones drop to the treetops (radars lose them sooner). if d.engaged > 0 && matches!(spec.class, Class::Drone | Class::Jet) { d.low = true; } // A planned route: the next point, then the next. // Reached when near (as near as it can turn), or passed: behind it and close. let radius = (spec.speed / spec.turn.max(0.01)).max(24.0); while d.route.len() > 1 && d.route.first().is_some_and(|w| { let to = horizontal(*w - d.pos); to.length() < radius * 0.8 || (to.dot(horizontal(d.vel)) < 0.0 && to.length() < radius * 2.5) }) { d.route.remove(0); } let climb = d.kind.climb(); // A dive begins far enough out to bring it down onto the target: a missile that turns slowly needs a long // way to lose height it has over the aim. let dive_at = { let r = spec.speed * 1.35 / (spec.turn * 3.5).max(0.01); let drop = (d.pos.y - d.target.y).clamp(0.0, r); spec.dive.max(((2.0 * r * drop - drop * drop).sqrt() * 1.1).min(spec.dive * 2.5)) }; let way = d.route.first().copied().unwrap_or(d.target); let to_target = horizontal(d.target - d.pos).length(); // What the land ahead asks: along its heading and towards its next point, several seconds out. if d.age <= 1 || (now + u64::from(d.id)) % 3 == 0 { let heading = horizontal(d.vel).normalize_or_zero(); let towards = horizontal(way - d.pos).normalize_or_zero(); let look = d.kind.look_len(); let mut lines = vec![(heading, look)]; let angle = heading.angle_between(towards); if towards != DVec3::ZERO && angle > 0.1 { let reach = look.min(horizontal(way - d.pos).length() + 30.0); lines.push((towards, reach)); if angle > 0.4 { lines.push(((heading + towards).normalize_or_zero(), reach)); } } d.nav.need = terrain::required_height(ground, d.pos, &lines, spec.clearance, &climb).unwrap_or(f64::NEG_INFINITY); d.nav.dive = if to_target < dive_at * 3.0 { terrain::dive_height(ground, d.pos, d.target, terrain::DIVE_MARGIN).unwrap_or(f64::NEG_INFINITY) } else { f64::NEG_INFINITY }; } // The dive into the target starts when the line down to it is clear of the land, else it comes in higher; // a dive that finds the line blocked pulls out and climbs. if d.flight == Flight::Cruise && d.route.len() <= 1 && to_target < dive_at && d.pos.y >= d.nav.dive - 1.0 && d.vel.y < 0.3 * spec.speed { d.flight = Flight::Dive; } else if d.flight == Flight::Dive && to_target > 40.0 && d.pos.y < d.nav.dive - 4.0 { d.flight = Flight::Cruise; } let rate = if d.flight == Flight::Dive { spec.turn * 3.5 } else { spec.turn }; let old = d.vel.normalize_or(DVec3::NEG_Z); let probe_len = (spec.speed * 10.0).clamp(50.0, 150.0); let new = if d.flight == Flight::Dive { let mut new = turn(old, d.target - d.pos, rate); // Something solid across the dive, short of the target (a building the heights don't show): up and round. if to_target > 40.0 && d.age > 8 && let world::Sweep::Hit { point, .. } = ground.world.probe(d.pos, d.pos + new * probe_len.min(to_target - 20.0), now) && point.distance(d.target) > 16.0 { d.flight = Flight::Cruise; d.nav.need = d.nav.need.max(point.y + spec.clearance); new = turn(old, horizontal(way - d.pos).normalize_or(horizontal(old)) + DVec3::Y * climb.pitch.tan(), spec.turn); } new } else { let heading = horizontal(way - d.pos).normalize_or(horizontal(old).normalize_or(DVec3::NEG_Z)); // Cruise height over the target; as low as the land lets it when under the radars. let base = if d.low { d.target.y + 4.0 } else { d.target.y + spec.cruise }; let own = below.map_or(f64::NEG_INFINITY, |g| g + spec.clearance); let need = d.nav.need.max(own); let safe = if need.is_finite() { need + 2.0 } else { d.target.y + spec.clearance }; let height = base.max(safe).max(d.nav.dive + 1.0); let full = climb.pitch.tan(); let urgent = need.is_finite() && d.pos.y < need; let slope = if urgent { full } else { ((height - d.pos.y) / (climb.radius * 1.5).min((to_target * 0.5).max(30.0)).max(30.0)).clamp(-0.35, full) }; let mut new = terrain::fly_towards(old, heading, slope, rate, urgent); // Something solid ahead that the heights don't show (trees, a building, an overhang): climb. if d.age > 8 && let world::Sweep::Hit { point, .. } = ground.world.probe(d.pos - DVec3::Y * 4.0, d.pos - DVec3::Y * 4.0 + new * probe_len, now) { d.nav.need = d.nav.need.max(point.y + spec.clearance); new = terrain::fly_towards(old, heading, full, rate, true); } terrain::clamp_pitch(new, -0.6, climb.pitch, heading) }; let side = old.cross(new).y; d.roll += ((side * 40.0).clamp(-0.8, 0.8) - d.roll) * 0.15; let speed = if d.flight == Flight::Dive { spec.speed * 1.35 } else { spec.speed }; d.vel = new * speed; d.pos += d.vel; let sweep = ground.trace(d.id, from, d.pos, d.age <= 3, now); if let Some((_, p)) = sweep.hit() { d.pos = p; } else if let Some(t) = ground.unread(&sweep, &mut d.hold, below.is_some_and(|g| from.y < g + 12.0)) { // Not told of what lies ahead: it doesn't fly into it unseen (a held flight waits, a tick or two). d.pos = from.lerp(d.pos, t); } let at_target = d.pos.distance(d.target) < speed.max(1.5); let in_ground = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now); if at_target || in_ground || d.age > 8000 { boom.push((d.pos, spec.warhead, spec.decoy)); // Into the land or a building short of its aim: a crash, not a hit. if in_ground && d.pos.distance(d.target) >= speed.max(1.5) + 3.0 { if !spec.decoy { crashes.push((d.id, d.kind, d.pos, spec.warhead)); } removed.push(d.id); continue; } if !spec.decoy { impacts.push((d.id, d.kind, d.pos, spec.warhead)); } removed.push(d.id); if !spec.decoy { self.hits += 1; } continue; } let back = d.pos - d.vel.normalize_or_zero() * 1.5 * spec.scale; match spec.engine { Engine::Prop(..) => { if d.age % 2 == 0 { out.particles("minecraft:smoke", back, 0.05, DVec3::ZERO, 0.01, 1); } } Engine::Jet(..) => out.particles("minecraft:smoke", back, 0.08, DVec3::ZERO, 0.01, 1), Engine::Rocket(..) => { out.particles("minecraft:flame", back, 0.08, -d.vel * 0.05, 0.01, 1); out.particles("minecraft:cloud", back - d.vel.normalize_or_zero() * 1.5, 0.15, DVec3::ZERO, 0.005, 1); } } let (event, pitch, every) = spec.sound; if d.age % every == 0 { out.sound(event, d.pos, 1.0, pitch); } } for (drone, pos) in splits { self.log(pos, record::EventKind::Split { drone }); } for (drone, kind, pos, tnt) in crashes { self.crashes += 1; let power = match tnt { 0 => 0, 1 | 2 => 1, 3..=5 => 2, 6 | 7 => 3, _ => 4, }; self.log(pos, record::EventKind::Crash { drone, kind, power }); } for (drone, kind, pos, tnt) in impacts { if let Some(sector) = self.drones.iter().find(|x| x.id == drone).map(|x| x.sector as usize) { self.learn(sector, false); } let power = match tnt { 0 => 0, 1 | 2 => 1, 3..=5 => 2, 6 | 7 => 3, _ => 4, }; let object = self.targets.iter().position(|t| t.distance(pos) < 16.0); let sam = self.sams.iter().enumerate().find(|(_, s)| s.alive() && s.pos.distance(pos) < 12.0).map(|(i, s)| (s.kind, i)); self.log(pos, record::EventKind::Hit { drone, kind, power, object, sam }); let vel = self.drones.iter().find(|x| x.id == drone).map_or(DVec3::ZERO, |x| x.vel); self.direct_hit(pos, vel, tnt, out); } for (pos, warhead, small) in boom { self.explode(pos, warhead, small, ground, player, out); } self.drones.retain(|d| !removed.contains(&d.id)); for (kind, pos, vel, target, sector) in released { let id = self.id(); self.drones.push(Drone { id, kind, pos, vel, target, flight: Flight::Ballistic, roll: 0.0, spin: 1.0, age: 60, engaged: 0, route: Vec::new(), low: false, jammed: false, sector, smart: false, nav: Nav::default(), err: DVec3::ZERO, hold: 0, }); } for d in &self.drones { let spec = d.kind.spec(); let s = spec.scale; let mut rot = look(d.vel, d.roll); if d.flight == Flight::Falling { rot = rot * DQuat::from_rotation_z(d.spin) * DQuat::from_rotation_x(-0.6); } if !matches!(d.kind, DroneKind::OreshnikBlock | DroneKind::OreshnikSub) || !d.alive() { out.model(d.id, spec.model, d.pos, rot, s, false); } if !d.alive() { continue; } match spec.engine { Engine::Prop(back, size) => { let at = d.pos + rot * DVec3::new(0.0, 0.0, back * s); let spin = DQuat::from_rotation_z(d.age as f64 * 1.1); out.model(d.id + 1, "airdef:shahed_prop", at, rot * spin, s * size, false); } Engine::Jet(up, back, size) => { let at = d.pos + rot * DVec3::new(0.0, up * s, back * s); let flicker = 0.7 + 0.3 * self.vis.f(); out.model(d.id + 1, "airdef:geran3_flame", at, rot, s * size * flicker, true); } Engine::Rocket(back, size) => { // Ballistic missiles fly far and high: their glow keeps a size on screen, so they are seen // all the way (the motor while it burns, then the hot body). let far = player.map_or(1.0, |p| (d.pos.distance(p) / 140.0).max(1.0)); let flicker = 0.8 + 0.2 * self.vis.f(); if d.flight != Flight::Ballistic || d.age < 60 { let size = if d.flight == Flight::Ballistic { size * far.sqrt() } else { size }; let at = d.pos + rot * DVec3::new(0.0, 0.0, back * s + size * flicker); out.model(d.id + 1, "airdef:flame", at, rot, size * flicker, true); } else if !matches!(d.kind, DroneKind::OreshnikBlock | DroneKind::OreshnikSub) { out.model(d.id + 1, "airdef:reentry", d.pos, rot, s * 0.6 * far.sqrt() * flicker, true); } else { out.model(d.id + 1, "airdef:reentry", d.pos, rot, s * far.sqrt().min(4.0) * flicker, true); } } } } } /// A blast: a fireball, smoke, sound, a jolt for a near player, and (with /// `damage`) primed TNT for the land. fn explode(&mut self, pos: DVec3, tnt: u32, small: bool, ground: &mut Ground, player: Option, out: &mut Out) { let big = tnt >= 3; // Power: 0 air burst, 1 small, 2 drone, 3 cruise missile, 4 ballistic. let power = match tnt { 0 => 0, 1 | 2 => 1, 3..=5 => 2, 6 | 7 => 3, _ => 4, }; let size = [2.5, 4.5, 7.0, 10.0, 13.0][power]; self.fireball(pos, size, [11, 11, 18, 22, 26][power]); out.particles("minecraft:explosion_emitter", pos, 0.5 + power as f32, DVec3::ZERO, 0.0, [1, 1, 3, 5, 8][power]); // The shader draws the fireball and smoke; particles add sparks and debris. let n = if self.shader { 12 } else { 40 }; out.particles("minecraft:large_smoke", pos, size as f32 * 0.4, DVec3::new(0.0, 0.1, 0.0), 0.08, n); out.particles("minecraft:flame", pos, size as f32 * 0.3, DVec3::ZERO, 0.2, n); out.particles("minecraft:lava", pos, 1.0, DVec3::ZERO, 0.0, 16); out.particles("minecraft:campfire_signal_smoke", pos, 1.2, DVec3::new(0.0, 0.04, 0.0), 0.01, if big { 30 } else { 10 }); let pitch = 0.9 + self.vis.f() as f32 * 0.2; let event = ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][power]; out.sound(event, pos, 1.0, if small { pitch * 1.15 } else { pitch }); if power >= 3 { // A heavy warhead throws a column of dirt and a ring of dust. out.particles("minecraft:campfire_cosy_smoke", pos, size as f32 * 0.5, DVec3::new(0.0, 0.15, 0.0), 0.05, 20 * power as u32); out.particles("minecraft:explosion", pos, size as f32 * 0.6, DVec3::ZERO, 0.0, 10 * power as u32); } if let Some(p) = player { let d = p.distance(pos); let reach = [70.0, 45.0, 100.0, 160.0, 240.0][power]; if d < reach { let strength = [0.55, 0.4, 0.9, 1.3, 1.8][power]; out.jolt((1.0 - d / reach) as f32 * strength, [1.0, 0.8, 1.6, 2.2, 3.0][power]); } } self.blast_damage(pos, power, out); if self.damage && tnt > 0 { for i in 0..tnt { out.commands.push(format!( "summon tnt {:.2} {:.2} {:.2} {{fuse:0s}}", pos.x + self.rng.s() * 0.8, pos.y + i as f64 * 0.5, pos.z + self.rng.s() * 0.8 )); } ground.forget(pos, 8); } } fn fireball(&mut self, pos: DVec3, size: f64, life: u32) { self.blast_seq = self.blast_seq.wrapping_add(1); self.blasts.push(Blast { id: self.blast_seq, at: pos, size: size as f32, start: self.tick, life: life * 9 }); if self.blasts.len() > 48 { self.blasts.remove(0); } if self.shader { return; } let id = fx_ident(&mut self.fx_next); let spin = DQuat::from_euler(glam::EulerRot::XYZ, self.vis.s() * 0.2, self.vis.s() * 0.2, self.vis.s() * 0.2); self.fx.push((id, Fx::Fireball { pos, size, age: 0, life, spin })); } /// A drone hit by the guns of system `by`. fn shoot_down(&mut self, idx: usize, by: u32, missile: Option, out: &mut Out) -> Option { let kind = self.sams.iter().find(|s| s.id == by).map_or(SamKind::Gepard, |s| s.kind); let shell = kind.spec().gun.as_ref().map_or(5.0, |g| g.shell); let at = self.drones[idx].pos; self.shoot_down_by(idx, by, missile, Impact::gun(kind, at, shell), out) } /// A drone brought down by `imp` from system `by`: how it ends is rolled once (combat randomness, so a /// detonation really means no ground impact), recorded with the kill, and drawn from the visual randomness /// only. The kill is counted here and nowhere else. fn shoot_down_by(&mut self, idx: usize, by: u32, missile: Option, imp: Impact, out: &mut Out) -> Option { if !self.drones[idx].alive() { return None; } let dkind = self.drones[idx].kind; let roll = self.rng.f(); let outcome = outcome::choose(dkind, &imp, roll); let spec = dkind.spec(); // A broken-up target's warhead section may stay whole: it falls on as a wreck (and may go off on landing). let keeps_warhead = outcome == Outcome::Breakup && !spec.decoy && spec.warhead > 0 && self.rng.chance(outcome::WARHEAD_SURVIVES); let sector = self.drones[idx].sector as usize; self.learn(sector, true); let until = self.tick + 70; if let Some(s) = self.sams.iter_mut().find(|s| s.id == by) { s.kill_view = Some((self.drones[idx].id, self.drones[idx].pos, until)); } if let Some((i, s)) = self.sams.iter().enumerate().find(|(_, s)| s.id == by) { let (drone, kind, pos) = (self.drones[idx].id, dkind, self.drones[idx].pos); let kill = record::EventKind::Kill { drone, kind, sam: by, sam_kind: s.kind, sam_index: i, missile, outcome }; self.log(pos, kill); } let d = &mut self.drones[idx]; let before = d.vel; d.flight = Flight::Falling; d.age = 0; d.vel = match outcome { Outcome::DisabledFalling => d.vel * 0.6 + DVec3::new(0.0, 0.15, 0.0), _ => d.vel * 0.35 + DVec3::new(0.0, 0.1, 0.0), }; let pos = d.pos; let vel = d.vel; self.kills += 1; if let Some(s) = self.sams.iter_mut().find(|s| s.id == by) { s.kills += 1; } // The look of the kill goes by the warhead it blew up and by how it ended (replays use the same). let (size, life, gain, sound) = outcome::look(dkind, outcome); self.fireball(pos, size as f64, life / 9); let pitch = 1.0 + self.vis.f() as f32 * 0.2; match outcome { Outcome::DisabledFalling => { out.particles("minecraft:explosion", pos, 0.6, DVec3::ZERO, 0.0, 4); out.particles("minecraft:large_smoke", pos, 1.0, DVec3::ZERO, 0.06, 25); out.particles("minecraft:firework", pos, 0.3, DVec3::ZERO, 0.35, 30); for _ in 0..5 { let id = fx_ident(&mut self.fx_next); let v = vel + DVec3::new(self.vis.s(), self.vis.f() * 0.8, self.vis.s()) * 0.35; let spin = DQuat::from_euler(glam::EulerRot::XYZ, self.vis.s() * 0.4, self.vis.s() * 0.4, self.vis.s() * 0.4); self.fx.push((id, Fx::Debris { pos, vel: v, rot: DQuat::IDENTITY, spin, age: 0 })); } } Outcome::Breakup => { out.particles("minecraft:explosion", pos, 0.8, DVec3::ZERO, 0.0, 3); out.particles("minecraft:large_smoke", pos, 1.4, DVec3::ZERO, 0.08, 30); out.particles("minecraft:firework", pos, 0.4, DVec3::ZERO, 0.3, 14); } Outcome::AirDetonation => { out.particles("minecraft:flash", pos, 0.0, DVec3::ZERO, 0.0, 1); out.particles("minecraft:explosion_emitter", pos, 0.8, DVec3::ZERO, 0.0, 2); out.particles("minecraft:large_smoke", pos, size * 0.3, DVec3::new(0.0, 0.05, 0.0), 0.07, if self.shader { 20 } else { 40 }); out.particles("minecraft:firework", pos, 0.6, DVec3::ZERO, 0.5, 50); // The shock arrives after the flash (`boom` sounds travel); the warhead's blast hurts what is near. out.sound("airdef:boom.sonic", pos, 0.8, 1.2); self.blast_damage(pos, 0, out); } } out.sound(sound, pos, gain, pitch); let seed = self.vis.next(); let mut alive = self.fx.iter().filter(|(_, f)| matches!(f, Fx::Piece { .. })).count(); for p in outcome::pieces(seed, outcome, before) { if alive + 1 > outcome::PIECES_MAX { break; } let id = fx_ident(&mut self.fx_next); let rot = DQuat::from_euler(glam::EulerRot::XYZ, self.vis.s(), self.vis.s(), self.vis.s()); self.fx.push((id, Fx::Piece { pos, vel: p.vel, rot, piece: p, age: 0 })); alive += 1; } if outcome == Outcome::AirDetonation || (outcome == Outcome::Breakup && !keeps_warhead) { // The main mass is gone: nothing falls to the ground and nothing goes off there. self.drones.remove(idx); } Some(outcome) } // ------------------------------------------------ air defence fn step_sams(&mut self, ground: &mut Ground, out: &mut Out) { if self.hold { for i in 0..self.sams.len() { if self.sams[i].alive() && !self.sams[i].moving() { self.sams[i].state = SamState::Hold; } self.draw_sam(i, out); } return; } let logistics = self.logistics(); // Drones by id (none come or go while the systems act), and how many systems have each as their // missile target and as their gun target: counted once, then kept up to date as targets change. let drone_at: HashMap = self.drones.iter().enumerate().map(|(k, d)| (d.id, k)).collect(); let (mut missile_on, mut guns_on) = why::engagement_counts(&self.sams); // For the raid's report: tracks dropped and systems that ran dry and wait for a truck. let (mut tracks_lost, mut ammo_waits) = (0u32, 0u32); let mut launches: Vec<(MissileKind, DVec3, DVec3, u32, u32)> = Vec::new(); let mut gun_hits: Vec<(u32, u32)> = Vec::new(); let mut tracers: Vec<(u32, DVec3, DVec3, u32)> = Vec::new(); for i in 0..self.sams.len() { let spec = self.sams[i].kind.spec(); if !self.sams[i].alive() { let sam = &mut self.sams[i]; sam.state = SamState::Destroyed; retarget(&mut missile_on, sam.target.take(), None); retarget(&mut guns_on, sam.gun_target.take(), None); continue; } { let sam = &mut self.sams[i]; sam.cooldown = sam.cooldown.saturating_sub(1); sam.gun_cooldown = sam.gun_cooldown.saturating_sub(1); sam.flash = sam.flash.saturating_sub(1); sam.recoil *= 0.6; if sam.radar_up() { sam.dish = sam.beam; } sam.jam = sam.jam.saturating_sub(1); if self.tick % 64 == 0 { let now = self.tick; sam.terrain.retain(|_, (when, _)| now.saturating_sub(*when) < 4 * terrain::VERDICT_AGE); sam.waits.retain(|_, since| now.saturating_sub(*since) < 4 * terrain::WAIT_TICKS); } if sam.reloading > 0 { sam.reloading -= 1; if sam.reloading == 0 { sam.missiles = sam.cells(); sam.bursts = spec.gun.as_ref().map_or(0, |g| g.bursts); out.sound("minecraft:block.piston.extend", sam.pos, 2.0, 0.6); } } } if self.sams[i].moving() { continue; } if !self.sams[i].kind.armed() { let sam = &mut self.sams[i]; sam.state = SamState::Search; // A searchlight's target is its beam's (step_lights): kept for its spot of light. if sam.kind != SamKind::Light { retarget(&mut missile_on, sam.target.take(), None); } retarget(&mut guns_on, sam.gun_target.take(), None); continue; } let pos = self.sams[i].pos; let roles = self.sams[i].roles; let (had_target, had_gun) = (self.sams[i].target, self.sams[i].gun_target); let now = self.tick; let (target, gun_target) = { let me = &self.sams[i]; let tracks = &me.tracks; // A shot the land ruled out a moment ago is not tried again at once. let open = |id: u32| me.terrain_verdict(id, now).is_none_or(|b| b == 0); // The conditions are `why`'s, shared with the diagnostic. A system knows its targets by their tracks: // the forecast of the last measurement, never the true place. let drone = |tr: &Track| drone_at.get(&tr.id).map(|&k| &self.drones[k]); let claimed = |d: &Drone| why::claimed(d, &missile_on, had_target); let needed = why::needed; let target = spec.missile.as_ref().map(|load| { let ok = |tr: &Track, k: f64| { drone(tr).is_some_and(|d| why::allowed(roles, tr, now, d) && open(tr.id) && why::in_reach(load.min, load.max, k, pos, tr.est(now))) }; let keep = had_target.filter(|t| tracks.iter().any(|tr| tr.id == *t && ok(tr, 1.1))); keep.or_else(|| { tracks .iter() .filter(|tr| ok(tr, 1.0)) .filter_map(|tr| drone(tr).map(|d| (tr, d))) .filter(|(_, d)| claimed(d) < needed(d)) .max_by(|a, b| a.1.threat_at(pos, a.0.est(now)).total_cmp(&b.1.threat_at(pos, b.0.est(now)))) .map(|(_, d)| d.id) }) }); // Guns: the nearest, spread over the targets the other guns and missiles aren't on. let gun_target = spec.gun.as_ref().map(|gun| { let on_it = |d: &Drone| { (guns_on.get(&d.id).copied().unwrap_or(0) - u32::from(had_gun == Some(d.id))) as f64 }; tracks .iter() .filter_map(|tr| drone(tr).map(|d| (tr, d))) .filter(|(tr, d)| why::gun_candidate(roles, tr, now, gun.track, pos, d)) .map(|(tr, d)| { let busy = 1.0 + 2.0 * on_it(d) + if d.engaged > 0 { 1.5 } else { 0.0 }; (tr.est(now).distance(pos) * busy, d.id) }) .min_by(|a, b| a.0.total_cmp(&b.0)) .map(|(_, id)| id) }); (target, gun_target) }; if let Some(target) = target { // A target still in the sky that the system had and now doesn't: its track was lost. if let Some(h) = had_target && target != Some(h) && drone_at.get(&h).is_some_and(|&k| self.drones[k].alive()) { tracks_lost += 1; } retarget(&mut missile_on, had_target, target); self.sams[i].target = target; } if let Some(gun_target) = gun_target { retarget(&mut guns_on, had_gun, gun_target); self.sams[i].gun_target = gun_target; } let sam = &mut self.sams[i]; // What the turret or launcher points at: guns first when a target is close. let aim = sam .gun_target .or(sam.target) .filter(|t| drone_at.contains_key(t)) .and_then(|t| sam.track(t)) .map(|tr| (tr.id, tr.est(now), tr.vel)); sam.launched = sam.launched.saturating_sub(1); let Some(aim) = aim else { // Out of missiles or of shells, and nothing to shoot: reload now (both, as a // combined system's crew would), not only once both are spent. let spent = (spec.missile.is_some() && sam.missiles == 0 && sam.part(Part::Launcher) > 0.0) || (spec.gun.is_some() && sam.bursts == 0); if spent && sam.reloading == 0 && !sam.empty { if logistics { sam.empty = true; ammo_waits += 1; } else { sam.reloading = sam.reload_time() } } sam.state = if sam.reloading > 0 { SamState::Reload } else if sam.empty { SamState::Waiting } else { SamState::Search }; // Back to the rest position, under the drive's limits like any other turn. sam.aim_towards(sam.heading, Some(spec.rest_pitch)); continue; }; let (mount, _) = sam.mount(); let lead_speed = spec.gun.as_ref().map_or(5.0, |g| g.shell); let (aim_id, aim_pos, aim_vel) = aim; let (lead, want_yaw, want_pitch) = why::aim_wants(mount, aim_pos, aim_vel, lead_speed); let cells = matches!(spec.muzzle, Muzzle::Cells); match spec.family { // Vertical launch: no aiming (the Tor's turret turns its radar to the target). Family::Vertical if cells => sam.aim_towards(want_yaw, None), Family::Vertical => {} // The launcher's elevation is fixed (the missile turns after its boost): only the training is aimed. Family::Trainable => sam.aim_towards(want_yaw, None), _ => sam.aim_towards(want_yaw, Some(want_pitch)), } // How far the mount still is from the aim, over the axes this family aims. let aim_error = why::aim_error(spec.family, sam.yaw, sam.pitch, want_yaw, want_pitch); let vertical = spec.family == Family::Vertical; sam.state = if sam.reloading > 0 { SamState::Reload } else if sam.empty && sam.missiles == 0 && sam.bursts == 0 { SamState::Waiting } else if sam.launched > 0 { SamState::Launch } else if sam.flash > 0 { SamState::Fire } else if !vertical && aim_error > 0.15 { SamState::Slew } else if aim_pos.distance(mount) < self::SamKind::range(sam.kind) { SamState::Lock } else { SamState::Track }; // Guns. if let (Some(gun), Some(gt)) = (&spec.gun, sam.gun_target) { if let Some(t) = drone_at.get(>).map(|&k| &self.drones[k]) { let est = sam.track(gt).map_or(t.pos, |tr| tr.est(now)); let dist = est.distance(mount); let mut blocks = why::gun_blocks(sam, aim_id, gt, aim_error, dist, gun.range, now); // A damaged gun jams now and then, for a while: the burst it was about to fire is lost. let wear = 1.0 - sam.part(Part::Gun); if blocks == 0 && wear > 0.0 && dice(self.seed, now, sam.id as u64, 77) < wear * 0.1 { sam.jam = 30 + (dice(self.seed, now, sam.id as u64, 78) * 90.0) as u32; blocks = why::JAMMED; } if blocks == 0 { sam.gun_cooldown = 2; sam.bursts -= 1; sam.flash = 2; sam.recoil = 1.0; if sam.bursts == 0 && sam.missiles == 0 { if logistics { sam.empty = true; ammo_waits += 1; } else { sam.reloading = sam.reload_time() } } let (_, rot) = sam.mount(); let s = spec.scale; let barrels: &[DVec3] = match sam.kind { SamKind::Pantsir => &[DVec3::new(-0.95, 0.33, -1.5), DVec3::new(0.95, 0.33, -1.5)], _ => &[DVec3::new(-0.92, -0.11, -1.55), DVec3::new(0.92, -0.11, -1.55)], }; for b in barrels { let muzzle = mount + rot * *b * s; let dir = (lead - muzzle).normalize_or(DVec3::NEG_Z); let len = muzzle.distance(lead).min(gun.range * 1.2); // Every round is a red tracer flying out at the shell's speed. let scatter = DVec3::new(self.vis.s(), self.vis.s(), self.vis.s()) * 0.012; let id = fx_ident(&mut self.fx_next); tracers.push((id, muzzle + dir * 1.5, (dir + scatter).normalize() * gun.shell, (len / gun.shell) as u32 + 2)); out.particles("minecraft:smoke", muzzle, 0.1, dir * 0.05, 0.01, 2); } // A gun sound holds a few shots: one every other burst. if sam.bursts % 2 == 0 { let event = if sam.kind == SamKind::Pantsir { "airdef:gun.pantsir" } else { "airdef:gun.gepard" }; out.sound(event, mount, 1.0, 0.95 + self.vis.f() as f32 * 0.1); } // At night a gun sees what is lit (a searchlight, a flare) and little else. let dark = if self.night && !self.lit.contains(&t.id) { lights::DARK_HITS } else { 1.0 }; // The aim point is the track's forecast: the farther it is from the truth, the more shells miss. let off = est.distance(t.pos); let p = gun.p * sam.gun_fit() * dark / (t.kind.spec().stealth * (1.0 + (off / 8.0).powi(2))) * (1.0 - dist / (gun.range * 1.3)).max(0.35); if self.rng.chance(p) { gun_hits.push((gt, sam.id)); } else { out.particles("minecraft:crit", t.pos, 1.2, DVec3::ZERO, 0.2, 4); } } } } // Missiles. if let (Some(load), Some(mt)) = (&spec.missile, sam.target) { let ready = why::missile_blocks(sam) == 0; // The missile turns after its boost, but the launcher must point near the target. let t = sam.track(mt).filter(|_| drone_at.contains_key(&mt)).map(|tr| tr.est(now)); let aligned = why::missile_aligned(spec.family, sam.yaw, mount, t, aim_id, mt, aim_error); let n = load.magazine - sam.missiles; let (muzzle, dir) = sam.muzzle(n); // The land: a target behind a hill, or a path the hill closes, is not fired at; the system looks for another. let land = if ready && aligned { match (sam.terrain_verdict(mt, now), sam.track(mt)) { (Some(bits), _) => bits, (None, Some(tr)) => { let mut bits = terrain::launch_blocks(ground, load.kind, muzzle, sight::antenna(sam), tr.est(now), tr.vel); if bits == terrain::WAIT { // The land is being asked for: a moment's wait, not a long one (the game may not answer). if now.saturating_sub(*sam.waits.entry(mt).or_insert(now)) > terrain::WAIT_TICKS { bits = 0; } } else { sam.waits.remove(&mt); if bits != 0 { sam.terrain.insert(mt, (now, bits)); } } bits } (None, None) => 0, } } else { 0 }; if land == terrain::WAIT { // The land under the shot is not known yet: it waits. } else if land != 0 { retarget(&mut missile_on, Some(mt), None); sam.target = None; } else if ready && aligned { launches.push((load.kind, muzzle, dir, mt, sam.id)); // Its missile is on it from now, so the systems after this one this tick see // the target taken; this one's next launch looks for another. if let Some(&k) = drone_at.get(&mt) { self.drones[k].engaged += 1; } sam.target = None; retarget(&mut missile_on, Some(mt), None); sam.launched = 12; sam.missiles -= 1; sam.fired += 1; sam.cooldown = load.interval; if sam.missiles == 0 && (spec.gun.is_none() || sam.bursts == 0) { if logistics { sam.empty = true; ammo_waits += 1; } else { sam.reloading = sam.reload_time() } } } } } self.note(|t| { t.tracks_lost += tracks_lost; t.ammo_waits += ammo_waits; }); for (kind, muzzle, dir, target, sam) in launches { // A launch gives the system away. if let Some(s) = self.sams.iter().find(|s| s.id == sam) { self.intel.revealed.push((sam, s.pos, self.tick)); } // It leaves with the track the system fired on (#66). let fix = self .sams .iter() .find(|s| s.id == sam) .and_then(|s| s.track(target)) .map(|tr| Fix { pos: tr.pos, vel: tr.vel, tick: tr.tick, seeker: false }); let spec = kind.spec(); let id = self.id(); let v0 = if spec.cold > 0 { 0.7 } else { 0.8 }; self.missiles.push(Missile { id, kind, pos: muzzle, vel: dir * v0, target, sam, age: 0, hold: 0, fix, seen: 0 }); if spec.cold > 0 { // Thrown out by gas: a puff, a thump; the motor lights later. out.particles("minecraft:cloud", muzzle, 0.6, dir * 0.05, 0.05, 30); let event = if kind == MissileKind::M77n6 { "airdef:launch.s500_eject" } else { "airdef:launch.s400_eject" }; out.sound(event, muzzle, 1.0, 1.0); } else { out.particles("minecraft:flash", muzzle, 0.0, DVec3::ZERO, 0.0, 1); out.particles("minecraft:cloud", muzzle - dir * 2.0, 1.4, DVec3::new(0.0, 0.02, 0.0), 0.06, 70); out.particles("minecraft:campfire_cosy_smoke", muzzle - dir * 3.0, 1.2, DVec3::ZERO, 0.02, 20); let event = if kind == MissileKind::M57e6 { "airdef:launch.pantsir" } else { "airdef:launch.patriot" }; out.sound(event, muzzle, 1.0, 0.95 + self.vis.f() as f32 * 0.1); } } for (id, pos, vel, life) in tracers { self.fx.push((id, Fx::Tracer { pos, vel, life })); } for (t, by) in gun_hits { if let Some(i) = self.drones.iter().position(|d| d.id == t) { let _ = self.shoot_down(i, by, None, out); } } for i in 0..self.sams.len() { self.draw_sam(i, out); } } // ------------------------------------------------ interceptors fn step_missiles(&mut self, ground: &mut Ground, out: &mut Out) { let now = self.tick; let mut done: Vec<(u32, Option, bool, u32)> = Vec::new(); // (missile, target, kill, sam) let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32); let (mut blocked, mut scrubbed) = (0u32, 0u32); let sams_fit: HashMap = self.sams.iter().map(|s| (s.id, 1.0 - 0.3 * (1.0 - s.health.clamp(0.0, 1.0)))).collect(); // The data link (#66): the track the firing system holds of each missile's target, if it is up. let links: HashMap<(u32, u32), Track> = self .missiles .iter() .filter_map(|m| { let s = self.sams.iter().find(|s| s.id == m.sam)?; (s.alive() && !s.moving() && s.link_range() > 0.0).then_some(())?; Some(((m.sam, m.target), *s.track(m.target)?)) }) .collect(); for m in &mut self.missiles { m.age += 1; let spec = m.kind.spec(); let target = self.drones.iter().find(|d| d.id == m.target && d.alive()); let lit = m.age > spec.cold; let speed = if !lit { (m.vel.length() * 0.97).max(0.4) } else { (m.vel.length() + spec.accel).min(spec.speed) }; let dir = m.vel.normalize_or(DVec3::Y); // What it knows: the link's newest track, and its own seeker once the target is in its reach and in view. if let Some(tr) = links.get(&(m.sam, m.target)) && m.fix.is_none_or(|f| tr.tick > f.tick && !(f.seeker && now.saturating_sub(f.tick) <= REACQUIRE)) { m.fix = Some(Fix { pos: tr.pos, vel: tr.vel, tick: tr.tick, seeker: false }); } if let Some(t) = target && lit && m.age >= spec.boost && m.kind.seeker() > 0.0 { let range = m.kind.seeker() * t.kind.rcs().powf(0.25).clamp(0.6, 1.6); let to = t.pos - m.pos; let in_view = to.length() < range && to.normalize_or_zero().dot(dir) > 0.5; // Only a line of sight the world has confirmed counts: blocks not read (Unknown) are not air (#75). if in_view && matches!(ground.world.probe(m.pos, t.pos, now), world::Sweep::Clear) { m.seen = now; // The seeker measures the target: position and velocity, now. Out of sight (behind a hill, // an unread section) nothing new is measured and the last fix is carried on as it was. m.fix = Some(Fix { pos: t.pos, vel: t.vel, tick: now, seeker: true }); } } // Nothing new for too long and no seeker on it: the interceptor has lost its target. if target.is_some() && m.fix.is_none_or(|f| now.saturating_sub(f.tick) > MAX_COAST) && m.age > spec.boost + 20 { expired += 1; done.push((m.id, None, false, m.sam)); continue; } let aim = m.fix.map(|f| (f.est(now), f.vel)); let new = match aim { Some((at, vel)) if m.age >= spec.boost => { // Time to go by the closing speed: head-on targets come to meet it. let los = (at - m.pos).normalize_or_zero(); let closing = (speed - vel.dot(los)).max(1.0); let tgo = at.distance(m.pos) / closing; // Over the land in the way: lofted as far as the missile can, else it blows up before the ridge. let (want, fail) = terrain::steer(ground, m.kind, m.pos, speed, at + vel * tgo); if fail { scrubbed += 1; done.push((m.id, target.map(|t| t.id), false, m.sam)); continue; } turn(dir, want, spec.turn) } // After a cold launch it tips over towards the target while the motor burns. Some((at, _)) if lit && spec.cold > 0 => turn(dir, at - m.pos, spec.turn * 0.5), _ => dir, }; let before = m.pos; m.vel = new * speed; m.pos += m.vel; let end = m.pos; // Where the exact shape is still being asked for, the contact point (by the cube) bounds the fuse (#74). let mut waiting: Option = None; // The first block on the way this tick; a fuse that goes off earlier along the way still counts. ground.ahead(m.pos, m.vel, 10.0, now); let sweep = ground.trace(m.id | 0x8000_0000, before, m.pos, m.age <= 12, now); let mut world = sweep.hit(); // Blocks not told of (#61): the interceptor holds at the edge of what is read, and no fuse is // counted beyond it; low over a chunk the game doesn't have, it is lost there as against a wall. if world.is_none() && let world::Sweep::Unknown { t, pending } = sweep { let low = ground.at(m.pos, now).is_some_and(|g| before.y < g + 12.0); if !pending && low { world = Some((t, before.lerp(m.pos, t))); } else if let Some(t) = ground.unread(&sweep, &mut m.hold, low || pending) { m.pos = before.lerp(m.pos, t); } } else if let world::Sweep::Wait { t } = sweep { m.pos = before.lerp(m.pos, (t - 1e-6).max(0.0)); waiting = Some(t); } else if matches!(sweep, world::Sweep::Clear) { m.hold = 0; } if let Some(t) = target { // Closest approach this tick, both moving (fast targets cross a fuse in a tick). let rel0 = (t.pos - t.vel) - before; // Waiting for the shape: the fuse is judged on the whole step against the cube's face, as for a // known hit, so that a pending answer is neither a free pass nor a different outcome. let rel1 = t.pos - if waiting.is_some() { end } else { m.pos }; let delta = rel1 - rel0; let k = (-rel0.dot(delta) / delta.length_squared().max(1e-9)).clamp(0.0, 1.0); if (rel0 + delta * k).length() < spec.fuse && world.map(|w| w.0).or(waiting).is_none_or(|w| k <= w) { // A target faster than the interceptor is hard to hit. let ratio = (spec.speed / t.vel.length().max(0.1)).min(1.0).powf(1.5); let fit = sams_fit.get(&m.sam).copied().unwrap_or(1.0); let kill = self.rng.chance(spec.pk * fit * ratio / t.kind.spec().stealth.sqrt()); missed += u32::from(!kill); done.push((m.id, Some(t.id), kill, m.sam)); continue; } } if let Some((_, p)) = world { // The land or a building is in the way: the interceptor is lost there. m.pos = p; blocked += 1; done.push((m.id, None, false, m.sam)); continue; } if m.age > spec.life || (target.is_none() && m.age > 30) { // Its target gone (shot down by another, or lost), or its life spent chasing one. if target.is_none() { orphaned += 1; } else { expired += 1; } done.push((m.id, None, false, m.sam)); continue; } if lit && spec.flame == 0.0 { if m.age % 20 == 1 { out.sound("airdef:drone.lancet", m.pos, 0.8, 1.6); } } else if lit { if (m.age - spec.cold) % 46 == 1 { let pitch = match m.kind { MissileKind::Pac3 => 1.0, MissileKind::M57e6 => 1.12, MissileKind::M48n6 => 0.88, MissileKind::M77n6 => 0.8, MissileKind::IrisT | MissileKind::Amraam => 1.05, MissileKind::M9m317 => 0.9, MissileKind::M9m338 => 1.15, MissileKind::P1sun => 1.7, }; out.sound("airdef:missile.flight", m.pos, 1.0, pitch); } let back = m.pos - new * 1.4 * spec.scale; out.particles("minecraft:flame", back, 0.05, -new * 0.1, 0.01, 2); if m.age % 2 == 0 { out.particles("minecraft:cloud", back - new * 0.5, 0.12 * spec.scale as f32, DVec3::ZERO, 0.005, if m.age < 30 { 3 } else { 1 }); } if m.age == spec.cold + 1 && spec.cold > 0 { let event = if m.kind == MissileKind::M77n6 { "airdef:launch.s500" } else { "airdef:launch.s400" }; out.sound(event, m.pos, 1.0, 1.0); out.particles("minecraft:cloud", m.pos, 1.0, DVec3::ZERO, 0.08, 40); } } } self.note(|t| { t.missed += missed; t.expired += expired; t.orphaned += orphaned; t.blocked += blocked; t.scrubbed += scrubbed; }); for (mid, target, kill, sam) in done { if let Some(i) = self.missiles.iter().position(|m| m.id == mid) { let m = self.missiles.swap_remove(i); self.fireball(m.pos, 2.5 * m.kind.spec().scale, 7); out.particles("minecraft:explosion", m.pos, 0.4, DVec3::ZERO, 0.0, 3); out.sound("airdef:blast.air", m.pos, 1.0, 1.25); if let Some(t) = target.and_then(|t| self.drones.iter().position(|d| d.id == t)) { self.drones[t].engaged = self.drones[t].engaged.saturating_sub(1); if kill { let (at, closing) = (self.drones[t].pos, (m.vel - self.drones[t].vel).length()); let _ = self.shoot_down_by(t, sam, Some(mid), Impact::missile(m.kind, at, closing), out); } } // A missile that lost its target frees it for others. if target.is_none() && let Some(d) = self.drones.iter_mut().find(|d| d.id == m.target) { d.engaged = d.engaged.saturating_sub(1); } } } for m in &self.missiles { let spec = m.kind.spec(); let rot = look(m.vel, 0.0) * DQuat::from_rotation_z(m.age as f64 * 0.3); out.model(m.id, spec.model, m.pos, rot, spec.scale, false); if m.age > spec.cold && spec.flame > 0.0 { let back = (spec.rear - 8.0) / 16.0 * spec.scale + spec.flame; let size = spec.flame * if m.age < 40 { 1.2 } else { 0.8 } * (0.85 + 0.15 * self.vis.f()); out.model(m.id + 1, "airdef:flame", m.pos + rot * DVec3::new(0.0, 0.0, back), rot, size, true); } } } // ------------------------------------------------ effects fn step_fx(&mut self, ground: &mut Ground, out: &mut Out) { let now = self.tick; let mut keep = Vec::with_capacity(self.fx.len()); for (id, mut fx) in std::mem::take(&mut self.fx) { let alive = match &mut fx { Fx::Fireball { pos, size, age, life, spin } => { *age += 1; let t = *age as f64 / *life as f64; let s = if t < 0.25 { *size * t / 0.25 } else { *size * (1.0 - (t - 0.25) / 0.75).max(0.0) }; let rot = DQuat::from_rotation_y(*age as f64 * 0.2) * *spin; out.model(id, "airdef:fireball", *pos + DVec3::Y * (*age as f64 * 0.05), rot, s.max(0.01), true); *age < *life } Fx::Debris { pos, vel, rot, spin, age } => { *age += 1; vel.y -= 0.05; *vel *= 0.98; *pos += *vel; *rot = *spin * *rot; out.model(id, "airdef:debris", *pos, *rot, 0.7, false); if *age % 2 == 0 { out.particles("minecraft:smoke", *pos, 0.05, DVec3::ZERO, 0.0, 1); } let landed = ground.at(*pos, now).is_some_and(|g| pos.y <= g); !landed && *age < 200 } Fx::Piece { pos, vel, rot, piece, age } => { *age += 1; outcome::fly(pos, vel, piece.drag); *rot = piece.spin * *rot; out.model(id, "airdef:debris", *pos, *rot, piece.scale, false); if piece.burns { out.particles("minecraft:large_smoke", *pos, 0.1, DVec3::new(0.0, 0.03, 0.0), 0.01, 1); if *age % 3 == 0 { out.particles("minecraft:flame", *pos, 0.1, DVec3::ZERO, 0.01, 1); } } let landed = ground.at(*pos, now).is_some_and(|g| pos.y <= g); !landed && *age < piece.life } Fx::Tracer { pos, vel, life } => { *pos += *vel; vel.y -= 0.02; *life = life.saturating_sub(1); out.model(id, "airdef:tracer", *pos, look(*vel, 0.0), 1.0, true); *life > 0 } }; if alive { keep.push((id, fx)); } } self.fx = keep; } fn draw_targets(&mut self, out: &mut Out) { for (i, t) in self.targets.iter().enumerate() { let bob = (self.tick as f64 * 0.1 + i as f64).sin() * 0.25; let rot = DQuat::from_rotation_y(self.tick as f64 * 0.08); out.model(0x7000_0000 + i as u32 * PARTS, "airdef:target", *t + DVec3::Y * (1.6 + bob), rot, 0.8, true); if self.tick % 10 == 0 { out.dust(*t + DVec3::Y * 0.2, 0xFF2020, 1.5, 0.4, 3); } } } }