Files
airdef/airdef-mod/src/sim/mod.rs
T
2026-10-30 15:47:03 +03:00

2833 lines
119 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Model>,
pub particles: Vec<Particle>,
pub sounds: Vec<(&'static str, DVec3, f32, f32)>,
pub commands: Vec<String>,
/// Camera jolt (strength, seconds), the strongest this tick.
pub shake: Option<(f32, f32)>,
pub messages: Vec<String>,
}
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<u32, u32>, from: Option<u32>, to: Option<u32>) {
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<f64> {
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<f64> {
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<f64>, 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> {
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<u8> {
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::<Vec<_>>().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<f64>,
}
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> {
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<DVec3>,
/// 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<Fix>,
/// 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<u32>,
pub gun_target: Option<u32>,
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<Track>,
/// The ids of the tracks it holds this tick: its own radar's and the network's.
pub seen: Vec<u32>,
/// The drones its own radar (or optics) holds: guns need these.
pub own: Vec<u32>,
/// 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<DVec3>,
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<u32>,
/// 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<u32, (u64, u8)>,
/// Targets whose land hasn't been answered yet, and since when it has been waited for.
waits: HashMap<u32, u64>,
}
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<f64>) {
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<SamKind, u8>,
pub drones: Vec<Drone>,
pub missiles: Vec<Missile>,
pub sams: Vec<Sam>,
pub targets: Vec<DVec3>,
fx: Vec<(u32, Fx)>,
/// Blasts for the post-processing shader.
pub blasts: Vec<Blast>,
/// 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<u32, sight::Horizon>,
/// 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<raid::Plan>,
/// Each marked object's health, 0 to 100 (`targets`' order).
pub target_health: Vec<f64>,
/// The raid in progress, for its report.
wave: Option<raid::WaveStats>,
/// The raid being recorded, the last ones recorded, and how many have been.
rec: Option<record::Recording>,
pub replays: Vec<record::Recording>,
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<u64>,
/// Illumination flares in the sky, and the drones lit this tick.
pub flares: Vec<lights::Flare>,
pub lit: Vec<u32>,
/// 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<DVec3> {
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<DVec3> {
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<DVec3>, 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<DVec3>, out: &mut Out) {
let now = self.tick;
let mut boom: Vec<(DVec3, u32, bool)> = Vec::new();
let mut removed: Vec<u32> = 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<DVec3>, 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<u32>, out: &mut Out) -> Option<Outcome> {
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<u32>, imp: Impact, out: &mut Out) -> Option<Outcome> {
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<u32, usize> = 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(&gt).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<u32>, 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<u32, f64> = 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<f64> = 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);
}
}
}
}