Compare commits

...

10 Commits

Author SHA1 Message Date
fae633ea54 fixed wave delay 2025-06-15 11:20:07 +02:00
337a3d8213 enemy spawn and next wave start refactoring 2025-06-15 11:14:12 +02:00
d26999ace9 fixed dynamic enemy pathfinding 2025-06-14 22:24:03 +02:00
9b81bef804 fix enemy speed 2025-06-14 20:58:42 +02:00
c61a10d79a fix spawning of enemies 2025-06-02 21:16:04 +02:00
61b86b7a60 fixed timeline controls 2025-06-02 20:57:29 +02:00
102c1f59dc input handlers 2025-05-18 16:34:33 +02:00
e0797d2cfc fixed canvas resize 2025-05-18 14:44:51 +02:00
c6a3056dcd fixed preloading and integrated vis 2025-05-17 18:43:17 +02:00
6ffa7e9c68 ported simulation 2025-05-17 15:55:26 +02:00
47 changed files with 1932 additions and 724 deletions

View File

@@ -3,11 +3,13 @@ root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true
# Add this to help VSCode and Copilot use tabs for indentation
indent_size = tab
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false

View File

@@ -1,8 +1,11 @@
{
"json.schemas": [
{
"fileMatch": [ "src/assets/data/*.json" ],
"fileMatch": ["src/assets/data/*.json"],
"url": "./schemas/gdRoot.json"
}
]
],
"editor.insertSpaces": false,
"editor.detectIndentation": false,
"editor.tabSize": 4
}

View File

@@ -1,4 +1,4 @@
{
"tabWidth": 4,
"useTabs": true
"useTabs": true,
"printWidth": 9999
}

View File

