Repository navigation
Expand file tree
/
Copy pathscript.js
More file actions
3354 lines (3095 loc) · 129 KB
/
Copy pathscript.js
File metadata and controls
3354 lines (3095 loc) · 129 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* TypeSpeed game logic. Plain browser script, loaded at the end of <body>.
*/
// --- DOM Elements ---
const quoteDisplayElement = document.getElementById('quote-display');
const quoteTextElement = document.getElementById('quote-text');
const typingContainer = document.querySelector('.typing-container');
const typingInputElement = document.getElementById('typing-input');
const timerElement = document.getElementById('timer');
const wpmElement = document.getElementById('wpm');
const accuracyElement = document.getElementById('accuracy');
const mistakesElement = document.getElementById('mistakes');
const highScoreElement = document.getElementById('high-score');
const resetButton = document.getElementById('reset-button');
const containerElement = document.querySelector('.container');
const categorySelect = document.getElementById('category-select');
const quickMode = document.getElementById('quick-mode');
const quickDuration = document.getElementById('quick-duration');
const quickWordCount = document.getElementById('quick-word-count');
const ghostBadge = document.getElementById('ghost-badge');
const storageWarning = document.getElementById('storage-warning');
const updateNotice = document.getElementById('update-notice');
const updateText = document.getElementById('update-text');
const dismissUpdateBtn = document.getElementById('dismiss-update-btn');
const versionLink = document.getElementById('version-link');
// Modals
const resultModal = document.getElementById('result-modal');
const settingsModal = document.getElementById('settings-modal');
const resultTitle = document.getElementById('result-title');
const modalWpmElement = document.getElementById('modal-wpm');
const modalAccuracyElement = document.getElementById('modal-accuracy');
const modalSourceElement = document.getElementById('modal-source');
const modalBestElement = document.getElementById('modal-best');
const modalRawElement = document.getElementById('modal-raw');
const modalCharsElement = document.getElementById('modal-chars');
const modalTimeElement = document.getElementById('modal-time');
const modalKeysElement = document.getElementById('modal-keys');
const practiceBtn = document.getElementById('practice-btn');
const replayBtn = document.getElementById('replay-btn');
const saveImageBtn = document.getElementById('save-image-btn');
const replayModal = document.getElementById('replay-modal');
const replayClock = document.getElementById('replay-clock');
const replayDisplay = document.getElementById('replay-display');
const replaySpeed = document.getElementById('replay-speed');
const replayPauseBtn = document.getElementById('replay-pause-btn');
const closeReplayBtn = document.getElementById('close-replay-btn');
const practiceNote = document.getElementById('practice-note');
const challengeBtn = document.getElementById('challenge-btn');
const modalChallengeElement = document.getElementById('modal-challenge');
const modalProgressElement = document.getElementById('modal-progress');
const dailyBtn = document.getElementById('daily-btn');
const challengeLinkRow = document.getElementById('challenge-link-row');
const challengeLinkInput = document.getElementById('challenge-link');
const shareBtn = document.getElementById('share-btn');
const playAgainBtn = document.getElementById('play-again-btn');
const settingsBtn = document.getElementById('settings-btn');
const closeSettingsBtn = document.getElementById('close-settings-btn');
// Custom text dialog
const customModal = document.getElementById('custom-modal');
const editCustomBtn = document.getElementById('edit-custom-btn');
const codeModal = document.getElementById('code-modal');
const editCodeBtn = document.getElementById('edit-code-btn');
const ownCodeInput = document.getElementById('own-code');
const codeFileBtn = document.getElementById('code-file-btn');
const codeFileInput = document.getElementById('code-file');
const codeFileStatus = document.getElementById('code-file-status');
const closeCodeBtn = document.getElementById('close-code-btn');
const closeCustomBtn = document.getElementById('close-custom-btn');
// Stats dialog
const statsModal = document.getElementById('stats-modal');
const statsBtn = document.getElementById('stats-btn');
const statsSummary = document.getElementById('stats-summary');
const statsTables = document.getElementById('stats-tables');
const statsHeatmap = document.getElementById('stats-heatmap');
const dailyChartContainer = document.getElementById('daily-chart-container');
const dailyChartTitle = document.getElementById('daily-chart-title');
const dailyChart = document.getElementById('daily-chart');
const dailyChartSummary = document.getElementById('daily-chart-summary');
const statsBestsBody = document.getElementById('stats-bests');
const statsRunsBody = document.getElementById('stats-runs');
const statsStatus = document.getElementById('stats-status');
const exportBtn = document.getElementById('export-btn');
const importBtn = document.getElementById('import-btn');
const importInput = document.getElementById('import-input');
const clearBtn = document.getElementById('clear-btn');
const closeStatsBtn = document.getElementById('close-stats-btn');
// Settings inputs
const themeSelector = document.getElementById('theme-selector');
const soundToggle = document.getElementById('sound-toggle-settings');
const soundPackSelect = document.getElementById('sound-pack-select');
const soundPreviewBtn = document.getElementById('sound-preview-btn');
const hardModeToggle = document.getElementById('hard-mode-toggle-settings');
const ghostModeToggle = document.getElementById('ghost-mode-toggle');
const paceSelect = document.getElementById('pace-select');
const paceWpmInput = document.getElementById('pace-wpm');
const fontSelect = document.getElementById('font-select');
const textSizeSelect = document.getElementById('text-size-select');
const lineSpacingSelect = document.getElementById('line-spacing-select');
const caretSelect = document.getElementById('caret-select');
const punctuationToggle = document.getElementById('punctuation-toggle');
const numbersToggle = document.getElementById('numbers-toggle');
const stopOnErrorToggle = document.getElementById('stop-on-error-toggle');
const layoutSelect = document.getElementById('layout-select');
const customTextInput = document.getElementById('custom-text');
const autoIndentToggle = document.getElementById('auto-indent-toggle');
// Main-screen options shown only for the categories they apply to
const punctuationOption = document.getElementById('punctuation-option');
const numbersOption = document.getElementById('numbers-option');
const wordLanguageSelect = document.getElementById('word-language-select');
const autoIndentOption = document.getElementById('auto-indent-option');
const codeLanguageSelect = document.getElementById('code-language-select');
const lessonSelect = document.getElementById('lesson-select');
const bookSelect = document.getElementById('book-select');
const capsWarning = document.getElementById('caps-warning');
const ghostStatus = document.getElementById('ghost-status');
// Chart
const chartContainer = document.getElementById('progress-chart-container');
const runChartContainer = document.getElementById('run-chart-container');
const runChart = document.getElementById('run-chart');
const progressChartTitle = document.getElementById('progress-chart-title');
const progressChart = document.getElementById('progress-chart');
// --- Storage ---
const STORAGE_KEYS = {
history: 'typingGameHistory',
bests: 'typingGameBests',
settings: 'typingGameSettings',
keyStats: 'typingGameKeyStats',
daily: 'typingGameDaily',
lessons: 'typingGameLessons',
books: 'typingGameBooks',
};
function loadJSON(key, fallback) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch (err) {
console.warn(`Ignoring unreadable localStorage value for ${key}`, err);
return fallback;
}
}
// A failed save (storage full, or blocked in some private modes) shows a
// notice that stays until the page is reloaded.
function saveJSON(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (err) {
console.warn(`Could not save ${key} to localStorage`, err);
storageWarning.classList.remove('hidden');
}
}
// Keystroke timings, {t: ms since start, i: character index} per input
// event, are stored as a flat list of differences, [t1, i1, t2 - t1,
// i2 - i1, ...], about a third of the size in JSON. Bests store them this
// way and challenge links carry them. Bests saved before this hold the
// objects themselves; bestStrokes reads both.
function encodeStrokes(strokes) {
const deltas = [];
let t = 0;
let i = 0;
for (const stroke of strokes) {
deltas.push(stroke.t - t, stroke.i - i);
t = stroke.t;
i = stroke.i;
}
return deltas;
}
function decodeStrokes(deltas) {
const strokes = [];
let t = 0;
let i = 0;
for (let n = 0; n + 1 < deltas.length; n += 2) {
t += deltas[n];
i += deltas[n + 1];
strokes.push({ t, i });
}
return strokes;
}
const bestStrokes = (best) =>
typeof best.keystrokes[0] === 'object' ? best.keystrokes : decodeStrokes(best.keystrokes);
// --- Game State ---
const DURATIONS = [15, 30, 60];
const WORD_COUNTS = [10, 25, 50];
const DEFAULT_SETTINGS = {
mode: 'time', // 'time' or 'words'
duration: 60, // seconds, time mode
wordCount: 10, // words mode
category: 'common', // 'common', 'weak', or a key of QUOTES (texts.js)
isGhostEnabled: false,
pace: 'off', // pace caret: 'off', 'target', 'average', or 'last'
paceWpm: 60, // speed of the pace caret when pace is 'target'
isHardMode: false,
isSoundEnabled: true,
soundPack: 'tones', // one of SOUND_PACK_NAMES
hasPunctuation: false, // common words only
hasNumbers: false, // common words only
wordLanguage: 'en', // common words only: 'en' or a key of WORD_LISTS (wordlists.js)
isStopOnError: false, // wrong keys do not advance the caret
layout: 'qwerty', // key of LAYOUTS; keys are remapped when it is not qwerty
customText: '', // passage for the Custom Text category, already normalized
codeLanguage: 'javascript', // key of CODE_SNIPPETS (texts.js), or OWN_CODE
ownCode: '', // code for the "Your code" language, already normalized
isAutoIndent: true, // fill in leading spaces after a new line in code
theme: 'system', // 'system', 'dark', 'light', or 'contrast'
passageFont: 'mono', // values of these four in PASSAGE_STYLES
textSize: 'medium',
lineSpacing: 'normal',
caretStyle: 'underline',
lastSeenVersion: '', // VERSION when the page was last opened
lessonStage: 1, // stage number in the Lessons category, from 1
bookId: 'alice', // id in BOOKS, for the Books category
};
const THEMES = ['system', 'dark', 'light', 'contrast'];
const PACES = ['off', 'target', 'average', 'last'];
// --- Lessons ---
// Stages for learning to touch type. Each adds keys to the ones before it.
// A run passes a stage at LESSON_PASS accuracy and WPM and unlocks the next.
const ALL_LETTERS = 'abcdefghijklmnopqrstuvwxyz';
const LESSON_STAGES = [
{ name: 'Home row', letters: 'asdfghjkl' },
{ name: 'Top row', letters: 'asdfghjklqwertyuiop' },
{ name: 'Bottom row', letters: ALL_LETTERS },
{ name: 'Capitals', letters: ALL_LETTERS, capitals: true },
{ name: 'Punctuation', letters: ALL_LETTERS, punctuation: true },
{ name: 'Numbers', letters: ALL_LETTERS, punctuation: true, numbers: true },
];
const LESSON_PASS = { accuracy: 95, wpm: 20 };
// --- Books ---
// Public-domain books in books/<id>.json, built by scripts/build-books.mjs
// as chapters of pages. A Books run is one page; the next run continues
// with the next page. The files load from the site when a book is picked.
const BOOKS = [
{ id: 'alice', title: "Alice's Adventures in Wonderland", author: 'Lewis Carroll' },
{ id: 'carol', title: 'A Christmas Carol', author: 'Charles Dickens' },
{ id: 'time-machine', title: 'The Time Machine', author: 'H. G. Wells' },
{ id: 'sherlock', title: 'The Adventures of Sherlock Holmes', author: 'Arthur Conan Doyle' },
{ id: 'frankenstein', title: 'Frankenstein', author: 'Mary Shelley' },
{ id: 'pride', title: 'Pride and Prejudice', author: 'Jane Austen' },
];
// --- Layouts ---
// Each layout as the characters on the physical keys of a US keyboard, row
// by row: Minus and Equal, then the top, home, and bottom letter rows. The
// key under a character is found by its KeyboardEvent.code in LAYOUT_CODES.
const LAYOUTS = {
qwerty: ['-=', 'qwertyuiop[]', "asdfghjkl;'", 'zxcvbnm,./'],
colemak: ['-=', 'qwfpgjluy;[]', "arstdhneio'", 'zxcvbkm,./'],
'colemak-dh': ['-=', 'qwfpbjluy;[]', "arstgmneio'", 'zxcdvkh,./'],
dvorak: ['[]', "',.pyfgcrl/=", 'aoeuidhtns-', ';qjkxbmwvz'],
workman: ['-=', 'qdrwbjfup;[]', "ashtgyneoi'", 'zxmcvkl,./'],
};
const LAYOUT_CODES = [
['Minus', 'Equal'],
['KeyQ', 'KeyW', 'KeyE', 'KeyR', 'KeyT', 'KeyY', 'KeyU', 'KeyI', 'KeyO', 'KeyP'],
['KeyA', 'KeyS', 'KeyD', 'KeyF', 'KeyG', 'KeyH', 'KeyJ', 'KeyK', 'KeyL', 'Semicolon', 'Quote'],
['KeyZ', 'KeyX', 'KeyC', 'KeyV', 'KeyB', 'KeyN', 'KeyM', 'Comma', 'Period', 'Slash'],
];
LAYOUT_CODES[1].push('BracketLeft', 'BracketRight');
const SOUND_PACK_NAMES = ['tones', 'mechanical', 'typewriter', 'soft']; // keys of SOUND_PACKS
// How the passage looks. Each is set as a data attribute of the same name on
// <html>, which style.css reads; theme.js does the same before first paint.
const PASSAGE_STYLES = {
passageFont: ['mono', 'sans', 'dyslexic'],
textSize: ['medium', 'small', 'large', 'xlarge'],
lineSpacing: ['normal', 'compact', 'wide'],
caretStyle: ['underline', 'line', 'block'],
};
const PACE_MIN = 10;
const PACE_MAX = 300;
const CUSTOM_TEXT_LIMIT = 2000;
// Typographic quotes, dashes, and ellipses become the keys people can type,
// and all whitespace runs become single spaces.
function normalizeCustomText(text) {
return String(text)
.replace(/[\u2018\u2019\u201a\u2032]/g, "'")
.replace(/[\u201c\u201d\u201e\u2033]/g, '"')
.replace(/[\u2013\u2014\u2212]/g, '-')
.replace(/\u2026/g, '...')
.replace(/\s+/g, ' ')
.trim()
.slice(0, CUSTOM_TEXT_LIMIT)
.trim();
}
// Short FNV-1a hash, so each custom text keeps its own best scores.
function textHash(text) {
let hash = 0x811c9dc5;
for (let n = 0; n < text.length; n++) {
hash ^= text.charCodeAt(n);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(36);
}
const CATEGORIES = ['common', 'weak', 'lessons', 'books', 'custom', 'code', ...Object.keys(QUOTES)];
const OWN_CODE = 'own';
const CODE_LANGUAGES = [...Object.keys(CODE_SNIPPETS), OWN_CODE];
const OWN_CODE_LIMIT = 20000; // characters
const OWN_CODE_LINE_MAX = 100;
const OWN_CODE_CHUNK_LINES = 15;
// Code keeps its line breaks and indentation, unlike custom text.
function normalizeOwnCode(text) {
const lines = String(text)
.replace(/\r\n?/g, '\n')
.replace(/\t/g, ' ')
.replace(/[\u2018\u2019]/g, "'")
.replace(/[\u201c\u201d]/g, '"')
.replace(/[\u2013\u2014]/g, '-')
.split('\n')
.map((line) => line.replace(/\s+$/, ''))
.filter((line) => line.length <= OWN_CODE_LINE_MAX);
let code = lines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.replace(/^\n+|\n+$/g, '');
if (code.length > OWN_CODE_LIMIT) {
code = code.slice(0, OWN_CODE_LIMIT);
code = code.slice(0, code.lastIndexOf('\n')).replace(/\n+$/, '');
}
return code;
}
// Blocks between blank lines, cut to OWN_CODE_CHUNK_LINES lines, each moved
// left so its least indented line starts at the margin.
function ownCodeChunks(code) {
const chunks = [];
for (const block of code.split(/\n\n+/)) {
const lines = block.split('\n');
for (let n = 0; n < lines.length; n += OWN_CODE_CHUNK_LINES) {
const chunk = lines.slice(n, n + OWN_CODE_CHUNK_LINES);
const indent = Math.min(...chunk.map((line) => line.match(/^ */)[0].length));
chunks.push(chunk.map((line) => line.slice(indent)));
}
}
return chunks;
}
// The language whose snippets a Code run uses: "Your code" with nothing
// saved uses JavaScript.
const codeLanguage = () =>
gameState.codeLanguage === OWN_CODE && !gameState.ownCode
? 'javascript'
: gameState.codeLanguage;
const codeSnippets = () =>
codeLanguage() === OWN_CODE ? ownCodeChunks(gameState.ownCode) : CODE_SNIPPETS[codeLanguage()];
// Custom text and your own code stay on this device: no challenge links.
// Books pages are not drawn from a seed, so they cannot be either.
const isLocalText = () =>
passageCategory() === 'custom' ||
passageCategory() === 'books' ||
(passageCategory() === 'code' && codeLanguage() === OWN_CODE);
function sanitizeSettings(saved) {
const settings = { ...DEFAULT_SETTINGS };
if (!saved || typeof saved !== 'object') return settings;
if (saved.mode === 'words') settings.mode = 'words';
if (DURATIONS.includes(saved.duration)) settings.duration = saved.duration;
if (WORD_COUNTS.includes(saved.wordCount)) settings.wordCount = saved.wordCount;
if (CATEGORIES.includes(saved.category)) settings.category = saved.category;
if (CODE_LANGUAGES.includes(saved.codeLanguage)) settings.codeLanguage = saved.codeLanguage;
if (THEMES.includes(saved.theme)) settings.theme = saved.theme;
if (PACES.includes(saved.pace)) settings.pace = saved.pace;
if (BOOKS.some((book) => book.id === saved.bookId)) settings.bookId = saved.bookId;
if (Object.hasOwn(WORD_LISTS, saved.wordLanguage)) settings.wordLanguage = saved.wordLanguage;
if (Number.isInteger(saved.lessonStage) && LESSON_STAGES[saved.lessonStage - 1]) {
settings.lessonStage = saved.lessonStage;
}
if (Object.hasOwn(LAYOUTS, saved.layout)) settings.layout = saved.layout;
if (
typeof saved.lastSeenVersion === 'string' &&
/^\d+\.\d+\.\d+$/.test(saved.lastSeenVersion)
) {
settings.lastSeenVersion = saved.lastSeenVersion;
}
if (SOUND_PACK_NAMES.includes(saved.soundPack)) settings.soundPack = saved.soundPack;
for (const [setting, values] of Object.entries(PASSAGE_STYLES)) {
if (values.includes(saved[setting])) settings[setting] = saved[setting];
}
if (Number.isInteger(saved.paceWpm) && saved.paceWpm >= PACE_MIN && saved.paceWpm <= PACE_MAX) {
settings.paceWpm = saved.paceWpm;
}
if (typeof saved.ownCode === 'string') settings.ownCode = normalizeOwnCode(saved.ownCode);
if (typeof saved.customText === 'string') {
settings.customText = normalizeCustomText(saved.customText);
}
for (const flag of [
'isGhostEnabled',
'isHardMode',
'isSoundEnabled',
'hasPunctuation',
'hasNumbers',
'isStopOnError',
'isAutoIndent',
]) {
if (typeof saved[flag] === 'boolean') settings[flag] = saved[flag];
}
return settings;
}
const gameState = sanitizeSettings(loadJSON(STORAGE_KEYS.settings, null));
// Per-run data. Reset by resetGame().
const runtime = {
isStarted: false,
startTime: 0,
endTime: 0,
interval: null,
timer: 0, // seconds shown in the HUD
quoteText: '', // text currently on screen
quoteSource: '', // attribution of the passage on screen, if known
quoteBase: 0, // characters in quotes already completed this run (time mode)
baseCorrect: 0, // correct characters in completed quotes
baseTyped: 0, // typed characters in completed quotes
correctChars: 0, // correct characters across the whole run
typedChars: 0, // typed characters across the whole run
previousValue: '', // input value after the last input event
keystrokes: 0, // characters inserted, including ones later deleted
mistakes: 0, // inserted characters that did not match
ghostStrokes: [], // {t: ms since start, i: character index} for Ghost Mode
keyStats: {}, // {[expected character]: {attempts, misses}} for this run
seed: 0, // passages for the run are drawn from this seed
random: Math.random, // seeded generator, replaced in resetGame
weakKeys: [], // letters the Weak Keys category uses this run
passageHash: '', // textHash of the run's first passage
textChanged: false, // the replayed run was recorded on a different first passage
ghostIndex: 0, // character index the ghost has reached
challenge: null, // decoded challenge link, kept across resets until a run uses it
indent: [], // per character of quoteText: true for indentation Skip indentation fills in
ghostTimeouts: [],
grid: [], // {typed, wrong} per GRID_SECONDS of the run, for the daily's share grid
replay: { passages: [], events: [] }, // the run's passages and {t, p, value} per input event
playback: null, // replay dialog state: {interval, position, last, speed, shown}
cardFile: null, // result image File, built when the result dialog opens
challengeUrl: '', // link for the finished run, built when the result dialog opens
books: {}, // book files loaded so far, by id; kept across resets
bookError: '', // id of a book that could not be loaded
};
const HISTORY_LIMIT = 500;
const CHART_POINTS = 10;
let runHistory = loadJSON(STORAGE_KEYS.history, []);
if (!Array.isArray(runHistory)) runHistory = [];
// Best run per settings combination:
// { [settingsKey]: { wpm, keystrokes, seed, textHash, weakKeys? } }.
let bests = loadJSON(STORAGE_KEYS.bests, {});
if (!bests || typeof bests !== 'object' || Array.isArray(bests)) bests = {};
// Attempts and misses per expected character across all recorded runs.
let keyStats = loadJSON(STORAGE_KEYS.keyStats, {});
if (!keyStats || typeof keyStats !== 'object' || Array.isArray(keyStats)) keyStats = {};
// Where each book continues: { [book id]: { chapter, page } }, from 0.
let bookProgress = validBooks(loadJSON(STORAGE_KEYS.books, null));
// The highest lesson stage unlocked: { unlocked }.
let lessonProgress = validLessons(loadJSON(STORAGE_KEYS.lessons, null));
// The first recorded result of each daily: { [day number]: { wpm, accuracy, grid } }.
let dailyResults = loadJSON(STORAGE_KEYS.daily, {});
if (!dailyResults || typeof dailyResults !== 'object' || Array.isArray(dailyResults)) {
dailyResults = {};
}
// Keys missed most often, by miss rate, among keys tried at least
// WEAK_MIN_ATTEMPTS times: letters (lowercased), digits, and symbols, but not
// space or new line. Used by the Weak Keys category.
const WEAK_MIN_ATTEMPTS = 5;
const WEAK_KEY_COUNT = 3;
const isWeakKeyCandidate = (key) => /^[!-~]$/.test(key); // printable ASCII, no space
// Key stats are kept per layout: QWERTY under the character, the others
// under '<layout>:<character>'.
const statKey = (character) =>
gameState.layout === 'qwerty' ? character : `${gameState.layout}:${character}`;
// The current layout's key stats, keyed by character.
function layoutStats() {
const prefix = gameState.layout === 'qwerty' ? '' : `${gameState.layout}:`;
const result = {};
for (const [key, stat] of Object.entries(keyStats)) {
if (!key.startsWith(prefix)) continue;
const character = key.slice(prefix.length);
if (character.length === 1) result[character] = stat;
}
return result;
}
function weakestKeys() {
const keys = Object.entries(layoutStats())
.filter(([key, stat]) => isWeakKeyCandidate(key) && stat && stat.misses > 0)
.filter(([, stat]) => stat.attempts >= WEAK_MIN_ATTEMPTS)
.sort(([, a], [, b]) => b.misses / b.attempts - a.misses / a.attempts)
.map(([key]) => key.toLowerCase());
return [...new Set(keys)].slice(0, WEAK_KEY_COUNT);
}
// Custom Text with no text saved uses common words, and is recorded as them.
function passageCategory() {
return gameState.category === 'custom' && !gameState.customText ? 'common' : gameState.category;
}
// Runs only compete with runs of the same mode, length, category, and
// rules. The defaults (Stop on error off, Skip indentation on) add nothing.
// Books runs end with the page, whatever the mode.
const isTimed = () => gameState.mode === 'time' && gameState.category !== 'books';
function settingsKey() {
if (gameState.category === 'books') return `page:book:books-${gameState.bookId}`;
const length = gameState.mode === 'time' ? `${gameState.duration}s` : `${gameState.wordCount}w`;
let category = passageCategory();
if (category === 'custom') category = `custom-${textHash(gameState.customText)}`;
if (category === 'lessons') category = `lessons-${gameState.lessonStage}`;
if (category === 'code') {
const language = codeLanguage();
category =
language === OWN_CODE ? `code-own-${textHash(gameState.ownCode)}` : `code-${language}`;
}
if (category === 'common') {
if (gameState.wordLanguage !== 'en') category += `-${gameState.wordLanguage}`;
if (gameState.hasPunctuation) category += '+punctuation';
if (gameState.hasNumbers) category += '+numbers';
}
if (category.startsWith('code-') && !gameState.isAutoIndent) category += '+noindent';
if (gameState.isStopOnError) category += '+stop';
if (gameState.layout !== 'qwerty') category += `+${gameState.layout}`;
return `${gameState.mode}:${length}:${category}`;
}
function currentBest() {
const best = bests[settingsKey()];
if (!best || typeof best.wpm !== 'number' || !Array.isArray(best.keystrokes)) return null;
return best;
}
// --- Audio ---
// Each sound is a recipe: oscillator shape, start and end frequency,
// duration, and volume. A `noise` recipe plays white noise through a
// band-pass filter centered on `freq` instead of an oscillator. `steps`
// lists sub-recipes with start offsets in seconds, for sounds made of
// several parts. Each pack has a click, an error, and a success sound.
const chord = (freqs, gap, recipe) => freqs.map((freq, i) => ({ at: i * gap, freq, ...recipe }));
const SOUND_PACKS = {
tones: {
click: { wave: 'sine', freq: 800, toFreq: 400, duration: 0.05, volume: 0.05, jitter: 0.03 },
error: {
wave: 'sawtooth',
freq: 100,
toFreq: 80,
slide: 'linear',
duration: 0.15,
volume: 0.08,
},
// C5, E5, G5, C6
success: {
steps: chord([523.25, 659.25, 783.99, 1046.5], 0.1, {
wave: 'sine',
duration: 0.5,
volume: 0.05,
}),
},
},
// A switch: a short high click and a low thump as the key bottoms out.
mechanical: {
click: {
steps: [
{ noise: true, freq: 4000, duration: 0.025, volume: 0.25, jitter: 0.1 },
{ wave: 'sine', freq: 180, toFreq: 70, duration: 0.05, volume: 0.06, jitter: 0.05 },
],
},
error: {
steps: [
{ noise: true, freq: 1200, duration: 0.05, volume: 0.25 },
{ wave: 'square', freq: 110, toFreq: 70, duration: 0.12, volume: 0.04 },
],
},
success: {
steps: [
...[0, 0.08, 0.16].map((at) => ({
at,
noise: true,
freq: 4000,
duration: 0.025,
volume: 0.25,
})),
{ at: 0.3, wave: 'sine', freq: 880, duration: 0.4, volume: 0.04 },
],
},
},
// A typebar strike, a dull clunk for a mistake, and the carriage bell.
typewriter: {
click: {
steps: [
{ noise: true, freq: 2200, duration: 0.04, volume: 0.3, jitter: 0.1 },
{ wave: 'triangle', freq: 1400, toFreq: 900, duration: 0.02, volume: 0.03 },
],
},
error: {
steps: [
{ noise: true, freq: 600, duration: 0.08, volume: 0.3 },
{ wave: 'triangle', freq: 90, toFreq: 60, duration: 0.1, volume: 0.06 },
],
},
success: {
steps: [
{ wave: 'sine', freq: 2093, duration: 0.9, volume: 0.06 },
{ wave: 'sine', freq: 4186, duration: 0.5, volume: 0.02 },
],
},
},
// Quiet, low, and rounded.
soft: {
click: { wave: 'sine', freq: 420, toFreq: 300, duration: 0.04, volume: 0.03, jitter: 0.05 },
error: { wave: 'triangle', freq: 200, toFreq: 160, duration: 0.12, volume: 0.05 },
// C4, E4, G4
success: {
steps: chord([261.63, 329.63, 392], 0.12, {
wave: 'triangle',
duration: 0.5,
volume: 0.04,
}),
},
},
};
const MAX_VOICES = 16; // oldest voice is stopped beyond this
const GAIN_FLOOR = 0.0001; // exponential ramps cannot reach 0
// The context is created on the first sound, after a keystroke, so the
// browser does not start it suspended. Every voice goes through one master
// gain node. If Web Audio is missing or blocked, sounds are skipped.
const audio = { context: null, master: null, failed: false, voices: [], noise: null };
function getAudioContext() {
if (audio.failed) return null;
if (!audio.context) {
try {
const Ctx = window.AudioContext || window.webkitAudioContext;
audio.context = new Ctx();
audio.master = audio.context.createGain();
audio.master.connect(audio.context.destination);
} catch {
audio.failed = true;
audio.context = null;
return null;
}
}
if (audio.context.state === 'suspended') audio.context.resume().catch(() => {});
return audio.context;
}
// Half a second of white noise, made once and shared by every noise voice.
function noiseBuffer(ctx) {
if (!audio.noise) {
audio.noise = ctx.createBuffer(1, Math.round(ctx.sampleRate / 2), ctx.sampleRate);
const samples = audio.noise.getChannelData(0);
for (let n = 0; n < samples.length; n++) samples[n] = Math.random() * 2 - 1;
}
return audio.noise;
}
function playVoice(ctx, recipe, at) {
while (audio.voices.length >= MAX_VOICES) audio.voices.shift().stop();
// A few percent of random pitch keeps repeated clicks from sounding identical.
const jitter = recipe.jitter ? 1 + (Math.random() * 2 - 1) * recipe.jitter : 1;
const duration = recipe.duration > 0 ? recipe.duration : 0.1;
const gain = ctx.createGain();
gain.connect(audio.master);
// `pitch` is the oscillator's frequency, or for noise the filter's center.
let source;
let pitch;
const nodes = [gain];
if (recipe.noise) {
source = ctx.createBufferSource();
source.buffer = noiseBuffer(ctx);
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
source.connect(filter);
filter.connect(gain);
pitch = filter.frequency;
nodes.push(filter);
} else {
source = ctx.createOscillator();
source.type = recipe.wave || 'square';
source.connect(gain);
pitch = source.frequency;
}
nodes.push(source);
pitch.setValueAtTime(Math.max(recipe.freq * jitter, 1), at);
if (recipe.toFreq !== undefined) {
const target = Math.max(recipe.toFreq * jitter, 1);
if (recipe.slide === 'linear') pitch.linearRampToValueAtTime(target, at + duration);
else pitch.exponentialRampToValueAtTime(target, at + duration);
}
gain.gain.setValueAtTime(Math.max(recipe.volume ?? 0.08, GAIN_FLOOR), at);
gain.gain.exponentialRampToValueAtTime(GAIN_FLOOR, at + duration);
source.start(at);
source.stop(at + duration);
audio.voices.push(source);
source.onended = () => {
nodes.forEach((node) => node.disconnect());
const index = audio.voices.indexOf(source);
if (index !== -1) audio.voices.splice(index, 1);
};
}
// Plays the current pack's sound `delay` seconds from now. Never throws.
function playRecipe(name, delay = 0) {
const recipe = SOUND_PACKS[gameState.soundPack][name];
const ctx = getAudioContext();
if (!recipe || !ctx) return;
try {
const now = ctx.currentTime + delay;
for (const step of recipe.steps || [recipe]) playVoice(ctx, step, now + (step.at || 0));
} catch {
// A failed sound must not interrupt typing.
}
}
// Fire and forget: returns before touching Web Audio when sound is off.
function playSound(name) {
if (gameState.isSoundEnabled) playRecipe(name);
}
// The Play button in Settings: a few keys, a mistake, and the end of a run,
// whether or not sound is on.
function previewSoundPack() {
[0, 0.15, 0.3].forEach((delay) => playRecipe('click', delay));
playRecipe('error', 0.5);
playRecipe('success', 0.9);
}
// --- Random ---
// Small seeded generator (mulberry32), so the passages of a run can be
// rebuilt from its seed for Ghost Mode and challenge links.
function seededRandom(seed) {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function newSeed() {
return Math.floor(Math.random() * 4294967296);
}
// --- Text Selection ---
// Words per passage when Common Words runs in time mode. A new passage is
// loaded each time one is finished.
const COMMON_CHUNK = 25;
// Longest word, with its trailing space, that renderNewQuote groups into a
// .word. A phone screen fits about 20 characters per line.
const WORD_GROUP_MAX = 16;
let lastQuoteIndex = -1;
function pickQuote(categoryQuotes) {
let index;
do {
index = Math.floor(runtime.random() * categoryQuotes.length);
} while (categoryQuotes.length > 1 && index === lastQuoteIndex);
lastQuoteIndex = index;
return categoryQuotes[index];
}
// Random common words with no word twice in a row, plus optional
// punctuation (capitalized sentences, commas, full stops) and numbers.
// Only Common Words shows those options, so other callers turn them off.
// Common Words in the chosen language.
const commonWordList = () =>
gameState.wordLanguage === 'en' ? COMMON_WORDS : WORD_LISTS[gameState.wordLanguage];
function commonWords(
count,
list = commonWordList(),
{ punctuation = gameState.hasPunctuation, numbers = gameState.hasNumbers } = {}
) {
const words = [];
let previous = '';
let sentenceStart = true;
for (let n = 0; n < count; n++) {
let word;
do {
word = list[Math.floor(runtime.random() * list.length)];
} while (word === previous);
previous = word;
if (numbers && runtime.random() < 0.1) {
word = String(1 + Math.floor(runtime.random() * 9999));
}
if (punctuation) {
if (sentenceStart) word = word[0].toUpperCase() + word.slice(1);
sentenceStart = false;
const roll = runtime.random();
if (n === count - 1 || roll < 0.1) {
word += roll < 0.02 ? '?' : '.';
sentenceStart = true;
} else if (roll < 0.2) {
word += ',';
}
}
words.push(word);
}
return words.join(' ');
}
// Common words that contain at least one of the given letters. Falls back
// to the whole list when too few words match.
// Words that use only the stage's letters; the first stage adds
// LESSON_HOME_WORDS (texts.js). Capitals start about a third of the words.
function lessonText(count) {
const stage = LESSON_STAGES[gameState.lessonStage - 1];
const fits = (word) => [...word].every((character) => stage.letters.includes(character));
const list = [...new Set([...LESSON_HOME_WORDS, ...COMMON_WORDS])].filter(fits);
const text = commonWords(count, list, {
punctuation: Boolean(stage.punctuation),
numbers: Boolean(stage.numbers),
});
if (!stage.capitals) return text;
return text
.split(' ')
.map((word) => (runtime.random() < 0.35 ? word[0].toUpperCase() + word.slice(1) : word))
.join(' ');
}
// The page the current book continues at, or an empty passage while the
// file loads (or failed to load).
function bookPage() {
const book = runtime.books[gameState.bookId];
if (!book) return { text: '', source: '' };
const { chapter, page } = bookPosition(book);
const info = BOOKS.find((entry) => entry.id === gameState.bookId);
return {
text: book.chapters[chapter].pages[page],
source: `${info.title} by ${info.author}, ${book.chapters[chapter].title}`,
};
}
// The stored position, if it still exists in the book.
function bookPosition(book) {
const saved = bookProgress[book.id];
if (saved && book.chapters[saved.chapter]?.pages[saved.page] !== undefined) return saved;
return { chapter: 0, page: 0 };
}
// On failure the note says so; Restart tries again.
async function loadBook(id) {
runtime.bookError = '';
try {
const response = await fetch(`books/${id}.json`);
if (!response.ok) throw new Error(response.statusText);
runtime.books[id] = await response.json();
} catch {
runtime.bookError = id;
updateQuickBar();
return;
}
if (gameState.category === 'books' && gameState.bookId === id && !runtime.isStarted) {
resetGame();
}
}
// After a finished page: move on, to the next chapter at its end and back
// to the start after the last page. Returns what the result dialog says.
function advanceBook() {
const book = runtime.books[gameState.bookId];
const { chapter, page } = bookPosition(book);
const pages = book.chapters[chapter].pages.length;
let next = { chapter, page: page + 1 };
let note = `${book.chapters[chapter].title}, page ${page + 1} of ${pages} done.`;
if (next.page === pages) {
next = { chapter: chapter + 1, page: 0 };
if (next.chapter === book.chapters.length) {
next = { chapter: 0, page: 0 };
note += ' That is the end of the book; the next run starts it again.';
} else {
note += ` Next: ${book.chapters[next.chapter].title}.`;
}
}
bookProgress[book.id] = next;
saveJSON(STORAGE_KEYS.books, bookProgress);
return note;
}
function validBooks(data) {
const result = {};
if (!data || typeof data !== 'object' || Array.isArray(data)) return result;
for (const book of BOOKS) {
const saved = data[book.id];
if (saved && Number.isInteger(saved.chapter) && Number.isInteger(saved.page)) {
if (saved.chapter >= 0 && saved.page >= 0) {
result[book.id] = { chapter: saved.chapter, page: saved.page };
}
}
}
return result;
}
function validLessons(data) {
const unlocked = data && data.unlocked;
return {
unlocked:
Number.isInteger(unlocked) && unlocked >= 1 && unlocked <= LESSON_STAGES.length
? unlocked
: 1,
};
}
// Records a lesson run: a pass unlocks the next stage. Returns what the
// result dialog says about it.
function recordLesson(wpm, accuracy) {
const stage = gameState.lessonStage;
const passed = accuracy >= LESSON_PASS.accuracy && wpm >= LESSON_PASS.wpm;
if (!passed) {
return `Stage ${stage} needs ${LESSON_PASS.accuracy}% accuracy and ${LESSON_PASS.wpm} WPM; this run had ${accuracy}% and ${wpm} WPM.`;
}
if (stage === LESSON_STAGES.length) return `Stage ${stage} passed. That is the last stage.`;
if (stage < lessonProgress.unlocked) return `Stage ${stage} passed.`;
lessonProgress.unlocked = stage + 1;
saveJSON(STORAGE_KEYS.lessons, lessonProgress);
return `Stage ${stage} passed. Stage ${stage + 1}, ${LESSON_STAGES[stage].name}, is unlocked.`;
}
function wordsWithKeys(keys) {
const matching = COMMON_WORDS.filter((word) => keys.some((key) => word.includes(key)));
return matching.length >= 10 ? matching : COMMON_WORDS;
}
// Weak digits and symbols are practiced on common words: about every other
// word gets one, wrapped around the word for brackets and quotes, as a
// two-digit number for digits, and after the word otherwise.
const SYMBOL_PAIRS = { '(': ')', '[': ']', '{': '}', '<': '>', '"': '"', "'": "'", '`': '`' };
function withSymbols(text, symbols) {
const closers = Object.fromEntries(Object.entries(SYMBOL_PAIRS).map(([a, b]) => [b, a]));
return text
.split(' ')
.map((word) => {
if (runtime.random() >= 0.5) return word;
const symbol = symbols[Math.floor(runtime.random() * symbols.length)];
if (/\d/.test(symbol)) return `${symbol}${Math.floor(runtime.random() * 10)}`;
const open = SYMBOL_PAIRS[symbol] ? symbol : closers[symbol];
if (open) return `${open}${word}${SYMBOL_PAIRS[open]}`;
return `${word}${symbol}`;
})
.join(' ');
}
// Returns { text, source } for the next passage.
function getPassage() {
if (gameState.category === 'weak') {
const count = gameState.mode === 'words' ? gameState.wordCount : COMMON_CHUNK;
const plain = { punctuation: false, numbers: false };
const letters = runtime.weakKeys.filter((key) => /^[a-z]$/.test(key));
const symbols = runtime.weakKeys.filter((key) => !/^[a-z]$/.test(key));
const text = commonWords(count, wordsWithKeys(letters), plain);
return { text: symbols.length ? withSymbols(text, symbols) : text, source: '' };
}
if (gameState.category === 'lessons') {
const count = gameState.mode === 'words' ? gameState.wordCount : COMMON_CHUNK;
return { text: lessonText(count), source: '' };
}
if (gameState.category === 'books') return bookPage();
if (gameState.category === 'code') {
const snippets = codeSnippets();
if (gameState.mode !== 'words') return { text: pickQuote(snippets).join('\n'), source: '' };
// Words mode uses whole snippets until there are at least that many words.
const parts = [];
let words = 0;
while (words < gameState.wordCount) {
const snippet = pickQuote(snippets).join('\n');
parts.push(snippet);