diff --git a/TODO b/TODO index b1c5676..636391f 100644 --- a/TODO +++ b/TODO @@ -20,7 +20,7 @@ * Menu: end appear animation when a button is clicked * Menu: allow to delete cloud saves * Arena: display effects description instead of attribute changes -* Arena: display radius for area effects (both on action hover, and while action is activated) +* Arena: display radius for area effects (both on action hover, and while action is active) * Arena: add auto-move to attack * Arena: fix effects originating from real ship location instead of current sprite (when AI fires then moves) * Arena: add engine trail @@ -45,7 +45,6 @@ * 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 * AI: use a first batch of producers, and only if no "good" move has been found, go on with some infinite producers -* AI: apply safety distances to move actions * AI: evaluate buffs/debuffs * AI: abandon fight * AI: add combination of random small move and actual maneuver, as producer diff --git a/src/core/MoveFireSimulator.spec.ts b/src/core/MoveFireSimulator.spec.ts index 97f928c..6e71984 100644 --- a/src/core/MoveFireSimulator.spec.ts +++ b/src/core/MoveFireSimulator.spec.ts @@ -71,6 +71,64 @@ module TS.SpaceTac.Specs { ]); }); + it("scans a circle for move targets", function () { + let simulator = new MoveFireSimulator(new Ship()); + + let result = simulator.scanCircle(50, 30, 10, 1, 1); + expect(imaterialize(result)).toEqual([ + new Target(50, 30) + ]); + + result = simulator.scanCircle(50, 30, 10, 2, 1); + expect(imaterialize(result)).toEqual([ + new Target(50, 30), + new Target(60, 30) + ]); + + result = simulator.scanCircle(50, 30, 10, 2, 2); + expect(imaterialize(result)).toEqual([ + new Target(50, 30), + new Target(60, 30), + new Target(40, 30) + ]); + + result = simulator.scanCircle(50, 30, 10, 3, 4); + expect(imaterialize(result)).toEqual([ + new Target(50, 30), + new Target(55, 30), + new Target(45, 30), + new Target(60, 30), + new Target(50, 40), + new Target(40, 30), + new Target(50, 20) + ]); + }); + + it("accounts for exclusion areas for the approach", function () { + let [ship, simulator, action] = simpleWeaponCase(100, 5, 1, 50); + ship.setArenaPosition(300, 200); + let battle = new Battle(); + battle.fleets[0].addShip(ship); + let ship1 = battle.fleets[0].addShip(); + let moveaction = nn(simulator.findBestEngine()).action; + moveaction.safety_distance = 30; + + expect(simulator.getApproach(moveaction, Target.newFromLocation(350, 200), 100)).toBe(ApproachSimulationError.NO_MOVE_NEEDED); + expect(simulator.getApproach(moveaction, Target.newFromLocation(400, 200), 100)).toBe(ApproachSimulationError.NO_MOVE_NEEDED); + expect(simulator.getApproach(moveaction, Target.newFromLocation(500, 200), 100)).toEqual(new Target(400, 200)); + + ship1.setArenaPosition(420, 200); + + spyOn(simulator, "scanCircle").and.returnValue(iarray([ + new Target(400, 200), + new Target(410, 200), + new Target(410, 230), + new Target(420, 210), + new Target(480, 260), + ])); + expect(simulator.getApproach(moveaction, Target.newFromLocation(500, 200), 100)).toEqual(new Target(410, 230)); + }); + it("moves to get in range, even if not enough AP to fire", function () { let [ship, simulator, action] = simpleWeaponCase(8, 3, 2, 5); let result = simulator.simulateAction(action, new Target(ship.arena_x + 18, ship.arena_y, null)); diff --git a/src/core/MoveFireSimulator.ts b/src/core/MoveFireSimulator.ts index 7fe3439..f226132 100644 --- a/src/core/MoveFireSimulator.ts +++ b/src/core/MoveFireSimulator.ts @@ -1,4 +1,11 @@ module TS.SpaceTac { + /** + * Error codes for approach simulation + */ + export enum ApproachSimulationError { + NO_MOVE_NEEDED, + NO_VECTOR_FOUND, + } /** * A single action in the sequence result from the simulator @@ -57,52 +64,118 @@ module TS.SpaceTac { } /** - * Simulate a given action on a given valid target. + * Check that a move action can reach a given destination */ - simulateAction(action: BaseAction, target: Target, move_margin = 0): MoveFireResult { - let result = new MoveFireResult(); + canMoveTo(action: MoveAction, target: Target): boolean { + return action.checkLocationTarget(this.ship, target) == target; + } + /** + * Get an iterator for scanning a circle + */ + scanCircle(x: number, y: number, radius: number, nr = 6, na = 30): Iterator { + return ichainit(imap(istep(0, irepeat(nr ? 1 / (nr - 1) : 0, nr - 1)), r => { + let angles = Math.max(1, Math.ceil(na * r)); + return imap(istep(0, irepeat(2 * Math.PI / angles, angles - 1)), a => { + return new Target(x + r * radius * Math.cos(a), y + r * radius * Math.sin(a)) + }); + })); + } + + /** + * Find the best approach location, to put a target in a given range. + * + * Return null if no approach vector was found. + */ + getApproach(action: MoveAction, target: Target, radius: number): Target | ApproachSimulationError { let dx = target.x - this.ship.arena_x; let dy = target.y - this.ship.arena_y; let distance = Math.sqrt(dx * dx + dy * dy); - let ap = this.ship.values.power.get(); - let action_radius = action.getRangeRadius(this.ship); + if (distance <= radius) { + return ApproachSimulationError.NO_MOVE_NEEDED; + } else { + let factor = (distance - radius) / distance; + let candidate = new Target(this.ship.arena_x + dx * factor, this.ship.arena_y + dy * factor); + if (this.canMoveTo(action, candidate)) { + return candidate; + } else { + let candidates: [number, Target][] = []; + iforeach(this.scanCircle(target.x, target.y, radius), candidate => { + if (this.canMoveTo(action, candidate)) { + candidates.push([candidate.getDistanceTo(this.ship.location), candidate]); + } + }); - if (action instanceof MoveAction || distance > action_radius) { - let move_distance = action instanceof MoveAction ? distance : (distance - action_radius + move_margin); - if (move_distance > 0.000001) { - result.need_move = true; + if (candidates.length) { + return minBy(candidates, ([distance, candidate]) => distance)[1]; + } else { + return ApproachSimulationError.NO_VECTOR_FOUND; + } + } + } + } - let move_target = new Target(this.ship.arena_x + dx * move_distance / distance, this.ship.arena_y + dy * move_distance / distance, null); - let engine = this.findBestEngine(); - if (engine) { - result.total_move_ap = engine.action.getActionPointsUsage(this.ship, move_target); - result.can_move = ap > 0; - result.can_end_move = result.total_move_ap <= ap; - result.move_location = move_target; - // TODO Split in "this turn" part and "next turn" part if needed - result.parts.push({ action: engine.action, target: move_target, ap: result.total_move_ap, possible: result.can_move }); + /** + * Simulate a given action on a given valid target. + */ + simulateAction(action: BaseAction, target: Target, move_margin = 0): MoveFireResult { + let result = new MoveFireResult(); + let ap = this.ship.getValue("power"); - ap -= result.total_move_ap; - distance -= move_distance; + // Move or approach needed ? + let move_target: Target | null = null; + if (action instanceof MoveAction) { + let corrected_target = action.applyExclusion(this.ship, target); + if (corrected_target) { + result.need_move = target.getDistanceTo(this.ship.location) > 0; + move_target = corrected_target; + } + } else { + let engine = this.findBestEngine(); + if (engine && engine.action instanceof MoveAction) { + let approach_radius = action.getRangeRadius(this.ship); + if (move_margin && approach_radius > move_margin) { + approach_radius -= move_margin; + } + let approach = this.getApproach(engine.action, target, approach_radius); + if (approach instanceof Target) { + result.need_move = true; + move_target = approach; + } else if (approach != ApproachSimulationError.NO_MOVE_NEEDED) { + result.need_move = true; + result.can_move = false; + result.success = false; + return result; } } } - if (distance <= action_radius) { - result.success = true; - if (!(action instanceof MoveAction)) { - result.need_fire = true; - result.total_fire_ap = action.getActionPointsUsage(this.ship, target); - result.can_fire = result.total_fire_ap <= ap; - result.fire_location = target; - result.parts.push({ action: action, target: target, ap: result.total_fire_ap, possible: (!result.need_move || result.can_end_move) && result.can_fire }); + // Check move AP + if (result.need_move && move_target) { + let engine = this.findBestEngine(); + if (engine) { + result.total_move_ap = engine.action.getActionPointsUsage(this.ship, move_target); + result.can_move = ap > 0; + result.can_end_move = result.total_move_ap <= ap; + result.move_location = move_target; + // TODO Split in "this turn" part and "next turn" part if needed + result.parts.push({ action: engine.action, target: move_target, ap: result.total_move_ap, possible: result.can_move }); + + ap -= result.total_move_ap; } - } else { - result.success = false; } + // Check action AP + if (!(action instanceof MoveAction)) { + result.need_fire = true; + result.total_fire_ap = action.getActionPointsUsage(this.ship, target); + result.can_fire = result.total_fire_ap <= ap; + result.fire_location = target; + result.parts.push({ action: action, target: target, ap: result.total_fire_ap, possible: (!result.need_move || result.can_end_move) && result.can_fire }); + } + result.success = true; + return result; } } diff --git a/src/core/actions/MoveAction.ts b/src/core/actions/MoveAction.ts index cd96a51..3c8fb0a 100644 --- a/src/core/actions/MoveAction.ts +++ b/src/core/actions/MoveAction.ts @@ -54,11 +54,10 @@ module TS.SpaceTac { return this.distance_per_power; } - checkLocationTarget(ship: Ship, target: Target): Target { - // Apply maximal distance - var max_distance = this.getRangeRadius(ship); - target = target.constraintInRange(ship.arena_x, ship.arena_y, max_distance); - + /** + * Apply exclusion areas (neer arena borders, or other ships) + */ + applyExclusion(ship: Ship, target: Target): Target { let battle = ship.getBattle(); if (battle) { // Keep out of arena borders @@ -74,6 +73,16 @@ module TS.SpaceTac { target = target.moveOutOfCircle(s.arena_x, s.arena_y, this.safety_distance, ship.arena_x, ship.arena_y); }); } + return target; + } + + checkLocationTarget(ship: Ship, target: Target): Target { + // Apply maximal distance + var max_distance = this.getRangeRadius(ship); + target = target.constraintInRange(ship.arena_x, ship.arena_y, max_distance); + + // Apply exclusion areas + target = this.applyExclusion(ship, target); return target; } diff --git a/src/core/ai/TacticalAIHelpers.spec.ts b/src/core/ai/TacticalAIHelpers.spec.ts index 8e0abd7..39e626d 100644 --- a/src/core/ai/TacticalAIHelpers.spec.ts +++ b/src/core/ai/TacticalAIHelpers.spec.ts @@ -88,19 +88,27 @@ module TS.SpaceTac.Specs { let weapon = TestTools.addWeapon(ship, 50, 5, 100); let engine = TestTools.addEngine(ship, 25); - let maneuver = new Maneuver(ship, weapon.action, Target.newFromLocation(100, 0)); + let maneuver = new Maneuver(ship, new BaseAction("fake", "Nothing", false), new Target(0, 0), 0); + expect(TacticalAIHelpers.evaluateTurnCost(ship, battle, maneuver)).toBe(-1); + + maneuver = new Maneuver(ship, weapon.action, Target.newFromLocation(100, 0), 0); + expect(TacticalAIHelpers.evaluateTurnCost(ship, battle, maneuver)).toBe(-Infinity); + + TestTools.setShipAP(ship, 4); + maneuver = new Maneuver(ship, weapon.action, Target.newFromLocation(100, 0), 0); expect(TacticalAIHelpers.evaluateTurnCost(ship, battle, maneuver)).toBe(-Infinity); TestTools.setShipAP(ship, 10); + maneuver = new Maneuver(ship, weapon.action, Target.newFromLocation(100, 0), 0); expect(TacticalAIHelpers.evaluateTurnCost(ship, battle, maneuver)).toBe(0.5); // 5 power remaining on 10 - maneuver = new Maneuver(ship, weapon.action, Target.newFromLocation(110, 0)); + maneuver = new Maneuver(ship, weapon.action, Target.newFromLocation(110, 0), 0); expect(TacticalAIHelpers.evaluateTurnCost(ship, battle, maneuver)).toBe(0.4); // 4 power remaining on 10 - maneuver = new Maneuver(ship, weapon.action, Target.newFromLocation(140, 0)); + maneuver = new Maneuver(ship, weapon.action, Target.newFromLocation(140, 0), 0); expect(TacticalAIHelpers.evaluateTurnCost(ship, battle, maneuver)).toBe(0.3); // 3 power remaining on 10 - maneuver = new Maneuver(ship, weapon.action, Target.newFromLocation(310, 0)); + maneuver = new Maneuver(ship, weapon.action, Target.newFromLocation(310, 0), 0); expect(TacticalAIHelpers.evaluateTurnCost(ship, battle, maneuver)).toBe(-1); // can't do in one turn }); @@ -159,7 +167,8 @@ module TS.SpaceTac.Specs { let weapon = TestTools.addWeapon(ship, 100, 1, 100, 10); let maneuver = new Maneuver(ship, weapon.action, Target.newFromLocation(200, 0), 0.5); - expect(maneuver.simulation.move_location).toEqual(Target.newFromLocation(100.5, 0)); + expect(maneuver.simulation.move_location.x).toBeCloseTo(100.5, 1); + expect(maneuver.simulation.move_location.y).toBe(0); expect(TacticalAIHelpers.evaluateClustering(ship, battle, maneuver)).toEqual(0); battle.fleets[1].addShip().setArenaPosition(battle.width, battle.height); diff --git a/src/core/ai/TacticalAIHelpers.ts b/src/core/ai/TacticalAIHelpers.ts index 7308e2d..08eb6e1 100644 --- a/src/core/ai/TacticalAIHelpers.ts +++ b/src/core/ai/TacticalAIHelpers.ts @@ -131,7 +131,9 @@ module TS.SpaceTac { */ static evaluateTurnCost(ship: Ship, battle: Battle, maneuver: Maneuver): number { let powerusage = maneuver.simulation.total_move_ap + maneuver.simulation.total_fire_ap; - if (maneuver.simulation.total_fire_ap > ship.getAttribute("power_capacity")) { + if (powerusage == 0) { + return -1; + } else if (maneuver.simulation.total_fire_ap > ship.getAttribute("power_capacity")) { return -Infinity; } else if (powerusage > ship.getValue("power")) { return -1;