Main menu
This commit is contained in:
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user