fixed preloading and integrated vis

This commit is contained in:
2025-05-17 18:43:17 +02:00
parent 6ffa7e9c68
commit c6a3056dcd
7 changed files with 74 additions and 185 deletions

View File

@@ -29,13 +29,36 @@ export class AssetPreloaderService {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
if (entry.type === 'image') { if (entry.type === 'image') {
const img = new Image(); const img = new Image();
const loadedPromise = new Promise<void>((resolve, reject) => {
img.onload = () => {
URL.revokeObjectURL(url);
resolve();
};
img.onerror = (e) => {
console.error(`Failed to load image: ${entry.path}`, e);
URL.revokeObjectURL(url);
reject(e);
};
});
img.src = url; img.src = url;
this.images.set(entry.path, img); return loadedPromise.then(() => {
this.images.set(entry.path, img);
});
} else if (entry.type === 'audio') { } else if (entry.type === 'audio') {
const audio = new Audio(); const audio = new Audio();
const loadedPromise = new Promise<void>((resolve, reject) => {
audio.oncanplaythrough = () => resolve();
audio.onerror = (e) => {
console.error(`Failed to load audio: ${entry.path}`, e);
resolve(); // Don't reject to avoid blocking all
};
});
audio.src = url; audio.src = url;
this.audio.set(entry.path, audio); return loadedPromise.then(() => {
this.audio.set(entry.path, audio);
});
} }
return Promise.resolve();
}) })
); );
@@ -44,21 +67,24 @@ export class AssetPreloaderService {
private async loadAsset(entry: AssetEntry): Promise<Blob> { private async loadAsset(entry: AssetEntry): Promise<Blob> {
const response = await fetch(`/assets/${entry.path}`); const response = await fetch(`/assets/${entry.path}`);
if (!response.ok) {
throw new Error(`Failed to fetch asset: ${entry.path}`);
}
const contentType = response.headers.get('Content-Type') || undefined;
const reader = response.body?.getReader(); const reader = response.body?.getReader();
let loaded = 0; let loaded = 0;
const chunks = []; const chunks = [];
while (true) { while (true) {
const { done, value } = await reader!.read(); const { done, value } = await reader!.read();
if (done) if (done) break;
break;
chunks.push(value); chunks.push(value);
loaded += value.length; loaded += value.length;
this.loadedBytes += value.length; this.loadedBytes += value.length;
this._progress.next(this.loadedBytes / this.totalBytes); this._progress.next(this.loadedBytes / this.totalBytes);
} }
return new Blob(chunks); return new Blob(chunks, contentType ? { type: contentType } : undefined);
} }
getImage(name: string): HTMLImageElement { getImage(name: string): HTMLImageElement {

View File

@@ -3,7 +3,7 @@
<div class="main-area"> <div class="main-area">
<div class="canvas-wrapper" #canvasWrapper> <div class="canvas-wrapper" #canvasWrapper>
<canvas #gameCanvas (click)="onCanvasClick($event)"></canvas> <canvas #gameCanvas></canvas>
</div> </div>
<div class="bottom-panel"> <div class="bottom-panel">
<button (click)="rewind()">⏮ Rewind</button> <button (click)="rewind()">⏮ Rewind</button>

View File

@@ -3,6 +3,8 @@ import { OptionsComponent } from '../options/options.component';
import { SimMain } from './sim/SimMain'; import { SimMain } from './sim/SimMain';
import { GdRoot } from './data/GdRoot'; import { GdRoot } from './data/GdRoot';
import { SimCommand } from './sim/commands/SimCommand'; import { SimCommand } from './sim/commands/SimCommand';
import { VisMain } from './vis/VisMain';
import { SplashComponent } from '../splash/splash.component';
@Component({ @Component({
selector: 'app-game', selector: 'app-game',
@@ -10,33 +12,30 @@ import { SimCommand } from './sim/commands/SimCommand';
styleUrls: ['./game.component.css'], styleUrls: ['./game.component.css'],
imports: [OptionsComponent], imports: [OptionsComponent],
}) })
export class GameComponent implements OnInit { export class GameComponent {
simMain!: SimMain; simMain: SimMain = new SimMain();
private optionsOpen = false; visMain!: VisMain;
private isPaused = false;
private lastFrameTime = 0;
private tileSize = 10; // Size of each tile in pixels
@ViewChild('gameCanvas') canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('gameCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
@ViewChild('canvasWrapper') wrapperRef!: ElementRef<HTMLDivElement>; @ViewChild('canvasWrapper') wrapperRef!: ElementRef<HTMLDivElement>;
private logicalWidth = 800;
private logicalHeight = 600;
scaleX: number = 1; scaleX: number = 1;
scaleY: number = 1; scaleY: number = 1;
pixelScale: number = 1; pixelScale: number = 1;
optionsOpen: boolean = false;
async ngOnInit() { async ngAfterViewInit() {
const gdRoot = await this.loadGdRoot(); const gdRoot = await this.loadGdRoot();
this.simMain = new SimMain(gdRoot); this.simMain.setGdRoot(gdRoot);
requestAnimationFrame(this.gameLoop.bind(this)); this.visMain = new VisMain(this.simMain, SplashComponent.assetPreloader, this.wrapperRef.nativeElement, this.canvasRef.nativeElement);
window.addEventListener('resize', _ => this.visMain.onResized());
} }
start() { start() {
this.simMain.start(performance.now()); this.visMain.start();
} }
stop() { stop() {
this.simMain.stop(); this.visMain.start();
} }
step() { step() {
@@ -48,14 +47,13 @@ export class GameComponent implements OnInit {
} }
fastForward() { fastForward() {
this.simMain.fastForwardToEnd(); this.simMain.executeToEnd();
} }
async loadGdRoot(): Promise<GdRoot> { async loadGdRoot(): Promise<GdRoot> {
const data = await fetch('/assets/data/gdRoot.json').then((r) => r.json()); const data = await fetch('/assets/data/gdRoot.json').then((r) =>
// if (!isGdRoot(data)) { r.json()
// throw new Error("Invalid GdRoot!"); );
// }
const gdRoot: GdRoot = data; const gdRoot: GdRoot = data;
return gdRoot; return gdRoot;
} }
@@ -64,126 +62,15 @@ export class GameComponent implements OnInit {
this.simMain.setGdRoot(await this.loadGdRoot()); this.simMain.setGdRoot(await this.loadGdRoot());
} }
ngAfterViewInit(): void {
this.resizeCanvas();
requestAnimationFrame(this.update);
window.addEventListener('resize', this.resizeCanvas.bind(this));
}
update = (now: number) => {
if (!this.simMain) {
return;
}
this.simMain.update(now)
const lastStepTime = this.simMain.isRunning
? this.simMain.lastStepTime
: now;
const t = this.simMain.isRunning
? Math.max(
Math.min((now - lastStepTime) / this.simMain.interval, 1),
0
)
: 1;
this.renderFrame(t);
requestAnimationFrame(this.update);
};
renderFrame(t: number) {
const ctx = this.canvasRef.nativeElement.getContext('2d');
if (!ctx) return;
const canvas = this.canvasRef.nativeElement;
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
resizeCanvas(): void {
const wrapper = this.wrapperRef.nativeElement;
const canvas = this.canvasRef.nativeElement;
const targetAspect = this.logicalWidth / this.logicalHeight;
const maxW = wrapper.clientWidth;
const maxH = wrapper.clientHeight;
let width = maxW;
let height = width / targetAspect;
if (height > maxH) {
height = maxH;
width = height * targetAspect;
}
canvas.width = width;
canvas.height = height;
this.scaleX = canvas.width / this.logicalWidth; // e.g. 800
this.scaleY = canvas.height / this.logicalHeight; // e.g. 600
// Optionally use the smaller of the two for uniform scaling
this.pixelScale = Math.min(this.scaleX, this.scaleY);
}
toScreen(x: number, y: number): [number, number] {
return [x * this.pixelScale, y * this.pixelScale];
}
fromScreen(px: number, py: number): [number, number] {
return [px / this.pixelScale, py / this.pixelScale];
}
onCanvasClick(event: MouseEvent) {
const canvas = this.canvasRef.nativeElement;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const px = (event.clientX - rect.left) * scaleX;
const py = (event.clientY - rect.top) * scaleY;
const [gx, gy] = this.fromScreen(px, py);
}
private gameLoop(currentTime: number): void {
if (this.lastFrameTime === 0) {
this.lastFrameTime = currentTime;
}
const deltaTime = (currentTime - this.lastFrameTime) / 1000;
this.lastFrameTime = currentTime;
// Only update the game state if not paused
if (!this.isPaused) {
this.updateGame(deltaTime);
requestAnimationFrame(this.gameLoop.bind(this));
}
}
private updateGame(deltaTime: number): void {
// Implement your game update logic here using deltaTime
}
// Called when opening options: pause the game // Called when opening options: pause the game
public openOptions(): void { public openOptions(): void {
this.optionsOpen = true; this.optionsOpen = true;
this.pauseGame(); this.stop();
} }
// Called when closing options: resume the game // Called when closing options: resume the game
public closeOptions(): void { public closeOptions(): void {
this.optionsOpen = false; this.optionsOpen = false;
this.resumeGame(); this.start();
}
private pauseGame(): void {
this.isPaused = true;
}
private resumeGame(): void {
this.isPaused = false;
// Reset the lastFrameTime to avoid a huge delta on resume
this.lastFrameTime = performance.now();
requestAnimationFrame(this.gameLoop.bind(this));
} }
} }

View File

@@ -9,21 +9,14 @@ import { SimCommand } from './commands/SimCommand';
import { SimLevel } from './SimLevel'; import { SimLevel } from './SimLevel';
export class SimMain { export class SimMain {
executeUntilStep(targetStep: number) {
throw new Error("Method not implemented.");
}
lastTime = 0;
currentStep = -1; currentStep = -1;
isRunning = false;
lastStepTime: number = 0;
currentLevel: SimLevel | null = null; currentLevel: SimLevel | null = null;
commandHistory: SimCommand[] = []; commandHistory: SimCommand[] = [];
gdRoot: GdRoot = null!; gdRoot: GdRoot = null!;
interval: number = 0; interval: number = 0;
private actions: ISimAction[] = []; private actions: ISimAction[] = [];
constructor(gdRoot: GdRoot) { constructor() {
this.setGdRoot(gdRoot);
this.actions.push(new SimActionMoveEnemies()); this.actions.push(new SimActionMoveEnemies());
this.actions.push(new SimActionSpawnEnemies()); this.actions.push(new SimActionSpawnEnemies());
this.actions.push(new SimActionFireTowers()); this.actions.push(new SimActionFireTowers());
@@ -33,15 +26,16 @@ export class SimMain {
setGdRoot(gdRoot: GdRoot) { setGdRoot(gdRoot: GdRoot) {
this.gdRoot = gdRoot; this.gdRoot = gdRoot;
this.interval = 1.0 / this.gdRoot.simulation.stepsPerSecond; this.interval = 1.0 / this.gdRoot.simulation.stepsPerSecond;
this.currentLevel = new SimLevel(this.gdRoot, 0);
} }
fastForwardToEnd() { executeToEnd() {
this.fastForwardTo( this.executeUntilStep(
this.commandHistory[this.commandHistory.length - 1].step this.commandHistory[this.commandHistory.length - 1].step
); );
} }
private fastForwardTo(target: number) { executeUntilStep(target: number) {
this.currentStep = 0; this.currentStep = 0;
while (this.currentStep < target) { while (this.currentStep < target) {
this.step(); this.step();
@@ -62,19 +56,6 @@ export class SimMain {
for (const cmd of commands) { for (const cmd of commands) {
cmd.execute(this); cmd.execute(this);
} }
this.lastStepTime = performance.now();
}
start(now: number) {
if (this.isRunning) return;
this.isRunning = true;
this.lastTime = now;
}
stop() {
this.isRunning = false;
} }
addCommand(command: SimCommand) { addCommand(command: SimCommand) {
@@ -83,13 +64,4 @@ export class SimMain {
); );
this.commandHistory.push(command); this.commandHistory.push(command);
} }
update = (now: number) => {
if (!this.isRunning) return;
while (now - this.lastTime >= this.interval) {
this.step();
this.lastTime += this.interval;
}
};
} }

View File

@@ -54,7 +54,7 @@ export class VisEnemy {
ctx.scale(2, 2); ctx.scale(2, 2);
break; 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.drawImage(this.assets.getImage("images/enemy-" + (this.simEnemy.index | 0) + ".svg"), -this.image.width / 2, -this.image.height / 2, this.image.width, this.image.height);
ctx.restore(); ctx.restore();
} }
} }

View File

@@ -228,7 +228,7 @@ export class VisLevel {
const pos = Vector2.lerp(positions[0], positions[1], t); const pos = Vector2.lerp(positions[0], positions[1], t);
const width = this.screenCellWidth * simProjectile.size; const width = this.screenCellWidth * simProjectile.size;
const height = this.screenCellHeight * simProjectile.size; const height = this.screenCellHeight * simProjectile.size;
this.visMain.context.drawImage(this.assets.getImage("projectile.svg"), this.visMain.context.drawImage(this.assets.getImage("images/projectile.svg"),
this.screenXOffset + pos.x * this.hexSize - width / 2, this.screenXOffset + pos.x * this.hexSize - width / 2,
this.screenYOffset + pos.y * this.hexSize - height / 2, this.screenYOffset + pos.y * this.hexSize - height / 2,
width, width,
@@ -293,6 +293,6 @@ export class VisLevel {
private drawCellImage(context: CanvasRenderingContext2D, cell: SimCell, name: string) { private drawCellImage(context: CanvasRenderingContext2D, cell: SimCell, name: string) {
const coords = this.getScreenCoords(cell.hex); const coords = this.getScreenCoords(cell.hex);
context.drawImage(this.assets.getImage(name), coords.x, coords.y, this.screenCellWidth, this.screenCellHeight); context.drawImage(this.assets.getImage("images/" + name), coords.x, coords.y, this.screenCellWidth, this.screenCellHeight);
} }
} }

View File

@@ -14,19 +14,18 @@ export class VisMain {
private ready: boolean = false; private ready: boolean = false;
private gap: number = 0; private gap: number = 0;
assets: AssetPreloaderService; assets: AssetPreloaderService;
wrapper: HTMLDivElement;
constructor(simMain: SimMain, assets: AssetPreloaderService, canvas: HTMLCanvasElement) { constructor(simMain: SimMain, assets: AssetPreloaderService, wrapper: HTMLDivElement, canvas: HTMLCanvasElement) {
this.assets = assets; this.assets = assets;
this.simMain = simMain; this.simMain = simMain;
this.canvas = canvas; this.canvas = canvas;
this.wrapper = wrapper;
this.context = this.canvas.getContext("2d")!; this.context = this.canvas.getContext("2d")!;
this.context.globalCompositeOperation = "source-over"; this.context.globalCompositeOperation = "source-over";
this.wallPattern = this.createPattern(assets.getImage("wall.png"), 48); this.wallPattern = this.createPattern(assets.getImage("images/wall.png"), 48);
this.visLevel = new VisLevel(this, this.simMain, this.simMain.gdRoot, assets); this.visLevel = new VisLevel(this, this.simMain, this.simMain.gdRoot, assets);
const host = this; this.start();
requestAnimationFrame(function step(timestamp) {
host.step(timestamp);
});
} }
private createPattern(image: HTMLImageElement, size: number): CanvasPattern { private createPattern(image: HTMLImageElement, size: number): CanvasPattern {
@@ -45,9 +44,8 @@ export class VisMain {
return; return;
} }
const host = this;
requestAnimationFrame((timestamp: number) => { requestAnimationFrame((timestamp: number) => {
host.step(timestamp); this.step(timestamp);
}); });
if (!this.startTimestamp) { if (!this.startTimestamp) {
@@ -70,9 +68,8 @@ export class VisMain {
}; };
public onResized() { public onResized() {
const gameHost = document.getElementById("game-host") as HTMLDivElement; const width = this.wrapper.clientWidth;
const width = gameHost.clientWidth; const height = this.wrapper.clientHeight;
const height = gameHost.clientHeight;
const ratio = window.devicePixelRatio; const ratio = window.devicePixelRatio;
this.canvas.width = width * ratio; this.canvas.width = width * ratio;
this.canvas.height = height * ratio; this.canvas.height = height * ratio;
@@ -100,7 +97,14 @@ export class VisMain {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
} }
private stop() { stop() {
this.active = false; this.active = false;
} }
start() {
this.active = true;
requestAnimationFrame((timestamp: number) => {
this.step(timestamp);
});
}
} }