diff --git a/MODS.md b/MODS.md index f0f0049..3181be1 100644 --- a/MODS.md +++ b/MODS.md @@ -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 diff --git a/client/client.ts b/client/client.ts index 4f17ed9..feb8e6d 100644 --- a/client/client.ts +++ b/client/client.ts @@ -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; diff --git a/client/gui/disconnected_screen.ts b/client/gui/disconnected_screen.ts new file mode 100644 index 0000000..65c72bc --- /dev/null +++ b/client/gui/disconnected_screen.ts @@ -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); + } +} diff --git a/client/gui/gui_chat.ts b/client/gui/gui_chat.ts index 25a3f45..7878acf 100644 --- a/client/gui/gui_chat.ts +++ b/client/gui/gui_chat.ts @@ -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(); } } diff --git a/client/gui/pause_screen.ts b/client/gui/pause_screen.ts new file mode 100644 index 0000000..2b39cc5 --- /dev/null +++ b/client/gui/pause_screen.ts @@ -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 {} +} diff --git a/client/gui/title_screen.ts b/client/gui/title_screen.ts new file mode 100644 index 0000000..a411622 --- /dev/null +++ b/client/gui/title_screen.ts @@ -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; + +// 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; +} diff --git a/client/gui/widgets.ts b/client/gui/widgets.ts new file mode 100644 index 0000000..1c6defd --- /dev/null +++ b/client/gui/widgets.ts @@ -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("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("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); + } + } +} diff --git a/client/handshake.ts b/client/handshake.ts index 9c2eea2..e8c48e0 100644 --- a/client/handshake.ts +++ b/client/handshake.ts @@ -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`, diff --git a/client/input_manager.ts b/client/input_manager.ts index e80c8c7..8fd0914 100644 --- a/client/input_manager.ts +++ b/client/input_manager.ts @@ -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; } diff --git a/client/main.ts b/client/main.ts index a82d688..eead047 100644 --- a/client/main.ts +++ b/client/main.ts @@ -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 { + 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 { - 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(); diff --git a/client/network.ts b/client/network.ts index 903cc9f..443fe7c 100644 --- a/client/network.ts +++ b/client/network.ts @@ -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") ?? ""; }