Compare commits
25
Commits
2568694ada
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef25e66f08 | ||
|
|
a469202959 | ||
|
|
e6f5db8a89 | ||
|
|
fdd236071e | ||
|
|
ea9d821047 | ||
|
|
05f80c81ad | ||
|
|
d07528bd0e | ||
|
|
c750321ced | ||
|
|
d107405f62 | ||
|
|
a254b96f65 | ||
|
|
5fb23cf404 | ||
|
|
213363d0be | ||
|
|
4539898c58 | ||
|
|
25f8c683bb | ||
|
|
f8d406dcf0 | ||
|
|
dfabe40e7a | ||
|
|
2a2ccae9ce | ||
|
|
6458bc0440 | ||
|
|
bb42dd662e | ||
|
|
79faa556de | ||
|
|
1bef94ce0c | ||
|
|
d12fa84b00 | ||
|
|
14aba6b129 | ||
|
|
a37ffd9127 | ||
|
|
a758037f20 |
+4
-1
@@ -1 +1,4 @@
|
||||
build/
|
||||
build/
|
||||
world.json
|
||||
world.json.tmp
|
||||
server_mods/
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 159 B |
Binary file not shown.
|
Before Width: | Height: | Size: 216 B |
Binary file not shown.
|
Before Width: | Height: | Size: 123 B |
Binary file not shown.
|
Before Width: | Height: | Size: 170 B |
Binary file not shown.
|
Before Width: | Height: | Size: 222 B |
Binary file not shown.
|
Before Width: | Height: | Size: 251 B |
@@ -1,11 +1,27 @@
|
||||
import { copy } from "@std/fs";
|
||||
import { createCanvas, loadImage } from "@gfx/canvas-wasm";
|
||||
import { ENGINE_TEXTURE_DIR, load_and_check, load_order, LoadedMod } from "./tools/check_mods.ts";
|
||||
import type { AtlasListing, ModData, ModListing } from "./common/mod_loader.ts";
|
||||
|
||||
const BUILD_FOLDER = "build";
|
||||
// all overridable so tests can build somewhere else
|
||||
const BUILD_FOLDER = Deno.env.get("BUILD_DIR") ?? "build";
|
||||
const MODS_FOLDER = Deno.env.get("MODS_DIR") ?? "mods";
|
||||
// server scripts go here instead of the served build folder, players never get them
|
||||
const SERVER_MODS_FOLDER = Deno.env.get("SERVER_MODS_DIR") ?? "server_mods";
|
||||
|
||||
function clear_folder() {
|
||||
Deno.removeSync(BUILD_FOLDER, { recursive: true });
|
||||
Deno.mkdirSync(BUILD_FOLDER);
|
||||
// what the server reads at startup, see server/main.ts
|
||||
export interface ServerModIndex {
|
||||
atlas: AtlasListing;
|
||||
mods: { listing: ModListing; server?: string }[];
|
||||
}
|
||||
|
||||
function clear_folder(folder: string) {
|
||||
try {
|
||||
Deno.removeSync(folder, { recursive: true });
|
||||
} catch (e) {
|
||||
if (!(e instanceof Deno.errors.NotFound)) throw e;
|
||||
}
|
||||
Deno.mkdirSync(folder, { recursive: true });
|
||||
}
|
||||
|
||||
async function build_fonts() {
|
||||
@@ -59,14 +75,23 @@ function calculate_atlas_size(count: number) {
|
||||
};
|
||||
}
|
||||
|
||||
async function build_atlas_from_folder(folder: string) {
|
||||
let sprite_count = 0;
|
||||
for (const entry of Deno.readDirSync(folder)) {
|
||||
console.assert(entry.isFile && entry.name.endsWith(".png"));
|
||||
sprite_count += 1;
|
||||
// one atlas with the engine's textures (engine:<file>) and every mod's (<mod>:<file>).
|
||||
// named by its hash, so clients can cache it forever and download it from other origins
|
||||
async function build_atlas(mods: LoadedMod[]): Promise<AtlasListing> {
|
||||
const textures = new Map<string, string>();
|
||||
for (const entry of Deno.readDirSync(ENGINE_TEXTURE_DIR)) {
|
||||
if (entry.isFile && entry.name.endsWith(".png")) {
|
||||
textures.set(`engine:${entry.name.replace(".png", "")}`, `${ENGINE_TEXTURE_DIR}/${entry.name}`);
|
||||
}
|
||||
}
|
||||
for (const mod of mods) {
|
||||
for (const [id, path] of mod.texture_files) {
|
||||
textures.set(id, path);
|
||||
}
|
||||
}
|
||||
|
||||
const atlas = calculate_atlas_size(sprite_count);
|
||||
// + 1 for the missing texture
|
||||
const atlas = calculate_atlas_size(textures.size + 1);
|
||||
|
||||
const canvas = createCanvas(atlas.size, atlas.size);
|
||||
const ctx = canvas.getContext("2d");
|
||||
@@ -80,45 +105,150 @@ async function build_atlas_from_folder(folder: string) {
|
||||
ctx.fillStyle = "black";
|
||||
ctx.fillRect(8, 0, 8, 8);
|
||||
ctx.fillRect(0, 8, 8, 8);
|
||||
atlas_info["engine:missing"] = { x: 0, y: 0 };
|
||||
|
||||
let index = 1;
|
||||
for (const entry of Deno.readDirSync(folder)) {
|
||||
const sprite = await loadImage(`${folder}/${entry.name}`);
|
||||
for (const [id, path] of [...textures].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const sprite = await loadImage(path);
|
||||
const row = Math.floor(index / atlas.sprites_per_side);
|
||||
const column = index % atlas.sprites_per_side;
|
||||
ctx.drawImage(sprite, column * SPRITE_SIZE, row * SPRITE_SIZE);
|
||||
index += 1;
|
||||
|
||||
const id = `bworld:${entry.name.replace(".png", "")}`;
|
||||
atlas_info[id] = { x: column, y: row };
|
||||
}
|
||||
|
||||
Deno.writeFileSync(`${BUILD_FOLDER}/${folder}.png`, canvas.toBuffer());
|
||||
Deno.writeTextFileSync(`${BUILD_FOLDER}/${folder}.json`, JSON.stringify(atlas_info));
|
||||
const png = new Uint8Array(canvas.toBuffer());
|
||||
const json = new TextEncoder().encode(JSON.stringify(atlas_info));
|
||||
const hashes = { png: await sha256(png), json: await sha256(json) };
|
||||
const name = `assets/sprites/textures.${(hashes.png + hashes.json).slice(0, 12)}`;
|
||||
Deno.writeFileSync(`${BUILD_FOLDER}/${name}.png`, png);
|
||||
Deno.writeFileSync(`${BUILD_FOLDER}/${name}.json`, json);
|
||||
return { png: `${name}.png`, json: `${name}.json`, sha256: hashes };
|
||||
}
|
||||
|
||||
async function build_sprites() {
|
||||
async function build_sprites(mods: LoadedMod[]) {
|
||||
for (const entry of Deno.readDirSync("assets/sprites")) {
|
||||
if (entry.name.endsWith(".png")) {
|
||||
await copy(`assets/sprites/${entry.name}`, `${BUILD_FOLDER}/assets/sprites/${entry.name}`);
|
||||
} else if (entry.isDirectory) {
|
||||
await build_atlas_from_folder(`assets/sprites/${entry.name}`);
|
||||
}
|
||||
}
|
||||
return await build_atlas(mods);
|
||||
}
|
||||
|
||||
async function build_assets() {
|
||||
async function build_assets(mods: LoadedMod[]): Promise<AtlasListing> {
|
||||
Deno.mkdirSync(`${BUILD_FOLDER}/assets`, { recursive: true });
|
||||
await copy("assets/ASSETS.md", `${BUILD_FOLDER}/assets/ASSETS.md`);
|
||||
|
||||
build_fonts();
|
||||
await build_fonts();
|
||||
Deno.mkdirSync(`${BUILD_FOLDER}/assets/sprites`, { recursive: true });
|
||||
await build_sprites();
|
||||
return await build_sprites(mods);
|
||||
}
|
||||
|
||||
// every mod in mods/, checked and sorted so dependencies load first. a broken mod fails the whole build,
|
||||
// the game never runs with some mods missing
|
||||
function load_mods(): LoadedMod[] {
|
||||
const mods = load_and_check(MODS_FOLDER);
|
||||
const problems = mods.flatMap((mod) => mod.report.errors.map((error) => ` ${mod.id}: ${error}`));
|
||||
if (problems.length > 0) {
|
||||
throw new Error(`Mods have errors (deno task check-mods for details):\n${problems.join("\n")}`);
|
||||
}
|
||||
return load_order(mods);
|
||||
}
|
||||
|
||||
async function bundle_script(path: string, platform: "browser" | "deno"): Promise<string> {
|
||||
const result = await Deno.bundle({ entrypoints: [path], platform, write: false, minify: false });
|
||||
if (!result.success || !result.outputFiles?.length) {
|
||||
throw new Error(`Couldn't bundle ${path}:\n${result.errors.map((e) => e.text).join("\n")}`);
|
||||
}
|
||||
return result.outputFiles[0].text();
|
||||
}
|
||||
|
||||
async function sha256(bytes: Uint8Array<ArrayBuffer> | string) {
|
||||
const data = typeof bytes === "string" ? new TextEncoder().encode(bytes) : bytes;
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function short_hash(parts: string[]) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(parts.join("\0")));
|
||||
return [...new Uint8Array(digest)].slice(0, 6).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
// build/mods/<id>/<hash>/ gets what players download, server_mods/ the server scripts
|
||||
async function build_mods(mods: LoadedMod[], atlas: AtlasListing) {
|
||||
clear_folder(SERVER_MODS_FOLDER);
|
||||
const index: ServerModIndex = { atlas, mods: [] };
|
||||
|
||||
for (const mod of mods) {
|
||||
const manifest = mod.manifest as {
|
||||
name: string;
|
||||
version: string;
|
||||
scripts?: Record<string, string>;
|
||||
credits?: string;
|
||||
};
|
||||
const scripts = manifest.scripts ?? {};
|
||||
|
||||
const data: ModData = {
|
||||
blocks: mod.blocks.map((b) => b.json),
|
||||
items: mod.items.map((i) => i.json),
|
||||
recipes: mod.recipes.map((r) => r.json),
|
||||
ores: mod.ores.map((o) => o.json),
|
||||
};
|
||||
const data_json = JSON.stringify(data);
|
||||
const client = scripts.client ? await bundle_script(`${mod.dir}/${scripts.client}`, "browser") : undefined;
|
||||
const worldgen = scripts.worldgen
|
||||
? await bundle_script(`${mod.dir}/${scripts.worldgen}`, "browser")
|
||||
: undefined;
|
||||
const server = scripts.server ? await bundle_script(`${mod.dir}/${scripts.server}`, "deno") : undefined;
|
||||
|
||||
const credits = manifest.credits ? Deno.readTextFileSync(`${mod.dir}/${manifest.credits}`) : undefined;
|
||||
|
||||
const hash = await short_hash([data_json, client ?? "", worldgen ?? "", credits ?? ""]);
|
||||
const public_dir = `mods/${mod.id}/${hash}`;
|
||||
Deno.mkdirSync(`${BUILD_FOLDER}/${public_dir}`, { recursive: true });
|
||||
|
||||
const listing: ModListing = {
|
||||
id: mod.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
hash,
|
||||
data: `${public_dir}/data.json`,
|
||||
sha256: { data: await sha256(data_json) },
|
||||
};
|
||||
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.data}`, data_json);
|
||||
if (client) {
|
||||
listing.client = `${public_dir}/client.js`;
|
||||
listing.sha256.client = await sha256(client);
|
||||
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.client}`, client);
|
||||
}
|
||||
if (worldgen) {
|
||||
listing.worldgen = `${public_dir}/worldgen.js`;
|
||||
listing.sha256.worldgen = await sha256(worldgen);
|
||||
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.worldgen}`, worldgen);
|
||||
}
|
||||
if (credits !== undefined) {
|
||||
listing.credits = `${public_dir}/credits.md`;
|
||||
listing.sha256.credits = await sha256(credits);
|
||||
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.credits}`, credits);
|
||||
}
|
||||
|
||||
const entry: ServerModIndex["mods"][number] = { listing };
|
||||
if (server) {
|
||||
const server_dir = `${SERVER_MODS_FOLDER}/${mod.id}/${await short_hash([server])}`;
|
||||
Deno.mkdirSync(server_dir, { recursive: true });
|
||||
entry.server = `${server_dir}/server.js`;
|
||||
Deno.writeTextFileSync(entry.server, server);
|
||||
}
|
||||
index.mods.push(entry);
|
||||
}
|
||||
|
||||
Deno.writeTextFileSync(`${SERVER_MODS_FOLDER}/index.json`, JSON.stringify(index, null, "\t"));
|
||||
console.log(`Mods: ${mods.map((m) => m.id).join(", ") || "none"}`);
|
||||
}
|
||||
|
||||
async function build_client() {
|
||||
const _result = await Deno.bundle({
|
||||
entrypoints: ["./client/main.ts", "./client/workers/chunk_mesh_worker.ts"],
|
||||
entrypoints: ["./client/main.ts", "./client/workers/chunk_worker.ts"],
|
||||
outputDir: `${BUILD_FOLDER}/client`,
|
||||
platform: "browser",
|
||||
minify: false,
|
||||
@@ -130,20 +260,32 @@ async function build_client() {
|
||||
async function build() {
|
||||
try {
|
||||
const now = performance.now();
|
||||
clear_folder();
|
||||
await build_assets();
|
||||
clear_folder(BUILD_FOLDER);
|
||||
const mods = load_mods();
|
||||
const atlas = await build_assets(mods);
|
||||
await build_client();
|
||||
await build_mods(mods, atlas);
|
||||
console.log(`Built in ${(performance.now() - now).toFixed(2)}ms`);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
// no index means the server refuses to start, instead of running without some mods
|
||||
try {
|
||||
Deno.removeSync(`${SERVER_MODS_FOLDER}/index.json`);
|
||||
} catch { /* wasn't there */ }
|
||||
console.log(e instanceof Error ? e.message : e);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let last_build = 0;
|
||||
|
||||
if (import.meta.main) {
|
||||
await build();
|
||||
const watcher = Deno.watchFs(["assets", "client", "common"], { recursive: true });
|
||||
const ok = await build();
|
||||
// deno task build --once, for scripts and ci
|
||||
if (Deno.args.includes("--once")) {
|
||||
Deno.exit(ok ? 0 : 1);
|
||||
}
|
||||
const watcher = Deno.watchFs(["assets", "client", "common", MODS_FOLDER], { recursive: true });
|
||||
for await (const event of watcher) {
|
||||
const now = performance.now();
|
||||
if (now - last_build < 500) {
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { register_block_item } from "$/common/utils.ts";
|
||||
import { Container, Inventory } from "../inventory.ts";
|
||||
import { PlayerComponent } from "../player.ts";
|
||||
import { GuiChest } from "$/client/gui/gui_chest.ts";
|
||||
|
||||
interface TileChestData {
|
||||
inventory: Inventory;
|
||||
}
|
||||
|
||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:chest", {
|
||||
id: "bworld:chest",
|
||||
textures: "bworld:planks",
|
||||
toughness: 8,
|
||||
requires_tool: false,
|
||||
tool_to_break: "axe",
|
||||
drop_table: "bworld:chest",
|
||||
|
||||
has_collision: false,
|
||||
on_interact(dimension, block) {
|
||||
const [player] = dimension.world.get_tag("player")!;
|
||||
const player_component = player.get(PlayerComponent)!;
|
||||
const block_data = dimension.get_block_data<TileChestData>(block.x, block.y, block.z);
|
||||
if (block_data && block_data.data.inventory) {
|
||||
const gui = new GuiChest(block_data.data.inventory, player_component.player_inventory);
|
||||
player_component.screens.push(gui);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
on_create(dimension, block) {
|
||||
dimension.add_block_data({
|
||||
id: block.id,
|
||||
x: block.x,
|
||||
y: block.y,
|
||||
z: block.z,
|
||||
data: {
|
||||
inventory: new Inventory(new Container(9 * 3)),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
register_block_item(block);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { register_block_item } from "$/common/utils.ts";
|
||||
|
||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:coal_ore", {
|
||||
id: "bworld:coal_ore",
|
||||
textures: "bworld:stone_coal",
|
||||
has_collision: true,
|
||||
drop_table: "bworld:coal_ore",
|
||||
toughness: 5,
|
||||
requires_tool: true,
|
||||
tool_to_break: "pickaxe",
|
||||
});
|
||||
|
||||
register_block_item(block);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { register_block_item } from "$/common/utils.ts";
|
||||
|
||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:copper_ore", {
|
||||
id: "bworld:copper_ore",
|
||||
textures: "bworld:stone_copper",
|
||||
has_collision: true,
|
||||
drop_table: "bworld:copper_ore",
|
||||
toughness: 5,
|
||||
requires_tool: true,
|
||||
tool_to_break: "pickaxe",
|
||||
});
|
||||
|
||||
register_block_item(block);
|
||||
@@ -1,26 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { register_block_item } from "$/common/utils.ts";
|
||||
import { PlayerComponent } from "../player.ts";
|
||||
|
||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:dirt", {
|
||||
id: "bworld:dirt",
|
||||
textures: "bworld:dirt",
|
||||
has_collision: false,
|
||||
drop_table: "bworld:dirt",
|
||||
toughness: 2,
|
||||
requires_tool: false,
|
||||
tool_to_break: "shovel",
|
||||
|
||||
on_interact(dimension, block) {
|
||||
const [player] = dimension.world.get_tag("player")!;
|
||||
const player_inventory = player.get(PlayerComponent)!.player_inventory;
|
||||
const maybe_item = player_inventory.container.get_item(player_inventory.hotbar_selected);
|
||||
if (maybe_item && maybe_item.type_id === "bworld:hoe") {
|
||||
dimension.add_block({ x: block.x, y: block.y, z: block.z, id: "bworld:hoed_dirt" });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
register_block_item(block);
|
||||
@@ -1,200 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { Container, Inventory, ItemStack } from "../inventory.ts";
|
||||
import { PlayerComponent } from "../player.ts";
|
||||
import { GuiFurnace } from "$/client/gui/gui_furnace.ts";
|
||||
import { register_block_item } from "../../common/utils.ts";
|
||||
|
||||
interface FurnaceRecipe {
|
||||
input: string;
|
||||
output: ItemStack;
|
||||
cook_time: number;
|
||||
}
|
||||
|
||||
const FURNACE_RECIPES: FurnaceRecipe[] = [
|
||||
{
|
||||
input: "bworld:log",
|
||||
output: new ItemStack("bworld:coal", 1),
|
||||
cook_time: 100,
|
||||
},
|
||||
{
|
||||
input: "bworld:coal_ore",
|
||||
output: new ItemStack("bworld:coal", 1),
|
||||
cook_time: 100,
|
||||
},
|
||||
{
|
||||
input: "bworld:iron_ore",
|
||||
output: new ItemStack("bworld:iron_ingot", 1),
|
||||
cook_time: 200,
|
||||
},
|
||||
{
|
||||
input: "bworld:tin_ore",
|
||||
output: new ItemStack("bworld:tin_ingot", 1),
|
||||
cook_time: 200,
|
||||
},
|
||||
{
|
||||
input: "bworld:copper_ore",
|
||||
output: new ItemStack("bworld:copper_ingot", 1),
|
||||
cook_time: 200,
|
||||
},
|
||||
{
|
||||
input: "bworld:gold_ore",
|
||||
output: new ItemStack("bworld:gold_ingot", 1),
|
||||
cook_time: 200,
|
||||
},
|
||||
];
|
||||
|
||||
function get_recipe(input?: ItemStack | undefined): FurnaceRecipe | undefined {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
return FURNACE_RECIPES.find((r) => r.input === input.type_id);
|
||||
}
|
||||
|
||||
function can_craft(container: Container, recipe?: FurnaceRecipe) {
|
||||
if (!recipe) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const output = container.get_item(2);
|
||||
|
||||
if (!output) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (output.type_id !== recipe.output.type_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return output.amount < output.max_amount;
|
||||
}
|
||||
|
||||
const FUEL_VALUES: Record<string, number> = {
|
||||
"bworld:coal": 1000,
|
||||
"bworld:log": 100,
|
||||
};
|
||||
|
||||
function get_fuel_value(item?: ItemStack | undefined): number {
|
||||
if (!item) {
|
||||
return 0;
|
||||
}
|
||||
return FUEL_VALUES[item.type_id] ?? 0;
|
||||
}
|
||||
|
||||
function has_fuel(container: Container) {
|
||||
return get_fuel_value(container.get_item(1)) > 0;
|
||||
}
|
||||
|
||||
function consume_fuel(container: Container): number {
|
||||
const fuel = container.get_slot(1)!;
|
||||
const value = get_fuel_value(fuel.get_item());
|
||||
|
||||
if (fuel.has_item()) {
|
||||
fuel.amount! -= 1;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function craft(container: Container, recipe: FurnaceRecipe) {
|
||||
const input = container.get_item(0)!;
|
||||
const output = container.get_item(2);
|
||||
|
||||
if (!output) {
|
||||
container.set_item(2, recipe.output.clone());
|
||||
} else {
|
||||
output.amount += recipe.output.amount;
|
||||
}
|
||||
|
||||
input.amount -= 1;
|
||||
container.set_item(0, input.amount > 0 ? input : undefined);
|
||||
}
|
||||
|
||||
interface TileChestData {
|
||||
inventory: Inventory;
|
||||
progress: number;
|
||||
progress_max: number;
|
||||
fuel: number;
|
||||
fuel_max: number;
|
||||
}
|
||||
|
||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:furnace", {
|
||||
id: "bworld:furnace",
|
||||
textures: { front: "bworld:furnace", side: "bworld:stone" },
|
||||
has_collision: true,
|
||||
drop_table: "bworld:furnace",
|
||||
toughness: 5,
|
||||
requires_tool: true,
|
||||
tool_to_break: "pickaxe",
|
||||
|
||||
on_interact(dimension, block) {
|
||||
const [player] = dimension.world.get_tag("player")!;
|
||||
const player_component = player.get(PlayerComponent)!;
|
||||
const block_data = dimension.get_block_data<TileChestData>(block.x, block.y, block.z);
|
||||
if (block_data && block_data.data.inventory) {
|
||||
const gui = new GuiFurnace(block_data.data.inventory, player_component.player_inventory, block_data.data);
|
||||
player_component.screens.push(gui);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
on_create(dimension, block) {
|
||||
dimension.add_block_data({
|
||||
id: block.id,
|
||||
x: block.x,
|
||||
y: block.y,
|
||||
z: block.z,
|
||||
data: {
|
||||
inventory: new Inventory(new Container(3)),
|
||||
progress: 0,
|
||||
progress_max: 0,
|
||||
fuel: 0,
|
||||
fuel_max: 0,
|
||||
},
|
||||
});
|
||||
},
|
||||
on_break() {
|
||||
// TODO: remove block data
|
||||
},
|
||||
on_tick(dimension, block) {
|
||||
const block_data = dimension.get_block_data<TileChestData>(block.x, block.y, block.z);
|
||||
if (!block_data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = block_data.data;
|
||||
const container = data.inventory.container;
|
||||
|
||||
const input = container.get_item(0);
|
||||
const recipe = get_recipe(input);
|
||||
|
||||
// burn fuel
|
||||
if (data.fuel > 0) {
|
||||
data.fuel -= 1;
|
||||
}
|
||||
|
||||
if (!can_craft(container, recipe)) {
|
||||
data.progress = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// refuel
|
||||
if (data.fuel === 0 && has_fuel(container)) {
|
||||
data.fuel = consume_fuel(container);
|
||||
data.fuel_max = data.fuel;
|
||||
}
|
||||
|
||||
// cook
|
||||
if (data.fuel > 0 && recipe) {
|
||||
data.progress_max = recipe.cook_time;
|
||||
data.progress += 1;
|
||||
|
||||
if (data.progress >= recipe.cook_time) {
|
||||
data.progress = 0;
|
||||
craft(container, recipe);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
register_block_item(block);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { register_block_item } from "$/common/utils.ts";
|
||||
|
||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:glass", {
|
||||
id: "bworld:glass",
|
||||
textures: "bworld:glass",
|
||||
has_collision: true,
|
||||
transparent: true,
|
||||
toughness: 3,
|
||||
requires_tool: false,
|
||||
tool_to_break: "pickaxe",
|
||||
});
|
||||
|
||||
register_block_item(block);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { register_block_item } from "$/common/utils.ts";
|
||||
|
||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:gold_ore", {
|
||||
id: "bworld:gold_ore",
|
||||
textures: "bworld:stone_gold",
|
||||
has_collision: true,
|
||||
drop_table: "bworld:gold_ore",
|
||||
toughness: 5,
|
||||
requires_tool: true,
|
||||
tool_to_break: "pickaxe",
|
||||
});
|
||||
|
||||
register_block_item(block);
|
||||
@@ -1,24 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { PlayerComponent } from "../player.ts";
|
||||
|
||||
EverythingRegistry.register<BlockRegistry>("blocks", "bworld:grass", {
|
||||
id: "bworld:grass",
|
||||
textures: { top: "bworld:grass_top", bottom: "bworld:dirt", "side": "bworld:grass_side" },
|
||||
has_collision: false,
|
||||
drop_table: "bworld:dirt",
|
||||
toughness: 2,
|
||||
requires_tool: false,
|
||||
tool_to_break: "shovel",
|
||||
|
||||
on_interact(dimension, block) {
|
||||
const [player] = dimension.world.get_tag("player")!;
|
||||
const player_inventory = player.get(PlayerComponent)!.player_inventory;
|
||||
const item = player_inventory.container.get_item(player_inventory.hotbar_selected);
|
||||
if (item?.type_id === "bworld:hoe") {
|
||||
block.id = "bworld:hoed_dirt";
|
||||
dimension.add_block(block);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { register_block_item } from "$/common/utils.ts";
|
||||
|
||||
// TODO: watered state
|
||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:hoed_dirt", {
|
||||
id: "bworld:hoed_dirt",
|
||||
textures: { side: "bworld:dirt", top: "bworld:hoed_dirt", bottom: "bworld:dirt" },
|
||||
has_collision: false,
|
||||
drop_table: "bworld:dirt",
|
||||
toughness: 5,
|
||||
requires_tool: false,
|
||||
tool_to_break: "shovel",
|
||||
states: [
|
||||
{ name: "watered", bits: 1, default: 0 },
|
||||
],
|
||||
});
|
||||
|
||||
register_block_item(block);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { register_block_item } from "$/common/utils.ts";
|
||||
|
||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:iron_ore", {
|
||||
id: "bworld:iron_ore",
|
||||
textures: "bworld:stone_iron",
|
||||
has_collision: true,
|
||||
drop_table: "bworld:iron_ore",
|
||||
toughness: 5,
|
||||
requires_tool: true,
|
||||
tool_to_break: "pickaxe",
|
||||
});
|
||||
|
||||
register_block_item(block);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { register_block_item } from "$/common/utils.ts";
|
||||
|
||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:leaves", {
|
||||
id: "bworld:leaves",
|
||||
textures: "bworld:leaves",
|
||||
has_collision: true,
|
||||
transparent: true,
|
||||
toughness: 3,
|
||||
requires_tool: false,
|
||||
tool_to_break: "hoe",
|
||||
});
|
||||
|
||||
register_block_item(block);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { register_block_item } from "$/common/utils.ts";
|
||||
|
||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:log", {
|
||||
id: "bworld:log",
|
||||
textures: { side: "bworld:log_side", top: "bworld:log_top", bottom: "bworld:log_top" },
|
||||
has_collision: true,
|
||||
drop_table: "bworld:log",
|
||||
toughness: 3,
|
||||
requires_tool: false,
|
||||
tool_to_break: "axe",
|
||||
});
|
||||
|
||||
register_block_item(block);
|
||||
@@ -1,19 +0,0 @@
|
||||
import "./grass.ts";
|
||||
import "./dirt.ts";
|
||||
import "./crops.ts";
|
||||
import "./water.ts";
|
||||
import "./chest.ts";
|
||||
import "./furnace.ts";
|
||||
import "./stone.ts";
|
||||
import "./log.ts";
|
||||
import "./sand.ts";
|
||||
import "./snow.ts";
|
||||
import "./glass.ts";
|
||||
import "./hoed_dirt.ts";
|
||||
import "./leaves.ts";
|
||||
import "./coal_ore.ts";
|
||||
import "./copper_ore.ts";
|
||||
import "./iron_ore.ts";
|
||||
import "./tin_ore.ts";
|
||||
import "./gold_ore.ts";
|
||||
import "./planks.ts";
|
||||
@@ -1,14 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { register_block_item } from "$/common/utils.ts";
|
||||
|
||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:planks", {
|
||||
id: "bworld:planks",
|
||||
textures: "bworld:planks",
|
||||
has_collision: true,
|
||||
drop_table: "bworld:log",
|
||||
toughness: 3,
|
||||
requires_tool: false,
|
||||
tool_to_break: "axe",
|
||||
});
|
||||
|
||||
register_block_item(block);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { register_block_item } from "$/common/utils.ts";
|
||||
|
||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:sand", {
|
||||
id: "bworld:sand",
|
||||
textures: "bworld:sand",
|
||||
has_collision: true,
|
||||
drop_table: "bworld:sand",
|
||||
toughness: 3,
|
||||
requires_tool: false,
|
||||
tool_to_break: "shovel",
|
||||
});
|
||||
|
||||
register_block_item(block);
|
||||
@@ -1,13 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { register_block_item } from "$/common/utils.ts";
|
||||
|
||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:snow", {
|
||||
id: "bworld:snow",
|
||||
textures: "bworld:snow",
|
||||
has_collision: true,
|
||||
toughness: 2,
|
||||
requires_tool: true,
|
||||
tool_to_break: "shovel",
|
||||
});
|
||||
|
||||
register_block_item(block);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { register_block_item } from "$/common/utils.ts";
|
||||
|
||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:stone", {
|
||||
id: "bworld:stone",
|
||||
textures: "bworld:stone",
|
||||
has_collision: true,
|
||||
drop_table: "bworld:stone",
|
||||
toughness: 3,
|
||||
requires_tool: true,
|
||||
tool_to_break: "pickaxe",
|
||||
});
|
||||
|
||||
register_block_item(block);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { register_block_item } from "$/common/utils.ts";
|
||||
|
||||
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:tin_ore", {
|
||||
id: "bworld:tin_ore",
|
||||
textures: "bworld:stone_tin",
|
||||
has_collision: true,
|
||||
drop_table: "bworld:tin_ore",
|
||||
toughness: 5,
|
||||
requires_tool: true,
|
||||
tool_to_break: "pickaxe",
|
||||
});
|
||||
|
||||
register_block_item(block);
|
||||
@@ -1,9 +0,0 @@
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<BlockRegistry>("blocks", "bworld:water", {
|
||||
id: "bworld:water",
|
||||
textures: "bworld:water",
|
||||
has_collision: false,
|
||||
transparent: true,
|
||||
alpha: 0.8,
|
||||
});
|
||||
@@ -0,0 +1,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,39 @@
|
||||
import type { FromChunkWorker, ToChunkWorker } from "./workers/chunk_messages.ts";
|
||||
|
||||
// leave a core for the main thread, more than 4 doesnt help much
|
||||
const POOL_SIZE = Math.max(1, Math.min(4, (navigator.hardwareConcurrency ?? 4) - 1));
|
||||
|
||||
export class ChunkWorkerPool {
|
||||
readonly size = POOL_SIZE;
|
||||
#workers: Worker[] = [];
|
||||
#next = 0;
|
||||
|
||||
constructor(on_message: (message: FromChunkWorker) => void) {
|
||||
for (let i = 0; i < this.size; i += 1) {
|
||||
const worker = new Worker(new URL("./workers/chunk_worker.js", import.meta.url), { type: "module" });
|
||||
worker.onmessage = (event: MessageEvent<FromChunkWorker>) => on_message(event.data);
|
||||
worker.onerror = (event) => console.error("Chunk worker error:", event.message);
|
||||
this.#workers.push(worker);
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(message: ToChunkWorker) {
|
||||
for (const worker of this.#workers) {
|
||||
worker.postMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
// round robin, results can come back out of order
|
||||
post(message: ToChunkWorker, transfer: Transferable[] = []) {
|
||||
const worker = this.#workers[this.#next];
|
||||
this.#next = (this.#next + 1) % this.#workers.length;
|
||||
worker.postMessage(message, transfer);
|
||||
}
|
||||
|
||||
terminate() {
|
||||
for (const worker of this.#workers) {
|
||||
worker.terminate();
|
||||
}
|
||||
this.#workers = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { ClientLevel } from "./level/client_level.ts";
|
||||
import type { BlockHitResult } from "./level/client_level.ts";
|
||||
import { LocalPlayer } from "./entity/local_player.ts";
|
||||
import { RemotePlayer } from "./entity/remote_player.ts";
|
||||
import { ItemEntity } from "./entity/item_entity.ts";
|
||||
import { Camera } from "./camera.ts";
|
||||
import { Options } from "./options.ts";
|
||||
import { MultiPlayerGameMode } from "./game_mode.ts";
|
||||
import { ClientPacketListener } from "./packet_listener.ts";
|
||||
import { GameRenderer } from "./rendering/game_renderer.ts";
|
||||
import { ChatComponent } from "./gui/chat_component.ts";
|
||||
import { GuiInventoryScreen, GuiScreen } from "./gui/gui_screen.ts";
|
||||
import { GuiPlayerInventory } from "./gui/gui_player_inventory.ts";
|
||||
import { GuiChat } from "./gui/gui_chat.ts";
|
||||
import { InputManager } from "./input_manager.ts";
|
||||
import { Connection } from "./network.ts";
|
||||
import { PauseScreen } from "./gui/pause_screen.ts";
|
||||
import { back_to_title } from "./gui/title_screen.ts";
|
||||
import { TICK_DELTA } from "$/common/constants.ts";
|
||||
|
||||
// after a long stall (a hidden tab, a debugger) the game skips ahead instead of running every missed tick at once
|
||||
const MAX_TICKS_PER_FRAME = 10;
|
||||
|
||||
// the game client, like minecraft's Minecraft class: owns the level, the player, the screens and the renderer.
|
||||
// the game runs in fixed ticks (TICKS_PER_SECOND), input and drawing happen every frame
|
||||
export class Client {
|
||||
connection: Connection;
|
||||
options = new Options();
|
||||
level: ClientLevel;
|
||||
player: LocalPlayer;
|
||||
camera = new Camera();
|
||||
game_mode: MultiPlayerGameMode;
|
||||
packet_listener: ClientPacketListener;
|
||||
game_renderer = new GameRenderer();
|
||||
chat = new ChatComponent();
|
||||
|
||||
// open screens, the last one is on top and gets the input
|
||||
screens: GuiScreen[] = [];
|
||||
// what the player is looking at, updated every frame
|
||||
hit_result: BlockHitResult | undefined;
|
||||
debugging = false;
|
||||
|
||||
// seconds of game time not ticked yet
|
||||
#pending_time = 0;
|
||||
// whether the attack button is held on a block, breaking it advances every tick
|
||||
#attacking = false;
|
||||
#stopped = false;
|
||||
// called once when the connection drops, with why
|
||||
#on_disconnect: (message: string) => void;
|
||||
|
||||
constructor(connection: Connection, on_disconnect: (message: string) => void) {
|
||||
this.connection = connection;
|
||||
this.#on_disconnect = on_disconnect;
|
||||
|
||||
this.level = new ClientLevel(connection.seed);
|
||||
for (const [x, y, z, id, state] of connection.initial_changes) {
|
||||
this.level.record_change(x, y, z, id, state);
|
||||
}
|
||||
|
||||
const spawn = connection.spawn;
|
||||
this.player = new LocalPlayer(this, connection.id, connection.name);
|
||||
this.player.set_position(spawn.x, spawn.y, spawn.z);
|
||||
this.player.yaw = spawn.yaw;
|
||||
this.player.pitch = spawn.pitch;
|
||||
this.player.inventories.hotbar_selected = connection.selected_slot;
|
||||
this.level.add_entity(this.player);
|
||||
for (const info of connection.initial_players) {
|
||||
this.level.add_entity(new RemotePlayer(this.level, info));
|
||||
}
|
||||
for (const info of connection.initial_entities) {
|
||||
this.level.add_entity(new ItemEntity(this.level, info));
|
||||
}
|
||||
|
||||
this.game_mode = new MultiPlayerGameMode(this);
|
||||
this.packet_listener = new ClientPacketListener(this);
|
||||
}
|
||||
|
||||
get screen(): GuiScreen | undefined {
|
||||
return this.screens.at(-1);
|
||||
}
|
||||
|
||||
push_screen(screen: GuiScreen) {
|
||||
this.screens.push(screen);
|
||||
}
|
||||
|
||||
pop_screen() {
|
||||
this.screens.pop()?.on_close();
|
||||
}
|
||||
|
||||
// leaving on purpose, from the pause screen
|
||||
disconnect() {
|
||||
this.#stopped = true;
|
||||
this.connection.close();
|
||||
back_to_title();
|
||||
}
|
||||
|
||||
#stop() {
|
||||
this.#stopped = true;
|
||||
this.level.dispose();
|
||||
InputManager.set_mouse_grabbed(false);
|
||||
}
|
||||
|
||||
// one frame: input, as many ticks as are due, then drawing between the last tick and the next
|
||||
run_frame(delta: number) {
|
||||
if (this.#stopped) {
|
||||
return;
|
||||
}
|
||||
this.screen?.on_tick(delta);
|
||||
this.#handle_keybinds();
|
||||
|
||||
this.#pending_time += delta;
|
||||
let ticks = 0;
|
||||
while (this.#pending_time >= TICK_DELTA && ticks < MAX_TICKS_PER_FRAME) {
|
||||
this.tick();
|
||||
if (this.#stopped) {
|
||||
return;
|
||||
}
|
||||
this.#pending_time -= TICK_DELTA;
|
||||
ticks += 1;
|
||||
}
|
||||
if (ticks === MAX_TICKS_PER_FRAME) {
|
||||
this.#pending_time = Math.min(this.#pending_time, TICK_DELTA);
|
||||
}
|
||||
|
||||
this.game_renderer.render(this, this.#pending_time / TICK_DELTA);
|
||||
}
|
||||
|
||||
// one step of the game: what the server sent, breaking, chunk loading, then every entity
|
||||
tick() {
|
||||
this.packet_listener.handle_packets();
|
||||
if (this.connection.closed) {
|
||||
this.#stop();
|
||||
this.#on_disconnect("Lost connection to the server");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#attacking && this.hit_result) {
|
||||
this.game_mode.continue_destroy_block(this.hit_result);
|
||||
}
|
||||
|
||||
this.level.update_loaded_chunks(this.player.x, this.player.z, this.options.render_distance);
|
||||
this.level.tick();
|
||||
}
|
||||
|
||||
#handle_keybinds() {
|
||||
const options = this.options;
|
||||
const player = this.player;
|
||||
|
||||
if (InputManager.is_key_pressed(options.key_inventory)) {
|
||||
if (!this.screen) {
|
||||
this.push_screen(new GuiPlayerInventory(player.inventories, (m) => this.connection.send(m)));
|
||||
} else if (this.screen instanceof GuiInventoryScreen) {
|
||||
this.pop_screen();
|
||||
}
|
||||
}
|
||||
if (InputManager.is_key_pressed(options.key_drop)) {
|
||||
this.#handle_drop();
|
||||
}
|
||||
if (InputManager.is_key_pressed(options.key_chat) && !this.screen) {
|
||||
this.push_screen(new GuiChat(this));
|
||||
}
|
||||
// browsers eat the escape that lets go of the mouse, so losing the mouse pauses too
|
||||
const lost_mouse = InputManager.take_lost_pointer_lock();
|
||||
if (InputManager.is_key_pressed("Escape")) {
|
||||
if (this.screen) {
|
||||
this.pop_screen();
|
||||
} else {
|
||||
this.push_screen(new PauseScreen(this));
|
||||
}
|
||||
} else if (lost_mouse && !this.screen) {
|
||||
this.push_screen(new PauseScreen(this));
|
||||
}
|
||||
if (InputManager.is_key_pressed(options.key_debug)) {
|
||||
this.debugging = !this.debugging;
|
||||
}
|
||||
if (InputManager.is_key_pressed(options.key_fullscreen)) {
|
||||
InputManager.toggle_fullscreen();
|
||||
}
|
||||
|
||||
if (InputManager.is_mouse_grabbed()) {
|
||||
const mouse = InputManager.get_mouse_delta();
|
||||
player.turn(mouse.x, mouse.y);
|
||||
}
|
||||
InputManager.set_mouse_grabbed(!this.screen);
|
||||
|
||||
this.hit_result = this.level.pick(player.x, player.y + player.eye_height, player.z, player.yaw, player.pitch);
|
||||
this.#handle_block_interaction();
|
||||
|
||||
if (!this.screen) {
|
||||
this.#handle_hotbar();
|
||||
}
|
||||
}
|
||||
|
||||
// the held item while playing, the slot under the mouse in an inventory (only with nothing on the cursor)
|
||||
#handle_drop() {
|
||||
const all = InputManager.is_key_down("ControlLeft") || InputManager.is_key_down("ControlRight");
|
||||
const inventories = this.player.inventories;
|
||||
const screen = this.screen;
|
||||
if (!screen) {
|
||||
this.game_mode.drop_item(inventories.inventory, "inventory", inventories.hotbar_selected, all);
|
||||
return;
|
||||
}
|
||||
const slot = screen instanceof GuiInventoryScreen ? screen.hovering : undefined;
|
||||
const container = slot && screen instanceof GuiInventoryScreen
|
||||
? screen.get_container(slot.container)
|
||||
: undefined;
|
||||
if (slot && container && !slot.output && !inventories.cursor.item) {
|
||||
this.game_mode.drop_item(container, slot.container, slot.index, all);
|
||||
}
|
||||
}
|
||||
|
||||
// clicks happen right away, holding to break advances in tick()
|
||||
#handle_block_interaction() {
|
||||
const hit = this.hit_result;
|
||||
const attacking = !this.screen && InputManager.is_mouse_down(0);
|
||||
this.#attacking = attacking;
|
||||
|
||||
if (!attacking || !hit) {
|
||||
this.game_mode.stop_destroy_block();
|
||||
}
|
||||
if (this.screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hit) {
|
||||
if (InputManager.is_mouse_pressed(0)) {
|
||||
this.game_mode.start_destroy_block(hit);
|
||||
}
|
||||
if (!attacking && InputManager.is_mouse_pressed(2)) {
|
||||
this.game_mode.use_item_on(hit);
|
||||
}
|
||||
} else if (InputManager.is_mouse_pressed(2)) {
|
||||
this.game_mode.use_item();
|
||||
}
|
||||
}
|
||||
|
||||
#handle_hotbar() {
|
||||
const inventories = this.player.inventories;
|
||||
const previous = inventories.hotbar_selected;
|
||||
|
||||
const scroll = InputManager.get_wheel_delta();
|
||||
if (scroll > 0) {
|
||||
inventories.hotbar_selected = Math.min(8, inventories.hotbar_selected + 1);
|
||||
} else if (scroll < 0) {
|
||||
inventories.hotbar_selected = Math.max(0, inventories.hotbar_selected - 1);
|
||||
}
|
||||
|
||||
const pressed = this.options.key_hotbar.findIndex((key) => InputManager.is_key_pressed(key));
|
||||
if (pressed !== -1) {
|
||||
inventories.hotbar_selected = pressed;
|
||||
}
|
||||
|
||||
if (inventories.hotbar_selected !== previous) {
|
||||
this.connection.send({ type: "select_slot", slot: inventories.hotbar_selected });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { World } from "$/common/ecs/mod.ts";
|
||||
import { MovementSystem } from "$/common/systems/movement_system.ts";
|
||||
import { RenderSystem } from "$/client/systems/render_system.ts";
|
||||
import { PlayerControlsSystem } from "$/client/systems/player_controls.ts";
|
||||
import { DebugSystem } from "$/client/systems/debug_system.ts";
|
||||
import { UIInteractionSystem } from "$/client/systems/ui_interaction_system.ts";
|
||||
import { UIRenderSystem } from "$/client/systems/ui_render_system.ts";
|
||||
import { create_main_menu } from "./main_menu.ts";
|
||||
import { start_game } from "./game.ts";
|
||||
import { canvas, resize_canvas } from "./renderer/mod.ts";
|
||||
import { DimensionLogicSystem } from "./systems/dimension_logic.ts";
|
||||
import { Dimension } from "./components/dimension.ts";
|
||||
import { GuiRenderSystem, GuiTickSystem } from "./gui/gui_systems.ts";
|
||||
import { WorldGenerationSystem } from "./systems/world_generation_system.ts";
|
||||
import { CollisionSystem } from "./systems/collision_system.ts";
|
||||
|
||||
export class ClientWorld extends World {
|
||||
paused = false;
|
||||
debugging = false;
|
||||
|
||||
dimension!: Dimension;
|
||||
|
||||
constructor() {
|
||||
super("game");
|
||||
|
||||
this.add_state("main_menu");
|
||||
this.add_state("paused");
|
||||
this.add_state("game");
|
||||
|
||||
self.addEventListener("resize", resize_canvas);
|
||||
resize_canvas();
|
||||
|
||||
canvas.addEventListener("contextmenu", function (event) {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
start_game(this);
|
||||
|
||||
// Logic systems
|
||||
this.add_system(new UIInteractionSystem(), "main_menu");
|
||||
this.add_system(new UIInteractionSystem(), "paused");
|
||||
this.add_system(new GuiTickSystem(), "game");
|
||||
this.add_system(new PlayerControlsSystem(), "game");
|
||||
this.add_system(new WorldGenerationSystem(), "game");
|
||||
this.add_system(new DimensionLogicSystem(), "game");
|
||||
this.add_system(new CollisionSystem(), "game");
|
||||
this.add_system(new MovementSystem(), "game");
|
||||
|
||||
// render systems
|
||||
this.add_system(new RenderSystem(), "game");
|
||||
this.add_system(new GuiRenderSystem(), "game");
|
||||
this.add_system(new DebugSystem(), "game");
|
||||
this.add_system(new UIRenderSystem(), "main_menu");
|
||||
this.add_system(new UIRenderSystem(), "paused");
|
||||
}
|
||||
}
|
||||
@@ -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,346 +0,0 @@
|
||||
import { Component } from "$/common/ecs/mod.ts";
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import { AIR, Faces, ID_MASK, VOID } from "../../common/constants.ts";
|
||||
import { AssetManager } from "../assets.ts";
|
||||
import { ClientWorld } from "../client_world.ts";
|
||||
import { generate_chunk } from "../generation.ts";
|
||||
import { ItemStack } from "../inventory.ts";
|
||||
import { PlayerComponent } from "../player.ts";
|
||||
import { gl, Texture } from "../renderer/mod.ts";
|
||||
import { Camera } from "./camera.ts";
|
||||
|
||||
export interface BlockData<T = unknown> {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface Block {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export const CHUNK_SIZE = 16;
|
||||
export const CHUNK_HEIGHT = 128;
|
||||
export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE;
|
||||
|
||||
export interface Chunk {
|
||||
x: number;
|
||||
z: number;
|
||||
blocks: Uint32Array;
|
||||
blocks_data: BlockData[];
|
||||
generated: boolean;
|
||||
dirty: boolean;
|
||||
opaque_vertex_buffer?: WebGLBuffer;
|
||||
opaque_vertex_count?: number;
|
||||
transparent_vertex_buffer?: WebGLBuffer;
|
||||
transparent_vertex_count?: number;
|
||||
}
|
||||
|
||||
export class Dimension extends Component {
|
||||
world: ClientWorld;
|
||||
image: Texture = AssetManager.instance.get("bworld:textures");
|
||||
chunks: Chunk[] = [];
|
||||
second_timer = 0;
|
||||
tick_timer = 0;
|
||||
|
||||
constructor(world: ClientWorld) {
|
||||
super();
|
||||
this.world = world;
|
||||
}
|
||||
|
||||
add_chunk(x: number, z: number) {
|
||||
const chunk = {
|
||||
x,
|
||||
z,
|
||||
blocks: new Uint32Array(CHUNK_AREA * CHUNK_HEIGHT),
|
||||
blocks_data: [],
|
||||
dirty: true,
|
||||
generated: false,
|
||||
};
|
||||
this.chunks.push(chunk);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
get_chunk(x: number, z: number) {
|
||||
return this.chunks.find((chunk) => chunk.x === x && chunk.z === z);
|
||||
}
|
||||
|
||||
add_block(block: Block) {
|
||||
const block_chunk_x = Math.floor(block.x / CHUNK_SIZE);
|
||||
const block_chunk_z = Math.floor(block.z / CHUNK_SIZE);
|
||||
let chunk = this.get_chunk(block_chunk_x, block_chunk_z);
|
||||
if (!chunk) {
|
||||
chunk = this.add_chunk(block_chunk_x, block_chunk_z);
|
||||
}
|
||||
|
||||
const [nid, block_info] = EverythingRegistry.get_full<BlockRegistry>("blocks", block.id)!;
|
||||
|
||||
const lx = block.x - block_chunk_x * CHUNK_SIZE;
|
||||
const lz = block.z - block_chunk_z * CHUNK_SIZE;
|
||||
const ly = block.y;
|
||||
|
||||
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
|
||||
|
||||
chunk.blocks[index] = nid;
|
||||
chunk.dirty = true;
|
||||
if (block_info?.on_create) {
|
||||
block_info?.on_create(this, block);
|
||||
}
|
||||
}
|
||||
|
||||
get_block(x: number, y: number, z: number) {
|
||||
const chunk_x = Math.floor(x / CHUNK_SIZE);
|
||||
const chunk_z = Math.floor(z / CHUNK_SIZE);
|
||||
|
||||
const chunk = this.get_chunk(chunk_x, chunk_z);
|
||||
|
||||
if (!chunk) {
|
||||
return VOID;
|
||||
}
|
||||
|
||||
const lx = x - chunk_x * CHUNK_SIZE;
|
||||
const lz = z - chunk_z * CHUNK_SIZE;
|
||||
const ly = y;
|
||||
|
||||
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
|
||||
|
||||
return chunk.blocks[index] & ID_MASK;
|
||||
}
|
||||
|
||||
add_block_data(block_data: BlockData) {
|
||||
const block_chunk_x = Math.floor(block_data.x / CHUNK_SIZE);
|
||||
const block_chunk_z = Math.floor(block_data.z / CHUNK_SIZE);
|
||||
let chunk = this.get_chunk(block_chunk_x, block_chunk_z);
|
||||
if (!chunk) {
|
||||
chunk = this.add_chunk(block_chunk_x, block_chunk_z);
|
||||
}
|
||||
|
||||
chunk.blocks_data.push(block_data);
|
||||
}
|
||||
|
||||
get_block_data<T>(x: number, y: number, z: number) {
|
||||
const chunk_x = Math.floor(x / CHUNK_SIZE);
|
||||
const chunk_z = Math.floor(z / CHUNK_SIZE);
|
||||
|
||||
const chunk = this.get_chunk(chunk_x, chunk_z);
|
||||
|
||||
if (!chunk) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return chunk.blocks_data.find((block_data) =>
|
||||
block_data.x === x && block_data.y === y && block_data.z === z
|
||||
) as BlockData<T>;
|
||||
}
|
||||
|
||||
break_block(x: number, y: number, z: number) {
|
||||
const block_chunk_x = Math.floor(x / CHUNK_SIZE);
|
||||
const block_chunk_z = Math.floor(z / CHUNK_SIZE);
|
||||
const chunk = this.get_chunk(block_chunk_x, block_chunk_z);
|
||||
if (!chunk) {
|
||||
return;
|
||||
}
|
||||
|
||||
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", this.get_block(x, y, z))!;
|
||||
|
||||
if (block_info.drop_table) {
|
||||
const [player] = this.world.get_tag("player")!;
|
||||
const player_component = player.get(PlayerComponent)!;
|
||||
player_component.player_inventory.container.add_item(new ItemStack(block_info.drop_table));
|
||||
}
|
||||
|
||||
const lx = x - block_chunk_x * CHUNK_SIZE;
|
||||
const lz = z - block_chunk_z * CHUNK_SIZE;
|
||||
const ly = y;
|
||||
|
||||
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
|
||||
chunk.blocks[index] = AIR;
|
||||
chunk.dirty = true;
|
||||
|
||||
if (lx === 0) {
|
||||
const n = this.get_chunk(block_chunk_x - 1, block_chunk_z);
|
||||
if (n) {
|
||||
n.dirty = true;
|
||||
}
|
||||
} else if (lx === CHUNK_SIZE - 1) {
|
||||
const n = this.get_chunk(block_chunk_x + 1, block_chunk_z);
|
||||
if (n) {
|
||||
n.dirty = true;
|
||||
}
|
||||
}
|
||||
if (lz === 0) {
|
||||
const n = this.get_chunk(block_chunk_x, block_chunk_z - 1);
|
||||
if (n) {
|
||||
n.dirty = true;
|
||||
}
|
||||
} else if (lz === CHUNK_SIZE - 1) {
|
||||
const n = this.get_chunk(block_chunk_x, block_chunk_z + 1);
|
||||
if (n) {
|
||||
n.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (block_info?.on_break) {
|
||||
block_info.on_break(this, { x, y, z, id: block_info.id });
|
||||
}
|
||||
}
|
||||
|
||||
index_to_xyz(index: number) {
|
||||
const y = Math.floor(index / CHUNK_AREA);
|
||||
const rem = index % CHUNK_AREA;
|
||||
|
||||
const z = Math.floor(rem / CHUNK_SIZE);
|
||||
const x = rem % CHUNK_SIZE;
|
||||
|
||||
return [x, y, z];
|
||||
}
|
||||
|
||||
load_chunk(cx: number, cz: number) {
|
||||
generate_chunk(this, cx, cz);
|
||||
const chunk = this.get_chunk(cx, cz);
|
||||
if (chunk) {
|
||||
chunk.generated = true;
|
||||
chunk.dirty = true;
|
||||
}
|
||||
|
||||
const neighbors = [
|
||||
[cx - 1, cz],
|
||||
[cx + 1, cz],
|
||||
[cx, cz - 1],
|
||||
[cx, cz + 1],
|
||||
];
|
||||
|
||||
for (const [nx, nz] of neighbors) {
|
||||
const neighbor = this.chunks.find((c) => c.x === nx && c.z === nz);
|
||||
if (neighbor && neighbor.generated) {
|
||||
neighbor.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unload_chunk(cx: number, cz: number) {
|
||||
const chunk_i = this.chunks.findIndex((c) => c.x === cx && c.z === cz);
|
||||
if (chunk_i === -1) {
|
||||
console.warn("Tried unloading a chunk that doesn't exist");
|
||||
return;
|
||||
}
|
||||
|
||||
this.delete_chunk_mesh(this.chunks[chunk_i]);
|
||||
this.chunks.splice(chunk_i, 1);
|
||||
}
|
||||
|
||||
delete_chunk_mesh(chunk: Chunk) {
|
||||
if (chunk.opaque_vertex_buffer) {
|
||||
gl.deleteBuffer(chunk.opaque_vertex_buffer);
|
||||
}
|
||||
if (chunk.transparent_vertex_buffer) {
|
||||
gl.deleteBuffer(chunk.transparent_vertex_buffer);
|
||||
}
|
||||
}
|
||||
|
||||
get_looked_block(
|
||||
dimension: Dimension,
|
||||
camera: Camera,
|
||||
max_distance = 6,
|
||||
step = 0.05,
|
||||
): { x: number; y: number; z: number; block: number; face: Faces } | undefined {
|
||||
const yaw = camera.yaw;
|
||||
const pitch = camera.pitch;
|
||||
|
||||
const cos_pitch = Math.cos(pitch);
|
||||
const dx = -Math.sin(yaw) * cos_pitch;
|
||||
const dy = Math.sin(pitch);
|
||||
const dz = -Math.cos(yaw) * cos_pitch;
|
||||
|
||||
let x = camera.x;
|
||||
let y = camera.y;
|
||||
let z = camera.z;
|
||||
|
||||
let prev_bx = Math.floor(x);
|
||||
let prev_by = Math.floor(y);
|
||||
let prev_bz = Math.floor(z);
|
||||
|
||||
let dist = 0;
|
||||
|
||||
while (dist <= max_distance) {
|
||||
x += dx * step;
|
||||
y += dy * step;
|
||||
z += dz * step;
|
||||
dist += step;
|
||||
|
||||
const bx = Math.floor(x);
|
||||
const by = Math.floor(y);
|
||||
const bz = Math.floor(z);
|
||||
|
||||
if (bx === prev_bx && by === prev_by && bz === prev_bz) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const block = dimension.get_block(bx, by, bz);
|
||||
|
||||
if (block && block !== AIR && block !== VOID) {
|
||||
let face: Faces;
|
||||
|
||||
if (bx > prev_bx) {
|
||||
face = "west";
|
||||
} else if (bx < prev_bx) {
|
||||
face = "east";
|
||||
} else if (by > prev_by) {
|
||||
face = "bottom";
|
||||
} else if (by < prev_by) {
|
||||
face = "top";
|
||||
} else if (bz > prev_bz) {
|
||||
face = "north";
|
||||
} else {
|
||||
face = "south";
|
||||
}
|
||||
|
||||
return { x: bx, y: by, z: bz, face, block };
|
||||
}
|
||||
|
||||
prev_bx = bx;
|
||||
prev_by = by;
|
||||
prev_bz = bz;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
create_padded_chunk(chunk: Chunk) {
|
||||
const cx = chunk.x;
|
||||
const cz = chunk.z;
|
||||
|
||||
const size = CHUNK_SIZE + 2;
|
||||
const padded = new Uint32Array(size * size * CHUNK_HEIGHT);
|
||||
|
||||
for (let y = 0; y < CHUNK_HEIGHT; y++) {
|
||||
for (let z = -1; z <= CHUNK_SIZE; z++) {
|
||||
for (let x = -1; x <= CHUNK_SIZE; x++) {
|
||||
let block: number;
|
||||
|
||||
if (x >= 0 && x < CHUNK_SIZE && z >= 0 && z < CHUNK_SIZE) {
|
||||
const index = y * CHUNK_SIZE * CHUNK_SIZE + z * CHUNK_SIZE + x;
|
||||
block = chunk.blocks[index] & ID_MASK;
|
||||
} else {
|
||||
const wx = cx * CHUNK_SIZE + x;
|
||||
const wz = cz * CHUNK_SIZE + z;
|
||||
block = this.get_block(wx, y, wz) ?? AIR;
|
||||
}
|
||||
|
||||
const px = x + 1;
|
||||
const pz = z + 1;
|
||||
const pindex = y * size * size + pz * size + px;
|
||||
|
||||
padded[pindex] = block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return padded;
|
||||
}
|
||||
}
|
||||
@@ -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,50 @@
|
||||
import type { ServerAddress, Welcome } from "./handshake.ts";
|
||||
|
||||
// asks before running a server's mods when the page came from somewhere else, see "Security" in MODS.md.
|
||||
// resolves with whether the player wants to join
|
||||
export function confirm_mods(address: ServerAddress, welcome: Welcome): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.cssText =
|
||||
"position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:16px;" +
|
||||
"background:rgba(0,0,0,0.85);color:white;font:16px system-ui,sans-serif;";
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.style.cssText = "max-width:480px;display:flex;flex-direction:column;gap:12px;";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.style.cssText = "font-size:20px;";
|
||||
title.textContent = `Join ${address.base.host}?`;
|
||||
|
||||
const warning = document.createElement("div");
|
||||
warning.style.cssText = "opacity:0.8;";
|
||||
warning.textContent = "This server runs these mods in your browser. They can do anything this page can, " +
|
||||
"including reading what it saved. Only join servers you trust.";
|
||||
|
||||
const list = document.createElement("ul");
|
||||
list.style.cssText = "margin:0;padding-left:20px;";
|
||||
for (const mod of welcome.mods) {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = `${mod.name} ${mod.version} (${mod.id})`;
|
||||
list.append(item);
|
||||
}
|
||||
|
||||
const buttons = document.createElement("div");
|
||||
buttons.style.cssText = "display:flex;gap:8px;justify-content:flex-end;";
|
||||
const button = (label: string, answer: boolean) => {
|
||||
const element = document.createElement("button");
|
||||
element.textContent = label;
|
||||
element.style.cssText = "font:inherit;padding:6px 16px;cursor:pointer;";
|
||||
element.addEventListener("click", () => {
|
||||
overlay.remove();
|
||||
resolve(answer);
|
||||
});
|
||||
return element;
|
||||
};
|
||||
buttons.append(button("Cancel", false), button("Join", true));
|
||||
|
||||
panel.append(title, warning, list, buttons);
|
||||
overlay.append(panel);
|
||||
document.body.append(overlay);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,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];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
let stopped = false;
|
||||
|
||||
// replaces the game with a message, for when it can't go on (no server, lost connection)
|
||||
export function show_fatal_error(message: string) {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
document.exitPointerLock?.();
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.cssText =
|
||||
"position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;" +
|
||||
"gap:12px;background:rgba(0,0,0,0.85);color:white;font:20px system-ui,sans-serif;text-align:center;";
|
||||
const text = document.createElement("div");
|
||||
text.textContent = message;
|
||||
const hint = document.createElement("div");
|
||||
hint.textContent = "Reload the page to try again.";
|
||||
hint.style.cssText = "font-size:14px;opacity:0.7;";
|
||||
overlay.append(text, hint);
|
||||
document.body.append(overlay);
|
||||
}
|
||||
|
||||
export function is_stopped() {
|
||||
return stopped;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Entity } from "$/common/ecs/mod.ts";
|
||||
import { Position } from "$/common/components/position.ts";
|
||||
import { ClientWorld } from "./client_world.ts";
|
||||
import { Dimension } from "./components/dimension.ts";
|
||||
import { create_player } from "./player.ts";
|
||||
import { UIButton } from "./components/ui_components.ts";
|
||||
import { open_about } from "./about.ts";
|
||||
import { canvas } from "./renderer/mod.ts";
|
||||
|
||||
export function start_game(world: ClientWorld) {
|
||||
world.state = "game";
|
||||
world.clear_entities();
|
||||
|
||||
const dimension = new Entity("dimension");
|
||||
world.dimension = new Dimension(world);
|
||||
dimension.add(world.dimension);
|
||||
world.add_entity(dimension);
|
||||
|
||||
create_player(world);
|
||||
|
||||
// UI !
|
||||
const unpause_button = new Entity("unpausebutton");
|
||||
unpause_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 - 80));
|
||||
unpause_button.add(new UIButton("Unpause", 320, 64, () => world.state = "paused"));
|
||||
world.add_entity(unpause_button);
|
||||
|
||||
const about_button = new Entity("aboutbutton");
|
||||
about_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 + 80));
|
||||
about_button.add(new UIButton("About", 320, 64, () => open_about()));
|
||||
world.add_entity(about_button);
|
||||
}
|
||||
@@ -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 ?? "");
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D } from "@paulaboks/rng";
|
||||
import { CHUNK_SIZE, Dimension } from "./components/dimension.ts";
|
||||
|
||||
type Biome =
|
||||
| "desert"
|
||||
| "plains"
|
||||
| "forest"
|
||||
| "jungle"
|
||||
| "tundra"
|
||||
| "taiga"
|
||||
| "snow"
|
||||
| "savanna"
|
||||
| "swamp";
|
||||
|
||||
type OreDef = {
|
||||
id: string;
|
||||
min_y: number;
|
||||
max_y: number;
|
||||
scale: number;
|
||||
threshold: number;
|
||||
};
|
||||
|
||||
const ORES: OreDef[] = [
|
||||
{ id: "bworld:coal_ore", min_y: 20, max_y: 120, scale: 0.05, threshold: 0.55 },
|
||||
{ id: "bworld:copper_ore", min_y: 10, max_y: 80, scale: 0.06, threshold: 0.6 },
|
||||
{ id: "bworld:tin_ore", min_y: 5, max_y: 60, scale: 0.06, threshold: 0.62 },
|
||||
{ id: "bworld:iron_ore", min_y: 5, max_y: 50, scale: 0.05, threshold: 0.65 },
|
||||
{ id: "bworld:gold_ore", min_y: 0, max_y: 30, scale: 0.04, threshold: 0.7 },
|
||||
];
|
||||
|
||||
function get_biome(temp: number, moisture: number): Biome {
|
||||
if (temp > 0.6) {
|
||||
if (moisture < -0.2) {
|
||||
return "desert";
|
||||
}
|
||||
if (moisture > 0.4) {
|
||||
return "jungle";
|
||||
}
|
||||
return "savanna";
|
||||
}
|
||||
if (temp > 0) {
|
||||
if (moisture > 0.5) {
|
||||
return "swamp";
|
||||
}
|
||||
if (moisture > 0) {
|
||||
return "forest";
|
||||
}
|
||||
return "plains";
|
||||
}
|
||||
if (temp > -0.5) {
|
||||
return "taiga";
|
||||
}
|
||||
return "tundra";
|
||||
}
|
||||
|
||||
function get_surface_block(biome: Biome) {
|
||||
if (biome === "desert") {
|
||||
return "bworld:sand";
|
||||
} else if (biome === "tundra") {
|
||||
return "bworld:snow";
|
||||
}
|
||||
return "bworld:grass";
|
||||
}
|
||||
|
||||
function biome_height_modifier(biome: Biome) {
|
||||
if (biome === "desert") {
|
||||
return 0.2;
|
||||
}
|
||||
if (biome === "plains") {
|
||||
return 0.4;
|
||||
}
|
||||
if (biome === "forest") {
|
||||
return 0.5;
|
||||
}
|
||||
if (biome === "jungle") {
|
||||
return 0.45;
|
||||
}
|
||||
if (biome === "taiga") {
|
||||
return 0.55;
|
||||
}
|
||||
if (biome === "tundra") {
|
||||
return 0.35;
|
||||
}
|
||||
if (biome === "savanna") {
|
||||
return 0.4;
|
||||
}
|
||||
if (biome === "swamp") {
|
||||
return 0.35;
|
||||
}
|
||||
|
||||
return 0.4;
|
||||
}
|
||||
|
||||
function fractal_noise(noise: NoiseFunction2D, x: number, y: number, octaves = 2) {
|
||||
let value = 0;
|
||||
let amp = 1;
|
||||
let freq = 1;
|
||||
let max = 0;
|
||||
for (let i = 0; i < octaves; i++) {
|
||||
value += noise(x * freq, y * freq) * amp;
|
||||
max += amp;
|
||||
amp *= 0.5;
|
||||
freq *= 2;
|
||||
}
|
||||
return value / max;
|
||||
}
|
||||
|
||||
function get_terrain_height(base: number, biome: Biome, x: number, z: number, noise: NoiseFunction2D) {
|
||||
const biomeMod = biome_height_modifier(biome);
|
||||
|
||||
const main = fractal_noise(noise, x * 0.003, z * 0.003) * 15;
|
||||
|
||||
const detail = fractal_noise(noise, x * 0.01, z * 0.01) * 3;
|
||||
|
||||
return Math.floor(base + biomeMod * 20 + main + detail);
|
||||
}
|
||||
|
||||
function can_place_tree(tree_map: boolean[][], local_x: number, local_z: number) {
|
||||
const TREE_SPACING = 4;
|
||||
for (let dx = -TREE_SPACING; dx <= TREE_SPACING; dx++) {
|
||||
for (let dz = -TREE_SPACING; dz <= TREE_SPACING; dz++) {
|
||||
const nx = local_x + dx;
|
||||
const nz = local_z + dz;
|
||||
if (nx >= 0 && nx < CHUNK_SIZE && nz >= 0 && nz < CHUNK_SIZE && tree_map[nx][nz]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function place_tree(dimension: Dimension, x: number, y: number, z: number, biome: Biome) {
|
||||
const height = Math.floor(Math.random() * 3) + (biome === "jungle" ? 8 : 4);
|
||||
const trunk_block = "bworld:log";
|
||||
const leaves_block = "bworld:leaves";
|
||||
|
||||
for (let i = 0; i < height; i++) {
|
||||
dimension.add_block({ x, y: y + i, z, id: trunk_block });
|
||||
}
|
||||
|
||||
for (let dx = -2; dx <= 2; dx++) {
|
||||
for (let dz = -2; dz <= 2; dz++) {
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
if (Math.abs(dx) + Math.abs(dz) + Math.abs(dy) <= 3) {
|
||||
dimension.add_block({
|
||||
x: x + dx,
|
||||
y: y + height + dy,
|
||||
z: z + dz,
|
||||
id: leaves_block,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TREE_THRESHOLD: Record<Biome, number> = {
|
||||
forest: 0.5,
|
||||
jungle: 0.3,
|
||||
taiga: 0.6,
|
||||
plains: 0.95,
|
||||
desert: 1,
|
||||
tundra: 1,
|
||||
savanna: 0.65,
|
||||
swamp: 0.5,
|
||||
snow: 0.8,
|
||||
};
|
||||
|
||||
function should_place_tree(feature_noise: NoiseFunction2D, biome: Biome, x: number, z: number) {
|
||||
const n = feature_noise(x * 0.1, z * 0.1);
|
||||
return n > (TREE_THRESHOLD[biome] ?? 0.8);
|
||||
}
|
||||
|
||||
export function generate_chunk(dimension: Dimension, cx: number, cz: number, seed = "seed") {
|
||||
const height_noise = create_noise_2d(new Alea(seed + "_height"));
|
||||
const temp_noise = create_noise_2d(new Alea(seed + "_temp"));
|
||||
const moisture_noise = create_noise_2d(new Alea(seed + "_moisture"));
|
||||
const feature_noise = create_noise_2d(new Alea(seed + "_feature"));
|
||||
const ore_noises = ORES.map((ore) => create_noise_3d(new Alea(seed + "_" + ore.id)));
|
||||
|
||||
const biome_scale = 0.003;
|
||||
const terrain_scale = 0.01;
|
||||
|
||||
const tree_map: boolean[][] = Array.from({ length: CHUNK_SIZE }, () => Array(CHUNK_SIZE).fill(false));
|
||||
|
||||
for (let x = 0; x < CHUNK_SIZE; x++) {
|
||||
for (let z = 0; z < CHUNK_SIZE; z++) {
|
||||
const wx = cx * CHUNK_SIZE + x;
|
||||
const wz = cz * CHUNK_SIZE + z;
|
||||
|
||||
const temp = temp_noise(wx * biome_scale, wz * biome_scale);
|
||||
const moisture = moisture_noise(wx * biome_scale, wz * biome_scale);
|
||||
const biome = get_biome(temp, moisture);
|
||||
|
||||
const height_noise_value = fractal_noise(height_noise, wx * terrain_scale, wz * terrain_scale);
|
||||
const base_height = (height_noise_value + 1) * 15 + 50;
|
||||
const height = get_terrain_height(base_height, biome, wx, wz, height_noise);
|
||||
|
||||
const surface_block = get_surface_block(biome);
|
||||
|
||||
for (let y = 0; y <= height; y++) {
|
||||
let block = "bworld:stone";
|
||||
|
||||
if (y < height - 3) {
|
||||
for (let i = 0; i < ORES.length; i++) {
|
||||
const ore = ORES[i];
|
||||
|
||||
if (y >= ore.min_y && y <= ore.max_y) {
|
||||
const noise = ore_noises[i](
|
||||
wx * ore.scale,
|
||||
y * ore.scale,
|
||||
wz * ore.scale,
|
||||
);
|
||||
|
||||
if (noise > ore.threshold) {
|
||||
block = ore.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (y === height) {
|
||||
block = surface_block;
|
||||
} else if (y > height - 4) {
|
||||
block = "bworld:dirt";
|
||||
}
|
||||
|
||||
if (biome === "swamp" && y === height && Math.random() < 0.2) {
|
||||
block = "bworld:water";
|
||||
}
|
||||
|
||||
dimension.add_block({ x: wx, y, z: wz, id: block });
|
||||
}
|
||||
|
||||
if (should_place_tree(feature_noise, biome, wx, wz) && can_place_tree(tree_map, x, z)) {
|
||||
place_tree(dimension, wx, height + 1, wz, biome);
|
||||
tree_map[x][z] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,45 @@
|
||||
import type { Client } from "$/client/client.ts";
|
||||
import { DebugUI } from "$/client/debug_ui.ts";
|
||||
|
||||
// f3: every entity's fields, editable
|
||||
export class DebugOverlay {
|
||||
render(client: Client) {
|
||||
DebugUI.begin("Entities", 10, 10, 300);
|
||||
|
||||
for (const entity of client.level.entities.values()) {
|
||||
if (DebugUI.collapsing_header(`${entity.constructor.name} - ${entity.id}`)) {
|
||||
this.#render_fields(entity);
|
||||
}
|
||||
}
|
||||
if (DebugUI.collapsing_header("Camera")) {
|
||||
this.#render_fields(client.camera);
|
||||
}
|
||||
if (DebugUI.collapsing_header("Options")) {
|
||||
this.#render_fields(client.options);
|
||||
}
|
||||
|
||||
DebugUI.end();
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
#render_fields(object: any) {
|
||||
for (const key in object) {
|
||||
const value = object[key];
|
||||
if (typeof value === "number") {
|
||||
object[key] = DebugUI.float_input(key, value);
|
||||
} else if (typeof value === "string") {
|
||||
object[key] = DebugUI.text_input(key, value);
|
||||
} else if (typeof value === "boolean") {
|
||||
object[key] = DebugUI.checkbox(key, value);
|
||||
} else if (Array.isArray(value)) {
|
||||
DebugUI.text(`${key}: ${JSON.stringify(value.slice(0, 10))}`);
|
||||
} else if (value && typeof value === "object" && value.constructor !== Object) {
|
||||
// other objects like the level point back at this one, only name them
|
||||
DebugUI.text(`${key}: ${value.constructor.name}`);
|
||||
} else {
|
||||
DebugUI.text(`${key}: ${JSON.stringify(value)}`);
|
||||
}
|
||||
DebugUI.separator();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mod.ts";
|
||||
import { GuiScreen } from "./gui_screen.ts";
|
||||
import { back_to_title } from "./title_screen.ts";
|
||||
import { Button, TEXT_HEIGHT, TEXT_SCALE } from "./widgets.ts";
|
||||
|
||||
const BUTTON_WIDTH = 440;
|
||||
const BUTTON_HEIGHT = 48;
|
||||
|
||||
// why the game stopped, like minecraft's DisconnectedScreen
|
||||
export class DisconnectedScreen extends GuiScreen {
|
||||
message: string;
|
||||
back = new Button("Back to title screen", BUTTON_WIDTH, BUTTON_HEIGHT, back_to_title);
|
||||
|
||||
constructor(message: string) {
|
||||
super();
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
on_tick(_delta: number): void {
|
||||
this.back.x = (canvas.width - BUTTON_WIDTH) / 2;
|
||||
this.back.y = canvas.height / 2 + 20;
|
||||
this.back.handle_input();
|
||||
}
|
||||
|
||||
on_render(): void {
|
||||
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.6]);
|
||||
this.#centered("Disconnected", canvas.height / 2 - 90, 4, [1, 1, 1, 1]);
|
||||
this.#centered(this.message, canvas.height / 2 - 30, TEXT_SCALE, [0.85, 0.85, 0.85, 1]);
|
||||
this.back.render();
|
||||
}
|
||||
|
||||
on_close(): void {}
|
||||
|
||||
#centered(text: string, y: number, scale: number, color: number[]) {
|
||||
draw_text(text, (canvas.width - measure_text(text, scale)) / 2, y - (TEXT_HEIGHT * scale) / 2, scale, color);
|
||||
}
|
||||
}
|
||||
+17
-104
@@ -1,131 +1,44 @@
|
||||
import { GuiScreen } from "./gui_screen.ts";
|
||||
import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mod.ts";
|
||||
import { canvas, draw_rect, draw_text } from "$/client/renderer/mod.ts";
|
||||
import { InputManager } from "../input_manager.ts";
|
||||
import { ClientWorld } from "../client_world.ts";
|
||||
import { PlayerComponent } from "../player.ts";
|
||||
import { ItemStack } from "../inventory.ts";
|
||||
import type { Client } from "../client.ts";
|
||||
import { MAX_CHAT_LENGTH } from "$/common/protocol.ts";
|
||||
import { TextInput } from "./widgets.ts";
|
||||
|
||||
export class GuiChat extends GuiScreen {
|
||||
world: ClientWorld;
|
||||
client: Client;
|
||||
input = new TextInput("", MAX_CHAT_LENGTH);
|
||||
|
||||
text_typed = "";
|
||||
caret = 0;
|
||||
key_repeat_timer = 0;
|
||||
show_caret = true;
|
||||
|
||||
constructor(world: ClientWorld) {
|
||||
constructor(client: Client) {
|
||||
super();
|
||||
this.world = world;
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
override on_render(): void {
|
||||
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
|
||||
|
||||
const y = canvas.height - 32;
|
||||
this.client.chat.render(true, y - 8);
|
||||
draw_rect(0, y, canvas.width, canvas.height, [0, 0, 0, 0.4]);
|
||||
|
||||
draw_text(this.text_typed, 0, y, 2, [1, 1, 1]);
|
||||
|
||||
if (this.show_caret) {
|
||||
const before_text = this.text_typed.substring(0, this.caret);
|
||||
const caret_x = 0 + measure_text(before_text, 2);
|
||||
|
||||
draw_rect(caret_x, y + 4, 1, 32 - 4);
|
||||
}
|
||||
draw_text(this.input.value, 0, y, 2, [1, 1, 1]);
|
||||
draw_rect(this.input.caret_x(0, 2), y + 4, 1, 32 - 4);
|
||||
}
|
||||
|
||||
override on_tick(_delta: number): void {
|
||||
const now = performance.now();
|
||||
const repeat_delay = 400;
|
||||
const repeat_rate = 40;
|
||||
|
||||
const allow_repeat = () => {
|
||||
if (InputManager.is_key_pressed("Backspace")) {
|
||||
this.key_repeat_timer = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (InputManager.is_key_down("Backspace")) {
|
||||
if (now - this.key_repeat_timer > repeat_delay) {
|
||||
this.key_repeat_timer = now - (repeat_delay - repeat_rate);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
if (allow_repeat()) {
|
||||
if (this.caret > 0) {
|
||||
this.text_typed = this.text_typed.slice(0, this.caret - 1) + this.text_typed.slice(this.caret);
|
||||
this.caret -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (InputManager.is_key_pressed("Delete")) {
|
||||
this.text_typed = this.text_typed.slice(0, this.caret) + this.text_typed.slice(this.caret + 1);
|
||||
}
|
||||
|
||||
if (InputManager.is_key_pressed("ArrowLeft")) {
|
||||
this.caret = Math.max(0, this.caret - 1);
|
||||
}
|
||||
|
||||
if (InputManager.is_key_pressed("ArrowRight")) {
|
||||
this.caret = Math.min(this.text_typed.length, this.caret + 1);
|
||||
}
|
||||
|
||||
if (InputManager.is_key_pressed("Home")) {
|
||||
this.caret = 0;
|
||||
}
|
||||
|
||||
if (InputManager.is_key_pressed("End")) {
|
||||
this.caret = this.text_typed.length;
|
||||
}
|
||||
|
||||
const typed = InputManager.get_typed_characters();
|
||||
|
||||
for (const char of typed) {
|
||||
this.text_typed = this.text_typed.slice(0, this.caret) + char + this.text_typed.slice(this.caret);
|
||||
this.caret += 1;
|
||||
}
|
||||
|
||||
this.input.handle_keys();
|
||||
if (InputManager.is_key_pressed("Enter")) {
|
||||
this.submit();
|
||||
}
|
||||
}
|
||||
|
||||
override on_close(): void {}
|
||||
|
||||
submit() {
|
||||
if (this.text_typed.startsWith("/")) {
|
||||
this.command();
|
||||
} else {
|
||||
// we dont have multiplayer lol?
|
||||
}
|
||||
this.text_typed = "";
|
||||
|
||||
const [player] = this.world.get_tag("player")!;
|
||||
const player_component = player.get(PlayerComponent);
|
||||
if (player_component) {
|
||||
player_component.pop_screen();
|
||||
}
|
||||
}
|
||||
|
||||
command() {
|
||||
if (this.text_typed.startsWith("/give")) {
|
||||
let [_, item_id, quantity] = this.text_typed.split(" ");
|
||||
|
||||
if (!item_id.includes(":")) {
|
||||
item_id = `bworld:${item_id}`;
|
||||
}
|
||||
if (quantity === undefined) {
|
||||
quantity = "1";
|
||||
}
|
||||
|
||||
const [player] = this.world.get_tag("player")!;
|
||||
const player_component = player.get(PlayerComponent);
|
||||
if (player_component) {
|
||||
player_component.player_inventory.container.add_item(new ItemStack(item_id, Number(quantity)));
|
||||
}
|
||||
// commands like /give run on the server too
|
||||
if (this.input.value.trim().length > 0) {
|
||||
this.client.connection.send({ type: "chat", text: this.input.value });
|
||||
}
|
||||
this.client.pop_screen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Inventory, PlayerInventory } from "../inventory.ts";
|
||||
import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
|
||||
import { canvas, draw_rect, Texture } from "$/client/renderer/mod.ts";
|
||||
import { AssetManager } from "../assets.ts";
|
||||
import { SLOT_SIZE } from "../../common/constants.ts";
|
||||
import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
|
||||
|
||||
const PADDING = 10;
|
||||
|
||||
export class GuiChest extends GuiInventoryScreen {
|
||||
override inventory_width = PADDING * 2 + SLOT_SIZE * 9;
|
||||
override inventory_height = PADDING * 4 + SLOT_SIZE * 7;
|
||||
|
||||
constructor(inventory: Inventory, player_inventory: PlayerInventory) {
|
||||
super(inventory, player_inventory, undefined);
|
||||
|
||||
add_player_hotbar(this, player_inventory, PADDING, PADDING);
|
||||
add_player_inventory(this, player_inventory, PADDING, PADDING * 2 + SLOT_SIZE);
|
||||
|
||||
const chest_x = PADDING;
|
||||
const chest_y = PADDING * 3 + SLOT_SIZE * 4;
|
||||
|
||||
for (let i = 0; i < 3; ++i) {
|
||||
for (let l = 0; l < 9; ++l) {
|
||||
this.slots.push(
|
||||
new Slot(
|
||||
this.inventory,
|
||||
l + i * 9,
|
||||
chest_x + l * SLOT_SIZE,
|
||||
chest_y + i * SLOT_SIZE,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override on_render(): void {
|
||||
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
|
||||
|
||||
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
||||
|
||||
draw_nine_slice(
|
||||
ui,
|
||||
160,
|
||||
0,
|
||||
16,
|
||||
16,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
this.x,
|
||||
this.y,
|
||||
this.inventory_width,
|
||||
this.inventory_height,
|
||||
);
|
||||
|
||||
super.on_render();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
|
||||
import { canvas, draw_rect, draw_texture_region, Texture } from "$/client/renderer/mod.ts";
|
||||
import { AssetManager } from "../assets.ts";
|
||||
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
|
||||
import { ClientMessage, ScreenLayout } from "$/common/protocol.ts";
|
||||
import { draw_nine_slice } from "../rendering/render_utils.ts";
|
||||
import { get_sprite_region } from "$/client/sprites.ts";
|
||||
import { ClientInventories } from "../inventory.ts";
|
||||
|
||||
const PADDING = 10;
|
||||
|
||||
// a screen opened by the server (chest, furnace, ...), drawn from the layout it sent
|
||||
export class GuiContainer extends GuiInventoryScreen {
|
||||
layout: ScreenLayout;
|
||||
properties: Record<string, number>;
|
||||
|
||||
constructor(
|
||||
inventories: ClientInventories,
|
||||
send: (message: ClientMessage) => void,
|
||||
layout: ScreenLayout,
|
||||
properties: Record<string, number>,
|
||||
) {
|
||||
super(inventories, send);
|
||||
this.layout = layout;
|
||||
this.properties = properties;
|
||||
|
||||
this.inventory_width = PADDING * 2 + SLOT_SIZE * 9;
|
||||
this.inventory_height = PADDING * 4 + SLOT_SIZE * (4 + layout.rows);
|
||||
|
||||
add_player_hotbar(this, PADDING, PADDING);
|
||||
add_player_inventory(this, PADDING, PADDING * 2 + SLOT_SIZE);
|
||||
|
||||
const area_y = PADDING * 3 + SLOT_SIZE * 4;
|
||||
for (const slot of layout.slots) {
|
||||
this.slots.push(
|
||||
new Slot("screen", slot.index, PADDING + slot.x * SLOT_SIZE, area_y + slot.y * SLOT_SIZE, slot.output),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
override on_render(): void {
|
||||
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
|
||||
|
||||
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
||||
|
||||
draw_nine_slice(
|
||||
ui,
|
||||
160,
|
||||
0,
|
||||
16,
|
||||
16,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
this.x,
|
||||
this.y,
|
||||
this.inventory_width,
|
||||
this.inventory_height,
|
||||
);
|
||||
|
||||
this.draw_bars();
|
||||
|
||||
super.on_render();
|
||||
}
|
||||
|
||||
draw_bars() {
|
||||
const atlas = AssetManager.instance.get<Texture>("bworld:textures");
|
||||
const area_y = PADDING * 3 + SLOT_SIZE * 4;
|
||||
|
||||
for (const bar of this.layout.bars) {
|
||||
const x = this.x + PADDING + bar.x * SLOT_SIZE;
|
||||
const y = this.y + area_y + bar.y * SLOT_SIZE;
|
||||
const max = this.properties[bar.max] ?? 0;
|
||||
const pct = max > 0 ? Math.max(0, Math.min(1, (this.properties[bar.value] ?? 0) / max)) : 0;
|
||||
|
||||
const empty = get_sprite_region(bar.empty_texture);
|
||||
draw_texture_region(
|
||||
atlas,
|
||||
empty.x * TEXTURE_SIZE,
|
||||
empty.y * TEXTURE_SIZE,
|
||||
TEXTURE_SIZE,
|
||||
TEXTURE_SIZE,
|
||||
x,
|
||||
y,
|
||||
SLOT_SIZE,
|
||||
SLOT_SIZE,
|
||||
);
|
||||
|
||||
const full = get_sprite_region(bar.full_texture);
|
||||
if (bar.direction === "right") {
|
||||
draw_texture_region(
|
||||
atlas,
|
||||
full.x * TEXTURE_SIZE,
|
||||
full.y * TEXTURE_SIZE,
|
||||
TEXTURE_SIZE * pct,
|
||||
TEXTURE_SIZE,
|
||||
x,
|
||||
y,
|
||||
SLOT_SIZE * pct,
|
||||
SLOT_SIZE,
|
||||
);
|
||||
} else {
|
||||
// fills from the bottom up
|
||||
draw_texture_region(
|
||||
atlas,
|
||||
full.x * TEXTURE_SIZE,
|
||||
full.y * TEXTURE_SIZE + TEXTURE_SIZE * (1 - pct),
|
||||
TEXTURE_SIZE,
|
||||
TEXTURE_SIZE * pct,
|
||||
x,
|
||||
y + SLOT_SIZE * (1 - pct),
|
||||
SLOT_SIZE,
|
||||
SLOT_SIZE * pct,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import { Inventory, PlayerInventory } from "../inventory.ts";
|
||||
import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
|
||||
import { canvas, draw_rect, draw_texture_region, Texture } from "$/client/renderer/mod.ts";
|
||||
import { AssetManager } from "../assets.ts";
|
||||
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
|
||||
import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
|
||||
import { get_sprite_region } from "$/common/utils.ts";
|
||||
|
||||
const PADDING = 10;
|
||||
|
||||
// crazy this is how i found out interface X {} is diffrent then type X = {}
|
||||
type TileChestData = {
|
||||
inventory: Inventory;
|
||||
progress: number;
|
||||
progress_max: number;
|
||||
fuel: number;
|
||||
fuel_max: number;
|
||||
};
|
||||
|
||||
export class GuiFurnace extends GuiInventoryScreen<Inventory, TileChestData> {
|
||||
override inventory_width = PADDING * 2 + SLOT_SIZE * 9;
|
||||
override inventory_height = PADDING * 4 + SLOT_SIZE * 7;
|
||||
|
||||
constructor(
|
||||
inventory: Inventory,
|
||||
player_inventory: PlayerInventory,
|
||||
properties: TileChestData,
|
||||
) {
|
||||
super(inventory, player_inventory, properties);
|
||||
|
||||
add_player_hotbar(this, player_inventory, PADDING, PADDING);
|
||||
add_player_inventory(this, player_inventory, PADDING, PADDING * 2 + SLOT_SIZE);
|
||||
|
||||
const furnace_y = PADDING * 3 + SLOT_SIZE * 4;
|
||||
|
||||
this.slots.push(
|
||||
new Slot(
|
||||
this.inventory,
|
||||
0,
|
||||
this.inventory_width / 2 - SLOT_SIZE,
|
||||
furnace_y,
|
||||
),
|
||||
);
|
||||
this.slots.push(
|
||||
new Slot(
|
||||
this.inventory,
|
||||
1,
|
||||
this.inventory_width / 2 - SLOT_SIZE,
|
||||
furnace_y + SLOT_SIZE * 2,
|
||||
),
|
||||
);
|
||||
this.slots.push(
|
||||
new Slot(
|
||||
this.inventory,
|
||||
2,
|
||||
this.inventory_width / 2 + SLOT_SIZE,
|
||||
furnace_y + SLOT_SIZE,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
override on_render(): void {
|
||||
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
|
||||
|
||||
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
||||
|
||||
draw_nine_slice(
|
||||
ui,
|
||||
160,
|
||||
0,
|
||||
16,
|
||||
16,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
this.x,
|
||||
this.y,
|
||||
this.inventory_width,
|
||||
this.inventory_height,
|
||||
);
|
||||
|
||||
this.draw_fire();
|
||||
this.draw_arrow();
|
||||
|
||||
super.on_render();
|
||||
}
|
||||
|
||||
override handle_left_click(slot: Slot): void {
|
||||
if (slot.inventory === this.inventory && slot.index === 2) {
|
||||
if (this.inventory.container.get_item(2)) {
|
||||
this.pickup();
|
||||
}
|
||||
} else {
|
||||
super.handle_left_click(slot);
|
||||
}
|
||||
}
|
||||
|
||||
override handle_right_click(slot: Slot): void {
|
||||
if (slot.inventory === this.inventory && slot.index === 2) {
|
||||
if (this.inventory.container.get_item(2)) {
|
||||
this.pickup();
|
||||
}
|
||||
} else {
|
||||
super.handle_right_click(slot);
|
||||
}
|
||||
}
|
||||
|
||||
pickup() {
|
||||
const holding_item = this.player_inventory.holding_item;
|
||||
const item = this.inventory.container.get_item(2)!;
|
||||
|
||||
if (holding_item) {
|
||||
if (holding_item.type_id === item.type_id) {
|
||||
const items_left = holding_item.max_amount - holding_item.amount;
|
||||
if (items_left >= item.amount) {
|
||||
holding_item.amount += item.amount;
|
||||
this.inventory.container.set_item(2, undefined);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.player_inventory.holding_item = item;
|
||||
this.inventory.container.set_item(2, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
draw_fire() {
|
||||
const atlas = AssetManager.instance.get<Texture>("bworld:textures");
|
||||
|
||||
const fire_empty_region = get_sprite_region("bworld:fire_empty");
|
||||
const fire_full_region = get_sprite_region("bworld:fire_full");
|
||||
|
||||
const fuel_pct = Math.max(0, Math.min(1, this.properties.fuel / this.properties.fuel_max));
|
||||
const full_height = SLOT_SIZE * fuel_pct;
|
||||
|
||||
const furnace_y = PADDING * 3 + SLOT_SIZE * 4;
|
||||
|
||||
draw_texture_region(
|
||||
atlas,
|
||||
fire_empty_region.x * TEXTURE_SIZE,
|
||||
fire_empty_region.y * TEXTURE_SIZE,
|
||||
TEXTURE_SIZE,
|
||||
TEXTURE_SIZE,
|
||||
this.x + this.inventory_width / 2 - SLOT_SIZE,
|
||||
this.y + furnace_y + SLOT_SIZE,
|
||||
SLOT_SIZE,
|
||||
SLOT_SIZE,
|
||||
);
|
||||
|
||||
const fire_draw_x = this.x + this.inventory_width / 2 - SLOT_SIZE;
|
||||
const fire_draw_y = this.y + furnace_y + SLOT_SIZE;
|
||||
|
||||
const fire_src_height = TEXTURE_SIZE * fuel_pct;
|
||||
|
||||
const fire_src_y_offset = TEXTURE_SIZE - fire_src_height;
|
||||
const fire_dst_y_offset = SLOT_SIZE - full_height;
|
||||
|
||||
draw_texture_region(
|
||||
atlas,
|
||||
fire_full_region.x * TEXTURE_SIZE,
|
||||
fire_full_region.y * TEXTURE_SIZE + fire_src_y_offset,
|
||||
TEXTURE_SIZE,
|
||||
fire_src_height,
|
||||
fire_draw_x,
|
||||
fire_draw_y + fire_dst_y_offset,
|
||||
SLOT_SIZE,
|
||||
full_height,
|
||||
);
|
||||
}
|
||||
|
||||
draw_arrow() {
|
||||
const atlas = AssetManager.instance.get<Texture>("bworld:textures");
|
||||
|
||||
const arrow_empty_region = get_sprite_region("bworld:arrow_empty");
|
||||
const arrow_full_region = get_sprite_region("bworld:arrow_full");
|
||||
|
||||
const progress_pct = Math.max(0, Math.min(1, this.properties.progress / this.properties.progress_max));
|
||||
const full_width = SLOT_SIZE * progress_pct;
|
||||
|
||||
const furnace_y = PADDING * 3 + SLOT_SIZE * 4;
|
||||
|
||||
draw_texture_region(
|
||||
atlas,
|
||||
arrow_empty_region.x * TEXTURE_SIZE,
|
||||
arrow_empty_region.y * TEXTURE_SIZE,
|
||||
TEXTURE_SIZE,
|
||||
TEXTURE_SIZE,
|
||||
this.x + this.inventory_width / 2,
|
||||
this.y + furnace_y + SLOT_SIZE,
|
||||
SLOT_SIZE,
|
||||
SLOT_SIZE,
|
||||
);
|
||||
|
||||
const draw_x = this.x + this.inventory_width / 2;
|
||||
const draw_y = this.y + furnace_y + SLOT_SIZE;
|
||||
|
||||
const src_width = TEXTURE_SIZE * progress_pct;
|
||||
|
||||
draw_texture_region(
|
||||
atlas,
|
||||
arrow_full_region.x * TEXTURE_SIZE,
|
||||
arrow_full_region.y * TEXTURE_SIZE,
|
||||
src_width,
|
||||
TEXTURE_SIZE,
|
||||
draw_x,
|
||||
draw_y,
|
||||
full_width,
|
||||
SLOT_SIZE,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,96 +1,23 @@
|
||||
import { Container, Inventory, ItemStack, PlayerInventory } from "../inventory.ts";
|
||||
import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
|
||||
import { canvas, draw_rect, Texture } from "$/client/renderer/mod.ts";
|
||||
import { AssetManager } from "../assets.ts";
|
||||
import { SLOT_SIZE } from "../../common/constants.ts";
|
||||
import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
|
||||
|
||||
export interface CraftingRecipe {
|
||||
width: number;
|
||||
height: number;
|
||||
pattern: (string | undefined)[];
|
||||
result: ItemStack;
|
||||
}
|
||||
|
||||
const recipes: CraftingRecipe[] = [
|
||||
{
|
||||
width: 3,
|
||||
height: 3,
|
||||
pattern: [
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
undefined,
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
],
|
||||
result: new ItemStack("bworld:chest", 1),
|
||||
},
|
||||
{
|
||||
width: 3,
|
||||
height: 3,
|
||||
pattern: [
|
||||
"bworld:stone",
|
||||
"bworld:stone",
|
||||
"bworld:stone",
|
||||
"bworld:stone",
|
||||
undefined,
|
||||
"bworld:stone",
|
||||
"bworld:stone",
|
||||
"bworld:stone",
|
||||
"bworld:stone",
|
||||
],
|
||||
result: new ItemStack("bworld:furnace", 1),
|
||||
},
|
||||
{
|
||||
width: 1,
|
||||
height: 1,
|
||||
pattern: [
|
||||
"bworld:log",
|
||||
],
|
||||
result: new ItemStack("bworld:planks", 2),
|
||||
},
|
||||
{
|
||||
width: 1,
|
||||
height: 2,
|
||||
pattern: [
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
],
|
||||
result: new ItemStack("bworld:stick", 2),
|
||||
},
|
||||
{
|
||||
width: 3,
|
||||
height: 3,
|
||||
pattern: [
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
"bworld:planks",
|
||||
undefined,
|
||||
"bworld:stick",
|
||||
undefined,
|
||||
undefined,
|
||||
"bworld:stick",
|
||||
undefined,
|
||||
],
|
||||
result: new ItemStack("bworld:wood_pickaxe", 1),
|
||||
},
|
||||
];
|
||||
import { draw_nine_slice } from "../rendering/render_utils.ts";
|
||||
import { ClientInventories } from "../inventory.ts";
|
||||
import { ClientMessage, CRAFTING_RESULT_SLOT } from "$/common/protocol.ts";
|
||||
|
||||
const PADDING = 10;
|
||||
|
||||
// the recipes live on the server, which fills in the result slot
|
||||
export class GuiPlayerInventory extends GuiInventoryScreen {
|
||||
override inventory_width = PADDING * 2 + SLOT_SIZE * 9;
|
||||
override inventory_height = PADDING * 2 + SLOT_SIZE * 4 + PADDING + SLOT_SIZE * 3 + PADDING;
|
||||
|
||||
constructor(player_inventory: PlayerInventory) {
|
||||
super(new Inventory(new Container(10)), player_inventory, undefined);
|
||||
constructor(inventories: ClientInventories, send: (message: ClientMessage) => void) {
|
||||
super(inventories, send);
|
||||
|
||||
add_player_hotbar(this, player_inventory, PADDING, PADDING);
|
||||
add_player_inventory(this, player_inventory, PADDING, PADDING * 2 + SLOT_SIZE);
|
||||
add_player_hotbar(this, PADDING, PADDING);
|
||||
add_player_inventory(this, PADDING, PADDING * 2 + SLOT_SIZE);
|
||||
|
||||
const crafting_x = PADDING;
|
||||
const crafting_y = PADDING * 3 + SLOT_SIZE * 4;
|
||||
@@ -98,17 +25,14 @@ export class GuiPlayerInventory extends GuiInventoryScreen {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
for (let j = 0; j < 3; j += 1) {
|
||||
this.slots.push(
|
||||
new Slot(this.inventory, j * 3 + i, crafting_x + i * SLOT_SIZE, crafting_y + j * SLOT_SIZE),
|
||||
new Slot("crafting", j * 3 + i, crafting_x + i * SLOT_SIZE, crafting_y + j * SLOT_SIZE),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.slots.push(new Slot(this.inventory, 9, crafting_x + 5 * SLOT_SIZE, crafting_y + 1 * SLOT_SIZE));
|
||||
}
|
||||
|
||||
override on_tick(delta: number): void {
|
||||
super.on_tick(delta);
|
||||
this.update_crafting_result();
|
||||
this.slots.push(
|
||||
new Slot("crafting", CRAFTING_RESULT_SLOT, crafting_x + 5 * SLOT_SIZE, crafting_y + 1 * SLOT_SIZE, true),
|
||||
);
|
||||
}
|
||||
|
||||
override on_render(): void {
|
||||
@@ -134,136 +58,4 @@ export class GuiPlayerInventory extends GuiInventoryScreen {
|
||||
|
||||
super.on_render();
|
||||
}
|
||||
|
||||
override on_close(): void {
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const item = this.inventory.container.get_item(i);
|
||||
if (item) {
|
||||
this.player_inventory.container.add_item(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get_crafting_grid(): (string | undefined)[] {
|
||||
const grid: (string | undefined)[] = [];
|
||||
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const item = this.inventory.container.get_item(i);
|
||||
grid.push(item?.type_id);
|
||||
}
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
matches_recipe(
|
||||
grid: (string | undefined)[],
|
||||
recipe: CraftingRecipe,
|
||||
): boolean {
|
||||
for (let y = 0; y <= 3 - recipe.height; y++) {
|
||||
for (let x = 0; x <= 3 - recipe.width; x++) {
|
||||
let match = true;
|
||||
|
||||
for (let gy = 0; gy < 3; gy++) {
|
||||
for (let gx = 0; gx < 3; gx++) {
|
||||
const grid_index = gy * 3 + gx;
|
||||
|
||||
if (
|
||||
gx >= x &&
|
||||
gx < x + recipe.width &&
|
||||
gy >= y &&
|
||||
gy < y + recipe.height
|
||||
) {
|
||||
const rx = gx - x;
|
||||
const ry = gy - y;
|
||||
const recipe_index = ry * recipe.width + rx;
|
||||
|
||||
if (grid[grid_index] !== recipe.pattern[recipe_index]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (grid[grid_index] !== undefined) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!match) break;
|
||||
}
|
||||
|
||||
if (match) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
update_crafting_result() {
|
||||
const grid = this.get_crafting_grid();
|
||||
|
||||
for (const recipe of recipes) {
|
||||
if (this.matches_recipe(grid, recipe)) {
|
||||
this.inventory.container.set_item(
|
||||
9,
|
||||
recipe.result.clone(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.inventory.container.set_item(9, undefined);
|
||||
}
|
||||
|
||||
consume_recipe_items() {
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const item = this.inventory.container.get_item(i);
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
item.amount -= 1;
|
||||
|
||||
if (item.amount <= 0) {
|
||||
this.inventory.container.set_item(i, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override handle_left_click(slot: Slot): void {
|
||||
if (slot.inventory === this.inventory && slot.index === 9) {
|
||||
if (this.inventory.container.get_item(9)) {
|
||||
this.craft();
|
||||
}
|
||||
} else {
|
||||
super.handle_left_click(slot);
|
||||
}
|
||||
}
|
||||
|
||||
override handle_right_click(slot: Slot): void {
|
||||
if (slot.inventory === this.inventory && slot.index === 9) {
|
||||
if (this.inventory.container.get_item(9)) {
|
||||
this.craft();
|
||||
}
|
||||
} else {
|
||||
super.handle_right_click(slot);
|
||||
}
|
||||
}
|
||||
|
||||
craft() {
|
||||
const holding_item = this.player_inventory.holding_item;
|
||||
const item = this.inventory.container.get_item(9)!;
|
||||
|
||||
if (holding_item) {
|
||||
if (holding_item.type_id === item.type_id) {
|
||||
const items_left = holding_item.max_amount - holding_item.amount;
|
||||
if (items_left >= item.amount) {
|
||||
this.consume_recipe_items();
|
||||
holding_item.amount += item.amount;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.player_inventory.holding_item = item;
|
||||
this.consume_recipe_items();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+76
-186
@@ -1,27 +1,28 @@
|
||||
import { SLOT_SIZE } from "$/common/constants.ts";
|
||||
import { click_slot, Container } from "$/common/inventory.ts";
|
||||
import { ClientMessage, ContainerKey, CRAFTING_RESULT_SLOT } from "$/common/protocol.ts";
|
||||
import { point_inside_rec } from "../../common/utils.ts";
|
||||
import { AssetManager } from "../assets.ts";
|
||||
import { InputManager } from "../input_manager.ts";
|
||||
import { Inventory, ItemStack, PlayerInventory } from "../inventory.ts";
|
||||
import { ClientInventories } from "../inventory.ts";
|
||||
import { canvas, Texture } from "../renderer/mod.ts";
|
||||
import { draw_item, draw_nine_slice } from "../systems/rendering/render_utils.ts";
|
||||
import { draw_item, draw_nine_slice } from "../rendering/render_utils.ts";
|
||||
|
||||
export class Slot {
|
||||
inventory: Inventory;
|
||||
container: ContainerKey;
|
||||
index: number;
|
||||
// relative to screen top left
|
||||
x: number;
|
||||
y: number;
|
||||
// can only be taken from, like the crafting or furnace result
|
||||
output: boolean;
|
||||
|
||||
constructor(inventory: Inventory, index: number, x: number, y: number) {
|
||||
this.inventory = inventory;
|
||||
constructor(container: ContainerKey, index: number, x: number, y: number, output = false) {
|
||||
this.container = container;
|
||||
this.index = index;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
get item(): ItemStack | undefined {
|
||||
return this.inventory.container.get_item(this.index);
|
||||
this.output = output;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,25 +35,32 @@ export abstract class GuiScreen {
|
||||
abstract on_close(): void;
|
||||
}
|
||||
|
||||
export class GuiInventoryScreen<Inv extends Inventory = Inventory, Properties = Record<string, unknown> | undefined>
|
||||
extends GuiScreen {
|
||||
inventory: Inv;
|
||||
player_inventory: PlayerInventory;
|
||||
properties: Properties;
|
||||
// a screen of slots. the server owns the contents: clicks are sent to it and guessed locally
|
||||
// so they feel instant, then whatever the server syncs back wins
|
||||
export class GuiInventoryScreen extends GuiScreen {
|
||||
inventories: ClientInventories;
|
||||
send: (message: ClientMessage) => void;
|
||||
|
||||
slots: Slot[] = [];
|
||||
hovering: Slot | undefined;
|
||||
inventory_width: number = 500;
|
||||
inventory_height: number = 500;
|
||||
|
||||
constructor(
|
||||
inventory: Inv,
|
||||
player_inventory: PlayerInventory,
|
||||
properties: Properties,
|
||||
) {
|
||||
constructor(inventories: ClientInventories, send: (message: ClientMessage) => void) {
|
||||
super();
|
||||
this.inventory = inventory;
|
||||
this.player_inventory = player_inventory;
|
||||
this.properties = properties;
|
||||
this.inventories = inventories;
|
||||
this.send = send;
|
||||
}
|
||||
|
||||
get_container(key: ContainerKey): Container | undefined {
|
||||
switch (key) {
|
||||
case "inventory":
|
||||
return this.inventories.inventory;
|
||||
case "crafting":
|
||||
return this.inventories.crafting;
|
||||
case "screen":
|
||||
return this.inventories.screen;
|
||||
}
|
||||
}
|
||||
|
||||
on_tick(_delta: number): void {
|
||||
@@ -60,20 +68,17 @@ export class GuiInventoryScreen<Inv extends Inventory = Inventory, Properties =
|
||||
this.y = canvas.height / 2 - (this.inventory_height / 2);
|
||||
|
||||
this.handle_interaction();
|
||||
|
||||
if (this.player_inventory.holding_item?.amount === 0) {
|
||||
this.player_inventory.holding_item = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
on_render(): void {
|
||||
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
||||
|
||||
for (const slot of this.slots) {
|
||||
const hovered = slot === this.hovering;
|
||||
draw_nine_slice(
|
||||
ui,
|
||||
slot.inventory.hovering_slot === slot.index ? 19 * 16 : 160 + 32,
|
||||
slot.inventory.hovering_slot === slot.index ? 16 : 0,
|
||||
hovered ? 19 * 16 : 160 + 32,
|
||||
hovered ? 16 : 0,
|
||||
16,
|
||||
16,
|
||||
4,
|
||||
@@ -88,198 +93,83 @@ export class GuiInventoryScreen<Inv extends Inventory = Inventory, Properties =
|
||||
}
|
||||
|
||||
for (const slot of this.slots) {
|
||||
const item = slot.item;
|
||||
const item = this.get_container(slot.container)?.get_item(slot.index);
|
||||
if (item) {
|
||||
draw_item(item, this.x + slot.x, this.y + slot.y);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.player_inventory.holding_item) {
|
||||
if (this.inventories.cursor.item) {
|
||||
const mouse = InputManager.get_mouse_position();
|
||||
draw_item(this.player_inventory.holding_item, mouse.x, mouse.y);
|
||||
draw_item(this.inventories.cursor.item, mouse.x, mouse.y);
|
||||
}
|
||||
}
|
||||
|
||||
on_close(): void {}
|
||||
on_close(): void {
|
||||
this.send({ type: "close_screen" });
|
||||
|
||||
// the server puts these back in the inventory, show it right away
|
||||
const { inventory, crafting, cursor } = this.inventories;
|
||||
for (let i = 0; i < CRAFTING_RESULT_SLOT; i++) {
|
||||
const item = crafting.get_item(i);
|
||||
if (item) {
|
||||
inventory.add_item(item);
|
||||
crafting.set_item(i, undefined);
|
||||
}
|
||||
}
|
||||
crafting.set_item(CRAFTING_RESULT_SLOT, undefined);
|
||||
if (cursor.item) {
|
||||
inventory.add_item(cursor.item);
|
||||
cursor.item = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
handle_interaction() {
|
||||
this.player_inventory.hovering_slot = -1;
|
||||
this.inventory.hovering_slot = -1;
|
||||
this.hovering = undefined;
|
||||
const mouse = InputManager.get_mouse_position();
|
||||
|
||||
for (const slot of this.slots) {
|
||||
const mouse = InputManager.get_mouse_position();
|
||||
const slot_x = slot.x + this.x;
|
||||
const slot_y = slot.y + this.y;
|
||||
const hovering = point_inside_rec(mouse.x, mouse.y, slot_x, slot_y, SLOT_SIZE, SLOT_SIZE);
|
||||
if (hovering) {
|
||||
slot.inventory.hovering_slot = slot.index;
|
||||
const hovering = point_inside_rec(mouse.x, mouse.y, this.x + slot.x, this.y + slot.y, SLOT_SIZE, SLOT_SIZE);
|
||||
if (!hovering) {
|
||||
continue;
|
||||
}
|
||||
this.hovering = slot;
|
||||
|
||||
if (InputManager.is_mouse_pressed(0)) {
|
||||
InputManager.consume_mouse(0);
|
||||
this.handle_left_click(slot);
|
||||
return;
|
||||
}
|
||||
|
||||
if (InputManager.is_mouse_pressed(2)) {
|
||||
InputManager.consume_mouse(2);
|
||||
this.handle_right_click(slot);
|
||||
for (const button of [0, 2]) {
|
||||
if (InputManager.is_mouse_pressed(button)) {
|
||||
InputManager.consume_mouse(button);
|
||||
this.click(slot, button);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch_item_with_holding(inventory: Inventory, index: number) {
|
||||
const container_slot = inventory.container.get_slot(index);
|
||||
const original_holding_item = this.player_inventory.holding_item;
|
||||
this.player_inventory.holding_item = container_slot.get_item();
|
||||
container_slot.set_item(original_holding_item);
|
||||
}
|
||||
click(slot: Slot, button: number) {
|
||||
this.send({ type: "click", container: slot.container, index: slot.index, button });
|
||||
|
||||
handle_left_click(slot: Slot) {
|
||||
const holding = this.player_inventory.holding_item;
|
||||
const inventory_slot = slot.inventory.container.get_slot(slot.index);
|
||||
const slot_item = inventory_slot.get_item();
|
||||
|
||||
// if you aren't holding anything
|
||||
// "swap" with nothing on your hand (pick it up)
|
||||
if (!holding) {
|
||||
this.switch_item_with_holding(slot.inventory, slot.index);
|
||||
return;
|
||||
}
|
||||
|
||||
// if you are holding something and slot type equals holding type
|
||||
// try to add to stack
|
||||
if (slot_item && inventory_slot.type_id === holding.type_id) {
|
||||
const space_left = inventory_slot.max_amount! - slot_item.amount!;
|
||||
const amount_to_add = Math.min(space_left, holding.amount);
|
||||
|
||||
slot_item.amount += amount_to_add;
|
||||
holding.amount -= amount_to_add;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// if something on hand but not the same
|
||||
// swap
|
||||
this.switch_item_with_holding(slot.inventory, slot.index);
|
||||
}
|
||||
|
||||
handle_right_click(slot: Slot) {
|
||||
const holding = this.player_inventory.holding_item;
|
||||
const inventory_slot = slot.inventory.container.get_slot(slot.index);
|
||||
const slot_item = inventory_slot.get_item();
|
||||
|
||||
// if holding something
|
||||
if (holding) {
|
||||
// and slot type equals holding type
|
||||
// add 1 to matching stack
|
||||
if (slot_item && inventory_slot.type_id === holding.type_id) {
|
||||
if (slot_item.amount < slot_item.max_amount!) {
|
||||
slot_item.amount += 1;
|
||||
holding.amount -= 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// (actually the same as the last one but creates a new one techinically)
|
||||
// place 1 into empty slot
|
||||
if (!slot_item) {
|
||||
const new_item = holding.clone();
|
||||
new_item.amount = 1;
|
||||
inventory_slot.set_item(new_item);
|
||||
holding.amount -= 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// if something on hand but not the same
|
||||
// swap
|
||||
this.switch_item_with_holding(slot.inventory, slot.index);
|
||||
return;
|
||||
}
|
||||
|
||||
// if player is holding nothing and clicks nothing, nothing happens
|
||||
if (!slot_item) {
|
||||
return;
|
||||
}
|
||||
|
||||
// pick up half of the stack
|
||||
const original_amount = slot_item.amount;
|
||||
const half = Math.floor(original_amount / 2);
|
||||
|
||||
slot_item.amount = half;
|
||||
|
||||
const picked_up = slot_item.clone();
|
||||
picked_up.amount = original_amount - half;
|
||||
|
||||
this.player_inventory.holding_item = picked_up;
|
||||
|
||||
if (slot_item.amount === 0) {
|
||||
inventory_slot.set_item(undefined);
|
||||
// output slots depend on server side things like recipes, just wait for the server
|
||||
const container = this.get_container(slot.container);
|
||||
if (container && !slot.output) {
|
||||
click_slot(container, slot.index, this.inventories.cursor, button);
|
||||
}
|
||||
}
|
||||
|
||||
/*handle_hotbar(world: ClientWorld, player_inventory: PlayerInventory) {
|
||||
const [player, player_hand] = world.get_tag("player")!;
|
||||
|
||||
const player_position = player.get(Position);
|
||||
const player_sprite = player.get(AnimatedSprite);
|
||||
const hand_position = player_hand.get(Position);
|
||||
const hand_sprite = player_hand.get(Sprite);
|
||||
|
||||
if (!player_position || !hand_position || !hand_sprite || !player_sprite) {
|
||||
return;
|
||||
}
|
||||
|
||||
// sync position
|
||||
hand_position.x = player_position.x + (player_sprite.flip_x ? 34 : 4);
|
||||
hand_position.y = player_position.y + 34;
|
||||
hand_sprite.flip_x = player_sprite.flip_x;
|
||||
|
||||
const maybe_item = player_inventory.container.get_item(player_inventory.hotbar_selected);
|
||||
if (maybe_item) {
|
||||
const region = get_sprite_region(maybe_item.type_id);
|
||||
hand_sprite.source_x = region.x * 16;
|
||||
hand_sprite.source_y = region.y * 16;
|
||||
hand_sprite.source_width = 16;
|
||||
hand_sprite.source_height = 16;
|
||||
hand_sprite.width = 32;
|
||||
hand_sprite.height = 32;
|
||||
} else {
|
||||
hand_sprite.width = 0;
|
||||
hand_sprite.height = 0;
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
// generic methods that is often needed
|
||||
|
||||
export function add_player_inventory(
|
||||
handler: GuiInventoryScreen,
|
||||
player_inventory: PlayerInventory,
|
||||
offset_x: number,
|
||||
offset_y: number,
|
||||
) {
|
||||
export function add_player_inventory(handler: GuiInventoryScreen, offset_x: number, offset_y: number) {
|
||||
for (let i = 0; i < 3; ++i) {
|
||||
for (let l = 0; l < 9; ++l) {
|
||||
handler.slots.push(
|
||||
new Slot(
|
||||
player_inventory,
|
||||
l + i * 9 + 9,
|
||||
offset_x + l * SLOT_SIZE,
|
||||
offset_y + i * SLOT_SIZE,
|
||||
),
|
||||
new Slot("inventory", l + i * 9 + 9, offset_x + l * SLOT_SIZE, offset_y + i * SLOT_SIZE),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function add_player_hotbar(
|
||||
handler: GuiInventoryScreen,
|
||||
player_inventory: PlayerInventory,
|
||||
offset_x: number,
|
||||
offset_y: number,
|
||||
) {
|
||||
export function add_player_hotbar(handler: GuiInventoryScreen, offset_x: number, offset_y: number) {
|
||||
for (let i = 0; i < 9; ++i) {
|
||||
handler.slots.push(new Slot(player_inventory, i, offset_x + i * SLOT_SIZE, offset_y));
|
||||
handler.slots.push(new Slot("inventory", i, offset_x + i * SLOT_SIZE, offset_y));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,66 @@
|
||||
import { SLOT_SIZE } from "$/common/constants.ts";
|
||||
import { AssetManager } from "$/client/assets.ts";
|
||||
import type { Client } from "$/client/client.ts";
|
||||
import type { ClientInventories } from "$/client/inventory.ts";
|
||||
import { draw_item, draw_nine_slice } from "$/client/rendering/render_utils.ts";
|
||||
import { canvas, draw_rect_stroke, Texture } from "$/client/renderer/mod.ts";
|
||||
|
||||
const PADDING = 10;
|
||||
const CROSSHAIR_SIZE = 8;
|
||||
|
||||
// what's drawn over the world while playing, like minecraft's Gui: hotbar, crosshair and chat
|
||||
export class Hud {
|
||||
render(client: Client) {
|
||||
this.#render_hotbar(client.player.inventories);
|
||||
this.#render_crosshair();
|
||||
client.chat.render(false);
|
||||
}
|
||||
|
||||
#render_hotbar(inventories: ClientInventories) {
|
||||
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
||||
|
||||
const hotbar_width = PADDING * 2 + SLOT_SIZE * 9;
|
||||
const hotbar_height = PADDING * 2 + SLOT_SIZE;
|
||||
|
||||
const x = canvas.width / 2 - hotbar_width / 2;
|
||||
const y = canvas.height - hotbar_height;
|
||||
|
||||
draw_nine_slice(ui, 160, 0, 16, 16, 4, 4, 4, 4, x, y, hotbar_width, hotbar_height);
|
||||
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const selected = inventories.hotbar_selected === index;
|
||||
draw_nine_slice(
|
||||
ui,
|
||||
selected ? 19 * 16 : 160 + 32,
|
||||
selected ? 16 : 0,
|
||||
16,
|
||||
16,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
x + PADDING + index * SLOT_SIZE,
|
||||
y + PADDING,
|
||||
SLOT_SIZE,
|
||||
SLOT_SIZE,
|
||||
);
|
||||
}
|
||||
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const item = inventories.inventory.get_item(index);
|
||||
if (item) {
|
||||
draw_item(item, x + PADDING + index * SLOT_SIZE, y + PADDING);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#render_crosshair() {
|
||||
draw_rect_stroke(
|
||||
(canvas.width - CROSSHAIR_SIZE) / 2,
|
||||
(canvas.height - CROSSHAIR_SIZE) / 2,
|
||||
CROSSHAIR_SIZE,
|
||||
CROSSHAIR_SIZE,
|
||||
[0, 0, 0, 0.6],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,209 @@
|
||||
import { MAX_NAME_LENGTH } from "$/common/protocol.ts";
|
||||
import { InputManager } from "$/client/input_manager.ts";
|
||||
import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mod.ts";
|
||||
import { default_server, HandshakeError, server_address, ServerAddress } from "$/client/handshake.ts";
|
||||
import { default_player_name } from "$/client/network.ts";
|
||||
import { GuiScreen } from "./gui_screen.ts";
|
||||
import { CreditsScreen } from "./credits_screen.ts";
|
||||
import { Button, EditBox, TEXT_HEIGHT, TEXT_SCALE, TextInput } from "./widgets.ts";
|
||||
|
||||
const LAST_SERVER_KEY = "bworld:last_server";
|
||||
const LAST_NAME_KEY = "bworld:last_name";
|
||||
|
||||
const WIDTH = 440;
|
||||
const ROW_HEIGHT = 48;
|
||||
const LABEL_GAP = 28;
|
||||
const GAP = 20;
|
||||
const TITLE_SCALE = 8;
|
||||
|
||||
// connects to a server and joins it. it reports progress through status, and throws with a message
|
||||
// for the player when it fails
|
||||
export type JoinServer = (address: ServerAddress, name: string, status: (text: string) => void) => Promise<void>;
|
||||
|
||||
// the first thing players see, like minecraft's title and multiplayer screens rolled into one:
|
||||
// a name, a server, and a button to join it
|
||||
export class TitleScreen extends GuiScreen {
|
||||
#join_server: JoinServer;
|
||||
|
||||
name = new EditBox(
|
||||
WIDTH,
|
||||
ROW_HEIGHT,
|
||||
new TextInput(initial_name(), MAX_NAME_LENGTH, (char) => /^[A-Za-z0-9_]$/.test(char)),
|
||||
"Random name",
|
||||
);
|
||||
server = new EditBox(WIDTH, ROW_HEIGHT, new TextInput(initial_server(), 256, (char) => char !== " "), "host:port");
|
||||
join_button = new Button("Join server", WIDTH, ROW_HEIGHT, () => this.#join());
|
||||
credits_button = new Button("Credits", WIDTH, ROW_HEIGHT, () => this.#open_credits());
|
||||
// open over the title screen, it gets the input while it's there
|
||||
#credits: CreditsScreen | undefined;
|
||||
|
||||
status = "";
|
||||
status_is_error = false;
|
||||
#joining = false;
|
||||
|
||||
constructor(join_server: JoinServer, message?: string) {
|
||||
super();
|
||||
this.#join_server = join_server;
|
||||
this.name.focused = this.name.value === "";
|
||||
this.server.focused = !this.name.focused;
|
||||
if (message) {
|
||||
this.#show_error(message);
|
||||
}
|
||||
}
|
||||
|
||||
on_tick(delta: number): void {
|
||||
this.#layout();
|
||||
if (this.#credits) {
|
||||
if (InputManager.is_key_pressed("Escape")) {
|
||||
this.#close_credits();
|
||||
} else {
|
||||
this.#credits.on_tick(delta);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = [this.name, this.server];
|
||||
for (const field of fields) {
|
||||
if (field.handle_input()) {
|
||||
for (const other of fields) other.focused = other === field;
|
||||
}
|
||||
}
|
||||
if (InputManager.is_key_pressed("Tab")) {
|
||||
const next = this.name.focused ? this.server : this.name;
|
||||
for (const field of fields) field.focused = field === next;
|
||||
}
|
||||
if (InputManager.is_key_pressed("Enter")) {
|
||||
this.#join();
|
||||
}
|
||||
this.join_button.handle_input();
|
||||
this.credits_button.handle_input();
|
||||
}
|
||||
|
||||
on_render(): void {
|
||||
const title = "bworld";
|
||||
const title_width = measure_text(title, TITLE_SCALE);
|
||||
draw_text(
|
||||
title,
|
||||
(canvas.width - title_width) / 2,
|
||||
this.name.y - LABEL_GAP - 40 - TEXT_HEIGHT * TITLE_SCALE,
|
||||
TITLE_SCALE,
|
||||
);
|
||||
|
||||
this.#label("Name", this.name);
|
||||
this.#label("Server", this.server);
|
||||
this.name.render();
|
||||
this.server.render();
|
||||
this.join_button.render();
|
||||
this.credits_button.render();
|
||||
|
||||
if (this.status) {
|
||||
const width = measure_text(this.status, TEXT_SCALE);
|
||||
const x = (canvas.width - width) / 2;
|
||||
const y = this.credits_button.y + ROW_HEIGHT + GAP;
|
||||
draw_rect(x - 8, y - 4, width + 16, TEXT_HEIGHT * TEXT_SCALE + 8, [0, 0, 0, 0.5]);
|
||||
draw_text(this.status, x, y, TEXT_SCALE, this.status_is_error ? [1, 0.45, 0.45, 1] : [1, 1, 1, 1]);
|
||||
}
|
||||
this.#credits?.on_render();
|
||||
}
|
||||
|
||||
on_close(): void {
|
||||
this.#close_credits();
|
||||
}
|
||||
|
||||
#open_credits() {
|
||||
this.#credits ??= new CreditsScreen(() => this.#close_credits());
|
||||
}
|
||||
|
||||
#close_credits() {
|
||||
this.#credits?.on_close();
|
||||
this.#credits = undefined;
|
||||
}
|
||||
|
||||
#layout() {
|
||||
const x = (canvas.width - WIDTH) / 2;
|
||||
const total = 2 * (LABEL_GAP + ROW_HEIGHT + GAP) + 2 * ROW_HEIGHT + GAP;
|
||||
let y = (canvas.height - total) / 2 + 40;
|
||||
for (const field of [this.name, this.server]) {
|
||||
field.x = x;
|
||||
field.y = y + LABEL_GAP;
|
||||
y += LABEL_GAP + ROW_HEIGHT + GAP;
|
||||
}
|
||||
this.join_button.x = x;
|
||||
this.join_button.y = y;
|
||||
this.credits_button.x = x;
|
||||
this.credits_button.y = y + ROW_HEIGHT + GAP;
|
||||
}
|
||||
|
||||
#label(text: string, field: EditBox) {
|
||||
draw_text(text, field.x, field.y - LABEL_GAP, TEXT_SCALE, [0.15, 0.15, 0.2, 1]);
|
||||
}
|
||||
|
||||
async #join() {
|
||||
if (this.#joining) {
|
||||
return;
|
||||
}
|
||||
|
||||
let address: ServerAddress;
|
||||
try {
|
||||
address = server_address(this.server.value);
|
||||
} catch (e) {
|
||||
this.#show_error((e as HandshakeError).message);
|
||||
return;
|
||||
}
|
||||
remember(LAST_SERVER_KEY, this.server.value.trim());
|
||||
remember(LAST_NAME_KEY, this.name.value);
|
||||
|
||||
this.#set_joining(true);
|
||||
this.status = `Connecting to ${address.base.host}...`;
|
||||
this.status_is_error = false;
|
||||
try {
|
||||
await this.#join_server(address, this.name.value, (text) => this.status = text);
|
||||
} catch (e) {
|
||||
this.#show_error(e instanceof Error ? e.message : String(e));
|
||||
this.#set_joining(false);
|
||||
}
|
||||
}
|
||||
|
||||
#set_joining(joining: boolean) {
|
||||
this.#joining = joining;
|
||||
this.name.active = this.server.active = this.join_button.active = !joining;
|
||||
}
|
||||
|
||||
#show_error(message: string) {
|
||||
this.status = message;
|
||||
this.status_is_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
// what was typed last time, unless the page was opened with ?server= or ?name=
|
||||
function initial_server() {
|
||||
const page = new URL(location.href);
|
||||
return page.searchParams.has("server") ? default_server(page) : recall(LAST_SERVER_KEY) ?? default_server(page);
|
||||
}
|
||||
|
||||
function initial_name() {
|
||||
const page = new URL(location.href);
|
||||
return page.searchParams.has("name") ? default_player_name() : recall(LAST_NAME_KEY) ?? "";
|
||||
}
|
||||
|
||||
function recall(key: string): string | undefined {
|
||||
try {
|
||||
return localStorage.getItem(key) ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function remember(key: string, value: string) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// private windows and blocked storage just start empty next time
|
||||
}
|
||||
}
|
||||
|
||||
// a server's mods can't be unloaded, so going back to the title screen starts the page over. without ?server=
|
||||
// or ?name=, so the fields show what was used last
|
||||
export function back_to_title() {
|
||||
location.href = location.pathname;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// connecting to a server and getting what it needs before joining, see "Delivery to clients" in MODS.md:
|
||||
// hello -> welcome, download and check everything, ready -> join
|
||||
import type { AtlasListing } from "$/common/mod_loader.ts";
|
||||
import { ClientMessage, PROTOCOL_VERSION, ServerMessage } from "$/common/protocol.ts";
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 5000;
|
||||
const TRUST_KEY_PREFIX = "bworld:trusted:";
|
||||
|
||||
export type Welcome = Extract<ServerMessage, { type: "welcome" }>;
|
||||
export type Join = Extract<ServerMessage, { type: "join" }>;
|
||||
|
||||
export interface ServerAddress {
|
||||
ws_url: string;
|
||||
// where the server's files are, mod paths are relative to this
|
||||
base: URL;
|
||||
// the page came from somewhere else than the server, so its mods need the player's ok
|
||||
cross_origin: boolean;
|
||||
}
|
||||
|
||||
// something went wrong in a way the player should see
|
||||
export class HandshakeError extends Error {}
|
||||
|
||||
// what the server field starts as: ?server=host:port, otherwise the server that served the page
|
||||
export function default_server(page = new URL(location.href)): string {
|
||||
return page.searchParams.get("server") ?? page.host;
|
||||
}
|
||||
|
||||
// what a player typed as the server: host:port, or a full http(s) or ws(s) url
|
||||
export function server_address(input: string, page = new URL(location.href)): ServerAddress {
|
||||
const text = input.trim();
|
||||
let host = text;
|
||||
// without a scheme it's as secure as the page, browsers block insecure sockets from secure pages anyway
|
||||
let secure = page.protocol === "https:";
|
||||
if (text.includes("://")) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(text);
|
||||
} catch {
|
||||
throw new HandshakeError(`${text} isn't a server address`);
|
||||
}
|
||||
host = url.host;
|
||||
secure = url.protocol === "https:" || url.protocol === "wss:";
|
||||
}
|
||||
if (!host || /[\s/?#]/.test(host)) {
|
||||
throw new HandshakeError(`${text || "An empty address"} isn't a server address`);
|
||||
}
|
||||
const base = new URL(`${secure ? "https" : "http"}://${host}/`);
|
||||
return {
|
||||
ws_url: `${secure ? "wss" : "ws"}://${host}/ws`,
|
||||
base,
|
||||
cross_origin: base.origin !== page.origin,
|
||||
};
|
||||
}
|
||||
|
||||
// a socket whose messages all land in one queue, so none get lost between the handshake and the game
|
||||
export class ServerSocket {
|
||||
socket: WebSocket;
|
||||
messages: ServerMessage[] = [];
|
||||
closed = false;
|
||||
#waiters: (() => void)[] = [];
|
||||
|
||||
constructor(socket: WebSocket) {
|
||||
this.socket = socket;
|
||||
socket.addEventListener("message", (event) => {
|
||||
this.messages.push(JSON.parse(event.data));
|
||||
this.#wake();
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
this.closed = true;
|
||||
this.#wake();
|
||||
});
|
||||
}
|
||||
|
||||
send(message: ClientMessage) {
|
||||
if (this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket.close();
|
||||
}
|
||||
|
||||
// takes the first message of one of these types out of the queue, waiting for it if needed
|
||||
async next<T extends ServerMessage["type"]>(...types: T[]): Promise<Extract<ServerMessage, { type: T }>> {
|
||||
while (true) {
|
||||
const index = this.messages.findIndex((m) => (types as string[]).includes(m.type));
|
||||
if (index !== -1) {
|
||||
return this.messages.splice(index, 1)[0] as Extract<ServerMessage, { type: T }>;
|
||||
}
|
||||
if (this.closed) {
|
||||
throw new HandshakeError("The server closed the connection");
|
||||
}
|
||||
await new Promise<void>((resolve) => this.#waiters.push(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
#wake() {
|
||||
for (const waiter of this.#waiters.splice(0)) waiter();
|
||||
}
|
||||
}
|
||||
|
||||
export async function connect(
|
||||
address: ServerAddress,
|
||||
name: string,
|
||||
): Promise<{ socket: ServerSocket; welcome: Welcome }> {
|
||||
const socket = await new Promise<WebSocket>((resolve, reject) => {
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(address.ws_url);
|
||||
} catch {
|
||||
reject(new HandshakeError(`Couldn't connect to the server at ${address.ws_url}`));
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new HandshakeError(`The server at ${address.ws_url} didn't answer`));
|
||||
}, CONNECT_TIMEOUT_MS);
|
||||
ws.addEventListener("open", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(ws);
|
||||
});
|
||||
ws.addEventListener("error", () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new HandshakeError(`Couldn't connect to the server at ${address.ws_url}`));
|
||||
});
|
||||
});
|
||||
|
||||
const server = new ServerSocket(socket);
|
||||
server.send({ type: "hello", name, protocol: PROTOCOL_VERSION });
|
||||
const answer = await server.next("welcome", "rejected");
|
||||
if (answer.type === "rejected") {
|
||||
server.close();
|
||||
throw new HandshakeError(answer.reason);
|
||||
}
|
||||
return { socket: server, welcome: answer };
|
||||
}
|
||||
|
||||
// tell the server everything's loaded and wait to be let in
|
||||
export async function join(socket: ServerSocket): Promise<Join> {
|
||||
socket.send({ type: "ready" });
|
||||
const answer = await socket.next("join", "rejected");
|
||||
if (answer.type === "rejected") {
|
||||
socket.close();
|
||||
throw new HandshakeError(answer.reason);
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
// downloads a file and checks it's the one the server listed. use the bytes returned, never fetch it again
|
||||
export async function fetch_verified(url: URL, sha256: string, what: string): Promise<Uint8Array<ArrayBuffer>> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url);
|
||||
} catch {
|
||||
throw new HandshakeError(`Couldn't download ${what} from ${url}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new HandshakeError(`Couldn't download ${what} (${response.status})`);
|
||||
}
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))]
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
if (digest !== sha256) {
|
||||
throw new HandshakeError(`${what} doesn't match what the server listed`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// a url for code that was already downloaded and checked, so importing it can't fetch something else
|
||||
export function code_url(bytes: Uint8Array<ArrayBuffer>): string {
|
||||
return URL.createObjectURL(new Blob([bytes], { type: "text/javascript" }));
|
||||
}
|
||||
|
||||
export async function load_atlas(address: ServerAddress, atlas: AtlasListing) {
|
||||
const [png, json] = await Promise.all([
|
||||
fetch_verified(new URL(atlas.png, address.base), atlas.sha256.png, "the texture atlas"),
|
||||
fetch_verified(new URL(atlas.json, address.base), atlas.sha256.json, "the texture atlas"),
|
||||
]);
|
||||
return {
|
||||
image: await createImageBitmap(new Blob([png], { type: "image/png" })),
|
||||
regions: JSON.parse(new TextDecoder().decode(json)) as Record<string, { x: number; y: number }>,
|
||||
};
|
||||
}
|
||||
|
||||
// players ok a cross-origin server's mods once, per server and exact mod versions
|
||||
|
||||
function trust_key(address: ServerAddress) {
|
||||
return TRUST_KEY_PREFIX + address.base.origin;
|
||||
}
|
||||
|
||||
function mod_versions(welcome: Welcome) {
|
||||
return welcome.mods.map((mod) => `${mod.id}@${mod.hash}`).sort().join(",");
|
||||
}
|
||||
|
||||
export function is_trusted(address: ServerAddress, welcome: Welcome): boolean {
|
||||
try {
|
||||
return localStorage.getItem(trust_key(address)) === mod_versions(welcome);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function remember_trust(address: ServerAddress, welcome: Welcome) {
|
||||
try {
|
||||
localStorage.setItem(trust_key(address), mod_versions(welcome));
|
||||
} catch {
|
||||
// private windows and blocked storage just ask again next time
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,8 @@ export class InputManager {
|
||||
static mouse_ungrab_timer = 0;
|
||||
static mouse_ungrab_timeout = -1;
|
||||
static pointer_lock_waiting = false;
|
||||
// the browser let go of the mouse while the game wanted it, like escape or switching windows
|
||||
static #lost_pointer_lock = false;
|
||||
|
||||
static initialize(canvas: HTMLCanvasElement) {
|
||||
self.addEventListener("keydown", (e) => {
|
||||
@@ -164,6 +166,7 @@ export class InputManager {
|
||||
if (!grab) {
|
||||
if (this.pointer_lock_flag) {
|
||||
this.mouse_ungrab_timer = performance.now();
|
||||
this.#lost_pointer_lock = true;
|
||||
}
|
||||
}
|
||||
this.pointer_lock_flag = grab;
|
||||
@@ -266,6 +269,13 @@ export class InputManager {
|
||||
}
|
||||
}
|
||||
|
||||
// whether the mouse was taken away since the last call, browsers eat the escape that does it
|
||||
static take_lost_pointer_lock() {
|
||||
const lost = this.#lost_pointer_lock;
|
||||
this.#lost_pointer_lock = false;
|
||||
return lost;
|
||||
}
|
||||
|
||||
static is_mouse_grabbed() {
|
||||
return document.hasFocus() && document.pointerLockElement === canvas;
|
||||
}
|
||||
|
||||
+9
-136
@@ -1,138 +1,11 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
import { Container, Cursor } from "$/common/inventory.ts";
|
||||
|
||||
export class ItemStack<T = unknown | undefined> {
|
||||
type_id: string;
|
||||
amount: number;
|
||||
max_amount: number;
|
||||
data?: T;
|
||||
|
||||
constructor(type_id: string | string, amount: number = 1, max_amount: number = 64) {
|
||||
this.type_id = type_id;
|
||||
this.amount = amount;
|
||||
this.max_amount = max_amount;
|
||||
this.data = undefined;
|
||||
const item_info = EverythingRegistry.get<ItemRegistry>("items", type_id);
|
||||
if (item_info?.on_create) {
|
||||
item_info.on_create(this);
|
||||
}
|
||||
}
|
||||
|
||||
clone(): ItemStack {
|
||||
return new ItemStack(this.type_id, this.amount, this.max_amount);
|
||||
}
|
||||
}
|
||||
|
||||
export class ContainerSlot {
|
||||
#item_stack: ItemStack | undefined;
|
||||
|
||||
has_item() {
|
||||
return this.#item_stack !== undefined;
|
||||
}
|
||||
|
||||
set_item(item_stack: ItemStack | undefined) {
|
||||
if ((item_stack?.amount ?? 0) <= 0) {
|
||||
item_stack = undefined;
|
||||
}
|
||||
this.#item_stack = item_stack;
|
||||
}
|
||||
|
||||
get_item() {
|
||||
return this.#item_stack;
|
||||
}
|
||||
|
||||
get type_id() {
|
||||
return this.#item_stack?.type_id;
|
||||
}
|
||||
|
||||
set amount(new_amount: number) {
|
||||
if (this.#item_stack) {
|
||||
this.#item_stack.amount = new_amount;
|
||||
if (this.#item_stack.amount <= 0) {
|
||||
this.#item_stack = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get amount(): number | undefined {
|
||||
return this.#item_stack?.amount;
|
||||
}
|
||||
|
||||
get max_amount() {
|
||||
return this.#item_stack?.max_amount;
|
||||
}
|
||||
}
|
||||
|
||||
export class Container {
|
||||
#slots: ContainerSlot[] = [];
|
||||
readonly size: number;
|
||||
|
||||
constructor(size: number) {
|
||||
this.size = size;
|
||||
for (let i = 0; i < size; i += 1) {
|
||||
this.#slots.push(new ContainerSlot());
|
||||
}
|
||||
}
|
||||
|
||||
add_item(item_stack: ItemStack) {
|
||||
for (const slot of this.#slots.filter((slot) => slot.has_item())) {
|
||||
const slot_item = slot.get_item()!;
|
||||
if (slot_item.type_id === item_stack.type_id) {
|
||||
const missing = slot_item.max_amount - slot_item.amount;
|
||||
const adding = Math.min(missing, item_stack.amount);
|
||||
slot_item.amount += adding;
|
||||
item_stack.amount -= adding;
|
||||
if (item_stack.amount === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const slot of this.#slots) {
|
||||
if (!slot.has_item()) {
|
||||
slot.set_item(item_stack);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// TODO: drop item on ground
|
||||
console.error("Failed to place item in container");
|
||||
}
|
||||
|
||||
get_item(slot: number): ItemStack | undefined {
|
||||
return this.#slots[slot]?.get_item();
|
||||
}
|
||||
|
||||
get_slot(slot: number): ContainerSlot {
|
||||
return this.#slots[slot];
|
||||
}
|
||||
|
||||
set_item(slot: number, item: ItemStack | undefined) {
|
||||
this.#slots[slot].set_item(item);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ContainerLayout {
|
||||
slots: { type: string; x: number; y: number }[];
|
||||
offset_x: number;
|
||||
offset_y: number;
|
||||
}
|
||||
|
||||
export class Inventory {
|
||||
container: Container;
|
||||
|
||||
hovering_slot: number = -1;
|
||||
|
||||
constructor(container: Container) {
|
||||
this.container = container;
|
||||
}
|
||||
}
|
||||
|
||||
export class PlayerInventory extends Inventory {
|
||||
owner_id: string;
|
||||
holding_item: ItemStack | undefined;
|
||||
hotbar_selected: number = 0;
|
||||
|
||||
constructor(owner_id: string) {
|
||||
const container = new Container(9 * 4);
|
||||
super(container);
|
||||
this.owner_id = owner_id;
|
||||
}
|
||||
// the local copies of the containers the server syncs to this player
|
||||
export class ClientInventories {
|
||||
inventory = new Container(9 * 4);
|
||||
crafting = new Container(10);
|
||||
// the open server screen's container
|
||||
screen: Container | undefined;
|
||||
cursor: Cursor = { item: undefined };
|
||||
hotbar_selected = 0;
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:axe", {
|
||||
texture_id: "bworld:axe",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:coal", {
|
||||
texture_id: "bworld:coal",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:copper_ingot", {
|
||||
texture_id: "bworld:copper_ingot",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:gold_ingot", {
|
||||
texture_id: "bworld:gold_ingot",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:hoe", {
|
||||
texture_id: "bworld:hoe",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:iron_ingot", {
|
||||
texture_id: "bworld:iron_ingot",
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import "./watering_can.ts";
|
||||
import "./axe.ts";
|
||||
import "./pickaxe.ts";
|
||||
import "./hoe.ts";
|
||||
import "./coal.ts";
|
||||
import "./tin_ingot.ts";
|
||||
import "./iron_ingot.ts";
|
||||
import "./copper_ingot.ts";
|
||||
import "./gold_ingot.ts";
|
||||
import "./stick.ts";
|
||||
import "./wood_pickaxe.ts";
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:pickaxe", {
|
||||
texture_id: "bworld:pickaxe",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:stick", {
|
||||
texture_id: "bworld:stick",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:tin_ingot", {
|
||||
texture_id: "bworld:tin_ingot",
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
export interface WateringCanData {
|
||||
water: number;
|
||||
max_water: number;
|
||||
}
|
||||
|
||||
EverythingRegistry.register<ItemRegistry<WateringCanData>>("items", "bworld:watering_can", {
|
||||
texture_id: "bworld:watering_can",
|
||||
on_create(item) {
|
||||
item.data = { water: 0, max_water: 32 };
|
||||
},
|
||||
get_lore(item) {
|
||||
return `Water: ${item.data?.water}/${item.data?.max_water}`;
|
||||
},
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:wood_pickaxe", {
|
||||
texture_id: "bworld:wood_pickaxe",
|
||||
});
|
||||
@@ -0,0 +1,718 @@
|
||||
import { block_value, chunk_key, default_block_value } from "$/common/utils.ts";
|
||||
import { AIR_ID, BlockChange } from "$/common/protocol.ts";
|
||||
import {
|
||||
block_light_emission,
|
||||
block_light_opacity,
|
||||
BlockRegistry,
|
||||
EverythingRegistry,
|
||||
RENDER_LAYERS,
|
||||
RenderLayer,
|
||||
} from "$/common/everything_registry.ts";
|
||||
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from "../../common/constants.ts";
|
||||
import { AssetManager } from "../assets.ts";
|
||||
import { ChunkWorkerPool } from "../chunk_workers.ts";
|
||||
import { worldgen_mods } from "../mods.ts";
|
||||
import { type FromChunkWorker, TERRAIN_VERTEX_FLOATS } from "../workers/chunk_messages.ts";
|
||||
import { create_index_buffer, create_vertex_buffer, destroy_buffer, Texture } from "../renderer/mod.ts";
|
||||
import { crosses_planes } from "../workers/translucent_sort.ts";
|
||||
import { Camera } from "../camera.ts";
|
||||
import type { Entity } from "../entity/entity.ts";
|
||||
|
||||
export interface Block {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export { CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE };
|
||||
|
||||
export interface Chunk {
|
||||
x: number;
|
||||
z: number;
|
||||
blocks: Uint32Array;
|
||||
generated: boolean;
|
||||
dirty: boolean;
|
||||
// bumped on every mesh request so late results from older requests get ignored
|
||||
mesh_version: number;
|
||||
meshes: Partial<Record<RenderLayer, ChunkMesh>>;
|
||||
// only for translucent meshes that have to be sorted again as the camera moves
|
||||
translucent_sort?: TranslucentSort;
|
||||
// blocks its generation put in neighboring chunks (leaves), as x, y, z, numeric id. kept so a neighbor that
|
||||
// generates later, or unloads and comes back, still gets them
|
||||
spills?: Int32Array;
|
||||
}
|
||||
|
||||
export interface ChunkMesh {
|
||||
vertex_buffer: GPUBuffer;
|
||||
quad_count: number;
|
||||
// the translucent layer's quads sorted back to front, the others are drawn in order
|
||||
index_buffer?: GPUBuffer;
|
||||
}
|
||||
|
||||
interface TranslucentSort {
|
||||
// the mesh_version of the mesh these are for
|
||||
mesh_version: number;
|
||||
centers: Float32Array;
|
||||
planes: [Float32Array, Float32Array, Float32Array];
|
||||
// where the camera was for the last sort that was requested
|
||||
camera: number[];
|
||||
// sorts come back out of order, older ones than what's shown get skipped
|
||||
requested: number;
|
||||
applied: number;
|
||||
}
|
||||
|
||||
const FLOATS_PER_QUAD = 4 * TERRAIN_VERTEX_FLOATS;
|
||||
|
||||
export { chunk_key };
|
||||
|
||||
// the block being looked at and which face of it
|
||||
export interface BlockHitResult {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
block: number;
|
||||
face: Faces;
|
||||
}
|
||||
|
||||
const NEIGHBOR_OFFSETS = [[-1, 0], [1, 0], [0, -1], [0, 1]] as const;
|
||||
// light spreads diagonally too, so meshing and lighting need all 8
|
||||
const ALL_NEIGHBOR_OFFSETS = [...NEIGHBOR_OFFSETS, [-1, -1], [1, -1], [-1, 1], [1, 1]] as const;
|
||||
|
||||
// what minecraft calls the ClientLevel: this client's copy of the world, its chunks and the entities in it
|
||||
export class ClientLevel {
|
||||
image: Texture = AssetManager.instance.get("bworld:textures");
|
||||
chunks = new Map<number, Chunk>();
|
||||
second_timer = 0;
|
||||
tick_timer = 0;
|
||||
seed: string;
|
||||
|
||||
// blocks players changed from the generated terrain, per chunk, so they survive reloading chunks
|
||||
changes = new Map<string, Map<string, BlockChange>>();
|
||||
|
||||
workers: ChunkWorkerPool;
|
||||
#blocks = EverythingRegistry.get_registry<BlockRegistry>("blocks");
|
||||
// chunks being generated by a worker, by chunk key
|
||||
pending_generation = new Map<number, { x: number; z: number }>();
|
||||
|
||||
// every entity this client knows about, the local player included, by id
|
||||
entities = new Map<string, Entity>();
|
||||
|
||||
constructor(seed = "seed") {
|
||||
this.seed = seed;
|
||||
|
||||
this.workers = new ChunkWorkerPool((message) => this.#on_worker_message(message));
|
||||
|
||||
const blocks_registry = EverythingRegistry.get_registry<BlockRegistry>("blocks");
|
||||
const block_ids: Record<string, number> = {};
|
||||
blocks_registry.forEach((block, nid) => block_ids[block.id] = nid);
|
||||
// sent once instead of with every mesh request
|
||||
this.workers.broadcast({
|
||||
type: "init",
|
||||
blocks_registry: strip_functions(blocks_registry),
|
||||
block_ids,
|
||||
textures_info: AssetManager.instance.get("bworld:textures_info"),
|
||||
image: { width: this.image.width, height: this.image.height },
|
||||
worldgen_scripts: worldgen_mods.scripts,
|
||||
ores: worldgen_mods.ores,
|
||||
});
|
||||
}
|
||||
|
||||
add_entity(entity: Entity) {
|
||||
this.entities.set(entity.id, entity);
|
||||
}
|
||||
|
||||
remove_entity(id: string) {
|
||||
this.entities.delete(id);
|
||||
}
|
||||
|
||||
tick() {
|
||||
for (const entity of this.entities.values()) {
|
||||
entity.save_previous_position();
|
||||
entity.tick();
|
||||
}
|
||||
}
|
||||
|
||||
// generates the chunks around a position and forgets the ones too far away. one extra ring past the render
|
||||
// distance gets generated so the edge has neighbors to mesh against
|
||||
update_loaded_chunks(x: number, z: number, render_distance: number) {
|
||||
const center_x = Math.floor(x / CHUNK_SIZE);
|
||||
const center_z = Math.floor(z / CHUNK_SIZE);
|
||||
const load_distance = render_distance + 1;
|
||||
const out_of_range = (cx: number, cz: number) =>
|
||||
Math.max(Math.abs(cx - center_x), Math.abs(cz - center_z)) > load_distance;
|
||||
|
||||
// collect first, deleting from the map while iterating it skips entries
|
||||
const to_unload = [...this.chunks.values()].filter((chunk) => out_of_range(chunk.x, chunk.z));
|
||||
for (const chunk of to_unload) {
|
||||
this.unload_chunk(chunk.x, chunk.z);
|
||||
}
|
||||
for (const pending of [...this.pending_generation.values()]) {
|
||||
if (out_of_range(pending.x, pending.z)) {
|
||||
this.cancel_chunk_request(pending.x, pending.z);
|
||||
}
|
||||
}
|
||||
|
||||
// dont queue up the whole area at once, so walking somewhere new gets the close chunks first
|
||||
const max_in_flight = this.workers.size * 2;
|
||||
if (this.pending_generation.size >= max_in_flight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const missing: { x: number; z: number; distance: number }[] = [];
|
||||
for (let cx = center_x - load_distance; cx <= center_x + load_distance; cx += 1) {
|
||||
for (let cz = center_z - load_distance; cz <= center_z + load_distance; cz += 1) {
|
||||
if (!this.is_generated(cx, cz) && !this.is_generating(cx, cz)) {
|
||||
const dx = cx - center_x;
|
||||
const dz = cz - center_z;
|
||||
missing.push({ x: cx, z: cz, distance: dx * dx + dz * dz });
|
||||
}
|
||||
}
|
||||
}
|
||||
missing.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
for (const chunk of missing.slice(0, max_in_flight - this.pending_generation.size)) {
|
||||
this.request_chunk(chunk.x, chunk.z);
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.workers.terminate();
|
||||
for (const chunk of this.chunks.values()) {
|
||||
this.delete_chunk_mesh(chunk);
|
||||
}
|
||||
this.chunks.clear();
|
||||
this.pending_generation.clear();
|
||||
}
|
||||
|
||||
add_chunk(x: number, z: number, blocks: Uint32Array = new Uint32Array(CHUNK_AREA * CHUNK_HEIGHT)) {
|
||||
const chunk: Chunk = {
|
||||
x,
|
||||
z,
|
||||
blocks,
|
||||
dirty: true,
|
||||
generated: false,
|
||||
mesh_version: 0,
|
||||
meshes: {},
|
||||
};
|
||||
this.chunks.set(chunk_key(x, z), chunk);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
get_chunk(x: number, z: number) {
|
||||
return this.chunks.get(chunk_key(x, z));
|
||||
}
|
||||
|
||||
// state is the block's state bits, its defaults when not given
|
||||
add_block(block: Block, state?: number) {
|
||||
const block_chunk_x = Math.floor(block.x / CHUNK_SIZE);
|
||||
const block_chunk_z = Math.floor(block.z / CHUNK_SIZE);
|
||||
let chunk = this.get_chunk(block_chunk_x, block_chunk_z);
|
||||
if (!chunk) {
|
||||
chunk = this.add_chunk(block_chunk_x, block_chunk_z);
|
||||
}
|
||||
|
||||
const [nid, info] = EverythingRegistry.get_full<BlockRegistry>("blocks", block.id)!;
|
||||
|
||||
const lx = block.x - block_chunk_x * CHUNK_SIZE;
|
||||
const lz = block.z - block_chunk_z * CHUNK_SIZE;
|
||||
const ly = block.y;
|
||||
|
||||
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
|
||||
|
||||
const old_nid = chunk.blocks[index] & ID_MASK;
|
||||
chunk.blocks[index] = state === undefined ? default_block_value(nid, info) : block_value(nid, state);
|
||||
chunk.dirty = true;
|
||||
this.#mark_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz, old_nid, nid);
|
||||
}
|
||||
|
||||
// the id with its state bits, VOID outside loaded chunks
|
||||
get_block_value(x: number, y: number, z: number) {
|
||||
const chunk_x = Math.floor(x / CHUNK_SIZE);
|
||||
const chunk_z = Math.floor(z / CHUNK_SIZE);
|
||||
const chunk = this.get_chunk(chunk_x, chunk_z);
|
||||
if (!chunk) {
|
||||
return VOID;
|
||||
}
|
||||
return chunk.blocks[y * CHUNK_AREA + (z - chunk_z * CHUNK_SIZE) * CHUNK_SIZE + (x - chunk_x * CHUNK_SIZE)] ??
|
||||
AIR;
|
||||
}
|
||||
|
||||
get_block(x: number, y: number, z: number) {
|
||||
const chunk_x = Math.floor(x / CHUNK_SIZE);
|
||||
const chunk_z = Math.floor(z / CHUNK_SIZE);
|
||||
|
||||
const chunk = this.get_chunk(chunk_x, chunk_z);
|
||||
|
||||
if (!chunk) {
|
||||
return VOID;
|
||||
}
|
||||
|
||||
const lx = x - chunk_x * CHUNK_SIZE;
|
||||
const lz = z - chunk_z * CHUNK_SIZE;
|
||||
const ly = y;
|
||||
|
||||
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
|
||||
|
||||
return chunk.blocks[index] & ID_MASK;
|
||||
}
|
||||
|
||||
// whether entities bump into the block there. unloaded chunks count as solid, so nothing falls out of the world
|
||||
has_collision(x: number, y: number, z: number) {
|
||||
const block = this.get_block(x, y, z);
|
||||
return block === VOID || (block !== AIR && (this.#blocks[block]?.has_collision ?? true));
|
||||
}
|
||||
|
||||
// only changes what this client shows, drops and everything else happen on the server
|
||||
break_block(x: number, y: number, z: number) {
|
||||
const block_chunk_x = Math.floor(x / CHUNK_SIZE);
|
||||
const block_chunk_z = Math.floor(z / CHUNK_SIZE);
|
||||
const chunk = this.get_chunk(block_chunk_x, block_chunk_z);
|
||||
if (!chunk) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lx = x - block_chunk_x * CHUNK_SIZE;
|
||||
const lz = z - block_chunk_z * CHUNK_SIZE;
|
||||
const ly = y;
|
||||
|
||||
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
|
||||
const old_nid = chunk.blocks[index] & ID_MASK;
|
||||
chunk.blocks[index] = AIR;
|
||||
chunk.dirty = true;
|
||||
this.#mark_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz, old_nid, AIR);
|
||||
}
|
||||
|
||||
// a block that lets through or gives off a different amount of light changes the light up to 15 blocks
|
||||
// away, so in every neighbor. otherwise only a block on a chunk's edge matters, for its neighbor's faces
|
||||
#mark_neighbors_dirty(
|
||||
block_chunk_x: number,
|
||||
block_chunk_z: number,
|
||||
lx: number,
|
||||
lz: number,
|
||||
old_nid: number,
|
||||
new_nid: number,
|
||||
) {
|
||||
const old_block = this.#blocks[old_nid];
|
||||
const new_block = this.#blocks[new_nid];
|
||||
if (
|
||||
block_light_opacity(old_block) !== block_light_opacity(new_block) ||
|
||||
block_light_emission(old_block) !== block_light_emission(new_block)
|
||||
) {
|
||||
for (const [dx, dz] of ALL_NEIGHBOR_OFFSETS) {
|
||||
const n = this.get_chunk(block_chunk_x + dx, block_chunk_z + dz);
|
||||
if (n) {
|
||||
n.dirty = true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (lx === 0) {
|
||||
const n = this.get_chunk(block_chunk_x - 1, block_chunk_z);
|
||||
if (n) {
|
||||
n.dirty = true;
|
||||
}
|
||||
} else if (lx === CHUNK_SIZE - 1) {
|
||||
const n = this.get_chunk(block_chunk_x + 1, block_chunk_z);
|
||||
if (n) {
|
||||
n.dirty = true;
|
||||
}
|
||||
}
|
||||
if (lz === 0) {
|
||||
const n = this.get_chunk(block_chunk_x, block_chunk_z - 1);
|
||||
if (n) {
|
||||
n.dirty = true;
|
||||
}
|
||||
} else if (lz === CHUNK_SIZE - 1) {
|
||||
const n = this.get_chunk(block_chunk_x, block_chunk_z + 1);
|
||||
if (n) {
|
||||
n.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
index_to_xyz(index: number) {
|
||||
const y = Math.floor(index / CHUNK_AREA);
|
||||
const rem = index % CHUNK_AREA;
|
||||
|
||||
const z = Math.floor(rem / CHUNK_SIZE);
|
||||
const x = rem % CHUNK_SIZE;
|
||||
|
||||
return [x, y, z];
|
||||
}
|
||||
|
||||
record_change(x: number, y: number, z: number, id: string, state = 0) {
|
||||
const chunk_key = `${Math.floor(x / CHUNK_SIZE)},${Math.floor(z / CHUNK_SIZE)}`;
|
||||
let chunk_changes = this.changes.get(chunk_key);
|
||||
if (!chunk_changes) {
|
||||
chunk_changes = new Map();
|
||||
this.changes.set(chunk_key, chunk_changes);
|
||||
}
|
||||
chunk_changes.set(`${x},${y},${z}`, [x, y, z, id, state]);
|
||||
}
|
||||
|
||||
// set a block the server told us about
|
||||
apply_change(x: number, y: number, z: number, id: string, state = 0) {
|
||||
const chunk = this.get_chunk(Math.floor(x / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE));
|
||||
if (!chunk || !chunk.generated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = this.get_block(x, y, z);
|
||||
if (id === AIR_ID) {
|
||||
if (current !== AIR) {
|
||||
this.break_block(x, y, z);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nid = EverythingRegistry.get_id("blocks", id);
|
||||
if (nid === undefined || block_value(nid, state) === this.get_block_value(x, y, z)) {
|
||||
return;
|
||||
}
|
||||
this.add_block({ x, y, z, id }, state);
|
||||
}
|
||||
|
||||
apply_chunk_changes(cx: number, cz: number) {
|
||||
for (const [x, y, z, id, state] of this.changes.get(`${cx},${cz}`)?.values() ?? []) {
|
||||
this.apply_change(x, y, z, id, state);
|
||||
}
|
||||
}
|
||||
|
||||
is_generated(cx: number, cz: number) {
|
||||
return this.get_chunk(cx, cz)?.generated ?? false;
|
||||
}
|
||||
|
||||
is_generating(cx: number, cz: number) {
|
||||
return this.pending_generation.has(chunk_key(cx, cz));
|
||||
}
|
||||
|
||||
// generation happens in a worker, the chunk shows up in #on_generated
|
||||
request_chunk(cx: number, cz: number) {
|
||||
const key = chunk_key(cx, cz);
|
||||
if (this.pending_generation.has(key) || this.chunks.get(key)?.generated) {
|
||||
return;
|
||||
}
|
||||
this.pending_generation.set(key, { x: cx, z: cz });
|
||||
this.workers.post({ type: "generate", chunk_x: cx, chunk_z: cz, seed: this.seed });
|
||||
}
|
||||
|
||||
cancel_chunk_request(cx: number, cz: number) {
|
||||
this.pending_generation.delete(chunk_key(cx, cz));
|
||||
}
|
||||
|
||||
unload_chunk(cx: number, cz: number) {
|
||||
const key = chunk_key(cx, cz);
|
||||
const chunk = this.chunks.get(key);
|
||||
if (!chunk) {
|
||||
return;
|
||||
}
|
||||
this.delete_chunk_mesh(chunk);
|
||||
this.chunks.delete(key);
|
||||
}
|
||||
|
||||
// a chunk is only meshed once all its neighbors exist, otherwise its border faces and light would be
|
||||
// wrong and it would have to be meshed again as each neighbor loads
|
||||
can_mesh(chunk: Chunk) {
|
||||
if (!chunk.generated) {
|
||||
return false;
|
||||
}
|
||||
for (const [dx, dz] of ALL_NEIGHBOR_OFFSETS) {
|
||||
if (!this.is_generated(chunk.x + dx, chunk.z + dz)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// sends every dirty chunk that can be meshed to the workers
|
||||
request_meshes(camera: Camera) {
|
||||
for (const chunk of this.chunks.values()) {
|
||||
if (!chunk.dirty || !this.can_mesh(chunk)) {
|
||||
continue;
|
||||
}
|
||||
chunk.dirty = false;
|
||||
chunk.mesh_version += 1;
|
||||
// copies, the worker lights the whole 3x3 area
|
||||
const chunks: (Uint32Array | null)[] = [];
|
||||
for (let dz = -1; dz <= 1; dz++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
chunks.push(this.get_chunk(chunk.x + dx, chunk.z + dz)?.blocks.slice() ?? null);
|
||||
}
|
||||
}
|
||||
this.workers.post({
|
||||
type: "mesh",
|
||||
chunk_x: chunk.x,
|
||||
chunk_z: chunk.z,
|
||||
version: chunk.mesh_version,
|
||||
chunks,
|
||||
camera: [camera.x, camera.y, camera.z],
|
||||
}, chunks.filter((blocks) => blocks !== null).map((blocks) => blocks.buffer));
|
||||
}
|
||||
}
|
||||
|
||||
// like sodium, a chunk's translucent quads only change order when the camera crosses one of the
|
||||
// planes they lie on, so that's the only time they get sorted again
|
||||
update_translucent_sorting(camera: Camera) {
|
||||
const position = [camera.x, camera.y, camera.z];
|
||||
for (const chunk of this.chunks.values()) {
|
||||
const sort = chunk.translucent_sort;
|
||||
if (!sort || !crosses_planes(sort.planes, sort.camera, position)) {
|
||||
continue;
|
||||
}
|
||||
sort.camera = position;
|
||||
sort.requested += 1;
|
||||
this.workers.post({
|
||||
type: "sort",
|
||||
chunk_x: chunk.x,
|
||||
chunk_z: chunk.z,
|
||||
version: sort.mesh_version,
|
||||
sort_version: sort.requested,
|
||||
centers: sort.centers,
|
||||
camera: position,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#on_worker_message(message: FromChunkWorker) {
|
||||
if (message.type === "generated") {
|
||||
this.#on_generated(message.chunk_x, message.chunk_z, message.blocks, message.spills);
|
||||
} else if (message.type === "meshed") {
|
||||
this.#on_meshed(message);
|
||||
} else {
|
||||
this.#on_sorted(message);
|
||||
}
|
||||
}
|
||||
|
||||
#on_generated(cx: number, cz: number, blocks: Uint32Array, spills: Int32Array) {
|
||||
const key = chunk_key(cx, cz);
|
||||
// unloaded or cancelled while the worker was busy
|
||||
if (!this.pending_generation.delete(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let chunk = this.chunks.get(key);
|
||||
if (chunk) {
|
||||
// blocks placed here before it generated, keep them where generation left air
|
||||
const existing = chunk.blocks;
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
if (blocks[i] !== AIR) {
|
||||
existing[i] = blocks[i];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
chunk = this.add_chunk(cx, cz, blocks);
|
||||
}
|
||||
chunk.generated = true;
|
||||
chunk.dirty = true;
|
||||
chunk.spills = spills;
|
||||
|
||||
// the same rules as the server (server/game/world.ts): a chunk's own blocks, then what its neighbors'
|
||||
// features put in it, only where it has air. both ways, since the neighbors may have generated first
|
||||
for (const [dx, dz] of ALL_NEIGHBOR_OFFSETS) {
|
||||
const neighbor_spills = this.get_chunk(cx + dx, cz + dz)?.spills;
|
||||
if (neighbor_spills) {
|
||||
this.#apply_spills(neighbor_spills, chunk);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < spills.length; i += 4) {
|
||||
this.#set_block_raw(spills[i], spills[i + 1], spills[i + 2], spills[i + 3]);
|
||||
}
|
||||
|
||||
for (const [dx, dz] of ALL_NEIGHBOR_OFFSETS) {
|
||||
const neighbor = this.get_chunk(cx + dx, cz + dz);
|
||||
if (neighbor && neighbor.generated) {
|
||||
neighbor.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
// features spill into neighboring chunks, so their changes need reapplying too
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
for (let dz = -1; dz <= 1; dz++) {
|
||||
this.apply_chunk_changes(cx + dx, cz + dz);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#on_meshed(message: Extract<FromChunkWorker, { type: "meshed" }>) {
|
||||
const chunk = this.get_chunk(message.chunk_x, message.chunk_z);
|
||||
// unloaded, or remeshed again since this was requested
|
||||
if (!chunk || chunk.mesh_version !== message.version) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.delete_chunk_mesh(chunk);
|
||||
|
||||
for (const layer of RENDER_LAYERS) {
|
||||
const { vertices, quad_count } = message[layer];
|
||||
if (quad_count === 0) {
|
||||
continue;
|
||||
}
|
||||
chunk.meshes[layer] = {
|
||||
vertex_buffer: create_vertex_buffer(vertices.subarray(0, quad_count * FLOATS_PER_QUAD)),
|
||||
quad_count,
|
||||
};
|
||||
}
|
||||
|
||||
const translucent = message.translucent;
|
||||
if (translucent.quad_count === 0) {
|
||||
return;
|
||||
}
|
||||
chunk.meshes.translucent!.index_buffer = create_index_buffer(translucent.indices);
|
||||
if (translucent.sort_type === "dynamic") {
|
||||
chunk.translucent_sort = {
|
||||
mesh_version: message.version,
|
||||
centers: translucent.centers,
|
||||
planes: translucent.planes,
|
||||
camera: message.camera,
|
||||
requested: 0,
|
||||
applied: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#on_sorted(message: Extract<FromChunkWorker, { type: "sorted" }>) {
|
||||
const chunk = this.get_chunk(message.chunk_x, message.chunk_z);
|
||||
const sort = chunk?.translucent_sort;
|
||||
const mesh = chunk?.meshes.translucent;
|
||||
// remeshed since, or a newer sort already came back
|
||||
if (!sort || !mesh || sort.mesh_version !== message.version || message.sort_version <= sort.applied) {
|
||||
return;
|
||||
}
|
||||
sort.applied = message.sort_version;
|
||||
if (mesh.index_buffer) {
|
||||
destroy_buffer(mesh.index_buffer);
|
||||
}
|
||||
mesh.index_buffer = create_index_buffer(message.indices);
|
||||
}
|
||||
|
||||
// blocks a neighbor's features put here, only fill air so the result doesn't depend on which chunk loaded first.
|
||||
// the server builds chunks the same way (server/game/world.ts)
|
||||
#set_block_raw(x: number, y: number, z: number, nid: number) {
|
||||
if (y < 0 || y >= CHUNK_HEIGHT) {
|
||||
return;
|
||||
}
|
||||
const chunk_x = Math.floor(x / CHUNK_SIZE);
|
||||
const chunk_z = Math.floor(z / CHUNK_SIZE);
|
||||
const chunk = this.get_chunk(chunk_x, chunk_z);
|
||||
// a chunk that isn't generated yet takes it from our spills when it is
|
||||
if (!chunk?.generated) {
|
||||
return;
|
||||
}
|
||||
const lx = x - chunk_x * CHUNK_SIZE;
|
||||
const lz = z - chunk_z * CHUNK_SIZE;
|
||||
const index = y * CHUNK_AREA + lz * CHUNK_SIZE + lx;
|
||||
if (chunk.blocks[index] === AIR) {
|
||||
chunk.blocks[index] = nid;
|
||||
chunk.dirty = true;
|
||||
this.#mark_neighbors_dirty(chunk_x, chunk_z, lx, lz, AIR, nid & ID_MASK);
|
||||
}
|
||||
}
|
||||
|
||||
// the spills that land in chunk
|
||||
#apply_spills(spills: Int32Array, chunk: Chunk) {
|
||||
for (let i = 0; i < spills.length; i += 4) {
|
||||
if (Math.floor(spills[i] / CHUNK_SIZE) === chunk.x && Math.floor(spills[i + 2] / CHUNK_SIZE) === chunk.z) {
|
||||
this.#set_block_raw(spills[i], spills[i + 1], spills[i + 2], spills[i + 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete_chunk_mesh(chunk: Chunk) {
|
||||
for (const mesh of Object.values(chunk.meshes)) {
|
||||
destroy_buffer(mesh.vertex_buffer);
|
||||
if (mesh.index_buffer) {
|
||||
destroy_buffer(mesh.index_buffer);
|
||||
}
|
||||
}
|
||||
chunk.meshes = {};
|
||||
chunk.translucent_sort = undefined;
|
||||
}
|
||||
|
||||
// the first block along the view of something at x/y/z looking at yaw/pitch, like minecraft's pick
|
||||
pick(
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
yaw: number,
|
||||
pitch: number,
|
||||
max_distance = 6,
|
||||
step = 0.05,
|
||||
): BlockHitResult | undefined {
|
||||
const cos_pitch = Math.cos(pitch);
|
||||
const dx = -Math.sin(yaw) * cos_pitch;
|
||||
const dy = Math.sin(pitch);
|
||||
const dz = -Math.cos(yaw) * cos_pitch;
|
||||
|
||||
let prev_bx = Math.floor(x);
|
||||
let prev_by = Math.floor(y);
|
||||
let prev_bz = Math.floor(z);
|
||||
|
||||
let dist = 0;
|
||||
|
||||
while (dist <= max_distance) {
|
||||
x += dx * step;
|
||||
y += dy * step;
|
||||
z += dz * step;
|
||||
dist += step;
|
||||
|
||||
const bx = Math.floor(x);
|
||||
const by = Math.floor(y);
|
||||
const bz = Math.floor(z);
|
||||
|
||||
if (bx === prev_bx && by === prev_by && bz === prev_bz) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const block = this.get_block(bx, by, bz);
|
||||
|
||||
if (block && block !== AIR && block !== VOID) {
|
||||
let face: Faces;
|
||||
|
||||
if (bx > prev_bx) {
|
||||
face = "west";
|
||||
} else if (bx < prev_bx) {
|
||||
face = "east";
|
||||
} else if (by > prev_by) {
|
||||
face = "bottom";
|
||||
} else if (by < prev_by) {
|
||||
face = "top";
|
||||
} else if (bz > prev_bz) {
|
||||
face = "north";
|
||||
} else {
|
||||
face = "south";
|
||||
}
|
||||
|
||||
return { x: bx, y: by, z: bz, face, block };
|
||||
}
|
||||
|
||||
prev_bx = bx;
|
||||
prev_by = by;
|
||||
prev_bz = bz;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// functions cant be sent to workers
|
||||
// deno-lint-ignore no-explicit-any
|
||||
function strip_functions<T extends Record<string, any>>(obj: T): T {
|
||||
// deno-lint-ignore no-explicit-any
|
||||
const out: any = Array.isArray(obj) ? [] : {};
|
||||
|
||||
for (const k in obj) {
|
||||
const v = obj[k];
|
||||
|
||||
if (typeof v === "function") continue;
|
||||
|
||||
if (v && typeof v === "object") {
|
||||
out[k] = strip_functions(v);
|
||||
} else {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
+123
-24
@@ -1,23 +1,36 @@
|
||||
import { AssetManager } from "./assets.ts";
|
||||
import { ClientWorld } from "./client_world.ts";
|
||||
import { Client } from "./client.ts";
|
||||
import { InputManager } from "./input_manager.ts";
|
||||
import { begin_drawing, clear_background, end_drawing, init_font, init_window } from "./renderer/mod.ts";
|
||||
|
||||
await import("./blocks/mod.ts");
|
||||
await import("./items/mod.ts");
|
||||
import { Connection } from "./network.ts";
|
||||
import { connect, HandshakeError, is_trusted, join, load_atlas, remember_trust, ServerAddress } from "./handshake.ts";
|
||||
import { confirm_mods } from "./confirm_mods.ts";
|
||||
import { ModLoadError } from "$/common/mod_loader.ts";
|
||||
import {
|
||||
begin_drawing,
|
||||
canvas,
|
||||
clear_background,
|
||||
end_drawing,
|
||||
init_font,
|
||||
init_window,
|
||||
load_texture,
|
||||
resize_canvas,
|
||||
} from "./renderer/mod.ts";
|
||||
import { is_stopped, show_fatal_error } from "./fatal.ts";
|
||||
import { load_client_mods, set_mods_client } from "./mods.ts";
|
||||
import type { GuiScreen } from "./gui/gui_screen.ts";
|
||||
import { TitleScreen } from "./gui/title_screen.ts";
|
||||
import { DisconnectedScreen } from "./gui/disconnected_screen.ts";
|
||||
|
||||
// runs whatever is showing every frame: a menu screen before joining (and after leaving), or the game
|
||||
export class ClientLoop {
|
||||
running = false;
|
||||
last_time = 0;
|
||||
world: ClientWorld;
|
||||
client: Client | undefined;
|
||||
screen: GuiScreen | undefined;
|
||||
|
||||
frame_count = 0;
|
||||
last_fps_time = 0;
|
||||
|
||||
constructor(world: ClientWorld) {
|
||||
this.world = world;
|
||||
}
|
||||
|
||||
start() {
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) {
|
||||
@@ -37,8 +50,25 @@ export class ClientLoop {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
show_screen(screen: GuiScreen) {
|
||||
this.screen?.on_close();
|
||||
this.client = undefined;
|
||||
this.screen = screen;
|
||||
InputManager.set_mouse_grabbed(false);
|
||||
}
|
||||
|
||||
play(connection: Connection) {
|
||||
const client = new Client(connection, (message) => this.show_screen(new DisconnectedScreen(message)));
|
||||
set_mods_client(client);
|
||||
client.chat.add("Connected to the server");
|
||||
this.screen?.on_close();
|
||||
this.screen = undefined;
|
||||
this.client = client;
|
||||
console.log("Game started");
|
||||
}
|
||||
|
||||
loop(time: number) {
|
||||
if (!this.running) {
|
||||
if (!this.running || is_stopped()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,7 +76,12 @@ export class ClientLoop {
|
||||
|
||||
begin_drawing();
|
||||
clear_background(0.69, 0.8, 1, 1.0);
|
||||
this.world.update(delta);
|
||||
if (this.client) {
|
||||
this.client.run_frame(delta);
|
||||
} else if (this.screen) {
|
||||
this.screen.on_tick(delta);
|
||||
this.screen.on_render();
|
||||
}
|
||||
end_drawing();
|
||||
|
||||
this.frame_count += 1;
|
||||
@@ -65,20 +100,73 @@ export class ClientLoop {
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.getElementById("game") as HTMLCanvasElement;
|
||||
if (!canvas) {
|
||||
// a server's blocks, items and scripts can't be taken back out once loaded, so a failure after that point
|
||||
// can't go back to the title screen to try again, the page has to start over
|
||||
class FailedAfterLoadingMods extends Error {}
|
||||
|
||||
// the game only runs against a server, it owns the world and everything in it. see "Delivery to clients" in MODS.md
|
||||
async function join_server(address: ServerAddress, name: string, status: (text: string) => void): Promise<Connection> {
|
||||
const { socket, welcome } = await connect(address, name);
|
||||
console.log(`Connected to ${address.ws_url}`);
|
||||
|
||||
let loaded_mods = false;
|
||||
try {
|
||||
if (address.cross_origin && !is_trusted(address, welcome)) {
|
||||
status("Waiting for you to accept the server's mods...");
|
||||
if (!await confirm_mods(address, welcome)) {
|
||||
throw new HandshakeError(`You didn't join ${address.base.host}`);
|
||||
}
|
||||
remember_trust(address, welcome);
|
||||
}
|
||||
|
||||
// textures, blocks and items all come from the server's mods, so this happens before anything else
|
||||
status("Downloading the server's mods...");
|
||||
const atlas = await load_atlas(address, welcome.atlas);
|
||||
loaded_mods = true;
|
||||
AssetManager.instance.assets["bworld:textures"] = load_texture(atlas.image);
|
||||
AssetManager.instance.assets["bworld:textures_info"] = atlas.regions;
|
||||
await load_client_mods(welcome.mods, address.base);
|
||||
console.log(`Mods: ${welcome.mods.map((mod) => `${mod.id} ${mod.version}`).join(", ") || "none"}`);
|
||||
|
||||
status("Joining...");
|
||||
const joined = await join(socket);
|
||||
return new Connection(socket, welcome, joined);
|
||||
} catch (e) {
|
||||
socket.close();
|
||||
const message = error_message(e);
|
||||
throw loaded_mods ? new FailedAfterLoadingMods(message) : new HandshakeError(message);
|
||||
}
|
||||
}
|
||||
|
||||
function error_message(e: unknown) {
|
||||
if (e instanceof HandshakeError) {
|
||||
return e.message;
|
||||
}
|
||||
if (e instanceof ModLoadError) {
|
||||
return `Couldn't load the server's mods: ${e.message}`;
|
||||
}
|
||||
return `Something went wrong joining the server: ${e instanceof Error ? e.message : e}`;
|
||||
}
|
||||
|
||||
const game_canvas = document.getElementById("game") as HTMLCanvasElement;
|
||||
if (!game_canvas) {
|
||||
throw Error("Canvas was not found");
|
||||
}
|
||||
|
||||
init_window(canvas);
|
||||
try {
|
||||
await init_window(game_canvas);
|
||||
} catch (e) {
|
||||
show_fatal_error(e instanceof Error ? e.message : String(e));
|
||||
throw e;
|
||||
}
|
||||
|
||||
InputManager.initialize(canvas);
|
||||
InputManager.initialize(game_canvas);
|
||||
self.addEventListener("resize", resize_canvas);
|
||||
resize_canvas();
|
||||
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
|
||||
AssetManager.instance.load("bworld:assets_text", "/assets/ASSETS.md");
|
||||
|
||||
AssetManager.instance.load("bworld:textures", "/assets/sprites/textures.png");
|
||||
AssetManager.instance.load("bworld:textures_info", "/assets/sprites/textures.json");
|
||||
|
||||
AssetManager.instance.load("bworld:player", "/assets/sprites/player.png");
|
||||
AssetManager.instance.load("bworld:roguelike", "/assets/sprites/roguelike.png");
|
||||
AssetManager.instance.load("bworld:ui", "/assets/sprites/ui.png");
|
||||
@@ -90,9 +178,20 @@ await AssetManager.instance.load_all();
|
||||
|
||||
init_font();
|
||||
|
||||
const client_world = new ClientWorld();
|
||||
|
||||
const loop = new ClientLoop(client_world);
|
||||
const loop = new ClientLoop();
|
||||
loop.show_screen(
|
||||
new TitleScreen(async (address, name, status) => {
|
||||
let connection: Connection;
|
||||
try {
|
||||
connection = await join_server(address, name, status);
|
||||
} catch (e) {
|
||||
if (e instanceof FailedAfterLoadingMods) {
|
||||
loop.show_screen(new DisconnectedScreen(e.message));
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
loop.play(connection);
|
||||
}),
|
||||
);
|
||||
loop.start();
|
||||
|
||||
console.log("Game started");
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Entity } from "$/common/ecs/mod.ts";
|
||||
import { Position } from "$/common/components/position.ts";
|
||||
import { ClientWorld } from "./client_world.ts";
|
||||
import { UIButton } from "./components/ui_components.ts";
|
||||
import { open_about } from "./about.ts";
|
||||
import { start_game } from "./game.ts";
|
||||
import { canvas } from "./renderer/mod.ts";
|
||||
|
||||
export function create_main_menu(world: ClientWorld) {
|
||||
const play_button = new Entity("play");
|
||||
play_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 - 80));
|
||||
play_button.add(new UIButton("Play", 320, 64, () => start_game(world)));
|
||||
world.add_entity(play_button);
|
||||
|
||||
const about_button = new Entity("aboutbutton");
|
||||
about_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 + 80));
|
||||
about_button.add(new UIButton("About", 320, 64, () => open_about()));
|
||||
world.add_entity(about_button);
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
// loads the mods the server lists: registers their data, then runs their client scripts.
|
||||
// see "Delivery to clients" in MODS.md
|
||||
import { AIR } from "$/common/constants.ts";
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import type { ClientContext } from "$/common/mod_api/client.ts";
|
||||
import { ModData, ModListing, ModLoadError, register_mod_data } from "$/common/mod_loader.ts";
|
||||
import type { OreJson } from "$/common/mod_data.ts";
|
||||
import { AIR_ID } from "$/common/protocol.ts";
|
||||
import type { Client } from "./client.ts";
|
||||
import { code_url, fetch_verified } from "./handshake.ts";
|
||||
|
||||
// each loaded mod's credits file, for the credits screen
|
||||
export const mod_credits: { name: string; version: string; text: string }[] = [];
|
||||
|
||||
// what the chunk workers need to generate the same world as the server
|
||||
export const worldgen_mods: { scripts: { mod: string; url: string }[]; ores: OreJson[] } = { scripts: [], ores: [] };
|
||||
|
||||
// set once the game is running, mods can't look at it during setup
|
||||
let client: Client | undefined;
|
||||
|
||||
export function set_mods_client(game_client: Client) {
|
||||
client = game_client;
|
||||
}
|
||||
|
||||
// downloads every mod's files and checks them against the hashes the server listed, then registers the data and
|
||||
// runs the client scripts. everything a mod runs is imported from the checked bytes, never fetched twice
|
||||
export async function load_client_mods(listings: ModListing[], base: URL) {
|
||||
const downloads = await Promise.all(listings.map(async (listing) => {
|
||||
const get = async (path: string | undefined, sha256: string | undefined, what: string) => {
|
||||
if (!path) return undefined;
|
||||
try {
|
||||
return await fetch_verified(new URL(path, base), sha256 ?? "", what);
|
||||
} catch (e) {
|
||||
throw new ModLoadError(listing.id, (e as Error).message);
|
||||
}
|
||||
};
|
||||
const [data, client, worldgen, credits] = await Promise.all([
|
||||
get(listing.data, listing.sha256.data, "its data"),
|
||||
get(listing.client, listing.sha256.client, "its client script"),
|
||||
get(listing.worldgen, listing.sha256.worldgen, "its worldgen script"),
|
||||
get(listing.credits, listing.sha256.credits, "its credits"),
|
||||
]);
|
||||
return { listing, data: JSON.parse(new TextDecoder().decode(data)) as ModData, client, worldgen, credits };
|
||||
}));
|
||||
|
||||
for (const { listing, credits } of downloads) {
|
||||
if (credits) {
|
||||
mod_credits.push({ name: listing.name, version: listing.version, text: new TextDecoder().decode(credits) });
|
||||
}
|
||||
}
|
||||
|
||||
const recipes = register_mod_data(downloads.map(({ listing, data }) => ({ id: listing.id, data })));
|
||||
|
||||
worldgen_mods.ores = recipes.ores;
|
||||
worldgen_mods.scripts = downloads.flatMap(({ listing, worldgen }) =>
|
||||
worldgen ? [{ mod: listing.id, url: code_url(worldgen) }] : []
|
||||
);
|
||||
|
||||
for (const { listing, client } of downloads) {
|
||||
if (!client) continue;
|
||||
const module = await import(code_url(client));
|
||||
if (typeof module.setup !== "function") {
|
||||
throw new ModLoadError(listing.id, "the client script doesn't export a setup function");
|
||||
}
|
||||
await module.setup(client_context(listing));
|
||||
}
|
||||
}
|
||||
|
||||
function client_context(listing: ModListing): ClientContext {
|
||||
const mod = listing.id;
|
||||
const not_yet = (name: string, where: string) =>
|
||||
new Proxy({}, {
|
||||
get: (_, prop) => () => {
|
||||
throw new Error(`[${mod}] ctx.${name}.${String(prop)} isn't implemented yet (${where} in MODS.md)`);
|
||||
},
|
||||
});
|
||||
const need_client = () => {
|
||||
if (!client) throw new Error(`[${mod}] the world isn't there yet during setup`);
|
||||
return client;
|
||||
};
|
||||
|
||||
return {
|
||||
mod: { id: mod, version: listing.version },
|
||||
ui: not_yet("ui", "step 7") as ClientContext["ui"],
|
||||
hud: not_yet("hud", "step 7") as ClientContext["hud"],
|
||||
input: not_yet("input", "step 8") as ClientContext["input"],
|
||||
net: not_yet("net", "step 8") as ClientContext["net"],
|
||||
player: {
|
||||
get name() {
|
||||
return need_client().connection.name;
|
||||
},
|
||||
get position() {
|
||||
const { x, y, z } = need_client().player;
|
||||
return { x, y, z };
|
||||
},
|
||||
},
|
||||
world: {
|
||||
get_block(x, y, z) {
|
||||
const nid = need_client().level.get_block(x, y, z);
|
||||
if (nid === AIR) return AIR_ID;
|
||||
return EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid)?.id;
|
||||
},
|
||||
},
|
||||
log: (...args) => console.log(`[${mod}]`, ...args),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { BlockChange, ClientMessage, EntityInfo, PlayerInfo, ServerMessage } from "$/common/protocol.ts";
|
||||
import type { ModListing } from "$/common/mod_loader.ts";
|
||||
import type { Join, ServerSocket, Welcome } from "./handshake.ts";
|
||||
|
||||
export class Connection {
|
||||
#server: ServerSocket;
|
||||
id: string;
|
||||
name: string;
|
||||
seed: string;
|
||||
mods: ModListing[];
|
||||
initial_changes: BlockChange[];
|
||||
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
|
||||
selected_slot: number;
|
||||
// who was already there when we joined, they become entities in the level
|
||||
initial_players: PlayerInfo[];
|
||||
initial_entities: EntityInfo[];
|
||||
|
||||
constructor(server: ServerSocket, welcome: Welcome, join: Join) {
|
||||
this.#server = server;
|
||||
this.id = join.id;
|
||||
this.name = join.name;
|
||||
this.seed = welcome.seed;
|
||||
this.mods = welcome.mods;
|
||||
this.initial_changes = join.changes;
|
||||
this.spawn = join.spawn;
|
||||
this.selected_slot = join.selected_slot;
|
||||
this.initial_players = join.players;
|
||||
this.initial_entities = join.entities;
|
||||
}
|
||||
|
||||
// handled by the packet listener inside the game loop, not whenever the socket feels like it
|
||||
get incoming(): ServerMessage[] {
|
||||
return this.#server.messages;
|
||||
}
|
||||
|
||||
get closed() {
|
||||
return this.#server.closed;
|
||||
}
|
||||
|
||||
send(message: ClientMessage) {
|
||||
this.#server.send(message);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.#server.close();
|
||||
}
|
||||
}
|
||||
|
||||
// what the name field starts as, from ?name=
|
||||
export function default_player_name(): string {
|
||||
return new URLSearchParams(location.search).get("name") ?? "";
|
||||
}
|
||||
@@ -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,119 @@
|
||||
import type { ServerMessage } from "$/common/protocol.ts";
|
||||
import { Container, ItemStack } from "$/common/inventory.ts";
|
||||
import type { Client } from "./client.ts";
|
||||
import { GuiContainer } from "./gui/gui_container.ts";
|
||||
import { RemotePlayer } from "./entity/remote_player.ts";
|
||||
import { ItemEntity } from "./entity/item_entity.ts";
|
||||
|
||||
// applies what the server sends, like minecraft's ClientPacketListener. messages queue up on the connection and
|
||||
// get handled here once per frame, inside the game loop
|
||||
export class ClientPacketListener {
|
||||
client: Client;
|
||||
|
||||
constructor(client: Client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
handle_packets() {
|
||||
const connection = this.client.connection;
|
||||
for (const message of connection.incoming) {
|
||||
this.#handle(message);
|
||||
}
|
||||
connection.incoming.length = 0;
|
||||
}
|
||||
|
||||
#handle(message: ServerMessage) {
|
||||
const client = this.client;
|
||||
const level = client.level;
|
||||
const inventories = client.player.inventories;
|
||||
|
||||
switch (message.type) {
|
||||
case "player_join":
|
||||
level.add_entity(new RemotePlayer(level, message.player));
|
||||
break;
|
||||
case "player_leave":
|
||||
level.remove_entity(message.id);
|
||||
break;
|
||||
case "player_move": {
|
||||
const player = level.entities.get(message.id);
|
||||
if (player instanceof RemotePlayer) {
|
||||
player.lerp_to(message.x, message.y, message.z, message.yaw, message.pitch);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "add_entity":
|
||||
level.add_entity(new ItemEntity(level, message.entity));
|
||||
break;
|
||||
case "move_entity": {
|
||||
const entity = level.entities.get(message.id);
|
||||
if (entity instanceof ItemEntity) {
|
||||
entity.lerp_to(message.x, message.y, message.z);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "set_entity_item": {
|
||||
const entity = level.entities.get(message.id);
|
||||
if (entity instanceof ItemEntity) {
|
||||
entity.item = ItemStack.from_data(message.item);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "remove_entity":
|
||||
level.remove_entity(message.id);
|
||||
break;
|
||||
case "take_entity": {
|
||||
const entity = level.entities.get(message.id);
|
||||
const taker = level.entities.get(message.player);
|
||||
if (entity instanceof ItemEntity && taker) {
|
||||
entity.pick_up(taker);
|
||||
} else {
|
||||
level.remove_entity(message.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "set_block":
|
||||
level.record_change(message.x, message.y, message.z, message.id, message.state);
|
||||
level.apply_change(message.x, message.y, message.z, message.id, message.state);
|
||||
break;
|
||||
case "chat":
|
||||
client.chat.add(message.from ? `<${message.from}> ${message.text}` : message.text);
|
||||
break;
|
||||
case "container": {
|
||||
let container = message.container === "screen" ? inventories.screen : inventories[message.container];
|
||||
if (message.container === "screen" && container?.size !== message.items.length) {
|
||||
container = inventories.screen = new Container(message.items.length);
|
||||
}
|
||||
container?.load(message.items);
|
||||
break;
|
||||
}
|
||||
case "cursor":
|
||||
inventories.cursor.item = message.item ? ItemStack.from_data(message.item) : undefined;
|
||||
break;
|
||||
case "open_screen": {
|
||||
// replace anything open locally without telling the server, it just opened this one
|
||||
client.screens.length = 0;
|
||||
const size = Math.max(0, ...message.layout.slots.map((slot) => slot.index + 1));
|
||||
inventories.screen = new Container(size);
|
||||
client.push_screen(
|
||||
new GuiContainer(inventories, (m) => client.connection.send(m), message.layout, message.properties),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "screen_properties": {
|
||||
const screen = client.screen;
|
||||
if (screen instanceof GuiContainer) {
|
||||
screen.properties = message.properties;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "teleport":
|
||||
client.player.set_position(message.x, message.y, message.z);
|
||||
break;
|
||||
case "close_screen":
|
||||
// closed by the server (the block broke), it already put everything back
|
||||
client.screens = client.screens.filter((s) => !(s instanceof GuiContainer));
|
||||
inventories.screen = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Position } from "$/common/components/position.ts";
|
||||
import { Velocity } from "$/common/components/velocity.ts";
|
||||
import { Component, Entity } from "$/common/ecs/mod.ts";
|
||||
import { Camera } from "$/client/components/camera.ts";
|
||||
import { PlayerInventory } from "./inventory.ts";
|
||||
import { PlayerControls } from "$/client/components/player_controls.ts";
|
||||
import { ClientWorld } from "./client_world.ts";
|
||||
import { GuiScreen } from "./gui/gui_screen.ts";
|
||||
import { CollisionCuboid } from "./components/collision.ts";
|
||||
|
||||
export class PlayerComponent extends Component {
|
||||
player_inventory = new PlayerInventory("");
|
||||
screens: GuiScreen[] = [];
|
||||
render_distance = 6;
|
||||
|
||||
breaking_block?: { x: number; y: number; z: number };
|
||||
break_progress = 0;
|
||||
break_progress_max = 0;
|
||||
|
||||
pop_screen() {
|
||||
const screen = this.screens.pop();
|
||||
if (screen) {
|
||||
screen.on_close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function create_player(world: ClientWorld) {
|
||||
const player = new Entity("player");
|
||||
player.add(new Position(0, 100, 0));
|
||||
player.add(new Velocity(0, 0, 0));
|
||||
player.add(new PlayerControls());
|
||||
|
||||
player.add(new PlayerComponent());
|
||||
player.add(new Camera());
|
||||
player.add(new CollisionCuboid(0.55, 1.79, 0.55));
|
||||
|
||||
world.add_entity(player);
|
||||
|
||||
const player_hand = new Entity("playerhand");
|
||||
player_hand.add(new Position(0, 0));
|
||||
world.add_entity(player_hand);
|
||||
|
||||
world.add_tag("player", [player, player_hand]);
|
||||
|
||||
return player;
|
||||
}
|
||||
+609
-161
@@ -1,28 +1,53 @@
|
||||
import { Camera } from "../components/camera.ts";
|
||||
import type { Camera } from "../camera.ts";
|
||||
import { mat4 } from "gl-matrix";
|
||||
import type { RenderLayer } from "$/common/everything_registry.ts";
|
||||
import { TERRAIN_VERTEX_FLOATS } from "../workers/chunk_messages.ts";
|
||||
|
||||
export let gl: WebGLRenderingContext;
|
||||
export let device: GPUDevice;
|
||||
export let canvas: HTMLCanvasElement;
|
||||
|
||||
const MAX_SPRITES = 100000;
|
||||
const VERTS_PER_SPRITE = 6;
|
||||
const FLOATS_PER_VERT = 9;
|
||||
const VERTEX_STRIDE = FLOATS_PER_VERT * 4;
|
||||
|
||||
let program: WebGLProgram;
|
||||
let main_buffer: WebGLBuffer;
|
||||
const SAMPLE_COUNT = 4;
|
||||
const DEPTH_FORMAT: GPUTextureFormat = "depth24plus";
|
||||
|
||||
// dynamic uniform offsets have to be 256 aligned
|
||||
const UNIFORM_SLOT_SIZE = 256;
|
||||
|
||||
let context: GPUCanvasContext;
|
||||
let canvas_format: GPUTextureFormat;
|
||||
|
||||
let pipeline_2d: GPURenderPipeline;
|
||||
let pipeline_3d: GPURenderPipeline;
|
||||
let terrain_pipelines: Record<RenderLayer, GPURenderPipeline>;
|
||||
// 0 1 2 0 2 3 for every quad, shared by all solid and cutout chunk meshes
|
||||
let quad_index_buffer: GPUBuffer | undefined;
|
||||
let quad_index_capacity = 0;
|
||||
let uniform_layout: GPUBindGroupLayout;
|
||||
let texture_layout: GPUBindGroupLayout;
|
||||
let sampler: GPUSampler;
|
||||
// minecraft's lightmap: the color for every block light (x) and sky light (y) pair, see update_lightmap
|
||||
let lightmap: GPUTexture;
|
||||
let lightmap_bind_group: GPUBindGroup;
|
||||
|
||||
const vertex_data = new Float32Array(MAX_SPRITES * VERTS_PER_SPRITE * FLOATS_PER_VERT);
|
||||
let vert_index = 0;
|
||||
|
||||
let pos_loc: GLint;
|
||||
let uv_loc: GLint;
|
||||
let color_loc: GLint;
|
||||
let texture_loc: WebGLUniformLocation | null;
|
||||
let mvp_loc: WebGLUniformLocation | null;
|
||||
let col_diffuse_loc: WebGLUniformLocation | null;
|
||||
// every flush in a frame appends to this, so earlier draws dont get overwritten before the submit
|
||||
let stream_buffer: GPUBuffer;
|
||||
let stream_offset = 0;
|
||||
|
||||
let current_texture: WebGLTexture | null = null;
|
||||
export let white_tex: WebGLTexture | null = null;
|
||||
let uniform_buffer: GPUBuffer;
|
||||
let uniform_bind_group: GPUBindGroup;
|
||||
let uniform_slot = -1;
|
||||
|
||||
const texture_bind_groups = new WeakMap<GPUTexture, GPUBindGroup>();
|
||||
|
||||
let current_texture: GPUTexture | null = null;
|
||||
export let white_tex: GPUTexture | null = null;
|
||||
|
||||
let mode3d = false;
|
||||
|
||||
@@ -33,91 +58,239 @@ const proj = mat4.create();
|
||||
const view = mat4.create();
|
||||
const mvp = mat4.create();
|
||||
|
||||
const vertex_src = `#version 300 es
|
||||
precision mediump float;
|
||||
in vec3 vertexPosition;
|
||||
in vec2 vertexTexCoord;
|
||||
in vec4 vertexColor;
|
||||
out vec2 fragTexCoord;
|
||||
out vec4 fragColor;
|
||||
uniform mat4 mvp;
|
||||
// per frame state
|
||||
let encoder: GPUCommandEncoder | undefined;
|
||||
let pass: GPURenderPassEncoder | undefined;
|
||||
let frame_view: GPUTextureView;
|
||||
let msaa_texture: GPUTexture | undefined;
|
||||
let depth_texture: GPUTexture | undefined;
|
||||
let current_pipeline: GPURenderPipeline | undefined;
|
||||
let clear_color: GPUColor = { r: 0, g: 0, b: 0, a: 1 };
|
||||
let pending_color_clear = false;
|
||||
let pending_depth_clear = false;
|
||||
let scissor: { x: number; y: number; width: number; height: number } | undefined;
|
||||
let pending_destroy: GPUBuffer[] = [];
|
||||
|
||||
void main() {
|
||||
fragTexCoord = vertexTexCoord;
|
||||
fragColor = vertexColor;
|
||||
gl_Position = mvp*vec4(vertexPosition, 1.0);
|
||||
const shader_src = /* wgsl */ `
|
||||
struct Uniforms {
|
||||
mvp: mat4x4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
|
||||
@group(1) @binding(0) var texture0: texture_2d<f32>;
|
||||
@group(1) @binding(1) var sampler0: sampler;
|
||||
|
||||
struct VertexOut {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
// centroid: with msaa, pixels on a triangle's edge would otherwise sample outside it, past the
|
||||
// sprite's edge in the atlas, which shows up as dark lines between blocks from far away
|
||||
@location(0) @interpolate(perspective, centroid) tex_coord: vec2<f32>,
|
||||
@location(1) @interpolate(perspective, centroid) color: vec4<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_main(
|
||||
@location(0) position: vec3<f32>,
|
||||
@location(1) tex_coord: vec2<f32>,
|
||||
@location(2) color: vec4<f32>,
|
||||
) -> VertexOut {
|
||||
var out: VertexOut;
|
||||
out.position = uniforms.mvp * vec4<f32>(position, 1.0);
|
||||
out.tex_coord = tex_coord;
|
||||
out.color = color;
|
||||
return out;
|
||||
}
|
||||
|
||||
// blended draws. fully clear texels don't write depth, or they would hide what's drawn behind them later
|
||||
@fragment
|
||||
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
|
||||
let color = textureSample(texture0, sampler0, in.tex_coord) * in.color;
|
||||
if (color.a < 0.01) {
|
||||
discard;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
`;
|
||||
|
||||
const fragment_src = `#version 300 es
|
||||
precision mediump float;
|
||||
in vec2 fragTexCoord;
|
||||
in vec4 fragColor;
|
||||
out vec4 finalColor;
|
||||
uniform sampler2D texture0;
|
||||
uniform vec4 colDiffuse;
|
||||
void main() {
|
||||
vec4 texelColor = texture(texture0, fragTexCoord);
|
||||
finalColor = texelColor*colDiffuse*fragColor;
|
||||
const terrain_shader_src = /* wgsl */ `
|
||||
struct Uniforms {
|
||||
mvp: mat4x4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
|
||||
@group(1) @binding(0) var texture0: texture_2d<f32>;
|
||||
@group(1) @binding(1) var sampler0: sampler;
|
||||
@group(2) @binding(0) var lightmap: texture_2d<f32>;
|
||||
@group(2) @binding(1) var lightmap_sampler: sampler;
|
||||
|
||||
struct VertexOut {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) @interpolate(perspective, centroid) tex_coord: vec2<f32>,
|
||||
// directional shade times ambient occlusion, and alpha
|
||||
@location(1) @interpolate(perspective, centroid) color: vec4<f32>,
|
||||
// block light, sky light, as lightmap coordinates
|
||||
@location(2) @interpolate(perspective, centroid) light: vec2<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_terrain(
|
||||
@location(0) position: vec3<f32>,
|
||||
@location(1) tex_coord: vec2<f32>,
|
||||
@location(2) color: vec4<f32>,
|
||||
@location(3) light: vec2<f32>,
|
||||
) -> VertexOut {
|
||||
var out: VertexOut;
|
||||
out.position = uniforms.mvp * vec4<f32>(position, 1.0);
|
||||
out.tex_coord = tex_coord;
|
||||
out.color = color;
|
||||
out.light = light;
|
||||
return out;
|
||||
}
|
||||
|
||||
fn lit(in: VertexOut) -> vec4<f32> {
|
||||
let texel = textureSample(texture0, sampler0, in.tex_coord);
|
||||
let light = textureSample(lightmap, lightmap_sampler, in.light).rgb;
|
||||
return vec4<f32>(texel.rgb * in.color.rgb * light, texel.a * in.color.a);
|
||||
}
|
||||
|
||||
// no discard, so the gpu can reject hidden fragments before running the shader
|
||||
@fragment
|
||||
fn fs_solid(in: VertexOut) -> @location(0) vec4<f32> {
|
||||
return vec4<f32>(lit(in).rgb, 1.0);
|
||||
}
|
||||
|
||||
// alpha tested instead of blended, so it doesn't need sorting
|
||||
@fragment
|
||||
fn fs_cutout(in: VertexOut) -> @location(0) vec4<f32> {
|
||||
let color = lit(in);
|
||||
if (color.a < 0.1) {
|
||||
discard;
|
||||
}
|
||||
return vec4<f32>(color.rgb, 1.0);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_translucent(in: VertexOut) -> @location(0) vec4<f32> {
|
||||
let color = lit(in);
|
||||
if (color.a < 0.01) {
|
||||
discard;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
`;
|
||||
|
||||
export function init_window(canvas_element: HTMLCanvasElement) {
|
||||
export async function init_window(canvas_element: HTMLCanvasElement) {
|
||||
canvas = canvas_element;
|
||||
canvas.width = 1800;
|
||||
canvas.height = 900;
|
||||
|
||||
const ctx = canvas.getContext("webgl2", { antialias: true, alpha: false });
|
||||
if (!ctx) {
|
||||
throw new Error("WebGL2 not supported");
|
||||
if (!navigator.gpu) {
|
||||
throw new Error("WebGPU not supported");
|
||||
}
|
||||
const adapter = await navigator.gpu.requestAdapter();
|
||||
if (!adapter) {
|
||||
throw new Error("No WebGPU adapter found");
|
||||
}
|
||||
device = await adapter.requestDevice();
|
||||
device.addEventListener("uncapturederror", (event) => {
|
||||
console.error("WebGPU error:", (event as GPUUncapturedErrorEvent).error.message);
|
||||
});
|
||||
device.lost.then((info) => console.error(`WebGPU device lost: ${info.message}`));
|
||||
|
||||
gl = ctx;
|
||||
// the dom typings dont know about the webgpu overload yet
|
||||
const ctx = canvas.getContext("webgpu") as GPUCanvasContext | null;
|
||||
if (!ctx) {
|
||||
throw new Error("WebGPU not supported");
|
||||
}
|
||||
context = ctx;
|
||||
canvas_format = navigator.gpu.getPreferredCanvasFormat();
|
||||
context.configure({ device, format: canvas_format, alphaMode: "opaque" });
|
||||
|
||||
program = create_program(vertex_src, fragment_src);
|
||||
create_pipelines();
|
||||
|
||||
main_buffer = gl.createBuffer()!;
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, main_buffer);
|
||||
sampler = device.createSampler({
|
||||
magFilter: "nearest",
|
||||
minFilter: "nearest",
|
||||
addressModeU: "clamp-to-edge",
|
||||
addressModeV: "clamp-to-edge",
|
||||
});
|
||||
|
||||
const stride = FLOATS_PER_VERT * 4;
|
||||
|
||||
pos_loc = gl.getAttribLocation(program, "vertexPosition");
|
||||
gl.enableVertexAttribArray(pos_loc);
|
||||
gl.vertexAttribPointer(pos_loc, 3, gl.FLOAT, false, stride, 0);
|
||||
|
||||
uv_loc = gl.getAttribLocation(program, "vertexTexCoord");
|
||||
gl.enableVertexAttribArray(uv_loc);
|
||||
gl.vertexAttribPointer(uv_loc, 2, gl.FLOAT, false, stride, 12);
|
||||
|
||||
color_loc = gl.getAttribLocation(program, "vertexColor");
|
||||
gl.enableVertexAttribArray(color_loc);
|
||||
gl.vertexAttribPointer(color_loc, 4, gl.FLOAT, false, stride, 20);
|
||||
|
||||
mvp_loc = gl.getUniformLocation(program, "mvp");
|
||||
texture_loc = gl.getUniformLocation(program, "texture0");
|
||||
col_diffuse_loc = gl.getUniformLocation(program, "colDiffuse");
|
||||
|
||||
gl.useProgram(program);
|
||||
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
gl.cullFace(gl.BACK);
|
||||
gl.frontFace(gl.CCW);
|
||||
stream_buffer = create_stream_buffer(8 * 1024 * 1024);
|
||||
create_uniform_buffer(64);
|
||||
|
||||
create_white_texture();
|
||||
create_lightmap();
|
||||
}
|
||||
|
||||
// how bright the sky is, 1 at noon. only the lightmap changes, the chunk meshes stay the same
|
||||
export function update_lightmap(daylight = 1) {
|
||||
// minecraft's LightTexture, with its default brightness setting and no flicker
|
||||
const gamma = 0.5;
|
||||
const brightness = (level: number) => {
|
||||
const f = level / 15;
|
||||
return f / (4 - 3 * f);
|
||||
};
|
||||
const lerp = (from: number, to: number, t: number) => from + (to - from) * t;
|
||||
const clamp = (value: number) => Math.min(1, Math.max(0, value));
|
||||
const not_gamma = (value: number) => 1 - (1 - value) ** 4;
|
||||
|
||||
const sky_factor = daylight * 0.95 + 0.05;
|
||||
// sky light turns blue as it gets dark
|
||||
const sky_color = [lerp(daylight, 1, 0.35), lerp(daylight, 1, 0.35), 1];
|
||||
const block_boost = 1.5;
|
||||
|
||||
const pixels = new Uint8Array(16 * 16 * 4);
|
||||
for (let sky = 0; sky < 16; sky++) {
|
||||
for (let block = 0; block < 16; block++) {
|
||||
const s = brightness(sky) * sky_factor;
|
||||
// block light is warm, it loses blue and green faster as it dims
|
||||
const b = brightness(block) * block_boost;
|
||||
const color = [
|
||||
b + sky_color[0] * s,
|
||||
b * ((b * 0.6 + 0.4) * 0.6 + 0.4) + sky_color[1] * s,
|
||||
b * (b * b * 0.6 + 0.4) + sky_color[2] * s,
|
||||
].map((c) => clamp(lerp(c, 0.75, 0.04)))
|
||||
.map((c) => clamp(lerp(lerp(c, not_gamma(c), gamma), 0.75, 0.04)));
|
||||
|
||||
const i = (sky * 16 + block) * 4;
|
||||
pixels[i] = Math.round(color[0] * 255);
|
||||
pixels[i + 1] = Math.round(color[1] * 255);
|
||||
pixels[i + 2] = Math.round(color[2] * 255);
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
device.queue.writeTexture({ texture: lightmap }, pixels, { bytesPerRow: 16 * 4 }, [16, 16]);
|
||||
}
|
||||
|
||||
export function begin_drawing() {
|
||||
encoder = device.createCommandEncoder();
|
||||
frame_view = context.getCurrentTexture().createView();
|
||||
ensure_render_targets();
|
||||
|
||||
update_2d_mvp();
|
||||
gl.depthFunc(gl.LEQUAL);
|
||||
gl.clearDepth(1.0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
stream_offset = 0;
|
||||
uniform_slot = -1;
|
||||
write_mvp();
|
||||
|
||||
pending_color_clear = true;
|
||||
pending_depth_clear = true;
|
||||
vert_index = 0;
|
||||
}
|
||||
|
||||
export function end_drawing() {
|
||||
flush_batch();
|
||||
// make sure the frame gets cleared even if nothing was drawn
|
||||
ensure_pass();
|
||||
|
||||
pass!.end();
|
||||
pass = undefined;
|
||||
device.queue.submit([encoder!.finish()]);
|
||||
encoder = undefined;
|
||||
|
||||
for (const buffer of pending_destroy) {
|
||||
buffer.destroy();
|
||||
}
|
||||
pending_destroy = [];
|
||||
}
|
||||
|
||||
function update_2d_mvp() {
|
||||
@@ -131,19 +304,23 @@ function update_2d_mvp() {
|
||||
const n = -1;
|
||||
const f = 1;
|
||||
|
||||
mat4.ortho(ortho, l, r, b, t, n, f);
|
||||
mat4.orthoZO(ortho, l, r, b, t, n, f);
|
||||
}
|
||||
|
||||
export function begin_clip(x: number, y: number, width: number, height: number) {
|
||||
gl.enable(gl.SCISSOR_TEST);
|
||||
|
||||
const flipped_y = canvas.height - (y + height);
|
||||
|
||||
gl.scissor(x, flipped_y, width, height);
|
||||
flush_batch();
|
||||
scissor = { x, y, width, height };
|
||||
if (pass) {
|
||||
apply_scissor(pass);
|
||||
}
|
||||
}
|
||||
|
||||
export function end_clip() {
|
||||
gl.disable(gl.SCISSOR_TEST);
|
||||
flush_batch();
|
||||
scissor = undefined;
|
||||
if (pass) {
|
||||
apply_scissor(pass);
|
||||
}
|
||||
}
|
||||
|
||||
export function begin_mode_3d(new_camera: Camera) {
|
||||
@@ -152,13 +329,8 @@ export function begin_mode_3d(new_camera: Camera) {
|
||||
mode3d = true;
|
||||
camera = new_camera;
|
||||
|
||||
gl.enable(gl.DEPTH_TEST);
|
||||
gl.enable(gl.CULL_FACE);
|
||||
gl.depthMask(true);
|
||||
gl.enable(gl.POLYGON_OFFSET_FILL);
|
||||
gl.polygonOffset(1, 1);
|
||||
|
||||
update_camera();
|
||||
write_mvp();
|
||||
}
|
||||
|
||||
export function end_mode_3d() {
|
||||
@@ -169,15 +341,18 @@ export function end_mode_3d() {
|
||||
flush_batch();
|
||||
|
||||
mode3d = false;
|
||||
gl.disable(gl.DEPTH_TEST);
|
||||
gl.disable(gl.CULL_FACE);
|
||||
gl.depthMask(false);
|
||||
gl.disable(gl.POLYGON_OFFSET_FILL);
|
||||
write_mvp();
|
||||
}
|
||||
|
||||
export function clear_background(r: number, g: number, b: number, a = 1) {
|
||||
gl.clearColor(r, g, b, a);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
flush_batch();
|
||||
clear_color = { r, g, b, a };
|
||||
// the clear happens when a render pass starts, so start a new one
|
||||
if (pass) {
|
||||
pass.end();
|
||||
pass = undefined;
|
||||
}
|
||||
pending_color_clear = true;
|
||||
}
|
||||
|
||||
export function flush_batch() {
|
||||
@@ -185,52 +360,76 @@ export function flush_batch() {
|
||||
return;
|
||||
}
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, main_buffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, vertex_data.subarray(0, vert_index), gl.STREAM_DRAW);
|
||||
|
||||
if (mode3d) {
|
||||
gl.uniformMatrix4fv(mvp_loc, false, mvp);
|
||||
} else {
|
||||
gl.uniformMatrix4fv(mvp_loc, false, ortho);
|
||||
const byte_length = vert_index * 4;
|
||||
if (stream_offset + byte_length > stream_buffer.size) {
|
||||
// the old buffer might still be used by draws in this frame, destroy it after the submit
|
||||
pending_destroy.push(stream_buffer);
|
||||
stream_buffer = create_stream_buffer(Math.max(stream_buffer.size * 2, byte_length));
|
||||
stream_offset = 0;
|
||||
}
|
||||
|
||||
const stride = FLOATS_PER_VERT * 4;
|
||||
gl.vertexAttribPointer(pos_loc, 3, gl.FLOAT, false, stride, 0);
|
||||
gl.vertexAttribPointer(uv_loc, 2, gl.FLOAT, false, stride, 12);
|
||||
gl.vertexAttribPointer(color_loc, 4, gl.FLOAT, false, stride, 20);
|
||||
|
||||
gl.uniform4f(col_diffuse_loc, 1, 1, 1, 1);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, current_texture);
|
||||
gl.uniform1i(texture_loc, 0);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, vert_index / FLOATS_PER_VERT);
|
||||
device.queue.writeBuffer(stream_buffer, stream_offset, vertex_data, 0, vert_index);
|
||||
draw(stream_buffer, stream_offset, vert_index / FLOATS_PER_VERT);
|
||||
stream_offset += byte_length;
|
||||
|
||||
vert_index = 0;
|
||||
}
|
||||
|
||||
export function flush_buffer(buffer: WebGLBuffer, draw_count: number) {
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
|
||||
if (mode3d) {
|
||||
gl.uniformMatrix4fv(mvp_loc, false, mvp);
|
||||
} else {
|
||||
gl.uniformMatrix4fv(mvp_loc, false, ortho);
|
||||
// draws a chunk mesh made of quads (4 vertices each). without an index buffer the quads are drawn in order
|
||||
export function draw_terrain(
|
||||
layer: RenderLayer,
|
||||
vertex_buffer: GPUBuffer,
|
||||
quad_count: number,
|
||||
index_buffer?: GPUBuffer,
|
||||
) {
|
||||
if (!current_texture || quad_count === 0) {
|
||||
return;
|
||||
}
|
||||
if (!index_buffer) {
|
||||
ensure_quad_indices(quad_count);
|
||||
index_buffer = quad_index_buffer!;
|
||||
}
|
||||
|
||||
const stride = FLOATS_PER_VERT * 4;
|
||||
gl.vertexAttribPointer(pos_loc, 3, gl.FLOAT, false, stride, 0);
|
||||
gl.vertexAttribPointer(uv_loc, 2, gl.FLOAT, false, stride, 12);
|
||||
gl.vertexAttribPointer(color_loc, 4, gl.FLOAT, false, stride, 20);
|
||||
const render_pass = ensure_pass();
|
||||
const pipeline = terrain_pipelines[layer];
|
||||
if (current_pipeline !== pipeline) {
|
||||
render_pass.setPipeline(pipeline);
|
||||
current_pipeline = pipeline;
|
||||
}
|
||||
|
||||
gl.uniform4f(col_diffuse_loc, 1, 1, 1, 1);
|
||||
render_pass.setBindGroup(0, uniform_bind_group, [uniform_slot * UNIFORM_SLOT_SIZE]);
|
||||
render_pass.setBindGroup(1, get_texture_bind_group(current_texture));
|
||||
render_pass.setBindGroup(2, lightmap_bind_group);
|
||||
render_pass.setVertexBuffer(0, vertex_buffer);
|
||||
render_pass.setIndexBuffer(index_buffer, "uint32");
|
||||
render_pass.drawIndexed(quad_count * 6);
|
||||
}
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, current_texture);
|
||||
gl.uniform1i(texture_loc, 0);
|
||||
export function create_vertex_buffer(vertices: Float32Array): GPUBuffer {
|
||||
const buffer = device.createBuffer({
|
||||
size: Math.max(4, vertices.byteLength),
|
||||
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
device.queue.writeBuffer(buffer, 0, vertices);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, draw_count);
|
||||
export function create_index_buffer(indices: Uint32Array): GPUBuffer {
|
||||
const buffer = device.createBuffer({
|
||||
size: Math.max(4, indices.byteLength),
|
||||
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
device.queue.writeBuffer(buffer, 0, indices);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function destroy_buffer(buffer: GPUBuffer) {
|
||||
// it might be used by a draw thats not submitted yet
|
||||
if (encoder) {
|
||||
pending_destroy.push(buffer);
|
||||
} else {
|
||||
buffer.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export function resize_canvas() {
|
||||
@@ -242,21 +441,26 @@ export function resize_canvas() {
|
||||
canvas.height = height;
|
||||
canvas.style.width = canvas.width + "px";
|
||||
canvas.style.height = canvas.height + "px";
|
||||
|
||||
gl.viewport(0, 0, width, height);
|
||||
|
||||
gl.useProgram(program);
|
||||
// render targets get recreated at the start of the next frame
|
||||
}
|
||||
}
|
||||
|
||||
export function get_current_texture(): WebGLTexture | null {
|
||||
export function get_current_texture(): GPUTexture | null {
|
||||
return current_texture;
|
||||
}
|
||||
|
||||
export function set_current_texture(texture: WebGLTexture) {
|
||||
export function set_current_texture(texture: GPUTexture) {
|
||||
current_texture = texture;
|
||||
}
|
||||
|
||||
export function create_texture(width: number, height: number): GPUTexture {
|
||||
return device.createTexture({
|
||||
size: [width, height],
|
||||
format: "rgba8unorm",
|
||||
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
||||
});
|
||||
}
|
||||
|
||||
export function push_vertex(px: number, py: number, pz: number, u: number, vv: number, r = 1, g = 1, b = 1, a = 1) {
|
||||
let i = vert_index;
|
||||
vertex_data[i++] = px;
|
||||
@@ -330,12 +534,137 @@ export function push_quad_vertices(
|
||||
|
||||
// internal
|
||||
|
||||
function ensure_quad_indices(quad_count: number) {
|
||||
if (quad_count <= quad_index_capacity) {
|
||||
return;
|
||||
}
|
||||
if (quad_index_buffer) {
|
||||
destroy_buffer(quad_index_buffer);
|
||||
}
|
||||
quad_index_capacity = Math.max(quad_count, quad_index_capacity * 2, 16384);
|
||||
const indices = new Uint32Array(quad_index_capacity * 6);
|
||||
for (let q = 0; q < quad_index_capacity; q++) {
|
||||
const v = q * 4;
|
||||
const i = q * 6;
|
||||
indices[i] = v;
|
||||
indices[i + 1] = v + 1;
|
||||
indices[i + 2] = v + 2;
|
||||
indices[i + 3] = v;
|
||||
indices[i + 4] = v + 2;
|
||||
indices[i + 5] = v + 3;
|
||||
}
|
||||
quad_index_buffer = create_index_buffer(indices);
|
||||
}
|
||||
|
||||
function draw(buffer: GPUBuffer, offset: number, vertex_count: number) {
|
||||
if (!current_texture || vertex_count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const render_pass = ensure_pass();
|
||||
|
||||
const pipeline = mode3d ? pipeline_3d : pipeline_2d;
|
||||
if (current_pipeline !== pipeline) {
|
||||
render_pass.setPipeline(pipeline);
|
||||
current_pipeline = pipeline;
|
||||
}
|
||||
|
||||
render_pass.setBindGroup(0, uniform_bind_group, [uniform_slot * UNIFORM_SLOT_SIZE]);
|
||||
render_pass.setBindGroup(1, get_texture_bind_group(current_texture));
|
||||
render_pass.setVertexBuffer(0, buffer, offset);
|
||||
render_pass.draw(vertex_count);
|
||||
}
|
||||
|
||||
function ensure_pass(): GPURenderPassEncoder {
|
||||
if (pass) {
|
||||
return pass;
|
||||
}
|
||||
|
||||
pass = encoder!.beginRenderPass({
|
||||
colorAttachments: [{
|
||||
view: msaa_texture!.createView(),
|
||||
resolveTarget: frame_view,
|
||||
clearValue: clear_color,
|
||||
loadOp: pending_color_clear ? "clear" : "load",
|
||||
storeOp: "store",
|
||||
}],
|
||||
depthStencilAttachment: {
|
||||
view: depth_texture!.createView(),
|
||||
depthClearValue: 1,
|
||||
depthLoadOp: pending_depth_clear ? "clear" : "load",
|
||||
depthStoreOp: "store",
|
||||
},
|
||||
});
|
||||
pending_color_clear = false;
|
||||
pending_depth_clear = false;
|
||||
current_pipeline = undefined;
|
||||
apply_scissor(pass);
|
||||
|
||||
return pass;
|
||||
}
|
||||
|
||||
function apply_scissor(render_pass: GPURenderPassEncoder) {
|
||||
const target_width = msaa_texture!.width;
|
||||
const target_height = msaa_texture!.height;
|
||||
|
||||
if (!scissor) {
|
||||
render_pass.setScissorRect(0, 0, target_width, target_height);
|
||||
return;
|
||||
}
|
||||
|
||||
// webgpu errors on rects outside the target instead of clipping them
|
||||
const x0 = Math.min(target_width, Math.max(0, Math.floor(scissor.x)));
|
||||
const y0 = Math.min(target_height, Math.max(0, Math.floor(scissor.y)));
|
||||
const x1 = Math.min(target_width, Math.max(x0, Math.ceil(scissor.x + scissor.width)));
|
||||
const y1 = Math.min(target_height, Math.max(y0, Math.ceil(scissor.y + scissor.height)));
|
||||
|
||||
render_pass.setScissorRect(x0, y0, x1 - x0, y1 - y0);
|
||||
}
|
||||
|
||||
function ensure_render_targets() {
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
if (msaa_texture && msaa_texture.width === width && msaa_texture.height === height) {
|
||||
return;
|
||||
}
|
||||
|
||||
msaa_texture?.destroy();
|
||||
depth_texture?.destroy();
|
||||
|
||||
msaa_texture = device.createTexture({
|
||||
size: [width, height],
|
||||
format: canvas_format,
|
||||
sampleCount: SAMPLE_COUNT,
|
||||
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
||||
});
|
||||
depth_texture = device.createTexture({
|
||||
size: [width, height],
|
||||
format: DEPTH_FORMAT,
|
||||
sampleCount: SAMPLE_COUNT,
|
||||
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
||||
});
|
||||
}
|
||||
|
||||
// puts the current matrix in a new uniform slot, earlier draws in the frame keep using theirs
|
||||
function write_mvp() {
|
||||
uniform_slot += 1;
|
||||
if (uniform_slot * UNIFORM_SLOT_SIZE >= uniform_buffer.size) {
|
||||
pending_destroy.push(uniform_buffer);
|
||||
create_uniform_buffer((uniform_buffer.size / UNIFORM_SLOT_SIZE) * 2);
|
||||
uniform_slot = 0;
|
||||
}
|
||||
|
||||
const matrix = mode3d ? mvp : ortho;
|
||||
device.queue.writeBuffer(uniform_buffer, uniform_slot * UNIFORM_SLOT_SIZE, matrix as Float32Array);
|
||||
}
|
||||
|
||||
function update_camera() {
|
||||
if (!camera) {
|
||||
return;
|
||||
}
|
||||
|
||||
mat4.perspective(
|
||||
mat4.perspectiveZO(
|
||||
proj,
|
||||
camera.fov,
|
||||
canvas.width / canvas.height,
|
||||
@@ -354,47 +683,166 @@ function update_camera() {
|
||||
mat4.multiply(mvp, proj, view);
|
||||
}
|
||||
|
||||
function compile_shader(type: number, src: string) {
|
||||
const shader = gl.createShader(type)!;
|
||||
function create_pipelines() {
|
||||
const module = device.createShaderModule({ code: shader_src });
|
||||
|
||||
gl.shaderSource(shader, src);
|
||||
gl.compileShader(shader);
|
||||
uniform_layout = device.createBindGroupLayout({
|
||||
entries: [{
|
||||
binding: 0,
|
||||
visibility: GPUShaderStage.VERTEX,
|
||||
buffer: { type: "uniform", hasDynamicOffset: true, minBindingSize: 64 },
|
||||
}],
|
||||
});
|
||||
texture_layout = device.createBindGroupLayout({
|
||||
entries: [
|
||||
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
||||
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
||||
],
|
||||
});
|
||||
const layout = device.createPipelineLayout({ bindGroupLayouts: [uniform_layout, texture_layout] });
|
||||
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.error(gl.getShaderInfoLog(shader));
|
||||
throw new Error("Shader compile failed");
|
||||
}
|
||||
const vertex: GPUVertexState = {
|
||||
module,
|
||||
entryPoint: "vs_main",
|
||||
buffers: [{
|
||||
arrayStride: VERTEX_STRIDE,
|
||||
attributes: [
|
||||
{ shaderLocation: 0, offset: 0, format: "float32x3" },
|
||||
{ shaderLocation: 1, offset: 12, format: "float32x2" },
|
||||
{ shaderLocation: 2, offset: 20, format: "float32x4" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
const fragment: GPUFragmentState = {
|
||||
module,
|
||||
entryPoint: "fs_main",
|
||||
targets: [{
|
||||
format: canvas_format,
|
||||
blend: {
|
||||
color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
|
||||
alpha: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
|
||||
},
|
||||
}],
|
||||
};
|
||||
const multisample: GPUMultisampleState = { count: SAMPLE_COUNT };
|
||||
|
||||
return shader;
|
||||
pipeline_2d = device.createRenderPipeline({
|
||||
layout,
|
||||
vertex,
|
||||
fragment,
|
||||
multisample,
|
||||
primitive: { topology: "triangle-list", cullMode: "none" },
|
||||
depthStencil: { format: DEPTH_FORMAT, depthWriteEnabled: false, depthCompare: "always" },
|
||||
});
|
||||
|
||||
const primitive_3d: GPUPrimitiveState = { topology: "triangle-list", cullMode: "back", frontFace: "ccw" };
|
||||
const depth_3d: GPUDepthStencilState = {
|
||||
format: DEPTH_FORMAT,
|
||||
depthWriteEnabled: true,
|
||||
depthCompare: "less-equal",
|
||||
// same as the old polygonOffset(1, 1)
|
||||
depthBias: 1,
|
||||
depthBiasSlopeScale: 1,
|
||||
};
|
||||
|
||||
pipeline_3d = device.createRenderPipeline({
|
||||
layout,
|
||||
vertex,
|
||||
fragment,
|
||||
multisample,
|
||||
primitive: primitive_3d,
|
||||
depthStencil: depth_3d,
|
||||
});
|
||||
|
||||
const terrain_module = device.createShaderModule({ code: terrain_shader_src });
|
||||
const terrain_layout = device.createPipelineLayout({
|
||||
bindGroupLayouts: [uniform_layout, texture_layout, texture_layout],
|
||||
});
|
||||
const terrain_vertex: GPUVertexState = {
|
||||
module: terrain_module,
|
||||
entryPoint: "vs_terrain",
|
||||
buffers: [{
|
||||
arrayStride: TERRAIN_VERTEX_FLOATS * 4,
|
||||
attributes: [
|
||||
{ shaderLocation: 0, offset: 0, format: "float32x3" },
|
||||
{ shaderLocation: 1, offset: 12, format: "float32x2" },
|
||||
{ shaderLocation: 2, offset: 20, format: "float32x4" },
|
||||
{ shaderLocation: 3, offset: 36, format: "float32x2" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
// like sodium and vanilla: solid and cutout don't blend, translucent blends and still writes depth
|
||||
// since it's drawn sorted back to front
|
||||
const terrain_pipeline = (entryPoint: string, blend?: GPUBlendState) =>
|
||||
device.createRenderPipeline({
|
||||
layout: terrain_layout,
|
||||
vertex: terrain_vertex,
|
||||
fragment: { module: terrain_module, entryPoint, targets: [{ format: canvas_format, blend }] },
|
||||
multisample,
|
||||
primitive: primitive_3d,
|
||||
depthStencil: depth_3d,
|
||||
});
|
||||
terrain_pipelines = {
|
||||
solid: terrain_pipeline("fs_solid"),
|
||||
cutout: terrain_pipeline("fs_cutout"),
|
||||
translucent: terrain_pipeline("fs_translucent", {
|
||||
color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
|
||||
alpha: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function create_program(vs_src: string, fs_src: string) {
|
||||
const vs = compile_shader(gl.VERTEX_SHADER, vs_src);
|
||||
const fs = compile_shader(gl.FRAGMENT_SHADER, fs_src);
|
||||
function create_stream_buffer(size: number) {
|
||||
return device.createBuffer({ size, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
|
||||
}
|
||||
|
||||
const program = gl.createProgram()!;
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
function create_uniform_buffer(slots: number) {
|
||||
uniform_buffer = device.createBuffer({
|
||||
size: slots * UNIFORM_SLOT_SIZE,
|
||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
uniform_bind_group = device.createBindGroup({
|
||||
layout: uniform_layout,
|
||||
entries: [{ binding: 0, resource: { buffer: uniform_buffer, size: 64 } }],
|
||||
});
|
||||
}
|
||||
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
console.error(gl.getProgramInfoLog(program));
|
||||
throw new Error("Program link failed");
|
||||
function get_texture_bind_group(texture: GPUTexture) {
|
||||
let bind_group = texture_bind_groups.get(texture);
|
||||
if (!bind_group) {
|
||||
bind_group = device.createBindGroup({
|
||||
layout: texture_layout,
|
||||
entries: [
|
||||
{ binding: 0, resource: texture.createView() },
|
||||
{ binding: 1, resource: sampler },
|
||||
],
|
||||
});
|
||||
texture_bind_groups.set(texture, bind_group);
|
||||
}
|
||||
return bind_group;
|
||||
}
|
||||
|
||||
return program;
|
||||
function create_lightmap() {
|
||||
lightmap = create_texture(16, 16);
|
||||
// linear, so light fades smoothly between levels across a face like in minecraft
|
||||
const lightmap_sampler = device.createSampler({
|
||||
magFilter: "linear",
|
||||
minFilter: "linear",
|
||||
addressModeU: "clamp-to-edge",
|
||||
addressModeV: "clamp-to-edge",
|
||||
});
|
||||
lightmap_bind_group = device.createBindGroup({
|
||||
layout: texture_layout,
|
||||
entries: [
|
||||
{ binding: 0, resource: lightmap.createView() },
|
||||
{ binding: 1, resource: lightmap_sampler },
|
||||
],
|
||||
});
|
||||
update_lightmap();
|
||||
}
|
||||
|
||||
function create_white_texture() {
|
||||
const tex = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
const white_pixel = new Uint8Array([255, 255, 255, 255]);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, white_pixel);
|
||||
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
const tex = create_texture(1, 1);
|
||||
device.queue.writeTexture({ texture: tex }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]);
|
||||
white_tex = tex;
|
||||
}
|
||||
|
||||
@@ -191,3 +191,69 @@ export function push_bottom_face(
|
||||
push_vertex(x2, y, z2, u1, v0, r, g, b, a);
|
||||
push_vertex(x, y, z2, u0, v0, r, g, b, a);
|
||||
}
|
||||
|
||||
// solid colored box, draw it with white_tex as the current texture
|
||||
export function push_box(
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
width: number,
|
||||
height: number,
|
||||
depth: number,
|
||||
r = 1,
|
||||
g = 1,
|
||||
b = 1,
|
||||
a = 1,
|
||||
) {
|
||||
const x2 = x + width;
|
||||
const y2 = y + height;
|
||||
const z2 = z + depth;
|
||||
|
||||
// front
|
||||
push_vertex(x, y, z2, 0, 1, r, g, b, a);
|
||||
push_vertex(x2, y, z2, 1, 1, r, g, b, a);
|
||||
push_vertex(x2, y2, z2, 1, 0, r, g, b, a);
|
||||
push_vertex(x, y, z2, 0, 1, r, g, b, a);
|
||||
push_vertex(x2, y2, z2, 1, 0, r, g, b, a);
|
||||
push_vertex(x, y2, z2, 0, 0, r, g, b, a);
|
||||
|
||||
// back
|
||||
push_vertex(x2, y, z, 0, 1, r * 0.8, g * 0.8, b * 0.8, a);
|
||||
push_vertex(x, y, z, 1, 1, r * 0.8, g * 0.8, b * 0.8, a);
|
||||
push_vertex(x, y2, z, 1, 0, r * 0.8, g * 0.8, b * 0.8, a);
|
||||
push_vertex(x2, y, z, 0, 1, r * 0.8, g * 0.8, b * 0.8, a);
|
||||
push_vertex(x, y2, z, 1, 0, r * 0.8, g * 0.8, b * 0.8, a);
|
||||
push_vertex(x2, y2, z, 0, 0, r * 0.8, g * 0.8, b * 0.8, a);
|
||||
|
||||
// left
|
||||
push_vertex(x, y, z, 0, 1, r * 0.9, g * 0.9, b * 0.9, a);
|
||||
push_vertex(x, y, z2, 1, 1, r * 0.9, g * 0.9, b * 0.9, a);
|
||||
push_vertex(x, y2, z2, 1, 0, r * 0.9, g * 0.9, b * 0.9, a);
|
||||
push_vertex(x, y, z, 0, 1, r * 0.9, g * 0.9, b * 0.9, a);
|
||||
push_vertex(x, y2, z2, 1, 0, r * 0.9, g * 0.9, b * 0.9, a);
|
||||
push_vertex(x, y2, z, 0, 0, r * 0.9, g * 0.9, b * 0.9, a);
|
||||
|
||||
// right
|
||||
push_vertex(x2, y, z2, 0, 1, r * 0.9, g * 0.9, b * 0.9, a);
|
||||
push_vertex(x2, y, z, 1, 1, r * 0.9, g * 0.9, b * 0.9, a);
|
||||
push_vertex(x2, y2, z, 1, 0, r * 0.9, g * 0.9, b * 0.9, a);
|
||||
push_vertex(x2, y, z2, 0, 1, r * 0.9, g * 0.9, b * 0.9, a);
|
||||
push_vertex(x2, y2, z, 1, 0, r * 0.9, g * 0.9, b * 0.9, a);
|
||||
push_vertex(x2, y2, z2, 0, 0, r * 0.9, g * 0.9, b * 0.9, a);
|
||||
|
||||
// top
|
||||
push_vertex(x, y2, z2, 0, 1, r, g, b, a);
|
||||
push_vertex(x2, y2, z2, 1, 1, r, g, b, a);
|
||||
push_vertex(x2, y2, z, 1, 0, r, g, b, a);
|
||||
push_vertex(x, y2, z2, 0, 1, r, g, b, a);
|
||||
push_vertex(x2, y2, z, 1, 0, r, g, b, a);
|
||||
push_vertex(x, y2, z, 0, 0, r, g, b, a);
|
||||
|
||||
// bottom
|
||||
push_vertex(x, y, z, 0, 1, r * 0.6, g * 0.6, b * 0.6, a);
|
||||
push_vertex(x2, y, z, 1, 1, r * 0.6, g * 0.6, b * 0.6, a);
|
||||
push_vertex(x2, y, z2, 1, 0, r * 0.6, g * 0.6, b * 0.6, a);
|
||||
push_vertex(x, y, z, 0, 1, r * 0.6, g * 0.6, b * 0.6, a);
|
||||
push_vertex(x2, y, z2, 1, 0, r * 0.6, g * 0.6, b * 0.6, a);
|
||||
push_vertex(x, y, z2, 0, 0, r * 0.6, g * 0.6, b * 0.6, a);
|
||||
}
|
||||
|
||||
+12
-23
@@ -1,28 +1,17 @@
|
||||
import { flush_batch, get_current_texture, gl, push_quad, push_quad_vertices, set_current_texture } from "./core.ts";
|
||||
import {
|
||||
create_texture,
|
||||
device,
|
||||
flush_batch,
|
||||
get_current_texture,
|
||||
push_quad,
|
||||
push_quad_vertices,
|
||||
set_current_texture,
|
||||
} from "./core.ts";
|
||||
import { Texture } from "./types.ts";
|
||||
|
||||
export function load_texture(image: HTMLImageElement): Texture {
|
||||
const texture = gl.createTexture();
|
||||
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
image,
|
||||
);
|
||||
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
export function load_texture(image: HTMLImageElement | ImageBitmap): Texture {
|
||||
const texture = create_texture(image.width, image.height);
|
||||
device.queue.copyExternalImageToTexture({ source: image }, { texture }, [image.width, image.height]);
|
||||
|
||||
return {
|
||||
tex: texture,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface Texture {
|
||||
tex: WebGLTexture;
|
||||
tex: GPUTexture;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Client } from "$/client/client.ts";
|
||||
import { begin_mode_3d, end_mode_3d } from "$/client/renderer/mod.ts";
|
||||
import { Hud } from "$/client/gui/hud.ts";
|
||||
import { DebugOverlay } from "$/client/gui/debug_overlay.ts";
|
||||
import { LevelRenderer } from "./level_renderer.ts";
|
||||
|
||||
// draws a frame, like minecraft's GameRenderer: the level from the camera, then the hud and screens on top
|
||||
export class GameRenderer {
|
||||
level_renderer = new LevelRenderer();
|
||||
hud = new Hud();
|
||||
debug_overlay = new DebugOverlay();
|
||||
|
||||
// partial_tick is how far this frame is between the last tick and the next, entities are drawn in between
|
||||
render(client: Client, partial_tick: number) {
|
||||
const camera = client.camera;
|
||||
camera.setup(client.player, partial_tick);
|
||||
|
||||
begin_mode_3d(camera);
|
||||
this.level_renderer.render_opaque(client.level, camera);
|
||||
this.level_renderer.render_destroy_progress(client.game_mode);
|
||||
this.level_renderer.render_entities(client.level, client.player, partial_tick);
|
||||
this.level_renderer.render_translucent(client.level, camera);
|
||||
end_mode_3d();
|
||||
|
||||
this.hud.render(client);
|
||||
client.screen?.on_render();
|
||||
if (client.debugging) {
|
||||
this.debug_overlay.render(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { TEXTURE_SIZE } from "$/common/constants.ts";
|
||||
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
import { AssetManager } from "$/client/assets.ts";
|
||||
import { get_sprite_region } from "$/client/sprites.ts";
|
||||
import type { ItemEntity } from "$/client/entity/item_entity.ts";
|
||||
import { push_vertex, Texture } from "$/client/renderer/mod.ts";
|
||||
|
||||
// how big items on the ground are drawn, minecraft's ground transform
|
||||
const BLOCK_SCALE = 0.25;
|
||||
const ITEM_SCALE = 0.5;
|
||||
// keeps texture lookups off the sprite's edge
|
||||
const UV_PAD = 0.5;
|
||||
|
||||
// each face's corners in drawing order (counter clockwise from outside) on a unit cube, with its shade.
|
||||
// top, bottom, front, back, left, right, like the chunk mesher
|
||||
const CUBE_FACES = [
|
||||
{ corners: [[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]], shade: 1.0, texture: "top" },
|
||||
{ corners: [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]], shade: 0.5, texture: "bottom" },
|
||||
{ corners: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]], shade: 0.8, texture: "front" },
|
||||
{ corners: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]], shade: 0.8, texture: "side" },
|
||||
{ corners: [[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]], shade: 0.6, texture: "side" },
|
||||
{ corners: [[1, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]], shade: 0.6, texture: "side" },
|
||||
] as const;
|
||||
// sprite end each corner gets, u then v
|
||||
const CORNER_UVS = [[0, 1], [1, 1], [1, 0], [0, 0]] as const;
|
||||
|
||||
// where the extra copies of a bigger stack sit, as fractions of the model's size
|
||||
const COPY_OFFSETS = [[0, 0, 0], [0.35, 0.2, -0.25], [-0.3, 0.4, 0.2], [0.15, 0.6, 0.35], [-0.2, 0.8, -0.3]];
|
||||
|
||||
// minecraft draws more copies of the model the bigger the stack is
|
||||
function copies(amount: number) {
|
||||
if (amount > 48) return 5;
|
||||
if (amount > 32) return 4;
|
||||
if (amount > 16) return 3;
|
||||
if (amount > 1) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// the atlas has to be the current texture
|
||||
export function render_item_entity(entity: ItemEntity, partial_tick: number) {
|
||||
const atlas = AssetManager.instance.get<Texture>("bworld:textures");
|
||||
const item_info = EverythingRegistry.get<ItemRegistry>("items", entity.item.type_id);
|
||||
const block_info = item_info?.block_id
|
||||
? EverythingRegistry.get<BlockRegistry>("blocks", item_info.block_id)
|
||||
: undefined;
|
||||
|
||||
// spinning and bobbing, like minecraft's ItemEntityRenderer
|
||||
const time = entity.age + partial_tick;
|
||||
const angle = time / 20 + entity.bob_offset;
|
||||
const bob = Math.sin(time / 10 + entity.bob_offset) * 0.1 + 0.1;
|
||||
const { x, y, z } = entity.render_position(partial_tick);
|
||||
|
||||
const scale = block_info ? BLOCK_SCALE : ITEM_SCALE;
|
||||
for (let i = 0; i < copies(entity.item.amount); i++) {
|
||||
const [ox, oy, oz] = COPY_OFFSETS[i];
|
||||
const base = { x: x + ox * scale, y: y + bob + oy * scale * 0.5, z: z + oz * scale };
|
||||
if (block_info) {
|
||||
push_block(atlas, block_info, base, scale, angle);
|
||||
} else {
|
||||
push_sprite(atlas, item_texture(entity, item_info), base, scale, angle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// a small cube turned around its middle
|
||||
function push_block(
|
||||
atlas: Texture,
|
||||
block: BlockRegistry,
|
||||
base: { x: number; y: number; z: number },
|
||||
size: number,
|
||||
angle: number,
|
||||
) {
|
||||
const sin = Math.sin(angle);
|
||||
const cos = Math.cos(angle);
|
||||
const corner = (cx: number, cy: number, cz: number) => {
|
||||
const lx = (cx - 0.5) * size;
|
||||
const lz = (cz - 0.5) * size;
|
||||
return [base.x + lx * cos - lz * sin, base.y + cy * size, base.z + lx * sin + lz * cos];
|
||||
};
|
||||
|
||||
for (const face of CUBE_FACES) {
|
||||
const region = get_sprite_region(block_face_texture(block, face.texture));
|
||||
push_textured_quad(atlas, region, face.corners.map(([cx, cy, cz]) => corner(cx, cy, cz)), face.shade);
|
||||
}
|
||||
}
|
||||
|
||||
// a flat sprite standing up and turning, drawn from both sides
|
||||
function push_sprite(
|
||||
atlas: Texture,
|
||||
texture_id: string,
|
||||
base: { x: number; y: number; z: number },
|
||||
size: number,
|
||||
angle: number,
|
||||
) {
|
||||
const region = get_sprite_region(texture_id);
|
||||
const dx = Math.cos(angle) * size / 2;
|
||||
const dz = Math.sin(angle) * size / 2;
|
||||
const bottom = base.y;
|
||||
const top = base.y + size;
|
||||
const front = [
|
||||
[base.x - dx, bottom, base.z - dz],
|
||||
[base.x + dx, bottom, base.z + dz],
|
||||
[base.x + dx, top, base.z + dz],
|
||||
[base.x - dx, top, base.z - dz],
|
||||
];
|
||||
push_textured_quad(atlas, region, front, 1);
|
||||
// the back is the same quad the other way round, mirrored so it isn't drawn backwards
|
||||
push_textured_quad(atlas, region, [front[1], front[0], front[3], front[2]], 1);
|
||||
}
|
||||
|
||||
function push_textured_quad(atlas: Texture, region: { x: number; y: number }, corners: number[][], shade: number) {
|
||||
const u0 = (region.x * TEXTURE_SIZE + UV_PAD) / atlas.width;
|
||||
const v0 = (region.y * TEXTURE_SIZE + UV_PAD) / atlas.height;
|
||||
const u1 = ((region.x + 1) * TEXTURE_SIZE - UV_PAD) / atlas.width;
|
||||
const v1 = ((region.y + 1) * TEXTURE_SIZE - UV_PAD) / atlas.height;
|
||||
|
||||
for (const i of [0, 1, 2, 0, 2, 3]) {
|
||||
const [px, py, pz] = corners[i];
|
||||
const [cu, cv] = CORNER_UVS[i];
|
||||
push_vertex(px, py, pz, cu ? u1 : u0, cv ? v1 : v0, shade, shade, shade, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function block_face_texture(block: BlockRegistry, face: "top" | "bottom" | "front" | "side") {
|
||||
const textures = block.textures;
|
||||
if (typeof textures === "string") {
|
||||
return textures;
|
||||
}
|
||||
if ("top" in textures) {
|
||||
return face === "top" ? textures.top : face === "bottom" ? textures.bottom : textures.side;
|
||||
}
|
||||
return face === "front" ? textures.front : textures.side;
|
||||
}
|
||||
|
||||
function item_texture(entity: ItemEntity, item_info: ItemRegistry | undefined) {
|
||||
const texture_id = item_info?.texture_id;
|
||||
if (typeof texture_id === "function") {
|
||||
return texture_id(entity.item);
|
||||
}
|
||||
return texture_id ?? "engine:missing";
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { TEXTURE_SIZE } from "$/common/constants.ts";
|
||||
import { CHUNK_SIZE, ClientLevel } from "$/client/level/client_level.ts";
|
||||
import { Camera } from "$/client/camera.ts";
|
||||
import { AssetManager } from "$/client/assets.ts";
|
||||
import { get_sprite_region } from "$/client/sprites.ts";
|
||||
import type { MultiPlayerGameMode } from "$/client/game_mode.ts";
|
||||
import type { Entity } from "$/client/entity/entity.ts";
|
||||
import { RemotePlayer } from "$/client/entity/remote_player.ts";
|
||||
import { ItemEntity } from "$/client/entity/item_entity.ts";
|
||||
import { render_item_entity } from "./item_renderer.ts";
|
||||
import {
|
||||
draw_terrain,
|
||||
flush_batch,
|
||||
push_back_face,
|
||||
push_bottom_face,
|
||||
push_box,
|
||||
push_front_face,
|
||||
push_left_face,
|
||||
push_right_face,
|
||||
push_top_face,
|
||||
set_current_texture,
|
||||
Texture,
|
||||
white_tex,
|
||||
} from "$/client/renderer/mod.ts";
|
||||
|
||||
const BREAKING_FACES = [
|
||||
push_back_face,
|
||||
push_bottom_face,
|
||||
push_front_face,
|
||||
push_left_face,
|
||||
push_right_face,
|
||||
push_top_face,
|
||||
];
|
||||
|
||||
// draws the level, like minecraft's LevelRenderer: terrain in layers, block breaking and entities
|
||||
export class LevelRenderer {
|
||||
// solid and cutout terrain, drawn before entities
|
||||
render_opaque(level: ClientLevel, camera: Camera) {
|
||||
level.request_meshes(camera);
|
||||
level.update_translucent_sorting(camera);
|
||||
|
||||
set_current_texture(level.image.tex);
|
||||
|
||||
for (const chunk of level.chunks.values()) {
|
||||
const mesh = chunk.meshes.solid;
|
||||
if (mesh) {
|
||||
draw_terrain("solid", mesh.vertex_buffer, mesh.quad_count);
|
||||
}
|
||||
}
|
||||
|
||||
for (const chunk of level.chunks.values()) {
|
||||
const mesh = chunk.meshes.cutout;
|
||||
if (mesh) {
|
||||
draw_terrain("cutout", mesh.vertex_buffer, mesh.quad_count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// translucent terrain, drawn after entities so they show through water and glass.
|
||||
// chunks go back to front, and each chunk's quads are already sorted back to front
|
||||
render_translucent(level: ClientLevel, camera: Camera) {
|
||||
const distance_sq = (x: number, z: number) => {
|
||||
const dx = (x + 0.5) * CHUNK_SIZE - camera.x;
|
||||
const dz = (z + 0.5) * CHUNK_SIZE - camera.z;
|
||||
return dx * dx + dz * dz;
|
||||
};
|
||||
const chunks = [...level.chunks.values()]
|
||||
.filter((chunk) => chunk.meshes.translucent)
|
||||
.map((chunk) => ({ mesh: chunk.meshes.translucent!, distance: distance_sq(chunk.x, chunk.z) }))
|
||||
.sort((a, b) => b.distance - a.distance);
|
||||
|
||||
set_current_texture(level.image.tex);
|
||||
|
||||
for (const { mesh } of chunks) {
|
||||
draw_terrain("translucent", mesh.vertex_buffer, mesh.quad_count, mesh.index_buffer);
|
||||
}
|
||||
}
|
||||
|
||||
// every entity but the one the camera is in
|
||||
render_entities(level: ClientLevel, camera_entity: Entity, partial_tick: number) {
|
||||
flush_batch();
|
||||
set_current_texture(white_tex!);
|
||||
|
||||
for (const entity of level.entities.values()) {
|
||||
if (entity !== camera_entity && entity instanceof RemotePlayer) {
|
||||
render_player(entity, partial_tick);
|
||||
}
|
||||
}
|
||||
flush_batch();
|
||||
|
||||
set_current_texture(level.image.tex);
|
||||
for (const entity of level.entities.values()) {
|
||||
if (entity instanceof ItemEntity) {
|
||||
render_item_entity(entity, partial_tick);
|
||||
}
|
||||
}
|
||||
flush_batch();
|
||||
}
|
||||
|
||||
// the cracks on the block being broken
|
||||
render_destroy_progress(game_mode: MultiPlayerGameMode) {
|
||||
const block = game_mode.destroy_pos;
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = Math.max(0, Math.min(1, game_mode.destroy_progress / game_mode.destroy_time));
|
||||
const stage = Math.round(progress * 8);
|
||||
if (Number.isNaN(stage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tex = AssetManager.instance.get<Texture>("bworld:textures");
|
||||
const region = get_sprite_region(`engine:break_${stage}`);
|
||||
for (const push_face of BREAKING_FACES) {
|
||||
push_face(
|
||||
tex,
|
||||
block.x,
|
||||
block.y,
|
||||
block.z,
|
||||
region.x * TEXTURE_SIZE,
|
||||
region.y * TEXTURE_SIZE,
|
||||
TEXTURE_SIZE,
|
||||
TEXTURE_SIZE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// a box body and head in the player's color
|
||||
function render_player(player: RemotePlayer, partial_tick: number) {
|
||||
const [r, g, b] = player.color;
|
||||
const { x, y, z } = player.render_position(partial_tick);
|
||||
|
||||
// body
|
||||
push_box(x - 0.3, y, z - 0.15, 0.6, 1.3, 0.3, r, g, b);
|
||||
// head
|
||||
push_box(x - 0.25, y + 1.3, z - 0.25, 0.5, 0.5, 0.5, 0.95, 0.8, 0.65);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
|
||||
import { get_sprite_region } from "$/common/utils.ts";
|
||||
import { get_sprite_region } from "$/client/sprites.ts";
|
||||
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
import { AssetManager } from "$/client/assets.ts";
|
||||
import { ItemStack } from "../../inventory.ts";
|
||||
import { ItemStack } from "$/common/inventory.ts";
|
||||
import { draw_text, draw_texture_region, draw_texture_region_skewed, Texture } from "$/client/renderer/mod.ts";
|
||||
|
||||
export function draw_nine_slice(
|
||||
@@ -231,7 +231,7 @@ export function draw_item(item: ItemStack, x: number, y: number) {
|
||||
|
||||
// ey its not a bad name
|
||||
function draw_item_item(item: ItemStack, item_info: ItemRegistry, x: number, y: number) {
|
||||
let texture_id = "bworld:missing";
|
||||
let texture_id = "engine:missing";
|
||||
if (typeof item_info?.texture_id === "string") {
|
||||
texture_id = item_info.texture_id;
|
||||
} else if (typeof item_info?.texture_id === "function") {
|
||||
@@ -256,9 +256,9 @@ function draw_item_block(_item: ItemStack, item_info: ItemRegistry, x: number, y
|
||||
|
||||
if (!block_info) throw new Error(`no textures for ${item_info.block_id}`);
|
||||
|
||||
let front_texture = "bworld:missing";
|
||||
let top_texture = "bworld:missing";
|
||||
let left_texture = "bworld:missing";
|
||||
let front_texture = "engine:missing";
|
||||
let top_texture = "engine:missing";
|
||||
let left_texture = "engine:missing";
|
||||
|
||||
const textures = block_info.textures;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SpriteRegion } from "$/common/constants.ts";
|
||||
import { AssetManager } from "./assets.ts";
|
||||
|
||||
type TexturesInfo = Record<string, SpriteRegion>;
|
||||
|
||||
export function get_sprite_region(id: string): SpriteRegion {
|
||||
const textures_info = AssetManager.instance.get<TexturesInfo>("bworld:textures_info");
|
||||
return textures_info?.[id] ?? { x: 0, y: 0 };
|
||||
}
|
||||
@@ -1,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,44 +0,0 @@
|
||||
import { System } from "$/common/ecs/mod.ts";
|
||||
import { ClientWorld } from "../client_world.ts";
|
||||
import { Dimension } from "../components/dimension.ts";
|
||||
import { TICK_DELTA } from "$/common/constants.ts";
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
export class DimensionLogicSystem extends System {
|
||||
update(world: ClientWorld, delta: number): void {
|
||||
const dimension = world.dimension;
|
||||
|
||||
dimension.second_timer += delta;
|
||||
dimension.tick_timer += delta;
|
||||
|
||||
if (dimension.second_timer >= 1) {
|
||||
this.handle_second(world, dimension);
|
||||
}
|
||||
if (dimension.tick_timer >= TICK_DELTA) {
|
||||
this.handle_tick(world, dimension);
|
||||
}
|
||||
}
|
||||
handle_second(world: ClientWorld, dimension: Dimension) {
|
||||
for (const chunk of dimension.chunks) {
|
||||
for (const tickable of chunk.blocks_data) {
|
||||
const tile_info = EverythingRegistry.get<BlockRegistry>("blocks", tickable.id);
|
||||
if (tile_info && tile_info.on_second) {
|
||||
tile_info.on_second(world.dimension, tickable, dimension.second_timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
dimension.second_timer = 0;
|
||||
}
|
||||
|
||||
handle_tick(world: ClientWorld, dimension: Dimension) {
|
||||
for (const chunk of dimension.chunks) {
|
||||
for (const tickable of chunk.blocks_data) {
|
||||
const tile_info = EverythingRegistry.get<BlockRegistry>("blocks", tickable.id);
|
||||
if (tile_info && tile_info.on_tick) {
|
||||
tile_info.on_tick(world.dimension, tickable, dimension.tick_timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
dimension.tick_timer = 0;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user