Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 766b292aec | |||
| ab6b61db59 | |||
| 4d4e1682f5 | |||
| 545d4777e8 |
@@ -4,3 +4,4 @@
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.hmod binary
|
||||
*.patch -text
|
||||
|
||||
@@ -221,6 +221,12 @@ API мода `render.set-lights` (до 512, плавно интерполиру
|
||||
У каждого объекта свой звук и своя дальность слышимости (`attenuation_distance` в `sounds.json`):
|
||||
- **двигатели:** двухтактный мопед шахеда (220 м), турбина Герани-3, электромотор Ланцета, тяжёлый рёв крылатых ракет (320 м),
|
||||
ракетный рёв Х-22, Циркона и перехватчиков, нарастающий визг падающей баллистики и её ударная волна (700–900 м);
|
||||
Двигатели дронов и ракет — **постоянные источники звука** (API 20, `render.sound-sources`, #95): один источник на
|
||||
объект идёт за его настоящей позицией, зажигается с нарастанием, при сбитии «захлёбывается» (тон падает, громкость
|
||||
гаснет за секунду) и замолкает вместе с объектом; в повторе и экспорте источники берутся из записанных траекторий и
|
||||
времени повтора (перемотка и смена плана пересоздают их). Одновременно звучат 24 ближайших и громких, остальные
|
||||
«виртуальные». Взрывы, пуски и выстрелы остались разовыми, с задержкой по скорости звука. Баллистика (визг при
|
||||
падении) тоже разовая. Зацикливается устойчивая часть файла (начало и затухание в конце обрезаны), стык — 50 мс.
|
||||
- **пуски:** хлопок и рвущийся рёв Patriot, свистящий старт Панциря, глухой газовый выброс и затем огромный рёв С-400,
|
||||
у С-500 ещё ниже и дольше (до 1 км);
|
||||
- **пушки:** тяжёлые 35-мм очереди Гепарда и частые 30-мм Панциря;
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
//! Engines as persistent sound sources (#95): a drone's or a missile's engine is one source that
|
||||
//! follows the entity, instead of a one-shot repeated every second or two.
|
||||
//!
|
||||
//! The simulation (live) and the replay (from a recording's tracks and the replay's time) each
|
||||
//! *declare* the engines sounding this tick; [`Engines::step`] turns that into the changes of
|
||||
//! `render.sound-sources`: a start with an attack when an engine appears (ignition), an update
|
||||
//! every tick, a stop with a release when it is gone (burnout, the entity removed), and a fall
|
||||
//! of pitch and gain over a second when it is damaged (a drone shot down). A declaration missing
|
||||
//! for a tick or two (a step the simulation skipped) does not stop the sound; a *cut* (a replay
|
||||
//! seek, a new shot) stops every engine and starts them again under new ids, so nothing glides
|
||||
//! across the jump.
|
||||
use glam::DVec3;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Ticks an engine may go undeclared before it is stopped.
|
||||
const GRACE: u32 = 3;
|
||||
/// Ticks a damaged engine takes to die: its pitch falls and it fades.
|
||||
const DYING: u32 = 20;
|
||||
/// The most engines that sound at once (the nearest ones; the game's budget picks the audible).
|
||||
const MAX: usize = 128;
|
||||
/// Doppler applied to engines. The kinds already differ in pitch, and the fight's speed must not
|
||||
/// change what is heard, so it stays off; the game bounds it to 12% anyway.
|
||||
pub const DOPPLER: f32 = 0.0;
|
||||
|
||||
/// An engine that sounds this tick.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Decl {
|
||||
/// The entity: a drone's id, a missile's with a high bit, a replay track's with another.
|
||||
pub key: u32,
|
||||
pub event: &'static str,
|
||||
pub at: DVec3,
|
||||
/// Blocks a second.
|
||||
pub vel: DVec3,
|
||||
pub gain: f32,
|
||||
pub pitch: f32,
|
||||
/// Shot down: the engine dies away.
|
||||
pub damaged: bool,
|
||||
}
|
||||
|
||||
/// What a source is asked to be.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Voice {
|
||||
pub event: &'static str,
|
||||
pub at: DVec3,
|
||||
pub vel: DVec3,
|
||||
pub gain: f32,
|
||||
pub pitch: f32,
|
||||
pub attack: f32,
|
||||
pub release: f32,
|
||||
pub loop_from: f32,
|
||||
pub loop_to: f32,
|
||||
}
|
||||
|
||||
/// One change of `render.sound-sources`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Op {
|
||||
Start(u32, Voice),
|
||||
Update(u32, Voice),
|
||||
Stop(u32),
|
||||
}
|
||||
|
||||
/// The steady part of each engine sound, seconds into the file: the sounds fade in at their start
|
||||
/// and die away at their end (synthesis, reverb tail, fade: tools/gen_sounds.py), so looping a
|
||||
/// whole file leaves a gap; these were found as `audio::tests::a_loop_of_the_steady_part_of_the_
|
||||
/// engine_sounds_has_no_hole` finds them (after the fade-in, before the sound begins to die).
|
||||
pub fn steady_part(event: &str) -> (f32, f32) {
|
||||
match event {
|
||||
"airdef:drone.shahed" => (0.2, 2.2),
|
||||
"airdef:drone.geran3" => (0.2, 2.0),
|
||||
"airdef:drone.lancet" => (0.2, 1.4),
|
||||
"airdef:drone.cruise" => (0.2, 2.6),
|
||||
"airdef:drone.rocket" => (0.2, 2.6),
|
||||
"airdef:missile.flight" => (0.2, 2.2),
|
||||
_ => (0.0, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
struct Live {
|
||||
id: u32,
|
||||
event: &'static str,
|
||||
missed: u32,
|
||||
dying: u32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Engines {
|
||||
live: BTreeMap<u32, Live>,
|
||||
/// Engines that died and are still declared (a wreck falling): not started again.
|
||||
dead: BTreeSet<u32>,
|
||||
next_id: u32,
|
||||
}
|
||||
|
||||
impl Engines {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.live.is_empty()
|
||||
}
|
||||
|
||||
/// The game released every source: forget them (nothing to stop).
|
||||
pub fn reset(&mut self) {
|
||||
self.live.clear();
|
||||
self.dead.clear();
|
||||
}
|
||||
|
||||
fn voice(d: &Decl, dying: u32) -> Voice {
|
||||
let u = (dying as f32 / DYING as f32).min(1.0);
|
||||
let (loop_from, loop_to) = steady_part(d.event);
|
||||
Voice {
|
||||
event: d.event,
|
||||
at: d.at,
|
||||
vel: d.vel,
|
||||
// A shot-down engine coughs: the pitch falls by almost half, the sound fades.
|
||||
gain: d.gain * (1.0 - u),
|
||||
pitch: d.pitch * (1.0 - 0.45 * u),
|
||||
attack: 0.3,
|
||||
release: 0.25,
|
||||
loop_from,
|
||||
loop_to,
|
||||
}
|
||||
}
|
||||
|
||||
/// One tick: what is declared now, and whether the picture cut (a seek or a new shot).
|
||||
pub fn step(&mut self, mut decls: Vec<Decl>, cut: bool, listener: DVec3) -> Vec<Op> {
|
||||
let mut ops = Vec::new();
|
||||
if cut {
|
||||
for (_, l) in std::mem::take(&mut self.live) {
|
||||
ops.push(Op::Stop(l.id));
|
||||
}
|
||||
self.dead.clear();
|
||||
}
|
||||
if decls.len() > MAX {
|
||||
decls.sort_by(|a, b| {
|
||||
a.at.distance_squared(listener)
|
||||
.total_cmp(&b.at.distance_squared(listener))
|
||||
.then(a.key.cmp(&b.key))
|
||||
});
|
||||
decls.truncate(MAX);
|
||||
}
|
||||
decls.sort_by_key(|d| d.key);
|
||||
for l in self.live.values_mut() {
|
||||
l.missed += 1;
|
||||
}
|
||||
let declared: BTreeSet<u32> = decls.iter().map(|d| d.key).collect();
|
||||
self.dead.retain(|k| declared.contains(k));
|
||||
for d in &decls {
|
||||
if self.dead.contains(&d.key) {
|
||||
continue;
|
||||
}
|
||||
match self.live.get_mut(&d.key) {
|
||||
Some(l) if l.event == d.event => {
|
||||
l.missed = 0;
|
||||
if d.damaged {
|
||||
l.dying += 1;
|
||||
}
|
||||
if l.dying >= DYING {
|
||||
ops.push(Op::Stop(l.id));
|
||||
self.live.remove(&d.key);
|
||||
self.dead.insert(d.key);
|
||||
} else {
|
||||
ops.push(Op::Update(l.id, Self::voice(d, l.dying)));
|
||||
}
|
||||
}
|
||||
other => {
|
||||
// A new engine, or the same entity with another sound: the old one releases.
|
||||
if let Some(l) = other {
|
||||
ops.push(Op::Stop(l.id));
|
||||
}
|
||||
self.next_id = self.next_id.wrapping_add(1).max(1);
|
||||
let dying = u32::from(d.damaged);
|
||||
ops.push(Op::Start(self.next_id, Self::voice(d, dying)));
|
||||
self.live.insert(
|
||||
d.key,
|
||||
Live {
|
||||
id: self.next_id,
|
||||
event: d.event,
|
||||
missed: 0,
|
||||
dying,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Gone for good: the entity was removed (burnout, a hit, a crash).
|
||||
let gone: Vec<u32> = self
|
||||
.live
|
||||
.iter()
|
||||
.filter(|(_, l)| l.missed > GRACE)
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
for k in gone {
|
||||
if let Some(l) = self.live.remove(&k) {
|
||||
ops.push(Op::Stop(l.id));
|
||||
}
|
||||
}
|
||||
ops
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn d(key: u32, x: f64) -> Decl {
|
||||
Decl {
|
||||
key,
|
||||
event: "airdef:drone.shahed",
|
||||
at: DVec3::new(x, 50.0, 0.0),
|
||||
vel: DVec3::new(30.0, 0.0, 0.0),
|
||||
gain: 0.8,
|
||||
pitch: 1.0,
|
||||
damaged: false,
|
||||
}
|
||||
}
|
||||
fn starts(ops: &[Op]) -> usize {
|
||||
ops.iter().filter(|o| matches!(o, Op::Start(..))).count()
|
||||
}
|
||||
fn stops(ops: &[Op]) -> usize {
|
||||
ops.iter().filter(|o| matches!(o, Op::Stop(_))).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_engine_ignites_follows_and_stops_when_its_entity_goes() {
|
||||
let mut e = Engines::default();
|
||||
let ops = e.step(vec![d(1, 0.0)], false, DVec3::ZERO);
|
||||
assert_eq!(starts(&ops), 1);
|
||||
let Op::Start(id, v) = &ops[0] else { panic!() };
|
||||
assert_eq!((v.attack, v.loop_from, v.loop_to), (0.3, 0.2, 2.2));
|
||||
for i in 1..10 {
|
||||
let ops = e.step(vec![d(1, f64::from(i))], false, DVec3::ZERO);
|
||||
assert!(matches!(&ops[..], [Op::Update(u, v)] if u == id && v.at.x == f64::from(i)));
|
||||
}
|
||||
// Undeclared for a moment: still sounding; then gone.
|
||||
for _ in 0..GRACE {
|
||||
assert!(e.step(vec![], false, DVec3::ZERO).is_empty());
|
||||
}
|
||||
let ops = e.step(vec![], false, DVec3::ZERO);
|
||||
assert_eq!(ops, vec![Op::Stop(*id)]);
|
||||
assert!(e.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shot_down_engine_coughs_and_dies_and_stays_dead_while_it_falls() {
|
||||
let mut e = Engines::default();
|
||||
e.step(vec![d(1, 0.0)], false, DVec3::ZERO);
|
||||
let mut last = 1.0;
|
||||
let mut stopped_at = None;
|
||||
for t in 1..60 {
|
||||
let mut f = d(1, 0.0);
|
||||
f.damaged = true;
|
||||
let ops = e.step(vec![f], false, DVec3::ZERO);
|
||||
match ops.as_slice() {
|
||||
[Op::Update(_, v)] => {
|
||||
assert!(v.pitch < last, "the pitch falls");
|
||||
last = v.pitch;
|
||||
}
|
||||
[Op::Stop(_)] => stopped_at = stopped_at.or(Some(t)),
|
||||
[] => {}
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(stopped_at, Some(DYING));
|
||||
assert!(last < 0.6);
|
||||
// The wreck is gone, a new drone with the same key sounds again.
|
||||
e.step(vec![], false, DVec3::ZERO);
|
||||
assert_eq!(starts(&e.step(vec![d(1, 0.0)], false, DVec3::ZERO)), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_stops_every_engine_and_starts_them_under_new_ids() {
|
||||
let mut e = Engines::default();
|
||||
let a = e.step(vec![d(1, 0.0), d(2, 5.0)], false, DVec3::ZERO);
|
||||
let ops = e.step(vec![d(1, 100.0), d(2, 105.0)], true, DVec3::ZERO);
|
||||
assert_eq!((stops(&ops), starts(&ops)), (2, 2));
|
||||
let old: Vec<u32> = a.iter().filter_map(|o| if let Op::Start(i, _) = o { Some(*i) } else { None }).collect();
|
||||
for o in &ops {
|
||||
if let Op::Start(i, _) = o {
|
||||
assert!(!old.contains(i), "a new id, so nothing glides across the jump");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_sound_for_the_same_entity_restarts_it() {
|
||||
let mut e = Engines::default();
|
||||
e.step(vec![d(1, 0.0)], false, DVec3::ZERO);
|
||||
let mut other = d(1, 0.0);
|
||||
other.event = "airdef:drone.rocket";
|
||||
let ops = e.step(vec![other], false, DVec3::ZERO);
|
||||
assert_eq!((stops(&ops), starts(&ops)), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_nearest_are_kept_and_the_same_input_gives_the_same_ops() {
|
||||
let run = || {
|
||||
let mut e = Engines::default();
|
||||
let all: Vec<Decl> = (0..300).map(|i| d(i, f64::from(i) * 3.0)).collect();
|
||||
e.step(all, false, DVec3::ZERO)
|
||||
};
|
||||
let ops = run();
|
||||
assert_eq!(starts(&ops), MAX);
|
||||
assert_eq!(ops, run());
|
||||
let far = ops.iter().filter_map(|o| if let Op::Start(_, v) = o { Some(v.at.x) } else { None }).fold(0.0, f64::max);
|
||||
assert!(far <= (MAX as f64) * 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hundred_seek_cycles_leave_no_engine_behind() {
|
||||
let mut e = Engines::default();
|
||||
let mut open = std::collections::BTreeSet::new();
|
||||
for round in 0..100 {
|
||||
let ops = e.step(vec![d(1, round as f64), d(2, 0.0)], round % 3 == 0, DVec3::ZERO);
|
||||
for o in ops {
|
||||
match o {
|
||||
Op::Start(i, _) => {
|
||||
open.insert(i);
|
||||
}
|
||||
Op::Stop(i) => {
|
||||
assert!(open.remove(&i), "a stop for a source that was never started");
|
||||
}
|
||||
Op::Update(i, _) => assert!(open.contains(&i)),
|
||||
}
|
||||
}
|
||||
}
|
||||
for o in e.step(vec![], true, DVec3::ZERO) {
|
||||
if let Op::Stop(i) = o {
|
||||
open.remove(&i);
|
||||
}
|
||||
}
|
||||
assert!(open.is_empty(), "every start was stopped");
|
||||
}
|
||||
}
|
||||
@@ -308,10 +308,15 @@ fn plan_event(rec: &Recording, i: usize, floor: f64) -> Vec<Shot> {
|
||||
Some(Shot::new(a, b, &[(a, 1.0), (b, 1.4)], variants, head, "", i))
|
||||
};
|
||||
match e.kind {
|
||||
EventKind::Kill { drone, kind, sam, sam_kind, sam_index, missile } => {
|
||||
EventKind::Kill { drone, kind, sam, sam_kind, sam_index, missile, outcome } => {
|
||||
let sp = rec.sam(sam).map_or(pos - DVec3::new(150.0, 0.0, 0.0), |s| s.1);
|
||||
let name = format!("{} №{}", sam_kind.name(), sam_index + 1);
|
||||
let sub = format!("Перехват «{}»", kind.spec().name);
|
||||
let how = match outcome {
|
||||
crate::sim::outcome::Outcome::DisabledFalling => "",
|
||||
crate::sim::outcome::Outcome::Breakup => ": разрушение в воздухе",
|
||||
crate::sim::outcome::Outcome::AirDetonation => ": детонация в воздухе",
|
||||
};
|
||||
let sub = format!("Перехват «{}»{how}", kind.spec().name);
|
||||
let mt = missile.and_then(|m| rec.track(m).map(|t| (m, t.first as f64)));
|
||||
match mt {
|
||||
Some((m, tl)) => {
|
||||
@@ -638,7 +643,7 @@ mod tests {
|
||||
rec.events.push(Event {
|
||||
at: kill,
|
||||
pos,
|
||||
kind: EventKind::Kill { drone: 1, kind: DroneKind::Geran3, sam: 5, sam_kind: SamKind::S400, sam_index: 0, missile: missile.then_some(2) },
|
||||
kind: EventKind::Kill { drone: 1, kind: DroneKind::Geran3, sam: 5, sam_kind: SamKind::S400, sam_index: 0, missile: missile.then_some(2), outcome: Default::default() },
|
||||
});
|
||||
rec
|
||||
}
|
||||
|
||||
+87
-1
@@ -13,6 +13,7 @@ use horizon_mod_sdk::{
|
||||
};
|
||||
|
||||
mod cinema;
|
||||
mod engines;
|
||||
mod commands;
|
||||
mod layout;
|
||||
mod film;
|
||||
@@ -107,6 +108,11 @@ struct AirDef {
|
||||
punch: f32,
|
||||
/// Sounds on their way to the player at the speed of sound.
|
||||
pending: Vec<PendingSound>,
|
||||
/// The engines as persistent sound sources (#95), and the source generation they were started under.
|
||||
engines: engines::Engines,
|
||||
engine_gen: u32,
|
||||
/// Whether the engines are a replay's (a change of mode cuts them).
|
||||
engines_replay: bool,
|
||||
/// 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>>,
|
||||
@@ -149,6 +155,30 @@ fn switch_allowed(exporting: bool) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// An engine voice as a `render.sound-sources` entry.
|
||||
fn source_op(kind: render::SourceOpKind, id: u32, v: &engines::Voice) -> render::SourceOp {
|
||||
render::SourceOp {
|
||||
kind,
|
||||
id,
|
||||
event: v.event.to_string(),
|
||||
x: v.at.x,
|
||||
y: v.at.y,
|
||||
z: v.at.z,
|
||||
vx: v.vel.x as f32,
|
||||
vy: v.vel.y as f32,
|
||||
vz: v.vel.z as f32,
|
||||
gain: v.gain,
|
||||
pitch: v.pitch,
|
||||
looping: true,
|
||||
priority: 1.0,
|
||||
attack: v.attack,
|
||||
release: v.release,
|
||||
doppler: engines::DOPPLER,
|
||||
loop_from: v.loop_from,
|
||||
loop_to: v.loop_to,
|
||||
}
|
||||
}
|
||||
|
||||
/// A sound heard later: when, what, where, and how loud and high it was.
|
||||
struct PendingSound {
|
||||
due: u64,
|
||||
@@ -615,7 +645,59 @@ impl AirDef {
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(&mut self, out: Out) {
|
||||
/// The engines the simulation or the replay declared become persistent sound sources (#95):
|
||||
/// started, followed, stopped. The one-shots below stay as they were.
|
||||
fn step_engines(&mut self, out: &mut Out) {
|
||||
let replay = self.replay.as_ref().is_some_and(|r| r.filming());
|
||||
let cut = std::mem::take(&mut out.engine_cut) || replay != self.engines_replay;
|
||||
self.engines_replay = replay;
|
||||
let generation = render::sound_source_generation();
|
||||
if generation != self.engine_gen {
|
||||
// The game released every source (a world closed, a film began or ended).
|
||||
self.engines.reset();
|
||||
self.engine_gen = generation;
|
||||
}
|
||||
if self.engines.is_empty() && out.engines.is_empty() && !cut {
|
||||
return;
|
||||
}
|
||||
let ops: Vec<render::SourceOp> = self
|
||||
.engines
|
||||
.step(std::mem::take(&mut out.engines), cut, self.listener)
|
||||
.into_iter()
|
||||
.map(|op| match op {
|
||||
engines::Op::Start(id, v) => source_op(render::SourceOpKind::Start, id, &v),
|
||||
engines::Op::Update(id, v) => source_op(render::SourceOpKind::Update, id, &v),
|
||||
engines::Op::Stop(id) => render::SourceOp {
|
||||
kind: render::SourceOpKind::Stop,
|
||||
id,
|
||||
event: String::new(),
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0.0,
|
||||
vx: 0.0,
|
||||
vy: 0.0,
|
||||
vz: 0.0,
|
||||
gain: 0.0,
|
||||
pitch: 1.0,
|
||||
looping: false,
|
||||
priority: 1.0,
|
||||
attack: 0.0,
|
||||
release: 0.0,
|
||||
doppler: 0.0,
|
||||
loop_from: 0.0,
|
||||
loop_to: 0.0,
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
if !ops.is_empty() && !render::sound_sources(generation, &ops) {
|
||||
// Released between the two calls: start them again under the new generation.
|
||||
self.engines.reset();
|
||||
self.engine_gen = u32::MAX;
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(&mut self, mut out: Out) {
|
||||
self.step_engines(&mut out);
|
||||
if self.sim.shader {
|
||||
render::set_params(&self.shader_params(&out));
|
||||
}
|
||||
@@ -1404,6 +1486,9 @@ impl Mod for AirDef {
|
||||
listener: DVec3::ZERO,
|
||||
punch: 0.0,
|
||||
pending: Vec::new(),
|
||||
engines: engines::Engines::default(),
|
||||
engine_gen: u32::MAX,
|
||||
engines_replay: false,
|
||||
model_ids: std::collections::HashMap::new(),
|
||||
model_generation: u32::MAX,
|
||||
by_id: Vec::new(),
|
||||
@@ -1547,6 +1632,7 @@ impl Mod for AirDef {
|
||||
out.models.clear();
|
||||
out.particles.clear();
|
||||
out.sounds.clear();
|
||||
out.engines.clear();
|
||||
out.shake = None;
|
||||
}
|
||||
r.tick(&self.sim, &mut self.ground, self.listener, &mut out);
|
||||
|
||||
+202
-8
@@ -68,6 +68,8 @@ pub struct Player {
|
||||
fov: f32,
|
||||
/// The last tick whose events made their sounds.
|
||||
heard: f64,
|
||||
/// The replay time the engines were last declared at (a jump back or far ahead cuts them).
|
||||
engine_t: f64,
|
||||
/// Time stands still (Space, or the end reached).
|
||||
paused: bool,
|
||||
/// The film's own clock in seconds (the replay's moment follows from the plan).
|
||||
@@ -125,13 +127,23 @@ fn clock(ticks: f64) -> String {
|
||||
format!("{:02}:{:04.1}", (s / 60.0) as u32, s % 60.0)
|
||||
}
|
||||
|
||||
/// How a kill ended, when it was not the plain fall.
|
||||
fn outcome_note(outcome: crate::sim::outcome::Outcome) -> &'static str {
|
||||
use crate::sim::outcome::Outcome;
|
||||
match outcome {
|
||||
Outcome::DisabledFalling => "",
|
||||
Outcome::Breakup => " (разрушение)",
|
||||
Outcome::AirDetonation => " (детонация в воздухе)",
|
||||
}
|
||||
}
|
||||
|
||||
/// What an event was, in a line.
|
||||
pub fn title(rec: &Recording, i: usize) -> String {
|
||||
let e = &rec.events[i];
|
||||
match e.kind {
|
||||
EventKind::Kill { kind, sam_kind, sam_index, missile, .. } => {
|
||||
EventKind::Kill { kind, sam_kind, sam_index, missile, outcome, .. } => {
|
||||
let how = if missile.is_some() { "ракетой" } else { "огнём пушек" };
|
||||
format!("{} №{} сбивает «{}» {how}", sam_kind.name(), sam_index + 1, kind.spec().name)
|
||||
format!("{} №{} сбивает «{}» {how}{}", sam_kind.name(), sam_index + 1, kind.spec().name, outcome_note(outcome))
|
||||
}
|
||||
EventKind::Hit { kind, object: Some(o), .. } => format!("«{}» поражает объект №{}", kind.spec().name, o + 1),
|
||||
EventKind::Hit { kind, sam: Some((sk, si)), .. } => format!("«{}» бьёт по {} №{}", kind.spec().name, sk.name(), si + 1),
|
||||
@@ -146,8 +158,8 @@ pub fn title(rec: &Recording, i: usize) -> String {
|
||||
pub fn row(rec: &Recording, i: usize) -> String {
|
||||
let e = &rec.events[i];
|
||||
let what = match e.kind {
|
||||
EventKind::Kill { kind, sam_kind, sam_index, missile, .. } => {
|
||||
format!("{}-{} > {}{}", sam_kind.name(), sam_index + 1, kind.spec().name, if missile.is_none() { " (пушки)" } else { "" })
|
||||
EventKind::Kill { kind, sam_kind, sam_index, missile, outcome, .. } => {
|
||||
format!("{}-{} > {}{}{}", sam_kind.name(), sam_index + 1, kind.spec().name, if missile.is_none() { " (пушки)" } else { "" }, outcome_note(outcome))
|
||||
}
|
||||
EventKind::Hit { kind, object: Some(o), .. } => format!("{} > объект {}", kind.spec().name, o + 1),
|
||||
EventKind::Hit { kind, sam: Some((sk, si)), .. } => format!("{} > {}-{}", kind.spec().name, sk.name(), si + 1),
|
||||
@@ -163,8 +175,9 @@ pub fn row(rec: &Recording, i: usize) -> String {
|
||||
pub fn interest(rec: &Recording, i: usize) -> f64 {
|
||||
let e = &rec.events[i];
|
||||
match e.kind {
|
||||
EventKind::Kill { kind, missile, .. } => {
|
||||
EventKind::Kill { kind, missile, outcome, .. } => {
|
||||
let spec = kind.spec();
|
||||
let bonus = if outcome == crate::sim::outcome::Outcome::AirDetonation { 2.0 } else { 0.0 };
|
||||
let class = match spec.class {
|
||||
Class::Ballistic => 10.0,
|
||||
Class::Supersonic => 7.0,
|
||||
@@ -179,7 +192,7 @@ pub fn interest(rec: &Recording, i: usize) -> f64 {
|
||||
};
|
||||
// A kill close to what it was going for.
|
||||
let close = rec.objects.iter().chain(rec.sams.iter().map(|s| &s.2)).any(|o| o.distance(e.pos) < 40.0);
|
||||
class + rare + if spec.decoy { -3.0 } else { 0.0 } + if missile.is_none() { 1.0 } else { 0.0 } + if close { 3.0 } else { 0.0 }
|
||||
bonus + class + rare + if spec.decoy { -3.0 } else { 0.0 } + if missile.is_none() { 1.0 } else { 0.0 } + if close { 3.0 } else { 0.0 }
|
||||
}
|
||||
EventKind::Hit { power, object, sam, .. } => {
|
||||
power as f64 * 1.5 + if object.is_some() { 6.0 } else if sam.is_some() { 5.0 } else { 0.0 }
|
||||
@@ -254,6 +267,7 @@ impl Player {
|
||||
look: DVec3::NEG_Z,
|
||||
fov: 50.0,
|
||||
heard: start,
|
||||
engine_t: -1.0,
|
||||
paused: false,
|
||||
tau: 0.0,
|
||||
wait: false,
|
||||
@@ -432,6 +446,48 @@ impl Player {
|
||||
sim.draw_ghosts(rec, self.t, GHOSTS, out);
|
||||
self.direct(rec, ground);
|
||||
self.sounds(rec, listener, out);
|
||||
self.engines(rec, listener, false, out);
|
||||
}
|
||||
|
||||
/// The engines as the recording had them at the replay's time, for persistent sound sources
|
||||
/// (#95): every flying drone and lit interceptor, where its track puts it now, moving as the
|
||||
/// track moves, placed round the listener like `sounds`. Taken from the tracks and the replay's
|
||||
/// time, never from the live entities. Silent while time stands still, runs too fast to
|
||||
/// follow, or a title card shows; a seek, a clip change or a new shot cuts them (they start
|
||||
/// afresh), so no sound glides across a jump.
|
||||
fn engines(&mut self, rec: &Recording, listener: DVec3, shot_changed: bool, out: &mut Out) {
|
||||
let moved = self.t - self.engine_t;
|
||||
if self.engine_t < 0.0 || shot_changed || moved < -1e-9 || moved > self.rate * 1.5 + 2.0 {
|
||||
out.engine_cut = true;
|
||||
}
|
||||
self.engine_t = self.t;
|
||||
if self.rate <= 0.0 || self.rate > 4.0 || self.card > 0 {
|
||||
return;
|
||||
}
|
||||
let step = STEP as f64;
|
||||
for (k, tr) in rec.tracks.iter().enumerate() {
|
||||
let Some(p) = tr.at(rec.origin, self.t) else { continue };
|
||||
let ahead = tr.at(rec.origin, self.t + step).unwrap_or(p);
|
||||
let behind = tr.at(rec.origin, self.t - step).unwrap_or(p);
|
||||
// Blocks per tick of the replay: the track's, times how fast the replay runs.
|
||||
let vel = (ahead - behind) / (2.0 * step) * self.rate;
|
||||
let key = 0x8000_0000 | k as u32;
|
||||
let at = listener + (p - self.eye);
|
||||
match tr.what {
|
||||
What::Drone(kind) => {
|
||||
if let Some((event, pitch)) = kind.engine_voice() {
|
||||
let falling = tr.fell.is_some_and(|f| self.t >= f as f64);
|
||||
out.engine(key, event, at, vel, 0.8, pitch, falling);
|
||||
}
|
||||
}
|
||||
What::Missile(kind, _) => {
|
||||
if self.t - tr.first as f64 > kind.lit_after() as f64 {
|
||||
let (event, gain, pitch) = kind.engine_voice();
|
||||
out.engine(key, event, at, vel, gain, pitch, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sounds as the camera hears them: moved so the player stands where the camera is.
|
||||
@@ -439,13 +495,16 @@ impl Player {
|
||||
let heard = |p: DVec3| listener + (p - self.eye);
|
||||
for e in rec.events.iter().filter(|e| (self.heard..self.t).contains(&(e.at as f64))) {
|
||||
let event = match e.kind {
|
||||
EventKind::Kill { .. } => "airdef:blast.air",
|
||||
EventKind::Kill { kind, outcome, .. } => crate::sim::outcome::look(kind, outcome).3,
|
||||
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][(power as usize).min(4)],
|
||||
EventKind::SamLost { .. } => "airdef:blast.big",
|
||||
EventKind::Split { .. } => "airdef:boom.sonic",
|
||||
};
|
||||
let gain = if let EventKind::Kill { kind, .. } = e.kind { crate::sim::record::kill_look(kind).2 } else { 1.0 };
|
||||
let gain = if let EventKind::Kill { kind, outcome, .. } = e.kind { crate::sim::outcome::look(kind, outcome).2 } else { 1.0 };
|
||||
out.sound(event, heard(e.pos), gain, 1.0);
|
||||
if let EventKind::Kill { outcome: crate::sim::outcome::Outcome::AirDetonation, .. } = e.kind {
|
||||
out.sound("airdef:boom.sonic", heard(e.pos), 0.8, 1.2);
|
||||
}
|
||||
}
|
||||
for tr in rec.tracks.iter().filter(|tr| (self.heard..self.t).contains(&(tr.first as f64))) {
|
||||
if let What::Missile(kind, _) = tr.what
|
||||
@@ -498,6 +557,7 @@ impl Player {
|
||||
sim.draw_ghosts(rec, self.t, base, out);
|
||||
if !self.wait {
|
||||
self.sounds(rec, listener, out);
|
||||
self.engines(rec, listener, changed, out);
|
||||
} else {
|
||||
self.heard = self.t;
|
||||
}
|
||||
@@ -990,3 +1050,137 @@ mod surface_tests {
|
||||
assert_eq!(above_surface(eye, Some(None)), eye);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod engine_tests {
|
||||
use super::*;
|
||||
use crate::engines::{Engines, Op};
|
||||
use crate::sim::{MissileKind, record::Recording};
|
||||
|
||||
fn recording() -> Recording {
|
||||
let mut rec = Recording::blank(2000);
|
||||
// A Shahed flying 1000 blocks east over 1000 ticks, shot down at 600; an interceptor from 400.
|
||||
rec.straight(1, What::Drone(DroneKind::Shahed), 0, 1000, DVec3::new(0.0, 80.0, 0.0), DVec3::new(1000.0, 80.0, 0.0));
|
||||
rec.tracks[0].fell = Some(600);
|
||||
rec.straight(2, What::Missile(MissileKind::Pac3, 0), 400, 700, DVec3::new(0.0, 0.0, 0.0), DVec3::new(600.0, 80.0, 0.0));
|
||||
rec
|
||||
}
|
||||
fn player(rec: &Recording) -> Player {
|
||||
let mut p = Player::new(1, Mode::Full, &Sim::new(1));
|
||||
p.eye = DVec3::new(500.0, 80.0, 30.0);
|
||||
let _ = rec;
|
||||
p
|
||||
}
|
||||
/// A host that remembers which sources run.
|
||||
#[derive(Default)]
|
||||
struct Host(std::collections::BTreeMap<u32, [f64; 3]>);
|
||||
impl Host {
|
||||
fn apply(&mut self, ops: Vec<Op>) {
|
||||
for o in ops {
|
||||
match o {
|
||||
Op::Start(id, v) => assert!(self.0.insert(id, [v.at.x, v.at.y, v.at.z]).is_none()),
|
||||
Op::Update(id, v) => assert!(self.0.insert(id, [v.at.x, v.at.y, v.at.z]).is_some()),
|
||||
Op::Stop(id) => assert!(self.0.remove(&id).is_some()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engines_follow_the_recorded_tracks_at_the_replays_time_around_the_listener() {
|
||||
let rec = recording();
|
||||
let mut p = player(&rec);
|
||||
let listener = DVec3::new(-5.0, 70.0, 12.0);
|
||||
p.t = 450.0;
|
||||
p.rate = 1.0;
|
||||
let mut out = Out::default();
|
||||
p.engines(&rec, listener, false, &mut out);
|
||||
// The first declaration of a replay starts the engines afresh.
|
||||
assert!(out.engine_cut);
|
||||
assert_eq!(out.engines.len(), 2);
|
||||
let drone = out.engines.iter().find(|d| d.key == 0x8000_0000).unwrap();
|
||||
// The drone at tick 450 is at x = 450; the eye stands at x = 500; the player at -5.
|
||||
assert!((drone.at.x - (listener.x + (450.0 - 500.0))).abs() < 1e-6, "{:?}", drone.at);
|
||||
assert!((drone.at.z - (listener.z + (0.0 - 30.0))).abs() < 1e-6);
|
||||
assert!(!drone.damaged && drone.vel.x > 0.0);
|
||||
// The interceptor is lit after its cold launch only.
|
||||
p.t = 401.0;
|
||||
let mut out = Out::default();
|
||||
p.engines(&rec, listener, false, &mut out);
|
||||
assert_eq!(out.engines.iter().filter(|d| d.key == 0x8000_0001).count(), usize::from(401.0 - 400.0 > f64::from(MissileKind::Pac3.lit_after())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_seek_and_cut_release_and_restart_engines_without_leaving_any() {
|
||||
let rec = recording();
|
||||
let mut p = player(&rec);
|
||||
let mut engines = Engines::default();
|
||||
let mut host = Host::default();
|
||||
let listener = DVec3::ZERO;
|
||||
let mut tick = |p: &mut Player, engines: &mut Engines, host: &mut Host, shot: bool| {
|
||||
let mut out = Out::default();
|
||||
p.engines(&rec, listener, shot, &mut out);
|
||||
host.apply(engines.step(std::mem::take(&mut out.engines), out.engine_cut, listener));
|
||||
};
|
||||
p.t = 100.0;
|
||||
p.rate = 1.0;
|
||||
for _ in 0..30 {
|
||||
p.t += 1.0;
|
||||
tick(&mut p, &mut engines, &mut host, false);
|
||||
}
|
||||
assert_eq!(host.0.len(), 1, "the drone alone");
|
||||
// Paused: the sources go after their grace.
|
||||
p.rate = 0.0;
|
||||
for _ in 0..6 {
|
||||
tick(&mut p, &mut engines, &mut host, false);
|
||||
}
|
||||
assert!(host.0.is_empty());
|
||||
// Played on again: they start again without a cut.
|
||||
p.rate = 1.0;
|
||||
tick(&mut p, &mut engines, &mut host, false);
|
||||
assert_eq!(host.0.len(), 1);
|
||||
let before: Vec<u32> = host.0.keys().copied().collect();
|
||||
// A seek back and forward, and a hundred shot changes: always one drone, a new source each time.
|
||||
p.t = 50.0;
|
||||
tick(&mut p, &mut engines, &mut host, false);
|
||||
assert_eq!(host.0.len(), 1);
|
||||
assert!(!host.0.contains_key(&before[0]), "the seek restarted the source");
|
||||
p.t = 380.0;
|
||||
tick(&mut p, &mut engines, &mut host, false);
|
||||
for i in 0..100 {
|
||||
p.t = 380.0 + f64::from(i % 7);
|
||||
tick(&mut p, &mut engines, &mut host, i % 3 == 0);
|
||||
assert!(host.0.len() <= 2);
|
||||
}
|
||||
// The drone is shot down at 600: its engine dies away and stays dead while the wreck falls
|
||||
// (the interceptor, flying till 700, is the one source left).
|
||||
p.t = 599.0;
|
||||
tick(&mut p, &mut engines, &mut host, false);
|
||||
let mut silent_after = None;
|
||||
for i in 0..40 {
|
||||
p.t = 600.0 + f64::from(i);
|
||||
tick(&mut p, &mut engines, &mut host, false);
|
||||
if host.0.len() == 1 && silent_after.is_none() {
|
||||
silent_after = Some(i);
|
||||
}
|
||||
}
|
||||
assert!(silent_after.is_some_and(|i| i <= 25), "{silent_after:?}");
|
||||
// The film ends: nothing is left.
|
||||
p.rate = 0.0;
|
||||
for _ in 0..6 {
|
||||
tick(&mut p, &mut engines, &mut host, false);
|
||||
}
|
||||
assert!(host.0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fast_replay_is_silent() {
|
||||
let rec = recording();
|
||||
let mut p = player(&rec);
|
||||
p.t = 100.0;
|
||||
p.rate = 16.0;
|
||||
let mut out = Out::default();
|
||||
p.engines(&rec, DVec3::ZERO, false, &mut out);
|
||||
assert!(out.engines.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+114
-40
@@ -20,6 +20,8 @@ mod ew;
|
||||
pub mod lights;
|
||||
mod logistics;
|
||||
mod orders;
|
||||
mod voices;
|
||||
pub mod outcome;
|
||||
mod units;
|
||||
mod why;
|
||||
pub mod world;
|
||||
@@ -31,6 +33,7 @@ pub use ew::EW_RADIUS;
|
||||
pub use damage::{Comps, Part, PartState};
|
||||
pub use sight::{Ident, Track};
|
||||
use damage::dice;
|
||||
use outcome::{Impact, Outcome};
|
||||
pub use units::{Family, SamKind};
|
||||
use units::Muzzle;
|
||||
pub use raid::{Approach, Aim, RaidOrder};
|
||||
@@ -79,6 +82,10 @@ pub struct Out {
|
||||
pub models: Vec<Model>,
|
||||
pub particles: Vec<Particle>,
|
||||
pub sounds: Vec<(&'static str, DVec3, f32, f32)>,
|
||||
/// The engines sounding this tick (persistent sources, `crate::engines`), and whether the
|
||||
/// picture cut (a replay seek or a new shot): every engine then starts afresh.
|
||||
pub engines: Vec<crate::engines::Decl>,
|
||||
pub engine_cut: bool,
|
||||
pub commands: Vec<String>,
|
||||
/// Camera jolt (strength, seconds), the strongest this tick.
|
||||
pub shake: Option<(f32, f32)>,
|
||||
@@ -107,6 +114,10 @@ impl Out {
|
||||
pub fn sound(&mut self, event: &'static str, at: DVec3, volume: f32, pitch: f32) {
|
||||
self.sounds.push((event, at, volume, pitch));
|
||||
}
|
||||
/// An engine that sounds: `vel` is the simulation's, blocks a tick.
|
||||
pub fn engine(&mut self, key: u32, event: &'static str, at: DVec3, vel: DVec3, gain: f32, pitch: f32, damaged: bool) {
|
||||
self.engines.push(crate::engines::Decl { key, event, at, vel: vel * 20.0, gain, pitch, damaged });
|
||||
}
|
||||
fn jolt(&mut self, strength: f32, seconds: f32) {
|
||||
if self.shake.is_none_or(|(s, _)| s < strength) {
|
||||
self.shake = Some((strength.min(1.0), seconds));
|
||||
@@ -1375,6 +1386,8 @@ enum Fx {
|
||||
Fireball { pos: DVec3, size: f64, age: u32, life: u32, spin: DQuat },
|
||||
/// A falling piece of a drone.
|
||||
Debris { pos: DVec3, vel: DVec3, rot: DQuat, spin: DQuat, age: u32 },
|
||||
/// A piece thrown off by a breakup or an air detonation: its own drag and life.
|
||||
Piece { pos: DVec3, vel: DVec3, rot: DQuat, piece: outcome::Piece, age: u32 },
|
||||
/// A glowing red tracer round.
|
||||
Tracer { pos: DVec3, vel: DVec3, life: u32 },
|
||||
}
|
||||
@@ -1680,6 +1693,9 @@ impl Sim {
|
||||
out.particles("minecraft:large_smoke", d.pos, 0.2, DVec3::new(0.0, 0.05, 0.0), 0.02, 1);
|
||||
}
|
||||
out.particles("minecraft:flame", d.pos, 0.25, DVec3::ZERO, 0.02, 2);
|
||||
if let Some((event, pitch)) = d.kind.engine_voice() {
|
||||
out.engine(d.id, event, d.pos, d.vel, 0.8, pitch, true);
|
||||
}
|
||||
if sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.3, &sweep, now) || d.age > 600 {
|
||||
// The wreck's warhead may still go off where it lands.
|
||||
let warhead = if spec.decoy || !self.rng.chance(0.35) { 0 } else { spec.warhead.min(2) };
|
||||
@@ -1923,9 +1939,9 @@ impl Sim {
|
||||
out.particles("minecraft:cloud", back - d.vel.normalize_or_zero() * 1.5, 0.15, DVec3::ZERO, 0.005, 1);
|
||||
}
|
||||
}
|
||||
let (event, pitch, every) = spec.sound;
|
||||
if d.age % every == 0 {
|
||||
out.sound(event, d.pos, 1.0, pitch);
|
||||
// The engine is one source that follows the drone (#95).
|
||||
if let Some((event, pitch)) = d.kind.engine_voice() {
|
||||
out.engine(d.id, event, d.pos, d.vel, 0.8, pitch, false);
|
||||
}
|
||||
}
|
||||
for (drone, pos) in splits {
|
||||
@@ -2094,11 +2110,27 @@ impl Sim {
|
||||
self.fx.push((id, Fx::Fireball { pos, size, age: 0, life, spin }));
|
||||
}
|
||||
|
||||
/// A drone hit by air defence: a flash, pieces, and it falls burning.
|
||||
fn shoot_down(&mut self, idx: usize, by: u32, missile: Option<u32>, out: &mut Out) {
|
||||
/// A drone hit by the guns of system `by`.
|
||||
fn shoot_down(&mut self, idx: usize, by: u32, missile: Option<u32>, out: &mut Out) -> Option<Outcome> {
|
||||
let kind = self.sams.iter().find(|s| s.id == by).map_or(SamKind::Gepard, |s| s.kind);
|
||||
let shell = kind.spec().gun.as_ref().map_or(5.0, |g| g.shell);
|
||||
let at = self.drones[idx].pos;
|
||||
self.shoot_down_by(idx, by, missile, Impact::gun(kind, at, shell), out)
|
||||
}
|
||||
|
||||
/// A drone brought down by `imp` from system `by`: how it ends is rolled once (combat randomness, so a
|
||||
/// detonation really means no ground impact), recorded with the kill, and drawn from the visual randomness
|
||||
/// only. The kill is counted here and nowhere else.
|
||||
fn shoot_down_by(&mut self, idx: usize, by: u32, missile: Option<u32>, imp: Impact, out: &mut Out) -> Option<Outcome> {
|
||||
if !self.drones[idx].alive() {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let dkind = self.drones[idx].kind;
|
||||
let roll = self.rng.f();
|
||||
let outcome = outcome::choose(dkind, &imp, roll);
|
||||
let spec = dkind.spec();
|
||||
// A broken-up target's warhead section may stay whole: it falls on as a wreck (and may go off on landing).
|
||||
let keeps_warhead = outcome == Outcome::Breakup && !spec.decoy && spec.warhead > 0 && self.rng.chance(outcome::WARHEAD_SURVIVES);
|
||||
let sector = self.drones[idx].sector as usize;
|
||||
self.learn(sector, true);
|
||||
let until = self.tick + 70;
|
||||
@@ -2106,33 +2138,72 @@ impl Sim {
|
||||
s.kill_view = Some((self.drones[idx].id, self.drones[idx].pos, until));
|
||||
}
|
||||
if let Some((i, s)) = self.sams.iter().enumerate().find(|(_, s)| s.id == by) {
|
||||
let (drone, kind, pos) = (self.drones[idx].id, self.drones[idx].kind, self.drones[idx].pos);
|
||||
let kill = record::EventKind::Kill { drone, kind, sam: by, sam_kind: s.kind, sam_index: i, missile };
|
||||
let (drone, kind, pos) = (self.drones[idx].id, dkind, self.drones[idx].pos);
|
||||
let kill = record::EventKind::Kill { drone, kind, sam: by, sam_kind: s.kind, sam_index: i, missile, outcome };
|
||||
self.log(pos, kill);
|
||||
}
|
||||
let d = &mut self.drones[idx];
|
||||
let before = d.vel;
|
||||
d.flight = Flight::Falling;
|
||||
d.age = 0;
|
||||
d.vel = d.vel * 0.6 + DVec3::new(0.0, 0.15, 0.0);
|
||||
d.vel = match outcome {
|
||||
Outcome::DisabledFalling => d.vel * 0.6 + DVec3::new(0.0, 0.15, 0.0),
|
||||
_ => d.vel * 0.35 + DVec3::new(0.0, 0.1, 0.0),
|
||||
};
|
||||
let pos = d.pos;
|
||||
let vel = d.vel;
|
||||
self.kills += 1;
|
||||
if let Some(s) = self.sams.iter_mut().find(|s| s.id == by) {
|
||||
s.kills += 1;
|
||||
}
|
||||
// The look of the kill goes by the warhead it blew up (replays use the same).
|
||||
let (size, life, gain) = record::kill_look(self.drones[idx].kind);
|
||||
// The look of the kill goes by the warhead it blew up and by how it ended (replays use the same).
|
||||
let (size, life, gain, sound) = outcome::look(dkind, outcome);
|
||||
self.fireball(pos, size as f64, life / 9);
|
||||
out.particles("minecraft:explosion", pos, 0.6, DVec3::ZERO, 0.0, 4);
|
||||
out.particles("minecraft:large_smoke", pos, 1.0, DVec3::ZERO, 0.06, 25);
|
||||
out.particles("minecraft:firework", pos, 0.3, DVec3::ZERO, 0.35, 30);
|
||||
out.sound("airdef:blast.air", pos, gain, 1.0 + self.vis.f() as f32 * 0.2);
|
||||
for _ in 0..5 {
|
||||
let id = fx_ident(&mut self.fx_next);
|
||||
let v = vel + DVec3::new(self.vis.s(), self.vis.f() * 0.8, self.vis.s()) * 0.35;
|
||||
let spin = DQuat::from_euler(glam::EulerRot::XYZ, self.vis.s() * 0.4, self.vis.s() * 0.4, self.vis.s() * 0.4);
|
||||
self.fx.push((id, Fx::Debris { pos, vel: v, rot: DQuat::IDENTITY, spin, age: 0 }));
|
||||
let pitch = 1.0 + self.vis.f() as f32 * 0.2;
|
||||
match outcome {
|
||||
Outcome::DisabledFalling => {
|
||||
out.particles("minecraft:explosion", pos, 0.6, DVec3::ZERO, 0.0, 4);
|
||||
out.particles("minecraft:large_smoke", pos, 1.0, DVec3::ZERO, 0.06, 25);
|
||||
out.particles("minecraft:firework", pos, 0.3, DVec3::ZERO, 0.35, 30);
|
||||
for _ in 0..5 {
|
||||
let id = fx_ident(&mut self.fx_next);
|
||||
let v = vel + DVec3::new(self.vis.s(), self.vis.f() * 0.8, self.vis.s()) * 0.35;
|
||||
let spin = DQuat::from_euler(glam::EulerRot::XYZ, self.vis.s() * 0.4, self.vis.s() * 0.4, self.vis.s() * 0.4);
|
||||
self.fx.push((id, Fx::Debris { pos, vel: v, rot: DQuat::IDENTITY, spin, age: 0 }));
|
||||
}
|
||||
}
|
||||
Outcome::Breakup => {
|
||||
out.particles("minecraft:explosion", pos, 0.8, DVec3::ZERO, 0.0, 3);
|
||||
out.particles("minecraft:large_smoke", pos, 1.4, DVec3::ZERO, 0.08, 30);
|
||||
out.particles("minecraft:firework", pos, 0.4, DVec3::ZERO, 0.3, 14);
|
||||
}
|
||||
Outcome::AirDetonation => {
|
||||
out.particles("minecraft:flash", pos, 0.0, DVec3::ZERO, 0.0, 1);
|
||||
out.particles("minecraft:explosion_emitter", pos, 0.8, DVec3::ZERO, 0.0, 2);
|
||||
out.particles("minecraft:large_smoke", pos, size * 0.3, DVec3::new(0.0, 0.05, 0.0), 0.07, if self.shader { 20 } else { 40 });
|
||||
out.particles("minecraft:firework", pos, 0.6, DVec3::ZERO, 0.5, 50);
|
||||
// The shock arrives after the flash (`boom` sounds travel); the warhead's blast hurts what is near.
|
||||
out.sound("airdef:boom.sonic", pos, 0.8, 1.2);
|
||||
self.blast_damage(pos, 0, out);
|
||||
}
|
||||
}
|
||||
out.sound(sound, pos, gain, pitch);
|
||||
let seed = self.vis.next();
|
||||
let mut alive = self.fx.iter().filter(|(_, f)| matches!(f, Fx::Piece { .. })).count();
|
||||
for p in outcome::pieces(seed, outcome, before) {
|
||||
if alive + 1 > outcome::PIECES_MAX {
|
||||
break;
|
||||
}
|
||||
let id = fx_ident(&mut self.fx_next);
|
||||
let rot = DQuat::from_euler(glam::EulerRot::XYZ, self.vis.s(), self.vis.s(), self.vis.s());
|
||||
self.fx.push((id, Fx::Piece { pos, vel: p.vel, rot, piece: p, age: 0 }));
|
||||
alive += 1;
|
||||
}
|
||||
if outcome == Outcome::AirDetonation || (outcome == Outcome::Breakup && !keeps_warhead) {
|
||||
// The main mass is gone: nothing falls to the ground and nothing goes off there.
|
||||
self.drones.remove(idx);
|
||||
}
|
||||
Some(outcome)
|
||||
}
|
||||
|
||||
// ------------------------------------------------ air defence
|
||||
@@ -2486,7 +2557,7 @@ impl Sim {
|
||||
}
|
||||
for (t, by) in gun_hits {
|
||||
if let Some(i) = self.drones.iter().position(|d| d.id == t) {
|
||||
self.shoot_down(i, by, None, out);
|
||||
let _ = self.shoot_down(i, by, None, out);
|
||||
}
|
||||
}
|
||||
for i in 0..self.sams.len() {
|
||||
@@ -2633,24 +2704,12 @@ impl Sim {
|
||||
done.push((m.id, None, false, m.sam));
|
||||
continue;
|
||||
}
|
||||
if lit && spec.flame == 0.0 {
|
||||
if m.age % 20 == 1 {
|
||||
out.sound("airdef:drone.lancet", m.pos, 0.8, 1.6);
|
||||
}
|
||||
} else if lit {
|
||||
if (m.age - spec.cold) % 46 == 1 {
|
||||
let pitch = match m.kind {
|
||||
MissileKind::Pac3 => 1.0,
|
||||
MissileKind::M57e6 => 1.12,
|
||||
MissileKind::M48n6 => 0.88,
|
||||
MissileKind::M77n6 => 0.8,
|
||||
MissileKind::IrisT | MissileKind::Amraam => 1.05,
|
||||
MissileKind::M9m317 => 0.9,
|
||||
MissileKind::M9m338 => 1.15,
|
||||
MissileKind::P1sun => 1.7,
|
||||
};
|
||||
out.sound("airdef:missile.flight", m.pos, 1.0, pitch);
|
||||
}
|
||||
if lit {
|
||||
// The motor is one source that follows the missile from ignition on (#95).
|
||||
let (event, gain, pitch) = m.kind.engine_voice();
|
||||
out.engine(0x4000_0000 | m.id, event, m.pos, m.vel, gain, pitch, false);
|
||||
}
|
||||
if lit && spec.flame > 0.0 {
|
||||
let back = m.pos - new * 1.4 * spec.scale;
|
||||
out.particles("minecraft:flame", back, 0.05, -new * 0.1, 0.01, 2);
|
||||
if m.age % 2 == 0 {
|
||||
@@ -2679,7 +2738,8 @@ impl Sim {
|
||||
if let Some(t) = target.and_then(|t| self.drones.iter().position(|d| d.id == t)) {
|
||||
self.drones[t].engaged = self.drones[t].engaged.saturating_sub(1);
|
||||
if kill {
|
||||
self.shoot_down(t, sam, Some(mid), out);
|
||||
let (at, closing) = (self.drones[t].pos, (m.vel - self.drones[t].vel).length());
|
||||
let _ = self.shoot_down_by(t, sam, Some(mid), Impact::missile(m.kind, at, closing), out);
|
||||
}
|
||||
}
|
||||
// A missile that lost its target frees it for others.
|
||||
@@ -2730,6 +2790,20 @@ impl Sim {
|
||||
let landed = ground.at(*pos, now).is_some_and(|g| pos.y <= g);
|
||||
!landed && *age < 200
|
||||
}
|
||||
Fx::Piece { pos, vel, rot, piece, age } => {
|
||||
*age += 1;
|
||||
outcome::fly(pos, vel, piece.drag);
|
||||
*rot = piece.spin * *rot;
|
||||
out.model(id, "airdef:debris", *pos, *rot, piece.scale, false);
|
||||
if piece.burns {
|
||||
out.particles("minecraft:large_smoke", *pos, 0.1, DVec3::new(0.0, 0.03, 0.0), 0.01, 1);
|
||||
if *age % 3 == 0 {
|
||||
out.particles("minecraft:flame", *pos, 0.1, DVec3::ZERO, 0.01, 1);
|
||||
}
|
||||
}
|
||||
let landed = ground.at(*pos, now).is_some_and(|g| pos.y <= g);
|
||||
!landed && *age < piece.life
|
||||
}
|
||||
Fx::Tracer { pos, vel, life } => {
|
||||
*pos += *vel;
|
||||
vel.y -= 0.02;
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
//! What a kill does to the air target (#97): it falls burning on, it breaks up, or its warhead goes
|
||||
//! off in the air. The chances are game profiles, all here and nowhere else; they are not the real
|
||||
//! weapons' figures. The roll is combat randomness (a detonation means no ground impact), one draw
|
||||
//! per kill; everything drawn from it (pieces, sparks) is decoration and uses only the visual RNG.
|
||||
//! The outcome is recorded with the kill, so a replay shows it and never rolls again.
|
||||
use super::*;
|
||||
|
||||
/// How an air target ended up when it was brought down.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub enum Outcome {
|
||||
/// Control lost: the burning hull falls on with what momentum it had. A recording without an
|
||||
/// outcome (an old one) is read as this, which is what every kill used to be.
|
||||
#[default]
|
||||
DisabledFalling,
|
||||
/// The airframe tears into several pieces; the warhead section may survive and fall on.
|
||||
Breakup,
|
||||
/// The warhead goes off in the air: a flash and a rain of debris, nothing left to hit the ground.
|
||||
AirDetonation,
|
||||
}
|
||||
|
||||
/// What brought the target down.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum KillCause {
|
||||
/// Shells of this system's guns.
|
||||
Gun(SamKind),
|
||||
Missile(MissileKind),
|
||||
}
|
||||
|
||||
/// A hit, as far as the outcome is concerned.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Impact {
|
||||
pub cause: KillCause,
|
||||
/// Game energy of the hit (see `energy_of`).
|
||||
pub energy: f64,
|
||||
pub at: DVec3,
|
||||
/// Closing speed, blocks a tick.
|
||||
pub rel_speed: f64,
|
||||
}
|
||||
|
||||
impl Impact {
|
||||
pub fn gun(sam: SamKind, at: DVec3, rel_speed: f64) -> Self {
|
||||
Impact { cause: KillCause::Gun(sam), energy: energy_of(KillCause::Gun(sam)), at, rel_speed }
|
||||
}
|
||||
pub fn missile(kind: MissileKind, at: DVec3, rel_speed: f64) -> Self {
|
||||
Impact { cause: KillCause::Missile(kind), energy: energy_of(KillCause::Missile(kind)), at, rel_speed }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the profiles: the one place to tune ------------------------------------------------------
|
||||
|
||||
/// Game energy of one hit: a shell is small, an interceptor's warhead is big.
|
||||
pub fn energy_of(cause: KillCause) -> f64 {
|
||||
match cause {
|
||||
KillCause::Gun(SamKind::Gepard) => 0.15,
|
||||
KillCause::Gun(_) => 0.13,
|
||||
KillCause::Missile(m) => match m {
|
||||
MissileKind::Pac3 => 1.0,
|
||||
MissileKind::M57e6 => 0.5,
|
||||
MissileKind::M48n6 => 1.2,
|
||||
MissileKind::M77n6 => 1.6,
|
||||
MissileKind::IrisT => 0.7,
|
||||
MissileKind::Amraam => 0.6,
|
||||
MissileKind::M9m317 => 0.8,
|
||||
MissileKind::M9m338 => 0.7,
|
||||
// Rams: a lot of speed, no warhead to speak of.
|
||||
MissileKind::P1sun => 0.25,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// How much a target takes before it comes apart: size, then what it carries.
|
||||
pub fn toughness(kind: DroneKind) -> f64 {
|
||||
let s = kind.spec();
|
||||
0.2 + 0.2 * s.scale + 0.03 * s.warhead as f64
|
||||
}
|
||||
|
||||
/// Chance of an air detonation: nothing without a warhead, rare for shells, likely when a strong
|
||||
/// interceptor meets a small target.
|
||||
const DET_BASE: f64 = 0.04;
|
||||
const DET_SLOPE: f64 = 0.32;
|
||||
const DET_FLOOR: f64 = 0.03;
|
||||
const DET_CEIL: f64 = 0.75;
|
||||
/// Of the rest, the share that breaks up instead of falling on.
|
||||
const BREAK_BASE: f64 = 0.12;
|
||||
const BREAK_SLOPE: f64 = 0.3;
|
||||
const BREAK_CEIL: f64 = 0.8;
|
||||
/// A broken-up target whose warhead section stays whole and falls on (and may cook off on landing).
|
||||
pub const WARHEAD_SURVIVES: f64 = 0.3;
|
||||
|
||||
/// The relative strength of the hit against the target.
|
||||
pub fn ratio(kind: DroneKind, imp: &Impact) -> f64 {
|
||||
let speed = (0.8 + 0.05 * imp.rel_speed).clamp(0.8, 1.2);
|
||||
imp.energy * speed / toughness(kind)
|
||||
}
|
||||
|
||||
/// (chance of an air detonation, chance of a breakup) of a hit on `kind`; the rest falls on.
|
||||
pub fn odds(kind: DroneKind, imp: &Impact) -> (f64, f64) {
|
||||
let s = kind.spec();
|
||||
let r = ratio(kind, imp);
|
||||
let det = if s.decoy || s.warhead == 0 { 0.0 } else { (DET_BASE + DET_SLOPE * (r - 0.4)).clamp(DET_FLOOR, DET_CEIL) };
|
||||
let share = (BREAK_BASE + BREAK_SLOPE * r).clamp(0.0, BREAK_CEIL);
|
||||
(det, (1.0 - det) * share)
|
||||
}
|
||||
|
||||
/// The outcome for a roll of 0..1.
|
||||
pub fn choose(kind: DroneKind, imp: &Impact, roll: f64) -> Outcome {
|
||||
let (det, brk) = odds(kind, imp);
|
||||
if roll < det {
|
||||
Outcome::AirDetonation
|
||||
} else if roll < det + brk {
|
||||
Outcome::Breakup
|
||||
} else {
|
||||
Outcome::DisabledFalling
|
||||
}
|
||||
}
|
||||
|
||||
/// The look of a kill by the target's warhead and the outcome: (fireball radius, ticks it burns,
|
||||
/// loudness, sound). A bare decoy pops; a detonation lights the sky. The live raid and its replay
|
||||
/// both take it from here.
|
||||
pub fn look(kind: DroneKind, outcome: Outcome) -> (f32, u32, f32, &'static str) {
|
||||
let w = kind.spec().warhead.min(8) as f32;
|
||||
let size = 3.5 * (0.85 + 0.12 * w);
|
||||
let life = (8 + (w as u32) / 2) * 9;
|
||||
let gain = 1.0 + 0.06 * w;
|
||||
match outcome {
|
||||
Outcome::DisabledFalling => (size, life, gain, "airdef:blast.air"),
|
||||
Outcome::Breakup => (size * 0.9, life, gain * 0.9, "airdef:blast.medium"),
|
||||
Outcome::AirDetonation => (size * 1.9, life * 8 / 5, gain * 1.5, "airdef:blast.big"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pieces -----------------------------------------------------------------------------------
|
||||
|
||||
/// Most pieces alive at once; a mass battle thins them out.
|
||||
pub const PIECES_MAX: usize = 320;
|
||||
/// Longest a piece lives, ticks (a replay's pieces end here too).
|
||||
pub const PIECE_LIFE: u32 = 110;
|
||||
|
||||
/// One piece thrown off: its own velocity, spin, size, drag and life.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Piece {
|
||||
pub vel: DVec3,
|
||||
pub spin: DQuat,
|
||||
pub scale: f64,
|
||||
pub drag: f64,
|
||||
pub life: u32,
|
||||
/// Trails smoke while it falls.
|
||||
pub burns: bool,
|
||||
}
|
||||
|
||||
/// The pieces of an outcome from `seed` (decorative randomness only), moving on with `base` (the
|
||||
/// target's velocity). None for a fall.
|
||||
pub fn pieces(seed: u64, outcome: Outcome, base: DVec3) -> Vec<Piece> {
|
||||
let (n, speed, size, life) = match outcome {
|
||||
Outcome::DisabledFalling => return Vec::new(),
|
||||
Outcome::Breakup => (7, 0.35, 1.0, 70),
|
||||
Outcome::AirDetonation => (12, 0.8, 0.8, 50),
|
||||
};
|
||||
let mut rng = Rng::new(seed ^ 0x9e37_79b9_7f4a_7c15);
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
let dir = DVec3::new(rng.s(), rng.s() * 0.8 + 0.2, rng.s()).normalize_or(DVec3::Y);
|
||||
let v = base * if outcome == Outcome::Breakup { 0.55 } else { 0.3 } + dir * speed * (0.4 + 0.8 * rng.f());
|
||||
let spin = DQuat::from_euler(glam::EulerRot::XYZ, rng.s() * 0.5, rng.s() * 0.5, rng.s() * 0.5);
|
||||
Piece {
|
||||
vel: v,
|
||||
spin,
|
||||
scale: size * (0.5 + 0.8 * rng.f()),
|
||||
drag: 0.96 + 0.025 * rng.f(),
|
||||
life: (life as f64 * (0.6 + 0.4 * rng.f())) as u32,
|
||||
burns: rng.chance(0.6),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One tick of a piece's flight: gravity, then drag (the replays and the live raid move the same).
|
||||
pub fn fly(pos: &mut DVec3, vel: &mut DVec3, drag: f64) {
|
||||
vel.y -= 0.05;
|
||||
*vel *= drag;
|
||||
*pos += *vel;
|
||||
}
|
||||
|
||||
/// Where a piece is `age` ticks after it was thrown from `at`, as a pure function (for replays).
|
||||
pub fn piece_at(at: DVec3, p: &Piece, age: u32) -> DVec3 {
|
||||
let (mut pos, mut vel) = (at, p.vel);
|
||||
for _ in 0..age.min(p.life) {
|
||||
fly(&mut pos, &mut vel, p.drag);
|
||||
}
|
||||
pos
|
||||
}
|
||||
|
||||
/// How a piece is turned `age` ticks after it was thrown (a replay's, from the start).
|
||||
pub fn piece_rot(p: &Piece, age: u32) -> DQuat {
|
||||
let mut rot = DQuat::IDENTITY;
|
||||
for _ in 0..age.min(p.life) {
|
||||
rot = p.spin * rot;
|
||||
}
|
||||
rot
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn shares(kind: DroneKind, imp: Impact, seeds: u64) -> [u32; 3] {
|
||||
let mut n = [0u32; 3];
|
||||
let mut rng = Rng::new(97);
|
||||
for _ in 0..seeds {
|
||||
let roll = rng.f();
|
||||
n[match choose(kind, &imp, roll) {
|
||||
Outcome::DisabledFalling => 0,
|
||||
Outcome::Breakup => 1,
|
||||
Outcome::AirDetonation => 2,
|
||||
}] += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
// #97: the profiles are tendencies, not laws: shells mostly leave a falling hull, a strong interceptor
|
||||
// against a small target often detonates it, and everything still can happen.
|
||||
#[test]
|
||||
fn outcomes_follow_the_weapon_and_the_target() {
|
||||
let at = DVec3::ZERO;
|
||||
let gun = shares(DroneKind::Shahed, Impact::gun(SamKind::Gepard, at, 10.0), 4000);
|
||||
assert!(gun[0] > 2400 && gun[2] < 400, "gun against a Shahed: {gun:?}");
|
||||
assert!(gun[1] > 0 && gun[2] > 0, "not a law: some break up, a few go off: {gun:?}");
|
||||
let strong = shares(DroneKind::Shahed, Impact::missile(MissileKind::M77n6, at, 6.0), 4000);
|
||||
assert!(strong[2] > 2000, "a strong interceptor against a small target: {strong:?}");
|
||||
let weak = shares(DroneKind::Shahed, Impact::missile(MissileKind::M57e6, at, 5.0), 4000);
|
||||
assert!(weak[2] < strong[2] && weak[2] > gun[2], "in between: {weak:?}");
|
||||
// A big missile is tougher than a small drone for the same interceptor.
|
||||
let big = shares(DroneKind::Iskander, Impact::missile(MissileKind::Pac3, at, 6.0), 4000);
|
||||
let small = shares(DroneKind::Shahed, Impact::missile(MissileKind::Pac3, at, 6.0), 4000);
|
||||
assert!(big[2] < small[2], "{big:?} {small:?}");
|
||||
// Without a warhead nothing goes off: a decoy or a bus breaks up or falls.
|
||||
for kind in [DroneKind::Gerbera, DroneKind::Oreshnik] {
|
||||
assert_eq!(shares(kind, Impact::missile(MissileKind::M77n6, at, 6.0), 2000)[2], 0, "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn kill(seed: u64, shader: bool, imp: impl Fn(DVec3) -> Impact) -> (Outcome, usize, u64) {
|
||||
let mut sim = Sim::new(seed);
|
||||
sim.shader = shader;
|
||||
let mut out = Out::default();
|
||||
let sam = sim.id();
|
||||
sim.sams.push(Sam::new(sam, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, 300.0), DVec3::X, DVec3::new(900.0, 70.0, 0.0));
|
||||
let at = sim.drones[0].pos;
|
||||
let o = sim.shoot_down_by(0, sam, None, imp(at), &mut out).unwrap();
|
||||
(o, sim.drones.len(), sim.rng.state())
|
||||
}
|
||||
|
||||
// #97: the roll is combat randomness: the same with graphics on and off, and the drawing never moves it.
|
||||
#[test]
|
||||
fn the_outcome_is_the_same_with_graphics_on_and_off() {
|
||||
for seed in 1..40 {
|
||||
let strong = |at| Impact::missile(MissileKind::M77n6, at, 6.0);
|
||||
assert_eq!(kill(seed, true, strong), kill(seed, false, strong), "seed {seed}");
|
||||
let gun = |at| Impact::gun(SamKind::Gepard, at, 10.0);
|
||||
assert_eq!(kill(seed, true, gun), kill(seed, false, gun), "seed {seed}");
|
||||
}
|
||||
}
|
||||
|
||||
// #97: a detonated warhead is spent: no wreck falls, so no second blast on landing; the kill counts once.
|
||||
#[test]
|
||||
fn a_detonated_target_does_no_ground_damage_and_counts_once() {
|
||||
let mut found = 0;
|
||||
for seed in 1..60 {
|
||||
let mut sim = Sim::new(seed);
|
||||
sim.damage = true;
|
||||
let mut out = Out::default();
|
||||
let mut ground = Ground::default();
|
||||
ground.answer((-50..50).flat_map(|x| (-50..400).map(move |z| (x, z, 70))).collect(), 0);
|
||||
let sam = sim.id();
|
||||
sim.sams.push(Sam::new(sam, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, 300.0), DVec3::X, DVec3::new(900.0, 70.0, 0.0));
|
||||
let at = sim.drones[0].pos;
|
||||
let o = sim.shoot_down_by(0, sam, Some(5), Impact::missile(MissileKind::M77n6, at, 6.0), &mut out).unwrap();
|
||||
if o != Outcome::AirDetonation {
|
||||
continue;
|
||||
}
|
||||
found += 1;
|
||||
assert!(sim.drones.is_empty(), "the main mass is gone");
|
||||
assert_eq!((sim.kills, sim.hits, sim.crashes), (1, 0, 0));
|
||||
assert_eq!(sim.sams[0].kills, 1);
|
||||
let mut cmds = out.commands.len();
|
||||
for _ in 0..400 {
|
||||
sim.tick += 1;
|
||||
let mut o = Out::default();
|
||||
sim.step_drones(&mut ground, None, &mut o);
|
||||
sim.step_fx(&mut ground, &mut o);
|
||||
cmds += o.commands.len();
|
||||
assert!(o.sounds.iter().all(|s| !s.0.starts_with("airdef:blast")), "a second blast");
|
||||
}
|
||||
assert_eq!(cmds, 0, "no primed TNT for the land from a detonated warhead");
|
||||
assert_eq!(sim.kills, 1);
|
||||
}
|
||||
assert!(found >= 10, "{found} detonations in 59 seeds");
|
||||
}
|
||||
|
||||
// #97: a breakup throws pieces that have a life: they expire, and the number alive is bounded in a mass battle.
|
||||
#[test]
|
||||
fn pieces_expire_and_a_mass_battle_keeps_them_bounded() {
|
||||
let mut sim = Sim::new(3);
|
||||
let mut out = Out::default();
|
||||
let mut ground = Ground::default();
|
||||
for i in 0..150 {
|
||||
let sam = sim.id();
|
||||
sim.sams.push(Sam::new(sam, SamKind::Patriot, DVec3::new(f64::from(i) * 500.0, 70.0, 0.0), 0.0));
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(f64::from(i) * 500.0, 400.0, 300.0), DVec3::X, DVec3::new(9e5, 70.0, 0.0));
|
||||
let at = sim.drones.last().unwrap().pos;
|
||||
let idx = sim.drones.len() - 1;
|
||||
let _ = sim.shoot_down_by(idx, sam, None, Impact::missile(MissileKind::M77n6, at, 6.0), &mut out);
|
||||
}
|
||||
let pieces = |s: &Sim| s.fx.iter().filter(|(_, f)| matches!(f, Fx::Piece { .. })).count();
|
||||
let n = pieces(&sim);
|
||||
assert!(n > 0 && n <= PIECES_MAX, "{n} pieces");
|
||||
for _ in 0..PIECE_LIFE + 1 {
|
||||
sim.tick += 1;
|
||||
sim.step_fx(&mut ground, &mut Out::default());
|
||||
}
|
||||
assert_eq!(pieces(&sim), 0, "pieces expire");
|
||||
}
|
||||
|
||||
// #97: a breakup either keeps the warhead section (a wreck still falling, which may go off on landing by the
|
||||
// existing roll) or leaves nothing to fall.
|
||||
#[test]
|
||||
fn a_breakup_keeps_the_warhead_section_only_sometimes() {
|
||||
let (mut kept, mut gone) = (0, 0);
|
||||
for seed in 1..200 {
|
||||
let (o, drones, _) = kill(seed, true, |at| Impact::missile(MissileKind::M57e6, at, 5.0));
|
||||
if o == Outcome::Breakup {
|
||||
if drones == 1 {
|
||||
kept += 1;
|
||||
} else {
|
||||
gone += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(kept > 0 && gone > kept, "kept {kept} gone {gone}");
|
||||
}
|
||||
|
||||
// #97: a flight of a piece is a pure function of its age (so a replay moves it as the raid did).
|
||||
#[test]
|
||||
fn a_pieces_flight_is_the_same_stepped_or_computed() {
|
||||
let ps = pieces(5, Outcome::AirDetonation, DVec3::new(0.5, 0.0, 0.2));
|
||||
assert!(!ps.is_empty() && pieces(5, Outcome::DisabledFalling, DVec3::ZERO).is_empty());
|
||||
let (mut pos, mut vel) = (DVec3::new(1.0, 100.0, 2.0), ps[0].vel);
|
||||
for _ in 0..20 {
|
||||
fly(&mut pos, &mut vel, ps[0].drag);
|
||||
}
|
||||
assert!(pos.distance(piece_at(DVec3::new(1.0, 100.0, 2.0), &ps[0], 20)) < 1e-9);
|
||||
assert!(ps.iter().all(|p| p.life <= PIECE_LIFE));
|
||||
}
|
||||
}
|
||||
@@ -168,8 +168,9 @@ pub struct Track {
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum EventKind {
|
||||
/// A drone shot down: by which system (its index then), with a missile or the guns.
|
||||
Kill { drone: u32, kind: DroneKind, sam: u32, sam_kind: SamKind, sam_index: usize, missile: Option<u32> },
|
||||
/// A drone shot down: by which system (its index then), with a missile or the guns, and how it ended
|
||||
/// (rolled once, live; a replay reads it and never rolls).
|
||||
Kill { drone: u32, kind: DroneKind, sam: u32, sam_kind: SamKind, sam_index: usize, missile: Option<u32>, outcome: Outcome },
|
||||
/// A drone's warhead went off where it aimed: at an object, a system, or the land.
|
||||
Hit { drone: u32, kind: DroneKind, power: u32, object: Option<usize>, sam: Option<(SamKind, usize)> },
|
||||
/// A system destroyed.
|
||||
@@ -331,8 +332,8 @@ pub struct ReplayFire {
|
||||
/// The look of an interception by the drone's warhead: (fireball radius, ticks it burns, loudness).
|
||||
/// A bare decoy pops; a big warhead lights the sky. The same in the live raid and its replay.
|
||||
pub fn kill_look(kind: DroneKind) -> (f32, u32, f32) {
|
||||
let w = kind.spec().warhead.min(8) as f32;
|
||||
(3.5 * (0.85 + 0.12 * w), (8 + (w as u32) / 2) * 9, 1.0 + 0.06 * w)
|
||||
let (size, life, gain, _) = outcome::look(kind, Outcome::DisabledFalling);
|
||||
(size, life, gain)
|
||||
}
|
||||
|
||||
/// A blast's look for an event: (fireball radius, ticks it burns). The live blasts use the same.
|
||||
@@ -340,8 +341,8 @@ pub fn blast_spec(kind: EventKind) -> (f32, u32) {
|
||||
const SIZE: [f32; 5] = [2.5, 4.5, 7.0, 10.0, 13.0];
|
||||
const LIFE: [u32; 5] = [11, 11, 18, 22, 26];
|
||||
match kind {
|
||||
EventKind::Kill { kind, .. } => {
|
||||
let (size, life, _) = kill_look(kind);
|
||||
EventKind::Kill { kind, outcome, .. } => {
|
||||
let (size, life, _, _) = outcome::look(kind, outcome);
|
||||
(size, life)
|
||||
}
|
||||
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => {
|
||||
@@ -581,6 +582,7 @@ impl Sim {
|
||||
|
||||
/// Draws the recording's drones and interceptors as they were at `t`, as ghosts: ids from `base`.
|
||||
pub fn draw_ghosts(&self, rec: &Recording, t: f64, base: u32, out: &mut Out) {
|
||||
let base_id = base;
|
||||
for (k, tr) in rec.tracks.iter().enumerate() {
|
||||
let Some(pos) = tr.at(rec.origin, t) else { continue };
|
||||
let dir = tr.dir(rec.origin, t);
|
||||
@@ -660,6 +662,31 @@ impl Sim {
|
||||
out.particles("minecraft:large_smoke", f.at + DVec3::Y, 0.8, DVec3::new(0.0, 0.1, 0.0), 0.03, 1 + (smoke * 2.0) as u32);
|
||||
}
|
||||
}
|
||||
// The pieces a breakup or an air detonation threw off, from the recorded outcome (never a new roll): the same
|
||||
// pieces the live raid made, moved by the same flight, a pure function of the time.
|
||||
for (k, e) in rec.events.iter().enumerate() {
|
||||
let EventKind::Kill { drone, outcome, .. } = e.kind else { continue };
|
||||
let age = t - e.at as f64;
|
||||
if outcome == Outcome::DisabledFalling || !(0.0..outcome::PIECE_LIFE as f64).contains(&age) {
|
||||
continue;
|
||||
}
|
||||
let base = rec
|
||||
.track(drone)
|
||||
.and_then(|tr| Some((tr.at(rec.origin, e.at as f64)? - tr.at(rec.origin, e.at as f64 - STEP as f64)?) / STEP as f64))
|
||||
.unwrap_or(DVec3::ZERO);
|
||||
let seed = (k as u64).wrapping_mul(0x2545_f491_4f6c_dd1d) ^ e.at as u64;
|
||||
for (n, p) in outcome::pieces(seed, outcome, base).iter().enumerate() {
|
||||
if age as u32 >= p.life {
|
||||
continue;
|
||||
}
|
||||
let at = outcome::piece_at(e.pos, p, age as u32);
|
||||
let id = base_id + 0x0060_0000 + (k as u32 * 16 + n as u32) * PARTS;
|
||||
out.model(id, "airdef:debris", at, outcome::piece_rot(p, age as u32), p.scale, false);
|
||||
if p.burns && (age as u32) % 3 == 0 {
|
||||
out.particles("minecraft:large_smoke", at, 0.1, DVec3::new(0.0, 0.03, 0.0), 0.01, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Blasts: with the shader effects on, the post pass and the HD lights draw them from
|
||||
// `replay_blasts` (the same as the live ones); the models are the fallback without them.
|
||||
if self.shader {
|
||||
@@ -793,4 +820,46 @@ mod tests {
|
||||
assert_eq!(r.world_tick(50.0), Some(1040 + 10));
|
||||
assert_eq!(r.world_tick(70.0), Some(1100 + 10));
|
||||
}
|
||||
|
||||
// #97: the kill's outcome is a recorded fact: a replay draws the pieces of that outcome and rolls nothing;
|
||||
// an event without one (an old recording) is the plain fall it always was.
|
||||
#[test]
|
||||
fn a_replay_shows_the_recorded_outcome() {
|
||||
let mut sim = Sim::new(5);
|
||||
sim.rec = Some(Recording::new(1, &sim));
|
||||
let sam = sim.id();
|
||||
sim.sams.push(Sam::new(sam, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 120.0, 300.0), DVec3::X, DVec3::new(900.0, 70.0, 0.0));
|
||||
let at = sim.drones[0].pos;
|
||||
let live = sim.shoot_down_by(0, sam, None, Impact::missile(MissileKind::M77n6, at, 6.0), &mut Out::default()).unwrap();
|
||||
let rec = sim.rec.take().unwrap();
|
||||
let recorded = rec.events.iter().find_map(|e| if let EventKind::Kill { outcome, .. } = e.kind { Some(outcome) } else { None });
|
||||
assert_eq!(recorded, Some(live), "the live outcome is what was recorded");
|
||||
let pieces = |rec: &Recording, t: f64| {
|
||||
let mut out = Out::default();
|
||||
sim.draw_ghosts(rec, t, 0x5000_0000, &mut out);
|
||||
out.models.iter().filter(|m| m.name == "airdef:debris").count()
|
||||
};
|
||||
let at_tick = rec.events[0].at as f64;
|
||||
// The same frame twice is the same (a pure function of the time), and the pieces are the outcome's.
|
||||
let mut forced = Recording { events: rec.events.clone(), ..Recording::blank(200) };
|
||||
for (outcome, want) in [(Outcome::AirDetonation, true), (Outcome::Breakup, true), (Outcome::DisabledFalling, false)] {
|
||||
if let EventKind::Kill { outcome: o, .. } = &mut forced.events[0].kind {
|
||||
*o = outcome;
|
||||
}
|
||||
assert_eq!(pieces(&forced, at_tick + 5.0), pieces(&forced, at_tick + 5.0));
|
||||
assert_eq!(pieces(&forced, at_tick + 5.0) > 0, want, "{outcome:?}");
|
||||
assert_eq!(pieces(&forced, at_tick + f64::from(outcome::PIECE_LIFE) + 1.0), 0, "the pieces are gone in time");
|
||||
}
|
||||
// Its flash and sound are the outcome's too.
|
||||
let spec = |o| {
|
||||
let mut e = rec.events[0];
|
||||
if let EventKind::Kill { outcome, .. } = &mut e.kind {
|
||||
*outcome = o;
|
||||
}
|
||||
blast_spec(e.kind)
|
||||
};
|
||||
assert!(spec(Outcome::AirDetonation).0 > spec(Outcome::DisabledFalling).0);
|
||||
assert_eq!(spec(Outcome::default()).0, kill_look(DroneKind::Shahed).0, "an old recording looks as it did");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,7 +473,7 @@ fn pending_exact_answer_does_not_let_the_fuse_kill_through_a_wall() {
|
||||
ground.world.answer(answers, sim.tick);
|
||||
}
|
||||
}
|
||||
(first, sim.kills, sim.drones[0].alive())
|
||||
(first, sim.kills, sim.drones.first().is_some_and(Drone::alive))
|
||||
};
|
||||
for seed in 1..=10 {
|
||||
// Just behind the wall, inside the fuse's reach of a missile held at its face.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! What the simulation tells the engine sources (`crate::engines`): which sound an entity's
|
||||
//! engine makes, from the specs the rest of the simulation owns.
|
||||
use super::{DroneKind, MissileKind};
|
||||
|
||||
impl DroneKind {
|
||||
/// The sound of its engine and its pitch; none for the ballistic ones, which fall with a
|
||||
/// scream played once (and the Oreshnik's blocks and submunitions, which have no engine).
|
||||
pub fn engine_voice(self) -> Option<(&'static str, f32)> {
|
||||
let spec = self.spec();
|
||||
if spec.ballistic.is_some() || matches!(self, DroneKind::OreshnikBlock | DroneKind::OreshnikSub) {
|
||||
return None;
|
||||
}
|
||||
Some((spec.sound.0, spec.sound.1))
|
||||
}
|
||||
}
|
||||
|
||||
impl MissileKind {
|
||||
/// The sound of its motor, its gain and its pitch: a rocket's roar, or the electric whine of
|
||||
/// a motor without a flame.
|
||||
pub fn engine_voice(self) -> (&'static str, f32, f32) {
|
||||
if self.spec().flame == 0.0 {
|
||||
return ("airdef:drone.lancet", 0.8, 1.6);
|
||||
}
|
||||
let pitch = match self {
|
||||
MissileKind::Pac3 => 1.0,
|
||||
MissileKind::M57e6 => 1.12,
|
||||
MissileKind::M48n6 => 0.88,
|
||||
MissileKind::M77n6 => 0.8,
|
||||
MissileKind::IrisT | MissileKind::Amraam => 1.05,
|
||||
MissileKind::M9m317 => 0.9,
|
||||
MissileKind::M9m338 => 1.15,
|
||||
MissileKind::P1sun => 1.7,
|
||||
};
|
||||
("airdef:missile.flight", 1.0, pitch)
|
||||
}
|
||||
/// Ticks from the launch until the motor lights (a cold launch throws it out of the tube first).
|
||||
pub fn lit_after(self) -> u32 {
|
||||
self.spec().cold
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
# Акустика AirDef: разбор Sound Physics Remastered и план переноса (#96, этап 1)
|
||||
|
||||
Это результат первого этапа: чтение исходников и план. Кода движка и мода этот этап не меняет. Если код
|
||||
разойдётся с текстом, прав код.
|
||||
|
||||
- Изучено: https://github.com/henkelmax/sound-physics-remastered, ветка `master`, коммит
|
||||
`453654ece086f40c97712d9016baa4dee7c4f6d2` (2026-10-29), лицензия GPL-3.0 (`LICENSE`). Клон лежит только в
|
||||
scratchpad, ничего из него не собиралось и не запускалось.
|
||||
- Пути ниже даны от `common/src/main/java/com/sonicether/soundphysics/`. Весь расчёт — это `SoundPhysics.java`
|
||||
(741 строка) плюс таблицы в `config/`.
|
||||
- Коммит из текста #96 (`76f680eb…`) в последних 200 коммитах `master` не нашёлся. Выводы привязаны к
|
||||
`453654ec`.
|
||||
|
||||
## 1. Как SPR идёт от источника к результату
|
||||
|
||||
Сигнал: Minecraft вызывает `Channel.play` → `mixin/SourceMixin.play` → `SoundPhysics.onPlaySound` →
|
||||
`evaluateEnvironment` (SoundPhysics.java:240). Это один синхронный расчёт на каждый новый звук, в потоке звукового
|
||||
движка. Движущиеся звуки пересчитываются только при `update_moving_sounds=true` (по умолчанию выключено), раз в
|
||||
`sound_update_interval=5` тиков, со сдвигом по `sound.hashCode()` (`mixin/SoundSystemMixin.tickNonPaused`). Относительные
|
||||
звуки (интерфейс) и дальше `max_sound_processing_distance=512` пропускаются (строки 248-259), как и звуки, превысившие
|
||||
лимит за тик (`utils/SoundRateManager`: по умолчанию без лимита, 0 только у дождя, грома и гудка козы).
|
||||
|
||||
### 1.1 Прямой путь: окклюзия и поглощение (`calculateOcclusion`, `runOcclusion`, строки 535-613)
|
||||
|
||||
- `runOcclusion`: луч от источника к камере, до `max_occlusion_rays=16` последовательных попаданий. Каждое попадание
|
||||
добавляет `occlusion` блока (`config/OcclusionConfig`), затем луч продолжается от точки попадания (так учитывается
|
||||
толщина стены). Остановка при сумме `> max_occlusion=64`.
|
||||
- Не полный блок (`!isFaceSturdy` грани, в которую попали): вклад `* 0.25` (`non_full_block_occlusion_factor`).
|
||||
- Если не `strict_occlusion`: берётся минимум по 9 путям: прямой и 8 со сдвигом обоих концов на `(±v,±v,±v)`,
|
||||
`v = occlusion_variation = 0.35`; у блочных звуков `v >= 0.49`. Минимум даёт «звук проходит в щель, у двери,
|
||||
у угла»; это недорогая замена дифракции.
|
||||
- Итог (строки 287, 302-303):
|
||||
`cutoff = exp(-occ * 3 * block_absorption)`, `gain = scale * cutoff^0.1`.
|
||||
Один обычный блок (occ 1): cutoff 0.050 (в OpenAL это `GAINHF`, усиление на 5 кГц), gain 0.74. Два блока:
|
||||
0.0025 и 0.55. Три: 0.0001 и 0.41. Громкость падает мало, главное — срез верхов; это нужное нам «глухо за горой».
|
||||
- Значения блоков (`OcclusionConfig.addDefaults`): по умолчанию 1.0; шерсть 1.5, мох 0.75, мёд 0.5, стекло, снег,
|
||||
бамбук, мокрая трава 0.1; лианы, кусты, посевы, лестницы, цепи, листья-подобное 0; вода 0.25, лава 0.75.
|
||||
- Фильтр воды и «подводные» множители (строки 319-321, 497-502) нам не нужны.
|
||||
|
||||
### 1.2 Выбор и трассировка лучей отражения (строки 323-432)
|
||||
|
||||
- `environment_evaluation_ray_count=32` направлений вокруг источника по спирали Фибоначчи на сфере:
|
||||
`longitude = i * phi * 2pi`, `latitude = asin(2i/N - 1)`; длина луча 256.
|
||||
- Попавший луч (`RaycastUtils.rayCast`, обход блоков с реальными формами коллизии и жидкостей) отражается
|
||||
`reflect(d, n) = d - 2(d.n)n` до `environment_evaluation_ray_bounces=4` раз. Если очередной отражённый луч
|
||||
ушёл в пустоту (`MISS`), цикл отскоков прерывается.
|
||||
- Для каждого отскока: `energyTowardsPlayer = 0.25 * (reflectivity * 0.75 + 0.25)` (reflectivity последнего
|
||||
блока, `ReflectivityConfig`: по умолчанию 0.5; камень, глубинный сланец, базальт, туф, кость, незер-кирпич 1.5;
|
||||
незерак 1.1; металл 1.25, медь 1.25; стекло 0.75; дерево 0.4; трава, гравий 0.3; песок 0.2, снег 0.15;
|
||||
шерсть, мох, мёд 0.1).
|
||||
- Аккумулируемая длина пути `totalRayDistance` (путь до первого попадания + сумма отрезков). Если луч последнего
|
||||
отскока ушёл в пустоту, к ней добавляется расстояние от точки отскока до слушателя.
|
||||
- «Общее воздушное пространство» (`getSharedAirspace`, строки 632-653): из каждой точки отскока и из самого
|
||||
источника пускается луч к слушателю без пропусков; если он свободен, это отражённый или прямой путь, который
|
||||
реально доходит. Из таких путей считаются `sharedAirspaces` и направления.
|
||||
- Перенаправление источника (`ReflectedAudio.evaluateSoundPosition`): новая позиция источника — направление,
|
||||
взвешенное по `1/(длина пути)^2` по всем свободным путям (плюс прямое, если оно есть), на прежнем расстоянии от
|
||||
слушателя. Так звук из-за горы «приходит» из проёма, а не сквозь гору. Не применяется, если путь виден напрямую
|
||||
(`redirect_non_occluded_sounds`).
|
||||
|
||||
### 1.3 Затухание, задержки, оценка окружения (строки 409-492)
|
||||
|
||||
Важное, чего в README нет и что менее очевидно, чем кажется по описанию #96:
|
||||
|
||||
- **Задержек и дискретных эхо у SPR нет.** `reflectionDelay = totalRayDistance * 0.12 * blockReflectivity` — не время,
|
||||
а индекс, который перекрёстным смешиванием (`cross0..cross3`, «шляпы» шириной 1) раскладывает вклад луча по
|
||||
четырём фиксированным реверб-слотам. Слот 0 берёт delay 0..1, слот 1 — 1, слот 2 — 2, слот 3 — больше 2.
|
||||
Для камня (1.5) слот 3 начинается примерно с пути 11 блоков, для травы (0.3) с 55. В ущелье почти всё
|
||||
уходит в слот 3: один длинный хвост, а не отдельное «бабах... бабах» от стенки.
|
||||
- Вклад в слот: `send_i += cross_i * energy * {6.4, 12.8, 12.8, 12.8} / (rays * bounces)`; затем слот 1
|
||||
умножается на долю отскоков 1-го порядка с отражающим блоком (`bounceReflectivityRatio[1]`), слот 2 на `ratio[2]^3`,
|
||||
слот 3 на `ratio[3]^4` (чем меньше лучей доживает до глубокого отскока, тем тише длинный хвост); слоты 2 и 3
|
||||
`*1.05 - 0.05`; всё в 0..1.
|
||||
- Фильтр посылки (`sendCutoff_i`): доля общего воздушного пространства
|
||||
`shared = sharedAirspaces * 64 / (rays * bounces)` даёт вес `clamp(shared / {20,15,10,10})`;
|
||||
`sendCutoff = exp(-occ*3*absorb) * (1-w) + w`. То есть, если до слушателя дотягивается много свободных путей,
|
||||
реверб идёт без срезания верхов, иначе приглушён как и прямой звук. Сухой звук при этом получает ту же
|
||||
логику: `directCutoff = max(sqrt(avg(w)) * 0.2, directCutoff)`: через щели всегда проходит что-то приглушённое.
|
||||
- Затухание реверба по расстоянию добавлено костылём (комментарий в коде, строки 484-488):
|
||||
`send *= 1 - min(dist / (maxDistance * reverb_distance), 1)`, `reverb_distance=1.5`.
|
||||
Сам звук затухает штатно в OpenAL (линейно по расстоянию до `AL_MAX_DISTANCE`; у нас это `distance_gain`).
|
||||
- Воздушное поглощение — штатное `AL_AIR_ABSORPTION_FACTOR = 1` (OpenAL Soft сам считает срез верхов от
|
||||
расстояния; по документации EFX это 0.994 усиления на 5 кГц на единицу длины, то есть 0.55 на 100, 0.16 на 300
|
||||
— проверить по исходникам OpenAL Soft, их здесь не читали).
|
||||
|
||||
### 1.4 Реверб и EFX (`setupEFX`, `setReverbParams`, `config/ReverbParams`)
|
||||
|
||||
Четыре auxiliary effect slot'а (`ALC_MAX_AUXILIARY_SENDS=4` через `mixin/LibraryMixin`), в каждом `EAXREVERB`
|
||||
с неизменными (не зависящими от места!) параметрами. Окружение выражено только уровнями посылок источника в эти
|
||||
слоты:
|
||||
|
||||
| слот | decay, с | density | gain | gainHF | decayHFratio | reflections gain / delay, с | late gain / delay, с | rolloff |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 0 | 0.15 | 0.0 | 0.2 | 0.99 | 0.6 | 2.5 / 0.001 | 1.26 / 0.011 | 0.16 |
|
||||
| 1 | 0.55 | 0.0 | 0.3 | 0.99 | 0.7 | 0.2 / 0.015 | 1.26 / 0.011 | 0.15 |
|
||||
| 2 | 1.68 | 0.1 | 0.5 | 0.99 | 0.7 | 0.0 / 0.021 | 1.26 / 0.021 | 0.13 |
|
||||
| 3 | 4.142 | 0.5 | 0.4 | 0.89 | 0.7 | 0.0 / 0.025 | 1.26 / 0.021 | 0.11 |
|
||||
|
||||
`gain` умножается на `0.7 * reverb_gain * 0.85`; `diffusion = 1` везде. На источник вешаются 4 lowpass-фильтра
|
||||
посылок (`AL_AUXILIARY_SEND_FILTER`), 1 прямой lowpass (`AL_DIRECT_FILTER`): `GAIN` и `GAINHF`
|
||||
(`setEnvironment`, строки 663-704). Всё остальное — собственный DSP OpenAL Soft (LGPL, и его мы не портируем).
|
||||
|
||||
### 1.5 Что SPR вычисляет сам и что отдаёт бэкенду
|
||||
|
||||
| Сам SPR | Отдаёт OpenAL |
|
||||
|---|---|
|
||||
| лучи, окклюзия, отскоки, пути, доли, `exp(-occ*3)`, перенаправление позиции | реальный lowpass (фильтр LP/HF-shelf на 5 кГц), посылки, EAXReverb, воздушное поглощение, затухание по расстоянию, панорама |
|
||||
|
||||
### 1.6 Мир и потоки, кеш
|
||||
|
||||
- `world/ClonedClientLevel`: копия секций чанков в радиусе `level_clone_range=4` чанка (петля `-4..3`, то есть
|
||||
8x8 чанков, примерно 64 блока вокруг игрока), обновляется раз в 20 тиков или после сдвига на 16 блоков
|
||||
(`utils/LevelAccessUtils.tickLevelCache`). Лучи читают только эту копию, поэтому звук можно считать вне главного
|
||||
потока. Блок вне копии — `VOID_AIR`. **Следствие: SPR не видит гор дальше ~64 блоков.** Дальний взрыв за
|
||||
горой для него — открытое поле.
|
||||
- Кеша результатов нет. Каждый новый звук — свои ~350 лучей (прямой 1..9 путей по до 16 пересечений, 32 луча x до 5
|
||||
отрезков, ещё ~160 лучей воздушного пространства). `TaskProfiler` только измеряет. Платит SPR временем потока
|
||||
звука на каждый `play`, поэтому и существует лимит `SoundRateManager`.
|
||||
- Приоритетов, бюджета по времени, затухающей детализации по расстоянию нет, только жёсткие пороги.
|
||||
|
||||
## 2. Что зависит от OpenAL, и чем это заменить в нашем микшере
|
||||
|
||||
`audio.rs` сегодня (`mix`, ~строки 402-501): на голос — линейная интерполяция, `distance_gain`, панорама
|
||||
`stereo_gains`; потом общий лимитер. Нет ни фильтров, ни посылок. Вызов `mix` общий для живого потока cpal и
|
||||
`film_mix` (48 кГц, на часах съёмки), поэтому любой DSP, живущий в `Mixer`, автоматически одинаков вживую,
|
||||
в реплее и в оффлайн-MP4.
|
||||
|
||||
Нужный минимум (всё своё, без кода OpenAL Soft; фильтры и FDN — стандартная техника):
|
||||
|
||||
1. **Low-pass на голос.** Однополюсный `y += a*(x-y)`; отсечка из «GAINHF на 5 кГц» SPR:
|
||||
`fc = 5000 / sqrt(1/g^2 - 1)` (для `g` в 0.001..0.999), `a = 1 - exp(-2 pi fc / rate)`. Плюс gain.
|
||||
Однополюсник — упрощение OpenAL-овского HF-shelf (там НЧ не режется, у нас и так почти не режется), для
|
||||
«глухо» достаточно; при сомнениях заменить на два каскада.
|
||||
2. **Сглаживание параметров** (slew 50-100 мс на gain и `a`), иначе щелчки при смене окклюзии.
|
||||
3. **Ранние эхо: 2-6 отводов на голос**, читающих тот же `Arc<Vec<f32>>` источника на позиции `position - delay*step`.
|
||||
Памяти на линии задержки не нужно (звуки у нас целиком декодированы, `Channel.samples`); для стримов (музыка)
|
||||
отводы выключены. Параметры отвода: задержка (сэмплы), gain, `a` фильтра. Голос после конца сэмплов остаётся
|
||||
жить в состоянии «хвост» ещё `max_delay`, иначе эхо оборвётся вместе с хлопком.
|
||||
4. **Посылка в реверб.** На голос уровень посылки `send` (1-2 шины), накапливается моно в буфер шины перед
|
||||
панорамой. Шина: вход → pre-delay → 4 all-pass (диффузия) → FDN из 8 линий (взаимно простые длины 7..80 мс,
|
||||
матрица Адамара 8x8, потери на линию `g = 10^(-3 L / (RT60 * rate))`, lowpass в петле для `decayHFratio`) → стерео-
|
||||
декорреляция (разные отводы для L и R). Две шины с разным RT60 (комната/тоннель ~0.4-1.2 с и открытая местность/
|
||||
ущелье 2-4 с), между ними перекрёстное смешивание по классу окружения (как слоты SPR). Параметры из §1.4
|
||||
(0.15/0.55/1.68/4.14 с, gainHF) годятся стартовыми значениями RT60.
|
||||
5. **Состояние сброса:** шины и хвосты обнуляются в `film_begin/film_end`, при смене мира, `stop_all`.
|
||||
|
||||
Оценка стоимости (расчёт по числу операций, не замер; замер — условие включения, см. этап 1):
|
||||
|
||||
| Часть | Операций на сэмпл | 100 голосов, 48 кГц |
|
||||
|---|---|---|
|
||||
| сегодня: интерполяция + панорама | ~12 | 58 Mflop/с |
|
||||
| + LP + slew | ~6 | +29 |
|
||||
| + 4 отвода (чтение, интерполяция, LP, gain) | ~32 | +150 |
|
||||
| + посылка | ~2 | +10 |
|
||||
| 2 шины FDN8 на всё | ~70 на шину | ~7 всего |
|
||||
|
||||
Итого около 250 Mflop/с на 100 одновременно звучащих голосов, то есть порядка 0.5-1 мс на буфер 512 кадров
|
||||
(10.7 мс) при скалярном коде — единицы процента одного ядра M-серии. Ограничения: отводы только у `N_full=16` самых
|
||||
слышимых голосов (остальные — LP и посылка), и всё равно сначала бенч `mix` в release с 100 голосами. Память:
|
||||
шины ~2x160 КБ, на голос ~100 байт состояния; задержки у голосов нет. Callback не аллоцирует: буферы шин и
|
||||
состояние в `Channel` создаются заранее, параметры пишет игровой поток под тем же `Mutex<Mixer>`, что уже есть
|
||||
(держать критическую секцию короткой; если встретятся пропуски — перейти на тройной буфер/атомики).
|
||||
|
||||
## 3. Лучи: что есть и как сопоставляется
|
||||
|
||||
Что есть:
|
||||
|
||||
- Мод: `render.query-rays` (до 256 лучей/тик, ответ со следующего тика, до 512 блоков, политика `sound` =
|
||||
только полные кубы), `query-sections` (64/тик, битовые сетки 16^3), `world-changes` (revision, секции) — в `horizon.wit`,
|
||||
реализация `game/mod_world.rs` (`Probe` над `World.chunks`, `CELLS_PER_TICK = 200_000`) и чистый обход
|
||||
`horizon_mods::world_query::trace` (обобщён над `BlockProbe`).
|
||||
- Клиент: `World.chunks` — `Arc<Chunk>` с копированием при записи (`Arc::make_mut`), поэтому рабочий поток может держать
|
||||
снимок чанков без блокировок и увидеть стабильную, пусть и устаревшую картину.
|
||||
|
||||
Что не хватает: политика `sound` бинарна и без материала; ответ на луч не говорит, что за блок попали (только
|
||||
координаты), а звуку нужны вклад окклюзии и отражательная способность. Поэтому **акустика живёт в клиенте** (это же
|
||||
требование #96: общий DSP для live/реплея/MP4), а мод только сообщает события и движущиеся источники через #95.
|
||||
Мод не должен сам считать лучи для звука и дублировать эхо.
|
||||
|
||||
Решение:
|
||||
|
||||
- Новый модуль `horizon-client/src/acoustics/`: `world.rs` (свой `AcousticProbe`: `Cell::Solid` с таблицей по
|
||||
состоянию блока `occ: u8` и `refl: u8`, строится из `StateFacts.sounds.step`/`classes`/`full_collision`, один раз на
|
||||
эпоху мира, как `WorldTables`); `trace.rs` (тот же обход `world_query::trace` — проверить, что модуль не за
|
||||
feature `mods`; иначе вынести в `horizon-core`/`horizon-world`).
|
||||
- Ускорение дальних лучей: пропуск пустых секций 16^3 (DDA по секциям поверх `section_bits`), иначе 512-блочный луч
|
||||
это ~1500 клеток, и бюджет 200k даёт только ~130 лучей. Со секциями луч — десятки шагов. Секции кешируются
|
||||
(`empty`/`full` — 1 байт, `mixed` — 512 байт; LRU, потолок 16 МБ).
|
||||
- Неизвестное (`Unknown`, чанка нет) трактуется как открытый воздух, а не стена; доля Unknown идёт в телеметрию.
|
||||
Исходный SPR делает то же (`VOID_AIR`), но у него «неизвестно» начинается за ~64 блоками, у нас за 512 и дальше.
|
||||
- В callback мира нет: callback видит только числа (`gain, lp_a, send, taps[]`), которые положил планировщик.
|
||||
|
||||
### Планировщик (для ~100 источников, взрыв издалека, большой налёт на Mac)
|
||||
|
||||
1. **Разделение работы.** Источник = голос с `AcousticState {dry_gain, dry_a, send, taps, virtual_pos, stamp}`.
|
||||
Есть три класса:
|
||||
- `Full` (до 16 самых слышимых, плюс все взрывы мощностью >= 2 вблизи 400 блоков): прямой путь (до 3 путей из 9
|
||||
SPR), 16 лучей среды x 2 отскока, до 4 эхо-лучей;
|
||||
- `Direct` (остальные до 100): 1 прямой путь, 1 пересечение стен;
|
||||
- `Free` (дальше 512 или тише порога): без расчёта, только воздушное поглощение по расстоянию.
|
||||
Слышимость: `loud = volume * distance_gain` до окклюзии. Cinema.rs уже режет налёт по `FULL=3` на тик — тот же
|
||||
`rank` использовать для класса.
|
||||
2. **Очередь с бюджетом.** Бюджет ~1 мс времени расчёта на игровой тик (замеряется `Instant`) и потолок клеток;
|
||||
в очереди работы приоритет `loud * (1 + возраст)`; недоделанное остаётся со старыми значениями. Взрыв
|
||||
ставится вперёд очереди (он звучит один раз). Телеметрия в `census()` (`status`): лучей, клеток, мс, длина
|
||||
очереди, попаданий в кеш, отброшенных.
|
||||
3. **Рабочий поток** (стадии 2-3; стадию 1 можно на игровом потоке под бюджетом, как `mod_world`). Он получает снимок
|
||||
`Arc<Chunk>` в радиусе ~512 блоков и список изменений мира; отдаёт `AcousticState` через канал; игровой поток
|
||||
применяет их в начале тика.
|
||||
4. **Кеш.** Ключ: (клетка источника 4x4x4, клетка слушателя 4x4x4, класс); значение: параметры и `AABB` лучей.
|
||||
Инвалидация: `world-changes` (revision и секции) — запись удаляется, если её AABB задевает изменённую секцию;
|
||||
`epoch` — всё. Разрушенная преграда (в полигоне) так обновляется за один тик очереди.
|
||||
Для окружения слушателя («зонд»: 24 луча вокруг камеры, раз в 250 мс или при сдвиге на 4 блока) отдельная запись,
|
||||
общая для всех источников: из неё берётся класс реверба и хвост (комната, тоннель, поле, ущелье); этим
|
||||
«много источников используют общие шины и зонды».
|
||||
5. **Движущиеся источники** (дрон, ракета): интервал пересчёта `clamp(distance / 200 с, 0.1, 1.0)` с фазовым сдвигом от
|
||||
id голоса (как `hashCode % interval` в SPR); значения сглаживаются в микшере; решение об окклюзии с гистерезисом.
|
||||
Ракета близко пролетает мимо слушателя — частоту повышаем по сближению, а не по времени.
|
||||
6. **Оффлайн и реплей.** При съёмке расчёт синхронный и бюджет по счётчику (лучи/клетки на тик), не по часам, иначе
|
||||
результат зависит от скорости GPU и пишется недетерминированно. Слушатель — камера реплея (та же `set_listener`).
|
||||
Геометрия — текущий мир; если реплей показывает уже разрушенное, акустика покажет сегодняшнюю карту (проверить, что
|
||||
делает реплей с блоками).
|
||||
7. **Двойное эхо с cinema.rs.** `_far`-варианты (приглушённый звук, поздний гул `blast.big_far` с `delay 10+2*power`)
|
||||
и `travels()` с задержкой по `SOUND_SPEED` — это моделирование «издалека» заранее запечёнными сэмплами. С включённой
|
||||
акустикой они дублируют срез верхов и хвост. При `acoustics=on` мод должен играть близкие варианты (без `_far`) и без
|
||||
«позднего гула»: срез от расстояния, эхо и хвост делает движок. Задержку распространения оставить в моде до #95, потом
|
||||
перенести в движок (по длине пути, с точностью до сэмпла), иначе она дважды считается и привязана к тику (50 мс).
|
||||
|
||||
## 4. Какие материалы и группы звуков важны
|
||||
|
||||
Берём `StateFacts.sounds.step` (например `minecraft:block.stone.step`), из него группа; для нее два числа
|
||||
из SPR (`occ`, `refl`). Большинство блоков остаётся на значениях по умолчанию (`occ` 1.0, `refl` 0.5).
|
||||
|
||||
| Группа | occ | refl | Зачем |
|
||||
|---|---|---|---|
|
||||
| stone, deepslate, tuff, basalt, calcite, netherrack, nether_bricks, bone | 1.0 | 1.5 (1.1 незерак) | горы, здания, бетон, кирпич: основные отражатели эха |
|
||||
| metal, copper, anvil, netherite | 1.0 | 1.25 | ангары, пусковые |
|
||||
| wood | 1.0 | 0.4 | крыши, дома |
|
||||
| gravel, grass, dirt | 1.0 | 0.3 | холмы глушат, но слабо переотражают |
|
||||
| sand | 1.0 | 0.2 | |
|
||||
| snow | 0.1 | 0.15 | снег не глушит |
|
||||
| wool | 1.5 | 0.1 | мешки с песком, окна: глушит, не отражает |
|
||||
| glass | 0.1 | 0.75 | окна пропускают звук, отражают |
|
||||
| water | 0.25 | 0.5 | море, озёра: гладкая плоскость |
|
||||
| листья, трава-растения, лианы, лестницы, цепи, посевы | 0 | 0.1 | нет окклюзии |
|
||||
| неполный блок (`!full_collision`) | `occ * 0.25` | как у группы | двери, ступеньки, заборы |
|
||||
|
||||
Пропускаем: подводный фильтр, мёд, аметист, фонари и прочее по `SoundType` (всего ~45 групп у SPR; нам хватает
|
||||
~12), звуки ambient и музыка, записи, интерфейс (`relative`), голосовой чат, расширение дальности `getRange x4`
|
||||
(у нас свои дальности в моде). Ключевое для AirDef: стоит ценность в **дальности лучей** (512, не 256) и
|
||||
в дешёвом обходе, а не в количестве материалов.
|
||||
|
||||
Целевые ощущения и как их получить:
|
||||
|
||||
- Ущелье: лучи среды находят стены на 20-150 блоков, из них 4 самых длинных (разнесённых по направлению) становятся
|
||||
эхо-отводами с задержкой `path/343 с` (150 блоков = 0.44 с, 400 = 1.2 с) и gain `0.25*(0.75 refl + 0.25) / (1 + path/50)`;
|
||||
плюс шина с RT60 2-4 с. **Дискретное эхо SPR не даёт, это наше добавление** (§1.3).
|
||||
- За горой: окклюзия `exp(-3 * occ_sum)` по нескольким блокам горы (до 64) даёт глухой остаток, а перенаправление позиции
|
||||
(§1.2) сдвигает источник в проём. Длинные лучи нужны, чтобы вообще её увидеть.
|
||||
- Под крышей, тоннель: зонд слушателя видит близкие стены во всех направлениях (средняя длина < 12 блоков), RT60 шины
|
||||
0.4-1.2 с, сильная посылка, малая задержка раннего. В открытом поле: лучи уходят в небо (MISS), хвоста нет.
|
||||
- Воздушный взрыв: короткий и сильный; эхо считаем один раз на событие и общим для его слоёв, а не на каждый слой.
|
||||
|
||||
## 5. Стадии
|
||||
|
||||
Общее правило: включается настройкой форка/мода `acoustics` (`off|direct|reflect|full`), по умолчанию `off` — звук
|
||||
как сегодня, бит в бит. Каждая стадия за своим флагом `HORIZON_ACOUSTICS_*` для отладки. Сводка логики в телеметрии
|
||||
`status`.
|
||||
|
||||
Лицензия: GPL-3.0 код переносить только в отдельные файлы. Планируется: `horizon-client/src/acoustics/spr_*.rs` (порт
|
||||
формул и таблиц) с шапкой «GPL-3.0-only, портировано из Sound Physics Remastered @453654ec, файл/функция»; рядом
|
||||
`acoustics/THIRD_PARTY_SPR.md` в стиле `gfx/THIRD_PARTY_PHOTON.md` (коммит, список взятого, полный текст GPL-3.0,
|
||||
что осталось нашим). Весь остальной код (микшер, планировщик, DSP) пишется заново и остаётся MIT. Предупреждение
|
||||
владельцу: бинарник форка, куда слинкован GPL-код, при распространении должен соответствовать GPL-3.0 (исходники
|
||||
целиком); «отдельные файлы» делают код опознаваемым, но не освобождают от этого. Смягчение: порт под cargo feature
|
||||
`spr-gpl`, без неё остаётся наш собственный `direct` на простой модели. Решение за владельцем. В SPR переносимы
|
||||
по сути только формулы (§1) и таблицы (§4); DSP у SPR нет вообще (он в OpenAL).
|
||||
|
||||
### Стадия 1. Прямой звук (окклюзия)
|
||||
|
||||
- Файлы: `audio.rs` (LP на голос, slew, поля `Channel`), `acoustics/{mod,world,trace,spr_occlusion}.rs`,
|
||||
`sound_engine.rs` (где голос получает позицию: вызов планировщика), `game/film.rs` (синхронный режим).
|
||||
- Порт: `runOcclusion`, `calculateOcclusion` (9 путей), `exp(-occ*3)`, `^0.1`, таблицы §4, перенаправление позиции.
|
||||
- Тесты: чистые, без игры: (а) фикстура `BlockProbe` как в `world_query::tests`: стена из 1, 2, 3 блоков → cutoff
|
||||
0.050 / 0.0025 / 0.0001, gain 0.74 / 0.55 / 0.41; щель у двери проходит; неизвестный чанк не глушит;
|
||||
(б) `mix` с синусом 8 кГц: энергия на выходе падает в нужное число раз, для 500 Гц почти не меняется; (в) slew
|
||||
без скачков (макс. разность соседних сэмплов); (г) выключатель: `off` даёт тот же вывод, что до изменения (хеш).
|
||||
- Проверка без слуха: `film_mix` на сцене «источник за стеной» → `.audio.raw` (s16le 48 кГц) → скрипт
|
||||
`tools/acoustics_check.py` (stdlib): энергия по полосам (до 1 кГц и выше 4 кГц), отношение «за стеной/на открытом»
|
||||
против ожидаемого; сравнение `off` и `direct` на одном сэмпле и точке.
|
||||
- Критерий: падение верхов на 20-30 дБ за 1 блоком камня, на открытом месте разницы нет; бюджет в `status`.
|
||||
|
||||
### Стадия 2. Ранние отражения и эхо
|
||||
|
||||
- Файлы: `audio.rs` (отводы, хвост голоса), `acoustics/{scheduler,echo,cache}.rs`, рабочий поток, `spr_bounce.rs`
|
||||
(спираль Фибоначчи, `reflect`, `energyTowardsPlayer`, общее воздушное пространство).
|
||||
- Тесты: детерминированная фикстура «ущелье» (две стены, расстояние до слушателя известно): отводы появляются с задержкой
|
||||
`path/343` ± 1 сэмпл; кеш: повтор запроса — попадание; разрушенная стена (изменение секции) инвалидирует запись;
|
||||
бюджет: 100 запросов за тик не превышают потолок клеток, очередь убывает; эхо живёт после конца сэмпла (голос в
|
||||
состоянии «хвост» ровно `max_delay`); `off` бит-в-бит.
|
||||
- Проверка без слуха: импульсный сэмпл (щелчок) в сцене «ущелье» и «поле»; в записи ищем пики автокорреляции/огибающей:
|
||||
в ущелье пики на расчётных задержках с точностью до 1 мс, в поле нет. Оффлайн-MP4 дважды с разным GPU-таймингом
|
||||
(`HORIZON_*` число кадров на тик) → побайтно одинаковая дорожка.
|
||||
- Критерий: измеренные задержки совпадают с геометрией, замер CPU `mix` на 100 голосах и время планировщика на налёте
|
||||
(по `status` и `hd_bench`-подобному прогону).
|
||||
|
||||
### Стадия 3. Поздний реверб
|
||||
|
||||
- Файлы: `audio.rs` (шины FDN, посылки, сброс), `acoustics/probe.rs` (зонд слушателя, класс окружения),
|
||||
`spr_reverb.rs` (формулы посылок §1.3, начальные параметры §1.4).
|
||||
- Тесты: FDN устойчив (энергия монотонно убывает при любых параметрах класса, нет NaN/denorm на 10 с тишины после
|
||||
импульса); RT60 шины совпадает с заданным (Schroeder backward integration, допуск 15%); перекрёстное смешивание
|
||||
классов не даёт скачка; сброс при `film_begin/film_end`; лимитер не срабатывает на нормальном налёте.
|
||||
- Проверка без слуха: импульсный отклик шины из `film_mix` → RT60 по спаду (`tools/acoustics_check.py`) для четырёх
|
||||
классов сцены (поле, ущелье, комната, тоннель) — таблица в отчёте; спад должен упорядочиться
|
||||
поле < комната < тоннель < ущелье; налёт 100 источников: пиковый уровень, число срабатываний лимитера, CPU.
|
||||
- Критерий: хвост после конца исходного хлопка длится ожидаемое RT60 и не продолжается после смены мира.
|
||||
|
||||
### Стадия 4 (по #96): движущиеся источники и интеграция с #95/cinema
|
||||
|
||||
Пересчёт по интервалам (§3.5), `acoustics=on` ⇒ мод играет близкие варианты (§3.7), категории и слушатель реплея.
|
||||
Зависит от #95 (постоянные источники): ждать его слияния, `audio.rs` сейчас правит другой агент — не начинать
|
||||
стадию 1 до него, чтобы не конфликтовать по `Channel`/`mix`.
|
||||
|
||||
## Что оказалось неверным или неточным в тексте #96
|
||||
|
||||
- «Ранние отражения с задержкой и затуханием» в SPR нет: есть только 4 фиксированных реверб-слота OpenAL; индекс слота
|
||||
вычисляется из длины пути. Эхо от скалы с реальной задержкой — наша собственная разработка (стадия 2).
|
||||
- DSP и реверб SPR целиком отдаёт OpenAL (LGPL, не портируем). Портируются формулы и таблицы, а не «микшер».
|
||||
- SPR видит мир только в ~64 блоках вокруг игрока (клон 8x8 чанков) и не кеширует результаты: к «дальним
|
||||
взрывам за горой» и к «100 источникам» он не готов, это придётся решать самим (длинные лучи, секции, очередь).
|
||||
- Коммит из #96 (`76f680eb…`) не найден в недавней истории `master`; разбор сделан по `453654ec`.
|
||||
- Мод-API `query-rays` с политикой `sound` для этого недостаточно (нет материала и полупрозрачных долей), поэтому
|
||||
акустика в клиенте; мод только сообщает события (#95).
|
||||
@@ -82,6 +82,10 @@ python3 tools/airdef.py play # или: python3 tools/airdef.py play --world "
|
||||
|
||||
## 3. В игре
|
||||
|
||||
- Обновление из исходников: `git pull`, затем снова `all` (Windows: `py tools\airdef.py all`). Форк и упаковщик мода
|
||||
пересоберутся сами, если патчи изменились.
|
||||
- Windows: нужна видеокарта с Vulkan. Экспорт фильмов (`/airdef replay export`) требует `ffmpeg` в PATH или `HORIZON_FFMPEG`.
|
||||
- Шейдеры уровня шейдерпаков: `/airdef shader hd`. Для слабой видеокарты перед запуском: `$env:HORIZON_HD_SCALE="0.67"` (PowerShell).
|
||||
- Создай мир с читами (или включи их через «Открыть для сети»): мод выдаёт пульты и взрывы командами.
|
||||
- `/airdef give` — пульты, **N** — командный пункт (роли ПВО), **K** — монитор комплекса,
|
||||
`/airdef wave mix 20` — налёт. Подробнее — README.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 12:37:45 +0300
|
||||
Subject: [PATCH 01/36] Mod API (AirDef fork): world models, client particles,
|
||||
Subject: [PATCH 01/37] Mod API (AirDef fork): world models, client particles,
|
||||
sounds, ground heights
|
||||
|
||||
render.set-models draws block models with an interpolated pose per id;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 12:39:45 +0300
|
||||
Subject: [PATCH 02/36] cargo-horizon-mod: report particles and sounds
|
||||
Subject: [PATCH 02/37] cargo-horizon-mod: report particles and sounds
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 13:33:10 +0300
|
||||
Subject: [PATCH 03/36] Mod API (AirDef fork): full-screen post pass for mods
|
||||
Subject: [PATCH 03/37] Mod API (AirDef fork): full-screen post pass for mods
|
||||
|
||||
render.set-post compiles a mod's WGSL after a prelude (camera matrices,
|
||||
time, 256 vec4 of params, the world's colour and depth) and runs it after
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 22:09:30 +0300
|
||||
Subject: [PATCH 04/36] Mod models: draw far models as scaled impostors past
|
||||
Subject: [PATCH 04/37] Mod models: draw far models as scaled impostors past
|
||||
the far plane, cull at 4096
|
||||
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 22:31:00 +0300
|
||||
Subject: [PATCH 05/36] Mod cameras: render.set-views draws the world from
|
||||
Subject: [PATCH 05/37] Mod cameras: render.set-views draws the world from
|
||||
other points into pictures that HUD view shapes show (API level 9)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 23:08:21 +0300
|
||||
Subject: [PATCH 06/36] Mods: more fuel per tick and per event (AirDef plans
|
||||
Subject: [PATCH 06/37] Mods: more fuel per tick and per event (AirDef plans
|
||||
raids)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Wed, 28 Oct 2026 23:25:29 +0300
|
||||
Subject: [PATCH 07/36] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
Subject: [PATCH 07/37] Mod cameras up to 1920x1080 (full-screen replays)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 11:15:11 +0300
|
||||
Subject: [PATCH 08/36] Mod cameras: distant terrain tiles, fog measured from
|
||||
Subject: [PATCH 08/37] Mod cameras: distant terrain tiles, fog measured from
|
||||
the camera, no impostors while one is shown
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 11:25:45 +0300
|
||||
Subject: [PATCH 09/36] Mods: game.world-id, so mods keep state per world (API
|
||||
Subject: [PATCH 09/37] Mods: game.world-id, so mods keep state per world (API
|
||||
level 9)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 12:01:08 +0300
|
||||
Subject: [PATCH 10/36] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
Subject: [PATCH 10/37] cargo-horizon-mod: trial runs behind feature full, so a
|
||||
packing-only tool skips wasmtime
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 13:18:43 +0300
|
||||
Subject: [PATCH 11/36] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
Subject: [PATCH 11/37] HD lighting: G-buffer pass, per-pixel mod point lights
|
||||
in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API
|
||||
level 10)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 13:59:43 +0300
|
||||
Subject: [PATCH 12/36] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
Subject: [PATCH 12/37] HD lighting stage 2: sun and moon shadow cascades, the
|
||||
world relit (sun by face angle and shadow, sky and block light through the
|
||||
lightmap), light shafts and halos, fog over it again, a light colour grade
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:19:53 +0300
|
||||
Subject: [PATCH 13/36] HD: bind groups cached until a resize or a new source,
|
||||
Subject: [PATCH 13/37] HD: bind groups cached until a resize or a new source,
|
||||
shadow passes draw only the entities that cast shadows
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:25:44 +0300
|
||||
Subject: [PATCH 14/36] HD: lights culled per 16x16 tile (surface and halo
|
||||
Subject: [PATCH 14/37] HD: lights culled per 16x16 tile (surface and halo
|
||||
lists built on the CPU), a naga test for the lighting shaders
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 16:43:36 +0300
|
||||
Subject: [PATCH 15/36] HD: GPU time per pass in the F3 timing list,
|
||||
Subject: [PATCH 15/37] HD: GPU time per pass in the F3 timing list,
|
||||
half-resolution GTAO with a depth-aware blur
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:02:02 +0300
|
||||
Subject: [PATCH 16/36] HD: shadows draw only sun-facing quads and skip buried
|
||||
Subject: [PATCH 16/37] HD: shadows draw only sun-facing quads and skip buried
|
||||
sections, the far cascade every second frame; TAA (Halton jitter,
|
||||
reprojection by depth, YCoCg variance clipping) before bloom
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:02:02 +0300
|
||||
Subject: [PATCH 17/36] Mods: model handles (render.model-id, model-generation,
|
||||
Subject: [PATCH 17/37] Mods: model handles (render.model-id, model-generation,
|
||||
set-models-by-id; API level 11) so mods send no model names each tick;
|
||||
clear-post and set-lighting documented
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:19:57 +0300
|
||||
Subject: [PATCH 18/36] HD: temporal upscaling (HORIZON_HD_SCALE: world,
|
||||
Subject: [PATCH 18/37] HD: temporal upscaling (HORIZON_HD_SCALE: world,
|
||||
G-buffer and lighting at a lower size, rebuilt to the window in the TAA
|
||||
pass); fix flickering shadows near the player (the near cascade lost its
|
||||
sections on frames the far one was held, the held far matrix ignored the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:47:52 +0300
|
||||
Subject: [PATCH 19/36] HD water: its own pass (colour, normal, depth), the
|
||||
Subject: [PATCH 19/37] HD water: its own pass (colour, normal, depth), the
|
||||
floor under it lit, a composite with world-space waves, refraction with depth
|
||||
absorption in the biome colour, screen-space reflections, Fresnel and a
|
||||
shadowed sun glint (HORIZON_HD_WATER=0 to switch off)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 17:56:04 +0300
|
||||
Subject: [PATCH 20/36] HD water: no vanilla texture on the surface (the biome
|
||||
Subject: [PATCH 20/37] HD water: no vanilla texture on the surface (the biome
|
||||
colour only), a darker, deeper colour
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 18:38:19 +0300
|
||||
Subject: [PATCH 21/36] HD sky and light: a physical atmosphere (Rayleigh, Mie,
|
||||
Subject: [PATCH 21/37] HD sky and light: a physical atmosphere (Rayleigh, Mie,
|
||||
ozone; constants and transmittance after Photon, MIT) for the sky, haze, fog
|
||||
and water reflections; warmer sunlight from the atmosphere's transmittance, a
|
||||
gentle sky-coloured ambient, soft PCSS-lite shadows near the player, a haze
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 19:02:16 +0300
|
||||
Subject: [PATCH 22/36] HD water: a darker blue, a little clearer, ripples a
|
||||
Subject: [PATCH 22/37] HD water: a darker blue, a little clearer, ripples a
|
||||
little higher
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 21:41:48 +0300
|
||||
Subject: [PATCH 23/36] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
|
||||
Subject: [PATCH 23/37] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun
|
||||
tint, blue hour; MIT), volumetric clouds with ground shadows instead of
|
||||
vanilla's in HD, volumetric fog with sun shafts and morning mist
|
||||
(HORIZON_HD_CLOUDS=0, HORIZON_HD_VOLFOG=0)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 21:43:22 +0300
|
||||
Subject: [PATCH 24/36] Mods: world queries for simulations (render.query-rays,
|
||||
Subject: [PATCH 24/37] Mods: world queries for simulations (render.query-rays,
|
||||
query-sections, world-changes; API level 12): block-exact rays and section
|
||||
occupancy by collision, sight or sound, answered next tick within budgets,
|
||||
and a log of changed sections
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 22:56:15 +0300
|
||||
Subject: [PATCH 25/36] HD fixes: rain level from the world not from rain
|
||||
Subject: [PATCH 25/37] HD fixes: rain level from the world not from rain
|
||||
geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint
|
||||
without a depth heuristic (#35), air fog once over water (#37), translucent
|
||||
things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 23:19:53 +0300
|
||||
Subject: [PATCH 26/36] HD split into modules (gfx/hd/: one Rust file and one
|
||||
Subject: [PATCH 26/37] HD split into modules (gfx/hd/: one Rust file and one
|
||||
WGSL file per pass, a shared prelude), contracts for colour, depth, alpha,
|
||||
time and coordinates written down, tests that the frame layout and bindings
|
||||
match between Rust and WGSL (#45)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Thu, 29 Oct 2026 23:22:19 +0300
|
||||
Subject: [PATCH 27/36] HD clouds: noise scales that divide the camera's
|
||||
Subject: [PATCH 27/37] HD clouds: noise scales that divide the camera's
|
||||
65536-block wrap, so the clouds and their shadows no longer jump at x or z =
|
||||
0
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:17:11 +0300
|
||||
Subject: [PATCH 28/36] HD: volumetric smoke and missile trails with clear
|
||||
Subject: [PATCH 28/37] HD: volumetric smoke and missile trails with clear
|
||||
tunnels through fog and cloud (render.set-smoke, API level 14; #54); sun
|
||||
speculars by material, wet surfaces and puddles in rain, wind in foliage
|
||||
(HORIZON_HD_SPECULAR=0, HORIZON_HD_WIND=0, HORIZON_HD_SMOKE=0); HD shaders
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:26:09 +0300
|
||||
Subject: [PATCH 29/36] HD clouds composited by depth so terrain and vehicles
|
||||
Subject: [PATCH 29/37] HD clouds composited by depth so terrain and vehicles
|
||||
inside or behind the layer are hidden and those in front stay in front (#67);
|
||||
world-anchored noise everywhere (cloud detail by height above the layer, fog,
|
||||
smoke and puddle scales dividing the wrap) (#68); the replay's time is visual
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:44:45 +0300
|
||||
Subject: [PATCH 30/36] HD smoke: young trails drawn at least as wide as a grid
|
||||
Subject: [PATCH 30/37] HD smoke: young trails drawn at least as wide as a grid
|
||||
cell (density scaled to keep the mass), gentler bulges, so a trail is a
|
||||
smooth ribbon and not a chain of puffs
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 03:59:41 +0300
|
||||
Subject: [PATCH 31/36] Mods: render.set-main-view (API level 15) renders the
|
||||
Subject: [PATCH 31/37] Mods: render.set-main-view (API level 15) renders the
|
||||
screen's world from a mod camera with the full pipeline, so replays get HD
|
||||
and the post effects; small mod views get the post pass with their own camera
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 27e24527550942695164dd3a769a42a659e797ca Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 09:28:05 +0300
|
||||
Subject: [PATCH 32/36] Spot lights for mods (render.set-spot-lights, API level
|
||||
Subject: [PATCH 32/37] Spot lights for mods (render.set-spot-lights, API level
|
||||
16): surfaces lit in the cone, a volumetric beam stopped by depth (#60); film
|
||||
capture (render.capture-*, set-film-quality, API level 17): fixed-step clock,
|
||||
frames piped to ffmpeg or PNGs, offline audio, no chat in the frames (#58);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 615f5c3fa7c19a9895f8972a88333c6f250ad78a Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 14:30:39 +0300
|
||||
Subject: [PATCH 33/36] HD clouds and fog polish: cloud panorama, cirrus,
|
||||
Subject: [PATCH 33/37] HD clouds and fog polish: cloud panorama, cirrus,
|
||||
terrain-aware fog, far smoke cascade; replay world log and film clock;
|
||||
material overlays by sprite name
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 0254984fdcce53a4a0f2bff15f9be3b78c93842e Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 15:01:09 +0300
|
||||
Subject: [PATCH 34/36] HD: coloured sun through glass and leaves, rain ripples
|
||||
Subject: [PATCH 34/37] HD: coloured sun through glass and leaves, rain ripples
|
||||
on water, film motion blur and depth of field
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 9645d3f1f47d8bb358d5630288bdd801bd874911 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 15:19:32 +0300
|
||||
Subject: [PATCH 35/36] Film export: every frame in flight reaches the writer,
|
||||
Subject: [PATCH 35/37] Film export: every frame in flight reaches the writer,
|
||||
listener follows the main view, cloud wind on film frames, set-smoke reports
|
||||
real activity
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
From 22d6835507d5627b0fe4a749b082b025b35e0262 Mon Sep 17 00:00:00 2001
|
||||
From: chexit <chexit@duck.com>
|
||||
Date: Fri, 30 Oct 2026 15:43:11 +0300
|
||||
Subject: [PATCH 36/36] Mod command trees: command.set-tree and
|
||||
Subject: [PATCH 36/37] Mod command trees: command.set-tree and
|
||||
set-suggestions, typed parse, colouring and completion from one tree (API 19)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+11
-5
@@ -71,7 +71,8 @@ def fork():
|
||||
stamp = fork_stamp(commit, patches)
|
||||
stamp_file = FORK / ".git" / "airdef-stamp"
|
||||
if not (FORK / ".git").exists():
|
||||
run(["git", "clone", "--filter=blob:none", UPSTREAM, FORK])
|
||||
# Без CRLF на Windows: патчи накладываются на файлы как в апстриме.
|
||||
run(["git", "clone", "-c", "core.autocrlf=false", "--filter=blob:none", UPSTREAM, FORK])
|
||||
branch = subprocess.run(["git", "-C", FORK, "branch", "--show-current"], capture_output=True, text=True).stdout.strip()
|
||||
if branch == "airdef" and stamp_file.exists() and stamp_file.read_text().strip() == stamp:
|
||||
print("horizon/: ветка airdef на нужных коммите и патчах")
|
||||
@@ -110,11 +111,16 @@ def built_game() -> Path:
|
||||
|
||||
def mod():
|
||||
tool = shutil.which("cargo-horizon-mod") or Path.home() / ".cargo" / "bin" / ("cargo-horizon-mod" + EXE)
|
||||
if not Path(tool).exists():
|
||||
if not (FORK / "Cargo.toml").exists():
|
||||
fork()
|
||||
if not (FORK / "Cargo.toml").exists():
|
||||
fork()
|
||||
# Упаковщик должен быть от этого форка: старый не знает новых уровней API.
|
||||
stamp_file = FORK / ".git" / "airdef-packer-stamp"
|
||||
commit = (ROOT / "HORIZON_COMMIT").read_text().strip()
|
||||
stamp = fork_stamp(commit, sorted((ROOT / "horizon-patches").glob("*.patch")))
|
||||
if not Path(tool).exists() or not stamp_file.exists() or stamp_file.read_text().strip() != stamp:
|
||||
# Without trial runs (wasmtime): building a mod needs only packing, a fraction of the compile.
|
||||
run([cargo(), "install", "--locked", "--no-default-features", "--path", FORK / "crates" / "cargo-horizon-mod"])
|
||||
run([cargo(), "install", "--locked", "--force", "--no-default-features", "--path", FORK / "crates" / "cargo-horizon-mod"])
|
||||
stamp_file.write_text(stamp + "\n")
|
||||
run([cargo(), "horizon-mod", "build"], cwd=ROOT / "airdef-mod")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user