Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 27 additions & 9 deletions crates/window-core/src/authoring/fullscreen.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use serde::Deserialize;

use super::element::{ElementDto, collect_handles, convert_children, intern_art};
use crate::ir::{FullscreenMode, Rgb};
use crate::ir::{Backdrop, FullscreenMode, Rgb};
use crate::model::{Art, Fullscreen};
use crate::{Error, Result};

Expand Down Expand Up @@ -35,14 +35,7 @@ impl FullscreenDto {
)));
}
};
let backdrop = self
.backdrop
.map(|color| {
Rgb::parse_hex(&color).ok_or_else(|| {
Error::Validation(format!("{owner} has invalid backdrop `{color}`; expected #rrggbb"))
})
})
.transpose()?;
let backdrop = self.backdrop.map(|color| parse_backdrop(&color, &owner)).transpose()?;
Ok(Fullscreen {
name: self.name,
mode,
Expand All @@ -53,3 +46,28 @@ impl FullscreenDto {
})
}
}

/// Parses `#rrggbb`, which hides the world, or `#rrggbbaa`, which dims it. The text shader drops fragments below 10%
/// opacity, so a dimming alpha starts at `1a`.
fn parse_backdrop(color: &str, owner: &str) -> Result<Backdrop> {
let invalid =
|| Error::Validation(format!("{owner} has invalid backdrop `{color}`; expected #rrggbb or #rrggbbaa"));
if !color.is_ascii() {
return Err(invalid());
}
let (rgb, alpha) = match color.len() {
7 => (color, 0xff),
9 => (&color[..7], u8::from_str_radix(&color[7..], 16).map_err(|_| invalid())?),
_ => return Err(invalid()),
};
let color_rgb = Rgb::parse_hex(rgb).ok_or_else(invalid)?;
if alpha < MIN_BACKDROP_ALPHA {
return Err(Error::Validation(format!(
"{owner} has backdrop `{color}` below the lowest alpha the text shader draws, `{MIN_BACKDROP_ALPHA:02x}`"
)));
}
Ok(Backdrop { color: color_rgb, alpha })
}

