182 lines
5.3 KiB
TypeScript
182 lines
5.3 KiB
TypeScript
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;
|
|
}
|