496 lines
18 KiB
TypeScript
496 lines
18 KiB
TypeScript
import type Phaser from 'phaser'
|
||
import { MAP_COLS, MAP_ROWS, HUD_HEIGHT, GAME_HEIGHT, GAME_WIDTH } from './constants'
|
||
import { GameManager } from './GameManager'
|
||
import {
|
||
buildPathTiles,
|
||
getCellSize,
|
||
drawAllTiles,
|
||
renderMapLabels,
|
||
renderMapBackground,
|
||
renderDecorations,
|
||
renderHUD,
|
||
updateKPIBar,
|
||
BAR_X, BAR_Y, BAR_W, BAR_H,
|
||
} from './mapRenderer'
|
||
import { TowerManager, type TowerType } from './towers/TowerManager'
|
||
import { WaveManager } from './enemies/WaveManager'
|
||
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
|
||
|
||
/** 所有游戏精灵的 key → public path 映射 */
|
||
const SPRITE_ASSETS: Record<string, string> = {
|
||
'tower-intern': '/game-assets/tower-intern.png',
|
||
'tower-senior': '/game-assets/tower-senior.png',
|
||
'tower-ppt': '/game-assets/tower-ppt.png',
|
||
'tower-hrbp': '/game-assets/tower-hrbp.png',
|
||
'tower-pm': '/game-assets/tower-pm.png',
|
||
'tower-ops': '/game-assets/tower-ops.png',
|
||
'tower-outsource': '/game-assets/tower-outsource.png',
|
||
'enemy-fresh': '/game-assets/enemy-fresh.png',
|
||
'enemy-old': '/game-assets/enemy-old.png',
|
||
'enemy-trouble': '/game-assets/enemy-trouble.png',
|
||
'enemy-boss': '/game-assets/enemy-boss.png',
|
||
'deco-coffee': '/game-assets/deco-coffee.png',
|
||
'deco-monitor': '/game-assets/deco-monitor.png',
|
||
'deco-desk': '/game-assets/deco-desk.png',
|
||
}
|
||
|
||
export function createGameScene(PhaserLib: typeof Phaser): typeof Phaser.Scene {
|
||
class GameScene extends PhaserLib.Scene {
|
||
private manager!: GameManager
|
||
private kpiBar!: Phaser.GameObjects.Graphics
|
||
private kpiText!: Phaser.GameObjects.Text
|
||
private hcText!: Phaser.GameObjects.Text
|
||
private hoveredTile: { col: number; row: number } | null = null
|
||
private tileGraphics!: Phaser.GameObjects.Graphics
|
||
private towerManager!: TowerManager
|
||
private waveManager!: WaveManager
|
||
private hud!: HUD
|
||
private weeklyModal!: WeeklyReportModal
|
||
private mapTransitionModal!: MapTransitionModal
|
||
private selectedTowerType: TowerType | null = null
|
||
private buildModeGraphics!: Phaser.GameObjects.Graphics
|
||
private isWaveRunning: boolean = false
|
||
|
||
// 多地图状态
|
||
private currentPathTiles: Set<string> = new Set()
|
||
private decorationObjects: Phaser.GameObjects.Image[] = []
|
||
private labelObjects: Phaser.GameObjects.Text[] = []
|
||
private bgObject: Phaser.GameObjects.Image | null = null
|
||
private mapInTransition: boolean = false
|
||
|
||
constructor() { super({ key: 'GameScene' }) }
|
||
|
||
preload(): void {
|
||
for (const [key, path] of Object.entries(SPRITE_ASSETS)) {
|
||
this.load.image(key, path)
|
||
}
|
||
// 预加载所有地图背景
|
||
for (const map of ALL_MAPS) {
|
||
if (!this.textures.exists(map.bgKey)) {
|
||
this.load.image(map.bgKey, map.bgPath)
|
||
}
|
||
}
|
||
}
|
||
|
||
create(): void {
|
||
this.manager = GameManager.getInstance()
|
||
this.manager.reset()
|
||
|
||
// 读取难度
|
||
if (typeof window !== 'undefined') {
|
||
const diff = (window as any).__gameDifficulty
|
||
if (diff === 'easy' || diff === 'normal' || diff === 'hard') {
|
||
this.manager.setDifficulty(diff)
|
||
}
|
||
}
|
||
|
||
this.manager.gameState = 'playing'
|
||
|
||
this.tileGraphics = this.add.graphics()
|
||
this.buildModeGraphics = this.add.graphics().setDepth(5)
|
||
const hudObjs = renderHUD(this)
|
||
this.kpiBar = hudObjs.kpiBar
|
||
this.kpiText = hudObjs.kpiText
|
||
this.hcText = hudObjs.hcText
|
||
|
||
this.towerManager = new TowerManager(this)
|
||
this.weeklyModal = new WeeklyReportModal({
|
||
onBossInspection: () => this.freezeAllTowers(3000),
|
||
onFullStamina: () => this.refillAllStamina(),
|
||
})
|
||
this.mapTransitionModal = new MapTransitionModal()
|
||
|
||
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
|
||
if (!type) this.buildModeGraphics.clear()
|
||
}
|
||
}
|
||
|
||
// 初始化音效引擎(首次点击后激活 AudioContext,默认开启)
|
||
const audio = AudioEngine.getInstance()
|
||
this.input.once('pointerdown', () => {
|
||
audio.init()
|
||
audio.startBGM()
|
||
})
|
||
|
||
// 注册 PUA buff 接口 + HC 查询/扣除接口供 React 层调用
|
||
if (typeof window !== 'undefined') {
|
||
;(window as any).__gamePuaBuff = (
|
||
effect: string, score: number, title: string
|
||
) => {
|
||
this.applyPuaBuff(effect, score, title)
|
||
}
|
||
// 查询当前 HC
|
||
;(window as any).__gameGetHC = () => this.manager.hc
|
||
// 尝试扣除 HC,成功返回 true,不足返回 false
|
||
;(window as any).__gameSpendHC = (amount: number): boolean => {
|
||
return this.manager.spendHC(amount)
|
||
}
|
||
}
|
||
|
||
this.setupInteraction()
|
||
this.setupManagerCallbacks()
|
||
if (typeof window !== 'undefined') { ;(window as any).__gameReady?.() }
|
||
}
|
||
|
||
private createMuteButton(_audio: AudioEngine): void {
|
||
// 静音按钮已移除,音乐默认开启
|
||
void _audio
|
||
}
|
||
|
||
/**
|
||
* 应用 PUA buff 效果到游戏
|
||
* effect: attack_boost | speed_boost | money_rain | rage_mode | backfire
|
||
*/
|
||
private applyPuaBuff(effect: string, score: number, title: string): void {
|
||
const towers = this.towerManager.getAllTowers()
|
||
const audio = AudioEngine.getInstance()
|
||
|
||
// 全屏效果提示横幅
|
||
const colors: Record<string, string> = {
|
||
rage_mode: '#FF4E00',
|
||
speed_boost: '#FBBF24',
|
||
attack_boost: '#22C55E',
|
||
money_rain: '#A78BFA',
|
||
backfire: '#6B7280',
|
||
}
|
||
const color = colors[effect] ?? '#E2E8F0'
|
||
this.showPuaBanner(title, score, color)
|
||
|
||
const dur = Math.max(8000, score * 1500) // buff 持续时间(ms)
|
||
|
||
switch (effect) {
|
||
case 'rage_mode': {
|
||
// 9-10分:全场狂暴 — 攻速×2,全图红色相机震动
|
||
audio.playBossAppear()
|
||
this.cameras.main.flash(600, 255, 60, 0, false)
|
||
this.cameras.main.shake(400, 0.008)
|
||
towers.forEach(t => { t['_puaSpeedMult'] = 2.0 })
|
||
this.manager.addHC(score * 15)
|
||
this.time.delayedCall(dur, () => {
|
||
towers.forEach(t => { t['_puaSpeedMult'] = 1.0 })
|
||
})
|
||
break
|
||
}
|
||
case 'speed_boost': {
|
||
// 7-8分:攻速 ×1.5
|
||
audio.playWaveStart()
|
||
towers.forEach(t => { t['_puaSpeedMult'] = 1.5 })
|
||
this.time.delayedCall(dur, () => {
|
||
towers.forEach(t => { t['_puaSpeedMult'] = 1.0 })
|
||
})
|
||
break
|
||
}
|
||
case 'attack_boost': {
|
||
// 4-6分:伤害 ×(1 + score*0.08)
|
||
audio.playDingTalk()
|
||
const mult = 1 + score * 0.08
|
||
towers.forEach(t => { t['_puaDmgMult'] = mult })
|
||
this.time.delayedCall(dur, () => {
|
||
towers.forEach(t => { t['_puaDmgMult'] = 1.0 })
|
||
})
|
||
break
|
||
}
|
||
case 'money_rain': {
|
||
// 5-7分:立即获得 HC,屏幕绿色雨
|
||
const hcBonus = score * 20
|
||
this.manager.addHC(hcBonus)
|
||
audio.playOpsAttack()
|
||
this.showMoneyRain(hcBonus)
|
||
break
|
||
}
|
||
case 'backfire': {
|
||
// 1-2分:废话反效果 — 全场塔禁锢 2 秒
|
||
audio.playEnemyDeath()
|
||
this.cameras.main.shake(300, 0.005)
|
||
this.freezeAllTowers(2000)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 显示 PUA buff 横幅 */
|
||
private showPuaBanner(title: string, score: number, color: string): void {
|
||
const stars = '★'.repeat(Math.min(score, 10))
|
||
const banner = this.add.text(
|
||
GAME_WIDTH / 2, HUD_HEIGHT + 80,
|
||
`${title} ${stars}`,
|
||
{
|
||
fontFamily: 'VT323, monospace',
|
||
fontSize: '28px',
|
||
color,
|
||
stroke: '#000000',
|
||
strokeThickness: 4,
|
||
shadow: { offsetX: 0, offsetY: 0, color, blur: 16, stroke: true, fill: true },
|
||
}
|
||
).setOrigin(0.5, 0.5).setDepth(60).setAlpha(0)
|
||
|
||
this.tweens.add({
|
||
targets: banner,
|
||
alpha: 1,
|
||
y: HUD_HEIGHT + 60,
|
||
duration: 300,
|
||
yoyo: false,
|
||
onComplete: () => {
|
||
this.time.delayedCall(2000, () => {
|
||
this.tweens.add({
|
||
targets: banner, alpha: 0, y: HUD_HEIGHT + 40,
|
||
duration: 400, onComplete: () => banner.destroy(),
|
||
})
|
||
})
|
||
},
|
||
})
|
||
}
|
||
|
||
/** 金币雨效果(money_rain) */
|
||
private showMoneyRain(amount: number): void {
|
||
for (let i = 0; i < 12; i++) {
|
||
const x = Math.random() * GAME_WIDTH
|
||
const coin = this.add.text(x, HUD_HEIGHT + 20, `+${Math.floor(amount / 12)}HC`, {
|
||
fontFamily: 'VT323, monospace',
|
||
fontSize: '16px',
|
||
color: '#A78BFA',
|
||
stroke: '#000',
|
||
strokeThickness: 2,
|
||
}).setOrigin(0.5, 0).setDepth(55).setAlpha(0.9)
|
||
this.tweens.add({
|
||
targets: coin,
|
||
y: coin.y + 120 + Math.random() * 80,
|
||
alpha: 0,
|
||
delay: i * 80,
|
||
duration: 900,
|
||
onComplete: () => coin.destroy(),
|
||
})
|
||
}
|
||
}
|
||
|
||
private setupManagerCallbacks(): void {
|
||
this.manager.onHCChange.push((hc: number) => {
|
||
this.hcText.setText(`HC: ${hc}`)
|
||
if (typeof window !== 'undefined') { ;(window as any).__gameOnHCChange?.(hc) }
|
||
})
|
||
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() })
|
||
}
|
||
|
||
/**
|
||
* 加载指定地图:渲染背景、路径、装饰物,重置 WaveManager
|
||
*/
|
||
private loadMap(mapConfig: MapConfig): void {
|
||
this.currentPathTiles = buildPathTiles(mapConfig.waypoints)
|
||
// 同步路径格给 TowerManager,用于建塔合法性判断
|
||
this.towerManager.setCurrentPathTiles(this.currentPathTiles)
|
||
this.bgObject?.destroy()
|
||
this.bgObject = renderMapBackground(this, mapConfig.bgKey)
|
||
drawAllTiles(this.tileGraphics, null, this.currentPathTiles, mapConfig)
|
||
this.labelObjects.forEach(l => l.destroy())
|
||
this.labelObjects = renderMapLabels(this, mapConfig.labels)
|
||
this.decorationObjects.forEach(d => d.destroy())
|
||
this.decorationObjects = renderDecorations(this, mapConfig.decorations, this.currentPathTiles)
|
||
this.waveManager = new WaveManager(
|
||
this, mapConfig.waves, this.manager.difficulty,
|
||
{
|
||
onWaveComplete: () => this.onWeeklyReport(),
|
||
onAllWavesComplete: () => this.onMapCleared(),
|
||
onDestroyRandomTower: () => this.towerManager.removeRandomTower(),
|
||
},
|
||
mapConfig.waypoints
|
||
)
|
||
this.mapInTransition = false
|
||
this.isWaveRunning = false
|
||
}
|
||
|
||
// 自动下一波倒计时(ms),-1 表示未激活
|
||
private autoNextWaveTimer: number = -1
|
||
private readonly AUTO_WAVE_DELAY = 3000 // 3 秒后自动开始
|
||
|
||
update(_time: number, delta: number): void {
|
||
if (this.manager.gameState !== 'playing' && this.manager.gameState !== 'idle') return
|
||
if (this.mapInTransition) return
|
||
|
||
this.towerManager.update(delta, this.waveManager.getAllActiveEnemies())
|
||
this.waveManager.update(delta)
|
||
|
||
// 当前波次怪物全灭且还有下一波时,启动自动倒计时
|
||
if (
|
||
this.isWaveRunning &&
|
||
this.waveManager.getAllActiveEnemies().length === 0 &&
|
||
this.waveManager.hasMoreWaves()
|
||
) {
|
||
this.isWaveRunning = false
|
||
if (this.autoNextWaveTimer < 0) {
|
||
this.autoNextWaveTimer = this.AUTO_WAVE_DELAY
|
||
this.hud.setWaveButtonText('3s 后自动开始...')
|
||
this.hud.enableWaveButton()
|
||
}
|
||
}
|
||
|
||
// 自动倒计时
|
||
if (this.autoNextWaveTimer > 0) {
|
||
this.autoNextWaveTimer -= delta
|
||
const sec = Math.ceil(this.autoNextWaveTimer / 1000)
|
||
this.hud.setWaveButtonText(`${sec}s 后自动开始...`)
|
||
if (this.autoNextWaveTimer <= 0) {
|
||
this.autoNextWaveTimer = -1
|
||
this.onWaveButtonClick()
|
||
}
|
||
}
|
||
}
|
||
|
||
private onWaveButtonClick(): void {
|
||
if (!this.waveManager.hasMoreWaves() || this.isWaveRunning || this.mapInTransition) return
|
||
this.autoNextWaveTimer = -1
|
||
this.isWaveRunning = true
|
||
this.hud.disableWaveButton()
|
||
this.hud.setWaveButtonText('波次进行中...')
|
||
this.waveManager.startNextWave()
|
||
const waveNum = this.waveManager.getCurrentWaveNumber()
|
||
this.hud.showWaveBanner(waveNum, this.waveManager.totalWaves)
|
||
AudioEngine.getInstance().playWaveStart()
|
||
// 通知 React 层:战斗已开始,允许发起激励
|
||
if (typeof window !== 'undefined') {
|
||
;(window as any).__gameWaveStarted = true
|
||
}
|
||
}
|
||
|
||
private onWeeklyReport(): void {
|
||
this.hud.showWeeklyReportAlert()
|
||
this.time.delayedCall(600, () => { this.weeklyModal.show() })
|
||
}
|
||
|
||
private onMapCleared(): void {
|
||
// 若游戏已失败,不触发地图切换
|
||
if (this.manager.gameState === 'defeat') return
|
||
|
||
this.mapInTransition = true
|
||
this.autoNextWaveTimer = -1
|
||
this.isWaveRunning = false
|
||
this.hud.disableWaveButton()
|
||
this.hud.setWaveButtonText('关卡完成!')
|
||
const currentMap = ALL_MAPS[this.manager.currentMapIndex]
|
||
const nextIndex = this.manager.currentMapIndex + 1
|
||
if (nextIndex >= ALL_MAPS.length) {
|
||
this.manager.totalWaveCleared += currentMap.waveCount
|
||
this.mapTransitionModal.show(currentMap, () => { this.manager.triggerVictory() })
|
||
return
|
||
}
|
||
this.mapTransitionModal.show(currentMap, () => {
|
||
this.manager.totalWaveCleared += currentMap.waveCount
|
||
this.manager.currentMapIndex = nextIndex
|
||
this.waveManager.clearAllEnemies()
|
||
// 清除上一关所有防御塔
|
||
this.towerManager.clearAllTowers()
|
||
// 重置 HC:每关重新开始,不带入上关余量
|
||
this.manager.resetHC()
|
||
this.loadMap(ALL_MAPS[nextIndex])
|
||
this.hud.enableWaveButton()
|
||
this.hud.setWaveButtonText('▶ 召唤下一波')
|
||
})
|
||
}
|
||
|
||
freezeAllTowers(duration: number = 3000): void {
|
||
this.towerManager.getAllTowers().forEach(tower => {
|
||
tower.isFrozen = true
|
||
setTimeout(() => { tower.isFrozen = false }, duration)
|
||
})
|
||
}
|
||
|
||
refillAllStamina(): void {
|
||
this.towerManager.getAllTowers().forEach(tower => {
|
||
tower.stamina = tower.maxStamina
|
||
tower['isActive'] = true
|
||
})
|
||
}
|
||
|
||
private setupInteraction(): void {
|
||
const { cellW, cellH } = getCellSize()
|
||
this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
|
||
const col = Math.floor(pointer.x / cellW)
|
||
const row = Math.floor((pointer.y - HUD_HEIGHT) / cellH)
|
||
const inBounds = col >= 0 && col < MAP_COLS && row >= 0 && row < MAP_ROWS
|
||
if (inBounds && !this.currentPathTiles.has(`${col},${row}`)) {
|
||
if (!this.hoveredTile || this.hoveredTile.col !== col || this.hoveredTile.row !== row) {
|
||
this.hoveredTile = { col, row }
|
||
drawAllTiles(this.tileGraphics, this.hoveredTile, this.currentPathTiles, ALL_MAPS[this.manager.currentMapIndex])
|
||
}
|
||
if (this.selectedTowerType) this.drawBuildPreview(col, row, cellW, cellH)
|
||
return
|
||
}
|
||
if (this.hoveredTile !== null) {
|
||
this.hoveredTile = null
|
||
drawAllTiles(this.tileGraphics, null, this.currentPathTiles, ALL_MAPS[this.manager.currentMapIndex])
|
||
this.buildModeGraphics.clear()
|
||
}
|
||
})
|
||
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
|
||
const col = Math.floor(pointer.x / cellW)
|
||
const row = Math.floor((pointer.y - HUD_HEIGHT) / cellH)
|
||
if (col >= 0 && col < MAP_COLS && row >= 0 && row < MAP_ROWS) {
|
||
this.handleTileClick(col, row, cellW, cellH)
|
||
}
|
||
})
|
||
}
|
||
|
||
private drawBuildPreview(col: number, row: number, cellW: number, cellH: number): void {
|
||
this.buildModeGraphics.clear()
|
||
if (!this.towerManager.canPlace(col, row)) return
|
||
const x = col * cellW
|
||
const y = HUD_HEIGHT + row * cellH
|
||
this.buildModeGraphics.lineStyle(2, 0xa78bfa, 0.9)
|
||
this.buildModeGraphics.strokeRect(x + 2, y + 2, cellW - 4, cellH - 4)
|
||
this.buildModeGraphics.fillStyle(0xa78bfa, 0.15)
|
||
this.buildModeGraphics.fillRect(x + 2, y + 2, cellW - 4, cellH - 4)
|
||
}
|
||
|
||
private handleTileClick(col: number, row: number, cellW: number, cellH: number): void {
|
||
if (this.selectedTowerType) {
|
||
if (this.currentPathTiles.has(`${col},${row}`)) return
|
||
const placed = this.towerManager.placeTower(col, row, this.selectedTowerType)
|
||
if (placed) {
|
||
this.buildModeGraphics.clear()
|
||
this.selectedTowerType = null
|
||
if (typeof window !== 'undefined') { ;(window as any).__gameOnTowerDeselect?.() }
|
||
} else {
|
||
this.showTip(col, row, cellW, cellH, 'HC不足或格子已占用', '#EF4444')
|
||
}
|
||
return
|
||
}
|
||
const hasTower = this.towerManager.handleTileClick(col, row)
|
||
if (!hasTower && !this.currentPathTiles.has(`${col},${row}`)) {
|
||
this.showTip(col, row, cellW, cellH, '请先从底部选择塔', '#A78BFA')
|
||
}
|
||
}
|
||
|
||
private showTip(col: number, row: number, cellW: number, cellH: number, msg: string, color: string): void {
|
||
const x = col * cellW + cellW / 2
|
||
const y = HUD_HEIGHT + row * cellH + cellH / 2
|
||
const tip = this.add.text(x, y - 20, msg, {
|
||
fontFamily: 'VT323, monospace', fontSize: '16px',
|
||
color, backgroundColor: '#0a1628', padding: { x: 8, y: 4 },
|
||
}).setOrigin(0.5, 1).setDepth(20)
|
||
this.time.delayedCall(1200, () => tip.destroy())
|
||
}
|
||
}
|
||
|
||
return GameScene
|
||
}
|