Files
OpenFrontIO/src/client/graphics/layers/StructureDrawingUtils.ts
T
VariableVince 496f1008bb Fix: icons structure icons and others at wrong location (#3453)
## Description:

Fix for all structure icons suddenly being displaced from the actual
structure location. And in some cases, structure itself created at wrong
location, or coordinate grid, nuke trajectory preview target, nuke
circles, naval landing target, or pop-up text of gold earned from train
or tradeship.

- Was triggered on iOS almost exclusively, but was also possible on
other devices when a top/left margin was present. Like when an ad was
shown. Why noticed almost only on iOS? Because of different behaviors
where it shifts the DOM elements around relative to the screen
temporarily, so we get a top/left offset on getBoundingClientRect for
the canvas. Possible culprit is overscroll which lets you scroll outside
of the viewport for several hundred pixels before snapping back. Which
was triggered by mistake by dragging instead of tapping somewhere, or
so.

- Some of the bug reports:
https://discord.com/channels/1284581928254701718/1451393982159523982,
https://discord.com/channels/1284581928254701718/1463548362526822649,
https://discord.com/channels/1284581928254701718/1378672255336189964,
fixes https://github.com/openfrontio/OpenFrontIO/issues/3406

- The fix brings a little performance win as well because we need to be
doing less calculations. It is basically "if drawing on a canvas, work
with canvas coordinates and not with screen coordinates". Was stress
tested by two players and me, see below for reproduction.

- (BTW. When researching if the current logic was intended in any way, I
found CodeRabbit had already noticed part of the bug twice. One of them
was
[resolved](https://github.com/openfrontio/OpenFrontIO/pull/2059#discussion_r2396277710),
the other [left
open](https://github.com/openfrontio/OpenFrontIO/pull/2059#discussion_r2370413213).
Another reminder that we need to heed Rabbits calls!)

**CONTAINS**
- StructureIconsLayer > computeNewLocation and StructureDrawingUtils >
createUnitContainer. In renderLayer, when TransformHandler.hasChanged
(after onZoom, goTo, onMove), computeNewLocation recalculates the
position of all structure icons. Sometimes all icons would suddenly be
displaced, not above their map position but somewhere else. New single
icons/levels/sprites would be placed wrongly too by computeNewLocation
and createUnitContainer.
-- Fix: don't use TransformHandler > worldToScreenCoordinates but the
new worldToCanvasCoordinates. Because worldToScreenCoordinates adds the
canvas boundingRect top/left offset. When the main canvas is already
shifted down with a margin, the icons also get shifted within the
canvas. So they're moved twice as much as they should be. We only need
to know at what canvas location to put the icons, the main GameRender
canvas already handles the overall displacement.

- TransformHandler > worldToCanvasCoordinates
-- Use the new worldToCanvasCoordinates too instead of
worldToScreenCoordinates in CoordinateGridLayer, MoveIncicatorUI,
NavalTarget, NukeTeleGraph, TextIndicator. They were affected by the
same bug as the shifting Structure icons because the boundingRect
top/left offset was applied twice, but it was noticed less. I have seen
reports of NavalTarget or MobveIndicatorUI (for warships) not being in
the correct spot though iirc. And just like for
StructureIconsLayer/StructureDrawingUtils, it's only logical. Since
we're drawing on the canvas, we only need to know where to place things
within that canvas.

- TransformHandler > worldToScreenCoordinates
-- Split in two sub-functions. New seperate function
worldToCanvasCoordinates was needed for the above fix. For
canvasToScreenCoordinates the reason is explained below.

- TransformHandler > screenToWorldCoordinates: this function already
subtracts the canvas boundingRect top/left offset. Some callers were
themselves getting the boundaryRect and subtracting top/left, before
calling screenToWorldCoordinates. Not only unnecessary. But also, when
there was more than 0 top/left offset, it would be subtracted twice from
the mouse/tap position. Meaning a (ghost) structure or nuke trajectory
preview target would not be put where the mouse/tap was. Same bug as
above but reversed.
-- Checked all callers. Most did it right. Fixes where needed in
StructureIconsLayer > createStructure and renderGhost, and in
NukeTrajectoryPreviewLayer > updateTrajectoryPreview and
updateTrajectoryPath.
-- Removed comment in screenToWorldCoordinates that talked about zoom.
It doesn't belong there because we do more than zooming there, it was
probably copied once from onZoom() which has the exact same comment and
it belongs in that function.
-- Small fix in caller BuildMenu when checking all callers of
screenToWorldCoordinates: it checked if clickedCell was null, but
screenToWorldCoordinates never returns null.

- TransformHandler > added public helper functions
screenToCanvasCoordinates and canvasToScreenCoordinates to make code
re-usable
-- screenToCanvasCoordinates is used in screenToWorldCoordinates and
onZoom in TransformHandler itself
-- screenToCanvasCoordinates is now also used also in moveGhost and
createGhostStructure in StructureIconsLayer. No bugs there, but the same
calculation was done (getting boundingRect, subtracting left/top from
mouse/tap position). So they now use the centralized function which also
adds to their readability.
-- canvasToScreenCoordinates is (for now) only used in
worldToScreenCoordinates in TransformHandler. It makes the function more
readable imo. And, since it has such a similar calculation to
screenToWorldCoordinates, it only seemed neat to have them both have
their own function.

**BEFORE** (only showing "all structure icons get displaced", but the
cause and fix is basically the same for all)
https://youtu.be/CfDdUwIRQeE

**AFTER**
https://youtu.be/w5w_wvh5V0g 

## 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:

tryout33
2026-03-23 16:27:46 -07:00

521 lines
16 KiB
TypeScript

import * as PIXI from "pixi.js";
import { assetUrl } from "../../../core/AssetUrls";
import { Theme } from "../../../core/configuration/Config";
import {
Cell,
PlayerBuildableUnitType,
UnitType,
} from "../../../core/game/Game";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
const anchorIcon = assetUrl("images/AnchorIcon.png");
const cityIcon = assetUrl("images/CityIcon.png");
const factoryIcon = assetUrl("images/FactoryUnit.png");
const missileSiloIcon = assetUrl("images/MissileSiloUnit.png");
const SAMMissileIcon = assetUrl("images/SamLauncherUnit.png");
const shieldIcon = assetUrl("images/ShieldIcon.png");
export const STRUCTURE_SHAPES: Partial<Record<UnitType, ShapeType>> = {
[UnitType.City]: "circle",
[UnitType.Port]: "pentagon",
[UnitType.Factory]: "circle",
[UnitType.DefensePost]: "octagon",
[UnitType.SAMLauncher]: "square",
[UnitType.MissileSilo]: "triangle",
[UnitType.Warship]: "cross",
[UnitType.AtomBomb]: "cross",
[UnitType.HydrogenBomb]: "cross",
[UnitType.MIRV]: "cross",
};
export const LEVEL_SCALE_FACTOR = 3;
export const ICON_SCALE_FACTOR_ZOOMED_IN = 3.5;
export const ICON_SCALE_FACTOR_ZOOMED_OUT = 1.4;
export const DOTS_ZOOM_THRESHOLD = 0.5;
export const ZOOM_THRESHOLD = 4.3;
export const ICON_SIZE = {
circle: 28,
octagon: 28,
pentagon: 30,
square: 28,
triangle: 28,
cross: 20,
};
export const OFFSET_ZOOM_Y = 4;
export type ShapeType =
| "triangle"
| "square"
| "pentagon"
| "octagon"
| "circle"
| "cross";
export class SpriteFactory {
private theme: Theme;
private game: GameView;
private transformHandler: TransformHandler;
private renderSprites: boolean;
private readonly textureCache: Map<string, PIXI.Texture> = new Map();
private readonly structuresInfos: Map<
UnitType,
{ iconPath: string; image: HTMLImageElement | null }
> = new Map([
[UnitType.City, { iconPath: cityIcon, image: null }],
[UnitType.Factory, { iconPath: factoryIcon, image: null }],
[UnitType.DefensePost, { iconPath: shieldIcon, image: null }],
[UnitType.Port, { iconPath: anchorIcon, image: null }],
[UnitType.MissileSilo, { iconPath: missileSiloIcon, image: null }],
[UnitType.SAMLauncher, { iconPath: SAMMissileIcon, image: null }],
]);
constructor(
theme: Theme,
game: GameView,
transformHandler: TransformHandler,
renderSprites: boolean,
) {
this.theme = theme;
this.game = game;
this.transformHandler = transformHandler;
this.renderSprites = renderSprites;
this.structuresInfos.forEach((u, unitType) => this.loadIcon(u, unitType));
}
private loadIcon(
unitInfo: {
iconPath: string;
image: HTMLImageElement | null;
},
unitType: UnitType,
) {
const image = new Image();
image.src = unitInfo.iconPath;
image.onload = () => {
unitInfo.image = image;
this.invalidateTextureCache(unitType);
};
image.onerror = () => {
console.error(
`Failed to load icon for ${unitType}: ${unitInfo.iconPath}`,
);
};
}
private invalidateTextureCache(unitType: UnitType) {
for (const key of Array.from(this.textureCache.keys())) {
if (key.includes(`-${unitType}`)) {
this.textureCache.delete(key);
}
}
}
createGhostContainer(
player: PlayerView,
ghostStage: PIXI.Container,
pos: { x: number; y: number },
structureType: PlayerBuildableUnitType,
): {
container: PIXI.Container;
priceText: PIXI.BitmapText;
priceBg: PIXI.Graphics;
priceGroup: PIXI.Container;
priceBox: { height: number; y: number; paddingX: number; minWidth: number };
} {
const parentContainer = new PIXI.Container();
const texture = this.createTexture(
structureType,
player,
false,
false,
true,
);
const sprite = new PIXI.Sprite(texture);
sprite.anchor.set(0.5);
sprite.alpha = 0.5;
parentContainer.addChild(sprite);
const priceText = new PIXI.BitmapText({
text: "125K",
style: { fontFamily: "round_6x6_modified", fontSize: 12 },
});
priceText.anchor.set(0.5);
const priceGroup = new PIXI.Container();
const boxHeight = 18;
const boxY =
(sprite.height > 0 ? sprite.height / 2 : 16) + boxHeight / 2 + 4;
// a way to resize the pill horizontally based on the text width
const paddingX = 8;
const minWidth = 32;
const textWidth = priceText.width;
const boxWidth = Math.max(minWidth, textWidth + paddingX * 2);
const priceBg = new PIXI.Graphics();
priceBg
.roundRect(-boxWidth / 2, boxY - boxHeight / 2, boxWidth, boxHeight, 4)
.fill({ color: 0x000000, alpha: 0.65 });
priceText.position.set(0, boxY);
priceGroup.addChild(priceBg);
priceGroup.addChild(priceText);
parentContainer.addChild(priceGroup);
parentContainer.position.set(pos.x, pos.y);
parentContainer.scale.set(
Math.min(1, this.transformHandler.scale / ICON_SCALE_FACTOR_ZOOMED_OUT),
);
ghostStage.addChild(parentContainer);
return {
container: parentContainer,
priceText,
priceBg,
priceGroup,
priceBox: { height: boxHeight, y: boxY, paddingX, minWidth },
};
}
// --- internal helpers ---
public createUnitContainer(
unit: UnitView,
options: { type?: "icon" | "dot" | "level"; stage: PIXI.Container },
): PIXI.Container {
const parentContainer = new PIXI.Container();
const tile = unit.tile();
const worldPos = new Cell(this.game.x(tile), this.game.y(tile));
const screenPos = this.transformHandler.worldToCanvasCoordinates(worldPos);
const isMarkedForDeletion = unit.markedForDeletion() !== false;
const isConstruction = unit.isUnderConstruction();
const structureType = unit.type();
const { type, stage } = options;
const { scale } = this.transformHandler;
this.renderSprites =
this.game.config().userSettings()?.structureSprites() ?? true;
if (type === "icon" || type === "dot") {
const texture = this.createTexture(
structureType,
unit.owner(),
isConstruction,
isMarkedForDeletion,
type === "icon",
);
const sprite = new PIXI.Sprite(texture);
sprite.anchor.set(0.5);
parentContainer.addChild(sprite);
}
if ((type === "icon" || type === "level") && unit.level() > 1) {
const text = new PIXI.BitmapText({
text: unit.level().toString(),
style: { fontFamily: "round_6x6_modified", fontSize: 14 },
});
text.anchor.set(0.5);
const shape = STRUCTURE_SHAPES[structureType];
if (shape !== undefined) {
text.position.y = Math.round(-ICON_SIZE[shape] / 2 - 2);
}
parentContainer.addChild(text);
}
const posX = Math.round(screenPos.x);
let posY = Math.round(screenPos.y);
if (type === "level" && scale >= ZOOM_THRESHOLD && this.renderSprites) {
posY = Math.round(screenPos.y - scale * OFFSET_ZOOM_Y);
}
parentContainer.position.set(posX, posY);
if (type === "icon") {
const s =
scale >= ZOOM_THRESHOLD && !this.renderSprites
? Math.max(1, scale / ICON_SCALE_FACTOR_ZOOMED_IN)
: Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT);
parentContainer.scale.set(s);
} else if (type === "level") {
parentContainer.scale.set(Math.max(1, scale / LEVEL_SCALE_FACTOR));
}
stage.addChild(parentContainer);
return parentContainer;
}
private createTexture(
type: UnitType,
owner: PlayerView,
isConstruction: boolean,
isMarkedForDeletion: boolean,
renderIcon: boolean,
): PIXI.Texture {
const cacheKeyBase = isConstruction
? `construction-${type}`
: `${this.theme.territoryColor(owner).toRgbString()}-${type}`;
const cacheKey =
cacheKeyBase +
(renderIcon ? "-icon" : "") +
(isMarkedForDeletion ? "-deleted" : "");
if (this.textureCache.has(cacheKey)) {
return this.textureCache.get(cacheKey)!;
}
const shape = STRUCTURE_SHAPES[type];
const texture = shape
? this.createIcon(
owner,
type,
isConstruction,
isMarkedForDeletion,
shape,
renderIcon,
)
: PIXI.Texture.EMPTY;
this.textureCache.set(cacheKey, texture);
return texture;
}
private createIcon(
owner: PlayerView,
structureType: UnitType,
isConstruction: boolean,
isMarkedForDeletion: boolean,
shape: string,
renderIcon: boolean,
): PIXI.Texture {
const structureCanvas = document.createElement("canvas");
let iconSize = ICON_SIZE[shape];
if (!renderIcon) {
iconSize /= 2.5;
}
structureCanvas.width = Math.ceil(iconSize);
structureCanvas.height = Math.ceil(iconSize);
const context = structureCanvas.getContext("2d")!;
// Use structureColors defined from the PlayerView.
context.fillStyle = isConstruction
? "rgb(198,198,198)"
: owner.structureColors().light.toRgbString();
context.strokeStyle = isConstruction
? "rgb(127,127, 127)"
: owner.structureColors().dark.toRgbString();
context.lineWidth = 1;
const halfIconSize = iconSize / 2;
switch (shape) {
case "triangle":
context.beginPath();
context.moveTo(halfIconSize, 1); // Top
context.lineTo(iconSize - 1, iconSize - 1); // Bottom right
context.lineTo(0, iconSize - 1); // Bottom left
context.closePath();
context.fill();
context.stroke();
break;
case "square":
context.fillRect(1, 1, iconSize - 2, iconSize - 2);
context.strokeRect(1, 1, iconSize - 3, iconSize - 3);
break;
case "octagon":
{
const cx = halfIconSize;
const cy = halfIconSize;
const r = halfIconSize - 1;
const step = (Math.PI * 2) / 8;
context.beginPath();
for (let i = 0; i < 8; i++) {
const angle = step * i - Math.PI / 8; // slight rotation for flat top
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) {
context.moveTo(x, y);
} else {
context.lineTo(x, y);
}
}
context.closePath();
context.fill();
context.stroke();
}
break;
case "pentagon":
{
const cx = halfIconSize;
const cy = halfIconSize;
const r = halfIconSize - 1;
const step = (Math.PI * 2) / 5;
context.beginPath();
for (let i = 0; i < 5; i++) {
const angle = step * i - Math.PI / 2; // rotate to have flat base or point up
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) {
context.moveTo(x, y);
} else {
context.lineTo(x, y);
}
}
context.closePath();
context.fill();
context.stroke();
}
break;
case "cross": {
context.strokeStyle = "rgba(0, 0, 0, 1)";
context.fillStyle = "rgba(0, 0, 0, 1)";
const gap = iconSize * 0.18; // gap at center
const lineLen = iconSize / 2;
context.save();
context.translate(halfIconSize, halfIconSize);
// Up
context.beginPath();
context.moveTo(0, -gap);
context.lineTo(0, -lineLen);
context.stroke();
// Down
context.beginPath();
context.moveTo(0, gap);
context.lineTo(0, lineLen);
context.stroke();
// Left
context.beginPath();
context.moveTo(-gap, 0);
context.lineTo(-lineLen, 0);
context.stroke();
// Right
context.beginPath();
context.moveTo(gap, 0);
context.lineTo(lineLen, 0);
context.stroke();
context.restore();
break;
}
case "circle":
context.beginPath();
context.arc(
halfIconSize,
halfIconSize,
halfIconSize - 1,
0,
Math.PI * 2,
);
context.fill();
context.stroke();
break;
default:
throw new Error(`Unknown shape: ${shape}`);
}
const structureInfo = this.structuresInfos.get(structureType);
if (structureInfo?.image && renderIcon) {
const SHAPE_OFFSETS = {
triangle: [6, 11],
square: [5, 5],
octagon: [6, 6],
pentagon: [7, 7],
circle: [6, 6],
cross: [0, 0],
};
const [offsetX, offsetY] = SHAPE_OFFSETS[shape] || [0, 0];
context.drawImage(
this.getImageColored(
structureInfo.image,
owner.structureColors().dark.toRgbString(),
),
offsetX,
offsetY,
);
}
if (isMarkedForDeletion) {
context.save();
context.strokeStyle = "rgba(255, 64, 64, 0.95)";
context.lineWidth = Math.max(2, Math.round(iconSize * 0.12));
context.lineCap = "round";
const padding = Math.max(2, iconSize * 0.12);
context.beginPath();
context.moveTo(padding, padding);
context.lineTo(iconSize - padding, iconSize - padding);
context.moveTo(iconSize - padding, padding);
context.lineTo(padding, iconSize - padding);
context.stroke();
context.restore();
}
return PIXI.Texture.from(structureCanvas);
}
public createRange(
type: UnitType,
stage: PIXI.Container,
pos: { x: number; y: number },
level?: number,
targetingAlly: boolean = false,
): PIXI.Container | null {
if (stage === undefined) throw new Error("Not initialized");
const parentContainer = new PIXI.Container();
const circle = new PIXI.Graphics();
let radius = 0;
switch (type) {
case UnitType.SAMLauncher:
radius = this.game.config().samRange(level ?? 1);
break;
case UnitType.Factory:
radius = this.game.config().trainStationMaxRange();
break;
case UnitType.DefensePost:
radius = this.game.config().defensePostRange();
break;
case UnitType.AtomBomb:
radius = this.game.config().nukeMagnitudes(UnitType.AtomBomb).outer;
break;
case UnitType.HydrogenBomb:
radius = this.game.config().nukeMagnitudes(UnitType.HydrogenBomb).outer;
break;
default:
return null;
}
// Add warning colors (red/orange) when targeting an ally to indicate alliance will break
const isNuke = type === UnitType.AtomBomb || type === UnitType.HydrogenBomb;
const fillColor = targetingAlly && isNuke ? 0xff6b35 : 0xffffff;
const fillAlpha = targetingAlly && isNuke ? 0.35 : 0.2;
const strokeColor = targetingAlly && isNuke ? 0xff4444 : 0xffffff;
const strokeAlpha = targetingAlly && isNuke ? 0.8 : 0.5;
const strokeWidth = targetingAlly && isNuke ? 2 : 1;
circle
.circle(0, 0, radius)
.fill({ color: fillColor, alpha: fillAlpha })
.stroke({ width: strokeWidth, color: strokeColor, alpha: strokeAlpha });
parentContainer.addChild(circle);
parentContainer.position.set(pos.x, pos.y);
parentContainer.scale.set(this.transformHandler.scale);
stage.addChild(parentContainer);
return parentContainer;
}
private getImageColored(
image: HTMLImageElement,
color: string,
): HTMLCanvasElement {
const imageCanvas = document.createElement("canvas");
imageCanvas.width = image.width;
imageCanvas.height = image.height;
const ctx = imageCanvas.getContext("2d")!;
ctx.fillStyle = color;
ctx.fillRect(0, 0, imageCanvas.width, imageCanvas.height);
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(image, 0, 0);
return imageCanvas;
}
}