Files
airdef/airdef-mod/src/lib.rs
T
2026-10-30 15:43:11 +03:00

1975 lines
87 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! AirDef: long-range drones and air defence, drawn as 3D models through the
//! AirDef fork's `render.set-models`. The simulation is `sim`; this file is
//! the glue: the player's items and commands, the HUD, saving, and handing
//! the simulation's output to the game.
use glam::DVec3;
use horizon_mod_sdk::{
Mod, Registration, chat, command, game,
hud::{self, Corner},
input::{self, Event},
render::{self, Lighting, ModelInstanceId, ParticleSpawn, PointLight, SpotLight},
storage,
ui::{self, ButtonWidget, ItemWidget, ListWidget, Screen, Widget},
};
mod cinema;
mod commands;
mod layout;
mod film;
mod monitor;
mod panel;
mod radar;
mod replay;
mod smoke;
pub mod sim;
mod scenario;
mod wave;
use sim::{Class, DroneKind, Ground, Out, Sam, SamKind, Sim};
const ITEMS: [&str; 29] = [
"airdef:designator",
"airdef:launch_shahed",
"airdef:launch_geran3",
"airdef:launch_lancet",
"airdef:launch_kh101",
"airdef:launch_kalibr",
"airdef:launch_kh22",
"airdef:launch_zircon",
"airdef:launch_iskander",
"airdef:launch_kinzhal",
"airdef:launch_oreshnik",
"airdef:deploy_patriot",
"airdef:deploy_gepard",
"airdef:deploy_pantsir",
"airdef:deploy_s400",
"airdef:deploy_s500",
"airdef:deploy_irist",
"airdef:deploy_nasams",
"airdef:deploy_buk",
"airdef:deploy_tor",
"airdef:deploy_p1sun",
"airdef:deploy_radar",
"airdef:deploy_depot",
"airdef:deploy_tzm",
"airdef:deploy_ew",
"airdef:deploy_light",
"airdef:mover",
"airdef:flare_gun",
"airdef:remover",
];
struct AirDef {
smoke: smoke::Smoke,
sim: Sim,
ground: Ground,
dirty: bool,
/// Wall-clock-free pacing of autosaves: the tick before which none is written (at most one a
/// second while guns fire, and a pause after a failed write).
next_save: u64,
hud_shown: bool,
/// The system the monitor shows (K cycles), none: hidden.
monitor: Option<usize>,
monitor_shown: bool,
/// Every system's monitor in a grid (J).
grid: bool,
/// The systems the grid shows (ids), the busiest, chosen every few seconds.
grid_ids: Vec<u32>,
/// The raid the wave menu orders.
wave: wave::WaveConfig,
/// A replay playing (it takes the screen and the mouse wheel).
replay: Option<replay::Player>,
/// An MP4 export in progress (`/airdef replay export`).
export: Option<Export>,
/// The effects preset a film replaced: (shader, hd).
film_prev: Option<(bool, bool)>,
/// Counts the replay/film sessions started; an export belongs to the one it began with.
session: u64,
/// The world drawn beyond the render distance (replays and cameras see far off).
far: bool,
/// The game's log of block changes (`render.world-log-start`): a replay shows the world as
/// it was at its moment, not the holes the raid left (None where the game has no such log).
block_log: Option<u32>,
/// A replay's time view was set (so it is cleared when the replay ends).
time_view: bool,
/// The unit the move remote picked (its id), waiting for where to go.
moving: Option<u32>,
/// The HD lighting preset (the engine's lights, bloom, tone mapping).
hd: bool,
/// The world whose state is loaded (`game.world-id`), once one is.
world: Option<Option<String>>,
/// The systems' and objects' health as last saved, to save again when it changes.
health_mark: u64,
/// The menu's page while it is open.
menu: Option<Page>,
/// Where the player is, for the sounds' delay and far variants.
listener: DVec3,
/// The picture punch (#51) the HD exposure and bloom were last set for.
punch: f32,
/// Sounds on their way to the player at the speed of sound.
pending: Vec<PendingSound>,
/// The handles `render.model-id` gave the model names (valid under `model_generation`); none:
/// a name the game doesn't know.
model_ids: std::collections::HashMap<&'static str, Option<u32>>,
model_generation: u32,
/// The instances handed to the game, kept between ticks so nothing is allocated for them.
by_id: Vec<ModelInstanceId>,
}
/// What `/airdef replay export` was asked for.
struct ExportOpts {
name: String,
which: String,
fps: u32,
size: (u32, u32),
crf: u32,
audio: bool,
seconds: Option<f64>,
}
#[derive(PartialEq)]
enum Stage {
Planning,
Arming,
Recording,
Finishing,
}
struct Export {
/// The session (`AirDef::session`) whose film this export records.
session: u64,
opts: ExportOpts,
stage: Stage,
ticks: u32,
}
/// Whether a replay or film may be started now: not while an export records (stop it first).
fn switch_allowed(exporting: bool) -> Result<(), String> {
if exporting {
Err("Экспорт идёт: дождитесь конца или /airdef replay stop".into())
} else {
Ok(())
}
}
/// A sound heard later: when, what, where, and how loud and high it was.
struct PendingSound {
due: u64,
name: String,
at: DVec3,
volume: f32,
pitch: f32,
}
/// Blocks a tick the sound travels (343 m/s).
const SOUND_SPEED: f64 = 17.15;
/// Past this the far variant plays: muffled, more rolling echo.
const FAR: f64 = 140.0;
/// Impulsive sounds (blasts, launches, shots, shock waves) come late from afar and have far variants.
fn travels(event: &str) -> bool {
["airdef:blast", "airdef:launch", "airdef:gun", "airdef:boom"].iter().any(|p| event.starts_with(p))
}
/// A page of the menu.
#[derive(Clone, Copy, PartialEq)]
enum Page {
Main,
Roles(SamKind),
/// The raid's make-up, then its parameters and the launch.
Wave,
WaveGo,
/// Fire, repair, effects, damage.
More,
/// The last raid's events and its replays.
Replays,
}
const MENU_KINDS: u32 = 1;
const BTN_HOLD: u32 = 10;
const BTN_SHADER: u32 = 11;
const BTN_DAMAGE: u32 = 12;
const BTN_MONITOR: u32 = 13;
const BTN_GRID: u32 = 14;
const BTN_WAVE: u32 = 15;
const BTN_REPAIR: u32 = 16;
const WAVE_KINDS: u32 = 2;
const BTN_PRESET: u32 = 30;
const BTN_APPROACH: u32 = 31;
const BTN_DIST: u32 = 32;
const BTN_AIM: u32 = 33;
const BTN_LAUNCH: u32 = 34;
const BTN_NEXT: u32 = 35;
const BTN_MORE: u32 = 17;
const BTN_REPLAYS: u32 = 18;
const REPLAY_EVENTS: u32 = 3;
const BTN_MAP: u32 = 40;
const BTN_FULL: u32 = 41;
const BTN_REEL: u32 = 42;
const BTN_FAR: u32 = 19;
const BTN_CLASS: u32 = 100;
const BTN_ALL: u32 = 120;
const BTN_DEFAULT: u32 = 121;
const BTN_NONE: u32 = 122;
const BTN_BACK: u32 = 199;
fn say(text: &str) {
chat::show(text);
}
/// What the effects draw this tick (`Mod::shown`).
struct Shown {
blasts: Vec<sim::Blast>,
/// The clock the blasts' `start` is on: the simulation's ticks, or the recording's.
now: f32,
/// 1 minus the share of the frame's partial tick the effects advance with.
hold: f32,
replay: bool,
}
fn player() -> Option<game::Player> {
game::get_player()
}
fn eye(p: &game::Player) -> DVec3 {
DVec3::new(p.x, p.y + 1.62, p.z)
}
/// The player's view direction (Minecraft's yaw and pitch, degrees).
fn view(p: &game::Player) -> DVec3 {
let (yaw, pitch) = (f64::from(p.yaw).to_radians(), f64::from(p.pitch).to_radians());
DVec3::new(-yaw.sin() * pitch.cos(), -pitch.sin(), yaw.cos() * pitch.cos())
}
/// Where the crosshair meets a block, up to 512 blocks.
fn aim() -> Option<DVec3> {
game::raycast(512.0).map(|h| DVec3::new(h.x, h.y, h.z))
}
impl AirDef {
fn use_item(&mut self, id: &str) {
let Some(p) = player() else { return };
let mut out = Out::default();
match id {
"airdef:designator" => match aim() {
Some(at) => {
if let Some(i) = self.sim.targets.iter().position(|t| t.distance(at) < 3.0) {
self.sim.targets.remove(i);
if i < self.sim.target_health.len() {
self.sim.target_health.remove(i);
}
say("Цель снята");
} else {
self.sim.targets.push(at);
self.sim.target_health.push(100.0);
say(&format!("Цель отмечена: {:.0} {:.0} {:.0}", at.x, at.y, at.z));
}
self.dirty = true;
}
None => say("Нет блока под прицелом"),
},
_ if id.starts_with("airdef:launch_") => {
let Some(kind) = DroneKind::from_key(&id["airdef:launch_".len()..]) else { return };
// Where you look, else the nearest marked target.
let target = aim().or_else(|| self.sim.nearest_target(eye(&p)));
match target {
Some(t) => {
let from = eye(&p) + view(&p) * 2.0 + DVec3::Y;
self.sim.launch(kind, from, view(&p), t, &mut out);
}
None => say("Наведитесь на блок или отметьте цель"),
}
}
_ if id.starts_with("airdef:deploy_") => {
let Some(kind) = SamKind::from_key(&id["airdef:deploy_".len()..]) else { return };
match aim() {
Some(at) => {
let d = view(&p);
let heading = (-d.x).atan2(-d.z);
let sid = self.sim.id();
let mut sam = Sam::new(sid, kind, at, heading);
if let Some(r) = self.sim.roles.get(&kind) {
sam.roles = *r;
}
self.sim.sams.push(sam);
out.sound("minecraft:block.anvil.place", at, 2.0, 0.7);
say(&format!("{} развёрнут", kind.name()));
self.dirty = true;
}
None => say("Наведитесь на землю, где поставить комплекс"),
}
}
"airdef:mover" => {
let Some(at) = aim() else {
say("Наведитесь на технику или на точку на земле");
return;
};
let near = self
.sim
.sams
.iter()
.enumerate()
.filter(|(_, s)| s.pos.distance(at) < 6.0 && s.alive())
.min_by(|a, b| a.1.pos.distance(at).total_cmp(&b.1.pos.distance(at)))
.map(|(i, s)| (i, s.id));
match (near, self.moving) {
// A unit pointed at: picked (or another picked instead).
(Some((i, id)), _) => {
let s = &self.sim.sams[i];
if s.kind.mobile() {
self.moving = Some(id);
say(&format!("{} №{} выбран: укажите, куда ехать", s.kind.name(), i + 1));
} else {
say(&format!("{} не перемещается", s.kind.name()));
}
}
// The ground: the picked unit drives there.
(None, Some(id)) => {
if let Some(i) = self.sim.sams.iter().position(|s| s.id == id)
&& self.sim.order_move(i, at)
{
let s = &self.sim.sams[i];
say(&format!("{} №{}: свёртывание и марш, {}", s.kind.name(), i + 1, panel::distance(s.pos.distance(at))));
out.sound("minecraft:ui.button.click", at, 1.0, 1.0);
}
self.moving = None;
}
(None, None) => say("Сначала наведитесь на технику, которую перемещать"),
}
}
"airdef:flare_gun" => {
self.sim.fire_flare(eye(&p) + view(&p), view(&p), &mut out);
}
"airdef:remover" => {
let at = aim().unwrap_or(eye(&p));
if let Some(i) = self
.sim
.sams
.iter()
.enumerate()
.filter(|(_, s)| s.pos.distance(at) < 10.0)
.min_by(|a, b| a.1.pos.distance(at).total_cmp(&b.1.pos.distance(at)))
.map(|(i, _)| i)
{
let s = self.sim.sams.remove(i);
say(&format!("{} свёрнут", s.kind.name()));
self.dirty = true;
} else {
say("Рядом нет комплекса ПВО");
}
}
_ => {}
}
self.emit(out);
}
/// The HD lighting's lights: flames, glowing warheads, flares, muzzle flashes, tracers,
/// blasts (a flash, then the fireball's glow dying down), burning wrecks and the
/// searchlights' spots.
fn lights(&self, out: &Out) -> Vec<PointLight> {
let shown = self.shown();
let mut lights: Vec<PointLight> = Vec::new();
let mut add = |id: u32, at: DVec3, rgb: (f32, f32, f32), intensity: f32, radius: f32| {
if lights.len() < 500 && intensity > 0.05 {
lights.push(PointLight { id, x: at.x, y: at.y, z: at.z, color: rgb, intensity, radius: radius.clamp(1.0, 256.0) });
}
};
let tick = shown.now as u64;
let flicker = |id: u32| 0.85 + 0.15 * (((tick as u32).wrapping_mul(2654435761) ^ id.wrapping_mul(97)) % 100) as f32 / 100.0;
// Blasts first, the nearest first: a crowded sky never costs the key intercept its light.
let viewer = self.viewer(&shown);
let mut near: Vec<&sim::Blast> = shown.blasts.iter().collect();
if let Some(e) = viewer {
near.sort_by(|a, b| a.at.distance_squared(e).total_cmp(&b.at.distance_squared(e)));
}
for (rank, b) in near.into_iter().enumerate() {
let age = (shown.now - b.start as f32).max(0.0);
let size = b.size;
let glow = (-age / (b.life as f32 * 0.15).max(1.0)).exp();
let flash = (-age * 0.35).exp() * 4.0;
// Its own id range: the models' and the systems' ids are small numbers.
let id = 0x6100_0000 | (b.id & 0x00ff_ffff);
let centre = b.at + DVec3::Y * size as f64 * 0.5;
add(id, centre, (1.0, 0.58, 0.25), size * size * 18.0 * (glow + flash), size * 14.0);
// The punch (#51): a white burst that lights the land, the smoke and the wrecks far round
// for a moment; for the nearest few, scaled by the blast.
if rank < 6 {
let burst = (-age / 2.0).exp();
add(0x6300_0000 | (b.id & 0x00ff_ffff), centre, (1.0, 0.9, 0.74), size * size * 95.0 * burst, (size * 30.0).min(256.0));
}
}
for m in out.models.iter().filter(|m| m.glow) {
let s = m.scale.max(0.1);
match m.name {
"airdef:flame" => add(m.id, m.at, (1.0, 0.55, 0.2), 45.0 * s * flicker(m.id), 32.0 * s.sqrt()),
"airdef:geran3_flame" => add(m.id, m.at, (1.0, 0.6, 0.25), 25.0 * s * flicker(m.id), 22.0),
"airdef:reentry" => add(m.id, m.at, (1.0, 0.6, 0.32), 60.0 * s, 40.0),
"airdef:flare" => add(m.id, m.at, (1.0, 0.93, 0.78), 420.0 * s, 110.0),
"airdef:flash" => add(m.id, m.at, (1.0, 0.8, 0.45), 30.0, 14.0),
"airdef:tracer" => add(m.id, m.at, (1.0, 0.25, 0.1), 2.5, 6.0),
_ => {}
}
}
// A replay shows its own flames (the models of the ghosts) and none of the live world's.
if shown.replay {
return lights;
}
// Fires burn on for minutes: a flickering, wide glow that dies down with the flames.
for f in self.sim.fires.list.iter().filter(|f| f.shown) {
let (fl, sm) = f.level(tick);
if f.quiet {
continue;
}
let glow = fl.max(0.12 * sm * (1.0 - (tick.saturating_sub(f.start)) as f32 / f.life as f32));
let id = 0x6200_0000 | (f.id & 0x00ff_ffff);
add(id, f.at + DVec3::Y * 1.2, (1.0, 0.5, 0.17), 70.0 * f.size * glow * flicker(f.id), 20.0 + 10.0 * f.size);
}
for s in &self.sim.sams {
if !s.alive() && s.burning() {
add(s.id + 15, s.pos + DVec3::Y * 1.5, (1.0, 0.5, 0.18), 80.0 * flicker(s.id), 30.0);
}
// Without the renderer's spot lights, a searchlight's spot where its beam meets the target.
if !self.sim.spot_hd
&& s.kind == SamKind::Light
&& let Some(t) = s.target.and_then(|t| self.sim.drones.iter().find(|d| d.id == t))
&& self.sim.lit.contains(&t.id)
{
add(s.id + 14, t.pos, (0.95, 0.97, 1.0), 220.0, 30.0);
}
}
lights
}
/// The searchlights' spot lights (HD): a cone from the head along its real direction, as far
/// as the beam runs free; at most 16, the renderer draws the nearest.
fn spots(&self) -> Vec<SpotLight> {
let shown = self.shown();
// A replay lights with the recording's searchlights, where and as they pointed.
let beams: Vec<sim::lights::Beam> = if shown.replay {
match self.replay.as_ref().and_then(|r| self.sim.replay(r.rec).map(|rec| rec.beams(f64::from(shown.now)))) {
Some(b) => b,
None => Vec::new(),
}
} else {
self.sim.sams.iter().filter_map(|s| s.spot_beam()).collect()
};
beams
.into_iter()
.take(16)
.map(|b| SpotLight {
id: b.id,
x: b.origin.x,
y: b.origin.y,
z: b.origin.z,
dx: b.dir.x as f32,
dy: b.dir.y as f32,
dz: b.dir.z as f32,
color: (0.95, 0.97, 1.0),
intensity: 90_000.0,
range: (b.free + 3.5).min(1024.0) as f32,
inner_angle: sim::lights::BEAM_INNER as f32,
outer_angle: sim::lights::BEAM_OUTER as f32,
beam: 1.0,
})
.collect()
}
/// The effects preset: none, the post pass, or the post pass under the HD lighting.
fn set_effects(&mut self, shader: bool, hd: bool) {
self.sim.shader = shader;
if shader {
render::set_post(include_str!("../shaders/effects.wgsl"));
} else {
render::clear_post();
}
self.hd = shader && hd;
self.apply_hd();
if !self.hd {
self.sim.spot_hd = false;
}
self.dirty = true;
}
/// Switches the HD lighting.
fn apply_hd(&self) {
render::set_lighting(Lighting { hd: self.hd, exposure: 1.0, bloom: 0.55 });
// (The punch of the next blast sets its own.)
if !self.hd {
render::set_lights(&[]);
let _ = render::set_spot_lights(&[]);
}
}
/// Hands the simulation's output to the game.
/// The post pass's parameters: blasts and glowing parts (shaders/effects.wgsl).
fn shader_params(&self, out: &Out) -> Vec<f32> {
let shown = self.shown();
let mut p = vec![0.0f32; 4];
p[0] = shown.now;
p[3] = if self.smoke.hd { 2.0 } else if self.hd { 1.0 } else { 0.0 };
let mut blasts: Vec<&sim::Blast> = shown.blasts.iter().rev().take(48).collect();
// The shader composites the volumes front to back with one shared transmittance, so
// the nearest to the player goes first. This is exact for volumes that do not
// overlap; where two intersect, no order by centre is right along every ray.
if let Some(e) = self.viewer(&shown) {
blasts.sort_by(|a, b| a.at.distance_squared(e).total_cmp(&b.at.distance_squared(e)));
}
p[1] = blasts.len() as f32;
for b in &blasts {
p.extend([b.at.x as f32, b.at.y as f32, b.at.z as f32, b.size]);
p.extend([b.start as f32, b.life as f32, 0.0, 0.0]);
}
p.resize(4 * 97, 0.0);
let mut lights = 0;
for m in out.models.iter().filter(|m| m.glow) {
if lights >= 79 {
break;
}
let (r, g, b, i) = match m.name {
"airdef:flame" | "airdef:geran3_flame" => (1.0, 0.55, 0.18, 1.2),
"airdef:flash" => (1.0, 0.85, 0.5, 2.0),
"airdef:target" => (1.0, 0.1, 0.1, 0.5),
"airdef:tracer" => (1.0, 0.12, 0.05, 0.9),
"airdef:reentry" => (1.0, 0.6, 0.3, 1.4),
"airdef:flare" => (1.0, 0.94, 0.78, 1.2),
_ => continue,
};
// The shader lights about six times the radius round a light and draws a glow of
// its radius: a flare lights some sixty blocks round, the rest about their size.
let radius = if m.name == "airdef:flare" { 10.0 } else { m.scale * 1.6 };
p.extend([m.at.x as f32, m.at.y as f32, m.at.z as f32, radius]);
p.extend([r, g, b, i]);
lights += 1;
}
p[2] = lights as f32;
// The last vec4: how much of the frame's partial tick the effects run on (0 live; a
// slowed or paused replay runs on its own time, not the frame's).
p.resize(1024, 0.0);
p[1020] = shown.hold;
p
}
/// The smoke volumes the renderer draws and the clock they are aged by: the live ones, or (in a
/// replay) the blasts' billows at the replay's moment, never the live raid's trails and fires.
/// The billows are the live ones' own function (`Smoke::segments`), run on the recording's
/// blasts and time with the live state set aside for the call.
fn shown_smoke(&mut self) -> (f64, Vec<render::SmokeSegment>) {
let shown = self.shown();
if !shown.replay {
return (self.sim.tick as f64, self.smoke.segments(&self.sim));
}
// The recording alone, as a function of its time: the live Sim is not touched.
let segments = match self.sim.replay(self.replay.as_ref().map_or(0, |r| r.rec)) {
Some(rec) => smoke::replay_smoke(rec, f64::from(shown.now)),
None => Vec::new(),
};
(f64::from(shown.now), segments)
}
/// Where the picture is seen from: the replay's camera, or the player's eyes.
fn viewer(&self, shown: &Shown) -> Option<DVec3> {
match &self.replay {
Some(r) if shown.replay => Some(r.eye()),
_ => player().map(|pl| eye(&pl)),
}
}
/// The blasts the effects show and the clock they run on: the live ones, or (in a replay) the
/// recording's at the replay's moment, never the live world's.
fn shown(&self) -> Shown {
if let Some(r) = &self.replay
&& r.filming()
&& let Some(rec) = self.sim.replay(r.rec)
{
return Shown { blasts: sim::record::replay_blasts(rec, r.time()), now: r.time() as f32, hold: 1.0 - r.rate().clamp(0.0, 1.0), replay: true };
}
Shown { blasts: self.sim.blasts.clone(), now: self.sim.tick as f32, hold: 0.0, replay: false }
}
/// The models to the game by handle: each name crosses once, the buffer stays allocated.
fn send_models(&mut self, models: &[sim::Model]) {
let generation = render::model_generation();
if generation != self.model_generation {
// The game's registry changed (or this is the first tick): ask again.
self.model_generation = generation;
self.model_ids.clear();
}
self.by_id.clear();
for m in models {
let ids = &mut self.model_ids;
let handle = *ids.entry(m.name).or_insert_with(|| render::model_id(m.name));
// A name the game's registry doesn't have draws nothing, by name too.
let Some(model) = handle else { continue };
let r = m.rotation.as_quat();
self.by_id.push(ModelInstanceId {
id: m.id,
model,
x: m.at.x,
y: m.at.y,
z: m.at.z,
rotation: (r.x, r.y, r.z, r.w),
scale: m.scale,
glow: m.glow,
});
}
if !render::set_models_by_id(generation, &self.by_id) {
// The registry changed between the two calls: ask again next tick.
self.model_generation = u32::MAX;
}
}
fn emit(&mut self, out: Out) {
if self.sim.shader {
render::set_params(&self.shader_params(&out));
}
if self.hd {
// The camera's reaction to a near blast: a brief exposure and bloom punch.
let shown = self.shown();
let p = self.viewer(&shown).map_or(0.0, |e| {
shown.blasts.iter().map(|b| cinema::punch(b.size, shown.now - b.start as f32, b.at.distance(e))).fold(0.0, f32::max)
});
if (p - self.punch).abs() > 0.01 || (p == 0.0 && self.punch != 0.0) {
self.punch = p;
render::set_lighting(Lighting { hd: true, exposure: 1.0 + 0.4 * p, bloom: 0.55 + 0.9 * p });
}
render::set_lights(&self.lights(&out));
self.sim.spot_hd = render::set_spot_lights(&self.spots());
let (clock, segments) = self.shown_smoke();
self.smoke.hd = render::set_smoke(clock, &segments);
} else {
self.smoke.hd = false;
}
if !out.models.is_empty() || self.sim.tick % 20 == 0 {
self.send_models(&out.models);
}
if !out.particles.is_empty() {
let hd_smoke = self.smoke.hd && !self.replay.as_ref().is_some_and(|r| r.filming());
let spawns: Vec<ParticleSpawn> = out
.particles
.into_iter()
// The long smoke is the renderer's volume then.
.filter(|p| !(hd_smoke && matches!(p.kind, "minecraft:campfire_cosy_smoke" | "minecraft:campfire_signal_smoke" | "minecraft:large_smoke")))
.take(512)
.map(|p| ParticleSpawn {
particle: p.kind.to_string(),
x: p.at.x,
y: p.at.y,
z: p.at.z,
spread: (p.spread[0], p.spread[1], p.spread[2]),
velocity: (p.velocity.x as f32, p.velocity.y as f32, p.velocity.z as f32),
speed: p.speed,
count: p.count,
color: p.color,
scale: p.scale,
})
.collect();
render::spawn_particles(&spawns);
}
let now = self.sim.tick;
// Blasts play layered (cinema.rs), the nearest ones in full.
let mut booms: Vec<(f64, usize, DVec3, f32, f32)> = Vec::new();
for (event, at, volume, pitch) in out.sounds {
let dist = at.distance(self.listener);
if let Some(power) = cinema::blast_power(event) {
booms.push((dist, power, at, volume, pitch));
} else if travels(event) {
// Seen first, heard later; from afar muffled.
let name = if dist > FAR { format!("{event}_far") } else { event.to_string() };
let delay = (dist / SOUND_SPEED) as u64;
if delay == 0 {
render::play_sound(&name, at.x, at.y, at.z, volume, pitch);
} else if self.pending.len() < 256 {
self.pending.push(PendingSound { due: now + delay, name, at, volume, pitch });
}
} else {
render::play_sound(event, at.x, at.y, at.z, volume, pitch);
}
}
booms.sort_by(|a, b| a.0.total_cmp(&b.0));
for (rank, (dist, power, at, volume, pitch)) in booms.into_iter().enumerate() {
for l in cinema::layers(power, dist, FAR, volume, pitch, rank) {
// Seen first, heard later: each voice after the sound has travelled.
let delay = (dist / SOUND_SPEED) as u64 + l.delay;
if delay == 0 {
render::play_sound(&l.name, at.x, at.y, at.z, l.volume, l.pitch);
} else if self.pending.len() < 256 {
self.pending.push(PendingSound { due: now + delay, name: l.name, at, volume: l.volume, pitch: l.pitch });
}
}
}
let mut i = 0;
while i < self.pending.len() {
if self.pending[i].due <= now {
let s = self.pending.swap_remove(i);
render::play_sound(&s.name, s.at.x, s.at.y, s.at.z, s.volume, s.pitch);
} else {
i += 1;
}
}
if !out.commands.is_empty() {
command::run_quiet(&out.commands);
}
if let Some((strength, seconds)) = out.shake {
render::shake(strength, seconds);
}
for m in out.messages {
say(&m);
}
}
fn draw_hud(&mut self, player: DVec3, yaw: f32) {
let air: Vec<&sim::Drone> = self.sim.drones.iter().filter(|d| d.alive()).collect();
// The command post's grid and replays cover the screen: the other panels wait under them.
if self.grid || self.replay.is_some() || (air.is_empty() && self.sim.sams.is_empty() && self.sim.missiles.is_empty()) {
if self.hud_shown {
hud::clear();
render::clear_corner();
self.hud_shown = false;
}
return;
}
self.hud_shown = true;
hud::set_panel(Corner::TopRight, &panel::summary(&self.sim, &air, player));
render::set_corner(render::Corner::BottomRight, radar::SIZE, radar::SIZE + 12.0, &radar::draw(&self.sim, player, yaw));
}
// ------------------------------------------------ saving
/// The state as text, lines of words: the autosave's format, and the body of a scenario.
/// `fresh`: every system and object whole, magazines full (a scenario to replay from the start).
fn state_text(&self, fresh: bool, view: bool) -> String {
let mut text = String::new();
for s in &self.sim.sams {
// Then its ammunition (missiles, bursts, out and waiting for a truck, reload ticks
// left) and where it was driving, if anywhere.
let dest = s.dest.filter(|_| !fresh).map_or("- - -".to_string(), |d| format!("{} {} {}", d.x, d.y, d.z));
let new = Sam::new(0, s.kind, s.pos, s.heading);
let (health, cargo, missiles, bursts, empty, reloading) = if fresh {
(new.health, new.cargo, new.missiles, new.bursts, false, 0)
} else {
(s.health, s.cargo, s.missiles, s.bursts, s.empty, s.reloading)
};
// The parts' states (sensor, mount, launcher, gun, tracks, comms) close the line; older saves lack them.
let comps = if fresh { new.comps } else { s.comps };
let parts = comps.0.iter().map(|v| format!("{v:.3}")).collect::<Vec<_>>().join(" ");
text += &format!(
"sam {} {} {} {} {} {} {} {} {} {} {} {} {dest} {parts}\n",
s.kind.key(),
s.pos.x,
s.pos.y,
s.pos.z,
s.heading,
s.roles,
health,
cargo,
missiles,
bursts,
u8::from(empty),
reloading,
);
}
for (i, t) in self.sim.targets.iter().enumerate() {
let h = if fresh { 100.0 } else { self.sim.target_health.get(i).copied().unwrap_or(100.0) };
text += &format!("target {} {} {} {h}\n", t.x, t.y, t.z);
}
text += &format!("damage {}\n", self.sim.damage);
if view {
text += &format!("far {}\n", self.far);
text += &format!("effects {} {}\n", self.sim.shader, self.hd);
}
for (kind, roles) in &self.sim.roles {
text += &format!("roles {} {}\n", kind.key(), roles);
}
text
}
fn save(&mut self) {
let text = self.state_text(false, true);
// Clean only once written: a failed write is tried again after a pause.
match storage::set(&self.state_key(), text.as_bytes()) {
Ok(()) => {
self.dirty = false;
self.next_save = self.sim.tick + 20;
}
Err(e) => {
horizon_mod_sdk::warn(&format!("could not save: {e}"));
self.next_save = self.sim.tick + 200;
}
}
}
fn load(&mut self) {
self.load_from(&self.state_key());
}
fn load_from(&mut self, key: &str) {
let Some(bytes) = storage::get(key) else { return };
if let Err(e) = self.apply_state(&String::from_utf8_lossy(&bytes)) {
horizon_mod_sdk::warn(&format!("saved state not loaded: {e}"));
// The next autosave would write over it: keep the refused file for a look or a repair.
if let Err(e) = storage::set(&format!("{key}-refused"), &bytes) {
horizon_mod_sdk::warn(&format!("could not keep the refused state: {e}"));
}
}
}
/// Adds the state in `text` (`state_text`'s lines; unknown lines are skipped, so older and
/// newer files read alike). The whole text is read and checked first: a bad line refuses all of it,
/// naming the line and field, and the scene stays as it was (#86).
fn apply_state(&mut self, text: &str) -> Result<(), String> {
let layout = layout::Layout::parse(text)?;
self.commit_layout(layout);
Ok(())
}
/// Adds a checked layout to the scene: nothing here can fail.
fn commit_layout(&mut self, layout: layout::Layout) {
for r in layout.sams {
let id = self.sim.id();
let mut sam = Sam::new(id, r.kind, r.pos, r.heading);
if let Some(v) = r.roles {
sam.roles = v;
}
if let Some(h) = r.health {
sam.health = h;
}
if let Some(c) = r.cargo {
sam.cargo = c;
}
// Older saves have no ammunition: full, as before.
if let Some((m, b, empty, reloading)) = r.ammo {
sam.missiles = m.min(sam.missiles);
sam.bursts = b.min(sam.bursts);
sam.empty = empty;
sam.reloading = reloading;
}
// The parts: absent in older saves, which load with every part whole.
for (v, c) in sam.comps.0.iter_mut().zip(&r.comps) {
*v = *c;
}
sam.fit_cells();
self.sim.sams.push(sam);
if let Some(to) = r.dest {
let i = self.sim.sams.len() - 1;
self.sim.order_move(i, to);
}
}
for (pos, hp) in layout.targets {
self.sim.targets.push(pos);
self.sim.target_health.push(hp);
}
if let Some(d) = layout.damage {
self.sim.damage = d;
}
// A scenario's: the random generator, and the wave menu's raid.
if let Some(v) = layout.seed {
self.sim.reseed(v);
}
if let Some(w) = layout.wave {
let w: Vec<&str> = w.iter().map(String::as_str).collect();
self.wave.parse(&w);
}
if let Some(f) = layout.far {
self.far = f;
}
if let Some((shader, hd)) = layout.effects {
self.set_effects(shader, hd);
}
for (kind, r) in layout.roles {
self.sim.roles.insert(kind, r);
}
}
fn give(&self) {
// run-quiet: command.run takes at most 8 lines a call.
let lines: Vec<String> = ITEMS.iter().map(|item| format!("give @s {item}")).collect();
command::run_quiet(&lines);
say(&format!("Выдано пультов: {}", ITEMS.len()));
}
}
impl AirDef {
/// The EW stations: on if none is, else off.
fn toggle_ew(&mut self) {
let on = !self.sim.ew_active();
let (n, hot) = self.sim.set_ew(on);
if !self.sim.sams.iter().any(|s| s.kind == SamKind::Ew && s.alive()) {
say("Нет станций РЭБ: поставьте их пультом");
} else if on {
say(&format!("РЭБ включён: станций {n}{}", if hot > 0 { format!(", остывают {hot}") } else { String::new() }));
} else {
say("РЭБ выключен");
}
}
/// Where the open world's state is kept: a key of its own per world.
fn state_key(&self) -> String {
match self.world.as_ref().and_then(|w| w.as_ref()) {
Some(w) => {
// FNV-1a: storage keys are short, lowercase and plain.
let h = w.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3));
format!("state-{h:016x}")
}
None => "state".into(),
}
}
/// Another world opened: the last one's state saved, a clean simulation with this one's loaded.
fn open_world(&mut self, world: Option<String>) {
if self.world.is_some() && self.dirty {
self.save();
}
if self.replay.is_some() {
self.stop_replay();
}
let shader = self.sim.shader;
let seed = game::get_clock().map_or(0x5eed, |c| c.game_time.wrapping_mul(2654435761) ^ 0x9e37);
self.sim = Sim::new(seed);
self.next_save = 0;
self.sim.shader = shader;
self.ground = Ground::default();
self.monitor = None;
self.grid = false;
self.grid_ids.clear();
// The last world's transient state: its sounds were due on its own
// clock, its picked unit may have another meaning in this one, and
// its models would stay until the next full model list.
self.pending.clear();
self.moving = None;
render::set_models(&[]);
self.far = true;
self.world = Some(world);
self.load();
self.apply_far();
self.dirty = false;
self.health_mark = 0;
}
/// Distant terrain (48 chunks, coarse) while `far` is on: the raid's whole area has land,
/// in the world and in the cameras.
fn apply_far(&self) {
let far = self.far.then_some(render::FarTerrain { distance: 48, reduced: true });
render::set_far_terrain(far);
}
/// The world's blocks as they were at the replay's moment `t` of raid `rec` (the holes the raid
/// made are not there yet); `show: false` puts the world back.
fn show_world_at(&mut self, rec: u32, t: f64, show: bool) {
let view = (|| {
let id = self.block_log.filter(|_| show)?;
let tick = self.sim.replay(rec)?.world_tick(t)?;
Some((id, tick))
})();
if view.is_some() || self.time_view {
render::set_world_time_view(view);
self.time_view = view.is_some();
}
}
/// Tells in the chat how far back the block log lets a replay of raid `rec` show the world.
fn say_block_log(&self, rec: &sim::record::Recording) {
let range = self.block_log.and_then(render::world_log_range);
let first = rec.first_tick();
match (range, first) {
(None, _) => say("Журнал блоков недоступен: в повторе мир такой, как сейчас (воронки уже на месте)"),
(Some((earliest, _)), Some(first)) if earliest > first => say(&format!(
"Журнал блоков начался позже налёта (на {} с): воронки до того уже в мире, он показан с самого раннего известного момента",
(earliest - first) / 20
)),
_ => {}
}
}
/// Plays the last recorded raid: "map", "full", "reel" or (with `event`) a clip.
fn start_replay(&mut self, what: &str, event: Option<usize>) -> Result<(), String> {
switch_allowed(self.export.is_some())?;
let n = self.sim.replays.len();
if n == 0 {
return Err("Повторов пока нет: запишется первый же налёт (когда небо очистится)".into());
}
let rec = &self.sim.replays[n - 1];
let mode = match (what, event) {
(_, Some(i)) if i < rec.events.len() => replay::Player::event_clip(rec, i),
("full", _) => replay::Mode::Full,
("reel", _) => {
let list = replay::reel(rec);
if list.is_empty() {
return Err("В этом налёте нечего показать".into());
}
replay::Mode::Clips { list, at: 0, reel: true }
}
_ => replay::Mode::Map,
};
self.say_block_log(rec);
let player = replay::Player::new(rec.number, mode, &self.sim);
// A film (its quality and effects) or another replay ends properly before this one.
self.end_session();
self.session += 1;
self.replay = Some(player);
self.grid = false;
input::claim_wheel(true);
Ok(())
}
fn stop_replay(&mut self) {
if self.export.take().is_some() {
render::capture_stop(true);
say("Экспорт отменён");
}
self.end_session();
}
/// Ends whatever replay or film shows, on every path: the film quality, the effects, the
/// clean HUD, the views and the time and camera overrides go back to the live game's.
fn end_session(&mut self) {
render::set_film_quality(false);
if let Some((shader, hd)) = self.film_prev.take() {
self.set_effects(shader, hd);
}
self.replay = None;
self.show_world_at(0, 0.0, false);
render::set_time_override(None);
render::set_main_view(None);
input::claim_wheel(false);
render::set_hud(&[]);
render::set_views(&[]);
self.monitor_shown = false;
}
/// Plays the director's film of the last raid at the best quality: HD effects on (restored after).
fn start_film(&mut self, which: &str, export: Option<&ExportOpts>) -> Result<(), String> {
if export.is_none() {
switch_allowed(self.export.is_some())?;
}
let n = self.sim.replays.len();
if n == 0 {
return Err("Повторов пока нет: запишется первый же налёт (когда небо очистится)".into());
}
let which = match which {
"best" => film::Which::Best,
"all" => film::Which::All,
n => film::Which::Top(n.parse().map_err(|_| "/airdef replay film [best|all|N]")?),
};
let rec = &self.sim.replays[n - 1];
let f = film::Film::build(rec, which).ok_or("В этом налёте нечего показать")?;
self.say_block_log(rec);
let mut player = replay::Player::new(rec.number, replay::Mode::Film(Box::new(f)), &self.sim);
if let Some(o) = export {
player.wait = true;
player.clean = true;
player.limit = o.seconds;
}
// What was shown ends first (its effects are remembered below, not lost).
self.end_session();
self.session += 1;
self.replay = Some(player);
self.grid = false;
input::claim_wheel(true);
// The best picture: HD on, the renderer's film quality (full-size world, more cloud and fog steps).
if self.film_prev.is_none() {
self.film_prev = Some((self.sim.shader, self.hd));
}
self.set_effects(true, true);
render::set_film_quality(true);
Ok(())
}
/// `/airdef replay export [имя] [best|all|N] [fps=60] [size=1920x1080] [crf=14] [sec=N] [noaudio]`.
fn start_export(&mut self, args: &[&str]) -> Result<(), String> {
let mut o = ExportOpts { name: String::new(), which: "best".into(), fps: 60, size: (1920, 1080), crf: 14, audio: true, seconds: None };
for a in args {
if let Some(v) = a.strip_prefix("fps=") {
o.fps = v.parse().map_err(|_| "fps=число")?;
} else if let Some(v) = a.strip_prefix("size=") {
let (w, h) = v.split_once('x').ok_or("size=1920x1080")?;
o.size = (w.parse().map_err(|_| "size=1920x1080")?, h.parse().map_err(|_| "size=1920x1080")?);
} else if let Some(v) = a.strip_prefix("crf=") {
o.crf = v.parse().map_err(|_| "crf=число")?;
} else if let Some(v) = a.strip_prefix("sec=") {
o.seconds = Some(v.parse().map_err(|_| "sec=число")?);
} else if *a == "noaudio" {
o.audio = false;
} else if matches!(*a, "best" | "all") || a.parse::<usize>().is_ok() {
o.which = a.to_string();
} else {
o.name = a.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')).collect();
}
}
if self.export.is_some() {
return Err("Экспорт уже идёт (/airdef replay stop — отмена)".into());
}
if o.name.is_empty() {
let n = self.sim.replays.last().map_or(0, |r| r.number);
o.name = format!("raid{n}-{}", o.which);
}
let which = o.which.clone();
self.start_film(&which, Some(&o))?;
say(&format!("Экспорт «{}»: {} fps, {}x{}; планирую кадры…", o.name, o.fps, o.size.0, o.size.1));
self.export = Some(Export { session: self.session, opts: o, stage: Stage::Planning, ticks: 0 });
Ok(())
}
/// Drives an export: plan, start the recorder, wait for its first frame, report, stop at the end.
fn export_step(&mut self, r: &mut replay::Player) {
let Some(ex) = &mut self.export else { return };
if ex.session != self.session {
// The film this export recorded was replaced: the recorder stops, nothing else drives it.
render::capture_stop(true);
self.export = None;
say("Экспорт отменён: показ заменён другим");
return;
}
ex.ticks += 1;
let st = render::capture_status();
let failed = |s: &render::CaptureState| s.phase == render::CapturePhase::Failed;
let mut finish: Option<Result<String, String>> = None;
match ex.stage {
Stage::Planning if r.film_ready() => {
let o = &ex.opts;
let res = render::capture_start(&render::CaptureOptions {
folder: "airdef-films".into(),
name: o.name.clone(),
fps: o.fps,
width: o.size.0,
height: o.size.1,
crf: o.crf,
audio: o.audio,
warmup: 8,
});
match res {
Ok(()) => {
say(&format!("Кадры готовы ({}); запись…", r.film_summary()));
ex.stage = Stage::Arming;
}
Err(e) => finish = Some(Err(e)),
}
}
Stage::Arming => {
if st.phase == render::CapturePhase::Recording {
r.wait = false;
ex.stage = Stage::Recording;
ex.ticks = 0;
} else if failed(&st) {
finish = Some(Err(st.message.clone()));
}
}
Stage::Recording => {
if failed(&st) {
finish = Some(Err(st.message.clone()));
} else if r.ended {
render::capture_stop(false);
ex.stage = Stage::Finishing;
} else if ex.ticks % 100 == 0 {
let len = r.film_len().unwrap_or(1.0).max(0.1);
say(&format!("Экспорт: {:.0}% ({} кадров)", (r.film_time() / len * 100.0).min(100.0), st.frames));
}
}
Stage::Finishing => match st.phase {
render::CapturePhase::Done => {
let note = if st.message.is_empty() { String::new() } else { format!(" ({})", st.message) };
finish = Some(Ok(format!("{}x{}, {} кадров: {}{note}", st.width, st.height, st.frames, st.file)));
}
render::CapturePhase::Failed => finish = Some(Err(st.message.clone())),
_ => {}
},
_ => {}
}
if let Some(res) = finish {
self.export = None;
match res {
Ok(m) => say(&format!("Экспорт готов: {m}")),
Err(e) => say(&format!("Экспорт не удался: {e}")),
}
r.done = true;
}
}
/// What a raid strikes: the marked objects, else where the player stands.
fn objects(&self, player: DVec3) -> Vec<DVec3> {
let live: Vec<DVec3> = self
.sim
.targets
.iter()
.enumerate()
.filter(|(i, _)| self.sim.target_health.get(*i).is_none_or(|h| *h > 0.0))
.map(|(_, t)| *t)
.collect();
if live.is_empty() { vec![player] } else { live }
}
fn launch_wave(&mut self, out: &mut Out) -> Result<(), String> {
let p = player().ok_or("Нет игрока")?;
if self.wave.total() == 0 {
return Err("Налёт пуст: выберите типы и число".into());
}
let objects = self.objects(DVec3::new(p.x, p.y, p.z));
self.sim.order_raid(&self.wave.order(), &objects, out);
Ok(())
}
/// The systems the grid shows: the busiest (firing, locking, hit, burning) first, the
/// monitored one always; those shown keep a head start, so the grid doesn't flicker.
fn pick_grid(&mut self) {
let shown = &self.grid_ids;
// Units that shoot, and the radars; depots, trucks and lights have nothing to show.
let mut scored: Vec<(f64, u32)> = self
.sim
.sams
.iter()
.enumerate()
.filter(|(_, s)| s.kind.armed() || s.kind == SamKind::Radar)
.map(|(i, s)| {
let busy = if self.sim.showing_kill(s) { 98.0 } else { match s.state {
sim::SamState::Launch | sim::SamState::Fire => 100.0,
sim::SamState::Lock => 85.0,
sim::SamState::Slew => 75.0,
sim::SamState::Track => 65.0,
sim::SamState::Reload => 30.0,
sim::SamState::Search => 10.0,
sim::SamState::Hold => 5.0,
sim::SamState::Destroyed => if s.burning() { 90.0 } else { 1.0 },
sim::SamState::Waiting => 35.0,
sim::SamState::Packing | sim::SamState::Moving | sim::SamState::Deploying => 40.0,
} };
let near = self.sim.watched(s).map_or(0.0, |d| 15.0 * (1.0 - (d.pos.distance(s.pos) / s.kind.range()).min(1.0)));
let hurt = if s.alive() && s.health < 0.7 { 15.0 } else { 0.0 };
let keep = if shown.contains(&s.id) { 12.0 } else { 0.0 };
let pinned = if self.monitor == Some(i) { 1000.0 } else { 0.0 };
(busy + near + hurt + keep + pinned + s.seen.len().min(5) as f64, s.id)
})
.collect();
scored.sort_by(|a, b| b.0.total_cmp(&a.0));
let mut ids: Vec<u32> = scored.iter().take(8).map(|s| s.1).collect();
// Shown in the order they were deployed, so a cell stays put while it stays chosen.
ids.sort_by_key(|id| self.sim.sams.iter().position(|s| s.id == *id));
self.grid_ids = ids;
}
fn cycle_monitor(&mut self) {
self.monitor = match self.monitor {
None if !self.sim.sams.is_empty() => Some(0),
Some(i) if i + 1 < self.sim.sams.len() => Some(i + 1),
_ => None,
};
}
fn open_menu(&mut self, page: Page) {
self.menu = Some(page);
let onoff = |b: bool| if b { "вкл" } else { "выкл" };
let mut widgets = Vec::new();
let title;
match page {
Page::Main => {
title = "AirDef — командный пункт (N меню, K монитор)".to_string();
let rows = SamKind::ARMED
.iter()
.map(|k| {
let n = self.sim.sams.iter().filter(|s| s.kind == *k).count();
let r = self.sim.roles.get(k).copied().unwrap_or(k.spec().roles);
format!("{} [{}] · развёрнуто: {n}", k.name(), sim::roles_text(r))
})
.collect();
widgets.push(Widget::List(ListWidget { id: MENU_KINDS, rows, selected: None }));
let _ = &self.monitor;
widgets.push(Widget::Button(ButtonWidget { id: BTN_GRID, label: "Командный пункт: все комплексы (J)".into() }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_WAVE, label: "Налёт: состав и запуск…".into() }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_REPLAYS, label: "Повторы…".into() }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_MORE, label: "Огонь, ремонт, эффекты…".into() }));
}
Page::Replays => {
match self.sim.replays.last() {
Some(rec) => {
let kills = rec.events.iter().filter(|e| matches!(e.kind, sim::record::EventKind::Kill { .. })).count();
title = format!("Налёт №{}: {} событий, сбито {kills} — клик: повтор момента", rec.number, rec.events.len());
let rows = (0..rec.events.len().min(200)).map(|i| replay::row(rec, i)).collect();
widgets.push(Widget::List(ListWidget { id: REPLAY_EVENTS, rows, selected: None }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_MAP, label: "Карта налёта (R)".into() }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_FULL, label: "Полный повтор".into() }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_REEL, label: format!("Лучшие моменты ({})", replay::reel(rec).len()) }));
}
None => {
title = "Повторы".into();
widgets.push(Widget::Text(horizon_mod_sdk::ui::TextWidget {
id: 0,
text: "Повторов пока нет: налёт записывается сам и появится здесь, когда небо очистится".into(),
color: 0,
}));
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Назад".into() }));
}
}
}
Page::More => {
title = "AirDef — управление".to_string();
widgets.push(Widget::Button(ButtonWidget {
id: BTN_HOLD,
label: format!("Огонь ПВО: {}", if self.sim.hold { "запрещён" } else { "разрешён" }),
}));
let broken = self.sim.sams.iter().filter(|s| s.health < 1.0).count();
widgets.push(Widget::Button(ButtonWidget { id: BTN_REPAIR, label: format!("Ремонт всех (повреждено: {broken})") }));
let fx = if !self.sim.shader { "выкл" } else if self.hd { "HD (свет, bloom)" } else { "обычные" };
widgets.push(Widget::Button(ButtonWidget { id: BTN_SHADER, label: format!("Эффекты: {fx}") }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_DAMAGE, label: format!("Разрушения: {}", onoff(self.sim.damage)) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_FAR, label: format!("Дальний ландшафт: {}", onoff(self.far)) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Назад".into() }));
}
Page::Wave => {
let w = &self.wave;
title = format!("Налёт: {} целей — клик по строке меняет число", w.total());
let rows = wave::KINDS
.iter()
.zip(w.counts)
.map(|(k, n)| format!("{} — {}", k.spec().name, if n == 0 { "нет".to_string() } else { n.to_string() }))
.collect();
widgets.push(Widget::List(ListWidget { id: WAVE_KINDS, rows, selected: None }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_NEXT, label: "Параметры и пуск →".into() }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Назад".into() }));
}
Page::WaveGo => {
let w = &self.wave;
title = format!("Налёт: {} целей", w.total());
widgets.push(Widget::Button(ButtonWidget { id: BTN_PRESET, label: format!("Готовый: {}", wave::PRESETS[w.preset].name) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_APPROACH, label: format!("Откуда: {}", w.approach.name()) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_DIST, label: format!("Старт: {}", panel::distance(w.dist)) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_AIM, label: format!("Цели: {}", w.aim.name()) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_LAUNCH, label: format!("▶ ПУСК ({})", w.total()) }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Состав".into() }));
}
Page::Roles(kind) => {
title = format!("{} ({}) — по каким целям стрелять", kind.name(), panel::distance(kind.range()));
let _ = ItemWidget { id: 0, item: String::new(), name: String::new() };
let roles = self.sim.roles.get(&kind).copied().unwrap_or(kind.spec().roles);
for (i, c) in Class::ALL.iter().enumerate() {
let on = roles & c.bit() != 0;
widgets.push(Widget::Button(ButtonWidget {
id: BTN_CLASS + i as u32,
label: format!("{} {}", if on { "[ВКЛ]" } else { "[ — ]" }, c.name()),
}));
}
widgets.push(Widget::Button(ButtonWidget { id: BTN_DEFAULT, label: "Сбросить по умолчанию".into() }));
widgets.push(Widget::Button(ButtonWidget { id: BTN_BACK, label: "← Назад".into() }));
}
}
ui::open_screen(&Screen { title, widgets });
}
fn set_roles(&mut self, kind: SamKind, mask: Option<u8>) {
let mask = match mask {
Some(m) => {
self.sim.roles.insert(kind, m);
m
}
None => {
self.sim.roles.remove(&kind);
kind.spec().roles
}
};
for s in self.sim.sams.iter_mut().filter(|s| s.kind == kind) {
s.roles = mask;
s.target = None;
s.gun_target = None;
}
self.dirty = true;
}
}
/// The help line: generated from the command tree (`commands.rs`), which the game checks lines with.
fn help() -> String {
commands::help()
}
impl Mod for AirDef {
fn init() -> Result<(Self, Registration), String> {
let seed = game::get_clock().map_or(0x5eed, |c| c.game_time.wrapping_mul(2654435761) ^ 0x9e37);
let mut me = AirDef {
sim: Sim::new(seed),
ground: Ground::default(),
dirty: false,
next_save: 0,
hud_shown: false,
monitor: None,
monitor_shown: false,
grid: false,
grid_ids: Vec::new(),
wave: wave::WaveConfig::default(),
replay: None,
block_log: None,
time_view: false,
export: None,
film_prev: None,
session: 0,
smoke: Default::default(),
far: true,
moving: None,
hd: false,
world: None,
health_mark: 0,
menu: None,
listener: DVec3::ZERO,
punch: 0.0,
pending: Vec::new(),
model_ids: std::collections::HashMap::new(),
model_generation: u32::MAX,
by_id: Vec::new(),
};
let _ = input::register_key("menu", "Меню AirDef", "key.keyboard.n");
let _ = input::register_key("monitor", "Монитор ПВО: следующий комплекс", "key.keyboard.k");
let _ = input::register_key("grid", "Командный пункт: все комплексы", "key.keyboard.j");
let _ = input::register_key("replay", "Повтор налёта: карта / выход", "key.keyboard.r");
let _ = input::register_key("ew", "РЭБ: включить / выключить", "key.keyboard.u");
// The replay's own keys (they do nothing outside a replay).
let _ = input::register_key("rp_back", "Повтор: назад", "key.keyboard.left");
let _ = input::register_key("rp_forward", "Повтор: вперёд", "key.keyboard.right");
let _ = input::register_key("rp_faster", "Повтор: быстрее", "key.keyboard.up");
let _ = input::register_key("rp_slower", "Повтор: медленнее", "key.keyboard.down");
// Not Enter: it opens the chat.
let _ = input::register_key("rp_pause", "Повтор: пауза", "key.keyboard.space");
let _ = input::register_key("rp_exit", "Повтор: выход", "key.keyboard.backspace");
input::claim_items(&ITEMS.map(String::from));
render::set_post(include_str!("../shaders/effects.wgsl"));
me.block_log = Some(render::world_log_start());
me.sim.shader = true;
me.apply_far();
// Subcommands, typed arguments and the saved scenarios' names for the chat box (#98).
commands::register(&scenario::saved_names());
Ok((me, Registration::new().command("airdef").on_tick()))
}
fn tick(&mut self, tick: u64) {
self.sim.engine_tick = tick;
let Some(p) = player() else { return };
// Each world keeps its own systems, objects and settings: loaded when it opens.
let world = game::world_id();
if self.world.as_ref() != Some(&world) {
self.open_world(world);
}
for event in input::poll() {
match event {
Event::UseStart(u) => self.use_item(&u.item.id),
Event::KeyDown(k) if k == "menu" => {
if self.replay.is_some() {
self.stop_replay();
}
self.open_menu(Page::Main)
}
Event::KeyDown(k) if k == "rp_exit" && self.replay.is_some() => self.stop_replay(),
Event::KeyDown(k) if k.starts_with("rp_") => {
let c = match k.as_str() {
"rp_back" => replay::Control::Back,
"rp_forward" => replay::Control::Forward,
"rp_faster" => replay::Control::Faster,
"rp_slower" => replay::Control::Slower,
_ => replay::Control::Pause,
};
if let Some(r) = &mut self.replay
&& let Some(rec) = self.sim.replay(r.rec)
{
r.control(c, rec);
}
}
Event::KeyDown(k) if k == "monitor" => self.cycle_monitor(),
Event::KeyDown(k) if k == "grid" => self.grid = !self.grid,
Event::KeyDown(k) if k == "ew" => self.toggle_ew(),
Event::KeyDown(k) if k == "replay" => {
if self.replay.is_some() {
self.stop_replay();
} else if let Err(e) = self.start_replay("map", None) {
say(&e);
}
}
Event::Wheel(w) => {
if let Some(r) = &mut self.replay
&& let Some(rec) = self.sim.replay(r.rec)
{
r.wheel(w, rec);
}
}
_ => {}
}
}
let now = self.sim.tick;
self.ground.answer(render::ground(), now);
// The blocks themselves (issues #42, #43): what changed, and the sections asked for last tick.
let changes = render::world_changes();
self.ground.world.changed_rev(changes.epoch, changes.revision, changes.overflow, &changes.sections);
let sections = render::sections()
.into_iter()
.map(|s| sim::world::SectionAnswer {
sec: (s.x, s.y, s.z),
fill: match s.fill {
render::SectionFill::Unknown => sim::world::Fill::Unknown,
render::SectionFill::Empty => sim::world::Fill::Empty,
render::SectionFill::Full => sim::world::Fill::Full,
render::SectionFill::Mixed => match <[u8; 512]>::try_from(s.bits) {
Ok(bits) => sim::world::Fill::Mixed(Box::new(bits)),
Err(_) => sim::world::Fill::Unknown,
},
},
})
.collect();
self.ground.world.answer(sections, now);
// Exact collision shapes of blocks a flight met (#63): the answers to last tick's rays.
let replies: Vec<sim::world::RayReply> = render::rays()
.into_iter()
.map(|r| sim::world::RayReply {
id: r.id,
clear: matches!(r.verdict, render::Verdict::Clear),
hit: matches!(r.verdict, render::Verdict::Hit),
fraction: r.fraction,
normal: [r.normal.0, r.normal.1, r.normal.2],
block: [r.block.0, r.block.1, r.block.2],
})
.collect();
self.ground.world.answer_rays(&replies);
let mut out = Out::default();
self.listener = DVec3::new(p.x, p.y + 1.6, p.z);
self.sim.night = game::get_clock().is_some_and(|c| (13000..23000).contains(&(c.day_time % 24000)));
self.sim.day_time = game::get_clock().map(|c| c.day_time);
self.sim.step(&mut self.ground, Some(DVec3::new(p.x, p.y, p.z)), &mut out);
self.smoke.update(&self.sim);
let wanted = self.ground.take_wanted();
if !wanted.is_empty() {
render::query_ground(&wanted);
}
let rays: Vec<render::Ray> = self
.ground
.world
.take_rays(now)
.into_iter()
.map(|(id, a, b)| render::Ray { id, start: (a.x, a.y, a.z), end: (b.x, b.y, b.z), policy: render::Policy::Collision })
.collect();
if !rays.is_empty() {
render::query_rays(&rays);
}
let sections = self.ground.world.take_wanted(now);
if !sections.is_empty() {
render::query_sections(render::Policy::Collision, &sections);
}
if let Some(mut r) = self.replay.take() {
// A replay shows the recording alone: not the live raid's models, particles, sounds or jolts.
if r.filming() {
out.models.clear();
out.particles.clear();
out.sounds.clear();
out.shake = None;
}
r.tick(&self.sim, &mut self.ground, self.listener, &mut out);
self.export_step(&mut r);
if r.done {
self.replay = Some(r);
self.stop_replay();
} else {
self.replay = Some(r);
}
}
self.emit(out);
self.draw_hud(DVec3::new(p.x, p.y, p.z), p.yaw);
// The replay takes the screen; else the monitors and the cameras they show.
if let Some(r) = &self.replay {
let size = render::gui_size();
render::set_hud(&r.hud(&self.sim, size));
render::set_views(&r.views(size));
// The world through the director's camera, with the full pipeline (HD, the post pass).
render::set_main_view(r.filming().then(|| r.view_id()));
// The sky of the raid, not of now (None for a raid whose clock was unknown).
render::set_time_override(r.day_time(&self.sim));
self.show_world_at(r.rec, r.time(), r.filming());
self.monitor_shown = true;
} else if self.grid {
let stale = self.grid_ids.iter().any(|id| !self.sim.sams.iter().any(|s| s.id == *id));
if self.sim.tick % 40 == 0 || stale || self.grid_ids.len() < self.sim.sams.len().min(8) {
self.pick_grid();
}
let picked: Vec<usize> =
self.grid_ids.iter().filter_map(|id| self.sim.sams.iter().position(|s| s.id == *id)).collect();
render::set_hud(&monitor::grid(&self.sim, &picked, render::gui_size()));
let views: Vec<_> = picked.iter().map(|&i| monitor::camera(&self.sim, &self.sim.sams[i])).collect();
render::set_views(&views);
self.monitor_shown = true;
} else {
match self.monitor {
Some(i) if i < self.sim.sams.len() => {
render::set_hud(&monitor::draw(&self.sim, i));
render::set_views(&[monitor::camera(&self.sim, &self.sim.sams[i])]);
self.monitor_shown = true;
}
_ => {
if self.monitor_shown {
render::set_hud(&[]);
render::set_views(&[]);
self.monitor_shown = false;
}
}
}
}
// Damage is saved too: a destroyed system stays destroyed. So are where the units stand
// (or drive to: not every step of the way), their ammunition and the depots' stock.
let mark = self
.sim
.sams
.iter()
.map(|s| {
let at = s.dest.unwrap_or(s.pos);
let place = (at.x * 10.0) as i64 as u64 ^ ((at.z * 10.0) as i64 as u64).rotate_left(21) ^ (if s.dest.is_some() { 0 } else { (s.heading * 100.0) as i64 as u64 }).rotate_left(42);
let ammo = u64::from(s.missiles) | u64::from(s.bursts) << 16 | u64::from(s.cargo) << 32 | u64::from(s.empty) << 48 | u64::from(s.reloading > 0) << 49;
let parts = s.comps.0.iter().fold(0u64, |a, v| a.wrapping_mul(131).wrapping_add((v * 100.0) as u64));
((s.health * 1000.0) as u64).wrapping_mul(1_000_003) ^ place ^ ammo.rotate_left(7) ^ parts.rotate_left(13)
})
.chain(self.sim.target_health.iter().map(|h| (h * 10.0) as u64))
.fold(self.sim.sams.len() as u64, |a, h| a.wrapping_mul(31).wrapping_add(h));
if mark != self.health_mark {
self.health_mark = mark;
self.dirty = true;
}
if self.dirty && self.sim.tick >= self.next_save {
self.save();
}
}
fn ui_select(&mut self, id: u32, row: u32) {
let page = self.menu.unwrap_or(Page::Main);
match (page, id) {
(Page::Main, MENU_KINDS) => {
if let Some(k) = SamKind::ARMED.get(row as usize) {
self.open_menu(Page::Roles(*k));
}
return;
}
(Page::More, BTN_HOLD) => self.sim.hold = !self.sim.hold,
(Page::Main, BTN_MONITOR) => self.cycle_monitor(),
(Page::Main, BTN_WAVE) => {
self.open_menu(Page::Wave);
return;
}
(Page::More, BTN_REPAIR) => {
self.sim.repair();
self.dirty = true;
}
(Page::Wave, WAVE_KINDS) => {
if (row as usize) < wave::KINDS.len() {
self.wave.step(row as usize);
}
}
(Page::WaveGo, BTN_PRESET) => {
let next = self.wave.preset + 1;
self.wave.apply(next);
}
(Page::WaveGo, BTN_APPROACH) => {
let all = sim::Approach::ALL;
let i = all.iter().position(|a| *a == self.wave.approach).unwrap_or(0);
self.wave.approach = all[(i + 1) % all.len()];
}
(Page::WaveGo, BTN_DIST) => {
let all = wave::DISTANCES;
let i = all.iter().position(|d| *d == self.wave.dist).unwrap_or(0);
self.wave.dist = all[(i + 1) % all.len()];
}
(Page::WaveGo, BTN_AIM) => {
let all = sim::Aim::ALL;
let i = all.iter().position(|a| *a == self.wave.aim).unwrap_or(0);
self.wave.aim = all[(i + 1) % all.len()];
}
(Page::WaveGo, BTN_LAUNCH) => {
let mut out = Out::default();
match self.launch_wave(&mut out) {
Ok(()) => {
self.emit(out);
self.menu = None;
ui::close_screen();
return;
}
Err(e) => say(&e),
}
}
(Page::Wave, BTN_BACK) | (Page::More, BTN_BACK) => {
self.open_menu(Page::Main);
return;
}
(Page::Wave, BTN_NEXT) => {
self.open_menu(Page::WaveGo);
return;
}
(Page::WaveGo, BTN_BACK) => {
self.open_menu(Page::Wave);
return;
}
(Page::Main, BTN_REPLAYS) => {
self.open_menu(Page::Replays);
return;
}
(Page::Replays, REPLAY_EVENTS | BTN_MAP | BTN_FULL | BTN_REEL) => {
let r = match id {
REPLAY_EVENTS => self.start_replay("clip", Some(row as usize)),
BTN_FULL => self.start_replay("full", None),
BTN_REEL => self.start_replay("reel", None),
_ => self.start_replay("map", None),
};
match r {
Ok(()) => {
self.menu = None;
ui::close_screen();
}
Err(e) => say(&e),
}
return;
}
(Page::Replays, BTN_BACK) => {
self.open_menu(Page::Main);
return;
}
(Page::Main, BTN_MORE) => {
self.open_menu(Page::More);
return;
}
(Page::Main, BTN_GRID) => {
self.grid = true;
self.menu = None;
ui::close_screen();
return;
}
(Page::More, BTN_SHADER) => {
// Off → effects → HD → off.
let (shader, hd) = match (self.sim.shader, self.hd) {
(false, _) => (true, false),
(true, false) => (true, true),
(true, true) => (false, false),
};
self.set_effects(shader, hd);
}
(Page::More, BTN_FAR) => {
self.far = !self.far;
self.apply_far();
self.dirty = true;
}
(Page::More, BTN_DAMAGE) => {
self.sim.damage = !self.sim.damage;
self.dirty = true;
}
(Page::Roles(kind), b) if (BTN_CLASS..BTN_CLASS + 5).contains(&b) => {
let roles = self.sim.roles.get(&kind).copied().unwrap_or(kind.spec().roles);
self.set_roles(kind, Some(roles ^ Class::ALL[(b - BTN_CLASS) as usize].bit()));
}
(Page::Roles(kind), BTN_ALL) => self.set_roles(kind, Some(0x1F)),
(Page::Roles(kind), BTN_DEFAULT) => self.set_roles(kind, None),
(Page::Roles(kind), BTN_NONE) => self.set_roles(kind, Some(0)),
(Page::Roles(_), BTN_BACK) => {
self.open_menu(Page::Main);
return;
}
_ => return,
}
self.open_menu(page);
}
fn ui_closed(&mut self) {
self.menu = None;
}
fn command(&mut self, _name: &str, args: &str) -> Result<Option<String>, String> {
let w: Vec<&str> = args.split_whitespace().collect();
let mut out = Out::default();
let reply = match w.as_slice() {
["menu"] => {
self.open_menu(Page::Main);
None
}
["monitor"] => {
self.cycle_monitor();
None
}
["grid"] => {
self.grid = !self.grid;
None
}
["give"] => {
self.give();
None
}
["deploy", kind] => {
// A system where the player stands, a few blocks ahead.
let kind = SamKind::from_key(kind)
.ok_or("Техника: patriot, gepard, pantsir, s400, s500, irist, nasams, buk, tor, p1sun, radar, depot, tzm, ew, light")?;
let p = player().ok_or("Нет игрока")?;
let d = view(&p);
let flat = DVec3::new(d.x, 0.0, d.z).normalize_or(DVec3::NEG_Z);
let at = DVec3::new(p.x, p.y, p.z) + flat * 8.0;
let sid = self.sim.id();
let mut sam = Sam::new(sid, kind, at, (-flat.x).atan2(-flat.z));
if let Some(r) = self.sim.roles.get(&kind) {
sam.roles = *r;
}
self.sim.sams.push(sam);
self.dirty = true;
Some(format!("{} развёрнут", kind.name()))
}
["wave", kind, rest @ ..] => {
let count = rest.first().and_then(|v| v.parse().ok()).unwrap_or(5u32).clamp(1, 80);
let dist = rest.get(1).and_then(|v| v.parse().ok()).unwrap_or(260.0f64).clamp(60.0, 800.0);
let p = player().ok_or("Нет игрока")?;
let target = self
.sim
.nearest_target(DVec3::new(p.x, p.y, p.z))
.unwrap_or(DVec3::new(p.x, p.y, p.z));
if *kind == "mix" {
let objects = self.objects(DVec3::new(p.x, p.y, p.z));
self.sim.order_raid(&wave::WaveConfig::mix(count, dist), &objects, &mut out);
} else {
let kind = DroneKind::from_key(kind).ok_or(
"Тип: shahed, geran3, lancet, gerbera, kh101, kalibr, kh22, zircon, iskander, kinzhal, oreshnik или mix",
)?;
self.sim.raid(kind, target, count, dist, &mut out);
}
None
}
["replay", rest @ ..] => {
match rest.first().copied().unwrap_or("map") {
"stop" => self.stop_replay(),
"film" => self.start_film(rest.get(1).copied().unwrap_or("best"), None)?,
"export" => self.start_export(&rest[1..])?,
what => self.start_replay(what, None)?,
}
None
}
["scenario", rest @ ..] => Some(self.scenario_command(rest)?),
["why", rest @ ..] => {
// The system n of the summary, else the monitored one.
let i = match rest.first() {
Some(n) => n.parse::<usize>().ok().and_then(|n| n.checked_sub(1)).ok_or("/airdef why <номер>")?,
None => self.monitor.ok_or("/airdef why <номер> (или откройте монитор K)")?,
};
if i >= self.sim.sams.len() {
return Err("Нет такой техники".into());
}
for c in self.sim.why(i) {
say(&format!("{} {}", if c.ok { "+" } else { "-" }, c.text));
}
None
}
["raid"] => {
self.launch_wave(&mut out)?;
None
}
["move", n] => {
// Unit n (as the summary numbers them) drives to where the player stands.
let p = player().ok_or("Нет игрока")?;
let i = n.parse::<usize>().ok().and_then(|n| n.checked_sub(1)).ok_or("/airdef move <номер>")?;
if self.sim.order_move(i, DVec3::new(p.x, p.y, p.z)) {
Some(format!("{} №{}: на марш", self.sim.sams[i].kind.name(), i + 1))
} else {
Some("Эта техника не может ехать".into())
}
}
["ew", v @ ("on" | "off")] => {
let (n, hot) = self.sim.set_ew(*v == "on");
Some(format!("РЭБ: включено станций {n}{}", if hot > 0 { format!(", остывают {hot}") } else { String::new() }))
}
["flare"] => {
let p = player().ok_or("Нет игрока")?;
self.sim.fire_flare(eye(&p) + view(&p), view(&p), &mut out);
None
}
["import"] => {
// The state the mod kept before worlds had their own: brought into this world.
let before = self.sim.sams.len();
self.load_from("state");
self.dirty = true;
Some(format!("Перенесено комплексов из старой версии: {}", self.sim.sams.len() - before))
}
["repair"] => {
let n = self.sim.repair();
self.dirty = true;
Some(format!("Отремонтировано комплексов: {n} (корпус и все узлы: РЛС, привод, пусковая, орудие, ходовая, связь); объекты восстановлены"))
}
["clear"] => {
self.sim.clear();
Some("Небо очищено".to_string())
}
["target", "here"] => {
let p = player().ok_or("Нет игрока")?;
self.sim.targets.push(DVec3::new(p.x, p.y, p.z));
self.sim.target_health.push(100.0);
self.dirty = true;
Some(format!("Объект №{} отмечен здесь", self.sim.targets.len()))
}
["targets", "clear"] => {
self.sim.targets.clear();
self.sim.target_health.clear();
self.dirty = true;
Some("Цели убраны".to_string())
}
["truth", v @ ("on" | "off")] => {
self.sim.truth = *v == "on";
Some(format!("Истинные данные на радаре: {} (боевую логику не меняют)", if self.sim.truth { "вкл" } else { "выкл" }))
}
["damage", v @ ("on" | "off")] => {
self.sim.damage = *v == "on";
self.dirty = true;
Some(format!("Разрушения: {}", if self.sim.damage { "вкл" } else { "выкл" }))
}
["roles"] => {
for kind in SamKind::ARMED {
let r = self.sim.roles.get(&kind).copied().unwrap_or(kind.spec().roles);
let names: Vec<&str> = Class::ALL.iter().filter(|c| r & c.bit() != 0).map(|c| c.name()).collect();
say(&format!("{}: {}", kind.name(), if names.is_empty() { "ничего".into() } else { names.join(", ") }));
}
Some("Классы: drone, jet, cruise, supersonic, ballistic (или all). /airdef roles <комплекс|all> <классы|default>".into())
}
["roles", who, what] => {
let kinds: Vec<SamKind> = if *who == "all" {
SamKind::ARMED.to_vec()
} else {
vec![SamKind::from_key(who).ok_or("Комплекс: patriot, gepard, pantsir, s400, s500, irist, nasams, buk, tor, p1sun или all")?]
};
for kind in kinds {
let mask = if *what == "default" {
self.sim.roles.remove(&kind);
kind.spec().roles
} else {
let m = sim::parse_roles(what).ok_or("Классы через запятую: drone,jet,cruise,supersonic,ballistic или all")?;
self.sim.roles.insert(kind, m);
m
};
for s in self.sim.sams.iter_mut().filter(|s| s.kind == kind) {
s.roles = mask;
s.target = None;
s.gun_target = None;
}
}
self.dirty = true;
Some("Роли ПВО обновлены".into())
}
["hold", v @ ("on" | "off")] => {
self.sim.hold = *v == "on";
Some(if self.sim.hold { "ПВО: огонь запрещён".into() } else { "ПВО: огонь разрешён".into() })
}
["shader", v @ ("on" | "off" | "hd")] => {
self.set_effects(*v != "off", *v == "hd");
Some(format!("Эффекты: {}", match *v { "off" => "выкл", "hd" => "HD", _ => "обычные" }))
}
["stats"] => Some(format!(
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}, разбились о препятствия: {}; {}",
self.sim.drones.iter().filter(|d| d.alive()).count(),
self.sim.missiles.len(),
self.sim.sams.len(),
self.sim.kills,
self.sim.hits,
self.sim.crashes,
self.ground.world.text()
)),
_ => return Err(help()),
};
self.emit(out);
Ok(reply)
}
}
horizon_mod_sdk::export_mod!(AirDef);
#[cfg(test)]
mod tests {
use super::*;
/// #83: while an export records no other replay or film replaces it; after it, they may.
#[test]
fn nothing_replaces_a_running_export() {
assert!(switch_allowed(true).is_err());
assert!(switch_allowed(false).is_ok());
}
}