feat(offline): local pass-and-play (hotseat) — 2-4 players, seat/host PINs #211

Merged
developer merged 18 commits from feature/offline-hotseat into development 2026-07-07 14:27:57 +00:00
Showing only changes of commit 452a686e07 - Show all commits
+37 -9
View File
@@ -4,6 +4,7 @@
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import Modal from '../components/Modal.svelte';
import PinPad from '../components/PinPad.svelte';
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
@@ -186,6 +187,9 @@
// revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is
// per-account and irreversible. Only one row is revealed at a time.
let revealedId = $state<string | null>(null);
// A hotseat game's deletion is gated by the host (master) PIN — both active (terminate the game)
// and finished; pinTarget holds the game id while its PIN pad is open.
let pinTarget = $state<string | null>(null);
let drag: { id: string; x0: number; y0: number } | null = null;
// A horizontal swipe must not also count as a tap that opens the game; armed on swipe,
// consumed by the next tap, and reset on the next pointerdown so a later tap is never eaten.
@@ -196,13 +200,14 @@
if (e.pointerType === 'mouse') return; // desktop reveals via the kebab, not a swipe
drag = { id, x0: e.clientX, y0: e.clientY };
}
function onRowUp(e: PointerEvent, finished: boolean): void {
function onRowUp(e: PointerEvent, revealable: boolean): void {
if (!drag) return;
const dx = e.clientX - drag.x0;
const dy = e.clientY - drag.y0;
// Only a finished row reveals on a horizontal swipe; that swipe then suppresses the tap so it
// does not also open the game. Active rows ignore swipes and stay plain tap-to-open.
if (finished && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
// Only a revealable row (a finished game, or any hotseat game) reveals its delete on a horizontal
// swipe; that swipe then suppresses the tap so it does not also open the game. Other active rows
// ignore swipes and stay plain tap-to-open.
if (revealable && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
swiped = true;
revealedId = dx < 0 ? drag.id : null;
}
@@ -240,6 +245,14 @@
}
}
// startDelete routes the row's delete: a hotseat game (active or finished) is gated behind the host
// master PIN (so the last mover cannot instantly wipe the game); any other finished game deletes at
// once.
function startDelete(g: GameView): void {
if (g.hotseat) pinTarget = g.id;
else void hide(g.id);
}
async function acceptInvite(inv: Invitation) {
try {
const r = await gateway.invitationAccept(inv.id);
@@ -315,15 +328,15 @@
<div class="list">
{#each group.list as g (g.id)}
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
{#if group.finished}
<button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button>
<div class="rowwrap" class:revealed={(group.finished || g.hotseat) && revealedId === g.id}>
{#if group.finished || g.hotseat}
<button class="del" onclick={() => startDelete(g)} disabled={!isLocalGameId(g.id) && !connection.online} aria-label={t('lobby.hideGame')}>❌</button>
{/if}
<div class="row">
<button
class="open"
onpointerdown={(e) => onRowDown(e, g.id)}
onpointerup={(e) => onRowUp(e, group.finished)}
onpointerup={(e) => onRowUp(e, group.finished || !!g.hotseat)}
onclick={() => openGame(g)}
>
<span class="info">
@@ -345,7 +358,7 @@
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{resultBadge(g, myId).emoji}</span>
{/key}
</button>
{#if group.finished}
{#if group.finished || g.hotseat}
<button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}></button>
{:else}
<!-- A visual duplicate of the row's tap target: opens the game on tap, but kept out
@@ -378,6 +391,21 @@
</Modal>
{/if}
<!-- Deleting a hotseat game (active or finished) is gated by the host master PIN. -->
{#if pinTarget}
<PinPad
mode="verify"
title={t('hotseat.hostPin')}
verify={(p) => localSource.verifyHostPin(pinTarget ?? '', p)}
onclose={() => (pinTarget = null)}
onresult={() => {
const id = pinTarget;
pinTarget = null;
if (id) void hide(id);
}}
/>
{/if}
{#snippet tabbar()}
<TabBar>
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>