diff --git a/README.md b/README.md index 8c970b2..a4ca03b 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ After making changes to sources, you need to recompile: * **Power** - Available action points (some actions require more power than others) * **Power recovery** - Power generated at the end of a ship's turn -## Capabilities +## Skills * **Materials** - Usage of physical materials such as bullets, shells... * **Electronics** - Components of computers and communication @@ -37,3 +37,19 @@ After making changes to sources, you need to recompile: * **Human** - Management of a human team and resources * **Gravity** - Interaction with gravitational forces * **Time** - Manipulation of time + +## Drones + +Drones are static objects, deployed by ships, that apply effects in a circular zone around themselves. + +Drone effects are applied : + +* On all ships in the zone at the time the drone is deployed +* On any ship entering the zone +* On any ship inside the zone at the start and end of its turn (there and staying there) + +Drones are fully autonomous, and once deployed, are not controlled by their owner ship. + +A drone lasts for a given number of turns, counting down each time its owner's turn starts. +When reaching the number of turns, the drone is destroyed (before the owner's turn is started). +For example, a drone with 1-turn duration will destroy just before the next turn of its owner. diff --git a/TODO b/TODO index c637a8d..693a620 100644 --- a/TODO +++ b/TODO @@ -7,6 +7,8 @@ * Merge identical sticky effects * Handle effects overflowing ship tooltip when too numerous * Proper arena scaling (not graphical, only space coordinates) +* Add a fleet evaluator to make balanced fleets +* Fix AI playing in background in GameSession.spec.ts * Mobile: think UI layout so that fingers do not block the view (right and left handed) * Mobile: display tooltips larger and on the side of screen where the finger is not * Mobile: targetting in two times, using a draggable target indicator diff --git a/src/game/Battle.spec.ts b/src/game/Battle.spec.ts index 0b5b011..6632f3a 100644 --- a/src/game/Battle.spec.ts +++ b/src/game/Battle.spec.ts @@ -212,5 +212,40 @@ module TS.SpaceTac.Game { var result = battle.collectShipsInCircle(Target.newFromLocation(5, 8), 3); expect(result).toEqual([ship2, ship3]); }); + + it("adds and remove drones", function () { + let battle = new Battle(); + let ship = new Ship(); + let drone = new Drone(ship); + + expect(battle.drones).toEqual([]); + expect(battle.log.events).toEqual([]); + + battle.addDrone(drone); + + expect(battle.drones).toEqual([drone]); + expect(battle.log.events).toEqual([new DroneDeployedEvent(drone)]); + + battle.addDrone(drone); + + expect(battle.drones).toEqual([drone]); + expect(battle.log.events).toEqual([new DroneDeployedEvent(drone)]); + + battle.removeDrone(drone); + + expect(battle.drones).toEqual([]); + expect(battle.log.events).toEqual([new DroneDeployedEvent(drone), new DroneDestroyedEvent(drone)]); + + battle.removeDrone(drone); + + expect(battle.drones).toEqual([]); + expect(battle.log.events).toEqual([new DroneDeployedEvent(drone), new DroneDestroyedEvent(drone)]); + + // check initial log fill + battle.drones = [drone]; + battle.log.events = []; + battle.injectInitialEvents(); + expect(battle.log.events).toEqual([new DroneDeployedEvent(drone)]); + }); }); } diff --git a/src/game/Battle.ts b/src/game/Battle.ts index 4008d97..3e95e4f 100644 --- a/src/game/Battle.ts +++ b/src/game/Battle.ts @@ -22,6 +22,9 @@ module TS.SpaceTac.Game { playing_ship_index: number; playing_ship: Ship; + // List of deployed drones + drones: Drone[] = []; + // Boolean indicating if its the first turn first_turn: boolean; @@ -239,6 +242,11 @@ module TS.SpaceTac.Game { log.add(new MoveEvent(ship, ship.arena_x, ship.arena_y)); }); + // Simulate drones deployment + this.drones.forEach(drone => { + log.add(new DroneDeployedEvent(drone)); + }); + // Simulate game turn if (this.playing_ship) { log.add(new ShipChangeEvent(this.playing_ship, this.playing_ship)); @@ -261,5 +269,27 @@ module TS.SpaceTac.Game { fleet.ships[i].setArenaFacingAngle(facing_angle); } } + + /** + * Add a drone to the battle + */ + addDrone(drone: Drone, log = true) { + if (add(this.drones, drone)) { + if (log) { + this.log.add(new DroneDeployedEvent(drone)); + } + } + } + + /** + * Remove a drone from the battle + */ + removeDrone(drone: Drone, log = true) { + if (remove(this.drones, drone)) { + if (log) { + this.log.add(new DroneDestroyedEvent(drone)); + } + } + } } } diff --git a/src/game/Drone.spec.ts b/src/game/Drone.spec.ts new file mode 100644 index 0000000..d76af8d --- /dev/null +++ b/src/game/Drone.spec.ts @@ -0,0 +1,123 @@ +/// + +module TS.SpaceTac.Game { + /** + * Fake effect to capture apply requests + */ + class FakeEffect extends BaseEffect { + applied: Ship[] = [] + + constructor() { + super("fake"); + } + + applyOnShip(ship: Ship): boolean { + this.applied.push(ship); + return true; + } + + getApplyCalls() { + let result = acopy(this.applied); + this.applied = []; + return result; + } + } + + function newTestDrone(x: number, y: number, radius: number, owner: Ship): [Drone, FakeEffect] { + let drone = new Drone(owner); + drone.x = x; + drone.y = y; + drone.radius = radius; + let effect = new FakeEffect(); + drone.effects.push(effect); + return [drone, effect]; + } + + describe("Drone", function () { + it("applies effects on deployment", function () { + let ship1 = new Ship(null, "ship1"); + ship1.setArenaPosition(0, 0); + let ship2 = new Ship(null, "ship2"); + ship2.setArenaPosition(5, 5); + let ship3 = new Ship(null, "ship3"); + ship3.setArenaPosition(10, 10); + let [drone, effect] = newTestDrone(2, 2, 8, ship1); + + expect(effect.getApplyCalls()).toEqual([]); + + drone.onDeploy([ship1, ship2, ship3]); + expect(effect.getApplyCalls()).toEqual([ship1, ship2]); + }); + + it("applies effects on ships entering the radius", function () { + let owner = new Ship(null, "owner"); + let target = new Ship(null, "target"); + target.setArenaPosition(10, 10); + let [drone, effect] = newTestDrone(0, 0, 5, owner); + + expect(effect.getApplyCalls()).toEqual([], "initial"); + + drone.onTurnStart(target); + expect(effect.getApplyCalls()).toEqual([], "turn start"); + + target.setArenaPosition(2, 3); + drone.onShipMove(target); + expect(effect.getApplyCalls()).toEqual([target], "enter"); + + target.setArenaPosition(1, 1); + drone.onShipMove(target); + expect(effect.getApplyCalls()).toEqual([], "move inside"); + + target.setArenaPosition(12, 12); + drone.onShipMove(target); + expect(effect.getApplyCalls()).toEqual([], "exit"); + + target.setArenaPosition(1, 1); + drone.onShipMove(target); + expect(effect.getApplyCalls()).toEqual([target], "re-enter"); + }); + + it("applies effects on ships remaining in the radius", function () { + let owner = new Ship(null, "owner"); + let target = new Ship(null, "target"); + let [drone, effect] = newTestDrone(0, 0, 5, owner); + + target.setArenaPosition(1, 2); + drone.onTurnStart(target); + expect(effect.getApplyCalls()).toEqual([], "start inside"); + + target.setArenaPosition(2, 2); + drone.onShipMove(target); + expect(effect.getApplyCalls()).toEqual([], "move inside"); + + drone.onTurnEnd(target); + expect(effect.getApplyCalls()).toEqual([target], "turn end"); + + drone.onTurnStart(target); + expect(effect.getApplyCalls()).toEqual([], "second turn start"); + + target.setArenaPosition(12, 12); + drone.onShipMove(target); + expect(effect.getApplyCalls()).toEqual([], "move out"); + + drone.onTurnEnd(target); + expect(effect.getApplyCalls()).toEqual([], "second turn end"); + }); + + it("signals the need for destruction after its lifetime", function () { + let owner = new Ship(null, "owner"); + let other = new Ship(null, "other"); + let [drone, effect] = newTestDrone(0, 0, 5, owner); + drone.duration = 2; + + let result = drone.onTurnStart(other); + expect(result).toBe(true); + result = drone.onTurnStart(owner); + expect(result).toBe(true); + result = drone.onTurnStart(other); + expect(result).toBe(true); + result = drone.onTurnStart(owner); + expect(result).toBe(false); + }); + }); +} diff --git a/src/game/Drone.ts b/src/game/Drone.ts new file mode 100644 index 0000000..ccff783 --- /dev/null +++ b/src/game/Drone.ts @@ -0,0 +1,106 @@ +/// + +module TS.SpaceTac.Game { + /** + * Drones are static objects that apply effects in a circular zone around themselves. + */ + export class Drone extends Serializable { + // Ship that deployed the drone + owner: Ship; + + // Location in arena + x: number; + y: number; + radius: number; + + // Lifetime in number of turns (not including the initial effect on deployment) + duration: number = 1; + + // Effects to apply + effects: BaseEffect[] = []; + + // Ships registered inside the radius + inside: Ship[] = []; + + // Ships starting their turn the radius + inside_at_start: Ship[] = []; + + constructor(owner: Ship) { + super(); + + this.owner = owner; + } + + /** + * Call a function for each ship in radius. + */ + forEachInRadius(ships: Ship[], callback: (ship: Ship) => any) { + ships.forEach(ship => { + if (ship.isInCircle(this.x, this.y, this.radius)) { + callback(ship); + } + }); + } + + /** + * Apply the effects on a single ship. + * + * This does not check if the ship is in range. + */ + singleApply(ship: Ship) { + this.effects.forEach(effect => effect.applyOnShip(ship)); + } + + /** + * Called when the drone is first deployed. + */ + onDeploy(ships: Ship[]) { + this.forEachInRadius(ships, ship => this.singleApply(ship)); + } + + /** + * Called when a ship turn starts + * + * Returns false if the drone should be destroyed + */ + onTurnStart(ship: Ship): boolean { + if (ship == this.owner) { + if (this.duration <= 1) { + return false; + } else { + this.duration--; + } + } + + if (ship.isInCircle(this.x, this.y, this.radius)) { + add(this.inside, ship); + add(this.inside_at_start, ship); + } else { + remove(this.inside_at_start, ship); + } + return true; + } + + /** + * Called when a ship turn ends + */ + onTurnEnd(ship: Ship) { + if (ship.isInCircle(this.x, this.y, this.radius) && contains(this.inside_at_start, ship)) { + this.singleApply(ship); + } + } + + /** + * Called after a ship moved + */ + onShipMove(ship: Ship) { + if (ship.isInCircle(this.x, this.y, this.radius)) { + if (add(this.inside, ship)) { + this.singleApply(ship); + } + } else { + remove(this.inside, ship); + } + } + } +} \ No newline at end of file diff --git a/src/game/GameSession.spec.ts b/src/game/GameSession.spec.ts index 2978c33..643a5bf 100644 --- a/src/game/GameSession.spec.ts +++ b/src/game/GameSession.spec.ts @@ -10,6 +10,7 @@ module TS.SpaceTac.Game.Specs { it("serializes to a string", () => { var session = new GameSession(); session.startQuickBattle(true); + // TODO AI sometimes starts playing in background... // Dump and reload var dumped = session.saveToString(); diff --git a/src/game/Ship.spec.ts b/src/game/Ship.spec.ts index 139d14a..c595595 100644 --- a/src/game/Ship.spec.ts +++ b/src/game/Ship.spec.ts @@ -254,5 +254,19 @@ module TS.SpaceTac.Game.Specs { ship.recoverActionPoints(); expect(ship.ap_current.current).toBe(8); }); + + it("checks if a ship is inside a given circle", function () { + let ship = new Ship(); + ship.arena_x = 5; + ship.arena_y = 8; + + expect(ship.isInCircle(5, 8, 0)).toBe(true); + expect(ship.isInCircle(5, 8, 1)).toBe(true); + expect(ship.isInCircle(5, 7, 1)).toBe(true); + expect(ship.isInCircle(6, 9, 1.7)).toBe(true); + expect(ship.isInCircle(5, 8.1, 0)).toBe(false); + expect(ship.isInCircle(5, 7, 0.9)).toBe(false); + expect(ship.isInCircle(12, -4, 5)).toBe(false); + }); }); } diff --git a/src/game/Ship.ts b/src/game/Ship.ts index 63aecd4..3138271 100644 --- a/src/game/Ship.ts +++ b/src/game/Ship.ts @@ -277,6 +277,16 @@ module TS.SpaceTac.Game { ended.forEach(effect => this.addBattleEvent(new EffectRemovedEvent(this, effect))); } + /** + * Check if the ship is inside a given circular area + */ + isInCircle(x: number, y: number, radius: number): boolean { + let dx = this.arena_x - x; + let dy = this.arena_y - y; + let distance = Math.sqrt(dx * dx + dy * dy); + return distance <= radius; + } + // Move toward a location // This does not check or consume action points moveTo(x: number, y: number, log: boolean = true): void { diff --git a/src/game/events/DroneDeployedEvent.ts b/src/game/events/DroneDeployedEvent.ts new file mode 100644 index 0000000..3f256be --- /dev/null +++ b/src/game/events/DroneDeployedEvent.ts @@ -0,0 +1,15 @@ +/// + +module TS.SpaceTac.Game { + // Event logged when a drone is deployed by a ship + export class DroneDeployedEvent extends BaseLogEvent { + // Pointer to the drone + drone: Drone; + + constructor(drone: Drone) { + super("droneadd", drone.owner); + + this.drone = drone; + } + } +} diff --git a/src/game/events/DroneDestroyedEvent.ts b/src/game/events/DroneDestroyedEvent.ts new file mode 100644 index 0000000..022561a --- /dev/null +++ b/src/game/events/DroneDestroyedEvent.ts @@ -0,0 +1,15 @@ +/// + +module TS.SpaceTac.Game { + // Event logged when a drone is destroyed + export class DroneDestroyedEvent extends BaseLogEvent { + // Pointer to the drone + drone: Drone; + + constructor(drone: Drone) { + super("dronedel", drone.owner); + + this.drone = drone; + } + } +}