From 4dc4810bcca70de28e9bfc0cce1a10b506ef00fd Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sun, 17 May 2026 19:13:29 -0700 Subject: [PATCH] render build ghost at cursor with sub-tile precision Bypass the snap-to-tile in TransformHandler by adding screenToWorldCoordinatesFloat. Each render frame, BuildPreviewController re-emits the ghost preview at the cursor's exact world position (adjusted by -0.5 to cancel the shader's tile-center offset). Buildable validation still runs on the snapped tile at the 50ms throttle, but the icon now follows the cursor 1:1 instead of stepping tile-to-tile. --- src/client/TransformHandler.ts | 28 ++++++++----- .../controllers/BuildPreviewController.ts | 41 ++++++++++++++++++- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/client/TransformHandler.ts b/src/client/TransformHandler.ts index c1175b788..abab40838 100644 --- a/src/client/TransformHandler.ts +++ b/src/client/TransformHandler.ts @@ -115,17 +115,25 @@ export class TransformHandler { } screenToWorldCoordinates(screenX: number, screenY: number): Cell { + const f = this.screenToWorldCoordinatesFloat(screenX, screenY); + return new Cell(Math.floor(f.x), Math.floor(f.y)); + } + + /** Like screenToWorldCoordinates but returns sub-tile precision. */ + screenToWorldCoordinatesFloat( + screenX: number, + screenY: number, + ): { x: number; y: number } { const canvasCoords = this.screenToCanvasCoordinates(screenX, screenY); - - const centerX = - (canvasCoords.x - this.game.width() / 2) / this.scale + this.offsetX; - const centerY = - (canvasCoords.y - this.game.height() / 2) / this.scale + this.offsetY; - - const gameX = centerX + this.game.width() / 2; - const gameY = centerY + this.game.height() / 2; - - return new Cell(Math.floor(gameX), Math.floor(gameY)); + const gameX = + (canvasCoords.x - this.game.width() / 2) / this.scale + + this.offsetX + + this.game.width() / 2; + const gameY = + (canvasCoords.y - this.game.height() / 2) / this.scale + + this.offsetY + + this.game.height() / 2; + return { x: gameX, y: gameY }; } canvasToScreenCoordinates( diff --git a/src/client/controllers/BuildPreviewController.ts b/src/client/controllers/BuildPreviewController.ts index 21a9398a1..c81443ee1 100644 --- a/src/client/controllers/BuildPreviewController.ts +++ b/src/client/controllers/BuildPreviewController.ts @@ -44,6 +44,12 @@ export class BuildPreviewController implements Controller { private lastGhostQueryAt: number = 0; private pendingConfirm: MouseUpEvent | null = null; + // Buildable validation runs on the snapped tile under the cursor, but the + // rendered icon follows the cursor at sub-tile precision so motion is + // continuous instead of stepping tile-to-tile. cursorLoop re-emits each + // frame with the current cursor world position. + private lastGhostData: GhostPreviewData | null = null; + constructor( private game: GameView, private eventBus: EventBus, @@ -60,6 +66,29 @@ export class BuildPreviewController implements Controller { new MouseUpEvent(this.mousePos.x, this.mousePos.y), ), ); + + // Re-emit the ghost each render frame at the cursor's current world + // position (sub-tile). Buildable validation still runs on the snapped + // tile in renderGhost(); this loop just keeps the icon under the cursor + // so motion is continuous instead of stepping tile-to-tile. + // The shader treats (tileX + 0.5, tileY + 0.5) as the icon center (so an + // integer tile coord centers on that tile), so we subtract 0.5 here to + // place the icon exactly under the cursor. + const cursorLoop = () => { + if (this.lastGhostData !== null) { + const w = this.transformHandler.screenToWorldCoordinatesFloat( + this.mousePos.x, + this.mousePos.y, + ); + this.view.updateGhostPreview({ + ...this.lastGhostData, + tileX: w.x - 0.5, + tileY: w.y - 0.5, + }); + } + requestAnimationFrame(cursorLoop); + }; + requestAnimationFrame(cursorLoop); } tick() { @@ -185,10 +214,17 @@ export class BuildPreviewController implements Controller { /** * Push a GhostPreviewData snapshot to the WebGL view (StructurePass / * RangeCirclePass / RailroadPass / CrosshairPass all read it). null when - * the ghost can't be placed. + * the ghost can't be placed. smoothLoop interpolates displayed position + * toward the target tile each frame. */ private emitGhostPreview(tileRef: TileRef | undefined): void { - this.view.updateGhostPreview(this.buildGhostPreviewData(tileRef)); + const data = this.buildGhostPreviewData(tileRef); + if (data === null) { + this.lastGhostData = null; + this.view.updateGhostPreview(null); + return; + } + this.lastGhostData = data; } private buildGhostPreviewData( @@ -307,6 +343,7 @@ export class BuildPreviewController implements Controller { this.pendingConfirm = null; this.ghostUnit = null; this.uiState.ghostRailPaths = []; + this.lastGhostData = null; this.view.updateGhostPreview(null); }