From 9946fd848541f34d0050f4091e50076bde6f9245 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 17 May 2025 15:54:11 +0200 Subject: [PATCH] ported Vis --- src/app/components/game/vis/VisEnemy.ts | 60 ++++ src/app/components/game/vis/VisLevel.ts | 298 +++++++++++++++++++ src/app/components/game/vis/VisMain.ts | 106 +++++++ src/app/components/game/vis/VisProjectile.ts | 16 + 4 files changed, 480 insertions(+) create mode 100644 src/app/components/game/vis/VisEnemy.ts create mode 100644 src/app/components/game/vis/VisLevel.ts create mode 100644 src/app/components/game/vis/VisMain.ts create mode 100644 src/app/components/game/vis/VisProjectile.ts diff --git a/src/app/components/game/vis/VisEnemy.ts b/src/app/components/game/vis/VisEnemy.ts new file mode 100644 index 0000000..e9d50ba --- /dev/null +++ b/src/app/components/game/vis/VisEnemy.ts @@ -0,0 +1,60 @@ +import { AssetPreloaderService } from "../../../assetPreloaderService"; +import { EEnemySize } from "../data/EEnemySize"; +import { GdRoot } from "../data/GdRoot"; +import { SimEnemy } from "../sim/SimEnemy"; +import { Vector2 } from "../util/Vector2"; + +export class VisEnemy { + positions: Vector2[]; + directions: Vector2[]; + image: HTMLCanvasElement; + context: CanvasRenderingContext2D; + private simEnemy: SimEnemy; + private angle: number | null = null; + private gdRoot: GdRoot; + assets: AssetPreloaderService; + + constructor(gdRoot: GdRoot, assets: AssetPreloaderService, simEnemy: SimEnemy, width: number, height: number) { + this.assets = assets; + this.gdRoot = gdRoot; + this.simEnemy = simEnemy; + this.image = document.createElement("canvas"); + this.image.width = width; + this.image.height = height; + this.context = this.image.getContext("2d")!; + this.positions = [simEnemy.position, simEnemy.position]; + this.directions = [simEnemy.direction, simEnemy.direction]; + } + + public advanceStep() { + const prevPos = this.positions[1]; + const prevDir = this.directions[1]; + this.positions = [prevPos, this.simEnemy.position]; + this.directions = [prevDir, this.simEnemy.direction]; + } + + public update(t: number) { + const directions = this.directions; + const dir = Vector2.lerp(directions[0], directions[1], t); + const angle = Math.atan2(dir.x, -dir.y) - Math.PI / 2; + if (this.angle == angle) { + return; + } + this.angle = angle; + const ctx = this.context; + ctx.clearRect(0, 0, this.image.width, this.image.height); + ctx.save(); + ctx.translate(this.image.width / 2, this.image.height / 2); + ctx.rotate(angle); + switch (this.simEnemy.size) { + case EEnemySize.Tiny: + ctx.scale(0.75, 0.75); + break; + case EEnemySize.Huge: + ctx.scale(2, 2); + break; + } + ctx.drawImage(this.assets.getImage("enemy-" + (this.simEnemy.index | 0) + ".svg"), -this.image.width / 2, -this.image.height / 2, this.image.width, this.image.height); + ctx.restore(); + } +} diff --git a/src/app/components/game/vis/VisLevel.ts b/src/app/components/game/vis/VisLevel.ts new file mode 100644 index 0000000..c7c8c73 --- /dev/null +++ b/src/app/components/game/vis/VisLevel.ts @@ -0,0 +1,298 @@ +import { AssetPreloaderService } from "../../../assetPreloaderService"; +import { GdRoot } from "../data/GdRoot"; +import { ECellType } from "../sim/ECellType"; +import { SimCell } from "../sim/SimCell"; +import { SimEnemy } from "../sim/SimEnemy"; +import { SimLevel } from "../sim/SimLevel"; +import { SimMain } from "../sim/SimMain"; +import { SimProjectile } from "../sim/SimProjectile"; +import { Hex } from "../util/Hex"; +import { Vector2 } from "../util/Vector2"; +import { VisEnemy } from "./VisEnemy"; +import { VisMain } from "./VisMain"; +import { VisProjectile } from "./VisProjectile"; + +export class VisLevel { + private screenCellWidth: number = -1; + private screenCellHeight: number = -1; + private screenXOffset: number = -1; + private screenYOffset: number = -1; + private hexSize: number = -1; + private lastStep: number = -1; + private projectileMap: Map; + private enemyMap: Map; + private background: HTMLCanvasElement | null = null; + private simLevel: SimLevel | null = null; + private visMain: VisMain; + private simMain: SimMain; + private gdRoot: GdRoot; + assets: AssetPreloaderService; + + constructor(visMain: VisMain, simMain: SimMain, gdRoot: GdRoot, assets: AssetPreloaderService) { + this.assets = assets; + this.visMain = visMain; + this.simMain = simMain; + this.gdRoot = gdRoot + this.enemyMap = new Map(); + this.projectileMap = new Map(); + this.reset(); + } + + public reset() { + this.projectileMap.clear(); + this.enemyMap.clear(); + this.background = null; + } + + public draw() { + const ctx = this.visMain.context; + const simLevel = this.simMain.currentLevel; + if (simLevel == null) { + return; + } + const gdLevel = this.gdRoot.levels[simLevel.index]; + + if (simLevel != this.simLevel) { + this.reset(); + this.simLevel = simLevel; + } + + this.drawBackground(); + ctx.globalCompositeOperation = "source-over"; + + simLevel.cells.forEach((cell: SimCell) => { + if (cell.distance > gdLevel.radius) { + return; + } + this.drawCell(cell); + }); + + simLevel.enemies.forEach((enemy: SimEnemy) => { + this.drawEnemy(enemy); + }); + + simLevel.projectiles.forEach((projectile: SimProjectile) => { + this.drawProjectile(projectile); + }); + + ctx.fillStyle = "white"; + ctx.fillText("Currency: " + simLevel.currency, 5, 15); + ctx.fillText("Current wave: " + simLevel.currentWave, 5, 35); + ctx.fillText("Enemies left: " + simLevel.enemiesLeftToSpawn, 5, 55); + ctx.fillText("Current step: " + simLevel.currentStep, 5, 75); + } + + public updateSize() { + const simLevel = this.simMain.currentLevel; + if (simLevel == null) { + return; + } + + const gdLevel = this.gdRoot.levels[simLevel.index]; + const minSize = Math.min(this.visMain.canvas.height, this.visMain.canvas.width * Math.sqrt(3) / 2); + this.hexSize = Math.floor(minSize / (4 * (gdLevel.radius - 1) - 4)); + this.screenCellHeight = Math.ceil(2 * this.hexSize); + this.screenCellHeight = Math.ceil(this.screenCellHeight * 0.5) * 2; + this.screenCellWidth = Math.ceil(Math.sqrt(3) / 2 * this.screenCellHeight); + this.screenCellWidth = Math.ceil(this.screenCellWidth * 0.5) * 2; + this.screenXOffset = this.screenCellWidth * (gdLevel.radius + 0.5); + this.screenYOffset = this.screenCellHeight * (gdLevel.radius + 0.5); + + const width = this.screenCellWidth * (gdLevel.radius * 2 + 1); + const height = this.screenCellHeight * (gdLevel.radius * 2 + 1); + this.screenXOffset += (this.visMain.canvas.width - width) * 0.5; + this.screenYOffset += (this.visMain.canvas.height - height) * 0.5; + + this.screenXOffset = Math.floor(this.screenXOffset); + this.screenYOffset = Math.floor(this.screenYOffset); + this.background = null; + this.enemyMap.clear(); + } + + public updateEveryFrame(currentStep: number) { + const simLevel = this.simMain.currentLevel; + if (simLevel == null) { + return; + } + + const t = currentStep - Math.floor(currentStep); + + const deadEnemies: SimEnemy[] = []; + simLevel.enemies.forEach((simEnemy: SimEnemy) => { + if (simEnemy.dead) { + deadEnemies.push(simEnemy); + return; + } + + const visEnemy = this.enemyMap.get(simEnemy); + if (!visEnemy) { + this.enemyMap.set(simEnemy, new VisEnemy(this.gdRoot, this.assets, simEnemy, this.screenCellWidth, this.screenCellHeight)); + } + else if (Math.floor(currentStep) != Math.floor(this.lastStep)) { + visEnemy.advanceStep(); + } + else { + visEnemy.update(t); + } + }); + for (const deadEnemy of deadEnemies) { + this.enemyMap.delete(deadEnemy); + } + + const deadProjectiles: SimProjectile[] = []; + simLevel.projectiles.forEach((simProjectile: SimProjectile) => { + if (simProjectile.dead) { + deadProjectiles.push(simProjectile); + return; + } + + const visProjectile = this.projectileMap.get(simProjectile); + if (!visProjectile) { + this.projectileMap.set(simProjectile, new VisProjectile(simProjectile)); + } + else if (Math.floor(currentStep) != Math.floor(this.lastStep)) { + visProjectile.advanceStep(); + } + }); + for (const deadProjectile of deadProjectiles) { + this.projectileMap.delete(deadProjectile); + } + + this.lastStep = currentStep; + } + + public getScreenCoords(hex: Hex): Vector2 { + const coord = Hex.toPixel(hex, this.hexSize); + return new Vector2(coord.x + this.screenXOffset - this.screenCellWidth / 2, coord.y + this.screenYOffset - this.screenCellHeight / 2); + } + + public getHexFromScreenCoords(coords: Vector2): Hex { + const x = coords.x - this.screenXOffset; + const y = coords.y - this.screenYOffset; + return Hex.fromPixel(new Vector2(x, y), this.hexSize); + } + + private drawBackground() { + const ctx = this.visMain.context; + const simLevel = this.simMain.currentLevel; + if (simLevel == null) { + return; + } + + const gdLevel = this.gdRoot.levels[simLevel.index]; + if (this.background == null) { + const backgroundCanvas = this.visMain.canvas.cloneNode() as HTMLCanvasElement; + const backgroundContext = backgroundCanvas.getContext("2d")!; + this.background = backgroundCanvas; + simLevel.cells.forEach((cell: SimCell) => { + if (cell.distance > gdLevel.radius) { + return; + } + if (cell.blockedType != -1 && cell.type == ECellType.Blocked) { + this.drawCellImage(backgroundContext, cell, "cell-blocked-" + (cell.blockedType | 0) + ".svg"); + } + }); + + backgroundContext.globalCompositeOperation = "source-atop"; + backgroundContext.fillStyle = this.visMain.wallPattern; + backgroundContext.fillRect(0, 0, this.visMain.canvas.width, this.visMain.canvas.height); + + const cellCanvas = this.visMain.canvas.cloneNode() as HTMLCanvasElement; + const cellContext = cellCanvas.getContext("2d")!; + simLevel.cells.forEach((cell: SimCell) => { + if (cell.distance > gdLevel.radius) { + return; + } + if (cell.type != ECellType.Entry) { + this.drawCellImage(cellContext, cell, "cell.svg"); + } + }); + backgroundContext.globalCompositeOperation = "destination-over"; + backgroundContext.drawImage(cellCanvas, 0, 0); + backgroundContext.globalCompositeOperation = "source-over"; + } + + ctx.drawImage(this.background, 0, 0); + } + + private drawProjectile(simProjectile: SimProjectile) { + const visProjectile = this.projectileMap.get(simProjectile); + if (!visProjectile) { + return; + } + const t = this.lastStep - Math.floor(this.lastStep); + const positions = visProjectile.positions; + if (!positions) { + return; + } + const pos = Vector2.lerp(positions[0], positions[1], t); + const width = this.screenCellWidth * simProjectile.size; + const height = this.screenCellHeight * simProjectile.size; + this.visMain.context.drawImage(this.assets.getImage("projectile.svg"), + this.screenXOffset + pos.x * this.hexSize - width / 2, + this.screenYOffset + pos.y * this.hexSize - height / 2, + width, + height); + } + + private drawEnemy(simEnemy: SimEnemy) { + const visEnemy = this.enemyMap.get(simEnemy); + if (!visEnemy) { + return; + } + const t = this.lastStep - Math.floor(this.lastStep); + const positions = visEnemy.positions; + if (!positions) { + return; + } + const pos = Vector2.lerp(positions[0], positions[1], t); + this.visMain.context.drawImage( + visEnemy.image, + this.screenXOffset + pos.x * this.hexSize - this.screenCellWidth / 2, + this.screenYOffset + pos.y * this.hexSize - this.screenCellHeight / 2, + this.screenCellWidth, + this.screenCellHeight); + } + + private drawCell(cell: SimCell) { + const simLevel = this.simMain.currentLevel; + if (simLevel == null) { + return; + } + + this.visMain.context.fillStyle = "rgba(192, 192, 192, 0.25)"; + if (cell.type == ECellType.Entry) { + this.drawCellImage(this.visMain.context, cell, "cell-entry-" + (cell.blockedType | 0) + ".svg"); + } + + const highlightedCell = simLevel.cells[simLevel.highlightedIndex]; + if (!!highlightedCell) { + let draw = highlightedCell.index == cell.index; + if (draw && highlightedCell.pathsToTarget != null) { + for (const routeIdx in highlightedCell.pathsToTarget) { + for (const idx in highlightedCell.pathsToTarget[routeIdx]) { + if (highlightedCell.pathsToTarget[routeIdx][idx] == highlightedCell.index) { + draw = true; + break; + } + } + } + } + if (draw) { + this.drawCellImage(this.visMain.context, highlightedCell, "cell-highlighted.svg"); + this.visMain.context.fillStyle = "rgba(0, 0, 0, 1)"; + } + } + if (cell.tower != null) { + this.drawCellImage(this.visMain.context, cell, "tower-" + (cell.tower.index | 0) + ".svg"); + } + + const coords = this.getScreenCoords(cell.hex); + this.visMain.context.fillText("(" + cell.hex.col + ", " + cell.hex.row + ")", coords.x + 10, coords.y + this.screenCellHeight / 2 + 5); + } + + private drawCellImage(context: CanvasRenderingContext2D, cell: SimCell, name: string) { + const coords = this.getScreenCoords(cell.hex); + context.drawImage(this.assets.getImage(name), coords.x, coords.y, this.screenCellWidth, this.screenCellHeight); + } +} diff --git a/src/app/components/game/vis/VisMain.ts b/src/app/components/game/vis/VisMain.ts new file mode 100644 index 0000000..2ccef06 --- /dev/null +++ b/src/app/components/game/vis/VisMain.ts @@ -0,0 +1,106 @@ +import { AssetPreloaderService } from "../../../assetPreloaderService"; +import { SplashComponent } from "../../splash/splash.component"; +import { SimMain } from "../sim/SimMain"; +import { VisLevel } from "./VisLevel"; + +export class VisMain { + canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D; + wallPattern: CanvasPattern; + visLevel: VisLevel; + simMain: SimMain; + private startTimestamp: number = 0; + private active: boolean = true; + private ready: boolean = false; + private gap: number = 0; + assets: AssetPreloaderService; + + constructor(simMain: SimMain, assets: AssetPreloaderService, canvas: HTMLCanvasElement) { + this.assets = assets; + this.simMain = simMain; + this.canvas = canvas; + this.context = this.canvas.getContext("2d")!; + this.context.globalCompositeOperation = "source-over"; + this.wallPattern = this.createPattern(assets.getImage("wall.png"), 48); + this.visLevel = new VisLevel(this, this.simMain, this.simMain.gdRoot, assets); + const host = this; + requestAnimationFrame(function step(timestamp) { + host.step(timestamp); + }); + } + + private createPattern(image: HTMLImageElement, size: number): CanvasPattern { + const tempCanvas = document.createElement("canvas"); + const tempContext = tempCanvas.getContext("2d")!; + + tempCanvas.width = size; + tempCanvas.height = size; + tempContext.drawImage(image, 0, 0, image.width, image.height, 0, 0, size, size); + + return this.context.createPattern(tempCanvas, 'repeat')!; + } + + private step(timestamp: number) { + if (!this.active) { + return; + } + + const host = this; + requestAnimationFrame((timestamp: number) => { + host.step(timestamp); + }); + + if (!this.startTimestamp) { + this.startTimestamp = timestamp; + } + + const simLevel = this.simMain.currentLevel; + if (!simLevel) { + return; + } + + let targetStep = (timestamp - this.startTimestamp) * this.simMain.gdRoot.simulation.stepsPerSecond / 1000 - this.gap; + if (simLevel.paused) { + this.gap += targetStep - simLevel.currentStep; + targetStep = simLevel.currentStep; + } + this.simMain.executeUntilStep(targetStep); + this.visLevel.updateEveryFrame(targetStep); + this.onRender(); + }; + + public onResized() { + const gameHost = document.getElementById("game-host") as HTMLDivElement; + const width = gameHost.clientWidth; + const height = gameHost.clientHeight; + const ratio = window.devicePixelRatio; + this.canvas.width = width * ratio; + this.canvas.height = height * ratio; + this.canvas.style.width = width + "px"; + this.canvas.style.height = height + "px"; + this.context.scale(ratio, ratio); + this.visLevel.updateSize(); + }; + + private onRender() { + this.clear(); + const ctx = this.context; + ctx.font = "12px Tahoma"; + const simLevel = this.simMain.currentLevel; + if (!!simLevel) { + if (!this.ready) { + this.onResized(); + this.ready = true; + } + this.visLevel.draw(); + } + }; + + private clear() { + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); + } + + private stop() { + this.active = false; + } +} diff --git a/src/app/components/game/vis/VisProjectile.ts b/src/app/components/game/vis/VisProjectile.ts new file mode 100644 index 0000000..323a03a --- /dev/null +++ b/src/app/components/game/vis/VisProjectile.ts @@ -0,0 +1,16 @@ +import { SimProjectile } from "../sim/SimProjectile"; +import { Vector2 } from "../util/Vector2"; + +export class VisProjectile { + positions: Vector2[]; + private simProjectile: SimProjectile; + + constructor(simProjectile: SimProjectile) { + this.simProjectile = simProjectile; + this.positions = [simProjectile.position, simProjectile.position]; + } + + public advanceStep() { + this.positions = [this.positions[1], this.simProjectile.position]; + } +}