@@ -29,13 +29,36 @@ export class AssetPreloaderService {
const url = URL.createObjectURL(blob);
if (entry.type === '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;
return loadedPromise.then(() => {
this.images.set(entry.path, img);
});
} else if (entry.type === '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;
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> {
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();
let loaded = 0;
const chunks = [];
while (true) {
const { done, value } = await reader!.read();
if (done)
break;
if (done) break;
chunks.push(value);
loaded += value.length;
this.loadedBytes += value.length;
this._progress.next(this.loadedBytes / this.totalBytes);
}
return new Blob(chunks);
return new Blob(chunks, contentType ? { type: contentType } : undefined);
}
getImage(name: string): HTMLImageElement {

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

@@ -24,10 +24,16 @@
align-items: center;
justify-content: center;
background: black;
overflow: hidden; /* Prevent canvas from causing wrapper to grow */
}
canvas {
display: block;
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.bottom-panel {

View File

@@ -7,12 +7,12 @@
</div>
<div class="bottom-panel">
<button (click)="rewind()">⏮ Rewind</button>
<button (click)="start()">▶ Play</button>
<button (click)="stop()">⏸ Pause</button>
<button (click)="step()">Step</button>
<button (click)="start()" [disabled]="visMain?.active">▶ Play</button>
<button (click)="stop()" [disabled]="!visMain?.active">⏸ Pause</button>
<button (click)="step()" [disabled]="visMain?.active">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)="startNextWave()">Start next wave</button>
<button (click)="openOptions()">Options</button>
</div>
</div>

View File

@@ -1,9 +1,11 @@
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';
import { VisMain } from './vis/VisMain';
import { SplashComponent } from '../splash/splash.component';
import { SimCommandStartNextWave } from './sim/commands/SimCommandStartNextWave';
@Component({
selector: 'app-game',
@@ -11,182 +13,79 @@ import { Command } from './command';
styleUrls: ['./game.component.css'],
imports: [OptionsComponent],
})
export class GameComponent implements OnInit {
engine!: SimulationEngine;
private optionsOpen = false;
private isPaused = false;
private lastFrameTime = 0;
export class GameComponent {
simMain: SimMain = new SimMain();
visMain!: VisMain;
@ViewChild('gameCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
@ViewChild('canvasWrapper') wrapperRef!: ElementRef<HTMLDivElement>;
private logicalWidth = 800;
private logicalHeight = 600;
scaleX: number = 1;
scaleY: number = 1;
pixelScale: number = 1;
optionsOpen: boolean = false;
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);
requestAnimationFrame(this.gameLoop.bind(this));
async ngAfterViewInit() {
const gdRoot = await this.loadGdRoot();
this.simMain.setGdRoot(gdRoot);
this.visMain = new VisMain(this.simMain, SplashComponent.assetPreloader, this.wrapperRef.nativeElement, this.canvasRef.nativeElement);
}
start() {
this.engine.start();
this.visMain.start();
}
stop() {
this.engine.stop();
this.visMain.stop();
}
step() {
this.engine.step();
this.simMain.step();
this.visMain.onRender();
}
rewind() {
this.engine.rewindToZero();
const wasActive = this.visMain.active;
if (wasActive)
this.visMain.stop();
this.simMain.rewind();
if (wasActive)
this.visMain.start();
}
startNextWave() {
this.simMain.addCommand(new SimCommandStartNextWave());
}
fastForward() {
this.engine.fastForwardToEnd();
const wasActive = this.visMain.active;
if (wasActive)
this.visMain.stop();
this.simMain.executeToEnd();
if (wasActive)
this.visMain.start();
}
async loadGdRoot(): Promise<GdRoot> {
const data = await fetch('/assets/data/gdRoot.json').then((r) =>
r.json()
);
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);
}
ngAfterViewInit(): void {
this.resizeCanvas();
requestAnimationFrame(this.render);
window.addEventListener('resize', this.resizeCanvas.bind(this));
}
render = (now: number) => {
if (!this.engine) return;
const [prevState, currState] = this.engine.getRenderStates();
// 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
this.renderFrame(prevState, currState, t);
requestAnimationFrame(this.render);
};
renderFrame(prev: GameState, curr: GameState, 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 {
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];
}
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
this.simMain.setGdRoot(await this.loadGdRoot());
}
// Called when opening options: pause the game
public openOptions(): void {
this.optionsOpen = true;
this.pauseGame();
this.stop();
}
// Called when closing options: resume the game
public closeOptions(): void {
this.optionsOpen = false;
this.resumeGame();
}
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));
this.start();
}
}

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,94 @@
import { EEnemySize } from '../data/EEnemySize';
import { EProjectileEffectType } from '../data/EProjectileEffectType';
import { GdEnemy } from '../data/GdEnemy';
import { GdLevel } from '../data/GdLevel';
import { GdProjectileEffect } from '../data/GdProjectileEffect';
import { Hex } from '../util/Hex';
import { PathFinding } from '../util/PathFinding';
import { Vector2 } from '../util/Vector2';
import { SimLevel } from './SimLevel';
import { SimMain } from './SimMain';
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, step: number, simMain: SimMain) {
const level = simMain.currentLevel!;
const gdLevel = simMain.gdRoot.levels[level.index];
const gdWave = gdLevel.waves[level.currentWave];
const gdEnemy = simMain.gdRoot.enemies[gdWave.enemy];
this.index = index;
this.routeIdx = routeIdx;
this.startHex = gdLevel.enemySpawns[gdLevel.enemyRoutes[routeIdx][0]];
this.endHex = gdLevel.enemyTargets[gdLevel.enemyRoutes[routeIdx][1]];
const startIndex = level.getCellIndex(this.startHex);
this.path = [...level.cells[startIndex].pathsToTarget[routeIdx]];
this.currentPathIndex = 0;
this.currentPathStep = step;
this.speed = gdEnemy.speed;
this.hitPonts = 10; // TODO gd
this.size = EEnemySize.Tiny; // TODO gd
this.gain = 10; // TODO gd
this.onPathUpdated(level, true);
}
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, initPosition: boolean = false) {
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(this.currentPathPosition);
const myLen = myDir.magnitude();
const hexDir = pos2.subtract(pos1);
const hexLen = hexDir.magnitude();
if (myLen > hexLen) {
this.path.unshift(this.path[0]);
}
if (initPosition)
{
this.currentPathPosition = pos1.clone();
this.prevPosition = pos1.clone();
this.position = pos1.clone();
}
this.direction = myDir.multiplyScalar(1 / myLen);
}
}

View File

