43346aecfe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
9.7 KiB
Rust
232 lines
9.7 KiB
Rust
//! The air-defence monitor: a system's state, its radar's picture round it
|
||
//! and its camera (a real view of the world from the system, the game
|
||
//! renders it) locked on its target. K shows one system at a time, J all of
|
||
//! them in a grid.
|
||
use crate::panel::distance;
|
||
use crate::sim::{Sam, SamState, Sim, heading_of, roles_text};
|
||
use glam::DVec3;
|
||
use horizon_mod_sdk::render::{self, CameraView, FillShape, Point, QuadShape, Rect, Shape, TextShape, ViewShape};
|
||
|
||
fn fill(x: f32, y: f32, w: f32, h: f32, color: u32) -> Shape {
|
||
Shape::Fill(FillShape { rect: Rect { x, y, w, h }, color })
|
||
}
|
||
|
||
fn text(t: impl Into<String>, x: f32, y: f32, color: u32) -> Shape {
|
||
Shape::Text(TextShape { text: t.into(), at: Point { x, y }, color, centered: false, shadow: true })
|
||
}
|
||
|
||
fn quad(a: (f32, f32), b: (f32, f32), c: (f32, f32), d: (f32, f32), color: u32) -> Shape {
|
||
let p = |(x, y): (f32, f32)| Point { x, y };
|
||
Shape::Quad(QuadShape { corners: (p(a), p(b), p(c), p(d)), color })
|
||
}
|
||
|
||
/// `t` cut to `w` GUI pixels, with an ellipsis.
|
||
fn fit(t: String, w: f32) -> String {
|
||
if render::text_width(&t) <= w {
|
||
return t;
|
||
}
|
||
let mut cut: String = t;
|
||
while !cut.is_empty() && render::text_width(&format!("{cut}…")) > w {
|
||
cut.pop();
|
||
}
|
||
format!("{cut}…")
|
||
}
|
||
|
||
fn argb(rgb: u32, a: u32) -> u32 {
|
||
(a << 24) | (rgb & 0xFFFFFF)
|
||
}
|
||
|
||
/// Corner brackets of a box.
|
||
fn brackets(s: &mut Vec<Shape>, x: f32, y: f32, w: f32, h: f32, len: f32, color: u32) {
|
||
for (cx, cy, dx, dy) in [(x, y, 1.0, 1.0), (x + w, y, -1.0, 1.0), (x, y + h, 1.0, -1.0), (x + w, y + h, -1.0, -1.0)] {
|
||
let fx = if dx > 0.0 { cx } else { cx - len };
|
||
s.push(fill(fx, if dy > 0.0 { cy } else { cy - 1.0 }, len, 1.0, color));
|
||
let gx = if dx > 0.0 { cx } else { cx - 1.0 };
|
||
s.push(fill(gx, if dy > 0.0 { cy } else { cy - len }, 1.0, len, color));
|
||
}
|
||
}
|
||
|
||
/// The camera's picture size, pixels.
|
||
const PICTURE: (u32, u32) = (320, 200);
|
||
|
||
/// Where system `sam`'s camera is and looks: at its target, zoomed so the
|
||
/// target fills a good part of the picture; else ahead and a little up.
|
||
pub fn camera(sim: &Sim, sam: &Sam) -> CameraView {
|
||
let eye = sam.pos + DVec3::Y * 3.5;
|
||
let (dir, fov) = match sim.watched(sam) {
|
||
Some(d) => {
|
||
let rel = d.pos - eye;
|
||
let dist = rel.length().max(1.0);
|
||
// About 14 blocks of view round a missile far off, wider when it is near.
|
||
let fov = (2.0 * (7.0 / dist).atan()).to_degrees().clamp(1.5, 50.0);
|
||
(rel / dist, fov as f32)
|
||
}
|
||
None => {
|
||
let h = sam.heading + (sim.tick as f64 * 0.004).sin() * 0.6;
|
||
(DVec3::new(-h.sin(), 0.25, -h.cos()).normalize(), 55.0)
|
||
}
|
||
};
|
||
// Minecraft's yaw: 0 looks to +z, 90 to -x; pitch 90 looks down.
|
||
let yaw = (-dir.x).atan2(dir.z).to_degrees() as f32;
|
||
let pitch = (-dir.y).asin().to_degrees() as f32;
|
||
CameraView { id: sam.id, x: eye.x, y: eye.y, z: eye.z, yaw, pitch, fov, width: PICTURE.0, height: PICTURE.1 }
|
||
}
|
||
|
||
/// One system's monitor in the box (x, y, w, h).
|
||
fn cell(sim: &Sim, index: usize, x: f32, y: f32, w: f32, h: f32, s: &mut Vec<Shape>) {
|
||
let Some(sam) = sim.sams.get(index) else { return };
|
||
let spec = sam.kind.spec();
|
||
let tick = sim.tick;
|
||
s.push(fill(x, y, w, h, 0xC0080C0A));
|
||
s.push(fill(x, y, w, 12.0, 0xE0142018));
|
||
let state = sam.state;
|
||
let lock = matches!(state, SamState::Lock | SamState::Launch | SamState::Fire);
|
||
let blink = lock && (tick / 4) % 2 == 0;
|
||
let label = state.name();
|
||
let lw = render::text_width(label);
|
||
s.push(text(fit(format!("№{} {}", index + 1, spec.name), w - lw - 12.0), x + 3.0, y + 2.0, 0xFF9FE0FF));
|
||
s.push(text(label, x + w - 3.0 - lw, y + 2.0, argb(state.color(), if blink { 0x80 } else { 0xFF })));
|
||
|
||
// The radar, at the left: round the system, north up.
|
||
let rr = ((h - 28.0) / 2.0).min(w * 0.2).max(10.0);
|
||
let (rx, ry) = (x + 4.0 + rr, y + 14.0 + (h - 26.0) / 2.0);
|
||
s.push(fill(rx - rr, ry - rr, rr * 2.0, rr * 2.0, 0x60103018));
|
||
let seg = if rr > 30.0 { 32 } else { 20 };
|
||
for k in [0.5f32, 1.0] {
|
||
for i in 0..seg {
|
||
let a0 = i as f32 / seg as f32 * std::f32::consts::TAU;
|
||
let a1 = (i + 1) as f32 / seg as f32 * std::f32::consts::TAU;
|
||
let p = |a: f32, r: f32| (rx + a.sin() * r, ry - a.cos() * r);
|
||
let r = rr * k;
|
||
s.push(quad(p(a0, r - 0.4), p(a0, r + 0.4), p(a1, r + 0.4), p(a1, r - 0.4), 0x9040FF60));
|
||
}
|
||
}
|
||
let reach = sam.kind.range();
|
||
let sweep = (tick as f32 * 0.12 + index as f32) % std::f32::consts::TAU;
|
||
for k in 0..6 {
|
||
let a1 = sweep - k as f32 * 0.09;
|
||
let a0 = a1 - 0.09;
|
||
let p = |a: f32| (rx + a.sin() * rr, ry - a.cos() * rr);
|
||
s.push(quad((rx, ry), p(a0), p(a1), p(a1), argb(0x40FF70, (0x70 - k * 10) as u32)));
|
||
}
|
||
let target = sim.watched(sam);
|
||
let mut seen = 0;
|
||
for d in sim.drones.iter().filter(|d| d.alive()) {
|
||
let rel = d.pos - sam.pos;
|
||
let flat = (rel.x * rel.x + rel.z * rel.z).sqrt();
|
||
if flat > reach * 1.25 {
|
||
continue;
|
||
}
|
||
seen += 1;
|
||
let k = (rr as f64 / (reach * 1.25)) as f32;
|
||
let (bx, by) = (rx + rel.x as f32 * k, ry + rel.z as f32 * k);
|
||
let allowed = sam.roles & d.kind.spec().class.bit() != 0;
|
||
let color = if Some(d.id) == target.map(|t| t.id) {
|
||
0xFFFFE040
|
||
} else if allowed {
|
||
0xFFFF4040
|
||
} else {
|
||
0xFF808080
|
||
};
|
||
s.push(fill(bx - 1.5, by - 1.5, 3.0, 3.0, color));
|
||
}
|
||
s.push(fill(rx - 1.5, ry - 1.5, 3.0, 3.0, 0xFF40A0FF));
|
||
s.push(text(fit(format!("РЛС {} · {}", distance(reach), seen), rr * 2.0 + 4.0), x + 4.0, y + h - 10.0, 0xFF80FF90));
|
||
|
||
// The camera, at the right: the world as the system sees it, thermal.
|
||
let cx = rx + rr + 6.0;
|
||
let (cy, cw, ch) = (y + 15.0, x + w - 4.0 - cx, h - 28.0);
|
||
s.push(fill(cx - 1.0, cy - 1.0, cw + 2.0, ch + 2.0, 0xFF203020));
|
||
// Searching: colour by day, night vision at night; tracking: thermal.
|
||
let night = horizon_mod_sdk::game::get_clock().is_some_and(|c| (13000..23000).contains(&(c.day_time % 24000)));
|
||
let mode = match (target.is_some(), night) {
|
||
(true, _) => 1,
|
||
(false, true) => 2,
|
||
(false, false) => 0,
|
||
};
|
||
s.push(Shape::View(ViewShape { view: sam.id, rect: Rect { x: cx, y: cy, w: cw, h: ch }, mode }));
|
||
// Crosshair.
|
||
s.push(fill(cx + cw / 2.0 - 0.5, cy + 3.0, 1.0, ch * 0.18, 0x90FFFFFF));
|
||
s.push(fill(cx + cw / 2.0 - 0.5, cy + ch * 0.82 - 3.0, 1.0, ch * 0.18, 0x90FFFFFF));
|
||
s.push(fill(cx + 3.0, cy + ch / 2.0 - 0.5, cw * 0.18, 1.0, 0x90FFFFFF));
|
||
s.push(fill(cx + cw * 0.82 - 3.0, cy + ch / 2.0 - 0.5, cw * 0.18, 1.0, 0x90FFFFFF));
|
||
match target {
|
||
Some(t) => {
|
||
let color = if lock { if blink { 0xFFFF3030 } else { 0xFFFF8080 } } else { 0xFFFFE060 };
|
||
let (bw, bh) = (cw * 0.3, ch * 0.34);
|
||
brackets(s, cx + cw / 2.0 - bw / 2.0, cy + ch / 2.0 - bh / 2.0, bw, bh, 6.0, color);
|
||
if lock {
|
||
s.push(text("ЗАХВАТ", cx + cw / 2.0 - 18.0, cy + 3.0, color));
|
||
}
|
||
if !t.alive() {
|
||
s.push(text("ПОРАЖЕНА", cx + cw / 2.0 - 24.0, cy + ch / 2.0 + bh / 2.0 + 2.0, 0xFF60FF60));
|
||
}
|
||
let rel = t.pos - sam.pos;
|
||
let bearing = (heading_of(rel).to_degrees() + 180.0).rem_euclid(360.0);
|
||
if ch > 50.0 {
|
||
s.push(text(fit(t.kind.spec().name.into(), cw - 6.0), cx + 3.0, cy + ch - 20.0, 0xFFFFFFFF));
|
||
}
|
||
let mut info = format!("{} в{:.0} {:.0}км/ч", distance(rel.length()), t.pos.y - sam.pos.y, t.vel.length() * 72.0);
|
||
if cw > 220.0 {
|
||
info += &format!(" П{bearing:03.0}");
|
||
}
|
||
s.push(text(fit(info, cw - 6.0), cx + 3.0, cy + ch - 10.0, 0xFFC0FFC0));
|
||
}
|
||
None => s.push(text("ПОИСК", cx + 3.0, cy + ch - 10.0, 0xFF80FF90)),
|
||
}
|
||
|
||
// What it carries and does.
|
||
let mut line = String::new();
|
||
if !matches!(sam.kind, crate::sim::SamKind::Gepard) {
|
||
line += &format!("Р{} ", sam.missiles);
|
||
}
|
||
if sam.bursts > 0 || spec.key == "gepard" {
|
||
line += &format!("С{} ", sam.bursts);
|
||
}
|
||
line += &format!("сб.{} [{}]", sam.kills, roles_text(sam.roles));
|
||
s.push(text(fit(line, cw), cx, y + h - 10.0, 0xFF9FE0FF));
|
||
}
|
||
|
||
/// One system, top left.
|
||
pub fn draw(sim: &Sim, index: usize) -> Vec<Shape> {
|
||
let mut s = Vec::new();
|
||
cell(sim, index, 4.0, 4.0, 300.0, 150.0, &mut s);
|
||
s
|
||
}
|
||
|
||
/// Every system in a grid over the screen.
|
||
pub fn grid(sim: &Sim, (gw, gh): (f32, f32)) -> Vec<Shape> {
|
||
let mut s = Vec::new();
|
||
let n = sim.sams.len().min(8);
|
||
s.push(fill(0.0, 0.0, gw, gh, 0xE0050806));
|
||
s.push(text(
|
||
fit(
|
||
format!("КОМАНДНЫЙ ПУНКТ ПВО · комплексов {n} · целей {} · сбито {} · J закрыть", sim.drones.iter().filter(|d| d.alive()).count(), sim.kills),
|
||
gw - 16.0,
|
||
),
|
||
8.0,
|
||
6.0,
|
||
0xFF9FE0FF,
|
||
));
|
||
if n == 0 {
|
||
s.push(text("Нет комплексов: поставьте их пультами развёртывания", 8.0, 24.0, 0xFFFFC060));
|
||
return s;
|
||
}
|
||
let cols = match n {
|
||
1 => 1,
|
||
2..=4 => 2,
|
||
_ => 3,
|
||
};
|
||
let rows = n.div_ceil(cols);
|
||
let (top, gap) = (18.0, 4.0);
|
||
let cw = (gw - gap * (cols as f32 + 1.0)) / cols as f32;
|
||
let ch = ((gh - top - gap * rows as f32) / rows as f32).min(cw * 0.62);
|
||
for i in 0..n {
|
||
let (c, r) = (i % cols, i / cols);
|
||
let x = gap + c as f32 * (cw + gap);
|
||
let y = top + r as f32 * (ch + gap);
|
||
cell(sim, i, x, y, cw, ch, &mut s);
|
||
}
|
||
s
|
||
}
|