mirror of
https://github.com/tiennm99/sokoban.git
synced 2026-10-11 03:13:52 +00:00
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:
1 parent
a1beee1dd0
commit
bb720327a0
7 files changed
+167
-4
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: <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;
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user