diff --git a/src/managers/SoundManager.js b/src/managers/SoundManager.js new file mode 100644 index 0000000..43dafb0 --- /dev/null +++ b/src/managers/SoundManager.js @@ -0,0 +1,131 @@ +class SoundManager { + constructor() { + this.ctx = null; + this.master = null; + this.muted = false; + this.musicNodes = null; + + const raw = localStorage.getItem('naddie_muted'); + this.muted = raw === '1'; + } + + init() { + if (this.ctx) return; + const AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return; + this.ctx = new AC(); + this.master = this.ctx.createGain(); + this.master.gain.value = 0.4; + this.master.connect(this.ctx.destination); + } + + resume() { + if (this.ctx && this.ctx.state === 'suspended') { + this.ctx.resume(); + } + } + + setMuted(value) { + this.muted = value; + localStorage.setItem('naddie_muted', value ? '1' : '0'); + if (this.master) { + this.master.gain.value = value ? 0 : 0.4; + } + } + + isMuted() { + return this.muted; + } + + _beep(freq, duration, type = 'square', volume = 0.3) { + if (!this.ctx || this.muted) return; + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(freq, this.ctx.currentTime); + gain.gain.setValueAtTime(volume, this.ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration); + osc.connect(gain); + gain.connect(this.master); + osc.start(); + osc.stop(this.ctx.currentTime + duration); + } + + _sweep(freqStart, freqEnd, duration, type = 'square', volume = 0.3) { + if (!this.ctx || this.muted) return; + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(freqStart, this.ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(Math.max(1, freqEnd), this.ctx.currentTime + duration); + gain.gain.setValueAtTime(volume, this.ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration); + osc.connect(gain); + gain.connect(this.master); + osc.start(); + osc.stop(this.ctx.currentTime + duration); + } + + _noise(duration, volume = 0.15) { + if (!this.ctx || this.muted) return; + const bufferSize = this.ctx.sampleRate * duration; + const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize); + } + const src = this.ctx.createBufferSource(); + src.buffer = buffer; + const gain = this.ctx.createGain(); + gain.gain.value = volume; + src.connect(gain); + gain.connect(this.master); + src.start(); + } + + jump() { + this._sweep(320, 540, 0.1, 'square', 0.14); + } + + land() { + this._beep(180, 0.06, 'triangle', 0.10); + } + + spring() { + this._sweep(280, 1000, 0.18, 'square', 0.22); + setTimeout(() => this._sweep(800, 1300, 0.12, 'triangle', 0.15), 50); + } + + powerup() { + [440, 554, 659, 880].forEach((f, i) => setTimeout(() => this._beep(f, 0.13, 'triangle', 0.20), i * 55)); + } + + stomp() { + this._sweep(420, 100, 0.16, 'sawtooth', 0.22); + this._noise(0.08, 0.10); + } + + break() { + this._noise(0.15, 0.12); + this._sweep(200, 60, 0.2, 'sawtooth', 0.10); + } + + death() { + this._sweep(280, 50, 0.5, 'sawtooth', 0.28); + setTimeout(() => this._noise(0.3, 0.15), 100); + } + + milestone() { + [523, 659, 783, 1046].forEach((f, i) => setTimeout(() => this._beep(f, 0.22, 'triangle', 0.25), i * 90)); + } + + click() { + this._beep(660, 0.05, 'square', 0.12); + } + + newBest() { + [523, 659, 783, 1046, 1318].forEach((f, i) => setTimeout(() => this._beep(f, 0.18, 'triangle', 0.25), i * 80)); + } +} + +export const sound = new SoundManager(); diff --git a/src/scenes/GameOverScene.js b/src/scenes/GameOverScene.js index cc20c45..6b10a09 100644 --- a/src/scenes/GameOverScene.js +++ b/src/scenes/GameOverScene.js @@ -1,4 +1,5 @@ import { Scene } from 'phaser'; +import { sound } from '../managers/SoundManager.js'; export class GameOverScene extends Scene { constructor() { @@ -16,19 +17,9 @@ export class GameOverScene extends Scene { this.add.tileSprite(width / 2, height / 2, width, height, 'gridBg'); - // Dead Naddie image - const naddie = this.add.image(width / 2, height * 0.22, 'player_dead') - .setScale(0.32); - this.tweens.add({ - targets: naddie, - angle: -10, - duration: 2000, - yoyo: true, - repeat: -1, - ease: 'Sine.easeInOut', - }); + const naddie = this.add.image(width / 2, height * 0.22, 'player_dead').setScale(0.32); + this.tweens.add({ targets: naddie, angle: -10, duration: 2000, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); - // Game Over text this.add.text(width / 2, height * 0.38, 'GAME OVER', { fontFamily: '"Press Start 2P", monospace', fontSize: '32px', @@ -37,11 +28,12 @@ export class GameOverScene extends Scene { }).setOrigin(0.5).setShadow(3, 3, '#7f1d1d', 0, false, true); if (this.isNewBest) { - this.add.text(width / 2, height * 0.46, 'NEW BEST!', { + const best = this.add.text(width / 2, height * 0.46, 'NEW BEST!', { fontFamily: '"Press Start 2P", monospace', fontSize: '14px', color: '#22c55e', }).setOrigin(0.5); + this.tweens.add({ targets: best, scale: 1.15, duration: 600, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); } this.add.text(width / 2, height * 0.54, `Block Height: ${this.blockHeight}`, { @@ -57,10 +49,12 @@ export class GameOverScene extends Scene { }).setOrigin(0.5); this.createButton(width / 2, height * 0.72, 'RETRY', () => { + sound.click(); this.scene.start('GameScene'); }); this.createButton(width / 2, height * 0.82, 'MAIN MENU', () => { + sound.click(); this.scene.start('MenuScene'); }); @@ -70,6 +64,15 @@ export class GameOverScene extends Scene { color: '#666', align: 'center', }).setOrigin(0.5); + + this.input.keyboard.once('keydown-ENTER', () => { + sound.click(); + this.scene.start('GameScene'); + }); + this.input.keyboard.once('keydown-SPACE', () => { + sound.click(); + this.scene.start('GameScene'); + }); } createButton(x, y, text, callback) { diff --git a/src/scenes/GameScene.js b/src/scenes/GameScene.js index de7a55c..5e07dea 100644 --- a/src/scenes/GameScene.js +++ b/src/scenes/GameScene.js @@ -3,6 +3,7 @@ import { Player } from '../entities/Player.js'; import { Platform } from '../entities/Platform.js'; import { PlatformManager } from '../managers/PlatformManager.js'; import { ScoreManager } from '../managers/ScoreManager.js'; +import { sound } from '../managers/SoundManager.js'; import { GAME_WIDTH, GAME_HEIGHT, SCORE, PHYSICS } from '../config/game.config.js'; export class GameScene extends Scene { @@ -11,6 +12,9 @@ export class GameScene extends Scene { } create() { + sound.init(); + sound.resume(); + this.bg = this.add.tileSprite(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 'gridBg') .setScrollFactor(0) .setDepth(-10); @@ -19,6 +23,7 @@ export class GameScene extends Scene { this.cursors = this.input.keyboard.createCursorKeys(); this.wasd = this.input.keyboard.addKeys({ up: 'W', down: 'S', left: 'A', right: 'D' }); + this.escKey = this.input.keyboard.addKey('ESC'); this.touchLeft = false; this.touchRight = false; this.setupTouchControls(); @@ -52,12 +57,23 @@ export class GameScene extends Scene { this.cameras.main.startFollow(this.player, true, 0, 0.05, 0, 180); this.isGameOver = false; + this.isPaused = false; this.difficultyLevel = 0; this.minScrollY = this.cameras.main.scrollY; + + this.onMilestone = () => sound.milestone(); + + this.createPauseUI(); + this.createMuteButton(); + + this.escKey.on('down', () => this.togglePause()); + + this.maybeShowTutorial(); + this.showTouchIndicators(); } update(time, delta) { - if (this.isGameOver) return; + if (this.isGameOver || this.isPaused) return; this.player.update(this.cursors, this.wasd, this.touchLeft, this.touchRight, time, delta); @@ -130,9 +146,12 @@ export class GameScene extends Scene { if (platform && typeof platform.onPlayerLand === 'function') { platform.onPlayerLand(player); this.scoreManager.onLand(platform.y, platform.platformType || 'stable'); + if (platform.platformType === 'breaking') sound.break(); } this.lastJumpY = player.y; - player.jump(); + if (player.jump()) { + sound.jump(); + } this.createJumpParticles(player.x, player.y + player.displayHeight / 2 + 3); } @@ -141,10 +160,13 @@ export class GameScene extends Scene { if (powerup && typeof powerup.onPlayerTouch === 'function') { const px = powerup.x; const py = powerup.y; + const isSpring = powerup.constructor.name === 'Spring'; const consumed = powerup.onPlayerTouch(player); if (consumed) { this.createPowerupParticles(px, py); this.flashScreen(); + if (isSpring) sound.spring(); + else sound.powerup(); } } } @@ -154,18 +176,21 @@ export class GameScene extends Scene { if (player.state === 'rocket') { this.createExplosion(enemy.x, enemy.y); enemy.destroy(); + sound.stomp(); return; } if (player.state === 'propeller') { player.endPowerUp(); this.createExplosion(enemy.x, enemy.y); enemy.destroy(); + sound.stomp(); return; } if (player.body.velocity.y > 0 && player.body.bottom <= enemy.body.top + PHYSICS.stompTolerance) { this.createExplosion(enemy.x, enemy.y); enemy.destroy(); player.jump(); + sound.stomp(); this.scoreManager.addPoints(SCORE.stompBonus); this.createJumpParticles(player.x, player.y + player.displayHeight / 2 + 3); return; @@ -177,11 +202,15 @@ export class GameScene extends Scene { if (this.isGameOver) return; this.isGameOver = true; this.player.die(); + sound.death(); this.cameras.main.shake(300, 0.012); const bx = ex ?? this.player.x; const by = ey ?? this.player.y; this.createExplosion(bx, by); const isNewBest = this.scoreManager.saveBest(); + if (isNewBest) { + this.time.delayedCall(400, () => sound.newBest()); + } const score = this.scoreManager.score; const blockHeight = this.scoreManager.blockHeight; @@ -191,6 +220,160 @@ export class GameScene extends Scene { }); } + togglePause() { + if (this.isGameOver) return; + this.isPaused = !this.isPaused; + if (this.isPaused) { + this.physics.pause(); + this.pauseOverlay.setVisible(true); + sound.click(); + } else { + this.physics.resume(); + this.pauseOverlay.setVisible(false); + sound.click(); + } + } + + createPauseUI() { + const { width, height } = this.scale; + const container = this.add.container(0, 0).setScrollFactor(0).setDepth(500).setVisible(false); + + const dim = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.65); + const panel = this.add.rectangle(width / 2, height / 2, 320, 280, 0x1a0533).setStrokeStyle(3, 0xa855f7); + + const title = this.add.text(width / 2, height / 2 - 90, 'PAUSED', { + fontFamily: '"Press Start 2P", monospace', + fontSize: '22px', + color: '#d8b4fe', + }).setOrigin(0.5); + + const resume = this.add.rectangle(width / 2, height / 2 - 20, 220, 44, 0x581c87) + .setStrokeStyle(2, 0xa855f7).setInteractive({ useHandCursor: true }); + const resumeLabel = this.add.text(width / 2, height / 2 - 20, 'RESUME', { + fontFamily: '"Press Start 2P", monospace', fontSize: '13px', color: '#fff', + }).setOrigin(0.5); + resume.on('pointerdown', () => this.togglePause()); + + const quit = this.add.rectangle(width / 2, height / 2 + 40, 220, 44, 0x581c87) + .setStrokeStyle(2, 0xa855f7).setInteractive({ useHandCursor: true }); + const quitLabel = this.add.text(width / 2, height / 2 + 40, 'MAIN MENU', { + fontFamily: '"Press Start 2P", monospace', fontSize: '13px', color: '#fff', + }).setOrigin(0.5); + quit.on('pointerdown', () => { + sound.click(); + this.physics.resume(); + this.scoreManager.destroy(); + this.scene.start('MenuScene'); + }); + + const hint = this.add.text(width / 2, height / 2 + 100, 'ESC to resume', { + fontFamily: '"Press Start 2P", monospace', fontSize: '9px', color: '#888', + }).setOrigin(0.5); + + container.add([dim, panel, title, resume, resumeLabel, quit, quitLabel, hint]); + this.pauseOverlay = container; + + const pauseBtn = this.add.text(GAME_WIDTH - 16, 50, '⏸', { + fontSize: '22px', + }).setOrigin(1, 0.5).setScrollFactor(0).setDepth(300).setInteractive({ useHandCursor: true }); + pauseBtn.on('pointerdown', () => this.togglePause()); + } + + createMuteButton() { + const icon = this.add.text(GAME_WIDTH - 16, 80, sound.isMuted() ? '🔇' : '🔊', { + fontSize: '18px', + }).setOrigin(1, 0.5).setScrollFactor(0).setDepth(300).setInteractive({ useHandCursor: true }); + icon.on('pointerdown', () => { + const next = !sound.isMuted(); + sound.setMuted(next); + icon.setText(next ? '🔇' : '🔊'); + if (!next) sound.click(); + }); + } + + maybeShowTutorial() { + if (localStorage.getItem('naddie_tutorial_seen') === '1') return; + + const { width, height } = this.scale; + this.isPaused = true; + this.physics.pause(); + + const container = this.add.container(0, 0).setScrollFactor(0).setDepth(600); + const dim = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.75); + const panel = this.add.rectangle(width / 2, height / 2, 380, 480, 0x1a0533).setStrokeStyle(3, 0xa855f7); + + const title = this.add.text(width / 2, height * 0.22, 'HOW TO PLAY', { + fontFamily: '"Press Start 2P", monospace', + fontSize: '18px', + color: '#d8b4fe', + }).setOrigin(0.5); + + const lines = [ + '← → / A D — Move', + 'TAP LEFT / RIGHT — Move', + 'AUTO-JUMP on platforms', + '', + 'PURPLE — Stable platform', + 'GOLD — Genesis (x2 bonus)', + 'GREY — Breaks on landing', + 'MOVING — Slides side to side', + '', + 'Avoid BUGS, grab POWER-UPS', + 'ESC — Pause', + ]; + + const texts = lines.map((l, i) => this.add.text(width / 2, height * 0.30 + i * 24, l, { + fontFamily: '"Press Start 2P", monospace', + fontSize: '10px', + color: l.includes('Genesis') ? '#ffd700' : l.includes('Breaks') ? '#999' : '#d8b4fe', + align: 'center', + }).setOrigin(0.5)); + + const startBtn = this.add.rectangle(width / 2, height * 0.85, 220, 48, 0x581c87) + .setStrokeStyle(2, 0xa855f7).setInteractive({ useHandCursor: true }); + const startLabel = this.add.text(width / 2, height * 0.85, "LET'S JUMP!", { + fontFamily: '"Press Start 2P", monospace', fontSize: '14px', color: '#fff', + }).setOrigin(0.5); + + container.add([dim, panel, title, ...texts, startBtn, startLabel]); + + startBtn.on('pointerdown', () => { + sound.click(); + localStorage.setItem('naddie_tutorial_seen', '1'); + container.destroy(); + this.isPaused = false; + this.physics.resume(); + }); + } + + showTouchIndicators() { + if (!this.sys.game.device.input.touch) return; + const { width, height } = this.scale; + + const left = this.add.rectangle(width / 4, height - 100, width / 2 - 20, 60, 0xa855f7, 0.15) + .setScrollFactor(0).setDepth(250); + const leftText = this.add.text(width / 4, height - 100, '◀ TAP', { + fontFamily: '"Press Start 2P", monospace', fontSize: '12px', color: '#fff', + }).setOrigin(0.5).setScrollFactor(0).setDepth(251).setAlpha(0.7); + + const right = this.add.rectangle(3 * width / 4, height - 100, width / 2 - 20, 60, 0xa855f7, 0.15) + .setScrollFactor(0).setDepth(250); + const rightText = this.add.text(3 * width / 4, height - 100, 'TAP ▶', { + fontFamily: '"Press Start 2P", monospace', fontSize: '12px', color: '#fff', + }).setOrigin(0.5).setScrollFactor(0).setDepth(251).setAlpha(0.7); + + this.tweens.add({ + targets: [left, right, leftText, rightText], + alpha: 0, + delay: 3500, + duration: 1500, + onComplete: () => { + left.destroy(); right.destroy(); + leftText.destroy(); rightText.destroy(); + }, + }); + } + flashScreen() { const flash = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0xffffff, 0.3); flash.setScrollFactor(0); @@ -228,7 +411,6 @@ export class GameScene extends Scene { Phaser.Math.Between(0, GAME_HEIGHT), 'gwei' ).setAlpha(0.5).setScrollFactor(0.3).setDepth(-8); - const scene = this; this.tweens.add({ targets: p, y: p.y - Phaser.Math.Between(100, 400), @@ -236,7 +418,7 @@ export class GameScene extends Scene { duration: Phaser.Math.Between(3000, 7000), repeat: -1, delay: Phaser.Math.Between(0, 4000), - onRepeat: function() { + onRepeat: () => { p.y = Phaser.Math.Between(0, GAME_HEIGHT); p.x = Phaser.Math.Between(0, GAME_WIDTH); p.setAlpha(0.5); diff --git a/src/scenes/MenuScene.js b/src/scenes/MenuScene.js index ced1c89..6c3a7cc 100644 --- a/src/scenes/MenuScene.js +++ b/src/scenes/MenuScene.js @@ -1,4 +1,5 @@ import { Scene } from 'phaser'; +import { sound } from '../managers/SoundManager.js'; export class MenuScene extends Scene { constructor() { @@ -8,10 +9,8 @@ export class MenuScene extends Scene { create() { const { width, height } = this.scale; - // Background this.add.tileSprite(width / 2, height / 2, width, height, 'gridBg'); - // Title this.add.text(width / 2, height * 0.18, 'NADDIE JUMP', { fontFamily: '"Press Start 2P", monospace', fontSize: '38px', @@ -26,51 +25,27 @@ export class MenuScene extends Scene { align: 'center', }).setOrigin(0.5); - // Floating Naddie preview - const preview = this.add.image(width / 2, height * 0.48, 'player_idle') - .setScale(0.55); - this.tweens.add({ - targets: preview, - y: height * 0.48 - 15, - duration: 1400, - yoyo: true, - repeat: -1, - ease: 'Sine.easeInOut', - }); - this.tweens.add({ - targets: preview, - angle: 5, - duration: 2000, - yoyo: true, - repeat: -1, - ease: 'Sine.easeInOut', - }); + const preview = this.add.image(width / 2, height * 0.48, 'player_idle').setScale(0.55); + this.tweens.add({ targets: preview, y: height * 0.48 - 15, duration: 1400, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + this.tweens.add({ targets: preview, angle: 5, duration: 2000, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); - // Start button - this.createButton(width / 2, height * 0.68, 'START GAME', () => { - this.scene.start('GameScene'); - }); + this.createButton(width / 2, height * 0.68, 'START GAME', () => this.startGame()); - this.add.text(width / 2, height * 0.74, 'Press ENTER or SPACE to start', { + this.add.text(width / 2, height * 0.74, 'ENTER / SPACE / TAP', { fontFamily: '"Press Start 2P", monospace', fontSize: '10px', color: '#888', align: 'center', }).setOrigin(0.5); - this.input.keyboard.on('keydown-ENTER', () => { - this.scene.start('GameScene'); - }); - this.input.keyboard.on('keydown-SPACE', () => { - this.scene.start('GameScene'); - }); + this.input.keyboard.on('keydown-ENTER', () => this.startGame()); + this.input.keyboard.on('keydown-SPACE', () => this.startGame()); - // Leaderboard button - this.createButton(width / 2, height * 0.78, 'LEADERBOARD', () => { - this.showLeaderboard(); - }); + this.createButton(width / 2, height * 0.82, 'LEADERBOARD', () => this.showLeaderboard()); - this.add.text(width / 2, height * 0.92, 'Web3 integration coming soon', { + this.createMuteButton(); + + this.add.text(width / 2, height * 0.94, 'Web3 integration coming soon', { fontFamily: '"Press Start 2P", monospace', fontSize: '9px', color: '#444', @@ -78,6 +53,22 @@ export class MenuScene extends Scene { }).setOrigin(0.5); this.createAmbientParticles(); + + this.input.once('pointerdown', () => { + sound.init(); + sound.resume(); + }); + this.input.keyboard.once('keydown', () => { + sound.init(); + sound.resume(); + }); + } + + startGame() { + sound.init(); + sound.resume(); + sound.click(); + this.scene.start('GameScene'); } createButton(x, y, text, callback) { @@ -99,11 +90,31 @@ export class MenuScene extends Scene { bg.setFillStyle(0x581c87); this.tweens.add({ targets: [bg, label], scaleX: 1, scaleY: 1, duration: 100 }); }); - bg.on('pointerdown', callback); + bg.on('pointerdown', () => { + sound.click(); + callback(); + }); return { bg, label }; } + createMuteButton() { + const { width } = this.scale; + const x = width - 30; + const y = 30; + const icon = this.add.text(x, y, sound.isMuted() ? '🔇' : '🔊', { + fontSize: '24px', + }).setOrigin(0.5).setInteractive({ useHandCursor: true }); + + icon.on('pointerdown', () => { + sound.init(); + const next = !sound.isMuted(); + sound.setMuted(next); + icon.setText(next ? '🔇' : '🔊'); + if (!next) sound.click(); + }); + } + showLeaderboard() { const { width, height } = this.scale; const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.7).setDepth(100); @@ -138,6 +149,7 @@ export class MenuScene extends Scene { }); close.on('pointerdown', () => { + sound.click(); overlay.destroy(); panel.destroy(); title.destroy();