Files
airdef/airdef-mod/src/sim/mod.rs
T
2026-11-02 23:59:11 +03:00

3270 lines
141 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;
pub mod battery;
pub mod launch;
pub mod catalog;
mod generated_bounds;
mod damage;
pub mod doctrine;
pub mod fires;
mod raid;
pub mod sites;
pub mod strike;
pub mod record;
mod sight;
mod terrain;
mod ew;
pub mod lights;
mod logistics;
mod orders;
mod route;
mod voices;
pub mod outcome;
mod units;
mod why;
pub mod world;
#[cfg(test)]
mod balance;
#[cfg(test)]
mod regress;
#[cfg(test)]
mod sensor_tests;
pub use ew::EW_RADIUS;
pub use damage::{Comps, Part, PartState};
pub use sight::{AirPicture, Ident, LowSensor, Track, RIM_RAYS};
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,
}
/// Sound source description produced by the simulation and consumed by the audio adapter.
#[derive(Clone, Debug, PartialEq)]
pub struct EngineDecl {
pub key: u32,
pub event: &'static str,
pub at: DVec3,
/// Blocks per second.
pub vel: DVec3,
pub gain: f32,
pub pitch: f32,
pub damaged: bool,
}
/// Shared distance text used by existing diagnostics and the HUD.
/// Moving diagnostic text to the presentation adapter is a separate migration.
pub fn distance(m: f64) -> String {
if m >= 1000.0 { format!("{:.1} км", m / 1000.0) } else { format!("{:.0} м", m) }
}
/// 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)>,
/// The engines sounding this tick (persistent sources, `crate::engines`), and whether the
/// picture cut (a replay seek or a new shot): every engine then starts afresh.
pub engines: Vec<EngineDecl>,
pub engine_cut: bool,
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));
}
/// An engine that sounds: `vel` is the simulation's, blocks a tick.
pub fn engine(&mut self, key: u32, event: &'static str, at: DVec3, vel: DVec3, gain: f32, pitch: f32, damaged: bool) {
self.engines.push(EngineDecl { key, event, at, vel: vel * 20.0, gain, pitch, damaged });
}
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)>,
/// Columns whose chunk the game is loading (it answered `PENDING`), and the tick it said so: not asked
/// again for a few ticks.
loading: HashMap<(i32, i32), u64>,
/// Chunks the search wants loaded (`render.load-chunks`), taken every tick.
chunks: Vec<(i32, i32)>,
/// The game has answered "loading" at least once: it loads chunks on request (a server doesn't).
pub loads: bool,
/// 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 world's epoch when the last questions went out: an answer to them is of that world or dimension, or of none.
asked_epoch: Option<u32>,
/// The blocks themselves (issues #42, #43): what stops a flight.
pub world: world::WorldCache,
}
/// What `render.ground` answers where the chunk is not loaded (and never will be, unless asked for).
pub const NO_LAND: i32 = -64;
/// A height the game answered as a land height: none where it had no chunk. The one place that reads `NO_LAND`.
pub(crate) fn land_height(y: i32) -> Option<f64> {
(y > NO_LAND).then_some(f64::from(y))
}
/// What `render.ground` answers (`i32::MIN`) for a column whose chunk it is loading for the mod.
pub const PENDING: i32 = i32::MIN;
/// Ticks before a column that was loading is asked about again.
const LOAD_RECHECK: u64 = 6;
/// The chunks asked to load at once (the game holds 64).
const CHUNKS_MAX: usize = 48;
/// Ticks a strike drone may fly before it is lost: a Shahed at 0.6 blocks a tick needs 6700 for 4000 blocks.
const MAX_FLIGHT: u32 = 24_000;
/// 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);
}
land_height(y)
}
None => {
if self.wanted.len() < 256 && self.asked.insert(key) {
self.wanted.push(key);
}
None
}
}
}
/// The game's answers to the questions of `take_wanted`. Each is a height (`land_height`), "no land" or
/// "loading"; one to a question of another world or dimension than the one open now is dropped (it is
/// asked again when needed), not taken as this one's.
pub fn answer(&mut self, answers: Vec<(i32, i32, i32)>, now: u64) {
self.now = now;
let answers = match self.asked_epoch.take() {
Some(e) if e != 0 && e != self.world.epoch() => {
self.world.stats.late_dropped += answers.len() as u64;
Vec::new()
}
_ => answers,
};
for (x, z, y) in answers {
if y == PENDING {
self.loading.insert((x, z), now);
self.loads = true;
continue;
}
self.loading.remove(&(x, z));
self.known.insert((x, z), (y, now));
}
if self.loading.len() > 4096 {
self.loading.retain(|_, t| now.saturating_sub(*t) < 400);
}
self.age_heights();
// Blasts the game did not report in the blocks: the land there is looked at again.
for at in self.world.overdue_blasts(now) {
let how = if self.peek(at).is_some() { world::Settled::Unchanged } else { world::Settled::Unloaded };
self.world.note_settled(how);
self.forget(at, 8);
}
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) {
// Another world or dimension: nothing known, asked or loading belongs to this one.
if std::mem::take(&mut self.world.epoch_reset) {
self.known.clear();
self.loading.clear();
self.wanted.clear();
self.asked.clear();
}
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
}
/// Asks the game to load the chunk (chunk x, z) for a while, if there is room; whether it did.
pub fn want_chunk(&mut self, c: (i32, i32)) -> bool {
if self.chunks.contains(&c) {
return true;
}
if self.chunks.len() >= CHUNKS_MAX {
return false;
}
self.chunks.push(c);
// What was answered as "no chunk" there is asked again: the chunk is being loaded now.
for x in c.0 * 16..c.0 * 16 + 16 {
for z in c.1 * 16..c.1 * 16 + 16 {
if self.known.get(&(x, z)).is_some_and(|v| land_height(v.0).is_none()) {
self.known.remove(&(x, z));
}
}
}
for x in (c.0 * 16..c.0 * 16 + 16).step_by(8) {
for z in (c.1 * 16..c.1 * 16 + 16).step_by(8) {
if self.known.get(&(x + 4, z + 4)).is_some_and(|v| land_height(v.0).is_none()) {
self.known.remove(&(x + 4, z + 4));
}
}
}
true
}
/// How many chunks are wanted so far this tick.
pub fn chunks_wanted(&self) -> usize {
self.chunks.len()
}
/// The chunks to ask the game to load this tick.
pub fn take_chunks(&mut self) -> Vec<(i32, i32)> {
std::mem::take(&mut self.chunks)
}
pub fn take_wanted(&mut self) -> Vec<(i32, i32)> {
self.asked.clear();
self.asked_epoch = Some(self.world.epoch());
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 DroneSpec {
/// The registered visual model for this profile.
pub fn model(&self) -> &'static str { self.model }
/// Its size, for how thick its smoke is.
pub fn trail_scale(&self) -> f32 {
self.scale as f32
}
}
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,
/// Its progress towards the aim: a flight that stops getting nearer runs out of fuel (see `Pace`).
pace: Pace,
}
/// Ticks a cruise flight may go without getting nearer its aim or passing a route point before it is out of fuel:
/// long enough for a detour round a radar, far short of `MAX_FLIGHT`. It stops a drone circling an aim it
/// cannot turn onto, or one that holds for an answer that never comes, from loitering over the map for ever.
pub const STALL_TICKS: u32 = 3600;
/// How much nearer counts as getting nearer (blocks).
const STALL_GAIN: f64 = 10.0;
#[derive(Clone, Copy, Debug)]
struct Pace {
/// The least distance to the aim so far, the age it was reached at, and the route points left then.
best: f64,
at: u32,
route: usize,
}
impl Default for Pace {
fn default() -> Self {
Pace { best: f64::INFINITY, at: 0, route: usize::MAX }
}
}
impl Pace {
/// One tick: true when the flight has gone `STALL_TICKS` without progress.
fn stalled(&mut self, age: u32, distance: f64, route: usize) -> bool {
if distance + STALL_GAIN < self.best || route < self.route {
*self = Pace { best: distance.min(self.best), at: age, route };
return false;
}
age.saturating_sub(self.at) > STALL_TICKS
}
}
/// 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,
/// The land ahead is not known (a chunk the game does not have, or not yet): it keeps a transit height
/// above anything it could meet and comes down only once the land ahead is told of.
blind: bool,
}
impl Default for Nav {
fn default() -> Self {
Nav { need: f64::NEG_INFINITY, dive: f64::NEG_INFINITY, blind: false }
}
}
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 {
/// How thick its motor smoke is (its size); `None` for an electric one.
pub fn trail_scale(self) -> Option<f32> {
let s = self.spec();
(s.flame > 0.0).then_some(s.scale as f32)
}
/// 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 {
/// Fictional game motor duration in ticks, independent of the flight lifetime.
pub fn burn_ticks(self) -> u32 {
let spec = self.spec();
if self == MissileKind::P1sun { spec.life } else { spec.life * 2 / 3 }
}
}
impl Missile {
/// Cold ejection and post-burn coast have no powered exhaust.
pub fn engine_lit(&self) -> bool {
let spec = self.kind.spec();
self.age > spec.cold && self.age <= spec.cold + self.kind.burn_ticks()
}
}
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,
pub mode: battery::SamMode,
pub radar_role: battery::RadarRole,
/// Player-controlled active radar emission; independent of component health and data link.
pub radar_on: bool,
pub spent_cells: u8,
preparing: Option<launch::Prepare>,
cap_cell: u8,
cap_open: f32,
/// 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,
/// Explicit RM48U game rounds loaded in shared launcher cells.
pub ground_rounds: u32,
/// Pending player request for one delivered ground round.
pub ground_request: bool,
/// Separate typed rounds and returned S-400 air rounds held by a depot.
pub ground_stock: u32,
pub air_48n6_stock: u32,
pub bursts: u32,
cooldown: u32,
gun_cooldown: u32,
/// Ticks until the system may take another long shot on a big radar's cue (`why::basis`).
far_cooldown: u32,
/// Ticks it has held its present missile target without launching, and a target it gave up (id, until when).
held: u32,
shy: Option<(u32, u64)>,
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,
/// Its fire doctrine (#47): protected objects, sector, reserve, salvo.
pub doctrine: doctrine::Doctrine,
/// 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,
mode: battery::SamMode::Integrated,
radar_role: battery::RadarRole::Multifunction,
radar_on: true,
spent_cells: 0,
preparing: None,
cap_cell: 0,
cap_open: 0.0,
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),
ground_rounds: 0,
ground_request: false,
ground_stock: 0,
air_48n6_stock: 0,
bursts: spec.gun.as_ref().map_or(0, |g| g.bursts),
cooldown: 40,
gun_cooldown: 0,
far_cooldown: 0,
held: 0,
shy: None,
reloading: 0,
target: None,
gun_target: None,
dish: 0.0,
recoil: 0.0,
flash: 0,
kills: 0,
fired: 0,
roles: spec.roles,
doctrine: doctrine::Doctrine::default(),
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}: {} перезарядок, RM48U {}, снятых ПВО {}", self.cargo, self.ground_stock, self.air_48n6_stock),
SamKind::Tzm => return format!("{text}: на борту {}/2, RM48U {}, снятых ПВО {}{}", self.cargo, self.ground_stock, self.air_48n6_stock, 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 let Some(p) = self.preparing {
text += &format!(" · открытие ТПК №{}", p.cell + 1);
}
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 {
if self.kind == SamKind::S400 {
text += &format!(" ПВО {} · RM48U {} / {} ячеек", self.missiles, self.ground_rounds, self.cells());
} else {
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,
/// Cruise flights that ran out of fuel for making no headway (`STALL_TICKS`).
pub stalled: u32,
/// Systems just deployed by hand and not yet checked for standing in the land: (id, until tick).
settling: Vec<(u32, u64)>,
/// What crashed, where, and its aim (balance measurements only).
#[cfg(test)]
pub crashed: Vec<(DroneKind, DVec3, DVec3)>,
/// Interceptors that blew up short of a ridge: kind, age, distance to the target, the target's height over the land under it (balance measurements only).
#[cfg(test)]
pub scrubs: Vec<(MissileKind, u32, f64, f64, DVec3, f64, f64)>,
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,
/// Last chance (`/airdef lastchance`): a system fires at a class outside its roles when it is about to hit.
pub last_chance: bool,
/// The heavy systems take long shots on the big radar's cue (a switch for the balance tests).
pub far_shots: 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,
/// Launch sites found by the radars, and the counter-strikes on them (#101).
pub strikes: strike::Strikes,
/// A raid being planned.
plan: Option<raid::Plan>,
/// The vehicles' ground routes, and the jobs the trucks found out of reach (#94).
routes: route::Routes,
/// 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,
stalled: 0,
settling: Vec::new(),
#[cfg(test)]
crashed: Vec::new(),
#[cfg(test)]
scrubs: Vec::new(),
launched: 0,
damage: true,
seed: seed.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1,
truth: false,
shader: false,
hold: false,
last_chance: true,
far_shots: true,
horizons: HashMap::new(),
later: Vec::new(),
queue: Vec::new(),
sites: sites::Sites::default(),
strikes: strike::Strikes::new(),
plan: None,
routes: Default::default(),
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.strikes.pending.clear();
for sam in &mut self.sams { sam.cancel_prepare(); }
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();
}
/// A system just put down by the player is looked at for a few seconds: one standing inside the land (a player
/// who fell into a chunk that had not loaded) is brought up to the surface. Buried, its interceptors would
/// strike the dirt above it and the raid's aim at it would lie under the land. Only a spot whose block is known
/// solid and whose land height is known is moved, to that height.
pub fn settle(&mut self, id: u32) {
self.settling.push((id, self.tick + 100));
}
fn step_settling(&mut self, ground: &mut Ground) {
let now = self.tick;
let sams = &mut self.sams;
self.settling.retain(|&(id, until)| {
let Some(sam) = sams.iter_mut().find(|s| s.id == id) else { return false };
let p = sam.pos;
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
if ground.world.is_known_open(x, y, z, now) {
return false;
}
if ground.world.is_known_solid(x, y, z, now) {
if let Some(g) = ground.at(p, now) {
if g > p.y + 1.5 && g < p.y + 96.0 {
sam.pos.y = g;
}
return false;
}
}
now < until
});
}
/// 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.step_settling(ground);
self.release_queue(ground, out);
self.begin_recording_if_active();
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.prepare_ground_strikes(out);
self.step_sams(ground, out);
self.step_missiles(ground, out);
self.step_strikes(ground, player, 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; an aim inside the land (a mark
// set from a spot the player stood in the ground) is the surface over it, else the strike counts as a crash.
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 || (g > d.target.y + 1.5 && !spec.decoy && spec.ballistic.is_none() && g < d.target.y + 96.0))
{
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 let Some((event, pitch)) = d.kind.engine_voice() {
out.engine(d.id, event, d.pos, d.vel, 0.8, pitch, true);
}
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));
#[cfg(test)]
{
self.crashed.push((d.kind, d.pos, d.target));
}
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;
}
// Out of fuel: no nearer its aim for a long time (it circles, or waits for what never comes): it falls
// where it is, a wreck like any other, and the raid is not held open by it.
if d.pace.stalled(d.age, d.pos.distance(d.target), d.route.len()) {
d.flight = Flight::Falling;
d.age = 0;
d.vel *= 0.35;
self.stalled += 1;
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));
}
}
let (need, blind) = terrain::required_height(ground, d.pos, &lines, spec.clearance, &climb);
d.nav.need = need.unwrap_or(f64::NEG_INFINITY);
d.nav.blind = blind;
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.nav.blind { 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 mut height = base.max(safe).max(d.nav.dive + 1.0);
// Blind: it does not come down over land nobody has told of; it holds the height it has.
if d.nav.blind && d.low && to_target > 300.0 {
height = height.max(d.pos.y);
}
let full = climb.pitch.tan();
// Blind (land ahead unknown): to the transit height at once, not at the gentle slope of a descent plan.
let urgent = (need.is_finite() && d.pos.y < need) || (d.nav.blind && d.pos.y < base - 2.0);
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 > MAX_FLIGHT {
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));
#[cfg(test)]
{
self.crashed.push((d.kind, d.pos, d.target));
}
}
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 % 10 == 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);
}
}
// The engine is one source that follows the drone (#95).
if let Some((event, pitch)) = d.kind.engine_voice() {
out.engine(d.id, event, d.pos, d.vel, 0.8, pitch, false);
}
}
for (drone, pos) in splits {
self.emit_combat_event(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.emit_combat_event(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.emit_combat_event(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,
pace: Pace::default(),
});
}
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, ground, 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);
ground.world.expect_blast(pos, 8.0, self.tick);
}
}
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>, ground: &Ground, 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), ground, 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, ground: &Ground, 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.emit_combat_event(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, ground, 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) {
for sam in &mut self.sams {
sam.sync_tube_cells();
if (self.hold && !sam.preparing.is_some_and(|p| p.ground)) || !sam.alive() || sam.moving() || sam.reloading > 0 || sam.part(Part::Launcher) <= 0.0 {
sam.cancel_prepare();
}
}
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.far_cooldown = sam.far_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().saturating_sub(sam.ground_rounds);
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 doc = &me.doctrine;
let needed = |d: &Drone| doc.needed(d);
let opts = why::Opts { last_chance: self.last_chance, far_shots: self.far_shots };
let target = spec.missile.as_ref().map(|load| {
// A launcher with nothing to fire (reloading, empty, failed) claims no target: others take it.
if me.missiles == 0 || me.reloading > 0 || me.part(Part::Launcher) <= 0.0 || me.preparing.is_some_and(|p| p.ground) {
return None;
}
let radars: Vec<&Sam> = if me.mode == battery::SamMode::LauncherOnly || (me.kind.far_shooter() && opts.far_shots) { self.sams.iter().filter(|s| s.kind == SamKind::Radar).collect() } else { Vec::new() };
let cued = |id: u32| !radars.is_empty() && why::radar_cue(me, &radars, id, now);
let basis = |tr: &Track, k: f64, role_in_reach: bool| {
drone(tr).filter(|_| open(tr.id)).and_then(|d| why::basis(me, load, tr, d, now, claimed(d), k, opts, cued(tr.id), role_in_reach))
};
// A target held too long without a launch (the mount can't lay on it, the land) is let go,
// and not taken again for a while: another system gets it.
let shy = |id: u32| me.shy.is_some_and(|(s, until)| s == id && until > now);
let stuck = me.held > why::HOLD_MAX;
let keep = had_target.filter(|t| !stuck && tracks.iter().any(|tr| tr.id == *t && basis(tr, 1.1, false).is_some()));
keep.or_else(|| {
let free = |tr: &Track| drone(tr).is_some_and(|d| claimed(d) < needed(d));
// Long shots only while nothing of the system's roles is in its normal reach.
let role_in_reach = !radars.is_empty() && tracks.iter().any(|tr| free(tr) && basis(tr, 1.0, true) == Some(why::Basis::Role));
tracks
.iter()
.filter(|tr| free(tr) && !shy(tr.id) && basis(tr, 1.0, role_in_reach).is_some())
.filter_map(|tr| drone(tr).map(|d| (tr, 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, doc, 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);
let sam = &mut self.sams[i];
if target.is_some() && target == had_target {
sam.held += 1;
} else {
if let (Some(h), true) = (had_target, sam.held > why::HOLD_MAX) {
sam.shy = Some((h, now + why::SHY_TICKS));
}
sam.held = 0;
}
sam.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];
if sam.preparing.is_some_and(|p| !p.ground && Some(p.target) != sam.target) { sam.cancel_prepare(); }
if let Some(p) = sam.preparing.filter(|p| !p.ground) {
sam.cap_open = (now.saturating_sub(p.start) as f32 / launch::OPEN_TICKS as f32).clamp(0.0, 1.0);
}
// 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 && sam.cells() > sam.ground_rounds) || (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
};
// Nothing to fire at: the mount watches the nearest track of its classes (a reaction a turn
// shorter), else it goes back to the rest position, under the drive's limits like any other turn.
let (mount, _) = sam.mount();
let watch = sam
.tracks
.iter()
.filter(|tr| tr.age(now) <= sight::MISSILE_AGE && drone_at.get(&tr.id).is_some_and(|&k| roles & self.drones[k].kind.spec().class.bit() != 0 && self.drones[k].alive()))
.min_by(|a, b| a.est(now).distance(mount).total_cmp(&b.est(now).distance(mount)))
.map(|tr| why::aim_wants(mount, tr.est(now), tr.vel, spec.gun.as_ref().map_or(5.0, |g| g.shell)));
match (watch, spec.family) {
(Some((_, yaw, pitch)), Family::Trainable) => {
let _ = pitch;
sam.aim_towards(yaw, None)
}
(Some((_, yaw, _)), Family::Vertical) if matches!(spec.muzzle, Muzzle::Cells) => sam.aim_towards(yaw, None),
(Some((_, yaw, pitch)), Family::Gun | Family::Turret) => sam.aim_towards(yaw, Some(pitch)),
_ => 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);
}
// A fresh working own-radar fire-control track does not depend on seeing the target.
// Guns without that radar picture still take the darkness penalty unless illuminated.
let radar_fire_control = sam.radar_up()
&& sam.track(gt).is_some_and(|tr| tr.own_within(now, sight::GUN_AGE));
let dark = lights::hit_factor(self.night, self.lit.contains(&t.id), radar_fire_control);
// 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 && !sam.preparing.is_some_and(|p| p.ground);
// 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 = if sam.animated_tubes() { sam.preparing.map_or_else(|| sam.next_tube_cell(), |p| p.cell) } else { 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 {
sam.cancel_prepare();
retarget(&mut missile_on, Some(mt), None);
sam.target = None;
} else if ready && aligned && sam.prepare_tube(mt, now) {
sam.eject_cell(n);
// Past its normal reach: a long shot, and the next waits.
if sam.kind.far_shooter() && t.is_some_and(|t| t.distance(sam.pos) >= load.max) {
sam.far_cooldown = why::FAR_COOLDOWN;
}
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);
self.scene_event(muzzle, record::SceneKind::MissileLaunch { missile: id, sam, target, kind, sound: event, pitch: 1.0 });
} else {
out.particles("minecraft:flash", muzzle, 0.0, DVec3::ZERO, 0.0, 1);
if spec.flame > 0.0 {
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" };
let pitch = 0.95 + self.vis.f() as f32 * 0.1;
out.sound(event, muzzle, 1.0, pitch);
self.scene_event(muzzle, record::SceneKind::MissileLaunch { missile: id, sam, target, kind, sound: event, pitch });
}
}
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, ground, 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, mut energy_lost) = (0u32, 0u32, 0u32, 0u32);
let (mut blocked, mut scrubbed) = (0u32, 0u32);
let mut impacts = HashMap::new();
let mut ignitions = Vec::new();
let mut burnouts = Vec::new();
let mut energy_losses = Vec::new();
#[cfg(test)]
let mut scrub_log = Vec::new();
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();
// Where each launcher stands and how far its missiles reach: a hit past that is less likely (`why::reach_fit`).
let launchers: HashMap<u32, (DVec3, f64)> = self.sams.iter().filter_map(|s| Some((s.id, (s.pos, s.kind.spec().missile.as_ref()?.max)))).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.engine_lit();
let coast = m.age > spec.cold + m.kind.burn_ticks();
if m.age == spec.cold + m.kind.burn_ticks() + 1 {
burnouts.push((m.pos, m.id, m.kind));
}
let speed = if !lit {
(m.vel.length() * 0.97).max(0.4)
} else if lit {
(m.vel.length() + spec.accel).min(spec.speed)
} else {
m.vel.length() * 0.995
};
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
&& m.age > spec.cold
&& 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, now);
if fail {
scrubbed += 1;
#[cfg(test)]
{
let over = target.and_then(|t| ground.at(t.pos, now).map(|g| t.pos.y - g)).unwrap_or(f64::NAN);
let dist = target.map_or(f64::NAN, |t| t.pos.distance(m.pos));
let mover = ground.at(m.pos, now).map_or(f64::NAN, |g| m.pos.y - g);
let from = launchers.get(&m.sam).map_or(0.0, |l| m.pos.distance(l.0));
if scrub_log.len() < 30 {
scrub_log.push((m.kind, m.age, dist, over, m.pos, mover, from));
}
}
done.push((m.id, target.map(|t| t.id), false, m.sam));
continue;
}
turn(dir, want, spec.turn * if coast { (speed / spec.speed).clamp(0.15, 1.0) } else { 1.0 })
}
// 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 = (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) * launchers.get(&m.sam).map_or(1.0, |(at, max)| why::reach_fit(*max, at.distance(m.pos)));
let kill = self.rng.chance(spec.pk * fit * ratio);
// The same swept closest approach determines the physical impact, not the
// target's end-of-tick location. Velocities are per simulation tick.
impacts.insert(m.id, (t.pos - t.vel + t.vel * k, (m.vel - t.vel).length()));
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;
}
// A spent motor cannot sustain guidance once it has lost most of its speed.
if coast && speed < spec.speed * 0.25 && target.is_some() {
energy_lost += 1;
energy_losses.push((m.pos, m.id, m.kind, speed));
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 && m.age == spec.cold + 1 {
let sound = (spec.cold > 0 && spec.flame > 0.0).then_some(if m.kind == MissileKind::M77n6 { "airdef:launch.s500" } else { "airdef:launch.s400" });
ignitions.push((m.pos, m.id, m.kind, sound));
}
if lit {
// The motor is one source that follows the missile from ignition on (#95).
let (event, gain, pitch) = m.kind.engine_voice();
out.engine(0x4000_0000 | m.id, event, m.pos, m.vel, gain, pitch, false);
}
if lit && spec.flame > 0.0 {
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);
}
}
}
for (pos, missile, kind, sound) in ignitions {
self.scene_event(pos, record::SceneKind::MotorIgnition { missile, kind, sound });
}
for (pos, missile, kind) in burnouts {
self.scene_event(pos, record::SceneKind::MotorBurnout { missile, kind });
}
for (pos, missile, kind, speed) in energy_losses {
self.scene_event(pos, record::SceneKind::MotorEnergyLost { missile, kind, speed });
}
#[cfg(test)]
self.scrubs.extend(scrub_log);
self.note(|t| {
t.missed += missed;
t.expired += expired;
t.energy_lost += energy_lost;
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) = impacts[&mid];
let _ = self.shoot_down_by(t, sam, Some(mid), Impact::missile(m.kind, at, closing), ground, 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.engine_lit() && 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);
}
}
}
}