Compare commits

...
25 Commits
Author SHA1 Message Date
paula ef25e66f08 Getting some textures from voxellibre 2026-09-25 17:58:05 -03:00
paula a469202959 Credits page 2026-09-25 17:52:59 -03:00
paula e6f5db8a89 19 blocks 2026-09-25 17:50:31 -03:00
paula fdd236071e Tree generation 2026-09-25 17:41:36 -03:00
paula ea9d821047 Tweak gravity and jumping 2026-09-25 17:30:52 -03:00
paula 05f80c81ad Remove collision from water 2026-09-25 17:21:46 -03:00
paula d07528bd0e Better world generation 2026-09-25 17:14:26 -03:00
paula c750321ced Main menu 2026-09-25 16:51:23 -03:00
paula d107405f62 Q to drop 2026-09-25 16:42:19 -03:00
paula a254b96f65 Item entities 2026-09-25 16:26:45 -03:00
paula 5fb23cf404 Change how ticking works 2026-09-25 16:13:38 -03:00
paula 213363d0be No more ecs 2026-09-25 16:05:25 -03:00
paula 4539898c58 Lighting system 2026-09-25 15:40:09 -03:00
paula 25f8c683bb Fix dark lines when viewing blocks from far away 2026-09-25 15:22:33 -03:00
paula f8d406dcf0 Fix transparent blocks 2026-09-25 15:01:03 -03:00
paula dfabe40e7a Server actually ticks 2026-09-25 13:25:25 -03:00
paula 2a2ccae9ce Improve networking 2026-09-25 00:08:01 -03:00
paula 6458bc0440 Implement main game as a mod 2026-09-24 23:49:34 -03:00
paula bb42dd662e Test mod 2026-09-24 23:28:01 -03:00
paula 79faa556de Server authority 2026-09-24 19:34:07 -03:00
paula 1bef94ce0c Mods plan 2026-09-24 19:11:19 -03:00
paula d12fa84b00 Optimize renderer 2026-09-24 18:52:20 -03:00
paula 14aba6b129 Webgpu renderer 2026-09-24 18:41:52 -03:00
paula a37ffd9127 Server 2026-09-24 18:34:16 -03:00
paula a758037f20 Tool 2026-04-14 10:51:57 -03:00
297 changed files with 15173 additions and 4289 deletions
+3
View File
@@ -1 +1,4 @@
build/
world.json
world.json.tmp
server_mods/
+1193
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 B

