Move UI elements from the FX layer to a new UI layer (#2827)

## Description:

Some FX animations were previously used as UI elements (e.g. nuke area,
naval invasion target, gold text).
This PR moves those animations to a dedicated UI layer.

Those UI elements handle correctly the current zoom level and remain
sharply rendered at all zoom levels.

The new UI layer can be disabled using the same setting that disables
the FX layer.

Performance-wise, this layer is equivalent to the FX layer, as it reuses
the same animations.

### Naval target
Don't scale with the zoom level, but has a minimum zoom level so the
targeted tile can still be easily highlighted by zooming


![ui_naval_invasion](https://github.com/user-attachments/assets/43c36c80-ffba-4443-bd53-05617c793fc8)

### Nukes
Has to scale because the size is set, but the border radius is not so
the area is more visible from afar.


![ui_nukes](https://github.com/user-attachments/assets/7ca0685c-0432-4b72-8c6d-48a814a02326)


### Popup text
Scale with zoom level, and stop showing when zoomed-out:

![ui_text](https://github.com/user-attachments/assets/d92c085e-9e20-4cad-bf3a-ae5d320dde33)

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

IngloriousTom
This commit is contained in:
DevelopingTom
2026-01-09 04:21:40 +01:00
committed by GitHub
parent 8ff3f4496c
commit 971e7f4a45
13 changed files with 486 additions and 325 deletions
+2
View File
@@ -12,6 +12,7 @@ import { BuildMenu } from "./layers/BuildMenu";
import { ChatDisplay } from "./layers/ChatDisplay";
import { ChatModal } from "./layers/ChatModal";
import { ControlPanel } from "./layers/ControlPanel";
import { DynamicUILayer } from "./layers/DynamicUILayer";
import { EmojiTable } from "./layers/EmojiTable";
import { EventsDisplay } from "./layers/EventsDisplay";
import { FxLayer } from "./layers/FxLayer";
@@ -257,6 +258,7 @@ export function createRenderer(
new UILayer(game, eventBus, transformHandler),
new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState),
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
new DynamicUILayer(game, transformHandler),
new NameLayer(game, transformHandler, eventBus),
eventsDisplay,
chatDisplay,
+2 -20
View File
@@ -1,22 +1,18 @@
import { ConquestUpdate } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { renderNumber } from "../../Utils";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "./Fx";
import { FadeFx, SpriteFx } from "./SpriteFx";
import { TextFx } from "./TextFx";
/**
* Conquest FX:
* - conquest sprite
* - gold displayed
*/
export function conquestFxFactory(
animatedSpriteLoader: AnimatedSpriteLoader,
conquest: ConquestUpdate,
game: GameView,
): Fx[] {
const conquestFx: Fx[] = [];
): Fx {
const conquered = game.player(conquest.conqueredId);
const x = conquered.nameLocation().x;
const y = conquered.nameLocation().y;
@@ -28,19 +24,5 @@ export function conquestFxFactory(
FxType.ConquestChampagne,
2500,
);
const fadeAnimation = new FadeFx(swordAnimation, 0.1, 0.6);
conquestFx.push(fadeAnimation);
const shortenedGold = renderNumber(conquest.gold);
const goldText = new TextFx(
`+ ${shortenedGold}`,
x,
y + 8,
2500,
0,
"11px sans-serif",
);
conquestFx.push(goldText);
return conquestFx;
return new FadeFx(swordAnimation, 0.1, 0.6);
}
-76
View File
@@ -1,76 +0,0 @@
import { NukeMagnitude } from "../../../core/configuration/Config";
import { Fx } from "./Fx";
export class NukeAreaFx implements Fx {
private lifeTime = 0;
private ended = false;
private readonly endAnimationDuration = 300; // in ms
private readonly startAnimationDuration = 200; // in ms
private readonly innerDiameter: number;
private readonly outerDiameter: number;
private offset = 0;
private readonly dashSize: number;
private readonly rotationSpeed = 20; // px per seconds
private readonly baseAlpha = 0.9;
constructor(
private x: number,
private y: number,
magnitude: NukeMagnitude,
) {
this.innerDiameter = magnitude.inner;
this.outerDiameter = magnitude.outer;
const numDash = Math.max(1, Math.floor(this.outerDiameter / 3));
this.dashSize = (Math.PI / numDash) * this.outerDiameter;
}
end() {
this.ended = true;
this.lifeTime = 0; // reset for fade-out timing
}
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
this.lifeTime += frameTime;
if (this.ended && this.lifeTime >= this.endAnimationDuration) return false;
let t: number;
if (this.ended) {
t = Math.max(0, 1 - this.lifeTime / this.endAnimationDuration);
} else {
t = Math.min(1, this.lifeTime / this.startAnimationDuration);
}
const alpha = Math.max(0, Math.min(1, this.baseAlpha * t));
ctx.save();
ctx.globalAlpha = alpha;
ctx.lineWidth = 1;
ctx.strokeStyle = `rgba(255,0,0,${alpha})`;
ctx.fillStyle = `rgba(255,0,0,${Math.max(0, alpha - 0.6)})`;
// Inner circle
ctx.beginPath();
ctx.lineWidth = 1;
const innerDiameter =
(this.innerDiameter / 2) * (1 - t) + this.innerDiameter * t;
ctx.arc(this.x, this.y, innerDiameter, 0, Math.PI * 2);
ctx.stroke();
ctx.fill();
// Outer circle
this.offset += this.rotationSpeed * (frameTime / 1000);
ctx.beginPath();
ctx.strokeStyle = `rgba(255,0,0,${alpha})`;
ctx.lineWidth = 1;
ctx.lineDashOffset = this.offset;
ctx.setLineDash([this.dashSize]);
const outerDiameter =
(this.outerDiameter + 20) * (1 - t) + this.outerDiameter * t;
ctx.arc(this.x, this.y, outerDiameter, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
return true;
}
}
-66
View File
@@ -1,66 +0,0 @@
import { Fx } from "./Fx";
export class TargetFx implements Fx {
private lifeTime = 0;
private ended = false;
private endFade = 300;
private offset = 0;
private rotationSpeed = 14; // px per seconds
private radius = 4;
constructor(
private x: number,
private y: number,
private duration = 0,
private persistent = false,
) {}
end() {
if (this.persistent) {
this.ended = true;
this.lifeTime = 0; // reuse for fade-out timing
}
}
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
this.lifeTime += frameTime;
if (!this.persistent) {
if (this.lifeTime >= this.duration) return false;
} else if (this.ended) {
if (this.lifeTime >= this.endFade) return false;
}
const t = this.persistent
? (this.lifeTime % 1000) / 1000 // looping for pulse
: this.lifeTime / this.duration;
const baseAlpha = this.persistent ? 0.9 : 1 - t;
const fadeAlpha =
this.persistent && this.ended ? 1 - this.lifeTime / this.endFade : 1;
const alpha = Math.max(0, Math.min(1, baseAlpha * fadeAlpha));
ctx.save();
ctx.globalAlpha = alpha;
ctx.lineWidth = 1;
ctx.strokeStyle = `rgba(255,0,0,${alpha})`;
this.offset += this.rotationSpeed * (frameTime / 1000);
ctx.beginPath();
ctx.lineWidth = 1;
ctx.lineDashOffset = this.offset;
ctx.setLineDash([3, 3]);
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.strokeStyle = `rgba(255,0,0,${alpha})`;
ctx.lineWidth = 2;
ctx.lineDashOffset = -this.offset / 2;
ctx.setLineDash([19, 3]);
ctx.arc(this.x, this.y, 7, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
return true;
}
}
-39
View File
@@ -1,39 +0,0 @@
import { Fx } from "./Fx";
export class TextFx implements Fx {
private lifeTime: number = 0;
constructor(
private text: string,
private x: number,
private y: number,
private duration: number,
private riseDistance: number = 30,
private font: string = "11px sans-serif",
private color: { r: number; g: number; b: number } = {
r: 255,
g: 255,
b: 255,
},
) {}
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
this.lifeTime += frameTime;
if (this.lifeTime >= this.duration) {
return false;
}
const t = this.lifeTime / this.duration;
const currentY = this.y - t * this.riseDistance;
const alpha = 1 - t;
ctx.save();
ctx.font = this.font;
ctx.fillStyle = `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, ${alpha})`;
ctx.textAlign = "center";
ctx.fillText(this.text, this.x, currentY);
ctx.restore();
return true;
}
}
@@ -0,0 +1,168 @@
import { renderNumber } from "src/client/Utils";
import { UnitType } from "src/core/game/Game";
import {
BonusEventUpdate,
ConquestUpdate,
GameUpdateType,
} from "src/core/game/GameUpdates";
import type { GameView, UnitView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { NavalTarget } from "../ui/NavalTarget";
import { NukeTelegraph } from "../ui/NukeTelegraph";
import { TextIndicator } from "../ui/TextIndicator";
import { UIElement } from "../ui/UIElement";
import { Layer } from "./Layer";
const TEXT_OFFSET_Y = -5;
const TEXT_STACK_SPACING = 8;
const TEXT_DURATION = 2500;
export class DynamicUILayer implements Layer {
private readonly allElements: Array<UIElement> = [];
private lastRefresh = Date.now();
constructor(
private readonly game: GameView,
private transformHandler: TransformHandler,
) {}
shouldTransform(): boolean {
return false;
}
tick() {
if (!this.game.config().userSettings()?.fxLayer()) {
return;
}
const updates = this.game.updatesSinceLastTick();
if (!updates) return;
updates[GameUpdateType.Unit]?.forEach((unit) => {
const unitView = this.game.unit(unit.id);
if (!unitView) return;
this.onUnitEvent(unitView);
});
updates[GameUpdateType.BonusEvent]?.forEach((bonusEvent) => {
if (bonusEvent === undefined) return;
this.onBonusEvent(bonusEvent);
});
updates[GameUpdateType.ConquestEvent]?.forEach((update) => {
if (update === undefined) return;
this.onConquestEvent(update);
});
}
onBonusEvent(bonus: BonusEventUpdate) {
// Only display text fx for the current player
if (this.game.player(bonus.player) !== this.game.myPlayer()) {
return;
}
const tile = bonus.tile;
const x = this.game.x(tile);
let y = this.game.y(tile) + TEXT_OFFSET_Y;
const gold = bonus.gold;
const troops = bonus.troops;
if (gold !== 0) {
this.addNumber(gold, x, y, 1000, 10);
y += TEXT_STACK_SPACING; // increase y so the next popup starts below
}
if (troops !== 0) {
this.addNumber(troops, x, y, 1000, 10);
}
}
onConquestEvent(conquest: ConquestUpdate) {
// Only display text for the current player
const conqueror = this.game.player(conquest.conquerorId);
if (conqueror !== this.game.myPlayer()) {
return;
}
const nameLocation = this.game.player(conquest.conqueredId).nameLocation();
const x = nameLocation.x;
const y = nameLocation.y;
this.addNumber(conquest.gold, x, y + 8, TEXT_DURATION, 0);
}
onUnitEvent(unit: UnitView) {
switch (unit.type()) {
case UnitType.HydrogenBomb:
case UnitType.AtomBomb: {
this.onBombEvent(unit);
break;
}
case UnitType.TransportShip: {
this.onTransportShipEvent(unit);
break;
}
}
}
onBombEvent(unit: UnitView) {
if (this.createdThisTick(unit) && this.isOwnedByPlayer(unit)) {
const target = new NukeTelegraph(this.transformHandler, this.game, unit);
this.allElements.push(target);
}
}
onTransportShipEvent(unit: UnitView) {
if (this.createdThisTick(unit) && this.isOwnedByPlayer(unit)) {
const target = new NavalTarget(this.transformHandler, this.game, unit);
this.allElements.push(target);
}
}
renderLayer(context: CanvasRenderingContext2D) {
const now = Date.now();
const dt = now - this.lastRefresh;
this.lastRefresh = now;
if (this.game.config().userSettings()?.fxLayer()) {
this.renderAllTargets(context, dt);
}
}
renderAllTargets(context: CanvasRenderingContext2D, delta: number) {
for (let i = this.allElements.length - 1; i >= 0; i--) {
if (!this.allElements[i].render(context, delta)) {
this.allElements.splice(i, 1);
}
}
}
private isOwnedByPlayer(unit: UnitView): boolean {
const my = this.game.myPlayer();
return my !== null && unit.owner() === my;
}
private createdThisTick(unit: UnitView): boolean {
return unit.createdAt() === this.game.ticks();
}
private addNumber(
num: bigint | number,
x: number,
y: number,
duration: number,
riseDistance: number,
) {
if (BigInt(num) === 0n) return; // Don't show anything for 0
const absNum =
typeof num === "bigint" ? (num < 0n ? -num : num) : Math.abs(num);
const shortened = renderNumber(absNum, 0);
const sign = num >= 0 ? "+" : "-";
this.allElements.push(
new TextIndicator(
this.transformHandler,
`${sign} ${shortened}`,
x,
y,
duration,
riseDistance,
),
);
}
}
+5 -117
View File
@@ -1,22 +1,17 @@
import { Theme } from "../../../core/configuration/Config";
import { UnitType } from "../../../core/game/Game";
import {
BonusEventUpdate,
ConquestUpdate,
GameUpdateType,
RailroadUpdate,
} from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import SoundManager, { SoundEffect } from "../../sound/SoundManager";
import { renderNumber } from "../../Utils";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { conquestFxFactory } from "../fx/ConquestFx";
import { Fx, FxType } from "../fx/Fx";
import { NukeAreaFx } from "../fx/NukeAreaFx";
import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx";
import { SpriteFx } from "../fx/SpriteFx";
import { TargetFx } from "../fx/TargetFx";
import { TextFx } from "../fx/TextFx";
import { UnitExplosionFx } from "../fx/UnitExplosionFx";
import { Layer } from "./Layer";
export class FxLayer implements Layer {
@@ -30,8 +25,6 @@ export class FxLayer implements Layer {
new AnimatedSpriteLoader();
private allFx: Fx[] = [];
private boatTargetFxByUnitId: Map<number, TargetFx> = new Map();
private nukeTargetFxByUnitId: Map<number, NukeAreaFx> = new Map();
constructor(private game: GameView) {
this.theme = this.game.config().theme();
@@ -42,7 +35,9 @@ export class FxLayer implements Layer {
}
tick() {
this.manageBoatTargetFx();
if (!this.game.config().userSettings()?.fxLayer()) {
return;
}
this.game
.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
@@ -50,13 +45,6 @@ export class FxLayer implements Layer {
if (unitView === undefined) return;
this.onUnitEvent(unitView);
});
this.game
.updatesSinceLastTick()
?.[GameUpdateType.BonusEvent]?.forEach((bonusEvent) => {
if (bonusEvent === undefined) return;
this.onBonusEvent(bonusEvent);
});
this.game
.updatesSinceLastTick()
?.[GameUpdateType.RailroadEvent]?.forEach((update) => {
@@ -71,100 +59,9 @@ export class FxLayer implements Layer {
});
}
private manageBoatTargetFx() {
// End markers for boats that arrived or retreated
for (const [unitId, fx] of Array.from(
this.boatTargetFxByUnitId.entries(),
)) {
const unit = this.game.unit(unitId);
if (
!unit ||
!unit.isActive() ||
unit.reachedTarget() ||
unit.retreating()
) {
(fx as any).end?.();
this.boatTargetFxByUnitId.delete(unitId);
}
}
}
// Register a persistent nuke target marker for the current player or teammates
private createNukeTargetFxIfOwned(unit: UnitView) {
const my = this.game.myPlayer();
if (!my) return;
// Show nuke marker owned by the player or by players on the same team
if (
(unit.owner() === my || my.isOnSameTeam(unit.owner())) &&
unit.isActive()
) {
if (!this.nukeTargetFxByUnitId.has(unit.id())) {
const t = unit.targetTile();
if (t !== undefined) {
const x = this.game.x(t);
const y = this.game.y(t);
const fx = new NukeAreaFx(
x,
y,
this.game.config().nukeMagnitudes(unit.type()),
);
this.allFx.push(fx);
this.nukeTargetFxByUnitId.set(unit.id(), fx);
}
}
}
}
onBonusEvent(bonus: BonusEventUpdate) {
if (this.game.player(bonus.player) !== this.game.myPlayer()) {
// Only display text fx for the current player
return;
}
const tile = bonus.tile;
const x = this.game.x(tile);
let y = this.game.y(tile);
const gold = bonus.gold;
const troops = bonus.troops;
if (gold > 0) {
const shortened = renderNumber(gold, 0);
this.addTextFx(`+ ${shortened}`, x, y);
y += 10; // increase y so the next popup starts below
}
if (troops > 0) {
const shortened = renderNumber(troops, 0);
this.addTextFx(`+ ${shortened} troops`, x, y);
y += 10;
}
}
addTextFx(text: string, x: number, y: number) {
const textFx = new TextFx(text, x, y, 1000, 20);
this.allFx.push(textFx);
}
onUnitEvent(unit: UnitView) {
switch (unit.type()) {
case UnitType.TransportShip: {
const my = this.game.myPlayer();
if (!my) return;
if (unit.owner() !== my) return;
if (!unit.isActive() || unit.retreating()) return;
if (this.boatTargetFxByUnitId.has(unit.id())) return;
const t = unit.targetTile();
if (t !== undefined) {
const x = this.game.x(t);
const y = this.game.y(t);
// persistent until boat finishes or retreats
const fx = new TargetFx(x, y, 0, true);
this.allFx.push(fx);
this.boatTargetFxByUnitId.set(unit.id(), fx);
}
break;
}
case UnitType.AtomBomb: {
this.createNukeTargetFxIfOwned(unit);
this.onNukeEvent(unit, 70);
break;
}
@@ -172,7 +69,6 @@ export class FxLayer implements Layer {
this.onNukeEvent(unit, 70);
break;
case UnitType.HydrogenBomb: {
this.createNukeTargetFxIfOwned(unit);
this.onNukeEvent(unit, 160);
break;
}
@@ -256,12 +152,9 @@ export class FxLayer implements Layer {
SoundManager.playSoundEffect(SoundEffect.KaChing);
const conquestFx = conquestFxFactory(
this.animatedSpriteLoader,
conquest,
this.game,
this.allFx.push(
conquestFxFactory(this.animatedSpriteLoader, conquest, this.game),
);
this.allFx = this.allFx.concat(conquestFx);
}
onWarshipEvent(unit: UnitView) {
@@ -304,11 +197,6 @@ export class FxLayer implements Layer {
onNukeEvent(unit: UnitView, radius: number) {
if (!unit.isActive()) {
const fx = this.nukeTargetFxByUnitId.get(unit.id());
if (fx) {
fx.end();
this.nukeTargetFxByUnitId.delete(unit.id());
}
if (!unit.reachedTarget()) {
this.handleSAMInterception(unit);
} else {
+131
View File
@@ -0,0 +1,131 @@
import { Cell, UnitType } from "src/core/game/Game";
import { GameView, UnitView } from "src/core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { UIElement } from "./UIElement";
const BASE_ALPHA = 0.9;
const SHADOW_OFFSET_Y = 2;
/**
* Draw a simple zoom-aware target
*/
export class Target implements UIElement {
private offset = 0;
private readonly rotationSpeed = 20;
private readonly dashSize: number;
private readonly outerRadius: number;
private readonly cell: Cell;
private readonly animationDuration = 150;
private animationElapsedTime = 0;
protected ended: boolean = false;
protected lifeTime: number = 0;
constructor(
private transformHandler: TransformHandler,
public x: number,
public y: number,
private radius: number,
) {
this.outerRadius = radius * 2 - 4;
// 2 dashes per circle, with a 10 pixel gap
this.dashSize = Math.PI * this.outerRadius - 10;
this.cell = new Cell(this.x + 0.5, this.y + 0.5);
}
render(ctx: CanvasRenderingContext2D, delta: number): boolean {
this.lifeTime += delta;
if (this.ended) {
this.animationElapsedTime += delta;
if (this.animationElapsedTime >= this.animationDuration) return false;
}
let t: number;
if (this.ended) {
// end animation
t = Math.max(0, 1 - this.lifeTime / this.animationDuration);
} else {
t = 1; // No start fade feels more reactive
}
const alpha = Math.max(0, Math.min(1, BASE_ALPHA * t));
const screenPos = this.transformHandler.worldToScreenCoordinates(this.cell);
screenPos.x = Math.round(screenPos.x);
screenPos.y = Math.round(screenPos.y);
const transformScale = this.transformHandler.scale;
const scale = transformScale > 10 ? 1 + (transformScale - 10) / 10 : 1;
this.offset += this.rotationSpeed * (delta / 1000);
ctx.save();
ctx.globalAlpha = alpha;
ctx.lineWidth = 1;
ctx.strokeStyle = `rgba(255,0,0,${alpha})`;
this.drawInnerRing(ctx, screenPos.x, screenPos.y, scale);
this.drawOuterRing(ctx, screenPos.x, screenPos.y, scale);
ctx.restore();
return true;
}
private drawInnerRing(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
scale: number,
) {
ctx.beginPath();
ctx.lineWidth = 2;
ctx.lineDashOffset = this.offset * scale;
ctx.setLineDash([8 * scale, 8 * scale]);
ctx.arc(x, y, this.radius * scale, 0, Math.PI * 2);
ctx.stroke();
}
private drawOuterRing(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
scale: number,
) {
ctx.beginPath();
ctx.lineWidth = 4 * scale;
ctx.lineDashOffset = (-this.offset / 2) * scale;
ctx.setLineDash([this.dashSize * scale, 10 * scale]);
ctx.arc(x, y, this.outerRadius * scale, 0, Math.PI * 2);
ctx.stroke();
// Small shadow under the outer circle
ctx.beginPath();
ctx.strokeStyle = `rgba(0,0,0,0.2)`;
ctx.arc(x, y + SHADOW_OFFSET_Y, this.outerRadius * scale, 0, Math.PI * 2);
ctx.stroke();
}
}
/**
* Bind a target to a naval invasion
*/
export class NavalTarget extends Target {
constructor(
transformHandler: TransformHandler,
readonly game: GameView,
private unit: UnitView,
) {
const tile = unit.targetTile();
if (tile === undefined) {
throw new Error("NavalTarget requires a target tile");
}
super(transformHandler, game.x(tile), game.y(tile), 10);
}
render(ctx: CanvasRenderingContext2D, delta: number): boolean {
if (
!this.ended &&
(!this.unit.isActive() ||
(this.unit.type() === UnitType.TransportShip && this.unit.retreating()))
) {
this.ended = true;
}
return super.render(ctx, delta);
}
}
+113
View File
@@ -0,0 +1,113 @@
import { Cell } from "src/core/game/Game";
import { GameView, UnitView } from "src/core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { UIElement } from "./UIElement";
const OUTER_EXPAND = 20;
const FILL_ALPHA_OFFSET = 0.6;
/**
* Draw an area with two disks
*/
export class CircleArea implements UIElement {
private offset = 0;
private readonly dashSize: number;
private readonly rotationSpeed = 20;
private readonly baseAlpha = 0.9;
private readonly cell: Cell;
private readonly animationDuration = 150;
protected ended: boolean = false;
protected lifeTime: number = 0;
constructor(
private transformHandler: TransformHandler,
public x: number,
public y: number,
private innerDiameter: number,
private outerDiameter: number,
) {
this.cell = new Cell(this.x + 0.5, this.y + 0.5);
// Compute a dash length that produces N dashes around the circle
const numDash = Math.max(1, Math.floor(this.outerDiameter / 3));
this.dashSize = (Math.PI / numDash) * this.outerDiameter;
}
render(ctx: CanvasRenderingContext2D, delta: number): boolean {
this.lifeTime += delta;
if (this.ended && this.lifeTime >= this.animationDuration) return false;
let t: number;
if (this.ended) {
t = Math.max(0, 1 - this.lifeTime / this.animationDuration);
} else {
t = Math.min(1, this.lifeTime / this.animationDuration);
}
const alpha = Math.max(0, Math.min(1, this.baseAlpha * t));
const scale = this.transformHandler.scale;
const innerDiameter =
(this.innerDiameter / 2) * (1 - t) + this.innerDiameter * t;
const screenPos = this.transformHandler.worldToScreenCoordinates(this.cell);
screenPos.x = Math.round(screenPos.x);
screenPos.y = Math.round(screenPos.y);
ctx.save();
ctx.globalAlpha = alpha;
ctx.lineWidth = 2;
ctx.strokeStyle = `rgba(255,0,0,${alpha})`;
ctx.fillStyle = `rgba(255,0,0,${Math.max(0, alpha - FILL_ALPHA_OFFSET)})`;
// Inner circle
ctx.beginPath();
ctx.lineWidth = 1;
ctx.arc(screenPos.x, screenPos.y, innerDiameter * scale, 0, Math.PI * 2);
ctx.stroke();
ctx.fill();
// Outer circle
this.offset += this.rotationSpeed * (delta / 1000);
ctx.beginPath();
ctx.strokeStyle = `rgba(255,0,0,${alpha})`;
ctx.lineWidth = Math.max(2, 1 * scale);
ctx.lineDashOffset = this.offset * scale;
ctx.setLineDash([this.dashSize * scale]);
const outerDiameter =
(this.outerDiameter + OUTER_EXPAND) * (1 - t) + this.outerDiameter * t;
ctx.arc(screenPos.x, screenPos.y, outerDiameter * scale, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
return true;
}
}
/**
* Bind a nuke destination to an area
*/
export class NukeTelegraph extends CircleArea {
constructor(
transformHandler: TransformHandler,
private readonly game: GameView,
private nuke: UnitView,
) {
const tile = nuke.targetTile();
if (tile === undefined) {
throw new Error("NukeArea requires a target tile");
}
const magnitude = game.config().nukeMagnitudes(nuke.type());
super(
transformHandler,
game.x(tile),
game.y(tile),
magnitude.inner,
magnitude.outer,
);
}
render(ctx: CanvasRenderingContext2D, delta: number): boolean {
if (!this.ended && !this.nuke.isActive()) {
this.ended = true;
this.lifeTime = 0; // reset lifetime to reuse animation logic
}
return super.render(ctx, delta);
}
}
+58
View File
@@ -0,0 +1,58 @@
import { Cell } from "src/core/game/Game";
import { TransformHandler } from "../TransformHandler";
import { UIElement } from "./UIElement";
const MIN_TEXT_ZOOM = 1.1;
export class TextIndicator implements UIElement {
private fontSize: number = 8;
private font: string = "Overpass, sans-serif";
private cell: Cell;
private lifeTime: number = 0;
constructor(
private transformHandler: TransformHandler,
private text: string,
public x: number,
public y: number,
private duration: number,
private riseDistance: number = 15,
private color: { r: number; g: number; b: number } = {
r: 255,
g: 255,
b: 255,
},
) {
this.cell = new Cell(this.x + 0.5, this.y + 0.5);
}
render(ctx: CanvasRenderingContext2D, delta: number): boolean {
this.lifeTime += delta;
if (this.lifeTime >= this.duration) {
return false;
}
const transformScale = this.transformHandler.scale;
if (transformScale < MIN_TEXT_ZOOM) {
// Reduce visual noise when dezoomed enough
return true;
}
const screenPos = this.transformHandler.worldToScreenCoordinates(this.cell);
screenPos.x = Math.round(screenPos.x);
screenPos.y = Math.round(screenPos.y);
const size = Math.round(this.fontSize * transformScale);
const t = this.lifeTime / this.duration;
const currentY = screenPos.y - t * this.riseDistance * transformScale;
const alpha = Math.max(0, 1 - t);
ctx.save();
ctx.font = `${size}px ${this.font}`;
ctx.fillStyle = `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, ${alpha})`;
ctx.textAlign = "center";
ctx.fillText(this.text, screenPos.x, currentY);
ctx.restore();
return true;
}
}
+5
View File
@@ -0,0 +1,5 @@
export interface UIElement {
x: number;
y: number;
render(ctx: CanvasRenderingContext2D, delta: number): boolean;
}
+1 -6
View File
@@ -140,14 +140,9 @@ export class TransportShipExecution implements Execution {
this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, {
troops: this.startTroops,
targetTile: this.dst ?? undefined,
});
if (this.dst !== null) {
this.boat.setTargetTile(this.dst);
} else {
this.boat.setTargetTile(undefined);
}
// Notify the target player about the incoming naval invasion
if (this.targetID && this.targetID !== mg.terraNullius().id()) {
mg.displayIncomingUnit(
+1 -1
View File
@@ -262,7 +262,7 @@ export type TrajectoryTile = {
export interface UnitParamsMap {
[UnitType.TransportShip]: {
troops?: number;
destination?: TileRef;
targetTile?: TileRef;
};
[UnitType.Warship]: {