mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
v1 slob
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import type { GameView } from "../../../core/game/GameView";
|
||||
|
||||
export type GridSegmentMotionPlanView = {
|
||||
planId: number;
|
||||
startTick: number;
|
||||
ticksPerStep: number;
|
||||
points: Uint32Array;
|
||||
segmentSteps: Uint32Array;
|
||||
segCumSteps: Uint32Array;
|
||||
};
|
||||
|
||||
export type SampledMotionPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
isComplete: boolean;
|
||||
tile0: TileRef;
|
||||
tile1: TileRef;
|
||||
};
|
||||
|
||||
function clamp01(v: number): number {
|
||||
if (v <= 0) return 0;
|
||||
if (v >= 1) return 1;
|
||||
return v;
|
||||
}
|
||||
|
||||
export function sampleGridSegmentPlan(
|
||||
game: GameView,
|
||||
plan: GridSegmentMotionPlanView,
|
||||
tickFloat: number,
|
||||
): SampledMotionPosition | null {
|
||||
const points = plan.points;
|
||||
if (points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (points.length === 1 || plan.segmentSteps.length === 0) {
|
||||
const t = points[0] as TileRef;
|
||||
return { x: game.x(t), y: game.y(t), isComplete: true, tile0: t, tile1: t };
|
||||
}
|
||||
|
||||
const ticksPerStep = Math.max(1, plan.ticksPerStep);
|
||||
const stepFloat = (tickFloat - plan.startTick) / ticksPerStep;
|
||||
|
||||
const segCum = plan.segCumSteps;
|
||||
const totalSteps = segCum.length === 0 ? 0 : segCum[segCum.length - 1] >>> 0;
|
||||
if (totalSteps <= 0) {
|
||||
const t = points[points.length - 1] as TileRef;
|
||||
return { x: game.x(t), y: game.y(t), isComplete: true, tile0: t, tile1: t };
|
||||
}
|
||||
|
||||
if (stepFloat <= 0) {
|
||||
const t = points[0] as TileRef;
|
||||
const t1 = points[1] as TileRef;
|
||||
return {
|
||||
x: game.x(t),
|
||||
y: game.y(t),
|
||||
isComplete: false,
|
||||
tile0: t,
|
||||
tile1: t1,
|
||||
};
|
||||
}
|
||||
if (stepFloat >= totalSteps) {
|
||||
const t = points[points.length - 1] as TileRef;
|
||||
return { x: game.x(t), y: game.y(t), isComplete: true, tile0: t, tile1: t };
|
||||
}
|
||||
|
||||
// Find the segment containing stepFloat.
|
||||
let seg = 0;
|
||||
let lo = 0;
|
||||
let hi = plan.segmentSteps.length - 1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
const start = segCum[mid] >>> 0;
|
||||
const end = segCum[mid + 1] >>> 0;
|
||||
if (stepFloat < start) {
|
||||
hi = mid - 1;
|
||||
} else if (stepFloat >= end) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
seg = mid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const segStart = segCum[seg] >>> 0;
|
||||
const steps = Math.max(1, plan.segmentSteps[seg] >>> 0);
|
||||
const u = clamp01((stepFloat - segStart) / steps);
|
||||
|
||||
const tile0 = points[seg] as TileRef;
|
||||
const tile1 = points[seg + 1] as TileRef;
|
||||
const x0 = game.x(tile0);
|
||||
const y0 = game.y(tile0);
|
||||
const x1 = game.x(tile1);
|
||||
const y1 = game.y(tile1);
|
||||
|
||||
return {
|
||||
x: x0 + (x1 - x0) * u,
|
||||
y: y0 + (y1 - y0) * u,
|
||||
isComplete: false,
|
||||
tile0,
|
||||
tile1,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { colord, Colord } from "colord";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import { Cell, UnitType } from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView, UnitView } from "../../../core/game/GameView";
|
||||
import { BezenhamLine } from "../../../core/utilities/Line";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { MoveWarshipIntentEvent } from "../../Transport";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
import { sampleGridSegmentPlan } from "./SegmentMotionSample";
|
||||
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import {
|
||||
@@ -34,9 +35,18 @@ export class UnitLayer implements Layer {
|
||||
private context: CanvasRenderingContext2D;
|
||||
private transportShipTrailCanvas: HTMLCanvasElement;
|
||||
private unitTrailContext: CanvasRenderingContext2D;
|
||||
private motionTrailCanvas: HTMLCanvasElement;
|
||||
private motionTrailContext: CanvasRenderingContext2D;
|
||||
|
||||
// Pixel trails (currently only used for nukes).
|
||||
private unitToTrail = new Map<UnitView, TileRef[]>();
|
||||
|
||||
private gridMoverUnitIds = new Set<number>();
|
||||
private moverTrailLast = new Map<
|
||||
number,
|
||||
{ x: number; y: number; planId: number; onScreen: boolean }
|
||||
>();
|
||||
|
||||
private theme: Theme;
|
||||
|
||||
private alternateView = false;
|
||||
@@ -65,6 +75,26 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
|
||||
tick() {
|
||||
const gridMoverUnitIds = new Set<number>();
|
||||
for (const id of this.game.motionPlans().keys()) {
|
||||
gridMoverUnitIds.add(id);
|
||||
}
|
||||
|
||||
const moverSetChanged = !this.setsEqual(
|
||||
gridMoverUnitIds,
|
||||
this.gridMoverUnitIds,
|
||||
);
|
||||
if (moverSetChanged) {
|
||||
this.gridMoverUnitIds = gridMoverUnitIds;
|
||||
for (const id of this.moverTrailLast.keys()) {
|
||||
if (!gridMoverUnitIds.has(id)) {
|
||||
this.moverTrailLast.delete(id);
|
||||
}
|
||||
}
|
||||
this.redrawStaticSprites();
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedUnitIds =
|
||||
this.game
|
||||
.updatesSinceLastTick()
|
||||
@@ -72,20 +102,22 @@ export class UnitLayer implements Layer {
|
||||
|
||||
const motionPlanUnitIds = this.game.motionPlannedUnitIds();
|
||||
|
||||
if (updatedUnitIds.length === 0) {
|
||||
this.updateUnitsSprites(motionPlanUnitIds);
|
||||
return;
|
||||
const unitIds = new Set<number>();
|
||||
for (const id of updatedUnitIds) {
|
||||
if (!gridMoverUnitIds.has(id)) {
|
||||
unitIds.add(id);
|
||||
}
|
||||
}
|
||||
if (motionPlanUnitIds.length === 0) {
|
||||
this.updateUnitsSprites(updatedUnitIds);
|
||||
return;
|
||||
for (const id of motionPlanUnitIds) {
|
||||
// Train plans still rely on discrete tick updates; grid movers are rendered smoothly in renderLayer().
|
||||
if (!gridMoverUnitIds.has(id)) {
|
||||
unitIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const unitIds = new Set<number>(updatedUnitIds);
|
||||
for (const id of motionPlanUnitIds) {
|
||||
unitIds.add(id);
|
||||
if (unitIds.size > 0) {
|
||||
this.updateUnitsSprites(Array.from(unitIds));
|
||||
}
|
||||
this.updateUnitsSprites(Array.from(unitIds));
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -220,6 +252,62 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
const moversToDraw: Array<{ unit: UnitView; x: number; y: number }> = [];
|
||||
|
||||
const tickAlpha = this.computeTickAlpha();
|
||||
const tickFloat = this.game.ticks() + tickAlpha;
|
||||
|
||||
if (this.game.motionPlans().size > 0) {
|
||||
this.fadeMotionTrailCanvas();
|
||||
}
|
||||
|
||||
for (const [unitId, plan] of this.game.motionPlans()) {
|
||||
const unit = this.game.unit(unitId);
|
||||
if (!unit || !unit.isActive()) {
|
||||
this.moverTrailLast.delete(unitId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sampled = sampleGridSegmentPlan(this.game, plan, tickFloat);
|
||||
if (!sampled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const onScreen = this.transformHandler.isOnScreen(
|
||||
new Cell(Math.floor(sampled.x), Math.floor(sampled.y)),
|
||||
);
|
||||
|
||||
const last = this.moverTrailLast.get(unitId);
|
||||
if (last && last.planId === plan.planId) {
|
||||
if (
|
||||
last.onScreen &&
|
||||
onScreen &&
|
||||
(last.x !== sampled.x || last.y !== sampled.y)
|
||||
) {
|
||||
this.motionTrailContext.save();
|
||||
this.motionTrailContext.lineCap = "round";
|
||||
this.motionTrailContext.lineJoin = "round";
|
||||
this.motionTrailContext.lineWidth = 1.5;
|
||||
this.motionTrailContext.strokeStyle = this.motionTrailColor(unit);
|
||||
this.motionTrailContext.beginPath();
|
||||
this.motionTrailContext.moveTo(last.x, last.y);
|
||||
this.motionTrailContext.lineTo(sampled.x, sampled.y);
|
||||
this.motionTrailContext.stroke();
|
||||
this.motionTrailContext.restore();
|
||||
}
|
||||
}
|
||||
this.moverTrailLast.set(unitId, {
|
||||
x: sampled.x,
|
||||
y: sampled.y,
|
||||
planId: plan.planId,
|
||||
onScreen,
|
||||
});
|
||||
|
||||
if (onScreen) {
|
||||
moversToDraw.push({ unit, x: sampled.x, y: sampled.y });
|
||||
}
|
||||
}
|
||||
|
||||
context.drawImage(
|
||||
this.transportShipTrailCanvas,
|
||||
-this.game.width() / 2,
|
||||
@@ -227,6 +315,13 @@ export class UnitLayer implements Layer {
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
context.drawImage(
|
||||
this.motionTrailCanvas,
|
||||
-this.game.width() / 2,
|
||||
-this.game.height() / 2,
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
context.drawImage(
|
||||
this.canvas,
|
||||
-this.game.width() / 2,
|
||||
@@ -234,6 +329,16 @@ export class UnitLayer implements Layer {
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
|
||||
for (const mover of moversToDraw) {
|
||||
this.drawSpriteAt(
|
||||
mover.unit,
|
||||
mover.x - this.game.width() / 2,
|
||||
mover.y - this.game.height() / 2,
|
||||
context,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onAlternativeViewEvent(event: AlternateViewEvent) {
|
||||
@@ -250,13 +355,23 @@ export class UnitLayer implements Layer {
|
||||
const trailContext = this.transportShipTrailCanvas.getContext("2d");
|
||||
if (trailContext === null) throw new Error("2d context not supported");
|
||||
this.unitTrailContext = trailContext;
|
||||
this.motionTrailCanvas = document.createElement("canvas");
|
||||
const motionTrailContext = this.motionTrailCanvas.getContext("2d");
|
||||
if (motionTrailContext === null)
|
||||
throw new Error("2d context not supported");
|
||||
this.motionTrailContext = motionTrailContext;
|
||||
|
||||
this.canvas.width = this.game.width();
|
||||
this.canvas.height = this.game.height();
|
||||
this.transportShipTrailCanvas.width = this.game.width();
|
||||
this.transportShipTrailCanvas.height = this.game.height();
|
||||
this.motionTrailCanvas.width = this.game.width();
|
||||
this.motionTrailCanvas.height = this.game.height();
|
||||
|
||||
this.updateUnitsSprites(this.game.units().map((unit) => unit.id()));
|
||||
this.gridMoverUnitIds = new Set<number>(this.game.motionPlans().keys());
|
||||
this.moverTrailLast.clear();
|
||||
|
||||
this.redrawStaticSprites();
|
||||
|
||||
this.unitToTrail.forEach((trail, unit) => {
|
||||
for (const t of trail) {
|
||||
@@ -272,6 +387,76 @@ export class UnitLayer implements Layer {
|
||||
});
|
||||
}
|
||||
|
||||
private setsEqual(a: Set<number>, b: Set<number>): boolean {
|
||||
if (a.size !== b.size) {
|
||||
return false;
|
||||
}
|
||||
for (const v of a) {
|
||||
if (!b.has(v)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private redrawStaticSprites(): void {
|
||||
this.context.clearRect(0, 0, this.game.width(), this.game.height());
|
||||
const units = this.game
|
||||
.units()
|
||||
.filter((u) => !this.gridMoverUnitIds.has(u.id()));
|
||||
this.drawUnitsCells(units);
|
||||
}
|
||||
|
||||
private computeTickAlpha(): number {
|
||||
if (this.game.isCatchingUp()) {
|
||||
return 1;
|
||||
}
|
||||
const dt = Math.max(1, this.game.tickDtEmaMs());
|
||||
const alpha = (performance.now() - this.game.lastUpdateAtMs()) / dt;
|
||||
return Math.max(0, Math.min(1, alpha));
|
||||
}
|
||||
|
||||
private fadeMotionTrailCanvas(): void {
|
||||
const ctx = this.motionTrailContext;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.fillStyle = "rgba(0,0,0,0.06)";
|
||||
ctx.fillRect(0, 0, this.game.width(), this.game.height());
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private relationshipForAlternateView(unit: UnitView): Relationship {
|
||||
let rel = this.relationship(unit);
|
||||
const dstPortId = unit.targetUnitId();
|
||||
if (unit.type() === UnitType.TradeShip && dstPortId !== undefined) {
|
||||
const target = this.game.unit(dstPortId)?.owner();
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (myPlayer !== null && target !== undefined) {
|
||||
if (myPlayer === target) {
|
||||
rel = Relationship.Self;
|
||||
} else if (myPlayer.isFriendly(target)) {
|
||||
rel = Relationship.Ally;
|
||||
}
|
||||
}
|
||||
}
|
||||
return rel;
|
||||
}
|
||||
|
||||
private motionTrailColor(unit: UnitView): string {
|
||||
if (this.alternateView) {
|
||||
const rel = this.relationshipForAlternateView(unit);
|
||||
switch (rel) {
|
||||
case Relationship.Self:
|
||||
return this.theme.selfColor().alpha(0.65).toRgbString();
|
||||
case Relationship.Ally:
|
||||
return this.theme.allyColor().alpha(0.65).toRgbString();
|
||||
case Relationship.Enemy:
|
||||
return this.theme.enemyColor().alpha(0.65).toRgbString();
|
||||
}
|
||||
}
|
||||
return unit.owner().territoryColor().alpha(0.55).toRgbString();
|
||||
}
|
||||
|
||||
private updateUnitsSprites(unitIds: number[]) {
|
||||
const unitsToUpdate = unitIds
|
||||
?.map((id) => this.game.unit(id))
|
||||
@@ -508,21 +693,7 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
|
||||
private handleBoatEvent(unit: UnitView) {
|
||||
const rel = this.relationship(unit);
|
||||
|
||||
if (!this.unitToTrail.has(unit)) {
|
||||
this.unitToTrail.set(unit, []);
|
||||
}
|
||||
const trail = this.unitToTrail.get(unit) ?? [];
|
||||
trail.push(unit.lastTile());
|
||||
|
||||
// Paint trail
|
||||
this.drawTrail(trail.slice(-1), unit.owner().territoryColor(), rel);
|
||||
this.drawSprite(unit);
|
||||
|
||||
if (!unit.isActive()) {
|
||||
this.clearTrail(unit);
|
||||
}
|
||||
}
|
||||
|
||||
paintCell(
|
||||
@@ -560,26 +731,18 @@ export class UnitLayer implements Layer {
|
||||
context.clearRect(x, y, 1, 1);
|
||||
}
|
||||
|
||||
drawSprite(unit: UnitView, customTerritoryColor?: Colord) {
|
||||
const x = this.game.x(unit.tile());
|
||||
const y = this.game.y(unit.tile());
|
||||
|
||||
private drawSpriteAt(
|
||||
unit: UnitView,
|
||||
x: number,
|
||||
y: number,
|
||||
ctx: CanvasRenderingContext2D = this.context,
|
||||
roundCoords: boolean = true,
|
||||
customTerritoryColor?: Colord,
|
||||
) {
|
||||
let alternateViewColor: Colord | null = null;
|
||||
|
||||
if (this.alternateView) {
|
||||
let rel = this.relationship(unit);
|
||||
const dstPortId = unit.targetUnitId();
|
||||
if (unit.type() === UnitType.TradeShip && dstPortId !== undefined) {
|
||||
const target = this.game.unit(dstPortId)?.owner();
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (myPlayer !== null && target !== undefined) {
|
||||
if (myPlayer === target) {
|
||||
rel = Relationship.Self;
|
||||
} else if (myPlayer.isFriendly(target)) {
|
||||
rel = Relationship.Ally;
|
||||
}
|
||||
}
|
||||
}
|
||||
const rel = this.relationshipForAlternateView(unit);
|
||||
switch (rel) {
|
||||
case Relationship.Self:
|
||||
alternateViewColor = this.theme.selfColor();
|
||||
@@ -600,22 +763,37 @@ export class UnitLayer implements Layer {
|
||||
alternateViewColor ?? undefined,
|
||||
);
|
||||
|
||||
if (unit.isActive()) {
|
||||
const targetable = unit.targetable();
|
||||
if (!targetable) {
|
||||
this.context.save();
|
||||
this.context.globalAlpha = 0.5;
|
||||
}
|
||||
this.context.drawImage(
|
||||
sprite,
|
||||
Math.round(x - sprite.width / 2),
|
||||
Math.round(y - sprite.height / 2),
|
||||
sprite.width,
|
||||
sprite.width,
|
||||
);
|
||||
if (!targetable) {
|
||||
this.context.restore();
|
||||
}
|
||||
if (!unit.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetable = unit.targetable();
|
||||
ctx.save();
|
||||
if (!targetable) {
|
||||
ctx.globalAlpha = 0.5;
|
||||
}
|
||||
|
||||
const drawX = x - sprite.width / 2;
|
||||
const drawY = y - sprite.height / 2;
|
||||
ctx.drawImage(
|
||||
sprite,
|
||||
roundCoords ? Math.round(drawX) : drawX,
|
||||
roundCoords ? Math.round(drawY) : drawY,
|
||||
sprite.width,
|
||||
sprite.width,
|
||||
);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private drawSprite(unit: UnitView, customTerritoryColor?: Colord) {
|
||||
this.drawSpriteAt(
|
||||
unit,
|
||||
this.game.x(unit.tile()),
|
||||
this.game.y(unit.tile()),
|
||||
this.context,
|
||||
true,
|
||||
customTerritoryColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { densePathToLosKeypointSegments } from "../game/MotionPlans";
|
||||
import { PathFinding } from "../pathfinding/PathFinder";
|
||||
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
|
||||
import { distSortUnit } from "../Util";
|
||||
@@ -114,18 +115,27 @@ export class TradeShipExecution implements Execution {
|
||||
if (dst !== this.motionPlanDst) {
|
||||
this.motionPlanId++;
|
||||
const from = result.node;
|
||||
const path = this.pathFinder.findPath(from, dst) ?? [from];
|
||||
if (path.length === 0 || path[0] !== from) {
|
||||
path.unshift(from);
|
||||
}
|
||||
const densePath = this.pathFinder.findPath(from, dst);
|
||||
const segPlan = (densePath &&
|
||||
densePathToLosKeypointSegments(
|
||||
densePath,
|
||||
this.mg.map(),
|
||||
(t) =>
|
||||
this.mg.isWater(t) ||
|
||||
(this.mg.isLand(t) && this.mg.isShoreline(t)),
|
||||
)) ?? {
|
||||
points: Uint32Array.from([from]),
|
||||
segmentSteps: new Uint32Array(0),
|
||||
};
|
||||
|
||||
this.mg.recordMotionPlan({
|
||||
kind: "grid",
|
||||
kind: "grid_segments",
|
||||
unitId: this.tradeShip.id(),
|
||||
planId: this.motionPlanId,
|
||||
startTick: ticks + 1,
|
||||
ticksPerStep: 1,
|
||||
path,
|
||||
points: segPlan.points,
|
||||
segmentSteps: segPlan.segmentSteps,
|
||||
});
|
||||
this.motionPlanDst = dst;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { MotionPlanRecord } from "../game/MotionPlans";
|
||||
import {
|
||||
densePathToLosKeypointSegments,
|
||||
MotionPlanRecord,
|
||||
} from "../game/MotionPlans";
|
||||
import { targetTransportTile } from "../game/TransportShipUtils";
|
||||
import { PathFinding } from "../pathfinding/PathFinder";
|
||||
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
|
||||
@@ -112,18 +115,26 @@ export class TransportShipExecution implements Execution {
|
||||
targetTile: this.dst,
|
||||
});
|
||||
|
||||
const fullPath = this.pathFinder.findPath(this.src, this.dst) ?? [this.src];
|
||||
if (fullPath.length === 0 || fullPath[0] !== this.src) {
|
||||
fullPath.unshift(this.src);
|
||||
}
|
||||
const densePath = this.pathFinder.findPath(this.src, this.dst);
|
||||
const segPlan = (densePath &&
|
||||
densePathToLosKeypointSegments(
|
||||
densePath,
|
||||
this.mg.map(),
|
||||
(t) =>
|
||||
this.mg.isWater(t) || (this.mg.isLand(t) && this.mg.isShoreline(t)),
|
||||
)) ?? {
|
||||
points: Uint32Array.from([this.src]),
|
||||
segmentSteps: new Uint32Array(0),
|
||||
};
|
||||
|
||||
const motionPlan: MotionPlanRecord = {
|
||||
kind: "grid",
|
||||
kind: "grid_segments",
|
||||
unitId: this.boat.id(),
|
||||
planId: this.motionPlanId,
|
||||
startTick: ticks + this.ticksPerMove,
|
||||
ticksPerStep: this.ticksPerMove,
|
||||
path: fullPath,
|
||||
points: segPlan.points,
|
||||
segmentSteps: segPlan.segmentSteps,
|
||||
};
|
||||
this.mg.recordMotionPlan(motionPlan);
|
||||
this.motionPlanDst = this.dst;
|
||||
@@ -269,20 +280,27 @@ export class TransportShipExecution implements Execution {
|
||||
|
||||
if (this.dst !== null && this.dst !== this.motionPlanDst) {
|
||||
this.motionPlanId++;
|
||||
const fullPath = this.pathFinder.findPath(this.boat.tile(), this.dst) ?? [
|
||||
this.boat.tile(),
|
||||
];
|
||||
if (fullPath.length === 0 || fullPath[0] !== this.boat.tile()) {
|
||||
fullPath.unshift(this.boat.tile());
|
||||
}
|
||||
const from = this.boat.tile();
|
||||
const densePath = this.pathFinder.findPath(from, this.dst);
|
||||
const segPlan = (densePath &&
|
||||
densePathToLosKeypointSegments(
|
||||
densePath,
|
||||
this.mg.map(),
|
||||
(t) =>
|
||||
this.mg.isWater(t) || (this.mg.isLand(t) && this.mg.isShoreline(t)),
|
||||
)) ?? {
|
||||
points: Uint32Array.from([from]),
|
||||
segmentSteps: new Uint32Array(0),
|
||||
};
|
||||
|
||||
this.mg.recordMotionPlan({
|
||||
kind: "grid",
|
||||
kind: "grid_segments",
|
||||
unitId: this.boat.id(),
|
||||
planId: this.motionPlanId,
|
||||
startTick: ticks + this.ticksPerMove,
|
||||
ticksPerStep: this.ticksPerMove,
|
||||
path: fullPath,
|
||||
points: segPlan.points,
|
||||
segmentSteps: segPlan.segmentSteps,
|
||||
});
|
||||
this.motionPlanDst = this.dst;
|
||||
}
|
||||
|
||||
@@ -435,7 +435,7 @@ export class GameImpl implements Game {
|
||||
|
||||
recordMotionPlan(record: MotionPlanRecord): void {
|
||||
switch (record.kind) {
|
||||
case "grid":
|
||||
case "grid_segments":
|
||||
this.planDrivenUnitIds.add(record.unitId);
|
||||
break;
|
||||
case "train":
|
||||
|
||||
+101
-13
@@ -610,6 +610,8 @@ type TrainPlanState = {
|
||||
|
||||
export class GameView implements GameMap {
|
||||
private lastUpdate: GameUpdateViewData | null;
|
||||
private _lastUpdateAtMs = performance.now();
|
||||
private _tickDtEmaMs = 100;
|
||||
private smallIDToID = new Map<number, PlayerID>();
|
||||
private _players = new Map<PlayerID, PlayerView>();
|
||||
private _units = new Map<number, UnitView>();
|
||||
@@ -624,7 +626,9 @@ export class GameView implements GameMap {
|
||||
planId: number;
|
||||
startTick: number;
|
||||
ticksPerStep: number;
|
||||
path: Uint32Array;
|
||||
points: Uint32Array;
|
||||
segmentSteps: Uint32Array;
|
||||
segCumSteps: Uint32Array;
|
||||
}
|
||||
>();
|
||||
private trainMotionPlans = new Map<number, TrainPlanState>();
|
||||
@@ -680,7 +684,9 @@ export class GameView implements GameMap {
|
||||
planId: number;
|
||||
startTick: number;
|
||||
ticksPerStep: number;
|
||||
path: Uint32Array;
|
||||
points: Uint32Array;
|
||||
segmentSteps: Uint32Array;
|
||||
segCumSteps: Uint32Array;
|
||||
}
|
||||
> {
|
||||
return this.unitMotionPlans;
|
||||
@@ -723,7 +729,24 @@ export class GameView implements GameMap {
|
||||
return (this.lastUpdate?.pendingTurns ?? 0) > 1;
|
||||
}
|
||||
|
||||
public lastUpdateAtMs(): number {
|
||||
return this._lastUpdateAtMs;
|
||||
}
|
||||
|
||||
public tickDtEmaMs(): number {
|
||||
return this._tickDtEmaMs;
|
||||
}
|
||||
|
||||
public update(gu: GameUpdateViewData) {
|
||||
const nowMs = performance.now();
|
||||
const dtMs = nowMs - this._lastUpdateAtMs;
|
||||
if (Number.isFinite(dtMs) && dtMs > 0 && dtMs < 10_000) {
|
||||
// Smooth tick interval estimation to avoid jitter when interpolation.
|
||||
const alpha = 0.12;
|
||||
this._tickDtEmaMs = this._tickDtEmaMs * (1 - alpha) + dtMs * alpha;
|
||||
}
|
||||
this._lastUpdateAtMs = nowMs;
|
||||
|
||||
this.toDelete.forEach((id) => this._units.delete(id));
|
||||
this.toDelete.clear();
|
||||
|
||||
@@ -816,9 +839,58 @@ export class GameView implements GameMap {
|
||||
const dt = currentTick - plan.startTick;
|
||||
const stepIndex =
|
||||
dt <= 0 ? 0 : Math.floor(dt / Math.max(1, plan.ticksPerStep));
|
||||
const lastIndex = plan.path.length - 1;
|
||||
const idx = Math.max(0, Math.min(lastIndex, stepIndex));
|
||||
const newTile = plan.path[idx] as TileRef;
|
||||
|
||||
const points = plan.points;
|
||||
const segmentSteps = plan.segmentSteps;
|
||||
const segCumSteps = plan.segCumSteps;
|
||||
const totalSteps =
|
||||
segCumSteps.length === 0
|
||||
? 0
|
||||
: segCumSteps[segCumSteps.length - 1] >>> 0;
|
||||
const idx = Math.max(0, Math.min(totalSteps, stepIndex));
|
||||
|
||||
let newTile: TileRef;
|
||||
if (points.length === 0) {
|
||||
newTile = oldTile;
|
||||
} else if (segmentSteps.length === 0 || idx >= totalSteps) {
|
||||
newTile = points[points.length - 1] as TileRef;
|
||||
} else {
|
||||
let seg = 0;
|
||||
let lo = 0;
|
||||
let hi = segmentSteps.length - 1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
const start = segCumSteps[mid] >>> 0;
|
||||
const end = segCumSteps[mid + 1] >>> 0;
|
||||
if (idx < start) {
|
||||
hi = mid - 1;
|
||||
} else if (idx >= end) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
seg = mid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const localStep = idx - (segCumSteps[seg] >>> 0);
|
||||
const p0 = points[seg] as TileRef;
|
||||
const p1 = points[seg + 1] as TileRef;
|
||||
const x0 = this.x(p0);
|
||||
const y0 = this.y(p0);
|
||||
const x1 = this.x(p1);
|
||||
const y1 = this.y(p1);
|
||||
const steps = segmentSteps[seg] >>> 0;
|
||||
if (steps === 0) {
|
||||
newTile = p0;
|
||||
} else {
|
||||
const dx = x1 - x0;
|
||||
const dy = y1 - y0;
|
||||
newTile = this.ref(
|
||||
Math.round(x0 + (dx * localStep) / steps),
|
||||
Math.round(y0 + (dy * localStep) / steps),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (newTile !== oldTile) {
|
||||
unit.applyDerivedPosition(newTile);
|
||||
@@ -828,7 +900,7 @@ export class GameView implements GameMap {
|
||||
|
||||
// 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) {
|
||||
if (dt > 0 && stepIndex >= totalSteps) {
|
||||
if (this.unitMotionPlans.delete(unitId)) {
|
||||
this.markMotionPlannedUnitIdsDirty();
|
||||
}
|
||||
@@ -957,8 +1029,12 @@ export class GameView implements GameMap {
|
||||
private applyMotionPlanRecords(records: readonly MotionPlanRecord[]): void {
|
||||
for (const record of records) {
|
||||
switch (record.kind) {
|
||||
case "grid": {
|
||||
if (record.ticksPerStep < 1 || record.path.length < 1) {
|
||||
case "grid_segments": {
|
||||
if (
|
||||
record.ticksPerStep < 1 ||
|
||||
record.points.length < 1 ||
|
||||
record.segmentSteps.length !== Math.max(0, record.points.length - 1)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
const existing = this.unitMotionPlans.get(record.unitId);
|
||||
@@ -966,16 +1042,28 @@ export class GameView implements GameMap {
|
||||
break;
|
||||
}
|
||||
|
||||
const path =
|
||||
record.path instanceof Uint32Array
|
||||
? record.path
|
||||
: Uint32Array.from(record.path);
|
||||
const points =
|
||||
record.points instanceof Uint32Array
|
||||
? record.points
|
||||
: Uint32Array.from(record.points);
|
||||
const segmentSteps =
|
||||
record.segmentSteps instanceof Uint32Array
|
||||
? record.segmentSteps
|
||||
: Uint32Array.from(record.segmentSteps);
|
||||
|
||||
const segCumSteps = new Uint32Array(segmentSteps.length + 1);
|
||||
for (let i = 0; i < segmentSteps.length; i++) {
|
||||
segCumSteps[i + 1] =
|
||||
(segCumSteps[i] + (segmentSteps[i] >>> 0)) >>> 0;
|
||||
}
|
||||
|
||||
this.unitMotionPlans.set(record.unitId, {
|
||||
planId: record.planId,
|
||||
startTick: record.startTick,
|
||||
ticksPerStep: record.ticksPerStep,
|
||||
path,
|
||||
points,
|
||||
segmentSteps,
|
||||
segCumSteps,
|
||||
});
|
||||
this.markMotionPlannedUnitIdsDirty();
|
||||
break;
|
||||
|
||||
+198
-28
@@ -1,20 +1,19 @@
|
||||
import type { GameMap } from "./GameMap";
|
||||
import { TileRef } from "./GameMap";
|
||||
|
||||
export enum PackedMotionPlanKind {
|
||||
GridPathSet = 1,
|
||||
TrainRailPathSet = 2,
|
||||
GridPathKeypointSegments = 3,
|
||||
}
|
||||
|
||||
export interface GridPathPlan {
|
||||
kind: "grid";
|
||||
export interface GridKeypointSegmentPlan {
|
||||
kind: "grid_segments";
|
||||
unitId: number;
|
||||
planId: number;
|
||||
startTick: number;
|
||||
ticksPerStep: number;
|
||||
/**
|
||||
* TileRef path where `path[0]` is the unit tile at `startTick`.
|
||||
*/
|
||||
path: readonly TileRef[] | Uint32Array;
|
||||
points: readonly TileRef[] | Uint32Array;
|
||||
segmentSteps: readonly number[] | Uint32Array;
|
||||
}
|
||||
|
||||
export interface TrainRailPathPlan {
|
||||
@@ -34,7 +33,7 @@ export interface TrainRailPathPlan {
|
||||
path: readonly TileRef[] | Uint32Array;
|
||||
}
|
||||
|
||||
export type MotionPlanRecord = GridPathPlan | TrainRailPathPlan;
|
||||
export type MotionPlanRecord = GridKeypointSegmentPlan | TrainRailPathPlan;
|
||||
|
||||
export function packMotionPlans(
|
||||
records: readonly MotionPlanRecord[],
|
||||
@@ -42,9 +41,9 @@ export function packMotionPlans(
|
||||
let totalWords = 1;
|
||||
for (const record of records) {
|
||||
switch (record.kind) {
|
||||
case "grid": {
|
||||
const pathLen = (record.path.length >>> 0) as number;
|
||||
totalWords += 2 + 5 + pathLen;
|
||||
case "grid_segments": {
|
||||
const pointCount = (record.points.length >>> 0) as number;
|
||||
totalWords += 2 + 5 + pointCount + Math.max(0, pointCount - 1);
|
||||
break;
|
||||
}
|
||||
case "train": {
|
||||
@@ -62,21 +61,32 @@ export function packMotionPlans(
|
||||
let offset = 1;
|
||||
for (const record of records) {
|
||||
switch (record.kind) {
|
||||
case "grid": {
|
||||
const path = record.path as ArrayLike<number>;
|
||||
const pathLen = path.length >>> 0;
|
||||
const wordCount = 2 + 5 + pathLen;
|
||||
case "grid_segments": {
|
||||
const points = record.points as ArrayLike<number>;
|
||||
const segmentSteps = record.segmentSteps as ArrayLike<number>;
|
||||
const pointCount = points.length >>> 0;
|
||||
const segmentCount = pointCount > 0 ? pointCount - 1 : 0;
|
||||
if (segmentSteps.length >>> 0 !== segmentCount) {
|
||||
throw new Error(
|
||||
`grid_segments segmentSteps length mismatch: points=${pointCount}, segmentSteps=${segmentSteps.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
out[offset++] = PackedMotionPlanKind.GridPathSet;
|
||||
const wordCount = 2 + 5 + pointCount + segmentCount;
|
||||
|
||||
out[offset++] = PackedMotionPlanKind.GridPathKeypointSegments;
|
||||
out[offset++] = wordCount >>> 0;
|
||||
out[offset++] = record.unitId >>> 0;
|
||||
out[offset++] = record.planId >>> 0;
|
||||
out[offset++] = record.startTick >>> 0;
|
||||
out[offset++] = record.ticksPerStep >>> 0;
|
||||
out[offset++] = pathLen >>> 0;
|
||||
out[offset++] = pointCount >>> 0;
|
||||
|
||||
for (let i = 0; i < pathLen; i++) {
|
||||
out[offset++] = path[i] >>> 0;
|
||||
for (let i = 0; i < pointCount; i++) {
|
||||
out[offset++] = points[i] >>> 0;
|
||||
}
|
||||
for (let i = 0; i < segmentCount; i++) {
|
||||
out[offset++] = segmentSteps[i] >>> 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -135,7 +145,7 @@ export function unpackMotionPlans(packed: Uint32Array): MotionPlanRecord[] {
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case PackedMotionPlanKind.GridPathSet: {
|
||||
case PackedMotionPlanKind.GridPathKeypointSegments: {
|
||||
if (wordCount < 2 + 5) {
|
||||
break;
|
||||
}
|
||||
@@ -143,24 +153,34 @@ export function unpackMotionPlans(packed: Uint32Array): MotionPlanRecord[] {
|
||||
const planId = packed[offset + 3] >>> 0;
|
||||
const startTick = packed[offset + 4] >>> 0;
|
||||
const ticksPerStep = packed[offset + 5] >>> 0;
|
||||
const pathLen = packed[offset + 6] >>> 0;
|
||||
const pointCount = packed[offset + 6] >>> 0;
|
||||
const segmentCount = pointCount > 0 ? pointCount - 1 : 0;
|
||||
|
||||
const expectedWordCount = 2 + 5 + pathLen;
|
||||
if (expectedWordCount !== wordCount) {
|
||||
const expectedWordCount = 2 + 5 + pointCount + segmentCount;
|
||||
if (
|
||||
expectedWordCount !== wordCount ||
|
||||
pointCount < 1 ||
|
||||
ticksPerStep < 1
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
const pathStart = offset + 7;
|
||||
const pathEnd = pathStart + pathLen;
|
||||
const path = packed.slice(pathStart, pathEnd);
|
||||
const pointsStart = offset + 7;
|
||||
const pointsEnd = pointsStart + pointCount;
|
||||
const segmentsStart = pointsEnd;
|
||||
const segmentsEnd = segmentsStart + segmentCount;
|
||||
|
||||
const points = packed.slice(pointsStart, pointsEnd);
|
||||
const segmentSteps = packed.slice(segmentsStart, segmentsEnd);
|
||||
|
||||
records.push({
|
||||
kind: "grid",
|
||||
kind: "grid_segments",
|
||||
unitId,
|
||||
planId,
|
||||
startTick,
|
||||
ticksPerStep,
|
||||
path,
|
||||
points,
|
||||
segmentSteps,
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -210,3 +230,153 @@ export function unpackMotionPlans(packed: Uint32Array): MotionPlanRecord[] {
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
export function densePathToKeypointSegments(path: ArrayLike<number>): {
|
||||
points: Uint32Array;
|
||||
segmentSteps: Uint32Array;
|
||||
} | null {
|
||||
const len = path.length >>> 0;
|
||||
if (len === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = path[0] >>> 0;
|
||||
if (len === 1) {
|
||||
return {
|
||||
points: Uint32Array.from([first]),
|
||||
segmentSteps: new Uint32Array(0),
|
||||
};
|
||||
}
|
||||
|
||||
const points: number[] = [first];
|
||||
const segmentSteps: number[] = [];
|
||||
|
||||
let last = first;
|
||||
let dirDelta: number | null = null;
|
||||
let runSteps = 0;
|
||||
|
||||
for (let i = 1; i < len; i++) {
|
||||
const cur = path[i] >>> 0;
|
||||
const delta = (cur - last) | 0;
|
||||
if (delta === 0) {
|
||||
last = cur;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dirDelta === null) {
|
||||
dirDelta = delta;
|
||||
runSteps = 1;
|
||||
} else if (delta === dirDelta) {
|
||||
runSteps++;
|
||||
} else {
|
||||
points.push(last);
|
||||
segmentSteps.push(runSteps);
|
||||
dirDelta = delta;
|
||||
runSteps = 1;
|
||||
}
|
||||
last = cur;
|
||||
}
|
||||
|
||||
if (dirDelta === null) {
|
||||
return {
|
||||
points: Uint32Array.from([first]),
|
||||
segmentSteps: new Uint32Array(0),
|
||||
};
|
||||
}
|
||||
|
||||
points.push(last);
|
||||
segmentSteps.push(runSteps);
|
||||
|
||||
return {
|
||||
points: Uint32Array.from(points),
|
||||
segmentSteps: Uint32Array.from(segmentSteps),
|
||||
};
|
||||
}
|
||||
|
||||
function canTraverseDda(
|
||||
map: GameMap,
|
||||
from: TileRef,
|
||||
to: TileRef,
|
||||
isTraversable: (t: TileRef) => boolean,
|
||||
): boolean {
|
||||
const x0 = map.x(from);
|
||||
const y0 = map.y(from);
|
||||
const x1 = map.x(to);
|
||||
const y1 = map.y(to);
|
||||
|
||||
const dx = x1 - x0;
|
||||
const dy = y1 - y0;
|
||||
const steps = Math.max(Math.abs(dx), Math.abs(dy));
|
||||
if (steps === 0) {
|
||||
return isTraversable(from);
|
||||
}
|
||||
|
||||
for (let t = 0; t <= steps; t++) {
|
||||
const x = Math.round(x0 + (dx * t) / steps);
|
||||
const y = Math.round(y0 + (dy * t) / steps);
|
||||
if (!map.isValidCoord(x, y)) {
|
||||
return false;
|
||||
}
|
||||
const ref = map.ref(x, y);
|
||||
if (!isTraversable(ref)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function densePathToLosKeypointSegments(
|
||||
path: readonly TileRef[] | Uint32Array,
|
||||
map: GameMap,
|
||||
isTraversable: (t: TileRef) => boolean,
|
||||
): { points: Uint32Array; segmentSteps: Uint32Array } | null {
|
||||
const len = path.length >>> 0;
|
||||
if (len === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = (path[0] ?? 0) as TileRef;
|
||||
if (len === 1) {
|
||||
return {
|
||||
points: Uint32Array.from([first >>> 0]),
|
||||
segmentSteps: new Uint32Array(0),
|
||||
};
|
||||
}
|
||||
|
||||
const points: number[] = [first >>> 0];
|
||||
const segmentSteps: number[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < len - 1) {
|
||||
let best = i + 1;
|
||||
let lo = i + 1;
|
||||
let hi = len - 1;
|
||||
|
||||
// Binary search for farthest "visible" point along the existing path.
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
const ok = canTraverseDda(
|
||||
map,
|
||||
path[i] as TileRef,
|
||||
path[mid] as TileRef,
|
||||
isTraversable,
|
||||
);
|
||||
if (ok) {
|
||||
best = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
points.push((path[best] as TileRef) >>> 0);
|
||||
segmentSteps.push(best - i);
|
||||
i = best;
|
||||
}
|
||||
|
||||
return {
|
||||
points: Uint32Array.from(points),
|
||||
segmentSteps: Uint32Array.from(segmentSteps),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GameMapImpl } from "../src/core/game/GameMap";
|
||||
import { densePathToKeypointSegments } from "../src/core/game/MotionPlans";
|
||||
|
||||
function makeMap(width: number, height: number): GameMapImpl {
|
||||
return new GameMapImpl(width, height, new Uint8Array(width * height), 0);
|
||||
}
|
||||
|
||||
function expandPlanDda(
|
||||
map: GameMapImpl,
|
||||
points: Uint32Array,
|
||||
segmentSteps: Uint32Array,
|
||||
): number[] {
|
||||
const out: number[] = [];
|
||||
if (points.length === 0) return out;
|
||||
out.push(points[0] >>> 0);
|
||||
for (let i = 0; i < segmentSteps.length; i++) {
|
||||
const steps = segmentSteps[i] >>> 0;
|
||||
const a = points[i] >>> 0;
|
||||
const b = points[i + 1] >>> 0;
|
||||
const ax = map.x(a);
|
||||
const ay = map.y(a);
|
||||
const bx = map.x(b);
|
||||
const by = map.y(b);
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
for (let t = 1; t <= steps; t++) {
|
||||
out.push(
|
||||
map.ref(
|
||||
Math.round(ax + (dx * t) / steps),
|
||||
Math.round(ay + (dy * t) / steps),
|
||||
) >>> 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("densePathToKeypointSegments", () => {
|
||||
it("expands back to the dense path for axis segments", () => {
|
||||
const map = makeMap(10, 10);
|
||||
|
||||
const dense = [
|
||||
map.ref(1, 1),
|
||||
map.ref(2, 1),
|
||||
map.ref(3, 1),
|
||||
map.ref(4, 1),
|
||||
map.ref(4, 2),
|
||||
map.ref(4, 3),
|
||||
map.ref(4, 4),
|
||||
];
|
||||
|
||||
const plan = densePathToKeypointSegments(dense);
|
||||
expect(plan).not.toBeNull();
|
||||
if (!plan) return;
|
||||
|
||||
const expanded = expandPlanDda(map, plan.points, plan.segmentSteps);
|
||||
expect(expanded).toEqual(dense.map((t) => t >>> 0));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
packMotionPlans,
|
||||
unpackMotionPlans,
|
||||
} from "../src/core/game/MotionPlans";
|
||||
|
||||
describe("MotionPlans grid_segments", () => {
|
||||
it("packs/unpacks grid_segments", () => {
|
||||
const packed = packMotionPlans([
|
||||
{
|
||||
kind: "grid_segments",
|
||||
unitId: 123,
|
||||
planId: 7,
|
||||
startTick: 10,
|
||||
ticksPerStep: 2,
|
||||
points: Uint32Array.from([1, 6, 11]),
|
||||
segmentSteps: Uint32Array.from([5, 5]),
|
||||
},
|
||||
]);
|
||||
|
||||
const records = unpackMotionPlans(packed);
|
||||
expect(records).toHaveLength(1);
|
||||
const r = records[0];
|
||||
expect(r.kind).toBe("grid_segments");
|
||||
if (r.kind !== "grid_segments") throw new Error("type guard");
|
||||
expect(r.unitId).toBe(123);
|
||||
expect(r.planId).toBe(7);
|
||||
expect(r.startTick).toBe(10);
|
||||
expect(r.ticksPerStep).toBe(2);
|
||||
expect(Array.from(r.points)).toEqual([1, 6, 11]);
|
||||
expect(Array.from(r.segmentSteps)).toEqual([5, 5]);
|
||||
});
|
||||
|
||||
it("skips unknown kinds using wordCount", () => {
|
||||
const gridPacked = packMotionPlans([
|
||||
{
|
||||
kind: "grid_segments",
|
||||
unitId: 1,
|
||||
planId: 1,
|
||||
startTick: 1,
|
||||
ticksPerStep: 1,
|
||||
points: Uint32Array.from([10, 12]),
|
||||
segmentSteps: Uint32Array.from([2]),
|
||||
},
|
||||
]);
|
||||
|
||||
const gridRecordWords = gridPacked.slice(1); // strip recordCount
|
||||
const unknownWordCount = 4;
|
||||
const out = new Uint32Array(1 + unknownWordCount + gridRecordWords.length);
|
||||
out[0] = 2;
|
||||
let o = 1;
|
||||
out[o++] = 999;
|
||||
out[o++] = unknownWordCount;
|
||||
out[o++] = 111;
|
||||
out[o++] = 222;
|
||||
out.set(gridRecordWords, o);
|
||||
|
||||
const records = unpackMotionPlans(out);
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0].kind).toBe("grid_segments");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PathFinderStepper } from "../src/core/pathfinding/PathFinderStepper";
|
||||
import { PathStatus } from "../src/core/pathfinding/types";
|
||||
|
||||
describe("PathFinderStepper cache priming", () => {
|
||||
it("does not prime next() cache via findPath()", () => {
|
||||
let calls = 0;
|
||||
const finder = {
|
||||
findPath(from: number | number[], to: number) {
|
||||
calls++;
|
||||
const start = Array.isArray(from) ? from[0] : from;
|
||||
return [start, to];
|
||||
},
|
||||
};
|
||||
|
||||
const stepper = new PathFinderStepper<number>(finder, {
|
||||
equals: (a, b) => a === b,
|
||||
});
|
||||
|
||||
const from = 10;
|
||||
const to = 42;
|
||||
|
||||
const path = stepper.findPath(from, to);
|
||||
expect(path).toEqual([from, to]);
|
||||
expect(calls).toBe(1);
|
||||
|
||||
const r1 = stepper.next(from, to);
|
||||
expect(r1.status).toBe(PathStatus.NEXT);
|
||||
if (r1.status === PathStatus.NEXT) {
|
||||
expect(r1.node).toBe(to);
|
||||
}
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user