diff --git a/src/client/graphics/layers/RendererStatusPanel.ts b/src/client/graphics/layers/RendererStatusPanel.ts index 63fb077f0..437b56a32 100644 --- a/src/client/graphics/layers/RendererStatusPanel.ts +++ b/src/client/graphics/layers/RendererStatusPanel.ts @@ -89,6 +89,31 @@ export class RendererStatusPanel extends LitElement implements Layer { border-bottom: 1px solid rgba(255, 255, 255, 0.1); } + .titleActions { + display: flex; + align-items: center; + gap: 6px; + } + + button { + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 6px; + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.9); + padding: 4px 7px; + font: inherit; + cursor: pointer; + } + + button:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.16); + } + + button:disabled { + opacity: 0.45; + cursor: default; + } + .panel.dragging .title { cursor: grabbing; } @@ -204,6 +229,32 @@ export class RendererStatusPanel extends LitElement implements Layer { this.preference = this.userSettings.territoryRenderer(); } + private rendererSettingsTarget(): "webgl" | "webgpu" | null { + if (this.activeRenderer === "webgl" || this.activeRenderer === "webgpu") { + return this.activeRenderer; + } + if ( + this.activeRenderer === null && + (this.preference === "webgl" || this.preference === "webgpu") + ) { + return this.preference; + } + return null; + } + + private openRendererSettings(event: Event) { + event.preventDefault(); + event.stopPropagation(); + const renderer = this.rendererSettingsTarget(); + if (renderer === "webgl") { + this.userSettings.setWebgpuDebug(false); + this.userSettings.setWebglDebug(true); + } else if (renderer === "webgpu") { + this.userSettings.setWebglDebug(false); + this.userSettings.setWebgpuDebug(true); + } + } + private rendererLabel(id: TerritoryRendererId | TerritoryRendererPreference) { if (id === "webgpu") return "WebGPU"; if (id === "webgl") return "WebGL"; @@ -341,6 +392,7 @@ export class RendererStatusPanel extends LitElement implements Layer { } const note = this.statusNote(); + const canOpenSettings = this.rendererSettingsTarget() !== null; return html`
Renderer +
+ +
diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 060014ecf..48e227967 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -60,28 +60,22 @@ const TRANSPORT_SHIP_MASK = [ ".BTB.", "..B..", ] as const; -const TRADE_SHIP_MASK = [ - "..T..", - ".TBT.", - "TBBBT", - ".TBT.", - "..T..", -] as const; +const TRADE_SHIP_MASK = ["..T..", ".TBT.", "TBBBT", ".TBT.", "..T.."] as const; -type TransportTrailState = { +type MotionTrailState = { activePlanId: number; - epochs: TransportTrailEpoch[]; + epochs: MotionTrailEpoch[]; lastOnScreen: boolean; }; -type TransportTrailEpoch = SegmentTrailPlanView & { +type MotionTrailEpoch = SegmentTrailPlanView & { planId: number; targetStep: number; drawnStep: number; sealed: boolean; }; -type ActiveTransportTrailPlan = { +type ActiveMotionTrailPlan = { unitId: number; unit: UnitView; plan: SegmentTrailPlanView & { planId: number }; @@ -134,7 +128,7 @@ export class UnitLayer implements Layer { private gridMoverUnitIds = new Set(); - private transportShipTrails = new Map(); + private segmentTrails = new Map(); private trailDirty = false; private moverState = new Map(); @@ -201,7 +195,7 @@ export class UnitLayer implements Layer { tick() { const trailPrune = pruneInactiveTrails( this.unitToTrail, - this.transportShipTrails, + this.segmentTrails, (unitId) => { const current = this.game.unit(unitId); return !!current && current.isActive(); @@ -482,13 +476,13 @@ export class UnitLayer implements Layer { const tickFloat = this.game.ticks() + tickAlpha; const viewBounds = this.currentViewBounds(); const activeMoverIds = new Set(); - const activeTransportTrailPlans: ActiveTransportTrailPlan[] = []; + const activeMotionTrailPlans: ActiveMotionTrailPlan[] = []; for (const [unitId, plan] of this.game.motionPlans()) { const unit = this.game.unit(unitId); if (!unit || !unit.isActive()) { this.clearMoverState(unitId); - if (this.transportShipTrails.delete(unitId)) this.trailDirty = true; + if (this.segmentTrails.delete(unitId)) this.trailDirty = true; continue; } activeMoverIds.add(unitId); @@ -500,8 +494,8 @@ export class UnitLayer implements Layer { tickFloat, viewBounds, ); - if (unit.type() === UnitType.TransportShip) { - activeTransportTrailPlans.push({ + if (this.shouldDrawSegmentTrail(unit)) { + activeMotionTrailPlans.push({ unitId, unit, plan, @@ -530,10 +524,7 @@ export class UnitLayer implements Layer { viewBounds, ); - this.advanceAndDrawTransportTrails( - this.game.ticks(), - activeTransportTrailPlans, - ); + this.advanceAndDrawSegmentTrails(this.game.ticks(), activeMotionTrailPlans); this.rebuildTrailCanvasIfDirty(); context.drawImage( @@ -960,7 +951,9 @@ export class UnitLayer implements Layer { continue; } - const candidateRects: MoverSpriteRect[] = [candidateState.lastSpriteRect]; + const candidateRects: MoverSpriteRect[] = [ + candidateState.lastSpriteRect, + ]; const candidateSample = this.getConflictSample( candidateId, tickFloat, @@ -998,7 +991,13 @@ export class UnitLayer implements Layer { return null; } - return this.getMoverSample(unitId, unit, plan.planId, tickFloat, sampledCache); + return this.getMoverSample( + unitId, + unit, + plan.planId, + tickFloat, + sampledCache, + ); } private anyRectsOverlap( @@ -1227,10 +1226,7 @@ export class UnitLayer implements Layer { private rectsOverlap(a: MoverSpriteRect, b: MoverSpriteRect): boolean { return ( - a.x < b.x + b.w && - a.x + a.w > b.x && - a.y < b.y + b.h && - a.y + a.h > b.y + a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y ); } @@ -1304,7 +1300,8 @@ export class UnitLayer implements Layer { while ( idx > 0 && - zoom < DYNAMIC_MOVER_ZOOM_THRESHOLDS[idx - 1] - DYNAMIC_MOVER_ZOOM_HYSTERESIS + zoom < + DYNAMIC_MOVER_ZOOM_THRESHOLDS[idx - 1] - DYNAMIC_MOVER_ZOOM_HYSTERESIS ) { idx--; } @@ -1344,7 +1341,8 @@ export class UnitLayer implements Layer { this.lastDynamicMoverCanvasRescaleMs = this.rebuildDynamicMoverCanvas(targetScale); - this.totalDynamicMoverCanvasRescaleMs += this.lastDynamicMoverCanvasRescaleMs; + this.totalDynamicMoverCanvasRescaleMs += + this.lastDynamicMoverCanvasRescaleMs; this.dynamicMoverCanvasRescaleCount++; this.dynamicMoverCanvasScale = targetScale; this.lastDynamicMoverCanvasScaleChangeAtMs = nowMs; @@ -1371,8 +1369,14 @@ export class UnitLayer implements Layer { const oldHeight = oldCanvas.height; this.dynamicMoverCanvas = document.createElement("canvas"); - this.dynamicMoverCanvas.width = Math.max(1, this.game.width() * targetScale); - this.dynamicMoverCanvas.height = Math.max(1, this.game.height() * targetScale); + this.dynamicMoverCanvas.width = Math.max( + 1, + this.game.width() * targetScale, + ); + this.dynamicMoverCanvas.height = Math.max( + 1, + this.game.height() * targetScale, + ); const dynamicMoverContext = this.dynamicMoverCanvas.getContext("2d"); if (dynamicMoverContext === null) { throw new Error("2d context not supported"); @@ -1681,35 +1685,35 @@ export class UnitLayer implements Layer { this.dynamicMoverContext.clearRect(rect.x, rect.y, rect.w, rect.h); } - private advanceAndDrawTransportTrails( + private advanceAndDrawSegmentTrails( currentTick: number, - activePlans: readonly ActiveTransportTrailPlan[], + activePlans: readonly ActiveMotionTrailPlan[], ): void { for (const { unitId, unit, plan, maybeOnScreen } of activePlans) { - const state = this.ensureTransportTrailState(unitId, plan, currentTick); + const state = this.ensureSegmentTrailState(unitId, plan, currentTick); const moverState = this.moverState.get(unitId); const onScreen = moverState ? moverState.bucket === "on" : maybeOnScreen; if (onScreen) { - this.drawPendingTransportTrailEpochs(unit, state); + this.drawPendingSegmentTrailEpochs(unit, state); } state.lastOnScreen = onScreen; } } - private ensureTransportTrailState( + private ensureSegmentTrailState( unitId: number, plan: SegmentTrailPlanView & { planId: number }, currentTick: number, - ): TransportTrailState { - let state = this.transportShipTrails.get(unitId); + ): MotionTrailState { + let state = this.segmentTrails.get(unitId); if (!state) { state = { activePlanId: plan.planId, epochs: [], lastOnScreen: false, }; - this.transportShipTrails.set(unitId, state); + this.segmentTrails.set(unitId, state); } let activeEpoch = state.epochs[state.epochs.length - 1]; @@ -1726,7 +1730,7 @@ export class UnitLayer implements Layer { activeEpoch.sealed = true; } - activeEpoch = this.createTransportTrailEpoch(plan, currentTick); + activeEpoch = this.createSegmentTrailEpoch(plan, currentTick); state.epochs.push(activeEpoch); state.activePlanId = plan.planId; return state; @@ -1741,10 +1745,10 @@ export class UnitLayer implements Layer { return state; } - private createTransportTrailEpoch( + private createSegmentTrailEpoch( plan: SegmentTrailPlanView & { planId: number }, currentTick: number, - ): TransportTrailEpoch { + ): MotionTrailEpoch { return { planId: plan.planId, startTick: plan.startTick, @@ -1758,9 +1762,9 @@ export class UnitLayer implements Layer { }; } - private drawPendingTransportTrailEpochs( + private drawPendingSegmentTrailEpochs( unit: UnitView, - state: TransportTrailState, + state: MotionTrailState, ): void { const ctx = this.trailContext; const strokeStyle = this.motionTrailColor(unit); @@ -1816,7 +1820,7 @@ export class UnitLayer implements Layer { } } - for (const [unitId, trailState] of this.transportShipTrails) { + for (const [unitId, trailState] of this.segmentTrails) { const unit = this.game.unit(unitId); if (!unit || !unit.isActive()) { continue; @@ -1837,6 +1841,17 @@ export class UnitLayer implements Layer { } } + private shouldDrawSegmentTrail(unit: UnitView): boolean { + const type = unit.type(); + return ( + type === UnitType.TransportShip || + type === UnitType.AtomBomb || + type === UnitType.HydrogenBomb || + type === UnitType.MIRV || + type === UnitType.MIRVWarhead + ); + } + private relationshipForAlternateView(unit: UnitView): Relationship { let rel = this.relationship(unit); const dstPortId = unit.targetUnitId(); diff --git a/src/client/graphics/layers/WebGLTerritoryBackend.ts b/src/client/graphics/layers/WebGLTerritoryBackend.ts index d714566d6..4259d32b3 100644 --- a/src/client/graphics/layers/WebGLTerritoryBackend.ts +++ b/src/client/graphics/layers/WebGLTerritoryBackend.ts @@ -467,10 +467,41 @@ export class WebGLTerritoryBackend implements TerritoryBackend { root.style.touchAction = "none"; const title = document.createElement("div"); - title.textContent = "Territory smoothing"; title.style.fontWeight = "700"; title.style.marginBottom = "6px"; title.style.cursor = "move"; + title.style.display = "flex"; + title.style.alignItems = "center"; + title.style.justifyContent = "space-between"; + title.style.gap = "10px"; + + const titleText = document.createElement("span"); + titleText.textContent = "Territory smoothing"; + title.appendChild(titleText); + + const closeButton = document.createElement("button"); + closeButton.type = "button"; + closeButton.textContent = "x"; + closeButton.title = "Close"; + closeButton.style.width = "22px"; + closeButton.style.height = "22px"; + closeButton.style.border = "1px solid rgba(255,255,255,0.18)"; + closeButton.style.borderRadius = "5px"; + closeButton.style.background = "rgba(255,255,255,0.08)"; + closeButton.style.color = "rgba(255,255,255,0.88)"; + closeButton.style.font = "12px monospace"; + closeButton.style.lineHeight = "1"; + closeButton.style.cursor = "pointer"; + closeButton.addEventListener("pointerdown", (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + closeButton.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + this.userSettings.setWebglDebug(false); + }); + title.appendChild(closeButton); root.appendChild(title); // Restore last position (if any) diff --git a/src/client/graphics/layers/WebGPUDebugOverlay.ts b/src/client/graphics/layers/WebGPUDebugOverlay.ts index 961cd61ce..97c7120f0 100644 --- a/src/client/graphics/layers/WebGPUDebugOverlay.ts +++ b/src/client/graphics/layers/WebGPUDebugOverlay.ts @@ -106,6 +106,22 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { cursor: grabbing; } + .closeButton { + width: 22px; + height: 22px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 5px; + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.88); + font: inherit; + line-height: 1; + cursor: pointer; + } + + .closeButton:hover { + background: rgba(255, 255, 255, 0.16); + } + .metrics { display: grid; grid-template-columns: 1fr 1fr; @@ -228,7 +244,7 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { } private selectedShaderId() { - const selected = this.userSettings.getInt(TERRITORY_SHADER_KEY, 0); + const selected = this.userSettings.getInt(TERRITORY_SHADER_KEY, 1); return territoryShaderIdFromInt(selected); } @@ -241,7 +257,7 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { } private selectedTerrainShaderId() { - const selected = this.userSettings.getInt(TERRAIN_SHADER_KEY, 0); + const selected = this.userSettings.getInt(TERRAIN_SHADER_KEY, 2); return terrainShaderIdFromInt(selected); } @@ -266,7 +282,7 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { } private selectedPostSmoothingId() { - const selected = this.userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 0); + const selected = this.userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 1); return territoryPostSmoothingIdFromInt(selected); } @@ -403,6 +419,12 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { event.stopPropagation(); } + private closeOverlay(event: Event) { + event.preventDefault(); + event.stopPropagation(); + this.userSettings.setWebgpuDebug(false); + } + private handleDragPointerDown(event: PointerEvent) { event.preventDefault(); event.stopPropagation(); @@ -486,6 +508,15 @@ export class WebGPUDebugOverlay extends LitElement implements Layer { >
WebGPU Debug
+
diff --git a/src/client/graphics/layers/WebGPUTerritoryBackend.ts b/src/client/graphics/layers/WebGPUTerritoryBackend.ts index 88d7b48de..9ca781e7f 100644 --- a/src/client/graphics/layers/WebGPUTerritoryBackend.ts +++ b/src/client/graphics/layers/WebGPUTerritoryBackend.ts @@ -121,6 +121,11 @@ export class WebGPUTerritoryBackend implements TerritoryBackend { this.markTile(updatedTiles[i]); } + const updatedTerrainTiles = this.game.recentlyUpdatedTerrainTiles(); + if (updatedTerrainTiles.length > 0) { + this.territoryRenderer?.updateTerrainDataTiles(updatedTerrainTiles); + } + // After collecting pending updates and handling palette/theme changes, // invoke the renderer's tick() to process compute passes. This ensures // compute shaders run at the simulation rate rather than every frame. diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts index 5e6d0d0bb..d37f84299 100644 --- a/src/client/graphics/webgpu/TerritoryRenderer.ts +++ b/src/client/graphics/webgpu/TerritoryRenderer.ts @@ -470,6 +470,16 @@ export class TerritoryRenderer { } } + updateTerrainDataTiles(tiles: readonly number[]): void { + if (!this.resources || !this.device || tiles.length === 0) { + return; + } + this.resources.uploadTerrainDataTiles(tiles); + if (this.terrainComputePass) { + this.terrainComputePass.markDirty(); + } + } + /** * Immediately execute terrain compute pass (for theme changes). * This ensures terrain is recomputed before the next render. diff --git a/src/client/graphics/webgpu/core/GroundTruthData.ts b/src/client/graphics/webgpu/core/GroundTruthData.ts index 00daa40dc..d7aad9a71 100644 --- a/src/client/graphics/webgpu/core/GroundTruthData.ts +++ b/src/client/graphics/webgpu/core/GroundTruthData.ts @@ -1,5 +1,6 @@ import { Theme } from "../../../../core/configuration/Config"; import { UnitType } from "../../../../core/game/Game"; +import type { TileRef } from "../../../../core/game/GameMap"; import { GameView } from "../../../../core/game/GameView"; /** @@ -606,6 +607,53 @@ export class GroundTruthData { } } + uploadTerrainDataTiles(tiles: readonly TileRef[]): void { + if (tiles.length === 0) { + return; + } + if (this.needsTerrainDataUpload) { + this.uploadTerrainData(); + return; + } + + const rowBounds = new Map(); + for (let i = 0; i < tiles.length; i++) { + const tile = tiles[i] >>> 0; + if (tile >= this.terrainData.length) { + continue; + } + const y = Math.floor(tile / this.mapWidth); + if (y < 0 || y >= this.mapHeight) { + continue; + } + const x = tile - y * this.mapWidth; + const existing = rowBounds.get(y); + if (existing) { + existing.minX = Math.min(existing.minX, x); + existing.maxX = Math.max(existing.maxX, x); + } else { + rowBounds.set(y, { minX: x, maxX: x }); + } + } + + for (const [y, bounds] of rowBounds) { + const width = bounds.maxX - bounds.minX + 1; + const paddedBytesPerRow = align(width, 256); + const row = new Uint8Array(paddedBytesPerRow); + const start = y * this.mapWidth + bounds.minX; + row.set(this.terrainData.subarray(start, start + width), 0); + this.device.queue.writeTexture( + { + texture: this.terrainDataTexture, + origin: { x: bounds.minX, y }, + }, + row, + { bytesPerRow: paddedBytesPerRow, rowsPerImage: 1 }, + { width, height: 1, depthOrArrayLayers: 1 }, + ); + } + } + uploadTerrainParams(): void { if (!this.needsTerrainParamsUpload) { return; diff --git a/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts b/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts index 9d61cc2cd..1258b4787 100644 --- a/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts +++ b/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts @@ -180,7 +180,7 @@ export function terrainShaderIntFromId(id: TerrainShaderId): number { export function readTerrainShaderId(userSettings: { getInt: (key: string, defaultValue: number) => number; }): TerrainShaderId { - return terrainShaderIdFromInt(userSettings.getInt(TERRAIN_SHADER_KEY, 0)); + return terrainShaderIdFromInt(userSettings.getInt(TERRAIN_SHADER_KEY, 2)); } export function buildTerrainShaderParams( diff --git a/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts b/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts index be5a76a8e..2c8655761 100644 --- a/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts +++ b/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts @@ -82,7 +82,7 @@ export function readTerritoryPostSmoothingId(userSettings: { getInt: (key: string, defaultValue: number) => number; }): TerritoryPostSmoothingId { return territoryPostSmoothingIdFromInt( - userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 0), + userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 1), ); } diff --git a/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts b/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts index ee78cdf1c..a32140944 100644 --- a/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts +++ b/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts @@ -189,15 +189,6 @@ export const TERRITORY_SHADERS: TerritoryShaderDefinition[] = [ max: 1, step: 0.01, }, - { - kind: "range", - key: "settings.webgpu.territory.retro.defendedThreshold", - label: "Defended Threshold", - defaultValue: 0.01, - min: 0.01, - max: 1, - step: 0.01, - }, ], }, ]; @@ -223,7 +214,7 @@ export function territoryShaderIntFromId(id: TerritoryShaderId): number { export function readTerritoryShaderId(userSettings: { getInt: (key: string, defaultValue: number) => number; }): TerritoryShaderId { - return territoryShaderIdFromInt(userSettings.getInt(TERRITORY_SHADER_KEY, 0)); + return territoryShaderIdFromInt(userSettings.getInt(TERRITORY_SHADER_KEY, 1)); } export function buildTerritoryShaderParams( @@ -280,10 +271,7 @@ export function buildTerritoryShaderParams( "settings.webgpu.territory.retro.defendedPatternStrength", 0.5, ); - const defendedThreshold = userSettings.getFloat( - "settings.webgpu.territory.retro.defendedThreshold", - 0.01, - ); + const defendedThreshold = 0.01; let flags = 0; if (colorByRelations) flags |= 1 << 0; diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 6e3e779f4..69c904de9 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -10,6 +10,7 @@ import { UnitType, } from "../game/Game"; import { TileRef } from "../game/GameMap"; +import type { MotionPlanRecord } from "../game/MotionPlans"; import { UniversalPathFinding } from "../pathfinding/PathFinder"; import { ParabolaUniversalPathFinder } from "../pathfinding/PathFinder.Parabola"; import { PathStatus } from "../pathfinding/types"; @@ -188,6 +189,18 @@ 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(); } diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 8a413e19e..816ba4e3b 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -486,6 +486,9 @@ export class GameImpl implements Game { case "grid_segments": this.planDrivenUnitIds.add(record.unitId); break; + case "parabola": + this.planDrivenUnitIds.add(record.unitId); + break; case "train": this.planDrivenUnitIds.add(record.engineUnitId); for (const unitId of record.carUnitIds) { diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 21b494036..98658dfd2 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -2,6 +2,8 @@ import { Colord, colord } from "colord"; import { base64url } from "jose"; import { Config } from "../configuration/Config"; import { ColorPalette } from "../CosmeticSchemas"; +import { UniversalPathFinding } from "../pathfinding/PathFinder"; +import { PathStatus } from "../pathfinding/types"; import { PatternDecoder } from "../PatternDecoder"; import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas"; import { createRandomName, formatPlayerDisplayName } from "../Util"; @@ -964,7 +966,7 @@ export class GameView implements GameMap { } } } else { - while (seg + 1 < segmentCount && idx >= (segCumSteps[seg + 1] >>> 0)) { + while (seg + 1 < segmentCount && idx >= segCumSteps[seg + 1] >>> 0) { seg++; } } @@ -1124,15 +1126,62 @@ export class GameView implements GameMap { } } + private setGridSegmentMotionPlan(record: { + unitId: number; + planId: number; + startTick: number; + ticksPerStep: number; + points: readonly TileRef[] | Uint32Array; + segmentSteps: readonly number[] | Uint32Array; + }): boolean { + if ( + record.ticksPerStep < 1 || + record.points.length < 1 || + record.segmentSteps.length !== Math.max(0, record.points.length - 1) + ) { + return false; + } + const existing = this.unitMotionPlans.get(record.unitId); + if (existing && record.planId <= existing.planId) { + return false; + } + + 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, + points, + segmentSteps, + segCumSteps, + lastSegIdx: 0, + }); + this.markMotionPlannedUnitIdsDirty(); + return true; + } + private applyMotionPlanRecords(records: readonly MotionPlanRecord[]): void { for (const record of records) { switch (record.kind) { case "grid_segments": { - if ( - record.ticksPerStep < 1 || - record.points.length < 1 || - record.segmentSteps.length !== Math.max(0, record.points.length - 1) - ) { + this.setGridSegmentMotionPlan(record); + break; + } + case "parabola": { + if (record.increment < 1) { break; } const existing = this.unitMotionPlans.get(record.unitId); @@ -1140,31 +1189,32 @@ export class GameView implements GameMap { break; } - 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 pf = UniversalPathFinding.Parabola(this._map, { + increment: record.increment, + distanceBasedHeight: record.distanceBasedHeight, + directionUp: record.directionUp, + }); - const segCumSteps = new Uint32Array(segmentSteps.length + 1); - for (let i = 0; i < segmentSteps.length; i++) { - segCumSteps[i + 1] = - (segCumSteps[i] + (segmentSteps[i] >>> 0)) >>> 0; + const points: TileRef[] = [record.src]; + for (let i = 0; i < 20000; i++) { + const step = pf.next(record.src, record.dst, record.increment); + if (step.status === PathStatus.NEXT) { + points.push(step.node); + continue; + } + break; } - this.unitMotionPlans.set(record.unitId, { + const segmentSteps = new Uint32Array(Math.max(0, points.length - 1)); + segmentSteps.fill(1); + this.setGridSegmentMotionPlan({ + unitId: record.unitId, planId: record.planId, startTick: record.startTick, - ticksPerStep: record.ticksPerStep, + ticksPerStep: 1, points, segmentSteps, - segCumSteps, - lastSegIdx: 0, }); - this.markMotionPlannedUnitIdsDirty(); break; } case "train": { diff --git a/src/core/game/MotionPlans.ts b/src/core/game/MotionPlans.ts index bc6e9a6a1..de9bc59e9 100644 --- a/src/core/game/MotionPlans.ts +++ b/src/core/game/MotionPlans.ts @@ -3,6 +3,7 @@ import { TileRef } from "./GameMap"; export enum PackedMotionPlanKind { TrainRailPathSet = 2, GridPathKeypointSegments = 3, + ParabolaSet = 4, } export interface GridKeypointSegmentPlan { @@ -32,7 +33,22 @@ export interface TrainRailPathPlan { path: readonly TileRef[] | Uint32Array; } -export type MotionPlanRecord = GridKeypointSegmentPlan | TrainRailPathPlan; +export interface ParabolaPlan { + kind: "parabola"; + unitId: number; + planId: number; + startTick: number; + src: TileRef; + dst: TileRef; + increment: number; + distanceBasedHeight: boolean; + directionUp: boolean; +} + +export type MotionPlanRecord = + | GridKeypointSegmentPlan + | TrainRailPathPlan + | ParabolaPlan; export function packMotionPlans( records: readonly MotionPlanRecord[], @@ -51,6 +67,10 @@ export function packMotionPlans( totalWords += 2 + 7 + carCount + pathLen; break; } + case "parabola": { + totalWords += 2 + 7; + break; + } } } @@ -115,6 +135,22 @@ export function packMotionPlans( } break; } + case "parabola": { + const flags = + (record.distanceBasedHeight ? 1 : 0) | (record.directionUp ? 2 : 0); + const wordCount = 2 + 7; + + out[offset++] = PackedMotionPlanKind.ParabolaSet; + out[offset++] = wordCount >>> 0; + out[offset++] = record.unitId >>> 0; + out[offset++] = record.planId >>> 0; + out[offset++] = record.startTick >>> 0; + out[offset++] = record.src >>> 0; + out[offset++] = record.dst >>> 0; + out[offset++] = record.increment >>> 0; + out[offset++] = flags >>> 0; + break; + } } } @@ -219,6 +255,34 @@ export function unpackMotionPlans(packed: Uint32Array): MotionPlanRecord[] { }); 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; + if (increment < 1) { + break; + } + + records.push({ + kind: "parabola", + unitId, + planId, + startTick, + src, + dst, + increment, + distanceBasedHeight: (flags & 1) !== 0, + directionUp: (flags & 2) !== 0, + }); + break; + } default: // Unknown kind: skip. break; diff --git a/tests/MotionPlansSegments.test.ts b/tests/MotionPlansSegments.test.ts index 1f6628025..f7fe42af3 100644 --- a/tests/MotionPlansSegments.test.ts +++ b/tests/MotionPlansSegments.test.ts @@ -31,6 +31,36 @@ describe("MotionPlans grid_segments", () => { expect(Array.from(r.segmentSteps)).toEqual([5, 5]); }); + it("packs/unpacks parabola records", () => { + const packed = packMotionPlans([ + { + kind: "parabola", + unitId: 44, + planId: 3, + startTick: 99, + src: 10, + dst: 20, + increment: 7, + distanceBasedHeight: true, + directionUp: false, + }, + ]); + + const records = unpackMotionPlans(packed); + expect(records).toHaveLength(1); + const r = records[0]; + expect(r.kind).toBe("parabola"); + if (r.kind !== "parabola") throw new Error("type guard"); + expect(r.unitId).toBe(44); + expect(r.planId).toBe(3); + expect(r.startTick).toBe(99); + expect(r.src).toBe(10); + expect(r.dst).toBe(20); + expect(r.increment).toBe(7); + expect(r.distanceBasedHeight).toBe(true); + expect(r.directionUp).toBe(false); + }); + it("skips unknown kinds using wordCount", () => { const gridPacked = packMotionPlans([ {