feat(mobile): comfort overhaul - D-pad, gesture blocking, haptics

Add on-screen D-pad (bottom-right) and action stack (UNDO / RESET /
LVLS, bottom-left), shown only on coarse-pointer devices. Hide the
duplicated header actions on touch.

Block iOS/Android browser quirks: pull-to-refresh, double-tap zoom,
long-press selection and callout. Game stays inside the viewport via
overscroll-behavior and per-element touch-action rules; viewport zoom
is left enabled for accessibility.

Add navigator.vibrate wrapper (haptics.pulse) and fire short pulses on
box push and longer pulse on level solve. Silent no-op where the API
is missing.

Reserve room for the bottom controls in computeTileSize on coarse
pointer; raise minTile to 16 for legibility on small phones.
This commit is contained in:
tiennm99 committed 2026-04-27 20:37:27 +07:00
1 parent a1beee1dd0
commit bb720327a0
7 files changed
+167 -4

No files matched your search

+2
View File
@@ -3,7 +3,9 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="./favicon.png" />
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#5e81ac" />
<title>Sokoban — 155 Puzzles</title>
</head>
<body>
+9
View File
@@ -47,6 +47,15 @@ html, body {
color: var(--text);
font-family: var(--font);
-webkit-font-smoothing: antialiased;
/* Mobile: kill pull-to-refresh, long-press selection, callout, tap flash. */
overscroll-behavior: contain;
-webkit-tap-highlight-color: transparent;
}
body {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
#app {
+12
View File
@@ -0,0 +1,12 @@
/**
* Tiny wrapper around navigator.vibrate. Silent no-op where the API is
* missing (iOS Safari, desktop) — callers don't need to feature-detect.
*/
export function pulse(ms) {
if (typeof navigator === 'undefined' || typeof navigator.vibrate !== 'function') return;
try {
navigator.vibrate(ms);
} catch {
// Some embedded WebViews throw on vibrate; ignore.
}
}
+3
View File
@@ -41,6 +41,9 @@
cursor: pointer;
transition: background 120ms ease, transform 80ms ease, box-shadow 120ms ease;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.btn:hover:not(:disabled) {
+5
View File
@@ -97,6 +97,11 @@
overflow: hidden;
background: var(--bg-deep);
margin: 0 auto;
/* `manipulation` kills the 300ms double-tap-zoom delay but still lets
the parent `.board-wrap` pan-scroll for the giant finale levels. */
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
}
.floor,
+37 -4
View File
@@ -6,9 +6,11 @@
import AppButton from './AppButton.svelte';
import Board from './Board.svelte';
import DonateModal from './DonateModal.svelte';
import MobileControls from './MobileControls.svelte';
import { parseLevel } from '../lib/core/level-parser.js';
import { BoardModel } from '../lib/core/board-model.js';
import { progressStore } from '../lib/core/progress-store.js';
import { pulse } from '../lib/core/haptics.js';
import { MICROBAN_LEVELS } from '../lib/data/microban-levels.js';
let { levelIndex, onMenu, onLevels, onNext } = $props();
@@ -38,8 +40,10 @@
function computeTileSize() {
if (!level) return 48;
const maxTile = 56;
const minTile = 10;
const margin = 140; // header + hud + padding
const minTile = 16;
// On touch devices reserve room for the bottom D-pad + action stack.
const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
const margin = isCoarse ? 260 : 140; // header + hud (+ mobile controls) + padding
const maxByWidth = Math.floor((window.innerWidth - 80) / level.width);
const maxByHeight = Math.floor((window.innerHeight - margin - 100) / level.height);
return Math.max(minTile, Math.min(maxTile, maxByWidth, maxByHeight));
@@ -63,12 +67,17 @@
if (model.isSolved() && !won) {
won = true;
progressStore.recordCompletion(levelIndex, moves);
pulse(60);
}
}
function tryMove(dx, dy) {
if (won || !model) return;
if (model.tryMove(dx, dy)) syncFromModel();
if (model.tryMove(dx, dy)) {
// Short buzz when a move pushed a box; plain steps stay silent.
if (model.history.at(-1)?.movedBox) pulse(10);
syncFromModel();
}
}
function undo() {
@@ -113,6 +122,16 @@
tileSize = computeTileSize();
}
// Recompute tile size when pointer type changes (e.g. external keyboard
// attached/detached on iPad), so the bottom-controls reservation matches.
$effect(() => {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(pointer: coarse)');
const handler = () => onResize();
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
});
const hasNext = $derived(levelIndex + 1 < MICROBAN_LEVELS.length);
let donateOpen = $state(false);
@@ -133,7 +152,7 @@
{#if best != null} &nbsp;·&nbsp; Best: <strong>{best}</strong>{/if}
</div>
</div>
<div class="hud-right">
<div class="hud-right desktop-actions">
<AppButton variant="ghost" size="sm" onclick={undo} title="Undo (U / Z)">UNDO</AppButton>
<AppButton variant="ghost" size="sm" onclick={restart} title="Restart (R)">RESTART</AppButton>
<AppButton variant="ghost" size="sm" onclick={onLevels} title="Back to levels (Esc)">LEVELS</AppButton>
@@ -153,6 +172,13 @@
/>
</div>
<MobileControls
onMove={tryMove}
onUndo={undo}
onRestart={restart}
{onLevels}
/>
{#if won}
<div class="overlay">
<div class="dialog">
@@ -210,6 +236,13 @@
max-width: calc(100vw - 48px);
}
/* On touch devices, hide the duplicated header action buttons (UNDO /
RESTART / LEVELS) and reserve room for the bottom MobileControls. */
@media (pointer: coarse) {
.desktop-actions { display: none; }
.board-wrap { max-height: calc(100vh - 260px); }
}
.error {
color: var(--danger);
font-weight: 700;
+99
View File
@@ -0,0 +1,99 @@
<!--
Touch-only on-screen controls. Hidden on desktop via @media (pointer: coarse).
Bottom-left action stack (UNDO / RESTART / LEVELS), bottom-right D-pad.
Tap-only — no auto-repeat. Calls back into GameView with plain handlers.
-->
<script>
let { onMove, onUndo, onRestart, onLevels } = $props();
</script>
<div class="dock-left">
<button class="action" type="button" onclick={onUndo} aria-label="Undo">UNDO</button>
<button class="action" type="button" onclick={onRestart} aria-label="Restart">RESET</button>
<button class="action" type="button" onclick={onLevels} aria-label="Levels">LVLS</button>
</div>
<div class="dpad">
<button class="arrow up" type="button" onclick={() => onMove(0, -1)} aria-label="Up">▲</button>
<button class="arrow left" type="button" onclick={() => onMove(-1, 0)} aria-label="Left">◀</button>
<button class="arrow right" type="button" onclick={() => onMove(1, 0)} aria-label="Right">▶</button>
<button class="arrow down" type="button" onclick={() => onMove(0, 1)} aria-label="Down">▼</button>
</div>
<style>
/* Hidden by default. Coarse-pointer (touch) devices only. */
.dock-left,
.dpad {
display: none;
}
@media (pointer: coarse) {
.dock-left {
position: fixed;
bottom: calc(12px + env(safe-area-inset-bottom));
left: calc(12px + env(safe-area-inset-left));
display: flex;
flex-direction: column;
gap: 8px;
z-index: 50;
}
.dpad {
position: fixed;
bottom: calc(12px + env(safe-area-inset-bottom));
right: calc(12px + env(safe-area-inset-right));
display: grid;
grid-template-columns: 56px 56px 56px;
grid-template-rows: 56px 56px;
grid-template-areas:
". up . "
"left down right";
gap: 4px;
z-index: 50;
}
}
.action {
min-width: 64px;
height: 44px;
padding: 0 12px;
font-size: 13px;
font-weight: 700;
letter-spacing: 1px;
font-family: inherit;
color: var(--text);
background: var(--panel);
border: 2px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
.arrow {
font-size: 22px;
font-family: inherit;
color: var(--text);
background: var(--panel);
border: 2px solid var(--accent);
border-radius: var(--radius);
cursor: pointer;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
.arrow.up { grid-area: up; }
.arrow.down { grid-area: down; }
.arrow.left { grid-area: left; }
.arrow.right { grid-area: right; }
.action:active,
.arrow:active {
background: var(--panel-hover);
transform: translateY(1px);
}
</style>