game.js (19759B)
1 const PALETTE = [0xE63946, 0x1D7CF2, 0xFFD23F, 0x2EC4B6]; 2 const PAUSE_MS = 2200; // idle pause before "Press Enter" hint appears 3 4 class WorkScene extends Phaser.Scene { 5 constructor() { 6 super({ key: 'WorkScene' }); 7 this.mode = 'idle'; 8 this.input_buf = ''; 9 this.acceptInput = false; 10 11 this.nameState = null; 12 this.playerName = ''; 13 this.lastTimeMs = null; 14 this._raceStart = null; 15 16 this.madState = null; 17 this.madTemplate = null; 18 this.madValues = {}; 19 this.madIndex = 0; 20 21 this._twEvent = null; 22 this._pauseEvent = null; 23 this._buttons = []; 24 } 25 26 preload() { 27 KGames.bakeFireworksAtlas(this); 28 } 29 30 create() { 31 window.__WORK_SCENE__ = this; 32 const W = this.scale.width; 33 const H = this.scale.height; 34 35 this.audio = new KGames.AudioSystem({ melody: [], harmony: [], bass: [] }); 36 37 this.pageGfx = this.add.graphics(); 38 this.ribbonGfx = this.add.graphics(); 39 40 this.promptText = this.add.text(0, 0, '', { 41 fontFamily: 'Fredoka, sans-serif', color: '#555566', 42 }).setOrigin(0, 0); 43 44 this.inputText = this.add.text(0, 0, '', { 45 fontFamily: 'Fredoka, sans-serif', color: '#222233', 46 }).setOrigin(0, 0); 47 48 this.hintText = this.add.text(0, 0, '', { 49 fontFamily: 'Fredoka, sans-serif', color: '#1D7CF2', 50 }).setOrigin(0, 0).setVisible(false); 51 52 this.bigText = this.add.text(0, 0, '', { 53 fontFamily: 'Fredoka, sans-serif', fontStyle: 'bold', color: '#E63946', 54 }).setOrigin(0.5, 0.5).setVisible(false); 55 56 this.stopwatchText = this.add.text(0, 0, '', { 57 fontFamily: 'Fredoka, sans-serif', fontStyle: 'bold', color: '#2EC4B6', 58 }).setOrigin(1, 0).setVisible(false); 59 60 this.cursor = this.add.rectangle(0, 0, 3, 30, 0x222233).setOrigin(0, 0); 61 this.tweens.add({ targets: this.cursor, alpha: 0, duration: 500, yoyo: true, repeat: -1 }); 62 63 this._buildRibbon(); 64 65 this.scale.on('resize', this.onResize, this); 66 this.input.keyboard.on('keydown', this.onKey, this); 67 68 const sfxBtn = document.getElementById('btn-sfx'); 69 if (sfxBtn) sfxBtn.addEventListener('click', () => { 70 this.audio.sfxMuted = !this.audio.sfxMuted; 71 sfxBtn.textContent = 'Sound: ' + (this.audio.sfxMuted ? 'OFF' : 'ON'); 72 sfxBtn.classList.toggle('muted', this.audio.sfxMuted); 73 }); 74 75 this.enterMode('idle'); 76 this.applyLayout(W, H); 77 } 78 79 // ── Layout ────────────────────────────────────────────────────────────────── 80 81 _pageRect(W, H) { 82 const ribbonH = Math.max(56, Math.floor(H * 0.12)); 83 const margin = Math.max(20, Math.floor(W * 0.06)); 84 const top = ribbonH + Math.floor(H * 0.05); 85 const x = margin; 86 const y = top; 87 const w = W - margin * 2; 88 const h = H - top - Math.floor(H * 0.05); 89 return { x, y, w, h, ribbonH }; 90 } 91 92 applyLayout(W, H) { 93 const r = this._pageRect(W, H); 94 const fontSize = Math.max(18, Math.floor(H * 0.04)); 95 const pad = Math.floor(r.w * 0.05); 96 97 // Page with soft shadow 98 this.pageGfx.clear(); 99 this.pageGfx.fillStyle(0x000000, 0.08); 100 this.pageGfx.fillRoundedRect(r.x + 5, r.y + 7, r.w, r.h, 10); 101 this.pageGfx.fillStyle(0xffffff, 1); 102 this.pageGfx.fillRoundedRect(r.x, r.y, r.w, r.h, 10); 103 104 const textX = r.x + pad; 105 const textW = r.w - pad * 2; 106 107 this._textX = textX; this._pageTop = r.y; this._pad = pad; this._baseFont = fontSize; 108 this.promptText.setStyle({ fontSize: fontSize + 'px', wordWrap: { width: textW } }); 109 this.inputText.setStyle({ fontSize: (fontSize + 4) + 'px', wordWrap: { width: textW } }); 110 this.hintText.setStyle({ fontSize: Math.floor(fontSize * 0.8) + 'px' }); 111 this._reflow(); 112 this.bigText.setStyle({ fontSize: Math.floor(H * 0.22) + 'px' }) 113 .setPosition(r.x + r.w / 2, r.y + r.h / 2); 114 this.stopwatchText.setStyle({ fontSize: Math.floor(fontSize * 1.4) + 'px' }) 115 .setPosition(r.x + r.w - pad, r.y + pad); 116 this.cursor.setSize(3, fontSize + 6); 117 118 this._layoutRibbon(W, H, r.ribbonH); 119 this._positionButtons(W, H); 120 this._updateCursor(); 121 } 122 123 onResize(gameSize) { 124 this.applyLayout(gameSize.width, gameSize.height); 125 } 126 127 // Single source of truth for vertical layout inside the page. Stacks each 128 // visible text block below the measured bottom of the previous one, so wrapped 129 // or multi-line text (long prompts, the "New record!" result) never overlaps. 130 // Call this after ANY change to prompt/input/hint text or visibility. 131 _reflow() { 132 if (this._textX == null) return; 133 const gap = Math.floor(this._baseFont * 0.6); 134 let y = this._pageTop + this._pad; 135 136 if (this.promptText.text !== '') { 137 this.promptText.setPosition(this._textX, y); 138 y += this.promptText.height + gap; 139 } 140 141 this.inputText.setPosition(this._textX, y); 142 if (this.inputText.text !== '') y += this.inputText.height + gap; 143 144 if (this.hintText.visible) this.hintText.setPosition(this._textX, y); 145 146 this._updateCursor(); 147 } 148 149 // ── Ribbon ────────────────────────────────────────────────────────────────── 150 151 _buildRibbon() { 152 this.ribbonItems = [ 153 { key: 'idle', label: 'New', glyph: '📄', color: 0xFFD23F }, 154 { key: 'name', label: 'Name', glyph: '🙂', color: 0xE63946 }, 155 { key: 'madlib', label: 'Stories', glyph: '✏️', color: 0x1D7CF2 }, 156 ]; 157 this.ribbonObjs = this.ribbonItems.map((item) => { 158 const g = this.add.graphics(); 159 const t = this.add.text(0, 0, item.glyph + '\n' + item.label, { 160 fontFamily: 'Fredoka, sans-serif', fontStyle: '600', color: '#334', 161 align: 'center', 162 }).setOrigin(0.5, 0.5); 163 const z = this.add.zone(0, 0, 10, 10).setOrigin(0.5, 0.5).setInteractive({ useHandCursor: true }); 164 z.on('pointerover', () => { this._hoverKey = item.key; this._drawRibbonBtn(g, item, true); this.audio.speak(item.label); }); 165 z.on('pointerout', () => { this._hoverKey = null; this._drawRibbonBtn(g, item, false); }); 166 z.on('pointerup', () => this.enterMode(item.key)); 167 return { item, g, t, z }; 168 }); 169 } 170 171 _layoutRibbon(W, H, ribbonH) { 172 this.ribbonGfx.clear(); 173 this.ribbonGfx.fillStyle(0xF4F4F6, 1); 174 this.ribbonGfx.fillRect(0, 0, W, ribbonH); 175 this.ribbonGfx.lineStyle(2, 0xD0D0D8, 1); 176 this.ribbonGfx.lineBetween(0, ribbonH, W, ribbonH); 177 178 const n = this.ribbonObjs.length; 179 const btnW = Math.min(120, Math.floor(W / (n + 1))); 180 const btnH = ribbonH - 12; 181 const gap = Math.floor(btnW * 0.15); 182 const totalW = n * btnW + (n - 1) * gap; 183 let x = (W - totalW) / 2; 184 const cy = ribbonH / 2; 185 const fs = Math.max(13, Math.floor(btnH * 0.22)); 186 187 this.ribbonObjs.forEach(({ item, g, t, z }) => { 188 const cx = x + btnW / 2; 189 g._rect = { x: x, y: 6, w: btnW, h: btnH }; 190 this._drawRibbonBtn(g, item, this._hoverKey === item.key); 191 t.setPosition(cx, cy).setStyle({ fontSize: fs + 'px' }); 192 z.setPosition(cx, cy).setSize(btnW, btnH); 193 if (z.input && z.input.hitArea) z.input.hitArea.setTo(0, 0, btnW, btnH); 194 x += btnW + gap; 195 }); 196 } 197 198 _drawRibbonBtn(g, item, hover) { 199 if (!g._rect) return; 200 const { x, y, w, h } = g._rect; 201 g.clear(); 202 g.fillStyle(item.color, hover ? 0.35 : 0.18); 203 g.fillRoundedRect(x, y, w, h, 8); 204 g.lineStyle(2, item.color, 1); 205 g.strokeRoundedRect(x, y, w, h, 8); 206 } 207 208 // ── Modes ───────────────────────────────────────────────────────────────── 209 210 // Parent-facing attribution below the canvas: visible only while a story is 211 // being played, and only for that story. 212 _showStoryCredit(source) { 213 if (typeof window.KGamesShowStoryCredit === 'function') { 214 window.KGamesShowStoryCredit(source || null); 215 } 216 } 217 218 enterMode(mode) { 219 this._cancelTypewriter(); 220 this._cancelPause(); 221 this._clearButtons(); 222 this.input_buf = ''; 223 this.acceptInput = false; 224 this.hintText.setVisible(false); 225 this.bigText.setVisible(false); 226 this.stopwatchText.setVisible(false); 227 this.inputText.setText(''); 228 this.mode = mode; 229 if (mode !== 'madlib') this._showStoryCredit(null); 230 231 if (mode === 'idle') { 232 this.nameState = null; this.madState = null; 233 this.promptText.setText(''); 234 this.acceptInput = true; // blank page: free typing 235 this.cursor.setVisible(true); 236 } 237 this._reflow(); 238 239 if (mode === 'name') { 240 this.startNameGame(); 241 } else if (mode === 'madlib') { 242 this.startMadlib(); 243 } 244 } 245 246 // ── Text input ────────────────────────────────────────────────────────────── 247 248 onKey(event) { 249 if (!this.acceptInput) return; 250 251 const k = event.key; 252 if (k === 'Backspace') { 253 event.preventDefault(); 254 this.input_buf = this.input_buf.slice(0, -1); 255 } else if (k === 'Enter') { 256 if (this.mode === 'idle') { 257 this.input_buf += '\n'; // blank page: newline 258 } else { 259 this._onEnter(); 260 return; 261 } 262 } else if (k === ' ' || /^[a-zA-Z]$/.test(k)) { 263 this.input_buf += k; 264 } else { 265 return; 266 } 267 268 this.audio.playKeyClick(); 269 this.hintText.setVisible(false); 270 this.inputText.setText(this.input_buf); 271 this._reflow(); 272 this._resetPause(); 273 this._onInputChanged(); 274 } 275 276 _onInputChanged() { 277 if (this.mode === 'name' && this.nameState === 'racing') { 278 if (WorkLib.normalizeName(this.input_buf) === WorkLib.normalizeName(this.playerName)) { 279 this._finishRace(); 280 } 281 } 282 } 283 284 _onEnter() { 285 if (this.mode === 'name') { 286 if (this.nameState === 'ask' && WorkLib.isNameReady(this.input_buf)) { 287 this.playerName = this.input_buf.trim(); 288 this._nameGreet(); 289 } 290 } else if (this.mode === 'madlib' && this.madState === 'asking') { 291 if (this.input_buf.trim().length > 0) { 292 this.madValues[this.madTemplate.blanks[this.madIndex].key] = this.input_buf.trim(); 293 this.madIndex++; 294 this._madNextBlank(); 295 } 296 } 297 } 298 299 _resetPause() { 300 this._cancelPause(); 301 this._pauseEvent = this.time.delayedCall(PAUSE_MS, () => this._onPause()); 302 } 303 304 _cancelPause() { 305 if (this._pauseEvent) { this._pauseEvent.remove(false); this._pauseEvent = null; } 306 } 307 308 _onPause() { 309 const ready = (this.mode === 'name' && this.nameState === 'ask' && WorkLib.isNameReady(this.input_buf)) || 310 (this.mode === 'madlib' && this.madState === 'asking' && this.input_buf.trim().length > 0); 311 if (ready) { 312 this.hintText.setText('Press Enter ⏎').setVisible(true); 313 this._reflow(); 314 } 315 } 316 317 _updateCursor() { 318 this.cursor.setVisible(this.acceptInput); 319 if (!this.acceptInput) return; 320 // Sit at the end of the last line of typed text. 321 const lines = this.inputText.text.split('\n'); 322 const lineH = this.inputText.height / Math.max(1, lines.length); 323 const lastY = this.inputText.y + lineH * (lines.length - 1); 324 const lastW = this.inputText.text === '' ? 0 : this._measureWidth(lines[lines.length - 1]); 325 this.cursor.setPosition(this.inputText.x + lastW + 2, lastY); 326 } 327 328 _measureWidth(str) { 329 if (str === '') return 0; 330 const probe = this._cursorProbe || (this._cursorProbe = this.add.text(0, 0, '', { 331 fontFamily: 'Fredoka, sans-serif', 332 }).setVisible(false)); 333 probe.setStyle({ fontSize: this.inputText.style.fontSize }).setText(str); 334 return probe.width; 335 } 336 337 // ── Typewriter ────────────────────────────────────────────────────────────── 338 339 _typewriter(target, onDone) { 340 this._cancelTypewriter(); 341 const steps = WorkLib.typewriterSteps(this.promptText.text, target); 342 this.audio.speak(target); 343 let i = 0; 344 this._twEvent = this.time.addEvent({ 345 delay: 45, 346 repeat: steps.length - 1, 347 callback: () => { 348 this.promptText.setText(steps[i++]); 349 this._reflow(); 350 if (i >= steps.length) { this._twEvent = null; if (onDone) onDone(); } 351 }, 352 }); 353 } 354 355 _cancelTypewriter() { 356 if (this._twEvent) { this._twEvent.remove(false); this._twEvent = null; } 357 } 358 359 // ── Name game ───────────────────────────────────────────────────────────── 360 361 startNameGame() { 362 this.nameState = 'ask'; 363 this.playerName = ''; 364 this.acceptInput = true; // let kids type right away 365 this._reflow(); 366 this._typewriter('What is your name?'); 367 } 368 369 _nameGreet() { 370 this.nameState = 'greet'; 371 this.acceptInput = false; 372 this._cancelPause(); 373 this.hintText.setVisible(false); 374 this.inputText.setText(''); 375 this._reflow(); 376 this._typewriter('Nice to meet you, ' + this.playerName + '!', () => { 377 this.time.delayedCall(1400, () => this._nameChallenge()); 378 }); 379 } 380 381 _nameChallenge() { 382 this.nameState = 'challenge'; 383 this._typewriter('How fast can you type your name?', () => { 384 this.time.delayedCall(1000, () => this._nameCountdown()); 385 }); 386 } 387 388 _nameCountdown() { 389 this.nameState = 'countdown'; 390 this.bigText.setVisible(true); 391 const seq = ['5', '4', '3', '2', '1', 'Go!']; 392 let i = 0; 393 const tick = () => { 394 if (i >= seq.length) { 395 this.bigText.setVisible(false); 396 this._nameRace(); 397 return; 398 } 399 const s = seq[i++]; 400 this.bigText.setText(s).setScale(0.6).setAlpha(1); 401 this.tweens.add({ targets: this.bigText, scale: 1, duration: 300, ease: 'Back.Out' }); 402 this.audio.speak(s === 'Go!' ? 'Go' : s); 403 this.time.delayedCall(900, tick); 404 }; 405 tick(); 406 } 407 408 _nameRace() { 409 this.nameState = 'racing'; 410 this._cancelTypewriter(); 411 this.promptText.setText('Type your name as fast as you can!'); 412 this.input_buf = ''; 413 this.inputText.setText(''); 414 this.acceptInput = true; 415 this.stopwatchText.setText('0.00 s').setVisible(true); 416 this._raceStart = performance.now(); 417 this._reflow(); 418 } 419 420 _finishRace() { 421 this.acceptInput = false; 422 this.nameState = 'result'; 423 const ms = performance.now() - this._raceStart; 424 this.lastTimeMs = ms; 425 this.stopwatchText.setVisible(false); 426 427 let best = null; 428 try { const v = localStorage.getItem('work.nameBest'); if (v != null) best = parseFloat(v); } catch (_) {} 429 const isRecord = WorkLib.isNewRecord(ms, best); 430 if (isRecord) { 431 try { localStorage.setItem('work.nameBest', String(ms)); } catch (_) {} 432 KGames.launchFireworks(this); 433 const r = this._pageRect(this.scale.width, this.scale.height); 434 KGames.burstConfetti(this, r.x + r.w / 2, r.y + r.h / 2); 435 this.audio.playVictorySound(); 436 } else { 437 this.audio.playSuccess(); 438 } 439 440 this.inputText.setText(''); 441 this.promptText.setText( 442 'You typed your name in ' + WorkLib.formatTime(ms) + '!' + 443 (isRecord ? '\nNew record! 🎉' : '') 444 ); 445 this._reflow(); 446 this._showButtons([ 447 { label: 'Play Again', color: 0x2EC4B6, cb: () => { this.stopwatchText.setVisible(false); this._clearButtons(); this._nameCountdown(); } }, 448 { label: 'New Page', color: 0xFFD23F, cb: () => this.enterMode('idle') }, 449 ]); 450 } 451 452 // ── Mad Libs ───────────────────────────────────────────────────────────── 453 454 startMadlib() { 455 this.madTemplate = WorkLib.pickTemplate(); 456 this._showStoryCredit(this.madTemplate.source); 457 this.madValues = {}; 458 this.madIndex = 0; 459 this.madState = 'asking'; 460 this._madNextBlank(); 461 } 462 463 _madNextBlank() { 464 this._cancelPause(); 465 this.hintText.setVisible(false); 466 this.input_buf = ''; 467 this.inputText.setText(''); 468 this.acceptInput = false; 469 470 if (this.madIndex >= this.madTemplate.blanks.length) { 471 this._madShowStory(); 472 return; 473 } 474 const blank = this.madTemplate.blanks[this.madIndex]; 475 this.acceptInput = true; // let kids type right away 476 this._reflow(); 477 this._typewriter(blank.label); 478 } 479 480 _madShowStory() { 481 this.madState = 'story'; 482 this.acceptInput = false; 483 this.cursor.setVisible(false); 484 const story = WorkLib.fillTemplate(this.madTemplate, this.madValues); 485 this.promptText.setText(this.madTemplate.title + '\n\n' + story); 486 this.inputText.setText(''); 487 this._reflow(); 488 this.audio.speak(story); 489 const r = this._pageRect(this.scale.width, this.scale.height); 490 KGames.burstConfetti(this, r.x + r.w / 2, r.y + r.h / 2); 491 this._showButtons([ 492 { label: 'Play Again', color: 0x2EC4B6, cb: () => this.startMadlib() }, 493 { label: 'New Page', color: 0xFFD23F, cb: () => this.enterMode('idle') }, 494 ]); 495 } 496 497 // ── In-page buttons ───────────────────────────────────────────────────────── 498 499 _showButtons(defs) { 500 this._clearButtons(); 501 defs.forEach((d) => { 502 const g = this.add.graphics(); 503 const t = this.add.text(0, 0, d.label, { 504 fontFamily: 'Fredoka, sans-serif', fontStyle: 'bold', color: '#ffffff', 505 }).setOrigin(0.5); 506 const z = this.add.zone(0, 0, 10, 10).setOrigin(0.5).setInteractive({ useHandCursor: true }); 507 z.on('pointerup', () => d.cb()); 508 this._buttons.push({ g, t, z, color: d.color }); 509 }); 510 this._positionButtons(this.scale.width, this.scale.height); 511 } 512 513 _positionButtons(W, H) { 514 if (!this._buttons.length) return; 515 const r = this._pageRect(W, H); 516 const btnW = Math.min(220, r.w * 0.4); 517 const btnH = Math.max(46, H * 0.08); 518 const fs = Math.max(18, Math.floor(btnH * 0.4)); 519 const gap = Math.floor(btnW * 0.12); 520 const n = this._buttons.length; 521 const totalW = n * btnW + (n - 1) * gap; 522 let x = r.x + r.w / 2 - totalW / 2; 523 const cy = r.y + r.h - btnH; 524 525 this._buttons.forEach(({ g, t, z, color }) => { 526 const cx = x + btnW / 2; 527 g.clear(); 528 g.fillStyle(color, 1); 529 g.fillRoundedRect(x, cy - btnH / 2, btnW, btnH, 12); 530 t.setPosition(cx, cy).setStyle({ fontSize: fs + 'px' }); 531 z.setPosition(cx, cy).setSize(btnW, btnH); 532 if (z.input && z.input.hitArea) z.input.hitArea.setTo(0, 0, btnW, btnH); 533 x += btnW + gap; 534 }); 535 } 536 537 _clearButtons() { 538 this._buttons.forEach(({ g, t, z }) => { g.destroy(); t.destroy(); z.destroy(); }); 539 this._buttons = []; 540 } 541 542 // ── Update loop ───────────────────────────────────────────────────────────── 543 544 update() { 545 if (this.mode === 'name' && this.nameState === 'racing' && this._raceStart != null) { 546 this.stopwatchText.setText(WorkLib.formatTime(performance.now() - this._raceStart)); 547 } 548 } 549 } 550 551 window.__KG_GAME__ = new Phaser.Game({ 552 type: Phaser.AUTO, 553 parent: 'game-container', 554 backgroundColor: '#E8E8EC', 555 scene: WorkScene, 556 scale: { 557 mode: Phaser.Scale.RESIZE, 558 width: '100%', 559 height: '100%', 560 }, 561 render: { preserveDrawingBuffer: true }, 562 });