diff --git a/resources/lang/en.json b/resources/lang/en.json index 9b415498e..409ec3c05 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index cfd32bff7..3e3b4bb3c 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -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, diff --git a/src/client/graphics/layers/TroopAdvantageLayer.ts b/src/client/graphics/layers/AttackingTroopsOverlay.ts similarity index 66% rename from src/client/graphics/layers/TroopAdvantageLayer.ts rename to src/client/graphics/layers/AttackingTroopsOverlay.ts index 683b430f2..60be22c57 100644 --- a/src/client/graphics/layers/TroopAdvantageLayer.ts +++ b/src/client/graphics/layers/AttackingTroopsOverlay.ts @@ -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(); + // 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; diff --git a/src/client/graphics/layers/AttacksDisplay.ts b/src/client/graphics/layers/AttacksDisplay.ts index b76c435b7..3a57a2b93 100644 --- a/src/client/graphics/layers/AttacksDisplay.ts +++ b/src/client/graphics/layers/AttacksDisplay.ts @@ -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, ); diff --git a/src/client/graphics/layers/SettingsModal.ts b/src/client/graphics/layers/SettingsModal.ts index 3da7325ba..8a285a661 100644 --- a/src/client/graphics/layers/SettingsModal.ts +++ b/src/client/graphics/layers/SettingsModal.ts @@ -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 {