Main menu

This commit is contained in:
2026-09-25 16:51:23 -03:00
parent d107405f62
commit c750321ced
11 changed files with 646 additions and 152 deletions
+4 -3
View File
@@ -879,9 +879,10 @@ where the page came from:
- **Page served by the game server** (the default): that server already chose every line of JavaScript on the page, so
running its mods doesn't trust it any more than loading the page did.
- **Page from one origin, game server on another** (`?server=`): mod code from that server runs with _the page's_
origin, including its local storage. The client shows the server's mod list and asks before loading anything, and
remembers the answer per server and mod hash. The server needs CORS headers on `build/mods/`.
- **Page from one origin, game server on another** (a different server typed in the title screen, or `?server=`): mod
code from that server runs with _the page's_ origin, including its local storage. The client shows the server's mod
list and asks before loading anything, and remembers the answer per server and mod hash. The server needs CORS
headers on `build/mods/`.
- The hash check confirms the files are the ones the server listed. It doesn't protect against a malicious server.
**Server scripts.** They run in a worker with **no Deno permissions** (Deno worker permissions; currently needs
+32 -9
View File
@@ -14,8 +14,8 @@ 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 { canvas, resize_canvas } from "./renderer/mod.ts";
import { show_fatal_error } from "./fatal.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
@@ -45,13 +45,12 @@ export class Client {
// 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) {
constructor(connection: Connection, on_disconnect: (message: string) => void) {
this.connection = connection;
self.addEventListener("resize", resize_canvas);
resize_canvas();
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
this.#on_disconnect = on_disconnect;
this.level = new ClientLevel(connection.seed);
for (const [x, y, z, id, state] of connection.initial_changes) {
@@ -88,8 +87,24 @@ export class Client {
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();
@@ -114,8 +129,8 @@ export class Client {
tick() {
this.packet_listener.handle_packets();
if (this.connection.closed) {
show_fatal_error("Lost connection to the server");
this.#stopped = true;
this.#stop();
this.#on_disconnect("Lost connection to the server");
return;
}
@@ -144,8 +159,16 @@ export class Client {
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;
+37
View File
@@ -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);
}
}
+9 -75
View File
@@ -1,16 +1,13 @@
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 type { Client } from "../client.ts";
import { MAX_CHAT_LENGTH } from "$/common/protocol.ts";
import { TextInput } from "./widgets.ts";
export class GuiChat extends GuiScreen {
client: Client;
text_typed = "";
caret = 0;
key_repeat_timer = 0;
show_caret = true;
input = new TextInput("", MAX_CHAT_LENGTH);
constructor(client: Client) {
super();
@@ -24,87 +21,24 @@ export class GuiChat extends GuiScreen {
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) {
if (this.text_typed.length >= MAX_CHAT_LENGTH) {
break;
}
this.text_typed = this.text_typed.slice(0, this.caret) + char + this.text_typed.slice(this.caret);
this.caret += 1;
}
this.input.handle_keys();
if (InputManager.is_key_pressed("Enter")) {
this.submit();
}
}
override on_close(): void {}
submit() {
// commands like /give run on the server too
if (this.text_typed.trim().length > 0) {
this.client.connection.send({ type: "chat", text: this.text_typed });
if (this.input.value.trim().length > 0) {
this.client.connection.send({ type: "chat", text: this.input.value });
}
this.text_typed = "";
this.client.pop_screen();
}
}
+46
View File
@@ -0,0 +1,46 @@
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";
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("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 {}
}
+181
View File
@@ -0,0 +1,181 @@
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 { 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());
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();
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();
}
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();
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;
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]);
}
}
on_close(): void {}
#layout() {
const x = (canvas.width - WIDTH) / 2;
const total = 2 * (LABEL_GAP + ROW_HEIGHT + GAP) + ROW_HEIGHT;
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;
}
#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;
}
+182
View File
@@ -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);
}
}
}
+24 -4
View File
@@ -20,10 +20,30 @@ export interface ServerAddress {
// something went wrong in a way the player should see
export class HandshakeError extends Error {}
export function server_address(page = new URL(location.href)): ServerAddress {
// ?server=host:port, otherwise the server that served the page
const host = page.searchParams.get("server") ?? page.host;
const secure = page.protocol === "https:";
// 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`,
+10
View File
@@ -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;
}
+110 -55
View File
@@ -1,26 +1,36 @@
import { AssetManager } from "./assets.ts";
import { Client } from "./client.ts";
import { InputManager } from "./input_manager.ts";
import { Connection, get_player_name } from "./network.ts";
import { connect, HandshakeError, is_trusted, join, load_atlas, remember_trust, server_address } from "./handshake.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, clear_background, end_drawing, init_font, init_window, load_texture } from "./renderer/mod.ts";
import {
begin_drawing,
canvas,
clear_background,
end_drawing,
init_font,
init_window,
load_texture,
resize_canvas,
} from "./renderer/mod.ts";
import { is_stopped, show_fatal_error } from "./fatal.ts";
import { 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;
client: Client;
client: Client | undefined;
screen: GuiScreen | undefined;
frame_count = 0;
last_fps_time = 0;
constructor(client: Client) {
this.client = client;
}
start() {
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
@@ -40,6 +50,21 @@ export class ClientLoop {
this.running = false;
}
show_screen(screen: GuiScreen) {
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 = undefined;
this.client = client;
console.log("Game started");
}
loop(time: number) {
if (!this.running || is_stopped()) {
return;
@@ -49,7 +74,12 @@ export class ClientLoop {
begin_drawing();
clear_background(0.69, 0.8, 1, 1.0);
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;
@@ -68,14 +98,70 @@ 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");
}
await init_window(canvas);
try {
await init_window(game_canvas);
} catch (e) {
show_fatal_error(e instanceof Error ? e.message : String(e));
throw e;
}
InputManager.initialize(canvas);
InputManager.initialize(game_canvas);
self.addEventListener("resize", resize_canvas);
resize_canvas();
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
AssetManager.instance.load("bworld:assets_text", "/assets/ASSETS.md");
@@ -90,51 +176,20 @@ await AssetManager.instance.load_all();
init_font();
// 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(): Promise<Connection> {
const address = server_address();
const { socket, welcome } = await connect(address, get_player_name());
console.log(`Connected to ${address.ws_url}`);
const loop = new ClientLoop();
loop.show_screen(
new TitleScreen(async (address, name, status) => {
let connection: Connection;
try {
if (address.cross_origin && !is_trusted(address, welcome)) {
if (!await confirm_mods(address, welcome)) {
throw new HandshakeError(`You didn't join ${address.base.host}`);
}
remember_trust(address, welcome);
}
// textures, blocks and items all come from the server's mods, so this happens before anything else
const atlas = await load_atlas(address, welcome.atlas);
AssetManager.instance.assets["bworld:textures"] = load_texture(atlas.image);
AssetManager.instance.assets["bworld:textures_info"] = atlas.regions;
await load_client_mods(welcome.mods, address.base);
console.log(`Mods: ${welcome.mods.map((mod) => `${mod.id} ${mod.version}`).join(", ") || "none"}`);
const joined = await join(socket);
return new Connection(socket, welcome, joined);
connection = await join_server(address, name, status);
} catch (e) {
socket.close();
if (e instanceof FailedAfterLoadingMods) {
loop.show_screen(new DisconnectedScreen(e.message));
return;
}
throw e;
}
}
try {
const connection = await join_server();
const client = new Client(connection);
set_mods_client(client);
client.chat.add("Connected to the server");
const loop = new ClientLoop(client);
loop.play(connection);
}),
);
loop.start();
console.log("Game started");
} catch (e) {
console.error(e);
const message = e instanceof HandshakeError
? e.message
: e instanceof ModLoadError
? `Couldn't load the server's mods: ${e.message}`
: `Something went wrong joining the server: ${e instanceof Error ? e.message : e}`;
show_fatal_error(message);
}
+6 -1
View File
@@ -40,8 +40,13 @@ export class Connection {
send(message: ClientMessage) {
this.#server.send(message);
}
close() {
this.#server.close();
}
}
export function get_player_name(): string {
// what the name field starts as, from ?name=
export function default_player_name(): string {
return new URLSearchParams(location.search).get("name") ?? "";
}