bb931ab95c
Co-authored-by: Codex <codex@openai.com>
641 lines
27 KiB
Rust
641 lines
27 KiB
Rust
//! Air defence can be hit too: a blast near a system damages it (it turns
|
||
//! slower, sees less, hits worse, smokes, then burns), a destroyed one leaves
|
||
//! a burning wreck and its magazine cooks off — the more missiles or shells
|
||
//! were left in it, the bigger the blast and the longer the secondary ones.
|
||
//! Marked objects take the same blasts.
|
||
use super::*;
|
||
|
||
/// By blast power (as `explode` grades it): how far it reaches a vehicle, and how hard it hits one at the centre.
|
||
const REACH: [f64; 5] = [4.0, 7.0, 12.0, 18.0, 26.0];
|
||
const HIT: [f64; 5] = [0.2, 0.45, 0.85, 1.25, 1.7];
|
||
/// A unit's working parts, each 1 (whole) to 0 (failed); the hull is `Sam::health` (0 destroys it).
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||
pub enum Part {
|
||
/// The radar (or optics): its reach and its scan.
|
||
Sensor,
|
||
/// The aiming drive of the launcher or turret.
|
||
Mount,
|
||
/// The launcher: cells, reload.
|
||
Launcher,
|
||
/// The gun: it jams.
|
||
Gun,
|
||
/// The running gear: it can't march when it fails.
|
||
Tracks,
|
||
/// The data link: no network when it fails.
|
||
Comms,
|
||
}
|
||
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||
pub enum PartState {
|
||
Ok,
|
||
Damaged,
|
||
Failed,
|
||
}
|
||
|
||
impl Part {
|
||
pub const ALL: [Part; 6] = [Part::Sensor, Part::Mount, Part::Launcher, Part::Gun, Part::Tracks, Part::Comms];
|
||
pub fn name(self) -> &'static str {
|
||
match self {
|
||
Part::Sensor => "РЛС",
|
||
Part::Mount => "привод наведения",
|
||
Part::Launcher => "пусковая",
|
||
Part::Gun => "орудие",
|
||
Part::Tracks => "ходовая",
|
||
Part::Comms => "связь",
|
||
}
|
||
}
|
||
pub fn short(self) -> &'static str {
|
||
match self {
|
||
Part::Sensor => "РЛС",
|
||
Part::Mount => "Прив",
|
||
Part::Launcher => "ПУ",
|
||
Part::Gun => "Ор",
|
||
Part::Tracks => "Ход",
|
||
Part::Comms => "Св",
|
||
}
|
||
}
|
||
fn index(self) -> usize {
|
||
self as usize
|
||
}
|
||
/// Whether this kind of unit has the part at all.
|
||
pub fn on(self, kind: SamKind) -> bool {
|
||
let spec = kind.spec();
|
||
match self {
|
||
Part::Sensor => spec.sensor > 0.0,
|
||
Part::Mount => kind.armed() && kind != SamKind::Light && (spec.missile.is_some() || spec.gun.is_some()),
|
||
Part::Launcher => spec.missile.is_some(),
|
||
Part::Gun => spec.gun.is_some(),
|
||
Part::Tracks => kind.mobile(),
|
||
Part::Comms => true,
|
||
}
|
||
}
|
||
/// Where the part is on the vehicle: right, up, back, blocks at scale 1 (the nose points to -back).
|
||
fn place(self, spec: &super::units::SamSpec) -> (f64, f64, f64) {
|
||
match self {
|
||
Part::Sensor => (0.0, spec.mast / spec.scale, 1.0),
|
||
Part::Mount => (0.0, spec.mount.0, -spec.mount.1),
|
||
Part::Launcher => (0.0, spec.mount.0, -spec.mount.1 + 0.4),
|
||
Part::Gun => (0.0, spec.mount.0, -spec.mount.1 - 1.0),
|
||
Part::Tracks => (0.0, 0.3, 0.0),
|
||
Part::Comms => (0.0, 1.6, 1.8),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Below this a part is damaged (works worse), at 0 it has failed.
|
||
pub const WORN: f64 = 0.6;
|
||
|
||
/// The state of every part: `Part::ALL`'s order.
|
||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||
pub struct Comps(pub [f64; 6]);
|
||
|
||
impl Default for Comps {
|
||
fn default() -> Self {
|
||
Self([1.0; 6])
|
||
}
|
||
}
|
||
|
||
impl Comps {
|
||
pub fn get(&self, p: Part) -> f64 {
|
||
self.0[p.index()]
|
||
}
|
||
pub fn state(&self, p: Part) -> PartState {
|
||
let v = self.get(p);
|
||
if v <= 0.0 {
|
||
PartState::Failed
|
||
} else if v < WORN {
|
||
PartState::Damaged
|
||
} else {
|
||
PartState::Ok
|
||
}
|
||
}
|
||
pub fn whole(&self) -> bool {
|
||
self.0.iter().all(|&v| v >= 1.0)
|
||
}
|
||
/// Parts of this kind that are not whole, as "РЛС 40%, ходовая отказ".
|
||
pub fn describe(&self, kind: SamKind) -> String {
|
||
Part::ALL
|
||
.iter()
|
||
.filter(|p| p.on(kind) && self.get(**p) < 1.0)
|
||
.map(|p| match self.state(*p) {
|
||
PartState::Failed => format!("{} отказ", p.short()),
|
||
_ => format!("{} {:.0}%", p.short(), self.get(*p) * 100.0),
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(", ")
|
||
}
|
||
/// The weakest part alone, for a one-line status (the full list is on the monitor).
|
||
pub fn worst(&self, kind: SamKind) -> String {
|
||
Part::ALL
|
||
.iter()
|
||
.filter(|p| p.on(kind) && self.get(**p) < 1.0)
|
||
.min_by(|a, b| self.get(**a).total_cmp(&self.get(**b)))
|
||
.map(|p| match self.state(*p) {
|
||
PartState::Failed => format!("{} отказ", p.short()),
|
||
_ => format!("{} {:.0}%", p.short(), self.get(*p) * 100.0),
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
}
|
||
|
||
/// Health points an object loses at the centre of a blast.
|
||
const OBJECT_HIT: [f64; 5] = [2.0, 6.0, 14.0, 26.0, 40.0];
|
||
|
||
/// Three cheap game samples across a hull. Known cover leaves residual splash;
|
||
/// unknown geometry keeps the previous unattenuated damage (not a claim of air).
|
||
/// Zero async wait freezes the pre-destruction cache and avoids late double damage.
|
||
fn blast_exposure(ground: &Ground, from: DVec3, centre: DVec3, size: f64, now: u64) -> f64 {
|
||
let across = horizontal(centre - from).cross(DVec3::Y).normalize_or(DVec3::X) * size;
|
||
[centre, centre - across, centre + across].into_iter().map(|to| {
|
||
if ground.world.blast_cover(from, to, now) == Some(true) { 0.15 } else { 1.0 }
|
||
}).sum::<f64>() / 3.0
|
||
}
|
||
|
||
impl MissileKind {
|
||
/// The explosive a missile left in a burning launcher adds (TNT).
|
||
fn charge(self) -> f64 {
|
||
match self {
|
||
MissileKind::Pac3 => 0.8,
|
||
MissileKind::M57e6 => 0.45,
|
||
MissileKind::M48n6 => 1.8,
|
||
MissileKind::M77n6 => 2.2,
|
||
MissileKind::IrisT => 0.6,
|
||
MissileKind::Amraam => 0.5,
|
||
MissileKind::M9m317 => 1.2,
|
||
MissileKind::M9m338 => 0.4,
|
||
MissileKind::P1sun => 0.1,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A strike that ends farther than this from its aim point misses (the lethal radius of a point target).
|
||
pub(super) const MISS_RADIUS: f64 = 3.0;
|
||
/// A system's hull, as a cylinder: its radius (× scale) and how far above the ground it reaches (× scale).
|
||
const HULL_RADIUS: f64 = 2.2;
|
||
const HULL_TOP: f64 = 3.0;
|
||
|
||
impl DroneKind {
|
||
/// Strike accuracy: the CEP (blocks; half the strikes land within it, the scatter is Rayleigh)
|
||
/// and the share of strikes with a rare gross navigation fault, which lands beyond `MISS_RADIUS`.
|
||
pub(super) fn accuracy(self) -> (f64, f64) {
|
||
match self {
|
||
DroneKind::Shahed => (1.2, 0.03),
|
||
DroneKind::Geran3 => (1.0, 0.03),
|
||
DroneKind::Lancet => (0.8, 0.02),
|
||
DroneKind::Gerbera => (1.5, 0.04),
|
||
DroneKind::Kh101 => (1.3, 0.05),
|
||
DroneKind::Kalibr => (1.3, 0.05),
|
||
DroneKind::Kh22 => (1.6, 0.06),
|
||
DroneKind::Zircon => (1.5, 0.06),
|
||
DroneKind::Iskander => (1.4, 0.06),
|
||
DroneKind::Kinzhal => (1.5, 0.06),
|
||
// The bus and its blocks: the blocks are aimed by the split, so only the bus's own error applies.
|
||
DroneKind::Oreshnik | DroneKind::OreshnikBlock | DroneKind::OreshnikSub => (1.6, 0.08),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Sim {
|
||
/// Apply combat consequences first, independently of whether a recorder is active.
|
||
pub(super) fn emit_combat_event(&mut self, pos: DVec3, kind: record::EventKind) {
|
||
match kind {
|
||
record::EventKind::Crash { power, .. } => self.ignite(pos, power, None),
|
||
record::EventKind::Hit { power, sam, .. } => {
|
||
let host = sam.and_then(|(_, i)| self.sams.get(i)).map(|s| s.id);
|
||
self.ignite(pos, power.max(1), host)
|
||
}
|
||
record::EventKind::SamLost { sam, .. } => self.ignite(pos, 2, Some(sam)),
|
||
record::EventKind::Strike { result, .. } if record::strike_power(result) > 0 => self.ignite(pos, record::strike_power(result), None),
|
||
_ => {}
|
||
}
|
||
self.record_event(pos, kind);
|
||
}
|
||
|
||
/// The aim error of a new strike: drawn once from the seed, the drone keeps it for its whole flight.
|
||
pub(super) fn aim_error(&mut self, kind: DroneKind) -> DVec3 {
|
||
let (cep, gross) = kind.accuracy();
|
||
let angle = self.rng.f() * std::f64::consts::TAU;
|
||
let r = if self.rng.chance(gross) {
|
||
MISS_RADIUS + cep * (0.5 + 2.5 * self.rng.f())
|
||
} else {
|
||
// Rayleigh with its median at the CEP.
|
||
let u = 1.0 - self.rng.f();
|
||
cep * (-2.0 * u.ln() / (2.0 * std::f64::consts::LN_2)).sqrt()
|
||
};
|
||
DVec3::new(angle.cos() * r, 0.0, angle.sin() * r)
|
||
}
|
||
|
||
/// An armed warhead reached `pos` (its last step was `vel`): a system whose hull it swept through is
|
||
/// destroyed at once, whatever the blast would have done to it. A near miss is only splash.
|
||
pub(super) fn direct_hit(&mut self, pos: DVec3, vel: DVec3, tnt: u32, out: &mut Out) {
|
||
if !self.damage || tnt == 0 {
|
||
return;
|
||
}
|
||
let from = pos - vel;
|
||
for i in 0..self.sams.len() {
|
||
let sam = &self.sams[i];
|
||
if !sam.alive() {
|
||
continue;
|
||
}
|
||
let s = sam.kind.spec().scale;
|
||
let hit = (0..=8).any(|k| {
|
||
let p = from.lerp(pos, k as f64 / 8.0);
|
||
let across = DVec3::new(p.x - sam.pos.x, 0.0, p.z - sam.pos.z).length();
|
||
across <= HULL_RADIUS * s && p.y >= sam.pos.y - 1.0 && p.y <= sam.pos.y + HULL_TOP * s
|
||
});
|
||
if hit {
|
||
self.destroy(i, out);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A blast of `power` (0 to 4) at `pos`: the systems and objects round it take damage.
|
||
pub(super) fn blast_damage(&mut self, pos: DVec3, power: usize, ground: &Ground, out: &mut Out) {
|
||
let (reach, hit) = (REACH[power], HIT[power]);
|
||
let mut destroyed = Vec::new();
|
||
for (i, sam) in self.sams.iter_mut().enumerate() {
|
||
if !sam.alive() {
|
||
continue;
|
||
}
|
||
// The vehicle is a few blocks long: measured from its hull.
|
||
let d = (sam.pos.distance(pos) - 2.5).max(0.0);
|
||
if d >= reach {
|
||
continue;
|
||
}
|
||
let k = 1.0 - d / reach;
|
||
let cover = blast_exposure(ground, pos, sam.pos + DVec3::Y * (1.5 * sam.kind.spec().scale), sam.kind.spec().scale, self.tick);
|
||
let before = sam.health;
|
||
sam.health -= cover * hit * k * k * if d < 1.0 { 1.5 } else { 1.0 };
|
||
if sam.health <= 0.0 {
|
||
destroyed.push(i);
|
||
continue;
|
||
}
|
||
if before >= 0.5 && sam.health < 0.5 {
|
||
out.messages.push(format!("{} №{} повреждён", sam.kind.name(), i + 1));
|
||
}
|
||
// The parts take the blast by their own distance from it: the side it came from decides which.
|
||
let spec = sam.kind.spec();
|
||
let rot = DQuat::from_rotation_y(sam.heading);
|
||
for part in Part::ALL {
|
||
if !part.on(sam.kind) {
|
||
continue;
|
||
}
|
||
let (x, y, back) = part.place(spec);
|
||
let at = sam.pos + rot * DVec3::new(x, y, back) * spec.scale;
|
||
let size = if part == Part::Tracks { 2.5 } else { 1.0 };
|
||
let dp = (at.distance(pos) - size).max(0.0);
|
||
if dp >= reach {
|
||
continue;
|
||
}
|
||
let kp = 1.0 - dp / reach;
|
||
let roll = dice(self.seed, self.tick, sam.id as u64, part as u64);
|
||
// The parts are softer than the hull: a blast the hull survives can still put one out.
|
||
let dmg = cover * hit * kp * kp * (0.7 + roll) * 1.5 * if dp < 1.0 { 1.4 } else { 1.0 };
|
||
let was = sam.comps.state(part);
|
||
let v = &mut sam.comps.0[part as usize];
|
||
*v = (*v - dmg).max(0.0);
|
||
let now = sam.comps.state(part);
|
||
if now != was && now != PartState::Ok {
|
||
let word = if now == PartState::Failed { "отказал(а)" } else { "повреждён(а)" };
|
||
out.messages.push(format!("{} №{}: {} {word}", sam.kind.name(), i + 1, part.name()));
|
||
}
|
||
}
|
||
sam.fit_cells();
|
||
}
|
||
for i in destroyed {
|
||
self.destroy(i, out);
|
||
}
|
||
for (i, t) in self.targets.iter().enumerate() {
|
||
let d = t.distance(pos);
|
||
let r = reach * 1.6;
|
||
if d < r {
|
||
if let Some(h) = self.target_health.get_mut(i) {
|
||
let before = *h;
|
||
let cover = blast_exposure(ground, pos, *t + DVec3::Y * 0.8, 0.75, self.tick);
|
||
*h = (*h - cover * OBJECT_HIT[power] * (1.0 - d / r)).max(0.0);
|
||
if before > 0.0 && *h <= 0.0 {
|
||
out.messages.push(format!("ОБЪЕКТ №{} УНИЧТОЖЕН", i + 1));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// System `i` destroyed: its magazine goes up, the wreck burns.
|
||
fn destroy(&mut self, i: usize, out: &mut Out) {
|
||
let now = self.tick;
|
||
let sam = &mut self.sams[i];
|
||
sam.health = 0.0;
|
||
sam.burn = 900;
|
||
sam.state = SamState::Destroyed;
|
||
sam.target = None;
|
||
sam.gun_target = None;
|
||
sam.reloading = 0;
|
||
sam.tracks.clear();
|
||
sam.seen.clear();
|
||
sam.own.clear();
|
||
let spec = sam.kind.spec();
|
||
let missiles = sam.missiles;
|
||
let bursts = sam.bursts;
|
||
sam.missiles = 0;
|
||
sam.bursts = 0;
|
||
let pos = sam.pos + DVec3::Y;
|
||
out.messages.push(format!("{} №{} УНИЧТОЖЕН", sam.kind.name(), i + 1));
|
||
let (id, kind) = (sam.id, sam.kind);
|
||
// A depot's stock (or a truck's load) goes up with it.
|
||
let stock = if matches!(self.sams[i].kind, SamKind::Depot | SamKind::Tzm) { self.sams[i].cargo } else { 0 };
|
||
self.sams[i].cargo = 0;
|
||
let typed = self.sams[i].ground_stock + self.sams[i].air_48n6_stock;
|
||
self.sams[i].ground_stock = 0;
|
||
self.sams[i].air_48n6_stock = 0;
|
||
self.sams[i].ground_rounds = 0;
|
||
self.sams[i].ground_request = false;
|
||
self.sams[i].ground_return = false;
|
||
self.sams[i].return_cargo = false;
|
||
let missiles = missiles + stock * 2 + typed;
|
||
let charge = spec.missile.as_ref().map_or(0.0, |m| m.kind.charge() * missiles as f64) + bursts as f64 / 60.0
|
||
+ stock as f64 * 1.5;
|
||
// The vehicle's own fuel and the first of its load at once, then the rest cooking off.
|
||
let first = (1.0 + charge * 0.6).round().clamp(1.0, 8.0) as u32;
|
||
self.later.push((now + 2, pos, first, false));
|
||
for k in 0..missiles.min(40) {
|
||
let when = now + 15 + (k as u64) * 12 + (self.rng.f() * 25.0) as u64;
|
||
let at = pos + DVec3::new(self.rng.s() * 3.0, self.rng.f() * 2.0, self.rng.s() * 3.0);
|
||
let tnt = if charge > 4.0 && self.rng.chance(0.4) { 2 } else { 1 };
|
||
self.later.push((when, at, tnt, true));
|
||
}
|
||
self.emit_combat_event(pos, record::EventKind::SamLost { sam: id, kind, index: i });
|
||
// Shells pop in strings.
|
||
for k in 0..(bursts / 12).min(14) {
|
||
let when = now + 10 + (k as u64) * 6 + (self.rng.f() * 8.0) as u64;
|
||
let at = pos + DVec3::new(self.rng.s() * 2.0, self.rng.f() * 1.5, self.rng.s() * 2.0);
|
||
self.later.push((when, at, 0, true));
|
||
}
|
||
}
|
||
|
||
/// The blasts whose time has come.
|
||
pub(super) fn step_later(&mut self, ground: &mut Ground, player: Option<DVec3>, out: &mut Out) {
|
||
let now = self.tick;
|
||
let due: Vec<_> = self.later.iter().filter(|l| l.0 <= now).copied().collect();
|
||
self.later.retain(|l| l.0 > now);
|
||
for (_, pos, tnt, small) in due {
|
||
self.explode(pos, tnt, small, ground, player, out);
|
||
if small {
|
||
// Something flies out of a burning launcher.
|
||
out.particles("minecraft:lava", pos, 0.8, DVec3::new(0.0, 0.3, 0.0), 0.3, 12);
|
||
out.particles("minecraft:firework", pos, 0.5, DVec3::new(0.0, 0.2, 0.0), 0.25, 10);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Damaged systems smoke, wrecks burn.
|
||
pub(super) fn step_wrecks(&mut self, out: &mut Out) {
|
||
let tick = self.tick;
|
||
for sam in &mut self.sams {
|
||
let top = sam.pos + DVec3::Y * (1.5 * sam.kind.spec().scale);
|
||
if !sam.alive() {
|
||
if sam.burn > 0 {
|
||
sam.burn -= 1;
|
||
out.particles("minecraft:flame", top, 1.0, DVec3::new(0.0, 0.05, 0.0), 0.03, 3);
|
||
out.particles("minecraft:large_smoke", top, 0.8, DVec3::new(0.0, 0.12, 0.0), 0.03, 2);
|
||
if tick % 8 == 0 {
|
||
out.particles("minecraft:campfire_cosy_smoke", top, 1.0, DVec3::new(0.0, 0.07, 0.0), 0.01, 1);
|
||
}
|
||
if tick % 40 == 0 {
|
||
out.sound("minecraft:block.fire.ambient", top, 3.0, 0.7);
|
||
}
|
||
} else if tick % 12 == 0 {
|
||
out.particles("minecraft:campfire_signal_smoke", top, 0.6, DVec3::new(0.0, 0.05, 0.0), 0.01, 1);
|
||
}
|
||
} else if sam.health < 0.7 {
|
||
let every = if sam.health < 0.35 { 2 } else { 5 };
|
||
if tick % every == 0 {
|
||
out.particles("minecraft:large_smoke", top, 0.4, DVec3::new(0.0, 0.08, 0.0), 0.02, 1);
|
||
}
|
||
if sam.health < 0.35 && tick % 3 == 0 {
|
||
out.particles("minecraft:flame", top, 0.3, DVec3::ZERO, 0.01, 1);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
/// A roll from 0 to 1 fixed by the seed and three numbers: the same events roll the same in every run,
|
||
/// and the simulation's own random sequence is left alone.
|
||
pub(super) fn dice(seed: u64, a: u64, b: u64, c: u64) -> f64 {
|
||
let mut x = seed ^ a.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ b.wrapping_mul(0xBF58_476D_1CE4_E5B9) ^ c.wrapping_mul(0x94D0_49BB_1331_11EB);
|
||
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||
x ^= x >> 31;
|
||
(x >> 11) as f64 / (1u64 << 53) as f64
|
||
}
|
||
|
||
impl Sam {
|
||
/// Its wreck still burns.
|
||
pub fn burning(&self) -> bool {
|
||
self.burn > 0
|
||
}
|
||
/// A part's state, `Ok` for one this unit does not have.
|
||
pub fn part(&self, p: Part) -> f64 {
|
||
if p.on(self.kind) { self.comps.get(p) } else { 1.0 }
|
||
}
|
||
pub fn part_state(&self, p: Part) -> PartState {
|
||
if p.on(self.kind) { self.comps.state(p) } else { PartState::Ok }
|
||
}
|
||
pub fn parts_text(&self) -> String {
|
||
self.comps.describe(self.kind)
|
||
}
|
||
/// The aiming drive's share of its speed: a failed one doesn't turn at all.
|
||
pub(super) fn drive_fit(&self) -> f64 {
|
||
let h = self.part(Part::Mount);
|
||
if h <= 0.0 { 0.0 } else { 0.3 + 0.7 * h }
|
||
}
|
||
/// The radar's share of its reach; the scan is slower by the same.
|
||
pub(super) fn radar_fit(&self) -> f64 {
|
||
let h = self.part(Part::Sensor);
|
||
if h <= 0.0 { 0.0 } else { 0.4 + 0.6 * h }
|
||
}
|
||
/// The gun's share of its hit chance.
|
||
pub(super) fn gun_fit(&self) -> f64 {
|
||
0.5 + 0.5 * self.part(Part::Gun)
|
||
}
|
||
/// Its launcher's cells still good: a damaged one holds fewer missiles.
|
||
pub(super) fn cells(&self) -> u32 {
|
||
let Some(load) = &self.kind.spec().missile else { return 0 };
|
||
let h = self.part(Part::Launcher);
|
||
if h <= 0.0 { 0 } else { ((load.magazine as f64 * (0.4 + 0.6 * h)).ceil() as u32).clamp(1, load.magazine) }
|
||
}
|
||
/// Missiles above what the cells hold are lost with them.
|
||
pub fn fit_cells(&mut self) {
|
||
if self.kind.spec().missile.is_some() {
|
||
self.ground_rounds = self.ground_rounds.min(self.cells());
|
||
self.missiles = self.missiles.min(self.cells().saturating_sub(self.ground_rounds));
|
||
}
|
||
}
|
||
/// Ticks a reload takes: longer with a damaged launcher.
|
||
pub(super) fn reload_time(&self) -> u32 {
|
||
let r = self.kind.spec().reload.max(1);
|
||
(r as f64 * (1.0 + 1.5 * (1.0 - self.part(Part::Launcher)))).round() as u32
|
||
}
|
||
/// The gun fires (not jammed for good, not jammed now).
|
||
pub(super) fn gun_up(&self) -> bool {
|
||
self.part(Part::Gun) > 0.0 && self.jam == 0
|
||
}
|
||
/// How far it can march: the running gear's share of the speed (0 can't).
|
||
pub(super) fn drive_speed(&self) -> f64 {
|
||
let h = self.part(Part::Tracks);
|
||
if h <= 0.0 { 0.0 } else { 0.35 + 0.65 * h }
|
||
}
|
||
/// The data link's reach, 0 when the link has failed.
|
||
pub(super) fn link_range(&self) -> f64 {
|
||
let h = self.part(Part::Comms);
|
||
if h <= 0.0 { 0.0 } else { super::sight::LINK * (0.3 + 0.7 * h) }
|
||
}
|
||
}
|
||
|
||
impl Sim {
|
||
/// Repairs every system (wrecks rebuilt, magazines full) and object.
|
||
pub fn repair(&mut self) -> u32 {
|
||
let mut n = 0;
|
||
for sam in &mut self.sams {
|
||
if sam.health < 1.0 || !sam.comps.whole() {
|
||
n += 1;
|
||
}
|
||
sam.comps = Comps::default();
|
||
sam.jam = 0;
|
||
let spec = sam.kind.spec();
|
||
sam.health = 1.0;
|
||
sam.burn = 0;
|
||
sam.state = SamState::Search;
|
||
sam.reloading = 0;
|
||
sam.empty = false;
|
||
// Rebuilt where it stands: a march under way is called off.
|
||
sam.dest = None;
|
||
sam.march = 0;
|
||
sam.missiles = spec.missile.as_ref().map_or(0, |m| m.magazine.saturating_sub(sam.ground_rounds));
|
||
sam.bursts = spec.gun.as_ref().map_or(0, |g| g.bursts);
|
||
}
|
||
for h in &mut self.target_health {
|
||
*h = 100.0;
|
||
}
|
||
// The wrecks' ammunition still to cook off goes with them.
|
||
self.later.clear();
|
||
self.fires.extinguish();
|
||
n
|
||
}
|
||
}
|
||
|
||
/// A wreck: the hull sunk and turned, the launcher or turret thrown down beside it (a live system's, or a replay's ghost).
|
||
pub(super) fn draw_wreck_of(sam: &Sam, out: &mut Out) {
|
||
{
|
||
let s = sam.kind.spec().scale;
|
||
let tilt = DQuat::from_rotation_y(sam.heading) * DQuat::from_rotation_z(0.12) * DQuat::from_rotation_x(-0.06);
|
||
let hull = sam.pos + DVec3::Y * (0.5 * s - 0.35);
|
||
let side = DQuat::from_rotation_y(sam.heading) * DVec3::new(1.8 * s, 0.0, 0.6 * s);
|
||
let thrown = DQuat::from_rotation_y(sam.heading + 1.1) * DQuat::from_rotation_z(1.2);
|
||
let parts = &sam.kind.spec().parts;
|
||
let (body, part) = (parts.body, parts.launcher.or(parts.pack));
|
||
out.model(sam.id, super::units::wreck(body), hull, tilt, s, false);
|
||
if let Some(part) = part {
|
||
out.model(sam.id + 1, super::units::wreck(part), sam.pos + side + DVec3::Y * 0.4 * s, thrown, s, false);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod cover_tests {
|
||
use super::*;
|
||
|
||
struct CoverWorld { wall: bool, opening: bool }
|
||
impl world::WorldSource for CoverWorld {
|
||
fn solid(&self, x: i32, _y: i32, z: i32) -> Option<bool> {
|
||
Some(self.wall && x == 8 && !(self.opening && z == 8))
|
||
}
|
||
}
|
||
|
||
fn cache(src: &CoverWorld) -> Ground {
|
||
let mut ground = Ground::default();
|
||
let mut answers = Vec::new();
|
||
for x in -1..=1 { for z in -1..=1 {
|
||
let sec = (x, 4, z);
|
||
answers.push(world::SectionAnswer { sec, fill: world::section_of(src, sec) });
|
||
}}
|
||
ground.world.answer(answers, 10);
|
||
ground
|
||
}
|
||
|
||
fn victim() -> Sim {
|
||
let mut sim = Sim::new(7);
|
||
sim.tick = 10;
|
||
let id = sim.id();
|
||
sim.sams.push(Sam::new(id, SamKind::S400, DVec3::new(10.5, 70.0, 8.5), 0.0));
|
||
sim.targets.push(DVec3::new(10.5, 71.0, 8.5));
|
||
sim.target_health.push(100.0);
|
||
sim
|
||
}
|
||
|
||
#[test]
|
||
fn known_wall_and_opening_attenuate_hull_parts_and_objects() {
|
||
let from = DVec3::new(0.5, 71.5, 8.5);
|
||
let mut results = Vec::new();
|
||
for src in [CoverWorld { wall: false, opening: false }, CoverWorld { wall: true, opening: false }, CoverWorld { wall: true, opening: true }] {
|
||
let mut sim = victim();
|
||
sim.blast_damage(from, 2, &cache(&src), &mut Out::default());
|
||
results.push((1.0 - sim.sams[0].health, 1.0 - sim.sams[0].comps.get(Part::Tracks), 100.0 - sim.target_health[0]));
|
||
}
|
||
for i in 0..3 {
|
||
let open = [results[0].0, results[0].1, results[0].2][i];
|
||
let wall = [results[1].0, results[1].1, results[1].2][i];
|
||
let gap = [results[2].0, results[2].1, results[2].2][i];
|
||
assert!(open > 0.0);
|
||
assert!((wall / open - 0.15).abs() < 1e-9);
|
||
assert!(wall < gap && gap < open, "sample {i}: wall {wall}, gap {gap}, open {open}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn unresolved_cover_has_bounded_zero_wait_and_no_late_damage() {
|
||
let from = DVec3::new(0.5, 71.5, 8.5);
|
||
let mut unknown = Ground::default();
|
||
assert_eq!(unknown.world.blast_cover(from, DVec3::new(10.5, 71.5, 8.5), 10), None);
|
||
let mut sim = victim();
|
||
let mut open = victim();
|
||
sim.blast_damage(from, 2, &unknown, &mut Out::default());
|
||
open.blast_damage(from, 2, &cache(&CoverWorld { wall: false, opening: false }), &mut Out::default());
|
||
assert_eq!(sim.sams[0].health, open.sams[0].health);
|
||
let health = sim.sams[0].health;
|
||
let hit_points = sim.target_health[0];
|
||
assert!(unknown.world.take_wanted(10).is_empty());
|
||
assert!(unknown.world.take_rays(10).is_empty());
|
||
// A reply, even one proving cover, is not a second combat event.
|
||
unknown.world.answer(vec![world::SectionAnswer { sec: (0, 4, 0), fill: world::Fill::Full }], 11);
|
||
assert_eq!(sim.sams[0].health, health);
|
||
assert_eq!(sim.target_health[0], hit_points);
|
||
}
|
||
|
||
#[test]
|
||
fn direct_contact_is_not_weakened_by_cover_and_blast_does_not_mutate_world() {
|
||
let mut sim = victim();
|
||
let ground = cache(&CoverWorld { wall: true, opening: false });
|
||
let from = DVec3::new(0.5, 71.5, 8.5);
|
||
let end = DVec3::new(10.5, 71.5, 8.5);
|
||
assert_eq!(ground.world.blast_cover(from, end, 10), Some(true));
|
||
sim.blast_damage(from, 2, &ground, &mut Out::default());
|
||
assert_eq!(ground.world.blast_cover(from, end, 10), Some(true));
|
||
let at = sim.sams[0].pos + DVec3::Y;
|
||
sim.direct_hit(at, DVec3::X * 2.0, 1, &mut Out::default());
|
||
assert!(!sim.sams[0].alive());
|
||
}
|
||
|
||
#[test]
|
||
fn stale_cover_is_unresolved_and_source_voxel_does_not_shield_everything() {
|
||
let ground = cache(&CoverWorld { wall: true, opening: false });
|
||
let from = DVec3::new(0.5, 71.5, 8.5);
|
||
let end = DVec3::new(10.5, 71.5, 8.5);
|
||
assert_eq!(ground.world.blast_cover(from, end, 1210), None);
|
||
let source = DVec3::new(8.5, 71.5, 8.5);
|
||
assert_eq!(ground.world.blast_cover(source, end, 10), Some(false));
|
||
}
|
||
}
|