kgames

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

game.js (8172B)


      1 const COLORS = ['#E63946', '#1D7CF2', '#FFD23F', '#2EC4B6'];
      2 const CHARS  = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
      3 const GOAL   = 10;
      4 
      5 // ~1-minute song: 16-step phrases (eighth notes at 128 BPM) concatenated so the
      6 // loop is long enough not to grate. 'A' is the original theme; B–F are new
      7 // sections in the same C-major character. '.' = rest. Harmony is a diatonic
      8 // 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('C5 E5 G5 A5 G5 E5 C5 E5  F5 A5 C6 A5 G5 B4 C5 G4'),
     13   B: N('E5 D5 C5 D5 E5 G5 E5 C5  D5 F5 A5 F5 E5 D5 C5 G4'),
     14   C: N('G5 A5 B5 C6 B5 A5 G5 E5  A5 G5 F5 E5 D5 E5 F5 D5'),
     15   D: N('C5 D5 E5 F5 E5 D5 C5 A4  B4 C5 D5 E5 D5 C5 B4 G4'),
     16   E: N('C6 B5 A5 G5 A5 B5 C6 E6  D6 C6 B5 A5 G5 F5 E5 D5'),
     17   F: N('E5 G5 C6 G5 E5 C5 D5 E5  F5 E5 D5 C5 D5 B4 C5 C5'),
     18 };
     19 const HAR = {
     20   A: N('E5 G5 B5 C6 B5 G5 E5 G5  A5 C6 E6 C6 B5 D5 E5 B4'),
     21   B: N('G5 F5 E5 F5 G5 B5 G5 E5  F5 A5 C6 A5 G5 F5 E5 B4'),
     22   C: N('B5 C6 D6 E6 D6 C6 B5 G5  C6 B5 A5 G5 F5 G5 A5 F5'),
     23   D: N('E5 F5 G5 A5 G5 F5 E5 C5  D5 E5 F5 G5 F5 E5 D5 B4'),
     24   E: N('E6 D6 C6 B5 C6 D6 E6 G6  F6 E6 D6 C6 B5 A5 G5 F5'),
     25   F: N('G5 B5 E6 B5 G5 E5 F5 G5  A5 G5 F5 E5 F5 D5 E5 E5'),
     26 };
     27 const BAS = {
     28   A: N('C3 . . . . . . .  F2 . . . G2 . . .'),
     29   B: N('C3 . . . . . . .  D3 . . . G2 . . .'),
     30   C: N('G2 . . . . . . .  F2 . . . G2 . . .'),
     31   D: N('C3 . . . A2 . . .  G2 . . . G2 . . .'),
     32   E: N('C3 . . . F2 . . .  G2 . . . G2 . . .'),
     33   F: N('C3 . . . F2 . . .  G2 . C3 . C3 . . .'),
     34 };
     35 const ORDER   = ['A','B','A','C','D','B','E','F','A','C','D','B','E','C','F','A'];
     36 const MELODY  = ORDER.flatMap(k => MEL[k]);
     37 const HARMONY = ORDER.flatMap(k => HAR[k]);
     38 const BASS    = ORDER.flatMap(k => BAS[k]);
     39 
     40 class LetterFindScene extends Phaser.Scene {
     41   constructor() {
     42     super({ key: 'LetterFindScene' });
     43     this.audio        = null;
     44     this.targetChar   = null;
     45     this.targetColor  = null;
     46     this.targetText   = null;
     47     this.instrText    = null;
     48     this.guessTexts   = [];
     49     this.accepting    = false;
     50     this.firstRound   = true;
     51     this.lettersFound = 0;
     52     this.winShowing   = false;
     53     this.progressGfx  = null;
     54     this.winElements  = [];
     55   }
     56 
     57   preload() {
     58     KGames.bakeFireworksAtlas(this);
     59   }
     60 
     61   create() {
     62     window.__LF_SCENE__ = this;
     63     const W = this.scale.width;
     64     const H = this.scale.height;
     65 
     66     this.audio = new KGames.AudioSystem({ melody: MELODY, harmony: HARMONY, bass: BASS });
     67 
     68     this.progressGfx = this.add.graphics();
     69 
     70     this.instrText = this.add.text(W / 2, 0, 'Find and press the matching letter', {
     71       fontFamily: 'Fredoka, sans-serif',
     72       color: '#888888',
     73     }).setOrigin(0.5, 0.5);
     74 
     75     this.targetText = this.add.text(W / 2, 0, '', {
     76       fontFamily: 'Fredoka, sans-serif',
     77       fontStyle: 'bold',
     78       color: '#ffffff',
     79     }).setOrigin(0.5, 0.5);
     80 
     81     this.scale.on('resize', this.onResize, this);
     82     this.input.keyboard.on('keydown', this.onKey, this);
     83 
     84     document.getElementById('btn-music').addEventListener('click', () => this.audio.toggleMusic());
     85     document.getElementById('btn-sfx').addEventListener('click', () => this.audio.toggleSfx());
     86 
     87     this.newTarget();
     88     this.applyLayout(W, H);
     89   }
     90 
     91   applyLayout(W, H) {
     92     const instrSize  = Math.max(16, Math.floor(H * 0.055));
     93     const targetSize = Math.max(48, Math.floor(H * 0.44));
     94 
     95     this.instrText.setStyle({ fontSize: instrSize + 'px' });
     96     this.instrText.setPosition(W / 2, H * 0.15);
     97 
     98     this.targetText.setStyle({ fontSize: targetSize + 'px' });
     99     this.targetText.setPosition(W / 2, H * 0.47);
    100 
    101     KGames.drawProgressBar(this.progressGfx, W, H, this.lettersFound, GOAL);
    102     this.reflowGuesses(W, H);
    103   }
    104 
    105   onResize(gameSize) {
    106     this.applyLayout(gameSize.width, gameSize.height);
    107   }
    108 
    109   newTarget() {
    110     let char, color;
    111     do { char = CHARS[Phaser.Math.Between(0, CHARS.length - 1)]; }
    112     while (char === this.targetChar);
    113 
    114     const available = COLORS.filter(c => c !== this.targetColor);
    115     color = Phaser.Math.RND.pick(available);
    116 
    117     this.targetChar  = char;
    118     this.targetColor = color;
    119     this.targetText.setText(char).setColor(color).setScale(0.3);
    120 
    121     this.tweens.add({
    122       targets: this.targetText,
    123       scaleX: 1, scaleY: 1,
    124       ease: 'Back.Out',
    125       duration: 200,
    126       onComplete: () => { this.accepting = true; },
    127     });
    128 
    129     if (this.firstRound) {
    130       this.audio.speak('find the letter. ' + char);
    131       this.firstRound = false;
    132     } else {
    133       this.audio.speak(char);
    134     }
    135   }
    136 
    137   onKey(event) {
    138     if (this.winShowing) return;
    139     if (!this.accepting) return;
    140     if (event.key.length !== 1 || !/[a-z]/i.test(event.key)) return;
    141 
    142     this.audio.initMusic();
    143 
    144     const pressed = event.key.toUpperCase();
    145     if (pressed === this.targetChar) {
    146       this.onCorrect();
    147     } else {
    148       this.onWrong(pressed);
    149     }
    150   }
    151 
    152   onCorrect() {
    153     this.accepting = false;
    154     this.audio.playSuccess();
    155     this.audio.playSuccessJingle();
    156     KGames.burstConfetti(this, this.scale.width / 2, this.scale.height * 0.47);
    157 
    158     this.lettersFound++;
    159     KGames.drawProgressBar(this.progressGfx, this.scale.width, this.scale.height, this.lettersFound, GOAL);
    160 
    161     this.guessTexts.forEach(obj => { this.tweens.killTweensOf(obj); obj.destroy(); });
    162     this.guessTexts = [];
    163 
    164     if (this.lettersFound >= GOAL) {
    165       this.time.delayedCall(700, () => {
    166         this.winShowing = true;
    167         this.accepting  = false;
    168         this.winElements = KGames.showWinScreen(this, {
    169           title:       'You did it!',
    170           subtitle:    'You found all 10 letters!',
    171           audio:       this.audio,
    172           onPlayAgain: () => this.resetGame(),
    173         });
    174       });
    175     } else {
    176       this.time.delayedCall(950, () => {
    177         this.newTarget();
    178         this.applyLayout(this.scale.width, this.scale.height);
    179       });
    180     }
    181   }
    182 
    183   onWrong(char) {
    184     this.audio.playBong();
    185     this.audio.playFailLick();
    186     this.addGuessLetter(char);
    187   }
    188 
    189   addGuessLetter(char) {
    190     const { width: W, height: H } = this.scale;
    191     const size = Math.max(20, Math.floor(H * 0.12));
    192 
    193     const text = this.add.text(W / 2, H * 0.78, char, {
    194       fontFamily: 'Fredoka, sans-serif',
    195       fontSize:   size + 'px',
    196       fontStyle:  'bold',
    197       color:      '#cc4444',
    198     }).setOrigin(0.5).setAlpha(0).setScale(0.3);
    199 
    200     this.tweens.add({
    201       targets: text,
    202       scaleX: 1, scaleY: 1, alpha: 1,
    203       ease: 'Back.Out',
    204       duration: 150,
    205     });
    206 
    207     this.guessTexts.push(text);
    208     this.reflowGuesses(W, H);
    209 
    210     this.time.delayedCall(1200, () => {
    211       if (!text.active) return;
    212       this.tweens.add({
    213         targets: text,
    214         alpha: 0, scaleX: 0.2, scaleY: 0.2,
    215         duration: 200,
    216         onComplete: () => {
    217           text.destroy();
    218           this.guessTexts = this.guessTexts.filter(t => t !== text);
    219           this.reflowGuesses(this.scale.width, this.scale.height);
    220         },
    221       });
    222     });
    223   }
    224 
    225   reflowGuesses(W, H) {
    226     const n = this.guessTexts.length;
    227     if (n === 0) return;
    228     const size    = Math.max(20, Math.floor(H * 0.12));
    229     const spacing = size * 1.15;
    230     const startX  = W / 2 - ((n - 1) * spacing) / 2;
    231     this.guessTexts.forEach((obj, i) => {
    232       obj.setPosition(startX + i * spacing, H * 0.78);
    233       obj.setStyle({ fontSize: size + 'px' });
    234     });
    235   }
    236 
    237   resetGame() {
    238     this.winElements.forEach(el => { if (el.active) el.destroy(); });
    239     this.winElements = [];
    240     this.winShowing  = false;
    241 
    242     this.guessTexts.forEach(obj => { if (obj.active) obj.destroy(); });
    243     this.guessTexts = [];
    244 
    245     this.lettersFound = 0;
    246     this.firstRound   = true;
    247     this.targetChar   = null;
    248     this.targetColor  = null;
    249 
    250     const { width: W, height: H } = this.scale;
    251     KGames.drawProgressBar(this.progressGfx, W, H, this.lettersFound, GOAL);
    252     this.newTarget();
    253     this.applyLayout(W, H);
    254   }
    255 }
    256 
    257 window.__KG_GAME__ = new Phaser.Game({
    258   type:   Phaser.AUTO,
    259   parent: 'game-container',
    260   backgroundColor: '#1A1A2E',
    261   scene:  LetterFindScene,
    262   scale: {
    263     mode:   Phaser.Scale.RESIZE,
    264     width:  '100%',
    265     height: '100%',
    266   },
    267   render: { preserveDrawingBuffer: true },
    268 });