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;
+ }
+ }
+}