From e22f12c0b6c3fbf7f8a9da85604b39f7d9a93ee0 Mon Sep 17 00:00:00 2001 From: Paula Date: Sat, 26 Sep 2026 18:06:05 -0300 Subject: [PATCH] Tooltips --- MODS.md | 8 +++-- client/client.ts | 42 ++++++++++++++++---------- client/gui/gui_screen.ts | 9 +++++- client/gui/hud.ts | 37 ++++++++++++++++++++++- client/gui/tooltip.ts | 55 +++++++++++++++++++++++++++++++++++ client/input_manager.ts | 11 +++---- common/everything_registry.ts | 4 +++ common/inventory.ts | 23 +++++++++++++++ common/mod_data.ts | 13 +++++++++ common/utils.ts | 15 +++++++--- server/game/game_server.ts | 9 +++--- tests/base_game_test.ts | 24 +++++++++++++-- 12 files changed, 215 insertions(+), 35 deletions(-) create mode 100644 client/gui/tooltip.ts diff --git a/MODS.md b/MODS.md index 451bb2a..67b60df 100644 --- a/MODS.md +++ b/MODS.md @@ -233,6 +233,7 @@ program**, so the server and each client can number blocks differently. Saves an | Field | Default | Maps to `BlockRegistry` | Meaning | | ---------------------- | ----------- | ----------------------- | -------------------------------------------------------------------------------------------- | | `id` | required | `id` | The block's id. | +| `name` | from the id | `name` | What players see, like `"Copper Block"`. Its item gets it too. Without it the name is made from the id: `copper_tools:copper_block` is "Copper Block". | | `textures` | required | `textures` | One texture id, `{ top, bottom, side }` or `{ front, side }`. With another `model`, the model's texture variables, like `{ "crop": "..." }`. | | `model` | `engine:cube` | `model` | The [block model](#block-models). | | `variants` | none | `variants` | A different `model`, `textures` or `y` rotation for some states, see [block models](#block-models). | @@ -357,11 +358,12 @@ block is lit evenly with the block's own light. | Field | Default | Maps to `ItemRegistry` | Meaning | | ------------ | -------- | ------------------------ | -------------------------------------------------- | | `id` | required | registry key | The item's id. | +| `name` | from the id | `name` | What players see, like `"Copper Pickaxe"`. Made from the id when not set. | | `texture` | required | `texture_id` | Texture id. | | `tool` | none | `tool_type` | Tool type used by blocks' `mining.tool`. | | `places` | none | `block_id` | Block placed when used on a block face. | | `max_stack` | `64` | new | Largest stack size. | -| `lore` | none | `get_lore` | Static tooltip text. | +| `lore` | none | `lore` | Tooltip text under the name. `\n` starts a new line. | | `components` | none | `on_create` / `get_lore` | Custom item components, handled by server scripts. | Item stacks can carry `data`, a JSON value set by the server (like the watering can's water level). It's synced to @@ -623,7 +625,9 @@ Item components use `ctx.components.register_item(id, { on_create(item, params), get_lore(item, params), on_use(item, params, player) })`. `on_use` runs when a player right clicks while holding the item, both at nothing and at a block. At a block, the block's -own interaction comes first, and an item with `on_use` is used instead of being placed. +own interaction comes first, and an item with `on_use` is used instead of being placed. `get_lore` is extra tooltip text +for one stack, like the watering can's "Water: 12/32": the server works it out whenever it sends the item to a player +and shows it under the item's own `lore`. It isn't saved. ### Events diff --git a/client/client.ts b/client/client.ts index ce91f02..072df7f 100644 --- a/client/client.ts +++ b/client/client.ts @@ -106,8 +106,10 @@ export class Client { if (this.#stopped) { return; } + const screen = this.screen; this.screen?.on_tick(delta); - this.#handle_keybinds(); + // a screen that closed itself just now, like chat on enter, had this frame's keys + this.#handle_keybinds(screen !== undefined && this.screen === undefined); this.#pending_time += delta; let ticks = 0; @@ -143,7 +145,30 @@ export class Client { this.level.tick(); } - #handle_keybinds() { + // closed_screen: a screen closed this frame and took its keys, so they don't also open the inventory or pick a + // hotbar slot. typing "/give pickaxe" and enter in the same slow frame would otherwise open the inventory + #handle_keybinds(closed_screen: boolean) { + const player = this.player; + + if (!closed_screen) { + this.#handle_keys(); + } + + if (InputManager.is_mouse_grabbed()) { + const mouse = InputManager.get_mouse_delta(); + player.turn(mouse.x, mouse.y); + } + InputManager.set_mouse_grabbed(!this.screen); + + this.hit_result = this.level.pick(player.x, player.y + player.eye_height, player.z, player.yaw, player.pitch); + this.#handle_block_interaction(); + + if (!this.screen && !closed_screen) { + this.#handle_hotbar(); + } + } + + #handle_keys() { const options = this.options; const player = this.player; @@ -177,19 +202,6 @@ export class Client { if (InputManager.is_key_pressed(options.key_fullscreen)) { InputManager.toggle_fullscreen(); } - - if (InputManager.is_mouse_grabbed()) { - const mouse = InputManager.get_mouse_delta(); - player.turn(mouse.x, mouse.y); - } - InputManager.set_mouse_grabbed(!this.screen); - - this.hit_result = this.level.pick(player.x, player.y + player.eye_height, player.z, player.yaw, player.pitch); - this.#handle_block_interaction(); - - if (!this.screen) { - this.#handle_hotbar(); - } } // the held item while playing, the slot under the mouse in an inventory (only with nothing on the cursor) diff --git a/client/gui/gui_screen.ts b/client/gui/gui_screen.ts index 20b6f8d..412d414 100644 --- a/client/gui/gui_screen.ts +++ b/client/gui/gui_screen.ts @@ -7,6 +7,7 @@ import { InputManager } from "../input_manager.ts"; import { ClientInventories } from "../inventory.ts"; import { canvas, Texture } from "../renderer/mod.ts"; import { draw_item, draw_nine_slice } from "../rendering/render_utils.ts"; +import { draw_tooltip, item_tooltip } from "./tooltip.ts"; export class Slot { container: ContainerKey; @@ -99,9 +100,15 @@ export class GuiInventoryScreen extends GuiScreen { } } + const mouse = InputManager.get_mouse_position(); if (this.inventories.cursor.item) { - const mouse = InputManager.get_mouse_position(); draw_item(this.inventories.cursor.item, mouse.x, mouse.y); + } else if (this.hovering) { + // what's in the slot under the mouse, while not carrying anything + const item = this.get_container(this.hovering.container)?.get_item(this.hovering.index); + if (item) { + draw_tooltip(item_tooltip(item), mouse.x, mouse.y); + } } } diff --git a/client/gui/hud.ts b/client/gui/hud.ts index 8221df3..350b516 100644 --- a/client/gui/hud.ts +++ b/client/gui/hud.ts @@ -3,15 +3,26 @@ import { AssetManager } from "$/client/assets.ts"; import type { Client } from "$/client/client.ts"; import type { ClientInventories } from "$/client/inventory.ts"; import { draw_item, draw_nine_slice } from "$/client/rendering/render_utils.ts"; -import { canvas, draw_rect_stroke, Texture } from "$/client/renderer/mod.ts"; +import { canvas, draw_rect_stroke, draw_text, measure_text, Texture } from "$/client/renderer/mod.ts"; +import { display_name } from "$/common/utils.ts"; +import { TEXT_HEIGHT, TEXT_SCALE } from "./widgets.ts"; const PADDING = 10; const CROSSHAIR_SIZE = 8; +// how long the held item's name shows above the hotbar after it changes, and the last part of that it fades out in +const HELD_NAME_MS = 2000; +const HELD_NAME_FADE_MS = 500; +const HELD_NAME_GAP = 12; // what's drawn over the world while playing, like minecraft's Gui: hotbar, crosshair and chat export class Hud { + // what was held last frame, as slot and item id, and when the held item last changed + #held = ""; + #held_since = -Infinity; + render(client: Client) { this.#render_hotbar(client.player.inventories); + this.#render_held_name(client.player.inventories); this.#render_crosshair(); client.chat.render(false); } @@ -54,6 +65,30 @@ export class Hud { } } + // like minecraft: switching slots, or a different item ending up in the selected one, shows its name for a bit + #render_held_name(inventories: ClientInventories) { + const item = inventories.inventory.get_item(inventories.hotbar_selected); + const held = item ? `${inventories.hotbar_selected} ${item.type_id}` : ""; + const now = performance.now(); + if (held !== this.#held) { + this.#held = held; + this.#held_since = item ? now : -Infinity; + } + + const shown_for = now - this.#held_since; + if (!item || shown_for > HELD_NAME_MS) { + return; + } + const alpha = Math.min(1, (HELD_NAME_MS - shown_for) / HELD_NAME_FADE_MS); + const name = display_name(item.type_id); + const hotbar_top = canvas.height - (PADDING * 2 + SLOT_SIZE); + const x = (canvas.width - measure_text(name, TEXT_SCALE)) / 2; + const y = hotbar_top - HELD_NAME_GAP - TEXT_HEIGHT * TEXT_SCALE; + // a shadow so it reads over bright sky and snow + draw_text(name, x + TEXT_SCALE, y + TEXT_SCALE, TEXT_SCALE, [0.15, 0.15, 0.15, alpha]); + draw_text(name, x, y, TEXT_SCALE, [1, 1, 1, alpha]); + } + #render_crosshair() { draw_rect_stroke( (canvas.width - CROSSHAIR_SIZE) / 2, diff --git a/client/gui/tooltip.ts b/client/gui/tooltip.ts new file mode 100644 index 0000000..594324d --- /dev/null +++ b/client/gui/tooltip.ts @@ -0,0 +1,55 @@ +// the box next to the mouse saying what an item is, like minecraft's: its name, then its lore +import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; +import type { ItemStack } from "$/common/inventory.ts"; +import { display_name } from "$/common/utils.ts"; +import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mod.ts"; +import { TEXT_HEIGHT, TEXT_SCALE } from "./widgets.ts"; + +type Color = [number, number, number, number]; + +const NAME_COLOR: Color = [1, 1, 1, 1]; +const LORE_COLOR: Color = [0.66, 0.66, 0.66, 1]; +const BACKGROUND: Color = [0.06, 0.02, 0.1, 0.94]; +const BORDER: Color = [0.31, 0.1, 0.6, 1]; +const BORDER_WIDTH = 2; +const PADDING = 8; +const LINE_GAP = 4; +// from the mouse, so the cursor doesn't cover it +const OFFSET = 16; + +export interface TooltipLine { + text: string; + color: Color; +} + +// its name, the item's lore from its json, then what the server's scripts said about this stack +export function item_tooltip(item: ItemStack): TooltipLine[] { + const lines: TooltipLine[] = [{ text: display_name(item.type_id), color: NAME_COLOR }]; + const static_lore = EverythingRegistry.get("items", item.type_id)?.lore; + for (const lore of [static_lore, item.lore]) { + for (const text of lore?.split("\n") ?? []) { + lines.push({ text, color: LORE_COLOR }); + } + } + return lines; +} + +// below and right of the mouse, moved back inside the window when it would go past its edge +export function draw_tooltip(lines: TooltipLine[], mouse_x: number, mouse_y: number) { + const line_height = TEXT_HEIGHT * TEXT_SCALE; + const width = Math.max(...lines.map((line) => measure_text(line.text, TEXT_SCALE))) + PADDING * 2; + const height = lines.length * line_height + (lines.length - 1) * LINE_GAP + PADDING * 2; + + let x = mouse_x + OFFSET; + let y = mouse_y + OFFSET; + if (x + width > canvas.width) x = mouse_x - OFFSET - width; + if (y + height > canvas.height) y = canvas.height - height; + x = Math.max(0, x); + y = Math.max(0, y); + + draw_rect(x, y, width, height, BORDER); + draw_rect(x + BORDER_WIDTH, y + BORDER_WIDTH, width - BORDER_WIDTH * 2, height - BORDER_WIDTH * 2, BACKGROUND); + lines.forEach((line, i) => { + draw_text(line.text, x + PADDING, y + PADDING + i * (line_height + LINE_GAP), TEXT_SCALE, line.color); + }); +} diff --git a/client/input_manager.ts b/client/input_manager.ts index 8fd0914..ccc8090 100644 --- a/client/input_manager.ts +++ b/client/input_manager.ts @@ -89,7 +89,8 @@ export class InputManager { static mouse_delta_y = 0; static wheel_delta = 0; - static typed_characters = new Set(); + // in the order they were typed. a list, since the same letter can be typed twice in one frame + static typed_characters: string[] = []; static pointer_lock_flag = false; static mouse_grab_timer = 0; @@ -108,7 +109,7 @@ export class InputManager { } this.keys_down.add(e.code); if (e.key.length === 1) { - this.typed_characters.add(e.key); + this.typed_characters.push(e.key); } }); @@ -221,8 +222,8 @@ export class InputManager { } static get_typed_characters(): string[] { - const chars = [...this.typed_characters]; - this.typed_characters.clear(); + const chars = this.typed_characters; + this.typed_characters = []; return chars; } @@ -234,7 +235,7 @@ export class InputManager { this.mouse_delta_x = 0; this.mouse_delta_y = 0; this.wheel_delta = 0; - this.typed_characters.clear(); + this.typed_characters = []; this.mouse_buttons_consumed.clear(); } diff --git a/common/everything_registry.ts b/common/everything_registry.ts index 1ed1cfa..dab2366 100644 --- a/common/everything_registry.ts +++ b/common/everything_registry.ts @@ -99,6 +99,8 @@ export function block_light_emission(block: BlockRegistry | undefined): number { export interface BlockRegistry { id: string; + // what players see, like "Iron Ore". made from the id when not set, see display_name in common/utils.ts + name?: string; textures: BlockTextures; // the block model, engine:cube when not set. see common/block_models.ts model?: string; @@ -135,6 +137,8 @@ export interface BlockRegistry { export interface ItemRegistry { texture_id: string | ((item: ItemStack) => string); + // what players see, like "Iron Ingot". made from the id when not set, see display_name in common/utils.ts + name?: string; block_id?: string; tool_type?: string; max_stack?: number; diff --git a/common/inventory.ts b/common/inventory.ts index 0aa292c..0766199 100644 --- a/common/inventory.ts +++ b/common/inventory.ts @@ -5,6 +5,8 @@ export interface ItemData { id: string; count: number; data?: unknown; + // what server scripts say about it (get_lore), only sent to players, never saved + lore?: string; } export class ItemStack { @@ -12,6 +14,8 @@ export class ItemStack { amount: number; max_amount: number; data?: T; + // on clients, the lore the server's scripts gave it when it was last synced + lore?: string; constructor(type_id: string | string, amount: number = 1, max_amount?: number) { const item_info = EverythingRegistry.get("items", type_id); @@ -27,6 +31,7 @@ export class ItemStack { clone(): ItemStack { const item = new ItemStack(this.type_id, this.amount, this.max_amount); item.data = structuredClone(this.data); + item.lore = this.lore; return item; } @@ -36,11 +41,24 @@ export class ItemStack { : { id: this.type_id, count: this.amount, data: this.data }; } + // what players are sent: to_data with the lore from the item's get_lore, which only runs on the server + to_synced_data(): ItemData { + const data = this.to_data(); + const lore = EverythingRegistry.get("items", this.type_id)?.get_lore?.(this); + if (lore) { + data.lore = lore; + } + return data; + } + static from_data(data: ItemData): ItemStack { const item = new ItemStack(data.id, data.count); if (data.data !== undefined) { item.data = data.data; } + if (data.lore !== undefined) { + item.lore = data.lore; + } return item; } } @@ -140,6 +158,11 @@ export class Container { return this.#slots.map((slot) => slot.get_item()?.to_data() ?? null); } + // what players are sent, with lore, see ItemStack.to_synced_data + to_synced_data(): (ItemData | null)[] { + return this.#slots.map((slot) => slot.get_item()?.to_synced_data() ?? null); + } + load(data: (ItemData | null)[]) { for (let i = 0; i < this.size; i += 1) { const item = data[i]; diff --git a/common/mod_data.ts b/common/mod_data.ts index de28353..a220726 100644 --- a/common/mod_data.ts +++ b/common/mod_data.ts @@ -31,6 +31,7 @@ export interface ManifestJson { export interface BlockJson { id: string; + name?: string; textures: BlockTextures; model?: string; variants?: Record; @@ -66,6 +67,7 @@ export interface ItemJson { tool?: string; places?: string; max_stack?: number; + name?: string; lore?: string; components?: Record; } @@ -100,6 +102,7 @@ export interface GridRecipe { export function block_to_json(block: BlockRegistry, has_item: boolean): BlockJson { const json: BlockJson = { id: block.id, textures: block.textures }; + if (block.name !== undefined) json.name = block.name; if (block.model !== undefined) json.model = block.model; if (block.variants !== undefined) json.variants = block.variants; if (block.render_layer && block.render_layer !== "solid") json.render_layer = block.render_layer; @@ -128,6 +131,7 @@ export function block_from_json(json: BlockJson): { block: BlockRegistry; has_it textures: json.textures, has_collision: json.collision ?? true, }; + if (json.name !== undefined) block.name = json.name; if (json.model !== undefined) block.model = json.model; if (json.variants !== undefined) block.variants = json.variants; const render_layer = json.render_layer ?? (json.transparent ? "translucent" : undefined); @@ -159,6 +163,7 @@ export function item_to_json(id: string, item: ItemRegistry): ItemJson { if (item.tool_type !== undefined) json.tool = item.tool_type; if (item.block_id !== undefined) json.places = item.block_id; if (item.max_stack !== undefined) json.max_stack = item.max_stack; + if (item.name !== undefined) json.name = item.name; if (item.lore !== undefined) json.lore = item.lore; if (item.components) json.components = item.components; return json; @@ -169,6 +174,7 @@ export function item_from_json(json: ItemJson): ItemRegistry { if (json.tool !== undefined) item.tool_type = json.tool; if (json.places !== undefined) item.block_id = json.places; if (json.max_stack !== undefined) item.max_stack = json.max_stack; + if (json.name !== undefined) item.name = json.name; if (json.lore !== undefined) item.lore = json.lore; if (json.components) item.components = json.components; return item; @@ -318,6 +324,7 @@ export function validate_block(json: unknown): Problems { problems.push(`${key} must be a whole number from 0 to 15`); } } + problems.push(...validate_text(json.name, "name")); for (const key of ["cull_same", "transparent", "collision", "item", "interactive", "replaceable"]) { if (json[key] !== undefined && typeof json[key] !== "boolean") problems.push(`${key} must be true or false`); } @@ -413,6 +420,7 @@ export function validate_item(json: unknown): Problems { const problems = validate_id(json.id, "id"); problems.push(...validate_id(json.texture, "texture")); if (json.places !== undefined) problems.push(...validate_id(json.places, "places")); + problems.push(...validate_text(json.name, "name"), ...validate_text(json.lore, "lore")); if (json.max_stack !== undefined && (!Number.isInteger(json.max_stack) || (json.max_stack as number) < 1)) { problems.push("max_stack must be a positive whole number"); } @@ -481,6 +489,11 @@ export function validate_ore(json: unknown): Problems { return problems; } +// optional text players see +function validate_text(value: unknown, field: string): Problems { + return value === undefined || (typeof value === "string" && value.length > 0) ? [] : [`${field} must be text`]; +} + function validate_id(value: unknown, field: string): Problems { return typeof value === "string" && ID_PATTERN.test(value) ? [] : [`${field} must be an id like "my_mod:thing"`]; } diff --git a/common/utils.ts b/common/utils.ts index 80da72c..f09bc24 100644 --- a/common/utils.ts +++ b/common/utils.ts @@ -41,10 +41,17 @@ export function distance_point_point(ax: number, ay: number, az: number, bx: num export function register_block_item(block: BlockRegistry) { // cubes are drawn from the block's textures, anything else as a sprite const texture_id = block_item_texture(block) ?? cube_face_texture(block, "side"); - EverythingRegistry.register("items", block.id, { - texture_id, - block_id: block.id, - }); + const item: ItemRegistry = { texture_id, block_id: block.id }; + if (block.name !== undefined) item.name = block.name; + EverythingRegistry.register("items", block.id, item); +} + +// what players see an item called: its name, or one made from its id, so bworld:iron_ingot is "Iron Ingot" +export function display_name(item_id: string): string { + const name = EverythingRegistry.get("items", item_id)?.name; + if (name) return name; + const words = (item_id.split(":")[1] ?? item_id).split("_").filter((word) => word.length > 0); + return words.map((word) => word[0].toUpperCase() + word.slice(1)).join(" "); } export function get_state_value(value: number, block_info: BlockRegistry, name: string) { diff --git a/server/game/game_server.ts b/server/game/game_server.ts index 047a4f3..b6c1986 100644 --- a/server/game/game_server.ts +++ b/server/game/game_server.ts @@ -1032,15 +1032,16 @@ export class GameServer { } }; - const inventory = player.inventory.to_data(); + // with lore, so it's resent when only the lore changed + const inventory = player.inventory.to_synced_data(); sync("inventory", inventory, () => ({ type: "container", container: "inventory", items: inventory })); - const crafting = player.crafting.to_data(); + const crafting = player.crafting.to_synced_data(); sync("crafting", crafting, () => ({ type: "container", container: "crafting", items: crafting })); - const cursor = player.cursor.item?.to_data() ?? null; + const cursor = player.cursor.item?.to_synced_data() ?? null; sync("cursor", cursor, () => ({ type: "cursor", item: cursor })); if (player.screen) { - const items = player.screen.container.to_data(); + const items = player.screen.container.to_synced_data(); sync("screen", items, () => ({ type: "container", container: "screen", items })); const properties = { ...player.screen.properties }; sync("properties", properties, () => ({ type: "screen_properties", properties })); diff --git a/tests/base_game_test.ts b/tests/base_game_test.ts index 334088a..548a46d 100644 --- a/tests/base_game_test.ts +++ b/tests/base_game_test.ts @@ -1,7 +1,7 @@ // the base game's block and item behavior: hoeing, chests, furnaces, crops and the watering can import { assert, assertEquals } from "@std/assert"; -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { get_state_value } from "$/common/utils.ts"; +import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; +import { display_name, get_state_value } from "$/common/utils.ts"; import { test_game } from "./helpers.ts"; // deno-lint-ignore no-explicit-any @@ -158,10 +158,28 @@ Deno.test("the last bone meal is used up", async () => { assertEquals(last_container(t.take(1), "inventory")[slot], null); }); -Deno.test("a new watering can starts empty", async () => { +Deno.test("a new watering can starts empty, and players are told so in its lore", async () => { const t = await setup([["bworld:watering_can", 1]]); const can = last_container(t.take(1), "inventory").find((i: Msg) => i?.id === "bworld:watering_can"); assertEquals(can.data, { water: 0, max_water: 32 }); + assertEquals(can.lore, "Water: 0/32"); + + // lore is worked out for players, it isn't saved + const saved = JSON.parse(t.game.save()); + const saved_can = saved.players.alice?.inventory?.find((i: Msg) => i?.id === "bworld:watering_can"); + assert(!saved_can || saved_can.lore === undefined); + t.game.on_disconnect(1); + const after = JSON.parse(t.game.save()).players.alice.inventory.find((i: Msg) => i?.id === "bworld:watering_can"); + assertEquals(after.lore, undefined); +}); + +Deno.test("items are called by their name, or by one made from their id", async () => { + await test_game("mods"); + assertEquals(display_name("bworld:iron_ingot"), "Iron Ingot"); + assertEquals(display_name("bworld:stone_pickaxe"), "Stone Pickaxe"); + assertEquals(display_name("bworld:hoed_dirt"), "Hoed Dirt"); + EverythingRegistry.register("items", "test:thing", { texture_id: "engine:missing", name: "A Thing" }); + assertEquals(display_name("test:thing"), "A Thing"); }); Deno.test("chests and furnaces from a world saved before they were mod code keep their items and keep smelting", async () => {