mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-13 04:33:48 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9351ec054 | ||
|
|
eb18bb42af |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)> =
|
||||
|
||||
@@ -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
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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,
|
||||
};
|
||||
@@ -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 |
@@ -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`,
|
||||
|
||||
Generated
+5
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user