Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfabe40e7a | ||
|
|
2a2ccae9ce | ||
|
|
6458bc0440 | ||
|
|
bb42dd662e | ||
|
|
79faa556de | ||
|
|
1bef94ce0c | ||
|
|
d12fa84b00 | ||
|
|
14aba6b129 | ||
|
|
a37ffd9127 | ||
|
|
a758037f20 |
+4
-1
@@ -1 +1,4 @@
|
||||
build/
|
||||
build/
|
||||
world.json
|
||||
world.json.tmp
|
||||
server_mods/
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { copy } from "@std/fs";
|
||||
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() {
|
||||
Deno.removeSync(BUILD_FOLDER, { recursive: true });
|
||||
Deno.mkdirSync(BUILD_FOLDER);
|
||||
// what the server reads at startup, see server/main.ts
|
||||
export interface ServerModIndex {
|
||||
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() {
|
||||
@@ -59,14 +75,23 @@ function calculate_atlas_size(count: number) {
|
||||
};
|
||||
}
|
||||
|
||||
async function build_atlas_from_folder(folder: string) {
|
||||
let sprite_count = 0;
|
||||
for (const entry of Deno.readDirSync(folder)) {
|
||||
console.assert(entry.isFile && entry.name.endsWith(".png"));
|
||||
sprite_count += 1;
|
||||
// one atlas with the engine's textures (engine:<file>) and every mod's (<mod>:<file>).
|
||||
// named by its hash, so clients can cache it forever and download it from other origins
|
||||
async function build_atlas(mods: LoadedMod[]): Promise<AtlasListing> {
|
||||
const textures = new Map<string, string>();
|
||||
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 ctx = canvas.getContext("2d");
|
||||
@@ -80,45 +105,138 @@ async function build_atlas_from_folder(folder: string) {
|
||||
ctx.fillStyle = "black";
|
||||
ctx.fillRect(8, 0, 8, 8);
|
||||
ctx.fillRect(0, 8, 8, 8);
|
||||
atlas_info["engine:missing"] = { x: 0, y: 0 };
|
||||
|
||||
let index = 1;
|
||||
for (const entry of Deno.readDirSync(folder)) {
|
||||
const sprite = await loadImage(`${folder}/${entry.name}`);
|
||||
for (const [id, path] of [...textures].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const sprite = await loadImage(path);
|
||||
const row = Math.floor(index / atlas.sprites_per_side);
|
||||
const column = index % atlas.sprites_per_side;
|
||||
ctx.drawImage(sprite, column * SPRITE_SIZE, row * SPRITE_SIZE);
|
||||
index += 1;
|
||||
|
||||
const id = `bworld:${entry.name.replace(".png", "")}`;
|
||||
atlas_info[id] = { x: column, y: row };
|
||||
}
|
||||
|
||||
Deno.writeFileSync(`${BUILD_FOLDER}/${folder}.png`, canvas.toBuffer());
|
||||
Deno.writeTextFileSync(`${BUILD_FOLDER}/${folder}.json`, JSON.stringify(atlas_info));
|
||||
const png = new Uint8Array(canvas.toBuffer());
|
||||
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")) {
|
||||
if (entry.name.endsWith(".png")) {
|
||||
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 });
|
||||
await copy("assets/ASSETS.md", `${BUILD_FOLDER}/assets/ASSETS.md`);
|
||||
|
||||
build_fonts();
|
||||
await build_fonts();
|
||||
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() {
|
||||
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`,
|
||||
platform: "browser",
|
||||
minify: false,
|
||||
@@ -130,20 +248,32 @@ async function build_client() {
|
||||
async function build() {
|
||||
try {
|
||||
const now = performance.now();
|
||||
clear_folder();
|
||||
await build_assets();
|
||||
clear_folder(BUILD_FOLDER);
|
||||
const mods = load_mods();
|
||||
const atlas = await build_assets(mods);
|
||||
await build_client();
|
||||
await build_mods(mods, atlas);
|
||||
console.log(`Built in ${(performance.now() - now).toFixed(2)}ms`);
|
||||
} 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;
|
||||
|
||||
if (import.meta.main) {
|
||||
await build();
|
||||
const watcher = Deno.watchFs(["assets", "client", "common"], { recursive: true });
|
||||
const ok = await build();
|
||||
// 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) {
|
||||
const now = performance.now();
|
||||
if (now - last_build < 500) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
@@ -8,11 +8,17 @@ import { UIRenderSystem } from "$/client/systems/ui_render_system.ts";
|
||||
import { create_main_menu } from "./main_menu.ts";
|
||||
import { start_game } from "./game.ts";
|
||||
import { canvas, resize_canvas } from "./renderer/mod.ts";
|
||||
import { DimensionLogicSystem } from "./systems/dimension_logic.ts";
|
||||
import { Dimension } from "./components/dimension.ts";
|
||||
import { GuiRenderSystem, GuiTickSystem } from "./gui/gui_systems.ts";
|
||||
import { WorldGenerationSystem } from "./systems/world_generation_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 {
|
||||
paused = false;
|
||||
@@ -20,8 +26,12 @@ export class ClientWorld extends World {
|
||||
|
||||
dimension!: Dimension;
|
||||
|
||||
constructor() {
|
||||
connection: Connection;
|
||||
chat_log: ChatLine[] = [];
|
||||
|
||||
constructor(connection: Connection) {
|
||||
super("game");
|
||||
this.connection = connection;
|
||||
|
||||
this.add_state("main_menu");
|
||||
this.add_state("paused");
|
||||
@@ -39,10 +49,10 @@ export class ClientWorld extends World {
|
||||
// Logic systems
|
||||
this.add_system(new UIInteractionSystem(), "main_menu");
|
||||
this.add_system(new UIInteractionSystem(), "paused");
|
||||
this.add_system(new NetworkSystem(), "game");
|
||||
this.add_system(new GuiTickSystem(), "game");
|
||||
this.add_system(new PlayerControlsSystem(), "game");
|
||||
this.add_system(new WorldGenerationSystem(), "game");
|
||||
this.add_system(new DimensionLogicSystem(), "game");
|
||||
this.add_system(new CollisionSystem(), "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(), "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
@@ -1,22 +1,16 @@
|
||||
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 { 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 { ClientWorld } from "../client_world.ts";
|
||||
import { generate_chunk } from "../generation.ts";
|
||||
import { ItemStack } from "../inventory.ts";
|
||||
import { PlayerComponent } from "../player.ts";
|
||||
import { gl, Texture } from "../renderer/mod.ts";
|
||||
import { ChunkWorkerPool } from "../chunk_workers.ts";
|
||||
import { worldgen_mods } from "../mods.ts";
|
||||
import type { FromChunkWorker } from "../workers/chunk_messages.ts";
|
||||
import { create_vertex_buffer, destroy_vertex_buffer, Texture } from "../renderer/mod.ts";
|
||||
import { Camera } from "./camera.ts";
|
||||
|
||||
export interface BlockData<T = unknown> {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface Block {
|
||||
id: string;
|
||||
x: number;
|
||||
@@ -24,53 +18,91 @@ export interface Block {
|
||||
z: number;
|
||||
}
|
||||
|
||||
export const CHUNK_SIZE = 16;
|
||||
export const CHUNK_HEIGHT = 128;
|
||||
export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE;
|
||||
export { CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE };
|
||||
|
||||
export interface Chunk {
|
||||
x: number;
|
||||
z: number;
|
||||
blocks: Uint32Array;
|
||||
blocks_data: BlockData[];
|
||||
generated: 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;
|
||||
transparent_vertex_buffer?: WebGLBuffer;
|
||||
transparent_vertex_buffer?: GPUBuffer;
|
||||
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 {
|
||||
world: ClientWorld;
|
||||
image: Texture = AssetManager.instance.get("bworld:textures");
|
||||
chunks: Chunk[] = [];
|
||||
chunks = new Map<number, Chunk>();
|
||||
second_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();
|
||||
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) {
|
||||
const chunk = {
|
||||
dispose() {
|
||||
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,
|
||||
z,
|
||||
blocks: new Uint32Array(CHUNK_AREA * CHUNK_HEIGHT),
|
||||
blocks_data: [],
|
||||
blocks,
|
||||
dirty: true,
|
||||
generated: false,
|
||||
mesh_version: 0,
|
||||
};
|
||||
this.chunks.push(chunk);
|
||||
this.chunks.set(chunk_key(x, z), chunk);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
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_z = Math.floor(block.z / CHUNK_SIZE);
|
||||
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);
|
||||
}
|
||||
|
||||
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 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;
|
||||
|
||||
chunk.blocks[index] = nid;
|
||||
chunk.blocks[index] = state === undefined ? default_block_value(nid, info) : block_value(nid, state);
|
||||
chunk.dirty = true;
|
||||
if (block_info?.on_create) {
|
||||
block_info?.on_create(this, block);
|
||||
this.#mark_border_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -112,32 +154,7 @@ export class Dimension extends Component {
|
||||
return chunk.blocks[index] & ID_MASK;
|
||||
}
|
||||
|
||||
add_block_data(block_data: BlockData) {
|
||||
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>;
|
||||
}
|
||||
|
||||
// only changes what this client shows, drops and everything else happen on the server
|
||||
break_block(x: number, y: number, z: number) {
|
||||
const block_chunk_x = Math.floor(x / CHUNK_SIZE);
|
||||
const block_chunk_z = Math.floor(z / CHUNK_SIZE);
|
||||
@@ -146,14 +163,6 @@ export class Dimension extends Component {
|
||||
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 lz = z - block_chunk_z * CHUNK_SIZE;
|
||||
const ly = y;
|
||||
@@ -161,7 +170,11 @@ export class Dimension extends Component {
|
||||
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
|
||||
chunk.blocks[index] = AIR;
|
||||
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) {
|
||||
const n = this.get_chunk(block_chunk_x - 1, block_chunk_z);
|
||||
if (n) {
|
||||
@@ -184,10 +197,6 @@ export class Dimension extends Component {
|
||||
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) {
|
||||
@@ -200,46 +209,202 @@ export class Dimension extends Component {
|
||||
return [x, y, z];
|
||||
}
|
||||
|
||||
load_chunk(cx: number, cz: number) {
|
||||
generate_chunk(this, cx, cz);
|
||||
const chunk = this.get_chunk(cx, cz);
|
||||
if (chunk) {
|
||||
chunk.generated = true;
|
||||
chunk.dirty = true;
|
||||
record_change(x: number, y: number, z: number, id: string, state = 0) {
|
||||
const chunk_key = `${Math.floor(x / CHUNK_SIZE)},${Math.floor(z / CHUNK_SIZE)}`;
|
||||
let chunk_changes = this.changes.get(chunk_key);
|
||||
if (!chunk_changes) {
|
||||
chunk_changes = new Map();
|
||||
this.changes.set(chunk_key, chunk_changes);
|
||||
}
|
||||
chunk_changes.set(`${x},${y},${z}`, [x, y, z, id, 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 = [
|
||||
[cx - 1, cz],
|
||||
[cx + 1, cz],
|
||||
[cx, cz - 1],
|
||||
[cx, cz + 1],
|
||||
];
|
||||
const current = this.get_block(x, y, z);
|
||||
if (id === AIR_ID) {
|
||||
if (current !== AIR) {
|
||||
this.break_block(x, y, z);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [nx, nz] of neighbors) {
|
||||
const neighbor = this.chunks.find((c) => c.x === nx && c.z === nz);
|
||||
const nid = EverythingRegistry.get_id("blocks", id);
|
||||
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) {
|
||||
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) {
|
||||
const chunk_i = this.chunks.findIndex((c) => c.x === cx && c.z === cz);
|
||||
if (chunk_i === -1) {
|
||||
console.warn("Tried unloading a chunk that doesn't exist");
|
||||
#on_meshed(message: Extract<FromChunkWorker, { type: "meshed" }>) {
|
||||
const chunk = this.get_chunk(message.chunk_x, message.chunk_z);
|
||||
// unloaded, or remeshed again since this was requested
|
||||
if (!chunk || chunk.mesh_version !== message.version) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.delete_chunk_mesh(this.chunks[chunk_i]);
|
||||
this.chunks.splice(chunk_i, 1);
|
||||
this.delete_chunk_mesh(chunk);
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
const cx = chunk.x;
|
||||
const cz = chunk.z;
|
||||
|
||||
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++) {
|
||||
for (let z = -1; z <= CHUNK_SIZE; z++) {
|
||||
for (let x = -1; x <= CHUNK_SIZE; x++) {
|
||||
let block: number;
|
||||
// look the 3x3 chunks up once instead of once per border block
|
||||
const around: (Uint32Array | undefined)[] = [];
|
||||
for (let dz = -1; dz <= 1; dz++) {
|
||||
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) {
|
||||
const index = y * CHUNK_SIZE * CHUNK_SIZE + z * CHUNK_SIZE + x;
|
||||
block = chunk.blocks[index] & ID_MASK;
|
||||
} else {
|
||||
const wx = cx * CHUNK_SIZE + x;
|
||||
const wz = cz * CHUNK_SIZE + z;
|
||||
block = this.get_block(wx, y, wz) ?? AIR;
|
||||
for (let z = -1; z <= CHUNK_SIZE; z++) {
|
||||
const dz = z < 0 ? -1 : z >= CHUNK_SIZE ? 1 : 0;
|
||||
const lz = z - dz * CHUNK_SIZE;
|
||||
for (let x = -1; x <= CHUNK_SIZE; x++) {
|
||||
const dx = x < 0 ? -1 : x >= CHUNK_SIZE ? 1 : 0;
|
||||
const lx = x - dx * CHUNK_SIZE;
|
||||
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;
|
||||
}
|
||||
|
||||
const px = x + 1;
|
||||
const pz = z + 1;
|
||||
const pindex = y * size * size + pz * size + px;
|
||||
|
||||
padded[pindex] = block;
|
||||
continue;
|
||||
}
|
||||
for (let y = 0; y < CHUNK_HEIGHT; y++) {
|
||||
padded[y * layer + padded_index] = source[y * CHUNK_AREA + source_index] & ID_MASK;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,3 +514,24 @@ export class Dimension extends Component {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -12,7 +12,11 @@ export function start_game(world: ClientWorld) {
|
||||
world.clear_entities();
|
||||
|
||||
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);
|
||||
world.add_entity(dimension);
|
||||
|
||||
|
||||
@@ -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
@@ -3,7 +3,8 @@ import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mo
|
||||
import { InputManager } from "../input_manager.ts";
|
||||
import { ClientWorld } from "../client_world.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 {
|
||||
world: ClientWorld;
|
||||
@@ -22,6 +23,7 @@ export class GuiChat extends GuiScreen {
|
||||
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
|
||||
|
||||
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_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();
|
||||
|
||||
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.caret += 1;
|
||||
}
|
||||
@@ -96,10 +101,9 @@ export class GuiChat extends GuiScreen {
|
||||
override on_close(): void {}
|
||||
|
||||
submit() {
|
||||
if (this.text_typed.startsWith("/")) {
|
||||
this.command();
|
||||
} else {
|
||||
// we dont have multiplayer lol?
|
||||
// commands like /give run on the server too
|
||||
if (this.text_typed.trim().length > 0) {
|
||||
this.world.connection.send({ type: "chat", text: this.text_typed });
|
||||
}
|
||||
this.text_typed = "";
|
||||
|
||||
@@ -109,23 +113,4 @@ export class GuiChat extends GuiScreen {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 { 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";
|
||||
|
||||
export interface CraftingRecipe {
|
||||
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),
|
||||
},
|
||||
];
|
||||
import { ClientInventories } from "../inventory.ts";
|
||||
import { ClientMessage, CRAFTING_RESULT_SLOT } from "$/common/protocol.ts";
|
||||
|
||||
const PADDING = 10;
|
||||
|
||||
// the recipes live on the server, which fills in the result slot
|
||||
export class GuiPlayerInventory extends GuiInventoryScreen {
|
||||
override inventory_width = PADDING * 2 + SLOT_SIZE * 9;
|
||||
override inventory_height = PADDING * 2 + SLOT_SIZE * 4 + PADDING + SLOT_SIZE * 3 + PADDING;
|
||||
|
||||
constructor(player_inventory: PlayerInventory) {
|
||||
super(new Inventory(new Container(10)), player_inventory, undefined);
|
||||
constructor(inventories: ClientInventories, send: (message: ClientMessage) => void) {
|
||||
super(inventories, send);
|
||||
|
||||
add_player_hotbar(this, player_inventory, PADDING, PADDING);
|
||||
add_player_inventory(this, player_inventory, PADDING, PADDING * 2 + SLOT_SIZE);
|
||||
add_player_hotbar(this, PADDING, PADDING);
|
||||
add_player_inventory(this, PADDING, PADDING * 2 + SLOT_SIZE);
|
||||
|
||||
const crafting_x = PADDING;
|
||||
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 j = 0; j < 3; j += 1) {
|
||||
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));
|
||||
}
|
||||
|
||||
override on_tick(delta: number): void {
|
||||
super.on_tick(delta);
|
||||
this.update_crafting_result();
|
||||
this.slots.push(
|
||||
new Slot("crafting", CRAFTING_RESULT_SLOT, crafting_x + 5 * SLOT_SIZE, crafting_y + 1 * SLOT_SIZE, true),
|
||||
);
|
||||
}
|
||||
|
||||
override on_render(): void {
|
||||
@@ -134,136 +58,4 @@ export class GuiPlayerInventory extends GuiInventoryScreen {
|
||||
|
||||
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
@@ -1,27 +1,28 @@
|
||||
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 { AssetManager } from "../assets.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 { draw_item, draw_nine_slice } from "../systems/rendering/render_utils.ts";
|
||||
|
||||
export class Slot {
|
||||
inventory: Inventory;
|
||||
container: ContainerKey;
|
||||
index: number;
|
||||
// relative to screen top left
|
||||
x: 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) {
|
||||
this.inventory = inventory;
|
||||
constructor(container: ContainerKey, index: number, x: number, y: number, output = false) {
|
||||
this.container = container;
|
||||
this.index = index;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
get item(): ItemStack | undefined {
|
||||
return this.inventory.container.get_item(this.index);
|
||||
this.output = output;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,25 +35,32 @@ export abstract class GuiScreen {
|
||||
abstract on_close(): void;
|
||||
}
|
||||
|
||||
export class GuiInventoryScreen<Inv extends Inventory = Inventory, Properties = Record<string, unknown> | undefined>
|
||||
extends GuiScreen {
|
||||
inventory: Inv;
|
||||
player_inventory: PlayerInventory;
|
||||
properties: Properties;
|
||||
// a screen of slots. the server owns the contents: clicks are sent to it and guessed locally
|
||||
// so they feel instant, then whatever the server syncs back wins
|
||||
export class GuiInventoryScreen extends GuiScreen {
|
||||
inventories: ClientInventories;
|
||||
send: (message: ClientMessage) => void;
|
||||
|
||||
slots: Slot[] = [];
|
||||
hovering: Slot | undefined;
|
||||
inventory_width: number = 500;
|
||||
inventory_height: number = 500;
|
||||
|
||||
constructor(
|
||||
inventory: Inv,
|
||||
player_inventory: PlayerInventory,
|
||||
properties: Properties,
|
||||
) {
|
||||
constructor(inventories: ClientInventories, send: (message: ClientMessage) => void) {
|
||||
super();
|
||||
this.inventory = inventory;
|
||||
this.player_inventory = player_inventory;
|
||||
this.properties = properties;
|
||||
this.inventories = inventories;
|
||||
this.send = send;
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -60,20 +68,17 @@ export class GuiInventoryScreen<Inv extends Inventory = Inventory, Properties =
|
||||
this.y = canvas.height / 2 - (this.inventory_height / 2);
|
||||
|
||||
this.handle_interaction();
|
||||
|
||||
if (this.player_inventory.holding_item?.amount === 0) {
|
||||
this.player_inventory.holding_item = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
on_render(): void {
|
||||
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
||||
|
||||
for (const slot of this.slots) {
|
||||
const hovered = slot === this.hovering;
|
||||
draw_nine_slice(
|
||||
ui,
|
||||
slot.inventory.hovering_slot === slot.index ? 19 * 16 : 160 + 32,
|
||||
slot.inventory.hovering_slot === slot.index ? 16 : 0,
|
||||
hovered ? 19 * 16 : 160 + 32,
|
||||
hovered ? 16 : 0,
|
||||
16,
|
||||
16,
|
||||
4,
|
||||
@@ -88,198 +93,83 @@ export class GuiInventoryScreen<Inv extends Inventory = Inventory, Properties =
|
||||
}
|
||||
|
||||
for (const slot of this.slots) {
|
||||
const item = slot.item;
|
||||
const item = this.get_container(slot.container)?.get_item(slot.index);
|
||||
if (item) {
|
||||
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();
|
||||
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() {
|
||||
this.player_inventory.hovering_slot = -1;
|
||||
this.inventory.hovering_slot = -1;
|
||||
this.hovering = undefined;
|
||||
const mouse = InputManager.get_mouse_position();
|
||||
|
||||
for (const slot of this.slots) {
|
||||
const mouse = InputManager.get_mouse_position();
|
||||
const slot_x = slot.x + this.x;
|
||||
const slot_y = slot.y + this.y;
|
||||
const hovering = point_inside_rec(mouse.x, mouse.y, slot_x, slot_y, SLOT_SIZE, SLOT_SIZE);
|
||||
if (hovering) {
|
||||
slot.inventory.hovering_slot = slot.index;
|
||||
const hovering = point_inside_rec(mouse.x, mouse.y, this.x + slot.x, this.y + slot.y, SLOT_SIZE, SLOT_SIZE);
|
||||
if (!hovering) {
|
||||
continue;
|
||||
}
|
||||
this.hovering = slot;
|
||||
|
||||
if (InputManager.is_mouse_pressed(0)) {
|
||||
InputManager.consume_mouse(0);
|
||||
this.handle_left_click(slot);
|
||||
return;
|
||||
}
|
||||
|
||||
if (InputManager.is_mouse_pressed(2)) {
|
||||
InputManager.consume_mouse(2);
|
||||
this.handle_right_click(slot);
|
||||
for (const button of [0, 2]) {
|
||||
if (InputManager.is_mouse_pressed(button)) {
|
||||
InputManager.consume_mouse(button);
|
||||
this.click(slot, button);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch_item_with_holding(inventory: Inventory, index: number) {
|
||||
const container_slot = inventory.container.get_slot(index);
|
||||
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);
|
||||
}
|
||||
click(slot: Slot, button: number) {
|
||||
this.send({ type: "click", container: slot.container, index: slot.index, button });
|
||||
|
||||
handle_left_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 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);
|
||||
// output slots depend on server side things like recipes, just wait for the server
|
||||
const container = this.get_container(slot.container);
|
||||
if (container && !slot.output) {
|
||||
click_slot(container, slot.index, this.inventories.cursor, button);
|
||||
}
|
||||
}
|
||||
|
||||
/*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
|
||||
|
||||
export function add_player_inventory(
|
||||
handler: GuiInventoryScreen,
|
||||
player_inventory: PlayerInventory,
|
||||
offset_x: number,
|
||||
offset_y: number,
|
||||
) {
|
||||
export function add_player_inventory(handler: GuiInventoryScreen, offset_x: number, offset_y: number) {
|
||||
for (let i = 0; i < 3; ++i) {
|
||||
for (let l = 0; l < 9; ++l) {
|
||||
handler.slots.push(
|
||||
new Slot(
|
||||
player_inventory,
|
||||
l + i * 9 + 9,
|
||||
offset_x + l * SLOT_SIZE,
|
||||
offset_y + i * SLOT_SIZE,
|
||||
),
|
||||
new Slot("inventory", l + i * 9 + 9, offset_x + l * SLOT_SIZE, offset_y + i * SLOT_SIZE),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function add_player_hotbar(
|
||||
handler: GuiInventoryScreen,
|
||||
player_inventory: PlayerInventory,
|
||||
offset_x: number,
|
||||
offset_y: number,
|
||||
) {
|
||||
export function add_player_hotbar(handler: GuiInventoryScreen, offset_x: number, offset_y: number) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
type_id: string;
|
||||
amount: number;
|
||||
max_amount: number;
|
||||
data?: T;
|
||||
|
||||
constructor(type_id: string | string, amount: number = 1, max_amount: number = 64) {
|
||||
this.type_id = type_id;
|
||||
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;
|
||||
}
|
||||
// the local copies of the containers the server syncs to this player
|
||||
export class ClientInventories {
|
||||
inventory = new Container(9 * 4);
|
||||
crafting = new Container(10);
|
||||
// the open server screen's container
|
||||
screen: Container | undefined;
|
||||
cursor: Cursor = { item: undefined };
|
||||
hotbar_selected = 0;
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:axe", {
|
||||
texture_id: "bworld:axe",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:coal", {
|
||||
texture_id: "bworld:coal",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:copper_ingot", {
|
||||
texture_id: "bworld:copper_ingot",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:gold_ingot", {
|
||||
texture_id: "bworld:gold_ingot",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:hoe", {
|
||||
texture_id: "bworld:hoe",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:iron_ingot", {
|
||||
texture_id: "bworld:iron_ingot",
|
||||
});
|
||||
@@ -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";
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:pickaxe", {
|
||||
texture_id: "bworld:pickaxe",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:stick", {
|
||||
texture_id: "bworld:stick",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:tin_ingot", {
|
||||
texture_id: "bworld:tin_ingot",
|
||||
});
|
||||
@@ -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}`;
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -1,10 +1,13 @@
|
||||
import { AssetManager } from "./assets.ts";
|
||||
import { ClientWorld } from "./client_world.ts";
|
||||
import { InputManager } from "./input_manager.ts";
|
||||
import { begin_drawing, clear_background, end_drawing, init_font, init_window } from "./renderer/mod.ts";
|
||||
|
||||
await import("./blocks/mod.ts");
|
||||
await import("./items/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";
|
||||
import { confirm_mods } from "./confirm_mods.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 {
|
||||
running = false;
|
||||
@@ -38,7 +41,7 @@ export class ClientLoop {
|
||||
}
|
||||
|
||||
loop(time: number) {
|
||||
if (!this.running) {
|
||||
if (!this.running || is_stopped()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -70,15 +73,12 @@ if (!canvas) {
|
||||
throw Error("Canvas was not found");
|
||||
}
|
||||
|
||||
init_window(canvas);
|
||||
await init_window(canvas);
|
||||
|
||||
InputManager.initialize(canvas);
|
||||
|
||||
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:roguelike", "/assets/sprites/roguelike.png");
|
||||
AssetManager.instance.load("bworld:ui", "/assets/sprites/ui.png");
|
||||
@@ -90,9 +90,51 @@ await AssetManager.instance.load_all();
|
||||
|
||||
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);
|
||||
loop.start();
|
||||
try {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -2,14 +2,14 @@ import { Position } from "$/common/components/position.ts";
|
||||
import { Velocity } from "$/common/components/velocity.ts";
|
||||
import { Component, Entity } from "$/common/ecs/mod.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 { ClientWorld } from "./client_world.ts";
|
||||
import { GuiScreen } from "./gui/gui_screen.ts";
|
||||
import { CollisionCuboid } from "./components/collision.ts";
|
||||
|
||||
export class PlayerComponent extends Component {
|
||||
player_inventory = new PlayerInventory("");
|
||||
inventories = new ClientInventories();
|
||||
screens: GuiScreen[] = [];
|
||||
render_distance = 6;
|
||||
|
||||
@@ -27,12 +27,16 @@ export class PlayerComponent extends Component {
|
||||
|
||||
export function create_player(world: ClientWorld) {
|
||||
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 PlayerControls());
|
||||
|
||||
player.add(new PlayerComponent());
|
||||
player.add(new Camera());
|
||||
const player_component = player.add(new PlayerComponent());
|
||||
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));
|
||||
|
||||
world.add_entity(player);
|
||||
|
||||
+374
-166
@@ -1,28 +1,44 @@
|
||||
import { Camera } from "../components/camera.ts";
|
||||
import { mat4 } from "gl-matrix";
|
||||
|
||||
export let gl: WebGLRenderingContext;
|
||||
export let device: GPUDevice;
|
||||
export let canvas: HTMLCanvasElement;
|
||||
|
||||
const MAX_SPRITES = 100000;
|
||||
const VERTS_PER_SPRITE = 6;
|
||||
const FLOATS_PER_VERT = 9;
|
||||
const VERTEX_STRIDE = FLOATS_PER_VERT * 4;
|
||||
|
||||
let program: WebGLProgram;
|
||||
let main_buffer: WebGLBuffer;
|
||||
const SAMPLE_COUNT = 4;
|
||||
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);
|
||||
let vert_index = 0;
|
||||
|
||||
let pos_loc: GLint;
|
||||
let uv_loc: GLint;
|
||||
let color_loc: GLint;
|
||||
let texture_loc: WebGLUniformLocation | null;
|
||||
let mvp_loc: WebGLUniformLocation | null;
|
||||
let col_diffuse_loc: WebGLUniformLocation | null;
|
||||
// every flush in a frame appends to this, so earlier draws dont get overwritten before the submit
|
||||
let stream_buffer: GPUBuffer;
|
||||
let stream_offset = 0;
|
||||
|
||||
let current_texture: WebGLTexture | null = null;
|
||||
export let white_tex: WebGLTexture | null = null;
|
||||
let uniform_buffer: GPUBuffer;
|
||||
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;
|
||||
|
||||
@@ -33,91 +49,124 @@ const proj = mat4.create();
|
||||
const view = mat4.create();
|
||||
const mvp = mat4.create();
|
||||
|
||||
const vertex_src = `#version 300 es
|
||||
precision mediump float;
|
||||
in vec3 vertexPosition;
|
||||
in vec2 vertexTexCoord;
|
||||
in vec4 vertexColor;
|
||||
out vec2 fragTexCoord;
|
||||
out vec4 fragColor;
|
||||
uniform mat4 mvp;
|
||||
// per frame state
|
||||
let encoder: GPUCommandEncoder | undefined;
|
||||
let pass: GPURenderPassEncoder | undefined;
|
||||
let frame_view: GPUTextureView;
|
||||
let msaa_texture: GPUTexture | undefined;
|
||||
let depth_texture: GPUTexture | undefined;
|
||||
let current_pipeline: GPURenderPipeline | undefined;
|
||||
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() {
|
||||
fragTexCoord = vertexTexCoord;
|
||||
fragColor = vertexColor;
|
||||
gl_Position = mvp*vec4(vertexPosition, 1.0);
|
||||
const shader_src = /* wgsl */ `
|
||||
struct Uniforms {
|
||||
mvp: mat4x4<f32>,
|
||||
}
|
||||
|
||||
@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
|
||||
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) {
|
||||
export async function init_window(canvas_element: HTMLCanvasElement) {
|
||||
canvas = canvas_element;
|
||||
canvas.width = 1800;
|
||||
canvas.height = 900;
|
||||
|
||||
const ctx = canvas.getContext("webgl2", { antialias: true, alpha: false });
|
||||
if (!ctx) {
|
||||
throw new Error("WebGL2 not supported");
|
||||
if (!navigator.gpu) {
|
||||
throw new Error("WebGPU 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()!;
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, main_buffer);
|
||||
sampler = device.createSampler({
|
||||
magFilter: "nearest",
|
||||
minFilter: "nearest",
|
||||
addressModeU: "clamp-to-edge",
|
||||
addressModeV: "clamp-to-edge",
|
||||
});
|
||||
|
||||
const stride = FLOATS_PER_VERT * 4;
|
||||
|
||||
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);
|
||||
stream_buffer = create_stream_buffer(8 * 1024 * 1024);
|
||||
create_uniform_buffer(64);
|
||||
|
||||
create_white_texture();
|
||||
}
|
||||
|
||||
export function begin_drawing() {
|
||||
encoder = device.createCommandEncoder();
|
||||
frame_view = context.getCurrentTexture().createView();
|
||||
ensure_render_targets();
|
||||
|
||||
update_2d_mvp();
|
||||
gl.depthFunc(gl.LEQUAL);
|
||||
gl.clearDepth(1.0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
stream_offset = 0;
|
||||
uniform_slot = -1;
|
||||
write_mvp();
|
||||
|
||||
pending_color_clear = true;
|
||||
pending_depth_clear = true;
|
||||
vert_index = 0;
|
||||
}
|
||||
|
||||
export function end_drawing() {
|
||||
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() {
|
||||
@@ -131,19 +180,23 @@ function update_2d_mvp() {
|
||||
const n = -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) {
|
||||
gl.enable(gl.SCISSOR_TEST);
|
||||
|
||||
const flipped_y = canvas.height - (y + height);
|
||||
|
||||
gl.scissor(x, flipped_y, width, height);
|
||||
flush_batch();
|
||||
scissor = { x, y, width, height };
|
||||
if (pass) {
|
||||
apply_scissor(pass);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -152,13 +205,8 @@ export function begin_mode_3d(new_camera: Camera) {
|
||||
mode3d = true;
|
||||
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();
|
||||
write_mvp();
|
||||
}
|
||||
|
||||
export function end_mode_3d() {
|
||||
@@ -169,15 +217,18 @@ export function end_mode_3d() {
|
||||
flush_batch();
|
||||
|
||||
mode3d = false;
|
||||
gl.disable(gl.DEPTH_TEST);
|
||||
gl.disable(gl.CULL_FACE);
|
||||
gl.depthMask(false);
|
||||
gl.disable(gl.POLYGON_OFFSET_FILL);
|
||||
write_mvp();
|
||||
}
|
||||
|
||||
export function clear_background(r: number, g: number, b: number, a = 1) {
|
||||
gl.clearColor(r, g, b, a);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
flush_batch();
|
||||
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() {
|
||||
@@ -185,52 +236,41 @@ export function flush_batch() {
|
||||
return;
|
||||
}
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, main_buffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, vertex_data.subarray(0, vert_index), gl.STREAM_DRAW);
|
||||
|
||||
if (mode3d) {
|
||||
gl.uniformMatrix4fv(mvp_loc, false, mvp);
|
||||
} else {
|
||||
gl.uniformMatrix4fv(mvp_loc, false, ortho);
|
||||
const byte_length = vert_index * 4;
|
||||
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
|
||||
pending_destroy.push(stream_buffer);
|
||||
stream_buffer = create_stream_buffer(Math.max(stream_buffer.size * 2, byte_length));
|
||||
stream_offset = 0;
|
||||
}
|
||||
|
||||
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, vert_index / FLOATS_PER_VERT);
|
||||
device.queue.writeBuffer(stream_buffer, stream_offset, vertex_data, 0, vert_index);
|
||||
draw(stream_buffer, stream_offset, vert_index / FLOATS_PER_VERT);
|
||||
stream_offset += byte_length;
|
||||
|
||||
vert_index = 0;
|
||||
}
|
||||
|
||||
export function flush_buffer(buffer: WebGLBuffer, draw_count: number) {
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
export function flush_buffer(buffer: GPUBuffer, draw_count: number) {
|
||||
draw(buffer, 0, draw_count);
|
||||
}
|
||||
|
||||
if (mode3d) {
|
||||
gl.uniformMatrix4fv(mvp_loc, false, mvp);
|
||||
export function create_vertex_buffer(vertices: Float32Array): GPUBuffer {
|
||||
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 {
|
||||
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() {
|
||||
@@ -242,21 +282,26 @@ export function resize_canvas() {
|
||||
canvas.height = height;
|
||||
canvas.style.width = canvas.width + "px";
|
||||
canvas.style.height = canvas.height + "px";
|
||||
|
||||
gl.viewport(0, 0, width, height);
|
||||
|
||||
gl.useProgram(program);
|
||||
// render targets get recreated at the start of the next frame
|
||||
}
|
||||
}
|
||||
|
||||
export function get_current_texture(): WebGLTexture | null {
|
||||
export function get_current_texture(): GPUTexture | null {
|
||||
return current_texture;
|
||||
}
|
||||
|
||||
export function set_current_texture(texture: WebGLTexture) {
|
||||
export function set_current_texture(texture: GPUTexture) {
|
||||
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) {
|
||||
let i = vert_index;
|
||||
vertex_data[i++] = px;
|
||||
@@ -330,12 +375,115 @@ export function push_quad_vertices(
|
||||
|
||||
// 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() {
|
||||
if (!camera) {
|
||||
return;
|
||||
}
|
||||
|
||||
mat4.perspective(
|
||||
mat4.perspectiveZO(
|
||||
proj,
|
||||
camera.fov,
|
||||
canvas.width / canvas.height,
|
||||
@@ -354,47 +502,107 @@ function update_camera() {
|
||||
mat4.multiply(mvp, proj, view);
|
||||
}
|
||||
|
||||
function compile_shader(type: number, src: string) {
|
||||
const shader = gl.createShader(type)!;
|
||||
function create_pipelines() {
|
||||
const module = device.createShaderModule({ code: shader_src });
|
||||
|
||||
gl.shaderSource(shader, src);
|
||||
gl.compileShader(shader);
|
||||
uniform_layout = device.createBindGroupLayout({
|
||||
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)) {
|
||||
console.error(gl.getShaderInfoLog(shader));
|
||||
throw new Error("Shader compile failed");
|
||||
}
|
||||
const vertex: GPUVertexState = {
|
||||
module,
|
||||
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) {
|
||||
const vs = compile_shader(gl.VERTEX_SHADER, vs_src);
|
||||
const fs = compile_shader(gl.FRAGMENT_SHADER, fs_src);
|
||||
function create_stream_buffer(size: number) {
|
||||
return device.createBuffer({ size, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
|
||||
}
|
||||
|
||||
const program = gl.createProgram()!;
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
function create_uniform_buffer(slots: number) {
|
||||
uniform_buffer = device.createBuffer({
|
||||
size: slots * UNIFORM_SLOT_SIZE,
|
||||
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)) {
|
||||
console.error(gl.getProgramInfoLog(program));
|
||||
throw new Error("Program link failed");
|
||||
function get_texture_bind_group(texture: GPUTexture) {
|
||||
let bind_group = texture_bind_groups.get(texture);
|
||||
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 program;
|
||||
return bind_group;
|
||||
}
|
||||
|
||||
function create_white_texture() {
|
||||
const tex = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
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);
|
||||
|
||||
const tex = create_texture(1, 1);
|
||||
device.queue.writeTexture({ texture: tex }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]);
|
||||
white_tex = tex;
|
||||
}
|
||||
|
||||
@@ -191,3 +191,69 @@ export function push_bottom_face(
|
||||
push_vertex(x2, y, z2, u1, 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
@@ -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";
|
||||
|
||||
export function load_texture(image: HTMLImageElement): Texture {
|
||||
const texture = gl.createTexture();
|
||||
|
||||
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);
|
||||
export function load_texture(image: HTMLImageElement | ImageBitmap): Texture {
|
||||
const texture = create_texture(image.width, image.height);
|
||||
device.queue.copyExternalImageToTexture({ source: image }, { texture }, [image.width, image.height]);
|
||||
|
||||
return {
|
||||
tex: texture,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface Texture {
|
||||
tex: WebGLTexture;
|
||||
tex: GPUTexture;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { PlayerComponent } from "../player.ts";
|
||||
import { GuiPlayerInventory } from "../gui/gui_player_inventory.ts";
|
||||
import { CollisionCuboid } from "../components/collision.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 { GuiInventoryScreen } from "../gui/gui_screen.ts";
|
||||
|
||||
@@ -24,6 +26,7 @@ export class PlayerControlsSystem extends System {
|
||||
const player_component = player.get(PlayerComponent)!;
|
||||
const position = player.get(Position)!;
|
||||
const camera = player.get(Camera)!;
|
||||
const send = (message: ClientMessage) => world.connection.send(message);
|
||||
|
||||
if (player_component.screens.length === 0) {
|
||||
let input_x = 0;
|
||||
@@ -71,7 +74,7 @@ export class PlayerControlsSystem extends System {
|
||||
|
||||
if (InputManager.is_key_pressed(controls.open_inventory)) {
|
||||
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) {
|
||||
player_component.pop_screen();
|
||||
}
|
||||
@@ -119,82 +122,83 @@ export class PlayerControlsSystem extends System {
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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 += 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) {
|
||||
// show it right away, the server decides drops and corrects us if it disagrees
|
||||
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 = 0;
|
||||
}
|
||||
} else if (InputManager.is_mouse_pressed(2)) {
|
||||
// interact if possible
|
||||
const interacted = block_info?.on_interact?.(world.dimension, {
|
||||
x: block.x,
|
||||
y: block.y,
|
||||
z: block.z,
|
||||
id: block_info.id,
|
||||
});
|
||||
if (!interacted) {
|
||||
const inventory = player_component.player_inventory;
|
||||
const hotbar_slot = inventory.container.get_slot(inventory.hotbar_selected);
|
||||
if (hotbar_slot && hotbar_slot.has_item()) {
|
||||
const item = hotbar_slot.get_item()!;
|
||||
const item_info = EverythingRegistry.get<ItemRegistry>("items", item.type_id);
|
||||
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;
|
||||
}
|
||||
}
|
||||
send({ type: "use_block", x: block.x, y: block.y, z: block.z, face: block.face });
|
||||
|
||||
// guess that it places the held block, unless the block does something when used
|
||||
const offset = FACE_OFFSETS[block.face];
|
||||
const target = { x: block.x + offset.x, y: block.y + offset.y, z: block.z + offset.z };
|
||||
const target_id = world.dimension.get_block(target.x, target.y, target.z);
|
||||
const replaceable = target_id === AIR ||
|
||||
EverythingRegistry.get_by_id<BlockRegistry>("blocks", target_id)?.replaceable;
|
||||
// items with components might do something else on the server, like on_use
|
||||
const place_id = holding_item_info?.components ? undefined : holding_item_info?.block_id;
|
||||
if (!block_info.interactive && place_id && replaceable) {
|
||||
world.dimension.add_block({ ...target, id: place_id });
|
||||
hotbar_slot.amount = 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) {
|
||||
const player_inventory = player_component.player_inventory;
|
||||
const previous = inventories.hotbar_selected;
|
||||
const scroll = InputManager.get_wheel_delta();
|
||||
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) {
|
||||
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)) {
|
||||
player_inventory.hotbar_selected = 0;
|
||||
} else if (InputManager.is_key_pressed(controls.hotbar_2)) {
|
||||
player_inventory.hotbar_selected = 1;
|
||||
} else if (InputManager.is_key_pressed(controls.hotbar_3)) {
|
||||
player_inventory.hotbar_selected = 2;
|
||||
} else if (InputManager.is_key_pressed(controls.hotbar_4)) {
|
||||
player_inventory.hotbar_selected = 3;
|
||||
} else if (InputManager.is_key_pressed(controls.hotbar_5)) {
|
||||
player_inventory.hotbar_selected = 4;
|
||||
} else if (InputManager.is_key_pressed(controls.hotbar_6)) {
|
||||
player_inventory.hotbar_selected = 5;
|
||||
} else if (InputManager.is_key_pressed(controls.hotbar_7)) {
|
||||
player_inventory.hotbar_selected = 6;
|
||||
} else if (InputManager.is_key_pressed(controls.hotbar_8)) {
|
||||
player_inventory.hotbar_selected = 7;
|
||||
} else if (InputManager.is_key_pressed(controls.hotbar_9)) {
|
||||
player_inventory.hotbar_selected = 8;
|
||||
const hotbar_keys = [
|
||||
controls.hotbar_1,
|
||||
controls.hotbar_2,
|
||||
controls.hotbar_3,
|
||||
controls.hotbar_4,
|
||||
controls.hotbar_5,
|
||||
controls.hotbar_6,
|
||||
controls.hotbar_7,
|
||||
controls.hotbar_8,
|
||||
controls.hotbar_9,
|
||||
];
|
||||
const pressed = hotbar_keys.findIndex((key) => InputManager.is_key_pressed(key));
|
||||
if (pressed !== -1) {
|
||||
inventories.hotbar_selected = pressed;
|
||||
}
|
||||
|
||||
if (inventories.hotbar_selected !== previous) {
|
||||
send({ type: "select_slot", slot: inventories.hotbar_selected });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { System } from "$/common/ecs/mod.ts";
|
||||
import { World } from "$/common/ecs/world.ts";
|
||||
import { Position } from "$/common/components/position.ts";
|
||||
import { AnimatedSprite, Sprite } from "$/client/components/sprite.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 { PlayerComponent } from "../player.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 {
|
||||
constructor() {
|
||||
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 = camera_entity?.get(Camera);
|
||||
|
||||
@@ -39,6 +40,10 @@ export class RenderSystem extends System {
|
||||
}
|
||||
}
|
||||
|
||||
if (world.connection) {
|
||||
render_remote_players(world.connection);
|
||||
}
|
||||
|
||||
end_mode_3d();
|
||||
|
||||
for (const entity of world.get_entities()) {
|
||||
@@ -56,9 +61,11 @@ export class RenderSystem extends System {
|
||||
|
||||
const player_component = entity.get(PlayerComponent);
|
||||
if (player_component) {
|
||||
render_player_hotbar(player_component.player_inventory);
|
||||
render_player_hotbar(player_component.inventories);
|
||||
render_player_crosshair();
|
||||
}
|
||||
}
|
||||
|
||||
render_chat_log(world, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +1,17 @@
|
||||
import { EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { Chunk, Dimension } from "$/client/components/dimension.ts";
|
||||
import { Camera } from "$/client/components/camera.ts";
|
||||
import { AssetManager } from "$/client/assets.ts";
|
||||
import { flush_buffer, gl, set_current_texture } from "$/client/renderer/mod.ts";
|
||||
import { flush_buffer, set_current_texture } from "$/client/renderer/mod.ts";
|
||||
|
||||
let gdimension: Dimension;
|
||||
|
||||
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;
|
||||
export function render_dimension(dimension: Dimension, _camera: Camera) {
|
||||
dimension.request_meshes();
|
||||
|
||||
set_current_texture(dimension.image.tex);
|
||||
|
||||
for (const chunk of dimension.chunks) {
|
||||
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) {
|
||||
for (const chunk of dimension.chunks.values()) {
|
||||
render_chunk_opaque(chunk);
|
||||
}
|
||||
|
||||
for (const chunk of dimension.chunks) {
|
||||
for (const chunk of dimension.chunks.values()) {
|
||||
render_chunk_transparent(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.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 {
|
||||
canvas,
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
Texture,
|
||||
} from "$/client/renderer/mod.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;
|
||||
|
||||
export function render_player_hotbar(player_inventory: PlayerInventory) {
|
||||
export function render_player_hotbar(inventories: ClientInventories) {
|
||||
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
||||
|
||||
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) {
|
||||
draw_nine_slice(
|
||||
ui,
|
||||
player_inventory.hotbar_selected === index ? 19 * 16 : 160 + 32,
|
||||
player_inventory.hotbar_selected === index ? 16 : 0,
|
||||
inventories.hotbar_selected === index ? 19 * 16 : 160 + 32,
|
||||
inventories.hotbar_selected === index ? 16 : 0,
|
||||
16,
|
||||
16,
|
||||
4,
|
||||
@@ -62,7 +62,7 @@ export function render_player_hotbar(player_inventory: PlayerInventory) {
|
||||
}
|
||||
|
||||
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) {
|
||||
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)) {
|
||||
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) {
|
||||
fn(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { 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";
|
||||
|
||||
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
|
||||
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") {
|
||||
texture_id = item_info.texture_id;
|
||||
} 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}`);
|
||||
|
||||
let front_texture = "bworld:missing";
|
||||
let top_texture = "bworld:missing";
|
||||
let left_texture = "bworld:missing";
|
||||
let front_texture = "engine:missing";
|
||||
let top_texture = "engine:missing";
|
||||
let left_texture = "engine:missing";
|
||||
|
||||
const textures = block_info.textures;
|
||||
|
||||
|
||||
@@ -18,27 +18,47 @@ export class WorldGenerationSystem extends System {
|
||||
const player_chunk_x = Math.floor(position.x / 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) {
|
||||
if (Math.abs(chunk.x - player_chunk_x) > render_distance) {
|
||||
dimension.unload_chunk(chunk.x, chunk.z);
|
||||
// collect first, deleting from the map while iterating it skips entries
|
||||
const to_unload = [];
|
||||
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) {
|
||||
for (let j = player_chunk_z - render_distance; j <= player_chunk_z + render_distance; j += 1) {
|
||||
const maybe_chunk = dimension.chunks.find((chunk) => chunk.x === i && chunk.z === j);
|
||||
if (!maybe_chunk || !maybe_chunk.generated) {
|
||||
console.log("loading chunk", i, j);
|
||||
dimension.load_chunk(i, j);
|
||||
// only generate one chunk per frame
|
||||
return;
|
||||
// dont queue up the whole area at once, so walking somewhere new gets the close chunks first
|
||||
const max_in_flight = dimension.workers.size * 2;
|
||||
if (dimension.pending_generation.size >= max_in_flight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const missing: { x: number; z: number; distance: number }[] = [];
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { SpriteRegion } from "$/common/constants.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;
|
||||
|
||||
@@ -260,22 +264,69 @@ const FACE_PUSHING_FUNCTIONS = {
|
||||
right: push_right_face,
|
||||
} as const;
|
||||
|
||||
self.onmessage = (event) => {
|
||||
const { chunk_x, chunk_z, padded_chunk, blocks_registry, textures_info, image } = event.data;
|
||||
const [opaque_vertices, opaque_count, transparent_vertices, transparent_count] = make_chunk_mesh(
|
||||
chunk_x,
|
||||
chunk_z,
|
||||
padded_chunk,
|
||||
blocks_registry,
|
||||
textures_info,
|
||||
image,
|
||||
);
|
||||
self.postMessage(
|
||||
{ opaque_vertices, opaque_count, transparent_vertices, transparent_count, chunk_x, chunk_z },
|
||||
[opaque_vertices.buffer, transparent_vertices.buffer],
|
||||
);
|
||||
let blocks_registry: BlockRegistry[] = [];
|
||||
let block_ids: Record<string, number> = {};
|
||||
let textures_info: TexturesInfo = {};
|
||||
let image: Texture;
|
||||
let worldgen: WorldgenSetup | undefined;
|
||||
// numeric id to what a generated block stores, with its default states. matches the server
|
||||
let default_values: number[] = [];
|
||||
// generating has to wait for mods' worldgen scripts, or this worker's terrain wouldn't match the server's
|
||||
let worldgen_ready: Promise<void> = Promise.resolve();
|
||||
|
||||
self.onmessage = async (event: MessageEvent<ToChunkWorker>) => {
|
||||
const message = event.data;
|
||||
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(
|
||||
chunk_x: number,
|
||||
chunk_z: number,
|
||||
@@ -314,12 +365,12 @@ function make_chunk_mesh(
|
||||
const block_info = blocks_registry[block_nid];
|
||||
|
||||
const texture_ids = {
|
||||
top: "bworld:missing",
|
||||
bottom: "bworld:missing",
|
||||
front: "bworld:missing",
|
||||
back: "bworld:missing",
|
||||
left: "bworld:missing",
|
||||
right: "bworld:missing",
|
||||
top: "engine:missing",
|
||||
bottom: "engine:missing",
|
||||
front: "engine:missing",
|
||||
back: "engine:missing",
|
||||
left: "engine:missing",
|
||||
right: "engine:missing",
|
||||
};
|
||||
|
||||
const textures = block_info.textures;
|
||||
@@ -1,6 +1,4 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { ItemStack } from "../inventory.ts";
|
||||
import { PlayerComponent } from "../player.ts";
|
||||
import { EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
interface CropsRegistry {
|
||||
total_stages: number;
|
||||
@@ -18,3 +18,17 @@ export const VOID = 0xFFFFFFFF;
|
||||
|
||||
export const ID_MASK = 0xFFFF;
|
||||
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 },
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Block, Dimension } from "$/client/components/dimension.ts";
|
||||
import { ItemStack } from "$/client/inventory.ts";
|
||||
import type { ItemStack } from "./inventory.ts";
|
||||
|
||||
export class EverythingRegistry {
|
||||
static #key_to_id = new Map<string, Map<string, number>>();
|
||||
static #id_to_value = new Map<string, unknown[]>();
|
||||
|
||||
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)) {
|
||||
this.#key_to_id.set(registry, new Map());
|
||||
this.#id_to_value.set(registry, []);
|
||||
@@ -49,6 +51,18 @@ export class EverythingRegistry {
|
||||
static get_registry<T>(registry: string): 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 {
|
||||
@@ -94,12 +108,13 @@ export interface BlockRegistry {
|
||||
states?: BlockStateDefinition[];
|
||||
variants?: Record<string, BlockStateVariant>;
|
||||
|
||||
on_create?(dimension: Dimension, block: Block): void;
|
||||
on_break?(dimension: Dimension, block: Block): void;
|
||||
on_click?(dimension: Dimension, block: Block): void;
|
||||
on_interact?(dimension: Dimension, block: Block): boolean;
|
||||
on_tick?(dimension: Dimension, block: Block, tick_delta: number): void;
|
||||
on_second?(dimension: Dimension, block: Block, second_delta: number): void;
|
||||
// right clicking it does something instead of placing a block, clients don't predict placing against it.
|
||||
// behavior runs on the server, see server/game/blocks.ts
|
||||
interactive?: boolean;
|
||||
// placing a block into it replaces it, like water
|
||||
replaceable?: boolean;
|
||||
// custom components from mods and their params, the server runs them
|
||||
components?: Record<string, unknown>;
|
||||
|
||||
compiled_states?: CompiledStateDefinition[];
|
||||
}
|
||||
@@ -107,8 +122,10 @@ export interface BlockRegistry {
|
||||
export interface ItemRegistry<T = unknown | undefined> {
|
||||
texture_id: string | ((item: ItemStack<T>) => string);
|
||||
block_id?: string;
|
||||
|
||||
place?(dimension: Dimension, block: Block): void;
|
||||
tool_type?: string;
|
||||
max_stack?: number;
|
||||
lore?: string;
|
||||
components?: Record<string, unknown>;
|
||||
|
||||
on_create?(item: ItemStack<T>): void;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -1,5 +1,4 @@
|
||||
import { AssetManager } from "../client/assets.ts";
|
||||
import { ID_MASK, SpriteRegion, STATE_SHIFT } from "./constants.ts";
|
||||
import { ID_MASK, STATE_SHIFT } from "./constants.ts";
|
||||
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "./everything_registry.ts";
|
||||
|
||||
export function point_inside_rec(
|
||||
@@ -16,13 +15,6 @@ export function point_inside_rec(
|
||||
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) {
|
||||
const x0 = sqx;
|
||||
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) {
|
||||
// TODO: handle block textures
|
||||
let texture_id = "bworld:missing";
|
||||
let texture_id = "engine:missing";
|
||||
if (typeof block.textures === "string") {
|
||||
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);
|
||||
|
||||
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) {
|
||||
@@ -106,3 +112,8 @@ function compile_block_states(block_info: BlockRegistry) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
{
|
||||
"tasks": {
|
||||
"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": {
|
||||
"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"],
|
||||
"fmt": {
|
||||
@@ -15,9 +19,15 @@
|
||||
},
|
||||
"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",
|
||||
"@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/http": "jsr:@std/http@^1.0.23",
|
||||
"@std/path": "jsr:@std/path@^1.0.0",
|
||||
"gl-matrix": "npm:gl-matrix@^3.4.4",
|
||||
"marked": "npm:marked@^17.0.3"
|
||||
}
|
||||
|
||||
@@ -3,10 +3,23 @@
|
||||
"specifiers": {
|
||||
"jsr:@gfx/canvas-wasm@~0.4.2": "0.4.2",
|
||||
"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.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/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/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/streams@^1.0.17": "1.1.2",
|
||||
"npm:gl-matrix@^3.4.4": "3.4.4",
|
||||
"npm:marked@^17.0.3": "17.0.3"
|
||||
},
|
||||
@@ -14,30 +27,71 @@
|
||||
"@gfx/canvas-wasm@0.4.2": {
|
||||
"integrity": "d653be3bd12cb2fa9bbe5d1b1f041a81b91d80b68502761204aaf60e4592532a",
|
||||
"dependencies": [
|
||||
"jsr:@std/encoding"
|
||||
"jsr:@std/encoding@1.0.5"
|
||||
]
|
||||
},
|
||||
"@paulaboks/rng@0.0.3": {
|
||||
"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": {
|
||||
"integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04"
|
||||
},
|
||||
"@std/encoding@1.0.10": {
|
||||
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
|
||||
},
|
||||
"@std/fmt@1.0.10": {
|
||||
"integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5"
|
||||
},
|
||||
"@std/fs@1.0.23": {
|
||||
"integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal",
|
||||
"jsr:@std/path"
|
||||
"jsr:@std/internal@^1.0.12",
|
||||
"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": {
|
||||
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
|
||||
},
|
||||
"@std/media-types@1.1.0": {
|
||||
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
|
||||
},
|
||||
"@std/net@1.0.6": {
|
||||
"integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c"
|
||||
},
|
||||
"@std/path@1.1.4": {
|
||||
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
"jsr:@std/internal@^1.0.12"
|
||||
]
|
||||
},
|
||||
"@std/streams@1.1.2": {
|
||||
"integrity": "0249bf9b78a999f57032ca4d8e7ccaa57579090242640b35ddc948fbb745d3af"
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
@@ -49,11 +103,18 @@
|
||||
"bin": true
|
||||
}
|
||||
},
|
||||
"remote": {
|
||||
"http://localhost:8768/mods/copper_tools/33af7e7e9e94/client.js": "2f5008856e1e7b13d0fe658aefcdea38dea80b4805e41101c5ff6c2d3d258245",
|
||||
"http://localhost:8768/mods/copper_tools/33af7e7e9e94/worldgen.js": "a3da997bdfee885433de282338bdba388825868f122c9a8759b2f1bd67120c78"
|
||||
},
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"jsr:@gfx/canvas-wasm@~0.4.2",
|
||||
"jsr:@paulaboks/rng@^0.0.3",
|
||||
"jsr:@std/assert@1",
|
||||
"jsr:@std/fs@^1.0.23",
|
||||
"jsr:@std/http@^1.0.23",
|
||||
"jsr:@std/path@1",
|
||||
"npm:gl-matrix@^3.4.4",
|
||||
"npm:marked@^17.0.3"
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"block": {
|
||||
"id": "bworld:dirt",
|
||||
"textures": "bworld:dirt",
|
||||
"collision": false,
|
||||
"mining": {
|
||||
"toughness": 2,
|
||||
"tool": "shovel"
|
||||
},
|
||||
"drops": "bworld:dirt"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"block": {
|
||||
"id": "bworld:glass",
|
||||
"textures": "bworld:glass",
|
||||
"transparent": true,
|
||||
"mining": {
|
||||
"toughness": 3,
|
||||
"tool": "pickaxe"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"block": {
|
||||
"id": "bworld:leaves",
|
||||
"textures": "bworld:leaves",
|
||||
"transparent": true,
|
||||
"mining": {
|
||||
"toughness": 3,
|
||||
"tool": "hoe"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user