Compare commits

..
10 Commits
Author SHA1 Message Date
paula dfabe40e7a Server actually ticks 2026-09-25 13:25:25 -03:00
paula 2a2ccae9ce Improve networking 2026-09-25 00:08:01 -03:00
paula 6458bc0440 Implement main game as a mod 2026-09-24 23:49:34 -03:00
paula bb42dd662e Test mod 2026-09-24 23:28:01 -03:00
paula 79faa556de Server authority 2026-09-24 19:34:07 -03:00
paula 1bef94ce0c Mods plan 2026-09-24 19:11:19 -03:00
paula d12fa84b00 Optimize renderer 2026-09-24 18:52:20 -03:00
paula 14aba6b129 Webgpu renderer 2026-09-24 18:41:52 -03:00
paula a37ffd9127 Server 2026-09-24 18:34:16 -03:00
paula a758037f20 Tool 2026-04-14 10:51:57 -03:00
223 changed files with 9574 additions and 2270 deletions
+3
View File
@@ -1 +1,4 @@
build/ build/
world.json
world.json.tmp
server_mods/
+1180
View File
File diff suppressed because it is too large Load Diff
+158 -28
View File
@@ -1,11 +1,27 @@
import { copy } from "@std/fs"; import { copy } from "@std/fs";
import { createCanvas, loadImage } from "@gfx/canvas-wasm"; import { createCanvas, loadImage } from "@gfx/canvas-wasm";
import { ENGINE_TEXTURE_DIR, load_and_check, load_order, LoadedMod } from "./tools/check_mods.ts";
import type { AtlasListing, ModData, ModListing } from "./common/mod_loader.ts";
const BUILD_FOLDER = "build"; // all overridable so tests can build somewhere else
const BUILD_FOLDER = Deno.env.get("BUILD_DIR") ?? "build";
const MODS_FOLDER = Deno.env.get("MODS_DIR") ?? "mods";
// server scripts go here instead of the served build folder, players never get them
const SERVER_MODS_FOLDER = Deno.env.get("SERVER_MODS_DIR") ?? "server_mods";
function clear_folder() { // what the server reads at startup, see server/main.ts
Deno.removeSync(BUILD_FOLDER, { recursive: true }); export interface ServerModIndex {
Deno.mkdirSync(BUILD_FOLDER); atlas: AtlasListing;
mods: { listing: ModListing; server?: string }[];
}
function clear_folder(folder: string) {
try {
Deno.removeSync(folder, { recursive: true });
} catch (e) {
if (!(e instanceof Deno.errors.NotFound)) throw e;
}
Deno.mkdirSync(folder, { recursive: true });
} }
async function build_fonts() { async function build_fonts() {
@@ -59,14 +75,23 @@ function calculate_atlas_size(count: number) {
}; };
} }
async function build_atlas_from_folder(folder: string) { // one atlas with the engine's textures (engine:<file>) and every mod's (<mod>:<file>).
let sprite_count = 0; // named by its hash, so clients can cache it forever and download it from other origins
for (const entry of Deno.readDirSync(folder)) { async function build_atlas(mods: LoadedMod[]): Promise<AtlasListing> {
console.assert(entry.isFile && entry.name.endsWith(".png")); const textures = new Map<string, string>();
sprite_count += 1; for (const entry of Deno.readDirSync(ENGINE_TEXTURE_DIR)) {
if (entry.isFile && entry.name.endsWith(".png")) {
textures.set(`engine:${entry.name.replace(".png", "")}`, `${ENGINE_TEXTURE_DIR}/${entry.name}`);
}
}
for (const mod of mods) {
for (const [id, path] of mod.texture_files) {
textures.set(id, path);
}
} }
const atlas = calculate_atlas_size(sprite_count); // + 1 for the missing texture
const atlas = calculate_atlas_size(textures.size + 1);
const canvas = createCanvas(atlas.size, atlas.size); const canvas = createCanvas(atlas.size, atlas.size);
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
@@ -80,45 +105,138 @@ async function build_atlas_from_folder(folder: string) {
ctx.fillStyle = "black"; ctx.fillStyle = "black";
ctx.fillRect(8, 0, 8, 8); ctx.fillRect(8, 0, 8, 8);
ctx.fillRect(0, 8, 8, 8); ctx.fillRect(0, 8, 8, 8);
atlas_info["engine:missing"] = { x: 0, y: 0 };
let index = 1; let index = 1;
for (const entry of Deno.readDirSync(folder)) { for (const [id, path] of [...textures].sort(([a], [b]) => a.localeCompare(b))) {
const sprite = await loadImage(`${folder}/${entry.name}`); const sprite = await loadImage(path);
const row = Math.floor(index / atlas.sprites_per_side); const row = Math.floor(index / atlas.sprites_per_side);
const column = index % atlas.sprites_per_side; const column = index % atlas.sprites_per_side;
ctx.drawImage(sprite, column * SPRITE_SIZE, row * SPRITE_SIZE); ctx.drawImage(sprite, column * SPRITE_SIZE, row * SPRITE_SIZE);
index += 1; index += 1;
const id = `bworld:${entry.name.replace(".png", "")}`;
atlas_info[id] = { x: column, y: row }; atlas_info[id] = { x: column, y: row };
} }
Deno.writeFileSync(`${BUILD_FOLDER}/${folder}.png`, canvas.toBuffer()); const png = new Uint8Array(canvas.toBuffer());
Deno.writeTextFileSync(`${BUILD_FOLDER}/${folder}.json`, JSON.stringify(atlas_info)); const json = new TextEncoder().encode(JSON.stringify(atlas_info));
const hashes = { png: await sha256(png), json: await sha256(json) };
const name = `assets/sprites/textures.${(hashes.png + hashes.json).slice(0, 12)}`;
Deno.writeFileSync(`${BUILD_FOLDER}/${name}.png`, png);
Deno.writeFileSync(`${BUILD_FOLDER}/${name}.json`, json);
return { png: `${name}.png`, json: `${name}.json`, sha256: hashes };
} }
async function build_sprites() { async function build_sprites(mods: LoadedMod[]) {
for (const entry of Deno.readDirSync("assets/sprites")) { for (const entry of Deno.readDirSync("assets/sprites")) {
if (entry.name.endsWith(".png")) { if (entry.name.endsWith(".png")) {
await copy(`assets/sprites/${entry.name}`, `${BUILD_FOLDER}/assets/sprites/${entry.name}`); await copy(`assets/sprites/${entry.name}`, `${BUILD_FOLDER}/assets/sprites/${entry.name}`);
} else if (entry.isDirectory) {
await build_atlas_from_folder(`assets/sprites/${entry.name}`);
} }
} }
return await build_atlas(mods);
} }
async function build_assets() { async function build_assets(mods: LoadedMod[]): Promise<AtlasListing> {
Deno.mkdirSync(`${BUILD_FOLDER}/assets`, { recursive: true }); Deno.mkdirSync(`${BUILD_FOLDER}/assets`, { recursive: true });
await copy("assets/ASSETS.md", `${BUILD_FOLDER}/assets/ASSETS.md`); await copy("assets/ASSETS.md", `${BUILD_FOLDER}/assets/ASSETS.md`);
build_fonts(); await build_fonts();
Deno.mkdirSync(`${BUILD_FOLDER}/assets/sprites`, { recursive: true }); Deno.mkdirSync(`${BUILD_FOLDER}/assets/sprites`, { recursive: true });
await build_sprites(); return await build_sprites(mods);
}
// every mod in mods/, checked and sorted so dependencies load first. a broken mod fails the whole build,
// the game never runs with some mods missing
function load_mods(): LoadedMod[] {
const mods = load_and_check(MODS_FOLDER);
const problems = mods.flatMap((mod) => mod.report.errors.map((error) => ` ${mod.id}: ${error}`));
if (problems.length > 0) {
throw new Error(`Mods have errors (deno task check-mods for details):\n${problems.join("\n")}`);
}
return load_order(mods);
}
async function bundle_script(path: string, platform: "browser" | "deno"): Promise<string> {
const result = await Deno.bundle({ entrypoints: [path], platform, write: false, minify: false });
if (!result.success || !result.outputFiles?.length) {
throw new Error(`Couldn't bundle ${path}:\n${result.errors.map((e) => e.text).join("\n")}`);
}
return result.outputFiles[0].text();
}
async function sha256(bytes: Uint8Array<ArrayBuffer> | string) {
const data = typeof bytes === "string" ? new TextEncoder().encode(bytes) : bytes;
const digest = await crypto.subtle.digest("SHA-256", data);
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function short_hash(parts: string[]) {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(parts.join("\0")));
return [...new Uint8Array(digest)].slice(0, 6).map((b) => b.toString(16).padStart(2, "0")).join("");
}
// build/mods/<id>/<hash>/ gets what players download, server_mods/ the server scripts
async function build_mods(mods: LoadedMod[], atlas: AtlasListing) {
clear_folder(SERVER_MODS_FOLDER);
const index: ServerModIndex = { atlas, mods: [] };
for (const mod of mods) {
const manifest = mod.manifest as { name: string; version: string; scripts?: Record<string, string> };
const scripts = manifest.scripts ?? {};
const data: ModData = {
blocks: mod.blocks.map((b) => b.json),
items: mod.items.map((i) => i.json),
recipes: mod.recipes.map((r) => r.json),
ores: mod.ores.map((o) => o.json),
};
const data_json = JSON.stringify(data);
const client = scripts.client ? await bundle_script(`${mod.dir}/${scripts.client}`, "browser") : undefined;
const worldgen = scripts.worldgen
? await bundle_script(`${mod.dir}/${scripts.worldgen}`, "browser")
: undefined;
const server = scripts.server ? await bundle_script(`${mod.dir}/${scripts.server}`, "deno") : undefined;
const hash = await short_hash([data_json, client ?? "", worldgen ?? ""]);
const public_dir = `mods/${mod.id}/${hash}`;
Deno.mkdirSync(`${BUILD_FOLDER}/${public_dir}`, { recursive: true });
const listing: ModListing = {
id: mod.id,
name: manifest.name,
version: manifest.version,
hash,
data: `${public_dir}/data.json`,
sha256: { data: await sha256(data_json) },
};
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.data}`, data_json);
if (client) {
listing.client = `${public_dir}/client.js`;
listing.sha256.client = await sha256(client);
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.client}`, client);
}
if (worldgen) {
listing.worldgen = `${public_dir}/worldgen.js`;
listing.sha256.worldgen = await sha256(worldgen);
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.worldgen}`, worldgen);
}
const entry: ServerModIndex["mods"][number] = { listing };
if (server) {
const server_dir = `${SERVER_MODS_FOLDER}/${mod.id}/${await short_hash([server])}`;
Deno.mkdirSync(server_dir, { recursive: true });
entry.server = `${server_dir}/server.js`;
Deno.writeTextFileSync(entry.server, server);
}
index.mods.push(entry);
}
Deno.writeTextFileSync(`${SERVER_MODS_FOLDER}/index.json`, JSON.stringify(index, null, "\t"));
console.log(`Mods: ${mods.map((m) => m.id).join(", ") || "none"}`);
} }
async function build_client() { async function build_client() {
const _result = await Deno.bundle({ const _result = await Deno.bundle({
entrypoints: ["./client/main.ts", "./client/workers/chunk_mesh_worker.ts"], entrypoints: ["./client/main.ts", "./client/workers/chunk_worker.ts"],
outputDir: `${BUILD_FOLDER}/client`, outputDir: `${BUILD_FOLDER}/client`,
platform: "browser", platform: "browser",
minify: false, minify: false,
@@ -130,20 +248,32 @@ async function build_client() {
async function build() { async function build() {
try { try {
const now = performance.now(); const now = performance.now();
clear_folder(); clear_folder(BUILD_FOLDER);
await build_assets(); const mods = load_mods();
const atlas = await build_assets(mods);
await build_client(); await build_client();
await build_mods(mods, atlas);
console.log(`Built in ${(performance.now() - now).toFixed(2)}ms`); console.log(`Built in ${(performance.now() - now).toFixed(2)}ms`);
} catch (e) { } catch (e) {
console.log(e); // no index means the server refuses to start, instead of running without some mods
try {
Deno.removeSync(`${SERVER_MODS_FOLDER}/index.json`);
} catch { /* wasn't there */ }
console.log(e instanceof Error ? e.message : e);
return false;
} }
return true;
} }
let last_build = 0; let last_build = 0;
if (import.meta.main) { if (import.meta.main) {
await build(); const ok = await build();
const watcher = Deno.watchFs(["assets", "client", "common"], { recursive: true }); // deno task build --once, for scripts and ci
if (Deno.args.includes("--once")) {
Deno.exit(ok ? 0 : 1);
}
const watcher = Deno.watchFs(["assets", "client", "common", MODS_FOLDER], { recursive: true });
for await (const event of watcher) { for await (const event of watcher) {
const now = performance.now(); const now = performance.now();
if (now - last_build < 500) { if (now - last_build < 500) {
-44
View File
@@ -1,44 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
import { Container, Inventory } from "../inventory.ts";
import { PlayerComponent } from "../player.ts";
import { GuiChest } from "$/client/gui/gui_chest.ts";
interface TileChestData {
inventory: Inventory;
}
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:chest", {
id: "bworld:chest",
textures: "bworld:planks",
toughness: 8,
requires_tool: false,
tool_to_break: "axe",
drop_table: "bworld:chest",
has_collision: false,
on_interact(dimension, block) {
const [player] = dimension.world.get_tag("player")!;
const player_component = player.get(PlayerComponent)!;
const block_data = dimension.get_block_data<TileChestData>(block.x, block.y, block.z);
if (block_data && block_data.data.inventory) {
const gui = new GuiChest(block_data.data.inventory, player_component.player_inventory);
player_component.screens.push(gui);
return true;
}
return false;
},
on_create(dimension, block) {
dimension.add_block_data({
id: block.id,
x: block.x,
y: block.y,
z: block.z,
data: {
inventory: new Inventory(new Container(9 * 3)),
},
});
},
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:coal_ore", {
id: "bworld:coal_ore",
textures: "bworld:stone_coal",
has_collision: true,
drop_table: "bworld:coal_ore",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:copper_ore", {
id: "bworld:copper_ore",
textures: "bworld:stone_copper",
has_collision: true,
drop_table: "bworld:copper_ore",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-26
View File
@@ -1,26 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
import { PlayerComponent } from "../player.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:dirt", {
id: "bworld:dirt",
textures: "bworld:dirt",
has_collision: false,
drop_table: "bworld:dirt",
toughness: 2,
requires_tool: false,
tool_to_break: "shovel",
on_interact(dimension, block) {
const [player] = dimension.world.get_tag("player")!;
const player_inventory = player.get(PlayerComponent)!.player_inventory;
const maybe_item = player_inventory.container.get_item(player_inventory.hotbar_selected);
if (maybe_item && maybe_item.type_id === "bworld:hoe") {
dimension.add_block({ x: block.x, y: block.y, z: block.z, id: "bworld:hoed_dirt" });
return true;
}
return false;
},
});
register_block_item(block);
-200
View File
@@ -1,200 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { Container, Inventory, ItemStack } from "../inventory.ts";
import { PlayerComponent } from "../player.ts";
import { GuiFurnace } from "$/client/gui/gui_furnace.ts";
import { register_block_item } from "../../common/utils.ts";
interface FurnaceRecipe {
input: string;
output: ItemStack;
cook_time: number;
}
const FURNACE_RECIPES: FurnaceRecipe[] = [
{
input: "bworld:log",
output: new ItemStack("bworld:coal", 1),
cook_time: 100,
},
{
input: "bworld:coal_ore",
output: new ItemStack("bworld:coal", 1),
cook_time: 100,
},
{
input: "bworld:iron_ore",
output: new ItemStack("bworld:iron_ingot", 1),
cook_time: 200,
},
{
input: "bworld:tin_ore",
output: new ItemStack("bworld:tin_ingot", 1),
cook_time: 200,
},
{
input: "bworld:copper_ore",
output: new ItemStack("bworld:copper_ingot", 1),
cook_time: 200,
},
{
input: "bworld:gold_ore",
output: new ItemStack("bworld:gold_ingot", 1),
cook_time: 200,
},
];
function get_recipe(input?: ItemStack | undefined): FurnaceRecipe | undefined {
if (!input) {
return;
}
return FURNACE_RECIPES.find((r) => r.input === input.type_id);
}
function can_craft(container: Container, recipe?: FurnaceRecipe) {
if (!recipe) {
return false;
}
const output = container.get_item(2);
if (!output) {
return true;
}
if (output.type_id !== recipe.output.type_id) {
return false;
}
return output.amount < output.max_amount;
}
const FUEL_VALUES: Record<string, number> = {
"bworld:coal": 1000,
"bworld:log": 100,
};
function get_fuel_value(item?: ItemStack | undefined): number {
if (!item) {
return 0;
}
return FUEL_VALUES[item.type_id] ?? 0;
}
function has_fuel(container: Container) {
return get_fuel_value(container.get_item(1)) > 0;
}
function consume_fuel(container: Container): number {
const fuel = container.get_slot(1)!;
const value = get_fuel_value(fuel.get_item());
if (fuel.has_item()) {
fuel.amount! -= 1;
}
return value;
}
function craft(container: Container, recipe: FurnaceRecipe) {
const input = container.get_item(0)!;
const output = container.get_item(2);
if (!output) {
container.set_item(2, recipe.output.clone());
} else {
output.amount += recipe.output.amount;
}
input.amount -= 1;
container.set_item(0, input.amount > 0 ? input : undefined);
}
interface TileChestData {
inventory: Inventory;
progress: number;
progress_max: number;
fuel: number;
fuel_max: number;
}
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:furnace", {
id: "bworld:furnace",
textures: { front: "bworld:furnace", side: "bworld:stone" },
has_collision: true,
drop_table: "bworld:furnace",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
on_interact(dimension, block) {
const [player] = dimension.world.get_tag("player")!;
const player_component = player.get(PlayerComponent)!;
const block_data = dimension.get_block_data<TileChestData>(block.x, block.y, block.z);
if (block_data && block_data.data.inventory) {
const gui = new GuiFurnace(block_data.data.inventory, player_component.player_inventory, block_data.data);
player_component.screens.push(gui);
return true;
}
return false;
},
on_create(dimension, block) {
dimension.add_block_data({
id: block.id,
x: block.x,
y: block.y,
z: block.z,
data: {
inventory: new Inventory(new Container(3)),
progress: 0,
progress_max: 0,
fuel: 0,
fuel_max: 0,
},
});
},
on_break() {
// TODO: remove block data
},
on_tick(dimension, block) {
const block_data = dimension.get_block_data<TileChestData>(block.x, block.y, block.z);
if (!block_data) {
return;
}
const data = block_data.data;
const container = data.inventory.container;
const input = container.get_item(0);
const recipe = get_recipe(input);
// burn fuel
if (data.fuel > 0) {
data.fuel -= 1;
}
if (!can_craft(container, recipe)) {
data.progress = 0;
return;
}
// refuel
if (data.fuel === 0 && has_fuel(container)) {
data.fuel = consume_fuel(container);
data.fuel_max = data.fuel;
}
// cook
if (data.fuel > 0 && recipe) {
data.progress_max = recipe.cook_time;
data.progress += 1;
if (data.progress >= recipe.cook_time) {
data.progress = 0;
craft(container, recipe);
}
}
},
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:glass", {
id: "bworld:glass",
textures: "bworld:glass",
has_collision: true,
transparent: true,
toughness: 3,
requires_tool: false,
tool_to_break: "pickaxe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:gold_ore", {
id: "bworld:gold_ore",
textures: "bworld:stone_gold",
has_collision: true,
drop_table: "bworld:gold_ore",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-24
View File
@@ -1,24 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { PlayerComponent } from "../player.ts";
EverythingRegistry.register<BlockRegistry>("blocks", "bworld:grass", {
id: "bworld:grass",
textures: { top: "bworld:grass_top", bottom: "bworld:dirt", "side": "bworld:grass_side" },
has_collision: false,
drop_table: "bworld:dirt",
toughness: 2,
requires_tool: false,
tool_to_break: "shovel",
on_interact(dimension, block) {
const [player] = dimension.world.get_tag("player")!;
const player_inventory = player.get(PlayerComponent)!.player_inventory;
const item = player_inventory.container.get_item(player_inventory.hotbar_selected);
if (item?.type_id === "bworld:hoe") {
block.id = "bworld:hoed_dirt";
dimension.add_block(block);
return true;
}
return false;
},
});
-18
View File
@@ -1,18 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
// TODO: watered state
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:hoed_dirt", {
id: "bworld:hoed_dirt",
textures: { side: "bworld:dirt", top: "bworld:hoed_dirt", bottom: "bworld:dirt" },
has_collision: false,
drop_table: "bworld:dirt",
toughness: 5,
requires_tool: false,
tool_to_break: "shovel",
states: [
{ name: "watered", bits: 1, default: 0 },
],
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:iron_ore", {
id: "bworld:iron_ore",
textures: "bworld:stone_iron",
has_collision: true,
drop_table: "bworld:iron_ore",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:leaves", {
id: "bworld:leaves",
textures: "bworld:leaves",
has_collision: true,
transparent: true,
toughness: 3,
requires_tool: false,
tool_to_break: "hoe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:log", {
id: "bworld:log",
textures: { side: "bworld:log_side", top: "bworld:log_top", bottom: "bworld:log_top" },
has_collision: true,
drop_table: "bworld:log",
toughness: 3,
requires_tool: false,
tool_to_break: "axe",
});
register_block_item(block);
-19
View File
@@ -1,19 +0,0 @@
import "./grass.ts";
import "./dirt.ts";
import "./crops.ts";
import "./water.ts";
import "./chest.ts";
import "./furnace.ts";
import "./stone.ts";
import "./log.ts";
import "./sand.ts";
import "./snow.ts";
import "./glass.ts";
import "./hoed_dirt.ts";
import "./leaves.ts";
import "./coal_ore.ts";
import "./copper_ore.ts";
import "./iron_ore.ts";
import "./tin_ore.ts";
import "./gold_ore.ts";
import "./planks.ts";
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:planks", {
id: "bworld:planks",
textures: "bworld:planks",
has_collision: true,
drop_table: "bworld:log",
toughness: 3,
requires_tool: false,
tool_to_break: "axe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:sand", {
id: "bworld:sand",
textures: "bworld:sand",
has_collision: true,
drop_table: "bworld:sand",
toughness: 3,
requires_tool: false,
tool_to_break: "shovel",
});
register_block_item(block);
-13
View File
@@ -1,13 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:snow", {
id: "bworld:snow",
textures: "bworld:snow",
has_collision: true,
toughness: 2,
requires_tool: true,
tool_to_break: "shovel",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:stone", {
id: "bworld:stone",
textures: "bworld:stone",
has_collision: true,
drop_table: "bworld:stone",
toughness: 3,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:tin_ore", {
id: "bworld:tin_ore",
textures: "bworld:stone_tin",
has_collision: true,
drop_table: "bworld:tin_ore",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-9
View File
@@ -1,9 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<BlockRegistry>("blocks", "bworld:water", {
id: "bworld:water",
textures: "bworld:water",
has_collision: false,
transparent: true,
alpha: 0.8,
});
+39
View File
@@ -0,0 +1,39 @@
import type { FromChunkWorker, ToChunkWorker } from "./workers/chunk_messages.ts";
// leave a core for the main thread, more than 4 doesnt help much
const POOL_SIZE = Math.max(1, Math.min(4, (navigator.hardwareConcurrency ?? 4) - 1));
export class ChunkWorkerPool {
readonly size = POOL_SIZE;
#workers: Worker[] = [];
#next = 0;
constructor(on_message: (message: FromChunkWorker) => void) {
for (let i = 0; i < this.size; i += 1) {
const worker = new Worker(new URL("./workers/chunk_worker.js", import.meta.url), { type: "module" });
worker.onmessage = (event: MessageEvent<FromChunkWorker>) => on_message(event.data);
worker.onerror = (event) => console.error("Chunk worker error:", event.message);
this.#workers.push(worker);
}
}
broadcast(message: ToChunkWorker) {
for (const worker of this.#workers) {
worker.postMessage(message);
}
}
// round robin, results can come back out of order
post(message: ToChunkWorker, transfer: Transferable[] = []) {
const worker = this.#workers[this.#next];
this.#next = (this.#next + 1) % this.#workers.length;
worker.postMessage(message, transfer);
}
terminate() {
for (const worker of this.#workers) {
worker.terminate();
}
this.#workers = [];
}
}
+20 -3
View File
@@ -8,11 +8,17 @@ import { UIRenderSystem } from "$/client/systems/ui_render_system.ts";
import { create_main_menu } from "./main_menu.ts"; import { create_main_menu } from "./main_menu.ts";
import { start_game } from "./game.ts"; import { start_game } from "./game.ts";
import { canvas, resize_canvas } from "./renderer/mod.ts"; import { canvas, resize_canvas } from "./renderer/mod.ts";
import { DimensionLogicSystem } from "./systems/dimension_logic.ts";
import { Dimension } from "./components/dimension.ts"; import { Dimension } from "./components/dimension.ts";
import { GuiRenderSystem, GuiTickSystem } from "./gui/gui_systems.ts"; import { GuiRenderSystem, GuiTickSystem } from "./gui/gui_systems.ts";
import { WorldGenerationSystem } from "./systems/world_generation_system.ts"; import { WorldGenerationSystem } from "./systems/world_generation_system.ts";
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 +26,12 @@ export class ClientWorld extends World {
dimension!: Dimension; dimension!: Dimension;
constructor() { 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,10 +49,10 @@ 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");
this.add_system(new DimensionLogicSystem(), "game");
this.add_system(new CollisionSystem(), "game"); this.add_system(new CollisionSystem(), "game");
this.add_system(new MovementSystem(), "game"); this.add_system(new MovementSystem(), "game");
@@ -53,4 +63,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();
}
}
} }
+304 -113
View File
@@ -1,22 +1,16 @@
import { Component } from "$/common/ecs/mod.ts"; import { Component } from "$/common/ecs/mod.ts";
import { block_value, chunk_key, default_block_value } from "$/common/utils.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 { ChunkWorkerPool } from "../chunk_workers.ts";
import { ItemStack } from "../inventory.ts"; import { worldgen_mods } from "../mods.ts";
import { PlayerComponent } from "../player.ts"; import type { FromChunkWorker } from "../workers/chunk_messages.ts";
import { gl, Texture } from "../renderer/mod.ts"; import { create_vertex_buffer, destroy_vertex_buffer, Texture } from "../renderer/mod.ts";
import { Camera } from "./camera.ts"; import { Camera } from "./camera.ts";
export interface BlockData<T = unknown> {
id: string;
x: number;
y: number;
z: number;
data: T;
}
export interface Block { export interface Block {
id: string; id: string;
x: number; x: number;
@@ -24,53 +18,91 @@ 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;
z: number; z: number;
blocks: Uint32Array; blocks: Uint32Array;
blocks_data: BlockData[];
generated: boolean; generated: boolean;
dirty: boolean; dirty: boolean;
opaque_vertex_buffer?: WebGLBuffer; // bumped on every mesh request so late results from older requests get ignored
mesh_version: number;
opaque_vertex_buffer?: GPUBuffer;
opaque_vertex_count?: number; opaque_vertex_count?: number;
transparent_vertex_buffer?: WebGLBuffer; transparent_vertex_buffer?: GPUBuffer;
transparent_vertex_count?: number; transparent_vertex_count?: number;
} }
export { chunk_key };
const NEIGHBOR_OFFSETS = [[-1, 0], [1, 0], [0, -1], [0, 1]] as const;
export class Dimension extends Component { export class Dimension extends Component {
world: ClientWorld; world: ClientWorld;
image: Texture = AssetManager.instance.get("bworld:textures"); image: Texture = AssetManager.instance.get("bworld:textures");
chunks: Chunk[] = []; chunks = new Map<number, 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>>();
workers: ChunkWorkerPool;
// chunks being generated by a worker, by chunk key
pending_generation = new Map<number, { x: number; z: number }>();
constructor(world: ClientWorld, seed = "seed") {
super(); super();
this.world = world; this.world = world;
this.seed = seed;
this.workers = new ChunkWorkerPool((message) => this.#on_worker_message(message));
const blocks_registry = EverythingRegistry.get_registry<BlockRegistry>("blocks");
const block_ids: Record<string, number> = {};
blocks_registry.forEach((block, nid) => block_ids[block.id] = nid);
// sent once instead of with every mesh request
this.workers.broadcast({
type: "init",
blocks_registry: strip_functions(blocks_registry),
block_ids,
textures_info: AssetManager.instance.get("bworld:textures_info"),
image: { width: this.image.width, height: this.image.height },
worldgen_scripts: worldgen_mods.scripts,
ores: worldgen_mods.ores,
});
} }
add_chunk(x: number, z: number) { dispose() {
const chunk = { this.workers.terminate();
for (const chunk of this.chunks.values()) {
this.delete_chunk_mesh(chunk);
}
this.chunks.clear();
this.pending_generation.clear();
}
add_chunk(x: number, z: number, blocks: Uint32Array = new Uint32Array(CHUNK_AREA * CHUNK_HEIGHT)) {
const chunk: Chunk = {
x, x,
z, z,
blocks: new Uint32Array(CHUNK_AREA * CHUNK_HEIGHT), blocks,
blocks_data: [],
dirty: true, dirty: true,
generated: false, generated: false,
mesh_version: 0,
}; };
this.chunks.push(chunk); this.chunks.set(chunk_key(x, z), chunk);
return chunk; return chunk;
} }
get_chunk(x: number, z: number) { get_chunk(x: number, z: number) {
return this.chunks.find((chunk) => chunk.x === x && chunk.z === z); return this.chunks.get(chunk_key(x, z));
} }
add_block(block: Block) { // state is the block's state bits, its defaults when not given
add_block(block: Block, state?: number) {
const block_chunk_x = Math.floor(block.x / CHUNK_SIZE); const block_chunk_x = Math.floor(block.x / CHUNK_SIZE);
const block_chunk_z = Math.floor(block.z / CHUNK_SIZE); const block_chunk_z = Math.floor(block.z / CHUNK_SIZE);
let chunk = this.get_chunk(block_chunk_x, block_chunk_z); let chunk = this.get_chunk(block_chunk_x, block_chunk_z);
@@ -78,7 +110,7 @@ export class Dimension extends Component {
chunk = this.add_chunk(block_chunk_x, block_chunk_z); chunk = this.add_chunk(block_chunk_x, block_chunk_z);
} }
const [nid, block_info] = EverythingRegistry.get_full<BlockRegistry>("blocks", block.id)!; const [nid, info] = EverythingRegistry.get_full<BlockRegistry>("blocks", block.id)!;
const lx = block.x - block_chunk_x * CHUNK_SIZE; const lx = block.x - block_chunk_x * CHUNK_SIZE;
const lz = block.z - block_chunk_z * CHUNK_SIZE; const lz = block.z - block_chunk_z * CHUNK_SIZE;
@@ -86,11 +118,21 @@ export class Dimension extends Component {
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx; const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
chunk.blocks[index] = nid; chunk.blocks[index] = state === undefined ? default_block_value(nid, info) : block_value(nid, state);
chunk.dirty = true; chunk.dirty = true;
if (block_info?.on_create) { this.#mark_border_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz);
block_info?.on_create(this, block); }
// the id with its state bits, VOID outside loaded chunks
get_block_value(x: number, y: number, z: number) {
const chunk_x = Math.floor(x / CHUNK_SIZE);
const chunk_z = Math.floor(z / CHUNK_SIZE);
const chunk = this.get_chunk(chunk_x, chunk_z);
if (!chunk) {
return VOID;
} }
return chunk.blocks[y * CHUNK_AREA + (z - chunk_z * CHUNK_SIZE) * CHUNK_SIZE + (x - chunk_x * CHUNK_SIZE)] ??
AIR;
} }
get_block(x: number, y: number, z: number) { get_block(x: number, y: number, z: number) {
@@ -112,32 +154,7 @@ export class Dimension extends Component {
return chunk.blocks[index] & ID_MASK; return chunk.blocks[index] & ID_MASK;
} }
add_block_data(block_data: BlockData) { // only changes what this client shows, drops and everything else happen on the server
const block_chunk_x = Math.floor(block_data.x / CHUNK_SIZE);
const block_chunk_z = Math.floor(block_data.z / CHUNK_SIZE);
let chunk = this.get_chunk(block_chunk_x, block_chunk_z);
if (!chunk) {
chunk = this.add_chunk(block_chunk_x, block_chunk_z);
}
chunk.blocks_data.push(block_data);
}
get_block_data<T>(x: number, y: number, z: number) {
const chunk_x = Math.floor(x / CHUNK_SIZE);
const chunk_z = Math.floor(z / CHUNK_SIZE);
const chunk = this.get_chunk(chunk_x, chunk_z);
if (!chunk) {
return undefined;
}
return chunk.blocks_data.find((block_data) =>
block_data.x === x && block_data.y === y && block_data.z === z
) as BlockData<T>;
}
break_block(x: number, y: number, z: number) { break_block(x: number, y: number, z: number) {
const block_chunk_x = Math.floor(x / CHUNK_SIZE); const block_chunk_x = Math.floor(x / CHUNK_SIZE);
const block_chunk_z = Math.floor(z / CHUNK_SIZE); const block_chunk_z = Math.floor(z / CHUNK_SIZE);
@@ -146,14 +163,6 @@ export class Dimension extends Component {
return; return;
} }
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", this.get_block(x, y, z))!;
if (block_info.drop_table) {
const [player] = this.world.get_tag("player")!;
const player_component = player.get(PlayerComponent)!;
player_component.player_inventory.container.add_item(new ItemStack(block_info.drop_table));
}
const lx = x - block_chunk_x * CHUNK_SIZE; const lx = x - block_chunk_x * CHUNK_SIZE;
const lz = z - block_chunk_z * CHUNK_SIZE; const lz = z - block_chunk_z * CHUNK_SIZE;
const ly = y; const ly = y;
@@ -161,7 +170,11 @@ export class Dimension extends Component {
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx; const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
chunk.blocks[index] = AIR; chunk.blocks[index] = AIR;
chunk.dirty = true; chunk.dirty = true;
this.#mark_border_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz);
}
// a block on a chunk's edge changes which faces its neighbor shows
#mark_border_neighbors_dirty(block_chunk_x: number, block_chunk_z: number, lx: number, lz: number) {
if (lx === 0) { if (lx === 0) {
const n = this.get_chunk(block_chunk_x - 1, block_chunk_z); const n = this.get_chunk(block_chunk_x - 1, block_chunk_z);
if (n) { if (n) {
@@ -184,10 +197,6 @@ export class Dimension extends Component {
n.dirty = true; n.dirty = true;
} }
} }
if (block_info?.on_break) {
block_info.on_break(this, { x, y, z, id: block_info.id });
}
} }
index_to_xyz(index: number) { index_to_xyz(index: number) {
@@ -200,46 +209,202 @@ export class Dimension extends Component {
return [x, y, z]; return [x, y, z];
} }
load_chunk(cx: number, cz: number) { record_change(x: number, y: number, z: number, id: string, state = 0) {
generate_chunk(this, cx, cz); const chunk_key = `${Math.floor(x / CHUNK_SIZE)},${Math.floor(z / CHUNK_SIZE)}`;
const chunk = this.get_chunk(cx, cz); let chunk_changes = this.changes.get(chunk_key);
if (chunk) { if (!chunk_changes) {
chunk.generated = true; chunk_changes = new Map();
chunk.dirty = true; this.changes.set(chunk_key, chunk_changes);
}
chunk_changes.set(`${x},${y},${z}`, [x, y, z, id, state]);
}
// set a block the server told us about
apply_change(x: number, y: number, z: number, id: string, state = 0) {
const chunk = this.get_chunk(Math.floor(x / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE));
if (!chunk || !chunk.generated) {
return;
} }
const neighbors = [ const current = this.get_block(x, y, z);
[cx - 1, cz], if (id === AIR_ID) {
[cx + 1, cz], if (current !== AIR) {
[cx, cz - 1], this.break_block(x, y, z);
[cx, cz + 1], }
]; return;
}
for (const [nx, nz] of neighbors) { const nid = EverythingRegistry.get_id("blocks", id);
const neighbor = this.chunks.find((c) => c.x === nx && c.z === nz); if (nid === undefined || block_value(nid, state) === this.get_block_value(x, y, z)) {
return;
}
this.add_block({ x, y, z, id }, state);
}
apply_chunk_changes(cx: number, cz: number) {
for (const [x, y, z, id, state] of this.changes.get(`${cx},${cz}`)?.values() ?? []) {
this.apply_change(x, y, z, id, state);
}
}
is_generated(cx: number, cz: number) {
return this.get_chunk(cx, cz)?.generated ?? false;
}
is_generating(cx: number, cz: number) {
return this.pending_generation.has(chunk_key(cx, cz));
}
// generation happens in a worker, the chunk shows up in #on_generated
request_chunk(cx: number, cz: number) {
const key = chunk_key(cx, cz);
if (this.pending_generation.has(key) || this.chunks.get(key)?.generated) {
return;
}
this.pending_generation.set(key, { x: cx, z: cz });
this.workers.post({ type: "generate", chunk_x: cx, chunk_z: cz, seed: this.seed });
}
cancel_chunk_request(cx: number, cz: number) {
this.pending_generation.delete(chunk_key(cx, cz));
}
unload_chunk(cx: number, cz: number) {
const key = chunk_key(cx, cz);
const chunk = this.chunks.get(key);
if (!chunk) {
return;
}
this.delete_chunk_mesh(chunk);
this.chunks.delete(key);
}
// a chunk is only meshed once all its neighbors exist, otherwise its border faces would be wrong
// and it would have to be meshed again as each neighbor loads
can_mesh(chunk: Chunk) {
if (!chunk.generated) {
return false;
}
for (const [dx, dz] of NEIGHBOR_OFFSETS) {
if (!this.is_generated(chunk.x + dx, chunk.z + dz)) {
return false;
}
}
return true;
}
// sends every dirty chunk that can be meshed to the workers
request_meshes() {
for (const chunk of this.chunks.values()) {
if (!chunk.dirty || !this.can_mesh(chunk)) {
continue;
}
chunk.dirty = false;
chunk.mesh_version += 1;
const padded_chunk = this.create_padded_chunk(chunk);
this.workers.post({
type: "mesh",
chunk_x: chunk.x,
chunk_z: chunk.z,
version: chunk.mesh_version,
padded_chunk,
}, [padded_chunk.buffer]);
}
}
#on_worker_message(message: FromChunkWorker) {
if (message.type === "generated") {
this.#on_generated(message.chunk_x, message.chunk_z, message.blocks, message.spills);
} else {
this.#on_meshed(message);
}
}
#on_generated(cx: number, cz: number, blocks: Uint32Array, spills: Int32Array) {
const key = chunk_key(cx, cz);
// unloaded or cancelled while the worker was busy
if (!this.pending_generation.delete(key)) {
return;
}
let chunk = this.chunks.get(key);
if (chunk) {
// a placeholder made by a neighbor's tree, keep its blocks where generation left air
const existing = chunk.blocks;
for (let i = 0; i < blocks.length; i++) {
if (blocks[i] !== AIR) {
existing[i] = blocks[i];
}
}
} else {
chunk = this.add_chunk(cx, cz, blocks);
}
chunk.generated = true;
chunk.dirty = true;
for (let i = 0; i < spills.length; i += 4) {
this.#set_block_raw(spills[i], spills[i + 1], spills[i + 2], spills[i + 3]);
}
for (const [dx, dz] of NEIGHBOR_OFFSETS) {
const neighbor = this.get_chunk(cx + dx, cz + dz);
if (neighbor && neighbor.generated) { if (neighbor && neighbor.generated) {
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) { #on_meshed(message: Extract<FromChunkWorker, { type: "meshed" }>) {
const chunk_i = this.chunks.findIndex((c) => c.x === cx && c.z === cz); const chunk = this.get_chunk(message.chunk_x, message.chunk_z);
if (chunk_i === -1) { // unloaded, or remeshed again since this was requested
console.warn("Tried unloading a chunk that doesn't exist"); if (!chunk || chunk.mesh_version !== message.version) {
return; return;
} }
this.delete_chunk_mesh(this.chunks[chunk_i]); this.delete_chunk_mesh(chunk);
this.chunks.splice(chunk_i, 1);
chunk.opaque_vertex_buffer = create_vertex_buffer(message.opaque_vertices.subarray(0, message.opaque_count));
chunk.opaque_vertex_count = message.opaque_count / 9;
chunk.transparent_vertex_buffer = create_vertex_buffer(
message.transparent_vertices.subarray(0, message.transparent_count),
);
chunk.transparent_vertex_count = message.transparent_count / 9;
}
// a neighbor's leaves, only fill air so the result doesn't depend on which chunk loaded first.
// the server builds chunks the same way (server/game/world.ts)
#set_block_raw(x: number, y: number, z: number, nid: number) {
if (y < 0 || y >= CHUNK_HEIGHT) {
return;
}
const chunk_x = Math.floor(x / CHUNK_SIZE);
const chunk_z = Math.floor(z / CHUNK_SIZE);
const chunk = this.get_chunk(chunk_x, chunk_z) ?? this.add_chunk(chunk_x, chunk_z);
const lx = x - chunk_x * CHUNK_SIZE;
const lz = z - chunk_z * CHUNK_SIZE;
const index = y * CHUNK_AREA + lz * CHUNK_SIZE + lx;
if (chunk.blocks[index] === AIR) {
chunk.blocks[index] = nid;
chunk.dirty = true;
}
} }
delete_chunk_mesh(chunk: Chunk) { delete_chunk_mesh(chunk: Chunk) {
if (chunk.opaque_vertex_buffer) { if (chunk.opaque_vertex_buffer) {
gl.deleteBuffer(chunk.opaque_vertex_buffer); destroy_vertex_buffer(chunk.opaque_vertex_buffer);
chunk.opaque_vertex_buffer = undefined;
} }
if (chunk.transparent_vertex_buffer) { if (chunk.transparent_vertex_buffer) {
gl.deleteBuffer(chunk.transparent_vertex_buffer); destroy_vertex_buffer(chunk.transparent_vertex_buffer);
chunk.transparent_vertex_buffer = undefined;
} }
} }
@@ -312,31 +477,36 @@ export class Dimension extends Component {
} }
create_padded_chunk(chunk: Chunk) { create_padded_chunk(chunk: Chunk) {
const cx = chunk.x;
const cz = chunk.z;
const size = CHUNK_SIZE + 2; const size = CHUNK_SIZE + 2;
const padded = new Uint32Array(size * size * CHUNK_HEIGHT); const layer = size * size;
const padded = new Uint32Array(layer * CHUNK_HEIGHT);
for (let y = 0; y < CHUNK_HEIGHT; y++) { // look the 3x3 chunks up once instead of once per border block
for (let z = -1; z <= CHUNK_SIZE; z++) { const around: (Uint32Array | undefined)[] = [];
for (let x = -1; x <= CHUNK_SIZE; x++) { for (let dz = -1; dz <= 1; dz++) {
let block: number; for (let dx = -1; dx <= 1; dx++) {
around.push(this.get_chunk(chunk.x + dx, chunk.z + dz)?.blocks);
}
}
if (x >= 0 && x < CHUNK_SIZE && z >= 0 && z < CHUNK_SIZE) { for (let z = -1; z <= CHUNK_SIZE; z++) {
const index = y * CHUNK_SIZE * CHUNK_SIZE + z * CHUNK_SIZE + x; const dz = z < 0 ? -1 : z >= CHUNK_SIZE ? 1 : 0;
block = chunk.blocks[index] & ID_MASK; const lz = z - dz * CHUNK_SIZE;
} else { for (let x = -1; x <= CHUNK_SIZE; x++) {
const wx = cx * CHUNK_SIZE + x; const dx = x < 0 ? -1 : x >= CHUNK_SIZE ? 1 : 0;
const wz = cz * CHUNK_SIZE + z; const lx = x - dx * CHUNK_SIZE;
block = this.get_block(wx, y, wz) ?? AIR; const source = around[(dz + 1) * 3 + (dx + 1)];
const source_index = lz * CHUNK_SIZE + lx;
const padded_index = (z + 1) * size + (x + 1);
if (!source) {
for (let y = 0; y < CHUNK_HEIGHT; y++) {
padded[y * layer + padded_index] = VOID;
} }
continue;
const px = x + 1; }
const pz = z + 1; for (let y = 0; y < CHUNK_HEIGHT; y++) {
const pindex = y * size * size + pz * size + px; padded[y * layer + padded_index] = source[y * CHUNK_AREA + source_index] & ID_MASK;
padded[pindex] = block;
} }
} }
} }
@@ -344,3 +514,24 @@ export class Dimension extends Component {
return padded; return padded;
} }
} }
// functions cant be sent to workers
// deno-lint-ignore no-explicit-any
function strip_functions<T extends Record<string, any>>(obj: T): T {
// deno-lint-ignore no-explicit-any
const out: any = Array.isArray(obj) ? [] : {};
for (const k in obj) {
const v = obj[k];
if (typeof v === "function") continue;
if (v && typeof v === "object") {
out[k] = strip_functions(v);
} else {
out[k] = v;
}
}
return out;
}
+50
View File
@@ -0,0 +1,50 @@
import type { ServerAddress, Welcome } from "./handshake.ts";
// asks before running a server's mods when the page came from somewhere else, see "Security" in MODS.md.
// resolves with whether the player wants to join
export function confirm_mods(address: ServerAddress, welcome: Welcome): Promise<boolean> {
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText =
"position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:16px;" +
"background:rgba(0,0,0,0.85);color:white;font:16px system-ui,sans-serif;";
const panel = document.createElement("div");
panel.style.cssText = "max-width:480px;display:flex;flex-direction:column;gap:12px;";
const title = document.createElement("div");
title.style.cssText = "font-size:20px;";
title.textContent = `Join ${address.base.host}?`;
const warning = document.createElement("div");
warning.style.cssText = "opacity:0.8;";
warning.textContent = "This server runs these mods in your browser. They can do anything this page can, " +
"including reading what it saved. Only join servers you trust.";
const list = document.createElement("ul");
list.style.cssText = "margin:0;padding-left:20px;";
for (const mod of welcome.mods) {
const item = document.createElement("li");
item.textContent = `${mod.name} ${mod.version} (${mod.id})`;
list.append(item);
}
const buttons = document.createElement("div");
buttons.style.cssText = "display:flex;gap:8px;justify-content:flex-end;";
const button = (label: string, answer: boolean) => {
const element = document.createElement("button");
element.textContent = label;
element.style.cssText = "font:inherit;padding:6px 16px;cursor:pointer;";
element.addEventListener("click", () => {
overlay.remove();
resolve(answer);
});
return element;
};
buttons.append(button("Cancel", false), button("Join", true));
panel.append(title, warning, list, buttons);
overlay.append(panel);
document.body.append(overlay);
});
}
+26
View File
@@ -0,0 +1,26 @@
let stopped = false;
// replaces the game with a message, for when it can't go on (no server, lost connection)
export function show_fatal_error(message: string) {
if (stopped) {
return;
}
stopped = true;
document.exitPointerLock?.();
const overlay = document.createElement("div");
overlay.style.cssText =
"position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;" +
"gap:12px;background:rgba(0,0,0,0.85);color:white;font:20px system-ui,sans-serif;text-align:center;";
const text = document.createElement("div");
text.textContent = message;
const hint = document.createElement("div");
hint.textContent = "Reload the page to try again.";
hint.style.cssText = "font-size:14px;opacity:0.7;";
overlay.append(text, hint);
document.body.append(overlay);
}
export function is_stopped() {
return stopped;
}
+5 -1
View File
@@ -12,7 +12,11 @@ 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?.dispose();
world.dimension = new Dimension(world, world.connection.seed);
for (const [x, y, z, id, state] of world.connection.initial_changes) {
world.dimension.record_change(x, y, z, id, state);
}
dimension.add(world.dimension); dimension.add(world.dimension);
world.add_entity(dimension); world.add_entity(dimension);
-242
View File
@@ -1,242 +0,0 @@
import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D } from "@paulaboks/rng";
import { CHUNK_SIZE, Dimension } from "./components/dimension.ts";
type Biome =
| "desert"
| "plains"
| "forest"
| "jungle"
| "tundra"
| "taiga"
| "snow"
| "savanna"
| "swamp";
type OreDef = {
id: string;
min_y: number;
max_y: number;
scale: number;
threshold: number;
};
const ORES: OreDef[] = [
{ id: "bworld:coal_ore", min_y: 20, max_y: 120, scale: 0.05, threshold: 0.55 },
{ id: "bworld:copper_ore", min_y: 10, max_y: 80, scale: 0.06, threshold: 0.6 },
{ id: "bworld:tin_ore", min_y: 5, max_y: 60, scale: 0.06, threshold: 0.62 },
{ id: "bworld:iron_ore", min_y: 5, max_y: 50, scale: 0.05, threshold: 0.65 },
{ id: "bworld:gold_ore", min_y: 0, max_y: 30, scale: 0.04, threshold: 0.7 },
];
function get_biome(temp: number, moisture: number): Biome {
if (temp > 0.6) {
if (moisture < -0.2) {
return "desert";
}
if (moisture > 0.4) {
return "jungle";
}
return "savanna";
}
if (temp > 0) {
if (moisture > 0.5) {
return "swamp";
}
if (moisture > 0) {
return "forest";
}
return "plains";
}
if (temp > -0.5) {
return "taiga";
}
return "tundra";
}
function get_surface_block(biome: Biome) {
if (biome === "desert") {
return "bworld:sand";
} else if (biome === "tundra") {
return "bworld:snow";
}
return "bworld:grass";
}
function biome_height_modifier(biome: Biome) {
if (biome === "desert") {
return 0.2;
}
if (biome === "plains") {
return 0.4;
}
if (biome === "forest") {
return 0.5;
}
if (biome === "jungle") {
return 0.45;
}
if (biome === "taiga") {
return 0.55;
}
if (biome === "tundra") {
return 0.35;
}
if (biome === "savanna") {
return 0.4;
}
if (biome === "swamp") {
return 0.35;
}
return 0.4;
}
function fractal_noise(noise: NoiseFunction2D, x: number, y: number, octaves = 2) {
let value = 0;
let amp = 1;
let freq = 1;
let max = 0;
for (let i = 0; i < octaves; i++) {
value += noise(x * freq, y * freq) * amp;
max += amp;
amp *= 0.5;
freq *= 2;
}
return value / max;
}
function get_terrain_height(base: number, biome: Biome, x: number, z: number, noise: NoiseFunction2D) {
const biomeMod = biome_height_modifier(biome);
const main = fractal_noise(noise, x * 0.003, z * 0.003) * 15;
const detail = fractal_noise(noise, x * 0.01, z * 0.01) * 3;
return Math.floor(base + biomeMod * 20 + main + detail);
}
function can_place_tree(tree_map: boolean[][], local_x: number, local_z: number) {
const TREE_SPACING = 4;
for (let dx = -TREE_SPACING; dx <= TREE_SPACING; dx++) {
for (let dz = -TREE_SPACING; dz <= TREE_SPACING; dz++) {
const nx = local_x + dx;
const nz = local_z + dz;
if (nx >= 0 && nx < CHUNK_SIZE && nz >= 0 && nz < CHUNK_SIZE && tree_map[nx][nz]) {
return false;
}
}
}
return true;
}
function place_tree(dimension: Dimension, x: number, y: number, z: number, biome: Biome) {
const height = Math.floor(Math.random() * 3) + (biome === "jungle" ? 8 : 4);
const trunk_block = "bworld:log";
const leaves_block = "bworld:leaves";
for (let i = 0; i < height; i++) {
dimension.add_block({ x, y: y + i, z, id: trunk_block });
}
for (let dx = -2; dx <= 2; dx++) {
for (let dz = -2; dz <= 2; dz++) {
for (let dy = -1; dy <= 1; dy++) {
if (Math.abs(dx) + Math.abs(dz) + Math.abs(dy) <= 3) {
dimension.add_block({
x: x + dx,
y: y + height + dy,
z: z + dz,
id: leaves_block,
});
}
}
}
}
}
const TREE_THRESHOLD: Record<Biome, number> = {
forest: 0.5,
jungle: 0.3,
taiga: 0.6,
plains: 0.95,
desert: 1,
tundra: 1,
savanna: 0.65,
swamp: 0.5,
snow: 0.8,
};
function should_place_tree(feature_noise: NoiseFunction2D, biome: Biome, x: number, z: number) {
const n = feature_noise(x * 0.1, z * 0.1);
return n > (TREE_THRESHOLD[biome] ?? 0.8);
}
export function generate_chunk(dimension: Dimension, cx: number, cz: number, seed = "seed") {
const height_noise = create_noise_2d(new Alea(seed + "_height"));
const temp_noise = create_noise_2d(new Alea(seed + "_temp"));
const moisture_noise = create_noise_2d(new Alea(seed + "_moisture"));
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 biome_scale = 0.003;
const terrain_scale = 0.01;
const tree_map: boolean[][] = Array.from({ length: CHUNK_SIZE }, () => Array(CHUNK_SIZE).fill(false));
for (let x = 0; x < CHUNK_SIZE; x++) {
for (let z = 0; z < CHUNK_SIZE; z++) {
const wx = cx * CHUNK_SIZE + x;
const wz = cz * CHUNK_SIZE + z;
const temp = temp_noise(wx * biome_scale, wz * biome_scale);
const moisture = moisture_noise(wx * biome_scale, wz * biome_scale);
const biome = get_biome(temp, moisture);
const height_noise_value = fractal_noise(height_noise, wx * terrain_scale, wz * terrain_scale);
const base_height = (height_noise_value + 1) * 15 + 50;
const height = get_terrain_height(base_height, biome, wx, wz, height_noise);
const surface_block = get_surface_block(biome);
for (let y = 0; y <= height; y++) {
let block = "bworld:stone";
if (y < height - 3) {
for (let i = 0; i < ORES.length; i++) {
const ore = ORES[i];
if (y >= ore.min_y && y <= ore.max_y) {
const noise = ore_noises[i](
wx * ore.scale,
y * ore.scale,
wz * ore.scale,
);
if (noise > ore.threshold) {
block = ore.id;
break;
}
}
}
}
if (y === height) {
block = surface_block;
} else if (y > height - 4) {
block = "bworld:dirt";
}
if (biome === "swamp" && y === height && Math.random() < 0.2) {
block = "bworld:water";
}
dimension.add_block({ x: wx, y, z: wz, id: block });
}
if (should_place_tree(feature_noise, biome, wx, wz) && can_place_tree(tree_map, x, z)) {
place_tree(dimension, wx, height + 1, wz, biome);
tree_map[x][z] = true;
}
}
}
}
+9 -24
View File
@@ -3,7 +3,8 @@ import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mo
import { InputManager } from "../input_manager.ts"; import { InputManager } from "../input_manager.ts";
import { ClientWorld } from "../client_world.ts"; import { ClientWorld } from "../client_world.ts";
import { PlayerComponent } from "../player.ts"; import { PlayerComponent } from "../player.ts";
import { ItemStack } from "../inventory.ts"; import { MAX_CHAT_LENGTH } from "$/common/protocol.ts";
import { render_chat_log } from "../systems/rendering/network.ts";
export class GuiChat extends GuiScreen { export class GuiChat extends GuiScreen {
world: ClientWorld; world: ClientWorld;
@@ -22,6 +23,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 +87,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;
} }
@@ -96,10 +101,9 @@ export class GuiChat extends GuiScreen {
override on_close(): void {} override on_close(): void {}
submit() { submit() {
if (this.text_typed.startsWith("/")) { // commands like /give run on the server too
this.command(); if (this.text_typed.trim().length > 0) {
} else { this.world.connection.send({ type: "chat", text: this.text_typed });
// we dont have multiplayer lol?
} }
this.text_typed = ""; this.text_typed = "";
@@ -109,23 +113,4 @@ export class GuiChat extends GuiScreen {
player_component.pop_screen(); player_component.pop_screen();
} }
} }
command() {
if (this.text_typed.startsWith("/give")) {
let [_, item_id, quantity] = this.text_typed.split(" ");
if (!item_id.includes(":")) {
item_id = `bworld:${item_id}`;
}
if (quantity === undefined) {
quantity = "1";
}
const [player] = this.world.get_tag("player")!;
const player_component = player.get(PlayerComponent);
if (player_component) {
player_component.player_inventory.container.add_item(new ItemStack(item_id, Number(quantity)));
}
}
}
} }
-60
View File
@@ -1,60 +0,0 @@
import { Inventory, PlayerInventory } from "../inventory.ts";
import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
import { canvas, draw_rect, Texture } from "$/client/renderer/mod.ts";
import { AssetManager } from "../assets.ts";
import { SLOT_SIZE } from "../../common/constants.ts";
import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
const PADDING = 10;
export class GuiChest extends GuiInventoryScreen {
override inventory_width = PADDING * 2 + SLOT_SIZE * 9;
override inventory_height = PADDING * 4 + SLOT_SIZE * 7;
constructor(inventory: Inventory, player_inventory: PlayerInventory) {
super(inventory, player_inventory, undefined);
add_player_hotbar(this, player_inventory, PADDING, PADDING);
add_player_inventory(this, player_inventory, PADDING, PADDING * 2 + SLOT_SIZE);
const chest_x = PADDING;
const chest_y = PADDING * 3 + SLOT_SIZE * 4;
for (let i = 0; i < 3; ++i) {
for (let l = 0; l < 9; ++l) {
this.slots.push(
new Slot(
this.inventory,
l + i * 9,
chest_x + l * SLOT_SIZE,
chest_y + i * SLOT_SIZE,
),
);
}
}
}
override on_render(): void {
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
const ui = AssetManager.instance.get<Texture>("bworld:ui");
draw_nine_slice(
ui,
160,
0,
16,
16,
4,
4,
4,
4,
this.x,
this.y,
this.inventory_width,
this.inventory_height,
);
super.on_render();
}
}
+119
View File
@@ -0,0 +1,119 @@
import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
import { canvas, draw_rect, draw_texture_region, Texture } from "$/client/renderer/mod.ts";
import { AssetManager } from "../assets.ts";
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
import { ClientMessage, ScreenLayout } from "$/common/protocol.ts";
import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
import { get_sprite_region } from "$/client/sprites.ts";
import { ClientInventories } from "../inventory.ts";
const PADDING = 10;
// a screen opened by the server (chest, furnace, ...), drawn from the layout it sent
export class GuiContainer extends GuiInventoryScreen {
layout: ScreenLayout;
properties: Record<string, number>;
constructor(
inventories: ClientInventories,
send: (message: ClientMessage) => void,
layout: ScreenLayout,
properties: Record<string, number>,
) {
super(inventories, send);
this.layout = layout;
this.properties = properties;
this.inventory_width = PADDING * 2 + SLOT_SIZE * 9;
this.inventory_height = PADDING * 4 + SLOT_SIZE * (4 + layout.rows);
add_player_hotbar(this, PADDING, PADDING);
add_player_inventory(this, PADDING, PADDING * 2 + SLOT_SIZE);
const area_y = PADDING * 3 + SLOT_SIZE * 4;
for (const slot of layout.slots) {
this.slots.push(
new Slot("screen", slot.index, PADDING + slot.x * SLOT_SIZE, area_y + slot.y * SLOT_SIZE, slot.output),
);
}
}
override on_render(): void {
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
const ui = AssetManager.instance.get<Texture>("bworld:ui");
draw_nine_slice(
ui,
160,
0,
16,
16,
4,
4,
4,
4,
this.x,
this.y,
this.inventory_width,
this.inventory_height,
);
this.draw_bars();
super.on_render();
}
draw_bars() {
const atlas = AssetManager.instance.get<Texture>("bworld:textures");
const area_y = PADDING * 3 + SLOT_SIZE * 4;
for (const bar of this.layout.bars) {
const x = this.x + PADDING + bar.x * SLOT_SIZE;
const y = this.y + area_y + bar.y * SLOT_SIZE;
const max = this.properties[bar.max] ?? 0;
const pct = max > 0 ? Math.max(0, Math.min(1, (this.properties[bar.value] ?? 0) / max)) : 0;
const empty = get_sprite_region(bar.empty_texture);
draw_texture_region(
atlas,
empty.x * TEXTURE_SIZE,
empty.y * TEXTURE_SIZE,
TEXTURE_SIZE,
TEXTURE_SIZE,
x,
y,
SLOT_SIZE,
SLOT_SIZE,
);
const full = get_sprite_region(bar.full_texture);
if (bar.direction === "right") {
draw_texture_region(
atlas,
full.x * TEXTURE_SIZE,
full.y * TEXTURE_SIZE,
TEXTURE_SIZE * pct,
TEXTURE_SIZE,
x,
y,
SLOT_SIZE * pct,
SLOT_SIZE,
);
} else {
// fills from the bottom up
draw_texture_region(
atlas,
full.x * TEXTURE_SIZE,
full.y * TEXTURE_SIZE + TEXTURE_SIZE * (1 - pct),
TEXTURE_SIZE,
TEXTURE_SIZE * pct,
x,
y + SLOT_SIZE * (1 - pct),
SLOT_SIZE,
SLOT_SIZE * pct,
);
}
}
}
}
-211
View File
@@ -1,211 +0,0 @@
import { Inventory, PlayerInventory } from "../inventory.ts";
import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
import { canvas, draw_rect, draw_texture_region, Texture } from "$/client/renderer/mod.ts";
import { AssetManager } from "../assets.ts";
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
import { get_sprite_region } from "$/common/utils.ts";
const PADDING = 10;
// crazy this is how i found out interface X {} is diffrent then type X = {}
type TileChestData = {
inventory: Inventory;
progress: number;
progress_max: number;
fuel: number;
fuel_max: number;
};
export class GuiFurnace extends GuiInventoryScreen<Inventory, TileChestData> {
override inventory_width = PADDING * 2 + SLOT_SIZE * 9;
override inventory_height = PADDING * 4 + SLOT_SIZE * 7;
constructor(
inventory: Inventory,
player_inventory: PlayerInventory,
properties: TileChestData,
) {
super(inventory, player_inventory, properties);
add_player_hotbar(this, player_inventory, PADDING, PADDING);
add_player_inventory(this, player_inventory, PADDING, PADDING * 2 + SLOT_SIZE);
const furnace_y = PADDING * 3 + SLOT_SIZE * 4;
this.slots.push(
new Slot(
this.inventory,
0,
this.inventory_width / 2 - SLOT_SIZE,
furnace_y,
),
);
this.slots.push(
new Slot(
this.inventory,
1,
this.inventory_width / 2 - SLOT_SIZE,
furnace_y + SLOT_SIZE * 2,
),
);
this.slots.push(
new Slot(
this.inventory,
2,
this.inventory_width / 2 + SLOT_SIZE,
furnace_y + SLOT_SIZE,
),
);
}
override on_render(): void {
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
const ui = AssetManager.instance.get<Texture>("bworld:ui");
draw_nine_slice(
ui,
160,
0,
16,
16,
4,
4,
4,
4,
this.x,
this.y,
this.inventory_width,
this.inventory_height,
);
this.draw_fire();
this.draw_arrow();
super.on_render();
}
override handle_left_click(slot: Slot): void {
if (slot.inventory === this.inventory && slot.index === 2) {
if (this.inventory.container.get_item(2)) {
this.pickup();
}
} else {
super.handle_left_click(slot);
}
}
override handle_right_click(slot: Slot): void {
if (slot.inventory === this.inventory && slot.index === 2) {
if (this.inventory.container.get_item(2)) {
this.pickup();
}
} else {
super.handle_right_click(slot);
}
}
pickup() {
const holding_item = this.player_inventory.holding_item;
const item = this.inventory.container.get_item(2)!;
if (holding_item) {
if (holding_item.type_id === item.type_id) {
const items_left = holding_item.max_amount - holding_item.amount;
if (items_left >= item.amount) {
holding_item.amount += item.amount;
this.inventory.container.set_item(2, undefined);
}
}
} else {
this.player_inventory.holding_item = item;
this.inventory.container.set_item(2, undefined);
}
}
draw_fire() {
const atlas = AssetManager.instance.get<Texture>("bworld:textures");
const fire_empty_region = get_sprite_region("bworld:fire_empty");
const fire_full_region = get_sprite_region("bworld:fire_full");
const fuel_pct = Math.max(0, Math.min(1, this.properties.fuel / this.properties.fuel_max));
const full_height = SLOT_SIZE * fuel_pct;
const furnace_y = PADDING * 3 + SLOT_SIZE * 4;
draw_texture_region(
atlas,
fire_empty_region.x * TEXTURE_SIZE,
fire_empty_region.y * TEXTURE_SIZE,
TEXTURE_SIZE,
TEXTURE_SIZE,
this.x + this.inventory_width / 2 - SLOT_SIZE,
this.y + furnace_y + SLOT_SIZE,
SLOT_SIZE,
SLOT_SIZE,
);
const fire_draw_x = this.x + this.inventory_width / 2 - SLOT_SIZE;
const fire_draw_y = this.y + furnace_y + SLOT_SIZE;
const fire_src_height = TEXTURE_SIZE * fuel_pct;
const fire_src_y_offset = TEXTURE_SIZE - fire_src_height;
const fire_dst_y_offset = SLOT_SIZE - full_height;
draw_texture_region(
atlas,
fire_full_region.x * TEXTURE_SIZE,
fire_full_region.y * TEXTURE_SIZE + fire_src_y_offset,
TEXTURE_SIZE,
fire_src_height,
fire_draw_x,
fire_draw_y + fire_dst_y_offset,
SLOT_SIZE,
full_height,
);
}
draw_arrow() {
const atlas = AssetManager.instance.get<Texture>("bworld:textures");
const arrow_empty_region = get_sprite_region("bworld:arrow_empty");
const arrow_full_region = get_sprite_region("bworld:arrow_full");
const progress_pct = Math.max(0, Math.min(1, this.properties.progress / this.properties.progress_max));
const full_width = SLOT_SIZE * progress_pct;
const furnace_y = PADDING * 3 + SLOT_SIZE * 4;
draw_texture_region(
atlas,
arrow_empty_region.x * TEXTURE_SIZE,
arrow_empty_region.y * TEXTURE_SIZE,
TEXTURE_SIZE,
TEXTURE_SIZE,
this.x + this.inventory_width / 2,
this.y + furnace_y + SLOT_SIZE,
SLOT_SIZE,
SLOT_SIZE,
);
const draw_x = this.x + this.inventory_width / 2;
const draw_y = this.y + furnace_y + SLOT_SIZE;
const src_width = TEXTURE_SIZE * progress_pct;
draw_texture_region(
atlas,
arrow_full_region.x * TEXTURE_SIZE,
arrow_full_region.y * TEXTURE_SIZE,
src_width,
TEXTURE_SIZE,
draw_x,
draw_y,
full_width,
SLOT_SIZE,
);
}
}
+11 -219
View File
@@ -1,96 +1,23 @@
import { Container, Inventory, ItemStack, PlayerInventory } from "../inventory.ts";
import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts"; import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
import { canvas, draw_rect, Texture } from "$/client/renderer/mod.ts"; import { canvas, draw_rect, Texture } from "$/client/renderer/mod.ts";
import { AssetManager } from "../assets.ts"; import { AssetManager } from "../assets.ts";
import { SLOT_SIZE } from "../../common/constants.ts"; import { SLOT_SIZE } from "../../common/constants.ts";
import { draw_nine_slice } from "../systems/rendering/render_utils.ts"; import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
import { ClientInventories } from "../inventory.ts";
export interface CraftingRecipe { import { ClientMessage, CRAFTING_RESULT_SLOT } from "$/common/protocol.ts";
width: number;
height: number;
pattern: (string | undefined)[];
result: ItemStack;
}
const recipes: CraftingRecipe[] = [
{
width: 3,
height: 3,
pattern: [
"bworld:planks",
"bworld:planks",
"bworld:planks",
"bworld:planks",
undefined,
"bworld:planks",
"bworld:planks",
"bworld:planks",
"bworld:planks",
],
result: new ItemStack("bworld:chest", 1),
},
{
width: 3,
height: 3,
pattern: [
"bworld:stone",
"bworld:stone",
"bworld:stone",
"bworld:stone",
undefined,
"bworld:stone",
"bworld:stone",
"bworld:stone",
"bworld:stone",
],
result: new ItemStack("bworld:furnace", 1),
},
{
width: 1,
height: 1,
pattern: [
"bworld:log",
],
result: new ItemStack("bworld:planks", 2),
},
{
width: 1,
height: 2,
pattern: [
"bworld:planks",
"bworld:planks",
],
result: new ItemStack("bworld:stick", 2),
},
{
width: 3,
height: 3,
pattern: [
"bworld:planks",
"bworld:planks",
"bworld:planks",
undefined,
"bworld:stick",
undefined,
undefined,
"bworld:stick",
undefined,
],
result: new ItemStack("bworld:wood_pickaxe", 1),
},
];
const PADDING = 10; const PADDING = 10;
// the recipes live on the server, which fills in the result slot
export class GuiPlayerInventory extends GuiInventoryScreen { export class GuiPlayerInventory extends GuiInventoryScreen {
override inventory_width = PADDING * 2 + SLOT_SIZE * 9; override inventory_width = PADDING * 2 + SLOT_SIZE * 9;
override inventory_height = PADDING * 2 + SLOT_SIZE * 4 + PADDING + SLOT_SIZE * 3 + PADDING; override inventory_height = PADDING * 2 + SLOT_SIZE * 4 + PADDING + SLOT_SIZE * 3 + PADDING;
constructor(player_inventory: PlayerInventory) { constructor(inventories: ClientInventories, send: (message: ClientMessage) => void) {
super(new Inventory(new Container(10)), player_inventory, undefined); super(inventories, send);
add_player_hotbar(this, player_inventory, PADDING, PADDING); add_player_hotbar(this, PADDING, PADDING);
add_player_inventory(this, player_inventory, PADDING, PADDING * 2 + SLOT_SIZE); add_player_inventory(this, PADDING, PADDING * 2 + SLOT_SIZE);
const crafting_x = PADDING; const crafting_x = PADDING;
const crafting_y = PADDING * 3 + SLOT_SIZE * 4; const crafting_y = PADDING * 3 + SLOT_SIZE * 4;
@@ -98,17 +25,14 @@ export class GuiPlayerInventory extends GuiInventoryScreen {
for (let i = 0; i < 3; i += 1) { for (let i = 0; i < 3; i += 1) {
for (let j = 0; j < 3; j += 1) { for (let j = 0; j < 3; j += 1) {
this.slots.push( this.slots.push(
new Slot(this.inventory, j * 3 + i, crafting_x + i * SLOT_SIZE, crafting_y + j * SLOT_SIZE), new Slot("crafting", j * 3 + i, crafting_x + i * SLOT_SIZE, crafting_y + j * SLOT_SIZE),
); );
} }
} }
this.slots.push(new Slot(this.inventory, 9, crafting_x + 5 * SLOT_SIZE, crafting_y + 1 * SLOT_SIZE)); this.slots.push(
} new Slot("crafting", CRAFTING_RESULT_SLOT, crafting_x + 5 * SLOT_SIZE, crafting_y + 1 * SLOT_SIZE, true),
);
override on_tick(delta: number): void {
super.on_tick(delta);
this.update_crafting_result();
} }
override on_render(): void { override on_render(): void {
@@ -134,136 +58,4 @@ export class GuiPlayerInventory extends GuiInventoryScreen {
super.on_render(); super.on_render();
} }
override on_close(): void {
for (let i = 0; i < 8; i += 1) {
const item = this.inventory.container.get_item(i);
if (item) {
this.player_inventory.container.add_item(item);
}
}
}
get_crafting_grid(): (string | undefined)[] {
const grid: (string | undefined)[] = [];
for (let i = 0; i < 9; i++) {
const item = this.inventory.container.get_item(i);
grid.push(item?.type_id);
}
return grid;
}
matches_recipe(
grid: (string | undefined)[],
recipe: CraftingRecipe,
): boolean {
for (let y = 0; y <= 3 - recipe.height; y++) {
for (let x = 0; x <= 3 - recipe.width; x++) {
let match = true;
for (let gy = 0; gy < 3; gy++) {
for (let gx = 0; gx < 3; gx++) {
const grid_index = gy * 3 + gx;
if (
gx >= x &&
gx < x + recipe.width &&
gy >= y &&
gy < y + recipe.height
) {
const rx = gx - x;
const ry = gy - y;
const recipe_index = ry * recipe.width + rx;
if (grid[grid_index] !== recipe.pattern[recipe_index]) {
match = false;
break;
}
} else {
if (grid[grid_index] !== undefined) {
match = false;
break;
}
}
}
if (!match) break;
}
if (match) return true;
}
}
return false;
}
update_crafting_result() {
const grid = this.get_crafting_grid();
for (const recipe of recipes) {
if (this.matches_recipe(grid, recipe)) {
this.inventory.container.set_item(
9,
recipe.result.clone(),
);
return;
}
}
this.inventory.container.set_item(9, undefined);
}
consume_recipe_items() {
for (let i = 0; i < 9; i++) {
const item = this.inventory.container.get_item(i);
if (!item) {
continue;
}
item.amount -= 1;
if (item.amount <= 0) {
this.inventory.container.set_item(i, undefined);
}
}
}
override handle_left_click(slot: Slot): void {
if (slot.inventory === this.inventory && slot.index === 9) {
if (this.inventory.container.get_item(9)) {
this.craft();
}
} else {
super.handle_left_click(slot);
}
}
override handle_right_click(slot: Slot): void {
if (slot.inventory === this.inventory && slot.index === 9) {
if (this.inventory.container.get_item(9)) {
this.craft();
}
} else {
super.handle_right_click(slot);
}
}
craft() {
const holding_item = this.player_inventory.holding_item;
const item = this.inventory.container.get_item(9)!;
if (holding_item) {
if (holding_item.type_id === item.type_id) {
const items_left = holding_item.max_amount - holding_item.amount;
if (items_left >= item.amount) {
this.consume_recipe_items();
holding_item.amount += item.amount;
}
}
} else {
this.player_inventory.holding_item = item;
this.consume_recipe_items();
}
}
} }
+75 -185
View File
@@ -1,27 +1,28 @@
import { SLOT_SIZE } from "$/common/constants.ts"; import { SLOT_SIZE } from "$/common/constants.ts";
import { click_slot, Container } from "$/common/inventory.ts";
import { ClientMessage, ContainerKey, CRAFTING_RESULT_SLOT } from "$/common/protocol.ts";
import { point_inside_rec } from "../../common/utils.ts"; import { point_inside_rec } from "../../common/utils.ts";
import { AssetManager } from "../assets.ts"; import { AssetManager } from "../assets.ts";
import { InputManager } from "../input_manager.ts"; import { InputManager } from "../input_manager.ts";
import { Inventory, ItemStack, PlayerInventory } from "../inventory.ts"; import { ClientInventories } from "../inventory.ts";
import { canvas, Texture } from "../renderer/mod.ts"; import { canvas, Texture } from "../renderer/mod.ts";
import { draw_item, draw_nine_slice } from "../systems/rendering/render_utils.ts"; import { draw_item, draw_nine_slice } from "../systems/rendering/render_utils.ts";
export class Slot { export class Slot {
inventory: Inventory; container: ContainerKey;
index: number; index: number;
// relative to screen top left // relative to screen top left
x: number; x: number;
y: number; y: number;
// can only be taken from, like the crafting or furnace result
output: boolean;
constructor(inventory: Inventory, index: number, x: number, y: number) { constructor(container: ContainerKey, index: number, x: number, y: number, output = false) {
this.inventory = inventory; this.container = container;
this.index = index; this.index = index;
this.x = x; this.x = x;
this.y = y; this.y = y;
} this.output = output;
get item(): ItemStack | undefined {
return this.inventory.container.get_item(this.index);
} }
} }
@@ -34,25 +35,32 @@ export abstract class GuiScreen {
abstract on_close(): void; abstract on_close(): void;
} }
export class GuiInventoryScreen<Inv extends Inventory = Inventory, Properties = Record<string, unknown> | undefined> // a screen of slots. the server owns the contents: clicks are sent to it and guessed locally
extends GuiScreen { // so they feel instant, then whatever the server syncs back wins
inventory: Inv; export class GuiInventoryScreen extends GuiScreen {
player_inventory: PlayerInventory; inventories: ClientInventories;
properties: Properties; send: (message: ClientMessage) => void;
slots: Slot[] = []; slots: Slot[] = [];
hovering: Slot | undefined;
inventory_width: number = 500; inventory_width: number = 500;
inventory_height: number = 500; inventory_height: number = 500;
constructor( constructor(inventories: ClientInventories, send: (message: ClientMessage) => void) {
inventory: Inv,
player_inventory: PlayerInventory,
properties: Properties,
) {
super(); super();
this.inventory = inventory; this.inventories = inventories;
this.player_inventory = player_inventory; this.send = send;
this.properties = properties; }
get_container(key: ContainerKey): Container | undefined {
switch (key) {
case "inventory":
return this.inventories.inventory;
case "crafting":
return this.inventories.crafting;
case "screen":
return this.inventories.screen;
}
} }
on_tick(_delta: number): void { on_tick(_delta: number): void {
@@ -60,20 +68,17 @@ export class GuiInventoryScreen<Inv extends Inventory = Inventory, Properties =
this.y = canvas.height / 2 - (this.inventory_height / 2); this.y = canvas.height / 2 - (this.inventory_height / 2);
this.handle_interaction(); this.handle_interaction();
if (this.player_inventory.holding_item?.amount === 0) {
this.player_inventory.holding_item = undefined;
}
} }
on_render(): void { on_render(): void {
const ui = AssetManager.instance.get<Texture>("bworld:ui"); const ui = AssetManager.instance.get<Texture>("bworld:ui");
for (const slot of this.slots) { for (const slot of this.slots) {
const hovered = slot === this.hovering;
draw_nine_slice( draw_nine_slice(
ui, ui,
slot.inventory.hovering_slot === slot.index ? 19 * 16 : 160 + 32, hovered ? 19 * 16 : 160 + 32,
slot.inventory.hovering_slot === slot.index ? 16 : 0, hovered ? 16 : 0,
16, 16,
16, 16,
4, 4,
@@ -88,198 +93,83 @@ export class GuiInventoryScreen<Inv extends Inventory = Inventory, Properties =
} }
for (const slot of this.slots) { for (const slot of this.slots) {
const item = slot.item; const item = this.get_container(slot.container)?.get_item(slot.index);
if (item) { if (item) {
draw_item(item, this.x + slot.x, this.y + slot.y); draw_item(item, this.x + slot.x, this.y + slot.y);
} }
} }
if (this.player_inventory.holding_item) { if (this.inventories.cursor.item) {
const mouse = InputManager.get_mouse_position(); const mouse = InputManager.get_mouse_position();
draw_item(this.player_inventory.holding_item, mouse.x, mouse.y); draw_item(this.inventories.cursor.item, mouse.x, mouse.y);
} }
} }
on_close(): void {} on_close(): void {
this.send({ type: "close_screen" });
// the server puts these back in the inventory, show it right away
const { inventory, crafting, cursor } = this.inventories;
for (let i = 0; i < CRAFTING_RESULT_SLOT; i++) {
const item = crafting.get_item(i);
if (item) {
inventory.add_item(item);
crafting.set_item(i, undefined);
}
}
crafting.set_item(CRAFTING_RESULT_SLOT, undefined);
if (cursor.item) {
inventory.add_item(cursor.item);
cursor.item = undefined;
}
}
handle_interaction() { handle_interaction() {
this.player_inventory.hovering_slot = -1; this.hovering = undefined;
this.inventory.hovering_slot = -1; const mouse = InputManager.get_mouse_position();
for (const slot of this.slots) { for (const slot of this.slots) {
const mouse = InputManager.get_mouse_position(); const hovering = point_inside_rec(mouse.x, mouse.y, this.x + slot.x, this.y + slot.y, SLOT_SIZE, SLOT_SIZE);
const slot_x = slot.x + this.x; if (!hovering) {
const slot_y = slot.y + this.y; continue;
const hovering = point_inside_rec(mouse.x, mouse.y, slot_x, slot_y, SLOT_SIZE, SLOT_SIZE); }
if (hovering) { this.hovering = slot;
slot.inventory.hovering_slot = slot.index;
if (InputManager.is_mouse_pressed(0)) { for (const button of [0, 2]) {
InputManager.consume_mouse(0); if (InputManager.is_mouse_pressed(button)) {
this.handle_left_click(slot); InputManager.consume_mouse(button);
return; this.click(slot, button);
}
if (InputManager.is_mouse_pressed(2)) {
InputManager.consume_mouse(2);
this.handle_right_click(slot);
return; return;
} }
} }
} }
} }
switch_item_with_holding(inventory: Inventory, index: number) { click(slot: Slot, button: number) {
const container_slot = inventory.container.get_slot(index); this.send({ type: "click", container: slot.container, index: slot.index, button });
const original_holding_item = this.player_inventory.holding_item;
this.player_inventory.holding_item = container_slot.get_item();
container_slot.set_item(original_holding_item);
}
handle_left_click(slot: Slot) { // output slots depend on server side things like recipes, just wait for the server
const holding = this.player_inventory.holding_item; const container = this.get_container(slot.container);
const inventory_slot = slot.inventory.container.get_slot(slot.index); if (container && !slot.output) {
const slot_item = inventory_slot.get_item(); click_slot(container, slot.index, this.inventories.cursor, button);
// if you aren't holding anything
// "swap" with nothing on your hand (pick it up)
if (!holding) {
this.switch_item_with_holding(slot.inventory, slot.index);
return;
}
// if you are holding something and slot type equals holding type
// try to add to stack
if (slot_item && inventory_slot.type_id === holding.type_id) {
const space_left = inventory_slot.max_amount! - slot_item.amount!;
const amount_to_add = Math.min(space_left, holding.amount);
slot_item.amount += amount_to_add;
holding.amount -= amount_to_add;
return;
}
// if something on hand but not the same
// swap
this.switch_item_with_holding(slot.inventory, slot.index);
}
handle_right_click(slot: Slot) {
const holding = this.player_inventory.holding_item;
const inventory_slot = slot.inventory.container.get_slot(slot.index);
const slot_item = inventory_slot.get_item();
// if holding something
if (holding) {
// and slot type equals holding type
// add 1 to matching stack
if (slot_item && inventory_slot.type_id === holding.type_id) {
if (slot_item.amount < slot_item.max_amount!) {
slot_item.amount += 1;
holding.amount -= 1;
}
return;
}
// (actually the same as the last one but creates a new one techinically)
// place 1 into empty slot
if (!slot_item) {
const new_item = holding.clone();
new_item.amount = 1;
inventory_slot.set_item(new_item);
holding.amount -= 1;
return;
}
// if something on hand but not the same
// swap
this.switch_item_with_holding(slot.inventory, slot.index);
return;
}
// if player is holding nothing and clicks nothing, nothing happens
if (!slot_item) {
return;
}
// pick up half of the stack
const original_amount = slot_item.amount;
const half = Math.floor(original_amount / 2);
slot_item.amount = half;
const picked_up = slot_item.clone();
picked_up.amount = original_amount - half;
this.player_inventory.holding_item = picked_up;
if (slot_item.amount === 0) {
inventory_slot.set_item(undefined);
} }
} }
/*handle_hotbar(world: ClientWorld, player_inventory: PlayerInventory) {
const [player, player_hand] = world.get_tag("player")!;
const player_position = player.get(Position);
const player_sprite = player.get(AnimatedSprite);
const hand_position = player_hand.get(Position);
const hand_sprite = player_hand.get(Sprite);
if (!player_position || !hand_position || !hand_sprite || !player_sprite) {
return;
}
// sync position
hand_position.x = player_position.x + (player_sprite.flip_x ? 34 : 4);
hand_position.y = player_position.y + 34;
hand_sprite.flip_x = player_sprite.flip_x;
const maybe_item = player_inventory.container.get_item(player_inventory.hotbar_selected);
if (maybe_item) {
const region = get_sprite_region(maybe_item.type_id);
hand_sprite.source_x = region.x * 16;
hand_sprite.source_y = region.y * 16;
hand_sprite.source_width = 16;
hand_sprite.source_height = 16;
hand_sprite.width = 32;
hand_sprite.height = 32;
} else {
hand_sprite.width = 0;
hand_sprite.height = 0;
}
}*/
} }
// generic methods that is often needed // generic methods that is often needed
export function add_player_inventory( export function add_player_inventory(handler: GuiInventoryScreen, offset_x: number, offset_y: number) {
handler: GuiInventoryScreen,
player_inventory: PlayerInventory,
offset_x: number,
offset_y: number,
) {
for (let i = 0; i < 3; ++i) { for (let i = 0; i < 3; ++i) {
for (let l = 0; l < 9; ++l) { for (let l = 0; l < 9; ++l) {
handler.slots.push( handler.slots.push(
new Slot( new Slot("inventory", l + i * 9 + 9, offset_x + l * SLOT_SIZE, offset_y + i * SLOT_SIZE),
player_inventory,
l + i * 9 + 9,
offset_x + l * SLOT_SIZE,
offset_y + i * SLOT_SIZE,
),
); );
} }
} }
} }
export function add_player_hotbar( export function add_player_hotbar(handler: GuiInventoryScreen, offset_x: number, offset_y: number) {
handler: GuiInventoryScreen,
player_inventory: PlayerInventory,
offset_x: number,
offset_y: number,
) {
for (let i = 0; i < 9; ++i) { for (let i = 0; i < 9; ++i) {
handler.slots.push(new Slot(player_inventory, i, offset_x + i * SLOT_SIZE, offset_y)); handler.slots.push(new Slot("inventory", i, offset_x + i * SLOT_SIZE, offset_y));
} }
} }
+191
View File
@@ -0,0 +1,191 @@
// connecting to a server and getting what it needs before joining, see "Delivery to clients" in MODS.md:
// hello -> welcome, download and check everything, ready -> join
import type { AtlasListing } from "$/common/mod_loader.ts";
import { ClientMessage, PROTOCOL_VERSION, ServerMessage } from "$/common/protocol.ts";
const CONNECT_TIMEOUT_MS = 5000;
const TRUST_KEY_PREFIX = "bworld:trusted:";
export type Welcome = Extract<ServerMessage, { type: "welcome" }>;
export type Join = Extract<ServerMessage, { type: "join" }>;
export interface ServerAddress {
ws_url: string;
// where the server's files are, mod paths are relative to this
base: URL;
// the page came from somewhere else than the server, so its mods need the player's ok
cross_origin: boolean;
}
// something went wrong in a way the player should see
export class HandshakeError extends Error {}
export function server_address(page = new URL(location.href)): ServerAddress {
// ?server=host:port, otherwise the server that served the page
const host = page.searchParams.get("server") ?? page.host;
const secure = page.protocol === "https:";
const base = new URL(`${secure ? "https" : "http"}://${host}/`);
return {
ws_url: `${secure ? "wss" : "ws"}://${host}/ws`,
base,
cross_origin: base.origin !== page.origin,
};
}
// a socket whose messages all land in one queue, so none get lost between the handshake and the game
export class ServerSocket {
socket: WebSocket;
messages: ServerMessage[] = [];
closed = false;
#waiters: (() => void)[] = [];
constructor(socket: WebSocket) {
this.socket = socket;
socket.addEventListener("message", (event) => {
this.messages.push(JSON.parse(event.data));
this.#wake();
});
socket.addEventListener("close", () => {
this.closed = true;
this.#wake();
});
}
send(message: ClientMessage) {
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
}
}
close() {
this.socket.close();
}
// takes the first message of one of these types out of the queue, waiting for it if needed
async next<T extends ServerMessage["type"]>(...types: T[]): Promise<Extract<ServerMessage, { type: T }>> {
while (true) {
const index = this.messages.findIndex((m) => (types as string[]).includes(m.type));
if (index !== -1) {
return this.messages.splice(index, 1)[0] as Extract<ServerMessage, { type: T }>;
}
if (this.closed) {
throw new HandshakeError("The server closed the connection");
}
await new Promise<void>((resolve) => this.#waiters.push(resolve));
}
}
#wake() {
for (const waiter of this.#waiters.splice(0)) waiter();
}
}
export async function connect(
address: ServerAddress,
name: string,
): Promise<{ socket: ServerSocket; welcome: Welcome }> {
const socket = await new Promise<WebSocket>((resolve, reject) => {
let ws: WebSocket;
try {
ws = new WebSocket(address.ws_url);
} catch {
reject(new HandshakeError(`Couldn't connect to the server at ${address.ws_url}`));
return;
}
const timeout = setTimeout(() => {
ws.close();
reject(new HandshakeError(`The server at ${address.ws_url} didn't answer`));
}, CONNECT_TIMEOUT_MS);
ws.addEventListener("open", () => {
clearTimeout(timeout);
resolve(ws);
});
ws.addEventListener("error", () => {
clearTimeout(timeout);
reject(new HandshakeError(`Couldn't connect to the server at ${address.ws_url}`));
});
});
const server = new ServerSocket(socket);
server.send({ type: "hello", name, protocol: PROTOCOL_VERSION });
const answer = await server.next("welcome", "rejected");
if (answer.type === "rejected") {
server.close();
throw new HandshakeError(answer.reason);
}
return { socket: server, welcome: answer };
}
// tell the server everything's loaded and wait to be let in
export async function join(socket: ServerSocket): Promise<Join> {
socket.send({ type: "ready" });
const answer = await socket.next("join", "rejected");
if (answer.type === "rejected") {
socket.close();
throw new HandshakeError(answer.reason);
}
return answer;
}
// downloads a file and checks it's the one the server listed. use the bytes returned, never fetch it again
export async function fetch_verified(url: URL, sha256: string, what: string): Promise<Uint8Array<ArrayBuffer>> {
let response: Response;
try {
response = await fetch(url);
} catch {
throw new HandshakeError(`Couldn't download ${what} from ${url}`);
}
if (!response.ok) {
throw new HandshakeError(`Couldn't download ${what} (${response.status})`);
}
const bytes = new Uint8Array(await response.arrayBuffer());
const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
if (digest !== sha256) {
throw new HandshakeError(`${what} doesn't match what the server listed`);
}
return bytes;
}
// a url for code that was already downloaded and checked, so importing it can't fetch something else
export function code_url(bytes: Uint8Array<ArrayBuffer>): string {
return URL.createObjectURL(new Blob([bytes], { type: "text/javascript" }));
}
export async function load_atlas(address: ServerAddress, atlas: AtlasListing) {
const [png, json] = await Promise.all([
fetch_verified(new URL(atlas.png, address.base), atlas.sha256.png, "the texture atlas"),
fetch_verified(new URL(atlas.json, address.base), atlas.sha256.json, "the texture atlas"),
]);
return {
image: await createImageBitmap(new Blob([png], { type: "image/png" })),
regions: JSON.parse(new TextDecoder().decode(json)) as Record<string, { x: number; y: number }>,
};
}
// players ok a cross-origin server's mods once, per server and exact mod versions
function trust_key(address: ServerAddress) {
return TRUST_KEY_PREFIX + address.base.origin;
}
function mod_versions(welcome: Welcome) {
return welcome.mods.map((mod) => `${mod.id}@${mod.hash}`).sort().join(",");
}
export function is_trusted(address: ServerAddress, welcome: Welcome): boolean {
try {
return localStorage.getItem(trust_key(address)) === mod_versions(welcome);
} catch {
return false;
}
}
export function remember_trust(address: ServerAddress, welcome: Welcome) {
try {
localStorage.setItem(trust_key(address), mod_versions(welcome));
} catch {
// private windows and blocked storage just ask again next time
}
}
+9 -136
View File
@@ -1,138 +1,11 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; import { Container, Cursor } from "$/common/inventory.ts";
export class ItemStack<T = unknown | undefined> { // the local copies of the containers the server syncs to this player
type_id: string; export class ClientInventories {
amount: number; inventory = new Container(9 * 4);
max_amount: number; crafting = new Container(10);
data?: T; // the open server screen's container
screen: Container | undefined;
constructor(type_id: string | string, amount: number = 1, max_amount: number = 64) { cursor: Cursor = { item: undefined };
this.type_id = type_id; hotbar_selected = 0;
this.amount = amount;
this.max_amount = max_amount;
this.data = undefined;
const item_info = EverythingRegistry.get<ItemRegistry>("items", type_id);
if (item_info?.on_create) {
item_info.on_create(this);
}
}
clone(): ItemStack {
return new ItemStack(this.type_id, this.amount, this.max_amount);
}
}
export class ContainerSlot {
#item_stack: ItemStack | undefined;
has_item() {
return this.#item_stack !== undefined;
}
set_item(item_stack: ItemStack | undefined) {
if ((item_stack?.amount ?? 0) <= 0) {
item_stack = undefined;
}
this.#item_stack = item_stack;
}
get_item() {
return this.#item_stack;
}
get type_id() {
return this.#item_stack?.type_id;
}
set amount(new_amount: number) {
if (this.#item_stack) {
this.#item_stack.amount = new_amount;
if (this.#item_stack.amount <= 0) {
this.#item_stack = undefined;
}
}
}
get amount(): number | undefined {
return this.#item_stack?.amount;
}
get max_amount() {
return this.#item_stack?.max_amount;
}
}
export class Container {
#slots: ContainerSlot[] = [];
readonly size: number;
constructor(size: number) {
this.size = size;
for (let i = 0; i < size; i += 1) {
this.#slots.push(new ContainerSlot());
}
}
add_item(item_stack: ItemStack) {
for (const slot of this.#slots.filter((slot) => slot.has_item())) {
const slot_item = slot.get_item()!;
if (slot_item.type_id === item_stack.type_id) {
const missing = slot_item.max_amount - slot_item.amount;
const adding = Math.min(missing, item_stack.amount);
slot_item.amount += adding;
item_stack.amount -= adding;
if (item_stack.amount === 0) {
return;
}
}
}
for (const slot of this.#slots) {
if (!slot.has_item()) {
slot.set_item(item_stack);
return;
}
}
// TODO: drop item on ground
console.error("Failed to place item in container");
}
get_item(slot: number): ItemStack | undefined {
return this.#slots[slot]?.get_item();
}
get_slot(slot: number): ContainerSlot {
return this.#slots[slot];
}
set_item(slot: number, item: ItemStack | undefined) {
this.#slots[slot].set_item(item);
}
}
export interface ContainerLayout {
slots: { type: string; x: number; y: number }[];
offset_x: number;
offset_y: number;
}
export class Inventory {
container: Container;
hovering_slot: number = -1;
constructor(container: Container) {
this.container = container;
}
}
export class PlayerInventory extends Inventory {
owner_id: string;
holding_item: ItemStack | undefined;
hotbar_selected: number = 0;
constructor(owner_id: string) {
const container = new Container(9 * 4);
super(container);
this.owner_id = owner_id;
}
} }
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:axe", {
texture_id: "bworld:axe",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:coal", {
texture_id: "bworld:coal",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:copper_ingot", {
texture_id: "bworld:copper_ingot",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:gold_ingot", {
texture_id: "bworld:gold_ingot",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:hoe", {
texture_id: "bworld:hoe",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:iron_ingot", {
texture_id: "bworld:iron_ingot",
});
-11
View File
@@ -1,11 +0,0 @@
import "./watering_can.ts";
import "./axe.ts";
import "./pickaxe.ts";
import "./hoe.ts";
import "./coal.ts";
import "./tin_ingot.ts";
import "./iron_ingot.ts";
import "./copper_ingot.ts";
import "./gold_ingot.ts";
import "./stick.ts";
import "./wood_pickaxe.ts";
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:pickaxe", {
texture_id: "bworld:pickaxe",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:stick", {
texture_id: "bworld:stick",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:tin_ingot", {
texture_id: "bworld:tin_ingot",
});
-16
View File
@@ -1,16 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
export interface WateringCanData {
water: number;
max_water: number;
}
EverythingRegistry.register<ItemRegistry<WateringCanData>>("items", "bworld:watering_can", {
texture_id: "bworld:watering_can",
on_create(item) {
item.data = { water: 0, max_water: 32 };
},
get_lore(item) {
return `Water: ${item.data?.water}/${item.data?.max_water}`;
},
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:wood_pickaxe", {
texture_id: "bworld:wood_pickaxe",
});
+55 -13
View File
@@ -1,10 +1,13 @@
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 { begin_drawing, clear_background, end_drawing, init_font, init_window } from "./renderer/mod.ts"; import { Connection, get_player_name } from "./network.ts";
import { connect, HandshakeError, is_trusted, join, load_atlas, remember_trust, server_address } from "./handshake.ts";
await import("./blocks/mod.ts"); import { confirm_mods } from "./confirm_mods.ts";
await import("./items/mod.ts"); import { ModLoadError } from "$/common/mod_loader.ts";
import { begin_drawing, clear_background, end_drawing, init_font, init_window, load_texture } from "./renderer/mod.ts";
import { is_stopped, show_fatal_error } from "./fatal.ts";
import { load_client_mods, set_mods_world } from "./mods.ts";
export class ClientLoop { export class ClientLoop {
running = false; running = false;
@@ -38,7 +41,7 @@ export class ClientLoop {
} }
loop(time: number) { loop(time: number) {
if (!this.running) { if (!this.running || is_stopped()) {
return; return;
} }
@@ -70,15 +73,12 @@ if (!canvas) {
throw Error("Canvas was not found"); throw Error("Canvas was not found");
} }
init_window(canvas); await init_window(canvas);
InputManager.initialize(canvas); InputManager.initialize(canvas);
AssetManager.instance.load("bworld:assets_text", "/assets/ASSETS.md"); AssetManager.instance.load("bworld:assets_text", "/assets/ASSETS.md");
AssetManager.instance.load("bworld:textures", "/assets/sprites/textures.png");
AssetManager.instance.load("bworld:textures_info", "/assets/sprites/textures.json");
AssetManager.instance.load("bworld:player", "/assets/sprites/player.png"); AssetManager.instance.load("bworld:player", "/assets/sprites/player.png");
AssetManager.instance.load("bworld:roguelike", "/assets/sprites/roguelike.png"); AssetManager.instance.load("bworld:roguelike", "/assets/sprites/roguelike.png");
AssetManager.instance.load("bworld:ui", "/assets/sprites/ui.png"); AssetManager.instance.load("bworld:ui", "/assets/sprites/ui.png");
@@ -90,9 +90,51 @@ await AssetManager.instance.load_all();
init_font(); init_font();
const client_world = new ClientWorld(); // the game only runs against a server, it owns the world and everything in it. see "Delivery to clients" in MODS.md
async function join_server(): Promise<Connection> {
const address = server_address();
const { socket, welcome } = await connect(address, get_player_name());
console.log(`Connected to ${address.ws_url}`);
const loop = new ClientLoop(client_world); try {
loop.start(); if (address.cross_origin && !is_trusted(address, welcome)) {
if (!await confirm_mods(address, welcome)) {
throw new HandshakeError(`You didn't join ${address.base.host}`);
}
remember_trust(address, welcome);
}
console.log("Game started"); // textures, blocks and items all come from the server's mods, so this happens before anything else
const atlas = await load_atlas(address, welcome.atlas);
AssetManager.instance.assets["bworld:textures"] = load_texture(atlas.image);
AssetManager.instance.assets["bworld:textures_info"] = atlas.regions;
await load_client_mods(welcome.mods, address.base);
console.log(`Mods: ${welcome.mods.map((mod) => `${mod.id} ${mod.version}`).join(", ") || "none"}`);
const joined = await join(socket);
return new Connection(socket, welcome, joined);
} catch (e) {
socket.close();
throw e;
}
}
try {
const connection = await join_server();
const client_world = new ClientWorld(connection);
set_mods_world(client_world);
client_world.add_chat("Connected to the server");
const loop = new ClientLoop(client_world);
loop.start();
console.log("Game started");
} catch (e) {
console.error(e);
const message = e instanceof HandshakeError
? e.message
: e instanceof ModLoadError
? `Couldn't load the server's mods: ${e.message}`
: `Something went wrong joining the server: ${e instanceof Error ? e.message : e}`;
show_fatal_error(message);
}
+98
View File
@@ -0,0 +1,98 @@
// loads the mods the server lists: registers their data, then runs their client scripts.
// see "Delivery to clients" in MODS.md
import { AIR } from "$/common/constants.ts";
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import type { ClientContext } from "$/common/mod_api/client.ts";
import { ModData, ModListing, ModLoadError, register_mod_data } from "$/common/mod_loader.ts";
import type { OreJson } from "$/common/mod_data.ts";
import { AIR_ID } from "$/common/protocol.ts";
import { Position } from "$/common/components/position.ts";
import type { ClientWorld } from "./client_world.ts";
import { code_url, fetch_verified } from "./handshake.ts";
// what the chunk workers need to generate the same world as the server
export const worldgen_mods: { scripts: { mod: string; url: string }[]; ores: OreJson[] } = { scripts: [], ores: [] };
// set once the world exists, mods can't look at it during setup
let world: ClientWorld | undefined;
export function set_mods_world(client_world: ClientWorld) {
world = client_world;
}
// downloads every mod's files and checks them against the hashes the server listed, then registers the data and
// runs the client scripts. everything a mod runs is imported from the checked bytes, never fetched twice
export async function load_client_mods(listings: ModListing[], base: URL) {
const downloads = await Promise.all(listings.map(async (listing) => {
const get = async (path: string | undefined, sha256: string | undefined, what: string) => {
if (!path) return undefined;
try {
return await fetch_verified(new URL(path, base), sha256 ?? "", what);
} catch (e) {
throw new ModLoadError(listing.id, (e as Error).message);
}
};
const [data, client, worldgen] = await Promise.all([
get(listing.data, listing.sha256.data, "its data"),
get(listing.client, listing.sha256.client, "its client script"),
get(listing.worldgen, listing.sha256.worldgen, "its worldgen script"),
]);
return { listing, data: JSON.parse(new TextDecoder().decode(data)) as ModData, client, worldgen };
}));
const recipes = register_mod_data(downloads.map(({ listing, data }) => ({ id: listing.id, data })));
worldgen_mods.ores = recipes.ores;
worldgen_mods.scripts = downloads.flatMap(({ listing, worldgen }) =>
worldgen ? [{ mod: listing.id, url: code_url(worldgen) }] : []
);
for (const { listing, client } of downloads) {
if (!client) continue;
const module = await import(code_url(client));
if (typeof module.setup !== "function") {
throw new ModLoadError(listing.id, "the client script doesn't export a setup function");
}
await module.setup(client_context(listing));
}
}
function client_context(listing: ModListing): ClientContext {
const mod = listing.id;
const not_yet = (name: string, where: string) =>
new Proxy({}, {
get: (_, prop) => () => {
throw new Error(`[${mod}] ctx.${name}.${String(prop)} isn't implemented yet (${where} in MODS.md)`);
},
});
const need_world = () => {
if (!world) throw new Error(`[${mod}] the world isn't there yet during setup`);
return world;
};
return {
mod: { id: mod, version: listing.version },
ui: not_yet("ui", "step 7") as ClientContext["ui"],
hud: not_yet("hud", "step 7") as ClientContext["hud"],
input: not_yet("input", "step 8") as ClientContext["input"],
net: not_yet("net", "step 8") as ClientContext["net"],
player: {
get name() {
return need_world().connection.name;
},
get position() {
const [player] = need_world().get_tag("player")!;
const position = player.get(Position)!;
return { x: position.x, y: position.y, z: position.z };
},
},
world: {
get_block(x, y, z) {
const nid = need_world().dimension.get_block(x, y, z);
if (nid === AIR) return AIR_ID;
return EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid)?.id;
},
},
log: (...args) => console.log(`[${mod}]`, ...args),
};
}
+88
View File
@@ -0,0 +1,88 @@
import { BlockChange, ClientMessage, PlayerInfo, ServerMessage } from "$/common/protocol.ts";
import type { ModListing } from "$/common/mod_loader.ts";
import type { Join, ServerSocket, Welcome } from "./handshake.ts";
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 {
#server: ServerSocket;
id: string;
name: string;
seed: string;
mods: ModListing[];
initial_changes: BlockChange[];
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
selected_slot: number;
players = new Map<string, RemotePlayer>();
constructor(server: ServerSocket, welcome: Welcome, join: Join) {
this.#server = server;
this.id = join.id;
this.name = join.name;
this.seed = welcome.seed;
this.mods = welcome.mods;
this.initial_changes = join.changes;
this.spawn = join.spawn;
this.selected_slot = join.selected_slot;
for (const player of join.players) {
this.add_player(player);
}
}
// handled by the network system inside the game loop, not whenever the socket feels like it
get incoming(): ServerMessage[] {
return this.#server.messages;
}
get closed() {
return this.#server.closed;
}
send(message: ClientMessage) {
this.#server.send(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),
});
}
}
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_player_name(): string {
return new URLSearchParams(location.search).get("name") ?? "";
}
+9 -5
View File
@@ -2,14 +2,14 @@ import { Position } from "$/common/components/position.ts";
import { Velocity } from "$/common/components/velocity.ts"; import { Velocity } from "$/common/components/velocity.ts";
import { Component, Entity } from "$/common/ecs/mod.ts"; import { Component, Entity } from "$/common/ecs/mod.ts";
import { Camera } from "$/client/components/camera.ts"; import { Camera } from "$/client/components/camera.ts";
import { PlayerInventory } from "./inventory.ts"; import { ClientInventories } from "./inventory.ts";
import { PlayerControls } from "$/client/components/player_controls.ts"; import { PlayerControls } from "$/client/components/player_controls.ts";
import { ClientWorld } from "./client_world.ts"; import { ClientWorld } from "./client_world.ts";
import { GuiScreen } from "./gui/gui_screen.ts"; import { GuiScreen } from "./gui/gui_screen.ts";
import { CollisionCuboid } from "./components/collision.ts"; import { CollisionCuboid } from "./components/collision.ts";
export class PlayerComponent extends Component { export class PlayerComponent extends Component {
player_inventory = new PlayerInventory(""); inventories = new ClientInventories();
screens: GuiScreen[] = []; screens: GuiScreen[] = [];
render_distance = 6; render_distance = 6;
@@ -27,12 +27,16 @@ export class PlayerComponent extends Component {
export function create_player(world: ClientWorld) { export function create_player(world: ClientWorld) {
const player = new Entity("player"); const player = new Entity("player");
player.add(new Position(0, 100, 0)); const spawn = world.connection.spawn;
player.add(new Position(spawn.x, spawn.y, spawn.z));
player.add(new Velocity(0, 0, 0)); player.add(new Velocity(0, 0, 0));
player.add(new PlayerControls()); player.add(new PlayerControls());
player.add(new PlayerComponent()); const player_component = player.add(new PlayerComponent());
player.add(new Camera()); player_component.inventories.hotbar_selected = world.connection.selected_slot;
const camera = player.add(new Camera());
camera.yaw = spawn.yaw;
camera.pitch = spawn.pitch;
player.add(new CollisionCuboid(0.55, 1.79, 0.55)); player.add(new CollisionCuboid(0.55, 1.79, 0.55));
world.add_entity(player); world.add_entity(player);
+374 -166
View File
@@ -1,28 +1,44 @@
import { Camera } from "../components/camera.ts"; import { Camera } from "../components/camera.ts";
import { mat4 } from "gl-matrix"; import { mat4 } from "gl-matrix";
export let gl: WebGLRenderingContext; export let device: GPUDevice;
export let canvas: HTMLCanvasElement; export let canvas: HTMLCanvasElement;
const MAX_SPRITES = 100000; const MAX_SPRITES = 100000;
const VERTS_PER_SPRITE = 6; const VERTS_PER_SPRITE = 6;
const FLOATS_PER_VERT = 9; const FLOATS_PER_VERT = 9;
const VERTEX_STRIDE = FLOATS_PER_VERT * 4;
let program: WebGLProgram; const SAMPLE_COUNT = 4;
let main_buffer: WebGLBuffer; const DEPTH_FORMAT: GPUTextureFormat = "depth24plus";
// dynamic uniform offsets have to be 256 aligned
const UNIFORM_SLOT_SIZE = 256;
let context: GPUCanvasContext;
let canvas_format: GPUTextureFormat;
let pipeline_2d: GPURenderPipeline;
let pipeline_3d: GPURenderPipeline;
let uniform_layout: GPUBindGroupLayout;
let texture_layout: GPUBindGroupLayout;
let sampler: GPUSampler;
const vertex_data = new Float32Array(MAX_SPRITES * VERTS_PER_SPRITE * FLOATS_PER_VERT); const vertex_data = new Float32Array(MAX_SPRITES * VERTS_PER_SPRITE * FLOATS_PER_VERT);
let vert_index = 0; let vert_index = 0;
let pos_loc: GLint; // every flush in a frame appends to this, so earlier draws dont get overwritten before the submit
let uv_loc: GLint; let stream_buffer: GPUBuffer;
let color_loc: GLint; let stream_offset = 0;
let texture_loc: WebGLUniformLocation | null;
let mvp_loc: WebGLUniformLocation | null;
let col_diffuse_loc: WebGLUniformLocation | null;
let current_texture: WebGLTexture | null = null; let uniform_buffer: GPUBuffer;
export let white_tex: WebGLTexture | null = null; let uniform_bind_group: GPUBindGroup;
let uniform_slot = -1;
const texture_bind_groups = new WeakMap<GPUTexture, GPUBindGroup>();
let current_texture: GPUTexture | null = null;
export let white_tex: GPUTexture | null = null;
let mode3d = false; let mode3d = false;
@@ -33,91 +49,124 @@ const proj = mat4.create();
const view = mat4.create(); const view = mat4.create();
const mvp = mat4.create(); const mvp = mat4.create();
const vertex_src = `#version 300 es // per frame state
precision mediump float; let encoder: GPUCommandEncoder | undefined;
in vec3 vertexPosition; let pass: GPURenderPassEncoder | undefined;
in vec2 vertexTexCoord; let frame_view: GPUTextureView;
in vec4 vertexColor; let msaa_texture: GPUTexture | undefined;
out vec2 fragTexCoord; let depth_texture: GPUTexture | undefined;
out vec4 fragColor; let current_pipeline: GPURenderPipeline | undefined;
uniform mat4 mvp; let clear_color: GPUColor = { r: 0, g: 0, b: 0, a: 1 };
let pending_color_clear = false;
let pending_depth_clear = false;
let scissor: { x: number; y: number; width: number; height: number } | undefined;
let pending_destroy: GPUBuffer[] = [];
void main() { const shader_src = /* wgsl */ `
fragTexCoord = vertexTexCoord; struct Uniforms {
fragColor = vertexColor; mvp: mat4x4<f32>,
gl_Position = mvp*vec4(vertexPosition, 1.0); }
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(1) @binding(0) var texture0: texture_2d<f32>;
@group(1) @binding(1) var sampler0: sampler;
struct VertexOut {
@builtin(position) position: vec4<f32>,
@location(0) tex_coord: vec2<f32>,
@location(1) color: vec4<f32>,
}
@vertex
fn vs_main(
@location(0) position: vec3<f32>,
@location(1) tex_coord: vec2<f32>,
@location(2) color: vec4<f32>,
) -> VertexOut {
var out: VertexOut;
out.position = uniforms.mvp * vec4<f32>(position, 1.0);
out.tex_coord = tex_coord;
out.color = color;
return out;
}
@fragment
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
return textureSample(texture0, sampler0, in.tex_coord) * in.color;
} }
`; `;
const fragment_src = `#version 300 es export async function init_window(canvas_element: HTMLCanvasElement) {
precision mediump float;
in vec2 fragTexCoord;
in vec4 fragColor;
out vec4 finalColor;
uniform sampler2D texture0;
uniform vec4 colDiffuse;
void main() {
vec4 texelColor = texture(texture0, fragTexCoord);
finalColor = texelColor*colDiffuse*fragColor;
}
`;
export function init_window(canvas_element: HTMLCanvasElement) {
canvas = canvas_element; canvas = canvas_element;
canvas.width = 1800; canvas.width = 1800;
canvas.height = 900; canvas.height = 900;
const ctx = canvas.getContext("webgl2", { antialias: true, alpha: false }); if (!navigator.gpu) {
if (!ctx) { throw new Error("WebGPU not supported");
throw new Error("WebGL2 not supported");
} }
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("No WebGPU adapter found");
}
device = await adapter.requestDevice();
device.addEventListener("uncapturederror", (event) => {
console.error("WebGPU error:", (event as GPUUncapturedErrorEvent).error.message);
});
device.lost.then((info) => console.error(`WebGPU device lost: ${info.message}`));
gl = ctx; // the dom typings dont know about the webgpu overload yet
const ctx = canvas.getContext("webgpu") as GPUCanvasContext | null;
if (!ctx) {
throw new Error("WebGPU not supported");
}
context = ctx;
canvas_format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format: canvas_format, alphaMode: "opaque" });
program = create_program(vertex_src, fragment_src); create_pipelines();
main_buffer = gl.createBuffer()!; sampler = device.createSampler({
gl.bindBuffer(gl.ARRAY_BUFFER, main_buffer); magFilter: "nearest",
minFilter: "nearest",
addressModeU: "clamp-to-edge",
addressModeV: "clamp-to-edge",
});
const stride = FLOATS_PER_VERT * 4; stream_buffer = create_stream_buffer(8 * 1024 * 1024);
create_uniform_buffer(64);
pos_loc = gl.getAttribLocation(program, "vertexPosition");
gl.enableVertexAttribArray(pos_loc);
gl.vertexAttribPointer(pos_loc, 3, gl.FLOAT, false, stride, 0);
uv_loc = gl.getAttribLocation(program, "vertexTexCoord");
gl.enableVertexAttribArray(uv_loc);
gl.vertexAttribPointer(uv_loc, 2, gl.FLOAT, false, stride, 12);
color_loc = gl.getAttribLocation(program, "vertexColor");
gl.enableVertexAttribArray(color_loc);
gl.vertexAttribPointer(color_loc, 4, gl.FLOAT, false, stride, 20);
mvp_loc = gl.getUniformLocation(program, "mvp");
texture_loc = gl.getUniformLocation(program, "texture0");
col_diffuse_loc = gl.getUniformLocation(program, "colDiffuse");
gl.useProgram(program);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.cullFace(gl.BACK);
gl.frontFace(gl.CCW);
create_white_texture(); create_white_texture();
} }
export function begin_drawing() { export function begin_drawing() {
encoder = device.createCommandEncoder();
frame_view = context.getCurrentTexture().createView();
ensure_render_targets();
update_2d_mvp(); update_2d_mvp();
gl.depthFunc(gl.LEQUAL); stream_offset = 0;
gl.clearDepth(1.0); uniform_slot = -1;
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); write_mvp();
pending_color_clear = true;
pending_depth_clear = true;
vert_index = 0; vert_index = 0;
} }
export function end_drawing() { export function end_drawing() {
flush_batch(); flush_batch();
// make sure the frame gets cleared even if nothing was drawn
ensure_pass();
pass!.end();
pass = undefined;
device.queue.submit([encoder!.finish()]);
encoder = undefined;
for (const buffer of pending_destroy) {
buffer.destroy();
}
pending_destroy = [];
} }
function update_2d_mvp() { function update_2d_mvp() {
@@ -131,19 +180,23 @@ function update_2d_mvp() {
const n = -1; const n = -1;
const f = 1; const f = 1;
mat4.ortho(ortho, l, r, b, t, n, f); mat4.orthoZO(ortho, l, r, b, t, n, f);
} }
export function begin_clip(x: number, y: number, width: number, height: number) { export function begin_clip(x: number, y: number, width: number, height: number) {
gl.enable(gl.SCISSOR_TEST); flush_batch();
scissor = { x, y, width, height };
const flipped_y = canvas.height - (y + height); if (pass) {
apply_scissor(pass);
gl.scissor(x, flipped_y, width, height); }
} }
export function end_clip() { export function end_clip() {
gl.disable(gl.SCISSOR_TEST); flush_batch();
scissor = undefined;
if (pass) {
apply_scissor(pass);
}
} }
export function begin_mode_3d(new_camera: Camera) { export function begin_mode_3d(new_camera: Camera) {
@@ -152,13 +205,8 @@ export function begin_mode_3d(new_camera: Camera) {
mode3d = true; mode3d = true;
camera = new_camera; camera = new_camera;
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.CULL_FACE);
gl.depthMask(true);
gl.enable(gl.POLYGON_OFFSET_FILL);
gl.polygonOffset(1, 1);
update_camera(); update_camera();
write_mvp();
} }
export function end_mode_3d() { export function end_mode_3d() {
@@ -169,15 +217,18 @@ export function end_mode_3d() {
flush_batch(); flush_batch();
mode3d = false; mode3d = false;
gl.disable(gl.DEPTH_TEST); write_mvp();
gl.disable(gl.CULL_FACE);
gl.depthMask(false);
gl.disable(gl.POLYGON_OFFSET_FILL);
} }
export function clear_background(r: number, g: number, b: number, a = 1) { export function clear_background(r: number, g: number, b: number, a = 1) {
gl.clearColor(r, g, b, a); flush_batch();
gl.clear(gl.COLOR_BUFFER_BIT); clear_color = { r, g, b, a };
// the clear happens when a render pass starts, so start a new one
if (pass) {
pass.end();
pass = undefined;
}
pending_color_clear = true;
} }
export function flush_batch() { export function flush_batch() {
@@ -185,52 +236,41 @@ export function flush_batch() {
return; return;
} }
gl.bindBuffer(gl.ARRAY_BUFFER, main_buffer); const byte_length = vert_index * 4;
gl.bufferData(gl.ARRAY_BUFFER, vertex_data.subarray(0, vert_index), gl.STREAM_DRAW); if (stream_offset + byte_length > stream_buffer.size) {
// the old buffer might still be used by draws in this frame, destroy it after the submit
if (mode3d) { pending_destroy.push(stream_buffer);
gl.uniformMatrix4fv(mvp_loc, false, mvp); stream_buffer = create_stream_buffer(Math.max(stream_buffer.size * 2, byte_length));
} else { stream_offset = 0;
gl.uniformMatrix4fv(mvp_loc, false, ortho);
} }
const stride = FLOATS_PER_VERT * 4; device.queue.writeBuffer(stream_buffer, stream_offset, vertex_data, 0, vert_index);
gl.vertexAttribPointer(pos_loc, 3, gl.FLOAT, false, stride, 0); draw(stream_buffer, stream_offset, vert_index / FLOATS_PER_VERT);
gl.vertexAttribPointer(uv_loc, 2, gl.FLOAT, false, stride, 12); stream_offset += byte_length;
gl.vertexAttribPointer(color_loc, 4, gl.FLOAT, false, stride, 20);
gl.uniform4f(col_diffuse_loc, 1, 1, 1, 1);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, current_texture);
gl.uniform1i(texture_loc, 0);
gl.drawArrays(gl.TRIANGLES, 0, vert_index / FLOATS_PER_VERT);
vert_index = 0; vert_index = 0;
} }
export function flush_buffer(buffer: WebGLBuffer, draw_count: number) { export function flush_buffer(buffer: GPUBuffer, draw_count: number) {
gl.bindBuffer(gl.ARRAY_BUFFER, buffer); draw(buffer, 0, draw_count);
}
if (mode3d) { export function create_vertex_buffer(vertices: Float32Array): GPUBuffer {
gl.uniformMatrix4fv(mvp_loc, false, mvp); const buffer = device.createBuffer({
size: Math.max(4, vertices.byteLength),
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(buffer, 0, vertices);
return buffer;
}
export function destroy_vertex_buffer(buffer: GPUBuffer) {
// it might be used by a draw thats not submitted yet
if (encoder) {
pending_destroy.push(buffer);
} else { } else {
gl.uniformMatrix4fv(mvp_loc, false, ortho); buffer.destroy();
} }
const stride = FLOATS_PER_VERT * 4;
gl.vertexAttribPointer(pos_loc, 3, gl.FLOAT, false, stride, 0);
gl.vertexAttribPointer(uv_loc, 2, gl.FLOAT, false, stride, 12);
gl.vertexAttribPointer(color_loc, 4, gl.FLOAT, false, stride, 20);
gl.uniform4f(col_diffuse_loc, 1, 1, 1, 1);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, current_texture);
gl.uniform1i(texture_loc, 0);
gl.drawArrays(gl.TRIANGLES, 0, draw_count);
} }
export function resize_canvas() { export function resize_canvas() {
@@ -242,21 +282,26 @@ export function resize_canvas() {
canvas.height = height; canvas.height = height;
canvas.style.width = canvas.width + "px"; canvas.style.width = canvas.width + "px";
canvas.style.height = canvas.height + "px"; canvas.style.height = canvas.height + "px";
// render targets get recreated at the start of the next frame
gl.viewport(0, 0, width, height);
gl.useProgram(program);
} }
} }
export function get_current_texture(): WebGLTexture | null { export function get_current_texture(): GPUTexture | null {
return current_texture; return current_texture;
} }
export function set_current_texture(texture: WebGLTexture) { export function set_current_texture(texture: GPUTexture) {
current_texture = texture; current_texture = texture;
} }
export function create_texture(width: number, height: number): GPUTexture {
return device.createTexture({
size: [width, height],
format: "rgba8unorm",
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
});
}
export function push_vertex(px: number, py: number, pz: number, u: number, vv: number, r = 1, g = 1, b = 1, a = 1) { export function push_vertex(px: number, py: number, pz: number, u: number, vv: number, r = 1, g = 1, b = 1, a = 1) {
let i = vert_index; let i = vert_index;
vertex_data[i++] = px; vertex_data[i++] = px;
@@ -330,12 +375,115 @@ export function push_quad_vertices(
// internal // internal
function draw(buffer: GPUBuffer, offset: number, vertex_count: number) {
if (!current_texture || vertex_count === 0) {
return;
}
const render_pass = ensure_pass();
const pipeline = mode3d ? pipeline_3d : pipeline_2d;
if (current_pipeline !== pipeline) {
render_pass.setPipeline(pipeline);
current_pipeline = pipeline;
}
render_pass.setBindGroup(0, uniform_bind_group, [uniform_slot * UNIFORM_SLOT_SIZE]);
render_pass.setBindGroup(1, get_texture_bind_group(current_texture));
render_pass.setVertexBuffer(0, buffer, offset);
render_pass.draw(vertex_count);
}
function ensure_pass(): GPURenderPassEncoder {
if (pass) {
return pass;
}
pass = encoder!.beginRenderPass({
colorAttachments: [{
view: msaa_texture!.createView(),
resolveTarget: frame_view,
clearValue: clear_color,
loadOp: pending_color_clear ? "clear" : "load",
storeOp: "store",
}],
depthStencilAttachment: {
view: depth_texture!.createView(),
depthClearValue: 1,
depthLoadOp: pending_depth_clear ? "clear" : "load",
depthStoreOp: "store",
},
});
pending_color_clear = false;
pending_depth_clear = false;
current_pipeline = undefined;
apply_scissor(pass);
return pass;
}
function apply_scissor(render_pass: GPURenderPassEncoder) {
const target_width = msaa_texture!.width;
const target_height = msaa_texture!.height;
if (!scissor) {
render_pass.setScissorRect(0, 0, target_width, target_height);
return;
}
// webgpu errors on rects outside the target instead of clipping them
const x0 = Math.min(target_width, Math.max(0, Math.floor(scissor.x)));
const y0 = Math.min(target_height, Math.max(0, Math.floor(scissor.y)));
const x1 = Math.min(target_width, Math.max(x0, Math.ceil(scissor.x + scissor.width)));
const y1 = Math.min(target_height, Math.max(y0, Math.ceil(scissor.y + scissor.height)));
render_pass.setScissorRect(x0, y0, x1 - x0, y1 - y0);
}
function ensure_render_targets() {
const width = canvas.width;
const height = canvas.height;
if (msaa_texture && msaa_texture.width === width && msaa_texture.height === height) {
return;
}
msaa_texture?.destroy();
depth_texture?.destroy();
msaa_texture = device.createTexture({
size: [width, height],
format: canvas_format,
sampleCount: SAMPLE_COUNT,
usage: GPUTextureUsage.RENDER_ATTACHMENT,
});
depth_texture = device.createTexture({
size: [width, height],
format: DEPTH_FORMAT,
sampleCount: SAMPLE_COUNT,
usage: GPUTextureUsage.RENDER_ATTACHMENT,
});
}
// puts the current matrix in a new uniform slot, earlier draws in the frame keep using theirs
function write_mvp() {
uniform_slot += 1;
if (uniform_slot * UNIFORM_SLOT_SIZE >= uniform_buffer.size) {
pending_destroy.push(uniform_buffer);
create_uniform_buffer((uniform_buffer.size / UNIFORM_SLOT_SIZE) * 2);
uniform_slot = 0;
}
const matrix = mode3d ? mvp : ortho;
device.queue.writeBuffer(uniform_buffer, uniform_slot * UNIFORM_SLOT_SIZE, matrix as Float32Array);
}
function update_camera() { function update_camera() {
if (!camera) { if (!camera) {
return; return;
} }
mat4.perspective( mat4.perspectiveZO(
proj, proj,
camera.fov, camera.fov,
canvas.width / canvas.height, canvas.width / canvas.height,
@@ -354,47 +502,107 @@ function update_camera() {
mat4.multiply(mvp, proj, view); mat4.multiply(mvp, proj, view);
} }
function compile_shader(type: number, src: string) { function create_pipelines() {
const shader = gl.createShader(type)!; const module = device.createShaderModule({ code: shader_src });
gl.shaderSource(shader, src); uniform_layout = device.createBindGroupLayout({
gl.compileShader(shader); entries: [{
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: { type: "uniform", hasDynamicOffset: true, minBindingSize: 64 },
}],
});
texture_layout = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
],
});
const layout = device.createPipelineLayout({ bindGroupLayouts: [uniform_layout, texture_layout] });
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { const vertex: GPUVertexState = {
console.error(gl.getShaderInfoLog(shader)); module,
throw new Error("Shader compile failed"); entryPoint: "vs_main",
} buffers: [{
arrayStride: VERTEX_STRIDE,
attributes: [
{ shaderLocation: 0, offset: 0, format: "float32x3" },
{ shaderLocation: 1, offset: 12, format: "float32x2" },
{ shaderLocation: 2, offset: 20, format: "float32x4" },
],
}],
};
const fragment: GPUFragmentState = {
module,
entryPoint: "fs_main",
targets: [{
format: canvas_format,
blend: {
color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
alpha: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
},
}],
};
const multisample: GPUMultisampleState = { count: SAMPLE_COUNT };
return shader; pipeline_2d = device.createRenderPipeline({
layout,
vertex,
fragment,
multisample,
primitive: { topology: "triangle-list", cullMode: "none" },
depthStencil: { format: DEPTH_FORMAT, depthWriteEnabled: false, depthCompare: "always" },
});
pipeline_3d = device.createRenderPipeline({
layout,
vertex,
fragment,
multisample,
primitive: { topology: "triangle-list", cullMode: "back", frontFace: "ccw" },
depthStencil: {
format: DEPTH_FORMAT,
depthWriteEnabled: true,
depthCompare: "less-equal",
// same as the old polygonOffset(1, 1)
depthBias: 1,
depthBiasSlopeScale: 1,
},
});
} }
function create_program(vs_src: string, fs_src: string) { function create_stream_buffer(size: number) {
const vs = compile_shader(gl.VERTEX_SHADER, vs_src); return device.createBuffer({ size, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
const fs = compile_shader(gl.FRAGMENT_SHADER, fs_src); }
const program = gl.createProgram()!; function create_uniform_buffer(slots: number) {
gl.attachShader(program, vs); uniform_buffer = device.createBuffer({
gl.attachShader(program, fs); size: slots * UNIFORM_SLOT_SIZE,
gl.linkProgram(program); usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
uniform_bind_group = device.createBindGroup({
layout: uniform_layout,
entries: [{ binding: 0, resource: { buffer: uniform_buffer, size: 64 } }],
});
}
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { function get_texture_bind_group(texture: GPUTexture) {
console.error(gl.getProgramInfoLog(program)); let bind_group = texture_bind_groups.get(texture);
throw new Error("Program link failed"); if (!bind_group) {
bind_group = device.createBindGroup({
layout: texture_layout,
entries: [
{ binding: 0, resource: texture.createView() },
{ binding: 1, resource: sampler },
],
});
texture_bind_groups.set(texture, bind_group);
} }
return bind_group;
return program;
} }
function create_white_texture() { function create_white_texture() {
const tex = gl.createTexture(); const tex = create_texture(1, 1);
gl.bindTexture(gl.TEXTURE_2D, tex); device.queue.writeTexture({ texture: tex }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]);
const white_pixel = new Uint8Array([255, 255, 255, 255]);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, white_pixel);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
white_tex = tex; white_tex = tex;
} }
+66
View File
@@ -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);
}
+12 -23
View File
@@ -1,28 +1,17 @@
import { flush_batch, get_current_texture, gl, push_quad, push_quad_vertices, set_current_texture } from "./core.ts"; import {
create_texture,
device,
flush_batch,
get_current_texture,
push_quad,
push_quad_vertices,
set_current_texture,
} from "./core.ts";
import { Texture } from "./types.ts"; import { Texture } from "./types.ts";
export function load_texture(image: HTMLImageElement): Texture { export function load_texture(image: HTMLImageElement | ImageBitmap): Texture {
const texture = gl.createTexture(); const texture = create_texture(image.width, image.height);
device.queue.copyExternalImageToTexture({ source: image }, { texture }, [image.width, image.height]);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
image,
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return { return {
tex: texture, tex: texture,
+1 -1
View File
@@ -1,5 +1,5 @@
export interface Texture { export interface Texture {
tex: WebGLTexture; tex: GPUTexture;
width: number; width: number;
height: number; height: number;
} }
+9
View File
@@ -0,0 +1,9 @@
import { SpriteRegion } from "$/common/constants.ts";
import { AssetManager } from "./assets.ts";
type TexturesInfo = Record<string, SpriteRegion>;
export function get_sprite_region(id: string): SpriteRegion {
const textures_info = AssetManager.instance.get<TexturesInfo>("bworld:textures_info");
return textures_info?.[id] ?? { x: 0, y: 0 };
}
-44
View File
@@ -1,44 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { ClientWorld } from "../client_world.ts";
import { Dimension } from "../components/dimension.ts";
import { TICK_DELTA } from "$/common/constants.ts";
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
export class DimensionLogicSystem extends System {
update(world: ClientWorld, delta: number): void {
const dimension = world.dimension;
dimension.second_timer += delta;
dimension.tick_timer += delta;
if (dimension.second_timer >= 1) {
this.handle_second(world, dimension);
}
if (dimension.tick_timer >= TICK_DELTA) {
this.handle_tick(world, dimension);
}
}
handle_second(world: ClientWorld, dimension: Dimension) {
for (const chunk of dimension.chunks) {
for (const tickable of chunk.blocks_data) {
const tile_info = EverythingRegistry.get<BlockRegistry>("blocks", tickable.id);
if (tile_info && tile_info.on_second) {
tile_info.on_second(world.dimension, tickable, dimension.second_timer);
}
}
}
dimension.second_timer = 0;
}
handle_tick(world: ClientWorld, dimension: Dimension) {
for (const chunk of dimension.chunks) {
for (const tickable of chunk.blocks_data) {
const tile_info = EverythingRegistry.get<BlockRegistry>("blocks", tickable.id);
if (tile_info && tile_info.on_tick) {
tile_info.on_tick(world.dimension, tickable, dimension.tick_timer);
}
}
}
dimension.tick_timer = 0;
}
}
+122
View File
@@ -0,0 +1,122 @@
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";
import { Container, ItemStack } from "$/common/inventory.ts";
import { PlayerComponent } from "../player.ts";
import { GuiContainer } from "../gui/gui_container.ts";
import { show_fatal_error } from "../fatal.ts";
const MOVE_SEND_INTERVAL = 1 / 10;
const REMOTE_PLAYER_SMOOTHING = 12;
export class NetworkSystem extends System {
move_timer = 0;
update(world: ClientWorld, delta: number): void {
const connection = world.connection;
const [local_player] = world.get_tag("player")!;
const player_component = local_player.get(PlayerComponent)!;
const inventories = player_component.inventories;
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, message.state);
world.dimension.apply_change(message.x, message.y, message.z, message.id, message.state);
break;
case "chat":
world.add_chat(message.from ? `<${message.from}> ${message.text}` : message.text);
break;
case "container": {
let container = message.container === "screen"
? inventories.screen
: inventories[message.container];
if (message.container === "screen" && container?.size !== message.items.length) {
container = inventories.screen = new Container(message.items.length);
}
container?.load(message.items);
break;
}
case "cursor":
inventories.cursor.item = message.item ? ItemStack.from_data(message.item) : undefined;
break;
case "open_screen": {
// replace anything open locally without telling the server, it just opened this one
player_component.screens.length = 0;
const size = Math.max(0, ...message.layout.slots.map((slot) => slot.index + 1));
inventories.screen = new Container(size);
player_component.screens.push(
new GuiContainer(inventories, (m) => connection.send(m), message.layout, message.properties),
);
break;
}
case "screen_properties": {
const screen = player_component.screens.at(-1);
if (screen instanceof GuiContainer) {
screen.properties = message.properties;
}
break;
}
case "teleport": {
const position = local_player.get(Position)!;
position.x = message.x;
position.y = message.y;
position.z = message.z;
break;
}
case "close_screen":
// closed by the server (the block broke), it already put everything back
player_component.screens = player_component.screens.filter((s) => !(s instanceof GuiContainer));
inventories.screen = undefined;
break;
}
}
connection.incoming.length = 0;
if (connection.closed) {
show_fatal_error("Lost connection to the server");
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,
});
}
}
}
+59 -55
View File
@@ -9,6 +9,8 @@ import { PlayerComponent } from "../player.ts";
import { GuiPlayerInventory } from "../gui/gui_player_inventory.ts"; import { GuiPlayerInventory } from "../gui/gui_player_inventory.ts";
import { CollisionCuboid } from "../components/collision.ts"; import { CollisionCuboid } from "../components/collision.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { AIR, FACE_OFFSETS } from "$/common/constants.ts";
import { ClientMessage } from "$/common/protocol.ts";
import { GuiChat } from "../gui/gui_chat.ts"; import { GuiChat } from "../gui/gui_chat.ts";
import { GuiInventoryScreen } from "../gui/gui_screen.ts"; import { GuiInventoryScreen } from "../gui/gui_screen.ts";
@@ -24,6 +26,7 @@ export class PlayerControlsSystem extends System {
const player_component = player.get(PlayerComponent)!; const player_component = player.get(PlayerComponent)!;
const position = player.get(Position)!; const position = player.get(Position)!;
const camera = player.get(Camera)!; const camera = player.get(Camera)!;
const send = (message: ClientMessage) => world.connection.send(message);
if (player_component.screens.length === 0) { if (player_component.screens.length === 0) {
let input_x = 0; let input_x = 0;
@@ -71,7 +74,7 @@ export class PlayerControlsSystem extends System {
if (InputManager.is_key_pressed(controls.open_inventory)) { if (InputManager.is_key_pressed(controls.open_inventory)) {
if (player_component.screens.length === 0) { if (player_component.screens.length === 0) {
player_component.screens.push(new GuiPlayerInventory(player_component.player_inventory)); player_component.screens.push(new GuiPlayerInventory(player_component.inventories, send));
} else if (player_component.screens.at(-1) instanceof GuiInventoryScreen) { } else if (player_component.screens.at(-1) instanceof GuiInventoryScreen) {
player_component.pop_screen(); player_component.pop_screen();
} }
@@ -119,82 +122,83 @@ export class PlayerControlsSystem extends System {
} }
const block = world.dimension.get_looked_block(world.dimension, camera); const block = world.dimension.get_looked_block(world.dimension, camera);
const inventories = player_component.inventories;
const hotbar_slot = inventories.inventory.get_slot(inventories.hotbar_selected);
const holding_item_info = EverythingRegistry.get<ItemRegistry>("items", hotbar_slot.type_id ?? "");
if (block && player_component.screens.length === 0) { if (block && player_component.screens.length === 0) {
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", block.block)!; const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", block.block)!;
if (InputManager.is_mouse_pressed(0)) {
// for mods' on_click, breaking itself is timed here and sent when done
send({ type: "hit_block", x: block.x, y: block.y, z: block.z });
}
if (player_component.breaking_block) { if (player_component.breaking_block) {
player_component.breaking_block = { x: block.x, y: block.y, z: block.z }; player_component.breaking_block = { x: block.x, y: block.y, z: block.z };
player_component.break_progress_max = block_info.toughness ?? 9999; player_component.break_progress_max = block_info.toughness ?? 9999;
player_component.break_progress += delta; let multiplier = 1;
if (holding_item_info?.tool_type === block_info.tool_to_break) {
multiplier *= 2;
}
player_component.break_progress += delta * multiplier;
if (player_component.break_progress >= player_component.break_progress_max) { if (player_component.break_progress >= player_component.break_progress_max) {
// show it right away, the server decides drops and corrects us if it disagrees
world.dimension.break_block(block.x, block.y, block.z); world.dimension.break_block(block.x, block.y, block.z);
send({ type: "break_block", x: block.x, y: block.y, z: block.z });
player_component.break_progress_max = 0; player_component.break_progress_max = 0;
player_component.break_progress = 0; player_component.break_progress = 0;
} }
} else if (InputManager.is_mouse_pressed(2)) { } else if (InputManager.is_mouse_pressed(2)) {
// interact if possible send({ type: "use_block", x: block.x, y: block.y, z: block.z, face: block.face });
const interacted = block_info?.on_interact?.(world.dimension, {
x: block.x, // guess that it places the held block, unless the block does something when used
y: block.y, const offset = FACE_OFFSETS[block.face];
z: block.z, const target = { x: block.x + offset.x, y: block.y + offset.y, z: block.z + offset.z };
id: block_info.id, const target_id = world.dimension.get_block(target.x, target.y, target.z);
}); const replaceable = target_id === AIR ||
if (!interacted) { EverythingRegistry.get_by_id<BlockRegistry>("blocks", target_id)?.replaceable;
const inventory = player_component.player_inventory; // items with components might do something else on the server, like on_use
const hotbar_slot = inventory.container.get_slot(inventory.hotbar_selected); const place_id = holding_item_info?.components ? undefined : holding_item_info?.block_id;
if (hotbar_slot && hotbar_slot.has_item()) { if (!block_info.interactive && place_id && replaceable) {
const item = hotbar_slot.get_item()!; world.dimension.add_block({ ...target, id: place_id });
const item_info = EverythingRegistry.get<ItemRegistry>("items", item.type_id); hotbar_slot.amount = hotbar_slot.amount! - 1;
if (item_info && item_info.block_id) {
const FACE_OFFSETS = {
top: { x: 0, y: 1, z: 0 },
bottom: { x: 0, y: -1, z: 0 },
north: { x: 0, y: 0, z: -1 },
south: { x: 0, y: 0, z: 1 },
west: { x: -1, y: 0, z: 0 },
east: { x: 1, y: 0, z: 0 },
};
const offset = FACE_OFFSETS[block.face];
world.dimension.add_block({
x: block.x + offset.x,
y: block.y + offset.y,
z: block.z + offset.z,
id: item_info.block_id,
});
hotbar_slot.amount! -= 1;
}
}
} }
} }
} else {
player_component.breaking_block = undefined;
player_component.break_progress_max = 0;
player_component.break_progress = 0;
if (!block && player_component.screens.length === 0 && InputManager.is_mouse_pressed(2)) {
send({ type: "use_item" });
}
} }
if (player_component.screens.length === 0) { if (player_component.screens.length === 0) {
const player_inventory = player_component.player_inventory; const previous = inventories.hotbar_selected;
const scroll = InputManager.get_wheel_delta(); const scroll = InputManager.get_wheel_delta();
if (scroll > 0) { if (scroll > 0) {
player_inventory.hotbar_selected = Math.min(8, player_inventory.hotbar_selected + 1); inventories.hotbar_selected = Math.min(8, inventories.hotbar_selected + 1);
} else if (scroll < 0) { } else if (scroll < 0) {
player_inventory.hotbar_selected = Math.max(0, player_inventory.hotbar_selected - 1); inventories.hotbar_selected = Math.max(0, inventories.hotbar_selected - 1);
} }
if (InputManager.is_key_pressed(controls.hotbar_1)) { const hotbar_keys = [
player_inventory.hotbar_selected = 0; controls.hotbar_1,
} else if (InputManager.is_key_pressed(controls.hotbar_2)) { controls.hotbar_2,
player_inventory.hotbar_selected = 1; controls.hotbar_3,
} else if (InputManager.is_key_pressed(controls.hotbar_3)) { controls.hotbar_4,
player_inventory.hotbar_selected = 2; controls.hotbar_5,
} else if (InputManager.is_key_pressed(controls.hotbar_4)) { controls.hotbar_6,
player_inventory.hotbar_selected = 3; controls.hotbar_7,
} else if (InputManager.is_key_pressed(controls.hotbar_5)) { controls.hotbar_8,
player_inventory.hotbar_selected = 4; controls.hotbar_9,
} else if (InputManager.is_key_pressed(controls.hotbar_6)) { ];
player_inventory.hotbar_selected = 5; const pressed = hotbar_keys.findIndex((key) => InputManager.is_key_pressed(key));
} else if (InputManager.is_key_pressed(controls.hotbar_7)) { if (pressed !== -1) {
player_inventory.hotbar_selected = 6; inventories.hotbar_selected = pressed;
} else if (InputManager.is_key_pressed(controls.hotbar_8)) { }
player_inventory.hotbar_selected = 7;
} else if (InputManager.is_key_pressed(controls.hotbar_9)) { if (inventories.hotbar_selected !== previous) {
player_inventory.hotbar_selected = 8; send({ type: "select_slot", slot: inventories.hotbar_selected });
} }
} }
} }
+10 -3
View File
@@ -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()) {
@@ -56,9 +61,11 @@ export class RenderSystem extends System {
const player_component = entity.get(PlayerComponent); const player_component = entity.get(PlayerComponent);
if (player_component) { if (player_component) {
render_player_hotbar(player_component.player_inventory); render_player_hotbar(player_component.inventories);
render_player_crosshair(); render_player_crosshair();
} }
} }
render_chat_log(world, false);
} }
} }
+5 -76
View File
@@ -1,88 +1,17 @@
import { EverythingRegistry } from "$/common/everything_registry.ts";
import { Chunk, Dimension } from "$/client/components/dimension.ts"; import { Chunk, Dimension } from "$/client/components/dimension.ts";
import { Camera } from "$/client/components/camera.ts"; import { Camera } from "$/client/components/camera.ts";
import { AssetManager } from "$/client/assets.ts"; import { flush_buffer, set_current_texture } from "$/client/renderer/mod.ts";
import { flush_buffer, gl, set_current_texture } from "$/client/renderer/mod.ts";
let gdimension: Dimension; export function render_dimension(dimension: Dimension, _camera: Camera) {
dimension.request_meshes();
const worker = new Worker(new URL("./workers/chunk_mesh_worker.js", import.meta.url), {
type: "module",
});
worker.onmessage = (event) => {
const { opaque_vertices, opaque_count, transparent_vertices, transparent_count, chunk_x, chunk_z } = event.data;
const chunk = gdimension.get_chunk(chunk_x, chunk_z);
if (!chunk) {
console.error("bad");
return;
}
gdimension.delete_chunk_mesh(chunk);
chunk.dirty = false;
chunk.opaque_vertex_buffer = gl.createBuffer();
chunk.opaque_vertex_count = opaque_count / 9;
gl.bindBuffer(gl.ARRAY_BUFFER, chunk.opaque_vertex_buffer);
gl.bufferData(
gl.ARRAY_BUFFER,
opaque_vertices.subarray(0, opaque_count),
gl.STATIC_DRAW,
);
chunk.transparent_vertex_buffer = gl.createBuffer();
chunk.transparent_vertex_count = transparent_count / 9;
gl.bindBuffer(gl.ARRAY_BUFFER, chunk.transparent_vertex_buffer);
gl.bufferData(
gl.ARRAY_BUFFER,
transparent_vertices.subarray(0, transparent_count),
gl.STATIC_DRAW,
);
};
function strip_functions<T extends Record<string, any>>(obj: T) {
const out: any = {};
for (const k in obj) {
const v = obj[k];
if (typeof v === "function") continue;
if (v && typeof v === "object") {
out[k] = strip_functions(v);
} else {
out[k] = v;
}
}
return out;
}
export function render_dimension(dimension: Dimension, camera: Camera) {
gdimension = dimension;
set_current_texture(dimension.image.tex); set_current_texture(dimension.image.tex);
for (const chunk of dimension.chunks) { for (const chunk of dimension.chunks.values()) {
if (chunk.dirty && chunk.generated) {
const padded_chunk = dimension.create_padded_chunk(chunk);
worker.postMessage({
chunk_x: chunk.x,
chunk_z: chunk.z,
padded_chunk,
blocks_registry: strip_functions(EverythingRegistry.get_registry("blocks")),
textures_info: AssetManager.instance.get("bworld:textures_info"),
image: { width: dimension.image.width, height: dimension.image.height },
}, [padded_chunk.buffer]);
chunk.dirty = false;
}
}
for (const chunk of dimension.chunks) {
render_chunk_opaque(chunk); render_chunk_opaque(chunk);
} }
for (const chunk of dimension.chunks) { for (const chunk of dimension.chunks.values()) {
render_chunk_transparent(chunk); render_chunk_transparent(chunk);
} }
} }
+53
View File
@@ -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;
}
}
+7 -7
View File
@@ -1,6 +1,6 @@
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts"; import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
import { AssetManager } from "$/client/assets.ts"; import { AssetManager } from "$/client/assets.ts";
import { PlayerInventory } from "../../inventory.ts"; import { ClientInventories } from "../../inventory.ts";
import { draw_item, draw_nine_slice } from "./render_utils.ts"; import { draw_item, draw_nine_slice } from "./render_utils.ts";
import { import {
canvas, canvas,
@@ -14,11 +14,11 @@ import {
Texture, Texture,
} from "$/client/renderer/mod.ts"; } from "$/client/renderer/mod.ts";
import { PlayerComponent } from "../../player.ts"; import { PlayerComponent } from "../../player.ts";
import { get_sprite_region } from "../../../common/utils.ts"; import { get_sprite_region } from "$/client/sprites.ts";
const PADDING = 10; const PADDING = 10;
export function render_player_hotbar(player_inventory: PlayerInventory) { export function render_player_hotbar(inventories: ClientInventories) {
const ui = AssetManager.instance.get<Texture>("bworld:ui"); const ui = AssetManager.instance.get<Texture>("bworld:ui");
const hotbar_width = PADDING * 2 + SLOT_SIZE * 9; const hotbar_width = PADDING * 2 + SLOT_SIZE * 9;
@@ -46,8 +46,8 @@ export function render_player_hotbar(player_inventory: PlayerInventory) {
for (let index = 0; index < 9; index += 1) { for (let index = 0; index < 9; index += 1) {
draw_nine_slice( draw_nine_slice(
ui, ui,
player_inventory.hotbar_selected === index ? 19 * 16 : 160 + 32, inventories.hotbar_selected === index ? 19 * 16 : 160 + 32,
player_inventory.hotbar_selected === index ? 16 : 0, inventories.hotbar_selected === index ? 16 : 0,
16, 16,
16, 16,
4, 4,
@@ -62,7 +62,7 @@ export function render_player_hotbar(player_inventory: PlayerInventory) {
} }
for (let index = 0; index < 9; index += 1) { for (let index = 0; index < 9; index += 1) {
const item = player_inventory.container.get_item(index); const item = inventories.inventory.get_item(index);
if (item) { if (item) {
draw_item(item, x + PADDING + index * SLOT_SIZE, y + PADDING); draw_item(item, x + PADDING + index * SLOT_SIZE, y + PADDING);
} }
@@ -102,7 +102,7 @@ export function render_player_breaking(player_component: PlayerComponent) {
if (Number.isNaN(break_sprite)) { if (Number.isNaN(break_sprite)) {
return; return;
} }
const region = get_sprite_region(`bworld:break_${break_sprite}`); const region = get_sprite_region(`engine:break_${break_sprite}`);
for (const fn of FACE_FUNCTIONS) { for (const fn of FACE_FUNCTIONS) {
fn( fn(
+6 -6
View File
@@ -1,8 +1,8 @@
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts"; import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
import { get_sprite_region } from "$/common/utils.ts"; import { get_sprite_region } from "$/client/sprites.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { AssetManager } from "$/client/assets.ts"; import { AssetManager } from "$/client/assets.ts";
import { ItemStack } from "../../inventory.ts"; import { ItemStack } from "$/common/inventory.ts";
import { draw_text, draw_texture_region, draw_texture_region_skewed, Texture } from "$/client/renderer/mod.ts"; import { draw_text, draw_texture_region, draw_texture_region_skewed, Texture } from "$/client/renderer/mod.ts";
export function draw_nine_slice( export function draw_nine_slice(
@@ -231,7 +231,7 @@ export function draw_item(item: ItemStack, x: number, y: number) {
// ey its not a bad name // ey its not a bad name
function draw_item_item(item: ItemStack, item_info: ItemRegistry, x: number, y: number) { function draw_item_item(item: ItemStack, item_info: ItemRegistry, x: number, y: number) {
let texture_id = "bworld:missing"; let texture_id = "engine:missing";
if (typeof item_info?.texture_id === "string") { if (typeof item_info?.texture_id === "string") {
texture_id = item_info.texture_id; texture_id = item_info.texture_id;
} else if (typeof item_info?.texture_id === "function") { } else if (typeof item_info?.texture_id === "function") {
@@ -256,9 +256,9 @@ function draw_item_block(_item: ItemStack, item_info: ItemRegistry, x: number, y
if (!block_info) throw new Error(`no textures for ${item_info.block_id}`); if (!block_info) throw new Error(`no textures for ${item_info.block_id}`);
let front_texture = "bworld:missing"; let front_texture = "engine:missing";
let top_texture = "bworld:missing"; let top_texture = "engine:missing";
let left_texture = "bworld:missing"; let left_texture = "engine:missing";
const textures = block_info.textures; const textures = block_info.textures;
+34 -14
View File
@@ -18,27 +18,47 @@ export class WorldGenerationSystem extends System {
const player_chunk_x = Math.floor(position.x / CHUNK_SIZE); const player_chunk_x = Math.floor(position.x / CHUNK_SIZE);
const player_chunk_z = Math.floor(position.z / CHUNK_SIZE); const player_chunk_z = Math.floor(position.z / CHUNK_SIZE);
const render_distance = player_component.render_distance; // one extra ring gets generated so the edge of the render distance has neighbors to mesh against
const load_distance = player_component.render_distance + 1;
const out_of_range = (x: number, z: number) =>
Math.max(Math.abs(x - player_chunk_x), Math.abs(z - player_chunk_z)) > load_distance;
for (const chunk of dimension.chunks) { // collect first, deleting from the map while iterating it skips entries
if (Math.abs(chunk.x - player_chunk_x) > render_distance) { const to_unload = [];
dimension.unload_chunk(chunk.x, chunk.z); for (const chunk of dimension.chunks.values()) {
if (out_of_range(chunk.x, chunk.z)) {
to_unload.push(chunk);
} }
if (Math.abs(chunk.z - player_chunk_z) > render_distance) { }
dimension.unload_chunk(chunk.x, chunk.z); for (const chunk of to_unload) {
dimension.unload_chunk(chunk.x, chunk.z);
}
for (const pending of [...dimension.pending_generation.values()]) {
if (out_of_range(pending.x, pending.z)) {
dimension.cancel_chunk_request(pending.x, pending.z);
} }
} }
for (let i = player_chunk_x - render_distance; i <= player_chunk_x + render_distance; i += 1) { // dont queue up the whole area at once, so walking somewhere new gets the close chunks first
for (let j = player_chunk_z - render_distance; j <= player_chunk_z + render_distance; j += 1) { const max_in_flight = dimension.workers.size * 2;
const maybe_chunk = dimension.chunks.find((chunk) => chunk.x === i && chunk.z === j); if (dimension.pending_generation.size >= max_in_flight) {
if (!maybe_chunk || !maybe_chunk.generated) { return;
console.log("loading chunk", i, j); }
dimension.load_chunk(i, j);
// only generate one chunk per frame const missing: { x: number; z: number; distance: number }[] = [];
return; for (let x = player_chunk_x - load_distance; x <= player_chunk_x + load_distance; x += 1) {
for (let z = player_chunk_z - load_distance; z <= player_chunk_z + load_distance; z += 1) {
if (!dimension.is_generated(x, z) && !dimension.is_generating(x, z)) {
const dx = x - player_chunk_x;
const dz = z - player_chunk_z;
missing.push({ x, z, distance: dx * dx + dz * dz });
} }
} }
} }
missing.sort((a, b) => a.distance - b.distance);
for (const chunk of missing.slice(0, max_in_flight - dimension.pending_generation.size)) {
dimension.request_chunk(chunk.x, chunk.z);
}
} }
} }
+40
View File
@@ -0,0 +1,40 @@
import type { BlockRegistry } from "$/common/everything_registry.ts";
import type { SpriteRegion } from "$/common/constants.ts";
import type { OreJson } from "$/common/mod_data.ts";
// messages between the main thread and the chunk workers
export type ToChunkWorker =
| {
type: "init";
// the blocks registry without its functions, indexed by numeric id
blocks_registry: BlockRegistry[];
block_ids: Record<string, number>;
textures_info: Record<string, SpriteRegion>;
image: { width: number; height: number };
// mods' worldgen scripts and ores, so generation matches the server
worldgen_scripts: { mod: string; url: string }[];
ores: OreJson[];
}
| { type: "generate"; chunk_x: number; chunk_z: number; seed: string }
| { type: "mesh"; chunk_x: number; chunk_z: number; version: number; padded_chunk: Uint32Array };
export type FromChunkWorker =
| {
type: "generated";
chunk_x: number;
chunk_z: number;
blocks: Uint32Array;
// blocks that landed in other chunks (tree leaves), flattened as x, y, z, numeric id
spills: Int32Array;
}
| {
type: "meshed";
chunk_x: number;
chunk_z: number;
version: number;
opaque_vertices: Float32Array;
opaque_count: number;
transparent_vertices: Float32Array;
transparent_count: number;
};
@@ -3,6 +3,10 @@
import type { BlockRegistry } from "$/common/everything_registry.ts"; import type { BlockRegistry } from "$/common/everything_registry.ts";
import type { SpriteRegion } from "$/common/constants.ts"; import type { SpriteRegion } from "$/common/constants.ts";
import type { Texture } from "../renderer/types.ts"; import type { Texture } from "../renderer/types.ts";
import type { FromChunkWorker, ToChunkWorker } from "./chunk_messages.ts";
import { generate_raw_chunk, WorldgenSetup } from "$/common/generation.ts";
import { load_worldgen } from "$/common/worldgen_loader.ts";
import { default_block_value } from "$/common/utils.ts";
const pad = 0.5; const pad = 0.5;
@@ -260,22 +264,69 @@ const FACE_PUSHING_FUNCTIONS = {
right: push_right_face, right: push_right_face,
} as const; } as const;
self.onmessage = (event) => { let blocks_registry: BlockRegistry[] = [];
const { chunk_x, chunk_z, padded_chunk, blocks_registry, textures_info, image } = event.data; let block_ids: Record<string, number> = {};
const [opaque_vertices, opaque_count, transparent_vertices, transparent_count] = make_chunk_mesh( let textures_info: TexturesInfo = {};
chunk_x, let image: Texture;
chunk_z, let worldgen: WorldgenSetup | undefined;
padded_chunk, // numeric id to what a generated block stores, with its default states. matches the server
blocks_registry, let default_values: number[] = [];
textures_info, // generating has to wait for mods' worldgen scripts, or this worker's terrain wouldn't match the server's
image, let worldgen_ready: Promise<void> = Promise.resolve();
);
self.postMessage( self.onmessage = async (event: MessageEvent<ToChunkWorker>) => {
{ opaque_vertices, opaque_count, transparent_vertices, transparent_count, chunk_x, chunk_z }, const message = event.data;
[opaque_vertices.buffer, transparent_vertices.buffer], switch (message.type) {
); case "init":
blocks_registry = message.blocks_registry;
block_ids = message.block_ids;
textures_info = message.textures_info;
image = message.image as Texture;
default_values = blocks_registry.map((block, nid) => default_block_value(nid, block));
worldgen_ready = load_worldgen(message.worldgen_scripts, message.ores).then((setup) => {
worldgen = setup;
});
break;
case "generate":
await worldgen_ready;
generate(message.chunk_x, message.chunk_z, message.seed);
break;
case "mesh": {
const [opaque_vertices, opaque_count, transparent_vertices, transparent_count] = make_chunk_mesh(
message.chunk_x,
message.chunk_z,
message.padded_chunk,
blocks_registry,
textures_info,
image,
);
post(
{
type: "meshed",
chunk_x: message.chunk_x,
chunk_z: message.chunk_z,
version: message.version,
opaque_vertices,
opaque_count,
transparent_vertices,
transparent_count,
},
[opaque_vertices.buffer, transparent_vertices.buffer],
);
break;
}
}
}; };
function post(message: FromChunkWorker, transfer: Transferable[]) {
self.postMessage(message, transfer);
}
function generate(chunk_x: number, chunk_z: number, seed: string) {
const { blocks, spills } = generate_raw_chunk(chunk_x, chunk_z, seed, block_ids, worldgen, default_values);
post({ type: "generated", chunk_x, chunk_z, blocks, spills }, [blocks.buffer, spills.buffer]);
}
function make_chunk_mesh( function make_chunk_mesh(
chunk_x: number, chunk_x: number,
chunk_z: number, chunk_z: number,
@@ -314,12 +365,12 @@ function make_chunk_mesh(
const block_info = blocks_registry[block_nid]; const block_info = blocks_registry[block_nid];
const texture_ids = { const texture_ids = {
top: "bworld:missing", top: "engine:missing",
bottom: "bworld:missing", bottom: "engine:missing",
front: "bworld:missing", front: "engine:missing",
back: "bworld:missing", back: "engine:missing",
left: "bworld:missing", left: "engine:missing",
right: "bworld:missing", right: "engine:missing",
}; };
const textures = block_info.textures; const textures = block_info.textures;
@@ -1,6 +1,4 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; import { EverythingRegistry } from "$/common/everything_registry.ts";
import { ItemStack } from "../inventory.ts";
import { PlayerComponent } from "../player.ts";
interface CropsRegistry { interface CropsRegistry {
total_stages: number; total_stages: number;
+14
View File
@@ -18,3 +18,17 @@ 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;
// where a block placed against each face of another block goes
export const FACE_OFFSETS: Record<Faces, { x: number; y: number; z: number }> = {
top: { x: 0, y: 1, z: 0 },
bottom: { x: 0, y: -1, z: 0 },
north: { x: 0, y: 0, z: -1 },
south: { x: 0, y: 0, z: 1 },
west: { x: -1, y: 0, z: 0 },
east: { x: 1, y: 0, z: 0 },
};
+27 -10
View File
@@ -1,11 +1,13 @@
import { Block, Dimension } from "$/client/components/dimension.ts"; import type { ItemStack } from "./inventory.ts";
import { ItemStack } from "$/client/inventory.ts";
export class EverythingRegistry { export class EverythingRegistry {
static #key_to_id = new Map<string, Map<string, number>>(); static #key_to_id = new Map<string, Map<string, number>>();
static #id_to_value = new Map<string, unknown[]>(); static #id_to_value = new Map<string, unknown[]>();
static register<T>(registry: string, key: string, value: T): T { static register<T>(registry: string, key: string, value: T): T {
if (this.#key_to_id.get(registry)?.has(key)) {
throw new Error(`${key} is already registered in ${registry}`);
}
if (!this.#key_to_id.has(registry)) { if (!this.#key_to_id.has(registry)) {
this.#key_to_id.set(registry, new Map()); this.#key_to_id.set(registry, new Map());
this.#id_to_value.set(registry, []); this.#id_to_value.set(registry, []);
@@ -49,6 +51,18 @@ export class EverythingRegistry {
static get_registry<T>(registry: string): T[] { static get_registry<T>(registry: string): T[] {
return this.#id_to_value.get(registry) as T[]; return this.#id_to_value.get(registry) as T[];
} }
// forget everything, for tests that load mods more than once
static clear() {
this.#key_to_id.clear();
this.#id_to_value.clear();
}
// [key, value] pairs in registration order
static entries<T>(registry: string): [string, T][] {
const values = this.#id_to_value.get(registry) ?? [];
return [...(this.#key_to_id.get(registry) ?? [])].map(([key, id]) => [key, values[id] as T]);
}
} }
interface TextureSideTopBottom { interface TextureSideTopBottom {
@@ -94,12 +108,13 @@ export interface BlockRegistry {
states?: BlockStateDefinition[]; states?: BlockStateDefinition[];
variants?: Record<string, BlockStateVariant>; variants?: Record<string, BlockStateVariant>;
on_create?(dimension: Dimension, block: Block): void; // right clicking it does something instead of placing a block, clients don't predict placing against it.
on_break?(dimension: Dimension, block: Block): void; // behavior runs on the server, see server/game/blocks.ts
on_click?(dimension: Dimension, block: Block): void; interactive?: boolean;
on_interact?(dimension: Dimension, block: Block): boolean; // placing a block into it replaces it, like water
on_tick?(dimension: Dimension, block: Block, tick_delta: number): void; replaceable?: boolean;
on_second?(dimension: Dimension, block: Block, second_delta: number): void; // custom components from mods and their params, the server runs them
components?: Record<string, unknown>;
compiled_states?: CompiledStateDefinition[]; compiled_states?: CompiledStateDefinition[];
} }
@@ -107,8 +122,10 @@ export interface BlockRegistry {
export interface ItemRegistry<T = unknown | undefined> { export interface ItemRegistry<T = unknown | undefined> {
texture_id: string | ((item: ItemStack<T>) => string); texture_id: string | ((item: ItemStack<T>) => string);
block_id?: string; block_id?: string;
tool_type?: string;
place?(dimension: Dimension, block: Block): void; max_stack?: number;
lore?: string;
components?: Record<string, unknown>;
on_create?(item: ItemStack<T>): void; on_create?(item: ItemStack<T>): void;
+485
View File
@@ -0,0 +1,485 @@
import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D, NoiseFunction3D } from "@paulaboks/rng";
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts";
import type { FeatureChunk } from "$/common/mod_api/worldgen.ts";
import type { OreJson } from "$/common/mod_data.ts";
// generation runs in a worker now, so it only needs somewhere to put blocks
export interface BlockSink {
add_block(block: { x: number; y: number; z: number; id: string }): void;
// the surface height and biome of each column, for later passes
set_column?(x: number, z: number, height: number, biome: string): void;
}
type Biome =
| "desert"
| "plains"
| "forest"
| "jungle"
| "tundra"
| "taiga"
| "snow"
| "savanna"
| "swamp";
type OreDef = {
id: string;
min_y: number;
max_y: number;
scale: number;
threshold: number;
};
const ORES: OreDef[] = [
{ id: "bworld:coal_ore", min_y: 20, max_y: 120, scale: 0.05, threshold: 0.55 },
{ id: "bworld:copper_ore", min_y: 10, max_y: 80, scale: 0.06, threshold: 0.6 },
{ id: "bworld:tin_ore", min_y: 5, max_y: 60, scale: 0.06, threshold: 0.62 },
{ id: "bworld:iron_ore", min_y: 5, max_y: 50, scale: 0.05, threshold: 0.65 },
{ id: "bworld:gold_ore", min_y: 0, max_y: 30, scale: 0.04, threshold: 0.7 },
];
function get_biome(temp: number, moisture: number): Biome {
if (temp > 0.6) {
if (moisture < -0.2) {
return "desert";
}
if (moisture > 0.4) {
return "jungle";
}
return "savanna";
}
if (temp > 0) {
if (moisture > 0.5) {
return "swamp";
}
if (moisture > 0) {
return "forest";
}
return "plains";
}
if (temp > -0.5) {
return "taiga";
}
return "tundra";
}
function get_surface_block(biome: Biome) {
if (biome === "desert") {
return "bworld:sand";
} else if (biome === "tundra") {
return "bworld:snow";
}
return "bworld:grass";
}
function biome_height_modifier(biome: Biome) {
if (biome === "desert") {
return 0.2;
}
if (biome === "plains") {
return 0.4;
}
if (biome === "forest") {
return 0.5;
}
if (biome === "jungle") {
return 0.45;
}
if (biome === "taiga") {
return 0.55;
}
if (biome === "tundra") {
return 0.35;
}
if (biome === "savanna") {
return 0.4;
}
if (biome === "swamp") {
return 0.35;
}
return 0.4;
}
function fractal_noise(noise: NoiseFunction2D, x: number, y: number, octaves = 2) {
let value = 0;
let amp = 1;
let freq = 1;
let max = 0;
for (let i = 0; i < octaves; i++) {
value += noise(x * freq, y * freq) * amp;
max += amp;
amp *= 0.5;
freq *= 2;
}
return value / max;
}
function get_terrain_height(base: number, biome: Biome, x: number, z: number, noise: NoiseFunction2D) {
const biomeMod = biome_height_modifier(biome);
const main = fractal_noise(noise, x * 0.003, z * 0.003) * 15;
const detail = fractal_noise(noise, x * 0.01, z * 0.01) * 3;
return Math.floor(base + biomeMod * 20 + main + detail);
}
function can_place_tree(tree_map: boolean[][], local_x: number, local_z: number) {
const TREE_SPACING = 4;
for (let dx = -TREE_SPACING; dx <= TREE_SPACING; dx++) {
for (let dz = -TREE_SPACING; dz <= TREE_SPACING; dz++) {
const nx = local_x + dx;
const nz = local_z + dz;
if (nx >= 0 && nx < CHUNK_SIZE && nz >= 0 && nz < CHUNK_SIZE && tree_map[nx][nz]) {
return false;
}
}
}
return true;
}
function place_tree(dimension: BlockSink, rng: Alea, x: number, y: number, z: number, biome: Biome) {
const height = Math.floor(rng.next() * 3) + (biome === "jungle" ? 8 : 4);
const trunk_block = "bworld:log";
const leaves_block = "bworld:leaves";
for (let i = 0; i < height; i++) {
dimension.add_block({ x, y: y + i, z, id: trunk_block });
}
for (let dx = -2; dx <= 2; dx++) {
for (let dz = -2; dz <= 2; dz++) {
for (let dy = -1; dy <= 1; dy++) {
if (Math.abs(dx) + Math.abs(dz) + Math.abs(dy) <= 3) {
dimension.add_block({
x: x + dx,
y: y + height + dy,
z: z + dz,
id: leaves_block,
});
}
}
}
}
}
const TREE_THRESHOLD: Record<Biome, number> = {
forest: 0.5,
jungle: 0.3,
taiga: 0.6,
plains: 0.95,
desert: 1,
tundra: 1,
savanna: 0.65,
swamp: 0.5,
snow: 0.8,
};
function should_place_tree(feature_noise: NoiseFunction2D, biome: Biome, x: number, z: number) {
const n = feature_noise(x * 0.1, z * 0.1);
return n > (TREE_THRESHOLD[biome] ?? 0.8);
}
interface SeedNoises {
height_noise: NoiseFunction2D;
temp_noise: NoiseFunction2D;
moisture_noise: NoiseFunction2D;
feature_noise: NoiseFunction2D;
ore_noises: NoiseFunction3D[];
}
// building the permutation tables is expensive, only do it once per seed
const noise_cache = new Map<string, SeedNoises>();
function get_noises(seed: string): SeedNoises {
let noises = noise_cache.get(seed);
if (!noises) {
noises = {
height_noise: create_noise_2d(new Alea(seed + "_height")),
temp_noise: create_noise_2d(new Alea(seed + "_temp")),
moisture_noise: create_noise_2d(new Alea(seed + "_moisture")),
feature_noise: create_noise_2d(new Alea(seed + "_feature")),
ore_noises: ORES.map((ore) => create_noise_3d(new Alea(seed + "_" + ore.id))),
};
noise_cache.set(seed, noises);
}
return noises;
}
export function generate_chunk(dimension: BlockSink, cx: number, cz: number, seed = "seed") {
const { height_noise, temp_noise, moisture_noise, feature_noise, ore_noises } = get_noises(seed);
// 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 terrain_scale = 0.01;
const tree_map: boolean[][] = Array.from({ length: CHUNK_SIZE }, () => Array(CHUNK_SIZE).fill(false));
for (let x = 0; x < CHUNK_SIZE; x++) {
for (let z = 0; z < CHUNK_SIZE; z++) {
const wx = cx * CHUNK_SIZE + x;
const wz = cz * CHUNK_SIZE + z;
const temp = temp_noise(wx * biome_scale, wz * biome_scale);
const moisture = moisture_noise(wx * biome_scale, wz * biome_scale);
const biome = get_biome(temp, moisture);
const height_noise_value = fractal_noise(height_noise, wx * terrain_scale, wz * terrain_scale);
const base_height = (height_noise_value + 1) * 15 + 50;
const height = get_terrain_height(base_height, biome, wx, wz, height_noise);
const surface_block = get_surface_block(biome);
dimension.set_column?.(wx, wz, height, `bworld:${biome}`);
for (let y = 0; y <= height; y++) {
let block = "bworld:stone";
if (y < height - 3) {
for (let i = 0; i < ORES.length; i++) {
const ore = ORES[i];
if (y >= ore.min_y && y <= ore.max_y) {
const noise = ore_noises[i](
wx * ore.scale,
y * ore.scale,
wz * ore.scale,
);
if (noise > ore.threshold) {
block = ore.id;
break;
}
}
}
}
if (y === height) {
block = surface_block;
} else if (y > height - 4) {
block = "bworld:dirt";
}
if (biome === "swamp" && y === height && rng.next() < 0.2) {
block = "bworld:water";
}
dimension.add_block({ x: wx, y, z: wz, id: block });
}
if (should_place_tree(feature_noise, biome, wx, wz) && can_place_tree(tree_map, x, z)) {
place_tree(dimension, rng, wx, height + 1, wz, biome);
tree_map[x][z] = true;
}
}
}
}
// noise the way mods get it: create_noise_2d(new Alea(seed + "_" + name)), the same as the base terrain's
const mod_noise_2d = new Map<string, NoiseFunction2D>();
const mod_noise_3d = new Map<string, NoiseFunction3D>();
export function named_noise_2d(seed: string, name: string): NoiseFunction2D {
const key = `${seed}_${name}`;
let noise = mod_noise_2d.get(key);
if (!noise) {
noise = create_noise_2d(new Alea(key));
mod_noise_2d.set(key, noise);
}
return noise;
}
export function named_noise_3d(seed: string, name: string): NoiseFunction3D {
const key = `${seed}_${name}`;
let noise = mod_noise_3d.get(key);
if (!noise) {
noise = create_noise_3d(new Alea(key));
mod_noise_3d.set(key, noise);
}
return noise;
}
// what mods add to generation, see "World generation" in MODS.md
export interface WorldgenSetup {
ores: OreJson[];
features: { id: string; generate: (chunk: FeatureChunk) => void }[];
}
export interface RawChunk {
// numeric block ids, only what this chunk generated itself
blocks: Uint32Array;
// blocks it generated in other chunks (tree leaves), flattened as x, y, z, numeric id
spills: Int32Array;
}
// features that threw, so each is only reported once
const failed_features = new Set<string>();
// generates one chunk on its own. neighbors' spills get merged in by whoever assembles the world:
// a chunk's own blocks always win and spills only fill air, so the result doesn't depend on load order
export function generate_raw_chunk(
chunk_x: number,
chunk_z: number,
seed: string,
block_ids: Record<string, number>,
worldgen?: WorldgenSetup,
// the value to store for each numeric id, with its default states. just the id when missing
default_values?: number[],
): RawChunk {
const blocks = new Uint32Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT);
const spills: number[] = [];
const heights = new Int32Array(CHUNK_AREA);
const biomes: string[] = new Array(CHUNK_AREA);
const set = (x: number, y: number, z: number, nid: number) => {
if (y < 0 || y >= CHUNK_HEIGHT) {
return;
}
nid = default_values?.[nid] ?? nid;
const block_chunk_x = Math.floor(x / CHUNK_SIZE);
const block_chunk_z = Math.floor(z / CHUNK_SIZE);
if (block_chunk_x !== chunk_x || block_chunk_z !== chunk_z) {
spills.push(x, y, z, nid);
return;
}
const lx = x - chunk_x * CHUNK_SIZE;
const lz = z - chunk_z * CHUNK_SIZE;
blocks[y * CHUNK_AREA + lz * CHUNK_SIZE + lx] = nid;
};
generate_chunk(
{
add_block(block) {
const nid = block_ids[block.id];
if (nid !== undefined) {
set(block.x, block.y, block.z, nid);
}
},
set_column(x, z, height, biome) {
const index = (z - chunk_z * CHUNK_SIZE) * CHUNK_SIZE + (x - chunk_x * CHUNK_SIZE);
heights[index] = height;
biomes[index] = biome;
},
},
chunk_x,
chunk_z,
seed,
);
if (worldgen) {
generate_ores(blocks, chunk_x, chunk_z, seed, block_ids, worldgen.ores, default_values);
generate_features(blocks, heights, biomes, set, chunk_x, chunk_z, seed, block_ids, worldgen.features);
}
return { blocks, spills: new Int32Array(spills) };
}
// each block an ore replaces becomes the first ore whose noise is above its threshold
function generate_ores(
blocks: Uint32Array,
chunk_x: number,
chunk_z: number,
seed: string,
block_ids: Record<string, number>,
ores: OreJson[],
default_values: number[] | undefined,
) {
const usable = ores
.map((ore) => ({ ...ore, nid: block_ids[ore.id], replaces_nid: block_ids[ore.replaces] }))
.filter((ore) => ore.nid !== undefined && ore.replaces_nid !== undefined);
if (usable.length === 0) {
return;
}
const noises = usable.map((ore) => named_noise_3d(seed, ore.id));
for (let y = 0; y < CHUNK_HEIGHT; y++) {
for (let lz = 0; lz < CHUNK_SIZE; lz++) {
for (let lx = 0; lx < CHUNK_SIZE; lx++) {
const index = y * CHUNK_AREA + lz * CHUNK_SIZE + lx;
const current = blocks[index] & ID_MASK;
if (current === AIR) {
continue;
}
for (let i = 0; i < usable.length; i++) {
const ore = usable[i];
if (current !== ore.replaces_nid || y < ore.min_y || y > ore.max_y) {
continue;
}
const wx = chunk_x * CHUNK_SIZE + lx;
const wz = chunk_z * CHUNK_SIZE + lz;
if (noises[i](wx * ore.scale, y * ore.scale, wz * ore.scale) > ore.threshold) {
blocks[index] = default_values?.[ore.nid] ?? ore.nid;
break;
}
}
}
}
}
}
function generate_features(
blocks: Uint32Array,
heights: Int32Array,
biomes: string[],
set: (x: number, y: number, z: number, nid: number) => void,
chunk_x: number,
chunk_z: number,
seed: string,
block_ids: Record<string, number>,
features: WorldgenSetup["features"],
) {
const ids_by_nid: string[] = [];
for (const [id, nid] of Object.entries(block_ids)) {
ids_by_nid[nid] = id;
}
const local = (x: number, z: number, what: string) => {
const lx = x - chunk_x * CHUNK_SIZE;
const lz = z - chunk_z * CHUNK_SIZE;
if (lx < 0 || lx >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE) {
throw new Error(`${what}(${x}, ${z}) is outside chunk ${chunk_x}, ${chunk_z}`);
}
return lz * CHUNK_SIZE + lx;
};
for (const feature of features) {
const chunk: FeatureChunk = {
x: chunk_x,
z: chunk_z,
seed,
rng: new Alea(`${seed}_feature_${feature.id}_${chunk_x}_${chunk_z}`),
noise_2d: (name) => named_noise_2d(seed, name),
noise_3d: (name) => named_noise_3d(seed, name),
height_at: (x, z) => heights[local(x, z, "height_at")],
biome_at: (x, z) => biomes[local(x, z, "biome_at")],
get_block(x, y, z) {
if (y < 0 || y >= CHUNK_HEIGHT) {
return undefined;
}
const nid = blocks[y * CHUNK_AREA + local(x, z, "get_block")] & ID_MASK;
return nid === AIR ? "bworld:air" : ids_by_nid[nid];
},
set_block(x, y, z, id) {
const nid = id === "bworld:air" ? AIR : block_ids[id];
if (nid === undefined) {
throw new Error(`unknown block ${id}`);
}
const dx = Math.floor(x / CHUNK_SIZE) - chunk_x;
const dz = Math.floor(z / CHUNK_SIZE) - chunk_z;
if (Math.abs(dx) > 1 || Math.abs(dz) > 1) {
throw new Error(`set_block(${x}, ${y}, ${z}) is more than one chunk away`);
}
set(x, y, z, nid);
},
};
try {
feature.generate(chunk);
} catch (e) {
// every client and the server hit the same error, so skipping it keeps them in agreement
if (!failed_features.has(feature.id)) {
failed_features.add(feature.id);
console.error(`Worldgen feature ${feature.id} failed, skipping it where it throws:`, e);
}
}
}
}
+270
View File
@@ -0,0 +1,270 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
// how item stacks are saved and sent over the network
export interface ItemData {
id: string;
count: number;
data?: unknown;
}
export class ItemStack<T = unknown | undefined> {
type_id: string;
amount: number;
max_amount: number;
data?: T;
constructor(type_id: string | string, amount: number = 1, max_amount?: number) {
const item_info = EverythingRegistry.get<ItemRegistry>("items", type_id);
this.type_id = type_id;
this.amount = amount;
this.max_amount = max_amount ?? item_info?.max_stack ?? 64;
this.data = undefined;
if (item_info?.on_create) {
item_info.on_create(this);
}
}
clone(): ItemStack {
const item = new ItemStack(this.type_id, this.amount, this.max_amount);
item.data = structuredClone(this.data);
return item;
}
to_data(): ItemData {
return this.data === undefined
? { id: this.type_id, count: this.amount }
: { id: this.type_id, count: this.amount, data: this.data };
}
static from_data(data: ItemData): ItemStack {
const item = new ItemStack(data.id, data.count);
if (data.data !== undefined) {
item.data = data.data;
}
return item;
}
}
export class ContainerSlot {
#item_stack: ItemStack | undefined;
has_item() {
return this.#item_stack !== undefined;
}
set_item(item_stack: ItemStack | undefined) {
if ((item_stack?.amount ?? 0) <= 0) {
item_stack = undefined;
}
this.#item_stack = item_stack;
}
get_item() {
return this.#item_stack;
}
get type_id() {
return this.#item_stack?.type_id;
}
set amount(new_amount: number) {
if (this.#item_stack) {
this.#item_stack.amount = new_amount;
if (this.#item_stack.amount <= 0) {
this.#item_stack = undefined;
}
}
}
get amount(): number | undefined {
return this.#item_stack?.amount;
}
get max_amount() {
return this.#item_stack?.max_amount;
}
}
export class Container {
#slots: ContainerSlot[] = [];
readonly size: number;
constructor(size: number) {
this.size = size;
for (let i = 0; i < size; i += 1) {
this.#slots.push(new ContainerSlot());
}
}
// returns how many items didn't fit
add_item(item_stack: ItemStack): number {
for (const slot of this.#slots.filter((slot) => slot.has_item())) {
const slot_item = slot.get_item()!;
if (slot_item.type_id === item_stack.type_id) {
const missing = slot_item.max_amount - slot_item.amount;
const adding = Math.min(missing, item_stack.amount);
slot_item.amount += adding;
item_stack.amount -= adding;
if (item_stack.amount === 0) {
return 0;
}
}
}
for (const slot of this.#slots) {
if (!slot.has_item()) {
slot.set_item(item_stack);
return 0;
}
}
// TODO: drop item on ground
return item_stack.amount;
}
get_item(slot: number): ItemStack | undefined {
return this.#slots[slot]?.get_item();
}
get_slot(slot: number): ContainerSlot {
return this.#slots[slot];
}
set_item(slot: number, item: ItemStack | undefined) {
this.#slots[slot].set_item(item);
}
to_data(): (ItemData | null)[] {
return this.#slots.map((slot) => slot.get_item()?.to_data() ?? null);
}
load(data: (ItemData | null)[]) {
for (let i = 0; i < this.size; i += 1) {
const item = data[i];
this.#slots[i].set_item(item ? ItemStack.from_data(item) : undefined);
}
}
clear() {
for (const slot of this.#slots) {
slot.set_item(undefined);
}
}
}
// the item a player is carrying around with the mouse in an inventory screen
export interface Cursor {
item: ItemStack | undefined;
}
export const LEFT_CLICK = 0;
export const RIGHT_CLICK = 2;
// what clicking a normal slot does, the server runs this for real and clients run it to predict
export function click_slot(container: Container, index: number, cursor: Cursor, button: number) {
if (button === LEFT_CLICK) {
left_click(container, index, cursor);
} else if (button === RIGHT_CLICK) {
right_click(container, index, cursor);
}
if (cursor.item && cursor.item.amount <= 0) {
cursor.item = undefined;
}
}
function swap_with_cursor(container: Container, index: number, cursor: Cursor) {
const slot = container.get_slot(index);
const original = cursor.item;
cursor.item = slot.get_item();
slot.set_item(original);
}
function left_click(container: Container, index: number, cursor: Cursor) {
const holding = cursor.item;
const slot = container.get_slot(index);
const slot_item = slot.get_item();
// if you aren't holding anything
// "swap" with nothing on your hand (pick it up)
if (!holding) {
swap_with_cursor(container, index, cursor);
return;
}
// if you are holding something and slot type equals holding type
// try to add to stack
if (slot_item && slot.type_id === holding.type_id) {
const space_left = slot.max_amount! - slot_item.amount;
const amount_to_add = Math.min(space_left, holding.amount);
slot_item.amount += amount_to_add;
holding.amount -= amount_to_add;
return;
}
// if something on hand but not the same
// swap
swap_with_cursor(container, index, cursor);
}
function right_click(container: Container, index: number, cursor: Cursor) {
const holding = cursor.item;
const slot = container.get_slot(index);
const slot_item = slot.get_item();
// if holding something
if (holding) {
// and slot type equals holding type
// add 1 to matching stack
if (slot_item && slot.type_id === holding.type_id) {
if (slot_item.amount < slot_item.max_amount) {
slot_item.amount += 1;
holding.amount -= 1;
}
return;
}
// place 1 into empty slot
if (!slot_item) {
const new_item = holding.clone();
new_item.amount = 1;
slot.set_item(new_item);
holding.amount -= 1;
return;
}
// if something on hand but not the same
// swap
swap_with_cursor(container, index, cursor);
return;
}
// if player is holding nothing and clicks nothing, nothing happens
if (!slot_item) {
return;
}
// pick up half of the stack
const original_amount = slot_item.amount;
const half = Math.floor(original_amount / 2);
const picked_up = slot_item.clone();
picked_up.amount = original_amount - half;
cursor.item = picked_up;
slot.amount = half;
}
// output slots (furnace result, crafting result) can only be taken from, all at once
// returns whether it was taken
export function take_output(item: ItemStack, cursor: Cursor): boolean {
const holding = cursor.item;
if (!holding) {
cursor.item = item.clone();
return true;
}
if (holding.type_id === item.type_id && holding.max_amount - holding.amount >= item.amount) {
holding.amount += item.amount;
return true;
}
return false;
}
+64
View File
@@ -0,0 +1,64 @@
// what client scripts get, see "Client scripts" and "GUIs" in MODS.md
import type { Id, KeyCode, ModInfo, Position } from "./shared.ts";
export type { Id, ItemStack, KeyCode, ModInfo, Position } from "./shared.ts";
export interface ClientContext {
mod: ModInfo;
ui: ClientUi;
hud: HudRegistry;
input: { bind(id: Id, default_key: KeyCode, on_press: () => void): void };
net: ClientNet;
player: { readonly name: string; readonly position: Readonly<Position> };
// read only, what this client sees
world: { get_block(x: number, y: number, z: number): Id | undefined };
log(...args: unknown[]): void;
}
export interface ModScreen<Props = unknown> {
on_open?(): void;
on_tick?(dt: number): void;
on_render(g: Graphics): void;
// the server sent new props for this screen
on_props?(props: Props): void;
on_close?(): void;
// return true to keep the screen open when escape is pressed
on_escape?(): boolean;
}
export type Color = [number, number, number, number];
// immediate mode drawing, like the debug ui, styled with assets/sprites/ui.png
export interface Graphics {
readonly width: number;
readonly height: number;
readonly mouse: { x: number; y: number; down: boolean; pressed: boolean };
rect(x: number, y: number, w: number, h: number, color?: Color): void;
panel(x: number, y: number, w: number, h: number): void;
text(text: string, x: number, y: number, options?: { scale?: number; color?: Color }): void;
measure_text(text: string, scale?: number): number;
texture(id: Id, x: number, y: number, w: number, h: number): void;
item(id: Id, x: number, y: number, count?: number): void;
clip(x: number, y: number, w: number, h: number, draw: () => void): void;
button(label: string, x: number, y: number, w: number, h: number): boolean;
text_input(id: string, x: number, y: number, w: number): string;
slider(id: string, x: number, y: number, w: number, min: number, max: number): number;
// server synced slots, same rules as container screens
slots(container: string, layout: { slot: number; x: number; y: number }[], x: number, y: number): void;
key_pressed(key: KeyCode): boolean;
}
export interface ClientUi {
register_screen<Props>(id: Id, create: (props: Props) => ModScreen<Props>): void;
// open a screen that doesn't involve the server, like a settings page
open<Props>(id: Id, props: Props): void;
}
export interface HudRegistry {
register(id: Id, element: { on_render(g: Graphics): void }): void;
}
export interface ClientNet {
on<T = unknown>(channel: Id, handler: (data: T) => void): void;
send(channel: Id, data: unknown): void;
}
+252
View File
@@ -0,0 +1,252 @@
// what server scripts get, see "Server scripts" in MODS.md
import type { EventSignal, Face, Id, ItemStack, ModInfo, Position } from "./shared.ts";
export type { EventSignal, Face, Id, ItemStack, ModInfo, Position } from "./shared.ts";
export interface ServerContext {
mod: ModInfo;
components: ComponentRegistry; // only during setup
commands: CommandRegistry; // only during setup
events: { before: ServerBeforeEvents; after: ServerAfterEvents };
system: System;
world: ServerWorld;
players: PlayerList;
containers: ContainerApi;
recipes: RecipeApi;
ui: ServerUi;
net: ServerNet;
storage: ModStorage;
log(...args: unknown[]): void;
}
// components
export interface BlockRef {
readonly id: Id;
readonly x: number;
readonly y: number;
readonly z: number;
// tile data, any json value. saved with the world, never sent to clients
// deno-lint-ignore no-explicit-any
data: any;
}
// P is the component's params from the block json
// deno-lint-ignore no-explicit-any
export interface BlockComponent<P = any> {
on_create?(block: BlockRef, params: P): void;
on_break?(block: BlockRef, params: P, player: Player | undefined): void;
on_click?(block: BlockRef, params: P, player: Player): void;
// return true if it did something, so no block gets placed
on_interact?(block: BlockRef, params: P, player: Player): boolean;
on_tick?(block: BlockRef, params: P, dt: number): void;
on_second?(block: BlockRef, params: P, dt: number): void;
}
// deno-lint-ignore no-explicit-any
export interface ItemComponent<P = any> {
on_create?(item: ItemStack, params: P): void;
get_lore?(item: ItemStack, params: P): string;
on_use?(item: ItemStack, params: P, player: Player): void;
}
export interface ComponentRegistry {
register_block<P>(id: Id, component: BlockComponent<P>): void;
register_item<P>(id: Id, component: ItemComponent<P>): void;
}
// commands
export interface Command {
description: string;
usage: string;
run(args: string[], player: Player): void;
}
export interface CommandRegistry {
register(name: string, command: Command): void;
}
// events
export interface Cancelable {
cancel: boolean;
}
export interface BlockBreakEvent {
readonly player: Player;
readonly block: BlockRef;
readonly item: ItemStack | undefined;
}
export interface BlockPlaceEvent {
readonly player: Player;
readonly block: { readonly id: Id } & Position;
readonly face: Face;
readonly item: ItemStack | undefined;
}
export interface BlockInteractEvent {
readonly player: Player;
readonly block: BlockRef;
readonly item: ItemStack | undefined;
}
export interface ChatSendEvent {
readonly player: Player;
message: string;
}
export interface ServerBeforeEvents {
block_break: EventSignal<BlockBreakEvent & Cancelable>;
block_place: EventSignal<BlockPlaceEvent & Cancelable>;
block_interact: EventSignal<BlockInteractEvent & Cancelable>;
chat_send: EventSignal<ChatSendEvent & Cancelable>;
}
export interface ServerAfterEvents {
block_break: EventSignal<BlockBreakEvent>;
block_place: EventSignal<BlockPlaceEvent>;
block_interact: EventSignal<BlockInteractEvent>;
chat_send: EventSignal<Readonly<ChatSendEvent>>;
player_join: EventSignal<{ readonly player: Player }>;
player_leave: EventSignal<{ readonly player: Player }>;
server_start: EventSignal<Record<never, never>>;
tick: EventSignal<{ readonly dt: number }>;
}
// world and players
export interface ServerWorld {
get_block(x: number, y: number, z: number): Id | undefined; // undefined when the chunk isn't loaded
set_block(x: number, y: number, z: number, id: Id): boolean; // runs on_break / on_create, synced to everyone
get_state(x: number, y: number, z: number, name: string): number | undefined;
set_state(x: number, y: number, z: number, name: string, value: number): boolean;
get_block_data<T>(x: number, y: number, z: number): T | undefined;
is_loaded(x: number, z: number): boolean;
readonly seed: string;
}
export interface Player {
readonly id: string;
readonly name: string;
readonly position: Readonly<Position>;
readonly inventory: Container; // 36 slots, hotbar is 0-8
readonly selected_slot: number;
readonly held_item: ItemStack | undefined;
give_item(id: Id, count?: number, data?: unknown): void;
send_message(text: string): void;
teleport(x: number, y: number, z: number): void;
}
export interface PlayerList {
all(): Player[];
get(id: string): Player | undefined;
by_name(name: string): Player | undefined;
}
export interface System {
run_timeout(fn: () => void, ticks: number): number;
run_interval(fn: () => void, ticks: number): number;
clear_run(handle: number): void;
readonly current_tick: number;
}
// small key value store per mod, saved with the world
export interface ModStorage {
get<T>(key: string): T | undefined;
set(key: string, value: unknown): void; // json values only
delete(key: string): void;
}
// containers and recipes
export interface Container {
readonly id: string;
readonly size: number;
get(slot: number): ItemStack | undefined;
set(slot: number, item: ItemStack | undefined): void;
add(item: ItemStack): ItemStack | undefined; // returns what didn't fit
on_change(fn: (slot: number) => void): () => void;
}
export interface ContainerApi {
create(size: number): Container; // saved with the world
get(id: string): Container | undefined;
delete(id: string): void;
}
export interface RecipeApi {
furnace_result(input: Id): { output: ItemStack; cook_time: number } | undefined;
fuel_value(item: Id): number; // burn time in ticks, 0 if it isn't fuel
is_fuel(item: Id): boolean;
is_smeltable(item: Id): boolean;
}
// guis
export interface ActionForm {
title: string;
body?: string;
buttons: { text: string; icon?: Id }[];
}
export interface MessageForm {
title: string;
body: string;
buttons: [string, string];
}
export type FormField =
| { type: "toggle"; label: string; default?: boolean }
| { type: "slider"; label: string; min: number; max: number; step?: number; default?: number }
| { type: "dropdown"; label: string; options: string[]; default?: number }
| { type: "text"; label: string; placeholder?: string; default?: string; max_length?: number };
export interface ModalForm {
title: string;
fields: FormField[];
}
export type FormResult<T> = ({ canceled: true } & Partial<T>) | ({ canceled: false } & T);
export type SlotFilter = "smeltable" | "fuel" | ((item: ItemStack) => boolean);
export interface ContainerScreenOptions {
title: string;
container: Container;
// x and y in slot units
layout: { slot: number; x: number; y: number; filter?: SlotFilter; output_only?: boolean }[];
player_inventory?: boolean;
bars?: { id: string; x: number; y: number; texture: Id }[];
labels?: { x: number; y: number; property: string }[];
}
export interface ScreenHandle<Props = unknown> {
readonly player: Player;
set_property(id: string, value: number): void;
update(props: Props): void; // custom screens only
close(): void;
on_close(fn: () => void): void;
}
export interface ServerUi {
message_form(player: Player, form: MessageForm): Promise<FormResult<{ selection: 0 | 1 }>>;
action_form(player: Player, form: ActionForm): Promise<FormResult<{ selection: number }>>;
modal_form(player: Player, form: ModalForm): Promise<FormResult<{ values: (boolean | number | string)[] }>>;
open_container(player: Player, options: ContainerScreenOptions): ScreenHandle;
open_screen<Props>(
player: Player,
id: Id,
props: Props,
options?: { containers?: Record<string, Container> },
): ScreenHandle<Props>;
}
// mod channels
export interface ServerNet {
on<T = unknown>(channel: Id, handler: (player: Player, data: T) => void): void;
send(player: Player, channel: Id, data: unknown): void;
broadcast(channel: Id, data: unknown): void;
}
+33
View File
@@ -0,0 +1,33 @@
// types every side of a mod shares. these are the api mods see, not the engine's own classes:
// items are { id, count, data } here, like on the network
export type Id = string;
export interface ItemStack {
readonly id: Id;
count: number;
// any json value, set by server scripts
data?: unknown;
}
export interface ModInfo {
readonly id: string;
readonly version: string;
}
export interface Position {
x: number;
y: number;
z: number;
}
export type Face = "west" | "east" | "bottom" | "top" | "north" | "south";
// KeyboardEvent.code values, like "KeyE" or "Digit1"
export type KeyCode = string;
export type Unsubscribe = () => void;
export interface EventSignal<T> {
subscribe(handler: (event: T) => void): Unsubscribe;
}
+31
View File
@@ -0,0 +1,31 @@
// what worldgen scripts get, see "World generation" in MODS.md. runs in chunk workers and must be deterministic
import type { Id } from "./shared.ts";
export type { Id } from "./shared.ts";
export interface WorldgenContext {
register_terrain(id: Id, generate: (chunk: TerrainChunk) => void): void;
register_feature(id: Id, generate: (chunk: FeatureChunk) => void): void;
}
export interface FeatureChunk {
// chunk coordinates
readonly x: number;
readonly z: number;
readonly seed: string;
// seeded from the seed, chunk and feature id
readonly rng: { next(): number };
// create_noise_2d(new Alea(seed + "_" + name)), cached per seed and name
noise_2d(name: string): (x: number, z: number) => number;
noise_3d(name: string): (x: number, y: number, z: number) => number;
// surface height and biome, inside this chunk only
height_at(x: number, z: number): number;
biome_at(x: number, z: number): Id;
get_block(x: number, y: number, z: number): Id | undefined; // inside this chunk only
set_block(x: number, y: number, z: number, id: Id): void; // up to one chunk away, like trees
}
export interface TerrainChunk extends FeatureChunk {
set_height(x: number, z: number, height: number): void;
set_biome(x: number, z: number, biome: Id): void;
}
+350
View File
@@ -0,0 +1,350 @@
// the json formats from MODS.md, and converting them to and from the engine's registry entries.
// the mod loader uses the from_json direction, tests use both to check nothing is lost
import type { BlockRegistry, BlockStateDefinition, ItemRegistry } from "./everything_registry.ts";
export const FORMAT_VERSION = 1;
export const ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/;
export const NAMESPACE_PATTERN = /^[a-z0-9_]{1,32}$/;
export const RESERVED_NAMESPACES = ["bworld", "engine"];
type BlockTextures = BlockRegistry["textures"];
export interface ManifestJson {
format_version: number;
id: string;
name: string;
description?: string;
version: string;
authors?: string[];
game_version?: string;
dependencies?: { id: string; version: string }[];
scripts?: { server?: string; client?: string; worldgen?: string };
credits?: string;
}
export interface BlockJson {
id: string;
textures: BlockTextures;
transparent?: boolean;
alpha?: number;
collision?: boolean;
mining?: { toughness: number; tool?: string; requires_tool?: boolean };
drops?: string;
item?: boolean;
interactive?: boolean;
replaceable?: boolean;
states?: BlockStateDefinition[];
components?: Record<string, unknown>;
}
export interface OreJson {
id: string;
replaces: string;
min_y: number;
max_y: number;
scale: number;
threshold: number;
}
export interface ItemJson {
id: string;
texture: string;
tool?: string;
places?: string;
max_stack?: number;
lore?: string;
components?: Record<string, unknown>;
}
export type RecipeJson =
| {
type: "shaped";
pattern: string[];
key: Record<string, string>;
result: { id: string; count: number };
}
| { type: "furnace"; input: string; output: { id: string; count: number }; cook_time: number }
| { type: "fuel"; item: string; burn_time: number };
// the crafting grid's format, see server/game/crafting.ts
export interface GridRecipe {
width: number;
height: number;
pattern: (string | undefined)[];
result: { id: string; count: number };
}
// blocks
export function block_to_json(block: BlockRegistry, has_item: boolean): BlockJson {
const json: BlockJson = { id: block.id, textures: block.textures };
if (block.transparent) json.transparent = true;
if (block.alpha !== undefined) json.alpha = block.alpha;
if (!block.has_collision) json.collision = false;
if (block.toughness !== undefined) {
json.mining = { toughness: block.toughness };
if (block.tool_to_break !== undefined) json.mining.tool = block.tool_to_break;
if (block.requires_tool) json.mining.requires_tool = true;
}
if (block.drop_table !== undefined) json.drops = block.drop_table;
if (!has_item) json.item = false;
if (block.interactive) json.interactive = true;
if (block.replaceable) json.replaceable = true;
if (block.states) json.states = block.states;
if (block.components) json.components = block.components;
return json;
}
export function block_from_json(json: BlockJson): { block: BlockRegistry; has_item: boolean } {
const block: BlockRegistry = {
id: json.id,
textures: json.textures,
has_collision: json.collision ?? true,
};
if (json.transparent) block.transparent = true;
if (json.alpha !== undefined) block.alpha = json.alpha;
if (json.mining) {
block.toughness = json.mining.toughness;
block.requires_tool = json.mining.requires_tool ?? false;
if (json.mining.tool !== undefined) block.tool_to_break = json.mining.tool;
}
if (json.drops !== undefined) block.drop_table = json.drops;
if (json.interactive) block.interactive = true;
if (json.replaceable) block.replaceable = true;
if (json.states) block.states = json.states;
if (json.components) block.components = json.components;
return { block, has_item: json.item ?? true };
}
// items that aren't the item form of a block
export function item_to_json(id: string, item: ItemRegistry): ItemJson {
if (typeof item.texture_id !== "string") {
throw new Error(`${id} picks its texture with a function, which json can't hold`);
}
const json: ItemJson = { id, texture: item.texture_id };
if (item.tool_type !== undefined) json.tool = item.tool_type;
if (item.block_id !== undefined) json.places = item.block_id;
if (item.max_stack !== undefined) json.max_stack = item.max_stack;
if (item.lore !== undefined) json.lore = item.lore;
if (item.components) json.components = item.components;
return json;
}
export function item_from_json(json: ItemJson): ItemRegistry {
const item: ItemRegistry = { texture_id: json.texture };
if (json.tool !== undefined) item.tool_type = json.tool;
if (json.places !== undefined) item.block_id = json.places;
if (json.max_stack !== undefined) item.max_stack = json.max_stack;
if (json.lore !== undefined) item.lore = json.lore;
if (json.components) item.components = json.components;
return item;
}
// shaped recipes <-> the grid format
export function grid_recipe_to_json(recipe: GridRecipe): RecipeJson {
const key: Record<string, string> = {};
const letters = new Map<string, string>();
for (const id of recipe.pattern) {
if (id === undefined || letters.has(id)) continue;
const letter = pick_letter(id, new Set(letters.values()));
letters.set(id, letter);
key[letter] = id;
}
const pattern: string[] = [];
for (let y = 0; y < recipe.height; y++) {
let row = "";
for (let x = 0; x < recipe.width; x++) {
const id = recipe.pattern[y * recipe.width + x];
row += id === undefined ? " " : letters.get(id);
}
pattern.push(row);
}
return { type: "shaped", pattern, key, result: recipe.result };
}
export function grid_recipe_from_json(json: Extract<RecipeJson, { type: "shaped" }>): GridRecipe {
const height = json.pattern.length;
const width = Math.max(...json.pattern.map((row) => row.length));
const pattern: (string | undefined)[] = [];
for (const row of json.pattern) {
for (let x = 0; x < width; x++) {
const letter = row[x] ?? " ";
pattern.push(letter === " " ? undefined : json.key[letter]);
}
}
return { width, height, pattern, result: json.result };
}
// a readable letter for an item in a pattern: its first letter if free, like P for planks
function pick_letter(id: string, taken: Set<string>) {
const name = id.split(":")[1].toUpperCase();
for (const letter of [...name, ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ"]) {
if (/[A-Z]/.test(letter) && !taken.has(letter)) {
return letter;
}
}
throw new Error(`Ran out of letters for ${id}`);
}
// validation. every function returns a list of problems, empty when it's fine
type Problems = string[];
export function validate_manifest(json: unknown, folder_name: string): Problems {
const problems: Problems = [];
if (!is_object(json)) return ["manifest.json must be an object"];
if (json.format_version !== FORMAT_VERSION) {
problems.push(`format_version must be ${FORMAT_VERSION}`);
}
if (typeof json.id !== "string" || !NAMESPACE_PATTERN.test(json.id)) {
problems.push("id must be 1-32 characters of a-z, 0-9 and _");
} else if (json.id !== folder_name) {
problems.push(`id "${json.id}" must match the folder name "${folder_name}"`);
}
if (typeof json.name !== "string" || json.name.length === 0) problems.push("name is required");
if (typeof json.version !== "string" || !/^\d+\.\d+\.\d+/.test(json.version)) {
problems.push("version must be semver, like 1.0.0");
}
if (json.dependencies !== undefined) {
if (!Array.isArray(json.dependencies)) {
problems.push("dependencies must be a list");
} else {
for (const dep of json.dependencies) {
if (!is_object(dep) || typeof dep.id !== "string" || typeof dep.version !== "string") {
problems.push("each dependency needs an id and a version range");
}
}
}
}
if (json.scripts !== undefined) {
if (!is_object(json.scripts)) {
problems.push("scripts must be an object");
} else {
for (const [side, path] of Object.entries(json.scripts)) {
if (!["server", "client", "worldgen"].includes(side)) problems.push(`unknown script "${side}"`);
if (typeof path !== "string") problems.push(`scripts.${side} must be a path`);
}
}
}
return problems;
}
export function validate_block(json: unknown): Problems {
if (!is_object(json)) return ["block must be an object"];
const problems = validate_id(json.id, "id");
const textures = json.textures;
const texture_ok = typeof textures === "string" ||
(is_object(textures) &&
(["top", "bottom", "side"].every((k) => typeof textures[k] === "string") ||
["front", "side"].every((k) => typeof textures[k] === "string")));
if (!texture_ok) problems.push("textures must be a texture id, { top, bottom, side } or { front, side }");
for (const key of ["transparent", "collision", "item", "interactive", "replaceable"]) {
if (json[key] !== undefined && typeof json[key] !== "boolean") problems.push(`${key} must be true or false`);
}
if (json.alpha !== undefined && (typeof json.alpha !== "number" || json.alpha < 0 || json.alpha > 1)) {
problems.push("alpha must be between 0 and 1");
}
if (json.mining !== undefined) {
if (!is_object(json.mining) || typeof json.mining.toughness !== "number" || json.mining.toughness < 0) {
problems.push("mining.toughness must be a number of seconds");
}
}
if (json.drops !== undefined) problems.push(...validate_id(json.drops, "drops"));
if (json.states !== undefined) {
const states = json.states;
if (!Array.isArray(states)) {
problems.push("states must be a list");
} else {
const bits = states.reduce((sum, s) => sum + (is_object(s) && typeof s.bits === "number" ? s.bits : 0), 0);
if (bits > 16) problems.push(`states use ${bits} bits, the most is 16`);
}
}
return problems;
}
export function validate_item(json: unknown): Problems {
if (!is_object(json)) return ["item must be an object"];
const problems = validate_id(json.id, "id");
problems.push(...validate_id(json.texture, "texture"));
if (json.places !== undefined) problems.push(...validate_id(json.places, "places"));
if (json.max_stack !== undefined && (!Number.isInteger(json.max_stack) || (json.max_stack as number) < 1)) {
problems.push("max_stack must be a positive whole number");
}
return problems;
}
export function validate_recipe(json: unknown): Problems {
if (!is_object(json)) return ["recipe must be an object"];
const problems: Problems = [];
switch (json.type) {
case "shaped": {
const pattern = json.pattern;
if (
!Array.isArray(pattern) || pattern.length < 1 || pattern.length > 3 ||
!pattern.every((row) => typeof row === "string" && row.length >= 1 && row.length <= 3)
) {
problems.push("pattern must be 1-3 rows of 1-3 characters");
} else if (is_object(json.key)) {
for (const letter of pattern.join("").replaceAll(" ", "")) {
if (!(letter in json.key)) problems.push(`pattern uses "${letter}" but key doesn't define it`);
}
}
if (!is_object(json.key)) {
problems.push("key must map letters to item ids");
} else {
for (const id of Object.values(json.key)) problems.push(...validate_id(id, "key"));
}
problems.push(...validate_stack(json.result, "result"));
break;
}
case "furnace":
problems.push(...validate_id(json.input, "input"), ...validate_stack(json.output, "output"));
if (!Number.isInteger(json.cook_time) || (json.cook_time as number) < 1) {
problems.push("cook_time must be a positive number of ticks");
}
break;
case "fuel":
problems.push(...validate_id(json.item, "item"));
if (!Number.isInteger(json.burn_time) || (json.burn_time as number) < 1) {
problems.push("burn_time must be a positive number of ticks");
}
break;
default:
problems.push('type must be "shaped", "furnace" or "fuel"');
}
return problems;
}
export function validate_ore(json: unknown): Problems {
if (!is_object(json)) return ["ore must be an object"];
const problems = [...validate_id(json.id, "id"), ...validate_id(json.replaces, "replaces")];
for (const key of ["min_y", "max_y", "scale", "threshold"]) {
if (typeof json[key] !== "number") problems.push(`${key} must be a number`);
}
if (typeof json.min_y === "number" && typeof json.max_y === "number" && json.min_y > json.max_y) {
problems.push("min_y must not be above max_y");
}
return problems;
}
function validate_id(value: unknown, field: string): Problems {
return typeof value === "string" && ID_PATTERN.test(value) ? [] : [`${field} must be an id like "my_mod:thing"`];
}
function validate_stack(value: unknown, field: string): Problems {
if (!is_object(value)) return [`${field} must be { id, count }`];
const problems = validate_id(value.id, `${field}.id`);
if (!Number.isInteger(value.count) || (value.count as number) < 1) {
problems.push(`${field}.count must be a positive whole number`);
}
return problems;
}
function is_object(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+99
View File
@@ -0,0 +1,99 @@
// registers mods' data, the same way on the server and on clients
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "./everything_registry.ts";
import {
block_from_json,
BlockJson,
grid_recipe_from_json,
GridRecipe,
item_from_json,
ItemJson,
OreJson,
RecipeJson,
} from "./mod_data.ts";
import { register_block_item } from "./utils.ts";
// everything a mod's json files hold, merged into one file by the build (build/mods/<id>/<hash>/data.json)
export interface ModData {
blocks: BlockJson[];
items: ItemJson[];
recipes: RecipeJson[];
ores: OreJson[];
}
// a mod as the server lists it to clients, in load order. paths are relative to the server's root
export interface ModListing {
id: string;
name: string;
version: string;
// short hash of everything public, the folder it's served from
hash: string;
data: string;
client?: string;
worldgen?: string;
// sha-256 in hex of each file, clients check these before using them
sha256: { data: string; client?: string; worldgen?: string };
}
// the texture atlas with every mod's textures, built by the server
export interface AtlasListing {
png: string;
json: string;
sha256: { png: string; json: string };
}
export class RecipeBook {
shaped: GridRecipe[] = [];
furnace = new Map<string, { output: { id: string; count: number }; cook_time: number }>();
fuel = new Map<string, number>();
ores: OreJson[] = [];
}
export class ModLoadError extends Error {
constructor(mod: string, message: string) {
super(`${mod}: ${message}`);
this.name = "ModLoadError";
}
}
// registers every mod's blocks, items and recipes, in the order given (dependencies first)
export function register_mod_data(mods: { id: string; data: ModData }[]): RecipeBook {
const recipes = new RecipeBook();
for (const { id, data } of mods) {
const fail = (message: string): never => {
throw new ModLoadError(id, message);
};
try {
for (const json of data.blocks) {
const { block, has_item } = block_from_json(json);
EverythingRegistry.register<BlockRegistry>("blocks", block.id, block);
if (has_item) {
register_block_item(block);
}
}
for (const json of data.items) {
EverythingRegistry.register<ItemRegistry>("items", json.id, item_from_json(json));
}
} catch (e) {
fail((e as Error).message);
}
for (const recipe of data.recipes) {
switch (recipe.type) {
case "shaped":
recipes.shaped.push(grid_recipe_from_json(recipe));
break;
case "furnace":
if (recipes.furnace.has(recipe.input)) fail(`two furnace recipes for ${recipe.input}`);
recipes.furnace.set(recipe.input, { output: recipe.output, cook_time: recipe.cook_time });
break;
case "fuel":
recipes.fuel.set(recipe.item, recipe.burn_time);
break;
}
}
recipes.ores.push(...data.ores);
}
return recipes;
}
+105
View File
@@ -0,0 +1,105 @@
// messages sent between the client and the server, as json over a websocket
import type { Faces } from "./constants.ts";
import type { ItemData } from "./inventory.ts";
import type { AtlasListing, ModListing } from "./mod_loader.ts";
export const AIR_ID = "bworld:air";
// bump when a client and server of different versions can't play together.
// the server rejects a different version before the client downloads anything
export const PROTOCOL_VERSION = 1;
export interface PlayerInfo {
id: string;
name: string;
x: number;
y: number;
z: number;
yaw: number;
pitch: number;
}
// x, y, z, block id, and its state bits when they aren't 0
export type BlockChange = [number, number, number, string, number?];
// the containers a client can see and click. "screen" is whatever the open server screen shows
export type ContainerKey = "inventory" | "crafting" | "screen";
export const CRAFTING_RESULT_SLOT = 9;
// a screen the server opens, drawn below the player's inventory and hotbar
export interface ScreenLayout {
// height of the screen's own area, in slots
rows: number;
// x and y in slots, can be fractional
// output slots can only be taken from, like the furnace result
slots: { index: number; x: number; y: number; output?: boolean }[];
// progress bars filled from properties[value] / properties[max]
bars: {
x: number;
y: number;
value: string;
max: string;
direction: "up" | "right";
empty_texture: string;
full_texture: string;
}[];
}
// clients send what the player is trying to do, the server decides what happens
export type ClientMessage =
| { type: "hello"; name: string; protocol: number }
// sent once the client has downloaded, checked and loaded everything in welcome
| { type: "ready" }
| { type: "move"; x: number; y: number; z: number; yaw: number; pitch: number }
| { type: "break_block"; x: number; y: number; z: number }
// the player started hitting a block, for on_click
| { type: "hit_block"; x: number; y: number; z: number }
// right click without looking at a block, for items' on_use
| { type: "use_item" }
// right click on a block: interact with it, or place the held block against `face`
| { type: "use_block"; x: number; y: number; z: number; face: Faces }
| { type: "select_slot"; slot: number }
| { type: "click"; container: ContainerKey; index: number; button: number }
// closes the open screen, including the player's own inventory screen
| { type: "close_screen" }
| { type: "chat"; text: string };
export type ServerMessage =
// what to download before joining, see "Delivery to clients" in MODS.md
| {
type: "welcome";
protocol: number;
seed: string;
atlas: AtlasListing;
// in load order, the client loads them before joining the world
mods: ModListing[];
}
// the connection is closed right after
| { type: "rejected"; reason: string }
// answers ready, the player is in the world from here
| {
type: "join";
id: string;
// the server may change the name asked for, like alice to alice2
name: string;
players: PlayerInfo[];
changes: BlockChange[];
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
selected_slot: number;
}
| { type: "player_join"; player: PlayerInfo }
| { type: "player_leave"; id: string }
| { type: "player_move"; id: string; x: number; y: number; z: number; yaw: number; pitch: number }
// also sent to the player who caused it, which corrects anything their client predicted wrong
| { type: "set_block"; x: number; y: number; z: number; id: string; state?: number }
| { type: "chat"; from?: string; text: string }
| { type: "teleport"; x: number; y: number; z: number }
| { type: "container"; container: ContainerKey; items: (ItemData | null)[] }
| { type: "cursor"; item: ItemData | null }
| { type: "open_screen"; layout: ScreenLayout; properties: Record<string, number> }
| { type: "screen_properties"; properties: Record<string, number> }
| { type: "close_screen" };
export const MAX_NAME_LENGTH = 16;
export const MAX_CHAT_LENGTH = 256;
+22 -11
View File
@@ -1,5 +1,4 @@
import { AssetManager } from "../client/assets.ts"; import { ID_MASK, STATE_SHIFT } from "./constants.ts";
import { ID_MASK, SpriteRegion, STATE_SHIFT } from "./constants.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "./everything_registry.ts"; import { BlockRegistry, EverythingRegistry, ItemRegistry } from "./everything_registry.ts";
export function point_inside_rec( export function point_inside_rec(
@@ -16,13 +15,6 @@ export function point_inside_rec(
point_y < rec_y + rec_h; point_y < rec_y + rec_h;
} }
type TexturesInfo = Record<string, SpriteRegion>;
export function get_sprite_region(id: string): SpriteRegion {
const textures_info = AssetManager.instance.get<TexturesInfo>("bworld:textures_info");
return textures_info?.[id] ?? { x: 0, y: 0 };
}
export function distance_point_rectangle(px: number, py: number, sqx: number, sqy: number, sqw: number, sqh: number) { export function distance_point_rectangle(px: number, py: number, sqx: number, sqy: number, sqw: number, sqh: number) {
const x0 = sqx; const x0 = sqx;
const y0 = sqy; const y0 = sqy;
@@ -47,7 +39,7 @@ export function distance_point_point(ax: number, ay: number, az: number, bx: num
export function register_block_item(block: BlockRegistry) { export function register_block_item(block: BlockRegistry) {
// TODO: handle block textures // TODO: handle block textures
let texture_id = "bworld:missing"; let texture_id = "engine:missing";
if (typeof block.textures === "string") { if (typeof block.textures === "string") {
texture_id = block.textures; texture_id = block.textures;
} }
@@ -87,7 +79,21 @@ export function set_state_value(value: number, block_info: BlockRegistry, name:
state_bits = (state_bits & ~s.mask) | (new_value << s.shift); state_bits = (state_bits & ~s.mask) | (new_value << s.shift);
return (state_bits << 16) | id; // >>> 0 keeps it unsigned when the top state bit is set
return ((state_bits << 16) | id) >>> 0;
}
// a block's numeric id with its states at their defaults, what placing it should store
export function default_block_value(nid: number, block_info: BlockRegistry | undefined): number {
let value = nid;
for (const state of block_info?.states ?? []) {
value = set_state_value(value, block_info!, state.name, state.default) ?? value;
}
return value;
}
export function block_value(nid: number, state: number): number {
return ((state << STATE_SHIFT) | nid) >>> 0;
} }
function compile_block_states(block_info: BlockRegistry) { function compile_block_states(block_info: BlockRegistry) {
@@ -106,3 +112,8 @@ function compile_block_states(block_info: BlockRegistry) {
return { name: s.name, mask, shift }; return { name: s.name, mask, shift };
}); });
} }
// numeric so looking chunks up doesnt allocate a string every time, fine for |x|, |z| < 32768
export function chunk_key(x: number, z: number) {
return (x + 32768) * 65536 + (z + 32768);
}
+37
View File
@@ -0,0 +1,37 @@
// imports mods' worldgen scripts and collects what they register. used by client chunk workers and the game server
import type { WorldgenContext } from "$/common/mod_api/worldgen.ts";
import type { OreJson } from "$/common/mod_data.ts";
import type { WorldgenSetup } from "./generation.ts";
import { ModLoadError } from "./mod_loader.ts";
export async function load_worldgen(scripts: { mod: string; url: string }[], ores: OreJson[]): Promise<WorldgenSetup> {
const setup: WorldgenSetup = { ores, features: [] };
for (const { mod, url } of scripts) {
const ctx: WorldgenContext = {
register_feature(id, generate) {
if (!id.startsWith(`${mod}:`)) {
throw new ModLoadError(mod, `feature ${id} must be in the namespace "${mod}"`);
}
if (setup.features.some((f) => f.id === id)) {
throw new ModLoadError(mod, `feature ${id} is registered twice`);
}
setup.features.push({ id, generate });
},
register_terrain(id) {
throw new ModLoadError(
mod,
`can't register terrain ${id}: the base terrain is still built into the engine (phase 3 in MODS.md)`,
);
},
};
const module = await import(url);
if (typeof module.setup !== "function") {
throw new ModLoadError(mod, "the worldgen script doesn't export a setup function");
}
await module.setup(ctx);
}
return setup;
}
+12 -2
View File
@@ -1,10 +1,14 @@
{ {
"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 --unstable-worker-options --allow-net --allow-read --allow-write --allow-env server/main.ts",
"new-mod": "deno run --allow-read --allow-write tools/new_mod.ts",
"check-mods": "deno run --allow-read --allow-run tools/check_mods.ts",
"test": "deno test --allow-read --allow-write --allow-run tests/"
}, },
"compilerOptions": { "compilerOptions": {
"lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable"] "lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable", "deno.webgpu"]
}, },
"unstable": ["bundle", "raw-imports"], "unstable": ["bundle", "raw-imports"],
"fmt": { "fmt": {
@@ -15,9 +19,15 @@
}, },
"imports": { "imports": {
"$/": "./", "$/": "./",
"bworld/server": "./common/mod_api/server.ts",
"bworld/client": "./common/mod_api/client.ts",
"bworld/worldgen": "./common/mod_api/worldgen.ts",
"@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/assert": "jsr:@std/assert@^1.0.0",
"@std/fs": "jsr:@std/fs@^1.0.23", "@std/fs": "jsr:@std/fs@^1.0.23",
"@std/http": "jsr:@std/http@^1.0.23",
"@std/path": "jsr:@std/path@^1.0.0",
"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"
} }
Generated
+65 -4
View File
@@ -3,10 +3,23 @@
"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/assert@1": "1.0.14",
"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.10": "1.0.12",
"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.1.4",
"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,30 +27,71 @@
"@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/assert@1.0.14": {
"integrity": "68d0d4a43b365abc927f45a9b85c639ea18a9fab96ad92281e493e4ed84abaa4",
"dependencies": [
"jsr:@std/internal@^1.0.10"
]
},
"@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": [
"jsr:@std/internal", "jsr:@std/internal@^1.0.12",
"jsr:@std/path" "jsr:@std/path@^1.1.4"
]
},
"@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@^1.1.4",
"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@^1.0.12"
] ]
},
"@std/streams@1.1.2": {
"integrity": "0249bf9b78a999f57032ca4d8e7ccaa57579090242640b35ddc948fbb745d3af"
} }
}, },
"npm": { "npm": {
@@ -49,11 +103,18 @@
"bin": true "bin": true
} }
}, },
"remote": {
"http://localhost:8768/mods/copper_tools/33af7e7e9e94/client.js": "2f5008856e1e7b13d0fe658aefcdea38dea80b4805e41101c5ff6c2d3d258245",
"http://localhost:8768/mods/copper_tools/33af7e7e9e94/worldgen.js": "a3da997bdfee885433de282338bdba388825868f122c9a8759b2f1bd67120c78"
},
"workspace": { "workspace": {
"dependencies": [ "dependencies": [
"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/assert@1",
"jsr:@std/fs@^1.0.23", "jsr:@std/fs@^1.0.23",
"jsr:@std/http@^1.0.23",
"jsr:@std/path@1",
"npm:gl-matrix@^3.4.4", "npm:gl-matrix@^3.4.4",
"npm:marked@^17.0.3" "npm:marked@^17.0.3"
] ]
+82
View File
@@ -0,0 +1,82 @@
# bworld
Textures and sprites by Kenney, from these CC0 packs:
# Tiny town
https://kenney.nl/assets/tiny-town
license: [Creative Commons CC0](https://creativecommons.org/publicdomain/zero/1.0/)
# Roguelike
https://kenney.nl/assets/roguelike-rpg-pack
license: [Creative Commons CC0](https://creativecommons.org/publicdomain/zero/1.0/)
# UI
https://kenney.nl/assets/ui-pack-pixel-adventure
license: [Creative Commons CC0](https://creativecommons.org/publicdomain/zero/1.0/)
# m6x11.ttf
Font by Daniel Linssen (https://managore.itch.io)
https://managore.itch.io/m6x11 (https://web.archive.org/web/20260217032856/https://managore.itch.io/m6x11)
license: `free to use with attribution`
# Tiny wonder farm
https://butterymilk.itch.io/tiny-wonder-farm-asset-pack
(https://web.archive.org/web/20260204234306/https://butterymilk.itch.io/tiny-wonder-farm-asset-pack)
license:
```
There's two versions, free and paid (premium):
Free: It includes a limited number of items available for free.
You are free to use sprites included in the pack in any of your non-commercial projects as well as edit the sprites and
add something new!
Premium: It includes everything shown from the showcase, all tilemaps, sprites, characters and more!
You are free to use sprites included in the pack in any of your commercial or non-commercial projects as well as edit
the sprites and add something new!
You can't resell the sprites, even if changes were made. You cannot use it in any projects related to NFT."
```
# Farmer's delight
https://github.com/vectorwing/FarmersDelight
License:
```
MIT License
Copyright (c) 2020 vectorwing
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
+13
View File
@@ -0,0 +1,13 @@
# bworld
The base game as a mod, following "The base game as a mod" in `MODS.md`. The game loads its blocks, items, recipes and
textures from here, the same way it loads any other mod.
Still built into the engine, by phase:
| Phase | What |
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2 | Block behavior in `server/game/blocks.ts` (hoeing, chest, furnace) and the watering can's starting water. They become the `bworld:hoeable`, `bworld:storage`, `bworld:furnace` and `bworld:watering_can` components. |
| 3 | Terrain, biomes, trees and ores in `common/generation.ts`. |
`bworld:stick` has no texture yet, so sticks show as missing.
+14
View File
@@ -0,0 +1,14 @@
{
"format_version": 1,
"block": {
"id": "bworld:chest",
"textures": "bworld:planks",
"collision": false,
"mining": {
"toughness": 8,
"tool": "axe"
},
"drops": "bworld:chest",
"interactive": true
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"format_version": 1,
"block": {
"id": "bworld:coal_ore",
"textures": "bworld:stone_coal",
"mining": {
"toughness": 5,
"tool": "pickaxe",
"requires_tool": true
},
"drops": "bworld:coal_ore"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"format_version": 1,
"block": {
"id": "bworld:copper_ore",
"textures": "bworld:stone_copper",
"mining": {
"toughness": 5,
"tool": "pickaxe",
"requires_tool": true
},
"drops": "bworld:copper_ore"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"format_version": 1,
"block": {
"id": "bworld:dirt",
"textures": "bworld:dirt",
"collision": false,
"mining": {
"toughness": 2,
"tool": "shovel"
},
"drops": "bworld:dirt"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"format_version": 1,
"block": {
"id": "bworld:furnace",
"textures": {
"front": "bworld:furnace",
"side": "bworld:stone"
},
"mining": {
"toughness": 5,
"tool": "pickaxe",
"requires_tool": true
},
"drops": "bworld:furnace",
"interactive": true
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"format_version": 1,
"block": {
"id": "bworld:glass",
"textures": "bworld:glass",
"transparent": true,
"mining": {
"toughness": 3,
"tool": "pickaxe"
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"format_version": 1,
"block": {
"id": "bworld:gold_ore",
"textures": "bworld:stone_gold",
"mining": {
"toughness": 5,
"tool": "pickaxe",
"requires_tool": true
},
"drops": "bworld:gold_ore"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"format_version": 1,
"block": {
"id": "bworld:grass",
"textures": {
"top": "bworld:grass_top",
"bottom": "bworld:dirt",
"side": "bworld:grass_side"
},
"collision": false,
"mining": {
"toughness": 2,
"tool": "shovel"
},
"drops": "bworld:dirt",
"item": false
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"format_version": 1,
"block": {
"id": "bworld:hoed_dirt",
"textures": {
"side": "bworld:dirt",
"top": "bworld:hoed_dirt",
"bottom": "bworld:dirt"
},
"collision": false,
"mining": {
"toughness": 5,
"tool": "shovel"
},
"drops": "bworld:dirt",
"states": [
{
"name": "watered",
"bits": 1,
"default": 0
}
]
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"format_version": 1,
"block": {
"id": "bworld:iron_ore",
"textures": "bworld:stone_iron",
"mining": {
"toughness": 5,
"tool": "pickaxe",
"requires_tool": true
},
"drops": "bworld:iron_ore"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"format_version": 1,
"block": {
"id": "bworld:leaves",
"textures": "bworld:leaves",
"transparent": true,
"mining": {
"toughness": 3,
"tool": "hoe"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"format_version": 1,
"block": {
"id": "bworld:log",
"textures": {
"side": "bworld:log_side",
"top": "bworld:log_top",
"bottom": "bworld:log_top"
},
"mining": {
"toughness": 3,
"tool": "axe"
},
"drops": "bworld:log"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"format_version": 1,
"block": {
"id": "bworld:planks",
"textures": "bworld:planks",
"mining": {
"toughness": 3,
"tool": "axe"
},
"drops": "bworld:planks"
}
}

Some files were not shown because too many files have changed in this diff Show More