mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-28 05:44:16 +00:00
4ee3cbc255
## Description: This PR addresses issue #2302: where there is no obvious information about the self-player's team during the spawn phase of the game. Currently, during TEAM games (where there is a set number of teams defined), the self player's spawn highlight color is white, while all other players are shown with a team-based spawn highlight color. This makes it difficult to discern who is a teammate, especially since the current live version (v0.26.7) uses green/yellow for other players to depict teammate/other team, respectively. Technically, the same is true for Duos, Trios, and Quads games, although this has been addressed separately with PR #2298 by reverting to green/yellow for teammate/other team players. This PR changes the color of the self player's breathing spawn highlight ring to match their assigned team color (with `alpha=0.5` for transparency). The breathing ring is semi transparent on top of the existing white static semi-transparent ring, giving the breathing ring a **tint** of the team color instead of the exact team color. This allows a player to immediately identify their assigned team and maintains overall consistency with FFA, Duos, Trios, and Quads game modes. See below for example implementation. In this screenshot, the self player is on the red team as shown by the red tint to the breathing spawn highlight ring. The screenshot also shows some of the static white semi-transparent ring around the spawn location. The self player's teammate is to the top left of the image with a solid red spawn highlight. The non-player (nation) opponent on the blue team is shown to the bottom left. In online games, nations are not present in team games. In single player or private lobby games they are shown as here, without a spawn color highlight and only a territory color. <img width="402" height="292" alt="Team Spawn Color Tint" src="https://github.com/user-attachments/assets/5696a408-a633-4ec8-bf93-c8afa8e2e121" /> ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: GlacialDrift
559 lines
15 KiB
TypeScript
559 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),
|
|
unit.owner().territoryColor(),
|
|
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("rgb(200,0,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,
|
|
unit.owner().borderColor(),
|
|
255,
|
|
);
|
|
this.paintCell(
|
|
this.game.x(unit.lastTile()),
|
|
this.game.y(unit.lastTile()),
|
|
rel,
|
|
unit.owner().borderColor(),
|
|
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,
|
|
other.owner().territoryColor(),
|
|
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),
|
|
unit.owner().territoryColor(),
|
|
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,
|
|
unit.owner().borderColor(),
|
|
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), unit.owner().territoryColor(), 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();
|
|
}
|
|
}
|
|
}
|
|
}
|