mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-22 12:48:18 +00:00
Merge branch 'v31'
This commit is contained in:
@@ -1,54 +1,33 @@
|
||||
import { assetUrl } from "../../../core/AssetUrls";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Cell } from "../../../core/game/Game";
|
||||
import { Cell, PlayerType } 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";
|
||||
const soldierIcon = assetUrl("images/SoldierIcon.svg");
|
||||
|
||||
// Match AttacksDisplay: aquarius for outgoing, red-400 for incoming.
|
||||
const OUTGOING_COLOR = "var(--color-aquarius)";
|
||||
const INCOMING_COLOR = "var(--color-red-400)";
|
||||
|
||||
// At/above this zoom, the label stays at its full screen size. Below it the
|
||||
// label shrinks linearly with zoom-out, floored so it never disappears.
|
||||
const LABEL_FULL_SIZE_ZOOM = 1.5;
|
||||
const LABEL_MIN_SCREEN_SCALE = 0.5;
|
||||
const OUTGOING_ICON_FILTER =
|
||||
"brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%)";
|
||||
const INCOMING_ICON_FILTER =
|
||||
"brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)";
|
||||
// At/above this zoom the label is rendered at full size; below it shrinks
|
||||
// linearly toward LABEL_MIN_RENDERED_SIZE as zoom→0.
|
||||
const LABEL_FULL_SIZE_ZOOM = 4.0;
|
||||
const LABEL_MIN_RENDERED_SIZE = 0.63;
|
||||
// Overall size multiplier applied to the rendered label.
|
||||
const LABEL_SIZE_MULTIPLIER = 1.0;
|
||||
|
||||
// Vertical strength bar to the left of the icon: grows in height as the
|
||||
// attacker outnumbers the opposition. Maxes out at BAR_MAX_HEIGHT_PX when the
|
||||
// attacker has BAR_FULL_HEIGHT_RATIO× the opposing troops.
|
||||
const BAR_FULL_HEIGHT_RATIO = 2;
|
||||
const BAR_MAX_HEIGHT_PX = 13;
|
||||
|
||||
// Element scale factor that, combined with the container's `scale(zoom)`,
|
||||
// yields the desired on-screen label size: constant screen size when zoomed
|
||||
// in past LABEL_FULL_SIZE_ZOOM, then shrinking linearly as zoom drops, with a
|
||||
// floor at LABEL_MIN_SCREEN_SCALE so the label never disappears.
|
||||
// Counter-scale against the container's `scale(zoom)`. At/above
|
||||
// LABEL_FULL_SIZE_ZOOM the rendered size is capped at LABEL_SIZE_MULTIPLIER;
|
||||
// below it the rendered size shrinks linearly toward
|
||||
// LABEL_SIZE_MULTIPLIER * LABEL_MIN_RENDERED_SIZE as zoom→0.
|
||||
export function computeLabelScale(zoom: number): number {
|
||||
const netScale = Math.max(
|
||||
LABEL_MIN_SCREEN_SCALE,
|
||||
Math.min(1, zoom / LABEL_FULL_SIZE_ZOOM),
|
||||
);
|
||||
return netScale / zoom;
|
||||
}
|
||||
|
||||
// Fraction (0–1) of BAR_MAX_HEIGHT_PX the strength bar should occupy. 0 means
|
||||
// the attacker is harmless; 1 means they have BAR_FULL_HEIGHT_RATIO× or more
|
||||
// of the opposing troops.
|
||||
export function computeBarStrength(
|
||||
attackerTroops: number,
|
||||
opposingTroops: number,
|
||||
): number {
|
||||
if (opposingTroops <= 0) return 1;
|
||||
return Math.min(1, attackerTroops / opposingTroops / BAR_FULL_HEIGHT_RATIO);
|
||||
const t = Math.min(1, zoom / LABEL_FULL_SIZE_ZOOM);
|
||||
const renderedSize =
|
||||
LABEL_SIZE_MULTIPLIER *
|
||||
(LABEL_MIN_RENDERED_SIZE + (1 - LABEL_MIN_RENDERED_SIZE) * t);
|
||||
return renderedSize / zoom;
|
||||
}
|
||||
|
||||
// Worker returns clusters sorted by size; two near-equal-size fronts can flip
|
||||
@@ -70,7 +49,6 @@ interface AttackLabel {
|
||||
positions: (Cell | null)[];
|
||||
isIncoming: boolean;
|
||||
attackerTroops: number;
|
||||
barStrength: number;
|
||||
}
|
||||
|
||||
export class AttackingTroopsOverlay implements Layer {
|
||||
@@ -144,7 +122,7 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
|
||||
const activeIDs = new Set<string>();
|
||||
|
||||
// Outgoing: cyan bar widens as our attack outnumbers the defender.
|
||||
// Outgoing: only label attacks targeting another player.
|
||||
for (const attack of myPlayer.outgoingAttacks()) {
|
||||
activeIDs.add(attack.id);
|
||||
if (!attack.targetID) {
|
||||
@@ -156,20 +134,22 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
this.removeLabel(attack.id);
|
||||
continue;
|
||||
}
|
||||
const barStrength = computeBarStrength(attack.troops, defender.troops());
|
||||
this.ensureLabel(attack.id, attack.troops, false, barStrength);
|
||||
this.ensureLabel(attack.id, attack.troops, false);
|
||||
}
|
||||
|
||||
// Incoming: red bar widens as the attacker outnumbers the player.
|
||||
// Incoming: only label attacks coming from another player; skip tribes.
|
||||
for (const attack of myPlayer.incomingAttacks()) {
|
||||
activeIDs.add(attack.id);
|
||||
const attacker = this.game.playerBySmallID(attack.attackerID);
|
||||
if (!attacker || !attacker.isPlayer()) {
|
||||
if (
|
||||
!attacker ||
|
||||
!attacker.isPlayer() ||
|
||||
attacker.type() === PlayerType.Bot
|
||||
) {
|
||||
this.removeLabel(attack.id);
|
||||
continue;
|
||||
}
|
||||
const barStrength = computeBarStrength(attack.troops, myPlayer.troops());
|
||||
this.ensureLabel(attack.id, attack.troops, true, barStrength);
|
||||
this.ensureLabel(attack.id, attack.troops, true);
|
||||
}
|
||||
|
||||
for (const [id] of this.labels) {
|
||||
@@ -202,7 +182,6 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
attackID: string,
|
||||
attackerTroops: number,
|
||||
isIncoming: boolean,
|
||||
barStrength: number,
|
||||
) {
|
||||
let label = this.labels.get(attackID);
|
||||
if (!label) {
|
||||
@@ -211,15 +190,13 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
positions: [],
|
||||
isIncoming,
|
||||
attackerTroops,
|
||||
barStrength,
|
||||
};
|
||||
this.labels.set(attackID, label);
|
||||
} else {
|
||||
label.attackerTroops = attackerTroops;
|
||||
label.barStrength = barStrength;
|
||||
}
|
||||
for (const el of label.elements) {
|
||||
this.updateLabelContent(el, attackerTroops, barStrength);
|
||||
this.updateLabelContent(el, attackerTroops);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +212,7 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
|
||||
// Hoist the per-frame label scale once; zoom is constant within a frame.
|
||||
const scale = this.labelScale();
|
||||
const innerTransform = `scale(${scale})`;
|
||||
for (const label of this.labels.values()) {
|
||||
for (let i = 0; i < label.elements.length; i++) {
|
||||
const el = label.elements[i];
|
||||
@@ -245,15 +223,17 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
continue;
|
||||
}
|
||||
|
||||
el.style.display = "inline-flex";
|
||||
// Centre the label on its world position; counter-scale keeps the
|
||||
// label at constant screen size while zoomed in, then it shrinks
|
||||
// (floored) as zoom drops below LABEL_FULL_SIZE_ZOOM.
|
||||
const transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${scale})`;
|
||||
if (this.lastTransform.get(el) !== transform) {
|
||||
el.style.transform = transform;
|
||||
this.lastTransform.set(el, transform);
|
||||
el.style.display = "";
|
||||
const inner = el.children[0] as HTMLDivElement;
|
||||
// Outer: world position only — the 0.25s transition smooths cluster
|
||||
// shifts. Inner: scale only — applied without transition so zoom is
|
||||
// instant.
|
||||
const outerTransform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%)`;
|
||||
if (this.lastTransform.get(el) !== outerTransform) {
|
||||
el.style.transform = outerTransform;
|
||||
this.lastTransform.set(el, outerTransform);
|
||||
}
|
||||
inner.style.transform = innerTransform;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,11 +242,7 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
// Add elements for new clusters.
|
||||
while (lbl.elements.length < positions.length) {
|
||||
lbl.elements.push(
|
||||
this.createLabelElement(
|
||||
lbl.attackerTroops,
|
||||
lbl.isIncoming,
|
||||
lbl.barStrength,
|
||||
),
|
||||
this.createLabelElement(lbl.attackerTroops, lbl.isIncoming),
|
||||
);
|
||||
lbl.positions.push(null);
|
||||
}
|
||||
@@ -286,9 +262,9 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
if (old && Math.hypot(next.x - old.x, next.y - old.y) > 200) {
|
||||
const el = lbl.elements[i];
|
||||
el.style.transition = "none";
|
||||
const transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${this.labelScale()})`;
|
||||
el.style.transform = transform;
|
||||
this.lastTransform.set(el, transform);
|
||||
const outerTransform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%)`;
|
||||
el.style.transform = outerTransform;
|
||||
this.lastTransform.set(el, outerTransform);
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transition = "transform 0.25s linear";
|
||||
});
|
||||
@@ -297,73 +273,48 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
// Outer wraps position+transition (animates cluster moves). Inner holds the
|
||||
// scale (instant on zoom) plus all visual chrome. Splitting them keeps the
|
||||
// 0.25s transition off zoom changes.
|
||||
private createLabelTemplate(): HTMLDivElement {
|
||||
const el = document.createElement("div");
|
||||
el.style.position = "absolute";
|
||||
el.style.display = "none";
|
||||
el.style.alignItems = "center";
|
||||
el.style.gap = "3px";
|
||||
el.style.whiteSpace = "nowrap";
|
||||
el.style.fontSize = "14px";
|
||||
el.style.fontWeight = "bold";
|
||||
el.style.padding = "2px 5px";
|
||||
el.style.borderRadius = "3px";
|
||||
el.style.backgroundColor = "rgba(0,0,0,0.85)";
|
||||
el.style.pointerEvents = "none";
|
||||
el.style.lineHeight = "1.3";
|
||||
el.style.transition = "transform 0.25s linear";
|
||||
el.style.width = "max-content";
|
||||
const outer = document.createElement("div");
|
||||
outer.style.position = "absolute";
|
||||
outer.style.display = "none";
|
||||
outer.style.pointerEvents = "none";
|
||||
outer.style.transition = "transform 0.25s linear";
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.style.width = "2px";
|
||||
bar.style.borderRadius = "1px";
|
||||
bar.style.alignSelf = "flex-end";
|
||||
bar.style.transition = "height 0.25s linear";
|
||||
el.appendChild(bar);
|
||||
const inner = document.createElement("div");
|
||||
inner.style.whiteSpace = "nowrap";
|
||||
inner.style.fontSize = "17px";
|
||||
inner.style.fontWeight = "bold";
|
||||
inner.style.lineHeight = "1.3";
|
||||
inner.style.width = "max-content";
|
||||
// No background — let the territory border show through. Stacked black
|
||||
// text-shadows form a soft dark glow so the number stays readable over
|
||||
// any terrain.
|
||||
inner.style.textShadow =
|
||||
"0 0 2px rgba(0,0,0,1), 0 0 3px rgba(0,0,0,0.85), 0 0 5px rgba(0,0,0,0.5)";
|
||||
outer.appendChild(inner);
|
||||
|
||||
const icon = document.createElement("img");
|
||||
icon.style.width = "13px";
|
||||
icon.style.height = "13px";
|
||||
el.appendChild(icon);
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.style.minWidth = "25px";
|
||||
el.appendChild(span);
|
||||
|
||||
return el;
|
||||
return outer;
|
||||
}
|
||||
|
||||
private createLabelElement(
|
||||
attackerTroops: number,
|
||||
isIncoming: boolean,
|
||||
barStrength: number,
|
||||
): HTMLDivElement {
|
||||
const el = this.labelTemplate.cloneNode(true) as HTMLDivElement;
|
||||
el.style.fontFamily = this.game.config().theme().font();
|
||||
const bar = el.children[0] as HTMLDivElement;
|
||||
const icon = el.children[1] as HTMLImageElement;
|
||||
const span = el.children[2] as HTMLSpanElement;
|
||||
icon.src = soldierIcon;
|
||||
icon.style.filter = isIncoming
|
||||
? INCOMING_ICON_FILTER
|
||||
: OUTGOING_ICON_FILTER;
|
||||
span.style.color = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR;
|
||||
span.textContent = renderTroops(attackerTroops);
|
||||
bar.style.backgroundColor = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR;
|
||||
bar.style.height = `${barStrength * BAR_MAX_HEIGHT_PX}px`;
|
||||
const inner = el.children[0] as HTMLDivElement;
|
||||
inner.style.fontFamily = this.game.config().theme().font();
|
||||
inner.style.color = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR;
|
||||
inner.textContent = renderTroops(attackerTroops);
|
||||
this.container.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
private updateLabelContent(
|
||||
el: HTMLDivElement,
|
||||
attackerTroops: number,
|
||||
barStrength: number,
|
||||
) {
|
||||
const bar = el.children[0] as HTMLDivElement;
|
||||
const span = el.children[2] as HTMLSpanElement;
|
||||
span.textContent = renderTroops(attackerTroops);
|
||||
bar.style.height = `${barStrength * BAR_MAX_HEIGHT_PX}px`;
|
||||
private updateLabelContent(el: HTMLDivElement, attackerTroops: number) {
|
||||
const inner = el.children[0] as HTMLDivElement;
|
||||
inner.textContent = renderTroops(attackerTroops);
|
||||
}
|
||||
|
||||
private removeLabel(attackID: string) {
|
||||
|
||||
Reference in New Issue
Block a user