feat(ui): tile-crossword loading splash for cold lobby open #98
+5
-4
@@ -34,10 +34,11 @@ point values** (hardcoded in `lib/splash.ts`, since the alphabet table the board
|
||||
|
||||
It is an **App-level overlay** shown while `routeIsLobby && !app.splashDone` (so it also
|
||||
covers the session bootstrap; a deep-link to another screen is not covered). The lobby sets
|
||||
`app.lobbyReady` when its first load settles (success **or** error), and the splash dismisses
|
||||
on the next **word boundary**. ЭРУДИТ lays over ~1 s, then the splash loops
|
||||
ЗАГРУЗКА → ОЖИДАНИЕ (clearing back to ЭРУДИТ between rounds) until ready, so even a fast load
|
||||
shows it for ~1 s. Each tile **drops in** with a brief scale + fade. Under **reduced motion**
|
||||
`app.lobbyReady` when its first load settles (success **or** error). Each word is laid, then
|
||||
**held ~0.25 s** so it stays readable, and only after that hold does the readiness check fire —
|
||||
so a word never blinks away the instant it finishes. ЭРУДИТ lays + holds over ~1.25 s, then the
|
||||
splash loops ЗАГРУЗКА → ОЖИДАНИЕ (clearing back to ЭРУДИТ between rounds) until ready, so even a
|
||||
fast load shows it for ~1.25 s. Each tile **drops in** with a brief scale + fade. Under **reduced motion**
|
||||
(or the mock build, to keep the Playwright smoke unblocked) it shows a static ЭРУДИТ and
|
||||
dismisses as soon as the lobby is ready. The pure layout and timing live in `lib/splash.ts`
|
||||
(unit-tested); `Splash.svelte` is the renderer.
|
||||
|
||||
+14
-10
@@ -54,37 +54,41 @@ describe('splash layout', () => {
|
||||
|
||||
describe('splash schedule', () => {
|
||||
const s = splashSchedule();
|
||||
const HOLD = WORD_MS + PAUSE_MS;
|
||||
|
||||
it('lays ЭРУДИТ first, completing by WORD_MS, then checks', () => {
|
||||
it('lays ЭРУДИТ first, then holds it for PAUSE_MS before the check', () => {
|
||||
const r = reveals(s.prefix);
|
||||
expect(r).toHaveLength(6);
|
||||
expect(r.map((x) => x.atMs)).toEqual([...r.map((x) => x.atMs)].sort((a, b) => a - b));
|
||||
expect(r[0].atMs).toBe(0);
|
||||
expect(r[r.length - 1].atMs).toBeLessThan(WORD_MS);
|
||||
expect(s.prefix.at(-1)).toEqual({ atMs: WORD_MS, kind: 'check' });
|
||||
expect(s.prefixMs).toBe(WORD_MS);
|
||||
// The check waits out the whole word plus the readability hold — it never fires the instant
|
||||
// the last tile lands.
|
||||
expect(s.prefix.at(-1)).toEqual({ atMs: HOLD, kind: 'check' });
|
||||
expect(HOLD - WORD_MS).toBe(PAUSE_MS);
|
||||
expect(s.prefixMs).toBe(HOLD);
|
||||
});
|
||||
|
||||
it('reveals both vertical words per cycle and never re-lays a shared tile', () => {
|
||||
const r = reveals(s.cycle);
|
||||
expect(r).toHaveLength(14); // 7 + 7
|
||||
expect(r.some((x) => x.key === '3-1' || x.key === '3-3')).toBe(false);
|
||||
// The first vertical reveal waits out the leading pause.
|
||||
expect(r[0].atMs).toBe(PAUSE_MS);
|
||||
// ЗАГРУЗКА starts at the cycle start (no leading gap); the hold lives after the word.
|
||||
expect(r[0].atMs).toBe(0);
|
||||
});
|
||||
|
||||
it('checks at each word boundary and clears at the end of the cycle', () => {
|
||||
it('checks one hold after each word and clears at the end of the cycle', () => {
|
||||
const checks = s.cycle.filter((x) => x.kind === 'check').map((x) => x.atMs);
|
||||
expect(checks).toEqual([PAUSE_MS + WORD_MS, PAUSE_MS + 2 * WORD_MS + PAUSE_MS]);
|
||||
expect(checks).toEqual([HOLD, 2 * HOLD]);
|
||||
const last = s.cycle.at(-1)!;
|
||||
expect(last.kind).toBe('clear');
|
||||
expect(last.atMs).toBe(s.cycleMs);
|
||||
expect(s.cycleMs).toBe(2 * WORD_MS + 3 * PAUSE_MS);
|
||||
expect(s.cycleMs).toBe(2 * HOLD);
|
||||
});
|
||||
|
||||
it('honours overridden timings', () => {
|
||||
const fast = splashSchedule({ wordMs: 700, pauseMs: 100 });
|
||||
expect(fast.prefixMs).toBe(700);
|
||||
expect(fast.cycleMs).toBe(2 * 700 + 3 * 100);
|
||||
expect(fast.prefixMs).toBe(700 + 100);
|
||||
expect(fast.cycleMs).toBe(2 * (700 + 100));
|
||||
});
|
||||
});
|
||||
|
||||
+20
-18
@@ -76,7 +76,8 @@ export const SPLASH_TILES: SplashTile[] = [...ERUDIT_TILES, ...ZAGRUZKA_TILES, .
|
||||
/** ERUDIT_KEYS is the set of ЭРУДИТ tile keys — the tiles kept on screen across a clear. */
|
||||
export const ERUDIT_KEYS: ReadonlySet<string> = new Set(ERUDIT_TILES.map((t) => t.key));
|
||||
|
||||
/** WORD_MS is the time to lay one whole word; PAUSE_MS the gap between words. */
|
||||
/** WORD_MS is the time to lay one whole word; PAUSE_MS the hold after each word — so the word
|
||||
* stays readable before the next word starts or the splash dismisses into the lobby. */
|
||||
export const WORD_MS = 1000;
|
||||
export const PAUSE_MS = 250;
|
||||
|
||||
@@ -106,33 +107,34 @@ function revealSteps(tiles: SplashTile[], base: number, perTile: number): Splash
|
||||
|
||||
/**
|
||||
* splashSchedule builds the reveal plan. Each word is laid over wordMs (so its per-tile
|
||||
* stagger is wordMs divided by the word's tile count), a readiness check lands at every word
|
||||
* boundary, and pauseMs separates the words; after ОЖИДАНИЕ the cycle clears the two vertical
|
||||
* words and restarts (ЭРУДИТ persists). wordMs and pauseMs are overridable for tuning/tests.
|
||||
* stagger is wordMs divided by the word's tile count), then **held for pauseMs** so it stays
|
||||
* readable; only after that hold does a readiness check land — so the splash never dismisses
|
||||
* (nor starts the next word) the instant a word finishes. The next word begins right at the
|
||||
* check. After ОЖИДАНИЕ the cycle clears the two vertical words and restarts (ЭРУДИТ persists).
|
||||
* wordMs and pauseMs are overridable for tuning/tests.
|
||||
*/
|
||||
export function splashSchedule(opts: { wordMs?: number; pauseMs?: number } = {}): SplashSchedule {
|
||||
const wordMs = opts.wordMs ?? WORD_MS;
|
||||
const pauseMs = opts.pauseMs ?? PAUSE_MS;
|
||||
const sV = wordMs / ZAGRUZKA_TILES.length;
|
||||
const hold = wordMs + pauseMs; // lay the word, then hold it readable before the check
|
||||
|
||||
// Prefix: ЭРУДИТ left → right, the word completing at wordMs, then a readiness check.
|
||||
// Prefix: ЭРУДИТ left → right, held, then a readiness check.
|
||||
const prefix: SplashStep[] = [
|
||||
...revealSteps(ERUDIT_TILES, 0, wordMs / ERUDIT_TILES.length),
|
||||
{ atMs: wordMs, kind: 'check' },
|
||||
{ atMs: hold, kind: 'check' },
|
||||
];
|
||||
|
||||
// Cycle: pause, ЗАГРУЗКА, check, pause, ОЖИДАНИЕ, check, pause, clear. The leading pause is
|
||||
// the gap after ЭРУДИТ (and, on repeat, after the previous cycle's clear).
|
||||
const sV = wordMs / ZAGRUZKA_TILES.length;
|
||||
const zBase = pauseMs;
|
||||
const oBase = pauseMs + wordMs + pauseMs;
|
||||
const clearAt = oBase + wordMs + pauseMs;
|
||||
// Cycle: ЗАГРУЗКА, hold, check, ОЖИДАНИЕ, hold, check, clear. Each word starts at the previous
|
||||
// word's check (no leading gap); the hold lives at the end of every word.
|
||||
const endAt = 2 * hold;
|
||||
const cycle: SplashStep[] = [
|
||||
...revealSteps(ZAGRUZKA_TILES, zBase, sV),
|
||||
{ atMs: zBase + wordMs, kind: 'check' },
|
||||
...revealSteps(OZHIDANIE_TILES, oBase, sV),
|
||||
{ atMs: oBase + wordMs, kind: 'check' },
|
||||
{ atMs: clearAt, kind: 'clear' },
|
||||
...revealSteps(ZAGRUZKA_TILES, 0, sV),
|
||||
{ atMs: hold, kind: 'check' },
|
||||
...revealSteps(OZHIDANIE_TILES, hold, sV),
|
||||
{ atMs: endAt, kind: 'check' },
|
||||
{ atMs: endAt, kind: 'clear' },
|
||||
];
|
||||
|
||||
return { prefix, cycle, prefixMs: wordMs, cycleMs: clearAt };
|
||||
return { prefix, cycle, prefixMs: hold, cycleMs: endAt };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user