/// The lowest backdrop alpha whose fragments pass the text shader's 0.1 alpha cutoff.
const MIN_BACKDROP_ALPHA: u8 = 0x1a;
12 changes: 6 additions & 6 deletions crates/window-core/src/fullscreen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,9 @@ pub(crate) fn backdrop_texture() -> Texture {

/// The include: the contract's constants, the color table `colors`, the control rects `controls` in canvas pixels,
/// and the functions decoding markers and mapping rotation to the cursor.
pub(crate) fn include(colors: &[Rgb], controls: &[Rect], cursor_size: Size) -> String {
let rgb = |color: &Rgb| format!("vec3({}.0, {}.0, {}.0)", color.r, color.g, color.b);
let colors_glsl = colors.iter().map(rgb).collect::<Vec<_>>().join(", ");
pub(crate) fn include(colors: &[(Rgb, u8)], controls: &[Rect], cursor_size: Size) -> String {
let rgba = |(color, alpha): &(Rgb, u8)| format!("vec4({}.0, {}.0, {}.0, {alpha}.0)", color.r, color.g, color.b);
let colors_glsl = colors.iter().map(rgba).collect::<Vec<_>>().join(", ");
let rects: Vec<String> = controls
.iter()
.map(|rect| format!("vec4({}.0, {}.0, {}.0, {}.0)", rect.x, rect.y, rect.width, rect.height))
Expand Down Expand Up @@ -277,9 +277,9 @@ const vec2 WINDOW_FS_CURSOR_SIZE = vec2({cursor_width}.0, {cursor_height}.0);
// pixels.
const vec2 WINDOW_FS_HUD_STRIP = vec2({strip_half_width}.0, {strip_height}.0);

// Marker colors, 0-255 per channel; index 0 is white.
// Marker colors with their alpha, 0-255 per channel; index 0 is white. Only backdrops are translucent.
const int WINDOW_FS_COLOR_COUNT = {color_count};
const vec3 WINDOW_FS_COLORS[{color_count}] = vec3[]({colors_glsl});
const vec4 WINDOW_FS_COLORS[{color_count}] = vec4[]({colors_glsl});

// Every full-screen control rect (x, y, width, height) in canvas pixels, by the control's manifest index.
const int WINDOW_FS_CONTROL_COUNT = {control_count};
Expand All @@ -305,7 +305,7 @@ int window_fs_layer(int payload) {{
}}

// The color a marked glyph's texels are multiplied by.
vec3 window_fs_color(int payload) {{
vec4 window_fs_color(int payload) {{
return WINDOW_FS_COLORS[min(payload & 1023, WINDOW_FS_COLOR_COUNT - 1)] / 255.0;
}}

Expand Down
2 changes: 1 addition & 1 deletion crates/window-core/src/hud/renderer/fullscreen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ void window_fs_apply() {
gl_Position = vec4(ndc, window_fs_depth(window_fs_layer(payload)), 1.0);
sphericalVertexDistance = WINDOW_FS_NO_FOG_DISTANCE;
cylindricalVertexDistance = WINDOW_FS_NO_FOG_DISTANCE;
vertexColor = vec4(window_fs_color(payload), 1.0);
vertexColor = window_fs_color(payload);
texCoord0 = UV0;
#ifdef IS_GRAYSCALE
windowHudTextureMode = 0;
Expand Down
13 changes: 11 additions & 2 deletions crates/window-core/src/ir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,15 @@ impl Rgb {
}
}

/// What fills the whole screen behind a full-screen canvas, letterbox included.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Backdrop {
/// The fill color.
pub color: Rgb,
/// The fill's opacity: 255 hides the world, and anything lower dims it.
pub alpha: u8,
}

/// The runtime action closing the window. Action ids in the `window:` namespace are resolved by the runtime.
pub const CLOSE_ACTION: &str = "window:close";

Expand Down Expand Up @@ -631,8 +640,8 @@ pub struct LaidOutFullscreen {
pub name: String,
/// How the cursor follows the mouse.
pub mode: FullscreenMode,
/// The color filling the whole screen behind the canvas, if any.
pub backdrop: Option<Rgb>,
/// What fills the whole screen behind the canvas, if anything.
pub backdrop: Option<Backdrop>,
/// Static draws in paint order (back to front).
pub draws: Vec<Draw>,
/// Text regions (dynamic slots and static labels), document order.
Expand Down
6 changes: 3 additions & 3 deletions crates/window-core/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet};

use crate::geometry::{Insets, Point, Size};
use crate::inventory::{InventorySlotRef, SlotPattern};
use crate::ir::{Align, FullscreenMode, Handle, HudChannel, HudShader, Rgb};
use crate::ir::{Align, Backdrop, FullscreenMode, Handle, HudChannel, HudShader, Rgb};
use crate::surface::ContainerKind;

mod flex;
Expand Down Expand Up @@ -130,8 +130,8 @@ pub struct Fullscreen {
pub name: String,
/// How the cursor follows the mouse.
pub mode: FullscreenMode,
/// The color filling the whole screen behind the canvas, letterbox included; `None` shows the world.
pub backdrop: Option<Rgb>,
/// What fills the whole screen behind the canvas, letterbox included; `None` shows the world as is.
pub backdrop: Option<Backdrop>,
/// The root elements, laid out in the canvas rect. Inventory elements are rejected during layout.
pub children: Vec<Element>,
/// Typed handles by id; their elements carry the entry names of their uses.
Expand Down
20 changes: 15 additions & 5 deletions crates/window-core/src/pipeline/fullscreen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,28 +28,36 @@ use super::{CompileContext, OutputFile};
mod horse;

const WHITE: Rgb = Rgb::new(0xff, 0xff, 0xff);
const OPAQUE: u8 = 0xff;

/// The item model, under the pack namespace, that draws nothing: the runtime fills the hotbar with it while a
/// full-screen window is open, which also hides the hand.
const BLANK_ITEM: &str = "fullscreen/blank";

/// What the full-screen windows compiled so far put in the include: the color table and every control rect.
pub(super) struct Contract {
colors: Vec<Rgb>,
/// Colors with their alpha; only backdrops are translucent.
colors: Vec<(Rgb, u8)>,
controls: Vec<Rect>,
/// Whether a horse-mode window was compiled.
horse: bool,
}

impl Default for Contract {
fn default() -> Self {
Self { colors: vec![WHITE], controls: Vec::new(), horse: false }
Self { colors: vec![(WHITE, OPAQUE)], controls: Vec::new(), horse: false }
}
}

impl Contract {
/// The marker color of a `kind` run at `layer` drawn in `color`, adding `color` to the color table.
/// The marker color of a `kind` run at `layer` drawn in opaque `color`, adding `color` to the color table.
fn marker(&mut self, kind: MarkerKind, layer: u8, color: Rgb) -> Result<String> {
self.translucent_marker(kind, layer, color, OPAQUE)
}

/// Like [`Self::marker`], with the run drawn at `alpha`.
fn translucent_marker(&mut self, kind: MarkerKind, layer: u8, color: Rgb, alpha: u8) -> Result<String> {
let color = (color, alpha);
let index = match self.colors.iter().position(|known| *known == color) {
Some(index) => index,
None => {
Expand Down Expand Up @@ -183,8 +191,10 @@ pub(super) fn compile_fullscreen(
contract.controls.push(rect);
controls.insert(region.name.clone(), entry);
}
let backdrop_marker =
f.backdrop.map(|color| contract.marker(MarkerKind::Backdrop, BACKDROP_LAYER, color)).transpose()?;
let backdrop_marker = f
.backdrop
.map(|b| contract.translucent_marker(MarkerKind::Backdrop, BACKDROP_LAYER, b.color, b.alpha))
.transpose()?;

Ok(FullscreenEntry {
surface: FullscreenSurfaceEntry {
Expand Down
10 changes: 5 additions & 5 deletions crates/window-core/src/pipeline/preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::compose::{Composite, Texture, blit_sprite, multiply};
use crate::geometry::{Point, Rect, Size};
use crate::inventory::InventorySlotRef;
use crate::ir::{
Align, AnvilInputIr, ArtIr, CollectionIr, Draw, Handle, HandleKind, HandleRole, HandleUse, ItemIr,
Align, AnvilInputIr, ArtIr, Backdrop, CollectionIr, Draw, Handle, HandleKind, HandleRole, HandleUse, ItemIr,
LaidOutFullscreen, LaidOutHud, LaidOutWindow, Layer, RegionIr, Rgb, SlotIr, SpriteSlotIr, SwitchIr, bar_columns,
};
use crate::model::{GeneratedStyle, SpriteDef};
Expand Down Expand Up @@ -260,8 +260,8 @@ struct Pane<'a> {
label: String,
/// The surface rect in GUI space, or in canvas pixels for a full-screen window.
rect: Rect,
/// The color filling the whole image behind everything else.
backdrop: Option<Rgb>,
/// What fills the whole image behind everything else.
backdrop: Option<Backdrop>,
/// The shadow of slot text.
shadow: Shadow,
container: Option<ContainerKind>,
Expand Down Expand Up @@ -667,8 +667,8 @@ impl Scene<'_> {
placement.include_in(&mut bounds);
}
let mut canvas = Canvas::new(bounds.into_rect(&request.name, request.scale.unwrap_or(1))?);
if let Some(Rgb { r, g, b }) = pane.backdrop {
canvas.texture.rgba.as_chunks_mut::<4>().0.fill([r, g, b, 0xff]);
if let Some(Backdrop { color: Rgb { r, g, b }, alpha }) = pane.backdrop {
canvas.texture.rgba.as_chunks_mut::<4>().0.fill([r, g, b, alpha]);
}
let field = ContainerKind::ANVIL_TEXT_FIELD;
if pane.container == Some(ContainerKind::Anvil)
Expand Down
36 changes: 23 additions & 13 deletions crates/window-core/src/pipeline/tests/fullscreens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ fn include(out: &CompileOutput) -> &str {

/// The include's color table, as `r, g, b` strings.
fn color_table(include: &str) -> Vec<String> {
let line = include.lines().find(|line| line.starts_with("const vec3 WINDOW_FS_COLORS")).unwrap();
let list = line.split_once("vec3[](").unwrap().1.trim_end_matches(");");
list.split("vec3(")
let line = include.lines().find(|line| line.starts_with("const vec4 WINDOW_FS_COLORS")).unwrap();
let list = line.split_once("vec4[](").unwrap().1.trim_end_matches(");");
list.split("vec4(")
.filter(|entry| !entry.is_empty())
.map(|entry| entry.trim_end_matches([')', ',', ' ']).replace(".0", ""))
.collect()
Expand All @@ -49,7 +49,7 @@ fn panel() -> serde_json::Value {
fn compiles_layers_controls_and_the_contract() {
let tone = json!({ "kind": "tint", "id": "tone", "values": ["ok", "low"], "colors": ["#3ddc84", "#ff4d4d"] });
let out = compile(json!({
"name": "menu", "mode": "horse", "backdrop": "#101010",
"name": "menu", "mode": "horse", "backdrop": "#10101080",
"children": [
{ "type": "sprite", "art": panel(), "x": 220, "y": 125 },
{ "type": "slot", "handle": { "kind": "text", "id": "title" }, "width": 60, "x": 10, "y": 200,
Expand All @@ -72,15 +72,15 @@ fn compiles_layers_controls_and_the_contract() {
let (layer, index) = decode(marker, kind);
(layer, table[index].as_str())
};
assert_eq!(color(&menu.static_marker, 0), (1, "255, 255, 255"));
assert_eq!(color(&menu.markers["title"], 0), (2, "255, 215, 0"));
assert_eq!(color(&menu.static_marker, 0), (1, "255, 255, 255, 255"));
assert_eq!(color(&menu.markers["title"], 0), (2, "255, 215, 0, 255"));
assert_eq!(color(&menu.markers["status"], 0).0, 3);
let status = &menu.slots["status"].tint_markers;
assert_eq!(color(&status["ok"], 0), (3, "61, 220, 132"));
assert_eq!(color(&status["low"], 0), (3, "255, 77, 77"));
assert_eq!(color(menu.backdrop_marker.as_deref().unwrap(), 2), (0, "16, 16, 16"));
assert_eq!(color(&canvas.cursor.marker, 1), (255, "255, 255, 255"));
assert_eq!(color(&canvas.overlay_marker, 0), (fullscreen::OVERLAY_LAYER.into(), "255, 255, 255"));
assert_eq!(color(&status["ok"], 0), (3, "61, 220, 132, 255"));
assert_eq!(color(&status["low"], 0), (3, "255, 77, 77, 255"));
assert_eq!(color(menu.backdrop_marker.as_deref().unwrap(), 2), (0, "16, 16, 16, 128"));
assert_eq!(color(&canvas.cursor.marker, 1), (255, "255, 255, 255, 255"));
assert_eq!(color(&canvas.overlay_marker, 0), (fullscreen::OVERLAY_LAYER.into(), "255, 255, 255, 255"));

let play = &menu.controls["play"];
assert_eq!((play.index, play.x, play.y, play.width, play.height), (0, 0, 0, 40, 20));
Expand All @@ -93,6 +93,16 @@ fn compiles_layers_controls_and_the_contract() {
assert_eq!(&parsed, manifest);
}

#[test]
fn a_dimming_backdrop_needs_an_alpha_the_text_shader_draws() {
let backdrop = |color: &str| compile(json!({ "name": "menu", "backdrop": color, "children": [] }));
assert!(backdrop("#0000001a").is_ok());
let error = backdrop("#00000019").unwrap_err().to_string();
assert!(error.contains("below the lowest alpha"), "{error}");
assert!(backdrop("#0000").is_err());
assert!(backdrop("#00000\u{e9}0").is_err());
}

#[test]
fn packs_without_fullscreens_emit_no_contract_or_shaders() {
let project = json!({ "huds": [{ "name": "h", "children": [] }], "target": { "pack_format": 88 } }).to_string();
Expand Down Expand Up @@ -196,7 +206,7 @@ fn lays_tooltips_out_beside_their_controls_above_every_window_layer() {
let [title] = top.lines.as_slice() else { panic!("{:?}", top.lines) };
assert_eq!((title.x, title.y, title.text.as_deref()), ((40 - (title.width as i32 + 8)) / 2 + 4, 36, Some("Top")));
let (layer, color) = decode(title.shader_marker.as_deref().unwrap(), 0);
assert_eq!((layer, table[color].as_str()), (u32::from(fullscreen::TOOLTIP_TEXT_LAYER), "255, 255, 255"));
assert_eq!((layer, table[color].as_str()), (u32::from(fullscreen::TOOLTIP_TEXT_LAYER), "255, 255, 255, 255"));

// Above its control where the canvas ends below it, and kept on the canvas; the title is 2px further from its
// lines than they are apart.
Expand All @@ -207,7 +217,7 @@ fn lays_tooltips_out_beside_their_controls_above_every_window_layer() {
let left = 480 - (bottom.lines.iter().map(|line| line.width).max().unwrap() as i32 + 8) + 4;
assert_eq!(rows, [(left, top_y), (left, top_y + 12), (left, top_y + 22)]);
let gray = decode(bottom.lines[1].shader_marker.as_deref().unwrap(), 0).1;
assert_eq!(table[gray], "170, 170, 170");
assert_eq!(table[gray], "170, 170, 170, 255");

let file = out
.files
Expand Down
5 changes: 4 additions & 1 deletion docs/AUTHORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,10 @@ the core shaders that draw it, with no `coreShaders` option, and needs pack form

- `mode`: `"unlocked"`, the default, keeps the camera on the player, so the world turns behind the canvas as they aim.
`"horse"` locks the view in place and draws the cursor about a tick behind the mouse.
- `backdrop`: a hex color filling the screen behind the canvas, letterbox included. Omit it to show the world.
- `backdrop`: a hex color filling the screen behind the canvas, letterbox included. `#rrggbb` hides the world, and
`#rrggbbaa` dims it, such as `#00000099`; the alpha starts at `1a`, since the text shader drops fainter fragments.
Omit it to show the world. Translucent world layers drawn after the canvas, such as water, stained glass, and some
particles, can be hidden behind a dimming backdrop rather than dimmed.

It takes the same elements as a window, except those that need a container:

Expand Down
4 changes: 2 additions & 2 deletions example/pack/src/window/storefront.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -358,12 +358,12 @@ const footer = (
);

/**
* A web-style storefront over the world, in horse mode so the view holds still behind it: no backdrop, translucent
* A web-style storefront over the world, in horse mode so the view holds still behind it: a backdrop dims it, translucent
* glass panes, category tabs, a search box the player types into in place, a product grid and a cart that each scroll,
* and a detail pane to buy from.
*/
export default (
<Fullscreen name="storefront" mode="horse" padding={6} gap={6} text={{ color: "#ffffff" }}>
<Fullscreen name="storefront" mode="horse" backdrop="#05080f99" padding={6} gap={6} text={{ color: "#ffffff" }}>
{nav}
<Row gap={6} align="stretch" grow={1}>
{catalog}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7277,6 +7277,7 @@ internal object WindowFullscreenEntries {
"store_sort_label" to "#a05800",
"store_toast" to "#a23c02",
),
backdropMarker = "#a80010",
),
)
val canvas: FullscreenCanvasEntry =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,14 @@ import net.minestom.server.network.packet.client.play.ClientHeldItemChangePacket
import net.minestom.server.network.packet.client.play.ClientInputPacket
import net.minestom.server.network.packet.client.play.ClientInteractEntityPacket
import net.minestom.server.network.packet.client.play.ClientPlayerActionPacket
import net.minestom.server.network.packet.client.play.ClientPlayerBlockPlacementPacket
import net.minestom.server.network.packet.client.play.ClientPlayerPositionAndRotationPacket
import net.minestom.server.network.packet.client.play.ClientPlayerRotationPacket
import net.minestom.server.network.packet.client.play.ClientUseItemPacket
import net.minestom.server.network.packet.server.ServerPacket
import net.minestom.server.network.packet.server.common.ClearDialogPacket
import net.minestom.server.network.packet.server.common.ShowDialogPacket
import net.minestom.server.network.packet.server.play.AcknowledgeBlockChangePacket
import net.minestom.server.network.packet.server.play.ChangeGameStatePacket
import net.minestom.server.network.packet.server.play.CloseWindowPacket
import net.minestom.server.network.packet.server.play.DestroyEntitiesPacket
Expand Down Expand Up @@ -381,6 +384,17 @@ internal class MinestomFullscreen private constructor(
event.isCancelled = true
}

// A right click that misses the interaction entity would use the item the server's player really holds.
is ClientUseItemPacket -> {
event.isCancelled = true
rejectUse(packet.sequence())
}

is ClientPlayerBlockPlacementPacket -> {
event.isCancelled = true
rejectUse(packet.sequence())
}

is ClientCloseWindowPacket -> {
// Minestom ignores a close for any window but the open one, so a stale close must not hide the HUD.
whileOpen { if (packet.windowId() == containerWindowId) containerClosed() }
Expand All @@ -396,6 +410,15 @@ internal class MinestomFullscreen private constructor(
}
}

/**
* Undoes what the client predicted for a cancelled use: acknowledging [sequence] restores the blocks it placed, and
* the inventory resync restores the off-hand item it spent, while the hotbar stays blank.
*/
private fun rejectUse(sequence: Int) {
player.sendPacket(AcknowledgeBlockChangePacket(sequence))
player.inventory.update(player)
}

/** Reports the client selecting [slot] as steps the shortest way around the hotbar from the slot it had. */
private fun scroll(slot: Int) {
if (slot !in 0 until HOTBAR_SIZE) return
Expand Down
Loading
Loading