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.
This commit is contained in:
evanpelle
2026-05-17 19:13:29 -07:00
parent b8d72d3a4e
commit 4dc4810bcc
2 changed files with 57 additions and 12 deletions
+18 -10
View File
@@ -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(
@@ -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);
}