diff --git a/apps/site/src/tools/blueprint-editor/index.ts b/apps/site/src/tools/blueprint-editor/index.ts index 4c5f780..e38f104 100644 --- a/apps/site/src/tools/blueprint-editor/index.ts +++ b/apps/site/src/tools/blueprint-editor/index.ts @@ -280,6 +280,7 @@ const TEMPLATE = `
@@ -1214,6 +1215,7 @@ export function mountBlueprintEditor(root: HTMLElement): () => void { const iconName = paletteSelection ?? heldModule?.name; label.replaceChildren(...(iconName ? [icon(iconName, "", 24)] : []), document.createTextNode(what)); $("#touch-hand-rotate").hidden = !(paletteSelection || pasteArmed); + $("#touch-hand-place").hidden = !(paletteSelection || pasteArmed); } const moreOn: Record = { alt: altModeOn, copper: wireColorInHand === "copper", red: wireColorInHand === "red", green: wireColorInHand === "green" }; for (const item of root.querySelectorAll("[data-touch-more]")) { @@ -3945,6 +3947,7 @@ export function mountBlueprintEditor(root: HTMLElement): () => void { if (touchMore.contains(target) || root.querySelector('[data-touch-tool="more"]')!.contains(target)) return; setTouchMoreOpen(false); }, { capture: true, signal }); + $("#touch-hand-place").addEventListener("click", () => renderer.placeHeld(), { signal }); $("#touch-hand-rotate").addEventListener("click", () => { if (paletteSelection) renderer.rotateGhost(); else if (pasteArmed) renderer.rotatePasteGhost(); diff --git a/packages/renderer/src/render.ts b/packages/renderer/src/render.ts index 45c8dec..6e2a60d 100644 --- a/packages/renderer/src/render.ts +++ b/packages/renderer/src/render.ts @@ -302,6 +302,11 @@ export interface BlueprintRenderer { * effect, which is harmless. Free rotation has no per-entity * restrictions (flip, separately, is meant to). */ rotatePasteGhost(reverse?: boolean): void; + /** Puts the held item (or stamps the held paste) down at the ghost, for + * touch screens where the finger only moves the ghost: a "Place" button + * calls this. Falls back to the middle of the view when no touch has + * positioned the ghost yet. A no-op when nothing is held. */ + placeHeld(): void; hitTest(clientX: number, clientY: number): number | undefined; /** Fires on pointermove while not panning, with the entity under the * cursor (or undefined). */ @@ -2310,7 +2315,7 @@ export function mountRenderer(container: HTMLElement, data: GameData, catalog: R * stamps again, matching the real game's own "keep the item in hand * until you put it away" convention. Modifier keys read at commit time * (not copy time) decide how the app should resolve any collision. */ - function placePasteGhost(e: PointerEvent): void { + function placePasteGhost(e: { shiftKey: boolean; altKey: boolean }): void { if (mode.kind !== "paste" || !ghostWorldPos) return; const snappedAnchor = snapPasteAnchor(ghostWorldPos); // Relative snapping: the first stamp fixes the grid later ones line up with. @@ -2332,6 +2337,25 @@ export function mountRenderer(container: HTMLElement, data: GameData, catalog: R }; } + const DOUBLE_TAP_MS = 350; + const DOUBLE_TAP_DISTANCE = 30; // screen px + let lastTouchTap: { time: number; x: number; y: number } | undefined; + + function placeHeld(): void { + if (mode.kind !== "place" && mode.kind !== "paste") return; + if (!ghostWorldPos) { + const rect = canvas.getBoundingClientRect(); + ghostWorldPos = camera.screenToWorld(rect.width / 2, rect.height / 2, rect.width, rect.height); + } + if (mode.kind === "paste") { + placePasteGhost({ shiftKey: false, altKey: false }); + } else { + placedThisGesture = new Set(); + placeAtGhost(); + } + invalidate(); + } + function onPointerDown(e: PointerEvent): void { // Stops the browser's own left-click text-selection drag from starting // alongside pointer-capture panning/placing/opening — confirmed by @@ -2402,6 +2426,26 @@ export function mountRenderer(container: HTMLElement, data: GameData, catalog: R // the last touch left it: move it under this one before acting on it. if (mode.kind === "place" || mode.kind === "paste") ghostWorldPos = worldAtPointer(e); + // On a touch screen a finger only positions the held item — it is + // dropped by a double tap or the app's Place button (placeHeld), so a + // building can be lined up under the finger without going down early. + // Track is the exception: it is laid by dragging, which has no ghost + // to position first. + if ( + e.pointerType === "touch" && + (mode.kind === "paste" || (mode.kind === "place" && !isRailPlannerItem(mode.entityName))) + ) { + const now = performance.now(); + const doubleTap = + lastTouchTap !== undefined && + now - lastTouchTap.time < DOUBLE_TAP_MS && + Math.hypot(e.clientX - lastTouchTap.x, e.clientY - lastTouchTap.y) < DOUBLE_TAP_DISTANCE; + lastTouchTap = doubleTap ? undefined : { time: now, x: e.clientX, y: e.clientY }; + if (doubleTap) placeHeld(); + invalidate(); + return; + } + if (mode.kind === "place") { isPlacingDrag = true; placedThisGesture = new Set(); @@ -2982,6 +3026,7 @@ export function mountRenderer(container: HTMLElement, data: GameData, catalog: R mode = { ...mode, groupRotation: (((mode.groupRotation + delta) % 16) + 16) % 16 as 0 | 4 | 8 | 12 }; invalidate(); }, + placeHeld, hitTest(clientX, clientY) { const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top, rect.width, rect.height);