Credits page
This commit is contained in:
@@ -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, """);
|
||||
}
|
||||
@@ -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()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user