diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 243e036..fe4ca18 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,13 +1,16 @@
# How to contribute
## Before contributing, keep in mind:
+
- This app is still in **early development stage**
- Somewhat big changes may be done without notice, as the code grows and needs to be reorganized
- Features may be added in no specific order
- For those reasons, the existing issues are not organized in time, and may have hidden dependencies with other issues
## Working on an issue
+
To avoid conflicts in this stage, and to avoid frustration or wasting time:
+
- Comment on the issues you want to work on, and wait for confirmation. Ask for specific details, as issues are not explained in depth.
It _may or may not_ be planned to be done yet, and it _may_ be just an idea without real product definition
- Work on it and submit a PR. If there are visual changes, consider adding a screenshot
@@ -19,15 +22,18 @@ To avoid conflicts in this stage, and to avoid frustration or wasting time:
>
🕒 We are all human, and we all make mistakes. This is just a reminder to avoid opening low-effort PRs that waste collaborators time.
### Understanding issues: Labels
+
There are some sets of labels you will see in the issues, usually one of each:
+
- `:`: Labels that define which components are mainly related with the issue
- `!`: Labels that define whether the issue can be assigned, if it's not ready yet, if it's a private issue, etc.
If an issue lacks this label, use your best judgement. It may have been forgotten, or it may be too convoluted to define.
- ``: Labels that define the kind of issue (bug, enhancement...). There may be other unrelated topics too
## How to report a bug
+
The short answer is: You don't! Unless it's some kind of uncaught exception you found in there.
-In which case feel free to open an issue, and tag it with the "bug" label, with the full stack trace, an explanation of what happen and, if possible, a reproduction method.
+In which case feel free to open an issue, and tag it with the "bug" label, with the full stack trace, an explanation of what happen and, if possible, a reproduction method.
As the project is still in an early stage, there will be multiple bugs. Some will have an issue open, and some won't.
Unfortunately, the project evolves at a relatively slow pace, and some weird interactions may be _allowed_.
diff --git a/client/index.html b/client/index.html
index 5a868d1..0e264aa 100644
--- a/client/index.html
+++ b/client/index.html
@@ -4,39 +4,46 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
Hammerfight.io
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
- event.preventDefault();
-
- const username = usernameInput.value;
- const inputHandlerId = inputModeInput.value as InputHandlerId;
- const weapon = weaponInput.value as WeaponType;
- const bots = botsInput.checked;
- const debugMode = debugModeInput.checked;
-
- joinRoom(username, inputHandlerId, weapon, bots, debugMode);
- });
+ runStateMachine(new FormUiState()).catch((error: unknown) => {
+ console.error("Error running UI state machine", error);
+ });
}
window.addEventListener("DOMContentLoaded", main);
diff --git a/client/socket-io.ts b/client/socket-io.ts
deleted file mode 100644
index 46a3508..0000000
--- a/client/socket-io.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { io, type Socket } from "socket.io-client";
-import { assert } from "../common/errors";
-import type {
- SocketIoClientSentEvents,
- SocketIoServerSentEvents,
-} from "../common/types/socket-io";
-import { WeaponType } from "../common/types/weapon";
-import { joinUrl } from "../common/urls";
-import { env } from "./env";
-import { InputHandlerId } from "./input/input-handler-catalog";
-import {
- initializeGame,
- playerDied,
- playerJoined,
- playerLeft,
- playerUpdated,
- roomUpdated,
- stopGame,
-} from "./logic";
-
-let socket:
- | Socket
- | undefined;
-
-export function joinRoom(
- username: string,
- inputHandlerId: InputHandlerId,
- weapon: WeaponType,
- roomWithBots: boolean,
- debugMode: boolean,
-) {
- if (socket) {
- socket.disconnect();
- socket = undefined;
- stopGame();
- }
-
- socket = io({
- path: joinUrl("/", env.BASE_PATH, "socket.io/"),
- });
-
- socket.on("playerJoined", ({ player }) => {
- console.log(`Player ${player.id} joined the room`);
-
- playerJoined(player);
- });
-
- socket.on("playerLeft", ({ player }) => {
- console.log(`Player ${player.id} left the room`);
-
- playerLeft(player);
- });
-
- socket.on("playerDied", ({ player }) => {
- console.log(`Player ${player.id} died`);
-
- playerDied(player);
- });
-
- socket.on("playerUpdated", ({ player }) => {
- playerUpdated(player);
- });
-
- socket.on("roomUpdated", ({ room }) => {
- roomUpdated(room);
- });
-
- socket.on("disconnect", (reason) => {
- console.log("Disconnected from server:", reason);
-
- stopGame();
- });
-
- socket.emit(
- "requestJoin",
- { username, roomWithBots, weapon },
- (room, player) => {
- assert(socket, "Socket should be defined");
- console.log(`Joined room ${room.id} as player ${player.id}`, room);
-
- initializeGame(socket, room, player, inputHandlerId, debugMode);
- },
- );
-}
diff --git a/client/styles/index.scss b/client/styles/index.scss
index f7313a1..de548c8 100644
--- a/client/styles/index.scss
+++ b/client/styles/index.scss
@@ -1,3 +1,5 @@
+@import "ui-state.form";
+
body {
width: 100vw;
height: 100vh;
@@ -12,6 +14,7 @@ body {
flex-grow: 1;
min-height: 0;
justify-content: center;
+ align-items: center;
user-select: none;
& > svg {
diff --git a/client/styles/ui-state.form.scss b/client/styles/ui-state.form.scss
new file mode 100644
index 0000000..f4107c4
--- /dev/null
+++ b/client/styles/ui-state.form.scss
@@ -0,0 +1,34 @@
+#room-selection-form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ height: fit-content;
+
+ padding: 40px;
+ border: 1px solid #225c09;
+ border-radius: 5px;
+ background-color: #48be11;
+ box-shadow: 0 0 10px rgba(77, 117, 39, 0.1);
+
+ & > h1 {
+ margin: 0;
+ margin-bottom: 16px;
+ }
+
+ & > .form-fields {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ & > span {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 8px;
+ }
+
+ & > .submit {
+ justify-content: center;
+ }
+ }
+}
diff --git a/client/ui-manager/ui-manager.ts b/client/ui-manager/ui-manager.ts
new file mode 100644
index 0000000..14b1b53
--- /dev/null
+++ b/client/ui-manager/ui-manager.ts
@@ -0,0 +1,24 @@
+import { UiState } from "./ui-state";
+
+/**
+ * Entry point of the UI state machine.
+ *
+ * This function will run the UI, which works as a simple state machine,
+ * going from one state to another, where each state represents a different screen of the UI.
+ */
+export async function runStateMachine(initialState: UiState) {
+ let currentState = initialState;
+
+ // Infinite loop with a safe limit of iterations
+ for (let i = 0; i < 1_000_000; i++) {
+ const newState = await currentState.enter();
+
+ if (newState !== currentState) {
+ await currentState.exit();
+ }
+
+ currentState = newState;
+ }
+
+ console.error("UI state machine exceeded maximum number of iterations");
+}
diff --git a/client/ui-manager/ui-state.form.ts b/client/ui-manager/ui-state.form.ts
new file mode 100644
index 0000000..03efbb3
--- /dev/null
+++ b/client/ui-manager/ui-state.form.ts
@@ -0,0 +1,94 @@
+import { capitalize } from "lodash-es";
+import { assert } from "../../common/errors";
+import { WEAPON_TYPES, WeaponType } from "../../common/types/weapon";
+import {
+ availableInputHandlers,
+ InputHandlerId,
+} from "../input/input-handler-catalog";
+import { BaseUiState } from "./ui-state";
+import { GameUiState } from "./ui-state.game";
+
+/**
+ * Stores the last username entered by the user.
+ *
+ * This lets us pre-fill the username input field between games.
+ */
+let lastUsername = "";
+
+export class FormUiState extends BaseUiState {
+ usernameInput?: HTMLInputElement;
+ inputModeInput?: HTMLSelectElement;
+ weaponInput?: HTMLSelectElement;
+ botsInput?: HTMLInputElement;
+ debugModeInput?: HTMLInputElement;
+
+ doEnter() {
+ this.initializeForm();
+
+ document
+ .getElementById("room-selection-form-submit")
+ ?.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ assert(
+ this.usernameInput &&
+ this.inputModeInput &&
+ this.weaponInput &&
+ this.botsInput &&
+ this.debugModeInput,
+ "Inputs not initialized",
+ );
+
+ const username = this.usernameInput.value;
+ const inputHandlerId = this.inputModeInput.value as InputHandlerId;
+ const weapon = this.weaponInput.value as WeaponType;
+ const bots = this.botsInput.checked;
+ const debugMode = this.debugModeInput.checked;
+
+ lastUsername = username;
+
+ this.resolve(
+ new GameUiState(username, inputHandlerId, weapon, bots, debugMode),
+ );
+ });
+ }
+
+ private initializeForm() {
+ const formTemplate = this.getTemplate("form-template");
+ const newForm = formTemplate.content.cloneNode(true);
+
+ this.rootElement.appendChild(newForm);
+
+ // Get controls
+ this.usernameInput = document.getElementById(
+ "username",
+ ) as HTMLInputElement;
+ this.inputModeInput = document.getElementById(
+ "input-mode",
+ ) as HTMLSelectElement;
+ this.weaponInput = document.getElementById("weapon") as HTMLSelectElement;
+ this.botsInput = document.getElementById("bots") as HTMLInputElement;
+ this.debugModeInput = document.getElementById(
+ "debug-mode",
+ ) as HTMLInputElement;
+
+ // Initialize controls
+ this.usernameInput.value = lastUsername;
+ this.inputModeInput.innerHTML = "";
+ for (const inputHandler of availableInputHandlers) {
+ const option = document.createElement("option");
+ option.value = inputHandler.id;
+ option.text = inputHandler.getName();
+ this.inputModeInput.add(option);
+ }
+ this.weaponInput.innerHTML = "";
+ for (const weaponType of WEAPON_TYPES) {
+ const option = document.createElement("option");
+ option.value = weaponType;
+ option.text = capitalize(weaponType);
+ this.weaponInput.add(option);
+ }
+ }
+
+ doExit() {}
+}
diff --git a/client/ui-manager/ui-state.game.ts b/client/ui-manager/ui-state.game.ts
new file mode 100644
index 0000000..39ab509
--- /dev/null
+++ b/client/ui-manager/ui-state.game.ts
@@ -0,0 +1,120 @@
+import { io, type Socket } from "socket.io-client";
+import { assert } from "../../common/errors";
+import type {
+ SocketIoClientSentEvents,
+ SocketIoServerSentEvents,
+} from "../../common/types/socket-io";
+import { WeaponType } from "../../common/types/weapon";
+import { joinUrl } from "../../common/urls";
+import { env } from "../env";
+import { InputHandlerId } from "../input/input-handler-catalog";
+import {
+ initializeGame,
+ playerDied,
+ playerJoined,
+ playerLeft,
+ playerUpdated,
+ roomUpdated,
+ stopGame,
+} from "../logic";
+import { BaseUiState } from "./ui-state";
+import { FormUiState } from "./ui-state.form";
+
+export class GameUiState extends BaseUiState {
+ username: string;
+ inputHandlerId: InputHandlerId;
+ weapon: WeaponType;
+ roomWithBots: boolean;
+ debugMode: boolean;
+
+ socket?: Socket;
+
+ constructor(
+ username: string,
+ inputHandlerId: InputHandlerId,
+ weapon: WeaponType,
+ roomWithBots: boolean,
+ debugMode: boolean,
+ ) {
+ super();
+
+ this.username = username;
+ this.inputHandlerId = inputHandlerId;
+ this.weapon = weapon;
+ this.roomWithBots = roomWithBots;
+ this.debugMode = debugMode;
+ }
+
+ doEnter() {
+ if (this.socket) {
+ this.socket.disconnect();
+ this.socket = undefined;
+ stopGame();
+ }
+
+ this.socket = io({
+ path: joinUrl("/", env.BASE_PATH, "socket.io/"),
+ });
+
+ this.socket.on("playerJoined", ({ player }) => {
+ console.log(`Player ${player.id} joined the room`);
+
+ playerJoined(player);
+ });
+
+ this.socket.on("playerLeft", ({ player }) => {
+ console.log(`Player ${player.id} left the room`);
+
+ playerLeft(player);
+ });
+
+ this.socket.on("playerDied", ({ player }) => {
+ console.log(`Player ${player.id} died`);
+
+ playerDied(player);
+ });
+
+ this.socket.on("playerUpdated", ({ player }) => {
+ playerUpdated(player);
+ });
+
+ this.socket.on("roomUpdated", ({ room }) => {
+ roomUpdated(room);
+ });
+
+ this.socket.on("disconnect", (reason) => {
+ console.log("Disconnected from server:", reason);
+
+ stopGame();
+
+ this.resolve(new FormUiState());
+ });
+
+ this.socket.emit(
+ "requestJoin",
+ {
+ username: this.username,
+ roomWithBots: this.roomWithBots,
+ weapon: this.weapon,
+ },
+ (room, player) => {
+ assert(this.socket, "Socket should be defined");
+ console.log(`Joined room ${room.id} as player ${player.id}`, room);
+
+ initializeGame(
+ this.socket,
+ room,
+ player,
+ this.inputHandlerId,
+ this.debugMode,
+ );
+ },
+ );
+ }
+
+ doExit() {
+ this.socket?.disconnect();
+ this.socket = undefined;
+ stopGame();
+ }
+}
diff --git a/client/ui-manager/ui-state.ts b/client/ui-manager/ui-state.ts
new file mode 100644
index 0000000..fbbad30
--- /dev/null
+++ b/client/ui-manager/ui-state.ts
@@ -0,0 +1,84 @@
+import { assert } from "../../common/errors";
+
+export type UiState = {
+ /**
+ * Method called when the state begins its execution.
+ */
+ enter(): Promise;
+
+ /**
+ * Method called when the state ends its execution, before transitioning to the next state.
+ */
+ exit(): Promise | void;
+};
+
+/**
+ * Base class for UI states, which simplifies the UiState `enter()` promise management.
+ */
+export abstract class BaseUiState implements UiState {
+ /**
+ * The root element to render the UI in.
+ */
+ protected readonly rootElement: HTMLElement;
+
+ /**
+ * Function to be called to transition to the next state.
+ */
+ protected resolve: (value: UiState) => void;
+
+ public constructor() {
+ // Just for type safety
+ this.resolve = () => {};
+
+ const rootElement = document.getElementById("game");
+ assert(rootElement, "Root element not found");
+ this.rootElement = rootElement;
+ }
+
+ async enter() {
+ return new Promise((resolve) => {
+ this.resolve = resolve;
+
+ this.doEnter();
+ });
+ }
+
+ async exit() {
+ await this.doExit();
+
+ this.rootElement.innerHTML = "";
+ }
+
+ /**
+ * Method called when the state begins its execution.
+ *
+ * This method must eventually call `this.resolve` to transition to the next state.
+ *
+ * It's just a shortcut of the `enter` method.
+ */
+ abstract doEnter(): void;
+
+ /**
+ * Method called when the state ends its execution, before transitioning to the next state.
+ *
+ * It's just a shortcut of the `exit` method.
+ */
+ abstract doExit(): Promise | void;
+
+ /**
+ * Gets a template element, and asserts it exists and is, in fact, a template.
+ */
+ protected getTemplate(templateId: string): HTMLTemplateElement {
+ const template = document.getElementById(templateId) as HTMLTemplateElement;
+ assert(template, `Template "${templateId}" not found`);
+ assert(
+ Object.prototype.isPrototypeOf.call(
+ HTMLTemplateElement.prototype,
+ template,
+ ),
+ `Element "${templateId}" is not a template`,
+ );
+
+ return template;
+ }
+}