Tooltips
This commit is contained in:
@@ -233,6 +233,7 @@ program**, so the server and each client can number blocks differently. Saves an
|
|||||||
| Field | Default | Maps to `BlockRegistry` | Meaning |
|
| Field | Default | Maps to `BlockRegistry` | Meaning |
|
||||||
| ---------------------- | ----------- | ----------------------- | -------------------------------------------------------------------------------------------- |
|
| ---------------------- | ----------- | ----------------------- | -------------------------------------------------------------------------------------------- |
|
||||||
| `id` | required | `id` | The block's id. |
|
| `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": "..." }`. |
|
| `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). |
|
| `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). |
|
| `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 |
|
| Field | Default | Maps to `ItemRegistry` | Meaning |
|
||||||
| ------------ | -------- | ------------------------ | -------------------------------------------------- |
|
| ------------ | -------- | ------------------------ | -------------------------------------------------- |
|
||||||
| `id` | required | registry key | The item's id. |
|
| `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. |
|
| `texture` | required | `texture_id` | Texture id. |
|
||||||
| `tool` | none | `tool_type` | Tool type used by blocks' `mining.tool`. |
|
| `tool` | none | `tool_type` | Tool type used by blocks' `mining.tool`. |
|
||||||
| `places` | none | `block_id` | Block placed when used on a block face. |
|
| `places` | none | `block_id` | Block placed when used on a block face. |
|
||||||
| `max_stack` | `64` | new | Largest stack size. |
|
| `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. |
|
| `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
|
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,
|
`ctx.components.register_item(id, { on_create(item, params), get_lore(item, params), on_use(item,
|
||||||
params, player) })`.
|
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
|
`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
|
### Events
|
||||||
|
|
||||||
|
|||||||
+27
-15
@@ -106,8 +106,10 @@ export class Client {
|
|||||||
if (this.#stopped) {
|
if (this.#stopped) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const screen = this.screen;
|
||||||
this.screen?.on_tick(delta);
|
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;
|
this.#pending_time += delta;
|
||||||
let ticks = 0;
|
let ticks = 0;
|
||||||
@@ -143,7 +145,30 @@ export class Client {
|
|||||||
this.level.tick();
|
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 options = this.options;
|
||||||
const player = this.player;
|
const player = this.player;
|
||||||
|
|
||||||
@@ -177,19 +202,6 @@ export class Client {
|
|||||||
if (InputManager.is_key_pressed(options.key_fullscreen)) {
|
if (InputManager.is_key_pressed(options.key_fullscreen)) {
|
||||||
InputManager.toggle_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)
|
// the held item while playing, the slot under the mouse in an inventory (only with nothing on the cursor)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { InputManager } from "../input_manager.ts";
|
|||||||
import { ClientInventories } from "../inventory.ts";
|
import { ClientInventories } from "../inventory.ts";
|
||||||
import { canvas, Texture } from "../renderer/mod.ts";
|
import { canvas, Texture } from "../renderer/mod.ts";
|
||||||
import { draw_item, draw_nine_slice } from "../rendering/render_utils.ts";
|
import { draw_item, draw_nine_slice } from "../rendering/render_utils.ts";
|
||||||
|
import { draw_tooltip, item_tooltip } from "./tooltip.ts";
|
||||||
|
|
||||||
export class Slot {
|
export class Slot {
|
||||||
container: ContainerKey;
|
container: ContainerKey;
|
||||||
@@ -99,9 +100,15 @@ export class GuiInventoryScreen extends GuiScreen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mouse = InputManager.get_mouse_position();
|
||||||
if (this.inventories.cursor.item) {
|
if (this.inventories.cursor.item) {
|
||||||
const mouse = InputManager.get_mouse_position();
|
|
||||||
draw_item(this.inventories.cursor.item, mouse.x, mouse.y);
|
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
@@ -3,15 +3,26 @@ import { AssetManager } from "$/client/assets.ts";
|
|||||||
import type { Client } from "$/client/client.ts";
|
import type { Client } from "$/client/client.ts";
|
||||||
import type { ClientInventories } from "$/client/inventory.ts";
|
import type { ClientInventories } from "$/client/inventory.ts";
|
||||||
import { draw_item, draw_nine_slice } from "$/client/rendering/render_utils.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 PADDING = 10;
|
||||||
const CROSSHAIR_SIZE = 8;
|
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
|
// what's drawn over the world while playing, like minecraft's Gui: hotbar, crosshair and chat
|
||||||
export class Hud {
|
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) {
|
render(client: Client) {
|
||||||
this.#render_hotbar(client.player.inventories);
|
this.#render_hotbar(client.player.inventories);
|
||||||
|
this.#render_held_name(client.player.inventories);
|
||||||
this.#render_crosshair();
|
this.#render_crosshair();
|
||||||
client.chat.render(false);
|
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() {
|
#render_crosshair() {
|
||||||
draw_rect_stroke(
|
draw_rect_stroke(
|
||||||
(canvas.width - CROSSHAIR_SIZE) / 2,
|
(canvas.width - CROSSHAIR_SIZE) / 2,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -89,7 +89,8 @@ export class InputManager {
|
|||||||
static mouse_delta_y = 0;
|
static mouse_delta_y = 0;
|
||||||
static wheel_delta = 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 pointer_lock_flag = false;
|
||||||
static mouse_grab_timer = 0;
|
static mouse_grab_timer = 0;
|
||||||
@@ -108,7 +109,7 @@ export class InputManager {
|
|||||||
}
|
}
|
||||||
this.keys_down.add(e.code);
|
this.keys_down.add(e.code);
|
||||||
if (e.key.length === 1) {
|
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[] {
|
static get_typed_characters(): string[] {
|
||||||
const chars = [...this.typed_characters];
|
const chars = this.typed_characters;
|
||||||
this.typed_characters.clear();
|
this.typed_characters = [];
|
||||||
return chars;
|
return chars;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +235,7 @@ export class InputManager {
|
|||||||
this.mouse_delta_x = 0;
|
this.mouse_delta_x = 0;
|
||||||
this.mouse_delta_y = 0;
|
this.mouse_delta_y = 0;
|
||||||
this.wheel_delta = 0;
|
this.wheel_delta = 0;
|
||||||
this.typed_characters.clear();
|
this.typed_characters = [];
|
||||||
this.mouse_buttons_consumed.clear();
|
this.mouse_buttons_consumed.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ export function block_light_emission(block: BlockRegistry | undefined): number {
|
|||||||
|
|
||||||
export interface BlockRegistry {
|
export interface BlockRegistry {
|
||||||
id: string;
|
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;
|
textures: BlockTextures;
|
||||||
// the block model, engine:cube when not set. see common/block_models.ts
|
// the block model, engine:cube when not set. see common/block_models.ts
|
||||||
model?: string;
|
model?: string;
|
||||||
@@ -135,6 +137,8 @@ export interface BlockRegistry {
|
|||||||
|
|
||||||
export interface ItemRegistry<T = unknown | undefined> {
|
export interface ItemRegistry<T = unknown | undefined> {
|
||||||
texture_id: string | ((item: ItemStack<T>) => string);
|
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;
|
block_id?: string;
|
||||||
tool_type?: string;
|
tool_type?: string;
|
||||||
max_stack?: number;
|
max_stack?: number;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export interface ItemData {
|
|||||||
id: string;
|
id: string;
|
||||||
count: number;
|
count: number;
|
||||||
data?: unknown;
|
data?: unknown;
|
||||||
|
// what server scripts say about it (get_lore), only sent to players, never saved
|
||||||
|
lore?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ItemStack<T = unknown | undefined> {
|
export class ItemStack<T = unknown | undefined> {
|
||||||
@@ -12,6 +14,8 @@ export class ItemStack<T = unknown | undefined> {
|
|||||||
amount: number;
|
amount: number;
|
||||||
max_amount: number;
|
max_amount: number;
|
||||||
data?: T;
|
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) {
|
constructor(type_id: string | string, amount: number = 1, max_amount?: number) {
|
||||||
const item_info = EverythingRegistry.get<ItemRegistry>("items", type_id);
|
const item_info = EverythingRegistry.get<ItemRegistry>("items", type_id);
|
||||||
@@ -27,6 +31,7 @@ export class ItemStack<T = unknown | undefined> {
|
|||||||
clone(): ItemStack {
|
clone(): ItemStack {
|
||||||
const item = new ItemStack(this.type_id, this.amount, this.max_amount);
|
const item = new ItemStack(this.type_id, this.amount, this.max_amount);
|
||||||
item.data = structuredClone(this.data);
|
item.data = structuredClone(this.data);
|
||||||
|
item.lore = this.lore;
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,11 +41,24 @@ export class ItemStack<T = unknown | undefined> {
|
|||||||
: { id: this.type_id, count: this.amount, data: this.data };
|
: { 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 {
|
static from_data(data: ItemData): ItemStack {
|
||||||
const item = new ItemStack(data.id, data.count);
|
const item = new ItemStack(data.id, data.count);
|
||||||
if (data.data !== undefined) {
|
if (data.data !== undefined) {
|
||||||
item.data = data.data;
|
item.data = data.data;
|
||||||
}
|
}
|
||||||
|
if (data.lore !== undefined) {
|
||||||
|
item.lore = data.lore;
|
||||||
|
}
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,6 +158,11 @@ export class Container {
|
|||||||
return this.#slots.map((slot) => slot.get_item()?.to_data() ?? null);
|
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)[]) {
|
load(data: (ItemData | null)[]) {
|
||||||
for (let i = 0; i < this.size; i += 1) {
|
for (let i = 0; i < this.size; i += 1) {
|
||||||
const item = data[i];
|
const item = data[i];
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface ManifestJson {
|
|||||||
|
|
||||||
export interface BlockJson {
|
export interface BlockJson {
|
||||||
id: string;
|
id: string;
|
||||||
|
name?: string;
|
||||||
textures: BlockTextures;
|
textures: BlockTextures;
|
||||||
model?: string;
|
model?: string;
|
||||||
variants?: Record<string, BlockVariant>;
|
variants?: Record<string, BlockVariant>;
|
||||||
@@ -66,6 +67,7 @@ export interface ItemJson {
|
|||||||
tool?: string;
|
tool?: string;
|
||||||
places?: string;
|
places?: string;
|
||||||
max_stack?: number;
|
max_stack?: number;
|
||||||
|
name?: string;
|
||||||
lore?: string;
|
lore?: string;
|
||||||
components?: Record<string, unknown>;
|
components?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -100,6 +102,7 @@ export interface GridRecipe {
|
|||||||
|
|
||||||
export function block_to_json(block: BlockRegistry, has_item: boolean): BlockJson {
|
export function block_to_json(block: BlockRegistry, has_item: boolean): BlockJson {
|
||||||
const json: BlockJson = { id: block.id, textures: block.textures };
|
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.model !== undefined) json.model = block.model;
|
||||||
if (block.variants !== undefined) json.variants = block.variants;
|
if (block.variants !== undefined) json.variants = block.variants;
|
||||||
if (block.render_layer && block.render_layer !== "solid") json.render_layer = block.render_layer;
|
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,
|
textures: json.textures,
|
||||||
has_collision: json.collision ?? true,
|
has_collision: json.collision ?? true,
|
||||||
};
|
};
|
||||||
|
if (json.name !== undefined) block.name = json.name;
|
||||||
if (json.model !== undefined) block.model = json.model;
|
if (json.model !== undefined) block.model = json.model;
|
||||||
if (json.variants !== undefined) block.variants = json.variants;
|
if (json.variants !== undefined) block.variants = json.variants;
|
||||||
const render_layer = json.render_layer ?? (json.transparent ? "translucent" : undefined);
|
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.tool_type !== undefined) json.tool = item.tool_type;
|
||||||
if (item.block_id !== undefined) json.places = item.block_id;
|
if (item.block_id !== undefined) json.places = item.block_id;
|
||||||
if (item.max_stack !== undefined) json.max_stack = item.max_stack;
|
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.lore !== undefined) json.lore = item.lore;
|
||||||
if (item.components) json.components = item.components;
|
if (item.components) json.components = item.components;
|
||||||
return json;
|
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.tool !== undefined) item.tool_type = json.tool;
|
||||||
if (json.places !== undefined) item.block_id = json.places;
|
if (json.places !== undefined) item.block_id = json.places;
|
||||||
if (json.max_stack !== undefined) item.max_stack = json.max_stack;
|
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.lore !== undefined) item.lore = json.lore;
|
||||||
if (json.components) item.components = json.components;
|
if (json.components) item.components = json.components;
|
||||||
return item;
|
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(`${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"]) {
|
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`);
|
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");
|
const problems = validate_id(json.id, "id");
|
||||||
problems.push(...validate_id(json.texture, "texture"));
|
problems.push(...validate_id(json.texture, "texture"));
|
||||||
if (json.places !== undefined) problems.push(...validate_id(json.places, "places"));
|
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)) {
|
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");
|
problems.push("max_stack must be a positive whole number");
|
||||||
}
|
}
|
||||||
@@ -481,6 +489,11 @@ export function validate_ore(json: unknown): Problems {
|
|||||||
return 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 {
|
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"`];
|
return typeof value === "string" && ID_PATTERN.test(value) ? [] : [`${field} must be an id like "my_mod:thing"`];
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-4
@@ -41,10 +41,17 @@ export function distance_point_point(ax: number, ay: number, az: number, bx: num
|
|||||||
export function register_block_item(block: BlockRegistry) {
|
export function register_block_item(block: BlockRegistry) {
|
||||||
// cubes are drawn from the block's textures, anything else as a sprite
|
// 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");
|
const texture_id = block_item_texture(block) ?? cube_face_texture(block, "side");
|
||||||
EverythingRegistry.register<ItemRegistry>("items", block.id, {
|
const item: ItemRegistry = { texture_id, block_id: block.id };
|
||||||
texture_id,
|
if (block.name !== undefined) item.name = block.name;
|
||||||
block_id: block.id,
|
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) {
|
export function get_state_value(value: number, block_info: BlockRegistry, name: string) {
|
||||||
|
|||||||
@@ -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 }));
|
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 }));
|
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 }));
|
sync("cursor", cursor, () => ({ type: "cursor", item: cursor }));
|
||||||
|
|
||||||
if (player.screen) {
|
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 }));
|
sync("screen", items, () => ({ type: "container", container: "screen", items }));
|
||||||
const properties = { ...player.screen.properties };
|
const properties = { ...player.screen.properties };
|
||||||
sync("properties", properties, () => ({ type: "screen_properties", properties }));
|
sync("properties", properties, () => ({ type: "screen_properties", properties }));
|
||||||
|
|||||||
+21
-3
@@ -1,7 +1,7 @@
|
|||||||
// the base game's block and item behavior: hoeing, chests, furnaces, crops and the watering can
|
// the base game's block and item behavior: hoeing, chests, furnaces, crops and the watering can
|
||||||
import { assert, assertEquals } from "@std/assert";
|
import { assert, assertEquals } from "@std/assert";
|
||||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||||
import { get_state_value } from "$/common/utils.ts";
|
import { display_name, get_state_value } from "$/common/utils.ts";
|
||||||
import { test_game } from "./helpers.ts";
|
import { test_game } from "./helpers.ts";
|
||||||
|
|
||||||
// deno-lint-ignore no-explicit-any
|
// 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);
|
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 t = await setup([["bworld:watering_can", 1]]);
|
||||||
const can = last_container(t.take(1), "inventory").find((i: Msg) => i?.id === "bworld:watering_can");
|
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.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 () => {
|
Deno.test("chests and furnaces from a world saved before they were mod code keep their items and keep smelting", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user