@@ -0,0 +1,213 @@
import { EEnemySize } from '../data/EEnemySize';
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 { SimMain } from './SimMain';
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;
spawnDelay: 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 startNextWave(simMain: SimMain) {
const data = simMain.gdRoot.levels[this.index];
this.currentWave += 1;
const gdWave = data.waves[this.currentWave];
if (!gdWave) {
this.paused = true;
return;
}
this.lastEnemySpawnStep = this.currentStep;
this.enemiesLeftToSpawn = data.waves[this.currentWave].amount;
this.paused = false;
this.spawnDelay = simMain.gdRoot.simulation.spawnDelay;
switch (gdWave.size) {
case EEnemySize.Huge:
this.spawnDelay *= 2;
break;
case EEnemySize.Tiny:
this.spawnDelay *= 0.5;
break;
}
this.nextWaveStep = this.currentStep + (simMain.gdRoot.simulation.waveDuration + gdWave.amount * this.spawnDelay) * simMain.gdRoot.simulation.stepsPerSecond - 1;
}
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 (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];
}
for (let idx in this.enemies) {
const simEnemy = this.enemies[idx];
const currentIndex = simEnemy.path[simEnemy.currentPathIndex];
const nextIndex = simEnemy.path[simEnemy.currentPathIndex + 1];
const nextCell = this.cells[nextIndex];
const endIndex = this.getCellIndex(simEnemy.endHex);
let startIndex = nextCell.type != ECellType.Free ? currentIndex : nextIndex;
let path = PathFinding.bfs(this, startIndex, endIndex);
if (path == null) {
invalid = true;
break;
}
if (nextCell.type != ECellType.Free) {
path = [nextIndex, ...path];
}
else {
path = [currentIndex, ...path];
}
newEnemyPaths[idx] = path;
}
if (invalid) {
return false;
}
this.enemies.forEach((simEnemy: SimEnemy, idx: number) => {
const newPath = newEnemyPaths[idx];
simEnemy.path = newPath;
simEnemy.currentPathIndex = 0;
simEnemy.onPathUpdated(this);
});
return true;
}
}

View File

@@ -0,0 +1,72 @@
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 {
currentStep = -1;
maxStep = -1;
currentLevel: SimLevel | null = null;
commandHistory: SimCommand[] = [];
gdRoot: GdRoot = null!;
interval: number = 0;
private actions: ISimAction[] = [];
constructor() {
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;
this.currentLevel = new SimLevel(this.gdRoot, 0);
}
executeToEnd() {
this.executeUntilStep(this.maxStep);
}
executeUntilStep(target: number) {
while (this.currentStep < target) {
this.step();
}
}
rewind() {
this.currentLevel = new SimLevel(this.gdRoot, this.currentLevel?.index ?? 0);
this.currentStep = -1;
}
step() {
this.currentStep++;
this.maxStep = Math.max(this.maxStep, this.currentStep);
if (this.currentLevel && !this.currentLevel.paused) {
this.currentLevel.currentStep++;
}
for (const action of this.actions) {
action.execute(this);
}
const commands = this.commandHistory.filter((c) => c.step === this.currentStep);
for (const cmd of commands) {
cmd.execute(this);
}
}
addCommand(command: SimCommand) {
command.step = this.currentStep + 1;
this.commandHistory = this.commandHistory.filter(
(c) => c.step < command.step
);
this.commandHistory.push(command);
}
}

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,46 @@
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 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);
const segmentLength = pos2.subtract(pos1).magnitude();
const stepsToTraverse = (segmentLength * simMain.gdRoot.simulation.stepsPerSecond) / simEnemy.speed;
const t = (level.currentStep - simEnemy.currentPathStep) / stepsToTraverse;
simEnemy.prevPosition = simEnemy.position;
simEnemy.position = Vector2.lerp(pos1, pos2, t);
if (t >= 1) {
simEnemy.currentPathIndex += 1;
simEnemy.currentPathStep = level.currentStep;
simEnemy.currentPathPosition = pos2.clone();
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,35 @@
import { EEnemySize } from '../../data/EEnemySize';
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];
if (!gdWave) {
return;
}
if (level.enemiesLeftToSpawn > 0 && level.lastEnemySpawnStep + level.spawnDelay * simMain.gdRoot.simulation.stepsPerSecond <= level.currentStep) {
level.enemiesLeftToSpawn -= 1;
level.lastEnemySpawnStep = step;
const route = Math.floor(Math.random() * gdLevel.enemyRoutes.length);
const simEnemy = new SimEnemy(gdWave.enemy, route, step, simMain);
level.enemies.push(simEnemy);
}
if (level.nextWaveStep == step) {
if (!!gdLevel.waves[level.currentWave + 1])
level.startNextWave(simMain);
}
}
}

