mirror of
https://github.com/tiennm99/sokoban.git
synced 2026-10-11 14:13:36 +00:00
fix(mobile): kill footer mistouch and dock/board overlap
Footer link was overlapping the on-screen D-pad and action stack tap zones during gameplay. Hide the footer on the game view and respect safe-area-inset on the screens where it stays. MobileControls drops position:fixed and lives in a flex row at the bottom of GameView's flex column. .board-wrap is now flex:1 1 auto with min-height:0 so puzzles scroll inside their wrapper, never under the controls. Magic max-height:calc(100vh - 260px) gone. RESET on mobile is now two-tap armed (label flips to TAP AGAIN, red background, 2s timeout) so a stray thumb cannot wipe a puzzle. The R keyboard shortcut is unchanged. D-pad arrows fire on pointerdown and auto-repeat at 130ms while held, matching the keyboard REPEAT_MS. preventDefault on pointerdown blocks the synthesized click double-fire. $effect cleanup clears intervals on unmount. Armed RESET text switched to white (4.85:1 on --danger) to clear WCAG-AA; using --bg failed at 3.1:1.
This commit is contained in:
1 parent
9b55be4cfb
commit
c80b9cedb6
3 files changed
+132
-33
No files matched your search
+7
-5
@@ -31,17 +31,19 @@
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
<footer class="site-footer">
|
||||
Made with <span class="heart">♥</span> by
|
||||
<a href="https://miti99.com" target="_blank" rel="noopener noreferrer">miti99</a>
|
||||
</footer>
|
||||
{#if view !== 'game'}
|
||||
<footer class="site-footer">
|
||||
Made with <span class="heart">♥</span> by
|
||||
<a href="https://miti99.com" target="_blank" rel="noopener noreferrer">miti99</a>
|
||||
</footer>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.site-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 6px;
|
||||
bottom: calc(6px + env(safe-area-inset-bottom));
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
|
||||
@@ -41,9 +41,9 @@
|
||||
if (!level) return 48;
|
||||
const maxTile = 56;
|
||||
const minTile = 16;
|
||||
// On touch devices reserve room for the bottom D-pad + action stack.
|
||||
// Dock is now in-flow (flex row at bottom); margin only covers header + hud + padding.
|
||||
const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
|
||||
const margin = isCoarse ? 260 : 140; // header + hud (+ mobile controls) + padding
|
||||
const margin = isCoarse ? 220 : 120; // header + hud (+ in-flow mobile dock) + 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));
|
||||
@@ -206,6 +206,11 @@
|
||||
|
||||
<style>
|
||||
.game {
|
||||
/* Stretch to #app's inner area (min-height: 100vh minus padding) so
|
||||
.board-wrap can flex-grow without forcing page-scroll. */
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -233,17 +238,17 @@
|
||||
}
|
||||
|
||||
.board-wrap {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 140px);
|
||||
min-height: 0;
|
||||
max-width: calc(100vw - 48px);
|
||||
}
|
||||
|
||||
/* On touch devices, hide the duplicated header action buttons (UNDO /
|
||||
RESTART / LEVELS) and reserve room for the bottom MobileControls. */
|
||||
/* On touch devices, hide the duplicated header action buttons —
|
||||
MobileControls now occupies its own row at the bottom of the flex column. */
|
||||
@media (pointer: coarse) {
|
||||
.desktop-actions { display: none; }
|
||||
.board-wrap { max-height: calc(100vh - 260px); }
|
||||
}
|
||||
|
||||
.error {
|
||||
|
||||
+114
-22
@@ -1,47 +1,134 @@
|
||||
<!--
|
||||
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.
|
||||
In-flow bottom row: action stack on the left (UNDO / RESET / LVLS), D-pad on the right.
|
||||
RESET is two-tap armed (mobile only) to prevent destructive mis-taps.
|
||||
Arrow buttons fire on pointerdown and auto-repeat at 130ms while held.
|
||||
-->
|
||||
<script>
|
||||
const REPEAT_MS = 130;
|
||||
const ARM_MS = 2000;
|
||||
|
||||
let { onMove, onUndo, onRestart, onLevels } = $props();
|
||||
|
||||
// Press-and-hold repeat shared across all four arrows. Last-press wins.
|
||||
let holdTimer = null;
|
||||
|
||||
function startHold(dx, dy) {
|
||||
onMove(dx, dy);
|
||||
clearInterval(holdTimer);
|
||||
holdTimer = setInterval(() => onMove(dx, dy), REPEAT_MS);
|
||||
}
|
||||
function endHold() {
|
||||
clearInterval(holdTimer);
|
||||
holdTimer = null;
|
||||
}
|
||||
|
||||
// RESET arming: first tap arms (visual delta), second tap within ARM_MS resets.
|
||||
let resetArmed = $state(false);
|
||||
let armTimer = null;
|
||||
|
||||
function disarm() {
|
||||
resetArmed = false;
|
||||
armTimer = null;
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
if (resetArmed) {
|
||||
clearTimeout(armTimer);
|
||||
disarm();
|
||||
onRestart();
|
||||
return;
|
||||
}
|
||||
resetArmed = true;
|
||||
clearTimeout(armTimer);
|
||||
armTimer = setTimeout(disarm, ARM_MS);
|
||||
}
|
||||
|
||||
// Defensive cleanup on unmount.
|
||||
$effect(() => () => {
|
||||
clearInterval(holdTimer);
|
||||
clearTimeout(armTimer);
|
||||
});
|
||||
</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="mobile-dock">
|
||||
<div class="dock-left">
|
||||
<button class="action" type="button" onclick={onUndo} aria-label="Undo">UNDO</button>
|
||||
<button
|
||||
class="action"
|
||||
class:armed={resetArmed}
|
||||
type="button"
|
||||
onclick={handleReset}
|
||||
aria-label={resetArmed ? 'Tap again to confirm reset' : 'Reset'}>
|
||||
{resetArmed ? 'TAP AGAIN' : '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 class="dpad">
|
||||
<button
|
||||
class="arrow up"
|
||||
type="button"
|
||||
onpointerdown={(e) => { e.preventDefault(); startHold(0, -1); }}
|
||||
onpointerup={endHold}
|
||||
onpointercancel={endHold}
|
||||
onpointerleave={endHold}
|
||||
aria-label="Up">▲</button>
|
||||
<button
|
||||
class="arrow left"
|
||||
type="button"
|
||||
onpointerdown={(e) => { e.preventDefault(); startHold(-1, 0); }}
|
||||
onpointerup={endHold}
|
||||
onpointercancel={endHold}
|
||||
onpointerleave={endHold}
|
||||
aria-label="Left">◀</button>
|
||||
<button
|
||||
class="arrow right"
|
||||
type="button"
|
||||
onpointerdown={(e) => { e.preventDefault(); startHold(1, 0); }}
|
||||
onpointerup={endHold}
|
||||
onpointercancel={endHold}
|
||||
onpointerleave={endHold}
|
||||
aria-label="Right">▶</button>
|
||||
<button
|
||||
class="arrow down"
|
||||
type="button"
|
||||
onpointerdown={(e) => { e.preventDefault(); startHold(0, 1); }}
|
||||
onpointerup={endHold}
|
||||
onpointercancel={endHold}
|
||||
onpointerleave={endHold}
|
||||
aria-label="Down">▼</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Hidden by default. Coarse-pointer (touch) devices only. */
|
||||
.dock-left,
|
||||
.dpad {
|
||||
.mobile-dock {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.mobile-dock {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding:
|
||||
0
|
||||
calc(12px + env(safe-area-inset-right))
|
||||
calc(12px + env(safe-area-inset-bottom))
|
||||
calc(12px + env(safe-area-inset-left));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -49,7 +136,6 @@
|
||||
". up . "
|
||||
"left down right";
|
||||
gap: 4px;
|
||||
z-index: 50;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +156,12 @@
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.action.armed {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 22px;
|
||||
color: var(--text);
|
||||
|
||||
Reference in new issue
Block a user