mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:20:47 +00:00
delete dead canvas2D utilities, rename mountWebGLDebugRenderer → mountWebGLFrameLoop
ProgressBar and StructureDrawingUtils had no production callers — only their own test referenced ProgressBar, and StructureDrawingUtils was a canvas2D-era helper module that nothing imports anymore. mountWebGLDebugRenderer was named back when WebGL was a side-by-side debug overlay; it's the only renderer now, so the "Debug" prefix is misleading. Also dropped the `\` keybind that hid the GL canvas — with no other renderer, hiding it just blanks the game.
This commit is contained in:
@@ -294,7 +294,7 @@ function createWebGLView(terrainMap: TerrainMapData): {
|
||||
return { view, glCanvas, cachedWebGLFrameCallback };
|
||||
}
|
||||
|
||||
function mountWebGLDebugRenderer(
|
||||
function mountWebGLFrameLoop(
|
||||
terrainMap: TerrainMapData,
|
||||
view: WebGLGameView,
|
||||
glCanvas: HTMLCanvasElement,
|
||||
@@ -307,13 +307,6 @@ function mountWebGLDebugRenderer(
|
||||
const mapWidth = gameMap.width();
|
||||
const mapHeight = gameMap.height();
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.key === "\\") {
|
||||
glCanvas.style.display =
|
||||
glCanvas.style.display === "none" ? "block" : "none";
|
||||
}
|
||||
});
|
||||
|
||||
// Cache canvas dimensions to avoid forced reflows every frame. Reading
|
||||
// clientWidth/clientHeight flushes pending layout — at 60fps that's a
|
||||
// measurable cost. Only update on resize events from the observer.
|
||||
@@ -420,7 +413,7 @@ async function createClientGame(
|
||||
|
||||
// Transparent fullscreen overlay used purely as the pointer-event /
|
||||
// bounding-rect target for InputHandler + TransformHandler. The actual
|
||||
// map drawing happens on the WebGL canvas created in mountWebGLDebugRenderer.
|
||||
// map drawing happens on the WebGL canvas created in createWebGLView.
|
||||
const inputOverlay = document.createElement("div");
|
||||
inputOverlay.id = "game-input-overlay";
|
||||
inputOverlay.style.position = "fixed";
|
||||
@@ -444,7 +437,7 @@ async function createClientGame(
|
||||
view,
|
||||
);
|
||||
|
||||
const { builder: webglBuilder } = mountWebGLDebugRenderer(
|
||||
const { builder: webglBuilder } = mountWebGLFrameLoop(
|
||||
gameMap,
|
||||
view,
|
||||
glCanvas,
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
export class ProgressBar {
|
||||
private static readonly CLEAR_PADDING = 2;
|
||||
constructor(
|
||||
private colors: string[] = [],
|
||||
private ctx: CanvasRenderingContext2D,
|
||||
private x: number,
|
||||
private y: number,
|
||||
private w: number,
|
||||
private h: number,
|
||||
private progress: number = 0, // Progress from 0 to 1
|
||||
) {
|
||||
this.setProgress(progress);
|
||||
}
|
||||
|
||||
setProgress(progress: number): void {
|
||||
progress = Math.max(0, Math.min(1, progress));
|
||||
this.clear();
|
||||
// Draw the loading bar background
|
||||
this.ctx.fillStyle = "rgba(0, 0, 0, 1)";
|
||||
this.ctx.fillRect(this.x - 1, this.y - 1, this.w, this.h);
|
||||
|
||||
// Draw the loading progress
|
||||
if (this.colors.length === 0) {
|
||||
this.ctx.fillStyle = "#808080"; // default gray
|
||||
} else {
|
||||
const idx = Math.min(
|
||||
this.colors.length - 1,
|
||||
Math.floor(progress * this.colors.length),
|
||||
);
|
||||
this.ctx.fillStyle = this.colors[idx];
|
||||
}
|
||||
this.ctx.fillRect(
|
||||
this.x,
|
||||
this.y,
|
||||
Math.max(1, Math.floor(progress * (this.w - 2))),
|
||||
this.h - 2,
|
||||
);
|
||||
this.progress = progress;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.ctx.clearRect(
|
||||
this.x - ProgressBar.CLEAR_PADDING,
|
||||
this.y - ProgressBar.CLEAR_PADDING,
|
||||
this.w + ProgressBar.CLEAR_PADDING,
|
||||
this.h + ProgressBar.CLEAR_PADDING,
|
||||
);
|
||||
}
|
||||
|
||||
getX(): number {
|
||||
return this.x;
|
||||
}
|
||||
|
||||
getY(): number {
|
||||
return this.y;
|
||||
}
|
||||
|
||||
getProgress(): number {
|
||||
return this.progress;
|
||||
}
|
||||
}
|
||||
@@ -1,549 +0,0 @@
|
||||
import * as PIXI from "pixi.js";
|
||||
import { Theme } from "src/core/configuration/Theme";
|
||||
import { assetUrl } from "../../../core/AssetUrls";
|
||||
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.v1.png");
|
||||
const cityIcon = assetUrl("images/CityIcon.v1.png");
|
||||
const factoryIcon = assetUrl("images/FactoryUnit.v1.png");
|
||||
const missileSiloIcon = assetUrl("images/MissileSiloUnit.v1.png");
|
||||
const SAMMissileIcon = assetUrl("images/SamLauncherUnit.v1.png");
|
||||
const shieldIcon = assetUrl("images/ShieldIcon.v1.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 colorCanvas: HTMLCanvasElement | null = null;
|
||||
private colorCtx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
public clearCache() {
|
||||
for (const texture of this.textureCache.values()) {
|
||||
if (texture && texture !== PIXI.Texture.EMPTY) {
|
||||
try {
|
||||
texture.destroy(true);
|
||||
} catch (e) {
|
||||
console.error("Error clearing texture cache:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.textureCache.clear();
|
||||
this.colorCanvas = null;
|
||||
this.colorCtx = null;
|
||||
}
|
||||
|
||||
private loadIcon(
|
||||
unitInfo: {
|
||||
iconPath: string;
|
||||
image: HTMLImageElement | null;
|
||||
},
|
||||
unitType: UnitType,
|
||||
) {
|
||||
const image = new Image();
|
||||
// crossOrigin must be set before src so the fetch is CORS-checked.
|
||||
// Without this, an icon served from CDN_BASE taints structureCanvas
|
||||
// and PIXI.Texture.from rejects the upload to WebGL.
|
||||
image.crossOrigin = "anonymous";
|
||||
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}`)) {
|
||||
const tex = this.textureCache.get(key);
|
||||
if (tex && tex !== PIXI.Texture.EMPTY) {
|
||||
tex.destroy(true);
|
||||
}
|
||||
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: keyof typeof ICON_SIZE,
|
||||
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, true);
|
||||
}
|
||||
|
||||
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: number;
|
||||
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 {
|
||||
if (!this.colorCanvas || !this.colorCtx) {
|
||||
this.colorCanvas = document.createElement("canvas");
|
||||
this.colorCtx = this.colorCanvas.getContext("2d")!;
|
||||
}
|
||||
const { colorCanvas, colorCtx: ctx } = this;
|
||||
if (colorCanvas.width !== image.width) colorCanvas.width = image.width;
|
||||
if (colorCanvas.height !== image.height) colorCanvas.height = image.height;
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, colorCanvas.width, colorCanvas.height);
|
||||
ctx.globalCompositeOperation = "destination-in";
|
||||
ctx.drawImage(image, 0, 0);
|
||||
return colorCanvas;
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { ProgressBar } from "../../../src/client/graphics/ProgressBar";
|
||||
|
||||
describe("ProgressBar", () => {
|
||||
let ctx: CanvasRenderingContext2D;
|
||||
let canvas: HTMLCanvasElement;
|
||||
|
||||
beforeEach(() => {
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.width = 100;
|
||||
canvas.height = 20;
|
||||
ctx = canvas.getContext("2d")!;
|
||||
});
|
||||
|
||||
it("should initialize and draw the background", () => {
|
||||
const spyClearRect = vi.spyOn(ctx, "clearRect");
|
||||
const spyFillRect = vi.spyOn(ctx, "fillRect");
|
||||
const spyFillStyle = vi.spyOn(ctx, "fillStyle", "set");
|
||||
const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, 0.5);
|
||||
expect(spyClearRect).toHaveBeenCalledWith(0, 0, 82, 12);
|
||||
expect(spyFillRect).toHaveBeenCalledWith(1, 1, 80, 10);
|
||||
expect(spyFillStyle).toHaveBeenCalledWith("#00ff00");
|
||||
expect(bar.getX()).toBe(2);
|
||||
expect(bar.getY()).toBe(2);
|
||||
});
|
||||
|
||||
it("should set progress and draw the progress bar", () => {
|
||||
const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10);
|
||||
const spyFillRect = vi.spyOn(ctx, "fillRect");
|
||||
bar.setProgress(0.5);
|
||||
expect(bar.getProgress()).toBe(0.5);
|
||||
expect(spyFillRect).toHaveBeenCalledWith(
|
||||
2,
|
||||
2,
|
||||
Math.floor(0.5 * (80 - 2)),
|
||||
8,
|
||||
);
|
||||
expect(ctx.fillStyle).toBe("#00ff00");
|
||||
|
||||
bar.setProgress(0.1);
|
||||
expect(ctx.fillStyle).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("should clamp progress between 0 and 1 on init", () => {
|
||||
const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, -1);
|
||||
expect(bar.getProgress()).toBe(0);
|
||||
const bar2 = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, 2);
|
||||
expect(bar2.getProgress()).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle empty colors array gracefully", () => {
|
||||
const bar = new ProgressBar([], ctx, 2, 2, 80, 10, 0.5);
|
||||
expect(() => bar.setProgress(0.5)).not.toThrow();
|
||||
expect(ctx.fillStyle).toBe("#808080");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user