Added ranged attacks and fixed right-clicking unit icons

This commit is contained in:
2026-09-18 20:33:48 +02:00
parent 2377489d4a
commit d8928e1220
9 changed files with 189 additions and 27 deletions
+12 -15
View File
@@ -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
+26
View File
@@ -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}`);
+5 -1
View File
@@ -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;
+7 -3
View File
@@ -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);
}
+17 -8
View File
@@ -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) {
+41
View File
@@ -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");
}
}
+34
View File
@@ -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 {
+16
View File
@@ -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);
+31
View File
@@ -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);