srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'web/src/app.js')
-rw-r--r--web/src/app.js362
1 files changed, 362 insertions, 0 deletions
diff --git a/web/src/app.js b/web/src/app.js
new file mode 100644
index 0000000..fac89d4
--- /dev/null
+++ b/web/src/app.js
@@ -0,0 +1,362 @@
+import { renderMainMenu } from './screens/mainMenu.js';
+import { renderTypingGame } from './screens/typingGame.js';
+import { renderEndScreen } from './screens/endScreen.js';
+import { renderPassiveMode } from './screens/passiveMode.js';
+import { renderStatsScreen } from './screens/statsScreen.js';
+import { renderPlaceholderScreen } from './screens/placeholderScreen.js';
+import { renderAccountScreen } from './screens/accountScreen.js';
+import { renderLeaderboardScreen } from './screens/leaderboardScreen.js';
+import { renderFriendsScreen } from './screens/friendsScreen.js';
+import { renderMultiplayerScreen } from './screens/multiplayerScreen.js';
+import { renderLyricsScreen } from './screens/lyricsScreen.js';
+import { renderPublicProfileScreen } from './screens/publicProfileScreen.js';
+import { renderStoreScreen } from './screens/storeScreen.js';
+import { createGame, freeGame } from './game.js';
+import { getSettings } from './settings.js';
+import { generateWordStream, generateWeakKeyStream, wordCountForDuration } from './wordGenerator.js';
+import { getWeakChars } from './keyStats.js';
+
+const FALLBACK_TEXT = { category: 'general', content: 'The quick brown fox jumps over the lazy dog.', attribution: 'Traditional pangram' };
+
+function uniqueCategories(items) {
+ const set = new Set();
+ for (const t of items) if (t.category) set.add(t.category);
+ return Array.from(set).sort();
+}
+
+function pickRandom(arr) {
+ return arr[Math.floor(Math.random() * arr.length)];
+}
+
+function getRandomTextItem(items, category) {
+ const pool = category && category !== 'random' ? items.filter(t => t.category === category) : items;
+ return pool.length ? pickRandom(pool) : FALLBACK_TEXT;
+}
+
+export function startApp(root, localTexts) {
+ let allTexts = localTexts;
+ let categories = uniqueCategories(localTexts);
+ let selectedCategory = localStorage.getItem('typerpunk:last_mode') || 'time';
+ let customText = null; // { name, chunks: [{content, time}], language, timed }
+ let session = { type: 'random', customIndex: 0 };
+ let game = null;
+ let cleanupScreen = null;
+
+ const onlineUrl = window.TYPERPUNK_TEXTS_URL;
+ if (onlineUrl) {
+ fetch(onlineUrl, { cache: 'no-store' })
+ .then(res => (res.ok ? res.json() : null))
+ .then(data => {
+ if (Array.isArray(data)) {
+ allTexts = data;
+ categories = uniqueCategories(data);
+ }
+ })
+ .catch(() => {});
+ }
+
+ function teardown() {
+ if (cleanupScreen) {
+ cleanupScreen();
+ cleanupScreen = null;
+ }
+ }
+
+ // Which screen is showing, and whether leaving it costs the user
+ // something. Escape is a global "get me out of here", but walking out of a
+ // live race drops you from it for everyone else in the room too, so that
+ // one case asks first.
+ let currentScreenName = 'menu';
+ let escapeIsCostly = () => false;
+
+ function setScreen(name, costly = () => false) {
+ currentScreenName = name;
+ escapeIsCostly = costly;
+ }
+
+ function handleGlobalEscape(e) {
+ if (e.key !== 'Escape') return;
+ // Let an open popover close itself first - one Escape should not both
+ // dismiss a menu and navigate away from the screen behind it.
+ const openPopover = document.querySelector(
+ '.mode-popover:not([hidden]), .rail-settings-panel:not([hidden]), .lang-popover:not([hidden]), .theme-gallery-popover:not([hidden])');
+ if (openPopover) {
+ openPopover.hidden = true;
+ return;
+ }
+ if (currentScreenName === 'menu') return;
+ if (escapeIsCostly() && !window.confirm('Leave the race? Your opponents will see you drop out.')) return;
+ showMainMenu();
+ }
+ document.addEventListener('keydown', handleGlobalEscape);
+
+ function showMainMenu() {
+ teardown();
+ freeGame(game);
+ game = null;
+ setScreen('menu');
+ cleanupScreen = renderMainMenu(root, {
+ onStartGame: () => { session = { type: 'random', customIndex: 0 }; startGame(); },
+ categories,
+ selectedCategory,
+ onSelectCategory: cat => {
+ selectedCategory = cat;
+ try { localStorage.setItem('typerpunk:last_mode', cat); } catch {}
+ showMainMenu();
+ },
+ customText,
+ onLoadCustom: loaded => {
+ customText = loaded;
+ showMainMenu();
+ },
+ onClearCustom: () => {
+ customText = null;
+ showMainMenu();
+ },
+ onStartCustom: () => {
+ session = { type: 'custom', customIndex: 0 };
+ startGame();
+ },
+ onStartPassive: startPassive,
+ onShowStats: showStats,
+ onShowPlaceholder: showPlaceholder,
+ onShowAccount: showAccount,
+ onShowLeaderboard: showLeaderboard,
+ onShowFriends: showFriends,
+ onShowMultiplayer: showMultiplayer,
+ onShowStore: showStore,
+ onShowLyrics: showLyrics,
+ onSimulateTest: simulateTest,
+ });
+ }
+
+ // Dev-only shortcut: jumps straight to the end screen with synthetic
+ // results, no actual typing needed - for iterating on the end screen's
+ // design without retyping a full passage every time.
+ function simulateTest() {
+ teardown();
+ const text = 'The quick brown fox jumps over the lazy dog while typing very quickly today, right now, in this exact moment.';
+ const wpm = 70 + Math.random() * 90;
+ const accuracy = 85 + Math.random() * 15;
+ const time = 8 + Math.random() * 15;
+ const charTimings = [];
+ const keypressHistory = [];
+ for (let i = 0; i < text.length; i++) {
+ const t = (i / text.length) * time;
+ const isCorrect = Math.random() * 100 < accuracy;
+ charTimings.push({ time: t, isCorrect, index: i });
+ keypressHistory.push({ time: t, isCorrect, index: i });
+ }
+ const incorrectChars = charTimings.filter(c => !c.isCorrect).length;
+ const stats = {
+ wpm, rawWpm: wpm * 1.08, accuracy, time,
+ correctChars: text.length - incorrectChars, incorrectChars, totalChars: text.length,
+ currentStreak: 0, bestStreak: Math.floor(text.length / 3),
+ };
+ showEndScreen({ stats, text, userInput: text, charTimings, keypressHistory, modeKey: null });
+ }
+
+ function showStats() {
+ teardown();
+ setScreen('stats');
+ cleanupScreen = renderStatsScreen(root, { onBack: showMainMenu, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer, onShowStore: showStore });
+ }
+
+ function showPlaceholder(title, description) {
+ teardown();
+ setScreen('placeholder');
+ cleanupScreen = renderPlaceholderScreen(root, { title, description, onBack: showMainMenu, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer, onShowStore: showStore });
+ }
+
+ function showAccount() {
+ teardown();
+ setScreen('account');
+ cleanupScreen = renderAccountScreen(root, { onBack: showMainMenu, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer, onShowStore: showStore });
+ }
+
+ function showLeaderboard() {
+ teardown();
+ setScreen('leaderboard');
+ cleanupScreen = renderLeaderboardScreen(root, { onBack: showMainMenu, onShowPublicProfile: showPublicProfile, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer, onShowStore: showStore });
+ }
+
+ function showPublicProfile(username) {
+ teardown();
+ setScreen('profile');
+ cleanupScreen = renderPublicProfileScreen(root, { username, onBack: showLeaderboard, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer, onShowStore: showStore });
+ }
+
+ function showFriends() {
+ teardown();
+ setScreen('friends');
+ cleanupScreen = renderFriendsScreen(root, { onBack: showMainMenu, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowMultiplayer: showMultiplayer, onShowStore: showStore });
+ }
+
+ function showMultiplayer() {
+ teardown();
+ setScreen('multiplayer', () => !!document.querySelector('.mp-opponents'));
+ cleanupScreen = renderMultiplayerScreen(root, { onBack: showMainMenu, onFinish: showEndScreen, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowStore: showStore });
+ }
+
+ function showStore() {
+ teardown();
+ setScreen('store');
+ cleanupScreen = renderStoreScreen(root, { onBack: showMainMenu, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer });
+ }
+
+ function showLyrics() {
+ teardown();
+ setScreen('lyrics');
+ cleanupScreen = renderLyricsScreen(root, {
+ onBack: showMainMenu,
+ onLyricsReady: loaded => {
+ customText = loaded;
+ session = { type: 'custom', customIndex: 0 };
+ startGame();
+ },
+ onShowStats: showStats,
+ onShowPlaceholder: showPlaceholder,
+ onShowAccount: showAccount,
+ onShowLeaderboard: showLeaderboard,
+ onShowFriends: showFriends,
+ onShowMultiplayer: showMultiplayer,
+ onShowStore: showStore,
+ });
+ }
+
+ async function startGame() {
+ teardown();
+ let content;
+ let attribution;
+ let language;
+ let progress;
+ let timeLimit;
+ // Custom text has no stable, repeatable challenge to compare against,
+ // so it's excluded from personal-best tracking (stays undefined).
+ let modeKey;
+
+ if (session.type === 'custom' && customText) {
+ const chunk = customText.chunks[session.customIndex];
+ content = chunk.content;
+ attribution = customText.name;
+ language = customText.language;
+ progress = `${customText.name} ยท segment ${session.customIndex + 1}/${customText.chunks.length}`;
+ } else if (selectedCategory === 'words') {
+ const s = getSettings();
+ content = generateWordStream(s.wordCount || 25, { punctuation: s.wordsPunctuation, numbers: s.wordsNumbers, tier: s.wordListTier, language: s.language });
+ modeKey = `words-${s.wordCount || 25}`;
+ } else if (selectedCategory === 'time') {
+ const s = getSettings();
+ timeLimit = s.timeDuration || 30;
+ content = generateWordStream(wordCountForDuration(timeLimit), { punctuation: s.wordsPunctuation, numbers: s.wordsNumbers, tier: s.wordListTier, language: s.language });
+ modeKey = `time-${timeLimit}`;
+ } else if (selectedCategory === 'zen') {
+ // No timer, no fixed length - type until you choose to stop (the
+ // visible Finish button in the typing screen). No modeKey: like
+ // custom text, there's no fixed-length challenge to compare a
+ // "personal best" against since every session covers a different
+ // amount of text.
+ const s = getSettings();
+ content = generateWordStream(800, { punctuation: s.wordsPunctuation, numbers: s.wordsNumbers, tier: s.wordListTier, language: s.language });
+ } else if (selectedCategory === 'practice') {
+ // Adaptive, personal, and different every time by nature - no
+ // modeKey, same reasoning as Zen/Custom: there's no fixed
+ // challenge here to compare a "personal best" against.
+ const s = getSettings();
+ const weakChars = getWeakChars();
+ content = generateWeakKeyStream(wordCountForDuration(30), weakChars, { punctuation: s.wordsPunctuation, numbers: s.wordsNumbers });
+ } else {
+ const item = getRandomTextItem(allTexts, selectedCategory);
+ content = item.content;
+ attribution = item.attribution;
+ // Code snippets highlight themselves; prose packs have no language
+ // and fall through as plain text.
+ language = item.language || undefined;
+ modeKey = `quote-${selectedCategory || 'random'}`;
+ }
+
+ try {
+ game = await createGame();
+ game.set_text(content);
+ } catch (err) {
+ console.error('Failed to start game:', err);
+ showMainMenu();
+ return;
+ }
+ setScreen('typing');
+ cleanupScreen = renderTypingGame(root, {
+ game,
+ text: content,
+ attribution,
+ language,
+ progress,
+ timeLimit,
+ modeKey,
+ zenMode: selectedCategory === 'zen' && !(session.type === 'custom' && customText),
+ onFinish: showEndScreen,
+ onMainMenu: showMainMenu,
+ onRestart: startGame,
+ onShowStats: showStats,
+ onShowPlaceholder: showPlaceholder,
+ onShowAccount: showAccount,
+ onShowLeaderboard: showLeaderboard,
+ onShowFriends: showFriends,
+ onShowMultiplayer: showMultiplayer,
+ onShowStore: showStore,
+ });
+ }
+
+ function playAgain() {
+ if (session.type === 'custom' && customText) {
+ if (session.customIndex + 1 < customText.chunks.length) {
+ session.customIndex += 1;
+ } else {
+ showMainMenu();
+ return;
+ }
+ }
+ startGame();
+ }
+
+ function showEndScreen(result) {
+ teardown();
+ setScreen('end');
+ cleanupScreen = renderEndScreen(root, {
+ ...result,
+ onPlayAgain: playAgain,
+ onMainMenu: showMainMenu,
+ onShowStats: showStats,
+ onShowPlaceholder: showPlaceholder,
+ onShowAccount: showAccount,
+ onShowLeaderboard: showLeaderboard,
+ onShowFriends: showFriends,
+ onShowMultiplayer: showMultiplayer,
+ onShowStore: showStore,
+ });
+ }
+
+ function startPassive() {
+ teardown();
+ if (!customText) {
+ showMainMenu();
+ return;
+ }
+ setScreen('passive');
+ cleanupScreen = renderPassiveMode(root, {
+ name: customText.name,
+ chunks: customText.chunks,
+ onExit: showMainMenu,
+ });
+ }
+
+ const { skipMenu, favoriteMode } = getSettings();
+ const bootMode = favoriteMode || selectedCategory;
+ if (skipMenu && bootMode !== 'custom') {
+ selectedCategory = bootMode;
+ try { localStorage.setItem('typerpunk:last_mode', bootMode); } catch {}
+ session = { type: 'random', customIndex: 0 };
+ startGame();
+ } else {
+ showMainMenu();
+ }
+}