Compare commits

..
2 Commits
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 d9351ec054 Menus: Horizon-style options screen, pause polish, screenshots
Options open as the launcher's Settings page: a sidebar with the sliding
selection (Video, Audio, Controls, Accessibility, Language, Chat, Interface)
and rows with a switch, slider or value button per option
(options_horizon.rs; new sections or rows slot into SECTIONS). Key binds,
the language list and resource packs open vanilla's pages. Interface holds
Menu style (Horizon / Classic). The pause menu hides the in-game HUD over the
blurred world. Scripted and frame-limited runs default to Classic, so the
harnesses see vanilla's screens.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:33:15 +02:00
Ludovic MarieandClaude Opus 5.5 eb18bb42af Menus: Horizon style for the title, worlds, create, multiplayer and pause screens
One theme for the launcher and the game: crates/horizon-theme holds the
palette, type scale, spacing, radii, control sizes, motion and the bundled
fonts; launcher/src/theme.rs and the game's ui_theme both read it. ui_theme
ports the launcher's components (button with hover layer and press state,
sidebar list with the sliding selection, segmented control, switch, rows)
and draws Geist and Instrument Serif through the HUD atlas.

The title is laid out like the launcher (sidebar, page header, rows) over a
live backdrop: Horizon generates an ice-spikes landscape for a fixed seed,
orbits a camera over it with falling snow, then blurs and cold-grades the
frame (post.rs). The launcher's opening (wordmark, then the interface) plays
once per session and a click or key skips it. Menu style Horizon / Classic
lives in horizon.txt, never options.txt; Classic keeps vanilla's drawing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:19:14 +02:00
48 changed files with 3786 additions and 694 deletions

No files matched your search

-8
View File
@@ -27,12 +27,7 @@ jobs:
- os: windows-latest
asset: horizon-launcher-windows-x64.zip
binary: horizon-launcher.exe
# The C runtime linked in: without it the launcher asks for VCRUNTIME140.dll,
# which only the Visual C++ redistributable installs.
rustflags: -C target-feature=+crt-static
runs-on: ${{ matrix.os }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
defaults:
run:
working-directory: launcher
@@ -53,9 +48,6 @@ jobs:
run: grep -qx "version = \"${GITHUB_REF_NAME#launcher-v}\"" Cargo.toml
- run: cargo test --release --locked --lib
- run: cargo build --release --locked
- name: Only system libraries
if: runner.os == 'Windows'
run: python ../tools/check_imports.py target/release/${{ matrix.binary }}
- name: Package
run: |
mkdir -p dist
+2 -26
View File
@@ -1,9 +1,7 @@
name: release builds
# Started by hand: gh workflow run release.yml --ref <branch>
# Builds the Windows x64 and Linux x86_64 clients and uploads the playable folders as artifacts.
# macOS builds are made on the development machines. Linux is built here, on an older Ubuntu,
# so the binary uses the standard loader and runs on any recent distribution (and on NixOS
# through nix-ld): a build made on NixOS asks for a /nix/store loader no other system has.
# Builds the Windows x64 client and uploads the playable folder as an artifact.
# Linux and macOS builds are made on the development machines.
on:
workflow_dispatch:
permissions:
@@ -20,10 +18,6 @@ jobs:
env:
RUSTFLAGS: -C target-feature=+crt-static
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
- name: Only system DLLs
# Vulkan and the rest are loaded at run time; a linked non-system DLL is a
# "DLL not found" dialog on some players' machines.
run: python tools/check_imports.py target/release/horizon-client.exe
- name: Package
shell: bash
run: packaging/package.sh target/release/horizon-client.exe dist/horizon-windows-x64
@@ -31,21 +25,3 @@ jobs:
with:
name: horizon-windows-x64
path: dist/horizon-windows-x64
linux:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
run: rustup toolchain install stable --profile minimal
- name: ALSA headers
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev
- name: Build the client
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
- name: Standard loader and libraries only
run: python3 tools/check_imports.py target/release/horizon-client
- name: Package
run: packaging/package.sh target/release/horizon-client dist/horizon-linux-x86_64
- uses: actions/upload-artifact@v4
with:
name: horizon-linux-x86_64
path: dist/horizon-linux-x86_64
Generated
+6
View File
@@ -681,12 +681,14 @@ dependencies = [
name = "horizon-client"
version = "0.1.0"
dependencies = [
"ab_glyph",
"bytemuck",
"cpal",
"glam",
"horizon-assets",
"horizon-core",
"horizon-protocol",
"horizon-theme",
"horizon-world",
"miniz_oxide 0.8.9",
"png",
@@ -713,6 +715,10 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-theme"
version = "0.1.0"
[[package]]
name = "horizon-world"
version = "0.1.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher"]
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol"]
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme"]
resolver = "2"
[workspace.package]
+2
View File
@@ -22,6 +22,8 @@ serde_json = { version = "1.0.151", features = ["raw_value"] }
cpal = "0.16"
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
horizon-theme = { path = "../horizon-theme" }
[features]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
+58 -262
View File
@@ -61,78 +61,10 @@ pub struct Heard {
pub struct MixerBackend {
mixer: Arc<Mutex<Mixer>>,
root: PathBuf,
cache: Arc<Mutex<SoundCache>>,
/// The decoder thread for static sounds, started with the first one.
decoder: Option<std::sync::mpsc::Sender<Decode>>,
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
next: ChannelId,
live: bool,
}
/// Decoded samples and their rate (`None`: the file did not load).
type Samples = Option<(Arc<Vec<f32>>, u32)>;
/// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least
/// recently used first past `CACHE_BYTES`: kept forever, every sound heard in
/// a session stayed (up to 1.4 GiB of samples, villages playing hundreds).
#[derive(Default)]
struct SoundCache {
entries: HashMap<String, (Samples, u64)>,
bytes: usize,
clock: u64,
}
const CACHE_BYTES: usize = 64 << 20;
impl SoundCache {
fn get(&mut self, key: &str) -> Option<Samples> {
self.clock += 1;
let entry = self.entries.get_mut(key)?;
entry.1 = self.clock;
Some(entry.0.clone())
}
fn insert(&mut self, key: String, samples: Samples) {
self.clock += 1;
self.bytes += Self::size(&samples);
if let Some((old, _)) = self.entries.insert(key, (samples, self.clock)) {
self.bytes -= Self::size(&old);
}
while self.bytes > CACHE_BYTES && self.entries.len() > 1 {
let Some(oldest) = self
.entries
.iter()
.min_by_key(|(_, (_, used))| *used)
.map(|(k, _)| k.clone())
else {
break;
};
if let Some((gone, _)) = self.entries.remove(&oldest) {
self.bytes -= Self::size(&gone);
}
}
}
fn size(samples: &Samples) -> usize {
samples
.as_ref()
.map_or(0, |(s, _)| s.len() * std::mem::size_of::<f32>())
}
}
/// A static sound to decode for a channel waiting on it.
struct Decode {
key: String,
file: PathBuf,
channel: ChannelId,
}
/// Give a channel waiting on its sound the decoded samples and start it, as
/// the buffer future attaching and playing the source. A source stopped or
/// released meanwhile stays so.
fn attach(mixer: &Mutex<Mixer>, channel: ChannelId, samples: Samples, looping: bool) {
if let Some(samples) = samples
&& let Ok(mut m) = mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
&& c.state == State::Initial
{
c.samples = Some(samples);
c.position = 0.0;
c.state = State::Playing;
c.looping |= looping;
}
}
pub struct Audio {
_stream: Option<cpal::Stream>,
pub engine: SoundEngine,
@@ -244,59 +176,53 @@ impl Backend for MixerBackend {
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
self.with(channel, |c| c.relative = relative);
}
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
/// `getStream` (decoded on a thread of its own), then attach and
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
/// as a failed buffer future does in vanilla.
/// `getCompleteBuffer` (decoded now, cached) or `getStream` (decoded on a
/// worker), then attach and `alSourcePlay`. A sound that cannot load
/// leaves its source initial, as a failed buffer future does in vanilla.
fn start(&mut self, channel: ChannelId, sound: &Sound, looping: bool) {
if !self.live {
return;
}
let (namespace, path) = sound.path();
let file = self.root.join("assets").join(namespace).join(&path);
let attach = move |mixer: &Mutex<Mixer>, samples: Option<(Arc<Vec<f32>>, u32)>| {
if let Some(samples) = samples
&& let Ok(mut m) = mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
c.samples = Some(samples);
c.position = 0.0;
c.state = State::Playing;
}
};
if sound.stream {
// A streamed sound loops by restarting its stream.
let mixer = self.mixer.clone();
std::thread::spawn(move || {
let samples = decode(&file).map(|(s, r)| (Arc::new(s), r));
attach(&mixer, channel, samples, looping);
if looping
&& let Ok(mut m) = mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
c.looping = true;
}
attach(&mixer, samples);
});
return;
} else {
let samples = self
.cache
.entry(format!("{namespace}:{path}"))
.or_insert_with(|| decode(&file).map(|(s, r)| (Arc::new(s), r)))
.clone();
attach(&self.mixer, samples);
}
let key = format!("{namespace}:{path}");
let cached = self.cache.lock().ok().and_then(|mut c| c.get(&key));
if let Some(samples) = cached {
attach(&self.mixer, channel, samples, false);
return;
}
let decoder = self.decoder.get_or_insert_with(|| {
let (send, receive) = std::sync::mpsc::channel::<Decode>();
let (mixer, cache) = (self.mixer.clone(), self.cache.clone());
std::thread::Builder::new()
.name("sound decoder".into())
.spawn(move || {
for job in receive {
// Queued twice before the first decode finished.
let cached = cache.lock().ok().and_then(|mut c| c.get(&job.key));
let samples = cached.unwrap_or_else(|| {
let samples = decode(&job.file).map(|(s, r)| (Arc::new(s), r));
if let Ok(mut c) = cache.lock() {
c.insert(job.key, samples.clone());
}
samples
});
attach(&mixer, job.channel, samples, false);
}
})
.expect("sound decoder thread");
send
});
let _ = decoder.send(Decode { key, file, channel });
}
/// `alSourceStop`: any state becomes stopped, an initial source too (a
/// sound still decoding then never starts).
fn stop(&mut self, channel: ChannelId) {
self.with(channel, |c| c.state = State::Stopped);
self.with(channel, |c| {
if c.state != State::Initial {
c.state = State::Stopped;
}
});
}
fn pause(&mut self, channel: ChannelId) {
self.with(channel, |c| {
@@ -401,71 +327,6 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
*s = s.clamp(-1.0, 1.0);
}
}
/// Why the game should not open an output, if it should not:
/// `HORIZON_NO_AUDIO`, or a virtual screen (`vscreen`), where agents and
/// harnesses run the game and must stay off the user's speakers.
/// `HORIZON_AUDIO=1` opens the output anyway (testing into a null sink).
fn output_disabled(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<String> {
if var("HORIZON_NO_AUDIO").is_some() {
return Some("audio off: HORIZON_NO_AUDIO is set".into());
}
if var("HORIZON_AUDIO").is_some_and(|v| v == "1") {
return None;
}
let display = var("WAYLAND_DISPLAY")?;
Path::new(&display)
.components()
.any(|c| c.as_os_str() == "vscreen")
.then(|| "audio off on a virtual screen (vscreen); HORIZON_AUDIO=1 opens it".into())
}
/// An output stream on `device` in its default configuration, whatever its
/// sample format: the mixer works in f32 and converts.
fn open_device(
device: &cpal::Device,
mixer: Arc<Mutex<Mixer>>,
) -> Result<(cpal::Stream, cpal::SupportedStreamConfig)> {
use cpal::SampleFormat as F;
let config = device.default_output_config()?;
let stream = match config.sample_format() {
F::F32 => build_stream::<f32>(device, &config, mixer),
F::F64 => build_stream::<f64>(device, &config, mixer),
F::I16 => build_stream::<i16>(device, &config, mixer),
F::I32 => build_stream::<i32>(device, &config, mixer),
F::I8 => build_stream::<i8>(device, &config, mixer),
F::U16 => build_stream::<u16>(device, &config, mixer),
F::U32 => build_stream::<u32>(device, &config, mixer),
F::U8 => build_stream::<u8>(device, &config, mixer),
other => Err(format!("unsupported sample format {other}").into()),
}?;
stream.play()?;
Ok((stream, config))
}
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
device: &cpal::Device,
config: &cpal::SupportedStreamConfig,
mixer: Arc<Mutex<Mixer>>,
) -> Result<cpal::Stream> {
let rate = config.sample_rate().0;
let channels = config.channels() as usize;
let mut scratch = Vec::new();
let stream = device.build_output_stream(
&config.config(),
move |data: &mut [T], _| {
scratch.resize(data.len(), 0.0f32);
if let Ok(mut m) = mixer.lock() {
mix(&mut m, &mut scratch, channels, rate);
} else {
scratch.fill(0.0);
}
for (out, s) in data.iter_mut().zip(&scratch) {
*out = T::from_sample(*s);
}
},
|e| eprintln!("Horizon: audio stream error: {e}"),
None,
)?;
Ok(stream)
}
impl Audio {
pub fn new(root: &Path) -> Self {
Self::with_output(root, true)
@@ -486,7 +347,7 @@ impl Audio {
Ok(v) => (Some(v), true),
Err(e) => {
if output {
eprintln!("Horizon: no sound: {e}");
eprintln!("Horizon: audio unavailable: {e}");
}
(None, false)
}
@@ -499,8 +360,7 @@ impl Audio {
backend: MixerBackend {
mixer,
root: root.to_owned(),
cache: Arc::default(),
decoder: None,
cache: HashMap::new(),
next: 0,
live,
},
@@ -514,40 +374,32 @@ impl Audio {
}
}
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<cpal::Stream> {
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
return Err(reason.into());
// Test runs (harnesses, agents) launch the game on a virtual screen;
// keep them off the user's speakers.
if std::env::var_os("HORIZON_NO_AUDIO").is_some() {
return Err("audio output disabled by HORIZON_NO_AUDIO".into());
}
let host = cpal::default_host();
let mut errors = Vec::new();
// The default device first, then any other output a host lists:
// a default that cannot open (busy, or a stale ALSA default) should
// not leave the game silent when another device works.
let default = host.default_output_device();
let others = host.output_devices().into_iter().flatten();
let mut tried = Vec::new();
for device in default.into_iter().chain(others) {
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
if tried.contains(&name) {
continue;
}
tried.push(name.clone());
match open_device(&device, mixer.clone()) {
Ok((stream, config)) => {
eprintln!(
"Horizon: audio output {name} ({} Hz, {} channels, {})",
config.sample_rate().0,
config.channels(),
config.sample_format()
);
return Ok(stream);
let device = host
.default_output_device()
.ok_or("no audio output device")?;
let config = device.default_output_config()?;
let rate = config.sample_rate().0;
let channels = config.channels() as usize;
let stream = device.build_output_stream(
&config.into(),
move |data: &mut [f32], _| {
if let Ok(mut m) = mixer.lock() {
mix(&mut m, data, channels, rate);
} else {
data.fill(0.0);
}
Err(e) => errors.push(format!("{name}: {e}")),
}
}
if errors.is_empty() {
return Err(format!("no audio output device on the {} host", host.id().name()).into());
}
Err(format!("no audio output device opened ({})", errors.join("; ")).into())
},
|e| eprintln!("Horizon: audio stream error: {e}"),
None,
)?;
stream.play()?;
Ok(stream)
}
fn bound(&mut self) -> Bound<'_, MixerBackend> {
Bound {
@@ -673,21 +525,6 @@ impl Audio {
self.release(id);
}
/// Every sound event id (for command completion).
/// The engine's table sizes and the mixer's channels (`status`).
pub fn census(&self) -> serde_json::Value {
let mut census = self.engine.census();
census["channels"] = self
.backend
.mixer
.lock()
.map_or(0, |m| m.channels.len())
.into();
if let Ok(cache) = self.backend.cache.lock() {
census["decoded"] = cache.entries.len().into();
census["decoded_mib"] = (cache.bytes >> 20).into();
}
census
}
pub fn event_names(&self) -> Vec<String> {
let mut v: Vec<String> = self.engine.registry.names().cloned().collect();
v.sort();
@@ -753,47 +590,6 @@ mod tests {
}
}
#[test]
fn decoded_sounds_stay_within_the_budget_least_recently_used_out() {
let mut cache = SoundCache::default();
let quarter = CACHE_BYTES / 4 / std::mem::size_of::<f32>();
let sound = || Some((Arc::new(vec![0.0f32; quarter]), 44100));
for key in ["a", "b", "c", "d"] {
cache.insert(key.into(), sound());
}
assert!(cache.get("a").is_some());
cache.insert("e".into(), sound());
assert!(cache.bytes <= CACHE_BYTES);
assert!(
cache.get("b").is_none(),
"the least recently used goes first"
);
assert!(cache.get("a").is_some() && cache.get("e").is_some());
// A failed load is remembered without counting.
cache.insert("missing".into(), None);
assert_eq!(cache.get("missing"), Some(None));
}
#[test]
fn output_stays_off_on_a_virtual_screen_unless_asked() {
let env = |pairs: &'static [(&'static str, &'static str)]| {
move |name: &str| {
pairs
.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| std::ffi::OsString::from(*v))
}
};
assert!(output_disabled(env(&[("WAYLAND_DISPLAY", "wayland-1")])).is_none());
assert!(output_disabled(env(&[])).is_none());
const VSCREEN: &str = "/run/user/1000/vscreen/main/wayland-1";
assert!(output_disabled(env(&[("WAYLAND_DISPLAY", VSCREEN)])).is_some());
assert!(
output_disabled(env(&[("WAYLAND_DISPLAY", VSCREEN), ("HORIZON_AUDIO", "1")])).is_none()
);
assert!(
output_disabled(env(&[("HORIZON_NO_AUDIO", "1"), ("HORIZON_AUDIO", "1")])).is_some()
);
}
#[test]
fn linear_attenuation_reaches_zero_at_the_max_distance() {
assert_eq!(distance_gain(None, 100.0), 1.0);
+987
View File
@@ -0,0 +1,987 @@
//! The front-end screens in Horizon's menu style (`ui_theme`), laid out like
//! the launcher: a 200-wide sidebar with the sliding selection (Home,
//! Singleplayer, Multiplayer, Options), content pages with a page header,
//! settings rows and 1px dividers, and the launcher's opening (the wordmark,
//! then the interface). They drive the same state as the classic layouts in
//! `title` and `multiplayer`; only drawing and hit testing differ. The pause
//! menu's Horizon layout is `pause`, below.
use crate::{
hud::{HudAtlas, HudBatch},
multiplayer::{Outcome, Ping, Screen},
title::{Action, Mode, Title, list_worlds},
ui_theme::{
self as t, BODY, CONTROL, CONTROL_LARGE, Face, Kind, SIDEBAR, SMALL, Ui, color, id,
},
};
use std::time::Instant;
const NAV: u64 = id("title.nav", 0);
const QUIT: u64 = id("title.quit", 0);
const PLAY: u64 = id("home.play", 0);
const MULTIPLAYER: u64 = id("home.multiplayer", 0);
const RESUME: u64 = id("home.resume", 0);
const BACK: u64 = id("front.back", 0);
const PLAY_WORLD: u64 = id("worlds.play", 0);
const CREATE: u64 = id("worlds.create", 0);
const WORLD: u64 = id("worlds.row", 0);
const NAME: u64 = id("create.name", 0);
const SEED: u64 = id("create.seed", 0);
const MODE: u64 = id("create.mode", 0);
const MAKE: u64 = id("create.make", 0);
const SERVER: u64 = id("mp.row", 0);
const JOIN: u64 = id("mp.join", 0);
const DIRECT: u64 = id("mp.direct", 0);
const ADD: u64 = id("mp.add", 0);
const EDIT: u64 = id("mp.edit", 0);
const DELETE: u64 = id("mp.delete", 0);
const REFRESH: u64 = id("mp.refresh", 0);
const FIELD_A: u64 = id("mp.field", 0);
const FIELD_B: u64 = id("mp.field", 1);
const DONE: u64 = id("mp.done", 0);
/// The launcher's opening (`launcher/src/main.rs`): the wordmark holds over
/// the backdrop, then the interface comes in. A click or a key skips ahead.
#[derive(Clone, Copy, Debug)]
pub struct Opening {
started: Instant,
reveal: Option<Instant>,
}
impl Opening {
pub fn new() -> Self {
Self {
started: Instant::now(),
reveal: None,
}
}
/// The reveal's progress, 0 to 1; `ready` says the backdrop is showing.
fn phase(&mut self, ready: bool) -> f32 {
let held = self.started.elapsed();
if self.reveal.is_none()
&& ((held >= horizon_theme::OPENING_HOLD && ready)
|| held >= horizon_theme::OPENING_MAX)
{
self.reveal = Some(Instant::now());
}
self.reveal.map_or(0.0, |at| {
at.elapsed().as_secs_f32() / horizon_theme::OPENING_REVEAL.as_secs_f32()
})
}
pub fn skip(&mut self) {
self.reveal.get_or_insert_with(Instant::now);
}
}
impl Default for Opening {
fn default() -> Self {
Self::new()
}
}
/// Which sidebar item a mode belongs to.
fn page(mode: &Mode) -> usize {
match mode {
Mode::Main => 0,
Mode::Select { .. } | Mode::Create { .. } => 1,
Mode::Multiplayer(_) => 2,
}
}
const PAGES: [&str; 4] = ["Home", "Singleplayer", "Multiplayer", "Options"];
impl Title {
/// Horizon's layout for every title mode. `backdrop` is how much of the
/// live backdrop is visible (0 shows the plain cold background).
pub fn draw_horizon(&self, ui: &mut Ui, backdrop: f32) {
let reveal = self.opening_phase(backdrop >= 1.0);
if backdrop < 1.0 {
ui.cold_background(1.0 - backdrop);
}
let shade = t::ease_out_cubic(reveal);
let veil = if matches!(self.mode, Mode::Main) {
0.42
} else {
0.66
};
ui.atmosphere(veil * shade, shade);
let side = t::ease_out_cubic((reveal - 0.2) / 0.7);
let main = t::ease_out_cubic((reveal - 0.35) / 0.65);
// The sidebar slides in from the left.
ui.opacity = side;
ui.offset = (-16.0 * (1.0 - side), 0.0);
if side > 0.0 {
self.sidebar(ui);
}
// The content rises; each new page fades in and rises 8 over MOVE.
let entered = self.entered();
let p = t::ease_out_quint(entered.elapsed().as_secs_f32() / t::move_());
ui.opacity = main * p;
ui.offset = (0.0, 16.0 * (1.0 - main) + 8.0 * (1.0 - p));
if main > 0.0 {
let x = SIDEBAR + 48.0;
let w = (ui.w - x - 48.0).min(760.0);
match &self.mode {
Mode::Main => self.home(ui, x, w),
Mode::Select { worlds, selected } => worlds_page(ui, x, w, worlds, *selected),
Mode::Create {
name,
seed,
creative,
focus,
} => {
let caret = (self.started.elapsed().as_millis() / 500).is_multiple_of(2);
create_page(ui, x, w, name, seed, *creative, *focus, caret);
}
Mode::Multiplayer(screen) => {
let caret = (self.started.elapsed().as_millis() / 500).is_multiple_of(2);
multiplayer_page(ui, x, w, screen, caret);
}
}
}
ui.opacity = 1.0;
ui.offset = (0.0, 0.0);
if reveal < 1.0 {
// The wordmark rises and fades as the interface comes in.
let word = 1.0 - t::ease_out_cubic(reveal / 0.6);
ui.opacity = word;
let tw = ui.measure(Face::Display, t::WORDMARK, "Horizon");
let line = (t::WORDMARK * 1.2).round();
ui.text_line(
Face::Display,
t::WORDMARK,
line,
((ui.w - tw) / 2.0).round(),
(ui.h - line) / 2.0 - 28.0 * (1.0 - word),
color::WHITE,
"Horizon",
);
ui.opacity = 1.0;
}
}
fn opening_phase(&self, ready: bool) -> f32 {
let mut opening = self.opening.get();
let phase = opening.as_mut().map_or(1.0, |o| o.phase(ready));
self.opening.set(opening.filter(|_| phase < 1.0));
phase.min(1.0)
}
/// A click or key during the opening jumps to the reveal; true if it did.
pub fn skip_opening(&self) -> bool {
let mut opening = self.opening.get();
let skipped = opening.as_mut().is_some_and(|o| {
let holding = o.reveal.is_none();
o.skip();
holding
});
self.opening.set(opening);
skipped
}
fn sidebar(&self, ui: &mut Ui) {
let h = ui.h;
ui.fill(0.0, 0.0, SIDEBAR, h, color::with_alpha(color::SIDEBAR, 0.8));
ui.vline(SIDEBAR - 1.0, 0.0, h, color::BORDER);
ui.text_line(
Face::Display,
22.0,
30.0,
22.0,
16.0,
color::TEXT,
"Horizon",
);
ui.sidebar_list(NAV, 12.0, 66.0, SIDEBAR - 24.0, &PAGES, page(&self.mode));
let qy = h - 12.0 - 18.0 - 12.0 - CONTROL;
let hover = ui.interact(QUIT, 12.0, qy, SIDEBAR - 24.0, CONTROL, true);
ui.rounded(
12.0,
qy,
SIDEBAR - 24.0,
CONTROL,
t::RADIUS,
color::with_alpha(color::HOVER, 0.7 * hover),
);
ui.text_line(
Face::Regular,
BODY,
CONTROL,
22.0,
qy,
color::mix(color::MUTED, color::TEXT, hover),
"Quit game",
);
ui.text(
Face::Regular,
SMALL,
22.0,
h - 12.0 - 18.0,
color::FAINT,
"Horizon 26.3",
);
}
/// Home: the launcher's Play page over the backdrop.
fn home(&self, ui: &mut Ui, x: f32, w: f32) {
let mut y = 36.0 + 28.0;
ui.text(
Face::Medium,
SMALL,
x,
y,
color::FAINT,
"Minecraft 26.3 · Java Edition, rewritten in Rust",
);
y += 22.0;
ui.text_line(Face::Display, t::HERO, 58.0, x, y, color::TEXT, "Horizon");
y += 58.0 + 12.0;
ui.text(
Face::Regular,
t::LARGE,
x,
y,
color::MUTED,
"The world you know, rebuilt from the ground up.",
);
y += 22.0 + 24.0;
let pw = ui.button_width("Singleplayer", true).max(120.0);
ui.button(
PLAY,
[x, y, pw, CONTROL_LARGE],
"Singleplayer",
Kind::Primary,
true,
);
let mw = ui.button_width("Multiplayer", true);
ui.button(
MULTIPLAYER,
[x + pw + 8.0, y, mw, CONTROL_LARGE],
"Multiplayer",
Kind::Secondary,
true,
);
y += CONTROL_LARGE + 24.0;
ui.divider(x, y, w);
let worlds = self.recent_worlds();
let last = worlds.first();
let value = last.map_or("None yet".to_owned(), |l| {
let secs = l
.modified
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
format!("{} · {}", l.name, crate::title::date_label(secs))
});
let rh = ui.row(x, y, "Last played", &value, w - t::ROW_LABEL - 120.0);
if last.is_some() {
let bw = ui.button_width("Resume", false);
ui.button(
RESUME,
[x + w - bw, y + (rh - CONTROL) / 2.0, bw, CONTROL],
"Resume",
Kind::Secondary,
true,
);
}
y += rh;
ui.divider(x, y, w);
let count = match worlds.len() {
1 => "1 world".to_owned(),
n => format!("{n} worlds"),
};
y += ui.row(x, y, "Saves", &count, w - t::ROW_LABEL - 16.0);
ui.divider(x, y, w);
let note = "Not affiliated with Mojang or Microsoft";
ui.text(
Face::Regular,
SMALL,
x,
ui.h - 12.0 - 18.0,
color::FAINT,
note,
);
}
/// A click in Horizon's layout (`x`, `y` in GUI pixels).
pub fn click_horizon(
&mut self,
atlas: &HudAtlas,
batch: &mut HudBatch,
x: f32,
y: f32,
) -> Action {
if self.skip_opening() {
return Action::None;
}
let hit = {
let mut ui = Ui::dry(atlas, batch, (x, y));
self.draw_horizon(&mut ui, 1.0);
ui.hit((x, y))
};
let Some(hit) = hit else {
if let Mode::Create { focus, .. } = &mut self.mode {
*focus = 2;
}
return Action::None;
};
let saves = self.saves.clone();
let select = |saves: &std::path::Path| Mode::Select {
worlds: list_worlds(saves),
selected: None,
};
match hit {
QUIT => return Action::Quit,
h if h == NAV => {
self.mode = Mode::Main;
return Action::None;
}
h if h == NAV + 1 => {
if !matches!(self.mode, Mode::Select { .. }) {
self.mode = select(&saves);
}
return Action::None;
}
h if h == NAV + 2 => {
if !matches!(self.mode, Mode::Multiplayer(_)) {
self.mode = Mode::Multiplayer(Screen::list(&saves));
}
return Action::None;
}
h if h == NAV + 3 => return Action::Options,
_ => {}
}
match &mut self.mode {
Mode::Main => match hit {
PLAY => self.mode = select(&saves),
MULTIPLAYER => self.mode = Mode::Multiplayer(Screen::list(&saves)),
RESUME => {
if let Some(w) = list_worlds(&saves).into_iter().next() {
return Action::Play { folder: w.folder };
}
}
_ => {}
},
Mode::Select { worlds, selected } => match hit {
PLAY_WORLD => {
if let Some(w) = selected.and_then(|i| worlds.get(i)) {
return Action::Play {
folder: w.folder.clone(),
};
}
}
CREATE => {
self.mode = Mode::Create {
name: "New World".into(),
seed: String::new(),
creative: false,
focus: 0,
}
}
h if (WORLD..WORLD + 10_000).contains(&h) => {
let i = (h - WORLD) as usize;
if *selected == Some(i) {
return Action::Play {
folder: worlds[i].folder.clone(),
};
}
*selected = Some(i);
}
_ => {}
},
Mode::Create {
creative, focus, ..
} => match hit {
BACK => self.mode = select(&saves),
NAME => *focus = 0,
SEED => *focus = 1,
h if h == MODE || h == MODE + 1 => *creative = h == MODE + 1,
MAKE => return self.create_action(),
_ => {}
},
Mode::Multiplayer(screen) => {
let id = match hit {
JOIN => Some(40),
DIRECT => Some(41),
ADD => Some(42),
EDIT => Some(43),
DELETE => Some(44),
REFRESH => Some(45),
BACK => Some(match screen {
Screen::List { .. } => 46,
Screen::Edit { .. } => 51,
Screen::Direct { .. } => 53,
Screen::Disconnected { .. } => 54,
}),
DONE => Some(match screen {
Screen::Direct { .. } => 52,
_ => 50,
}),
FIELD_A | FIELD_B => {
if let Screen::Edit { focus, .. } = screen {
*focus = u8::from(hit == FIELD_B);
}
None
}
h if (SERVER..SERVER + 10_000).contains(&h) => {
let i = (h - SERVER) as usize;
let again =
matches!(screen, Screen::List { selected, .. } if *selected == Some(i));
if again {
if let Some(address) = screen.address_of(i) {
return Action::Connect { address };
}
} else if let Screen::List { selected, .. } = screen {
*selected = Some(i);
}
None
}
_ => None,
};
if let Some(id) = id {
match screen.click(Some(id), 0.0, 0.0, (0.0, 0.0), &saves) {
Outcome::Back => self.mode = Mode::Main,
Outcome::Connect(address) => return Action::Connect { address },
Outcome::None => {}
}
}
}
}
Action::None
}
}
/// The page header plus a row of actions under it; returns the y below.
fn header(ui: &mut Ui, x: f32, title: &str, description: &str) -> f32 {
let y = 36.0;
y + ui.page_header(x, y, title, description) + 24.0
}
/// Actions laid out right to left from `right`; returns the left edge.
fn actions_right(ui: &mut Ui, right: f32, y: f32, items: &[(u64, &str, Kind, bool)]) -> f32 {
let mut rx = right;
for &(bid, label, kind, on) in items {
let large = kind == Kind::Primary;
let (bw, bh) = if large {
(ui.button_width(label, true).max(120.0), CONTROL_LARGE)
} else {
(ui.button_width(label, false), CONTROL)
};
rx -= bw;
ui.button(
bid,
[rx, y + (CONTROL_LARGE - bh) / 2.0, bw, bh],
label,
kind,
on,
);
rx -= 8.0;
}
rx
}
/// A selectable list row: the launcher's row look with a hover layer and
/// the raised selection with its 1px border. Returns the row height.
#[allow(clippy::too_many_arguments)]
fn list_row(
ui: &mut Ui,
rid: u64,
x: f32,
y: f32,
w: f32,
selected: bool,
title: &str,
meta: &str,
right: &str,
dot: Option<u32>,
) -> f32 {
let h = 60.0;
let hover = ui.interact(rid, x - 12.0, y + 2.0, w + 24.0, h - 4.0, true);
let sel = ui.tween(rid ^ 0x5e1ec7, if selected { 1.0 } else { 0.0 }, t::quick());
ui.rounded(
x - 12.0,
y + 4.0,
w + 24.0,
h - 8.0,
t::RADIUS,
color::with_alpha(color::HOVER, 0.7 * hover),
);
ui.rounded(
x - 12.0,
y + 4.0,
w + 24.0,
h - 8.0,
t::RADIUS,
color::with_alpha(color::RAISED, sel),
);
ui.border(
x - 12.0,
y + 4.0,
w + 24.0,
h - 8.0,
t::RADIUS,
color::with_alpha(color::BORDER, sel),
);
let mut tx = x;
if let Some(dot) = dot {
ui.rounded(x, y + 17.0, 6.0, 6.0, 3.0, dot);
tx += 16.0;
}
let title = ui.fit(Face::Medium, BODY, title, w - 200.0);
ui.text(Face::Medium, BODY, tx, y + 10.0, color::TEXT, &title);
let meta = ui.fit(Face::Regular, SMALL, meta, w - 200.0);
ui.text(Face::Regular, SMALL, tx, y + 31.0, color::MUTED, &meta);
ui.text_right(Face::Regular, SMALL, x + w, y + 21.0, color::FAINT, right);
ui.divider(x, y + h, w);
h
}
/// An empty list: a serif line and a hint.
fn empty(ui: &mut Ui, x: f32, y: f32, title: &str, hint: &str) {
ui.text_line(Face::Display, 22.0, 30.0, x, y + 32.0, color::TEXT, title);
ui.text(Face::Regular, BODY, x, y + 66.0, color::MUTED, hint);
}
fn worlds_page(
ui: &mut Ui,
x: f32,
w: f32,
worlds: &[crate::title::WorldEntry],
selected: Option<usize>,
) {
let count = match worlds.len() {
0 => "No worlds yet. They live in the saves folder.".to_owned(),
1 => "1 world in the saves folder.".to_owned(),
n => format!("{n} worlds in the saves folder."),
};
let mut y = header(ui, x, "Singleplayer", &count);
actions_right(
ui,
x + w,
y - 24.0 - 58.0,
&[
(PLAY_WORLD, "Play", Kind::Primary, selected.is_some()),
(CREATE, "Create world", Kind::Secondary, true),
],
);
ui.divider(x, y, w);
if worlds.is_empty() {
empty(ui, x, y, "A blank map.", "Create a world to begin.");
return;
}
let bottom = ui.h - 36.0;
for (i, world) in worlds.iter().enumerate() {
if y + 60.0 > bottom {
break;
}
let secs = world
.modified
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let meta = format!("{} · {}", crate::title::date_label(secs), world.folder);
let mode = if world.creative {
"Creative"
} else {
"Survival"
};
y += list_row(
ui,
WORLD + i as u64,
x,
y,
w,
selected == Some(i),
&world.name,
&meta,
mode,
None,
);
}
}
#[allow(clippy::too_many_arguments)]
fn create_page(
ui: &mut Ui,
x: f32,
w: f32,
name: &str,
seed: &str,
creative: bool,
focus: u8,
caret: bool,
) {
let mut y = header(
ui,
x,
"Create world",
"Generated by Horizon from the seed, as vanilla would.",
);
actions_right(
ui,
x + w,
y - 24.0 - 58.0,
&[
(MAKE, "Create", Kind::Primary, true),
(BACK, "Cancel", Kind::Ghost, true),
],
);
ui.divider(x, y, w);
let fx = x + t::ROW_LABEL + 16.0;
let fw = (w - t::ROW_LABEL - 16.0).min(360.0);
for (i, label) in ["World name", "Seed", "Game mode"].iter().enumerate() {
let rh = 12.0 + CONTROL + 12.0;
ui.text_line(Face::Regular, BODY, rh, x, y, color::MUTED, label);
let r = [fx, y + 12.0, fw, CONTROL];
match i {
0 => ui.field(NAME, r, name, "New World", focus == 0, caret),
1 => ui.field(SEED, r, seed, "Random", focus == 1, caret),
_ => {
ui.segmented(
MODE,
fx,
y + 12.0,
&["Survival", "Creative"],
usize::from(creative),
);
}
}
y += rh;
ui.divider(x, y, w);
}
}
fn multiplayer_page(ui: &mut Ui, x: f32, w: f32, screen: &Screen, caret: bool) {
match screen {
Screen::List {
servers,
selected,
pings,
lan,
} => {
let games = lan.games();
let sub = match servers.len() {
0 => "No saved servers. Worlds opened to LAN show up here too.".to_owned(),
1 => "1 saved server, and worlds opened to LAN.".to_owned(),
n => format!("{n} saved servers, and worlds opened to LAN."),
};
let mut y = header(ui, x, "Multiplayer", &sub);
let has = selected.is_some();
let saved = selected.is_some_and(|i| i < servers.len());
let mut items = vec![
(JOIN, "Join", Kind::Primary, has),
(ADD, "Add server", Kind::Secondary, true),
(DIRECT, "Direct connect", Kind::Secondary, true),
(REFRESH, "Refresh", Kind::Ghost, true),
];
if saved {
items.push((EDIT, "Edit", Kind::Ghost, true));
items.push((DELETE, "Delete", Kind::Danger, true));
}
actions_right(ui, x + w, y - 24.0 - 58.0, &items);
ui.divider(x, y, w);
let pings = pings.lock().map(|p| p.clone()).unwrap_or_default();
let mut rows: Vec<(String, String, String, u32)> = servers
.iter()
.map(|s| {
let (line, right, dot) = match pings.get(&s.ip) {
Some(Ping::Ok {
motd, players, ms, ..
}) => (
motd.lines().next().unwrap_or("").trim().to_owned(),
format!("{players} · {ms} ms"),
color::ACCENT,
),
Some(Ping::Failed(_)) => (
"Can't reach the server".to_owned(),
String::new(),
color::DANGER,
),
_ => ("Pinging…".to_owned(), String::new(), color::FAINT),
};
(s.name.clone(), format!("{line} · {}", s.ip), right, dot)
})
.collect();
rows.extend(games.iter().map(|g| {
(
"LAN world".to_owned(),
format!("{} · {}", g.motd, g.address),
"On your network".to_owned(),
color::ACCENT,
)
}));
if rows.is_empty() {
empty(
ui,
x,
y,
"Nobody here yet.",
"Add a server, connect directly, or open a world to LAN.",
);
return;
}
let bottom = ui.h - 36.0;
for (i, (name, meta, right, dot)) in rows.iter().enumerate() {
if y + 60.0 > bottom {
break;
}
y += list_row(
ui,
SERVER + i as u64,
x,
y,
w,
*selected == Some(i),
name,
meta,
right,
Some(*dot),
);
}
}
Screen::Edit {
index,
name,
address,
focus,
} => {
let title = if index.is_some() {
"Edit server"
} else {
"Add server"
};
let mut y = header(
ui,
x,
title,
"Saved to servers.dat, like vanilla's server list.",
);
let ok = horizon_protocol::servers::parse_address(address).is_some();
actions_right(
ui,
x + w,
y - 24.0 - 58.0,
&[
(DONE, "Save", Kind::Primary, ok),
(BACK, "Cancel", Kind::Ghost, true),
],
);
ui.divider(x, y, w);
let fx = x + t::ROW_LABEL + 16.0;
let fw = (w - t::ROW_LABEL - 16.0).min(360.0);
for (i, (label, value, hint)) in [
("Name", name, "Minecraft Server"),
("Address", address, "play.example.net"),
]
.iter()
.enumerate()
{
let rh = 12.0 + CONTROL + 12.0;
ui.text_line(Face::Regular, BODY, rh, x, y, color::MUTED, label);
let fid = if i == 0 { FIELD_A } else { FIELD_B };
ui.field(
fid,
[fx, y + 12.0, fw, CONTROL],
value,
hint,
usize::from(*focus) == i,
caret,
);
y += rh;
ui.divider(x, y, w);
}
}
Screen::Direct { address } => {
let y = header(
ui,
x,
"Direct connect",
"Join a server once without saving it.",
);
let ok = horizon_protocol::servers::parse_address(address).is_some();
actions_right(
ui,
x + w,
y - 24.0 - 58.0,
&[
(DONE, "Join", Kind::Primary, ok),
(BACK, "Cancel", Kind::Ghost, true),
],
);
ui.divider(x, y, w);
let rh = 12.0 + CONTROL + 12.0;
ui.text_line(Face::Regular, BODY, rh, x, y, color::MUTED, "Address");
let fx = x + t::ROW_LABEL + 16.0;
ui.field(
FIELD_A,
[fx, y + 12.0, (w - t::ROW_LABEL - 16.0).min(360.0), CONTROL],
address,
"host:port",
true,
caret,
);
ui.divider(x, y + rh, w);
}
Screen::Disconnected { reason } => {
let mut y = header(
ui,
x,
"Connection lost",
"The server closed the connection.",
);
ui.divider(x, y, w);
y += 16.0;
// The launcher's error note: a danger dot beside the message.
let lines = wrap(ui, reason, w - 40.0);
let box_h = 24.0 + lines.len() as f32 * 19.0;
ui.rounded(x, y, w, box_h, t::RADIUS, color::RAISED);
ui.border(x, y, w, box_h, t::RADIUS, color::BORDER);
ui.rounded(x + 12.0, y + 18.0, 6.0, 6.0, 3.0, color::DANGER);
for (i, line) in lines.iter().enumerate() {
ui.text_line(
Face::Regular,
BODY,
19.0,
x + 28.0,
y + 12.0 + i as f32 * 19.0,
color::TEXT,
line,
);
}
y += box_h + 16.0;
let bw = ui.button_width("Back to servers", false);
ui.button(
BACK,
[x, y, bw, CONTROL],
"Back to servers",
Kind::Secondary,
true,
);
}
}
}
/// Greedy word wrap in dp.
pub fn wrap(ui: &Ui, text: &str, width: f32) -> Vec<String> {
let mut lines = Vec::new();
for para in text.lines() {
let mut line = String::new();
for word in para.split_whitespace() {
let next = if line.is_empty() {
word.to_owned()
} else {
format!("{line} {word}")
};
if !line.is_empty() && ui.measure(Face::Regular, BODY, &next) > width {
lines.push(std::mem::replace(&mut line, word.to_owned()));
} else {
line = next;
}
}
lines.push(line);
}
lines
}
/// The pause menu in Horizon's style: the launcher's sidebar over the
/// blurred world, with the game's actions as a page. Ids match
/// `game/screens.rs`'s PauseScreen actions: 0 back to game, 1 options,
/// 2 save and quit, 3 statistics, 4 advancements, 9 world options.
pub mod pause {
use super::*;
fn act(a: u8) -> u64 {
id("pause", u64::from(a))
}
pub fn draw(ui: &mut Ui, world: &str, quit_label: &str, opened: Instant) {
let (w, h) = (ui.w, ui.h);
let p = t::ease_out_quint(opened.elapsed().as_secs_f32() / t::move_());
ui.opacity = p;
ui.atmosphere(0.35, 1.0);
ui.offset = (-16.0 * (1.0 - p), 0.0);
ui.fill(0.0, 0.0, SIDEBAR, h, color::with_alpha(color::SIDEBAR, 0.8));
ui.vline(SIDEBAR - 1.0, 0.0, h, color::BORDER);
ui.text_line(
Face::Display,
22.0,
30.0,
22.0,
16.0,
color::TEXT,
"Horizon",
);
ui.sidebar_list(
id("pause.nav", 0),
12.0,
66.0,
SIDEBAR - 24.0,
&["Game", "Advancements", "Statistics", "Options"],
0,
);
ui.text(
Face::Regular,
SMALL,
22.0,
h - 12.0 - 18.0,
color::FAINT,
"Horizon 26.3",
);
ui.offset = (0.0, 8.0 * (1.0 - p));
let x = SIDEBAR + 48.0;
let cw = (w - x - 48.0).min(760.0);
let mut y = 36.0;
ui.text(Face::Medium, SMALL, x, y, color::FAINT, "Game paused");
y += 22.0;
let title = ui.fit(Face::Display, t::HERO, world, cw);
ui.text_line(Face::Display, t::HERO, 58.0, x, y, color::TEXT, &title);
y += 58.0 + 24.0;
let bw = ui.button_width("Back to game", true).max(120.0);
ui.button(
act(0),
[x, y, bw, CONTROL_LARGE],
"Back to game",
Kind::Primary,
true,
);
let lw = ui.button_width("Open to LAN", true);
ui.button(
act(9),
[x + bw + 8.0, y, lw, CONTROL_LARGE],
"Open to LAN",
Kind::Secondary,
true,
);
y += CONTROL_LARGE + 24.0;
ui.divider(x, y, cw);
let rh = ui.row(
x,
y,
"Leave",
"Saves the world, then returns to the title.",
cw - t::ROW_LABEL - 220.0,
);
let qw = ui.button_width(quit_label, false);
ui.button(
act(2),
[x + cw - qw, y + (rh - CONTROL) / 2.0, qw, CONTROL],
quit_label,
Kind::Danger,
true,
);
y += rh;
ui.divider(x, y, cw);
ui.opacity = 1.0;
ui.offset = (0.0, 0.0);
}
/// The PauseScreen action under a click, if any.
pub fn click(
atlas: &HudAtlas,
batch: &mut HudBatch,
x: f32,
y: f32,
world: &str,
quit_label: &str,
) -> Option<u8> {
let mut ui = Ui::dry(atlas, batch, (x, y));
draw(
&mut ui,
world,
quit_label,
Instant::now() - std::time::Duration::from_secs(5),
);
let hit = ui.hit((x, y))?;
let nav = id("pause.nav", 0);
match hit {
h if h == nav => Some(0),
h if h == nav + 1 => Some(4),
h if h == nav + 2 => Some(3),
h if h == nav + 3 => Some(1),
h => [0u8, 1, 2, 3, 4, 9].into_iter().find(|&a| act(a) == h),
}
}
}
+83 -11
View File
@@ -4,6 +4,7 @@ mod advancements;
mod advancements_screen;
mod allays;
mod armor_stands;
mod backdrop;
mod banners;
mod beacon;
mod block_entity_anims;
@@ -694,6 +695,10 @@ struct Game<'a> {
settings: Settings,
/// Where the options screen returns: true = title, false = pause menu.
options_from_title: bool,
/// The live title backdrop (game/backdrop.rs).
backdrop: backdrop::Backdrop,
/// When the pause menu opened (Horizon style's entrance).
pause_since: Option<Instant>,
dragging: bool,
/// `ClientAvatarState`: walked distance and view bob.
avatar: horizon_client::camera::AvatarBob,
@@ -2969,7 +2974,15 @@ impl Game<'_> {
return batch;
}
if let Some(title) = &self.title {
title.draw(&self.res.atlas, &mut batch, self.cursor);
if horizon_client::ui_theme::horizon() {
let fade = self.backdrop_fade();
let mut ui =
horizon_client::ui_theme::Ui::new(&self.res.atlas, &mut batch, self.cursor);
title.draw_horizon(&mut ui, fade);
ui.finish();
} else {
title.draw(&self.res.atlas, &mut batch, self.cursor);
}
return batch;
}
let atlas_ptr: *const HudAtlas = &self.res.atlas;
@@ -2994,7 +3007,8 @@ impl Game<'_> {
// the sleep overlay, debug screen and open screens still draw.
let spectator = self.spectator();
let (wi, hi) = (w as i32, h as i32);
if !self.ui.hide_gui {
// Horizon's pause menu sits over the blurred world alone.
if !self.ui.hide_gui && !self.horizon_pause() {
self.draw_spyglass(&mut batch);
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
// extractVehicleHealth and the experience bar, laid out by hud_layout.
@@ -7150,7 +7164,13 @@ impl Game<'_> {
return Ok(false);
};
let alpha = self.clock.fraction();
let (camera, underwater, view_yaw, view_pitch) = match self.camera_view(alpha as f32) {
let live = self.backdrop_live();
let view = if live {
self.backdrop_view()
} else {
self.camera_view(alpha as f32)
};
let (camera, underwater, view_yaw, view_pitch) = match view {
Some((c, yaw, pitch)) => {
let s =
self.world
@@ -7236,7 +7256,7 @@ impl Game<'_> {
entities.extend(dragon);
}
}
let celestial = if title {
let celestial = if title && !live {
self.panorama_geometry()
} else if self.dim == Dim::Overworld
&& self.potions.effects.get("darkness").is_none()
@@ -7262,12 +7282,14 @@ impl Game<'_> {
if !title {
self.end_geometry(camera, &mut entities);
}
if !title && self.dim == Dim::Overworld {
if (!title || live) && self.dim == Dim::Overworld {
self.cloud_geometry(&sky, camera, alpha, &mut entities);
self.weather_geometry(camera, alpha, &mut entities);
}
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
let (yaw, pitch) = if title {
let (yaw, pitch) = if live {
(view_yaw, view_pitch)
} else if title {
(-(self.started.elapsed().as_secs_f32() * 2.0) % 360.0, 10.0)
} else {
(view_yaw, view_pitch)
@@ -7287,7 +7309,9 @@ impl Game<'_> {
camera,
yaw,
pitch,
fov_degrees: if title {
fov_degrees: if live {
72.0
} else if title {
85.0
} else {
self.fov_degrees(alpha as f32, camera.to_array())
@@ -7300,7 +7324,7 @@ impl Game<'_> {
sky,
distance: (self.streaming.radius * 16) as f32,
underwater,
title,
title: title && !live,
skybox: self.dim == Dim::Overworld,
end_sky: self.dim == Dim::End,
environment_fog: (self.dim == Dim::Nether).then_some((sky.fog_start, sky.fog_end)),
@@ -7334,6 +7358,13 @@ impl Game<'_> {
darkness: self.darkness_lighting(alpha as f32),
night_vision: self.night_vision_intensity(),
block_flicker: self.res.fog.state.block_light_flicker,
post: if live {
Some(horizon_client::post::PostGrade::FROST)
} else if self.horizon_pause() {
Some(horizon_client::post::PostGrade::PAUSE)
} else {
None
},
};
self.renderer.as_mut().ok_or("renderer")?.render(&frame)
}
@@ -7627,6 +7658,15 @@ impl ApplicationHandler for Game<'_> {
) {
self.last_input = Instant::now();
}
if let WindowEvent::MouseInput {
state,
button: MouseButton::Left,
..
} = &event
{
// The press state of Horizon-style buttons.
horizon_client::ui_theme::set_pressed(*state == ElementState::Pressed);
}
let result: Result<()> = (|| {
match event {
WindowEvent::CloseRequested => {
@@ -7752,9 +7792,14 @@ impl ApplicationHandler for Game<'_> {
.as_ref()
.map(|r| [r.config.width, r.config.height])
.unwrap_or([1, 1]);
let b = HudBatch::new(size);
let mut b = HudBatch::new(size);
let atlas = &self.res.atlas;
let action = self.title.as_mut().map_or(Action::None, |t| {
t.click(self.cursor.0, self.cursor.1, (b.width, b.height))
if horizon_client::ui_theme::horizon() {
t.click_horizon(atlas, &mut b, self.cursor.0, self.cursor.1)
} else {
t.click(self.cursor.0, self.cursor.1, (b.width, b.height))
}
});
match action {
Action::Quit => event_loop.exit(),
@@ -7920,6 +7965,7 @@ impl ApplicationHandler for Game<'_> {
WindowEvent::KeyboardInput { event, .. } if self.title.is_some() => {
if event.state == ElementState::Pressed
&& let Some(title) = self.title.as_mut()
&& !title.skip_opening()
{
match event.physical_key {
PhysicalKey::Code(KeyCode::Backspace) => title.backspace(),
@@ -8001,6 +8047,11 @@ impl ApplicationHandler for Game<'_> {
self.pitch = (self.pitch
+ (turn(KeyCode::ArrowDown) - turn(KeyCode::ArrowUp)) * 90.0 * secs)
.clamp(-90.0, 90.0);
self.update_backdrop()?;
horizon_client::ui_theme::set_backdrop(self.backdrop_fade());
if self.screen != Screen::Paused {
self.pause_since = None;
}
if self.title.is_none() {
self.update_world();
self.spawn_player()?;
@@ -8013,6 +8064,8 @@ impl ApplicationHandler for Game<'_> {
self.tick()?;
self.tick_ambience();
self.tick_fov();
} else if self.title.is_some() {
self.tick_backdrop();
}
self.tick_music();
self.tick_sound_engine();
@@ -8246,6 +8299,14 @@ pub fn run() -> Result<()> {
let now = Instant::now();
let game_root = options.resources.clone();
let mut settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
horizon_client::ui_theme::load_preferences(&std::env::current_dir()?);
// Scripted and frame-limited runs (the parity harnesses) see vanilla's
// screens unless HORIZON_MENU_STYLE asks otherwise.
if (options.frames.is_some() || options.control.is_some())
&& std::env::var_os("HORIZON_MENU_STYLE").is_none()
{
horizon_client::ui_theme::set_style(horizon_client::ui_theme::MenuStyle::Classic);
}
if let Some(d) = options.distance_override {
settings.render_distance = d;
}
@@ -8363,6 +8424,8 @@ pub fn run() -> Result<()> {
hand_item: None,
settings,
options_from_title: false,
backdrop: Default::default(),
pause_since: None,
dragging: false,
avatar: Default::default(),
flash: 0,
@@ -8493,7 +8556,16 @@ pub fn run() -> Result<()> {
let (seed, creative) = (game.options.seed, game.options.creative);
game.open_world(&folder, None, Some(seed), creative)?;
}
None => game.title = Some(Title::new(std::env::current_dir()?.join("saves"))),
None => {
let title = Title::new(std::env::current_dir()?.join("saves"));
// The launcher's opening, once per session and only in Horizon's style.
if horizon_client::ui_theme::horizon() && game.options.frames.is_none() {
title
.opening
.set(Some(horizon_client::front::Opening::new()));
}
game.title = Some(title);
}
}
if let (Some(script), Some(output)) = (
std::env::var_os("HORIZON_COMMAND_SCRIPT"),
+245
View File
@@ -0,0 +1,245 @@
//! The live title backdrop (Menu style: Horizon): a frozen landscape that
//! Horizon generates and renders itself behind the title screens, seen
//! through a slow orbiting camera, falling snow, an overcast sky and the cold
//! blur and grade of `gfx::PostGrade`. Nothing is bundled: the chunks come
//! from the vanilla generator for a fixed seed and are cached in
//! `saves/.horizon-title/backdrop` (no level.dat, so no world list entry).
//! The ice-spikes spot is found once with `/locate biome`'s search and kept
//! in `spot.txt` there.
use super::Game;
use horizon_client::{
Result,
save::{Persistent, SaveDir},
streaming::Streaming,
vanillagen::VanillaTerrain,
world::{Weather, World},
};
use horizon_core::biome::{BiomeZoom, obfuscate_seed};
use std::{
sync::{Arc, Mutex},
time::Instant,
};
/// The world the backdrop is cut from (HORIZON_BACKDROP_SEED overrides it).
const SEED: i64 = 2_026_031_337;
/// The biome the camera looks for.
const BIOME: &str = "minecraft:ice_spikes";
/// Late morning: the snow reads clearly; the grade (`PostGrade::FROST`) makes it cold.
const DAY_TIME: i64 = 4_000;
#[derive(Default)]
pub(super) struct Backdrop {
pub active: bool,
/// The search for the spot, then the spot (block x, z).
spot: Arc<Mutex<Option<Option<(i32, i32)>>>>,
centred: bool,
/// Camera position once the ground under it has loaded.
pub camera: Option<glam::DVec3>,
pub ready: Option<Instant>,
pub started: Option<Instant>,
}
fn seed() -> i64 {
std::env::var("HORIZON_BACKDROP_SEED")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(SEED)
}
impl Game<'_> {
/// How visible the live backdrop is (0 until its first chunks are drawn).
pub(super) fn backdrop_fade(&self) -> f32 {
let Some(at) = self.backdrop.ready else {
return 0.0;
};
let x = (at.elapsed().as_secs_f32() / 1.6).clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
}
/// Whether the frame should draw the backdrop world instead of the panorama.
pub(super) fn backdrop_live(&self) -> bool {
self.title.is_some() && self.backdrop.active && self.backdrop.camera.is_some()
}
/// The orbiting camera: position, yaw and pitch.
pub(super) fn backdrop_view(&self) -> Option<(glam::DVec3, f32, f32)> {
let c = self.backdrop.camera?;
let t = self
.backdrop
.started
.map_or(0.0, |s| s.elapsed().as_secs_f32());
// A full turn in about six minutes.
let yaw = (200.0 + t * 1.0) % 360.0;
let pitch = 6.0 + (t * 0.05).sin() * 2.0;
Some((c, yaw, pitch))
}
/// Called every frame while the title is up.
pub(super) fn update_backdrop(&mut self) -> Result<()> {
let wanted = self.title.is_some()
&& horizon_client::ui_theme::horizon()
&& self.renderer.is_some()
&& self.options.frames.is_none();
if !wanted {
if self.backdrop.active && self.title.is_some() {
self.stop_backdrop();
}
if self.title.is_none() && self.backdrop.active {
// open_world / connect_server replaced the world and streaming.
self.backdrop = Backdrop::default();
}
return Ok(());
}
if !self.backdrop.active {
self.start_backdrop()?;
}
let found = *self.backdrop.spot.lock().unwrap_or_else(|e| e.into_inner());
let Some(Some((x, z))) = found else {
return Ok(());
};
if !self.backdrop.centred {
self.backdrop.centred = true;
self.streaming.center = (x >> 4, z >> 4);
self.streaming.paused = false;
}
self.update_world();
if self.backdrop.camera.is_none()
&& (-2..=2)
.all(|dz| (-2..=2).all(|dx| self.streaming.is_lit((x >> 4) + dx, (z >> 4) + dz)))
{
// Above the tallest block nearby, so no spike pokes through the lens.
let mut top = 64;
for dz in (-12..=12).step_by(3) {
for dx in (-12..=12).step_by(3) {
if let Some(y) = self.surface_y(x + dx, z + dz) {
top = top.max(y);
}
}
}
let ground = self.surface_y(x, z).unwrap_or(top);
let y = (ground + 22).max(top + 4);
self.backdrop.camera = Some(glam::DVec3::new(
f64::from(x) + 0.5,
f64::from(y),
f64::from(z) + 0.5,
));
}
if self.backdrop.camera.is_some()
&& self.backdrop.ready.is_none()
&& self
.renderer
.as_ref()
.is_some_and(|r| r.section_count() > 40)
{
self.backdrop.ready = Some(Instant::now());
}
Ok(())
}
/// The world tick of the backdrop: time passes for clouds and snow only.
pub(super) fn tick_backdrop(&mut self) {
if self.backdrop.active {
self.world.game_time += 1;
// The snow's fall follows the client tick counter.
self.ticks += 1;
}
}
fn surface_y(&self, x: i32, z: i32) -> Option<i32> {
let air = self.res.facts.air;
(-60..320).rev().find(|&y| self.world.block(x, y, z) != air)
}
fn start_backdrop(&mut self) -> Result<()> {
let seed = seed();
let root = std::env::current_dir()?
.join("saves")
.join(".horizon-title")
.join("backdrop");
std::fs::create_dir_all(&root)?;
let save = Arc::new(SaveDir::new(root.join(format!("{seed}"))));
let res = &*self.res;
let inner = Arc::new(
VanillaTerrain::new(
&self.options.resources.join("worldgen"),
res.assets.registry,
&res.biomes,
seed,
)
.map_err(|e| format!("title backdrop: {e}"))?,
);
let mut streaming = Streaming::new(
Arc::new(Persistent {
inner: inner.clone(),
save: save.clone(),
registry: res.assets.clone(),
biomes: res.biomes.clone(),
}),
res.context.clone(),
res.threads.min(6),
);
streaming.radius = self.settings.render_distance.clamp(4, 10);
streaming.biome_blend = self.settings.biome_blend;
streaming.smooth_lighting = self.settings.smooth_lighting;
streaming.cutout_leaves = self.settings.cutout_leaves;
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
streaming.paused = true;
self.streaming = streaming;
self.save = save;
let mut world = World::new(self.res.facts.air, seed);
world.day_time = DAY_TIME;
world.game_time = 6000;
// Light snow: the weather renders as snow in frozen biomes.
world.weather = Weather {
clear_time: 0,
rain_time: i32::MAX / 2,
thunder_time: i32::MAX / 2,
raining: true,
thundering: false,
rain_level: (0.35, 0.35),
thunder_level: (0.0, 0.0),
};
self.world = world;
self.dim = super::Dim::Overworld;
if let Some(r) = self.renderer.as_mut() {
r.clear_sections();
}
self.backdrop = Backdrop {
active: true,
started: Some(Instant::now()),
..Default::default()
};
// Find (or recall) the spot off the main thread.
let spot = self.backdrop.spot.clone();
std::fs::create_dir_all(root.join(format!("{seed}")))?;
let cache = root.join(format!("{seed}")).join("spot.txt");
std::thread::Builder::new()
.name("horizon-backdrop".into())
.spawn(move || {
let known = std::fs::read_to_string(&cache).ok().and_then(|t| {
let mut it = t.split_whitespace().map(|v| v.parse::<i32>().ok());
Some((it.next()??, it.next()??))
});
let found = known.or_else(|| {
let allowed = |name: &str| name == BIOME;
let search = horizon_world::worldgen::generator::BiomeSearch {
allowed: &allowed,
origin: [0, 64, 0],
radius: 6400,
horizontal: 32,
vertical: 64,
min_y: -64,
max_y: 320,
};
let found = inner.find_closest_biome(&search);
eprintln!("Horizon: title backdrop: {BIOME} at {found:?}");
let (_, p) = found?;
let _ = std::fs::write(&cache, format!("{} {}\n", p[0], p[2]));
Some((p[0], p[2]))
});
*spot.lock().unwrap_or_else(|e| e.into_inner()) =
Some(Some(found.unwrap_or((0, 0))));
})?;
Ok(())
}
fn stop_backdrop(&mut self) {
self.streaming.paused = true;
self.world = World::new(self.res.facts.air, 0);
if let Some(r) = self.renderer.as_mut() {
r.clear_sections();
}
self.backdrop = Backdrop::default();
}
}
@@ -491,7 +491,6 @@ impl Game<'_> {
"present_mode": format!("{:?}", r.config.present_mode),
"drawn_sections": r.drawn_sections,
"uploaded_bytes": r.uploaded_bytes,
"resident_sections": r.resident_sections(),
})
});
json!({
@@ -511,7 +510,6 @@ impl Game<'_> {
"ticks": self.ticks,
"day_time": self.world.day_time,
"music": self.music.current(&self.audio.engine),
"sounds": self.audio.census(),
"fishing": self.hook_status(),
"tridents": self.tridents.len(),
"rockets": self.rockets.len(),
@@ -521,8 +519,6 @@ impl Game<'_> {
"using": self.using,
"render_distance": self.streaming.radius,
"chunks": self.world.chunks.len(),
"mobs": self.mobs.len(),
"block_entities": self.world.block_entities.len(),
"pending_chunks": self.streaming.pending(),
"generated_chunks": self.streaming.generated_total,
"meshed_sections": self.streaming.meshed_total,
+46 -4
View File
@@ -52,7 +52,32 @@ fn death_buttons(w: f32, h: f32) -> [(f32, f32, &'static str, u8); 2] {
]
}
impl Game<'_> {
/// Whether the pause menu is drawn in Horizon's style (over a blurred world).
pub(super) fn horizon_pause(&self) -> bool {
self.screen == Screen::Paused && self.title.is_none() && horizon_client::ui_theme::horizon()
}
fn pause_labels(&self) -> (String, &'static str) {
let world = if self.remote.is_some() {
"Multiplayer".to_owned()
} else {
self.level.name.clone()
};
let quit = if self.remote.is_some() {
"Disconnect"
} else {
"Save and quit"
};
(world, quit)
}
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
if horizon_client::ui_theme::horizon() {
let opened = *self.pause_since.get_or_insert_with(std::time::Instant::now);
let (world, quit) = self.pause_labels();
let mut ui = horizon_client::ui_theme::Ui::new(&self.res.atlas, batch, self.cursor);
horizon_client::front::pause::draw(&mut ui, &world, quit, opened);
ui.finish();
return;
}
let atlas = &self.res.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
@@ -102,10 +127,27 @@ impl Game<'_> {
let b = HudBatch::new(size);
(b.width, b.height)
};
let hit = pause_buttons(w, h)
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4);
let hit = if horizon_client::ui_theme::horizon() {
let size = self
.renderer
.as_ref()
.map_or([1, 1], |r| [r.config.width, r.config.height]);
let mut b = HudBatch::new(size);
let (world, quit) = self.pause_labels();
horizon_client::front::pause::click(
&self.res.atlas,
&mut b,
self.cursor.0,
self.cursor.1,
&world,
quit,
)
} else {
pause_buttons(w, h)
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
};
match hit {
Some(0) => {
self.screen = Screen::Playing;
+23 -20
View File
@@ -96,6 +96,8 @@ pub struct Frame<'a> {
pub night_vision: f32,
/// Torch flicker added to the block light factor of 1.4.
pub block_flicker: f32,
/// Blur and grade the frame under a Horizon-style menu (`post`).
pub post: Option<crate::post::PostGrade>,
}
/// One frame of the world border wall (`WorldBorderRenderer.render`).
#[derive(Clone, Debug)]
@@ -162,6 +164,8 @@ pub struct Renderer {
reachable_from: Option<SectionKey>,
visibility_dirty: bool,
pub hud: HudRenderer,
/// The menus' backdrop blur, made on first use.
post: Option<crate::post::Post>,
pub info: wgpu::AdapterInfo,
pub suspended: bool,
pub drawn_sections: usize,
@@ -214,24 +218,6 @@ fn mip_chain(pixels: &[u8], size: u32, layers: u32) -> (Vec<u8>, u32) {
}
(out, levels)
}
/// What to tell a player whose system offers no usable graphics adapter.
/// Vulkan is loaded at run time (the game links no graphics library), so a
/// missing driver shows here rather than as a missing-DLL dialog.
#[cfg(target_os = "windows")]
const NO_GPU_DRIVER: &str = "No Vulkan graphics driver found: Horizon needs a GPU driver with Vulkan \
(vulkan-1.dll comes with it). Install the latest driver for your NVIDIA, AMD or Intel GPU \
and start the game again";
#[cfg(target_os = "macos")]
const NO_GPU_DRIVER: &str = "No Metal graphics device found";
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
const NO_GPU_DRIVER: &str = "No Vulkan graphics driver found: Horizon needs the Vulkan loader \
(libvulkan.so.1) and a Vulkan driver for your GPU (Mesa's, or NVIDIA's)";
impl Renderer {
/// Sections with GPU buffers, for soak tests watching residency.
pub fn resident_sections(&self) -> usize {
self.sections.len()
}
}
impl Renderer {
pub async fn new(
window: Arc<Window>,
@@ -252,8 +238,7 @@ impl Renderer {
force_fallback_adapter: false,
..Default::default()
})
.await
.map_err(|e| format!("{NO_GPU_DRIVER} ({e})"))?;
.await?;
let info = adapter.get_info();
eprintln!(
"Horizon renderer: {} ({:?}, {:?})",
@@ -1105,6 +1090,7 @@ impl Renderer {
reachable_from: None,
visibility_dirty: true,
hud,
post: None,
info,
suspended: size.width == 0 || size.height == 0,
drawn_sections: 0,
@@ -1872,6 +1858,23 @@ impl Renderer {
pass.draw_indexed(0..6, (i * 4) as i32, 0..1);
}
}
if let Some(grade) = &frame.post
&& self.config.usage.contains(wgpu::TextureUsages::COPY_SRC)
{
let format = self.config.format;
let device = &self.device;
self.post
.get_or_insert_with(|| crate::post::Post::new(device, format))
.apply(
&self.device,
&self.queue,
&mut encoder,
&surface_frame.texture,
&view_target,
[self.config.width, self.config.height],
grade,
);
}
self.hud.draw(
&self.device,
&self.queue,
+7
View File
@@ -204,6 +204,13 @@ impl HudAtlas {
for i in 0..SKIN_SLOTS {
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
}
// Horizon-style menu text and corner masks (ui_theme), rasterized at run time.
images.push((
"horizon/ui-text".into(),
2048,
1024,
vec![0; 2048 * 1024 * 4],
));
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
let width = 4096u32;
let mut placements = Vec::with_capacity(images.len());
+4
View File
@@ -44,6 +44,7 @@ pub mod fastutil_order;
pub mod flatgen;
pub mod fog;
pub mod font;
pub mod front;
pub mod gamerules;
pub mod gfx;
pub mod gossip;
@@ -79,12 +80,14 @@ pub mod nbt_path;
pub mod net;
pub mod options_file;
pub mod options_fixes;
pub mod options_horizon;
pub mod options_ui;
pub mod pack_screen;
pub mod packs;
pub mod particles;
pub mod patrol;
pub mod pose;
pub mod post;
pub mod projectiles;
pub mod raid;
pub mod random_sequences;
@@ -116,6 +119,7 @@ pub mod testgen;
pub mod text;
pub mod title;
pub mod ui;
pub mod ui_theme;
pub mod vanillagen;
pub mod window;
pub mod world;
-7
View File
@@ -5,13 +5,6 @@ mod game;
fn main() {
if let Err(error) = game::run() {
eprintln!("Horizon: {error}");
// Started from Explorer, the console window closes with the game:
// keep the message on screen until it is read.
#[cfg(windows)]
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
eprintln!("Press Enter to close.");
let _ = std::io::stdin().read_line(&mut String::new());
}
std::process::exit(1);
}
}
@@ -17,7 +17,6 @@ use horizon_core::{
geometry::Vec3,
random::{LegacyRandom, RandomSource},
};
use std::collections::HashMap;
/// `MemorySlot.NEVER_EXPIRE`.
pub(super) const NEVER: i64 = i64::MAX;
@@ -844,20 +843,12 @@ impl Poi {
}
/// The POI a block state is (`PoiTypes`): job site blocks, bed heads, bells.
pub fn poi_at(w: &MobWorld, x: i32, y: i32, z: i32) -> Option<Poi> {
poi_of_state(w, w.world.block(x, y, z))
}
/// The POI a block state is, if any (`PoiTypes.forState`).
fn poi_of_state(w: &MobWorld, state: horizon_world::registry::BlockStateId) -> Option<Poi> {
let name = &*w.facts.get(state).name;
let name = w.block_name(x, y, z);
if name == "minecraft:bell" {
return Some(Poi::Meeting);
}
if name.ends_with("_bed") {
let part = w
.registry
.state(state)
.and_then(|s| s.properties.get("part"));
return (part.map(String::as_str) == Some("head")).then_some(Poi::Home);
return (w.prop(x, y, z, "part") == Some("head")).then_some(Poi::Home);
}
super::villager::job_site_profession(name).map(Poi::Job)
}
@@ -929,30 +920,12 @@ fn pois_in_range(
let chunks = radius.div_euclid(16) + 1;
let y0 = (c[1] - radius).max(crate::chunk::MIN_Y);
let y1 = (c[1] + radius).min(crate::chunk::MIN_Y + crate::chunk::HEIGHT - 1);
// The POI each block state is, worked out once per search, and the
// sections whose palette holds none skipped whole, as PoiManager only
// visits sections with records: the same positions in the same order,
// without reading and naming every block of the sphere.
let mut kinds: HashMap<u32, Option<Poi>> = HashMap::new();
let mut kind = |state: u32| {
*kinds
.entry(state)
.or_insert_with(|| poi_of_state(w, horizon_world::registry::BlockStateId(state)))
};
for qz in cz - chunks..=cz + chunks {
for qx in cx - chunks..=cx + chunks {
let Some(chunk) = w.world.chunks.get(&(qx, qz)) else {
if !w.world.chunks.contains_key(&(qx, qz)) {
continue;
};
let holds: Vec<bool> = chunk
.sections
.iter()
.map(|s| s.blocks.maybe_has(|v| kind(v).is_some()))
.collect();
}
for y in y0..=y1 {
if !holds[((y - crate::chunk::MIN_Y) >> 4) as usize] {
continue;
}
for z in qz * 16..qz * 16 + 16 {
for x in qx * 16..qx * 16 + 16 {
if (x - c[0]).abs() > radius || (z - c[2]).abs() > radius {
@@ -964,11 +937,11 @@ fn pois_in_range(
if d > r2 {
continue;
}
let state = chunk.block(x & 15, y, z & 15);
let state = w.world.block(x, y, z);
if w.facts.is_air(state) {
continue;
}
let Some(poi) = kind(state.0) else {
let Some(poi) = poi_at(w, x, y, z) else {
continue;
};
if !poi_matches(m, f, poi) {
+7
View File
@@ -108,6 +108,13 @@ fn entry_address(servers: &[ServerEntry], lan: &Scanner, index: usize) -> Option
}
impl Screen {
/// The address of list row `row` (saved servers, then LAN games).
pub fn address_of(&self, row: usize) -> Option<String> {
match self {
Self::List { servers, lan, .. } => entry_address(servers, lan, row),
_ => None,
}
}
pub fn list(saves: &Path) -> Self {
let servers = servers::load(&servers_file(saves));
let pings: Pings = Arc::default();
@@ -0,0 +1,518 @@
//! The options screen in Horizon's menu style, laid out like the launcher's
//! Settings page: a sidebar with the sliding selection (one item per
//! section), then the section's page header and settings rows (label column,
//! value, control on the right) between 1px dividers.
//!
//! Adding settings (for example the Simple/Advanced graphics menu): add rows
//! to a section in [`SECTIONS`], or a new section. A row is an [`HRow`]: an
//! existing option (`Opt`, drawn as a switch, a slider or a value button from
//! its kind), a header, or a button opening one of vanilla's pages. New kinds
//! of control belong in `ui_theme` so the launcher-matching look stays in one
//! place. Everything else (sliders dragging, scrolling, Done) is handled here.
use crate::{
hud::{HudAtlas, HudBatch},
options_ui::{Event, Kind, Opt, OptionsUi, Page, apply_preset},
settings::{SOUND_SOURCES, Settings},
ui_theme::{
self as t, CONTROL, Face, Kind as Button, MenuStyle, ROW_LABEL, SIDEBAR, SMALL, Ui, color,
id,
},
};
/// One row of a section.
#[derive(Clone, Copy, Debug)]
pub enum HRow {
Header(&'static str),
Opt(Opt),
/// A label, a value and a button opening one of vanilla's pages.
Page(&'static str, &'static str, Page),
/// Menu style: Horizon / Classic.
Style,
}
/// A sidebar section: name, description, rows.
pub struct Section {
pub name: &'static str,
pub blurb: &'static str,
pub rows: fn(&Settings) -> Vec<HRow>,
}
pub const SECTIONS: &[Section] = &[
Section {
name: "Video",
blurb: "Display, quality and how the world is drawn.",
rows: |_| {
let mut r = vec![HRow::Header("Display")];
r.extend(
[
Opt::Fullscreen,
Opt::Vsync,
Opt::MaxFps,
Opt::InactivityFps,
Opt::GuiScale,
Opt::Gamma,
]
.map(HRow::Opt),
);
r.push(HRow::Header("Quality"));
r.extend(
[
Opt::Preset,
Opt::RenderDistance,
Opt::SimulationDistance,
Opt::SmoothLighting,
Opt::Clouds,
Opt::CloudRange,
Opt::Particles,
Opt::Mipmaps,
Opt::EntityShadows,
Opt::EntityDistance,
Opt::BiomeBlend,
Opt::CutoutLeaves,
Opt::ImprovedTransparency,
Opt::WeatherRadius,
Opt::ChunkBuilder,
]
.map(HRow::Opt),
);
r.push(HRow::Header("Preferences"));
r.extend(
[
Opt::Fov,
Opt::ViewBobbing,
Opt::Autosave,
Opt::Vignette,
Opt::AttackIndicator,
Opt::ChunkFade,
]
.map(HRow::Opt),
);
r
},
},
Section {
name: "Audio",
blurb: "Volume for every kind of sound, and subtitles.",
rows: |_| {
let mut r: Vec<HRow> = (0..SOUND_SOURCES.len())
.map(|i| HRow::Opt(Opt::Volume(i)))
.collect();
r.push(HRow::Opt(Opt::ShowSubtitles));
r
},
},
Section {
name: "Controls",
blurb: "Keys, the mouse and how movement keys behave.",
rows: |_| {
let mut r = vec![HRow::Page(
"Key binds",
"Every action and its key",
Page::KeyBinds,
)];
r.push(HRow::Header("Mouse"));
r.extend(
[
Opt::Sensitivity,
Opt::WheelSensitivity,
Opt::InvertY,
Opt::DiscreteScroll,
]
.map(HRow::Opt),
);
r.push(HRow::Header("Movement"));
r.extend([Opt::ToggleCrouch, Opt::ToggleSprint, Opt::AutoJump].map(HRow::Opt));
r
},
},
Section {
name: "Accessibility",
blurb: "Contrast, motion, text backgrounds and notifications.",
rows: |_| {
[
Opt::HighContrast,
Opt::MenuBlur,
Opt::TextBackgroundOpacity,
Opt::NotificationDisplayTime,
Opt::ShowSubtitles,
Opt::ViewBobbing,
Opt::ChatLineSpacing,
]
.map(HRow::Opt)
.to_vec()
},
},
Section {
name: "Language",
blurb: "The game's language and its font.",
rows: |_| {
vec![
HRow::Page("Language", "", Page::Language),
HRow::Opt(Opt::ForceUnicode),
HRow::Opt(Opt::JapaneseGlyphs),
]
},
},
Section {
name: "Chat",
blurb: "What chat shows and how it looks.",
rows: |_| {
[
Opt::ChatVisibility,
Opt::ChatColors,
Opt::ChatLinks,
Opt::ChatOpacity,
Opt::ChatScale,
Opt::ChatWidth,
Opt::ChatHeightFocused,
Opt::ChatHeightUnfocused,
Opt::AutoSuggestions,
Opt::ReducedDebugInfo,
]
.map(HRow::Opt)
.to_vec()
},
},
Section {
name: "Interface",
blurb: "Menus and resource packs.",
rows: |_| {
vec![
HRow::Style,
HRow::Page("Resource packs", "Textures, sounds and fonts", Page::Packs),
]
},
},
];
const NAV: u64 = id("options.nav", 0);
const DONE: u64 = id("options.done", 0);
const ROW: u64 = id("options.row", 0);
const STYLE: u64 = id("options.style", 0);
const ROW_H: f32 = 12.0 + CONTROL + 12.0;
const HEADER_H: f32 = 40.0;
/// Whether an option is an on/off switch, and its value.
fn switch_value(opt: Opt, s: &Settings) -> Option<bool> {
Some(match opt {
Opt::InvertY => s.invert_y,
Opt::DiscreteScroll => s.discrete_scroll,
Opt::ViewBobbing => s.view_bobbing,
Opt::Vsync => s.vsync,
Opt::Fullscreen => s.fullscreen,
Opt::SmoothLighting => s.smooth_lighting,
Opt::EntityShadows => s.entity_shadows,
Opt::Vignette => s.vignette,
Opt::Autosave => s.autosave_indicator,
Opt::CutoutLeaves => s.cutout_leaves,
Opt::ImprovedTransparency => s.improved_transparency,
Opt::AutoJump => s.auto_jump,
Opt::ChatColors => s.chat_colors,
Opt::ChatLinks => s.chat_links,
Opt::AutoSuggestions => s.auto_suggestions,
Opt::ReducedDebugInfo => s.reduced_debug_info,
Opt::HighContrast => s.high_contrast,
Opt::ShowSubtitles => s.show_subtitles,
Opt::ForceUnicode => s.force_unicode,
Opt::JapaneseGlyphs => s.japanese_glyphs,
_ => return None,
})
}
/// The option's caption and value text, from its vanilla label
/// ("Caption: Value") and its caption key.
fn caption_value(opt: Opt, s: &Settings) -> (String, String) {
let label = opt.label(s).plain();
let caption = crate::text::Text::translate(opt.caption(), &[]).plain();
if let Some(rest) = label.strip_prefix(&caption) {
return (caption, rest.trim_start_matches([':', ' ']).to_owned());
}
match label.split_once(": ") {
Some((c, v)) => (c.to_owned(), v.to_owned()),
None => (caption, label),
}
}
/// What a click hit.
enum Target {
Nav(usize),
Done,
Row(usize),
Slider(Opt, f32),
Style(usize),
}
impl OptionsUi {
/// Lay out (and draw, on a real frame) the Horizon options shell.
/// Returns the sliders' tracks (option, x, width) for dragging.
fn horizon_layout(&mut self, s: &Settings, ui: &mut Ui, backdrop: f32) -> Vec<(Opt, f32, f32)> {
let (w, h) = (ui.w, ui.h);
if self.in_world {
ui.atmosphere(0.45, 1.0);
} else {
if backdrop < 1.0 {
ui.cold_background(1.0 - backdrop);
}
ui.atmosphere(0.66, 1.0);
}
let section = self.section.min(SECTIONS.len() - 1);
// The sidebar.
ui.fill(0.0, 0.0, SIDEBAR, h, color::with_alpha(color::SIDEBAR, 0.8));
ui.vline(SIDEBAR - 1.0, 0.0, h, color::BORDER);
ui.text_line(
Face::Display,
22.0,
30.0,
22.0,
16.0,
color::TEXT,
"Options",
);
let names: Vec<&str> = SECTIONS.iter().map(|s| s.name).collect();
ui.sidebar_list(NAV, 12.0, 66.0, SIDEBAR - 24.0, &names, section);
let dy = h - 12.0 - CONTROL;
ui.button(
DONE,
[12.0, dy, SIDEBAR - 24.0, CONTROL],
"Done",
Button::Secondary,
true,
);
// The page fades in and rises 8 when the section changes.
let p = ui.tween(id("options.page", section as u64), 1.0, t::move_());
if self.section_shown != Some(section) {
self.section_shown = Some(section);
ui.restart(id("options.page", section as u64), 0.0, 1.0, t::move_());
}
let x = SIDEBAR + 48.0;
let cw = (w - x - 48.0).min(760.0);
ui.opacity = p;
ui.offset = (0.0, 8.0 * (1.0 - p));
let sec = &SECTIONS[section];
let mut y = 36.0;
y += ui.page_header(x, y, sec.name, sec.blurb) + 24.0;
let top = y;
let rows = (sec.rows)(s);
let content: f32 = rows
.iter()
.map(|r| {
if matches!(r, HRow::Header(_)) {
HEADER_H
} else {
ROW_H
}
})
.sum();
let bottom = h - 24.0;
let max = (content - (bottom - top)).max(0.0);
self.hscroll = self.hscroll.clamp(0.0, max);
y -= self.hscroll;
let vstart = ui.batch.vertices.len();
let hstart = ui.hits.len();
let mut sliders = Vec::new();
ui.divider(x, y, cw);
let control_x = x + cw;
for (i, row) in rows.iter().enumerate() {
match *row {
HRow::Header(label) => {
ui.text(Face::Medium, SMALL, x, y + 18.0, color::FAINT, label);
y += HEADER_H;
ui.divider(x, y, cw);
continue;
}
HRow::Opt(opt) => {
let (caption, value) = caption_value(opt, s);
let cy = y + ROW_H / 2.0;
if let Some(on) = switch_value(opt, s) {
ui.row(x, y, &caption, "", 0.0);
ui.switch(ROW + i as u64, control_x, cy, on);
} else if let Kind::Slider(v) = opt.kind(s) {
let sw = 220.0_f32.min(cw - ROW_LABEL - 200.0).max(80.0);
ui.row(x, y, &caption, &value, cw - ROW_LABEL - 16.0 - sw - 24.0);
let sx = control_x - sw - 8.0;
ui.slider(ROW + i as u64, sx, cy, sw, v);
sliders.push((opt, sx, sw));
} else {
ui.row(x, y, &caption, "", 0.0);
let bw = ui.button_width(&value, false).max(96.0);
ui.button(
ROW + i as u64,
[control_x - bw, y + 12.0, bw, CONTROL],
&value,
Button::Secondary,
true,
);
}
}
HRow::Page(label, value, _) => {
let value = if value.is_empty() {
self.languages
.iter()
.find(|l| l.code == s.language)
.map_or_else(|| s.language.clone(), |l| l.label())
} else {
value.to_owned()
};
ui.row(x, y, label, &value, cw - ROW_LABEL - 140.0);
let bw = ui.button_width("Change…", false);
let open = if label == "Language" {
"Change…"
} else {
"Open…"
};
let bw = bw.max(ui.button_width(open, false));
ui.button(
ROW + i as u64,
[control_x - bw, y + 12.0, bw, CONTROL],
open,
Button::Secondary,
true,
);
}
HRow::Style => {
ui.row(x, y, "Menu style", "", 0.0);
let labels = ["Horizon", "Classic"];
let probe = ui.segmented(STYLE, -10_000.0, -10_000.0, &labels, 0);
let selected = usize::from(t::style() == MenuStyle::Classic);
ui.segmented(STYLE, control_x - probe, y + 12.0 - 3.0, &labels, selected);
}
}
y += ROW_H;
ui.divider(x, y, cw);
}
// Clip the rows to the page below the header, like the launcher's scroll view.
let (ox, oy) = ui.offset;
let scale = ui.batch.scale;
let k = ui.density / scale;
let (y0, y1) = ((top - 1.0 + oy) * k, (bottom + oy) * k);
ui.batch.clip_since(vstart, 0.0, y0, ui.batch.width, y1);
let (cy0, cy1) = (top - 1.0 + oy, bottom + oy);
let kept: Vec<_> = ui
.hits
.drain(hstart..)
.filter(|h| h.rect[1] >= cy0 && h.rect[3] <= cy1)
.collect();
ui.hits.extend(kept);
if max > 0.0 {
// A thin scroll indicator.
let track = bottom - top;
let bar = (track * track / (track + max)).max(24.0);
let by = top + (track - bar) * (self.hscroll / max);
ui.rounded(
x + cw + 16.0,
by,
3.0,
bar,
1.5,
color::with_alpha(color::MUTED, 0.35),
);
}
let _ = ox;
ui.opacity = 1.0;
ui.offset = (0.0, 0.0);
sliders
}
pub(crate) fn draw_horizon(
&mut self,
s: &Settings,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
) {
let mut ui = Ui::new(atlas, batch, cursor);
let backdrop = t::backdrop();
self.horizon_layout(s, &mut ui, backdrop);
ui.finish();
}
fn horizon_target(&mut self, s: &Settings, atlas: &HudAtlas, x: f32, y: f32) -> Option<Target> {
let mut batch = t::scratch_batch();
let mut ui = Ui::dry(atlas, &mut batch, (x, y));
let sliders = self.horizon_layout(s, &mut ui, 1.0);
let hit = ui.hit((x, y))?;
let section = self.section.min(SECTIONS.len() - 1);
if (NAV..NAV + SECTIONS.len() as u64).contains(&hit) {
return Some(Target::Nav((hit - NAV) as usize));
}
if hit == DONE {
return Some(Target::Done);
}
if hit == STYLE || hit == STYLE + 1 {
return Some(Target::Style((hit - STYLE) as usize));
}
let i = hit.checked_sub(ROW)? as usize;
let rows = (SECTIONS[section].rows)(s);
if let Some(HRow::Opt(opt)) = rows.get(i)
&& let Some(&(_, sx, sw)) = sliders.iter().find(|(o, ..)| o == opt)
{
let v = t::slider_value(&ui, sx, sw, x);
return Some(Target::Slider(*opt, v));
}
(i < rows.len()).then_some(Target::Row(i))
}
/// A click (or a drag with the button held) on the Horizon shell.
pub(crate) fn click_horizon(
&mut self,
s: &mut Settings,
atlas: &HudAtlas,
x: f32,
y: f32,
drag: bool,
right: bool,
) -> Vec<Event> {
let mut events = Vec::new();
if drag {
if let Some(opt) = self.dragging {
let mut batch = t::scratch_batch();
let mut ui = Ui::dry(atlas, &mut batch, (x, y));
let sliders = self.horizon_layout(s, &mut ui, 1.0);
if let Some(&(_, sx, sw)) = sliders.iter().find(|(o, ..)| *o == opt) {
opt.set_slider(s, t::slider_value(&ui, sx, sw, x));
if opt == Opt::Preset {
apply_preset(s);
}
events.push(Event::Changed);
}
}
return events;
}
match self.horizon_target(s, atlas, x, y) {
Some(Target::Nav(i)) => {
self.section = i;
self.hscroll = 0.0;
}
Some(Target::Done) => events.extend(self.back(s)),
Some(Target::Style(i)) => {
t::set_style(if i == 1 {
MenuStyle::Classic
} else {
MenuStyle::Horizon
});
if let Ok(dir) = std::env::current_dir() {
t::save_preferences(&dir);
}
}
Some(Target::Slider(opt, v)) => {
self.dragging = Some(opt);
opt.set_slider(s, v);
if opt == Opt::Preset {
apply_preset(s);
}
events.push(Event::Changed);
}
Some(Target::Row(i)) => {
let section = self.section.min(SECTIONS.len() - 1);
match (SECTIONS[section].rows)(s).get(i) {
Some(HRow::Opt(opt)) => {
opt.cycle(s, right);
if *opt == Opt::Preset {
apply_preset(s);
}
events.push(Event::Changed);
}
Some(HRow::Page(_, _, page)) => self.open_page(*page),
_ => {}
}
}
None => {}
}
events
}
pub(crate) fn scroll_horizon(&mut self, amount: f32) {
self.hscroll -= amount * 40.0;
}
}
+27 -4
View File
@@ -92,13 +92,13 @@ fn chat_px(caption: &str, px: f32) -> Text {
&[tr(caption), Text::literal((px as i32).to_string())],
)
}
enum Kind {
pub(crate) enum Kind {
/// Slider position 0..1.
Slider(f32),
Cycle,
}
impl Opt {
fn caption(self) -> &'static str {
pub(crate) fn caption(self) -> &'static str {
match self {
Opt::Fov => "options.fov",
Opt::RenderDistance => "options.renderDistance",
@@ -155,7 +155,7 @@ impl Opt {
Opt::JapaneseGlyphs => "options.japaneseGlyphVariants",
}
}
fn kind(self, s: &Settings) -> Kind {
pub(crate) fn kind(self, s: &Settings) -> Kind {
let unit = |v: f32, lo: f32, hi: f32| Kind::Slider(((v - lo) / (hi - lo)).clamp(0.0, 1.0));
match self {
Opt::Fov => unit(s.fov, 30.0, 110.0),
@@ -590,7 +590,12 @@ pub enum Event {
pub struct OptionsUi {
pub stack: Vec<Page>,
scroll: f32,
dragging: Option<Opt>,
pub(crate) dragging: Option<Opt>,
/// Horizon style: the selected sidebar section, the one last drawn and
/// the rows' scroll in dp (`options_horizon`).
pub section: usize,
pub(crate) section_shown: Option<usize>,
pub(crate) hscroll: f32,
/// Key Binds: the mapping waiting for a key.
pub selected_key: Option<&'static str>,
pub languages: Vec<LanguageInfo>,
@@ -615,6 +620,9 @@ impl Default for OptionsUi {
stack: vec![Page::Main],
scroll: 0.0,
dragging: None,
section: 0,
section_shown: None,
hscroll: 0.0,
selected_key: None,
languages: Vec::new(),
language_choice: None,
@@ -693,6 +701,10 @@ impl OptionsUi {
self.in_world = in_world;
self.selected_key = None;
}
/// Open one of vanilla's pages over the current one.
pub(crate) fn open_page(&mut self, page: Page) {
self.push(page);
}
fn push(&mut self, page: Page) {
self.stack.push(page);
self.scroll = 0.0;
@@ -1175,6 +1187,10 @@ impl OptionsUi {
cursor: (f32, f32),
) {
let (w, h) = (batch.width, batch.height);
if crate::ui_theme::horizon() && self.page() == Page::Main {
self.draw_horizon(s, batch, atlas, cursor);
return;
}
if self.page() == Page::Packs {
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
let blink = self.pack_focus.elapsed().as_millis() as u64;
@@ -1324,6 +1340,9 @@ impl OptionsUi {
drag: bool,
right: bool,
) -> Vec<Event> {
if crate::ui_theme::horizon() && self.page() == Page::Main {
return self.click_horizon(s, atlas, x, y, drag, right);
}
if self.page() == Page::Packs {
let mut events = Vec::new();
if drag || right {
@@ -1427,6 +1446,10 @@ impl OptionsUi {
}
/// The mouse wheel at the cursor.
pub fn scroll_at(&mut self, cursor: (f32, f32), amount: f32) {
if crate::ui_theme::horizon() && self.page() == Page::Main {
self.scroll_horizon(amount);
return;
}
match &mut self.pack_screen {
Some(p) if self.stack.last() == Some(&Page::Packs) => {
p.scroll(cursor.0 as f64, cursor.1 as f64, amount as f64)
+273
View File
@@ -0,0 +1,273 @@
//! The menus' backdrop treatment (`ui_theme`'s Horizon style): the finished
//! world frame is blurred (dual Kawase: three halvings down, back up) and
//! colour graded before the HUD is drawn over it. Needs a swapchain that can
//! be copied from; without one the frame is left as it is.
/// How the frame under a Horizon-style menu is treated.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PostGrade {
/// Colour multiplier and lift (`c * tint + lift`).
pub tint: [f32; 3],
pub lift: [f32; 3],
/// Toward grey, 0..1.
pub desaturate: f32,
/// Darkening toward the corners, 0..1.
pub vignette: f32,
}
impl PostGrade {
/// The title backdrop: icy, slightly lifted shadows.
pub const FROST: Self = Self {
tint: [0.62, 0.78, 1.0],
lift: [0.0, 0.01, 0.035],
desaturate: 0.45,
vignette: 0.5,
};
/// Behind the pause menu: the world stays recognisable.
pub const PAUSE: Self = Self {
tint: [0.8, 0.86, 0.98],
lift: [0.0, 0.004, 0.012],
desaturate: 0.25,
vignette: 0.3,
};
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Uniform {
texel: [f32; 4],
tint: [f32; 4],
lift: [f32; 4],
params: [f32; 4],
}
const LEVELS: usize = 3;
pub struct Post {
layout: wgpu::BindGroupLayout,
down: wgpu::RenderPipeline,
up: wgpu::RenderPipeline,
finish: wgpu::RenderPipeline,
sampler: wgpu::Sampler,
/// The copied frame, then the halvings.
levels: Vec<(wgpu::Texture, wgpu::TextureView, [u32; 2])>,
uniforms: Vec<wgpu::Buffer>,
size: [u32; 2],
format: wgpu::TextureFormat,
}
impl Post {
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Menu backdrop"),
source: wgpu::ShaderSource::Wgsl(include_str!("post.wgsl").into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Menu backdrop bindings"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Menu backdrop"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = |entry: &str| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Menu backdrop pass"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
})
};
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Menu backdrop"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
let uniforms = (0..LEVELS * 2 + 1)
.map(|_| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Menu backdrop pass"),
size: std::mem::size_of::<Uniform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
})
.collect();
Self {
down: pipeline("fs_down"),
up: pipeline("fs_up"),
finish: pipeline("fs_final"),
layout,
sampler,
levels: Vec::new(),
uniforms,
size: [0, 0],
format,
}
}
fn ensure(&mut self, device: &wgpu::Device, size: [u32; 2]) {
if self.size == size && !self.levels.is_empty() {
return;
}
self.size = size;
self.levels = (0..=LEVELS)
.map(|i| {
let s = [(size[0] >> i).max(1), (size[1] >> i).max(1)];
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Menu backdrop level"),
size: wgpu::Extent3d {
width: s[0],
height: s[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.format,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
(texture, view, s)
})
.collect();
}
fn pass(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
pipeline: &wgpu::RenderPipeline,
(from, uniform): (usize, usize),
target: &wgpu::TextureView,
data: Uniform,
) {
let (_, view, s) = &self.levels[from];
let mut data = data;
data.texel = [0.5 / s[0] as f32, 0.5 / s[1] as f32, 0.0, 0.0];
queue.write_buffer(&self.uniforms[uniform], 0, bytemuck::bytes_of(&data));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu backdrop pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[uniform].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu backdrop pass"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
/// Blur and grade `frame` (the swapchain texture) into `target`.
#[allow(clippy::too_many_arguments)]
pub fn apply(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
frame: &wgpu::Texture,
target: &wgpu::TextureView,
size: [u32; 2],
grade: &PostGrade,
) {
self.ensure(device, size);
encoder.copy_texture_to_texture(
frame.as_image_copy(),
self.levels[0].0.as_image_copy(),
wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
);
let data = Uniform {
texel: [0.0; 4],
tint: [grade.tint[0], grade.tint[1], grade.tint[2], 1.0],
lift: [grade.lift[0], grade.lift[1], grade.lift[2], 0.0],
params: [grade.desaturate, grade.vignette, 0.0, 0.0],
};
let mut u = 0;
for i in 1..=LEVELS {
let target = &self.levels[i].1;
self.pass(device, queue, encoder, &self.down, (i - 1, u), target, data);
u += 1;
}
for i in (1..LEVELS).rev() {
let target = &self.levels[i].1;
self.pass(device, queue, encoder, &self.up, (i + 1, u), target, data);
u += 1;
}
self.pass(device, queue, encoder, &self.finish, (1, u), target, data);
}
}
+51
View File
@@ -0,0 +1,51 @@
// Menu backdrop: dual-Kawase blur (down and up passes) and a cold colour grade.
struct Post {
// Half a source texel (u, v), unused, unused.
texel: vec4<f32>,
// Multiplier and lift of the grade.
tint: vec4<f32>,
lift: vec4<f32>,
// Desaturation, vignette, unused, unused.
params: vec4<f32>,
}
@group(0) @binding(0) var src: texture_2d<f32>;
@group(0) @binding(1) var samp: sampler;
@group(0) @binding(2) var<uniform> post: Post;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex fn vs_main(@builtin(vertex_index) i: u32) -> Out {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: Out;
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2(p.x, 1.0 - p.y);
return out;
}
fn s(uv: vec2<f32>) -> vec3<f32> {
return textureSampleLevel(src, samp, uv, 0.0).rgb;
}
@fragment fn fs_down(in: Out) -> @location(0) vec4<f32> {
let h = post.texel.xy;
let c = s(in.uv) * 4.0 + s(in.uv - h) + s(in.uv + h) + s(in.uv + vec2(h.x, -h.y)) + s(in.uv - vec2(h.x, -h.y));
return vec4(c / 8.0, 1.0);
}
fn up(uv: vec2<f32>) -> vec3<f32> {
let h = post.texel.xy;
var c = s(uv + vec2(-h.x * 2.0, 0.0)) + s(uv + vec2(h.x * 2.0, 0.0));
c += s(uv + vec2(0.0, -h.y * 2.0)) + s(uv + vec2(0.0, h.y * 2.0));
c += (s(uv + vec2(-h.x, h.y)) + s(uv + vec2(h.x, h.y)) + s(uv + vec2(-h.x, -h.y)) + s(uv + vec2(h.x, -h.y))) * 2.0;
return c / 12.0;
}
@fragment fn fs_up(in: Out) -> @location(0) vec4<f32> {
return vec4(up(in.uv), 1.0);
}
@fragment fn fs_final(in: Out) -> @location(0) vec4<f32> {
var c = up(in.uv);
let l = dot(c, vec3(0.2126, 0.7152, 0.0722));
c = mix(c, vec3(l), post.params.x);
c = c * post.tint.rgb + post.lift.rgb;
let d = length((in.uv - 0.5) * vec2(1.25, 1.0));
c *= 1.0 - post.params.y * smoothstep(0.35, 0.95, d);
return vec4(clamp(c, vec3(0.0), vec3(1.0)), 1.0);
}
-11
View File
@@ -445,17 +445,6 @@ impl SoundEngine {
self.instances.remove(&id);
}
}
/// Table sizes, for soak tests watching what the engine holds.
pub fn census(&self) -> serde_json::Value {
serde_json::json!({
"instances": self.instances.len(),
"playing": self.instance_to_channel.len(),
"handles": self.handles.len(),
"ticking": self.ticking.len(),
"queued": self.queued.len(),
"used": self.used,
})
}
pub fn instance(&self, id: InstanceId) -> Option<&SoundInstance> {
self.instances.get(&id)
}
+10 -27
View File
@@ -358,24 +358,20 @@ impl Streaming {
}
Done::Generated(mut chunk) => {
let key = (chunk.x, chunk.z);
world
.block_entities
.extend(std::mem::take(&mut chunk.block_entities));
world
.block_names
.extend(std::mem::take(&mut chunk.block_names));
let entities = std::mem::take(&mut chunk.entities);
if !entities.is_empty() {
world.loaded_entities.push(((chunk.x, chunk.z), entities));
}
self.generating.remove(&key);
self.finishing.remove(&key);
self.decorated.insert(key);
// A column that finished after the player moved away is
// dropped whole: its block entities and saved entities
// too, or they would stay in the world (and the entities
// spawn again, duplicated, when the column loads later).
if self.in_range(key.0, key.1, 2) || self.near_finished(key.0, key.1, 1) {
world
.block_entities
.extend(std::mem::take(&mut chunk.block_entities));
world
.block_names
.extend(std::mem::take(&mut chunk.block_names));
let entities = std::mem::take(&mut chunk.entities);
if !entities.is_empty() {
world.loaded_entities.push(((chunk.x, chunk.z), entities));
}
self.generated_total += 1;
// A chunk saved with its light needs no light job; its
// unlit neighbours still light themselves from it.
@@ -428,12 +424,6 @@ impl Streaming {
if self.meshing.get(&(x, sy, z)) != Some(&seq) {
continue;
}
// Its column unloaded while it was meshed: nothing would
// ever remove the uploaded section.
if !world.chunks.contains_key(&(x, z)) {
self.meshing.remove(&(x, sy, z));
continue;
}
self.meshing.remove(&(x, sy, z));
self.meshed_total += 1;
uploads.push(((x, sy, z), mesh));
@@ -459,13 +449,6 @@ impl Streaming {
}
self.lit.remove(key);
self.meshed.remove(key);
self.lighting.remove(key);
self.light_snapshots.remove(key);
}
if !far.is_empty() {
let far: HashSet<(i32, i32)> = far.iter().copied().collect();
self.meshing.retain(|k, _| !far.contains(&(k.0, k.2)));
world.dirty_sections.retain(|k| !far.contains(&(k.0, k.2)));
}
// Drop far proto chunks that are not checked out.
let areas: Vec<((i32, i32), i64)> =
+83 -40
View File
@@ -50,6 +50,12 @@ pub struct Title {
pub mode: Mode,
pub saves: PathBuf,
pub started: std::time::Instant,
/// The mode last drawn and when it appeared (Horizon's screen fades).
shown: std::cell::Cell<(std::mem::Discriminant<Mode>, std::time::Instant)>,
/// The launcher-style opening (Horizon style, first title of the session).
pub opening: std::cell::Cell<Option<crate::front::Opening>>,
/// The saves listed for Home, re-read every couple of seconds.
recent: std::cell::RefCell<Option<(std::time::Instant, Vec<WorldEntry>)>>,
}
struct Button {
x: f32,
@@ -96,10 +102,78 @@ pub fn list_worlds(saves: &Path) -> Vec<WorldEntry> {
}
impl Title {
pub fn new(saves: PathBuf) -> Self {
let mode = Mode::Main;
let now = std::time::Instant::now();
Self {
mode: Mode::Main,
shown: std::cell::Cell::new((std::mem::discriminant(&mode), now)),
mode,
saves,
started: std::time::Instant::now(),
started: now,
opening: std::cell::Cell::new(None),
recent: std::cell::RefCell::new(None),
}
}
/// The worlds, newest first, cached for two seconds (drawn every frame).
pub fn recent_worlds(&self) -> Vec<WorldEntry> {
let mut recent = self.recent.borrow_mut();
match &*recent {
Some((at, worlds)) if at.elapsed().as_secs() < 2 => worlds.clone(),
_ => {
let worlds = list_worlds(&self.saves);
*recent = Some((std::time::Instant::now(), worlds.clone()));
worlds
}
}
}
/// When the current mode first appeared.
pub fn entered(&self) -> std::time::Instant {
let now = std::mem::discriminant(&self.mode);
let (mode, at) = self.shown.get();
if mode == now {
return at;
}
let at = std::time::Instant::now();
self.shown.set((now, at));
at
}
/// Create New World: the folder name and options (FileUtil.findAvailableName).
pub fn create_action(&self) -> Action {
let Mode::Create {
name,
seed,
creative,
..
} = &self.mode
else {
return Action::None;
};
let base: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let base = if base.trim().is_empty() {
"World".to_owned()
} else {
base.trim().to_owned()
};
// FileUtil.findAvailableName: append (n) until unused.
let mut folder = base.clone();
let mut n = 1;
while self.saves.join(&folder).exists() {
folder = format!("{base} ({n})");
n += 1;
}
Action::Create {
folder,
name: name.clone(),
seed: parse_seed(seed),
creative: *creative,
}
}
fn buttons(&self, w: f32, h: f32) -> Vec<Button> {
@@ -446,44 +520,7 @@ impl Title {
*creative = !*creative;
Action::None
}
(
Mode::Create {
name,
seed,
creative,
..
},
Some(21),
) => {
let base: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let base = if base.trim().is_empty() {
"World".to_owned()
} else {
base.trim().to_owned()
};
// FileUtil.findAvailableName: append (n) until unused.
let mut folder = base.clone();
let mut n = 1;
while self.saves.join(&folder).exists() {
folder = format!("{base} ({n})");
n += 1;
}
Action::Create {
folder,
name: name.clone(),
seed: parse_seed(seed),
creative: *creative,
}
}
(Mode::Create { .. }, Some(21)) => self.create_action(),
(Mode::Create { .. }, Some(22)) => {
self.mode = Mode::Select {
worlds: list_worlds(&self.saves),
@@ -510,6 +547,7 @@ impl Title {
if let Mode::Create {
name, seed, focus, ..
} = &mut self.mode
&& *focus < 2
{
let field = if *focus == 0 { name } else { seed };
for c in text.chars().filter(|c| !c.is_control()) {
@@ -526,6 +564,7 @@ impl Title {
if let Mode::Create {
name, seed, focus, ..
} = &mut self.mode
&& *focus < 2
{
let field = if *focus == 0 { name } else { seed };
field.pop();
@@ -554,6 +593,10 @@ impl Title {
};
}
}
/// `yyyy/MM/dd HH:mm` (UTC) for a modification time in Unix seconds.
pub fn date_label(secs: u64) -> String {
date_string(secs)
}
fn date_string(secs: u64) -> String {
// Civil date from Unix time (days-from-civil inverse), UTC.
let days = (secs / 86400) as i64;
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "horizon-theme"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "Horizon's design tokens and fonts, shared by the launcher and the game's menus"
[dependencies]
File renamed without changes.
+120
View File
@@ -0,0 +1,120 @@
//! Horizon's design tokens, shared by the launcher (`launcher/src/theme.rs`)
//! and the game's menus (`horizon-client`'s `ui_theme`), so the two read as
//! one product: change a value here and both follow.
//!
//! One neutral palette in two appearances and one accent. Spacing follows
//! 4/8/12/16/24/32. Type: Geist for the interface, Instrument Serif for
//! titles, Geist Mono for consoles and commands, all bundled here under the
//! SIL Open Font License (`fonts/OFL-*.txt`). Five sizes: 12, 13, 15, 28 and 56.
//! Colours are `0xRRGGBB`; sizes are in logical pixels.
use std::time::Duration;
pub const FONT: &str = "Geist";
pub const DISPLAY: &str = "Instrument Serif";
pub const MONO: &str = "Geist Mono";
/// The bundled font files.
pub mod fonts {
pub const GEIST_REGULAR: &[u8] = include_bytes!("../fonts/Geist-Regular.ttf");
pub const GEIST_MEDIUM: &[u8] = include_bytes!("../fonts/Geist-Medium.ttf");
pub const GEIST_SEMIBOLD: &[u8] = include_bytes!("../fonts/Geist-SemiBold.ttf");
pub const GEIST_MONO: &[u8] = include_bytes!("../fonts/GeistMono-Regular.ttf");
pub const INSTRUMENT_SERIF: &[u8] = include_bytes!("../fonts/InstrumentSerif-Regular.ttf");
}
pub const SMALL: f32 = 12.;
pub const BODY: f32 = 13.;
pub const LARGE: f32 = 15.;
pub const TITLE: f32 = 28.;
pub const HERO: f32 = 56.;
/// The opening's wordmark.
pub const WORDMARK: f32 = 96.;
/// The spacing scale.
pub const SPACE: [f32; 6] = [4., 8., 12., 16., 24., 32.];
/// Corner radii: buttons, fields, nav items and rows; segments inside a
/// segmented control; the segmented control's frame; menus and cards.
pub const RADIUS: f32 = 6.;
pub const RADIUS_SEGMENT: f32 = 5.;
pub const RADIUS_GROUP: f32 = 7.;
pub const RADIUS_CARD: f32 = 8.;
/// Dividers and borders are one pixel.
pub const HAIRLINE: f32 = 1.;
/// Control heights: buttons and nav items, segments, the large Play button.
pub const CONTROL: f32 = 30.;
pub const SEGMENT: f32 = 26.;
pub const CONTROL_LARGE: f32 = 40.;
/// A pressed primary button's opacity (secondary and ghost: `PRESSED_SOFT`).
pub const PRESSED: f32 = 0.82;
pub const PRESSED_SOFT: f32 = 0.8;
/// Disabled controls' opacity.
pub const DISABLED: f32 = 0.4;
/// The hover layer of a primary button: white at this opacity.
pub const PRIMARY_HOVER: f32 = 0.10;
/// Sidebar width and the label column of settings rows.
pub const SIDEBAR: f32 = 200.;
pub const ROW_LABEL: f32 = 150.;
/// Motion: short and eased out, never bouncy.
pub const QUICK: Duration = Duration::from_millis(160);
pub const MOVE: Duration = Duration::from_millis(240);
/// The opening: the wordmark holds, then the interface appears.
pub const OPENING_HOLD: Duration = Duration::from_millis(1400);
pub const OPENING_MAX: Duration = Duration::from_millis(2500);
pub const OPENING_REVEAL: Duration = Duration::from_millis(800);
/// GPUI's `ease_out_quint`, used for hovers, the sliding selection and page changes.
pub fn ease_out_quint(t: f32) -> f32 {
1. - (1. - t.clamp(0., 1.)).powi(5)
}
pub fn ease_out_cubic(t: f32) -> f32 {
1. - (1. - t.clamp(0., 1.)).powi(3)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Palette {
pub bg: u32,
pub sidebar: u32,
pub raised: u32,
pub hover: u32,
pub border: u32,
pub text: u32,
pub muted: u32,
pub faint: u32,
pub accent: u32,
pub on_accent: u32,
pub danger: u32,
pub dark: bool,
}
pub const DARK: Palette = Palette {
bg: 0x111113,
sidebar: 0x0c0c0d,
raised: 0x1a1a1d,
hover: 0x202024,
border: 0x26262a,
text: 0xe8e8ea,
muted: 0x9a9aa1,
faint: 0x626269,
accent: 0x7c5cc4,
on_accent: 0xffffff,
danger: 0xd9705f,
dark: true,
};
pub const LIGHT: Palette = Palette {
bg: 0xfcfcfb,
sidebar: 0xf4f4f2,
raised: 0xf2f2f0,
hover: 0xebebe8,
border: 0xe3e3df,
text: 0x19191b,
muted: 0x63636a,
faint: 0x9a9a9f,
accent: 0x6a4bb3,
on_accent: 0xffffff,
danger: 0xb84a3a,
dark: false,
};
+1
View File
@@ -7,6 +7,7 @@ To play, use [the launcher](../launcher/README.md). These pages are for develope
- [Build from source](../README.md#build-from-source): your own copy of the game, resource preparation, `cargo run`.
- [Native client](native-client.md): options, controls, the dedicated server, `horizon-ctl` scripting, packaging, architecture.
- [Launcher](../launcher/README.md): installs, updates and starts builds; its CLI and MCP server; releasing it.
- Menus: the title, world list, create world, multiplayer, options and pause screens share the launcher's design tokens and fonts (`crates/horizon-theme`), drawn by `ui_theme` over a live frozen backdrop (`game/backdrop.rs`, blurred and graded by `post.rs`). Options → Interface → Menu style switches to vanilla's screens (Classic, kept in `horizon.txt`; `HORIZON_MENU_STYLE=horizon|classic` overrides it, and scripted runs default to Classic). [Title](media/menu-title.png) ([webm](media/menu-title.webm)), [worlds](media/menu-worlds.png), [options](media/menu-options.png), [pause](media/menu-pause.png).
## Parity
Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

-9
View File
@@ -55,15 +55,6 @@ Worlds autosave every 6000 ticks (5 minutes), when chunks unload and on quit.
On headless Linux use `vscreen start ./target/release/horizon-client` and
`vscreen shot`.
Sound: the game prints the output it opened (`Horizon: audio output ...`) or
why it has none (`Horizon: no sound: ...`). It opens none with
`HORIZON_NO_AUDIO` set, nor on a `vscreen`, where tests run beside someone
listening; `HORIZON_AUDIO=1` opens it there anyway, so send it to a null sink
(`PIPEWIRE_PROPS='{ target.object=<sink> node.dont-fallback=true }'`). Don't
mute a test run's stream in the mixer: PipeWire remembers the mute for every
later `horizon-client`. The launcher starts the game as "Horizon" for that
reason.
### Drive the game from scripts (`horizon-ctl`)
`--control` opens a local Unix socket (`$XDG_RUNTIME_DIR/horizon-control.sock`,
+5
View File
@@ -2571,6 +2571,7 @@ dependencies = [
"dirs 5.0.1",
"flate2",
"gpui",
"horizon-theme",
"image",
"pulldown-cmark",
"self-replace",
@@ -2581,6 +2582,10 @@ dependencies = [
"zip",
]
[[package]]
name = "horizon-theme"
version = "0.1.0"
[[package]]
name = "http"
version = "1.5.0"
+1
View File
@@ -20,6 +20,7 @@ dirs = "5"
self-replace = "1"
image = { version = "0.25", default-features = false, features = ["jpeg"] }
clap = { version = "4", features = ["derive"] }
horizon-theme = { path = "../crates/horizon-theme" }
[profile.release]
strip = true
+1 -1
View File
@@ -87,7 +87,7 @@ workflow builds all three platforms on every push to the `launcher` branch.
cargo test --lib # tag separation, version order, update selection, notes as text
Geist, Geist Mono and Instrument Serif (SIL Open Font License, `assets/fonts/OFL-*.txt`) are
Geist, Geist Mono and Instrument Serif (SIL Open Font License, `crates/horizon-theme/fonts/OFL-*.txt`, shared with the game's menus) are
bundled so the type is the same on every platform.
Background image (`assets/background.jpg`): backgrounds.supply (free preview), licence to confirm before release.
-16
View File
@@ -351,12 +351,6 @@ pub fn client_command(tag: &str) -> Command {
let _ = fs::create_dir_all(&home);
let mut cmd = Command::new(version_dir(tag).join(CLIENT));
cmd.current_dir(home).arg("--resources").arg(resources(tag));
// PipeWire remembers volume and mute per application name. The game's own name is
// "PipeWire ALSA [horizon-client]", shared with development and test runs, where
// muting it once left every later game silent: played from here it is "Horizon".
if std::env::var_os("PIPEWIRE_PROPS").is_none() {
cmd.env("PIPEWIRE_PROPS", "{ application.name = \"Horizon\" media.role = \"Game\" }");
}
cmd
}
@@ -805,16 +799,6 @@ mod tests {
use super::*;
use std::cmp::Ordering::*;
#[test]
fn the_game_plays_under_its_own_audio_stream_name() {
if std::env::var_os("PIPEWIRE_PROPS").is_some() {
return;
}
let cmd = client_command("v2026.10.13");
let props = cmd.get_envs().find(|(k, _)| *k == "PIPEWIRE_PROPS").and_then(|(_, v)| v);
assert!(props.is_some_and(|v| v.to_string_lossy().contains("application.name = \"Horizon\"")));
}
#[test]
fn tags_never_mix() {
assert!(is_game_tag("v2026.10.13"));
+28 -28
View File
@@ -53,9 +53,9 @@ enum SelfUpdate {
/// The opening: the wordmark over the image holds at least `OPENING_HOLD` (until the release
/// list is in, at most `OPENING_MAX`), then the launcher appears over `OPENING_REVEAL`.
const OPENING_HOLD: Duration = Duration::from_millis(1400);
const OPENING_MAX: Duration = Duration::from_millis(2500);
const OPENING_REVEAL: Duration = Duration::from_millis(800);
const OPENING_HOLD: Duration = horizon_theme::OPENING_HOLD;
const OPENING_MAX: Duration = horizon_theme::OPENING_MAX;
const OPENING_REVEAL: Duration = horizon_theme::OPENING_REVEAL;
/// The phase of the opening at one moment: how far the reveal has gone, 0 to 1.
struct Opening {
@@ -78,7 +78,7 @@ fn background_image() -> Arc<RenderImage> {
}
fn ease_out_cubic(x: f32) -> f32 {
1. - (1. - x.clamp(0., 1.)).powi(3)
horizon_theme::ease_out_cubic(x)
}
/// How often the launcher looks for new releases while it is open.
@@ -560,7 +560,7 @@ fn button(id: impl Into<SharedString>, label: impl Into<SharedString>, kind: Kin
let id: SharedString = id.into();
let t = *t;
let hover = match kind {
Kind::Primary => gpui::white().opacity(0.10),
Kind::Primary => gpui::white().opacity(theme::PRIMARY_HOVER),
_ => t.hover,
};
let base = div()
@@ -570,19 +570,19 @@ fn button(id: impl Into<SharedString>, label: impl Into<SharedString>, kind: Kin
.flex_none()
.items_center()
.justify_center()
.h(px(30.))
.h(px(theme::CONTROL))
.px(px(12.))
.rounded(px(6.))
.rounded(px(theme::RADIUS))
.text_size(px(theme::BODY))
.font_weight(FontWeight::MEDIUM)
.cursor_pointer()
.child(hover_layer(&id, hover, 6.))
.child(hover_layer(&id, hover, theme::RADIUS))
.child(div().relative().child(label.into()));
let base = track_hover(base, id);
match kind {
Kind::Primary => base.bg(t.accent).text_color(t.on_accent).active(|s| s.opacity(0.82)),
Kind::Secondary => base.border_1().border_color(t.border).bg(t.raised).text_color(t.text).active(|s| s.opacity(0.8)),
Kind::Ghost => base.text_color(t.muted).hover(move |s| s.text_color(t.text)).active(|s| s.opacity(0.8)),
Kind::Primary => base.bg(t.accent).text_color(t.on_accent).active(|s| s.opacity(theme::PRESSED)),
Kind::Secondary => base.border_1().border_color(t.border).bg(t.raised).text_color(t.text).active(|s| s.opacity(theme::PRESSED_SOFT)),
Kind::Ghost => base.text_color(t.muted).hover(move |s| s.text_color(t.text)).active(|s| s.opacity(theme::PRESSED_SOFT)),
}
}
@@ -607,7 +607,7 @@ fn switch(id: &'static str, on: bool, t: &Theme) -> Stateful<Div> {
}
fn disabled(b: Stateful<Div>) -> Stateful<Div> {
b.opacity(0.4).cursor_default()
b.opacity(theme::DISABLED).cursor_default()
}
fn page_header(title: &str, description: &str, t: &Theme) -> Div {
@@ -637,7 +637,7 @@ fn row(label: &str, value: impl IntoElement, action: Option<Stateful<Div>>, t: &
.items_center()
.gap(px(16.))
.py(px(12.))
.child(div().w(px(150.)).flex_none().text_size(px(13.)).text_color(t.muted).child(label.to_string()))
.child(div().w(px(theme::ROW_LABEL)).flex_none().text_size(px(13.)).text_color(t.muted).child(label.to_string()))
.child(div().flex_1().min_w_0().text_size(px(13.)).text_color(t.text).child(value))
.children(action)
}
@@ -687,7 +687,7 @@ fn megabytes(bytes: u64) -> String {
impl Launcher {
fn sidebar(&self, cx: &mut Context<Self>) -> Div {
let t = self.theme;
const STEP: f32 = 32.;
const STEP: f32 = theme::CONTROL + 2.;
let mut nav = div().relative().flex().flex_col().gap(px(2.));
// The selection is one pill that slides to the chosen item.
let (from, to) = (self.previous_page.index() as f32 * STEP, self.page.index() as f32 * STEP);
@@ -696,8 +696,8 @@ impl Launcher {
.absolute()
.left_0()
.w_full()
.h(px(30.))
.rounded(px(6.))
.h(px(theme::CONTROL))
.rounded(px(theme::RADIUS))
.bg(t.raised)
.border_1()
.border_color(t.border)
@@ -715,12 +715,12 @@ impl Launcher {
.relative()
.flex()
.items_center()
.h(px(30.))
.h(px(theme::CONTROL))
.px(px(10.))
.rounded(px(6.))
.rounded(px(theme::RADIUS))
.text_size(px(theme::BODY))
.cursor_pointer()
.when(!active, |d| d.child(hover_layer(&id, t.hover.opacity(0.7), 6.)))
.when(!active, |d| d.child(hover_layer(&id, t.hover.opacity(0.7), theme::RADIUS)))
.child(
div()
.relative()
@@ -756,7 +756,7 @@ impl Launcher {
.flex()
.flex_col()
.flex_none()
.w(px(200.))
.w(px(theme::SIDEBAR))
.h_full()
.bg(t.sidebar.opacity(0.8))
.border_r_1()
@@ -785,9 +785,9 @@ impl Launcher {
.id(label)
.flex()
.items_center()
.h(px(26.))
.h(px(theme::SEGMENT))
.px(px(14.))
.rounded(px(5.))
.rounded(px(theme::RADIUS_SEGMENT))
.text_size(px(13.))
.cursor_pointer()
.when(active, |d| {
@@ -804,7 +804,7 @@ impl Launcher {
.flex()
.p(px(2.))
.gap(px(2.))
.rounded(px(7.))
.rounded(px(theme::RADIUS_GROUP))
.bg(t.raised)
.border_1()
.border_color(t.border)
@@ -1769,11 +1769,11 @@ fn main() {
Application::new().run(|cx: &mut App| {
cx.text_system()
.add_fonts(vec![
Cow::Borrowed(include_bytes!("../assets/fonts/Geist-Regular.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/Geist-Medium.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/Geist-SemiBold.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/GeistMono-Regular.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/InstrumentSerif-Regular.ttf").as_slice()),
Cow::Borrowed(horizon_theme::fonts::GEIST_REGULAR),
Cow::Borrowed(horizon_theme::fonts::GEIST_MEDIUM),
Cow::Borrowed(horizon_theme::fonts::GEIST_SEMIBOLD),
Cow::Borrowed(horizon_theme::fonts::GEIST_MONO),
Cow::Borrowed(horizon_theme::fonts::INSTRUMENT_SERIF),
])
.expect("bundled fonts");
let bounds = Bounds::centered(None, size(px(1040.), px(700.)), cx);
+37 -39
View File
@@ -1,22 +1,29 @@
//! One neutral palette in two appearances and one accent. Spacing follows 4/8/12/16/24/32.
//! Type: Geist for the interface, Instrument Serif for titles, Geist Mono for consoles and
//! commands, all bundled (SIL Open Font License). Five sizes: 12, 13, 15, 28 and 56.
//! The launcher's view of Horizon's design tokens (`crates/horizon-theme`), which the game's
//! menus share: one neutral palette in two appearances and one accent, spacing 4/8/12/16/24/32,
//! Geist / Instrument Serif / Geist Mono (bundled, SIL Open Font License), five sizes.
use gpui::{rgb, Hsla, WindowAppearance};
use horizon_theme as tokens;
use std::time::Duration;
pub const FONT: &str = "Geist";
pub const DISPLAY: &str = "Instrument Serif";
pub const MONO: &str = "Geist Mono";
pub const FONT: &str = tokens::FONT;
pub const DISPLAY: &str = tokens::DISPLAY;
pub const MONO: &str = tokens::MONO;
pub const SMALL: f32 = 12.;
pub const BODY: f32 = 13.;
pub const LARGE: f32 = 15.;
pub const TITLE: f32 = 28.;
pub const HERO: f32 = 56.;
pub const SMALL: f32 = tokens::SMALL;
pub const BODY: f32 = tokens::BODY;
pub const LARGE: f32 = tokens::LARGE;
pub const TITLE: f32 = tokens::TITLE;
pub const HERO: f32 = tokens::HERO;
/// Shapes and controls, shared with the game's menus.
pub use tokens::{
CONTROL, CONTROL_LARGE, DISABLED, PRESSED, PRESSED_SOFT, PRIMARY_HOVER, RADIUS, RADIUS_CARD, RADIUS_GROUP,
RADIUS_SEGMENT, ROW_LABEL, SEGMENT, SIDEBAR,
};
/// Motion: short and eased out, never bouncy.
pub const QUICK: Duration = Duration::from_millis(160);
pub const MOVE: Duration = Duration::from_millis(240);
pub const QUICK: Duration = tokens::QUICK;
pub const MOVE: Duration = tokens::MOVE;
#[derive(Clone, Copy)]
pub struct Theme {
@@ -47,36 +54,27 @@ impl Theme {
}
pub fn dark() -> Self {
Self {
bg: c(0x111113),
sidebar: c(0x0c0c0d),
raised: c(0x1a1a1d),
hover: c(0x202024),
border: c(0x26262a),
text: c(0xe8e8ea),
muted: c(0x9a9aa1),
faint: c(0x626269),
accent: c(0x7c5cc4),
on_accent: c(0xffffff),
danger: c(0xd9705f),
dark: true,
}
Self::from(tokens::DARK)
}
pub fn light() -> Self {
Self::from(tokens::LIGHT)
}
fn from(p: tokens::Palette) -> Self {
Self {
bg: c(0xfcfcfb),
sidebar: c(0xf4f4f2),
raised: c(0xf2f2f0),
hover: c(0xebebe8),
border: c(0xe3e3df),
text: c(0x19191b),
muted: c(0x63636a),
faint: c(0x9a9a9f),
accent: c(0x6a4bb3),
on_accent: c(0xffffff),
danger: c(0xb84a3a),
dark: false,
bg: c(p.bg),
sidebar: c(p.sidebar),
raised: c(p.raised),
hover: c(p.hover),
border: c(p.border),
text: c(p.text),
muted: c(p.muted),
faint: c(p.faint),
accent: c(p.accent),
on_accent: c(p.on_accent),
danger: c(p.danger),
dark: p.dark,
}
}
}
-143
View File
@@ -1,143 +0,0 @@
#!/usr/bin/env python3
"""Fail when a release binary needs a library its players may not have.
Windows .exe: every DLL in the import and delay-import tables must ship with
Windows (no VCRUNTIME140.dll, vulkan-1.dll, ...): Vulkan and the like are
loaded at run time, so a missing driver is an error message, not a dialog.
Linux ELF: the loader must be the standard one (not a /nix/store path) and the
needed libraries the ones every desktop distribution has.
"""
import argparse
import struct
import sys
from pathlib import Path
# DLLs that are part of Windows 10 and 11. api-ms-win-* and ext-ms-win-* API
# sets resolve inside Windows too.
WINDOWS_SYSTEM = {
"advapi32.dll", "bcrypt.dll", "bcryptprimitives.dll", "cfgmgr32.dll", "combase.dll",
"comctl32.dll", "comdlg32.dll", "crypt32.dll", "d2d1.dll", "d3d11.dll", "d3d12.dll",
"d3dcompiler_47.dll", "dbghelp.dll", "dcomp.dll", "dwmapi.dll", "dwrite.dll", "dxgi.dll",
"gdi32.dll", "hid.dll", "icuuc.dll", "imm32.dll", "iphlpapi.dll", "kernel32.dll",
"kernelbase.dll", "mfplat.dll", "mmdevapi.dll", "msvcrt.dll", "ncrypt.dll", "ntdll.dll",
"ole32.dll", "oleaut32.dll", "opengl32.dll", "propsys.dll", "secur32.dll", "setupapi.dll",
"shcore.dll", "shell32.dll", "shlwapi.dll", "ucrtbase.dll", "user32.dll", "userenv.dll",
"uxtheme.dll", "version.dll", "winmm.dll", "ws2_32.dll", "xinput1_4.dll",
}
LINUX_LOADER = "/lib64/ld-linux-x86-64.so.2"
LINUX_SYSTEM = {
"libc.so.6", "libm.so.6", "libgcc_s.so.1", "libdl.so.2", "libpthread.so.0", "librt.so.1",
"ld-linux-x86-64.so.2", "libasound.so.2",
}
def windows_allowed(name):
name = name.lower()
return name in WINDOWS_SYSTEM or name.startswith(("api-ms-win-", "ext-ms-win-"))
def pe_imports(data):
"""DLL names in a PE32+ file's import and delay-import tables."""
pe = struct.unpack_from("<I", data, 0x3C)[0]
if data[pe:pe + 4] != b"PE\0\0":
raise ValueError("not a PE file")
sections_count, optional_size = struct.unpack_from("<H12xH", data, pe + 6)
optional = pe + 24
magic = struct.unpack_from("<H", data, optional)[0]
directories = optional + (112 if magic == 0x20B else 96)
sections = [struct.unpack_from("<8xIIII", data, optional + optional_size + 40 * i)
for i in range(sections_count)]
def offset(rva):
for size, address, raw_size, raw in sections:
if address <= rva < address + max(size, raw_size):
return rva - address + raw
raise ValueError(f"RVA {rva:#x} outside every section")
def name_at(rva):
start = offset(rva)
return data[start:data.index(b"\0", start)].decode("ascii")
names = []
# Directory 1: imports (20-byte descriptors, name at +12); 13: delay imports (32 bytes, name at +4).
for index, size, name_field in ((1, 20, 12), (13, 32, 4)):
rva = struct.unpack_from("<I", data, directories + 8 * index)[0]
if not rva:
continue
at = offset(rva)
while any(data[at:at + size]):
names.append(name_at(struct.unpack_from("<I", data, at + name_field)[0]))
at += size
return names
def elf_needs(data):
"""The interpreter and DT_NEEDED libraries of a little-endian ELF64 file."""
if data[:4] != b"\x7fELF" or data[4] != 2:
raise ValueError("not an ELF64 file")
phoff, phentsize, phnum = struct.unpack_from("<Q", data, 32)[0], *struct.unpack_from("<HH", data, 54)
interpreter, dynamic, loads = None, None, []
for i in range(phnum):
kind, _, offset, vaddr, _, filesz = struct.unpack_from("<IIQQQQ", data, phoff + i * phentsize)
if kind == 3:
interpreter = data[offset:offset + filesz].rstrip(b"\0").decode()
elif kind == 2:
dynamic = (offset, filesz)
elif kind == 1:
loads.append((vaddr, offset, filesz))
def file_offset(address):
for vaddr, offset, size in loads:
if vaddr <= address < vaddr + size:
return address - vaddr + offset
raise ValueError(f"address {address:#x} outside every segment")
entries = []
if dynamic:
for at in range(dynamic[0], dynamic[0] + dynamic[1], 16):
tag, value = struct.unpack_from("<qQ", data, at)
if tag == 0:
break
entries.append((tag, value))
strtab = next((file_offset(v) for t, v in entries if t == 5), None)
def string(index):
start = strtab + index
return data[start:data.index(b"\0", start)].decode()
needed = [string(v) for t, v in entries if t == 1]
runpath = [string(v) for t, v in entries if t in (15, 29)]
return interpreter, needed, runpath
def check(path):
data = Path(path).read_bytes()
problems = []
if data[:2] == b"MZ":
names = pe_imports(data)
problems += [f"imports {n}, which Windows does not ship" for n in names if not windows_allowed(n)]
summary = ", ".join(sorted(set(n.lower() for n in names)))
else:
interpreter, needed, runpath = elf_needs(data)
if interpreter not in (None, LINUX_LOADER):
problems.append(f"loader is {interpreter}, not {LINUX_LOADER}")
problems += [f"needs {n}, which not every distribution has" for n in needed if n not in LINUX_SYSTEM]
problems += [f"has a run path to {p}" for p in runpath if "/nix/store" in p]
summary = f"{interpreter}; " + ", ".join(needed)
print(f"{path}: {summary}")
for problem in problems:
print(f" {path} {problem}", file=sys.stderr)
return not problems
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("binaries", nargs="+")
args = parser.parse_args()
ok = all([check(path) for path in args.binaries])
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()