mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-18 09:14:09 +00:00
Rename to AttackingTroopsOverlay, label transitions
This commit is contained in:
@@ -537,8 +537,8 @@
|
|||||||
"territory_patterns_desc": "Choose whether to display territory skin designs in game",
|
"territory_patterns_desc": "Choose whether to display territory skin designs in game",
|
||||||
"coordinate_grid_label": "Coordinate Grid",
|
"coordinate_grid_label": "Coordinate Grid",
|
||||||
"coordinate_grid_desc": "Toggle the alphanumeric grid overlay",
|
"coordinate_grid_desc": "Toggle the alphanumeric grid overlay",
|
||||||
"troop_advantage_label": "Troop Advantage Display",
|
"attacking_troops_overlay_label": "Attacking Troops Overlay",
|
||||||
"troop_advantage_desc": "Show attacker vs defender troop counts on active front lines.",
|
"attacking_troops_overlay_desc": "Show attacker vs defender troop counts on active front lines.",
|
||||||
"performance_overlay_label": "Performance Overlay",
|
"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.",
|
"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",
|
"easter_writing_speed_label": "Writing Speed Multiplier",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { FrameProfiler } from "./FrameProfiler";
|
|||||||
import { TransformHandler } from "./TransformHandler";
|
import { TransformHandler } from "./TransformHandler";
|
||||||
import { UIState } from "./UIState";
|
import { UIState } from "./UIState";
|
||||||
import { AlertFrame } from "./layers/AlertFrame";
|
import { AlertFrame } from "./layers/AlertFrame";
|
||||||
|
import { AttackingTroopsOverlay } from "./layers/AttackingTroopsOverlay";
|
||||||
import { AttacksDisplay } from "./layers/AttacksDisplay";
|
import { AttacksDisplay } from "./layers/AttacksDisplay";
|
||||||
import { BuildMenu } from "./layers/BuildMenu";
|
import { BuildMenu } from "./layers/BuildMenu";
|
||||||
import { ChatDisplay } from "./layers/ChatDisplay";
|
import { ChatDisplay } from "./layers/ChatDisplay";
|
||||||
@@ -42,7 +43,6 @@ import { StructureLayer } from "./layers/StructureLayer";
|
|||||||
import { TeamStats } from "./layers/TeamStats";
|
import { TeamStats } from "./layers/TeamStats";
|
||||||
import { TerrainLayer } from "./layers/TerrainLayer";
|
import { TerrainLayer } from "./layers/TerrainLayer";
|
||||||
import { TerritoryLayer } from "./layers/TerritoryLayer";
|
import { TerritoryLayer } from "./layers/TerritoryLayer";
|
||||||
import { TroopAdvantageLayer } from "./layers/TroopAdvantageLayer";
|
|
||||||
import { UILayer } from "./layers/UILayer";
|
import { UILayer } from "./layers/UILayer";
|
||||||
import { UnitDisplay } from "./layers/UnitDisplay";
|
import { UnitDisplay } from "./layers/UnitDisplay";
|
||||||
import { UnitLayer } from "./layers/UnitLayer";
|
import { UnitLayer } from "./layers/UnitLayer";
|
||||||
@@ -293,8 +293,8 @@ export function createRenderer(
|
|||||||
new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState),
|
new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState),
|
||||||
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
|
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
|
||||||
new DynamicUILayer(game, transformHandler, eventBus),
|
new DynamicUILayer(game, transformHandler, eventBus),
|
||||||
new TroopAdvantageLayer(game, transformHandler, eventBus, userSettings),
|
|
||||||
new NameLayer(game, transformHandler, eventBus),
|
new NameLayer(game, transformHandler, eventBus),
|
||||||
|
new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings),
|
||||||
eventsDisplay,
|
eventsDisplay,
|
||||||
attacksDisplay,
|
attacksDisplay,
|
||||||
chatDisplay,
|
chatDisplay,
|
||||||
|
|||||||
+78
-28
@@ -21,7 +21,8 @@ export function troopDefenceColor(
|
|||||||
return attackerTroops > myTroops ? "#ff4444" : "#ff9944";
|
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 {
|
interface AttackLabel {
|
||||||
elements: HTMLDivElement[];
|
elements: HTMLDivElement[];
|
||||||
positions: (Cell | null)[];
|
positions: (Cell | null)[];
|
||||||
@@ -30,9 +31,10 @@ interface AttackLabel {
|
|||||||
defenderTroops: number;
|
defenderTroops: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TroopAdvantageLayer implements Layer {
|
export class AttackingTroopsOverlay implements Layer {
|
||||||
private container: HTMLDivElement;
|
private container: HTMLDivElement;
|
||||||
private labels = new Map<string, AttackLabel>();
|
private labels = new Map<string, AttackLabel>();
|
||||||
|
// Guard against queuing multiple worker requests in the same tick window.
|
||||||
private inFlightRequest = false;
|
private inFlightRequest = false;
|
||||||
private isVisible = true;
|
private isVisible = true;
|
||||||
private onAlternateView: (e: AlternateViewEvent) => void;
|
private onAlternateView: (e: AlternateViewEvent) => void;
|
||||||
@@ -49,11 +51,14 @@ export class TroopAdvantageLayer implements Layer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
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 = document.createElement("div");
|
||||||
this.container.style.position = "fixed";
|
this.container.style.position = "fixed";
|
||||||
this.container.style.left = "50%";
|
this.container.style.left = "50%";
|
||||||
this.container.style.top = "50%";
|
this.container.style.top = "50%";
|
||||||
this.container.style.pointerEvents = "none";
|
this.container.style.pointerEvents = "none";
|
||||||
|
// z-index 4 places labels above NameLayer (z-index 3).
|
||||||
this.container.style.zIndex = "4";
|
this.container.style.zIndex = "4";
|
||||||
document.body.appendChild(this.container);
|
document.body.appendChild(this.container);
|
||||||
|
|
||||||
@@ -76,7 +81,7 @@ export class TroopAdvantageLayer implements Layer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tick() {
|
tick() {
|
||||||
if (!this.userSettings.troopAdvantageLayer() || !this.isVisible) {
|
if (!this.userSettings.attackingTroopsOverlay() || !this.isVisible) {
|
||||||
if (this.labels.size > 0) this.clearAllLabels();
|
if (this.labels.size > 0) this.clearAllLabels();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -94,15 +99,15 @@ export class TroopAdvantageLayer implements Layer {
|
|||||||
...incoming.map((a) => a.id),
|
...incoming.map((a) => a.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Remove labels for attacks that no longer exist
|
|
||||||
for (const [id] of this.labels) {
|
for (const [id] of this.labels) {
|
||||||
if (!activeIDs.has(id)) this.removeLabel(id);
|
if (!activeIDs.has(id)) this.removeLabel(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const myTroops = myPlayer.troops();
|
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) {
|
for (const attack of outgoing) {
|
||||||
|
// targetID === 0 means the attack is targeting sea/empty tiles; skip it.
|
||||||
if (!attack.targetID) {
|
if (!attack.targetID) {
|
||||||
this.removeLabel(attack.id);
|
this.removeLabel(attack.id);
|
||||||
continue;
|
continue;
|
||||||
@@ -115,7 +120,7 @@ export class TroopAdvantageLayer implements Layer {
|
|||||||
this.ensureLabel(attack.id, attack.troops, defender.troops(), false);
|
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) {
|
for (const attack of incoming) {
|
||||||
const attacker = this.game.playerBySmallID(attack.attackerID);
|
const attacker = this.game.playerBySmallID(attack.attackerID);
|
||||||
if (!attacker || !attacker.isPlayer()) {
|
if (!attacker || !attacker.isPlayer()) {
|
||||||
@@ -125,38 +130,21 @@ export class TroopAdvantageLayer implements Layer {
|
|||||||
this.ensureLabel(attack.id, attack.troops, myTroops, true);
|
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;
|
if (this.inFlightRequest) return;
|
||||||
this.inFlightRequest = true;
|
this.inFlightRequest = true;
|
||||||
|
|
||||||
void myPlayer
|
void myPlayer
|
||||||
.attackClusterPositions(myPlayer.smallID())
|
.attackFrontLinePositions()
|
||||||
.then((attacks) => {
|
.then((attacks) => {
|
||||||
for (const { id, clusters } of attacks) {
|
for (const { id, centers } of attacks) {
|
||||||
const lbl = this.labels.get(id);
|
const lbl = this.labels.get(id);
|
||||||
if (!lbl) continue;
|
if (!lbl) continue;
|
||||||
|
this.reconcileLabelPositions(lbl, centers);
|
||||||
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];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
// On error, hide all labels until the next successful response.
|
||||||
for (const lbl of this.labels.values()) lbl.positions.fill(null);
|
for (const lbl of this.labels.values()) lbl.positions.fill(null);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -210,11 +198,71 @@ export class TroopAdvantageLayer implements Layer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
el.style.display = "block";
|
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})`;
|
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(
|
private createLabelElement(
|
||||||
attackerTroops: number,
|
attackerTroops: number,
|
||||||
defenderTroops: number,
|
defenderTroops: number,
|
||||||
@@ -232,6 +280,8 @@ export class TroopAdvantageLayer implements Layer {
|
|||||||
el.style.backgroundColor = "rgba(0,0,0,0.55)";
|
el.style.backgroundColor = "rgba(0,0,0,0.55)";
|
||||||
el.style.pointerEvents = "none";
|
el.style.pointerEvents = "none";
|
||||||
el.style.lineHeight = "1.3";
|
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.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming);
|
||||||
this.container.appendChild(el);
|
this.container.appendChild(el);
|
||||||
return el;
|
return el;
|
||||||
@@ -185,7 +185,6 @@ export class AttacksDisplay extends LitElement implements Layer {
|
|||||||
if (playerView !== undefined) {
|
if (playerView !== undefined) {
|
||||||
if (playerView instanceof PlayerView) {
|
if (playerView instanceof PlayerView) {
|
||||||
const averagePosition = await playerView.attackAveragePosition(
|
const averagePosition = await playerView.attackAveragePosition(
|
||||||
attack.attackerID,
|
|
||||||
attack.id,
|
attack.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -164,8 +164,8 @@ export class SettingsModal extends LitElement implements Layer {
|
|||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private onToggleTroopAdvantageLayerButtonClick() {
|
private onToggleAttackingTroopsOverlayButtonClick() {
|
||||||
this.userSettings.toggleTroopAdvantageLayer();
|
this.userSettings.toggleAttackingTroopsOverlay();
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,19 +416,21 @@ export class SettingsModal extends LitElement implements Layer {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
|
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
|
||||||
@click="${this.onToggleTroopAdvantageLayerButtonClick}"
|
@click="${this.onToggleAttackingTroopsOverlayButtonClick}"
|
||||||
>
|
>
|
||||||
<img src=${swordIcon} alt="swordIcon" width="20" height="20" />
|
<img src=${swordIcon} alt="swordIcon" width="20" height="20" />
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-medium">
|
<div class="font-medium">
|
||||||
${translateText("user_setting.troop_advantage_label")}
|
${translateText(
|
||||||
|
"user_setting.attacking_troops_overlay_label",
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-slate-400">
|
<div class="text-sm text-slate-400">
|
||||||
${translateText("user_setting.troop_advantage_desc")}
|
${translateText("user_setting.attacking_troops_overlay_desc")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-slate-400">
|
<div class="text-sm text-slate-400">
|
||||||
${this.userSettings.troopAdvantageLayer()
|
${this.userSettings.attackingTroopsOverlay()
|
||||||
? translateText("user_setting.on")
|
? translateText("user_setting.on")
|
||||||
: translateText("user_setting.off")}
|
: translateText("user_setting.off")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+14
-4
@@ -254,10 +254,10 @@ export class GameRunner {
|
|||||||
} as PlayerBorderTiles;
|
} as PlayerBorderTiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
public attackClusterPositions(
|
public attackFrontLinePositions(
|
||||||
playerID: number,
|
playerID: number,
|
||||||
attackID?: string,
|
attackID?: string,
|
||||||
): { id: string; clusters: { x: number; y: number }[] }[] {
|
): { id: string; centers: { x: number; y: number }[] }[] {
|
||||||
const player = this.game.playerBySmallID(playerID);
|
const player = this.game.playerBySmallID(playerID);
|
||||||
if (!player.isPlayer()) {
|
if (!player.isPlayer()) {
|
||||||
throw new Error(`player with id ${playerID} not found`);
|
throw new Error(`player with id ${playerID} not found`);
|
||||||
@@ -271,7 +271,13 @@ export class GameRunner {
|
|||||||
? allAttacks.filter((a) => a.id() === attackID)
|
? allAttacks.filter((a) => a.id() === attackID)
|
||||||
: allAttacks;
|
: allAttacks;
|
||||||
|
|
||||||
return attacks.map((a) => ({ id: a.id(), clusters: a.clusterPositions() }));
|
return attacks.map((a) => ({
|
||||||
|
id: a.id(),
|
||||||
|
centers: a.frontLinePositions().map((tile) => ({
|
||||||
|
x: this.game.map().x(tile),
|
||||||
|
y: this.game.map().y(tile),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
public attackAveragePosition(
|
public attackAveragePosition(
|
||||||
@@ -291,7 +297,11 @@ export class GameRunner {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return attack.averagePosition();
|
// Use the largest front line's representative tile (index 0, sorted by frontLinePositions)
|
||||||
|
const tiles = attack.frontLinePositions();
|
||||||
|
if (tiles.length === 0) return null;
|
||||||
|
const tile = tiles[0];
|
||||||
|
return new Cell(this.game.map().x(tile), this.game.map().y(tile));
|
||||||
}
|
}
|
||||||
|
|
||||||
public bestTransportShipSpawn(
|
public bestTransportShipSpawn(
|
||||||
|
|||||||
+27
-44
@@ -1,4 +1,4 @@
|
|||||||
import { Attack, Cell, Player, TerraNullius } from "./Game";
|
import { Attack, Player, TerraNullius } from "./Game";
|
||||||
import { GameImpl } from "./GameImpl";
|
import { GameImpl } from "./GameImpl";
|
||||||
import { TileRef } from "./GameMap";
|
import { TileRef } from "./GameMap";
|
||||||
import { PlayerImpl } from "./PlayerImpl";
|
import { PlayerImpl } from "./PlayerImpl";
|
||||||
@@ -97,19 +97,17 @@ export class AttackImpl implements Attack {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clusterPositions(): Cell[] {
|
frontLinePositions(): TileRef[] {
|
||||||
// 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) {
|
if (this._borderSize === 0) {
|
||||||
const avg = this.averagePosition();
|
const tile = this.sourceTile();
|
||||||
return avg ? [avg] : [];
|
return tile !== null ? [tile] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Segments smaller than this are suppressed; the largest is always kept.
|
||||||
|
const MIN_FRONT_LINE_LENGTH = 30;
|
||||||
const map = this._mg.map();
|
const map = this._mg.map();
|
||||||
const visited = new Set<TileRef>();
|
const visited = new Set<TileRef>();
|
||||||
const clusters: { centroid: Cell; size: number }[] = [];
|
const clusters: { representative: TileRef; size: number }[] = [];
|
||||||
|
|
||||||
for (const startTile of this._border) {
|
for (const startTile of this._border) {
|
||||||
if (visited.has(startTile)) continue;
|
if (visited.has(startTile)) continue;
|
||||||
@@ -143,44 +141,29 @@ export class AttackImpl implements Attack {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clusters.push({
|
// Pick the border tile nearest to the cluster centroid as representative
|
||||||
centroid: new Cell(sumX / count, sumY / count),
|
const cx = sumX / count;
|
||||||
size: count,
|
const cy = sumY / count;
|
||||||
});
|
let best = queue[0];
|
||||||
}
|
let bestDist = Infinity;
|
||||||
|
for (const tile of queue) {
|
||||||
// Keep only clusters above the minimum size.
|
const dx = map.x(tile) - cx;
|
||||||
// Always keep the largest cluster so there's at least one label.
|
const dy = map.y(tile) - cy;
|
||||||
const significant = clusters.filter((c) => c.size >= MIN_CLUSTER_SIZE);
|
const dist = dx * dx + dy * dy;
|
||||||
if (significant.length === 0) {
|
if (dist < bestDist) {
|
||||||
const largest = clusters.reduce((a, b) => (b.size > a.size ? b : a));
|
bestDist = dist;
|
||||||
return [largest.centroid];
|
best = tile;
|
||||||
}
|
}
|
||||||
return significant.map((c) => c.centroid);
|
|
||||||
}
|
|
||||||
|
|
||||||
averagePosition(): Cell | null {
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
// No border tiles yet—use the source tile's location
|
clusters.push({ representative: best, size: count });
|
||||||
const tile: number = this.sourceTile()!;
|
|
||||||
return new Cell(this._mg.map().x(tile), this._mg.map().y(tile));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let averageX = 0;
|
// Sort largest first so index 0 is always the main front line
|
||||||
let averageY = 0;
|
clusters.sort((a, b) => b.size - a.size);
|
||||||
|
|
||||||
for (const t of this._border) {
|
// Keep only clusters above the minimum size; always keep at least the largest
|
||||||
averageX += this._mg.map().x(t);
|
const significant = clusters.filter((c) => c.size >= MIN_FRONT_LINE_LENGTH);
|
||||||
averageY += this._mg.map().y(t);
|
const kept = significant.length > 0 ? significant : [clusters[0]];
|
||||||
}
|
return kept.map((c) => c.representative);
|
||||||
|
|
||||||
averageX = averageX / this._borderSize;
|
|
||||||
averageY = averageY / this._borderSize;
|
|
||||||
|
|
||||||
return new Cell(averageX, averageY);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -470,8 +470,7 @@ export interface Attack {
|
|||||||
removeBorderTile(tile: TileRef): void;
|
removeBorderTile(tile: TileRef): void;
|
||||||
clearBorder(): void;
|
clearBorder(): void;
|
||||||
borderSize(): number;
|
borderSize(): number;
|
||||||
averagePosition(): Cell | null;
|
frontLinePositions(): TileRef[];
|
||||||
clusterPositions(): Cell[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AllianceRequest {
|
export interface AllianceRequest {
|
||||||
|
|||||||
@@ -453,18 +453,14 @@ export class PlayerView {
|
|||||||
return this.data.incomingAttacks;
|
return this.data.incomingAttacks;
|
||||||
}
|
}
|
||||||
|
|
||||||
async attackAveragePosition(
|
async attackAveragePosition(attackID: string): Promise<Cell | null> {
|
||||||
playerID: number,
|
return this.game.worker.attackAveragePosition(this.smallID(), attackID);
|
||||||
attackID: string,
|
|
||||||
): Promise<Cell | null> {
|
|
||||||
return this.game.worker.attackAveragePosition(playerID, attackID);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async attackClusterPositions(
|
async attackFrontLinePositions(
|
||||||
playerID: number,
|
|
||||||
attackID?: string,
|
attackID?: string,
|
||||||
): Promise<{ id: string; clusters: Cell[] }[]> {
|
): Promise<{ id: string; centers: Cell[] }[]> {
|
||||||
return this.game.worker.attackClusterPositions(playerID, attackID);
|
return this.game.worker.attackFrontLinePositions(this.smallID(), attackID);
|
||||||
}
|
}
|
||||||
|
|
||||||
units(...types: UnitType[]): UnitView[] {
|
units(...types: UnitType[]): UnitView[] {
|
||||||
|
|||||||
@@ -89,12 +89,12 @@ export class UserSettings {
|
|||||||
return this.get("settings.territoryPatterns", true);
|
return this.get("settings.territoryPatterns", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
troopAdvantageLayer() {
|
attackingTroopsOverlay() {
|
||||||
return this.get("settings.troopAdvantageLayer", true);
|
return this.get("settings.attackingTroopsOverlay", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleTroopAdvantageLayer() {
|
toggleAttackingTroopsOverlay() {
|
||||||
this.set("settings.troopAdvantageLayer", !this.troopAdvantageLayer());
|
this.set("settings.attackingTroopsOverlay", !this.attackingTroopsOverlay());
|
||||||
}
|
}
|
||||||
|
|
||||||
cursorCostLabel() {
|
cursorCostLabel() {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
|
|||||||
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
|
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
|
||||||
import {
|
import {
|
||||||
AttackAveragePositionResultMessage,
|
AttackAveragePositionResultMessage,
|
||||||
AttackClusterPositionsResultMessage,
|
AttackFrontLinePositionsResultMessage,
|
||||||
InitializedMessage,
|
InitializedMessage,
|
||||||
MainThreadMessage,
|
MainThreadMessage,
|
||||||
PlayerActionsResultMessage,
|
PlayerActionsResultMessage,
|
||||||
@@ -265,28 +265,28 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "attack_cluster_positions":
|
case "attack_front_line_positions":
|
||||||
if (!gameRunner) {
|
if (!gameRunner) {
|
||||||
throw new Error("Game runner not initialized");
|
throw new Error("Game runner not initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const attacks = (await gameRunner).attackClusterPositions(
|
const attacks = (await gameRunner).attackFrontLinePositions(
|
||||||
message.playerID,
|
message.playerID,
|
||||||
message.attackID,
|
message.attackID,
|
||||||
);
|
);
|
||||||
sendMessage({
|
sendMessage({
|
||||||
type: "attack_cluster_positions_result",
|
type: "attack_front_line_positions_result",
|
||||||
id: message.id,
|
id: message.id,
|
||||||
attacks,
|
attacks,
|
||||||
} as AttackClusterPositionsResultMessage);
|
} as AttackFrontLinePositionsResultMessage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to get attack cluster positions:", error);
|
console.error("Failed to get attack front line centers:", error);
|
||||||
sendMessage({
|
sendMessage({
|
||||||
type: "attack_cluster_positions_result",
|
type: "attack_front_line_positions_result",
|
||||||
id: message.id,
|
id: message.id,
|
||||||
attacks: [],
|
attacks: [],
|
||||||
} as AttackClusterPositionsResultMessage);
|
} as AttackFrontLinePositionsResultMessage);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "transport_ship_spawn":
|
case "transport_ship_spawn":
|
||||||
|
|||||||
@@ -266,10 +266,10 @@ export class WorkerClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
attackClusterPositions(
|
attackFrontLinePositions(
|
||||||
playerID: number,
|
playerID: number,
|
||||||
attackID?: string,
|
attackID?: string,
|
||||||
): Promise<{ id: string; clusters: Cell[] }[]> {
|
): Promise<{ id: string; centers: Cell[] }[]> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!this.isInitialized) {
|
if (!this.isInitialized) {
|
||||||
reject(new Error("Worker not initialized"));
|
reject(new Error("Worker not initialized"));
|
||||||
@@ -280,15 +280,15 @@ export class WorkerClient {
|
|||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
this.messageHandlers.delete(messageId);
|
this.messageHandlers.delete(messageId);
|
||||||
reject(new Error("attack_cluster_positions request timed out"));
|
reject(new Error("attack_front_line_positions request timed out"));
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
this.messageHandlers.set(messageId, (message) => {
|
this.messageHandlers.set(messageId, (message) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
if (message.type !== "attack_cluster_positions_result") {
|
if (message.type !== "attack_front_line_positions_result") {
|
||||||
reject(
|
reject(
|
||||||
new Error(
|
new Error(
|
||||||
`Unexpected message type for attackClusterPositions: ${message.type}`,
|
`Unexpected message type for attackFrontLinePositions: ${message.type}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -296,13 +296,13 @@ export class WorkerClient {
|
|||||||
resolve(
|
resolve(
|
||||||
message.attacks.map((a) => ({
|
message.attacks.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
clusters: a.clusters.map((c) => new Cell(c.x, c.y)),
|
centers: a.centers.map((c) => new Cell(c.x, c.y)),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.worker.postMessage({
|
this.worker.postMessage({
|
||||||
type: "attack_cluster_positions",
|
type: "attack_front_line_positions",
|
||||||
id: messageId,
|
id: messageId,
|
||||||
playerID,
|
playerID,
|
||||||
attackID,
|
attackID,
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export type WorkerMessageType =
|
|||||||
| "player_border_tiles_result"
|
| "player_border_tiles_result"
|
||||||
| "attack_average_position"
|
| "attack_average_position"
|
||||||
| "attack_average_position_result"
|
| "attack_average_position_result"
|
||||||
| "attack_cluster_positions"
|
| "attack_front_line_positions"
|
||||||
| "attack_cluster_positions_result"
|
| "attack_front_line_positions_result"
|
||||||
| "transport_ship_spawn"
|
| "transport_ship_spawn"
|
||||||
| "transport_ship_spawn_result";
|
| "transport_ship_spawn_result";
|
||||||
|
|
||||||
@@ -122,15 +122,16 @@ export interface AttackAveragePositionResultMessage extends BaseWorkerMessage {
|
|||||||
y: number | null;
|
y: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttackClusterPositionsMessage extends BaseWorkerMessage {
|
export interface AttackFrontLinePositionsMessage extends BaseWorkerMessage {
|
||||||
type: "attack_cluster_positions";
|
type: "attack_front_line_positions";
|
||||||
playerID: number;
|
playerID: number;
|
||||||
attackID?: string;
|
attackID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttackClusterPositionsResultMessage extends BaseWorkerMessage {
|
export interface AttackFrontLinePositionsResultMessage
|
||||||
type: "attack_cluster_positions_result";
|
extends BaseWorkerMessage {
|
||||||
attacks: { id: string; clusters: { x: number; y: number }[] }[];
|
type: "attack_front_line_positions_result";
|
||||||
|
attacks: { id: string; centers: { x: number; y: number }[] }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TransportShipSpawnMessage extends BaseWorkerMessage {
|
export interface TransportShipSpawnMessage extends BaseWorkerMessage {
|
||||||
@@ -153,7 +154,7 @@ export type MainThreadMessage =
|
|||||||
| PlayerProfileMessage
|
| PlayerProfileMessage
|
||||||
| PlayerBorderTilesMessage
|
| PlayerBorderTilesMessage
|
||||||
| AttackAveragePositionMessage
|
| AttackAveragePositionMessage
|
||||||
| AttackClusterPositionsMessage
|
| AttackFrontLinePositionsMessage
|
||||||
| TransportShipSpawnMessage;
|
| TransportShipSpawnMessage;
|
||||||
|
|
||||||
// Message send from worker
|
// Message send from worker
|
||||||
@@ -166,5 +167,5 @@ export type WorkerMessage =
|
|||||||
| PlayerProfileResultMessage
|
| PlayerProfileResultMessage
|
||||||
| PlayerBorderTilesResultMessage
|
| PlayerBorderTilesResultMessage
|
||||||
| AttackAveragePositionResultMessage
|
| AttackAveragePositionResultMessage
|
||||||
| AttackClusterPositionsResultMessage
|
| AttackFrontLinePositionsResultMessage
|
||||||
| TransportShipSpawnResultMessage;
|
| TransportShipSpawnResultMessage;
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
troopAttackColor,
|
troopAttackColor,
|
||||||
troopDefenceColor,
|
troopDefenceColor,
|
||||||
} from "../../../../src/client/graphics/layers/TroopAdvantageLayer";
|
} from "../../../../src/client/graphics/layers/AttackingTroopsOverlay";
|
||||||
|
|
||||||
describe("troopAttackColor", () => {
|
describe("troopAttackColor", () => {
|
||||||
test("returns green when attacker has more troops", () => {
|
test("returns green when attacker has more troops", () => {
|
||||||
Reference in New Issue
Block a user