Credits page

This commit is contained in:
2026-09-25 17:52:59 -03:00
parent e6f5db8a89
commit a469202959
9 changed files with 793 additions and 11 deletions
+93
View File
@@ -0,0 +1,93 @@
import { Marked } from "marked";
import { AssetManager } from "$/client/assets.ts";
import { mod_credits } from "$/client/mods.ts";
import { canvas, draw_rect } from "$/client/renderer/mod.ts";
import { GuiScreen } from "./gui_screen.ts";
// credits come from servers' mods, so markdown only: raw html is shown as text and links can't run code
const markdown = new Marked({
renderer: {
html({ text }) {
return escape_html(text);
},
link({ href, title, tokens }) {
const text = this.parser.parseInline(tokens);
if (!/^(https?:|mailto:)/i.test(href)) {
return text;
}
const title_attribute = title ? ` title="${escape_html(title)}"` : "";
return `<a href="${
escape_html(href)
}"${title_attribute} target="_blank" rel="noopener noreferrer">${text}</a>`;
},
},
});
// who made what: the engine's assets/ASSETS.md, then each loaded mod's credits file. shown as a page over the
// game since licenses are long, the game underneath keeps drawing
export class CreditsScreen extends GuiScreen {
#overlay: HTMLElement;
constructor(on_back: () => void) {
super();
this.#overlay = document.createElement("div");
this.#overlay.style.cssText = "position:fixed;inset:0;display:flex;justify-content:center;padding:32px 16px;" +
"box-sizing:border-box;color:#e5e7eb;font:15px/1.6 system-ui,sans-serif;";
const panel = document.createElement("div");
panel.style.cssText = "width:100%;max-width:760px;display:flex;flex-direction:column;gap:12px;" +
"background:rgba(17,24,39,0.95);border-radius:8px;padding:20px 24px;box-sizing:border-box;";
const header = document.createElement("div");
header.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:12px;";
const title = document.createElement("div");
title.textContent = "Credits";
title.style.cssText = "font-size:24px;font-weight:600;";
const back = document.createElement("button");
back.textContent = "Back";
back.style.cssText = "font:inherit;padding:6px 20px;cursor:pointer;";
back.addEventListener("click", on_back);
header.append(title, back);
const content = document.createElement("div");
content.style.cssText = "overflow-y:auto;flex:1;min-height:0;padding-right:8px;";
content.innerHTML = this.#sections().map(({ heading, text }) =>
`<section><h2 style="border-bottom:1px solid #374151;padding-bottom:4px">${escape_html(heading)}</h2>` +
`${markdown.parse(text, { async: false })}</section>`
).join("");
for (const pre of content.querySelectorAll("pre")) {
pre.style.cssText =
"white-space:pre-wrap;background:#0b1220;padding:12px;border-radius:6px;font-size:12px;";
}
for (const link of content.querySelectorAll("a")) {
link.style.color = "#60a5fa";
}
panel.append(header, content);
this.#overlay.append(panel);
document.body.append(this.#overlay);
}
#sections() {
const sections = [{ heading: "bworld", text: AssetManager.instance.get<string>("bworld:assets_text") ?? "" }];
for (const mod of mod_credits) {
sections.push({ heading: `${mod.name} ${mod.version}`, text: mod.text });
}
return sections;
}
on_tick(_delta: number): void {}
on_render(): void {
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.4]);
}
on_close(): void {
this.#overlay.remove();
}
}
function escape_html(text: string) {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
+4
View File
@@ -2,6 +2,7 @@ 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;
@@ -15,6 +16,9 @@ export class PauseScreen extends GuiScreen {
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()),
];
}
+32 -4
View File
@@ -4,6 +4,7 @@ import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mo
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";
@@ -32,6 +33,9 @@ export class TitleScreen extends GuiScreen {
);
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;
@@ -47,8 +51,16 @@ export class TitleScreen extends GuiScreen {
}
}
on_tick(_delta: number): void {
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) {
@@ -64,6 +76,7 @@ export class TitleScreen extends GuiScreen {
this.#join();
}
this.join_button.handle_input();
this.credits_button.handle_input();
}
on_render(): void {
@@ -81,21 +94,34 @@ export class TitleScreen extends GuiScreen {
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.join_button.y + ROW_HEIGHT + GAP;
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 {}
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) + ROW_HEIGHT;
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;
@@ -104,6 +130,8 @@ export class TitleScreen extends GuiScreen {
}
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) {
+2
View File
@@ -51,6 +51,7 @@ export class ClientLoop {
}
show_screen(screen: GuiScreen) {
this.screen?.on_close();
this.client = undefined;
this.screen = screen;
InputManager.set_mouse_grabbed(false);
@@ -60,6 +61,7 @@ export class ClientLoop {
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");
+12 -2
View File
@@ -9,6 +9,9 @@ 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: [] };
@@ -31,14 +34,21 @@ export async function load_client_mods(listings: ModListing[], base: URL) {
throw new ModLoadError(listing.id, (e as Error).message);
}
};
const [data, client, worldgen] = await Promise.all([
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 };
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;