mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-28 14:14:15 +00:00
Show front line per attack/defence line
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -471,6 +471,7 @@ export interface Attack {
|
||||
clearBorder(): void;
|
||||
borderSize(): number;
|
||||
averagePosition(): Cell | null;
|
||||
clusterPositions(): Cell[];
|
||||
}
|
||||
|
||||
export interface AllianceRequest {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user