diff --git a/src/client/graphics/layers/TroopAdvantageLayer.ts b/src/client/graphics/layers/TroopAdvantageLayer.ts index dcdc932c0..b435f51cf 100644 --- a/src/client/graphics/layers/TroopAdvantageLayer.ts +++ b/src/client/graphics/layers/TroopAdvantageLayer.ts @@ -1,16 +1,31 @@ import { EventBus } from "../../../core/EventBus"; import { Cell } from "../../../core/game/Game"; -import { GameView } from "../../../core/game/GameView"; +import { GameView, PlayerView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; import { AlternateViewEvent } from "../../InputHandler"; import { renderTroops } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; +export function troopAttackColor( + attackerTroops: number, + defenderTroops: number, +): string { + return attackerTroops > defenderTroops ? "#66ff66" : "#ffbe3c"; +} + +export function troopDefenceColor( + attackerTroops: number, + myTroops: number, +): string { + return attackerTroops > myTroops ? "#ff4444" : "#ff9944"; +} + +// One label element per disconnected cluster of front-line tiles interface AttackLabel { - attackID: string; - element: HTMLDivElement; - position: Cell | null; + elements: HTMLDivElement[]; + positions: (Cell | null)[]; + isIncoming: boolean; } export class TroopAdvantageLayer implements Layer { @@ -60,7 +75,7 @@ export class TroopAdvantageLayer implements Layer { tick() { if (!this.userSettings.troopAdvantageLayer()) { - this.clearAllLabels(); + if (this.labels.size > 0) this.clearAllLabels(); return; } @@ -70,66 +85,111 @@ export class TroopAdvantageLayer implements Layer { return; } - const attacks = myPlayer.outgoingAttacks(); - const activeIDs = new Set(attacks.map((a) => a.id)); + const outgoing = myPlayer.outgoingAttacks(); + const incoming = myPlayer.incomingAttacks(); + const activeIDs = new Set([ + ...outgoing.map((a) => a.id), + ...incoming.map((a) => a.id), + ]); // Remove labels for attacks that no longer exist - for (const [id, label] of this.labels) { - if (!activeIDs.has(id)) { - label.element.remove(); - this.labels.delete(id); - this.inFlightPositionRequests.delete(id); - } + for (const [id] of this.labels) { + if (!activeIDs.has(id)) this.removeLabel(id); } - // Update or create labels for active attacks - for (const attack of attacks) { - // Skip boat attacks (targetID === 0 means attacking sea/empty) + const myTroops = myPlayer.troops(); + + // Outgoing attacks — ⚔ green if winning, amber if losing + for (const attack of outgoing) { if (!attack.targetID) { this.removeLabel(attack.id); continue; } - const defender = this.game.playerBySmallID(attack.targetID); if (!defender || !defender.isPlayer()) { this.removeLabel(attack.id); continue; } - const attackerTroops = attack.troops; - const defenderTroops = defender.troops(); + this.processAttack( + myPlayer, + attack.id, + attack.attackerID, + attack.troops, + defender.troops(), + false, + ); + } - let label = this.labels.get(attack.id); - if (!label) { - const element = this.createLabelElement(attackerTroops, defenderTroops); - label = { attackID: attack.id, element, position: null }; - this.labels.set(attack.id, label); - } else { - this.updateLabelContent(label.element, attackerTroops, defenderTroops); + // Incoming attacks — red if attacker > my troops, orange if attacker < my troops + for (const attack of incoming) { + const attacker = this.game.playerBySmallID(attack.attackerID); + if (!attacker || !attacker.isPlayer()) { + this.removeLabel(attack.id); + continue; } - // Re-fetch position every tick so the label follows the moving front line - const attackID = attack.id; - if (this.inFlightPositionRequests.has(attackID)) continue; - this.inFlightPositionRequests.add(attackID); - - void myPlayer - .attackAveragePosition(attack.attackerID, attackID) - .then((pos) => { - const lbl = this.labels.get(attackID); - if (!lbl) return; - lbl.position = pos; // null hides stale label - }) - .catch(() => { - const lbl = this.labels.get(attackID); - if (lbl) lbl.position = null; - }) - .finally(() => { - this.inFlightPositionRequests.delete(attackID); - }); + this.processAttack( + myPlayer, + attack.id, + attack.attackerID, + attack.troops, + myTroops, + true, + ); } } + private processAttack( + myPlayer: PlayerView, + attackID: string, + attackerSmallID: number, + attackerTroops: number, + defenderTroops: number, + isIncoming: boolean, + ) { + let label = this.labels.get(attackID); + if (!label) { + label = { elements: [], positions: [], isIncoming }; + this.labels.set(attackID, label); + } + for (const el of label.elements) { + this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming); + } + + if (this.inFlightPositionRequests.has(attackID)) return; + this.inFlightPositionRequests.add(attackID); + + void myPlayer + .attackClusterPositions(attackerSmallID, attackID) + .then((clusters) => { + const lbl = this.labels.get(attackID); + if (!lbl) return; + + while (lbl.elements.length < clusters.length) { + lbl.elements.push( + this.createLabelElement(attackerTroops, defenderTroops, 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]; + } + }) + .catch(() => { + const lbl = this.labels.get(attackID); + if (lbl) lbl.positions.fill(null); + }) + .finally(() => { + this.inFlightPositionRequests.delete(attackID); + }); + } + renderLayer(_context: CanvasRenderingContext2D) { const screenPosOld = this.transformHandler.worldToScreenCoordinates( new Cell(0, 0), @@ -141,25 +201,25 @@ export class TroopAdvantageLayer implements Layer { this.container.style.transform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`; for (const label of this.labels.values()) { - if (!label.position) { - label.element.style.display = "none"; - continue; - } + for (let i = 0; i < label.elements.length; i++) { + const el = label.elements[i]; + const pos = label.positions[i]; - const isOnScreen = this.transformHandler.isOnScreen(label.position); - if (!isOnScreen) { - label.element.style.display = "none"; - continue; - } + if (!pos || !this.transformHandler.isOnScreen(pos)) { + el.style.display = "none"; + continue; + } - label.element.style.display = "block"; - label.element.style.transform = `translate(${label.position.x}px, ${label.position.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`; + el.style.display = "block"; + el.style.transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`; + } } } private createLabelElement( attackerTroops: number, defenderTroops: number, + isIncoming: boolean, ): HTMLDivElement { const el = document.createElement("div"); el.style.position = "absolute"; @@ -173,7 +233,7 @@ export class TroopAdvantageLayer implements Layer { el.style.backgroundColor = "rgba(0,0,0,0.55)"; el.style.pointerEvents = "none"; el.style.lineHeight = "1.3"; - this.updateLabelContent(el, attackerTroops, defenderTroops); + this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming); this.container.appendChild(el); return el; } @@ -182,37 +242,33 @@ export class TroopAdvantageLayer implements Layer { el: HTMLDivElement, attackerTroops: number, defenderTroops: number, + isIncoming: boolean, ) { - const atkStr = renderTroops(attackerTroops); - const defStr = renderTroops(defenderTroops); - const advantage = attackerTroops > defenderTroops; - - const atk = document.createElement("span"); - atk.style.color = advantage ? "#66ff66" : "#ff6666"; - atk.textContent = `⚔ ${atkStr}`; - - const vs = document.createElement("span"); - vs.style.color = "#aaa"; - vs.textContent = " vs "; - - const def = document.createElement("span"); - def.style.color = "#ff9944"; - def.textContent = defStr; - - el.replaceChildren(atk, vs, def); + const span = document.createElement("span"); + if (isIncoming) { + const icon = document.createElement("span"); + icon.textContent = "🛡 "; + span.style.color = troopDefenceColor(attackerTroops, defenderTroops); + span.textContent = renderTroops(attackerTroops); + el.replaceChildren(icon, span); + } else { + span.style.color = troopAttackColor(attackerTroops, defenderTroops); + span.textContent = `⚔ ${renderTroops(attackerTroops)}`; + el.replaceChildren(span); + } } private removeLabel(attackID: string) { const label = this.labels.get(attackID); if (!label) return; - label.element.remove(); + for (const el of label.elements) el.remove(); this.labels.delete(attackID); this.inFlightPositionRequests.delete(attackID); } private clearAllLabels() { for (const label of this.labels.values()) { - label.element.remove(); + for (const el of label.elements) el.remove(); } this.labels.clear(); this.inFlightPositionRequests.clear(); diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index ddf4da20d..e3742a7c8 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -254,6 +254,24 @@ export class GameRunner { } as PlayerBorderTiles; } + public attackClusterPositions( + playerID: number, + attackID: string, + ): { x: number; y: number }[] { + const player = this.game.playerBySmallID(playerID); + if (!player.isPlayer()) { + throw new Error(`player with id ${playerID} not found`); + } + + const condition = (a: Attack) => a.id() === attackID; + const attack = + player.outgoingAttacks().find(condition) ?? + player.incomingAttacks().find(condition); + if (attack === undefined) return []; + + return attack.clusterPositions(); + } + public attackAveragePosition( playerID: number, attackID: string, diff --git a/src/core/game/AttackImpl.ts b/src/core/game/AttackImpl.ts index 8b5fc811e..0a6b75f90 100644 --- a/src/core/game/AttackImpl.ts +++ b/src/core/game/AttackImpl.ts @@ -97,6 +97,68 @@ 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; + + if (this._borderSize === 0) { + const avg = this.averagePosition(); + return avg ? [avg] : []; + } + + const map = this._mg.map(); + const visited = new Set(); + const clusters: { centroid: Cell; size: number }[] = []; + + for (const startTile of this._border) { + if (visited.has(startTile)) continue; + + const queue: TileRef[] = [startTile]; + visited.add(startTile); + let qi = 0; + let sumX = 0; + let sumY = 0; + let count = 0; + + while (qi < queue.length) { + const tile = queue[qi++]; + const tx = map.x(tile); + const ty = map.y(tile); + sumX += tx; + sumY += ty; + count++; + + // 8-directional BFS so diagonal border tiles merge into one cluster + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + if (dx === 0 && dy === 0) continue; + if (!map.isValidCoord(tx + dx, ty + dy)) continue; + const neighbor = map.ref(tx + dx, ty + dy); + if (this._border.has(neighbor) && !visited.has(neighbor)) { + visited.add(neighbor); + queue.push(neighbor); + } + } + } + } + + 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) { diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index d77d46069..47aa04560 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -471,6 +471,7 @@ export interface Attack { clearBorder(): void; borderSize(): number; averagePosition(): Cell | null; + clusterPositions(): Cell[]; } export interface AllianceRequest { diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index d3e3ad87e..7215c6a31 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -460,6 +460,13 @@ export class PlayerView { return this.game.worker.attackAveragePosition(playerID, attackID); } + async attackClusterPositions( + playerID: number, + attackID: string, + ): Promise { + return this.game.worker.attackClusterPositions(playerID, attackID); + } + units(...types: UnitType[]): UnitView[] { return this.game .units(...types) diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 5f72c7554..b2add8455 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -4,6 +4,7 @@ import { FetchGameMapLoader } from "../game/FetchGameMapLoader"; import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates"; import { AttackAveragePositionResultMessage, + AttackClusterPositionsResultMessage, InitializedMessage, MainThreadMessage, PlayerActionsResultMessage, @@ -264,6 +265,30 @@ ctx.addEventListener("message", async (e: MessageEvent) => { throw error; } break; + case "attack_cluster_positions": + if (!gameRunner) { + throw new Error("Game runner not initialized"); + } + + try { + const clusters = (await gameRunner).attackClusterPositions( + message.playerID, + message.attackID, + ); + sendMessage({ + type: "attack_cluster_positions_result", + id: message.id, + clusters, + } as AttackClusterPositionsResultMessage); + } catch (error) { + console.error("Failed to get attack cluster positions:", error); + sendMessage({ + type: "attack_cluster_positions_result", + id: message.id, + clusters: [], + } as AttackClusterPositionsResultMessage); + } + break; case "transport_ship_spawn": if (!gameRunner) { throw new Error("Game runner not initialized"); diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index 372e4af96..8726e2a46 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -266,6 +266,30 @@ export class WorkerClient { }); } + attackClusterPositions(playerID: number, attackID: string): Promise { + return new Promise((resolve, reject) => { + if (!this.isInitialized) { + reject(new Error("Worker not initialized")); + return; + } + + const messageId = generateID(); + + this.messageHandlers.set(messageId, (message) => { + if (message.type === "attack_cluster_positions_result") { + resolve(message.clusters.map((c) => new Cell(c.x, c.y))); + } + }); + + this.worker.postMessage({ + type: "attack_cluster_positions", + id: messageId, + playerID, + attackID, + }); + }); + } + transportShipSpawn( playerID: PlayerID, targetTile: TileRef, diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index 7d75f1882..eef76eac5 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -26,6 +26,8 @@ export type WorkerMessageType = | "player_border_tiles_result" | "attack_average_position" | "attack_average_position_result" + | "attack_cluster_positions" + | "attack_cluster_positions_result" | "transport_ship_spawn" | "transport_ship_spawn_result"; @@ -120,6 +122,17 @@ export interface AttackAveragePositionResultMessage extends BaseWorkerMessage { y: number | null; } +export interface AttackClusterPositionsMessage extends BaseWorkerMessage { + type: "attack_cluster_positions"; + playerID: number; + attackID: string; +} + +export interface AttackClusterPositionsResultMessage extends BaseWorkerMessage { + type: "attack_cluster_positions_result"; + clusters: { x: number; y: number }[]; +} + export interface TransportShipSpawnMessage extends BaseWorkerMessage { type: "transport_ship_spawn"; playerID: PlayerID; @@ -140,6 +153,7 @@ export type MainThreadMessage = | PlayerProfileMessage | PlayerBorderTilesMessage | AttackAveragePositionMessage + | AttackClusterPositionsMessage | TransportShipSpawnMessage; // Message send from worker @@ -152,4 +166,5 @@ export type WorkerMessage = | PlayerProfileResultMessage | PlayerBorderTilesResultMessage | AttackAveragePositionResultMessage + | AttackClusterPositionsResultMessage | TransportShipSpawnResultMessage; diff --git a/tests/client/graphics/layers/TroopAdvantageLayer.test.ts b/tests/client/graphics/layers/TroopAdvantageLayer.test.ts new file mode 100644 index 000000000..cfbbae307 --- /dev/null +++ b/tests/client/graphics/layers/TroopAdvantageLayer.test.ts @@ -0,0 +1,32 @@ +import { + troopAttackColor, + troopDefenceColor, +} from "../../../../src/client/graphics/layers/TroopAdvantageLayer"; + +describe("troopAttackColor", () => { + test("returns green when attacker has more troops", () => { + expect(troopAttackColor(1000, 500)).toBe("#66ff66"); + }); + + test("returns amber when defender has more troops", () => { + expect(troopAttackColor(500, 1000)).toBe("#ffbe3c"); + }); + + test("returns amber when troops are equal", () => { + expect(troopAttackColor(500, 500)).toBe("#ffbe3c"); + }); +}); + +describe("troopDefenceColor", () => { + test("returns red when attacker has more troops than defender", () => { + expect(troopDefenceColor(1000, 500)).toBe("#ff4444"); + }); + + test("returns orange when defender has more troops", () => { + expect(troopDefenceColor(500, 1000)).toBe("#ff9944"); + }); + + test("returns orange when troops are equal", () => { + expect(troopDefenceColor(500, 500)).toBe("#ff9944"); + }); +});