Compare commits
25
Commits
dfabe40e7a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e22f12c0b6 | ||
|
|
01a81b926e | ||
|
|
243ff52063 | ||
|
|
86d5c39eab | ||
|
|
b68fe3a19f | ||
|
|
58afaac821 | ||
|
|
8b549162c4 | ||
|
|
8f32a59bd6 | ||
|
|
9237152aa1 | ||
|
|
099811670f | ||
|
|
ef25e66f08 | ||
|
|
a469202959 | ||
|
|
e6f5db8a89 | ||
|
|
fdd236071e | ||
|
|
ea9d821047 | ||
|
|
05f80c81ad | ||
|
|
d07528bd0e | ||
|
|
c750321ced | ||
|
|
d107405f62 | ||
|
|
a254b96f65 | ||
|
|
5fb23cf404 | ||
|
|
213363d0be | ||
|
|
4539898c58 | ||
|
|
25f8c683bb | ||
|
|
f8d406dcf0 |
@@ -2,3 +2,4 @@ build/
|
|||||||
world.json
|
world.json
|
||||||
world.json.tmp
|
world.json.tmp
|
||||||
server_mods/
|
server_mods/
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>`;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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 ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
+121
-58
@@ -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();
|
|
||||||
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();
|
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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") ?? "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function create_vertex_buffer(vertices: Float32Array): GPUBuffer {
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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) };
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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 },
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export * from "./component.ts";
|
|
||||||
export * from "./entity.ts";
|
|
||||||
export * from "./system.ts";
|
|
||||||
export * from "./world.ts";
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import type { World } from "./world.ts";
|
|
||||||
|
|
||||||
export abstract class System {
|
|
||||||
abstract update(world: World, delta: number): void;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+13
-8
@@ -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,
|
// what players see an item called: its name, or one made from its id, so bworld:iron_ingot is "Iron Ingot"
|
||||||
block_id: block.id,
|
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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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] })));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"server": "bworld.moder.fans"
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user