diff --git a/index.html b/index.html index 4afbedd..0c31505 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,9 @@ + + Sokoban — 155 Puzzles diff --git a/src/app.css b/src/app.css index 737df3c..d95d9e9 100644 --- a/src/app.css +++ b/src/app.css @@ -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 { diff --git a/src/lib/core/haptics.js b/src/lib/core/haptics.js new file mode 100644 index 0000000..dc3a747 --- /dev/null +++ b/src/lib/core/haptics.js @@ -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. + } +} diff --git a/src/views/AppButton.svelte b/src/views/AppButton.svelte index d53d69b..c7c7485 100644 --- a/src/views/AppButton.svelte +++ b/src/views/AppButton.svelte @@ -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) { diff --git a/src/views/Board.svelte b/src/views/Board.svelte index 8074940..fd3e5ca 100644 --- a/src/views/Board.svelte +++ b/src/views/Board.svelte @@ -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, diff --git a/src/views/GameView.svelte b/src/views/GameView.svelte index 466ccdb..a548fdf 100644 --- a/src/views/GameView.svelte +++ b/src/views/GameView.svelte @@ -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}  ·  Best: {best}{/if} -
+
UNDO RESTART LEVELS @@ -153,6 +172,13 @@ />
+ + {#if won}
@@ -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; diff --git a/src/views/MobileControls.svelte b/src/views/MobileControls.svelte new file mode 100644 index 0000000..a55875d --- /dev/null +++ b/src/views/MobileControls.svelte @@ -0,0 +1,99 @@ + + + +
+ + + +
+ +
+ + + + +
+ +