kgames

KGames — free keyboard games for kids
git clone https://www.keyboard.games/code/kgames.git
Log | Files | Refs | README | LICENSE

game.js (15435B)


      1 const COLORS = ['#E63946', '#1D7CF2', '#FFD23F', '#2EC4B6'];
      2 const GOAL   = 10;
      3 const CDN    = 'https://cdn.jsdelivr.net/gh/jdecked/twemoji@15.1.0/assets/svg/';
      4 
      5 // ~1-minute song: 32-step sections (eighth notes at 128 BPM) concatenated so
      6 // the loop is long enough not to grate. 'A' is the original circus fanfare;
      7 // B–D are new sections in the same F-major character. '.' = rest. Harmony is
      8 // a diatonic third above the melody (it plays during the success jingle).
      9 const N = s => s.trim().split(/\s+/).map(t => (t === '.' ? null : t));
     10 
     11 const MEL = {
     12   A: N(`F4 C5 F5 C5  F4 A4 C5 A4   Bb4 D5 F5 Bb5  F5 D5 Bb4 F4
     13        C5 E5 G5 C6  G5 E5 C5 E5   F5 D5 Bb4 F4   C5 F5 A5 F5`),
     14   B: N(`F5 E5 D5 C5  D5 E5 F5 A5   G5 F5 E5 D5    C5 D5 E5 C5
     15        A5 G5 F5 G5  A5 C6 A5 F5   G5 E5 C5 E5    F5 A5 G5 F5`),
     16   C: N(`D5 F5 A5 D6  A5 F5 D5 F5   C5 E5 G5 C6    G5 E5 C5 E5
     17        Bb4 D5 F5 Bb5  F5 D5 Bb4 D5   C5 F5 A5 C6  A5 G5 F5 F5`),
     18   D: N(`A4 Bb4 C5 D5  C5 Bb4 A4 F4   G4 A4 Bb4 C5  Bb4 A4 G4 E4
     19        F4 A4 C5 F5   E5 C5 A4 C5    D5 C5 Bb4 A4  G4 C5 E5 F5`),
     20 };
     21 const HAR = {
     22   A: N(`A4 E5 A5 E5  A4 C5 E5 C5   D5 F5 A5 D6    A5 F5 D5 A4
     23        E5 G5 Bb5 E6  Bb5 G5 E5 G5  A5 F5 D5 A4    E5 A5 C6 A5`),
     24   B: N(`A5 G5 F5 E5  F5 G5 A5 C6   Bb5 A5 G5 F5   E5 F5 G5 E5
     25        C6 Bb5 A5 Bb5  C6 E6 C6 A5  Bb5 G5 E5 G5   A5 C6 Bb5 A5`),
     26   C: N(`F5 A5 C6 F6  C6 A5 F5 A5   E5 G5 Bb5 E6   Bb5 G5 E5 G5
     27        D5 F5 A5 D6  A5 F5 D5 F5   E5 A5 C6 E6    C6 Bb5 A5 A5`),
     28   D: N(`C5 D5 E5 F5  E5 D5 C5 A4   Bb4 C5 D5 E5   D5 C5 Bb4 G4
     29        A4 C5 E5 A5  G5 E5 C5 E5   F5 E5 D5 C5    Bb4 E5 G5 A5`),
     30 };
     31 const BAS = {
     32   A: N(`F2 . . . . . . .  Bb2 . . . C3 . . .   F2 . . . . . . .  Bb2 . . . C3 . . .`),
     33   B: N(`F2 . . . . . . .  C3 . . . . . . .    F2 . . . . . . .  C3 . . . F2 . . .`),
     34   C: N(`D3 . . . . . . .  C3 . . . . . . .    Bb2 . . . . . . . C3 . . . F2 . . .`),
     35   D: N(`F2 . . . . . . .  C3 . . . . . . .    F2 . . . . . . .  Bb2 . . . C3 . . .`),
     36 };
     37 const ORDER   = ['A','B','A','C','D','B','C','A'];
     38 const MELODY  = ORDER.flatMap(k => MEL[k]);
     39 const HARMONY = ORDER.flatMap(k => HAR[k]);
     40 const BASS    = ORDER.flatMap(k => BAS[k]);
     41 
     42 class AnimalSpellScene extends Phaser.Scene {
     43   constructor() {
     44     super({ key: 'AnimalSpellScene' });
     45     this.audio         = null;
     46     this.currentAnimal = null;
     47     this.roundColor    = null;
     48     this.wordLetters   = [];
     49     this.letterIndex   = 0;
     50     this.wrongCount    = 0;
     51     this.accepting     = false;
     52     this.wordsSpelled  = 0;
     53     this.winShowing    = false;
     54     this.progressGfx   = null;
     55     this.winElements   = [];
     56     this.guessTexts    = [];
     57     this.animalImg     = null;
     58     this.instrText     = null;
     59     this.hintText      = null;
     60     this._imgScale     = 1;
     61     this._idleTweens   = [];
     62     this._deck         = [];
     63     this._slotTexts    = [];
     64     this._slotGfx      = null;
     65   }
     66 
     67   preload() {
     68     KGames.bakeFireworksAtlas(this);
     69     AnimalSpellLib.ANIMALS.forEach(a => {
     70       this.load.svg(a.key, CDN + a.codepoint + '.svg', { width: 256, height: 256 });
     71     });
     72   }
     73 
     74   create() {
     75     window.__AS_SCENE__ = this;
     76     const W = this.scale.width;
     77     const H = this.scale.height;
     78 
     79     this.audio       = new KGames.AudioSystem({ melody: MELODY, harmony: HARMONY, bass: BASS });
     80     this.progressGfx = this.add.graphics();
     81     this._slotGfx    = this.add.graphics();
     82 
     83     this.instrText = this.add.text(W / 2, H * 0.14, '', {
     84       fontFamily: 'Fredoka, sans-serif',
     85       color:      '#888888',
     86     }).setOrigin(0.5, 0.5);
     87 
     88     this.animalImg = this.add.image(W / 2, H * 0.36, '__DEFAULT').setVisible(false);
     89 
     90     this.hintText = this.add.text(W / 2, H * 0.80, '', {
     91       fontFamily: 'Fredoka, sans-serif',
     92       fontStyle:  'bold',
     93     }).setOrigin(0.5, 0.5).setVisible(false);
     94 
     95     this.scale.on('resize', this.onResize, this);
     96     this.input.keyboard.on('keydown', this.onKey, this);
     97 
     98     document.getElementById('btn-music').addEventListener('click', () => this.audio.toggleMusic());
     99     document.getElementById('btn-sfx').addEventListener('click', () => this.audio.toggleSfx());
    100 
    101     this.newRound();
    102     this.applyLayout(W, H);
    103   }
    104 
    105   applyLayout(W, H) {
    106     const instrSize = Math.max(16, Math.floor(H * 0.055));
    107     const hintSize  = Math.max(40, Math.floor(H * 0.12));
    108     const imgSize   = Math.max(80, Math.floor(H * 0.32));
    109 
    110     this.instrText.setStyle({ fontSize: instrSize + 'px' }).setPosition(W / 2, H * 0.14);
    111 
    112     const wasAccepting = this.accepting;
    113     this._stopIdleAnimation();
    114 
    115     this._imgScale = imgSize / 256;
    116     if (this.animalImg.visible) {
    117       this.animalImg.setPosition(W / 2, H * 0.36).setScale(this._imgScale);
    118     }
    119 
    120     if (wasAccepting) this._startIdleAnimation();
    121 
    122     this.hintText.setStyle({ fontSize: hintSize + 'px' }).setPosition(W / 2, H * 0.80);
    123 
    124     KGames.drawProgressBar(this.progressGfx, W, H, this.wordsSpelled, GOAL);
    125     this._redrawSlots(W, H);
    126     this.reflowGuesses(W, H);
    127   }
    128 
    129   onResize(gameSize) {
    130     this.applyLayout(gameSize.width, gameSize.height);
    131   }
    132 
    133   _slotMetrics(W, H) {
    134     const n = this.wordLetters.length;
    135     if (n === 0) return { startX: 0, slotW: 40, y: H * 0.67, fontSize: 24 };
    136     const maxSlotW = Math.floor(W / (n + 1));
    137     const slotW    = Math.min(maxSlotW, Math.floor(H * 0.09));
    138     const startX   = (W - slotW * n) / 2 + slotW / 2;
    139     const y        = H * 0.67;
    140     const fontSize = Math.max(20, Math.min(Math.floor(slotW * 0.75), Math.floor(H * 0.07)));
    141     return { startX, slotW, y, fontSize };
    142   }
    143 
    144   _buildSlots(W, H) {
    145     this._slotTexts.forEach(t => { if (t.active) t.destroy(); });
    146     this._slotTexts = [];
    147 
    148     const { startX, slotW, y, fontSize } = this._slotMetrics(W, H);
    149     this.wordLetters.forEach((_, i) => {
    150       const x = startX + i * slotW;
    151       const t = this.add.text(x, y, '', {
    152         fontFamily: 'Fredoka, sans-serif',
    153         fontSize:   fontSize + 'px',
    154         fontStyle:  'bold',
    155         color:      '#ffffff',
    156       }).setOrigin(0.5, 0.5).setVisible(false);
    157       this._slotTexts.push(t);
    158     });
    159   }
    160 
    161   _redrawSlots(W, H) {
    162     if (this._slotTexts.length === 0) return;
    163     const { startX, slotW, y, fontSize } = this._slotMetrics(W, H);
    164     const underY  = y + Math.floor(fontSize * 0.6);
    165     const lineLen = Math.floor(slotW * 0.72);
    166     const lineW   = Math.max(2, Math.floor(fontSize * 0.07));
    167 
    168     this._slotGfx.clear();
    169     this._slotGfx.lineStyle(lineW, 0x555577, 1);
    170 
    171     this._slotTexts.forEach((t, i) => {
    172       const x = startX + i * slotW;
    173       t.setPosition(x, y).setStyle({ fontSize: fontSize + 'px' });
    174 
    175       if (i < this.letterIndex) {
    176         t.setText(this.wordLetters[i]).setColor('#ffffff').setVisible(true);
    177       } else if (i === this.letterIndex) {
    178         if (this.wrongCount === 0) {
    179           t.setVisible(false);
    180           this._slotGfx.beginPath();
    181           this._slotGfx.moveTo(x - lineLen / 2, underY);
    182           this._slotGfx.lineTo(x + lineLen / 2, underY);
    183           this._slotGfx.strokePath();
    184         } else {
    185           // Hint: show letter in muted color, underline disappears
    186           t.setText(this.wordLetters[i]).setColor('#888888').setVisible(true);
    187         }
    188       } else {
    189         t.setVisible(false);
    190         this._slotGfx.beginPath();
    191         this._slotGfx.moveTo(x - lineLen / 2, underY);
    192         this._slotGfx.lineTo(x + lineLen / 2, underY);
    193         this._slotGfx.strokePath();
    194       }
    195     });
    196   }
    197 
    198   _startIdleAnimation() {
    199     const { width: W } = this.scale;
    200     const swayAmt = Math.max(6, Math.floor(W * 0.012));
    201     this._idleTweens = [
    202       this.tweens.add({
    203         targets:  this.animalImg,
    204         x:        this.animalImg.x + swayAmt,
    205         ease:     'Sine.InOut',
    206         duration: 1600,
    207         yoyo:     true,
    208         repeat:   -1,
    209       }),
    210       this.tweens.add({
    211         targets:  this.animalImg,
    212         scaleX:   this._imgScale * 1.06,
    213         scaleY:   this._imgScale * 1.06,
    214         ease:     'Sine.InOut',
    215         duration: 1100,
    216         yoyo:     true,
    217         repeat:   -1,
    218       }),
    219     ];
    220   }
    221 
    222   _stopIdleAnimation() {
    223     this._idleTweens.forEach(t => { try { t.destroy(); } catch (_) {} });
    224     this._idleTweens = [];
    225   }
    226 
    227   newRound() {
    228     this._stopIdleAnimation();
    229 
    230     if (this._deck.length === 0) {
    231       this._deck = AnimalSpellLib.shuffleAnimals(Math.random);
    232     }
    233     this.currentAnimal = this._deck.pop();
    234     this.wordLetters   = AnimalSpellLib.getWordLetters(this.currentAnimal);
    235     this.letterIndex   = 0;
    236     this.wrongCount    = 0;
    237 
    238     const available = COLORS.filter(c => c !== this.roundColor);
    239     this.roundColor = available[Math.floor(Math.random() * available.length)];
    240 
    241     this.hintText.setVisible(false);
    242     this.instrText.setText('Spell the animal!').setColor('#888888');
    243 
    244     const { width: W, height: H } = this.scale;
    245     const imgSize = Math.max(80, Math.floor(H * 0.32));
    246     this._imgScale = imgSize / 256;
    247 
    248     this._buildSlots(W, H);
    249     this._redrawSlots(W, H);
    250 
    251     if (this.textures.exists(this.currentAnimal.key)) {
    252       this.animalImg
    253         .setTexture(this.currentAnimal.key)
    254         .setPosition(W / 2, H * 0.36)
    255         .setScale(this._imgScale * 0.3)
    256         .setVisible(true);
    257       this.tweens.add({
    258         targets:  this.animalImg,
    259         scaleX:   this._imgScale,
    260         scaleY:   this._imgScale,
    261         ease:     'Back.Out',
    262         duration: 250,
    263         onComplete: () => {
    264           this.accepting = true;
    265           this._startIdleAnimation();
    266         },
    267       });
    268     } else {
    269       this.animalImg.setVisible(false);
    270       this.accepting = true;
    271     }
    272 
    273     this.audio.speak(this.currentAnimal.name);
    274   }
    275 
    276   onKey(event) {
    277     if (this.winShowing) return;
    278     if (!this.accepting) return;
    279     if (event.key.length !== 1 || !/[a-z]/i.test(event.key)) return;
    280 
    281     this.audio.initMusic();
    282 
    283     const pressed = event.key.toUpperCase();
    284     if (pressed === this.wordLetters[this.letterIndex]) {
    285       this.onLetterCorrect();
    286     } else {
    287       this.onLetterWrong(pressed);
    288     }
    289   }
    290 
    291   onLetterCorrect() {
    292     this.accepting = false;
    293     this.audio.playPing();
    294 
    295     this.hintText.setVisible(false);
    296     this.instrText.setText('Spell the animal!').setColor('#888888');
    297     this.wrongCount = 0;
    298 
    299     const { width: W, height: H } = this.scale;
    300     const { startX, slotW, y } = this._slotMetrics(W, H);
    301     const slotX = startX + this.letterIndex * slotW;
    302 
    303     // Pop the confirmed letter into the slot
    304     const slot = this._slotTexts[this.letterIndex];
    305     slot.setText(this.wordLetters[this.letterIndex])
    306       .setColor('#ffffff')
    307       .setVisible(true)
    308       .setScale(0.3)
    309       .setAlpha(0);
    310     this.tweens.add({
    311       targets: slot,
    312       scaleX: 1, scaleY: 1, alpha: 1,
    313       ease:    'Back.Out',
    314       duration: 180,
    315     });
    316 
    317     KGames.burstConfetti(this, slotX, y);
    318 
    319     this.letterIndex++;
    320     this._redrawSlots(W, H);
    321 
    322     this.guessTexts.forEach(obj => { this.tweens.killTweensOf(obj); obj.destroy(); });
    323     this.guessTexts = [];
    324 
    325     if (this.letterIndex >= this.wordLetters.length) {
    326       this.time.delayedCall(400, () => this.onWordComplete());
    327     } else {
    328       this.time.delayedCall(200, () => { this.accepting = true; });
    329     }
    330   }
    331 
    332   onWordComplete() {
    333     this._stopIdleAnimation();
    334     this.audio.playSuccess();
    335     this.audio.playSuccessJingle();
    336 
    337     const { width: W, height: H } = this.scale;
    338     KGames.burstConfetti(this, W / 2, H * 0.36);
    339 
    340     // Flash all slots in the round color
    341     this._slotTexts.forEach(t => t.setColor(this.roundColor));
    342     this.audio.speak(this.currentAnimal.name);
    343 
    344     this.wordsSpelled++;
    345     KGames.drawProgressBar(this.progressGfx, W, H, this.wordsSpelled, GOAL);
    346 
    347     if (this.wordsSpelled >= GOAL) {
    348       this.time.delayedCall(1500, () => {
    349         this.winShowing = true;
    350         this.accepting  = false;
    351         this.winElements = KGames.showWinScreen(this, {
    352           title:       'Amazing!',
    353           subtitle:    'You can spell all the animals!',
    354           audio:       this.audio,
    355           onPlayAgain: () => this.resetGame(),
    356         });
    357       });
    358     } else {
    359       this.time.delayedCall(1500, () => {
    360         this.newRound();
    361         this.applyLayout(this.scale.width, this.scale.height);
    362       });
    363     }
    364   }
    365 
    366   onLetterWrong(pressed) {
    367     this.audio.playBong();
    368     this.audio.playFailLick();
    369     this.wrongCount++;
    370     this.updateHints();
    371     this.addGuessLetter(pressed);
    372   }
    373 
    374   updateHints() {
    375     const { width: W, height: H } = this.scale;
    376     const hintSize      = Math.max(40, Math.floor(H * 0.12));
    377     const currentLetter = this.wordLetters[this.letterIndex];
    378 
    379     if (this.wrongCount === 1) {
    380       // Reveal the current letter in the slot as a hint
    381       this._redrawSlots(W, H);
    382       this.audio.speak(this.currentAnimal.name);
    383     } else if (this.wrongCount >= 2) {
    384       // Big letter hint below + instruction update
    385       this.hintText
    386         .setText(currentLetter)
    387         .setStyle({ fontSize: hintSize + 'px', color: this.roundColor })
    388         .setPosition(W / 2, H * 0.80)
    389         .setVisible(true);
    390       this.instrText.setText('Press  ' + currentLetter).setColor(this.roundColor);
    391       this.audio.speak(currentLetter);
    392     }
    393   }
    394 
    395   addGuessLetter(char) {
    396     const { width: W, height: H } = this.scale;
    397     const size = Math.max(20, Math.floor(H * 0.10));
    398 
    399     const text = this.add.text(W / 2, H * 0.88, char, {
    400       fontFamily: 'Fredoka, sans-serif',
    401       fontSize:   size + 'px',
    402       fontStyle:  'bold',
    403       color:      '#cc4444',
    404     }).setOrigin(0.5).setAlpha(0).setScale(0.3);
    405 
    406     this.tweens.add({
    407       targets: text,
    408       scaleX: 1, scaleY: 1, alpha: 1,
    409       ease:   'Back.Out',
    410       duration: 150,
    411     });
    412 
    413     this.guessTexts.push(text);
    414     this.reflowGuesses(W, H);
    415 
    416     this.time.delayedCall(1200, () => {
    417       if (!text.active) return;
    418       this.tweens.add({
    419         targets: text,
    420         alpha: 0, scaleX: 0.2, scaleY: 0.2,
    421         duration: 200,
    422         onComplete: () => {
    423           text.destroy();
    424           this.guessTexts = this.guessTexts.filter(t => t !== text);
    425           this.reflowGuesses(this.scale.width, this.scale.height);
    426         },
    427       });
    428     });
    429   }
    430 
    431   reflowGuesses(W, H) {
    432     const n = this.guessTexts.length;
    433     if (n === 0) return;
    434     const size    = Math.max(20, Math.floor(H * 0.10));
    435     const spacing = size * 1.15;
    436     const startX  = W / 2 - ((n - 1) * spacing) / 2;
    437     this.guessTexts.forEach((obj, i) => {
    438       obj.setPosition(startX + i * spacing, H * 0.88);
    439       obj.setStyle({ fontSize: size + 'px' });
    440     });
    441   }
    442 
    443   resetGame() {
    444     this._stopIdleAnimation();
    445 
    446     this.winElements.forEach(el => { if (el.active) el.destroy(); });
    447     this.winElements = [];
    448     this.winShowing  = false;
    449 
    450     this._slotTexts.forEach(t => { if (t.active) t.destroy(); });
    451     this._slotTexts = [];
    452 
    453     this.guessTexts.forEach(obj => { if (obj.active) obj.destroy(); });
    454     this.guessTexts = [];
    455 
    456     this.wordsSpelled  = 0;
    457     this.currentAnimal = null;
    458     this.roundColor    = null;
    459     this.letterIndex   = 0;
    460     this.wrongCount    = 0;
    461     this._deck         = [];
    462 
    463     const { width: W, height: H } = this.scale;
    464     KGames.drawProgressBar(this.progressGfx, W, H, this.wordsSpelled, GOAL);
    465     this.newRound();
    466     this.applyLayout(W, H);
    467   }
    468 }
    469 
    470 window.__KG_GAME__ = new Phaser.Game({
    471   type:   Phaser.AUTO,
    472   parent: 'game-container',
    473   backgroundColor: '#1A1A2E',
    474   scene:  AnimalSpellScene,
    475   scale: {
    476     mode:   Phaser.Scale.RESIZE,
    477     width:  '100%',
    478     height: '100%',
    479   },
    480   render: { preserveDrawingBuffer: true },
    481 });