243 lines
8.2 KiB
TypeScript
243 lines
8.2 KiB
TypeScript
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, WorldgenSetup } from "$/common/generation.ts";
|
|
import { Container } from "$/common/inventory.ts";
|
|
import { AIR_ID, BlockChange } from "$/common/protocol.ts";
|
|
import { block_value, chunk_key, default_block_value } from "$/common/utils.ts";
|
|
import { LruMap } from "./lru.ts";
|
|
|
|
// generation only, rebuilt when needed. 256 KB each
|
|
const RAW_CACHE_SIZE = 128;
|
|
const CHUNK_CACHE_SIZE = 256;
|
|
// how far from the middle of the world to look for dry land to spawn on, in chunks
|
|
const SPAWN_SEARCH_CHUNKS = 32;
|
|
|
|
// 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>;
|
|
// a mod block's data, what BlockRef.data holds
|
|
mod_data?: unknown;
|
|
}
|
|
|
|
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> = {};
|
|
readonly worldgen: WorldgenSetup | undefined;
|
|
// what a freshly placed or generated block stores, by numeric id: the id with its default states
|
|
readonly default_values: 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, state: number) => void;
|
|
|
|
constructor(seed: string, worldgen?: WorldgenSetup) {
|
|
this.seed = seed;
|
|
this.worldgen = worldgen;
|
|
EverythingRegistry.get_registry<BlockRegistry>("blocks").forEach((block, nid) => {
|
|
this.block_ids[block.id] = nid;
|
|
this.default_values[nid] = default_block_value(nid, block);
|
|
});
|
|
}
|
|
|
|
#spawn: { x: number; y: number; z: number } | undefined;
|
|
|
|
// where new players start: the dry land closest to the middle of the world, like minecraft's world spawn
|
|
spawn_point() {
|
|
if (!this.#spawn) {
|
|
this.#spawn = this.#find_spawn();
|
|
}
|
|
return this.#spawn;
|
|
}
|
|
|
|
#find_spawn() {
|
|
const water = this.block_ids["bworld:water"];
|
|
// chunks in rings around the middle, one column each
|
|
for (let radius = 0; radius <= SPAWN_SEARCH_CHUNKS; radius++) {
|
|
for (let cz = -radius; cz <= radius; cz++) {
|
|
for (let cx = -radius; cx <= radius; cx++) {
|
|
if (Math.max(Math.abs(cx), Math.abs(cz)) !== radius) continue;
|
|
const x = cx * CHUNK_SIZE + CHUNK_SIZE / 2;
|
|
const z = cz * CHUNK_SIZE + CHUNK_SIZE / 2;
|
|
let y = CHUNK_HEIGHT - 1;
|
|
while (y > 0 && this.get_block_nid(x, y, z) === AIR) y--;
|
|
if (y > 0 && this.get_block_nid(x, y, z) !== water) {
|
|
return { x: x + 0.5, y: y + 1, z: z + 0.5 };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return { x: 0.5, y: CHUNK_HEIGHT - 1, z: 0.5 };
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// the id with its state bits
|
|
get_block_value(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);
|
|
return this.#get_chunk(chunk_x, chunk_z)[index_in_chunk(x, y, z, chunk_x, chunk_z)];
|
|
}
|
|
|
|
// whether entities bump into the block there, water and other blocks with "collision": false don't
|
|
has_collision(x: number, y: number, z: number) {
|
|
return this.get_block_info(x, y, z)?.has_collision ?? false;
|
|
}
|
|
|
|
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. state bits default to the block's defaults
|
|
set_block(x: number, y: number, z: number, id: string, state?: number) {
|
|
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 value = state === undefined ? this.default_values[nid] ?? nid : block_value(nid, state);
|
|
|
|
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)] = value;
|
|
|
|
const state_bits = value >>> 16;
|
|
this.#record_change(x, y, z, id, state_bits);
|
|
this.dirty = true;
|
|
this.on_block_change?.(x, y, z, id, state_bits);
|
|
}
|
|
|
|
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, state] of changes) {
|
|
this.#record_change(x, y, z, id, state ?? 0);
|
|
}
|
|
}
|
|
|
|
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, state: number) {
|
|
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), state ? [x, y, z, id, state] : [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.worldgen, this.default_values);
|
|
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, state] 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)] = block_value(nid, state ?? 0);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|