Rename to AttackingTroopsOverlay, label transitions

This commit is contained in:
ralfisalhon
2026-03-17 05:43:00 +00:00
parent e101f92fda
commit 59c6f4297f
14 changed files with 167 additions and 127 deletions
+2 -2
View File
@@ -537,8 +537,8 @@
"territory_patterns_desc": "Choose whether to display territory skin designs in game",
"coordinate_grid_label": "Coordinate Grid",
"coordinate_grid_desc": "Toggle the alphanumeric grid overlay",
"troop_advantage_label": "Troop Advantage Display",
"troop_advantage_desc": "Show attacker vs defender troop counts on active front lines.",
"attacking_troops_overlay_label": "Attacking Troops Overlay",
"attacking_troops_overlay_desc": "Show attacker vs defender troop counts on active front lines.",
"performance_overlay_label": "Performance Overlay",
"performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.",
"easter_writing_speed_label": "Writing Speed Multiplier",
+2 -2
View File
@@ -7,6 +7,7 @@ import { FrameProfiler } from "./FrameProfiler";
import { TransformHandler } from "./TransformHandler";
import { UIState } from "./UIState";
import { AlertFrame } from "./layers/AlertFrame";
import { AttackingTroopsOverlay } from "./layers/AttackingTroopsOverlay";
import { AttacksDisplay } from "./layers/AttacksDisplay";
import { BuildMenu } from "./layers/BuildMenu";
import { ChatDisplay } from "./layers/ChatDisplay";
@@ -42,7 +43,6 @@ import { StructureLayer } from "./layers/StructureLayer";
import { TeamStats } from "./layers/TeamStats";
import { TerrainLayer } from "./layers/TerrainLayer";
import { TerritoryLayer } from "./layers/TerritoryLayer";
import { TroopAdvantageLayer } from "./layers/TroopAdvantageLayer";
import { UILayer } from "./layers/UILayer";
import { UnitDisplay } from "./layers/UnitDisplay";
import { UnitLayer } from "./layers/UnitLayer";
@@ -293,8 +293,8 @@ export function createRenderer(
new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState),
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
new DynamicUILayer(game, transformHandler, eventBus),
new TroopAdvantageLayer(game, transformHandler, eventBus, userSettings),
new NameLayer(game, transformHandler, eventBus),
new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings),
eventsDisplay,
attacksDisplay,
chatDisplay,
@@ -21,7 +21,8 @@ export function troopDefenceColor(
return attackerTroops > myTroops ? "#ff4444" : "#ff9944";
}
// One label element per disconnected cluster of front-line tiles
// An attack can have multiple disconnected front-line segments, so elements
// and positions are parallel arrays with one entry per segment.
interface AttackLabel {
elements: HTMLDivElement[];
positions: (Cell | null)[];
@@ -30,9 +31,10 @@ interface AttackLabel {
defenderTroops: number;
}
export class TroopAdvantageLayer implements Layer {
export class AttackingTroopsOverlay implements Layer {
private container: HTMLDivElement;
private labels = new Map<string, AttackLabel>();
// Guard against queuing multiple worker requests in the same tick window.
private inFlightRequest = false;
private isVisible = true;
private onAlternateView: (e: AlternateViewEvent) => void;
@@ -49,11 +51,14 @@ export class TroopAdvantageLayer implements Layer {
}
init() {
// The container is anchored at the viewport centre (50%, 50%) so that
// label transforms can use raw world coordinates without an extra offset.
this.container = document.createElement("div");
this.container.style.position = "fixed";
this.container.style.left = "50%";
this.container.style.top = "50%";
this.container.style.pointerEvents = "none";
// z-index 4 places labels above NameLayer (z-index 3).
this.container.style.zIndex = "4";
document.body.appendChild(this.container);
@@ -76,7 +81,7 @@ export class TroopAdvantageLayer implements Layer {
}
tick() {
if (!this.userSettings.troopAdvantageLayer() || !this.isVisible) {
if (!this.userSettings.attackingTroopsOverlay() || !this.isVisible) {
if (this.labels.size > 0) this.clearAllLabels();
return;
}
@@ -94,15 +99,15 @@ export class TroopAdvantageLayer implements Layer {
...incoming.map((a) => a.id),
]);
// Remove labels for attacks that no longer exist
for (const [id] of this.labels) {
if (!activeIDs.has(id)) this.removeLabel(id);
}
const myTroops = myPlayer.troops();
// Outgoing attacks — green if winning, amber if losing
// Outgoing attacks — green if winning, amber if losing.
for (const attack of outgoing) {
// targetID === 0 means the attack is targeting sea/empty tiles; skip it.
if (!attack.targetID) {
this.removeLabel(attack.id);
continue;
@@ -115,7 +120,7 @@ export class TroopAdvantageLayer implements Layer {
this.ensureLabel(attack.id, attack.troops, defender.troops(), false);
}
// Incoming attacks — red if attacker > my troops, orange if attacker < my troops
// Incoming attacks — red if the attacker outnumbers my troops, orange otherwise.
for (const attack of incoming) {
const attacker = this.game.playerBySmallID(attack.attackerID);
if (!attacker || !attacker.isPlayer()) {
@@ -125,38 +130,21 @@ export class TroopAdvantageLayer implements Layer {
this.ensureLabel(attack.id, attack.troops, myTroops, true);
}
// Single request per tick for all attack cluster positions
// Single worker request per tick; skip if the previous one is still in flight.
if (this.inFlightRequest) return;
this.inFlightRequest = true;
void myPlayer
.attackClusterPositions(myPlayer.smallID())
.attackFrontLinePositions()
.then((attacks) => {
for (const { id, clusters } of attacks) {
for (const { id, centers } of attacks) {
const lbl = this.labels.get(id);
if (!lbl) continue;
while (lbl.elements.length < clusters.length) {
lbl.elements.push(
this.createLabelElement(
lbl.attackerTroops,
lbl.defenderTroops,
lbl.isIncoming,
),
);
lbl.positions.push(null);
}
while (lbl.elements.length > clusters.length) {
lbl.elements.pop()!.remove();
lbl.positions.pop();
}
for (let i = 0; i < clusters.length; i++) {
lbl.positions[i] = clusters[i];
}
this.reconcileLabelPositions(lbl, centers);
}
})
.catch(() => {
// On error, hide all labels until the next successful response.
for (const lbl of this.labels.values()) lbl.positions.fill(null);
})
.finally(() => {
@@ -210,11 +198,71 @@ export class TroopAdvantageLayer implements Layer {
}
el.style.display = "block";
// Centre the label on its world position and counter-scale so text
// stays the same screen size regardless of zoom level.
el.style.transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`;
}
}
}
// Assign each existing label element to the new center closest to its current
// position (greedy nearest-neighbour matching). This prevents labels from
// swapping front-line segments when their relative sizes change between ticks,
// which would otherwise cause visible jumping.
private reconcileLabelPositions(lbl: AttackLabel, centers: Cell[]) {
const availableCenterIndexes = centers.map((_, i) => i);
const updatedPositions: (Cell | null)[] = [];
for (
let elementIndex = 0;
elementIndex < lbl.elements.length && availableCenterIndexes.length > 0;
elementIndex++
) {
const currentPos = lbl.positions[elementIndex];
if (!currentPos) {
// Element has no position yet — assign the first available center.
updatedPositions.push(centers[availableCenterIndexes.shift()!]);
continue;
}
// Find the available center closest to this element's current position.
let closestCenterAt = 0;
let closestDistance = Infinity;
for (let i = 0; i < availableCenterIndexes.length; i++) {
const candidate = centers[availableCenterIndexes[i]];
const dx = candidate.x - currentPos.x;
const dy = candidate.y - currentPos.y;
const squaredDistance = dx * dx + dy * dy;
if (squaredDistance < closestDistance) {
closestDistance = squaredDistance;
closestCenterAt = i;
}
}
updatedPositions.push(
centers[availableCenterIndexes.splice(closestCenterAt, 1)[0]],
);
}
// Create new label elements for centers that had no existing element to match.
for (const centerIndex of availableCenterIndexes) {
lbl.elements.push(
this.createLabelElement(
lbl.attackerTroops,
lbl.defenderTroops,
lbl.isIncoming,
),
);
updatedPositions.push(centers[centerIndex]);
}
// Remove elements for front-line segments that no longer exist.
while (lbl.elements.length > updatedPositions.length) {
lbl.elements.pop()!.remove();
}
lbl.positions = updatedPositions;
}
private createLabelElement(
attackerTroops: number,
defenderTroops: number,
@@ -232,6 +280,8 @@ export class TroopAdvantageLayer implements Layer {
el.style.backgroundColor = "rgba(0,0,0,0.55)";
el.style.pointerEvents = "none";
el.style.lineHeight = "1.3";
// Smooth the label to its new position as the front line advances.
el.style.transition = "transform 0.2s ease-out";
this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming);
this.container.appendChild(el);
return el;
@@ -185,7 +185,6 @@ export class AttacksDisplay extends LitElement implements Layer {
if (playerView !== undefined) {
if (playerView instanceof PlayerView) {
const averagePosition = await playerView.attackAveragePosition(
attack.attackerID,
attack.id,
);
+8 -6
View File
@@ -164,8 +164,8 @@ export class SettingsModal extends LitElement implements Layer {
this.requestUpdate();
}
private onToggleTroopAdvantageLayerButtonClick() {
this.userSettings.toggleTroopAdvantageLayer();
private onToggleAttackingTroopsOverlayButtonClick() {
this.userSettings.toggleAttackingTroopsOverlay();
this.requestUpdate();
}
@@ -416,19 +416,21 @@ export class SettingsModal extends LitElement implements Layer {
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleTroopAdvantageLayerButtonClick}"
@click="${this.onToggleAttackingTroopsOverlayButtonClick}"
>
<img src=${swordIcon} alt="swordIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.troop_advantage_label")}
${translateText(
"user_setting.attacking_troops_overlay_label",
)}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.troop_advantage_desc")}
${translateText("user_setting.attacking_troops_overlay_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.troopAdvantageLayer()
${this.userSettings.attackingTroopsOverlay()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
+14 -4
View File
@@ -254,10 +254,10 @@ export class GameRunner {
} as PlayerBorderTiles;
}
public attackClusterPositions(
public attackFrontLinePositions(
playerID: number,
attackID?: string,
): { id: string; clusters: { x: number; y: number }[] }[] {
): { id: string; centers: { x: number; y: number }[] }[] {
const player = this.game.playerBySmallID(playerID);
if (!player.isPlayer()) {
throw new Error(`player with id ${playerID} not found`);
@@ -271,7 +271,13 @@ export class GameRunner {
? allAttacks.filter((a) => a.id() === attackID)
: allAttacks;
return attacks.map((a) => ({ id: a.id(), clusters: a.clusterPositions() }));
return attacks.map((a) => ({
id: a.id(),
centers: a.frontLinePositions().map((tile) => ({
x: this.game.map().x(tile),
y: this.game.map().y(tile),
})),
}));
}
public attackAveragePosition(
@@ -291,7 +297,11 @@ export class GameRunner {
return null;
}
return attack.averagePosition();
// Use the largest front line's representative tile (index 0, sorted by frontLinePositions)
const tiles = attack.frontLinePositions();
if (tiles.length === 0) return null;
const tile = tiles[0];
return new Cell(this.game.map().x(tile), this.game.map().y(tile));
}
public bestTransportShipSpawn(
+27 -44
View File
@@ -1,4 +1,4 @@
import { Attack, Cell, Player, TerraNullius } from "./Game";
import { Attack, Player, TerraNullius } from "./Game";
import { GameImpl } from "./GameImpl";
import { TileRef } from "./GameMap";
import { PlayerImpl } from "./PlayerImpl";
@@ -97,19 +97,17 @@ export class AttackImpl implements Attack {
}
}
clusterPositions(): Cell[] {
// Minimum border tiles for a cluster to get its own label.
// Clusters smaller than this are suppressed (except we always keep the largest).
const MIN_CLUSTER_SIZE = 30;
frontLinePositions(): TileRef[] {
if (this._borderSize === 0) {
const avg = this.averagePosition();
return avg ? [avg] : [];
const tile = this.sourceTile();
return tile !== null ? [tile] : [];
}
// Segments smaller than this are suppressed; the largest is always kept.
const MIN_FRONT_LINE_LENGTH = 30;
const map = this._mg.map();
const visited = new Set<TileRef>();
const clusters: { centroid: Cell; size: number }[] = [];
const clusters: { representative: TileRef; size: number }[] = [];
for (const startTile of this._border) {
if (visited.has(startTile)) continue;
@@ -143,44 +141,29 @@ export class AttackImpl implements Attack {
}
}
clusters.push({
centroid: new Cell(sumX / count, sumY / count),
size: count,
});
}
// Keep only clusters above the minimum size.
// Always keep the largest cluster so there's at least one label.
const significant = clusters.filter((c) => c.size >= MIN_CLUSTER_SIZE);
if (significant.length === 0) {
const largest = clusters.reduce((a, b) => (b.size > a.size ? b : a));
return [largest.centroid];
}
return significant.map((c) => c.centroid);
}
averagePosition(): Cell | null {
if (this._borderSize === 0) {
if (this.sourceTile() === null) {
// No border tiles and no source tile—return a default position or throw an error
return null;
// Pick the border tile nearest to the cluster centroid as representative
const cx = sumX / count;
const cy = sumY / count;
let best = queue[0];
let bestDist = Infinity;
for (const tile of queue) {
const dx = map.x(tile) - cx;
const dy = map.y(tile) - cy;
const dist = dx * dx + dy * dy;
if (dist < bestDist) {
bestDist = dist;
best = tile;
}
}
// No border tiles yet—use the source tile's location
const tile: number = this.sourceTile()!;
return new Cell(this._mg.map().x(tile), this._mg.map().y(tile));
clusters.push({ representative: best, size: count });
}
let averageX = 0;
let averageY = 0;
// Sort largest first so index 0 is always the main front line
clusters.sort((a, b) => b.size - a.size);
for (const t of this._border) {
averageX += this._mg.map().x(t);
averageY += this._mg.map().y(t);
}
averageX = averageX / this._borderSize;
averageY = averageY / this._borderSize;
return new Cell(averageX, averageY);
// Keep only clusters above the minimum size; always keep at least the largest
const significant = clusters.filter((c) => c.size >= MIN_FRONT_LINE_LENGTH);
const kept = significant.length > 0 ? significant : [clusters[0]];
return kept.map((c) => c.representative);
}
}
+1 -2
View File
@@ -470,8 +470,7 @@ export interface Attack {
removeBorderTile(tile: TileRef): void;
clearBorder(): void;
borderSize(): number;
averagePosition(): Cell | null;
clusterPositions(): Cell[];
frontLinePositions(): TileRef[];
}
export interface AllianceRequest {
+5 -9
View File
@@ -453,18 +453,14 @@ export class PlayerView {
return this.data.incomingAttacks;
}
async attackAveragePosition(
playerID: number,
attackID: string,
): Promise<Cell | null> {
return this.game.worker.attackAveragePosition(playerID, attackID);
async attackAveragePosition(attackID: string): Promise<Cell | null> {
return this.game.worker.attackAveragePosition(this.smallID(), attackID);
}
async attackClusterPositions(
playerID: number,
async attackFrontLinePositions(
attackID?: string,
): Promise<{ id: string; clusters: Cell[] }[]> {
return this.game.worker.attackClusterPositions(playerID, attackID);
): Promise<{ id: string; centers: Cell[] }[]> {
return this.game.worker.attackFrontLinePositions(this.smallID(), attackID);
}
units(...types: UnitType[]): UnitView[] {
+4 -4
View File
@@ -89,12 +89,12 @@ export class UserSettings {
return this.get("settings.territoryPatterns", true);
}
troopAdvantageLayer() {
return this.get("settings.troopAdvantageLayer", true);
attackingTroopsOverlay() {
return this.get("settings.attackingTroopsOverlay", true);
}
toggleTroopAdvantageLayer() {
this.set("settings.troopAdvantageLayer", !this.troopAdvantageLayer());
toggleAttackingTroopsOverlay() {
this.set("settings.attackingTroopsOverlay", !this.attackingTroopsOverlay());
}
cursorCostLabel() {
+8 -8
View File
@@ -4,7 +4,7 @@ import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import {
AttackAveragePositionResultMessage,
AttackClusterPositionsResultMessage,
AttackFrontLinePositionsResultMessage,
InitializedMessage,
MainThreadMessage,
PlayerActionsResultMessage,
@@ -265,28 +265,28 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
throw error;
}
break;
case "attack_cluster_positions":
case "attack_front_line_positions":
if (!gameRunner) {
throw new Error("Game runner not initialized");
}
try {
const attacks = (await gameRunner).attackClusterPositions(
const attacks = (await gameRunner).attackFrontLinePositions(
message.playerID,
message.attackID,
);
sendMessage({
type: "attack_cluster_positions_result",
type: "attack_front_line_positions_result",
id: message.id,
attacks,
} as AttackClusterPositionsResultMessage);
} as AttackFrontLinePositionsResultMessage);
} catch (error) {
console.error("Failed to get attack cluster positions:", error);
console.error("Failed to get attack front line centers:", error);
sendMessage({
type: "attack_cluster_positions_result",
type: "attack_front_line_positions_result",
id: message.id,
attacks: [],
} as AttackClusterPositionsResultMessage);
} as AttackFrontLinePositionsResultMessage);
}
break;
case "transport_ship_spawn":
+7 -7
View File
@@ -266,10 +266,10 @@ export class WorkerClient {
});
}
attackClusterPositions(
attackFrontLinePositions(
playerID: number,
attackID?: string,
): Promise<{ id: string; clusters: Cell[] }[]> {
): Promise<{ id: string; centers: Cell[] }[]> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
reject(new Error("Worker not initialized"));
@@ -280,15 +280,15 @@ export class WorkerClient {
const timeout = setTimeout(() => {
this.messageHandlers.delete(messageId);
reject(new Error("attack_cluster_positions request timed out"));
reject(new Error("attack_front_line_positions request timed out"));
}, 5000);
this.messageHandlers.set(messageId, (message) => {
clearTimeout(timeout);
if (message.type !== "attack_cluster_positions_result") {
if (message.type !== "attack_front_line_positions_result") {
reject(
new Error(
`Unexpected message type for attackClusterPositions: ${message.type}`,
`Unexpected message type for attackFrontLinePositions: ${message.type}`,
),
);
return;
@@ -296,13 +296,13 @@ export class WorkerClient {
resolve(
message.attacks.map((a) => ({
id: a.id,
clusters: a.clusters.map((c) => new Cell(c.x, c.y)),
centers: a.centers.map((c) => new Cell(c.x, c.y)),
})),
);
});
this.worker.postMessage({
type: "attack_cluster_positions",
type: "attack_front_line_positions",
id: messageId,
playerID,
attackID,
+10 -9
View File
@@ -26,8 +26,8 @@ export type WorkerMessageType =
| "player_border_tiles_result"
| "attack_average_position"
| "attack_average_position_result"
| "attack_cluster_positions"
| "attack_cluster_positions_result"
| "attack_front_line_positions"
| "attack_front_line_positions_result"
| "transport_ship_spawn"
| "transport_ship_spawn_result";
@@ -122,15 +122,16 @@ export interface AttackAveragePositionResultMessage extends BaseWorkerMessage {
y: number | null;
}
export interface AttackClusterPositionsMessage extends BaseWorkerMessage {
type: "attack_cluster_positions";
export interface AttackFrontLinePositionsMessage extends BaseWorkerMessage {
type: "attack_front_line_positions";
playerID: number;
attackID?: string;
}
export interface AttackClusterPositionsResultMessage extends BaseWorkerMessage {
type: "attack_cluster_positions_result";
attacks: { id: string; clusters: { x: number; y: number }[] }[];
export interface AttackFrontLinePositionsResultMessage
extends BaseWorkerMessage {
type: "attack_front_line_positions_result";
attacks: { id: string; centers: { x: number; y: number }[] }[];
}
export interface TransportShipSpawnMessage extends BaseWorkerMessage {
@@ -153,7 +154,7 @@ export type MainThreadMessage =
| PlayerProfileMessage
| PlayerBorderTilesMessage
| AttackAveragePositionMessage
| AttackClusterPositionsMessage
| AttackFrontLinePositionsMessage
| TransportShipSpawnMessage;
// Message send from worker
@@ -166,5 +167,5 @@ export type WorkerMessage =
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage
| AttackAveragePositionResultMessage
| AttackClusterPositionsResultMessage
| AttackFrontLinePositionsResultMessage
| TransportShipSpawnResultMessage;
@@ -1,7 +1,7 @@
import {
troopAttackColor,
troopDefenceColor,
} from "../../../../src/client/graphics/layers/TroopAdvantageLayer";
} from "../../../../src/client/graphics/layers/AttackingTroopsOverlay";
describe("troopAttackColor", () => {
test("returns green when attacker has more troops", () => {