diff --git a/src/client/render/frame/TrailManager.ts b/src/client/render/frame/TrailManager.ts index c9fd32d01..01e89b797 100644 --- a/src/client/render/frame/TrailManager.ts +++ b/src/client/render/frame/TrailManager.ts @@ -11,6 +11,7 @@ */ import type { UnitState } from "../types"; +import { SMOOTHED_NUKE_TYPES } from "../types"; interface UnitTrail { ownerID: number; @@ -69,14 +70,20 @@ export class TrailManager { trail = { ownerID: unit.ownerID, tiles: new Set(), lastPosStamped: -1 }; this.unitTrails.set(id, trail); } + // Smoothed nukes render lastPos→pos interpolated per frame (UnitPass); + // stamp their trail only up to lastPos so the tail never leads the + // rendered missile. + const head = SMOOTHED_NUKE_TYPES.has(unit.unitType) + ? unit.lastPos + : unit.pos; if (trail.lastPosStamped === -1) { - // First sighting — just stamp current pos - this.stamp(unit.pos, trail.ownerID); - trail.tiles.add(unit.pos); - trail.lastPosStamped = unit.pos; - } else if (trail.lastPosStamped !== unit.pos) { - this.bresenham(trail.lastPosStamped, unit.pos, trail); - trail.lastPosStamped = unit.pos; + // First sighting — just stamp the current head + this.stamp(head, trail.ownerID); + trail.tiles.add(head); + trail.lastPosStamped = head; + } else if (trail.lastPosStamped !== head) { + this.bresenham(trail.lastPosStamped, head, trail); + trail.lastPosStamped = head; } } } diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 3ae5617c3..a5d651228 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -456,7 +456,13 @@ export class GPURenderer { this.settings, ); this.structureLevelPass = new StructureLevelPass(gl, header, this.settings); - this.unitPass = new UnitPass(gl, header, this.paletteTex, this.settings); + this.unitPass = new UnitPass( + gl, + header, + this.paletteTex, + this.settings, + config, + ); this.namePass = new NamePass( gl, header, diff --git a/src/client/render/gl/passes/UnitPass.ts b/src/client/render/gl/passes/UnitPass.ts index daabd7707..320bd5beb 100644 --- a/src/client/render/gl/passes/UnitPass.ts +++ b/src/client/render/gl/passes/UnitPass.ts @@ -33,8 +33,10 @@ */ import { assetUrl } from "src/core/AssetUrls"; +import type { Config } from "src/core/configuration/Config"; import type { RendererConfig, UnitState } from "../../types"; import { + SMOOTHED_NUKE_TYPES, TrainType, UT_ATOM_BOMB, UT_HYDROGEN_BOMB, @@ -94,7 +96,8 @@ const HYDROGEN_BOMB_COL = UNIT_ORDER.indexOf(UT_HYDROGEN_BOMB); * float x, y, ownerID — 12 bytes (3 floats) * uint8 atlasIdx — 1 byte (atlas column 0–11) * uint8 flags — 1 byte (0 = normal, 1 = flicker, 2 = angry, 3 = trade-friendly, 4 = retreating, 5 = flicker-untargetable) - * 2 bytes padding — aligns to 4-byte boundary + * uint8 flickerHash — 1 byte (per-instance flicker phase offset) + * 1 byte padding — aligns to 4-byte boundary */ const FLOATS_PER_INSTANCE = 4; const BYTES_PER_INSTANCE = FLOATS_PER_INSTANCE * 4; @@ -133,6 +136,20 @@ const MISSILE_TYPES: ReadonlySet = new Set([ UT_MIRV_WARHEAD, ]); +/** Values per smoothing segment in the flat `smoothSegs` array: + * (instanceIdx, lastX, lastY, x, y). The push site and the read loop must + * agree on this width — it's the record size, not a tunable. */ +const SMOOTH_SEG_STRIDE = 5; + +/** Per-instance flicker phase offset, hashed from the tick position. Computed + * CPU-side (not from the shader's instance position) so per-frame position + * smoothing doesn't re-roll the flicker every frame. Matches the formula the + * vertex shader previously applied to its rendered position. */ +export function flickerHashByte(x: number, y: number): number { + const f = x * 0.1731 + y * 0.3179; + return ((f - Math.floor(f)) * 255) | 0; +} + // --------------------------------------------------------------------------- // Helper: create a VAO for instanced unit rendering // --------------------------------------------------------------------------- @@ -156,9 +173,9 @@ function createUnitVao( gl.vertexAttribPointer(1, 3, gl.FLOAT, false, BYTES_PER_INSTANCE, 0); gl.vertexAttribDivisor(1, 1); - // Attribute 2: per-instance (atlasIdx, flags) — 2 uint8s at offset 12, converted to float + // Attribute 2: per-instance (atlasIdx, flags, flickerHash) — 3 uint8s at offset 12, converted to float gl.enableVertexAttribArray(2); - gl.vertexAttribPointer(2, 2, gl.UNSIGNED_BYTE, false, BYTES_PER_INSTANCE, 12); + gl.vertexAttribPointer(2, 3, gl.UNSIGNED_BYTE, false, BYTES_PER_INSTANCE, 12); gl.vertexAttribDivisor(2, 1); gl.bindVertexArray(null); @@ -199,6 +216,14 @@ export class UnitPass { private missileBuf: DynamicInstanceBuffer; private missileCount = 0; + // Per-frame nuke smoothing: flat SMOOTH_SEG_STRIDE-wide tuples + // (instanceIdx, lastX, lastY, x, y) recorded each tick, lerped into the + // missile buffer in drawMissiles. + private smoothSegs: number[] = []; + private lastUnitsUpdateMs = 0; + /** Simulation tick duration in ms (Config.msPerTick). */ + private tickIntervalMs: number; + private quadBuf: WebGLBuffer; private paletteTex: WebGLTexture; private atlasTex: WebGLTexture; @@ -220,11 +245,13 @@ export class UnitPass { header: RendererConfig, paletteTex: WebGLTexture, settings: RenderSettings, + config: Config, ) { this.gl = gl; this.settings = settings; this.mapW = header.mapWidth; this.paletteTex = paletteTex; + this.tickIntervalMs = config.msPerTick(); // Build unitType string → atlas column mapping for (let i = 0; i < header.unitTypes.length; i++) { @@ -356,6 +383,7 @@ export class UnitPass { const byteOff = this.groundCount * BYTES_PER_INSTANCE; this.groundBuf.uint8[byteOff + 12] = atlasIdx; this.groundBuf.uint8[byteOff + 13] = flags; + this.groundBuf.uint8[byteOff + 14] = flickerHashByte(x, y); this.groundCount++; } @@ -374,6 +402,7 @@ export class UnitPass { const byteOff = this.missileCount * BYTES_PER_INSTANCE; this.missileBuf.uint8[byteOff + 12] = atlasIdx; this.missileBuf.uint8[byteOff + 13] = flags; + this.missileBuf.uint8[byteOff + 14] = flickerHashByte(x, y); this.missileCount++; } @@ -381,6 +410,8 @@ export class UnitPass { this.frameTick = tick; this.groundCount = 0; this.missileCount = 0; + this.smoothSegs.length = 0; + this.lastUnitsUpdateMs = performance.now(); for (const unit of units.values()) { if (!unit.isActive) continue; @@ -442,6 +473,14 @@ export class UnitPass { const y = (unit.pos - x) / this.mapW; if (isMissile) { + if ( + SMOOTHED_NUKE_TYPES.has(unit.unitType) && + unit.lastPos !== unit.pos + ) { + const lx = unit.lastPos % this.mapW; + const ly = (unit.lastPos - lx) / this.mapW; + this.smoothSegs.push(this.missileCount, lx, ly, x, y); + } this.emitMissile(x, y, unit.ownerID, atlasIdx, flags); // Shells emit a second instance at lastPos (2-pixel trail effect) @@ -541,12 +580,39 @@ export class UnitPass { /** Draw missiles/projectiles (nukes, shells, SAM, MIRV warheads). Render above structures. */ drawMissiles(cameraMatrix: Float32Array): void { if (this.missileCount === 0) return; + this.applyMissileSmoothing(); this.bindProgram(cameraMatrix); const gl = this.gl; gl.bindVertexArray(this.missileVao); gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.missileCount); } + /** Lerp smoothed nukes lastPos→pos by wall-clock progress through the + * current tick and re-upload the (small) missile instance buffer. */ + private applyMissileSmoothing(): void { + const segs = this.smoothSegs; + if (segs.length === 0) return; + const alpha = Math.min( + 1, + (performance.now() - this.lastUnitsUpdateMs) / this.tickIntervalMs, + ); + const f32 = this.missileBuf.float32; + for (let i = 0; i < segs.length; i += SMOOTH_SEG_STRIDE) { + const off = segs[i] * FLOATS_PER_INSTANCE; + f32[off + 0] = segs[i + 1] + (segs[i + 3] - segs[i + 1]) * alpha; + f32[off + 1] = segs[i + 2] + (segs[i + 4] - segs[i + 2]) * alpha; + } + const gl = this.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, this.missileBuf.buffer); + gl.bufferSubData( + gl.ARRAY_BUFFER, + 0, + f32, + 0, + this.missileCount * FLOATS_PER_INSTANCE, + ); + } + dispose(): void { const gl = this.gl; gl.deleteProgram(this.program); diff --git a/src/client/render/gl/shaders/unit/unit.vert.glsl b/src/client/render/gl/shaders/unit/unit.vert.glsl index 7bd39eb95..4f06cacad 100644 --- a/src/client/render/gl/shaders/unit/unit.vert.glsl +++ b/src/client/render/gl/shaders/unit/unit.vert.glsl @@ -5,7 +5,7 @@ layout(location = 0) in vec2 aPos; // Per-instance attributes layout(location = 1) in vec3 aInstPos; // x, y, ownerID -layout(location = 2) in vec2 aInstFlags; // atlasIdx (uint8→float), flags (uint8→float) +layout(location = 2) in vec3 aInstFlags; // atlasIdx, flags, flickerHash (uint8→float) uniform mat3 uCamera; @@ -29,8 +29,10 @@ void main() { vFlags = aInstFlags.y; vAtlasCol = atlasCol; - // Position-based hash so each unit flickers independently - vHash = fract(worldX * 0.1731 + worldY * 0.3179); + // Per-instance hash so each unit flickers independently. Computed CPU-side + // from the tick position — hashing worldX/Y here would re-roll the phase + // every frame for nukes whose position is smoothed per frame. + vHash = aInstFlags.z * (1.0 / 255.0); // Hydrogen bombs render an enlarged quad so there's room for a glow halo // around the sprite. All other units keep scale 1 (no behavior change). diff --git a/src/client/render/types/UnitType.ts b/src/client/render/types/UnitType.ts index 70dd17efd..d5cbb9f98 100644 --- a/src/client/render/types/UnitType.ts +++ b/src/client/render/types/UnitType.ts @@ -49,6 +49,16 @@ export const NUKE_TYPES: ReadonlySet = new Set([ UT_MIRV, ]); +/** Nuke types whose rendered position is interpolated lastPos→pos each render + * frame (UnitPass). Their trails stamp only up to lastPos so the tail never + * leads the smoothly-moving missile. */ +export const SMOOTHED_NUKE_TYPES: ReadonlySet = new Set([ + UT_ATOM_BOMB, + UT_HYDROGEN_BOMB, + UT_MIRV, + UT_MIRV_WARHEAD, +]); + /** Blast radii (in tiles) matching upstream DefaultConfig.nukeMagnitudes(). */ export const NUKE_MAGNITUDES: Readonly< Record diff --git a/src/client/render/types/index.ts b/src/client/render/types/index.ts index 122620e17..a5c455a20 100644 --- a/src/client/render/types/index.ts +++ b/src/client/render/types/index.ts @@ -30,6 +30,7 @@ export { ALL_UNIT_TYPES, NUKE_MAGNITUDES, NUKE_TYPES, + SMOOTHED_NUKE_TYPES, STRUCTURE_TYPES, UT_ATOM_BOMB, UT_CITY, diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts index 9a9596c5c..961db0a0f 100644 --- a/src/client/view/GameView.ts +++ b/src/client/view/GameView.ts @@ -684,6 +684,8 @@ export class GameView implements GameMap { continue; } + unit.applyDerivedRest(); + // Once a plan is past its final step, `newTile` remains clamped to the last path tile. // Drop finished plans to avoid repeatedly marking static units as updated each tick. if (dt > 0 && stepIndex >= lastIndex) { diff --git a/src/client/view/UnitView.ts b/src/client/view/UnitView.ts index e1b4c7095..4c0d077b3 100644 --- a/src/client/view/UnitView.ts +++ b/src/client/view/UnitView.ts @@ -164,6 +164,13 @@ export class UnitView { this.state.pos = pos; } + /** Plan-driven unit stayed put this tick — its previous-tick position is + * its current one. Keeps lastPos→pos frame interpolation from replaying + * the prior segment. */ + applyDerivedRest() { + this.state.lastPos = this.state.pos; + } + id(): number { return this.state.id; } diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 123edd1c2..395ec8767 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -188,6 +188,7 @@ export class NukeExecution implements Execution { targetTile: this.dst, trajectory: this.getTrajectory(this.dst), }); + this.recordMotionPlan(ticks); if (this.nuke.type() !== UnitType.MIRVWarhead) { this.maybeBreakAlliances(); } @@ -255,6 +256,38 @@ export class NukeExecution implements Execution { return this.nuke; } + /** + * Record a motion plan so the client can derive the nuke's position each + * tick instead of receiving per-tick unit updates (see TradeShipExecution). + * Replays a separate pathfinder because the curve's cached points don't + * advance exactly one index per tick — the plan path must be the exact + * tile sequence that movement's `next()` calls will produce. + */ + private recordMotionPlan(ticks: number): void { + if (this.nuke === null || this.src === undefined || this.src === null) { + return; + } + const pathFinder = UniversalPathFinding.Parabola(this.mg, { + increment: this.speed, + distanceBasedHeight: this.nukeType !== UnitType.MIRVWarhead, + directionUp: this.rocketDirectionUp, + }); + const path: TileRef[] = [this.src]; + let result = pathFinder.next(this.src, this.dst, this.speed); + while (result.status === PathStatus.NEXT) { + path.push(result.node); + result = pathFinder.next(this.src, this.dst, this.speed); + } + this.mg.recordMotionPlan({ + kind: "grid", + unitId: this.nuke.id(), + planId: 1, + startTick: ticks + this.waitTicks + 1, + ticksPerStep: 1, + path, + }); + } + private getTrajectory(target: TileRef): TrajectoryTile[] { const trajectoryTiles: TrajectoryTile[] = []; const targetRangeSquared = diff --git a/tests/client/render/frame/TrailManager.test.ts b/tests/client/render/frame/TrailManager.test.ts new file mode 100644 index 000000000..356d39960 --- /dev/null +++ b/tests/client/render/frame/TrailManager.test.ts @@ -0,0 +1,106 @@ +/** + * TrailManager stamps a unit's path into the per-tile "last owner" texture. + * + * Smoothed nukes are interpolated lastPos→pos per render frame (UnitPass), so + * their trail must stamp only up to `lastPos` — otherwise the tail would lead + * the smoothly-moving missile sprite. Every other unit stamps up to `pos`. + */ + +import { describe, expect, it } from "vitest"; +import { TrailManager } from "../../../../src/client/render/frame/TrailManager"; +import type { UnitState } from "../../../../src/client/render/types"; +import { + UT_ATOM_BOMB, + UT_TRADE_SHIP, +} from "../../../../src/client/render/types"; + +const MAP_W = 50; +const MAP_H = 50; + +function unit(overrides: Partial = {}): UnitState { + return { + id: 1, + unitType: UT_ATOM_BOMB, + ownerID: 7, + lastOwnerID: null, + pos: 0, + lastPos: 0, + isActive: true, + reachedTarget: false, + retreating: false, + targetable: true, + markedForDeletion: false, + health: null, + underConstruction: false, + targetUnitId: null, + targetTile: null, + troops: 0, + missileTimerQueue: [], + level: 1, + hasTrainStation: false, + trainType: null, + loaded: null, + constructionStartTick: null, + ...overrides, + }; +} + +function units(...us: UnitState[]): Map { + return new Map(us.map((u) => [u.id, u])); +} + +const ref = (x: number, y: number) => y * MAP_W + x; + +describe("TrailManager", () => { + it("stamps a smoothed nuke's trail only up to lastPos, not pos", () => { + const tm = new TrailManager(MAP_W, MAP_H); + const trail = tm.getTrailState(); + + // First sighting: lastPos === pos at spawn. + tm.update(units(unit({ pos: ref(2, 2), lastPos: ref(2, 2) })), [1]); + expect(trail[ref(2, 2)]).toBe(7); + + // Move: lastPos trails pos by a tile. The trail head must reach lastPos + // (3,2) but NOT the current pos (4,2) — the smoothed sprite occupies the + // lastPos→pos span this frame. + tm.update(units(unit({ pos: ref(4, 2), lastPos: ref(3, 2) })), [1]); + expect(trail[ref(3, 2)]).toBe(7); + expect(trail[ref(4, 2)]).toBe(0); + }); + + it("stamps a non-smoothed unit's trail up to pos", () => { + const tm = new TrailManager(MAP_W, MAP_H); + const trail = tm.getTrailState(); + + tm.update( + units( + unit({ unitType: UT_TRADE_SHIP, pos: ref(2, 2), lastPos: ref(2, 2) }), + ), + [1], + ); + tm.update( + units( + unit({ unitType: UT_TRADE_SHIP, pos: ref(4, 2), lastPos: ref(3, 2) }), + ), + [1], + ); + + // Trade ships are not interpolated, so the trail reaches the current pos. + expect(trail[ref(4, 2)]).toBe(7); + }); + + it("clears a unit's trail when it disappears", () => { + const tm = new TrailManager(MAP_W, MAP_H); + const trail = tm.getTrailState(); + + tm.update(units(unit({ pos: ref(5, 5), lastPos: ref(5, 5) })), [1]); + tm.update(units(unit({ pos: ref(7, 5), lastPos: ref(6, 5) })), [1]); + expect(trail[ref(5, 5)]).toBe(7); + expect(trail[ref(6, 5)]).toBe(7); + + // Unit gone from the map → its tiles are cleared. + tm.update(new Map(), []); + expect(trail[ref(5, 5)]).toBe(0); + expect(trail[ref(6, 5)]).toBe(0); + }); +}); diff --git a/tests/client/render/gl/UnitPassSmoothing.test.ts b/tests/client/render/gl/UnitPassSmoothing.test.ts new file mode 100644 index 000000000..bb4e9ed01 --- /dev/null +++ b/tests/client/render/gl/UnitPassSmoothing.test.ts @@ -0,0 +1,187 @@ +/** + * Render-side nuke smoothing (UnitPass). + * + * `applyMissileSmoothing` lerps each recorded segment's lastPos→pos by + * wall-clock progress through the current tick and rewrites the missile + * instance buffer's x/y, leaving the packed per-instance bytes untouched. + * `flickerHashByte` reproduces (CPU-side) the per-instance flicker phase the + * vertex shader used to derive from its rendered position. + * + * The GL pass is exercised directly via its prototype with stubbed GL calls — + * the real method runs, only the two WebGL calls it makes are captured. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + flickerHashByte, + UnitPass, +} from "../../../../src/client/render/gl/passes/UnitPass"; + +const FLOATS_PER_INSTANCE = 4; +const TICK_MS = 100; + +interface SmoothingHarness { + pass: UnitPass; + f32: Float32Array; + bufferSubData: ReturnType; +} + +/** + * Build a UnitPass instance without its GL-heavy constructor, wiring up only + * the fields applyMissileSmoothing touches. + */ +function makeSmoothingHarness( + segs: number[], + instanceCount: number, + nowMs: number, + lastUpdateMs: number, +): SmoothingHarness { + const f32 = new Float32Array(instanceCount * FLOATS_PER_INSTANCE); + const bufferSubData = vi.fn(); + const gl = { + ARRAY_BUFFER: 0x8892, + bindBuffer: vi.fn(), + bufferSubData, + }; + + vi.spyOn(performance, "now").mockReturnValue(nowMs); + + const pass = Object.create(UnitPass.prototype) as UnitPass; + Object.assign(pass, { + gl, + smoothSegs: segs, + missileCount: instanceCount, + missileBuf: { float32: f32, buffer: {} }, + lastUnitsUpdateMs: lastUpdateMs, + tickIntervalMs: TICK_MS, + }); + + return { pass, f32, bufferSubData }; +} + +function runSmoothing(h: SmoothingHarness): void { + ( + h.pass as unknown as { applyMissileSmoothing(): void } + ).applyMissileSmoothing(); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("UnitPass.applyMissileSmoothing", () => { + it("lerps lastPos→pos by wall-clock progress through the tick", () => { + // instance 2; lastPos (10,20) → pos (30,40); 50ms into a 100ms tick. + const h = makeSmoothingHarness([2, 10, 20, 30, 40], 5, 1050, 1000); + runSmoothing(h); + + const off = 2 * FLOATS_PER_INSTANCE; + expect(h.f32[off + 0]).toBeCloseTo(20); // 10 + (30-10)*0.5 + expect(h.f32[off + 1]).toBeCloseTo(30); // 20 + (40-20)*0.5 + }); + + it("sits at lastPos at the start of the tick (alpha 0)", () => { + const h = makeSmoothingHarness([0, 10, 20, 30, 40], 1, 1000, 1000); + runSmoothing(h); + expect(h.f32[0]).toBeCloseTo(10); + expect(h.f32[1]).toBeCloseTo(20); + }); + + it("clamps alpha to 1 so a stalled tick settles exactly on pos", () => { + // 200ms elapsed into a 100ms tick → alpha would be 2, clamped to 1. + const h = makeSmoothingHarness([0, 10, 20, 30, 40], 1, 1200, 1000); + runSmoothing(h); + expect(h.f32[0]).toBeCloseTo(30); + expect(h.f32[1]).toBeCloseTo(40); + }); + + it("writes each segment to its own instance slot and leaves others alone", () => { + const h = makeSmoothingHarness([1, 0, 0, 8, 16], 3, 1050, 1000); + // Mark instance 0's slot; it has no segment and must be untouched. + h.f32[0] = 111; + h.f32[1] = 222; + runSmoothing(h); + + expect(h.f32[0]).toBe(111); + expect(h.f32[1]).toBe(222); + const off = 1 * FLOATS_PER_INSTANCE; + expect(h.f32[off + 0]).toBeCloseTo(4); // 0 + 8*0.5 + expect(h.f32[off + 1]).toBeCloseTo(8); // 0 + 16*0.5 + }); + + it("overwrites only x/y, preserving the packed ownerID/atlas/flags floats", () => { + const h = makeSmoothingHarness([0, 10, 20, 30, 40], 1, 1050, 1000); + h.f32[2] = 999; // ownerID slot + h.f32[3] = 888; // packed atlasIdx/flags/flickerHash slot + runSmoothing(h); + expect(h.f32[2]).toBe(999); + expect(h.f32[3]).toBe(888); + }); + + it("handles multiple smoothed nukes in one pass", () => { + const h = makeSmoothingHarness( + [0, 0, 0, 10, 10, 2, 100, 100, 200, 200], + 3, + 1050, + 1000, + ); + runSmoothing(h); + expect(h.f32[0]).toBeCloseTo(5); + expect(h.f32[1]).toBeCloseTo(5); + expect(h.f32[2 * FLOATS_PER_INSTANCE + 0]).toBeCloseTo(150); + expect(h.f32[2 * FLOATS_PER_INSTANCE + 1]).toBeCloseTo(150); + }); + + it("re-uploads exactly the active missile-instance float range", () => { + const h = makeSmoothingHarness([0, 10, 20, 30, 40], 4, 1050, 1000); + runSmoothing(h); + expect(h.bufferSubData).toHaveBeenCalledTimes(1); + const args = h.bufferSubData.mock.calls[0]; + // gl.bufferSubData(ARRAY_BUFFER, dstOffset, srcData, srcOffset, length) + expect(args[1]).toBe(0); + expect(args[3]).toBe(0); + expect(args[4]).toBe(4 * FLOATS_PER_INSTANCE); + }); + + it("does nothing when there are no smoothed segments", () => { + const h = makeSmoothingHarness([], 3, 1050, 1000); + runSmoothing(h); + expect(h.bufferSubData).not.toHaveBeenCalled(); + }); +}); + +describe("flickerHashByte", () => { + const fract = (x: number, y: number) => { + const v = x * 0.1731 + y * 0.3179; + return v - Math.floor(v); + }; + + it("returns an integer byte in [0, 254]", () => { + for (const [x, y] of [ + [0, 0], + [3, 5], + [40, 80], + [127, 255], + ]) { + const h = flickerHashByte(x, y); + expect(Number.isInteger(h)).toBe(true); + expect(h).toBeGreaterThanOrEqual(0); + expect(h).toBeLessThanOrEqual(254); + } + }); + + it("is deterministic for a given tile position", () => { + expect(flickerHashByte(12, 34)).toBe(flickerHashByte(12, 34)); + }); + + it("matches the shader's fract(worldX*0.1731 + worldY*0.3179) phase", () => { + for (const [x, y] of [ + [3, 5], + [17, 42], + [80, 9], + ]) { + // byte/255 should reproduce the original fract phase to within one step. + expect(flickerHashByte(x, y) / 255).toBeCloseTo(fract(x, y), 2); + } + }); +}); diff --git a/tests/nukes/NukeMotionPlan.test.ts b/tests/nukes/NukeMotionPlan.test.ts new file mode 100644 index 000000000..53eb78124 --- /dev/null +++ b/tests/nukes/NukeMotionPlan.test.ts @@ -0,0 +1,148 @@ +import { ConstructionExecution } from "../../src/core/execution/ConstructionExecution"; +import { NukeExecution } from "../../src/core/execution/NukeExecution"; +import { + Game, + Player, + PlayerInfo, + PlayerType, + Unit, + UnitType, +} from "../../src/core/game/Game"; +import { TileRef } from "../../src/core/game/GameMap"; +import { GameUpdateType, UnitUpdate } from "../../src/core/game/GameUpdates"; +import { + GridPathPlan, + unpackMotionPlans, +} from "../../src/core/game/MotionPlans"; +import { setup } from "../util/Setup"; + +describe("Nuke motion plan", () => { + let game: Game; + let player: Player; + const info = new PlayerInfo("p", PlayerType.Human, null, "p"); + + beforeEach(async () => { + game = await setup("plains", { infiniteGold: true, instantBuild: true }, [ + info, + ]); + player = game.player(info.id); + player.conquer(game.ref(1, 1)); + game.addExecution( + new ConstructionExecution(player, UnitType.MissileSilo, game.ref(1, 1)), + ); + game.executeNextTick(); + game.executeNextTick(); + expect(player.units(UnitType.MissileSilo)).toHaveLength(1); + game.drainPackedMotionPlans(); + }); + + function buildNuke(): Unit { + for ( + let i = 0; + i < 10 && player.units(UnitType.AtomBomb).length === 0; + i++ + ) { + game.executeNextTick(); + } + const nukes = player.units(UnitType.AtomBomb); + expect(nukes).toHaveLength(1); + return nukes[0]; + } + + function drainGridPlan(unitId: number): GridPathPlan { + const packed = game.drainPackedMotionPlans(); + expect(packed).not.toBeNull(); + const plan = unpackMotionPlans(packed!).find( + (r): r is GridPathPlan => r.kind === "grid" && r.unitId === unitId, + ); + expect(plan).toBeDefined(); + return plan!; + } + + // game.ticks() after executeNextTick() matches the tick the client receives + // for that update batch (GameRunner reads ticks() post-increment), so this + // mirrors GameView.advanceMotionPlannedUnits exactly. + function expectedTile(plan: GridPathPlan, tick: number): TileRef { + const dt = tick - plan.startTick; + const stepIndex = + dt <= 0 ? 0 : Math.floor(dt / Math.max(1, plan.ticksPerStep)); + const idx = Math.max(0, Math.min(plan.path.length - 1, stepIndex)); + return plan.path[idx] as TileRef; + } + + test("records a grid plan matching the nuke's per-tick positions", () => { + game.addExecution( + new ConstructionExecution(player, UnitType.AtomBomb, game.ref(80, 80)), + ); + const nuke = buildNuke(); + const plan = drainGridPlan(nuke.id()); + + expect(plan.ticksPerStep).toBe(1); + expect(plan.startTick).toBe(game.ticks()); + expect(plan.path[0]).toBe(nuke.tile()); + expect(plan.path.length).toBeGreaterThan(2); + + let moveTicks = 0; + for (let i = 0; i < 10_000 && nuke.isActive(); i++) { + const lastTile = nuke.tile(); + game.executeNextTick(); + if (!nuke.isActive()) break; + expect(nuke.tile()).toBe(expectedTile(plan, game.ticks())); + if (nuke.tile() !== lastTile) moveTicks++; + } + expect(nuke.isActive()).toBe(false); + expect(moveTicks).toBeGreaterThan(2); + }); + + test("plan start accounts for waitTicks", () => { + const waitTicks = 5; + game.addExecution( + new NukeExecution( + UnitType.AtomBomb, + player, + game.ref(80, 80), + null, + -1, + waitTicks, + ), + ); + const nuke = buildNuke(); + const spawn = nuke.tile(); + const plan = drainGridPlan(nuke.id()); + + expect(plan.startTick).toBe(game.ticks() + waitTicks); + + for (let i = 0; i < 10_000 && nuke.isActive(); i++) { + game.executeNextTick(); + if (!nuke.isActive()) break; + if (i < waitTicks) { + expect(nuke.tile()).toBe(spawn); + } + expect(nuke.tile()).toBe(expectedTile(plan, game.ticks())); + } + expect(nuke.isActive()).toBe(false); + }); + + test("does not emit per-tick unit updates while in flight", () => { + game.addExecution( + new ConstructionExecution(player, UnitType.AtomBomb, game.ref(80, 80)), + ); + const nuke = buildNuke(); + let lastTargetable = nuke.isTargetable(); + + for (let i = 0; i < 10_000 && nuke.isActive(); i++) { + const updates = game.executeNextTick(); + const nukeUpdates = (updates[GameUpdateType.Unit] as UnitUpdate[]).filter( + (u) => u.id === nuke.id(), + ); + for (const u of nukeUpdates) { + // Moves are plan-driven; only deletion and targetable flips may emit. + expect(u.isActive === false || u.targetable !== lastTargetable).toBe( + true, + ); + lastTargetable = u.targetable; + } + } + expect(nuke.isActive()).toBe(false); + }); +});