game.js (44969B)
1 // Story Stamp — the game. Plain DOM + CSS animations, keyboard only. 2 // 3 // The child runs the checkout desk of the island library. Kids queue up, each 4 // wanting a book about something (bugs, space, dinosaurs ...). The child 5 // picks who to help; that patron hands over a request slip with the book's 6 // title in sound boxes. The child stamps each letter (hearing its sound), 7 // then presses SPACE to sound the title out: the sounds play with shrinking 8 // gaps until they nearly touch — the word itself is never said, the child 9 // has to say it — and then picks the right book from the cart, stamps it, and 10 // the patron leaves happy. "Returns" (unlocked later) go the other way: the 11 // patron says the word and the child types its sounds to shelve the book. 12 // Pure logic lives in lib.js, audio in audio.js; this file is UI + flow. 13 // 14 // Wall-clock rule: every delay is a setTimeout so the flow keeps moving in 15 // throttled / headless browsers. window.__TEST_MODE__ shortens them all. 16 (function (root) { 17 'use strict'; 18 19 const SS = root.SS; 20 const ASSETS = root.SS_ASSETS || { phonemes: {}, words: {}, images: {} }; 21 const TEST = !!root.__TEST_MODE__; 22 23 const T = (ms) => (TEST ? Math.min(ms, 15) : ms); 24 const sleep = (ms) => new Promise((r) => setTimeout(r, T(ms))); 25 const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); 26 const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; 27 const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1); 28 // Picture by Fluent Emoji asset name (see manifest.js); unknown names draw nothing. 29 const img = (name, cls) => { 30 const f = ASSETS.images && ASSETS.images[name]; 31 return f ? '<img class="ss-img ' + (cls || '') + '" src="assets/' + f + '" alt="" draggable="false">' : ''; 32 }; 33 const BOX_COLORS = ['#E63946', '#1D7CF2', '#2EC4B6', '#9B5DE5', '#F4A261', '#06A77D', '#FF5DA2', '#8AC926']; 34 const WALKERS = ['Child/Default', 'Girl/Default', 'Boy/Default', 'Penguin', 'Koala', 'Panda', 'Lion', 'Tiger face', 'Bear', 'Alien']; 35 const DECOR = { 36 rug: '<div class="d-rug"></div>', 37 plant: '<span class="d-plant">' + img('Potted plant') + '</span>', 38 lamp: '<span class="d-lamp">' + img('Light bulb') + '</span>', 39 fishtank: '<div class="d-tank">' + img('Fish') + img('Tropical fish') + '</div>', 40 bunting: '<div class="d-bunting"></div>', 41 cat: '<span class="d-cat">' + img('Cat') + '</span>', 42 globe: '<span class="d-globe">' + img('Globe showing americas') + '</span>', 43 telescope: '<span class="d-telescope">' + img('Telescope') + '</span>', 44 dino: '<span class="d-dino">' + img('Sauropod') + '</span>', 45 rocket: '<span class="d-rocket">' + img('Rocket') + '</span>', 46 }; 47 48 const LINES = { 49 hello: ['Hello!', 'Hi there!', 'Good morning!', 'Hi!', 'Yoo-hoo!', 'Hello, librarian!'], 50 again: ['It is me again!', 'I am back!', 'Remember me?', 'Back for another book!'], 51 want: ['I want a book about {t}!', 'Do you have a book about {t}?', 'A book about {t}, please!', 'I would love a book about {t}.'], 52 wantAny: ['I want a good book!', 'Can you pick me a book?', 'Any good book, please!'], 53 slip: ['Here is my request slip. Can you read it?', 'It is called this. Can you read it?', 'This is the one I want!'], 54 returning: ['I am bringing this book back.', 'I finished my book! Here it is.', 'Time to return this one.'], 55 happy: ['Thank you!', 'Just what I wanted!', 'I love it!', 'You are the best librarian!', 'Hooray!', 'My favourite!'], 56 wrongBook: ['Hmm, that is not it.', 'Oops! Not that one.', 'Not quite! Try again.'], 57 instrRead: 'Sound it out! Press each letter.', 58 instrHear: 'Which sounds do you hear? Press the letters.', 59 readIt: 'Now press space to read it out loud!', 60 shelveIt: 'Now press space to put it back!', 61 choose: 'What does it say? Say the word, then pick the right book.', 62 queue: 'Who is next? Use the arrows and press enter.', 63 }; 64 65 class StoryStamp { 66 constructor(container) { 67 this.container = container; 68 this.storage = null; 69 try { this.storage = root.localStorage; } catch (_) {} 70 this.storage = this.storage || { getItem() { return null; }, setItem() {}, removeItem() {} }; 71 this.store = SS.loadStore(this.storage); 72 this.profile = null; 73 this.audio = new root.StoryStampAudio(ASSETS, 'assets/audio/'); 74 this.state = 'boot'; 75 this.busy = false; 76 this.order = null; 77 this.queue = []; 78 this.sel = 0; 79 this.booksToday = 0; 80 this.todayWords = []; 81 this.todaySounds = []; 82 this.applied = {}; 83 this._build(); 84 this._bindKeys(); 85 this._watchSize(); 86 this.showProfiles(); 87 if (!TEST) this._ambient(); 88 } 89 90 now() { return root.__SS_NOW__ || Date.now(); } 91 save() { SS.saveStore(this.storage, this.store); } 92 93 // ── DOM ──────────────────────────────────────────────────────────────── 94 95 _build() { 96 const r = document.createElement('div'); 97 r.className = 'ss-root' + (TEST ? ' ss-test' : ''); 98 const spines = (n, seed) => { 99 let out = ''; 100 for (let i = 0; i < n; i++) { 101 const h = 55 + (SS.hashStr(seed + i) % 40); 102 out += '<i style="height:' + h + '%;background:' + BOX_COLORS[(SS.hashStr(seed + 'c' + i)) % BOX_COLORS.length] + '"></i>'; 103 } 104 return out; 105 }; 106 const shelf = (side) => '<div class="ss-shelf ' + side + '">' + 107 [0, 1, 2, 3].map((row) => '<div class="ss-row"><div class="ss-spines">' + spines(7, side + row) + '</div></div>').join('') + '</div>'; 108 r.innerHTML = 109 '<div class="ss-wall">' + 110 '<div class="ss-window"><div class="ss-sky"><div class="ss-sun"></div><div class="ss-cloud"></div><div class="ss-cloud c2"></div>' + 111 '<span class="ss-bird">' + img('Bird') + '</span></div><div class="ss-sea"><span class="ss-boat">' + img('Sailboat') + '</span></div>' + 112 '<span class="ss-palm">' + img('Palm tree') + '</span><div class="ss-walkers"></div></div>' + 113 shelf('left') + shelf('right') + 114 '<div class="ss-sign">Island Library</div>' + 115 '<div class="ss-decor"></div>' + 116 '</div>' + 117 '<div class="ss-floor"></div>' + 118 '<div class="ss-queue"></div>' + 119 '<div class="ss-customer"><div class="ss-bubble"></div><div class="ss-cust-body"></div><div class="ss-cust-name"></div></div>' + 120 '<div class="ss-desk"><div class="ss-desk-top"></div><div class="ss-desk-front"></div>' + 121 '<div class="ss-slip"><div class="ss-slip-title"></div><div class="ss-boxes"></div><div class="ss-finger">' + img('Backhand index pointing up/Default') + '</div></div>' + 122 '<div class="ss-stamp"><div class="ss-stamp-handle"></div><div class="ss-stamp-base"></div></div>' + 123 '<div class="ss-cart"></div>' + 124 '<div class="ss-book-out"></div>' + 125 '</div>' + 126 '<div class="ss-prompt"></div>' + 127 '<div class="ss-hud"><div class="ss-chip ss-who"></div><div class="ss-chip right ss-shelf-chip"></div></div>' + 128 '<div class="ss-day"></div>' + 129 '<div class="ss-overlay" hidden><div class="ss-panel"></div></div>'; 130 this.container.appendChild(r); 131 this.$ = (sel) => r.querySelector(sel); 132 this.root = r; 133 this.$queue = this.$('.ss-queue'); this.$boxes = this.$('.ss-boxes'); this.$prompt = this.$('.ss-prompt'); 134 this.$cart = this.$('.ss-cart'); this.$slip = this.$('.ss-slip'); this.$slipTitle = this.$('.ss-slip-title'); 135 this.$finger = this.$('.ss-finger'); this.$stamp = this.$('.ss-stamp'); this.$bookOut = this.$('.ss-book-out'); 136 this.$customer = this.$('.ss-customer'); this.$bubble = this.$('.ss-bubble'); this.$custBody = this.$('.ss-cust-body'); 137 this.$custName = this.$('.ss-cust-name'); this.$overlay = this.$('.ss-overlay'); this.$panel = this.$('.ss-panel'); 138 this.$who = this.$('.ss-who'); this.$shelfChip = this.$('.ss-shelf-chip'); this.$day = this.$('.ss-day'); 139 this.$decor = this.$('.ss-decor'); this.$walkers = this.$('.ss-walkers'); 140 141 const btnV = document.getElementById('btn-voice'), btnS = document.getElementById('btn-sfx'); 142 if (btnV) btnV.addEventListener('click', () => { this.audio.voiceMuted = !this.audio.voiceMuted; btnV.classList.toggle('muted', this.audio.voiceMuted); btnV.textContent = 'Speech: ' + (this.audio.voiceMuted ? 'OFF' : 'ON'); if (this.audio.voiceMuted) this.audio.cancel(); }); 143 if (btnS) btnS.addEventListener('click', () => { this.audio.sfxMuted = !this.audio.sfxMuted; btnS.classList.toggle('muted', this.audio.sfxMuted); btnS.textContent = 'SFX: ' + (this.audio.sfxMuted ? 'OFF' : 'ON'); }); 144 } 145 146 _watchSize() { 147 const apply = () => { 148 const w = this.container.clientWidth || 800, h = this.container.clientHeight || 600; 149 this.root.style.setProperty('--u', (Math.min(w, h * 1.25) / 100) + 'px'); 150 this.root.classList.toggle('ss-narrow', w < h * 0.9); 151 }; 152 apply(); 153 if (root.ResizeObserver) { try { new ResizeObserver(apply).observe(this.container); } catch (_) {} } 154 root.addEventListener('resize', apply); 155 } 156 157 // Passers-by outside the window. 158 _ambient() { 159 const spawn = () => { 160 if (document.visibilityState === 'hidden') return; 161 const el = document.createElement('span'); 162 el.className = 'ss-walker'; 163 const rev = Math.random() < 0.5; 164 el.style.left = rev ? '100%' : '-15%'; 165 el.style.transform = 'translateX(0)'; 166 el.innerHTML = img(pick(WALKERS)); 167 this.$walkers.appendChild(el); 168 requestAnimationFrame(() => requestAnimationFrame(() => { el.style.transform = 'translateX(' + (rev ? '-' : '') + '130%)'; })); 169 setTimeout(() => el.remove(), 16000); 170 }; 171 setTimeout(spawn, 3000); 172 setInterval(spawn, 12000 + Math.random() * 6000); 173 } 174 175 setPrompt(html) { this.$prompt.innerHTML = html || ''; } 176 177 toast(text) { 178 const t = document.createElement('div'); 179 t.className = 'ss-toast'; t.innerHTML = text; 180 this.root.appendChild(t); 181 setTimeout(() => t.remove(), T(2600)); 182 } 183 184 fx(emoji, x, y) { 185 const e = document.createElement('span'); 186 e.className = 'ss-fx'; e.textContent = emoji; 187 e.style.left = x + '%'; e.style.top = y + '%'; 188 this.root.appendChild(e); 189 setTimeout(() => e.remove(), T(1400)); 190 } 191 192 // ── Keyboard ─────────────────────────────────────────────────────────── 193 194 _bindKeys() { 195 root.addEventListener('keydown', (e) => { 196 if (e.ctrlKey || e.metaKey || e.altKey) return; 197 const k = e.key; 198 if ([' ', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Backspace'].indexOf(k) >= 0) e.preventDefault(); 199 this.onKey(k); 200 }); 201 } 202 203 onKey(k) { 204 const letter = k.length === 1 && /[a-z]/i.test(k) ? k.toLowerCase() : null; 205 const s = this.state; 206 if (s === 'profiles') return this._keyProfiles(k, letter); 207 if (s === 'avatar') return this._keyAvatar(k); 208 if (s === 'name') return this._keyName(k, letter); 209 if (s === 'confirmDelete') return this._keyConfirm(letter); 210 if (s === 'menu') return this._keyMenu(k); 211 if (s === 'shelf') return this._keyShelf(k); 212 if (s === 'dayend') { if (k === 'Enter' || k === ' ') this.newDay(); else if (k === 'Backspace') this.showProfiles(); return; } 213 if (s === 'newsound') { if ((k === 'Enter' || k === ' ') && this._continue) { const c = this._continue; this._continue = null; c(); } return; } 214 if (k === 'Backspace' || k === 'Escape') { if (!this.busy) this.showMenu(); return; } 215 if (s === 'queue') return this._keyQueue(k); 216 if (s === 'read') { if (letter) this.typeLetter(letter); else if (k === ' ' || k === 'Enter') this.hint(); return; } 217 if (s === 'blend') { if (k === ' ' || k === 'Enter') this.soundOut(); return; } 218 if (s === 'choose') return this._keyChoose(k); 219 } 220 221 // ── Profiles ─────────────────────────────────────────────────────────── 222 223 showProfiles() { 224 this.audio.cancel(); 225 this.state = 'profiles'; this.busy = false; this.sel = 0; 226 const list = this.store.profiles; 227 const idx = list.findIndex((p) => p.id === this.store.last); 228 if (idx >= 0) this.sel = idx; 229 this._renderProfiles(); 230 this.audio.speak(list.length ? 'Who is the librarian today? Use the arrows and press enter.' : 'Welcome to the island library! Press enter to make a new librarian.'); 231 } 232 233 _renderProfiles() { 234 const list = this.store.profiles; 235 const items = list.map((p, i) => '<div class="ss-item' + (i === this.sel ? ' sel' : '') + '">' + img(p.avatar) + esc(p.name) + 236 '<small>' + SS.masteredWords(p).length + ' books</small></div>'); 237 if (list.length < SS.MAX_PROFILES) items.push('<div class="ss-item new' + (this.sel === list.length ? ' sel' : '') + '">✨ New librarian</div>'); 238 this._panel('<h2>Who is the librarian today?</h2><div class="ss-list">' + items.join('') + '</div>' + 239 '<p><kbd>↑</kbd> <kbd>↓</kbd> choose · <kbd>Enter</kbd> start' + (list.length ? ' · <kbd>X</kbd> remove' : '') + '</p>'); 240 } 241 242 _keyProfiles(k, letter) { 243 const list = this.store.profiles; 244 const n = list.length + (list.length < SS.MAX_PROFILES ? 1 : 0); 245 if (k === 'ArrowUp') { this.sel = (this.sel + n - 1) % n; this._renderProfiles(); this.audio.init(); this.audio.tick(); return; } 246 if (k === 'ArrowDown') { this.sel = (this.sel + 1) % n; this._renderProfiles(); this.audio.init(); this.audio.tick(); return; } 247 if (/^[1-9]$/.test(k) && +k <= n) { this.sel = +k - 1; this._renderProfiles(); return; } 248 if (k === 'Enter' || k === ' ') { 249 this.audio.init(); 250 if (this.sel < list.length) this.start(list[this.sel]); 251 else this.showAvatar(); 252 return; 253 } 254 if ((letter === 'x' || k === 'Delete') && this.sel < list.length) { 255 this.state = 'confirmDelete'; 256 const p = list[this.sel]; 257 this._panel('<h2>Remove ' + esc(p.name) + '?</h2><p>' + img(p.avatar) + '</p><p>All of ' + esc(p.name) + '\'s books will be forgotten.</p><p><kbd>Y</kbd> yes, remove · any other key: keep</p>'); 258 this.audio.speak('Remove ' + p.name + '? Press Y to remove, or any other key to keep.'); 259 } 260 } 261 262 _keyConfirm(letter) { 263 if (letter === 'y') { 264 const p = this.store.profiles[this.sel]; 265 this.store.profiles = this.store.profiles.filter((x) => x !== p); 266 if (this.store.last === p.id) this.store.last = null; 267 this.save(); 268 } 269 this.showProfiles(); 270 } 271 272 showAvatar() { 273 this.state = 'avatar'; this.avatarSel = Math.floor(Math.random() * SS.AVATARS.length); 274 this._renderAvatar(); 275 this.audio.speak('Pick a picture for yourself. Use the arrows, then press enter.'); 276 } 277 _renderAvatar() { 278 this._panel('<h2>Pick your picture</h2><div class="ss-avatars">' + 279 SS.AVATARS.map((a, i) => img(a, i === this.avatarSel ? 'sel' : '')).join('') + 280 '</div><p><kbd>←</kbd> <kbd>→</kbd> choose · <kbd>Enter</kbd> next</p>'); 281 } 282 _keyAvatar(k) { 283 const n = SS.AVATARS.length; 284 if (k === 'ArrowLeft' || k === 'ArrowUp') { this.avatarSel = (this.avatarSel + n - 1) % n; this._renderAvatar(); this.audio.tick(); } 285 if (k === 'ArrowRight' || k === 'ArrowDown') { this.avatarSel = (this.avatarSel + 1) % n; this._renderAvatar(); this.audio.tick(); } 286 if (k === 'Enter' || k === ' ') { this.state = 'name'; this.nameBuf = ''; this._renderName(); this.audio.speak('Now type your name and press enter.'); } 287 if (k === 'Backspace') this.showProfiles(); 288 } 289 _renderName() { 290 this._panel('<h2>What is your name?</h2><p>' + img(SS.AVATARS[this.avatarSel]) + '</p><div class="ss-namefield">' + esc(this.nameBuf) + '</div>' + 291 '<p>Type your name · <kbd>Enter</kbd> when done</p>'); 292 } 293 _keyName(k, letter) { 294 if (letter && this.nameBuf.length < 12) { this.nameBuf += letter; this._renderName(); this.audio.tick(); return; } 295 if (k === 'Backspace') { this.nameBuf = this.nameBuf.slice(0, -1); this._renderName(); return; } 296 if (k === 'Enter') { 297 const p = SS.newProfile(cap(this.nameBuf || 'librarian'), SS.AVATARS[this.avatarSel], this.now()); 298 this.store.profiles.push(p); 299 this.save(); 300 this.start(p); 301 } 302 } 303 304 // Test / debug helper: create (or reuse) a profile and open the desk. 305 quickStart(name) { 306 let p = this.store.profiles.find((x) => x.name === name); 307 if (!p) { p = SS.newProfile(name, SS.AVATARS[0], this.now()); this.store.profiles.push(p); this.save(); } 308 this.audio.init(); 309 return this.start(p); 310 } 311 312 // ── Menu / my shelf ──────────────────────────────────────────────────── 313 314 showMenu() { 315 this.audio.cancel(); 316 this._resumeState = this.state; this.state = 'menu'; this.sel = 0; 317 this._renderMenu(); 318 this.audio.speak('Menu. My shelf, change librarian, or back to the desk.'); 319 } 320 _menuItems() { return ['📚 My shelf', '👋 Change librarian', '🏷️ Back to the desk']; } 321 _renderMenu() { 322 this._panel('<h2>Menu</h2><div class="ss-list">' + this._menuItems().map((t, i) => '<div class="ss-item' + (i === this.sel ? ' sel' : '') + '">' + t + '</div>').join('') + 323 '</div><p><kbd>↑</kbd> <kbd>↓</kbd> · <kbd>Enter</kbd> · <kbd>⌫</kbd> back</p>'); 324 } 325 _keyMenu(k) { 326 const n = 3; 327 if (k === 'ArrowUp') { this.sel = (this.sel + n - 1) % n; this._renderMenu(); this.audio.tick(); } 328 if (k === 'ArrowDown') { this.sel = (this.sel + 1) % n; this._renderMenu(); this.audio.tick(); } 329 if (k === 'Backspace' || k === 'Escape') this._closeOverlay(); 330 if (k === 'Enter' || k === ' ') { 331 if (this.sel === 0) this.showShelf(); 332 else if (this.sel === 1) { this.$overlay.hidden = true; this.showProfiles(); } 333 else this._closeOverlay(); 334 } 335 } 336 _closeOverlay() { 337 this.$overlay.hidden = true; 338 this.state = this._resumeState || 'queue'; 339 this.audio.speak(this.state === 'queue' ? LINES.queue : 'Back to the desk!'); 340 } 341 342 showShelf() { 343 this.state = 'shelf'; this.sel = 0; 344 this._renderShelf(); 345 const n = SS.masteredWords(this.profile).length; 346 this.audio.speak(n ? 'Your shelf has ' + n + ' books. Press enter to hear one.' : 'Your shelf is empty. Check out some books!'); 347 } 348 _shelfEntries() { 349 const p = this.profile; 350 const solid = SS.masteredWords(p), shaky = SS.shakyWords(p); // both skip words no longer in WORDS 351 const all = solid.map((w) => ({ w, solid: true })).concat(shaky.map((w) => ({ w, solid: false }))); 352 // Group by first topic so the shelf reads like the library's sections. 353 const order = SS.TOPICS.map((t) => t.id); 354 all.forEach((e) => { e.topic = SS.WORD_BY_NAME[e.w].t[0]; }); 355 all.sort((a, b) => order.indexOf(a.topic) - order.indexOf(b.topic) || a.w.localeCompare(b.w)); 356 return all; 357 } 358 _renderShelf() { 359 const entries = this._shelfEntries(); 360 let html = '', lastTopic = null; 361 entries.forEach((e, i) => { 362 if (e.topic !== lastTopic) { html += '<div class="ss-shelf-label">' + img(SS.TOPIC_BY_ID[e.topic].art) + ' ' + esc(SS.TOPIC_BY_ID[e.topic].name) + '</div>'; lastTopic = e.topic; } 363 html += '<div class="ss-book-mini' + (e.solid ? '' : ' shaky') + (i === this.sel ? ' sel' : '') + '">' + img(SS.WORD_BY_NAME[e.w].art) + '<span>' + e.w + '</span></div>'; 364 }); 365 this._panel('<h2>My shelf</h2>' + (entries.length ? '<div class="ss-books">' + html + '</div>' : '<p>No books yet — help someone at the desk!</p>') + 366 '<p><kbd>←</kbd> <kbd>→</kbd> choose · <kbd>Enter</kbd> hear it · <kbd>⌫</kbd> back</p>'); 367 const sel = this.$panel.querySelector('.ss-book-mini.sel'); 368 if (sel && sel.scrollIntoView) { try { sel.scrollIntoView({ block: 'nearest' }); } catch (_) {} } 369 } 370 _keyShelf(k) { 371 const entries = this._shelfEntries(); const n = entries.length || 1; 372 if (k === 'ArrowLeft' || k === 'ArrowUp') { this.sel = (this.sel + n - 1) % n; this._renderShelf(); this.audio.tick(); } 373 if (k === 'ArrowRight' || k === 'ArrowDown') { this.sel = (this.sel + 1) % n; this._renderShelf(); this.audio.tick(); } 374 if (k === 'Backspace' || k === 'Escape') this._closeOverlay(); 375 if ((k === 'Enter' || k === ' ') && entries.length) { 376 const word = SS.WORD_BY_NAME[entries[this.sel].w]; 377 this.audio.cancel(); 378 this.audio.playSegmented(SS.phonemeUnits(word), { gap: 0.3 }).then(() => this.audio.playWord(word.w)); 379 } 380 } 381 382 _panel(html) { this.$panel.innerHTML = html; this.$overlay.hidden = false; } 383 384 // ── Start / HUD / library ────────────────────────────────────────────── 385 386 async start(profile) { 387 this.profile = profile; 388 this.store.last = profile.id; 389 this.save(); 390 this.$overlay.hidden = true; 391 this.booksToday = 0; this.todayWords = []; this.todaySounds = []; 392 this.root.classList.remove('ss-dusk'); 393 this.applied = {}; 394 this._applyLibrary(true); 395 this._hud(); 396 this.audio.init(); 397 this.busy = true; this.state = 'opening'; 398 root.__SS_READY__ = true; 399 await this.audio.speak('Hello ' + profile.name + '! Welcome to the island library. You are at the checkout desk.'); 400 this.showQueue(); 401 } 402 403 _hud() { 404 const p = this.profile; if (!p) return; 405 this.$who.innerHTML = img(p.avatar) + esc(p.name); 406 this.$shelfChip.innerHTML = '📚 ' + SS.masteredWords(p).length + ' <small>⌫ menu</small>'; 407 let dots = ''; 408 for (let i = 0; i < SS.BOOKS_PER_DAY; i++) dots += '<span class="' + (i < this.booksToday ? 'on' : '') + '"></span>'; 409 this.$day.innerHTML = dots; 410 } 411 412 // Decorate the library for every upgrade earned; announce new ones if asked. 413 _applyLibrary(silent) { 414 const ups = SS.libraryUpgrades(SS.masteredWords(this.profile).length); 415 const fresh = []; 416 ups.forEach((u) => { 417 if (this.applied[u.id]) return; 418 this.applied[u.id] = true; 419 this.root.classList.add('has-' + u.id); 420 if (DECOR[u.id]) this.$decor.insertAdjacentHTML('beforeend', DECOR[u.id]); 421 if (!silent) fresh.push(u); 422 }); 423 return fresh; 424 } 425 426 // ── The queue ────────────────────────────────────────────────────────── 427 428 _resetDesk() { 429 this.order = null; 430 this.$boxes.innerHTML = ''; this.$boxes.className = 'ss-boxes'; 431 this.$slip.className = 'ss-slip'; this.$slipTitle.innerHTML = ''; 432 this.$cart.innerHTML = ''; this.$cart.className = 'ss-cart'; this.$cart.parentNode.classList.remove('choosing'); 433 this.$bookOut.innerHTML = ''; this.$bookOut.className = 'ss-book-out'; 434 this.$stamp.className = 'ss-stamp'; 435 this.$finger.className = 'ss-finger'; 436 this.setPrompt(''); 437 } 438 439 async showQueue() { 440 this.busy = true; this.state = 'arrive'; 441 this._resetDesk(); 442 if (SS.shouldIntroduce(this.profile)) { 443 const g = SS.introduceNext(this.profile); 444 this.save(); 445 this.todaySounds.push(g); 446 await this.showNewSound(g); 447 } 448 const queue = SS.buildQueue(this.profile, this.now(), Math.random, 3); 449 if (root.__SS_FORCE_MODE__) queue.forEach((q) => { if (this.profile.words[q.word.w] && this.audio.hasWord(q.word.w)) q.mode = root.__SS_FORCE_MODE__; }); 450 // Returns need a recording of the word (the patron has to say it). 451 queue.forEach((q) => { if (q.mode === 'return' && !this.audio.hasWord(q.word.w)) q.mode = 'read'; }); 452 this.queue = queue; 453 this.sel = 0; 454 queue.forEach((q) => this.audio.preloadWord(q.word.w)); 455 this._renderQueue(true); 456 await sleep(900); 457 this.state = 'queue'; this.busy = false; 458 this.setPrompt('Who is next? <kbd>←</kbd> <kbd>→</kbd> then <kbd>Enter</kbd>'); 459 await this.audio.speak(LINES.queue); 460 if (this.state === 'queue') this._sayWish(); 461 } 462 463 _renderQueue(fresh) { 464 this.$queue.innerHTML = this.queue.map((q, i) => { 465 const t = q.topic; 466 return '<div class="ss-patron' + (i === this.sel ? ' sel' : '') + (fresh ? ' in' : '') + '" style="--c:' + q.patron.color + ';animation-delay:' + (i * 0.15) + 's">' + 467 '<div class="ss-sign-card" style="border-color:' + t.color + '">' + img(t.art) + '<span>' + esc(t.name) + '</span></div>' + 468 (q.mode === 'return' ? '<div class="ss-holding">' + img(q.word.art) + '</div>' : '') + 469 '<div class="ss-patron-body">' + img(q.patron.art) + '</div>' + 470 '<div class="ss-patron-name">' + esc(q.patron.name) + '</div><span class="ss-num">' + (i + 1) + '</span></div>'; 471 }).join(''); 472 } 473 474 _sayWish() { 475 const q = this.queue[this.sel]; if (!q) return; 476 const line = q.mode === 'return' ? pick(LINES.returning) 477 : q.topic.id === 'any' ? pick(LINES.wantAny) : pick(LINES.want).replace('{t}', q.topic.name); 478 this.audio.cancel(); 479 this.audio.speak(q.patron.name + ' says: ' + line, q.patron.voice); 480 } 481 482 _keyQueue(k) { 483 if (this.busy) return; 484 const n = this.queue.length; 485 if (!n) { this.showQueue(); return; } 486 if (k === 'ArrowLeft' || k === 'ArrowUp') { this.sel = (this.sel + n - 1) % n; this._renderQueue(); this.audio.tick(); this._sayWish(); } 487 if (k === 'ArrowRight' || k === 'ArrowDown') { this.sel = (this.sel + 1) % n; this._renderQueue(); this.audio.tick(); this._sayWish(); } 488 if (/^[1-9]$/.test(k) && +k <= n) { this.sel = +k - 1; this._renderQueue(); this.callPatron(); } 489 if (k === 'Enter' || k === ' ') this.callPatron(); 490 } 491 492 async callPatron() { 493 const q = this.queue[this.sel]; 494 if (!q) return; 495 this.busy = true; this.state = 'arrive'; 496 this.audio.cancel(); 497 SS.recordTopic(this.profile, q.topic.id); 498 this.save(); 499 this.order = { 500 word: q.word, mode: q.mode, review: q.review, topic: q.topic, patron: q.patron, 501 idx: 0, buffer: '', missed: false, boxMissed: false, wrongPicks: 0, 502 units: SS.phonemeUnits(q.word), graphemes: SS.graphemes(q.word), 503 }; 504 const els = Array.from(this.$queue.children); 505 els.forEach((el, i) => el.classList.add(i === this.sel ? 'called' : 'wait')); 506 this.setPrompt(''); 507 await sleep(500); 508 this.$queue.innerHTML = ''; 509 await this.arrive(); 510 } 511 512 async showNewSound(g) { 513 this.state = 'newsound'; 514 const ps = SS.graphemePhonemes(g); 515 const example = SS.availableWords(this.profile.introduced).find((w) => SS.graphemes(w).map(SS.baseGrapheme).indexOf(g) >= 0 && this.audio.hasWord(w.w)) 516 || SS.availableWords(this.profile.introduced).find((w) => SS.graphemes(w).map(SS.baseGrapheme).indexOf(g) >= 0); 517 this._panel('<h2>A new sound!</h2><div class="ss-big-letter">' + g + '</div>' + 518 (example ? '<p>like in <b>' + example.w + '</b> ' + img(example.art) + '</p>' : '') + '<p><kbd>Enter</kbd> to keep going</p>'); 519 this.audio.sparkle(); 520 await this.audio.speak('A new sound! This one says'); 521 for (let rep = 0; rep < 2; rep++) { 522 let t = 0; 523 ps.forEach((p) => { t += this.audio.playPhoneme(p, { delay: t, len: 0.7 }) + 0.05; }); 524 await sleep(t * 1000 + 500); 525 } 526 if (example) { 527 await this.audio.speak('Like in'); 528 await this.audio.playSegmented(SS.phonemeUnits(example), { gap: 0.25, len: 0.35 }); 529 await this.audio.playWord(example.w); 530 } 531 await new Promise((resolve) => { 532 this._continue = resolve; 533 if (TEST) resolve(); 534 else setTimeout(() => { if (this._continue === resolve) { this._continue = null; resolve(); } }, 9000); 535 }); 536 this.$overlay.hidden = true; 537 } 538 539 // ── Serving one patron ───────────────────────────────────────────────── 540 541 _renderCustomer() { 542 const c = this.order.patron; 543 this.$custBody.innerHTML = img(c.art); 544 this.$custName.textContent = c.name; 545 this.$custName.style.background = c.color; 546 this.$bubble.className = 'ss-bubble'; this.$bubble.innerHTML = ''; 547 } 548 549 // Build the sound boxes once per book; _updateBoxes() keeps their classes in 550 // step so a stamped tile stays stamped (re-rendering would replay its drop). 551 _renderBoxes() { 552 const o = this.order; 553 this.$boxes.innerHTML = o.graphemes.map((g, i) => 554 '<div class="ss-box' + (g.length > 1 ? ' wide' : '') + '" data-i="' + i + '" style="--c:' + BOX_COLORS[i % BOX_COLORS.length] + '">' + esc(g) + '</div>').join(''); 555 this._updateBoxes(); 556 } 557 _updateBoxes() { 558 const o = this.order; 559 Array.from(this.$boxes.children).forEach((b, i) => { 560 const g = o.graphemes[i]; 561 b.classList.toggle('done', i < o.idx); 562 b.classList.toggle('next', i === o.idx); 563 b.classList.toggle('hidden-letter', o.mode === 'return' && i >= o.idx); 564 if (i === o.idx && o.buffer) b.innerHTML = '<span class="ss-partial">' + esc(o.buffer) + '</span>' + esc(g.slice(o.buffer.length)); 565 else b.textContent = g; 566 }); 567 } 568 569 async arrive() { 570 const o = this.order, c = o.patron; 571 this._renderCustomer(); 572 this.$customer.classList.remove('in', 'out', 'happy', 'puzzled'); 573 void this.$customer.offsetWidth; // restart the walk-in transition 574 this.$customer.classList.add('in'); 575 this.$boxes.innerHTML = ''; 576 await sleep(900); 577 const hello = pick(LINES.hello) + (o.review ? ' ' + pick(LINES.again) : ''); 578 await this.audio.speak(hello, c.voice); 579 if (o.mode === 'read') { 580 this.$bubble.innerHTML = img(o.topic.art); 581 this.$bubble.classList.add('show'); 582 await this.audio.speak(o.topic.id === 'any' ? pick(LINES.wantAny) : pick(LINES.want).replace('{t}', o.topic.name), c.voice); 583 this.$slipTitle.innerHTML = 'Request'; 584 this._renderBoxes(); 585 this.$slip.classList.add('show'); 586 this.audio.pageFlip(); 587 await this.audio.speak(pick(LINES.slip), c.voice); 588 this.setPrompt(LINES.instrRead); 589 this.audio.speak(LINES.instrRead); 590 } else { 591 this.$bubble.innerHTML = img(o.word.art); 592 this.$bubble.classList.add('show'); 593 await this.audio.speak(pick(LINES.returning), c.voice); 594 await this.audio.playWord(o.word.w); 595 this.$slipTitle.innerHTML = 'Return card'; 596 this._renderBoxes(); 597 this.$slip.classList.add('show'); 598 this.audio.pageFlip(); 599 this.setPrompt(LINES.instrHear); 600 this.audio.speak(LINES.instrHear); 601 } 602 this.state = 'read'; this.busy = false; 603 } 604 605 // Replay the demonstration for the current box. 606 hint() { 607 if (this.busy) return; 608 const o = this.order; 609 this.audio.cancel(); 610 if (o.mode === 'return') { 611 this.audio.playWord(o.word.w).then(() => this.audio.playSegmented(o.units, { gap: 0.3, onUnit: (i) => this._flashBox(i) })); 612 } else { 613 this._flashBox(o.idx); 614 let t = 0; 615 o.units[o.idx].forEach((p) => { t += this.audio.playPhoneme(p, { delay: t, len: 0.45 }) + 0.04; }); 616 } 617 } 618 619 _flashBox(i, ms) { 620 const b = this.$boxes.querySelector('[data-i="' + i + '"]'); 621 if (!b) return; 622 b.classList.add('speaking'); 623 if (this.order.mode === 'return') b.classList.add('reveal'); 624 setTimeout(() => b.classList.remove('speaking', 'reveal'), T(ms || 650)); 625 } 626 627 typeLetter(ch) { 628 if (this.busy) return; 629 const o = this.order; 630 const expected = o.graphemes[o.idx]; 631 const attempt = o.buffer + ch; 632 if (expected.indexOf(attempt) === 0) { 633 o.buffer = attempt; 634 if (attempt === expected) this._acceptGrapheme(); 635 else { this._updateBoxes(); this.audio.tick(); } 636 } else { 637 this._wrongKey(); 638 } 639 } 640 641 _acceptGrapheme() { 642 const o = this.order; 643 SS.recordGrapheme(this.profile, o.graphemes[o.idx], !o.boxMissed); 644 o.boxMissed = false; 645 const unit = o.units[o.idx]; 646 let t = 0; 647 unit.forEach((p) => { t += this.audio.playPhoneme(p, { delay: t, len: 0.4 }) + 0.03; }); 648 this.audio.stampLetter(); 649 o.idx += 1; o.buffer = ''; 650 this._updateBoxes(); 651 const box = this.$boxes.querySelector('[data-i="' + (o.idx - 1) + '"]'); 652 if (box) { box.classList.add('stamped'); box.classList.add('reveal'); } 653 this.$stamp.classList.remove('press'); void this.$stamp.offsetWidth; this.$stamp.classList.add('press'); 654 if (o.idx >= o.graphemes.length) this._wordComplete(); 655 } 656 657 async _wordComplete() { 658 const o = this.order; 659 this.state = 'blend'; this.busy = false; // SPACE works straight away; the prompt just follows 660 this.$slip.classList.add('ready'); 661 await sleep(450); 662 if (this.state !== 'blend') return; 663 this.setPrompt(o.mode === 'read' ? 'Press <kbd>space</kbd> to read it!' : 'Press <kbd>space</kbd> to shelve it!'); 664 this.audio.speak(o.mode === 'read' ? LINES.readIt : LINES.shelveIt); 665 } 666 667 async _wrongKey() { 668 const o = this.order; 669 o.missed = true; 670 if (!o.boxMissed) { o.boxMissed = true; SS.recordGrapheme(this.profile, o.graphemes[o.idx], false); } 671 o.buffer = ''; 672 this._updateBoxes(); 673 this.busy = true; 674 this.audio.cancel(); 675 this.audio.wrong(); 676 this.$slip.classList.remove('wobble'); void this.$slip.offsetWidth; this.$slip.classList.add('wobble'); 677 const box = this.$boxes.querySelector('[data-i="' + o.idx + '"]'); 678 if (box) { box.classList.add('hint'); setTimeout(() => box.classList.remove('hint'), T(1000)); } 679 if (o.mode === 'read') { 680 await this.audio.speak('This one says'); 681 this._flashBox(o.idx, 900); 682 let t = 0; 683 o.units[o.idx].forEach((p) => { t += this.audio.playPhoneme(p, { delay: t, len: 0.6 }) + 0.04; }); 684 await sleep(t * 1000 + 200); 685 } else { 686 await this.audio.speak('Listen again.'); 687 await this.audio.playWord(o.word.w); 688 await this.audio.playSegmented(o.units, { gap: 0.35, onUnit: (i) => this._flashBox(i) }); 689 await this.audio.speak('Which sound comes next?'); 690 } 691 this.busy = false; 692 } 693 694 // SPACE: sound the title out. The finger slides under each box as its 695 // sound plays; each pass is faster and closer until the boxes touch. 696 async soundOut() { 697 if (this.busy) return; 698 const o = this.order; 699 this.busy = true; this.state = 'sounding'; 700 this.setPrompt(''); 701 this.$slip.classList.remove('ready'); 702 this.$slip.classList.add('reading'); 703 this.$finger.classList.add('show'); 704 const boxes = Array.from(this.$boxes.children); 705 await this.audio.blend(o.units, (ev) => { 706 if (ev.kind === 'pass') { this.$boxes.className = 'ss-boxes pass' + ev.pass; this._moveFinger(boxes[0]); } 707 if (ev.kind === 'phoneme') { 708 const x = boxes[ev.unit]; 709 if (x) { x.classList.add('speaking', 'reveal'); setTimeout(() => x.classList.remove('speaking'), T(ev.len * 1000)); this._moveFinger(x); } 710 } 711 if (ev.kind === 'done') { 712 this.$boxes.classList.add('fused'); 713 boxes.forEach((x) => x.classList.add('done', 'reveal')); 714 this.$finger.classList.remove('show'); 715 this.audio.sparkle(); 716 this.fx('✨', 46, 58); this.fx('✨', 54, 56); 717 } 718 }); 719 this.$slip.classList.remove('reading'); 720 await sleep(400); 721 if (o.mode === 'read') this.choose(); else this.shelve(); 722 } 723 724 _moveFinger(box) { 725 if (!box) return; 726 const r = box.getBoundingClientRect(), s = this.$slip.getBoundingClientRect(); 727 if (!s.width) return; 728 this.$finger.style.left = ((r.left + r.width / 2 - s.left) / s.width * 100) + '%'; 729 } 730 731 // Which book on the cart matches what was just read? 732 choose() { 733 const o = this.order; 734 const cards = [o.word].concat(SS.distractors(o.word, this.profile, Math.random)); 735 for (let i = cards.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [cards[i], cards[j]] = [cards[j], cards[i]]; } 736 o.cards = cards; this.sel = 0; 737 this._renderCards(); 738 this.$cart.classList.add('show'); 739 this.$cart.parentNode.classList.add('choosing'); 740 this.audio.slide(); 741 this.setPrompt('Which book is it?'); 742 this.audio.speak(LINES.choose); 743 this.state = 'choose'; this.busy = false; 744 } 745 _renderCards() { 746 this.$cart.innerHTML = this.order.cards.map((w, i) => '<div class="ss-book' + (i === this.sel ? ' sel' : '') + '" data-w="' + w.w + '" style="--c:' + BOX_COLORS[SS.hashStr(w.w) % BOX_COLORS.length] + '">' + 747 '<div class="ss-cover">' + img(w.art) + '</div><span class="ss-num">' + (i + 1) + '</span></div>').join(''); 748 } 749 _keyChoose(k) { 750 if (this.busy) return; 751 const n = this.order.cards.length; 752 if (k === 'ArrowLeft' || k === 'ArrowUp') { this.sel = (this.sel + n - 1) % n; this._renderCards(); this.audio.tick(); } 753 if (k === 'ArrowRight' || k === 'ArrowDown') { this.sel = (this.sel + 1) % n; this._renderCards(); this.audio.tick(); } 754 if (/^[1-9]$/.test(k) && +k <= n) { this.sel = +k - 1; this._renderCards(); this._confirmChoice(); } 755 if (k === 'Enter' || k === ' ') this._confirmChoice(); 756 } 757 async _confirmChoice() { 758 const o = this.order, chosen = o.cards[this.sel]; 759 this.busy = true; 760 const cardEls = Array.from(this.$cart.children); 761 if (chosen.w === o.word.w) { 762 cardEls[this.sel].classList.add('yes'); 763 this.audio.ding(); 764 await sleep(450); 765 this.checkout(); 766 return; 767 } 768 o.missed = true; o.wrongPicks += 1; 769 cardEls[this.sel].classList.add('no'); 770 this.audio.wrong(); 771 this.$customer.classList.remove('puzzled'); void this.$customer.offsetWidth; this.$customer.classList.add('puzzled'); 772 await this.audio.speak(pick(LINES.wrongBook) + ' That one is about a ' + chosen.w + '.', o.patron.voice); 773 if (o.wrongPicks === 2 && o.cards.length > 2) { 774 // Take the wrong book away so the next try is between two. 775 o.cards = o.cards.filter((c) => c.w !== chosen.w); 776 this.sel = 0; 777 this._renderCards(); 778 await this.audio.speak('Let me take that one away.', o.patron.voice); 779 } else { 780 cardEls[this.sel].classList.remove('no'); 781 } 782 await this.audio.speak('Read it again.'); 783 this.$finger.classList.add('show'); 784 const boxes = Array.from(this.$boxes.children); 785 await this.audio.playSegmented(o.units, { gap: 0.1, len: 0.25, onUnit: (i) => { this._flashBox(i, 300); this._moveFinger(boxes[i]); } }); 786 this.$finger.classList.remove('show'); 787 if (o.wrongPicks >= 3) { 788 // No fail state: after three tries the word is given and the book pointed out. 789 await this.audio.speak('It says'); 790 await this.audio.playWord(o.word.w); 791 const right = o.cards.findIndex((c) => c.w === o.word.w); 792 this.sel = right; this._renderCards(); 793 this.$cart.children[right].classList.add('point'); 794 } 795 this.busy = false; 796 } 797 798 // Read mode success: stamp the book and hand it over. 799 async checkout() { 800 const o = this.order; 801 this.state = 'stamp'; this.busy = true; 802 this.setPrompt(''); 803 const right = o.cards.findIndex((c) => c.w === o.word.w); 804 const book = this.$cart.children[right]; 805 this.$cart.classList.add('picked'); 806 Array.from(this.$cart.children).forEach((b, i) => { if (i !== right) b.classList.add('gone'); }); 807 this.$boxes.querySelectorAll('.ss-box').forEach((b) => b.classList.add('done', 'reveal')); 808 await sleep(350); 809 // The stamp comes down on the cover. 810 this.$stamp.classList.add('over'); 811 await sleep(350); 812 this.$stamp.classList.add('slam'); 813 this.audio.bigStamp(); 814 if (book) book.classList.add('stamped'); 815 this.root.classList.remove('shake'); void this.root.offsetWidth; this.root.classList.add('shake'); 816 await sleep(500); 817 this.$stamp.classList.remove('over', 'slam'); 818 await this.audio.speak('Checked out!'); 819 if (book) book.classList.add('handed'); 820 this.audio.whoosh(); 821 await sleep(600); 822 this.$bubble.innerHTML = img(o.word.art); 823 this.$customer.classList.add('happy'); 824 await this.audio.speak('Yes! ' + o.word.w + '! ' + pick(LINES.happy), o.patron.voice); 825 this.serve(); 826 } 827 828 // Return mode success: the book flies back to its shelf. 829 async shelve() { 830 const o = this.order; 831 this.state = 'stamp'; this.busy = true; 832 this.$bookOut.innerHTML = '<div class="ss-book stamped" style="--c:' + BOX_COLORS[SS.hashStr(o.word.w) % BOX_COLORS.length] + '"><div class="ss-cover">' + img(o.word.art) + '</div></div>'; 833 this.$bookOut.classList.add('show'); 834 this.$stamp.classList.add('over'); 835 await sleep(350); 836 this.$stamp.classList.add('slam'); 837 this.audio.bigStamp(); 838 await sleep(450); 839 this.$stamp.classList.remove('over', 'slam'); 840 this.$bookOut.classList.add('fly'); 841 this.audio.whoosh(); 842 await sleep(700); 843 this.$customer.classList.add('happy'); 844 await this.audio.speak('Back on the shelf! ' + pick(LINES.happy), o.patron.voice); 845 this.serve(); 846 } 847 848 async serve() { 849 const o = this.order, p = this.profile; 850 this.state = 'serve'; this.busy = true; 851 this.audio.fanfare(); 852 this.fx('⭐', 68, 40); this.fx('❤️', 74, 44); this.fx('⭐', 62, 42); 853 854 const wasMastered = SS.isMastered(SS.wordRecord(p, o.word.w)); 855 const rec = SS.recordOrder(p, o.word.w, !o.missed, this.now()); 856 this.booksToday += 1; 857 this.todayWords.push(o.word.w); 858 this.save(); 859 this._hud(); 860 861 if (!wasMastered && SS.isMastered(rec)) { 862 this.toast('📚 ' + esc(o.word.w) + ' is on your shelf!'); 863 this.audio.sparkle(); 864 await this.audio.speak('A new book for your shelf!'); 865 } 866 const fresh = this._applyLibrary(false); 867 for (const u of fresh) { 868 this.toast('🎉 The library got ' + u.label + '!'); 869 this.audio.fanfare(); 870 await this.audio.speak('The library got ' + u.label + '!'); 871 await sleep(600); 872 } 873 this.$customer.classList.remove('in'); this.$customer.classList.add('out'); 874 this.$bubble.classList.remove('show'); 875 await sleep(800); 876 if (this.booksToday >= SS.BOOKS_PER_DAY) this.dayEnd(); 877 else this.showQueue(); 878 } 879 880 async dayEnd() { 881 this.state = 'dayend'; this.busy = false; 882 this.root.classList.add('ss-dusk'); 883 this._resetDesk(); 884 const pics = this.todayWords.map((w) => '<span class="ss-day-book">' + img(SS.WORD_BY_NAME[w].art) + '<i>' + esc(w) + '</i></span>').join(''); 885 const sounds = this.todaySounds.length ? '<p>New sounds: <b>' + this.todaySounds.join(', ') + '</b></p>' : ''; 886 this._panel('<h2>Closing time!</h2><div class="ss-summary">' + pics + '</div>' + sounds + 887 '<p>' + SS.masteredWords(this.profile).length + ' books on your shelf</p>' + 888 '<p><kbd>Enter</kbd> open the library again · <kbd>⌫</kbd> change librarian</p>'); 889 this.audio.fanfare(); 890 await this.audio.speak('The library is closed for today. Great reading, ' + this.profile.name + '! Press enter to open again, or take a break.'); 891 } 892 893 newDay() { 894 this.$overlay.hidden = true; 895 this.root.classList.remove('ss-dusk'); 896 this.booksToday = 0; this.todayWords = []; this.todaySounds = []; 897 this._hud(); 898 this.showQueue(); 899 } 900 } 901 902 document.addEventListener('DOMContentLoaded', () => { 903 const container = document.getElementById('game-container'); 904 root.__SS_GAME__ = new StoryStamp(container); 905 }); 906 root.StoryStamp = StoryStamp; 907 }(window));