This commit is contained in:
2026-09-26 18:06:05 -03:00
parent 01a81b926e
commit e22f12c0b6
12 changed files with 215 additions and 35 deletions
+6 -2
View File
@@ -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
+27 -15
View File
@@ -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)
+8 -1
View File
@@ -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 {
}
}
if (this.inventories.cursor.item) {
const mouse = InputManager.get_mouse_position();
if (this.inventories.cursor.item) {
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);
}
}
}
+36 -1
View File
@@ -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,
+55
View File
@@ -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<ItemRegistry>("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);
});
}
+6 -5
View File
@@ -89,7 +89,8 @@ export class InputManager {
static mouse_delta_y = 0;
static wheel_delta = 0;
static typed_characters = new Set<string>();
// 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();
}
+4
View File
@@ -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<T = unknown | undefined> {
texture_id: string | ((item: ItemStack<T>) => 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;
+23
View File
@@ -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<T = unknown | undefined> {
@@ -12,6 +14,8 @@ export class ItemStack<T = unknown | undefined> {
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<ItemRegistry>("items", type_id);
@@ -27,6 +31,7 @@ export class ItemStack<T = unknown | undefined> {
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<T = unknown | undefined> {
: { 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<ItemRegistry>("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];
+13
View File
@@ -31,6 +31,7 @@ export interface ManifestJson {
export interface BlockJson {
id: string;
name?: string;
textures: BlockTextures;
model?: string;
variants?: Record<string, BlockVariant>;
@@ -66,6 +67,7 @@ export interface ItemJson {
tool?: string;
places?: string;
max_stack?: number;
name?: string;
lore?: string;
components?: Record<string, unknown>;
}
@@ -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"`];
}
+11 -4
View File
@@ -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<ItemRegistry>("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<ItemRegistry>("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<ItemRegistry>("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) {
+5 -4
View File
@@ -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 }));
+21 -3
View File
@@ -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<ItemRegistry>("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 () => {