mirror of
https://github.com/tiennm99/sokoban.git
synced 2026-10-13 06:15:45 +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}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<footer class="site-footer">
|
{#if view !== 'game'}
|
||||||
Made with <span class="heart">♥</span> by
|
<footer class="site-footer">
|
||||||
<a href="https://miti99.com" target="_blank" rel="noopener noreferrer">miti99</a>
|
Made with <span class="heart">♥</span> by
|
||||||
</footer>
|
<a href="https://miti99.com" target="_blank" rel="noopener noreferrer">miti99</a>
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.site-footer {
|
.site-footer {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 6px;
|
bottom: calc(6px + env(safe-area-inset-bottom));
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
|
|||||||
@@ -41,9 +41,9 @@
|
|||||||
if (!level) return 48;
|
if (!level) return 48;
|
||||||
const maxTile = 56;
|
const maxTile = 56;
|
||||||
const minTile = 16;
|
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 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 maxByWidth = Math.floor((window.innerWidth - 80) / level.width);
|
||||||
const maxByHeight = Math.floor((window.innerHeight - margin - 100) / level.height);
|
const maxByHeight = Math.floor((window.innerHeight - margin - 100) / level.height);
|
||||||
return Math.max(minTile, Math.min(maxTile, maxByWidth, maxByHeight));
|
return Math.max(minTile, Math.min(maxTile, maxByWidth, maxByHeight));
|
||||||
@@ -206,6 +206,11 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.game {
|
.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;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,17 +238,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.board-wrap {
|
.board-wrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: calc(100vh - 140px);
|
min-height: 0;
|
||||||
max-width: calc(100vw - 48px);
|
max-width: calc(100vw - 48px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* On touch devices, hide the duplicated header action buttons (UNDO /
|
/* On touch devices, hide the duplicated header action buttons —
|
||||||
RESTART / LEVELS) and reserve room for the bottom MobileControls. */
|
MobileControls now occupies its own row at the bottom of the flex column. */
|
||||||
@media (pointer: coarse) {
|
@media (pointer: coarse) {
|
||||||
.desktop-actions { display: none; }
|
.desktop-actions { display: none; }
|
||||||
.board-wrap { max-height: calc(100vh - 260px); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
|
|||||||
+114
-22
@@ -1,47 +1,134 @@
|
|||||||
<!--
|
<!--
|
||||||
Touch-only on-screen controls. Hidden on desktop via @media (pointer: coarse).
|
Touch-only on-screen controls. Hidden on desktop via @media (pointer: coarse).
|
||||||
Bottom-left action stack (UNDO / RESTART / LEVELS), bottom-right D-pad.
|
In-flow bottom row: action stack on the left (UNDO / RESET / LVLS), D-pad on the right.
|
||||||
Tap-only — no auto-repeat. Calls back into GameView with plain handlers.
|
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>
|
<script>
|
||||||
|
const REPEAT_MS = 130;
|
||||||
|
const ARM_MS = 2000;
|
||||||
|
|
||||||
let { onMove, onUndo, onRestart, onLevels } = $props();
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="dock-left">
|
<div class="mobile-dock">
|
||||||
<button class="action" type="button" onclick={onUndo} aria-label="Undo">UNDO</button>
|
<div class="dock-left">
|
||||||
<button class="action" type="button" onclick={onRestart} aria-label="Restart">RESET</button>
|
<button class="action" type="button" onclick={onUndo} aria-label="Undo">UNDO</button>
|
||||||
<button class="action" type="button" onclick={onLevels} aria-label="Levels">LVLS</button>
|
<button
|
||||||
</div>
|
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">
|
<div class="dpad">
|
||||||
<button class="arrow up" type="button" onclick={() => onMove(0, -1)} aria-label="Up">▲</button>
|
<button
|
||||||
<button class="arrow left" type="button" onclick={() => onMove(-1, 0)} aria-label="Left">◀</button>
|
class="arrow up"
|
||||||
<button class="arrow right" type="button" onclick={() => onMove(1, 0)} aria-label="Right">▶</button>
|
type="button"
|
||||||
<button class="arrow down" type="button" onclick={() => onMove(0, 1)} aria-label="Down">▼</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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Hidden by default. Coarse-pointer (touch) devices only. */
|
/* Hidden by default. Coarse-pointer (touch) devices only. */
|
||||||
.dock-left,
|
.mobile-dock {
|
||||||
.dpad {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (pointer: coarse) {
|
@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 {
|
.dock-left {
|
||||||
position: fixed;
|
|
||||||
bottom: calc(12px + env(safe-area-inset-bottom));
|
|
||||||
left: calc(12px + env(safe-area-inset-left));
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
z-index: 50;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dpad {
|
.dpad {
|
||||||
position: fixed;
|
|
||||||
bottom: calc(12px + env(safe-area-inset-bottom));
|
|
||||||
right: calc(12px + env(safe-area-inset-right));
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 56px 56px 56px;
|
grid-template-columns: 56px 56px 56px;
|
||||||
grid-template-rows: 56px 56px;
|
grid-template-rows: 56px 56px;
|
||||||
@@ -49,7 +136,6 @@
|
|||||||
". up . "
|
". up . "
|
||||||
"left down right";
|
"left down right";
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
z-index: 50;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +156,12 @@
|
|||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action.armed {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.arrow {
|
.arrow {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|||||||
Reference in new issue
Block a user