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 @@ -
- - - - - - - - - - - - - - - - - - - - - - - -
+ + + +
{ - 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; + } +}