Holiday music: a dark carol for Christmas, a spring tune for Easter, a harpsichord dance for Halloween

During a holiday the title music plays its piece, the build music every fourth time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 00:12:08 +01:00
1 parent 65ed76865d
commit 9800ce7156
8 files changed
+319 -8

No files matched your search

+66 -1
View File
@@ -24,6 +24,10 @@ pub enum Category {
Defeat,
Surface, // above ground: flute, harp, birdsong
SurfaceBattle, // fighting above ground
// the holidays' pieces (see holiday_category)
Christmas,
Easter,
Halloween,
}
// indices of all tracks of a category, in play order
@@ -34,7 +38,27 @@ pub fn tracks_in(c: Category) -> Vec<usize> {
// the calm tracks the music player offers (no battle, victory or defeat music)
pub fn player_tracks() -> Vec<usize> {
use Category as C;
TRACKS.iter().enumerate().filter(|(_, t)| matches!(t.1, C::Title | C::Build | C::Surface | C::Torture | C::Temple)).map(|(i, _)| i).collect()
TRACKS.iter().enumerate().filter(|(_, t)| matches!(t.1, C::Title | C::Build | C::Surface | C::Torture | C::Temple | C::Christmas | C::Easter | C::Halloween)).map(|(i, _)| i).collect()
}
// the music of a holiday (holiday::current()), if it has one
pub fn holiday_music(holiday: u8) -> Option<Category> {
match holiday {
crate::holiday::CHRISTMAS => Some(Category::Christmas),
crate::holiday::EASTER => Some(Category::Easter),
crate::holiday::HALLOWEEN => Some(Category::Halloween),
_ => None,
}
}
// the category actually played for `want` during a holiday: the title music always becomes the
// holiday's piece, the build music every fourth time (`build_picks`: build tracks picked so far)
pub fn holiday_category(want: Category, holiday: u8, build_picks: usize) -> Category {
match (want, holiday_music(holiday)) {
(Category::Title, Some(h)) => h,
(Category::Build, Some(h)) if build_picks % 4 == 3 => h,
_ => want,
}
}
pub fn track_id(t: usize) -> &'static str {
@@ -366,6 +390,47 @@ mod tests {
assert_eq!(track_title(track_by_id("build13").unwrap()), "Underground River");
}
#[test]
fn every_holiday_has_its_piece_for_the_title_and_now_and_then_the_build_music() {
use crate::holiday::{CHRISTMAS, EASTER, HALLOWEEN, NONE};
for (h, c, id, title) in [
(CHRISTMAS, Category::Christmas, "holiday_christmas", "Frostbound Carol"),
(EASTER, Category::Easter, "holiday_easter", "Eggs in the Moss"),
(HALLOWEEN, Category::Halloween, "holiday_halloween", "Crypt of Lanterns"),
] {
let t = track_by_id(id).unwrap();
assert_eq!(tracks_in(c), vec![t]);
assert_eq!(track_title(t), title);
assert!(player_tracks().contains(&t));
assert_eq!(holiday_category(Category::Title, h, 0), c);
assert_eq!(holiday_category(Category::Title, h, 5), c);
let builds: Vec<Category> = (0..8).map(|k| holiday_category(Category::Build, h, k)).collect();
assert_eq!(builds.iter().filter(|&&b| b == c).count(), 2, "now and then, not always");
assert!(builds.iter().all(|&b| b == c || b == Category::Build));
for other in [Category::Battle, Category::Surface, Category::Victory, Category::Torture] {
assert_eq!(holiday_category(other, h, 3), other);
}
// the piece plays: audible, finite and not clipping
let m = std::rc::Rc::new(xm::load(TRACKS[t].2).unwrap());
let mut p = Player::new(m, 22050.0);
let mut buf = vec![0.0f32; 2 * 22050];
let mut peak = 0.0f32;
for _ in 0..20 {
buf.iter_mut().for_each(|v| *v = 0.0);
p.render(&mut buf, MUSIC_GAIN);
for &v in &buf {
assert!(v.is_finite());
peak = peak.max(v.abs());
}
}
assert!(peak > 0.05 && peak < 1.0, "{} peak {}", id, peak);
}
for k in 0..8 {
assert_eq!(holiday_category(Category::Title, NONE, k), Category::Title);
assert_eq!(holiday_category(Category::Build, NONE, k), Category::Build);
}
}
#[test]
fn engine_crossfade_and_sfx() {
dc_audio_init(48000.0);
+10 -3
View File
@@ -492,11 +492,12 @@ fn apply_settings(s: &Settings, r3d: &mut R3d) {
struct MusicDirector {
next: std::collections::HashMap<u8, usize>, // category -> next index within the category
current: Option<audio::Category>,
build_picks: usize, // build tracks picked so far (during a holiday every fourth is its piece)
}
impl MusicDirector {
fn new() -> MusicDirector {
MusicDirector { next: std::collections::HashMap::new(), current: None }
MusicDirector { next: std::collections::HashMap::new(), current: None, build_picks: 0 }
}
fn pick(&mut self, c: audio::Category) -> usize {
@@ -573,11 +574,17 @@ impl MusicDirector {
}
let rotate = matches!(want, C::Build | C::Title | C::Surface) && self.current == Some(want) && audio::music_loops() >= 2;
// after the chosen track was let go, the director picks again
let foreign = chosen.is_none() && audio::current_track().map_or(false, |t| crate::tracks::TRACKS.get(t).map_or(false, |x| x.1 != want)) && calm && self.current == Some(want);
// (a holiday's piece belongs to the title and build music)
let holiday = holiday::current();
let fits = |c: C| c == want || (matches!(want, C::Title | C::Build) && audio::holiday_music(holiday) == Some(c));
let foreign = chosen.is_none() && audio::current_track().map_or(false, |t| crate::tracks::TRACKS.get(t).map_or(false, |x| !fits(x.1))) && calm && self.current == Some(want);
if self.current != Some(want) || rotate || foreign || audio::current_track().is_none() {
let before = self.current;
self.current = Some(want);
let t = self.pick(want);
let t = self.pick(audio::holiday_category(want, holiday, self.build_picks));
if want == C::Build {
self.build_picks += 1;
}
// between the dungeon and the surface the music changes slowly
let fade = if matches!(want, C::Victory | C::Defeat) {
1.0
+4 -1
View File
@@ -3,7 +3,7 @@
use crate::audio::Category;
pub const TRACKS: [(&str, Category, &[u8]); 47] = [
pub const TRACKS: [(&str, Category, &[u8]); 50] = [
("title", Category::Title, include_bytes!("../assets/music/title.xm")),
("title2", Category::Title, include_bytes!("../assets/music/title2.xm")),
("build1", Category::Build, include_bytes!("../assets/music/build1.xm")),
@@ -51,4 +51,7 @@ pub const TRACKS: [(&str, Category, &[u8]); 47] = [
("victory", Category::Victory, include_bytes!("../assets/music/victory.xm")),
("victory2", Category::Victory, include_bytes!("../assets/music/victory2.xm")),
("defeat", Category::Defeat, include_bytes!("../assets/music/defeat.xm")),
("holiday_christmas", Category::Christmas, include_bytes!("../assets/music/holiday_christmas.xm")),
("holiday_easter", Category::Easter, include_bytes!("../assets/music/holiday_easter.xm")),
("holiday_halloween", Category::Halloween, include_bytes!("../assets/music/holiday_halloween.xm")),
];
+2 -1
View File
@@ -1130,7 +1130,8 @@ mod tests {
fn render_all_songs() {
for (k, (name, _, data)) in crate::tracks::TRACKS.iter().enumerate() {
let m = Rc::new(load(data).expect("load"));
assert!(m.channels == 8 || (m.channels == 10 && name.starts_with("surface")), "{} {}", name, m.channels);
// birds and wind above ground and in the spring
assert!(m.channels == 8 || (m.channels == 10 && (name.starts_with("surface") || *name == "holiday_easter")), "{} {}", name, m.channels);
let mut p = Player::new(m, 48000.0);
let mut buf = vec![0.0f32; 48000 * 2];
let mut peak = 0.0f32;