View File

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

View File

@@ -0,0 +1,46 @@
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];
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);
}
public check(simMain: SimMain): boolean {
const level = simMain.currentLevel;
if (!level) {
return false;
}
const cellIndex = level.getCellIndex(this.hex);
const cell = level.cells[cellIndex];
if (!cell) {
return false;
}
if (cell.type != ECellType.Free || cell.tower != null || !level.reservePaths(simMain.gdRoot, this.hex)) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,75 @@
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];
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);
}
}
public check(simMain: SimMain): boolean {
const level = simMain.currentLevel;
if (!level) {
return false;
}
const cellIndex = level.getCellIndex(this.hex);
const cell = level.cells[cellIndex];
if (!cell) {
return false;
}
const towerData = simMain.gdRoot.towers[this.index];
if (cell.type != ECellType.Free) {
return false;
}
if (cell.tower != null) {
return false;
}
if (level.currency < towerData.cost) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,21 @@
import { SimMain } from '../SimMain';
import { SimCommand } from './SimCommand';
export class SimCommandStartNextWave extends SimCommand {
public execute(simMain: SimMain) {
const level = simMain.currentLevel;
if (!level) {
return;
}
level.startNextWave(simMain);
}
public check(simMain: SimMain): boolean {
const level = simMain.currentLevel;
if (!level) {
return false;
}
return true;
}
}

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

@@ -54,7 +54,7 @@ export class VisEnemy {
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.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();
}
}

View File

