mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:10:42 +00:00
Send nukes as motion plans and render them smoothly per frame (#4255)
## Summary Follow-up to #4244's payload work: nukes were the last per-tick movers flooding the worker → main update stream. - **Core**: nuke trajectories are fully determined at launch (precomputed parabola), so `NukeExecution` now records a `GridPathPlan` when the nuke is built — same mechanism trade ships use — and the client derives the position each tick. Per-tick `UnitUpdate`s for nukes in flight are suppressed; only targetable flips and deletion (interception/detonation) still emit. This covers atom bombs, hydrogen bombs, and MIRV warheads (dozens of per-tick movers per MIRV separation). - The plan path replays a separate pathfinder rather than reusing the stored trajectory array: the curve's cached points don't advance exactly one index per tick, and the plan must match the movement pathfinder's exact per-tick tile sequence. - `startTick` accounts for MIRV warheads' staggered `waitTicks`. - **Render**: `UnitPass.drawMissiles` now lerps each nuke's instance position `lastPos→pos` by wall-clock progress through the current tick, so nukes glide along their arc at render framerate instead of jumping once per 100ms tick. Both endpoints are real simulated positions — the rendered nuke trails the sim by at most one tick and settles exactly on it when ticks stop. Plan-driven units sync `lastPos` on path-stall ticks so the lerp never replays a segment. Shells keep their existing two-instance trail; SAM missiles are unchanged. ## Test plan - New `tests/nukes/NukeMotionPlan.test.ts`: tick-exact alignment between the recorded plan and core nuke position over the whole flight (mirroring `GameView.advanceMotionPlannedUnits` math), `waitTicks` offset, and that no per-tick unit updates are emitted in flight except targetable flips and deletion. - Full suite passes (1452 + 65), tsc/eslint/prettier clean. - Verified in-game (headless Chromium, real WebGL): atom bomb arcs from silo to target with the client position driven by the plan, missile sprite renders intact while the smoothing rewrites the instance buffer every frame, detonation FX land at the target. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string> = 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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -49,6 +49,16 @@ export const NUKE_TYPES: ReadonlySet<string> = 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<string> = 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<string, { inner: number; outer: number }>
|
||||
|
||||
@@ -30,6 +30,7 @@ export {
|
||||
ALL_UNIT_TYPES,
|
||||
NUKE_MAGNITUDES,
|
||||
NUKE_TYPES,
|
||||
SMOOTHED_NUKE_TYPES,
|
||||
STRUCTURE_TYPES,
|
||||
UT_ATOM_BOMB,
|
||||
UT_CITY,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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> = {}): 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<number, UnitState> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user