diff options
| author | srdusr <[email protected]> | 2025-09-11 21:53:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-09-11 21:53:00 +0200 |
| commit | a726d9f5fb56e1fd7983c5ac806d408ad78daa86 (patch) | |
| tree | 4129a058e2a9e8fa4e0a51e3ed943b746750c3cf /web/src/customText.js | |
| parent | 8c95e90fa54db565919ec323818f77e7256812c8 (diff) | |
| download | typerpunk-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/customText.js')
| -rw-r--r-- | web/src/customText.js | 155 |
1 files changed, 155 insertions, 0 deletions
diff --git a/web/src/customText.js b/web/src/customText.js new file mode 100644 index 0000000..dc74541 --- /dev/null +++ b/web/src/customText.js @@ -0,0 +1,155 @@ +const LANGUAGE_BY_EXT = { + js: 'javascript', jsx: 'javascript', mjs: 'javascript', ts: 'javascript', tsx: 'javascript', + py: 'python', + rs: 'rust', + c: 'clike', h: 'clike', cpp: 'clike', hpp: 'clike', cc: 'clike', java: 'clike', go: 'clike', cs: 'clike', + sh: 'shell', bash: 'shell', zsh: 'shell', +}; + +const KEYWORDS = { + javascript: new Set(['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'class', 'import', 'export', 'from', 'new', 'this', 'async', 'await', 'try', 'catch', 'switch', 'case', 'break', 'continue', 'typeof', 'null', 'undefined', 'true', 'false']), + python: new Set(['def', 'return', 'if', 'elif', 'else', 'for', 'while', 'class', 'import', 'from', 'as', 'try', 'except', 'with', 'lambda', 'None', 'True', 'False', 'pass', 'break', 'continue', 'yield', 'self']), + rust: new Set(['fn', 'let', 'mut', 'return', 'if', 'else', 'for', 'while', 'loop', 'match', 'struct', 'enum', 'impl', 'trait', 'pub', 'use', 'mod', 'self', 'Self', 'true', 'false', 'const', 'static']), + clike: new Set(['int', 'float', 'double', 'char', 'void', 'if', 'else', 'for', 'while', 'return', 'struct', 'class', 'public', 'private', 'static', 'const', 'new', 'true', 'false', 'null']), + shell: new Set(['if', 'then', 'else', 'fi', 'for', 'do', 'done', 'while', 'function', 'echo', 'export', 'local']), +}; + +const LINE_COMMENT = { javascript: '//', rust: '//', clike: '//', shell: '#', python: '#' }; + +export function languageForFilename(filename) { + const ext = (filename || '').split('.').pop().toLowerCase(); + return LANGUAGE_BY_EXT[ext] || null; +} + +export function highlightClasses(text, language) { + const classes = new Array(text.length).fill(null); + if (!language || !KEYWORDS[language]) return classes; + const keywords = KEYWORDS[language]; + const commentMarker = LINE_COMMENT[language]; + const tokenRegex = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`|\/\/[^\n]*|#[^\n]*|\b\d+(?:\.\d+)?\b|[A-Za-z_][A-Za-z0-9_]*)/g; + let match; + while ((match = tokenRegex.exec(text)) !== null) { + const token = match[0]; + const start = match.index; + let cls = null; + if (token[0] === '"' || token[0] === "'" || token[0] === '`') { + cls = 'syn-string'; + } else if ((token.startsWith('//') || token.startsWith('#')) && token.startsWith(commentMarker || '\0')) { + cls = 'syn-comment'; + } else if (/^\d/.test(token)) { + cls = 'syn-number'; + } else if (keywords.has(token)) { + cls = 'syn-keyword'; + } + if (cls) { + for (let i = 0; i < token.length; i++) classes[start + i] = cls; + } + } + return classes; +} + +function splitLong(str, maxLen = 400) { + if (str.length <= maxLen) return [str]; + const parts = []; + let start = 0; + while (start < str.length) { + const end = Math.min(start + maxLen, str.length); + const slice = str.slice(start, end); + const lastNewline = slice.lastIndexOf('\n'); + const lastSpace = slice.lastIndexOf(' '); + const minCut = Math.floor(maxLen * 0.4); + const cut = lastNewline > minCut ? lastNewline + 1 : (lastSpace > minCut ? lastSpace + 1 : slice.length); + parts.push(str.slice(start, start + cut)); + start += cut; + } + return parts; +} + +export function chunkPlainText(raw) { + const normalized = raw.replace(/\r\n/g, '\n').trim(); + if (!normalized) return []; + const paragraphs = normalized.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean); + const source = paragraphs.length > 1 ? paragraphs : normalized.split('\n').map(l => l.trim()).filter(Boolean); + const chunks = []; + for (const block of source) { + for (const piece of splitLong(block)) { + const trimmed = piece.trim(); + if (trimmed) chunks.push({ content: trimmed, time: null }); + } + } + return chunks; +} + +function timeToSeconds(str) { + // "00:00:01,000" / "00:00:01.000" (h:mm:ss) or "00:12.34" (mm:ss, lrc-style) + const parts = str.replace(',', '.').split(':').map(Number); + if (parts.some(Number.isNaN)) return null; + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; + if (parts.length === 2) return parts[0] * 60 + parts[1]; + return parts[0] ?? null; +} + +function parseSrt(raw) { + const blocks = raw.replace(/\r\n/g, '\n').trim().split(/\n\s*\n/); + const chunks = []; + for (const block of blocks) { + const lines = block.split('\n').filter(Boolean); + let i = 0; + if (/^\d+$/.test((lines[i] || '').trim())) i += 1; + let time = null; + const timing = lines[i] || ''; + const startMatch = timing.match(/(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->/); + if (startMatch) { + time = timeToSeconds(startMatch[1]); + i += 1; + } + const text = lines.slice(i).join(' ').replace(/<[^>]+>/g, '').trim(); + if (text) chunks.push({ content: text, time }); + } + return chunks; +} + +function parseVtt(raw) { + const body = raw.replace(/\r\n/g, '\n').replace(/^WEBVTT[^\n]*\n/, '').trim(); + const blocks = body.split(/\n\s*\n/); + const chunks = []; + for (const block of blocks) { + const lines = block.split('\n').filter(Boolean); + let i = 0; + if (lines[i] && !/-->/.test(lines[i])) i += 1; // optional cue identifier + let time = null; + const timing = lines[i] || ''; + const startMatch = timing.match(/(\d{2}:)?(\d{2}:\d{2}[.,]\d{3})\s*-->/); + if (startMatch) { + time = timeToSeconds((startMatch[1] || '') + startMatch[2]); + i += 1; + } + const text = lines.slice(i).join(' ').replace(/<[^>]+>/g, '').trim(); + if (text) chunks.push({ content: text, time }); + } + return chunks; +} + +function parseLrc(raw) { + const lines = raw.replace(/\r\n/g, '\n').split('\n'); + const chunks = []; + for (const line of lines) { + const match = line.match(/^((?:\[\d{2}:\d{2}(?:\.\d{2,3})?\])+)(.*)$/); + if (!match) continue; + const text = match[2].trim(); + if (!text) continue; + const firstStamp = match[1].match(/\[(\d{2}:\d{2}(?:\.\d{2,3})?)\]/); + const time = firstStamp ? timeToSeconds(firstStamp[1]) : null; + chunks.push({ content: text, time }); + } + return chunks; +} + +export function parseCustomContent(raw, filename) { + const ext = (filename || '').split('.').pop().toLowerCase(); + if (ext === 'srt') return { chunks: parseSrt(raw), language: null, timed: true }; + if (ext === 'vtt') return { chunks: parseVtt(raw), language: null, timed: true }; + if (ext === 'lrc') return { chunks: parseLrc(raw), language: null, timed: true }; + const language = languageForFilename(filename); + return { chunks: chunkPlainText(raw), language, timed: false }; +} |