Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
@@ -36,6 +36,7 @@ jobs:
|
||||
run: |
|
||||
python3 -m pip install --user -r tools/requirements.txt
|
||||
python3 tools/gen_catalog.py --check
|
||||
python3 -m unittest discover -s tools -p test_sound_catalog.py
|
||||
python3 tools/gen_models.py --check
|
||||
python3 -m unittest discover -s tools -p test_model_bounds.py
|
||||
- name: Тесты ядра без SDK
|
||||
|
||||
@@ -52,6 +52,11 @@ impl Unit {
|
||||
}
|
||||
|
||||
use DroneKind::*;
|
||||
/// Stable sound IDs shared with the audio resource generator.
|
||||
pub mod sound {
|
||||
include!("catalog_sound_ids.rs");
|
||||
}
|
||||
|
||||
// Generated entries keep the historical raid and unit ordering.
|
||||
include!("catalog_entries.rs");
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Generated by tools/gen_catalog.py from catalog/sounds.json. Do not edit.
|
||||
pub const DRONE_SHAHED: &str = "airdef:drone.shahed";
|
||||
pub const DRONE_GERAN3: &str = "airdef:drone.geran3";
|
||||
pub const DRONE_LANCET: &str = "airdef:drone.lancet";
|
||||
pub const DRONE_CRUISE: &str = "airdef:drone.cruise";
|
||||
pub const DRONE_ROCKET: &str = "airdef:drone.rocket";
|
||||
pub const MISSILE_FLIGHT: &str = "airdef:missile.flight";
|
||||
pub const DRONE_BALLISTIC: &str = "airdef:drone.ballistic";
|
||||
pub const BOOM_SONIC: &str = "airdef:boom.sonic";
|
||||
pub const LAUNCH_PATRIOT: &str = "airdef:launch.patriot";
|
||||
pub const LAUNCH_S400_EJECT: &str = "airdef:launch.s400_eject";
|
||||
pub const LAUNCH_S400: &str = "airdef:launch.s400";
|
||||
pub const LAUNCH_S500_EJECT: &str = "airdef:launch.s500_eject";
|
||||
pub const LAUNCH_S500: &str = "airdef:launch.s500";
|
||||
pub const LAUNCH_PANTSIR: &str = "airdef:launch.pantsir";
|
||||
pub const GUN_GEPARD: &str = "airdef:gun.gepard";
|
||||
pub const GUN_PANTSIR: &str = "airdef:gun.pantsir";
|
||||
pub const BLAST_BIG: &str = "airdef:blast.big";
|
||||
pub const BLAST_HEAVY: &str = "airdef:blast.heavy";
|
||||
pub const BLAST_HUGE: &str = "airdef:blast.huge";
|
||||
pub const BLAST_MEDIUM: &str = "airdef:blast.medium";
|
||||
pub const BLAST_AIR: &str = "airdef:blast.air";
|
||||
@@ -123,9 +123,9 @@ pub fn look(kind: DroneKind, outcome: Outcome) -> (f32, u32, f32, &'static str)
|
||||
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"),
|
||||
Outcome::DisabledFalling => (size, life, gain, super::catalog::sound::BLAST_AIR),
|
||||
Outcome::Breakup => (size * 0.9, life, gain * 0.9, super::catalog::sound::BLAST_MEDIUM),
|
||||
Outcome::AirDetonation => (size * 1.9, life * 8 / 5, gain * 1.5, super::catalog::sound::BLAST_BIG),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -660,7 +660,7 @@ impl Sim {
|
||||
out.model(id + 1, "airdef:geran3_flame", pos + rot * DVec3::new(0.0, up * spec.scale, back * spec.scale), rot, spec.scale * size * flicker, true);
|
||||
}
|
||||
if f.carrier.is_some() {
|
||||
if age == EJECT_TICKS { out.sound("airdef:launch.s400", pos, 1.0, 1.0); }
|
||||
if age == EJECT_TICKS { out.sound(super::catalog::sound::LAUNCH_S400, pos, 1.0, 1.0); }
|
||||
if age >= EJECT_TICKS {
|
||||
let (event, gain, pitch) = MissileKind::M48n6.engine_voice();
|
||||
out.engine(id, event, pos, pos - from, gain, pitch, false);
|
||||
@@ -759,7 +759,7 @@ impl Sim {
|
||||
land: p.aim + DVec2::new(gx, gz) * TUNE.cep, start: self.tick, arrive: self.tick + ticks, snap, hold: 0 });
|
||||
if let Some(k) = self.strikes.known.iter_mut().find(|k| k.site == p.site) { k.aimed = true; }
|
||||
self.record_strike_launch(self.strikes.next, p.carrier, start);
|
||||
out.sound("airdef:launch.s400_eject", start, 1.0, 1.0);
|
||||
out.sound(super::catalog::sound::LAUNCH_S400_EJECT, start, 1.0, 1.0);
|
||||
out.particles("minecraft:cloud", start, 0.6, DVec3::Y * 0.05, 0.05, 30);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ impl MissileKind {
|
||||
/// 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);
|
||||
return (super::catalog::sound::DRONE_LANCET, 0.8, 1.6);
|
||||
}
|
||||
let pitch = match self {
|
||||
MissileKind::Pac3 => 1.0,
|
||||
@@ -31,7 +31,7 @@ impl MissileKind {
|
||||
MissileKind::M9m338 => 1.15,
|
||||
MissileKind::P1sun => 1.7,
|
||||
};
|
||||
("airdef:missile.flight", 1.0, pitch)
|
||||
(super::catalog::sound::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 {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
[
|
||||
{
|
||||
"event": "drone.shahed",
|
||||
"distance": 220,
|
||||
"far": false
|
||||
},
|
||||
{
|
||||
"event": "drone.geran3",
|
||||
"distance": 260,
|
||||
"far": false
|
||||
},
|
||||
{
|
||||
"event": "drone.lancet",
|
||||
"distance": 120,
|
||||
"far": false
|
||||
},
|
||||
{
|
||||
"event": "drone.cruise",
|
||||
"distance": 320,
|
||||
"far": false
|
||||
},
|
||||
{
|
||||
"event": "drone.rocket",
|
||||
"distance": 450,
|
||||
"far": false
|
||||
},
|
||||
{
|
||||
"event": "missile.flight",
|
||||
"distance": 380,
|
||||
"far": false
|
||||
},
|
||||
{
|
||||
"event": "drone.ballistic",
|
||||
"distance": 700,
|
||||
"far": false
|
||||
},
|
||||
{
|
||||
"event": "boom.sonic",
|
||||
"distance": 900,
|
||||
"far": true
|
||||
},
|
||||
{
|
||||
"event": "launch.patriot",
|
||||
"distance": 650,
|
||||
"far": true
|
||||
},
|
||||
{
|
||||
"event": "launch.s400_eject",
|
||||
"distance": 400,
|
||||
"far": true
|
||||
},
|
||||
{
|
||||
"event": "launch.s400",
|
||||
"distance": 900,
|
||||
"far": true
|
||||
},
|
||||
{
|
||||
"event": "launch.s500_eject",
|
||||
"distance": 500,
|
||||
"far": true
|
||||
},
|
||||
{
|
||||
"event": "launch.s500",
|
||||
"distance": 1100,
|
||||
"far": true
|
||||
},
|
||||
{
|
||||
"event": "launch.pantsir",
|
||||
"distance": 500,
|
||||
"far": true
|
||||
},
|
||||
{
|
||||
"event": "gun.gepard",
|
||||
"distance": 400,
|
||||
"far": true
|
||||
},
|
||||
{
|
||||
"event": "gun.pantsir",
|
||||
"distance": 380,
|
||||
"far": true
|
||||
},
|
||||
{
|
||||
"event": "blast.big",
|
||||
"distance": 1000,
|
||||
"far": true
|
||||
},
|
||||
{
|
||||
"event": "blast.heavy",
|
||||
"distance": 1300,
|
||||
"far": true
|
||||
},
|
||||
{
|
||||
"event": "blast.huge",
|
||||
"distance": 1700,
|
||||
"far": true
|
||||
},
|
||||
{
|
||||
"event": "blast.medium",
|
||||
"distance": 700,
|
||||
"far": true
|
||||
},
|
||||
{
|
||||
"event": "blast.air",
|
||||
"distance": 800,
|
||||
"far": true
|
||||
}
|
||||
]
|
||||
+1
-1
@@ -38,7 +38,7 @@
|
||||
- `python3 tools/gen_models.py` — перегенерировать.
|
||||
- `python3 tools/gen_models.py --check` — только сравнить, выход 1 при различиях.
|
||||
|
||||
`tools/gen_sounds.py` создаёт `airdef-mod/assets/airdef/sounds/*.ogg` и `sounds.json`. Этот генератор не использует manifest и не входит в проверку `gen_models.py`.
|
||||
`catalog/sounds.json` хранит звуковые ID, дальности и наличие дальнего варианта; `tools/gen_catalog.py` экспортирует ID для Rust, а `tools/gen_sounds.py` создаёт `airdef-mod/assets/airdef/sounds/*.ogg` и `sounds.json`. Этот генератор не использует manifest и не входит в проверку `gen_models.py`.
|
||||
|
||||
## Архив: `legacy/`
|
||||
|
||||
|
||||
@@ -23,3 +23,37 @@ def load():
|
||||
if len(item["color"]) != 3 or any(not isinstance(c, int) or not 0 <= c <= 255 for c in item["color"]):
|
||||
raise ValueError(f"invalid icon color for {item['id']}")
|
||||
return data
|
||||
|
||||
|
||||
def load_sounds():
|
||||
"""Load stable event IDs and engine attenuation metadata."""
|
||||
import re
|
||||
|
||||
entries = json.loads((PATH.parent / "sounds.json").read_text(encoding="utf-8"))
|
||||
seen = set()
|
||||
for entry in entries:
|
||||
event, distance, far = entry["event"], entry["distance"], entry["far"]
|
||||
if not re.fullmatch(r"[a-z0-9_]+\.[a-z0-9_]+", event) or event in seen:
|
||||
raise ValueError(f"invalid or duplicate sound event {event!r}")
|
||||
if type(distance) is not int or distance <= 0 or type(far) is not bool:
|
||||
raise ValueError(f"invalid sound metadata for {event}")
|
||||
seen.add(event)
|
||||
names = [entry["event"].replace(".", "_") for entry in entries]
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("sound event IDs collide as resource names")
|
||||
registered = [name for entry in entries for name in ([entry["event"], entry["event"] + "_far"] if entry["far"] else [entry["event"]])]
|
||||
if len(registered) != len(set(registered)):
|
||||
raise ValueError("sound event IDs collide with far variants")
|
||||
return entries
|
||||
|
||||
|
||||
def sound_events(entries):
|
||||
"""Build the Minecraft sound event registry without generating audio."""
|
||||
events = {}
|
||||
for entry in entries:
|
||||
event, distance = entry["event"], entry["distance"]
|
||||
name = event.replace(".", "_")
|
||||
events[event] = {"sounds": [{"name": f"airdef:{name}", "attenuation_distance": distance}]}
|
||||
if entry["far"]:
|
||||
events[event + "_far"] = {"sounds": [{"name": f"airdef:{name}_far", "attenuation_distance": int(distance * 1.6)}]}
|
||||
return events
|
||||
|
||||
+16
-5
@@ -4,7 +4,7 @@ import argparse
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from airdef_assets.catalog import load
|
||||
from airdef_assets.catalog import load, load_sounds
|
||||
|
||||
OUTPUT = Path(__file__).resolve().parents[1] / "airdef-mod/src/sim/catalog_entries.rs"
|
||||
|
||||
@@ -29,16 +29,27 @@ def generate(data):
|
||||
return "\n".join(lines + ["];", ""])
|
||||
|
||||
|
||||
def generate_sound_ids(entries):
|
||||
lines = ["// Generated by tools/gen_catalog.py from catalog/sounds.json. Do not edit."]
|
||||
for entry in entries:
|
||||
event = entry["event"]
|
||||
name = event.replace(".", "_").upper()
|
||||
lines.append(f'pub const {name}: &str = "airdef:{event}";')
|
||||
return "\n".join(lines + [""])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--check", action="store_true")
|
||||
args = parser.parse_args()
|
||||
generated = generate(load())
|
||||
sound_output = OUTPUT.with_name("catalog_sound_ids.rs")
|
||||
outputs = ((OUTPUT, generate(load())), (sound_output, generate_sound_ids(load_sounds())))
|
||||
for path, generated in outputs:
|
||||
if args.check:
|
||||
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != generated:
|
||||
parser.exit(1, f"{OUTPUT} is stale; run python3 tools/gen_catalog.py\n")
|
||||
if not path.exists() or path.read_text(encoding="utf-8") != generated:
|
||||
parser.exit(1, f"{path} is stale; run python3 tools/gen_catalog.py\n")
|
||||
else:
|
||||
OUTPUT.write_text(generated, encoding="utf-8")
|
||||
path.write_text(generated, encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+32
-27
@@ -19,6 +19,8 @@ import wave
|
||||
|
||||
import numpy as np
|
||||
|
||||
from airdef_assets.catalog import load_sounds, sound_events
|
||||
|
||||
SR = 44100
|
||||
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "airdef-mod", "assets", "airdef"))
|
||||
rng = np.random.default_rng(1945)
|
||||
@@ -367,29 +369,29 @@ def blast_air():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- запись
|
||||
SOUNDS = {
|
||||
# событие: (звук, дальность слышимости в блоках, есть ли дальний вариант)
|
||||
"drone.shahed": (shahed, 220, False),
|
||||
"drone.geran3": (geran3, 260, False),
|
||||
"drone.lancet": (lancet, 120, False),
|
||||
"drone.cruise": (cruise, 320, False),
|
||||
"drone.rocket": (rocket, 450, False),
|
||||
"missile.flight": (interceptor, 380, False),
|
||||
"drone.ballistic": (ballistic, 700, False),
|
||||
"boom.sonic": (sonic, 900, True),
|
||||
"launch.patriot": (launch_patriot, 650, True),
|
||||
"launch.s400_eject": (lambda: eject(42), 400, True),
|
||||
"launch.s400": (lambda: ignite(34, 6.5), 900, True),
|
||||
"launch.s500_eject": (lambda: eject(30), 500, True),
|
||||
"launch.s500": (lambda: ignite(26, 8.0), 1100, True),
|
||||
"launch.pantsir": (launch_pantsir, 500, True),
|
||||
"gun.gepard": (lambda: gun(3, 0.056, 55, 1500, 1.6), 400, True),
|
||||
"gun.pantsir": (lambda: gun(6, 0.034, 75, 2200, 1.4), 380, True),
|
||||
"blast.big": (lambda: blast(1.0), 1000, True),
|
||||
"blast.heavy": (lambda: blast(1.35), 1300, True),
|
||||
"blast.huge": (lambda: blast(1.75), 1700, True),
|
||||
"blast.medium": (lambda: blast(0.5), 700, True),
|
||||
"blast.air": (blast_air, 800, True),
|
||||
SYNTHESIZERS = {
|
||||
# Only waveform construction belongs here; IDs and distances come from the catalog.
|
||||
"drone.shahed": shahed,
|
||||
"drone.geran3": geran3,
|
||||
"drone.lancet": lancet,
|
||||
"drone.cruise": cruise,
|
||||
"drone.rocket": rocket,
|
||||
"missile.flight": interceptor,
|
||||
"drone.ballistic": ballistic,
|
||||
"boom.sonic": sonic,
|
||||
"launch.patriot": launch_patriot,
|
||||
"launch.s400_eject": lambda: eject(42),
|
||||
"launch.s400": lambda: ignite(34, 6.5),
|
||||
"launch.s500_eject": lambda: eject(30),
|
||||
"launch.s500": lambda: ignite(26, 8.0),
|
||||
"launch.pantsir": launch_pantsir,
|
||||
"gun.gepard": lambda: gun(3, 0.056, 55, 1500, 1.6),
|
||||
"gun.pantsir": lambda: gun(6, 0.034, 75, 2200, 1.4),
|
||||
"blast.big": lambda: blast(1.0),
|
||||
"blast.heavy": lambda: blast(1.35),
|
||||
"blast.huge": lambda: blast(1.75),
|
||||
"blast.medium": lambda: blast(0.5),
|
||||
"blast.air": blast_air,
|
||||
}
|
||||
|
||||
|
||||
@@ -408,19 +410,22 @@ def write_ogg(path, x):
|
||||
|
||||
|
||||
def main():
|
||||
catalog = load_sounds()
|
||||
if set(SYNTHESIZERS) != {entry["event"] for entry in catalog}:
|
||||
raise ValueError("sound synthesizers and catalog IDs differ")
|
||||
events = sound_events(catalog)
|
||||
out = os.path.join(ROOT, "sounds")
|
||||
os.makedirs(out, exist_ok=True)
|
||||
for f in os.listdir(out):
|
||||
os.remove(os.path.join(out, f))
|
||||
events = {}
|
||||
for event, (fn, distance, has_far) in SOUNDS.items():
|
||||
for entry in catalog:
|
||||
event, has_far = entry["event"], entry["far"]
|
||||
fn = SYNTHESIZERS[event]
|
||||
name = event.replace(".", "_")
|
||||
x = fn()
|
||||
write_ogg(os.path.join(out, name + ".ogg"), x)
|
||||
events[event] = {"sounds": [{"name": f"airdef:{name}", "attenuation_distance": distance}]}
|
||||
if has_far:
|
||||
write_ogg(os.path.join(out, name + "_far.ogg"), master(far(x), rms=0.2))
|
||||
events[event + "_far"] = {"sounds": [{"name": f"airdef:{name}_far", "attenuation_distance": int(distance * 1.6)}]}
|
||||
with open(os.path.join(ROOT, "sounds.json"), "w") as f:
|
||||
json.dump(events, f, indent=1)
|
||||
print(f"{len(events)} звуков -> {out}")
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Check sound metadata against shipped resources without regenerating audio."""
|
||||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from airdef_assets.catalog import load_sounds, sound_events
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class SoundCatalogTests(unittest.TestCase):
|
||||
def test_registry_and_audio_files_match_catalog(self):
|
||||
assets = ROOT / "airdef-mod/assets/airdef"
|
||||
expected = sound_events(load_sounds())
|
||||
actual = json.loads((assets / "sounds.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(actual, expected)
|
||||
files = {sound["name"].split(":", 1)[1] + ".ogg"
|
||||
for event in expected.values() for sound in event["sounds"]}
|
||||
self.assertEqual(files, {path.name for path in (assets / "sounds").glob("*.ogg")})
|
||||
|
||||
def test_every_catalog_event_has_one_synthesizer(self):
|
||||
source = (ROOT / "tools/gen_sounds.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
mapping = next(node.value for node in tree.body if isinstance(node, ast.Assign)
|
||||
and any(isinstance(target, ast.Name) and target.id == "SYNTHESIZERS"
|
||||
for target in node.targets))
|
||||
keys = [ast.literal_eval(key) for key in mapping.keys]
|
||||
self.assertEqual(len(keys), len(set(keys)))
|
||||
# Synthesis uses a shared random stream, so changing order changes the audio.
|
||||
self.assertEqual(keys, [entry["event"] for entry in load_sounds()])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user