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 { CreditsScreen } from "./credits_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()); 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; #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(); 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) { 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(); this.credits_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(); this.credits_button.render(); if (this.status) { const width = measure_text(this.status, TEXT_SCALE); const x = (canvas.width - width) / 2; 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 { 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) + 2 * ROW_HEIGHT + GAP; 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; this.credits_button.x = x; this.credits_button.y = y + ROW_HEIGHT + GAP; } #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; }