New modding system
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
// the texture atlas: the engine's textures and every mod's in one image, built by the client when it joins a server
|
||||
// from the textures in the server's .bmod files
|
||||
import { TEXTURE_SIZE } from "$/common/constants.ts";
|
||||
import type { SpriteRegion } from "$/common/constants.ts";
|
||||
|
||||
export interface AtlasLayout {
|
||||
// in pixels, a power of two
|
||||
size: number;
|
||||
// in sprites, by texture id. engine:missing is always at 0, 0
|
||||
regions: Record<string, SpriteRegion>;
|
||||
}
|
||||
|
||||
// engine:missing first, then every other texture sorted by id, row by row
|
||||
export function atlas_layout(ids: Iterable<string>): AtlasLayout {
|
||||
const sorted = [...new Set(ids)].filter((id) => id !== "engine:missing").sort((a, b) => a.localeCompare(b));
|
||||
const count = sorted.length + 1;
|
||||
const size = 2 ** Math.ceil(Math.log2(Math.ceil(Math.sqrt(count)) * TEXTURE_SIZE));
|
||||
const per_row = size / TEXTURE_SIZE;
|
||||
const regions: Record<string, SpriteRegion> = { "engine:missing": { x: 0, y: 0 } };
|
||||
sorted.forEach((id, i) => {
|
||||
regions[id] = { x: (i + 1) % per_row, y: Math.floor((i + 1) / per_row) };
|
||||
});
|
||||
return { size, regions };
|
||||
}
|
||||
|
||||
// textures are png bytes by id. ones that don't decode show as missing
|
||||
export async function build_atlas(textures: Map<string, Uint8Array>) {
|
||||
const { size, regions } = atlas_layout(textures.keys());
|
||||
const canvas = new OffscreenCanvas(size, size);
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
|
||||
// magenta and black checker
|
||||
ctx.fillStyle = "magenta";
|
||||
ctx.fillRect(0, 0, 8, 8);
|
||||
ctx.fillRect(8, 8, 8, 8);
|
||||
ctx.fillStyle = "black";
|
||||
ctx.fillRect(8, 0, 8, 8);
|
||||
ctx.fillRect(0, 8, 8, 8);
|
||||
|
||||
await Promise.all([...textures].map(async ([id, png]) => {
|
||||
const region = regions[id];
|
||||
try {
|
||||
const image = await createImageBitmap(new Blob([png as Uint8Array<ArrayBuffer>], { type: "image/png" }));
|
||||
ctx.drawImage(image, region.x * TEXTURE_SIZE, region.y * TEXTURE_SIZE);
|
||||
image.close();
|
||||
} catch {
|
||||
console.warn(`Texture ${id} isn't a png that can be shown, it will show as missing`);
|
||||
regions[id] = regions["engine:missing"];
|
||||
}
|
||||
}));
|
||||
|
||||
return { image: canvas.transferToImageBitmap(), regions };
|
||||
}
|
||||
|
||||
// the engine's own textures, which come with the client instead of a mod. see build_engine_textures in build.ts
|
||||
export async function engine_textures(): Promise<Map<string, Uint8Array>> {
|
||||
const names: string[] = await (await fetch("/assets/textures/index.json")).json();
|
||||
const textures = new Map<string, Uint8Array>();
|
||||
await Promise.all(names.map(async (name) => {
|
||||
const response = await fetch(`/assets/textures/${name}.png`);
|
||||
textures.set(`engine:${name}`, new Uint8Array(await response.arrayBuffer()));
|
||||
}));
|
||||
return textures;
|
||||
}
|
||||
+1
-13
@@ -1,6 +1,5 @@
|
||||
// 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;
|
||||
@@ -189,17 +188,6 @@ 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) {
|
||||
@@ -207,7 +195,7 @@ function trust_key(address: ServerAddress) {
|
||||
}
|
||||
|
||||
function mod_versions(welcome: Welcome) {
|
||||
return welcome.mods.map((mod) => `${mod.id}@${mod.hash}`).sort().join(",");
|
||||
return welcome.mods.map((mod) => `${mod.id}@${mod.sha256}`).sort().join(",");
|
||||
}
|
||||
|
||||
export function is_trusted(address: ServerAddress, welcome: Welcome): boolean {
|
||||
|
||||
+10
-4
@@ -2,7 +2,8 @@ import { AssetManager } from "./assets.ts";
|
||||
import { Client } from "./client.ts";
|
||||
import { InputManager } from "./input_manager.ts";
|
||||
import { Connection } from "./network.ts";
|
||||
import { connect, HandshakeError, is_trusted, join, load_atlas, remember_trust, ServerAddress } from "./handshake.ts";
|
||||
import { connect, HandshakeError, is_trusted, join, remember_trust, ServerAddress } from "./handshake.ts";
|
||||
import { build_atlas, engine_textures } from "./atlas.ts";
|
||||
import { confirm_mods } from "./confirm_mods.ts";
|
||||
import { ModLoadError } from "$/common/mod_loader.ts";
|
||||
import {
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
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 { download_mods, load_client_mods, set_mods_client } from "./mods.ts";
|
||||
import type { GuiScreen } from "./gui/gui_screen.ts";
|
||||
import { TitleScreen } from "./gui/title_screen.ts";
|
||||
import { DisconnectedScreen } from "./gui/disconnected_screen.ts";
|
||||
@@ -121,11 +122,16 @@ async function join_server(address: ServerAddress, name: string, status: (text:
|
||||
|
||||
// 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);
|
||||
const bmods = await download_mods(welcome.mods, address.base);
|
||||
const textures = await engine_textures();
|
||||
for (const bmod of bmods) {
|
||||
for (const [id, png] of bmod.textures) textures.set(id, png);
|
||||
}
|
||||
const atlas = await build_atlas(textures);
|
||||
loaded_mods = true;
|
||||
AssetManager.instance.assets["bworld:textures"] = load_texture(atlas.image);
|
||||
AssetManager.instance.assets["bworld:textures_info"] = atlas.regions;
|
||||
await load_client_mods(welcome.mods, address.base);
|
||||
await load_client_mods(welcome.mods, bmods);
|
||||
console.log(`Mods: ${welcome.mods.map((mod) => `${mod.id} ${mod.version}`).join(", ") || "none"}`);
|
||||
|
||||
status("Joining...");
|
||||
|
||||
+33
-32
@@ -1,9 +1,10 @@
|
||||
// loads the mods the server lists: registers their data, then runs their client scripts.
|
||||
// loads the mods the server lists: downloads their .bmod files, registers their data, then runs their client scripts.
|
||||
// see "Delivery to clients" in MODS.md
|
||||
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 { ModListing, ModLoadError, register_mod_data } from "$/common/mod_loader.ts";
|
||||
import { type Bmod, read_bmod } from "$/common/bmod.ts";
|
||||
import type { OreJson } from "$/common/mod_data.ts";
|
||||
import { AIR_ID } from "$/common/protocol.ts";
|
||||
import type { Client } from "./client.ts";
|
||||
@@ -22,50 +23,50 @@ 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 };
|
||||
// downloads every mod's .bmod and checks it against the hash the server listed. everything a mod runs comes from
|
||||
// the checked bytes, never fetched twice
|
||||
export async function download_mods(listings: ModListing[], base: URL): Promise<Bmod[]> {
|
||||
return await Promise.all(listings.map(async (listing) => {
|
||||
try {
|
||||
const bytes = await fetch_verified(new URL(listing.file, base), listing.sha256, `${listing.name}`);
|
||||
const bmod = read_bmod(bytes, `${listing.id}.bmod`);
|
||||
if (bmod.manifest.id !== listing.id) throw new Error(`the server listed it as ${listing.id}`);
|
||||
return bmod;
|
||||
} catch (e) {
|
||||
throw new ModLoadError(listing.id, (e as Error).message);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
for (const { listing, credits } of downloads) {
|
||||
if (credits) {
|
||||
mod_credits.push({ name: listing.name, version: listing.version, text: new TextDecoder().decode(credits) });
|
||||
// registers the mods' data and runs their client scripts, in the order the server listed them
|
||||
export async function load_client_mods(listings: ModListing[], bmods: Bmod[]) {
|
||||
for (const [i, bmod] of bmods.entries()) {
|
||||
if (bmod.credits) {
|
||||
mod_credits.push({ name: listings[i].name, version: listings[i].version, text: bmod.credits });
|
||||
}
|
||||
}
|
||||
|
||||
const recipes = register_mod_data(downloads.map(({ listing, data }) => ({ id: listing.id, data })));
|
||||
const recipes = register_mod_data(bmods.map((bmod) => ({ id: bmod.manifest.id, data: bmod.data })));
|
||||
|
||||
worldgen_mods.ores = recipes.ores;
|
||||
worldgen_mods.scripts = downloads.flatMap(({ listing, worldgen }) =>
|
||||
worldgen ? [{ mod: listing.id, url: code_url(worldgen) }] : []
|
||||
worldgen_mods.scripts = bmods.flatMap((bmod) =>
|
||||
bmod.scripts.worldgen ? [{ mod: bmod.manifest.id, url: script_url(bmod.scripts.worldgen) }] : []
|
||||
);
|
||||
|
||||
for (const { listing, client } of downloads) {
|
||||
if (!client) continue;
|
||||
const module = await import(code_url(client));
|
||||
for (const [i, bmod] of bmods.entries()) {
|
||||
if (!bmod.scripts.client) continue;
|
||||
const module = await import(script_url(bmod.scripts.client));
|
||||
if (typeof module.setup !== "function") {
|
||||
throw new ModLoadError(listing.id, "the client script doesn't export a setup function");
|
||||
throw new ModLoadError(bmod.manifest.id, "the client script doesn't export a setup function");
|
||||
}
|
||||
await module.setup(client_context(listing));
|
||||
await module.setup(client_context(listings[i]));
|
||||
}
|
||||
}
|
||||
|
||||
function script_url(code: string) {
|
||||
return code_url(new TextEncoder().encode(code));
|
||||
}
|
||||
|
||||
function client_context(listing: ModListing): ClientContext {
|
||||
const mod = listing.id;
|
||||
const not_yet = (name: string, where: string) =>
|
||||
|
||||
Reference in New Issue
Block a user