mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
feat: Attacking Troops Overlay (#3427)
## Description: https://troop-advantage-layer.openfront.dev/ Hey OpenFront dev team, I've been really enjoying the game, and the v0.30 changes have felt great so far. Happy to start contributing! This PR introduces `AttackingTroopsOverlay`, a layer that renders live attacker vs. defender troop counts directly on active front lines. Players can immediately gauge combat strength without leaving the map view.  A recent change updates the layer to just the # of attackers and a symbol for attack/defence:  Left: Perspective of Anon 667 (Blue) | Right: Perspective of Anon332 (Red)  **How it works:** - Attacker count shown for ground invasions. When attacking, your troop count will display amber for disadvantageous, and green for advantageous battles. When defending, the enemy troop count will switch to red if you are at a severe disadvantage. - Label position recalculates every tick at 200ms, tracking the front line as it moves. - Automatically hidden during Terrain view (spacebar) - Labels clean up when an attack ends or its target becomes invalid **Settings:** An "Attacking Troops Overlay" toggle is added to Settings, enabled by default. --> the screenshot is old, but the text has been updated <img width="448" height="410" alt="Settings toggle" src="https://github.com/user-attachments/assets/2df8ec7a-3f77-48b7-a9b5-ee4a6eed0412" /> ## Checklist - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Discord Radyus
This commit is contained in:
@@ -540,6 +540,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",
|
||||
"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",
|
||||
|
||||
@@ -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";
|
||||
@@ -284,6 +285,7 @@ export function createRenderer(
|
||||
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
|
||||
new DynamicUILayer(game, transformHandler, eventBus),
|
||||
new NameLayer(game, transformHandler, eventBus),
|
||||
new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings),
|
||||
eventsDisplay,
|
||||
attacksDisplay,
|
||||
chatDisplay,
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Cell } from "../../../core/game/Game";
|
||||
import { GameView } 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";
|
||||
import shieldIcon from "/images/ShieldIconWhite.svg?url";
|
||||
import swordIcon from "/images/SwordIconWhite.svg?url";
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
// 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)[];
|
||||
isIncoming: boolean;
|
||||
attackerTroops: number;
|
||||
defenderTroops: number;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
constructor(
|
||||
private readonly game: GameView,
|
||||
private readonly transformHandler: TransformHandler,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly userSettings: UserSettings,
|
||||
) {}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
init() {
|
||||
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);
|
||||
|
||||
this.onAlternateView = (e) => {
|
||||
this.isVisible = !e.alternateView;
|
||||
this.container.style.display = this.isVisible ? "" : "none";
|
||||
};
|
||||
this.eventBus.on(AlternateViewEvent, this.onAlternateView);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!this.container) return;
|
||||
this.clearAllLabels();
|
||||
this.container.remove();
|
||||
this.eventBus.off(AlternateViewEvent, this.onAlternateView);
|
||||
}
|
||||
|
||||
getTickIntervalMs() {
|
||||
return 200;
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (!this.userSettings.attackingTroopsOverlay() || !this.isVisible) {
|
||||
if (this.labels.size > 0) this.clearAllLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) {
|
||||
this.clearAllLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
const activeIDs = new Set<string>();
|
||||
|
||||
// Outgoing attacks — green if winning, amber if losing.
|
||||
for (const attack of myPlayer.outgoingAttacks()) {
|
||||
activeIDs.add(attack.id);
|
||||
if (!attack.targetID) {
|
||||
this.removeLabel(attack.id);
|
||||
continue;
|
||||
}
|
||||
const defender = this.game.playerBySmallID(attack.targetID);
|
||||
if (!defender || !defender.isPlayer()) {
|
||||
this.removeLabel(attack.id);
|
||||
continue;
|
||||
}
|
||||
this.ensureLabel(attack.id, attack.troops, defender.troops(), false);
|
||||
}
|
||||
|
||||
// Incoming attacks — red if the attacker outnumbers the player, orange otherwise.
|
||||
for (const attack of myPlayer.incomingAttacks()) {
|
||||
activeIDs.add(attack.id);
|
||||
const attacker = this.game.playerBySmallID(attack.attackerID);
|
||||
if (!attacker || !attacker.isPlayer()) {
|
||||
this.removeLabel(attack.id);
|
||||
continue;
|
||||
}
|
||||
this.ensureLabel(attack.id, attack.troops, myPlayer.troops(), true);
|
||||
}
|
||||
|
||||
for (const [id] of this.labels) {
|
||||
if (!activeIDs.has(id)) this.removeLabel(id);
|
||||
}
|
||||
|
||||
// Single worker request per tick; skip if the previous one is still in flight.
|
||||
if (this.inFlightRequest) return;
|
||||
this.inFlightRequest = true;
|
||||
|
||||
void myPlayer
|
||||
.attackClusteredPositions()
|
||||
.then((attacks) => {
|
||||
for (const { id, positions } of attacks) {
|
||||
const lbl = this.labels.get(id);
|
||||
if (!lbl) continue;
|
||||
this.reconcileLabelPositions(lbl, positions);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// On error, hide all labels until the next successful response.
|
||||
for (const lbl of this.labels.values()) lbl.positions.fill(null);
|
||||
})
|
||||
.finally(() => {
|
||||
this.inFlightRequest = false;
|
||||
});
|
||||
}
|
||||
|
||||
private ensureLabel(
|
||||
attackID: string,
|
||||
attackerTroops: number,
|
||||
defenderTroops: number,
|
||||
isIncoming: boolean,
|
||||
) {
|
||||
let label = this.labels.get(attackID);
|
||||
if (!label) {
|
||||
label = {
|
||||
elements: [],
|
||||
positions: [],
|
||||
isIncoming,
|
||||
attackerTroops,
|
||||
defenderTroops,
|
||||
};
|
||||
this.labels.set(attackID, label);
|
||||
} else {
|
||||
label.attackerTroops = attackerTroops;
|
||||
label.defenderTroops = defenderTroops;
|
||||
}
|
||||
for (const el of label.elements) {
|
||||
this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming);
|
||||
}
|
||||
}
|
||||
|
||||
renderLayer(_context: CanvasRenderingContext2D) {
|
||||
const screenPosOld = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(0, 0),
|
||||
);
|
||||
const screenPos = new Cell(
|
||||
screenPosOld.x - window.innerWidth / 2,
|
||||
screenPosOld.y - window.innerHeight / 2,
|
||||
);
|
||||
this.container.style.transform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`;
|
||||
|
||||
for (const label of this.labels.values()) {
|
||||
for (let i = 0; i < label.elements.length; i++) {
|
||||
const el = label.elements[i];
|
||||
const pos = label.positions[i];
|
||||
|
||||
if (!pos || !this.transformHandler.isOnScreen(pos)) {
|
||||
el.style.display = "none";
|
||||
continue;
|
||||
}
|
||||
|
||||
el.style.display = "inline-flex";
|
||||
// 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})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private reconcileLabelPositions(lbl: AttackLabel, positions: Cell[]) {
|
||||
// Add elements for new clusters.
|
||||
while (lbl.elements.length < positions.length) {
|
||||
lbl.elements.push(
|
||||
this.createLabelElement(
|
||||
lbl.attackerTroops,
|
||||
lbl.defenderTroops,
|
||||
lbl.isIncoming,
|
||||
),
|
||||
);
|
||||
lbl.positions.push(null);
|
||||
}
|
||||
|
||||
// Remove elements for clusters that no longer exist.
|
||||
while (lbl.elements.length > positions.length) {
|
||||
lbl.elements.pop()!.remove();
|
||||
lbl.positions.pop();
|
||||
}
|
||||
|
||||
// Snap large jumps instantly; let the CSS transition handle small advances.
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
const old = lbl.positions[i];
|
||||
const next = positions[i];
|
||||
if (old && Math.hypot(next.x - old.x, next.y - old.y) > 50) {
|
||||
const el = lbl.elements[i];
|
||||
el.style.transition = "none";
|
||||
el.style.transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`;
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transition = "transform 0.2s ease-out";
|
||||
});
|
||||
}
|
||||
lbl.positions[i] = next;
|
||||
}
|
||||
}
|
||||
|
||||
private createLabelElement(
|
||||
attackerTroops: number,
|
||||
defenderTroops: number,
|
||||
isIncoming: boolean,
|
||||
): HTMLDivElement {
|
||||
const el = document.createElement("div");
|
||||
el.style.position = "absolute";
|
||||
el.style.display = "none";
|
||||
el.style.alignItems = "center";
|
||||
el.style.gap = "3px";
|
||||
el.style.width = "max-content";
|
||||
el.style.whiteSpace = "nowrap";
|
||||
el.style.fontSize = "11px";
|
||||
el.style.fontWeight = "bold";
|
||||
el.style.fontFamily = this.game.config().theme().font();
|
||||
el.style.padding = "1px 4px";
|
||||
el.style.borderRadius = "3px";
|
||||
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;
|
||||
}
|
||||
|
||||
private updateLabelContent(
|
||||
el: HTMLDivElement,
|
||||
attackerTroops: number,
|
||||
defenderTroops: number,
|
||||
isIncoming: boolean,
|
||||
) {
|
||||
// Reuse existing children to avoid DOM churn on every tick.
|
||||
let icon = el.querySelector("img") as HTMLImageElement | null;
|
||||
let span = el.querySelector("span") as HTMLSpanElement | null;
|
||||
if (!icon || !span) {
|
||||
icon = document.createElement("img");
|
||||
icon.style.width = "10px";
|
||||
icon.style.height = "10px";
|
||||
span = document.createElement("span");
|
||||
el.replaceChildren(icon, span);
|
||||
}
|
||||
|
||||
if (isIncoming) {
|
||||
icon.src = shieldIcon;
|
||||
span.style.color = troopDefenceColor(attackerTroops, defenderTroops);
|
||||
span.textContent = renderTroops(attackerTroops);
|
||||
} else {
|
||||
icon.src = swordIcon;
|
||||
span.style.color = troopAttackColor(attackerTroops, defenderTroops);
|
||||
span.textContent = renderTroops(attackerTroops);
|
||||
}
|
||||
}
|
||||
|
||||
private removeLabel(attackID: string) {
|
||||
const label = this.labels.get(attackID);
|
||||
if (!label) return;
|
||||
for (const el of label.elements) el.remove();
|
||||
this.labels.delete(attackID);
|
||||
}
|
||||
|
||||
private clearAllLabels() {
|
||||
for (const label of this.labels.values()) {
|
||||
for (const el of label.elements) el.remove();
|
||||
}
|
||||
this.labels.clear();
|
||||
}
|
||||
}
|
||||
@@ -184,17 +184,13 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
const playerView = this.game.playerBySmallID(attack.attackerID);
|
||||
if (playerView !== undefined) {
|
||||
if (playerView instanceof PlayerView) {
|
||||
const averagePosition = await playerView.attackAveragePosition(
|
||||
attack.attackerID,
|
||||
attack.id,
|
||||
);
|
||||
const attacks = await playerView.attackClusteredPositions(attack.id);
|
||||
const pos = attacks[0]?.positions[0];
|
||||
|
||||
if (averagePosition === null) {
|
||||
if (!pos) {
|
||||
this.emitGoToPlayerEvent(attack.attackerID);
|
||||
} else {
|
||||
this.eventBus.emit(
|
||||
new GoToPositionEvent(averagePosition.x, averagePosition.y),
|
||||
);
|
||||
this.eventBus.emit(new GoToPositionEvent(pos.x, pos.y));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -18,6 +18,7 @@ import mouseIcon from "/images/MouseIconWhite.svg?url";
|
||||
import ninjaIcon from "/images/NinjaIconWhite.svg?url";
|
||||
import settingsIcon from "/images/SettingIconWhite.svg?url";
|
||||
import sirenIcon from "/images/SirenIconWhite.svg?url";
|
||||
import swordIcon from "/images/SwordIconWhite.svg?url";
|
||||
import treeIcon from "/images/TreeIconWhite.svg?url";
|
||||
import musicIcon from "/images/music.svg?url";
|
||||
|
||||
@@ -163,6 +164,11 @@ export class SettingsModal extends LitElement implements Layer {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private onToggleAttackingTroopsOverlayButtonClick() {
|
||||
this.userSettings.toggleAttackingTroopsOverlay();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private onTogglePerformanceOverlayButtonClick() {
|
||||
this.userSettings.togglePerformanceOverlay();
|
||||
this.requestUpdate();
|
||||
@@ -408,6 +414,28 @@ export class SettingsModal extends LitElement implements Layer {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<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.onToggleAttackingTroopsOverlayButtonClick}"
|
||||
>
|
||||
<img src=${swordIcon} alt="swordIcon" width="20" height="20" />
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">
|
||||
${translateText(
|
||||
"user_setting.attacking_troops_overlay_label",
|
||||
)}
|
||||
</div>
|
||||
<div class="text-sm text-slate-400">
|
||||
${translateText("user_setting.attacking_troops_overlay_desc")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-slate-400">
|
||||
${this.userSettings.attackingTroopsOverlay()
|
||||
? translateText("user_setting.on")
|
||||
: translateText("user_setting.off")}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<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.onToggleCursorCostLabelButtonClick}"
|
||||
|
||||
+13
-16
@@ -5,9 +5,7 @@ import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterE
|
||||
import { WinCheckExecution } from "./execution/WinCheckExecution";
|
||||
import {
|
||||
AllPlayers,
|
||||
Attack,
|
||||
BuildableUnit,
|
||||
Cell,
|
||||
Game,
|
||||
GameUpdates,
|
||||
NameViewData,
|
||||
@@ -255,24 +253,23 @@ export class GameRunner {
|
||||
} as PlayerBorderTiles;
|
||||
}
|
||||
|
||||
public attackAveragePosition(
|
||||
public attackClusteredPositions(
|
||||
playerID: number,
|
||||
attackID: string,
|
||||
): Cell | null {
|
||||
attackID?: string,
|
||||
): { id: string; positions: { x: number; y: number }[] }[] {
|
||||
const player = this.game.playerBySmallID(playerID);
|
||||
if (!player.isPlayer()) {
|
||||
if (!player.isPlayer())
|
||||
throw new Error(`player with id ${playerID} not found`);
|
||||
}
|
||||
const all = [...player.outgoingAttacks(), ...player.incomingAttacks()];
|
||||
const attacks = attackID ? all.filter((a) => a.id() === attackID) : all;
|
||||
|
||||
const condition = (a: Attack) => a.id() === attackID;
|
||||
const attack =
|
||||
player.outgoingAttacks().find(condition) ??
|
||||
player.incomingAttacks().find(condition);
|
||||
if (attack === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return attack.averagePosition();
|
||||
return attacks.map((a) => ({
|
||||
id: a.id(),
|
||||
positions: a.clusteredPositions().map((tile) => ({
|
||||
x: this.game.map().x(tile),
|
||||
y: this.game.map().y(tile),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
public bestTransportShipSpawn(
|
||||
|
||||
+79
-18
@@ -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,28 +97,89 @@ export class AttackImpl implements Attack {
|
||||
}
|
||||
}
|
||||
|
||||
averagePosition(): Cell | null {
|
||||
// Returns the top 2 clustered positions of the attack's border.
|
||||
// If the second cluster is too small, only returns the largest one.
|
||||
clusteredPositions(): TileRef[] {
|
||||
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;
|
||||
const tile = this.sourceTile();
|
||||
return tile !== null ? [tile] : [];
|
||||
}
|
||||
return this.clusterBorderTiles(30, 2);
|
||||
}
|
||||
|
||||
// Partitions the attack's border tiles into disconnected segments using BFS,
|
||||
// then returns one representative tile per segment.
|
||||
//
|
||||
// Border tiles naturally fragment when fighting across non-contiguous
|
||||
// territory (e.g. islands, chokepoints).
|
||||
//
|
||||
// Results are sorted largest-first, small clusters below minSize are
|
||||
// dropped (the largest is always kept as a fallback), and the list is capped
|
||||
// at maxClusters to avoid label clutter on heavily fragmented borders.
|
||||
private clusterBorderTiles(minSize: number, maxClusters: number): TileRef[] {
|
||||
const map = this._mg.map();
|
||||
const visited = new Set<TileRef>();
|
||||
const clusters: { tile: TileRef; 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 t = queue[qi++];
|
||||
sumX += map.x(t);
|
||||
sumY += map.y(t);
|
||||
count++;
|
||||
|
||||
this._mg.forEachNeighborWithDiag(t, (neighbor) => {
|
||||
if (this._border.has(neighbor) && !visited.has(neighbor)) {
|
||||
visited.add(neighbor);
|
||||
queue.push(neighbor);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 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));
|
||||
|
||||
// The centroid (sumX/count, sumY/count) may not be a real border tile,
|
||||
// so we pick whichever tile in the cluster is closest to it. This ensures
|
||||
// the representative always sits on an actual front-line tile.
|
||||
const cx = sumX / count;
|
||||
const cy = sumY / count;
|
||||
let best = queue[0];
|
||||
let bestDist = Infinity;
|
||||
for (const t of queue) {
|
||||
const dx = map.x(t) - cx;
|
||||
const dy = map.y(t) - cy;
|
||||
const dist = dx * dx + dy * dy;
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = t;
|
||||
}
|
||||
}
|
||||
clusters.push({ tile: best, size: count });
|
||||
}
|
||||
|
||||
let averageX = 0;
|
||||
let averageY = 0;
|
||||
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);
|
||||
switch (clusters.length) {
|
||||
case 0:
|
||||
return [];
|
||||
case 1:
|
||||
// If there's only one cluster, return it even if it's smaller than minSize.
|
||||
return [clusters[0].tile];
|
||||
default: {
|
||||
const significant = clusters.filter((c) => c.size >= minSize);
|
||||
if (significant.length === 0) {
|
||||
// Always keep at least the largest cluster even if it falls below minSize.
|
||||
return [clusters[0].tile];
|
||||
}
|
||||
return significant.slice(0, maxClusters).map((c) => c.tile);
|
||||
}
|
||||
}
|
||||
|
||||
averageX = averageX / this._borderSize;
|
||||
averageY = averageY / this._borderSize;
|
||||
|
||||
return new Cell(averageX, averageY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,7 +470,7 @@ export interface Attack {
|
||||
removeBorderTile(tile: TileRef): void;
|
||||
clearBorder(): void;
|
||||
borderSize(): number;
|
||||
averagePosition(): Cell | null;
|
||||
clusteredPositions(): TileRef[];
|
||||
}
|
||||
|
||||
export interface AllianceRequest {
|
||||
|
||||
@@ -453,11 +453,10 @@ export class PlayerView {
|
||||
return this.data.incomingAttacks;
|
||||
}
|
||||
|
||||
async attackAveragePosition(
|
||||
playerID: number,
|
||||
attackID: string,
|
||||
): Promise<Cell | null> {
|
||||
return this.game.worker.attackAveragePosition(playerID, attackID);
|
||||
async attackClusteredPositions(
|
||||
attackID?: string,
|
||||
): Promise<{ id: string; positions: Cell[] }[]> {
|
||||
return this.game.worker.attackClusteredPositions(this.smallID(), attackID);
|
||||
}
|
||||
|
||||
units(...types: UnitType[]): UnitView[] {
|
||||
|
||||
@@ -89,6 +89,14 @@ export class UserSettings {
|
||||
return this.get("settings.territoryPatterns", true);
|
||||
}
|
||||
|
||||
attackingTroopsOverlay() {
|
||||
return this.get("settings.attackingTroopsOverlay", true);
|
||||
}
|
||||
|
||||
toggleAttackingTroopsOverlay() {
|
||||
this.set("settings.attackingTroopsOverlay", !this.attackingTroopsOverlay());
|
||||
}
|
||||
|
||||
cursorCostLabel() {
|
||||
const legacy = this.get("settings.ghostPricePill", true);
|
||||
return this.get("settings.cursorCostLabel", legacy);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createGameRunner, GameRunner } from "../GameRunner";
|
||||
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
|
||||
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
|
||||
import {
|
||||
AttackAveragePositionResultMessage,
|
||||
AttackClusteredPositionsResultMessage,
|
||||
InitializedMessage,
|
||||
MainThreadMessage,
|
||||
PlayerActionsResultMessage,
|
||||
@@ -243,25 +243,28 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
throw error;
|
||||
}
|
||||
break;
|
||||
case "attack_average_position":
|
||||
case "attack_clustered_positions":
|
||||
if (!gameRunner) {
|
||||
throw new Error("Game runner not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
const averagePosition = (await gameRunner).attackAveragePosition(
|
||||
const attacks = (await gameRunner).attackClusteredPositions(
|
||||
message.playerID,
|
||||
message.attackID,
|
||||
);
|
||||
sendMessage({
|
||||
type: "attack_average_position_result",
|
||||
type: "attack_clustered_positions_result",
|
||||
id: message.id,
|
||||
x: averagePosition ? averagePosition.x : null,
|
||||
y: averagePosition ? averagePosition.y : null,
|
||||
} as AttackAveragePositionResultMessage);
|
||||
attacks,
|
||||
} as AttackClusteredPositionsResultMessage);
|
||||
} catch (error) {
|
||||
console.error("Failed to get attack average position:", error);
|
||||
throw error;
|
||||
console.error("Failed to get attack front line centers:", error);
|
||||
sendMessage({
|
||||
type: "attack_clustered_positions_result",
|
||||
id: message.id,
|
||||
attacks: [],
|
||||
} as AttackClusteredPositionsResultMessage);
|
||||
}
|
||||
break;
|
||||
case "transport_ship_spawn":
|
||||
|
||||
@@ -231,10 +231,10 @@ export class WorkerClient {
|
||||
});
|
||||
}
|
||||
|
||||
attackAveragePosition(
|
||||
attackClusteredPositions(
|
||||
playerID: number,
|
||||
attackID: string,
|
||||
): Promise<Cell | null> {
|
||||
attackID?: string,
|
||||
): Promise<{ id: string; positions: Cell[] }[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isInitialized) {
|
||||
reject(new Error("Worker not initialized"));
|
||||
@@ -243,25 +243,34 @@ export class WorkerClient {
|
||||
|
||||
const messageId = generateID();
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.messageHandlers.delete(messageId);
|
||||
reject(new Error("attack_clustered_positions request timed out"));
|
||||
}, 5000);
|
||||
|
||||
this.messageHandlers.set(messageId, (message) => {
|
||||
if (
|
||||
message.type === "attack_average_position_result" &&
|
||||
message.x !== undefined &&
|
||||
message.y !== undefined
|
||||
) {
|
||||
if (message.x === null || message.y === null) {
|
||||
resolve(null);
|
||||
} else {
|
||||
resolve(new Cell(message.x, message.y));
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
if (message.type !== "attack_clustered_positions_result") {
|
||||
reject(
|
||||
new Error(
|
||||
`Unexpected message type for attackClusteredPositions: ${message.type}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve(
|
||||
message.attacks.map((a) => ({
|
||||
id: a.id,
|
||||
positions: a.positions.map((c) => new Cell(c.x, c.y)),
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
this.worker.postMessage({
|
||||
type: "attack_average_position",
|
||||
type: "attack_clustered_positions",
|
||||
id: messageId,
|
||||
playerID: playerID,
|
||||
attackID: attackID,
|
||||
playerID,
|
||||
attackID,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ export type WorkerMessageType =
|
||||
| "player_profile_result"
|
||||
| "player_border_tiles"
|
||||
| "player_border_tiles_result"
|
||||
| "attack_average_position"
|
||||
| "attack_average_position_result"
|
||||
| "attack_clustered_positions"
|
||||
| "attack_clustered_positions_result"
|
||||
| "transport_ship_spawn"
|
||||
| "transport_ship_spawn_result";
|
||||
|
||||
@@ -108,16 +108,16 @@ export interface PlayerBorderTilesResultMessage extends BaseWorkerMessage {
|
||||
result: PlayerBorderTiles;
|
||||
}
|
||||
|
||||
export interface AttackAveragePositionMessage extends BaseWorkerMessage {
|
||||
type: "attack_average_position";
|
||||
export interface AttackClusteredPositionsMessage extends BaseWorkerMessage {
|
||||
type: "attack_clustered_positions";
|
||||
playerID: number;
|
||||
attackID: string;
|
||||
attackID?: string;
|
||||
}
|
||||
|
||||
export interface AttackAveragePositionResultMessage extends BaseWorkerMessage {
|
||||
type: "attack_average_position_result";
|
||||
x: number | null;
|
||||
y: number | null;
|
||||
export interface AttackClusteredPositionsResultMessage
|
||||
extends BaseWorkerMessage {
|
||||
type: "attack_clustered_positions_result";
|
||||
attacks: { id: string; positions: { x: number; y: number }[] }[];
|
||||
}
|
||||
|
||||
export interface TransportShipSpawnMessage extends BaseWorkerMessage {
|
||||
@@ -139,7 +139,7 @@ export type MainThreadMessage =
|
||||
| PlayerBuildablesMessage
|
||||
| PlayerProfileMessage
|
||||
| PlayerBorderTilesMessage
|
||||
| AttackAveragePositionMessage
|
||||
| AttackClusteredPositionsMessage
|
||||
| TransportShipSpawnMessage;
|
||||
|
||||
// Message send from worker
|
||||
@@ -151,5 +151,5 @@ export type WorkerMessage =
|
||||
| PlayerBuildablesResultMessage
|
||||
| PlayerProfileResultMessage
|
||||
| PlayerBorderTilesResultMessage
|
||||
| AttackAveragePositionResultMessage
|
||||
| AttackClusteredPositionsResultMessage
|
||||
| TransportShipSpawnResultMessage;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
troopAttackColor,
|
||||
troopDefenceColor,
|
||||
} from "../../../../src/client/graphics/layers/AttackingTroopsOverlay";
|
||||
|
||||
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