Main menu
This commit is contained in:
@@ -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
|
- **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.
|
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_
|
- **Page from one origin, game server on another** (a different server typed in the title screen, or `?server=`): mod
|
||||||
origin, including its local storage. The client shows the server's mod list and asks before loading anything, and
|
code from that server runs with _the page's_ origin, including its local storage. The client shows the server's mod
|
||||||
remembers the answer per server and mod hash. The server needs CORS headers on `build/mods/`.
|
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.
|
- 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
|
**Server scripts.** They run in a worker with **no Deno permissions** (Deno worker permissions; currently needs
|
||||||
|
|||||||
+33
-10
@@ -14,8 +14,8 @@ import { GuiPlayerInventory } from "./gui/gui_player_inventory.ts";
|
|||||||
import { GuiChat } from "./gui/gui_chat.ts";
|
import { GuiChat } from "./gui/gui_chat.ts";
|
||||||
import { InputManager } from "./input_manager.ts";
|
import { InputManager } from "./input_manager.ts";
|
||||||
import { Connection } from "./network.ts";
|
import { Connection } from "./network.ts";
|
||||||
import { canvas, resize_canvas } from "./renderer/mod.ts";
|
import { PauseScreen } from "./gui/pause_screen.ts";
|
||||||
import { show_fatal_error } from "./fatal.ts";
|
import { back_to_title } from "./gui/title_screen.ts";
|
||||||
import { TICK_DELTA } from "$/common/constants.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
|
// 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
|
// whether the attack button is held on a block, breaking it advances every tick
|
||||||
#attacking = false;
|
#attacking = false;
|
||||||
#stopped = 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;
|
this.connection = connection;
|
||||||
|
this.#on_disconnect = on_disconnect;
|
||||||
self.addEventListener("resize", resize_canvas);
|
|
||||||
resize_canvas();
|
|
||||||
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
|
|
||||||
|
|
||||||
this.level = new ClientLevel(connection.seed);
|
this.level = new ClientLevel(connection.seed);
|
||||||
for (const [x, y, z, id, state] of connection.initial_changes) {
|
for (const [x, y, z, id, state] of connection.initial_changes) {
|
||||||
@@ -88,8 +87,24 @@ export class Client {
|
|||||||
this.screens.pop()?.on_close();
|
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
|
// one frame: input, as many ticks as are due, then drawing between the last tick and the next
|
||||||
run_frame(delta: number) {
|
run_frame(delta: number) {
|
||||||
|
if (this.#stopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.screen?.on_tick(delta);
|
this.screen?.on_tick(delta);
|
||||||
this.#handle_keybinds();
|
this.#handle_keybinds();
|
||||||
|
|
||||||
@@ -114,8 +129,8 @@ export class Client {
|
|||||||
tick() {
|
tick() {
|
||||||
this.packet_listener.handle_packets();
|
this.packet_listener.handle_packets();
|
||||||
if (this.connection.closed) {
|
if (this.connection.closed) {
|
||||||
show_fatal_error("Lost connection to the server");
|
this.#stop();
|
||||||
this.#stopped = true;
|
this.#on_disconnect("Lost connection to the server");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,8 +159,16 @@ export class Client {
|
|||||||
if (InputManager.is_key_pressed(options.key_chat) && !this.screen) {
|
if (InputManager.is_key_pressed(options.key_chat) && !this.screen) {
|
||||||
this.push_screen(new GuiChat(this));
|
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")) {
|
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)) {
|
if (InputManager.is_key_pressed(options.key_debug)) {
|
||||||
this.debugging = !this.debugging;
|
this.debugging = !this.debugging;
|
||||||
|
|||||||
@@ -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 { 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 { InputManager } from "../input_manager.ts";
|
||||||
import type { Client } from "../client.ts";
|
import type { Client } from "../client.ts";
|
||||||
import { MAX_CHAT_LENGTH } from "$/common/protocol.ts";
|
import { MAX_CHAT_LENGTH } from "$/common/protocol.ts";
|
||||||
|
import { TextInput } from "./widgets.ts";
|
||||||
|
|
||||||
export class GuiChat extends GuiScreen {
|
export class GuiChat extends GuiScreen {
|
||||||
client: Client;
|
client: Client;
|
||||||
|
input = new TextInput("", MAX_CHAT_LENGTH);
|
||||||
text_typed = "";
|
|
||||||
caret = 0;
|
|
||||||
key_repeat_timer = 0;
|
|
||||||
show_caret = true;
|
|
||||||
|
|
||||||
constructor(client: Client) {
|
constructor(client: Client) {
|
||||||
super();
|
super();
|
||||||
@@ -24,87 +21,24 @@ export class GuiChat extends GuiScreen {
|
|||||||
this.client.chat.render(true, y - 8);
|
this.client.chat.render(true, y - 8);
|
||||||
draw_rect(0, y, canvas.width, canvas.height, [0, 0, 0, 0.4]);
|
draw_rect(0, y, canvas.width, canvas.height, [0, 0, 0, 0.4]);
|
||||||
|
|
||||||
draw_text(this.text_typed, 0, y, 2, [1, 1, 1]);
|
draw_text(this.input.value, 0, y, 2, [1, 1, 1]);
|
||||||
|
draw_rect(this.input.caret_x(0, 2), y + 4, 1, 32 - 4);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override on_tick(_delta: number): void {
|
override on_tick(_delta: number): void {
|
||||||
const now = performance.now();
|
this.input.handle_keys();
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (InputManager.is_key_pressed("Enter")) {
|
if (InputManager.is_key_pressed("Enter")) {
|
||||||
this.submit();
|
this.submit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override on_close(): void {}
|
override on_close(): void {}
|
||||||
|
|
||||||
submit() {
|
submit() {
|
||||||
// commands like /give run on the server too
|
// commands like /give run on the server too
|
||||||
if (this.text_typed.trim().length > 0) {
|
if (this.input.value.trim().length > 0) {
|
||||||
this.client.connection.send({ type: "chat", text: this.text_typed });
|
this.client.connection.send({ type: "chat", text: this.input.value });
|
||||||
}
|
}
|
||||||
this.text_typed = "";
|
|
||||||
|
|
||||||
this.client.pop_screen();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-4
@@ -20,10 +20,30 @@ export interface ServerAddress {
|
|||||||
// something went wrong in a way the player should see
|
// something went wrong in a way the player should see
|
||||||
export class HandshakeError extends Error {}
|
export class HandshakeError extends Error {}
|
||||||
|
|
||||||
export function server_address(page = new URL(location.href)): ServerAddress {
|
// what the server field starts as: ?server=host:port, otherwise the server that served the page
|
||||||
// ?server=host:port, otherwise the server that served the page
|
export function default_server(page = new URL(location.href)): string {
|
||||||
const host = page.searchParams.get("server") ?? page.host;
|
return page.searchParams.get("server") ?? page.host;
|
||||||
const secure = page.protocol === "https:";
|
}
|
||||||
|
|
||||||
|
// 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}/`);
|
const base = new URL(`${secure ? "https" : "http"}://${host}/`);
|
||||||
return {
|
return {
|
||||||
ws_url: `${secure ? "wss" : "ws"}://${host}/ws`,
|
ws_url: `${secure ? "wss" : "ws"}://${host}/ws`,
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ export class InputManager {
|
|||||||
static mouse_ungrab_timer = 0;
|
static mouse_ungrab_timer = 0;
|
||||||
static mouse_ungrab_timeout = -1;
|
static mouse_ungrab_timeout = -1;
|
||||||
static pointer_lock_waiting = false;
|
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) {
|
static initialize(canvas: HTMLCanvasElement) {
|
||||||
self.addEventListener("keydown", (e) => {
|
self.addEventListener("keydown", (e) => {
|
||||||
@@ -164,6 +166,7 @@ export class InputManager {
|
|||||||
if (!grab) {
|
if (!grab) {
|
||||||
if (this.pointer_lock_flag) {
|
if (this.pointer_lock_flag) {
|
||||||
this.mouse_ungrab_timer = performance.now();
|
this.mouse_ungrab_timer = performance.now();
|
||||||
|
this.#lost_pointer_lock = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.pointer_lock_flag = grab;
|
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() {
|
static is_mouse_grabbed() {
|
||||||
return document.hasFocus() && document.pointerLockElement === canvas;
|
return document.hasFocus() && document.pointerLockElement === canvas;
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-59
@@ -1,26 +1,36 @@
|
|||||||
import { AssetManager } from "./assets.ts";
|
import { AssetManager } from "./assets.ts";
|
||||||
import { Client } from "./client.ts";
|
import { Client } from "./client.ts";
|
||||||
import { InputManager } from "./input_manager.ts";
|
import { InputManager } from "./input_manager.ts";
|
||||||
import { Connection, get_player_name } from "./network.ts";
|
import { Connection } from "./network.ts";
|
||||||
import { connect, HandshakeError, is_trusted, join, load_atlas, remember_trust, server_address } from "./handshake.ts";
|
import { connect, HandshakeError, is_trusted, join, load_atlas, remember_trust, ServerAddress } from "./handshake.ts";
|
||||||
import { confirm_mods } from "./confirm_mods.ts";
|
import { confirm_mods } from "./confirm_mods.ts";
|
||||||
import { ModLoadError } from "$/common/mod_loader.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 { is_stopped, show_fatal_error } from "./fatal.ts";
|
||||||
import { load_client_mods, set_mods_client } from "./mods.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 {
|
export class ClientLoop {
|
||||||
running = false;
|
running = false;
|
||||||
last_time = 0;
|
last_time = 0;
|
||||||
client: Client;
|
client: Client | undefined;
|
||||||
|
screen: GuiScreen | undefined;
|
||||||
|
|
||||||
frame_count = 0;
|
frame_count = 0;
|
||||||
last_fps_time = 0;
|
last_fps_time = 0;
|
||||||
|
|
||||||
constructor(client: Client) {
|
|
||||||
this.client = client;
|
|
||||||
}
|
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
@@ -40,6 +50,21 @@ export class ClientLoop {
|
|||||||
this.running = false;
|
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) {
|
loop(time: number) {
|
||||||
if (!this.running || is_stopped()) {
|
if (!this.running || is_stopped()) {
|
||||||
return;
|
return;
|
||||||
@@ -49,7 +74,12 @@ export class ClientLoop {
|
|||||||
|
|
||||||
begin_drawing();
|
begin_drawing();
|
||||||
clear_background(0.69, 0.8, 1, 1.0);
|
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();
|
end_drawing();
|
||||||
|
|
||||||
this.frame_count += 1;
|
this.frame_count += 1;
|
||||||
@@ -68,14 +98,70 @@ export class ClientLoop {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvas = document.getElementById("game") as HTMLCanvasElement;
|
// a server's blocks, items and scripts can't be taken back out once loaded, so a failure after that point
|
||||||
if (!canvas) {
|
// 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");
|
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");
|
AssetManager.instance.load("bworld:assets_text", "/assets/ASSETS.md");
|
||||||
|
|
||||||
@@ -90,51 +176,20 @@ await AssetManager.instance.load_all();
|
|||||||
|
|
||||||
init_font();
|
init_font();
|
||||||
|
|
||||||
// the game only runs against a server, it owns the world and everything in it. see "Delivery to clients" in MODS.md
|
const loop = new ClientLoop();
|
||||||
async function join_server(): Promise<Connection> {
|
loop.show_screen(
|
||||||
const address = server_address();
|
new TitleScreen(async (address, name, status) => {
|
||||||
const { socket, welcome } = await connect(address, get_player_name());
|
let connection: Connection;
|
||||||
console.log(`Connected to ${address.ws_url}`);
|
try {
|
||||||
|
connection = await join_server(address, name, status);
|
||||||
try {
|
} catch (e) {
|
||||||
if (address.cross_origin && !is_trusted(address, welcome)) {
|
if (e instanceof FailedAfterLoadingMods) {
|
||||||
if (!await confirm_mods(address, welcome)) {
|
loop.show_screen(new DisconnectedScreen(e.message));
|
||||||
throw new HandshakeError(`You didn't join ${address.base.host}`);
|
return;
|
||||||
}
|
}
|
||||||
remember_trust(address, welcome);
|
throw e;
|
||||||
}
|
}
|
||||||
|
loop.play(connection);
|
||||||
// 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);
|
loop.start();
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|||||||
+6
-1
@@ -40,8 +40,13 @@ export class Connection {
|
|||||||
send(message: ClientMessage) {
|
send(message: ClientMessage) {
|
||||||
this.#server.send(message);
|
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") ?? "";
|
return new URLSearchParams(location.search).get("name") ?? "";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user