render player names efficiently

This commit is contained in:
evanpelle
2024-08-11 13:47:10 -07:00
parent 13808f4d70
commit 1070a5171a
13 changed files with 207 additions and 108 deletions
+5 -2
View File
@@ -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<TerrainMap>
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()
})
+21 -7
View File
@@ -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(),
@@ -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<Player, NameRender> = 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))]
}
}
+121
View File
@@ -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<Player, RenderInfo> = new Map()
private context: CanvasRenderingContext2D
private canvas: HTMLCanvasElement
private toRender: PriorityQueue<RenderInfo> = new PriorityQueue<RenderInfo>(1000, (a: RenderInfo, b: RenderInfo) => a.lastRendered - b.lastRendered);
private seenPlayers: Set<Player> = 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);
}
}
-1
View File
@@ -9,7 +9,6 @@
<body>
<h1>Warfront</h1>
<button id="startButton">Start Game</button>
<div id="game-setup">
<div id="lobbies-container">
<h2>Available Lobbies</h2>
+2 -1
View File
@@ -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
})
+2 -2
View File
@@ -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;}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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()
})
+6 -1
View File
@@ -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)
+5 -1
View File
@@ -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