Compare commits

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

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

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

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

No files matched your search

Generated
+6
View File
@@ -681,12 +681,14 @@ dependencies = [
name = "horizon-client"
version = "0.1.0"
dependencies = [
"ab_glyph",
"bytemuck",
"cpal",
"glam",
"horizon-assets",
"horizon-core",
"horizon-protocol",
"horizon-theme",
"horizon-world",
"miniz_oxide 0.8.9",
"png",
@@ -713,6 +715,10 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-theme"
version = "0.1.0"
[[package]]
name = "horizon-world"
version = "0.1.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher"]
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol"]
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme"]
resolver = "2"
[workspace.package]
+2
View File
@@ -22,6 +22,8 @@ serde_json = { version = "1.0.151", features = ["raw_value"] }
cpal = "0.16"
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
horizon-theme = { path = "../horizon-theme" }
[features]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
+987
View File
@@ -0,0 +1,987 @@
//! The front-end screens in Horizon's menu style (`ui_theme`), laid out like
//! the launcher: a 200-wide sidebar with the sliding selection (Home,
//! Singleplayer, Multiplayer, Options), content pages with a page header,
//! settings rows and 1px dividers, and the launcher's opening (the wordmark,
//! then the interface). They drive the same state as the classic layouts in
//! `title` and `multiplayer`; only drawing and hit testing differ. The pause
//! menu's Horizon layout is `pause`, below.
use crate::{
hud::{HudAtlas, HudBatch},
multiplayer::{Outcome, Ping, Screen},
title::{Action, Mode, Title, list_worlds},
ui_theme::{
self as t, BODY, CONTROL, CONTROL_LARGE, Face, Kind, SIDEBAR, SMALL, Ui, color, id,
},
};
use std::time::Instant;
const NAV: u64 = id("title.nav", 0);
const QUIT: u64 = id("title.quit", 0);
const PLAY: u64 = id("home.play", 0);
const MULTIPLAYER: u64 = id("home.multiplayer", 0);
const RESUME: u64 = id("home.resume", 0);
const BACK: u64 = id("front.back", 0);
const PLAY_WORLD: u64 = id("worlds.play", 0);
const CREATE: u64 = id("worlds.create", 0);
const WORLD: u64 = id("worlds.row", 0);
const NAME: u64 = id("create.name", 0);
const SEED: u64 = id("create.seed", 0);
const MODE: u64 = id("create.mode", 0);
const MAKE: u64 = id("create.make", 0);
const SERVER: u64 = id("mp.row", 0);
const JOIN: u64 = id("mp.join", 0);
const DIRECT: u64 = id("mp.direct", 0);
const ADD: u64 = id("mp.add", 0);
const EDIT: u64 = id("mp.edit", 0);
const DELETE: u64 = id("mp.delete", 0);
const REFRESH: u64 = id("mp.refresh", 0);
const FIELD_A: u64 = id("mp.field", 0);
const FIELD_B: u64 = id("mp.field", 1);
const DONE: u64 = id("mp.done", 0);
/// The launcher's opening (`launcher/src/main.rs`): the wordmark holds over
/// the backdrop, then the interface comes in. A click or a key skips ahead.
#[derive(Clone, Copy, Debug)]
pub struct Opening {
started: Instant,
reveal: Option<Instant>,
}
impl Opening {
pub fn new() -> Self {
Self {
started: Instant::now(),
reveal: None,
}
}
/// The reveal's progress, 0 to 1; `ready` says the backdrop is showing.
fn phase(&mut self, ready: bool) -> f32 {
let held = self.started.elapsed();
if self.reveal.is_none()
&& ((held >= horizon_theme::OPENING_HOLD && ready)
|| held >= horizon_theme::OPENING_MAX)
{
self.reveal = Some(Instant::now());
}
self.reveal.map_or(0.0, |at| {
at.elapsed().as_secs_f32() / horizon_theme::OPENING_REVEAL.as_secs_f32()
})
}
pub fn skip(&mut self) {
self.reveal.get_or_insert_with(Instant::now);
}
}
impl Default for Opening {
fn default() -> Self {
Self::new()
}
}
/// Which sidebar item a mode belongs to.
fn page(mode: &Mode) -> usize {
match mode {
Mode::Main => 0,
Mode::Select { .. } | Mode::Create { .. } => 1,
Mode::Multiplayer(_) => 2,
}
}
const PAGES: [&str; 4] = ["Home", "Singleplayer", "Multiplayer", "Options"];
impl Title {
/// Horizon's layout for every title mode. `backdrop` is how much of the
/// live backdrop is visible (0 shows the plain cold background).
pub fn draw_horizon(&self, ui: &mut Ui, backdrop: f32) {
let reveal = self.opening_phase(backdrop >= 1.0);
if backdrop < 1.0 {
ui.cold_background(1.0 - backdrop);
}
let shade = t::ease_out_cubic(reveal);
let veil = if matches!(self.mode, Mode::Main) {
0.42
} else {
0.66
};
ui.atmosphere(veil * shade, shade);
let side = t::ease_out_cubic((reveal - 0.2) / 0.7);
let main = t::ease_out_cubic((reveal - 0.35) / 0.65);
// The sidebar slides in from the left.
ui.opacity = side;
ui.offset = (-16.0 * (1.0 - side), 0.0);
if side > 0.0 {
self.sidebar(ui);
}
// The content rises; each new page fades in and rises 8 over MOVE.
let entered = self.entered();
let p = t::ease_out_quint(entered.elapsed().as_secs_f32() / t::move_());
ui.opacity = main * p;
ui.offset = (0.0, 16.0 * (1.0 - main) + 8.0 * (1.0 - p));
if main > 0.0 {
let x = SIDEBAR + 48.0;
let w = (ui.w - x - 48.0).min(760.0);
match &self.mode {
Mode::Main => self.home(ui, x, w),
Mode::Select { worlds, selected } => worlds_page(ui, x, w, worlds, *selected),
Mode::Create {
name,
seed,
creative,
focus,
} => {
let caret = (self.started.elapsed().as_millis() / 500).is_multiple_of(2);
create_page(ui, x, w, name, seed, *creative, *focus, caret);
}
Mode::Multiplayer(screen) => {
let caret = (self.started.elapsed().as_millis() / 500).is_multiple_of(2);
multiplayer_page(ui, x, w, screen, caret);
}
}
}
ui.opacity = 1.0;
ui.offset = (0.0, 0.0);
if reveal < 1.0 {
// The wordmark rises and fades as the interface comes in.
let word = 1.0 - t::ease_out_cubic(reveal / 0.6);
ui.opacity = word;
let tw = ui.measure(Face::Display, t::WORDMARK, "Horizon");
let line = (t::WORDMARK * 1.2).round();
ui.text_line(
Face::Display,
t::WORDMARK,
line,
((ui.w - tw) / 2.0).round(),
(ui.h - line) / 2.0 - 28.0 * (1.0 - word),
color::WHITE,
"Horizon",
);
ui.opacity = 1.0;
}
}
fn opening_phase(&self, ready: bool) -> f32 {
let mut opening = self.opening.get();
let phase = opening.as_mut().map_or(1.0, |o| o.phase(ready));
self.opening.set(opening.filter(|_| phase < 1.0));
phase.min(1.0)
}
/// A click or key during the opening jumps to the reveal; true if it did.
pub fn skip_opening(&self) -> bool {
let mut opening = self.opening.get();
let skipped = opening.as_mut().is_some_and(|o| {
let holding = o.reveal.is_none();
o.skip();
holding
});
self.opening.set(opening);
skipped
}
fn sidebar(&self, ui: &mut Ui) {
let h = ui.h;
ui.fill(0.0, 0.0, SIDEBAR, h, color::with_alpha(color::SIDEBAR, 0.8));
ui.vline(SIDEBAR - 1.0, 0.0, h, color::BORDER);
ui.text_line(
Face::Display,
22.0,
30.0,
22.0,
16.0,
color::TEXT,
"Horizon",
);
ui.sidebar_list(NAV, 12.0, 66.0, SIDEBAR - 24.0, &PAGES, page(&self.mode));
let qy = h - 12.0 - 18.0 - 12.0 - CONTROL;
let hover = ui.interact(QUIT, 12.0, qy, SIDEBAR - 24.0, CONTROL, true);
ui.rounded(
12.0,
qy,
SIDEBAR - 24.0,
CONTROL,
t::RADIUS,
color::with_alpha(color::HOVER, 0.7 * hover),
);
ui.text_line(
Face::Regular,
BODY,
CONTROL,
22.0,
qy,
color::mix(color::MUTED, color::TEXT, hover),
"Quit game",
);
ui.text(
Face::Regular,
SMALL,
22.0,
h - 12.0 - 18.0,
color::FAINT,
"Horizon 26.3",
);
}
/// Home: the launcher's Play page over the backdrop.
fn home(&self, ui: &mut Ui, x: f32, w: f32) {
let mut y = 36.0 + 28.0;
ui.text(
Face::Medium,
SMALL,
x,
y,
color::FAINT,
"Minecraft 26.3 · Java Edition, rewritten in Rust",
);
y += 22.0;
ui.text_line(Face::Display, t::HERO, 58.0, x, y, color::TEXT, "Horizon");
y += 58.0 + 12.0;
ui.text(
Face::Regular,
t::LARGE,
x,
y,
color::MUTED,
"The world you know, rebuilt from the ground up.",
);
y += 22.0 + 24.0;
let pw = ui.button_width("Singleplayer", true).max(120.0);
ui.button(
PLAY,
[x, y, pw, CONTROL_LARGE],
"Singleplayer",
Kind::Primary,
true,
);
let mw = ui.button_width("Multiplayer", true);
ui.button(
MULTIPLAYER,
[x + pw + 8.0, y, mw, CONTROL_LARGE],
"Multiplayer",
Kind::Secondary,
true,
);
y += CONTROL_LARGE + 24.0;
ui.divider(x, y, w);
let worlds = self.recent_worlds();
let last = worlds.first();
let value = last.map_or("None yet".to_owned(), |l| {
let secs = l
.modified
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
format!("{} · {}", l.name, crate::title::date_label(secs))
});
let rh = ui.row(x, y, "Last played", &value, w - t::ROW_LABEL - 120.0);
if last.is_some() {
let bw = ui.button_width("Resume", false);
ui.button(
RESUME,
[x + w - bw, y + (rh - CONTROL) / 2.0, bw, CONTROL],
"Resume",
Kind::Secondary,
true,
);
}
y += rh;
ui.divider(x, y, w);
let count = match worlds.len() {
1 => "1 world".to_owned(),
n => format!("{n} worlds"),
};
y += ui.row(x, y, "Saves", &count, w - t::ROW_LABEL - 16.0);
ui.divider(x, y, w);
let note = "Not affiliated with Mojang or Microsoft";
ui.text(
Face::Regular,
SMALL,
x,
ui.h - 12.0 - 18.0,
color::FAINT,
note,
);
}
/// A click in Horizon's layout (`x`, `y` in GUI pixels).
pub fn click_horizon(
&mut self,
atlas: &HudAtlas,
batch: &mut HudBatch,
x: f32,
y: f32,
) -> Action {
if self.skip_opening() {
return Action::None;
}
let hit = {
let mut ui = Ui::dry(atlas, batch, (x, y));
self.draw_horizon(&mut ui, 1.0);
ui.hit((x, y))
};
let Some(hit) = hit else {
if let Mode::Create { focus, .. } = &mut self.mode {
*focus = 2;
}
return Action::None;
};
let saves = self.saves.clone();
let select = |saves: &std::path::Path| Mode::Select {
worlds: list_worlds(saves),
selected: None,
};
match hit {
QUIT => return Action::Quit,
h if h == NAV => {
self.mode = Mode::Main;
return Action::None;
}
h if h == NAV + 1 => {
if !matches!(self.mode, Mode::Select { .. }) {
self.mode = select(&saves);
}
return Action::None;
}
h if h == NAV + 2 => {
if !matches!(self.mode, Mode::Multiplayer(_)) {
self.mode = Mode::Multiplayer(Screen::list(&saves));
}
return Action::None;
}
h if h == NAV + 3 => return Action::Options,
_ => {}
}
match &mut self.mode {
Mode::Main => match hit {
PLAY => self.mode = select(&saves),
MULTIPLAYER => self.mode = Mode::Multiplayer(Screen::list(&saves)),
RESUME => {
if let Some(w) = list_worlds(&saves).into_iter().next() {
return Action::Play { folder: w.folder };
}
}
_ => {}
},
Mode::Select { worlds, selected } => match hit {
PLAY_WORLD => {
if let Some(w) = selected.and_then(|i| worlds.get(i)) {
return Action::Play {
folder: w.folder.clone(),
};
}
}
CREATE => {
self.mode = Mode::Create {
name: "New World".into(),
seed: String::new(),
creative: false,
focus: 0,
}
}
h if (WORLD..WORLD + 10_000).contains(&h) => {
let i = (h - WORLD) as usize;
if *selected == Some(i) {
return Action::Play {
folder: worlds[i].folder.clone(),
};
}
*selected = Some(i);
}
_ => {}
},
Mode::Create {
creative, focus, ..
} => match hit {
BACK => self.mode = select(&saves),
NAME => *focus = 0,
SEED => *focus = 1,
h if h == MODE || h == MODE + 1 => *creative = h == MODE + 1,
MAKE => return self.create_action(),
_ => {}
},
Mode::Multiplayer(screen) => {
let id = match hit {
JOIN => Some(40),
DIRECT => Some(41),
ADD => Some(42),
EDIT => Some(43),
DELETE => Some(44),
REFRESH => Some(45),
BACK => Some(match screen {
Screen::List { .. } => 46,
Screen::Edit { .. } => 51,
Screen::Direct { .. } => 53,
Screen::Disconnected { .. } => 54,
}),
DONE => Some(match screen {
Screen::Direct { .. } => 52,
_ => 50,
}),
FIELD_A | FIELD_B => {
if let Screen::Edit { focus, .. } = screen {
*focus = u8::from(hit == FIELD_B);
}
None
}
h if (SERVER..SERVER + 10_000).contains(&h) => {
let i = (h - SERVER) as usize;
let again =
matches!(screen, Screen::List { selected, .. } if *selected == Some(i));
if again {
if let Some(address) = screen.address_of(i) {
return Action::Connect { address };
}
} else if let Screen::List { selected, .. } = screen {
*selected = Some(i);
}
None
}
_ => None,
};
if let Some(id) = id {
match screen.click(Some(id), 0.0, 0.0, (0.0, 0.0), &saves) {
Outcome::Back => self.mode = Mode::Main,
Outcome::Connect(address) => return Action::Connect { address },
Outcome::None => {}
}
}
}
}
Action::None
}
}
/// The page header plus a row of actions under it; returns the y below.
fn header(ui: &mut Ui, x: f32, title: &str, description: &str) -> f32 {
let y = 36.0;
y + ui.page_header(x, y, title, description) + 24.0
}
/// Actions laid out right to left from `right`; returns the left edge.
fn actions_right(ui: &mut Ui, right: f32, y: f32, items: &[(u64, &str, Kind, bool)]) -> f32 {
let mut rx = right;
for &(bid, label, kind, on) in items {
let large = kind == Kind::Primary;
let (bw, bh) = if large {
(ui.button_width(label, true).max(120.0), CONTROL_LARGE)
} else {
(ui.button_width(label, false), CONTROL)
};
rx -= bw;
ui.button(
bid,
[rx, y + (CONTROL_LARGE - bh) / 2.0, bw, bh],
label,
kind,
on,
);
rx -= 8.0;
}
rx
}
/// A selectable list row: the launcher's row look with a hover layer and
/// the raised selection with its 1px border. Returns the row height.
#[allow(clippy::too_many_arguments)]
fn list_row(
ui: &mut Ui,
rid: u64,
x: f32,
y: f32,
w: f32,
selected: bool,
title: &str,
meta: &str,
right: &str,
dot: Option<u32>,
) -> f32 {
let h = 60.0;
let hover = ui.interact(rid, x - 12.0, y + 2.0, w + 24.0, h - 4.0, true);
let sel = ui.tween(rid ^ 0x5e1ec7, if selected { 1.0 } else { 0.0 }, t::quick());
ui.rounded(
x - 12.0,
y + 4.0,
w + 24.0,
h - 8.0,
t::RADIUS,
color::with_alpha(color::HOVER, 0.7 * hover),
);
ui.rounded(
x - 12.0,
y + 4.0,
w + 24.0,
h - 8.0,
t::RADIUS,
color::with_alpha(color::RAISED, sel),
);
ui.border(
x - 12.0,
y + 4.0,
w + 24.0,
h - 8.0,
t::RADIUS,
color::with_alpha(color::BORDER, sel),
);
let mut tx = x;
if let Some(dot) = dot {
ui.rounded(x, y + 17.0, 6.0, 6.0, 3.0, dot);
tx += 16.0;
}
let title = ui.fit(Face::Medium, BODY, title, w - 200.0);
ui.text(Face::Medium, BODY, tx, y + 10.0, color::TEXT, &title);
let meta = ui.fit(Face::Regular, SMALL, meta, w - 200.0);
ui.text(Face::Regular, SMALL, tx, y + 31.0, color::MUTED, &meta);
ui.text_right(Face::Regular, SMALL, x + w, y + 21.0, color::FAINT, right);
ui.divider(x, y + h, w);
h
}
/// An empty list: a serif line and a hint.
fn empty(ui: &mut Ui, x: f32, y: f32, title: &str, hint: &str) {
ui.text_line(Face::Display, 22.0, 30.0, x, y + 32.0, color::TEXT, title);
ui.text(Face::Regular, BODY, x, y + 66.0, color::MUTED, hint);
}
fn worlds_page(
ui: &mut Ui,
x: f32,
w: f32,
worlds: &[crate::title::WorldEntry],
selected: Option<usize>,
) {
let count = match worlds.len() {
0 => "No worlds yet. They live in the saves folder.".to_owned(),
1 => "1 world in the saves folder.".to_owned(),
n => format!("{n} worlds in the saves folder."),
};
let mut y = header(ui, x, "Singleplayer", &count);
actions_right(
ui,
x + w,
y - 24.0 - 58.0,
&[
(PLAY_WORLD, "Play", Kind::Primary, selected.is_some()),
(CREATE, "Create world", Kind::Secondary, true),
],
);
ui.divider(x, y, w);
if worlds.is_empty() {
empty(ui, x, y, "A blank map.", "Create a world to begin.");
return;
}
let bottom = ui.h - 36.0;
for (i, world) in worlds.iter().enumerate() {
if y + 60.0 > bottom {
break;
}
let secs = world
.modified
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let meta = format!("{} · {}", crate::title::date_label(secs), world.folder);
let mode = if world.creative {
"Creative"
} else {
"Survival"
};
y += list_row(
ui,
WORLD + i as u64,
x,
y,
w,
selected == Some(i),
&world.name,
&meta,
mode,
None,
);
}
}
#[allow(clippy::too_many_arguments)]
fn create_page(
ui: &mut Ui,
x: f32,
w: f32,
name: &str,
seed: &str,
creative: bool,
focus: u8,
caret: bool,
) {
let mut y = header(
ui,
x,
"Create world",
"Generated by Horizon from the seed, as vanilla would.",
);
actions_right(
ui,
x + w,
y - 24.0 - 58.0,
&[
(MAKE, "Create", Kind::Primary, true),
(BACK, "Cancel", Kind::Ghost, true),
],
);
ui.divider(x, y, w);
let fx = x + t::ROW_LABEL + 16.0;
let fw = (w - t::ROW_LABEL - 16.0).min(360.0);
for (i, label) in ["World name", "Seed", "Game mode"].iter().enumerate() {
let rh = 12.0 + CONTROL + 12.0;
ui.text_line(Face::Regular, BODY, rh, x, y, color::MUTED, label);
let r = [fx, y + 12.0, fw, CONTROL];
match i {
0 => ui.field(NAME, r, name, "New World", focus == 0, caret),
1 => ui.field(SEED, r, seed, "Random", focus == 1, caret),
_ => {
ui.segmented(
MODE,
fx,
y + 12.0,
&["Survival", "Creative"],
usize::from(creative),
);
}
}
y += rh;
ui.divider(x, y, w);
}
}
fn multiplayer_page(ui: &mut Ui, x: f32, w: f32, screen: &Screen, caret: bool) {
match screen {
Screen::List {
servers,
selected,
pings,
lan,
} => {
let games = lan.games();
let sub = match servers.len() {
0 => "No saved servers. Worlds opened to LAN show up here too.".to_owned(),
1 => "1 saved server, and worlds opened to LAN.".to_owned(),
n => format!("{n} saved servers, and worlds opened to LAN."),
};
let mut y = header(ui, x, "Multiplayer", &sub);
let has = selected.is_some();
let saved = selected.is_some_and(|i| i < servers.len());
let mut items = vec![
(JOIN, "Join", Kind::Primary, has),
(ADD, "Add server", Kind::Secondary, true),
(DIRECT, "Direct connect", Kind::Secondary, true),
(REFRESH, "Refresh", Kind::Ghost, true),
];
if saved {
items.push((EDIT, "Edit", Kind::Ghost, true));
items.push((DELETE, "Delete", Kind::Danger, true));
}
actions_right(ui, x + w, y - 24.0 - 58.0, &items);
ui.divider(x, y, w);
let pings = pings.lock().map(|p| p.clone()).unwrap_or_default();
let mut rows: Vec<(String, String, String, u32)> = servers
.iter()
.map(|s| {
let (line, right, dot) = match pings.get(&s.ip) {
Some(Ping::Ok {
motd, players, ms, ..
}) => (
motd.lines().next().unwrap_or("").trim().to_owned(),
format!("{players} · {ms} ms"),
color::ACCENT,
),
Some(Ping::Failed(_)) => (
"Can't reach the server".to_owned(),
String::new(),
color::DANGER,
),
_ => ("Pinging…".to_owned(), String::new(), color::FAINT),
};
(s.name.clone(), format!("{line} · {}", s.ip), right, dot)
})
.collect();
rows.extend(games.iter().map(|g| {
(
"LAN world".to_owned(),
format!("{} · {}", g.motd, g.address),
"On your network".to_owned(),
color::ACCENT,
)
}));
if rows.is_empty() {
empty(
ui,
x,
y,
"Nobody here yet.",
"Add a server, connect directly, or open a world to LAN.",
);
return;
}
let bottom = ui.h - 36.0;
for (i, (name, meta, right, dot)) in rows.iter().enumerate() {
if y + 60.0 > bottom {
break;
}
y += list_row(
ui,
SERVER + i as u64,
x,
y,
w,
*selected == Some(i),
name,
meta,
right,
Some(*dot),
);
}
}
Screen::Edit {
index,
name,
address,
focus,
} => {
let title = if index.is_some() {
"Edit server"
} else {
"Add server"
};
let mut y = header(
ui,
x,
title,
"Saved to servers.dat, like vanilla's server list.",
);
let ok = horizon_protocol::servers::parse_address(address).is_some();
actions_right(
ui,
x + w,
y - 24.0 - 58.0,
&[
(DONE, "Save", Kind::Primary, ok),
(BACK, "Cancel", Kind::Ghost, true),
],
);
ui.divider(x, y, w);
let fx = x + t::ROW_LABEL + 16.0;
let fw = (w - t::ROW_LABEL - 16.0).min(360.0);
for (i, (label, value, hint)) in [
("Name", name, "Minecraft Server"),
("Address", address, "play.example.net"),
]
.iter()
.enumerate()
{
let rh = 12.0 + CONTROL + 12.0;
ui.text_line(Face::Regular, BODY, rh, x, y, color::MUTED, label);
let fid = if i == 0 { FIELD_A } else { FIELD_B };
ui.field(
fid,
[fx, y + 12.0, fw, CONTROL],
value,
hint,
usize::from(*focus) == i,
caret,
);
y += rh;
ui.divider(x, y, w);
}
}
Screen::Direct { address } => {
let y = header(
ui,
x,
"Direct connect",
"Join a server once without saving it.",
);
let ok = horizon_protocol::servers::parse_address(address).is_some();
actions_right(
ui,
x + w,
y - 24.0 - 58.0,
&[
(DONE, "Join", Kind::Primary, ok),
(BACK, "Cancel", Kind::Ghost, true),
],
);
ui.divider(x, y, w);
let rh = 12.0 + CONTROL + 12.0;
ui.text_line(Face::Regular, BODY, rh, x, y, color::MUTED, "Address");
let fx = x + t::ROW_LABEL + 16.0;
ui.field(
FIELD_A,
[fx, y + 12.0, (w - t::ROW_LABEL - 16.0).min(360.0), CONTROL],
address,
"host:port",
true,
caret,
);
ui.divider(x, y + rh, w);
}
Screen::Disconnected { reason } => {
let mut y = header(
ui,
x,
"Connection lost",
"The server closed the connection.",
);
ui.divider(x, y, w);
y += 16.0;
// The launcher's error note: a danger dot beside the message.
let lines = wrap(ui, reason, w - 40.0);
let box_h = 24.0 + lines.len() as f32 * 19.0;
ui.rounded(x, y, w, box_h, t::RADIUS, color::RAISED);
ui.border(x, y, w, box_h, t::RADIUS, color::BORDER);
ui.rounded(x + 12.0, y + 18.0, 6.0, 6.0, 3.0, color::DANGER);
for (i, line) in lines.iter().enumerate() {
ui.text_line(
Face::Regular,
BODY,
19.0,
x + 28.0,
y + 12.0 + i as f32 * 19.0,
color::TEXT,
line,
);
}
y += box_h + 16.0;
let bw = ui.button_width("Back to servers", false);
ui.button(
BACK,
[x, y, bw, CONTROL],
"Back to servers",
Kind::Secondary,
true,
);
}
}
}
/// Greedy word wrap in dp.
pub fn wrap(ui: &Ui, text: &str, width: f32) -> Vec<String> {
let mut lines = Vec::new();
for para in text.lines() {
let mut line = String::new();
for word in para.split_whitespace() {
let next = if line.is_empty() {
word.to_owned()
} else {
format!("{line} {word}")
};
if !line.is_empty() && ui.measure(Face::Regular, BODY, &next) > width {
lines.push(std::mem::replace(&mut line, word.to_owned()));
} else {
line = next;
}
}
lines.push(line);
}
lines
}
/// The pause menu in Horizon's style: the launcher's sidebar over the
/// blurred world, with the game's actions as a page. Ids match
/// `game/screens.rs`'s PauseScreen actions: 0 back to game, 1 options,
/// 2 save and quit, 3 statistics, 4 advancements, 9 world options.
pub mod pause {
use super::*;
fn act(a: u8) -> u64 {
id("pause", u64::from(a))
}
pub fn draw(ui: &mut Ui, world: &str, quit_label: &str, opened: Instant) {
let (w, h) = (ui.w, ui.h);
let p = t::ease_out_quint(opened.elapsed().as_secs_f32() / t::move_());
ui.opacity = p;
ui.atmosphere(0.35, 1.0);
ui.offset = (-16.0 * (1.0 - p), 0.0);
ui.fill(0.0, 0.0, SIDEBAR, h, color::with_alpha(color::SIDEBAR, 0.8));
ui.vline(SIDEBAR - 1.0, 0.0, h, color::BORDER);
ui.text_line(
Face::Display,
22.0,
30.0,
22.0,
16.0,
color::TEXT,
"Horizon",
);
ui.sidebar_list(
id("pause.nav", 0),
12.0,
66.0,
SIDEBAR - 24.0,
&["Game", "Advancements", "Statistics", "Options"],
0,
);
ui.text(
Face::Regular,
SMALL,
22.0,
h - 12.0 - 18.0,
color::FAINT,
"Horizon 26.3",
);
ui.offset = (0.0, 8.0 * (1.0 - p));
let x = SIDEBAR + 48.0;
let cw = (w - x - 48.0).min(760.0);
let mut y = 36.0;
ui.text(Face::Medium, SMALL, x, y, color::FAINT, "Game paused");
y += 22.0;
let title = ui.fit(Face::Display, t::HERO, world, cw);
ui.text_line(Face::Display, t::HERO, 58.0, x, y, color::TEXT, &title);
y += 58.0 + 24.0;
let bw = ui.button_width("Back to game", true).max(120.0);
ui.button(
act(0),
[x, y, bw, CONTROL_LARGE],
"Back to game",
Kind::Primary,
true,
);
let lw = ui.button_width("Open to LAN", true);
ui.button(
act(9),
[x + bw + 8.0, y, lw, CONTROL_LARGE],
"Open to LAN",
Kind::Secondary,
true,
);
y += CONTROL_LARGE + 24.0;
ui.divider(x, y, cw);
let rh = ui.row(
x,
y,
"Leave",
"Saves the world, then returns to the title.",
cw - t::ROW_LABEL - 220.0,
);
let qw = ui.button_width(quit_label, false);
ui.button(
act(2),
[x + cw - qw, y + (rh - CONTROL) / 2.0, qw, CONTROL],
quit_label,
Kind::Danger,
true,
);
y += rh;
ui.divider(x, y, cw);
ui.opacity = 1.0;
ui.offset = (0.0, 0.0);
}
/// The PauseScreen action under a click, if any.
pub fn click(
atlas: &HudAtlas,
batch: &mut HudBatch,
x: f32,
y: f32,
world: &str,
quit_label: &str,
) -> Option<u8> {
let mut ui = Ui::dry(atlas, batch, (x, y));
draw(
&mut ui,
world,
quit_label,
Instant::now() - std::time::Duration::from_secs(5),
);
let hit = ui.hit((x, y))?;
let nav = id("pause.nav", 0);
match hit {
h if h == nav => Some(0),
h if h == nav + 1 => Some(4),
h if h == nav + 2 => Some(3),
h if h == nav + 3 => Some(1),
h => [0u8, 1, 2, 3, 4, 9].into_iter().find(|&a| act(a) == h),
}
}
}
+83 -11
View File
@@ -4,6 +4,7 @@ mod advancements;
mod advancements_screen;
mod allays;
mod armor_stands;
mod backdrop;
mod banners;
mod beacon;
mod block_entity_anims;
@@ -694,6 +695,10 @@ struct Game<'a> {
settings: Settings,
/// Where the options screen returns: true = title, false = pause menu.
options_from_title: bool,
/// The live title backdrop (game/backdrop.rs).
backdrop: backdrop::Backdrop,
/// When the pause menu opened (Horizon style's entrance).
pause_since: Option<Instant>,
dragging: bool,
/// `ClientAvatarState`: walked distance and view bob.
avatar: horizon_client::camera::AvatarBob,
@@ -2969,7 +2974,15 @@ impl Game<'_> {
return batch;
}
if let Some(title) = &self.title {
title.draw(&self.res.atlas, &mut batch, self.cursor);
if horizon_client::ui_theme::horizon() {
let fade = self.backdrop_fade();
let mut ui =
horizon_client::ui_theme::Ui::new(&self.res.atlas, &mut batch, self.cursor);
title.draw_horizon(&mut ui, fade);
ui.finish();
} else {
title.draw(&self.res.atlas, &mut batch, self.cursor);
}
return batch;
}
let atlas_ptr: *const HudAtlas = &self.res.atlas;
@@ -2994,7 +3007,8 @@ impl Game<'_> {
// the sleep overlay, debug screen and open screens still draw.
let spectator = self.spectator();
let (wi, hi) = (w as i32, h as i32);
if !self.ui.hide_gui {
// Horizon's pause menu sits over the blurred world alone.
if !self.ui.hide_gui && !self.horizon_pause() {
self.draw_spyglass(&mut batch);
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
// extractVehicleHealth and the experience bar, laid out by hud_layout.
@@ -7150,7 +7164,13 @@ impl Game<'_> {
return Ok(false);
};
let alpha = self.clock.fraction();
let (camera, underwater, view_yaw, view_pitch) = match self.camera_view(alpha as f32) {
let live = self.backdrop_live();
let view = if live {
self.backdrop_view()
} else {
self.camera_view(alpha as f32)
};
let (camera, underwater, view_yaw, view_pitch) = match view {
Some((c, yaw, pitch)) => {
let s =
self.world
@@ -7236,7 +7256,7 @@ impl Game<'_> {
entities.extend(dragon);
}
}
let celestial = if title {
let celestial = if title && !live {
self.panorama_geometry()
} else if self.dim == Dim::Overworld
&& self.potions.effects.get("darkness").is_none()
@@ -7262,12 +7282,14 @@ impl Game<'_> {
if !title {
self.end_geometry(camera, &mut entities);
}
if !title && self.dim == Dim::Overworld {
if (!title || live) && self.dim == Dim::Overworld {
self.cloud_geometry(&sky, camera, alpha, &mut entities);
self.weather_geometry(camera, alpha, &mut entities);
}
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
let (yaw, pitch) = if title {
let (yaw, pitch) = if live {
(view_yaw, view_pitch)
} else if title {
(-(self.started.elapsed().as_secs_f32() * 2.0) % 360.0, 10.0)
} else {
(view_yaw, view_pitch)
@@ -7287,7 +7309,9 @@ impl Game<'_> {
camera,
yaw,
pitch,
fov_degrees: if title {
fov_degrees: if live {
72.0
} else if title {
85.0
} else {
self.fov_degrees(alpha as f32, camera.to_array())
@@ -7300,7 +7324,7 @@ impl Game<'_> {
sky,
distance: (self.streaming.radius * 16) as f32,
underwater,
title,
title: title && !live,
skybox: self.dim == Dim::Overworld,
end_sky: self.dim == Dim::End,
environment_fog: (self.dim == Dim::Nether).then_some((sky.fog_start, sky.fog_end)),
@@ -7334,6 +7358,13 @@ impl Game<'_> {
darkness: self.darkness_lighting(alpha as f32),
night_vision: self.night_vision_intensity(),
block_flicker: self.res.fog.state.block_light_flicker,
post: if live {
Some(horizon_client::post::PostGrade::FROST)
} else if self.horizon_pause() {
Some(horizon_client::post::PostGrade::PAUSE)
} else {
None
},
};
self.renderer.as_mut().ok_or("renderer")?.render(&frame)
}
@@ -7627,6 +7658,15 @@ impl ApplicationHandler for Game<'_> {
) {
self.last_input = Instant::now();
}
if let WindowEvent::MouseInput {
state,
button: MouseButton::Left,
..
} = &event
{
// The press state of Horizon-style buttons.
horizon_client::ui_theme::set_pressed(*state == ElementState::Pressed);
}
let result: Result<()> = (|| {
match event {
WindowEvent::CloseRequested => {
@@ -7752,9 +7792,14 @@ impl ApplicationHandler for Game<'_> {
.as_ref()
.map(|r| [r.config.width, r.config.height])
.unwrap_or([1, 1]);
let b = HudBatch::new(size);
let mut b = HudBatch::new(size);
let atlas = &self.res.atlas;
let action = self.title.as_mut().map_or(Action::None, |t| {
t.click(self.cursor.0, self.cursor.1, (b.width, b.height))
if horizon_client::ui_theme::horizon() {
t.click_horizon(atlas, &mut b, self.cursor.0, self.cursor.1)
} else {
t.click(self.cursor.0, self.cursor.1, (b.width, b.height))
}
});
match action {
Action::Quit => event_loop.exit(),
@@ -7920,6 +7965,7 @@ impl ApplicationHandler for Game<'_> {
WindowEvent::KeyboardInput { event, .. } if self.title.is_some() => {
if event.state == ElementState::Pressed
&& let Some(title) = self.title.as_mut()
&& !title.skip_opening()
{
match event.physical_key {
PhysicalKey::Code(KeyCode::Backspace) => title.backspace(),
@@ -8001,6 +8047,11 @@ impl ApplicationHandler for Game<'_> {
self.pitch = (self.pitch
+ (turn(KeyCode::ArrowDown) - turn(KeyCode::ArrowUp)) * 90.0 * secs)
.clamp(-90.0, 90.0);
self.update_backdrop()?;
horizon_client::ui_theme::set_backdrop(self.backdrop_fade());
if self.screen != Screen::Paused {
self.pause_since = None;
}
if self.title.is_none() {
self.update_world();
self.spawn_player()?;
@@ -8013,6 +8064,8 @@ impl ApplicationHandler for Game<'_> {
self.tick()?;
self.tick_ambience();
self.tick_fov();
} else if self.title.is_some() {
self.tick_backdrop();
}
self.tick_music();
self.tick_sound_engine();
@@ -8246,6 +8299,14 @@ pub fn run() -> Result<()> {
let now = Instant::now();
let game_root = options.resources.clone();
let mut settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
horizon_client::ui_theme::load_preferences(&std::env::current_dir()?);
// Scripted and frame-limited runs (the parity harnesses) see vanilla's
// screens unless HORIZON_MENU_STYLE asks otherwise.
if (options.frames.is_some() || options.control.is_some())
&& std::env::var_os("HORIZON_MENU_STYLE").is_none()
{
horizon_client::ui_theme::set_style(horizon_client::ui_theme::MenuStyle::Classic);
}
if let Some(d) = options.distance_override {
settings.render_distance = d;
}
@@ -8363,6 +8424,8 @@ pub fn run() -> Result<()> {
hand_item: None,
settings,
options_from_title: false,
backdrop: Default::default(),
pause_since: None,
dragging: false,
avatar: Default::default(),
flash: 0,
@@ -8493,7 +8556,16 @@ pub fn run() -> Result<()> {
let (seed, creative) = (game.options.seed, game.options.creative);
game.open_world(&folder, None, Some(seed), creative)?;
}
None => game.title = Some(Title::new(std::env::current_dir()?.join("saves"))),
None => {
let title = Title::new(std::env::current_dir()?.join("saves"));
// The launcher's opening, once per session and only in Horizon's style.
if horizon_client::ui_theme::horizon() && game.options.frames.is_none() {
title
.opening
.set(Some(horizon_client::front::Opening::new()));
}
game.title = Some(title);
}
}
if let (Some(script), Some(output)) = (
std::env::var_os("HORIZON_COMMAND_SCRIPT"),
+245
View File
@@ -0,0 +1,245 @@
//! The live title backdrop (Menu style: Horizon): a frozen landscape that
//! Horizon generates and renders itself behind the title screens, seen
//! through a slow orbiting camera, falling snow, an overcast sky and the cold
//! blur and grade of `gfx::PostGrade`. Nothing is bundled: the chunks come
//! from the vanilla generator for a fixed seed and are cached in
//! `saves/.horizon-title/backdrop` (no level.dat, so no world list entry).
//! The ice-spikes spot is found once with `/locate biome`'s search and kept
//! in `spot.txt` there.
use super::Game;
use horizon_client::{
Result,
save::{Persistent, SaveDir},
streaming::Streaming,
vanillagen::VanillaTerrain,
world::{Weather, World},
};
use horizon_core::biome::{BiomeZoom, obfuscate_seed};
use std::{
sync::{Arc, Mutex},
time::Instant,
};
/// The world the backdrop is cut from (HORIZON_BACKDROP_SEED overrides it).
const SEED: i64 = 2_026_031_337;
/// The biome the camera looks for.
const BIOME: &str = "minecraft:ice_spikes";
/// Late morning: the snow reads clearly; the grade (`PostGrade::FROST`) makes it cold.
const DAY_TIME: i64 = 4_000;
#[derive(Default)]
pub(super) struct Backdrop {
pub active: bool,
/// The search for the spot, then the spot (block x, z).
spot: Arc<Mutex<Option<Option<(i32, i32)>>>>,
centred: bool,
/// Camera position once the ground under it has loaded.
pub camera: Option<glam::DVec3>,
pub ready: Option<Instant>,
pub started: Option<Instant>,
}
fn seed() -> i64 {
std::env::var("HORIZON_BACKDROP_SEED")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(SEED)
}
impl Game<'_> {
/// How visible the live backdrop is (0 until its first chunks are drawn).
pub(super) fn backdrop_fade(&self) -> f32 {
let Some(at) = self.backdrop.ready else {
return 0.0;
};
let x = (at.elapsed().as_secs_f32() / 1.6).clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
}
/// Whether the frame should draw the backdrop world instead of the panorama.
pub(super) fn backdrop_live(&self) -> bool {
self.title.is_some() && self.backdrop.active && self.backdrop.camera.is_some()
}
/// The orbiting camera: position, yaw and pitch.
pub(super) fn backdrop_view(&self) -> Option<(glam::DVec3, f32, f32)> {
let c = self.backdrop.camera?;
let t = self
.backdrop
.started
.map_or(0.0, |s| s.elapsed().as_secs_f32());
// A full turn in about six minutes.
let yaw = (200.0 + t * 1.0) % 360.0;
let pitch = 6.0 + (t * 0.05).sin() * 2.0;
Some((c, yaw, pitch))
}
/// Called every frame while the title is up.
pub(super) fn update_backdrop(&mut self) -> Result<()> {
let wanted = self.title.is_some()
&& horizon_client::ui_theme::horizon()
&& self.renderer.is_some()
&& self.options.frames.is_none();
if !wanted {
if self.backdrop.active && self.title.is_some() {
self.stop_backdrop();
}
if self.title.is_none() && self.backdrop.active {
// open_world / connect_server replaced the world and streaming.
self.backdrop = Backdrop::default();
}
return Ok(());
}
if !self.backdrop.active {
self.start_backdrop()?;
}
let found = *self.backdrop.spot.lock().unwrap_or_else(|e| e.into_inner());
let Some(Some((x, z))) = found else {
return Ok(());
};
if !self.backdrop.centred {
self.backdrop.centred = true;
self.streaming.center = (x >> 4, z >> 4);
self.streaming.paused = false;
}
self.update_world();
if self.backdrop.camera.is_none()
&& (-2..=2)
.all(|dz| (-2..=2).all(|dx| self.streaming.is_lit((x >> 4) + dx, (z >> 4) + dz)))
{
// Above the tallest block nearby, so no spike pokes through the lens.
let mut top = 64;
for dz in (-12..=12).step_by(3) {
for dx in (-12..=12).step_by(3) {
if let Some(y) = self.surface_y(x + dx, z + dz) {
top = top.max(y);
}
}
}
let ground = self.surface_y(x, z).unwrap_or(top);
let y = (ground + 22).max(top + 4);
self.backdrop.camera = Some(glam::DVec3::new(
f64::from(x) + 0.5,
f64::from(y),
f64::from(z) + 0.5,
));
}
if self.backdrop.camera.is_some()
&& self.backdrop.ready.is_none()
&& self
.renderer
.as_ref()
.is_some_and(|r| r.section_count() > 40)
{
self.backdrop.ready = Some(Instant::now());
}
Ok(())
}
/// The world tick of the backdrop: time passes for clouds and snow only.
pub(super) fn tick_backdrop(&mut self) {
if self.backdrop.active {
self.world.game_time += 1;
// The snow's fall follows the client tick counter.
self.ticks += 1;
}
}
fn surface_y(&self, x: i32, z: i32) -> Option<i32> {
let air = self.res.facts.air;
(-60..320).rev().find(|&y| self.world.block(x, y, z) != air)
}
fn start_backdrop(&mut self) -> Result<()> {
let seed = seed();
let root = std::env::current_dir()?
.join("saves")
.join(".horizon-title")
.join("backdrop");
std::fs::create_dir_all(&root)?;
let save = Arc::new(SaveDir::new(root.join(format!("{seed}"))));
let res = &*self.res;
let inner = Arc::new(
VanillaTerrain::new(
&self.options.resources.join("worldgen"),
res.assets.registry,
&res.biomes,
seed,
)
.map_err(|e| format!("title backdrop: {e}"))?,
);
let mut streaming = Streaming::new(
Arc::new(Persistent {
inner: inner.clone(),
save: save.clone(),
registry: res.assets.clone(),
biomes: res.biomes.clone(),
}),
res.context.clone(),
res.threads.min(6),
);
streaming.radius = self.settings.render_distance.clamp(4, 10);
streaming.biome_blend = self.settings.biome_blend;
streaming.smooth_lighting = self.settings.smooth_lighting;
streaming.cutout_leaves = self.settings.cutout_leaves;
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
streaming.paused = true;
self.streaming = streaming;
self.save = save;
let mut world = World::new(self.res.facts.air, seed);
world.day_time = DAY_TIME;
world.game_time = 6000;
// Light snow: the weather renders as snow in frozen biomes.
world.weather = Weather {
clear_time: 0,
rain_time: i32::MAX / 2,
thunder_time: i32::MAX / 2,
raining: true,
thundering: false,
rain_level: (0.35, 0.35),
thunder_level: (0.0, 0.0),
};
self.world = world;
self.dim = super::Dim::Overworld;
if let Some(r) = self.renderer.as_mut() {
r.clear_sections();
}
self.backdrop = Backdrop {
active: true,
started: Some(Instant::now()),
..Default::default()
};
// Find (or recall) the spot off the main thread.
let spot = self.backdrop.spot.clone();
std::fs::create_dir_all(root.join(format!("{seed}")))?;
let cache = root.join(format!("{seed}")).join("spot.txt");
std::thread::Builder::new()
.name("horizon-backdrop".into())
.spawn(move || {
let known = std::fs::read_to_string(&cache).ok().and_then(|t| {
let mut it = t.split_whitespace().map(|v| v.parse::<i32>().ok());
Some((it.next()??, it.next()??))
});
let found = known.or_else(|| {
let allowed = |name: &str| name == BIOME;
let search = horizon_world::worldgen::generator::BiomeSearch {
allowed: &allowed,
origin: [0, 64, 0],
radius: 6400,
horizontal: 32,
vertical: 64,
min_y: -64,
max_y: 320,
};
let found = inner.find_closest_biome(&search);
eprintln!("Horizon: title backdrop: {BIOME} at {found:?}");
let (_, p) = found?;
let _ = std::fs::write(&cache, format!("{} {}\n", p[0], p[2]));
Some((p[0], p[2]))
});
*spot.lock().unwrap_or_else(|e| e.into_inner()) =
Some(Some(found.unwrap_or((0, 0))));
})?;
Ok(())
}
fn stop_backdrop(&mut self) {
self.streaming.paused = true;
self.world = World::new(self.res.facts.air, 0);
if let Some(r) = self.renderer.as_mut() {
r.clear_sections();
}
self.backdrop = Backdrop::default();
}
}
+46 -4
View File
@@ -52,7 +52,32 @@ fn death_buttons(w: f32, h: f32) -> [(f32, f32, &'static str, u8); 2] {
]
}
impl Game<'_> {
/// Whether the pause menu is drawn in Horizon's style (over a blurred world).
pub(super) fn horizon_pause(&self) -> bool {
self.screen == Screen::Paused && self.title.is_none() && horizon_client::ui_theme::horizon()
}
fn pause_labels(&self) -> (String, &'static str) {
let world = if self.remote.is_some() {
"Multiplayer".to_owned()
} else {
self.level.name.clone()
};
let quit = if self.remote.is_some() {
"Disconnect"
} else {
"Save and quit"
};
(world, quit)
}
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
if horizon_client::ui_theme::horizon() {
let opened = *self.pause_since.get_or_insert_with(std::time::Instant::now);
let (world, quit) = self.pause_labels();
let mut ui = horizon_client::ui_theme::Ui::new(&self.res.atlas, batch, self.cursor);
horizon_client::front::pause::draw(&mut ui, &world, quit, opened);
ui.finish();
return;
}
let atlas = &self.res.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
@@ -102,10 +127,27 @@ impl Game<'_> {
let b = HudBatch::new(size);
(b.width, b.height)
};
let hit = pause_buttons(w, h)
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4);
let hit = if horizon_client::ui_theme::horizon() {
let size = self
.renderer
.as_ref()
.map_or([1, 1], |r| [r.config.width, r.config.height]);
let mut b = HudBatch::new(size);
let (world, quit) = self.pause_labels();
horizon_client::front::pause::click(
&self.res.atlas,
&mut b,
self.cursor.0,
self.cursor.1,
&world,
quit,
)
} else {
pause_buttons(w, h)
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
};
match hit {
Some(0) => {
self.screen = Screen::Playing;
+22
View File
@@ -96,6 +96,8 @@ pub struct Frame<'a> {
pub night_vision: f32,
/// Torch flicker added to the block light factor of 1.4.
pub block_flicker: f32,
/// Blur and grade the frame under a Horizon-style menu (`post`).
pub post: Option<crate::post::PostGrade>,
}
/// One frame of the world border wall (`WorldBorderRenderer.render`).
#[derive(Clone, Debug)]
@@ -162,6 +164,8 @@ pub struct Renderer {
reachable_from: Option<SectionKey>,
visibility_dirty: bool,
pub hud: HudRenderer,
/// The menus' backdrop blur, made on first use.
post: Option<crate::post::Post>,
pub info: wgpu::AdapterInfo,
pub suspended: bool,
pub drawn_sections: usize,
@@ -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,
+7
View File
@@ -204,6 +204,13 @@ impl HudAtlas {
for i in 0..SKIN_SLOTS {
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
}
// Horizon-style menu text and corner masks (ui_theme), rasterized at run time.
images.push((
"horizon/ui-text".into(),
2048,
1024,
vec![0; 2048 * 1024 * 4],
));
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
let width = 4096u32;
let mut placements = Vec::with_capacity(images.len());
+4
View File
@@ -44,6 +44,7 @@ pub mod fastutil_order;
pub mod flatgen;
pub mod fog;
pub mod font;
pub mod front;
pub mod gamerules;
pub mod gfx;
pub mod gossip;
@@ -79,12 +80,14 @@ pub mod nbt_path;
pub mod net;
pub mod options_file;
pub mod options_fixes;
pub mod options_horizon;
pub mod options_ui;
pub mod pack_screen;
pub mod packs;
pub mod particles;
pub mod patrol;
pub mod pose;
pub mod post;
pub mod projectiles;
pub mod raid;
pub mod random_sequences;
@@ -116,6 +119,7 @@ pub mod testgen;
pub mod text;
pub mod title;
pub mod ui;
pub mod ui_theme;
pub mod vanillagen;
pub mod window;
pub mod world;
+7
View File
@@ -108,6 +108,13 @@ fn entry_address(servers: &[ServerEntry], lan: &Scanner, index: usize) -> Option
}
impl Screen {
/// The address of list row `row` (saved servers, then LAN games).
pub fn address_of(&self, row: usize) -> Option<String> {
match self {
Self::List { servers, lan, .. } => entry_address(servers, lan, row),
_ => None,
}
}
pub fn list(saves: &Path) -> Self {
let servers = servers::load(&servers_file(saves));
let pings: Pings = Arc::default();
@@ -0,0 +1,518 @@
//! The options screen in Horizon's menu style, laid out like the launcher's
//! Settings page: a sidebar with the sliding selection (one item per
//! section), then the section's page header and settings rows (label column,
//! value, control on the right) between 1px dividers.
//!
//! Adding settings (for example the Simple/Advanced graphics menu): add rows
//! to a section in [`SECTIONS`], or a new section. A row is an [`HRow`]: an
//! existing option (`Opt`, drawn as a switch, a slider or a value button from
//! its kind), a header, or a button opening one of vanilla's pages. New kinds
//! of control belong in `ui_theme` so the launcher-matching look stays in one
//! place. Everything else (sliders dragging, scrolling, Done) is handled here.
use crate::{
hud::{HudAtlas, HudBatch},
options_ui::{Event, Kind, Opt, OptionsUi, Page, apply_preset},
settings::{SOUND_SOURCES, Settings},
ui_theme::{
self as t, CONTROL, Face, Kind as Button, MenuStyle, ROW_LABEL, SIDEBAR, SMALL, Ui, color,
id,
},
};
/// One row of a section.
#[derive(Clone, Copy, Debug)]
pub enum HRow {
Header(&'static str),
Opt(Opt),
/// A label, a value and a button opening one of vanilla's pages.
Page(&'static str, &'static str, Page),
/// Menu style: Horizon / Classic.
Style,
}
/// A sidebar section: name, description, rows.
pub struct Section {
pub name: &'static str,
pub blurb: &'static str,
pub rows: fn(&Settings) -> Vec<HRow>,
}
pub const SECTIONS: &[Section] = &[
Section {
name: "Video",
blurb: "Display, quality and how the world is drawn.",
rows: |_| {
let mut r = vec![HRow::Header("Display")];
r.extend(
[
Opt::Fullscreen,
Opt::Vsync,
Opt::MaxFps,
Opt::InactivityFps,
Opt::GuiScale,
Opt::Gamma,
]
.map(HRow::Opt),
);
r.push(HRow::Header("Quality"));
r.extend(
[
Opt::Preset,
Opt::RenderDistance,
Opt::SimulationDistance,
Opt::SmoothLighting,
Opt::Clouds,
Opt::CloudRange,
Opt::Particles,
Opt::Mipmaps,
Opt::EntityShadows,
Opt::EntityDistance,
Opt::BiomeBlend,
Opt::CutoutLeaves,
Opt::ImprovedTransparency,
Opt::WeatherRadius,
Opt::ChunkBuilder,
]
.map(HRow::Opt),
);
r.push(HRow::Header("Preferences"));
r.extend(
[
Opt::Fov,
Opt::ViewBobbing,
Opt::Autosave,
Opt::Vignette,
Opt::AttackIndicator,
Opt::ChunkFade,
]
.map(HRow::Opt),
);
r
},
},
Section {
name: "Audio",
blurb: "Volume for every kind of sound, and subtitles.",
rows: |_| {
let mut r: Vec<HRow> = (0..SOUND_SOURCES.len())
.map(|i| HRow::Opt(Opt::Volume(i)))
.collect();
r.push(HRow::Opt(Opt::ShowSubtitles));
r
},
},
Section {
name: "Controls",
blurb: "Keys, the mouse and how movement keys behave.",
rows: |_| {
let mut r = vec![HRow::Page(
"Key binds",
"Every action and its key",
Page::KeyBinds,
)];
r.push(HRow::Header("Mouse"));
r.extend(
[
Opt::Sensitivity,
Opt::WheelSensitivity,
Opt::InvertY,
Opt::DiscreteScroll,
]
.map(HRow::Opt),
);
r.push(HRow::Header("Movement"));
r.extend([Opt::ToggleCrouch, Opt::ToggleSprint, Opt::AutoJump].map(HRow::Opt));
r
},
},
Section {
name: "Accessibility",
blurb: "Contrast, motion, text backgrounds and notifications.",
rows: |_| {
[
Opt::HighContrast,
Opt::MenuBlur,
Opt::TextBackgroundOpacity,
Opt::NotificationDisplayTime,
Opt::ShowSubtitles,
Opt::ViewBobbing,
Opt::ChatLineSpacing,
]
.map(HRow::Opt)
.to_vec()
},
},
Section {
name: "Language",
blurb: "The game's language and its font.",
rows: |_| {
vec![
HRow::Page("Language", "", Page::Language),
HRow::Opt(Opt::ForceUnicode),
HRow::Opt(Opt::JapaneseGlyphs),
]
},
},
Section {
name: "Chat",
blurb: "What chat shows and how it looks.",
rows: |_| {
[
Opt::ChatVisibility,
Opt::ChatColors,
Opt::ChatLinks,
Opt::ChatOpacity,
Opt::ChatScale,
Opt::ChatWidth,
Opt::ChatHeightFocused,
Opt::ChatHeightUnfocused,
Opt::AutoSuggestions,
Opt::ReducedDebugInfo,
]
.map(HRow::Opt)
.to_vec()
},
},
Section {
name: "Interface",
blurb: "Menus and resource packs.",
rows: |_| {
vec![
HRow::Style,
HRow::Page("Resource packs", "Textures, sounds and fonts", Page::Packs),
]
},
},
];
const NAV: u64 = id("options.nav", 0);
const DONE: u64 = id("options.done", 0);
const ROW: u64 = id("options.row", 0);
const STYLE: u64 = id("options.style", 0);
const ROW_H: f32 = 12.0 + CONTROL + 12.0;
const HEADER_H: f32 = 40.0;
/// Whether an option is an on/off switch, and its value.
fn switch_value(opt: Opt, s: &Settings) -> Option<bool> {
Some(match opt {
Opt::InvertY => s.invert_y,
Opt::DiscreteScroll => s.discrete_scroll,
Opt::ViewBobbing => s.view_bobbing,
Opt::Vsync => s.vsync,
Opt::Fullscreen => s.fullscreen,
Opt::SmoothLighting => s.smooth_lighting,
Opt::EntityShadows => s.entity_shadows,
Opt::Vignette => s.vignette,
Opt::Autosave => s.autosave_indicator,
Opt::CutoutLeaves => s.cutout_leaves,
Opt::ImprovedTransparency => s.improved_transparency,
Opt::AutoJump => s.auto_jump,
Opt::ChatColors => s.chat_colors,
Opt::ChatLinks => s.chat_links,
Opt::AutoSuggestions => s.auto_suggestions,
Opt::ReducedDebugInfo => s.reduced_debug_info,
Opt::HighContrast => s.high_contrast,
Opt::ShowSubtitles => s.show_subtitles,
Opt::ForceUnicode => s.force_unicode,
Opt::JapaneseGlyphs => s.japanese_glyphs,
_ => return None,
})
}
/// The option's caption and value text, from its vanilla label
/// ("Caption: Value") and its caption key.
fn caption_value(opt: Opt, s: &Settings) -> (String, String) {
let label = opt.label(s).plain();
let caption = crate::text::Text::translate(opt.caption(), &[]).plain();
if let Some(rest) = label.strip_prefix(&caption) {
return (caption, rest.trim_start_matches([':', ' ']).to_owned());
}
match label.split_once(": ") {
Some((c, v)) => (c.to_owned(), v.to_owned()),
None => (caption, label),
}
}
/// What a click hit.
enum Target {
Nav(usize),
Done,
Row(usize),
Slider(Opt, f32),
Style(usize),
}
impl OptionsUi {
/// Lay out (and draw, on a real frame) the Horizon options shell.
/// Returns the sliders' tracks (option, x, width) for dragging.
fn horizon_layout(&mut self, s: &Settings, ui: &mut Ui, backdrop: f32) -> Vec<(Opt, f32, f32)> {
let (w, h) = (ui.w, ui.h);
if self.in_world {
ui.atmosphere(0.45, 1.0);
} else {
if backdrop < 1.0 {
ui.cold_background(1.0 - backdrop);
}
ui.atmosphere(0.66, 1.0);
}
let section = self.section.min(SECTIONS.len() - 1);
// The sidebar.
ui.fill(0.0, 0.0, SIDEBAR, h, color::with_alpha(color::SIDEBAR, 0.8));
ui.vline(SIDEBAR - 1.0, 0.0, h, color::BORDER);
ui.text_line(
Face::Display,
22.0,
30.0,
22.0,
16.0,
color::TEXT,
"Options",
);
let names: Vec<&str> = SECTIONS.iter().map(|s| s.name).collect();
ui.sidebar_list(NAV, 12.0, 66.0, SIDEBAR - 24.0, &names, section);
let dy = h - 12.0 - CONTROL;
ui.button(
DONE,
[12.0, dy, SIDEBAR - 24.0, CONTROL],
"Done",
Button::Secondary,
true,
);
// The page fades in and rises 8 when the section changes.
let p = ui.tween(id("options.page", section as u64), 1.0, t::move_());
if self.section_shown != Some(section) {
self.section_shown = Some(section);
ui.restart(id("options.page", section as u64), 0.0, 1.0, t::move_());
}
let x = SIDEBAR + 48.0;
let cw = (w - x - 48.0).min(760.0);
ui.opacity = p;
ui.offset = (0.0, 8.0 * (1.0 - p));
let sec = &SECTIONS[section];
let mut y = 36.0;
y += ui.page_header(x, y, sec.name, sec.blurb) + 24.0;
let top = y;
let rows = (sec.rows)(s);
let content: f32 = rows
.iter()
.map(|r| {
if matches!(r, HRow::Header(_)) {
HEADER_H
} else {
ROW_H
}
})
.sum();
let bottom = h - 24.0;
let max = (content - (bottom - top)).max(0.0);
self.hscroll = self.hscroll.clamp(0.0, max);
y -= self.hscroll;
let vstart = ui.batch.vertices.len();
let hstart = ui.hits.len();
let mut sliders = Vec::new();
ui.divider(x, y, cw);
let control_x = x + cw;
for (i, row) in rows.iter().enumerate() {
match *row {
HRow::Header(label) => {
ui.text(Face::Medium, SMALL, x, y + 18.0, color::FAINT, label);
y += HEADER_H;
ui.divider(x, y, cw);
continue;
}
HRow::Opt(opt) => {
let (caption, value) = caption_value(opt, s);
let cy = y + ROW_H / 2.0;
if let Some(on) = switch_value(opt, s) {
ui.row(x, y, &caption, "", 0.0);
ui.switch(ROW + i as u64, control_x, cy, on);
} else if let Kind::Slider(v) = opt.kind(s) {
let sw = 220.0_f32.min(cw - ROW_LABEL - 200.0).max(80.0);
ui.row(x, y, &caption, &value, cw - ROW_LABEL - 16.0 - sw - 24.0);
let sx = control_x - sw - 8.0;
ui.slider(ROW + i as u64, sx, cy, sw, v);
sliders.push((opt, sx, sw));
} else {
ui.row(x, y, &caption, "", 0.0);
let bw = ui.button_width(&value, false).max(96.0);
ui.button(
ROW + i as u64,
[control_x - bw, y + 12.0, bw, CONTROL],
&value,
Button::Secondary,
true,
);
}
}
HRow::Page(label, value, _) => {
let value = if value.is_empty() {
self.languages
.iter()
.find(|l| l.code == s.language)
.map_or_else(|| s.language.clone(), |l| l.label())
} else {
value.to_owned()
};
ui.row(x, y, label, &value, cw - ROW_LABEL - 140.0);
let bw = ui.button_width("Change…", false);
let open = if label == "Language" {
"Change…"
} else {
"Open…"
};
let bw = bw.max(ui.button_width(open, false));
ui.button(
ROW + i as u64,
[control_x - bw, y + 12.0, bw, CONTROL],
open,
Button::Secondary,
true,
);
}
HRow::Style => {
ui.row(x, y, "Menu style", "", 0.0);
let labels = ["Horizon", "Classic"];
let probe = ui.segmented(STYLE, -10_000.0, -10_000.0, &labels, 0);
let selected = usize::from(t::style() == MenuStyle::Classic);
ui.segmented(STYLE, control_x - probe, y + 12.0 - 3.0, &labels, selected);
}
}
y += ROW_H;
ui.divider(x, y, cw);
}
// Clip the rows to the page below the header, like the launcher's scroll view.
let (ox, oy) = ui.offset;
let scale = ui.batch.scale;
let k = ui.density / scale;
let (y0, y1) = ((top - 1.0 + oy) * k, (bottom + oy) * k);
ui.batch.clip_since(vstart, 0.0, y0, ui.batch.width, y1);
let (cy0, cy1) = (top - 1.0 + oy, bottom + oy);
let kept: Vec<_> = ui
.hits
.drain(hstart..)
.filter(|h| h.rect[1] >= cy0 && h.rect[3] <= cy1)
.collect();
ui.hits.extend(kept);
if max > 0.0 {
// A thin scroll indicator.
let track = bottom - top;
let bar = (track * track / (track + max)).max(24.0);
let by = top + (track - bar) * (self.hscroll / max);
ui.rounded(
x + cw + 16.0,
by,
3.0,
bar,
1.5,
color::with_alpha(color::MUTED, 0.35),
);
}
let _ = ox;
ui.opacity = 1.0;
ui.offset = (0.0, 0.0);
sliders
}
pub(crate) fn draw_horizon(
&mut self,
s: &Settings,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
) {
let mut ui = Ui::new(atlas, batch, cursor);
let backdrop = t::backdrop();
self.horizon_layout(s, &mut ui, backdrop);
ui.finish();
}
fn horizon_target(&mut self, s: &Settings, atlas: &HudAtlas, x: f32, y: f32) -> Option<Target> {
let mut batch = t::scratch_batch();
let mut ui = Ui::dry(atlas, &mut batch, (x, y));
let sliders = self.horizon_layout(s, &mut ui, 1.0);
let hit = ui.hit((x, y))?;
let section = self.section.min(SECTIONS.len() - 1);
if (NAV..NAV + SECTIONS.len() as u64).contains(&hit) {
return Some(Target::Nav((hit - NAV) as usize));
}
if hit == DONE {
return Some(Target::Done);
}
if hit == STYLE || hit == STYLE + 1 {
return Some(Target::Style((hit - STYLE) as usize));
}
let i = hit.checked_sub(ROW)? as usize;
let rows = (SECTIONS[section].rows)(s);
if let Some(HRow::Opt(opt)) = rows.get(i)
&& let Some(&(_, sx, sw)) = sliders.iter().find(|(o, ..)| o == opt)
{
let v = t::slider_value(&ui, sx, sw, x);
return Some(Target::Slider(*opt, v));
}
(i < rows.len()).then_some(Target::Row(i))
}
/// A click (or a drag with the button held) on the Horizon shell.
pub(crate) fn click_horizon(
&mut self,
s: &mut Settings,
atlas: &HudAtlas,
x: f32,
y: f32,
drag: bool,
right: bool,
) -> Vec<Event> {
let mut events = Vec::new();
if drag {
if let Some(opt) = self.dragging {
let mut batch = t::scratch_batch();
let mut ui = Ui::dry(atlas, &mut batch, (x, y));
let sliders = self.horizon_layout(s, &mut ui, 1.0);
if let Some(&(_, sx, sw)) = sliders.iter().find(|(o, ..)| *o == opt) {
opt.set_slider(s, t::slider_value(&ui, sx, sw, x));
if opt == Opt::Preset {
apply_preset(s);
}
events.push(Event::Changed);
}
}
return events;
}
match self.horizon_target(s, atlas, x, y) {
Some(Target::Nav(i)) => {
self.section = i;
self.hscroll = 0.0;
}
Some(Target::Done) => events.extend(self.back(s)),
Some(Target::Style(i)) => {
t::set_style(if i == 1 {
MenuStyle::Classic
} else {
MenuStyle::Horizon
});
if let Ok(dir) = std::env::current_dir() {
t::save_preferences(&dir);
}
}
Some(Target::Slider(opt, v)) => {
self.dragging = Some(opt);
opt.set_slider(s, v);
if opt == Opt::Preset {
apply_preset(s);
}
events.push(Event::Changed);
}
Some(Target::Row(i)) => {
let section = self.section.min(SECTIONS.len() - 1);
match (SECTIONS[section].rows)(s).get(i) {
Some(HRow::Opt(opt)) => {
opt.cycle(s, right);
if *opt == Opt::Preset {
apply_preset(s);
}
events.push(Event::Changed);
}
Some(HRow::Page(_, _, page)) => self.open_page(*page),
_ => {}
}
}
None => {}
}
events
}
pub(crate) fn scroll_horizon(&mut self, amount: f32) {
self.hscroll -= amount * 40.0;
}
}
+27 -4
View File
@@ -92,13 +92,13 @@ fn chat_px(caption: &str, px: f32) -> Text {
&[tr(caption), Text::literal((px as i32).to_string())],
)
}
enum Kind {
pub(crate) enum Kind {
/// Slider position 0..1.
Slider(f32),
Cycle,
}
impl Opt {
fn caption(self) -> &'static str {
pub(crate) fn caption(self) -> &'static str {
match self {
Opt::Fov => "options.fov",
Opt::RenderDistance => "options.renderDistance",
@@ -155,7 +155,7 @@ impl Opt {
Opt::JapaneseGlyphs => "options.japaneseGlyphVariants",
}
}
fn kind(self, s: &Settings) -> Kind {
pub(crate) fn kind(self, s: &Settings) -> Kind {
let unit = |v: f32, lo: f32, hi: f32| Kind::Slider(((v - lo) / (hi - lo)).clamp(0.0, 1.0));
match self {
Opt::Fov => unit(s.fov, 30.0, 110.0),
@@ -590,7 +590,12 @@ pub enum Event {
pub struct OptionsUi {
pub stack: Vec<Page>,
scroll: f32,
dragging: Option<Opt>,
pub(crate) dragging: Option<Opt>,
/// Horizon style: the selected sidebar section, the one last drawn and
/// the rows' scroll in dp (`options_horizon`).
pub section: usize,
pub(crate) section_shown: Option<usize>,
pub(crate) hscroll: f32,
/// Key Binds: the mapping waiting for a key.
pub selected_key: Option<&'static str>,
pub languages: Vec<LanguageInfo>,
@@ -615,6 +620,9 @@ impl Default for OptionsUi {
stack: vec![Page::Main],
scroll: 0.0,
dragging: None,
section: 0,
section_shown: None,
hscroll: 0.0,
selected_key: None,
languages: Vec::new(),
language_choice: None,
@@ -693,6 +701,10 @@ impl OptionsUi {
self.in_world = in_world;
self.selected_key = None;
}
/// Open one of vanilla's pages over the current one.
pub(crate) fn open_page(&mut self, page: Page) {
self.push(page);
}
fn push(&mut self, page: Page) {
self.stack.push(page);
self.scroll = 0.0;
@@ -1175,6 +1187,10 @@ impl OptionsUi {
cursor: (f32, f32),
) {
let (w, h) = (batch.width, batch.height);
if crate::ui_theme::horizon() && self.page() == Page::Main {
self.draw_horizon(s, batch, atlas, cursor);
return;
}
if self.page() == Page::Packs {
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
let blink = self.pack_focus.elapsed().as_millis() as u64;
@@ -1324,6 +1340,9 @@ impl OptionsUi {
drag: bool,
right: bool,
) -> Vec<Event> {
if crate::ui_theme::horizon() && self.page() == Page::Main {
return self.click_horizon(s, atlas, x, y, drag, right);
}
if self.page() == Page::Packs {
let mut events = Vec::new();
if drag || right {
@@ -1427,6 +1446,10 @@ impl OptionsUi {
}
/// The mouse wheel at the cursor.
pub fn scroll_at(&mut self, cursor: (f32, f32), amount: f32) {
if crate::ui_theme::horizon() && self.page() == Page::Main {
self.scroll_horizon(amount);
return;
}
match &mut self.pack_screen {
Some(p) if self.stack.last() == Some(&Page::Packs) => {
p.scroll(cursor.0 as f64, cursor.1 as f64, amount as f64)
+273
View File
@@ -0,0 +1,273 @@
//! The menus' backdrop treatment (`ui_theme`'s Horizon style): the finished
//! world frame is blurred (dual Kawase: three halvings down, back up) and
//! colour graded before the HUD is drawn over it. Needs a swapchain that can
//! be copied from; without one the frame is left as it is.
/// How the frame under a Horizon-style menu is treated.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PostGrade {
/// Colour multiplier and lift (`c * tint + lift`).
pub tint: [f32; 3],
pub lift: [f32; 3],
/// Toward grey, 0..1.
pub desaturate: f32,
/// Darkening toward the corners, 0..1.
pub vignette: f32,
}
impl PostGrade {
/// The title backdrop: icy, slightly lifted shadows.
pub const FROST: Self = Self {
tint: [0.62, 0.78, 1.0],
lift: [0.0, 0.01, 0.035],
desaturate: 0.45,
vignette: 0.5,
};
/// Behind the pause menu: the world stays recognisable.
pub const PAUSE: Self = Self {
tint: [0.8, 0.86, 0.98],
lift: [0.0, 0.004, 0.012],
desaturate: 0.25,
vignette: 0.3,
};
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Uniform {
texel: [f32; 4],
tint: [f32; 4],
lift: [f32; 4],
params: [f32; 4],
}
const LEVELS: usize = 3;
pub struct Post {
layout: wgpu::BindGroupLayout,
down: wgpu::RenderPipeline,
up: wgpu::RenderPipeline,
finish: wgpu::RenderPipeline,
sampler: wgpu::Sampler,
/// The copied frame, then the halvings.
levels: Vec<(wgpu::Texture, wgpu::TextureView, [u32; 2])>,
uniforms: Vec<wgpu::Buffer>,
size: [u32; 2],
format: wgpu::TextureFormat,
}
impl Post {
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Menu backdrop"),
source: wgpu::ShaderSource::Wgsl(include_str!("post.wgsl").into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Menu backdrop bindings"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Menu backdrop"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = |entry: &str| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Menu backdrop pass"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
})
};
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Menu backdrop"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
let uniforms = (0..LEVELS * 2 + 1)
.map(|_| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Menu backdrop pass"),
size: std::mem::size_of::<Uniform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
})
.collect();
Self {
down: pipeline("fs_down"),
up: pipeline("fs_up"),
finish: pipeline("fs_final"),
layout,
sampler,
levels: Vec::new(),
uniforms,
size: [0, 0],
format,
}
}
fn ensure(&mut self, device: &wgpu::Device, size: [u32; 2]) {
if self.size == size && !self.levels.is_empty() {
return;
}
self.size = size;
self.levels = (0..=LEVELS)
.map(|i| {
let s = [(size[0] >> i).max(1), (size[1] >> i).max(1)];
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Menu backdrop level"),
size: wgpu::Extent3d {
width: s[0],
height: s[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.format,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
(texture, view, s)
})
.collect();
}
fn pass(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
pipeline: &wgpu::RenderPipeline,
(from, uniform): (usize, usize),
target: &wgpu::TextureView,
data: Uniform,
) {
let (_, view, s) = &self.levels[from];
let mut data = data;
data.texel = [0.5 / s[0] as f32, 0.5 / s[1] as f32, 0.0, 0.0];
queue.write_buffer(&self.uniforms[uniform], 0, bytemuck::bytes_of(&data));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu backdrop pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[uniform].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu backdrop pass"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
/// Blur and grade `frame` (the swapchain texture) into `target`.
#[allow(clippy::too_many_arguments)]
pub fn apply(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
frame: &wgpu::Texture,
target: &wgpu::TextureView,
size: [u32; 2],
grade: &PostGrade,
) {
self.ensure(device, size);
encoder.copy_texture_to_texture(
frame.as_image_copy(),
self.levels[0].0.as_image_copy(),
wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
);
let data = Uniform {
texel: [0.0; 4],
tint: [grade.tint[0], grade.tint[1], grade.tint[2], 1.0],
lift: [grade.lift[0], grade.lift[1], grade.lift[2], 0.0],
params: [grade.desaturate, grade.vignette, 0.0, 0.0],
};
let mut u = 0;
for i in 1..=LEVELS {
let target = &self.levels[i].1;
self.pass(device, queue, encoder, &self.down, (i - 1, u), target, data);
u += 1;
}
for i in (1..LEVELS).rev() {
let target = &self.levels[i].1;
self.pass(device, queue, encoder, &self.up, (i + 1, u), target, data);
u += 1;
}
self.pass(device, queue, encoder, &self.finish, (1, u), target, data);
}
}
+51
View File
@@ -0,0 +1,51 @@
// Menu backdrop: dual-Kawase blur (down and up passes) and a cold colour grade.
struct Post {
// Half a source texel (u, v), unused, unused.
texel: vec4<f32>,
// Multiplier and lift of the grade.
tint: vec4<f32>,
lift: vec4<f32>,
// Desaturation, vignette, unused, unused.
params: vec4<f32>,
}
@group(0) @binding(0) var src: texture_2d<f32>;
@group(0) @binding(1) var samp: sampler;
@group(0) @binding(2) var<uniform> post: Post;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex fn vs_main(@builtin(vertex_index) i: u32) -> Out {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: Out;
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2(p.x, 1.0 - p.y);
return out;
}
fn s(uv: vec2<f32>) -> vec3<f32> {
return textureSampleLevel(src, samp, uv, 0.0).rgb;
}
@fragment fn fs_down(in: Out) -> @location(0) vec4<f32> {
let h = post.texel.xy;
let c = s(in.uv) * 4.0 + s(in.uv - h) + s(in.uv + h) + s(in.uv + vec2(h.x, -h.y)) + s(in.uv - vec2(h.x, -h.y));
return vec4(c / 8.0, 1.0);
}
fn up(uv: vec2<f32>) -> vec3<f32> {
let h = post.texel.xy;
var c = s(uv + vec2(-h.x * 2.0, 0.0)) + s(uv + vec2(h.x * 2.0, 0.0));
c += s(uv + vec2(0.0, -h.y * 2.0)) + s(uv + vec2(0.0, h.y * 2.0));
c += (s(uv + vec2(-h.x, h.y)) + s(uv + vec2(h.x, h.y)) + s(uv + vec2(-h.x, -h.y)) + s(uv + vec2(h.x, -h.y))) * 2.0;
return c / 12.0;
}
@fragment fn fs_up(in: Out) -> @location(0) vec4<f32> {
return vec4(up(in.uv), 1.0);
}
@fragment fn fs_final(in: Out) -> @location(0) vec4<f32> {
var c = up(in.uv);
let l = dot(c, vec3(0.2126, 0.7152, 0.0722));
c = mix(c, vec3(l), post.params.x);
c = c * post.tint.rgb + post.lift.rgb;
let d = length((in.uv - 0.5) * vec2(1.25, 1.0));
c *= 1.0 - post.params.y * smoothstep(0.35, 0.95, d);
return vec4(clamp(c, vec3(0.0), vec3(1.0)), 1.0);
}
+83 -40
View File
@@ -50,6 +50,12 @@ pub struct Title {
pub mode: Mode,
pub saves: PathBuf,
pub started: std::time::Instant,
/// The mode last drawn and when it appeared (Horizon's screen fades).
shown: std::cell::Cell<(std::mem::Discriminant<Mode>, std::time::Instant)>,
/// The launcher-style opening (Horizon style, first title of the session).
pub opening: std::cell::Cell<Option<crate::front::Opening>>,
/// The saves listed for Home, re-read every couple of seconds.
recent: std::cell::RefCell<Option<(std::time::Instant, Vec<WorldEntry>)>>,
}
struct Button {
x: f32,
@@ -96,10 +102,78 @@ pub fn list_worlds(saves: &Path) -> Vec<WorldEntry> {
}
impl Title {
pub fn new(saves: PathBuf) -> Self {
let mode = Mode::Main;
let now = std::time::Instant::now();
Self {
mode: Mode::Main,
shown: std::cell::Cell::new((std::mem::discriminant(&mode), now)),
mode,
saves,
started: std::time::Instant::now(),
started: now,
opening: std::cell::Cell::new(None),
recent: std::cell::RefCell::new(None),
}
}
/// The worlds, newest first, cached for two seconds (drawn every frame).
pub fn recent_worlds(&self) -> Vec<WorldEntry> {
let mut recent = self.recent.borrow_mut();
match &*recent {
Some((at, worlds)) if at.elapsed().as_secs() < 2 => worlds.clone(),
_ => {
let worlds = list_worlds(&self.saves);
*recent = Some((std::time::Instant::now(), worlds.clone()));
worlds
}
}
}
/// When the current mode first appeared.
pub fn entered(&self) -> std::time::Instant {
let now = std::mem::discriminant(&self.mode);
let (mode, at) = self.shown.get();
if mode == now {
return at;
}
let at = std::time::Instant::now();
self.shown.set((now, at));
at
}
/// Create New World: the folder name and options (FileUtil.findAvailableName).
pub fn create_action(&self) -> Action {
let Mode::Create {
name,
seed,
creative,
..
} = &self.mode
else {
return Action::None;
};
let base: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let base = if base.trim().is_empty() {
"World".to_owned()
} else {
base.trim().to_owned()
};
// FileUtil.findAvailableName: append (n) until unused.
let mut folder = base.clone();
let mut n = 1;
while self.saves.join(&folder).exists() {
folder = format!("{base} ({n})");
n += 1;
}
Action::Create {
folder,
name: name.clone(),
seed: parse_seed(seed),
creative: *creative,
}
}
fn buttons(&self, w: f32, h: f32) -> Vec<Button> {
@@ -446,44 +520,7 @@ impl Title {
*creative = !*creative;
Action::None
}
(
Mode::Create {
name,
seed,
creative,
..
},
Some(21),
) => {
let base: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let base = if base.trim().is_empty() {
"World".to_owned()
} else {
base.trim().to_owned()
};
// FileUtil.findAvailableName: append (n) until unused.
let mut folder = base.clone();
let mut n = 1;
while self.saves.join(&folder).exists() {
folder = format!("{base} ({n})");
n += 1;
}
Action::Create {
folder,
name: name.clone(),
seed: parse_seed(seed),
creative: *creative,
}
}
(Mode::Create { .. }, Some(21)) => self.create_action(),
(Mode::Create { .. }, Some(22)) => {
self.mode = Mode::Select {
worlds: list_worlds(&self.saves),
@@ -510,6 +547,7 @@ impl Title {
if let Mode::Create {
name, seed, focus, ..
} = &mut self.mode
&& *focus < 2
{
let field = if *focus == 0 { name } else { seed };
for c in text.chars().filter(|c| !c.is_control()) {
@@ -526,6 +564,7 @@ impl Title {
if let Mode::Create {
name, seed, focus, ..
} = &mut self.mode
&& *focus < 2
{
let field = if *focus == 0 { name } else { seed };
field.pop();
@@ -554,6 +593,10 @@ impl Title {
};
}
}
/// `yyyy/MM/dd HH:mm` (UTC) for a modification time in Unix seconds.
pub fn date_label(secs: u64) -> String {
date_string(secs)
}
fn date_string(secs: u64) -> String {
// Civil date from Unix time (days-from-civil inverse), UTC.
let days = (secs / 86400) as i64;
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "horizon-theme"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "Horizon's design tokens and fonts, shared by the launcher and the game's menus"
[dependencies]
File renamed without changes.
+120
View File
@@ -0,0 +1,120 @@
//! Horizon's design tokens, shared by the launcher (`launcher/src/theme.rs`)
//! and the game's menus (`horizon-client`'s `ui_theme`), so the two read as
//! one product: change a value here and both follow.
//!
//! One neutral palette in two appearances and one accent. Spacing follows
//! 4/8/12/16/24/32. Type: Geist for the interface, Instrument Serif for
//! titles, Geist Mono for consoles and commands, all bundled here under the
//! SIL Open Font License (`fonts/OFL-*.txt`). Five sizes: 12, 13, 15, 28 and 56.
//! Colours are `0xRRGGBB`; sizes are in logical pixels.
use std::time::Duration;
pub const FONT: &str = "Geist";
pub const DISPLAY: &str = "Instrument Serif";
pub const MONO: &str = "Geist Mono";
/// The bundled font files.
pub mod fonts {
pub const GEIST_REGULAR: &[u8] = include_bytes!("../fonts/Geist-Regular.ttf");
pub const GEIST_MEDIUM: &[u8] = include_bytes!("../fonts/Geist-Medium.ttf");
pub const GEIST_SEMIBOLD: &[u8] = include_bytes!("../fonts/Geist-SemiBold.ttf");
pub const GEIST_MONO: &[u8] = include_bytes!("../fonts/GeistMono-Regular.ttf");
pub const INSTRUMENT_SERIF: &[u8] = include_bytes!("../fonts/InstrumentSerif-Regular.ttf");
}
pub const SMALL: f32 = 12.;
pub const BODY: f32 = 13.;
pub const LARGE: f32 = 15.;
pub const TITLE: f32 = 28.;
pub const HERO: f32 = 56.;
/// The opening's wordmark.
pub const WORDMARK: f32 = 96.;
/// The spacing scale.
pub const SPACE: [f32; 6] = [4., 8., 12., 16., 24., 32.];
/// Corner radii: buttons, fields, nav items and rows; segments inside a
/// segmented control; the segmented control's frame; menus and cards.
pub const RADIUS: f32 = 6.;
pub const RADIUS_SEGMENT: f32 = 5.;
pub const RADIUS_GROUP: f32 = 7.;
pub const RADIUS_CARD: f32 = 8.;
/// Dividers and borders are one pixel.
pub const HAIRLINE: f32 = 1.;
/// Control heights: buttons and nav items, segments, the large Play button.
pub const CONTROL: f32 = 30.;
pub const SEGMENT: f32 = 26.;
pub const CONTROL_LARGE: f32 = 40.;
/// A pressed primary button's opacity (secondary and ghost: `PRESSED_SOFT`).
pub const PRESSED: f32 = 0.82;
pub const PRESSED_SOFT: f32 = 0.8;
/// Disabled controls' opacity.
pub const DISABLED: f32 = 0.4;
/// The hover layer of a primary button: white at this opacity.
pub const PRIMARY_HOVER: f32 = 0.10;
/// Sidebar width and the label column of settings rows.
pub const SIDEBAR: f32 = 200.;
pub const ROW_LABEL: f32 = 150.;
/// Motion: short and eased out, never bouncy.
pub const QUICK: Duration = Duration::from_millis(160);
pub const MOVE: Duration = Duration::from_millis(240);
/// The opening: the wordmark holds, then the interface appears.
pub const OPENING_HOLD: Duration = Duration::from_millis(1400);
pub const OPENING_MAX: Duration = Duration::from_millis(2500);
pub const OPENING_REVEAL: Duration = Duration::from_millis(800);
/// GPUI's `ease_out_quint`, used for hovers, the sliding selection and page changes.
pub fn ease_out_quint(t: f32) -> f32 {
1. - (1. - t.clamp(0., 1.)).powi(5)
}
pub fn ease_out_cubic(t: f32) -> f32 {
1. - (1. - t.clamp(0., 1.)).powi(3)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Palette {
pub bg: u32,
pub sidebar: u32,
pub raised: u32,
pub hover: u32,
pub border: u32,
pub text: u32,
pub muted: u32,
pub faint: u32,
pub accent: u32,
pub on_accent: u32,
pub danger: u32,
pub dark: bool,
}
pub const DARK: Palette = Palette {
bg: 0x111113,
sidebar: 0x0c0c0d,
raised: 0x1a1a1d,
hover: 0x202024,
border: 0x26262a,
text: 0xe8e8ea,
muted: 0x9a9aa1,
faint: 0x626269,
accent: 0x7c5cc4,
on_accent: 0xffffff,
danger: 0xd9705f,
dark: true,
};
pub const LIGHT: Palette = Palette {
bg: 0xfcfcfb,
sidebar: 0xf4f4f2,
raised: 0xf2f2f0,
hover: 0xebebe8,
border: 0xe3e3df,
text: 0x19191b,
muted: 0x63636a,
faint: 0x9a9a9f,
accent: 0x6a4bb3,
on_accent: 0xffffff,
danger: 0xb84a3a,
dark: false,
};
+1
View File
@@ -7,6 +7,7 @@ To play, use [the launcher](../launcher/README.md). These pages are for develope
- [Build from source](../README.md#build-from-source): your own copy of the game, resource preparation, `cargo run`.
- [Native client](native-client.md): options, controls, the dedicated server, `horizon-ctl` scripting, packaging, architecture.
- [Launcher](../launcher/README.md): installs, updates and starts builds; its CLI and MCP server; releasing it.
- Menus: the title, world list, create world, multiplayer, options and pause screens share the launcher's design tokens and fonts (`crates/horizon-theme`), drawn by `ui_theme` over a live frozen backdrop (`game/backdrop.rs`, blurred and graded by `post.rs`). Options → Interface → Menu style switches to vanilla's screens (Classic, kept in `horizon.txt`; `HORIZON_MENU_STYLE=horizon|classic` overrides it, and scripted runs default to Classic). [Title](media/menu-title.png) ([webm](media/menu-title.webm)), [worlds](media/menu-worlds.png), [options](media/menu-options.png), [pause](media/menu-pause.png).
## Parity
Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

+5
View File
@@ -2571,6 +2571,7 @@ dependencies = [
"dirs 5.0.1",
"flate2",
"gpui",
"horizon-theme",
"image",
"pulldown-cmark",
"self-replace",
@@ -2581,6 +2582,10 @@ dependencies = [
"zip",
]
[[package]]
name = "horizon-theme"
version = "0.1.0"
[[package]]
name = "http"
version = "1.5.0"
+1
View File
@@ -20,6 +20,7 @@ dirs = "5"
self-replace = "1"
image = { version = "0.25", default-features = false, features = ["jpeg"] }
clap = { version = "4", features = ["derive"] }
horizon-theme = { path = "../crates/horizon-theme" }
[profile.release]
strip = true
+1 -1
View File
@@ -87,7 +87,7 @@ workflow builds all three platforms on every push to the `launcher` branch.
cargo test --lib # tag separation, version order, update selection, notes as text
Geist, Geist Mono and Instrument Serif (SIL Open Font License, `assets/fonts/OFL-*.txt`) are
Geist, Geist Mono and Instrument Serif (SIL Open Font License, `crates/horizon-theme/fonts/OFL-*.txt`, shared with the game's menus) are
bundled so the type is the same on every platform.
Background image (`assets/background.jpg`): backgrounds.supply (free preview), licence to confirm before release.
+28 -28
View File
@@ -53,9 +53,9 @@ enum SelfUpdate {
/// The opening: the wordmark over the image holds at least `OPENING_HOLD` (until the release
/// list is in, at most `OPENING_MAX`), then the launcher appears over `OPENING_REVEAL`.
const OPENING_HOLD: Duration = Duration::from_millis(1400);
const OPENING_MAX: Duration = Duration::from_millis(2500);
const OPENING_REVEAL: Duration = Duration::from_millis(800);
const OPENING_HOLD: Duration = horizon_theme::OPENING_HOLD;
const OPENING_MAX: Duration = horizon_theme::OPENING_MAX;
const OPENING_REVEAL: Duration = horizon_theme::OPENING_REVEAL;
/// The phase of the opening at one moment: how far the reveal has gone, 0 to 1.
struct Opening {
@@ -78,7 +78,7 @@ fn background_image() -> Arc<RenderImage> {
}
fn ease_out_cubic(x: f32) -> f32 {
1. - (1. - x.clamp(0., 1.)).powi(3)
horizon_theme::ease_out_cubic(x)
}
/// How often the launcher looks for new releases while it is open.
@@ -560,7 +560,7 @@ fn button(id: impl Into<SharedString>, label: impl Into<SharedString>, kind: Kin
let id: SharedString = id.into();
let t = *t;
let hover = match kind {
Kind::Primary => gpui::white().opacity(0.10),
Kind::Primary => gpui::white().opacity(theme::PRIMARY_HOVER),
_ => t.hover,
};
let base = div()
@@ -570,19 +570,19 @@ fn button(id: impl Into<SharedString>, label: impl Into<SharedString>, kind: Kin
.flex_none()
.items_center()
.justify_center()
.h(px(30.))
.h(px(theme::CONTROL))
.px(px(12.))
.rounded(px(6.))
.rounded(px(theme::RADIUS))
.text_size(px(theme::BODY))
.font_weight(FontWeight::MEDIUM)
.cursor_pointer()
.child(hover_layer(&id, hover, 6.))
.child(hover_layer(&id, hover, theme::RADIUS))
.child(div().relative().child(label.into()));
let base = track_hover(base, id);
match kind {
Kind::Primary => base.bg(t.accent).text_color(t.on_accent).active(|s| s.opacity(0.82)),
Kind::Secondary => base.border_1().border_color(t.border).bg(t.raised).text_color(t.text).active(|s| s.opacity(0.8)),
Kind::Ghost => base.text_color(t.muted).hover(move |s| s.text_color(t.text)).active(|s| s.opacity(0.8)),
Kind::Primary => base.bg(t.accent).text_color(t.on_accent).active(|s| s.opacity(theme::PRESSED)),
Kind::Secondary => base.border_1().border_color(t.border).bg(t.raised).text_color(t.text).active(|s| s.opacity(theme::PRESSED_SOFT)),
Kind::Ghost => base.text_color(t.muted).hover(move |s| s.text_color(t.text)).active(|s| s.opacity(theme::PRESSED_SOFT)),
}
}
@@ -607,7 +607,7 @@ fn switch(id: &'static str, on: bool, t: &Theme) -> Stateful<Div> {
}
fn disabled(b: Stateful<Div>) -> Stateful<Div> {
b.opacity(0.4).cursor_default()
b.opacity(theme::DISABLED).cursor_default()
}
fn page_header(title: &str, description: &str, t: &Theme) -> Div {
@@ -637,7 +637,7 @@ fn row(label: &str, value: impl IntoElement, action: Option<Stateful<Div>>, t: &
.items_center()
.gap(px(16.))
.py(px(12.))
.child(div().w(px(150.)).flex_none().text_size(px(13.)).text_color(t.muted).child(label.to_string()))
.child(div().w(px(theme::ROW_LABEL)).flex_none().text_size(px(13.)).text_color(t.muted).child(label.to_string()))
.child(div().flex_1().min_w_0().text_size(px(13.)).text_color(t.text).child(value))
.children(action)
}
@@ -687,7 +687,7 @@ fn megabytes(bytes: u64) -> String {
impl Launcher {
fn sidebar(&self, cx: &mut Context<Self>) -> Div {
let t = self.theme;
const STEP: f32 = 32.;
const STEP: f32 = theme::CONTROL + 2.;
let mut nav = div().relative().flex().flex_col().gap(px(2.));
// The selection is one pill that slides to the chosen item.
let (from, to) = (self.previous_page.index() as f32 * STEP, self.page.index() as f32 * STEP);
@@ -696,8 +696,8 @@ impl Launcher {
.absolute()
.left_0()
.w_full()
.h(px(30.))
.rounded(px(6.))
.h(px(theme::CONTROL))
.rounded(px(theme::RADIUS))
.bg(t.raised)
.border_1()
.border_color(t.border)
@@ -715,12 +715,12 @@ impl Launcher {
.relative()
.flex()
.items_center()
.h(px(30.))
.h(px(theme::CONTROL))
.px(px(10.))
.rounded(px(6.))
.rounded(px(theme::RADIUS))
.text_size(px(theme::BODY))
.cursor_pointer()
.when(!active, |d| d.child(hover_layer(&id, t.hover.opacity(0.7), 6.)))
.when(!active, |d| d.child(hover_layer(&id, t.hover.opacity(0.7), theme::RADIUS)))
.child(
div()
.relative()
@@ -756,7 +756,7 @@ impl Launcher {
.flex()
.flex_col()
.flex_none()
.w(px(200.))
.w(px(theme::SIDEBAR))
.h_full()
.bg(t.sidebar.opacity(0.8))
.border_r_1()
@@ -785,9 +785,9 @@ impl Launcher {
.id(label)
.flex()
.items_center()
.h(px(26.))
.h(px(theme::SEGMENT))
.px(px(14.))
.rounded(px(5.))
.rounded(px(theme::RADIUS_SEGMENT))
.text_size(px(13.))
.cursor_pointer()
.when(active, |d| {
@@ -804,7 +804,7 @@ impl Launcher {
.flex()
.p(px(2.))
.gap(px(2.))
.rounded(px(7.))
.rounded(px(theme::RADIUS_GROUP))
.bg(t.raised)
.border_1()
.border_color(t.border)
@@ -1769,11 +1769,11 @@ fn main() {
Application::new().run(|cx: &mut App| {
cx.text_system()
.add_fonts(vec![
Cow::Borrowed(include_bytes!("../assets/fonts/Geist-Regular.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/Geist-Medium.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/Geist-SemiBold.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/GeistMono-Regular.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/InstrumentSerif-Regular.ttf").as_slice()),
Cow::Borrowed(horizon_theme::fonts::GEIST_REGULAR),
Cow::Borrowed(horizon_theme::fonts::GEIST_MEDIUM),
Cow::Borrowed(horizon_theme::fonts::GEIST_SEMIBOLD),
Cow::Borrowed(horizon_theme::fonts::GEIST_MONO),
Cow::Borrowed(horizon_theme::fonts::INSTRUMENT_SERIF),
])
.expect("bundled fonts");
let bounds = Bounds::centered(None, size(px(1040.), px(700.)), cx);
+37 -39
View File
@@ -1,22 +1,29 @@
//! One neutral palette in two appearances and one accent. Spacing follows 4/8/12/16/24/32.
//! Type: Geist for the interface, Instrument Serif for titles, Geist Mono for consoles and
//! commands, all bundled (SIL Open Font License). Five sizes: 12, 13, 15, 28 and 56.
//! The launcher's view of Horizon's design tokens (`crates/horizon-theme`), which the game's
//! menus share: one neutral palette in two appearances and one accent, spacing 4/8/12/16/24/32,
//! Geist / Instrument Serif / Geist Mono (bundled, SIL Open Font License), five sizes.
use gpui::{rgb, Hsla, WindowAppearance};
use horizon_theme as tokens;
use std::time::Duration;
pub const FONT: &str = "Geist";
pub const DISPLAY: &str = "Instrument Serif";
pub const MONO: &str = "Geist Mono";
pub const FONT: &str = tokens::FONT;
pub const DISPLAY: &str = tokens::DISPLAY;
pub const MONO: &str = tokens::MONO;
pub const SMALL: f32 = 12.;
pub const BODY: f32 = 13.;
pub const LARGE: f32 = 15.;
pub const TITLE: f32 = 28.;
pub const HERO: f32 = 56.;
pub const SMALL: f32 = tokens::SMALL;
pub const BODY: f32 = tokens::BODY;
pub const LARGE: f32 = tokens::LARGE;
pub const TITLE: f32 = tokens::TITLE;
pub const HERO: f32 = tokens::HERO;
/// Shapes and controls, shared with the game's menus.
pub use tokens::{
CONTROL, CONTROL_LARGE, DISABLED, PRESSED, PRESSED_SOFT, PRIMARY_HOVER, RADIUS, RADIUS_CARD, RADIUS_GROUP,
RADIUS_SEGMENT, ROW_LABEL, SEGMENT, SIDEBAR,
};
/// Motion: short and eased out, never bouncy.
pub const QUICK: Duration = Duration::from_millis(160);
pub const MOVE: Duration = Duration::from_millis(240);
pub const QUICK: Duration = tokens::QUICK;
pub const MOVE: Duration = tokens::MOVE;
#[derive(Clone, Copy)]
pub struct Theme {
@@ -47,36 +54,27 @@ impl Theme {
}
pub fn dark() -> Self {
Self {
bg: c(0x111113),
sidebar: c(0x0c0c0d),
raised: c(0x1a1a1d),
hover: c(0x202024),
border: c(0x26262a),
text: c(0xe8e8ea),
muted: c(0x9a9aa1),
faint: c(0x626269),
accent: c(0x7c5cc4),
on_accent: c(0xffffff),
danger: c(0xd9705f),
dark: true,
}
Self::from(tokens::DARK)
}
pub fn light() -> Self {
Self::from(tokens::LIGHT)
}
fn from(p: tokens::Palette) -> Self {
Self {
bg: c(0xfcfcfb),
sidebar: c(0xf4f4f2),
raised: c(0xf2f2f0),
hover: c(0xebebe8),
border: c(0xe3e3df),
text: c(0x19191b),
muted: c(0x63636a),
faint: c(0x9a9a9f),
accent: c(0x6a4bb3),
on_accent: c(0xffffff),
danger: c(0xb84a3a),
dark: false,
bg: c(p.bg),
sidebar: c(p.sidebar),
raised: c(p.raised),
hover: c(p.hover),
border: c(p.border),
text: c(p.text),
muted: c(p.muted),
faint: c(p.faint),
accent: c(p.accent),
on_accent: c(p.on_accent),
danger: c(p.danger),
dark: p.dark,
}
}
}