diff --git a/TODO.txt b/TODO.txt index 5c3fae4ef..63541cc06 100644 --- a/TODO.txt +++ b/TODO.txt @@ -112,8 +112,9 @@ --- v3 Release DONE * Add discord link DONE 9/14/2024 -* front page mobile friendly -* game mobile friendly +* front page mobile friendly DONE 9/15/2024 +* game mobile friendly DONE 9/16/2024 +* UI: basic win condition & popup DONE 9/16/2024 * right click popup alliance option * click alliance sends alliance request * notification for alliance request @@ -125,7 +126,6 @@ * BUG: when send boat only captures one pixel * store cookies * names dissapear on bottom of screen -* UI: win condition & popup * UI: boats * UI: current attacks * UI: leader board diff --git a/package-lock.json b/package-lock.json index c676c53d2..dcbe26ea6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "css-loader": "^7.1.2", "file-loader": "^6.2.0", "html-inline-script-webpack-plugin": "^3.2.1", + "html-loader": "^5.1.0", "html-webpack-plugin": "^5.6.0", "jest": "^29.7.0", "mocha": "^10.7.0", @@ -7394,6 +7395,72 @@ "webpack": "^5.0.0" } }, + "node_modules/html-loader": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/html-loader/-/html-loader-5.1.0.tgz", + "integrity": "sha512-Jb3xwDbsm0W3qlXrCZwcYqYGnYz55hb6aoKQTlzyZPXsPpi6tHXzAfqalecglMQgNvtEfxrCQPaKT90Irt5XDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "html-minifier-terser": "^7.2.0", + "parse5": "^7.1.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/html-loader/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/html-loader/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-loader/node_modules/html-minifier-terser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", + "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "~5.3.2", + "commander": "^10.0.0", + "entities": "^4.4.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.15.1" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, "node_modules/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -10387,6 +10454,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", diff --git a/package.json b/package.json index 6c031069d..65de0c41b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "css-loader": "^7.1.2", "file-loader": "^6.2.0", "html-inline-script-webpack-plugin": "^3.2.1", + "html-loader": "^5.1.0", "html-webpack-plugin": "^5.6.0", "jest": "^29.7.0", "mocha": "^10.7.0", @@ -73,4 +74,4 @@ "zod": "^3.23.8" }, "type": "module" -} \ No newline at end of file +} diff --git a/src/client/ClientGame.ts b/src/client/ClientGame.ts index 104c9efc3..38c5da72b 100644 --- a/src/client/ClientGame.ts +++ b/src/client/ClientGame.ts @@ -9,6 +9,7 @@ import {ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, ClientLeav import {TerrainMap} from "../core/TerrainMapLoader"; import {and, bfs, dist, manhattanDist} from "../core/Util"; import {TerrainRenderer} from "./graphics/TerrainRenderer"; +import {WinCheckExecution} from "../core/execution/WinCheckExecution"; @@ -16,7 +17,7 @@ export function createClientGame(name: string, clientID: ClientID, playerID: Pla let eventBus = new EventBus() let game = createGame(terrainMap, eventBus, config) let terrainRenderer = new TerrainRenderer(game) - let gameRenderer = new GameRenderer(game, clientID, terrainRenderer) + let gameRenderer = new GameRenderer(eventBus, game, clientID, terrainRenderer) return new ClientGame( name, @@ -40,7 +41,6 @@ export class ClientGame { private currTurn = 0 - private intervalID: NodeJS.Timeout private isProcessingTurn = false @@ -131,8 +131,8 @@ export class ClientGame { this.renderer.initialize() this.input.initialize() this.gs.addExecution(...this.executor.spawnBots(this.gs.config().numBots())) - console.log('!!! number fake humans ') this.gs.addExecution(...this.executor.fakeHumanExecutions(this.gs.config().numFakeHumans(this.gameID))) + this.gs.addExecution(new WinCheckExecution(this.eventBus)) this.intervalID = setInterval(() => this.tick(), 10); } diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index c28700f37..7da863126 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -8,6 +8,7 @@ import {TerritoryRenderer} from "./TerritoryRenderer"; import {ClientID} from "../../core/Schemas"; import {renderTroops} from "./Utils"; import {UIRenderer} from "./UIRenderer"; +import {EventBus} from "../../core/EventBus"; export class GameRenderer { private territoryCanvas: HTMLCanvasElement @@ -28,11 +29,11 @@ export class GameRenderer { private theme: Theme - constructor(private gs: Game, private clientID: ClientID, private terrainRenderer: TerrainRenderer) { + constructor(private eventBus: EventBus, private gs: Game, private clientID: ClientID, private terrainRenderer: TerrainRenderer) { this.theme = gs.config().theme() this.nameRenderer = new NameRenderer(gs, this.theme) this.territoryRenderer = new TerritoryRenderer(gs) - this.uiRenderer = new UIRenderer(gs, this.theme, clientID) + this.uiRenderer = new UIRenderer(eventBus, gs, this.theme, clientID) } initialize() { @@ -107,7 +108,7 @@ export class GameRenderer { this.context.restore() - this.renderUIBar() + this.renderSpawnBar() this.uiRenderer.render(this.context) requestAnimationFrame(() => this.renderGame()); @@ -115,7 +116,7 @@ export class GameRenderer { // TODO: move to UIRenderer - renderUIBar() { + renderSpawnBar() { if (!this.gs.inSpawnPhase()) { return } diff --git a/src/client/graphics/UIRenderer.ts b/src/client/graphics/UIRenderer.ts index aea1290f2..5a1a87a3f 100644 --- a/src/client/graphics/UIRenderer.ts +++ b/src/client/graphics/UIRenderer.ts @@ -1,17 +1,94 @@ import {Theme} from "../../core/configuration/Config"; -import {Game} from "../../core/Game"; +import {EventBus} from "../../core/EventBus"; +import {WinEvent} from "../../core/execution/WinCheckExecution"; +import {Game, Player} from "../../core/Game"; import {ClientID} from "../../core/Schemas"; import {renderTroops} from "./Utils"; +import winModalHtml from '../WinModal.html'; export class UIRenderer { private exitButton: HTMLButtonElement; + private winModal: HTMLElement | null = null; - constructor(private game: Game, private theme: Theme, private clientID: ClientID) { + + constructor(private eventBus: EventBus, private game: Game, private theme: Theme, private clientID: ClientID) { } init() { this.createExitButton() + this.createWinModal() + this.eventBus.on(WinEvent, (e) => this.onWinEvent(e)) + } + + createWinModal() { + console.log("Creating win modal"); + this.winModal = document.createElement('div'); + this.winModal.style.cssText = ` + display: none; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: white; + padding: 20px; + border: 2px solid black; + border-radius: 10px; + z-index: 2000; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + `; + + const content = document.createElement('div'); + + const title = document.createElement('h2'); + title.textContent = 'Game Over'; + title.id = 'winTitle'; + title.style.marginTop = '0'; + + const message = document.createElement('p'); + message.id = 'winMessage'; + + const buttonContainer = document.createElement('div'); + buttonContainer.style.display = 'flex'; + buttonContainer.style.justifyContent = 'space-between'; + buttonContainer.style.marginTop = '20px'; + + const exitButton = document.createElement('button'); + exitButton.textContent = 'Exit Game'; + exitButton.onclick = () => this.exitGame(); + this.styleButton(exitButton); + + const continueButton = document.createElement('button'); + continueButton.textContent = 'Keep Playing'; + continueButton.onclick = () => this.closeWinModal(); + this.styleButton(continueButton); + + buttonContainer.appendChild(exitButton); + buttonContainer.appendChild(continueButton); + + content.appendChild(title); + content.appendChild(message); + content.appendChild(buttonContainer); + + this.winModal.appendChild(content); + document.body.appendChild(this.winModal); + + console.log("Win modal appended to body"); + } + + styleButton(button: HTMLButtonElement) { + button.style.cssText = ` + padding: 10px 20px; + font-size: 16px; + cursor: pointer; + background-color: #4A90E2; + color: white; + border: none; + border-radius: 5px; + transition: background-color 0.3s; + `; + button.onmouseover = () => button.style.backgroundColor = '#3A7BCE'; + button.onmouseout = () => button.style.backgroundColor = '#4A90E2'; } createExitButton() { @@ -48,26 +125,44 @@ export class UIRenderer { } render(context: CanvasRenderingContext2D) { - // const p = this.game.players().find(p => p.clientID() == this.clientID); - // let troopCount = p ? `${renderTroops(p.troops())}` : ''; + } - // context.save(); - // context.fillStyle = 'rgba(0, 0, 0, 0.7)'; // Black with 70% opacity - // context.textAlign = 'center'; - // context.textBaseline = 'top'; - // const x = context.canvas.width / 2; // Center horizontally - // const y = 40; // Distance from the top + onWinEvent(event: WinEvent) { + console.log(`${event.winner.name()} won the game!!}`) + this.showWinModal(event.winner) + } - // context.font = `bold ${60}px ${this.theme.font()}`; - // context.fillText(troopCount, x, y); - // context.restore(); + showWinModal(winner: Player) { + if (this.winModal) { + const message = this.winModal.querySelector('#winMessage'); + if (message) { + message.textContent = `${winner.name()} won the game!`; + } + const title = this.winModal.querySelector('#winTitle') + if (winner.clientID() == this.clientID) { + title.textContent = 'You Won!!!' + } else { + title.textContent = 'You Lost!!!' + } + this.winModal.style.display = 'block'; + } } onExitButtonClick() { console.log('Button clicked!'); window.location.reload(); - // Add your button action here + } + + closeWinModal() { + if (this.winModal) { + this.winModal.style.display = 'none'; + } + } + + exitGame() { + this.closeWinModal(); + window.location.reload(); } } \ No newline at end of file diff --git a/src/core/Game.ts b/src/core/Game.ts index c3960b51d..2f487d5ac 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -142,6 +142,7 @@ export interface Game { neighbors(cell: Cell | Tile): Tile[] width(): number height(): number + numLandTiles(): number forEachTile(fn: (tile: Tile) => void): void executions(): ExecutionView[] terraNullius(): TerraNullius diff --git a/src/core/GameImpl.ts b/src/core/GameImpl.ts index 9da94e714..0c35d7a6a 100644 --- a/src/core/GameImpl.ts +++ b/src/core/GameImpl.ts @@ -294,12 +294,14 @@ export class GameImpl implements MutableGame { private execs: Execution[] = [] private _width: number private _height: number + private _numLandTiles: number _terraNullius: TerraNulliusImpl constructor(terrainMap: TerrainMap, private eventBus: EventBus, private _config: Config) { this._terraNullius = new TerraNulliusImpl(this) this._width = terrainMap.width(); this._height = terrainMap.height(); + this._numLandTiles = terrainMap.numLandTiles this.map = new Array(this._width); for (let x = 0; x < this._width; x++) { this.map[x] = new Array(this._height); @@ -309,6 +311,9 @@ export class GameImpl implements MutableGame { } } } + numLandTiles(): number { + return this._numLandTiles + } hasPlayer(id: PlayerID): boolean { return this._players.has(id) } diff --git a/src/core/TerrainMapLoader.ts b/src/core/TerrainMapLoader.ts index 9642adfa9..62054e72f 100644 --- a/src/core/TerrainMapLoader.ts +++ b/src/core/TerrainMapLoader.ts @@ -2,7 +2,7 @@ import {Cell, TerrainType} from './Game'; import binAsString from "!!binary-loader!../../resources/TopoWorldMap.bin"; export class TerrainMap { - constructor(public readonly tiles: Terrain[][]) { } + constructor(public readonly tiles: Terrain[][], public readonly numLandTiles: number) { } terrain(cell: Cell): Terrain { return this.tiles[cell.x][cell.y] @@ -45,6 +45,7 @@ export async function loadTerrainMap(): Promise { } const terrain: Terrain[][] = Array(width).fill(null).map(() => Array(height).fill(null)); + let numLand = 0 // Start from the 5th byte (index 4) when processing terrain data for (let x = 0; x < width; x++) { @@ -58,6 +59,7 @@ export async function loadTerrainMap(): Promise { let type: TerrainType = null let land = false if (isLand) { + numLand++ land = true if (magnitude < 10) { type = TerrainType.Plains @@ -82,7 +84,7 @@ export async function loadTerrainMap(): Promise { } } - return new TerrainMap(terrain); + return new TerrainMap(terrain, numLand); } function logBinaryAsAscii(data: string, length: number = 8) { diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index ec3f32229..0f6be37b1 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -26,6 +26,7 @@ export function getGameEnv(): GameEnv { export interface Config { theme(): Theme; + percentageTilesOwnedToWin(): number turnIntervalMs(): number gameCreationRate(): number lobbyLifetime(): number diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 4c314a7cb..e712da7f0 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -7,6 +7,9 @@ import {pastelTheme} from "./PastelTheme"; export class DefaultConfig implements Config { + percentageTilesOwnedToWin(): number { + return 80 + } boatMaxNumber(): number { return 3 } diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index d7eb63cbb..75382888b 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -2,6 +2,9 @@ import {GameID} from "../Schemas"; import {DefaultConfig} from "./DefaultConfig"; export const devConfig = new class extends DefaultConfig { + percentageTilesOwnedToWin(): number { + return 80 + } numSpawnPhaseTurns(): number { return 40 } diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts new file mode 100644 index 000000000..19aa850eb --- /dev/null +++ b/src/core/execution/WinCheckExecution.ts @@ -0,0 +1,47 @@ +import {EventBus, GameEvent} from "../EventBus" +import {Execution, MutableGame, MutablePlayer, Player, PlayerID} from "../Game" + +export class WinEvent implements GameEvent { + constructor(public readonly winner: Player) { } +} + +export class WinCheckExecution implements Execution { + + private active = true + + private mg: MutableGame + + constructor(private eventBus: EventBus) { + } + + init(mg: MutableGame, ticks: number) { + this.mg = mg + } + + tick(ticks: number) { + if (ticks % 10 != 0) { + return + } + const sorted = this.mg.players().sort((a, b) => b.numTilesOwned() - a.numTilesOwned()) + if (sorted.length == 0) { + return + } + const max = sorted[0] + if (max.numTilesOwned() / this.mg.numLandTiles() * 100 > this.mg.config().percentageTilesOwnedToWin()) { + this.eventBus.emit(new WinEvent(max)) + this.active = false + } + } + + owner(): MutablePlayer { + return null + } + + isActive(): boolean { + return this.active + } + + activeDuringSpawnPhase(): boolean { + return false + } +} \ No newline at end of file diff --git a/src/global.d.ts b/src/global.d.ts index 418b6553a..ddb6cb6a8 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -23,3 +23,7 @@ declare module '*.txt' { const value: string; export default value; } +declare module '*.html' { + const content: string; + export default content; +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 5cf66dfe7..c1a869453 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -41,6 +41,10 @@ export default (env, argv) => { filename: 'images/[hash][ext][query]' } }, + { + test: /\.html$/, + use: ['html-loader'] + }, { test: /\.svg$/, type: 'asset/inline',