f3985d5363
Co-authored-by: Codex <codex@openai.com>
3270 lines
141 KiB
Rust
3270 lines
141 KiB
Rust
//! 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(>).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);
|
||
}
|
||
}
|
||
}
|
||
}
|