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
+33 -10
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")) {
this.pop_screen();
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;
}
+114 -59
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);
this.client.run_frame(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;
@@ -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}`);
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}`);
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;
}
remember_trust(address, welcome);
throw e;
}
// 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);
} catch (e) {
socket.close();
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.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);
}
loop.play(connection);
}),
);
loop.start();
+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") ?? "";
}