+170 -28
View File
@@ -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,150 @@ 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>;
credits?: 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 credits = manifest.credits ? Deno.readTextFileSync(`${mod.dir}/${manifest.credits}`) : undefined;
const hash = await short_hash([data_json, client ?? "", worldgen ?? "", credits ?? ""]);
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);
}
if (credits !== undefined) {
listing.credits = `${public_dir}/credits.md`;
listing.sha256.credits = await sha256(credits);
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.credits}`, credits);
}
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 +260,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) {
-97
View File
@@ -1,97 +0,0 @@
import { marked } from "marked";
import { AssetManager } from "./assets.ts";
export async function open_about() {
const popup = self.open(
"",
"popupWindow",
"width=500,height=400",
);
if (!popup) {
alert("oh no");
return;
}
popup.document.body.innerHTML = await marked.parse(AssetManager.instance.get("bworld:assets_text"));
popup.document.head.innerHTML = `<style>
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.7;
color: #e5e7eb;
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
}
h1, h2, h3, h4 {
font-weight: 600;
line-height: 1.3;
margin-top: 2rem;
margin-bottom: 1rem;
}
h1 {
font-size: 2rem;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.3rem;
}
h2 {
font-size: 1.5rem;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.3rem;
}
h3 {
font-size: 1.25rem;
}
p {
margin: 1rem 0;
}
a {
color: #2563eb;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
ul, ol {
padding-left: 2rem;
margin: 1rem 0;
}
code {
background: #1f2937;
padding: 0.2em 0.4em;
border-radius: 4px;
font-size: 0.9em;
}
pre {
background: #0f172a;
color: #f9fafb;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
}
pre code {
background: none;
padding: 0;
color: inherit;
}
blockquote {
border-left: 4px solid #d1d5db;
padding-left: 1rem;
color: #6b7280;
margin: 1rem 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 1rem 0;
}
th, td {
border: 1px solid #e5e7eb;
padding: 0.5rem;
}
th {
background: #f9fafb;
text-align: left;
}
</style>`;
}
-44
View File
@@ -1,44 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
import { Container, Inventory } from "../inventory.ts";
import { PlayerComponent } from "../player.ts";
import { GuiChest } from "$/client/gui/gui_chest.ts";
interface TileChestData {
inventory: Inventory;
}
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:chest", {
id: "bworld:chest",
textures: "bworld:planks",
toughness: 8,
requires_tool: false,
tool_to_break: "axe",
drop_table: "bworld:chest",
has_collision: false,
on_interact(dimension, block) {
const [player] = dimension.world.get_tag("player")!;
const player_component = player.get(PlayerComponent)!;
const block_data = dimension.get_block_data<TileChestData>(block.x, block.y, block.z);
if (block_data && block_data.data.inventory) {
const gui = new GuiChest(block_data.data.inventory, player_component.player_inventory);
player_component.screens.push(gui);
return true;
}
return false;
},
on_create(dimension, block) {
dimension.add_block_data({
id: block.id,
x: block.x,
y: block.y,
z: block.z,
data: {
inventory: new Inventory(new Container(9 * 3)),
},
});
},
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:coal_ore", {
id: "bworld:coal_ore",
textures: "bworld:stone_coal",
has_collision: true,
drop_table: "bworld:coal_ore",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:copper_ore", {
id: "bworld:copper_ore",
textures: "bworld:stone_copper",
has_collision: true,
drop_table: "bworld:copper_ore",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-26
View File
@@ -1,26 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
import { PlayerComponent } from "../player.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:dirt", {
id: "bworld:dirt",
textures: "bworld:dirt",
has_collision: false,
drop_table: "bworld:dirt",
toughness: 2,
requires_tool: false,
tool_to_break: "shovel",
on_interact(dimension, block) {
const [player] = dimension.world.get_tag("player")!;
const player_inventory = player.get(PlayerComponent)!.player_inventory;
const maybe_item = player_inventory.container.get_item(player_inventory.hotbar_selected);
if (maybe_item && maybe_item.type_id === "bworld:hoe") {
dimension.add_block({ x: block.x, y: block.y, z: block.z, id: "bworld:hoed_dirt" });
return true;
}
return false;
},
});
register_block_item(block);
-200
View File
@@ -1,200 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { Container, Inventory, ItemStack } from "../inventory.ts";
import { PlayerComponent } from "../player.ts";
import { GuiFurnace } from "$/client/gui/gui_furnace.ts";
import { register_block_item } from "../../common/utils.ts";
interface FurnaceRecipe {
input: string;
output: ItemStack;
cook_time: number;
}
const FURNACE_RECIPES: FurnaceRecipe[] = [
{
input: "bworld:log",
output: new ItemStack("bworld:coal", 1),
cook_time: 100,
},
{
input: "bworld:coal_ore",
output: new ItemStack("bworld:coal", 1),
cook_time: 100,
},
{
input: "bworld:iron_ore",
output: new ItemStack("bworld:iron_ingot", 1),
cook_time: 200,
},
{
input: "bworld:tin_ore",
output: new ItemStack("bworld:tin_ingot", 1),
cook_time: 200,
},
{
input: "bworld:copper_ore",
output: new ItemStack("bworld:copper_ingot", 1),
cook_time: 200,
},
{
input: "bworld:gold_ore",
output: new ItemStack("bworld:gold_ingot", 1),
cook_time: 200,
},
];
function get_recipe(input?: ItemStack | undefined): FurnaceRecipe | undefined {
if (!input) {
return;
}
return FURNACE_RECIPES.find((r) => r.input === input.type_id);
}
function can_craft(container: Container, recipe?: FurnaceRecipe) {
if (!recipe) {
return false;
}
const output = container.get_item(2);
if (!output) {
return true;
}
if (output.type_id !== recipe.output.type_id) {
return false;
}
return output.amount < output.max_amount;
}
const FUEL_VALUES: Record<string, number> = {
"bworld:coal": 1000,
"bworld:log": 100,
};
function get_fuel_value(item?: ItemStack | undefined): number {
if (!item) {
return 0;
}
return FUEL_VALUES[item.type_id] ?? 0;
}
function has_fuel(container: Container) {
return get_fuel_value(container.get_item(1)) > 0;
}
function consume_fuel(container: Container): number {
const fuel = container.get_slot(1)!;
const value = get_fuel_value(fuel.get_item());
if (fuel.has_item()) {
fuel.amount! -= 1;
}
return value;
}
function craft(container: Container, recipe: FurnaceRecipe) {
const input = container.get_item(0)!;
const output = container.get_item(2);
if (!output) {
container.set_item(2, recipe.output.clone());
} else {
output.amount += recipe.output.amount;
}
input.amount -= 1;
container.set_item(0, input.amount > 0 ? input : undefined);
}
interface TileChestData {
inventory: Inventory;
progress: number;
progress_max: number;
fuel: number;
fuel_max: number;
}
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:furnace", {
id: "bworld:furnace",
textures: { front: "bworld:furnace", side: "bworld:stone" },
has_collision: true,
drop_table: "bworld:furnace",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
on_interact(dimension, block) {
const [player] = dimension.world.get_tag("player")!;
const player_component = player.get(PlayerComponent)!;
const block_data = dimension.get_block_data<TileChestData>(block.x, block.y, block.z);
if (block_data && block_data.data.inventory) {
const gui = new GuiFurnace(block_data.data.inventory, player_component.player_inventory, block_data.data);
player_component.screens.push(gui);
return true;
}
return false;
},
on_create(dimension, block) {
dimension.add_block_data({
id: block.id,
x: block.x,
y: block.y,
z: block.z,
data: {
inventory: new Inventory(new Container(3)),
progress: 0,
progress_max: 0,
fuel: 0,
fuel_max: 0,
},
});
},
on_break() {
// TODO: remove block data
},
on_tick(dimension, block) {
const block_data = dimension.get_block_data<TileChestData>(block.x, block.y, block.z);
if (!block_data) {
return;
}
const data = block_data.data;
const container = data.inventory.container;
const input = container.get_item(0);
const recipe = get_recipe(input);
// burn fuel
if (data.fuel > 0) {
data.fuel -= 1;
}
if (!can_craft(container, recipe)) {
data.progress = 0;
return;
}
// refuel
if (data.fuel === 0 && has_fuel(container)) {
data.fuel = consume_fuel(container);
data.fuel_max = data.fuel;
}
// cook
if (data.fuel > 0 && recipe) {
data.progress_max = recipe.cook_time;
data.progress += 1;
if (data.progress >= recipe.cook_time) {
data.progress = 0;
craft(container, recipe);
}
}
},
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:glass", {
id: "bworld:glass",
textures: "bworld:glass",
has_collision: true,
transparent: true,
toughness: 3,
requires_tool: false,
tool_to_break: "pickaxe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:gold_ore", {
id: "bworld:gold_ore",
textures: "bworld:stone_gold",
has_collision: true,
drop_table: "bworld:gold_ore",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-24
View File
@@ -1,24 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { PlayerComponent } from "../player.ts";
EverythingRegistry.register<BlockRegistry>("blocks", "bworld:grass", {
id: "bworld:grass",
textures: { top: "bworld:grass_top", bottom: "bworld:dirt", "side": "bworld:grass_side" },
has_collision: false,
drop_table: "bworld:dirt",
toughness: 2,
requires_tool: false,
tool_to_break: "shovel",
on_interact(dimension, block) {
const [player] = dimension.world.get_tag("player")!;
const player_inventory = player.get(PlayerComponent)!.player_inventory;
const item = player_inventory.container.get_item(player_inventory.hotbar_selected);
if (item?.type_id === "bworld:hoe") {
block.id = "bworld:hoed_dirt";
dimension.add_block(block);
return true;
}
return false;
},
});
-18
View File
@@ -1,18 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
// TODO: watered state
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:hoed_dirt", {
id: "bworld:hoed_dirt",
textures: { side: "bworld:dirt", top: "bworld:hoed_dirt", bottom: "bworld:dirt" },
has_collision: false,
drop_table: "bworld:dirt",
toughness: 5,
requires_tool: false,
tool_to_break: "shovel",
states: [
{ name: "watered", bits: 1, default: 0 },
],
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:iron_ore", {
id: "bworld:iron_ore",
textures: "bworld:stone_iron",
has_collision: true,
drop_table: "bworld:iron_ore",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:leaves", {
id: "bworld:leaves",
textures: "bworld:leaves",
has_collision: true,
transparent: true,
toughness: 3,
requires_tool: false,
tool_to_break: "hoe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:log", {
id: "bworld:log",
textures: { side: "bworld:log_side", top: "bworld:log_top", bottom: "bworld:log_top" },
has_collision: true,
drop_table: "bworld:log",
toughness: 3,
requires_tool: false,
tool_to_break: "axe",
});
register_block_item(block);
-19
View File
@@ -1,19 +0,0 @@
import "./grass.ts";
import "./dirt.ts";
import "./crops.ts";
import "./water.ts";
import "./chest.ts";
import "./furnace.ts";
import "./stone.ts";
import "./log.ts";
import "./sand.ts";
import "./snow.ts";
import "./glass.ts";
import "./hoed_dirt.ts";
import "./leaves.ts";
import "./coal_ore.ts";
import "./copper_ore.ts";
import "./iron_ore.ts";
import "./tin_ore.ts";
import "./gold_ore.ts";
import "./planks.ts";
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:planks", {
id: "bworld:planks",
textures: "bworld:planks",
has_collision: true,
drop_table: "bworld:log",
toughness: 3,
requires_tool: false,
tool_to_break: "axe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:sand", {
id: "bworld:sand",
textures: "bworld:sand",
has_collision: true,
drop_table: "bworld:sand",
toughness: 3,
requires_tool: false,
tool_to_break: "shovel",
});
register_block_item(block);
-13
View File
@@ -1,13 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:snow", {
id: "bworld:snow",
textures: "bworld:snow",
has_collision: true,
toughness: 2,
requires_tool: true,
tool_to_break: "shovel",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:stone", {
id: "bworld:stone",
textures: "bworld:stone",
has_collision: true,
drop_table: "bworld:stone",
toughness: 3,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:tin_ore", {
id: "bworld:tin_ore",
textures: "bworld:stone_tin",
has_collision: true,
drop_table: "bworld:tin_ore",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-9
View File
@@ -1,9 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<BlockRegistry>("blocks", "bworld:water", {
id: "bworld:water",
textures: "bworld:water",
has_collision: false,
transparent: true,
alpha: 0.8,
});
+25
View File
@@ -0,0 +1,25 @@
import type { Entity } from "./entity/entity.ts";
// where the world is drawn from. like minecraft's Camera it isn't an entity, it's moved to one's eyes every frame
export class Camera {
x = 0;
y = 0;
z = 3;
pitch = 0;
yaw = 0;
roll = 0;
fov = Math.PI / 3;
near = 0.1;
far = 1000;
setup(entity: Entity, partial_tick: number) {
const { x, y, z } = entity.render_position(partial_tick);
this.x = x;
this.y = y + entity.eye_height;
this.z = z;
this.yaw = entity.yaw;
this.pitch = entity.pitch;
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { FromChunkWorker, ToChunkWorker } from "./workers/chunk_messages.ts";
// leave a core for the main thread, more than 4 doesnt help much
const POOL_SIZE = Math.max(1, Math.min(4, (navigator.hardwareConcurrency ?? 4) - 1));
export class ChunkWorkerPool {
readonly size = POOL_SIZE;
#workers: Worker[] = [];
#next = 0;
constructor(on_message: (message: FromChunkWorker) => void) {
for (let i = 0; i < this.size; i += 1) {
const worker = new Worker(new URL("./workers/chunk_worker.js", import.meta.url), { type: "module" });
worker.onmessage = (event: MessageEvent<FromChunkWorker>) => on_message(event.data);
worker.onerror = (event) => console.error("Chunk worker error:", event.message);
this.#workers.push(worker);
}
}
broadcast(message: ToChunkWorker) {
for (const worker of this.#workers) {
worker.postMessage(message);
}
}
// round robin, results can come back out of order
post(message: ToChunkWorker, transfer: Transferable[] = []) {
const worker = this.#workers[this.#next];
this.#next = (this.#next + 1) % this.#workers.length;
worker.postMessage(message, transfer);
}
terminate() {
for (const worker of this.#workers) {
worker.terminate();
}
this.#workers = [];
}
}
+257
View File
@@ -0,0 +1,257 @@
import { ClientLevel } from "./level/client_level.ts";
import type { BlockHitResult } from "./level/client_level.ts";
import { LocalPlayer } from "./entity/local_player.ts";
import { RemotePlayer } from "./entity/remote_player.ts";
import { ItemEntity } from "./entity/item_entity.ts";
import { Camera } from "./camera.ts";
import { Options } from "./options.ts";
import { MultiPlayerGameMode } from "./game_mode.ts";
import { ClientPacketListener } from "./packet_listener.ts";
import { GameRenderer } from "./rendering/game_renderer.ts";
import { ChatComponent } from "./gui/chat_component.ts";
import { GuiInventoryScreen, GuiScreen } from "./gui/gui_screen.ts";
import { GuiPlayerInventory } from "./gui/gui_player_inventory.ts";
import { GuiChat } from "./gui/gui_chat.ts";
import { InputManager } from "./input_manager.ts";
import { Connection } from "./network.ts";
import { PauseScreen } from "./gui/pause_screen.ts";
import { back_to_title } from "./gui/title_screen.ts";
import { TICK_DELTA } from "$/common/constants.ts";
// after a long stall (a hidden tab, a debugger) the game skips ahead instead of running every missed tick at once
const MAX_TICKS_PER_FRAME = 10;
// the game client, like minecraft's Minecraft class: owns the level, the player, the screens and the renderer.
// the game runs in fixed ticks (TICKS_PER_SECOND), input and drawing happen every frame
export class Client {
connection: Connection;
options = new Options();
level: ClientLevel;
player: LocalPlayer;
camera = new Camera();
game_mode: MultiPlayerGameMode;
packet_listener: ClientPacketListener;
game_renderer = new GameRenderer();
chat = new ChatComponent();
// open screens, the last one is on top and gets the input
screens: GuiScreen[] = [];
// what the player is looking at, updated every frame
hit_result: BlockHitResult | undefined;
debugging = false;
// seconds of game time not ticked yet
#pending_time = 0;
// whether the attack button is held on a block, breaking it advances every tick
#attacking = false;
#stopped = false;
// called once when the connection drops, with why
#on_disconnect: (message: string) => void;
constructor(connection: Connection, on_disconnect: (message: string) => void) {
this.connection = connection;
this.#on_disconnect = on_disconnect;
this.level = new ClientLevel(connection.seed);
for (const [x, y, z, id, state] of connection.initial_changes) {
this.level.record_change(x, y, z, id, state);
}
const spawn = connection.spawn;
this.player = new LocalPlayer(this, connection.id, connection.name);
this.player.set_position(spawn.x, spawn.y, spawn.z);
this.player.yaw = spawn.yaw;
this.player.pitch = spawn.pitch;
this.player.inventories.hotbar_selected = connection.selected_slot;
this.level.add_entity(this.player);
for (const info of connection.initial_players) {
this.level.add_entity(new RemotePlayer(this.level, info));
}
for (const info of connection.initial_entities) {
this.level.add_entity(new ItemEntity(this.level, info));
}
this.game_mode = new MultiPlayerGameMode(this);
this.packet_listener = new ClientPacketListener(this);
}
get screen(): GuiScreen | undefined {
return this.screens.at(-1);
}
push_screen(screen: GuiScreen) {
this.screens.push(screen);
}
pop_screen() {
this.screens.pop()?.on_close();
}
// leaving on purpose, from the pause screen
disconnect() {
this.#stopped = true;
this.connection.close();
back_to_title();
}
#stop() {
this.#stopped = true;
this.level.dispose();
InputManager.set_mouse_grabbed(false);
}
// one frame: input, as many ticks as are due, then drawing between the last tick and the next
run_frame(delta: number) {
if (this.#stopped) {
return;
}
this.screen?.on_tick(delta);
this.#handle_keybinds();
this.#pending_time += delta;
let ticks = 0;
while (this.#pending_time >= TICK_DELTA && ticks < MAX_TICKS_PER_FRAME) {
this.tick();
if (this.#stopped) {
return;
}
this.#pending_time -= TICK_DELTA;
ticks += 1;
}
if (ticks === MAX_TICKS_PER_FRAME) {
this.#pending_time = Math.min(this.#pending_time, TICK_DELTA);
}
this.game_renderer.render(this, this.#pending_time / TICK_DELTA);
}
// one step of the game: what the server sent, breaking, chunk loading, then every entity
tick() {
this.packet_listener.handle_packets();
if (this.connection.closed) {
this.#stop();
this.#on_disconnect("Lost connection to the server");
return;
}
if (this.#attacking && this.hit_result) {
this.game_mode.continue_destroy_block(this.hit_result);
}
this.level.update_loaded_chunks(this.player.x, this.player.z, this.options.render_distance);
this.level.tick();
}
#handle_keybinds() {
const options = this.options;
const player = this.player;
if (InputManager.is_key_pressed(options.key_inventory)) {
if (!this.screen) {
this.push_screen(new GuiPlayerInventory(player.inventories, (m) => this.connection.send(m)));
} else if (this.screen instanceof GuiInventoryScreen) {
this.pop_screen();
}
}
if (InputManager.is_key_pressed(options.key_drop)) {
this.#handle_drop();
}
if (InputManager.is_key_pressed(options.key_chat) && !this.screen) {
this.push_screen(new GuiChat(this));
}
// browsers eat the escape that lets go of the mouse, so losing the mouse pauses too
const lost_mouse = InputManager.take_lost_pointer_lock();
if (InputManager.is_key_pressed("Escape")) {
if (this.screen) {
this.pop_screen();
} else {
this.push_screen(new PauseScreen(this));
}
} else if (lost_mouse && !this.screen) {
this.push_screen(new PauseScreen(this));
}
if (InputManager.is_key_pressed(options.key_debug)) {
this.debugging = !this.debugging;
}
if (InputManager.is_key_pressed(options.key_fullscreen)) {
InputManager.toggle_fullscreen();
}
if (InputManager.is_mouse_grabbed()) {
const mouse = InputManager.get_mouse_delta();
player.turn(mouse.x, mouse.y);
}
InputManager.set_mouse_grabbed(!this.screen);
this.hit_result = this.level.pick(player.x, player.y + player.eye_height, player.z, player.yaw, player.pitch);
this.#handle_block_interaction();
if (!this.screen) {
this.#handle_hotbar();
}
}
// the held item while playing, the slot under the mouse in an inventory (only with nothing on the cursor)
#handle_drop() {
const all = InputManager.is_key_down("ControlLeft") || InputManager.is_key_down("ControlRight");
const inventories = this.player.inventories;
const screen = this.screen;
if (!screen) {
this.game_mode.drop_item(inventories.inventory, "inventory", inventories.hotbar_selected, all);
return;
}
const slot = screen instanceof GuiInventoryScreen ? screen.hovering : undefined;
const container = slot && screen instanceof GuiInventoryScreen
? screen.get_container(slot.container)
: undefined;
if (slot && container && !slot.output && !inventories.cursor.item) {
this.game_mode.drop_item(container, slot.container, slot.index, all);
}
}
// clicks happen right away, holding to break advances in tick()
#handle_block_interaction() {
const hit = this.hit_result;
const attacking = !this.screen && InputManager.is_mouse_down(0);
this.#attacking = attacking;
if (!attacking || !hit) {
this.game_mode.stop_destroy_block();
}
if (this.screen) {
return;
}
if (hit) {
if (InputManager.is_mouse_pressed(0)) {
this.game_mode.start_destroy_block(hit);
}
if (!attacking && InputManager.is_mouse_pressed(2)) {
this.game_mode.use_item_on(hit);
}
} else if (InputManager.is_mouse_pressed(2)) {
this.game_mode.use_item();
}
}
#handle_hotbar() {
const inventories = this.player.inventories;
const previous = inventories.hotbar_selected;
const scroll = InputManager.get_wheel_delta();
if (scroll > 0) {
inventories.hotbar_selected = Math.min(8, inventories.hotbar_selected + 1);
} else if (scroll < 0) {
inventories.hotbar_selected = Math.max(0, inventories.hotbar_selected - 1);
}
const pressed = this.options.key_hotbar.findIndex((key) => InputManager.is_key_pressed(key));
if (pressed !== -1) {
inventories.hotbar_selected = pressed;
}
if (inventories.hotbar_selected !== previous) {
this.connection.send({ type: "select_slot", slot: inventories.hotbar_selected });
}
}
}
-56
View File
@@ -1,56 +0,0 @@
import { World } from "$/common/ecs/mod.ts";
import { MovementSystem } from "$/common/systems/movement_system.ts";
import { RenderSystem } from "$/client/systems/render_system.ts";
import { PlayerControlsSystem } from "$/client/systems/player_controls.ts";
import { DebugSystem } from "$/client/systems/debug_system.ts";
import { UIInteractionSystem } from "$/client/systems/ui_interaction_system.ts";
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";
export class ClientWorld extends World {
paused = false;
debugging = false;
dimension!: Dimension;
constructor() {
super("game");
this.add_state("main_menu");
this.add_state("paused");
this.add_state("game");
self.addEventListener("resize", resize_canvas);
resize_canvas();
canvas.addEventListener("contextmenu", function (event) {
event.preventDefault();
});
start_game(this);
// Logic systems
this.add_system(new UIInteractionSystem(), "main_menu");
this.add_system(new UIInteractionSystem(), "paused");
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");
// render systems
this.add_system(new RenderSystem(), "game");
this.add_system(new GuiRenderSystem(), "game");
this.add_system(new DebugSystem(), "game");
this.add_system(new UIRenderSystem(), "main_menu");
this.add_system(new UIRenderSystem(), "paused");
}
}
-15
View File
@@ -1,15 +0,0 @@
import { Component } from "$/common/ecs/mod.ts";
export class Camera extends Component {
x = 0;
y = 0;
z = 3;
pitch = 0;
yaw = 0;
roll = 0;
fov = Math.PI / 3;
near = 0.1;
far = 1000;
}
-13
View File
@@ -1,13 +0,0 @@
import { Component } from "$/common/ecs/mod.ts";
export class ClickableSprite extends Component {
clicked = false;
button: number;
access_range: number;
constructor(button: number = 0, access_range = 2) {
super();
this.button = button;
this.access_range = access_range;
}
}
-20
View File
@@ -1,20 +0,0 @@
import { Component } from "$/common/ecs/mod.ts";
export class CollisionCuboid extends Component {
width: number;
height: number;
depth: number;
gravity: number;
colliding_x: number = 0;
colliding_y: number = 0;
colliding_z: number = 0;
constructor(width: number, height: number, depth: number, gravity = -15.8) {
super();
this.width = width;
this.height = height;
this.depth = depth;
this.gravity = gravity;
}
}
-346
View File
@@ -1,346 +0,0 @@
import { Component } from "$/common/ecs/mod.ts";
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { AIR, 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 { 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;
y: number;
z: number;
}
export const CHUNK_SIZE = 16;
export const CHUNK_HEIGHT = 128;
export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE;
export interface Chunk {
x: number;
z: number;
blocks: Uint32Array;
blocks_data: BlockData[];
generated: boolean;
dirty: boolean;
opaque_vertex_buffer?: WebGLBuffer;
opaque_vertex_count?: number;
transparent_vertex_buffer?: WebGLBuffer;
transparent_vertex_count?: number;
}
export class Dimension extends Component {
world: ClientWorld;
image: Texture = AssetManager.instance.get("bworld:textures");
chunks: Chunk[] = [];
second_timer = 0;
tick_timer = 0;
constructor(world: ClientWorld) {
super();
this.world = world;
}
add_chunk(x: number, z: number) {
const chunk = {
x,
z,
blocks: new Uint32Array(CHUNK_AREA * CHUNK_HEIGHT),
blocks_data: [],
dirty: true,
generated: false,
};
this.chunks.push(chunk);
return chunk;
}
get_chunk(x: number, z: number) {
return this.chunks.find((chunk) => chunk.x === x && chunk.z === z);
}
add_block(block: Block) {
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);
if (!chunk) {
chunk = this.add_chunk(block_chunk_x, block_chunk_z);
}
const [nid, block_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;
const ly = block.y;
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
chunk.blocks[index] = nid;
chunk.dirty = true;
if (block_info?.on_create) {
block_info?.on_create(this, block);
}
}
get_block(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;
}
const lx = x - chunk_x * CHUNK_SIZE;
const lz = z - chunk_z * CHUNK_SIZE;
const ly = y;
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
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>;
}
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);
const chunk = this.get_chunk(block_chunk_x, block_chunk_z);
if (!chunk) {
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;
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
chunk.blocks[index] = AIR;
chunk.dirty = true;
if (lx === 0) {
const n = this.get_chunk(block_chunk_x - 1, block_chunk_z);
if (n) {
n.dirty = true;
}
} else if (lx === CHUNK_SIZE - 1) {
const n = this.get_chunk(block_chunk_x + 1, block_chunk_z);
if (n) {
n.dirty = true;
}
}
if (lz === 0) {
const n = this.get_chunk(block_chunk_x, block_chunk_z - 1);
if (n) {
n.dirty = true;
}
} else if (lz === CHUNK_SIZE - 1) {
const n = this.get_chunk(block_chunk_x, block_chunk_z + 1);
if (n) {
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) {
const y = Math.floor(index / CHUNK_AREA);
const rem = index % CHUNK_AREA;
const z = Math.floor(rem / CHUNK_SIZE);
const x = rem % CHUNK_SIZE;
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;
}
const neighbors = [
[cx - 1, cz],
[cx + 1, cz],
[cx, cz - 1],
[cx, cz + 1],
];
for (const [nx, nz] of neighbors) {
const neighbor = this.chunks.find((c) => c.x === nx && c.z === nz);
if (neighbor && neighbor.generated) {
neighbor.dirty = true;
}
}
}
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");
return;
}
this.delete_chunk_mesh(this.chunks[chunk_i]);
this.chunks.splice(chunk_i, 1);
}
delete_chunk_mesh(chunk: Chunk) {
if (chunk.opaque_vertex_buffer) {
gl.deleteBuffer(chunk.opaque_vertex_buffer);
}
if (chunk.transparent_vertex_buffer) {
gl.deleteBuffer(chunk.transparent_vertex_buffer);
}
}
get_looked_block(
dimension: Dimension,
camera: Camera,
max_distance = 6,
step = 0.05,
): { x: number; y: number; z: number; block: number; face: Faces } | undefined {
const yaw = camera.yaw;
const pitch = camera.pitch;
const cos_pitch = Math.cos(pitch);
const dx = -Math.sin(yaw) * cos_pitch;
const dy = Math.sin(pitch);
const dz = -Math.cos(yaw) * cos_pitch;
let x = camera.x;
let y = camera.y;
let z = camera.z;
let prev_bx = Math.floor(x);
let prev_by = Math.floor(y);
let prev_bz = Math.floor(z);
let dist = 0;
while (dist <= max_distance) {
x += dx * step;
y += dy * step;
z += dz * step;
dist += step;
const bx = Math.floor(x);
const by = Math.floor(y);
const bz = Math.floor(z);
if (bx === prev_bx && by === prev_by && bz === prev_bz) {
continue;
}
const block = dimension.get_block(bx, by, bz);
if (block && block !== AIR && block !== VOID) {
let face: Faces;
if (bx > prev_bx) {
face = "west";
} else if (bx < prev_bx) {
face = "east";
} else if (by > prev_by) {
face = "bottom";
} else if (by < prev_by) {
face = "top";
} else if (bz > prev_bz) {
face = "north";
} else {
face = "south";
}
return { x: bx, y: by, z: bz, face, block };
}
prev_bx = bx;
prev_by = by;
prev_bz = bz;
}
return undefined;
}
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);
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;
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;
}
const px = x + 1;
const pz = z + 1;
const pindex = y * size * size + pz * size + px;
padded[pindex] = block;
}
}
}
return padded;
}
}
-29
View File
@@ -1,29 +0,0 @@
import { Component } from "$/common/ecs/mod.ts";
import { KeyCode } from "$/client/input_manager.ts";
export class PlayerControls extends Component {
move_speed = 4;
jump_force = 6.7;
// Keys
move_forward: KeyCode = "KeyW";
move_backwards: KeyCode = "KeyS";
move_left: KeyCode = "KeyA";
move_right: KeyCode = "KeyD";
sprint_key: KeyCode = "ShiftLeft";
hotbar_1: KeyCode = "Digit1";
hotbar_2: KeyCode = "Digit2";
hotbar_3: KeyCode = "Digit3";
hotbar_4: KeyCode = "Digit4";
hotbar_5: KeyCode = "Digit5";
hotbar_6: KeyCode = "Digit6";
hotbar_7: KeyCode = "Digit7";
hotbar_8: KeyCode = "Digit8";
hotbar_9: KeyCode = "Digit9";
open_inventory: KeyCode = "KeyE";
open_chat: KeyCode = "KeyT";
open_debug: KeyCode = "F3";
}
-87
View File
@@ -1,87 +0,0 @@
import { Component } from "$/common/ecs/mod.ts";
import { AssetManager } from "$/client/assets.ts";
import { Texture } from "../renderer/mod.ts";
export class Sprite extends Component {
image: Texture;
width: number;
height: number;
source_x: number;
source_y: number;
source_width: number;
source_height: number;
flip_x = false;
flip_y = false;
constructor(
image: Texture | string,
width: number,
height: number,
source_x = 0,
source_y = 0,
source_width = width,
source_height = height,
) {
super();
if (typeof image === "string") {
this.image = AssetManager.instance.get(image);
} else {
this.image = image;
}
this.width = width;
this.height = height;
this.source_x = source_x;
this.source_y = source_y;
this.source_width = source_width;
this.source_height = source_height;
}
}
interface AnimatedSpritePiece {
source_x: number[];
source_y: number[];
source_width: number;
source_height: number;
duration: number;
}
export class AnimatedSprite extends Component {
image: Texture;
width: number;
height: number;
flip_x = false;
flip_y = false;
current_state: string;
states: Record<string, AnimatedSpritePiece>;
timer = 0;
animation_frame = 0;
constructor(
image: Texture | string,
width: number,
height: number,
states: Record<string, AnimatedSpritePiece>,
initial_state: string,
) {
super();
if (typeof image === "string") {
this.image = AssetManager.instance.get(image);
} else {
this.image = image;
}
this.width = width;
this.height = height;
this.states = states;
this.current_state = initial_state;
}
set_state(state: string) {
if (this.current_state !== state) {
this.current_state = state;
this.timer = 0;
this.animation_frame = 0;
}
}
}
-18
View File
@@ -1,18 +0,0 @@
import { Component } from "$/common/ecs/mod.ts";
export class UIButton extends Component {
text: string;
width: number;
height: number;
on_click: () => void;
hovered = false;
constructor(text: string, width: number, height: number, on_click: () => void) {
super();
this.text = text;
this.width = width;
this.height = height;
this.on_click = on_click;
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { ServerAddress, Welcome } from "./handshake.ts";
// asks before running a server's mods when the page came from somewhere else, see "Security" in MODS.md.
// resolves with whether the player wants to join
export function confirm_mods(address: ServerAddress, welcome: Welcome): Promise<boolean> {
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText =
"position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:16px;" +
"background:rgba(0,0,0,0.85);color:white;font:16px system-ui,sans-serif;";
const panel = document.createElement("div");
panel.style.cssText = "max-width:480px;display:flex;flex-direction:column;gap:12px;";
const title = document.createElement("div");
title.style.cssText = "font-size:20px;";
title.textContent = `Join ${address.base.host}?`;
const warning = document.createElement("div");
warning.style.cssText = "opacity:0.8;";
warning.textContent = "This server runs these mods in your browser. They can do anything this page can, " +
"including reading what it saved. Only join servers you trust.";
const list = document.createElement("ul");
list.style.cssText = "margin:0;padding-left:20px;";
for (const mod of welcome.mods) {
const item = document.createElement("li");
item.textContent = `${mod.name} ${mod.version} (${mod.id})`;
list.append(item);
}
const buttons = document.createElement("div");
buttons.style.cssText = "display:flex;gap:8px;justify-content:flex-end;";
const button = (label: string, answer: boolean) => {
const element = document.createElement("button");
element.textContent = label;
element.style.cssText = "font:inherit;padding:6px 16px;cursor:pointer;";
element.addEventListener("click", () => {
overlay.remove();
resolve(answer);
});
return element;
};
buttons.append(button("Cancel", false), button("Join", true));
panel.append(title, warning, list, buttons);
overlay.append(panel);
document.body.append(overlay);
});
}
+87
View File
@@ -0,0 +1,87 @@
import { move_body } from "$/common/physics.ts";
import type { ClientLevel } from "../level/client_level.ts";
// anything that exists in the level and moves, like minecraft's Entity. position is the middle of its feet.
// it's simulated in fixed ticks (common/constants.ts), frames draw it between its last two positions
export abstract class Entity {
id: string;
level: ClientLevel;
x = 0;
y = 0;
z = 0;
// where it was at the start of the tick, what frames interpolate from
prev_x = 0;
prev_y = 0;
prev_z = 0;
// blocks per second
vx = 0;
vy = 0;
vz = 0;
yaw = 0;
pitch = 0;
// the collision box, width is used for both x and z
width: number;
height: number;
eye_height: number;
// blocks per second squared
gravity = -32;
// what's left of its speed after each tick in the air, like minecraft's 0.98. it caps falling speed
// at 49 * -gravity * TICK_DELTA, 78 blocks per second with minecraft's gravity
drag = 0.98;
// which way it hit something on each axis in the last move: 1 or -1, 0 for nothing.
// 1 on y means it's standing on something
colliding_x = 0;
colliding_y = 0;
colliding_z = 0;
constructor(level: ClientLevel, id: string, width: number, height: number, eye_height: number) {
this.level = level;
this.id = id;
this.width = width;
this.height = height;
this.eye_height = eye_height;
}
get on_ground() {
return this.colliding_y === 1;
}
// jumps there, without interpolating from where it was
set_position(x: number, y: number, z: number) {
this.x = this.prev_x = x;
this.y = this.prev_y = y;
this.z = this.prev_z = z;
}
save_previous_position() {
this.prev_x = this.x;
this.prev_y = this.y;
this.prev_z = this.z;
}
// where to draw it, partial_tick is how far the frame is between the last tick and the next
render_position(partial_tick: number) {
return {
x: this.prev_x + (this.x - this.prev_x) * partial_tick,
y: this.prev_y + (this.y - this.prev_y) * partial_tick,
z: this.prev_z + (this.z - this.prev_z) * partial_tick,
};
}
// one step of the game, TICK_DELTA seconds
abstract tick(): void;
// falls and moves by its velocity for one tick (see move_body), then slows down from drag
move() {
const collisions = move_body(this, this.gravity, (x, y, z) => this.level.has_collision(x, y, z));
this.colliding_x = collisions.x;
this.colliding_y = collisions.y;
this.colliding_z = collisions.z;
this.vx *= this.drag;
this.vy *= this.drag;
this.vz *= this.drag;
}
}
+83
View File
@@ -0,0 +1,83 @@
import { ItemStack } from "$/common/inventory.ts";
import type { EntityInfo } from "$/common/protocol.ts";
import type { ClientLevel } from "../level/client_level.ts";
import { Entity } from "./entity.ts";
// how many ticks a server position takes to reach, like minecraft's lerpTo
const LERP_TICKS = 3;
// how many ticks it takes to fly into whoever picked it up
const PICKUP_TICKS = 3;
// an item lying on the ground. the server simulates it, this only moves where it's told and spins
export class ItemEntity extends Entity {
item: ItemStack;
// ticks since it showed up, for spinning and bobbing
age = 0;
// so items dropped together don't spin in step
readonly bob_offset = Math.random() * Math.PI * 2;
#target_x: number;
#target_y: number;
#target_z: number;
#lerp_ticks = 0;
// who's picking it up, and for how many ticks it has been flying to them
#picked_up_by: Entity | undefined;
#pickup_age = 0;
constructor(level: ClientLevel, info: EntityInfo) {
super(level, info.id, 0.25, 0.25, 0.125);
this.item = ItemStack.from_data(info.item);
this.set_position(info.x, info.y, info.z);
this.#target_x = info.x;
this.#target_y = info.y;
this.#target_z = info.z;
}
lerp_to(x: number, y: number, z: number) {
this.#target_x = x;
this.#target_y = y;
this.#target_z = z;
this.#lerp_ticks = LERP_TICKS;
}
// it already went into their inventory on the server, this is only the animation
pick_up(by: Entity) {
this.#picked_up_by = by;
}
tick() {
this.age += 1;
if (this.#picked_up_by) {
this.#pickup_age += 1;
if (this.#pickup_age >= PICKUP_TICKS) {
this.level.remove_entity(this.id);
}
return;
}
if (this.#lerp_ticks > 0) {
this.x += (this.#target_x - this.x) / this.#lerp_ticks;
this.y += (this.#target_y - this.y) / this.#lerp_ticks;
this.z += (this.#target_z - this.z) / this.#lerp_ticks;
this.#lerp_ticks -= 1;
}
}
// while being picked up it speeds towards the middle of whoever took it, like minecraft's ItemPickupParticle
override render_position(partial_tick: number) {
const position = super.render_position(partial_tick);
const by = this.#picked_up_by;
if (!by) {
return position;
}
const target = by.render_position(partial_tick);
const t = Math.min(1, (this.#pickup_age + partial_tick) / PICKUP_TICKS) ** 2;
return {
x: position.x + (target.x - position.x) * t,
y: position.y + (target.y + 0.5 - position.y) * t,
z: position.z + (target.z - position.z) * t,
};
}
}
+94
View File
@@ -0,0 +1,94 @@
import type { Client } from "../client.ts";
import { InputManager } from "../input_manager.ts";
import { ClientInventories } from "../inventory.ts";
import { Player } from "./player.ts";
// the position goes to the server every other tick
const SEND_POSITION_TICKS = 2;
// the player this client controls, like minecraft's LocalPlayer
export class LocalPlayer extends Player {
client: Client;
inventories = new ClientInventories();
move_speed = 4;
// blocks per second, peaks about 1.25 blocks up with the entity's gravity and drag
jump_force = 9.23;
#ticks_since_sent = 0;
constructor(client: Client, id: string, name: string) {
super(client.level, id, name);
this.client = client;
}
tick() {
if (!this.client.screen) {
this.#apply_input();
}
this.move();
this.#send_position();
}
// turns with the mouse every frame, not every tick, like minecraft's MouseHandler.turnPlayer
turn(mouse_dx: number, mouse_dy: number) {
this.yaw += -mouse_dx * 0.001;
this.pitch += -mouse_dy * 0.001;
const limit = Math.PI / 2 - 0.01;
this.pitch = Math.max(-limit, Math.min(limit, this.pitch));
}
// walking relative to where it's looking
#apply_input() {
const options = this.client.options;
let input_x = 0;
let input_z = 0;
if (InputManager.is_key_down(options.key_left)) {
input_x -= 1;
}
if (InputManager.is_key_down(options.key_right)) {
input_x += 1;
}
if (InputManager.is_key_down(options.key_forward)) {
input_z -= 1;
}
if (InputManager.is_key_down(options.key_back)) {
input_z += 1;
}
const size = Math.hypot(input_x, input_z);
if (size > 0) {
input_x /= size;
input_z /= size;
}
const sin = Math.sin(this.yaw);
const cos = Math.cos(this.yaw);
const speed = this.move_speed * (InputManager.is_key_down(options.key_sprint) ? 1.75 : 1);
this.vx = (sin * input_z + cos * input_x) * speed;
this.vz = (cos * input_z - sin * input_x) * speed;
if (InputManager.is_key_down(options.key_jump) && this.on_ground) {
this.vy += this.jump_force;
}
}
#send_position() {
this.#ticks_since_sent += 1;
if (this.#ticks_since_sent < SEND_POSITION_TICKS) {
return;
}
this.#ticks_since_sent = 0;
this.client.connection.send({
type: "move",
x: this.x,
y: this.y,
z: this.z,
yaw: this.yaw,
pitch: this.pitch,
});
}
}
+12
View File
@@ -0,0 +1,12 @@
import { PLAYER_EYE_HEIGHT, PLAYER_HEIGHT, PLAYER_WIDTH } from "$/common/constants.ts";
import type { ClientLevel } from "../level/client_level.ts";
import { Entity } from "./entity.ts";
export abstract class Player extends Entity {
name: string;
constructor(level: ClientLevel, id: string, name: string) {
super(level, id, PLAYER_WIDTH, PLAYER_HEIGHT, PLAYER_EYE_HEIGHT);
this.name = name;
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { PlayerInfo } from "$/common/protocol.ts";
import { TICK_DELTA } from "$/common/constants.ts";
import type { ClientLevel } from "../level/client_level.ts";
import { Player } from "./player.ts";
const SMOOTHING = 12;
// another player on the server, it moves where the server says instead of simulating anything
export class RemotePlayer extends Player {
color: [number, number, number];
// where the server last said it is, the drawn position eases towards it so movement isn't choppy
target_x: number;
target_y: number;
target_z: number;
constructor(level: ClientLevel, info: PlayerInfo) {
super(level, info.id, info.name);
this.set_position(info.x, info.y, info.z);
this.target_x = info.x;
this.target_y = info.y;
this.target_z = info.z;
this.yaw = info.yaw;
this.pitch = info.pitch;
this.color = color_from_name(info.name);
}
lerp_to(x: number, y: number, z: number, yaw: number, pitch: number) {
this.target_x = x;
this.target_y = y;
this.target_z = z;
this.yaw = yaw;
this.pitch = pitch;
}
tick() {
const t = Math.min(1, TICK_DELTA * SMOOTHING);
this.x += (this.target_x - this.x) * t;
this.y += (this.target_y - this.y) * t;
this.z += (this.target_z - this.z) * t;
}
}
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];
}
+26
View File
@@ -0,0 +1,26 @@
let stopped = false;
// replaces the game with a message, for when it can't go on (no server, lost connection)
export function show_fatal_error(message: string) {
if (stopped) {
return;
}
stopped = true;
document.exitPointerLock?.();
const overlay = document.createElement("div");
overlay.style.cssText =
"position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;" +
"gap:12px;background:rgba(0,0,0,0.85);color:white;font:20px system-ui,sans-serif;text-align:center;";
const text = document.createElement("div");
text.textContent = message;
const hint = document.createElement("div");
hint.textContent = "Reload the page to try again.";
hint.style.cssText = "font-size:14px;opacity:0.7;";
overlay.append(text, hint);
document.body.append(overlay);
}
export function is_stopped() {
return stopped;
}
-31
View File
@@ -1,31 +0,0 @@
import { Entity } from "$/common/ecs/mod.ts";
import { Position } from "$/common/components/position.ts";
import { ClientWorld } from "./client_world.ts";
import { Dimension } from "./components/dimension.ts";
import { create_player } from "./player.ts";
import { UIButton } from "./components/ui_components.ts";
import { open_about } from "./about.ts";
import { canvas } from "./renderer/mod.ts";
export function start_game(world: ClientWorld) {
world.state = "game";
world.clear_entities();
const dimension = new Entity("dimension");
world.dimension = new Dimension(world);
dimension.add(world.dimension);
world.add_entity(dimension);
create_player(world);
// UI !
const unpause_button = new Entity("unpausebutton");
unpause_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 - 80));
unpause_button.add(new UIButton("Unpause", 320, 64, () => world.state = "paused"));
world.add_entity(unpause_button);
const about_button = new Entity("aboutbutton");
about_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 + 80));
about_button.add(new UIButton("About", 320, 64, () => open_about()));
world.add_entity(about_button);
}
+97
View File
@@ -0,0 +1,97 @@
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { AIR, FACE_OFFSETS, TICK_DELTA } from "$/common/constants.ts";
import type { Client } from "./client.ts";
import type { BlockHitResult } from "./level/client_level.ts";
import type { Container } from "$/common/inventory.ts";
import type { ContainerKey } from "$/common/protocol.ts";
// breaking and using blocks against a server, like minecraft's MultiPlayerGameMode. the client times breaking
// and guesses the results so they feel instant, the server decides what really happens
export class MultiPlayerGameMode {
client: Client;
// the block being broken, and how far along it is in seconds out of destroy_time
destroy_pos: { x: number; y: number; z: number } | undefined;
destroy_progress = 0;
destroy_time = 0;
constructor(client: Client) {
this.client = client;
}
// the click that starts breaking, for mods' on_click
start_destroy_block(hit: BlockHitResult) {
this.client.connection.send({ type: "hit_block", x: hit.x, y: hit.y, z: hit.z });
}
// called every tick the attack button is held on a block
continue_destroy_block(hit: BlockHitResult) {
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", hit.block)!;
const tool_type = this.#held_item()?.tool_type;
this.destroy_pos = { x: hit.x, y: hit.y, z: hit.z };
this.destroy_time = block_info.toughness ?? 9999;
this.destroy_progress += TICK_DELTA * (tool_type === block_info.tool_to_break ? 2 : 1);
if (this.destroy_progress >= this.destroy_time) {
// show it right away, the server decides drops and corrects us if it disagrees
this.client.level.break_block(hit.x, hit.y, hit.z);
this.client.connection.send({ type: "break_block", x: hit.x, y: hit.y, z: hit.z });
this.destroy_progress = 0;
this.destroy_time = 0;
}
}
stop_destroy_block() {
this.destroy_pos = undefined;
this.destroy_progress = 0;
this.destroy_time = 0;
}
// right click on a block
use_item_on(hit: BlockHitResult) {
const level = this.client.level;
this.client.connection.send({ type: "use_block", x: hit.x, y: hit.y, z: hit.z, face: hit.face });
// guess that it places the held block, unless the block does something when used
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", hit.block)!;
const offset = FACE_OFFSETS[hit.face];
const target = { x: hit.x + offset.x, y: hit.y + offset.y, z: hit.z + offset.z };
const target_id = level.get_block(target.x, target.y, target.z);
const replaceable = target_id === AIR ||
EverythingRegistry.get_by_id<BlockRegistry>("blocks", target_id)?.replaceable;
const held = this.#held_item();
// items with components might do something else on the server, like on_use
const place_id = held?.components ? undefined : held?.block_id;
if (!block_info.interactive && place_id && replaceable) {
level.add_block({ ...target, id: place_id });
const slot = this.#held_slot();
slot.amount = slot.amount! - 1;
}
}
// q on a slot: throws one, or the whole stack. the server spawns the item, this only takes it out right away
drop_item(container: Container, key: ContainerKey, index: number, all: boolean) {
const item = container.get_item(index);
if (!item) {
return;
}
this.client.connection.send({ type: "drop_item", container: key, index, all });
item.amount = all ? 0 : item.amount - 1;
container.set_item(index, item.amount > 0 ? item : undefined);
}
// right click on nothing
use_item() {
this.client.connection.send({ type: "use_item" });
}
#held_slot() {
const inventories = this.client.player.inventories;
return inventories.inventory.get_slot(inventories.hotbar_selected);
}
#held_item() {
return EverythingRegistry.get<ItemRegistry>("items", this.#held_slot().type_id ?? "");
}
}
-242
View File
@@ -1,242 +0,0 @@
import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D } from "@paulaboks/rng";
import { CHUNK_SIZE, Dimension } from "./components/dimension.ts";
type Biome =
| "desert"
| "plains"
| "forest"
| "jungle"
| "tundra"
| "taiga"
| "snow"
| "savanna"
| "swamp";
type OreDef = {
id: string;
min_y: number;
max_y: number;
scale: number;
threshold: number;
};
const ORES: OreDef[] = [
{ id: "bworld:coal_ore", min_y: 20, max_y: 120, scale: 0.05, threshold: 0.55 },
{ id: "bworld:copper_ore", min_y: 10, max_y: 80, scale: 0.06, threshold: 0.6 },
{ id: "bworld:tin_ore", min_y: 5, max_y: 60, scale: 0.06, threshold: 0.62 },
{ id: "bworld:iron_ore", min_y: 5, max_y: 50, scale: 0.05, threshold: 0.65 },
{ id: "bworld:gold_ore", min_y: 0, max_y: 30, scale: 0.04, threshold: 0.7 },
];
function get_biome(temp: number, moisture: number): Biome {
if (temp > 0.6) {
if (moisture < -0.2) {
return "desert";
}
if (moisture > 0.4) {
return "jungle";
}
return "savanna";
}
if (temp > 0) {
if (moisture > 0.5) {
return "swamp";
}
if (moisture > 0) {
return "forest";
}
return "plains";
}
if (temp > -0.5) {
return "taiga";
}
return "tundra";
}
function get_surface_block(biome: Biome) {
if (biome === "desert") {
return "bworld:sand";
} else if (biome === "tundra") {
return "bworld:snow";
}
return "bworld:grass";
}
function biome_height_modifier(biome: Biome) {
if (biome === "desert") {
return 0.2;
}
if (biome === "plains") {
return 0.4;
}
if (biome === "forest") {
return 0.5;
}
if (biome === "jungle") {
return 0.45;
}
if (biome === "taiga") {
return 0.55;
}
if (biome === "tundra") {
return 0.35;
}
if (biome === "savanna") {
return 0.4;
}
if (biome === "swamp") {
return 0.35;
}
return 0.4;
}
function fractal_noise(noise: NoiseFunction2D, x: number, y: number, octaves = 2) {
let value = 0;
let amp = 1;
let freq = 1;
let max = 0;
for (let i = 0; i < octaves; i++) {
value += noise(x * freq, y * freq) * amp;
max += amp;
amp *= 0.5;
freq *= 2;
}
return value / max;
}
function get_terrain_height(base: number, biome: Biome, x: number, z: number, noise: NoiseFunction2D) {
const biomeMod = biome_height_modifier(biome);
const main = fractal_noise(noise, x * 0.003, z * 0.003) * 15;
const detail = fractal_noise(noise, x * 0.01, z * 0.01) * 3;
return Math.floor(base + biomeMod * 20 + main + detail);
}
function can_place_tree(tree_map: boolean[][], local_x: number, local_z: number) {
const TREE_SPACING = 4;
for (let dx = -TREE_SPACING; dx <= TREE_SPACING; dx++) {
for (let dz = -TREE_SPACING; dz <= TREE_SPACING; dz++) {
const nx = local_x + dx;
const nz = local_z + dz;
if (nx >= 0 && nx < CHUNK_SIZE && nz >= 0 && nz < CHUNK_SIZE && tree_map[nx][nz]) {
return false;
}
}
}
return true;
}
function place_tree(dimension: Dimension, x: number, y: number, z: number, biome: Biome) {
const height = Math.floor(Math.random() * 3) + (biome === "jungle" ? 8 : 4);
const trunk_block = "bworld:log";
const leaves_block = "bworld:leaves";
for (let i = 0; i < height; i++) {
dimension.add_block({ x, y: y + i, z, id: trunk_block });
}
for (let dx = -2; dx <= 2; dx++) {
for (let dz = -2; dz <= 2; dz++) {
for (let dy = -1; dy <= 1; dy++) {
if (Math.abs(dx) + Math.abs(dz) + Math.abs(dy) <= 3) {
dimension.add_block({
x: x + dx,
y: y + height + dy,
z: z + dz,
id: leaves_block,
});
}
}
}
}
}
const TREE_THRESHOLD: Record<Biome, number> = {
forest: 0.5,
jungle: 0.3,
taiga: 0.6,
plains: 0.95,
desert: 1,
tundra: 1,
savanna: 0.65,
swamp: 0.5,
snow: 0.8,
};
function should_place_tree(feature_noise: NoiseFunction2D, biome: Biome, x: number, z: number) {
const n = feature_noise(x * 0.1, z * 0.1);
return n > (TREE_THRESHOLD[biome] ?? 0.8);
}
export function generate_chunk(dimension: Dimension, cx: number, cz: number, seed = "seed") {
const height_noise = create_noise_2d(new Alea(seed + "_height"));
const temp_noise = create_noise_2d(new Alea(seed + "_temp"));
const moisture_noise = create_noise_2d(new Alea(seed + "_moisture"));
const feature_noise = create_noise_2d(new Alea(seed + "_feature"));
const ore_noises = ORES.map((ore) => create_noise_3d(new Alea(seed + "_" + ore.id)));
const biome_scale = 0.003;
const terrain_scale = 0.01;
const tree_map: boolean[][] = Array.from({ length: CHUNK_SIZE }, () => Array(CHUNK_SIZE).fill(false));
for (let x = 0; x < CHUNK_SIZE; x++) {
for (let z = 0; z < CHUNK_SIZE; z++) {
const wx = cx * CHUNK_SIZE + x;
const wz = cz * CHUNK_SIZE + z;
const temp = temp_noise(wx * biome_scale, wz * biome_scale);
const moisture = moisture_noise(wx * biome_scale, wz * biome_scale);
const biome = get_biome(temp, moisture);
const height_noise_value = fractal_noise(height_noise, wx * terrain_scale, wz * terrain_scale);
const base_height = (height_noise_value + 1) * 15 + 50;
const height = get_terrain_height(base_height, biome, wx, wz, height_noise);
const surface_block = get_surface_block(biome);
for (let y = 0; y <= height; y++) {
let block = "bworld:stone";
if (y < height - 3) {
for (let i = 0; i < ORES.length; i++) {
const ore = ORES[i];
if (y >= ore.min_y && y <= ore.max_y) {
const noise = ore_noises[i](
wx * ore.scale,
y * ore.scale,
wz * ore.scale,
);
if (noise > ore.threshold) {
block = ore.id;
break;
}
}
}
}
if (y === height) {
block = surface_block;
} else if (y > height - 4) {
block = "bworld:dirt";
}
if (biome === "swamp" && y === height && Math.random() < 0.2) {
block = "bworld:water";
}
dimension.add_block({ x: wx, y, z: wz, id: block });
}
if (should_place_tree(feature_noise, biome, wx, wz) && can_place_tree(tree_map, x, z)) {
place_tree(dimension, wx, height + 1, wz, biome);
tree_map[x][z] = true;
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import { canvas, draw_rect, draw_text } from "$/client/renderer/mod.ts";
const LINE_HEIGHT = 24;
const VISIBLE_SECONDS = 10;
const MAX_LINES = 10;
const MAX_HISTORY = 100;
interface ChatLine {
text: string;
time: number;
}
// the chat log, like minecraft's ChatComponent
export class ChatComponent {
lines: ChatLine[] = [];
add(text: string) {
this.lines.push({ text, time: performance.now() });
if (this.lines.length > MAX_HISTORY) {
this.lines.shift();
}
}
// above the bottom left corner. `all` shows old messages too, for when the chat is open
render(all: boolean, bottom = canvas.height - 100) {
const now = performance.now();
const lines = this.lines
.filter((line) => all || now - line.time < VISIBLE_SECONDS * 1000)
.slice(-MAX_LINES);
let y = bottom - lines.length * LINE_HEIGHT;
for (const line of lines) {
draw_rect(0, y, 600, LINE_HEIGHT, [0, 0, 0, 0.4]);
draw_text(line.text, 4, y, 2, [1, 1, 1, 1]);
y += LINE_HEIGHT;
}
}
}
+93
View File
@@ -0,0 +1,93 @@
import { Marked } from "marked";
import { AssetManager } from "$/client/assets.ts";
import { mod_credits } from "$/client/mods.ts";
import { canvas, draw_rect } from "$/client/renderer/mod.ts";
import { GuiScreen } from "./gui_screen.ts";
// credits come from servers' mods, so markdown only: raw html is shown as text and links can't run code
const markdown = new Marked({
renderer: {
html({ text }) {
return escape_html(text);
},
link({ href, title, tokens }) {
const text = this.parser.parseInline(tokens);
if (!/^(https?:|mailto:)/i.test(href)) {
return text;
}
const title_attribute = title ? ` title="${escape_html(title)}"` : "";
return `<a href="${
escape_html(href)
}"${title_attribute} target="_blank" rel="noopener noreferrer">${text}</a>`;
},
},
});
// who made what: the engine's assets/ASSETS.md, then each loaded mod's credits file. shown as a page over the
// game since licenses are long, the game underneath keeps drawing
export class CreditsScreen extends GuiScreen {
#overlay: HTMLElement;
constructor(on_back: () => void) {
super();
this.#overlay = document.createElement("div");
this.#overlay.style.cssText = "position:fixed;inset:0;display:flex;justify-content:center;padding:32px 16px;" +
"box-sizing:border-box;color:#e5e7eb;font:15px/1.6 system-ui,sans-serif;";
const panel = document.createElement("div");
panel.style.cssText = "width:100%;max-width:760px;display:flex;flex-direction:column;gap:12px;" +
"background:rgba(17,24,39,0.95);border-radius:8px;padding:20px 24px;box-sizing:border-box;";
const header = document.createElement("div");
header.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:12px;";
const title = document.createElement("div");
title.textContent = "Credits";
title.style.cssText = "font-size:24px;font-weight:600;";
const back = document.createElement("button");
back.textContent = "Back";
back.style.cssText = "font:inherit;padding:6px 20px;cursor:pointer;";
back.addEventListener("click", on_back);
header.append(title, back);
const content = document.createElement("div");
content.style.cssText = "overflow-y:auto;flex:1;min-height:0;padding-right:8px;";
content.innerHTML = this.#sections().map(({ heading, text }) =>
`<section><h2 style="border-bottom:1px solid #374151;padding-bottom:4px">${escape_html(heading)}</h2>` +
`${markdown.parse(text, { async: false })}</section>`
).join("");
for (const pre of content.querySelectorAll("pre")) {
pre.style.cssText =
"white-space:pre-wrap;background:#0b1220;padding:12px;border-radius:6px;font-size:12px;";
}
for (const link of content.querySelectorAll("a")) {
link.style.color = "#60a5fa";
}
panel.append(header, content);
this.#overlay.append(panel);
document.body.append(this.#overlay);
}
#sections() {
const sections = [{ heading: "bworld", text: AssetManager.instance.get<string>("bworld:assets_text") ?? "" }];
for (const mod of mod_credits) {
sections.push({ heading: `${mod.name} ${mod.version}`, text: mod.text });
}
return sections;
}
on_tick(_delta: number): void {}
on_render(): void {
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.4]);
}
on_close(): void {
this.#overlay.remove();
}
}
function escape_html(text: string) {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
+45
View File
@@ -0,0 +1,45 @@
import type { Client } from "$/client/client.ts";
import { DebugUI } from "$/client/debug_ui.ts";
// f3: every entity's fields, editable
export class DebugOverlay {
render(client: Client) {
DebugUI.begin("Entities", 10, 10, 300);
for (const entity of client.level.entities.values()) {
if (DebugUI.collapsing_header(`${entity.constructor.name} - ${entity.id}`)) {
this.#render_fields(entity);
}
}
if (DebugUI.collapsing_header("Camera")) {
this.#render_fields(client.camera);
}
if (DebugUI.collapsing_header("Options")) {
this.#render_fields(client.options);
}
DebugUI.end();
}
// deno-lint-ignore no-explicit-any
#render_fields(object: any) {
for (const key in object) {
const value = object[key];
if (typeof value === "number") {
object[key] = DebugUI.float_input(key, value);
} else if (typeof value === "string") {
object[key] = DebugUI.text_input(key, value);
} else if (typeof value === "boolean") {
object[key] = DebugUI.checkbox(key, value);
} else if (Array.isArray(value)) {
DebugUI.text(`${key}: ${JSON.stringify(value.slice(0, 10))}`);
} else if (value && typeof value === "object" && value.constructor !== Object) {
// other objects like the level point back at this one, only name them
DebugUI.text(`${key}: ${value.constructor.name}`);
} else {
DebugUI.text(`${key}: ${JSON.stringify(value)}`);
}
DebugUI.separator();
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mod.ts";
import { GuiScreen } from "./gui_screen.ts";
import { back_to_title } from "./title_screen.ts";
import { Button, TEXT_HEIGHT, TEXT_SCALE } from "./widgets.ts";
const BUTTON_WIDTH = 440;
const BUTTON_HEIGHT = 48;
// why the game stopped, like minecraft's DisconnectedScreen
export class DisconnectedScreen extends GuiScreen {
message: string;
back = new Button("Back to title screen", BUTTON_WIDTH, BUTTON_HEIGHT, back_to_title);
constructor(message: string) {
super();
this.message = message;
}
on_tick(_delta: number): void {
this.back.x = (canvas.width - BUTTON_WIDTH) / 2;
this.back.y = canvas.height / 2 + 20;
this.back.handle_input();
}
on_render(): void {
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.6]);
this.#centered("Disconnected", canvas.height / 2 - 90, 4, [1, 1, 1, 1]);
this.#centered(this.message, canvas.height / 2 - 30, TEXT_SCALE, [0.85, 0.85, 0.85, 1]);
this.back.render();
}
on_close(): void {}
#centered(text: string, y: number, scale: number, color: number[]) {
draw_text(text, (canvas.width - measure_text(text, scale)) / 2, y - (TEXT_HEIGHT * scale) / 2, scale, color);
}
}
+17 -104
View File
@@ -1,131 +1,44 @@
import { GuiScreen } from "./gui_screen.ts";
import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mod.ts";
import { canvas, draw_rect, draw_text } from "$/client/renderer/mod.ts";
import { InputManager } from "../input_manager.ts";
import { ClientWorld } from "../client_world.ts";
import { PlayerComponent } from "../player.ts";
import { ItemStack } from "../inventory.ts";
import type { Client } from "../client.ts";
import { MAX_CHAT_LENGTH } from "$/common/protocol.ts";
import { TextInput } from "./widgets.ts";
export class GuiChat extends GuiScreen {
world: ClientWorld;
client: Client;
input = new TextInput("", MAX_CHAT_LENGTH);
text_typed = "";
caret = 0;
key_repeat_timer = 0;
show_caret = true;
constructor(world: ClientWorld) {
constructor(client: Client) {
super();
this.world = world;
this.client = client;
}
override on_render(): void {
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
const y = canvas.height - 32;
this.client.chat.render(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]);
if (this.show_caret) {
const before_text = this.text_typed.substring(0, this.caret);
const caret_x = 0 + measure_text(before_text, 2);
draw_rect(caret_x, y + 4, 1, 32 - 4);
}
draw_text(this.input.value, 0, y, 2, [1, 1, 1]);
draw_rect(this.input.caret_x(0, 2), y + 4, 1, 32 - 4);
}
override on_tick(_delta: number): void {
const now = performance.now();
const repeat_delay = 400;
const repeat_rate = 40;
const allow_repeat = () => {
if (InputManager.is_key_pressed("Backspace")) {
this.key_repeat_timer = now;
return true;
}
if (InputManager.is_key_down("Backspace")) {
if (now - this.key_repeat_timer > repeat_delay) {
this.key_repeat_timer = now - (repeat_delay - repeat_rate);
return true;
}
}
return false;
};
if (allow_repeat()) {
if (this.caret > 0) {
this.text_typed = this.text_typed.slice(0, this.caret - 1) + this.text_typed.slice(this.caret);
this.caret -= 1;
}
}
if (InputManager.is_key_pressed("Delete")) {
this.text_typed = this.text_typed.slice(0, this.caret) + this.text_typed.slice(this.caret + 1);
}
if (InputManager.is_key_pressed("ArrowLeft")) {
this.caret = Math.max(0, this.caret - 1);
}
if (InputManager.is_key_pressed("ArrowRight")) {
this.caret = Math.min(this.text_typed.length, this.caret + 1);
}
if (InputManager.is_key_pressed("Home")) {
this.caret = 0;
}
if (InputManager.is_key_pressed("End")) {
this.caret = this.text_typed.length;
}
const typed = InputManager.get_typed_characters();
for (const char of typed) {
this.text_typed = this.text_typed.slice(0, this.caret) + char + this.text_typed.slice(this.caret);
this.caret += 1;
}
this.input.handle_keys();
if (InputManager.is_key_pressed("Enter")) {
this.submit();
}
}
override on_close(): void {}
submit() {
if (this.text_typed.startsWith("/")) {
this.command();
} else {
// we dont have multiplayer lol?
}
this.text_typed = "";
const [player] = this.world.get_tag("player")!;
const player_component = player.get(PlayerComponent);
if (player_component) {
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)));
}
// commands like /give run on the server too
if (this.input.value.trim().length > 0) {
this.client.connection.send({ type: "chat", text: this.input.value });
}
this.client.pop_screen();
}
}
-60
View File
@@ -1,60 +0,0 @@
import { Inventory, PlayerInventory } from "../inventory.ts";
import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
import { canvas, draw_rect, Texture } from "$/client/renderer/mod.ts";
import { AssetManager } from "../assets.ts";
import { SLOT_SIZE } from "../../common/constants.ts";
import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
const PADDING = 10;
export class GuiChest extends GuiInventoryScreen {
override inventory_width = PADDING * 2 + SLOT_SIZE * 9;
override inventory_height = PADDING * 4 + SLOT_SIZE * 7;
constructor(inventory: Inventory, player_inventory: PlayerInventory) {
super(inventory, player_inventory, undefined);
add_player_hotbar(this, player_inventory, PADDING, PADDING);
add_player_inventory(this, player_inventory, PADDING, PADDING * 2 + SLOT_SIZE);
const chest_x = PADDING;
const chest_y = PADDING * 3 + SLOT_SIZE * 4;
for (let i = 0; i < 3; ++i) {
for (let l = 0; l < 9; ++l) {
this.slots.push(
new Slot(
this.inventory,
l + i * 9,
chest_x + l * SLOT_SIZE,
chest_y + i * SLOT_SIZE,
),
);
}
}
}
override on_render(): void {
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
const ui = AssetManager.instance.get<Texture>("bworld:ui");
draw_nine_slice(
ui,
160,
0,
16,
16,
4,
4,
4,
4,
this.x,
this.y,
this.inventory_width,
this.inventory_height,
);
super.on_render();
}
}
+119
View File
@@ -0,0 +1,119 @@
import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
import { canvas, draw_rect, draw_texture_region, Texture } from "$/client/renderer/mod.ts";
import { AssetManager } from "../assets.ts";
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
import { ClientMessage, ScreenLayout } from "$/common/protocol.ts";
import { draw_nine_slice } from "../rendering/render_utils.ts";
import { get_sprite_region } from "$/client/sprites.ts";
import { ClientInventories } from "../inventory.ts";
const PADDING = 10;
// a screen opened by the server (chest, furnace, ...), drawn from the layout it sent
export class GuiContainer extends GuiInventoryScreen {
layout: ScreenLayout;
properties: Record<string, number>;
constructor(
inventories: ClientInventories,
send: (message: ClientMessage) => void,
layout: ScreenLayout,
properties: Record<string, number>,
) {
super(inventories, send);
this.layout = layout;
this.properties = properties;
this.inventory_width = PADDING * 2 + SLOT_SIZE * 9;
this.inventory_height = PADDING * 4 + SLOT_SIZE * (4 + layout.rows);
add_player_hotbar(this, PADDING, PADDING);
add_player_inventory(this, PADDING, PADDING * 2 + SLOT_SIZE);
const area_y = PADDING * 3 + SLOT_SIZE * 4;
for (const slot of layout.slots) {
this.slots.push(
new Slot("screen", slot.index, PADDING + slot.x * SLOT_SIZE, area_y + slot.y * SLOT_SIZE, slot.output),
);
}
}
override on_render(): void {
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
const ui = AssetManager.instance.get<Texture>("bworld:ui");
draw_nine_slice(
ui,
160,
0,
16,
16,
4,
4,
4,
4,
this.x,
this.y,
this.inventory_width,
this.inventory_height,
);
this.draw_bars();
super.on_render();
}
draw_bars() {
const atlas = AssetManager.instance.get<Texture>("bworld:textures");
const area_y = PADDING * 3 + SLOT_SIZE * 4;
for (const bar of this.layout.bars) {
const x = this.x + PADDING + bar.x * SLOT_SIZE;
const y = this.y + area_y + bar.y * SLOT_SIZE;
const max = this.properties[bar.max] ?? 0;
const pct = max > 0 ? Math.max(0, Math.min(1, (this.properties[bar.value] ?? 0) / max)) : 0;
const empty = get_sprite_region(bar.empty_texture);
draw_texture_region(
atlas,
empty.x * TEXTURE_SIZE,
empty.y * TEXTURE_SIZE,
TEXTURE_SIZE,
TEXTURE_SIZE,
x,
y,
SLOT_SIZE,
SLOT_SIZE,
);
const full = get_sprite_region(bar.full_texture);
if (bar.direction === "right") {
draw_texture_region(
atlas,
full.x * TEXTURE_SIZE,
full.y * TEXTURE_SIZE,
TEXTURE_SIZE * pct,
TEXTURE_SIZE,
x,
y,
SLOT_SIZE * pct,
SLOT_SIZE,
);
} else {
// fills from the bottom up
draw_texture_region(
atlas,
full.x * TEXTURE_SIZE,
full.y * TEXTURE_SIZE + TEXTURE_SIZE * (1 - pct),
TEXTURE_SIZE,
TEXTURE_SIZE * pct,
x,
y + SLOT_SIZE * (1 - pct),
SLOT_SIZE,
SLOT_SIZE * pct,
);
}
}
}
}
-211
View File
@@ -1,211 +0,0 @@
import { Inventory, PlayerInventory } from "../inventory.ts";
import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
import { canvas, draw_rect, draw_texture_region, Texture } from "$/client/renderer/mod.ts";
import { AssetManager } from "../assets.ts";
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
import { get_sprite_region } from "$/common/utils.ts";
const PADDING = 10;
// crazy this is how i found out interface X {} is diffrent then type X = {}
type TileChestData = {
inventory: Inventory;
progress: number;
progress_max: number;
fuel: number;
fuel_max: number;
};
export class GuiFurnace extends GuiInventoryScreen<Inventory, TileChestData> {
override inventory_width = PADDING * 2 + SLOT_SIZE * 9;
override inventory_height = PADDING * 4 + SLOT_SIZE * 7;
constructor(
inventory: Inventory,
player_inventory: PlayerInventory,
properties: TileChestData,
) {
super(inventory, player_inventory, properties);
add_player_hotbar(this, player_inventory, PADDING, PADDING);
add_player_inventory(this, player_inventory, PADDING, PADDING * 2 + SLOT_SIZE);
const furnace_y = PADDING * 3 + SLOT_SIZE * 4;
this.slots.push(
new Slot(
this.inventory,
0,
this.inventory_width / 2 - SLOT_SIZE,
furnace_y,
),
);
this.slots.push(
new Slot(
this.inventory,
1,
this.inventory_width / 2 - SLOT_SIZE,
furnace_y + SLOT_SIZE * 2,
),
);
this.slots.push(
new Slot(
this.inventory,
2,
this.inventory_width / 2 + SLOT_SIZE,
furnace_y + SLOT_SIZE,
),
);
}
override on_render(): void {
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
const ui = AssetManager.instance.get<Texture>("bworld:ui");
draw_nine_slice(
ui,
160,
0,
16,
16,
4,
4,
4,
4,
this.x,
this.y,
this.inventory_width,
this.inventory_height,
);
this.draw_fire();
this.draw_arrow();
super.on_render();
}
override handle_left_click(slot: Slot): void {
if (slot.inventory === this.inventory && slot.index === 2) {
if (this.inventory.container.get_item(2)) {
this.pickup();
}
} else {
super.handle_left_click(slot);
}
}
override handle_right_click(slot: Slot): void {
if (slot.inventory === this.inventory && slot.index === 2) {
if (this.inventory.container.get_item(2)) {
this.pickup();
}
} else {
super.handle_right_click(slot);
}
}
pickup() {
const holding_item = this.player_inventory.holding_item;
const item = this.inventory.container.get_item(2)!;
if (holding_item) {
if (holding_item.type_id === item.type_id) {
const items_left = holding_item.max_amount - holding_item.amount;
if (items_left >= item.amount) {
holding_item.amount += item.amount;
this.inventory.container.set_item(2, undefined);
}
}
} else {
this.player_inventory.holding_item = item;
this.inventory.container.set_item(2, undefined);
}
}
draw_fire() {
const atlas = AssetManager.instance.get<Texture>("bworld:textures");
const fire_empty_region = get_sprite_region("bworld:fire_empty");
const fire_full_region = get_sprite_region("bworld:fire_full");
const fuel_pct = Math.max(0, Math.min(1, this.properties.fuel / this.properties.fuel_max));
const full_height = SLOT_SIZE * fuel_pct;
const furnace_y = PADDING * 3 + SLOT_SIZE * 4;
draw_texture_region(
atlas,
fire_empty_region.x * TEXTURE_SIZE,
fire_empty_region.y * TEXTURE_SIZE,
TEXTURE_SIZE,
TEXTURE_SIZE,
this.x + this.inventory_width / 2 - SLOT_SIZE,
this.y + furnace_y + SLOT_SIZE,
SLOT_SIZE,
SLOT_SIZE,
);
const fire_draw_x = this.x + this.inventory_width / 2 - SLOT_SIZE;
const fire_draw_y = this.y + furnace_y + SLOT_SIZE;
const fire_src_height = TEXTURE_SIZE * fuel_pct;
const fire_src_y_offset = TEXTURE_SIZE - fire_src_height;
const fire_dst_y_offset = SLOT_SIZE - full_height;
draw_texture_region(
atlas,
fire_full_region.x * TEXTURE_SIZE,
fire_full_region.y * TEXTURE_SIZE + fire_src_y_offset,
TEXTURE_SIZE,
fire_src_height,
fire_draw_x,
fire_draw_y + fire_dst_y_offset,
SLOT_SIZE,
full_height,
);
}
draw_arrow() {
const atlas = AssetManager.instance.get<Texture>("bworld:textures");
const arrow_empty_region = get_sprite_region("bworld:arrow_empty");
const arrow_full_region = get_sprite_region("bworld:arrow_full");
const progress_pct = Math.max(0, Math.min(1, this.properties.progress / this.properties.progress_max));
const full_width = SLOT_SIZE * progress_pct;
const furnace_y = PADDING * 3 + SLOT_SIZE * 4;
draw_texture_region(
atlas,
arrow_empty_region.x * TEXTURE_SIZE,
arrow_empty_region.y * TEXTURE_SIZE,
TEXTURE_SIZE,
TEXTURE_SIZE,
this.x + this.inventory_width / 2,
this.y + furnace_y + SLOT_SIZE,
SLOT_SIZE,
SLOT_SIZE,
);
const draw_x = this.x + this.inventory_width / 2;
const draw_y = this.y + furnace_y + SLOT_SIZE;
const src_width = TEXTURE_SIZE * progress_pct;
draw_texture_region(
atlas,
arrow_full_region.x * TEXTURE_SIZE,
arrow_full_region.y * TEXTURE_SIZE,
src_width,
TEXTURE_SIZE,
draw_x,
draw_y,
full_width,
SLOT_SIZE,
);
}
}
+12 -220
View File
@@ -1,96 +1,23 @@
import { Container, Inventory, ItemStack, PlayerInventory } from "../inventory.ts";
import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
import { 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 { draw_nine_slice } from "../rendering/render_utils.ts";
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();
}
}
}
+74 -184
View File
@@ -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";
import { draw_item, draw_nine_slice } from "../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;
for (const slot of this.slots) {
this.hovering = undefined;
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;
if (InputManager.is_mouse_pressed(0)) {
InputManager.consume_mouse(0);
this.handle_left_click(slot);
return;
for (const slot of this.slots) {
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(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));
}
}
-21
View File
@@ -1,21 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { ClientWorld } from "../client_world.ts";
import { PlayerComponent } from "../player.ts";
export class GuiRenderSystem extends System {
update(world: ClientWorld, _delta: number): void {
const [player] = world.get_tag("player")!;
const player_component = player.get(PlayerComponent)!;
player_component.screens.at(-1)?.on_render();
}
}
export class GuiTickSystem extends System {
update(world: ClientWorld, delta: number): void {
const [player] = world.get_tag("player")!;
const player_component = player.get(PlayerComponent)!;
player_component.screens.at(-1)?.on_tick(delta);
}
}
+66
View File
@@ -0,0 +1,66 @@
import { SLOT_SIZE } from "$/common/constants.ts";
import { AssetManager } from "$/client/assets.ts";
import type { Client } from "$/client/client.ts";
import type { ClientInventories } from "$/client/inventory.ts";
import { draw_item, draw_nine_slice } from "$/client/rendering/render_utils.ts";
import { canvas, draw_rect_stroke, Texture } from "$/client/renderer/mod.ts";
const PADDING = 10;
const CROSSHAIR_SIZE = 8;
// what's drawn over the world while playing, like minecraft's Gui: hotbar, crosshair and chat
export class Hud {
render(client: Client) {
this.#render_hotbar(client.player.inventories);
this.#render_crosshair();
client.chat.render(false);
}
#render_hotbar(inventories: ClientInventories) {
const ui = AssetManager.instance.get<Texture>("bworld:ui");
const hotbar_width = PADDING * 2 + SLOT_SIZE * 9;
const hotbar_height = PADDING * 2 + SLOT_SIZE;
const x = canvas.width / 2 - hotbar_width / 2;
const y = canvas.height - hotbar_height;
draw_nine_slice(ui, 160, 0, 16, 16, 4, 4, 4, 4, x, y, hotbar_width, hotbar_height);
for (let index = 0; index < 9; index += 1) {
const selected = inventories.hotbar_selected === index;
draw_nine_slice(
ui,
selected ? 19 * 16 : 160 + 32,
selected ? 16 : 0,
16,
16,
4,
4,
4,
4,
x + PADDING + index * SLOT_SIZE,
y + PADDING,
SLOT_SIZE,
SLOT_SIZE,
);
}
for (let index = 0; index < 9; index += 1) {
const item = inventories.inventory.get_item(index);
if (item) {
draw_item(item, x + PADDING + index * SLOT_SIZE, y + PADDING);
}
}
}
#render_crosshair() {
draw_rect_stroke(
(canvas.width - CROSSHAIR_SIZE) / 2,
(canvas.height - CROSSHAIR_SIZE) / 2,
CROSSHAIR_SIZE,
CROSSHAIR_SIZE,
[0, 0, 0, 0.6],
);
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { Client } from "$/client/client.ts";
import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mod.ts";
import { GuiScreen } from "./gui_screen.ts";
import { Button, TEXT_HEIGHT } from "./widgets.ts";
import { CreditsScreen } from "./credits_screen.ts";
const BUTTON_WIDTH = 440;
const BUTTON_HEIGHT = 48;
const GAP = 16;
// escape while playing, like minecraft's PauseScreen. the server keeps going, it's only a menu
export class PauseScreen extends GuiScreen {
buttons: Button[];
constructor(client: Client) {
super();
this.buttons = [
new Button("Back to game", BUTTON_WIDTH, BUTTON_HEIGHT, () => client.pop_screen()),
new Button("Credits", BUTTON_WIDTH, BUTTON_HEIGHT, () => {
client.push_screen(new CreditsScreen(() => client.pop_screen()));
}),
new Button("Disconnect", BUTTON_WIDTH, BUTTON_HEIGHT, () => client.disconnect()),
];
}
on_tick(_delta: number): void {
let y = canvas.height / 2 - BUTTON_HEIGHT;
for (const button of this.buttons) {
button.x = (canvas.width - BUTTON_WIDTH) / 2;
button.y = y;
y += BUTTON_HEIGHT + GAP;
}
for (const button of this.buttons) {
if (button.handle_input()) {
break;
}
}
}
on_render(): void {
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.5]);
const title = "Game menu";
draw_text(title, (canvas.width - measure_text(title, 4)) / 2, this.buttons[0].y - 40 - TEXT_HEIGHT * 4, 4);
for (const button of this.buttons) {
button.render();
}
}
on_close(): void {}
}
+209
View File
@@ -0,0 +1,209 @@
import { MAX_NAME_LENGTH } from "$/common/protocol.ts";
import { InputManager } from "$/client/input_manager.ts";
import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mod.ts";
import { default_server, HandshakeError, server_address, ServerAddress } from "$/client/handshake.ts";
import { default_player_name } from "$/client/network.ts";
import { GuiScreen } from "./gui_screen.ts";
import { CreditsScreen } from "./credits_screen.ts";
import { Button, EditBox, TEXT_HEIGHT, TEXT_SCALE, TextInput } from "./widgets.ts";
const LAST_SERVER_KEY = "bworld:last_server";
const LAST_NAME_KEY = "bworld:last_name";
const WIDTH = 440;
const ROW_HEIGHT = 48;
const LABEL_GAP = 28;
const GAP = 20;
const TITLE_SCALE = 8;
// connects to a server and joins it. it reports progress through status, and throws with a message
// for the player when it fails
export type JoinServer = (address: ServerAddress, name: string, status: (text: string) => void) => Promise<void>;
// the first thing players see, like minecraft's title and multiplayer screens rolled into one:
// a name, a server, and a button to join it
export class TitleScreen extends GuiScreen {
#join_server: JoinServer;
name = new EditBox(
WIDTH,
ROW_HEIGHT,
new TextInput(initial_name(), MAX_NAME_LENGTH, (char) => /^[A-Za-z0-9_]$/.test(char)),
"Random name",
);
server = new EditBox(WIDTH, ROW_HEIGHT, new TextInput(initial_server(), 256, (char) => char !== " "), "host:port");
join_button = new Button("Join server", WIDTH, ROW_HEIGHT, () => this.#join());
credits_button = new Button("Credits", WIDTH, ROW_HEIGHT, () => this.#open_credits());
// open over the title screen, it gets the input while it's there
#credits: CreditsScreen | undefined;
status = "";
status_is_error = false;
#joining = false;
constructor(join_server: JoinServer, message?: string) {
super();
this.#join_server = join_server;
this.name.focused = this.name.value === "";
this.server.focused = !this.name.focused;
if (message) {
this.#show_error(message);
}
}
on_tick(delta: number): void {
this.#layout();
if (this.#credits) {
if (InputManager.is_key_pressed("Escape")) {
this.#close_credits();
} else {
this.#credits.on_tick(delta);
}
return;
}
const fields = [this.name, this.server];
for (const field of fields) {
if (field.handle_input()) {
for (const other of fields) other.focused = other === field;
}
}
if (InputManager.is_key_pressed("Tab")) {
const next = this.name.focused ? this.server : this.name;
for (const field of fields) field.focused = field === next;
}
if (InputManager.is_key_pressed("Enter")) {
this.#join();
}
this.join_button.handle_input();
this.credits_button.handle_input();
}
on_render(): void {
const title = "bworld";
const title_width = measure_text(title, TITLE_SCALE);
draw_text(
title,
(canvas.width - title_width) / 2,
this.name.y - LABEL_GAP - 40 - TEXT_HEIGHT * TITLE_SCALE,
TITLE_SCALE,
);
this.#label("Name", this.name);
this.#label("Server", this.server);
this.name.render();
this.server.render();
this.join_button.render();
this.credits_button.render();
if (this.status) {
const width = measure_text(this.status, TEXT_SCALE);
const x = (canvas.width - width) / 2;
const y = this.credits_button.y + ROW_HEIGHT + GAP;
draw_rect(x - 8, y - 4, width + 16, TEXT_HEIGHT * TEXT_SCALE + 8, [0, 0, 0, 0.5]);
draw_text(this.status, x, y, TEXT_SCALE, this.status_is_error ? [1, 0.45, 0.45, 1] : [1, 1, 1, 1]);
}
this.#credits?.on_render();
}
on_close(): void {
this.#close_credits();
}
#open_credits() {
this.#credits ??= new CreditsScreen(() => this.#close_credits());
}
#close_credits() {
this.#credits?.on_close();
this.#credits = undefined;
}
#layout() {
const x = (canvas.width - WIDTH) / 2;
const total = 2 * (LABEL_GAP + ROW_HEIGHT + GAP) + 2 * ROW_HEIGHT + GAP;
let y = (canvas.height - total) / 2 + 40;
for (const field of [this.name, this.server]) {
field.x = x;
field.y = y + LABEL_GAP;
y += LABEL_GAP + ROW_HEIGHT + GAP;
}
this.join_button.x = x;
this.join_button.y = y;
this.credits_button.x = x;
this.credits_button.y = y + ROW_HEIGHT + GAP;
}
#label(text: string, field: EditBox) {
draw_text(text, field.x, field.y - LABEL_GAP, TEXT_SCALE, [0.15, 0.15, 0.2, 1]);
}
async #join() {
if (this.#joining) {
return;
}
let address: ServerAddress;
try {
address = server_address(this.server.value);
} catch (e) {
this.#show_error((e as HandshakeError).message);
return;
}
remember(LAST_SERVER_KEY, this.server.value.trim());
remember(LAST_NAME_KEY, this.name.value);
this.#set_joining(true);
this.status = `Connecting to ${address.base.host}...`;
this.status_is_error = false;
try {
await this.#join_server(address, this.name.value, (text) => this.status = text);
} catch (e) {
this.#show_error(e instanceof Error ? e.message : String(e));
this.#set_joining(false);
}
}
#set_joining(joining: boolean) {
this.#joining = joining;
this.name.active = this.server.active = this.join_button.active = !joining;
}
#show_error(message: string) {
this.status = message;
this.status_is_error = true;
}
}
// what was typed last time, unless the page was opened with ?server= or ?name=
function initial_server() {
const page = new URL(location.href);
return page.searchParams.has("server") ? default_server(page) : recall(LAST_SERVER_KEY) ?? default_server(page);
}
function initial_name() {
const page = new URL(location.href);
return page.searchParams.has("name") ? default_player_name() : recall(LAST_NAME_KEY) ?? "";
}
function recall(key: string): string | undefined {
try {
return localStorage.getItem(key) ?? undefined;
} catch {
return undefined;
}
}
function remember(key: string, value: string) {
try {
localStorage.setItem(key, value);
} catch {
// private windows and blocked storage just start empty next time
}
}
// a server's mods can't be unloaded, so going back to the title screen starts the page over. without ?server=
// or ?name=, so the fields show what was used last
export function back_to_title() {
location.href = location.pathname;
}
+182
View File
@@ -0,0 +1,182 @@
import { point_inside_rec } from "$/common/utils.ts";
import { AssetManager } from "$/client/assets.ts";
import { InputManager } from "$/client/input_manager.ts";
import { draw_nine_slice } from "$/client/rendering/render_utils.ts";
import { draw_rect, draw_text, measure_text, Texture } from "$/client/renderer/mod.ts";
export const TEXT_SCALE = 2;
// how tall the font is at scale 1
export const TEXT_HEIGHT = 11;
const REPEAT_DELAY_MS = 400;
const REPEAT_RATE_MS = 40;
const CARET_BLINK_MS = 500;
// editing a line of text with the keyboard: typing, backspace (held repeats), delete, arrows, home and end
export class TextInput {
value: string;
caret: number;
max_length: number;
// which typed characters are kept
allowed: (char: string) => boolean;
#repeat_timer = 0;
constructor(value = "", max_length = 256, allowed: (char: string) => boolean = () => true) {
this.value = value;
this.caret = value.length;
this.max_length = max_length;
this.allowed = allowed;
}
// call once per frame while it has the keyboard
handle_keys() {
const now = performance.now();
let backspace = false;
if (InputManager.is_key_pressed("Backspace")) {
this.#repeat_timer = now;
backspace = true;
} else if (InputManager.is_key_down("Backspace") && now - this.#repeat_timer > REPEAT_DELAY_MS) {
this.#repeat_timer = now - (REPEAT_DELAY_MS - REPEAT_RATE_MS);
backspace = true;
}
if (backspace && this.caret > 0) {
this.value = this.value.slice(0, this.caret - 1) + this.value.slice(this.caret);
this.caret -= 1;
}
if (InputManager.is_key_pressed("Delete")) {
this.value = this.value.slice(0, this.caret) + this.value.slice(this.caret + 1);
}
if (InputManager.is_key_pressed("ArrowLeft")) {
this.caret = Math.max(0, this.caret - 1);
}
if (InputManager.is_key_pressed("ArrowRight")) {
this.caret = Math.min(this.value.length, this.caret + 1);
}
if (InputManager.is_key_pressed("Home")) {
this.caret = 0;
}
if (InputManager.is_key_pressed("End")) {
this.caret = this.value.length;
}
for (const char of InputManager.get_typed_characters()) {
if (this.value.length >= this.max_length || !this.allowed(char)) {
continue;
}
this.value = this.value.slice(0, this.caret) + char + this.value.slice(this.caret);
this.caret += 1;
}
}
// where the caret goes when the text starts at x
caret_x(x: number, scale = TEXT_SCALE) {
return x + measure_text(this.value.slice(0, this.caret), scale);
}
static caret_visible() {
return Math.floor(performance.now() / CARET_BLINK_MS) % 2 === 0;
}
}
// a clickable button, like minecraft's Button
export class Button {
x = 0;
y = 0;
width: number;
height: number;
label: string;
on_press: () => void;
active = true;
#hovered = false;
constructor(label: string, width: number, height: number, on_press: () => void) {
this.label = label;
this.width = width;
this.height = height;
this.on_press = on_press;
}
// returns whether it was pressed
handle_input(): boolean {
const mouse = InputManager.get_mouse_position();
this.#hovered = this.active && point_inside_rec(mouse.x, mouse.y, this.x, this.y, this.width, this.height);
if (this.#hovered && InputManager.is_mouse_pressed(0)) {
InputManager.consume_mouse(0);
this.on_press();
return true;
}
return false;
}
render() {
const ui = AssetManager.instance.get<Texture>("bworld:ui");
const [sx, sy] = this.#hovered ? [224, 16] : [176, 0];
const tint = this.active ? [1, 1, 1, 1] : [0.6, 0.6, 0.6, 1];
draw_nine_slice(ui, sx, sy, 16, 16, 4, 4, 4, 4, this.x, this.y, this.width, this.height, tint);
const text_width = measure_text(this.label, TEXT_SCALE);
draw_text(
this.label,
this.x + (this.width - text_width) / 2,
this.y + (this.height - TEXT_HEIGHT * TEXT_SCALE) / 2,
TEXT_SCALE,
this.active ? [1, 1, 1, 1] : [0.7, 0.7, 0.7, 1],
);
}
}
// a one line text field, like minecraft's EditBox. clicking it gives it the keyboard
export class EditBox {
x = 0;
y = 0;
width: number;
height: number;
input: TextInput;
focused = false;
// shown greyed out while it's empty
hint: string;
active = true;
constructor(width: number, height: number, input: TextInput, hint = "") {
this.width = width;
this.height = height;
this.input = input;
this.hint = hint;
}
get value() {
return this.input.value;
}
// returns whether it was clicked, so the screen can move focus to it
handle_input(): boolean {
const mouse = InputManager.get_mouse_position();
const clicked = this.active && InputManager.is_mouse_pressed(0) &&
point_inside_rec(mouse.x, mouse.y, this.x, this.y, this.width, this.height);
if (clicked) {
InputManager.consume_mouse(0);
}
if (this.focused && this.active) {
this.input.handle_keys();
}
return clicked;
}
render() {
const ui = AssetManager.instance.get<Texture>("bworld:ui");
draw_nine_slice(ui, this.focused ? 320 : 304, 0, 16, 16, 4, 4, 4, 4, this.x, this.y, this.width, this.height);
const text_x = this.x + 10;
const text_y = this.y + (this.height - TEXT_HEIGHT * TEXT_SCALE) / 2;
if (this.value === "" && !this.focused) {
draw_text(this.hint, text_x, text_y, TEXT_SCALE, [0.6, 0.6, 0.6, 1]);
} else {
draw_text(this.value, text_x, text_y, TEXT_SCALE, this.active ? [1, 1, 1, 1] : [0.7, 0.7, 0.7, 1]);
}
if (this.focused && this.active && TextInput.caret_visible()) {
draw_rect(this.input.caret_x(text_x), text_y, 2, TEXT_HEIGHT * TEXT_SCALE);
}
}
}
+211
View File
@@ -0,0 +1,211 @@
// 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 {}
// what the server field starts as: ?server=host:port, otherwise the server that served the page
export function default_server(page = new URL(location.href)): string {
return page.searchParams.get("server") ?? page.host;
}
// what a player typed as the server: host:port, or a full http(s) or ws(s) url
export function server_address(input: string, page = new URL(location.href)): ServerAddress {
const text = input.trim();
let host = text;
// without a scheme it's as secure as the page, browsers block insecure sockets from secure pages anyway
let secure = page.protocol === "https:";
if (text.includes("://")) {
let url: URL;
try {
url = new URL(text);
} catch {
throw new HandshakeError(`${text} isn't a server address`);
}
host = url.host;
secure = url.protocol === "https:" || url.protocol === "wss:";
}
if (!host || /[\s/?#]/.test(host)) {
throw new HandshakeError(`${text || "An empty address"} isn't a server address`);
}
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
}
}
+10
View File
@@ -96,6 +96,8 @@ export class InputManager {
static mouse_ungrab_timer = 0;
static mouse_ungrab_timeout = -1;
static pointer_lock_waiting = false;
// the browser let go of the mouse while the game wanted it, like escape or switching windows
static #lost_pointer_lock = false;
static initialize(canvas: HTMLCanvasElement) {
self.addEventListener("keydown", (e) => {
@@ -164,6 +166,7 @@ export class InputManager {
if (!grab) {
if (this.pointer_lock_flag) {
this.mouse_ungrab_timer = performance.now();
this.#lost_pointer_lock = true;
}
}
this.pointer_lock_flag = grab;
@@ -266,6 +269,13 @@ export class InputManager {
}
}
// whether the mouse was taken away since the last call, browsers eat the escape that does it
static take_lost_pointer_lock() {
const lost = this.#lost_pointer_lock;
this.#lost_pointer_lock = false;
return lost;
}
static is_mouse_grabbed() {
return document.hasFocus() && document.pointerLockElement === canvas;
}
+9 -136
View File
@@ -1,138 +1,11 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { Container, Cursor } from "$/common/inventory.ts";
export class ItemStack<T = unknown | undefined> {
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;
}
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:axe", {
texture_id: "bworld:axe",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:coal", {
texture_id: "bworld:coal",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:copper_ingot", {
texture_id: "bworld:copper_ingot",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:gold_ingot", {
texture_id: "bworld:gold_ingot",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:hoe", {
texture_id: "bworld:hoe",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:iron_ingot", {
texture_id: "bworld:iron_ingot",
});
-11
View File
@@ -1,11 +0,0 @@
import "./watering_can.ts";
import "./axe.ts";
import "./pickaxe.ts";
import "./hoe.ts";
import "./coal.ts";
import "./tin_ingot.ts";
import "./iron_ingot.ts";
import "./copper_ingot.ts";
import "./gold_ingot.ts";
import "./stick.ts";
import "./wood_pickaxe.ts";
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:pickaxe", {
texture_id: "bworld:pickaxe",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:stick", {
texture_id: "bworld:stick",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:tin_ingot", {
texture_id: "bworld:tin_ingot",
});
-16
View File
@@ -1,16 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
export interface WateringCanData {
water: number;
max_water: number;
}
EverythingRegistry.register<ItemRegistry<WateringCanData>>("items", "bworld:watering_can", {
texture_id: "bworld:watering_can",
on_create(item) {
item.data = { water: 0, max_water: 32 };
},
get_lore(item) {
return `Water: ${item.data?.water}/${item.data?.max_water}`;
},
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:wood_pickaxe", {
texture_id: "bworld:wood_pickaxe",
});
+718
View File
@@ -0,0 +1,718 @@
import { block_value, chunk_key, default_block_value } from "$/common/utils.ts";
import { AIR_ID, BlockChange } from "$/common/protocol.ts";
import {
block_light_emission,
block_light_opacity,
BlockRegistry,
EverythingRegistry,
RENDER_LAYERS,
RenderLayer,
} from "$/common/everything_registry.ts";
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from "../../common/constants.ts";
import { AssetManager } from "../assets.ts";
import { ChunkWorkerPool } from "../chunk_workers.ts";
import { worldgen_mods } from "../mods.ts";
import { type FromChunkWorker, TERRAIN_VERTEX_FLOATS } from "../workers/chunk_messages.ts";
import { create_index_buffer, create_vertex_buffer, destroy_buffer, Texture } from "../renderer/mod.ts";
import { crosses_planes } from "../workers/translucent_sort.ts";
import { Camera } from "../camera.ts";
import type { Entity } from "../entity/entity.ts";
export interface Block {
id: string;
x: number;
y: number;
z: number;
}
export { CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE };
export interface Chunk {
x: number;
z: number;
blocks: Uint32Array;
generated: boolean;
dirty: boolean;
// bumped on every mesh request so late results from older requests get ignored
mesh_version: number;
meshes: Partial<Record<RenderLayer, ChunkMesh>>;
// only for translucent meshes that have to be sorted again as the camera moves
translucent_sort?: TranslucentSort;
// blocks its generation put in neighboring chunks (leaves), as x, y, z, numeric id. kept so a neighbor that
// generates later, or unloads and comes back, still gets them
spills?: Int32Array;
}
export interface ChunkMesh {
vertex_buffer: GPUBuffer;
quad_count: number;
// the translucent layer's quads sorted back to front, the others are drawn in order
index_buffer?: GPUBuffer;
}
interface TranslucentSort {
// the mesh_version of the mesh these are for
mesh_version: number;
centers: Float32Array;
planes: [Float32Array, Float32Array, Float32Array];
// where the camera was for the last sort that was requested
camera: number[];
// sorts come back out of order, older ones than what's shown get skipped
requested: number;
applied: number;
}
const FLOATS_PER_QUAD = 4 * TERRAIN_VERTEX_FLOATS;
export { chunk_key };
// the block being looked at and which face of it
export interface BlockHitResult {
x: number;
y: number;
z: number;
block: number;
face: Faces;
}
const NEIGHBOR_OFFSETS = [[-1, 0], [1, 0], [0, -1], [0, 1]] as const;
// light spreads diagonally too, so meshing and lighting need all 8
const ALL_NEIGHBOR_OFFSETS = [...NEIGHBOR_OFFSETS, [-1, -1], [1, -1], [-1, 1], [1, 1]] as const;
// what minecraft calls the ClientLevel: this client's copy of the world, its chunks and the entities in it
export class ClientLevel {
image: Texture = AssetManager.instance.get("bworld:textures");
chunks = new Map<number, Chunk>();
second_timer = 0;
tick_timer = 0;
seed: string;
// blocks players changed from the generated terrain, per chunk, so they survive reloading chunks
changes = new Map<string, Map<string, BlockChange>>();
workers: ChunkWorkerPool;
#blocks = EverythingRegistry.get_registry<BlockRegistry>("blocks");
// chunks being generated by a worker, by chunk key
pending_generation = new Map<number, { x: number; z: number }>();
// every entity this client knows about, the local player included, by id
entities = new Map<string, Entity>();
constructor(seed = "seed") {
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_entity(entity: Entity) {
this.entities.set(entity.id, entity);
}
remove_entity(id: string) {
this.entities.delete(id);
}
tick() {
for (const entity of this.entities.values()) {
entity.save_previous_position();
entity.tick();
}
}
// generates the chunks around a position and forgets the ones too far away. one extra ring past the render
// distance gets generated so the edge has neighbors to mesh against
update_loaded_chunks(x: number, z: number, render_distance: number) {
const center_x = Math.floor(x / CHUNK_SIZE);
const center_z = Math.floor(z / CHUNK_SIZE);
const load_distance = render_distance + 1;
const out_of_range = (cx: number, cz: number) =>
Math.max(Math.abs(cx - center_x), Math.abs(cz - center_z)) > load_distance;
// collect first, deleting from the map while iterating it skips entries
const to_unload = [...this.chunks.values()].filter((chunk) => out_of_range(chunk.x, chunk.z));
for (const chunk of to_unload) {
this.unload_chunk(chunk.x, chunk.z);
}
for (const pending of [...this.pending_generation.values()]) {
if (out_of_range(pending.x, pending.z)) {
this.cancel_chunk_request(pending.x, pending.z);
}
}
// dont queue up the whole area at once, so walking somewhere new gets the close chunks first
const max_in_flight = this.workers.size * 2;
if (this.pending_generation.size >= max_in_flight) {
return;
}
const missing: { x: number; z: number; distance: number }[] = [];
for (let cx = center_x - load_distance; cx <= center_x + load_distance; cx += 1) {
for (let cz = center_z - load_distance; cz <= center_z + load_distance; cz += 1) {
if (!this.is_generated(cx, cz) && !this.is_generating(cx, cz)) {
const dx = cx - center_x;
const dz = cz - center_z;
missing.push({ x: cx, z: cz, distance: dx * dx + dz * dz });
}
}
}
missing.sort((a, b) => a.distance - b.distance);
for (const chunk of missing.slice(0, max_in_flight - this.pending_generation.size)) {
this.request_chunk(chunk.x, chunk.z);
}
}
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,
dirty: true,
generated: false,
mesh_version: 0,
meshes: {},
};
this.chunks.set(chunk_key(x, z), chunk);
return chunk;
}
get_chunk(x: number, z: number) {
return this.chunks.get(chunk_key(x, z));
}
// 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);
if (!chunk) {
chunk = this.add_chunk(block_chunk_x, block_chunk_z);
}
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;
const ly = block.y;
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
const old_nid = chunk.blocks[index] & ID_MASK;
chunk.blocks[index] = state === undefined ? default_block_value(nid, info) : block_value(nid, state);
chunk.dirty = true;
this.#mark_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz, old_nid, nid);
}
// 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) {
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;
}
const lx = x - chunk_x * CHUNK_SIZE;
const lz = z - chunk_z * CHUNK_SIZE;
const ly = y;
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
return chunk.blocks[index] & ID_MASK;
}
// whether entities bump into the block there. unloaded chunks count as solid, so nothing falls out of the world
has_collision(x: number, y: number, z: number) {
const block = this.get_block(x, y, z);
return block === VOID || (block !== AIR && (this.#blocks[block]?.has_collision ?? true));
}
// 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);
const chunk = this.get_chunk(block_chunk_x, block_chunk_z);
if (!chunk) {
return;
}
const lx = x - block_chunk_x * CHUNK_SIZE;
const lz = z - block_chunk_z * CHUNK_SIZE;
const ly = y;
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
const old_nid = chunk.blocks[index] & ID_MASK;
chunk.blocks[index] = AIR;
chunk.dirty = true;
this.#mark_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz, old_nid, AIR);
}
// a block that lets through or gives off a different amount of light changes the light up to 15 blocks
// away, so in every neighbor. otherwise only a block on a chunk's edge matters, for its neighbor's faces
#mark_neighbors_dirty(
block_chunk_x: number,
block_chunk_z: number,
lx: number,
lz: number,
old_nid: number,
new_nid: number,
) {
const old_block = this.#blocks[old_nid];
const new_block = this.#blocks[new_nid];
if (
block_light_opacity(old_block) !== block_light_opacity(new_block) ||
block_light_emission(old_block) !== block_light_emission(new_block)
) {
for (const [dx, dz] of ALL_NEIGHBOR_OFFSETS) {
const n = this.get_chunk(block_chunk_x + dx, block_chunk_z + dz);
if (n) {
n.dirty = true;
}
}
return;
}
if (lx === 0) {
const n = this.get_chunk(block_chunk_x - 1, block_chunk_z);
if (n) {
n.dirty = true;
}
} else if (lx === CHUNK_SIZE - 1) {
const n = this.get_chunk(block_chunk_x + 1, block_chunk_z);
if (n) {
n.dirty = true;
}
}
if (lz === 0) {
const n = this.get_chunk(block_chunk_x, block_chunk_z - 1);
if (n) {
n.dirty = true;
}
} else if (lz === CHUNK_SIZE - 1) {
const n = this.get_chunk(block_chunk_x, block_chunk_z + 1);
if (n) {
n.dirty = true;
}
}
}
index_to_xyz(index: number) {
const y = Math.floor(index / CHUNK_AREA);
const rem = index % CHUNK_AREA;
const z = Math.floor(rem / CHUNK_SIZE);
const x = rem % CHUNK_SIZE;
return [x, y, z];
}
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 current = this.get_block(x, y, z);
if (id === AIR_ID) {
if (current !== AIR) {
this.break_block(x, y, z);
}
return;
}
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 and light 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 ALL_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(camera: Camera) {
for (const chunk of this.chunks.values()) {
if (!chunk.dirty || !this.can_mesh(chunk)) {
continue;
}
chunk.dirty = false;
chunk.mesh_version += 1;
// copies, the worker lights the whole 3x3 area
const chunks: (Uint32Array | null)[] = [];
for (let dz = -1; dz <= 1; dz++) {
for (let dx = -1; dx <= 1; dx++) {
chunks.push(this.get_chunk(chunk.x + dx, chunk.z + dz)?.blocks.slice() ?? null);
}
}
this.workers.post({
type: "mesh",
chunk_x: chunk.x,
chunk_z: chunk.z,
version: chunk.mesh_version,
chunks,
camera: [camera.x, camera.y, camera.z],
}, chunks.filter((blocks) => blocks !== null).map((blocks) => blocks.buffer));
}
}
// like sodium, a chunk's translucent quads only change order when the camera crosses one of the
// planes they lie on, so that's the only time they get sorted again
update_translucent_sorting(camera: Camera) {
const position = [camera.x, camera.y, camera.z];
for (const chunk of this.chunks.values()) {
const sort = chunk.translucent_sort;
if (!sort || !crosses_planes(sort.planes, sort.camera, position)) {
continue;
}
sort.camera = position;
sort.requested += 1;
this.workers.post({
type: "sort",
chunk_x: chunk.x,
chunk_z: chunk.z,
version: sort.mesh_version,
sort_version: sort.requested,
centers: sort.centers,
camera: position,
});
}
}
#on_worker_message(message: FromChunkWorker) {
if (message.type === "generated") {
this.#on_generated(message.chunk_x, message.chunk_z, message.blocks, message.spills);
} else if (message.type === "meshed") {
this.#on_meshed(message);
} else {
this.#on_sorted(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) {
// blocks placed here before it generated, keep them 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;
chunk.spills = spills;
// the same rules as the server (server/game/world.ts): a chunk's own blocks, then what its neighbors'
// features put in it, only where it has air. both ways, since the neighbors may have generated first
for (const [dx, dz] of ALL_NEIGHBOR_OFFSETS) {
const neighbor_spills = this.get_chunk(cx + dx, cz + dz)?.spills;
if (neighbor_spills) {
this.#apply_spills(neighbor_spills, chunk);
}
}
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 ALL_NEIGHBOR_OFFSETS) {
const neighbor = this.get_chunk(cx + dx, cz + dz);
if (neighbor && neighbor.generated) {
neighbor.dirty = true;
}
}
// features 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);
}
}
}
#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(chunk);
for (const layer of RENDER_LAYERS) {
const { vertices, quad_count } = message[layer];
if (quad_count === 0) {
continue;
}
chunk.meshes[layer] = {
vertex_buffer: create_vertex_buffer(vertices.subarray(0, quad_count * FLOATS_PER_QUAD)),
quad_count,
};
}
const translucent = message.translucent;
if (translucent.quad_count === 0) {
return;
}
chunk.meshes.translucent!.index_buffer = create_index_buffer(translucent.indices);
if (translucent.sort_type === "dynamic") {
chunk.translucent_sort = {
mesh_version: message.version,
centers: translucent.centers,
planes: translucent.planes,
camera: message.camera,
requested: 0,
applied: 0,
};
}
}
#on_sorted(message: Extract<FromChunkWorker, { type: "sorted" }>) {
const chunk = this.get_chunk(message.chunk_x, message.chunk_z);
const sort = chunk?.translucent_sort;
const mesh = chunk?.meshes.translucent;
// remeshed since, or a newer sort already came back
if (!sort || !mesh || sort.mesh_version !== message.version || message.sort_version <= sort.applied) {
return;
}
sort.applied = message.sort_version;
if (mesh.index_buffer) {
destroy_buffer(mesh.index_buffer);
}
mesh.index_buffer = create_index_buffer(message.indices);
}
// blocks a neighbor's features put here, 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);
// a chunk that isn't generated yet takes it from our spills when it is
if (!chunk?.generated) {
return;
}
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;
this.#mark_neighbors_dirty(chunk_x, chunk_z, lx, lz, AIR, nid & ID_MASK);
}
}
// the spills that land in chunk
#apply_spills(spills: Int32Array, chunk: Chunk) {
for (let i = 0; i < spills.length; i += 4) {
if (Math.floor(spills[i] / CHUNK_SIZE) === chunk.x && Math.floor(spills[i + 2] / CHUNK_SIZE) === chunk.z) {
this.#set_block_raw(spills[i], spills[i + 1], spills[i + 2], spills[i + 3]);
}
}
}
delete_chunk_mesh(chunk: Chunk) {
for (const mesh of Object.values(chunk.meshes)) {
destroy_buffer(mesh.vertex_buffer);
if (mesh.index_buffer) {
destroy_buffer(mesh.index_buffer);
}
}
chunk.meshes = {};
chunk.translucent_sort = undefined;
}
// the first block along the view of something at x/y/z looking at yaw/pitch, like minecraft's pick
pick(
x: number,
y: number,
z: number,
yaw: number,
pitch: number,
max_distance = 6,
step = 0.05,
): BlockHitResult | undefined {
const cos_pitch = Math.cos(pitch);
const dx = -Math.sin(yaw) * cos_pitch;
const dy = Math.sin(pitch);
const dz = -Math.cos(yaw) * cos_pitch;
let prev_bx = Math.floor(x);
let prev_by = Math.floor(y);
let prev_bz = Math.floor(z);
let dist = 0;
while (dist <= max_distance) {
x += dx * step;
y += dy * step;
z += dz * step;
dist += step;
const bx = Math.floor(x);
const by = Math.floor(y);
const bz = Math.floor(z);
if (bx === prev_bx && by === prev_by && bz === prev_bz) {
continue;
}
const block = this.get_block(bx, by, bz);
if (block && block !== AIR && block !== VOID) {
let face: Faces;
if (bx > prev_bx) {
face = "west";
} else if (bx < prev_bx) {
face = "east";
} else if (by > prev_by) {
face = "bottom";
} else if (by < prev_by) {
face = "top";
} else if (bz > prev_bz) {
face = "north";
} else {
face = "south";
}
return { x: bx, y: by, z: bz, face, block };
}
prev_bx = bx;
prev_by = by;
prev_bz = bz;
}
return undefined;
}
}
// 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;
}
+123 -24
View File
@@ -1,23 +1,36 @@
import { AssetManager } from "./assets.ts";
import { ClientWorld } from "./client_world.ts";
import { Client } from "./client.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 } from "./network.ts";
import { connect, HandshakeError, is_trusted, join, load_atlas, remember_trust, ServerAddress } from "./handshake.ts";
import { confirm_mods } from "./confirm_mods.ts";
import { ModLoadError } from "$/common/mod_loader.ts";
import {
begin_drawing,
canvas,
clear_background,
end_drawing,
init_font,
init_window,
load_texture,
resize_canvas,
} from "./renderer/mod.ts";
import { is_stopped, show_fatal_error } from "./fatal.ts";
import { load_client_mods, set_mods_client } from "./mods.ts";
import type { GuiScreen } from "./gui/gui_screen.ts";
import { TitleScreen } from "./gui/title_screen.ts";
import { DisconnectedScreen } from "./gui/disconnected_screen.ts";
// runs whatever is showing every frame: a menu screen before joining (and after leaving), or the game
export class ClientLoop {
running = false;
last_time = 0;
world: ClientWorld;
client: Client | undefined;
screen: GuiScreen | undefined;
frame_count = 0;
last_fps_time = 0;
constructor(world: ClientWorld) {
this.world = world;
}
start() {
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
@@ -37,8 +50,25 @@ export class ClientLoop {
this.running = false;
}
show_screen(screen: GuiScreen) {
this.screen?.on_close();
this.client = undefined;
this.screen = screen;
InputManager.set_mouse_grabbed(false);
}
play(connection: Connection) {
const client = new Client(connection, (message) => this.show_screen(new DisconnectedScreen(message)));
set_mods_client(client);
client.chat.add("Connected to the server");
this.screen?.on_close();
this.screen = undefined;
this.client = client;
console.log("Game started");
}
loop(time: number) {
if (!this.running) {
if (!this.running || is_stopped()) {
return;
}
@@ -46,7 +76,12 @@ export class ClientLoop {
begin_drawing();
clear_background(0.69, 0.8, 1, 1.0);
this.world.update(delta);
if (this.client) {
this.client.run_frame(delta);
} else if (this.screen) {
this.screen.on_tick(delta);
this.screen.on_render();
}
end_drawing();
this.frame_count += 1;
@@ -65,20 +100,73 @@ export class ClientLoop {
}
}
const canvas = document.getElementById("game") as HTMLCanvasElement;
if (!canvas) {
// a server's blocks, items and scripts can't be taken back out once loaded, so a failure after that point
// can't go back to the title screen to try again, the page has to start over
class FailedAfterLoadingMods extends Error {}
// 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(address: ServerAddress, name: string, status: (text: string) => void): Promise<Connection> {
const { socket, welcome } = await connect(address, name);
console.log(`Connected to ${address.ws_url}`);
let loaded_mods = false;
try {
if (address.cross_origin && !is_trusted(address, welcome)) {
status("Waiting for you to accept the server's mods...");
if (!await confirm_mods(address, welcome)) {
throw new HandshakeError(`You didn't join ${address.base.host}`);
}
remember_trust(address, welcome);
}
// textures, blocks and items all come from the server's mods, so this happens before anything else
status("Downloading the server's mods...");
const atlas = await load_atlas(address, welcome.atlas);
loaded_mods = true;
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"}`);
status("Joining...");
const joined = await join(socket);
return new Connection(socket, welcome, joined);
} catch (e) {
socket.close();
const message = error_message(e);
throw loaded_mods ? new FailedAfterLoadingMods(message) : new HandshakeError(message);
}
}
function error_message(e: unknown) {
if (e instanceof HandshakeError) {
return e.message;
}
if (e instanceof ModLoadError) {
return `Couldn't load the server's mods: ${e.message}`;
}
return `Something went wrong joining the server: ${e instanceof Error ? e.message : e}`;
}
const game_canvas = document.getElementById("game") as HTMLCanvasElement;
if (!game_canvas) {
throw Error("Canvas was not found");
}
init_window(canvas);
try {
await init_window(game_canvas);
} catch (e) {
show_fatal_error(e instanceof Error ? e.message : String(e));
throw e;
}
InputManager.initialize(canvas);
InputManager.initialize(game_canvas);
self.addEventListener("resize", resize_canvas);
resize_canvas();
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
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 +178,20 @@ await AssetManager.instance.load_all();
init_font();
const client_world = new ClientWorld();
const loop = new ClientLoop(client_world);
const loop = new ClientLoop();
loop.show_screen(
new TitleScreen(async (address, name, status) => {
let connection: Connection;
try {
connection = await join_server(address, name, status);
} catch (e) {
if (e instanceof FailedAfterLoadingMods) {
loop.show_screen(new DisconnectedScreen(e.message));
return;
}
throw e;
}
loop.play(connection);
}),
);
loop.start();
console.log("Game started");
-19
View File
@@ -1,19 +0,0 @@
import { Entity } from "$/common/ecs/mod.ts";
import { Position } from "$/common/components/position.ts";
import { ClientWorld } from "./client_world.ts";
import { UIButton } from "./components/ui_components.ts";
import { open_about } from "./about.ts";
import { start_game } from "./game.ts";
import { canvas } from "./renderer/mod.ts";
export function create_main_menu(world: ClientWorld) {
const play_button = new Entity("play");
play_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 - 80));
play_button.add(new UIButton("Play", 320, 64, () => start_game(world)));
world.add_entity(play_button);
const about_button = new Entity("aboutbutton");
about_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 + 80));
about_button.add(new UIButton("About", 320, 64, () => open_about()));
world.add_entity(about_button);
}
+106
View File
@@ -0,0 +1,106 @@
// 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 type { Client } from "./client.ts";
import { code_url, fetch_verified } from "./handshake.ts";
// each loaded mod's credits file, for the credits screen
export const mod_credits: { name: string; version: string; text: string }[] = [];
// 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 game is running, mods can't look at it during setup
let client: Client | undefined;
export function set_mods_client(game_client: Client) {
client = game_client;
}
// 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, credits] = 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"),
get(listing.credits, listing.sha256.credits, "its credits"),
]);
return { listing, data: JSON.parse(new TextDecoder().decode(data)) as ModData, client, worldgen, credits };
}));
for (const { listing, credits } of downloads) {
if (credits) {
mod_credits.push({ name: listing.name, version: listing.version, text: new TextDecoder().decode(credits) });
}
}
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_client = () => {
if (!client) throw new Error(`[${mod}] the world isn't there yet during setup`);
return client;
};
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_client().connection.name;
},
get position() {
const { x, y, z } = need_client().player;
return { x, y, z };
},
},
world: {
get_block(x, y, z) {
const nid = need_client().level.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),
};
}
+52
View File
@@ -0,0 +1,52 @@
import { BlockChange, ClientMessage, EntityInfo, PlayerInfo, ServerMessage } from "$/common/protocol.ts";
import type { ModListing } from "$/common/mod_loader.ts";
import type { Join, ServerSocket, Welcome } from "./handshake.ts";
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;
// who was already there when we joined, they become entities in the level
initial_players: PlayerInfo[];
initial_entities: EntityInfo[];
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;
this.initial_players = join.players;
this.initial_entities = join.entities;
}
// handled by the packet listener 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);
}
close() {
this.#server.close();
}
}
// what the name field starts as, from ?name=
export function default_player_name(): string {
return new URLSearchParams(location.search).get("name") ?? "";
}
+30
View File
@@ -0,0 +1,30 @@
import type { KeyCode } from "./input_manager.ts";
// the player's settings, like minecraft's Options and its key mappings
export class Options {
render_distance = 6;
key_forward: KeyCode = "KeyW";
key_back: KeyCode = "KeyS";
key_left: KeyCode = "KeyA";
key_right: KeyCode = "KeyD";
key_jump: KeyCode = "Space";
key_sprint: KeyCode = "ShiftLeft";
key_inventory: KeyCode = "KeyE";
// with ctrl it drops the whole stack
key_drop: KeyCode = "KeyQ";
key_chat: KeyCode = "KeyT";
key_debug: KeyCode = "F3";
key_fullscreen: KeyCode = "F11";
key_hotbar: KeyCode[] = [
"Digit1",
"Digit2",
"Digit3",
"Digit4",
"Digit5",
"Digit6",
"Digit7",
"Digit8",
"Digit9",
];
}
+119
View File
@@ -0,0 +1,119 @@
import type { ServerMessage } from "$/common/protocol.ts";
import { Container, ItemStack } from "$/common/inventory.ts";
import type { Client } from "./client.ts";
import { GuiContainer } from "./gui/gui_container.ts";
import { RemotePlayer } from "./entity/remote_player.ts";
import { ItemEntity } from "./entity/item_entity.ts";
// applies what the server sends, like minecraft's ClientPacketListener. messages queue up on the connection and
// get handled here once per frame, inside the game loop
export class ClientPacketListener {
client: Client;
constructor(client: Client) {
this.client = client;
}
handle_packets() {
const connection = this.client.connection;
for (const message of connection.incoming) {
this.#handle(message);
}
connection.incoming.length = 0;
}
#handle(message: ServerMessage) {
const client = this.client;
const level = client.level;
const inventories = client.player.inventories;
switch (message.type) {
case "player_join":
level.add_entity(new RemotePlayer(level, message.player));
break;
case "player_leave":
level.remove_entity(message.id);
break;
case "player_move": {
const player = level.entities.get(message.id);
if (player instanceof RemotePlayer) {
player.lerp_to(message.x, message.y, message.z, message.yaw, message.pitch);
}
break;
}
case "add_entity":
level.add_entity(new ItemEntity(level, message.entity));
break;
case "move_entity": {
const entity = level.entities.get(message.id);
if (entity instanceof ItemEntity) {
entity.lerp_to(message.x, message.y, message.z);
}
break;
}
case "set_entity_item": {
const entity = level.entities.get(message.id);
if (entity instanceof ItemEntity) {
entity.item = ItemStack.from_data(message.item);
}
break;
}
case "remove_entity":
level.remove_entity(message.id);
break;
case "take_entity": {
const entity = level.entities.get(message.id);
const taker = level.entities.get(message.player);
if (entity instanceof ItemEntity && taker) {
entity.pick_up(taker);
} else {
level.remove_entity(message.id);
}
break;
}
case "set_block":
level.record_change(message.x, message.y, message.z, message.id, message.state);
level.apply_change(message.x, message.y, message.z, message.id, message.state);
break;
case "chat":
client.chat.add(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
client.screens.length = 0;
const size = Math.max(0, ...message.layout.slots.map((slot) => slot.index + 1));
inventories.screen = new Container(size);
client.push_screen(
new GuiContainer(inventories, (m) => client.connection.send(m), message.layout, message.properties),
);
break;
}
case "screen_properties": {
const screen = client.screen;
if (screen instanceof GuiContainer) {
screen.properties = message.properties;
}
break;
}
case "teleport":
client.player.set_position(message.x, message.y, message.z);
break;
case "close_screen":
// closed by the server (the block broke), it already put everything back
client.screens = client.screens.filter((s) => !(s instanceof GuiContainer));
inventories.screen = undefined;
break;
}
}
}
-47
View File
@@ -1,47 +0,0 @@
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 { 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("");
screens: GuiScreen[] = [];
render_distance = 6;
breaking_block?: { x: number; y: number; z: number };
break_progress = 0;
break_progress_max = 0;
pop_screen() {
const screen = this.screens.pop();
if (screen) {
screen.on_close();
}
}
}
export function create_player(world: ClientWorld) {
const player = new Entity("player");
player.add(new Position(0, 100, 0));
player.add(new Velocity(0, 0, 0));
player.add(new PlayerControls());
player.add(new PlayerComponent());
player.add(new Camera());
player.add(new CollisionCuboid(0.55, 1.79, 0.55));
world.add_entity(player);
const player_hand = new Entity("playerhand");
player_hand.add(new Position(0, 0));
world.add_entity(player_hand);
world.add_tag("player", [player, player_hand]);
return player;
}
+609 -161
View File
@@ -1,28 +1,53 @@
import { Camera } from "../components/camera.ts";
import type { Camera } from "../camera.ts";
import { mat4 } from "gl-matrix";
import type { RenderLayer } from "$/common/everything_registry.ts";
import { TERRAIN_VERTEX_FLOATS } from "../workers/chunk_messages.ts";
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 terrain_pipelines: Record<RenderLayer, GPURenderPipeline>;
// 0 1 2 0 2 3 for every quad, shared by all solid and cutout chunk meshes
let quad_index_buffer: GPUBuffer | undefined;
let quad_index_capacity = 0;
let uniform_layout: GPUBindGroupLayout;
let texture_layout: GPUBindGroupLayout;
let sampler: GPUSampler;
// minecraft's lightmap: the color for every block light (x) and sky light (y) pair, see update_lightmap
let lightmap: GPUTexture;
let lightmap_bind_group: GPUBindGroup;
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 +58,239 @@ 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>,
// centroid: with msaa, pixels on a triangle's edge would otherwise sample outside it, past the
// sprite's edge in the atlas, which shows up as dark lines between blocks from far away
@location(0) @interpolate(perspective, centroid) tex_coord: vec2<f32>,
@location(1) @interpolate(perspective, centroid) 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;
}
// blended draws. fully clear texels don't write depth, or they would hide what's drawn behind them later
@fragment
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
let color = textureSample(texture0, sampler0, in.tex_coord) * in.color;
if (color.a < 0.01) {
discard;
}
return 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;
const terrain_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;
@group(2) @binding(0) var lightmap: texture_2d<f32>;
@group(2) @binding(1) var lightmap_sampler: sampler;
struct VertexOut {
@builtin(position) position: vec4<f32>,
@location(0) @interpolate(perspective, centroid) tex_coord: vec2<f32>,
// directional shade times ambient occlusion, and alpha
@location(1) @interpolate(perspective, centroid) color: vec4<f32>,
// block light, sky light, as lightmap coordinates
@location(2) @interpolate(perspective, centroid) light: vec2<f32>,
}
@vertex
fn vs_terrain(
@location(0) position: vec3<f32>,
@location(1) tex_coord: vec2<f32>,
@location(2) color: vec4<f32>,
@location(3) light: vec2<f32>,
) -> VertexOut {
var out: VertexOut;
out.position = uniforms.mvp * vec4<f32>(position, 1.0);
out.tex_coord = tex_coord;
out.color = color;
out.light = light;
return out;
}
fn lit(in: VertexOut) -> vec4<f32> {
let texel = textureSample(texture0, sampler0, in.tex_coord);
let light = textureSample(lightmap, lightmap_sampler, in.light).rgb;
return vec4<f32>(texel.rgb * in.color.rgb * light, texel.a * in.color.a);
}
// no discard, so the gpu can reject hidden fragments before running the shader
@fragment
fn fs_solid(in: VertexOut) -> @location(0) vec4<f32> {
return vec4<f32>(lit(in).rgb, 1.0);
}
// alpha tested instead of blended, so it doesn't need sorting
@fragment
fn fs_cutout(in: VertexOut) -> @location(0) vec4<f32> {
let color = lit(in);
if (color.a < 0.1) {
discard;
}
return vec4<f32>(color.rgb, 1.0);
}
@fragment
fn fs_translucent(in: VertexOut) -> @location(0) vec4<f32> {
let color = lit(in);
if (color.a < 0.01) {
discard;
}
return color;
}
`;
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();
create_lightmap();
}
// how bright the sky is, 1 at noon. only the lightmap changes, the chunk meshes stay the same
export function update_lightmap(daylight = 1) {
// minecraft's LightTexture, with its default brightness setting and no flicker
const gamma = 0.5;
const brightness = (level: number) => {
const f = level / 15;
return f / (4 - 3 * f);
};
const lerp = (from: number, to: number, t: number) => from + (to - from) * t;
const clamp = (value: number) => Math.min(1, Math.max(0, value));
const not_gamma = (value: number) => 1 - (1 - value) ** 4;
const sky_factor = daylight * 0.95 + 0.05;
// sky light turns blue as it gets dark
const sky_color = [lerp(daylight, 1, 0.35), lerp(daylight, 1, 0.35), 1];
const block_boost = 1.5;
const pixels = new Uint8Array(16 * 16 * 4);
for (let sky = 0; sky < 16; sky++) {
for (let block = 0; block < 16; block++) {
const s = brightness(sky) * sky_factor;
// block light is warm, it loses blue and green faster as it dims
const b = brightness(block) * block_boost;
const color = [
b + sky_color[0] * s,
b * ((b * 0.6 + 0.4) * 0.6 + 0.4) + sky_color[1] * s,
b * (b * b * 0.6 + 0.4) + sky_color[2] * s,
].map((c) => clamp(lerp(c, 0.75, 0.04)))
.map((c) => clamp(lerp(lerp(c, not_gamma(c), gamma), 0.75, 0.04)));
const i = (sky * 16 + block) * 4;
pixels[i] = Math.round(color[0] * 255);
pixels[i + 1] = Math.round(color[1] * 255);
pixels[i + 2] = Math.round(color[2] * 255);
pixels[i + 3] = 255;
}
}
device.queue.writeTexture({ texture: lightmap }, pixels, { bytesPerRow: 16 * 4 }, [16, 16]);
}
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 +304,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 +329,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 +341,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 +360,76 @@ 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);
if (mode3d) {
gl.uniformMatrix4fv(mvp_loc, false, mvp);
} else {
gl.uniformMatrix4fv(mvp_loc, false, ortho);
// draws a chunk mesh made of quads (4 vertices each). without an index buffer the quads are drawn in order
export function draw_terrain(
layer: RenderLayer,
vertex_buffer: GPUBuffer,
quad_count: number,
index_buffer?: GPUBuffer,
) {
if (!current_texture || quad_count === 0) {
return;
}
if (!index_buffer) {
ensure_quad_indices(quad_count);
index_buffer = quad_index_buffer!;
}
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);
const render_pass = ensure_pass();
const pipeline = terrain_pipelines[layer];
if (current_pipeline !== pipeline) {
render_pass.setPipeline(pipeline);
current_pipeline = pipeline;
}
gl.uniform4f(col_diffuse_loc, 1, 1, 1, 1);
render_pass.setBindGroup(0, uniform_bind_group, [uniform_slot * UNIFORM_SLOT_SIZE]);
render_pass.setBindGroup(1, get_texture_bind_group(current_texture));
render_pass.setBindGroup(2, lightmap_bind_group);
render_pass.setVertexBuffer(0, vertex_buffer);
render_pass.setIndexBuffer(index_buffer, "uint32");
render_pass.drawIndexed(quad_count * 6);
}
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, current_texture);
gl.uniform1i(texture_loc, 0);
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;
}
gl.drawArrays(gl.TRIANGLES, 0, draw_count);
export function create_index_buffer(indices: Uint32Array): GPUBuffer {
const buffer = device.createBuffer({
size: Math.max(4, indices.byteLength),
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(buffer, 0, indices);
return buffer;
}
export function destroy_buffer(buffer: GPUBuffer) {
// it might be used by a draw thats not submitted yet
if (encoder) {
pending_destroy.push(buffer);
} else {
buffer.destroy();
}
}
export function resize_canvas() {
@@ -242,21 +441,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 +534,137 @@ export function push_quad_vertices(
// internal
function ensure_quad_indices(quad_count: number) {
if (quad_count <= quad_index_capacity) {
return;
}
if (quad_index_buffer) {
destroy_buffer(quad_index_buffer);
}
quad_index_capacity = Math.max(quad_count, quad_index_capacity * 2, 16384);
const indices = new Uint32Array(quad_index_capacity * 6);
for (let q = 0; q < quad_index_capacity; q++) {
const v = q * 4;
const i = q * 6;
indices[i] = v;
indices[i + 1] = v + 1;
indices[i + 2] = v + 2;
indices[i + 3] = v;
indices[i + 4] = v + 2;
indices[i + 5] = v + 3;
}
quad_index_buffer = create_index_buffer(indices);
}
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 +683,166 @@ 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" },
});
const primitive_3d: GPUPrimitiveState = { topology: "triangle-list", cullMode: "back", frontFace: "ccw" };
const depth_3d: GPUDepthStencilState = {
format: DEPTH_FORMAT,
depthWriteEnabled: true,
depthCompare: "less-equal",
// same as the old polygonOffset(1, 1)
depthBias: 1,
depthBiasSlopeScale: 1,
};
pipeline_3d = device.createRenderPipeline({
layout,
vertex,
fragment,
multisample,
primitive: primitive_3d,
depthStencil: depth_3d,
});
const terrain_module = device.createShaderModule({ code: terrain_shader_src });
const terrain_layout = device.createPipelineLayout({
bindGroupLayouts: [uniform_layout, texture_layout, texture_layout],
});
const terrain_vertex: GPUVertexState = {
module: terrain_module,
entryPoint: "vs_terrain",
buffers: [{
arrayStride: TERRAIN_VERTEX_FLOATS * 4,
attributes: [
{ shaderLocation: 0, offset: 0, format: "float32x3" },
{ shaderLocation: 1, offset: 12, format: "float32x2" },
{ shaderLocation: 2, offset: 20, format: "float32x4" },
{ shaderLocation: 3, offset: 36, format: "float32x2" },
],
}],
};
// like sodium and vanilla: solid and cutout don't blend, translucent blends and still writes depth
// since it's drawn sorted back to front
const terrain_pipeline = (entryPoint: string, blend?: GPUBlendState) =>
device.createRenderPipeline({
layout: terrain_layout,
vertex: terrain_vertex,
fragment: { module: terrain_module, entryPoint, targets: [{ format: canvas_format, blend }] },
multisample,
primitive: primitive_3d,
depthStencil: depth_3d,
});
terrain_pipelines = {
solid: terrain_pipeline("fs_solid"),
cutout: terrain_pipeline("fs_cutout"),
translucent: terrain_pipeline("fs_translucent", {
color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
alpha: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
}),
};
}
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 bind_group;
}
return program;
function create_lightmap() {
lightmap = create_texture(16, 16);
// linear, so light fades smoothly between levels across a face like in minecraft
const lightmap_sampler = device.createSampler({
magFilter: "linear",
minFilter: "linear",
addressModeU: "clamp-to-edge",
addressModeV: "clamp-to-edge",
});
lightmap_bind_group = device.createBindGroup({
layout: texture_layout,
entries: [
{ binding: 0, resource: lightmap.createView() },
{ binding: 1, resource: lightmap_sampler },
],
});
update_lightmap();
}
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;
}
+66
View File
@@ -191,3 +191,69 @@ export function push_bottom_face(
push_vertex(x2, y, z2, u1, v0, r, g, b, a);
push_vertex(x, y, z2, u0, v0, r, g, b, a);
}
// solid colored box, draw it with white_tex as the current texture
export function push_box(
x: number,
y: number,
z: number,
width: number,
height: number,
depth: number,
r = 1,
g = 1,
b = 1,
a = 1,
) {
const x2 = x + width;
const y2 = y + height;
const z2 = z + depth;
// front
push_vertex(x, y, z2, 0, 1, r, g, b, a);
push_vertex(x2, y, z2, 1, 1, r, g, b, a);
push_vertex(x2, y2, z2, 1, 0, r, g, b, a);
push_vertex(x, y, z2, 0, 1, r, g, b, a);
push_vertex(x2, y2, z2, 1, 0, r, g, b, a);
push_vertex(x, y2, z2, 0, 0, r, g, b, a);
// back
push_vertex(x2, y, z, 0, 1, r * 0.8, g * 0.8, b * 0.8, a);
push_vertex(x, y, z, 1, 1, r * 0.8, g * 0.8, b * 0.8, a);
push_vertex(x, y2, z, 1, 0, r * 0.8, g * 0.8, b * 0.8, a);
push_vertex(x2, y, z, 0, 1, r * 0.8, g * 0.8, b * 0.8, a);
push_vertex(x, y2, z, 1, 0, r * 0.8, g * 0.8, b * 0.8, a);
push_vertex(x2, y2, z, 0, 0, r * 0.8, g * 0.8, b * 0.8, a);
// left
push_vertex(x, y, z, 0, 1, r * 0.9, g * 0.9, b * 0.9, a);
push_vertex(x, y, z2, 1, 1, r * 0.9, g * 0.9, b * 0.9, a);
push_vertex(x, y2, z2, 1, 0, r * 0.9, g * 0.9, b * 0.9, a);
push_vertex(x, y, z, 0, 1, r * 0.9, g * 0.9, b * 0.9, a);
push_vertex(x, y2, z2, 1, 0, r * 0.9, g * 0.9, b * 0.9, a);
push_vertex(x, y2, z, 0, 0, r * 0.9, g * 0.9, b * 0.9, a);
// right
push_vertex(x2, y, z2, 0, 1, r * 0.9, g * 0.9, b * 0.9, a);
push_vertex(x2, y, z, 1, 1, r * 0.9, g * 0.9, b * 0.9, a);
push_vertex(x2, y2, z, 1, 0, r * 0.9, g * 0.9, b * 0.9, a);
push_vertex(x2, y, z2, 0, 1, r * 0.9, g * 0.9, b * 0.9, a);
push_vertex(x2, y2, z, 1, 0, r * 0.9, g * 0.9, b * 0.9, a);
push_vertex(x2, y2, z2, 0, 0, r * 0.9, g * 0.9, b * 0.9, a);
// top
push_vertex(x, y2, z2, 0, 1, r, g, b, a);
push_vertex(x2, y2, z2, 1, 1, r, g, b, a);
push_vertex(x2, y2, z, 1, 0, r, g, b, a);
push_vertex(x, y2, z2, 0, 1, r, g, b, a);
push_vertex(x2, y2, z, 1, 0, r, g, b, a);
push_vertex(x, y2, z, 0, 0, r, g, b, a);
// bottom
push_vertex(x, y, z, 0, 1, r * 0.6, g * 0.6, b * 0.6, a);
push_vertex(x2, y, z, 1, 1, r * 0.6, g * 0.6, b * 0.6, a);
push_vertex(x2, y, z2, 1, 0, r * 0.6, g * 0.6, b * 0.6, a);
push_vertex(x, y, z, 0, 1, r * 0.6, g * 0.6, b * 0.6, a);
push_vertex(x2, y, z2, 1, 0, r * 0.6, g * 0.6, b * 0.6, a);
push_vertex(x, y, z2, 0, 0, r * 0.6, g * 0.6, b * 0.6, a);
}
+12 -23
View File
@@ -1,28 +1,17 @@
import { flush_batch, get_current_texture, gl, push_quad, push_quad_vertices, set_current_texture } from "./core.ts";
import {
create_texture,
device,
flush_batch,
get_current_texture,
push_quad,
push_quad_vertices,
set_current_texture,
} from "./core.ts";
import { Texture } from "./types.ts";
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 -1
View File
@@ -1,5 +1,5 @@
export interface Texture {
tex: WebGLTexture;
tex: GPUTexture;
width: number;
height: number;
}
+31
View File
@@ -0,0 +1,31 @@
import type { Client } from "$/client/client.ts";
import { begin_mode_3d, end_mode_3d } from "$/client/renderer/mod.ts";
import { Hud } from "$/client/gui/hud.ts";
import { DebugOverlay } from "$/client/gui/debug_overlay.ts";
import { LevelRenderer } from "./level_renderer.ts";
// draws a frame, like minecraft's GameRenderer: the level from the camera, then the hud and screens on top
export class GameRenderer {
level_renderer = new LevelRenderer();
hud = new Hud();
debug_overlay = new DebugOverlay();
// partial_tick is how far this frame is between the last tick and the next, entities are drawn in between
render(client: Client, partial_tick: number) {
const camera = client.camera;
camera.setup(client.player, partial_tick);
begin_mode_3d(camera);
this.level_renderer.render_opaque(client.level, camera);
this.level_renderer.render_destroy_progress(client.game_mode);
this.level_renderer.render_entities(client.level, client.player, partial_tick);
this.level_renderer.render_translucent(client.level, camera);
end_mode_3d();
this.hud.render(client);
client.screen?.on_render();
if (client.debugging) {
this.debug_overlay.render(client);
}
}
}
+141
View File
@@ -0,0 +1,141 @@
import { TEXTURE_SIZE } from "$/common/constants.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { AssetManager } from "$/client/assets.ts";
import { get_sprite_region } from "$/client/sprites.ts";
import type { ItemEntity } from "$/client/entity/item_entity.ts";
import { push_vertex, Texture } from "$/client/renderer/mod.ts";
// how big items on the ground are drawn, minecraft's ground transform
const BLOCK_SCALE = 0.25;
const ITEM_SCALE = 0.5;
// keeps texture lookups off the sprite's edge
const UV_PAD = 0.5;
// each face's corners in drawing order (counter clockwise from outside) on a unit cube, with its shade.
// top, bottom, front, back, left, right, like the chunk mesher
const CUBE_FACES = [
{ corners: [[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]], shade: 1.0, texture: "top" },
{ corners: [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]], shade: 0.5, texture: "bottom" },
{ corners: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]], shade: 0.8, texture: "front" },
{ corners: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]], shade: 0.8, texture: "side" },
{ corners: [[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]], shade: 0.6, texture: "side" },
{ corners: [[1, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]], shade: 0.6, texture: "side" },
] as const;
// sprite end each corner gets, u then v
const CORNER_UVS = [[0, 1], [1, 1], [1, 0], [0, 0]] as const;
// where the extra copies of a bigger stack sit, as fractions of the model's size
const COPY_OFFSETS = [[0, 0, 0], [0.35, 0.2, -0.25], [-0.3, 0.4, 0.2], [0.15, 0.6, 0.35], [-0.2, 0.8, -0.3]];
// minecraft draws more copies of the model the bigger the stack is
function copies(amount: number) {
if (amount > 48) return 5;
if (amount > 32) return 4;
if (amount > 16) return 3;
if (amount > 1) return 2;
return 1;
}
// the atlas has to be the current texture
export function render_item_entity(entity: ItemEntity, partial_tick: number) {
const atlas = AssetManager.instance.get<Texture>("bworld:textures");
const item_info = EverythingRegistry.get<ItemRegistry>("items", entity.item.type_id);
const block_info = item_info?.block_id
? EverythingRegistry.get<BlockRegistry>("blocks", item_info.block_id)
: undefined;
// spinning and bobbing, like minecraft's ItemEntityRenderer
const time = entity.age + partial_tick;
const angle = time / 20 + entity.bob_offset;
const bob = Math.sin(time / 10 + entity.bob_offset) * 0.1 + 0.1;
const { x, y, z } = entity.render_position(partial_tick);
const scale = block_info ? BLOCK_SCALE : ITEM_SCALE;
for (let i = 0; i < copies(entity.item.amount); i++) {
const [ox, oy, oz] = COPY_OFFSETS[i];
const base = { x: x + ox * scale, y: y + bob + oy * scale * 0.5, z: z + oz * scale };
if (block_info) {
push_block(atlas, block_info, base, scale, angle);
} else {
push_sprite(atlas, item_texture(entity, item_info), base, scale, angle);
}
}
}
// a small cube turned around its middle
function push_block(
atlas: Texture,
block: BlockRegistry,
base: { x: number; y: number; z: number },
size: number,
angle: number,
) {
const sin = Math.sin(angle);
const cos = Math.cos(angle);
const corner = (cx: number, cy: number, cz: number) => {
const lx = (cx - 0.5) * size;
const lz = (cz - 0.5) * size;
return [base.x + lx * cos - lz * sin, base.y + cy * size, base.z + lx * sin + lz * cos];
};
for (const face of CUBE_FACES) {
const region = get_sprite_region(block_face_texture(block, face.texture));
push_textured_quad(atlas, region, face.corners.map(([cx, cy, cz]) => corner(cx, cy, cz)), face.shade);
}
}
// a flat sprite standing up and turning, drawn from both sides
function push_sprite(
atlas: Texture,
texture_id: string,
base: { x: number; y: number; z: number },
size: number,
angle: number,
) {
const region = get_sprite_region(texture_id);
const dx = Math.cos(angle) * size / 2;
const dz = Math.sin(angle) * size / 2;
const bottom = base.y;
const top = base.y + size;
const front = [
[base.x - dx, bottom, base.z - dz],
[base.x + dx, bottom, base.z + dz],
[base.x + dx, top, base.z + dz],
[base.x - dx, top, base.z - dz],
];
push_textured_quad(atlas, region, front, 1);
// the back is the same quad the other way round, mirrored so it isn't drawn backwards
push_textured_quad(atlas, region, [front[1], front[0], front[3], front[2]], 1);
}
function push_textured_quad(atlas: Texture, region: { x: number; y: number }, corners: number[][], shade: number) {
const u0 = (region.x * TEXTURE_SIZE + UV_PAD) / atlas.width;
const v0 = (region.y * TEXTURE_SIZE + UV_PAD) / atlas.height;
const u1 = ((region.x + 1) * TEXTURE_SIZE - UV_PAD) / atlas.width;
const v1 = ((region.y + 1) * TEXTURE_SIZE - UV_PAD) / atlas.height;
for (const i of [0, 1, 2, 0, 2, 3]) {
const [px, py, pz] = corners[i];
const [cu, cv] = CORNER_UVS[i];
push_vertex(px, py, pz, cu ? u1 : u0, cv ? v1 : v0, shade, shade, shade, 1);
}
}
function block_face_texture(block: BlockRegistry, face: "top" | "bottom" | "front" | "side") {
const textures = block.textures;
if (typeof textures === "string") {
return textures;
}
if ("top" in textures) {
return face === "top" ? textures.top : face === "bottom" ? textures.bottom : textures.side;
}
return face === "front" ? textures.front : textures.side;
}
function item_texture(entity: ItemEntity, item_info: ItemRegistry | undefined) {
const texture_id = item_info?.texture_id;
if (typeof texture_id === "function") {
return texture_id(entity.item);
}
return texture_id ?? "engine:missing";
}
+139
View File
@@ -0,0 +1,139 @@
import { TEXTURE_SIZE } from "$/common/constants.ts";
import { CHUNK_SIZE, ClientLevel } from "$/client/level/client_level.ts";
import { Camera } from "$/client/camera.ts";
import { AssetManager } from "$/client/assets.ts";
import { get_sprite_region } from "$/client/sprites.ts";
import type { MultiPlayerGameMode } from "$/client/game_mode.ts";
import type { Entity } from "$/client/entity/entity.ts";
import { RemotePlayer } from "$/client/entity/remote_player.ts";
import { ItemEntity } from "$/client/entity/item_entity.ts";
import { render_item_entity } from "./item_renderer.ts";
import {
draw_terrain,
flush_batch,
push_back_face,
push_bottom_face,
push_box,
push_front_face,
push_left_face,
push_right_face,
push_top_face,
set_current_texture,
Texture,
white_tex,
} from "$/client/renderer/mod.ts";
const BREAKING_FACES = [
push_back_face,
push_bottom_face,
push_front_face,
push_left_face,
push_right_face,
push_top_face,
];
// draws the level, like minecraft's LevelRenderer: terrain in layers, block breaking and entities
export class LevelRenderer {
// solid and cutout terrain, drawn before entities
render_opaque(level: ClientLevel, camera: Camera) {
level.request_meshes(camera);
level.update_translucent_sorting(camera);
set_current_texture(level.image.tex);
for (const chunk of level.chunks.values()) {
const mesh = chunk.meshes.solid;
if (mesh) {
draw_terrain("solid", mesh.vertex_buffer, mesh.quad_count);
}
}
for (const chunk of level.chunks.values()) {
const mesh = chunk.meshes.cutout;
if (mesh) {
draw_terrain("cutout", mesh.vertex_buffer, mesh.quad_count);
}
}
}
// translucent terrain, drawn after entities so they show through water and glass.
// chunks go back to front, and each chunk's quads are already sorted back to front
render_translucent(level: ClientLevel, camera: Camera) {
const distance_sq = (x: number, z: number) => {
const dx = (x + 0.5) * CHUNK_SIZE - camera.x;
const dz = (z + 0.5) * CHUNK_SIZE - camera.z;
return dx * dx + dz * dz;
};
const chunks = [...level.chunks.values()]
.filter((chunk) => chunk.meshes.translucent)
.map((chunk) => ({ mesh: chunk.meshes.translucent!, distance: distance_sq(chunk.x, chunk.z) }))
.sort((a, b) => b.distance - a.distance);
set_current_texture(level.image.tex);
for (const { mesh } of chunks) {
draw_terrain("translucent", mesh.vertex_buffer, mesh.quad_count, mesh.index_buffer);
}
}
// every entity but the one the camera is in
render_entities(level: ClientLevel, camera_entity: Entity, partial_tick: number) {
flush_batch();
set_current_texture(white_tex!);
for (const entity of level.entities.values()) {
if (entity !== camera_entity && entity instanceof RemotePlayer) {
render_player(entity, partial_tick);
}
}
flush_batch();
set_current_texture(level.image.tex);
for (const entity of level.entities.values()) {
if (entity instanceof ItemEntity) {
render_item_entity(entity, partial_tick);
}
}
flush_batch();
}
// the cracks on the block being broken
render_destroy_progress(game_mode: MultiPlayerGameMode) {
const block = game_mode.destroy_pos;
if (!block) {
return;
}
const progress = Math.max(0, Math.min(1, game_mode.destroy_progress / game_mode.destroy_time));
const stage = Math.round(progress * 8);
if (Number.isNaN(stage)) {
return;
}
const tex = AssetManager.instance.get<Texture>("bworld:textures");
const region = get_sprite_region(`engine:break_${stage}`);
for (const push_face of BREAKING_FACES) {
push_face(
tex,
block.x,
block.y,
block.z,
region.x * TEXTURE_SIZE,
region.y * TEXTURE_SIZE,
TEXTURE_SIZE,
TEXTURE_SIZE,
);
}
}
}
// a box body and head in the player's color
function render_player(player: RemotePlayer, partial_tick: number) {
const [r, g, b] = player.color;
const { x, y, z } = player.render_position(partial_tick);
// 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);
}
@@ -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;
+9
View File
@@ -0,0 +1,9 @@
import { SpriteRegion } from "$/common/constants.ts";
import { AssetManager } from "./assets.ts";
type TexturesInfo = Record<string, SpriteRegion>;
export function get_sprite_region(id: string): SpriteRegion {
const textures_info = AssetManager.instance.get<TexturesInfo>("bworld:textures_info");
return textures_info?.[id] ?? { x: 0, y: 0 };
}
-101
View File
@@ -1,101 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { Position } from "$/common/components/position.ts";
import { ClientWorld } from "../client_world.ts";
import { CollisionCuboid } from "$/client/components/collision.ts";
import { Velocity } from "$/common/components/velocity.ts";
import { Dimension } from "../components/dimension.ts";
export class CollisionSystem extends System {
override update(world: ClientWorld, delta: number): void {
for (const entity of world.get_entities()) {
const position = entity.get(Position);
const velocity = entity.get(Velocity);
const cuboid = entity.get(CollisionCuboid);
if (!position || !velocity || !cuboid) {
continue;
}
velocity.vy += cuboid.gravity * delta;
let new_position = position.clone();
new_position.x += velocity.vx * delta;
let collisions = this.check_collision(new_position, velocity, cuboid, world.dimension);
cuboid.colliding_x = collisions.x;
if (collisions.x !== 0) {
velocity.vx = 0;
}
new_position = position.clone();
new_position.y += velocity.vy * delta;
collisions = this.check_collision(new_position, velocity, cuboid, world.dimension);
cuboid.colliding_y = collisions.y;
if (collisions.y !== 0) {
velocity.vy = 0;
}
new_position = position.clone();
new_position.z += velocity.vz * delta;
collisions = this.check_collision(new_position, velocity, cuboid, world.dimension);
cuboid.colliding_z = collisions.z;
if (collisions.z !== 0) {
velocity.vz = 0;
}
}
}
check_collision(
position: Position,
velocity: Velocity,
cuboid: CollisionCuboid,
dimension: Dimension,
): { x: number; y: number; z: number } {
const collisions = { x: 0, y: 0, z: 0 };
const min_x = Math.floor(position.x - cuboid.width / 2);
const max_x = Math.floor(position.x + cuboid.width / 2);
const min_y = Math.floor(position.y);
const max_y = Math.floor(position.y + cuboid.height);
const min_z = Math.floor(position.z - cuboid.depth / 2);
const max_z = Math.floor(position.z + cuboid.depth / 2);
for (let x = min_x; x <= max_x; x++) {
for (let y = min_y; y <= max_y; y++) {
for (let z = min_z; z <= max_z; z++) {
const block = dimension.get_block(x, y, z);
if (block && block !== 0) {
if (velocity.vx > 0) {
collisions.x = -1;
}
if (velocity.vx < 0) {
collisions.x = 1;
}
if (velocity.vy > 0) {
collisions.y = -1;
}
if (velocity.vy < 0) {
collisions.y = 1;
}
if (velocity.vz > 0) {
collisions.z = -1;
}
if (velocity.vz < 0) {
collisions.z = 1;
}
}
}
}
}
return collisions;
}
}
-59
View File
@@ -1,59 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { ClientWorld } from "$/client/client_world.ts";
import { DebugUI } from "$/client/debug_ui.ts";
export class DebugSystem extends System {
constructor() {
super();
}
update(world: ClientWorld, _delta: number): void {
if (!world.debugging) {
return;
}
DebugUI.begin("Entities", 10, 10, 300);
for (const entity of world.get_entities()) {
if (DebugUI.collapsing_header("Entity - " + entity.id)) {
for (const component of entity.get_all()) {
if (DebugUI.collapsing_header(`${component.constructor.name}##${entity.id}`)) {
this.render_component(component);
}
}
}
}
DebugUI.end();
}
// deno-lint-ignore no-explicit-any
render_component(component: any) {
for (const key in component) {
if (key === "__component") {
continue;
}
if (typeof component[key] === "number") {
component[key] = DebugUI.float_input(
key,
component[key],
);
} else if (typeof component[key] === "string") {
component[key] = DebugUI.text_input(
key,
component[key],
);
} else if (typeof component[key] === "boolean") {
component[key] = DebugUI.checkbox(
key,
component[key],
);
} else if (Array.isArray(component[key])) {
DebugUI.text(`${key}: ${JSON.stringify(component[key].slice(0, 10))}`);
} else {
DebugUI.text(`${key}: ${JSON.stringify(component[key])}`);
}
DebugUI.separator();
}
}
}
-44
View File
@@ -1,44 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { ClientWorld } from "../client_world.ts";
import { Dimension } from "../components/dimension.ts";
import { TICK_DELTA } from "$/common/constants.ts";
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
export class DimensionLogicSystem extends System {
update(world: ClientWorld, delta: number): void {
const dimension = world.dimension;
dimension.second_timer += delta;
dimension.tick_timer += delta;
if (dimension.second_timer >= 1) {
this.handle_second(world, dimension);
}
if (dimension.tick_timer >= TICK_DELTA) {
this.handle_tick(world, dimension);
}
}
handle_second(world: ClientWorld, dimension: Dimension) {
for (const chunk of dimension.chunks) {
for (const tickable of chunk.blocks_data) {
const tile_info = EverythingRegistry.get<BlockRegistry>("blocks", tickable.id);
if (tile_info && tile_info.on_second) {
tile_info.on_second(world.dimension, tickable, dimension.second_timer);
}
}
}
dimension.second_timer = 0;
}
handle_tick(world: ClientWorld, dimension: Dimension) {
for (const chunk of dimension.chunks) {
for (const tickable of chunk.blocks_data) {
const tile_info = EverythingRegistry.get<BlockRegistry>("blocks", tickable.id);
if (tile_info && tile_info.on_tick) {
tile_info.on_tick(world.dimension, tickable, dimension.tick_timer);
}
}
}
dimension.tick_timer = 0;
}
}

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