Merge main into #7292

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 20:38:24 +01:00
commit 82a73e8423
164 files changed
+10977 -2187

No files matched your search

+11
View File
@@ -36,6 +36,17 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
the same grid.
## Hugeicons
`crates/horizon-theme/icons/glass/*.svg` are icons from the free set of
[Hugeicons](https://github.com/hugeicons/hugeicons) (`@hugeicons/core-free-icons` 4.4.0), under
the MIT license (`crates/horizon-theme/icons/glass/LICENSE-hugeicons.txt`), exported with a 1.6
stroke in `currentColor`.
`crates/horizon-theme/icons/glass/discord.svg` is Discord's logo as drawn by
[Simple Icons](https://github.com/simple-icons/simple-icons) 13.21.0 (CC0 1.0). The logo is Discord's
trademark, used only on the button that opens Horizon's Discord server.
## AMD FidelityFX SDK (FSR 3.1 frame generation)
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` and `interpolation.wgsl` are
+203 -2
View File
@@ -182,13 +182,38 @@ impl LimiterClock for SystemClock {
i64::try_from(self.origin.elapsed().as_nanos()).unwrap_or(i64::MAX)
}
fn park(&mut self, nanos: i64) {
std::thread::sleep(std::time::Duration::from_nanos(nanos.max(0) as u64));
let end = self.nanos().saturating_add(nanos);
park_in_steps(
end,
|| self.nanos(),
|step| {
std::thread::sleep(std::time::Duration::from_nanos(step as u64));
},
);
}
fn spin(&mut self) {
std::hint::spin_loop();
}
}
/// The longest single sleep of [`SystemClock::park`].
const PARK_STEP_NS: i64 = 1_000_000;
/// Sleeps until `now()` reaches `end`, at most [`PARK_STEP_NS`] at a time. macOS lets
/// a sleep run late by a share of its length (timer coalescing: 1.8-3.5 ms on a 14 ms
/// sleep, 0.25 ms on 1 ms), more than the limiter's overshoot margin can learn (capped
/// at 2 ms): a 60 FPS cap gave frames of 17-20 ms. Short steps keep the lateness to
/// the last step's.
fn park_in_steps(end: i64, mut now: impl FnMut() -> i64, mut sleep: impl FnMut(i64)) {
loop {
let left = end - now();
if left <= 0 {
return;
}
sleep(left.min(PARK_STEP_NS));
}
}
/// The real clock without busy waiting: where vanilla spins on
/// `Thread.onSpinWait` for the last half millisecond, this sleeps in short
/// steps, so a capped frame rate leaves the core idle.
@@ -218,6 +243,18 @@ pub fn end_frame(
}
}
/// [`end_frame`] on a fixed grid of frame slots ([`FramerateLimiter::limit_on_grid`]):
/// Horizon's Efficient pacing.
pub fn end_frame_on_grid(
limiter: &mut FramerateLimiter,
framerate_limit: i32,
clock: &mut impl LimiterClock,
) {
if framerate_limit < 260 {
limiter.limit_on_grid(framerate_limit, clock);
}
}
impl FramerateLimiter {
pub fn new(now_ns: i64) -> Self {
Self::with_state(now_ns, 0, 0)
@@ -248,6 +285,37 @@ impl FramerateLimiter {
self.average_overshoot_ns = 0;
self.last_framerate_limit = framerate_limit;
}
self.wait_until(target_time_ns, clock);
self.last_frame_time = clock.nanos();
}
/// Horizon's Efficient pacing: [`Self::limit_display_fps`]'s wait, but each frame's
/// slot starts where the last one's ended (its deadline), not when the thread woke
/// up. Vanilla's way adds every late wake-up to the frame (on the Mac, 50 us sleeps
/// running 0.2 ms late made a 144 FPS cap 139); on a fixed grid the cap holds on
/// average. A frame that ends past its slot, or wakes up more than half a frame
/// late, starts the grid again from now: no frame is rushed out to catch up.
pub fn limit_on_grid(&mut self, framerate_limit: i32, clock: &mut impl LimiterClock) {
let period = 1_000_000_000 / i64::from(framerate_limit);
if framerate_limit != self.last_framerate_limit {
self.average_overshoot_ns = 0;
self.last_framerate_limit = framerate_limit;
}
let deadline = self.last_frame_time + period;
if clock.nanos() >= deadline {
self.last_frame_time = clock.nanos();
return;
}
self.wait_until(deadline, clock);
let woke = clock.nanos();
self.last_frame_time = if woke - deadline > period / 2 {
woke
} else {
deadline
};
}
/// `limitDisplayFPS`'s wait: sleep to within the learned overshoot and half a
/// millisecond of `target_time_ns`, then spin.
fn wait_until(&mut self, target_time_ns: i64, clock: &mut impl LimiterClock) {
loop {
let remaining = target_time_ns - clock.nanos();
if remaining <= 0 {
@@ -271,7 +339,6 @@ impl FramerateLimiter {
clock.spin();
}
}
self.last_frame_time = clock.nanos();
}
pub fn last_frame_time(&self) -> i64 {
self.last_frame_time
@@ -678,6 +745,140 @@ mod tests {
assert_eq!(calculate_scale([1920, 1080], 3, true), 4);
assert_eq!(gui_scaled_size([1921, 1081], 4), [481, 271]);
}
/// A clock whose sleeps run late by a quarter of their length, as macOS's do,
/// sleeping as [`SystemClock::park`] does.
struct Coalescing {
now: std::cell::Cell<i64>,
}
impl LimiterClock for Coalescing {
fn nanos(&mut self) -> i64 {
self.now.get()
}
fn park(&mut self, nanos: i64) {
let now = &self.now;
park_in_steps(
now.get() + nanos,
|| now.get(),
|step| {
now.set(now.get() + step + step / 4);
},
);
}
fn spin(&mut self) {
self.now.set(self.now.get() + 1000);
}
}
#[test]
fn a_frame_cap_holds_when_sleeps_run_late() {
let mut clock = Coalescing {
now: std::cell::Cell::new(0),
};
let mut limiter = FramerateLimiter::new(0);
let mut last = 0;
for frame in 0..120 {
// The frame's own work.
clock.now.set(clock.now.get() + 2_000_000);
limiter.limit_display_fps(60, &mut clock);
let period = clock.now.get() - last;
last = clock.now.get();
assert!(
(16_666_666..16_666_666 + 100_000).contains(&period),
"frame {frame}: {period} ns"
);
}
}
/// A clock whose sleeps and spins each run up to `late` ns late (a different
/// share each time), as a loaded machine's do.
struct Late {
now: i64,
late: i64,
seed: u64,
}
impl Late {
fn new(late: i64) -> Self {
Self {
now: 0,
late,
seed: 0x9e37_79b9_7f4a_7c15,
}
}
fn lateness(&mut self) -> i64 {
self.seed ^= self.seed << 13;
self.seed ^= self.seed >> 7;
self.seed ^= self.seed << 17;
(self.seed % 1000) as i64 * self.late / 1000
}
}
impl LimiterClock for Late {
fn nanos(&mut self) -> i64 {
self.now
}
fn park(&mut self, nanos: i64) {
self.now += nanos + self.lateness();
}
fn spin(&mut self) {
self.now += 50_000 + self.lateness();
}
}
/// Frame periods of 144 FPS frames whose work takes `work(frame)` ns.
fn grid_periods(late: i64, work: impl Fn(usize) -> i64) -> Vec<i64> {
let mut clock = Late::new(late);
let mut limiter = FramerateLimiter::new(0);
let mut ends = Vec::new();
for frame in 0..300 {
clock.now += work(frame);
limiter.limit_on_grid(144, &mut clock);
ends.push(clock.now);
}
ends.windows(2).map(|w| w[1] - w[0]).collect()
}
#[test]
fn the_grid_holds_the_cap_when_wake_ups_run_late() {
let period = 1_000_000_000 / 144;
for late in [0, 50_000, 200_000, 1_000_000] {
let periods = grid_periods(late, |_| 2_000_000);
let mean = periods[10..].iter().sum::<i64>() / (periods.len() - 10) as i64;
assert!(
(mean - period).abs() < 10_000,
"{late} ns late: mean period {mean} ns"
);
// Each slot waits at least half a frame: the grid never rushes one out.
assert!(
periods.iter().all(|&p| p > period / 2),
"{late} ns late: {periods:?}"
);
}
// Vanilla's way adds the lateness to the frames: the cap isn't held.
let mut clock = Late::new(400_000);
let mut limiter = FramerateLimiter::new(0);
for _ in 0..300 {
clock.now += 2_000_000;
limiter.limit_display_fps(144, &mut clock);
}
assert!(
clock.now / 300 > period + 50_000,
"vanilla: {} ns",
clock.now / 300
);
}
#[test]
fn a_long_frame_starts_the_grid_again_without_a_burst() {
let period = 1_000_000_000 / 144;
// Frame 100 works 30 ms: four slots go by.
let periods = grid_periods(
50_000,
|frame| if frame == 100 { 30_000_000 } else { 2_000_000 },
);
assert!(periods[99] >= 30_000_000);
for (i, &p) in periods.iter().enumerate().skip(100) {
// On the grid a slot is a late wake-up (50 us at most) short or long.
assert!(
(period - 100_000..period + 100_000).contains(&p),
"frame {}: {p} ns",
i + 1
);
}
}
#[test]
fn limiter_paces_frames_at_the_target() {
let mut limiter = FramerateLimiter::new(0);
+108 -16
View File
@@ -828,6 +828,12 @@ pub struct Renderer {
/// Solid quads facing away from the eye are left out (`mesher::Sides`).
sides_on: bool,
sky: wgpu::RenderPipeline,
/// The main sky without the sunrise fan, which `sunrise_pipeline` draws as
/// geometry over it. The mirror keeps `sky`: its per-pixel fan is cheap at
/// the reflection's size and needs no second projection.
sky_base: wgpu::RenderPipeline,
sunrise_pipeline: wgpu::RenderPipeline,
sunrise_buffer: wgpu::Buffer,
dark_disc: wgpu::RenderPipeline,
/// Clears the world pass's depth to the far plane so the first-person hand draws in the
/// same pass (`hand_in_world`), as the hand's own pass would clear it.
@@ -2373,6 +2379,47 @@ impl Renderer {
false,
None,
);
let sky_base = pipeline(
"Sky base",
"vs_sky",
"fs_sky_base",
&[],
tri,
None,
false,
false,
None,
);
let sunrise_attributes = wgpu::vertex_attr_array![0=>Float32x4, 1=>Float32];
let sunrise_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: 20,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &sunrise_attributes,
})];
let sunrise_blend = wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
// The sky's HDR alpha is the destination's; the fan only tints it.
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
};
let sunrise_pipeline = pipeline(
"Sunrise fan",
"vs_sunrise",
"fs_sunrise",
&sunrise_buffers,
tri,
None,
false,
false,
Some(sunrise_blend),
);
let dark_disc = pipeline(
"Dark disc",
"vs_sky",
@@ -3704,6 +3751,12 @@ impl Renderer {
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let sunrise_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Sunrise fan vertices"),
size: (sunrise_fan::VERTICES * 5 * std::mem::size_of::<f32>()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let entity_capacity = 1 << 14;
let entity_skin = entity_skin::EntitySkin::new(
&device,
@@ -3884,6 +3937,9 @@ impl Renderer {
cull,
flat,
sky,
sky_base,
sunrise_pipeline,
sunrise_buffer,
dark_disc,
depth_reset,
lines,
@@ -5091,8 +5147,8 @@ impl Renderer {
if self.detail_columns.remove(&(x, z)) == Some(complete) {
self.detail_revision += 1;
}
self.occlusion.remove_column(x, z);
}
self.occlusion.remove_columns(&gone);
let kept = |key: &SectionKey| !gone.contains(&(key.0, key.2));
let before = self.block_entities.len();
self.block_entities.retain(|key, _| kept(key));
@@ -6225,6 +6281,27 @@ impl Renderer {
};
self.uploads
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
// The sunrise fan as sixteen triangles. Its clip positions use the sky's
// own view (no TAA jitter, bob or spin: `fs_sky`'s `view_ray` has none),
// and its apex weight interpolates the barycentric the shader used to
// compute for every sky pixel.
if frame.sky.sunrise_color[3] > 0.001 && !frame.title && frame.skybox {
let sky_view_proj = views.primary().view_proj(Mat4::IDENTITY);
let mut mesh = [[0.0f32; 5]; sunrise_fan::VERTICES];
for (to, from) in mesh
.iter_mut()
.zip(sunrise_fan::mesh(frame.sky.sunrise_color[3], sun))
{
let clip = sky_view_proj * glam::Vec4::new(from[0], from[1], from[2], 1.0);
*to = [clip.x, clip.y, clip.z, clip.w, from[3]];
}
self.uploads.buffer(
&mut encoder,
&self.sunrise_buffer,
0,
bytemuck::cast_slice(&mesh),
);
}
laps.lap("r_targets");
// GameRenderer.render3dHud: the hand's projection at hudFov, with the
// bob in its pose rather than the projection (same product).
@@ -7851,8 +7928,13 @@ impl Renderer {
{
Some((sky, native)) => sky.draw(&mut pass, native),
None => {
pass.set_pipeline(&self.sky);
pass.set_pipeline(&self.sky_base);
pass.draw(0..3, 0..1);
if frame.sky.sunrise_color[3] > 0.001 {
pass.set_pipeline(&self.sunrise_pipeline);
pass.set_vertex_buffer(0, self.sunrise_buffer.slice(..));
pass.draw(0..sunrise_fan::VERTICES as u32, 0..1);
}
}
}
}
@@ -9133,20 +9215,30 @@ impl Renderer {
.contains(wgpu::TextureUsages::COPY_SRC)
{
let device = &self.device;
self.post_effects
.get_or_insert_with(|| crate::post_chain::PostEffects::new(device))
.apply(
&self.device,
&self.queue,
encoder,
target_texture,
view_target,
[self.config.width, self.config.height],
// The scaled world pass drew its own depth buffer.
overlay.world_depth.then_some(&self.depth),
&frame.post_effects,
timing.as_mut(),
);
let post = self
.post_effects
.get_or_insert_with(|| crate::post_chain::PostEffects::new(device));
// The chains a spectated mob applies compile in the background from the
// first frame in a world, not in the frame that first needs one.
post.preload(
&self.device,
&self.queue,
frame.post_effects.root,
&crate::post_chain::SPECTATOR_CHAINS,
target_texture.format(),
);
post.apply(
&self.device,
&self.queue,
encoder,
target_texture,
view_target,
[self.config.width, self.config.height],
// The scaled world pass drew its own depth buffer.
overlay.world_depth.then_some(&self.depth),
&frame.post_effects,
timing.as_mut(),
);
}
if !frame.screen.is_empty() {
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -13,6 +13,7 @@
//! frame has (vanilla's partial update), so what it opens shows at once; what
//! it closes goes at the next full search.
use super::SectionKey;
use crate::fast_hash::FxHashSet;
use crate::mesher::ALL_VISIBLE;
use glam::DVec3;
use std::collections::{HashMap, VecDeque};
@@ -589,6 +590,19 @@ impl Occlusion {
}
}
}
/// `remove_column` for each of `gone`. When they are every column it holds (a
/// teleport drops the whole view), the old graph says nothing about what arrives
/// next: it starts over as `clear` does instead of seeing through each removed
/// section one edit at a time (some 90 000 at render distance 32).
pub fn remove_columns(&mut self, gone: &FxHashSet<(i32, i32)>) {
if self.columns.map.keys().all(|column| gone.contains(column)) {
self.clear();
return;
}
for &(x, z) in gone {
self.remove_column(x, z);
}
}
pub fn clear(&mut self) {
self.columns.map.clear();
self.edits.clear();
@@ -1313,6 +1327,46 @@ mod tests {
println!("search_cost: best {best:.2} ms over 12, {reached} reached");
}
/// Removed columns see through every face: the graph is the one of a world that
/// never had them, whether some columns go or all of them (a teleport).
#[test]
fn removed_columns_leave_the_graph_of_a_world_without_them() {
fn without(o: &Occlusion, gone: &FxHashSet<(i32, i32)>) -> Occlusion {
let mut fresh = Occlusion::new(0, 6);
for (&(x, z), column) in &o.columns.map {
if !gone.contains(&(x, z)) {
for (y, &vis) in column.iter().enumerate() {
fresh.set((x, y as i32, z), vis);
}
}
}
fresh
}
let camera = at((0, 2, 0));
let mut o = Occlusion::new(0, 6);
random_world(&mut o, 0x51ed_2701, 900, 6);
o.settle(camera, 6);
let some: FxHashSet<(i32, i32)> = (-6..=6)
.flat_map(|x| (-1..=1).map(move |z| (x, z)))
.collect();
let mut expected = without(&o, &some);
o.remove_columns(&some);
o.settle(camera, 6);
expected.settle(camera, 6);
let graph = |o: &Occlusion| o.graph.as_ref().expect("searched").directions.clone();
assert_eq!(graph(&o), graph(&expected));
let all: FxHashSet<(i32, i32)> = o.columns.map.keys().copied().collect();
o.remove_columns(&all);
assert!(o.contains((40, 3, 40)), "no graph from the old view culls");
random_world(&mut o, 0x2545_f491, 900, 6);
let mut arrived = Occlusion::new(0, 6);
random_world(&mut arrived, 0x2545_f491, 900, 6);
o.settle(camera, 6);
arrived.settle(camera, 6);
assert_eq!(graph(&o), graph(&arrived));
}
/// The worker's graph is the one the render thread would compute.
#[test]
fn the_worker_matches_a_search_on_the_render_thread() {
@@ -15,6 +15,11 @@ use glam::Vec3;
pub const TRIANGLES: usize = 16;
/// One vertex per triangle corner, each as `(position, weight)`: the apex
/// weighs 1, the rim 0, so the rasterizer interpolates the barycentric apex
/// weight the shader used to compute per pixel.
pub const VERTICES: usize = TRIANGLES * 3;
/// Per triangle: xyz `r0 × r1` (its dot with a ray is the apex's weight
/// times S), w `[A, r0, r1]` (its sign is S's where the hit lies ahead).
/// Then per rim vertex: xyz `r × A`.
@@ -29,6 +34,19 @@ pub fn normals(alpha: f32, sun: Vec3) -> [[f32; 4]; 2 * TRIANGLES] {
out
}
/// The fan as triangle-list vertices in camera space, with the apex at
/// weight 1 and each rim vertex at 0 (`VERTICES`).
pub fn mesh(alpha: f32, sun: Vec3) -> [[f32; 4]; VERTICES] {
let (apex, rim) = vertices(alpha, sun);
let mut out = [[0.0; 4]; VERTICES];
for i in 0..TRIANGLES {
out[i * 3] = apex.extend(1.0).to_array();
out[i * 3 + 1] = rim[i].extend(0.0).to_array();
out[i * 3 + 2] = rim[(i + 1) % TRIANGLES].extend(0.0).to_array();
}
out
}
/// The fan's apex and rim in camera space, as vanilla builds them: the
/// fan turned to the horizon the sun sets on (or rises from), its rim
/// tilted by the alpha.
@@ -208,6 +226,21 @@ mod tests {
best
}
#[test]
fn mesh_puts_the_apex_weight_on_the_apex_of_every_triangle() {
let (apex, rim) = vertices(0.7, Vec3::new(0.0, 0.6, -0.8));
let mesh = mesh(0.7, Vec3::new(0.0, 0.6, -0.8));
assert_eq!(mesh.len(), VERTICES);
for i in 0..TRIANGLES {
assert_eq!(&mesh[i * 3][..3], apex.to_array());
assert_eq!(mesh[i * 3][3], 1.0);
assert_eq!(&mesh[i * 3 + 1][..3], rim[i].to_array());
assert_eq!(mesh[i * 3 + 1][3], 0.0);
assert_eq!(&mesh[i * 3 + 2][..3], rim[(i + 1) % TRIANGLES].to_array());
assert_eq!(mesh[i * 3 + 2][3], 0.0);
}
}
#[test]
fn normals_weigh_every_ray_as_the_per_pixel_fan_did() {
let mut hits = 0;
+8 -5
View File
@@ -245,7 +245,8 @@ impl GraphicsApi {
/// What the frame limiter does in the last half millisecond before a frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Pacing {
/// Sleep the whole wait: no CPU spent spinning, a little less precise.
/// Sleep the whole wait: no CPU spent spinning, a little less precise; frames
/// keep to a fixed grid of slots, so late wake-ups don't lower the frame rate.
#[default]
Efficient,
/// Vanilla's `FramerateLimiter`: sleep, then spin until the deadline.
@@ -1230,10 +1231,12 @@ mod tests {
assert_eq!(g.lod_distance_on(&discrete), distance);
}
assert!(
crate::graphics_ui::rows(crate::graphics_ui::Level::Simple)
.iter()
.any(|row| matches!(row, crate::graphics_ui::Row::Setting(s)
if s.text.contains(&format!("{INTEGRATED_LOD_DISTANCE} chunks")))),
crate::graphics_ui::rows()
.into_iter()
.flat_map(|(_, groups)| groups.into_iter().flat_map(|(_, rows)| rows))
.any(|r| r
.text
.contains(&format!("{INTEGRATED_LOD_DISTANCE} chunks"))),
"the LOD Distance description names the cap"
);
}
+238 -209
View File
@@ -1,14 +1,19 @@
//! The look of Horizon's graphics settings screens, in one place: sizes,
//! colours and the widgets they draw with. The screens (`graphics_ui`) only
//! lay out rows and handle input; a menu theme replaces this module.
//! The look of Horizon's graphics settings screen, in one place: sizes,
//! colours and the controls it draws with. The screen (`graphics_ui`) only
//! lays out rows and handles input; a menu theme replaces this module.
//!
//! It takes the design system's Blocks palette (`horizon_theme::BLOCKS_DARK`,
//! `docs/design-system.md`) in a lighter form: square panels of dark glass
//! with a thin edge, so the world shows through, flat controls, and the violet
//! face on the one main button, in vanilla's font.
use crate::{
hud::{HudAtlas, HudBatch},
lang,
text::Text,
ui,
};
use horizon_theme::BLOCKS_DARK as B;
/// What a setting costs in frame time, shown beside its description.
/// What a setting costs in frame time, shown with its description.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cost {
None,
@@ -28,217 +33,241 @@ impl Cost {
Cost::Saves => "Saves time",
}
}
}
pub struct Style {
/// Width of a setting's button or slider.
pub control_width: f32,
/// Space between the control and its description.
pub gap: f32,
/// Widest description column; narrower on small screens.
pub max_text_width: f32,
/// Vertical distance between setting rows.
pub row_height: f32,
pub header_height: f32,
/// Top and bottom bands holding the title and the buttons.
pub band: f32,
pub title_color: u32,
pub header_color: u32,
pub description_color: u32,
/// Colour of each `Cost`, in its order.
pub cost_colors: [u32; 5],
}
/// `hud::rgba` with full alpha, usable in constants.
const fn opaque(r: u8, g: u8, b: u8) -> u32 {
0xFF00_0000 | (b as u32) << 16 | (g as u32) << 8 | r as u32
}
pub const STYLE: Style = Style {
control_width: 150.0,
gap: 8.0,
max_text_width: 250.0,
row_height: 25.0,
header_height: 16.0,
band: 33.0,
title_color: u32::MAX,
header_color: opaque(255, 255, 85),
description_color: opaque(170, 170, 170),
cost_colors: [
opaque(120, 200, 120),
opaque(170, 220, 120),
opaque(240, 200, 90),
opaque(240, 110, 90),
opaque(85, 255, 85),
],
};
impl Style {
/// Width of the description column on a screen `screen_width` wide.
pub fn text_width(&self, screen_width: f32) -> f32 {
(screen_width - self.control_width - self.gap - 24.0).clamp(120.0, self.max_text_width)
}
/// Left edge of a row's control, centring control and description.
pub fn row_x(&self, screen_width: f32) -> f32 {
let total = self.control_width + self.gap + self.text_width(screen_width);
((screen_width - total) / 2.0).floor()
}
pub fn background(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
top: f32,
bottom: f32,
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
ui::list_background(batch, atlas, top, bottom, in_world);
}
pub fn frame(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
top: f32,
bottom: f32,
in_world: bool,
) {
ui::separators(batch, atlas, top, bottom, in_world);
}
pub fn title(&self, batch: &mut HudBatch, atlas: &HudAtlas, text: &Text) {
let w = batch.width;
ui::centered_text(batch, atlas, text, 0.0, 12.0, w, self.title_color);
}
pub fn header(&self, batch: &mut HudBatch, atlas: &HudAtlas, text: &Text, x: f32, y: f32) {
batch.text_styled(atlas, text, x, y + 4.0, self.header_color, true);
}
pub fn button(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
hover: bool,
active: bool,
) {
ui::button(batch, atlas, x, y, w, 20.0, label, active, hover);
}
pub fn slider(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
value: f32,
hover: bool,
active: bool,
dragging: bool,
) {
ui::slider(
batch,
atlas,
x,
y,
w,
label,
value,
hover && active,
active,
dragging && active,
);
}
pub fn slider_ext(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
value: f32,
hover: bool,
focused: bool,
can_change_value: bool,
active: bool,
dragging: bool,
) {
ui::slider_ext(
batch,
atlas,
x,
y,
w,
label,
value,
hover && active,
focused,
can_change_value,
active,
dragging && active,
);
}
/// The cost tag and the description beside a control, wrapped to two lines.
pub fn description(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
x: f32,
y: f32,
cost: Cost,
text: &str,
) {
let tag = Text::literal(lang::get(cost.label()));
batch.text_styled(atlas, &tag, x, y, self.cost_colors[cost as usize], true);
let indent = atlas.font.width_text(&tag) + 4.0;
let text = lang::get(text);
let lines = wrap(atlas, &text, self.text_width(batch.width), indent);
for (i, line) in lines.iter().take(2).enumerate() {
let lx = if i == 0 { x + indent } else { x };
batch.text_styled(
atlas,
&Text::literal(line.clone()),
lx,
y + i as f32 * 10.0,
self.description_color,
true,
);
/// `0xRRGGBB`: the palette's success, warn and danger.
pub fn rgb(self) -> u32 {
match self {
Cost::None => B.ink_soft,
Cost::Low | Cost::Saves => B.success,
Cost::Medium => B.warn,
Cost::High => B.danger,
}
}
}
/// Greedy word wrap; the first line is `indent` narrower.
fn wrap(atlas: &HudAtlas, text: &str, width: f32, indent: f32) -> Vec<String> {
let mut lines = vec![String::new()];
for word in text.split_whitespace() {
let room = if lines.len() == 1 {
width - indent
} else {
width
};
let line = lines.last().expect("one line");
let candidate = if line.is_empty() {
word.to_owned()
} else {
format!("{line} {word}")
};
if atlas.font.width(&candidate) <= room || line.is_empty() {
*lines.last_mut().expect("one line") = candidate;
} else {
lines.push(word.to_owned());
}
}
lines
/// `0xRRGGBB` and an alpha in the HUD's colour encoding.
const fn hex(c: u32, a: u8) -> u32 {
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
}
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
const GLASS: u32 = hex(B.page, 118);
/// Their 1 px edge.
const EDGE: u32 = hex(B.ink, 26);
/// A group's panel, a little lighter than the plate it sits on.
const GROUP: u32 = hex(B.ink, 11);
/// A hovered or focused row, lit on its group's panel.
pub const HOVER: u32 = hex(B.ink, 18);
/// The open page in the page list.
pub const OPEN: u32 = hex(B.ink, 26);
/// The rule under the plate's heading, and between two rows of a group.
pub const HEAD_RULE: u32 = hex(B.ink, 31);
pub const ROW_RULE: u32 = hex(B.ink, 15);
/// Fields, boxes and the value stepper: sunk, see-through.
const WELL: u32 = hex(B.outline, 102);
/// A button, a slider's track, the scroller's thumb.
const BUTTON: u32 = hex(B.ink, 20);
const BUTTON_HOVER: u32 = hex(B.ink, 41);
const TRACK: u32 = hex(B.ink, 64);
const HANDLE: u32 = hex(B.ink, 255);
/// The one main button.
const FACE: u32 = hex(B.face, 255);
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
/// A tick box's edge when off.
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
const OUTLINE: u32 = hex(B.outline, 255);
pub const ACCENT: u32 = hex(B.accent, 255);
/// Vanilla's white text, and its grey for what can't be used now.
pub const WHITE: u32 = u32::MAX;
pub const MUTED: u32 = hex(B.muted, 255);
/// A page that isn't open, a value at rest.
pub const SOFT: u32 = hex(B.ink_soft, 255);
/// Vanilla's grey for a tooltip's lines under its first (`ChatFormatting.GRAY`).
pub const GREY: u32 = hex(0xaaaaaa, 255);
/// A light veil over the backdrop, under the panels.
pub const VEIL: u32 = hex(B.page, 77);
#[cfg(test)]
mod tests {
use super::*;
/// Sizes in GUI pixels.
/// Shorter screens than this (a 720p window at scale 3) keep a narrower margin on top.
pub const TITLED: f32 = 300.0;
pub const ROW: f32 = 20.0;
/// A group's name over its panel, and a page's over its groups while searching.
pub const GROUP_ROW: f32 = 14.0;
pub const PAGE_ROW: f32 = 16.0;
/// Space under a group's panel, and more before the next page's name.
pub const GROUP_GAP: f32 = 10.0;
pub const PAGE_GAP: f32 = 6.0;
/// The control on the right of a row (a button, a box or a slider), and the
/// left column: the search field, the page list and the buttons.
pub const CONTROL_W: f32 = 112.0;
pub const CONTROL_H: f32 = 16.0;
pub const SEARCH_H: f32 = 16.0;
/// Vanilla's scroller: the column `ui::Scroll::press` takes.
pub const SCROLLER_W: f32 = 6.0;
/// The strip under the plate that describes a setting.
pub const DOCK_H: f32 = 50.0;
pub const BUTTON_H: f32 = 20.0;
/// A row's name and control, from its group panel's edges.
pub const INSET: f32 = 10.0;
#[test]
fn every_cost_has_a_label_and_a_colour() {
let all = [Cost::None, Cost::Low, Cost::Medium, Cost::High, Cost::Saves];
assert_eq!(all.len(), STYLE.cost_colors.len());
for (i, cost) in all.into_iter().enumerate() {
assert_eq!(cost as usize, i);
assert!(!cost.label().is_empty());
pub fn fill(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], color: u32) {
batch.fill(atlas, x, y, w, h, color);
}
/// A 1 px ring inside `rect`: the keyboard or controller focus, in white as vanilla draws it.
pub fn ring(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], color: u32) {
batch.fill(atlas, x, y, w, 1.0, color);
batch.fill(atlas, x, y + h - 1.0, w, 1.0, color);
batch.fill(atlas, x, y + 1.0, 1.0, h - 2.0, color);
batch.fill(atlas, x + w - 1.0, y + 1.0, 1.0, h - 2.0, color);
}
/// The screen's plate, and the strip under it: dark glass with a thin edge.
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, GLASS);
ring(batch, atlas, rect, EDGE);
}
/// A group's panel on the plate, its rows on it.
pub fn group(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, GROUP);
}
/// A sunk well: the search field, the value stepper.
pub fn well(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, WELL);
}
/// The scroller: a faint track and its thumb.
pub fn scroller(batch: &mut HudBatch, atlas: &HudAtlas, track: [f32; 4], thumb: [f32; 4]) {
fill(batch, atlas, track, ROW_RULE);
fill(batch, atlas, thumb, TRACK);
}
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control.
pub fn fit(atlas: &HudAtlas, text: &str, width: f32) -> String {
if atlas.font.width(text) <= width {
return text.to_owned();
}
let mut out = String::new();
for c in text.chars() {
if atlas.font.width(&format!("{out}{c}...")) > width {
break;
}
out.push(c);
}
format!("{}...", out.trim_end())
}
/// A 12 px tick box ending at `right`, centred in the row at `y`: its edge in the accent when on.
pub fn checkbox(batch: &mut HudBatch, atlas: &HudAtlas, right: f32, y: f32, on: bool, live: bool) {
let rect = [right - 12.0, y + 4.0, 12.0, 12.0];
fill(batch, atlas, rect, WELL);
let edge = match (on, live) {
(true, true) => ACCENT,
(true, false) => MUTED,
(false, _) => BOX_EDGE,
};
ring(batch, atlas, rect, edge);
if on {
tick(batch, atlas, rect[0] + 1.0, rect[1] + 2.0, edge);
}
}
/// A pixel tick, 10 by 8, over its own shadow.
fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
const TICK: [&str; 8] = [
" #",
" ##",
" ## ",
"# ## ",
"## ## ",
" ## ## ",
" ### ",
" # ",
];
for (j, row) in TICK.iter().enumerate() {
for (i, c) in row.chars().enumerate() {
if c == '#' {
let (px, py) = (x + i as f32, y + j as f32);
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], OUTLINE);
fill(batch, atlas, [px, py, 1.0, 1.0], color);
}
}
}
}
/// A flat button with its label centred: Undo, Calibrate..., and Done in the
/// violet (`primary`). Pointed at or focused, vanilla's white edge.
pub fn button(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
label: &Text,
(hover, live, primary): (bool, bool, bool),
) {
let [x, y, w, h] = rect;
let face = match (live, primary, hover) {
(false, _, _) => WELL,
(true, true, false) => FACE,
(true, true, true) => FACE_HOVER,
(true, false, false) => BUTTON,
(true, false, true) => BUTTON_HOVER,
};
fill(batch, atlas, rect, face);
if hover && live {
ring(batch, atlas, rect, WHITE);
}
let color = if live { WHITE } else { MUTED };
let ty = y + ((h - 8.0) / 2.0).floor();
ui::centered_text(batch, atlas, label, x + 2.0, ty, w - 4.0, color);
}
/// A slider: its value over a thin track filled in the accent up to the handle at `position`.
pub fn slider(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
value: &Text,
position: f32,
(hover, live): (bool, bool),
) {
let [x, y, w, h] = rect;
let color = if live { WHITE } else { MUTED };
ui::centered_text(batch, atlas, value, x + 2.0, y + 1.0, w - 4.0, color);
let line = y + h - 3.0;
let at = x + (position.clamp(0.0, 1.0) * (w - 4.0)).round();
fill(batch, atlas, [x, line, w, 2.0], TRACK);
fill(
batch,
atlas,
[x, line, at - x, 2.0],
if live { ACCENT } else { MUTED },
);
let handle = [at, line - 3.0, 4.0, 8.0];
fill(batch, atlas, handle, if live { HANDLE } else { MUTED });
if hover && live {
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], ACCENT);
}
}
/// A value that a press steps through: in a well, between the arrows that say so.
pub fn cycler(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
value: &Text,
(hover, live): (bool, bool),
) {
let [x, y, w, h] = rect;
well(batch, atlas, rect);
if hover && live {
ring(batch, atlas, rect, WHITE);
}
let ty = y + ((h - 8.0) / 2.0).floor();
let arrows = if hover && live { WHITE } else { MUTED };
batch.text_styled(atlas, &Text::literal("<"), x + 3.0, ty, arrows, true);
batch.text_styled(atlas, &Text::literal(">"), x + w - 8.0, ty, arrows, true);
let color = match (live, hover) {
(false, _) => MUTED,
(true, true) => WHITE,
(true, false) => SOFT,
};
batch.text_styled(atlas, value, x + 9.0, ty, color, true);
}
/// A screen's title, centred at the top (split-screen profiles).
pub fn title(batch: &mut HudBatch, atlas: &HudAtlas, text: &Text) {
let w = batch.width;
ui::centered_text(batch, atlas, text, 0.0, 12.0, w, u32::MAX);
}
/// The cost's colour in the HUD's encoding: the square beside its name.
pub fn cost_color(cost: Cost) -> u32 {
hex(cost.rgb(), 255)
}
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,4 @@
//! HDR calibration (Graphics: HDR, Calibrate...): full screen, one step at a time, the way
//! HDR calibration (the graphics screen's HDR page, Calibrate...): full screen, one step at a time, the way
//! display calibration tools do it. HDR output draws each step's test picture straight in
//! nits above a band of controls (`hdr.wgsl`, `Hdr::calibration`), past everything that
//! shapes the game's picture, so what the player sees is the display alone:
+11 -3
View File
@@ -278,10 +278,18 @@ impl Column {
/// The water is one span again.
fn light_open_water(&mut self, kinds: &[u8]) {
// Water; lava glows.
let water = |span: &Span| {
let kind = kinds.get(span.state.0 as usize).copied().unwrap_or(0);
self.light_open_water_where(|state| {
let kind = kinds.get(state.0 as usize).copied().unwrap_or(0);
kind & FLUID != 0 && kind >> 4 == 0
};
});
}
/// [`Column::light_open_water`] for a caller that knows which states are
/// water without `BlockFacts`: the save decoder, whose palette already
/// names water apart from lava. Saved columns skipping this lit their
/// water a level darker per block, which drew a band through the sea
/// where they met the generator's columns.
pub fn light_open_water_where(&mut self, is_water: impl Fn(BlockStateId) -> bool) {
let water = |span: &Span| is_water(span.state);
if !self.spans.last().is_some_and(water) {
return;
}
+75 -112
View File
@@ -2032,6 +2032,9 @@ impl OptionsUi {
.pack_screen
.as_ref()
.is_some_and(|p| p.search_focused()),
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => {
self.graphics.typing()
}
_ => self.settings_search_active(),
}
}
@@ -2136,11 +2139,8 @@ impl OptionsUi {
if page == Page::Packs {
self.pack_screen = None;
}
if matches!(
page,
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr
) {
self.graphics.reset();
if let Some(at) = Self::graphics_page(page) {
self.graphics.open(at);
}
if page == Page::SplitSetup {
self.split_setup.reset();
@@ -2149,12 +2149,35 @@ impl OptionsUi {
self.split_profiles.reset();
}
}
/// The graphics screen level of `page`, if it is one.
fn graphics_level(page: Page) -> Option<crate::graphics_ui::Level> {
/// What the graphics screen's actions do to these options.
fn graphics_events(
&mut self,
s: &mut Settings,
actions: Vec<crate::graphics_ui::Action>,
) -> Vec<Event> {
use crate::graphics_ui::Action;
let mut events = Vec::new();
for action in actions {
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::BindKey(name) => self.selected_key = Some(name),
Action::Calibrate => {
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
self.push(Page::HdrCalibration);
}
Action::Done => events.extend(self.back(s)),
}
}
events
}
/// Where the graphics screen opens for `page`, if it is one of its pages.
fn graphics_page(page: Page) -> Option<crate::graphics_ui::Page> {
match page {
Page::Graphics => Some(crate::graphics_ui::Level::Simple),
Page::GraphicsAdvanced => Some(crate::graphics_ui::Level::Advanced),
Page::GraphicsHdr => Some(crate::graphics_ui::Level::Hdr),
Page::Graphics => Some(crate::graphics_ui::Page::General),
Page::GraphicsAdvanced => Some(crate::graphics_ui::Page::Advanced),
Page::GraphicsHdr => Some(crate::graphics_ui::Page::Hdr),
_ => None,
}
}
@@ -2333,7 +2356,7 @@ impl OptionsUi {
}
Page::Horizon => {
// Two to a row, as vanilla's OptionsList.addSmall lays out
// buttons; each opens a page built from vanilla's widgets.
// buttons; each opens one of Horizon's settings pages.
let open =
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
vec![
@@ -2420,7 +2443,7 @@ impl OptionsUi {
rows
}
Page::Video => {
// Horizon's graphics screens, Simple and Advanced.
// Horizon's graphics settings screen, at General and at Advanced.
let mut rows = vec![Row::Buttons(
button(
0.0,
@@ -3388,10 +3411,9 @@ impl OptionsUi {
c.draw(batch, atlas, cursor);
return;
}
if let Some(level) = Self::graphics_level(self.page()) {
if Self::graphics_page(self.page()).is_some() {
self.graphics.binding = self.selected_key;
self.graphics
.draw(level, s, batch, atlas, cursor, self.in_world);
self.graphics.draw(s, batch, atlas, cursor, self.in_world);
return;
}
if self.page() == Page::SplitSetup {
@@ -3753,8 +3775,7 @@ impl OptionsUi {
let outcome = c.click((x, y), size, drag);
return self.calibration_outcome(s, outcome);
}
if let Some(level) = Self::graphics_level(self.page()) {
use crate::graphics_ui::Action;
if Self::graphics_page(self.page()).is_some() {
// Waiting on a key: a mouse button binds, as on Key Binds.
if self.selected_key.is_some() && !drag {
let button = if right {
@@ -3764,36 +3785,10 @@ impl OptionsUi {
};
return self.bind_mouse(s, button);
}
let mut events = Vec::new();
for action in self
let actions = self
.graphics
.click(level, s, (x, y), size, framebuffer, drag, right)
{
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenHdr => self.push(Page::GraphicsHdr),
Action::BindKey(name) => self.selected_key = Some(name),
Action::Calibrate => {
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
self.push(Page::HdrCalibration);
}
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
{
events.extend(self.back(s));
} else {
self.stack.pop();
self.push(Page::Graphics);
}
}
Action::Done => events.extend(self.back(s)),
}
}
return events;
.click(s, (x, y), size, framebuffer, drag, right);
return self.graphics_events(s, actions);
}
if self.page() == Page::Packs {
let mut events = Vec::new();
@@ -4201,6 +4196,7 @@ impl OptionsUi {
/// left by a click does not count (`focus_from_keyboard`): vanilla's wheel
/// ignores it, and honouring it toggled the clicked option on every later wheel
/// event, from anywhere on the screen, while the list never scrolled.
/// The graphics screen has its own wheel (`GraphicsUi::wheel`).
pub fn wheel(
&mut self,
s: &mut Settings,
@@ -4211,38 +4207,9 @@ impl OptionsUi {
) -> Vec<Event> {
self.size = size;
self.framebuffer = framebuffer;
if let Some(level) = Self::graphics_level(self.page()) {
use crate::graphics_ui::Action;
let actions = self
.graphics
.wheel(level, s, cursor, size, framebuffer, amount);
let mut events = Vec::new();
for action in actions {
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenHdr => self.push(Page::GraphicsHdr),
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
{
events.extend(self.back(s));
} else {
self.stack.pop();
self.push(Page::Graphics);
}
}
Action::BindKey(name) => self.selected_key = Some(name),
Action::Calibrate => {
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
self.push(Page::HdrCalibration);
}
Action::Done => events.extend(self.back(s)),
}
}
return events;
if Self::graphics_page(self.page()).is_some() {
let actions = self.graphics.wheel(s, size, cursor, framebuffer, amount);
return self.graphics_events(s, actions);
}
let plain = self.resource_error.is_none()
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
@@ -4322,8 +4289,8 @@ impl OptionsUi {
if self.resource_error.is_some() {
return;
}
if let Some(level) = Self::graphics_level(self.page()) {
self.graphics.scroll(level, s, size.1, amount);
if Self::graphics_page(self.page()).is_some() {
self.graphics.scroll(s, size, amount);
return;
}
match &mut self.pack_screen {
@@ -4394,7 +4361,7 @@ impl OptionsUi {
}
/// Escape or Done: leave the current page, applying page results.
/// After an input on HDR calibration: Save keeps what it landed on, Cancel what was
/// set before; both go back to Graphics: HDR.
/// set before; both go back to the graphics screen's HDR page.
fn calibration_outcome(
&mut self,
s: &mut Settings,
@@ -4505,6 +4472,8 @@ impl OptionsUi {
self.scroll = 0.0;
} else if self.page() == Page::SplitProfiles {
self.split_profiles.type_text(text);
} else if Self::graphics_page(self.page()).is_some() && self.graphics.typing() {
self.graphics.type_text(text);
} else if self.settings_search_active() {
self.settings_search.insert_text(text);
self.scroll = 0.0;
@@ -4536,6 +4505,9 @@ impl OptionsUi {
if self.page() == Page::SplitProfiles {
return self.split_profiles.shortcut(shortcut, clipboard);
}
if Self::graphics_page(self.page()).is_some() {
return self.graphics.shortcut(shortcut, clipboard);
}
if self.settings_search_active() {
if self.settings_search.shortcut(shortcut, clipboard) {
self.scroll = 0.0;
@@ -4646,37 +4618,11 @@ impl OptionsUi {
return Vec::new();
}
if let Some(level) = Self::graphics_level(self.page()) {
use crate::graphics_ui::Action;
if Self::graphics_page(self.page()).is_some() {
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
let fb = [self.framebuffer[0].max(480), self.framebuffer[1].max(240)];
let (actions, handled) = self.graphics.key_modifiers(level, s, key, shift, size, fb);
let mut events = Vec::new();
for action in actions {
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenHdr => self.push(Page::GraphicsHdr),
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
{
events.extend(self.back(s));
} else {
self.stack.pop();
self.push(Page::Graphics);
}
}
Action::BindKey(name) => self.selected_key = Some(name),
Action::Calibrate => {
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
self.push(Page::HdrCalibration);
}
Action::Done => events.extend(self.back(s)),
}
}
let (actions, handled) = self.graphics.key_modifiers(s, key, text, shift, size, fb);
let mut events = self.graphics_events(s, actions);
if key == K::Escape {
events.extend(self.back(s));
return events;
@@ -7191,6 +7137,22 @@ mod tests {
assert!(!events.iter().any(|e| matches!(e, Event::Volume(0, _))));
}
#[test]
fn the_graphics_screens_wheel_scrolls_even_over_a_slider_being_changed() {
use crate::graphics_ui::{H, Item, Target};
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Graphics);
let size = (640.0, 360.0);
s.horizon.chunk_updates = 16;
ui.graphics.focus = Some(Target::Item(Item::Horizon(H::ChunkUpdates)));
ui.graphics.can_change_value = true;
let before = ui.graphics.scroll_amount();
ui.wheel(&mut s, size, (320.0, 150.0), [1920, 1080], -1.0);
assert_eq!(s.horizon.chunk_updates, 16);
assert!(ui.graphics.scroll_amount() > before);
}
#[test]
fn horizon_pages_slider_and_wheel_parity() {
use winit::keyboard::KeyCode as K;
@@ -7223,13 +7185,14 @@ mod tests {
// 2. Horizon Graphics page through OptionsUi:
// Tab onto ChunkUpdates slider, ArrowRight adjusts value, Space toggles can_change_value
ui.show(Page::GraphicsAdvanced);
ui.show(Page::Graphics);
ui.graphics.open(crate::graphics_ui::Page::Performance);
s.horizon.chunk_updates = 10;
ui.size = size;
ui.framebuffer = [854, 480];
// Tab into controls
for _ in 0..15 {
for _ in 0..80 {
ui.key(&mut s, &atlas, K::Tab, None);
if ui.graphics.focus
== Some(crate::graphics_ui::Target::Item(
+192 -34
View File
@@ -22,6 +22,7 @@
use serde_json::Value;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
/// `PostChain.MAIN_TARGET_ID`.
const MAIN: &str = "minecraft:main";
@@ -539,8 +540,21 @@ pub struct PostEffects {
blit: Option<Pass>,
/// The `Globals` block.
globals: wgpu::Buffer,
/// Chains loading on a thread of their own ([`PostEffects::preload`]), for the
/// root they load from; `pending` until they arrive.
preloading: Option<(PathBuf, mpsc::Receiver<Preloaded>)>,
pending: HashSet<String>,
}
/// A chain [`PostEffects::preload`] loaded, by id.
type Preloaded = (String, Result<Option<Chain>, String>);
/// The chains a spectated entity applies (`GameRenderer.checkEntityPostEffect`): the
/// first frame through a creeper's eyes compiled its pipelines, a 0.1-0.9 s freeze on
/// the Mac mini.
pub const SPECTATOR_CHAINS: [&str; 3] =
["minecraft:creeper", "minecraft:spider", "minecraft:invert"];
fn resource_path(root: &Path, id: &str) -> PathBuf {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
root.join("assets")
@@ -612,6 +626,8 @@ impl PostEffects {
bilinear: make(wgpu::FilterMode::Linear),
chains: HashMap::new(),
failed: HashSet::new(),
preloading: None,
pending: HashSet::new(),
root: PathBuf::new(),
work: None,
staging: None,
@@ -632,6 +648,91 @@ impl PostEffects {
self.root = root.to_owned();
self.chains.clear();
self.failed.clear();
self.preloading = None;
self.pending.clear();
}
}
/// Loads the `ids` chains (namespaced) not loaded yet and compiles their pipelines for
/// `format`, on a thread of their own: `apply` leaves a chain out until it has
/// arrived instead of compiling it in the frame that first asks for it.
pub fn preload(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
root: &Path,
ids: &[&str],
format: wgpu::TextureFormat,
) {
self.use_root(root);
if self.preloading.is_some() {
return;
}
let ids: Vec<String> = ids
.iter()
.filter(|id| !self.chains.contains_key(**id) && !self.failed.contains(**id))
.map(|id| normalise(id))
.collect();
if ids.is_empty() {
return;
}
let (tx, rx) = mpsc::channel();
let (device, queue, root) = (device.clone(), queue.clone(), root.to_owned());
let format = format.remove_srgb_suffix();
let spawned = std::thread::Builder::new()
.name("Post effects".into())
.spawn({
let ids = ids.clone();
move || {
for id in ids {
let mut chain = Self::load(&root, &device, &queue, &id);
if let Ok(Some(chain)) = &mut chain {
for pass in &mut chain.passes {
if let Some(translated) = &mut pass.translated {
translated.ensure_pipeline(&device, format);
}
}
}
if tx.send((id, chain)).is_err() {
return;
}
}
}
});
if spawned.is_ok() {
self.pending.extend(ids);
self.preloading = Some((self.root.clone(), rx));
}
}
/// Takes the chains `preload` has finished.
fn collect_preloaded(&mut self) {
let Some((root, rx)) = &self.preloading else {
return;
};
if *root != self.root {
self.preloading = None;
self.pending.clear();
return;
}
loop {
match rx.try_recv() {
Ok((id, loaded)) => {
self.pending.remove(&id);
let loaded = loaded.unwrap_or_else(|e| {
log::warn!("Failed to load post effect {id}: {e}");
self.failed.insert(id.clone());
None
});
self.chains.insert(id, loaded);
}
Err(mpsc::TryRecvError::Empty) => return,
Err(mpsc::TryRecvError::Disconnected) => {
self.preloading = None;
self.pending.clear();
return;
}
}
}
}
@@ -679,12 +780,12 @@ impl PostEffects {
/// `PostChain.load` for the file of `id`: `Ok(None)` when it does not exist.
fn load(
&self,
root: &Path,
device: &wgpu::Device,
queue: &wgpu::Queue,
id: &str,
) -> Result<Option<Chain>, String> {
let Ok(text) = std::fs::read_to_string(resource_path(&self.root, id)) else {
let Ok(text) = std::fs::read_to_string(resource_path(root, id)) else {
return Ok(None);
};
let config = Config::parse(&text)?;
@@ -711,17 +812,18 @@ impl PostEffects {
let mut passes = Vec::new();
for (n, p) in config.passes.iter().enumerate() {
let output = index(&p.output).ok_or("a target is missing")?;
let pass = match self.translated_pass(device, queue, n, p, output, &index, &mut images)
{
Ok(pass) => pass,
Err(e) => match self.standin_pass(device, queue, n, p, output, &index) {
Ok(pass) => {
log::debug!("post effect {id} pass {n} uses the built-in shaders: {e}");
pass
}
Err(_) => return Err(e),
},
};
let pass =
match Self::translated_pass(root, device, queue, n, p, output, &index, &mut images)
{
Ok(pass) => pass,
Err(e) => match Self::standin_pass(device, queue, n, p, output, &index) {
Ok(pass) => {
log::debug!("post effect {id} pass {n} uses the built-in shaders: {e}");
pass
}
Err(_) => return Err(e),
},
};
passes.push(pass);
}
Ok(Some(Chain {
@@ -737,7 +839,7 @@ impl PostEffects {
/// A pass over the resources' own GLSL (`PostChain.createPass`).
#[allow(clippy::too_many_arguments)]
fn translated_pass(
&self,
root: &Path,
device: &wgpu::Device,
queue: &wgpu::Queue,
n: usize,
@@ -746,7 +848,7 @@ impl PostEffects {
index: &dyn Fn(&str) -> Option<usize>,
images: &mut Vec<(wgpu::Texture, wgpu::TextureView, [u32; 2])>,
) -> Result<Pass, String> {
let program = crate::post_glsl::translate(&self.root, &p.vertex, &p.fragment)?;
let program = crate::post_glsl::translate(root, &p.vertex, &p.fragment)?;
let mut refs = Vec::new();
for input in &p.inputs {
let src = match &input.source {
@@ -761,7 +863,7 @@ impl PostEffects {
}
}
Source::Texture { location } => {
images.push(self.image(device, queue, location)?);
images.push(Self::image(root, device, queue, location)?);
Src::Image(images.len() - 1)
}
};
@@ -859,7 +961,6 @@ impl PostEffects {
/// A pass over Horizon's WGSL stand-ins for the vanilla post shaders.
fn standin_pass(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
n: usize,
@@ -911,14 +1012,13 @@ impl PostEffects {
/// `textures/effect/<location>.png` as a texture (`PostChain.createPass`'s `TextureInput`).
fn image(
&self,
root: &Path,
device: &wgpu::Device,
queue: &wgpu::Queue,
location: &str,
) -> Result<(wgpu::Texture, wgpu::TextureView, [u32; 2]), String> {
let (ns, path) = location.split_once(':').unwrap_or(("minecraft", location));
let file = self
.root
let file = root
.join("assets")
.join(ns)
.join("textures/effect")
@@ -1029,14 +1129,16 @@ impl PostEffects {
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) -> bool {
self.use_root(req.root);
self.collect_preloaded();
let mut applied = Vec::new();
for effect in req.effects {
let id = normalise(effect);
if self.failed.contains(&id) || applied.contains(&id) {
// A chain still compiling on `preload`'s thread waits for it.
if self.failed.contains(&id) || applied.contains(&id) || self.pending.contains(&id) {
continue;
}
if !self.chains.contains_key(&id) {
let loaded = match self.load(device, queue, &id) {
let loaded = match Self::load(&self.root, device, queue, &id) {
Ok(chain) => chain,
Err(e) => {
log::warn!("Failed to load post effect {id}: {e}");
@@ -1930,6 +2032,26 @@ mod tests {
"output":"minecraft:main",
"uniforms":{"MineConfig":[{"name":"Scale","type":"float","value":1.0}]}}]}"#,
);
own_glsl(&root);
let mut effects = PostEffects::new(&device);
let [top, bottom] = run_halves(
&device,
&queue,
&mut effects,
&root,
&["minecraft:custom".to_owned()],
);
// Red (1, 0, 0) * the image (0.5, 1, 1) * 0.5 * depth 1, then again * 1: 0.125.
let near = |a: u8, b: u8| a.abs_diff(b) <= 3;
assert!(near(top[0], 32) && top[1] == 0 && top[3] == 255, "{top:?}");
// Green stays below it: 0.5 * 1 * 1.
assert!(bottom[0] == 0 && near(bottom[1], 128), "{bottom:?}");
assert!(effects.failed.is_empty());
std::fs::remove_dir_all(root).unwrap();
}
/// `core/quad.vsh`, `post/mine.fsh` and the `tint` image of `a_chain_runs_its_own_glsl`.
fn own_glsl(root: &Path) {
let shaders = root.join("assets/minecraft/shaders");
std::fs::create_dir_all(shaders.join("core")).unwrap();
std::fs::create_dir_all(shaders.join("post")).unwrap();
@@ -1960,20 +2082,60 @@ mod tests {
writer.write_image_data(&[128, 255, 255, 255]).unwrap();
}
std::fs::write(effect.join("tint.png"), png_bytes).unwrap();
}
/// A chain `preload` loaded arrives with its pipelines compiled on the loading
/// thread, and runs as one loaded in its frame does.
#[test]
fn a_preloaded_chain_arrives_compiled() {
let Some((device, queue)) = device() else {
return;
};
let root = scratch(
"preloaded",
r#"{"targets":{"half":{"width":4,"height":2}},"passes":[
{"vertex_shader":"minecraft:core/quad","fragment_shader":"minecraft:post/mine",
"inputs":[{"sampler_name":"In","target":"minecraft:main","bilinear":true},
{"sampler_name":"Depth","target":"minecraft:main","use_depth_buffer":true},
{"sampler_name":"Tint","location":"tint","width":1,"height":1}],
"output":"half",
"uniforms":{"MineConfig":[{"name":"Scale","type":"float","value":0.5}]}},
{"vertex_shader":"minecraft:core/quad","fragment_shader":"minecraft:post/mine",
"inputs":[{"sampler_name":"In","target":"half"},
{"sampler_name":"Depth","target":"half","use_depth_buffer":true},
{"sampler_name":"Tint","location":"tint","width":1,"height":1}],
"output":"minecraft:main",
"uniforms":{"MineConfig":[{"name":"Scale","type":"float","value":1.0}]}}]}"#,
);
own_glsl(&root);
let mut effects = PostEffects::new(&device);
let format = wgpu::TextureFormat::Rgba8Unorm;
effects.preload(&device, &queue, &root, &["minecraft:preloaded"], format);
assert!(effects.pending.contains("minecraft:preloaded"));
let started = std::time::Instant::now();
while !effects.pending.is_empty() {
assert!(started.elapsed().as_secs() < 60, "the chain never arrived");
std::thread::sleep(std::time::Duration::from_millis(1));
effects.collect_preloaded();
}
let chain = effects.chains["minecraft:preloaded"]
.as_ref()
.expect("loaded");
assert!(chain.passes.iter().all(|pass| {
pass.translated
.as_ref()
.is_some_and(|t| t.pipelines.contains_key(&format))
}));
let [top, bottom] = run_halves(
&device,
&queue,
&mut effects,
&root,
&["minecraft:custom".to_owned()],
&["minecraft:preloaded".to_owned()],
);
// Red (1, 0, 0) * the image (0.5, 1, 1) * 0.5 * depth 1, then again * 1: 0.125.
let near = |a: u8, b: u8| a.abs_diff(b) <= 3;
assert!(near(top[0], 32) && top[1] == 0 && top[3] == 255, "{top:?}");
// Green stays below it: 0.5 * 1 * 1.
assert!(bottom[0] == 0 && near(bottom[1], 128), "{bottom:?}");
assert!(effects.failed.is_empty());
std::fs::remove_dir_all(root).unwrap();
}
@@ -2024,20 +2186,16 @@ mod tests {
{
return;
}
let mut effects = PostEffects::new(&device);
effects.use_root(&root);
for name in ["blur", "creeper", "invert", "spider"] {
let id = format!("minecraft:{name}");
assert!(
effects.load(&device, &queue, &id).unwrap().is_some(),
PostEffects::load(&root, &device, &queue, &id)
.unwrap()
.is_some(),
"{id}"
);
}
// entity_outline needs targets and shaders the world pass has of its own.
assert!(
effects
.load(&device, &queue, "minecraft:entity_outline")
.is_err()
);
assert!(PostEffects::load(&root, &device, &queue, "minecraft:entity_outline").is_err());
}
}
@@ -3,10 +3,10 @@
//! pause menu (Splitscreen Settings), playing on until the new one can join. A profile is a name
//! (kept in the game folder), with its own options. The
//! game fills the list when the page opens (`filled`), takes the choice, and says why a pick
//! can't play (`refused`). Built from vanilla's widgets, as Horizon's own pages are.
//! can't play (`refused`). Built from vanilla's widgets.
use crate::{
gamepad::{Button as PadButton, GlyphStyle},
graphics_style::STYLE,
graphics_style,
hud::{HudAtlas, HudBatch, rgba},
lang,
text::Text,
@@ -241,7 +241,7 @@ impl SplitProfiles {
};
let buttons = self.buttons(w, h);
if let Some(name) = &self.forgetting {
STYLE.title(
graphics_style::title(
batch,
atlas,
&Text::literal(tr(
@@ -272,7 +272,7 @@ impl SplitProfiles {
line(batch, why, 176.0, rgba(255, 85, 85, 255));
}
} else if let Some(field) = &mut self.new {
STYLE.title(
graphics_style::title(
batch,
atlas,
&Text::literal(tr("horizon.splitscreen.profiles.new_title", "New Profile")),
@@ -293,7 +293,7 @@ impl SplitProfiles {
),
None => tr("horizon.pages.split_profiles", "Who's playing?"),
};
STYLE.title(batch, atlas, &Text::literal(title));
graphics_style::title(batch, atlas, &Text::literal(title));
self.draw_bumpers(batch, atlas, style);
// Under Cancel: where offline profiles live.
let y = buttons.last().map_or(0.0, |b| b.0[1] + 30.0);
+2 -2
View File
@@ -1,7 +1,7 @@
//! Setup's monitor map and draft window choices. Apply is consumed by the game adapter.
use crate::{
gamepad::Button as PadButton,
graphics_style::STYLE,
graphics_style,
hud::{HudAtlas, HudBatch, rgba},
lang,
split_layout::{Draft, Monitor, Placement, Spot, Warning, ordered},
@@ -568,7 +568,7 @@ impl SplitUi {
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32), in_world: bool) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
STYLE.title(
graphics_style::title(
batch,
atlas,
&Text::literal(tr("horizon.pages.split_setup", &[])),
@@ -99,6 +99,10 @@ pub trait ChunkGenerator: Send + Sync + 'static {
/// The worker has had nothing to do for [`IDLE_RELEASE`]: give back what
/// it keeps between jobs only to go faster.
fn idle(&self, _worker: &mut Self::Worker) {}
/// Every worker has had nothing to do for [`IDLE_RELEASE`]: give back the
/// caches the workers share, which only spare work for chunks generated
/// next to each other in a run. Results do not change.
fn all_idle(&self) {}
fn proto(&self, worker: &mut Self::Worker, x: i32, z: i32) -> Proto;
fn decorates(&self) -> bool {
false
@@ -149,6 +153,16 @@ pub trait ChunkGenerator: Send + Sync + 'static {
fn non_air_sections(&self, _proto: &Proto) -> Option<SectionMask> {
None
}
/// What the generator's shared caches hold: (name, entries, bytes, if
/// known), for `horizon-ctl memory`.
fn cache_census(&self) -> Vec<(&'static str, usize, Option<usize>)> {
Vec::new()
}
/// A proto chunk's heap bytes by part, and its sections by bits per
/// block, for `horizon-ctl memory` (`ProtoChunk::heap_bytes`).
fn proto_heap_bytes(&self, _proto: &Proto) -> ([usize; 6], [usize; 16]) {
([0; 6], [0; 16])
}
fn saved_proto(&self, _proto: &Proto) -> Option<crate::chunk::SavedProto> {
None
}
@@ -399,12 +413,29 @@ impl Mailbox {
done.pop_front()
}
}
/// [`Streaming::proto_census`].
#[derive(Debug, Default)]
pub struct ProtoCensus {
pub protos: usize,
/// Packed blocks, section palettes and lists, heightmaps, other terrain
/// data, biomes, the box and its structure lists.
pub bytes: [usize; 6],
pub sections_by_bits: [usize; 16],
pub light_views: usize,
pub light_view_bytes: usize,
/// `ChunkGenerator::cache_census`.
pub caches: Vec<(&'static str, usize, Option<usize>)>,
}
pub struct Streaming {
shared: Arc<Shared>,
/// Columns generated and saved as they were left behind: new when they
/// next arrive, though read from the save (`World::loaded_chunks`).
dropped_new: FxHashSet<(i32, i32)>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
/// `ChunkGenerator::proto_heap_bytes`, for the memory census.
proto_bytes: Arc<dyn Fn(&Proto) -> ([usize; 6], [usize; 16]) + Send + Sync>,
/// `ChunkGenerator::cache_census`.
caches: Arc<dyn Fn() -> Vec<(&'static str, usize, Option<usize>)> + Send + Sync>,
/// This streamer's place in the shared pool (`horizon_threads::jobs`);
/// dropping it waits for its running jobs.
jobs: Registration,
@@ -824,6 +855,7 @@ impl<G: ChunkGenerator> Source for Jobs<G> {
// pool lock; the pool then asks the allocator to return freed pages.
let meshes = std::mem::take(&mut *self.shared.meshes.lock().unwrap());
drop(meshes);
self.generator.all_idle();
}
}
impl<G: ChunkGenerator> Jobs<G> {
@@ -961,9 +993,19 @@ impl Streaming {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.non_air_sections(p))
};
let proto_bytes = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.proto_heap_bytes(p))
};
let caches = {
let generator = generator.clone();
Arc::new(move || generator.cache_census())
};
Self {
shared,
non_air_sections,
proto_bytes,
caches,
jobs,
workers,
order: 0,
@@ -1482,6 +1524,26 @@ impl Streaming {
&& self.finishing.is_empty()
&& self.lighting.is_empty())
}
/// The proto chunks held (not checked out): how many, their heap bytes by
/// part and their sections by bits per block; and the light views of
/// them with their bytes. For `horizon-ctl memory`.
pub fn proto_census(&self) -> ProtoCensus {
let mut census = ProtoCensus::default();
for proto in self.protos.values().flatten() {
let (bytes, bits) = (self.proto_bytes)(proto);
census.protos += 1;
for (sum, b) in census.bytes.iter_mut().zip(bytes) {
*sum += b;
}
for (sum, b) in census.sections_by_bits.iter_mut().zip(bits) {
*sum += b;
}
}
census.light_views = self.light_views.len();
census.light_view_bytes = self.light_views.values().map(|v| v.heap_bytes()).sum();
census.caches = (self.caches)();
census
}
/// How much work each stage holds: queued jobs, then what is in flight or
/// waiting at each step from generation to meshing.
pub fn stages(&self) -> [(&'static str, usize); 7] {
+27 -5
View File
@@ -646,21 +646,43 @@ fn sunrise_weight(d: vec3<f32>) -> f32 {
// The sky's colour along the view ray `d` (a shader pack's sky stage gets it as its input:
// gfx/native_sky.rs builds that stage from this section).
fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
var color = globals.fog_color.rgb;
let dist = disc_distance(d, 16.0);
if dist.x >= 0.0 {
color = sky_fog(globals.sky_color.rgb, dist);
}
var color = sky_along_ray_base(d);
let w = sunrise_weight(d);
if w > 0.0 {
color = mix(color, globals.sunrise.rgb, w * globals.sunrise.a);
}
return color;
}
// The sky without the sunrise fan: the renderer rasterizes the fan's sixteen
// triangles over this colour instead of testing them in every sky pixel.
fn sky_along_ray_base(d: vec3<f32>) -> vec3<f32> {
var color = globals.fog_color.rgb;
let dist = disc_distance(d, 16.0);
if dist.x >= 0.0 {
color = sky_fog(globals.sky_color.rgb, dist);
}
return color;
}
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
// `hdr_scene_alpha`, spelled out: this part of the file also goes into shader packs' skies.
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0 - 0.24 * globals.hdr_light.y);
}
@fragment fn fs_sky_base(in: SkyOut) -> @location(0) vec4<f32> {
return vec4(sky_along_ray_base(view_ray(in.ndc)), 1.0 - 0.24 * globals.hdr_light.y);
}
// The sunrise/sunset fan as geometry: the clip position is projected by the
// CPU with the sky's unjittered view, and the apex weight (1 at the apex, 0 at
// the rim) interpolates exactly the barycentric the per-pixel test computed.
struct SunriseOut {
@builtin(position) clip: vec4<f32>,
@location(0) weight: f32,
}
@vertex fn vs_sunrise(@location(0) clip: vec4<f32>, @location(1) weight: f32) -> SunriseOut {
return SunriseOut(clip, weight);
}
@fragment fn fs_sunrise(in: SunriseOut) -> @location(0) vec4<f32> {
return vec4(globals.sunrise.rgb, in.weight * globals.sunrise.a);
}
// The first-person hand's depth clear inside the world pass: `vs_sky`'s triangle lies at the
// far plane, written over the whole depth target with no colour.
@fragment fn fs_depth_reset(in: SkyOut) -> @location(0) vec4<f32> {
@@ -88,7 +88,6 @@
"Resolution Scale": "Auflösungsskalierung",
"Anti-aliasing": "Kantenglättung",
"Upscaling": "Upscaling",
"HDR Settings...": "HDR-Einstellungen …",
"Paper White": "Papierweiß",
"Peak Brightness": "Spitzenhelligkeit",
"Black Level": "Schwarzwert",
@@ -129,16 +128,10 @@
"Medium cost": "Mittlere Kosten",
"High cost": "Hohe Kosten",
"Saves time": "Spart Zeit",
"Rendering": "Rendering",
"Display": "Anzeige",
"Preferences": "Präferenzen",
"Overlay": "Einblendung",
"HDR": "HDR",
"Graphics": "Grafik",
"Graphics: Advanced": "Grafik: Erweitert",
"Graphics: HDR": "Grafik: HDR",
"Advanced...": "Erweitert …",
"Simple...": "Einfach …",
"Done": "Fertig",
"This feature is not yet available in Horizon.": "Diese Funktion ist in Horizon noch nicht verfügbar.",
"Your display or system doesn't offer HDR output.": "Dein Bildschirm oder System unterstützt keine HDR-Ausgabe.",
@@ -93,7 +93,6 @@
"Resolution Scale": "Resolution Scale",
"Anti-aliasing": "Anti-aliasing",
"Upscaling": "Upscaling",
"HDR Settings...": "HDR Settings...",
"Paper White": "Paper White",
"Peak Brightness": "Peak Brightness",
"Black Level": "Black Level",
@@ -134,16 +133,8 @@
"Medium cost": "Medium cost",
"High cost": "High cost",
"Saves time": "Saves time",
"Rendering": "Rendering",
"Display": "Display",
"Preferences": "Preferences",
"Overlay": "Overlay",
"HDR": "HDR",
"Graphics": "Graphics",
"Graphics: Advanced": "Graphics: Advanced",
"Graphics: HDR": "Graphics: HDR",
"Advanced...": "Advanced...",
"Simple...": "Simple...",
"Done": "Done",
"This feature is not yet available in Horizon.": "This feature is not yet available in Horizon.",
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
@@ -216,6 +207,32 @@
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay...",
"General": "General",
"Advanced": "Advanced",
"Undo": "Undo",
"Search options": "Search options",
"Shortcut": "Shortcut",
"Distance": "Distance",
"Display": "Display",
"Interface": "Interface",
"Sky and weather": "Sky and weather",
"Lighting and entities": "Lighting and entities",
"Textures": "Textures",
"Image": "Image",
"Chunk loading": "Chunk loading",
"Culling": "Culling",
"Power": "Power",
"Frames": "Frames",
"Overlay": "Overlay",
"Output": "Output",
"Brightness": "Brightness",
"How far you see, the window and the HUD.": "How far you see, the window and the HUD.",
"The sky and weather, lighting, textures and the image.": "The sky and weather, lighting, textures and the image.",
"Chunk loading, culling and power use.": "Chunk loading, culling and power use.",
"How frames reach the screen, and the performance overlay.": "How frames reach the screen, and the performance overlay.",
"Brighter-than-white output on an HDR display.": "Brighter-than-white output on an HDR display.",
"Results for \"%s\"": "Results for \"%s\"",
"No settings match.": "No settings match.",
"horizon.title.not_affiliated": "Not affiliated with Mojang or Microsoft",
"horizon.extras.header.lighting": "Lighting",
"horizon.extras.header.third_person": "Third person",
@@ -376,7 +393,6 @@
"horizon.splitscreen.setup.help.give_up_pad": "Your controller stops driving you and is offered to join (A).",
"horizon.splitscreen.setup.note.held_at": "%s to %s: A drops it here, B puts it back.",
"horizon.splitscreen.setup.note.held": "%s picked up: left/right picks where, A drops, B puts it back.",
"Culling": "Culling",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
"Block Entities": "Block Entities",
"Shulker Boxes": "Shulker Boxes",
@@ -82,7 +82,6 @@
"Resolution Scale": "Escala de resolución",
"Anti-aliasing": "Antialiasing",
"Upscaling": "Reescalado",
"HDR Settings...": "Ajustes HDR...",
"Paper White": "Blanco de papel",
"Peak Brightness": "Brillo máximo",
"Black Level": "Nivel de negro",
@@ -122,16 +121,8 @@
"Medium cost": "Coste medio",
"High cost": "Coste alto",
"Saves time": "Ahorra tiempo",
"Rendering": "Renderizado",
"Display": "Pantalla",
"Preferences": "Preferencias",
"Overlay": "Superposición",
"HDR": "HDR",
"Graphics": "Gráficos",
"Graphics: Advanced": "Gráficos: avanzados",
"Graphics: HDR": "Gráficos: HDR",
"Advanced...": "Avanzados...",
"Simple...": "Simples...",
"Done": "Hecho",
"This feature is not yet available in Horizon.": "Esta función aún no está disponible en Horizon.",
"Your display or system doesn't offer HDR output.": "Tu pantalla o sistema no ofrece salida HDR.",
@@ -207,5 +198,31 @@
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
"horizon.shader_options.open": "Opciones de sombreadores…",
"horizon.shader_options.profile": "Perfil",
"horizon.shader_options.loading": "Leyendo las opciones del paquete de sombreadores…"
"horizon.shader_options.loading": "Leyendo las opciones del paquete de sombreadores…",
"General": "General",
"Advanced": "Avanzado",
"Undo": "Deshacer",
"Search options": "Buscar opciones",
"Shortcut": "Atajo",
"Distance": "Distancia",
"Display": "Pantalla",
"Interface": "Interfaz",
"Sky and weather": "Cielo y clima",
"Lighting and entities": "Iluminación y entidades",
"Textures": "Texturas",
"Image": "Imagen",
"Chunk loading": "Carga de chunks",
"Culling": "Descarte",
"Power": "Energía",
"Frames": "Fotogramas",
"Overlay": "Superposición",
"Output": "Salida",
"Brightness": "Brillo",
"How far you see, the window and the HUD.": "Hasta dónde ves, la ventana y el HUD.",
"The sky and weather, lighting, textures and the image.": "El cielo y el clima, la iluminación, las texturas y la imagen.",
"Chunk loading, culling and power use.": "Carga de chunks, descarte y consumo de energía.",
"How frames reach the screen, and the performance overlay.": "Cómo llegan los fotogramas a la pantalla, y la superposición de rendimiento.",
"Brighter-than-white output on an HDR display.": "Salida más brillante que el blanco en una pantalla HDR.",
"Results for \"%s\"": "Resultados de \"%s\"",
"No settings match.": "Ningún ajuste coincide."
}
@@ -88,7 +88,6 @@
"Resolution Scale": "Échelle de résolution",
"Anti-aliasing": "Anticrénelage",
"Upscaling": "Upscaling",
"HDR Settings...": "Options HDR...",
"Paper White": "Point blanc",
"Peak Brightness": "Luminosité max.",
"Black Level": "Niveau de noir",
@@ -129,16 +128,10 @@
"Medium cost": "Coût moyen",
"High cost": "Coût élevé",
"Saves time": "Fait gagner du temps",
"Rendering": "Rendu",
"Display": "Affichage",
"Preferences": "Préférences",
"Overlay": "Surimpression",
"HDR": "HDR",
"Graphics": "Graphismes",
"Graphics: Advanced": "Graphismes : avancés",
"Graphics: HDR": "Graphismes : HDR",
"Advanced...": "Avancé...",
"Simple...": "Simple...",
"Done": "Terminé",
"This feature is not yet available in Horizon.": "Cette fonction n'est pas encore disponible dans Horizon.",
"Your display or system doesn't offer HDR output.": "Votre écran ou votre système ne propose pas de sortie HDR.",
@@ -88,7 +88,6 @@
"Resolution Scale": "Skala rozdzielczości",
"Anti-aliasing": "Antyaliasing",
"Upscaling": "Skalowanie",
"HDR Settings...": "Ustawienia HDR…",
"Paper White": "Punkt bieli",
"Peak Brightness": "Jasność maks.",
"Black Level": "Poziom czerni",
@@ -129,16 +128,10 @@
"Medium cost": "Średni koszt",
"High cost": "Wysoki koszt",
"Saves time": "Oszczędza czas",
"Rendering": "Renderowanie",
"Display": "Wyświetlanie",
"Preferences": "Preferencje",
"Overlay": "Nakładka",
"HDR": "HDR",
"Graphics": "Grafika",
"Graphics: Advanced": "Grafika: zaawansowane",
"Graphics: HDR": "Grafika: HDR",
"Advanced...": "Zaawansowane…",
"Simple...": "Proste…",
"Done": "Gotowe",
"This feature is not yet available in Horizon.": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
"Your display or system doesn't offer HDR output.": "Twój wyświetlacz lub system nie obsługuje wyjścia HDR.",
@@ -211,6 +204,30 @@
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…",
"General": "Ogólne",
"Advanced": "Zaawansowane",
"Undo": "Cofnij",
"Search options": "Szukaj opcji",
"Shortcut": "Skrót",
"Distance": "Odległość",
"Interface": "Interfejs",
"Sky and weather": "Niebo i pogoda",
"Lighting and entities": "Oświetlenie i byty",
"Textures": "Tekstury",
"Image": "Obraz",
"Chunk loading": "Wczytywanie chunków",
"Culling": "Pomijanie",
"Power": "Zasilanie",
"Frames": "Klatki",
"Output": "Wyjście",
"Brightness": "Jasność",
"How far you see, the window and the HUD.": "Jak daleko widzisz, okno i HUD.",
"The sky and weather, lighting, textures and the image.": "Niebo i pogoda, oświetlenie, tekstury i obraz.",
"Chunk loading, culling and power use.": "Wczytywanie chunków, pomijanie i zużycie energii.",
"How frames reach the screen, and the performance overlay.": "Jak klatki trafiają na ekran oraz nakładka wydajności.",
"Brighter-than-white output on an HDR display.": "Obraz jaśniejszy niż biel na ekranie HDR.",
"Results for \"%s\"": "Wyniki dla \"%s\"",
"No settings match.": "Żadne ustawienie nie pasuje.",
"horizon.title.not_affiliated": "Niepowiązane z Mojang ani Microsoft",
"horizon.extras.header.lighting": "Oświetlenie",
"horizon.extras.header.third_person": "Widok z trzeciej osoby",
@@ -291,7 +308,6 @@
"horizon.osk.type": "Wpisz",
"horizon.osk.symbols": "Symbole",
"horizon.osk.close": "Zamknij",
"Culling": "Pomijanie",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ukrywa modele za pełnymi blokami. Sprawdzanie w tle; byty nadal się aktualizują.",
"Leaf Culling": "Pomijanie liści",
"Every leaf face. Vanilla's fancy leaves.": "Każda ściana liści, tak jak rysuje je Minecraft.",
@@ -81,7 +81,6 @@
"Resolution Scale": "Масштаб разрешения",
"Anti-aliasing": "Сглаживание",
"Upscaling": "Апскейл",
"HDR Settings...": "Настройки HDR…",
"Paper White": "Белая точка",
"Peak Brightness": "Пиковая яркость",
"Black Level": "Уровень чёрного",
@@ -121,16 +120,8 @@
"Medium cost": "Средние затраты",
"High cost": "Много затрат",
"Saves time": "Экономит время",
"Rendering": "Рендеринг",
"Display": "Изображение",
"Preferences": "Предпочтения",
"Overlay": "Оверлей",
"HDR": "HDR",
"Graphics": "Графика",
"Graphics: Advanced": "Графика: подробно",
"Graphics: HDR": "Графика: HDR",
"Advanced...": "Подробно…",
"Simple...": "Просто…",
"Done": "Готово",
"This feature is not yet available in Horizon.": "Эта функция в Horizon пока недоступна.",
"Your display or system doesn't offer HDR output.": "Ваш экран или система не поддерживает HDR-вывод.",
@@ -206,5 +197,31 @@
"horizon.pages.split_profiles.tooltip": "Кто играет: профиль, хранимый в этой папке игры.",
"horizon.shader_options.open": "Настройки шейдеров…",
"horizon.shader_options.profile": "Профиль",
"horizon.shader_options.loading": "Чтение настроек набора шейдеров…"
"horizon.shader_options.loading": "Чтение настроек набора шейдеров…",
"General": "Общие",
"Advanced": "Дополнительно",
"Undo": "Отменить",
"Search options": "Поиск настроек",
"Shortcut": "Сочетание клавиш",
"Distance": "Дальность",
"Display": "Экран",
"Interface": "Интерфейс",
"Sky and weather": "Небо и погода",
"Lighting and entities": "Освещение и сущности",
"Textures": "Текстуры",
"Image": "Изображение",
"Chunk loading": "Загрузка чанков",
"Culling": "Отсечение",
"Power": "Питание",
"Frames": "Кадры",
"Overlay": "Оверлей",
"Output": "Вывод",
"Brightness": "Яркость",
"How far you see, the window and the HUD.": "Дальность обзора, окно и HUD.",
"The sky and weather, lighting, textures and the image.": "Небо и погода, освещение, текстуры и изображение.",
"Chunk loading, culling and power use.": "Загрузка чанков, отсечение и энергопотребление.",
"How frames reach the screen, and the performance overlay.": "Как кадры попадают на экран, и оверлей производительности.",
"Brighter-than-white output on an HDR display.": "Вывод ярче белого на HDR-дисплее.",
"Results for \"%s\"": "Результаты для «%s»",
"No settings match.": "Нет подходящих настроек."
}
@@ -82,7 +82,6 @@
"Resolution Scale": "Çözünürlük Ölçeği",
"Anti-aliasing": "Kenar Yumuşatma",
"Upscaling": "Yükseltme",
"HDR Settings...": "HDR Ayarları...",
"Paper White": "Referans Beyaz",
"Peak Brightness": "Tepe Parlaklığı",
"Black Level": "Siyah Seviyesi",
@@ -122,16 +121,8 @@
"Medium cost": "Orta Performans Maliyeti",
"High cost": "Yüksek Performans Maliyeti",
"Saves time": "Zaman Kazandırır",
"Rendering": "Görüntüleme",
"Display": "Ekran",
"Preferences": "Tercihler",
"Overlay": "Bilgi Katmanı",
"HDR": "HDR",
"Graphics": "Grafikler",
"Graphics: Advanced": "Grafikler: Gelişmiş",
"Graphics: HDR": "Grafikler: HDR",
"Advanced...": "Gelişmiş...",
"Simple...": "Basit...",
"Done": "Tamam",
"This feature is not yet available in Horizon.": "Bu özellik henüz Horizon'da mevcut değil.",
"Your display or system doesn't offer HDR output.": "Ekranınız veya sisteminiz HDR çıkışını desteklemiyor.",
@@ -202,5 +193,31 @@
"horizon.pages.split_profiles.tooltip": "Kim oynuyor: bu oyun klasöründe tutulan bir profil.",
"horizon.shader_options.open": "Gölgelendirici Seçenekleri…",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Gölgelendirici paketinin seçenekleri okunuyor…"
"horizon.shader_options.loading": "Gölgelendirici paketinin seçenekleri okunuyor…",
"General": "Genel",
"Advanced": "Gelişmiş",
"Undo": "Geri al",
"Search options": "Seçeneklerde ara",
"Shortcut": "Kısayol",
"Distance": "Mesafe",
"Display": "Ekran",
"Interface": "Arayüz",
"Sky and weather": "Gökyüzü ve hava",
"Lighting and entities": "Işıklandırma ve varlıklar",
"Textures": "Dokular",
"Image": "Görüntü",
"Chunk loading": "Chunk yükleme",
"Culling": "Ayıklama",
"Power": "Güç",
"Frames": "Kareler",
"Overlay": "Kaplama",
"Output": "Çıkış",
"Brightness": "Parlaklık",
"How far you see, the window and the HUD.": "Ne kadar uzağı gördüğün, pencere ve HUD.",
"The sky and weather, lighting, textures and the image.": "Gökyüzü ve hava, ışıklandırma, dokular ve görüntü.",
"Chunk loading, culling and power use.": "Chunk yükleme, ayıklama ve güç kullanımı.",
"How frames reach the screen, and the performance overlay.": "Karelerin ekrana nasıl ulaştığı ve performans kaplaması.",
"Brighter-than-white output on an HDR display.": "HDR ekranda beyazdan parlak çıkış.",
"Results for \"%s\"": "\"%s\" için sonuçlar",
"No settings match.": "Eşleşen ayar yok."
}
@@ -88,7 +88,6 @@
"Resolution Scale": "Масштаб зображення",
"Anti-aliasing": "Згладжування",
"Upscaling": "Апскейлінг",
"HDR Settings...": "Налаштування HDR…",
"Paper White": "Біла точка",
"Peak Brightness": "Пік яскравості",
"Black Level": "Рівень чорного",
@@ -129,16 +128,10 @@
"Medium cost": "Середні витрати",
"High cost": "Високі витрати",
"Saves time": "Заощаджує час",
"Rendering": "Промальовування",
"Display": "Дисплей",
"Preferences": "Уподобання",
"Overlay": "Накладка",
"HDR": "HDR",
"Graphics": "Графіка",
"Graphics: Advanced": "Графіка: розширені",
"Graphics: HDR": "Графіка: HDR",
"Advanced...": "Розширені…",
"Simple...": "Прості…",
"Done": "Готово",
"This feature is not yet available in Horizon.": "Ця функція ще недоступна в Horizon.",
"Your display or system doesn't offer HDR output.": "Ваш дисплей або система не підтримує вивід HDR.",
@@ -211,6 +204,30 @@
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…",
"General": "Загальні",
"Advanced": "Додатково",
"Undo": "Скасувати",
"Search options": "Пошук налаштувань",
"Shortcut": "Сполучення клавіш",
"Distance": "Дальність",
"Interface": "Інтерфейс",
"Sky and weather": "Небо й погода",
"Lighting and entities": "Освітлення й сутності",
"Textures": "Текстури",
"Image": "Зображення",
"Chunk loading": "Завантаження чанків",
"Culling": "Відсікання",
"Power": "Живлення",
"Frames": "Кадри",
"Output": "Вивід",
"Brightness": "Яскравість",
"How far you see, the window and the HUD.": "Як далеко видно, вікно й HUD.",
"The sky and weather, lighting, textures and the image.": "Небо й погода, освітлення, текстури й зображення.",
"Chunk loading, culling and power use.": "Завантаження чанків, відсікання й енергоспоживання.",
"How frames reach the screen, and the performance overlay.": "Як кадри потрапляють на екран, і оверлей продуктивності.",
"Brighter-than-white output on an HDR display.": "Вивід, яскравіший за білий, на HDR-дисплеї.",
"Results for \"%s\"": "Результати для «%s»",
"No settings match.": "Немає відповідних налаштувань.",
"horizon.title.not_affiliated": "Не пов’язано з Mojang чи Microsoft",
"horizon.extras.header.lighting": "Освітлення",
"horizon.extras.header.third_person": "Вигляд від третьої особи",
@@ -291,7 +308,6 @@
"horizon.osk.type": "Ввести",
"horizon.osk.symbols": "Символи",
"horizon.osk.close": "Закрити",
"Culling": "Відсікання",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ховає моделі за непрозорими блоками. Перевірка у фоні; сутності оновлюються.",
"Leaf Culling": "Відсікання листя",
"Every leaf face. Vanilla's fancy leaves.": "Кожна грань листя, як це робить Minecraft у гарній графіці.",
@@ -82,7 +82,6 @@
"Resolution Scale": "渲染比例",
"Anti-aliasing": "抗锯齿",
"Upscaling": "放大",
"HDR Settings...": "HDR 设置…",
"Paper White": "纸白亮度",
"Peak Brightness": "峰值亮度",
"Black Level": "黑位",
@@ -123,16 +122,8 @@
"Medium cost": "中等开销",
"High cost": "高开销",
"Saves time": "节省时间",
"Rendering": "渲染",
"Display": "显示",
"Preferences": "偏好",
"Overlay": "叠加层",
"HDR": "HDR",
"Graphics": "图形",
"Graphics: Advanced": "图形:高级",
"Graphics: HDR": "图形:HDR",
"Advanced...": "高级…",
"Simple...": "简单…",
"Done": "完成",
"This feature is not yet available in Horizon.": "此功能在 Horizon 中尚不可用。",
"Your display or system doesn't offer HDR output.": "你的显示器或系统不支持 HDR 输出。",
@@ -211,6 +202,32 @@
"horizon.shader_options.open": "光影选项…",
"horizon.shader_options.profile": "档案",
"horizon.shader_options.loading": "正在读取光影包的选项…",
"General": "常规",
"Advanced": "高级",
"Undo": "撤销",
"Search options": "搜索选项",
"Shortcut": "快捷键",
"Distance": "距离",
"Display": "显示",
"Interface": "界面",
"Sky and weather": "天空与天气",
"Lighting and entities": "光照与实体",
"Textures": "纹理",
"Image": "图像",
"Chunk loading": "区块加载",
"Culling": "剔除",
"Power": "电源",
"Frames": "帧",
"Overlay": "叠加层",
"Output": "输出",
"Brightness": "亮度",
"How far you see, the window and the HUD.": "视距、窗口与 HUD。",
"The sky and weather, lighting, textures and the image.": "天空与天气、光照、纹理与图像。",
"Chunk loading, culling and power use.": "区块加载、剔除与功耗。",
"How frames reach the screen, and the performance overlay.": "帧如何送达屏幕,以及性能叠加层。",
"Brighter-than-white output on an HDR display.": "在 HDR 显示器上输出比白色更亮的画面。",
"Results for \"%s\"": "“%s”的结果",
"No settings match.": "没有匹配的设置。",
"horizon.pages.split_settings": "分屏设置",
"horizon.pages.split_setup": "分屏",
"horizon.pages.split_setup.tooltip": "在本机游玩的玩家:手柄可以加入你的世界。",
@@ -308,7 +325,6 @@
"horizon.extras.chat_animation.tooltip": "新聊天行从左侧滑入并淡入,而不是整行直接出现。",
"horizon.extras.chat_indicator.tooltip": "聊天行左侧的彩色竖条。",
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。",
"Culling": "剔除",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "隐藏不透明方块后面的模型。检测在后台线程进行,实体仍会更新。",
"Leaf Culling": "树叶剔除",
"Every leaf face. Vanilla's fancy leaves.": "绘制每个树叶面,与原版的华丽树叶一致。",
+14
View File
@@ -1420,6 +1420,20 @@ pub fn mob_from_nbt(
crate::mobs::finalize::Attr::MovementSpeed,
v,
),
// A saved base other than the type's default is the
// attribute's base, which `/attribute ... base get` reads.
"minecraft:max_health"
if crate::mobs::finalize::default_value(
&m,
crate::mobs::finalize::Attr::MaxHealth,
) != Some(v) =>
{
crate::mobs::finalize::set_base(
&mut m,
crate::mobs::finalize::Attr::MaxHealth,
v,
)
}
"minecraft:max_health" => m.attrs.max_health = v as f32,
"minecraft:movement_speed" => m.attrs.speed = v,
"minecraft:attack_damage" => m.attrs.attack_damage = v as f32,
+26
View File
@@ -208,6 +208,7 @@ mod native_explosions;
mod native_falling;
mod native_hooks;
mod native_inventories;
mod native_item_container;
mod native_item_energy;
mod native_item_fluids;
mod native_lifecycle;
@@ -9021,6 +9022,10 @@ impl Game<'_> {
self.last_inv_click = None;
}
pub(super) fn close_container(&mut self) {
// `Gui.setScreen` clears the old screen's focus before it goes: the
// key that closed the container (Tab bound to the inventory) has
// already moved the focus, and it must not outlive the screen.
self.pad_clear_focus();
self.mod_browser_closed();
self.reset_inventory_clicks();
self.close_station();
@@ -15828,6 +15833,7 @@ impl Game<'_> {
self.capture(false);
}
Screen::Creative => {
self.pad_clear_focus();
self.carried = None;
self.screen = Screen::Playing;
self.capture(true);
@@ -15940,6 +15946,7 @@ impl Game<'_> {
}
}
Screen::Creative => {
self.pad_clear_focus();
if let Some(c) = self.carried.take() {
let _ = self.give(c);
}
@@ -17368,6 +17375,25 @@ impl Game<'_> {
self.title_action(event_loop, action)?;
self.reopen_data_packs(event_loop)?;
}
// AbstractSelectionList.mouseClicked: the world list takes a
// click of any button, so the middle one selects its row
// without joining (only the left click's play icon or its
// click on the selected row does that).
WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Middle,
..
} if self.title.is_some() && self.screen != Screen::Options => {
let size = self
.renderer
.as_ref()
.map(|r| [r.config.width, r.config.height])
.unwrap_or([1, 1]);
let b = HudBatch::new(size);
if let Some(title) = self.title.as_mut() {
title.select_world_at(self.cursor.0, self.cursor.1, (b.width, b.height));
}
}
WindowEvent::MouseInput { .. } if self.title.is_some() => {}
WindowEvent::MouseInput { state, button, .. } if self.book.is_some() => {
if state == ElementState::Pressed && button == MouseButton::Left {
+50 -1
View File
@@ -287,7 +287,8 @@ fn resource_ids(root: &Path) -> Arc<ResourceIds> {
let ids = Arc::new(ResourceIds {
recipes: ids_under(&data.join("recipe"), "json"),
damage_types: ids_under(&data.join("damage_type"), "json"),
loot_tables: ids_under(&data.join("loot_table"), "json"),
// `/loot ... loot <id>` must find a content mod's tables too.
loot_tables: json_ids_of_every_namespace(root, "loot_table"),
templates: templates_under(&root.join("worldgen/structure")),
entity_types,
enchantments: json_ids_in(&data.join("enchantment")),
@@ -364,6 +365,33 @@ fn ids_under(root: &Path, extension: &str) -> Vec<String> {
ids_in(root, extension, "minecraft")
}
/// The ids of the `.json` files in `data/<namespace>/<kind>` of every
/// namespace of `root` (vanilla's and the content mods'), sorted.
fn json_ids_of_every_namespace(root: &Path, kind: &str) -> Vec<String> {
let data = root.join("data");
let mut namespaces: std::collections::BTreeSet<String> = std::fs::read_dir(&data)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
// A prepared root's namespaces live in its data file, not on the disk.
if let Some(files) = horizon_core::prepared_files::files_under(&data) {
namespaces.extend(
files
.iter()
.filter_map(|f| Some(f.split_once('/')?.0.to_owned())),
);
}
let mut out: Vec<String> = namespaces
.iter()
.flat_map(|ns| ids_in(&data.join(ns).join(kind), "json", ns))
.collect();
out.sort();
out
}
/// The templates of every namespace folder under `root` (vanilla's and the
/// content mods'), as `namespace:path`, sorted.
fn templates_under(root: &Path) -> Vec<String> {
@@ -1579,6 +1607,27 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// A content mod's loot tables are listed with vanilla's, so `/loot`
/// finds them.
#[test]
fn loot_tables_of_every_namespace_are_listed() {
let root = std::env::temp_dir().join(format!("horizon-loot-ids-{}", std::process::id()));
for dir in [
"data/minecraft/loot_table/chests",
"data/mymod/loot_table/rooms",
] {
std::fs::create_dir_all(root.join(dir)).unwrap();
}
std::fs::write(root.join("data/minecraft/loot_table/chests/a.json"), "{}").unwrap();
std::fs::write(root.join("data/mymod/loot_table/rooms/b.json"), "{}").unwrap();
std::fs::write(root.join("data/mymod/loot_table/notes.txt"), "").unwrap();
assert_eq!(
resource_ids(&root).loot_tables,
["minecraft:chests/a", "mymod:rooms/b"]
);
let _ = std::fs::remove_dir_all(&root);
}
/// Only the overworld clock's timelines count; a marker is a bare tick
/// count (hidden from commands) or an object that may show itself.
#[test]
@@ -2342,6 +2342,37 @@ impl Game<'_> {
state,
)
}
/// `BlockInput.overwriteWithDefinedProperties`: restore explicit command
/// properties after normalizing the state against neighbouring blocks.
fn block_input_state(
&mut self,
pos: [i32; 3],
input: &BlockInput,
state: BlockStateId,
strict: bool,
) -> R<BlockStateId> {
if strict {
return Ok(state);
}
let updated = self.with_level(|b, level, ticks| {
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
let updated = b.update_from_neighbour_shapes(level, ticks, at, state);
if b.is_air(updated) { state } else { updated }
});
let registry = self.pack.assets.registry;
let mut descriptor = registry
.state(updated)
.expect("registered block state")
.clone();
for (key, value) in &input.properties {
if descriptor.properties.contains_key(key) {
descriptor.properties.insert(key.clone(), value.clone());
}
}
registry
.resolve(&descriptor)
.map_err(|_| err("argument.block.id.invalid", &[&input.id]))
}
/// Place a state like BlockInput.place: false when nothing changed.
pub(super) fn place_state(
&mut self,
@@ -2410,6 +2441,7 @@ impl Game<'_> {
if ctx.has("destroy") && current != self.world.air {
self.destroy_block_dropping(pos)?;
}
let state = self.block_input_state(pos, &b, state, ctx.has("strict"))?;
let placed = self.place_state(pos, state, ctx.has("strict"))?;
let loaded = match &b.nbt {
Some(nbt) => self.load_block_entity_nbt(pos, nbt),
@@ -7,7 +7,7 @@ use horizon_client::{
text::Text,
worldborder::MAX_SIZE,
};
use horizon_core::random::RandomSource;
use horizon_core::{position::ChunkPos, random::RandomSource};
fn num(n: impl std::fmt::Display) -> Text {
Text::literal(n.to_string())
@@ -41,7 +41,8 @@ impl Game<'_> {
match id {
"forceload.query" if ctx.get("pos").is_none() => {
let mut forced: Vec<(i32, i32)> = self.streaming.forced.iter().copied().collect();
forced.sort_unstable();
// ForceLoadCommand sorts its LongStream before formatting chunks.
forced.sort_unstable_by_key(|&(x, z)| ChunkPos::new(x, z).pack());
if forced.is_empty() {
self.command_failure(Text::translate("commands.forceload.added.none", &[dim]));
return Ok(0);
@@ -574,10 +575,11 @@ impl Game<'_> {
let p = self.block_pos_arg(ctx, "pos")?;
let state = self.world.block(p[0], p[1], p[2]);
let name = self.data_block_name(p);
let table = format!("minecraft:blocks/{}", name.trim_start_matches("minecraft:"));
if !world_items!(self).has_loot_table(&table) {
let Some(table) =
world_items!(self).block_loot_table(self.pack.assets.registry, state)
else {
return Err(err("commands.drop.no_loot_table.block", &[&name]));
}
};
let tool = self.tool_stack(ctx)?;
let drops = match world_items!(self).loot_sequence(&table) {
Some(seq) => {
@@ -2,7 +2,10 @@
//! `/particle`, `/attribute`, `/ride` and `/spreadplayers`.
use super::{Arg, Context, Game, R, Target, err, wrap_degrees};
use horizon_client::{
mobs::{Damage, DamageKind, Who},
mobs::{
Damage, DamageKind, Who,
finalize::{self, Attr},
},
scoreboard_cmd::{Holder, Tracker},
settings::SOUND_SOURCES,
text::Text,
@@ -918,9 +921,13 @@ impl Game<'_> {
let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob_id) else {
return Ok(0);
};
// Mobs keep three attributes as plain values.
// Core mob attributes also have cached gameplay fields.
let slot: Option<f64> = match short.as_str() {
"max_health" => Some(f64::from(m.attrs.max_health)),
"max_health" => Some(if id == "attribute.base.get" {
finalize::base_value(m, Attr::MaxHealth)
} else {
finalize::value(m, Attr::MaxHealth)
}),
"movement_speed" => Some(m.attrs.speed),
"attack_damage" => Some(f64::from(m.attrs.attack_damage)),
_ => None,
@@ -944,8 +951,9 @@ impl Game<'_> {
"attribute.base.set" => {
match short.as_str() {
"max_health" => {
m.attrs.max_health = value_arg as f32;
m.health = m.health.min(m.attrs.max_health);
// AttributeInstance keeps the raw base, clamps only
// the effective value, and does not change Health.
finalize::set_base(m, Attr::MaxHealth, value_arg);
}
"movement_speed" => m.attrs.speed = value_arg,
_ => m.attrs.attack_damage = value_arg as f32,
@@ -958,12 +966,15 @@ impl Game<'_> {
}
"attribute.base.reset" => {
match short.as_str() {
"max_health" => m.attrs.max_health = m.kind.max_health,
"max_health" => {
let default = f64::from(m.kind.max_health);
finalize::set_base(m, Attr::MaxHealth, default);
}
"movement_speed" => m.attrs.speed = m.kind.speed,
_ => m.attrs.attack_damage = m.kind.attack_damage,
}
let v = match short.as_str() {
"max_health" => f64::from(m.attrs.max_health),
"max_health" => finalize::base_value(m, Attr::MaxHealth),
"movement_speed" => m.attrs.speed,
_ => f64::from(m.attrs.attack_damage),
};
@@ -1736,6 +1736,14 @@ impl Game<'_> {
for text in self.chat_capture.take().unwrap_or_default() {
self.console_reply(text);
}
// ServerPlayer.onEffectAdded and onEffectUpdated send an effect
// the command gave as it was given: vanilla's console runs after
// the players' tick (`DedicatedServer.tickConnection`), so the
// next tick is the first to count it down. A player's own
// commands sync theirs the same way (`run_guest_command`).
if let Some(h) = self.lan.as_mut() {
h.hub.sync_all_effects();
}
}
}
}
+6 -1
View File
@@ -155,7 +155,12 @@ impl Game<'_> {
if let Some(id) = self.spawn_mob("minecraft:happy_ghast", at, true)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.extra.age = -24000;
// DriedGhastBlock.spawnGhastling sets the baby age before
// addFreshEntity, so ageBoundaryReached runs the ghastling setup
// (`babyGhastSetup`) while the mob is still being hatched: its
// goals, navigation and brain are a ghastling's before its first
// tick, not an adult's until the next aiStep.
horizon_client::mobs::finalize::set_age(m, -24000);
m.yaw = yaw;
m.body_yaw = yaw;
m.head_yaw = yaw;
+24
View File
@@ -94,6 +94,29 @@ impl Game<'_> {
.integrated
.as_ref()
.and_then(|i| i.call(|g| (g.level_census(), g.section_addresses())));
// The server thread's generation: proto chunks waiting for their
// neighbours, and the light jobs' views of them.
let generation = self.integrated.as_ref().and_then(|i| {
i.call(|g| {
let c = g.streaming.proto_census();
let [blocks, lists, heightmaps, other, biomes, records] = c.bytes;
json!({
"protos": c.protos,
"protos_mb": mb(c.bytes.iter().sum::<usize>()),
"proto_blocks_mb": mb(blocks),
"proto_section_lists_mb": mb(lists),
"proto_heightmaps_mb": mb(heightmaps),
"proto_other_terrain_mb": mb(other),
"proto_biomes_mb": mb(biomes),
"proto_records_mb": mb(records),
"proto_sections_by_bits": c.sections_by_bits,
"light_views": c.light_views,
"light_views_mb": mb(c.light_view_bytes),
"caches": c.caches.iter().map(|(name, n, bytes)| json!([name, n, bytes.map(mb)])).collect::<Vec<_>>(),
"stages": g.streaming.stages().iter().map(|(k, v)| json!([k, v])).collect::<Vec<_>>(),
})
})
});
// The window's sections the server thread holds too (lan/handoff.rs).
let shared = server.as_ref().map(|(_, at)| {
let (sections, bytes) = shared(self.all_worlds(), at);
@@ -136,6 +159,7 @@ impl Game<'_> {
"client_columns": census_json(&client),
"server_columns": server.as_ref().map(|(c, _)| census_json(c)),
"shared_with_server": shared,
"server_generation": generation,
"far_tiles": self.lod.as_ref().map(|lod| {
let (active, cached) = lod.stats();
json!({ "active": active, "cached": cached })
+18
View File
@@ -495,4 +495,22 @@ impl Host for super::Game<'_> {
fn block_collision(&self, pos: [i32; 3]) -> Vec<[f64; 6]> {
self.native_block_collision(pos)
}
fn item_container_slots(&self, stack: &ItemStack) -> Option<Vec<Option<ItemStack>>> {
self.native_item_container_slots(stack)
}
fn item_container_insert(
&self,
stack: &ItemStack,
item: &ItemStack,
) -> Option<(u32, ItemStack)> {
self.native_item_container_insert(stack, item)
}
fn item_container_extract(
&self,
stack: &ItemStack,
slot: usize,
max: u32,
) -> Option<(Option<ItemStack>, ItemStack)> {
self.native_item_container_extract(stack, slot, max)
}
}
@@ -2827,6 +2827,18 @@ impl Game<'_> {
) {
mod_api::dispatch_item_energy_buffer(self, mod_api::mods(), item, buffer);
}
/// Item `item` (`mod_id:name`) holds items (`Capabilities.Item.ITEM`): set
/// `slots` when the item is yours, for a backpack, a storage cell or a
/// toolbox. Clamped to 1..=256, `ItemContainerContents`' limit; 0 leaves
/// the item without slots, and vanilla's items have none. Asked at each
/// call of the item container helpers (`native_item_container_slots`,
/// `native_item_container_insert`, `native_item_container_extract`, in
/// `native_item_container.rs`). The contents are kept in the stack's
/// `minecraft:container`, as a shulker box item keeps them.
/// API @API:item_container@.
pub(super) fn native_item_container(&self, item: &str, slots: &mut Option<u32>) {
mod_api::dispatch_item_container(self, mod_api::mods(), item, slots);
}
/// A server tick of the block entity at `pos`, in vanilla's order among
/// the others (`Level.tickBlockEntities`), when its `id` is yours: read
/// and change its saved compound `nbt`, its item slots `items` (keep
@@ -0,0 +1,370 @@
//! Items held by item stacks for native mods (docs/native-mods.md): a mod's
//! backpacks, storage cells and toolboxes (`native_item_container`), read,
//! filled and emptied. NeoForge's `Capabilities.Item.ITEM` is a
//! `ResourceHandler<ItemResource>` reached through an `ItemAccess`: it acts on
//! one item of a stack at a time and may change it. So do these, as the item
//! fluid and energy helpers do: they take a stack and answer without changing
//! it, and the caller shrinks its stack by one and hands out the changed item
//! they return. The contents live in `minecraft:container`, as a shulker box
//! item keeps them. They hold no world state, so they work on any side.
use super::Game;
use horizon_client::items::ItemStack;
use horizon_world::nbt::Tag;
/// `ItemContainerContents`' limit on slots.
const MAX_SLOTS: u32 = 256;
/// `Container.getMaxStackSize`.
const CONTAINER_MAX: u32 = 99;
/// The slots of one item of `stack`, empty ones included; slots past `size`
/// are dropped, as the item has no room for them.
fn contents(stack: &ItemStack, size: u32) -> Vec<Option<ItemStack>> {
let mut slots = vec![None; size as usize];
for (slot, item) in stack.data().map_or(&[][..], |d| &d.container[..]) {
if let Some(s) = slots.get_mut(usize::from(*slot))
&& item.count > 0
{
*s = Some(item.clone());
}
}
slots
}
/// One item of `stack`, unchanged.
fn one_of(stack: &ItemStack) -> ItemStack {
let mut one = stack.clone();
one.count = 1;
one
}
/// One item of `stack` holding `slots`, its other data kept. An empty item
/// carries nothing, so it stacks with a new one.
fn with_contents(stack: &ItemStack, slots: &[Option<ItemStack>]) -> ItemStack {
let mut one = one_of(stack);
let data = one.data_mut();
data.container = slots
.iter()
.enumerate()
.filter_map(|(i, s)| Some((i as u8, s.clone().filter(|s| s.count > 0)?)))
.collect();
// What a server sent for the stack no longer describes it; kept, an
// emptied item would not stack with a new one.
data.wire = Default::default();
one.tidy();
one
}
/// `SimpleContainer.addItem` on one item of `stack`: `item` tops up the
/// slots that hold the same item and components, then goes into the first
/// empty ones. Unlike `SimpleContainer`, which puts the whole rest in the
/// first empty slot and cuts it to the limit, no slot takes more than its
/// limit (`limit`), and what doesn't fit stays out. A shulker box never goes
/// in (`Item.canFitInsideContainerItems`). The count that went in, and the
/// item it becomes.
fn insert_one(
stack: &ItemStack,
size: Option<u32>,
item: &ItemStack,
limit: u32,
) -> Option<(u32, ItemStack)> {
let size = size?;
if stack.count == 0 {
return None;
}
if item.count == 0 || !horizon_client::menu::fits_inside_container_items(item) {
return Some((0, one_of(stack)));
}
let mut slots = contents(stack, size);
let mut left = item.count;
for held in slots.iter_mut().flatten() {
if left == 0 {
break;
}
if held.same_item_same_components(item) {
let room = limit.saturating_sub(held.count).min(left);
held.count += room;
left -= room;
}
}
for slot in slots.iter_mut().filter(|s| s.is_none()) {
if left == 0 {
break;
}
let put = limit.min(left);
*slot = Some(ItemStack {
count: put,
..item.clone()
});
left -= put;
}
let moved = item.count - left;
if moved == 0 {
return Some((0, one_of(stack)));
}
Some((moved, with_contents(stack, &slots)))
}
/// Up to `max` out of `slot` of one item of `stack`: what came out (none
/// when nothing did), and the item it becomes.
fn extract_one(
stack: &ItemStack,
size: Option<u32>,
slot: usize,
max: u32,
) -> Option<(Option<ItemStack>, ItemStack)> {
let size = size?;
if stack.count == 0 {
return None;
}
let mut slots = contents(stack, size);
let Some(Some(held)) = slots.get_mut(slot) else {
return Some((None, one_of(stack)));
};
let take = held.count.min(max);
if take == 0 {
return Some((None, one_of(stack)));
}
let taken = ItemStack {
count: take,
..held.clone()
};
held.count -= take;
Some((Some(taken), with_contents(stack, &slots)))
}
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// The slots a mod gave item `item`, clamped; none for any other item.
fn item_container_size(&self, item: &str) -> Option<u32> {
if item.starts_with("minecraft:") {
return None;
}
let mut slots = None;
self.native_item_container(item, &mut slots);
slots.filter(|s| *s >= 1).map(|s| s.min(MAX_SLOTS))
}
/// `Container.getMaxStackSize(stack)`: the container's 99, or the stack's
/// own limit (its `max_stack_size` component, else the item's) when less.
fn item_container_limit(&self, stack: &ItemStack) -> u32 {
let own = match stack
.data()
.and_then(|d| d.other("minecraft:max_stack_size"))
{
Some(Tag::Int(n)) => (*n).clamp(1, 99) as u32,
_ => self
.res
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack),
};
own.min(CONTAINER_MAX)
}
/// `Capabilities.Item.ITEM`, read: the slots of one item of `stack`,
/// empty ones included, kept in its `minecraft:container`. None for an
/// item without slots, and for an empty stack.
pub(in crate::game) fn native_item_container_slots(
&self,
stack: &ItemStack,
) -> Option<Vec<Option<ItemStack>>> {
let size = self.item_container_size(&stack.item)?;
(stack.count > 0).then(|| contents(stack, size))
}
/// `ResourceHandler.insert`: as much of `item` as fits into one item of
/// `stack`, topping up the slots that hold the same item and components
/// first, then the first empty ones (`SimpleContainer.addItem`'s order),
/// no slot past its limit (99, or the item's own when less). A shulker
/// box never goes in. The count that went in, and the single item it
/// becomes (the item itself when none did). The stack given is not
/// changed: shrink it by one and hand out the result, or leave the result
/// unused to simulate. None where the item has no slots.
pub(in crate::game) fn native_item_container_insert(
&self,
stack: &ItemStack,
item: &ItemStack,
) -> Option<(u32, ItemStack)> {
let size = self.item_container_size(&stack.item);
insert_one(stack, size, item, self.item_container_limit(item))
}
/// `ResourceHandler.extract`: up to `max` out of slot `slot` of one item
/// of `stack`. What came out (none when the slot is empty or past the
/// item's slots), and the single item it becomes. The stack given is not
/// changed, as with `native_item_container_insert`. None where the item
/// has no slots.
pub(in crate::game) fn native_item_container_extract(
&self,
stack: &ItemStack,
slot: usize,
max: u32,
) -> Option<(Option<ItemStack>, ItemStack)> {
let size = self.item_container_size(&stack.item);
extract_one(stack, size, slot, max)
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_world::nbt::JavaString;
use std::sync::Arc;
fn bag(count: u32) -> ItemStack {
ItemStack::new("mymod:bag", count)
}
fn of(item: &str, count: u32) -> ItemStack {
ItemStack::new(item, count)
}
/// (slot, item, count) of what one item holds.
fn held(stack: &ItemStack, size: u32) -> Vec<(usize, String, u32)> {
contents(stack, size)
.into_iter()
.enumerate()
.filter_map(|(i, s)| s.map(|s| (i, s.item.to_string(), s.count)))
.collect()
}
#[test]
fn an_insert_tops_up_equal_stacks_before_empty_slots() {
let (_, one) = insert_one(&bag(1), Some(3), &of("minecraft:stone", 10), 64).unwrap();
let (_, one) = insert_one(&one, Some(3), &of("minecraft:dirt", 5), 64).unwrap();
let (moved, one) = insert_one(&one, Some(3), &of("minecraft:stone", 60), 64).unwrap();
// 54 top up slot 0 to 64, the other 6 take the first empty slot.
assert_eq!(moved, 60);
assert_eq!(
held(&one, 3),
[
(0, "minecraft:stone".into(), 64),
(1, "minecraft:dirt".into(), 5),
(2, "minecraft:stone".into(), 6),
]
);
}
#[test]
fn no_slot_passes_its_limit_and_the_rest_stays_out() {
// 16 per slot (ender pearls), two slots: 32 of 40 go in.
let (moved, one) =
insert_one(&bag(1), Some(2), &of("minecraft:ender_pearl", 40), 16).unwrap();
assert_eq!(moved, 32);
assert_eq!(
held(&one, 2),
[
(0, "minecraft:ender_pearl".into(), 16),
(1, "minecraft:ender_pearl".into(), 16),
]
);
// Full: nothing goes in, and the item comes back as it was.
assert_eq!(
insert_one(&one, Some(2), &of("minecraft:ender_pearl", 1), 16).unwrap(),
(0, one.clone())
);
}
#[test]
fn different_components_never_share_a_slot() {
let mut named = of("minecraft:stone", 1);
named.data_mut().set_other(
"minecraft:custom_name",
Some(Tag::String(JavaString::from("Rock"))),
);
let (_, one) = insert_one(&bag(1), Some(2), &of("minecraft:stone", 1), 64).unwrap();
let (moved, one) = insert_one(&one, Some(2), &named, 64).unwrap();
assert_eq!(moved, 1);
assert_eq!(held(&one, 2).len(), 2);
}
#[test]
fn a_shulker_box_never_goes_in() {
let shulker = of("minecraft:red_shulker_box", 1);
assert_eq!(
insert_one(&bag(1), Some(9), &shulker, 1).unwrap(),
(0, bag(1))
);
// Another container item does.
assert_eq!(insert_one(&bag(1), Some(9), &bag(1), 64).unwrap().0, 1);
}
#[test]
fn an_extract_takes_up_to_max_and_an_emptied_bag_is_new_again() {
let (_, full) = insert_one(&bag(1), Some(4), &of("minecraft:apple", 20), 64).unwrap();
let (taken, rest) = extract_one(&full, Some(4), 0, 15).unwrap();
assert_eq!(taken.map(|t| t.count), Some(15));
assert_eq!(held(&rest, 4), [(0, "minecraft:apple".into(), 5)]);
let (taken, empty) = extract_one(&rest, Some(4), 0, 64).unwrap();
assert_eq!(
taken.map(|t| (t.item.to_string(), t.count)),
Some(("minecraft:apple".into(), 5))
);
assert_eq!(empty, bag(1));
// An empty slot, or one past the bag's slots, gives nothing.
assert_eq!(extract_one(&empty, Some(4), 0, 1).unwrap(), (None, bag(1)));
assert_eq!(
extract_one(&full, Some(4), 7, 1).unwrap(),
(None, full.clone())
);
}
#[test]
fn the_stack_given_stays_and_one_item_comes_back() {
let stack = bag(3);
let (moved, one) = insert_one(&stack, Some(1), &of("minecraft:stone", 1), 64).unwrap();
assert_eq!((moved, one.count, stack.count), (1, 1, 3));
assert_eq!(stack, bag(3));
}
#[test]
fn items_without_slots_hold_nothing() {
assert_eq!(
insert_one(&bag(1), None, &of("minecraft:stone", 1), 64),
None
);
assert_eq!(extract_one(&bag(1), None, 0, 1), None);
assert_eq!(
insert_one(&bag(0), Some(3), &of("minecraft:stone", 1), 64),
None
);
// Nothing to insert moves nothing.
assert_eq!(
insert_one(&bag(1), Some(3), &of("minecraft:stone", 0), 64).unwrap(),
(0, bag(1))
);
}
#[test]
fn slots_past_the_size_are_dropped() {
// A mod that shrank its bag after it was filled.
let (_, big) = insert_one(&bag(1), Some(3), &of("minecraft:stone", 64 * 3), 64).unwrap();
assert_eq!(held(&big, 2).len(), 2);
assert_eq!(extract_one(&big, Some(2), 2, 64).unwrap().0, None);
}
#[test]
fn an_emptied_bag_from_a_server_stacks_with_a_new_one() {
let mut sent = bag(2);
sent.data_mut().wire = horizon_client::item_data::Wire::new(bag(2), Arc::from(vec![1u8]));
let (_, filled) = insert_one(&sent, Some(1), &of("minecraft:stone", 3), 64).unwrap();
let (_, emptied) = extract_one(&filled, Some(1), 0, 3).unwrap();
assert!(emptied.same_item_same_components(&bag(1)));
}
#[test]
fn a_bag_keeps_the_rest_of_its_data() {
let mut stack = bag(1);
stack.data_mut().set_other(
"minecraft:custom_name",
Some(Tag::String(JavaString::from("Kit"))),
);
let (_, filled) = insert_one(&stack, Some(2), &of("minecraft:stone", 2), 64).unwrap();
assert!(
filled
.data()
.and_then(|d| d.other("minecraft:custom_name"))
.is_some()
);
assert_eq!(held(&filled, 2), [(0, "minecraft:stone".into(), 2)]);
let (_, emptied) = extract_one(&filled, Some(2), 0, 2).unwrap();
assert_eq!(emptied, stack);
}
}
+1 -1
View File
@@ -611,7 +611,7 @@ impl Game<'_> {
}
horizon_client::graphics::Pacing::Efficient => {
let mut clock = horizon_client::window::SleepingClock(self.frame_clock);
horizon_client::window::end_frame(&mut self.limiter, limit, &mut clock);
horizon_client::window::end_frame_on_grid(&mut self.limiter, limit, &mut clock);
}
}
}
@@ -112,6 +112,17 @@ pub(super) struct MenuCursor {
key_restart: Option<u64>,
}
impl MenuCursor {
/// `Screen.clearFocus`: nothing is focused any more, and no click focus
/// is kept for the next Tab (`ComponentPath.applyFocus(false)`).
fn clear_focus(&mut self) {
self.focus = None;
self.origin = None;
self.slider_edit = false;
self.pending = None;
}
}
impl Game<'_> {
/// A menu, a screen, the title, a sign or a book is open: the pad drives
/// it instead of the player.
@@ -1041,6 +1052,15 @@ impl Game<'_> {
|| self.ui.options.selected_pad_action.is_some()))
}
/// `Screen.clearFocus`, which `Gui.setScreen` runs on the screen it takes
/// down: the keyboard's focus belongs to that screen. A key bound to the
/// container's own close key can move the focus and close the screen in
/// one press (Tab bound to the inventory), and vanilla clears the focus
/// before the next frame, so nothing stays highlighted.
pub(super) fn pad_clear_focus(&mut self) {
self.pad.menu.clear_focus();
}
/// A key pressed in a menu, before the screen takes it
/// (`Screen.keyPressed`): Tab, Shift+Tab and the arrows move the focus,
/// Enter, Space and keypad Enter press the focused widget; the focused
@@ -1543,6 +1563,23 @@ mod tests {
assert!(!osk_auto_opens(false, false, true, 5, false));
}
#[test]
fn clear_focus_leaves_nothing_focused() {
let mut menu = MenuCursor {
focus: Some(Target {
rect: [229.0, 98.0, 20.0, 18.0],
kind: Kind::Button,
}),
origin: Some([229.0, 98.0, 20.0, 18.0]),
slider_edit: true,
..MenuCursor::default()
};
menu.clear_focus();
assert!(menu.focus.is_none());
assert!(menu.origin.is_none());
assert!(!menu.slider_edit);
}
#[test]
fn field_hit_test() {
let t = |rect, kind| Target { rect, kind };
+4
View File
@@ -9096,6 +9096,10 @@ impl Game<'_> {
self.remote_close_local();
}
fn remote_close_local(&mut self) {
// `Gui.setScreen` clears the focus of the screen it takes down: the
// container's keyboard focus must not outlive it (a Tab bound to the
// inventory moves the focus and closes the container in one press).
self.pad_clear_focus();
self.recipe_ui.reset_screen();
if let Some(r) = self.remote.as_mut() {
r.menu = None;
@@ -1056,6 +1056,22 @@ impl Loading {
}
}
}
// AbstractSelectionList.mouseClicked: a world row takes a click
// of any button, so the middle one selects it without joining.
WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Middle,
..
} => {
let batch = HudBatch::new(self.menu_size());
if let Some(title) = &mut self.menu.title {
title.select_world_at(
self.menu.cursor.0,
self.menu.cursor.1,
(batch.width, batch.height),
);
}
}
WindowEvent::MouseWheel { delta, .. } => {
let raw = match delta {
MouseScrollDelta::LineDelta(_, y) => y,
+1 -1
View File
@@ -449,7 +449,7 @@ impl Game<'_> {
pub(super) fn sort_mouse_press(&mut self, button: MouseButton) -> bool {
self.sort_input(&keybinds::mouse_name(button))
}
/// The HDR settings key (`Graphics::hdr_key`): opens Graphics: HDR from the game, where
/// The HDR settings key (`Graphics::hdr_key`): opens the graphics screen's HDR page from the game, where
/// the display offers HDR; Done goes back to the game.
pub(super) fn hdr_key_press(&mut self, key: KeyCode) -> bool {
keybinds::key_name(key).is_some_and(|name| self.hdr_input(name))
+107
View File
@@ -4172,6 +4172,113 @@ mod tests {
use super::*;
use crate::fast_hash::FxHashSet;
/// A hub with one placed guest, the speed effect registered, and what the
/// guest is sent.
fn hub_with_a_guest() -> (Hub, std::sync::mpsc::Receiver<crate::lan::Out>) {
let config = crate::lan::NetConfig {
announce: false,
..crate::lan::NetConfig::lan(0, "EffectSyncTest".into(), 2)
};
let mut sync = SyncData::empty();
sync.builtin.registries.insert(
"minecraft:mob_effect".to_string(),
horizon_protocol::registry::Ids::from_names(vec!["minecraft:speed".to_string()]),
);
let sync = std::sync::Arc::new(sync);
let lan = Lan::start_with(sync.clone(), config).unwrap();
let encoder = ChunkEncoder::new(
[] as [(std::sync::Arc<str>, bool); 0],
&horizon_protocol::registry::Builtin::default(),
&[],
&[],
);
let hub_config = HubConfig {
default_game_mode: 0,
guest_commands: false,
force_game_mode: false,
hardcore: false,
view_distance: 10,
simulation_distance: 10,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 20,
integrated: false,
levels: Default::default(),
};
let mut hub = Hub::new(lan, sync, encoder, hub_config);
let (tx, rx) = std::sync::mpsc::channel();
let mut bob = Guest::new(
crate::lan::Joined {
conn: 1,
uuid: 1,
name: "Bob".into(),
properties: Vec::new(),
info: horizon_protocol::packets::configuration::ClientInformation::new(8),
brand: "vanilla".into(),
sender: crate::lan::PlayerSender::new(tx, std::sync::Arc::default()),
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
},
1,
[0.5, 64.0, 0.5],
8,
);
bob.placed = true;
bob.dimension = "minecraft:overworld".into();
hub.guests.push(bob);
(hub, rx)
}
/// The durations of the effect packets the guest was sent.
fn sent_durations(rx: &std::sync::mpsc::Receiver<crate::lan::Out>) -> Vec<i32> {
let mut durations = Vec::new();
while let Ok(out) = rx.try_recv() {
let crate::lan::Out::Packet(body) = out else {
continue;
};
let mut r = horizon_protocol::buf::Reader::new(&body);
let id = r.varint().unwrap();
if let Ok(C::UpdateMobEffect(p)) = C::decode(id, r.rest()) {
durations.push(p.duration);
}
}
durations
}
/// `/effect give Bob minecraft:speed 30 1` from a dedicated server's
/// console (#7115). Vanilla runs the console after the players' tick
/// (`DedicatedServer.tickConnection`) and `ServerPlayer.onEffectAdded`
/// sends the effect at once: 30 s is 600 ticks, and the tick after, which
/// counts it down to 599, sends nothing (`LivingEntity.tickEffects` only
/// refreshes it at a multiple of 600 left). The console syncs right after
/// its command (`console_command`) for the same packet.
#[test]
fn an_effect_synced_after_its_command_is_sent_once_at_its_full_duration() {
let (mut hub, rx) = hub_with_a_guest();
hub.guests[0]
.effects
.add(EffectInstance::new("speed", 30 * 20, 1));
// The console's command, then its sync.
hub.sync_all_effects();
// The next tick: the guests' effects count down (`tick_guests`), then
// the hub syncs them (`tick_level`).
hub.guests[0].effects.tick();
hub.sync_all_effects();
hub.guests[0].effects.tick();
hub.sync_all_effects();
assert_eq!(sent_durations(&rx), vec![600]);
// Without the sync after the command, the first sync comes after the
// tick has counted it down: what #7115's transcript shows.
let (mut late, rx) = hub_with_a_guest();
late.guests[0]
.effects
.add(EffectInstance::new("speed", 30 * 20, 1));
late.guests[0].effects.tick();
late.sync_all_effects();
assert_eq!(sent_durations(&rx), vec![599]);
}
/// The owner's own shoulder parrots are sent when a slot changes.
#[test]
fn the_owner_is_sent_the_shoulder_slots_that_changed() {
@@ -863,6 +863,11 @@ fn age_boundary_reached(m: &mut Mob) {
Species::Goat => set_base(m, Attr::AttackDamage, if baby { 1.0 } else { 2.0 }),
Species::Hoglin => set_base(m, Attr::AttackDamage, if baby { 0.5 } else { 6.0 }),
Species::SulfurCube if !baby => sulfur_size(m, 2),
// HappyGhast.ageBoundaryReached: adultGhastSetup or babyGhastSetup,
// then Animal's no-op. A hatched ghastling needs it before it is in
// the level (DriedGhastBlock.spawnGhastling sets the baby age before
// addFreshEntity), or its first tick flies the adult goals.
Species::HappyGhast => super::happy_ghast::ensure_setup(m),
_ => {}
}
}
+4 -2
View File
@@ -2155,9 +2155,11 @@ impl Goal for Breed {
return false;
}
let species = m.species();
// Wolf.canMate: both tame and standing.
// Wolf.canMate and Cat.canMate: both tame. The sitting pose sits on the
// partner side only, and only for a wolf (`wolf.isInSittingPose()`), so
// a tamed mob ordered to sit still walks off to breed.
let tamable = matches!(species, Species::Wolf | Species::Cat);
if tamable && (!m.extra.tame || m.extra.sitting) {
if tamable && !m.extra.tame {
return false;
}
// AbstractHorse.canMate is false and Mule keeps it: mules never breed.
@@ -667,4 +667,40 @@ mod tests {
fn travel_speed_is_five_thirds_of_the_flying_speed() {
assert!((f64::from(AIR_SPEED) - FLYING_SPEED * 5.0 / 3.0).abs() < 1e-7);
}
/// `HappyGhast.ageBoundaryReached` runs the setup at the age crossing, not
/// at the next tick: `DriedGhastBlock.spawnGhastling` makes its ghastling a
/// baby before `addFreshEntity`, and `AgeableMob.setAge` calls
/// `ageBoundaryReached` there. Without it the hatched ghastling joins the
/// level with the adult's three goals, its ground navigation and its brain
/// stopped, and only its first `aiStep` (`ensure_setup`) fixes that.
#[test]
fn a_hatched_ghastling_is_set_up_as_one_at_the_age_crossing() {
use crate::mobs::Species;
let mut t = (*crate::mobs::sensor_scan_tests::kind()).clone();
t.species = Species::HappyGhast;
let mut m = Mob::new(
1,
std::sync::Arc::new(t),
horizon_core::geometry::Vec3::ZERO,
0.0,
);
// `Mob::new` registers the adult's goals (HappyGhastFloat, its tempt
// goal and FloatAround) for the age-zero ghast it makes.
assert_eq!(m.extra.scratch[0], 2, "an adult's setup at construction");
assert!(m.ai.goals.has("HappyGhastFloatGoal"));
crate::mobs::finalize::set_age(&mut m, -24000);
assert!(m.is_baby());
assert_eq!(m.extra.scratch[0], 1, "babyGhastSetup ran");
assert!(
!m.ai.goals.has("HappyGhastFloatGoal")
&& !m.ai.goals.has("RandomFloatAroundGoal")
&& !m.ai.goals.has("ForNonPathfinders"),
"a ghastling has no goals: its brain drives it"
);
assert_eq!(m.ai.nav.kind, crate::mobs::nav::NavKind::Flying);
assert!(m.ai.nav.can_float);
}
}
+45
View File
@@ -1874,6 +1874,51 @@ mod tests {
assert!(m.damage(1.0, Damage::player([0.0, 64.0, 1.0])));
assert!(m.extra.sitting);
}
/// `Cat.canMate` and `Wolf.canMate`: the seeker only has to be tamed, so a
/// mob ordered to sit still goes looking for a mate. Only a wolf's
/// *partner* is refused while it sits (`wolf.isInSittingPose()`), which is
/// the one sitting check vanilla makes.
#[test]
fn a_sitting_tame_mob_still_looks_for_a_mate() {
use crate::mobs::goals::{Breed, Cx, Goal};
use crate::mobs::world::with_test_world;
let named = |species, name: &str, id: u32, sat: bool| {
let mut m = sitting(species, name);
m.id = id;
m.extra.in_love = 600;
m.extra.sitting = sat;
m
};
for (species, name, seeker_sat, partner_sat, breeds) in [
// A tamed cat pairs with a tamed cat, sitting or not.
(Species::Cat, "minecraft:cat", true, true, true),
(Species::Cat, "minecraft:cat", true, false, true),
// A wolf pairs unless the wolf it would take is sitting.
(Species::Wolf, "minecraft:wolf", true, false, true),
(Species::Wolf, "minecraft:wolf", true, true, false),
(Species::Wolf, "minecraft:wolf", false, true, false),
] {
let mut seeker = named(species, name, 1, seeker_sat);
let partner = named(species, name, 2, partner_sat);
let seen = [partner.seen(None)];
with_test_world(&seen, |w, _| {
let mut events = Vec::new();
let mut lr = horizon_core::random::LegacyRandom::new(0);
let mut cx = Cx {
w,
lr: &mut lr,
events: &mut events,
};
let mut goal = Breed::new(1.0);
assert_eq!(
goal.can_use(&mut seeker, &mut cx),
breeds,
"{name}: seeker sits {seeker_sat}, partner sits {partner_sat}"
);
});
}
}
}
/// `NeutralMob.isAngryAtAllPlayers`: with the `universal_anger` game rule on, a
+38
View File
@@ -4518,6 +4518,22 @@ pub trait Hooks: Send + Sync {
let _ = host;
let _ = event;
}
/// Item `item` (`mod_id:name`) holds items (`Capabilities.Item.ITEM`): set
/// `slots` when the item is yours, for a backpack, a storage cell or a
/// toolbox. Clamped to 1..=256, `ItemContainerContents`' limit; 0 leaves
/// the item without slots, and vanilla's items have none. Asked at each
/// call of the item container helpers (`native_item_container_slots`,
/// `native_item_container_insert`, `native_item_container_extract`, in
/// `native_item_container.rs`). The contents are kept in the stack's
/// `minecraft:container`, as a shulker box item keeps them.
/// API @API:item_container@.
///
/// Return `Some` to set `slots`; the first mod returning `Some` wins.
fn item_container(&self, host: &dyn Host, event: &mut ItemContainerEvent<'_>) -> Option<u32> {
let _ = host;
let _ = event;
None
}
/// Local player movement input, after reading it and before applying it.
fn movement_input(&self, host: &dyn Host, event: &mut MovementInputEvent) {
let _ = host;
@@ -13874,6 +13890,28 @@ pub fn dispatch_compute_fov_modifier(
}
*modifier = event.modifier;
}
/// What [`Hooks::item_container`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ItemContainerEvent<'a> {
pub item: &'a str,
}
pub fn dispatch_item_container(
host: &dyn Host,
mods: &[Box<dyn Hooks>],
item: &str,
slots: &mut Option<u32>,
) {
if slots.is_some() {
return;
}
let mut event = ItemContainerEvent { item };
for mod_hook in super::entered(mods, "item_container") {
if let Some(replacement) = mod_hook.item_container(host, &mut event) {
*slots = Some(replacement);
break;
}
}
}
/// What [`Hooks::movement_input`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct MovementInputEvent {
+40
View File
@@ -1212,4 +1212,44 @@ pub trait Host {
let _ = pos;
unimplemented!("Host::block_collision")
}
/// `Capabilities.Item.ITEM`, read: the slots of one item of `stack`,
/// empty ones included, kept in its `minecraft:container`. None for an
/// item without slots, and for an empty stack.
fn item_container_slots(&self, stack: &ItemStack) -> Option<Vec<Option<ItemStack>>> {
let _ = stack;
unimplemented!("Host::item_container_slots")
}
/// `ResourceHandler.insert`: as much of `item` as fits into one item of
/// `stack`, topping up the slots that hold the same item and components
/// first, then the first empty ones (`SimpleContainer.addItem`'s order),
/// no slot past its limit (99, or the item's own when less). A shulker
/// box never goes in. The count that went in, and the single item it
/// becomes (the item itself when none did). The stack given is not
/// changed: shrink it by one and hand out the result, or leave the result
/// unused to simulate. None where the item has no slots.
fn item_container_insert(
&self,
stack: &ItemStack,
item: &ItemStack,
) -> Option<(u32, ItemStack)> {
let _ = stack;
let _ = item;
unimplemented!("Host::item_container_insert")
}
/// `ResourceHandler.extract`: up to `max` out of slot `slot` of one item
/// of `stack`. What came out (none when the slot is empty or past the
/// item's slots), and the single item it becomes. The stack given is not
/// changed, as with `native_item_container_insert`. None where the item
/// has no slots.
fn item_container_extract(
&self,
stack: &ItemStack,
slot: usize,
max: u32,
) -> Option<(Option<ItemStack>, ItemStack)> {
let _ = stack;
let _ = slot;
let _ = max;
unimplemented!("Host::item_container_extract")
}
}
+38
View File
@@ -4846,8 +4846,23 @@ fn decode_lod_surfaces(
}
}
}
// Spans are pushed top down, so the reverse puts the topmost last, as
// `Column::from_chunk` leaves them. Open water is then lit as open sky,
// exactly as the live path does: without it a saved column kept each
// water block's own faded skylight and drew a dark band through the sea
// where saved columns met the generator's (`grid`'s stride limit).
// Only where there is a sky to be lit by: the Nether's water must not
// gain skylight it never had.
let is_water = |state| {
sky_light
&& assets
.registry
.state(state)
.is_some_and(|block| block.name == "minecraft:water")
};
for column in &mut *columns {
column.spans.reverse();
column.light_open_water_where(is_water);
}
Some(Arc::from(columns))
}
@@ -4963,6 +4978,9 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
fn prepare_loaded(&self, worker: &mut G::Worker, chunk: &Chunk) {
self.inner.prepare_loaded(worker, chunk);
}
fn all_idle(&self) {
self.inner.all_idle();
}
fn idle(&self, worker: &mut G::Worker) {
self.inner.idle(worker);
}
@@ -4988,6 +5006,12 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
) -> Option<crate::section_mask::SectionMask> {
self.inner.non_air_sections(proto)
}
fn cache_census(&self) -> Vec<(&'static str, usize, Option<usize>)> {
self.inner.cache_census()
}
fn proto_heap_bytes(&self, proto: &crate::streaming::Proto) -> ([usize; 6], [usize; 16]) {
self.inner.proto_heap_bytes(proto)
}
fn save_protos(&self, protos: &[&crate::streaming::Proto]) {
self.save
.tables
@@ -5639,6 +5663,20 @@ mod tests {
let underwater = surfaces[4 * 16 + 4].surface.unwrap();
assert_eq!((underwater.height, underwater.state), (55, sand));
assert_eq!(underwater.water, Some((64, water)));
// Open water reads as open sky, as the generator's far columns do.
// Saved columns that kept each block's own faded skylight drew a dark
// band through the sea where the two met.
let sea = &surfaces[4 * 16 + 4].spans;
let lit: Vec<_> = sea
.iter()
.filter(|span| span.state == water)
.map(|span| span.light & 0xf0)
.collect();
assert!(!lit.is_empty(), "the column keeps its water spans");
assert!(
lit.iter().all(|sky| *sky == 0xf0),
"saved open water is lit as open sky, got {lit:?}"
);
let chorus_column = &surfaces[2 * 16 + 12];
assert_eq!(
chorus_column
+24
View File
@@ -3275,6 +3275,30 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
let next = create_world::next_tab_index(options.tab, key, shift)?;
Some(self.select_create_tab(next.min(create_world::tab_count() - 1)))
}
/// `AbstractSelectionList.mouseClicked`: the row under the pointer takes
/// a click of any button (`ObjectSelectionList.Entry.mouseClicked` accepts
/// them all), so a middle click selects a world and its Play, Edit and
/// Delete buttons hear of it (`WorldSelectionList.setSelected`), without
/// opening it. Only the left button joins, through [`Self::click`].
pub fn select_world_at(&mut self, x: f32, y: f32, size: (f32, f32)) -> bool {
let index = {
let Mode::Select { worlds, .. } = &self.mode else {
return false;
};
let Some((row, _)) = self.world_row_at(worlds, (x, y), size) else {
return false;
};
let Some((index, _)) = self.visible_worlds(worlds).nth(row) else {
return false;
};
index
};
if let Mode::Select { selected, .. } = &mut self.mode {
*selected = Some(index);
}
true
}
/// The action and whether to play `AbstractWidget.playButtonClickSound`.
/// Navigation buttons can return `Action::None` while still sounding.
pub fn click(&mut self, x: f32, y: f32, size: (f32, f32)) -> (Action, bool) {
+34
View File
@@ -719,6 +719,40 @@ fn classic_world_selection_is_silent_until_the_entry_launches() {
);
}
#[test]
fn middle_clicking_a_world_selects_it_without_joining() {
let mut f = Fixture::new();
f.world("World", "World");
f.title.enter_singleplayer();
let size = (480.0, 262.0);
let enabled = |f: &Fixture, label: &str| {
f.title
.buttons(size.0, size.1)
.into_iter()
.find(|b| b.label == label)
.unwrap()
.enabled
};
assert!(!enabled(&f, "Edit"), "nothing is selected yet");
assert!(f.title.select_world_at(240.0, 60.0, size));
assert!(matches!(
f.title.mode,
Mode::Select {
selected: Some(0),
..
}
));
assert!(
enabled(&f, "Edit"),
"the selection callback reaches the footer"
);
// Again on the selected row: still only a selection, not a join.
assert!(f.title.select_world_at(240.0, 60.0, size));
assert!(matches!(f.title.mode, Mode::Select { .. }));
// Outside the rows (the footer) nothing is selected.
assert!(!f.title.select_world_at(240.0, 250.0, size));
}
#[test]
fn a_world_open_elsewhere_stays_closed_behind_a_toast() {
let mut f = Fixture::new();
+18
View File
@@ -719,6 +719,9 @@ impl ChunkGenerator for VanillaTerrain {
fn idle(&self, worker: &mut GeneratorWorker) {
worker.release();
}
fn all_idle(&self) {
self.generator.release_caches();
}
fn lod_surface(
&self,
worker: &mut GeneratorWorker,
@@ -856,6 +859,21 @@ impl ChunkGenerator for VanillaTerrain {
_ => None,
}
}
fn cache_census(&self) -> Vec<(&'static str, usize, Option<usize>)> {
let c = self.generator.cache_census();
vec![
("biome_chunks", c.biome_chunks, Some(c.biome_bytes)),
("carver_biomes", c.carver_entries, None),
("start_chunks", c.start_chunks, None),
("start_lists", c.start_lists, None),
]
}
fn proto_heap_bytes(&self, proto: &Proto) -> ([usize; 6], [usize; 16]) {
match proto.downcast_ref::<VanillaProto>() {
Some(VanillaProto::Ready(chunk)) => (chunk.heap_bytes(), chunk.terrain.storage_bits()),
_ => ([0; 6], [0; 16]),
}
}
fn non_air_sections(&self, proto: &Proto) -> Option<crate::section_mask::SectionMask> {
let Some(VanillaProto::Ready(chunk)) = proto.downcast_ref::<VanillaProto>() else {
return None;
+4
View File
@@ -70,6 +70,10 @@ pub struct PackedStorage {
}
impl PackedStorage {
/// The heap bytes of the packed words (`horizon-ctl memory`).
pub fn heap_bytes(&self) -> usize {
self.words.capacity() * std::mem::size_of::<u64>()
}
pub fn new(bits: u8, len: usize) -> Result<Self, StorageError> {
let per_word = Self::per_word(bits)?;
Self::from_words(bits, len, vec![0; len.div_ceil(per_word)])
+26
View File
@@ -2256,6 +2256,32 @@ mod tests {
assert_eq!(table(0), None);
}
#[test]
fn content_mod_blocks_keep_their_namespace_in_loot_table_ids() {
let facts = br#"{"defaults":{"minecraft:air":0,"loot_repro:glowing_amethyst":1},
"states":[
["air",true,false,"","minecraft:air"],
["amethyst",false,false,"","loot_repro:glowing_amethyst"]
]}"#;
let registry = BlockRegistry::from_facts_json(&facts[..]).unwrap();
let mut items = Items::for_tests(HashMap::new());
items.loot = Arc::new(
[(
"loot_repro:blocks/glowing_amethyst".to_owned(),
json!({"pools": []}),
)]
.into_iter()
.collect(),
);
assert_eq!(
items
.block_loot_table(&registry, BlockStateId(1))
.as_deref(),
Some("loot_repro:blocks/glowing_amethyst")
);
}
fn ctx<'a>(
items: &'a Items,
held: Option<&'a ItemStack>,
+31 -1
View File
@@ -275,7 +275,18 @@ fn strip_comments(text: &str) -> (String, Vec<(usize, String)>) {
out.push_str(before);
line += before.matches('\n').count();
if let Some(body) = from.strip_prefix("//") {
let end = body.find('\n').unwrap_or(body.len());
// A line comment ending in a backslash includes the next line (GLSL 4.60, 3.4; Iris
// does so too): such a comment runs on. Its line breaks stay.
let mut end = body.len();
for (at, _) in body.match_indices('\n') {
if !body[..at].trim_end_matches('\r').ends_with('\\') {
end = at;
break;
}
}
let joined = body[..end].matches('\n').count();
line += joined;
out.extend(std::iter::repeat_n('\n', joined));
rest = &body[end..];
} else if let Some(body) = from.strip_prefix("/*") {
let end = body.find("*/").unwrap_or(body.len());
@@ -422,6 +433,25 @@ mod tests {
assert_eq!(messages, ["#error live", "unknown directive `#bogus`"]);
}
/// A line comment ending in a backslash runs on to the next line, as in Iris: the line it
/// takes is not code and not a directive, and later lines keep their numbers.
#[test]
fn a_line_comment_runs_on_after_a_backslash() {
let p = run(&[(
"program/final.fsh",
"#version 330\n#if 1\nfloat a; // ends in a backslash \\\n#endif\n\
// alone \\\r\n#define HIDDEN\n#ifdef HIDDEN\nfloat b;\n#endif\nfloat c;\n#endif\n",
)]);
assert!(p.diagnostics.is_empty(), "{:?}", p.diagnostics);
assert_eq!(
code_lines(&p),
[
("float a;", "program/final.fsh:3".into()),
("float c;", "program/final.fsh:10".into()),
]
);
}
/// A stray `#endif` and an `#if` left open are warnings, as in Iris 1.11.7 (which draws such
/// a program): the `#endif` is ignored, the open `#if` reads on to the end of the file.
#[test]
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Hugeicons
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M21 11.002V12.4981C21 15.3265 21 16.7407 20.1213 17.6194C19.2426 18.4981 17.8284 18.4981 15 18.4981H11L7.20567 21.8181C7.07308 21.9341 6.90289 21.9981 6.72671 21.9981C6.32501 21.9981 5.99937 21.6724 5.99937 21.2707V18.4981C5.06999 18.4981 4.6053 18.4981 4.22402 18.396C3.18836 18.1187 2.37942 17.3097 2.10209 16.2741C2 15.8928 2 15.4281 2 14.4987V9.00195C2 6.17353 2 4.75931 2.87868 3.88063C3.75736 3.00195 5.17157 3.00195 8 3.00195H13" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M19 3.12695V5.00195M19 5.00195V6.87695M19 5.00195H17.5M19 5.00195H20.5M22 5.00195L20.6987 4.56818C20.1015 4.36911 19.6328 3.90048 19.4338 3.30327L19 2.00195L18.5662 3.30327C18.3672 3.90048 17.8985 4.36911 17.3013 4.56818L16 5.00195L17.3013 5.43573C17.8985 5.6348 18.3672 6.10343 18.5662 6.70064L19 8.00195L19.4338 6.70064C19.6328 6.10343 20.1015 5.6348 20.6987 5.43573L22 5.00195Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M18 9.00005C18 9.00005 13.5811 15 12 15C10.4188 15 6 9 6 9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 260 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M9.00005 6C9.00005 6 15 10.4189 15 12C15 13.5812 9 18 9 18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 260 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M18.9991 19L5 5.00098" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M19 5L5.00092 18.999" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 343 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M13.435 7L7.15915 13.2759M7.15915 13.2759L4.82728 15.6077C3.92569 16.5093 3.47489 16.9601 3.23745 17.5334C3 18.1066 3 18.7441 3 20.0192V21H3.98082C5.25586 21 5.89338 21 6.46663 20.7626C7.03988 20.5251 7.49068 20.0743 8.39227 19.1727L14.2891 13.2759M7.15915 13.2759H14.2891M14.2891 13.2759L17 10.565" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M19.2087 8.38869L20.82 10M19.2087 8.38869L20.0705 7.52682C20.363 7.23431 20.5093 7.08805 20.611 6.94529C21.1297 6.21676 21.1297 5.23953 20.611 4.511C20.5093 4.36824 20.363 4.22198 20.0705 3.92947C19.778 3.63697 19.6318 3.4907 19.489 3.38905C18.7605 2.87032 17.7832 2.87032 17.0547 3.38905C16.912 3.4907 16.7657 3.63695 16.4732 3.92947L15.6113 4.79133M19.2087 8.38869L15.6113 4.79133M14 3.18002L15.6113 4.79133" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 1009 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M7.5 7.5L8.72654 8.55719C9.24218 9.00163 9.5 9.22386 9.5 9.5C9.5 9.77614 9.24218 9.99836 8.72654 10.4428L7.5 11.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M11.5 12.5H15.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M12 21C15.7497 21 17.6246 21 18.9389 20.0451C19.3634 19.7367 19.7367 19.3634 20.0451 18.9389C21 17.6246 21 15.7497 21 12C21 8.25027 21 6.3754 20.0451 5.06107C19.7367 4.6366 19.3634 4.26331 18.9389 3.95491C17.6246 3 15.7497 3 12 3C8.25027 3 6.3754 3 5.06107 3.95491C4.6366 4.26331 4.26331 4.6366 3.95491 5.06107C3 6.3754 3 8.25027 3 12C3 15.7497 3 17.6246 3.95491 18.9389C4.26331 19.3634 4.6366 19.7367 5.06107 20.0451C6.3754 21 8.25027 21 12 21Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 975 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M14 21H16M14 21C13.1716 21 12.5 20.3284 12.5 19.5V17L12 17M14 21H10M10 21H8M10 21C10.8284 21 11.5 20.3284 11.5 19.5V17L12 17M12 17V21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M16 3H8C5.17157 3 3.75736 3 2.87868 3.87868C2 4.75736 2 6.17157 2 9V11C2 13.8284 2 15.2426 2.87868 16.1213C3.75736 17 5.17157 17 8 17H16C18.8284 17 20.2426 17 21.1213 16.1213C22 15.2426 22 13.8284 22 11V9C22 6.17157 22 4.75736 21.1213 3.87868C20.2426 3 18.8284 3 16 3Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 703 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M7.5 14.5C7.5 11.2002 7.5 9.55025 8.52513 8.52513C9.55025 7.5 11.2002 7.5 14.5 7.5C17.7998 7.5 19.4497 7.5 20.4749 8.52513C21.5 9.55025 21.5 11.2002 21.5 14.5C21.5 17.7998 21.5 19.4497 20.4749 20.4749C19.4497 21.5 17.7998 21.5 14.5 21.5C11.2002 21.5 9.55025 21.5 8.52513 20.4749C7.5 19.4497 7.5 17.7998 7.5 14.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M7.5 16.5C6.10355 16.5 5.40533 16.5 4.84402 16.3036C3.83866 15.9518 3.0482 15.1613 2.69641 14.156C2.5 13.5947 2.5 12.8964 2.5 11.5V9.5C2.5 6.20017 2.5 4.55025 3.52513 3.52513C4.55025 2.5 6.20017 2.5 9.5 2.5H11.5C12.8964 2.5 13.5947 2.5 14.156 2.69641C15.1613 3.0482 15.9518 3.83866 16.3036 4.84402C16.5 5.40533 16.5 6.10355 16.5 7.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 946 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M13.6903 19.4567C13.5 18.9973 13.5 18.4149 13.5 17.25C13.5 16.0851 13.5 15.5027 13.6903 15.0433C13.944 14.4307 14.4307 13.944 15.0433 13.6903C15.5027 13.5 16.0851 13.5 17.25 13.5C18.4149 13.5 18.9973 13.5 19.4567 13.6903C20.0693 13.944 20.556 14.4307 20.8097 15.0433C21 15.5027 21 16.0851 21 17.25C21 18.4149 21 18.9973 20.8097 19.4567C20.556 20.0693 20.0693 20.556 19.4567 20.8097C18.9973 21 18.4149 21 17.25 21C16.0851 21 15.5027 21 15.0433 20.8097C14.4307 20.556 13.944 20.0693 13.6903 19.4567Z" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.6"/><path d="M13.6903 8.95671C13.5 8.49728 13.5 7.91485 13.5 6.75C13.5 5.58515 13.5 5.00272 13.6903 4.54329C13.944 3.93072 14.4307 3.44404 15.0433 3.1903C15.5027 3 16.0851 3 17.25 3C18.4149 3 18.9973 3 19.4567 3.1903C20.0693 3.44404 20.556 3.93072 20.8097 4.54329C21 5.00272 21 5.58515 21 6.75C21 7.91485 21 8.49728 20.8097 8.95671C20.556 9.56928 20.0693 10.056 19.4567 10.3097C18.9973 10.5 18.4149 10.5 17.25 10.5C16.0851 10.5 15.5027 10.5 15.0433 10.3097C14.4307 10.056 13.944 9.56928 13.6903 8.95671Z" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.6"/><path d="M3.1903 19.4567C3 18.9973 3 18.4149 3 17.25C3 16.0851 3 15.5027 3.1903 15.0433C3.44404 14.4307 3.93072 13.944 4.54329 13.6903C5.00272 13.5 5.58515 13.5 6.75 13.5C7.91485 13.5 8.49728 13.5 8.95671 13.6903C9.56928 13.944 10.056 14.4307 10.3097 15.0433C10.5 15.5027 10.5 16.0851 10.5 17.25C10.5 18.4149 10.5 18.9973 10.3097 19.4567C10.056 20.0693 9.56928 20.556 8.95671 20.8097C8.49728 21 7.91485 21 6.75 21C5.58515 21 5.00272 21 4.54329 20.8097C3.93072 20.556 3.44404 20.0693 3.1903 19.4567Z" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.6"/><path d="M3.1903 8.95671C3 8.49728 3 7.91485 3 6.75C3 5.58515 3 5.00272 3.1903 4.54329C3.44404 3.93072 3.93072 3.44404 4.54329 3.1903C5.00272 3 5.58515 3 6.75 3C7.91485 3 8.49728 3 8.95671 3.1903C9.56928 3.44404 10.056 3.93072 10.3097 4.54329C10.5 5.00272 10.5 5.58515 10.5 6.75C10.5 7.91485 10.5 8.49728 10.3097 8.95671C10.056 9.56928 9.56928 10.056 8.95671 10.3097C8.49728 10.5 7.91485 10.5 6.75 10.5C5.58515 10.5 5.00272 10.5 4.54329 10.3097C3.93072 10.056 3.44404 9.56928 3.1903 8.95671Z" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M18.8516 16.3743L19.5 6H4.5L5.14839 16.3743C5.315 19.0401 5.39831 20.373 6.26426 21.1865C7.13021 22 8.46571 22 11.1367 22H12.8633C15.5343 22 16.8698 22 17.7357 21.1865C18.6017 20.373 18.685 19.0401 18.8516 16.3743Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M16 6H21M16 6L15.3778 4.34082C15.0007 3.33524 14.8122 2.83245 14.4402 2.50292C14.3213 2.39765 14.1904 2.3069 14.0501 2.2326C13.6109 2 13.074 2 12 2C10.926 2 10.3891 2 9.9499 2.2326C9.8096 2.3069 9.67865 2.39765 9.55982 2.50292C9.18783 2.83245 8.99928 3.33524 8.62219 4.34082L8 6M16 6H8M3 6H8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M9.5 11V17M14.5 11V17" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 928 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M16.9504 12.1817C17.1981 12.814 16.5076 13.5726 15.1267 15.0899C13.6702 16.6902 12.9201 17.4904 12 17.5C11.0799 17.4904 10.3298 16.6902 8.87331 15.0899C7.49239 13.5726 6.80193 12.814 7.04964 12.1817C7.05868 12.1586 7.06851 12.1359 7.0791 12.1135C7.34928 11.542 8.24477 11.5029 10 11.5002V4.99998C10 4.53501 10 4.30253 10.0511 4.11179C10.1898 3.59414 10.5941 3.1898 11.1118 3.05111C11.3025 3 11.535 3 12 3C12.4649 3 12.6974 3 12.8882 3.05111C13.4058 3.1898 13.8102 3.59414 13.9489 4.11179C14 4.30253 14 4.53501 14 4.99998V11.5002C15.7552 11.5029 16.6507 11.542 16.9209 12.1135C16.9315 12.1359 16.9413 12.1586 16.9504 12.1817Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M5.00006 21H19.0001" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 945 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M12 22C6.47715 22 2 17.5228 2 12C2 9.20746 3.14465 6.68227 4.99037 4.86802M12 22C11.037 21.2864 11.1907 20.4555 11.6738 19.6247C12.4166 18.3474 12.4166 18.3474 12.4166 16.6444C12.4166 14.9414 13.4286 14.1429 17 14.8571C18.6047 15.1781 19.7741 12.9609 21.8573 13.693M12 22C16.9458 22 21.053 18.4096 21.8573 13.693M21.8573 13.693C21.9511 13.1427 22 12.5771 22 12C22 7.11857 18.5024 3.05405 13.8766 2.17579M13.8766 2.17579C14.3872 3.11599 14.1816 4.23551 13.1027 4.66298C11.3429 5.3603 12.6029 6.64343 11.1035 7.4356C10.1038 7.96372 8.6044 7.83152 7.10496 6.24716C6.31517 5.41264 5.83966 4.95765 4.99037 4.86802M13.8766 2.17579C13.2687 2.06039 12.6414 2 12 2C9.26969 2 6.79495 3.09421 4.99037 4.86802" stroke="currentColor" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 876 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M2 8.69444C2 13.0348 7.58942 17.858 10.4107 19.9677C11.3601 20.6776 12.6399 20.6776 13.5893 19.9677C16.4106 17.858 22 13.0348 22 8.69444C22 5.82563 19.8947 3.5 17 3.5C15.5 3.5 14 4 12 6C10 4 8.5 3.5 7 3.5C4.10526 3.5 2 5.82563 2 8.69444Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 439 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M13.6656 6.49446H16C18.8284 6.49446 20.2426 6.49446 21.1213 7.37314C22 8.25182 22 9.66603 22 12.4945V14.5C22 17.3284 22 18.7426 21.1213 19.6213C20.2426 20.5 18.8284 20.5 16 20.5H8C5.17157 20.5 3.75736 20.5 2.87868 19.6213C2 18.7426 2 17.3284 2 14.5V8.99183C2 6.65409 2 5.48521 2.61604 4.67881C2.7778 4.46706 2.96706 4.2778 3.17881 4.11604C3.98521 3.5 5.15409 3.5 7.49183 3.5C8.16633 3.5 8.50358 3.5 8.81392 3.60442C8.8976 3.63257 8.97932 3.66623 9.05854 3.70518C9.35239 3.84965 9.59179 4.08719 10.0706 4.56227L10.8483 5.33389C11.4254 5.9066 11.714 6.19295 12.08 6.3437C12.446 6.49446 12.8525 6.49446 13.6656 6.49446Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 818 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M11 13H13" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M12 9V3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M20.1526 20.9174C22.0143 20.3289 22.5597 16.7547 21.3708 12.9344C20.1819 9.11398 17.709 6.49405 15.8474 7.08259C14.6972 7.44619 14.6438 8.86174 13.6968 9.378C13.473 9.5 13.1595 9.5 12.5324 9.5H11.4676C10.8405 9.5 10.527 9.5 10.3032 9.378C9.35625 8.86174 9.30278 7.44619 8.15264 7.08259C6.29102 6.49405 3.8181 9.11398 2.62922 12.9344C1.44035 16.7547 1.98573 20.3289 3.84736 20.9174C5.20631 21.347 6.891 20.067 8.16417 17.8583C9.01333 16.3851 9.39787 16 11.1039 16H12.8961C14.6021 16 14.9867 16.3851 15.8358 17.8583C17.109 20.067 18.7937 21.347 20.1526 20.9174Z" stroke="currentColor" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 930 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.6"/><path d="M8 12C8 18 12 22 12 22C12 22 16 18 16 12C16 6 12 2 12 2C12 2 8 6 8 12Z" stroke="currentColor" stroke-linejoin="round" stroke-width="1.6"/><path d="M21 15H3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M21 9H3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 537 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><circle cx="7.5" cy="7.5" r="1.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M2.5 12C2.5 7.52166 2.5 5.28249 3.89124 3.89124C5.28249 2.5 7.52166 2.5 12 2.5C16.4783 2.5 18.7175 2.5 20.1088 3.89124C21.5 5.28249 21.5 7.52166 21.5 12C21.5 16.4783 21.5 18.7175 20.1088 20.1088C18.7175 21.5 16.4783 21.5 12 21.5C7.52166 21.5 5.28249 21.5 3.89124 20.1088C2.5 18.7175 2.5 16.4783 2.5 12Z" stroke="currentColor" stroke-width="1.6"/><path d="M5 21C9.37246 15.775 14.2741 8.88406 21.4975 13.5424" stroke="currentColor" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 685 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M12 16V12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M12.125 8.25H12M12.25 8.25C12.25 8.11193 12.1381 8 12 8C11.8619 8 11.75 8.11193 11.75 8.25C11.75 8.38807 11.8619 8.5 12 8.5C12.1381 8.5 12.25 8.38807 12.25 8.25Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 592 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M9.14339 10.691L9.35031 10.4841C11.329 8.50532 14.5372 8.50532 16.5159 10.4841C18.4947 12.4628 18.4947 15.671 16.5159 17.6497L13.6497 20.5159C11.671 22.4947 8.46279 22.4947 6.48405 20.5159C4.50532 18.5372 4.50532 15.329 6.48405 13.3503L6.9484 12.886" stroke="currentColor" stroke-linecap="round" stroke-width="1.6"/><path d="M17.0516 11.114L17.5159 10.6497C19.4947 8.67095 19.4947 5.46279 17.5159 3.48405C15.5372 1.50532 12.329 1.50532 10.3503 3.48405L7.48405 6.35031C5.50532 8.32904 5.50532 11.5372 7.48405 13.5159C9.46279 15.4947 12.671 15.4947 14.6497 13.5159L14.8566 13.309" stroke="currentColor" stroke-linecap="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 755 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M20 12L4 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 213 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M6.00449 12.5V12M18.0045 12.5V12M12.0045 12.5V12M7.00449 12.5C7.00449 11.9477 6.55677 11.5 6.00449 11.5C5.4522 11.5 5.00449 11.9477 5.00449 12.5C5.00449 13.0523 5.4522 13.5 6.00449 13.5C6.55677 13.5 7.00449 13.0523 7.00449 12.5ZM19.0045 12.5C19.0045 11.9477 18.5568 11.5 18.0045 11.5C17.4522 11.5 17.0045 11.9477 17.0045 12.5C17.0045 13.0523 17.4522 13.5 18.0045 13.5C18.5568 13.5 19.0045 13.0523 19.0045 12.5ZM13.0045 12.5C13.0045 11.9477 12.5568 11.5 12.0045 11.5C11.4522 11.5 11.0045 11.9477 11.0045 12.5C11.0045 13.0523 11.4522 13.5 12.0045 13.5C12.5568 13.5 13.0045 13.0523 13.0045 12.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 794 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M11.9967 12.5V12M11.9967 6.5V6M11.9967 18.5V18M12.9967 12.5C12.9967 11.9477 12.549 11.5 11.9967 11.5C11.4444 11.5 10.9967 11.9477 10.9967 12.5C10.9967 13.0523 11.4444 13.5 11.9967 13.5C12.549 13.5 12.9967 13.0523 12.9967 12.5ZM12.9967 6.5C12.9967 5.94772 12.549 5.5 11.9967 5.5C11.4444 5.5 10.9967 5.94772 10.9967 6.5C10.9967 7.05228 11.4444 7.5 11.9967 7.5C12.549 7.5 12.9967 7.05228 12.9967 6.5ZM12.9967 18.5C12.9967 17.9477 12.549 17.5 11.9967 17.5C11.4444 17.5 10.9967 17.9477 10.9967 18.5C10.9967 19.0523 11.4444 19.5 11.9967 19.5C12.549 19.5 12.9967 19.0523 12.9967 18.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 779 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M2.5 7.5V13.5C2.5 17.2712 2.5 19.1569 3.67157 20.3284C4.84315 21.5 6.72876 21.5 10.5 21.5H13.5C17.2712 21.5 19.1569 21.5 20.3284 20.3284C21.5 19.1569 21.5 17.2712 21.5 13.5V7.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M3.86909 5.31461L2.5 7.5H21.5L20.2478 5.41303C19.3941 3.99021 18.9673 3.2788 18.2795 2.8894C17.5918 2.5 16.7621 2.5 15.1029 2.5H8.95371C7.32998 2.5 6.51812 2.5 5.84013 2.8753C5.16215 3.2506 4.73113 3.93861 3.86909 5.31461Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M12 7.5V2.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M10 10.5H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 922 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M16.4249 4.60509L17.4149 3.6151C18.2351 2.79497 19.5648 2.79497 20.3849 3.6151C21.205 4.43524 21.205 5.76493 20.3849 6.58507L19.3949 7.57506M16.4249 4.60509L9.76558 11.2644C9.25807 11.772 8.89804 12.4078 8.72397 13.1041L8 16L10.8959 15.276C11.5922 15.102 12.228 14.7419 12.7356 14.2344L19.3949 7.57506M16.4249 4.60509L19.3949 7.57506" stroke="currentColor" stroke-linejoin="round" stroke-width="1.6"/><path d="M18.9999 13.5C18.9999 16.7875 18.9999 18.4312 18.092 19.5376C17.9258 19.7401 17.7401 19.9258 17.5375 20.092C16.4312 21 14.7874 21 11.4999 21H11C7.22876 21 5.34316 21 4.17159 19.8284C3.00003 18.6569 3 16.7712 3 13V12.5C3 9.21252 3 7.56879 3.90794 6.46244C4.07417 6.2599 4.2599 6.07417 4.46244 5.90794C5.56879 5 7.21252 5 10.5 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 938 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M18.8906 12.846C18.5371 14.189 16.8667 15.138 13.5257 17.0361C10.296 18.8709 8.6812 19.7884 7.37983 19.4196C6.8418 19.2671 6.35159 18.9776 5.95624 18.5787C5 17.6139 5 15.7426 5 12C5 8.2574 5 6.3861 5.95624 5.42132C6.35159 5.02245 6.8418 4.73288 7.37983 4.58042C8.6812 4.21165 10.296 5.12907 13.5257 6.96393C16.8667 8.86197 18.5371 9.811 18.8906 11.154C19.0365 11.7084 19.0365 12.2916 18.8906 12.846Z" stroke="currentColor" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 578 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M12 4V20M20 12H4" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 218 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M12.828 6.00096C12.9388 5.68791 12.999 5.35099 12.999 5C12.999 3.34315 11.6559 2 9.99904 2C8.34219 2 6.99904 3.34315 6.99904 5C6.99904 5.35099 7.05932 5.68791 7.17008 6.00096C4.88532 6.0093 3.66601 6.09039 2.87772 6.87868C2.08951 7.66689 2.00836 8.88603 2 11.1704C2.31251 11.06 2.64876 11 2.99904 11C4.6559 11 5.99904 12.3431 5.99904 14C5.99904 15.6569 4.6559 17 2.99904 17C2.64876 17 2.31251 16.94 2 16.8296C2.00836 19.114 2.08951 20.3331 2.87772 21.1213C3.66593 21.9095 4.88508 21.9907 7.16941 21.999C7.05908 21.6865 6.99904 21.3503 6.99904 21C6.99904 19.3431 8.34219 18 9.99904 18C11.6559 18 12.999 19.3431 12.999 21C12.999 21.3503 12.939 21.6865 12.8287 21.999C15.113 21.9907 16.3322 21.9095 17.1204 21.1213C17.9086 20.333 17.9897 19.1137 17.9981 16.829C18.3111 16.9397 18.648 17 18.999 17C20.6559 17 21.999 15.6569 21.999 14C21.999 12.3431 20.6559 11 18.999 11C18.648 11 18.3111 11.0603 17.9981 11.171C17.9897 8.88627 17.9086 7.66697 17.1204 6.87868C16.3321 6.09039 15.1128 6.0093 12.828 6.00096Z" stroke="currentColor" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M20.0092 2V5.13219C20.0092 5.42605 19.6418 5.55908 19.4537 5.33333C17.6226 3.2875 14.9617 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 378 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M17 17L21 21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11C3 15.4183 6.58172 19 11 19C15.4183 19 19 15.4183 19 11Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 427 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M2.99982 12.0002H10.9998" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M13.9998 5.00015L20.9998 5.00015" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M11.9998 19.0002L20.9998 19.0002" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M2.99982 5.00015H9.99982M9.99982 5.00015V3.00015M9.99982 5.00015V7.00015" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M2.99982 19.0002H7.99982M7.99982 19.0002V17.0002M7.99982 19.0002V21.0002" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M14.9998 12.0002L20.9998 12.0002M14.9998 12.0002V10.0002M14.9998 12.0002V14.0002" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 1014 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M4 12C4 8.72077 4 7.08116 4.81382 5.91891C5.1149 5.48891 5.48891 5.1149 5.91891 4.81382C7.08116 4 8.72077 4 12 4C15.2792 4 16.9188 4 18.0811 4.81382C18.5111 5.1149 18.8851 5.48891 19.1862 5.91891C20 7.08116 20 8.72077 20 12C20 15.2792 20 16.9188 19.1862 18.0811C18.8851 18.5111 18.5111 18.8851 18.0811 19.1862C16.9188 20 15.2792 20 12 20C8.72077 20 7.08116 20 5.91891 19.1862C5.48891 18.8851 5.1149 18.5111 4.81382 18.0811C4 16.9188 4 15.2792 4 12Z" stroke="currentColor" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 603 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M5 14L8.5 17.5L19 6.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 223 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M5.25 21H18.75C18.9822 21 19.0983 21 19.196 20.9904C20.1456 20.8968 20.8968 20.1456 20.9904 19.196C21 19.0983 21 18.9822 21 18.75C21 18.5178 21 18.4017 20.9904 18.304C20.8968 17.3544 20.1456 16.6032 19.196 16.5096C19.0983 16.5 18.9822 16.5 18.75 16.5H17.0607C16.8324 16.5 16.7182 16.5 16.6087 16.5121C16.2317 16.5538 15.8742 16.7018 15.5781 16.939C15.4921 17.0079 15.4114 17.0886 15.25 17.25C15.0886 17.4114 15.0079 17.4921 14.9219 17.561C14.6258 17.7982 14.2683 17.9462 13.8913 17.9879C13.7818 18 13.6676 18 13.4393 18H10.5607C10.3324 18 10.2182 18 10.1087 17.9879C9.73165 17.9462 9.37423 17.7982 9.07814 17.561C8.99213 17.4921 8.91142 17.4114 8.75 17.25C8.58858 17.0886 8.50787 17.0079 8.42186 16.939C8.12577 16.7018 7.76835 16.5538 7.3913 16.5121C7.28177 16.5 7.16763 16.5 6.93934 16.5H5.25C5.01783 16.5 4.90175 16.5 4.80397 16.5096C3.85441 16.6032 3.10315 17.3544 3.00963 18.304C3 18.4017 3 18.5178 3 18.75C3 18.9822 3 19.0983 3.00963 19.196C3.10315 20.1456 3.85441 20.8968 4.80397 20.9904C4.90175 21 5.01783 21 5.25 21Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M16.5 7.49996C16.5 7.49996 13.1858 3.00001 12 3C10.8141 2.99999 7.5 7.5 7.5 7.5M12 4V15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M3 20.5002C3.28417 16.8058 6.3 13.7193 10.0008 13.5379C10.3134 13.5226 10.6446 13.5097 11 13.5L11.995 13.5663C12.6939 13.6129 13.3665 13.7543 14 13.9777" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M18 15.5V21.5M21 18.5L15 18.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><circle cx="11" cy="6.5" r="4" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 603 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M18.5 7C18.5 9.20914 16.7091 11 14.5 11C12.2909 11 10.5 9.20914 10.5 7C10.5 4.79086 12.2909 3 14.5 3C16.7091 3 18.5 4.79086 18.5 7Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M8 11C6.34315 11 5 9.65685 5 8C5 6.34315 6.34315 5 8 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M14.5 14C9.8125 14 7 16.5 7 19C7 20.1046 7.83947 21 8.875 21H20.125C21.1605 21 22 20.1046 22 19C22 16.5 19.1875 14 14.5 14Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M4 19.9992H3.5C2.67157 19.9992 2 19.2317 2 18.2849C2 16.673 3.27307 15.0612 5.5 14.3613" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 897 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M20 21.0001C19.713 17.269 16.7289 14.3151 12.995 14.0662L12 13.9999C11.6446 14.0096 11.3134 14.0225 11.0008 14.0378C7.3 14.2192 4.28417 17.3057 4 21.0001" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><circle cx="12" cy="6.99988" r="4" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 479 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M19.439 15.439C20.3636 14.5212 21.0775 13.6091 21.544 12.955C21.848 12.5287 22 12.3155 22 12C22 11.6845 21.848 11.4713 21.544 11.045C20.1779 9.12944 16.6892 5 12 5C11.0922 5 10.2294 5.15476 9.41827 5.41827M6.74742 6.74742C4.73118 8.1072 3.24215 9.94266 2.45604 11.045C2.15201 11.4713 2 11.6845 2 12C2 12.3155 2.15201 12.5287 2.45604 12.955C3.8221 14.8706 7.31078 19 12 19C13.9908 19 15.7651 18.2557 17.2526 17.2526" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M9.85786 10C9.32783 10.53 9 11.2623 9 12.0711C9 13.6887 10.3113 15 11.9289 15C12.7377 15 13.47 14.6722 14 14.1421" stroke="currentColor" stroke-linecap="round" stroke-width="1.6"/><path d="M3 3L21 21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 915 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M21.544 11.045C21.848 11.4713 22 11.6845 22 12C22 12.3155 21.848 12.5287 21.544 12.955C20.1779 14.8706 16.6892 19 12 19C7.31078 19 3.8221 14.8706 2.45604 12.955C2.15201 12.5287 2 12.3155 2 12C2 11.6845 2.15201 11.4713 2.45604 11.045C3.8221 9.12944 7.31078 5 12 5C16.6892 5 20.1779 9.12944 21.544 11.045Z" stroke="currentColor" stroke-width="1.6"/><path d="M15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12Z" stroke="currentColor" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 624 B

+280
View File
@@ -0,0 +1,280 @@
//! The launcher's glass look ("Glass" in `docs/design-system.md`), from Anthony S.'s mockup
//! (horizon-site `app/launcher/launcher.css` and `components/launcher/launcher-ui.tsx`): frosted
//! plates over the pixel sunset, white text at a few opacities, one accent. Geist sets the text,
//! Bricolage Grotesque the headings (SemiBold, at the optical size a browser picks for each).
//!
//! The mockup derives its colours with CSS `color-mix`: [`Glass::new`] does the same sums, so a
//! player's own accent comes out as it does on the website.
/// The mockup's defaults: accent, how much white the plates take, and their blur in px.
pub const ACCENT: u32 = 0x6b4bff;
pub const TINT: f32 = 0.06;
pub const BLUR: f32 = 24.;
/// Behind the scene before it is drawn.
pub const PAGE: u32 = 0x0a0919;
/// The dark text on a light accent, and on the white buttons.
pub const ON_LIGHT: u32 = 0x100a1f;
/// A running game or server, and a ready instance.
pub const LIVE: u32 = 0x4ade80;
/// Busy (pulsing) and partly-working states.
pub const WARN: u32 = 0xf2c46b;
/// Destructive menu rows (`#ff8a8a`, hovered over `#ff6b6b` at 12 %).
pub const DANGER: u32 = 0xff8a8a;
pub const DANGER_HOVER: u32 = 0xff6b6b;
/// The Discord button and its hover.
pub const DISCORD: u32 = 0x5865f2;
pub const DISCORD_HOVER: u32 = 0x4752c4;
/// The scrim under dialogs and the palette: this colour at 50 %.
pub const SCRIM: u32 = 0x07050f;
/// Text families: Geist for everything, Bricolage for page, instance and dialog names.
pub const FONT: &str = crate::FONT;
pub const DISPLAY_FONT: &str = "Bricolage Grotesque Display";
pub const HEADING_FONT: &str = "Bricolage Grotesque Heading";
/// Corners, in px. No step anywhere: everything is round.
pub mod radius {
/// Dialogs.
pub const MODAL: f32 = 28.;
/// Panels (the sidebar, cards, the tabbed panel).
pub const PANEL: f32 = 20.;
/// Menus and popovers; choice cards in dialogs.
pub const POPOVER: f32 = 16.;
/// The Play group, the big icon buttons, the top bar's tab strip.
pub const LARGE: f32 = 14.;
/// Rows, chips, fields in dialogs.
pub const ROW: f32 = 12.;
/// Buttons, tabs, small fields, menu rows.
pub const CONTROL: f32 = 10.;
/// The sidebar rows' ⋯ button.
pub const SMALL: f32 = 9.;
}
/// How opaque white text is in each role (the mockup's `text-white/NN`).
pub mod ink {
pub const FULL: f32 = 1.;
pub const STRONG: f32 = 0.8;
pub const SOFT: f32 = 0.75;
pub const BODY: f32 = 0.7;
pub const MUTED: f32 = 0.65;
pub const NOTE: f32 = 0.6;
pub const QUIET: f32 = 0.55;
pub const FAINT: f32 = 0.5;
pub const DIM: f32 = 0.45;
pub const PLACEHOLDER: f32 = 0.4;
pub const OFF: f32 = 0.35;
}
/// How opaque white fills are (`bg-white/[0.NN]`): rows hovered, selected, buttons, fields.
pub mod fill {
pub const HOVER: f32 = 0.05;
pub const ROW_HOVER: f32 = 0.06;
pub const FIELD: f32 = 0.07;
pub const BUTTON: f32 = 0.08;
pub const SELECTED: f32 = 0.1;
pub const FIELD_FOCUS: f32 = 0.11;
pub const ACTIVE: f32 = 0.12;
pub const BUTTON_HOVER: f32 = 0.14;
}
/// The look's colours for one accent and glass setting.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Glass {
pub accent: u32,
/// The accent mixed half with white: progress bars, ticks, the focus ring, links.
pub accent_soft: u32,
/// A dark violet from the accent: what the plates are tinted with.
pub base: u32,
/// Text on the accent: white, or [`ON_LIGHT`] on a light accent.
pub on_accent: u32,
/// A plate's fill and its opacity.
pub plate: u32,
pub plate_alpha: f32,
pub tint: f32,
pub blur: f32,
}
impl Glass {
/// The mockup's sums: `--accent-soft: color-mix(in oklab, accent 52%, white)`,
/// `--base: color-mix(in oklab, accent 10%, #14111d)`, and `.hzl-glass`'s background
/// `color-mix(in srgb, white tint, color-mix(in oklab, base 22%, transparent))`.
pub fn new(accent: u32, tint: f32, blur: f32) -> Glass {
let base = mix_oklab(accent, 0x14111d, 0.10);
// White at `tint` over the base at 22 %, mixed with premultiplied alpha as CSS does.
let base_alpha = 0.22;
let alpha = tint + (1. - tint) * base_alpha;
let plate = channels(|s| {
let premul = tint + (1. - tint) * base_alpha * (((base >> s) & 255) as f32 / 255.);
premul / alpha
});
Glass {
accent,
accent_soft: mix_oklab(accent, 0xffffff, 0.52),
base,
on_accent: if luminance(accent) > 0.4 {
ON_LIGHT
} else {
0xffffff
},
plate,
plate_alpha: alpha,
tint,
blur,
}
}
/// The mockup's defaults.
pub fn default_look() -> Glass {
Glass::new(ACCENT, TINT, BLUR)
}
/// Every colour by its CSS name (`--glass-<name>`), for `tokens.css`.
pub fn css(&self) -> String {
let hex = |c: u32| format!("#{c:06x}");
[
("accent", hex(self.accent)),
("accent-soft", hex(self.accent_soft)),
("base", hex(self.base)),
("on-accent", hex(self.on_accent)),
("page", hex(PAGE)),
("live", hex(LIVE)),
("warn", hex(WARN)),
("danger", hex(DANGER)),
(
"plate",
format!(
"rgba({}, {}, {}, {:.3})",
self.plate >> 16 & 255,
self.plate >> 8 & 255,
self.plate & 255,
self.plate_alpha
),
),
("blur", format!("{}px", self.blur)),
]
.into_iter()
.map(|(name, value)| format!(" --glass-{name}: {value};\n"))
.collect()
}
}
/// Builds a colour from a function of each channel's shift (16, 8, 0) giving 0..=1.
fn channels(f: impl Fn(u32) -> f32) -> u32 {
[16, 8, 0].iter().fold(0, |out, &s| {
out | ((f(s).clamp(0., 1.) * 255.).round() as u32) << s
})
}
fn to_linear(c: f32) -> f32 {
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
fn from_linear(c: f32) -> f32 {
if c <= 0.0031308 {
c * 12.92
} else {
1.055 * c.powf(1. / 2.4) - 0.055
}
}
/// sRGB to OKLab (Björn Ottosson's matrices, as CSS Color 4 uses).
// The matrices as CSS Color 4 publishes them, digit for digit.
#[allow(clippy::excessive_precision)]
pub fn oklab(c: u32) -> [f32; 3] {
let [r, g, b] = [16, 8, 0].map(|s| to_linear(((c >> s) & 255) as f32 / 255.));
let l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b).cbrt();
let m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b).cbrt();
let s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b).cbrt();
[
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
]
}
/// OKLab back to sRGB, clamped to the gamut.
// The matrices as CSS Color 4 publishes them, digit for digit.
#[allow(clippy::excessive_precision)]
pub fn from_oklab([l, a, b]: [f32; 3]) -> u32 {
let l_ = (l + 0.3963377774 * a + 0.2158037573 * b).powi(3);
let m_ = (l - 0.1055613458 * a - 0.0638541728 * b).powi(3);
let s_ = (l - 0.0894841775 * a - 1.2914855480 * b).powi(3);
let rgb = [
4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
-0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_,
];
channels(|s| from_linear(rgb[(2 - s / 8) as usize]))
}
/// CSS `color-mix(in oklab, a p, b)`: `p` of `a`, the rest `b`.
pub fn mix_oklab(a: u32, b: u32, p: f32) -> u32 {
let (x, y) = (oklab(a), oklab(b));
from_oklab([0, 1, 2].map(|i| x[i] * p + y[i] * (1. - p)))
}
/// WCAG relative luminance, as the mockup's `luminance` picks the label colour on the accent.
pub fn luminance(c: u32) -> f32 {
let [r, g, b] = [16, 8, 0].map(|s| {
let v = ((c >> s) & 255) as f32 / 255.;
if v <= 0.03928 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
});
0.2126 * r + 0.7152 * g + 0.0722 * b
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: u32, b: u32) -> bool {
[16, 8, 0]
.iter()
.all(|s| (((a >> s) & 255) as i32 - ((b >> s) & 255) as i32).abs() <= 1)
}
/// Chrome's own results for the mockup's `color-mix` sums (getComputedStyle, then a 1 px
/// canvas for the sRGB bytes): an independent check of the OKLab maths and the plate's mix.
#[test]
fn mixes_match_the_browser() {
let g = Glass::default_look();
let soft = oklab(g.accent_soft);
let mixed = [0, 1, 2].map(|i| oklab(ACCENT)[i] * 0.52 + oklab(0xffffff)[i] * 0.48);
for (i, want) in [0.772018, 0.0291158, -0.12693].into_iter().enumerate() {
assert!(
(mixed[i] - want).abs() < 1e-3,
"accent-soft oklab {mixed:?}"
);
}
// Chrome clips the out-of-gamut blue the same way.
assert!(
close(g.accent_soft, 0xa9a9ff),
"accent-soft {:06x} {soft:?}",
g.accent_soft
);
assert!(close(g.base, 0x1b1830), "base {:06x}", g.base);
// color(srgb 0.30732 0.296469 0.370862 / 0.2668)
assert!(close(g.plate, 0x4e4c5f), "plate {:06x}", g.plate);
assert!((g.plate_alpha - 0.2668).abs() < 1e-4);
}
#[test]
fn a_light_accent_takes_dark_labels() {
assert_eq!(Glass::new(0xf2c46b, TINT, BLUR).on_accent, ON_LIGHT);
assert_eq!(Glass::default_look().on_accent, 0xffffff);
}
#[test]
fn oklab_round_trips() {
for c in [0x000000, 0xffffff, 0x6b4bff, 0x14111d, 0x5865f2, 0xf89d62] {
assert!(close(from_oklab(oklab(c)), c), "{c:06x}");
}
}
}
+68
View File
@@ -12,6 +12,7 @@ use std::time::Duration;
pub const FONT: &str = "Geist";
pub const MONO: &str = "Geist Mono";
pub mod glass;
pub mod night;
/// The launcher's and the website's display face (headings, navigation, buttons, badges only):
@@ -35,6 +36,71 @@ pub mod fonts {
pub const BRICOLAGE_BOLD: &[u8] = include_bytes!("../fonts/BricolageGrotesque-Bold.ttf");
pub const BRICOLAGE_DISPLAY_BOLD: &[u8] =
include_bytes!("../fonts/BricolageGrotesqueDisplay-Bold.ttf");
/// The glass look's headings ([`crate::glass`]): SemiBold at optical sizes 56 and 28.
pub const BRICOLAGE_DISPLAY_SEMIBOLD: &[u8] =
include_bytes!("../fonts/BricolageGrotesqueDisplay-SemiBold.ttf");
pub const BRICOLAGE_HEADING_SEMIBOLD: &[u8] =
include_bytes!("../fonts/BricolageGrotesqueHeading-SemiBold.ttf");
}
/// The glass look's icons: Hugeicons' free set (MIT, `icons/glass/LICENSE-hugeicons.txt`), the
/// ones the mockup uses, 24 unit outlines stroked 1.6 in `currentColor`, by Hugeicons' name
/// (`"folder-01"`, `"more-vertical"`…).
pub mod glass_icons {
macro_rules! icons {
($($name:literal),* $(,)?) => {
/// Every icon, `(name, svg)`.
pub const ALL: &[(&str, &[u8])] = &[
$(($name, include_bytes!(concat!("../icons/glass/", $name, ".svg")))),*
];
};
}
icons!(
"ai-chat-02",
"arrow-down-01",
"arrow-right-01",
"cancel-01",
"color-picker",
"computer",
"computer-terminal-01",
"copy-01",
"dashboard-square-01",
"delete-02",
"discord",
"download-04",
"earth",
"favourite",
"folder-01",
"game-controller-01",
"globe-02",
"image-01",
"information-circle",
"link-01",
"minus-sign",
"more-horizontal",
"more-vertical",
"package-01",
"pencil-edit-02",
"play",
"plus-sign",
"puzzle",
"refresh",
"search-01",
"sliders-horizontal",
"stop",
"tick-02",
"upload-04",
"user",
"user-add-01",
"user-multiple-02",
"view",
"view-off-slash",
);
/// The icon called `name`, if there is one.
pub fn get(name: &str) -> Option<&'static [u8]> {
ALL.iter().find(|(n, _)| *n == name).map(|(_, svg)| *svg)
}
}
/// The launcher's pixel icons: Pixelarticons (MIT, `icons/pixel/LICENSE-pixelarticons.txt`), 24
@@ -560,6 +626,8 @@ pub fn css() -> String {
);
// The launcher's night look, for pages that wear it.
s.push_str(&if p.dark { night::NIGHT } else { night::DAWN }.css());
// The launcher's glass look, the same in both appearances.
s.push_str(&glass::Glass::default_look().css());
};
s.push_str(
"/* Generated from crates/horizon-theme by `cargo run -p xtask -- tokens`; do not edit.\n \
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more