diff --git a/game/ui/HUD.ts b/game/ui/HUD.ts new file mode 100644 index 0000000..da12a53 --- /dev/null +++ b/game/ui/HUD.ts @@ -0,0 +1,176 @@ +import type Phaser from 'phaser' +import { GAME_WIDTH, HUD_HEIGHT } from '../constants' + +/** + * 游戏 HUD 辅助工具 + * 负责管理"召唤下一波"按钮和波次提示横幅 + */ +export class HUD { + private scene: Phaser.Scene + private waveBtn: Phaser.GameObjects.Text | null = null + private waveBannerTimeout: (() => void) | null = null + + constructor(scene: Phaser.Scene) { + this.scene = scene + } + + /** + * 创建"召唤下一波"按钮 + * @param onClick 点击回调 + */ + createWaveButton(onClick: () => void): void { + if (this.waveBtn) this.waveBtn.destroy() + + this.waveBtn = this.scene.add + .text(GAME_WIDTH - 160, HUD_HEIGHT + 20, '▶ 召唤下一波', { + fontFamily: "'Press Start 2P', monospace", + fontSize: '9px', + color: '#A78BFA', + backgroundColor: '#1e3a5f', + padding: { x: 10, y: 6 }, + }) + .setOrigin(0, 0) + .setDepth(20) + .setInteractive({ useHandCursor: true }) + + this.waveBtn.on('pointerover', () => { + if (this.waveBtn) { + this.waveBtn.setStyle({ backgroundColor: '#2d5a8e' }) + } + }) + this.waveBtn.on('pointerout', () => { + if (this.waveBtn) { + this.waveBtn.setStyle({ backgroundColor: '#1e3a5f' }) + } + }) + this.waveBtn.on('pointerdown', () => { + onClick() + }) + } + + /** 更新按钮文字(如禁用状态) */ + setWaveButtonText(text: string): void { + this.waveBtn?.setText(text) + } + + disableWaveButton(): void { + if (!this.waveBtn) return + this.waveBtn.setStyle({ color: '#4B5563', backgroundColor: '#0F172A' }) + this.waveBtn.removeAllListeners('pointerdown') + } + + enableWaveButton(): void { + if (!this.waveBtn) return + this.waveBtn.setStyle({ color: '#A78BFA', backgroundColor: '#1e3a5f' }) + } + + /** + * 显示波次开始横幅 + * @param waveNumber 当前波次(1-based) + */ + showWaveBanner(waveNumber: number, totalWaves: number): void { + const isBoss = waveNumber === totalWaves + const label = isBoss + ? `!! 第${waveNumber}波:空降VP来袭 !!` + : `第 ${waveNumber} 波来袭!` + const color = isBoss ? '#FBBF24' : '#F43F5E' + const bg = isBoss ? '#7C2D12' : '#0A1628' + + const banner = this.scene.add + .text(GAME_WIDTH / 2, HUD_HEIGHT + 60, label, { + fontFamily: "'Press Start 2P', monospace", + fontSize: isBoss ? '16px' : '14px', + color, + backgroundColor: bg, + padding: { x: 20, y: 10 }, + }) + .setOrigin(0.5, 0.5) + .setDepth(40) + .setAlpha(0) + + this.scene.tweens.add({ + targets: banner, + alpha: 1, + duration: 300, + yoyo: true, + hold: 1800, + onComplete: () => banner.destroy(), + }) + } + + /** 显示周报触发提示 */ + showWeeklyReportAlert(): void { + const banner = this.scene.add + .text(GAME_WIDTH / 2, HUD_HEIGHT + 120, '📋 季度周报截止!效率翻倍!', { + fontFamily: "'Press Start 2P', monospace", + fontSize: '11px', + color: '#FCD34D', + backgroundColor: '#78350F', + padding: { x: 16, y: 8 }, + }) + .setOrigin(0.5, 0.5) + .setDepth(40) + .setAlpha(0) + + this.scene.tweens.add({ + targets: banner, + alpha: 1, + duration: 400, + yoyo: true, + hold: 2500, + onComplete: () => banner.destroy(), + }) + } + + /** 显示胜利画面 */ + showVictory(): void { + const overlay = this.scene.add.graphics() + overlay.fillStyle(0x000000, 0.6) + overlay.fillRect(0, 0, GAME_WIDTH, 720) + overlay.setDepth(50) + + this.scene.add + .text(GAME_WIDTH / 2, 300, '🎉 大厂保卫成功!', { + fontFamily: "'Press Start 2P', monospace", + fontSize: '22px', + color: '#A78BFA', + backgroundColor: '#0A1628', + padding: { x: 24, y: 12 }, + }) + .setOrigin(0.5, 0.5) + .setDepth(55) + + this.scene.add + .text(GAME_WIDTH / 2, 380, 'KPI 绩效已发放!', { + fontFamily: 'VT323, monospace', + fontSize: '24px', + color: '#A78BFA', + }) + .setOrigin(0.5, 0.5) + .setDepth(55) + } + + /** 显示失败画面 */ + showGameOver(): void { + const overlay = this.scene.add.graphics() + overlay.fillStyle(0x000000, 0.7) + overlay.fillRect(0, 0, GAME_WIDTH, 720) + overlay.setDepth(50) + + this.scene.add + .text(GAME_WIDTH / 2, 300, 'KPI 归零!被裁了!', { + fontFamily: "'Press Start 2P', monospace", + fontSize: '18px', + color: '#EF4444', + backgroundColor: '#0A1628', + padding: { x: 24, y: 12 }, + }) + .setOrigin(0.5, 0.5) + .setDepth(55) + } + + destroy(): void { + this.waveBtn?.destroy() + if (this.waveBannerTimeout) this.waveBannerTimeout() + } +} diff --git a/game/ui/TowerPanel.ts b/game/ui/TowerPanel.ts new file mode 100644 index 0000000..066f04e --- /dev/null +++ b/game/ui/TowerPanel.ts @@ -0,0 +1,235 @@ +import { TOWER_COSTS, type TowerType } from '../towers/TowerManager' +import { GameManager } from '../GameManager' + +const TOWER_META: { + type: TowerType + name: string + desc: string + color: string + icon: string +}[] = [ + { + type: 'intern', + name: '00后实习生', + desc: '近战 15伤 1.5/s', + color: '#22C55E', + icon: '🧑‍💻', + }, + { + type: 'senior', + name: 'P6资深开发', + desc: '远程 30伤 5格', + color: '#3B82F6', + icon: '', + }, + { + type: 'ppt', + name: 'PPT大师', + desc: 'AOE 5伤 减速', + color: '#F59E0B', + icon: '📊', + }, + { + type: 'hrbp', + name: 'HRBP', + desc: '辅助 +攻速', + color: '#EC4899', + icon: '❤', + }, +] + +export class TowerPanel { + private container: HTMLElement + private cards: Map = new Map() + private selectedType: TowerType | null = null + private onSelectCallback?: (type: TowerType | null) => void + + constructor(parentId: string) { + this.container = document.createElement('div') + this.container.id = 'tower-panel' + this.applyContainerStyles() + + this.buildCards() + + const parent = document.getElementById(parentId) + if (parent) { + parent.style.position = 'relative' + parent.appendChild(this.container) + } else { + document.body.appendChild(this.container) + } + + // 监听 HC 变化更新可用状态 + GameManager.getInstance().onHCChange.push(() => this.refreshCardStates()) + this.refreshCardStates() + } + + private applyContainerStyles(): void { + Object.assign(this.container.style, { + position: 'absolute', + bottom: '0', + left: '50%', + transform: 'translateX(-50%)', + display: 'flex', + gap: '8px', + padding: '8px 12px', + background: 'rgba(10,22,40,0.92)', + borderTop: '2px solid #1e3a5f', + zIndex: '100', + borderRadius: '8px 8px 0 0', + pointerEvents: 'auto', + }) + } + + private buildCards(): void { + for (const meta of TOWER_META) { + const card = document.createElement('div') + card.id = `tower-card-${meta.type}` + this.applyCardStyles(card, meta.color) + + const icon = document.createElement('div') + icon.textContent = meta.icon + icon.style.fontSize = '20px' + icon.style.textAlign = 'center' + + const name = document.createElement('div') + name.textContent = meta.name + name.style.fontSize = '11px' + name.style.fontWeight = 'bold' + name.style.color = meta.color + name.style.fontFamily = 'VT323, monospace' + name.style.textAlign = 'center' + + const cost = document.createElement('div') + cost.id = `tower-cost-${meta.type}` + cost.textContent = `${TOWER_COSTS[meta.type]} HC` + cost.style.fontSize = '10px' + cost.style.color = '#A78BFA' + cost.style.textAlign = 'center' + + const desc = document.createElement('div') + desc.textContent = meta.desc + desc.style.fontSize = '10px' + desc.style.color = '#94A3B8' + desc.style.textAlign = 'center' + + card.appendChild(icon) + card.appendChild(name) + card.appendChild(cost) + card.appendChild(desc) + + card.addEventListener('click', (e) => { + e.stopPropagation() + this.onCardClick(meta.type) + }) + + this.container.appendChild(card) + this.cards.set(meta.type, card) + } + } + + private applyCardStyles(card: HTMLElement, accentColor: string): void { + Object.assign(card.style, { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '2px', + padding: '6px 10px', + background: '#0F1B2D', + border: `2px solid #1e3a5f`, + borderRadius: '6px', + cursor: 'pointer', + minWidth: '80px', + transition: 'all 0.15s ease', + userSelect: 'none', + }) + card.setAttribute('data-accent', accentColor) + + card.addEventListener('mouseenter', () => { + if (!card.classList.contains('disabled') && !card.classList.contains('selected')) { + card.style.background = '#1e3a5f' + card.style.transform = 'translateY(-2px)' + } + }) + card.addEventListener('mouseleave', () => { + if (!card.classList.contains('selected')) { + card.style.background = '#0F1B2D' + card.style.transform = 'translateY(0)' + } + }) + } + + private onCardClick(type: TowerType): void { + const card = this.cards.get(type) + if (!card || card.classList.contains('disabled')) return + + if (this.selectedType === type) { + this.clearSelection() + } else { + this.setSelected(type) + } + } + + private setSelected(type: TowerType): void { + this.clearSelection() + this.selectedType = type + const card = this.cards.get(type) + if (card) { + const accent = card.getAttribute('data-accent') ?? '#7C3AED' + card.classList.add('selected') + card.style.border = `2px solid ${accent}` + card.style.background = '#1e3a5f' + card.style.boxShadow = `0 0 8px ${accent}88` + } + this.onSelectCallback?.(type) + } + + private clearSelection(): void { + if (this.selectedType) { + const card = this.cards.get(this.selectedType) + if (card) { + card.classList.remove('selected') + card.style.border = '2px solid #1e3a5f' + card.style.background = '#0F1B2D' + card.style.boxShadow = '' + card.style.transform = 'translateY(0)' + } + } + this.selectedType = null + this.onSelectCallback?.(null) + } + + refreshCardStates(): void { + const hc = GameManager.getInstance().hc + for (const meta of TOWER_META) { + const card = this.cards.get(meta.type) + if (!card) continue + const affordable = hc >= TOWER_COSTS[meta.type] + if (!affordable) { + card.classList.add('disabled') + card.style.opacity = '0.4' + card.style.cursor = 'not-allowed' + } else { + card.classList.remove('disabled') + card.style.opacity = '1' + card.style.cursor = 'pointer' + } + } + } + + onSelect(cb: (type: TowerType | null) => void): void { + this.onSelectCallback = cb + } + + getSelectedType(): TowerType | null { + return this.selectedType + } + + deselect(): void { + this.clearSelection() + } + + destroy(): void { + this.container.remove() + } +}