mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 12:00:44 +00:00
SAM Radius (#2307)
## Description: Adds a radius to SAM sites - but only when hovering/ghost mode for sam/atom/hydrogen. solves #1157 <img width="395" height="321" alt="image" src="https://github.com/user-attachments/assets/130c5fe5-c0d3-4637-bce5-11eec74b652a" /> <img width="561" height="468" alt="image" src="https://github.com/user-attachments/assets/9b30149b-8596-4dd4-8399-908593c95e0d" /> <img width="844" height="634" alt="image" src="https://github.com/user-attachments/assets/dafcca96-3f79-4985-88f4-935d461888f0" /> <img width="665" height="668" alt="image" src="https://github.com/user-attachments/assets/924fdf7b-2cb6-4f64-9438-7412838f3d09" /> ## Please complete the following: - [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 ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Evan <evanpelle@gmail.com>
This commit is contained in:
+1
-1
@@ -20,7 +20,7 @@ export default {
|
||||
collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts"],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
statements: 21.5,
|
||||
statements: 21.25,
|
||||
branches: 16,
|
||||
lines: 21.0,
|
||||
functions: 20.5,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
|
||||
import { PlayerPanel } from "./layers/PlayerPanel";
|
||||
import { RailroadLayer } from "./layers/RailroadLayer";
|
||||
import { ReplayPanel } from "./layers/ReplayPanel";
|
||||
import { SAMRadiusLayer } from "./layers/SAMRadiusLayer";
|
||||
import { SettingsModal } from "./layers/SettingsModal";
|
||||
import { SpawnTimer } from "./layers/SpawnTimer";
|
||||
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
|
||||
@@ -201,6 +202,12 @@ export function createRenderer(
|
||||
headsUpMessage.game = game;
|
||||
|
||||
const structureLayer = new StructureLayer(game, eventBus, transformHandler);
|
||||
const samRadiusLayer = new SAMRadiusLayer(
|
||||
game,
|
||||
eventBus,
|
||||
transformHandler,
|
||||
uiState,
|
||||
);
|
||||
|
||||
const fpsDisplay = document.querySelector("fps-display") as FPSDisplay;
|
||||
if (!(fpsDisplay instanceof FPSDisplay)) {
|
||||
@@ -230,6 +237,7 @@ export function createRenderer(
|
||||
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
|
||||
new RailroadLayer(game, transformHandler),
|
||||
structureLayer,
|
||||
samRadiusLayer,
|
||||
new UnitLayer(game, eventBus, transformHandler),
|
||||
new FxLayer(game),
|
||||
new UILayer(game, eventBus, transformHandler),
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import type { EventBus } from "../../../core/EventBus";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import type { GameView } from "../../../core/game/GameView";
|
||||
import { ToggleStructureEvent } from "../../InputHandler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { UIState } from "../UIState";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
/**
|
||||
* Layer responsible for rendering SAM launcher defense radiuses
|
||||
*/
|
||||
export class SAMRadiusLayer implements Layer {
|
||||
private readonly canvas: HTMLCanvasElement;
|
||||
private readonly context: CanvasRenderingContext2D;
|
||||
private readonly samLaunchers: Set<number> = new Set(); // Track SAM launcher IDs
|
||||
private needsRedraw = true;
|
||||
// track whether the stroke should be shown due to hover or due to an active build ghost
|
||||
private hoveredShow: boolean = false;
|
||||
private ghostShow: boolean = false;
|
||||
private showStroke: boolean = false;
|
||||
|
||||
private handleToggleStructure(e: ToggleStructureEvent) {
|
||||
const types = e.structureTypes;
|
||||
this.hoveredShow = !!types && types.indexOf(UnitType.SAMLauncher) !== -1;
|
||||
this.updateStrokeVisibility();
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly game: GameView,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly transformHandler: TransformHandler,
|
||||
private readonly uiState: UIState,
|
||||
) {
|
||||
this.canvas = document.createElement("canvas");
|
||||
const ctx = this.canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
throw new Error("2d context not supported");
|
||||
}
|
||||
this.context = ctx;
|
||||
this.canvas.width = this.game.width();
|
||||
this.canvas.height = this.game.height();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Listen for game updates to detect SAM launcher changes
|
||||
// Also listen for UI toggle structure events so we can show borders when
|
||||
// the user is hovering the Atom/Hydrogen option (UnitDisplay emits
|
||||
// ToggleStructureEvent with SAMLauncher included in the list).
|
||||
this.eventBus.on(ToggleStructureEvent, (e) =>
|
||||
this.handleToggleStructure(e),
|
||||
);
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
private updateStrokeVisibility() {
|
||||
const next = this.hoveredShow || this.ghostShow;
|
||||
if (next !== this.showStroke) {
|
||||
this.showStroke = next;
|
||||
this.needsRedraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
tick() {
|
||||
// Check for updates to SAM launchers
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
const unitUpdates = updates?.[GameUpdateType.Unit];
|
||||
|
||||
if (unitUpdates) {
|
||||
let hasChanges = false;
|
||||
|
||||
for (const update of unitUpdates) {
|
||||
const unit = this.game.unit(update.id);
|
||||
if (unit && unit.type() === UnitType.SAMLauncher) {
|
||||
const wasTracked = this.samLaunchers.has(update.id);
|
||||
const shouldTrack = unit.isActive();
|
||||
|
||||
if (wasTracked && !shouldTrack) {
|
||||
// SAM was destroyed
|
||||
this.samLaunchers.delete(update.id);
|
||||
hasChanges = true;
|
||||
} else if (!wasTracked && shouldTrack) {
|
||||
// New SAM was built
|
||||
this.samLaunchers.add(update.id);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
this.needsRedraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
// show when in ghost mode for sam/atom/hydrogen
|
||||
this.ghostShow =
|
||||
this.uiState.ghostStructure === UnitType.SAMLauncher ||
|
||||
this.uiState.ghostStructure === UnitType.AtomBomb ||
|
||||
this.uiState.ghostStructure === UnitType.HydrogenBomb;
|
||||
this.updateStrokeVisibility();
|
||||
|
||||
// Redraw if transform changed or if we need to redraw
|
||||
if (this.transformHandler.hasChanged() || this.needsRedraw) {
|
||||
this.redraw();
|
||||
this.needsRedraw = false;
|
||||
}
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
context.drawImage(
|
||||
this.canvas,
|
||||
-this.game.width() / 2,
|
||||
-this.game.height() / 2,
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
}
|
||||
|
||||
redraw() {
|
||||
// Clear the canvas
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Get all active SAM launchers
|
||||
const samLaunchers = this.game
|
||||
.units(UnitType.SAMLauncher)
|
||||
.filter((unit) => unit.isActive());
|
||||
|
||||
// Update our tracking set
|
||||
this.samLaunchers.clear();
|
||||
samLaunchers.forEach((sam) => this.samLaunchers.add(sam.id()));
|
||||
|
||||
// Draw union of SAM radiuses. Collect circle data then draw union outer arcs only
|
||||
const circles = samLaunchers.map((sam) => {
|
||||
const tile = sam.tile();
|
||||
return {
|
||||
x: this.game.x(tile),
|
||||
y: this.game.y(tile),
|
||||
r: this.game.config().defaultSamRange(),
|
||||
owner: sam.owner().smallID(),
|
||||
};
|
||||
});
|
||||
|
||||
this.drawCirclesUnion(circles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw union of multiple circles: fill the union, then stroke only the outer arcs
|
||||
* so overlapping circles appear as one combined shape.
|
||||
*/
|
||||
private drawCirclesUnion(
|
||||
circles: Array<{ x: number; y: number; r: number; owner: number }>,
|
||||
) {
|
||||
const ctx = this.context;
|
||||
if (circles.length === 0) return;
|
||||
|
||||
// styles
|
||||
const strokeStyleOuter =
|
||||
this.game.myPlayer()?.borderColor().toRgbString() ??
|
||||
"rgba(230, 230, 230, 0.9)";
|
||||
|
||||
// 1) Fill union simply by drawing all full circle paths and filling once
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
for (const c of circles) {
|
||||
ctx.moveTo(c.x + c.r, c.y);
|
||||
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// 2) For stroke, compute for each circle which angular segments are NOT covered by any other circle,
|
||||
// and stroke only those segments. This produces a union outline without overlapping inner strokes.
|
||||
// Only draw the stroke when UI toggle indicates SAM launchers are focused (e.g. hovering Atom/Hydrogen option).
|
||||
if (!this.showStroke) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([12, 6]);
|
||||
ctx.strokeStyle = strokeStyleOuter;
|
||||
|
||||
const TWO_PI = Math.PI * 2;
|
||||
|
||||
// helper functions
|
||||
const normalize = (a: number) => {
|
||||
while (a < 0) a += TWO_PI;
|
||||
while (a >= TWO_PI) a -= TWO_PI;
|
||||
return a;
|
||||
};
|
||||
|
||||
// merge a list of intervals [s,e] (both between 0..2pi), taking wraparound into account
|
||||
const mergeIntervals = (
|
||||
intervals: Array<[number, number]>,
|
||||
): Array<[number, number]> => {
|
||||
if (intervals.length === 0) return [];
|
||||
// normalize to non-wrap intervals
|
||||
const flat: Array<[number, number]> = [];
|
||||
for (const [s, e] of intervals) {
|
||||
const ns = normalize(s);
|
||||
const ne = normalize(e);
|
||||
if (ne < ns) {
|
||||
// wraps, split
|
||||
flat.push([ns, TWO_PI]);
|
||||
flat.push([0, ne]);
|
||||
} else {
|
||||
flat.push([ns, ne]);
|
||||
}
|
||||
}
|
||||
flat.sort((a, b) => a[0] - b[0]);
|
||||
const merged: Array<[number, number]> = [];
|
||||
let cur = flat[0].slice() as [number, number];
|
||||
for (let i = 1; i < flat.length; i++) {
|
||||
const it = flat[i];
|
||||
if (it[0] <= cur[1] + 1e-9) {
|
||||
cur[1] = Math.max(cur[1], it[1]);
|
||||
} else {
|
||||
merged.push([cur[0], cur[1]]);
|
||||
cur = it.slice() as [number, number];
|
||||
}
|
||||
}
|
||||
merged.push([cur[0], cur[1]]);
|
||||
return merged;
|
||||
};
|
||||
|
||||
for (let i = 0; i < circles.length; i++) {
|
||||
const a = circles[i];
|
||||
// collect intervals on circle a that are covered by other circles
|
||||
const covered: Array<[number, number]> = [];
|
||||
let fullyCovered = false;
|
||||
|
||||
for (let j = 0; j < circles.length; j++) {
|
||||
if (i === j) continue;
|
||||
// Only consider coverage from circles owned by the same player.
|
||||
// This shows separate boundaries for different players' SAM coverage,
|
||||
// making contested areas visually distinct.
|
||||
if (a.owner !== circles[j].owner) continue;
|
||||
const b = circles[j];
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d + a.r <= b.r + 1e-9) {
|
||||
// circle a is fully inside b
|
||||
fullyCovered = true;
|
||||
break;
|
||||
}
|
||||
if (d >= a.r + b.r - 1e-9) {
|
||||
// no overlap
|
||||
continue;
|
||||
}
|
||||
if (d <= 1e-9) {
|
||||
// coincident centers but not fully covered (should be covered by previous check if radii differ)
|
||||
if (b.r >= a.r) {
|
||||
fullyCovered = true;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// compute angular span on circle a that is inside circle b
|
||||
const theta = Math.atan2(dy, dx);
|
||||
// law of cosines for angle between center-line and intersection points
|
||||
const cosPhi = (a.r * a.r + d * d - b.r * b.r) / (2 * a.r * d);
|
||||
// numerical clamp
|
||||
const clamp = Math.max(-1, Math.min(1, cosPhi));
|
||||
const phi = Math.acos(clamp);
|
||||
const start = theta - phi;
|
||||
const end = theta + phi;
|
||||
covered.push([start, end]);
|
||||
}
|
||||
|
||||
if (fullyCovered) continue; // nothing to stroke for this circle
|
||||
|
||||
const merged = mergeIntervals(covered);
|
||||
|
||||
// subtract merged covered intervals from [0,2pi) to get uncovered intervals
|
||||
const uncovered: Array<[number, number]> = [];
|
||||
if (merged.length === 0) {
|
||||
uncovered.push([0, TWO_PI]);
|
||||
} else {
|
||||
let cursor = 0;
|
||||
for (const [s, e] of merged) {
|
||||
if (s > cursor + 1e-9) {
|
||||
uncovered.push([cursor, s]);
|
||||
}
|
||||
cursor = Math.max(cursor, e);
|
||||
}
|
||||
if (cursor < TWO_PI - 1e-9) uncovered.push([cursor, TWO_PI]);
|
||||
}
|
||||
|
||||
// draw uncovered arcs
|
||||
for (const [s, e] of uncovered) {
|
||||
// skip tiny arcs
|
||||
if (e - s < 1e-3) continue;
|
||||
ctx.beginPath();
|
||||
ctx.arc(a.x, a.y, a.r, s, e);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user