diff --git a/src/client/graphics/layers/SegmentMotionSample.ts b/src/client/graphics/layers/SegmentMotionSample.ts new file mode 100644 index 000000000..15e11a162 --- /dev/null +++ b/src/client/graphics/layers/SegmentMotionSample.ts @@ -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, + }; +} diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index e5d72f0e8..83c99ffca 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -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(); + private gridMoverUnitIds = new Set(); + 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(); + 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(); + 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(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(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, b: Set): 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, + ); } } diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 2c6b80e59..01e1534e7 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -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; } diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 3504e691c..37c407ccc 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -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"; @@ -113,18 +116,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; @@ -264,20 +275,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; } diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index ab2a179f5..bf6d04821 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -449,7 +449,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": diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index f08b8178b..314800818 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -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(); private _players = new Map(); private _units = new Map(); @@ -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(); @@ -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; diff --git a/src/core/game/MotionPlans.ts b/src/core/game/MotionPlans.ts index 4417d0212..4154d7447 100644 --- a/src/core/game/MotionPlans.ts +++ b/src/core/game/MotionPlans.ts @@ -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; - const pathLen = path.length >>> 0; - const wordCount = 2 + 5 + pathLen; + case "grid_segments": { + const points = record.points as ArrayLike; + const segmentSteps = record.segmentSteps as ArrayLike; + 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): { + 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), + }; +} diff --git a/tests/MiniMapTransformerPlanSegments.test.ts b/tests/MiniMapTransformerPlanSegments.test.ts new file mode 100644 index 000000000..50ba1a0f6 --- /dev/null +++ b/tests/MiniMapTransformerPlanSegments.test.ts @@ -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)); + }); +}); diff --git a/tests/MotionPlansSegments.test.ts b/tests/MotionPlansSegments.test.ts new file mode 100644 index 000000000..1f6628025 --- /dev/null +++ b/tests/MotionPlansSegments.test.ts @@ -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"); + }); +}); diff --git a/tests/PathFinderStepperPriming.test.ts b/tests/PathFinderStepperPriming.test.ts new file mode 100644 index 000000000..a63464c50 --- /dev/null +++ b/tests/PathFinderStepperPriming.test.ts @@ -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(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); + }); +});