Server authority
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user