diff --git a/TODO.txt b/TODO.txt index b928d66c7..ae4cdf091 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,11 +1,13 @@ * fix conquer expansion DONE * perf improvements on graphics (only draw images to canvas on ticks) DONE -* double join lobby bug +* double join lobby bug DONE * render player info efficiently * better troop addition logic -* use draw rect instead of image data ? +* better expansion, add back directed expansion +* use pastel theme for territories * improve front page +* add username in front page +* upload and start server * make boats larger * have boats not get close to shore -* better algorithm for name render placement -* re-enable directed expansion \ No newline at end of file +* better algorithm for name render placement \ No newline at end of file diff --git a/src/client/Client.ts b/src/client/Client.ts index 73e71f42a..66a011a34 100644 --- a/src/client/Client.ts +++ b/src/client/Client.ts @@ -11,7 +11,6 @@ import {v4 as uuidv4} from 'uuid'; class Client { private hasJoined = false - private startButton: HTMLButtonElement | null; private socket: WebSocket | null = null; private terrainMap: Promise private game: ClientGame @@ -20,7 +19,6 @@ class Client { private lobbiesInterval: NodeJS.Timeout | null = null; constructor() { - this.startButton = document.getElementById('startButton') as HTMLButtonElement | null; this.lobbiesContainer = document.getElementById('lobbies-container'); } @@ -80,7 +78,12 @@ class Client { } private async joinLobby(lobbyID: string) { + clearInterval(this.lobbiesInterval) + this.lobbiesContainer.innerHTML = 'Joining'; // Clear existing lobbies this.terrainMap.then((map) => { + if (this.game != null) { + return + } this.game = createClientGame(uuidv4().slice(0, 4), generateUniqueID(), lobbyID, defaultSettings, map) this.game.joinLobby() }) diff --git a/src/client/ClientGame.ts b/src/client/ClientGame.ts index 37e6952da..1c0b32534 100644 --- a/src/client/ClientGame.ts +++ b/src/client/ClientGame.ts @@ -4,7 +4,7 @@ import {createGame} from "../core/GameImpl"; import {Ticker, TickEvent} from "../core/Ticker"; import {EventBus} from "../core/EventBus"; import {Settings} from "../core/Settings"; -import {GameRenderer} from "./GameRenderer"; +import {GameRenderer} from "./graphics/GameRenderer"; import {InputHandler, MouseUpEvent, ZoomEvent, DragEvent, MouseDownEvent} from "./InputHandler" import {ClientIntentMessageSchema, ClientJoinMessageSchema, ClientMessageSchema, ServerMessage, ServerMessageSchema, ServerSyncMessage, Turn} from "../core/Schemas"; @@ -34,7 +34,7 @@ export class ClientGame { private myPlayer: Player private turns: Turn[] = [] private socket: WebSocket - private started = false + private isActive = false private ticksPerTurn = 1 @@ -43,10 +43,12 @@ export class ClientGame { private spawned = false + private intervalID: NodeJS.Timeout + constructor( private playerName: string, private id: ClientID, - private lobbyID: LobbyID, + private gameID: LobbyID, private ticker: Ticker, private eventBus: EventBus, private gs: Game, @@ -63,7 +65,7 @@ export class ClientGame { JSON.stringify( ClientJoinMessageSchema.parse({ type: "join", - lobbyID: this.lobbyID, + lobbyID: this.gameID, clientID: this.id }) ) @@ -76,13 +78,14 @@ export class ClientGame { this.start() } if (message.type == "turn") { - this.addTurn(message.turn) + if (message.turn.intents) + this.addTurn(message.turn) } }; } public start() { - this.started = true + this.isActive = true console.log('starting game!') // TODO: make each class do this, or maybe have client intercept all requests? //this.eventBus.on(TickEvent, (e) => this.tick(e)) @@ -98,7 +101,12 @@ export class ClientGame { this.executor.spawnBots(1000) - setInterval(() => this.tick(), 10); + this.intervalID = setInterval(() => this.tick(), 10); + } + + public stop() { + clearInterval(this.intervalID) + this.isActive = false } public addTurn(turn: Turn): void { @@ -131,6 +139,9 @@ export class ClientGame { } private inputEvent(event: MouseDownEvent) { + if (!this.isActive) { + return + } const cell = this.renderer.screenToWorldCoordinates(event.x, event.y) if (!this.gs.isOnMap(cell)) { return @@ -164,6 +175,7 @@ export class ClientGame { ClientIntentMessageSchema.parse({ type: "intent", clientID: this.id, + gameID: this.gameID, intent: { type: "spawn", name: this.playerName, @@ -187,6 +199,7 @@ export class ClientGame { ClientIntentMessageSchema.parse({ type: "intent", clientID: this.id, + gameID: this.gameID, intent: { type: "attack", attackerID: this.myPlayer.id(), @@ -211,6 +224,7 @@ export class ClientGame { ClientIntentMessageSchema.parse({ type: "intent", clientID: this.id, + gameID: this.gameID, intent: { type: "boat", attackerID: this.myPlayer.id(), diff --git a/src/client/GameRenderer.ts b/src/client/graphics/GameRenderer.ts similarity index 66% rename from src/client/GameRenderer.ts rename to src/client/graphics/GameRenderer.ts index 3c9619cd9..6856109a9 100644 --- a/src/client/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -1,14 +1,13 @@ import {Colord} from "colord"; -import {Cell, MutableGame, Game, PlayerEvent, Tile, TileEvent, Player, Execution, BoatEvent} from "../core/Game"; -import {Theme} from "../core/Settings"; -import {DragEvent, ZoomEvent} from "./InputHandler"; -import {calculateBoundingBox, placeName} from "./NameBoxCalculator"; -import {PseudoRandom} from "../core/PseudoRandom"; -import {BoatAttackExecution} from "../core/execution/BoatAttackExecution"; +import {Cell, MutableGame, Game, PlayerEvent, Tile, TileEvent, Player, Execution, BoatEvent} from "../../core/Game"; +import {Theme} from "../../core/Settings"; +import {DragEvent, ZoomEvent} from "../InputHandler"; +import {calculateBoundingBox, placeName} from "../NameBoxCalculator"; +import {PseudoRandom} from "../../core/PseudoRandom"; +import {BoatAttackExecution} from "../../core/execution/BoatAttackExecution"; +import {NameRenderer} from "./NameRenderer"; + -class NameRender { - constructor(public lastRendered: number, public location: Cell, public fontSize: number) { } -} export class GameRenderer { private tempCanvas; @@ -21,16 +20,12 @@ export class GameRenderer { private imageData: ImageData - private nameRenders: Map = new Map() - - private rand = new PseudoRandom(10) - - private offscreenContext: CanvasRenderingContext2D - private offscreenCanvas: HTMLCanvasElement + private nameRenderer: NameRenderer; constructor(private gs: Game, private theme: Theme, private canvas: HTMLCanvasElement) { this.context = canvas.getContext("2d") + this.nameRenderer = new NameRenderer(gs, theme) } initialize() { @@ -46,6 +41,7 @@ export class GameRenderer { this.imageData = this.context.getImageData(0, 0, this.gs.width(), this.gs.height()) this.initImageData() + this.nameRenderer.initialize() document.body.appendChild(this.canvas); @@ -53,12 +49,6 @@ export class GameRenderer { this.resizeCanvas(); - this.offscreenCanvas = document.createElement('canvas'); - this.offscreenContext = this.offscreenCanvas.getContext('2d'); - this.offscreenCanvas.width = this.gs.width(); - this.offscreenCanvas.height = this.gs.height(); - - requestAnimationFrame(() => this.renderGame()); } @@ -111,14 +101,8 @@ export class GameRenderer { this.gs.height() ); } - - this.context.drawImage( - this.offscreenCanvas, - -this.gs.width() / 2, - -this.gs.height() / 2, - this.gs.width(), - this.gs.height() - ); + const [upperLeft, bottomRight] = this.boundingRect() + this.nameRenderer.render(this.context, this.scale, upperLeft, bottomRight) // const paths = this.gs.executions().map(e => e as Execution).filter(e => e instanceof BoatAttackExecution).map(e => e as BoatAttackExecution).filter(e => e.path != null).map(e => e.path) // paths.forEach(p => { @@ -139,56 +123,7 @@ export class GameRenderer { // Put the ImageData on the temp canvas tempCtx.putImageData(this.imageData, 0, 0); - let numCalcs = 0 - for (const player of this.gs.players()) { - if (numCalcs < 50 && this.maybeRecalculatePlayerInfo(player)) { - numCalcs++ - } - //this.renderPlayerInfo(player) - } - } - - maybeRecalculatePlayerInfo(player: Player): boolean { - if (!this.nameRenders.has(player)) { - this.nameRenders.set(player, new NameRender(0, null, null)) - } - - const render = this.nameRenders.get(player) - - let wasUpdated = false - - if (Date.now() - render.lastRendered > 1000) { - render.lastRendered = Date.now() + this.rand.nextInt(0, 100) - wasUpdated = true - - const box = calculateBoundingBox(player) - const centerX = box.min.x + ((box.max.x - box.min.x) / 2) - const centerY = box.min.y + ((box.max.y - box.min.y) / 2) - render.location = new Cell(centerX, centerY) - render.fontSize = Math.max(Math.min(box.max.x - box.min.x, box.max.y - box.min.y) / player.info().name.length / 2, 1) - } - return wasUpdated - } - - renderPlayerInfo(player: Player) { - if (!player.isAlive()) { - return - } - if (!this.nameRenders.has(player)) { - return - } - - const render = this.nameRenders.get(player) - - this.offscreenContext.font = `${render.fontSize}px Arial`; - this.offscreenContext.fillStyle = this.theme.playerInfoColor(player.id()).toHex(); - this.offscreenContext.textAlign = 'center'; - this.offscreenContext.textBaseline = 'middle'; - - const nameCenterX = render.location.x - this.gs.width() / 2 - const nameCenterY = render.location.y - this.gs.height() / 2 - this.offscreenContext.fillText(player.info().name, nameCenterX, nameCenterY - render.fontSize / 2); - this.offscreenContext.fillText(String(Math.floor(player.troops())), nameCenterX, nameCenterY + render.fontSize); + this.nameRenderer.tick() } tileUpdate(event: TileEvent) { @@ -271,12 +206,27 @@ export class GameRenderer { const gameX = centerX + this.gs.width() / 2 const gameY = centerY + this.gs.height() / 2 - - console.log(`zoom point ${centerX} ${centerY}`) - console.log(`Current scale: ${this.scale}`); - console.log(`Current offset: ${this.offsetX}, ${this.offsetY}`); - return new Cell(Math.floor(gameX), Math.floor(gameY)); } + boundingRect(): [Cell, Cell] { + + // Calculate the world point we want to zoom towards + const LeftX = (- this.gs.width() / 2) / this.scale + this.offsetX; + const TopY = (- this.gs.height() / 2) / this.scale + this.offsetY; + + const gameLeftX = LeftX + this.gs.width() / 2 + const gameTopY = TopY + this.gs.height() / 2 + + + // Calculate the world point we want to zoom towards + const rightX = (screen.width - this.gs.width() / 2) / this.scale + this.offsetX; + const rightY = (screen.height - this.gs.height() / 2) / this.scale + this.offsetY; + + const gameRightX = rightX + this.gs.width() / 2 + const gameBottomY = rightY + this.gs.height() / 2 + + return [new Cell(Math.floor(gameLeftX), Math.floor(gameTopY)), new Cell(Math.floor(gameRightX), Math.floor(gameBottomY))] + } + } \ No newline at end of file diff --git a/src/client/graphics/NameRenderer.ts b/src/client/graphics/NameRenderer.ts new file mode 100644 index 000000000..49665ba0e --- /dev/null +++ b/src/client/graphics/NameRenderer.ts @@ -0,0 +1,121 @@ +import PriorityQueue from "priority-queue-typescript" +import {Cell, Game, Player} from "../../core/Game" +import {PseudoRandom} from "../../core/PseudoRandom" +import {Theme} from "../../core/Settings" +import {calculateBoundingBox} from "../NameBoxCalculator" + +class RenderInfo { + constructor(public player: Player, public lastRendered: number, public location: Cell, public fontSize: number) { } +} + +export class NameRenderer { + + private lastChecked = 0 + private refreshRate = 1000 + + private rand = new PseudoRandom(10) + private renderInfo: Map = new Map() + private context: CanvasRenderingContext2D + private canvas: HTMLCanvasElement + private toRender: PriorityQueue = new PriorityQueue(1000, (a: RenderInfo, b: RenderInfo) => a.lastRendered - b.lastRendered); + private seenPlayers: Set = new Set() + + + + constructor(private game: Game, private theme: Theme) { + + } + + + public initialize() { + this.canvas = document.createElement('canvas'); + this.context = this.canvas.getContext('2d'); + + this.canvas.style.position = 'fixed'; + this.canvas.style.left = '0'; + this.canvas.style.top = '0'; + this.canvas.width = this.game.width(); + this.canvas.height = this.game.height(); + } + + public render(mainContex: CanvasRenderingContext2D, scale: number, uppperLeft: Cell, bottomRight: Cell) { + // mainContex.drawImage( + // this.canvas, + // -this.game.width() / 2, + // -this.game.height() / 2, + // this.game.width(), + // this.game.height() + // ) + for (const render of this.toRender) { + if (render.player.isAlive()) { + this.renderPlayerInfo(render, mainContex, scale, uppperLeft, bottomRight) + } + } + } + + public tick() { + const now = Date.now() + if (now - this.lastChecked > this.refreshRate) { + this.lastChecked = now + for (const player of this.game.players()) { + if (!this.seenPlayers.has(player)) { + this.toRender.add(new RenderInfo(player, 0, null, null)) + this.seenPlayers.add(player) + } + } + } + + while (!this.toRender.empty() && now - this.toRender.peek().lastRendered > this.refreshRate) { + const renderInfo = this.toRender.poll() + this.calculateRenderInfo(renderInfo) + renderInfo.lastRendered = now + this.rand.nextInt(-50, 50) + this.toRender.add(renderInfo) + } + + } + + calculateRenderInfo(render: RenderInfo): boolean { + + let wasUpdated = false + + render.lastRendered = Date.now() + this.rand.nextInt(0, 100) + wasUpdated = true + + const box = calculateBoundingBox(render.player) + const centerX = box.min.x + ((box.max.x - box.min.x) / 2) + const centerY = box.min.y + ((box.max.y - box.min.y) / 2) + render.location = new Cell(centerX, centerY) + render.fontSize = Math.max(Math.min(box.max.x - box.min.x, box.max.y - box.min.y) / render.player.info().name.length / 2, 2) + return wasUpdated + } + + renderPlayerInfo(render: RenderInfo, context: CanvasRenderingContext2D, scale: number, uppperLeft: Cell, bottomRight: Cell) { + + // console.log(`scale: ${scale}, fontSize: ${render.fontSize}, mult: ${scale * render.fontSize}`) + if (render.fontSize * scale < 10) { + return + } + + const nameCenterX = Math.floor(render.location.x - this.game.width() / 2) + const nameCenterY = Math.floor(render.location.y - this.game.height() / 2) + + if (render.location.x < uppperLeft.x || render.location.x > bottomRight.x || render.location.y < uppperLeft.y || render.location.y > bottomRight.y) { + return + } + + // if (nameCenterX, ) { + + // } + + context.textRendering = "optimizeSpeed"; + + context.font = `${render.fontSize}px Arial`; + context.fillStyle = this.theme.playerInfoColor(render.player.id()).toHex(); + context.textAlign = 'center'; + context.textBaseline = 'middle'; + + + context.fillText(render.player.info().name, nameCenterX, nameCenterY - render.fontSize / 2); + context.fillText(String(Math.floor(render.player.troops())), nameCenterX, nameCenterY + render.fontSize); + } +} \ No newline at end of file diff --git a/src/client/index.html b/src/client/index.html index dc4b8ed64..35965a4eb 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -9,7 +9,6 @@

Warfront

-

Available Lobbies

diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index d0eae60b0..d92171c41 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -56,6 +56,7 @@ const IntentSchema = z.union([AttackIntentSchema, SpawnIntentSchema, BoatAttackI const TurnSchema = z.object({ turnNumber: z.number(), + gameID: z.string(), intents: z.array(IntentSchema) }) @@ -87,7 +88,7 @@ const ClientBaseMessageSchema = z.object({ export const ClientIntentMessageSchema = ClientBaseMessageSchema.extend({ type: z.literal('intent'), clientID: z.string(), - //gameID: z.string(), + gameID: z.string(), intent: IntentSchema }) diff --git a/src/core/Settings.ts b/src/core/Settings.ts index ea606fc58..279def78e 100644 --- a/src/core/Settings.ts +++ b/src/core/Settings.ts @@ -28,10 +28,10 @@ export const defaultSettings = new class implements Settings { return 100 } lobbyCreationRate(): number { - return 5 * 1000 + return 2 * 1000 } lobbyLifetime(): number { - return 2 * 1000 + return 3 * 1000 } theme(): Theme {return pastelTheme;} diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 6970c78af..0748bd9aa 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -43,7 +43,7 @@ export class AttackExecution implements Execution { // } - let numTilesPerTick = this._owner.borderTiles().size / 2 + let numTilesPerTick = this._owner.borderTiles().size / 5 while (numTilesPerTick > 0) { if (this.troops < 1) { this.active = false diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 671a08824..4efe5638f 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -12,7 +12,7 @@ export class PlayerExecution implements Execution { } tick(ticks: number) { - this.player.addTroops(Math.sqrt(this.player.numTilesOwned() * this.player.troops() + 1000) / 1000 + 100) + this.player.addTroops(Math.sqrt(this.player.numTilesOwned() * this.player.troops() + 1000) / 1000) } owner(): MutablePlayer { diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 097951853..15b5aa918 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -49,11 +49,11 @@ export class GameManager { tick() { const now = Date.now() - const active = this.lobbies().filter(l => !l.isExpired(now - 1000)) - const expired = this.lobbies().filter(l => l.isExpired(now - 1000)) + const active = this.lobbies().filter(l => !l.isExpired(now - 2000)) + const expired = this.lobbies().filter(l => l.isExpired(now - 2000)) this._lobbies = new Map(active.map(lobby => [lobby.id, lobby])); expired.forEach(lobby => { - const game = new GameServer(generateUniqueID(), lobby.clients, this.settings) + const game = new GameServer(lobby.id, lobby.clients, this.settings) this.games.set(game.id, game) game.start() }) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index de772f0f7..a14cb99da 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -22,7 +22,11 @@ export class GameServer { c.ws.on('message', (message: string) => { const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message)) if (clientMsg.type == "intent") { - this.addIntent(clientMsg.intent) + if (clientMsg.gameID == this.id) { + this.addIntent(clientMsg.intent) + } else { + console.warn(`client ${clientMsg.clientID} sent to wrong game`) + } } }) }) @@ -46,6 +50,7 @@ export class GameServer { private endTurn() { const pastTurn: Turn = { turnNumber: this.turns.length, + gameID: this.id, intents: this.intents } this.turns.push(pastTurn) diff --git a/src/server/Server.ts b/src/server/Server.ts index 6c6cb757d..e0817be1b 100644 --- a/src/server/Server.ts +++ b/src/server/Server.ts @@ -27,7 +27,7 @@ const gm = new GameManager(defaultSettings) // New GET endpoint to list lobbies app.get('/lobbies', (req, res) => { - const lobbyList = Array.from(gm.lobbies()).map(lobby => ({ + const lobbyList = Array.from(gm.lobbies()).filter(l => !l.isExpired(Date.now())).map(lobby => ({ id: lobby.id, })); @@ -42,8 +42,12 @@ wss.on('connection', (ws) => { console.log(`got message ${message}`) const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message)) if (clientMsg.type == "join") { + console.log('got join request') if (gm.hasLobby(clientMsg.lobbyID)) { + console.log('client joining lobby') gm.addClientToLobby(new Client(clientMsg.clientID, ws), clientMsg.lobbyID) + } else { + console.log('lobby not found') } } // TODO: send error message