diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..460ce4c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "mochaExplorer.files": "test/**/*.spec.[jt]s", + "mochaExplorer.require": "ts-node/register" +} \ No newline at end of file diff --git a/images/spider.png b/images/spider.png new file mode 100644 index 0000000..1b742d1 Binary files /dev/null and b/images/spider.png differ diff --git a/images/spider_explosion.png b/images/spider_explosion.png new file mode 100644 index 0000000..caab693 Binary files /dev/null and b/images/spider_explosion.png differ diff --git a/images/web.png b/images/web.png new file mode 100644 index 0000000..6df0c5d Binary files /dev/null and b/images/web.png differ diff --git a/index.html b/index.html index 3e3a564..ff1098c 100644 --- a/index.html +++ b/index.html @@ -82,12 +82,15 @@ { name: 'probe_explosion', url: 'images/probe_explosion.png' }, { name: 'saucer', url: 'images/saucer.png' }, { name: 'saucer_explosion', url: 'images/saucer_explosion.png' }, + { name: 'spider', url: 'images/spider.png' }, + { name: 'spider_explosion', url: 'images/spider_explosion.png' }, { name: 'spinner', url: 'images/spinner.png' }, { name: 'splitter', url: 'images/splitter.png' }, { name: 'splitter_fragment', url: 'images/splitter_fragment.png' }, { name: 'splitter_left_separation', url: 'images/splitter_left_separation.png' }, { name: 'splitter_right_separation', url: 'images/splitter_right_separation.png' }, - { name: 'star', url: 'images/star.png' } + { name: 'star', url: 'images/star.png' }, + { name: 'web', url: 'images/web.png' } ]; renderer.preLoadImages(images, onImagesLoaded); @@ -165,7 +168,8 @@ { name: 'player_explosion', url: 'sounds/player_explosion.mp3' }, { name: 'player_hit', url: 'sounds/player_hit.mp3' }, { name: 'probe_explosion', url: 'sounds/probe_explosion.mp3' }, - { name: 'saucer_explosion', url: 'sounds/saucer_explosion.mp3' } + { name: 'saucer_explosion', url: 'sounds/saucer_explosion.mp3' }, + { name: 'spider_explosion', url: 'sounds/spider_explosion.mp3' } ]; audioPlayer.preLoadSounds(sounds, onSoundsLoaded, function(errors) { alert('Failed to load sounds: ' + JSON.stringify(errors)); diff --git a/sounds/spider_explosion.mp3 b/sounds/spider_explosion.mp3 new file mode 100644 index 0000000..396f0e7 Binary files /dev/null and b/sounds/spider_explosion.mp3 differ diff --git a/src/Game.ts b/src/Game.ts index fd076b2..68f7fb7 100644 --- a/src/Game.ts +++ b/src/Game.ts @@ -16,6 +16,7 @@ import {Scheduler} from './timing/Scheduler'; import {ScoreCounter} from './ScoreCounter'; import {SecondWave} from './waves/SecondWave'; import {SimpleWave} from './waves/SimpleWave'; +import {SpiderWave} from "./waves/SpiderWave"; import {SpinnerWave} from './waves/SpinnerWave'; import {SpinnerWave2} from './waves/SpinnerWave2'; import {SplitterWave} from './waves/SplitterWave'; @@ -92,7 +93,7 @@ export class Game { if (this._isActive) { setTimeout(() => { global.requestAnimationFrame(() => { this.tick() }); - }, 1000/30); + }, 1000/40); } else { debug('ticker: Ticker stopping because game is over.'); @@ -188,6 +189,11 @@ export class Game { new SimpleWave(this._audioPlayer, this._world, this._clock), new SecondWave(this._audioPlayer, this._world, this._clock), new SplitterWave(this._audioPlayer, this._world, this._clock) + ]), + new Level([ + new SpinnerWave(this._audioPlayer, this._world, this._clock), + new SpinnerWave2(this._audioPlayer, this._world, this._clock), + new SpiderWave(this._audioPlayer, this._world, this._clock) ]) ] ); diff --git a/src/Point.ts b/src/Point.ts index a360fd5..ddfbb5c 100644 --- a/src/Point.ts +++ b/src/Point.ts @@ -43,6 +43,10 @@ export class Point { return new Point(this._x + count, this._y); } + equals(other: Point): boolean { + return this._x === other._x && this._y === other._y; + } + toString(): string { return 'Point(x=' + this._x + ', y=' + this._y + ')'; } diff --git a/src/enemies/Spider.ts b/src/enemies/Spider.ts new file mode 100644 index 0000000..fa9e06e --- /dev/null +++ b/src/enemies/Spider.ts @@ -0,0 +1,228 @@ +import Debug from "debug"; +const debug = Debug("Blaster:Saucer"); +import {random} from 'underscore'; + +import {Actor} from "../Actor"; +import {AudioPlayer} from "../devices/AudioPlayer"; +import {Bounds} from '../Bounds'; +import {Clock} from "../timing/Clock"; +import {Enemy} from './Enemy'; +import {ExplosionProperties} from '../ExplosionProperties'; +import {HitArbiter} from '../HitArbiter'; +import {ImageDetails} from '../ImageDetails'; +import {LinePath} from '../paths/LinePath'; +import {LineSegmentPath} from "../paths/LineSegmentPath"; +import {PathAction} from '../paths/PathAction'; +import {PathEntry} from "../paths/PathEntry"; +import {Point} from '../Point'; +import {ScheduledAction} from '../paths/ScheduledAction'; +import {Scheduler} from '../timing/Scheduler'; +import {Web} from '../shots/Web'; +import {World} from "../World"; + +export class Spider extends Enemy { + public static readonly InitialHealth: number = 1; + + private readonly _clock: Clock; + private readonly _scheduler: Scheduler; + private readonly _hitArbiter: HitArbiter; + private readonly _homePosition: Point; + private _currentFrame: number = 0; + private _currentPath!: PathEntry[]; + private _pathPosition!: number; + private _state: Spider.State; + + constructor(audioPlayer: AudioPlayer, world: World, clock: Clock, startingPoint: Point, homePosition: Point) { + super(audioPlayer, world, startingPoint, Spider.InitialHealth); + debug('Spider constructor'); + + this._clock = clock; + this._scheduler = new Scheduler(clock); + this._hitArbiter = new HitArbiter(this); + this._homePosition = homePosition; + + this.prepareEntryPath(homePosition); + this._state = Spider.State.Entering; + this.advanceCurrentFrame(); + } + + get state(): Spider.State { + return this._state; + } + + get explosionProperties(): ExplosionProperties { + return new ExplosionProperties( + 'spider_explosion', + 5, + 52, + 0.8, + 'spider_explosion' + ); + } + + get scoreTotal(): number { + return 10; + } + + getCollisionMask(actor: Actor): Bounds[] { + return [new Bounds(-40, 40, -25, 25)]; + } + + getDamageAgainst(target: Actor): number { + return 5; + } + + get imageDetails(): ImageDetails { + return new ImageDetails('spider', 3, 95, this._currentFrame); + } + + hitBy(actor: Actor, damage: number): boolean { + this._health = Math.max(0, this._health - damage); + return true; + } + + tick(): void { + debug('Spider.tick'); + super.tick(); + + if (!this._isActive) { + return; + } + + this._scheduler.executeDueOperations(); + + this.step(); + + // Check if this enemy has hit the player. + const player = this._world.player; + if (player) { + this._hitArbiter.attemptToHit(player); + } + } + + private advanceCurrentFrame(): void { + this._currentFrame = (this._currentFrame + 1) % 3; + + this._scheduler.scheduleOperation( + 'advanceCurrentFrame', + 200, + () => { this.advanceCurrentFrame() } + ); + } + + private dropWeb(): void { + const web = new Web(this._audioPlayer, this._clock, this._world, this._location); + this._world.addActor(web); + } + + public swoop(): void { + if (random(0, 1) === 0) { + // Swoop down and off the screen. + const lowestPoint = new Point(Math.floor(random(10, 430)), 670); + const turns = random(0, 2); + switch (turns) { + case 0: + const linePath = new LinePath(this._location, lowestPoint, [new ScheduledAction(0.50, PathAction.Fire)]); + this._currentPath = linePath.getPathForSpeed(10); + this._pathPosition = 0; + this._state = Spider.State.Swooping; + break; + case 1: + const midPoint = new Point(Math.floor(random(10, 430)), random(300, 500)); + this._currentPath = new LineSegmentPath( + [this._location, midPoint, lowestPoint], + [new ScheduledAction(0.25, PathAction.Fire), new ScheduledAction(0.55, PathAction.Fire)] + ).getPathForSpeed(10); + this._pathPosition = 0; + this._state = Spider.State.Swooping; + break; + case 2: + const midPoint1 = new Point(Math.floor(random(10, 430)), random(300, 400)); + const midPoint2 = new Point(Math.floor(random(10, 430)), random(400, 500)); + this._currentPath = new LineSegmentPath( + [this._location, midPoint1, midPoint2, lowestPoint], + [new ScheduledAction(0.30, PathAction.Fire), new ScheduledAction(0.60, PathAction.Fire)] + ).getPathForSpeed(10); + this._pathPosition = 0; + this._state = Spider.State.Swooping; + break; + } + } + else { + // Swoop and return to home. + const lowestPoint = new Point(Math.floor(random(10, 430)), 500); + const swoopDownPath = new LinePath(this._location, lowestPoint, []); + const swoopReturnPath = new LinePath(lowestPoint, this._homePosition, [new ScheduledAction(0.50, PathAction.Fire)]); + this._currentPath = swoopDownPath.getPathForSpeed(8).concat(swoopReturnPath.getPathForSpeed(10)); + this._pathPosition = 0; + this._state = Spider.State.SwoopAndReturn; + } + + if (random(0, 1) === 0) { + this.dropWeb(); + } + } + + private step(): void { + // Choose the next path to follow once we've reach the end of the current path. + if (this._pathPosition >= this._currentPath.length) { + switch (this._state) { + case Spider.State.Entering: + this._state = Spider.State.Waiting; + break; + case Spider.State.SwoopAndReturn: + this._state = Spider.State.Waiting; + break; + case Spider.State.Swooping: + this._scheduler.scheduleOperation( + 'enter', + 3000, + () => { + // Determine our path back to our home position after. + const worldDimensions = this._world.dimensions; + const spiderStartingPoint = new Point( + Math.floor(random(0, worldDimensions.width-50)), + -20 + ); + // TODO: We might be able to call prepareEntryPath here instead. + const linePath = new LinePath(spiderStartingPoint, this._homePosition, []); + this._currentPath = linePath.getPathForSteps(10); + this._pathPosition = 0; + this._state = Spider.State.Entering; + } + ); + this._state = Spider.State.SwoopComplete; + break; + } + + return; + } + + // Follow the current path. + switch(this._currentPath[this._pathPosition].action) { + case PathAction.Move: + this._location = this._currentPath[this._pathPosition].location!; + break; + case PathAction.Fire: + this.dropWeb(); + break; + } + this._pathPosition++; + } + + private prepareEntryPath(homePosition: Point): void { + const linePath = new LinePath(this._location, homePosition, []); + this._currentPath = linePath.getPathForSteps(10); + this._pathPosition = 0; + } +} + +export namespace Spider { + export enum State { + Entering, + SwoopAndReturn, + SwoopComplete, + Swooping, + Waiting + } +} diff --git a/src/enemies/Spinner.ts b/src/enemies/Spinner.ts index 0714f47..a294110 100644 --- a/src/enemies/Spinner.ts +++ b/src/enemies/Spinner.ts @@ -255,7 +255,7 @@ export class Spinner extends Enemy { } } -export module Spinner { +export namespace Spinner { export enum Bias { Left, Right diff --git a/src/paths/LinePath.ts b/src/paths/LinePath.ts index d26f237..9f659b2 100644 --- a/src/paths/LinePath.ts +++ b/src/paths/LinePath.ts @@ -17,7 +17,17 @@ export class LinePath { this._scheduledActions = scheduledActions; } - getPath(numberOfSteps: number): PathEntry[] { + getPathForSpeed(speed: number): PathEntry[] { + const lineLength = Math.sqrt( + Math.pow(this._end.x - this._start.x, 2) + + Math.pow(this._end.y - this._start.y, 2) + ); + const numberOfSteps = Math.max(Math.floor(lineLength / speed), 1); + + return this.getPathForSteps(numberOfSteps); + } + + getPathForSteps(numberOfSteps: number): PathEntry[] { const path: PathEntry[] = []; const stepSize = 1.0 / numberOfSteps; diff --git a/src/paths/LineSegmentPath.ts b/src/paths/LineSegmentPath.ts new file mode 100644 index 0000000..ca8fd32 --- /dev/null +++ b/src/paths/LineSegmentPath.ts @@ -0,0 +1,75 @@ +import Debug from "debug"; +import {Point} from "../Point"; +import {ScheduledAction} from "./ScheduledAction"; +import {PathEntry} from "./PathEntry"; +import {LinePath} from "./LinePath"; + +const debug = Debug("Blaster:Paths:LinePath"); + +export class LineSegmentPath { + private readonly _points: Point[]; + private readonly _scheduledActions: ScheduledAction[]; + + constructor(points: Point[], scheduledActions: ScheduledAction[]) { + this._points = points; + this._scheduledActions = scheduledActions; + } + + private getSegmentLength(point1: Point, point2: Point): number { + const dx = point2.x - point1.x; + const dy = point2.y - point1.y; + return Math.sqrt(dx * dx + dy * dy); + } + + getPathForSpeed(speed: number): PathEntry[] { + let lineLength = 0.0; + for (let i = 0; i < this._points.length - 1; i++) { + lineLength += this.getSegmentLength(this._points[i], this._points[i + 1]); + } + const numberOfSteps = Math.max(Math.floor(lineLength / speed), 1); + + return this.getPathForSteps(numberOfSteps); + } + + getPathForSteps(numberOfSteps: number): PathEntry[] { + const path: PathEntry[] = []; + + const stepSize = 1.0 / numberOfSteps; + + let totalLength: number = 0; + for (let i = 0; i < this._points.length - 1; i++) { + totalLength += this.getSegmentLength(this._points[i], this._points[i + 1]); + } + + // foreach segment + // determine ratio of this segment to total length + // create line path for this segment with number of points based on ratio + // add path to a total path + for (let i = 0; i < this._points.length - 1; i++) { + const segmentLength = this.getSegmentLength(this._points[i], this._points[i + 1]); + const segmentNumberOfSteps = Math.round(segmentLength / totalLength * numberOfSteps); + + const linePath = new LinePath(this._points[i], this._points[i + 1], []); + const segmentPath = linePath.getPathForSteps(segmentNumberOfSteps); + + // The end of each segment is the same as the start of the next segment, so we don't want to duplicate it. + path.pop(); + path.push(...segmentPath); + } + + if (this._scheduledActions) { + for (const scheduledAction of this._scheduledActions) { + // Create the new action entry. + const pathEntry = new PathEntry(scheduledAction.action, null); + + // Figure out what offset to include it at. + const stepPosition = Math.floor(numberOfSteps * scheduledAction.when); + + // Insert it at that position. + path.splice(stepPosition, 0, pathEntry); + } + } + + return path; + }; +} diff --git a/src/shots/Web.ts b/src/shots/Web.ts new file mode 100644 index 0000000..29a9a46 --- /dev/null +++ b/src/shots/Web.ts @@ -0,0 +1,88 @@ +import Debug from "debug"; +const debug = Debug("Blaster:Bomb"); + +import {Actor} from "../Actor"; +import {AudioPlayer} from "../devices/AudioPlayer"; +import {Bounds} from '../Bounds'; +import {Clock} from "../timing/Clock"; +import {Direction} from "../devices/Direction"; +import {HitArbiter} from '../HitArbiter'; +import {HitResult} from '../HitResult'; +import {ImageDetails} from '../ImageDetails'; +import {Point} from '../Point'; +import {Scheduler} from '../timing/Scheduler'; +import {Shot} from './Shot'; +import {World} from "../World"; + +export class Web extends Shot { + private readonly _audioPlayer: AudioPlayer; + private readonly _scheduler: Scheduler; + private _currentFrame: number = 0; + private _firstTick: boolean = true; + + constructor(audioPlayer: AudioPlayer, clock: Clock, world: World, startingPoint: Point) { + super(world, startingPoint); + debug('Web constructor'); + + this._audioPlayer = audioPlayer; + this._scheduler = new Scheduler(clock); + this._currentFrame = 0; + } + + getCollisionMask(actor: Actor): Bounds[] { + const offset = this._currentFrame / 10.0 * 45; + + return [new Bounds(-offset, offset, -offset, offset)]; + } + + getDamageAgainst(actor: Actor): number { + return 1; + } + + get imageDetails(): ImageDetails { + return new ImageDetails('web', 10, 100, this._currentFrame); + } + + tick(): void { + debug('Web.tick'); + super.tick(); + + if (this._firstTick) { + this._audioPlayer.play('bomb_drop'); // TODO: Find another sound for this? + this._firstTick = false; + } + + this._scheduler.scheduleOperation( + 'increaseSize', + 250, + () => { this._currentFrame = Math.min(this._currentFrame + 1, 9); } + ); + this._scheduler.executeDueOperations(); + + const speed = 12; + for (let step = 0; step < speed; step++) { + this.move(Direction.Down); + + if (this._location.y > this._world.dimensions.height) { + // When the web leaves the world, it becomes inactive. + debug('De-activating web ' + this._id); + this._isActive = false; + } + else { + // Check if this web has collided with any active enemies. + const player = this._world.player; + if (player) { + const hitArbiter = new HitArbiter(this); + //TODO: Do something if the hit is ineffective. + if (hitArbiter.attemptToHit(player) !== HitResult.Miss) { + this._isActive = false; + } + } + } + + if (!this._isActive) { + break; + } + } + } +} diff --git a/src/waves/SpiderWave.ts b/src/waves/SpiderWave.ts new file mode 100644 index 0000000..65cefe5 --- /dev/null +++ b/src/waves/SpiderWave.ts @@ -0,0 +1,112 @@ +import Debug from "debug"; +const debug = Debug("Blaster:SimpleWave"); +import {random} from 'underscore'; + +import {AudioPlayer} from "../devices/AudioPlayer"; +import {Clock} from "../timing/Clock"; +import {Point} from '../Point'; +import {Scheduler} from '../timing/Scheduler'; +import {Wave} from './Wave'; +import {World} from "../World"; +import {Spider} from '../enemies/Spider'; + +export class SpiderWave implements Wave { + private readonly _audioPlayer: AudioPlayer; + private readonly _world: World; + private readonly _clock: Clock; + private _numberOfEnemiesLeftToDeploy: number = 15; + private readonly _swoopers: Map = new Map(); + + private readonly _scheduler: Scheduler; + + constructor(audioPlayer: AudioPlayer, world: World, clock: Clock) { + debug('SpiderWave constructor'); + this._audioPlayer = audioPlayer; + this._world = world; + this._clock = clock; + + this._scheduler = new Scheduler(clock); + + this._scheduler.scheduleOperation( + 'deploy', + 0, + () => { this.deploySpider() } + ); + } + + get isActive(): boolean { + return (this._numberOfEnemiesLeftToDeploy > 0) + || (this._world.activeEnemies.length > 0) + || (this._world.activeExplosions.length > 0); + } + + tick(): void { + debug('SpiderWave.tick'); + + this._scheduler.executeDueOperations(); + } + + scheduleNextSwoop(swoopIndex: number) : void { + // If no swooper has been chosen yet, or if the last chosen swooper is dead or waiting, schedule a new swoop. + if (!this._swoopers.has(swoopIndex) || (!this._world.activeEnemies.includes(this._swoopers.get(swoopIndex)!) || this._swoopers.get(swoopIndex)!.state === Spider.State.Waiting)) { + if (this._world.activeEnemies.length > 0) { + const timeTillSwoop = random(100, 1000); + this._scheduler.scheduleOperation( + `next swoop ${swoopIndex}`, + timeTillSwoop, + () => { + const waitingSpiders = this._world.activeEnemies.filter(enemy => (enemy as Spider).state === Spider.State.Waiting); + if (waitingSpiders.length > 0) { + const spider = waitingSpiders[random(waitingSpiders.length-1)] as Spider; + this._swoopers.set(swoopIndex, spider); + + spider.swoop(); + + this.scheduleNextSwoop(swoopIndex); + } + else { + // No waiting spiders, so try again later. + this._scheduler.scheduleOperation( + `next swoop ${swoopIndex}`, + 500, + () => this.scheduleNextSwoop(swoopIndex) + ); + } + } + ); + } + } + + // TODO: This could be done such that we pass a callback to the swoop method, which gets called when the swoop is complete (or die). We wouldn't have to call this repeatedly. + this._scheduler.scheduleOperation(`next swoop ${swoopIndex}`, 0, () => this.scheduleNextSwoop(swoopIndex)); + } + + deploySpider(): void { + if (this._numberOfEnemiesLeftToDeploy > 0) { + const worldDimensions = this._world.dimensions; + const spiderStartingPoint = new Point( + Math.floor(random(0, worldDimensions.width-50)), + -20 + ); + const spiderHomeX = (5-(this._numberOfEnemiesLeftToDeploy-1)%5) * 80; + const spiderHomeY = 100 + Math.floor((this._numberOfEnemiesLeftToDeploy-1)/5) * 70; + const spiderHomePosition = new Point(spiderHomeX, spiderHomeY); + const spider = new Spider(this._audioPlayer, this._world, this._clock, spiderStartingPoint, spiderHomePosition); + this._world.addActor(spider); + + this._numberOfEnemiesLeftToDeploy--; + + this._scheduler.scheduleOperation( + 'deploy', + 50, + () => { this.deploySpider() } + ); + } + else { + // Get two swoopers going right away, then let them get re-scheduled as they finish. + this._scheduler.scheduleOperation('next swoop 1', 0, () => this.scheduleNextSwoop(0)); + this._scheduler.scheduleOperation('next swoop 2', 500, () => this.scheduleNextSwoop(1)); + this._scheduler.scheduleOperation('next swoop 3', 1000, () => this.scheduleNextSwoop(2)); + } + } +} diff --git a/test/Point.spec.ts b/test/Point.spec.ts new file mode 100644 index 0000000..e8ece6c --- /dev/null +++ b/test/Point.spec.ts @@ -0,0 +1,93 @@ +import {describe} from 'mocha'; +import {expect} from 'chai'; + +import {Point} from '../src/Point'; + +describe('Point', () => { + describe('#ctor()', () => { + it('defines a position', () => { + const point = new Point(3, 4); + expect(point.x).to.be.equal(3); + expect(point.y).to.be.equal(4); + }); + }); + + describe('#withX()', () => { + it('returns a new Point with the specified x coordinate', () => { + const point = new Point(3, 4); + const newPoint = point.withX(10); + expect(newPoint.x).to.be.equal(10); + expect(newPoint.y).to.be.equal(4); + }); + }); + + describe('#withY()', () => { + it('returns a new Point with the specified y coordinate', () => { + const point = new Point(3, 4); + const newPoint = point.withY(10); + expect(newPoint.x).to.be.equal(3); + expect(newPoint.y).to.be.equal(10); + }); + }); + + describe('#translate()', () => { + it('returns a new Point translated by the specified amounts', () => { + const point = new Point(3, 4); + const newPoint = point.translate(2, 3); + expect(newPoint.x).to.be.equal(5); + expect(newPoint.y).to.be.equal(7); + }); + }); + + describe('#up()', () => { + it('returns a new Point moved upwards by the specified amount', () => { + const point = new Point(3, 4); + const newPoint = point.up(2); + expect(newPoint.x).to.be.equal(3); + expect(newPoint.y).to.be.equal(2); + }); + }); + + describe('#down()', () => { + it('returns a new Point moved downwards by the specified amount', () => { + const point = new Point(3, 4); + const newPoint = point.down(2); + expect(newPoint.x).to.be.equal(3); + expect(newPoint.y).to.be.equal(6); + }); + }); + + describe('#left()', () => { + it('returns a new Point moved left by the specified amount', () => { + const point = new Point(3, 4); + const newPoint = point.left(2); + expect(newPoint.x).to.be.equal(1); + expect(newPoint.y).to.be.equal(4); + }); + }); + + describe('#right()', () => { + it('returns a new Point moved right by the specified amount', () => { + const point = new Point(3, 4); + const newPoint = point.right(2); + expect(newPoint.x).to.be.equal(5); + expect(newPoint.y).to.be.equal(4); + }); + }); + + describe('#equals()', () => { + it('returns true for points with the same coordinates', () => { + const point1 = new Point(3, 4); + const point2 = new Point(3, 4); + expect(point1.equals(point2)).to.be.true; + }); + + it('returns false for points with different coordinates', () => { + const point1 = new Point(3, 4); + const point2 = new Point(5, 4); + const point3 = new Point(3, 6); + expect(point1.equals(point2)).to.be.false; + expect(point1.equals(point3)).to.be.false; + }); + }); +}); diff --git a/test/enemies/Spider.spec.ts b/test/enemies/Spider.spec.ts new file mode 100644 index 0000000..66a1985 --- /dev/null +++ b/test/enemies/Spider.spec.ts @@ -0,0 +1,80 @@ +import {describe} from 'mocha'; +import {expect} from 'chai'; + +import {AudioPlayer} from "../../src/devices/AudioPlayer"; +import {Bullet} from "../../src/shots/Bullet"; +import {Dimensions} from "../../src/Dimensions"; +import {Point} from '../../src/Point'; +import {Spider} from '../../src/enemies/Spider'; +import {ScoreCounter} from '../../src/ScoreCounter'; +import {World} from '../../src/World'; + +import {AudioPlayerStub} from "../stubs/AudioPlayerStub"; +import {ClockStub} from "../stubs/ClockStub"; +import {ActorStub} from "../stubs/ActorStub"; + +describe('Spider', () => { + let audioPlayer: AudioPlayer; + let clock: ClockStub; + let scoreCounter: ScoreCounter; + let world: World; + + beforeEach(() => { + audioPlayer = new AudioPlayerStub(); + clock = new ClockStub(); + scoreCounter = new ScoreCounter(); + world = new World(new Dimensions(480, 640), scoreCounter); + }); + + describe('#hitBy()', () => { + it('should return true', () => { + const spider = new Spider(audioPlayer, world, clock, new Point(5, 10), new Point(100, 100)); + const actor = new ActorStub(world, new Point(5, 10)); + world.addActor(actor); + + expect(spider.hitBy(actor, 1)).to.be.true; + }); + }); + + describe('#tick()', () => { + it('should de-activate after health reaches zero', () => { + const bullet = new Bullet(audioPlayer, world, new Point(5, 10)); + world.addActor(bullet); + + const spider = new Spider(audioPlayer, world, clock, new Point(5, 10), new Point(100, 100)); + spider.hitBy(bullet, Spider.InitialHealth); + spider.tick(); + expect(spider.isActive).to.be.false; + }); + + it('should remain active after hit if health remains above zero', () => { + const bullet = new Bullet(audioPlayer, world, new Point(5, 10)); + world.addActor(bullet); + + const spider = new Spider(audioPlayer, world, clock, new Point(5, 10), new Point(100, 100)); + spider.hitBy(bullet, Spider.InitialHealth / 2); + spider.tick(); + expect(spider.isActive).to.be.true; + }); + + it('should add an explosion when it is destroyed', () => { + const bullet = new Bullet(audioPlayer, world, new Point(5, 10)); + world.addActor(bullet); + + const spider = new Spider(audioPlayer, world, clock, new Point(5, 10), new Point(100, 100)); + spider.hitBy(bullet, Spider.InitialHealth); + spider.tick(); + expect(world.activeExplosions.length).to.be.equal(1); + }); + + it('should increment the score when it is destroyed', () => { + const bullet = new Bullet(audioPlayer, world, new Point(5, 10)); + world.addActor(bullet); + + const spider = new Spider(audioPlayer, world, clock, new Point(5, 10), new Point(100, 100)); + spider.hitBy(bullet, Spider.InitialHealth); + spider.tick(); + expect(scoreCounter.currentScore).to.be.above(0); + }); + }); +}); diff --git a/test/paths/LinePath.spec.ts b/test/paths/LinePath.spec.ts index e9291ae..8b2a6a2 100644 --- a/test/paths/LinePath.spec.ts +++ b/test/paths/LinePath.spec.ts @@ -10,12 +10,30 @@ import {PathEntry} from "../../src/paths/PathEntry"; import {PathAction} from "../../src/paths/PathAction"; describe('LinePath', () => { - describe('#getPath()', () => { + describe('getPathForSpeed()', () => { + it('creates a path with the specified speed', () => { + const linePath = new LinePath(new Point(0.0, 0.0), new Point(100.0, 100.0), []); + + const path = linePath.getPathForSpeed(10.0); + + expect(path.length).to.be.equal(15); + }); + + it('creates a path with one step if speed greater than distance', () => { + const linePath = new LinePath(new Point(0.0, 0.0), new Point(5.0, 5.0), []); + + const path = linePath.getPathForSpeed(100.0); + + expect(path.length).to.be.equal(2); + }); + }); + + describe('#getPathForSteps()', () => { it ('creates a path with the specified number of steps', () => { const linePath = new LinePath(new Point(0.0, 0.0), new Point(100.0, 100.0), []); const numberOfSteps = 10; - const path = linePath.getPath(numberOfSteps); + const path = linePath.getPathForSteps(numberOfSteps); // A given number of steps require one additional point (fencepost analogy). expect(path.length).to.be.equal(numberOfSteps + 1); @@ -24,7 +42,7 @@ describe('LinePath', () => { it ('creates a path containing only movements when no other actions are specified in the template', () => { const linePath = new LinePath(new Point(0.0, 0.0), new Point(100.0, 100.0), []); - const path = linePath.getPath(10); + const path = linePath.getPathForSteps(10); // All steps that are movements. const movements = path.filter((value: PathEntry): boolean => { return value.action === PathAction.Move }); @@ -35,7 +53,7 @@ describe('LinePath', () => { it('creates paths that begin and terminate at the first and last provided points', () => { const linePath = new LinePath(new Point(0.0, 0.0), new Point(10.0, 30.0), []); - const path = linePath.getPath(10); + const path = linePath.getPathForSteps(10); expect(Math.round(path[0].location!.x)).to.be.eql(0.0); expect(Math.round(path[0].location!.y)).to.be.eql(0.0); @@ -50,7 +68,7 @@ describe('LinePath', () => { new Point(100.0, 100.0), [new ScheduledAction(0.50, PathAction.Fire)]); - const path = linePath.getPath(10); + const path = linePath.getPathForSteps(10); // One extra step for the fire action. expect(path.length).to.be.equal(12); @@ -61,7 +79,7 @@ describe('LinePath', () => { it('only generates coordinates between the start and end points', () => { const linePath = new LinePath(new Point(10.0, 50.0), new Point(40.0, 90.0), []); - const path = linePath.getPath(10); + const path = linePath.getPathForSteps(10); expect( every( diff --git a/test/paths/LineSegmentPath.spec.ts b/test/paths/LineSegmentPath.spec.ts new file mode 100644 index 0000000..2fd6cce --- /dev/null +++ b/test/paths/LineSegmentPath.spec.ts @@ -0,0 +1,161 @@ +import {describe} from 'mocha'; +import {expect} from 'chai'; + +import {every} from 'underscore'; + +import {LineSegmentPath} from "../../src/paths/LineSegmentPath"; +import {Point} from "../../src/Point"; +import {ScheduledAction} from "../../src/paths/ScheduledAction"; +import {PathEntry} from "../../src/paths/PathEntry"; +import {PathAction} from "../../src/paths/PathAction"; + +describe('LineSegmentPath', () => { + describe('#getPathForSpeed() [two segment]', () => { + it('creates a path with specified speed', () => { + const linePath = new LineSegmentPath([new Point(0.0, 0.0), new Point(100.0, 100.0), new Point(200.0, 0.0)], []); + + const path = linePath.getPathForSpeed(10); + + expect(path.length).to.be.equal(29); + }); + }); + + describe('#getPathForSteps() [two segment]', () => { + it('creates a path with the specified number of steps', () => { + const linePath = new LineSegmentPath([new Point(0.0, 0.0), new Point(100.0, 100.0), new Point(200.0, 0.0)], []); + + const numberOfSteps = 10; + const path = linePath.getPathForSteps(numberOfSteps); + + // A given number of steps require one additional point (fencepost analogy). + expect(path.length).to.be.equal(numberOfSteps + 1); + }); + + it('creates a path containing only movements when no other actions are specified in the template', () => { + const linePath = new LineSegmentPath([new Point(0.0, 0.0), new Point(100.0, 100.0), new Point(200.0, 0.0)], []); + + const path = linePath.getPathForSteps(10); + + // All steps that are movements. + const movements = path.filter((value: PathEntry): boolean => { return value.action === PathAction.Move }); + + expect(movements.length).to.be.equal(path.length); + }); + + it('creates paths that begin and terminate at the first and last provided points', () => { + const linePath = new LineSegmentPath([new Point(0.0, 0.0), new Point(10.0, 30.0), new Point(20.0, 60.0)], []); + + const path = linePath.getPathForSteps(10); + + expect(Math.round(path[0].location!.x)).to.be.eql(0.0); + expect(Math.round(path[0].location!.y)).to.be.eql(0.0); + + expect(Math.round(path[10].location!.x)).to.be.closeTo(20.0, 0.01); + expect(Math.round(path[10].location!.y)).to.be.closeTo(60.0, 0.01); + }); + + it('inserts non-movement actions at specified positions along the path', () => { + const linePath = new LineSegmentPath([new Point(0.0, 0.0), new Point(100.0, 100.0), new Point(200.0, 200.0)], [new ScheduledAction(0.50, PathAction.Fire)]); + + const path = linePath.getPathForSteps(10); + + // One extra step for the fire action. + expect(path.length).to.be.equal(12); + + expect(path[5].action).to.be.equal(PathAction.Fire); + }); + + it('generates coordinates that include specified points', () => { + const points = [new Point(0.0, 0.0), new Point(100.0, 100.0), new Point(200.0, 200.0)]; + const linePath = new LineSegmentPath(points, []); + + const path = linePath.getPathForSteps(10); + + for (const point of points) { + expect(path.filter((value: PathEntry): boolean => { return value.location!.equals(point); }).length).to.be.equal(1); + } + }); + + it('only generates coordinates between the start and end of each pair of points', () => { + const linePath = new LineSegmentPath([new Point(0.0, 0.0), new Point(10.0, 0.0), new Point(10.0, 10.0)], []); + + const path = linePath.getPathForSteps(10); + + path.slice(0, 4).forEach((point: PathEntry): void => { + expect(point.location!.x).to.be.lessThanOrEqual(10.0); + expect(point.location!.y).to.be.closeTo(0.0, 0.01); + }); + path.slice(5, 10).forEach((point: PathEntry): void => { + expect(point.location!.x).to.be.closeTo(10.0, 0.01); + expect(point.location!.y).to.be.lessThanOrEqual(10.0); + }); + }); + }); + + describe('#getPath() [single segment]', () => { + it ('creates a path with the specified number of steps', () => { + const linePath = new LineSegmentPath([new Point(0.0, 0.0), new Point(100.0, 100.0)], []); + + const numberOfSteps = 10; + const path = linePath.getPathForSteps(numberOfSteps); + + // A given number of steps require one additional point (fencepost analogy). + expect(path.length).to.be.equal(numberOfSteps + 1); + }); + + it ('creates a path containing only movements when no other actions are specified in the template', () => { + const linePath = new LineSegmentPath([new Point(0.0, 0.0), new Point(100.0, 100.0)], []); + + const path = linePath.getPathForSteps(10); + + // All steps that are movements. + const movements = path.filter((value: PathEntry): boolean => { return value.action === PathAction.Move }); + + expect(movements.length).to.be.equal(path.length); + }); + + it('creates paths that begin and terminate at the first and last provided points', () => { + const linePath = new LineSegmentPath([new Point(0.0, 0.0), new Point(10.0, 30.0)], []); + + const path = linePath.getPathForSteps(10); + + expect(Math.round(path[0].location!.x)).to.be.eql(0.0); + expect(Math.round(path[0].location!.y)).to.be.eql(0.0); + + expect(Math.round(path[10].location!.x)).to.be.closeTo(10.0, 0.01); + expect(Math.round(path[10].location!.y)).to.be.closeTo(30.0, 0.01); + }); + + it('inserts non-movement actions at specified positions along the path', () => { + const linePath = new LineSegmentPath( + [new Point(0.0, 0.0), new Point(100.0, 100.0)], + [new ScheduledAction(0.50, PathAction.Fire)]); + + const path = linePath.getPathForSteps(10); + + // One extra step for the fire action. + expect(path.length).to.be.equal(12); + + expect(path[5].action).to.be.equal(PathAction.Fire); + }); + + it('only generates coordinates between the start and end points', () => { + const linePath = new LineSegmentPath([new Point(10.0, 50.0), new Point(40.0, 90.0)], []); + + const path = linePath.getPathForSteps(10); + + expect( + every( + path.map((point: PathEntry): number => { return point.location!.x }), + (value: number): boolean => { return (value >= 10.0) && (value <= 40.0); } + ) + ).to.be.true; + expect( + every( + path.map((point: PathEntry): number => { return point.location!.y }), + (value: number): boolean => { return (value >= 50.0) && (value <= 90.0); } + ) + ).to.be.true; + }); + }); +}); diff --git a/test/shots/Web.spec.ts b/test/shots/Web.spec.ts new file mode 100644 index 0000000..4651e5f --- /dev/null +++ b/test/shots/Web.spec.ts @@ -0,0 +1,144 @@ +import {describe} from 'mocha'; +import {expect} from 'chai'; + +import {Dimensions} from "../../src/Dimensions"; +import {Point} from '../../src/Point'; +import {ScoreCounter} from "../../src/ScoreCounter"; +import {Web} from '../../src/shots/Web'; +import {World} from '../../src/World'; + +import {AudioPlayerStub} from "../stubs/AudioPlayerStub"; +import {ClockStub} from '../stubs/ClockStub'; +import {PlayerStub} from "../stubs/PlayerStub"; + +describe('Web', () => { + describe('#tick()', () => { + let audioPlayer: AudioPlayerStub; + let clock: ClockStub; + let world: World; + + beforeEach(() => { + audioPlayer = new AudioPlayerStub(); + clock = new ClockStub(); + world = new World(new Dimensions(480, 640), new ScoreCounter()); + }); + + it('should move the web directly downwards', () => { + const web = new Web(audioPlayer, clock, world, new Point(5, 10)); + web.tick(); + expect(web.coordinates.x).to.be.equal(5); + expect(web.coordinates.y).to.be.above(10); + }); + + it ('should advance sprite frames over time', () => { + const web = new Web(audioPlayer, clock, world, new Point(5, 10)); + expect(web.imageDetails.currentFrame).to.be.equal(0); + web.tick(); + expect(web.imageDetails.currentFrame).to.be.equal(0); + clock.addSeconds(1); + web.tick(); + expect(web.imageDetails.currentFrame).to.be.greaterThanOrEqual(1); + const lastFrame = web.imageDetails.currentFrame; + clock.addSeconds(1); + web.tick(); + expect(web.imageDetails.currentFrame).to.be.greaterThanOrEqual(lastFrame); + }); + + it ('should stop advancing frames at the maximum frame', () => { + const web = new Web(audioPlayer, clock, world, new Point(5, 10)); + expect(web.imageDetails.currentFrame).to.be.equal(0); + web.tick(); + clock.addSeconds(5); + web.tick(); + expect(web.imageDetails.currentFrame).to.be.greaterThanOrEqual(1); + const lastFrame = web.imageDetails.currentFrame; + clock.addSeconds(1); + web.tick(); + expect(web.imageDetails.currentFrame).to.be.equal(lastFrame); + }); + + it('should remain active while it remains within the world', () => { + const web = new Web(audioPlayer, clock, world, new Point(5, 10)); + web.tick(); + expect(web.isActive).to.be.true; + }); + + it('should become inactive when it leaves the world', () => { + const web = new Web(audioPlayer, clock, world, new Point(5, world.dimensions.height - 1)); + web.tick(); + expect(web.isActive).to.be.false; + }); + + it('should hit an active player within collision distance', () => { + const player = new PlayerStub(world, new Point(10, 10)); + world.addActor(player); + + let hit: boolean = false; + player.onHit(damage => { hit = true }); + + const web = new Web(audioPlayer, clock, world, new Point(10, 10)); + web.tick(); + expect(hit).to.be.true; + }); + + it('should not hit an active player outside collision distance', () => { + const player = new PlayerStub(world, new Point(100, 100)); + world.addActor(player); + + let hit: boolean = false; + player.onHit(damage => { hit = true }); + + const web = new Web(audioPlayer, clock, world, new Point(10, 10)); + web.tick(); + expect(hit).to.be.false; + }); + + it('should hit the player with damage equal to 1', () => { + const player = new PlayerStub(world, new Point(10, 10)); + world.addActor(player); + + const hitFor: number[] = []; + player.onHit(damage => { hitFor.push(damage) }); + + const web = new Web(audioPlayer, clock, world, new Point(10, 10)); + web.tick(); + expect(hitFor).to.be.eql([1]); + }); + + it('should become inactive after it has made a successful hit', () => { + const player = new PlayerStub(world, new Point(10, 10)); + world.addActor(player); + + const hitFor: number[] = []; + player.onHit(damage => { hitFor.push(damage) }); + + const web = new Web(audioPlayer, clock, world, new Point(10, 10)); + web.tick(); + expect(web.isActive).to.be.false; + }); + + it('should become inactive if it makes an unsuccessful hit', () => { + const player = new PlayerStub(world, new Point(10, 10)).ignoreHits(); + world.addActor(player); + + const web = new Web(audioPlayer, clock, world, new Point(10, 10)); + web.tick(); + expect(web.isActive).to.be.false; + }); + + it('should remain active when there is no player', () => { + const web = new Web(audioPlayer, clock, world, new Point(10, 10)); + web.tick(); + expect(web.isActive).to.be.true; + }); + + it('should play a sound on the first tick', () => { + const playedSounds: string[] = []; + audioPlayer.onPlay((soundName: string) => playedSounds.push(soundName)); + + const web = new Web(audioPlayer, clock, world, new Point(10, 10)); + web.tick(); + expect(playedSounds.length).to.be.above(0); + }); + }); +}); diff --git a/test/timing/Scheduler.spec.ts b/test/timing/Scheduler.spec.ts index c1efc5a..1d56436 100644 --- a/test/timing/Scheduler.spec.ts +++ b/test/timing/Scheduler.spec.ts @@ -1,27 +1,27 @@ -import {describe} from 'mocha'; -import {expect} from 'chai'; +import { describe } from 'mocha'; +import { expect } from 'chai'; -import {Clock} from '../../src/timing/Clock'; -import {Scheduler} from "../../src/timing/Scheduler"; +import { Clock } from '../../src/timing/Clock'; +import { Scheduler } from "../../src/timing/Scheduler"; -import {ClockStub} from '../stubs/ClockStub'; +import { ClockStub } from '../stubs/ClockStub'; describe('Scheduler', () => { describe('#scheduleOperation()', () => { - it('schedules an operation that has not already been scheduled', () => { - const clock = new Clock(); - const scheduler = new Scheduler(clock); - const result = scheduler.scheduleOperation('someTag', 0, () => {}); - expect(result).to.be.true; - }); - - it('does not schedule an operation that has already been scheduled', () => { - const clock = new Clock(); - const scheduler = new Scheduler(clock); - scheduler.scheduleOperation('someTag', 0, () => {}); - const result = scheduler.scheduleOperation('someTag', 0, () => {}); - expect(result).to.be.false; - }); + it('schedules an operation that has not already been scheduled', () => { + const clock = new Clock(); + const scheduler = new Scheduler(clock); + const result = scheduler.scheduleOperation('someTag', 0, () => { }); + expect(result).to.be.true; + }); + + it('does not schedule an operation that has already been scheduled', () => { + const clock = new Clock(); + const scheduler = new Scheduler(clock); + scheduler.scheduleOperation('someTag', 0, () => { }); + const result = scheduler.scheduleOperation('someTag', 0, () => { }); + expect(result).to.be.false; + }); it('allows an operation to be re-scheduled from inside the handler of that operation', () => { const clock = new ClockStub(); @@ -30,57 +30,57 @@ describe('Scheduler', () => { let operationExecuted = false; // Schedule an initial operation... scheduler.scheduleOperation('someTag', 0, () => { - // ...which, when due, will schedule another operation with the same tag - scheduler.scheduleOperation('someTag', 0, () => { - operationExecuted = true; - }); + // ...which, when due, will schedule another operation with the same tag + scheduler.scheduleOperation('someTag', 0, () => { + operationExecuted = true; + }); }); scheduler.executeDueOperations(); // Execute initial operation. scheduler.executeDueOperations(); // Execute new operation added during first operation. expect(operationExecuted).to.be.true; }); + }); - describe('#executeDueOperations()', () => { - it('does not execute scheduled operations that are not yet due', () => { - const clock = new ClockStub(); - const scheduler = new Scheduler(clock); - - let operationExecuted = false; - scheduler.scheduleOperation('someTag', 5000, () => { - operationExecuted = true; - }); - scheduler.executeDueOperations(); + describe('#executeDueOperations()', () => { + it('does not execute scheduled operations that are not yet due', () => { + const clock = new ClockStub(); + const scheduler = new Scheduler(clock); - expect(operationExecuted).to.be.false; + let operationExecuted = false; + scheduler.scheduleOperation('someTag', 5000, () => { + operationExecuted = true; }); + scheduler.executeDueOperations(); - it('executes scheduled operations that are exactly due', () => { - const clock = new ClockStub(); - const scheduler = new Scheduler(clock); + expect(operationExecuted).to.be.false; + }); - let operationExecuted = false; - scheduler.scheduleOperation('someTag', 0, () => { - operationExecuted = true; - }); - scheduler.executeDueOperations(); + it('executes scheduled operations that are exactly due', () => { + const clock = new ClockStub(); + const scheduler = new Scheduler(clock); - expect(operationExecuted).to.be.true; + let operationExecuted = false; + scheduler.scheduleOperation('someTag', 0, () => { + operationExecuted = true; }); + scheduler.executeDueOperations(); - it ('executes scheduled operations that are past due', () => { - const clock = new ClockStub(); - const scheduler = new Scheduler(clock); + expect(operationExecuted).to.be.true; + }); - let operationExecuted = false; - scheduler.scheduleOperation('someTag', 0, () => { - operationExecuted = true; - }); - clock.addSeconds(5); - scheduler.executeDueOperations(); + it('executes scheduled operations that are past due', () => { + const clock = new ClockStub(); + const scheduler = new Scheduler(clock); - expect(operationExecuted).to.be.true; + let operationExecuted = false; + scheduler.scheduleOperation('someTag', 0, () => { + operationExecuted = true; }); + clock.addSeconds(5); + scheduler.executeDueOperations(); + + expect(operationExecuted).to.be.true; }); }); });