The Music page is an extra, and lists every biome pool's tracks

The Music page becomes a Horizon extra like the others: off by default, a
switch on the Extras page (sound section), musicPage in horizon-extras.txt.
While it is off, Music & Sounds is exactly vanilla's page and no pool is
filtered; turning it on adds the Music... button and applies musicOff.

The page now covers every track the loaded sounds.json can play. After the
seven built-in situations, the tracks only a biome's pool names (the
overworld's deep dark, swamp, badlands and cave pieces, the Nether's) are
listed under Overworld biomes and Nether biomes: vanilla shares them between
several biomes, so no heading claims one. A track several pools share is
listed once, from the first pool that names it, so a row's "play now" always
has a pool that plays it and no track is left out.

That needs rows whose pool is not the group's, so MusicGroup holds
MusicTrack { event, id } and the page's headings are made at run time
(Row::Heading). track_groups is a pure function of the registry, tested with
a registry of base, biome, record and non-music events.

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
This commit is contained in:
JustKarkatandJustKarkat's AI agent committed 2026-11-02 13:30:10 -06:00
1 parent c6e49ac73c
commit 775981f9a8
6 files changed
+299 -87

No files matched your search

+25 -2
View File
@@ -142,6 +142,10 @@ pub struct Extras {
/// leave every pool they play in (`musicOff`, empty by default: vanilla's
/// random selection).
pub music_off: Vec<String>,
/// The Music page: the Music... button on Music & Sounds, playing a
/// built-in track and leaving the off ones out (`musicPage`, off by
/// default: vanilla plays exactly as before).
pub music_page: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -186,6 +190,7 @@ impl Default for Extras {
splitscreen_names: true,
wheel_cycles_settings: true,
music_off: Vec::new(),
music_page: false,
}
}
}
@@ -279,6 +284,7 @@ impl Extras {
.map(str::to_owned)
.collect()
}
"musicPage" => e.music_page = flag(e.music_page),
_ => {}
}
}
@@ -286,7 +292,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -332,6 +338,7 @@ impl Extras {
bool_text(self.splitscreen_names),
bool_text(self.wheel_cycles_settings),
self.music_off.join(","),
bool_text(self.music_page),
)
}
/// Binds the extra key `name` (`SORT_KEY`); false for
@@ -459,6 +466,8 @@ pub enum Item {
HideBlockParticles,
AutoReconnect,
UiSoundReserve,
/// The Music... page on Music & Sounds (`Music page`).
MusicPage,
MouseTweaks,
MouseTweaksWheel,
Sort,
@@ -501,6 +510,7 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(AutoReconnect),
Err("horizon.extras.header.sound"),
Ok(UiSoundReserve),
Ok(MusicPage),
Err("horizon.extras.header.menus"),
Ok(WheelCyclesSettings),
]
@@ -515,6 +525,7 @@ impl Item {
Item::HideBlockParticles => "horizon.extras.hide_block_particles",
Item::AutoReconnect => "horizon.extras.auto_reconnect",
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve",
Item::MusicPage => "horizon.extras.music_page",
Item::MouseTweaks => "horizon.extras.mouse_tweaks",
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel",
Item::Sort => "horizon.extras.sort",
@@ -538,6 +549,7 @@ impl Item {
Item::HideBlockParticles => "horizon.extras.hide_block_particles.tooltip",
Item::AutoReconnect => "horizon.extras.auto_reconnect.tooltip",
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve.tooltip",
Item::MusicPage => "horizon.extras.music_page.tooltip",
Item::MouseTweaks => "horizon.extras.mouse_tweaks.tooltip",
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel.tooltip",
Item::Sort => "horizon.extras.sort.tooltip",
@@ -570,6 +582,7 @@ impl Item {
Item::HideBlockParticles => "Hide block-break particles",
Item::AutoReconnect => "Auto-reconnect",
Item::UiSoundReserve => "Keep channels for UI sounds",
Item::MusicPage => "Music page",
Item::MouseTweaks => "Mouse Tweaks controls",
Item::MouseTweaksWheel => "Scroll wheel moves items",
Item::Sort => "Sort buttons",
@@ -594,7 +607,7 @@ impl Item {
ChatIndicator => ChatAnimation,
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings => {
| ChatAnimation | WheelCyclesSettings | MusicPage => {
return None;
}
})
@@ -618,6 +631,9 @@ impl Item {
Item::UiSoundReserve => {
"Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks."
}
Item::MusicPage => {
"Music & Sounds gets a Music... page: play any built-in track now, or turn one off so the game stops picking it. Nothing changes until you turn a track off."
}
Item::MouseTweaks => {
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
}
@@ -660,6 +676,7 @@ impl Item {
match self {
Item::Fullbright => Some(Cost::None),
Item::UiSoundReserve => Some(Cost::None),
Item::MusicPage => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::ChatAnimation => Some(Cost::None),
Item::OwnNameTag
@@ -681,6 +698,7 @@ impl Item {
Item::HideBlockParticles => e.hide_block_particles,
Item::AutoReconnect => e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve,
Item::MusicPage => e.music_page,
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
@@ -715,6 +733,7 @@ impl Item {
Item::HideBlockParticles => e.hide_block_particles = !e.hide_block_particles,
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve = !e.ui_sound_reserve,
Item::MusicPage => e.music_page = !e.music_page,
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
Item::Sort => e.sort = !e.sort,
@@ -749,6 +768,7 @@ mod tests {
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles);
assert!(!d.music_page && d.music_off.is_empty());
assert!(d.discord_presence && d.wheel_cycles_settings);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
@@ -796,6 +816,9 @@ mod tests {
"minecraft:music/game/calm1".into(),
"minecraft:music.menu".into(),
];
assert!(!e.music_page);
Item::MusicPage.cycle(&mut e, false);
assert_eq!(Item::MusicPage.switch(&e), Some(true));
assert_eq!(Extras::parse(&e.serialize()), e);
}
+104 -57
View File
@@ -1464,15 +1464,21 @@ pub enum Event {
/// A Music page row: play this track of this event now.
PlayMusicTrack(String, String),
}
/// One situation's built-in tracks, for the Music page.
/// A row of the Music page: the track, and the pool it is played from.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MusicTrack {
/// The music event the track belongs to (`minecraft:music.game`).
pub event: String,
/// The sound id (`minecraft:music/game/calm1`).
pub id: String,
}
/// One group of rows on the Music page: a situation, or a dimension's biomes.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MusicGroup {
/// The heading the page shows ("Overworld").
pub label: &'static str,
/// The music event the tracks belong to (`minecraft:music.game`).
pub event: String,
/// The sound ids, in `sounds.json` order.
pub tracks: Vec<String>,
/// The heading the page shows ("Overworld", "Nether biomes").
pub label: String,
/// The rows, in `sounds.json` order.
pub tracks: Vec<MusicTrack>,
}
/// `SliderableValueSet.applyValueImmediately`: these apply 600 ms after
/// the last change (`OptionInstanceSliderButton.delayedApplyAt`).
@@ -1814,6 +1820,8 @@ enum Row {
Big(Opt),
Pair(Opt, Option<Opt>),
Header(&'static str),
/// A heading the page makes at run time (the Music page's groups).
Heading(String),
/// A line of grey text (a path).
Note(String),
Buttons(Widget, Option<Widget>),
@@ -2520,17 +2528,20 @@ impl OptionsUi {
rows.extend(pairs(&[Opt::ShowSubtitles, Opt::DirectionalAudio]));
rows.extend(pairs(&[Opt::MusicFrequency, Opt::MusicToast]));
rows.push(Row::Big(Opt::SoundDevice));
// Horizon's own music page: the built-in tracks, by situation.
rows.push(Row::Buttons(
button(
0.0,
0.0,
310.0,
tr("horizon.pages.open.music"),
Act::Push(Page::Music),
),
None,
));
// Horizon's music page (an extra): the built-in tracks, by
// situation, once it is on in Horizon Extras.
if s.extras.music_page {
rows.push(Row::Buttons(
button(
0.0,
0.0,
310.0,
tr("horizon.pages.open.music"),
Act::Push(Page::Music),
),
None,
));
}
rows
}
Page::Music => {
@@ -2539,24 +2550,24 @@ impl OptionsUi {
}
let mut rows = Vec::new();
for group in &self.music_groups {
rows.push(Row::Header(group.label));
rows.push(Row::Heading(group.label.clone()));
for track in &group.tracks {
// Left: the track, played now. Right: whether its pool
// still picks it (vanilla until one is turned off).
let on = !s.extras.music_off.iter().any(|off| off == track);
let on = !s.extras.music_off.contains(&track.id);
let play = button(
0.0,
0.0,
150.0,
Text::literal(track_name(track).to_owned()),
Act::PlayMusicTrack(group.event.clone(), track.clone()),
Text::literal(track_name(&track.id).to_owned()),
Act::PlayMusicTrack(track.event.clone(), track.id.clone()),
);
let toggle = button(
0.0,
0.0,
150.0,
on_off(on),
Act::MusicTrackOn(track.clone(), on),
Act::MusicTrackOn(track.id.clone(), on),
);
rows.push(Row::Buttons(play, Some(toggle)));
}
@@ -2760,6 +2771,33 @@ impl OptionsUi {
}
}
}
/// A section heading of a list (`Row::Header`, `Row::Heading`), at `y`.
fn heading_row(&self, out: &mut Vec<Widget>, label: Text, y: &mut f32, w: f32, top: f32) {
let padding = if *y + self.scroll <= top + 2.0 {
0.0
} else {
18.0
};
let label = label.with_style(&Style {
bold: Some(true),
underlined: Some(true),
..Style::default()
});
out.push(Widget {
x: 0.0,
y: *y + 2.0 + padding + 4.0,
w,
h: 9.0,
kind: WKind::Text {
text: label,
color: u32::MAX,
centered: true,
},
list: false,
row: 0,
});
*y += padding + 18.0;
}
/// Build every widget of the current page.
fn layout(&mut self, s: &Settings, w: f32, h: f32) -> (Vec<Widget>, f32, f32, f32) {
let mut out = Vec::new();
@@ -3219,30 +3257,12 @@ impl OptionsUi {
rows.begin(&out, y);
match row {
Row::Header(key) => {
let padding = if y + self.scroll <= top + 2.0 {
0.0
} else {
18.0
};
let label = tr(key).with_style(&Style {
bold: Some(true),
underlined: Some(true),
..Style::default()
});
out.push(Widget {
x: 0.0,
y: y + 2.0 + padding + 4.0,
w,
h: 9.0,
kind: WKind::Text {
text: label,
color: u32::MAX,
centered: true,
},
list: false,
row: 0,
});
y += padding + 18.0;
let label = tr(key);
self.heading_row(&mut out, label, &mut y, w, top);
}
Row::Heading(text) => {
let label = Text::literal(text);
self.heading_row(&mut out, label, &mut y, w, top);
}
Row::Note(text) => {
out.push(Widget {
@@ -7582,16 +7602,26 @@ mod tests {
}
#[test]
fn music_and_sounds_opens_the_music_page() {
fn the_music_page_button_follows_the_extra() {
let atlas = atlas();
let size = (960.0, 540.0);
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Sound);
// The button is the last row: scroll the list to its end first.
let open = |ui: &mut OptionsUi, s: &Settings| {
let (widgets, ..) = ui.layout(s, size.0, size.1);
widgets.into_iter().find(
|w| matches!(&w.kind, WKind::Button { act, .. } if *act == Act::Push(Page::Music)),
)
};
// The extra is off by default: Music & Sounds is vanilla's page.
assert!(open(&mut ui, &s).is_none());
s.extras.music_page = true;
assert!(open(&mut ui, &s).is_some(), "Music... once the extra is on");
// It is the last row: scroll the list to its end before clicking it.
ui.scroll(&s, size, -1000.0);
let (widgets, ..) = ui.layout(&s, size.0, size.1);
let open = widgets
let open_button = widgets
.into_iter()
.find(
|w| matches!(&w.kind, WKind::Button { act, .. } if *act == Act::Push(Page::Music)),
@@ -7600,8 +7630,8 @@ mod tests {
let events = ui.click(
&mut s,
&atlas,
open.x + open.w / 2.0,
open.y + open.h / 2.0,
open_button.x + open_button.w / 2.0,
open_button.y + open_button.h / 2.0,
size,
[854, 480],
false,
@@ -7618,11 +7648,16 @@ mod tests {
let mut s = Settings::default();
let mut ui = OptionsUi {
music_groups: vec![MusicGroup {
label: "Overworld",
event: "minecraft:music.game".into(),
label: "Overworld".into(),
tracks: vec![
"minecraft:music/game/calm1".into(),
"minecraft:music/game/haggstrom".into(),
MusicTrack {
event: "minecraft:music.game".into(),
id: "minecraft:music/game/calm1".into(),
},
MusicTrack {
event: "minecraft:music.overworld.forest".into(),
id: "minecraft:music/game/bromeliad".into(),
},
],
}],
..OptionsUi::default()
@@ -7636,7 +7671,8 @@ mod tests {
.expect("button");
(w.x + w.w / 2.0, w.y + w.h / 2.0)
};
// The left of a row plays that track now.
// The left of a row plays that track now, from its own pool (a biome
// pool's track plays from that pool).
let calm1 = "minecraft:music/game/calm1";
let (x, y) = at(
&mut ui,
@@ -7648,6 +7684,17 @@ mod tests {
"minecraft:music.game".into(),
calm1.into()
)));
let bromeliad = "minecraft:music/game/bromeliad";
let (x, y) = at(
&mut ui,
&s,
&Act::PlayMusicTrack("minecraft:music.overworld.forest".into(), bromeliad.into()),
);
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert!(events.contains(&Event::PlayMusicTrack(
"minecraft:music.overworld.forest".into(),
bromeliad.into()
)));
// The right of a row turns it off, and the game saves that on `Changed`.
let (x, y) = at(&mut ui, &s, &Act::MusicTrackOn(calm1.into(), true));
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
@@ -235,6 +235,7 @@
"horizon.extras.hide_block_particles": "Hide block-break particles",
"horizon.extras.auto_reconnect": "Auto-reconnect",
"horizon.extras.ui_sound_reserve": "Keep channels for UI sounds",
"horizon.extras.music_page": "Music page",
"horizon.extras.mouse_tweaks": "Mouse Tweaks controls",
"horizon.extras.mouse_tweaks_wheel": "Scroll wheel moves items",
"horizon.extras.sort": "Sort buttons",
@@ -252,6 +253,7 @@
"horizon.extras.hide_block_particles.tooltip": "Hides the debris particles that burst from a broken block and crumble off the one you mine. Other particles stay.",
"horizon.extras.auto_reconnect.tooltip": "Rejoins a server after a lost connection or a restart, never after a kick or ban.",
"horizon.extras.ui_sound_reserve.tooltip": "Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks.",
"horizon.extras.music_page.tooltip": "Music & Sounds gets a Music... page: play any built-in track now, or turn one off so the game stops picking it. Nothing changes until you turn a track off.",
"horizon.extras.mouse_tweaks.tooltip": "Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Down sends one item of the slot to the other side, up brings one back.",
"horizon.extras.sort.tooltip": "Sorts the inventory, chests, barrels, ender chests and shulker boxes in creative tab order, merging stacks.",
+11 -16
View File
@@ -26,8 +26,14 @@ impl Game<'_> {
if frequency != self.music.frequency {
self.music.set_frequency(frequency, music.as_ref());
}
// The Music page's off tracks leave every pool they play in.
self.music.set_off(&self.settings.extras.music_off);
// The Music page's off tracks leave every pool they play in (the
// extra off: nothing is filtered, vanilla's pools play as they are).
let off = if self.settings.extras.music_page {
&self.settings.extras.music_off[..]
} else {
&[][..]
};
self.music.set_off(off);
// LevelLoadingScreen: no new track before the player is in the world.
let loading = self.title.is_none() && self.player.is_none() && self.win.is_none();
self.music
@@ -50,21 +56,10 @@ impl Game<'_> {
}
}
}
/// The Music page's groups: each built-in situation's tracks, from the
/// sound registry (`sounds.json`).
/// The Music page's groups: every track the loaded `sounds.json` can play,
/// by situation (see `music::track_groups`).
pub(super) fn music_groups(&self) -> Vec<MusicGroup> {
let registry = &self.audio.engine.registry;
Music::builtin()
.into_iter()
.filter_map(|(label, music)| {
let tracks = registry.tracks(&music.sound);
(!tracks.is_empty()).then_some(MusicGroup {
label,
event: music.sound,
tracks,
})
})
.collect()
horizon_client::music::track_groups(&self.audio.engine.registry)
}
/// A Music page row: play this track of `event` now.
pub(super) fn play_music_track(&mut self, event: &str, track: &str) {
+143 -3
View File
@@ -2,11 +2,16 @@
//! `Minecraft.getSituationalMusic`, with the `minecraft:audio/background_music`
//! and `minecraft:audio/music_volume` environment attributes of dimension
//! types and biomes (a biome value overrides its dimension's).
use crate::options_ui::{MusicGroup, MusicTrack};
use crate::sound_engine::{InstanceId, MUSIC, PlayResult, SoundEngine, SoundInstance, Sounds};
use crate::sound_events::Sentinel;
use crate::sound_events::{Sentinel, SoundRegistry};
use horizon_core::random::{LegacyRandom, RandomSource};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
use std::{
collections::{BTreeMap, HashMap},
fs,
path::Path,
};
/// `Music`: a sound event and the delay range (ticks) before it starts.
#[derive(Clone, Debug, PartialEq)]
@@ -78,7 +83,84 @@ impl Music {
))
}
}
/// `BackgroundMusic`: the attribute value.
/// The Music page's groups: each built-in situation's own tracks first, then
/// every other `music.` event's tracks that no earlier group listed, under
/// its dimension's biomes. A track several pools share is listed once, from
/// the first pool that names it, so "play now" always has a pool that plays
/// it and no track is left without a row.
pub fn track_groups(registry: &SoundRegistry) -> Vec<MusicGroup> {
let mut groups: Vec<MusicGroup> = Vec::new();
let mut listed: Vec<String> = Vec::new();
let builtin = Music::builtin();
for (label, music) in &builtin {
let tracks = registry.tracks(&music.sound);
if tracks.is_empty() {
continue;
}
listed.extend(tracks.iter().cloned());
groups.push(MusicGroup {
label: (*label).to_owned(),
tracks: tracks
.into_iter()
.map(|id| MusicTrack {
event: music.sound.clone(),
id,
})
.collect(),
});
}
// `music_disc.*` (records) do not start with "music.".
let mut events: Vec<&String> = registry
.names()
.filter(|event| event.starts_with("minecraft:music."))
.collect();
events.sort();
// Keyed by the heading's rank, so the dimensions keep their order however
// the event ids sort.
let mut rest: BTreeMap<u8, (&'static str, Vec<MusicTrack>)> = BTreeMap::new();
for event in events {
if builtin.iter().any(|(_, music)| music.sound == *event) {
continue;
}
let tracks: Vec<MusicTrack> = registry
.tracks(event)
.into_iter()
.filter(|id| !listed.contains(id))
.map(|id| MusicTrack {
event: event.clone(),
id,
})
.collect();
if tracks.is_empty() {
continue;
}
listed.extend(tracks.iter().map(|track| track.id.clone()));
let (rank, label) = biome_group(event);
rest.entry(rank)
.or_insert_with(|| (label, Vec::new()))
.1
.extend(tracks);
}
groups.extend(rest.into_values().map(|(label, tracks)| MusicGroup {
label: label.to_owned(),
tracks,
}));
groups
}
/// The heading a biome-only pool's tracks go under: vanilla plays these
/// tracks in several biomes, so no heading names a single one.
fn biome_group(event: &str) -> (u8, &'static str) {
let path = event.strip_prefix("minecraft:").unwrap_or(event);
if path.starts_with("music.overworld.") {
(1, "Overworld biomes")
} else if path.starts_with("music.nether.") {
(2, "Nether biomes")
} else if path.starts_with("music.end.") {
(3, "End biomes")
} else {
(4, "Other music")
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BackgroundMusic {
pub default: Option<Music>,
@@ -800,6 +882,64 @@ mod tests {
assert_eq!(bg.select(false, false).unwrap().sound, "a");
}
#[test]
fn the_music_page_lists_every_track_once_from_a_pool_that_plays_it() {
let mut registry = SoundRegistry::default();
registry.add_file(
"minecraft",
br#"{
"music.game": {"sounds": ["music/game/shared", "music/game/second"]},
"music.menu": {"sounds": ["music/menu/one"]},
"music.overworld.forest": {"sounds": ["music/game/shared", "music/game/bromeliad"]},
"music.overworld.swamp": {"sounds": ["music/game/bromeliad", "music/game/aerie"]},
"music.nether.warped_forest": {"sounds": ["music/game/rubedo"]},
"music_disc.cat": {"sounds": ["records/cat"]},
"note": {"sounds": ["block/note/harp"]}
}"#,
&|_, _| true,
);
let groups = track_groups(&registry);
let summary: Vec<(&str, Vec<&str>)> = groups
.iter()
.map(|group| {
(
group.label.as_str(),
group
.tracks
.iter()
.map(|track| track.id.rsplit('/').next().unwrap())
.collect(),
)
})
.collect();
assert_eq!(
summary,
[
("Menu", vec!["one"]),
("Overworld", vec!["shared", "second"]),
// A biome pool's own track, from the pool that plays it.
("Overworld biomes", vec!["bromeliad", "aerie"]),
("Nether biomes", vec!["rubedo"]),
]
);
// Each row plays from a pool that has the track; no track repeats.
let mut seen = Vec::new();
for group in &groups {
for track in &group.tracks {
assert!(!seen.contains(&track.id), "{} listed twice", track.id);
seen.push(track.id.clone());
assert!(
registry.tracks(&track.event).contains(&track.id),
"{} does not play {}",
track.event,
track.id
);
}
}
// Records and other events are not music.
assert!(seen.iter().all(|id| !id.contains("records/")));
}
/// The overworld pool, two tracks, and a menu track.
fn two_tracks() -> SoundEngine {
let mut registry = SoundRegistry::default();
+14 -9
View File
@@ -1,7 +1,7 @@
# Horizon extras
Optional conveniences built into the client: fullbright, dynamic lights, your own name tag, hiding block-break particles, auto-reconnect,
Mouse Tweaks-style inventory controls, inventory sorting and a smooth chat. **Every one is off by default**: a fresh
Mouse Tweaks-style inventory controls, inventory sorting, a smooth chat and a music page. **Every one is off by default**: a fresh
install looks and behaves exactly like vanilla 26.3, and turning an extra off
again restores vanilla's behaviour completely.
@@ -18,6 +18,7 @@ again restores vanilla's behaviour completely.
| Game glue (input hooks, per-frame update) | `crates/horizon-client/src/game/extras.rs` |
| Mouse Tweaks-style controls, sort buttons | `crates/horizon-client/src/game/tweaks.rs` |
| Sort order and its clicks | `crates/horizon-client/src/inv_sort.rs` (pure, unit tested) |
| The music page's rows, and the pools it filters | `options_ui.rs` (`Page::Music`), `crates/horizon-client/src/music.rs` (`track_groups`, `MusicManager::set_off`), `sound_events.rs` (`sound_except`) |
| The settings page | `options_ui.rs` (`Page::Extras`): **Options → Horizon Settings... → Extras...** |
The settings are grouped by extra, under a header each, two buttons to a row as
@@ -220,20 +221,24 @@ say **Leave** for disconnecting. `HORIZON_SPLIT_STATS=1` logs every 5 s where a
goes (each player's turn and render, the presents, the GPU wait) and each player's frame rate.
Joining P1 on an external server follows separately.
### Music page (`musicOff`, none off by default)
- Options > Music & Sounds > **Music...** lists the game's built-in tracks by
situation (menu, overworld, creative, underwater, the End, boss, credits),
read from the loaded `sounds.json` (`SoundRegistry::tracks`).
- **Left click** a track plays it now: it takes the music manager's slot, and the
situation's own music waits until it ends.
### Music page (`musicPage`, default off; `musicOff`, none off by default)
- An extra like the rest: off, Music & Sounds is exactly vanilla's page and every
pool plays as vanilla picks. On, it gains a **Music...** button.
- The page lists every track the loaded `sounds.json` can play
(`SoundRegistry::tracks`): the built-in situations' own tracks first (menu,
overworld, creative, underwater, the End, boss, credits), then the tracks only a
biome's pool names, under **Overworld biomes** and **Nether biomes** (vanilla
shares those between several biomes, so no heading claims one). A track a few
pools share is listed once, from the first pool that names it, so every row has
a pool its "play now" can use.
- **Left click** a track plays it now: it takes the music manager's slot (the track
already playing stops), and the situation's own music waits until it ends.
- **On/Off** at the right of a row takes a track out of every pool it plays in, so
a situation with all its tracks off plays nothing. Every track is on by default,
so vanilla's random selection is unchanged; the off ones are `musicOff` in
`horizon-extras.txt`.
- The pools are filtered where they are picked (`sound_except` in
`sound_events.rs`), the manager is `music.rs`, and the page is in `options_ui.rs`.
- Tracks only a single biome's pool names (the mangrove or deep-dark pieces) are
not rows of their own yet.
## Shared rules