ported simulation

This commit is contained in:
2025-05-17 15:55:26 +02:00
parent 23e5707e98
commit 6ffa7e9c68
33 changed files with 1265 additions and 233 deletions

View File

@@ -1,5 +0,0 @@
export interface Command {
step: number;
type: string;
payload: any;
}

View File

@@ -0,0 +1,5 @@
export enum EEnemySize {
Tiny,
Normal,
Huge
}

View File

@@ -0,0 +1,3 @@
export enum EProjectileEffectType {
Damage = 0
}

View File

@@ -3,7 +3,7 @@
<div class="main-area">
<div class="canvas-wrapper" #canvasWrapper>
<canvas #gameCanvas></canvas>
<canvas #gameCanvas (click)="onCanvasClick($event)"></canvas>
</div>
<div class="bottom-panel">
<button (click)="rewind()">⏮ Rewind</button>
@@ -11,8 +11,7 @@
<button (click)="stop()">⏸ Pause</button>
<button (click)="step()">Step</button>
<button (click)="fastForward()">⏭ Fast Forward</button>
<div>Step: {{ engine.currentStep }}</div>
<button (click)="issueMove()">🚶 Move Unit</button>
<div>Step: {{ simMain.currentStep }}</div>
<button (click)="openOptions()">Options</button>
</div>
</div>

View File

