Added ranged attacks and fixed right-clicking unit icons
This commit is contained in:
+12
-15
@@ -204,22 +204,22 @@ export class GameScreen {
|
||||
// foreign territory at peace. `type` is "move", "attack" or "schedule".
|
||||
async _orderMove(units, coords, type = "move") {
|
||||
const { targets, speed, routes } = await this._groupMovePlan(units, coords);
|
||||
// Aircraft are refused an order outside their mission radius before it is
|
||||
// ever sent, so the browser and the server never disagree on a route.
|
||||
// A ranged attack is refused before it is ever sent when the target lies
|
||||
// beyond the unit's reach, so the browser and the server never disagree.
|
||||
const striking = type === "attack" && this._rangedAttackOrder(units, coords);
|
||||
const outOfRange = units.some((unit) => {
|
||||
const proto = this.protoUnits[unit.proto];
|
||||
if (!proto || !proto.air) return false;
|
||||
if (!proto) return false;
|
||||
const route = routes.get(unit.id);
|
||||
if (!route) return false;
|
||||
// A bombing run is limited by the strike range; a ferry flight by the
|
||||
const distance = route.length - 1;
|
||||
// A ranged attack is limited by the weapon range; a ferry flight by the
|
||||
// aircraft's mission radius.
|
||||
const limit = type === "attack" && proto.strike
|
||||
? (proto.range || 1)
|
||||
: (proto.missionRange || Infinity);
|
||||
return route.length - 1 > limit;
|
||||
if (striking) return distance > (proto.range || 1);
|
||||
return proto.air && distance > (proto.missionRange || Infinity);
|
||||
});
|
||||
if (outOfRange) {
|
||||
this.confirmModal.show("Out of range", "That target lies beyond this aircraft's reach.", null);
|
||||
this.confirmModal.show("Out of range", "That target lies beyond this unit's reach.", null);
|
||||
return;
|
||||
}
|
||||
if (targets.length > 0) {
|
||||
@@ -245,13 +245,10 @@ export class GameScreen {
|
||||
order.append = true;
|
||||
}
|
||||
this.sendOrder(order);
|
||||
// A bombing run is resolved instantly on the server without moving, so
|
||||
// A ranged attack is resolved instantly on the server without moving, so
|
||||
// predicting a march there would only make the icon drift.
|
||||
const strike = type === "attack" && units.every((unit) => {
|
||||
const proto = this.protoUnits[unit.proto];
|
||||
return proto && proto.strike;
|
||||
});
|
||||
if (strike) return Promise.resolve();
|
||||
const striking = type === "attack" && this._rangedAttackOrder(units, coords);
|
||||
if (striking) return Promise.resolve();
|
||||
const pending = [];
|
||||
for (const unit of units) {
|
||||
// An append joins the queue behind the leg already under way, so only an
|
||||
|
||||
@@ -224,6 +224,32 @@ export const panelMethods = {
|
||||
return !!(proto && proto.military);
|
||||
},
|
||||
|
||||
// A unit that attacks from range instead of charging into melee: every
|
||||
// aircraft and anything with a reach beyond a single tile. Mirrors
|
||||
// GameState._isStrikeUnit so the browser predicts the same outcome.
|
||||
_isRangedUnit(unit) {
|
||||
const proto = this.protoUnits[unit.proto];
|
||||
return !!(proto && (proto.strike || proto.air || (proto.range || 1) > 1));
|
||||
},
|
||||
|
||||
// Whether an attack on `coords` is resolved instantly from range rather than
|
||||
// by marching. Mirrors the server: a group of ranged units bombs a hostile
|
||||
// military unit, while aircraft always attack from the air and never march
|
||||
// onto a hostile tile.
|
||||
_rangedAttackOrder(units, coords) {
|
||||
if (units.length === 0) return false;
|
||||
const allAir = units.every((unit) => {
|
||||
const proto = this.protoUnits[unit.proto];
|
||||
return !!(proto && proto.air);
|
||||
});
|
||||
if (allAir) return true;
|
||||
if (!units.every((unit) => this._isRangedUnit(unit))) return false;
|
||||
return this.map.unitIdsAt(coords).some((id) => {
|
||||
const data = this._unit(id);
|
||||
return data && data.civ !== this.localCiv && this._isMilitaryUnit(data);
|
||||
});
|
||||
},
|
||||
|
||||
_isAtWar(a, b) {
|
||||
if (a < 0 || b < 0 || a === b) return false;
|
||||
return this._wars.has(a < b ? `${a}:${b}` : `${b}:${a}`);
|
||||
|
||||
@@ -391,8 +391,12 @@ export const entityMethods = {
|
||||
view.appendChild(count);
|
||||
view.appendChild(statuses);
|
||||
view.addEventListener("mousedown", (event) => {
|
||||
// A left click on the icon picks the unit and stops there so the map does
|
||||
// not re-handle it. Right-clicks fall through to the viewport so the
|
||||
// selected stack can be ordered onto this tile (move, stack or attack).
|
||||
if (event.button !== 0) return;
|
||||
event.stopPropagation();
|
||||
if (event.button === 0) this.onUnitSelected(data.id);
|
||||
this.onUnitSelected(data.id);
|
||||
});
|
||||
this.$entities[0].appendChild(view);
|
||||
return view;
|
||||
|
||||
@@ -129,9 +129,13 @@ export class GameServer extends EventEmitter {
|
||||
const units = this._ownUnitIds(civ, order);
|
||||
if (units.length === 0) return false;
|
||||
const coords = coordsOf(order.coords);
|
||||
// A group of bombers bombs from afar; anything else charges in.
|
||||
const allStrike = units.every((id) => this.state._isStrikeUnit(this.state.findUnit(id)));
|
||||
if (allStrike) return this.state.requestStrike(units, coords);
|
||||
// A group that hits from range (artillery, warships, aircraft) bombs a
|
||||
// hostile unit without closing in; anything else -- or an attack on a tile
|
||||
// with no unit on it, an undefended city say -- is a charge.
|
||||
const allRanged = units.every((id) => this.state._isStrikeUnit(this.state.findUnit(id)));
|
||||
if (allRanged && this.state._strikeTargetAt(coords, civ)) {
|
||||
return this.state.requestStrike(units, coords);
|
||||
}
|
||||
return this.state.requestAttack(units, coords);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,9 +40,12 @@ export const airMethods = {
|
||||
return this._isAirProto(this.unitProto(unit));
|
||||
},
|
||||
|
||||
// A unit that attacks from range instead of charging into melee: every
|
||||
// aircraft and anything with a reach beyond a single tile. A melee unit has a
|
||||
// range of one and stays on the ground.
|
||||
_isStrikeUnit(unit) {
|
||||
const proto = this.unitProto(unit);
|
||||
return !!(proto && proto.strike);
|
||||
return !!(proto && (proto.strike || proto.air || (proto.range || 1) > 1));
|
||||
},
|
||||
|
||||
// The friendly airport city on `coords`, or null. An aircraft parked here
|
||||
@@ -153,8 +156,8 @@ export const airMethods = {
|
||||
return enemies[0];
|
||||
},
|
||||
|
||||
// Whether every aircraft in the group may bomb `goal`: all strike-capable,
|
||||
// within strike range, inside their mission radius and still carrying fuel.
|
||||
// Whether every ranged unit in the group may bomb `goal`: all strike-capable,
|
||||
// within weapon range and, for aircraft, inside the mission radius with fuel.
|
||||
canStrike(unitIds, goal) {
|
||||
const units = this._unitsForOrder(unitIds).filter((unit) => this._isStrikeUnit(unit));
|
||||
if (units.length === 0) return false;
|
||||
@@ -168,15 +171,19 @@ export const airMethods = {
|
||||
if (!proto) return false;
|
||||
const distance = this.topology.tileDistance(unit.coords, goal);
|
||||
if (distance > (proto.range || 1)) return false;
|
||||
// Aircraft must also stay inside their mission radius and keep fuel in the
|
||||
// tanks; a ground battery has no such endurance.
|
||||
if (!this._isAirProto(proto)) return true;
|
||||
if (distance > (proto.missionRange || distance)) return false;
|
||||
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
|
||||
return fuel > 0;
|
||||
},
|
||||
|
||||
// A bombing run: every strike aircraft damages the weakest hostile military
|
||||
// unit on `goal` without moving and without joining the melee. The sortie
|
||||
// burns `strikeHours` of endurance, so bombers must still return to an
|
||||
// airport. Declares war on the target if the two were at peace.
|
||||
// A ranged attack: every unit bombs/attacks the weakest hostile military unit
|
||||
// on `goal` from a distance, without moving and without joining the melee.
|
||||
// An aircraft sortie burns `strikeHours` of endurance, so planes must still
|
||||
// return to an airport; a ground battery has no endurance to spend. Declares
|
||||
// war on the target if the two were at peace.
|
||||
requestStrike(unitIds, goal) {
|
||||
const units = this._unitsForOrder(unitIds).filter((unit) => this._isStrikeUnit(unit));
|
||||
if (units.length === 0) return false;
|
||||
@@ -196,7 +203,9 @@ export const airMethods = {
|
||||
destroyed.push(target);
|
||||
}
|
||||
}
|
||||
unit.airHours = Math.max(0, (unit.airHours === undefined ? proto.enduranceHours : unit.airHours) - (proto.strikeHours || 1));
|
||||
if (this._isAirProto(proto)) {
|
||||
unit.airHours = Math.max(0, (unit.airHours === undefined ? proto.enduranceHours : unit.airHours) - (proto.strikeHours || 1));
|
||||
}
|
||||
unit.autoReturn = false;
|
||||
}
|
||||
if (destroyed.length > 0) {
|
||||
|
||||
@@ -228,4 +228,45 @@ export class AirTest extends TestCase {
|
||||
this.assertEqual(attacker.coords.y, pair[0].y);
|
||||
this.assertEmpty(state._battleTiles(), "no melee battle was started");
|
||||
}
|
||||
|
||||
test_ground_ranged_units_strike_from_afar_like_aircraft() {
|
||||
const state = smallState();
|
||||
const artillery = protoUnit(state, "artillery");
|
||||
const infantry = protoUnit(state, "modern_infantry");
|
||||
const pair = freeAdjacentLand(state);
|
||||
const attacker = state._spawnUnit(pair[0], 0, artillery);
|
||||
const defender = state._spawnUnit(pair[1], 1, infantry);
|
||||
this.assertTrue(state._isStrikeUnit(attacker), "artillery counts as a ranged attacker");
|
||||
this.assertTrue(state.requestStrike([attacker.id], defender.coords), "the battery opens fire");
|
||||
this.assertLess(defender.hp, defender.maxHp, "the shell damaged the target");
|
||||
this.assertEqual(attacker.coords.x, pair[0].x, "the artillery never moved");
|
||||
this.assertEqual(attacker.coords.y, pair[0].y);
|
||||
this.assertEmpty(state._battleTiles(), "no melee battle was started");
|
||||
}
|
||||
|
||||
test_ranged_attack_beyond_reach_is_refused() {
|
||||
const state = smallState();
|
||||
const artillery = protoUnit(state, "artillery");
|
||||
const infantry = protoUnit(state, "modern_infantry");
|
||||
const from = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c));
|
||||
const attacker = state._spawnUnit(from, 0, artillery);
|
||||
const goal = farTile(state, from, artillery.range);
|
||||
this.assertNotNull(goal, "the map has a tile beyond the battery's range");
|
||||
const defender = state._spawnUnit(goal, 1, infantry);
|
||||
this.assertFalse(state.requestStrike([attacker.id], goal), "a target out of reach is refused");
|
||||
this.assertEqual(defender.hp, defender.maxHp, "the target is untouched");
|
||||
}
|
||||
|
||||
test_only_air_or_long_reach_units_are_ranged() {
|
||||
const state = smallState();
|
||||
const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c));
|
||||
const infantry = state._spawnUnit(spot, 0, protoUnit(state, "modern_infantry"), null);
|
||||
const worker = state._spawnUnit(spot, 0, protoUnit(state, "worker"), null);
|
||||
const artillery = state._spawnUnit(spot, 0, protoUnit(state, "artillery"), null);
|
||||
const jet = state._spawnUnit(spot, 0, protoUnit(state, "jet_fighter"), null);
|
||||
this.assertFalse(state._isStrikeUnit(infantry), "infantry is melee");
|
||||
this.assertFalse(state._isStrikeUnit(worker), "a worker is not a ranged attacker");
|
||||
this.assertTrue(state._isStrikeUnit(artillery), "artillery outranges one tile");
|
||||
this.assertTrue(state._isStrikeUnit(jet), "an aircraft attacks from the air");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +198,40 @@ export class GameScreenTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_ranged_attack_is_sent_without_predicting_a_march() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const home = state.units.find((u) => u.civ === 0);
|
||||
const artillery = state._spawnUnit(
|
||||
home.coords,
|
||||
0,
|
||||
state.protoUnits.find((u) => u.id === "artillery")
|
||||
);
|
||||
const goal = adjacentLand(state, artillery);
|
||||
const defender = state._spawnUnit(goal, 1, state.protoUnits[0]);
|
||||
screen.onState(state.snapshot(0));
|
||||
const unit = screen._unit(artillery.id);
|
||||
this.assertNotNull(screen._unit(defender.id), "the enemy stack is visible");
|
||||
await screen._sendMove([unit], goal, null, "attack");
|
||||
this.assertEqual(network.orders[0], {
|
||||
type: "attack",
|
||||
unit: unit.id,
|
||||
coords: [goal.x, goal.y],
|
||||
});
|
||||
this.assertFalse(
|
||||
screen.map._unitMotion.get(unit.id).predicting,
|
||||
"the icon does not march toward a target hit from range"
|
||||
);
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_training_modal_lists_the_queue_and_cancels() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
|
||||
@@ -196,6 +196,22 @@ export class GameServerOrdersTest extends TestCase {
|
||||
);
|
||||
}
|
||||
|
||||
test_ranged_attack_order_bombs_without_closing_in() {
|
||||
const { server } = makeServer();
|
||||
server.peerCiv.set(1, 0);
|
||||
const state = server.state;
|
||||
const pair = emptyLandPair(state);
|
||||
const artillery = state.protoUnits.find((u) => u.id === "artillery");
|
||||
const attacker = state._spawnUnit(pair.a, 0, artillery);
|
||||
const defender = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
|
||||
this.assertTrue(
|
||||
server.handleOrder(1, { type: "attack", unit: attacker.id, coords: [pair.b.x, pair.b.y] }),
|
||||
"the ranged attack is accepted"
|
||||
);
|
||||
this.assertEqual(attacker.coords, pair.a, "the artillery held its ground");
|
||||
this.assertLess(defender.hp, defender.maxHp, "the enemy stack was bombed");
|
||||
}
|
||||
|
||||
test_attack_order_without_a_target_is_rejected() {
|
||||
const { server } = makeServer();
|
||||
server.peerCiv.set(1, 0);
|
||||
|
||||
@@ -416,6 +416,37 @@ export class MapViewTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_right_click_on_a_unit_icon_orders_the_selected_stack() {
|
||||
const state = defaultState();
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
const goal = adjacentLand(state, unit);
|
||||
const target = state._spawnUnit(goal, 0, state.protoUnits[0]);
|
||||
const { env, map } = await this.build(state);
|
||||
try {
|
||||
let moved = null;
|
||||
map.onMoveOrdered = (ids, coords) => {
|
||||
moved = { ids, coords };
|
||||
};
|
||||
map.setSelectedUnit(unit.id);
|
||||
const local = mapToLocal(goal.x, goal.y);
|
||||
const clientX = 400 + (local.x - map.camera.x) * map.camera.zoom;
|
||||
const clientY = 300 + (local.y - map.camera.y) * map.camera.zoom;
|
||||
const view = map._unitViews.get(target.id);
|
||||
this.assertNotNull(view, "the target unit has a view");
|
||||
// Real bubbling events, so the icon's own native handler is exercised:
|
||||
// it must let right-clicks reach the viewport's mousedown listener.
|
||||
const press = new env.window.MouseEvent("mousedown", { button: 2, bubbles: true, clientX, clientY });
|
||||
view.dispatchEvent(press);
|
||||
const release = new env.window.MouseEvent("mouseup", { button: 2, bubbles: true, clientX, clientY });
|
||||
env.window.dispatchEvent(release);
|
||||
this.assertNotNull(moved, "right-clicking an icon gives the order");
|
||||
this.assertEqual(moved.ids, [unit.id]);
|
||||
this.assertEqual(moved.coords, goal);
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_left_click_selects_city_or_clears() {
|
||||
const state = defaultState();
|
||||
const { env, map, snapshot } = await this.build(state);
|
||||
|
||||
Reference in New Issue
Block a user