Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/site/src/tools/blueprint-editor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ const TEMPLATE = `
<div id="touch-dock" class="touch-dock">
<div id="touch-hand" class="touch-hand" role="toolbar" aria-label="In hand" hidden>
<span id="touch-hand-what" class="touch-hand-what"></span>
<button type="button" id="touch-hand-place" class="touch-hand-button touch-hand-place" aria-label="Place" title="Place at the ghost (or double-tap the map)"><svg class="touch-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></button>
<button type="button" id="touch-hand-rotate" class="touch-hand-button" aria-label="Rotate"><svg class="touch-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.4-5.7"/><path d="M20 4v4.5h-4.5"/></svg></button>
<button type="button" id="touch-hand-away" class="touch-hand-button" aria-label="Put away"><svg class="touch-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
</div>
Expand Down Expand Up @@ -1214,6 +1215,7 @@ export function mountBlueprintEditor(root: HTMLElement): () => void {
const iconName = paletteSelection ?? heldModule?.name;
label.replaceChildren(...(iconName ? [icon(iconName, "", 24)] : []), document.createTextNode(what));
$<HTMLButtonElement>("#touch-hand-rotate").hidden = !(paletteSelection || pasteArmed);
$<HTMLButtonElement>("#touch-hand-place").hidden = !(paletteSelection || pasteArmed);
}
const moreOn: Record<string, boolean> = { alt: altModeOn, copper: wireColorInHand === "copper", red: wireColorInHand === "red", green: wireColorInHand === "green" };
for (const item of root.querySelectorAll<HTMLButtonElement>("[data-touch-more]")) {
Expand Down Expand Up @@ -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();
Expand Down
47 changes: 46 additions & 1 deletion packages/renderer/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
Loading