mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
motion plans: packed buffers + client-side movement
This commit is contained in:
@@ -44,6 +44,7 @@ import { TerritoryLayer } from "./layers/TerritoryLayer";
|
||||
import { UILayer } from "./layers/UILayer";
|
||||
import { UnitDisplay } from "./layers/UnitDisplay";
|
||||
import { UnitLayer } from "./layers/UnitLayer";
|
||||
import { MotionPlanLayer } from "./layers/MotionPlanLayer";
|
||||
import { WinModal } from "./layers/WinModal";
|
||||
|
||||
export function createRenderer(
|
||||
@@ -284,6 +285,7 @@ export function createRenderer(
|
||||
new RailroadLayer(game, eventBus, transformHandler, uiState),
|
||||
structureLayer,
|
||||
samRadiusLayer,
|
||||
new MotionPlanLayer(game, eventBus),
|
||||
new UnitLayer(game, eventBus, transformHandler),
|
||||
new FxLayer(game, eventBus, transformHandler),
|
||||
new UILayer(game, eventBus, transformHandler),
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { colord, Colord } from "colord";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView, UnitView } from "../../../core/game/GameView";
|
||||
import { AlternateViewEvent, UnitSelectionEvent } from "../../InputHandler";
|
||||
import { getColoredSprite, isSpriteReady } from "../SpriteLoader";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
const TICK_MS = 100;
|
||||
|
||||
enum Relationship {
|
||||
Self,
|
||||
Ally,
|
||||
Enemy,
|
||||
}
|
||||
|
||||
type StoredMotionPlan = {
|
||||
planId: number;
|
||||
startTick: number;
|
||||
ticksPerStep: number;
|
||||
path: Uint32Array;
|
||||
};
|
||||
|
||||
export class MotionPlanLayer implements Layer {
|
||||
private theme: Theme;
|
||||
private alternateView = false;
|
||||
private selectedUnitId: number | null = null;
|
||||
|
||||
private lastTickAtMs = performance.now();
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
private eventBus: EventBus,
|
||||
) {
|
||||
this.theme = game.config().theme();
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
init(): void {
|
||||
this.eventBus.on(AlternateViewEvent, (e) => {
|
||||
this.alternateView = e.alternateView;
|
||||
});
|
||||
this.eventBus.on(UnitSelectionEvent, (e) => {
|
||||
this.selectedUnitId = e.isSelected ? (e.unit?.id() ?? null) : null;
|
||||
});
|
||||
}
|
||||
|
||||
tick(): void {
|
||||
this.lastTickAtMs = performance.now();
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D): void {
|
||||
const now = performance.now();
|
||||
const alpha = this.game.isCatchingUp()
|
||||
? 1
|
||||
: Math.max(0, Math.min(1, (now - this.lastTickAtMs) / TICK_MS));
|
||||
const tRender = (this.game.ticks() - 1) + alpha;
|
||||
|
||||
for (const [unitId, plan] of this.game.motionPlans()) {
|
||||
const unit = this.game.unit(unitId);
|
||||
if (!unit || !unit.isActive()) {
|
||||
continue;
|
||||
}
|
||||
if (!isSpriteReady(unit)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pos = this.positionAtTime(plan, tRender);
|
||||
if (!pos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isSelected = this.selectedUnitId === unitId;
|
||||
this.drawUnitSprite(context, unit, pos.x, pos.y, isSelected);
|
||||
}
|
||||
}
|
||||
|
||||
private positionAtTime(
|
||||
plan: StoredMotionPlan,
|
||||
t: number,
|
||||
): { x: number; y: number } | null {
|
||||
if (plan.path.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseTick = Math.floor(t);
|
||||
const frac = t - baseTick;
|
||||
|
||||
const tileA = this.tileAtTick(plan, baseTick);
|
||||
const tileB = this.tileAtTick(plan, baseTick + 1);
|
||||
|
||||
const xA = this.game.x(tileA);
|
||||
const yA = this.game.y(tileA);
|
||||
const xB = this.game.x(tileB);
|
||||
const yB = this.game.y(tileB);
|
||||
|
||||
return {
|
||||
x: xA + (xB - xA) * frac,
|
||||
y: yA + (yB - yA) * frac,
|
||||
};
|
||||
}
|
||||
|
||||
private tileAtTick(plan: StoredMotionPlan, tick: number): TileRef {
|
||||
const dt = tick - plan.startTick;
|
||||
const stepIndex =
|
||||
dt <= 0 ? 0 : Math.floor(dt / Math.max(1, plan.ticksPerStep));
|
||||
const idx = Math.max(0, Math.min(plan.path.length - 1, stepIndex));
|
||||
return plan.path[idx] as TileRef;
|
||||
}
|
||||
|
||||
private relationship(unit: UnitView): Relationship {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (myPlayer === null) {
|
||||
return Relationship.Enemy;
|
||||
}
|
||||
if (myPlayer === unit.owner()) {
|
||||
return Relationship.Self;
|
||||
}
|
||||
if (myPlayer.isFriendly(unit.owner())) {
|
||||
return Relationship.Ally;
|
||||
}
|
||||
return Relationship.Enemy;
|
||||
}
|
||||
|
||||
private drawUnitSprite(
|
||||
context: CanvasRenderingContext2D,
|
||||
unit: UnitView,
|
||||
x: number,
|
||||
y: number,
|
||||
isSelected: boolean,
|
||||
): void {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
switch (rel) {
|
||||
case Relationship.Self:
|
||||
alternateViewColor = this.theme.selfColor();
|
||||
break;
|
||||
case Relationship.Ally:
|
||||
alternateViewColor = this.theme.allyColor();
|
||||
break;
|
||||
case Relationship.Enemy:
|
||||
alternateViewColor = this.theme.enemyColor();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const sprite = getColoredSprite(
|
||||
unit,
|
||||
this.theme,
|
||||
alternateViewColor ?? undefined,
|
||||
alternateViewColor ?? undefined,
|
||||
);
|
||||
|
||||
const mapX = x - this.game.width() / 2;
|
||||
const mapY = y - this.game.height() / 2;
|
||||
|
||||
const targetable = unit.targetable();
|
||||
if (!targetable) {
|
||||
context.save();
|
||||
context.globalAlpha = 0.5;
|
||||
}
|
||||
|
||||
context.drawImage(
|
||||
sprite,
|
||||
Math.round(mapX - sprite.width / 2),
|
||||
Math.round(mapY - sprite.height / 2),
|
||||
sprite.width,
|
||||
sprite.width,
|
||||
);
|
||||
|
||||
if (!targetable) {
|
||||
context.restore();
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
context.save();
|
||||
context.strokeStyle = colord("white").alpha(0.9).toRgbString();
|
||||
context.lineWidth = 2;
|
||||
context.beginPath();
|
||||
context.arc(mapX, mapY, Math.max(sprite.width, 18) / 2 + 4, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -545,6 +545,10 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
|
||||
drawSprite(unit: UnitView, customTerritoryColor?: Colord) {
|
||||
if (this.game.hasMotionPlan(unit.id())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const x = this.game.x(unit.tile());
|
||||
const y = this.game.y(unit.tile());
|
||||
|
||||
|
||||
@@ -169,10 +169,12 @@ export class GameRunner {
|
||||
}
|
||||
|
||||
const packedTileUpdates = this.game.drainPackedTileUpdates();
|
||||
const packedMotionPlans = this.game.drainPackedMotionPlans();
|
||||
|
||||
this.callBack({
|
||||
tick: this.game.ticks(),
|
||||
packedTileUpdates,
|
||||
...(packedMotionPlans ? { packedMotionPlans } : {}),
|
||||
updates: updates,
|
||||
playerNameViewData: this.playerViewData,
|
||||
tickExecutionDuration: tickExecutionDuration,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { PathStatus } from "../pathfinding/types";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { NukeType } from "../StatsSchemas";
|
||||
import { listNukeBreakAlliance } from "./Util";
|
||||
import { MotionPlanRecord } from "../game/MotionPlans";
|
||||
|
||||
const SPRITE_RADIUS = 16;
|
||||
|
||||
@@ -139,6 +140,19 @@ export class NukeExecution implements Execution {
|
||||
targetTile: this.dst,
|
||||
trajectory: this.getTrajectory(this.dst),
|
||||
});
|
||||
|
||||
const motionPlan: MotionPlanRecord = {
|
||||
kind: "parabola",
|
||||
unitId: this.nuke.id(),
|
||||
planId: 1,
|
||||
startTick: ticks + 1 + this.waitTicks,
|
||||
src: spawn,
|
||||
dst: this.dst,
|
||||
increment: this.speed,
|
||||
distanceBasedHeight: this.nukeType !== UnitType.MIRVWarhead,
|
||||
directionUp: this.rocketDirectionUp,
|
||||
};
|
||||
this.mg.recordMotionPlan(motionPlan);
|
||||
if (this.nuke.type() !== UnitType.MIRVWarhead) {
|
||||
this.maybeBreakAlliances();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { TileRef } from "../game/GameMap";
|
||||
import { PathFinding } from "../pathfinding/PathFinder";
|
||||
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
|
||||
import { distSortUnit } from "../Util";
|
||||
import { MotionPlanRecord } from "../game/MotionPlans";
|
||||
|
||||
export class TradeShipExecution implements Execution {
|
||||
private active = true;
|
||||
@@ -19,6 +20,8 @@ export class TradeShipExecution implements Execution {
|
||||
private wasCaptured = false;
|
||||
private pathFinder: SteppingPathFinder<TileRef>;
|
||||
private tilesTraveled = 0;
|
||||
private motionPlanId = 1;
|
||||
private motionPlanDst: TileRef | null = null;
|
||||
|
||||
constructor(
|
||||
private origOwner: Player,
|
||||
@@ -32,6 +35,7 @@ export class TradeShipExecution implements Execution {
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
let spawnedThisTick = false;
|
||||
if (this.tradeShip === undefined) {
|
||||
const spawn = this.origOwner.canBuild(
|
||||
UnitType.TradeShip,
|
||||
@@ -47,6 +51,18 @@ export class TradeShipExecution implements Execution {
|
||||
lastSetSafeFromPirates: ticks,
|
||||
});
|
||||
this.mg.stats().boatSendTrade(this.origOwner, this._dstPort.owner());
|
||||
spawnedThisTick = true;
|
||||
|
||||
const placeholderPlan: MotionPlanRecord = {
|
||||
kind: "grid",
|
||||
unitId: this.tradeShip.id(),
|
||||
planId: this.motionPlanId,
|
||||
startTick: ticks + 1,
|
||||
ticksPerStep: 1,
|
||||
path: [spawn],
|
||||
};
|
||||
this.mg.recordMotionPlan(placeholderPlan);
|
||||
this.motionPlanDst = this._dstPort.tile();
|
||||
}
|
||||
|
||||
if (!this.tradeShip.isActive()) {
|
||||
@@ -106,8 +122,6 @@ export class TradeShipExecution implements Execution {
|
||||
|
||||
switch (result.status) {
|
||||
case PathStatus.PENDING:
|
||||
// Fire unit event to rerender.
|
||||
this.tradeShip.move(curTile);
|
||||
break;
|
||||
case PathStatus.NEXT:
|
||||
// Update safeFromPirates status
|
||||
@@ -128,6 +142,26 @@ export class TradeShipExecution implements Execution {
|
||||
this.active = false;
|
||||
break;
|
||||
}
|
||||
|
||||
const dst = this._dstPort.tile();
|
||||
if (spawnedThisTick || dst !== this.motionPlanDst) {
|
||||
this.motionPlanId++;
|
||||
const from = this.tradeShip.tile();
|
||||
const path = this.pathFinder.findPath(from, dst) ?? [from];
|
||||
if (path.length === 0 || path[0] !== from) {
|
||||
path.unshift(from);
|
||||
}
|
||||
|
||||
this.mg.recordMotionPlan({
|
||||
kind: "grid",
|
||||
unitId: this.tradeShip.id(),
|
||||
planId: this.motionPlanId,
|
||||
startTick: ticks + 1,
|
||||
ticksPerStep: 1,
|
||||
path,
|
||||
});
|
||||
this.motionPlanDst = dst;
|
||||
}
|
||||
}
|
||||
|
||||
private complete() {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { targetTransportTile } from "../game/TransportShipUtils";
|
||||
import { PathFinding } from "../pathfinding/PathFinder";
|
||||
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
|
||||
import { AttackExecution } from "./AttackExecution";
|
||||
import { MotionPlanRecord } from "../game/MotionPlans";
|
||||
|
||||
const malusForRetreat = 25;
|
||||
|
||||
@@ -30,6 +31,8 @@ export class TransportShipExecution implements Execution {
|
||||
private dst: TileRef | null;
|
||||
private src: TileRef | null;
|
||||
private boat: Unit;
|
||||
private motionPlanId = 1;
|
||||
private motionPlanDst: TileRef | null = null;
|
||||
|
||||
private originalOwner: Player;
|
||||
|
||||
@@ -109,6 +112,22 @@ 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 motionPlan: MotionPlanRecord = {
|
||||
kind: "grid",
|
||||
unitId: this.boat.id(),
|
||||
planId: this.motionPlanId,
|
||||
startTick: ticks + this.ticksPerMove,
|
||||
ticksPerStep: this.ticksPerMove,
|
||||
path: fullPath,
|
||||
};
|
||||
this.mg.recordMotionPlan(motionPlan);
|
||||
this.motionPlanDst = this.dst;
|
||||
|
||||
// Notify the target player about the incoming naval invasion
|
||||
if (this.target.id() !== mg.terraNullius().id()) {
|
||||
mg.displayIncomingUnit(
|
||||
@@ -249,6 +268,25 @@ export class TransportShipExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
this.mg.recordMotionPlan({
|
||||
kind: "grid",
|
||||
unitId: this.boat.id(),
|
||||
planId: this.motionPlanId,
|
||||
startTick: ticks + 1,
|
||||
ticksPerStep: this.ticksPerMove,
|
||||
path: fullPath,
|
||||
});
|
||||
this.motionPlanDst = this.dst;
|
||||
}
|
||||
}
|
||||
|
||||
owner(): Player {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { RailNetwork } from "./RailNetwork";
|
||||
import { Stats } from "./Stats";
|
||||
import { UnitPredicate } from "./UnitGrid";
|
||||
import type { MotionPlanRecord } from "./MotionPlans";
|
||||
|
||||
function isEnumValue<T extends Record<string, string | number>>(
|
||||
enumObj: T,
|
||||
@@ -767,6 +768,8 @@ export interface Game extends GameMap {
|
||||
inSpawnPhase(): boolean;
|
||||
executeNextTick(): GameUpdates;
|
||||
drainPackedTileUpdates(): Uint32Array;
|
||||
recordMotionPlan(record: MotionPlanRecord): void;
|
||||
drainPackedMotionPlans(): Uint32Array | null;
|
||||
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void;
|
||||
getWinner(): Player | Team | null;
|
||||
config(): Config;
|
||||
|
||||
@@ -49,6 +49,7 @@ import { StatsImpl } from "./StatsImpl";
|
||||
import { assignTeams } from "./TeamAssignment";
|
||||
import { TerraNulliusImpl } from "./TerraNulliusImpl";
|
||||
import { UnitGrid, UnitPredicate } from "./UnitGrid";
|
||||
import { MotionPlanRecord, packMotionPlans } from "./MotionPlans";
|
||||
|
||||
export function createGame(
|
||||
humans: PlayerInfo[],
|
||||
@@ -84,6 +85,8 @@ export class GameImpl implements Game {
|
||||
|
||||
private updates: GameUpdates = createGameUpdatesMap();
|
||||
private tileUpdatePairs: number[] = [];
|
||||
private motionPlanRecords: MotionPlanRecord[] = [];
|
||||
private planDrivenUnitIds = new Set<number>();
|
||||
private unitGrid: UnitGrid;
|
||||
|
||||
private playerTeams: Team[];
|
||||
@@ -232,6 +235,12 @@ export class GameImpl implements Game {
|
||||
}
|
||||
|
||||
addUpdate(update: GameUpdate) {
|
||||
if (update.type === GameUpdateType.Unit) {
|
||||
const unitUpdate = update as any as { id: number; isActive: boolean };
|
||||
if (unitUpdate.isActive === false) {
|
||||
this.planDrivenUnitIds.delete(unitUpdate.id);
|
||||
}
|
||||
}
|
||||
(this.updates[update.type] as GameUpdate[]).push(update);
|
||||
}
|
||||
|
||||
@@ -430,6 +439,36 @@ export class GameImpl implements Game {
|
||||
return packed;
|
||||
}
|
||||
|
||||
recordMotionPlan(record: MotionPlanRecord): void {
|
||||
switch (record.kind) {
|
||||
case "grid":
|
||||
case "parabola":
|
||||
this.planDrivenUnitIds.add(record.unitId);
|
||||
break;
|
||||
case "clear":
|
||||
this.planDrivenUnitIds.delete(record.unitId);
|
||||
break;
|
||||
case "reset_all":
|
||||
this.planDrivenUnitIds.clear();
|
||||
break;
|
||||
}
|
||||
this.motionPlanRecords.push(record);
|
||||
}
|
||||
|
||||
isUnitPlanDriven(unitId: number): boolean {
|
||||
return this.planDrivenUnitIds.has(unitId);
|
||||
}
|
||||
|
||||
drainPackedMotionPlans(): Uint32Array | null {
|
||||
const records = this.motionPlanRecords;
|
||||
if (records.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const packed = packMotionPlans(records);
|
||||
records.length = 0;
|
||||
return packed;
|
||||
}
|
||||
|
||||
private hash(): number {
|
||||
let hash = 1;
|
||||
this._players.forEach((p) => {
|
||||
|
||||
@@ -24,6 +24,13 @@ export interface GameUpdateViewData {
|
||||
* state (`uint16`) stored in a `uint32` lane.
|
||||
*/
|
||||
packedTileUpdates: Uint32Array;
|
||||
/**
|
||||
* Optional packed motion plan records.
|
||||
*
|
||||
* When present, this buffer is expected to be transferred worker -> main
|
||||
* (similar to `packedTileUpdates`) to avoid structured-clone copies.
|
||||
*/
|
||||
packedMotionPlans?: Uint32Array;
|
||||
playerNameViewData: Record<string, NameViewData>;
|
||||
tickExecutionDuration?: number;
|
||||
pendingTurns?: number;
|
||||
|
||||
@@ -38,6 +38,13 @@ import { TerrainMapData } from "./TerrainMapLoader";
|
||||
import { TerraNulliusImpl } from "./TerraNulliusImpl";
|
||||
import { UnitGrid, UnitPredicate } from "./UnitGrid";
|
||||
import { UserSettings } from "./UserSettings";
|
||||
import {
|
||||
MOTION_PLANS_SCHEMA_VERSION,
|
||||
MotionPlanRecord,
|
||||
unpackMotionPlans,
|
||||
} from "./MotionPlans";
|
||||
import { UniversalPathFinding } from "../pathfinding/PathFinder";
|
||||
import { PathStatus } from "../pathfinding/types";
|
||||
|
||||
const userSettings: UserSettings = new UserSettings();
|
||||
|
||||
@@ -83,6 +90,17 @@ export class UnitView {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
applyDerivedPosition(pos: TileRef) {
|
||||
const prev = this.data.pos;
|
||||
this.lastPos.push(pos);
|
||||
this._wasUpdated = true;
|
||||
this.data = {
|
||||
...this.data,
|
||||
lastPos: prev,
|
||||
pos,
|
||||
};
|
||||
}
|
||||
|
||||
id(): number {
|
||||
return this.data.id;
|
||||
}
|
||||
@@ -592,6 +610,15 @@ export class GameView implements GameMap {
|
||||
private _myPlayer: PlayerView | null = null;
|
||||
|
||||
private unitGrid: UnitGrid;
|
||||
private unitMotionPlans = new Map<
|
||||
number,
|
||||
{
|
||||
planId: number;
|
||||
startTick: number;
|
||||
ticksPerStep: number;
|
||||
path: Uint32Array;
|
||||
}
|
||||
>();
|
||||
|
||||
private toDelete = new Set<number>();
|
||||
|
||||
@@ -637,6 +664,22 @@ export class GameView implements GameMap {
|
||||
return this.lastUpdate?.updates ?? null;
|
||||
}
|
||||
|
||||
public hasMotionPlan(unitId: number): boolean {
|
||||
return this.unitMotionPlans.has(unitId);
|
||||
}
|
||||
|
||||
public motionPlans(): ReadonlyMap<
|
||||
number,
|
||||
{
|
||||
planId: number;
|
||||
startTick: number;
|
||||
ticksPerStep: number;
|
||||
path: Uint32Array;
|
||||
}
|
||||
> {
|
||||
return this.unitMotionPlans;
|
||||
}
|
||||
|
||||
public isCatchingUp(): boolean {
|
||||
return (this.lastUpdate?.pendingTurns ?? 0) > 1;
|
||||
}
|
||||
@@ -656,6 +699,13 @@ export class GameView implements GameMap {
|
||||
this.updatedTiles.push(tile);
|
||||
}
|
||||
|
||||
if (gu.packedMotionPlans) {
|
||||
const { schemaVersion, records } = unpackMotionPlans(gu.packedMotionPlans);
|
||||
if (schemaVersion === MOTION_PLANS_SCHEMA_VERSION) {
|
||||
this.applyMotionPlanRecords(records);
|
||||
}
|
||||
}
|
||||
|
||||
if (gu.updates === null) {
|
||||
throw new Error("lastUpdate.updates not initialized");
|
||||
}
|
||||
@@ -704,8 +754,115 @@ export class GameView implements GameMap {
|
||||
if (!unit.isActive()) {
|
||||
// Wait until next tick to delete the unit.
|
||||
this.toDelete.add(unit.id());
|
||||
this.unitMotionPlans.delete(unit.id());
|
||||
}
|
||||
});
|
||||
|
||||
this.advanceMotionPlannedUnits(gu.tick);
|
||||
}
|
||||
|
||||
private advanceMotionPlannedUnits(currentTick: Tick): void {
|
||||
for (const [unitId, plan] of this.unitMotionPlans) {
|
||||
const unit = this._units.get(unitId);
|
||||
if (!unit || !unit.isActive()) {
|
||||
this.unitMotionPlans.delete(unitId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldTile = unit.tile();
|
||||
const newTile = this.motionTileAtTick(plan, currentTick);
|
||||
unit.applyDerivedPosition(newTile);
|
||||
|
||||
if (newTile !== oldTile) {
|
||||
this.unitGrid.updateUnitCell(unit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private motionTileAtTick(
|
||||
plan: { startTick: number; ticksPerStep: number; path: Uint32Array },
|
||||
tick: Tick,
|
||||
): TileRef {
|
||||
if (plan.path.length < 1) {
|
||||
throw new Error("motion plan path must be non-empty");
|
||||
}
|
||||
const dt = tick - plan.startTick;
|
||||
const stepIndex =
|
||||
dt <= 0 ? 0 : Math.floor(dt / Math.max(1, plan.ticksPerStep));
|
||||
const idx = Math.max(0, Math.min(plan.path.length - 1, stepIndex));
|
||||
return plan.path[idx] as TileRef;
|
||||
}
|
||||
|
||||
private applyMotionPlanRecords(records: readonly MotionPlanRecord[]): void {
|
||||
for (const record of records) {
|
||||
switch (record.kind) {
|
||||
case "reset_all":
|
||||
this.unitMotionPlans.clear();
|
||||
break;
|
||||
case "clear": {
|
||||
const existing = this.unitMotionPlans.get(record.unitId);
|
||||
if (!existing) {
|
||||
break;
|
||||
}
|
||||
if (record.planId === 0 || existing.planId === record.planId) {
|
||||
this.unitMotionPlans.delete(record.unitId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "grid": {
|
||||
if (record.ticksPerStep < 1 || record.path.length < 1) {
|
||||
break;
|
||||
}
|
||||
const existing = this.unitMotionPlans.get(record.unitId);
|
||||
if (existing && record.planId <= existing.planId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const path =
|
||||
(record.path as unknown) instanceof Uint32Array
|
||||
? (record.path as unknown as Uint32Array)
|
||||
: Uint32Array.from(record.path as readonly number[]);
|
||||
|
||||
this.unitMotionPlans.set(record.unitId, {
|
||||
planId: record.planId,
|
||||
startTick: record.startTick,
|
||||
ticksPerStep: record.ticksPerStep,
|
||||
path,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "parabola": {
|
||||
const existing = this.unitMotionPlans.get(record.unitId);
|
||||
if (existing && record.planId <= existing.planId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const pf = UniversalPathFinding.Parabola(this._map, {
|
||||
increment: record.increment,
|
||||
distanceBasedHeight: record.distanceBasedHeight,
|
||||
directionUp: record.directionUp,
|
||||
});
|
||||
|
||||
const tiles: number[] = [record.src];
|
||||
for (let i = 0; i < 20000; i++) {
|
||||
const step = pf.next(record.src, record.dst, record.increment);
|
||||
if (step.status === PathStatus.NEXT) {
|
||||
tiles.push(step.node);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
this.unitMotionPlans.set(record.unitId, {
|
||||
planId: record.planId,
|
||||
startTick: record.startTick,
|
||||
ticksPerStep: 1,
|
||||
path: Uint32Array.from(tiles),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recentlyUpdatedTiles(): TileRef[] {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { TileRef } from "./GameMap";
|
||||
|
||||
export const MOTION_PLANS_SCHEMA_VERSION = 1;
|
||||
|
||||
export enum PackedMotionPlanKind {
|
||||
GridPathSet = 1,
|
||||
ParabolaSet = 2,
|
||||
ClearUnitPlan = 3,
|
||||
ResetAllPlans = 4,
|
||||
}
|
||||
|
||||
export interface GridPathPlan {
|
||||
kind: "grid";
|
||||
unitId: number;
|
||||
planId: number;
|
||||
startTick: number;
|
||||
ticksPerStep: number;
|
||||
/**
|
||||
* TileRef path where `path[0]` is the unit tile at `startTick`.
|
||||
*/
|
||||
path: readonly TileRef[];
|
||||
flags?: number;
|
||||
}
|
||||
|
||||
export interface ParabolaPlan {
|
||||
kind: "parabola";
|
||||
unitId: number;
|
||||
planId: number;
|
||||
startTick: number;
|
||||
src: TileRef;
|
||||
dst: TileRef;
|
||||
increment: number;
|
||||
distanceBasedHeight: boolean;
|
||||
directionUp: boolean;
|
||||
}
|
||||
|
||||
export interface ClearUnitPlanRecord {
|
||||
kind: "clear";
|
||||
unitId: number;
|
||||
/**
|
||||
* Clear only if the current planId matches. `0` means clear unconditionally.
|
||||
*/
|
||||
planId: number;
|
||||
}
|
||||
|
||||
export interface ResetAllPlansRecord {
|
||||
kind: "reset_all";
|
||||
}
|
||||
|
||||
export type MotionPlanRecord =
|
||||
| GridPathPlan
|
||||
| ParabolaPlan
|
||||
| ClearUnitPlanRecord
|
||||
| ResetAllPlansRecord;
|
||||
|
||||
export function packMotionPlans(records: readonly MotionPlanRecord[]): Uint32Array {
|
||||
const out: number[] = [MOTION_PLANS_SCHEMA_VERSION, records.length];
|
||||
|
||||
for (const record of records) {
|
||||
switch (record.kind) {
|
||||
case "grid": {
|
||||
const flags = record.flags ?? 0;
|
||||
const pathLen = record.path.length >>> 0;
|
||||
const wordCount = 2 + 6 + pathLen;
|
||||
out.push(
|
||||
PackedMotionPlanKind.GridPathSet,
|
||||
wordCount,
|
||||
record.unitId >>> 0,
|
||||
record.planId >>> 0,
|
||||
record.startTick >>> 0,
|
||||
record.ticksPerStep >>> 0,
|
||||
flags >>> 0,
|
||||
pathLen,
|
||||
);
|
||||
for (let i = 0; i < record.path.length; i++) {
|
||||
out.push(record.path[i] >>> 0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "parabola": {
|
||||
const flags =
|
||||
(record.distanceBasedHeight ? 1 : 0) |
|
||||
(record.directionUp ? 2 : 0);
|
||||
const wordCount = 2 + 7;
|
||||
out.push(
|
||||
PackedMotionPlanKind.ParabolaSet,
|
||||
wordCount,
|
||||
record.unitId >>> 0,
|
||||
record.planId >>> 0,
|
||||
record.startTick >>> 0,
|
||||
record.src >>> 0,
|
||||
record.dst >>> 0,
|
||||
record.increment >>> 0,
|
||||
flags >>> 0,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "clear": {
|
||||
const wordCount = 2 + 2;
|
||||
out.push(
|
||||
PackedMotionPlanKind.ClearUnitPlan,
|
||||
wordCount,
|
||||
record.unitId >>> 0,
|
||||
record.planId >>> 0,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "reset_all": {
|
||||
out.push(PackedMotionPlanKind.ResetAllPlans, 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Uint32Array(out);
|
||||
}
|
||||
|
||||
export function unpackMotionPlans(
|
||||
packed: Uint32Array,
|
||||
): { schemaVersion: number; records: MotionPlanRecord[] } {
|
||||
if (packed.length < 2) {
|
||||
return { schemaVersion: 0, records: [] };
|
||||
}
|
||||
|
||||
const schemaVersion = packed[0] >>> 0;
|
||||
const recordCount = packed[1] >>> 0;
|
||||
|
||||
const records: MotionPlanRecord[] = [];
|
||||
let offset = 2;
|
||||
|
||||
for (let i = 0; i < recordCount && offset + 1 < packed.length; i++) {
|
||||
const kind = packed[offset] >>> 0;
|
||||
const wordCount = packed[offset + 1] >>> 0;
|
||||
|
||||
if (wordCount < 2 || offset + wordCount > packed.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case PackedMotionPlanKind.GridPathSet: {
|
||||
if (wordCount < 2 + 6) {
|
||||
break;
|
||||
}
|
||||
const unitId = packed[offset + 2] >>> 0;
|
||||
const planId = packed[offset + 3] >>> 0;
|
||||
const startTick = packed[offset + 4] >>> 0;
|
||||
const ticksPerStep = packed[offset + 5] >>> 0;
|
||||
const flags = packed[offset + 6] >>> 0;
|
||||
const pathLen = packed[offset + 7] >>> 0;
|
||||
|
||||
const expectedWordCount = 2 + 6 + pathLen;
|
||||
if (expectedWordCount !== wordCount) {
|
||||
break;
|
||||
}
|
||||
|
||||
const pathStart = offset + 8;
|
||||
const pathEnd = pathStart + pathLen;
|
||||
const path = packed.slice(pathStart, pathEnd) as unknown as TileRef[];
|
||||
|
||||
records.push({
|
||||
kind: "grid",
|
||||
unitId,
|
||||
planId,
|
||||
startTick,
|
||||
ticksPerStep,
|
||||
flags,
|
||||
path,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case PackedMotionPlanKind.ParabolaSet: {
|
||||
if (wordCount !== 2 + 7) {
|
||||
break;
|
||||
}
|
||||
const unitId = packed[offset + 2] >>> 0;
|
||||
const planId = packed[offset + 3] >>> 0;
|
||||
const startTick = packed[offset + 4] >>> 0;
|
||||
const src = packed[offset + 5] as TileRef;
|
||||
const dst = packed[offset + 6] as TileRef;
|
||||
const increment = packed[offset + 7] >>> 0;
|
||||
const flags = packed[offset + 8] >>> 0;
|
||||
|
||||
records.push({
|
||||
kind: "parabola",
|
||||
unitId,
|
||||
planId,
|
||||
startTick,
|
||||
src,
|
||||
dst,
|
||||
increment,
|
||||
distanceBasedHeight: (flags & 1) !== 0,
|
||||
directionUp: (flags & 2) !== 0,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case PackedMotionPlanKind.ClearUnitPlan: {
|
||||
if (wordCount !== 2 + 2) {
|
||||
break;
|
||||
}
|
||||
const unitId = packed[offset + 2] >>> 0;
|
||||
const planId = packed[offset + 3] >>> 0;
|
||||
records.push({ kind: "clear", unitId, planId });
|
||||
break;
|
||||
}
|
||||
case PackedMotionPlanKind.ResetAllPlans: {
|
||||
if (wordCount !== 2) {
|
||||
break;
|
||||
}
|
||||
records.push({ kind: "reset_all" });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Unknown kind: skip.
|
||||
break;
|
||||
}
|
||||
|
||||
offset += wordCount;
|
||||
}
|
||||
|
||||
return { schemaVersion, records };
|
||||
}
|
||||
|
||||
@@ -160,7 +160,9 @@ export class UnitImpl implements Unit {
|
||||
this._lastTile = this._tile;
|
||||
this._tile = tile;
|
||||
this.mg.updateUnitTile(this);
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
if (!this.mg.isUnitPlanDriven(this._id)) {
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
setTroops(troops: number): void {
|
||||
@@ -336,7 +338,10 @@ export class UnitImpl implements Unit {
|
||||
if (this.type() !== UnitType.TransportShip) {
|
||||
throw new Error(`Cannot retreat ${this.type()}`);
|
||||
}
|
||||
this._retreating = true;
|
||||
if (!this._retreating) {
|
||||
this._retreating = true;
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
isUnderConstruction(): boolean {
|
||||
@@ -402,7 +407,12 @@ export class UnitImpl implements Unit {
|
||||
}
|
||||
|
||||
setTargetUnit(target: Unit | undefined): void {
|
||||
this._targetUnit = target;
|
||||
if (this._targetUnit !== target) {
|
||||
this._targetUnit = target;
|
||||
if (this.mg.isUnitPlanDriven(this._id)) {
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targetUnit(): Unit | undefined {
|
||||
|
||||
@@ -33,7 +33,13 @@ function sendMessage(message: WorkerMessage) {
|
||||
if (message.type === "game_update") {
|
||||
// Transfer the packed tile updates buffer to avoid structured-clone copies and
|
||||
// reduce worker-side memory churn during long runs / catch-up.
|
||||
ctx.postMessage(message, [message.gameUpdate.packedTileUpdates.buffer]);
|
||||
const transfers: Transferable[] = [
|
||||
message.gameUpdate.packedTileUpdates.buffer,
|
||||
];
|
||||
if (message.gameUpdate.packedMotionPlans) {
|
||||
transfers.push(message.gameUpdate.packedMotionPlans.buffer);
|
||||
}
|
||||
ctx.postMessage(message, transfers);
|
||||
return;
|
||||
}
|
||||
ctx.postMessage(message);
|
||||
|
||||
Reference in New Issue
Block a user