Files
OpenFrontIO/src/client/graphics/layers/UnitLayer.ts
T
DevelopingTom eeeb7e4b4e SAM smart targeting (#1618)
## Description:

Current SAM behavior is to shoot a missile as soon as a nuke is in
range.
Players can exploit it by overshooting behind the SAM, so the SAM
missile will take way longer to reach the nuke, usually too late to
prevent its explosion.

This PR introduces a "smart" targeting system that allows SAM to
calculate an optimal interception tile along the nuke's trajectory. They
can also preshot before the nuke becomes vulnerable, as long as the
interception tile will be within the vulnerable window.

This change makes SAM range enforcement much more strict.

Changes:
- Nukes now precompute their full trajectory on creation and update
their current position index every tick.
- SAMs use this trajectory data and their own missile speed to calculate
the ideal interception tile.
- SAM missiles now aim directly at that interception point rather than
chasing the nuke.

Small changes on the fly:
- `BezierCurve` now uses a provided increment so the curve LUT is the
optimal size
- Increased nuke opacity when untargetable: 0.4 → 0.5
- Slightly extended nuke vulnerability range to SAMs: 120 → 150


===

Preshot an incoming nuke still in the unfocusable state. Notice how the
nuke is destroyed as soon as becomes focusable:



https://github.com/user-attachments/assets/9fbf1ae4-33b4-4fa0-9b53-cb53f3adc17b


Shooting right at the range limit:


https://github.com/user-attachments/assets/d68793ac-b249-45fe-88bf-e20f70758449


Shooting behind the SAM:


https://github.com/user-attachments/assets/800cd7ff-d9d9-40f3-aba8-fa3ab526b3b2




## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I have read and accepted the CLA agreement (only required once).

## Please put your Discord username so you can be contacted if a bug or
regression is found:

IngloriousTom
2025-08-02 22:03:29 +00:00

563 lines
15 KiB
TypeScript

import { colord, Colord } from "colord";
import { EventBus } from "../../../core/EventBus";
import { Theme } from "../../../core/configuration/Config";
import { UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, UnitView } from "../../../core/game/GameView";
import { BezenhamLine } from "../../../core/utilities/Line";
import {
AlternateViewEvent,
MouseUpEvent,
UnitSelectionEvent,
} from "../../InputHandler";
import { MoveWarshipIntentEvent } from "../../Transport";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import {
getColoredSprite,
isSpriteReady,
loadAllSprites,
} from "../SpriteLoader";
enum Relationship {
Self,
Ally,
Enemy,
}
export class UnitLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private transportShipTrailCanvas: HTMLCanvasElement;
private unitTrailContext: CanvasRenderingContext2D;
private unitToTrail = new Map<UnitView, TileRef[]>();
private theme: Theme;
private alternateView = false;
private oldShellTile = new Map<UnitView, TileRef>();
private transformHandler: TransformHandler;
// Selected unit property as suggested in the review comment
private selectedUnit: UnitView | null = null;
// Configuration for unit selection
private readonly WARSHIP_SELECTION_RADIUS = 10; // Radius in game cells for warship selection hit zone
constructor(
private game: GameView,
private eventBus: EventBus,
transformHandler: TransformHandler,
) {
this.theme = game.config().theme();
this.transformHandler = transformHandler;
}
shouldTransform(): boolean {
return true;
}
tick() {
const unitIds = this.game
.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.map((unit) => unit.id);
this.updateUnitsSprites(unitIds ?? []);
}
init() {
this.eventBus.on(AlternateViewEvent, (e) => this.onAlternativeViewEvent(e));
this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e));
this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelectionChange(e));
this.redraw();
loadAllSprites();
}
/**
* Find player-owned warships near the given cell within a configurable radius
* @param cell The cell to check
* @returns Array of player's warships in range, sorted by distance (closest first)
*/
private findWarshipsNearCell(cell: { x: number; y: number }): UnitView[] {
if (!this.game.isValidCoord(cell.x, cell.y)) {
// The cell coordinate were invalid (user probably clicked outside the map), therefore no warships can be found
return [];
}
const clickRef = this.game.ref(cell.x, cell.y);
// Only select warships owned by the player
return this.game
.units(UnitType.Warship)
.filter(
(unit) =>
unit.isActive() &&
unit.owner() === this.game.myPlayer() && // Only allow selecting own warships
this.game.manhattanDist(unit.tile(), clickRef) <=
this.WARSHIP_SELECTION_RADIUS,
)
.sort((a, b) => {
// Sort by distance (closest first)
const distA = this.game.manhattanDist(a.tile(), clickRef);
const distB = this.game.manhattanDist(b.tile(), clickRef);
return distA - distB;
});
}
private onMouseUp(event: MouseUpEvent) {
// Convert screen coordinates to world coordinates
const cell = this.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
);
// Find warships near this cell, sorted by distance
const nearbyWarships = this.findWarshipsNearCell(cell);
if (this.selectedUnit) {
const clickRef = this.game.ref(cell.x, cell.y);
if (this.game.isOcean(clickRef)) {
this.eventBus.emit(
new MoveWarshipIntentEvent(this.selectedUnit.id(), clickRef),
);
}
// Deselect
this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
} else if (nearbyWarships.length > 0) {
// Toggle selection of the closest warship
const clickedUnit = nearbyWarships[0];
this.eventBus.emit(new UnitSelectionEvent(clickedUnit, true));
}
}
/**
* Handle unit selection changes
*/
private onUnitSelectionChange(event: UnitSelectionEvent) {
if (event.isSelected) {
this.selectedUnit = event.unit;
} else if (this.selectedUnit === event.unit) {
this.selectedUnit = null;
}
}
/**
* Handle unit deactivation or destruction
* If the selected unit is removed from the game, deselect it
*/
private handleUnitDeactivation(unit: UnitView) {
if (this.selectedUnit === unit && !unit.isActive()) {
this.eventBus.emit(new UnitSelectionEvent(unit, false));
}
}
renderLayer(context: CanvasRenderingContext2D) {
context.drawImage(
this.transportShipTrailCanvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
}
onAlternativeViewEvent(event: AlternateViewEvent) {
this.alternateView = event.alternateView;
this.redraw();
}
redraw() {
this.canvas = document.createElement("canvas");
const context = this.canvas.getContext("2d");
if (context === null) throw new Error("2d context not supported");
this.context = context;
this.transportShipTrailCanvas = document.createElement("canvas");
const trailContext = this.transportShipTrailCanvas.getContext("2d");
if (trailContext === null) throw new Error("2d context not supported");
this.unitTrailContext = trailContext;
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.transportShipTrailCanvas.width = this.game.width();
this.transportShipTrailCanvas.height = this.game.height();
this.updateUnitsSprites(this.game.units().map((unit) => unit.id()));
this.unitToTrail.forEach((trail, unit) => {
for (const t of trail) {
this.paintCell(
this.game.x(t),
this.game.y(t),
this.relationship(unit),
this.theme.territoryColor(unit.owner()),
150,
this.unitTrailContext,
);
}
});
}
private updateUnitsSprites(unitIds: number[]) {
const unitsToUpdate = unitIds
?.map((id) => this.game.unit(id))
.filter((unit) => unit !== undefined);
if (unitsToUpdate) {
// the clearing and drawing of unit sprites need to be done in 2 passes
// otherwise the sprite of a unit can be drawn on top of another unit
this.clearUnitsCells(unitsToUpdate);
this.drawUnitsCells(unitsToUpdate);
}
}
private clearUnitsCells(unitViews: UnitView[]) {
unitViews
.filter((unitView) => isSpriteReady(unitView))
.forEach((unitView) => {
const sprite = getColoredSprite(unitView, this.theme);
const clearsize = sprite.width + 1;
const lastX = this.game.x(unitView.lastTile());
const lastY = this.game.y(unitView.lastTile());
this.context.clearRect(
lastX - clearsize / 2,
lastY - clearsize / 2,
clearsize,
clearsize,
);
});
}
private drawUnitsCells(unitViews: UnitView[]) {
unitViews.forEach((unitView) => this.onUnitEvent(unitView));
}
private relationship(unit: UnitView): Relationship {
const myPlayer = this.game.myPlayer();
if (myPlayer === null) {
return Relationship.Enemy;
}
if (myPlayer === unit.owner()) {
return Relationship.Self;
}
if (myPlayer.isFriendly(unit.owner())) {
return Relationship.Ally;
}
return Relationship.Enemy;
}
onUnitEvent(unit: UnitView) {
// Check if unit was deactivated
if (!unit.isActive()) {
this.handleUnitDeactivation(unit);
}
switch (unit.type()) {
case UnitType.TransportShip:
this.handleBoatEvent(unit);
break;
case UnitType.Warship:
this.handleWarShipEvent(unit);
break;
case UnitType.Shell:
this.handleShellEvent(unit);
break;
case UnitType.SAMMissile:
this.handleMissileEvent(unit);
break;
case UnitType.TradeShip:
this.handleTradeShipEvent(unit);
break;
case UnitType.Train:
this.handleTrainEvent(unit);
break;
case UnitType.MIRVWarhead:
this.handleMIRVWarhead(unit);
break;
case UnitType.AtomBomb:
case UnitType.HydrogenBomb:
case UnitType.MIRV:
this.handleNuke(unit);
break;
}
}
private handleWarShipEvent(unit: UnitView) {
if (unit.targetUnitId()) {
this.drawSprite(unit, colord({ r: 200, b: 0, g: 0 }));
} else {
this.drawSprite(unit);
}
}
private handleShellEvent(unit: UnitView) {
const rel = this.relationship(unit);
// Clear current and previous positions
this.clearCell(this.game.x(unit.lastTile()), this.game.y(unit.lastTile()));
const oldTile = this.oldShellTile.get(unit);
if (oldTile !== undefined) {
this.clearCell(this.game.x(oldTile), this.game.y(oldTile));
}
this.oldShellTile.set(unit, unit.lastTile());
if (!unit.isActive()) {
return;
}
// Paint current and previous positions
this.paintCell(
this.game.x(unit.tile()),
this.game.y(unit.tile()),
rel,
this.theme.borderColor(unit.owner()),
255,
);
this.paintCell(
this.game.x(unit.lastTile()),
this.game.y(unit.lastTile()),
rel,
this.theme.borderColor(unit.owner()),
255,
);
}
// interception missle from SAM
private handleMissileEvent(unit: UnitView) {
this.drawSprite(unit);
}
private drawTrail(trail: number[], color: Colord, rel: Relationship) {
// Paint new trail
for (const t of trail) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
color,
150,
this.unitTrailContext,
);
}
}
private clearTrail(unit: UnitView) {
const trail = this.unitToTrail.get(unit) ?? [];
const rel = this.relationship(unit);
for (const t of trail) {
this.clearCell(this.game.x(t), this.game.y(t), this.unitTrailContext);
}
this.unitToTrail.delete(unit);
// Repaint overlapping trails
const trailSet = new Set(trail);
for (const [other, trail] of this.unitToTrail) {
for (const t of trail) {
if (trailSet.has(t)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(other.owner()),
150,
this.unitTrailContext,
);
}
}
}
}
private handleNuke(unit: UnitView) {
const rel = this.relationship(unit);
if (!this.unitToTrail.has(unit)) {
this.unitToTrail.set(unit, []);
}
let newTrailSize = 1;
const trail = this.unitToTrail.get(unit) ?? [];
// It can move faster than 1 pixel, draw a line for the trail or else it will be dotted
if (trail.length >= 1) {
const cur = {
x: this.game.x(unit.lastTile()),
y: this.game.y(unit.lastTile()),
};
const prev = {
x: this.game.x(trail[trail.length - 1]),
y: this.game.y(trail[trail.length - 1]),
};
const line = new BezenhamLine(prev, cur);
let point = line.increment();
while (point !== true) {
trail.push(this.game.ref(point.x, point.y));
point = line.increment();
}
newTrailSize = line.size();
} else {
trail.push(unit.lastTile());
}
this.drawTrail(
trail.slice(-newTrailSize),
this.theme.territoryColor(unit.owner()),
rel,
);
this.drawSprite(unit);
if (!unit.isActive()) {
this.clearTrail(unit);
}
}
private handleMIRVWarhead(unit: UnitView) {
const rel = this.relationship(unit);
this.clearCell(this.game.x(unit.lastTile()), this.game.y(unit.lastTile()));
if (unit.isActive()) {
// Paint area
this.paintCell(
this.game.x(unit.tile()),
this.game.y(unit.tile()),
rel,
this.theme.borderColor(unit.owner()),
255,
);
}
}
private handleTradeShipEvent(unit: UnitView) {
this.drawSprite(unit);
}
private handleTrainEvent(unit: UnitView) {
this.drawSprite(unit);
}
private handleBoatEvent(unit: UnitView) {
const rel = this.relationship(unit);
if (!this.unitToTrail.has(unit)) {
this.unitToTrail.set(unit, []);
}
const trail = this.unitToTrail.get(unit) ?? [];
trail.push(unit.lastTile());
// Paint trail
this.drawTrail(
trail.slice(-1),
this.theme.territoryColor(unit.owner()),
rel,
);
this.drawSprite(unit);
if (!unit.isActive()) {
this.clearTrail(unit);
}
}
paintCell(
x: number,
y: number,
relationship: Relationship,
color: Colord,
alpha: number,
context: CanvasRenderingContext2D = this.context,
) {
this.clearCell(x, y, context);
if (this.alternateView) {
switch (relationship) {
case Relationship.Self:
context.fillStyle = this.theme.selfColor().toRgbString();
break;
case Relationship.Ally:
context.fillStyle = this.theme.allyColor().toRgbString();
break;
case Relationship.Enemy:
context.fillStyle = this.theme.enemyColor().toRgbString();
break;
}
} else {
context.fillStyle = color.alpha(alpha / 255).toRgbString();
}
context.fillRect(x, y, 1, 1);
}
clearCell(
x: number,
y: number,
context: CanvasRenderingContext2D = this.context,
) {
context.clearRect(x, y, 1, 1);
}
drawSprite(unit: UnitView, customTerritoryColor?: Colord) {
const x = this.game.x(unit.tile());
const y = this.game.y(unit.tile());
let alternateViewColor: Colord | null = null;
if (this.alternateView) {
let rel = this.relationship(unit);
const dstPortId = unit.targetUnitId();
if (unit.type() === UnitType.TradeShip && dstPortId !== undefined) {
const target = this.game.unit(dstPortId)?.owner();
const myPlayer = this.game.myPlayer();
if (myPlayer !== null && target !== undefined) {
if (myPlayer === target) {
rel = Relationship.Self;
} else if (myPlayer.isFriendly(target)) {
rel = Relationship.Ally;
}
}
}
switch (rel) {
case Relationship.Self:
alternateViewColor = this.theme.selfColor();
break;
case Relationship.Ally:
alternateViewColor = this.theme.allyColor();
break;
case Relationship.Enemy:
alternateViewColor = this.theme.enemyColor();
break;
}
}
const sprite = getColoredSprite(
unit,
this.theme,
alternateViewColor ?? customTerritoryColor,
alternateViewColor ?? undefined,
);
if (unit.isActive()) {
const targetable = unit.targetable();
if (!targetable) {
this.context.save();
this.context.globalAlpha = 0.5;
}
this.context.drawImage(
sprite,
Math.round(x - sprite.width / 2),
Math.round(y - sprite.height / 2),
sprite.width,
sprite.width,
);
if (!targetable) {
this.context.restore();
}
}
}
}