feat(ui): tile-crossword loading splash for cold lobby open #98
@@ -65,6 +65,10 @@ account is kept and the guest's games move into it. A merge is blocked only whil
|
||||
two accounts share a game still in progress.
|
||||
|
||||
### Lobby & matchmaking
|
||||
On a cold open the lobby greets the player with a brief **loading splash** — Scrabble tiles
|
||||
spelling **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** as a small crossword — that clears the moment the
|
||||
games list is ready, so the list never flashes an "empty" state on a slow connection.
|
||||
|
||||
The lobby lists **my games** and offers a bottom tab bar — new game, statistics, and a
|
||||
**⚙️ settings** tab opening the settings hub (settings, profile, friends, about). The
|
||||
**my games** list groups games into three
|
||||
|
||||
@@ -66,6 +66,10 @@ Mini App** авторизует по подписанным `initData` плат
|
||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||
|
||||
### Лобби и подбор
|
||||
При холодном запуске лобби встречает игрока короткой **заставкой загрузки** — фишки Scrabble
|
||||
складывают небольшой кроссворд из слов **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** — и она исчезает, как
|
||||
только список игр готов, поэтому на медленном соединении список не мигает «пустым» состоянием.
|
||||
|
||||
В лобби — список **мои игры** и нижний tab-bar (новая игра, статистика и вкладка
|
||||
**⚙️ настройки**, открывающая хаб настроек — настройки, профиль, друзья, о программе).
|
||||
Список **мои игры** разбит на три секции —
|
||||
|
||||
@@ -20,6 +20,28 @@ the game** (`growNav`) does the nav bar grow to absorb spare height, so the stri
|
||||
the title while the board and controls pin to the **bottom** for thumb reach. Every screen
|
||||
except Login uses `Screen`.
|
||||
|
||||
## Loading splash (`components/Splash.svelte`)
|
||||
|
||||
On a **cold app open** the lobby is the landing screen, but its game list arrives over the
|
||||
network — on a slow link the empty "no games yet" line would flash before the games load. A
|
||||
full-screen **tile splash** covers that gap: it lays a small Scrabble crossword out of the
|
||||
words **ЭРУДИТ** / **ЗАГРУЗКА** / **ОЖИДАНИЕ**, tile by tile, until the lobby's first load
|
||||
settles, then removes itself to reveal the populated list. The tiles carry their **Эрудит
|
||||
point values** (hardcoded in `lib/splash.ts`, since the alphabet table the board's
|
||||
`valueForLetter` reads is not cached yet at boot) and mirror a placed board tile's look
|
||||
(cream stock, bottom edge, drop shadow). The words form a 6×8 crossword: ЭРУДИТ horizontal,
|
||||
ЗАГРУЗКА and ОЖИДАНИЕ vertical, crossing it through the shared **Р** and **Д** (laid once).
|
||||
|
||||
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**
|
||||
(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.
|
||||
|
||||
## Navigation
|
||||
|
||||
- **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte`
|
||||
|
||||
+15
-1
@@ -6,6 +6,7 @@
|
||||
import { t } from './lib/i18n/index.svelte';
|
||||
import { insideTelegram, telegramBackButton } from './lib/telegram';
|
||||
import Toast from './components/Toast.svelte';
|
||||
import Splash from './components/Splash.svelte';
|
||||
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
||||
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
||||
import Login from './screens/Login.svelte';
|
||||
@@ -22,6 +23,11 @@
|
||||
void bootstrap();
|
||||
});
|
||||
|
||||
// The lobby is the cold-start landing (an empty hash and Telegram launch params both parse
|
||||
// to it). The tile loading splash overlays it until its first load settles; a deep-link to
|
||||
// another screen is not covered.
|
||||
const routeIsLobby = $derived(router.route.name === 'lobby');
|
||||
|
||||
// Inside Telegram, drive its native header back button: show it on any sub-screen
|
||||
// (everything returns to the lobby root), hide it on the lobby/login. The app's own
|
||||
// back chevron is hidden in Telegram (Header.svelte) so only the native one shows.
|
||||
@@ -72,7 +78,11 @@
|
||||
</script>
|
||||
|
||||
{#if !app.ready}
|
||||
<div class="splash">{t('common.loading')}</div>
|
||||
<!-- On a cold lobby open the tile splash (below) covers bootstrap too; a deep-link to
|
||||
another screen keeps the plain text splash while bootstrap runs. -->
|
||||
{#if !routeIsLobby}
|
||||
<div class="splash">{t('common.loading')}</div>
|
||||
{/if}
|
||||
{:else if app.blocked}
|
||||
<Blocked />
|
||||
{:else}
|
||||
@@ -113,6 +123,10 @@
|
||||
<StaleInviteModal />
|
||||
<WelcomeRedeemModal />
|
||||
|
||||
{#if routeIsLobby && !app.splashDone && !app.blocked}
|
||||
<Splash />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.splash {
|
||||
height: 100%;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { app } from '../lib/app.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { ERUDIT_KEYS, SPLASH_TILES, splashSchedule, type SplashStep } from '../lib/splash';
|
||||
|
||||
// Animate normally, but fall back to a static frame (no per-tile drop, no looping) under
|
||||
// reduced motion or the mock build — the latter so the Playwright smoke is not held up by
|
||||
// the overlay intercepting taps while it animates over the lobby.
|
||||
const instant = $derived(app.reduceMotion || import.meta.env.MODE === 'mock');
|
||||
|
||||
// The keys of the tiles currently on screen; the schedule adds and (on a clear) removes them.
|
||||
const revealed = new SvelteSet<string>();
|
||||
let timers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
function stop(): void {
|
||||
for (const id of timers) clearTimeout(id);
|
||||
timers = [];
|
||||
}
|
||||
function later(fn: () => void, ms: number): void {
|
||||
timers.push(setTimeout(fn, ms));
|
||||
}
|
||||
function finish(): void {
|
||||
stop();
|
||||
app.splashDone = true;
|
||||
}
|
||||
function clearVerticals(): void {
|
||||
for (const k of [...revealed]) if (!ERUDIT_KEYS.has(k)) revealed.delete(k);
|
||||
}
|
||||
function apply(step: SplashStep): void {
|
||||
if (step.kind === 'reveal' && step.key) revealed.add(step.key);
|
||||
else if (step.kind === 'check') {
|
||||
// Dismiss at a word boundary once the lobby's first load has settled.
|
||||
if (app.lobbyReady) finish();
|
||||
} else if (step.kind === 'clear') clearVerticals();
|
||||
}
|
||||
|
||||
function runAnimated(): void {
|
||||
const sch = splashSchedule();
|
||||
for (const step of sch.prefix) later(() => apply(step), step.atMs);
|
||||
const loop = (): void => {
|
||||
if (app.splashDone) return;
|
||||
for (const step of sch.cycle) later(() => apply(step), step.atMs);
|
||||
later(loop, sch.cycleMs);
|
||||
};
|
||||
later(loop, sch.prefixMs);
|
||||
}
|
||||
|
||||
function showStatic(): void {
|
||||
stop();
|
||||
for (const k of ERUDIT_KEYS) revealed.add(k);
|
||||
clearVerticals();
|
||||
}
|
||||
|
||||
// Drive the splash. The effect re-runs if `instant` flips (the reduce-motion preference
|
||||
// loads early in bootstrap, possibly just after this overlay mounts), swapping the
|
||||
// animation for the static frame.
|
||||
$effect(() => {
|
||||
if (instant) {
|
||||
showStatic();
|
||||
return;
|
||||
}
|
||||
runAnimated();
|
||||
return stop;
|
||||
});
|
||||
|
||||
// The static path has no word boundary to wait for, so dismiss as soon as the lobby settles.
|
||||
$effect(() => {
|
||||
if (instant && app.lobbyReady) app.splashDone = true;
|
||||
});
|
||||
|
||||
onDestroy(stop);
|
||||
</script>
|
||||
|
||||
<div class="splash" role="status" aria-busy="true">
|
||||
<span class="sr">{t('common.loading')}</span>
|
||||
<div class="grid" aria-hidden="true">
|
||||
{#each SPLASH_TILES as tile (tile.key)}
|
||||
{#if revealed.has(tile.key)}
|
||||
<div
|
||||
class="tile"
|
||||
class:drop={!instant}
|
||||
style="grid-row: {tile.row + 1}; grid-column: {tile.col + 1};"
|
||||
>
|
||||
<span class="letter">{tile.letter}</span>
|
||||
<span class="val">{tile.value}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/* Above the lobby and any modal (Modal.svelte z 40), below toasts (z 50) so an offline
|
||||
error toast still shows over the splash. */
|
||||
z-index: 45;
|
||||
background: var(--bg);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.grid {
|
||||
/* Six columns wide, eight rows tall — capped so the whole crossword fits a phone portrait
|
||||
(width) and is not oversized on desktop. */
|
||||
--tile: min(14vw, 9vh, 56px);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, var(--tile));
|
||||
grid-template-rows: repeat(8, var(--tile));
|
||||
gap: calc(var(--tile) * 0.12);
|
||||
}
|
||||
/* The tile mirrors a placed board tile (game/Board.svelte .cell.filled): the same cream
|
||||
stock, bottom edge and drop shadow, with the letter top-left and value bottom-right. */
|
||||
.tile {
|
||||
position: relative;
|
||||
background: var(--tile-bg);
|
||||
color: var(--tile-text);
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
inset 0 -2px 0 var(--tile-edge),
|
||||
2px 0 3px -1px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
/* "Falling" tile: it appears a touch larger and translucent and settles to size and full
|
||||
opacity as it lands. */
|
||||
.tile.drop {
|
||||
animation: tile-drop 180ms ease-out;
|
||||
}
|
||||
@keyframes tile-drop {
|
||||
from {
|
||||
transform: scale(1.15);
|
||||
opacity: 0.4;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tile.drop {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.letter {
|
||||
position: absolute;
|
||||
top: 6%;
|
||||
left: 10%;
|
||||
font-size: calc(var(--tile) * 0.5);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.val {
|
||||
position: absolute;
|
||||
right: 8%;
|
||||
bottom: 5%;
|
||||
font-size: calc(var(--tile) * 0.28);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Visually-hidden live label for assistive tech; the tiles themselves are decorative. */
|
||||
.sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -41,6 +41,12 @@ export interface Toast {
|
||||
|
||||
export const app = $state<{
|
||||
ready: boolean;
|
||||
/** Whether the lobby's first cold load has settled (success or error). The loading splash
|
||||
* (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */
|
||||
lobbyReady: boolean;
|
||||
/** Whether the loading splash has finished and removed itself. Gates the App-level overlay
|
||||
* so a warm intra-session return to the lobby shows no splash again. */
|
||||
splashDone: boolean;
|
||||
/** Whether the live-event stream is connected. The event hub is best-effort and never replays a
|
||||
* missed event, so an open game waiting for an opponent uses this to recover: it polls the game
|
||||
* state while the stream is down and refetches once on reconnect (see game/Game.svelte). */
|
||||
@@ -84,6 +90,8 @@ export const app = $state<{
|
||||
resync: number;
|
||||
}>({
|
||||
ready: false,
|
||||
lobbyReady: false,
|
||||
splashDone: false,
|
||||
streamAlive: false,
|
||||
session: null,
|
||||
profile: null,
|
||||
@@ -658,6 +666,9 @@ export async function logout(): Promise<void> {
|
||||
resetConnection();
|
||||
clearGameCache();
|
||||
clearLobby();
|
||||
// Replay the loading splash on the next cold lobby load after a re-login.
|
||||
app.lobbyReady = false;
|
||||
app.splashDone = false;
|
||||
gateway.setToken(null);
|
||||
await clearSession();
|
||||
app.session = null;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ERUDIT_KEYS,
|
||||
ERUDIT_VALUES,
|
||||
PAUSE_MS,
|
||||
SPLASH_TILES,
|
||||
WORD_MS,
|
||||
splashSchedule,
|
||||
type SplashStep,
|
||||
} from './splash';
|
||||
|
||||
const at = (word: 'erudit' | 'zagruzka' | 'ozhidanie') =>
|
||||
SPLASH_TILES.filter((t) => t.word === word);
|
||||
const text = (word: 'erudit' | 'zagruzka' | 'ozhidanie') => at(word).map((t) => t.letter).join('');
|
||||
const reveals = (steps: SplashStep[]) => steps.filter((s) => s.kind === 'reveal');
|
||||
|
||||
describe('splash layout', () => {
|
||||
it('uses the Эрудит point values for every splash letter', () => {
|
||||
// Spot-check the distinctive weights against scrabble-solver rules.Erudit.
|
||||
expect(ERUDIT_VALUES['Э']).toBe(10);
|
||||
expect(ERUDIT_VALUES['Ж']).toBe(5);
|
||||
expect(ERUDIT_VALUES['З']).toBe(5);
|
||||
expect(ERUDIT_VALUES['У']).toBe(3);
|
||||
expect(ERUDIT_VALUES['Г']).toBe(3);
|
||||
expect(ERUDIT_VALUES['Р']).toBe(2);
|
||||
expect(ERUDIT_VALUES['И']).toBe(1);
|
||||
});
|
||||
|
||||
it('places ЭРУДИТ horizontally on row 3, left to right, with its values', () => {
|
||||
const e = at('erudit');
|
||||
expect(text('erudit')).toBe('ЭРУДИТ');
|
||||
expect(e.map((t) => t.col)).toEqual([0, 1, 2, 3, 4, 5]);
|
||||
expect(e.every((t) => t.row === 3)).toBe(true);
|
||||
expect(e.map((t) => t.value)).toEqual([10, 2, 3, 2, 1, 2]);
|
||||
});
|
||||
|
||||
it('drops the shared crossing tiles (Р, Д) from the vertical words', () => {
|
||||
// ЗАГРУЗКА in column 1 minus the shared Р at row 3; ОЖИДАНИЕ in column 3 minus Д.
|
||||
expect(at('zagruzka').map((t) => t.row)).toEqual([0, 1, 2, 4, 5, 6, 7]);
|
||||
expect(text('zagruzka')).toBe('ЗАГУЗКА'); // ЗАГ(Р)УЗКА without the shared Р
|
||||
expect(at('zagruzka').every((t) => t.col === 1)).toBe(true);
|
||||
expect(at('ozhidanie').map((t) => t.row)).toEqual([0, 1, 2, 4, 5, 6, 7]);
|
||||
expect(text('ozhidanie')).toBe('ОЖИАНИЕ'); // ОЖИ(_)АНИЕ without Д
|
||||
expect(at('ozhidanie').every((t) => t.col === 3)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps only the ЭРУДИТ tiles as the persistent set', () => {
|
||||
expect(ERUDIT_KEYS.size).toBe(6);
|
||||
expect(ERUDIT_KEYS.has('3-1')).toBe(true); // Р crossing
|
||||
expect(ERUDIT_KEYS.has('3-3')).toBe(true); // Д crossing
|
||||
expect(SPLASH_TILES).toHaveLength(20); // 6 + 7 + 7 unique cells
|
||||
});
|
||||
});
|
||||
|
||||
describe('splash schedule', () => {
|
||||
const s = splashSchedule();
|
||||
|
||||
it('lays ЭРУДИТ first, completing by WORD_MS, then checks', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('checks at each word boundary 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]);
|
||||
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);
|
||||
});
|
||||
|
||||
it('honours overridden timings', () => {
|
||||
const fast = splashSchedule({ wordMs: 700, pauseMs: 100 });
|
||||
expect(fast.prefixMs).toBe(700);
|
||||
expect(fast.cycleMs).toBe(2 * 700 + 3 * 100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
// Pure model for the cold-start loading splash: a small Scrabble crossword that lays the
|
||||
// words ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ out of tiles, letter by letter, until the lobby is
|
||||
// ready. Kept free of any DOM or reactive state so it unit-tests in the node environment;
|
||||
// components/Splash.svelte wraps it with the reactive reveal set and the timer driver.
|
||||
//
|
||||
// The tile point values are hardcoded from the Эрудит ruleset (scrabble-solver
|
||||
// rules.Erudit). They cannot come from alphabet.valueForLetter() here: the splash renders on
|
||||
// a cold start, before the server has sent the variant's alphabet table that lookup reads.
|
||||
|
||||
/**
|
||||
* ERUDIT_VALUES is the per-letter point value (вес) for every letter the splash words use,
|
||||
* taken from the Эрудит ruleset. Letters are upper-cased, matching the rest of the UI.
|
||||
*/
|
||||
export const ERUDIT_VALUES: Record<string, number> = {
|
||||
А: 1,
|
||||
Г: 3,
|
||||
Д: 2,
|
||||
Е: 1,
|
||||
Ж: 5,
|
||||
З: 5,
|
||||
И: 1,
|
||||
К: 2,
|
||||
Н: 1,
|
||||
О: 1,
|
||||
Р: 2,
|
||||
Т: 2,
|
||||
У: 3,
|
||||
Э: 10,
|
||||
};
|
||||
|
||||
/** SplashWord names the three crossword words, used to group tiles for the reveal sequence. */
|
||||
export type SplashWord = 'erudit' | 'zagruzka' | 'ozhidanie';
|
||||
|
||||
/** SplashTile is one placed tile on the 6×8 splash grid. */
|
||||
export interface SplashTile {
|
||||
/** row is the grid row, 0..7 (ЭРУДИТ sits on row 3). */
|
||||
row: number;
|
||||
/** col is the grid column, 0..5. */
|
||||
col: number;
|
||||
/** letter is the upper-cased Cyrillic glyph shown on the tile. */
|
||||
letter: string;
|
||||
/** value is the tile's Эрудит point value. */
|
||||
value: number;
|
||||
/** word groups the tile by its crossword word. */
|
||||
word: SplashWord;
|
||||
/** key is the stable `"row-col"` identifier used by the renderer's reveal set. */
|
||||
key: string;
|
||||
}
|
||||
|
||||
/** COLS and ROWS are the splash grid dimensions (6 wide, 8 tall). */
|
||||
export const COLS = 6;
|
||||
export const ROWS = 8;
|
||||
|
||||
function tile(row: number, col: number, letter: string, word: SplashWord): SplashTile {
|
||||
return { row, col, letter, value: ERUDIT_VALUES[letter] ?? 0, word, key: `${row}-${col}` };
|
||||
}
|
||||
|
||||
// ЭРУДИТ — horizontal on row 3, columns 0..5 (laid left → right).
|
||||
const ERUDIT_TILES: SplashTile[] = [...'ЭРУДИТ'].map((l, i) => tile(3, i, l, 'erudit'));
|
||||
|
||||
// ЗАГРУЗКА — vertical in column 1, rows 0..7 (laid top → bottom). Its row-3 letter (Р) is the
|
||||
// tile already placed by ЭРУДИТ, so it is dropped here ("через имеющиеся буквы").
|
||||
const ZAGRUZKA_TILES: SplashTile[] = [...'ЗАГРУЗКА']
|
||||
.map((l, r) => tile(r, 1, l, 'zagruzka'))
|
||||
.filter((t) => t.row !== 3);
|
||||
|
||||
// ОЖИДАНИЕ — vertical in column 3, rows 0..7. Its row-3 letter (Д) is shared with ЭРУДИТ and
|
||||
// likewise dropped.
|
||||
const OZHIDANIE_TILES: SplashTile[] = [...'ОЖИДАНИЕ']
|
||||
.map((l, r) => tile(r, 3, l, 'ozhidanie'))
|
||||
.filter((t) => t.row !== 3);
|
||||
|
||||
/** SPLASH_TILES is every unique tile on the grid (ЭРУДИТ owns the two shared crossings). */
|
||||
export const SPLASH_TILES: SplashTile[] = [...ERUDIT_TILES, ...ZAGRUZKA_TILES, ...OZHIDANIE_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. */
|
||||
export const WORD_MS = 1000;
|
||||
export const PAUSE_MS = 250;
|
||||
|
||||
/** SplashStep is one scheduled action: reveal a tile, check whether the lobby is ready (and
|
||||
* if so dismiss), or clear every non-ЭРУДИТ tile before the cycle repeats. atMs is relative
|
||||
* to the start of its segment (prefix or cycle). */
|
||||
export interface SplashStep {
|
||||
atMs: number;
|
||||
kind: 'reveal' | 'check' | 'clear';
|
||||
/** key is the tile to reveal, set only on a 'reveal' step. */
|
||||
key?: string;
|
||||
}
|
||||
|
||||
/** SplashSchedule is the timed plan the renderer drives: the one-time prefix (ЭРУДИТ) and the
|
||||
* repeating cycle (ЗАГРУЗКА → ОЖИДАНИЕ → clear). The driver plays the prefix once, then loops
|
||||
* the cycle every cycleMs until the splash is dismissed. */
|
||||
export interface SplashSchedule {
|
||||
prefix: SplashStep[];
|
||||
cycle: SplashStep[];
|
||||
prefixMs: number;
|
||||
cycleMs: number;
|
||||
}
|
||||
|
||||
function revealSteps(tiles: SplashTile[], base: number, perTile: number): SplashStep[] {
|
||||
return tiles.map((t, i) => ({ atMs: Math.round(base + i * perTile), kind: 'reveal', key: t.key }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function splashSchedule(opts: { wordMs?: number; pauseMs?: number } = {}): SplashSchedule {
|
||||
const wordMs = opts.wordMs ?? WORD_MS;
|
||||
const pauseMs = opts.pauseMs ?? PAUSE_MS;
|
||||
|
||||
// Prefix: ЭРУДИТ left → right, the word completing at wordMs, then a readiness check.
|
||||
const prefix: SplashStep[] = [
|
||||
...revealSteps(ERUDIT_TILES, 0, wordMs / ERUDIT_TILES.length),
|
||||
{ atMs: wordMs, 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;
|
||||
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' },
|
||||
];
|
||||
|
||||
return { prefix, cycle, prefixMs: wordMs, cycleMs: clearAt };
|
||||
}
|
||||
@@ -52,6 +52,10 @@
|
||||
if (connection.online) void preloadGames(games);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
// The first cold load has settled (success or error): release the loading splash. Set
|
||||
// unconditionally on every refresh — it is an idempotent latch, reset only on logout.
|
||||
app.lobbyReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user