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..206bd77aa8 100644 --- a/src/client/render/gl/utils/NukeTrajectory.ts +++ b/src/client/render/gl/utils/NukeTrajectory.ts @@ -11,12 +11,13 @@ 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; 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); @@ -25,33 +26,13 @@ export function samRange(level: number): number { export interface SAMInfo { x: number; y: number; - rangeSq: 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 - ); + r: number; } 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,18 +74,16 @@ export function computeNukeControlPoints( }; } -/** Binary-search for the exact t where distSq to (cx,cy) crosses rangeSq. */ +/** 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,14 +91,43 @@ function refineCrossing( tHi: number, exitingRange: boolean, ): number { + let foundInside = false; + 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 dx = xMid - cx; + const dy = yMid - cy; + const inside = dx * dx + dy * dy <= rangeSq; + if (inside) { + foundInside = true; + } + + if (exitingRange ? inside : !inside) { + tLo = tMid; + } 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; } @@ -157,100 +165,189 @@ export function computeTrajectoryThresholds( const dt = 1.0 / THRESHOLD_SAMPLES; - // Pass 1: find untargetable zone boundaries + // 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; + 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; + 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 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 > maxDSrcSq) { + 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; - for (let s = 0; s < sams.length; s++) { - if (distSq(x, y, sams[s].x, sams[s].y) <= sams[s].rangeSq) { + if (dot <= 0) { + dSq = dSrcSq; + } else if (dot >= l2) { + dSq = dSrcSq + l2 - 2 * dot; + } else { + dSq = dSrcSq - dot * dot * invL2; + } + + 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 + SAM_SAFETY_MARGIN) * (sam.r + SAM_SAFETY_MARGIN); + if (dSq <= candidateRangeSq) { const lo = tUntargetableEnd >= 0 && tPrev < tUntargetableEnd ? tUntargetableEnd : tPrev; - tSamIntercept = refineCrossing( - cp, - sams[s].x, - sams[s].y, - sams[s].rangeSq, + const intercept = refineCrossing( + polyAx, + polyBx, + polyCx, + polyDx, + polyAy, + polyBy, + polyCy, + polyDy, + sam.x, + sam.y, + rangeSq, lo, t, false, ); - break; + if (intercept < 1.0) { + tSamIntercept = intercept; + break; + } } } if (tSamIntercept < 1.0) break; } + + prevX = x; + prevY = y; } return { tUntargetableStart, tUntargetableEnd, tSamIntercept }; @@ -258,7 +355,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 +367,7 @@ export function buildNukeTrajectory( directionUp: boolean, sams: readonly SAMInfo[], ): NukeTrajectoryData { - const cp = computeNukeControlPoints( + const cpRender = computeNukeControlPoints( srcX, srcY, dstX, @@ -277,6 +375,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..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. @@ -45,8 +46,40 @@ 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 across tangent cells and safe cells without padding", () => { + const srcX = 1249; + const srcY = 108; + const samX = 984; + const samY = 380; + const r = samRange(6); // Level 6 SAM exact radius + const sams = [{ x: samX, y: samY, r }]; + + // 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( + 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..042c101f40 --- /dev/null +++ b/tests/perf/NukeTrajectoryPerf.ts @@ -0,0 +1,182 @@ +import Benchmark from "benchmark"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { + buildNukeTrajectory, + SAMInfo, +} 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(); + +// 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)) { + if ((x + y) % 20 === 0) { + myPlayer.conquer(tile); + } else { + 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 = 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(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 >= 8 && x <= 22 && !isMine ? UnitType.SAMLauncher : type; + player.buildUnit(forceSam, tile, {}); + unitCount++; + } + } +} + +// Exact mock scenarios for mathematical comparisons +const sparseSams: SAMInfo[] = [ + { 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: SAMInfo[] = Array.from({ length: 20 }, (_, k) => ({ + x: 200 + k * 40, + y: 200 + (k % 5) * 100, + r: 70 + (k % 4) * 10, +})); + +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(): { + srcX: number; + srcY: number; + directionUp: boolean; + sams: SAMInfo[]; +} { + const extractedSams: SAMInfo[] = []; + 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, + y: Math.floor(u.tile() / mapWidth), + r: 150 - 480 / (u.level() + 5), + }); + } + } + + return { + srcX: mySiloTile % mapWidth, + srcY: Math.floor(mySiloTile / mapWidth), + 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: Benchmark.Event) => { + 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 });