From a5b0304ecfc856a3189d0ea5b64f1a42420b4dad Mon Sep 17 00:00:00 2001 From: JB940 Date: Thu, 20 Aug 2026 22:06:58 +0200 Subject: [PATCH 1/4] redo nuke trajectory sam interaction + performance --- .../controllers/BuildPreviewController.ts | 2 +- src/client/render/gl/utils/NukeTrajectory.ts | 304 ++++++++++++------ tests/NukeTrajectory.test.ts | 52 ++- tests/perf/NukeTrajectoryPerf.ts | 162 ++++++++++ 4 files changed, 424 insertions(+), 96 deletions(-) create mode 100644 tests/perf/NukeTrajectoryPerf.ts diff --git a/src/client/controllers/BuildPreviewController.ts b/src/client/controllers/BuildPreviewController.ts index 4dab7c6a2d..23bb674b93 100644 --- a/src/client/controllers/BuildPreviewController.ts +++ b/src/client/controllers/BuildPreviewController.ts @@ -407,7 +407,7 @@ export class BuildPreviewController implements Controller { sams.push({ x: this.game.x(s.tile()), y: this.game.y(s.tile()), - rangeSq: r * r, + r, }); } diff --git a/src/client/render/gl/utils/NukeTrajectory.ts b/src/client/render/gl/utils/NukeTrajectory.ts index 633b929654..7f9b8f0ec0 100644 --- a/src/client/render/gl/utils/NukeTrajectory.ts +++ b/src/client/render/gl/utils/NukeTrajectory.ts @@ -11,7 +11,7 @@ import type { NukeTrajectoryData } from "../../types"; const PARABOLA_MIN_HEIGHT = 50; const TARGETABLE_RANGE = 150; const TARGETABLE_RANGE_SQ = TARGETABLE_RANGE * TARGETABLE_RANGE; -const THRESHOLD_SAMPLES = 64; +const THRESHOLD_SAMPLES = 32; // SAM range formula: 150 - 480 / (level + 5) const MAX_SAM_RANGE = 150; @@ -25,7 +25,7 @@ export function samRange(level: number): number { export interface SAMInfo { x: number; y: number; - rangeSq: number; + r: number; } /** Cubic Bezier evaluation at parameter t. */ @@ -93,18 +93,43 @@ export function computeNukeControlPoints( }; } -/** Binary-search for the exact t where distSq to (cx,cy) crosses rangeSq. */ +/** + * Minimum squared distance from a point (px, py) to line segment (x0, y0) -> (x1, y1). + * Prevents skipping SAM circles on tangent crossings between discrete sample steps. + */ +function segmentDistSq( + x0: number, + y0: number, + x1: number, + y1: number, + px: number, + py: number, +): number { + const dx = x1 - x0; + const dy = y1 - y0; + const dxP = px - x0; + const dyP = py - y0; + const dSrcSq = dxP * dxP + dyP * dyP; + const dot = dxP * dx + dyP * dy; + + if (dot <= 0) return dSrcSq; + + const l2 = dx * dx + dy * dy; + if (dot >= l2) return dSrcSq + l2 - 2 * dot; + + return dSrcSq - (dot * dot) / l2; +} + +/** Binary-search for the exact parameter t where the trajectory enters/exits rangeSq. */ function refineCrossing( - cp: { - p0x: number; - p0y: number; - p1x: number; - p1y: number; - p2x: number; - p2y: number; - p3x: number; - p3y: number; - }, + polyAx: number, + polyBx: number, + polyCx: number, + polyDx: number, + polyAy: number, + polyBy: number, + polyCy: number, + polyDy: number, cx: number, cy: number, rangeSq: number, @@ -112,13 +137,28 @@ function refineCrossing( tHi: number, exitingRange: boolean, ): number { + let cachedLoX = + (((polyAx * tLo + polyBx) * tLo + polyCx) * tLo + polyDx + 0.5) | 0; + let cachedLoY = + (((polyAy * tLo + polyBy) * tLo + polyCy) * tLo + polyDy + 0.5) | 0; + for (let i = 0; i < 10; i++) { const tMid = (tLo + tHi) * 0.5; - const x = bezier(tMid, cp.p0x, cp.p1x, cp.p2x, cp.p3x); - const y = bezier(tMid, cp.p0y, cp.p1y, cp.p2y, cp.p3y); - const inside = distSq(x, y, cx, cy) <= rangeSq; - if (exitingRange ? inside : !inside) tLo = tMid; - else tHi = tMid; + + const xMid = + (((polyAx * tMid + polyBx) * tMid + polyCx) * tMid + polyDx + 0.5) | 0; + const yMid = + (((polyAy * tMid + polyBy) * tMid + polyCy) * tMid + polyDy + 0.5) | 0; + + const leftInside = + segmentDistSq(cachedLoX, cachedLoY, xMid, yMid, cx, cy) <= rangeSq; + if (exitingRange ? !leftInside : leftInside) { + tHi = tMid; + } else { + tLo = tMid; + cachedLoX = xMid; + cachedLoY = yMid; + } } return (tLo + tHi) * 0.5; } @@ -154,103 +194,167 @@ export function computeTrajectoryThresholds( let tUntargetableStart = -1; let tUntargetableEnd = -1; let tSamIntercept = 1.0; + let tSamPaddedIntercept = 1.0; const dt = 1.0 / THRESHOLD_SAMPLES; - // Pass 1: find untargetable zone boundaries + const polyCx = 3 * (cp.p1x - cp.p0x); + const polyBx = 3 * (cp.p2x - 2 * cp.p1x + cp.p0x); + const polyAx = dstX - 3 * cp.p2x + 3 * cp.p1x - cp.p0x; + const polyDx = cp.p0x; + + const polyCy = 3 * (cp.p1y - cp.p0y); + const polyBy = 3 * (cp.p2y - 2 * cp.p1y + cp.p0y); + const polyAy = dstY - 3 * cp.p2y + 3 * cp.p1y - cp.p0y; + const polyDy = cp.p0y; + + const srcDstDx = dstX - srcX; + const srcDstDy = dstY - srcY; + const srcDstDistSq = srcDstDx * srcDstDx + srcDstDy * srcDstDy; + + const hasUntargetable = srcDstDistSq > 4 * TARGETABLE_RANGE_SQ; + const samLen = sams.length; + + let prevX = (cp.p0x + 0.5) | 0; + let prevY = (cp.p0y + 0.5) | 0; + for (let i = 1; i <= THRESHOLD_SAMPLES; i++) { const t = i * dt; - const x = bezier(t, cp.p0x, cp.p1x, cp.p2x, cp.p3x); - const y = bezier(t, cp.p0y, cp.p1y, cp.p2y, cp.p3y); - - if (tUntargetableStart < 0) { - // Looking for first point outside source range - if (distSq(x, y, srcX, srcY) > TARGETABLE_RANGE_SQ) { - if (distSq(x, y, dstX, dstY) < TARGETABLE_RANGE_SQ) { - // Overlapping source & target range — no untargetable zone - break; + const tPrev = t - dt; + const x = (((polyAx * t + polyBx) * t + polyCx) * t + polyDx + 0.5) | 0; + const y = (((polyAy * t + polyBy) * t + polyCy) * t + polyDy + 0.5) | 0; + + let isUntargetableZone = false; + + if (hasUntargetable) { + if (tUntargetableStart < 0) { + // Looking for first point outside source range + const dxSrc = x - srcX; + const dySrc = y - srcY; + if (dxSrc * dxSrc + dySrc * dySrc > TARGETABLE_RANGE_SQ) { + const dxDst = x - dstX; + const dyDst = y - dstY; + if (dxDst * dxDst + dyDst * dyDst >= TARGETABLE_RANGE_SQ) { + tUntargetableStart = refineCrossing( + polyAx, polyBx, polyCx, polyDx, polyAy, polyBy, polyCy, polyDy, + srcX, srcY, TARGETABLE_RANGE_SQ, tPrev, t, true + ); + isUntargetableZone = true; + } + } + } else if (tUntargetableEnd < 0) { + // Looking for first point inside target range + const dxDst = x - dstX; + const dyDst = y - dstY; + if (dxDst * dxDst + dyDst * dyDst < TARGETABLE_RANGE_SQ) { + tUntargetableEnd = refineCrossing( + polyAx, polyBx, polyCx, polyDx, polyAy, polyBy, polyCy, polyDy, + dstX, dstY, TARGETABLE_RANGE_SQ, tPrev, t, false + ); + } else { + isUntargetableZone = true; } - tUntargetableStart = refineCrossing( - cp, - srcX, - srcY, - TARGETABLE_RANGE_SQ, - t - dt, - t, - true, - ); - } - } else { - // Looking for first point inside target range - if (distSq(x, y, dstX, dstY) < TARGETABLE_RANGE_SQ) { - tUntargetableEnd = refineCrossing( - cp, - dstX, - dstY, - TARGETABLE_RANGE_SQ, - t - dt, - t, - false, - ); - break; } } - } - // Pass 2: find SAM intercept (skip untargetable zone) - if (sams.length > 0) { - for (let i = 1; i <= THRESHOLD_SAMPLES; i++) { - const t = i * dt; - const tPrev = t - dt; - - if ( - tUntargetableStart >= 0 && - t > tUntargetableStart && - t < tUntargetableEnd - ) { - continue; + // Check exact boundary when crossing into the targetable terminal phase + if ( + tUntargetableEnd >= 0 && + tPrev < tUntargetableEnd && + t >= tUntargetableEnd && + samLen > 0 + ) { + const xe = + (((polyAx * tUntargetableEnd + polyBx) * tUntargetableEnd + polyCx) * + tUntargetableEnd + + polyDx + + 0.5) | + 0; + const ye = + (((polyAy * tUntargetableEnd + polyBy) * tUntargetableEnd + polyCy) * + tUntargetableEnd + + polyDy + + 0.5) | + 0; + for (let s = 0; s < samLen; s++) { + const sam = sams[s]; + const dx = xe - sam.x; + const dy = ye - sam.y; + if (dx * dx + dy * dy <= sam.r * sam.r) { + tSamIntercept = tUntargetableEnd; + break; + } } + if (tSamIntercept < 1.0) break; + } - // Check exact boundary when crossing into the targetable terminal phase - if ( - tUntargetableEnd >= 0 && - tPrev < tUntargetableEnd && - t >= tUntargetableEnd - ) { - const xe = bezier(tUntargetableEnd, cp.p0x, cp.p1x, cp.p2x, cp.p3x); - const ye = bezier(tUntargetableEnd, cp.p0y, cp.p1y, cp.p2y, cp.p3y); - for (let s = 0; s < sams.length; s++) { - if (distSq(xe, ye, sams[s].x, sams[s].y) <= sams[s].rangeSq) { - tSamIntercept = tUntargetableEnd; - break; - } + if (!isUntargetableZone && samLen > 0) { + const segDx = x - prevX; + const segDy = y - prevY; + const l2 = segDx * segDx + segDy * segDy; + const invL2 = l2 === 0 ? 0 : 1.0 / l2; + + for (let s = 0; s < samLen; s++) { + const sam = sams[s]; + + // Fast proximity rejection + const dxSam = sam.x - prevX; + const dySam = sam.y - prevY; + const dSrcSq = dxSam * dxSam + dySam * dySam; + if (dSrcSq > 62500) { + continue; } - if (tSamIntercept < 1.0) break; - } - const x = bezier(t, cp.p0x, cp.p1x, cp.p2x, cp.p3x); - const y = bezier(t, cp.p0y, cp.p1y, cp.p2y, cp.p3y); + let dSq: number; + const dot = dxSam * segDx + dySam * segDy; + + if (dot <= 0) { + dSq = dSrcSq; + } else if (dot >= l2) { + dSq = dSrcSq + l2 - 2 * dot; + } else { + dSq = dSrcSq - dot * dot * invL2; + } - for (let s = 0; s < sams.length; s++) { - if (distSq(x, y, sams[s].x, sams[s].y) <= sams[s].rangeSq) { + const rangeSq = sam.r * sam.r; + if (dSq <= rangeSq) { const lo = tUntargetableEnd >= 0 && tPrev < tUntargetableEnd ? tUntargetableEnd : tPrev; + // Direct hit: exact boundary intersection (r) tSamIntercept = refineCrossing( - cp, - sams[s].x, - sams[s].y, - sams[s].rangeSq, - lo, - t, - false, + polyAx, polyBx, polyCx, polyDx, polyAy, polyBy, polyCy, polyDy, + sam.x, sam.y, rangeSq, lo, t, false ); break; + } else { + // Glancing blow: record padded intercept (r + 0.5) as fallback for edge-case skimming + const paddedRangeSq = (sam.r + 0.5) * (sam.r + 0.5); + if (dSq <= paddedRangeSq) { + if (tSamPaddedIntercept === 1.0) { + const lo = + tUntargetableEnd >= 0 && tPrev < tUntargetableEnd + ? tUntargetableEnd + : tPrev; + tSamPaddedIntercept = refineCrossing( + polyAx, polyBx, polyCx, polyDx, polyAy, polyBy, polyCy, polyDy, + sam.x, sam.y, paddedRangeSq, lo, t, false + ); + } + } } } if (tSamIntercept < 1.0) break; } + + prevX = x; + prevY = y; + } + + // Fallback to padded intercept ONLY if no direct hit occurred along the trajectory + if (tSamIntercept === 1.0 && tSamPaddedIntercept < 1.0) { + tSamIntercept = tSamPaddedIntercept; } return { tUntargetableStart, tUntargetableEnd, tSamIntercept }; @@ -258,7 +362,8 @@ export function computeTrajectoryThresholds( /** * Build complete NukeTrajectoryData from source/target positions. - * Convenience function combining control point + threshold computation. + * Uses smooth render control points for continuous 60fps GPU mouse tracking, + * combined with discrete tile control points for Core simulation threshold accuracy. */ export function buildNukeTrajectory( srcX: number, @@ -269,7 +374,7 @@ export function buildNukeTrajectory( directionUp: boolean, sams: readonly SAMInfo[], ): NukeTrajectoryData { - const cp = computeNukeControlPoints( + const cpRender = computeNukeControlPoints( srcX, srcY, dstX, @@ -277,6 +382,17 @@ export function buildNukeTrajectory( mapH, directionUp, ); - const th = computeTrajectoryThresholds(cp, srcX, srcY, dstX, dstY, sams); - return { ...cp, ...th }; + + const targetX = Math.round(dstX); + const targetY = Math.round(dstY); + + const th = computeTrajectoryThresholds( + cpRender, + srcX, + srcY, + targetX, + targetY, + sams, + ); + return { ...cpRender, ...th }; } diff --git a/tests/NukeTrajectory.test.ts b/tests/NukeTrajectory.test.ts index 24b97a1e09..b7d4d637ef 100644 --- a/tests/NukeTrajectory.test.ts +++ b/tests/NukeTrajectory.test.ts @@ -45,8 +45,58 @@ describe("NukeTrajectory thresholds", () => { T * (T * (T * cp.p0y + 3 * t * cp.p1y) + 3 * t * t * cp.p2y) + t * t * t * cp.p3y; const th = computeTrajectoryThresholds(cp, 100, 500, 800, 500, [ - { x, y, rangeSq: 25 }, + { x, y, r: 5 }, ]); expect(th.tSamIntercept).toBe(t); }); + + test("detects SAM intercept along trajectory", () => { + const cp = horizontalCp(100, 800); + const sam = { x: 750, y: 500, r: 50 }; + const th = computeTrajectoryThresholds(cp, 100, 500, 800, 500, [sam]); + expect(th.tSamIntercept).toBeLessThan(1.0); + }); + + test("validates 32 samples with 0.5 offset across all tangent cells and safe cells", () => { + const srcX = 1249; + const srcY = 108; + const samX = 984; + const samY = 380; + const r = 150 - 480 / 11 + 0.5; // Level 6 SAM + 0.5 buffer + const sams = [{ x: samX, y: samY, r }]; + + // Tangent cells crossing into SAM range + const tangentCells = [ + { dstX: 859, dstY: 397 }, + { dstX: 864, dstY: 385 }, + { dstX: 860, dstY: 395 }, + { dstX: 862, dstY: 390 }, + { dstX: 863, dstY: 387 }, + ]; + + for (const cell of tangentCells) { + const traj = buildNukeTrajectory( + srcX, + srcY, + cell.dstX, + cell.dstY, + 1000, + true, + sams, + ); + expect(traj.tSamIntercept).toBeLessThan(1.0); + } + + // Safe cell passing outside SAM range + const safeTraj = buildNukeTrajectory( + srcX, + srcY, + 865, + 380, + 1000, + true, + sams, + ); + expect(safeTraj.tSamIntercept).toBe(1.0); + }); }); diff --git a/tests/perf/NukeTrajectoryPerf.ts b/tests/perf/NukeTrajectoryPerf.ts new file mode 100644 index 0000000000..2d1d5b8f00 --- /dev/null +++ b/tests/perf/NukeTrajectoryPerf.ts @@ -0,0 +1,162 @@ +import Benchmark from "benchmark"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { buildNukeTrajectory } from "../../src/client/render/gl/utils/NukeTrajectory"; +import { PlayerInfo, PlayerType, UnitType } from "../../src/core/game/Game"; +import { setup } from "../util/Setup"; + +// Setup giant world map scenario with 2 players for in-game pipeline testing +const giantMapGame = await setup( + "giantworldmap", + { infiniteGold: true, instantBuild: true }, + [ + new PlayerInfo("player1", PlayerType.Human, "client_id1", "my_player_id"), + new PlayerInfo("enemy", PlayerType.Bot, "client_id2", "enemy_player_id"), + ], + dirname(fileURLToPath(import.meta.url)), +); + +const myPlayer = giantMapGame.player("my_player_id"); +const enemyPlayer = giantMapGame.player("enemy_player_id"); +const mapH = giantMapGame.map().height() * 50; + +// Conquer land to place units +console.log("Setting up in-game board state (500 mixed units)..."); +for (let x = 0; x < giantMapGame.map().width(); x += 10) { + for (let y = 0; y < giantMapGame.map().height(); y += 10) { + const tile = giantMapGame.ref(x, y); + if (giantMapGame.map().isLand(tile)) { + myPlayer.conquer(tile); + enemyPlayer.conquer(tile); + } + } +} + +// Build my missile silo +const mySiloTile = giantMapGame.ref(25, 2); +myPlayer.buildUnit(UnitType.MissileSilo, mySiloTile, {}); + +// Populate mixed units (Factories, Cities, Silos, SAMs) across the map +const unitTypes = [UnitType.MissileSilo, UnitType.Factory, UnitType.SAMLauncher, UnitType.City]; +let unitCount = 0; + +for (let x = 50; x < 3500; x += 150) { + for (let y = 50; y < 1500; y += 150) { + if (unitCount >= 500) break; + const tile = giantMapGame.ref(Math.floor(x / 50), Math.floor(y / 50)); + if (giantMapGame.map().isLand(tile)) { + const isMine = unitCount % 2 === 0; + const type = unitTypes[unitCount % unitTypes.length]; + const player = isMine ? myPlayer : enemyPlayer; + const forceSam = x >= 400 && x <= 1100 && !isMine ? UnitType.SAMLauncher : type; + player.buildUnit(forceSam, tile, {}); + unitCount++; + } + } +} + +// Exact mock scenarios for mathematical comparisons +const sparseSams: { x: number; y: number; r: number }[] = [ + { x: 300, y: 300, r: 120 }, + { x: 450, y: 250, r: 90 }, + { x: 600, y: 400, r: 150 }, + { x: 800, y: 350, r: 100 }, + { x: 950, y: 300, r: 80 }, +]; + +const denseSams = Array.from({ length: 20 }, (_, k) => ({ + x: 200 + k * 40, + y: 200 + (k % 5) * 100, + r: 70 + (k % 4) * 10, +})); + +const giantSams = Array.from({ length: 100 }, (_, k) => ({ + x: 100 + (k % 10) * 350, + y: 100 + Math.floor(k / 10) * 180, + r: 110, +})); + +// In-game: Starting nuke trajectory render extracts static SAM list ONCE +function startNukeTrajectoryRender() { + const extractedSams: { x: number; y: number; r: number }[] = []; + const mapWidth = giantMapGame.map().width(); + + for (const u of giantMapGame.units()) { + if ( + u.type() === UnitType.SAMLauncher && + u.owner().id() !== "my_player_id" && + u.isActive() + ) { + extractedSams.push({ + x: (u.tile() % mapWidth) * 50, + y: Math.floor(u.tile() / mapWidth) * 50, + r: 150 - 480 / (u.level() + 5), + }); + } + } + + return { + srcX: (mySiloTile % mapWidth) * 50, + srcY: Math.floor(mySiloTile / mapWidth) * 50, + directionUp: true, + sams: extractedSams, + }; +} + +const inGameStaticTrajectory = startNukeTrajectoryRender(); + +// Simulated mouse motion path +let mouseTick = 0; +function getSimulatedMousePos() { + mouseTick++; + return { + x: 859 + Math.sin(mouseTick * 0.1) * 100, + y: 397 + Math.cos(mouseTick * 0.1) * 100, + }; +} + +const srcX = 1249; +const srcY = 108; +const dstX = 859; +const dstY = 397; + +const results: string[] = []; + +new Benchmark.Suite() + .add("Scenario 1: 0 SAMs (Early game)", () => { + buildNukeTrajectory(srcX, srcY, dstX, dstY, mapH, true, []); + }) + .add("Scenario 2: 5 SAMs (Active Intercept)", () => { + buildNukeTrajectory(srcX, srcY, dstX, dstY, mapH, true, sparseSams); + }) + .add("Scenario 3: 20 SAMs (Mid-Late Game)", () => { + buildNukeTrajectory(srcX, srcY, dstX, dstY, mapH, true, denseSams); + }) + .add("Scenario 4: 100 SAMs (Giant World Map Endgame)", () => { + buildNukeTrajectory(srcX, srcY, dstX, dstY, mapH, true, giantSams); + }) + .add("In-Game Phase 1: Start Render (Find Silo + Extract SAMs)", () => { + startNukeTrajectoryRender(); + }) + .add("In-Game Phase 2: Live Cursor Move (60 FPS per-frame trajectory update)", () => { + const mouse = getSimulatedMousePos(); + buildNukeTrajectory( + inGameStaticTrajectory.srcX, + inGameStaticTrajectory.srcY, + mouse.x, + mouse.y, + mapH, + inGameStaticTrajectory.directionUp, + inGameStaticTrajectory.sams, + ); + }) + .on("cycle", (event: any) => { + results.push(String(event.target)); + }) + .on("complete", () => { + console.log("\n=== NukeTrajectory Performance Benchmark Results ==="); + for (const result of results) { + console.log(result); + } + }) + .run({ async: true }); From ea9e87f4f2eb9e7bb655f7f868a20fa3fa2a6017 Mon Sep 17 00:00:00 2001 From: JB940 Date: Fri, 21 Aug 2026 01:52:57 +0200 Subject: [PATCH 2/4] run prettier and minor fixes --- src/client/render/gl/utils/NukeTrajectory.ts | 167 +++++++++---------- tests/NukeTrajectory.test.ts | 28 +--- tests/perf/NukeTrajectoryPerf.ts | 39 +++-- 3 files changed, 106 insertions(+), 128 deletions(-) diff --git a/src/client/render/gl/utils/NukeTrajectory.ts b/src/client/render/gl/utils/NukeTrajectory.ts index 7f9b8f0ec0..9a3449c040 100644 --- a/src/client/render/gl/utils/NukeTrajectory.ts +++ b/src/client/render/gl/utils/NukeTrajectory.ts @@ -28,30 +28,10 @@ export interface SAMInfo { r: number; } -/** Cubic Bezier evaluation at parameter t. */ -function bezier( - t: number, - p0: number, - p1: number, - p2: number, - p3: number, -): number { - const T = 1 - t; - return ( - T * T * T * p0 + 3 * T * T * t * p1 + 3 * T * t * t * p2 + t * t * t * p3 - ); -} - function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : v > hi ? hi : v; } -function distSq(ax: number, ay: number, bx: number, by: number): number { - const dx = ax - bx; - const dy = ay - by; - return dx * dx + dy * dy; -} - /** * Compute Bezier control points matching upstream parabola pathfinder. * @@ -93,33 +73,6 @@ export function computeNukeControlPoints( }; } -/** - * Minimum squared distance from a point (px, py) to line segment (x0, y0) -> (x1, y1). - * Prevents skipping SAM circles on tangent crossings between discrete sample steps. - */ -function segmentDistSq( - x0: number, - y0: number, - x1: number, - y1: number, - px: number, - py: number, -): number { - const dx = x1 - x0; - const dy = y1 - y0; - const dxP = px - x0; - const dyP = py - y0; - const dSrcSq = dxP * dxP + dyP * dyP; - const dot = dxP * dx + dyP * dy; - - if (dot <= 0) return dSrcSq; - - const l2 = dx * dx + dy * dy; - if (dot >= l2) return dSrcSq + l2 - 2 * dot; - - return dSrcSq - (dot * dot) / l2; -} - /** Binary-search for the exact parameter t where the trajectory enters/exits rangeSq. */ function refineCrossing( polyAx: number, @@ -137,10 +90,7 @@ function refineCrossing( tHi: number, exitingRange: boolean, ): number { - let cachedLoX = - (((polyAx * tLo + polyBx) * tLo + polyCx) * tLo + polyDx + 0.5) | 0; - let cachedLoY = - (((polyAy * tLo + polyBy) * tLo + polyCy) * tLo + polyDy + 0.5) | 0; + let foundInside = false; for (let i = 0; i < 10; i++) { const tMid = (tLo + tHi) * 0.5; @@ -150,16 +100,33 @@ function refineCrossing( const yMid = (((polyAy * tMid + polyBy) * tMid + polyCy) * tMid + polyDy + 0.5) | 0; - const leftInside = - segmentDistSq(cachedLoX, cachedLoY, xMid, yMid, cx, cy) <= rangeSq; - if (exitingRange ? !leftInside : leftInside) { - tHi = tMid; - } else { + const dx = xMid - cx; + const dy = yMid - cy; + const inside = dx * dx + dy * dy <= rangeSq; + if (inside) { + foundInside = true; + } + + if (exitingRange ? inside : !inside) { tLo = tMid; - cachedLoX = xMid; - cachedLoY = yMid; + } else { + tHi = tMid; } } + + // If testing entry and no point on the curve was inside rangeSq, reject chord false-alarm + if (!exitingRange && !foundInside) { + const xHi = + (((polyAx * tHi + polyBx) * tHi + polyCx) * tHi + polyDx + 0.5) | 0; + const yHi = + (((polyAy * tHi + polyBy) * tHi + polyCy) * tHi + polyDy + 0.5) | 0; + const dxHi = xHi - cx; + const dyHi = yHi - cy; + if (dxHi * dxHi + dyHi * dyHi > rangeSq) { + return 1.0; + } + } + return (tLo + tHi) * 0.5; } @@ -194,7 +161,6 @@ export function computeTrajectoryThresholds( let tUntargetableStart = -1; let tUntargetableEnd = -1; let tSamIntercept = 1.0; - let tSamPaddedIntercept = 1.0; const dt = 1.0 / THRESHOLD_SAMPLES; @@ -211,7 +177,7 @@ export function computeTrajectoryThresholds( const srcDstDx = dstX - srcX; const srcDstDy = dstY - srcY; const srcDstDistSq = srcDstDx * srcDstDx + srcDstDy * srcDstDy; - + const hasUntargetable = srcDstDistSq > 4 * TARGETABLE_RANGE_SQ; const samLen = sams.length; @@ -236,8 +202,20 @@ export function computeTrajectoryThresholds( const dyDst = y - dstY; if (dxDst * dxDst + dyDst * dyDst >= TARGETABLE_RANGE_SQ) { tUntargetableStart = refineCrossing( - polyAx, polyBx, polyCx, polyDx, polyAy, polyBy, polyCy, polyDy, - srcX, srcY, TARGETABLE_RANGE_SQ, tPrev, t, true + polyAx, + polyBx, + polyCx, + polyDx, + polyAy, + polyBy, + polyCy, + polyDy, + srcX, + srcY, + TARGETABLE_RANGE_SQ, + tPrev, + t, + true, ); isUntargetableZone = true; } @@ -248,8 +226,20 @@ export function computeTrajectoryThresholds( const dyDst = y - dstY; if (dxDst * dxDst + dyDst * dyDst < TARGETABLE_RANGE_SQ) { tUntargetableEnd = refineCrossing( - polyAx, polyBx, polyCx, polyDx, polyAy, polyBy, polyCy, polyDy, - dstX, dstY, TARGETABLE_RANGE_SQ, tPrev, t, false + polyAx, + polyBx, + polyCx, + polyDx, + polyAy, + polyBy, + polyCy, + polyDy, + dstX, + dstY, + TARGETABLE_RANGE_SQ, + tPrev, + t, + false, ); } else { isUntargetableZone = true; @@ -307,7 +297,7 @@ export function computeTrajectoryThresholds( let dSq: number; const dot = dxSam * segDx + dySam * segDy; - + if (dot <= 0) { dSq = dSrcSq; } else if (dot >= l2) { @@ -317,31 +307,33 @@ export function computeTrajectoryThresholds( } const rangeSq = sam.r * sam.r; - if (dSq <= rangeSq) { + // safety margin, since we compare straight lines to arcs + // assures even on giant-world-map worst-case, it will correctly calculate + const candidateRangeSq = (sam.r + 0.75) * (sam.r + 0.75); + if (dSq <= candidateRangeSq) { const lo = tUntargetableEnd >= 0 && tPrev < tUntargetableEnd ? tUntargetableEnd : tPrev; - // Direct hit: exact boundary intersection (r) - tSamIntercept = refineCrossing( - polyAx, polyBx, polyCx, polyDx, polyAy, polyBy, polyCy, polyDy, - sam.x, sam.y, rangeSq, lo, t, false + const intercept = refineCrossing( + polyAx, + polyBx, + polyCx, + polyDx, + polyAy, + polyBy, + polyCy, + polyDy, + sam.x, + sam.y, + rangeSq, + lo, + t, + false, ); - break; - } else { - // Glancing blow: record padded intercept (r + 0.5) as fallback for edge-case skimming - const paddedRangeSq = (sam.r + 0.5) * (sam.r + 0.5); - if (dSq <= paddedRangeSq) { - if (tSamPaddedIntercept === 1.0) { - const lo = - tUntargetableEnd >= 0 && tPrev < tUntargetableEnd - ? tUntargetableEnd - : tPrev; - tSamPaddedIntercept = refineCrossing( - polyAx, polyBx, polyCx, polyDx, polyAy, polyBy, polyCy, polyDy, - sam.x, sam.y, paddedRangeSq, lo, t, false - ); - } + if (intercept < 1.0) { + tSamIntercept = intercept; + break; } } } @@ -352,11 +344,6 @@ export function computeTrajectoryThresholds( prevY = y; } - // Fallback to padded intercept ONLY if no direct hit occurred along the trajectory - if (tSamIntercept === 1.0 && tSamPaddedIntercept < 1.0) { - tSamIntercept = tSamPaddedIntercept; - } - return { tUntargetableStart, tUntargetableEnd, tSamIntercept }; } diff --git a/tests/NukeTrajectory.test.ts b/tests/NukeTrajectory.test.ts index b7d4d637ef..9261a1c51b 100644 --- a/tests/NukeTrajectory.test.ts +++ b/tests/NukeTrajectory.test.ts @@ -57,35 +57,17 @@ describe("NukeTrajectory thresholds", () => { expect(th.tSamIntercept).toBeLessThan(1.0); }); - test("validates 32 samples with 0.5 offset across all tangent cells and safe cells", () => { + test("validates 32 samples across tangent cells and safe cells without padding", () => { const srcX = 1249; const srcY = 108; const samX = 984; const samY = 380; - const r = 150 - 480 / 11 + 0.5; // Level 6 SAM + 0.5 buffer + const r = 150 - 480 / 11; // Level 6 SAM exact radius const sams = [{ x: samX, y: samY, r }]; - // Tangent cells crossing into SAM range - const tangentCells = [ - { dstX: 859, dstY: 397 }, - { dstX: 864, dstY: 385 }, - { dstX: 860, dstY: 395 }, - { dstX: 862, dstY: 390 }, - { dstX: 863, dstY: 387 }, - ]; - - for (const cell of tangentCells) { - const traj = buildNukeTrajectory( - srcX, - srcY, - cell.dstX, - cell.dstY, - 1000, - true, - sams, - ); - expect(traj.tSamIntercept).toBeLessThan(1.0); - } + // Direct intercept cell + const traj = buildNukeTrajectory(srcX, srcY, 859, 397, 1000, true, sams); + expect(traj.tSamIntercept).toBeLessThan(1.0); // Safe cell passing outside SAM range const safeTraj = buildNukeTrajectory( diff --git a/tests/perf/NukeTrajectoryPerf.ts b/tests/perf/NukeTrajectoryPerf.ts index 2d1d5b8f00..50e0f4f29d 100644 --- a/tests/perf/NukeTrajectoryPerf.ts +++ b/tests/perf/NukeTrajectoryPerf.ts @@ -37,7 +37,12 @@ const mySiloTile = giantMapGame.ref(25, 2); myPlayer.buildUnit(UnitType.MissileSilo, mySiloTile, {}); // Populate mixed units (Factories, Cities, Silos, SAMs) across the map -const unitTypes = [UnitType.MissileSilo, UnitType.Factory, UnitType.SAMLauncher, UnitType.City]; +const unitTypes = [ + UnitType.MissileSilo, + UnitType.Factory, + UnitType.SAMLauncher, + UnitType.City, +]; let unitCount = 0; for (let x = 50; x < 3500; x += 150) { @@ -48,7 +53,8 @@ for (let x = 50; x < 3500; x += 150) { const isMine = unitCount % 2 === 0; const type = unitTypes[unitCount % unitTypes.length]; const player = isMine ? myPlayer : enemyPlayer; - const forceSam = x >= 400 && x <= 1100 && !isMine ? UnitType.SAMLauncher : type; + const forceSam = + x >= 400 && x <= 1100 && !isMine ? UnitType.SAMLauncher : type; player.buildUnit(forceSam, tile, {}); unitCount++; } @@ -80,7 +86,7 @@ const giantSams = Array.from({ length: 100 }, (_, k) => ({ function startNukeTrajectoryRender() { const extractedSams: { x: number; y: number; r: number }[] = []; const mapWidth = giantMapGame.map().width(); - + for (const u of giantMapGame.units()) { if ( u.type() === UnitType.SAMLauncher && @@ -138,18 +144,21 @@ new Benchmark.Suite() .add("In-Game Phase 1: Start Render (Find Silo + Extract SAMs)", () => { startNukeTrajectoryRender(); }) - .add("In-Game Phase 2: Live Cursor Move (60 FPS per-frame trajectory update)", () => { - const mouse = getSimulatedMousePos(); - buildNukeTrajectory( - inGameStaticTrajectory.srcX, - inGameStaticTrajectory.srcY, - mouse.x, - mouse.y, - mapH, - inGameStaticTrajectory.directionUp, - inGameStaticTrajectory.sams, - ); - }) + .add( + "In-Game Phase 2: Live Cursor Move (60 FPS per-frame trajectory update)", + () => { + const mouse = getSimulatedMousePos(); + buildNukeTrajectory( + inGameStaticTrajectory.srcX, + inGameStaticTrajectory.srcY, + mouse.x, + mouse.y, + mapH, + inGameStaticTrajectory.directionUp, + inGameStaticTrajectory.sams, + ); + }, + ) .on("cycle", (event: any) => { results.push(String(event.target)); }) From 4b82f6d819b6922e0fc4a9da38dc7e615cac507d Mon Sep 17 00:00:00 2001 From: JB940 Date: Sun, 23 Aug 2026 02:28:15 +0200 Subject: [PATCH 3/4] Fix rabbit feedback --- src/client/render/gl/utils/NukeTrajectory.ts | 12 +++-- tests/NukeTrajectory.test.ts | 3 +- tests/perf/NukeTrajectoryPerf.ts | 47 ++++++++++++-------- 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/src/client/render/gl/utils/NukeTrajectory.ts b/src/client/render/gl/utils/NukeTrajectory.ts index 9a3449c040..206bd77aa8 100644 --- a/src/client/render/gl/utils/NukeTrajectory.ts +++ b/src/client/render/gl/utils/NukeTrajectory.ts @@ -17,6 +17,7 @@ const THRESHOLD_SAMPLES = 32; const MAX_SAM_RANGE = 150; const SAM_RANGE_DIVISOR = 480; const SAM_RANGE_OFFSET = 5; +const SAM_SAFETY_MARGIN = 0.75; export function samRange(level: number): number { return MAX_SAM_RANGE - SAM_RANGE_DIVISOR / (level + SAM_RANGE_OFFSET); @@ -164,6 +165,8 @@ export function computeTrajectoryThresholds( const dt = 1.0 / THRESHOLD_SAMPLES; + // dstX and dstY represent the rounded integer target tile coordinates (unlike cp.p3x/p3y + // which track the live float cursor for GPU rendering), ensuring threshold math matches Core. const polyCx = 3 * (cp.p1x - cp.p0x); const polyBx = 3 * (cp.p2x - 2 * cp.p1x + cp.p0x); const polyAx = dstX - 3 * cp.p2x + 3 * cp.p1x - cp.p0x; @@ -283,15 +286,17 @@ export function computeTrajectoryThresholds( const segDy = y - prevY; const l2 = segDx * segDx + segDy * segDy; const invL2 = l2 === 0 ? 0 : 1.0 / l2; + const maxDist = Math.sqrt(l2) + MAX_SAM_RANGE + SAM_SAFETY_MARGIN; + const maxDSrcSq = maxDist * maxDist; for (let s = 0; s < samLen; s++) { const sam = sams[s]; - // Fast proximity rejection + // Fast proximity rejection based on maximum reachable distance of this segment const dxSam = sam.x - prevX; const dySam = sam.y - prevY; const dSrcSq = dxSam * dxSam + dySam * dySam; - if (dSrcSq > 62500) { + if (dSrcSq > maxDSrcSq) { continue; } @@ -309,7 +314,8 @@ export function computeTrajectoryThresholds( const rangeSq = sam.r * sam.r; // safety margin, since we compare straight lines to arcs // assures even on giant-world-map worst-case, it will correctly calculate - const candidateRangeSq = (sam.r + 0.75) * (sam.r + 0.75); + const candidateRangeSq = + (sam.r + SAM_SAFETY_MARGIN) * (sam.r + SAM_SAFETY_MARGIN); if (dSq <= candidateRangeSq) { const lo = tUntargetableEnd >= 0 && tPrev < tUntargetableEnd diff --git a/tests/NukeTrajectory.test.ts b/tests/NukeTrajectory.test.ts index 9261a1c51b..e3f56dc072 100644 --- a/tests/NukeTrajectory.test.ts +++ b/tests/NukeTrajectory.test.ts @@ -2,6 +2,7 @@ import { buildNukeTrajectory, computeNukeControlPoints, computeTrajectoryThresholds, + samRange, } from "../src/client/render/gl/utils/NukeTrajectory"; // A large map height so the parabola arc isn't clamped. @@ -62,7 +63,7 @@ describe("NukeTrajectory thresholds", () => { const srcY = 108; const samX = 984; const samY = 380; - const r = 150 - 480 / 11; // Level 6 SAM exact radius + const r = samRange(6); // Level 6 SAM exact radius const sams = [{ x: samX, y: samY, r }]; // Direct intercept cell diff --git a/tests/perf/NukeTrajectoryPerf.ts b/tests/perf/NukeTrajectoryPerf.ts index 50e0f4f29d..ae9cda0078 100644 --- a/tests/perf/NukeTrajectoryPerf.ts +++ b/tests/perf/NukeTrajectoryPerf.ts @@ -1,7 +1,10 @@ import Benchmark from "benchmark"; import { dirname } from "path"; import { fileURLToPath } from "url"; -import { buildNukeTrajectory } from "../../src/client/render/gl/utils/NukeTrajectory"; +import { + buildNukeTrajectory, + SAMInfo, +} from "../../src/client/render/gl/utils/NukeTrajectory"; import { PlayerInfo, PlayerType, UnitType } from "../../src/core/game/Game"; import { setup } from "../util/Setup"; @@ -18,16 +21,19 @@ const giantMapGame = await setup( const myPlayer = giantMapGame.player("my_player_id"); const enemyPlayer = giantMapGame.player("enemy_player_id"); -const mapH = giantMapGame.map().height() * 50; +const mapH = giantMapGame.map().height(); -// Conquer land to place units +// Conquer land to place units (split territory between players) console.log("Setting up in-game board state (500 mixed units)..."); for (let x = 0; x < giantMapGame.map().width(); x += 10) { for (let y = 0; y < giantMapGame.map().height(); y += 10) { const tile = giantMapGame.ref(x, y); if (giantMapGame.map().isLand(tile)) { - myPlayer.conquer(tile); - enemyPlayer.conquer(tile); + if ((x + y) % 20 === 0) { + myPlayer.conquer(tile); + } else { + enemyPlayer.conquer(tile); + } } } } @@ -45,16 +51,16 @@ const unitTypes = [ ]; let unitCount = 0; -for (let x = 50; x < 3500; x += 150) { - for (let y = 50; y < 1500; y += 150) { +for (let x = 1; x < giantMapGame.map().width(); x += 3) { + for (let y = 1; y < giantMapGame.map().height(); y += 3) { if (unitCount >= 500) break; - const tile = giantMapGame.ref(Math.floor(x / 50), Math.floor(y / 50)); + const tile = giantMapGame.ref(x, y); if (giantMapGame.map().isLand(tile)) { const isMine = unitCount % 2 === 0; const type = unitTypes[unitCount % unitTypes.length]; const player = isMine ? myPlayer : enemyPlayer; const forceSam = - x >= 400 && x <= 1100 && !isMine ? UnitType.SAMLauncher : type; + x >= 8 && x <= 22 && !isMine ? UnitType.SAMLauncher : type; player.buildUnit(forceSam, tile, {}); unitCount++; } @@ -62,7 +68,7 @@ for (let x = 50; x < 3500; x += 150) { } // Exact mock scenarios for mathematical comparisons -const sparseSams: { x: number; y: number; r: number }[] = [ +const sparseSams: SAMInfo[] = [ { x: 300, y: 300, r: 120 }, { x: 450, y: 250, r: 90 }, { x: 600, y: 400, r: 150 }, @@ -70,21 +76,26 @@ const sparseSams: { x: number; y: number; r: number }[] = [ { x: 950, y: 300, r: 80 }, ]; -const denseSams = Array.from({ length: 20 }, (_, k) => ({ +const denseSams: SAMInfo[] = Array.from({ length: 20 }, (_, k) => ({ x: 200 + k * 40, y: 200 + (k % 5) * 100, r: 70 + (k % 4) * 10, })); -const giantSams = Array.from({ length: 100 }, (_, k) => ({ +const giantSams: SAMInfo[] = Array.from({ length: 100 }, (_, k) => ({ x: 100 + (k % 10) * 350, y: 100 + Math.floor(k / 10) * 180, r: 110, })); // In-game: Starting nuke trajectory render extracts static SAM list ONCE -function startNukeTrajectoryRender() { - const extractedSams: { x: number; y: number; r: number }[] = []; +function startNukeTrajectoryRender(): { + srcX: number; + srcY: number; + directionUp: boolean; + sams: SAMInfo[]; +} { + const extractedSams: SAMInfo[] = []; const mapWidth = giantMapGame.map().width(); for (const u of giantMapGame.units()) { @@ -94,16 +105,16 @@ function startNukeTrajectoryRender() { u.isActive() ) { extractedSams.push({ - x: (u.tile() % mapWidth) * 50, - y: Math.floor(u.tile() / mapWidth) * 50, + x: u.tile() % mapWidth, + y: Math.floor(u.tile() / mapWidth), r: 150 - 480 / (u.level() + 5), }); } } return { - srcX: (mySiloTile % mapWidth) * 50, - srcY: Math.floor(mySiloTile / mapWidth) * 50, + srcX: mySiloTile % mapWidth, + srcY: Math.floor(mySiloTile / mapWidth), directionUp: true, sams: extractedSams, }; From e6193a7178b512737d7ae21d3c25f98a7eeb036e Mon Sep 17 00:00:00 2001 From: JB940 Date: Sun, 23 Aug 2026 03:12:22 +0200 Subject: [PATCH 4/4] add event to test --- tests/perf/NukeTrajectoryPerf.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/perf/NukeTrajectoryPerf.ts b/tests/perf/NukeTrajectoryPerf.ts index ae9cda0078..042c101f40 100644 --- a/tests/perf/NukeTrajectoryPerf.ts +++ b/tests/perf/NukeTrajectoryPerf.ts @@ -170,7 +170,7 @@ new Benchmark.Suite() ); }, ) - .on("cycle", (event: any) => { + .on("cycle", (event: Benchmark.Event) => { results.push(String(event.target)); }) .on("complete", () => {