Show front line per attack/defence line

This commit is contained in:
ralfisalhon
2026-03-15 00:30:41 +00:00
parent a768574d96
commit a83fe28e93
9 changed files with 316 additions and 76 deletions
+132 -76
View File
@@ -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();
+18
View File
@@ -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,
+62
View File
@@ -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<TileRef>();
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) {
+1
View File
@@ -471,6 +471,7 @@ export interface Attack {
clearBorder(): void;
borderSize(): number;
averagePosition(): Cell | null;
clusterPositions(): Cell[];
}
export interface AllianceRequest {
+7
View File
@@ -460,6 +460,13 @@ export class PlayerView {
return this.game.worker.attackAveragePosition(playerID, attackID);
}
async attackClusterPositions(
playerID: number,
attackID: string,
): Promise<Cell[]> {
return this.game.worker.attackClusterPositions(playerID, attackID);
}
units(...types: UnitType[]): UnitView[] {
return this.game
.units(...types)
+25
View File
@@ -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<MainThreadMessage>) => {
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");
+24
View File
@@ -266,6 +266,30 @@ export class WorkerClient {
});
}
attackClusterPositions(playerID: number, attackID: string): Promise<Cell[]> {
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,
+15
View File
@@ -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;
@@ -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");
});
});