mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:50:42 +00:00
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  ### Nukes Has to scale because the size is set, but the border radius is not so the area is more visible from afar.  ### Popup text Scale with zoom level, and stop showing when zoomed-out:  ## 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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface UIElement {
|
||||
x: number;
|
||||
y: number;
|
||||
render(ctx: CanvasRenderingContext2D, delta: number): boolean;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -262,7 +262,7 @@ export type TrajectoryTile = {
|
||||
export interface UnitParamsMap {
|
||||
[UnitType.TransportShip]: {
|
||||
troops?: number;
|
||||
destination?: TileRef;
|
||||
targetTile?: TileRef;
|
||||
};
|
||||
|
||||
[UnitType.Warship]: {
|
||||
|
||||
Reference in New Issue
Block a user