Server actually ticks
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
// runs the game at a fixed rate. a slow tick makes the next ones run back to back until the game catches up,
|
||||
// so game time keeps up with real time. if it falls too far behind, it skips ahead instead and says so
|
||||
import { TICKS_PER_SECOND } from "$/common/constants.ts";
|
||||
|
||||
// at most this many ticks in a row to catch up, then skip the rest
|
||||
const MAX_CATCH_UP_TICKS = 10;
|
||||
// don't repeat the can't keep up warning more often than this
|
||||
const WARN_INTERVAL_MS = 15_000;
|
||||
|
||||
export interface LoopClock {
|
||||
now(): number;
|
||||
// run fn after ms, returns something clear() takes
|
||||
schedule(fn: () => void, ms: number): unknown;
|
||||
clear(handle: unknown): void;
|
||||
}
|
||||
|
||||
const real_clock: LoopClock = {
|
||||
now: () => performance.now(),
|
||||
schedule: (fn, ms) => setTimeout(fn, ms),
|
||||
clear: (handle) => clearTimeout(handle as number),
|
||||
};
|
||||
|
||||
export interface LoopStats {
|
||||
// ticks that ran in the last second
|
||||
tps: number;
|
||||
// average milliseconds a tick took over the last second
|
||||
mspt: number;
|
||||
// ticks skipped because the game couldn't keep up, since it started
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export class GameLoop {
|
||||
readonly tick_ms: number;
|
||||
#tick: () => void;
|
||||
#clock: LoopClock;
|
||||
#timer: unknown;
|
||||
#running = false;
|
||||
#next_tick = 0;
|
||||
#last_warning = -Infinity;
|
||||
|
||||
// ticks in the last second, as [when it started, how long it took]
|
||||
#recent: [number, number][] = [];
|
||||
#skipped = 0;
|
||||
|
||||
constructor(tick: () => void, tps = TICKS_PER_SECOND, clock: LoopClock = real_clock) {
|
||||
this.#tick = tick;
|
||||
this.tick_ms = 1000 / tps;
|
||||
this.#clock = clock;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.#running) return;
|
||||
this.#running = true;
|
||||
this.#next_tick = this.#clock.now();
|
||||
this.#run();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.#running = false;
|
||||
this.#clock.clear(this.#timer);
|
||||
}
|
||||
|
||||
get stats(): LoopStats {
|
||||
const now = this.#clock.now();
|
||||
const recent = this.#recent.filter(([at]) => now - at < 1000);
|
||||
const total = recent.reduce((sum, [, took]) => sum + took, 0);
|
||||
return { tps: recent.length, mspt: recent.length ? total / recent.length : 0, skipped: this.#skipped };
|
||||
}
|
||||
|
||||
#run() {
|
||||
if (!this.#running) return;
|
||||
|
||||
let ran = 0;
|
||||
while (this.#clock.now() >= this.#next_tick && ran < MAX_CATCH_UP_TICKS) {
|
||||
const started = this.#clock.now();
|
||||
try {
|
||||
this.#tick();
|
||||
} catch (e) {
|
||||
// one bad tick shouldn't stop the world
|
||||
console.error("Tick failed:", e);
|
||||
}
|
||||
const took = this.#clock.now() - started;
|
||||
this.#recent.push([started, took]);
|
||||
this.#next_tick += this.tick_ms;
|
||||
ran++;
|
||||
}
|
||||
|
||||
const now = this.#clock.now();
|
||||
this.#recent = this.#recent.filter(([at]) => now - at < 1000);
|
||||
|
||||
const behind = Math.floor((now - this.#next_tick) / this.tick_ms);
|
||||
if (behind > 0) {
|
||||
// catching up would mean running the game in fast forward for a while, skip instead
|
||||
this.#skipped += behind;
|
||||
this.#next_tick += behind * this.tick_ms;
|
||||
if (now - this.#last_warning > WARN_INTERVAL_MS) {
|
||||
this.#last_warning = now;
|
||||
console.warn(
|
||||
`Can't keep up, skipped ${behind} ticks (${Math.round(behind * this.tick_ms)} ms). ` +
|
||||
`Ticks take ${this.stats.mspt.toFixed(1)} ms on average, ${this.tick_ms} ms is the budget.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.#timer = this.#clock.schedule(() => this.#run(), Math.max(0, this.#next_tick - this.#clock.now()));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import type { AtlasListing, ModListing, RecipeBook } from "$/common/mod_loader.t
|
||||
import type { WorldgenSetup } from "$/common/generation.ts";
|
||||
import { BLOCK_BEHAVIORS } from "./blocks.ts";
|
||||
import { ModRuntime, run_guarded } from "./mod_runtime.ts";
|
||||
import type { GameLoop } from "./game_loop.ts";
|
||||
import { consume_recipe_items, update_crafting_result } from "./crafting.ts";
|
||||
import { OpenScreen, SavedPlayer, ServerPlayer } from "./player.ts";
|
||||
import { ServerWorld, Tile } from "./world.ts";
|
||||
@@ -83,6 +84,8 @@ export class GameServer {
|
||||
#mods: GameMods;
|
||||
mods: ModRuntime;
|
||||
recipes: RecipeBook;
|
||||
// what runs tick(), for /tps. tests call tick() themselves
|
||||
loop?: GameLoop;
|
||||
|
||||
constructor(host: GameHost, save: string | undefined, default_seed: string, mods: GameMods) {
|
||||
this.#host = host;
|
||||
@@ -106,7 +109,8 @@ export class GameServer {
|
||||
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 });
|
||||
this.world.on_block_change = (x, y, z, id, state) =>
|
||||
this.#broadcast(state ? { type: "set_block", x, y, z, id, state } : { type: "set_block", x, y, z, id });
|
||||
}
|
||||
|
||||
// connections
|
||||
@@ -186,6 +190,7 @@ export class GameServer {
|
||||
}
|
||||
|
||||
this.mods.tick();
|
||||
this.mods.check_watched_containers();
|
||||
|
||||
for (const [conn, pending] of this.#pending) {
|
||||
if (this.#tick - pending.since > READY_TIMEOUT_TICKS) {
|
||||
@@ -257,7 +262,15 @@ export class GameServer {
|
||||
this.#broadcast({ type: "player_move", id: player.id, x, y, z, yaw: player.yaw, pitch: player.pitch }, player);
|
||||
}
|
||||
|
||||
set_block(x: number, y: number, z: number, id: string, player?: ServerPlayer) {
|
||||
// changes a block's state bits without anything else happening, see ServerWorld.get_block_value
|
||||
set_block_state(x: number, y: number, z: number, state: number) {
|
||||
const id = this.world.get_block_id(x, y, z);
|
||||
if (id !== AIR_ID) {
|
||||
this.world.set_block(x, y, z, id, state);
|
||||
}
|
||||
}
|
||||
|
||||
set_block(x: number, y: number, z: number, id: string, player?: ServerPlayer, state?: number) {
|
||||
const old_id = this.world.get_block_id(x, y, z);
|
||||
if (old_id !== AIR_ID) {
|
||||
const components = this.mods.components_of(old_id).filter(({ component }) => component.on_break);
|
||||
@@ -282,7 +295,7 @@ export class GameServer {
|
||||
}
|
||||
}
|
||||
|
||||
this.world.set_block(x, y, z, id);
|
||||
this.world.set_block(x, y, z, id, state);
|
||||
|
||||
if (BLOCK_BEHAVIORS[id]?.create_tile) {
|
||||
this.get_or_create_tile(x, y, z);
|
||||
@@ -407,6 +420,14 @@ export class GameServer {
|
||||
this.#break_block(player, message.x, message.y, message.z);
|
||||
}
|
||||
break;
|
||||
case "hit_block":
|
||||
if (is_block_position(message.x, message.y, message.z)) {
|
||||
this.#hit_block(player, message.x, message.y, message.z);
|
||||
}
|
||||
break;
|
||||
case "use_item":
|
||||
this.#use_item(player);
|
||||
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);
|
||||
@@ -459,6 +480,40 @@ export class GameServer {
|
||||
this.mods.after.block_break.emit(event);
|
||||
}
|
||||
|
||||
#hit_block(player: ServerPlayer, x: number, y: number, z: number) {
|
||||
const info = this.world.get_block_info(x, y, z);
|
||||
if (!info || !this.#in_reach(player, x, y, z)) {
|
||||
return;
|
||||
}
|
||||
const components = this.mods.components_of(info.id).filter(({ component }) => component.on_click);
|
||||
if (components.length === 0) {
|
||||
return;
|
||||
}
|
||||
const block = this.mods.block_ref(x, y, z, info.id);
|
||||
const player_api = this.mods.player(player);
|
||||
for (const { mod, id, component, params } of components) {
|
||||
run_guarded(mod, `${id} on_click`, () => component.on_click!(block, params, player_api));
|
||||
}
|
||||
}
|
||||
|
||||
// the held item's on_use handlers, returns whether it has any
|
||||
#use_item(player: ServerPlayer): boolean {
|
||||
const held = player.held_item;
|
||||
if (!held) {
|
||||
return false;
|
||||
}
|
||||
const components = this.mods.item_components_of(held.type_id).filter(({ component }) => component.on_use);
|
||||
if (components.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const player_api = this.mods.player(player);
|
||||
const item = player_api.held_item!;
|
||||
for (const { mod, id, component, params } of components) {
|
||||
run_guarded(mod, `${id} on_use`, () => component.on_use!(item, params, player_api));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#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];
|
||||
@@ -495,6 +550,12 @@ export class GameServer {
|
||||
return;
|
||||
}
|
||||
|
||||
// an item that does something when used does that instead of being placed
|
||||
if (this.#use_item(player)) {
|
||||
this.#correct(player, tx, ty, tz);
|
||||
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 ?? "");
|
||||
@@ -596,6 +657,19 @@ export class GameServer {
|
||||
this.give(player, item_id, amount);
|
||||
return;
|
||||
}
|
||||
if (command === "tps") {
|
||||
if (!this.loop) {
|
||||
this.#send(player, { type: "chat", text: "The game loop isn't running" });
|
||||
return;
|
||||
}
|
||||
const { tps, mspt, skipped } = this.loop.stats;
|
||||
this.#send(player, {
|
||||
type: "chat",
|
||||
text: `${tps} ticks per second (aiming for ${TICKS_PER_SECOND}), ${mspt.toFixed(1)} ms per tick, ` +
|
||||
`${skipped} skipped since starting`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const mod_command = this.mods.commands.get(command);
|
||||
if (mod_command) {
|
||||
run_guarded(mod_command.mod, `/${command}`, () => mod_command.command.run(args, this.mods.player(player)));
|
||||
@@ -672,7 +746,7 @@ export class GameServer {
|
||||
|
||||
#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";
|
||||
return nid === AIR || (EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid)?.replaceable ?? false);
|
||||
}
|
||||
|
||||
#in_reach(player: ServerPlayer, x: number, y: number, z: number) {
|
||||
@@ -723,9 +797,6 @@ export class GameServer {
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// runs mods' server scripts: builds each one's ServerContext and keeps what they register.
|
||||
// see "Server scripts" in MODS.md. parts not built yet throw when used, saying so
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
import { ItemStack as EngineItemStack } from "$/common/inventory.ts";
|
||||
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
import { Container as EngineContainer, ItemStack as EngineItemStack } from "$/common/inventory.ts";
|
||||
import { get_state_value, set_state_value } from "$/common/utils.ts";
|
||||
import type {
|
||||
BlockComponent,
|
||||
BlockRef,
|
||||
@@ -47,12 +48,19 @@ export class Signal<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// the mod whose code is running right now, for apis shared between mods that need to know who called
|
||||
let running_mod = "";
|
||||
|
||||
export function run_guarded<T>(mod: string, what: string, fn: () => T): T | undefined {
|
||||
const previous = running_mod;
|
||||
running_mod = mod;
|
||||
try {
|
||||
return fn();
|
||||
} catch (e) {
|
||||
console.error(`[${mod}] ${what} threw:`, e);
|
||||
return undefined;
|
||||
} finally {
|
||||
running_mod = previous;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +102,8 @@ export class ModRuntime {
|
||||
storage: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
#timers = new Map<number, Timer>();
|
||||
// containers mods listen to with on_change, compared slot by slot every tick
|
||||
#watched: { mod: string; container: EngineContainer; last: string[]; fn: (slot: number) => void }[] = [];
|
||||
#next_timer = 1;
|
||||
#setting_up = true;
|
||||
#players = new WeakMap<ServerPlayer, Player>();
|
||||
@@ -107,7 +117,12 @@ export class ModRuntime {
|
||||
if (typeof module.setup !== "function") {
|
||||
throw new ModLoadError(mod, "the server script doesn't export a setup function");
|
||||
}
|
||||
await module.setup(this.#context(mod, version));
|
||||
running_mod = mod;
|
||||
try {
|
||||
await module.setup(this.#context(mod, version));
|
||||
} finally {
|
||||
running_mod = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +180,36 @@ export class ModRuntime {
|
||||
}));
|
||||
}
|
||||
|
||||
// components on an item, with their params from its json
|
||||
item_components_of(item_id: string): { mod: string; id: string; component: ItemComponent; params: unknown }[] {
|
||||
const item = EverythingRegistry.get<ItemRegistry>("items", item_id);
|
||||
return Object.entries(item?.components ?? {}).map(([id, params]) => ({
|
||||
...this.item_components.get(id)!,
|
||||
id,
|
||||
params,
|
||||
}));
|
||||
}
|
||||
|
||||
watch_container(mod: string, container: EngineContainer, fn: (slot: number) => void) {
|
||||
const entry = { mod, container, fn, last: snapshot(container) };
|
||||
this.#watched.push(entry);
|
||||
return () => {
|
||||
this.#watched = this.#watched.filter((w) => w !== entry);
|
||||
};
|
||||
}
|
||||
|
||||
// items change in many places (clicks, drops, crafting, scripts), so compare instead of hooking every one
|
||||
check_watched_containers() {
|
||||
for (const watch of [...this.#watched]) {
|
||||
const now = snapshot(watch.container);
|
||||
const changed = now.flatMap((slot, i) => slot !== watch.last[i] ? [i] : []);
|
||||
watch.last = now;
|
||||
for (const slot of changed) {
|
||||
run_guarded(watch.mod, "a container on_change", () => watch.fn(slot));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tick() {
|
||||
const now = this.game.current_tick;
|
||||
for (const [handle, timer] of [...this.#timers]) {
|
||||
@@ -246,7 +291,7 @@ export class ModRuntime {
|
||||
if (!/^[a-z0-9_]+$/.test(name)) {
|
||||
throw new ModLoadError(mod, `command name "${name}" must be a-z, 0-9 and _`);
|
||||
}
|
||||
if (name === "give") throw new ModLoadError(mod, `/give belongs to the base game`);
|
||||
if (["give", "tps"].includes(name)) throw new ModLoadError(mod, `/${name} belongs to the engine`);
|
||||
const existing = this.commands.get(name);
|
||||
if (existing) {
|
||||
throw new ModLoadError(mod, `/${name} is also registered by ${existing.mod}`);
|
||||
@@ -275,8 +320,24 @@ export class ModRuntime {
|
||||
game().set_block(x, y, z, id);
|
||||
return true;
|
||||
},
|
||||
get_state: not_yet(mod, "world.get_state", "block states aren't synced or saved yet"),
|
||||
set_state: not_yet(mod, "world.set_state", "block states aren't synced or saved yet"),
|
||||
get_state: (x, y, z, name) => {
|
||||
const id = game().world.get_block_id(x, y, z);
|
||||
if (id === AIR_ID) return undefined;
|
||||
const info = state_owner(id, name);
|
||||
return get_state_value(game().world.get_block_value(x, y, z), info, name);
|
||||
},
|
||||
set_state: (x, y, z, name, value) => {
|
||||
const id = game().world.get_block_id(x, y, z);
|
||||
if (id === AIR_ID) return false;
|
||||
const info = state_owner(id, name);
|
||||
const bits = info.states!.find((s) => s.name === name)!.bits;
|
||||
if (!Number.isInteger(value) || value < 0 || value >= 2 ** bits) {
|
||||
throw new Error(`${id} state "${name}" has ${bits} bits, ${value} doesn't fit`);
|
||||
}
|
||||
const new_value = set_state_value(game().world.get_block_value(x, y, z), info, name, value)!;
|
||||
game().set_block_state(x, y, z, new_value >>> 16);
|
||||
return true;
|
||||
},
|
||||
get_block_data: (x, y, z) => game().world.get_tile(x, y, z)?.mod_data as never,
|
||||
is_loaded: () => true,
|
||||
get seed() {
|
||||
@@ -330,6 +391,19 @@ export class ModRuntime {
|
||||
}
|
||||
|
||||
// each event's signal, as seen by one mod so errors say which mod threw
|
||||
// a block that has this state, or an error saying it doesn't
|
||||
function state_owner(id: string, name: string): BlockRegistry {
|
||||
const info = EverythingRegistry.get<BlockRegistry>("blocks", id)!;
|
||||
if (!info.states?.some((s) => s.name === name)) {
|
||||
throw new Error(`${id} has no state "${name}"`);
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
function snapshot(container: EngineContainer): string[] {
|
||||
return container.to_data().map((item) => JSON.stringify(item));
|
||||
}
|
||||
|
||||
function map_signals<T extends { [name: string]: { for_mod(mod: string): unknown } }>(
|
||||
signals: T,
|
||||
mod: string,
|
||||
@@ -394,7 +468,7 @@ function player_api(game: GameServer, player: ServerPlayer): Player {
|
||||
const left = player.inventory.add_item(stack);
|
||||
return left > 0 ? { id: item.id, count: left, data: stack.data } : undefined;
|
||||
},
|
||||
on_change: not_yet("player", "inventory.on_change", "containers are step 7 in MODS.md"),
|
||||
on_change: (fn) => game.mods.watch_container(running_mod || "unknown", player.inventory, fn),
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// runs the game in a worker without any permissions, the host does files and networking
|
||||
import type { GameServer } from "./game_server.ts";
|
||||
import { TICK_MS } from "./game_server.ts";
|
||||
import { GameLoop } from "./game_loop.ts";
|
||||
import { GameToHost, HostToGame } from "./host_protocol.ts";
|
||||
import { data_url, start_game } from "./load_mods.ts";
|
||||
|
||||
@@ -68,7 +68,10 @@ async function init(message: Extract<HostToGame, { type: "init" }>) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInterval(() => game!.tick(), TICK_MS);
|
||||
const loop = new GameLoop(() => game!.tick());
|
||||
game.loop = loop;
|
||||
loop.start();
|
||||
|
||||
setInterval(() => {
|
||||
if (game!.world.dirty) {
|
||||
post({ type: "save", data: game!.save(), final: false });
|
||||
|
||||
+29
-14
@@ -3,7 +3,7 @@ import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.
|
||||
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 { chunk_key } from "$/common/utils.ts";
|
||||
import { block_value, chunk_key, default_block_value } from "$/common/utils.ts";
|
||||
import { LruMap } from "./lru.ts";
|
||||
|
||||
// generation only, rebuilt when needed. 128 KB each
|
||||
@@ -31,6 +31,8 @@ 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);
|
||||
@@ -43,13 +45,14 @@ export class ServerWorld {
|
||||
// set when anything that gets saved changes
|
||||
dirty = false;
|
||||
|
||||
on_block_change?: (x: number, y: number, z: number, id: string) => void;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,13 +71,23 @@ export class ServerWorld {
|
||||
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)];
|
||||
}
|
||||
|
||||
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) {
|
||||
// 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;
|
||||
}
|
||||
@@ -82,15 +95,17 @@ export class ServerWorld {
|
||||
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)] = nid;
|
||||
blocks[index_in_chunk(x, y, z, chunk_x, chunk_z)] = value;
|
||||
|
||||
this.#record_change(x, y, z, id);
|
||||
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);
|
||||
this.on_block_change?.(x, y, z, id, state_bits);
|
||||
}
|
||||
|
||||
all_changes(): BlockChange[] {
|
||||
@@ -102,8 +117,8 @@ export class ServerWorld {
|
||||
}
|
||||
|
||||
load_changes(changes: BlockChange[]) {
|
||||
for (const [x, y, z, id] of changes) {
|
||||
this.#record_change(x, y, z, id);
|
||||
for (const [x, y, z, id, state] of changes) {
|
||||
this.#record_change(x, y, z, id, state ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,21 +136,21 @@ export class ServerWorld {
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
#record_change(x: number, y: number, z: number, id: string) {
|
||||
#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), [x, y, z, id]);
|
||||
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);
|
||||
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;
|
||||
@@ -174,10 +189,10 @@ export class ServerWorld {
|
||||
}
|
||||
}
|
||||
|
||||
for (const [x, y, z, id] of this.#changes.get(chunk_key(chunk_x, chunk_z))?.values() ?? []) {
|
||||
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)] = nid;
|
||||
blocks[index_in_chunk(x, y, z, chunk_x, chunk_z)] = block_value(nid, state ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user