@@ -1,9 +1,8 @@
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import { OptionsComponent } from '../options/options.component';
import { GameState } from './gameState';
import { SimulationEngine } from './simulationEngine';
import { GameRules } from './gameRules';
import { Command } from './command';
import { SimMain } from './sim/SimMain';
import { GdRoot } from './data/GdRoot';
import { SimCommand } from './sim/commands/SimCommand';
@Component({
selector: 'app-game',
@@ -12,10 +11,11 @@ import { Command } from './command';
imports: [OptionsComponent],
})
export class GameComponent implements OnInit {
engine!: SimulationEngine;
simMain!: SimMain;
private optionsOpen = false;
private isPaused = false;
private lastFrameTime = 0;
private tileSize = 10; // Size of each tile in pixels
@ViewChild('gameCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
@ViewChild('canvasWrapper') wrapperRef!: ElementRef<HTMLDivElement>;
@@ -26,93 +26,78 @@ export class GameComponent implements OnInit {
pixelScale: number = 1;
async ngOnInit() {
const initialState: GameState = {
tick: 0,
units: [{ id: 'u1', x: 0, y: 0 }],
commandHistory: [],
};
const json = await fetch('/assets/gameData.json').then((r) => r.json());
this.engine = new SimulationEngine(initialState, GameRules, json);
const gdRoot = await this.loadGdRoot();
this.simMain = new SimMain(gdRoot);
requestAnimationFrame(this.gameLoop.bind(this));
}
start() {
this.engine.start();
this.simMain.start(performance.now());
}
stop() {
this.engine.stop();
this.simMain.stop();
}
step() {
this.engine.step();
this.simMain.step();
}
rewind() {
this.engine.rewindToZero();
//this.simMain.rewindToZero();
}
fastForward() {
this.engine.fastForwardToEnd();
this.simMain.fastForwardToEnd();
}
async loadGdRoot(): Promise<GdRoot> {
const data = await fetch('/assets/data/gdRoot.json').then((r) => r.json());
// if (!isGdRoot(data)) {
// throw new Error("Invalid GdRoot!");
// }
const gdRoot: GdRoot = data;
return gdRoot;
}
async reloadGameData() {
const json = await fetch('/assets/gameData.json').then((r) => r.json());
this.engine.reinitializeWithNewData(json);
}
issueMove() {
const cmd: Command = {
step: this.engine.currentStep + 1,
type: 'move',
payload: { id: 'u1', dx: 1, dy: 0 },
};
this.engine.issueCommand(cmd);
this.simMain.setGdRoot(await this.loadGdRoot());
}
ngAfterViewInit(): void {
this.resizeCanvas();
requestAnimationFrame(this.render);
requestAnimationFrame(this.update);
window.addEventListener('resize', this.resizeCanvas.bind(this));
}
render = (now: number) => {
if (!this.engine) return;
update = (now: number) => {
if (!this.simMain) {
return;
}
const [prevState, currState] = this.engine.getRenderStates();
this.simMain.update(now)
// Compute interpolation factor (0..1)
const lastStepTime = this.engine.isRunning ? this.engine.lastStepTime : now;
const t = this.engine.isRunning
? Math.max(Math.min((now - lastStepTime) / this.engine.interval, 1), 0)
: 1; // fully render the last known state
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(prevState, currState, t);
this.renderFrame(t);
requestAnimationFrame(this.render);
requestAnimationFrame(this.update);
};
renderFrame(prev: GameState, curr: GameState, t: number) {
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);
for (let i = 0; i < curr.units.length; i++) {
const currUnit = curr.units[i];
const prevUnit =
prev.units.find((u) => u.id === currUnit.id) ?? currUnit;
const x = prevUnit.x + (currUnit.x - prevUnit.x) * t;
const y = prevUnit.y + (currUnit.y - prevUnit.y) * t;
ctx.fillStyle = 'cyan';
ctx.beginPath();
ctx.arc(x * 10, y * 10, 5, 0, Math.PI * 2);
ctx.fill();
}
}
resizeCanvas(): void {
@@ -149,6 +134,18 @@ export class GameComponent implements OnInit {
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;

View File

@@ -1,6 +0,0 @@
export interface GameData {
unitSpeed: number;
maxUnits: number;
enemySpawnRate: number;
// anything else that's tunable
}

View File

@@ -1,37 +0,0 @@
import { Command } from './command';
import { GameData } from './gameData';
import { GameState } from './gameState';
export const GameRules = {
step(state: GameState, data: GameData): GameState {
const nextTick = state.tick + 1;
// Get all commands for this step
const commands = state.commandHistory.filter(
(c) => c.step === nextTick
);
// Clone and update
const newState: GameState = {
...state,
tick: state.tick + 1,
units: state.units.map((u) => ({
...u,
x: u.x + 1, // Simple movement
})),
};
for (const cmd of commands) {
this.applyCommand(newState, cmd, data);
}
return newState;
},
applyCommand(state: GameState, cmd: Command, data: GameData): void {
if (cmd.type === 'move') {
const unit = state.units.find((u) => u.id === cmd.payload.id);
if (unit) {
unit.x += cmd.payload.dx * data.unitSpeed;
unit.y += cmd.payload.dy * data.unitSpeed;
}
}
},
};

View File

@@ -1,7 +0,0 @@
import { Command } from "./command";
export interface GameState {
tick: number;
units: { id: string; x: number; y: number }[];
commandHistory: Command[];
}

View File

@@ -0,0 +1,6 @@
export enum ECellType {
Free = 0,
Blocked = 1,
Entry = 2,
Reserved
};

View File

@@ -0,0 +1,20 @@
import { Hex } from "../util/Hex";
import { ECellType } from "./ECellType";
import { SimTower } from "./SimTower";
export class SimCell {
hex: Hex;
distance: number;
type: ECellType;
index: number;
blockedType: number = -1;
tower: SimTower | null = null;
pathsToTarget: number[][] = [];
constructor(hex: Hex, distance: number, type: ECellType, cellIndex: number) {
this.hex = hex;
this.distance = distance;
this.type = type;
this.index = cellIndex;
}
}

View File

@@ -0,0 +1,71 @@
import { EEnemySize } from '../data/EEnemySize';
import { EProjectileEffectType } from '../data/EProjectileEffectType';
import { GdProjectileEffect } from '../data/GdProjectileEffect';
import { Hex } from '../util/Hex';
import { Vector2 } from '../util/Vector2';
import { SimLevel } from './SimLevel';
export class SimEnemy {
index: number = -1;
routeIdx: number = -1;
startHex: Hex = new Hex(0, 0);
endHex: Hex = new Hex(0, 0);
path: number[] = [];
prevPosition: Vector2 = new Vector2(0, 0);
position: Vector2 = new Vector2(0, 0);
direction: Vector2 = new Vector2(0, 0);
currentPathIndex: number = -1;
currentPathStep: number = -1;
currentPathPosition: Vector2 = new Vector2(0, 0);
dead: boolean = false;
hitPonts: number = 0;
size: EEnemySize = EEnemySize.Tiny;
speed: number = 0;
gain: number = 0;
constructor(index: number, routeIdx: number) {
this.index = index;
this.routeIdx = routeIdx;
}
public suffer(level: SimLevel, effect: GdProjectileEffect) {
switch (effect.type) {
case EProjectileEffectType.Damage: {
this.hitPonts -= effect.amount;
if (this.hitPonts <= 0) {
level.currency += this.gain;
this.dead = true;
}
break;
}
}
}
public onPathUpdated(level: SimLevel) {
const myPos = this.position;
this.currentPathPosition = myPos;
if (this.path == null) {
return;
}
const current = this.path[this.currentPathIndex];
const next = this.path[this.currentPathIndex + 1];
if (!next) {
return;
}
const cells = level.cells;
const pos1 = cells[current].hex.toWorld();
const pos2 = cells[next].hex.toWorld();
const myDir = pos2.subtract(myPos);
const myLen = myDir.magnitude();
const hexDir = pos2.subtract(pos1);
const hexLen = hexDir.magnitude();
if (myLen > hexLen) {
this.path.unshift(this.path[0]);
}
this.direction = myDir.multiplyScalar(1 / myLen);
}
}

View File

@@ -0,0 +1,181 @@
import { GdRoot } from "../data/GdRoot";
import { EDirection } from "../util/EDirection";
import { Hex } from "../util/Hex";
import { PathFinding } from "../util/PathFinding";
import { ECellType } from "./ECellType";
import { SimCell } from "./SimCell";
import { SimEnemy } from "./SimEnemy";
import { SimProjectile } from "./SimProjectile";
export class SimLevel {
paused: boolean = true;
currentStep: number = -1;
highlightedIndex: number = -1;
currency: number = -1;
stride: number = -1;
cells: SimCell[] = [];
enemies: SimEnemy[] = [];
projectiles: SimProjectile[] = [];
nextWaveStep: number = -1;
lastEnemySpawnStep: number = 0;
currentWave: number = -1;
enemiesLeftToSpawn: number = 0;
index: number = 0;
radius: number = 0;
constructor(gdRoot: GdRoot, levelIdx: number) {
const gdLevel = gdRoot.levels[levelIdx];
this.index = levelIdx;
this.currency = gdLevel.currency;
this.enemiesLeftToSpawn = gdLevel.waves[0].amount;
this.radius = gdLevel.radius;
this.stride = 2 * this.radius + 1;
const h0 = new Hex(0, 0);
for (let y = -this.radius; y <= this.radius; ++y) {
for (let x = -this.radius; x <= this.radius; ++x) {
const hex = new Hex(x, y);
const distance = Hex.distance(hex, h0);
const type = distance >= this.radius ? ECellType.Blocked : ECellType.Free;
const cellIndex = this.getCellIndex(hex);
this.cells[cellIndex] = new SimCell(hex, distance, type, cellIndex);
}
}
gdLevel.walls.forEach((wall: Hex) => {
const cellIndex = this.getCellIndex(wall);
this.cells[cellIndex].type = ECellType.Blocked;
});
gdLevel.enemySpawns.forEach((hex: Hex) => {
const cellIndex = this.getCellIndex(hex);
this.cells[cellIndex].type = ECellType.Entry;
});
gdLevel.enemyTargets.forEach((hex: Hex) => {
const cellIndex = this.getCellIndex(hex);
this.cells[cellIndex].type = ECellType.Entry;
});
this.cells.forEach((cell: SimCell) => {
this.updateBlockedType(cell);
});
this.updatePaths(gdRoot);
};
public getCellIndex(hex: Hex) {
const x = hex.col + this.radius;
const y = hex.row + this.radius;
if (x < 0 || x >= this.stride || y < 0 || y >= this.stride) {
return -1;
}
return y * this.stride + x;
}
public getNeighbourCell(cell: SimCell, direction: EDirection) {
const hex = cell.hex;
const neighbourHex = Hex.neighbour(hex, direction);
const neighbourIndex = this.getCellIndex(neighbourHex);
return this.cells[neighbourIndex];
}
public updateBlockedType(cell: SimCell) {
if (cell.type == ECellType.Free) {
cell.blockedType = -1;
return;
}
if (cell.type == ECellType.Entry && cell.blockedType != -1) {
return;
}
let blockedType = 0;
for (let direction = 0; direction < 6; ++direction) {
const neighbourCell = this.getNeighbourCell(cell, direction);
if (!!neighbourCell && neighbourCell.type == ECellType.Free) {
blockedType |= 1 << direction;
}
}
cell.blockedType = blockedType;
}
public reservePaths(gdRoot: GdRoot, hexToBlock: Hex | null): boolean {
try {
if (!!hexToBlock) {
const reservedCell = this.cells[this.getCellIndex(hexToBlock)];
if (reservedCell.type != ECellType.Free || !!reservedCell.tower)
return false;
reservedCell.type = ECellType.Reserved;
}
return this.updatePaths(gdRoot);
}
finally {
if (!!hexToBlock) {
const reservedCell = this.cells[this.getCellIndex(hexToBlock)];
if (reservedCell.type == ECellType.Reserved)
reservedCell.type = ECellType.Free
}
}
}
public updatePaths(gdRoot: GdRoot): boolean {
const newRoutePaths: number[][] = [];
const newEnemyPaths: number[][] = [];
const gdLevel = gdRoot.levels[this.index];
let invalid = false;
for (const routeIdx in gdLevel.enemyRoutes) {
const route = gdLevel.enemyRoutes[routeIdx];
const enemySpawnHex = gdLevel.enemySpawns[route[0]];
const enemySpawnCell = this.cells[this.getCellIndex(enemySpawnHex)];
const enemyTargetHex = gdLevel.enemyTargets[route[1]];
const enemyTargetCell = this.cells[this.getCellIndex(enemyTargetHex)];
const path = PathFinding.bfs(this, enemySpawnCell.index, enemyTargetCell.index);
if (path == null) {
invalid = true;
break;
}
newRoutePaths[routeIdx] = path;
}
if (invalid) {
return false;
}
for (let idx in this.enemies) {
const simEnemy = this.enemies[idx];
const hex = Hex.fromWorld(simEnemy.position);
const startIndex = this.getCellIndex(hex);
const endIndex = this.getCellIndex(simEnemy.endHex);
const path = PathFinding.bfs(this, startIndex, endIndex);
if (path == null) {
invalid = true;
break;
}
newEnemyPaths[idx] = path;
}
if (invalid) {
return false;
}
for (const routeIdx in gdLevel.enemyRoutes) {
const route = gdLevel.enemyRoutes[routeIdx];
const enemySpawnHex = gdLevel.enemySpawns[route[0]];
const enemySpawnCell = this.cells[this.getCellIndex(enemySpawnHex)];
enemySpawnCell.pathsToTarget[routeIdx] = newRoutePaths[routeIdx];
}
this.enemies.forEach((simEnemy: SimEnemy, idx: number) => {
simEnemy.path = newEnemyPaths[idx];
simEnemy.currentPathIndex = 0;
simEnemy.onPathUpdated(this);
});
return true;
}
}

View File

@@ -0,0 +1,95 @@
import { SplashComponent } from '../../splash/splash.component';
import { GdRoot } from '../data/GdRoot';
import { ISimAction } from './actions/ISimAction';
import { SimActionFireTowers } from './actions/SimActionFireTowers';
import { SimActionMoveEnemies } from './actions/SimActionMoveEnemies';
import { SimActionMoveProjectiles } from './actions/SimActionMoveProjectiles';
import { SimActionSpawnEnemies } from './actions/SimActionSpawnEnemies';
import { SimCommand } from './commands/SimCommand';
import { SimLevel } from './SimLevel';
export class SimMain {
executeUntilStep(targetStep: number) {
throw new Error("Method not implemented.");
}
lastTime = 0;
currentStep = -1;
isRunning = false;
lastStepTime: number = 0;
currentLevel: SimLevel | null = null;
commandHistory: SimCommand[] = [];
gdRoot: GdRoot = null!;
interval: number = 0;
private actions: ISimAction[] = [];
constructor(gdRoot: GdRoot) {
this.setGdRoot(gdRoot);
this.actions.push(new SimActionMoveEnemies());
this.actions.push(new SimActionSpawnEnemies());
this.actions.push(new SimActionFireTowers());
this.actions.push(new SimActionMoveProjectiles());
}
setGdRoot(gdRoot: GdRoot) {
this.gdRoot = gdRoot;
this.interval = 1.0 / this.gdRoot.simulation.stepsPerSecond;
}
fastForwardToEnd() {
this.fastForwardTo(
this.commandHistory[this.commandHistory.length - 1].step
);
}
private fastForwardTo(target: number) {
this.currentStep = 0;
while (this.currentStep < target) {
this.step();
}
}
step() {
const nextStep = this.currentStep + 1;
// Get all commands for this step
const commands = this.commandHistory.filter((c) => c.step === nextStep);
this.currentStep = nextStep;
for (const action of this.actions) {
action.execute(this);
}
for (const cmd of commands) {
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) {
this.commandHistory = this.commandHistory.filter(
(c) => c.step < command.step
);
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

@@ -0,0 +1,24 @@
import { GdProjectileEffect } from "../data/GdProjectileEffect";
import { Vector2 } from "../util/Vector2";
export class SimProjectile {
index: number;
prevPosition: Vector2;
position: Vector2;
targetEnemyIdx: number;
spawnStep: number;
size: number;
dead: boolean;
projectileEffect: GdProjectileEffect;
constructor(spawnStep: number, index: number, position: Vector2, targetEnemyIdx: number, size: number, projectileEffect: GdProjectileEffect) {
this.index = index;
this.position = position.clone();
this.prevPosition = position.clone();
this.targetEnemyIdx = targetEnemyIdx;
this.spawnStep = spawnStep;
this.size = size;
this.projectileEffect = projectileEffect;
this.dead = false;
}
}

View File

@@ -0,0 +1,91 @@
import { GdProjectileEffect } from "../data/GdProjectileEffect";
import { Vector2 } from "../util/Vector2";
import { SimLevel } from "./SimLevel";
import { SimProjectile } from "./SimProjectile";
export class SimTower {
lastProjectileStep: number = -1;
lastAoeStep: number = -1;
projectileEffect: GdProjectileEffect | null = null;
projectileRange: number = -1;
projectileRate: number = -1;
projectileSize: number = -1;
aoeEffect: number = -1;
aoeRange: number = -1;
aoeRate: number = -1;
position: Vector2 = new Vector2(0, 0);
currentProjectileTarget: number = -1;
index: number = -1;
constructor(index: number, position: Vector2) {
this.index = index;
this.position = position;
}
public fireIfAble(level: SimLevel) {
const currentStep = level.currentStep;
if (this.canFireProjectile(level) && !!this.projectileEffect) {
level.projectiles.push(new SimProjectile(currentStep, level.projectiles.length, this.position, this.pickProjectileTarget(level), this.projectileSize, this.projectileEffect));
this.lastProjectileStep = currentStep;
}
if (this.canFireAoe(level)) {
// TODO
}
}
private canFireProjectile(level: SimLevel) {
if (!this.projectileEffect)
return false;
const currentStep = level.currentStep;
if (this.lastProjectileStep == -1 || this.lastProjectileStep + this.projectileRate < currentStep) {
return this.isEnemyInRange(level, this.projectileRange);
}
return false;
}
private canFireAoe(level: SimLevel) {
const currentStep = level.currentStep;
if (this.aoeEffect != null) {
if (this.lastAoeStep == -1 || this.lastAoeStep + this.aoeRate < currentStep) {
return this.isEnemyInRange(level, this.aoeRange);
}
}
return false;
}
private pickProjectileTarget(level: SimLevel) {
if (this.currentProjectileTarget != -1) {
return this.currentProjectileTarget;
}
let minLength = -1;
let candidate = -1;
for (let idx = 0; idx < level.enemies.length; idx++) {
const enemy = level.enemies[idx];
const delta = enemy.currentPathPosition.subtract(this.position);
const length = delta.magnitude();
if (enemy.path != null && length < this.projectileRange) {
const pathLength = enemy.path.length - enemy.currentPathIndex;
if (minLength == -1 || pathLength < minLength) {
candidate = idx;
minLength = pathLength;
}
}
}
return candidate;
}
private isEnemyInRange(level: SimLevel, range: number) {
for (const idx in level.enemies) {
const enemy = level.enemies[idx];
const delta = enemy.currentPathPosition.subtract(this.position);
const length = delta.magnitude();
if (length < range) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,5 @@
import { SimMain } from "../SimMain";
export interface ISimAction {
execute(simMain: SimMain): void;
}

View File

@@ -0,0 +1,16 @@
import { SimCell } from "../SimCell";
import { SimMain } from "../SimMain";
import { ISimAction } from "./ISimAction";
export class SimActionFireTowers implements ISimAction {
public execute(simMain: SimMain) {
const level = simMain.currentLevel;
if (!level) return;
level.cells.forEach((simCell: SimCell) => {
if (simCell.tower != null) {
simCell.tower.fireIfAble(level);
}
});
}
}

View File

@@ -0,0 +1,43 @@
import { Hex } from "../../util/Hex";
import { Vector2 } from "../../util/Vector2";
import { SimEnemy } from "../SimEnemy";
import { SimMain } from "../SimMain";
import { ISimAction } from "./ISimAction";
export class SimActionMoveEnemies implements ISimAction {
public execute(simMain: SimMain) {
const level = simMain.currentLevel;
if (!level) return;
const deadEnemies: number[] = [];
level.enemies.forEach((enemy: SimEnemy, idx: number) => {
if (enemy.dead)
deadEnemies.push(idx);
});
for (const idx of deadEnemies) {
level.enemies.splice(idx, 1);
}
for (const simEnemy of level.enemies) {
const duration = simEnemy.speed / simMain.gdRoot.simulation.stepsPerSecond;
const t = (level.currentStep - simEnemy.currentPathStep) * duration;
const path2 = level.cells[simEnemy.path[simEnemy.currentPathIndex + 1]];
if (!path2) {
simEnemy.dead = true;
return;
}
const hex2 = path2.hex;
const pos1 = simEnemy.currentPathPosition;
const pos2 = Hex.toWorld(hex2);
simEnemy.prevPosition = simEnemy.position;
simEnemy.position = Vector2.lerp(pos1, pos2, t);
if (t >= 1) {
simEnemy.currentPathIndex += 1;
simEnemy.currentPathStep = level.currentStep;
simEnemy.onPathUpdated(level);
}
}
}
}

View File

@@ -0,0 +1,41 @@
import { SimMain } from "../SimMain";
import { SimProjectile } from "../SimProjectile";
import { ISimAction } from "./ISimAction";
export class SimActionMoveProjectiles implements ISimAction {
public execute(simMain: SimMain) {
const level = simMain.currentLevel;
if (!level) return;
const deadProjectiles: number[] = [];
level.projectiles.forEach((simProjectile: SimProjectile, idx: number) => {
if (simProjectile.dead)
deadProjectiles.push(idx);
});
for (const idx of deadProjectiles) {
level.projectiles.splice(idx, 1);
}
level.projectiles.forEach((simProjectile: SimProjectile) => {
const target = level.enemies[simProjectile.targetEnemyIdx];
if (!target || target.dead) {
simProjectile.dead = true;
return;
}
const pos1 = simProjectile.position;
const pos2 = target.position;
const dir = pos2.subtract(pos1);
const len = dir.magnitude();
let duration = simProjectile.projectileEffect.speed / simMain.gdRoot.simulation.stepsPerSecond;
if (len < duration) {
duration = len;
simProjectile.dead = true;
target.suffer(level, simProjectile.projectileEffect);
}
simProjectile.prevPosition = simProjectile.position;
simProjectile.position = pos1.add(dir.normalized().multiplyScalar(duration));
});
}
}

View File

@@ -0,0 +1,39 @@
import { EEnemySize } from "../../data/EEnemySize";
import { SimCommandStartNextWave } from "../commands/SimCommandStartNextWave";
import { SimEnemy } from "../SimEnemy";
import { SimMain } from "../SimMain";
import { ISimAction } from "./ISimAction";
export class SimActionSpawnEnemies implements ISimAction {
public execute(simMain: SimMain) {
const level = simMain.currentLevel;
if (!level) return;
const gdLevel = simMain.gdRoot.levels[level.index];
const step = level.currentStep;
const gdWave = gdLevel.waves[level.currentWave];
let spawnDelay = simMain.gdRoot.simulation.spawnDelay;
switch (gdWave.size) {
case EEnemySize.Huge:
spawnDelay *= 2;
break;
case EEnemySize.Tiny:
spawnDelay *= 0.5;
break;
}
if (level.enemiesLeftToSpawn > 0 && level.lastEnemySpawnStep + spawnDelay * simMain.gdRoot.simulation.stepsPerSecond <= level.currentStep) {
level.enemiesLeftToSpawn -= 1;
level.lastEnemySpawnStep = step;
const route = Math.floor(Math.random() * gdLevel.enemyRoutes.length);
const enemy = new SimEnemy(gdWave.enemy, route);
enemy.onPathUpdated(level);
level.enemies.push(enemy);
}
if (level.nextWaveStep == step && !!gdLevel.waves[level.currentWave + 1]) {
simMain.addCommand(new SimCommandStartNextWave());
}
}
}

View File

@@ -0,0 +1,6 @@
import { SimMain } from "../SimMain";
export abstract class SimCommand {
step: number = -1;
abstract execute(simMain: SimMain): void;
}

View File

@@ -0,0 +1,32 @@
import { Hex } from "../../util/Hex";
import { ECellType } from "../ECellType";
import { SimMain } from "../SimMain";
import { SimCommand } from "./SimCommand";
export class SimCommandBlockTerrain extends SimCommand {
private hex: Hex;
constructor(hex: Hex) {
super();
this.hex = hex;
}
public execute(simMain: SimMain) {
const level = simMain.currentLevel;
if (!level) return;
const cellIndex = level.getCellIndex(this.hex);
const cell = level.cells[cellIndex];
if (cell.type != ECellType.Free || cell.tower != null || !level.reservePaths(simMain.gdRoot, this.hex)) {
return;
}
cell.type = ECellType.Blocked;
level.updateBlockedType(cell);
for (let i = 0; i < 6; ++i) {
const neighbourHex = Hex.neighbour(cell.hex, i);
const neighbourIndex = level.getCellIndex(neighbourHex);
level.updateBlockedType(level.cells[neighbourIndex]);
}
level.updatePaths(simMain.gdRoot);
}
}

View File

@@ -0,0 +1,54 @@
import { Hex } from "../../util/Hex";
import { ECellType } from "../ECellType";
import { SimEnemy } from "../SimEnemy";
import { SimMain } from "../SimMain";
import { SimTower } from "../SimTower";
import { SimCommand } from "./SimCommand";
export class SimCommandCreateTower extends SimCommand {
hex: Hex;
index: number;
constructor(hex: Hex, index: number) {
super();
this.hex = hex;
this.index = index;
}
public execute(simMain: SimMain) {
const level = simMain.currentLevel;
if (!level) {
return;
}
const cellIndex = level.getCellIndex(this.hex);
const cell = level.cells[cellIndex];
const towerData = simMain.gdRoot.towers[this.index];
if (cell.type != ECellType.Free) {
return;
}
if (cell.tower != null) {
return;
}
level.enemies.forEach((enemy: SimEnemy) => {
const hex = Hex.fromWorld(enemy.position);
const enemyCellIndex = level.getCellIndex(hex);
if (cellIndex == enemyCellIndex) {
return;
}
});
if (level.currency < towerData.cost) {
return;
}
if (level.reservePaths(simMain.gdRoot, this.hex)) {
level.currency -= towerData.cost;
cell.tower = new SimTower(this.index, Hex.toWorld(this.hex));
level.updatePaths(simMain.gdRoot);
}
}
}

View File

@@ -0,0 +1,22 @@
import { GdRoot } from "../../data/GdRoot";
import { SimLevel } from "../SimLevel";
import { SimMain } from "../SimMain";
import { SimCommand } from "./SimCommand";
export class SimCommandStartNextWave extends SimCommand {
execute(simMain: SimMain) {
const level = simMain.currentLevel;
if (!level) return;
const data = simMain.gdRoot.levels[level.index];
level.currentWave += 1;
if (!data.waves[level.currentWave]) {
level.paused = true;
return;
}
level.nextWaveStep = level.currentStep + simMain.gdRoot.simulation.waveDuration * simMain.gdRoot.simulation.stepsPerSecond - 1;
level.lastEnemySpawnStep = level.currentStep;
level.enemiesLeftToSpawn = data.waves[level.currentWave].amount;
}
}

View File

@@ -1,116 +0,0 @@
import { Command } from './command';
import { GameData } from './gameData';
import { GameRules } from './gameRules';
import { GameState } from './gameState';
export class SimulationEngine {
interval: number = 50; // ms per step
lastTime = 0;
private frameRequestId: number | null = null;
private data: GameData;
private previousState: GameState | null = null;
currentStep = 0;
initialState: GameState;
state: GameState;
isRunning = false;
lastStepTime: number = 0;
constructor(
initialState: GameState,
private rules: typeof GameRules,
initialData: GameData
) {
this.initialState = initialState;
this.state = this.cloneState(this.initialState);
this.data = initialData;
}
setGameData(newData: GameData) {
this.data = newData;
}
reinitializeWithNewData(data: GameData) {
this.setGameData(data);
const rewound = JSON.parse(JSON.stringify(this.initialState));
rewound.tick = this.state.tick;
rewound.commandHistory = this.state.commandHistory.filter(
(c) => c.step <= rewound.tick
);
this.state = rewound;
this.fastForwardTo(this.currentStep);
}
fastForwardToEnd() {
this.fastForwardTo(
this.state.commandHistory[this.state.commandHistory.length - 1].step
);
}
private fastForwardTo(target: number) {
this.state.tick = 0;
while (this.state.tick < target) {
this.step();
}
}
step() {
this.previousState = this.cloneState(this.state);
this.rules.step(this.state, this.data);
this.currentStep = this.state.tick;
this.lastStepTime = performance.now();
}
start() {
if (this.isRunning) return;
this.isRunning = true;
this.lastTime = performance.now();
this.loop();
}
stop() {
this.isRunning = false;
if (this.frameRequestId) {
cancelAnimationFrame(this.frameRequestId);
this.frameRequestId = null;
}
}
rewindToZero() {
this.stop();
this.state = this.cloneState(this.initialState);
this.previousState = this.cloneState(this.state);
this.currentStep = 0;
}
issueCommand(command: Command) {
// Purge future commands
this.state.commandHistory = this.state.commandHistory.filter(
(c) => c.step < command.step
);
this.state.commandHistory.push(command);
}
getRenderStates(): [GameState, GameState] {
return [this.previousState ?? this.state, this.state];
}
cloneState(state: GameState): GameState {
return {
tick: state.tick,
commandHistory: [...state.commandHistory],
units: state.units.map((u) => ({ ...u })),
};
}
private loop = () => {
const now = performance.now();
while (now - this.lastTime >= this.interval) {
this.step();
this.lastTime += this.interval;
}
if (this.isRunning) {
this.frameRequestId = requestAnimationFrame(this.loop);
}
};
}

View File

@@ -0,0 +1,12 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.EDirection = void 0;
var EDirection;
(function (EDirection) {
EDirection[EDirection["Right"] = 0] = "Right";
EDirection[EDirection["TopRight"] = 1] = "TopRight";
EDirection[EDirection["TopLeft"] = 2] = "TopLeft";
EDirection[EDirection["Left"] = 3] = "Left";
EDirection[EDirection["BottomLeft"] = 4] = "BottomLeft";
EDirection[EDirection["BottomRight"] = 5] = "BottomRight";
})(EDirection || (exports.EDirection = EDirection = {}));

View File

@@ -0,0 +1,8 @@
export enum EDirection {
Right = 0,
TopRight = 1,
TopLeft = 2,
Left = 3,
BottomLeft = 4,
BottomRight = 5
}

View File

@@ -0,0 +1,97 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Hex = void 0;
var Vector2_1 = require("./Vector2");
var Cube = /** @class */ (function () {
function Cube(x, y, z) {
this.x = x;
this.y = y;
this.z = z;
}
return Cube;
}());
var Hex = /** @class */ (function () {
function Hex(x, y) {
this.col = x;
this.row = y;
}
Hex.prototype.toWorld = function () {
return Hex.toWorld(this);
};
Hex.prototype.toPixel = function (size) {
return Hex.toPixel(this, size);
};
Hex.offsetDirections = [
[
new Hex(+1, 0),
new Hex(0, -1),
new Hex(-1, -1),
new Hex(-1, 0),
new Hex(-1, +1),
new Hex(0, +1)
],
[
new Hex(+1, 0),
new Hex(+1, -1),
new Hex(0, -1),
new Hex(-1, 0),
new Hex(0, +1),
new Hex(+1, +1),
new Hex(+1, +1)
]
];
Hex.neighbour = function (hex, direction) {
var parity = hex.row & 1;
var dir = Hex.offsetDirections[parity][direction];
return new Hex(hex.col + dir.col, hex.row + dir.row);
};
Hex.distance = function (a, b) {
var ac = Hex.offsetToCube(a);
var bc = Hex.offsetToCube(b);
return Math.max(Math.abs(ac.x - bc.x), Math.abs(ac.y - bc.y), Math.abs(ac.z - bc.z));
};
Hex.offsetToCube = function (hex) {
var x = hex.col - (hex.row - (hex.row & 1)) / 2;
var z = hex.row;
var y = -x - z;
return new Cube(x, y, z);
};
Hex.toWorld = function (hex) {
var x = Math.sqrt(3) * (hex.col + 0.5 * (hex.row & 1));
var y = (3 / 2) * hex.row;
return new Vector2_1.Vector2(x, y);
};
Hex.fromWorld = function (coord) {
var q = (coord.x * Math.sqrt(3)) / 3 - coord.y / 3;
var r = (coord.y * 2) / 3;
var cube = new Cube(q, -q - r, r);
var rx = Math.round(cube.x);
var ry = Math.round(cube.y);
var rz = Math.round(cube.z);
var xDiff = Math.abs(rx - cube.x);
var yDiff = Math.abs(ry - cube.y);
var zDiff = Math.abs(rz - cube.z);
if (xDiff > yDiff && xDiff > zDiff) {
rx = -ry - rz;
}
else if (yDiff > zDiff) {
ry = -rx - rz;
}
else {
rz = -rx - ry;
}
var rounded = new Cube(rx, ry, rz);
var col = rounded.x + (rounded.z - (rounded.z & 1)) / 2;
var row = rounded.z;
return new Hex(col, row);
};
Hex.toPixel = function (hex, size) {
var w = Hex.toWorld(hex);
return new Vector2_1.Vector2(w.x * size, w.y * size);
};
Hex.fromPixel = function (coord, size) {
return Hex.fromWorld(new Vector2_1.Vector2(coord.x / size, coord.y / size));
};
return Hex;
}());
exports.Hex = Hex;

View File

@@ -0,0 +1,116 @@
import { EDirection } from "./EDirection";
import { Vector2 } from "./Vector2";
class Cube {
x: number;
y: number;
z: number;
constructor(x: number, y: number, z: number) {
this.x = x;
this.y = y;
this.z = z;
}
}
export class Hex {
public col: number;
public row: number;
constructor(x: number, y: number) {
this.col = x;
this.row = y;
}
private static offsetDirections: Hex[][] = [
[
new Hex(+1, 0),
new Hex(0, -1),
new Hex(-1, -1),
new Hex(-1, 0),
new Hex(-1, +1),
new Hex(0, +1)
],
[
new Hex(+1, 0),
new Hex(+1, -1),
new Hex(0, -1),
new Hex(-1, 0),
new Hex(0, +1),
new Hex(+1, +1),
new Hex(+1, +1)
]
];
public static neighbour = (hex: Hex, direction: EDirection): Hex => {
const parity = hex.row & 1;
const dir = Hex.offsetDirections[parity][direction];
return new Hex(hex.col + dir.col, hex.row + dir.row);
};
public static distance = (a: Hex, b: Hex): number => {
const ac = Hex.offsetToCube(a);
const bc = Hex.offsetToCube(b);
return Math.max(
Math.abs(ac.x - bc.x),
Math.abs(ac.y - bc.y),
Math.abs(ac.z - bc.z)
);
};
private static offsetToCube = (hex: Hex): Cube => {
const x = hex.col - (hex.row - (hex.row & 1)) / 2;
const z = hex.row;
const y = -x - z;
return new Cube(x, y, z);
};
public static toWorld = (hex: Hex): Vector2 => {
const x = Math.sqrt(3) * (hex.col + 0.5 * (hex.row & 1));
const y = (3 / 2) * hex.row;
return new Vector2(x, y);
};
public toWorld(): Vector2 {
return Hex.toWorld(this);
}
public static fromWorld = (coord: Vector2): Hex => {
const q = (coord.x * Math.sqrt(3)) / 3 - coord.y / 3;
const r = (coord.y * 2) / 3;
const cube = new Cube(q, -q - r, r);
let rx = Math.round(cube.x);
let ry = Math.round(cube.y);
let rz = Math.round(cube.z);
const xDiff = Math.abs(rx - cube.x);
const yDiff = Math.abs(ry - cube.y);
const zDiff = Math.abs(rz - cube.z);
if (xDiff > yDiff && xDiff > zDiff) {
rx = -ry - rz;
} else if (yDiff > zDiff) {
ry = -rx - rz;
} else {
rz = -rx - ry;
}
const rounded = new Cube(rx, ry, rz);
const col = rounded.x + (rounded.z - (rounded.z & 1)) / 2;
const row = rounded.z;
return new Hex(col, row);
};
public static toPixel = (hex: Hex, size: number): Vector2 => {
const w = Hex.toWorld(hex);
return new Vector2(w.x * size, w.y * size);
};
public toPixel(size: number): Vector2 {
return Hex.toPixel(this, size);
}
public static fromPixel = (coord: Vector2, size: number): Hex => {
return Hex.fromWorld(new Vector2(coord.x / size, coord.y / size));
};
}

View File

@@ -0,0 +1,51 @@
import { ECellType } from "../sim/ECellType";
import { SimLevel } from "../sim/SimLevel";
import { Hex } from "./Hex";
export abstract class PathFinding {
public static bfs(level: SimLevel, startIndex: number, endIndex: number): number[] | null {
const listToExplore: number[] = [startIndex];
let cameFrom: number[] = new Array<number>(level.cells.length);
cameFrom.fill(-1);
while (listToExplore.length > 0) {
const nodeIndex = listToExplore.shift()!;
const cell = level.cells[nodeIndex];
for (let i = 0; i < 6; ++i) {
const neighbourHex = Hex.neighbour(cell.hex, i);
const neighbourIndex = level.getCellIndex(neighbourHex);
if (neighbourIndex === -1) {
continue;
}
const neighbourCell = level.cells[neighbourIndex];
if (neighbourCell.type === ECellType.Blocked || neighbourCell.type === ECellType.Reserved || neighbourCell.tower !== null) {
continue;
}
if (cameFrom[neighbourIndex] === -1) {
cameFrom[neighbourIndex] = nodeIndex;
if (neighbourIndex !== endIndex) {
listToExplore.push(neighbourIndex);
} else {
let idx = neighbourIndex;
const path: number[] = [idx];
while (idx !== startIndex) {
const prev = cameFrom[idx];
idx = prev;
path.unshift(idx);
}
return path;
}
}
}
}
return null;
}
}

View File

@@ -0,0 +1,74 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Vector2 = void 0;
var Vector2 = /** @class */ (function () {
function Vector2(x, y) {
this.x = x;
this.y = y;
}
Vector2.lerp = function (a, b, t) {
return a.multiplyScalar(1 - t).add(b.multiplyScalar(t));
};
Vector2.prototype.add = function (vector) {
return new Vector2(this.x + vector.x, this.y + vector.y);
};
Vector2.prototype.subtract = function (vector) {
return new Vector2(this.x - vector.x, this.y - vector.y);
};
Vector2.prototype.multiplyScalar = function (scalar) {
return new Vector2(this.x * scalar, this.y * scalar);
};
Vector2.prototype.dot = function (vector) {
return this.x * vector.x + this.y * vector.y;
};
Vector2.prototype.cross = function (vector) {
return this.x * vector.y - this.y * vector.x;
};
Vector2.prototype.magnitude = function () {
return Math.sqrt(this.x * this.x + this.y * this.y);
};
Vector2.prototype.normalized = function () {
var magnitude = this.magnitude();
if (magnitude === 0) {
throw new Error("Cannot normalize a vector with magnitude 0");
}
return this.multiplyScalar(1 / magnitude);
};
Vector2.prototype.distance = function (vector) {
return Math.sqrt(Math.pow((this.x - vector.x), 2) + Math.pow((this.y - vector.y), 2));
};
Vector2.prototype.distanceSquared = function (vector) {
return Math.pow((this.x - vector.x), 2) + Math.pow((this.y - vector.y), 2);
};
Vector2.prototype.limit = function (max) {
var magnitude = this.magnitude();
if (magnitude > max) {
return this.normalized().multiplyScalar(max);
}
return this;
};
Vector2.prototype.angle = function () {
return Math.atan2(this.y, this.x);
};
Vector2.prototype.angleBetween = function (vector) {
var dotProd = this.dot(vector);
var magnitudes = this.magnitude() * vector.magnitude();
if (magnitudes === 0) {
throw new Error("Cannot calculate angle with a zero-magnitude vector");
}
return Math.acos(dotProd / magnitudes);
};
Vector2.prototype.clone = function () {
return new Vector2(this.x, this.y);
};
Vector2.prototype.equals = function (vector) {
return this.x === vector.x && this.y === vector.y;
};
Vector2.prototype.rotate = function (angle) {
var cos = Math.cos(angle);
var sin = Math.sin(angle);
return new Vector2(this.x * cos - this.y * sin, this.x * sin + this.y * cos);
};
return Vector2;
}());
exports.Vector2 = Vector2;

View File

@@ -0,0 +1,91 @@
export class Vector2 {
public x: number;
public y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
public static lerp(a: Vector2, b: Vector2, t: number) {
return a.multiplyScalar(1 - t).add(b.multiplyScalar(t));
}
public add(vector: Vector2): Vector2 {
return new Vector2(this.x + vector.x, this.y + vector.y);
}
public subtract(vector: Vector2): Vector2 {
return new Vector2(this.x - vector.x, this.y - vector.y);
}
public multiplyScalar(scalar: number): Vector2 {
return new Vector2(this.x * scalar, this.y * scalar);
}
public dot(vector: Vector2): number {
return this.x * vector.x + this.y * vector.y;
}
public cross(vector: Vector2): number {
return this.x * vector.y - this.y * vector.x;
}
public magnitude(): number {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
public normalized(): Vector2 {
const magnitude = this.magnitude();
if (magnitude === 0) {
throw new Error("Cannot normalize a vector with magnitude 0");
}
return this.multiplyScalar(1 / magnitude);
}
public distance(vector: Vector2): number {
return Math.sqrt((this.x - vector.x) ** 2 + (this.y - vector.y) ** 2);
}
public distanceSquared(vector: Vector2): number {
return (this.x - vector.x) ** 2 + (this.y - vector.y) ** 2;
}
public limit(max: number): Vector2 {
const magnitude = this.magnitude();
if (magnitude > max) {
return this.normalized().multiplyScalar(max);
}
return this;
}
public angle(): number {
return Math.atan2(this.y, this.x);
}
public angleBetween(vector: Vector2): number {
const dotProd = this.dot(vector);
const magnitudes = this.magnitude() * vector.magnitude();
if (magnitudes === 0) {
throw new Error("Cannot calculate angle with a zero-magnitude vector");
}
return Math.acos(dotProd / magnitudes);
}
public clone(): Vector2 {
return new Vector2(this.x, this.y);
}
public equals(vector: Vector2): boolean {
return this.x === vector.x && this.y === vector.y;
}
public rotate(angle: number): Vector2 {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return new Vector2(
this.x * cos - this.y * sin,
this.x * sin + this.y * cos
);
}
}

View File

@@ -8,12 +8,16 @@ import { AssetPreloaderService } from '../../assetPreloaderService';
styleUrls: ['./splash.component.css'],
})
export class SplashComponent {
static assetPreloader: AssetPreloaderService = new AssetPreloaderService();
show: boolean = true;
assetPreloader: AssetPreloaderService = new AssetPreloaderService();
progress: number = 0;
constructor(private router: Router) {
this.assetPreloader.progress$.subscribe(p => this.progress = p);
this.assetPreloader.preload().then(() => this.router.navigate(['/menu']));
SplashComponent.assetPreloader.progress$.subscribe(
(p) => (this.progress = p)
);
SplashComponent.assetPreloader
.preload()
.then(() => this.router.navigate(['/menu']));
}
}