Server
This commit is contained in:
@@ -1 +1,3 @@
|
|||||||
build/
|
build/
|
||||||
|
world.json
|
||||||
|
world.json.tmp
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:dirt"
|
|||||||
const maybe_item = player_inventory.container.get_item(player_inventory.hotbar_selected);
|
const maybe_item = player_inventory.container.get_item(player_inventory.hotbar_selected);
|
||||||
if (maybe_item && maybe_item.type_id === "bworld:hoe") {
|
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.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 true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ EverythingRegistry.register<BlockRegistry>("blocks", "bworld:grass", {
|
|||||||
if (item?.type_id === "bworld:hoe") {
|
if (item?.type_id === "bworld:hoe") {
|
||||||
block.id = "bworld:hoed_dirt";
|
block.id = "bworld:hoed_dirt";
|
||||||
dimension.add_block(block);
|
dimension.add_block(block);
|
||||||
|
dimension.sync_block(block.x, block.y, block.z);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+21
-1
@@ -13,6 +13,13 @@ 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";
|
||||||
import { CollisionSystem } from "./systems/collision_system.ts";
|
import { CollisionSystem } from "./systems/collision_system.ts";
|
||||||
|
import { NetworkSystem } from "./systems/network_system.ts";
|
||||||
|
import { Connection } from "./network.ts";
|
||||||
|
|
||||||
|
export interface ChatLine {
|
||||||
|
text: string;
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class ClientWorld extends World {
|
export class ClientWorld extends World {
|
||||||
paused = false;
|
paused = false;
|
||||||
@@ -20,8 +27,13 @@ export class ClientWorld extends World {
|
|||||||
|
|
||||||
dimension!: Dimension;
|
dimension!: Dimension;
|
||||||
|
|
||||||
constructor() {
|
// undefined when playing single player
|
||||||
|
connection?: Connection;
|
||||||
|
chat_log: ChatLine[] = [];
|
||||||
|
|
||||||
|
constructor(connection?: Connection) {
|
||||||
super("game");
|
super("game");
|
||||||
|
this.connection = connection;
|
||||||
|
|
||||||
this.add_state("main_menu");
|
this.add_state("main_menu");
|
||||||
this.add_state("paused");
|
this.add_state("paused");
|
||||||
@@ -39,6 +51,7 @@ export class ClientWorld extends World {
|
|||||||
// Logic systems
|
// Logic systems
|
||||||
this.add_system(new UIInteractionSystem(), "main_menu");
|
this.add_system(new UIInteractionSystem(), "main_menu");
|
||||||
this.add_system(new UIInteractionSystem(), "paused");
|
this.add_system(new UIInteractionSystem(), "paused");
|
||||||
|
this.add_system(new NetworkSystem(), "game");
|
||||||
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");
|
||||||
@@ -53,4 +66,11 @@ export class ClientWorld extends World {
|
|||||||
this.add_system(new UIRenderSystem(), "main_menu");
|
this.add_system(new UIRenderSystem(), "main_menu");
|
||||||
this.add_system(new UIRenderSystem(), "paused");
|
this.add_system(new UIRenderSystem(), "paused");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
add_chat(text: string) {
|
||||||
|
this.chat_log.push({ text, time: performance.now() });
|
||||||
|
if (this.chat_log.length > 100) {
|
||||||
|
this.chat_log.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component } from "$/common/ecs/mod.ts";
|
import { Component } from "$/common/ecs/mod.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, Faces, ID_MASK, VOID } from "../../common/constants.ts";
|
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from "../../common/constants.ts";
|
||||||
import { AssetManager } from "../assets.ts";
|
import { AssetManager } from "../assets.ts";
|
||||||
import { ClientWorld } from "../client_world.ts";
|
import { ClientWorld } from "../client_world.ts";
|
||||||
import { generate_chunk } from "../generation.ts";
|
import { generate_chunk } from "../generation.ts";
|
||||||
@@ -24,9 +25,7 @@ export interface Block {
|
|||||||
z: number;
|
z: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CHUNK_SIZE = 16;
|
export { CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE };
|
||||||
export const CHUNK_HEIGHT = 128;
|
|
||||||
export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE;
|
|
||||||
|
|
||||||
export interface Chunk {
|
export interface Chunk {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -47,10 +46,15 @@ export class Dimension extends Component {
|
|||||||
chunks: Chunk[] = [];
|
chunks: Chunk[] = [];
|
||||||
second_timer = 0;
|
second_timer = 0;
|
||||||
tick_timer = 0;
|
tick_timer = 0;
|
||||||
|
seed: string;
|
||||||
|
|
||||||
constructor(world: ClientWorld) {
|
// blocks players changed from the generated terrain, per chunk, so they survive reloading chunks
|
||||||
|
changes = new Map<string, Map<string, BlockChange>>();
|
||||||
|
|
||||||
|
constructor(world: ClientWorld, seed = "seed") {
|
||||||
super();
|
super();
|
||||||
this.world = world;
|
this.world = world;
|
||||||
|
this.seed = seed;
|
||||||
}
|
}
|
||||||
|
|
||||||
add_chunk(x: number, z: number) {
|
add_chunk(x: number, z: number) {
|
||||||
@@ -200,8 +204,57 @@ export class Dimension extends Component {
|
|||||||
return [x, y, z];
|
return [x, y, z];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
record_change(x: number, y: number, z: number, id: string) {
|
||||||
|
const chunk_key = `${Math.floor(x / CHUNK_SIZE)},${Math.floor(z / CHUNK_SIZE)}`;
|
||||||
|
let chunk_changes = this.changes.get(chunk_key);
|
||||||
|
if (!chunk_changes) {
|
||||||
|
chunk_changes = new Map();
|
||||||
|
this.changes.set(chunk_key, chunk_changes);
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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) {
|
||||||
|
const chunk = this.get_chunk(Math.floor(x / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE));
|
||||||
|
if (!chunk || !chunk.generated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.get_block(x, y, z);
|
||||||
|
if (id === AIR_ID) {
|
||||||
|
if (current !== AIR) {
|
||||||
|
this.break_block(x, y, z, false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nid = EverythingRegistry.get_id("blocks", id);
|
||||||
|
if (nid === undefined || nid === current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.add_block({ x, y, z, id });
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_chunk_changes(cx: number, cz: number) {
|
||||||
|
for (const [x, y, z, id] of this.changes.get(`${cx},${cz}`)?.values() ?? []) {
|
||||||
|
this.apply_change(x, y, z, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
load_chunk(cx: number, cz: number) {
|
load_chunk(cx: number, cz: number) {
|
||||||
generate_chunk(this, cx, cz);
|
generate_chunk(this, cx, cz, this.seed);
|
||||||
const chunk = this.get_chunk(cx, cz);
|
const chunk = this.get_chunk(cx, cz);
|
||||||
if (chunk) {
|
if (chunk) {
|
||||||
chunk.generated = true;
|
chunk.generated = true;
|
||||||
@@ -221,6 +274,13 @@ export class Dimension extends Component {
|
|||||||
neighbor.dirty = true;
|
neighbor.dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trees spill into neighboring chunks, so their changes need reapplying too
|
||||||
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
for (let dz = -1; dz <= 1; dz++) {
|
||||||
|
this.apply_chunk_changes(cx + dx, cz + dz);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
unload_chunk(cx: number, cz: number) {
|
unload_chunk(cx: number, cz: number) {
|
||||||
|
|||||||
+4
-1
@@ -12,7 +12,10 @@ export function start_game(world: ClientWorld) {
|
|||||||
world.clear_entities();
|
world.clear_entities();
|
||||||
|
|
||||||
const dimension = new Entity("dimension");
|
const dimension = new Entity("dimension");
|
||||||
world.dimension = new Dimension(world);
|
world.dimension = new Dimension(world, world.connection?.seed);
|
||||||
|
for (const [x, y, z, id] of world.connection?.initial_changes ?? []) {
|
||||||
|
world.dimension.record_change(x, y, z, id);
|
||||||
|
}
|
||||||
dimension.add(world.dimension);
|
dimension.add(world.dimension);
|
||||||
world.add_entity(dimension);
|
world.add_entity(dimension);
|
||||||
|
|
||||||
|
|||||||
@@ -129,8 +129,8 @@ function can_place_tree(tree_map: boolean[][], local_x: number, local_z: number)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function place_tree(dimension: Dimension, x: number, y: number, z: number, biome: Biome) {
|
function place_tree(dimension: Dimension, rng: Alea, x: number, y: number, z: number, biome: Biome) {
|
||||||
const height = Math.floor(Math.random() * 3) + (biome === "jungle" ? 8 : 4);
|
const height = Math.floor(rng.next() * 3) + (biome === "jungle" ? 8 : 4);
|
||||||
const trunk_block = "bworld:log";
|
const trunk_block = "bworld:log";
|
||||||
const leaves_block = "bworld:leaves";
|
const leaves_block = "bworld:leaves";
|
||||||
|
|
||||||
@@ -177,6 +177,8 @@ export function generate_chunk(dimension: Dimension, cx: number, cz: number, see
|
|||||||
const moisture_noise = create_noise_2d(new Alea(seed + "_moisture"));
|
const moisture_noise = create_noise_2d(new Alea(seed + "_moisture"));
|
||||||
const feature_noise = create_noise_2d(new Alea(seed + "_feature"));
|
const feature_noise = create_noise_2d(new Alea(seed + "_feature"));
|
||||||
const ore_noises = ORES.map((ore) => create_noise_3d(new Alea(seed + "_" + ore.id)));
|
const ore_noises = ORES.map((ore) => create_noise_3d(new Alea(seed + "_" + ore.id)));
|
||||||
|
// seeded per chunk so every client generates the exact same terrain
|
||||||
|
const rng = new Alea(`${seed}_chunk_${cx}_${cz}`);
|
||||||
|
|
||||||
const biome_scale = 0.003;
|
const biome_scale = 0.003;
|
||||||
const terrain_scale = 0.01;
|
const terrain_scale = 0.01;
|
||||||
@@ -226,7 +228,7 @@ export function generate_chunk(dimension: Dimension, cx: number, cz: number, see
|
|||||||
block = "bworld:dirt";
|
block = "bworld:dirt";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (biome === "swamp" && y === height && Math.random() < 0.2) {
|
if (biome === "swamp" && y === height && rng.next() < 0.2) {
|
||||||
block = "bworld:water";
|
block = "bworld:water";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +236,7 @@ export function generate_chunk(dimension: Dimension, cx: number, cz: number, see
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (should_place_tree(feature_noise, biome, wx, wz) && can_place_tree(tree_map, x, z)) {
|
if (should_place_tree(feature_noise, biome, wx, wz) && can_place_tree(tree_map, x, z)) {
|
||||||
place_tree(dimension, wx, height + 1, wz, biome);
|
place_tree(dimension, rng, wx, height + 1, wz, biome);
|
||||||
tree_map[x][z] = true;
|
tree_map[x][z] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -4,6 +4,8 @@ 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 { ItemStack } from "../inventory.ts";
|
||||||
|
import { MAX_CHAT_LENGTH } from "$/common/protocol.ts";
|
||||||
|
import { render_chat_log } from "../systems/rendering/network.ts";
|
||||||
|
|
||||||
export class GuiChat extends GuiScreen {
|
export class GuiChat extends GuiScreen {
|
||||||
world: ClientWorld;
|
world: ClientWorld;
|
||||||
@@ -22,6 +24,7 @@ export class GuiChat extends GuiScreen {
|
|||||||
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
|
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
|
||||||
|
|
||||||
const y = canvas.height - 32;
|
const y = canvas.height - 32;
|
||||||
|
render_chat_log(this.world, true, y - 8);
|
||||||
draw_rect(0, y, canvas.width, canvas.height, [0, 0, 0, 0.4]);
|
draw_rect(0, y, canvas.width, canvas.height, [0, 0, 0, 0.4]);
|
||||||
|
|
||||||
draw_text(this.text_typed, 0, y, 2, [1, 1, 1]);
|
draw_text(this.text_typed, 0, y, 2, [1, 1, 1]);
|
||||||
@@ -85,6 +88,9 @@ export class GuiChat extends GuiScreen {
|
|||||||
const typed = InputManager.get_typed_characters();
|
const typed = InputManager.get_typed_characters();
|
||||||
|
|
||||||
for (const char of typed) {
|
for (const char of typed) {
|
||||||
|
if (this.text_typed.length >= MAX_CHAT_LENGTH) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
this.text_typed = this.text_typed.slice(0, this.caret) + char + this.text_typed.slice(this.caret);
|
this.text_typed = this.text_typed.slice(0, this.caret) + char + this.text_typed.slice(this.caret);
|
||||||
this.caret += 1;
|
this.caret += 1;
|
||||||
}
|
}
|
||||||
@@ -98,8 +104,12 @@ export class GuiChat extends GuiScreen {
|
|||||||
submit() {
|
submit() {
|
||||||
if (this.text_typed.startsWith("/")) {
|
if (this.text_typed.startsWith("/")) {
|
||||||
this.command();
|
this.command();
|
||||||
} else {
|
} else if (this.text_typed.trim().length > 0) {
|
||||||
// we dont have multiplayer lol?
|
if (this.world.connection) {
|
||||||
|
this.world.connection.send({ type: "chat", text: this.text_typed });
|
||||||
|
} else {
|
||||||
|
this.world.add_chat(this.text_typed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.text_typed = "";
|
this.text_typed = "";
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -1,6 +1,7 @@
|
|||||||
import { AssetManager } from "./assets.ts";
|
import { AssetManager } from "./assets.ts";
|
||||||
import { ClientWorld } from "./client_world.ts";
|
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 { 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";
|
||||||
|
|
||||||
await import("./blocks/mod.ts");
|
await import("./blocks/mod.ts");
|
||||||
@@ -90,7 +91,15 @@ await AssetManager.instance.load_all();
|
|||||||
|
|
||||||
init_font();
|
init_font();
|
||||||
|
|
||||||
const client_world = new ClientWorld();
|
const connection = await Connection.open(get_server_url(), get_player_name());
|
||||||
|
if (connection) {
|
||||||
|
console.log(`Connected to ${get_server_url()}`);
|
||||||
|
} else {
|
||||||
|
console.log("Couldn't reach a server, playing single player");
|
||||||
|
}
|
||||||
|
|
||||||
|
const client_world = new ClientWorld(connection);
|
||||||
|
client_world.add_chat(connection ? "Connected to the server" : "Playing single player");
|
||||||
|
|
||||||
const loop = new ClientLoop(client_world);
|
const loop = new ClientLoop(client_world);
|
||||||
loop.start();
|
loop.start();
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { BlockChange, ClientMessage, PlayerInfo, ServerMessage } from "$/common/protocol.ts";
|
||||||
|
|
||||||
|
const CONNECT_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
|
export interface RemotePlayer extends PlayerInfo {
|
||||||
|
// where we draw them, eased towards x/y/z so movement isnt choppy
|
||||||
|
display_x: number;
|
||||||
|
display_y: number;
|
||||||
|
display_z: number;
|
||||||
|
color: [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Connection {
|
||||||
|
socket: WebSocket;
|
||||||
|
id: string;
|
||||||
|
seed: string;
|
||||||
|
initial_changes: BlockChange[];
|
||||||
|
players = new Map<string, RemotePlayer>();
|
||||||
|
|
||||||
|
// handled by the network system inside the game loop, not whenever the socket feels like it
|
||||||
|
incoming: ServerMessage[] = [];
|
||||||
|
closed = false;
|
||||||
|
|
||||||
|
constructor(socket: WebSocket, id: string, seed: string, players: PlayerInfo[], changes: BlockChange[]) {
|
||||||
|
this.socket = socket;
|
||||||
|
this.id = id;
|
||||||
|
this.seed = seed;
|
||||||
|
this.initial_changes = changes;
|
||||||
|
for (const player of players) {
|
||||||
|
this.add_player(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
this.incoming.push(JSON.parse(event.data));
|
||||||
|
});
|
||||||
|
socket.addEventListener("close", () => {
|
||||||
|
this.closed = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
send(message: ClientMessage) {
|
||||||
|
if (this.socket.readyState === WebSocket.OPEN) {
|
||||||
|
this.socket.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add_player(player: PlayerInfo) {
|
||||||
|
this.players.set(player.id, {
|
||||||
|
...player,
|
||||||
|
display_x: player.x,
|
||||||
|
display_y: player.y,
|
||||||
|
display_z: player.z,
|
||||||
|
color: color_from_name(player.name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static open(url: string, name: string): Promise<Connection | undefined> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let socket: WebSocket;
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(url);
|
||||||
|
} catch {
|
||||||
|
resolve(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
socket.close();
|
||||||
|
resolve(undefined);
|
||||||
|
}, CONNECT_TIMEOUT_MS);
|
||||||
|
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
socket.send(JSON.stringify({ type: "hello", name } satisfies ClientMessage));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
const message: ServerMessage = JSON.parse(event.data);
|
||||||
|
if (message.type === "welcome") {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(new Connection(socket, message.id, message.seed, message.players, message.changes));
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
socket.addEventListener("error", () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function color_from_name(name: string): [number, number, number] {
|
||||||
|
let hash = 0;
|
||||||
|
for (const ch of name) {
|
||||||
|
hash = (hash * 31 + ch.charCodeAt(0)) | 0;
|
||||||
|
}
|
||||||
|
const hue = ((hash % 360) + 360) % 360;
|
||||||
|
// hsl with s=0.6 l=0.6 to rgb
|
||||||
|
const c = 0.48;
|
||||||
|
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
|
||||||
|
const m = 0.36;
|
||||||
|
const [r, g, b] = hue < 60
|
||||||
|
? [c, x, 0]
|
||||||
|
: hue < 120
|
||||||
|
? [x, c, 0]
|
||||||
|
: hue < 180
|
||||||
|
? [0, c, x]
|
||||||
|
: hue < 240
|
||||||
|
? [0, x, c]
|
||||||
|
: hue < 300
|
||||||
|
? [x, 0, c]
|
||||||
|
: [c, 0, x];
|
||||||
|
return [r + m, g + m, b + m];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get_server_url(): string {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const server = params.get("server");
|
||||||
|
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
return `${protocol}//${server ?? location.host}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get_player_name(): string {
|
||||||
|
return new URLSearchParams(location.search).get("name") ?? "";
|
||||||
|
}
|
||||||
@@ -191,3 +191,69 @@ export function push_bottom_face(
|
|||||||
push_vertex(x2, y, z2, u1, v0, r, g, b, a);
|
push_vertex(x2, y, z2, u1, v0, r, g, b, a);
|
||||||
push_vertex(x, y, z2, u0, v0, r, g, b, a);
|
push_vertex(x, y, z2, u0, v0, r, g, b, a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// solid colored box, draw it with white_tex as the current texture
|
||||||
|
export function push_box(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
z: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
depth: number,
|
||||||
|
r = 1,
|
||||||
|
g = 1,
|
||||||
|
b = 1,
|
||||||
|
a = 1,
|
||||||
|
) {
|
||||||
|
const x2 = x + width;
|
||||||
|
const y2 = y + height;
|
||||||
|
const z2 = z + depth;
|
||||||
|
|
||||||
|
// front
|
||||||
|
push_vertex(x, y, z2, 0, 1, r, g, b, a);
|
||||||
|
push_vertex(x2, y, z2, 1, 1, r, g, b, a);
|
||||||
|
push_vertex(x2, y2, z2, 1, 0, r, g, b, a);
|
||||||
|
push_vertex(x, y, z2, 0, 1, r, g, b, a);
|
||||||
|
push_vertex(x2, y2, z2, 1, 0, r, g, b, a);
|
||||||
|
push_vertex(x, y2, z2, 0, 0, r, g, b, a);
|
||||||
|
|
||||||
|
// back
|
||||||
|
push_vertex(x2, y, z, 0, 1, r * 0.8, g * 0.8, b * 0.8, a);
|
||||||
|
push_vertex(x, y, z, 1, 1, r * 0.8, g * 0.8, b * 0.8, a);
|
||||||
|
push_vertex(x, y2, z, 1, 0, r * 0.8, g * 0.8, b * 0.8, a);
|
||||||
|
push_vertex(x2, y, z, 0, 1, r * 0.8, g * 0.8, b * 0.8, a);
|
||||||
|
push_vertex(x, y2, z, 1, 0, r * 0.8, g * 0.8, b * 0.8, a);
|
||||||
|
push_vertex(x2, y2, z, 0, 0, r * 0.8, g * 0.8, b * 0.8, a);
|
||||||
|
|
||||||
|
// left
|
||||||
|
push_vertex(x, y, z, 0, 1, r * 0.9, g * 0.9, b * 0.9, a);
|
||||||
|
push_vertex(x, y, z2, 1, 1, r * 0.9, g * 0.9, b * 0.9, a);
|
||||||
|
push_vertex(x, y2, z2, 1, 0, r * 0.9, g * 0.9, b * 0.9, a);
|
||||||
|
push_vertex(x, y, z, 0, 1, r * 0.9, g * 0.9, b * 0.9, a);
|
||||||
|
push_vertex(x, y2, z2, 1, 0, r * 0.9, g * 0.9, b * 0.9, a);
|
||||||
|
push_vertex(x, y2, z, 0, 0, r * 0.9, g * 0.9, b * 0.9, a);
|
||||||
|
|
||||||
|
// right
|
||||||
|
push_vertex(x2, y, z2, 0, 1, r * 0.9, g * 0.9, b * 0.9, a);
|
||||||
|
push_vertex(x2, y, z, 1, 1, r * 0.9, g * 0.9, b * 0.9, a);
|
||||||
|
push_vertex(x2, y2, z, 1, 0, r * 0.9, g * 0.9, b * 0.9, a);
|
||||||
|
push_vertex(x2, y, z2, 0, 1, r * 0.9, g * 0.9, b * 0.9, a);
|
||||||
|
push_vertex(x2, y2, z, 1, 0, r * 0.9, g * 0.9, b * 0.9, a);
|
||||||
|
push_vertex(x2, y2, z2, 0, 0, r * 0.9, g * 0.9, b * 0.9, a);
|
||||||
|
|
||||||
|
// top
|
||||||
|
push_vertex(x, y2, z2, 0, 1, r, g, b, a);
|
||||||
|
push_vertex(x2, y2, z2, 1, 1, r, g, b, a);
|
||||||
|
push_vertex(x2, y2, z, 1, 0, r, g, b, a);
|
||||||
|
push_vertex(x, y2, z2, 0, 1, r, g, b, a);
|
||||||
|
push_vertex(x2, y2, z, 1, 0, r, g, b, a);
|
||||||
|
push_vertex(x, y2, z, 0, 0, r, g, b, a);
|
||||||
|
|
||||||
|
// bottom
|
||||||
|
push_vertex(x, y, z, 0, 1, r * 0.6, g * 0.6, b * 0.6, a);
|
||||||
|
push_vertex(x2, y, z, 1, 1, r * 0.6, g * 0.6, b * 0.6, a);
|
||||||
|
push_vertex(x2, y, z2, 1, 0, r * 0.6, g * 0.6, b * 0.6, a);
|
||||||
|
push_vertex(x, y, z, 0, 1, r * 0.6, g * 0.6, b * 0.6, a);
|
||||||
|
push_vertex(x2, y, z2, 1, 0, r * 0.6, g * 0.6, b * 0.6, a);
|
||||||
|
push_vertex(x, y, z2, 0, 0, r * 0.6, g * 0.6, b * 0.6, a);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { System } from "$/common/ecs/mod.ts";
|
||||||
|
import { Position } from "$/common/components/position.ts";
|
||||||
|
import { ClientWorld } from "../client_world.ts";
|
||||||
|
import { Camera } from "../components/camera.ts";
|
||||||
|
|
||||||
|
const MOVE_SEND_INTERVAL = 1 / 10;
|
||||||
|
const REMOTE_PLAYER_SMOOTHING = 12;
|
||||||
|
|
||||||
|
export class NetworkSystem extends System {
|
||||||
|
move_timer = 0;
|
||||||
|
|
||||||
|
update(world: ClientWorld, delta: number): void {
|
||||||
|
const connection = world.connection;
|
||||||
|
if (!connection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const message of connection.incoming) {
|
||||||
|
switch (message.type) {
|
||||||
|
case "player_join":
|
||||||
|
connection.add_player(message.player);
|
||||||
|
break;
|
||||||
|
case "player_leave":
|
||||||
|
connection.players.delete(message.id);
|
||||||
|
break;
|
||||||
|
case "player_move": {
|
||||||
|
const player = connection.players.get(message.id);
|
||||||
|
if (player) {
|
||||||
|
player.x = message.x;
|
||||||
|
player.y = message.y;
|
||||||
|
player.z = message.z;
|
||||||
|
player.yaw = message.yaw;
|
||||||
|
player.pitch = message.pitch;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "set_block":
|
||||||
|
world.dimension.record_change(message.x, message.y, message.z, message.id);
|
||||||
|
world.dimension.apply_change(message.x, message.y, message.z, message.id);
|
||||||
|
break;
|
||||||
|
case "chat":
|
||||||
|
world.add_chat(message.from ? `<${message.from}> ${message.text}` : message.text);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.incoming.length = 0;
|
||||||
|
|
||||||
|
if (connection.closed) {
|
||||||
|
world.add_chat("Lost connection to the server");
|
||||||
|
world.connection = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = Math.min(1, delta * REMOTE_PLAYER_SMOOTHING);
|
||||||
|
for (const player of connection.players.values()) {
|
||||||
|
player.display_x += (player.x - player.display_x) * t;
|
||||||
|
player.display_y += (player.y - player.display_y) * t;
|
||||||
|
player.display_z += (player.z - player.display_z) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.move_timer += delta;
|
||||||
|
if (this.move_timer >= MOVE_SEND_INTERVAL) {
|
||||||
|
this.move_timer = 0;
|
||||||
|
const [player] = world.get_tag("player")!;
|
||||||
|
const position = player.get(Position)!;
|
||||||
|
const camera = player.get(Camera)!;
|
||||||
|
connection.send({
|
||||||
|
type: "move",
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
z: position.z,
|
||||||
|
yaw: camera.yaw,
|
||||||
|
pitch: camera.pitch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,7 @@ export class PlayerControlsSystem extends System {
|
|||||||
const drop_item = !block_info.requires_tool ||
|
const drop_item = !block_info.requires_tool ||
|
||||||
holding_item_info?.tool_type === block_info.tool_to_break;
|
holding_item_info?.tool_type === block_info.tool_to_break;
|
||||||
world.dimension.break_block(block.x, block.y, block.z, drop_item);
|
world.dimension.break_block(block.x, block.y, block.z, drop_item);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@@ -171,6 +172,7 @@ export class PlayerControlsSystem extends System {
|
|||||||
z: block.z + offset.z,
|
z: block.z + offset.z,
|
||||||
id: item_info.block_id,
|
id: item_info.block_id,
|
||||||
});
|
});
|
||||||
|
world.dimension.sync_block(block.x + offset.x, block.y + offset.y, block.z + offset.z);
|
||||||
hotbar_slot.amount! -= 1;
|
hotbar_slot.amount! -= 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { System } from "$/common/ecs/mod.ts";
|
import { System } from "$/common/ecs/mod.ts";
|
||||||
import { World } from "$/common/ecs/world.ts";
|
|
||||||
import { Position } from "$/common/components/position.ts";
|
import { Position } from "$/common/components/position.ts";
|
||||||
import { AnimatedSprite, Sprite } from "$/client/components/sprite.ts";
|
import { AnimatedSprite, Sprite } from "$/client/components/sprite.ts";
|
||||||
import { Dimension } from "../components/dimension.ts";
|
import { Dimension } from "../components/dimension.ts";
|
||||||
@@ -10,13 +9,15 @@ import { render_dimension } from "./rendering/dimension.ts";
|
|||||||
import { render_player_breaking, render_player_crosshair, render_player_hotbar } from "./rendering/player.ts";
|
import { render_player_breaking, render_player_crosshair, render_player_hotbar } from "./rendering/player.ts";
|
||||||
import { PlayerComponent } from "../player.ts";
|
import { PlayerComponent } from "../player.ts";
|
||||||
import { begin_mode_3d, end_mode_3d } from "../renderer/core.ts";
|
import { begin_mode_3d, end_mode_3d } from "../renderer/core.ts";
|
||||||
|
import { ClientWorld } from "../client_world.ts";
|
||||||
|
import { render_chat_log, render_remote_players } from "./rendering/network.ts";
|
||||||
|
|
||||||
export class RenderSystem extends System {
|
export class RenderSystem extends System {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
update(world: World, _delta: number): void {
|
update(world: ClientWorld, _delta: number): void {
|
||||||
const camera_entity = world.get_entities().values().find((e) => e.get(Camera));
|
const camera_entity = world.get_entities().values().find((e) => e.get(Camera));
|
||||||
const camera = camera_entity?.get(Camera);
|
const camera = camera_entity?.get(Camera);
|
||||||
|
|
||||||
@@ -39,6 +40,10 @@ export class RenderSystem extends System {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (world.connection) {
|
||||||
|
render_remote_players(world.connection);
|
||||||
|
}
|
||||||
|
|
||||||
end_mode_3d();
|
end_mode_3d();
|
||||||
|
|
||||||
for (const entity of world.get_entities()) {
|
for (const entity of world.get_entities()) {
|
||||||
@@ -60,5 +65,7 @@ export class RenderSystem extends System {
|
|||||||
render_player_crosshair();
|
render_player_crosshair();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
render_chat_log(world, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { ClientWorld } from "$/client/client_world.ts";
|
||||||
|
import { Connection } from "$/client/network.ts";
|
||||||
|
import {
|
||||||
|
canvas,
|
||||||
|
draw_rect,
|
||||||
|
draw_text,
|
||||||
|
flush_batch,
|
||||||
|
push_box,
|
||||||
|
set_current_texture,
|
||||||
|
white_tex,
|
||||||
|
} from "$/client/renderer/mod.ts";
|
||||||
|
|
||||||
|
const CHAT_LINE_HEIGHT = 24;
|
||||||
|
const CHAT_VISIBLE_SECONDS = 10;
|
||||||
|
const CHAT_MAX_LINES = 10;
|
||||||
|
|
||||||
|
export function render_remote_players(connection: Connection) {
|
||||||
|
if (connection.players.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
flush_batch();
|
||||||
|
set_current_texture(white_tex!);
|
||||||
|
|
||||||
|
for (const player of connection.players.values()) {
|
||||||
|
const [r, g, b] = player.color;
|
||||||
|
const x = player.display_x;
|
||||||
|
const y = player.display_y;
|
||||||
|
const z = player.display_z;
|
||||||
|
|
||||||
|
// body
|
||||||
|
push_box(x - 0.3, y, z - 0.15, 0.6, 1.3, 0.3, r, g, b);
|
||||||
|
// head
|
||||||
|
push_box(x - 0.25, y + 1.3, z - 0.25, 0.5, 0.5, 0.5, 0.95, 0.8, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
flush_batch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// draws the chat log above the bottom left corner, `all` shows old messages too (when the chat is open)
|
||||||
|
export function render_chat_log(world: ClientWorld, all: boolean, bottom = canvas.height - 100) {
|
||||||
|
const now = performance.now();
|
||||||
|
const lines = world.chat_log
|
||||||
|
.filter((line) => all || now - line.time < CHAT_VISIBLE_SECONDS * 1000)
|
||||||
|
.slice(-CHAT_MAX_LINES);
|
||||||
|
|
||||||
|
let y = bottom - lines.length * CHAT_LINE_HEIGHT;
|
||||||
|
for (const line of lines) {
|
||||||
|
draw_rect(0, y, 600, CHAT_LINE_HEIGHT, [0, 0, 0, 0.4]);
|
||||||
|
draw_text(line.text, 4, y, 2, [1, 1, 1, 1]);
|
||||||
|
y += CHAT_LINE_HEIGHT;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,3 +18,7 @@ export const VOID = 0xFFFFFFFF;
|
|||||||
|
|
||||||
export const ID_MASK = 0xFFFF;
|
export const ID_MASK = 0xFFFF;
|
||||||
export const STATE_SHIFT = 16;
|
export const STATE_SHIFT = 16;
|
||||||
|
|
||||||
|
export const CHUNK_SIZE = 16;
|
||||||
|
export const CHUNK_HEIGHT = 128;
|
||||||
|
export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// messages sent between the client and the server, as json over a websocket
|
||||||
|
|
||||||
|
export const AIR_ID = "bworld:air";
|
||||||
|
|
||||||
|
export interface PlayerInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
yaw: number;
|
||||||
|
pitch: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// x, y, z, block id
|
||||||
|
export type BlockChange = [number, number, number, string];
|
||||||
|
|
||||||
|
export type ClientMessage =
|
||||||
|
| { type: "hello"; name: string }
|
||||||
|
| { type: "move"; x: number; y: number; z: number; yaw: number; pitch: number }
|
||||||
|
| { type: "set_block"; x: number; y: number; z: number; id: string }
|
||||||
|
| { type: "chat"; text: string };
|
||||||
|
|
||||||
|
export type ServerMessage =
|
||||||
|
| { type: "welcome"; id: string; seed: string; players: PlayerInfo[]; changes: BlockChange[] }
|
||||||
|
| { type: "player_join"; player: PlayerInfo }
|
||||||
|
| { type: "player_leave"; id: string }
|
||||||
|
| { type: "player_move"; id: string; x: number; y: number; z: number; yaw: number; pitch: number }
|
||||||
|
| { type: "set_block"; x: number; y: number; z: number; id: string }
|
||||||
|
| { type: "chat"; from?: string; text: string };
|
||||||
|
|
||||||
|
export const MAX_NAME_LENGTH = 16;
|
||||||
|
export const MAX_CHAT_LENGTH = 256;
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable"]
|
"lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable"]
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"@gfx/canvas-wasm": "jsr:@gfx/canvas-wasm@^0.4.2",
|
"@gfx/canvas-wasm": "jsr:@gfx/canvas-wasm@^0.4.2",
|
||||||
"@paulaboks/rng": "jsr:@paulaboks/rng@^0.0.3",
|
"@paulaboks/rng": "jsr:@paulaboks/rng@^0.0.3",
|
||||||
"@std/fs": "jsr:@std/fs@^1.0.23",
|
"@std/fs": "jsr:@std/fs@^1.0.23",
|
||||||
|
"@std/http": "jsr:@std/http@^1.0.23",
|
||||||
"gl-matrix": "npm:gl-matrix@^3.4.4",
|
"gl-matrix": "npm:gl-matrix@^3.4.4",
|
||||||
"marked": "npm:marked@^17.0.3"
|
"marked": "npm:marked@^17.0.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,20 @@
|
|||||||
"specifiers": {
|
"specifiers": {
|
||||||
"jsr:@gfx/canvas-wasm@~0.4.2": "0.4.2",
|
"jsr:@gfx/canvas-wasm@~0.4.2": "0.4.2",
|
||||||
"jsr:@paulaboks/rng@^0.0.3": "0.0.3",
|
"jsr:@paulaboks/rng@^0.0.3": "0.0.3",
|
||||||
|
"jsr:@std/cli@^1.0.27": "1.0.27",
|
||||||
"jsr:@std/encoding@1.0.5": "1.0.5",
|
"jsr:@std/encoding@1.0.5": "1.0.5",
|
||||||
|
"jsr:@std/encoding@^1.0.10": "1.0.10",
|
||||||
|
"jsr:@std/fmt@^1.0.9": "1.0.10",
|
||||||
|
"jsr:@std/fs@^1.0.22": "1.0.23",
|
||||||
"jsr:@std/fs@^1.0.23": "1.0.23",
|
"jsr:@std/fs@^1.0.23": "1.0.23",
|
||||||
|
"jsr:@std/html@^1.0.5": "1.0.5",
|
||||||
|
"jsr:@std/http@*": "1.0.24",
|
||||||
|
"jsr:@std/http@^1.0.23": "1.0.24",
|
||||||
"jsr:@std/internal@^1.0.12": "1.0.12",
|
"jsr:@std/internal@^1.0.12": "1.0.12",
|
||||||
|
"jsr:@std/media-types@^1.1.0": "1.1.0",
|
||||||
|
"jsr:@std/net@^1.0.6": "1.0.6",
|
||||||
"jsr:@std/path@^1.1.4": "1.1.4",
|
"jsr:@std/path@^1.1.4": "1.1.4",
|
||||||
|
"jsr:@std/streams@^1.0.17": "1.1.2",
|
||||||
"npm:gl-matrix@^3.4.4": "3.4.4",
|
"npm:gl-matrix@^3.4.4": "3.4.4",
|
||||||
"npm:marked@^17.0.3": "17.0.3"
|
"npm:marked@^17.0.3": "17.0.3"
|
||||||
},
|
},
|
||||||
@@ -14,15 +24,24 @@
|
|||||||
"@gfx/canvas-wasm@0.4.2": {
|
"@gfx/canvas-wasm@0.4.2": {
|
||||||
"integrity": "d653be3bd12cb2fa9bbe5d1b1f041a81b91d80b68502761204aaf60e4592532a",
|
"integrity": "d653be3bd12cb2fa9bbe5d1b1f041a81b91d80b68502761204aaf60e4592532a",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@std/encoding"
|
"jsr:@std/encoding@1.0.5"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@paulaboks/rng@0.0.3": {
|
"@paulaboks/rng@0.0.3": {
|
||||||
"integrity": "8d2571f9f406dab2674178f4034825cc654c5a52aa7c2a176a25f8b713eee202"
|
"integrity": "8d2571f9f406dab2674178f4034825cc654c5a52aa7c2a176a25f8b713eee202"
|
||||||
},
|
},
|
||||||
|
"@std/cli@1.0.27": {
|
||||||
|
"integrity": "eba97edd0891871a7410e835dd94b3c260c709cca5983df2689c25a71fbe04de"
|
||||||
|
},
|
||||||
"@std/encoding@1.0.5": {
|
"@std/encoding@1.0.5": {
|
||||||
"integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04"
|
"integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04"
|
||||||
},
|
},
|
||||||
|
"@std/encoding@1.0.10": {
|
||||||
|
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
|
||||||
|
},
|
||||||
|
"@std/fmt@1.0.10": {
|
||||||
|
"integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5"
|
||||||
|
},
|
||||||
"@std/fs@1.0.23": {
|
"@std/fs@1.0.23": {
|
||||||
"integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37",
|
"integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -30,14 +49,40 @@
|
|||||||
"jsr:@std/path"
|
"jsr:@std/path"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@std/html@1.0.5": {
|
||||||
|
"integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e"
|
||||||
|
},
|
||||||
|
"@std/http@1.0.24": {
|
||||||
|
"integrity": "4dd59afd7cfd6e2e96e175b67a5a829b449ae55f08575721ec691e5d85d886d4",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/cli",
|
||||||
|
"jsr:@std/encoding@^1.0.10",
|
||||||
|
"jsr:@std/fmt",
|
||||||
|
"jsr:@std/fs@^1.0.22",
|
||||||
|
"jsr:@std/html",
|
||||||
|
"jsr:@std/media-types",
|
||||||
|
"jsr:@std/net",
|
||||||
|
"jsr:@std/path",
|
||||||
|
"jsr:@std/streams"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@std/internal@1.0.12": {
|
"@std/internal@1.0.12": {
|
||||||
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
|
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
|
||||||
},
|
},
|
||||||
|
"@std/media-types@1.1.0": {
|
||||||
|
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
|
||||||
|
},
|
||||||
|
"@std/net@1.0.6": {
|
||||||
|
"integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c"
|
||||||
|
},
|
||||||
"@std/path@1.1.4": {
|
"@std/path@1.1.4": {
|
||||||
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
|
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@std/internal"
|
"jsr:@std/internal"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"@std/streams@1.1.2": {
|
||||||
|
"integrity": "0249bf9b78a999f57032ca4d8e7ccaa57579090242640b35ddc948fbb745d3af"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"npm": {
|
"npm": {
|
||||||
@@ -54,6 +99,7 @@
|
|||||||
"jsr:@gfx/canvas-wasm@~0.4.2",
|
"jsr:@gfx/canvas-wasm@~0.4.2",
|
||||||
"jsr:@paulaboks/rng@^0.0.3",
|
"jsr:@paulaboks/rng@^0.0.3",
|
||||||
"jsr:@std/fs@^1.0.23",
|
"jsr:@std/fs@^1.0.23",
|
||||||
|
"jsr:@std/http@^1.0.23",
|
||||||
"npm:gl-matrix@^3.4.4",
|
"npm:gl-matrix@^3.4.4",
|
||||||
"npm:marked@^17.0.3"
|
"npm:marked@^17.0.3"
|
||||||
]
|
]
|
||||||
|
|||||||
+256
@@ -0,0 +1,256 @@
|
|||||||
|
import { serveDir } from "@std/http/file-server";
|
||||||
|
import { CHUNK_HEIGHT } from "$/common/constants.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 WORLD_FILE = Deno.env.get("WORLD_FILE") ?? "world.json";
|
||||||
|
const STATIC_ROOT = "build";
|
||||||
|
const SAVE_INTERVAL_MS = 30_000;
|
||||||
|
const MAX_MESSAGE_SIZE = 4096;
|
||||||
|
// how far from a player a block can be changed, a bit more than the client's reach
|
||||||
|
const MAX_REACH = 8;
|
||||||
|
|
||||||
|
const BLOCK_ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/;
|
||||||
|
|
||||||
|
interface SavedWorld {
|
||||||
|
seed: string;
|
||||||
|
changes: BlockChange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Client {
|
||||||
|
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 {
|
||||||
|
const saved: SavedWorld = JSON.parse(Deno.readTextFileSync(path));
|
||||||
|
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) {
|
||||||
|
if (!(e instanceof Deno.errors.NotFound)) {
|
||||||
|
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) {
|
||||||
|
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
|
||||||
|
Deno.writeTextFileSync(`${path}.tmp`, JSON.stringify(saved));
|
||||||
|
Deno.renameSync(`${path}.tmp`, path);
|
||||||
|
this.dirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const world = ServerWorld.load(WORLD_FILE);
|
||||||
|
const clients = new Set<Client>();
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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 });
|
||||||
|
broadcast({ type: "player_move", id: player.id, x, y, z, yaw, pitch }, client);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "set_block": {
|
||||||
|
const { x, y, z, id } = message;
|
||||||
|
if (!is_int(x) || !is_int(y) || !is_int(z) || y < 0 || y >= CHUNK_HEIGHT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
case "chat": {
|
||||||
|
if (typeof message.text !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = message.text.trim().slice(0, MAX_CHAT_LENGTH);
|
||||||
|
if (text.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`<${player.name}> ${text}`);
|
||||||
|
broadcast({ type: "chat", from: player.name, text });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_socket(socket: WebSocket) {
|
||||||
|
const client: Client = { socket };
|
||||||
|
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
clients.add(client);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
if (typeof event.data !== "string" || event.data.length > MAX_MESSAGE_SIZE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let message: ClientMessage;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(event.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof message !== "object" || message === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handle_message(client, message);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("close", () => {
|
||||||
|
clients.delete(client);
|
||||||
|
if (client.player) {
|
||||||
|
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() {
|
||||||
|
console.log("Saving world...");
|
||||||
|
world.save(WORLD_FILE);
|
||||||
|
Deno.exit(0);
|
||||||
|
}
|
||||||
|
Deno.addSignalListener("SIGINT", shutdown);
|
||||||
|
if (Deno.build.os !== "windows") {
|
||||||
|
Deno.addSignalListener("SIGTERM", shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve({ port: PORT, onListen: ({ port }) => console.log(`bworld server on http://localhost:${port}/`) }, (req) => {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
if (url.pathname === "/ws") {
|
||||||
|
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
|
||||||
|
return new Response("Expected a websocket", { status: 426 });
|
||||||
|
}
|
||||||
|
const { socket, response } = Deno.upgradeWebSocket(req);
|
||||||
|
handle_socket(socket);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/") {
|
||||||
|
return Response.redirect(new URL("/client/", url), 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
return serveDir(req, { fsRoot: STATIC_ROOT, quiet: true });
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user