Commit e4ccd12e authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(game): in-game shell and domino in Night Arcade; invite fixes

Shell (match UI, player plates, dialogs, reactions, queue, VS, challenge):
flat Night Arcade surfaces, turn timers on the plates, banners for your
turn / time low / waiting, a claimable disconnect ring, and a cancel that
waits for an in-flight join so it never leaves a ghost queue row.
tests/play/shell.mjs: 144/147 (the 3 were the invite bugs below).

Domino: flat white tiles on a lip, a purple-tinted table, playable tiles
lifted, the hand fanned with translate. tests/play/domino.mjs: 785/785
against the bot, scripted positions and online.

friends.php: a ludo invite wrote a column ludo_matches does not have (400)
and opened a four-seat table; domino and ludo invites stored players as a
JSON string, so check-invites never showed them to the friend.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent b789888e
...@@ -322,9 +322,11 @@ if ($method === 'POST') { ...@@ -322,9 +322,11 @@ if ($method === 'POST') {
if ($gameKey === 'domino') { if ($gameKey === 'domino') {
$players = [$userId, $targetId]; $players = [$userId, $targetId];
// An array, not json_encode(): a JSON string in the jsonb column never
// matched check-invites' players=cs.[id], so the friend saw nothing.
$match = $sdb->insert('domino_matches', [ $match = $sdb->insert('domino_matches', [
'status' => 'waiting', 'status' => 'waiting',
'players' => json_encode($players), 'players' => $players,
'current_turn' => 0, 'current_turn' => 0,
'board' => [], 'board' => [],
'hands' => (object)[], 'hands' => (object)[],
...@@ -347,12 +349,21 @@ if ($method === 'POST') { ...@@ -347,12 +349,21 @@ if ($method === 'POST') {
]); ]);
} elseif ($gameKey === 'ludo') { } elseif ($gameKey === 'ludo') {
$players = [$userId, $targetId]; $players = [$userId, $targetId];
// A two-seat table shaped like a matchmade one. This wrote board_state,
// a column ludo_matches does not have (the insert failed with 400),
// stored players as a JSON string, and left player_count at its
// default of 4.
$match = $sdb->insert('ludo_matches', [ $match = $sdb->insert('ludo_matches', [
'room_code' => strtoupper(substr(bin2hex(random_bytes(3)), 0, 6)),
'status' => 'waiting', 'status' => 'waiting',
'players' => json_encode($players), 'player_count' => 2,
'players' => $players,
'current_turn' => 0, 'current_turn' => 0,
'board_state' => '{}', 'dice_value' => null,
'game_state' => ['mode' => 'live', 'invite_from' => $userId, 'invite_to' => $targetId, 'invite_t' => time()], 'positions' => [['pos' => [-1, -1, -1, -1]], ['pos' => [-1, -1, -1, -1]]],
'moves' => [],
'winners' => [],
'game_state' => ['mode' => 'live', 'turn_count' => 0, 'invite_from' => $userId, 'invite_to' => $targetId, 'invite_t' => time()],
'host_id' => $userId 'host_id' => $userId
]); ]);
......
/* In-game reactions and quick chat.
Every value comes from tokens.css — no hardcoded colours or radii. */
/* ── The bubble ──────────────────────────────────────────────────────────
Drawn attached to its sender's seat, carrying their avatar and name, so a
reaction always says who sent it. */
.rx-bubble {
position: fixed;
z-index: 900;
display: flex;
align-items: center;
gap: var(--s-2);
max-width: min(78vw, 320px);
padding: var(--s-2) var(--s-3);
border-radius: var(--r-full);
background: var(--bg-elevated);
border: 1px solid var(--border);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
pointer-events: none;
opacity: 0;
transform: translateY(6px) scale(0.94);
transition: opacity var(--dur-fast) ease, transform var(--dur-normal) cubic-bezier(0.16, 1, 0.3, 1);
}
/* Your own reactions are tinted, so the two sides never read the same. */
.rx-bubble.rx-mine {
background: var(--bg-card);
border-color: var(--gold);
}
.rx-bubble.rx-in { opacity: 1; transform: translateY(0) scale(1); }
.rx-bubble.rx-out { opacity: 0; transform: translateY(-8px) scale(0.96); }
/* Seats at the top of the screen open downward; seats at the bottom open up. */
.rx-bubble.rx-top { transform-origin: top center; }
.rx-bubble.rx-bottom { transform-origin: bottom center; }
.rx-avatar {
inline-size: 22px;
block-size: 22px;
border-radius: var(--r-full);
object-fit: cover;
flex: none;
}
.rx-avatar-blank {
display: grid;
place-items: center;
background: var(--bg-hover);
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.rx-emoji {
font-size: 24px;
line-height: 1;
flex: none;
}
.rx-text {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rx-who {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
max-inline-size: 90px;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── The picker ──────────────────────────────────────────────────────────
A bottom sheet: the only region a thumb reaches on a tall phone. */
.rx-panel {
position: fixed;
inset-inline: 0;
inset-block-end: 0;
z-index: 950;
display: flex;
flex-direction: column;
gap: var(--s-3);
padding: var(--s-2) var(--s-4) calc(var(--s-4) + env(safe-area-inset-bottom, 0px));
background: var(--bg-elevated);
border-start-start-radius: var(--r-xl);
border-start-end-radius: var(--r-xl);
border-block-start: 1px solid var(--border);
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.5);
transform: translateY(100%);
transition: transform var(--dur-normal) cubic-bezier(0.16, 1, 0.3, 1);
}
/* `display: flex` above outranks the user-agent's `[hidden] { display: none }`,
so without this the panel is never actually hidden — it just sits translated
off-screen, still focusable and still catching taps along the bottom edge. */
.rx-panel[hidden] { display: none !important; }
.rx-panel.rx-panel-in { transform: translateY(0); }
.rx-panel-grip {
inline-size: 36px;
block-size: 4px;
border-radius: var(--r-full);
background: var(--border-hover, rgba(255, 255, 255, 0.18));
margin: var(--s-1) auto var(--s-1);
}
.rx-row {
display: flex;
gap: var(--s-2);
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
padding-block-end: var(--s-1);
}
.rx-row::-webkit-scrollbar { display: none; }
/* 48px keeps every control above the 44px minimum tap target with room to spare. */
.rx-pick {
flex: none;
min-block-size: 48px;
border-radius: var(--r-md);
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-primary);
cursor: pointer;
font-family: inherit;
transition: transform var(--dur-fast) ease, background var(--dur-fast) ease;
}
.rx-pick-emote {
inline-size: 48px;
font-size: 24px;
line-height: 1;
display: grid;
place-items: center;
}
.rx-pick-phrase {
padding-inline: var(--s-4);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.rx-pick:hover { background: var(--bg-hover); }
.rx-pick:active { transform: scale(0.93); }
.rx-pick:disabled { opacity: 0.4; cursor: default; }
.rx-pick:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.rx-bubble, .rx-panel, .rx-pick { transition: none; }
}
This diff is collapsed.
// Match Live — glue between match-session (logic) and match-ui (visuals) // Match Live — glue between match-session (logic) and match-ui (visuals)
// Games import this ONE module to get full multiplayer edge case handling // Games import this ONE module to get full multiplayer edge case handling:
// waiting for the opponent to sit down, disconnect with a countdown ring,
// reconnect, auto-win (recorded on the server), your own connection, and the
// "restoring" panel after a refresh.
import * as session from './match-session.js'; import * as session from './match-session.js';
import * as ui from './match-ui.js'; import * as ui from './match-ui.js';
import * as mp from './multiplayer.js'; import * as mp from './multiplayer.js';
import * as store from './store.js'; import * as store from './store.js';
import * as net from './net.js';
const WAIT_CHIP_AFTER_MS = 2500; // a quick opponent never sees the waiting chip
const PRESENCE_FRESH_MS = 25000; // a first ping counts as "here" only if this recent
const ENDPOINT = { chess: 'game.php', ludo: 'ludo-match.php', domino: 'domino-match.php', backgammon: 'backgammon-match.php' };
function parseState(data) {
if (!data || !data.game_state) return null;
try {
const gs = typeof data.game_state === 'string' ? JSON.parse(data.game_state) : data.game_state;
return gs && typeof gs === 'object' ? gs : null;
} catch (e) { return null; }
}
/** /**
* When the opponent was last seen, from the heartbeat the server records in * When the opponent was last seen, from the heartbeat the server records in
* game_state. Returns 0 when there is nothing to go on. * game_state: chess stamps a flat `hb_<playerId>` key, and the heartbeat RPC
* keeps `connections.<playerId>.last_ping` for every game. Returns 0 when
* there is nothing to go on.
*/ */
function opponentHeartbeatAt(data) { function opponentHeartbeatAt(data) {
if (!data || !data.game_state) return 0; const gs = parseState(data);
try { if (!gs) return 0;
const gs = typeof data.game_state === 'string' ? JSON.parse(data.game_state) : data.game_state; const me = myPlayerId();
if (!gs || typeof gs !== 'object') return 0; const mine = 'hb_' + me;
// The server stamps one flat `hb_<playerId>` key per player every 10s.
// Take the most recent one that is not ours.
const mine = 'hb_' + myPlayerId();
let latest = 0; let latest = 0;
for (const [key, ts] of Object.entries(gs)) { for (const [key, ts] of Object.entries(gs)) {
if (!key.startsWith('hb_') || key === mine) continue; if (!key.startsWith('hb_') || key === mine) continue;
const v = typeof ts === 'number' ? ts : Date.parse(ts); const v = typeof ts === 'number' ? ts : Date.parse(ts);
if (Number.isFinite(v) && v > latest) latest = v; if (Number.isFinite(v) && v > latest) latest = v;
} }
const conns = gs.connections;
if (conns && typeof conns === 'object') {
for (const [id, info] of Object.entries(conns)) {
if (id === me) continue;
const v = session.pingTime(info?.last_ping);
if (v > latest) latest = v;
}
}
return latest; return latest;
} catch (e) { return 0; }
} }
function myPlayerId() { function myPlayerId() {
try { return store.get('auth.userId'); } catch (e) { return null; } try { return store.get('auth.userId'); } catch (e) { return null; }
} }
/** The other seated players' ids, from whatever shape the match row has. */
function opponentIds(data) {
const me = myPlayerId();
let players = data?.players;
if (typeof players === 'string') { try { players = JSON.parse(players); } catch (e) { players = null; } }
const ids = Array.isArray(players) ? players : [data?.white_player_id, data?.black_player_id];
return ids.filter(id => id && id !== me && typeof id === 'string' && !id.startsWith('bot'));
}
/**
* Record the win on the server when the opponent walked away, so the result,
* the rating and the coins are real rather than a picture of a trophy.
*/
async function claimAbandonWin(matchId, gameType, lastData) {
const me = myPlayerId();
const endpoint = ENDPOINT[gameType] || 'game.php';
if (gameType === 'chess') {
return net.post(endpoint, { action: 'complete', match_id: matchId, reason: 'abandon', result: 'win' });
}
if (gameType === 'backgammon') {
return net.post(endpoint, { action: 'complete', match_id: matchId, winner: me, reason: 'abandon' });
}
const others = opponentIds(lastData);
return net.post(endpoint, { action: 'complete', match_id: matchId, winners: [me, ...others], reason: 'abandon' });
}
export function start(matchId, gameType, options = {}) { export function start(matchId, gameType, options = {}) {
const { onMove, onGameEnd } = options; const { onMove, onGameEnd, onOpponentAbandon } = options;
// A refresh (or coming back into the app) finds this very match saved: say
// so until the first answer from the server puts the table back.
const saved = session.getRecoverableMatch();
let recovering = !!(saved && saved.matchId === matchId);
if (recovering) ui.showReconnecting();
let lastMoveCount = 0; let lastMoveCount = 0;
let lastOpponentSeenAt = 0; let lastOpponentSeenAt = 0;
let connectionLost = false; let connectionLost = false;
let lastData = null;
let opponentHere = false;
let ended = false;
// Until the opponent shows a sign of life, a chip says we are waiting.
const startedAt = Date.now();
const waitTimer = recovering ? null : setTimeout(() => { if (!opponentHere && !ended) ui.showWaitingForOpponent(); }, WAIT_CHIP_AFTER_MS);
const opponentArrived = (fresh) => {
if (opponentHere) return;
opponentHere = true;
clearTimeout(waitTimer);
ui.hideWaitingForOpponent(fresh && Date.now() - startedAt > WAIT_CHIP_AFTER_MS);
};
const sess = session.create(matchId, gameType, { const sess = session.create(matchId, gameType, {
onOpponentMove: (data) => { onOpponentMove: (data) => {
lastData = data;
ui.hideOpponentThinking(); ui.hideOpponentThinking();
if (recovering) { recovering = false; ui.hideReconnecting(); }
if (connectionLost) { if (connectionLost) {
connectionLost = false; connectionLost = false;
ui.showConnectionRestored(); ui.showConnectionRestored();
...@@ -60,29 +130,50 @@ export function start(matchId, gameType, options = {}) { ...@@ -60,29 +130,50 @@ export function start(matchId, gameType, options = {}) {
if (moveCount > lastMoveCount) { if (moveCount > lastMoveCount) {
lastMoveCount = moveCount; lastMoveCount = moveCount;
session.markOpponentActive(); session.markOpponentActive();
opponentArrived(false);
} }
if (opponentHeartbeatAt(data) > lastOpponentSeenAt) { const hb = opponentHeartbeatAt(data);
lastOpponentSeenAt = opponentHeartbeatAt(data); if (hb > lastOpponentSeenAt) {
const first = lastOpponentSeenAt === 0;
lastOpponentSeenAt = hb;
// A ping left over from before they went away is not a sign of life.
if (!first || Math.abs(Date.now() - hb) < PRESENCE_FRESH_MS) {
session.markOpponentActive(); session.markOpponentActive();
opponentArrived(true);
}
} }
onMove?.(data); onMove?.(data);
if (data.status === 'completed') { if (data.status === 'completed') {
ended = true;
clearTimeout(waitTimer);
ui.hideWaitingForOpponent(false);
onGameEnd?.(data); onGameEnd?.(data);
} }
}, },
onOpponentPresence: () => opponentArrived(true),
onOpponentDisconnect: () => { onOpponentDisconnect: () => {
ui.showOpponentDisconnect(); if (ended) return;
ui.hideWaitingForOpponent(false);
ui.showOpponentDisconnect({
seconds: 60,
onClaim: async () => {
if (ended) return;
if (onOpponentAbandon) { onOpponentAbandon(lastData); return; }
try { await claimAbandonWin(matchId, gameType, lastData); } catch (e) {}
},
});
}, },
onOpponentReconnect: () => { onOpponentReconnect: () => {
ui.showOpponentReconnect(); if (ui.isShowing() === 'disconnect') ui.showOpponentReconnect();
}, },
onOpponentAbandon: () => { onOpponentAbandon: () => {
// Auto-win handled by UI (shows trophy + navigates back) // The countdown on the disconnect panel decides, and claims the win.
}, },
onConnectionLost: () => { onConnectionLost: () => {
...@@ -105,7 +196,11 @@ export function start(matchId, gameType, options = {}) { ...@@ -105,7 +196,11 @@ export function start(matchId, gameType, options = {}) {
hideThinking: () => ui.hideOpponentThinking(), hideThinking: () => ui.hideOpponentThinking(),
showReconnecting: () => ui.showReconnecting(), showReconnecting: () => ui.showReconnecting(),
toast: (msg, color) => ui.toast(msg, color), toast: (msg, color) => ui.toast(msg, color),
cleanup: () => { session.destroy(); ui.cleanup(); } // Additive: game banners the scene can raise.
yourTurn: (sub) => ui.showYourTurn(sub),
timeLow: (seconds) => ui.showTimeLow(seconds),
banner: (opts) => ui.banner(opts),
cleanup: () => { ended = true; clearTimeout(waitTimer); session.destroy(); ui.cleanup(); }
}; };
} }
......
...@@ -34,6 +34,8 @@ export function create(matchId, gameType, options = {}) { ...@@ -34,6 +34,8 @@ export function create(matchId, gameType, options = {}) {
onOpponentAbandon: options.onOpponentAbandon || null, onOpponentAbandon: options.onOpponentAbandon || null,
onConnectionLost: options.onConnectionLost || null, onConnectionLost: options.onConnectionLost || null,
onConnectionRestored: options.onConnectionRestored || null, onConnectionRestored: options.onConnectionRestored || null,
onOpponentPresence: options.onOpponentPresence || null, // (lastPingMs) on each new sign of the opponent
opponentPings: {}, // playerId -> last_ping (ms) the heartbeat last reported
opponentDisconnected: false, opponentDisconnected: false,
abandonReported: false, // latched separately from opponentDisconnected abandonReported: false, // latched separately from opponentDisconnected
connectionLost: false // Track whether we are currently in a disconnected state connectionLost: false // Track whether we are currently in a disconnected state
...@@ -142,18 +144,65 @@ function startPolling() { ...@@ -142,18 +144,65 @@ function startPolling() {
} }
// ===== HEARTBEAT (let server know we're still connected) ===== // ===== HEARTBEAT (let server know we're still connected) =====
//
// The first beat goes out the moment the session starts, not ten seconds in:
// the opponent's client is waiting for it to know we have sat down (and, after
// a refresh, that we are back).
//
// The heartbeat RPC answers with every other player's last ping
// ({ others: { id: { last_ping } } }). A ping newer than the last one we saw is
// evidence the opponent is still at the table even while they think, which is
// what keeps a long think from reading as a disconnect.
function startPinging() { function startPinging() {
if (!currentSession) return; if (!currentSession) return;
currentSession.pingTimer = setInterval(async () => { const beat = async () => {
if (!currentSession || !currentSession.isActive) return; const sess = currentSession;
if (!sess || !sess.isActive) return;
try { try {
const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : currentSession.gameType === 'domino' ? 'domino-match.php' : currentSession.gameType === 'backgammon' ? 'backgammon-match.php' : 'game.php'; const endpoint = sess.gameType === 'ludo' ? 'ludo-match.php' : sess.gameType === 'domino' ? 'domino-match.php' : sess.gameType === 'backgammon' ? 'backgammon-match.php' : 'game.php';
await net.post(endpoint, { const res = await net.post(endpoint, {
action: 'heartbeat', action: 'heartbeat',
match_id: currentSession.matchId match_id: sess.matchId
}); });
if (sess === currentSession) notePings(res?.others);
} catch (e) {} } catch (e) {}
}, PING_INTERVAL); };
beat();
currentSession.pingTimer = setInterval(beat, PING_INTERVAL);
}
/** Parse the server's ping time ("2026-10-19 01:34:10.98+00") to ms. */
export function pingTime(v) {
if (typeof v === 'number') return v;
if (typeof v !== 'string' || !v) return 0;
let s = v.trim().replace(' ', 'T');
s = s.replace(/([+-]\d{2})$/, '$1:00'); // "+00" -> "+00:00"
s = s.replace(/(\.\d{3})\d+/, '$1'); // microseconds -> ms
const ms = Date.parse(s);
return Number.isFinite(ms) ? ms : 0;
}
/**
* Record what the heartbeat said about the other players. A ping newer than
* the last one seen counts as the opponent being here; the first one counts
* only when it is recent, so a ping left over from before they went away does
* not bring them back.
*/
export function notePings(others) {
const sess = currentSession;
if (!sess || !others || typeof others !== 'object') return;
for (const [id, info] of Object.entries(others)) {
if (!id || id === sess.myUserId) continue;
const at = pingTime(info?.last_ping ?? info);
if (!at) continue;
const prev = sess.opponentPings[id];
sess.opponentPings[id] = Math.max(prev || 0, at);
const fresh = prev === undefined ? Math.abs(Date.now() - at) < 25000 : at > prev;
if (fresh) {
markOpponentActive();
sess.onOpponentPresence?.(at);
}
}
} }
// ===== DISCONNECT DETECTION ===== // ===== DISCONNECT DETECTION =====
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -14,14 +14,17 @@ let subscriptions = {}; ...@@ -14,14 +14,17 @@ let subscriptions = {};
let reconnectTimer = null; let reconnectTimer = null;
let connected = false; let connected = false;
let reconnectDelay = 1000; let reconnectDelay = 1000;
let wanted = false; // false after disconnect(): a closing socket must not dial back in
export function connect() { export function connect() {
wanted = true;
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
const token = store.get('auth.token') || ANON_KEY; const token = store.get('auth.token') || ANON_KEY;
ws = new WebSocket(`${REALTIME_URL}?apikey=${ANON_KEY}&token=${token}&vsn=1.0.0`); const sock = new WebSocket(`${REALTIME_URL}?apikey=${ANON_KEY}&token=${token}&vsn=1.0.0`);
ws = sock;
ws.onopen = () => { sock.onopen = () => {
connected = true; connected = true;
reconnectDelay = 1000; reconnectDelay = 1000;
clearTimeout(reconnectTimer); clearTimeout(reconnectTimer);
...@@ -32,7 +35,7 @@ export function connect() { ...@@ -32,7 +35,7 @@ export function connect() {
Object.keys(subscriptions).forEach(topic => joinChannel(topic)); Object.keys(subscriptions).forEach(topic => joinChannel(topic));
}; };
ws.onmessage = (e) => { sock.onmessage = (e) => {
try { try {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.event === 'postgres_changes') { if (msg.event === 'postgres_changes') {
...@@ -59,23 +62,27 @@ export function connect() { ...@@ -59,23 +62,27 @@ export function connect() {
} }
}; };
ws.onclose = () => { sock.onclose = () => {
connected = false; if (ws === sock) { ws = null; connected = false; }
ws = null;
clearInterval(heartbeatInterval); clearInterval(heartbeatInterval);
// An explicit disconnect() closes the socket too; only an unexpected
// close dials back in (with backoff).
if (!wanted) return;
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => connect(), reconnectDelay); reconnectTimer = setTimeout(() => connect(), reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000); reconnectDelay = Math.min(reconnectDelay * 2, 30000);
}; };
ws.onerror = () => { sock.onerror = () => {
ws.close(); try { sock.close(); } catch (e) {}
}; };
} }
export function disconnect() { export function disconnect() {
wanted = false;
clearInterval(heartbeatInterval); clearInterval(heartbeatInterval);
clearTimeout(reconnectTimer); clearTimeout(reconnectTimer);
if (ws) { ws.close(); ws = null; } if (ws) { try { ws.close(); } catch (e) {} ws = null; }
connected = false; connected = false;
subscriptions = {}; subscriptions = {};
} }
......
...@@ -4,7 +4,7 @@ import * as store from './store.js'; ...@@ -4,7 +4,7 @@ import * as store from './store.js';
import * as realtime from './realtime.js'; import * as realtime from './realtime.js';
import * as audio from './audio.js'; import * as audio from './audio.js';
import * as juice from './juice.js'; import * as juice from './juice.js';
import { icon } from './icons.js'; import { icon, gicon } from './icons.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
let pendingMatches = []; let pendingMatches = [];
...@@ -19,28 +19,23 @@ export function init() { ...@@ -19,28 +19,23 @@ export function init() {
bus.on('tournament:paired', showPairingToast); bus.on('tournament:paired', showPairingToast);
} }
// The pairing notice is a toast card dealt from the top of the table: the // The pairing notice is a toast that slams down from the top of the screen:
// tournament's trophy on a face-down corner, the round, and the play button. // the trophy sticker, the round, and a green play button.
const TOAST_STYLE = ` const TOAST_STYLE = `
.tour-toast { display: flex; align-items: center; gap: var(--s-3); width: 100%; padding-inline-end: var(--s-3); } .tour-toast { display: flex; align-items: center; gap: var(--s-3); width: 100%; padding-inline-end: var(--s-3); }
.tour-toast.is-slam { animation: tourToastSlam 460ms cubic-bezier(0.34, 1.56, 0.64, 1) both; } .tour-toast.is-slam { animation: tourToastSlam 520ms cubic-bezier(0.34, 1.56, 0.64, 1) both; }
@keyframes tourToastSlam { @keyframes tourToastSlam {
0% { opacity: 0; transform: translateY(-34px) rotate(-3deg) scale(0.9); } 0% { opacity: 0; transform: translateY(-40px) rotate(-3deg) scale(0.85); }
55% { opacity: 1; } 55% { opacity: 1; transform: translateY(4px) scale(1.04); }
100% { opacity: 1; transform: none; } 100% { opacity: 1; transform: none; }
} }
@media (prefers-reduced-motion: reduce) { .tour-toast.is-slam { animation: none; } } @media (prefers-reduced-motion: reduce) { .tour-toast.is-slam { animation: none; } }
.tour-toast-mark { .tour-toast-mark { flex-shrink: 0; display: flex; animation: tourMark 1.6s ease-in-out infinite; }
width: 40px; height: 40px; .tour-toast-mark .gi { width: 44px; height: 44px; }
flex-shrink: 0; @keyframes tourMark { 0%, 100% { rotate: -6deg; } 50% { rotate: 6deg; } }
display: flex; align-items: center; justify-content: center;
border-radius: var(--r-md);
background: var(--back-800);
color: var(--gold);
}
.tour-toast-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } .tour-toast-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.tour-toast-title { font-size: var(--fs-sm); font-weight: 800; color: var(--ink); } .tour-toast-title { font-family: var(--font-display); font-size: var(--fs-md); line-height: 1.15; color: var(--ink); }
.tour-toast-sub { font-size: var(--fs-xs); font-weight: 500; color: var(--ink-2); } .tour-toast-sub { font-size: var(--fs-xs); font-weight: 700; color: var(--ink-2); }
.tour-toast .btn { min-height: 44px; padding: 0 var(--s-4); font-size: var(--fs-sm); flex-shrink: 0; } .tour-toast .btn { min-height: 44px; padding: 0 var(--s-4); font-size: var(--fs-sm); flex-shrink: 0; }
`; `;
...@@ -57,11 +52,11 @@ function showPairingToast(data) { ...@@ -57,11 +52,11 @@ function showPairingToast(data) {
const toast = document.createElement('div'); const toast = document.createElement('div');
toast.id = 'tournament-toast'; toast.id = 'tournament-toast';
toast.className = 'toast tour-toast is-slam'; toast.className = 'toast reward tour-toast is-slam';
toast.setAttribute('role', 'status'); toast.setAttribute('role', 'status');
const round = `<span class="num">${data.roundNumber ?? ''}</span>`; const round = `<span class="num">${data.roundNumber ?? ''}</span>`;
toast.innerHTML = ` toast.innerHTML = `
<span class="tour-toast-mark">${icon('trophy', 22)}</span> <span class="tour-toast-mark">${gicon('trophy', 44)}</span>
<div class="tour-toast-text"> <div class="tour-toast-text">
<div class="tour-toast-title">${t('tournament.round_ready', { n: round })}</div> <div class="tour-toast-title">${t('tournament.round_ready', { n: round })}</div>
<div class="tour-toast-sub">${t('tournament.tap_to_play')}</div> <div class="tour-toast-sub">${t('tournament.tap_to_play')}</div>
......
This diff is collapsed.
This diff is collapsed.
// A tile in flight across the screen: from a hand to the table, from the
// boneyard to a hand. It lifts on a short arc, turns to lie the way it will
// land, and can turn over on the way (face down to face up).
import { tileInner } from './tile-dom.js';
const reduced = () => { try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) { return false; } };
const centre = (r) => ({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
/**
* from, to: client rects ({ left, top, width, height })
* top/bottom: values printed on the upright face
* turn: degrees it turns on the way (90 or -90 to land lying, 180 to swap ends)
* flip: 'none' | 'up' (face down to face up) | 'down' | 'back' (stays face down)
* Resolves when it has landed; the element is removed on the next frame so
* whatever replaces it can paint first.
*/
export function flyTile({ from, to, top = 0, bottom = 0, turn = 0, flip = 'none', duration = 420, arc = 46, keep = false }) {
const lying = Math.abs(turn) % 180 === 90;
// The element is the upright form of the tile at its landing size.
const w = lying ? to.height : to.width;
const h = lying ? to.width : to.height;
const c1 = centre(to), c0 = centre(from);
const el = document.createElement('div');
el.className = 'dt-tile dg-flying' + (flip === 'back' ? ' dt-back' : '');
el.setAttribute('aria-hidden', 'true');
el.innerHTML = flip === 'back' ? '' : tileInner(top, bottom, { back: flip !== 'none' });
el.style.cssText = `position:fixed;z-index:9990;pointer-events:none;width:${w}px;height:${h}px;left:${c1.x - w / 2}px;top:${c1.y - h / 2}px;`;
document.body.appendChild(el);
const dx = c0.x - c1.x, dy = c0.y - c1.y;
const k0 = Math.max(0.3, Math.max(from.width, from.height) / Math.max(w, h));
const y0 = flip === 'up' ? 180 : 0;
const y1 = flip === 'down' ? 180 : 0;
const frames = [
{ transform: `translate(${dx}px, ${dy}px) rotateY(${y0}deg) rotate(0deg) scale(${k0})`, filter: 'drop-shadow(0 4px 4px rgba(0,0,0,0.4))' },
{ transform: `translate(${dx * 0.45}px, ${dy * 0.45 - arc}px) rotateY(${(y0 + y1) / 2}deg) rotate(${turn * 0.7}deg) scale(${(k0 + 1) / 2 * 1.12})`, filter: 'drop-shadow(0 16px 12px rgba(0,0,0,0.45))', offset: 0.5 },
{ transform: `translate(0px, 0px) rotateY(${y1}deg) rotate(${turn}deg) scale(1)`, filter: 'drop-shadow(0 2px 2px rgba(0,0,0,0.35))' },
];
return new Promise((resolve) => {
const done = () => {
resolve(el);
if (!keep) requestAnimationFrame(() => el.remove());
};
if (reduced()) { el.style.transform = frames[2].transform; done(); return; }
const anim = el.animate(frames, { duration, easing: 'cubic-bezier(0.3, 0.7, 0.3, 1)', fill: 'forwards' });
anim.onfinish = done;
anim.oncancel = done;
});
}
/**
* How far an upright tile showing `topValue` on top must turn to land as
* `target` (a laid-out tile: a/b values, w/h size).
*/
export function turnFor(topValue, target) {
const lying = target.w > target.h;
if (lying) return topValue === target.a ? -90 : 90;
return topValue === target.a ? 0 : 180;
}
This diff is collapsed.
// One domino tile as markup: the same white face in your hand, under your
// finger, in flight and turned up at the end of a round. Upright: `top` on the
// upper half, `bottom` on the lower. Pips and the engraved line are SVG, so
// they stay crisp at any size.
const SPOTS = {
0: [],
1: [[0, 0]],
2: [[1, -1], [-1, 1]],
3: [[1, -1], [0, 0], [-1, 1]],
4: [[-1, -1], [1, -1], [-1, 1], [1, 1]],
5: [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
6: [[-1, -1], [-1, 0], [-1, 1], [1, -1], [1, 0], [1, 1]],
};
function pips(value, cy) {
return (SPOTS[value] || []).map(([gx, gy]) =>
`<circle cx="${20 + gx * 10.6}" cy="${cy + gy * 10.6}" r="4.1"/>`).join('');
}
/** The printed face of an upright tile, viewBox 40 by 80. */
export function faceSVG(top, bottom) {
return `<svg class="dt-face" viewBox="0 0 40 80" aria-hidden="true" focusable="false">
<path class="dt-line" d="M8 40H32"/>
<g class="dt-pips">${pips(top, 20)}${pips(bottom, 60)}</g>
</svg>`;
}
/** A whole tile element's inner markup; `back` adds the purple back for flips. */
export function tileInner(top, bottom, { back = false } = {}) {
return `<span class="dt-front">${faceSVG(top, bottom)}</span>${back ? '<span class="dt-back-face" aria-hidden="true"></span>' : ''}`;
}
// Flat white face on a solid lip of its own shape; face down, flat purple.
export const TILE_CSS = `
.dt-tile {
position: relative;
display: block;
padding: 0;
border: 0;
border-radius: 20% / 10%;
background: var(--card);
box-shadow: 0 var(--dt-lip, 4px) 0 var(--card-edge), 0 8px 14px -6px rgba(0, 0, 0, 0.5);
color: var(--ink);
-webkit-tap-highlight-color: transparent;
transform-style: preserve-3d;
}
.dt-front { position: absolute; inset: 0; display: block; border-radius: inherit; backface-visibility: hidden; }
.dt-face { display: block; width: 100%; height: 100%; }
.dt-pips { fill: currentColor; }
.dt-line { stroke: var(--card-line); stroke-width: 2; stroke-linecap: round; }
.dt-back-face, .dt-back {
border-radius: 20% / 10%;
background: var(--domino-primary);
box-shadow: 0 3px 0 var(--domino-secondary), 0 6px 10px -4px rgba(0, 0, 0, 0.5);
}
.dt-back-face::after, .dt-back::after {
content: ''; position: absolute; inset: 0; margin: auto;
width: 32%; aspect-ratio: 1; rotate: 45deg; border-radius: 2px;
background: var(--btn-purple-1);
}
.dt-back-face { position: absolute; inset: 0; transform: rotateY(180deg); backface-visibility: hidden; }
.dt-back { display: block; position: relative; }
`;
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
import * as scene from '../../core/scene.js'; import * as scene from '../../core/scene.js';
import { mountGame, unmountGame } from './scenes/game.js'; import { mountGame, unmountGame } from './scenes/game.js';
import { mountResult } from './scenes/result.js'; import { mountResult, unmountResult } from './scenes/result.js';
import { mountRoom, unmountRoom } from './scenes/room.js'; import { mountRoom, unmountRoom } from './scenes/room.js';
scene.register('domino-game', mountGame, unmountGame); scene.register('domino-game', mountGame, unmountGame);
scene.register('domino-result', mountResult); scene.register('domino-result', mountResult, unmountResult);
scene.register('domino-room', mountRoom, unmountRoom); scene.register('domino-room', mountRoom, unmountRoom);
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment