Compare commits

..
25 Commits
Author SHA1 Message Date
paula e22f12c0b6 Tooltips 2026-09-26 18:06:05 -03:00
paula 01a81b926e Fix padding on fnt file 2026-09-26 17:49:01 -03:00
paula 243ff52063 New modding system 2026-09-26 17:33:07 -03:00
paula 86d5c39eab Deno desktop stuff 2026-09-26 13:25:34 -03:00
paula b68fe3a19f Partly mods plan 2026-09-26 12:50:57 -03:00
paula 58afaac821 Optimize renderer 2026-09-26 11:38:32 -03:00
paula 8b549162c4 Day night cycle 2026-09-26 11:03:07 -03:00
paula 8f32a59bd6 Smithing table 2026-09-25 23:48:29 -03:00
paula 9237152aa1 Actually implement mod code into server 2026-09-25 23:13:46 -03:00
paula 099811670f Basic crops system 2026-09-25 22:49:27 -03:00
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
172 changed files with 11343 additions and 3898 deletions
+1
View File
@@ -2,3 +2,4 @@ build/
world.json world.json
world.json.tmp world.json.tmp
server_mods/ server_mods/
dist/
+685 -251
View File
File diff suppressed because it is too large Load Diff
+39 -160
View File
@@ -1,20 +1,16 @@
import { copy } from "@std/fs"; import { copy } from "@std/fs";
import { createCanvas, loadImage } from "@gfx/canvas-wasm"; import { createCanvas, loadImage } from "@gfx/canvas-wasm";
import { ENGINE_TEXTURE_DIR, load_and_check, load_order, LoadedMod } from "./tools/check_mods.ts"; import { ENGINE_TEXTURE_DIR, load_and_check, load_order, LoadedMod } from "./tools/check_mods.ts";
import type { AtlasListing, ModData, ModListing } from "./common/mod_loader.ts"; import { pack_mod } from "./tools/pack_mod.ts";
import { BMOD_EXTENSION } from "./common/bmod.ts";
import { ENGINE_TEXTURES_INDEX } from "./server/host.ts";
// all overridable so tests can build somewhere else // all overridable so tests can build somewhere else
const BUILD_FOLDER = Deno.env.get("BUILD_DIR") ?? "build"; const BUILD_FOLDER = Deno.env.get("BUILD_DIR") ?? "build";
const MODS_FOLDER = Deno.env.get("MODS_DIR") ?? "mods"; const MODS_FOLDER = Deno.env.get("MODS_DIR") ?? "mods";
// server scripts go here instead of the served build folder, players never get them // where the server loads .bmod files from. the build puts every mod in mods/ there, next to any others
const SERVER_MODS_FOLDER = Deno.env.get("SERVER_MODS_DIR") ?? "server_mods"; const SERVER_MODS_FOLDER = Deno.env.get("SERVER_MODS_DIR") ?? "server_mods";
// 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) { function clear_folder(folder: string) {
try { try {
Deno.removeSync(folder, { recursive: true }); Deno.removeSync(folder, { recursive: true });
@@ -58,90 +54,36 @@ async function build_fonts() {
await copy("assets/fonts/m6x11.fnt", `${BUILD_FOLDER}/assets/fonts/m6x11.fnt`); await copy("assets/fonts/m6x11.fnt", `${BUILD_FOLDER}/assets/fonts/m6x11.fnt`);
} }
function next_power_of_two(value: number): number { async function build_sprites() {
return Math.pow(2, Math.ceil(Math.log2(value)));
}
const SPRITE_SIZE = 16;
function calculate_atlas_size(count: number) {
const raw_sprites_per_side = Math.ceil(Math.sqrt(count));
const raw_size = raw_sprites_per_side * SPRITE_SIZE;
const size = next_power_of_two(raw_size);
return {
sprites_per_side: size / SPRITE_SIZE,
size,
};
}
// 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);
}
}
// + 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");
const atlas_info: Record<string, { x: number; y: number }> = {};
// purple and black missing texture at x:0 y:0 wow !
ctx.fillStyle = "magenta";
ctx.fillRect(0, 0, 8, 8);
ctx.fillRect(8, 8, 8, 8);
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 [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;
atlas_info[id] = { x: column, y: row };
}
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(mods: LoadedMod[]) {
for (const entry of Deno.readDirSync("assets/sprites")) { for (const entry of Deno.readDirSync("assets/sprites")) {
if (entry.name.endsWith(".png")) { if (entry.name.endsWith(".png")) {
await copy(`assets/sprites/${entry.name}`, `${BUILD_FOLDER}/assets/sprites/${entry.name}`); await copy(`assets/sprites/${entry.name}`, `${BUILD_FOLDER}/assets/sprites/${entry.name}`);
} }
} }
return await build_atlas(mods);
} }
async function build_assets(mods: LoadedMod[]): Promise<AtlasListing> { // the engine's textures (engine:<file>), with a list of them. clients put them in the texture atlas with the mods'
function build_engine_textures() {
const folder = `${BUILD_FOLDER}/assets/textures`;
Deno.mkdirSync(folder, { recursive: true });
const names: string[] = [];
for (const entry of [...Deno.readDirSync(ENGINE_TEXTURE_DIR)].sort((a, b) => a.name.localeCompare(b.name))) {
if (entry.isFile && entry.name.endsWith(".png")) {
Deno.copyFileSync(`${ENGINE_TEXTURE_DIR}/${entry.name}`, `${folder}/${entry.name}`);
names.push(entry.name.replace(/\.png$/, ""));
}
}
Deno.writeTextFileSync(`${BUILD_FOLDER}/${ENGINE_TEXTURES_INDEX}`, JSON.stringify(names));
}
async function build_assets() {
Deno.mkdirSync(`${BUILD_FOLDER}/assets`, { recursive: true }); Deno.mkdirSync(`${BUILD_FOLDER}/assets`, { recursive: true });
await copy("assets/ASSETS.md", `${BUILD_FOLDER}/assets/ASSETS.md`); await copy("assets/ASSETS.md", `${BUILD_FOLDER}/assets/ASSETS.md`);
await build_fonts(); await build_fonts();
Deno.mkdirSync(`${BUILD_FOLDER}/assets/sprites`, { recursive: true }); Deno.mkdirSync(`${BUILD_FOLDER}/assets/sprites`, { recursive: true });
return await build_sprites(mods); await build_sprites();
build_engine_textures();
} }
// every mod in mods/, checked and sorted so dependencies load first. a broken mod fails the whole build, // every mod in mods/, checked and sorted so dependencies load first. a broken mod fails the whole build,
@@ -155,85 +97,25 @@ function load_mods(): LoadedMod[] {
return load_order(mods); return load_order(mods);
} }
async function bundle_script(path: string, platform: "browser" | "deno"): Promise<string> { // every mod in mods/ into server_mods/<id>.bmod. other .bmod files there are left alone, they're other people's mods
const result = await Deno.bundle({ entrypoints: [path], platform, write: false, minify: false }); async function build_mods(mods: LoadedMod[]) {
if (!result.success || !result.outputFiles?.length) { Deno.mkdirSync(SERVER_MODS_FOLDER, { recursive: true });
throw new Error(`Couldn't bundle ${path}:\n${result.errors.map((e) => e.text).join("\n")}`); remove_old_format();
}
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) { for (const mod of mods) {
const manifest = mod.manifest as { name: string; version: string; scripts?: Record<string, string> }; Deno.writeFileSync(`${SERVER_MODS_FOLDER}/${mod.id}${BMOD_EXTENSION}`, await pack_mod(mod));
const scripts = manifest.scripts ?? {};
const data: ModData = {
blocks: mod.blocks.map((b) => b.json),
items: mod.items.map((i) => i.json),
recipes: mod.recipes.map((r) => r.json),
ores: mod.ores.map((o) => o.json),
};
const data_json = JSON.stringify(data);
const client = scripts.client ? await bundle_script(`${mod.dir}/${scripts.client}`, "browser") : undefined;
const worldgen = scripts.worldgen
? await bundle_script(`${mod.dir}/${scripts.worldgen}`, "browser")
: undefined;
const server = scripts.server ? await bundle_script(`${mod.dir}/${scripts.server}`, "deno") : undefined;
const hash = await short_hash([data_json, client ?? "", worldgen ?? ""]);
const public_dir = `mods/${mod.id}/${hash}`;
Deno.mkdirSync(`${BUILD_FOLDER}/${public_dir}`, { recursive: true });
const listing: ModListing = {
id: mod.id,
name: manifest.name,
version: manifest.version,
hash,
data: `${public_dir}/data.json`,
sha256: { data: await sha256(data_json) },
};
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.data}`, data_json);
if (client) {
listing.client = `${public_dir}/client.js`;
listing.sha256.client = await sha256(client);
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.client}`, client);
} }
if (worldgen) {
listing.worldgen = `${public_dir}/worldgen.js`;
listing.sha256.worldgen = await sha256(worldgen);
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.worldgen}`, worldgen);
}
const entry: ServerModIndex["mods"][number] = { listing };
if (server) {
const server_dir = `${SERVER_MODS_FOLDER}/${mod.id}/${await short_hash([server])}`;
Deno.mkdirSync(server_dir, { recursive: true });
entry.server = `${server_dir}/server.js`;
Deno.writeTextFileSync(entry.server, server);
}
index.mods.push(entry);
}
Deno.writeTextFileSync(`${SERVER_MODS_FOLDER}/index.json`, JSON.stringify(index, null, "\t"));
console.log(`Mods: ${mods.map((m) => m.id).join(", ") || "none"}`); console.log(`Mods: ${mods.map((m) => m.id).join(", ") || "none"}`);
} }
// builds before .bmod files put each mod's server script in a folder here with an index.json
function remove_old_format() {
for (const entry of Deno.readDirSync(SERVER_MODS_FOLDER)) {
if (entry.isDirectory || entry.name === "index.json") {
Deno.removeSync(`${SERVER_MODS_FOLDER}/${entry.name}`, { recursive: true });
}
}
}
async function build_client() { async function build_client() {
const _result = await Deno.bundle({ const _result = await Deno.bundle({
entrypoints: ["./client/main.ts", "./client/workers/chunk_worker.ts"], entrypoints: ["./client/main.ts", "./client/workers/chunk_worker.ts"],
@@ -249,16 +131,13 @@ async function build() {
try { try {
const now = performance.now(); const now = performance.now();
clear_folder(BUILD_FOLDER); clear_folder(BUILD_FOLDER);
// checked first: a mod with errors stops the build before any .bmod is written
const mods = load_mods(); const mods = load_mods();
const atlas = await build_assets(mods); await build_assets();
await build_client(); await build_client();
await build_mods(mods, atlas); await build_mods(mods);
console.log(`Built in ${(performance.now() - now).toFixed(2)}ms`); console.log(`Built in ${(performance.now() - now).toFixed(2)}ms`);
} catch (e) { } catch (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); console.log(e instanceof Error ? e.message : e);
return false; return false;
} }
-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>`;
}
+64
View File
@@ -0,0 +1,64 @@
// the texture atlas: the engine's textures and every mod's in one image, built by the client when it joins a server
// from the textures in the server's .bmod files
import { TEXTURE_SIZE } from "$/common/constants.ts";
import type { SpriteRegion } from "$/common/constants.ts";
export interface AtlasLayout {
// in pixels, a power of two
size: number;
// in sprites, by texture id. engine:missing is always at 0, 0
regions: Record<string, SpriteRegion>;
}
// engine:missing first, then every other texture sorted by id, row by row
export function atlas_layout(ids: Iterable<string>): AtlasLayout {
const sorted = [...new Set(ids)].filter((id) => id !== "engine:missing").sort((a, b) => a.localeCompare(b));
const count = sorted.length + 1;
const size = 2 ** Math.ceil(Math.log2(Math.ceil(Math.sqrt(count)) * TEXTURE_SIZE));
const per_row = size / TEXTURE_SIZE;
const regions: Record<string, SpriteRegion> = { "engine:missing": { x: 0, y: 0 } };
sorted.forEach((id, i) => {
regions[id] = { x: (i + 1) % per_row, y: Math.floor((i + 1) / per_row) };
});
return { size, regions };
}
// textures are png bytes by id. ones that don't decode show as missing
export async function build_atlas(textures: Map<string, Uint8Array>) {
const { size, regions } = atlas_layout(textures.keys());
const canvas = new OffscreenCanvas(size, size);
const ctx = canvas.getContext("2d")!;
// magenta and black checker
ctx.fillStyle = "magenta";
ctx.fillRect(0, 0, 8, 8);
ctx.fillRect(8, 8, 8, 8);
ctx.fillStyle = "black";
ctx.fillRect(8, 0, 8, 8);
ctx.fillRect(0, 8, 8, 8);
await Promise.all([...textures].map(async ([id, png]) => {
const region = regions[id];
try {
const image = await createImageBitmap(new Blob([png as Uint8Array<ArrayBuffer>], { type: "image/png" }));
ctx.drawImage(image, region.x * TEXTURE_SIZE, region.y * TEXTURE_SIZE);
image.close();
} catch {
console.warn(`Texture ${id} isn't a png that can be shown, it will show as missing`);
regions[id] = regions["engine:missing"];
}
}));
return { image: canvas.transferToImageBitmap(), regions };
}
// the engine's own textures, which come with the client instead of a mod. see build_engine_textures in build.ts
export async function engine_textures(): Promise<Map<string, Uint8Array>> {
const names: string[] = await (await fetch("/assets/textures/index.json")).json();
const textures = new Map<string, Uint8Array>();
await Promise.all(names.map(async (name) => {
const response = await fetch(`/assets/textures/${name}.png`);
textures.set(`engine:${name}`, new Uint8Array(await response.arrayBuffer()));
}));
return textures;
}
+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;
}
}
+270
View File
@@ -0,0 +1,270 @@
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);
this.level.time = connection.time;
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;
}
const screen = this.screen;
this.screen?.on_tick(delta);
// a screen that closed itself just now, like chat on enter, had this frame's keys
this.#handle_keybinds(screen !== undefined && this.screen === undefined);
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();
}
// closed_screen: a screen closed this frame and took its keys, so they don't also open the inventory or pick a
// hotbar slot. typing "/give pickaxe" and enter in the same slow frame would otherwise open the inventory
#handle_keybinds(closed_screen: boolean) {
const player = this.player;
if (!closed_screen) {
this.#handle_keys();
}
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 && !closed_screen) {
this.#handle_hotbar();
}
}
#handle_keys() {
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();
}
}
// 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 });
}
}
}
-73
View File
@@ -1,73 +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 { Dimension } from "./components/dimension.ts";
import { GuiRenderSystem, GuiTickSystem } from "./gui/gui_systems.ts";
import { WorldGenerationSystem } from "./systems/world_generation_system.ts";
import { CollisionSystem } from "./systems/collision_system.ts";
import { NetworkSystem } from "./systems/network_system.ts";
import { Connection } from "./network.ts";
export interface ChatLine {
text: string;
time: number;
}
export class ClientWorld extends World {
paused = false;
debugging = false;
dimension!: Dimension;
connection: Connection;
chat_log: ChatLine[] = [];
constructor(connection: Connection) {
super("game");
this.connection = connection;
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 NetworkSystem(), "game");
this.add_system(new GuiTickSystem(), "game");
this.add_system(new PlayerControlsSystem(), "game");
this.add_system(new WorldGenerationSystem(), "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");
}
add_chat(text: string) {
this.chat_log.push({ text, time: performance.now() });
if (this.chat_log.length > 100) {
this.chat_log.shift();
}
}
}
-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;
}
}
-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;
}
}
+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];
}
-35
View File
@@ -1,35 +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?.dispose();
world.dimension = new Dimension(world, world.connection.seed);
for (const [x, y, z, id, state] of world.connection.initial_changes) {
world.dimension.record_change(x, y, z, id, state);
}
dimension.add(world.dimension);
world.add_entity(dimension);
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 ?? "");
}
}
+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;");
}
+48
View File
@@ -0,0 +1,48 @@
import type { Client } from "$/client/client.ts";
import { DebugUI } from "$/client/debug_ui.ts";
import { format_clock } from "$/common/time.ts";
// f3: every entity's fields, editable
export class DebugOverlay {
render(client: Client) {
DebugUI.begin("Entities", 10, 10, 300);
DebugUI.text(`${format_clock(client.level.time)} (tick ${client.level.time})`);
DebugUI.separator();
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);
}
}
+15 -87
View File
@@ -1,116 +1,44 @@
import { GuiScreen } from "./gui_screen.ts"; 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 { InputManager } from "../input_manager.ts";
import { ClientWorld } from "../client_world.ts"; import type { Client } from "../client.ts";
import { PlayerComponent } from "../player.ts";
import { MAX_CHAT_LENGTH } from "$/common/protocol.ts"; import { MAX_CHAT_LENGTH } from "$/common/protocol.ts";
import { render_chat_log } from "../systems/rendering/network.ts"; import { TextInput } from "./widgets.ts";
export class GuiChat extends GuiScreen { export class GuiChat extends GuiScreen {
world: ClientWorld; client: Client;
input = new TextInput("", MAX_CHAT_LENGTH);
text_typed = ""; constructor(client: Client) {
caret = 0;
key_repeat_timer = 0;
show_caret = true;
constructor(world: ClientWorld) {
super(); super();
this.world = world; this.client = client;
} }
override on_render(): void { override on_render(): void {
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]); draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
const y = canvas.height - 32; const y = canvas.height - 32;
render_chat_log(this.world, true, y - 8); this.client.chat.render(true, y - 8);
draw_rect(0, y, canvas.width, canvas.height, [0, 0, 0, 0.4]); draw_rect(0, y, canvas.width, canvas.height, [0, 0, 0, 0.4]);
draw_text(this.text_typed, 0, y, 2, [1, 1, 1]); draw_text(this.input.value, 0, y, 2, [1, 1, 1]);
draw_rect(this.input.caret_x(0, 2), y + 4, 1, 32 - 4);
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);
}
} }
override on_tick(_delta: number): void { override on_tick(_delta: number): void {
const now = performance.now(); this.input.handle_keys();
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) {
if (this.text_typed.length >= MAX_CHAT_LENGTH) {
break;
}
this.text_typed = this.text_typed.slice(0, this.caret) + char + this.text_typed.slice(this.caret);
this.caret += 1;
}
if (InputManager.is_key_pressed("Enter")) { if (InputManager.is_key_pressed("Enter")) {
this.submit(); this.submit();
} }
} }
override on_close(): void {} override on_close(): void {}
submit() { submit() {
// commands like /give run on the server too // commands like /give run on the server too
if (this.text_typed.trim().length > 0) { if (this.input.value.trim().length > 0) {
this.world.connection.send({ type: "chat", text: this.text_typed }); this.client.connection.send({ type: "chat", text: this.input.value });
}
this.text_typed = "";
const [player] = this.world.get_tag("player")!;
const player_component = player.get(PlayerComponent);
if (player_component) {
player_component.pop_screen();
} }
this.client.pop_screen();
} }
} }
+1 -1
View File
@@ -3,7 +3,7 @@ import { canvas, draw_rect, draw_texture_region, Texture } from "$/client/render
import { AssetManager } from "../assets.ts"; import { AssetManager } from "../assets.ts";
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts"; import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
import { ClientMessage, ScreenLayout } from "$/common/protocol.ts"; import { ClientMessage, ScreenLayout } from "$/common/protocol.ts";
import { draw_nine_slice } from "../systems/rendering/render_utils.ts"; import { draw_nine_slice } from "../rendering/render_utils.ts";
import { get_sprite_region } from "$/client/sprites.ts"; import { get_sprite_region } from "$/client/sprites.ts";
import { ClientInventories } from "../inventory.ts"; import { ClientInventories } from "../inventory.ts";
+1 -1
View File
@@ -2,7 +2,7 @@ import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } fro
import { canvas, draw_rect, Texture } from "$/client/renderer/mod.ts"; import { canvas, draw_rect, Texture } from "$/client/renderer/mod.ts";
import { AssetManager } from "../assets.ts"; import { AssetManager } from "../assets.ts";
import { SLOT_SIZE } from "../../common/constants.ts"; import { SLOT_SIZE } from "../../common/constants.ts";
import { draw_nine_slice } from "../systems/rendering/render_utils.ts"; import { draw_nine_slice } from "../rendering/render_utils.ts";
import { ClientInventories } from "../inventory.ts"; import { ClientInventories } from "../inventory.ts";
import { ClientMessage, CRAFTING_RESULT_SLOT } from "$/common/protocol.ts"; import { ClientMessage, CRAFTING_RESULT_SLOT } from "$/common/protocol.ts";
+9 -2
View File
@@ -6,7 +6,8 @@ import { AssetManager } from "../assets.ts";
import { InputManager } from "../input_manager.ts"; import { InputManager } from "../input_manager.ts";
import { ClientInventories } from "../inventory.ts"; import { ClientInventories } from "../inventory.ts";
import { canvas, Texture } from "../renderer/mod.ts"; import { canvas, Texture } from "../renderer/mod.ts";
import { draw_item, draw_nine_slice } from "../systems/rendering/render_utils.ts"; import { draw_item, draw_nine_slice } from "../rendering/render_utils.ts";
import { draw_tooltip, item_tooltip } from "./tooltip.ts";
export class Slot { export class Slot {
container: ContainerKey; container: ContainerKey;
@@ -99,9 +100,15 @@ export class GuiInventoryScreen extends GuiScreen {
} }
} }
if (this.inventories.cursor.item) {
const mouse = InputManager.get_mouse_position(); const mouse = InputManager.get_mouse_position();
if (this.inventories.cursor.item) {
draw_item(this.inventories.cursor.item, mouse.x, mouse.y); draw_item(this.inventories.cursor.item, mouse.x, mouse.y);
} else if (this.hovering) {
// what's in the slot under the mouse, while not carrying anything
const item = this.get_container(this.hovering.container)?.get_item(this.hovering.index);
if (item) {
draw_tooltip(item_tooltip(item), mouse.x, mouse.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);
}
}
+101
View File
@@ -0,0 +1,101 @@
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, draw_text, measure_text, Texture } from "$/client/renderer/mod.ts";
import { display_name } from "$/common/utils.ts";
import { TEXT_HEIGHT, TEXT_SCALE } from "./widgets.ts";
const PADDING = 10;
const CROSSHAIR_SIZE = 8;
// how long the held item's name shows above the hotbar after it changes, and the last part of that it fades out in
const HELD_NAME_MS = 2000;
const HELD_NAME_FADE_MS = 500;
const HELD_NAME_GAP = 12;
// what's drawn over the world while playing, like minecraft's Gui: hotbar, crosshair and chat
export class Hud {
// what was held last frame, as slot and item id, and when the held item last changed
#held = "";
#held_since = -Infinity;
render(client: Client) {
this.#render_hotbar(client.player.inventories);
this.#render_held_name(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);
}
}
}
// like minecraft: switching slots, or a different item ending up in the selected one, shows its name for a bit
#render_held_name(inventories: ClientInventories) {
const item = inventories.inventory.get_item(inventories.hotbar_selected);
const held = item ? `${inventories.hotbar_selected} ${item.type_id}` : "";
const now = performance.now();
if (held !== this.#held) {
this.#held = held;
this.#held_since = item ? now : -Infinity;
}
const shown_for = now - this.#held_since;
if (!item || shown_for > HELD_NAME_MS) {
return;
}
const alpha = Math.min(1, (HELD_NAME_MS - shown_for) / HELD_NAME_FADE_MS);
const name = display_name(item.type_id);
const hotbar_top = canvas.height - (PADDING * 2 + SLOT_SIZE);
const x = (canvas.width - measure_text(name, TEXT_SCALE)) / 2;
const y = hotbar_top - HELD_NAME_GAP - TEXT_HEIGHT * TEXT_SCALE;
// a shadow so it reads over bright sky and snow
draw_text(name, x + TEXT_SCALE, y + TEXT_SCALE, TEXT_SCALE, [0.15, 0.15, 0.15, alpha]);
draw_text(name, x, y, TEXT_SCALE, [1, 1, 1, alpha]);
}
#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 {}
}
+246
View File
@@ -0,0 +1,246 @@
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. in the desktop app there's also singleplayer, which joins the app's own server
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());
// the page was served by a game server of its own to play on alone, see desktop/main.ts
singleplayer_button = has_singleplayer()
? new Button("Singleplayer", WIDTH, ROW_HEIGHT, () => this.#join_singleplayer())
: undefined;
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.singleplayer_button?.handle_input();
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.singleplayer_button?.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 singleplayer_height = this.singleplayer_button ? ROW_HEIGHT + GAP : 0;
const total = 2 * (LABEL_GAP + ROW_HEIGHT + GAP) + singleplayer_height + 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;
if (field === this.name && this.singleplayer_button) {
this.singleplayer_button.x = x;
this.singleplayer_button.y = y;
y += singleplayer_height;
}
}
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());
await this.#connect(address);
}
// the server that served this page
async #join_singleplayer() {
if (this.#joining) {
return;
}
await this.#connect(server_address(location.host), "Starting singleplayer...");
}
async #connect(address: ServerAddress, status = `Connecting to ${address.base.host}...`) {
remember(LAST_NAME_KEY, this.name.value);
this.#set_joining(true);
this.status = status;
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;
if (this.singleplayer_button) {
this.singleplayer_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
}
}
function has_singleplayer() {
return new URL(location.href).searchParams.has("singleplayer");
}
// 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. the desktop app keeps its ?singleplayer and ?server=, its saved
// fields don't last between launches and the page's own address isn't a server to join
export function back_to_title() {
const page = new URL(location.href);
const next = new URL(location.pathname, location.href);
if (has_singleplayer()) {
next.searchParams.set("server", page.searchParams.get("server") ?? "");
next.searchParams.set("singleplayer", "");
}
location.href = next.href;
}
+55
View File
@@ -0,0 +1,55 @@
// the box next to the mouse saying what an item is, like minecraft's: its name, then its lore
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import type { ItemStack } from "$/common/inventory.ts";
import { display_name } from "$/common/utils.ts";
import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mod.ts";
import { TEXT_HEIGHT, TEXT_SCALE } from "./widgets.ts";
type Color = [number, number, number, number];
const NAME_COLOR: Color = [1, 1, 1, 1];
const LORE_COLOR: Color = [0.66, 0.66, 0.66, 1];
const BACKGROUND: Color = [0.06, 0.02, 0.1, 0.94];
const BORDER: Color = [0.31, 0.1, 0.6, 1];
const BORDER_WIDTH = 2;
const PADDING = 8;
const LINE_GAP = 4;
// from the mouse, so the cursor doesn't cover it
const OFFSET = 16;
export interface TooltipLine {
text: string;
color: Color;
}
// its name, the item's lore from its json, then what the server's scripts said about this stack
export function item_tooltip(item: ItemStack): TooltipLine[] {
const lines: TooltipLine[] = [{ text: display_name(item.type_id), color: NAME_COLOR }];
const static_lore = EverythingRegistry.get<ItemRegistry>("items", item.type_id)?.lore;
for (const lore of [static_lore, item.lore]) {
for (const text of lore?.split("\n") ?? []) {
lines.push({ text, color: LORE_COLOR });
}
}
return lines;
}
// below and right of the mouse, moved back inside the window when it would go past its edge
export function draw_tooltip(lines: TooltipLine[], mouse_x: number, mouse_y: number) {
const line_height = TEXT_HEIGHT * TEXT_SCALE;
const width = Math.max(...lines.map((line) => measure_text(line.text, TEXT_SCALE))) + PADDING * 2;
const height = lines.length * line_height + (lines.length - 1) * LINE_GAP + PADDING * 2;
let x = mouse_x + OFFSET;
let y = mouse_y + OFFSET;
if (x + width > canvas.width) x = mouse_x - OFFSET - width;
if (y + height > canvas.height) y = canvas.height - height;
x = Math.max(0, x);
y = Math.max(0, y);
draw_rect(x, y, width, height, BORDER);
draw_rect(x + BORDER_WIDTH, y + BORDER_WIDTH, width - BORDER_WIDTH * 2, height - BORDER_WIDTH * 2, BACKGROUND);
lines.forEach((line, i) => {
draw_text(line.text, x + PADDING, y + PADDING + i * (line_height + LINE_GAP), TEXT_SCALE, line.color);
});
}
+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);
}
}
}
+41 -17
View File
@@ -1,6 +1,5 @@
// connecting to a server and getting what it needs before joining, see "Delivery to clients" in MODS.md: // 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 // 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"; import { ClientMessage, PROTOCOL_VERSION, ServerMessage } from "$/common/protocol.ts";
const CONNECT_TIMEOUT_MS = 5000; const CONNECT_TIMEOUT_MS = 5000;
@@ -20,10 +19,33 @@ export interface ServerAddress {
// something went wrong in a way the player should see // something went wrong in a way the player should see
export class HandshakeError extends Error {} export class HandshakeError extends Error {}
export function server_address(page = new URL(location.href)): ServerAddress { // what the server field starts as: ?server=host:port, otherwise the server that served the page
// ?server=host:port, otherwise the server that served the page export function default_server(page = new URL(location.href)): string {
const host = page.searchParams.get("server") ?? page.host; return page.searchParams.get("server") ?? page.host;
const secure = page.protocol === "https:"; }
// what a player typed as the server: host:port, or a full http(s) or ws(s) url to pick the scheme
export function server_address(input: string, page = new URL(location.href)): ServerAddress {
const text = input.trim();
let host = text;
let secure: boolean;
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:";
} else {
// servers on this machine are plain ws and everything else is wss, whatever the page was served over. the
// desktop app's page is plain http on localhost, but the servers it joins are real domains behind tls
secure = !is_local_host(host);
}
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}/`); const base = new URL(`${secure ? "https" : "http"}://${host}/`);
return { return {
ws_url: `${secure ? "wss" : "ws"}://${host}/ws`, ws_url: `${secure ? "wss" : "ws"}://${host}/ws`,
@@ -32,6 +54,19 @@ export function server_address(page = new URL(location.href)): ServerAddress {
}; };
} }
const LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "0.0.0.0"];
// host is host:port, or just a host
function is_local_host(host: string) {
let hostname: string;
try {
hostname = new URL(`http://${host}/`).hostname;
} catch {
return false;
}
return LOCAL_HOSTS.includes(hostname);
}
// a socket whose messages all land in one queue, so none get lost between the handshake and the game // a socket whose messages all land in one queue, so none get lost between the handshake and the game
export class ServerSocket { export class ServerSocket {
socket: WebSocket; socket: WebSocket;
@@ -153,17 +188,6 @@ export function code_url(bytes: Uint8Array<ArrayBuffer>): string {
return URL.createObjectURL(new Blob([bytes], { type: "text/javascript" })); 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 // players ok a cross-origin server's mods once, per server and exact mod versions
function trust_key(address: ServerAddress) { function trust_key(address: ServerAddress) {
@@ -171,7 +195,7 @@ function trust_key(address: ServerAddress) {
} }
function mod_versions(welcome: Welcome) { function mod_versions(welcome: Welcome) {
return welcome.mods.map((mod) => `${mod.id}@${mod.hash}`).sort().join(","); return welcome.mods.map((mod) => `${mod.id}@${mod.sha256}`).sort().join(",");
} }
export function is_trusted(address: ServerAddress, welcome: Welcome): boolean { export function is_trusted(address: ServerAddress, welcome: Welcome): boolean {
+16 -5
View File
@@ -89,13 +89,16 @@ export class InputManager {
static mouse_delta_y = 0; static mouse_delta_y = 0;
static wheel_delta = 0; static wheel_delta = 0;
static typed_characters = new Set<string>(); // in the order they were typed. a list, since the same letter can be typed twice in one frame
static typed_characters: string[] = [];
static pointer_lock_flag = false; static pointer_lock_flag = false;
static mouse_grab_timer = 0; static mouse_grab_timer = 0;
static mouse_ungrab_timer = 0; static mouse_ungrab_timer = 0;
static mouse_ungrab_timeout = -1; static mouse_ungrab_timeout = -1;
static pointer_lock_waiting = false; 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) { static initialize(canvas: HTMLCanvasElement) {
self.addEventListener("keydown", (e) => { self.addEventListener("keydown", (e) => {
@@ -106,7 +109,7 @@ export class InputManager {
} }
this.keys_down.add(e.code); this.keys_down.add(e.code);
if (e.key.length === 1) { if (e.key.length === 1) {
this.typed_characters.add(e.key); this.typed_characters.push(e.key);
} }
}); });
@@ -164,6 +167,7 @@ export class InputManager {
if (!grab) { if (!grab) {
if (this.pointer_lock_flag) { if (this.pointer_lock_flag) {
this.mouse_ungrab_timer = performance.now(); this.mouse_ungrab_timer = performance.now();
this.#lost_pointer_lock = true;
} }
} }
this.pointer_lock_flag = grab; this.pointer_lock_flag = grab;
@@ -218,8 +222,8 @@ export class InputManager {
} }
static get_typed_characters(): string[] { static get_typed_characters(): string[] {
const chars = [...this.typed_characters]; const chars = this.typed_characters;
this.typed_characters.clear(); this.typed_characters = [];
return chars; return chars;
} }
@@ -231,7 +235,7 @@ export class InputManager {
this.mouse_delta_x = 0; this.mouse_delta_x = 0;
this.mouse_delta_y = 0; this.mouse_delta_y = 0;
this.wheel_delta = 0; this.wheel_delta = 0;
this.typed_characters.clear(); this.typed_characters = [];
this.mouse_buttons_consumed.clear(); this.mouse_buttons_consumed.clear();
} }
@@ -266,6 +270,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() { static is_mouse_grabbed() {
return document.hasFocus() && document.pointerLockElement === canvas; return document.hasFocus() && document.pointerLockElement === canvas;
} }
@@ -1,15 +1,23 @@
import { Component } from "$/common/ecs/mod.ts";
import { block_value, chunk_key, default_block_value } from "$/common/utils.ts"; import { block_value, chunk_key, default_block_value } from "$/common/utils.ts";
import { AIR_ID, BlockChange } from "$/common/protocol.ts"; import { AIR_ID, BlockChange } from "$/common/protocol.ts";
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.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 { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from "../../common/constants.ts";
import { AssetManager } from "../assets.ts"; import { AssetManager } from "../assets.ts";
import { ClientWorld } from "../client_world.ts";
import { ChunkWorkerPool } from "../chunk_workers.ts"; import { ChunkWorkerPool } from "../chunk_workers.ts";
import { worldgen_mods } from "../mods.ts"; import { worldgen_mods } from "../mods.ts";
import type { FromChunkWorker } from "../workers/chunk_messages.ts"; import type { FaceGroup, FromChunkWorker } from "../workers/chunk_messages.ts";
import { create_vertex_buffer, destroy_vertex_buffer, Texture } from "../renderer/mod.ts"; import { create_index_buffer, create_vertex_buffer, destroy_buffer, Texture } from "../renderer/mod.ts";
import { Camera } from "./camera.ts"; import { crosses_planes } from "../workers/translucent_sort.ts";
import { Camera } from "../camera.ts";
import type { Entity } from "../entity/entity.ts";
import type { ModelJson } from "$/common/block_models.ts";
export interface Block { export interface Block {
id: string; id: string;
@@ -28,34 +36,75 @@ export interface Chunk {
dirty: boolean; dirty: boolean;
// bumped on every mesh request so late results from older requests get ignored // bumped on every mesh request so late results from older requests get ignored
mesh_version: number; mesh_version: number;
opaque_vertex_buffer?: GPUBuffer; meshes: Partial<Record<RenderLayer, ChunkMesh>>;
opaque_vertex_count?: number; // the lowest and highest y of its meshes, for frustum culling
transparent_vertex_buffer?: GPUBuffer; min_y: number;
transparent_vertex_count?: number; max_y: number;
// 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;
// solid and cutout quads by the way they face, see FACE_GROUPS in chunk_messages.ts
groups?: FaceGroup[];
}
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;
} }
export { chunk_key }; export { chunk_key };
const NEIGHBOR_OFFSETS = [[-1, 0], [1, 0], [0, -1], [0, 1]] as const; // the block being looked at and which face of it
export interface BlockHitResult {
x: number;
y: number;
z: number;
block: number;
face: Faces;
}
export class Dimension extends Component { const NEIGHBOR_OFFSETS = [[-1, 0], [1, 0], [0, -1], [0, 1]] as const;
world: ClientWorld; // 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"); image: Texture = AssetManager.instance.get("bworld:textures");
chunks = new Map<number, Chunk>(); chunks = new Map<number, Chunk>();
second_timer = 0; second_timer = 0;
tick_timer = 0; tick_timer = 0;
seed: string; seed: string;
// world time in ticks, counted here between the server's updates. see common/time.ts
time = 0;
// blocks players changed from the generated terrain, per chunk, so they survive reloading chunks // blocks players changed from the generated terrain, per chunk, so they survive reloading chunks
changes = new Map<string, Map<string, BlockChange>>(); changes = new Map<string, Map<string, BlockChange>>();
workers: ChunkWorkerPool; workers: ChunkWorkerPool;
#blocks = EverythingRegistry.get_registry<BlockRegistry>("blocks");
// chunks being generated by a worker, by chunk key // chunks being generated by a worker, by chunk key
pending_generation = new Map<number, { x: number; z: number }>(); pending_generation = new Map<number, { x: number; z: number }>();
constructor(world: ClientWorld, seed = "seed") { // every entity this client knows about, the local player included, by id
super(); entities = new Map<string, Entity>();
this.world = world;
constructor(seed = "seed") {
this.seed = seed; this.seed = seed;
this.workers = new ChunkWorkerPool((message) => this.#on_worker_message(message)); this.workers = new ChunkWorkerPool((message) => this.#on_worker_message(message));
@@ -68,6 +117,7 @@ export class Dimension extends Component {
type: "init", type: "init",
blocks_registry: strip_functions(blocks_registry), blocks_registry: strip_functions(blocks_registry),
block_ids, block_ids,
models: Object.fromEntries(EverythingRegistry.entries<ModelJson>("models")),
textures_info: AssetManager.instance.get("bworld:textures_info"), textures_info: AssetManager.instance.get("bworld:textures_info"),
image: { width: this.image.width, height: this.image.height }, image: { width: this.image.width, height: this.image.height },
worldgen_scripts: worldgen_mods.scripts, worldgen_scripts: worldgen_mods.scripts,
@@ -75,6 +125,65 @@ export class Dimension extends Component {
}); });
} }
add_entity(entity: Entity) {
this.entities.set(entity.id, entity);
}
remove_entity(id: string) {
this.entities.delete(id);
}
tick() {
this.time += 1;
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() { dispose() {
this.workers.terminate(); this.workers.terminate();
for (const chunk of this.chunks.values()) { for (const chunk of this.chunks.values()) {
@@ -92,6 +201,9 @@ export class Dimension extends Component {
dirty: true, dirty: true,
generated: false, generated: false,
mesh_version: 0, mesh_version: 0,
meshes: {},
min_y: 0,
max_y: 0,
}; };
this.chunks.set(chunk_key(x, z), chunk); this.chunks.set(chunk_key(x, z), chunk);
return chunk; return chunk;
@@ -118,9 +230,10 @@ export class Dimension extends Component {
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx; const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
const old_nid = chunk.blocks[index] & ID_MASK;
chunk.blocks[index] = state === undefined ? default_block_value(nid, info) : block_value(nid, state); chunk.blocks[index] = state === undefined ? default_block_value(nid, info) : block_value(nid, state);
chunk.dirty = true; chunk.dirty = true;
this.#mark_border_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz); 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 // the id with its state bits, VOID outside loaded chunks
@@ -154,6 +267,12 @@ export class Dimension extends Component {
return chunk.blocks[index] & ID_MASK; 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 // only changes what this client shows, drops and everything else happen on the server
break_block(x: number, y: number, z: number) { break_block(x: number, y: number, z: number) {
const block_chunk_x = Math.floor(x / CHUNK_SIZE); const block_chunk_x = Math.floor(x / CHUNK_SIZE);
@@ -168,13 +287,37 @@ export class Dimension extends Component {
const ly = y; const ly = y;
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx; const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
const old_nid = chunk.blocks[index] & ID_MASK;
chunk.blocks[index] = AIR; chunk.blocks[index] = AIR;
chunk.dirty = true; chunk.dirty = true;
this.#mark_border_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz); 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;
} }
// a block on a chunk's edge changes which faces its neighbor shows
#mark_border_neighbors_dirty(block_chunk_x: number, block_chunk_z: number, lx: number, lz: number) {
if (lx === 0) { if (lx === 0) {
const n = this.get_chunk(block_chunk_x - 1, block_chunk_z); const n = this.get_chunk(block_chunk_x - 1, block_chunk_z);
if (n) { if (n) {
@@ -279,13 +422,13 @@ export class Dimension extends Component {
this.chunks.delete(key); this.chunks.delete(key);
} }
// a chunk is only meshed once all its neighbors exist, otherwise its border faces would be wrong // a chunk is only meshed once all its neighbors exist, otherwise its border faces and light would be
// and it would have to be meshed again as each neighbor loads // wrong and it would have to be meshed again as each neighbor loads
can_mesh(chunk: Chunk) { can_mesh(chunk: Chunk) {
if (!chunk.generated) { if (!chunk.generated) {
return false; return false;
} }
for (const [dx, dz] of NEIGHBOR_OFFSETS) { for (const [dx, dz] of ALL_NEIGHBOR_OFFSETS) {
if (!this.is_generated(chunk.x + dx, chunk.z + dz)) { if (!this.is_generated(chunk.x + dx, chunk.z + dz)) {
return false; return false;
} }
@@ -294,29 +437,61 @@ export class Dimension extends Component {
} }
// sends every dirty chunk that can be meshed to the workers // sends every dirty chunk that can be meshed to the workers
request_meshes() { request_meshes(camera: Camera) {
for (const chunk of this.chunks.values()) { for (const chunk of this.chunks.values()) {
if (!chunk.dirty || !this.can_mesh(chunk)) { if (!chunk.dirty || !this.can_mesh(chunk)) {
continue; continue;
} }
chunk.dirty = false; chunk.dirty = false;
chunk.mesh_version += 1; chunk.mesh_version += 1;
const padded_chunk = this.create_padded_chunk(chunk); // 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({ this.workers.post({
type: "mesh", type: "mesh",
chunk_x: chunk.x, chunk_x: chunk.x,
chunk_z: chunk.z, chunk_z: chunk.z,
version: chunk.mesh_version, version: chunk.mesh_version,
padded_chunk, chunks,
}, [padded_chunk.buffer]); 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) { #on_worker_message(message: FromChunkWorker) {
if (message.type === "generated") { if (message.type === "generated") {
this.#on_generated(message.chunk_x, message.chunk_z, message.blocks, message.spills); this.#on_generated(message.chunk_x, message.chunk_z, message.blocks, message.spills);
} else { } else if (message.type === "meshed") {
this.#on_meshed(message); this.#on_meshed(message);
} else {
this.#on_sorted(message);
} }
} }
@@ -329,7 +504,7 @@ export class Dimension extends Component {
let chunk = this.chunks.get(key); let chunk = this.chunks.get(key);
if (chunk) { if (chunk) {
// a placeholder made by a neighbor's tree, keep its blocks where generation left air // blocks placed here before it generated, keep them where generation left air
const existing = chunk.blocks; const existing = chunk.blocks;
for (let i = 0; i < blocks.length; i++) { for (let i = 0; i < blocks.length; i++) {
if (blocks[i] !== AIR) { if (blocks[i] !== AIR) {
@@ -341,19 +516,28 @@ export class Dimension extends Component {
} }
chunk.generated = true; chunk.generated = true;
chunk.dirty = 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) { for (let i = 0; i < spills.length; i += 4) {
this.#set_block_raw(spills[i], spills[i + 1], spills[i + 2], spills[i + 3]); this.#set_block_raw(spills[i], spills[i + 1], spills[i + 2], spills[i + 3]);
} }
for (const [dx, dz] of NEIGHBOR_OFFSETS) { for (const [dx, dz] of ALL_NEIGHBOR_OFFSETS) {
const neighbor = this.get_chunk(cx + dx, cz + dz); const neighbor = this.get_chunk(cx + dx, cz + dz);
if (neighbor && neighbor.generated) { if (neighbor && neighbor.generated) {
neighbor.dirty = true; neighbor.dirty = true;
} }
} }
// trees spill into neighboring chunks, so their changes need reapplying too // features spill into neighboring chunks, so their changes need reapplying too
for (let dx = -1; dx <= 1; dx++) { for (let dx = -1; dx <= 1; dx++) {
for (let dz = -1; dz <= 1; dz++) { for (let dz = -1; dz <= 1; dz++) {
this.apply_chunk_changes(cx + dx, cz + dz); this.apply_chunk_changes(cx + dx, cz + dz);
@@ -369,17 +553,50 @@ export class Dimension extends Component {
} }
this.delete_chunk_mesh(chunk); this.delete_chunk_mesh(chunk);
chunk.min_y = message.min_y;
chunk.max_y = message.max_y;
chunk.opaque_vertex_buffer = create_vertex_buffer(message.opaque_vertices.subarray(0, message.opaque_count)); for (const layer of RENDER_LAYERS) {
chunk.opaque_vertex_count = message.opaque_count / 9; const { vertices, quad_count, groups } = message[layer];
if (quad_count === 0) {
chunk.transparent_vertex_buffer = create_vertex_buffer( continue;
message.transparent_vertices.subarray(0, message.transparent_count), }
); chunk.meshes[layer] = { vertex_buffer: create_vertex_buffer(vertices), quad_count, groups };
chunk.transparent_vertex_count = message.transparent_count / 9;
} }
// a neighbor's leaves, only fill air so the result doesn't depend on which chunk loaded first. 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) // the server builds chunks the same way (server/game/world.ts)
#set_block_raw(x: number, y: number, z: number, nid: number) { #set_block_raw(x: number, y: number, z: number, nid: number) {
if (y < 0 || y >= CHUNK_HEIGHT) { if (y < 0 || y >= CHUNK_HEIGHT) {
@@ -387,45 +604,56 @@ export class Dimension extends Component {
} }
const chunk_x = Math.floor(x / CHUNK_SIZE); const chunk_x = Math.floor(x / CHUNK_SIZE);
const chunk_z = Math.floor(z / CHUNK_SIZE); const chunk_z = Math.floor(z / CHUNK_SIZE);
const chunk = this.get_chunk(chunk_x, chunk_z) ?? this.add_chunk(chunk_x, chunk_z); const 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 lx = x - chunk_x * CHUNK_SIZE;
const lz = z - chunk_z * CHUNK_SIZE; const lz = z - chunk_z * CHUNK_SIZE;
const index = y * CHUNK_AREA + lz * CHUNK_SIZE + lx; const index = y * CHUNK_AREA + lz * CHUNK_SIZE + lx;
if (chunk.blocks[index] === AIR) { if (chunk.blocks[index] === AIR) {
chunk.blocks[index] = nid; chunk.blocks[index] = nid;
chunk.dirty = true; 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) { delete_chunk_mesh(chunk: Chunk) {
if (chunk.opaque_vertex_buffer) { for (const mesh of Object.values(chunk.meshes)) {
destroy_vertex_buffer(chunk.opaque_vertex_buffer); destroy_buffer(mesh.vertex_buffer);
chunk.opaque_vertex_buffer = undefined; if (mesh.index_buffer) {
destroy_buffer(mesh.index_buffer);
} }
if (chunk.transparent_vertex_buffer) {
destroy_vertex_buffer(chunk.transparent_vertex_buffer);
chunk.transparent_vertex_buffer = undefined;
} }
chunk.meshes = {};
chunk.translucent_sort = undefined;
} }
get_looked_block( // the first block along the view of something at x/y/z looking at yaw/pitch, like minecraft's pick
dimension: Dimension, pick(
camera: Camera, x: number,
y: number,
z: number,
yaw: number,
pitch: number,
max_distance = 6, max_distance = 6,
step = 0.05, step = 0.05,
): { x: number; y: number; z: number; block: number; face: Faces } | undefined { ): BlockHitResult | undefined {
const yaw = camera.yaw;
const pitch = camera.pitch;
const cos_pitch = Math.cos(pitch); const cos_pitch = Math.cos(pitch);
const dx = -Math.sin(yaw) * cos_pitch; const dx = -Math.sin(yaw) * cos_pitch;
const dy = Math.sin(pitch); const dy = Math.sin(pitch);
const dz = -Math.cos(yaw) * cos_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_bx = Math.floor(x);
let prev_by = Math.floor(y); let prev_by = Math.floor(y);
let prev_bz = Math.floor(z); let prev_bz = Math.floor(z);
@@ -446,7 +674,7 @@ export class Dimension extends Component {
continue; continue;
} }
const block = dimension.get_block(bx, by, bz); const block = this.get_block(bx, by, bz);
if (block && block !== AIR && block !== VOID) { if (block && block !== AIR && block !== VOID) {
let face: Faces; let face: Faces;
@@ -475,44 +703,6 @@ export class Dimension extends Component {
return undefined; return undefined;
} }
create_padded_chunk(chunk: Chunk) {
const size = CHUNK_SIZE + 2;
const layer = size * size;
const padded = new Uint32Array(layer * CHUNK_HEIGHT);
// look the 3x3 chunks up once instead of once per border block
const around: (Uint32Array | undefined)[] = [];
for (let dz = -1; dz <= 1; dz++) {
for (let dx = -1; dx <= 1; dx++) {
around.push(this.get_chunk(chunk.x + dx, chunk.z + dz)?.blocks);
}
}
for (let z = -1; z <= CHUNK_SIZE; z++) {
const dz = z < 0 ? -1 : z >= CHUNK_SIZE ? 1 : 0;
const lz = z - dz * CHUNK_SIZE;
for (let x = -1; x <= CHUNK_SIZE; x++) {
const dx = x < 0 ? -1 : x >= CHUNK_SIZE ? 1 : 0;
const lx = x - dx * CHUNK_SIZE;
const source = around[(dz + 1) * 3 + (dx + 1)];
const source_index = lz * CHUNK_SIZE + lx;
const padded_index = (z + 1) * size + (x + 1);
if (!source) {
for (let y = 0; y < CHUNK_HEIGHT; y++) {
padded[y * layer + padded_index] = VOID;
}
continue;
}
for (let y = 0; y < CHUNK_HEIGHT; y++) {
padded[y * layer + padded_index] = source[y * CHUNK_AREA + source_index] & ID_MASK;
}
}
}
return padded;
}
} }
// functions cant be sent to workers // functions cant be sent to workers
+122 -59
View File
@@ -1,26 +1,37 @@
import { AssetManager } from "./assets.ts"; import { AssetManager } from "./assets.ts";
import { ClientWorld } from "./client_world.ts"; import { Client } from "./client.ts";
import { InputManager } from "./input_manager.ts"; import { InputManager } from "./input_manager.ts";
import { Connection, get_player_name } from "./network.ts"; import { Connection } from "./network.ts";
import { connect, HandshakeError, is_trusted, join, load_atlas, remember_trust, server_address } from "./handshake.ts"; import { connect, HandshakeError, is_trusted, join, remember_trust, ServerAddress } from "./handshake.ts";
import { build_atlas, engine_textures } from "./atlas.ts";
import { confirm_mods } from "./confirm_mods.ts"; import { confirm_mods } from "./confirm_mods.ts";
import { ModLoadError } from "$/common/mod_loader.ts"; import { ModLoadError } from "$/common/mod_loader.ts";
import { begin_drawing, clear_background, end_drawing, init_font, init_window, load_texture } from "./renderer/mod.ts"; import {
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 { is_stopped, show_fatal_error } from "./fatal.ts";
import { load_client_mods, set_mods_world } from "./mods.ts"; import { download_mods, 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 { export class ClientLoop {
running = false; running = false;
last_time = 0; last_time = 0;
world: ClientWorld; client: Client | undefined;
screen: GuiScreen | undefined;
frame_count = 0; frame_count = 0;
last_fps_time = 0; last_fps_time = 0;
constructor(world: ClientWorld) {
this.world = world;
}
start() { start() {
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
if (!document.hidden) { if (!document.hidden) {
@@ -40,6 +51,23 @@ export class ClientLoop {
this.running = false; 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) { loop(time: number) {
if (!this.running || is_stopped()) { if (!this.running || is_stopped()) {
return; return;
@@ -49,7 +77,12 @@ export class ClientLoop {
begin_drawing(); begin_drawing();
clear_background(0.69, 0.8, 1, 1.0); 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(); end_drawing();
this.frame_count += 1; this.frame_count += 1;
@@ -68,14 +101,75 @@ export class ClientLoop {
} }
} }
const canvas = document.getElementById("game") as HTMLCanvasElement; // a server's blocks, items and scripts can't be taken back out once loaded, so a failure after that point
if (!canvas) { // 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 bmods = await download_mods(welcome.mods, address.base);
const textures = await engine_textures();
for (const bmod of bmods) {
for (const [id, png] of bmod.textures) textures.set(id, png);
}
const atlas = await build_atlas(textures);
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, bmods);
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"); throw Error("Canvas was not found");
} }
await 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:assets_text", "/assets/ASSETS.md");
@@ -90,51 +184,20 @@ await AssetManager.instance.load_all();
init_font(); init_font();
// the game only runs against a server, it owns the world and everything in it. see "Delivery to clients" in MODS.md const loop = new ClientLoop();
async function join_server(): Promise<Connection> { loop.show_screen(
const address = server_address(); new TitleScreen(async (address, name, status) => {
const { socket, welcome } = await connect(address, get_player_name()); let connection: Connection;
console.log(`Connected to ${address.ws_url}`);
try { try {
if (address.cross_origin && !is_trusted(address, welcome)) { connection = await join_server(address, name, status);
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
const atlas = await load_atlas(address, welcome.atlas);
AssetManager.instance.assets["bworld:textures"] = load_texture(atlas.image);
AssetManager.instance.assets["bworld:textures_info"] = atlas.regions;
await load_client_mods(welcome.mods, address.base);
console.log(`Mods: ${welcome.mods.map((mod) => `${mod.id} ${mod.version}`).join(", ") || "none"}`);
const joined = await join(socket);
return new Connection(socket, welcome, joined);
} catch (e) { } catch (e) {
socket.close(); if (e instanceof FailedAfterLoadingMods) {
loop.show_screen(new DisconnectedScreen(e.message));
return;
}
throw e; throw e;
} }
} loop.play(connection);
}),
try { );
const connection = await join_server(); loop.start();
const client_world = new ClientWorld(connection);
set_mods_world(client_world);
client_world.add_chat("Connected to the server");
const loop = new ClientLoop(client_world);
loop.start();
console.log("Game started");
} catch (e) {
console.error(e);
const message = e instanceof HandshakeError
? e.message
: e instanceof ModLoadError
? `Couldn't load the server's mods: ${e.message}`
: `Something went wrong joining the server: ${e instanceof Error ? e.message : e}`;
show_fatal_error(message);
}
-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);
}
+50 -38
View File
@@ -1,62 +1,72 @@
// loads the mods the server lists: registers their data, then runs their client scripts. // loads the mods the server lists: downloads their .bmod files, registers their data, then runs their client scripts.
// see "Delivery to clients" in MODS.md // see "Delivery to clients" in MODS.md
import { AIR } from "$/common/constants.ts"; import { AIR } from "$/common/constants.ts";
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import type { ClientContext } from "$/common/mod_api/client.ts"; import type { ClientContext } from "$/common/mod_api/client.ts";
import { ModData, ModListing, ModLoadError, register_mod_data } from "$/common/mod_loader.ts"; import { ModListing, ModLoadError, register_mod_data } from "$/common/mod_loader.ts";
import { type Bmod, read_bmod } from "$/common/bmod.ts";
import type { OreJson } from "$/common/mod_data.ts"; import type { OreJson } from "$/common/mod_data.ts";
import { AIR_ID } from "$/common/protocol.ts"; import { AIR_ID } from "$/common/protocol.ts";
import { Position } from "$/common/components/position.ts"; import type { Client } from "./client.ts";
import type { ClientWorld } from "./client_world.ts";
import { code_url, fetch_verified } from "./handshake.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 // 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: [] }; export const worldgen_mods: { scripts: { mod: string; url: string }[]; ores: OreJson[] } = { scripts: [], ores: [] };
// set once the world exists, mods can't look at it during setup // set once the game is running, mods can't look at it during setup
let world: ClientWorld | undefined; let client: Client | undefined;
export function set_mods_world(client_world: ClientWorld) { export function set_mods_client(game_client: Client) {
world = client_world; client = game_client;
} }
// downloads every mod's files and checks them against the hashes the server listed, then registers the data and // downloads every mod's .bmod and checks it against the hash the server listed. everything a mod runs comes from
// runs the client scripts. everything a mod runs is imported from the checked bytes, never fetched twice // the checked bytes, never fetched twice
export async function load_client_mods(listings: ModListing[], base: URL) { export async function download_mods(listings: ModListing[], base: URL): Promise<Bmod[]> {
const downloads = await Promise.all(listings.map(async (listing) => { return await Promise.all(listings.map(async (listing) => {
const get = async (path: string | undefined, sha256: string | undefined, what: string) => {
if (!path) return undefined;
try { try {
return await fetch_verified(new URL(path, base), sha256 ?? "", what); const bytes = await fetch_verified(new URL(listing.file, base), listing.sha256, `${listing.name}`);
const bmod = read_bmod(bytes, `${listing.id}.bmod`);
if (bmod.manifest.id !== listing.id) throw new Error(`the server listed it as ${listing.id}`);
return bmod;
} catch (e) { } catch (e) {
throw new ModLoadError(listing.id, (e as Error).message); throw new ModLoadError(listing.id, (e as Error).message);
} }
};
const [data, client, worldgen] = await Promise.all([
get(listing.data, listing.sha256.data, "its data"),
get(listing.client, listing.sha256.client, "its client script"),
get(listing.worldgen, listing.sha256.worldgen, "its worldgen script"),
]);
return { listing, data: JSON.parse(new TextDecoder().decode(data)) as ModData, client, worldgen };
})); }));
}
const recipes = register_mod_data(downloads.map(({ listing, data }) => ({ id: listing.id, data }))); // registers the mods' data and runs their client scripts, in the order the server listed them
export async function load_client_mods(listings: ModListing[], bmods: Bmod[]) {
for (const [i, bmod] of bmods.entries()) {
if (bmod.credits) {
mod_credits.push({ name: listings[i].name, version: listings[i].version, text: bmod.credits });
}
}
const recipes = register_mod_data(bmods.map((bmod) => ({ id: bmod.manifest.id, data: bmod.data })));
worldgen_mods.ores = recipes.ores; worldgen_mods.ores = recipes.ores;
worldgen_mods.scripts = downloads.flatMap(({ listing, worldgen }) => worldgen_mods.scripts = bmods.flatMap((bmod) =>
worldgen ? [{ mod: listing.id, url: code_url(worldgen) }] : [] bmod.scripts.worldgen ? [{ mod: bmod.manifest.id, url: script_url(bmod.scripts.worldgen) }] : []
); );
for (const { listing, client } of downloads) { for (const [i, bmod] of bmods.entries()) {
if (!client) continue; if (!bmod.scripts.client) continue;
const module = await import(code_url(client)); const module = await import(script_url(bmod.scripts.client));
if (typeof module.setup !== "function") { if (typeof module.setup !== "function") {
throw new ModLoadError(listing.id, "the client script doesn't export a setup function"); throw new ModLoadError(bmod.manifest.id, "the client script doesn't export a setup function");
} }
await module.setup(client_context(listing)); await module.setup(client_context(listings[i]));
} }
} }
function script_url(code: string) {
return code_url(new TextEncoder().encode(code));
}
function client_context(listing: ModListing): ClientContext { function client_context(listing: ModListing): ClientContext {
const mod = listing.id; const mod = listing.id;
const not_yet = (name: string, where: string) => const not_yet = (name: string, where: string) =>
@@ -65,9 +75,9 @@ function client_context(listing: ModListing): ClientContext {
throw new Error(`[${mod}] ctx.${name}.${String(prop)} isn't implemented yet (${where} in MODS.md)`); throw new Error(`[${mod}] ctx.${name}.${String(prop)} isn't implemented yet (${where} in MODS.md)`);
}, },
}); });
const need_world = () => { const need_client = () => {
if (!world) throw new Error(`[${mod}] the world isn't there yet during setup`); if (!client) throw new Error(`[${mod}] the world isn't there yet during setup`);
return world; return client;
}; };
return { return {
@@ -78,20 +88,22 @@ function client_context(listing: ModListing): ClientContext {
net: not_yet("net", "step 8") as ClientContext["net"], net: not_yet("net", "step 8") as ClientContext["net"],
player: { player: {
get name() { get name() {
return need_world().connection.name; return need_client().connection.name;
}, },
get position() { get position() {
const [player] = need_world().get_tag("player")!; const { x, y, z } = need_client().player;
const position = player.get(Position)!; return { x, y, z };
return { x: position.x, y: position.y, z: position.z };
}, },
}, },
world: { world: {
get_block(x, y, z) { get_block(x, y, z) {
const nid = need_world().dimension.get_block(x, y, z); const nid = need_client().level.get_block(x, y, z);
if (nid === AIR) return AIR_ID; if (nid === AIR) return AIR_ID;
return EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid)?.id; return EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid)?.id;
}, },
get time() {
return need_client().level.time;
},
}, },
log: (...args) => console.log(`[${mod}]`, ...args), log: (...args) => console.log(`[${mod}]`, ...args),
}; };
+13 -47
View File
@@ -1,15 +1,7 @@
import { BlockChange, ClientMessage, PlayerInfo, ServerMessage } from "$/common/protocol.ts"; import { BlockChange, ClientMessage, EntityInfo, PlayerInfo, ServerMessage } from "$/common/protocol.ts";
import type { ModListing } from "$/common/mod_loader.ts"; import type { ModListing } from "$/common/mod_loader.ts";
import type { Join, ServerSocket, Welcome } from "./handshake.ts"; import type { Join, ServerSocket, Welcome } from "./handshake.ts";
export interface RemotePlayer extends PlayerInfo {
// where we draw them, eased towards x/y/z so movement isnt choppy
display_x: number;
display_y: number;
display_z: number;
color: [number, number, number];
}
export class Connection { export class Connection {
#server: ServerSocket; #server: ServerSocket;
id: string; id: string;
@@ -19,7 +11,10 @@ export class Connection {
initial_changes: BlockChange[]; initial_changes: BlockChange[];
spawn: { x: number; y: number; z: number; yaw: number; pitch: number }; spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
selected_slot: number; selected_slot: number;
players = new Map<string, RemotePlayer>(); time: 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) { constructor(server: ServerSocket, welcome: Welcome, join: Join) {
this.#server = server; this.#server = server;
@@ -30,12 +25,12 @@ export class Connection {
this.initial_changes = join.changes; this.initial_changes = join.changes;
this.spawn = join.spawn; this.spawn = join.spawn;
this.selected_slot = join.selected_slot; this.selected_slot = join.selected_slot;
for (const player of join.players) { this.time = join.time;
this.add_player(player); this.initial_players = join.players;
} this.initial_entities = join.entities;
} }
// handled by the network system inside the game loop, not whenever the socket feels like it // handled by the packet listener inside the game loop, not whenever the socket feels like it
get incoming(): ServerMessage[] { get incoming(): ServerMessage[] {
return this.#server.messages; return this.#server.messages;
} }
@@ -48,41 +43,12 @@ export class Connection {
this.#server.send(message); this.#server.send(message);
} }
add_player(player: PlayerInfo) { close() {
this.players.set(player.id, { this.#server.close();
...player,
display_x: player.x,
display_y: player.y,
display_z: player.z,
color: color_from_name(player.name),
});
} }
} }
function color_from_name(name: string): [number, number, number] { // what the name field starts as, from ?name=
let hash = 0; export function default_player_name(): string {
for (const ch of name) {
hash = (hash * 31 + ch.charCodeAt(0)) | 0;
}
const hue = ((hash % 360) + 360) % 360;
// hsl with s=0.6 l=0.6 to rgb
const c = 0.48;
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
const m = 0.36;
const [r, g, b] = hue < 60
? [c, x, 0]
: hue < 120
? [x, c, 0]
: hue < 180
? [0, c, x]
: hue < 240
? [0, x, c]
: hue < 300
? [x, 0, c]
: [c, 0, x];
return [r + m, g + m, b + m];
}
export function get_player_name(): string {
return new URLSearchParams(location.search).get("name") ?? ""; 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",
];
}
+122
View File
@@ -0,0 +1,122 @@
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 "time":
level.time = message.time;
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;
}
}
}
-51
View File
@@ -1,51 +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 { ClientInventories } from "./inventory.ts";
import { PlayerControls } from "$/client/components/player_controls.ts";
import { ClientWorld } from "./client_world.ts";
import { GuiScreen } from "./gui/gui_screen.ts";
import { CollisionCuboid } from "./components/collision.ts";
export class PlayerComponent extends Component {
inventories = new ClientInventories();
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");
const spawn = world.connection.spawn;
player.add(new Position(spawn.x, spawn.y, spawn.z));
player.add(new Velocity(0, 0, 0));
player.add(new PlayerControls());
const player_component = player.add(new PlayerComponent());
player_component.inventories.hotbar_selected = world.connection.selected_slot;
const camera = player.add(new Camera());
camera.yaw = spawn.yaw;
camera.pitch = spawn.pitch;
player.add(new CollisionCuboid(0.55, 1.79, 0.55));
world.add_entity(player);
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;
}
+279 -16
View File
@@ -1,5 +1,7 @@
import { Camera } from "../components/camera.ts"; import type { Camera } from "../camera.ts";
import { mat4 } from "gl-matrix"; import { mat4 } from "gl-matrix";
import type { RenderLayer } from "$/common/everything_registry.ts";
import { TERRAIN_VERTEX_BYTES } from "../workers/chunk_messages.ts";
export let device: GPUDevice; export let device: GPUDevice;
export let canvas: HTMLCanvasElement; export let canvas: HTMLCanvasElement;
@@ -20,9 +22,16 @@ let canvas_format: GPUTextureFormat;
let pipeline_2d: GPURenderPipeline; let pipeline_2d: GPURenderPipeline;
let pipeline_3d: 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 uniform_layout: GPUBindGroupLayout;
let texture_layout: GPUBindGroupLayout; let texture_layout: GPUBindGroupLayout;
let sampler: GPUSampler; 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); const vertex_data = new Float32Array(MAX_SPRITES * VERTS_PER_SPRITE * FLOATS_PER_VERT);
let vert_index = 0; let vert_index = 0;
@@ -73,8 +82,10 @@ struct Uniforms {
struct VertexOut { struct VertexOut {
@builtin(position) position: vec4<f32>, @builtin(position) position: vec4<f32>,
@location(0) tex_coord: vec2<f32>, // centroid: with msaa, pixels on a triangle's edge would otherwise sample outside it, past the
@location(1) color: vec4<f32>, // 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 @vertex
@@ -90,9 +101,81 @@ fn vs_main(
return out; return out;
} }
// blended draws. fully clear texels don't write depth, or they would hide what's drawn behind them later
@fragment @fragment
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> { fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
return textureSample(texture0, sampler0, in.tex_coord) * in.color; let color = textureSample(texture0, sampler0, in.tex_coord) * in.color;
if (color.a < 0.01) {
discard;
}
return color;
}
`;
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;
} }
`; `;
@@ -136,6 +219,47 @@ export async function init_window(canvas_element: HTMLCanvasElement) {
create_uniform_buffer(64); create_uniform_buffer(64);
create_white_texture(); 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() { export function begin_drawing() {
@@ -251,11 +375,59 @@ export function flush_batch() {
vert_index = 0; vert_index = 0;
} }
export function flush_buffer(buffer: GPUBuffer, draw_count: number) { // what's bound for terrain draws in the current pass, so drawing hundreds of chunks doesn't bind the same things
draw(buffer, 0, draw_count); // again for each one. cleared whenever the pipeline changes
let terrain_binds: { slot: number; texture: GPUTexture; index_buffer: GPUBuffer } | undefined;
// draws quads first to first + quad_count of a chunk mesh (4 vertices each). without an index buffer the quads are
// drawn in order
export function draw_terrain(
layer: RenderLayer,
vertex_buffer: GPUBuffer,
first: number,
quad_count: number,
index_buffer?: GPUBuffer,
) {
if (!current_texture || quad_count === 0) {
return;
}
if (!index_buffer) {
ensure_quad_indices(first + quad_count);
index_buffer = quad_index_buffer!;
}
const render_pass = ensure_pass();
const pipeline = terrain_pipelines[layer];
if (current_pipeline !== pipeline) {
render_pass.setPipeline(pipeline);
current_pipeline = pipeline;
terrain_binds = undefined;
}
if (!terrain_binds) {
render_pass.setBindGroup(2, lightmap_bind_group);
}
if (terrain_binds?.slot !== uniform_slot) {
render_pass.setBindGroup(0, uniform_bind_group, [uniform_slot * UNIFORM_SLOT_SIZE]);
}
if (terrain_binds?.texture !== current_texture) {
render_pass.setBindGroup(1, get_texture_bind_group(current_texture));
}
if (terrain_binds?.index_buffer !== index_buffer) {
render_pass.setIndexBuffer(index_buffer, "uint32");
}
terrain_binds = { slot: uniform_slot, texture: current_texture, index_buffer };
render_pass.setVertexBuffer(0, vertex_buffer);
render_pass.drawIndexed(quad_count * 6, 1, first * 6);
} }
export function create_vertex_buffer(vertices: Float32Array): GPUBuffer { // the camera's view and projection, for frustum culling. only meaningful in 3d mode
export function view_projection(): Readonly<Float32Array> {
return mvp as Float32Array;
}
export function create_vertex_buffer(vertices: Float32Array<ArrayBuffer> | Uint8Array<ArrayBuffer>): GPUBuffer {
const buffer = device.createBuffer({ const buffer = device.createBuffer({
size: Math.max(4, vertices.byteLength), size: Math.max(4, vertices.byteLength),
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
@@ -264,7 +436,16 @@ export function create_vertex_buffer(vertices: Float32Array): GPUBuffer {
return buffer; return buffer;
} }
export function destroy_vertex_buffer(buffer: GPUBuffer) { 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 // it might be used by a draw thats not submitted yet
if (encoder) { if (encoder) {
pending_destroy.push(buffer); pending_destroy.push(buffer);
@@ -375,6 +556,28 @@ export function push_quad_vertices(
// internal // 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) { function draw(buffer: GPUBuffer, offset: number, vertex_count: number) {
if (!current_texture || vertex_count === 0) { if (!current_texture || vertex_count === 0) {
return; return;
@@ -417,6 +620,7 @@ function ensure_pass(): GPURenderPassEncoder {
pending_color_clear = false; pending_color_clear = false;
pending_depth_clear = false; pending_depth_clear = false;
current_pipeline = undefined; current_pipeline = undefined;
terrain_binds = undefined;
apply_scissor(pass); apply_scissor(pass);
return pass; return pass;
@@ -554,21 +758,61 @@ function create_pipelines() {
depthStencil: { format: DEPTH_FORMAT, depthWriteEnabled: false, depthCompare: "always" }, depthStencil: { format: DEPTH_FORMAT, depthWriteEnabled: false, depthCompare: "always" },
}); });
pipeline_3d = device.createRenderPipeline({ const primitive_3d: GPUPrimitiveState = { topology: "triangle-list", cullMode: "back", frontFace: "ccw" };
layout, const depth_3d: GPUDepthStencilState = {
vertex,
fragment,
multisample,
primitive: { topology: "triangle-list", cullMode: "back", frontFace: "ccw" },
depthStencil: {
format: DEPTH_FORMAT, format: DEPTH_FORMAT,
depthWriteEnabled: true, depthWriteEnabled: true,
depthCompare: "less-equal", depthCompare: "less-equal",
// same as the old polygonOffset(1, 1) // same as the old polygonOffset(1, 1)
depthBias: 1, depthBias: 1,
depthBiasSlopeScale: 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_BYTES,
attributes: [
{ shaderLocation: 0, offset: 0, format: "float32x3" },
{ shaderLocation: 1, offset: 12, format: "unorm16x2" },
{ shaderLocation: 2, offset: 16, format: "unorm8x4" },
{ shaderLocation: 3, offset: 20, format: "unorm8x2" },
],
}],
};
// 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_stream_buffer(size: number) { function create_stream_buffer(size: number) {
@@ -601,6 +845,25 @@ function get_texture_bind_group(texture: GPUTexture) {
return bind_group; return bind_group;
} }
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() { function create_white_texture() {
const tex = create_texture(1, 1); const tex = create_texture(1, 1);
device.queue.writeTexture({ texture: tex }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]); device.queue.writeTexture({ texture: tex }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]);
+10 -2
View File
@@ -11,8 +11,12 @@ export function init_font() {
font_fnt = parse_fnt(AssetManager.instance.get("bworld:m6x11_fnt")); font_fnt = parse_fnt(AssetManager.instance.get("bworld:m6x11_fnt"));
} }
// bmfont's text format. glyphs in the image have transparent padding around them, which their offsets don't count, so
// it's taken off here or text is drawn padding * scale pixels right of and below where it's measured
function parse_fnt(text: string): FntFont { function parse_fnt(text: string): FntFont {
const lines = text.split(/\r?\n/); const lines = text.split(/\r?\n/);
// up, right, down, left
let padding = [0, 0, 0, 0];
const font: FntFont = { const font: FntFont = {
line_height: 0, line_height: 0,
@@ -34,6 +38,10 @@ function parse_fnt(text: string): FntFont {
data[k] = v?.replace(/"/g, ""); data[k] = v?.replace(/"/g, "");
} }
if (type === "info" && data.padding) {
padding = data.padding.split(",").map(Number);
}
if (type === "common") { if (type === "common") {
font.line_height = Number(data.lineHeight); font.line_height = Number(data.lineHeight);
} }
@@ -49,8 +57,8 @@ function parse_fnt(text: string): FntFont {
y: Number(data.y), y: Number(data.y),
width: Number(data.width), width: Number(data.width),
height: Number(data.height), height: Number(data.height),
xoffset: Number(data.xoffset), xoffset: Number(data.xoffset) - padding[3],
yoffset: Number(data.yoffset), yoffset: Number(data.yoffset) - padding[0],
xadvance: Number(data.xadvance), xadvance: Number(data.xadvance),
page: Number(data.page), page: Number(data.page),
}; };
+41
View File
@@ -0,0 +1,41 @@
// what the camera can see: the six planes around it, taken from its view projection matrix (gl-matrix's column major
// order, with webgpu's 0 to 1 depth). a box is out of view when it's entirely behind one of them
export class Frustum {
// a, b, c, d per plane, where a * x + b * y + c * z + d >= 0 is the inside
#planes = new Float32Array(24);
update(m: Readonly<Float32Array>) {
const planes = this.#planes;
const set = (i: number, a: number, b: number, c: number, d: number) => {
planes[i * 4] = a;
planes[i * 4 + 1] = b;
planes[i * 4 + 2] = c;
planes[i * 4 + 3] = d;
};
// row r of the matrix is m[r], m[4 + r], m[8 + r], m[12 + r]
set(0, m[3] + m[0], m[7] + m[4], m[11] + m[8], m[15] + m[12]); // left
set(1, m[3] - m[0], m[7] - m[4], m[11] - m[8], m[15] - m[12]); // right
set(2, m[3] + m[1], m[7] + m[5], m[11] + m[9], m[15] + m[13]); // bottom
set(3, m[3] - m[1], m[7] - m[5], m[11] - m[9], m[15] - m[13]); // top
set(4, m[2], m[6], m[10], m[14]); // near, depth 0
set(5, m[3] - m[2], m[7] - m[6], m[11] - m[10], m[15] - m[14]); // far
}
// whether any of the box can be seen. may say yes for boxes just outside a corner, never no for one inside
intersects_box(min_x: number, min_y: number, min_z: number, max_x: number, max_y: number, max_z: number) {
const planes = this.#planes;
for (let i = 0; i < 24; i += 4) {
const a = planes[i];
const b = planes[i + 1];
const c = planes[i + 2];
// the box's corner furthest along the plane's normal
const x = a > 0 ? max_x : min_x;
const y = b > 0 ? max_y : min_y;
const z = c > 0 ? max_z : min_z;
if (a * x + b * y + c * z + planes[i + 3] < 0) {
return false;
}
}
return true;
}
}
+53
View File
@@ -0,0 +1,53 @@
import type { Client } from "$/client/client.ts";
import { begin_mode_3d, clear_background, end_mode_3d, update_lightmap } from "$/client/renderer/mod.ts";
import { daylight } from "$/common/time.ts";
import { Hud } from "$/client/gui/hud.ts";
import { DebugOverlay } from "$/client/gui/debug_overlay.ts";
import { LevelRenderer } from "./level_renderer.ts";
// the sky at noon and in the middle of the night
const DAY_SKY = [0.69, 0.8, 1];
const NIGHT_SKY = [0.02, 0.03, 0.08];
// minecraft's darkest sky: moonlight still lights things a little
const NIGHT_SKY_LIGHT = 0.2;
// 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();
// what the lightmap was last made for, so it's only rebuilt when the light changes
#lightmap_daylight = -1;
// sky color and sky light for the time of day
#setup_sky(client: Client, partial_tick: number) {
const light = daylight(client.level.time + partial_tick);
const [r, g, b] = DAY_SKY.map((day, i) => NIGHT_SKY[i] + (day - NIGHT_SKY[i]) * light);
clear_background(r, g, b, 1);
if (Math.abs(light - this.#lightmap_daylight) > 0.001) {
this.#lightmap_daylight = light;
update_lightmap(NIGHT_SKY_LIGHT + (1 - NIGHT_SKY_LIGHT) * light);
}
}
// 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);
this.#setup_sky(client, 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);
}
}
}
+133
View File
@@ -0,0 +1,133 @@
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";
import { block_item_texture, cube_face_texture } from "$/common/block_models.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);
// blocks that aren't cubes, like plants, are drawn as their item's sprite
let block_info = item_info?.block_id
? EverythingRegistry.get<BlockRegistry>("blocks", item_info.block_id)
: undefined;
if (block_info && block_item_texture(block_info) !== undefined) block_info = 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(cube_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 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";
}
+193
View File
@@ -0,0 +1,193 @@
import { TEXTURE_SIZE } from "$/common/constants.ts";
import { type Chunk, CHUNK_SIZE, type ChunkMesh, 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,
view_projection,
white_tex,
} from "$/client/renderer/mod.ts";
import type { RenderLayer } from "$/common/everything_registry.ts";
import { FACE_GROUPS, UNALIGNED_GROUP } from "$/client/workers/chunk_messages.ts";
import { FACE_AXIS, FACE_NORMALS } from "$/client/workers/translucent_sort.ts";
import { Frustum } from "./frustum.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 {
frustum = new Frustum();
// chunks in view this frame, worked out once for all the layers
#visible: Chunk[] = [];
// solid and cutout terrain, drawn before entities
render_opaque(level: ClientLevel, camera: Camera) {
level.request_meshes(camera);
level.update_translucent_sorting(camera);
this.frustum.update(view_projection());
this.#visible.length = 0;
for (const chunk of level.chunks.values()) {
if (Object.keys(chunk.meshes).length > 0 && this.#in_view(chunk)) {
this.#visible.push(chunk);
}
}
set_current_texture(level.image.tex);
for (const layer of ["solid", "cutout"] as const) {
for (const chunk of this.#visible) {
const mesh = chunk.meshes[layer];
if (mesh) {
draw_facing_camera(layer, mesh, camera);
}
}
}
}
// 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 = this.#visible
.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);
for (const { mesh } of chunks) {
draw_terrain("translucent", mesh.vertex_buffer, 0, mesh.quad_count, mesh.index_buffer);
}
}
#in_view(chunk: Chunk) {
const x = chunk.x * CHUNK_SIZE;
const z = chunk.z * CHUNK_SIZE;
return this.frustum.intersects_box(x, chunk.min_y, z, x + CHUNK_SIZE, chunk.max_y, z + CHUNK_SIZE);
}
// 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,
);
}
}
}
// only the face groups that can face the camera: quads facing +x can only be seen from beyond the lowest x plane any
// of them lie on. neighboring groups that are both drawn go in one draw call
function draw_facing_camera(layer: RenderLayer, mesh: ChunkMesh, camera: Camera) {
const groups = mesh.groups;
if (!groups) {
draw_terrain(layer, mesh.vertex_buffer, 0, mesh.quad_count);
return;
}
let start = 0;
let end = 0;
for (let g = 0; g < FACE_GROUPS; g++) {
const group = groups[g];
if (group.count === 0 || !group_faces_camera(g, group.min, group.max, camera)) {
continue;
}
if (group.first !== end) {
draw_terrain(layer, mesh.vertex_buffer, start, end - start);
start = group.first;
}
end = group.first + group.count;
}
draw_terrain(layer, mesh.vertex_buffer, start, end - start);
}
export function group_faces_camera(
group: number,
min: number,
max: number,
camera: { x: number; y: number; z: number },
) {
if (group === UNALIGNED_GROUP) {
return true;
}
const axis = FACE_AXIS[group];
const position = axis === 0 ? camera.x : axis === 1 ? camera.y : camera.z;
return FACE_NORMALS[group][axis] > 0 ? position > min : position < max;
}
// 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);
}
@@ -3,6 +3,7 @@ import { get_sprite_region } from "$/client/sprites.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { AssetManager } from "$/client/assets.ts"; import { AssetManager } from "$/client/assets.ts";
import { ItemStack } from "$/common/inventory.ts"; import { ItemStack } from "$/common/inventory.ts";
import { block_item_texture, cube_face_texture } from "$/common/block_models.ts";
import { draw_text, draw_texture_region, draw_texture_region_skewed, Texture } from "$/client/renderer/mod.ts"; import { draw_text, draw_texture_region, draw_texture_region_skewed, Texture } from "$/client/renderer/mod.ts";
export function draw_nine_slice( export function draw_nine_slice(
@@ -206,8 +207,12 @@ export function draw_item(item: ItemStack, x: number, y: number) {
throw Error(`Didn't find item registry for '${item.type_id}'`); throw Error(`Didn't find item registry for '${item.type_id}'`);
} }
if (item_info?.block_id) { const block_info = item_info.block_id
draw_item_block(item, item_info, x, y); ? EverythingRegistry.get<BlockRegistry>("blocks", item_info.block_id)
: undefined;
// blocks that aren't cubes, like plants, show a flat sprite
if (block_info && block_item_texture(block_info) === undefined) {
draw_item_block(block_info, x, y);
} else { } else {
draw_item_item(item, item_info, x, y); draw_item_item(item, item_info, x, y);
} }
@@ -251,30 +256,10 @@ function draw_item_item(item: ItemStack, item_info: ItemRegistry, x: number, y:
); );
} }
function draw_item_block(_item: ItemStack, item_info: ItemRegistry, x: number, y: number) { function draw_item_block(block_info: BlockRegistry, x: number, y: number) {
const block_info = EverythingRegistry.get<BlockRegistry>("blocks", item_info.block_id!); const top_texture = cube_face_texture(block_info, "top");
const front_texture = cube_face_texture(block_info, "front");
if (!block_info) throw new Error(`no textures for ${item_info.block_id}`); const left_texture = cube_face_texture(block_info, "side");
let front_texture = "engine:missing";
let top_texture = "engine:missing";
let left_texture = "engine:missing";
const textures = block_info.textures;
if (typeof textures === "string") {
front_texture = textures;
top_texture = textures;
left_texture = textures;
} else if ("top" in textures && "bottom" in textures && "side" in textures) {
top_texture = textures.top;
front_texture = textures.side;
left_texture = textures.side;
} else if ("front" in textures && "side" in textures) {
top_texture = textures.side;
front_texture = textures.front;
left_texture = textures.side;
}
const atlas = AssetManager.instance.get<Texture>("bworld:textures"); const atlas = AssetManager.instance.get<Texture>("bworld:textures");
-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();
}
}
}
-122
View File
@@ -1,122 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { Position } from "$/common/components/position.ts";
import { ClientWorld } from "../client_world.ts";
import { Camera } from "../components/camera.ts";
import { Container, ItemStack } from "$/common/inventory.ts";
import { PlayerComponent } from "../player.ts";
import { GuiContainer } from "../gui/gui_container.ts";
import { show_fatal_error } from "../fatal.ts";
const MOVE_SEND_INTERVAL = 1 / 10;
const REMOTE_PLAYER_SMOOTHING = 12;
export class NetworkSystem extends System {
move_timer = 0;
update(world: ClientWorld, delta: number): void {
const connection = world.connection;
const [local_player] = world.get_tag("player")!;
const player_component = local_player.get(PlayerComponent)!;
const inventories = player_component.inventories;
for (const message of connection.incoming) {
switch (message.type) {
case "player_join":
connection.add_player(message.player);
break;
case "player_leave":
connection.players.delete(message.id);
break;
case "player_move": {
const player = connection.players.get(message.id);
if (player) {
player.x = message.x;
player.y = message.y;
player.z = message.z;
player.yaw = message.yaw;
player.pitch = message.pitch;
}
break;
}
case "set_block":
world.dimension.record_change(message.x, message.y, message.z, message.id, message.state);
world.dimension.apply_change(message.x, message.y, message.z, message.id, message.state);
break;
case "chat":
world.add_chat(message.from ? `<${message.from}> ${message.text}` : message.text);
break;
case "container": {
let container = message.container === "screen"
? inventories.screen
: inventories[message.container];
if (message.container === "screen" && container?.size !== message.items.length) {
container = inventories.screen = new Container(message.items.length);
}
container?.load(message.items);
break;
}
case "cursor":
inventories.cursor.item = message.item ? ItemStack.from_data(message.item) : undefined;
break;
case "open_screen": {
// replace anything open locally without telling the server, it just opened this one
player_component.screens.length = 0;
const size = Math.max(0, ...message.layout.slots.map((slot) => slot.index + 1));
inventories.screen = new Container(size);
player_component.screens.push(
new GuiContainer(inventories, (m) => connection.send(m), message.layout, message.properties),
);
break;
}
case "screen_properties": {
const screen = player_component.screens.at(-1);
if (screen instanceof GuiContainer) {
screen.properties = message.properties;
}
break;
}
case "teleport": {
const position = local_player.get(Position)!;
position.x = message.x;
position.y = message.y;
position.z = message.z;
break;
}
case "close_screen":
// closed by the server (the block broke), it already put everything back
player_component.screens = player_component.screens.filter((s) => !(s instanceof GuiContainer));
inventories.screen = undefined;
break;
}
}
connection.incoming.length = 0;
if (connection.closed) {
show_fatal_error("Lost connection to the server");
return;
}
const t = Math.min(1, delta * REMOTE_PLAYER_SMOOTHING);
for (const player of connection.players.values()) {
player.display_x += (player.x - player.display_x) * t;
player.display_y += (player.y - player.display_y) * t;
player.display_z += (player.z - player.display_z) * t;
}
this.move_timer += delta;
if (this.move_timer >= MOVE_SEND_INTERVAL) {
this.move_timer = 0;
const [player] = world.get_tag("player")!;
const position = player.get(Position)!;
const camera = player.get(Camera)!;
connection.send({
type: "move",
x: position.x,
y: position.y,
z: position.z,
yaw: camera.yaw,
pitch: camera.pitch,
});
}
}
}
-205
View File
@@ -1,205 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { Velocity } from "$/common/components/velocity.ts";
import { InputManager } from "../input_manager.ts";
import { ClientWorld } from "../client_world.ts";
import { PlayerControls } from "../components/player_controls.ts";
import { Camera } from "../components/camera.ts";
import { Position } from "../../common/components/position.ts";
import { PlayerComponent } from "../player.ts";
import { GuiPlayerInventory } from "../gui/gui_player_inventory.ts";
import { CollisionCuboid } from "../components/collision.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { AIR, FACE_OFFSETS } from "$/common/constants.ts";
import { ClientMessage } from "$/common/protocol.ts";
import { GuiChat } from "../gui/gui_chat.ts";
import { GuiInventoryScreen } from "../gui/gui_screen.ts";
export class PlayerControlsSystem extends System {
constructor() {
super();
}
update(world: ClientWorld, delta: number): void {
const [player] = world.get_tag("player")!;
const velocity = player.get(Velocity)!;
const controls = player.get(PlayerControls)!;
const player_component = player.get(PlayerComponent)!;
const position = player.get(Position)!;
const camera = player.get(Camera)!;
const send = (message: ClientMessage) => world.connection.send(message);
if (player_component.screens.length === 0) {
let input_x = 0;
let input_z = 0;
if (InputManager.is_key_down(controls.move_left)) {
input_x -= 1;
}
if (InputManager.is_key_down(controls.move_right)) {
input_x += 1;
}
if (InputManager.is_key_down(controls.move_forward)) {
input_z -= 1;
}
if (InputManager.is_key_down(controls.move_backwards)) {
input_z += 1;
}
const size = Math.hypot(input_x, input_z);
if (size > 0) {
input_x /= size;
input_z /= size;
}
const sin = Math.sin(camera.yaw);
const cos = Math.cos(camera.yaw);
const forwardX = sin;
const forwardZ = cos;
const rightX = cos;
const rightZ = -sin;
const speed_modifier = InputManager.is_key_down(controls.sprint_key) ? 1.75 : 1;
velocity.vx = (forwardX * input_z + rightX * input_x) * controls.move_speed * speed_modifier;
velocity.vz = (forwardZ * input_z + rightZ * input_x) * controls.move_speed * speed_modifier;
const cuboid = player.get(CollisionCuboid);
if (InputManager.is_key_down("Space") && cuboid?.colliding_y === 1) {
velocity.vy += controls.jump_force;
}
}
if (InputManager.is_key_pressed(controls.open_inventory)) {
if (player_component.screens.length === 0) {
player_component.screens.push(new GuiPlayerInventory(player_component.inventories, send));
} else if (player_component.screens.at(-1) instanceof GuiInventoryScreen) {
player_component.pop_screen();
}
}
if (InputManager.is_key_pressed(controls.open_chat)) {
if (player_component.screens.length === 0) {
player_component.screens.push(new GuiChat(world));
}
}
if (InputManager.is_key_pressed("Escape")) {
player_component.pop_screen();
}
if (InputManager.is_key_pressed(controls.open_debug)) {
world.debugging = !world.debugging;
}
if (InputManager.is_key_pressed("F11")) {
InputManager.toggle_fullscreen();
}
camera.x = position.x;
camera.y = position.y + 1.69;
camera.z = position.z;
if (InputManager.is_mouse_grabbed()) {
const mouse_delta = InputManager.get_mouse_delta();
camera.yaw += -mouse_delta.x * 0.001;
camera.pitch += -mouse_delta.y * 0.001;
const limit = Math.PI / 2 - 0.01;
camera.pitch = Math.max(-limit, Math.min(limit, camera.pitch));
}
InputManager.set_mouse_grabbed(player_component.screens.length === 0);
if (InputManager.is_mouse_down(0) && player_component.screens.length === 0) {
player_component.breaking_block = { x: 0, y: 9999, z: 0 };
} else {
player_component.breaking_block = undefined;
player_component.break_progress_max = 0;
player_component.break_progress = 0;
}
const block = world.dimension.get_looked_block(world.dimension, camera);
const inventories = player_component.inventories;
const hotbar_slot = inventories.inventory.get_slot(inventories.hotbar_selected);
const holding_item_info = EverythingRegistry.get<ItemRegistry>("items", hotbar_slot.type_id ?? "");
if (block && player_component.screens.length === 0) {
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", block.block)!;
if (InputManager.is_mouse_pressed(0)) {
// for mods' on_click, breaking itself is timed here and sent when done
send({ type: "hit_block", x: block.x, y: block.y, z: block.z });
}
if (player_component.breaking_block) {
player_component.breaking_block = { x: block.x, y: block.y, z: block.z };
player_component.break_progress_max = block_info.toughness ?? 9999;
let multiplier = 1;
if (holding_item_info?.tool_type === block_info.tool_to_break) {
multiplier *= 2;
}
player_component.break_progress += delta * multiplier;
if (player_component.break_progress >= player_component.break_progress_max) {
// show it right away, the server decides drops and corrects us if it disagrees
world.dimension.break_block(block.x, block.y, block.z);
send({ type: "break_block", x: block.x, y: block.y, z: block.z });
player_component.break_progress_max = 0;
player_component.break_progress = 0;
}
} else if (InputManager.is_mouse_pressed(2)) {
send({ type: "use_block", x: block.x, y: block.y, z: block.z, face: block.face });
// guess that it places the held block, unless the block does something when used
const offset = FACE_OFFSETS[block.face];
const target = { x: block.x + offset.x, y: block.y + offset.y, z: block.z + offset.z };
const target_id = world.dimension.get_block(target.x, target.y, target.z);
const replaceable = target_id === AIR ||
EverythingRegistry.get_by_id<BlockRegistry>("blocks", target_id)?.replaceable;
// items with components might do something else on the server, like on_use
const place_id = holding_item_info?.components ? undefined : holding_item_info?.block_id;
if (!block_info.interactive && place_id && replaceable) {
world.dimension.add_block({ ...target, id: place_id });
hotbar_slot.amount = hotbar_slot.amount! - 1;
}
}
} else {
player_component.breaking_block = undefined;
player_component.break_progress_max = 0;
player_component.break_progress = 0;
if (!block && player_component.screens.length === 0 && InputManager.is_mouse_pressed(2)) {
send({ type: "use_item" });
}
}
if (player_component.screens.length === 0) {
const 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 hotbar_keys = [
controls.hotbar_1,
controls.hotbar_2,
controls.hotbar_3,
controls.hotbar_4,
controls.hotbar_5,
controls.hotbar_6,
controls.hotbar_7,
controls.hotbar_8,
controls.hotbar_9,
];
const pressed = hotbar_keys.findIndex((key) => InputManager.is_key_pressed(key));
if (pressed !== -1) {
inventories.hotbar_selected = pressed;
}
if (inventories.hotbar_selected !== previous) {
send({ type: "select_slot", slot: inventories.hotbar_selected });
}
}
}
}
-71
View File
@@ -1,71 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { Position } from "$/common/components/position.ts";
import { AnimatedSprite, Sprite } from "$/client/components/sprite.ts";
import { Dimension } from "../components/dimension.ts";
import { Camera } from "$/client/components/camera.ts";
import { render_animated_sprite, render_sprite } from "./rendering/sprites.ts";
import { render_dimension } from "./rendering/dimension.ts";
import { render_player_breaking, render_player_crosshair, render_player_hotbar } from "./rendering/player.ts";
import { PlayerComponent } from "../player.ts";
import { begin_mode_3d, end_mode_3d } from "../renderer/core.ts";
import { ClientWorld } from "../client_world.ts";
import { render_chat_log, render_remote_players } from "./rendering/network.ts";
export class RenderSystem extends System {
constructor() {
super();
}
update(world: ClientWorld, _delta: number): void {
const camera_entity = world.get_entities().values().find((e) => e.get(Camera));
const camera = camera_entity?.get(Camera);
if (!camera) {
return;
}
begin_mode_3d(camera);
for (const entity of world.get_entities()) {
const dimension = entity.get(Dimension);
if (dimension) {
render_dimension(dimension, camera);
}
const player_component = entity.get(PlayerComponent);
if (player_component) {
render_player_breaking(player_component);
}
}
if (world.connection) {
render_remote_players(world.connection);
}
end_mode_3d();
for (const entity of world.get_entities()) {
const position = entity.get(Position);
const sprite = entity.get(Sprite);
if (position && sprite) {
render_sprite(sprite, position);
}
const animated_sprite = entity.get(AnimatedSprite);
if (position && animated_sprite) {
render_animated_sprite(animated_sprite, position);
}
const player_component = entity.get(PlayerComponent);
if (player_component) {
render_player_hotbar(player_component.inventories);
render_player_crosshair();
}
}
render_chat_log(world, false);
}
}
-33
View File
@@ -1,33 +0,0 @@
import { Chunk, Dimension } from "$/client/components/dimension.ts";
import { Camera } from "$/client/components/camera.ts";
import { flush_buffer, set_current_texture } from "$/client/renderer/mod.ts";
export function render_dimension(dimension: Dimension, _camera: Camera) {
dimension.request_meshes();
set_current_texture(dimension.image.tex);
for (const chunk of dimension.chunks.values()) {
render_chunk_opaque(chunk);
}
for (const chunk of dimension.chunks.values()) {
render_chunk_transparent(chunk);
}
}
function render_chunk_opaque(chunk: Chunk) {
if (!chunk.opaque_vertex_buffer || !chunk.opaque_vertex_count) {
return;
}
flush_buffer(chunk.opaque_vertex_buffer, chunk.opaque_vertex_count);
}
function render_chunk_transparent(chunk: Chunk) {
if (!chunk.transparent_vertex_buffer || !chunk.transparent_vertex_count) {
return;
}
flush_buffer(chunk.transparent_vertex_buffer, chunk.transparent_vertex_count);
}
-53
View File
@@ -1,53 +0,0 @@
import { ClientWorld } from "$/client/client_world.ts";
import { Connection } from "$/client/network.ts";
import {
canvas,
draw_rect,
draw_text,
flush_batch,
push_box,
set_current_texture,
white_tex,
} from "$/client/renderer/mod.ts";
const CHAT_LINE_HEIGHT = 24;
const CHAT_VISIBLE_SECONDS = 10;
const CHAT_MAX_LINES = 10;
export function render_remote_players(connection: Connection) {
if (connection.players.size === 0) {
return;
}
flush_batch();
set_current_texture(white_tex!);
for (const player of connection.players.values()) {
const [r, g, b] = player.color;
const x = player.display_x;
const y = player.display_y;
const z = player.display_z;
// body
push_box(x - 0.3, y, z - 0.15, 0.6, 1.3, 0.3, r, g, b);
// head
push_box(x - 0.25, y + 1.3, z - 0.25, 0.5, 0.5, 0.5, 0.95, 0.8, 0.65);
}
flush_batch();
}
// draws the chat log above the bottom left corner, `all` shows old messages too (when the chat is open)
export function render_chat_log(world: ClientWorld, all: boolean, bottom = canvas.height - 100) {
const now = performance.now();
const lines = world.chat_log
.filter((line) => all || now - line.time < CHAT_VISIBLE_SECONDS * 1000)
.slice(-CHAT_MAX_LINES);
let y = bottom - lines.length * CHAT_LINE_HEIGHT;
for (const line of lines) {
draw_rect(0, y, 600, CHAT_LINE_HEIGHT, [0, 0, 0, 0.4]);
draw_text(line.text, 4, y, 2, [1, 1, 1, 1]);
y += CHAT_LINE_HEIGHT;
}
}
-120
View File
@@ -1,120 +0,0 @@
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
import { AssetManager } from "$/client/assets.ts";
import { ClientInventories } from "../../inventory.ts";
import { draw_item, draw_nine_slice } from "./render_utils.ts";
import {
canvas,
draw_rect_stroke,
push_back_face,
push_bottom_face,
push_front_face,
push_left_face,
push_right_face,
push_top_face,
Texture,
} from "$/client/renderer/mod.ts";
import { PlayerComponent } from "../../player.ts";
import { get_sprite_region } from "$/client/sprites.ts";
const PADDING = 10;
export function render_player_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) {
draw_nine_slice(
ui,
inventories.hotbar_selected === index ? 19 * 16 : 160 + 32,
inventories.hotbar_selected === index ? 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);
}
}
}
export function render_player_crosshair() {
const CROSSHAIR_SIZE = 8;
draw_rect_stroke(
(canvas.width - CROSSHAIR_SIZE) / 2,
(canvas.height - CROSSHAIR_SIZE) / 2,
CROSSHAIR_SIZE,
CROSSHAIR_SIZE,
[0, 0, 0, 0.6],
);
}
const FACE_FUNCTIONS = [
push_back_face,
push_bottom_face,
push_front_face,
push_left_face,
push_right_face,
push_top_face,
];
export function render_player_breaking(player_component: PlayerComponent) {
const block = player_component.breaking_block;
if (block) {
const tex = AssetManager.instance.get<Texture>("bworld:textures");
const progress = Math.max(
0,
Math.min(1, player_component.break_progress / player_component.break_progress_max),
);
const break_sprite = Math.round(progress * 8);
if (Number.isNaN(break_sprite)) {
return;
}
const region = get_sprite_region(`engine:break_${break_sprite}`);
for (const fn of FACE_FUNCTIONS) {
fn(
tex,
block.x,
block.y,
block.z,
region.x * TEXTURE_SIZE,
region.y * TEXTURE_SIZE,
TEXTURE_SIZE,
TEXTURE_SIZE,
);
}
}
}
-54
View File
@@ -1,54 +0,0 @@
import { Position } from "$/common/components/position.ts";
import { AnimatedSprite, Sprite } from "$/client/components/sprite.ts";
import { draw_texture_region } from "$/client/renderer/mod.ts";
export function render_sprite(sprite: Sprite, position: Position) {
draw_texture_region(
sprite.image,
sprite.source_x,
sprite.source_y,
sprite.source_width,
sprite.source_height,
position.x,
position.y,
sprite.width,
sprite.height,
sprite.flip_x,
sprite.flip_y,
);
}
export function render_animated_sprite(
animated_sprite: AnimatedSprite,
position: Position,
) {
const current_animation = animated_sprite.states[animated_sprite.current_state];
if (!current_animation) {
console.error(`Missing animation for state ${animated_sprite.current_state}`);
return;
}
draw_texture_region(
animated_sprite.image,
current_animation.source_x[animated_sprite.animation_frame],
current_animation.source_y[animated_sprite.animation_frame],
current_animation.source_width,
current_animation.source_height,
position.x,
position.y,
animated_sprite.width,
animated_sprite.height,
animated_sprite.flip_x,
animated_sprite.flip_y,
);
animated_sprite.timer += 1;
if (animated_sprite.timer >= current_animation.duration) {
animated_sprite.timer = 0;
animated_sprite.animation_frame += 1;
if (animated_sprite.animation_frame >= current_animation.source_x.length) {
animated_sprite.animation_frame = 0;
}
}
}
-26
View File
@@ -1,26 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { Position } from "$/common/components/position.ts";
import { point_inside_rec } from "$/common/utils.ts";
import { ClientWorld } from "$/client/client_world.ts";
import { UIButton } from "$/client/components/ui_components.ts";
import { InputManager } from "$/client/input_manager.ts";
export class UIInteractionSystem implements System {
update(world: ClientWorld, _delta: number) {
const mouse = InputManager.get_mouse_position();
for (const entity of world.get_entities()) {
const position = entity.get(Position);
const button = entity.get(UIButton);
if (position && button) {
const hovered = point_inside_rec(mouse.x, mouse.y, position.x, position.y, button.width, button.height);
button.hovered = hovered;
if (hovered && InputManager.is_mouse_pressed(0)) {
InputManager.consume_mouse(0);
button.on_click();
}
}
}
}
}
-49
View File
@@ -1,49 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { Position } from "$/common/components/position.ts";
import { ClientWorld } from "$/client/client_world.ts";
import { UIButton } from "$/client/components/ui_components.ts";
import { draw_nine_slice } from "./rendering/render_utils.ts";
import { AssetManager } from "../assets.ts";
import { draw_text, Texture } from "../renderer/mod.ts";
const SCALE = 4;
export class UIRenderSystem implements System {
constructor() {}
update(world: ClientWorld, _delta: number) {
const ui = AssetManager.instance.get<Texture>("bworld:ui");
for (const entity of world.get_entities()) {
const position = entity.get(Position);
const button = entity.get(UIButton);
if (position && button) {
draw_nine_slice(
ui,
16 * (button.hovered ? 14 : 11),
16 * (button.hovered ? 1 : 0),
16,
16,
3,
3,
3,
3,
position.x / SCALE,
position.y / SCALE,
button.width / SCALE,
button.height / SCALE,
);
// const measure = measure_text(ctx, button.text, 1.25);
draw_text(
button.text,
(position.x / SCALE) + (button.width / SCALE / 2) - (100 / 2),
position.y / SCALE + (button.height / SCALE / 2),
//1.5,
//"white",
//"middle",
);
}
}
}
}
-64
View File
@@ -1,64 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { ClientWorld } from "../client_world.ts";
import { Position } from "../../common/components/position.ts";
import { CHUNK_SIZE } from "../components/dimension.ts";
import { PlayerComponent } from "$/client/player.ts";
export class WorldGenerationSystem extends System {
constructor() {
super();
}
update(world: ClientWorld, _delta: number): void {
const [player] = world.get_tag("player")!;
const position = player.get(Position)!;
const player_component = player.get(PlayerComponent)!;
const dimension = world.dimension;
const player_chunk_x = Math.floor(position.x / CHUNK_SIZE);
const player_chunk_z = Math.floor(position.z / CHUNK_SIZE);
// one extra ring gets generated so the edge of the render distance has neighbors to mesh against
const load_distance = player_component.render_distance + 1;
const out_of_range = (x: number, z: number) =>
Math.max(Math.abs(x - player_chunk_x), Math.abs(z - player_chunk_z)) > load_distance;
// collect first, deleting from the map while iterating it skips entries
const to_unload = [];
for (const chunk of dimension.chunks.values()) {
if (out_of_range(chunk.x, chunk.z)) {
to_unload.push(chunk);
}
}
for (const chunk of to_unload) {
dimension.unload_chunk(chunk.x, chunk.z);
}
for (const pending of [...dimension.pending_generation.values()]) {
if (out_of_range(pending.x, pending.z)) {
dimension.cancel_chunk_request(pending.x, pending.z);
}
}
// dont queue up the whole area at once, so walking somewhere new gets the close chunks first
const max_in_flight = dimension.workers.size * 2;
if (dimension.pending_generation.size >= max_in_flight) {
return;
}
const missing: { x: number; z: number; distance: number }[] = [];
for (let x = player_chunk_x - load_distance; x <= player_chunk_x + load_distance; x += 1) {
for (let z = player_chunk_z - load_distance; z <= player_chunk_z + load_distance; z += 1) {
if (!dimension.is_generated(x, z) && !dimension.is_generating(x, z)) {
const dx = x - player_chunk_x;
const dz = z - player_chunk_z;
missing.push({ x, z, distance: dx * dx + dz * dz });
}
}
}
missing.sort((a, b) => a.distance - b.distance);
for (const chunk of missing.slice(0, max_in_flight - dimension.pending_generation.size)) {
dimension.request_chunk(chunk.x, chunk.z);
}
}
}
+72 -6
View File
@@ -1,15 +1,37 @@
import type { BlockRegistry } from "$/common/everything_registry.ts"; import type { BlockRegistry } from "$/common/everything_registry.ts";
import type { SpriteRegion } from "$/common/constants.ts"; import type { SpriteRegion } from "$/common/constants.ts";
import type { OreJson } from "$/common/mod_data.ts"; import type { OreJson } from "$/common/mod_data.ts";
import type { ModelJson } from "$/common/block_models.ts";
import type { SortType } from "./translucent_sort.ts";
// messages between the main thread and the chunk workers // messages between the main thread and the chunk workers
// a terrain vertex, 24 bytes: position as float32x3, atlas uv as unorm16x2, directional shade times ambient occlusion
// and alpha as unorm8x4 (shade repeated in rgb), and lightmap coordinates as unorm8x2 plus two unused bytes
export const TERRAIN_VERTEX_BYTES = 24;
export const TERRAIN_QUAD_BYTES = 4 * TERRAIN_VERTEX_BYTES;
// solid and cutout quads are grouped by the way they face, so groups facing away from the camera can be skipped, like
// sodium's block face culling. the groups follow FACE_NORMALS' order, then the quads that aren't axis aligned
export const FACE_GROUPS = 7;
export const UNALIGNED_GROUP = 6;
export interface FaceGroup {
first: number;
count: number;
// the lowest and highest plane the group's quads lie on, along its axis
min: number;
max: number;
}
export type ToChunkWorker = export type ToChunkWorker =
| { | {
type: "init"; type: "init";
// the blocks registry without its functions, indexed by numeric id // the blocks registry without its functions, indexed by numeric id
blocks_registry: BlockRegistry[]; blocks_registry: BlockRegistry[];
block_ids: Record<string, number>; block_ids: Record<string, number>;
// mods' block models by id, the engine's are built in
models: Record<string, ModelJson>;
textures_info: Record<string, SpriteRegion>; textures_info: Record<string, SpriteRegion>;
image: { width: number; height: number }; image: { width: number; height: number };
// mods' worldgen scripts and ores, so generation matches the server // mods' worldgen scripts and ores, so generation matches the server
@@ -17,7 +39,33 @@ export type ToChunkWorker =
ores: OreJson[]; ores: OreJson[];
} }
| { type: "generate"; chunk_x: number; chunk_z: number; seed: string } | { type: "generate"; chunk_x: number; chunk_z: number; seed: string }
| { type: "mesh"; chunk_x: number; chunk_z: number; version: number; padded_chunk: Uint32Array }; | {
type: "mesh";
chunk_x: number;
chunk_z: number;
version: number;
// copies of the blocks of the 3x3 chunks around it, going +x then +z from -x -z, for lighting
chunks: (Uint32Array | null)[];
// where the camera is, to sort the translucent quads
camera: number[];
}
| {
type: "sort";
chunk_x: number;
chunk_z: number;
version: number;
sort_version: number;
centers: Float32Array;
camera: number[];
};
// 4 vertices per quad, TERRAIN_VERTEX_BYTES each
export interface LayerMesh {
vertices: Uint8Array<ArrayBuffer>;
quad_count: number;
// solid and cutout only, see FACE_GROUPS. the quads are stored group after group
groups?: FaceGroup[];
}
export type FromChunkWorker = export type FromChunkWorker =
| { | {
@@ -25,7 +73,7 @@ export type FromChunkWorker =
chunk_x: number; chunk_x: number;
chunk_z: number; chunk_z: number;
blocks: Uint32Array; blocks: Uint32Array;
// blocks that landed in other chunks (tree leaves), flattened as x, y, z, numeric id // blocks that landed in other chunks (from features like a mod's trees), flattened as x, y, z, numeric id
spills: Int32Array; spills: Int32Array;
} }
| { | {
@@ -33,8 +81,26 @@ export type FromChunkWorker =
chunk_x: number; chunk_x: number;
chunk_z: number; chunk_z: number;
version: number; version: number;
opaque_vertices: Float32Array; // the lowest and highest y of the chunk's quads, for frustum culling
opaque_count: number; min_y: number;
transparent_vertices: Float32Array; max_y: number;
transparent_count: number; solid: LayerMesh;
cutout: LayerMesh;
translucent: LayerMesh & {
// sorted back to front for the camera the mesh was requested with
indices: Uint32Array;
sort_type: SortType;
// what resorting needs, see translucent_sort.ts
centers: Float32Array;
planes: [Float32Array, Float32Array, Float32Array];
};
camera: number[];
}
| {
type: "sorted";
chunk_x: number;
chunk_z: number;
version: number;
sort_version: number;
indices: Uint32Array;
}; };
+475 -385
View File
@@ -1,271 +1,116 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
import type { BlockRegistry } from "$/common/everything_registry.ts"; import {
import type { SpriteRegion } from "$/common/constants.ts"; block_light_emission,
block_light_opacity,
type BlockRegistry,
type RenderLayer,
} from "$/common/everything_registry.ts";
import {
AIR,
CHUNK_AREA,
CHUNK_HEIGHT,
CHUNK_SIZE,
ID_MASK,
type SpriteRegion,
TEXTURE_SIZE,
} from "$/common/constants.ts";
import type { Texture } from "../renderer/types.ts"; import type { Texture } from "../renderer/types.ts";
import type { FromChunkWorker, ToChunkWorker } from "./chunk_messages.ts"; import {
FACE_GROUPS,
type FaceGroup,
type FromChunkWorker,
TERRAIN_QUAD_BYTES,
TERRAIN_VERTEX_BYTES,
type ToChunkWorker,
UNALIGNED_GROUP,
} from "./chunk_messages.ts";
import { generate_raw_chunk, WorldgenSetup } from "$/common/generation.ts"; import { generate_raw_chunk, WorldgenSetup } from "$/common/generation.ts";
import { load_worldgen } from "$/common/worldgen_loader.ts"; import { load_worldgen } from "$/common/worldgen_loader.ts";
import { default_block_value } from "$/common/utils.ts"; import { default_block_value, get_state_value } from "$/common/utils.ts";
import { bake_model, block_variant, FACE_CORNERS, find_model, type ModelJson } from "$/common/block_models.ts";
const pad = 0.5; import {
choose_sort_type,
function push_vertex( FACE_AXIS,
vertices: Float32Array, FACE_NORMALS,
i: number, quad_indices,
px: number, quad_planes,
py: number, sort_by_distance,
pz: number, sort_quads,
u: number, } from "./translucent_sort.ts";
v: number, import {
r: number, LightRegion,
g: number, type LightTables,
b: number, region_block,
a: number, region_block_light,
) { REGION_LAYER,
vertices[i++] = px; REGION_SIZE,
vertices[i++] = py; region_sky,
vertices[i++] = pz; REGION_VOID,
vertices[i++] = u; } from "./lighting.ts";
vertices[i++] = v;
vertices[i++] = r;
vertices[i++] = g;
vertices[i++] = b;
vertices[i++] = a;
return i;
}
export function push_front_face(
vertices: Float32Array,
i: number,
texture: Texture,
x: number,
y: number,
z: number,
sx: number,
sy: number,
sw: number,
sh: number,
r = 1,
g = 1,
b = 1,
a = 1,
) {
const x2 = x + 1;
const y2 = y + 1;
const z2 = z + 1;
const u0 = (sx + pad) / texture.width;
const v0 = (sy + pad) / texture.height;
const u1 = (sx + sw - pad) / texture.width;
const v1 = (sy + sh - pad) / texture.height;
i = push_vertex(vertices, i, x, y, z2, u0, v1, r, g, b, a);
i = push_vertex(vertices, i, x2, y, z2, u1, v1, r, g, b, a);
i = push_vertex(vertices, i, x2, y2, z2, u1, v0, r, g, b, a);
i = push_vertex(vertices, i, x, y, z2, u0, v1, r, g, b, a);
i = push_vertex(vertices, i, x2, y2, z2, u1, v0, r, g, b, a);
i = push_vertex(vertices, i, x, y2, z2, u0, v0, r, g, b, a);
return i;
}
export function push_back_face(
vertices: Float32Array,
i: number,
texture: Texture,
x: number,
y: number,
z: number,
sx: number,
sy: number,
sw: number,
sh: number,
r = 1,
g = 1,
b = 1,
a = 1,
) {
const x2 = x + 1;
const y2 = y + 1;
const u0 = (sx + pad) / texture.width;
const v0 = (sy + pad) / texture.height;
const u1 = (sx + sw - pad) / texture.width;
const v1 = (sy + sh - pad) / texture.height;
i = push_vertex(vertices, i, x2, y, z, u0, v1, r, g, b, a);
i = push_vertex(vertices, i, x, y, z, u1, v1, r, g, b, a);
i = push_vertex(vertices, i, x, y2, z, u1, v0, r, g, b, a);
i = push_vertex(vertices, i, x2, y, z, u0, v1, r, g, b, a);
i = push_vertex(vertices, i, x, y2, z, u1, v0, r, g, b, a);
i = push_vertex(vertices, i, x2, y2, z, u0, v0, r, g, b, a);
return i;
}
export function push_left_face(
vertices: Float32Array,
i: number,
texture: Texture,
x: number,
y: number,
z: number,
sx: number,
sy: number,
sw: number,
sh: number,
r = 1,
g = 1,
b = 1,
a = 1,
) {
const y2 = y + 1;
const z2 = z + 1;
const u0 = (sx + pad) / texture.width;
const v0 = (sy + pad) / texture.height;
const u1 = (sx + sw - pad) / texture.width;
const v1 = (sy + sh - pad) / texture.height;
i = push_vertex(vertices, i, x, y, z, u0, v1, r, g, b, a);
i = push_vertex(vertices, i, x, y, z2, u1, v1, r, g, b, a);
i = push_vertex(vertices, i, x, y2, z2, u1, v0, r, g, b, a);
i = push_vertex(vertices, i, x, y, z, u0, v1, r, g, b, a);
i = push_vertex(vertices, i, x, y2, z2, u1, v0, r, g, b, a);
i = push_vertex(vertices, i, x, y2, z, u0, v0, r, g, b, a);
return i;
}
export function push_right_face(
vertices: Float32Array,
i: number,
texture: Texture,
x: number,
y: number,
z: number,
sx: number,
sy: number,
sw: number,
sh: number,
r = 1,
g = 1,
b = 1,
a = 1,
) {
const x2 = x + 1;
const y2 = y + 1;
const z2 = z + 1;
const u0 = (sx + pad) / texture.width;
const v0 = (sy + pad) / texture.height;
const u1 = (sx + sw - pad) / texture.width;
const v1 = (sy + sh - pad) / texture.height;
i = push_vertex(vertices, i, x2, y, z2, u0, v1, r, g, b, a);
i = push_vertex(vertices, i, x2, y, z, u1, v1, r, g, b, a);
i = push_vertex(vertices, i, x2, y2, z, u1, v0, r, g, b, a);
i = push_vertex(vertices, i, x2, y, z2, u0, v1, r, g, b, a);
i = push_vertex(vertices, i, x2, y2, z, u1, v0, r, g, b, a);
i = push_vertex(vertices, i, x2, y2, z2, u0, v0, r, g, b, a);
return i;
}
export function push_top_face(
vertices: Float32Array,
i: number,
texture: Texture,
x: number,
y: number,
z: number,
sx: number,
sy: number,
sw: number,
sh: number,
r = 1,
g = 1,
b = 1,
a = 1,
) {
const x2 = x + 1;
const z2 = z + 1;
const y2 = y + 1;
const u0 = (sx + pad) / texture.width;
const v0 = (sy + pad) / texture.height;
const u1 = (sx + sw - pad) / texture.width;
const v1 = (sy + sh - pad) / texture.height;
i = push_vertex(vertices, i, x, y2, z2, u0, v1, r, g, b, a);
i = push_vertex(vertices, i, x2, y2, z2, u1, v1, r, g, b, a);
i = push_vertex(vertices, i, x2, y2, z, u1, v0, r, g, b, a);
i = push_vertex(vertices, i, x, y2, z2, u0, v1, r, g, b, a);
i = push_vertex(vertices, i, x2, y2, z, u1, v0, r, g, b, a);
i = push_vertex(vertices, i, x, y2, z, u0, v0, r, g, b, a);
return i;
}
export function push_bottom_face(
vertices: Float32Array,
i: number,
texture: Texture,
x: number,
y: number,
z: number,
sx: number,
sy: number,
sw: number,
sh: number,
r = 1,
g = 1,
b = 1,
a = 1,
) {
const x2 = x + 1;
const z2 = z + 1;
const u0 = (sx + pad) / texture.width;
const v0 = (sy + pad) / texture.height;
const u1 = (sx + sw - pad) / texture.width;
const v1 = (sy + sh - pad) / texture.height;
i = push_vertex(vertices, i, x, y, z, u0, v1, r, g, b, a);
i = push_vertex(vertices, i, x2, y, z, u1, v1, r, g, b, a);
i = push_vertex(vertices, i, x2, y, z2, u1, v0, r, g, b, a);
i = push_vertex(vertices, i, x, y, z, u0, v1, r, g, b, a);
i = push_vertex(vertices, i, x2, y, z2, u1, v0, r, g, b, a);
i = push_vertex(vertices, i, x, y, z2, u0, v0, r, g, b, a);
return i;
}
type TexturesInfo = Record<string, SpriteRegion>; type TexturesInfo = Record<string, SpriteRegion>;
const CHUNK_SIZE = 16; // keeps texture lookups off the sprite's edge
const CHUNK_HEIGHT = 128; const UV_PAD = 0.5;
const TEXTURE_SIZE = 16;
const FACE_PUSHING_FUNCTIONS = { // same order as FACE_NORMALS in translucent_sort.ts, and FACE_CORNERS and CORNER_UVS in block_models.ts
top: push_top_face, // minecraft's shading by direction, so faces stay apart even in flat light
bottom: push_bottom_face, const FACE_SHADE = [1.0, 0.5, 0.8, 0.8, 0.6, 0.6];
front: push_front_face,
back: push_back_face, // for each face corner, the two cells beside the cell in front of the face that touch that corner,
left: push_left_face, // as [index offset, y offset] for each. the cell touching both is at the sum of them
right: push_right_face, const CORNER_SIDES = FACE_CORNERS.map((corners, face) =>
} as const; corners.map((corner) => {
const sides: number[] = [];
for (let axis = 0; axis < 3; axis++) {
if (FACE_NORMALS[face][axis] !== 0) continue;
const d = corner[axis] * 2 - 1;
sides.push(axis === 0 ? d : axis === 1 ? d * REGION_LAYER : d * REGION_SIZE, axis === 1 ? d : 0);
}
return sides;
})
);
const SOLID = 0;
const CUTOUT = 1;
const TRANSLUCENT = 2;
const LAYER_IDS: Record<RenderLayer, number> = { solid: SOLID, cutout: CUTOUT, translucent: TRANSLUCENT };
let blocks_registry: BlockRegistry[] = []; let blocks_registry: BlockRegistry[] = [];
let block_ids: Record<string, number> = {}; let block_ids: Record<string, number> = {};
// by numeric id, looked up for every face. like sodium's light data cache, everything the mesher asks about a
// block is worked out once instead of per face
const TABLE_SIZE = ID_MASK + 1;
const block_layers = new Uint8Array(TABLE_SIZE);
const block_cull_same = new Uint8Array(TABLE_SIZE);
// darkens the corners it touches (minecraft's ambient occlusion), blocks with a full collision box do
const block_occludes = new Uint8Array(TABLE_SIZE);
// light can come around a corner past it
const block_lets_light_by = new Uint8Array(TABLE_SIZE);
const light_tables: LightTables = { opacity: new Uint8Array(TABLE_SIZE), emission: new Uint8Array(TABLE_SIZE) };
const region = new LightRegion();
// a model's quad ready for the mesher: uvs in the atlas, and for each corner how much of each of the face's
// four corner lights it gets, so faces smaller than the block are lit like minecraft's
interface MeshQuad {
positions: number[];
uvs: number[];
face: number;
cull: number;
flush: boolean;
// the face group it goes in, see FACE_GROUPS
group: number;
shade: number;
sprite: SpriteRegion;
// 4 weights per corner
light_weights: number[];
}
let models: Record<string, ModelJson> = {};
// by block value, or by numeric id for blocks without variants
const baked = new Map<number, MeshQuad[]>();
let textures_info: TexturesInfo = {}; let textures_info: TexturesInfo = {};
let image: Texture; let image: Texture;
let worldgen: WorldgenSetup | undefined; let worldgen: WorldgenSetup | undefined;
@@ -280,6 +125,9 @@ self.onmessage = async (event: MessageEvent<ToChunkWorker>) => {
case "init": case "init":
blocks_registry = message.blocks_registry; blocks_registry = message.blocks_registry;
block_ids = message.block_ids; block_ids = message.block_ids;
models = message.models;
baked.clear();
build_block_tables();
textures_info = message.textures_info; textures_info = message.textures_info;
image = message.image as Texture; image = message.image as Texture;
default_values = blocks_registry.map((block, nid) => default_block_value(nid, block)); default_values = blocks_registry.map((block, nid) => default_block_value(nid, block));
@@ -292,13 +140,13 @@ self.onmessage = async (event: MessageEvent<ToChunkWorker>) => {
generate(message.chunk_x, message.chunk_z, message.seed); generate(message.chunk_x, message.chunk_z, message.seed);
break; break;
case "mesh": { case "mesh": {
const [opaque_vertices, opaque_count, transparent_vertices, transparent_count] = make_chunk_mesh( region.fill(message.chunks);
region.compute(light_tables);
const { solid, cutout, translucent, min_y, max_y } = make_chunk_mesh(
message.chunk_x, message.chunk_x,
message.chunk_z, message.chunk_z,
message.padded_chunk, message.chunks[4]!,
blocks_registry, message.camera,
textures_info,
image,
); );
post( post(
{ {
@@ -306,15 +154,37 @@ self.onmessage = async (event: MessageEvent<ToChunkWorker>) => {
chunk_x: message.chunk_x, chunk_x: message.chunk_x,
chunk_z: message.chunk_z, chunk_z: message.chunk_z,
version: message.version, version: message.version,
opaque_vertices, min_y,
opaque_count, max_y,
transparent_vertices, solid,
transparent_count, cutout,
translucent,
camera: message.camera,
}, },
[opaque_vertices.buffer, transparent_vertices.buffer], [
solid.vertices.buffer,
cutout.vertices.buffer,
translucent.vertices.buffer,
translucent.indices.buffer,
translucent.centers.buffer,
...translucent.planes.map((planes) => planes.buffer),
],
); );
break; break;
} }
case "sort": {
const [x, y, z] = message.camera;
const indices = quad_indices(sort_by_distance(message.centers, message.centers.length / 3, x, y, z));
post({
type: "sorted",
chunk_x: message.chunk_x,
chunk_z: message.chunk_z,
version: message.version,
sort_version: message.sort_version,
indices,
}, [indices.buffer]);
break;
}
} }
}; };
@@ -327,147 +197,367 @@ function generate(chunk_x: number, chunk_z: number, seed: string) {
post({ type: "generated", chunk_x, chunk_z, blocks, spills }, [blocks.buffer, spills.buffer]); post({ type: "generated", chunk_x, chunk_z, blocks, spills }, [blocks.buffer, spills.buffer]);
} }
function make_chunk_mesh( function build_block_tables() {
chunk_x: number, blocks_registry.forEach((block, nid) => {
chunk_z: number, if (!block || nid === AIR) return;
padded_chunk: Uint32Array, const layer = LAYER_IDS[block.render_layer ?? "solid"];
blocks_registry: BlockRegistry[], const opacity = block_light_opacity(block);
textures_info: TexturesInfo, block_layers[nid] = layer;
image: Texture, block_cull_same[nid] = (block.cull_same ?? layer === TRANSLUCENT) ? 1 : 0;
): [Float32Array, number, Float32Array, number] { block_occludes[nid] = block.has_collision && layer !== TRANSLUCENT ? 1 : 0;
let opaque_vertices = new Float32Array(2048); block_lets_light_by[nid] = layer !== SOLID || opacity === 0 ? 1 : 0;
let opaque_count = 0; light_tables.opacity[nid] = opacity;
let transparent_vertices = new Float32Array(2048); light_tables.emission[nid] = block_light_emission(block);
let transparent_count = 0; });
block_lets_light_by[AIR] = 1;
block_layers[REGION_VOID] = SOLID;
block_occludes[REGION_VOID] = 1;
light_tables.opacity[REGION_VOID] = 15;
}
const size = CHUNK_SIZE + 2; // the rule vanilla minecraft (and so sodium) uses: solid neighbors hide a face, and some blocks
const layer = size * size; // hide faces between two of themselves
function show_face(block: number, neighbor: number) {
if (neighbor === AIR) return true;
if (block_layers[neighbor] === SOLID) return false;
return !(neighbor === block && block_cull_same[block]);
}
const padded_index = (x: number, y: number, z: number) => { // per corner of the face being built
return y * layer + z * size + x; const corner_sky = new Float32Array(4);
}; const corner_block = new Float32Array(4);
const corner_ao = new Float32Array(4);
const show_face = (block: number) => { // minecraft's smooth lighting: each corner averages the light of the cell in front of the face and the three
if (block === 0) return true; // cells around it that touch the corner, and gets darker for each of those that's a full block
const info = blocks_registry[block]; function light_face_corners(face: number, front: number, front_y: number) {
return info?.transparent ?? false; const front_id = region_block(region, front, front_y);
}; const front_sky = region_sky(region, front, front_y);
const front_block = region_block_light(region, front, front_y);
const front_ao = block_occludes[front_id] ? 0.2 : 1;
for (let corner = 0; corner < 4; corner++) {
const [a_offset, a_dy, b_offset, b_dy] = CORNER_SIDES[face][corner];
const a = front + a_offset;
const a_y = front_y + a_dy;
const b = front + b_offset;
const b_y = front_y + b_dy;
const a_id = region_block(region, a, a_y);
const b_id = region_block(region, b, b_y);
let a_sky = region_sky(region, a, a_y);
let a_block = region_block_light(region, a, a_y);
let b_sky = region_sky(region, b, b_y);
let b_block = region_block_light(region, b, b_y);
const a_ao = block_occludes[a_id] ? 0.2 : 1;
const b_ao = block_occludes[b_id] ? 0.2 : 1;
// with both sides closed the corner cell can't be seen, vanilla uses a side's values instead
let c_sky = a_sky;
let c_block = a_block;
let c_ao = a_ao;
if (block_lets_light_by[a_id] || block_lets_light_by[b_id]) {
const c = a + b_offset;
const c_y = a_y + b_dy;
c_sky = region_sky(region, c, c_y);
c_block = region_block_light(region, c, c_y);
c_ao = block_occludes[region_block(region, c, c_y)] ? 0.2 : 1;
}
// cells with no light at all are usually inside solid blocks, vanilla counts them as the front cell
// so corners against walls don't go black
if (a_sky === 0 && a_block === 0) {
a_sky = front_sky;
a_block = front_block;
}
if (b_sky === 0 && b_block === 0) {
b_sky = front_sky;
b_block = front_block;
}
if (c_sky === 0 && c_block === 0) {
c_sky = front_sky;
c_block = front_block;
}
corner_sky[corner] = (a_sky + b_sky + c_sky + front_sky) / 4;
corner_block[corner] = (a_block + b_block + c_block + front_block) / 4;
corner_ao[corner] = (a_ao + b_ao + c_ao + front_ao) / 4;
}
}
// sodium's rule for which diagonal splits the quad: the brighter one, otherwise the ambient occlusion
// gets smeared across the whole face
function should_flip() {
const ao_02 = corner_ao[0] + corner_ao[2];
const ao_13 = corner_ao[1] + corner_ao[3];
if (ao_02 !== ao_13) {
return ao_02 < ao_13;
}
const light = (corner: number) => corner_sky[corner] * 16 + corner_block[corner];
return light(0) + light(2) > light(1) + light(3);
}
// quads being built, in the terrain vertex format
class QuadBuffer {
count = 0;
bytes = new Uint8Array(TERRAIN_QUAD_BYTES * 64);
f32 = new Float32Array(this.bytes.buffer);
u16 = new Uint16Array(this.bytes.buffer);
// room for one more quad
reserve() {
if ((this.count + 1) * TERRAIN_QUAD_BYTES <= this.bytes.length) return;
const bytes = new Uint8Array(this.bytes.length * 2);
bytes.set(this.bytes);
this.bytes = bytes;
this.f32 = new Float32Array(bytes.buffer);
this.u16 = new Uint16Array(bytes.buffer);
}
}
// the lowest and highest y of any quad in the chunk being meshed
let mesh_min_y = Infinity;
let mesh_max_y = -Infinity;
function push_quad(buffer: QuadBuffer, quad: MeshQuad, x: number, y: number, z: number, alpha: number) {
buffer.reserve();
const { f32, u16, bytes } = buffer;
const sprite = quad.sprite;
// starting from the second corner moves the diagonal, the winding stays the same
const first = should_flip() ? 1 : 0;
const alpha_byte = Math.round(alpha * 255);
for (let k = 0; k < 4; k++) {
const corner = (first + k) & 3;
const byte = (buffer.count * 4 + k) * TERRAIN_VERTEX_BYTES;
const vy = y + quad.positions[corner * 3 + 1];
f32[byte / 4] = x + quad.positions[corner * 3];
f32[byte / 4 + 1] = vy;
f32[byte / 4 + 2] = z + quad.positions[corner * 3 + 2];
u16[byte / 2 + 6] = Math.round(atlas_u(sprite, quad.uvs[corner * 2]) * 65535);
u16[byte / 2 + 7] = Math.round(atlas_v(sprite, quad.uvs[corner * 2 + 1]) * 65535);
const brightness = Math.round(quad.shade * corner_ao[corner] * 255);
bytes[byte + 16] = brightness;
bytes[byte + 17] = brightness;
bytes[byte + 18] = brightness;
bytes[byte + 19] = alpha_byte;
// where to read the lightmap, block light across and sky light down
bytes[byte + 20] = Math.round((corner_block[corner] + 0.5) / 16 * 255);
bytes[byte + 21] = Math.round((corner_sky[corner] + 0.5) / 16 * 255);
if (vy < mesh_min_y) mesh_min_y = vy;
if (vy > mesh_max_y) mesh_max_y = vy;
}
buffer.count += 1;
}
// pixels of a sprite to atlas coordinates, kept off the sprite's edge
function atlas_u(sprite: SpriteRegion, u: number) {
return (sprite.x * TEXTURE_SIZE + Math.min(Math.max(u, UV_PAD), TEXTURE_SIZE - UV_PAD)) / image.width;
}
function atlas_v(sprite: SpriteRegion, v: number) {
return (sprite.y * TEXTURE_SIZE + Math.min(Math.max(v, UV_PAD), TEXTURE_SIZE - UV_PAD)) / image.height;
}
// the model quads for a block value, baked the first time it's seen
function block_quads(value: number): MeshQuad[] {
const nid = value & ID_MASK;
const block = blocks_registry[nid];
const key = block.variants ? value : nid;
let quads = baked.get(key);
if (quads) return quads;
let states: Record<string, number> | undefined;
if (block.variants && block.states) {
states = {};
for (const state of block.states) states[state.name] = get_state_value(value, block, state.name)!;
}
const variant = block_variant(block, states);
const model = find_model(variant.model, models) ?? find_model("engine:cube", models)!;
quads = bake_model(model, variant.textures, variant.y).map((quad) => ({
positions: quad.positions,
uvs: quad.uvs,
face: quad.face,
cull: quad.cull,
flush: quad.flush,
group: quad.aligned ? quad.face : UNALIGNED_GROUP,
shade: quad.shade ? FACE_SHADE[quad.face] : 1,
sprite: textures_info[quad.texture] ?? textures_info["engine:missing"],
light_weights: light_weights(quad.face, quad.positions),
}));
baked.set(key, quads);
return quads;
}
// bilinear weights of the face's four corners at each of the quad's corners
function light_weights(face: number, positions: number[]) {
const axes = [0, 1, 2].filter((axis) => FACE_NORMALS[face][axis] === 0);
const weights: number[] = [];
for (let k = 0; k < 4; k++) {
for (const corner of FACE_CORNERS[face]) {
let weight = 1;
for (const axis of axes) {
const t = Math.min(Math.max(positions[k * 3 + axis], 0), 1);
weight *= corner[axis] ? t : 1 - t;
}
weights.push(weight);
}
}
return weights;
}
// light for a quad smaller than a face, or not on the block's edge at all
const face_sky = new Float32Array(4);
const face_block = new Float32Array(4);
const face_ao = new Float32Array(4);
function light_quad(quad: MeshQuad, index: number, y: number, face_offsets: number[]) {
if (quad.flush) {
light_face_corners(quad.face, index + face_offsets[quad.face], y + FACE_NORMALS[quad.face][1]);
face_sky.set(corner_sky);
face_block.set(corner_block);
face_ao.set(corner_ao);
const w = quad.light_weights;
for (let k = 0; k < 4; k++) {
let sky = 0;
let block = 0;
let ao = 0;
for (let c = 0; c < 4; c++) {
sky += w[k * 4 + c] * face_sky[c];
block += w[k * 4 + c] * face_block[c];
ao += w[k * 4 + c] * face_ao[c];
}
corner_sky[k] = sky;
corner_block[k] = block;
corner_ao[k] = ao;
}
return;
}
// inside the block: its own cell's light, or the cell it faces if the block stops light itself
let cell = index;
let cell_y = y;
if (light_tables.opacity[region_block(region, index, y)] === 15) {
cell = index + face_offsets[quad.face];
cell_y = y + FACE_NORMALS[quad.face][1];
}
corner_sky.fill(region_sky(region, cell, cell_y));
corner_block.fill(region_block_light(region, cell, cell_y));
corner_ao.fill(1);
}
// region has to be filled and lit first
// values is the middle chunk's blocks with their states, for models that change with them
function make_chunk_mesh(chunk_x: number, chunk_z: number, values: Uint32Array, camera: number[]) {
// solid and cutout get a buffer per face group, translucent one for everything since it's sorted instead
const opaque = [SOLID, CUTOUT].map(() =>
Array.from({ length: FACE_GROUPS }, () => ({ buffer: new QuadBuffer(), min: Infinity, max: -Infinity }))
);
const translucent_quads = new QuadBuffer();
mesh_min_y = Infinity;
mesh_max_y = -Infinity;
// for sorting the translucent quads
let centers = new Float32Array(256);
let faces = new Uint8Array(256);
// where the neighbor on each face is, same order as FACE_NORMALS
const face_offsets = FACE_NORMALS.map(([nx, ny, nz]) => nx + ny * REGION_LAYER + nz * REGION_SIZE);
for (let y = 0; y < CHUNK_HEIGHT; y++) { for (let y = 0; y < CHUNK_HEIGHT; y++) {
for (let z = 0; z < CHUNK_SIZE; z++) { for (let z = 0; z < CHUNK_SIZE; z++) {
for (let x = 0; x < CHUNK_SIZE; x++) { for (let x = 0; x < CHUNK_SIZE; x++) {
const px = x + 1; // the middle chunk of the region
const pz = z + 1; const index = y * REGION_LAYER + (z + CHUNK_SIZE) * REGION_SIZE + x + CHUNK_SIZE;
const block_nid = region.blocks[index];
const block_nid = padded_chunk[padded_index(px, y, pz)]; if (block_nid === AIR) continue;
if (block_nid === 0) continue;
const block_info = blocks_registry[block_nid]; const block_info = blocks_registry[block_nid];
const layer_id = block_layers[block_nid];
const texture_ids = { const alpha = layer_id === TRANSLUCENT ? block_info.alpha ?? 1 : 1;
top: "engine:missing",
bottom: "engine:missing",
front: "engine:missing",
back: "engine:missing",
left: "engine:missing",
right: "engine:missing",
};
const textures = block_info.textures;
if (!textures) throw new Error(`no textures for ${block_nid}`);
if (typeof textures === "string") {
texture_ids.top = textures;
texture_ids.bottom = textures;
texture_ids.front = textures;
texture_ids.back = textures;
texture_ids.left = textures;
texture_ids.right = textures;
} else if ("top" in textures && "bottom" in textures && "side" in textures) {
texture_ids.top = textures.top;
texture_ids.bottom = textures.bottom;
texture_ids.front = textures.side;
texture_ids.back = textures.side;
texture_ids.left = textures.side;
texture_ids.right = textures.side;
} else if ("front" in textures && "side" in textures) {
texture_ids.top = textures.side;
texture_ids.bottom = textures.side;
texture_ids.front = textures.front;
texture_ids.back = textures.side;
texture_ids.left = textures.side;
texture_ids.right = textures.side;
}
const wx = chunk_x * CHUNK_SIZE + x; const wx = chunk_x * CHUNK_SIZE + x;
const wz = chunk_z * CHUNK_SIZE + z; const wz = chunk_z * CHUNK_SIZE + z;
const faces = { for (const quad of block_quads(values[y * CHUNK_AREA + z * CHUNK_SIZE + x])) {
front: show_face(padded_chunk[padded_index(px, y, pz + 1)]), if (quad.cull >= 0) {
back: show_face(padded_chunk[padded_index(px, y, pz - 1)]), const neighbor = region_block(
left: show_face(padded_chunk[padded_index(px - 1, y, pz)]), region,
right: show_face(padded_chunk[padded_index(px + 1, y, pz)]), index + face_offsets[quad.cull],
top: show_face(padded_chunk[padded_index(px, y + 1, pz)]), y + FACE_NORMALS[quad.cull][1],
bottom: show_face(padded_chunk[padded_index(px, y - 1, pz)]),
} as const;
for (const side of ["front", "back", "left", "right", "top", "bottom"] as const) {
if (faces[side]) {
const region = textures_info[texture_ids[side]];
if (block_info.transparent) {
transparent_vertices = ensure_capacity(
transparent_vertices,
transparent_count + (6 * 6 * 9),
);
transparent_count = FACE_PUSHING_FUNCTIONS[side](
transparent_vertices,
transparent_count,
image,
wx,
y,
wz,
region.x * TEXTURE_SIZE,
region.y * TEXTURE_SIZE,
TEXTURE_SIZE,
TEXTURE_SIZE,
1,
1,
1,
block_info.alpha ?? 1,
); );
if (!show_face(block_nid, neighbor)) continue;
}
light_quad(quad, index, y, face_offsets);
if (layer_id !== TRANSLUCENT) {
const group = opaque[layer_id][quad.group];
push_quad(group.buffer, quad, wx, y, wz, alpha);
if (quad.group !== UNALIGNED_GROUP) {
const axis = FACE_AXIS[quad.face];
const plane = (axis === 0 ? wx : axis === 1 ? y : wz) + quad.positions[axis];
if (plane < group.min) group.min = plane;
if (plane > group.max) group.max = plane;
}
} else { } else {
opaque_vertices = ensure_capacity(opaque_vertices, opaque_count + (6 * 6 * 9)); push_quad(translucent_quads, quad, wx, y, wz, alpha);
opaque_count = FACE_PUSHING_FUNCTIONS[side]( // sorting treats every quad as facing along an axis, rotated ones too
opaque_vertices, const q = translucent_quads.count - 1;
opaque_count, centers = ensure_capacity(centers, (q + 1) * 3);
image, faces = ensure_capacity(faces, q + 1);
wx, const p = quad.positions;
y, centers[q * 3] = wx + (p[0] + p[6]) / 2;
wz, centers[q * 3 + 1] = y + (p[1] + p[7]) / 2;
region.x * TEXTURE_SIZE, centers[q * 3 + 2] = wz + (p[2] + p[8]) / 2;
region.y * TEXTURE_SIZE, faces[q] = quad.face;
TEXTURE_SIZE,
TEXTURE_SIZE,
1,
1,
1,
1,
);
}
} }
} }
} }
} }
} }
return [opaque_vertices, opaque_count, transparent_vertices, transparent_count]; // each layer's groups one after another, in one buffer
const [solid, cutout] = opaque.map((groups) => {
const quad_count = groups.reduce((sum, group) => sum + group.buffer.count, 0);
const vertices = new Uint8Array(quad_count * TERRAIN_QUAD_BYTES);
const ranges: FaceGroup[] = [];
let first = 0;
for (const { buffer, min, max } of groups) {
vertices.set(buffer.bytes.subarray(0, buffer.count * TERRAIN_QUAD_BYTES), first * TERRAIN_QUAD_BYTES);
ranges.push({ first, count: buffer.count, min, max });
first += buffer.count;
}
return { vertices, quad_count, groups: ranges };
});
const translucent = {
vertices: translucent_quads.bytes.slice(0, translucent_quads.count * TERRAIN_QUAD_BYTES),
quad_count: translucent_quads.count,
};
const quads = { centers, faces, count: translucent.quad_count };
const sort_type = choose_sort_type(quads);
const [camera_x, camera_y, camera_z] = camera;
return {
min_y: mesh_min_y === Infinity ? 0 : mesh_min_y,
max_y: mesh_max_y === -Infinity ? 0 : mesh_max_y,
solid,
cutout,
translucent: {
...translucent,
indices: sort_quads(quads, sort_type, camera_x, camera_y, camera_z),
sort_type,
centers: centers.slice(0, quads.count * 3),
planes: quad_planes(quads),
},
};
} }
function ensure_capacity( function ensure_capacity<T extends Float32Array<ArrayBuffer> | Uint8Array<ArrayBuffer>>(
buffer: Float32Array<ArrayBuffer>, buffer: T,
required: number, required: number,
) { ): T {
if (required <= buffer.length) return buffer; if (required <= buffer.length) return buffer;
let new_length = buffer.length; let new_length = buffer.length;
@@ -475,7 +565,7 @@ function ensure_capacity(
new_length *= 2; new_length *= 2;
} }
const new_buffer = new Float32Array(new_length); const new_buffer = new (buffer.constructor as new (length: number) => T)(new_length);
new_buffer.set(buffer); new_buffer.set(buffer as ArrayLike<number>);
return new_buffer; return new_buffer;
} }
+176
View File
@@ -0,0 +1,176 @@
// minecraft's lighting: every block has a sky light and a block light level from 0 to 15.
// sky light starts at 15 above the world and goes straight down without getting weaker until it hits
// something that isn't fully clear, block light starts at blocks that give off light. both spread to
// neighbors losing max(1, the neighbor's opacity) per step.
//
// minecraft stores light and updates it as blocks change. here it's worked out from scratch for the
// 3x3 chunks around the chunk being meshed, which gives the same result: light reaches at most 15
// blocks, so nothing outside those chunks can light the middle one or its border
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts";
export const REGION_SIZE = CHUNK_SIZE * 3;
export const REGION_LAYER = REGION_SIZE * REGION_SIZE;
export const REGION_VOLUME = REGION_LAYER * CHUNK_HEIGHT;
// what unloaded chunks and the space below the world are made of: opaque, dark, never shown
export const REGION_VOID = ID_MASK;
// by numeric block id
export interface LightTables {
opacity: Uint8Array;
emission: Uint8Array;
}
export class LightRegion {
// block ids without their state bits, indexed y * REGION_LAYER + z * REGION_SIZE + x
blocks = new Uint16Array(REGION_VOLUME);
sky = new Uint8Array(REGION_VOLUME);
block_light = new Uint8Array(REGION_VOLUME);
// the lowest y that still sees the sky, per column
#heights = new Int32Array(REGION_LAYER);
#queue = new Int32Array(1 << 18);
#queue_length = 0;
// the 3x3 chunks around the one being meshed, going +x then +z, starting at -x -z. missing ones are void
fill(chunks: (Uint32Array | null)[]) {
for (let i = 0; i < 9; i++) {
const source = chunks[i];
const origin = Math.floor(i / 3) * CHUNK_SIZE * REGION_SIZE + (i % 3) * CHUNK_SIZE;
for (let y = 0; y < CHUNK_HEIGHT; y++) {
for (let z = 0; z < CHUNK_SIZE; z++) {
const to = y * REGION_LAYER + origin + z * REGION_SIZE;
if (!source) {
this.blocks.fill(REGION_VOID, to, to + CHUNK_SIZE);
continue;
}
const from = y * CHUNK_AREA + z * CHUNK_SIZE;
for (let x = 0; x < CHUNK_SIZE; x++) {
this.blocks[to + x] = source[from + x] & ID_MASK;
}
}
}
}
}
compute(tables: LightTables) {
this.#compute_sky(tables);
this.#compute_block_light(tables);
}
#compute_sky({ opacity }: LightTables) {
const { blocks, sky } = this;
sky.fill(0);
this.#queue_length = 0;
// straight down from the top, until something isn't fully clear
for (let column = 0; column < REGION_LAYER; column++) {
let y = CHUNK_HEIGHT - 1;
while (y >= 0 && opacity[blocks[y * REGION_LAYER + column]] === 0) {
sky[y * REGION_LAYER + column] = 15;
y--;
}
this.#heights[column] = y + 1;
}
// only the lit cells next to a darker one can spread: the bottom of each column's sunlight,
// and the part of it that's beside a neighbor column's shade
for (let z = 0; z < REGION_SIZE; z++) {
for (let x = 0; x < REGION_SIZE; x++) {
const column = z * REGION_SIZE + x;
const height = this.#heights[column];
let highest_neighbor = height;
if (x > 0) highest_neighbor = Math.max(highest_neighbor, this.#heights[column - 1]);
if (x < REGION_SIZE - 1) highest_neighbor = Math.max(highest_neighbor, this.#heights[column + 1]);
if (z > 0) highest_neighbor = Math.max(highest_neighbor, this.#heights[column - REGION_SIZE]);
if (z < REGION_SIZE - 1) {
highest_neighbor = Math.max(highest_neighbor, this.#heights[column + REGION_SIZE]);
}
const top = Math.min(CHUNK_HEIGHT - 1, Math.max(height, highest_neighbor - 1));
for (let y = height; y <= top; y++) {
this.#push(y * REGION_LAYER + column);
}
}
}
this.#propagate(sky, opacity, true);
}
#compute_block_light({ opacity, emission }: LightTables) {
const { blocks, block_light } = this;
block_light.fill(0);
this.#queue_length = 0;
for (let i = 0; i < REGION_VOLUME; i++) {
const level = emission[blocks[i]];
if (level > 0) {
block_light[i] = level;
this.#push(i);
}
}
this.#propagate(block_light, opacity, false);
}
#push(index: number) {
if (this.#queue_length === this.#queue.length) {
const bigger = new Int32Array(this.#queue.length * 2);
bigger.set(this.#queue);
this.#queue = bigger;
}
this.#queue[this.#queue_length++] = index;
}
// breadth first from everything queued. a cell can be queued again when a brighter path reaches it
#propagate(light: Uint8Array, opacity: Uint8Array, is_sky: boolean) {
const blocks = this.blocks;
const spread = (to: number, level: number, down: boolean) => {
const block_opacity = opacity[blocks[to]];
const next = is_sky && down && level === 15 && block_opacity === 0
? 15
: level - Math.max(1, block_opacity);
if (next > light[to]) {
light[to] = next;
this.#push(to);
}
};
for (let head = 0; head < this.#queue_length; head++) {
const index = this.#queue[head];
const level = light[index];
if (level <= 1) continue;
const y = Math.floor(index / REGION_LAYER);
const rest = index - y * REGION_LAYER;
const z = Math.floor(rest / REGION_SIZE);
const x = rest - z * REGION_SIZE;
if (y > 0) spread(index - REGION_LAYER, level, true);
if (y < CHUNK_HEIGHT - 1) spread(index + REGION_LAYER, level, false);
if (x > 0) spread(index - 1, level, false);
if (x < REGION_SIZE - 1) spread(index + 1, level, false);
if (z > 0) spread(index - REGION_SIZE, level, false);
if (z < REGION_SIZE - 1) spread(index + REGION_SIZE, level, false);
}
this.#queue_length = 0;
}
}
// what a cell looks like to the mesher, including above and below the world
export function region_block(region: LightRegion, index: number, y: number) {
if (y >= CHUNK_HEIGHT) return AIR;
if (y < 0) return REGION_VOID;
return region.blocks[index];
}
export function region_sky(region: LightRegion, index: number, y: number) {
if (y >= CHUNK_HEIGHT) return 15;
if (y < 0) return 0;
return region.sky[index];
}
export function region_block_light(region: LightRegion, index: number, y: number) {
if (y >= CHUNK_HEIGHT || y < 0) return 0;
return region.block_light[index];
}
+158
View File
@@ -0,0 +1,158 @@
// translucent quad sorting, the way sodium does it (its "translucency sorting"), simplified for quads
// that all face along an axis.
// every chunk's translucent mesh gets a sort type when it's meshed:
// - none: the order can't matter, one quad or all of them in a single plane
// - static: all quads face along one axis, so sorting them by distance along their normal once is right
// from anywhere the camera can see them
// - dynamic: sorted by distance to the camera, and sorted again only when the camera crosses one of the
// planes the chunk's quads lie on, the only time the order between them can change (sodium's GFNI)
export type SortType = "none" | "static" | "dynamic";
// same order as the mesher's faces
export const FACE_NORMALS = [
[0, 1, 0], // top
[0, -1, 0], // bottom
[0, 0, 1], // front
[0, 0, -1], // back
[-1, 0, 0], // left
[1, 0, 0], // right
] as const;
export const FACE_AXIS = [1, 1, 2, 2, 0, 0] as const;
// for each quad: its center, and which face it is
export interface TranslucentQuads {
centers: Float32Array;
faces: Uint8Array;
count: number;
}
export function choose_sort_type(quads: TranslucentQuads): SortType {
if (quads.count <= 1) {
return "none";
}
let axes = 0;
for (let q = 0; q < quads.count; q++) {
axes |= 1 << FACE_AXIS[quads.faces[q]];
}
// more than one axis, the order depends on where the camera is
if (axes & (axes - 1)) {
return "dynamic";
}
// quads facing opposite ways on the same axis are never both visible over each other, so only
// the distance along the axis matters. if they all share a plane, nothing can overlap at all
const axis = FACE_AXIS[quads.faces[0]];
const plane = quads.centers[axis];
for (let q = 1; q < quads.count; q++) {
if (quads.centers[q * 3 + axis] !== plane) {
return "static";
}
}
return "none";
}
// the unique coordinates of the planes the quads lie on, per axis, sorted. the camera crossing one of
// these is what triggers a dynamic sort
export function quad_planes(quads: TranslucentQuads): [Float32Array, Float32Array, Float32Array] {
const sets = [new Set<number>(), new Set<number>(), new Set<number>()];
for (let q = 0; q < quads.count; q++) {
const axis = FACE_AXIS[quads.faces[q]];
sets[axis].add(quads.centers[q * 3 + axis]);
}
return sets.map((set) => Float32Array.from(set).sort()) as [Float32Array, Float32Array, Float32Array];
}
// whether moving the camera from a to b crosses any of the planes
export function crosses_planes(planes: [Float32Array, Float32Array, Float32Array], a: number[], b: number[]) {
for (let axis = 0; axis < 3; axis++) {
if (a[axis] === b[axis] || planes[axis].length === 0) {
continue;
}
if (count_below(planes[axis], a[axis]) !== count_below(planes[axis], b[axis])) {
return true;
}
}
return false;
}
function count_below(sorted: Float32Array, value: number) {
let lo = 0;
let hi = sorted.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (sorted[mid] < value) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
// back to front, for the camera at camera_x/y/z when the sort type is dynamic
export function sort_quads(
quads: TranslucentQuads,
sort_type: SortType,
camera_x: number,
camera_y: number,
camera_z: number,
): Uint32Array {
const { centers, faces, count } = quads;
if (sort_type === "dynamic") {
return quad_indices(sort_by_distance(centers, count, camera_x, camera_y, camera_z));
}
const order = new Uint32Array(count);
for (let q = 0; q < count; q++) {
order[q] = q;
}
if (sort_type === "static") {
// for quads facing the camera, the ones further along their normal are closer to it
const keys = new Float32Array(count);
for (let q = 0; q < count; q++) {
const [nx, ny, nz] = FACE_NORMALS[faces[q]];
keys[q] = centers[q * 3] * nx + centers[q * 3 + 1] * ny + centers[q * 3 + 2] * nz;
}
order.sort((a, b) => keys[a] - keys[b]);
}
return quad_indices(order);
}
// dynamic sorting only needs the centers, so resorting doesn't need the whole mesh
export function sort_by_distance(
centers: Float32Array,
count: number,
camera_x: number,
camera_y: number,
camera_z: number,
): Uint32Array {
const order = new Uint32Array(count);
const distances = new Float32Array(count);
for (let q = 0; q < count; q++) {
order[q] = q;
const dx = centers[q * 3] - camera_x;
const dy = centers[q * 3 + 1] - camera_y;
const dz = centers[q * 3 + 2] - camera_z;
distances[q] = dx * dx + dy * dy + dz * dz;
}
return order.sort((a, b) => distances[b] - distances[a]);
}
// two triangles per quad, drawn in the given order
export function quad_indices(order: Uint32Array): Uint32Array {
const indices = new Uint32Array(order.length * 6);
for (let i = 0; i < order.length; i++) {
const v = order[i] * 4;
const o = i * 6;
indices[o] = v;
indices[o + 1] = v + 1;
indices[o + 2] = v + 2;
indices[o + 3] = v;
indices[o + 4] = v + 2;
indices[o + 5] = v + 3;
}
return indices;
}
+313
View File
@@ -0,0 +1,313 @@
// block models, in the shape of minecraft's block model json: boxes ("elements") with a texture per face.
// a block picks one with "model" and fills in its texture variables with "textures". baking turns a model
// into quads in block space (0 to 1) that the chunk mesher and the item renderers draw
import type { BlockRegistry, BlockTextures } from "./everything_registry.ts";
export type ModelFace = "top" | "bottom" | "north" | "south" | "west" | "east";
export const MODEL_FACES: ModelFace[] = ["top", "bottom", "north", "south", "west", "east"];
export interface ModelFaceJson {
// a texture variable like "#side", or a texture id
texture: string;
// the part of the texture, [u1, v1, u2, v2] in pixels (0-16). defaults to the part the face covers
uv?: [number, number, number, number];
// hidden when the neighbor on this side hides faces (a solid block)
cullface?: ModelFace;
}
export interface ModelElementJson {
// corners of the box in pixels, 0-16 is the block
from: [number, number, number];
to: [number, number, number];
rotation?: {
origin: [number, number, number];
axis: "x" | "y" | "z";
// -45, -22.5, 0, 22.5 or 45
angle: number;
// stretch the rotated faces back to the block's size, like the cross model does
rescale?: boolean;
};
// directional shading, off for plants so they look the same from every side
shade?: boolean;
faces: Partial<Record<ModelFace, ModelFaceJson>>;
}
export interface ModelJson {
id: string;
// defaults for texture variables, can point at other variables ("top": "#side")
textures?: Record<string, string>;
elements: ModelElementJson[];
}
// a model a block uses, and the textures it fills in
export interface BlockVariant {
model?: string;
textures?: BlockTextures;
// turns the model around the vertical axis, clockwise seen from above: 0, 90, 180 or 270
y?: number;
}
export const DEFAULT_MODEL = "engine:cube";
const cube_face = (texture: string, cullface: ModelFace): ModelFaceJson => ({ texture, cullface });
// the engine's own models
export const BUILTIN_MODELS: Record<string, ModelJson> = {
// a full block. "textures" can be one texture, { top, bottom, side } or { front, side }
"engine:cube": {
id: "engine:cube",
textures: { top: "#side", bottom: "#side", front: "#side" },
elements: [{
from: [0, 0, 0],
to: [16, 16, 16],
faces: {
top: cube_face("#top", "top"),
bottom: cube_face("#bottom", "bottom"),
north: cube_face("#side", "north"),
south: cube_face("#front", "south"),
west: cube_face("#side", "west"),
east: cube_face("#side", "east"),
},
}],
},
// two crossed planes, like flowers and saplings. uses the "cross" texture
"engine:cross": {
id: "engine:cross",
elements: [
cross_plane(45),
cross_plane(-45),
],
},
// four planes in a # shape, like wheat. uses the "crop" texture
"engine:crop": {
id: "engine:crop",
elements: [
{ from: [4, 0, 0], to: [4, 16, 16], shade: false, faces: both_ways("west", "east", "#crop") },
{ from: [12, 0, 0], to: [12, 16, 16], shade: false, faces: both_ways("west", "east", "#crop") },
{ from: [0, 0, 4], to: [16, 16, 4], shade: false, faces: both_ways("north", "south", "#crop") },
{ from: [0, 0, 12], to: [16, 16, 12], shade: false, faces: both_ways("north", "south", "#crop") },
],
},
};
function cross_plane(angle: number): ModelElementJson {
return {
from: [0.8, 0, 8],
to: [15.2, 16, 8],
rotation: { origin: [8, 8, 8], axis: "y", angle, rescale: true },
shade: false,
faces: both_ways("north", "south", "#cross"),
};
}
// a flat element seen from both sides, since faces are only drawn from the front
function both_ways(a: ModelFace, b: ModelFace, texture: string) {
return { [a]: { texture, uv: [0, 0, 16, 16] }, [b]: { texture, uv: [0, 0, 16, 16] } } as Partial<
Record<ModelFace, ModelFaceJson>
>;
}
export function find_model(id: string, models: Record<string, ModelJson>): ModelJson | undefined {
return BUILTIN_MODELS[id] ?? models[id];
}
// what a texture reference means for a block: "#name" looks up the block's textures, then the model's
// defaults, and anything else is already a texture id
export function resolve_texture(reference: string, textures: BlockTextures | undefined, model?: ModelJson): string {
for (let depth = 0; depth < 8 && reference.startsWith("#"); depth++) {
const name = reference.slice(1);
if (typeof textures === "string") return textures;
const next = (textures as Record<string, string> | undefined)?.[name] ?? model?.textures?.[name];
if (next === undefined) return "engine:missing";
reference = next;
}
return reference.startsWith("#") ? "engine:missing" : reference;
}
// the block's model and textures for a value with state bits, from the first variant whose conditions match
export function block_variant(block: BlockRegistry, states?: Record<string, number>): Required<BlockVariant> {
const variant: Required<BlockVariant> = {
model: block.model ?? DEFAULT_MODEL,
textures: block.textures,
y: 0,
};
if (!block.variants || !states) return variant;
for (const [condition, override] of Object.entries(block.variants)) {
if (variant_matches(condition, states)) {
return {
model: override.model ?? variant.model,
textures: override.textures ?? variant.textures,
y: override.y ?? 0,
};
}
}
return variant;
}
// "age=7" or "age=7,facing=2", "" matches everything
export function variant_matches(condition: string, states: Record<string, number>) {
if (condition === "") return true;
return condition.split(",").every((part) => {
const [name, value] = part.split("=");
return states[name.trim()] === Number(value);
});
}
// for inventories and dropped items: blocks with a full cube draw as a cube, anything else as a flat sprite
// of its first texture, like minecraft's items for plants
export function block_item_texture(block: BlockRegistry): string | undefined {
if ((block.model ?? DEFAULT_MODEL) === DEFAULT_MODEL) return undefined;
const textures = block.textures;
if (typeof textures === "string") return textures;
return Object.values(textures)[0] ?? "engine:missing";
}
export function cube_face_texture(block: BlockRegistry, face: "top" | "bottom" | "front" | "side") {
return resolve_texture(`#${face}`, block.textures, BUILTIN_MODELS[DEFAULT_MODEL]);
}
// baking
export interface BakedQuad {
// 4 corners counter clockwise seen from the front, x y z in block space
positions: number[];
// u v per corner, in pixels of the texture (0-16)
uvs: number[];
texture: string;
// the side it faces most, as the mesher's face index (see FACE_INDEX)
face: number;
// the side whose neighbor can hide it, or -1
cull: number;
// on the block's edge facing straight out, so it's lit like a full block's face
flush: boolean;
// flat on a plane facing straight along face's axis, so it can only be seen from that side of the plane.
// rotated quads, like the cross model's, aren't
aligned: boolean;
shade: boolean;
}
// the mesher's face order: top, bottom, front (+z), back (-z), left (-x), right (+x)
export const FACE_INDEX: Record<ModelFace, number> = { top: 0, bottom: 1, south: 2, north: 3, west: 4, east: 5 };
const FACE_NORMALS = [[0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1], [-1, 0, 0], [1, 0, 0]];
// each face's corners in drawing order as which end of the box they're at, same as the mesher
export const FACE_CORNERS = [
[[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]],
[[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]],
[[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]],
[[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]],
[[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]],
[[1, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]],
] as const;
// which end of the uv rectangle each corner gets, u then v
export const CORNER_UVS = [[0, 1], [1, 1], [1, 0], [0, 0]] as const;
// the face a face becomes after turning the model 90 degrees clockwise seen from above
const TURN_Y = [0, 1, 4, 5, 3, 2];
// minecraft's default uvs: the part of the texture the face would cover if the texture was wrapped around the block
function default_uv(face: ModelFace, from: number[], to: number[]): [number, number, number, number] {
switch (face) {
case "top":
return [from[0], from[2], to[0], to[2]];
case "bottom":
return [from[0], 16 - to[2], to[0], 16 - from[2]];
case "north":
return [16 - to[0], 16 - to[1], 16 - from[0], 16 - from[1]];
case "south":
return [from[0], 16 - to[1], to[0], 16 - from[1]];
case "west":
return [from[2], 16 - to[1], to[2], 16 - from[1]];
case "east":
return [16 - to[2], 16 - to[1], 16 - from[2], 16 - from[1]];
}
}
const EPSILON = 1e-4;
export function bake_model(model: ModelJson, textures: BlockTextures | undefined, y_rotation = 0): BakedQuad[] {
const quads: BakedQuad[] = [];
const turns = ((Math.round(y_rotation / 90) % 4) + 4) % 4;
for (const element of model.elements) {
const rotate = element_rotation(element);
for (const face of MODEL_FACES) {
const face_json = element.faces[face];
if (!face_json) continue;
const index = FACE_INDEX[face];
const uv = face_json.uv ?? default_uv(face, element.from, element.to);
const positions: number[] = [];
const uvs: number[] = [];
FACE_CORNERS[index].forEach((corner, k) => {
let point = corner.map((end, axis) => (end ? element.to[axis] : element.from[axis]) / 16);
point = rotate(point);
for (let t = 0; t < turns; t++) point = [1 - point[2], point[1], point[0]];
positions.push(...point);
const [cu, cv] = CORNER_UVS[k];
uvs.push(cu ? uv[2] : uv[0], cv ? uv[3] : uv[1]);
});
let cull = face_json.cullface ? FACE_INDEX[face_json.cullface] : -1;
for (let t = 0; t < turns && cull >= 0; t++) cull = TURN_Y[cull];
const { face: facing, flush, aligned } = classify(positions);
quads.push({
positions,
uvs,
texture: resolve_texture(face_json.texture, textures, model),
face: facing,
cull,
flush,
aligned,
shade: element.shade ?? true,
});
}
}
return quads;
}
// turns a point (in block space) by the element's rotation
function element_rotation(element: ModelElementJson): (point: number[]) => number[] {
const rotation = element.rotation;
if (!rotation || rotation.angle === 0) return (point) => point;
const axis = { x: 0, y: 1, z: 2 }[rotation.axis];
// the two axes that move, in the order that makes a positive angle counter clockwise looking down the axis
const [a, b] = axis === 0 ? [1, 2] : axis === 1 ? [2, 0] : [0, 1];
const radians = rotation.angle * Math.PI / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
// minecraft's rescale keeps a 45 degree face as wide as the block
const scale = rotation.rescale ? 1 / Math.max(Math.abs(cos), Math.abs(sin)) : 1;
const origin = rotation.origin.map((v) => v / 16);
return (point) => {
const out = [...point];
const da = point[a] - origin[a];
const db = point[b] - origin[b];
out[a] = origin[a] + (da * cos - db * sin) * scale;
out[b] = origin[b] + (da * sin + db * cos) * scale;
return out;
};
}
// which way a quad faces most, whether it's flat against the block's edge, and whether it faces straight along an axis
function classify(p: number[]): { face: number; flush: boolean; aligned: boolean } {
const e1 = [p[3] - p[0], p[4] - p[1], p[5] - p[2]];
const e2 = [p[9] - p[0], p[10] - p[1], p[11] - p[2]];
const normal = [e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]];
let face = 0;
let best = -Infinity;
FACE_NORMALS.forEach((n, i) => {
const dot = n[0] * normal[0] + n[1] * normal[1] + n[2] * normal[2];
if (dot > best) {
best = dot;
face = i;
}
});
const axis = FACE_NORMALS[face].findIndex((v) => v !== 0);
const edge = FACE_NORMALS[face][axis] > 0 ? 1 : 0;
const aligned = [1, 2, 3].every((k) => Math.abs(p[k * 3 + axis] - p[axis]) < EPSILON);
const flush = aligned && Math.abs(p[axis] - edge) < EPSILON;
return { face, flush, aligned };
}
+201
View File
@@ -0,0 +1,201 @@
// .bmod files: a built mod in one zip, what servers load from server_mods/ and send to players. see "Mod files" in
// MODS.md. the same code reads them on the server and in the browser, so it only works on bytes
import { unzipSync, zipSync } from "fflate";
import type { ModelJson } from "./block_models.ts";
import {
type BlockJson,
ID_PATTERN,
type ItemJson,
type ManifestJson,
type OreJson,
type RecipeJson,
validate_block,
validate_item,
validate_manifest,
validate_model,
validate_ore,
validate_recipe,
} from "./mod_data.ts";
import type { ModData } from "./mod_loader.ts";
export const BMOD_EXTENSION = ".bmod";
// what a .bmod can hold at most unzipped, so a small file can't unzip into gigabytes
const MAX_FILES = 4096;
const MAX_UNZIPPED_BYTES = 64 * 1024 * 1024;
// written into every entry, so packing the same mod twice gives the same bytes and the same hash
const FIXED_TIME = new Date(Date.UTC(2020, 0, 1));
export type ScriptSide = "server" | "client" | "worldgen";
export const SCRIPT_SIDES: ScriptSide[] = ["server", "client", "worldgen"];
export interface Bmod {
manifest: ManifestJson;
data: ModData;
// bundled javascript, one module per side
scripts: Partial<Record<ScriptSide, string>>;
// png bytes by texture id, like copper_tools:copper_block
textures: Map<string, Uint8Array>;
credits?: string;
}
export class BmodError extends Error {
constructor(file: string, problems: string[]) {
super(`${file}: ${problems.join("; ")}`);
this.name = "BmodError";
}
}
// the zip's layout:
// manifest.json the mod's manifest, with "scripts" and "credits" pointing into the zip
// data.json every block, model, item, recipe and ore
// scripts/<side>.js
// textures/<name>.png the texture <mod id>:<name>
// credits.md
export function write_bmod(bmod: Bmod): Uint8Array<ArrayBuffer> {
const text = (value: string) => new TextEncoder().encode(value);
const manifest: ManifestJson = { ...bmod.manifest, scripts: {} };
delete manifest.credits;
const files: Record<string, Uint8Array> = {};
for (const side of SCRIPT_SIDES) {
const code = bmod.scripts[side];
if (code !== undefined) {
files[`scripts/${side}.js`] = text(code);
manifest.scripts![side] = `scripts/${side}.js`;
}
}
if (Object.keys(manifest.scripts!).length === 0) delete manifest.scripts;
if (bmod.credits !== undefined) {
files["credits.md"] = text(bmod.credits);
manifest.credits = "credits.md";
}
for (const [id, png] of bmod.textures) {
files[`textures/${id.split(":")[1]}.png`] = png;
}
files["manifest.json"] = text(JSON.stringify(manifest, null, "\t"));
files["data.json"] = text(JSON.stringify(bmod.data));
// sorted, so the order doesn't depend on how the files were collected
const sorted: Record<string, Uint8Array> = {};
for (const name of Object.keys(files).sort()) sorted[name] = files[name];
return zipSync(sorted, { mtime: FIXED_TIME, level: 9 }) as Uint8Array<ArrayBuffer>;
}
// opens and checks a .bmod. file is only for error messages. throws BmodError listing what's wrong
export function read_bmod(bytes: Uint8Array, file: string): Bmod {
let files: Record<string, Uint8Array>;
let count = 0;
let total = 0;
try {
files = unzipSync(bytes, {
filter(entry) {
count += 1;
total += entry.originalSize;
if (count > MAX_FILES || total > MAX_UNZIPPED_BYTES) {
throw new Error(`unzips to more than ${MAX_FILES} files or ${MAX_UNZIPPED_BYTES >> 20} MB`);
}
return !entry.name.endsWith("/");
},
});
} catch (e) {
throw new BmodError(file, [`isn't a readable .bmod: ${(e as Error).message}`]);
}
const problems: string[] = [];
const json = (name: string): unknown => {
const content = files[name];
if (!content) {
problems.push(`${name} is missing`);
return undefined;
}
try {
return JSON.parse(new TextDecoder().decode(content));
} catch (e) {
problems.push(`${name} isn't valid json: ${(e as Error).message}`);
return undefined;
}
};
const text = (name: string) => files[name] && new TextDecoder().decode(files[name]);
const manifest = json("manifest.json") as ManifestJson | undefined;
const raw_data = json("data.json") as Partial<ModData> | undefined;
if (!manifest || !raw_data) throw new BmodError(file, problems);
for (const problem of validate_manifest(manifest, manifest.id)) problems.push(`manifest.json: ${problem}`);
if (problems.length > 0) throw new BmodError(file, problems);
const mod = manifest.id;
const data: ModData = {
blocks: checked(raw_data.blocks, "blocks", validate_block, problems) as BlockJson[],
models: checked(raw_data.models, "models", validate_model, problems) as ModelJson[],
items: checked(raw_data.items, "items", validate_item, problems) as ItemJson[],
recipes: checked(raw_data.recipes, "recipes", validate_recipe, problems) as RecipeJson[],
ores: checked(raw_data.ores, "ores", validate_ore, problems) as OreJson[],
};
// a mod only registers ids in its own namespace
for (const { id } of [...data.blocks, ...data.models, ...data.items]) {
if (typeof id === "string" && id.split(":")[0] !== mod) problems.push(`${id} isn't in this mod's namespace`);
}
const scripts: Bmod["scripts"] = {};
for (const [side, path] of Object.entries(manifest.scripts ?? {})) {
const code = text(path);
if (!SCRIPT_SIDES.includes(side as ScriptSide) || code === undefined) {
problems.push(`manifest.json: scripts.${side} ${path} isn't in the file`);
} else {
scripts[side as ScriptSide] = code;
}
}
const credits = manifest.credits === undefined ? undefined : text(manifest.credits);
if (manifest.credits !== undefined && credits === undefined) {
problems.push(`manifest.json: credits ${manifest.credits} isn't in the file`);
}
const textures = new Map<string, Uint8Array>();
for (const [name, content] of Object.entries(files)) {
const match = name.match(/^textures\/(.+)\.png$/);
if (!match) continue;
const id = `${mod}:${match[1]}`;
const size = png_size(content);
if (!ID_PATTERN.test(id)) {
problems.push(`${name}: texture names must be a-z, 0-9 and _`);
} else if (!size) {
problems.push(`${name}: isn't a png`);
} else if (size.width !== 16 || size.height !== 16) {
problems.push(`${name}: is ${size.width}×${size.height}, textures must be 16×16`);
} else {
textures.set(id, content);
}
}
if (problems.length > 0) throw new BmodError(file, problems);
return { manifest, data, scripts, textures, credits };
}
// the same mod with its server script taken out, what players get
export function client_copy(bmod: Bmod): Bmod {
const scripts = { ...bmod.scripts };
delete scripts.server;
return { ...bmod, scripts };
}
function checked(list: unknown, name: string, validate: (json: unknown) => string[], problems: string[]): unknown[] {
if (list === undefined) return [];
if (!Array.isArray(list)) {
problems.push(`data.json: ${name} must be a list`);
return [];
}
list.forEach((entry, i) => {
for (const problem of validate(entry)) problems.push(`data.json: ${name}[${i}]: ${problem}`);
});
return list;
}
// reads the size out of the png header instead of decoding the image
export function png_size(bytes: Uint8Array): { width: number; height: number } | undefined {
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
if (bytes.length < 24 || !signature.every((b, i) => bytes[i] === b)) return undefined;
const view = new DataView(bytes.buffer, bytes.byteOffset);
return { width: view.getUint32(16), height: view.getUint32(20) };
}
-18
View File
@@ -1,18 +0,0 @@
import { Component } from "$/common/ecs/component.ts";
export class Position extends Component {
x: number;
y: number;
z: number;
constructor(x: number, y: number, z = 0) {
super();
this.x = x;
this.y = y;
this.z = z;
}
clone() {
return new Position(this.x, this.y, this.z);
}
}
-14
View File
@@ -1,14 +0,0 @@
import { Component } from "$/common/ecs/component.ts";
export class Velocity extends Component {
vx: number;
vy: number;
vz: number;
constructor(vx: number, vy: number, vz = 0) {
super();
this.vx = vx;
this.vy = vy;
this.vz = vz;
}
}
+8 -1
View File
@@ -20,9 +20,16 @@ export const ID_MASK = 0xFFFF;
export const STATE_SHIFT = 16; export const STATE_SHIFT = 16;
export const CHUNK_SIZE = 16; export const CHUNK_SIZE = 16;
export const CHUNK_HEIGHT = 128; export const CHUNK_HEIGHT = 256;
// the top of oceans and rivers
export const SEA_LEVEL = 64;
export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE; export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE;
// the player's collision box, position is the middle of its feet
export const PLAYER_WIDTH = 0.55;
export const PLAYER_HEIGHT = 1.79;
export const PLAYER_EYE_HEIGHT = 1.69;
// where a block placed against each face of another block goes // where a block placed against each face of another block goes
export const FACE_OFFSETS: Record<Faces, { x: number; y: number; z: number }> = { export const FACE_OFFSETS: Record<Faces, { x: number; y: number; z: number }> = {
top: { x: 0, y: 1, z: 0 }, top: { x: 0, y: 1, z: 0 },
-9
View File
@@ -1,9 +0,0 @@
export abstract class Component {
__component = true;
}
export abstract class SerializableComponent extends Component {
abstract serialize(): unknown;
// right, this is static you cant do this,,
// abstract deserialize<T>(data: unknown): T;
}
-28
View File
@@ -1,28 +0,0 @@
import type { Component } from "./component.ts";
// deno-lint-ignore no-explicit-any
type ComponentConstructor<T extends Component> = new (...args: any[]) => T;
export class Entity {
id: string;
// deno-lint-ignore no-explicit-any
components = new Map<ComponentConstructor<any>, Component>();
active = true;
constructor(id: string = crypto.randomUUID()) {
this.id = id;
}
add<T extends Component>(component: T): T {
this.components.set(component.constructor as ComponentConstructor<T>, component);
return component;
}
get<T extends Component>(type: ComponentConstructor<T>): T | undefined {
return this.components.get(type) as T;
}
get_all(): Iterable<Component> {
return this.components.values();
}
}
-4
View File
@@ -1,4 +0,0 @@
export * from "./component.ts";
export * from "./entity.ts";
export * from "./system.ts";
export * from "./world.ts";
-5
View File
@@ -1,5 +0,0 @@
import type { World } from "./world.ts";
export abstract class System {
abstract update(world: World, delta: number): void;
}
-80
View File
@@ -1,80 +0,0 @@
import type { Entity } from "./entity.ts";
import type { System } from "./system.ts";
export class World {
#entities = new Set<Entity>();
#entities_for_deletion = new Set<Entity>();
#systems = new Map<string, Set<System>>();
#tags = new Map<string, Entity[]>();
#states = new Set<string>();
#state: string = "";
#new_state: string | undefined;
constructor(initial_state: string) {
this.#state = initial_state;
this.add_state("*");
}
add_state(new_state: string) {
this.#states.add(new_state);
this.#systems.set(new_state, new Set());
}
add_entity(entity: Entity) {
this.#entities.add(entity);
}
add_system(system: System, state: string) {
console.assert(this.#states.has(state));
this.#systems.get(state)!.add(system);
}
add_tag(tag: string, entities: Entity[]) {
this.#tags.set(tag, entities);
}
get_tag(tag: string) {
return this.#tags.get(tag);
}
update(delta: number) {
for (const system of this.#systems.get("*") ?? []) {
system.update(this, delta);
}
for (const system of this.#systems.get(this.#state) ?? []) {
system.update(this, delta);
}
// should be faster than Set.prototype.difference lol
for (const entity of this.#entities_for_deletion) {
this.#entities.delete(entity);
}
if (this.#new_state) {
this.#state = this.#new_state;
this.#new_state = undefined;
}
}
get_entities() {
return this.#entities;
}
delete_entity(entity: Entity) {
this.#entities_for_deletion.add(entity);
}
clear_entities() {
for (const entity of this.#entities) {
this.#entities_for_deletion.add(entity);
}
}
get state() {
return this.#state;
}
set state(new_state: string) {
this.#new_state = new_state;
}
}
+34 -16
View File
@@ -1,4 +1,5 @@
import type { ItemStack } from "./inventory.ts"; import type { ItemStack } from "./inventory.ts";
import type { BlockVariant } from "./block_models.ts";
export class EverythingRegistry { export class EverythingRegistry {
static #key_to_id = new Map<string, Map<string, number>>(); static #key_to_id = new Map<string, Map<string, number>>();
@@ -65,16 +66,9 @@ export class EverythingRegistry {
} }
} }
interface TextureSideTopBottom { // one texture for everything, or the model's texture variables: { top, bottom, side } or { front, side } for
top: string; // cubes, { crop } for engine:crop and so on
bottom: string; export type BlockTextures = string | Record<string, string>;
side: string;
}
interface TextureFront {
front: string;
side: string;
}
export interface BlockStateDefinition { export interface BlockStateDefinition {
name: string; name: string;
@@ -88,15 +82,36 @@ interface CompiledStateDefinition {
shift: number; shift: number;
} }
interface BlockStateVariant { // solid: fully opaque. cutout: texels are either opaque or see-through (leaves).
model: string; // translucent: blended and sorted back to front (water, glass)
y: number; export const RENDER_LAYERS = ["solid", "cutout", "translucent"] as const;
export type RenderLayer = typeof RENDER_LAYERS[number];
// like minecraft: solid blocks stop light, everything else lets it through unless it says otherwise
export function block_light_opacity(block: BlockRegistry | undefined): number {
if (!block) return 0;
return block.light_opacity ?? ((block.render_layer ?? "solid") === "solid" ? 15 : 0);
}
export function block_light_emission(block: BlockRegistry | undefined): number {
return block?.light_emission ?? 0;
} }
export interface BlockRegistry { export interface BlockRegistry {
id: string; id: string;
textures: string | TextureSideTopBottom | TextureFront; // what players see, like "Iron Ore". made from the id when not set, see display_name in common/utils.ts
transparent?: boolean; name?: string;
textures: BlockTextures;
// the block model, engine:cube when not set. see common/block_models.ts
model?: string;
// solid when not set. anything else doesn't hide its neighbors' faces
render_layer?: RenderLayer;
// hide faces between two of this block, like glass and water. defaults to true for translucent blocks
cull_same?: boolean;
// light level 0-15 it gives off
light_emission?: number;
// how much light going through it loses, 0-15. see block_light_opacity for the default
light_opacity?: number;
alpha?: number; alpha?: number;
has_collision: boolean; has_collision: boolean;
@@ -106,7 +121,8 @@ export interface BlockRegistry {
drop_table?: string; drop_table?: string;
states?: BlockStateDefinition[]; states?: BlockStateDefinition[];
variants?: Record<string, BlockStateVariant>; // a different model or textures for some states, by conditions like "age=7". the first match wins
variants?: Record<string, BlockVariant>;
// right clicking it does something instead of placing a block, clients don't predict placing against it. // right clicking it does something instead of placing a block, clients don't predict placing against it.
// behavior runs on the server, see server/game/blocks.ts // behavior runs on the server, see server/game/blocks.ts
@@ -121,6 +137,8 @@ export interface BlockRegistry {
export interface ItemRegistry<T = unknown | undefined> { export interface ItemRegistry<T = unknown | undefined> {
texture_id: string | ((item: ItemStack<T>) => string); texture_id: string | ((item: ItemStack<T>) => string);
// what players see, like "Iron Ingot". made from the id when not set, see display_name in common/utils.ts
name?: string;
block_id?: string; block_id?: string;
tool_type?: string; tool_type?: string;
max_stack?: number; max_stack?: number;
+29 -313
View File
@@ -1,304 +1,23 @@
import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D, NoiseFunction3D } from "@paulaboks/rng"; // generating a chunk: the overworld's terrain (common/worldgen), then ores, then mods' features.
// runs in chunk workers on the server and every client, which must all get the same blocks
import { Alea } from "@paulaboks/rng";
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts"; import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts";
import type { FeatureChunk } from "$/common/mod_api/worldgen.ts"; import type { FeatureChunk } from "$/common/mod_api/worldgen.ts";
import type { OreJson } from "$/common/mod_data.ts"; import type { OreJson } from "$/common/mod_data.ts";
import { generate_overworld } from "./worldgen/overworld.ts";
import { named_noise_2d, named_noise_3d } from "./worldgen/noise.ts";
// generation runs in a worker now, so it only needs somewhere to put blocks export { named_noise_2d, named_noise_3d };
export interface BlockSink {
add_block(block: { x: number; y: number; z: number; id: string }): void;
// the surface height and biome of each column, for later passes
set_column?(x: number, z: number, height: number, biome: string): void;
}
type Biome = // the base game's ores, placed like mods' ores.json. the world is 256 tall with the sea at 64
| "desert" const BASE_ORES: OreJson[] = [
| "plains" { id: "bworld:coal_ore", replaces: "bworld:stone", min_y: 5, max_y: 200, scale: 0.05, threshold: 0.55 },
| "forest" { id: "bworld:copper_ore", replaces: "bworld:stone", min_y: 5, max_y: 110, scale: 0.06, threshold: 0.6 },
| "jungle" { id: "bworld:tin_ore", replaces: "bworld:stone", min_y: 5, max_y: 70, scale: 0.06, threshold: 0.62 },
| "tundra" { id: "bworld:iron_ore", replaces: "bworld:stone", min_y: 5, max_y: 80, scale: 0.05, threshold: 0.65 },
| "taiga" { id: "bworld:gold_ore", replaces: "bworld:stone", min_y: 5, max_y: 36, scale: 0.04, threshold: 0.7 },
| "snow"
| "savanna"
| "swamp";
type OreDef = {
id: string;
min_y: number;
max_y: number;
scale: number;
threshold: number;
};
const ORES: OreDef[] = [
{ id: "bworld:coal_ore", min_y: 20, max_y: 120, scale: 0.05, threshold: 0.55 },
{ id: "bworld:copper_ore", min_y: 10, max_y: 80, scale: 0.06, threshold: 0.6 },
{ id: "bworld:tin_ore", min_y: 5, max_y: 60, scale: 0.06, threshold: 0.62 },
{ id: "bworld:iron_ore", min_y: 5, max_y: 50, scale: 0.05, threshold: 0.65 },
{ id: "bworld:gold_ore", min_y: 0, max_y: 30, scale: 0.04, threshold: 0.7 },
]; ];
function get_biome(temp: number, moisture: number): Biome {
if (temp > 0.6) {
if (moisture < -0.2) {
return "desert";
}
if (moisture > 0.4) {
return "jungle";
}
return "savanna";
}
if (temp > 0) {
if (moisture > 0.5) {
return "swamp";
}
if (moisture > 0) {
return "forest";
}
return "plains";
}
if (temp > -0.5) {
return "taiga";
}
return "tundra";
}
function get_surface_block(biome: Biome) {
if (biome === "desert") {
return "bworld:sand";
} else if (biome === "tundra") {
return "bworld:snow";
}
return "bworld:grass";
}
function biome_height_modifier(biome: Biome) {
if (biome === "desert") {
return 0.2;
}
if (biome === "plains") {
return 0.4;
}
if (biome === "forest") {
return 0.5;
}
if (biome === "jungle") {
return 0.45;
}
if (biome === "taiga") {
return 0.55;
}
if (biome === "tundra") {
return 0.35;
}
if (biome === "savanna") {
return 0.4;
}
if (biome === "swamp") {
return 0.35;
}
return 0.4;
}
function fractal_noise(noise: NoiseFunction2D, x: number, y: number, octaves = 2) {
let value = 0;
let amp = 1;
let freq = 1;
let max = 0;
for (let i = 0; i < octaves; i++) {
value += noise(x * freq, y * freq) * amp;
max += amp;
amp *= 0.5;
freq *= 2;
}
return value / max;
}
function get_terrain_height(base: number, biome: Biome, x: number, z: number, noise: NoiseFunction2D) {
const biomeMod = biome_height_modifier(biome);
const main = fractal_noise(noise, x * 0.003, z * 0.003) * 15;
const detail = fractal_noise(noise, x * 0.01, z * 0.01) * 3;
return Math.floor(base + biomeMod * 20 + main + detail);
}
function can_place_tree(tree_map: boolean[][], local_x: number, local_z: number) {
const TREE_SPACING = 4;
for (let dx = -TREE_SPACING; dx <= TREE_SPACING; dx++) {
for (let dz = -TREE_SPACING; dz <= TREE_SPACING; dz++) {
const nx = local_x + dx;
const nz = local_z + dz;
if (nx >= 0 && nx < CHUNK_SIZE && nz >= 0 && nz < CHUNK_SIZE && tree_map[nx][nz]) {
return false;
}
}
}
return true;
}
function place_tree(dimension: BlockSink, rng: Alea, x: number, y: number, z: number, biome: Biome) {
const height = Math.floor(rng.next() * 3) + (biome === "jungle" ? 8 : 4);
const trunk_block = "bworld:log";
const leaves_block = "bworld:leaves";
for (let i = 0; i < height; i++) {
dimension.add_block({ x, y: y + i, z, id: trunk_block });
}
for (let dx = -2; dx <= 2; dx++) {
for (let dz = -2; dz <= 2; dz++) {
for (let dy = -1; dy <= 1; dy++) {
if (Math.abs(dx) + Math.abs(dz) + Math.abs(dy) <= 3) {
dimension.add_block({
x: x + dx,
y: y + height + dy,
z: z + dz,
id: leaves_block,
});
}
}
}
}
}
const TREE_THRESHOLD: Record<Biome, number> = {
forest: 0.5,
jungle: 0.3,
taiga: 0.6,
plains: 0.95,
desert: 1,
tundra: 1,
savanna: 0.65,
swamp: 0.5,
snow: 0.8,
};
function should_place_tree(feature_noise: NoiseFunction2D, biome: Biome, x: number, z: number) {
const n = feature_noise(x * 0.1, z * 0.1);
return n > (TREE_THRESHOLD[biome] ?? 0.8);
}
interface SeedNoises {
height_noise: NoiseFunction2D;
temp_noise: NoiseFunction2D;
moisture_noise: NoiseFunction2D;
feature_noise: NoiseFunction2D;
ore_noises: NoiseFunction3D[];
}
// building the permutation tables is expensive, only do it once per seed
const noise_cache = new Map<string, SeedNoises>();
function get_noises(seed: string): SeedNoises {
let noises = noise_cache.get(seed);
if (!noises) {
noises = {
height_noise: create_noise_2d(new Alea(seed + "_height")),
temp_noise: create_noise_2d(new Alea(seed + "_temp")),
moisture_noise: create_noise_2d(new Alea(seed + "_moisture")),
feature_noise: create_noise_2d(new Alea(seed + "_feature")),
ore_noises: ORES.map((ore) => create_noise_3d(new Alea(seed + "_" + ore.id))),
};
noise_cache.set(seed, noises);
}
return noises;
}
export function generate_chunk(dimension: BlockSink, cx: number, cz: number, seed = "seed") {
const { height_noise, temp_noise, moisture_noise, feature_noise, ore_noises } = get_noises(seed);
// seeded per chunk so every client generates the exact same terrain
const rng = new Alea(`${seed}_chunk_${cx}_${cz}`);
const biome_scale = 0.003;
const terrain_scale = 0.01;
const tree_map: boolean[][] = Array.from({ length: CHUNK_SIZE }, () => Array(CHUNK_SIZE).fill(false));
for (let x = 0; x < CHUNK_SIZE; x++) {
for (let z = 0; z < CHUNK_SIZE; z++) {
const wx = cx * CHUNK_SIZE + x;
const wz = cz * CHUNK_SIZE + z;
const temp = temp_noise(wx * biome_scale, wz * biome_scale);
const moisture = moisture_noise(wx * biome_scale, wz * biome_scale);
const biome = get_biome(temp, moisture);
const height_noise_value = fractal_noise(height_noise, wx * terrain_scale, wz * terrain_scale);
const base_height = (height_noise_value + 1) * 15 + 50;
const height = get_terrain_height(base_height, biome, wx, wz, height_noise);
const surface_block = get_surface_block(biome);
dimension.set_column?.(wx, wz, height, `bworld:${biome}`);
for (let y = 0; y <= height; y++) {
let block = "bworld:stone";
if (y < height - 3) {
for (let i = 0; i < ORES.length; i++) {
const ore = ORES[i];
if (y >= ore.min_y && y <= ore.max_y) {
const noise = ore_noises[i](
wx * ore.scale,
y * ore.scale,
wz * ore.scale,
);
if (noise > ore.threshold) {
block = ore.id;
break;
}
}
}
}
if (y === height) {
block = surface_block;
} else if (y > height - 4) {
block = "bworld:dirt";
}
if (biome === "swamp" && y === height && rng.next() < 0.2) {
block = "bworld:water";
}
dimension.add_block({ x: wx, y, z: wz, id: block });
}
if (should_place_tree(feature_noise, biome, wx, wz) && can_place_tree(tree_map, x, z)) {
place_tree(dimension, rng, wx, height + 1, wz, biome);
tree_map[x][z] = true;
}
}
}
}
// noise the way mods get it: create_noise_2d(new Alea(seed + "_" + name)), the same as the base terrain's
const mod_noise_2d = new Map<string, NoiseFunction2D>();
const mod_noise_3d = new Map<string, NoiseFunction3D>();
export function named_noise_2d(seed: string, name: string): NoiseFunction2D {
const key = `${seed}_${name}`;
let noise = mod_noise_2d.get(key);
if (!noise) {
noise = create_noise_2d(new Alea(key));
mod_noise_2d.set(key, noise);
}
return noise;
}
export function named_noise_3d(seed: string, name: string): NoiseFunction3D {
const key = `${seed}_${name}`;
let noise = mod_noise_3d.get(key);
if (!noise) {
noise = create_noise_3d(new Alea(key));
mod_noise_3d.set(key, noise);
}
return noise;
}
// what mods add to generation, see "World generation" in MODS.md // what mods add to generation, see "World generation" in MODS.md
export interface WorldgenSetup { export interface WorldgenSetup {
ores: OreJson[]; ores: OreJson[];
@@ -308,7 +27,7 @@ export interface WorldgenSetup {
export interface RawChunk { export interface RawChunk {
// numeric block ids, only what this chunk generated itself // numeric block ids, only what this chunk generated itself
blocks: Uint32Array; blocks: Uint32Array;
// blocks it generated in other chunks (tree leaves), flattened as x, y, z, numeric id // blocks it generated in other chunks (from features like a mod's trees), flattened as x, y, z, numeric id
spills: Int32Array; spills: Int32Array;
} }
@@ -347,24 +66,21 @@ export function generate_raw_chunk(
blocks[y * CHUNK_AREA + lz * CHUNK_SIZE + lx] = nid; blocks[y * CHUNK_AREA + lz * CHUNK_SIZE + lx] = nid;
}; };
generate_chunk( const id = (name: string) => {
{ const nid = block_ids[name];
add_block(block) { return nid === undefined ? AIR : default_values?.[nid] ?? nid;
const nid = block_ids[block.id]; };
if (nid !== undefined) { generate_overworld(blocks, heights, biomes, chunk_x, chunk_z, seed, {
set(block.x, block.y, block.z, nid); stone: id("bworld:stone"),
} dirt: id("bworld:dirt"),
}, grass: id("bworld:grass"),
set_column(x, z, height, biome) { sand: id("bworld:sand"),
const index = (z - chunk_z * CHUNK_SIZE) * CHUNK_SIZE + (x - chunk_x * CHUNK_SIZE); snow: id("bworld:snow"),
heights[index] = height; water: id("bworld:water"),
biomes[index] = biome; log: id("bworld:log"),
}, leaves: id("bworld:leaves"),
}, }, (x, y, z, block) => spills.push(x, y, z, block));
chunk_x, generate_ores(blocks, chunk_x, chunk_z, seed, block_ids, BASE_ORES, default_values);
chunk_z,
seed,
);
if (worldgen) { if (worldgen) {
generate_ores(blocks, chunk_x, chunk_z, seed, block_ids, worldgen.ores, default_values); generate_ores(blocks, chunk_x, chunk_z, seed, block_ids, worldgen.ores, default_values);
+37 -4
View File
@@ -5,6 +5,8 @@ export interface ItemData {
id: string; id: string;
count: number; count: number;
data?: unknown; data?: unknown;
// what server scripts say about it (get_lore), only sent to players, never saved
lore?: string;
} }
export class ItemStack<T = unknown | undefined> { export class ItemStack<T = unknown | undefined> {
@@ -12,6 +14,8 @@ export class ItemStack<T = unknown | undefined> {
amount: number; amount: number;
max_amount: number; max_amount: number;
data?: T; data?: T;
// on clients, the lore the server's scripts gave it when it was last synced
lore?: string;
constructor(type_id: string | string, amount: number = 1, max_amount?: number) { constructor(type_id: string | string, amount: number = 1, max_amount?: number) {
const item_info = EverythingRegistry.get<ItemRegistry>("items", type_id); const item_info = EverythingRegistry.get<ItemRegistry>("items", type_id);
@@ -27,6 +31,7 @@ export class ItemStack<T = unknown | undefined> {
clone(): ItemStack { clone(): ItemStack {
const item = new ItemStack(this.type_id, this.amount, this.max_amount); const item = new ItemStack(this.type_id, this.amount, this.max_amount);
item.data = structuredClone(this.data); item.data = structuredClone(this.data);
item.lore = this.lore;
return item; return item;
} }
@@ -36,11 +41,24 @@ export class ItemStack<T = unknown | undefined> {
: { id: this.type_id, count: this.amount, data: this.data }; : { id: this.type_id, count: this.amount, data: this.data };
} }
// what players are sent: to_data with the lore from the item's get_lore, which only runs on the server
to_synced_data(): ItemData {
const data = this.to_data();
const lore = EverythingRegistry.get<ItemRegistry>("items", this.type_id)?.get_lore?.(this);
if (lore) {
data.lore = lore;
}
return data;
}
static from_data(data: ItemData): ItemStack { static from_data(data: ItemData): ItemStack {
const item = new ItemStack(data.id, data.count); const item = new ItemStack(data.id, data.count);
if (data.data !== undefined) { if (data.data !== undefined) {
item.data = data.data; item.data = data.data;
} }
if (data.lore !== undefined) {
item.lore = data.lore;
}
return item; return item;
} }
} }
@@ -49,7 +67,7 @@ export class ContainerSlot {
#item_stack: ItemStack | undefined; #item_stack: ItemStack | undefined;
has_item() { has_item() {
return this.#item_stack !== undefined; return this.get_item() !== undefined;
} }
set_item(item_stack: ItemStack | undefined) { set_item(item_stack: ItemStack | undefined) {
@@ -60,11 +78,15 @@ export class ContainerSlot {
} }
get_item() { get_item() {
// scripts can count a stack down to nothing, it's gone then
if (this.#item_stack && this.#item_stack.amount <= 0) {
this.#item_stack = undefined;
}
return this.#item_stack; return this.#item_stack;
} }
get type_id() { get type_id() {
return this.#item_stack?.type_id; return this.get_item()?.type_id;
} }
set amount(new_amount: number) { set amount(new_amount: number) {
@@ -77,11 +99,11 @@ export class ContainerSlot {
} }
get amount(): number | undefined { get amount(): number | undefined {
return this.#item_stack?.amount; return this.get_item()?.amount;
} }
get max_amount() { get max_amount() {
return this.#item_stack?.max_amount; return this.get_item()?.max_amount;
} }
} }
@@ -136,6 +158,11 @@ export class Container {
return this.#slots.map((slot) => slot.get_item()?.to_data() ?? null); return this.#slots.map((slot) => slot.get_item()?.to_data() ?? null);
} }
// what players are sent, with lore, see ItemStack.to_synced_data
to_synced_data(): (ItemData | null)[] {
return this.#slots.map((slot) => slot.get_item()?.to_synced_data() ?? null);
}
load(data: (ItemData | null)[]) { load(data: (ItemData | null)[]) {
for (let i = 0; i < this.size; i += 1) { for (let i = 0; i < this.size; i += 1) {
const item = data[i]; const item = data[i];
@@ -254,6 +281,12 @@ function right_click(container: Container, index: number, cursor: Cursor) {
slot.amount = half; slot.amount = half;
} }
// whether take_output would take it
export function can_take_output(item: ItemStack, cursor: Cursor): boolean {
const holding = cursor.item;
return !holding || (holding.type_id === item.type_id && holding.max_amount - holding.amount >= item.amount);
}
// output slots (furnace result, crafting result) can only be taken from, all at once // output slots (furnace result, crafting result) can only be taken from, all at once
// returns whether it was taken // returns whether it was taken
export function take_output(item: ItemStack, cursor: Cursor): boolean { export function take_output(item: ItemStack, cursor: Cursor): boolean {
+5 -1
View File
@@ -11,7 +11,11 @@ export interface ClientContext {
net: ClientNet; net: ClientNet;
player: { readonly name: string; readonly position: Readonly<Position> }; player: { readonly name: string; readonly position: Readonly<Position> };
// read only, what this client sees // read only, what this client sees
world: { get_block(x: number, y: number, z: number): Id | undefined }; world: {
get_block(x: number, y: number, z: number): Id | undefined;
// ticks since 06:00 of day 1, see ServerWorld.time
readonly time: number;
};
log(...args: unknown[]): void; log(...args: unknown[]): void;
} }
+42 -6
View File
@@ -12,6 +12,7 @@ export interface ServerContext {
world: ServerWorld; world: ServerWorld;
players: PlayerList; players: PlayerList;
containers: ContainerApi; containers: ContainerApi;
items: ItemApi;
recipes: RecipeApi; recipes: RecipeApi;
ui: ServerUi; ui: ServerUi;
net: ServerNet; net: ServerNet;
@@ -123,8 +124,13 @@ export interface ServerWorld {
get_state(x: number, y: number, z: number, name: string): number | undefined; get_state(x: number, y: number, z: number, name: string): number | undefined;
set_state(x: number, y: number, z: number, name: string, value: number): boolean; set_state(x: number, y: number, z: number, name: string, value: number): boolean;
get_block_data<T>(x: number, y: number, z: number): T | undefined; get_block_data<T>(x: number, y: number, z: number): T | undefined;
// pops an item out of the block at x y z, like a broken block's drops
drop_item(x: number, y: number, z: number, item: ItemStack): void;
is_loaded(x: number, z: number): boolean; is_loaded(x: number, z: number): boolean;
readonly seed: string; readonly seed: string;
// ticks since 06:00 of day 1. a day is 36000 ticks (06:00 to 18:00), a night 12000
readonly time: number;
set_time(time: number): void;
} }
export interface Player { export interface Player {
@@ -134,6 +140,7 @@ export interface Player {
readonly inventory: Container; // 36 slots, hotbar is 0-8 readonly inventory: Container; // 36 slots, hotbar is 0-8
readonly selected_slot: number; readonly selected_slot: number;
readonly held_item: ItemStack | undefined; readonly held_item: ItemStack | undefined;
// what doesn't fit in the inventory drops at their feet
give_item(id: Id, count?: number, data?: unknown): void; give_item(id: Id, count?: number, data?: unknown): void;
send_message(text: string): void; send_message(text: string): void;
teleport(x: number, y: number, z: number): void; teleport(x: number, y: number, z: number): void;
@@ -176,11 +183,19 @@ export interface ContainerApi {
delete(id: string): void; delete(id: string): void;
} }
export interface ItemApi {
exists(id: Id): boolean;
// how many fit in one slot
max_stack(id: Id): number;
}
export interface RecipeApi { export interface RecipeApi {
furnace_result(input: Id): { output: ItemStack; cook_time: number } | undefined; furnace_result(input: Id): { output: ItemStack; cook_time: number } | undefined;
fuel_value(item: Id): number; // burn time in ticks, 0 if it isn't fuel fuel_value(item: Id): number; // burn time in ticks, 0 if it isn't fuel
is_fuel(item: Id): boolean; is_fuel(item: Id): boolean;
is_smeltable(item: Id): boolean; is_smeltable(item: Id): boolean;
// the smithing recipe for these items, whatever the material's count. addition is the optional third item
smithing(tool: Id, material: Id, addition?: Id): { result: Id; material_count: number } | undefined;
} }
// guis // guis
@@ -213,20 +228,41 @@ export type FormResult<T> = ({ canceled: true } & Partial<T>) | ({ canceled: fal
export type SlotFilter = "smeltable" | "fuel" | ((item: ItemStack) => boolean); export type SlotFilter = "smeltable" | "fuel" | ((item: ItemStack) => boolean);
export interface ContainerScreenOptions { export interface ContainerScreenOptions {
title: string;
container: Container; container: Container;
// x and y in slot units // x and y in slot units, below the player's inventory. can be fractional
layout: { slot: number; x: number; y: number; filter?: SlotFilter; output_only?: boolean }[]; layout: {
player_inventory?: boolean; slot: number;
bars?: { id: string; x: number; y: number; texture: Id }[]; x: number;
labels?: { x: number; y: number; property: string }[]; y: number;
// what can be put in, checked on the server
filter?: SlotFilter;
// can only be taken from, all at once, like the furnace result
output_only?: boolean;
// runs when a player takes from an output_only slot, before they get it. false stops them taking it
on_take?: (item: ItemStack) => boolean;
}[];
// height of the screen's area in slots, fits the layout and bars when not set
rows?: number;
// progress bars, filled with the property value divided by the property max
bars?: {
x: number;
y: number;
value: string;
max: string;
direction: "up" | "right";
empty_texture: Id;
full_texture: Id;
}[];
} }
export interface ScreenHandle<Props = unknown> { export interface ScreenHandle<Props = unknown> {
readonly player: Player; readonly player: Player;
readonly open: boolean;
// numbers the bars show, synced to the player when they change
set_property(id: string, value: number): void; set_property(id: string, value: number): void;
update(props: Props): void; // custom screens only update(props: Props): void; // custom screens only
close(): void; close(): void;
// when it closes for any reason: the player closed it, left, opened another screen, or it was closed
on_close(fn: () => void): void; on_close(fn: () => void): void;
} }
+157
View File
@@ -0,0 +1,157 @@
// checks between mods: dependencies, load order, and ids one mod uses from another. pure, so the build, check-mods
// and the server (on .bmod files) all use it
import type { BlockJson, ItemJson, OreJson, RecipeJson } from "./mod_data.ts";
import { BUILTIN_MODELS, type ModelJson } from "./block_models.ts";
export interface ModReport {
id: string;
errors: string[];
warnings: string[];
}
export interface LoadedMod {
id: string;
dir: string;
report: ModReport;
manifest?: Record<string, unknown>;
blocks: { file: string; json: BlockJson }[];
models: { file: string; json: ModelJson }[];
items: { file: string; json: ItemJson }[];
recipes: { file: string; json: RecipeJson }[];
ores: { file: string; json: OreJson }[];
textures: string[];
// absolute paths by texture id
texture_files: Map<string, string>;
}
// dependencies before the mods that need them, otherwise alphabetical
export function load_order(mods: LoadedMod[]): LoadedMod[] {
const by_id = new Map(mods.map((mod) => [mod.id, mod]));
const ordered: LoadedMod[] = [];
const state = new Map<string, "visiting" | "done">();
const visit = (mod: LoadedMod, path: string[]) => {
if (state.get(mod.id) === "done") return;
if (state.get(mod.id) === "visiting") {
throw new Error(`mods depend on each other in a circle: ${[...path, mod.id].join(" -> ")}`);
}
state.set(mod.id, "visiting");
const dependencies = ((mod.manifest?.dependencies ?? []) as { id: string }[]).map((d) => d.id).sort();
for (const dependency of dependencies) {
const other = by_id.get(dependency);
if (other) visit(other, [...path, mod.id]);
}
state.set(mod.id, "done");
ordered.push(mod);
};
for (const mod of [...mods].sort((a, b) => a.id.localeCompare(b.id))) {
visit(mod, []);
}
return ordered;
}
// engine_textures are the engine: texture ids, for telling whether a texture a mod uses exists
export function check_references(mods: LoadedMod[], engine_textures: Iterable<string>) {
const block_owners = new Map<string, string[]>();
const item_owners = new Map<string, string[]>();
const model_owners = new Map<string, string[]>();
const add = (map: Map<string, string[]>, id: string, mod: string) => map.set(id, [...(map.get(id) ?? []), mod]);
const textures = new Set<string>(engine_textures);
for (const mod of mods) {
for (const { json } of mod.blocks) {
add(block_owners, json.id, mod.id);
if (json.item !== false) add(item_owners, json.id, mod.id);
}
for (const { json } of mod.items) add(item_owners, json.id, mod.id);
for (const { json } of mod.models) add(model_owners, json.id, mod.id);
for (const texture of mod.textures) textures.add(texture);
}
const mod_ids = new Set(mods.map((mod) => mod.id));
for (const mod of mods) {
const { errors, warnings } = mod.report;
const dependencies = new Set(
((mod.manifest?.dependencies ?? []) as { id: string }[]).map((dep) => dep.id),
);
for (const dep of dependencies) {
if (!mod_ids.has(dep)) errors.push(`manifest.json: depends on "${dep}", which isn't installed`);
}
const uses = (file: string, id: string, what: "block" | "item" | "texture" | "model") => {
const namespace = id.split(":")[0];
if (namespace !== mod.id && namespace !== "engine" && !dependencies.has(namespace)) {
warnings.push(`${file}: uses ${id} but doesn't list "${namespace}" in dependencies`);
}
if (what === "model") {
if (!BUILTIN_MODELS[id] && !model_owners.has(id)) errors.push(`${file}: model ${id} doesn't exist`);
} else if (what === "texture") {
if (!textures.has(id)) warnings.push(`${file}: texture ${id} doesn't exist, it will show as missing`);
} else if (!(what === "block" ? block_owners : item_owners).has(id)) {
errors.push(`${file}: ${what} ${id} doesn't exist`);
}
};
for (const { file, json } of mod.blocks) {
if ((block_owners.get(json.id)?.length ?? 0) > 1) {
errors.push(`${file}: block ${json.id} is also defined by ${block_owners.get(json.id)!.join(", ")}`);
}
for (const variant of [json, ...Object.values(json.variants ?? {})]) {
if (variant.model) uses(file, variant.model, "model");
const textures = variant.textures ?? {};
for (const texture of typeof textures === "string" ? [textures] : Object.values(textures)) {
uses(file, texture, "texture");
}
}
if (json.drops) uses(file, json.drops, "item");
}
for (const { file, json } of mod.models) {
if ((model_owners.get(json.id)?.length ?? 0) > 1 || BUILTIN_MODELS[json.id]) {
errors.push(`${file}: model ${json.id} is defined more than once`);
}
for (const texture of Object.values(json.textures ?? {})) {
if (!texture.startsWith("#")) uses(file, texture, "texture");
}
for (const element of json.elements) {
for (const face of Object.values(element.faces)) {
if (face && !face.texture.startsWith("#")) uses(file, face.texture, "texture");
}
}
}
for (const { file, json } of mod.items) {
if ((item_owners.get(json.id)?.length ?? 0) > 1) {
errors.push(`${file}: item ${json.id} is also defined by ${item_owners.get(json.id)!.join(", ")}`);
}
uses(file, json.texture, "texture");
if (json.places) uses(file, json.places, "block");
}
for (const { file, json } of mod.recipes) {
switch (json.type) {
case "shaped":
for (const id of Object.values(json.key)) uses(file, id, "item");
uses(file, json.result.id, "item");
break;
case "furnace":
uses(file, json.input, "item");
uses(file, json.output.id, "item");
break;
case "fuel":
uses(file, json.item, "item");
break;
case "smithing":
uses(file, json.tool, "item");
uses(file, json.material.id, "item");
if (json.addition) uses(file, json.addition, "item");
uses(file, json.result, "item");
break;
}
}
for (const { file, json } of mod.ores) {
uses(file, json.id, "block");
uses(file, json.replaces, "block");
}
}
}
+168 -6
View File
@@ -1,6 +1,13 @@
// the json formats from MODS.md, and converting them to and from the engine's registry entries. // the json formats from MODS.md, and converting them to and from the engine's registry entries.
// the mod loader uses the from_json direction, tests use both to check nothing is lost // the mod loader uses the from_json direction, tests use both to check nothing is lost
import type { BlockRegistry, BlockStateDefinition, ItemRegistry } from "./everything_registry.ts"; import {
type BlockRegistry,
type BlockStateDefinition,
type ItemRegistry,
RENDER_LAYERS,
type RenderLayer,
} from "./everything_registry.ts";
import { type BlockVariant, MODEL_FACES, type ModelFace } from "./block_models.ts";
export const FORMAT_VERSION = 1; export const FORMAT_VERSION = 1;
export const ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/; export const ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/;
@@ -24,7 +31,15 @@ export interface ManifestJson {
export interface BlockJson { export interface BlockJson {
id: string; id: string;
name?: string;
textures: BlockTextures; textures: BlockTextures;
model?: string;
variants?: Record<string, BlockVariant>;
render_layer?: RenderLayer;
cull_same?: boolean;
light_emission?: number;
light_opacity?: number;
// replaced by render_layer, true means translucent
transparent?: boolean; transparent?: boolean;
alpha?: number; alpha?: number;
collision?: boolean; collision?: boolean;
@@ -52,6 +67,7 @@ export interface ItemJson {
tool?: string; tool?: string;
places?: string; places?: string;
max_stack?: number; max_stack?: number;
name?: string;
lore?: string; lore?: string;
components?: Record<string, unknown>; components?: Record<string, unknown>;
} }
@@ -64,7 +80,15 @@ export type RecipeJson =
result: { id: string; count: number }; result: { id: string; count: number };
} }
| { type: "furnace"; input: string; output: { id: string; count: number }; cook_time: number } | { type: "furnace"; input: string; output: { id: string; count: number }; cook_time: number }
| { type: "fuel"; item: string; burn_time: number }; | { type: "fuel"; item: string; burn_time: number }
// upgrades a tool at a smithing table, with some of a material and maybe one more item
| {
type: "smithing";
tool: string;
material: { id: string; count: number };
addition?: string;
result: string;
};
// the crafting grid's format, see server/game/crafting.ts // the crafting grid's format, see server/game/crafting.ts
export interface GridRecipe { export interface GridRecipe {
@@ -78,7 +102,13 @@ export interface GridRecipe {
export function block_to_json(block: BlockRegistry, has_item: boolean): BlockJson { export function block_to_json(block: BlockRegistry, has_item: boolean): BlockJson {
const json: BlockJson = { id: block.id, textures: block.textures }; const json: BlockJson = { id: block.id, textures: block.textures };
if (block.transparent) json.transparent = true; if (block.name !== undefined) json.name = block.name;
if (block.model !== undefined) json.model = block.model;
if (block.variants !== undefined) json.variants = block.variants;
if (block.render_layer && block.render_layer !== "solid") json.render_layer = block.render_layer;
if (block.cull_same !== undefined) json.cull_same = block.cull_same;
if (block.light_emission !== undefined) json.light_emission = block.light_emission;
if (block.light_opacity !== undefined) json.light_opacity = block.light_opacity;
if (block.alpha !== undefined) json.alpha = block.alpha; if (block.alpha !== undefined) json.alpha = block.alpha;
if (!block.has_collision) json.collision = false; if (!block.has_collision) json.collision = false;
if (block.toughness !== undefined) { if (block.toughness !== undefined) {
@@ -101,7 +131,14 @@ export function block_from_json(json: BlockJson): { block: BlockRegistry; has_it
textures: json.textures, textures: json.textures,
has_collision: json.collision ?? true, has_collision: json.collision ?? true,
}; };
if (json.transparent) block.transparent = true; if (json.name !== undefined) block.name = json.name;
if (json.model !== undefined) block.model = json.model;
if (json.variants !== undefined) block.variants = json.variants;
const render_layer = json.render_layer ?? (json.transparent ? "translucent" : undefined);
if (render_layer && render_layer !== "solid") block.render_layer = render_layer;
if (json.cull_same !== undefined) block.cull_same = json.cull_same;
if (json.light_emission !== undefined) block.light_emission = json.light_emission;
if (json.light_opacity !== undefined) block.light_opacity = json.light_opacity;
if (json.alpha !== undefined) block.alpha = json.alpha; if (json.alpha !== undefined) block.alpha = json.alpha;
if (json.mining) { if (json.mining) {
block.toughness = json.mining.toughness; block.toughness = json.mining.toughness;
@@ -126,6 +163,7 @@ export function item_to_json(id: string, item: ItemRegistry): ItemJson {
if (item.tool_type !== undefined) json.tool = item.tool_type; if (item.tool_type !== undefined) json.tool = item.tool_type;
if (item.block_id !== undefined) json.places = item.block_id; if (item.block_id !== undefined) json.places = item.block_id;
if (item.max_stack !== undefined) json.max_stack = item.max_stack; if (item.max_stack !== undefined) json.max_stack = item.max_stack;
if (item.name !== undefined) json.name = item.name;
if (item.lore !== undefined) json.lore = item.lore; if (item.lore !== undefined) json.lore = item.lore;
if (item.components) json.components = item.components; if (item.components) json.components = item.components;
return json; return json;
@@ -136,6 +174,7 @@ export function item_from_json(json: ItemJson): ItemRegistry {
if (json.tool !== undefined) item.tool_type = json.tool; if (json.tool !== undefined) item.tool_type = json.tool;
if (json.places !== undefined) item.block_id = json.places; if (json.places !== undefined) item.block_id = json.places;
if (json.max_stack !== undefined) item.max_stack = json.max_stack; if (json.max_stack !== undefined) item.max_stack = json.max_stack;
if (json.name !== undefined) item.name = json.name;
if (json.lore !== undefined) item.lore = json.lore; if (json.lore !== undefined) item.lore = json.lore;
if (json.components) item.components = json.components; if (json.components) item.components = json.components;
return item; return item;
@@ -237,13 +276,56 @@ export function validate_manifest(json: unknown, folder_name: string): Problems
export function validate_block(json: unknown): Problems { export function validate_block(json: unknown): Problems {
if (!is_object(json)) return ["block must be an object"]; if (!is_object(json)) return ["block must be an object"];
const problems = validate_id(json.id, "id"); const problems = validate_id(json.id, "id");
if (json.model !== undefined) problems.push(...validate_id(json.model, "model"));
const is_cube = json.model === undefined || json.model === "engine:cube";
const textures = json.textures; const textures = json.textures;
if (is_cube) {
const texture_ok = typeof textures === "string" || const texture_ok = typeof textures === "string" ||
(is_object(textures) && (is_object(textures) &&
(["top", "bottom", "side"].every((k) => typeof textures[k] === "string") || (["top", "bottom", "side"].every((k) => typeof textures[k] === "string") ||
["front", "side"].every((k) => typeof textures[k] === "string"))); ["front", "side"].every((k) => typeof textures[k] === "string")));
if (!texture_ok) problems.push("textures must be a texture id, { top, bottom, side } or { front, side }"); if (!texture_ok) problems.push("textures must be a texture id, { top, bottom, side } or { front, side }");
for (const key of ["transparent", "collision", "item", "interactive", "replaceable"]) { } else {
problems.push(...validate_textures(textures, "textures"));
}
if (json.variants !== undefined) {
if (!is_object(json.variants)) {
problems.push('variants must map conditions like "age=3" to { model, textures, y }');
} else {
const names = new Set(Array.isArray(json.states) ? json.states.map((s) => is_object(s) ? s.name : "") : []);
for (const [condition, variant] of Object.entries(json.variants)) {
const where = `variants["${condition}"]`;
for (const part of condition === "" ? [] : condition.split(",")) {
const [name, value] = part.split("=").map((p) => p.trim());
if (!names.has(name) || !/^\d+$/.test(value ?? "")) {
problems.push(`${where}: "${part}" must be a state of this block and a number, like age=3`);
}
}
if (!is_object(variant)) {
problems.push(`${where} must be { model, textures, y }`);
continue;
}
if (variant.model !== undefined) problems.push(...validate_id(variant.model, `${where}.model`));
if (variant.textures !== undefined) {
problems.push(...validate_textures(variant.textures, `${where}.textures`));
}
if (variant.y !== undefined && ![0, 90, 180, 270].includes(variant.y as number)) {
problems.push(`${where}.y must be 0, 90, 180 or 270`);
}
}
}
}
if (json.render_layer !== undefined && !RENDER_LAYERS.includes(json.render_layer as RenderLayer)) {
problems.push(`render_layer must be one of ${RENDER_LAYERS.join(", ")}`);
}
for (const key of ["light_emission", "light_opacity"]) {
const value = json[key];
if (value !== undefined && (!Number.isInteger(value) || (value as number) < 0 || (value as number) > 15)) {
problems.push(`${key} must be a whole number from 0 to 15`);
}
}
problems.push(...validate_text(json.name, "name"));
for (const key of ["cull_same", "transparent", "collision", "item", "interactive", "replaceable"]) {
if (json[key] !== undefined && typeof json[key] !== "boolean") problems.push(`${key} must be true or false`); if (json[key] !== undefined && typeof json[key] !== "boolean") problems.push(`${key} must be true or false`);
} }
if (json.alpha !== undefined && (typeof json.alpha !== "number" || json.alpha < 0 || json.alpha > 1)) { if (json.alpha !== undefined && (typeof json.alpha !== "number" || json.alpha < 0 || json.alpha > 1)) {
@@ -267,11 +349,78 @@ export function validate_block(json: unknown): Problems {
return problems; return problems;
} }
export function validate_model(json: unknown): Problems {
if (!is_object(json)) return ["model must be an object"];
const problems = validate_id(json.id, "id");
if (json.textures !== undefined) problems.push(...validate_textures(json.textures, "textures", true));
if (!Array.isArray(json.elements) || json.elements.length === 0) {
problems.push("elements must be a list of boxes");
return problems;
}
json.elements.forEach((element, i) => {
const where = `elements[${i}]`;
if (!is_object(element)) {
problems.push(`${where} must be { from, to, faces }`);
return;
}
for (const key of ["from", "to"]) {
const point = element[key];
if (!Array.isArray(point) || point.length !== 3 || !point.every((v) => typeof v === "number")) {
problems.push(`${where}.${key} must be [x, y, z] in pixels`);
}
}
const rotation = element.rotation;
if (rotation !== undefined) {
if (
!is_object(rotation) || !["x", "y", "z"].includes(rotation.axis as string) ||
![-45, -22.5, 0, 22.5, 45].includes(rotation.angle as number) || !Array.isArray(rotation.origin)
) {
problems.push(
`${where}.rotation must be { origin, axis: x, y or z, angle: -45, -22.5, 0, 22.5 or 45 }`,
);
}
}
if (element.shade !== undefined && typeof element.shade !== "boolean") {
problems.push(`${where}.shade must be true or false`);
}
if (!is_object(element.faces)) {
problems.push(`${where}.faces must map sides to { texture }`);
return;
}
for (const [side, face] of Object.entries(element.faces)) {
if (!MODEL_FACES.includes(side as ModelFace)) {
problems.push(`${where}.faces: "${side}" isn't one of ${MODEL_FACES.join(", ")}`);
}
if (!is_object(face) || typeof face.texture !== "string") {
problems.push(`${where}.faces.${side} needs a texture, like "#side"`);
continue;
}
if (face.uv !== undefined && (!Array.isArray(face.uv) || face.uv.length !== 4)) {
problems.push(`${where}.faces.${side}.uv must be [u1, v1, u2, v2] in pixels`);
}
if (face.cullface !== undefined && !MODEL_FACES.includes(face.cullface as ModelFace)) {
problems.push(`${where}.faces.${side}.cullface isn't one of ${MODEL_FACES.join(", ")}`);
}
}
});
return problems;
}
// a texture id, or texture variables mapped to ids. a model's own defaults may also point at variables ("#side")
function validate_textures(value: unknown, field: string, allow_variables = false): Problems {
if (typeof value === "string") return validate_id(value, field);
if (!is_object(value)) return [`${field} must be a texture id or { variable: texture id }`];
return Object.entries(value).flatMap(([name, id]) =>
allow_variables && typeof id === "string" && id.startsWith("#") ? [] : validate_id(id, `${field}.${name}`)
);
}
export function validate_item(json: unknown): Problems { export function validate_item(json: unknown): Problems {
if (!is_object(json)) return ["item must be an object"]; if (!is_object(json)) return ["item must be an object"];
const problems = validate_id(json.id, "id"); const problems = validate_id(json.id, "id");
problems.push(...validate_id(json.texture, "texture")); problems.push(...validate_id(json.texture, "texture"));
if (json.places !== undefined) problems.push(...validate_id(json.places, "places")); if (json.places !== undefined) problems.push(...validate_id(json.places, "places"));
problems.push(...validate_text(json.name, "name"), ...validate_text(json.lore, "lore"));
if (json.max_stack !== undefined && (!Number.isInteger(json.max_stack) || (json.max_stack as number) < 1)) { if (json.max_stack !== undefined && (!Number.isInteger(json.max_stack) || (json.max_stack as number) < 1)) {
problems.push("max_stack must be a positive whole number"); problems.push("max_stack must be a positive whole number");
} }
@@ -314,8 +463,16 @@ export function validate_recipe(json: unknown): Problems {
problems.push("burn_time must be a positive number of ticks"); problems.push("burn_time must be a positive number of ticks");
} }
break; break;
case "smithing":
problems.push(
...validate_id(json.tool, "tool"),
...validate_stack(json.material, "material"),
...validate_id(json.result, "result"),
);
if (json.addition !== undefined) problems.push(...validate_id(json.addition, "addition"));
break;
default: default:
problems.push('type must be "shaped", "furnace" or "fuel"'); problems.push('type must be "shaped", "furnace", "fuel" or "smithing"');
} }
return problems; return problems;
} }
@@ -332,6 +489,11 @@ export function validate_ore(json: unknown): Problems {
return problems; return problems;
} }
// optional text players see
function validate_text(value: unknown, field: string): Problems {
return value === undefined || (typeof value === "string" && value.length > 0) ? [] : [`${field} must be text`];
}
function validate_id(value: unknown, field: string): Problems { function validate_id(value: unknown, field: string): Problems {
return typeof value === "string" && ID_PATTERN.test(value) ? [] : [`${field} must be an id like "my_mod:thing"`]; return typeof value === "string" && ID_PATTERN.test(value) ? [] : [`${field} must be an id like "my_mod:thing"`];
} }
+24 -15
View File
@@ -11,40 +11,35 @@ import {
RecipeJson, RecipeJson,
} from "./mod_data.ts"; } from "./mod_data.ts";
import { register_block_item } from "./utils.ts"; import { register_block_item } from "./utils.ts";
import type { ModelJson } from "./block_models.ts";
// everything a mod's json files hold, merged into one file by the build (build/mods/<id>/<hash>/data.json) // everything a mod's json files hold, merged into one file by the build (build/mods/<id>/<hash>/data.json)
export interface ModData { export interface ModData {
blocks: BlockJson[]; blocks: BlockJson[];
// block models, see common/block_models.ts
models: ModelJson[];
items: ItemJson[]; items: ItemJson[];
recipes: RecipeJson[]; recipes: RecipeJson[];
ores: OreJson[]; ores: OreJson[];
} }
// a mod as the server lists it to clients, in load order. paths are relative to the server's root // a mod as the server lists it to clients, in load order: the .bmod players download, without its server script
export interface ModListing { export interface ModListing {
id: string; id: string;
name: string; name: string;
version: string; version: string;
// short hash of everything public, the folder it's served from // sha-256 in hex of the .bmod, clients check it before using anything in it
hash: string; sha256: string;
data: string; // where it's served from, relative to the server's root. named by its hash, so it never changes
client?: string; file: string;
worldgen?: string; size: number;
// sha-256 in hex of each file, clients check these before using them
sha256: { data: string; client?: string; worldgen?: string };
}
// the texture atlas with every mod's textures, built by the server
export interface AtlasListing {
png: string;
json: string;
sha256: { png: string; json: string };
} }
export class RecipeBook { export class RecipeBook {
shaped: GridRecipe[] = []; shaped: GridRecipe[] = [];
furnace = new Map<string, { output: { id: string; count: number }; cook_time: number }>(); furnace = new Map<string, { output: { id: string; count: number }; cook_time: number }>();
fuel = new Map<string, number>(); fuel = new Map<string, number>();
smithing: Extract<RecipeJson, { type: "smithing" }>[] = [];
ores: OreJson[] = []; ores: OreJson[] = [];
} }
@@ -64,6 +59,9 @@ export function register_mod_data(mods: { id: string; data: ModData }[]): Recipe
throw new ModLoadError(id, message); throw new ModLoadError(id, message);
}; };
try { try {
for (const model of data.models) {
EverythingRegistry.register<ModelJson>("models", model.id, model);
}
for (const json of data.blocks) { for (const json of data.blocks) {
const { block, has_item } = block_from_json(json); const { block, has_item } = block_from_json(json);
EverythingRegistry.register<BlockRegistry>("blocks", block.id, block); EverythingRegistry.register<BlockRegistry>("blocks", block.id, block);
@@ -90,6 +88,17 @@ export function register_mod_data(mods: { id: string; data: ModData }[]): Recipe
case "fuel": case "fuel":
recipes.fuel.set(recipe.item, recipe.burn_time); recipes.fuel.set(recipe.item, recipe.burn_time);
break; break;
case "smithing":
if (
recipes.smithing.some((other) =>
other.tool === recipe.tool && other.material.id === recipe.material.id &&
other.addition === recipe.addition
)
) {
fail(`two smithing recipes for ${recipe.tool} with ${recipe.material.id}`);
}
recipes.smithing.push(recipe);
break;
} }
} }
recipes.ores.push(...data.ores); recipes.ores.push(...data.ores);
+96
View File
@@ -0,0 +1,96 @@
// collision against blocks, shared by the client's entities and the server's, so both move things the same way
import { TICK_DELTA } from "./constants.ts";
// how far inside a block face still counts as touching it, so boxes resting exactly on a face don't snag
const EPSILON = 1e-7;
// the two axes that aren't the one being moved along
const OTHER_AXES = [[1, 2], [0, 2], [0, 1]] as const;
// something with a box that moves. position is the middle of its feet, velocity is in blocks per second
export interface Body {
x: number;
y: number;
z: number;
vx: number;
vy: number;
vz: number;
// width is used for both x and z
width: number;
height: number;
}
// which way the body hit something on each axis: 1 or -1, 0 for nothing. 1 on y means it landed on something
export interface Collisions {
x: number;
y: number;
z: number;
}
// falls and moves a body by its velocity for one tick. like minecraft it moves along y, then x, then z, each
// time only as far as it can before touching a block, and stops its velocity on the axes it hit something.
// blocks it's already inside don't stop it, so it can get out of them
export function move_body(body: Body, gravity: number, is_solid: (x: number, y: number, z: number) => boolean) {
// the average of this tick's start and end speed, so the arc is the same at any tick rate
const wanted_y = (body.vy + gravity * TICK_DELTA / 2) * TICK_DELTA;
body.vy += gravity * TICK_DELTA;
const wanted = [body.vx * TICK_DELTA, wanted_y, body.vz * TICK_DELTA];
const half = body.width / 2;
const min = [body.x - half, body.y, body.z - half];
const max = [body.x + half, body.y + body.height, body.z + half];
const moved = [0, 0, 0];
for (const axis of [1, 0, 2]) {
const distance = clip(min, max, axis, wanted[axis], is_solid);
min[axis] += distance;
max[axis] += distance;
moved[axis] = distance;
}
const hit = (axis: number) => moved[axis] !== wanted[axis] ? -Math.sign(wanted[axis]) : 0;
const collisions: Collisions = { x: hit(0), y: hit(1), z: hit(2) };
if (collisions.x !== 0) body.vx = 0;
if (collisions.y !== 0) body.vy = 0;
if (collisions.z !== 0) body.vz = 0;
body.x += moved[0];
body.y += moved[1];
body.z += moved[2];
return collisions;
}
// how far the box can go along an axis before it runs into a block
function clip(
min: number[],
max: number[],
axis: number,
distance: number,
is_solid: (x: number, y: number, z: number) => boolean,
) {
if (distance === 0) {
return 0;
}
const [a, b] = OTHER_AXES[axis];
const from = Math.floor(Math.min(min[axis], min[axis] + distance));
const to = Math.floor(Math.max(max[axis], max[axis] + distance));
const position = [0, 0, 0];
for (let i = from; i <= to; i++) {
for (let j = Math.floor(min[a] + EPSILON); j <= Math.floor(max[a] - EPSILON); j++) {
for (let k = Math.floor(min[b] + EPSILON); k <= Math.floor(max[b] - EPSILON); k++) {
position[axis] = i;
position[a] = j;
position[b] = k;
if (!is_solid(position[0], position[1], position[2])) {
continue;
}
if (distance > 0 && i >= max[axis] - EPSILON) {
distance = Math.min(distance, i - max[axis]);
} else if (distance < 0 && i + 1 <= min[axis] + EPSILON) {
distance = Math.max(distance, i + 1 - min[axis]);
}
}
}
}
return distance;
}
+29 -5
View File
@@ -1,13 +1,13 @@
// messages sent between the client and the server, as json over a websocket // messages sent between the client and the server, as json over a websocket
import type { Faces } from "./constants.ts"; import type { Faces } from "./constants.ts";
import type { ItemData } from "./inventory.ts"; import type { ItemData } from "./inventory.ts";
import type { AtlasListing, ModListing } from "./mod_loader.ts"; import type { ModListing } from "./mod_loader.ts";
export const AIR_ID = "bworld:air"; export const AIR_ID = "bworld:air";
// bump when a client and server of different versions can't play together. // bump when a client and server of different versions can't play together.
// the server rejects a different version before the client downloads anything // the server rejects a different version before the client downloads anything
export const PROTOCOL_VERSION = 1; export const PROTOCOL_VERSION = 4;
export interface PlayerInfo { export interface PlayerInfo {
id: string; id: string;
@@ -19,6 +19,16 @@ export interface PlayerInfo {
pitch: number; pitch: number;
} }
// an entity that isn't a player, as the server first sends it. players have their own messages
export interface EntityInfo {
kind: "item";
id: string;
x: number;
y: number;
z: number;
item: ItemData;
}
// x, y, z, block id, and its state bits when they aren't 0 // x, y, z, block id, and its state bits when they aren't 0
export type BlockChange = [number, number, number, string, number?]; export type BlockChange = [number, number, number, string, number?];
@@ -61,6 +71,8 @@ export type ClientMessage =
| { type: "use_block"; x: number; y: number; z: number; face: Faces } | { type: "use_block"; x: number; y: number; z: number; face: Faces }
| { type: "select_slot"; slot: number } | { type: "select_slot"; slot: number }
| { type: "click"; container: ContainerKey; index: number; button: number } | { type: "click"; container: ContainerKey; index: number; button: number }
// q on a slot (the held one when no screen is open) throws one item, or the whole stack with ctrl
| { type: "drop_item"; container: ContainerKey; index: number; all: boolean }
// closes the open screen, including the player's own inventory screen // closes the open screen, including the player's own inventory screen
| { type: "close_screen" } | { type: "close_screen" }
| { type: "chat"; text: string }; | { type: "chat"; text: string };
@@ -71,8 +83,7 @@ export type ServerMessage =
type: "welcome"; type: "welcome";
protocol: number; protocol: number;
seed: string; seed: string;
atlas: AtlasListing; // in load order, the client downloads and loads them before joining the world
// in load order, the client loads them before joining the world
mods: ModListing[]; mods: ModListing[];
} }
// the connection is closed right after // the connection is closed right after
@@ -84,13 +95,24 @@ export type ServerMessage =
// the server may change the name asked for, like alice to alice2 // the server may change the name asked for, like alice to alice2
name: string; name: string;
players: PlayerInfo[]; players: PlayerInfo[];
// items on the ground
entities: EntityInfo[];
changes: BlockChange[]; changes: BlockChange[];
spawn: { x: number; y: number; z: number; yaw: number; pitch: number }; spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
selected_slot: number; selected_slot: number;
// world time in ticks, see common/time.ts
time: number;
} }
| { type: "player_join"; player: PlayerInfo } | { type: "player_join"; player: PlayerInfo }
| { type: "player_leave"; id: string } | { type: "player_leave"; id: string }
| { type: "player_move"; id: string; x: number; y: number; z: number; yaw: number; pitch: number } | { type: "player_move"; id: string; x: number; y: number; z: number; yaw: number; pitch: number }
| { type: "add_entity"; entity: EntityInfo }
| { type: "move_entity"; id: string; x: number; y: number; z: number }
// a dropped item's stack changed, like when two stacks merge
| { type: "set_entity_item"; id: string; item: ItemData }
| { type: "remove_entity"; id: string }
// a player picked up an item, it flies to them and goes away
| { type: "take_entity"; id: string; player: string }
// also sent to the player who caused it, which corrects anything their client predicted wrong // also sent to the player who caused it, which corrects anything their client predicted wrong
| { type: "set_block"; x: number; y: number; z: number; id: string; state?: number } | { type: "set_block"; x: number; y: number; z: number; id: string; state?: number }
| { type: "chat"; from?: string; text: string } | { type: "chat"; from?: string; text: string }
@@ -99,7 +121,9 @@ export type ServerMessage =
| { type: "cursor"; item: ItemData | null } | { type: "cursor"; item: ItemData | null }
| { type: "open_screen"; layout: ScreenLayout; properties: Record<string, number> } | { type: "open_screen"; layout: ScreenLayout; properties: Record<string, number> }
| { type: "screen_properties"; properties: Record<string, number> } | { type: "screen_properties"; properties: Record<string, number> }
| { type: "close_screen" }; | { type: "close_screen" }
// the world time, every second and when it's changed. clients count ticks in between
| { type: "time"; time: number };
export const MAX_NAME_LENGTH = 16; export const MAX_NAME_LENGTH = 16;
export const MAX_CHAT_LENGTH = 256; export const MAX_CHAT_LENGTH = 256;
-18
View File
@@ -1,18 +0,0 @@
import { System, World } from "$/common/ecs/mod.ts";
import { Position } from "$/common/components/position.ts";
import { Velocity } from "$/common/components/velocity.ts";
export class MovementSystem extends System {
update(world: World, delta: number): void {
for (const entity of world.get_entities()) {
const position = entity.get(Position);
const velocity = entity.get(Velocity);
if (position && velocity) {
position.x += velocity.vx * delta;
position.y += velocity.vy * delta;
position.z += velocity.vz * delta;
}
}
}
}
+67
View File
@@ -0,0 +1,67 @@
// the day and night cycle. world time counts ticks since 06:00 of day 1, it never wraps around
import { TICKS_PER_SECOND } from "./constants.ts";
// 30 minutes of day, 06:00 to 18:00
export const DAY_TICKS = 30 * 60 * TICKS_PER_SECOND;
// 10 minutes of night, 18:00 to 06:00
export const NIGHT_TICKS = 10 * 60 * TICKS_PER_SECOND;
export const CYCLE_TICKS = DAY_TICKS + NIGHT_TICKS;
// dusk right after sunset and dawn right before sunrise, so the whole day is bright
export const TWILIGHT_TICKS = 60 * TICKS_PER_SECOND;
// in game hours are longer during the day than at night, since both halves are 12 hours
const DAY_TICKS_PER_HOUR = DAY_TICKS / 12;
const NIGHT_TICKS_PER_HOUR = NIGHT_TICKS / 12;
// from midnight to 06:00, to count days from midnight
const TICKS_BEFORE_SIX = 6 * NIGHT_TICKS_PER_HOUR;
// named times of day, as ticks into the cycle
export const TIMES_OF_DAY: Record<string, number> = {
day: 0,
noon: 6 * DAY_TICKS_PER_HOUR,
sunset: DAY_TICKS,
night: DAY_TICKS + TWILIGHT_TICKS,
midnight: DAY_TICKS + 6 * NIGHT_TICKS_PER_HOUR,
};
// ticks into the current cycle, 0 is 06:00
export function time_of_day(time: number): number {
return ((time % CYCLE_TICKS) + CYCLE_TICKS) % CYCLE_TICKS;
}
export function is_day(time: number): boolean {
return time_of_day(time) < DAY_TICKS;
}
// the day number, starting at 1 and going up at midnight, and the time on a 24 hour clock
export function clock(time: number): { day: number; hour: number; minute: number } {
const t = time_of_day(time);
const hours = t < DAY_TICKS ? 6 + t / DAY_TICKS_PER_HOUR : 18 + (t - DAY_TICKS) / NIGHT_TICKS_PER_HOUR;
const total_minutes = Math.floor(hours * 60) % (24 * 60);
return {
day: Math.floor((time + TICKS_BEFORE_SIX) / CYCLE_TICKS) + 1,
hour: Math.floor(total_minutes / 60),
minute: total_minutes % 60,
};
}
// like "Day 3, 14:05"
export function format_clock(time: number): string {
const { day, hour, minute } = clock(time);
return `Day ${day}, ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
// how much daylight there is, 1 all day and 0 in the middle of the night, fading through dusk and dawn.
// time can be fractional, for drawing between ticks
export function daylight(time: number): number {
const t = time_of_day(time);
if (t < DAY_TICKS) return 1;
const into_night = t - DAY_TICKS;
const until_day = CYCLE_TICKS - t;
const fade = Math.min(1, into_night / TWILIGHT_TICKS, until_day / TWILIGHT_TICKS);
return 1 - smoothstep(fade);
}
function smoothstep(x: number) {
return x * x * (3 - 2 * x);
}
+14 -9
View File
@@ -1,5 +1,6 @@
import { ID_MASK, STATE_SHIFT } from "./constants.ts"; import { ID_MASK, STATE_SHIFT } from "./constants.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "./everything_registry.ts"; import { BlockRegistry, EverythingRegistry, ItemRegistry } from "./everything_registry.ts";
import { block_item_texture, cube_face_texture } from "./block_models.ts";
export function point_inside_rec( export function point_inside_rec(
point_x: number, point_x: number,
@@ -38,15 +39,19 @@ export function distance_point_point(ax: number, ay: number, az: number, bx: num
} }
export function register_block_item(block: BlockRegistry) { export function register_block_item(block: BlockRegistry) {
// TODO: handle block textures // cubes are drawn from the block's textures, anything else as a sprite
let texture_id = "engine:missing"; const texture_id = block_item_texture(block) ?? cube_face_texture(block, "side");
if (typeof block.textures === "string") { const item: ItemRegistry = { texture_id, block_id: block.id };
texture_id = block.textures; if (block.name !== undefined) item.name = block.name;
} EverythingRegistry.register<ItemRegistry>("items", block.id, item);
EverythingRegistry.register<ItemRegistry>("items", block.id, { }
texture_id,
block_id: block.id, // what players see an item called: its name, or one made from its id, so bworld:iron_ingot is "Iron Ingot"
}); export function display_name(item_id: string): string {
const name = EverythingRegistry.get<ItemRegistry>("items", item_id)?.name;
if (name) return name;
const words = (item_id.split(":")[1] ?? item_id).split("_").filter((word) => word.length > 0);
return words.map((word) => word[0].toUpperCase() + word.slice(1)).join(" ");
} }
export function get_state_value(value: number, block_info: BlockRegistry, name: string) { export function get_state_value(value: number, block_info: BlockRegistry, name: string) {
+130
View File
@@ -0,0 +1,130 @@
// noise for world generation. everything is built from named noises, the same ones mods get through
// FeatureChunk.noise_2d and noise_3d, so a generator using them gives the same world wherever it runs
import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D, NoiseFunction3D } from "@paulaboks/rng";
// create_noise_2d(new Alea(seed + "_" + name)), cached since building the permutation tables is slow
const noise_2d_cache = new Map<string, NoiseFunction2D>();
const noise_3d_cache = new Map<string, NoiseFunction3D>();
export function named_noise_2d(seed: string, name: string): NoiseFunction2D {
const key = `${seed}_${name}`;
let noise = noise_2d_cache.get(key);
if (!noise) {
noise = create_noise_2d(new Alea(key));
noise_2d_cache.set(key, noise);
}
return noise;
}
export function named_noise_3d(seed: string, name: string): NoiseFunction3D {
const key = `${seed}_${name}`;
let noise = noise_3d_cache.get(key);
if (!noise) {
noise = create_noise_3d(new Alea(key));
noise_3d_cache.set(key, noise);
}
return noise;
}
// several octaves of simplex noise, like minecraft's NormalNoise: each octave has twice the frequency and half the
// strength of the last, times its amplitude. wavelength is the size in blocks of the first octave's features, an
// amplitude of 0 skips that octave. the result is roughly -1 to 1 but bunched in the middle, see Quantiles for an
// even spread
export class OctaveNoise2D {
#octaves: { noise: NoiseFunction2D; frequency: number; amplitude: number }[] = [];
#total: number;
constructor(seed: string, name: string, wavelength: number, amplitudes: number[]) {
amplitudes.forEach((amplitude, i) => {
if (amplitude !== 0) {
this.#octaves.push({
noise: named_noise_2d(seed, `${name}_${i}`),
frequency: 2 ** i / wavelength,
amplitude: amplitude / 2 ** i,
});
}
});
this.#total = amplitudes.reduce((sum, amplitude, i) => sum + amplitude / 2 ** i, 0);
}
sample(x: number, z: number) {
let value = 0;
for (const { noise, frequency, amplitude } of this.#octaves) {
value += noise(x * frequency, z * frequency) * amplitude;
}
return value / this.#total;
}
}
export class OctaveNoise3D {
#octaves: { noise: NoiseFunction3D; frequency: number; amplitude: number }[] = [];
#total: number;
// how much slower it changes vertically than horizontally
#vertical_stretch: number;
constructor(seed: string, name: string, wavelength: number, amplitudes: number[], vertical_stretch = 1) {
amplitudes.forEach((amplitude, i) => {
if (amplitude !== 0) {
this.#octaves.push({
noise: named_noise_3d(seed, `${name}_${i}`),
frequency: 2 ** i / wavelength,
amplitude: amplitude / 2 ** i,
});
}
});
this.#total = amplitudes.reduce((sum, amplitude, i) => sum + amplitude / 2 ** i, 0);
this.#vertical_stretch = vertical_stretch;
}
sample(x: number, y: number, z: number) {
let value = 0;
const sy = y / this.#vertical_stretch;
for (const { noise, frequency, amplitude } of this.#octaves) {
value += noise(x * frequency, sy * frequency, z * frequency) * amplitude;
}
return value / this.#total;
}
}
// maps a noise's bunched up values to an even spread from -1 to 1, so "the lowest 20%" is always below -0.6.
// built from the noise's measured percentiles (every 5%, see tools/noise_quantiles.ts), which only depend
// on its amplitudes
export class Quantiles {
#values: readonly number[];
constructor(values: readonly number[]) {
this.#values = values;
}
even(value: number) {
const values = this.#values;
const last = values.length - 1;
if (value <= values[0]) return -1;
if (value >= values[last]) return 1;
let lo = 0;
let hi = last;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (values[mid] <= value) lo = mid;
else hi = mid;
}
const t = (value - values[lo]) / (values[hi] - values[lo]);
return ((lo + t) / last) * 2 - 1;
}
}
// helpers
export function clamp(value: number, min: number, max: number) {
return value < min ? min : value > max ? max : value;
}
export function lerp(t: number, from: number, to: number) {
return from + (to - from) * t;
}
// 0 below edge0, 1 above edge1, smooth in between
export function smoothstep(edge0: number, edge1: number, value: number) {
const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
}
+373
View File
@@ -0,0 +1,373 @@
// fills a chunk with the overworld: the terrain's density sampled on a coarse grid and interpolated (minecraft's
// noise cells), caves cut out of it, water up to sea level, then biomes and their surface blocks
import { CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, SEA_LEVEL } from "$/common/constants.ts";
import { OctaveNoise2D } from "./noise.ts";
import { OverworldTerrain, TerrainColumn } from "./terrain.ts";
import { BIOME_TREES, grow_tree, tree_sites } from "./trees.ts";
// density is sampled every CELL_WIDTH blocks across and CELL_HEIGHT up, caves every CAVE_CELL_HEIGHT up since
// tunnels are thinner than terrain features
const CELL_WIDTH = 4;
const CELL_HEIGHT = 8;
const CAVE_CELL_HEIGHT = 4;
const CORNERS = CHUNK_SIZE / CELL_WIDTH + 1;
const TERRAIN_LEVELS = CHUNK_HEIGHT / CELL_HEIGHT + 1;
const CAVE_LEVELS = CHUNK_HEIGHT / CAVE_CELL_HEIGHT + 1;
// surface blocks only go this far below the ground's height, so cave floors stay stone
const SURFACE_REACH = 20;
// a column is a cliff when its ground is this much higher or lower than a neighbor's
const STEEP = 3.5;
// snow covers the tops of everything above this, give or take
const SNOW_LINE = SEA_LEVEL + 112;
// the blocks the overworld is made of, as numeric ids
export interface OverworldBlocks {
stone: number;
dirt: number;
grass: number;
sand: number;
snow: number;
water: number;
log: number;
leaves: number;
}
type Palette = "stone" | "dirt" | "grass" | "sand" | "snow";
// how a biome covers its ground, like minecraft's surface rules
interface Surface {
top: Palette;
filler: Palette;
filler_depth: number;
// the ground's cover under water
underwater_top: Palette;
underwater_filler: Palette;
// cliffs show bare stone instead of top
bare_cliffs: boolean;
// stripes of sand, stone and dirt down the cliffs, like badlands
strata?: boolean;
}
const GRASSY: Surface = {
top: "grass",
filler: "dirt",
filler_depth: 3,
underwater_top: "dirt",
underwater_filler: "dirt",
bare_cliffs: true,
};
const SANDY: Surface = {
top: "sand",
filler: "sand",
filler_depth: 4,
underwater_top: "sand",
underwater_filler: "sand",
bare_cliffs: false,
};
const SNOWY: Surface = { ...GRASSY, top: "snow" };
const STONY: Surface = {
...GRASSY,
top: "stone",
filler: "stone",
underwater_top: "stone",
underwater_filler: "stone",
};
const SEA_FLOOR: Surface = { ...SANDY, filler_depth: 3 };
// every biome the overworld has, and its surface. names follow minecraft's and terralith's
export const BIOMES: Record<string, Surface> = {
"bworld:deep_ocean": SEA_FLOOR,
"bworld:deep_frozen_ocean": SEA_FLOOR,
"bworld:deep_lukewarm_ocean": SEA_FLOOR,
"bworld:ocean": SEA_FLOOR,
"bworld:frozen_ocean": SEA_FLOOR,
"bworld:warm_ocean": SEA_FLOOR,
"bworld:river": { ...GRASSY, underwater_top: "sand", underwater_filler: "sand" },
"bworld:frozen_river": { ...SNOWY, underwater_top: "sand", underwater_filler: "sand" },
"bworld:beach": SANDY,
"bworld:snowy_beach": { ...SANDY, top: "snow" },
"bworld:stony_shore": STONY,
"bworld:plains": GRASSY,
"bworld:meadow": GRASSY,
"bworld:forest": GRASSY,
"bworld:dark_forest": GRASSY,
"bworld:swamp": { ...GRASSY, bare_cliffs: false },
"bworld:taiga": GRASSY,
"bworld:snowy_plains": SNOWY,
"bworld:snowy_taiga": SNOWY,
"bworld:savanna": GRASSY,
"bworld:jungle": GRASSY,
"bworld:desert": SANDY,
"bworld:alpine_highlands": GRASSY,
"bworld:snowy_slopes": SNOWY,
"bworld:stony_peaks": STONY,
"bworld:jagged_peaks": { ...STONY, top: "snow" },
"bworld:frozen_peaks": { ...SNOWY, filler: "stone" },
"bworld:yosemite_cliffs": GRASSY,
"bworld:snowy_cliffs": SNOWY,
"bworld:painted_mountains": { ...SANDY, filler_depth: 2, bare_cliffs: true, strata: true },
"bworld:stony_spires": GRASSY,
"bworld:shattered_savanna": GRASSY,
"bworld:skylands": GRASSY,
};
// picks a column's biome from its climate and shape, like minecraft's multi noise biome source
export function pick_biome(column: TerrainColumn, surface_y: number, steep: boolean): string {
const { continentalness: c, erosion: e, pv, temperature: t, humidity: h } = column.climate;
const frozen = t < -0.65;
const cold = t < -0.3;
const warm = t > 0.2;
const hot = t > 0.55;
if (column.island && surface_y >= column.island.bottom) {
return "bworld:skylands";
}
if (surface_y < SEA_LEVEL - 1) {
if (pv < -0.7 && c > -0.12) return frozen ? "bworld:frozen_river" : "bworld:river";
if (c < -0.45) {
return frozen ? "bworld:deep_frozen_ocean" : hot ? "bworld:deep_lukewarm_ocean" : "bworld:deep_ocean";
}
return frozen ? "bworld:frozen_ocean" : hot ? "bworld:warm_ocean" : "bworld:ocean";
}
if (c < -0.04 && surface_y <= SEA_LEVEL + 4) {
return steep ? "bworld:stony_shore" : cold ? "bworld:snowy_beach" : "bworld:beach";
}
if (pv < -0.8 && c > -0.12 && surface_y <= SEA_LEVEL + 1) {
return frozen ? "bworld:frozen_river" : "bworld:river";
}
const above = surface_y - SEA_LEVEL;
if (above > 105) {
if (cold) return "bworld:frozen_peaks";
return column.jaggedness > 0.35 ? "bworld:jagged_peaks" : "bworld:stony_peaks";
}
if (column.plateau > 0.5) {
if (hot && h < 0.1) return "bworld:painted_mountains";
return cold ? "bworld:snowy_cliffs" : "bworld:yosemite_cliffs";
}
if (column.shattered > 0.5) {
return warm ? "bworld:shattered_savanna" : "bworld:stony_spires";
}
if (above > 60) {
return cold ? "bworld:snowy_slopes" : "bworld:alpine_highlands";
}
if (above > 30 && e > -0.2 && !cold && h > -0.3) {
return "bworld:meadow";
}
if (frozen) return h > 0 ? "bworld:snowy_taiga" : "bworld:snowy_plains";
if (cold) return h > 0 ? "bworld:taiga" : "bworld:plains";
if (hot) return h < -0.2 ? "bworld:desert" : h < 0.4 ? "bworld:savanna" : "bworld:jungle";
if (warm) return h < -0.3 ? "bworld:savanna" : h > 0.6 ? "bworld:jungle" : "bworld:forest";
if (h > 0.5 && e > 0.4) return "bworld:swamp";
return h < -0.35 ? "bworld:plains" : h < 0.45 ? "bworld:forest" : "bworld:dark_forest";
}
interface SeedGenerators {
terrain: OverworldTerrain;
snow_line: OctaveNoise2D;
strata: OctaveNoise2D;
}
const generators = new Map<string, SeedGenerators>();
function generators_for(seed: string): SeedGenerators {
let found = generators.get(seed);
if (!found) {
found = {
terrain: new OverworldTerrain(seed),
snow_line: new OctaveNoise2D(seed, "snow_line", 96, [1, 1]),
strata: new OctaveNoise2D(seed, "strata", 128, [1]),
};
generators.set(seed, found);
}
return found;
}
// fills blocks (indexed y * CHUNK_AREA + z * CHUNK_SIZE + x) and each column's surface height and biome.
// blocks trees put in other chunks go to spill
export function generate_overworld(
blocks: Uint32Array,
heights: Int32Array,
biomes: string[],
chunk_x: number,
chunk_z: number,
seed: string,
ids: OverworldBlocks,
spill: (x: number, y: number, z: number, block: number) => void,
) {
const { terrain, snow_line, strata } = generators_for(seed);
const x0 = chunk_x * CHUNK_SIZE;
const z0 = chunk_z * CHUNK_SIZE;
// density at the corners of every cell
const corner_columns: TerrainColumn[] = [];
for (let cz = 0; cz < CORNERS; cz++) {
for (let cx = 0; cx < CORNERS; cx++) {
corner_columns.push(terrain.column(x0 + cx * CELL_WIDTH, z0 + cz * CELL_WIDTH));
}
}
const solid = new Float32Array(CORNERS * CORNERS * TERRAIN_LEVELS);
const caves = new Float32Array(CORNERS * CORNERS * CAVE_LEVELS);
for (let i = 0; i < corner_columns.length; i++) {
const column = corner_columns[i];
const x = x0 + (i % CORNERS) * CELL_WIDTH;
const z = z0 + Math.floor(i / CORNERS) * CELL_WIDTH;
for (let level = 0; level < TERRAIN_LEVELS; level++) {
solid[i * TERRAIN_LEVELS + level] = terrain.density(column, x, level * CELL_HEIGHT, z);
}
// caves only matter below the ground, skip the sky above it
const cave_top = column.height + CAVE_CELL_HEIGHT;
for (let level = 0; level < CAVE_LEVELS; level++) {
const y = level * CAVE_CELL_HEIGHT;
caves[i * CAVE_LEVELS + level] = y > cave_top ? 1 : terrain.cave(column, x, y, z);
}
}
// every column of the chunk plus a ring around it, for how steep the ground is
const columns: TerrainColumn[] = [];
const ring = CHUNK_SIZE + 2;
for (let z = -1; z <= CHUNK_SIZE; z++) {
for (let x = -1; x <= CHUNK_SIZE; x++) {
columns.push(terrain.column(x0 + x, z0 + z));
}
}
const column_at = (x: number, z: number) => columns[(z + 1) * ring + x + 1];
for (let z = 0; z < CHUNK_SIZE; z++) {
for (let x = 0; x < CHUNK_SIZE; x++) {
fill_column(x, z);
}
}
grow_trees();
function grow_trees() {
const place = (x: number, y: number, z: number, block: "log" | "leaves") => {
if (y < 0 || y >= CHUNK_HEIGHT) return;
const lx = x - x0;
const lz = z - z0;
if (lx < 0 || lx >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE) {
// the other chunk only takes it where it has air
spill(x, y, z, ids[block]);
return;
}
const i = y * CHUNK_AREA + lz * CHUNK_SIZE + lx;
const current = blocks[i];
if (current === 0 || (block === "log" && current === ids.leaves)) {
blocks[i] = ids[block];
}
};
for (const site of tree_sites(seed, chunk_x, chunk_z)) {
const column = (site.z - z0) * CHUNK_SIZE + (site.x - x0);
const trees = BIOME_TREES[biomes[column]];
if (!trees || site.rng.next() >= trees.chance) continue;
// on soil with air above it, so never under water or on bare rock
const ground_y = heights[column];
const ground = blocks[ground_y * CHUNK_AREA + column];
const above = blocks[(ground_y + 1) * CHUNK_AREA + column];
if ((ground !== ids.grass && ground !== ids.dirt && ground !== ids.snow) || above !== 0) continue;
const kind = trees.kinds[Math.floor(site.rng.next() * trees.kinds.length)];
grow_tree(kind, site.x, ground_y + 1, site.z, site.rng, place);
}
}
function fill_column(x: number, z: number) {
const cell_x = Math.min(Math.floor(x / CELL_WIDTH), CORNERS - 2);
const cell_z = Math.min(Math.floor(z / CELL_WIDTH), CORNERS - 2);
const tx = (x - cell_x * CELL_WIDTH) / CELL_WIDTH;
const tz = (z - cell_z * CELL_WIDTH) / CELL_WIDTH;
const c00 = cell_z * CORNERS + cell_x;
const c10 = c00 + 1;
const c01 = c00 + CORNERS;
const c11 = c01 + 1;
const w00 = (1 - tx) * (1 - tz);
const w10 = tx * (1 - tz);
const w01 = (1 - tx) * tz;
const w11 = tx * tz;
// one column through the corner grid, blended between its four corners
const blend = (grid: Float32Array, levels: number, level: number) =>
grid[c00 * levels + level] * w00 + grid[c10 * levels + level] * w10 +
grid[c01 * levels + level] * w01 + grid[c11 * levels + level] * w11;
const is_solid = new Uint8Array(CHUNK_HEIGHT);
for (let y = 0; y < CHUNK_HEIGHT; y++) {
const level = Math.min(Math.floor(y / CELL_HEIGHT), TERRAIN_LEVELS - 2);
const t = (y - level * CELL_HEIGHT) / CELL_HEIGHT;
let density = blend(solid, TERRAIN_LEVELS, level) * (1 - t) + blend(solid, TERRAIN_LEVELS, level + 1) * t;
if (density > 0) {
const cave_level = Math.min(Math.floor(y / CAVE_CELL_HEIGHT), CAVE_LEVELS - 2);
const ct = (y - cave_level * CAVE_CELL_HEIGHT) / CAVE_CELL_HEIGHT;
const cave = blend(caves, CAVE_LEVELS, cave_level) * (1 - ct) +
blend(caves, CAVE_LEVELS, cave_level + 1) * ct;
density = Math.min(density, cave);
}
is_solid[y] = density > 0 ? 1 : 0;
}
const column = column_at(x, z);
let surface_y = CHUNK_HEIGHT - 1;
while (surface_y > 0 && !is_solid[surface_y]) surface_y--;
const ground = column.height;
const neighbors = [column_at(x - 1, z), column_at(x + 1, z), column_at(x, z - 1), column_at(x, z + 1)];
const steep = neighbors.some((neighbor) => Math.abs(neighbor.height - ground) >= STEEP);
const biome = pick_biome(column, surface_y, steep);
const surface = BIOMES[biome];
const snow_y = SNOW_LINE + snow_line.sample(x0 + x, z0 + z) * 10;
const strata_offset = strata.sample(x0 + x, z0 + z) * 4;
const island_bottom = column.island ? column.island.bottom - 2 : Infinity;
heights[z * CHUNK_SIZE + x] = surface_y;
biomes[z * CHUNK_SIZE + x] = biome;
const index = (y: number) => y * CHUNK_AREA + z * CHUNK_SIZE + x;
// solid blocks since the last air going down, 0 is a block with air on top
let depth = -1;
// nothing but air (and sky islands) above so far: water fills it up to sea level
let open_sky = true;
// whether the ground's surface is under water, for its cover
let underwater = false;
for (let y = CHUNK_HEIGHT - 1; y >= 0; y--) {
if (!is_solid[y]) {
depth = -1;
if (open_sky && y < SEA_LEVEL) {
blocks[index(y)] = ids.water;
}
continue;
}
depth += 1;
const in_island = y >= island_bottom;
if (open_sky && !in_island) {
open_sky = false;
underwater = y < SEA_LEVEL - 1;
}
// surface rules reach the ground and anything above it, not cave floors
let block: Palette = "stone";
if (y >= ground - SURFACE_REACH || in_island) {
const wet = underwater && !in_island;
if (surface.strata && depth > 0 && y > SEA_LEVEL) {
block = strata_block(y + strata_offset);
} else if (depth === 0) {
if (wet) block = surface.underwater_top;
else if (y >= snow_y && !in_island) block = steep ? "stone" : "snow";
else block = steep && surface.bare_cliffs ? "stone" : surface.top;
} else if (depth <= surface.filler_depth) {
block = wet ? surface.underwater_filler : surface.filler;
}
}
blocks[index(y)] = ids[block];
}
}
}
// the bands down a painted mountain's cliffs
function strata_block(y: number): Palette {
const band = ((Math.floor(y / 3) % 6) + 6) % 6;
return band === 1 || band === 4 ? "stone" : band === 3 ? "dirt" : "sand";
}
+77
View File
@@ -0,0 +1,77 @@
// minecraft's CubicSpline: a smooth curve through points, where a point's value can itself be a spline of another
// input. that nesting is how its terrain (and terralith's) turns continentalness, erosion and peaks and valleys into
// heights: a spline over continentalness whose points are splines over erosion, whose points are splines over pv
export interface SplineInputs {
continentalness: number;
erosion: number;
pv: number;
weirdness: number;
}
export type SplineValue = number | Spline;
export interface SplinePoint {
at: number;
value: SplineValue;
// the slope there, worked out from the neighbors when not given
slope?: number;
}
export class Spline {
readonly input: keyof SplineInputs;
#locations: number[];
#values: SplineValue[];
#slopes: number[];
constructor(input: keyof SplineInputs, points: SplinePoint[]) {
this.input = input;
this.#locations = points.map((point) => point.at);
this.#values = points.map((point) => point.value);
// catmull-rom slopes between the neighbors, flat at the ends and where values are splines
this.#slopes = points.map((point, i) => {
if (point.slope !== undefined) return point.slope;
const before = points[i - 1];
const after = points[i + 1];
if (!before || !after || typeof before.value !== "number" || typeof after.value !== "number") {
return 0;
}
return (after.value - before.value) / (after.at - before.at);
});
}
get(inputs: SplineInputs): number {
const x = inputs[this.input];
const locations = this.#locations;
const last = locations.length - 1;
if (x <= locations[0]) {
return value_of(this.#values[0], inputs) + this.#slopes[0] * (x - locations[0]);
}
if (x >= locations[last]) {
return value_of(this.#values[last], inputs) + this.#slopes[last] * (x - locations[last]);
}
let i = 0;
while (locations[i + 1] < x) i++;
const x0 = locations[i];
const x1 = locations[i + 1];
const width = x1 - x0;
const t = (x - x0) / width;
const y0 = value_of(this.#values[i], inputs);
const y1 = value_of(this.#values[i + 1], inputs);
// hermite interpolation, written the way minecraft does it
const a = this.#slopes[i] * width - (y1 - y0);
const b = -this.#slopes[i + 1] * width + (y1 - y0);
return y0 + (y1 - y0) * t + t * (1 - t) * (a + (b - a) * t);
}
}
function value_of(value: SplineValue, inputs: SplineInputs) {
return typeof value === "number" ? value : value.get(inputs);
}
// a spline through evenly spaced values of one input
export function spline(input: keyof SplineInputs, at: number[], values: SplineValue[]) {
return new Spline(input, at.map((location, i) => ({ at: location, value: values[i] })));
}
+373
View File
@@ -0,0 +1,373 @@
// the shape of the overworld, built the way minecraft 1.18+ (and terralith on top of it) does it:
// large noises for continentalness, erosion and weirdness feed nested splines that give each column a target height,
// how jagged its peaks are and how rough its ground is. a 3d density around that height decides what's solid,
// and caves are cut out of it. terralith's flavor comes from the extra shapes: terraced plateaus with cliffs,
// shattered hills full of overhangs, deep river valleys and gorges, jagged peaks and rare sky islands
import { CHUNK_HEIGHT, SEA_LEVEL } from "$/common/constants.ts";
import { clamp, lerp, OctaveNoise2D, OctaveNoise3D, Quantiles, smoothstep } from "./noise.ts";
import { Spline, spline, SplineInputs } from "./spline.ts";
// measured with tools/noise_quantiles.ts
const QUANTILES_6 = new Quantiles([
-0.833,
-0.386,
-0.311,
-0.256,
-0.21,
-0.17,
-0.133,
-0.098,
-0.065,
-0.032,
0,
0.032,
0.065,
0.098,
0.133,
0.17,
0.21,
0.256,
0.311,
0.386,
0.833,
]);
const QUANTILES_4 = new Quantiles([
-0.917,
-0.48,
-0.393,
-0.328,
-0.273,
-0.222,
-0.174,
-0.13,
-0.086,
-0.042,
0,
0.042,
0.086,
0.13,
0.174,
0.222,
0.273,
0.328,
0.393,
0.48,
0.917,
]);
const QUANTILES_3 = new Quantiles([
-0.91,
-0.468,
-0.377,
-0.308,
-0.249,
-0.199,
-0.154,
-0.113,
-0.073,
-0.036,
0,
0.036,
0.073,
0.113,
0.154,
0.199,
0.249,
0.308,
0.377,
0.468,
0.91,
]);
const QUANTILES_2 = new Quantiles([
-0.962,
-0.536,
-0.439,
-0.368,
-0.306,
-0.248,
-0.195,
-0.146,
-0.096,
-0.046,
0,
0.046,
0.096,
0.146,
0.195,
0.248,
0.306,
0.368,
0.439,
0.536,
0.962,
]);
const QUANTILES_TEMPERATURE = new Quantiles([
-0.973,
-0.6,
-0.509,
-0.44,
-0.379,
-0.319,
-0.254,
-0.19,
-0.127,
-0.063,
0,
0.063,
0.127,
0.19,
0.254,
0.319,
0.379,
0.44,
0.509,
0.6,
0.973,
]);
// blocks of height per unit of density, how soft the ground's surface is
const THICKNESS = 20;
// solid ground always ends here, and nothing reaches past the top of the world
const TOP_SLIDE_START = CHUNK_HEIGHT - 24;
const TOP_SLIDE_END = CHUNK_HEIGHT - 4;
const MIN_CAVE_Y = 5;
// the climate at a column, every value spread evenly from -1 to 1
export interface Climate {
// ocean far below 0, coast around -0.2, further inland higher
continentalness: number;
// low is mountains, high is flat land
erosion: number;
// picks between variants, and its folded form pv
weirdness: number;
// peaks and valleys: -1 in a valley (rivers), 1 on a peak
pv: number;
temperature: number;
humidity: number;
}
// everything 2d about a column that the density needs, worked out once per column
export interface TerrainColumn {
climate: Climate;
// where the ground's surface is before 3d noise, in blocks
height: number;
// how much 3d noise moves the ground, in units of density
roughness: number;
// 0 to 1, how much of it are terraced plateaus and shattered hills
plateau: number;
shattered: number;
// 0 to 1, how pointy its peaks are
jaggedness: number;
// a sky island above it, when there is one
island?: { top: number; bottom: number };
}
// minecraft's peaks and valleys: weirdness folded so both its ends are peaks and its middle a valley
export function peaks_and_valleys(weirdness: number) {
return 1 - Math.abs(3 * Math.abs(weirdness) - 2);
}
// the valley value holds until -0.85, so rivers have a flat bottom and some width
const PV_POINTS = [-1, -0.85, -0.65, -0.35, 0, 0.45, 0.8, 1];
const EROSION_POINTS = [-1, -0.6, -0.3, 0, 0.3, 0.6, 1];
// the heights over land at one level of continentalness, relative to sea level. base lifts everything, mountains
// scales how tall they get. each erosion gets a spline over peaks and valleys: a valley value, then how far above
// base the ground rises towards the peaks
function land(base: number, mountains: number): Spline {
const row = (valley: number, rises: number[]) =>
spline("pv", PV_POINTS, [valley, valley, ...rises.map((rise) => base + rise * mountains)]);
return spline("erosion", EROSION_POINTS, [
// barely eroded: huge mountains, their valleys are gorges high above the sea
row(base + 14 * mountains, [22, 44, 66, 88, 104, 110]),
row(base + 8 * mountains, [14, 28, 42, 54, 62, 66]),
// hills and highlands, their valleys carry rivers
row(-4, [8, 20, 28, 36, 42, 44]),
row(-5, [4, 10, 15, 20, 23, 24]),
row(-5, [2, 6, 9, 12, 13, 14]),
// worn flat: plains and wetlands
row(-4, [1, 3, 5, 7, 8, 8]),
row(-3, [0, 1, 2, 3, 3, 3]),
]);
}
// target height above sea level
const OFFSET = spline(
"continentalness",
[-1, -0.55, -0.3, -0.18, -0.12, -0.04, 0.2, 0.5, 1],
[-46, -32, -18, -8, -2, land(1, 0.3), land(3, 0.65), land(8, 1), land(14, 1.15)],
);
// how pointy peaks get, 0 to 1. only tall, barely eroded mountains have them
const JAGGEDNESS = spline("erosion", [-1, -0.6, -0.3, 0], [
spline("pv", [-0.2, 0.3, 1], [0, 0.6, 1]),
spline("pv", [0, 0.5, 1], [0, 0.4, 0.7]),
spline("pv", [0.3, 0.8, 1], [0, 0.2, 0.3]),
0,
]);
// 3d noise strength: mountains are rougher than plains
const ROUGHNESS = spline("erosion", [-1, -0.5, 0, 0.5, 1], [0.32, 0.22, 0.14, 0.1, 0.06]);
export class OverworldTerrain {
#continentalness: OctaveNoise2D;
#erosion: OctaveNoise2D;
#weirdness: OctaveNoise2D;
#temperature: OctaveNoise2D;
#humidity: OctaveNoise2D;
#warp_x: OctaveNoise2D;
#warp_z: OctaveNoise2D;
#jagged: OctaveNoise2D;
#plateau: OctaveNoise2D;
#shattered: OctaveNoise2D;
#sky: OctaveNoise2D;
#island_shape: OctaveNoise2D;
#island_height: OctaveNoise2D;
#ground: OctaveNoise3D;
#island_noise: OctaveNoise3D;
#cheese: OctaveNoise3D;
#spaghetti_a: OctaveNoise3D;
#spaghetti_b: OctaveNoise3D;
#entrances: OctaveNoise2D;
constructor(seed: string) {
this.#continentalness = new OctaveNoise2D(seed, "continentalness", 1024, [1, 1, 2, 2, 1, 1]);
this.#erosion = new OctaveNoise2D(seed, "erosion", 768, [1, 1, 0, 1, 1]);
this.#weirdness = new OctaveNoise2D(seed, "weirdness", 256, [1, 2, 1]);
this.#temperature = new OctaveNoise2D(seed, "temperature", 1536, [1.5, 0, 1]);
this.#humidity = new OctaveNoise2D(seed, "humidity", 512, [1, 1]);
this.#warp_x = new OctaveNoise2D(seed, "warp_x", 200, [1, 1]);
this.#warp_z = new OctaveNoise2D(seed, "warp_z", 200, [1, 1]);
this.#jagged = new OctaveNoise2D(seed, "jagged", 48, [1, 1]);
this.#plateau = new OctaveNoise2D(seed, "plateau", 640, [1, 1]);
this.#shattered = new OctaveNoise2D(seed, "shattered", 512, [1, 1]);
this.#sky = new OctaveNoise2D(seed, "sky", 900, [1, 1]);
this.#island_shape = new OctaveNoise2D(seed, "island_shape", 56, [1, 1]);
this.#island_height = new OctaveNoise2D(seed, "island_height", 300, [1, 1]);
this.#ground = new OctaveNoise3D(seed, "ground", 64, [1, 1, 0.5], 1.2);
this.#island_noise = new OctaveNoise3D(seed, "island_noise", 24, [1, 1]);
this.#cheese = new OctaveNoise3D(seed, "cheese", 80, [1, 0.5], 0.6);
this.#spaghetti_a = new OctaveNoise3D(seed, "spaghetti_a", 64, [1], 0.8);
this.#spaghetti_b = new OctaveNoise3D(seed, "spaghetti_b", 64, [1], 0.8);
this.#entrances = new OctaveNoise2D(seed, "entrances", 90, [1, 1]);
}
climate(x: number, z: number): Climate {
// a small warp, so coasts and biome edges aren't the noise's smooth blobs
const wx = x + this.#warp_x.sample(x, z) * 24;
const wz = z + this.#warp_z.sample(x, z) * 24;
const weirdness = QUANTILES_3.even(this.#weirdness.sample(wx, wz));
return {
continentalness: QUANTILES_6.even(this.#continentalness.sample(wx, wz)),
erosion: QUANTILES_4.even(this.#erosion.sample(wx, wz)),
weirdness,
pv: peaks_and_valleys(weirdness),
temperature: QUANTILES_TEMPERATURE.even(this.#temperature.sample(wx, wz)),
humidity: QUANTILES_2.even(this.#humidity.sample(wx, wz)),
};
}
column(x: number, z: number): TerrainColumn {
const climate = this.climate(x, z);
const { continentalness: c, erosion: e } = climate;
const inputs: SplineInputs = climate;
const inland = smoothstep(-0.1, 0.3, c);
let height = OFFSET.get(inputs);
// jagged peaks: sharp ridges pushed up from the tallest mountains
const jaggedness = clamp(JAGGEDNESS.get(inputs), 0, 1) * inland;
if (jaggedness > 0) {
const ridge = 1 - Math.abs(this.#jagged.sample(x, z));
height += jaggedness * 26 * ridge * ridge;
}
// terraced plateaus: raised land cut into flat benches and cliffs, like terralith's
// yosemite cliffs and painted mountains
const plateau = smoothstep(0.35, 0.55, QUANTILES_2.even(this.#plateau.sample(x, z))) *
smoothstep(-0.75, -0.45, e) * (1 - smoothstep(0.1, 0.4, e)) * smoothstep(-0.05, 0.1, c);
if (plateau > 0 && climate.pv > -0.8) {
const raised = height + 20 * plateau;
height = lerp(plateau, height, terrace(raised, 16));
}
// shattered hills: ground broken up by strong 3d noise into overhangs, arches and spires
const shattered = smoothstep(0.55, 0.75, QUANTILES_2.even(this.#shattered.sample(x, z))) *
smoothstep(-0.6, -0.3, e) * (1 - smoothstep(0.3, 0.5, e)) * smoothstep(-0.05, 0.1, c);
const roughness = ROUGHNESS.get(inputs) + shattered * 0.9;
return {
climate,
height: SEA_LEVEL + height,
roughness,
plateau,
shattered,
jaggedness,
island: this.#island(x, z),
};
}
// skylands: rare regions of floating islands, flat topped with long hanging undersides
#island(x: number, z: number): TerrainColumn["island"] {
const region = smoothstep(0.9, 0.97, QUANTILES_2.even(this.#sky.sample(x, z)));
if (region <= 0) {
return undefined;
}
const shape = region * smoothstep(0.05, 0.35, this.#island_shape.sample(x, z));
if (shape <= 0) {
return undefined;
}
const center = 178 + this.#island_height.sample(x, z) * 24;
return { top: center + 2 + 6 * shape, bottom: center - 4 - 34 * shape * Math.sqrt(shape) };
}
// positive is solid, before caves
density(column: TerrainColumn, x: number, y: number, z: number) {
let density = (column.height - y) / THICKNESS + column.roughness * this.#ground.sample(x, y, z);
const island = column.island;
if (island && y > island.bottom - 4 && y < island.top + 4) {
const solid = Math.min((island.top - y) / 3, (y - island.bottom) / 6) +
0.4 * this.#island_noise.sample(x, y, z);
density = Math.max(density, solid);
}
if (y > TOP_SLIDE_START) {
density -= smoothstep(TOP_SLIDE_START, TOP_SLIDE_END, y) * 4;
}
if (y < 1) {
density = Math.max(density, 1);
}
return density;
}
// negative where a cave is. caves stay under the ground's skin except at entrances, and never break
// the floor of oceans and rivers
cave(column: TerrainColumn, x: number, y: number, z: number) {
if (y < MIN_CAVE_Y) {
return 1;
}
const depth = column.height - y;
if (column.height < SEA_LEVEL + 3 && depth < 14) {
return 1;
}
if (depth < 7 && this.#entrances.sample(x, z) < 0.5) {
return 1;
}
// cheese caves: big open caverns
const cheese = (0.52 - this.#cheese.sample(x, y, z)) * 4;
// spaghetti caves: long winding tunnels where two noises are both near zero
const a = Math.abs(this.#spaghetti_a.sample(x, y, z));
const b = Math.abs(this.#spaghetti_b.sample(x, y, z));
const spaghetti = (Math.max(a, b) - 0.07) * 8;
return Math.min(cheese, spaghetti);
}
}
// flat benches every step blocks, joined by steep cliffs
function terrace(height: number, step: number) {
const k = height / step;
const floor = Math.floor(k);
return (floor + smoothstep(0.4, 0.6, k - floor)) * step;
}
+192
View File
@@ -0,0 +1,192 @@
// trees, part of the terrain pass. where they grow can't depend on which chunk generates first, so it only
// depends on the seed: every CELL x CELL cell has one candidate spot and a random priority, and a candidate becomes a
// tree only if no other candidate within MIN_DISTANCE has a higher priority (then its biome may still say no). that
// spreads trees out like poisson disk sampling: never closer than MIN_DISTANCE, but without lining up in a grid.
// a tree is placed by the chunk its trunk is in, leaves that reach into the next chunk go through the spills like any
// other block there
import { Alea } from "@paulaboks/rng";
import { CHUNK_HEIGHT, CHUNK_SIZE } from "$/common/constants.ts";
const CELL = 4;
// trunks are at least this far apart
const MIN_DISTANCE = 4;
// how many cells out a candidate can be and still be within MIN_DISTANCE
const REACH = Math.ceil(MIN_DISTANCE / CELL);
// the widest a canopy layer gets
const MAX_CANOPY = 3;
export type TreeKind = "oak" | "big_oak" | "spruce" | "jungle" | "acacia";
// how likely each biome's cells are to have a tree, and which kinds grow there
export const BIOME_TREES: Record<string, { chance: number; kinds: TreeKind[] }> = {
"bworld:forest": { chance: 0.85, kinds: ["oak", "oak", "big_oak"] },
"bworld:dark_forest": { chance: 1, kinds: ["big_oak", "big_oak", "oak"] },
"bworld:plains": { chance: 0.05, kinds: ["oak"] },
"bworld:meadow": { chance: 0.04, kinds: ["oak"] },
"bworld:swamp": { chance: 0.4, kinds: ["big_oak"] },
"bworld:river": { chance: 0.05, kinds: ["oak"] },
"bworld:taiga": { chance: 0.75, kinds: ["spruce"] },
"bworld:snowy_taiga": { chance: 0.6, kinds: ["spruce"] },
"bworld:snowy_plains": { chance: 0.03, kinds: ["spruce"] },
"bworld:snowy_slopes": { chance: 0.06, kinds: ["spruce"] },
"bworld:snowy_cliffs": { chance: 0.1, kinds: ["spruce"] },
"bworld:alpine_highlands": { chance: 0.2, kinds: ["spruce", "spruce", "oak"] },
"bworld:yosemite_cliffs": { chance: 0.2, kinds: ["spruce", "oak"] },
"bworld:stony_spires": { chance: 0.12, kinds: ["spruce"] },
"bworld:savanna": { chance: 0.15, kinds: ["acacia", "acacia", "oak"] },
"bworld:shattered_savanna": { chance: 0.12, kinds: ["acacia"] },
"bworld:jungle": { chance: 1, kinds: ["jungle", "jungle", "big_oak"] },
"bworld:skylands": { chance: 0.3, kinds: ["oak", "big_oak"] },
};
export interface TreeSite {
x: number;
z: number;
// seeded from the cell, what's left of it picks the tree
rng: Alea;
}
interface Candidate extends TreeSite {
priority: number;
}
// every cell's candidate, from the seed and the cell alone
function candidate(seed: string, cell_x: number, cell_z: number): Candidate {
const rng = new Alea(`${seed}_tree_${cell_x}_${cell_z}`);
const x = cell_x * CELL + Math.floor(rng.next() * CELL);
const z = cell_z * CELL + Math.floor(rng.next() * CELL);
return { x, z, priority: rng.next(), rng };
}
// the spots in the chunk where a tree may grow, before biome and ground are checked
export function tree_sites(seed: string, chunk_x: number, chunk_z: number): TreeSite[] {
const cells = CHUNK_SIZE / CELL;
const first_x = chunk_x * cells - REACH;
const first_z = chunk_z * cells - REACH;
const size = cells + 2 * REACH;
const candidates: Candidate[] = [];
for (let cz = 0; cz < size; cz++) {
for (let cx = 0; cx < size; cx++) {
candidates.push(candidate(seed, first_x + cx, first_z + cz));
}
}
const sites: TreeSite[] = [];
for (let cz = REACH; cz < REACH + cells; cz++) {
for (let cx = REACH; cx < REACH + cells; cx++) {
const site = candidates[cz * size + cx];
let wins = true;
for (let dz = -REACH; dz <= REACH && wins; dz++) {
for (let dx = -REACH; dx <= REACH; dx++) {
const other = candidates[(cz + dz) * size + cx + dx];
if (other === site) continue;
const distance_sq = (other.x - site.x) ** 2 + (other.z - site.z) ** 2;
if (distance_sq < MIN_DISTANCE * MIN_DISTANCE && other.priority > site.priority) {
wins = false;
break;
}
}
}
if (wins) sites.push(site);
}
}
return sites;
}
// log replaces anything that isn't ground, leaves only fill air
export type PlaceBlock = (x: number, y: number, z: number, block: "log" | "leaves") => void;
// grows a tree with its trunk's bottom at x, y, z. returns false when it wouldn't fit under the top of the world
export function grow_tree(kind: TreeKind, x: number, y: number, z: number, rng: Alea, place: PlaceBlock) {
const random = (min: number, max: number) => min + Math.floor(rng.next() * (max - min + 1));
const trunk = (height: number) => {
for (let i = 0; i < height; i++) place(x, y + i, z, "log");
};
// a square layer of leaves, its corners left out at random like minecraft's
const layer = (ly: number, radius: number, corners: boolean) => {
for (let dx = -radius; dx <= radius; dx++) {
for (let dz = -radius; dz <= radius; dz++) {
const corner = Math.abs(dx) === radius && Math.abs(dz) === radius;
if (corner && radius > 0 && (!corners || rng.next() < 0.5)) continue;
place(x + dx, ly, z + dz, "leaves");
}
}
};
let top: number;
switch (kind) {
case "oak": {
const height = random(4, 6);
top = y + height + 1;
if (top >= CHUNK_HEIGHT - 1) return false;
trunk(height);
layer(y + height - 2, 2, true);
layer(y + height - 1, 2, true);
layer(y + height, 1, true);
layer(y + height + 1, 1, false);
break;
}
case "big_oak": {
const height = random(6, 8);
top = y + height + 1;
if (top >= CHUNK_HEIGHT - 1) return false;
trunk(height);
layer(y + height - 3, 2, true);
layer(y + height - 2, 3, false);
layer(y + height - 1, 3, true);
layer(y + height, 2, true);
layer(y + height + 1, 1, false);
break;
}
case "spruce": {
const height = random(7, 10);
top = y + height + 1;
if (top >= CHUNK_HEIGHT - 1) return false;
trunk(height);
// a cone of layers getting wider going down, every other one narrower, like minecraft's spruce
place(x, y + height + 1, z, "leaves");
layer(y + height, 1, false);
let radius = 1;
for (let ly = y + height - 1; ly >= y + 2; ly--) {
radius = radius >= 2 + Math.floor((y + height - ly) / 4) ? 1 : radius + 1;
layer(ly, Math.min(radius, MAX_CANOPY), false);
}
break;
}
case "jungle": {
const height = random(9, 13);
top = y + height + 1;
if (top >= CHUNK_HEIGHT - 1) return false;
trunk(height);
layer(y + height - 2, 3, false);
layer(y + height - 1, 3, true);
layer(y + height, 2, true);
layer(y + height + 1, 1, false);
break;
}
case "acacia": {
// a short trunk that leans one way at the top, under a flat, wide canopy
const height = random(4, 5);
top = y + height + 2;
if (top >= CHUNK_HEIGHT - 1) return false;
trunk(height);
const lean_x = random(-1, 1);
const lean_z = lean_x === 0 ? (rng.next() < 0.5 ? -1 : 1) : 0;
const cx = x + lean_x;
const cz = z + lean_z;
place(cx, y + height, cz, "log");
for (let dx = -MAX_CANOPY; dx <= MAX_CANOPY; dx++) {
for (let dz = -MAX_CANOPY; dz <= MAX_CANOPY; dz++) {
if (Math.abs(dx) + Math.abs(dz) <= 4 && !(Math.abs(dx) === 3 && Math.abs(dz) === 3)) {
place(cx + dx, y + height + 1, cz + dz, "leaves");
}
if (Math.abs(dx) + Math.abs(dz) <= 2) {
place(cx + dx, y + height + 2, cz + dz, "leaves");
}
}
}
break;
}
}
return true;
}
+12 -3
View File
@@ -5,17 +5,20 @@
"server": "deno run --unstable-worker-options --allow-net --allow-read --allow-write --allow-env server/main.ts", "server": "deno run --unstable-worker-options --allow-net --allow-read --allow-write --allow-env server/main.ts",
"new-mod": "deno run --allow-read --allow-write tools/new_mod.ts", "new-mod": "deno run --allow-read --allow-write tools/new_mod.ts",
"check-mods": "deno run --allow-read --allow-run tools/check_mods.ts", "check-mods": "deno run --allow-read --allow-run tools/check_mods.ts",
"test": "deno test --allow-read --allow-write --allow-run tests/" "pack-mod": "deno run --allow-read --allow-write --allow-run --allow-env tools/pack_mod.ts",
"test": "deno test --allow-read --allow-write --allow-run tests/",
"desktop": "deno run -A build.ts --once && deno desktop --allow-read --allow-write --allow-net --allow-env=BWORLD_SERVER,HOME,USERPROFILE,APPDATA,XDG_DATA_HOME --include build --include server_mods --include server/game/worker.ts --include desktop/config.json desktop/main.ts && deno run --allow-read --allow-write desktop/linux_launcher.ts dist/bworld"
}, },
"compilerOptions": { "compilerOptions": {
"lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable", "deno.webgpu"] "lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable", "deno.webgpu"]
}, },
"unstable": ["bundle", "raw-imports"], "unstable": ["bundle", "raw-imports", "worker-options"],
"fmt": { "fmt": {
"useTabs": true, "useTabs": true,
"indentWidth": 4, "indentWidth": 4,
"lineWidth": 120, "lineWidth": 120,
"newLineKind": "lf" "newLineKind": "lf",
"exclude": ["desktop/*.md"]
}, },
"imports": { "imports": {
"$/": "./", "$/": "./",
@@ -28,7 +31,13 @@
"@std/fs": "jsr:@std/fs@^1.0.23", "@std/fs": "jsr:@std/fs@^1.0.23",
"@std/http": "jsr:@std/http@^1.0.23", "@std/http": "jsr:@std/http@^1.0.23",
"@std/path": "jsr:@std/path@^1.0.0", "@std/path": "jsr:@std/path@^1.0.0",
"fflate": "npm:fflate@^0.8.2",
"gl-matrix": "npm:gl-matrix@^3.4.4", "gl-matrix": "npm:gl-matrix@^3.4.4",
"marked": "npm:marked@^17.0.3" "marked": "npm:marked@^17.0.3"
},
"desktop": {
"app": { "name": "bworld", "identifier": "com.bworld.game" },
"backend": "cef",
"output": { "macos": "./dist/bworld.app", "windows": "./dist/bworld", "linux": "./dist/bworld" }
} }
} }
Generated
+5
View File
@@ -20,6 +20,7 @@
"jsr:@std/path@1": "1.1.4", "jsr:@std/path@1": "1.1.4",
"jsr:@std/path@^1.1.4": "1.1.4", "jsr:@std/path@^1.1.4": "1.1.4",
"jsr:@std/streams@^1.0.17": "1.1.2", "jsr:@std/streams@^1.0.17": "1.1.2",
"npm:fflate@~0.8.2": "0.8.3",
"npm:gl-matrix@^3.4.4": "3.4.4", "npm:gl-matrix@^3.4.4": "3.4.4",
"npm:marked@^17.0.3": "17.0.3" "npm:marked@^17.0.3": "17.0.3"
}, },
@@ -95,6 +96,9 @@
} }
}, },
"npm": { "npm": {
"fflate@0.8.3": {
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="
},
"gl-matrix@3.4.4": { "gl-matrix@3.4.4": {
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==" "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="
}, },
@@ -115,6 +119,7 @@
"jsr:@std/fs@^1.0.23", "jsr:@std/fs@^1.0.23",
"jsr:@std/http@^1.0.23", "jsr:@std/http@^1.0.23",
"jsr:@std/path@1", "jsr:@std/path@1",
"npm:fflate@~0.8.2",
"npm:gl-matrix@^3.4.4", "npm:gl-matrix@^3.4.4",
"npm:marked@^17.0.3" "npm:marked@^17.0.3"
] ]
+3
View File
@@ -0,0 +1,3 @@
{
"server": "bworld.moder.fans"
}
+45
View File
@@ -0,0 +1,45 @@
// deno run --allow-read --allow-write desktop/linux_launcher.ts dist/bworld
// deno desktop has no setting for chromium's command line, but its linux launcher passes its arguments on to chromium.
// so this moves the launcher to bworld-bin and puts a script in its place that starts it with the switches webgpu
// needs on some linux setups. run it on the app folder after deno desktop builds it; running it again does nothing
const SWITCHES = [
"--enable-unsafe-webgpu",
"--ozone-platform=x11",
"--use-angle=vulkan",
"--enable-features=Vulkan,VulkanFromANGLE",
];
const dir = Deno.args[0];
if (!dir) {
console.error("usage: linux_launcher.ts <app folder>, like dist/bworld");
Deno.exit(1);
}
const name = dir.replace(/\/+$/, "").split("/").pop()!;
const launcher = `${dir}/${name}`;
const binary = `${launcher}-bin`;
// already wrapped: the launcher is a script now
const head = new Uint8Array(4);
const file = Deno.openSync(launcher);
file.readSync(head);
file.close();
const is_elf = head[0] === 0x7f && head[1] === 0x45 && head[2] === 0x4c && head[3] === 0x46;
if (!is_elf) {
console.log(`${launcher} is already wrapped`);
Deno.exit(0);
}
Deno.renameSync(launcher, binary);
Deno.writeTextFileSync(
launcher,
`#!/bin/sh
# starts ${name} with the chromium switches webgpu needs on some linux setups, see desktop/linux_launcher.ts.
# BWORLD_CHROMIUM_FLAGS replaces them, set it to "" to start without any
here="$(dirname "$(readlink -f "$0")")"
# the binary finds its runtime by its own name, which is ${name}.so and not ${name}-bin.so
export LAUFEY_RUNTIME_PATH="$here/${name}.so"
exec "$here/${name}-bin" \${BWORLD_CHROMIUM_FLAGS-${SWITCHES.join(" ")}} "$@"
`,
);
Deno.chmodSync(launcher, 0o755);
console.log(`Wrapped ${launcher}: ${SWITCHES.join(" ")}`);

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