diff --git a/game/AudioEngine.ts b/game/AudioEngine.ts new file mode 100644 index 0000000..5b057a6 --- /dev/null +++ b/game/AudioEngine.ts @@ -0,0 +1,289 @@ +/** + * AudioEngine — 纯 Web Audio API 程序化合成,零音频文件依赖 + * + * 使用单例模式,所有音效通过 AudioEngine.getInstance().play('soundName') 调用 + * 背景音乐通过 startBGM() / stopBGM() 控制 + */ +export class AudioEngine { + private static _instance: AudioEngine | null = null + private ctx: AudioContext | null = null + private bgmNodes: AudioNode[] = [] + private bgmRunning = false + private muted = false + private masterGain!: GainNode + + private constructor() {} + + static getInstance(): AudioEngine { + if (!AudioEngine._instance) AudioEngine._instance = new AudioEngine() + return AudioEngine._instance + } + + /** 首次用户交互后调用,初始化 AudioContext */ + init(): void { + if (this.ctx) return + try { + this.ctx = new AudioContext() + this.masterGain = this.ctx.createGain() + this.masterGain.gain.value = 0.55 + this.masterGain.connect(this.ctx.destination) + } catch { + // 浏览器不支持 Web Audio,静默失败 + } + } + + setMuted(v: boolean): void { + this.muted = v + if (this.masterGain) this.masterGain.gain.value = v ? 0 : 0.55 + } + isMuted(): boolean { return this.muted } + + private get ac(): AudioContext | null { return this.ctx } + + // ─── 工具方法 ──────────────────────────────────────────────────────────────── + + private osc( + type: OscillatorType, + freq: number, + startTime: number, + endTime: number, + gainStart = 0.3, + gainEnd = 0, + destination: AudioNode | null = null + ): void { + if (!this.ac) return + const g = this.ac.createGain() + g.gain.setValueAtTime(gainStart, startTime) + g.gain.exponentialRampToValueAtTime(Math.max(gainEnd, 0.001), endTime) + g.connect(destination ?? this.masterGain) + const o = this.ac.createOscillator() + o.type = type + o.frequency.setValueAtTime(freq, startTime) + o.connect(g) + o.start(startTime) + o.stop(endTime) + } + + private noise( + startTime: number, + duration: number, + gainVal = 0.15, + hipass = 0, + destination: AudioNode | null = null + ): void { + if (!this.ac) return + const bufLen = this.ac.sampleRate * duration + const buf = this.ac.createBuffer(1, bufLen, this.ac.sampleRate) + const data = buf.getChannelData(0) + for (let i = 0; i < bufLen; i++) data[i] = Math.random() * 2 - 1 + const src = this.ac.createBufferSource() + src.buffer = buf + const g = this.ac.createGain() + g.gain.setValueAtTime(gainVal, startTime) + g.gain.exponentialRampToValueAtTime(0.001, startTime + duration) + if (hipass > 0) { + const f = this.ac.createBiquadFilter() + f.type = 'highpass' + f.frequency.value = hipass + src.connect(f) + f.connect(g) + } else { + src.connect(g) + } + g.connect(destination ?? this.masterGain) + src.start(startTime) + src.stop(startTime + duration) + } + + // ─── 音效库 ───────────────────────────────────────────────────────────────── + + /** 钉钉消息通知音:建塔 / 波次开始 */ + playDingTalk(): void { + if (!this.ac) return + const t = this.ac.currentTime + // 叮 — 高频正弦 + this.osc('sine', 1047, t, t + 0.18, 0.4, 0.001) + // 咚 — 低频跟随 + this.osc('sine', 523, t + 0.1, t + 0.35, 0.25, 0.001) + } + + /** 钉钉消息发送音(更轻):波次开始 */ + playNotify(): void { + if (!this.ac) return + const t = this.ac.currentTime + this.osc('sine', 880, t, t + 0.12, 0.3, 0.001) + this.osc('sine', 1320, t + 0.07, t + 0.2, 0.2, 0.001) + } + + /** 拳击音:实习生近战 */ + playPunch(): void { + if (!this.ac) return + const t = this.ac.currentTime + this.noise(t, 0.06, 0.3, 200) + this.osc('sine', 120, t, t + 0.06, 0.35, 0.001) + } + + /** 键盘敲击音:资深开发射击 */ + playKeyboard(): void { + if (!this.ac) return + const t = this.ac.currentTime + this.noise(t, 0.04, 0.18, 1200) + this.osc('square', 440, t, t + 0.03, 0.12, 0.001) + } + + /** PPT 大师 AOE — 空洞回声 */ + playPPTBlast(): void { + if (!this.ac) return + const t = this.ac.currentTime + this.osc('sawtooth', 180, t, t + 0.4, 0.2, 0.001) + this.osc('sine', 90, t, t + 0.5, 0.15, 0.001) + this.noise(t, 0.3, 0.08, 100) + } + + /** 产品经理 — 需求变更铃 */ + playPMAttack(): void { + if (!this.ac) return + const t = this.ac.currentTime + this.osc('triangle', 660, t, t + 0.15, 0.25, 0.001) + this.osc('triangle', 440, t + 0.08, t + 0.25, 0.2, 0.001) + } + + /** 运营专员 — 数据上涨 */ + playOpsAttack(): void { + if (!this.ac) return + const t = this.ac.currentTime + this.osc('sine', 523, t, t + 0.08, 0.2, 0.001) + this.osc('sine', 659, t + 0.05, t + 0.15, 0.2, 0.001) + this.osc('sine', 784, t + 0.1, t + 0.22, 0.2, 0.001) + } + + /** 外包程序员 — 闷声丢出 */ + playOutsourceAttack(): void { + if (!this.ac) return + const t = this.ac.currentTime + this.noise(t, 0.08, 0.2, 300) + this.osc('sawtooth', 200, t, t + 0.08, 0.15, 0.001) + } + + /** 怪物死亡 — 碎纸机 */ + playEnemyDeath(): void { + if (!this.ac) return + const t = this.ac.currentTime + this.noise(t, 0.12, 0.22, 800) + this.noise(t + 0.05, 0.08, 0.15, 2000) + } + + /** Boss 登场 — 警报 + 低沉冲击 */ + playBossAppear(): void { + if (!this.ac) return + const t = this.ac.currentTime + // 低沉轰鸣 + this.osc('sawtooth', 55, t, t + 1.2, 0.5, 0.001) + this.osc('sawtooth', 60, t + 0.1, t + 1.0, 0.3, 0.001) + // 警报扫频 + for (let i = 0; i < 4; i++) { + const st = t + i * 0.25 + this.osc('square', 440, st, st + 0.1, 0.25, 0.001) + this.osc('square', 660, st + 0.12, st + 0.22, 0.2, 0.001) + } + } + + /** Boss 技能触发 — 电话挂断 */ + playBossSkill(): void { + if (!this.ac) return + const t = this.ac.currentTime + this.osc('square', 1480, t, t + 0.06, 0.3, 0.001) + this.osc('square', 1109, t + 0.07, t + 0.13, 0.3, 0.001) + this.osc('square', 740, t + 0.14, t + 0.22, 0.3, 0.001) + this.noise(t, 0.25, 0.1, 1000) + } + + /** 塔被摧毁 — 电话挂断 */ + playTowerDestroyed(): void { + if (!this.ac) return + const t = this.ac.currentTime + this.osc('square', 440, t, t + 0.1, 0.35, 0.001) + this.osc('square', 350, t + 0.1, t + 0.25, 0.35, 0.001) + this.osc('square', 220, t + 0.2, t + 0.4, 0.35, 0.001) + this.noise(t, 0.4, 0.12, 500) + } + + /** 新波次开始 — 钉钉来电铃声 */ + playWaveStart(): void { + if (!this.ac) return + const t = this.ac.currentTime + const pattern = [0, 0.18, 0.36, 0.64, 0.82, 1.0] + const freqs = [784, 784, 784, 659, 784, 880] + pattern.forEach((delay, i) => { + this.osc('sine', freqs[i], t + delay, t + delay + 0.14, 0.3, 0.001) + }) + } + + /** KPI 告警(KPI < 30%)— 紧张低音 */ + playKPIWarning(): void { + if (!this.ac) return + const t = this.ac.currentTime + this.osc('sawtooth', 110, t, t + 0.3, 0.4, 0.001) + this.osc('sawtooth', 146, t + 0.1, t + 0.3, 0.3, 0.001) + } + + // ─── 背景音乐 ──────────────────────────────────────────────────────────────── + + /** + * 启动背景音乐(办公室 ambient:低频嗡鸣 + 轻微节奏脉冲) + * 完全程序化合成,循环播放 + */ + startBGM(): void { + if (!this.ac || this.bgmRunning) return + this.bgmRunning = true + this._scheduleBGM() + } + + stopBGM(): void { + this.bgmRunning = false + this.bgmNodes.forEach(n => { + try { (n as OscillatorNode).stop?.() } catch { /* ignore */ } + }) + this.bgmNodes = [] + } + + private _scheduleBGM(): void { + if (!this.ac || !this.bgmRunning) return + const t = this.ac.currentTime + + // 底层办公室嗡鸣(电脑风扇感) + const droneGain = this.ac.createGain() + droneGain.gain.value = 0.04 + droneGain.connect(this.masterGain) + const drone = this.ac.createOscillator() + drone.type = 'sawtooth' + drone.frequency.setValueAtTime(55, t) + drone.frequency.linearRampToValueAtTime(58, t + 8) + drone.frequency.linearRampToValueAtTime(55, t + 16) + drone.connect(droneGain) + drone.start(t) + drone.stop(t + 16) + this.bgmNodes.push(drone) + + // 低频键盘噪声节拍(每 0.8s 一拍) + for (let i = 0; i < 20; i++) { + const beat = t + i * 0.8 + const ng = this.ac.createGain() + ng.gain.setValueAtTime(0.03, beat) + ng.gain.exponentialRampToValueAtTime(0.001, beat + 0.15) + ng.connect(this.masterGain) + const ns = this.ac.createOscillator() + ns.type = 'square' + ns.frequency.value = i % 4 === 0 ? 130 : i % 2 === 0 ? 110 : 87 + ns.connect(ng) + ns.start(beat) + ns.stop(beat + 0.12) + this.bgmNodes.push(ns) + } + + // 16s 后循环 + setTimeout(() => { + if (this.bgmRunning) this._scheduleBGM() + }, 15500) + } +} diff --git a/game/GameScene.ts b/game/GameScene.ts index 23fbe63..b161f7b 100644 --- a/game/GameScene.ts +++ b/game/GameScene.ts @@ -18,6 +18,7 @@ import { HUD } from './ui/HUD' import { WeeklyReportModal } from './ui/WeeklyReportModal' import { MapTransitionModal } from './ui/MapTransitionModal' import { ALL_MAPS, type MapConfig } from './data/mapConfigs' +import { AudioEngine } from './AudioEngine' void MAP_ROWS; void GAME_HEIGHT; void GAME_WIDTH; void BAR_X; void BAR_Y; void BAR_W; void BAR_H @@ -81,7 +82,7 @@ export function createGameScene(PhaserLib: typeof Phaser): typeof Phaser.Scene { this.manager = GameManager.getInstance() this.manager.reset() - // 读取难度(React 层通过 window.__gameDifficulty 传入) + // 读取难度 if (typeof window !== 'undefined') { const diff = (window as any).__gameDifficulty if (diff === 'easy' || diff === 'normal' || diff === 'hard') { @@ -99,7 +100,8 @@ export function createGameScene(PhaserLib: typeof Phaser): typeof Phaser.Scene { this.hcText = hudObjs.hcText this.towerManager = new TowerManager(this) - this.weeklyModal = new WeeklyReportModal({ onBossInspection: () => this.freezeAllTowers(3000), + this.weeklyModal = new WeeklyReportModal({ + onBossInspection: () => this.freezeAllTowers(3000), onFullStamina: () => this.refillAllStamina(), }) this.mapTransitionModal = new MapTransitionModal() @@ -107,6 +109,7 @@ export function createGameScene(PhaserLib: typeof Phaser): typeof Phaser.Scene { this.hud = new HUD(this) this.hud.createWaveButton(() => this.onWaveButtonClick()) this.loadMap(ALL_MAPS[0]) + if (typeof window !== 'undefined') { ;(window as any).__gameSelectTower = (type: TowerType | null) => { this.selectedTowerType = type @@ -114,11 +117,34 @@ export function createGameScene(PhaserLib: typeof Phaser): typeof Phaser.Scene { } } + // 初始化音效引擎(首次点击后激活 AudioContext) + const audio = AudioEngine.getInstance() + this.input.once('pointerdown', () => { + audio.init() + audio.startBGM() + }) + // 添加静音切换按钮(右上角) + this.createMuteButton(audio) + this.setupInteraction() this.setupManagerCallbacks() if (typeof window !== 'undefined') { ;(window as any).__gameReady?.() } } + private createMuteButton(audio: AudioEngine): void { + const btn = this.add.text(GAME_WIDTH - 10, 8, '🔊', { + fontSize: '16px', backgroundColor: 'rgba(0,0,0,0.35)', + padding: { x: 6, y: 3 }, + }).setOrigin(1, 0).setDepth(50).setInteractive({ useHandCursor: true }) + btn.on('pointerdown', () => { + const nowMuted = !audio.isMuted() + audio.setMuted(nowMuted) + btn.setText(nowMuted ? '🔇' : '🔊') + if (!nowMuted) audio.startBGM() + else audio.stopBGM() + }) + } + private setupManagerCallbacks(): void { this.manager.onHCChange.push((hc: number) => { this.hcText.setText(`HC: ${hc}`) @@ -127,6 +153,10 @@ export function createGameScene(PhaserLib: typeof Phaser): typeof Phaser.Scene { this.manager.onKPIChange.push((kpi: number) => { updateKPIBar(this.kpiBar, kpi) this.kpiText.setText(`${kpi}%`) + // KPI 危险时播放警告音(每次低于 30% 时触发一次) + if (kpi <= 30 && kpi > 0) { + AudioEngine.getInstance().playKPIWarning() + } }) this.manager.onGameOver.push(() => { this.hud.showGameOver() }) this.manager.onVictory.push(() => { this.hud.showVictory() }) @@ -205,6 +235,8 @@ export function createGameScene(PhaserLib: typeof Phaser): typeof Phaser.Scene { this.waveManager.startNextWave() const waveNum = this.waveManager.getCurrentWaveNumber() this.hud.showWaveBanner(waveNum, this.waveManager.totalWaves) + // 波次开始音效 + AudioEngine.getInstance().playWaveStart() } private onWeeklyReport(): void { diff --git a/game/enemies/BossVP.ts b/game/enemies/BossVP.ts index c0fbacc..f3156c4 100644 --- a/game/enemies/BossVP.ts +++ b/game/enemies/BossVP.ts @@ -1,6 +1,7 @@ import type Phaser from 'phaser' import { EnemyBase, type PathPoint } from './EnemyBase' import { getRandomQuote } from '../data/quotes' +import { AudioEngine } from '../AudioEngine' export class BossVP extends EnemyBase { private skillTimer: number = 20000 @@ -20,6 +21,8 @@ export class BossVP extends EnemyBase { this.imageSprite.setDisplaySize(bossSize, bossSize) this.imageSprite.setDepth(12) scene.cameras.main.flash(800, 255, 0, 0, false) + // Boss 登场音效 + AudioEngine.getInstance().playBossAppear() this.showBossAlert() this.bossLabel = scene.add.text(this.x, this.y + this.cellH * 0.5, '空降VP', { fontFamily: 'VT323, monospace', fontSize: '14px', @@ -55,6 +58,8 @@ export class BossVP extends EnemyBase { private triggerOrgRestructure(): void { this.onDestroyTower?.() + // Boss 技能音效 + AudioEngine.getInstance().playBossSkill() const txt = this.scene.add .text(this.x, this.y - 40, '组织架构调整!', { fontFamily: 'VT323, monospace', fontSize: '18px', diff --git a/game/enemies/EnemyBase.ts b/game/enemies/EnemyBase.ts index 7e01cef..9e347a6 100644 --- a/game/enemies/EnemyBase.ts +++ b/game/enemies/EnemyBase.ts @@ -2,6 +2,7 @@ import type Phaser from 'phaser' import { GameManager } from '../GameManager' import { HUD_HEIGHT } from '../constants' import { getCellSize } from '../mapRenderer' +import { AudioEngine } from '../AudioEngine' import { ALL_MAPS } from '../data/mapConfigs' export interface PathPoint { x: number; y: number } @@ -228,6 +229,8 @@ export abstract class EnemyBase { this.isDead = true const reward = this.hcRewardBonus ? this.hcReward * 2 : this.hcReward GameManager.getInstance().addHC(reward) + // 怪物死亡音效(碎纸机) + AudioEngine.getInstance().playEnemyDeath() this.onDeath() this.destroy() } diff --git a/game/towers/InternTower.ts b/game/towers/InternTower.ts index b45178b..12092ce 100644 --- a/game/towers/InternTower.ts +++ b/game/towers/InternTower.ts @@ -1,6 +1,7 @@ import type Phaser from 'phaser' import { TowerBase } from './TowerBase' import { GameManager } from '../GameManager' +import { AudioEngine } from '../AudioEngine' import type { EnemyBase } from '../enemies/EnemyBase' export class InternTower extends TowerBase { @@ -32,6 +33,7 @@ export class InternTower extends TowerBase { } attack(target: EnemyBase): void { + AudioEngine.getInstance().playPunch() const isInstakill = Math.random() < 0.05 && target.hp < 500 if (isInstakill) { target.takeDamage(9999) diff --git a/game/towers/OpsTower.ts b/game/towers/OpsTower.ts index 7f252cb..86aea85 100644 --- a/game/towers/OpsTower.ts +++ b/game/towers/OpsTower.ts @@ -1,5 +1,6 @@ import type Phaser from 'phaser' import { TowerBase } from './TowerBase' +import { AudioEngine } from '../AudioEngine' import type { EnemyBase } from '../enemies/EnemyBase' /** @@ -13,6 +14,7 @@ export class OpsTower extends TowerBase { } attack(target: EnemyBase): void { + AudioEngine.getInstance().playOpsAttack() this.fireChart(target) } diff --git a/game/towers/OutsourceTower.ts b/game/towers/OutsourceTower.ts index 907edd9..b7d3069 100644 --- a/game/towers/OutsourceTower.ts +++ b/game/towers/OutsourceTower.ts @@ -1,5 +1,6 @@ import type Phaser from 'phaser' import { TowerBase } from './TowerBase' +import { AudioEngine } from '../AudioEngine' import type { EnemyBase } from '../enemies/EnemyBase' /** @@ -15,6 +16,7 @@ export class OutsourceTower extends TowerBase { } attack(target: EnemyBase): void { + AudioEngine.getInstance().playOutsourceAttack() // 5% 概率 Bug 反弹(精力归零) if (Math.random() < 0.05) { this.stamina = 0 diff --git a/game/towers/PMTower.ts b/game/towers/PMTower.ts index c70470d..1df4cca 100644 --- a/game/towers/PMTower.ts +++ b/game/towers/PMTower.ts @@ -1,5 +1,6 @@ import type Phaser from 'phaser' import { TowerBase } from './TowerBase' +import { AudioEngine } from '../AudioEngine' import type { EnemyBase } from '../enemies/EnemyBase' /** @@ -15,6 +16,7 @@ export class PMTower extends TowerBase { } attack(target: EnemyBase): void { + AudioEngine.getInstance().playPMAttack() this.firePRD(target) } diff --git a/game/towers/PPTMasterTower.ts b/game/towers/PPTMasterTower.ts index 66345cb..8b24a33 100644 --- a/game/towers/PPTMasterTower.ts +++ b/game/towers/PPTMasterTower.ts @@ -1,5 +1,6 @@ import type Phaser from 'phaser' import { TowerBase } from './TowerBase' +import { AudioEngine } from '../AudioEngine' import type { EnemyBase } from '../enemies/EnemyBase' export class PPTMasterTower extends TowerBase { @@ -12,6 +13,7 @@ export class PPTMasterTower extends TowerBase { } attackAoe(enemies: EnemyBase[]): void { + AudioEngine.getInstance().playPPTBlast() const rangePx = this.attackRange * this.cellW this.showAoeEffect(rangePx) for (const e of enemies) { diff --git a/game/towers/SeniorDevTower.ts b/game/towers/SeniorDevTower.ts index 198b288..612c7c7 100644 --- a/game/towers/SeniorDevTower.ts +++ b/game/towers/SeniorDevTower.ts @@ -1,5 +1,6 @@ import type Phaser from 'phaser' import { TowerBase } from './TowerBase' +import { AudioEngine } from '../AudioEngine' import type { EnemyBase } from '../enemies/EnemyBase' export class SeniorDevTower extends TowerBase { @@ -8,6 +9,7 @@ export class SeniorDevTower extends TowerBase { } attack(target: EnemyBase): void { + AudioEngine.getInstance().playKeyboard() this.fireCodeBullet(target) } diff --git a/game/towers/TowerBase.ts b/game/towers/TowerBase.ts index c1bd0db..ea851c2 100644 --- a/game/towers/TowerBase.ts +++ b/game/towers/TowerBase.ts @@ -2,6 +2,7 @@ import type Phaser from 'phaser' import { GameManager } from '../GameManager' import { HUD_HEIGHT, COFFEE_COST, STAMINA_MAX, STAMINA_REGEN } from '../constants' import { getCellSize } from '../mapRenderer' +import { AudioEngine } from '../AudioEngine' import type { EnemyBase } from '../enemies/EnemyBase' export abstract class TowerBase { @@ -141,6 +142,8 @@ export abstract class TowerBase { this.isActive = true this.imageSprite.setAlpha(1) this.updateStaminaBar() + // 购买咖啡音效 + AudioEngine.getInstance().playDingTalk() return true } return false diff --git a/game/towers/TowerManager.ts b/game/towers/TowerManager.ts index f22a305..96ddb12 100644 --- a/game/towers/TowerManager.ts +++ b/game/towers/TowerManager.ts @@ -2,6 +2,7 @@ import type Phaser from 'phaser' import { GameManager } from '../GameManager' import { getCellSize } from '../mapRenderer' import { COFFEE_COST } from '../constants' +import { AudioEngine } from '../AudioEngine' import type { EnemyBase } from '../enemies/EnemyBase' import { TowerBase } from './TowerBase' import { InternTower } from './InternTower' @@ -60,6 +61,8 @@ export class TowerManager { const tower = this.createTower(type, gridX, gridY) this.towers.push(tower) this.occupiedCells.add(`${gridX},${gridY}`) + // 建塔音效(钉钉消息音) + AudioEngine.getInstance().playNotify() // 监听实习生自毁事件 if (tower instanceof InternTower) { @@ -226,6 +229,8 @@ export class TowerManager { this.showDestroyEffect(tower) tower.destroy() this.removeTower(tower) + // 塔被摧毁音效 + AudioEngine.getInstance().playTowerDestroyed() } private showDestroyEffect(tower: TowerBase): void {