@@ -1,16 +1,16 @@
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";
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;
@@ -27,12 +27,13 @@ export class VisLevel {
private simMain: SimMain;
private gdRoot: GdRoot;
assets: AssetPreloaderService;
private hoveredHex: Hex | null = null;
constructor(visMain: VisMain, simMain: SimMain, gdRoot: GdRoot, assets: AssetPreloaderService) {
this.assets = assets;
this.visMain = visMain;
this.simMain = simMain;
this.gdRoot = gdRoot
this.gdRoot = gdRoot;
this.enemyMap = new Map<SimEnemy, VisEnemy>();
this.projectileMap = new Map<SimProjectile, VisProjectile>();
this.reset();
@@ -58,7 +59,16 @@ export class VisLevel {
}
this.drawBackground();
ctx.globalCompositeOperation = "source-over";
ctx.globalCompositeOperation = 'source-over';
// Highlight hovered cell first (under everything else)
if (this.hoveredHex) {
const hoveredIdx = simLevel.getCellIndex(this.hoveredHex);
const hoveredCell = simLevel.cells[hoveredIdx];
if (hoveredCell && hoveredCell.distance <= gdLevel.radius) {
this.drawCellImage(ctx, hoveredCell, 'cell-highlighted.svg');
}
}
simLevel.cells.forEach((cell: SimCell) => {
if (cell.distance > gdLevel.radius) {
@@ -75,11 +85,11 @@ export class VisLevel {
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);
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() {
@@ -89,11 +99,11 @@ export class VisLevel {
}
const gdLevel = this.gdRoot.levels[simLevel.index];
const minSize = Math.min(this.visMain.canvas.height, this.visMain.canvas.width * Math.sqrt(3) / 2);
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((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);
@@ -110,6 +120,11 @@ export class VisLevel {
}
public updateEveryFrame(currentStep: number) {
if (currentStep < 0) {
this.lastStep = -1;
return;
}
const simLevel = this.simMain.currentLevel;
if (simLevel == null) {
return;
@@ -127,11 +142,9 @@ export class VisLevel {
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)) {
} else if (Math.floor(currentStep) != Math.floor(this.lastStep)) {
visEnemy.advanceStep();
}
else {
} else {
visEnemy.update(t);
}
});
@@ -149,8 +162,7 @@ export class VisLevel {
const visProjectile = this.projectileMap.get(simProjectile);
if (!visProjectile) {
this.projectileMap.set(simProjectile, new VisProjectile(simProjectile));
}
else if (Math.floor(currentStep) != Math.floor(this.lastStep)) {
} else if (Math.floor(currentStep) != Math.floor(this.lastStep)) {
visProjectile.advanceStep();
}
});
@@ -161,6 +173,10 @@ export class VisLevel {
this.lastStep = currentStep;
}
public setHoveredHex(hex: Hex | null) {
this.hoveredHex = hex;
}
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);
@@ -182,34 +198,34 @@ export class VisLevel {
const gdLevel = this.gdRoot.levels[simLevel.index];
if (this.background == null) {
const backgroundCanvas = this.visMain.canvas.cloneNode() as HTMLCanvasElement;
const backgroundContext = backgroundCanvas.getContext("2d")!;
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");
this.drawCellImage(backgroundContext, cell, 'cell-blocked-' + (cell.blockedType | 0) + '.svg');
}
});
backgroundContext.globalCompositeOperation = "source-atop";
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")!;
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");
this.drawCellImage(cellContext, cell, 'cell.svg');
}
});
backgroundContext.globalCompositeOperation = "destination-over";
backgroundContext.globalCompositeOperation = 'destination-over';
backgroundContext.drawImage(cellCanvas, 0, 0);
backgroundContext.globalCompositeOperation = "source-over";
backgroundContext.globalCompositeOperation = 'source-over';
}
ctx.drawImage(this.background, 0, 0);
@@ -228,11 +244,7 @@ export class VisLevel {
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);
this.visMain.context.drawImage(this.assets.getImage('images/projectile.svg'), this.screenXOffset + pos.x * this.hexSize - width / 2, this.screenYOffset + pos.y * this.hexSize - height / 2, width, height);
}
private drawEnemy(simEnemy: SimEnemy) {
@@ -246,12 +258,7 @@ export class VisLevel {
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);
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) {
@@ -260,9 +267,9 @@ export class VisLevel {
return;
}
this.visMain.context.fillStyle = "rgba(192, 192, 192, 0.25)";
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");
this.drawCellImage(this.visMain.context, cell, 'cell-entry-' + (cell.blockedType | 0) + '.svg');
}
const highlightedCell = simLevel.cells[simLevel.highlightedIndex];
@@ -279,20 +286,20 @@ export class VisLevel {
}
}
if (draw) {
this.drawCellImage(this.visMain.context, highlightedCell, "cell-highlighted.svg");
this.visMain.context.fillStyle = "rgba(0, 0, 0, 1)";
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");
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);
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);
context.drawImage(this.assets.getImage('images/' + name), coords.x, coords.y, this.screenCellWidth, this.screenCellHeight);
}
}

View File

