mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 23:51:55 +00:00
581 lines
16 KiB
TypeScript
581 lines
16 KiB
TypeScript
import { Colord, colord } from "colord";
|
|
import { Theme } from "../../../core/configuration/Config";
|
|
import { EventBus } from "../../../core/EventBus";
|
|
import { UnitType } from "../../../core/game/Game";
|
|
import { TileRef } from "../../../core/game/GameMap";
|
|
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
|
import { GameView, UnitView } from "../../../core/game/GameView";
|
|
import { BezenhamLine } from "../../../core/utilities/Line";
|
|
import {
|
|
AlternateViewEvent,
|
|
MouseUpEvent,
|
|
UnitSelectionEvent,
|
|
} from "../../InputHandler";
|
|
import { MoveWarshipIntentEvent } from "../../Transport";
|
|
import {
|
|
getColoredSprite,
|
|
isSpriteReady,
|
|
loadAllSprites,
|
|
} from "../SpriteLoader";
|
|
import { TransformHandler } from "../TransformHandler";
|
|
import { Layer } from "./Layer";
|
|
|
|
enum Relationship {
|
|
Self,
|
|
Ally,
|
|
Enemy,
|
|
}
|
|
|
|
export class UnitLayer implements Layer {
|
|
private canvas: HTMLCanvasElement | undefined;
|
|
private context: CanvasRenderingContext2D | undefined;
|
|
private transportShipTrailCanvas: HTMLCanvasElement | undefined;
|
|
private unitTrailContext: CanvasRenderingContext2D | undefined;
|
|
|
|
private readonly unitToTrail = new Map<UnitView, TileRef[]>();
|
|
|
|
private readonly theme: Theme;
|
|
|
|
private alternateView = false;
|
|
|
|
private readonly oldShellTile = new Map<UnitView, TileRef>();
|
|
|
|
private readonly 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 readonly game: GameView,
|
|
private readonly 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) {
|
|
if (this.transportShipTrailCanvas === undefined)
|
|
throw new Error("Not initialized");
|
|
if (this.canvas === undefined) throw new Error("Not initialized");
|
|
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) => {
|
|
if (this.unitTrailContext === undefined)
|
|
throw new Error("Not initialized");
|
|
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) => {
|
|
if (this.context === undefined) throw new Error("Not initialized");
|
|
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({ b: 0, g: 0, r: 200 }));
|
|
} else {
|
|
this.drawSprite(unit);
|
|
}
|
|
}
|
|
|
|
private handleShellEvent(unit: UnitView) {
|
|
if (this.context === undefined) throw new Error("Not initialized");
|
|
const rel = this.relationship(unit);
|
|
|
|
// Clear current and previous positions
|
|
this.clearCell(
|
|
this.game.x(unit.lastTile()),
|
|
this.game.y(unit.lastTile()),
|
|
this.context,
|
|
);
|
|
const oldTile = this.oldShellTile.get(unit);
|
|
if (oldTile !== undefined) {
|
|
this.clearCell(this.game.x(oldTile), this.game.y(oldTile), this.context);
|
|
}
|
|
|
|
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.context,
|
|
);
|
|
this.paintCell(
|
|
this.game.x(unit.lastTile()),
|
|
this.game.y(unit.lastTile()),
|
|
rel,
|
|
this.theme.borderColor(unit.owner()),
|
|
255,
|
|
this.context,
|
|
);
|
|
}
|
|
|
|
// interception missle from SAM
|
|
private handleMissileEvent(unit: UnitView) {
|
|
this.drawSprite(unit);
|
|
}
|
|
|
|
private drawTrail(trail: number[], color: Colord, rel: Relationship) {
|
|
if (this.unitTrailContext === undefined) throw new Error("Not initialized");
|
|
// 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) {
|
|
if (this.unitTrailContext === undefined) throw new Error("Not initialized");
|
|
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
|
|
if (this.unitTrailContext === undefined) throw new Error("Not initialized");
|
|
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);
|
|
|
|
if (this.context === undefined) throw new Error("Not initialized");
|
|
this.clearCell(
|
|
this.game.x(unit.lastTile()),
|
|
this.game.y(unit.lastTile()),
|
|
this.context,
|
|
);
|
|
|
|
if (unit.isActive()) {
|
|
// Paint area
|
|
this.paintCell(
|
|
this.game.x(unit.tile()),
|
|
this.game.y(unit.tile()),
|
|
rel,
|
|
this.theme.borderColor(unit.owner()),
|
|
255,
|
|
this.context,
|
|
);
|
|
}
|
|
}
|
|
|
|
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.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) {
|
|
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 (this.context === undefined) throw new Error("Not initialized");
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|