Server authority
This commit is contained in:
@@ -4,7 +4,7 @@ Status: **draft, format_version 1**. Nothing here is implemented yet, see [Imple
|
|||||||
|
|
||||||
Mods add blocks, items, textures, recipes, world generation, game logic and GUIs to bworld. A mod is installed **on the
|
Mods add blocks, items, textures, recipes, world generation, game logic and GUIs to bworld. A mod is installed **on the
|
||||||
server**. Players who join download its client code, data and textures from the server automatically, so they don't
|
server**. Players who join download its client code, data and textures from the server automatically, so they don't
|
||||||
install anything themselves. Single player works the same way, with a server running inside the browser.
|
install anything themselves. bworld is always played on a server; there is no single player mode.
|
||||||
|
|
||||||
This borrows from Minecraft Bedrock add-ons: data lives in JSON, blocks get behavior from named custom components, and
|
This borrows from Minecraft Bedrock add-ons: data lives in JSON, blocks get behavior from named custom components, and
|
||||||
scripts use before/after events. Bedrock servers also push resource packs to joining players, and their server scripts
|
scripts use before/after events. Bedrock servers also push resource packs to joining players, and their server scripts
|
||||||
@@ -27,7 +27,6 @@ too, since the client is already a web page.
|
|||||||
- [Mod channels](#mod-channels)
|
- [Mod channels](#mod-channels)
|
||||||
- [World generation](#world-generation)
|
- [World generation](#world-generation)
|
||||||
- [Delivery to clients](#delivery-to-clients)
|
- [Delivery to clients](#delivery-to-clients)
|
||||||
- [Single player](#single-player)
|
|
||||||
- [Security](#security)
|
- [Security](#security)
|
||||||
- [Example mod](#example-mod)
|
- [Example mod](#example-mod)
|
||||||
- [Implementation plan](#implementation-plan)
|
- [Implementation plan](#implementation-plan)
|
||||||
@@ -256,9 +255,8 @@ export function setup(ctx: ServerContext) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Server scripts run in the game server worker. They get the mod API and standard JavaScript, and **no Deno, Node or DOM
|
Server scripts run in the game server worker with **no Deno permissions** (see [Security](#security)). They get the mod
|
||||||
APIs**. That lets the same script run on a dedicated server and in the browser for [single player](#single-player). They
|
API and standard JavaScript, but no file, network or subprocess access; persistent state goes through `ctx.storage`.
|
||||||
have no file or network access; persistent state goes through `ctx.storage`.
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
interface ServerContext {
|
interface ServerContext {
|
||||||
@@ -750,20 +748,6 @@ client server
|
|||||||
- The `protocol` in `hello` is the game's protocol version. A mismatch is rejected before any mod code is downloaded.
|
- The `protocol` in `hello` is the game's protocol version. A mismatch is rejected before any mod code is downloaded.
|
||||||
- Leaving a server reloads the page, so one server's mod code never stays loaded while playing on another.
|
- Leaving a server reloads the page, so one server's mod code never stays loaded while playing on another.
|
||||||
|
|
||||||
## Single player
|
|
||||||
|
|
||||||
Single player runs the same game server inside the browser, like Minecraft's integrated server:
|
|
||||||
|
|
||||||
- The game server core (world, inventories, tile data, mod server scripts) is written against a small host interface for
|
|
||||||
**transport** and **storage**, with no Deno or DOM APIs.
|
|
||||||
- On a dedicated server, the host is `server/main.ts`: WebSocket transport, files for storage. The core runs in a Deno
|
|
||||||
worker.
|
|
||||||
- In single player, the host is the page: `postMessage` transport, IndexedDB for storage. The core runs in a web worker,
|
|
||||||
and `server.js` bundles are loaded from `server_mods/`, which the local build serves only to this page.
|
|
||||||
|
|
||||||
The client uses the same protocol either way, so a mod written and tested in single player behaves the same on a
|
|
||||||
dedicated server.
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
**Client scripts.** Mod client code runs in the game page with the page's full access. How much that matters depends on
|
**Client scripts.** Mod client code runs in the game page with the page's full access. How much that matters depends on
|
||||||
@@ -900,16 +884,17 @@ class SmelterStats implements ModScreen {
|
|||||||
|
|
||||||
## Implementation plan
|
## Implementation plan
|
||||||
|
|
||||||
None of this exists yet. Each step keeps the game working:
|
Steps 1–3 are done; the mod steps (4 onwards) aren't started. Each step keeps the game working:
|
||||||
|
|
||||||
1. **Game server core.** Move `client/generation.ts` to `common/` (it already only needs constants and the rng package)
|
1. **Game server core.** Move `client/generation.ts` to `common/` (it already only needs constants and the rng package)
|
||||||
and have the server generate terrain. Pull world state, chunks, tile data and player inventories into an
|
and have the server generate terrain. Move world state, chunks, tile data and player inventories into a game server
|
||||||
environment-free module behind a transport and storage interface. Run it in a Deno worker from `server/main.ts`.
|
module that runs in a Deno worker, with `server/main.ts` handling HTTP, WebSockets and files.
|
||||||
2. **Server authority.** Change the protocol from "here's the block I changed" to intents (`break_block`, `place_block`,
|
2. **Server authority.** Change the protocol from "here's the block I changed" to intents (`break_block`, `place_block`,
|
||||||
`interact`, `select_slot`, `container_click`). The client keeps showing breaks and places immediately and accepts
|
`interact`, `select_slot`, `container_click`). The client keeps showing breaks and places immediately and accepts
|
||||||
corrections. Move inventories, drops and `/give` to the server. Replace `GuiChest` / `GuiFurnace` with server-synced
|
corrections. Move inventories, drops and `/give` to the server. Replace `GuiChest` / `GuiFurnace` with server-synced
|
||||||
containers.
|
containers.
|
||||||
3. **Integrated server.** Run the same core in a web worker for single player, with IndexedDB storage.
|
3. **Server only.** Remove the offline fallback in `client/main.ts`, which currently starts a local game when it can't
|
||||||
|
reach a server, and show a connection error instead.
|
||||||
4. **Mod loader and build.** Discover mods, check manifests, sort by dependencies, turn JSON into registry entries,
|
4. **Mod loader and build.** Discover mods, check manifests, sort by dependencies, turn JSON into registry entries,
|
||||||
build the combined atlas (textures are currently all named `bworld:<file>`), bundle scripts per side, and write
|
build the combined atlas (textures are currently all named `bworld:<file>`), bundle scripts per side, and write
|
||||||
hashed output to `build/mods/` and `server_mods/`.
|
hashed output to `build/mods/` and `server_mods/`.
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
|
||||||
import { register_block_item } from "$/common/utils.ts";
|
|
||||||
import { Container, Inventory } from "../inventory.ts";
|
|
||||||
import { PlayerComponent } from "../player.ts";
|
|
||||||
import { GuiChest } from "$/client/gui/gui_chest.ts";
|
|
||||||
|
|
||||||
interface TileChestData {
|
|
||||||
inventory: Inventory;
|
|
||||||
}
|
|
||||||
|
|
||||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:chest", {
|
|
||||||
id: "bworld:chest",
|
|
||||||
textures: "bworld:planks",
|
|
||||||
toughness: 8,
|
|
||||||
requires_tool: false,
|
|
||||||
tool_to_break: "axe",
|
|
||||||
drop_table: "bworld:chest",
|
|
||||||
|
|
||||||
has_collision: false,
|
|
||||||
on_interact(dimension, block) {
|
|
||||||
const [player] = dimension.world.get_tag("player")!;
|
|
||||||
const player_component = player.get(PlayerComponent)!;
|
|
||||||
const block_data = dimension.get_block_data<TileChestData>(block.x, block.y, block.z);
|
|
||||||
if (block_data && block_data.data.inventory) {
|
|
||||||
const gui = new GuiChest(block_data.data.inventory, player_component.player_inventory);
|
|
||||||
player_component.screens.push(gui);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
on_create(dimension, block) {
|
|
||||||
dimension.add_block_data({
|
|
||||||
id: block.id,
|
|
||||||
x: block.x,
|
|
||||||
y: block.y,
|
|
||||||
z: block.z,
|
|
||||||
data: {
|
|
||||||
inventory: new Inventory(new Container(9 * 3)),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
register_block_item(block);
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
|
||||||
import { register_block_item } from "$/common/utils.ts";
|
|
||||||
import { PlayerComponent } from "../player.ts";
|
|
||||||
|
|
||||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:dirt", {
|
|
||||||
id: "bworld:dirt",
|
|
||||||
textures: "bworld:dirt",
|
|
||||||
has_collision: false,
|
|
||||||
drop_table: "bworld:dirt",
|
|
||||||
toughness: 2,
|
|
||||||
requires_tool: false,
|
|
||||||
tool_to_break: "shovel",
|
|
||||||
|
|
||||||
on_interact(dimension, block) {
|
|
||||||
const [player] = dimension.world.get_tag("player")!;
|
|
||||||
const player_inventory = player.get(PlayerComponent)!.player_inventory;
|
|
||||||
const maybe_item = player_inventory.container.get_item(player_inventory.hotbar_selected);
|
|
||||||
if (maybe_item && maybe_item.type_id === "bworld:hoe") {
|
|
||||||
dimension.add_block({ x: block.x, y: block.y, z: block.z, id: "bworld:hoed_dirt" });
|
|
||||||
dimension.sync_block(block.x, block.y, block.z);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
register_block_item(block);
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
|
||||||
import { Container, Inventory, ItemStack } from "../inventory.ts";
|
|
||||||
import { PlayerComponent } from "../player.ts";
|
|
||||||
import { GuiFurnace } from "$/client/gui/gui_furnace.ts";
|
|
||||||
import { register_block_item } from "../../common/utils.ts";
|
|
||||||
|
|
||||||
interface FurnaceRecipe {
|
|
||||||
input: string;
|
|
||||||
output: ItemStack;
|
|
||||||
cook_time: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FURNACE_RECIPES: FurnaceRecipe[] = [
|
|
||||||
{
|
|
||||||
input: "bworld:log",
|
|
||||||
output: new ItemStack("bworld:coal", 1),
|
|
||||||
cook_time: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "bworld:coal_ore",
|
|
||||||
output: new ItemStack("bworld:coal", 1),
|
|
||||||
cook_time: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "bworld:iron_ore",
|
|
||||||
output: new ItemStack("bworld:iron_ingot", 1),
|
|
||||||
cook_time: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "bworld:tin_ore",
|
|
||||||
output: new ItemStack("bworld:tin_ingot", 1),
|
|
||||||
cook_time: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "bworld:copper_ore",
|
|
||||||
output: new ItemStack("bworld:copper_ingot", 1),
|
|
||||||
cook_time: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "bworld:gold_ore",
|
|
||||||
output: new ItemStack("bworld:gold_ingot", 1),
|
|
||||||
cook_time: 200,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function get_recipe(input?: ItemStack | undefined): FurnaceRecipe | undefined {
|
|
||||||
if (!input) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return FURNACE_RECIPES.find((r) => r.input === input.type_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function can_craft(container: Container, recipe?: FurnaceRecipe) {
|
|
||||||
if (!recipe) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = container.get_item(2);
|
|
||||||
|
|
||||||
if (!output) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (output.type_id !== recipe.output.type_id) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return output.amount < output.max_amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FUEL_VALUES: Record<string, number> = {
|
|
||||||
"bworld:coal": 1000,
|
|
||||||
"bworld:log": 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
function get_fuel_value(item?: ItemStack | undefined): number {
|
|
||||||
if (!item) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return FUEL_VALUES[item.type_id] ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function has_fuel(container: Container) {
|
|
||||||
return get_fuel_value(container.get_item(1)) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function consume_fuel(container: Container): number {
|
|
||||||
const fuel = container.get_slot(1)!;
|
|
||||||
const value = get_fuel_value(fuel.get_item());
|
|
||||||
|
|
||||||
if (fuel.has_item()) {
|
|
||||||
fuel.amount! -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function craft(container: Container, recipe: FurnaceRecipe) {
|
|
||||||
const input = container.get_item(0)!;
|
|
||||||
const output = container.get_item(2);
|
|
||||||
|
|
||||||
if (!output) {
|
|
||||||
container.set_item(2, recipe.output.clone());
|
|
||||||
} else {
|
|
||||||
output.amount += recipe.output.amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.amount -= 1;
|
|
||||||
container.set_item(0, input.amount > 0 ? input : undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TileChestData {
|
|
||||||
inventory: Inventory;
|
|
||||||
progress: number;
|
|
||||||
progress_max: number;
|
|
||||||
fuel: number;
|
|
||||||
fuel_max: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:furnace", {
|
|
||||||
id: "bworld:furnace",
|
|
||||||
textures: { front: "bworld:furnace", side: "bworld:stone" },
|
|
||||||
has_collision: true,
|
|
||||||
drop_table: "bworld:furnace",
|
|
||||||
toughness: 5,
|
|
||||||
requires_tool: true,
|
|
||||||
tool_to_break: "pickaxe",
|
|
||||||
|
|
||||||
on_interact(dimension, block) {
|
|
||||||
const [player] = dimension.world.get_tag("player")!;
|
|
||||||
const player_component = player.get(PlayerComponent)!;
|
|
||||||
const block_data = dimension.get_block_data<TileChestData>(block.x, block.y, block.z);
|
|
||||||
if (block_data && block_data.data.inventory) {
|
|
||||||
const gui = new GuiFurnace(block_data.data.inventory, player_component.player_inventory, block_data.data);
|
|
||||||
player_component.screens.push(gui);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
on_create(dimension, block) {
|
|
||||||
dimension.add_block_data({
|
|
||||||
id: block.id,
|
|
||||||
x: block.x,
|
|
||||||
y: block.y,
|
|
||||||
z: block.z,
|
|
||||||
data: {
|
|
||||||
inventory: new Inventory(new Container(3)),
|
|
||||||
progress: 0,
|
|
||||||
progress_max: 0,
|
|
||||||
fuel: 0,
|
|
||||||
fuel_max: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
on_break() {
|
|
||||||
// TODO: remove block data
|
|
||||||
},
|
|
||||||
on_tick(dimension, block) {
|
|
||||||
const block_data = dimension.get_block_data<TileChestData>(block.x, block.y, block.z);
|
|
||||||
if (!block_data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = block_data.data;
|
|
||||||
const container = data.inventory.container;
|
|
||||||
|
|
||||||
const input = container.get_item(0);
|
|
||||||
const recipe = get_recipe(input);
|
|
||||||
|
|
||||||
// burn fuel
|
|
||||||
if (data.fuel > 0) {
|
|
||||||
data.fuel -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!can_craft(container, recipe)) {
|
|
||||||
data.progress = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// refuel
|
|
||||||
if (data.fuel === 0 && has_fuel(container)) {
|
|
||||||
data.fuel = consume_fuel(container);
|
|
||||||
data.fuel_max = data.fuel;
|
|
||||||
}
|
|
||||||
|
|
||||||
// cook
|
|
||||||
if (data.fuel > 0 && recipe) {
|
|
||||||
data.progress_max = recipe.cook_time;
|
|
||||||
data.progress += 1;
|
|
||||||
|
|
||||||
if (data.progress >= recipe.cook_time) {
|
|
||||||
data.progress = 0;
|
|
||||||
craft(container, recipe);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
register_block_item(block);
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
|
||||||
import { PlayerComponent } from "../player.ts";
|
|
||||||
|
|
||||||
EverythingRegistry.register<BlockRegistry>("blocks", "bworld:grass", {
|
|
||||||
id: "bworld:grass",
|
|
||||||
textures: { top: "bworld:grass_top", bottom: "bworld:dirt", "side": "bworld:grass_side" },
|
|
||||||
has_collision: false,
|
|
||||||
drop_table: "bworld:dirt",
|
|
||||||
toughness: 2,
|
|
||||||
requires_tool: false,
|
|
||||||
tool_to_break: "shovel",
|
|
||||||
|
|
||||||
on_interact(dimension, block) {
|
|
||||||
const [player] = dimension.world.get_tag("player")!;
|
|
||||||
const player_inventory = player.get(PlayerComponent)!.player_inventory;
|
|
||||||
const item = player_inventory.container.get_item(player_inventory.hotbar_selected);
|
|
||||||
if (item?.type_id === "bworld:hoe") {
|
|
||||||
block.id = "bworld:hoed_dirt";
|
|
||||||
dimension.add_block(block);
|
|
||||||
dimension.sync_block(block.x, block.y, block.z);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -8,7 +8,6 @@ import { UIRenderSystem } from "$/client/systems/ui_render_system.ts";
|
|||||||
import { create_main_menu } from "./main_menu.ts";
|
import { create_main_menu } from "./main_menu.ts";
|
||||||
import { start_game } from "./game.ts";
|
import { start_game } from "./game.ts";
|
||||||
import { canvas, resize_canvas } from "./renderer/mod.ts";
|
import { canvas, resize_canvas } from "./renderer/mod.ts";
|
||||||
import { DimensionLogicSystem } from "./systems/dimension_logic.ts";
|
|
||||||
import { Dimension } from "./components/dimension.ts";
|
import { Dimension } from "./components/dimension.ts";
|
||||||
import { GuiRenderSystem, GuiTickSystem } from "./gui/gui_systems.ts";
|
import { GuiRenderSystem, GuiTickSystem } from "./gui/gui_systems.ts";
|
||||||
import { WorldGenerationSystem } from "./systems/world_generation_system.ts";
|
import { WorldGenerationSystem } from "./systems/world_generation_system.ts";
|
||||||
@@ -27,11 +26,10 @@ export class ClientWorld extends World {
|
|||||||
|
|
||||||
dimension!: Dimension;
|
dimension!: Dimension;
|
||||||
|
|
||||||
// undefined when playing single player
|
connection: Connection;
|
||||||
connection?: Connection;
|
|
||||||
chat_log: ChatLine[] = [];
|
chat_log: ChatLine[] = [];
|
||||||
|
|
||||||
constructor(connection?: Connection) {
|
constructor(connection: Connection) {
|
||||||
super("game");
|
super("game");
|
||||||
this.connection = connection;
|
this.connection = connection;
|
||||||
|
|
||||||
@@ -55,7 +53,6 @@ export class ClientWorld extends World {
|
|||||||
this.add_system(new GuiTickSystem(), "game");
|
this.add_system(new GuiTickSystem(), "game");
|
||||||
this.add_system(new PlayerControlsSystem(), "game");
|
this.add_system(new PlayerControlsSystem(), "game");
|
||||||
this.add_system(new WorldGenerationSystem(), "game");
|
this.add_system(new WorldGenerationSystem(), "game");
|
||||||
this.add_system(new DimensionLogicSystem(), "game");
|
|
||||||
this.add_system(new CollisionSystem(), "game");
|
this.add_system(new CollisionSystem(), "game");
|
||||||
this.add_system(new MovementSystem(), "game");
|
this.add_system(new MovementSystem(), "game");
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Component } from "$/common/ecs/mod.ts";
|
import { Component } from "$/common/ecs/mod.ts";
|
||||||
|
import { chunk_key } from "$/common/utils.ts";
|
||||||
import { AIR_ID, BlockChange } from "$/common/protocol.ts";
|
import { AIR_ID, BlockChange } from "$/common/protocol.ts";
|
||||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||||
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from "../../common/constants.ts";
|
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from "../../common/constants.ts";
|
||||||
@@ -6,19 +7,9 @@ import { AssetManager } from "../assets.ts";
|
|||||||
import { ClientWorld } from "../client_world.ts";
|
import { ClientWorld } from "../client_world.ts";
|
||||||
import { ChunkWorkerPool } from "../chunk_workers.ts";
|
import { ChunkWorkerPool } from "../chunk_workers.ts";
|
||||||
import type { FromChunkWorker } from "../workers/chunk_messages.ts";
|
import type { FromChunkWorker } from "../workers/chunk_messages.ts";
|
||||||
import { ItemStack } from "../inventory.ts";
|
|
||||||
import { PlayerComponent } from "../player.ts";
|
|
||||||
import { create_vertex_buffer, destroy_vertex_buffer, Texture } from "../renderer/mod.ts";
|
import { create_vertex_buffer, destroy_vertex_buffer, Texture } from "../renderer/mod.ts";
|
||||||
import { Camera } from "./camera.ts";
|
import { Camera } from "./camera.ts";
|
||||||
|
|
||||||
export interface BlockData<T = unknown> {
|
|
||||||
id: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
z: number;
|
|
||||||
data: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Block {
|
export interface Block {
|
||||||
id: string;
|
id: string;
|
||||||
x: number;
|
x: number;
|
||||||
@@ -32,7 +23,6 @@ export interface Chunk {
|
|||||||
x: number;
|
x: number;
|
||||||
z: number;
|
z: number;
|
||||||
blocks: Uint32Array;
|
blocks: Uint32Array;
|
||||||
blocks_data: BlockData[];
|
|
||||||
generated: boolean;
|
generated: boolean;
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
// bumped on every mesh request so late results from older requests get ignored
|
// bumped on every mesh request so late results from older requests get ignored
|
||||||
@@ -43,10 +33,7 @@ export interface Chunk {
|
|||||||
transparent_vertex_count?: number;
|
transparent_vertex_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// numeric so looking chunks up doesnt allocate a string every time, fine for |x|, |z| < 32768
|
export { chunk_key };
|
||||||
export function chunk_key(x: number, z: number) {
|
|
||||||
return (x + 32768) * 65536 + (z + 32768);
|
|
||||||
}
|
|
||||||
|
|
||||||
const NEIGHBOR_OFFSETS = [[-1, 0], [1, 0], [0, -1], [0, 1]] as const;
|
const NEIGHBOR_OFFSETS = [[-1, 0], [1, 0], [0, -1], [0, 1]] as const;
|
||||||
|
|
||||||
@@ -99,7 +86,6 @@ export class Dimension extends Component {
|
|||||||
x,
|
x,
|
||||||
z,
|
z,
|
||||||
blocks,
|
blocks,
|
||||||
blocks_data: [],
|
|
||||||
dirty: true,
|
dirty: true,
|
||||||
generated: false,
|
generated: false,
|
||||||
mesh_version: 0,
|
mesh_version: 0,
|
||||||
@@ -120,7 +106,7 @@ export class Dimension extends Component {
|
|||||||
chunk = this.add_chunk(block_chunk_x, block_chunk_z);
|
chunk = this.add_chunk(block_chunk_x, block_chunk_z);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [nid, block_info] = EverythingRegistry.get_full<BlockRegistry>("blocks", block.id)!;
|
const nid = EverythingRegistry.get_id("blocks", block.id)!;
|
||||||
|
|
||||||
const lx = block.x - block_chunk_x * CHUNK_SIZE;
|
const lx = block.x - block_chunk_x * CHUNK_SIZE;
|
||||||
const lz = block.z - block_chunk_z * CHUNK_SIZE;
|
const lz = block.z - block_chunk_z * CHUNK_SIZE;
|
||||||
@@ -130,9 +116,7 @@ export class Dimension extends Component {
|
|||||||
|
|
||||||
chunk.blocks[index] = nid;
|
chunk.blocks[index] = nid;
|
||||||
chunk.dirty = true;
|
chunk.dirty = true;
|
||||||
if (block_info?.on_create) {
|
this.#mark_border_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz);
|
||||||
block_info?.on_create(this, block);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get_block(x: number, y: number, z: number) {
|
get_block(x: number, y: number, z: number) {
|
||||||
@@ -154,33 +138,8 @@ export class Dimension extends Component {
|
|||||||
return chunk.blocks[index] & ID_MASK;
|
return chunk.blocks[index] & ID_MASK;
|
||||||
}
|
}
|
||||||
|
|
||||||
add_block_data(block_data: BlockData) {
|
// only changes what this client shows, drops and everything else happen on the server
|
||||||
const block_chunk_x = Math.floor(block_data.x / CHUNK_SIZE);
|
break_block(x: number, y: number, z: number) {
|
||||||
const block_chunk_z = Math.floor(block_data.z / CHUNK_SIZE);
|
|
||||||
let chunk = this.get_chunk(block_chunk_x, block_chunk_z);
|
|
||||||
if (!chunk) {
|
|
||||||
chunk = this.add_chunk(block_chunk_x, block_chunk_z);
|
|
||||||
}
|
|
||||||
|
|
||||||
chunk.blocks_data.push(block_data);
|
|
||||||
}
|
|
||||||
|
|
||||||
get_block_data<T>(x: number, y: number, z: number) {
|
|
||||||
const chunk_x = Math.floor(x / CHUNK_SIZE);
|
|
||||||
const chunk_z = Math.floor(z / CHUNK_SIZE);
|
|
||||||
|
|
||||||
const chunk = this.get_chunk(chunk_x, chunk_z);
|
|
||||||
|
|
||||||
if (!chunk) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunk.blocks_data.find((block_data) =>
|
|
||||||
block_data.x === x && block_data.y === y && block_data.z === z
|
|
||||||
) as BlockData<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
break_block(x: number, y: number, z: number, drop_item: boolean = true) {
|
|
||||||
const block_chunk_x = Math.floor(x / CHUNK_SIZE);
|
const block_chunk_x = Math.floor(x / CHUNK_SIZE);
|
||||||
const block_chunk_z = Math.floor(z / CHUNK_SIZE);
|
const block_chunk_z = Math.floor(z / CHUNK_SIZE);
|
||||||
const chunk = this.get_chunk(block_chunk_x, block_chunk_z);
|
const chunk = this.get_chunk(block_chunk_x, block_chunk_z);
|
||||||
@@ -188,14 +147,6 @@ export class Dimension extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", this.get_block(x, y, z))!;
|
|
||||||
|
|
||||||
if (drop_item && block_info.drop_table) {
|
|
||||||
const [player] = this.world.get_tag("player")!;
|
|
||||||
const player_component = player.get(PlayerComponent)!;
|
|
||||||
player_component.player_inventory.container.add_item(new ItemStack(block_info.drop_table));
|
|
||||||
}
|
|
||||||
|
|
||||||
const lx = x - block_chunk_x * CHUNK_SIZE;
|
const lx = x - block_chunk_x * CHUNK_SIZE;
|
||||||
const lz = z - block_chunk_z * CHUNK_SIZE;
|
const lz = z - block_chunk_z * CHUNK_SIZE;
|
||||||
const ly = y;
|
const ly = y;
|
||||||
@@ -203,7 +154,11 @@ export class Dimension extends Component {
|
|||||||
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
|
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
|
||||||
chunk.blocks[index] = AIR;
|
chunk.blocks[index] = AIR;
|
||||||
chunk.dirty = true;
|
chunk.dirty = true;
|
||||||
|
this.#mark_border_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz);
|
||||||
|
}
|
||||||
|
|
||||||
|
// a block on a chunk's edge changes which faces its neighbor shows
|
||||||
|
#mark_border_neighbors_dirty(block_chunk_x: number, block_chunk_z: number, lx: number, lz: number) {
|
||||||
if (lx === 0) {
|
if (lx === 0) {
|
||||||
const n = this.get_chunk(block_chunk_x - 1, block_chunk_z);
|
const n = this.get_chunk(block_chunk_x - 1, block_chunk_z);
|
||||||
if (n) {
|
if (n) {
|
||||||
@@ -226,10 +181,6 @@ export class Dimension extends Component {
|
|||||||
n.dirty = true;
|
n.dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (block_info?.on_break) {
|
|
||||||
block_info.on_break(this, { x, y, z, id: block_info.id });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
index_to_xyz(index: number) {
|
index_to_xyz(index: number) {
|
||||||
@@ -252,18 +203,7 @@ export class Dimension extends Component {
|
|||||||
chunk_changes.set(`${x},${y},${z}`, [x, y, z, id]);
|
chunk_changes.set(`${x},${y},${z}`, [x, y, z, id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// call after the player changes a block so it gets saved and sent to the server
|
// set a block the server told us about
|
||||||
sync_block(x: number, y: number, z: number) {
|
|
||||||
const numeric_id = this.get_block(x, y, z);
|
|
||||||
const id = numeric_id === AIR ? AIR_ID : EverythingRegistry.get_by_id<BlockRegistry>("blocks", numeric_id)?.id;
|
|
||||||
if (!id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.record_change(x, y, z, id);
|
|
||||||
this.world.connection?.send({ type: "set_block", x, y, z, id });
|
|
||||||
}
|
|
||||||
|
|
||||||
// set a block without drops or syncing, for changes that came from somewhere else
|
|
||||||
apply_change(x: number, y: number, z: number, id: string) {
|
apply_change(x: number, y: number, z: number, id: string) {
|
||||||
const chunk = this.get_chunk(Math.floor(x / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE));
|
const chunk = this.get_chunk(Math.floor(x / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE));
|
||||||
if (!chunk || !chunk.generated) {
|
if (!chunk || !chunk.generated) {
|
||||||
@@ -273,7 +213,7 @@ export class Dimension extends Component {
|
|||||||
const current = this.get_block(x, y, z);
|
const current = this.get_block(x, y, z);
|
||||||
if (id === AIR_ID) {
|
if (id === AIR_ID) {
|
||||||
if (current !== AIR) {
|
if (current !== AIR) {
|
||||||
this.break_block(x, y, z, false);
|
this.break_block(x, y, z);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -423,7 +363,8 @@ export class Dimension extends Component {
|
|||||||
chunk.transparent_vertex_count = message.transparent_count / 9;
|
chunk.transparent_vertex_count = message.transparent_count / 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
// sets a block from generation, no hooks, makes a placeholder chunk like add_block does
|
// a neighbor's leaves, only fill air so the result doesn't depend on which chunk loaded first.
|
||||||
|
// the server builds chunks the same way (server/game/world.ts)
|
||||||
#set_block_raw(x: number, y: number, z: number, nid: number) {
|
#set_block_raw(x: number, y: number, z: number, nid: number) {
|
||||||
if (y < 0 || y >= CHUNK_HEIGHT) {
|
if (y < 0 || y >= CHUNK_HEIGHT) {
|
||||||
return;
|
return;
|
||||||
@@ -433,9 +374,12 @@ export class Dimension extends Component {
|
|||||||
const chunk = this.get_chunk(chunk_x, chunk_z) ?? this.add_chunk(chunk_x, chunk_z);
|
const chunk = this.get_chunk(chunk_x, chunk_z) ?? this.add_chunk(chunk_x, chunk_z);
|
||||||
const lx = x - chunk_x * CHUNK_SIZE;
|
const lx = x - chunk_x * CHUNK_SIZE;
|
||||||
const lz = z - chunk_z * CHUNK_SIZE;
|
const lz = z - chunk_z * CHUNK_SIZE;
|
||||||
chunk.blocks[y * CHUNK_AREA + lz * CHUNK_SIZE + lx] = nid;
|
const index = y * CHUNK_AREA + lz * CHUNK_SIZE + lx;
|
||||||
|
if (chunk.blocks[index] === AIR) {
|
||||||
|
chunk.blocks[index] = nid;
|
||||||
chunk.dirty = true;
|
chunk.dirty = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
delete_chunk_mesh(chunk: Chunk) {
|
delete_chunk_mesh(chunk: Chunk) {
|
||||||
if (chunk.opaque_vertex_buffer) {
|
if (chunk.opaque_vertex_buffer) {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
// replaces the game with a message, for when it can't go on (no server, lost connection)
|
||||||
|
export function show_fatal_error(message: string) {
|
||||||
|
if (stopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopped = true;
|
||||||
|
document.exitPointerLock?.();
|
||||||
|
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.style.cssText =
|
||||||
|
"position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;" +
|
||||||
|
"gap:12px;background:rgba(0,0,0,0.85);color:white;font:20px system-ui,sans-serif;text-align:center;";
|
||||||
|
const text = document.createElement("div");
|
||||||
|
text.textContent = message;
|
||||||
|
const hint = document.createElement("div");
|
||||||
|
hint.textContent = "Reload the page to try again.";
|
||||||
|
hint.style.cssText = "font-size:14px;opacity:0.7;";
|
||||||
|
overlay.append(text, hint);
|
||||||
|
document.body.append(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function is_stopped() {
|
||||||
|
return stopped;
|
||||||
|
}
|
||||||
+2
-2
@@ -13,8 +13,8 @@ export function start_game(world: ClientWorld) {
|
|||||||
|
|
||||||
const dimension = new Entity("dimension");
|
const dimension = new Entity("dimension");
|
||||||
world.dimension?.dispose();
|
world.dimension?.dispose();
|
||||||
world.dimension = new Dimension(world, world.connection?.seed);
|
world.dimension = new Dimension(world, world.connection.seed);
|
||||||
for (const [x, y, z, id] of world.connection?.initial_changes ?? []) {
|
for (const [x, y, z, id] of world.connection.initial_changes) {
|
||||||
world.dimension.record_change(x, y, z, id);
|
world.dimension.record_change(x, y, z, id);
|
||||||
}
|
}
|
||||||
dimension.add(world.dimension);
|
dimension.add(world.dimension);
|
||||||
|
|||||||
+2
-27
@@ -3,7 +3,6 @@ import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mo
|
|||||||
import { InputManager } from "../input_manager.ts";
|
import { InputManager } from "../input_manager.ts";
|
||||||
import { ClientWorld } from "../client_world.ts";
|
import { ClientWorld } from "../client_world.ts";
|
||||||
import { PlayerComponent } from "../player.ts";
|
import { PlayerComponent } from "../player.ts";
|
||||||
import { ItemStack } from "../inventory.ts";
|
|
||||||
import { MAX_CHAT_LENGTH } from "$/common/protocol.ts";
|
import { MAX_CHAT_LENGTH } from "$/common/protocol.ts";
|
||||||
import { render_chat_log } from "../systems/rendering/network.ts";
|
import { render_chat_log } from "../systems/rendering/network.ts";
|
||||||
|
|
||||||
@@ -102,14 +101,9 @@ export class GuiChat extends GuiScreen {
|
|||||||
override on_close(): void {}
|
override on_close(): void {}
|
||||||
|
|
||||||
submit() {
|
submit() {
|
||||||
if (this.text_typed.startsWith("/")) {
|
// commands like /give run on the server too
|
||||||
this.command();
|
if (this.text_typed.trim().length > 0) {
|
||||||
} else if (this.text_typed.trim().length > 0) {
|
|
||||||
if (this.world.connection) {
|
|
||||||
this.world.connection.send({ type: "chat", text: this.text_typed });
|
this.world.connection.send({ type: "chat", text: this.text_typed });
|
||||||
} else {
|
|
||||||
this.world.add_chat(this.text_typed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.text_typed = "";
|
this.text_typed = "";
|
||||||
|
|
||||||
@@ -119,23 +113,4 @@ export class GuiChat extends GuiScreen {
|
|||||||
player_component.pop_screen();
|
player_component.pop_screen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
command() {
|
|
||||||
if (this.text_typed.startsWith("/give")) {
|
|
||||||
let [_, item_id, quantity] = this.text_typed.split(" ");
|
|
||||||
|
|
||||||
if (!item_id.includes(":")) {
|
|
||||||
item_id = `bworld:${item_id}`;
|
|
||||||
}
|
|
||||||
if (quantity === undefined) {
|
|
||||||
quantity = "1";
|
|
||||||
}
|
|
||||||
|
|
||||||
const [player] = this.world.get_tag("player")!;
|
|
||||||
const player_component = player.get(PlayerComponent);
|
|
||||||
if (player_component) {
|
|
||||||
player_component.player_inventory.container.add_item(new ItemStack(item_id, Number(quantity)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import { Inventory, PlayerInventory } from "../inventory.ts";
|
|
||||||
import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
|
|
||||||
import { canvas, draw_rect, Texture } from "$/client/renderer/mod.ts";
|
|
||||||
import { AssetManager } from "../assets.ts";
|
|
||||||
import { SLOT_SIZE } from "../../common/constants.ts";
|
|
||||||
import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
|
|
||||||
|
|
||||||
const PADDING = 10;
|
|
||||||
|
|
||||||
export class GuiChest extends GuiInventoryScreen {
|
|
||||||
override inventory_width = PADDING * 2 + SLOT_SIZE * 9;
|
|
||||||
override inventory_height = PADDING * 4 + SLOT_SIZE * 7;
|
|
||||||
|
|
||||||
constructor(inventory: Inventory, player_inventory: PlayerInventory) {
|
|
||||||
super(inventory, player_inventory, undefined);
|
|
||||||
|
|
||||||
add_player_hotbar(this, player_inventory, PADDING, PADDING);
|
|
||||||
add_player_inventory(this, player_inventory, PADDING, PADDING * 2 + SLOT_SIZE);
|
|
||||||
|
|
||||||
const chest_x = PADDING;
|
|
||||||
const chest_y = PADDING * 3 + SLOT_SIZE * 4;
|
|
||||||
|
|
||||||
for (let i = 0; i < 3; ++i) {
|
|
||||||
for (let l = 0; l < 9; ++l) {
|
|
||||||
this.slots.push(
|
|
||||||
new Slot(
|
|
||||||
this.inventory,
|
|
||||||
l + i * 9,
|
|
||||||
chest_x + l * SLOT_SIZE,
|
|
||||||
chest_y + i * SLOT_SIZE,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override on_render(): void {
|
|
||||||
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
|
|
||||||
|
|
||||||
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
|
||||||
|
|
||||||
draw_nine_slice(
|
|
||||||
ui,
|
|
||||||
160,
|
|
||||||
0,
|
|
||||||
16,
|
|
||||||
16,
|
|
||||||
4,
|
|
||||||
4,
|
|
||||||
4,
|
|
||||||
4,
|
|
||||||
this.x,
|
|
||||||
this.y,
|
|
||||||
this.inventory_width,
|
|
||||||
this.inventory_height,
|
|
||||||
);
|
|
||||||
|
|
||||||
super.on_render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
|
||||||
|
import { canvas, draw_rect, draw_texture_region, Texture } from "$/client/renderer/mod.ts";
|
||||||
|
import { AssetManager } from "../assets.ts";
|
||||||
|
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
|
||||||
|
import { ClientMessage, ScreenLayout } from "$/common/protocol.ts";
|
||||||
|
import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
|
||||||
|
import { get_sprite_region } from "$/client/sprites.ts";
|
||||||
|
import { ClientInventories } from "../inventory.ts";
|
||||||
|
|
||||||
|
const PADDING = 10;
|
||||||
|
|
||||||
|
// a screen opened by the server (chest, furnace, ...), drawn from the layout it sent
|
||||||
|
export class GuiContainer extends GuiInventoryScreen {
|
||||||
|
layout: ScreenLayout;
|
||||||
|
properties: Record<string, number>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
inventories: ClientInventories,
|
||||||
|
send: (message: ClientMessage) => void,
|
||||||
|
layout: ScreenLayout,
|
||||||
|
properties: Record<string, number>,
|
||||||
|
) {
|
||||||
|
super(inventories, send);
|
||||||
|
this.layout = layout;
|
||||||
|
this.properties = properties;
|
||||||
|
|
||||||
|
this.inventory_width = PADDING * 2 + SLOT_SIZE * 9;
|
||||||
|
this.inventory_height = PADDING * 4 + SLOT_SIZE * (4 + layout.rows);
|
||||||
|
|
||||||
|
add_player_hotbar(this, PADDING, PADDING);
|
||||||
|
add_player_inventory(this, PADDING, PADDING * 2 + SLOT_SIZE);
|
||||||
|
|
||||||
|
const area_y = PADDING * 3 + SLOT_SIZE * 4;
|
||||||
|
for (const slot of layout.slots) {
|
||||||
|
this.slots.push(
|
||||||
|
new Slot("screen", slot.index, PADDING + slot.x * SLOT_SIZE, area_y + slot.y * SLOT_SIZE, slot.output),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override on_render(): void {
|
||||||
|
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
|
||||||
|
|
||||||
|
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
||||||
|
|
||||||
|
draw_nine_slice(
|
||||||
|
ui,
|
||||||
|
160,
|
||||||
|
0,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
this.x,
|
||||||
|
this.y,
|
||||||
|
this.inventory_width,
|
||||||
|
this.inventory_height,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.draw_bars();
|
||||||
|
|
||||||
|
super.on_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_bars() {
|
||||||
|
const atlas = AssetManager.instance.get<Texture>("bworld:textures");
|
||||||
|
const area_y = PADDING * 3 + SLOT_SIZE * 4;
|
||||||
|
|
||||||
|
for (const bar of this.layout.bars) {
|
||||||
|
const x = this.x + PADDING + bar.x * SLOT_SIZE;
|
||||||
|
const y = this.y + area_y + bar.y * SLOT_SIZE;
|
||||||
|
const max = this.properties[bar.max] ?? 0;
|
||||||
|
const pct = max > 0 ? Math.max(0, Math.min(1, (this.properties[bar.value] ?? 0) / max)) : 0;
|
||||||
|
|
||||||
|
const empty = get_sprite_region(bar.empty_texture);
|
||||||
|
draw_texture_region(
|
||||||
|
atlas,
|
||||||
|
empty.x * TEXTURE_SIZE,
|
||||||
|
empty.y * TEXTURE_SIZE,
|
||||||
|
TEXTURE_SIZE,
|
||||||
|
TEXTURE_SIZE,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
SLOT_SIZE,
|
||||||
|
SLOT_SIZE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const full = get_sprite_region(bar.full_texture);
|
||||||
|
if (bar.direction === "right") {
|
||||||
|
draw_texture_region(
|
||||||
|
atlas,
|
||||||
|
full.x * TEXTURE_SIZE,
|
||||||
|
full.y * TEXTURE_SIZE,
|
||||||
|
TEXTURE_SIZE * pct,
|
||||||
|
TEXTURE_SIZE,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
SLOT_SIZE * pct,
|
||||||
|
SLOT_SIZE,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// fills from the bottom up
|
||||||
|
draw_texture_region(
|
||||||
|
atlas,
|
||||||
|
full.x * TEXTURE_SIZE,
|
||||||
|
full.y * TEXTURE_SIZE + TEXTURE_SIZE * (1 - pct),
|
||||||
|
TEXTURE_SIZE,
|
||||||
|
TEXTURE_SIZE * pct,
|
||||||
|
x,
|
||||||
|
y + SLOT_SIZE * (1 - pct),
|
||||||
|
SLOT_SIZE,
|
||||||
|
SLOT_SIZE * pct,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import { Inventory, PlayerInventory } from "../inventory.ts";
|
|
||||||
import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
|
|
||||||
import { canvas, draw_rect, draw_texture_region, Texture } from "$/client/renderer/mod.ts";
|
|
||||||
import { AssetManager } from "../assets.ts";
|
|
||||||
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
|
|
||||||
import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
|
|
||||||
import { get_sprite_region } from "$/common/utils.ts";
|
|
||||||
|
|
||||||
const PADDING = 10;
|
|
||||||
|
|
||||||
// crazy this is how i found out interface X {} is diffrent then type X = {}
|
|
||||||
type TileChestData = {
|
|
||||||
inventory: Inventory;
|
|
||||||
progress: number;
|
|
||||||
progress_max: number;
|
|
||||||
fuel: number;
|
|
||||||
fuel_max: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class GuiFurnace extends GuiInventoryScreen<Inventory, TileChestData> {
|
|
||||||
override inventory_width = PADDING * 2 + SLOT_SIZE * 9;
|
|
||||||
override inventory_height = PADDING * 4 + SLOT_SIZE * 7;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
inventory: Inventory,
|
|
||||||
player_inventory: PlayerInventory,
|
|
||||||
properties: TileChestData,
|
|
||||||
) {
|
|
||||||
super(inventory, player_inventory, properties);
|
|
||||||
|
|
||||||
add_player_hotbar(this, player_inventory, PADDING, PADDING);
|
|
||||||
add_player_inventory(this, player_inventory, PADDING, PADDING * 2 + SLOT_SIZE);
|
|
||||||
|
|
||||||
const furnace_y = PADDING * 3 + SLOT_SIZE * 4;
|
|
||||||
|
|
||||||
this.slots.push(
|
|
||||||
new Slot(
|
|
||||||
this.inventory,
|
|
||||||
0,
|
|
||||||
this.inventory_width / 2 - SLOT_SIZE,
|
|
||||||
furnace_y,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
this.slots.push(
|
|
||||||
new Slot(
|
|
||||||
this.inventory,
|
|
||||||
1,
|
|
||||||
this.inventory_width / 2 - SLOT_SIZE,
|
|
||||||
furnace_y + SLOT_SIZE * 2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
this.slots.push(
|
|
||||||
new Slot(
|
|
||||||
this.inventory,
|
|
||||||
2,
|
|
||||||
this.inventory_width / 2 + SLOT_SIZE,
|
|
||||||
furnace_y + SLOT_SIZE,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
override on_render(): void {
|
|
||||||
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
|
|
||||||
|
|
||||||
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
|
||||||
|
|
||||||
draw_nine_slice(
|
|
||||||
ui,
|
|
||||||
160,
|
|
||||||
0,
|
|
||||||
16,
|
|
||||||
16,
|
|
||||||
4,
|
|
||||||
4,
|
|
||||||
4,
|
|
||||||
4,
|
|
||||||
this.x,
|
|
||||||
this.y,
|
|
||||||
this.inventory_width,
|
|
||||||
this.inventory_height,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.draw_fire();
|
|
||||||
this.draw_arrow();
|
|
||||||
|
|
||||||
super.on_render();
|
|
||||||
}
|
|
||||||
|
|
||||||
override handle_left_click(slot: Slot): void {
|
|
||||||
if (slot.inventory === this.inventory && slot.index === 2) {
|
|
||||||
if (this.inventory.container.get_item(2)) {
|
|
||||||
this.pickup();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
super.handle_left_click(slot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override handle_right_click(slot: Slot): void {
|
|
||||||
if (slot.inventory === this.inventory && slot.index === 2) {
|
|
||||||
if (this.inventory.container.get_item(2)) {
|
|
||||||
this.pickup();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
super.handle_right_click(slot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pickup() {
|
|
||||||
const holding_item = this.player_inventory.holding_item;
|
|
||||||
const item = this.inventory.container.get_item(2)!;
|
|
||||||
|
|
||||||
if (holding_item) {
|
|
||||||
if (holding_item.type_id === item.type_id) {
|
|
||||||
const items_left = holding_item.max_amount - holding_item.amount;
|
|
||||||
if (items_left >= item.amount) {
|
|
||||||
holding_item.amount += item.amount;
|
|
||||||
this.inventory.container.set_item(2, undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.player_inventory.holding_item = item;
|
|
||||||
this.inventory.container.set_item(2, undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
draw_fire() {
|
|
||||||
const atlas = AssetManager.instance.get<Texture>("bworld:textures");
|
|
||||||
|
|
||||||
const fire_empty_region = get_sprite_region("bworld:fire_empty");
|
|
||||||
const fire_full_region = get_sprite_region("bworld:fire_full");
|
|
||||||
|
|
||||||
const fuel_pct = Math.max(0, Math.min(1, this.properties.fuel / this.properties.fuel_max));
|
|
||||||
const full_height = SLOT_SIZE * fuel_pct;
|
|
||||||
|
|
||||||
const furnace_y = PADDING * 3 + SLOT_SIZE * 4;
|
|
||||||
|
|
||||||
draw_texture_region(
|
|
||||||
atlas,
|
|
||||||
fire_empty_region.x * TEXTURE_SIZE,
|
|
||||||
fire_empty_region.y * TEXTURE_SIZE,
|
|
||||||
TEXTURE_SIZE,
|
|
||||||
TEXTURE_SIZE,
|
|
||||||
this.x + this.inventory_width / 2 - SLOT_SIZE,
|
|
||||||
this.y + furnace_y + SLOT_SIZE,
|
|
||||||
SLOT_SIZE,
|
|
||||||
SLOT_SIZE,
|
|
||||||
);
|
|
||||||
|
|
||||||
const fire_draw_x = this.x + this.inventory_width / 2 - SLOT_SIZE;
|
|
||||||
const fire_draw_y = this.y + furnace_y + SLOT_SIZE;
|
|
||||||
|
|
||||||
const fire_src_height = TEXTURE_SIZE * fuel_pct;
|
|
||||||
|
|
||||||
const fire_src_y_offset = TEXTURE_SIZE - fire_src_height;
|
|
||||||
const fire_dst_y_offset = SLOT_SIZE - full_height;
|
|
||||||
|
|
||||||
draw_texture_region(
|
|
||||||
atlas,
|
|
||||||
fire_full_region.x * TEXTURE_SIZE,
|
|
||||||
fire_full_region.y * TEXTURE_SIZE + fire_src_y_offset,
|
|
||||||
TEXTURE_SIZE,
|
|
||||||
fire_src_height,
|
|
||||||
fire_draw_x,
|
|
||||||
fire_draw_y + fire_dst_y_offset,
|
|
||||||
SLOT_SIZE,
|
|
||||||
full_height,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
draw_arrow() {
|
|
||||||
const atlas = AssetManager.instance.get<Texture>("bworld:textures");
|
|
||||||
|
|
||||||
const arrow_empty_region = get_sprite_region("bworld:arrow_empty");
|
|
||||||
const arrow_full_region = get_sprite_region("bworld:arrow_full");
|
|
||||||
|
|
||||||
const progress_pct = Math.max(0, Math.min(1, this.properties.progress / this.properties.progress_max));
|
|
||||||
const full_width = SLOT_SIZE * progress_pct;
|
|
||||||
|
|
||||||
const furnace_y = PADDING * 3 + SLOT_SIZE * 4;
|
|
||||||
|
|
||||||
draw_texture_region(
|
|
||||||
atlas,
|
|
||||||
arrow_empty_region.x * TEXTURE_SIZE,
|
|
||||||
arrow_empty_region.y * TEXTURE_SIZE,
|
|
||||||
TEXTURE_SIZE,
|
|
||||||
TEXTURE_SIZE,
|
|
||||||
this.x + this.inventory_width / 2,
|
|
||||||
this.y + furnace_y + SLOT_SIZE,
|
|
||||||
SLOT_SIZE,
|
|
||||||
SLOT_SIZE,
|
|
||||||
);
|
|
||||||
|
|
||||||
const draw_x = this.x + this.inventory_width / 2;
|
|
||||||
const draw_y = this.y + furnace_y + SLOT_SIZE;
|
|
||||||
|
|
||||||
const src_width = TEXTURE_SIZE * progress_pct;
|
|
||||||
|
|
||||||
draw_texture_region(
|
|
||||||
atlas,
|
|
||||||
arrow_full_region.x * TEXTURE_SIZE,
|
|
||||||
arrow_full_region.y * TEXTURE_SIZE,
|
|
||||||
src_width,
|
|
||||||
TEXTURE_SIZE,
|
|
||||||
draw_x,
|
|
||||||
draw_y,
|
|
||||||
full_width,
|
|
||||||
SLOT_SIZE,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +1,23 @@
|
|||||||
import { Container, Inventory, ItemStack, PlayerInventory } from "../inventory.ts";
|
|
||||||
import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
|
import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
|
||||||
import { canvas, draw_rect, Texture } from "$/client/renderer/mod.ts";
|
import { canvas, draw_rect, Texture } from "$/client/renderer/mod.ts";
|
||||||
import { AssetManager } from "../assets.ts";
|
import { AssetManager } from "../assets.ts";
|
||||||
import { SLOT_SIZE } from "../../common/constants.ts";
|
import { SLOT_SIZE } from "../../common/constants.ts";
|
||||||
import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
|
import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
|
||||||
|
import { ClientInventories } from "../inventory.ts";
|
||||||
export interface CraftingRecipe {
|
import { ClientMessage, CRAFTING_RESULT_SLOT } from "$/common/protocol.ts";
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
pattern: (string | undefined)[];
|
|
||||||
result: ItemStack;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipes: CraftingRecipe[] = [
|
|
||||||
{
|
|
||||||
width: 3,
|
|
||||||
height: 3,
|
|
||||||
pattern: [
|
|
||||||
"bworld:planks",
|
|
||||||
"bworld:planks",
|
|
||||||
"bworld:planks",
|
|
||||||
"bworld:planks",
|
|
||||||
undefined,
|
|
||||||
"bworld:planks",
|
|
||||||
"bworld:planks",
|
|
||||||
"bworld:planks",
|
|
||||||
"bworld:planks",
|
|
||||||
],
|
|
||||||
result: new ItemStack("bworld:chest", 1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
width: 3,
|
|
||||||
height: 3,
|
|
||||||
pattern: [
|
|
||||||
"bworld:stone",
|
|
||||||
"bworld:stone",
|
|
||||||
"bworld:stone",
|
|
||||||
"bworld:stone",
|
|
||||||
undefined,
|
|
||||||
"bworld:stone",
|
|
||||||
"bworld:stone",
|
|
||||||
"bworld:stone",
|
|
||||||
"bworld:stone",
|
|
||||||
],
|
|
||||||
result: new ItemStack("bworld:furnace", 1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
pattern: [
|
|
||||||
"bworld:log",
|
|
||||||
],
|
|
||||||
result: new ItemStack("bworld:planks", 2),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
width: 1,
|
|
||||||
height: 2,
|
|
||||||
pattern: [
|
|
||||||
"bworld:planks",
|
|
||||||
"bworld:planks",
|
|
||||||
],
|
|
||||||
result: new ItemStack("bworld:stick", 2),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
width: 3,
|
|
||||||
height: 3,
|
|
||||||
pattern: [
|
|
||||||
"bworld:planks",
|
|
||||||
"bworld:planks",
|
|
||||||
"bworld:planks",
|
|
||||||
undefined,
|
|
||||||
"bworld:stick",
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
"bworld:stick",
|
|
||||||
undefined,
|
|
||||||
],
|
|
||||||
result: new ItemStack("bworld:wood_pickaxe", 1),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const PADDING = 10;
|
const PADDING = 10;
|
||||||
|
|
||||||
|
// the recipes live on the server, which fills in the result slot
|
||||||
export class GuiPlayerInventory extends GuiInventoryScreen {
|
export class GuiPlayerInventory extends GuiInventoryScreen {
|
||||||
override inventory_width = PADDING * 2 + SLOT_SIZE * 9;
|
override inventory_width = PADDING * 2 + SLOT_SIZE * 9;
|
||||||
override inventory_height = PADDING * 2 + SLOT_SIZE * 4 + PADDING + SLOT_SIZE * 3 + PADDING;
|
override inventory_height = PADDING * 2 + SLOT_SIZE * 4 + PADDING + SLOT_SIZE * 3 + PADDING;
|
||||||
|
|
||||||
constructor(player_inventory: PlayerInventory) {
|
constructor(inventories: ClientInventories, send: (message: ClientMessage) => void) {
|
||||||
super(new Inventory(new Container(10)), player_inventory, undefined);
|
super(inventories, send);
|
||||||
|
|
||||||
add_player_hotbar(this, player_inventory, PADDING, PADDING);
|
add_player_hotbar(this, PADDING, PADDING);
|
||||||
add_player_inventory(this, player_inventory, PADDING, PADDING * 2 + SLOT_SIZE);
|
add_player_inventory(this, PADDING, PADDING * 2 + SLOT_SIZE);
|
||||||
|
|
||||||
const crafting_x = PADDING;
|
const crafting_x = PADDING;
|
||||||
const crafting_y = PADDING * 3 + SLOT_SIZE * 4;
|
const crafting_y = PADDING * 3 + SLOT_SIZE * 4;
|
||||||
@@ -98,17 +25,14 @@ export class GuiPlayerInventory extends GuiInventoryScreen {
|
|||||||
for (let i = 0; i < 3; i += 1) {
|
for (let i = 0; i < 3; i += 1) {
|
||||||
for (let j = 0; j < 3; j += 1) {
|
for (let j = 0; j < 3; j += 1) {
|
||||||
this.slots.push(
|
this.slots.push(
|
||||||
new Slot(this.inventory, j * 3 + i, crafting_x + i * SLOT_SIZE, crafting_y + j * SLOT_SIZE),
|
new Slot("crafting", j * 3 + i, crafting_x + i * SLOT_SIZE, crafting_y + j * SLOT_SIZE),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.slots.push(new Slot(this.inventory, 9, crafting_x + 5 * SLOT_SIZE, crafting_y + 1 * SLOT_SIZE));
|
this.slots.push(
|
||||||
}
|
new Slot("crafting", CRAFTING_RESULT_SLOT, crafting_x + 5 * SLOT_SIZE, crafting_y + 1 * SLOT_SIZE, true),
|
||||||
|
);
|
||||||
override on_tick(delta: number): void {
|
|
||||||
super.on_tick(delta);
|
|
||||||
this.update_crafting_result();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override on_render(): void {
|
override on_render(): void {
|
||||||
@@ -134,136 +58,4 @@ export class GuiPlayerInventory extends GuiInventoryScreen {
|
|||||||
|
|
||||||
super.on_render();
|
super.on_render();
|
||||||
}
|
}
|
||||||
|
|
||||||
override on_close(): void {
|
|
||||||
for (let i = 0; i < 8; i += 1) {
|
|
||||||
const item = this.inventory.container.get_item(i);
|
|
||||||
if (item) {
|
|
||||||
this.player_inventory.container.add_item(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get_crafting_grid(): (string | undefined)[] {
|
|
||||||
const grid: (string | undefined)[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < 9; i++) {
|
|
||||||
const item = this.inventory.container.get_item(i);
|
|
||||||
grid.push(item?.type_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
matches_recipe(
|
|
||||||
grid: (string | undefined)[],
|
|
||||||
recipe: CraftingRecipe,
|
|
||||||
): boolean {
|
|
||||||
for (let y = 0; y <= 3 - recipe.height; y++) {
|
|
||||||
for (let x = 0; x <= 3 - recipe.width; x++) {
|
|
||||||
let match = true;
|
|
||||||
|
|
||||||
for (let gy = 0; gy < 3; gy++) {
|
|
||||||
for (let gx = 0; gx < 3; gx++) {
|
|
||||||
const grid_index = gy * 3 + gx;
|
|
||||||
|
|
||||||
if (
|
|
||||||
gx >= x &&
|
|
||||||
gx < x + recipe.width &&
|
|
||||||
gy >= y &&
|
|
||||||
gy < y + recipe.height
|
|
||||||
) {
|
|
||||||
const rx = gx - x;
|
|
||||||
const ry = gy - y;
|
|
||||||
const recipe_index = ry * recipe.width + rx;
|
|
||||||
|
|
||||||
if (grid[grid_index] !== recipe.pattern[recipe_index]) {
|
|
||||||
match = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (grid[grid_index] !== undefined) {
|
|
||||||
match = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!match) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match) return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
update_crafting_result() {
|
|
||||||
const grid = this.get_crafting_grid();
|
|
||||||
|
|
||||||
for (const recipe of recipes) {
|
|
||||||
if (this.matches_recipe(grid, recipe)) {
|
|
||||||
this.inventory.container.set_item(
|
|
||||||
9,
|
|
||||||
recipe.result.clone(),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.inventory.container.set_item(9, undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
consume_recipe_items() {
|
|
||||||
for (let i = 0; i < 9; i++) {
|
|
||||||
const item = this.inventory.container.get_item(i);
|
|
||||||
if (!item) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
item.amount -= 1;
|
|
||||||
|
|
||||||
if (item.amount <= 0) {
|
|
||||||
this.inventory.container.set_item(i, undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override handle_left_click(slot: Slot): void {
|
|
||||||
if (slot.inventory === this.inventory && slot.index === 9) {
|
|
||||||
if (this.inventory.container.get_item(9)) {
|
|
||||||
this.craft();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
super.handle_left_click(slot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override handle_right_click(slot: Slot): void {
|
|
||||||
if (slot.inventory === this.inventory && slot.index === 9) {
|
|
||||||
if (this.inventory.container.get_item(9)) {
|
|
||||||
this.craft();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
super.handle_right_click(slot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
craft() {
|
|
||||||
const holding_item = this.player_inventory.holding_item;
|
|
||||||
const item = this.inventory.container.get_item(9)!;
|
|
||||||
|
|
||||||
if (holding_item) {
|
|
||||||
if (holding_item.type_id === item.type_id) {
|
|
||||||
const items_left = holding_item.max_amount - holding_item.amount;
|
|
||||||
if (items_left >= item.amount) {
|
|
||||||
this.consume_recipe_items();
|
|
||||||
holding_item.amount += item.amount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.player_inventory.holding_item = item;
|
|
||||||
this.consume_recipe_items();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-183
@@ -1,27 +1,28 @@
|
|||||||
import { SLOT_SIZE } from "$/common/constants.ts";
|
import { SLOT_SIZE } from "$/common/constants.ts";
|
||||||
|
import { click_slot, Container } from "$/common/inventory.ts";
|
||||||
|
import { ClientMessage, ContainerKey, CRAFTING_RESULT_SLOT } from "$/common/protocol.ts";
|
||||||
import { point_inside_rec } from "../../common/utils.ts";
|
import { point_inside_rec } from "../../common/utils.ts";
|
||||||
import { AssetManager } from "../assets.ts";
|
import { AssetManager } from "../assets.ts";
|
||||||
import { InputManager } from "../input_manager.ts";
|
import { InputManager } from "../input_manager.ts";
|
||||||
import { Inventory, ItemStack, PlayerInventory } 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 "../systems/rendering/render_utils.ts";
|
import { draw_item, draw_nine_slice } from "../systems/rendering/render_utils.ts";
|
||||||
|
|
||||||
export class Slot {
|
export class Slot {
|
||||||
inventory: Inventory;
|
container: ContainerKey;
|
||||||
index: number;
|
index: number;
|
||||||
// relative to screen top left
|
// relative to screen top left
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
// can only be taken from, like the crafting or furnace result
|
||||||
|
output: boolean;
|
||||||
|
|
||||||
constructor(inventory: Inventory, index: number, x: number, y: number) {
|
constructor(container: ContainerKey, index: number, x: number, y: number, output = false) {
|
||||||
this.inventory = inventory;
|
this.container = container;
|
||||||
this.index = index;
|
this.index = index;
|
||||||
this.x = x;
|
this.x = x;
|
||||||
this.y = y;
|
this.y = y;
|
||||||
}
|
this.output = output;
|
||||||
|
|
||||||
get item(): ItemStack | undefined {
|
|
||||||
return this.inventory.container.get_item(this.index);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,25 +35,32 @@ export abstract class GuiScreen {
|
|||||||
abstract on_close(): void;
|
abstract on_close(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GuiInventoryScreen<Inv extends Inventory = Inventory, Properties = Record<string, unknown> | undefined>
|
// a screen of slots. the server owns the contents: clicks are sent to it and guessed locally
|
||||||
extends GuiScreen {
|
// so they feel instant, then whatever the server syncs back wins
|
||||||
inventory: Inv;
|
export class GuiInventoryScreen extends GuiScreen {
|
||||||
player_inventory: PlayerInventory;
|
inventories: ClientInventories;
|
||||||
properties: Properties;
|
send: (message: ClientMessage) => void;
|
||||||
|
|
||||||
slots: Slot[] = [];
|
slots: Slot[] = [];
|
||||||
|
hovering: Slot | undefined;
|
||||||
inventory_width: number = 500;
|
inventory_width: number = 500;
|
||||||
inventory_height: number = 500;
|
inventory_height: number = 500;
|
||||||
|
|
||||||
constructor(
|
constructor(inventories: ClientInventories, send: (message: ClientMessage) => void) {
|
||||||
inventory: Inv,
|
|
||||||
player_inventory: PlayerInventory,
|
|
||||||
properties: Properties,
|
|
||||||
) {
|
|
||||||
super();
|
super();
|
||||||
this.inventory = inventory;
|
this.inventories = inventories;
|
||||||
this.player_inventory = player_inventory;
|
this.send = send;
|
||||||
this.properties = properties;
|
}
|
||||||
|
|
||||||
|
get_container(key: ContainerKey): Container | undefined {
|
||||||
|
switch (key) {
|
||||||
|
case "inventory":
|
||||||
|
return this.inventories.inventory;
|
||||||
|
case "crafting":
|
||||||
|
return this.inventories.crafting;
|
||||||
|
case "screen":
|
||||||
|
return this.inventories.screen;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
on_tick(_delta: number): void {
|
on_tick(_delta: number): void {
|
||||||
@@ -60,20 +68,17 @@ export class GuiInventoryScreen<Inv extends Inventory = Inventory, Properties =
|
|||||||
this.y = canvas.height / 2 - (this.inventory_height / 2);
|
this.y = canvas.height / 2 - (this.inventory_height / 2);
|
||||||
|
|
||||||
this.handle_interaction();
|
this.handle_interaction();
|
||||||
|
|
||||||
if (this.player_inventory.holding_item?.amount === 0) {
|
|
||||||
this.player_inventory.holding_item = undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
on_render(): void {
|
on_render(): void {
|
||||||
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
||||||
|
|
||||||
for (const slot of this.slots) {
|
for (const slot of this.slots) {
|
||||||
|
const hovered = slot === this.hovering;
|
||||||
draw_nine_slice(
|
draw_nine_slice(
|
||||||
ui,
|
ui,
|
||||||
slot.inventory.hovering_slot === slot.index ? 19 * 16 : 160 + 32,
|
hovered ? 19 * 16 : 160 + 32,
|
||||||
slot.inventory.hovering_slot === slot.index ? 16 : 0,
|
hovered ? 16 : 0,
|
||||||
16,
|
16,
|
||||||
16,
|
16,
|
||||||
4,
|
4,
|
||||||
@@ -88,198 +93,83 @@ export class GuiInventoryScreen<Inv extends Inventory = Inventory, Properties =
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const slot of this.slots) {
|
for (const slot of this.slots) {
|
||||||
const item = slot.item;
|
const item = this.get_container(slot.container)?.get_item(slot.index);
|
||||||
if (item) {
|
if (item) {
|
||||||
draw_item(item, this.x + slot.x, this.y + slot.y);
|
draw_item(item, this.x + slot.x, this.y + slot.y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.player_inventory.holding_item) {
|
if (this.inventories.cursor.item) {
|
||||||
const mouse = InputManager.get_mouse_position();
|
const mouse = InputManager.get_mouse_position();
|
||||||
draw_item(this.player_inventory.holding_item, mouse.x, mouse.y);
|
draw_item(this.inventories.cursor.item, mouse.x, mouse.y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
on_close(): void {}
|
on_close(): void {
|
||||||
|
this.send({ type: "close_screen" });
|
||||||
|
|
||||||
|
// the server puts these back in the inventory, show it right away
|
||||||
|
const { inventory, crafting, cursor } = this.inventories;
|
||||||
|
for (let i = 0; i < CRAFTING_RESULT_SLOT; i++) {
|
||||||
|
const item = crafting.get_item(i);
|
||||||
|
if (item) {
|
||||||
|
inventory.add_item(item);
|
||||||
|
crafting.set_item(i, undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crafting.set_item(CRAFTING_RESULT_SLOT, undefined);
|
||||||
|
if (cursor.item) {
|
||||||
|
inventory.add_item(cursor.item);
|
||||||
|
cursor.item = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handle_interaction() {
|
handle_interaction() {
|
||||||
this.player_inventory.hovering_slot = -1;
|
this.hovering = undefined;
|
||||||
this.inventory.hovering_slot = -1;
|
|
||||||
for (const slot of this.slots) {
|
|
||||||
const mouse = InputManager.get_mouse_position();
|
const mouse = InputManager.get_mouse_position();
|
||||||
const slot_x = slot.x + this.x;
|
|
||||||
const slot_y = slot.y + this.y;
|
|
||||||
const hovering = point_inside_rec(mouse.x, mouse.y, slot_x, slot_y, SLOT_SIZE, SLOT_SIZE);
|
|
||||||
if (hovering) {
|
|
||||||
slot.inventory.hovering_slot = slot.index;
|
|
||||||
|
|
||||||
if (InputManager.is_mouse_pressed(0)) {
|
for (const slot of this.slots) {
|
||||||
InputManager.consume_mouse(0);
|
const hovering = point_inside_rec(mouse.x, mouse.y, this.x + slot.x, this.y + slot.y, SLOT_SIZE, SLOT_SIZE);
|
||||||
this.handle_left_click(slot);
|
if (!hovering) {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
this.hovering = slot;
|
||||||
|
|
||||||
if (InputManager.is_mouse_pressed(2)) {
|
for (const button of [0, 2]) {
|
||||||
InputManager.consume_mouse(2);
|
if (InputManager.is_mouse_pressed(button)) {
|
||||||
this.handle_right_click(slot);
|
InputManager.consume_mouse(button);
|
||||||
|
this.click(slot, button);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch_item_with_holding(inventory: Inventory, index: number) {
|
click(slot: Slot, button: number) {
|
||||||
const container_slot = inventory.container.get_slot(index);
|
this.send({ type: "click", container: slot.container, index: slot.index, button });
|
||||||
const original_holding_item = this.player_inventory.holding_item;
|
|
||||||
this.player_inventory.holding_item = container_slot.get_item();
|
|
||||||
container_slot.set_item(original_holding_item);
|
|
||||||
}
|
|
||||||
|
|
||||||
handle_left_click(slot: Slot) {
|
// output slots depend on server side things like recipes, just wait for the server
|
||||||
const holding = this.player_inventory.holding_item;
|
const container = this.get_container(slot.container);
|
||||||
const inventory_slot = slot.inventory.container.get_slot(slot.index);
|
if (container && !slot.output) {
|
||||||
const slot_item = inventory_slot.get_item();
|
click_slot(container, slot.index, this.inventories.cursor, button);
|
||||||
|
|
||||||
// if you aren't holding anything
|
|
||||||
// "swap" with nothing on your hand (pick it up)
|
|
||||||
if (!holding) {
|
|
||||||
this.switch_item_with_holding(slot.inventory, slot.index);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if you are holding something and slot type equals holding type
|
|
||||||
// try to add to stack
|
|
||||||
if (slot_item && inventory_slot.type_id === holding.type_id) {
|
|
||||||
const space_left = inventory_slot.max_amount! - slot_item.amount!;
|
|
||||||
const amount_to_add = Math.min(space_left, holding.amount);
|
|
||||||
|
|
||||||
slot_item.amount += amount_to_add;
|
|
||||||
holding.amount -= amount_to_add;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if something on hand but not the same
|
|
||||||
// swap
|
|
||||||
this.switch_item_with_holding(slot.inventory, slot.index);
|
|
||||||
}
|
|
||||||
|
|
||||||
handle_right_click(slot: Slot) {
|
|
||||||
const holding = this.player_inventory.holding_item;
|
|
||||||
const inventory_slot = slot.inventory.container.get_slot(slot.index);
|
|
||||||
const slot_item = inventory_slot.get_item();
|
|
||||||
|
|
||||||
// if holding something
|
|
||||||
if (holding) {
|
|
||||||
// and slot type equals holding type
|
|
||||||
// add 1 to matching stack
|
|
||||||
if (slot_item && inventory_slot.type_id === holding.type_id) {
|
|
||||||
if (slot_item.amount < slot_item.max_amount!) {
|
|
||||||
slot_item.amount += 1;
|
|
||||||
holding.amount -= 1;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// (actually the same as the last one but creates a new one techinically)
|
|
||||||
// place 1 into empty slot
|
|
||||||
if (!slot_item) {
|
|
||||||
const new_item = holding.clone();
|
|
||||||
new_item.amount = 1;
|
|
||||||
inventory_slot.set_item(new_item);
|
|
||||||
holding.amount -= 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if something on hand but not the same
|
|
||||||
// swap
|
|
||||||
this.switch_item_with_holding(slot.inventory, slot.index);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if player is holding nothing and clicks nothing, nothing happens
|
|
||||||
if (!slot_item) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// pick up half of the stack
|
|
||||||
const original_amount = slot_item.amount;
|
|
||||||
const half = Math.floor(original_amount / 2);
|
|
||||||
|
|
||||||
slot_item.amount = half;
|
|
||||||
|
|
||||||
const picked_up = slot_item.clone();
|
|
||||||
picked_up.amount = original_amount - half;
|
|
||||||
|
|
||||||
this.player_inventory.holding_item = picked_up;
|
|
||||||
|
|
||||||
if (slot_item.amount === 0) {
|
|
||||||
inventory_slot.set_item(undefined);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*handle_hotbar(world: ClientWorld, player_inventory: PlayerInventory) {
|
|
||||||
const [player, player_hand] = world.get_tag("player")!;
|
|
||||||
|
|
||||||
const player_position = player.get(Position);
|
|
||||||
const player_sprite = player.get(AnimatedSprite);
|
|
||||||
const hand_position = player_hand.get(Position);
|
|
||||||
const hand_sprite = player_hand.get(Sprite);
|
|
||||||
|
|
||||||
if (!player_position || !hand_position || !hand_sprite || !player_sprite) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// sync position
|
|
||||||
hand_position.x = player_position.x + (player_sprite.flip_x ? 34 : 4);
|
|
||||||
hand_position.y = player_position.y + 34;
|
|
||||||
hand_sprite.flip_x = player_sprite.flip_x;
|
|
||||||
|
|
||||||
const maybe_item = player_inventory.container.get_item(player_inventory.hotbar_selected);
|
|
||||||
if (maybe_item) {
|
|
||||||
const region = get_sprite_region(maybe_item.type_id);
|
|
||||||
hand_sprite.source_x = region.x * 16;
|
|
||||||
hand_sprite.source_y = region.y * 16;
|
|
||||||
hand_sprite.source_width = 16;
|
|
||||||
hand_sprite.source_height = 16;
|
|
||||||
hand_sprite.width = 32;
|
|
||||||
hand_sprite.height = 32;
|
|
||||||
} else {
|
|
||||||
hand_sprite.width = 0;
|
|
||||||
hand_sprite.height = 0;
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// generic methods that is often needed
|
// generic methods that is often needed
|
||||||
|
|
||||||
export function add_player_inventory(
|
export function add_player_inventory(handler: GuiInventoryScreen, offset_x: number, offset_y: number) {
|
||||||
handler: GuiInventoryScreen,
|
|
||||||
player_inventory: PlayerInventory,
|
|
||||||
offset_x: number,
|
|
||||||
offset_y: number,
|
|
||||||
) {
|
|
||||||
for (let i = 0; i < 3; ++i) {
|
for (let i = 0; i < 3; ++i) {
|
||||||
for (let l = 0; l < 9; ++l) {
|
for (let l = 0; l < 9; ++l) {
|
||||||
handler.slots.push(
|
handler.slots.push(
|
||||||
new Slot(
|
new Slot("inventory", l + i * 9 + 9, offset_x + l * SLOT_SIZE, offset_y + i * SLOT_SIZE),
|
||||||
player_inventory,
|
|
||||||
l + i * 9 + 9,
|
|
||||||
offset_x + l * SLOT_SIZE,
|
|
||||||
offset_y + i * SLOT_SIZE,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function add_player_hotbar(
|
export function add_player_hotbar(handler: GuiInventoryScreen, offset_x: number, offset_y: number) {
|
||||||
handler: GuiInventoryScreen,
|
|
||||||
player_inventory: PlayerInventory,
|
|
||||||
offset_x: number,
|
|
||||||
offset_y: number,
|
|
||||||
) {
|
|
||||||
for (let i = 0; i < 9; ++i) {
|
for (let i = 0; i < 9; ++i) {
|
||||||
handler.slots.push(new Slot(player_inventory, i, offset_x + i * SLOT_SIZE, offset_y));
|
handler.slots.push(new Slot("inventory", i, offset_x + i * SLOT_SIZE, offset_y));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-136
@@ -1,138 +1,11 @@
|
|||||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
import { Container, Cursor } from "$/common/inventory.ts";
|
||||||
|
|
||||||
export class ItemStack<T = unknown | undefined> {
|
// the local copies of the containers the server syncs to this player
|
||||||
type_id: string;
|
export class ClientInventories {
|
||||||
amount: number;
|
inventory = new Container(9 * 4);
|
||||||
max_amount: number;
|
crafting = new Container(10);
|
||||||
data?: T;
|
// the open server screen's container
|
||||||
|
screen: Container | undefined;
|
||||||
constructor(type_id: string | string, amount: number = 1, max_amount: number = 64) {
|
cursor: Cursor = { item: undefined };
|
||||||
this.type_id = type_id;
|
hotbar_selected = 0;
|
||||||
this.amount = amount;
|
|
||||||
this.max_amount = max_amount;
|
|
||||||
this.data = undefined;
|
|
||||||
const item_info = EverythingRegistry.get<ItemRegistry>("items", type_id);
|
|
||||||
if (item_info?.on_create) {
|
|
||||||
item_info.on_create(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clone(): ItemStack {
|
|
||||||
return new ItemStack(this.type_id, this.amount, this.max_amount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ContainerSlot {
|
|
||||||
#item_stack: ItemStack | undefined;
|
|
||||||
|
|
||||||
has_item() {
|
|
||||||
return this.#item_stack !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
set_item(item_stack: ItemStack | undefined) {
|
|
||||||
if ((item_stack?.amount ?? 0) <= 0) {
|
|
||||||
item_stack = undefined;
|
|
||||||
}
|
|
||||||
this.#item_stack = item_stack;
|
|
||||||
}
|
|
||||||
|
|
||||||
get_item() {
|
|
||||||
return this.#item_stack;
|
|
||||||
}
|
|
||||||
|
|
||||||
get type_id() {
|
|
||||||
return this.#item_stack?.type_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
set amount(new_amount: number) {
|
|
||||||
if (this.#item_stack) {
|
|
||||||
this.#item_stack.amount = new_amount;
|
|
||||||
if (this.#item_stack.amount <= 0) {
|
|
||||||
this.#item_stack = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get amount(): number | undefined {
|
|
||||||
return this.#item_stack?.amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
get max_amount() {
|
|
||||||
return this.#item_stack?.max_amount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Container {
|
|
||||||
#slots: ContainerSlot[] = [];
|
|
||||||
readonly size: number;
|
|
||||||
|
|
||||||
constructor(size: number) {
|
|
||||||
this.size = size;
|
|
||||||
for (let i = 0; i < size; i += 1) {
|
|
||||||
this.#slots.push(new ContainerSlot());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
add_item(item_stack: ItemStack) {
|
|
||||||
for (const slot of this.#slots.filter((slot) => slot.has_item())) {
|
|
||||||
const slot_item = slot.get_item()!;
|
|
||||||
if (slot_item.type_id === item_stack.type_id) {
|
|
||||||
const missing = slot_item.max_amount - slot_item.amount;
|
|
||||||
const adding = Math.min(missing, item_stack.amount);
|
|
||||||
slot_item.amount += adding;
|
|
||||||
item_stack.amount -= adding;
|
|
||||||
if (item_stack.amount === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const slot of this.#slots) {
|
|
||||||
if (!slot.has_item()) {
|
|
||||||
slot.set_item(item_stack);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO: drop item on ground
|
|
||||||
console.error("Failed to place item in container");
|
|
||||||
}
|
|
||||||
|
|
||||||
get_item(slot: number): ItemStack | undefined {
|
|
||||||
return this.#slots[slot]?.get_item();
|
|
||||||
}
|
|
||||||
|
|
||||||
get_slot(slot: number): ContainerSlot {
|
|
||||||
return this.#slots[slot];
|
|
||||||
}
|
|
||||||
|
|
||||||
set_item(slot: number, item: ItemStack | undefined) {
|
|
||||||
this.#slots[slot].set_item(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContainerLayout {
|
|
||||||
slots: { type: string; x: number; y: number }[];
|
|
||||||
offset_x: number;
|
|
||||||
offset_y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Inventory {
|
|
||||||
container: Container;
|
|
||||||
|
|
||||||
hovering_slot: number = -1;
|
|
||||||
|
|
||||||
constructor(container: Container) {
|
|
||||||
this.container = container;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PlayerInventory extends Inventory {
|
|
||||||
owner_id: string;
|
|
||||||
holding_item: ItemStack | undefined;
|
|
||||||
hotbar_selected: number = 0;
|
|
||||||
|
|
||||||
constructor(owner_id: string) {
|
|
||||||
const container = new Container(9 * 4);
|
|
||||||
super(container);
|
|
||||||
this.owner_id = owner_id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-12
@@ -3,9 +3,10 @@ import { ClientWorld } from "./client_world.ts";
|
|||||||
import { InputManager } from "./input_manager.ts";
|
import { InputManager } from "./input_manager.ts";
|
||||||
import { Connection, get_player_name, get_server_url } from "./network.ts";
|
import { Connection, get_player_name, get_server_url } from "./network.ts";
|
||||||
import { begin_drawing, clear_background, end_drawing, init_font, init_window } from "./renderer/mod.ts";
|
import { begin_drawing, clear_background, end_drawing, init_font, init_window } from "./renderer/mod.ts";
|
||||||
|
import { is_stopped, show_fatal_error } from "./fatal.ts";
|
||||||
|
|
||||||
await import("./blocks/mod.ts");
|
await import("$/common/blocks/mod.ts");
|
||||||
await import("./items/mod.ts");
|
await import("$/common/items/mod.ts");
|
||||||
|
|
||||||
export class ClientLoop {
|
export class ClientLoop {
|
||||||
running = false;
|
running = false;
|
||||||
@@ -39,7 +40,7 @@ export class ClientLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loop(time: number) {
|
loop(time: number) {
|
||||||
if (!this.running) {
|
if (!this.running || is_stopped()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,17 +92,18 @@ await AssetManager.instance.load_all();
|
|||||||
|
|
||||||
init_font();
|
init_font();
|
||||||
|
|
||||||
|
// the game only runs against a server, it owns the world and everything in it
|
||||||
const connection = await Connection.open(get_server_url(), get_player_name());
|
const connection = await Connection.open(get_server_url(), get_player_name());
|
||||||
if (connection) {
|
if (connection) {
|
||||||
console.log(`Connected to ${get_server_url()}`);
|
console.log(`Connected to ${get_server_url()}`);
|
||||||
|
|
||||||
|
const client_world = new ClientWorld(connection);
|
||||||
|
client_world.add_chat("Connected to the server");
|
||||||
|
|
||||||
|
const loop = new ClientLoop(client_world);
|
||||||
|
loop.start();
|
||||||
|
|
||||||
|
console.log("Game started");
|
||||||
} else {
|
} else {
|
||||||
console.log("Couldn't reach a server, playing single player");
|
show_fatal_error(`Couldn't connect to the server at ${get_server_url()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client_world = new ClientWorld(connection);
|
|
||||||
client_world.add_chat(connection ? "Connected to the server" : "Playing single player");
|
|
||||||
|
|
||||||
const loop = new ClientLoop(client_world);
|
|
||||||
loop.start();
|
|
||||||
|
|
||||||
console.log("Game started");
|
|
||||||
|
|||||||
+10
-6
@@ -15,18 +15,22 @@ export class Connection {
|
|||||||
id: string;
|
id: string;
|
||||||
seed: string;
|
seed: string;
|
||||||
initial_changes: BlockChange[];
|
initial_changes: BlockChange[];
|
||||||
|
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
|
||||||
|
selected_slot: number;
|
||||||
players = new Map<string, RemotePlayer>();
|
players = new Map<string, RemotePlayer>();
|
||||||
|
|
||||||
// handled by the network system inside the game loop, not whenever the socket feels like it
|
// handled by the network system inside the game loop, not whenever the socket feels like it
|
||||||
incoming: ServerMessage[] = [];
|
incoming: ServerMessage[] = [];
|
||||||
closed = false;
|
closed = false;
|
||||||
|
|
||||||
constructor(socket: WebSocket, id: string, seed: string, players: PlayerInfo[], changes: BlockChange[]) {
|
constructor(socket: WebSocket, welcome: Extract<ServerMessage, { type: "welcome" }>) {
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
this.id = id;
|
this.id = welcome.id;
|
||||||
this.seed = seed;
|
this.seed = welcome.seed;
|
||||||
this.initial_changes = changes;
|
this.initial_changes = welcome.changes;
|
||||||
for (const player of players) {
|
this.spawn = welcome.spawn;
|
||||||
|
this.selected_slot = welcome.selected_slot;
|
||||||
|
for (const player of welcome.players) {
|
||||||
this.add_player(player);
|
this.add_player(player);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +81,7 @@ export class Connection {
|
|||||||
const message: ServerMessage = JSON.parse(event.data);
|
const message: ServerMessage = JSON.parse(event.data);
|
||||||
if (message.type === "welcome") {
|
if (message.type === "welcome") {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
resolve(new Connection(socket, message.id, message.seed, message.players, message.changes));
|
resolve(new Connection(socket, message));
|
||||||
}
|
}
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
|
|
||||||
|
|||||||
+9
-5
@@ -2,14 +2,14 @@ import { Position } from "$/common/components/position.ts";
|
|||||||
import { Velocity } from "$/common/components/velocity.ts";
|
import { Velocity } from "$/common/components/velocity.ts";
|
||||||
import { Component, Entity } from "$/common/ecs/mod.ts";
|
import { Component, Entity } from "$/common/ecs/mod.ts";
|
||||||
import { Camera } from "$/client/components/camera.ts";
|
import { Camera } from "$/client/components/camera.ts";
|
||||||
import { PlayerInventory } from "./inventory.ts";
|
import { ClientInventories } from "./inventory.ts";
|
||||||
import { PlayerControls } from "$/client/components/player_controls.ts";
|
import { PlayerControls } from "$/client/components/player_controls.ts";
|
||||||
import { ClientWorld } from "./client_world.ts";
|
import { ClientWorld } from "./client_world.ts";
|
||||||
import { GuiScreen } from "./gui/gui_screen.ts";
|
import { GuiScreen } from "./gui/gui_screen.ts";
|
||||||
import { CollisionCuboid } from "./components/collision.ts";
|
import { CollisionCuboid } from "./components/collision.ts";
|
||||||
|
|
||||||
export class PlayerComponent extends Component {
|
export class PlayerComponent extends Component {
|
||||||
player_inventory = new PlayerInventory("");
|
inventories = new ClientInventories();
|
||||||
screens: GuiScreen[] = [];
|
screens: GuiScreen[] = [];
|
||||||
render_distance = 6;
|
render_distance = 6;
|
||||||
|
|
||||||
@@ -27,12 +27,16 @@ export class PlayerComponent extends Component {
|
|||||||
|
|
||||||
export function create_player(world: ClientWorld) {
|
export function create_player(world: ClientWorld) {
|
||||||
const player = new Entity("player");
|
const player = new Entity("player");
|
||||||
player.add(new Position(0, 100, 0));
|
const spawn = world.connection.spawn;
|
||||||
|
player.add(new Position(spawn.x, spawn.y, spawn.z));
|
||||||
player.add(new Velocity(0, 0, 0));
|
player.add(new Velocity(0, 0, 0));
|
||||||
player.add(new PlayerControls());
|
player.add(new PlayerControls());
|
||||||
|
|
||||||
player.add(new PlayerComponent());
|
const player_component = player.add(new PlayerComponent());
|
||||||
player.add(new Camera());
|
player_component.inventories.hotbar_selected = world.connection.selected_slot;
|
||||||
|
const camera = player.add(new Camera());
|
||||||
|
camera.yaw = spawn.yaw;
|
||||||
|
camera.pitch = spawn.pitch;
|
||||||
player.add(new CollisionCuboid(0.55, 1.79, 0.55));
|
player.add(new CollisionCuboid(0.55, 1.79, 0.55));
|
||||||
|
|
||||||
world.add_entity(player);
|
world.add_entity(player);
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { SpriteRegion } from "$/common/constants.ts";
|
||||||
|
import { AssetManager } from "./assets.ts";
|
||||||
|
|
||||||
|
type TexturesInfo = Record<string, SpriteRegion>;
|
||||||
|
|
||||||
|
export function get_sprite_region(id: string): SpriteRegion {
|
||||||
|
const textures_info = AssetManager.instance.get<TexturesInfo>("bworld:textures_info");
|
||||||
|
return textures_info?.[id] ?? { x: 0, y: 0 };
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { System } from "$/common/ecs/mod.ts";
|
|
||||||
import { ClientWorld } from "../client_world.ts";
|
|
||||||
import { Dimension } from "../components/dimension.ts";
|
|
||||||
import { TICK_DELTA } from "$/common/constants.ts";
|
|
||||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
|
||||||
|
|
||||||
export class DimensionLogicSystem extends System {
|
|
||||||
update(world: ClientWorld, delta: number): void {
|
|
||||||
const dimension = world.dimension;
|
|
||||||
|
|
||||||
dimension.second_timer += delta;
|
|
||||||
dimension.tick_timer += delta;
|
|
||||||
|
|
||||||
if (dimension.second_timer >= 1) {
|
|
||||||
this.handle_second(world, dimension);
|
|
||||||
}
|
|
||||||
if (dimension.tick_timer >= TICK_DELTA) {
|
|
||||||
this.handle_tick(world, dimension);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handle_second(world: ClientWorld, dimension: Dimension) {
|
|
||||||
for (const chunk of dimension.chunks.values()) {
|
|
||||||
for (const tickable of chunk.blocks_data) {
|
|
||||||
const tile_info = EverythingRegistry.get<BlockRegistry>("blocks", tickable.id);
|
|
||||||
if (tile_info && tile_info.on_second) {
|
|
||||||
tile_info.on_second(world.dimension, tickable, dimension.second_timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dimension.second_timer = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
handle_tick(world: ClientWorld, dimension: Dimension) {
|
|
||||||
for (const chunk of dimension.chunks.values()) {
|
|
||||||
for (const tickable of chunk.blocks_data) {
|
|
||||||
const tile_info = EverythingRegistry.get<BlockRegistry>("blocks", tickable.id);
|
|
||||||
if (tile_info && tile_info.on_tick) {
|
|
||||||
tile_info.on_tick(world.dimension, tickable, dimension.tick_timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dimension.tick_timer = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,10 @@ import { System } from "$/common/ecs/mod.ts";
|
|||||||
import { Position } from "$/common/components/position.ts";
|
import { Position } from "$/common/components/position.ts";
|
||||||
import { ClientWorld } from "../client_world.ts";
|
import { ClientWorld } from "../client_world.ts";
|
||||||
import { Camera } from "../components/camera.ts";
|
import { Camera } from "../components/camera.ts";
|
||||||
|
import { Container, ItemStack } from "$/common/inventory.ts";
|
||||||
|
import { PlayerComponent } from "../player.ts";
|
||||||
|
import { GuiContainer } from "../gui/gui_container.ts";
|
||||||
|
import { show_fatal_error } from "../fatal.ts";
|
||||||
|
|
||||||
const MOVE_SEND_INTERVAL = 1 / 10;
|
const MOVE_SEND_INTERVAL = 1 / 10;
|
||||||
const REMOTE_PLAYER_SMOOTHING = 12;
|
const REMOTE_PLAYER_SMOOTHING = 12;
|
||||||
@@ -11,9 +15,9 @@ export class NetworkSystem extends System {
|
|||||||
|
|
||||||
update(world: ClientWorld, delta: number): void {
|
update(world: ClientWorld, delta: number): void {
|
||||||
const connection = world.connection;
|
const connection = world.connection;
|
||||||
if (!connection) {
|
const [local_player] = world.get_tag("player")!;
|
||||||
return;
|
const player_component = local_player.get(PlayerComponent)!;
|
||||||
}
|
const inventories = player_component.inventories;
|
||||||
|
|
||||||
for (const message of connection.incoming) {
|
for (const message of connection.incoming) {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
@@ -41,13 +45,47 @@ export class NetworkSystem extends System {
|
|||||||
case "chat":
|
case "chat":
|
||||||
world.add_chat(message.from ? `<${message.from}> ${message.text}` : message.text);
|
world.add_chat(message.from ? `<${message.from}> ${message.text}` : message.text);
|
||||||
break;
|
break;
|
||||||
|
case "container": {
|
||||||
|
let container = message.container === "screen"
|
||||||
|
? inventories.screen
|
||||||
|
: inventories[message.container];
|
||||||
|
if (message.container === "screen" && container?.size !== message.items.length) {
|
||||||
|
container = inventories.screen = new Container(message.items.length);
|
||||||
|
}
|
||||||
|
container?.load(message.items);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "cursor":
|
||||||
|
inventories.cursor.item = message.item ? ItemStack.from_data(message.item) : undefined;
|
||||||
|
break;
|
||||||
|
case "open_screen": {
|
||||||
|
// replace anything open locally without telling the server, it just opened this one
|
||||||
|
player_component.screens.length = 0;
|
||||||
|
const size = Math.max(0, ...message.layout.slots.map((slot) => slot.index + 1));
|
||||||
|
inventories.screen = new Container(size);
|
||||||
|
player_component.screens.push(
|
||||||
|
new GuiContainer(inventories, (m) => connection.send(m), message.layout, message.properties),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "screen_properties": {
|
||||||
|
const screen = player_component.screens.at(-1);
|
||||||
|
if (screen instanceof GuiContainer) {
|
||||||
|
screen.properties = message.properties;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "close_screen":
|
||||||
|
// closed by the server (the block broke), it already put everything back
|
||||||
|
player_component.screens = player_component.screens.filter((s) => !(s instanceof GuiContainer));
|
||||||
|
inventories.screen = undefined;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection.incoming.length = 0;
|
connection.incoming.length = 0;
|
||||||
|
|
||||||
if (connection.closed) {
|
if (connection.closed) {
|
||||||
world.add_chat("Lost connection to the server");
|
show_fatal_error("Lost connection to the server");
|
||||||
world.connection = undefined;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { PlayerComponent } from "../player.ts";
|
|||||||
import { GuiPlayerInventory } from "../gui/gui_player_inventory.ts";
|
import { GuiPlayerInventory } from "../gui/gui_player_inventory.ts";
|
||||||
import { CollisionCuboid } from "../components/collision.ts";
|
import { CollisionCuboid } from "../components/collision.ts";
|
||||||
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||||
|
import { AIR, FACE_OFFSETS } from "$/common/constants.ts";
|
||||||
|
import { ClientMessage } from "$/common/protocol.ts";
|
||||||
import { GuiChat } from "../gui/gui_chat.ts";
|
import { GuiChat } from "../gui/gui_chat.ts";
|
||||||
import { GuiInventoryScreen } from "../gui/gui_screen.ts";
|
import { GuiInventoryScreen } from "../gui/gui_screen.ts";
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ export class PlayerControlsSystem extends System {
|
|||||||
const player_component = player.get(PlayerComponent)!;
|
const player_component = player.get(PlayerComponent)!;
|
||||||
const position = player.get(Position)!;
|
const position = player.get(Position)!;
|
||||||
const camera = player.get(Camera)!;
|
const camera = player.get(Camera)!;
|
||||||
|
const send = (message: ClientMessage) => world.connection.send(message);
|
||||||
|
|
||||||
if (player_component.screens.length === 0) {
|
if (player_component.screens.length === 0) {
|
||||||
let input_x = 0;
|
let input_x = 0;
|
||||||
@@ -71,7 +74,7 @@ export class PlayerControlsSystem extends System {
|
|||||||
|
|
||||||
if (InputManager.is_key_pressed(controls.open_inventory)) {
|
if (InputManager.is_key_pressed(controls.open_inventory)) {
|
||||||
if (player_component.screens.length === 0) {
|
if (player_component.screens.length === 0) {
|
||||||
player_component.screens.push(new GuiPlayerInventory(player_component.player_inventory));
|
player_component.screens.push(new GuiPlayerInventory(player_component.inventories, send));
|
||||||
} else if (player_component.screens.at(-1) instanceof GuiInventoryScreen) {
|
} else if (player_component.screens.at(-1) instanceof GuiInventoryScreen) {
|
||||||
player_component.pop_screen();
|
player_component.pop_screen();
|
||||||
}
|
}
|
||||||
@@ -119,10 +122,9 @@ export class PlayerControlsSystem extends System {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const block = world.dimension.get_looked_block(world.dimension, camera);
|
const block = world.dimension.get_looked_block(world.dimension, camera);
|
||||||
const holding_item = player_component.player_inventory.container.get_item(
|
const inventories = player_component.inventories;
|
||||||
player_component.player_inventory.hotbar_selected,
|
const hotbar_slot = inventories.inventory.get_slot(inventories.hotbar_selected);
|
||||||
);
|
const holding_item_info = EverythingRegistry.get<ItemRegistry>("items", hotbar_slot.type_id ?? "");
|
||||||
const holding_item_info = EverythingRegistry.get<ItemRegistry>("items", holding_item?.type_id ?? "");
|
|
||||||
|
|
||||||
if (block && player_component.screens.length === 0) {
|
if (block && player_component.screens.length === 0) {
|
||||||
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", block.block)!;
|
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", block.block)!;
|
||||||
@@ -135,47 +137,24 @@ export class PlayerControlsSystem extends System {
|
|||||||
}
|
}
|
||||||
player_component.break_progress += delta * multiplier;
|
player_component.break_progress += delta * multiplier;
|
||||||
if (player_component.break_progress >= player_component.break_progress_max) {
|
if (player_component.break_progress >= player_component.break_progress_max) {
|
||||||
const drop_item = !block_info.requires_tool ||
|
// show it right away, the server decides drops and corrects us if it disagrees
|
||||||
holding_item_info?.tool_type === block_info.tool_to_break;
|
world.dimension.break_block(block.x, block.y, block.z);
|
||||||
world.dimension.break_block(block.x, block.y, block.z, drop_item);
|
send({ type: "break_block", x: block.x, y: block.y, z: block.z });
|
||||||
world.dimension.sync_block(block.x, block.y, block.z);
|
|
||||||
player_component.break_progress_max = 0;
|
player_component.break_progress_max = 0;
|
||||||
player_component.break_progress = 0;
|
player_component.break_progress = 0;
|
||||||
}
|
}
|
||||||
} else if (InputManager.is_mouse_pressed(2)) {
|
} else if (InputManager.is_mouse_pressed(2)) {
|
||||||
// interact if possible
|
send({ type: "use_block", x: block.x, y: block.y, z: block.z, face: block.face });
|
||||||
const interacted = block_info?.on_interact?.(world.dimension, {
|
|
||||||
x: block.x,
|
// guess that it places the held block, unless the block does something when used
|
||||||
y: block.y,
|
|
||||||
z: block.z,
|
|
||||||
id: block_info.id,
|
|
||||||
});
|
|
||||||
if (!interacted) {
|
|
||||||
const inventory = player_component.player_inventory;
|
|
||||||
const hotbar_slot = inventory.container.get_slot(inventory.hotbar_selected);
|
|
||||||
if (hotbar_slot && hotbar_slot.has_item()) {
|
|
||||||
const item = hotbar_slot.get_item()!;
|
|
||||||
const item_info = EverythingRegistry.get<ItemRegistry>("items", item.type_id);
|
|
||||||
if (item_info && item_info.block_id) {
|
|
||||||
const FACE_OFFSETS = {
|
|
||||||
top: { x: 0, y: 1, z: 0 },
|
|
||||||
bottom: { x: 0, y: -1, z: 0 },
|
|
||||||
north: { x: 0, y: 0, z: -1 },
|
|
||||||
south: { x: 0, y: 0, z: 1 },
|
|
||||||
west: { x: -1, y: 0, z: 0 },
|
|
||||||
east: { x: 1, y: 0, z: 0 },
|
|
||||||
};
|
|
||||||
const offset = FACE_OFFSETS[block.face];
|
const offset = FACE_OFFSETS[block.face];
|
||||||
world.dimension.add_block({
|
const target = { x: block.x + offset.x, y: block.y + offset.y, z: block.z + offset.z };
|
||||||
x: block.x + offset.x,
|
const target_id = world.dimension.get_block(target.x, target.y, target.z);
|
||||||
y: block.y + offset.y,
|
const replaceable = target_id === AIR ||
|
||||||
z: block.z + offset.z,
|
EverythingRegistry.get_by_id<BlockRegistry>("blocks", target_id)?.id === "bworld:water";
|
||||||
id: item_info.block_id,
|
if (!block_info.interactive && holding_item_info?.block_id && replaceable) {
|
||||||
});
|
world.dimension.add_block({ ...target, id: holding_item_info.block_id });
|
||||||
world.dimension.sync_block(block.x + offset.x, block.y + offset.y, block.z + offset.z);
|
hotbar_slot.amount = hotbar_slot.amount! - 1;
|
||||||
hotbar_slot.amount! -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -185,32 +164,32 @@ export class PlayerControlsSystem extends System {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (player_component.screens.length === 0) {
|
if (player_component.screens.length === 0) {
|
||||||
const player_inventory = player_component.player_inventory;
|
const previous = inventories.hotbar_selected;
|
||||||
const scroll = InputManager.get_wheel_delta();
|
const scroll = InputManager.get_wheel_delta();
|
||||||
if (scroll > 0) {
|
if (scroll > 0) {
|
||||||
player_inventory.hotbar_selected = Math.min(8, player_inventory.hotbar_selected + 1);
|
inventories.hotbar_selected = Math.min(8, inventories.hotbar_selected + 1);
|
||||||
} else if (scroll < 0) {
|
} else if (scroll < 0) {
|
||||||
player_inventory.hotbar_selected = Math.max(0, player_inventory.hotbar_selected - 1);
|
inventories.hotbar_selected = Math.max(0, inventories.hotbar_selected - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (InputManager.is_key_pressed(controls.hotbar_1)) {
|
const hotbar_keys = [
|
||||||
player_inventory.hotbar_selected = 0;
|
controls.hotbar_1,
|
||||||
} else if (InputManager.is_key_pressed(controls.hotbar_2)) {
|
controls.hotbar_2,
|
||||||
player_inventory.hotbar_selected = 1;
|
controls.hotbar_3,
|
||||||
} else if (InputManager.is_key_pressed(controls.hotbar_3)) {
|
controls.hotbar_4,
|
||||||
player_inventory.hotbar_selected = 2;
|
controls.hotbar_5,
|
||||||
} else if (InputManager.is_key_pressed(controls.hotbar_4)) {
|
controls.hotbar_6,
|
||||||
player_inventory.hotbar_selected = 3;
|
controls.hotbar_7,
|
||||||
} else if (InputManager.is_key_pressed(controls.hotbar_5)) {
|
controls.hotbar_8,
|
||||||
player_inventory.hotbar_selected = 4;
|
controls.hotbar_9,
|
||||||
} else if (InputManager.is_key_pressed(controls.hotbar_6)) {
|
];
|
||||||
player_inventory.hotbar_selected = 5;
|
const pressed = hotbar_keys.findIndex((key) => InputManager.is_key_pressed(key));
|
||||||
} else if (InputManager.is_key_pressed(controls.hotbar_7)) {
|
if (pressed !== -1) {
|
||||||
player_inventory.hotbar_selected = 6;
|
inventories.hotbar_selected = pressed;
|
||||||
} else if (InputManager.is_key_pressed(controls.hotbar_8)) {
|
}
|
||||||
player_inventory.hotbar_selected = 7;
|
|
||||||
} else if (InputManager.is_key_pressed(controls.hotbar_9)) {
|
if (inventories.hotbar_selected !== previous) {
|
||||||
player_inventory.hotbar_selected = 8;
|
send({ type: "select_slot", slot: inventories.hotbar_selected });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export class RenderSystem extends System {
|
|||||||
|
|
||||||
const player_component = entity.get(PlayerComponent);
|
const player_component = entity.get(PlayerComponent);
|
||||||
if (player_component) {
|
if (player_component) {
|
||||||
render_player_hotbar(player_component.player_inventory);
|
render_player_hotbar(player_component.inventories);
|
||||||
render_player_crosshair();
|
render_player_crosshair();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
|
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
|
||||||
import { AssetManager } from "$/client/assets.ts";
|
import { AssetManager } from "$/client/assets.ts";
|
||||||
import { PlayerInventory } from "../../inventory.ts";
|
import { ClientInventories } from "../../inventory.ts";
|
||||||
import { draw_item, draw_nine_slice } from "./render_utils.ts";
|
import { draw_item, draw_nine_slice } from "./render_utils.ts";
|
||||||
import {
|
import {
|
||||||
canvas,
|
canvas,
|
||||||
@@ -14,11 +14,11 @@ import {
|
|||||||
Texture,
|
Texture,
|
||||||
} from "$/client/renderer/mod.ts";
|
} from "$/client/renderer/mod.ts";
|
||||||
import { PlayerComponent } from "../../player.ts";
|
import { PlayerComponent } from "../../player.ts";
|
||||||
import { get_sprite_region } from "../../../common/utils.ts";
|
import { get_sprite_region } from "$/client/sprites.ts";
|
||||||
|
|
||||||
const PADDING = 10;
|
const PADDING = 10;
|
||||||
|
|
||||||
export function render_player_hotbar(player_inventory: PlayerInventory) {
|
export function render_player_hotbar(inventories: ClientInventories) {
|
||||||
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
||||||
|
|
||||||
const hotbar_width = PADDING * 2 + SLOT_SIZE * 9;
|
const hotbar_width = PADDING * 2 + SLOT_SIZE * 9;
|
||||||
@@ -46,8 +46,8 @@ export function render_player_hotbar(player_inventory: PlayerInventory) {
|
|||||||
for (let index = 0; index < 9; index += 1) {
|
for (let index = 0; index < 9; index += 1) {
|
||||||
draw_nine_slice(
|
draw_nine_slice(
|
||||||
ui,
|
ui,
|
||||||
player_inventory.hotbar_selected === index ? 19 * 16 : 160 + 32,
|
inventories.hotbar_selected === index ? 19 * 16 : 160 + 32,
|
||||||
player_inventory.hotbar_selected === index ? 16 : 0,
|
inventories.hotbar_selected === index ? 16 : 0,
|
||||||
16,
|
16,
|
||||||
16,
|
16,
|
||||||
4,
|
4,
|
||||||
@@ -62,7 +62,7 @@ export function render_player_hotbar(player_inventory: PlayerInventory) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (let index = 0; index < 9; index += 1) {
|
for (let index = 0; index < 9; index += 1) {
|
||||||
const item = player_inventory.container.get_item(index);
|
const item = inventories.inventory.get_item(index);
|
||||||
if (item) {
|
if (item) {
|
||||||
draw_item(item, x + PADDING + index * SLOT_SIZE, y + PADDING);
|
draw_item(item, x + PADDING + index * SLOT_SIZE, y + PADDING);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
|
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
|
||||||
import { get_sprite_region } from "$/common/utils.ts";
|
import { get_sprite_region } from "$/client/sprites.ts";
|
||||||
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||||
import { AssetManager } from "$/client/assets.ts";
|
import { AssetManager } from "$/client/assets.ts";
|
||||||
import { ItemStack } from "../../inventory.ts";
|
import { ItemStack } from "$/common/inventory.ts";
|
||||||
import { draw_text, draw_texture_region, draw_texture_region_skewed, Texture } from "$/client/renderer/mod.ts";
|
import { draw_text, draw_texture_region, draw_texture_region_skewed, Texture } from "$/client/renderer/mod.ts";
|
||||||
|
|
||||||
export function draw_nine_slice(
|
export function draw_nine_slice(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { BlockRegistry } from "$/common/everything_registry.ts";
|
|||||||
import type { SpriteRegion } from "$/common/constants.ts";
|
import type { SpriteRegion } from "$/common/constants.ts";
|
||||||
import type { Texture } from "../renderer/types.ts";
|
import type { Texture } from "../renderer/types.ts";
|
||||||
import type { FromChunkWorker, ToChunkWorker } from "./chunk_messages.ts";
|
import type { FromChunkWorker, ToChunkWorker } from "./chunk_messages.ts";
|
||||||
import { generate_chunk } from "../generation.ts";
|
import { generate_raw_chunk } from "$/common/generation.ts";
|
||||||
|
|
||||||
const pad = 0.5;
|
const pad = 0.5;
|
||||||
|
|
||||||
@@ -311,37 +311,8 @@ function post(message: FromChunkWorker, transfer: Transferable[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generate(chunk_x: number, chunk_z: number, seed: string) {
|
function generate(chunk_x: number, chunk_z: number, seed: string) {
|
||||||
const blocks = new Uint32Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT);
|
const { blocks, spills } = generate_raw_chunk(chunk_x, chunk_z, seed, block_ids);
|
||||||
const spills: number[] = [];
|
post({ type: "generated", chunk_x, chunk_z, blocks, spills }, [blocks.buffer, spills.buffer]);
|
||||||
|
|
||||||
generate_chunk(
|
|
||||||
{
|
|
||||||
add_block(block) {
|
|
||||||
const nid = block_ids[block.id];
|
|
||||||
if (nid === undefined || block.y < 0 || block.y >= CHUNK_HEIGHT) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const block_chunk_x = Math.floor(block.x / CHUNK_SIZE);
|
|
||||||
const block_chunk_z = Math.floor(block.z / CHUNK_SIZE);
|
|
||||||
if (block_chunk_x !== chunk_x || block_chunk_z !== chunk_z) {
|
|
||||||
spills.push(block.x, block.y, block.z, nid);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lx = block.x - chunk_x * CHUNK_SIZE;
|
|
||||||
const lz = block.z - chunk_z * CHUNK_SIZE;
|
|
||||||
blocks[block.y * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx] = nid;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
chunk_x,
|
|
||||||
chunk_z,
|
|
||||||
seed,
|
|
||||||
);
|
|
||||||
|
|
||||||
const spills_array = new Int32Array(spills);
|
|
||||||
post({ type: "generated", chunk_x, chunk_z, blocks, spills: spills_array }, [
|
|
||||||
blocks.buffer,
|
|
||||||
spills_array.buffer,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function make_chunk_mesh(
|
function make_chunk_mesh(
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||||
|
import { register_block_item } from "$/common/utils.ts";
|
||||||
|
|
||||||
|
// behavior lives in server/game/blocks.ts
|
||||||
|
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:chest", {
|
||||||
|
id: "bworld:chest",
|
||||||
|
textures: "bworld:planks",
|
||||||
|
toughness: 8,
|
||||||
|
requires_tool: false,
|
||||||
|
tool_to_break: "axe",
|
||||||
|
drop_table: "bworld:chest",
|
||||||
|
has_collision: false,
|
||||||
|
interactive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
register_block_item(block);
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
import { EverythingRegistry } from "$/common/everything_registry.ts";
|
||||||
import { ItemStack } from "../inventory.ts";
|
|
||||||
import { PlayerComponent } from "../player.ts";
|
|
||||||
|
|
||||||
interface CropsRegistry {
|
interface CropsRegistry {
|
||||||
total_stages: number;
|
total_stages: number;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||||
|
import { register_block_item } from "$/common/utils.ts";
|
||||||
|
|
||||||
|
// hoeing it is handled in server/game/blocks.ts
|
||||||
|
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:dirt", {
|
||||||
|
id: "bworld:dirt",
|
||||||
|
textures: "bworld:dirt",
|
||||||
|
has_collision: false,
|
||||||
|
drop_table: "bworld:dirt",
|
||||||
|
toughness: 2,
|
||||||
|
requires_tool: false,
|
||||||
|
tool_to_break: "shovel",
|
||||||
|
});
|
||||||
|
|
||||||
|
register_block_item(block);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||||
|
import { register_block_item } from "$/common/utils.ts";
|
||||||
|
|
||||||
|
// behavior lives in server/game/blocks.ts
|
||||||
|
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:furnace", {
|
||||||
|
id: "bworld:furnace",
|
||||||
|
textures: { front: "bworld:furnace", side: "bworld:stone" },
|
||||||
|
has_collision: true,
|
||||||
|
drop_table: "bworld:furnace",
|
||||||
|
toughness: 5,
|
||||||
|
requires_tool: true,
|
||||||
|
tool_to_break: "pickaxe",
|
||||||
|
interactive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
register_block_item(block);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||||
|
|
||||||
|
// hoeing it is handled in server/game/blocks.ts
|
||||||
|
EverythingRegistry.register<BlockRegistry>("blocks", "bworld:grass", {
|
||||||
|
id: "bworld:grass",
|
||||||
|
textures: { top: "bworld:grass_top", bottom: "bworld:dirt", "side": "bworld:grass_side" },
|
||||||
|
has_collision: false,
|
||||||
|
drop_table: "bworld:dirt",
|
||||||
|
toughness: 2,
|
||||||
|
requires_tool: false,
|
||||||
|
tool_to_break: "shovel",
|
||||||
|
});
|
||||||
@@ -22,3 +22,13 @@ export const STATE_SHIFT = 16;
|
|||||||
export const CHUNK_SIZE = 16;
|
export const CHUNK_SIZE = 16;
|
||||||
export const CHUNK_HEIGHT = 128;
|
export const CHUNK_HEIGHT = 128;
|
||||||
export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE;
|
export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE;
|
||||||
|
|
||||||
|
// where a block placed against each face of another block goes
|
||||||
|
export const FACE_OFFSETS: Record<Faces, { x: number; y: number; z: number }> = {
|
||||||
|
top: { x: 0, y: 1, z: 0 },
|
||||||
|
bottom: { x: 0, y: -1, z: 0 },
|
||||||
|
north: { x: 0, y: 0, z: -1 },
|
||||||
|
south: { x: 0, y: 0, z: 1 },
|
||||||
|
west: { x: -1, y: 0, z: 0 },
|
||||||
|
east: { x: 1, y: 0, z: 0 },
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Block, Dimension } from "$/client/components/dimension.ts";
|
import type { ItemStack } from "./inventory.ts";
|
||||||
import { ItemStack } from "$/client/inventory.ts";
|
|
||||||
|
|
||||||
export class EverythingRegistry {
|
export class EverythingRegistry {
|
||||||
static #key_to_id = new Map<string, Map<string, number>>();
|
static #key_to_id = new Map<string, Map<string, number>>();
|
||||||
@@ -94,12 +93,9 @@ export interface BlockRegistry {
|
|||||||
states?: BlockStateDefinition[];
|
states?: BlockStateDefinition[];
|
||||||
variants?: Record<string, BlockStateVariant>;
|
variants?: Record<string, BlockStateVariant>;
|
||||||
|
|
||||||
on_create?(dimension: Dimension, block: Block): void;
|
// right clicking it does something instead of placing a block, clients don't predict placing against it.
|
||||||
on_break?(dimension: Dimension, block: Block): void;
|
// behavior runs on the server, see server/game/blocks.ts
|
||||||
on_click?(dimension: Dimension, block: Block): void;
|
interactive?: boolean;
|
||||||
on_interact?(dimension: Dimension, block: Block): boolean;
|
|
||||||
on_tick?(dimension: Dimension, block: Block, tick_delta: number): void;
|
|
||||||
on_second?(dimension: Dimension, block: Block, second_delta: number): void;
|
|
||||||
|
|
||||||
compiled_states?: CompiledStateDefinition[];
|
compiled_states?: CompiledStateDefinition[];
|
||||||
}
|
}
|
||||||
@@ -109,8 +105,6 @@ export interface ItemRegistry<T = unknown | undefined> {
|
|||||||
block_id?: string;
|
block_id?: string;
|
||||||
tool_type?: string;
|
tool_type?: string;
|
||||||
|
|
||||||
place?(dimension: Dimension, block: Block): void;
|
|
||||||
|
|
||||||
on_create?(item: ItemStack<T>): void;
|
on_create?(item: ItemStack<T>): void;
|
||||||
|
|
||||||
get_lore?(item: ItemStack<T>): string;
|
get_lore?(item: ItemStack<T>): string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D, NoiseFunction3D } from "@paulaboks/rng";
|
import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D, NoiseFunction3D } from "@paulaboks/rng";
|
||||||
import { CHUNK_SIZE } from "$/common/constants.ts";
|
import { CHUNK_HEIGHT, CHUNK_SIZE } from "$/common/constants.ts";
|
||||||
|
|
||||||
// generation runs in a worker now, so it only needs somewhere to put blocks
|
// generation runs in a worker now, so it only needs somewhere to put blocks
|
||||||
export interface BlockSink {
|
export interface BlockSink {
|
||||||
@@ -269,3 +269,47 @@ export function generate_chunk(dimension: BlockSink, cx: number, cz: number, see
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RawChunk {
|
||||||
|
// numeric block ids, only what this chunk generated itself
|
||||||
|
blocks: Uint32Array;
|
||||||
|
// blocks it generated in other chunks (tree leaves), flattened as x, y, z, numeric id
|
||||||
|
spills: Int32Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
// generates one chunk on its own. neighbors' spills get merged in by whoever assembles the world:
|
||||||
|
// a chunk's own blocks always win and spills only fill air, so the result doesn't depend on load order
|
||||||
|
export function generate_raw_chunk(
|
||||||
|
chunk_x: number,
|
||||||
|
chunk_z: number,
|
||||||
|
seed: string,
|
||||||
|
block_ids: Record<string, number>,
|
||||||
|
): RawChunk {
|
||||||
|
const blocks = new Uint32Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT);
|
||||||
|
const spills: number[] = [];
|
||||||
|
|
||||||
|
generate_chunk(
|
||||||
|
{
|
||||||
|
add_block(block) {
|
||||||
|
const nid = block_ids[block.id];
|
||||||
|
if (nid === undefined || block.y < 0 || block.y >= CHUNK_HEIGHT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const block_chunk_x = Math.floor(block.x / CHUNK_SIZE);
|
||||||
|
const block_chunk_z = Math.floor(block.z / CHUNK_SIZE);
|
||||||
|
if (block_chunk_x !== chunk_x || block_chunk_z !== chunk_z) {
|
||||||
|
spills.push(block.x, block.y, block.z, nid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lx = block.x - chunk_x * CHUNK_SIZE;
|
||||||
|
const lz = block.z - chunk_z * CHUNK_SIZE;
|
||||||
|
blocks[block.y * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx] = nid;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chunk_x,
|
||||||
|
chunk_z,
|
||||||
|
seed,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { blocks, spills: new Int32Array(spills) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||||
|
|
||||||
|
// how item stacks are saved and sent over the network
|
||||||
|
export interface ItemData {
|
||||||
|
id: string;
|
||||||
|
count: number;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ItemStack<T = unknown | undefined> {
|
||||||
|
type_id: string;
|
||||||
|
amount: number;
|
||||||
|
max_amount: number;
|
||||||
|
data?: T;
|
||||||
|
|
||||||
|
constructor(type_id: string | string, amount: number = 1, max_amount: number = 64) {
|
||||||
|
this.type_id = type_id;
|
||||||
|
this.amount = amount;
|
||||||
|
this.max_amount = max_amount;
|
||||||
|
this.data = undefined;
|
||||||
|
const item_info = EverythingRegistry.get<ItemRegistry>("items", type_id);
|
||||||
|
if (item_info?.on_create) {
|
||||||
|
item_info.on_create(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clone(): ItemStack {
|
||||||
|
const item = new ItemStack(this.type_id, this.amount, this.max_amount);
|
||||||
|
item.data = structuredClone(this.data);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
to_data(): ItemData {
|
||||||
|
return this.data === undefined
|
||||||
|
? { id: this.type_id, count: this.amount }
|
||||||
|
: { id: this.type_id, count: this.amount, data: this.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
static from_data(data: ItemData): ItemStack {
|
||||||
|
const item = new ItemStack(data.id, data.count);
|
||||||
|
if (data.data !== undefined) {
|
||||||
|
item.data = data.data;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContainerSlot {
|
||||||
|
#item_stack: ItemStack | undefined;
|
||||||
|
|
||||||
|
has_item() {
|
||||||
|
return this.#item_stack !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_item(item_stack: ItemStack | undefined) {
|
||||||
|
if ((item_stack?.amount ?? 0) <= 0) {
|
||||||
|
item_stack = undefined;
|
||||||
|
}
|
||||||
|
this.#item_stack = item_stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
get_item() {
|
||||||
|
return this.#item_stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
get type_id() {
|
||||||
|
return this.#item_stack?.type_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
set amount(new_amount: number) {
|
||||||
|
if (this.#item_stack) {
|
||||||
|
this.#item_stack.amount = new_amount;
|
||||||
|
if (this.#item_stack.amount <= 0) {
|
||||||
|
this.#item_stack = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get amount(): number | undefined {
|
||||||
|
return this.#item_stack?.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
get max_amount() {
|
||||||
|
return this.#item_stack?.max_amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Container {
|
||||||
|
#slots: ContainerSlot[] = [];
|
||||||
|
readonly size: number;
|
||||||
|
|
||||||
|
constructor(size: number) {
|
||||||
|
this.size = size;
|
||||||
|
for (let i = 0; i < size; i += 1) {
|
||||||
|
this.#slots.push(new ContainerSlot());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns how many items didn't fit
|
||||||
|
add_item(item_stack: ItemStack): number {
|
||||||
|
for (const slot of this.#slots.filter((slot) => slot.has_item())) {
|
||||||
|
const slot_item = slot.get_item()!;
|
||||||
|
if (slot_item.type_id === item_stack.type_id) {
|
||||||
|
const missing = slot_item.max_amount - slot_item.amount;
|
||||||
|
const adding = Math.min(missing, item_stack.amount);
|
||||||
|
slot_item.amount += adding;
|
||||||
|
item_stack.amount -= adding;
|
||||||
|
if (item_stack.amount === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const slot of this.#slots) {
|
||||||
|
if (!slot.has_item()) {
|
||||||
|
slot.set_item(item_stack);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: drop item on ground
|
||||||
|
return item_stack.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
get_item(slot: number): ItemStack | undefined {
|
||||||
|
return this.#slots[slot]?.get_item();
|
||||||
|
}
|
||||||
|
|
||||||
|
get_slot(slot: number): ContainerSlot {
|
||||||
|
return this.#slots[slot];
|
||||||
|
}
|
||||||
|
|
||||||
|
set_item(slot: number, item: ItemStack | undefined) {
|
||||||
|
this.#slots[slot].set_item(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
to_data(): (ItemData | null)[] {
|
||||||
|
return this.#slots.map((slot) => slot.get_item()?.to_data() ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
load(data: (ItemData | null)[]) {
|
||||||
|
for (let i = 0; i < this.size; i += 1) {
|
||||||
|
const item = data[i];
|
||||||
|
this.#slots[i].set_item(item ? ItemStack.from_data(item) : undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
for (const slot of this.#slots) {
|
||||||
|
slot.set_item(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the item a player is carrying around with the mouse in an inventory screen
|
||||||
|
export interface Cursor {
|
||||||
|
item: ItemStack | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LEFT_CLICK = 0;
|
||||||
|
export const RIGHT_CLICK = 2;
|
||||||
|
|
||||||
|
// what clicking a normal slot does, the server runs this for real and clients run it to predict
|
||||||
|
export function click_slot(container: Container, index: number, cursor: Cursor, button: number) {
|
||||||
|
if (button === LEFT_CLICK) {
|
||||||
|
left_click(container, index, cursor);
|
||||||
|
} else if (button === RIGHT_CLICK) {
|
||||||
|
right_click(container, index, cursor);
|
||||||
|
}
|
||||||
|
if (cursor.item && cursor.item.amount <= 0) {
|
||||||
|
cursor.item = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function swap_with_cursor(container: Container, index: number, cursor: Cursor) {
|
||||||
|
const slot = container.get_slot(index);
|
||||||
|
const original = cursor.item;
|
||||||
|
cursor.item = slot.get_item();
|
||||||
|
slot.set_item(original);
|
||||||
|
}
|
||||||
|
|
||||||
|
function left_click(container: Container, index: number, cursor: Cursor) {
|
||||||
|
const holding = cursor.item;
|
||||||
|
const slot = container.get_slot(index);
|
||||||
|
const slot_item = slot.get_item();
|
||||||
|
|
||||||
|
// if you aren't holding anything
|
||||||
|
// "swap" with nothing on your hand (pick it up)
|
||||||
|
if (!holding) {
|
||||||
|
swap_with_cursor(container, index, cursor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if you are holding something and slot type equals holding type
|
||||||
|
// try to add to stack
|
||||||
|
if (slot_item && slot.type_id === holding.type_id) {
|
||||||
|
const space_left = slot.max_amount! - slot_item.amount;
|
||||||
|
const amount_to_add = Math.min(space_left, holding.amount);
|
||||||
|
|
||||||
|
slot_item.amount += amount_to_add;
|
||||||
|
holding.amount -= amount_to_add;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if something on hand but not the same
|
||||||
|
// swap
|
||||||
|
swap_with_cursor(container, index, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function right_click(container: Container, index: number, cursor: Cursor) {
|
||||||
|
const holding = cursor.item;
|
||||||
|
const slot = container.get_slot(index);
|
||||||
|
const slot_item = slot.get_item();
|
||||||
|
|
||||||
|
// if holding something
|
||||||
|
if (holding) {
|
||||||
|
// and slot type equals holding type
|
||||||
|
// add 1 to matching stack
|
||||||
|
if (slot_item && slot.type_id === holding.type_id) {
|
||||||
|
if (slot_item.amount < slot_item.max_amount) {
|
||||||
|
slot_item.amount += 1;
|
||||||
|
holding.amount -= 1;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// place 1 into empty slot
|
||||||
|
if (!slot_item) {
|
||||||
|
const new_item = holding.clone();
|
||||||
|
new_item.amount = 1;
|
||||||
|
slot.set_item(new_item);
|
||||||
|
holding.amount -= 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if something on hand but not the same
|
||||||
|
// swap
|
||||||
|
swap_with_cursor(container, index, cursor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if player is holding nothing and clicks nothing, nothing happens
|
||||||
|
if (!slot_item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// pick up half of the stack
|
||||||
|
const original_amount = slot_item.amount;
|
||||||
|
const half = Math.floor(original_amount / 2);
|
||||||
|
|
||||||
|
const picked_up = slot_item.clone();
|
||||||
|
picked_up.amount = original_amount - half;
|
||||||
|
cursor.item = picked_up;
|
||||||
|
|
||||||
|
slot.amount = half;
|
||||||
|
}
|
||||||
|
|
||||||
|
// output slots (furnace result, crafting result) can only be taken from, all at once
|
||||||
|
// returns whether it was taken
|
||||||
|
export function take_output(item: ItemStack, cursor: Cursor): boolean {
|
||||||
|
const holding = cursor.item;
|
||||||
|
if (!holding) {
|
||||||
|
cursor.item = item.clone();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (holding.type_id === item.type_id && holding.max_amount - holding.amount >= item.amount) {
|
||||||
|
holding.amount += item.amount;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
+50
-3
@@ -1,4 +1,6 @@
|
|||||||
// messages sent between the client and the server, as json over a websocket
|
// messages sent between the client and the server, as json over a websocket
|
||||||
|
import type { Faces } from "./constants.ts";
|
||||||
|
import type { ItemData } from "./inventory.ts";
|
||||||
|
|
||||||
export const AIR_ID = "bworld:air";
|
export const AIR_ID = "bworld:air";
|
||||||
|
|
||||||
@@ -15,19 +17,64 @@ export interface PlayerInfo {
|
|||||||
// x, y, z, block id
|
// x, y, z, block id
|
||||||
export type BlockChange = [number, number, number, string];
|
export type BlockChange = [number, number, number, string];
|
||||||
|
|
||||||
|
// the containers a client can see and click. "screen" is whatever the open server screen shows
|
||||||
|
export type ContainerKey = "inventory" | "crafting" | "screen";
|
||||||
|
|
||||||
|
export const CRAFTING_RESULT_SLOT = 9;
|
||||||
|
|
||||||
|
// a screen the server opens, drawn below the player's inventory and hotbar
|
||||||
|
export interface ScreenLayout {
|
||||||
|
// height of the screen's own area, in slots
|
||||||
|
rows: number;
|
||||||
|
// x and y in slots, can be fractional
|
||||||
|
// output slots can only be taken from, like the furnace result
|
||||||
|
slots: { index: number; x: number; y: number; output?: boolean }[];
|
||||||
|
// progress bars filled from properties[value] / properties[max]
|
||||||
|
bars: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
value: string;
|
||||||
|
max: string;
|
||||||
|
direction: "up" | "right";
|
||||||
|
empty_texture: string;
|
||||||
|
full_texture: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// clients send what the player is trying to do, the server decides what happens
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
| { type: "hello"; name: string }
|
| { type: "hello"; name: string }
|
||||||
| { type: "move"; x: number; y: number; z: number; yaw: number; pitch: number }
|
| { type: "move"; x: number; y: number; z: number; yaw: number; pitch: number }
|
||||||
| { type: "set_block"; x: number; y: number; z: number; id: string }
|
| { type: "break_block"; x: number; y: number; z: number }
|
||||||
|
// right click on a block: interact with it, or place the held block against `face`
|
||||||
|
| { type: "use_block"; x: number; y: number; z: number; face: Faces }
|
||||||
|
| { type: "select_slot"; slot: number }
|
||||||
|
| { type: "click"; container: ContainerKey; index: number; button: number }
|
||||||
|
// closes the open screen, including the player's own inventory screen
|
||||||
|
| { type: "close_screen" }
|
||||||
| { type: "chat"; text: string };
|
| { type: "chat"; text: string };
|
||||||
|
|
||||||
export type ServerMessage =
|
export type ServerMessage =
|
||||||
| { type: "welcome"; id: string; seed: string; players: PlayerInfo[]; changes: BlockChange[] }
|
| {
|
||||||
|
type: "welcome";
|
||||||
|
id: string;
|
||||||
|
seed: string;
|
||||||
|
players: PlayerInfo[];
|
||||||
|
changes: BlockChange[];
|
||||||
|
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
|
||||||
|
selected_slot: number;
|
||||||
|
}
|
||||||
| { type: "player_join"; player: PlayerInfo }
|
| { type: "player_join"; player: PlayerInfo }
|
||||||
| { type: "player_leave"; id: string }
|
| { type: "player_leave"; id: string }
|
||||||
| { type: "player_move"; id: string; x: number; y: number; z: number; yaw: number; pitch: number }
|
| { type: "player_move"; id: string; x: number; y: number; z: number; yaw: number; pitch: number }
|
||||||
|
// also sent to the player who caused it, which corrects anything their client predicted wrong
|
||||||
| { type: "set_block"; x: number; y: number; z: number; id: string }
|
| { type: "set_block"; x: number; y: number; z: number; id: string }
|
||||||
| { type: "chat"; from?: string; text: string };
|
| { type: "chat"; from?: string; text: string }
|
||||||
|
| { type: "container"; container: ContainerKey; items: (ItemData | null)[] }
|
||||||
|
| { type: "cursor"; item: ItemData | null }
|
||||||
|
| { type: "open_screen"; layout: ScreenLayout; properties: Record<string, number> }
|
||||||
|
| { type: "screen_properties"; properties: Record<string, number> }
|
||||||
|
| { type: "close_screen" };
|
||||||
|
|
||||||
export const MAX_NAME_LENGTH = 16;
|
export const MAX_NAME_LENGTH = 16;
|
||||||
export const MAX_CHAT_LENGTH = 256;
|
export const MAX_CHAT_LENGTH = 256;
|
||||||
|
|||||||
+6
-9
@@ -1,5 +1,4 @@
|
|||||||
import { AssetManager } from "../client/assets.ts";
|
import { ID_MASK, STATE_SHIFT } from "./constants.ts";
|
||||||
import { ID_MASK, SpriteRegion, STATE_SHIFT } from "./constants.ts";
|
|
||||||
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "./everything_registry.ts";
|
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "./everything_registry.ts";
|
||||||
|
|
||||||
export function point_inside_rec(
|
export function point_inside_rec(
|
||||||
@@ -16,13 +15,6 @@ export function point_inside_rec(
|
|||||||
point_y < rec_y + rec_h;
|
point_y < rec_y + rec_h;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TexturesInfo = Record<string, SpriteRegion>;
|
|
||||||
|
|
||||||
export function get_sprite_region(id: string): SpriteRegion {
|
|
||||||
const textures_info = AssetManager.instance.get<TexturesInfo>("bworld:textures_info");
|
|
||||||
return textures_info?.[id] ?? { x: 0, y: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function distance_point_rectangle(px: number, py: number, sqx: number, sqy: number, sqw: number, sqh: number) {
|
export function distance_point_rectangle(px: number, py: number, sqx: number, sqy: number, sqw: number, sqh: number) {
|
||||||
const x0 = sqx;
|
const x0 = sqx;
|
||||||
const y0 = sqy;
|
const y0 = sqy;
|
||||||
@@ -106,3 +98,8 @@ function compile_block_states(block_info: BlockRegistry) {
|
|||||||
return { name: s.name, mask, shift };
|
return { name: s.name, mask, shift };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// numeric so looking chunks up doesnt allocate a string every time, fine for |x|, |z| < 32768
|
||||||
|
export function chunk_key(x: number, z: number) {
|
||||||
|
return (x + 32768) * 65536 + (z + 32768);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"tasks": {
|
"tasks": {
|
||||||
"build": "deno run -A build.ts",
|
"build": "deno run -A build.ts",
|
||||||
"serve:client": "deno run --allow-net --allow-read jsr:@std/http/file-server build",
|
"serve:client": "deno run --allow-net --allow-read jsr:@std/http/file-server build",
|
||||||
"server": "deno run --allow-net --allow-read --allow-write --allow-env server/main.ts"
|
"server": "deno run --unstable-worker-options --allow-net --allow-read --allow-write --allow-env server/main.ts"
|
||||||
},
|
},
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable", "deno.webgpu"]
|
"lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable", "deno.webgpu"]
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { Container, ItemStack } from "$/common/inventory.ts";
|
||||||
|
import { ScreenLayout } from "$/common/protocol.ts";
|
||||||
|
import type { GameServer } from "./game_server.ts";
|
||||||
|
import type { ServerPlayer } from "./player.ts";
|
||||||
|
import type { Tile } from "./world.ts";
|
||||||
|
|
||||||
|
// what blocks do, only on the server. blocks without an entry just sit there
|
||||||
|
export interface BlockBehavior {
|
||||||
|
// set up a tile for this block. blocks with this get a tile when placed
|
||||||
|
create_tile?(tile: Tile): void;
|
||||||
|
// right click, return true if it did something so no block gets placed
|
||||||
|
on_interact?(
|
||||||
|
game: GameServer,
|
||||||
|
block: { x: number; y: number; z: number; id: string },
|
||||||
|
player: ServerPlayer,
|
||||||
|
): boolean;
|
||||||
|
on_break?(game: GameServer, tile: Tile, player: ServerPlayer | undefined): void;
|
||||||
|
on_tick?(game: GameServer, tile: Tile): void;
|
||||||
|
on_second?(game: GameServer, tile: Tile): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BLOCK_BEHAVIORS: Record<string, BlockBehavior> = {};
|
||||||
|
|
||||||
|
// give the breaking player whatever was inside
|
||||||
|
function give_container_contents(tile: Tile, player: ServerPlayer | undefined) {
|
||||||
|
if (!player) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const container of Object.values(tile.containers)) {
|
||||||
|
for (let i = 0; i < container.size; i++) {
|
||||||
|
const item = container.get_item(i);
|
||||||
|
if (item) {
|
||||||
|
player.give(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hoe_into_hoed_dirt(
|
||||||
|
game: GameServer,
|
||||||
|
block: { x: number; y: number; z: number },
|
||||||
|
player: ServerPlayer,
|
||||||
|
): boolean {
|
||||||
|
if (player.held_item?.type_id !== "bworld:hoe") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
game.set_block(block.x, block.y, block.z, "bworld:hoed_dirt", player);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
BLOCK_BEHAVIORS["bworld:grass"] = { on_interact: hoe_into_hoed_dirt };
|
||||||
|
BLOCK_BEHAVIORS["bworld:dirt"] = { on_interact: hoe_into_hoed_dirt };
|
||||||
|
|
||||||
|
// chest
|
||||||
|
|
||||||
|
const CHEST_LAYOUT: ScreenLayout = {
|
||||||
|
rows: 3,
|
||||||
|
slots: Array.from({ length: 9 * 3 }, (_, index) => ({ index, x: index % 9, y: Math.floor(index / 9) })),
|
||||||
|
bars: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
BLOCK_BEHAVIORS["bworld:chest"] = {
|
||||||
|
create_tile(tile) {
|
||||||
|
tile.containers.main = new Container(9 * 3);
|
||||||
|
},
|
||||||
|
on_interact(game, block, player) {
|
||||||
|
const tile = game.get_or_create_tile(block.x, block.y, block.z);
|
||||||
|
game.open_screen(player, {
|
||||||
|
tile,
|
||||||
|
container: tile.containers.main,
|
||||||
|
layout: CHEST_LAYOUT,
|
||||||
|
properties: () => ({}),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
on_break(_game, tile, player) {
|
||||||
|
give_container_contents(tile, player);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// furnace
|
||||||
|
|
||||||
|
interface FurnaceRecipe {
|
||||||
|
input: string;
|
||||||
|
output: ItemStack;
|
||||||
|
cook_time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FURNACE_RECIPES: FurnaceRecipe[] = [
|
||||||
|
{
|
||||||
|
input: "bworld:log",
|
||||||
|
output: new ItemStack("bworld:coal", 1),
|
||||||
|
cook_time: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "bworld:coal_ore",
|
||||||
|
output: new ItemStack("bworld:coal", 1),
|
||||||
|
cook_time: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "bworld:iron_ore",
|
||||||
|
output: new ItemStack("bworld:iron_ingot", 1),
|
||||||
|
cook_time: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "bworld:tin_ore",
|
||||||
|
output: new ItemStack("bworld:tin_ingot", 1),
|
||||||
|
cook_time: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "bworld:copper_ore",
|
||||||
|
output: new ItemStack("bworld:copper_ingot", 1),
|
||||||
|
cook_time: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "bworld:gold_ore",
|
||||||
|
output: new ItemStack("bworld:gold_ingot", 1),
|
||||||
|
cook_time: 200,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const FUEL_VALUES: Record<string, number> = {
|
||||||
|
"bworld:coal": 1000,
|
||||||
|
"bworld:log": 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FurnaceData {
|
||||||
|
progress: number;
|
||||||
|
progress_max: number;
|
||||||
|
fuel: number;
|
||||||
|
fuel_max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_recipe(input?: ItemStack | undefined): FurnaceRecipe | undefined {
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FURNACE_RECIPES.find((r) => r.input === input.type_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function can_craft(container: Container, recipe?: FurnaceRecipe) {
|
||||||
|
if (!recipe) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = container.get_item(2);
|
||||||
|
|
||||||
|
if (!output) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (output.type_id !== recipe.output.type_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.amount < output.max_amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_fuel_value(item?: ItemStack | undefined): number {
|
||||||
|
if (!item) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return FUEL_VALUES[item.type_id] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function has_fuel(container: Container) {
|
||||||
|
return get_fuel_value(container.get_item(1)) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function consume_fuel(container: Container): number {
|
||||||
|
const fuel = container.get_slot(1)!;
|
||||||
|
const value = get_fuel_value(fuel.get_item());
|
||||||
|
|
||||||
|
if (fuel.has_item()) {
|
||||||
|
fuel.amount! -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function craft(container: Container, recipe: FurnaceRecipe) {
|
||||||
|
const input = container.get_item(0)!;
|
||||||
|
const output = container.get_item(2);
|
||||||
|
|
||||||
|
if (!output) {
|
||||||
|
container.set_item(2, recipe.output.clone());
|
||||||
|
} else {
|
||||||
|
output.amount += recipe.output.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.amount -= 1;
|
||||||
|
container.set_item(0, input.amount > 0 ? input : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FURNACE_LAYOUT: ScreenLayout = {
|
||||||
|
rows: 3,
|
||||||
|
slots: [
|
||||||
|
{ index: 0, x: 3.5, y: 0 },
|
||||||
|
{ index: 1, x: 3.5, y: 2 },
|
||||||
|
{ index: 2, x: 5.5, y: 1, output: true },
|
||||||
|
],
|
||||||
|
bars: [
|
||||||
|
{
|
||||||
|
x: 3.5,
|
||||||
|
y: 1,
|
||||||
|
value: "fuel",
|
||||||
|
max: "fuel_max",
|
||||||
|
direction: "up",
|
||||||
|
empty_texture: "bworld:fire_empty",
|
||||||
|
full_texture: "bworld:fire_full",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: 4.5,
|
||||||
|
y: 1,
|
||||||
|
value: "progress",
|
||||||
|
max: "progress_max",
|
||||||
|
direction: "right",
|
||||||
|
empty_texture: "bworld:arrow_empty",
|
||||||
|
full_texture: "bworld:arrow_full",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
BLOCK_BEHAVIORS["bworld:furnace"] = {
|
||||||
|
create_tile(tile) {
|
||||||
|
tile.containers.main = new Container(3);
|
||||||
|
tile.data = { progress: 0, progress_max: 0, fuel: 0, fuel_max: 0 } satisfies FurnaceData;
|
||||||
|
},
|
||||||
|
on_interact(game, block, player) {
|
||||||
|
const tile = game.get_or_create_tile(block.x, block.y, block.z);
|
||||||
|
const data = tile.data as unknown as FurnaceData;
|
||||||
|
game.open_screen(player, {
|
||||||
|
tile,
|
||||||
|
container: tile.containers.main,
|
||||||
|
layout: FURNACE_LAYOUT,
|
||||||
|
properties: () => ({ ...data }),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
on_break(_game, tile, player) {
|
||||||
|
give_container_contents(tile, player);
|
||||||
|
},
|
||||||
|
on_tick(_game, tile) {
|
||||||
|
const data = tile.data as unknown as FurnaceData;
|
||||||
|
const container = tile.containers.main;
|
||||||
|
|
||||||
|
const input = container.get_item(0);
|
||||||
|
const recipe = get_recipe(input);
|
||||||
|
|
||||||
|
// burn fuel
|
||||||
|
if (data.fuel > 0) {
|
||||||
|
data.fuel -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!can_craft(container, recipe)) {
|
||||||
|
data.progress = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// refuel
|
||||||
|
if (data.fuel === 0 && has_fuel(container)) {
|
||||||
|
data.fuel = consume_fuel(container);
|
||||||
|
data.fuel_max = data.fuel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// cook
|
||||||
|
if (data.fuel > 0 && recipe) {
|
||||||
|
data.progress_max = recipe.cook_time;
|
||||||
|
data.progress += 1;
|
||||||
|
|
||||||
|
if (data.progress >= recipe.cook_time) {
|
||||||
|
data.progress = 0;
|
||||||
|
craft(container, recipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { Container, ItemStack } from "$/common/inventory.ts";
|
||||||
|
import { CRAFTING_RESULT_SLOT } from "$/common/protocol.ts";
|
||||||
|
|
||||||
|
export interface CraftingRecipe {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
pattern: (string | undefined)[];
|
||||||
|
result: { id: string; count: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipes: CraftingRecipe[] = [
|
||||||
|
{
|
||||||
|
width: 3,
|
||||||
|
height: 3,
|
||||||
|
pattern: [
|
||||||
|
"bworld:planks",
|
||||||
|
"bworld:planks",
|
||||||
|
"bworld:planks",
|
||||||
|
"bworld:planks",
|
||||||
|
undefined,
|
||||||
|
"bworld:planks",
|
||||||
|
"bworld:planks",
|
||||||
|
"bworld:planks",
|
||||||
|
"bworld:planks",
|
||||||
|
],
|
||||||
|
result: { id: "bworld:chest", count: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
width: 3,
|
||||||
|
height: 3,
|
||||||
|
pattern: [
|
||||||
|
"bworld:stone",
|
||||||
|
"bworld:stone",
|
||||||
|
"bworld:stone",
|
||||||
|
"bworld:stone",
|
||||||
|
undefined,
|
||||||
|
"bworld:stone",
|
||||||
|
"bworld:stone",
|
||||||
|
"bworld:stone",
|
||||||
|
"bworld:stone",
|
||||||
|
],
|
||||||
|
result: { id: "bworld:furnace", count: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
pattern: [
|
||||||
|
"bworld:log",
|
||||||
|
],
|
||||||
|
result: { id: "bworld:planks", count: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
width: 1,
|
||||||
|
height: 2,
|
||||||
|
pattern: [
|
||||||
|
"bworld:planks",
|
||||||
|
"bworld:planks",
|
||||||
|
],
|
||||||
|
result: { id: "bworld:stick", count: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
width: 3,
|
||||||
|
height: 3,
|
||||||
|
pattern: [
|
||||||
|
"bworld:planks",
|
||||||
|
"bworld:planks",
|
||||||
|
"bworld:planks",
|
||||||
|
undefined,
|
||||||
|
"bworld:stick",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"bworld:stick",
|
||||||
|
undefined,
|
||||||
|
],
|
||||||
|
result: { id: "bworld:wood_pickaxe", count: 1 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function get_crafting_grid(crafting: Container): (string | undefined)[] {
|
||||||
|
const grid: (string | undefined)[] = [];
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
grid.push(crafting.get_item(i)?.type_id);
|
||||||
|
}
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches_recipe(grid: (string | undefined)[], recipe: CraftingRecipe): boolean {
|
||||||
|
for (let y = 0; y <= 3 - recipe.height; y++) {
|
||||||
|
for (let x = 0; x <= 3 - recipe.width; x++) {
|
||||||
|
let match = true;
|
||||||
|
|
||||||
|
for (let gy = 0; gy < 3; gy++) {
|
||||||
|
for (let gx = 0; gx < 3; gx++) {
|
||||||
|
const grid_index = gy * 3 + gx;
|
||||||
|
|
||||||
|
if (gx >= x && gx < x + recipe.width && gy >= y && gy < y + recipe.height) {
|
||||||
|
const recipe_index = (gy - y) * recipe.width + (gx - x);
|
||||||
|
if (grid[grid_index] !== recipe.pattern[recipe_index]) {
|
||||||
|
match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (grid[grid_index] !== undefined) {
|
||||||
|
match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!match) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// puts what the grid makes in the result slot
|
||||||
|
export function update_crafting_result(crafting: Container) {
|
||||||
|
const grid = get_crafting_grid(crafting);
|
||||||
|
const recipe = recipes.find((recipe) => matches_recipe(grid, recipe));
|
||||||
|
crafting.set_item(CRAFTING_RESULT_SLOT, recipe ? new ItemStack(recipe.result.id, recipe.result.count) : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consume_recipe_items(crafting: Container) {
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
const slot = crafting.get_slot(i);
|
||||||
|
if (slot.has_item()) {
|
||||||
|
slot.amount = slot.amount! - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
import "$/common/blocks/mod.ts";
|
||||||
|
import "$/common/items/mod.ts";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AIR,
|
||||||
|
CHUNK_HEIGHT,
|
||||||
|
CHUNK_SIZE,
|
||||||
|
FACE_OFFSETS,
|
||||||
|
Faces,
|
||||||
|
faces,
|
||||||
|
TICK_DELTA,
|
||||||
|
TICKS_PER_SECOND,
|
||||||
|
} from "$/common/constants.ts";
|
||||||
|
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||||
|
import { click_slot, Container, ItemData, ItemStack, take_output } from "$/common/inventory.ts";
|
||||||
|
import {
|
||||||
|
AIR_ID,
|
||||||
|
BlockChange,
|
||||||
|
ClientMessage,
|
||||||
|
ContainerKey,
|
||||||
|
CRAFTING_RESULT_SLOT,
|
||||||
|
MAX_CHAT_LENGTH,
|
||||||
|
MAX_NAME_LENGTH,
|
||||||
|
ServerMessage,
|
||||||
|
} from "$/common/protocol.ts";
|
||||||
|
import { BLOCK_BEHAVIORS } from "./blocks.ts";
|
||||||
|
import { consume_recipe_items, update_crafting_result } from "./crafting.ts";
|
||||||
|
import { OpenScreen, SavedPlayer, ServerPlayer } from "./player.ts";
|
||||||
|
import { ServerWorld, Tile } from "./world.ts";
|
||||||
|
|
||||||
|
// how far from a player's eyes a block can be changed, a bit more than the client's reach
|
||||||
|
const MAX_REACH = 8;
|
||||||
|
const EYE_HEIGHT = 1.69;
|
||||||
|
// tiles further than this many chunks from every player don't tick
|
||||||
|
const SIMULATION_DISTANCE = 6;
|
||||||
|
const MAX_GIVE = 64 * 36;
|
||||||
|
|
||||||
|
// everything the game server needs from whatever runs it
|
||||||
|
export interface GameHost {
|
||||||
|
send(conn: number, data: string): void;
|
||||||
|
close(conn: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedWorld {
|
||||||
|
version: 2;
|
||||||
|
seed: string;
|
||||||
|
changes: BlockChange[];
|
||||||
|
tiles: {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
containers: Record<string, (ItemData | null)[]>;
|
||||||
|
}[];
|
||||||
|
players: Record<string, SavedPlayer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GameServer {
|
||||||
|
world: ServerWorld;
|
||||||
|
#host: GameHost;
|
||||||
|
#players = new Map<number, ServerPlayer>();
|
||||||
|
#saved_players: Record<string, SavedPlayer> = {};
|
||||||
|
#tick = 0;
|
||||||
|
|
||||||
|
constructor(host: GameHost, save: string | undefined, default_seed: string) {
|
||||||
|
this.#host = host;
|
||||||
|
|
||||||
|
// version 1 saves only had the seed and block changes
|
||||||
|
const saved: Partial<SavedWorld> | undefined = save ? JSON.parse(save) : undefined;
|
||||||
|
this.world = new ServerWorld(saved?.seed ?? default_seed);
|
||||||
|
this.world.load_changes(saved?.changes ?? []);
|
||||||
|
for (const tile of saved?.tiles ?? []) {
|
||||||
|
const containers: Record<string, Container> = {};
|
||||||
|
for (const [name, items] of Object.entries(tile.containers)) {
|
||||||
|
containers[name] = new Container(items.length);
|
||||||
|
containers[name].load(items);
|
||||||
|
}
|
||||||
|
this.world.tiles.set(`${tile.x},${tile.y},${tile.z}`, { ...tile, containers });
|
||||||
|
}
|
||||||
|
this.#saved_players = saved?.players ?? {};
|
||||||
|
this.world.dirty = saved?.version !== 2;
|
||||||
|
|
||||||
|
this.world.on_block_change = (x, y, z, id) => this.#broadcast({ type: "set_block", x, y, z, id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// connections
|
||||||
|
|
||||||
|
on_connect(_conn: number) {}
|
||||||
|
|
||||||
|
on_disconnect(conn: number) {
|
||||||
|
const player = this.#players.get(conn);
|
||||||
|
if (!player) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#close_screen(player);
|
||||||
|
this.#saved_players[player.name] = player.save();
|
||||||
|
this.world.dirty = true;
|
||||||
|
this.#players.delete(conn);
|
||||||
|
this.#broadcast({ type: "player_leave", id: player.id });
|
||||||
|
this.#broadcast({ type: "chat", text: `${player.name} left` });
|
||||||
|
console.log(`${player.name} left (${this.#players.size} online)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
on_message(conn: number, data: string) {
|
||||||
|
let message: ClientMessage;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof message !== "object" || message === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = this.#players.get(conn);
|
||||||
|
if (!player) {
|
||||||
|
if (message.type === "hello") {
|
||||||
|
this.#join(conn, message.name);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#handle(player, message);
|
||||||
|
this.#sync(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
// game loop, the host calls this TICKS_PER_SECOND times a second
|
||||||
|
|
||||||
|
tick() {
|
||||||
|
this.#tick += 1;
|
||||||
|
const second = this.#tick % TICKS_PER_SECOND === 0;
|
||||||
|
|
||||||
|
for (const tile of [...this.world.tiles.values()]) {
|
||||||
|
const behavior = BLOCK_BEHAVIORS[tile.id];
|
||||||
|
if (!behavior?.on_tick && !behavior?.on_second) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!this.#near_any_player(tile.x, tile.z)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
behavior.on_tick?.(this, tile);
|
||||||
|
if (second) {
|
||||||
|
behavior.on_second?.(this, tile);
|
||||||
|
}
|
||||||
|
// tile data can change on any tick, saving is cheap enough to not track it exactly
|
||||||
|
this.world.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const player of this.#players.values()) {
|
||||||
|
this.#sync(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): string {
|
||||||
|
for (const player of this.#players.values()) {
|
||||||
|
this.#saved_players[player.name] = player.save();
|
||||||
|
}
|
||||||
|
const saved: SavedWorld = {
|
||||||
|
version: 2,
|
||||||
|
seed: this.world.seed,
|
||||||
|
changes: this.world.all_changes(),
|
||||||
|
tiles: [...this.world.tiles.values()].map((tile) => ({
|
||||||
|
id: tile.id,
|
||||||
|
x: tile.x,
|
||||||
|
y: tile.y,
|
||||||
|
z: tile.z,
|
||||||
|
data: tile.data,
|
||||||
|
containers: Object.fromEntries(
|
||||||
|
Object.entries(tile.containers).map(([name, container]) => [name, container.to_data()]),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
players: this.#saved_players,
|
||||||
|
};
|
||||||
|
this.world.dirty = false;
|
||||||
|
return JSON.stringify(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
// used by block behaviors
|
||||||
|
|
||||||
|
set_block(x: number, y: number, z: number, id: string, player?: ServerPlayer) {
|
||||||
|
const old_tile = this.world.get_tile(x, y, z);
|
||||||
|
if (old_tile) {
|
||||||
|
BLOCK_BEHAVIORS[old_tile.id]?.on_break?.(this, old_tile, player);
|
||||||
|
this.world.remove_tile(x, y, z);
|
||||||
|
for (const other of this.#players.values()) {
|
||||||
|
if (other.screen?.tile === old_tile) {
|
||||||
|
this.#close_screen(other);
|
||||||
|
this.#send(other, { type: "close_screen" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.world.set_block(x, y, z, id);
|
||||||
|
|
||||||
|
if (BLOCK_BEHAVIORS[id]?.create_tile) {
|
||||||
|
this.get_or_create_tile(x, y, z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get_or_create_tile(x: number, y: number, z: number): Tile {
|
||||||
|
let tile = this.world.get_tile(x, y, z);
|
||||||
|
if (!tile) {
|
||||||
|
// blocks placed before tiles were saved have none yet
|
||||||
|
const id = this.world.get_block_id(x, y, z);
|
||||||
|
tile = { id, x, y, z, data: {}, containers: {} };
|
||||||
|
BLOCK_BEHAVIORS[id]?.create_tile?.(tile);
|
||||||
|
this.world.add_tile(tile);
|
||||||
|
}
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
open_screen(player: ServerPlayer, screen: OpenScreen) {
|
||||||
|
this.#close_screen(player);
|
||||||
|
player.screen = screen;
|
||||||
|
this.#send(player, { type: "open_screen", layout: screen.layout, properties: screen.properties() });
|
||||||
|
player.sent.set("properties", JSON.stringify(screen.properties()));
|
||||||
|
player.sent.delete("screen");
|
||||||
|
}
|
||||||
|
|
||||||
|
// messages
|
||||||
|
|
||||||
|
#join(conn: number, raw_name: unknown) {
|
||||||
|
const player = new ServerPlayer(conn, this.#unique_name(raw_name));
|
||||||
|
const saved = this.#saved_players[player.name];
|
||||||
|
if (saved) {
|
||||||
|
player.load(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#send(player, {
|
||||||
|
type: "welcome",
|
||||||
|
id: player.id,
|
||||||
|
seed: this.world.seed,
|
||||||
|
players: [...this.#players.values()].map((p) => p.info()),
|
||||||
|
changes: this.world.all_changes(),
|
||||||
|
spawn: { x: player.x, y: player.y, z: player.z, yaw: player.yaw, pitch: player.pitch },
|
||||||
|
selected_slot: player.selected_slot,
|
||||||
|
});
|
||||||
|
this.#players.set(conn, player);
|
||||||
|
this.#sync(player);
|
||||||
|
|
||||||
|
this.#broadcast({ type: "player_join", player: player.info() }, player);
|
||||||
|
this.#broadcast({ type: "chat", text: `${player.name} joined` });
|
||||||
|
console.log(`${player.name} joined (${this.#players.size} online)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
#handle(player: ServerPlayer, message: ClientMessage) {
|
||||||
|
switch (message.type) {
|
||||||
|
case "move": {
|
||||||
|
const { x, y, z, yaw, pitch } = message;
|
||||||
|
if (![x, y, z, yaw, pitch].every(is_number)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.assign(player, { x, y, z, yaw, pitch });
|
||||||
|
this.#broadcast({ type: "player_move", id: player.id, x, y, z, yaw, pitch }, player);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "break_block":
|
||||||
|
if (is_block_position(message.x, message.y, message.z)) {
|
||||||
|
this.#break_block(player, message.x, message.y, message.z);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "use_block":
|
||||||
|
if (is_block_position(message.x, message.y, message.z) && faces.includes(message.face)) {
|
||||||
|
this.#use_block(player, message.x, message.y, message.z, message.face);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "select_slot":
|
||||||
|
if (is_int(message.slot) && message.slot >= 0 && message.slot < 9) {
|
||||||
|
player.selected_slot = message.slot;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "click":
|
||||||
|
this.#click(player, message.container, message.index, message.button);
|
||||||
|
break;
|
||||||
|
case "close_screen":
|
||||||
|
this.#close_screen(player);
|
||||||
|
break;
|
||||||
|
case "chat":
|
||||||
|
this.#chat(player, message.text);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#break_block(player: ServerPlayer, x: number, y: number, z: number) {
|
||||||
|
const info = this.world.get_block_info(x, y, z);
|
||||||
|
// air, or something like water that can't be broken
|
||||||
|
if (!info || info.toughness === undefined || !this.#in_reach(player, x, y, z)) {
|
||||||
|
this.#correct(player, x, y, z);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const held = EverythingRegistry.get<ItemRegistry>("items", player.held_item?.type_id ?? "");
|
||||||
|
const drops = info.drop_table && (!info.requires_tool || held?.tool_type === info.tool_to_break);
|
||||||
|
|
||||||
|
this.set_block(x, y, z, AIR_ID, player);
|
||||||
|
if (drops) {
|
||||||
|
player.give(new ItemStack(info.drop_table!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#use_block(player: ServerPlayer, x: number, y: number, z: number, face: Faces) {
|
||||||
|
const offset = FACE_OFFSETS[face];
|
||||||
|
const [tx, ty, tz] = [x + offset.x, y + offset.y, z + offset.z];
|
||||||
|
|
||||||
|
const info = this.world.get_block_info(x, y, z);
|
||||||
|
if (!info || !this.#in_reach(player, x, y, z)) {
|
||||||
|
this.#correct(player, x, y, z);
|
||||||
|
this.#correct(player, tx, ty, tz);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BLOCK_BEHAVIORS[info.id]?.on_interact?.(this, { x, y, z, id: info.id }, player)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// place the held block against the face
|
||||||
|
const held_slot = player.inventory.get_slot(player.selected_slot);
|
||||||
|
const held = EverythingRegistry.get<ItemRegistry>("items", held_slot.type_id ?? "");
|
||||||
|
if (!held?.block_id || ty < 0 || ty >= CHUNK_HEIGHT || !this.#replaceable(tx, ty, tz)) {
|
||||||
|
this.#correct(player, tx, ty, tz);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.set_block(tx, ty, tz, held.block_id, player);
|
||||||
|
held_slot.amount = held_slot.amount! - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#click(player: ServerPlayer, key: unknown, index: unknown, button: unknown) {
|
||||||
|
if (!is_int(index) || !is_int(button)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const container = this.#get_container(player, key as ContainerKey);
|
||||||
|
if (!container || index < 0 || index >= container.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "crafting" && index === CRAFTING_RESULT_SLOT) {
|
||||||
|
const result = container.get_item(CRAFTING_RESULT_SLOT);
|
||||||
|
if (result && take_output(result, player.cursor)) {
|
||||||
|
consume_recipe_items(container);
|
||||||
|
}
|
||||||
|
} else if (key === "screen" && player.screen?.layout.slots.find((s) => s.index === index)?.output) {
|
||||||
|
const item = container.get_item(index);
|
||||||
|
if (item && take_output(item, player.cursor)) {
|
||||||
|
container.set_item(index, undefined);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
click_slot(container, index, player.cursor, button);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "crafting") {
|
||||||
|
update_crafting_result(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat(player: ServerPlayer, raw: unknown) {
|
||||||
|
if (typeof raw !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = raw.trim().slice(0, MAX_CHAT_LENGTH);
|
||||||
|
if (text.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text.startsWith("/")) {
|
||||||
|
this.#command(player, text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`<${player.name}> ${text}`);
|
||||||
|
this.#broadcast({ type: "chat", from: player.name, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
#command(player: ServerPlayer, text: string) {
|
||||||
|
const [command, ...args] = text.slice(1).split(/\s+/);
|
||||||
|
if (command === "give") {
|
||||||
|
// TODO: only let operators do this
|
||||||
|
let [item_id, count] = args;
|
||||||
|
if (!item_id) {
|
||||||
|
this.#send(player, { type: "chat", text: "Usage: /give <item> [count]" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!item_id.includes(":")) {
|
||||||
|
item_id = `bworld:${item_id}`;
|
||||||
|
}
|
||||||
|
const amount = count === undefined ? 1 : Number(count);
|
||||||
|
if (!EverythingRegistry.get("items", item_id)) {
|
||||||
|
this.#send(player, { type: "chat", text: `Unknown item ${item_id}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(amount) || amount < 1 || amount > MAX_GIVE) {
|
||||||
|
this.#send(player, { type: "chat", text: `Count must be between 1 and ${MAX_GIVE}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// split into stacks so max stack sizes are respected
|
||||||
|
let left = amount;
|
||||||
|
while (left > 0) {
|
||||||
|
const stack = new ItemStack(item_id);
|
||||||
|
stack.amount = Math.min(left, stack.max_amount);
|
||||||
|
left -= stack.amount;
|
||||||
|
player.give(stack);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#send(player, { type: "chat", text: `Unknown command /${command}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
#close_screen(player: ServerPlayer) {
|
||||||
|
player.screen = undefined;
|
||||||
|
player.sent.delete("screen");
|
||||||
|
player.sent.delete("properties");
|
||||||
|
|
||||||
|
// the crafting grid and whatever the cursor holds go back into the inventory
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
const item = player.crafting.get_item(i);
|
||||||
|
if (item) {
|
||||||
|
player.give(item);
|
||||||
|
player.crafting.set_item(i, undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update_crafting_result(player.crafting);
|
||||||
|
if (player.cursor.item) {
|
||||||
|
player.give(player.cursor.item);
|
||||||
|
player.cursor.item = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
|
||||||
|
#get_container(player: ServerPlayer, key: ContainerKey): Container | undefined {
|
||||||
|
switch (key) {
|
||||||
|
case "inventory":
|
||||||
|
return player.inventory;
|
||||||
|
case "crafting":
|
||||||
|
return player.crafting;
|
||||||
|
case "screen":
|
||||||
|
return player.screen?.container;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sends whatever changed since last time
|
||||||
|
#sync(player: ServerPlayer) {
|
||||||
|
const sync = (key: string, value: unknown, message: () => ServerMessage) => {
|
||||||
|
const json = JSON.stringify(value);
|
||||||
|
if (player.sent.get(key) !== json) {
|
||||||
|
player.sent.set(key, json);
|
||||||
|
this.#send(player, message());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inventory = player.inventory.to_data();
|
||||||
|
sync("inventory", inventory, () => ({ type: "container", container: "inventory", items: inventory }));
|
||||||
|
const crafting = player.crafting.to_data();
|
||||||
|
sync("crafting", crafting, () => ({ type: "container", container: "crafting", items: crafting }));
|
||||||
|
const cursor = player.cursor.item?.to_data() ?? null;
|
||||||
|
sync("cursor", cursor, () => ({ type: "cursor", item: cursor }));
|
||||||
|
|
||||||
|
if (player.screen) {
|
||||||
|
const items = player.screen.container.to_data();
|
||||||
|
sync("screen", items, () => ({ type: "container", container: "screen", items }));
|
||||||
|
const properties = player.screen.properties();
|
||||||
|
sync("properties", properties, () => ({ type: "screen_properties", properties }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tell the player what's really at a position, undoing anything their client guessed
|
||||||
|
#correct(player: ServerPlayer, x: number, y: number, z: number) {
|
||||||
|
if (y < 0 || y >= CHUNK_HEIGHT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#send(player, { type: "set_block", x, y, z, id: this.world.get_block_id(x, y, z) });
|
||||||
|
}
|
||||||
|
|
||||||
|
#replaceable(x: number, y: number, z: number) {
|
||||||
|
const nid = this.world.get_block_nid(x, y, z);
|
||||||
|
return nid === AIR || EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid)?.id === "bworld:water";
|
||||||
|
}
|
||||||
|
|
||||||
|
#in_reach(player: ServerPlayer, x: number, y: number, z: number) {
|
||||||
|
const dx = x + 0.5 - player.x;
|
||||||
|
const dy = y + 0.5 - (player.y + EYE_HEIGHT);
|
||||||
|
const dz = z + 0.5 - player.z;
|
||||||
|
return dx * dx + dy * dy + dz * dz <= MAX_REACH * MAX_REACH;
|
||||||
|
}
|
||||||
|
|
||||||
|
#near_any_player(x: number, z: number) {
|
||||||
|
const chunk_x = Math.floor(x / CHUNK_SIZE);
|
||||||
|
const chunk_z = Math.floor(z / CHUNK_SIZE);
|
||||||
|
for (const player of this.#players.values()) {
|
||||||
|
const px = Math.floor(player.x / CHUNK_SIZE);
|
||||||
|
const pz = Math.floor(player.z / CHUNK_SIZE);
|
||||||
|
if (Math.max(Math.abs(px - chunk_x), Math.abs(pz - chunk_z)) <= SIMULATION_DISTANCE) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#unique_name(name: unknown): string {
|
||||||
|
const cleaned = typeof name === "string" ? name.replace(/[^A-Za-z0-9_]/g, "").slice(0, MAX_NAME_LENGTH) : "";
|
||||||
|
const base = cleaned || `player${Math.floor(Math.random() * 10000)}`;
|
||||||
|
|
||||||
|
const taken = new Set([...this.#players.values()].map((p) => p.name));
|
||||||
|
let final = base;
|
||||||
|
let i = 2;
|
||||||
|
while (taken.has(final)) {
|
||||||
|
final = `${base}${i}`;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return final;
|
||||||
|
}
|
||||||
|
|
||||||
|
#send(player: ServerPlayer, message: ServerMessage) {
|
||||||
|
this.#host.send(player.conn, JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
#broadcast(message: ServerMessage, except?: ServerPlayer) {
|
||||||
|
const data = JSON.stringify(message);
|
||||||
|
for (const player of this.#players.values()) {
|
||||||
|
if (player !== except) {
|
||||||
|
this.#host.send(player.conn, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ticks happen at a fixed rate
|
||||||
|
export const TICK_MS = TICK_DELTA * 1000;
|
||||||
|
|
||||||
|
function is_number(value: unknown): value is number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function is_int(value: unknown): value is number {
|
||||||
|
return Number.isInteger(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// messages are parsed json, so the types in ClientMessage are only what a well behaved client sends
|
||||||
|
function is_block_position(x: unknown, y: unknown, z: unknown): boolean {
|
||||||
|
return is_int(x) && is_int(y) && is_int(z) && y >= 0 && y < CHUNK_HEIGHT &&
|
||||||
|
Math.abs(x) < 30000 * CHUNK_SIZE && Math.abs(z) < 30000 * CHUNK_SIZE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// messages between server/main.ts (the host) and the game server worker
|
||||||
|
|
||||||
|
export type HostToGame =
|
||||||
|
| { type: "init"; save: string | undefined; default_seed: string }
|
||||||
|
| { type: "connect"; conn: number }
|
||||||
|
| { type: "message"; conn: number; data: string }
|
||||||
|
| { type: "disconnect"; conn: number }
|
||||||
|
// save now and say when it's done, for shutting down
|
||||||
|
| { type: "shutdown" };
|
||||||
|
|
||||||
|
export type GameToHost =
|
||||||
|
| { type: "ready"; seed: string }
|
||||||
|
| { type: "send"; conn: number; data: string }
|
||||||
|
| { type: "close"; conn: number }
|
||||||
|
| { type: "save"; data: string; final: boolean };
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// a Map that forgets the least recently used entries past max_size
|
||||||
|
export class LruMap<K, V> {
|
||||||
|
#map = new Map<K, V>();
|
||||||
|
readonly max_size: number;
|
||||||
|
|
||||||
|
constructor(max_size: number) {
|
||||||
|
this.max_size = max_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: K): V | undefined {
|
||||||
|
const value = this.#map.get(key);
|
||||||
|
if (value !== undefined) {
|
||||||
|
// maps keep insertion order, so re-inserting moves it to the back
|
||||||
|
this.#map.delete(key);
|
||||||
|
this.#map.set(key, value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: K, value: V) {
|
||||||
|
this.#map.delete(key);
|
||||||
|
this.#map.set(key, value);
|
||||||
|
if (this.#map.size > this.max_size) {
|
||||||
|
this.#map.delete(this.#map.keys().next().value!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: K) {
|
||||||
|
this.#map.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
get size() {
|
||||||
|
return this.#map.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Container, Cursor, ItemData, ItemStack } from "$/common/inventory.ts";
|
||||||
|
import { PlayerInfo, ScreenLayout } from "$/common/protocol.ts";
|
||||||
|
import { Tile } from "./world.ts";
|
||||||
|
|
||||||
|
export const INVENTORY_SIZE = 9 * 4;
|
||||||
|
export const CRAFTING_SIZE = 10;
|
||||||
|
|
||||||
|
// a screen the server opened for this player, showing a tile's container
|
||||||
|
export interface OpenScreen {
|
||||||
|
tile: Tile;
|
||||||
|
container: Container;
|
||||||
|
layout: ScreenLayout;
|
||||||
|
properties(): Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// what's saved about a player between sessions, by name
|
||||||
|
export interface SavedPlayer {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
yaw: number;
|
||||||
|
pitch: number;
|
||||||
|
selected_slot: number;
|
||||||
|
inventory: (ItemData | null)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServerPlayer {
|
||||||
|
readonly conn: number;
|
||||||
|
readonly id = crypto.randomUUID();
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
x = 0;
|
||||||
|
y = 100;
|
||||||
|
z = 0;
|
||||||
|
yaw = 0;
|
||||||
|
pitch = 0;
|
||||||
|
|
||||||
|
inventory = new Container(INVENTORY_SIZE);
|
||||||
|
crafting = new Container(CRAFTING_SIZE);
|
||||||
|
cursor: Cursor = { item: undefined };
|
||||||
|
selected_slot = 0;
|
||||||
|
screen: OpenScreen | undefined;
|
||||||
|
|
||||||
|
// last json sent for each synced thing, so only changes get sent
|
||||||
|
sent = new Map<string, string>();
|
||||||
|
|
||||||
|
constructor(conn: number, name: string) {
|
||||||
|
this.conn = conn;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get held_item(): ItemStack | undefined {
|
||||||
|
return this.inventory.get_item(this.selected_slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
give(item: ItemStack) {
|
||||||
|
// TODO: drop what doesn't fit once items can be on the ground
|
||||||
|
this.inventory.add_item(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(): PlayerInfo {
|
||||||
|
return { id: this.id, name: this.name, x: this.x, y: this.y, z: this.z, yaw: this.yaw, pitch: this.pitch };
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): SavedPlayer {
|
||||||
|
return {
|
||||||
|
x: this.x,
|
||||||
|
y: this.y,
|
||||||
|
z: this.z,
|
||||||
|
yaw: this.yaw,
|
||||||
|
pitch: this.pitch,
|
||||||
|
selected_slot: this.selected_slot,
|
||||||
|
inventory: this.inventory.to_data(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
load(saved: SavedPlayer) {
|
||||||
|
this.x = saved.x;
|
||||||
|
this.y = saved.y;
|
||||||
|
this.z = saved.z;
|
||||||
|
this.yaw = saved.yaw;
|
||||||
|
this.pitch = saved.pitch;
|
||||||
|
this.selected_slot = saved.selected_slot;
|
||||||
|
this.inventory.load(saved.inventory);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/// <reference lib="deno.worker" />
|
||||||
|
|
||||||
|
// runs the game in a worker without any permissions, the host does files and networking
|
||||||
|
import { GameServer, TICK_MS } from "./game_server.ts";
|
||||||
|
import { GameToHost, HostToGame } from "./host_protocol.ts";
|
||||||
|
|
||||||
|
const SAVE_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
let game: GameServer | undefined;
|
||||||
|
|
||||||
|
function post(message: GameToHost) {
|
||||||
|
self.postMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = (event: MessageEvent<HostToGame>) => {
|
||||||
|
const message = event.data;
|
||||||
|
|
||||||
|
if (message.type === "init") {
|
||||||
|
game = new GameServer(
|
||||||
|
{
|
||||||
|
send: (conn, data) => post({ type: "send", conn, data }),
|
||||||
|
close: (conn) => post({ type: "close", conn }),
|
||||||
|
},
|
||||||
|
message.save,
|
||||||
|
message.default_seed,
|
||||||
|
);
|
||||||
|
|
||||||
|
setInterval(() => game!.tick(), TICK_MS);
|
||||||
|
setInterval(() => {
|
||||||
|
if (game!.world.dirty) {
|
||||||
|
post({ type: "save", data: game!.save(), final: false });
|
||||||
|
}
|
||||||
|
}, SAVE_INTERVAL_MS);
|
||||||
|
|
||||||
|
post({ type: "ready", seed: game.world.seed });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!game) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case "connect":
|
||||||
|
game.on_connect(message.conn);
|
||||||
|
break;
|
||||||
|
case "message":
|
||||||
|
game.on_message(message.conn, message.data);
|
||||||
|
break;
|
||||||
|
case "disconnect":
|
||||||
|
game.on_disconnect(message.conn);
|
||||||
|
break;
|
||||||
|
case "shutdown":
|
||||||
|
post({ type: "save", data: game.save(), final: true });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts";
|
||||||
|
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||||
|
import { generate_raw_chunk, RawChunk } from "$/common/generation.ts";
|
||||||
|
import { Container } from "$/common/inventory.ts";
|
||||||
|
import { AIR_ID, BlockChange } from "$/common/protocol.ts";
|
||||||
|
import { chunk_key } from "$/common/utils.ts";
|
||||||
|
import { LruMap } from "./lru.ts";
|
||||||
|
|
||||||
|
// generation only, rebuilt when needed. 128 KB each
|
||||||
|
const RAW_CACHE_SIZE = 256;
|
||||||
|
const CHUNK_CACHE_SIZE = 512;
|
||||||
|
|
||||||
|
// a block with state the server keeps, like a chest's items. never sent to clients as is
|
||||||
|
export interface Tile {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
containers: Record<string, Container>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function position_key(x: number, y: number, z: number) {
|
||||||
|
return `${x},${y},${z}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the authoritative world: generated terrain plus everything players changed
|
||||||
|
export class ServerWorld {
|
||||||
|
readonly seed: string;
|
||||||
|
readonly block_ids: Record<string, number> = {};
|
||||||
|
|
||||||
|
// what generation made, and the final blocks with neighbors' leaves and player changes applied
|
||||||
|
#raw = new LruMap<number, RawChunk>(RAW_CACHE_SIZE);
|
||||||
|
#chunks = new LruMap<number, Uint32Array>(CHUNK_CACHE_SIZE);
|
||||||
|
|
||||||
|
// changes from generated terrain, per chunk. these and the tiles are what gets saved
|
||||||
|
#changes = new Map<number, Map<string, BlockChange>>();
|
||||||
|
tiles = new Map<string, Tile>();
|
||||||
|
|
||||||
|
// set when anything that gets saved changes
|
||||||
|
dirty = false;
|
||||||
|
|
||||||
|
on_block_change?: (x: number, y: number, z: number, id: string) => void;
|
||||||
|
|
||||||
|
constructor(seed: string) {
|
||||||
|
this.seed = seed;
|
||||||
|
EverythingRegistry.get_registry<BlockRegistry>("blocks").forEach((block, nid) => {
|
||||||
|
this.block_ids[block.id] = nid;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get_block_id(x: number, y: number, z: number): string {
|
||||||
|
const nid = this.get_block_nid(x, y, z);
|
||||||
|
return nid === AIR ? AIR_ID : EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid)?.id ?? AIR_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
get_block_nid(x: number, y: number, z: number): number {
|
||||||
|
if (y < 0 || y >= CHUNK_HEIGHT) {
|
||||||
|
return AIR;
|
||||||
|
}
|
||||||
|
const chunk_x = Math.floor(x / CHUNK_SIZE);
|
||||||
|
const chunk_z = Math.floor(z / CHUNK_SIZE);
|
||||||
|
const blocks = this.#get_chunk(chunk_x, chunk_z);
|
||||||
|
return blocks[index_in_chunk(x, y, z, chunk_x, chunk_z)] & ID_MASK;
|
||||||
|
}
|
||||||
|
|
||||||
|
get_block_info(x: number, y: number, z: number): BlockRegistry | undefined {
|
||||||
|
const nid = this.get_block_nid(x, y, z);
|
||||||
|
return nid === AIR ? undefined : EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// only changes the block, the game server runs behaviors and tiles
|
||||||
|
set_block(x: number, y: number, z: number, id: string) {
|
||||||
|
if (y < 0 || y >= CHUNK_HEIGHT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nid = id === AIR_ID ? AIR : this.block_ids[id];
|
||||||
|
if (nid === undefined) {
|
||||||
|
throw new Error(`Unknown block ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk_x = Math.floor(x / CHUNK_SIZE);
|
||||||
|
const chunk_z = Math.floor(z / CHUNK_SIZE);
|
||||||
|
const blocks = this.#get_chunk(chunk_x, chunk_z);
|
||||||
|
blocks[index_in_chunk(x, y, z, chunk_x, chunk_z)] = nid;
|
||||||
|
|
||||||
|
this.#record_change(x, y, z, id);
|
||||||
|
this.dirty = true;
|
||||||
|
this.on_block_change?.(x, y, z, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
all_changes(): BlockChange[] {
|
||||||
|
const all: BlockChange[] = [];
|
||||||
|
for (const chunk_changes of this.#changes.values()) {
|
||||||
|
all.push(...chunk_changes.values());
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
load_changes(changes: BlockChange[]) {
|
||||||
|
for (const [x, y, z, id] of changes) {
|
||||||
|
this.#record_change(x, y, z, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get_tile(x: number, y: number, z: number) {
|
||||||
|
return this.tiles.get(position_key(x, y, z));
|
||||||
|
}
|
||||||
|
|
||||||
|
add_tile(tile: Tile) {
|
||||||
|
this.tiles.set(position_key(tile.x, tile.y, tile.z), tile);
|
||||||
|
this.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_tile(x: number, y: number, z: number) {
|
||||||
|
this.tiles.delete(position_key(x, y, z));
|
||||||
|
this.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#record_change(x: number, y: number, z: number, id: string) {
|
||||||
|
const key = chunk_key(Math.floor(x / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE));
|
||||||
|
let chunk_changes = this.#changes.get(key);
|
||||||
|
if (!chunk_changes) {
|
||||||
|
chunk_changes = new Map();
|
||||||
|
this.#changes.set(key, chunk_changes);
|
||||||
|
}
|
||||||
|
chunk_changes.set(position_key(x, y, z), [x, y, z, id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#get_raw(chunk_x: number, chunk_z: number) {
|
||||||
|
const key = chunk_key(chunk_x, chunk_z);
|
||||||
|
let raw = this.#raw.get(key);
|
||||||
|
if (!raw) {
|
||||||
|
raw = generate_raw_chunk(chunk_x, chunk_z, this.seed, this.block_ids);
|
||||||
|
this.#raw.set(key, raw);
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
#get_chunk(chunk_x: number, chunk_z: number) {
|
||||||
|
const key = chunk_key(chunk_x, chunk_z);
|
||||||
|
let blocks = this.#chunks.get(key);
|
||||||
|
if (!blocks) {
|
||||||
|
blocks = this.#build_chunk(chunk_x, chunk_z);
|
||||||
|
this.#chunks.set(key, blocks);
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// same rules as the client: own blocks, then neighbors' leaves only into air, then player changes
|
||||||
|
#build_chunk(chunk_x: number, chunk_z: number) {
|
||||||
|
const blocks = this.#get_raw(chunk_x, chunk_z).blocks.slice();
|
||||||
|
|
||||||
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
for (let dz = -1; dz <= 1; dz++) {
|
||||||
|
if (dx === 0 && dz === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const spills = this.#get_raw(chunk_x + dx, chunk_z + dz).spills;
|
||||||
|
for (let i = 0; i < spills.length; i += 4) {
|
||||||
|
const [x, y, z, nid] = [spills[i], spills[i + 1], spills[i + 2], spills[i + 3]];
|
||||||
|
if (Math.floor(x / CHUNK_SIZE) !== chunk_x || Math.floor(z / CHUNK_SIZE) !== chunk_z) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const index = index_in_chunk(x, y, z, chunk_x, chunk_z);
|
||||||
|
if (blocks[index] === AIR) {
|
||||||
|
blocks[index] = nid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [x, y, z, id] of this.#changes.get(chunk_key(chunk_x, chunk_z))?.values() ?? []) {
|
||||||
|
const nid = id === AIR_ID ? AIR : this.block_ids[id];
|
||||||
|
if (nid !== undefined) {
|
||||||
|
blocks[index_in_chunk(x, y, z, chunk_x, chunk_z)] = nid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function index_in_chunk(x: number, y: number, z: number, chunk_x: number, chunk_z: number) {
|
||||||
|
return y * CHUNK_AREA + (z - chunk_z * CHUNK_SIZE) * CHUNK_SIZE + (x - chunk_x * CHUNK_SIZE);
|
||||||
|
}
|
||||||
+58
-188
@@ -1,235 +1,105 @@
|
|||||||
import { serveDir } from "@std/http/file-server";
|
import { serveDir } from "@std/http/file-server";
|
||||||
import { CHUNK_HEIGHT } from "$/common/constants.ts";
|
import { GameToHost, HostToGame } from "./game/host_protocol.ts";
|
||||||
import {
|
|
||||||
BlockChange,
|
|
||||||
ClientMessage,
|
|
||||||
MAX_CHAT_LENGTH,
|
|
||||||
MAX_NAME_LENGTH,
|
|
||||||
PlayerInfo,
|
|
||||||
ServerMessage,
|
|
||||||
} from "$/common/protocol.ts";
|
|
||||||
|
|
||||||
const PORT = Number(Deno.env.get("PORT") ?? 8000);
|
const PORT = Number(Deno.env.get("PORT") ?? 8000);
|
||||||
const WORLD_FILE = Deno.env.get("WORLD_FILE") ?? "world.json";
|
const WORLD_FILE = Deno.env.get("WORLD_FILE") ?? "world.json";
|
||||||
const STATIC_ROOT = "build";
|
const STATIC_ROOT = "build";
|
||||||
const SAVE_INTERVAL_MS = 30_000;
|
|
||||||
const MAX_MESSAGE_SIZE = 4096;
|
const MAX_MESSAGE_SIZE = 4096;
|
||||||
// how far from a player a block can be changed, a bit more than the client's reach
|
const SHUTDOWN_TIMEOUT_MS = 5000;
|
||||||
const MAX_REACH = 8;
|
|
||||||
|
|
||||||
const BLOCK_ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/;
|
// the host only does files and networking, the game runs in a worker with no permissions
|
||||||
|
const game = new Worker(new URL("./game/worker.ts", import.meta.url), {
|
||||||
|
type: "module",
|
||||||
|
deno: { permissions: "none" },
|
||||||
|
} as WorkerOptions);
|
||||||
|
|
||||||
interface SavedWorld {
|
const sockets = new Map<number, WebSocket>();
|
||||||
seed: string;
|
let next_conn = 1;
|
||||||
changes: BlockChange[];
|
|
||||||
|
function post(message: HostToGame) {
|
||||||
|
game.postMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Client {
|
function read_world(): string | undefined {
|
||||||
socket: WebSocket;
|
|
||||||
player?: PlayerInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
// the server doesnt generate terrain, clients do that from the seed
|
|
||||||
// we only keep track of what players changed on top of it
|
|
||||||
class ServerWorld {
|
|
||||||
seed: string;
|
|
||||||
changes = new Map<string, BlockChange>();
|
|
||||||
dirty = false;
|
|
||||||
|
|
||||||
constructor(seed: string) {
|
|
||||||
this.seed = seed;
|
|
||||||
}
|
|
||||||
|
|
||||||
set_block(x: number, y: number, z: number, id: string) {
|
|
||||||
this.changes.set(`${x},${y},${z}`, [x, y, z, id]);
|
|
||||||
this.dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static load(path: string): ServerWorld {
|
|
||||||
try {
|
try {
|
||||||
const saved: SavedWorld = JSON.parse(Deno.readTextFileSync(path));
|
return Deno.readTextFileSync(WORLD_FILE);
|
||||||
const world = new ServerWorld(saved.seed);
|
|
||||||
for (const [x, y, z, id] of saved.changes) {
|
|
||||||
world.changes.set(`${x},${y},${z}`, [x, y, z, id]);
|
|
||||||
}
|
|
||||||
console.log(`Loaded ${path} (${world.changes.size} block changes)`);
|
|
||||||
return world;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!(e instanceof Deno.errors.NotFound)) {
|
if (e instanceof Deno.errors.NotFound) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
const world = new ServerWorld(Deno.env.get("SEED") ?? crypto.randomUUID());
|
}
|
||||||
world.dirty = true;
|
|
||||||
console.log(`Created new world with seed ${world.seed}`);
|
|
||||||
return world;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
save(path: string) {
|
function write_world(data: string) {
|
||||||
if (!this.dirty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const saved: SavedWorld = { seed: this.seed, changes: [...this.changes.values()] };
|
|
||||||
// write then rename so a crash mid write doesnt eat the world
|
// write then rename so a crash mid write doesnt eat the world
|
||||||
Deno.writeTextFileSync(`${path}.tmp`, JSON.stringify(saved));
|
Deno.writeTextFileSync(`${WORLD_FILE}.tmp`, data);
|
||||||
Deno.renameSync(`${path}.tmp`, path);
|
Deno.renameSync(`${WORLD_FILE}.tmp`, WORLD_FILE);
|
||||||
this.dirty = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const world = ServerWorld.load(WORLD_FILE);
|
game.onmessage = (event: MessageEvent<GameToHost>) => {
|
||||||
const clients = new Set<Client>();
|
const message = event.data;
|
||||||
|
|
||||||
function send(client: Client, message: ServerMessage) {
|
|
||||||
if (client.socket.readyState === WebSocket.OPEN) {
|
|
||||||
client.socket.send(JSON.stringify(message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function broadcast(message: ServerMessage, except?: Client) {
|
|
||||||
const data = JSON.stringify(message);
|
|
||||||
for (const client of clients) {
|
|
||||||
if (client !== except && client.player && client.socket.readyState === WebSocket.OPEN) {
|
|
||||||
client.socket.send(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function is_number(value: unknown): value is number {
|
|
||||||
return typeof value === "number" && Number.isFinite(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function is_int(value: unknown): value is number {
|
|
||||||
return Number.isInteger(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clean_name(name: unknown): string {
|
|
||||||
const cleaned = typeof name === "string" ? name.replace(/[^A-Za-z0-9_]/g, "").slice(0, MAX_NAME_LENGTH) : "";
|
|
||||||
const base = cleaned || `player${Math.floor(Math.random() * 10000)}`;
|
|
||||||
|
|
||||||
// make it unique
|
|
||||||
const taken = new Set([...clients].map((c) => c.player?.name));
|
|
||||||
let final = base;
|
|
||||||
let i = 2;
|
|
||||||
while (taken.has(final)) {
|
|
||||||
final = `${base}${i}`;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
return final;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handle_message(client: Client, message: ClientMessage) {
|
|
||||||
if (!client.player) {
|
|
||||||
if (message.type !== "hello") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const player: PlayerInfo = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
name: clean_name(message.name),
|
|
||||||
x: 0,
|
|
||||||
y: 100,
|
|
||||||
z: 0,
|
|
||||||
yaw: 0,
|
|
||||||
pitch: 0,
|
|
||||||
};
|
|
||||||
send(client, {
|
|
||||||
type: "welcome",
|
|
||||||
id: player.id,
|
|
||||||
seed: world.seed,
|
|
||||||
players: [...clients].flatMap((c) => c.player ? [c.player] : []),
|
|
||||||
changes: [...world.changes.values()],
|
|
||||||
});
|
|
||||||
client.player = player;
|
|
||||||
broadcast({ type: "player_join", player }, client);
|
|
||||||
broadcast({ type: "chat", text: `${player.name} joined` });
|
|
||||||
console.log(`${player.name} joined (${clients.size} online)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const player = client.player;
|
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case "move": {
|
case "ready":
|
||||||
const { x, y, z, yaw, pitch } = message;
|
console.log(`World ${WORLD_FILE} ready, seed ${message.seed}`);
|
||||||
if (![x, y, z, yaw, pitch].every(is_number)) {
|
break;
|
||||||
return;
|
case "send": {
|
||||||
|
const socket = sockets.get(message.conn);
|
||||||
|
if (socket?.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(message.data);
|
||||||
}
|
}
|
||||||
Object.assign(player, { x, y, z, yaw, pitch });
|
|
||||||
broadcast({ type: "player_move", id: player.id, x, y, z, yaw, pitch }, client);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "set_block": {
|
case "close":
|
||||||
const { x, y, z, id } = message;
|
sockets.get(message.conn)?.close();
|
||||||
if (!is_int(x) || !is_int(y) || !is_int(z) || y < 0 || y >= CHUNK_HEIGHT) {
|
break;
|
||||||
return;
|
case "save":
|
||||||
|
write_world(message.data);
|
||||||
|
if (message.final) {
|
||||||
|
console.log("World saved");
|
||||||
|
Deno.exit(0);
|
||||||
}
|
}
|
||||||
if (typeof id !== "string" || !BLOCK_ID_PATTERN.test(id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dx = x + 0.5 - player.x;
|
|
||||||
const dy = y + 0.5 - (player.y + 1.69);
|
|
||||||
const dz = z + 0.5 - player.z;
|
|
||||||
if (dx * dx + dy * dy + dz * dz > MAX_REACH * MAX_REACH) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
world.set_block(x, y, z, id);
|
|
||||||
broadcast({ type: "set_block", x, y, z, id }, client);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "chat": {
|
};
|
||||||
if (typeof message.text !== "string") {
|
|
||||||
return;
|
game.onerror = (event) => {
|
||||||
}
|
console.error("Game server crashed:", event.message);
|
||||||
const text = message.text.trim().slice(0, MAX_CHAT_LENGTH);
|
Deno.exit(1);
|
||||||
if (text.length === 0) {
|
};
|
||||||
return;
|
|
||||||
}
|
const save = read_world();
|
||||||
console.log(`<${player.name}> ${text}`);
|
post({ type: "init", save, default_seed: Deno.env.get("SEED") ?? crypto.randomUUID() });
|
||||||
broadcast({ type: "chat", from: player.name, text });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handle_socket(socket: WebSocket) {
|
function handle_socket(socket: WebSocket) {
|
||||||
const client: Client = { socket };
|
const conn = next_conn++;
|
||||||
|
|
||||||
socket.addEventListener("open", () => {
|
socket.addEventListener("open", () => {
|
||||||
clients.add(client);
|
sockets.set(conn, socket);
|
||||||
|
post({ type: "connect", conn });
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener("message", (event) => {
|
socket.addEventListener("message", (event) => {
|
||||||
if (typeof event.data !== "string" || event.data.length > MAX_MESSAGE_SIZE) {
|
if (typeof event.data !== "string" || event.data.length > MAX_MESSAGE_SIZE) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let message: ClientMessage;
|
post({ type: "message", conn, data: event.data });
|
||||||
try {
|
|
||||||
message = JSON.parse(event.data);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof message !== "object" || message === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handle_message(client, message);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener("close", () => {
|
socket.addEventListener("close", () => {
|
||||||
clients.delete(client);
|
if (sockets.delete(conn)) {
|
||||||
if (client.player) {
|
post({ type: "disconnect", conn });
|
||||||
broadcast({ type: "player_leave", id: client.player.id });
|
|
||||||
broadcast({ type: "chat", text: `${client.player.name} left` });
|
|
||||||
console.log(`${client.player.name} left (${clients.size} online)`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setInterval(() => world.save(WORLD_FILE), SAVE_INTERVAL_MS);
|
|
||||||
|
|
||||||
function shutdown() {
|
function shutdown() {
|
||||||
console.log("Saving world...");
|
console.log("Saving world...");
|
||||||
world.save(WORLD_FILE);
|
post({ type: "shutdown" });
|
||||||
Deno.exit(0);
|
setTimeout(() => {
|
||||||
|
console.error("Game server didn't save in time");
|
||||||
|
Deno.exit(1);
|
||||||
|
}, SHUTDOWN_TIMEOUT_MS);
|
||||||
}
|
}
|
||||||
Deno.addSignalListener("SIGINT", shutdown);
|
Deno.addSignalListener("SIGINT", shutdown);
|
||||||
if (Deno.build.os !== "windows") {
|
if (Deno.build.os !== "windows") {
|
||||||
|
|||||||
Reference in New Issue
Block a user