@@ -1,7 +1,10 @@
import { AssetPreloaderService } from "../../../assetPreloaderService";
import { SplashComponent } from "../../splash/splash.component";
import { SimMain } from "../sim/SimMain";
import { VisLevel } from "./VisLevel";
import { AssetPreloaderService } from '../../../assetPreloaderService';
import { SimMain } from '../sim/SimMain';
import { VisLevel } from './VisLevel';
import { Vector2 } from '../util/Vector2';
import { InputHandler } from './input/InputHandler';
import { Hex } from '../util/Hex';
import { DefaultInputHandler } from './input/DefaultInputHandler';
export class VisMain {
canvas: HTMLCanvasElement;
@@ -9,29 +12,32 @@ export class VisMain {
wallPattern: CanvasPattern;
visLevel: VisLevel;
simMain: SimMain;
active: boolean = true;
assets: AssetPreloaderService;
wrapper: HTMLDivElement;
inputHandlers: InputHandler[] = [];
private startTimestamp: number = 0;
private active: boolean = true;
private ready: boolean = false;
private gap: number = 0;
assets: AssetPreloaderService;
private animationFrameId: number | null = null;
constructor(simMain: SimMain, assets: AssetPreloaderService, canvas: HTMLCanvasElement) {
constructor(simMain: SimMain, assets: AssetPreloaderService, wrapper: HTMLDivElement, 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.wrapper = wrapper;
this.context = this.canvas.getContext('2d')!;
this.context.globalCompositeOperation = 'source-over';
this.wallPattern = this.createPattern(assets.getImage('images/wall.png'), 48);
this.visLevel = new VisLevel(this, this.simMain, this.simMain.gdRoot, assets);
const host = this;
requestAnimationFrame(function step(timestamp) {
host.step(timestamp);
});
this.setupInput();
this.start();
this.inputHandlers.push(new DefaultInputHandler(this));
}
private createPattern(image: HTMLImageElement, size: number): CanvasPattern {
const tempCanvas = document.createElement("canvas");
const tempContext = tempCanvas.getContext("2d")!;
const tempCanvas = document.createElement('canvas');
const tempContext = tempCanvas.getContext('2d')!;
tempCanvas.width = size;
tempCanvas.height = size;
@@ -45,47 +51,45 @@ export class VisMain {
return;
}
const host = this;
requestAnimationFrame((timestamp: number) => {
host.step(timestamp);
this.animationFrameId = requestAnimationFrame((timestamp: number) => {
this.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;
let targetStep = Math.floor(((timestamp - this.startTimestamp) * this.simMain.gdRoot.simulation.stepsPerSecond) / 1000 - this.gap);
if (simLevel.paused) {
this.gap += targetStep - simLevel.currentStep;
targetStep = simLevel.currentStep;
this.gap += targetStep - this.simMain.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;
// Always get the latest wrapper size
const width = this.wrapper.clientWidth;
const height = this.wrapper.clientHeight;
const ratio = window.devicePixelRatio;
// Set canvas style to always fill the wrapper
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() {
// Reset transform before scaling to avoid compounding
this.context.setTransform(1, 0, 0, 1, 0, 0);
this.context.scale(ratio, ratio);
this.visLevel.updateSize();
}
onRender() {
this.clear();
const ctx = this.context;
ctx.font = "12px Tahoma";
ctx.font = '12px Tahoma';
const simLevel = this.simMain.currentLevel;
if (!!simLevel) {
if (!this.ready) {
@@ -94,13 +98,76 @@ export class VisMain {
}
this.visLevel.draw();
}
};
}
private clear() {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
private stop() {
stop() {
this.active = false;
this.startTimestamp = 0;
this.visLevel.updateEveryFrame(-1);
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
}
start() {
this.active = true;
this.animationFrameId = requestAnimationFrame((timestamp: number) => {
this.startTimestamp = timestamp - (this.simMain.currentStep / this.simMain.gdRoot.simulation.stepsPerSecond) * 1000;
this.step(timestamp);
});
}
private setupInput() {
this.canvas.addEventListener('mousedown', (e) => this.handleMouseDown(e));
this.canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e));
window.addEventListener('keydown', (e) => this.handleKeyDown(e));
window.addEventListener('resize', (_) => this.onResized());
}
private getHexFromMouseCoords(event: MouseEvent): Hex {
const rect = this.canvas.getBoundingClientRect();
const ratio = this.canvas.width / rect.width;
const x = ((event.clientX - rect.left) * ratio) / window.devicePixelRatio;
const y = ((event.clientY - rect.top) * ratio) / window.devicePixelRatio;
const coords = new Vector2(x, y);
const hex = this.visLevel.getHexFromScreenCoords(coords);
return hex;
}
private handleMouseDown(event: MouseEvent) {
const hex = this.getHexFromMouseCoords(event);
for (let i = this.inputHandlers.length - 1; i >= 0; i--) {
const handled = this.inputHandlers[i].onMouseDown?.(event, hex);
if (handled) {
event.preventDefault();
return;
}
}
}
private handleMouseMove(event: MouseEvent) {
const hex = this.getHexFromMouseCoords(event);
for (let i = this.inputHandlers.length - 1; i >= 0; i--) {
const handled = this.inputHandlers[i].onMouseMove?.(event, hex);
if (handled) {
event.preventDefault();
return;
}
}
}
handleKeyDown(event: KeyboardEvent) {
for (let i = this.inputHandlers.length - 1; i >= 0; i--) {
const handled = this.inputHandlers[i].onKeyDown?.(event);
if (handled) {
event.preventDefault();
return;
}
}
}
}

View File

@@ -0,0 +1,47 @@
import { SimCommandBlockTerrain } from '../../sim/commands/SimCommandBlockTerrain';
import { SimCommandCreateTower } from '../../sim/commands/SimCommandCreateTower';
import { Hex } from '../../util/Hex';
import { VisMain } from '../VisMain';
import { HexInteractionInputHandler } from './HexInteractionInputHandler';
import { InputHandler } from './InputHandler';
export class DefaultInputHandler implements InputHandler {
visMain: VisMain;
constructor(visMain: VisMain) {
this.visMain = visMain;
}
onKeyDown(event: KeyboardEvent): boolean {
if (event.code === 'Escape') {
if (this.visMain.inputHandlers.length > 1) {
this.visMain.inputHandlers.pop();
this.visMain.visLevel.setHoveredHex(null);
event.preventDefault();
return true;
}
} else if (event.code === 'Space') {
if (this.visMain.active) {
this.visMain.stop();
} else {
this.visMain.start();
}
return true;
} else if (event.code === 'KeyT') {
this.visMain.inputHandlers.push(new HexInteractionInputHandler(this.visMain, (hex) => new SimCommandCreateTower(hex, 0)));
return true;
} else if (event.code === 'KeyB') {
this.visMain.inputHandlers.push(new HexInteractionInputHandler(this.visMain, (hex) => new SimCommandBlockTerrain(hex)));
return true;
}
return false;
}
onMouseDown(event: MouseEvent, hex: Hex): boolean {
return false;
}
onMouseMove(event: MouseEvent, hex: Hex): boolean {
return false;
}
}

View File

@@ -0,0 +1,39 @@
import { SimCommand } from '../../sim/commands/SimCommand';
import { Hex } from '../../util/Hex';
import { VisMain } from '../VisMain';
import { InputHandler } from './InputHandler';
export class HexInteractionInputHandler implements InputHandler {
private hexCallback: (hex: Hex) => SimCommand;
private command: SimCommand | null = null;
visMain: VisMain;
constructor(visMain: VisMain, hexCallback: (hex: Hex) => SimCommand) {
this.visMain = visMain;
this.hexCallback = hexCallback;
}
onKeyDown(event: KeyboardEvent): boolean {
return false;
}
onMouseDown(event: MouseEvent, hex: Hex): boolean {
if (!this.command) {
return false;
}
this.visMain.simMain.addCommand(this.command);
return true;
}
onMouseMove(event: MouseEvent, hex: Hex): boolean {
this.command = this.hexCallback(hex);
if (this.command && this.command.check(this.visMain.simMain)) {
this.visMain.visLevel.setHoveredHex(hex);
}
else {
this.visMain.visLevel.setHoveredHex(null);
}
return false;
}
}

View File

@@ -0,0 +1,7 @@
import { Hex } from "../../util/Hex";
export interface InputHandler {
onKeyDown?: (event: KeyboardEvent) => boolean;
onMouseDown?: (event: MouseEvent, hex: Hex) => boolean;
onMouseMove?: (event: MouseEvent, hex: Hex) => boolean;
}

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']));
}
}

View File

@@ -24,18 +24,16 @@
{
"radius": 9,
"currency": 1500,
"walls": [
{ "col": 0, "row": 0 }
],
"walls": [{ "col": 0, "row": 0 }],
"enemySpawns": [
{ "col": -9, "row": -1},
{ "col": -9, "row": 0},
{ "col": -9, "row": 1}
{ "col": -9, "row": -1 },
{ "col": -9, "row": 0 },
{ "col": -9, "row": 1 }
],
"enemyTargets": [
{ "col": 8, "row": -1},
{ "col": 9, "row": 0},
{ "col": 8, "row": 1}
{ "col": 8, "row": -1 },
{ "col": 9, "row": 0 },
{ "col": 8, "row": 1 }
],
"enemyRoutes": [
[0, 0],

View File

@@ -1 +1,6 @@
/* You can add global styles to this file, and also import other style files */
html, body {
margin: 0;
padding: 0;
}