diff --git a/TODO.txt b/TODO.txt index cff2ead6e..c41621573 100644 --- a/TODO.txt +++ b/TODO.txt @@ -162,21 +162,24 @@ * disable double tap on mobile DONE 10/6/2024 * donate troops button DONE 10/7/2024 * Make fake humans spawn by their country DONE 10/9/2024 +* UI: leader board DONE 10/12/2024 +* single player mode DONE 10/12/2024 +* single player select map DONE 10/12/2024 +* implement private game +* private game can select map +* optimize sendBoat function * Test on android -* Increase disk size * NPC more likely to accept alliance fewer alliance player has -* fake humans target enemies -* create private lobby menu +* better NPC relation logic +* surface NPC relations * block user inputs if too far behind server * BUG: FakeHuman don't be enemy if don't share border (or send boat) * store cookies -* UI: leader board * Load terrain dataImage in background * BUG: half encircle Antartica causes capture * improve front page (make map larger?) * Add additional maps -* add offline mode * REFACTOR: give terranullius an ID, game.player() returns terranullius * REFACTOR: ocean is considered TerraNullius ? * Make icons svgs diff --git a/package-lock.json b/package-lock.json index 6f95f3f84..ba4fcfb58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "googleapis": "^143.0.0", "hammerjs": "^2.0.8", "jimp": "^0.22.12", + "lit": "^3.2.1", "msgpack5": "^6.0.2", "node-addon-api": "^8.1.0", "node-gyp": "^10.2.0", @@ -43,7 +44,7 @@ "@types/d3": "^7.4.3", "@types/jest": "^29.5.12", "@types/mocha": "^10.0.7", - "@types/node": "^22.5.2", + "@types/node": "^22.7.5", "@types/sinon": "^17.0.3", "@types/uuid": "^10.0.0", "@types/ws": "^8.5.11", @@ -69,7 +70,7 @@ "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "tsx": "^4.17.0", - "typescript": "^5.5.4", + "typescript": "^5.6.3", "webpack": "^5.91.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.0.4" @@ -3655,6 +3656,21 @@ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", "dev": true }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.1.tgz", + "integrity": "sha512-wx4aBmgeGvFmOKucFKY+8VFJSYZxs9poN3SDNQFF6lT6NrQUnHiPB2PWz2sc4ieEcAaYYzN+1uWahEeTq2aRIQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit/reactive-element": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.0.4.tgz", + "integrity": "sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.2.0" + } + }, "node_modules/@npmcli/agent": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz", @@ -4428,9 +4444,9 @@ } }, "node_modules/@types/node": { - "version": "22.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.2.tgz", - "integrity": "sha512-acJsPTEqYqulZS/Yp/S3GgeE6GZ0qYODUR8aVr/DkhHQ8l9nd4j5x1/ZJy9/gHrRlFMqkO6i0I3E27Alu4jjPg==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", "license": "MIT", "dependencies": { "undici-types": "~6.19.2" @@ -4542,6 +4558,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -9990,6 +10012,37 @@ "dev": true, "license": "MIT" }, + "node_modules/lit": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.2.1.tgz", + "integrity": "sha512-1BBa1E/z0O9ye5fZprPtdqnc0BFzxIxTTOO/tQFmyC/hj1O3jL4TfmLBw0WEwjAokdLwpclkvGgDJwTIh0/22w==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.0.4", + "lit-element": "^4.1.0", + "lit-html": "^3.2.0" + } + }, + "node_modules/lit-element": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.1.1.tgz", + "integrity": "sha512-HO9Tkkh34QkTeUmEdNYhMT8hzLid7YlMlATSi1q4q17HE5d9mrrEHJ/o8O2D0cMi182zK1F3v7x0PWFjrhXFew==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.2.0", + "@lit/reactive-element": "^2.0.4", + "lit-html": "^3.2.0" + } + }, + "node_modules/lit-html": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.2.1.tgz", + "integrity": "sha512-qI/3lziaPMSKsrwlxH/xMgikhQ0EGOX2ICU73Bi/YHFvz2j/yMCIrw4+puF2IpQ4+upd3EWbvnHM9+PnJn48YA==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, "node_modules/load-bmfont": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/load-bmfont/-/load-bmfont-1.4.2.tgz", @@ -11355,9 +11408,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "dev": true, "license": "ISC" }, @@ -11423,9 +11476,9 @@ } }, "node_modules/postcss": { - "version": "8.4.41", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", - "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -11444,8 +11497,8 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -12573,9 +12626,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -13470,9 +13523,9 @@ } }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 8b3daf665..033f7c891 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "start:server-dev": "GAME_ENV=dev node --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts", "dev": "GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev\"", "tunnel": "npm run build-prod && npm run start:server", - "test": "jest" + "test": "jest", + "tailwind": "tailwindcss build -i ./src/client/tailwind.css -o public/tailwind.css" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -19,7 +20,7 @@ "@types/d3": "^7.4.3", "@types/jest": "^29.5.12", "@types/mocha": "^10.0.7", - "@types/node": "^22.5.2", + "@types/node": "^22.7.5", "@types/sinon": "^17.0.3", "@types/uuid": "^10.0.0", "@types/ws": "^8.5.11", @@ -45,7 +46,7 @@ "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "tsx": "^4.17.0", - "typescript": "^5.5.4", + "typescript": "^5.6.3", "webpack": "^5.91.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.0.4" @@ -67,6 +68,7 @@ "googleapis": "^143.0.0", "hammerjs": "^2.0.8", "jimp": "^0.22.12", + "lit": "^3.2.1", "msgpack5": "^6.0.2", "node-addon-api": "^8.1.0", "node-gyp": "^10.2.0", diff --git a/src/client/Client.ts b/src/client/Client.ts deleted file mode 100644 index 6198453b8..000000000 --- a/src/client/Client.ts +++ /dev/null @@ -1,242 +0,0 @@ -import {Config, getConfig} from "../core/configuration/Config"; -import {GameID, Lobby, ServerMessage, ServerMessageSchema} from "../core/Schemas"; -import {loadTerrainMap, TerrainMap} from "../core/game/TerrainMapLoader"; -import {ClientGame, createClientGame} from "./ClientGame"; -import backgroundImage from '../../resources/images/TerrainMapFrontPage.png'; -import favicon from '../../resources/images/Favicon.png'; -import {v4 as uuidv4} from 'uuid'; - - -import './styles.css'; -import {simpleHash} from "../core/Util"; -import {PseudoRandom} from "../core/PseudoRandom"; - -const usernameKey: string = 'username'; - - -class Client { - - - private terrainMap: Promise - private game: ClientGame - private lobbiesInterval: NodeJS.Timeout | null = null; - private isLobbyHighlighted: boolean = false; - - private ip: Promise = null - - private config: Config - - constructor() { - } - - initialize(): void { - const storedUsername = localStorage.getItem(usernameKey) - if (storedUsername) { - const usernameInput = document.getElementById('username') as HTMLInputElement | null; - if (usernameInput) { - usernameInput.value = storedUsername - } - } - this.config = getConfig() - setFavicon() - this.terrainMap = loadTerrainMap() - this.startLobbyPolling() - this.ip = getClientIP() - } - - private startLobbyPolling(): void { - this.fetchAndUpdateLobbies(); // Fetch immediately on start - this.lobbiesInterval = setInterval(() => this.fetchAndUpdateLobbies(), 1000); - } - - private async fetchAndUpdateLobbies(): Promise { - try { - const lobbies = await this.fetchLobbies(); - this.updateLobbiesDisplay(lobbies); - } catch (error) { - console.error('Error fetching and updating lobbies:', error); - } - } - - private updateLobbiesDisplay(lobbies: Lobby[]): void { - if (lobbies.length === 0) { - document.getElementById('lobby-button').style.display = 'none'; - return; - } - - const lobby = lobbies[0]; - const lobbyButton = document.getElementById('lobby-button'); - const nameElement = document.getElementById('lobby-name'); - const timerElement = document.getElementById('lobby-timer'); - const playerCountElement = document.getElementById('player-count'); - - if (lobbyButton) { - lobbyButton.style.display = 'flex'; - lobbyButton.onclick = () => this.joinLobby(lobby); - - // Preserve the highlighted state - lobbyButton.classList.toggle('highlighted', this.isLobbyHighlighted); - } - - if (nameElement) nameElement.textContent = `Game ${lobby.id.substring(0, 3)}`; - if (timerElement) { - const timeRemaining = Math.max(0, Math.floor((lobby.msUntilStart) / 1000)); - timerElement.textContent = `Starts in: ${timeRemaining}s`; - } - - if (playerCountElement) playerCountElement.textContent = `Players: ${lobby.numClients}`; - - if (lobbies.length > 1) { - const nextLobby = lobbies[1] - const nextGame = document.getElementById('next-game'); - nextGame.textContent = `Next Game: ${nextLobby.id.substring(0, 3)}` - } - } - - async fetchLobbies(): Promise { - const url = '/lobbies'; - try { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}, statusText: ${response.statusText}`); - } - const data = await response.json(); - return data.lobbies; - } catch (error) { - console.error('Error fetching lobbies:', error); - throw error; - } - } - - private async joinLobby(lobby: Lobby) { - console.log(`joining lobby ${lobby.id}`) - const lobbyButton = document.getElementById('lobby-button'); - if (lobbyButton) { - this.isLobbyHighlighted = !this.isLobbyHighlighted; - lobbyButton.classList.toggle('highlighted', this.isLobbyHighlighted); - } - if (!this.isLobbyHighlighted) { - this.game.stop() - this.game = null - return - } - - if (this.game != null) { - return; - } - const [terrainMap, clientIP] = await Promise.all([ - this.terrainMap, - this.ip - ]); - console.log(`got ip ${clientIP}`) - this.game = createClientGame( - refreshUsername, - uuidv4(), - uuidv4(), - clientIP, - lobby.id, - this.config, - terrainMap - ); - this.game.join(); - const g = this.game; - window.addEventListener('beforeunload', function (event) { - console.log('Browser is closing'); - g.stop(); - }); - } -} - -function refreshUsername(): string { - const usernameInput = document.getElementById('username') as HTMLInputElement | null; - if (usernameInput == null) { - console.warn('username element not found') - return "Anon" - } - if (usernameInput && usernameInput.value.trim()) { - const trimmedValue = usernameInput.value.trim(); - localStorage.setItem(usernameKey, trimmedValue) - return trimmedValue - } else { - const storedUsername = localStorage.getItem(usernameKey); - if (storedUsername) { - return storedUsername - } - const newUsername = "Anon" + uuidToThreeDigits() - localStorage.setItem(usernameKey, newUsername) - return newUsername - } -} - -function setupUsernameCallback(callback: (username: string) => void): void { - const usernameInput = document.getElementById('username') as HTMLInputElement | null; - if (usernameInput) { - usernameInput.addEventListener('input', () => { - const username = refreshUsername(); - callback(username); - }); - } else { - console.error('Username input element not found'); - } -} - -async function getClientIP(timeoutMs: number = 1000): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - - try { - const response: Response = await fetch('https://api.ipify.org?format=json', { - signal: controller.signal - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data: {ip: string} = await response.json(); - return data.ip; - } catch (error) { - if (error instanceof Error) { - if (error.name === 'AbortError') { - console.error('Request timed out'); - } else { - console.error('Error fetching IP:', error.message); - } - } else { - console.error('An unknown error occurred'); - } - return null; - } finally { - clearTimeout(timeoutId); - } -} - -// Initialize the client when the DOM is loaded -document.addEventListener('DOMContentLoaded', () => { - new Client().initialize(); -}); - -document.body.style.backgroundImage = `url(${backgroundImage})`; - -function setFavicon(): void { - const link = document.createElement('link'); - link.type = 'image/x-icon'; - link.rel = 'shortcut icon'; - link.href = favicon; - document.head.appendChild(link); -} - -function uuidToThreeDigits(): string { - const uuid = uuidv4() - // Remove hyphens and convert to lowercase - const cleanUuid = uuid.replace(/-/g, '').toLowerCase(); - - // Convert hex string to decimal - const decimal = BigInt(`0x${cleanUuid}`); - - // Get last 3 digits - const threeDigits = decimal % 1000n; - - // Pad with leading zeros if necessary - return threeDigits.toString().padStart(3, '0'); -} \ No newline at end of file diff --git a/src/client/ClientGame.ts b/src/client/ClientGame.ts index 45b07aabc..2495f89c2 100644 --- a/src/client/ClientGame.ts +++ b/src/client/ClientGame.ts @@ -1,43 +1,52 @@ import {Executor} from "../core/execution/ExecutionManager"; -import {Cell, MutableGame, PlayerEvent, PlayerID, MutablePlayer, TileEvent, Player, Game, BoatEvent, Tile, PlayerType} from "../core/game/Game"; +import {Cell, MutableGame, PlayerEvent, PlayerID, MutablePlayer, TileEvent, Player, Game, BoatEvent, Tile, PlayerType, GameMap} from "../core/game/Game"; import {createGame} from "../core/game/GameImpl"; import {EventBus} from "../core/EventBus"; -import {Config} from "../core/configuration/Config"; +import {Config, getConfig} from "../core/configuration/Config"; import {createRenderer, GameRenderer} from "./graphics/GameRenderer"; import {InputHandler, MouseUpEvent, ZoomEvent, DragEvent, MouseDownEvent} from "./InputHandler" import {ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, ClientLeaveMessageSchema, ClientMessageSchema, GameID, Intent, ServerMessage, ServerMessageSchema, ServerSyncMessage, Turn} from "../core/Schemas"; -import {TerrainMap} from "../core/game/TerrainMapLoader"; +import {loadTerrainMap, TerrainMap} from "../core/game/TerrainMapLoader"; import {and, bfs, dist, manhattanDist} from "../core/Util"; -import {TerrainLayer} from "./graphics/layers/TerrainLayer"; import {WinCheckExecution} from "../core/execution/WinCheckExecution"; import {SendAttackIntentEvent, SendSpawnIntentEvent, Transport} from "./Transport"; import {createCanvas} from "./graphics/Utils"; import {DisplayMessageEvent, MessageType} from "./graphics/layers/EventsDisplay"; -import {placeName} from "./graphics/NameBoxCalculator"; +import {v4 as uuidv4} from 'uuid'; +export interface GameConfig { + isLocal: boolean + playerName: () => string + gameID: GameID + ip: string | null + map: GameMap +} -export function createClientGame(playerName: () => string, clientID: ClientID, playerID: PlayerID, ip: string | null, gameID: GameID, config: Config, terrainMap: TerrainMap): ClientGame { +export async function createClientGame(gameConfig: GameConfig): Promise { let eventBus = new EventBus() + const config = getConfig() + + const clientID = uuidv4() + const playerID = uuidv4() + + const terrainMap = await loadTerrainMap(gameConfig.map) let game = createGame(terrainMap, eventBus, config) const canvas = createCanvas() let gameRenderer = createRenderer(canvas, game, eventBus, clientID) - - - const transport = new Transport(null, eventBus, gameID, clientID, playerID, playerName) + const transport = new Transport(gameConfig.isLocal, eventBus, gameConfig.gameID, clientID, playerID, config, gameConfig.playerName) return new ClientGame( clientID, - ip, - gameID, + gameConfig.ip, eventBus, game, gameRenderer, new InputHandler(canvas, eventBus), - new Executor(game, gameID), + new Executor(game, gameConfig.gameID), transport, ) } @@ -56,7 +65,6 @@ export class ClientGame { constructor( private id: ClientID, private clientIP: string | null, - private gameID: GameID, private eventBus: EventBus, private gs: Game, private renderer: GameRenderer, diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts new file mode 100644 index 000000000..e45f55c45 --- /dev/null +++ b/src/client/LocalServer.ts @@ -0,0 +1,46 @@ +import {Config} from "../core/configuration/Config"; +import {ClientMessage, ClientMessageSchema, Intent, ServerMessage, ServerTurnMessageSchema, Turn} from "../core/Schemas"; + +export class LocalServer { + + private gameID = "LOCAL" + + + private turns: Turn[] = [] + private intents: Intent[] = [] + + private endTurnIntervalID + + constructor(private config: Config, private clientConnect: () => void, private clientMessage: (message: ServerMessage) => void) { + } + + start() { + this.endTurnIntervalID = setInterval(() => this.endTurn(), this.config.turnIntervalMs()); + this.clientConnect() + this.clientMessage({ + type: "start", + turns: [], + }) + } + + onMessage(message: string) { + const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message)) + if (clientMsg.type == "intent") { + this.intents.push(clientMsg.intent) + } + } + + private endTurn() { + const pastTurn: Turn = { + turnNumber: this.turns.length, + gameID: this.gameID, + intents: this.intents + } + this.turns.push(pastTurn) + this.intents = [] + this.clientMessage({ + type: "turn", + turn: pastTurn + }) + } +} \ No newline at end of file diff --git a/src/client/Main.ts b/src/client/Main.ts new file mode 100644 index 000000000..693731a4a --- /dev/null +++ b/src/client/Main.ts @@ -0,0 +1,132 @@ +import {ClientGame, createClientGame} from "./ClientGame"; +import backgroundImage from '../../resources/images/TerrainMapFrontPage.png'; +import favicon from '../../resources/images/Favicon.png'; + +import './PublicLobby'; +import './UsernameInput'; + + +import './styles.css'; +import {UsernameInput} from "./UsernameInput"; +import {SinglePlayerModal} from "./SinglePlayerModal"; +import {GameMap} from "../core/game/Game"; + + +const usernameKey: string = 'username'; + + +class Client { + private game: ClientGame + + private ip: Promise = null + + private usernameInput: UsernameInput | null = null; + + + constructor() { + } + + initialize(): void { + this.usernameInput = document.querySelector('username-input') as UsernameInput; + if (!this.usernameInput) { + console.warn('Username input element not found'); + } + + setFavicon() + this.ip = getClientIP() + document.addEventListener('join-lobby', this.handleJoinLobby.bind(this)); + document.addEventListener('leave-lobby', this.handleLeaveLobby.bind(this)); + document.addEventListener('single-player', this.handleSinglePlayer.bind(this)); + + + const singlePlayerButton = document.getElementById('single-player'); + const modal = document.querySelector('single-player-modal') as SinglePlayerModal; + + if (singlePlayerButton && modal instanceof SinglePlayerModal) { + singlePlayerButton.addEventListener('click', () => { + modal.open(); + }); + } + + } + + private async handleJoinLobby(event: CustomEvent) { + const lobby = event.detail.lobby + console.log(`joining lobby ${lobby.id}`) + const clientIP = await this.ip + console.log(`got ip ${clientIP}`) + if (this.game != null) { + this.game.stop() + } + this.game = await createClientGame( + { + isLocal: event.detail.singlePlayer, + playerName: (): string => this.usernameInput.getCurrentUsername(), + gameID: lobby.id, + ip: clientIP, + map: event.detail.map, + } + ); + this.game.join(); + const g = this.game; + window.addEventListener('beforeunload', function (event) { + console.log('Browser is closing'); + g.stop(); + }); + } + + private async handleLeaveLobby(event: CustomEvent) { + this.game.stop() + this.game = null + } + + private async handleSinglePlayer(event: CustomEvent) { + alert('coming soon') + } +} + +async function getClientIP(timeoutMs: number = 1000): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response: Response = await fetch('https://api.ipify.org?format=json', { + signal: controller.signal + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data: {ip: string} = await response.json(); + return data.ip; + } catch (error) { + if (error instanceof Error) { + if (error.name === 'AbortError') { + console.error('Request timed out'); + } else { + console.error('Error fetching IP:', error.message); + } + } else { + console.error('An unknown error occurred'); + } + return null; + } finally { + clearTimeout(timeoutId); + } +} + +// Initialize the client when the DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + new Client().initialize(); +}); + +document.body.style.backgroundImage = `url(${backgroundImage})`; + +function setFavicon(): void { + const link = document.createElement('link'); + link.type = 'image/x-icon'; + link.rel = 'shortcut icon'; + link.href = favicon; + document.head.appendChild(link); +} \ No newline at end of file diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts new file mode 100644 index 000000000..f812e796f --- /dev/null +++ b/src/client/PublicLobby.ts @@ -0,0 +1,129 @@ +import {LitElement, html, css} from 'lit'; +import {customElement, state} from 'lit/decorators.js'; +import {Lobby} from "../core/Schemas"; +import {GameMap} from '../core/game/Game'; + +@customElement('public-lobby') +export class PublicLobby extends LitElement { + @state() private lobbies: Lobby[] = []; + @state() private isLobbyHighlighted: boolean = false; + private lobbiesInterval: number | null = null; + + private currLobby: Lobby = null + + static styles = css` + /* Add your styles here, based on your existing CSS */ + .lobby-button { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + max-width: 20rem; + margin: 0 auto; + padding: 1.5rem 2rem; + background-color: #2563eb; + color: white; + font-weight: bold; + border-radius: 0.5rem; + transition: background-color 0.3s ease-in-out; + } + + .lobby-button:hover { + background-color: #1d4ed8; + } + + .lobby-button.highlighted { + background-color: #16a34a; + } + + .lobby-button.highlighted:hover { + background-color: #15803d; + } + + .lobby-name { font-size: 1.5rem; } + .lobby-timer { font-size: 1.25rem; } + .player-count { font-size: 1rem; } + `; + + connectedCallback() { + super.connectedCallback(); + this.fetchAndUpdateLobbies(); // Fetch immediately on start + this.lobbiesInterval = window.setInterval(() => this.fetchAndUpdateLobbies(), 1000); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.lobbiesInterval !== null) { + clearInterval(this.lobbiesInterval); + this.lobbiesInterval = null; + } + } + + private async fetchAndUpdateLobbies(): Promise { + try { + const lobbies = await this.fetchLobbies(); + this.lobbies = lobbies; + } catch (error) { + console.error('Error fetching and updating lobbies:', error); + } + } + + async fetchLobbies(): Promise { + const url = '/lobbies'; + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + return data.lobbies; + } catch (error) { + console.error('Error fetching lobbies:', error); + throw error; + } + } + + render() { + if (this.lobbies.length === 0) { + return html``; + } + + const lobby = this.lobbies[0]; + const timeRemaining = Math.max(0, Math.floor(lobby.msUntilStart / 1000)); + + return html` + + `; + } + + private lobbyClicked(lobby: Lobby) { + this.isLobbyHighlighted = !this.isLobbyHighlighted; + if (this.currLobby == null) { + this.currLobby = lobby + this.dispatchEvent(new CustomEvent('join-lobby', { + detail: { + lobby: lobby, + singlePlayer: false, + map: GameMap.World, + }, + bubbles: true, + composed: true + })); + } else { + this.dispatchEvent(new CustomEvent('leave-lobby', { + detail: {lobby: this.currLobby}, + bubbles: true, + composed: true + })); + this.currLobby = null + } + } +} \ No newline at end of file diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts new file mode 100644 index 000000000..a1ed2cc28 --- /dev/null +++ b/src/client/SinglePlayerModal.ts @@ -0,0 +1,123 @@ +import {LitElement, html, css} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {GameMap} from '../core/game/Game'; + +@customElement('single-player-modal') +export class SinglePlayerModal extends LitElement { + @state() private isModalOpen = false; + @state() private selectedMap: GameMap = GameMap.World; + + static styles = css` + .modal-overlay { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + } + + .modal-content { + background-color: white; + margin: 15% auto; + padding: 20px; + border-radius: 8px; + width: 80%; + max-width: 500px; + text-align: center; + } + + .close { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; + cursor: pointer; + } + + .close:hover, + .close:focus { + color: black; + text-decoration: none; + cursor: pointer; + } + + button { + padding: 10px 20px; + font-size: 16px; + cursor: pointer; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + transition: background-color 0.3s; + display: inline-block; + margin-top: 20px; + } + + button:hover { + background-color: #0056b3; + } + + select { + padding: 8px; + font-size: 16px; + margin-top: 10px; + width: 200px; + } + `; + + render() { + return html` + + `; + } + + public open() { + this.isModalOpen = true; + } + + public close() { + this.isModalOpen = false; + } + + private handleMapChange(e: Event) { + this.selectedMap = Number((e.target as HTMLSelectElement).value) as GameMap; + } + + private startGame() { + console.log(`Starting single player game with map: ${GameMap[this.selectedMap]}`); + this.dispatchEvent(new CustomEvent('join-lobby', { + detail: { + singlePlayer: true, + lobby: { + id: "LOCAL", + }, + map: this.selectedMap, + }, + bubbles: true, + composed: true + })); + this.close(); + } +} \ No newline at end of file diff --git a/src/client/Transport.ts b/src/client/Transport.ts index bd3d34dec..faf45845a 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -1,6 +1,8 @@ +import {Config} from "../core/configuration/Config" import {EventBus, GameEvent} from "../core/EventBus" import {AllianceRequest, AllPlayers, Cell, Player, PlayerID, PlayerType} from "../core/game/Game" import {ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, ClientLeaveMessageSchema, GameID, Intent, ServerMessage, ServerMessageSchema} from "../core/Schemas" +import {LocalServer} from "./LocalServer" export class SendAllianceRequestIntentEvent implements GameEvent { @@ -67,14 +69,17 @@ export class SendDonateIntentEvent implements GameEvent { export class Transport { - public onconnect: () => {} + private socket: WebSocket + + private localServer: LocalServer constructor( - public socket: WebSocket, + private isLocal: boolean, private eventBus: EventBus, private gameID: GameID, private clientID: ClientID, private playerID: PlayerID, + private config: Config, private playerName: () => string, ) { this.eventBus.on(SendAllianceRequestIntentEvent, (e) => this.onSendAllianceRequest(e)) @@ -89,6 +94,19 @@ export class Transport { } connect(onconnect: () => void, onmessage: (message: ServerMessage) => void, isActive: () => boolean) { + if (this.isLocal) { + this.connectLocal(onconnect, onmessage, isActive) + } else { + this.connectRemote(onconnect, onmessage, isActive) + } + } + + private connectLocal(onconnect: () => void, onmessage: (message: ServerMessage) => void, isActive: () => boolean) { + this.localServer = new LocalServer(this.config, onconnect, onmessage) + this.localServer.start() + } + + private connectRemote(onconnect: () => void, onmessage: (message: ServerMessage) => void, isActive: () => boolean) { const wsHost = process.env.WEBSOCKET_URL || window.location.host; const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; this.socket = new WebSocket(`${wsProtocol}//${wsHost}`) @@ -115,7 +133,7 @@ export class Transport { } joinGame(clientIP: string | null, numTurns: number) { - this.socket.send( + this.sendMsg( JSON.stringify( ClientJoinMessageSchema.parse({ type: "join", @@ -136,7 +154,7 @@ export class Transport { clientID: this.clientID, gameID: this.gameID, }) - this.socket.send(JSON.stringify(msg)) + this.sendMsg(JSON.stringify(msg)) } else { console.log('WebSocket is not open. Current state:', this.socket.readyState); console.log('attempting reconnect') @@ -239,17 +257,25 @@ export class Transport { } private sendIntent(intent: Intent) { - if (this.socket.readyState === WebSocket.OPEN) { + if (this.isLocal || this.socket.readyState === WebSocket.OPEN) { const msg = ClientIntentMessageSchema.parse({ type: "intent", clientID: this.clientID, gameID: this.gameID, intent: intent }) - this.socket.send(JSON.stringify(msg)) + this.sendMsg(JSON.stringify(msg)) } else { console.log('WebSocket is not open. Current state:', this.socket.readyState); console.log('attempting reconnect') } } -} + + private sendMsg(msg: string) { + if (this.isLocal) { + this.localServer.onMessage(msg) + } else { + this.socket.send(msg) + } + } +} \ No newline at end of file diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts new file mode 100644 index 000000000..4ce7f82ba --- /dev/null +++ b/src/client/UsernameInput.ts @@ -0,0 +1,96 @@ +import {LitElement, html, css} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {v4 as uuidv4} from 'uuid'; + +const usernameKey: string = 'username'; + +@customElement('username-input') +export class UsernameInput extends LitElement { + @state() private username: string = ''; + + static styles = css` + input { + width: 100%; + padding: 0.75rem; + background-color: white; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + font-size: 1rem; + line-height: 1.5; + color: #111827; + } + + input:focus { + outline: none; + ring: 2px; + ring-color: #3b82f6; + border-color: #3b82f6; + } + `; + + public getCurrentUsername(): string { + return this.username; + } + + connectedCallback() { + super.connectedCallback(); + this.username = this.getStoredUsername(); + this.dispatchUsernameEvent() + } + + render() { + return html` + + `; + } + + private handleInput(e: Event) { + const input = e.target as HTMLInputElement; + this.username = input.value.trim(); + this.storeUsername(this.username); + this.dispatchUsernameEvent() + } + + private getStoredUsername(): string { + const storedUsername = localStorage.getItem(usernameKey); + if (storedUsername) { + return storedUsername; + } + return this.generateNewUsername(); + } + + private storeUsername(username: string) { + if (username) { + localStorage.setItem(usernameKey, username); + } + } + + private dispatchUsernameEvent() { + this.dispatchEvent(new CustomEvent('username-change', { + detail: {username: this.username}, + bubbles: true, + composed: true + })); + } + + private generateNewUsername(): string { + const newUsername = "Anon" + this.uuidToThreeDigits(); + this.storeUsername(newUsername); + return newUsername; + } + + private uuidToThreeDigits(): string { + const uuid = uuidv4(); + const cleanUuid = uuid.replace(/-/g, '').toLowerCase(); + const decimal = BigInt(`0x${cleanUuid}`); + const threeDigits = decimal % 1000n; + return threeDigits.toString().padStart(3, '0'); + } +} \ No newline at end of file diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index cbd22ab31..cf898cc0d 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -8,19 +8,33 @@ import {EventBus} from "../../core/EventBus"; import {TransformHandler} from "./TransformHandler"; import {Layer} from "./layers/Layer"; import {EventsDisplay} from "./layers/EventsDisplay"; -import {RadialMenu} from "./layers/RadialMenu"; +import {RadialMenu} from "./layers/radial/RadialMenu"; +import {EmojiTable} from "./layers/radial/EmojiTable"; +import {Leaderboard} from "./layers/Leaderboard"; export function createRenderer(canvas: HTMLCanvasElement, game: Game, eventBus: EventBus, clientID: ClientID): GameRenderer { const transformHandler = new TransformHandler(game, eventBus, canvas) + const emojiTable = document.querySelector('emoji-table') as EmojiTable; + if (!emojiTable || !(emojiTable instanceof EmojiTable)) { + console.error('EmojiTable element not found in the DOM'); + } + + const leaderboard = document.querySelector('leader-board') as Leaderboard; + if (!emojiTable || !(leaderboard instanceof Leaderboard)) { + console.error('EmojiTable element not found in the DOM'); + } + leaderboard.clientID = clientID + const layers: Layer[] = [ new TerrainLayer(game), new TerritoryLayer(game, eventBus), new NameLayer(game, game.config().theme(), transformHandler, clientID), new UILayer(eventBus, game, clientID, transformHandler), new EventsDisplay(eventBus, game, clientID), - new RadialMenu(eventBus, game, transformHandler, clientID), + new RadialMenu(eventBus, game, transformHandler, clientID, emojiTable as EmojiTable), + leaderboard, ] return new GameRenderer(game, eventBus, canvas, transformHandler, layers) @@ -36,7 +50,7 @@ export class GameRenderer { } initialize() { - this.layers.forEach(l => l.init()) + this.layers.forEach(l => l.init(this.game)) document.body.appendChild(this.canvas); window.addEventListener('resize', () => this.resizeCanvas()); @@ -65,7 +79,7 @@ export class GameRenderer { this.layers.forEach(l => { if (l.shouldTransform()) { - l.render(this.context) + l.renderLayer(this.context) } }) @@ -73,7 +87,7 @@ export class GameRenderer { this.layers.forEach(l => { if (!l.shouldTransform()) { - l.render(this.context) + l.renderLayer(this.context) } }) diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index ce390f1e0..17a2dd078 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -263,7 +263,7 @@ export class EventsDisplay implements Layer { this.events[index] = event; } - render(): void { } + renderLayer(): void { } renderTable(): void { if (this.events.length === 0) { diff --git a/src/client/graphics/layers/Layer.ts b/src/client/graphics/layers/Layer.ts index 11f6e3e7e..b2d9c8388 100644 --- a/src/client/graphics/layers/Layer.ts +++ b/src/client/graphics/layers/Layer.ts @@ -1,8 +1,9 @@ +import {Game} from "../../../core/game/Game" import {TransformHandler} from "../TransformHandler" export interface Layer { - init() + init(game: Game) tick() - render(context: CanvasRenderingContext2D) + renderLayer(context: CanvasRenderingContext2D) shouldTransform(): boolean } \ No newline at end of file diff --git a/src/client/graphics/layers/Leaderboard.ts b/src/client/graphics/layers/Leaderboard.ts new file mode 100644 index 000000000..7929c5605 --- /dev/null +++ b/src/client/graphics/layers/Leaderboard.ts @@ -0,0 +1,179 @@ +import {LitElement, html, css} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {Layer} from './Layer'; +import {Game, Player} from '../../../core/game/Game'; +import {ClientID} from '../../../core/Schemas'; + +interface Entry { + name: string + position: number + score: number + isMyPlayer: boolean +} + +@customElement('leader-board') +export class Leaderboard extends LitElement implements Layer { + + private game: Game + public clientID: ClientID + + init(game: Game) { + this.game = game + } + + tick() { + if (this._hidden && !this.game.inSpawnPhase()) { + this.showLeaderboard() + this.updateLeaderboard() + } + if (this._hidden) { + return + } + + if (this.game.ticks() % 10 == 0) { + this.updateLeaderboard() + } + } + + private updateLeaderboard() { + if (this.clientID == null) { + return + } + const myPlayer = this.game.players().find(p => p.clientID() == this.clientID) + if (myPlayer == null) { + return + } + + const sorted = this.game.players() + .sort((a, b) => b.numTilesOwned() - a.numTilesOwned()) + + this.players = sorted + .slice(0, 5) + .map((player, index) => ({ + name: player.name(), + position: index + 1, + score: player.numTilesOwned(), + isMyPlayer: player == myPlayer + })); + + if (this.players.find(p => p.isMyPlayer) == null) { + let place = 0 + for (const p of sorted) { + place++ + if (p == myPlayer) { + break + } + } + + this.players.pop() + this.players.push({ + name: myPlayer.name(), + position: place, + score: myPlayer.numTilesOwned(), + isMyPlayer: true, + }) + } + + + this.requestUpdate() + } + + renderLayer(context: CanvasRenderingContext2D) { + } + shouldTransform(): boolean { + return false + } + + static styles = css` + :host { + display: block; + } + .leaderboard { + position: fixed; + top: 20px; + left: 20px; + z-index: 9999; + background-color: #1E1E1E; + padding: 15px; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); + border-radius: 10px; + max-width: 300px; + max-height: 80vh; + overflow-y: auto; + width: 300px; + } + table { + width: 100%; + border-collapse: collapse; + } + th, td { + padding: 8px; + text-align: left; + border-bottom: 1px solid #333; + color: white; + } + th { + background-color: #2C2C2C; + color: white; + } + .myPlayer { + font-weight: bold; + font-size: 1.2em; + } + tr:nth-child(even) { + background-color: #2C2C2C; + } + tr:hover { + background-color: #3A3A3A; + } + .hidden { + display: none !important; + } + `; + + @property({type: Array}) + players: Entry[] = []; + + @state() + private _hidden = true; + + render() { + return html` +
+ + + + + + + + + + ${this.players + .map((player, index) => html` + + + + + + `)} + +
RankPlayerScore
${player.position}${player.name.slice(0, 12)}${player.score}
+
+ `; + } + + hideLeaderboard() { + this._hidden = true; + this.requestUpdate(); + } + + showLeaderboard() { + this._hidden = false; + this.requestUpdate(); + } + + get isVisible() { + return !this._hidden; + } +} \ No newline at end of file diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 3303ff322..711310ede 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -60,7 +60,7 @@ export class NameLayer implements Layer { return true } - public init() { + public init(game: Game) { } @@ -100,7 +100,7 @@ export class NameLayer implements Layer { } } - public render(mainContex: CanvasRenderingContext2D) { + public renderLayer(mainContex: CanvasRenderingContext2D) { const [upperLeft, bottomRight] = this.transformHandler.screenBoundingRect() for (const render of this.renders) { render.isVisible = this.isVisible(render, upperLeft, bottomRight) diff --git a/src/client/graphics/layers/TerrainLayer.ts b/src/client/graphics/layers/TerrainLayer.ts index 5505cce62..544ab4e0b 100644 --- a/src/client/graphics/layers/TerrainLayer.ts +++ b/src/client/graphics/layers/TerrainLayer.ts @@ -17,7 +17,7 @@ export class TerrainLayer implements Layer { tick() { } - init() { + init(game: Game) { this.canvas = document.createElement('canvas'); this.context = this.canvas.getContext("2d") @@ -41,7 +41,7 @@ export class TerrainLayer implements Layer { }) } - render(context: CanvasRenderingContext2D) { + renderLayer(context: CanvasRenderingContext2D) { context.drawImage( this.canvas, -this.game.width() / 2, diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index b2c797761..85d76d87a 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -31,7 +31,7 @@ export class TerritoryLayer implements Layer { tick() { } - init() { + init(game: Game) { this.canvas = document.createElement('canvas'); this.context = this.canvas.getContext("2d") @@ -50,7 +50,7 @@ export class TerritoryLayer implements Layer { }) } - render(context: CanvasRenderingContext2D) { + renderLayer(context: CanvasRenderingContext2D) { this.renderTerritory() this.context.putImageData(this.imageData, 0, 0); context.drawImage( diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts index 34d49234c..d6efb4fe7 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -30,7 +30,7 @@ export class UILayer implements Layer { } - render(context: CanvasRenderingContext2D) { + renderLayer(context: CanvasRenderingContext2D) { if (!this.game.inSpawnPhase()) { return } @@ -55,7 +55,7 @@ export class UILayer implements Layer { tick() { } - init() { + init(game: Game) { this.createExitButton() this.createWinModal() this.initRightClickMenu() diff --git a/src/client/graphics/layers/radial/EmojiTable.ts b/src/client/graphics/layers/radial/EmojiTable.ts new file mode 100644 index 000000000..a689be1b3 --- /dev/null +++ b/src/client/graphics/layers/radial/EmojiTable.ts @@ -0,0 +1,107 @@ +import {LitElement, html, css} from 'lit'; +import {customElement, state} from 'lit/decorators.js'; + +const emojiTable: string[][] = [ + ["😀", "😱", "🤩", "đŸŽ¯", "đŸĨē"], + ["đŸĒĻ", "👏", "đŸĨ‰", "đŸĨˆ", "đŸĨ‡"], + ["🤙", "đŸĨ°", "😇", "😊", "đŸ”Ĩ"], + ["đŸ’Ē", "đŸĨŗ", "💀", "😭", "đŸ¤Ļâ€â™‚ī¸"], + ["😎", "👎", "👍", "đŸĨą", "💔"], + ["â¤ī¸", "💰", "🤝", "đŸ›Ąī¸", "đŸ’Ĩ"], + ["🆘", "đŸ•Šī¸", "âžĄī¸", "âŦ…ī¸", "â†™ī¸"], + ["â†–ī¸", "â†—ī¸", "âŦ†ī¸", "â†˜ī¸", "âŦ‡ī¸"] +]; + +@customElement('emoji-table') +export class EmojiTable extends LitElement { + static styles = css` + :host { + display: block; + } + .emoji-table { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 9999; + background-color: #1E1E1E; + padding: 15px; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); + border-radius: 10px; + display: flex; + flex-direction: column; + align-items: center; + max-width: 95vw; + max-height: 95vh; + overflow-y: auto; + } + .emoji-row { + display: flex; + justify-content: center; + width: 100%; + } + .emoji-button { + font-size: 60px; + width: 80px; + height: 80px; + border: 1px solid #333; + background-color: #2C2C2C; + color: white; + border-radius: 12px; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + justify-content: center; + align-items: center; + margin: 8px; + } + .emoji-button:hover { + background-color: #3A3A3A; + transform: scale(1.1); + } + .emoji-button:active { + background-color: #4A4A4A; + transform: scale(0.95); + } + .hidden { + display: none !important; + } + `; + + @state() + private _hidden = true; + + public onEmojiClicked: (emoji: string) => void = () => { } + + render() { + return html` +
+ ${emojiTable.map(row => html` +
+ ${row.map(emoji => html` + + `)} +
+ `)} +
+ `; + } + + + hideTable() { + this._hidden = true; + this.requestUpdate(); + + } + + showTable() { + this._hidden = false; + this.requestUpdate(); + } + + get isVisible() { + return !this._hidden; + } +} \ No newline at end of file diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/radial/RadialMenu.ts similarity index 83% rename from src/client/graphics/layers/RadialMenu.ts rename to src/client/graphics/layers/radial/RadialMenu.ts index fdbfd12a9..48c7ccd25 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/radial/RadialMenu.ts @@ -1,21 +1,21 @@ -import {EventBus} from "../../../core/EventBus"; -import {AllPlayers, Cell, Game, Player} from "../../../core/game/Game"; -import {ClientID} from "../../../core/Schemas"; -import {and, bfs, dist, manhattanDist, manhattanDistWrapped, sourceDstOceanShore} from "../../../core/Util"; -import {ContextMenuEvent, MouseUpEvent} from "../../InputHandler"; -import {SendAllianceRequestIntentEvent, SendAttackIntentEvent, SendBoatAttackIntentEvent, SendBreakAllianceIntentEvent, SendDonateIntentEvent, SendEmojiIntentEvent, SendSpawnIntentEvent, SendTargetPlayerIntentEvent} from "../../Transport"; -import {TransformHandler} from "../TransformHandler"; -import {Layer} from "./Layer"; +import {EventBus} from "../../../../core/EventBus"; +import {AllPlayers, Cell, Game, Player} from "../../../../core/game/Game"; +import {ClientID} from "../../../../core/Schemas"; +import {and, bfs, dist, manhattanDist, manhattanDistWrapped, sourceDstOceanShore} from "../../../../core/Util"; +import {ContextMenuEvent, MouseUpEvent} from "../../../InputHandler"; +import {SendAllianceRequestIntentEvent, SendAttackIntentEvent, SendBoatAttackIntentEvent, SendBreakAllianceIntentEvent, SendDonateIntentEvent, SendEmojiIntentEvent, SendSpawnIntentEvent, SendTargetPlayerIntentEvent} from "../../../Transport"; +import {TransformHandler} from "../../TransformHandler"; +import {Layer} from "../Layer"; import * as d3 from 'd3'; -import traitorIcon from '../../../../resources/images/TraitorIconWhite.png'; -import allianceIcon from '../../../../resources/images/AllianceIconWhite.png'; -import boatIcon from '../../../../resources/images/BoatIconWhite.png'; -import swordIcon from '../../../../resources/images/SwordIconWhite.png'; -import targetIcon from '../../../../resources/images/TargetIconWhite.png'; -import emojiIcon from '../../../../resources/images/EmojiIconWhite.png'; -import disabledIcon from '../../../../resources/images/DisabledIcon.png'; -import donateIcon from '../../../../resources/images/DonateIconWhite.png'; -import {MessageType} from "./EventsDisplay"; +import traitorIcon from '../../../../../resources/images/TraitorIconWhite.png'; +import allianceIcon from '../../../../../resources/images/AllianceIconWhite.png'; +import boatIcon from '../../../../../resources/images/BoatIconWhite.png'; +import swordIcon from '../../../../../resources/images/SwordIconWhite.png'; +import targetIcon from '../../../../../resources/images/TargetIconWhite.png'; +import emojiIcon from '../../../../../resources/images/EmojiIconWhite.png'; +import disabledIcon from '../../../../../resources/images/DisabledIcon.png'; +import donateIcon from '../../../../../resources/images/DonateIconWhite.png'; +import {EmojiTable} from "./EmojiTable"; enum Slot { @@ -45,12 +45,12 @@ export class RadialMenu implements Layer { private isCenterButtonEnabled = false - constructor( private eventBus: EventBus, private game: Game, private transformHandler: TransformHandler, private clientID: ClientID, + private emojiTable: EmojiTable ) { } init() { @@ -59,49 +59,6 @@ export class RadialMenu implements Layer { this.createMenuElement(); } - private hideEmojiTable(): void { - const emojiTable: HTMLTableElement | null = document.getElementById('uniqueEmojiTable') as HTMLTableElement | null; - - if (emojiTable instanceof HTMLTableElement) { - if (!emojiTable.classList.contains('hidden')) { - emojiTable.classList.add('hidden'); - } - } else { - console.error('Emoji table not found'); - } - } - - private showEmojiTable(recipient: Player | typeof AllPlayers): void { - const emojiTable: HTMLTableElement | null = document.getElementById('uniqueEmojiTable') as HTMLTableElement | null; - - if (emojiTable instanceof HTMLTableElement) { - emojiTable.classList.remove('hidden'); - } else { - console.error('Emoji table not found'); - } - this.setupEmojiButtons(recipient) - } - - private setupEmojiButtons(recipient: Player | typeof AllPlayers) { - let emojiTable = document.getElementById('uniqueEmojiTable'); - - if (emojiTable) { - // Remove existing listeners - emojiTable.replaceWith(emojiTable.cloneNode(true)); - emojiTable = document.getElementById('uniqueEmojiTable'); - emojiTable.addEventListener('click', (event) => { - const emojiElement = event.target as HTMLElement; - if (emojiElement.classList.contains('emoji-button')) { - const emoji = emojiElement.textContent; - this.hideEmojiTable() - this.eventBus.emit(new SendEmojiIntentEvent(recipient, emoji)) - } - }); - } else { - console.error('Emoji table not found'); - } - } - private createMenuElement() { this.menuElement = d3.select(document.body) .append('div') @@ -227,7 +184,7 @@ export class RadialMenu implements Layer { // Update logic if needed } - render(context: CanvasRenderingContext2D) { + renderLayer(context: CanvasRenderingContext2D) { // No need to render anything on the canvas } @@ -272,7 +229,11 @@ export class RadialMenu implements Layer { const target = tile.owner() == myPlayer ? AllPlayers : (tile.owner() as Player) if (myPlayer.canSendEmoji(target)) { this.activateMenuElement(Slot.Emoji, "#ebe250", emojiIcon, () => { - this.showEmojiTable(target) + this.emojiTable.onEmojiClicked = (emoji: string) => { + this.emojiTable.hideTable() + this.eventBus.emit(new SendEmojiIntentEvent(target, emoji)) + } + this.emojiTable.showTable() }) } } @@ -387,7 +348,7 @@ export class RadialMenu implements Layer { private onPointerUp(event: MouseUpEvent) { this.hideRadialMenu() - this.hideEmojiTable() + this.emojiTable.hideTable() } private showRadialMenu(x: number, y: number) { @@ -467,4 +428,4 @@ export class RadialMenu implements Layer { .attr('fill', enabled ? 'white' : '#cccccc'); }, 25); } -} +} \ No newline at end of file diff --git a/src/client/index.html b/src/client/index.html index 88e7a32c5..d0ec86595 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -5,6 +5,7 @@ OpenFront (ALPHA) + @@ -26,19 +27,36 @@ fill="#ffffff" /> -

OpenFront.io

-

(v0.6.5)

-
- +

OpenFront.io

+

(v0.6.5)

+
+
+ + +
+ + + + +
+ + +
+
+ +

Public Games

+ +
-
- -
-

@@ -49,71 +67,10 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + -