mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9351ec054 | ||
|
|
eb18bb42af | ||
|
|
27a32cc30d |
No files matched your search
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]
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
# Horizon
|
||||
|
||||
A Rust rewrite of Minecraft: Java Edition 26.3 with one-to-one vanilla parity, checked against the game itself with differential tests.
|
||||
A Rust rewrite of Minecraft: Java Edition 26.3 built for one-to-one vanilla parity, checked against the game itself with differential tests.
|
||||
|
||||
Every subsystem matches vanilla in all 281 parity harnesses, on Linux and macOS: 100% of what the harnesses compare (see [Project status](#project-status)). The native client is playable on Linux, macOS (Apple Silicon) and Windows.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -1086,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,
|
||||
@@ -1853,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 |
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.
|
||||
|
||||
+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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user