kgames

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

game.js (14020B)


      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 // F major — circus-like leaps and animal energy, extended to a ~1-minute song:
      6 // 32-step sections (eighth notes at 128 BPM) concatenated so the loop is long
      7 // enough not to grate. 'A' is the original fanfare; B–D are new sections in
      8 // the same character. '.' = rest. Harmony is a diatonic third above the
      9 // melody (it plays during the success jingle).
     10 const N = s => s.trim().split(/\s+/).map(t => (t === '.' ? null : t));
     11 
     12 const MEL = {
     13   A: N(`F4 C5 F5 C5  F4 A4 C5 A4   Bb4 D5 F5 Bb5  F5 D5 Bb4 F4
     14        C5 E5 G5 C6  G5 E5 C5 E5   F5 D5 Bb4 F4   C5 F5 A5 F5`),
     15   B: N(`F5 E5 D5 C5  D5 E5 F5 A5   G5 F5 E5 D5    C5 D5 E5 C5
     16        A5 G5 F5 G5  A5 C6 A5 F5   G5 E5 C5 E5    F5 A5 G5 F5`),
     17   C: N(`D5 F5 A5 D6  A5 F5 D5 F5   C5 E5 G5 C6    G5 E5 C5 E5
     18        Bb4 D5 F5 Bb5  F5 D5 Bb4 D5   C5 F5 A5 C6  A5 G5 F5 F5`),
     19   D: N(`A4 Bb4 C5 D5  C5 Bb4 A4 F4   G4 A4 Bb4 C5  Bb4 A4 G4 E4
     20        F4 A4 C5 F5   E5 C5 A4 C5    D5 C5 Bb4 A4  G4 C5 E5 F5`),
     21 };
     22 const HAR = {
     23   A: N(`A4 E5 A5 E5  A4 C5 E5 C5   D5 F5 A5 D6    A5 F5 D5 A4
     24        E5 G5 Bb5 E6  Bb5 G5 E5 G5  A5 F5 D5 A4    E5 A5 C6 A5`),
     25   B: N(`A5 G5 F5 E5  F5 G5 A5 C6   Bb5 A5 G5 F5   E5 F5 G5 E5
     26        C6 Bb5 A5 Bb5  C6 E6 C6 A5  Bb5 G5 E5 G5   A5 C6 Bb5 A5`),
     27   C: N(`F5 A5 C6 F6  C6 A5 F5 A5   E5 G5 Bb5 E6   Bb5 G5 E5 G5
     28        D5 F5 A5 D6  A5 F5 D5 F5   E5 A5 C6 E6    C6 Bb5 A5 A5`),
     29   D: N(`C5 D5 E5 F5  E5 D5 C5 A4   Bb4 C5 D5 E5   D5 C5 Bb4 G4
     30        A4 C5 E5 A5  G5 E5 C5 E5   F5 E5 D5 C5    Bb4 E5 G5 A5`),
     31 };
     32 const BAS = {
     33   A: N(`F2 . . . . . . .  Bb2 . . . C3 . . .   F2 . . . . . . .  Bb2 . . . C3 . . .`),
     34   B: N(`F2 . . . . . . .  C3 . . . . . . .    F2 . . . . . . .  C3 . . . F2 . . .`),
     35   C: N(`D3 . . . . . . .  C3 . . . . . . .    Bb2 . . . . . . . C3 . . . F2 . . .`),
     36   D: N(`F2 . . . . . . .  C3 . . . . . . .    F2 . . . . . . .  Bb2 . . . C3 . . .`),
     37 };
     38 const ORDER   = ['A','B','A','C','D','B','C','A'];
     39 const MELODY  = ORDER.flatMap(k => MEL[k]);
     40 const HARMONY = ORDER.flatMap(k => HAR[k]);
     41 const BASS    = ORDER.flatMap(k => BAS[k]);
     42 
     43 class AnimalLetterScene extends Phaser.Scene {
     44   constructor() {
     45     super({ key: 'AnimalLetterScene' });
     46     this.audio               = null;
     47     this.currentAnimal       = null;
     48     this.roundColor          = null;
     49     this.wrongCount          = 0;
     50     this.accepting           = false;
     51     this.lettersFound        = 0;
     52     this.winShowing          = false;
     53     this.progressGfx         = null;
     54     this.winElements         = [];
     55     this.guessTexts          = [];
     56     this.animalImg           = null;
     57     this.instrText           = null;
     58     this.nameFirstLetterText = null;
     59     this.nameRestText        = null;
     60     this.hintText            = null;
     61     this._imgScale           = 1;
     62     this._idleTweens         = [];
     63     this._deck               = [];   // shuffled queue; drawn without replacement per game
     64   }
     65 
     66   preload() {
     67     KGames.bakeFireworksAtlas(this);
     68     AnimalLetterLib.ANIMALS.forEach(a => {
     69       this.load.svg(a.key, CDN + a.codepoint + '.svg', { width: 256, height: 256 });
     70     });
     71   }
     72 
     73   create() {
     74     window.__AL_SCENE__ = this;
     75     const W = this.scale.width;
     76     const H = this.scale.height;
     77 
     78     this.audio = new KGames.AudioSystem({ melody: MELODY, harmony: HARMONY, bass: BASS });
     79 
     80     this.progressGfx = this.add.graphics();
     81 
     82     this.instrText = this.add.text(W / 2, 0, '', {
     83       fontFamily: 'Fredoka, sans-serif',
     84       color: '#888888',
     85     }).setOrigin(0.5, 0.5);
     86 
     87     this.animalImg = this.add.image(W / 2, H * 0.43, '__DEFAULT').setVisible(false);
     88 
     89     // First letter of name — shown in accent color
     90     this.nameFirstLetterText = this.add.text(0, 0, '', {
     91       fontFamily: 'Fredoka, sans-serif',
     92       fontStyle:  'bold',
     93       color:      '#ffffff',
     94     }).setOrigin(0, 0.5).setVisible(false);
     95 
     96     // Remainder of name — shown in white
     97     this.nameRestText = this.add.text(0, 0, '', {
     98       fontFamily: 'Fredoka, sans-serif',
     99       fontStyle:  'bold',
    100       color:      '#ffffff',
    101     }).setOrigin(0, 0.5).setVisible(false);
    102 
    103     // Large colored first-letter hint shown at hint level 3
    104     this.hintText = this.add.text(W / 2, 0, '', {
    105       fontFamily: 'Fredoka, sans-serif',
    106       fontStyle:  'bold',
    107     }).setOrigin(0.5, 0.5).setVisible(false);
    108 
    109     this.scale.on('resize', this.onResize, this);
    110     this.input.keyboard.on('keydown', this.onKey, this);
    111 
    112     document.getElementById('btn-music').addEventListener('click', () => this.audio.toggleMusic());
    113     document.getElementById('btn-sfx').addEventListener('click', () => this.audio.toggleSfx());
    114 
    115     this.newRound();
    116     this.applyLayout(W, H);
    117   }
    118 
    119   applyLayout(W, H) {
    120     const instrSize = Math.max(16, Math.floor(H * 0.055));
    121     const hintSize  = Math.max(40, Math.floor(H * 0.12));
    122     const imgSize   = Math.max(80, Math.floor(H * 0.38));
    123 
    124     this.instrText.setStyle({ fontSize: instrSize + 'px' });
    125     this.instrText.setPosition(W / 2, H * 0.14);
    126 
    127     const wasAccepting = this.accepting;
    128     this._stopIdleAnimation();
    129 
    130     this._imgScale = imgSize / 256;
    131     if (this.animalImg.visible) {
    132       this.animalImg.setPosition(W / 2, H * 0.43).setScale(this._imgScale);
    133     }
    134 
    135     if (wasAccepting) this._startIdleAnimation();
    136 
    137     this._reflowNameDisplay(W, H);
    138 
    139     this.hintText.setStyle({ fontSize: hintSize + 'px' });
    140     this.hintText.setPosition(W / 2, H * 0.80);
    141 
    142     KGames.drawProgressBar(this.progressGfx, W, H, this.lettersFound, GOAL);
    143     this.reflowGuesses(W, H);
    144   }
    145 
    146   onResize(gameSize) {
    147     this.applyLayout(gameSize.width, gameSize.height);
    148   }
    149 
    150   // Place both name text objects centered together at H*0.70
    151   _reflowNameDisplay(W, H) {
    152     const nameSize = Math.max(24, Math.floor(H * 0.075));
    153     this.nameFirstLetterText.setStyle({ fontSize: nameSize + 'px' });
    154     this.nameRestText.setStyle({ fontSize: nameSize + 'px' });
    155 
    156     if (!this.nameFirstLetterText.visible) return;
    157 
    158     const firstW  = this.nameFirstLetterText.width;
    159     const restW   = this.nameRestText.width;
    160     const totalW  = firstW + restW;
    161     const startX  = W / 2 - totalW / 2;
    162     this.nameFirstLetterText.setPosition(startX, H * 0.70);
    163     this.nameRestText.setPosition(startX + firstW, H * 0.70);
    164   }
    165 
    166   // Show animal name with first letter in accent color
    167   _showNameDisplay(animal, color) {
    168     const { width: W, height: H } = this.scale;
    169     const nameSize = Math.max(24, Math.floor(H * 0.075));
    170 
    171     this.nameFirstLetterText
    172       .setText(animal.name[0])
    173       .setStyle({ fontSize: nameSize + 'px', fontFamily: 'Fredoka, sans-serif', fontStyle: 'bold', color });
    174     this.nameRestText
    175       .setText(animal.name.slice(1))
    176       .setStyle({ fontSize: nameSize + 'px', fontFamily: 'Fredoka, sans-serif', fontStyle: 'bold', color: '#ffffff' });
    177 
    178     this.nameFirstLetterText.setVisible(true);
    179     this.nameRestText.setVisible(true);
    180 
    181     this._reflowNameDisplay(W, H);
    182   }
    183 
    184   _startIdleAnimation() {
    185     const { width: W } = this.scale;
    186     const swayAmt = Math.max(6, Math.floor(W * 0.012));
    187 
    188     this._idleTweens = [
    189       this.tweens.add({
    190         targets:  this.animalImg,
    191         x:        this.animalImg.x + swayAmt,
    192         ease:     'Sine.InOut',
    193         duration: 1600,
    194         yoyo:     true,
    195         repeat:   -1,
    196       }),
    197       this.tweens.add({
    198         targets:  this.animalImg,
    199         scaleX:   this._imgScale * 1.06,
    200         scaleY:   this._imgScale * 1.06,
    201         ease:     'Sine.InOut',
    202         duration: 1100,
    203         yoyo:     true,
    204         repeat:   -1,
    205       }),
    206     ];
    207   }
    208 
    209   _stopIdleAnimation() {
    210     this._idleTweens.forEach(t => { try { t.destroy(); } catch (_) {} });
    211     this._idleTweens = [];
    212   }
    213 
    214   newRound() {
    215     this._stopIdleAnimation();
    216 
    217     // Refill deck (shuffled, no-repeat) when exhausted
    218     if (this._deck.length === 0) {
    219       this._deck = AnimalLetterLib.shuffleAnimals(Math.random);
    220     }
    221     this.currentAnimal = this._deck.pop();
    222     this.wrongCount    = 0;
    223 
    224     const available = COLORS.filter(c => c !== this.roundColor);
    225     this.roundColor = available[Math.floor(Math.random() * available.length)];
    226 
    227     this.nameFirstLetterText.setVisible(false);
    228     this.nameRestText.setVisible(false);
    229     this.hintText.setVisible(false);
    230     this.instrText.setText('Press the first letter').setColor('#888888');
    231 
    232     const { width: W, height: H } = this.scale;
    233     const imgSize = Math.max(80, Math.floor(H * 0.38));
    234     this._imgScale = imgSize / 256;
    235 
    236     if (this.textures.exists(this.currentAnimal.key)) {
    237       this.animalImg
    238         .setTexture(this.currentAnimal.key)
    239         .setPosition(W / 2, H * 0.43)
    240         .setScale(this._imgScale * 0.3)
    241         .setVisible(true);
    242       this.tweens.add({
    243         targets:  this.animalImg,
    244         scaleX:   this._imgScale,
    245         scaleY:   this._imgScale,
    246         ease:     'Back.Out',
    247         duration: 250,
    248         onComplete: () => {
    249           this.accepting = true;
    250           this._startIdleAnimation();
    251         },
    252       });
    253     } else {
    254       this.animalImg.setVisible(false);
    255       this.accepting = true;
    256     }
    257 
    258     this.audio.speak(this.currentAnimal.name);
    259   }
    260 
    261   onKey(event) {
    262     if (this.winShowing) return;
    263     if (!this.accepting) return;
    264     if (event.key.length !== 1 || !/[a-z]/i.test(event.key)) return;
    265 
    266     this.audio.initMusic();
    267 
    268     const pressed = event.key.toUpperCase();
    269     if (pressed === this.currentAnimal.letter) {
    270       this.onCorrect();
    271     } else {
    272       this.onWrong(pressed);
    273     }
    274   }
    275 
    276   onCorrect() {
    277     this.accepting = false;
    278     this._stopIdleAnimation();
    279 
    280     this.audio.playSuccess();
    281     this.audio.playSuccessJingle();
    282 
    283     const { width: W, height: H } = this.scale;
    284     KGames.burstConfetti(this, W / 2, H * 0.43);
    285 
    286     // Always show the animal name with first letter accented so kids read the word
    287     this._showNameDisplay(this.currentAnimal, this.roundColor);
    288     this.audio.speak(this.currentAnimal.name);
    289 
    290     this.lettersFound++;
    291     KGames.drawProgressBar(this.progressGfx, W, H, this.lettersFound, GOAL);
    292 
    293     this.guessTexts.forEach(obj => { this.tweens.killTweensOf(obj); obj.destroy(); });
    294     this.guessTexts = [];
    295 
    296     if (this.lettersFound >= GOAL) {
    297       this.time.delayedCall(1500, () => {
    298         this.winShowing = true;
    299         this.accepting  = false;
    300         this.winElements = KGames.showWinScreen(this, {
    301           title:       'Amazing!',
    302           subtitle:    'You know all the animal letters!',
    303           audio:       this.audio,
    304           onPlayAgain: () => this.resetGame(),
    305         });
    306       });
    307     } else {
    308       this.time.delayedCall(1500, () => {
    309         this.newRound();
    310         this.applyLayout(this.scale.width, this.scale.height);
    311       });
    312     }
    313   }
    314 
    315   onWrong(pressed) {
    316     this.audio.playBong();
    317     this.audio.playFailLick();
    318     this.wrongCount++;
    319     this.updateHints();
    320     this.addGuessLetter(pressed);
    321   }
    322 
    323   updateHints() {
    324     const animal = this.currentAnimal;
    325     const { width: W, height: H } = this.scale;
    326     const hintSize = Math.max(40, Math.floor(H * 0.12));
    327 
    328     if (this.wrongCount === 1) {
    329       // Show name with first letter accented — speak it so child can sound out the first letter
    330       this._showNameDisplay(animal, this.roundColor);
    331       this.audio.speak(animal.name);
    332     } else if (this.wrongCount >= 2) {
    333       // Show big colored first letter + update instruction — speak just the letter
    334       this.hintText
    335         .setText(animal.letter)
    336         .setStyle({ fontSize: hintSize + 'px', color: this.roundColor })
    337         .setPosition(W / 2, H * 0.80)
    338         .setVisible(true);
    339       this.instrText.setText('Press  ' + animal.letter).setColor(this.roundColor);
    340       this.audio.speak(animal.letter);
    341     }
    342   }
    343 
    344   addGuessLetter(char) {
    345     const { width: W, height: H } = this.scale;
    346     const size = Math.max(20, Math.floor(H * 0.10));
    347 
    348     const text = this.add.text(W / 2, H * 0.88, char, {
    349       fontFamily: 'Fredoka, sans-serif',
    350       fontSize:   size + 'px',
    351       fontStyle:  'bold',
    352       color:      '#cc4444',
    353     }).setOrigin(0.5).setAlpha(0).setScale(0.3);
    354 
    355     this.tweens.add({
    356       targets: text,
    357       scaleX: 1, scaleY: 1, alpha: 1,
    358       ease: 'Back.Out',
    359       duration: 150,
    360     });
    361 
    362     this.guessTexts.push(text);
    363     this.reflowGuesses(W, H);
    364 
    365     this.time.delayedCall(1200, () => {
    366       if (!text.active) return;
    367       this.tweens.add({
    368         targets: text,
    369         alpha: 0, scaleX: 0.2, scaleY: 0.2,
    370         duration: 200,
    371         onComplete: () => {
    372           text.destroy();
    373           this.guessTexts = this.guessTexts.filter(t => t !== text);
    374           this.reflowGuesses(this.scale.width, this.scale.height);
    375         },
    376       });
    377     });
    378   }
    379 
    380   reflowGuesses(W, H) {
    381     const n = this.guessTexts.length;
    382     if (n === 0) return;
    383     const size    = Math.max(20, Math.floor(H * 0.10));
    384     const spacing = size * 1.15;
    385     const startX  = W / 2 - ((n - 1) * spacing) / 2;
    386     this.guessTexts.forEach((obj, i) => {
    387       obj.setPosition(startX + i * spacing, H * 0.88);
    388       obj.setStyle({ fontSize: size + 'px' });
    389     });
    390   }
    391 
    392   resetGame() {
    393     this._stopIdleAnimation();
    394 
    395     this.winElements.forEach(el => { if (el.active) el.destroy(); });
    396     this.winElements = [];
    397     this.winShowing  = false;
    398 
    399     this.guessTexts.forEach(obj => { if (obj.active) obj.destroy(); });
    400     this.guessTexts = [];
    401 
    402     this.lettersFound  = 0;
    403     this.currentAnimal = null;
    404     this.roundColor    = null;
    405     this.wrongCount    = 0;
    406     this._deck         = [];  // fresh shuffle for new game
    407 
    408     const { width: W, height: H } = this.scale;
    409     KGames.drawProgressBar(this.progressGfx, W, H, this.lettersFound, GOAL);
    410     this.newRound();
    411     this.applyLayout(W, H);
    412   }
    413 }
    414 
    415 window.__KG_GAME__ = new Phaser.Game({
    416   type:   Phaser.AUTO,
    417   parent: 'game-container',
    418   backgroundColor: '#1A1A2E',
    419   scene:  AnimalLetterScene,
    420   scale: {
    421     mode:   Phaser.Scale.RESIZE,
    422     width:  '100%',
    423     height: '100%',
    424   },
    425   render: { preserveDrawingBuffer: true },
    426 });