This commit is contained in:
scamiv
2026-02-27 01:47:29 +01:00
parent 9f5d0a57c5
commit e9df00729f
10 changed files with 845 additions and 122 deletions
+16 -6
View File
@@ -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;
}
+33 -15
View File
@@ -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;
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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),
};
}