f16bc2387f
build / mod (push) Successful in 3m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1975 lines
87 KiB
Rust
1975 lines
87 KiB
Rust
//! 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, §ions);
|
||
}
|
||
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());
|
||
}
|
||
}
|