srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/app.js
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-09-11 21:53:00 +0200
committersrdusr <[email protected]>2025-09-11 21:53:00 +0200
commita726d9f5fb56e1fd7983c5ac806d408ad78daa86 (patch)
tree4129a058e2a9e8fa4e0a51e3ed943b746750c3cf /web/src/app.js
parent8c95e90fa54db565919ec323818f77e7256812c8 (diff)
downloadtyperpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.tar.gz
typerpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.zip
Add multiplayer bots, typing languages, and rework the UI layout
Multiplayer - Quick match: POST /api/multiplayer/quickmatch returns whichever room is still filling, or opens one. Players never see a room code; joining by code stays for racing specific people. - Bots fill quick-match rooms after a short wait so a new game is never an empty lobby. They only ever join quick-match rooms, never a room opened by code. One or two per room, drawn from separate ~40 and ~80 WPM tiers so two bots are never near each other's pace, and they stall to correct mistakes rather than typing a clean straight line. - Live player count via GET /api/multiplayer/online, shown on the Multiplayer control and under the main menu's Multiplayer button. - Per-racer colours: you are the theme accent, opponents take distinct hues that stay the same from lobby to race. - The countdown no longer holds the room lock for its full three seconds, which is what reset clients mid-countdown. Typing languages - 16 languages for the generated-word modes, each with its own high-frequency vocabulary rather than a translation of the English list. - Picker in the top-right rail; non-English uses its own list at every difficulty tier instead of falling back to English words. Fix UTF-8 accuracy in the game core - update_game_state mixed byte and character counts: total_characters_typed accumulated byte-length deltas while total_correct_characters compared a char index against that byte count. Equal on ASCII, so it went unnoticed; a correctly typed Spanish passage scored 6%. The old byte slicing would also have panicked if an index landed inside a multi-byte character. Rewritten char-based, with regression tests. Programming mode - Replaced prose about programming with real code: 26 syntax-highlighted snippets across JavaScript, Python, Rust, C/Go/Java and shell. Single-line by necessity, since the typing input is a single-line field. Layout and readability - One icon rail arrangement on every screen: Settings/Store under the wordmark, Language/Theme/Friends/Account top-right, Stats/Leaderboard/ Multiplayer bottom-right. - Main menu: mode picker moved out of the Single Player button, which it was notching a divider through and pushing the label off-centre. - Escape returns to the menu, closing any open popover first, and confirms before abandoning a live race. - Split --text-color and --sub-color per theme; they shared one value that measured 3.65:1 against the background, below the 4.5:1 body-text floor. - Semantic colours used in exactly one place each: gold for a personal best, amber for the race countdown and the mobile-result badge. - Passage now sits in the same place on the typing and end screens, and its column is a whole number of characters wide so wrapping cannot leave a permanent gap on the right. - End screen: keystrokes and a correct/wrong/extra/missed split, attribution carried over from the typing screen, and a graph with a separate error axis, axis titles including seconds, and smoothed lines.
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();
+ }
+}