feat(game): 添加PUA激励台——AI评分打鸡血,触发攻击/攻速/HC/狂暴/翻车等游戏效果;移除静音按钮BGM默认开启

This commit is contained in:
Cloud Bot
2026-03-24 08:14:51 +00:00
parent e922c8fdf6
commit adda7ae57c
4 changed files with 558 additions and 95 deletions

View File

@@ -3,17 +3,293 @@
import { useEffect, useRef, useState, useCallback } from 'react'
const TOWER_META = [
{ type: 'outsource', name: '外包程序员', cost: 30, desc: '近战 8伤 0.7/s', color: '#94A3B8', img: '/game-assets/tower-outsource.png', tip: '廉价但30%丢空5%自伤' },
{ type: 'intern', name: '00后实习生', cost: 50, desc: '近战 15伤 1.5/s', color: '#22C55E', img: '/game-assets/tower-intern.png', tip: '整顿职场5%概率秒杀' },
{ type: 'hrbp', name: 'HRBP', cost: 80, desc: '辅助 +20%攻速', color: '#EC4899', img: '/game-assets/tower-hrbp.png', tip: '打鸡血:周围塔攻速+20%' },
{ type: 'ops', name: '运营专员', cost: 90, desc: '远程 18伤 范围溅射', color: '#8B5CF6', img: '/game-assets/tower-ops.png', tip: '增长黑客20%双倍HC' },
{ type: 'ppt', name: 'PPT大师', cost: 100, desc: 'AOE减速 5伤', color: '#F59E0B', img: '/game-assets/tower-ppt.png', tip: '黑话领域减速40%' },
{ type: 'senior', name: 'P6资深开发', cost: 120, desc: '远程 30伤 5格射程', color: '#3B82F6', img: '/game-assets/tower-senior.png', tip: '代码屎山:附带持续伤害' },
{ type: 'pm', name: '产品经理', cost: 160, desc: '远程 20伤 需求变更', color: '#06B6D4', img: '/game-assets/tower-pm.png', tip: '需求变更每4次把怪打回去' },
{ type: 'outsource', name: '外包程序员', cost: 30, desc: '近战 8伤', color: '#94A3B8', img: '/game-assets/tower-outsource.png', tip: '廉价但30%丢空5%自伤' },
{ type: 'intern', name: '00后实习生', cost: 50, desc: '近战 15伤', color: '#22C55E', img: '/game-assets/tower-intern.png', tip: '整顿职场5%概率秒杀' },
{ type: 'hrbp', name: 'HRBP', cost: 80, desc: '辅助+20%攻速', color: '#EC4899', img: '/game-assets/tower-hrbp.png', tip: '打鸡血:周围塔攻速+20%' },
{ type: 'ops', name: '运营专员', cost: 90, desc: '溅射 18伤', color: '#8B5CF6', img: '/game-assets/tower-ops.png', tip: '增长黑客20%双倍HC' },
{ type: 'ppt', name: 'PPT大师', cost: 100, desc: 'AOE减速', color: '#F59E0B', img: '/game-assets/tower-ppt.png', tip: '黑话领域减速40%' },
{ type: 'senior', name: 'P6资深开发', cost: 120, desc: '远程 30伤', color: '#3B82F6', img: '/game-assets/tower-senior.png', tip: '代码屎山:附带持续伤害' },
{ type: 'pm', name: '产品经理', cost: 160, desc: '需求变更', color: '#06B6D4', img: '/game-assets/tower-pm.png', tip: '需求变更每4次把怪打回去' },
] as const
type TowerType = 'outsource' | 'intern' | 'hrbp' | 'ops' | 'ppt' | 'senior' | 'pm'
type EffectType = 'attack_boost' | 'speed_boost' | 'money_rain' | 'rage_mode' | 'backfire'
interface PuaResult {
score: number
title: string
desc: string
effect: EffectType
}
const EFFECT_META: Record<EffectType, { label: string; color: string; icon: string }> = {
attack_boost: { label: '攻击力提升', color: '#22C55E', icon: '⚔️' },
speed_boost: { label: '攻速暴增', color: '#FBBF24', icon: '⚡' },
money_rain: { label: 'HC暴击', color: '#A78BFA', icon: '💰' },
rage_mode: { label: '全场狂暴', color: '#FF4E00', icon: '🔥' },
backfire: { label: '废话翻车', color: '#6B7280', icon: '💀' },
}
const PUA_PLACEHOLDERS = [
'今天是本季度最关键的一天,大家冲!',
'不拼搏,对不起父母的养育之恩...',
'996是福报感恩公司给的机会',
'我们要有狼性精神,卷赢对手!',
'大家加油,相信自己!',
]
// ── PUA 输入面板 ─────────────────────────────────────────────────────────────
function PuaPanel({ gameReady }: { gameReady: boolean }) {
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<PuaResult | null>(null)
const [history, setHistory] = useState<PuaResult[]>([])
const textareaRef = useRef<HTMLTextAreaElement>(null)
const placeholder = PUA_PLACEHOLDERS[Math.floor(Math.random() * PUA_PLACEHOLDERS.length)]
const handleSubmit = useCallback(async () => {
if (!text.trim() || loading || !gameReady) return
setLoading(true)
setResult(null)
try {
const res = await fetch('/api/pua-score', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text.trim() }),
})
const data: PuaResult = await res.json()
setResult(data)
setHistory(prev => [data, ...prev].slice(0, 5))
// 通知游戏场景应用 buff
if (typeof window !== 'undefined') {
;(window as any).__gamePuaBuff?.(data.effect, data.score, data.title)
}
} catch {
setResult({ score: 1, title: '网络故障', desc: 'AI开小差了', effect: 'backfire' })
} finally {
setLoading(false)
}
}, [text, loading, gameReady])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}
const scoreColor = (s: number) =>
s >= 9 ? '#FF4E00' : s >= 7 ? '#FBBF24' : s >= 4 ? '#22C55E' : '#6B7280'
return (
<div style={{
width: '240px',
flexShrink: 0,
backgroundColor: 'rgba(10,18,40,0.97)',
borderLeft: '2px solid #1e3a5f',
display: 'flex',
flexDirection: 'column',
padding: '12px 10px',
gap: '10px',
overflow: 'hidden',
}}>
{/* 标题 */}
<div style={{ textAlign: 'center' }}>
<div style={{
fontFamily: "'Press Start 2P', monospace",
fontSize: '7px',
color: '#EC4899',
letterSpacing: '0.5px',
lineHeight: 1.6,
}}>
PUA
</div>
<div style={{
fontFamily: 'VT323, monospace',
fontSize: '12px',
color: '#475569',
marginTop: '2px',
}}>
AI判断鸡血值
</div>
</div>
{/* 输入框 */}
<textarea
ref={textareaRef}
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={loading || !gameReady}
rows={4}
style={{
width: '100%',
backgroundColor: '#0F1B2D',
border: '1px solid #1e3a5f',
borderRadius: '6px',
color: '#E2E8F0',
fontFamily: 'VT323, monospace',
fontSize: '14px',
padding: '8px',
resize: 'none',
outline: 'none',
lineHeight: 1.4,
opacity: gameReady ? 1 : 0.5,
}}
/>
{/* 提交按钮 */}
<button
onClick={handleSubmit}
disabled={loading || !text.trim() || !gameReady}
style={{
width: '100%',
padding: '8px',
backgroundColor: loading ? '#1e3a5f' : '#7C3AED',
border: 'none',
borderRadius: '6px',
color: '#E2E8F0',
fontFamily: "'Press Start 2P', monospace",
fontSize: '8px',
cursor: loading || !text.trim() || !gameReady ? 'not-allowed' : 'pointer',
opacity: !text.trim() || !gameReady ? 0.5 : 1,
transition: 'all 0.15s',
letterSpacing: '0.5px',
}}
>
{loading ? '分析中...' : '发起激励 ▶'}
</button>
{/* 当前结果 */}
{result && (() => {
const em = EFFECT_META[result.effect]
return (
<div style={{
backgroundColor: '#0F1B2D',
border: `1px solid ${em.color}44`,
borderRadius: '8px',
padding: '10px',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}>
{/* 分数 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontFamily: 'VT323, monospace', fontSize: '13px', color: '#94A3B8' }}>
</span>
<div style={{ display: 'flex', gap: '2px' }}>
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} style={{
width: '8px', height: '8px', borderRadius: '1px',
backgroundColor: i < result.score ? scoreColor(result.score) : '#1e3a5f',
}} />
))}
</div>
</div>
{/* 效果名 */}
<div style={{
fontFamily: "'Press Start 2P', monospace",
fontSize: '9px',
color: em.color,
textAlign: 'center',
padding: '4px 0',
}}>
{em.icon} {result.title}
</div>
{/* 点评 */}
<div style={{
fontFamily: 'VT323, monospace',
fontSize: '13px',
color: '#94A3B8',
textAlign: 'center',
lineHeight: 1.3,
}}>
{result.desc}
</div>
{/* 效果标签 */}
<div style={{
backgroundColor: `${em.color}22`,
border: `1px solid ${em.color}55`,
borderRadius: '4px',
padding: '3px 6px',
fontFamily: 'VT323, monospace',
fontSize: '12px',
color: em.color,
textAlign: 'center',
}}>
{em.label}
</div>
</div>
)
})()}
{/* 历史记录 */}
{history.length > 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{
fontFamily: "'Press Start 2P', monospace",
fontSize: '6px',
color: '#334155',
marginBottom: '2px',
}}>
HISTORY
</div>
{history.slice(1).map((h, i) => {
const em = EFFECT_META[h.effect]
return (
<div key={i} style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#0a1628',
borderRadius: '4px',
padding: '4px 6px',
}}>
<span style={{
fontFamily: 'VT323, monospace', fontSize: '12px',
color: '#475569', flex: 1,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{em.icon} {h.title}
</span>
<div style={{
fontFamily: "'Press Start 2P', monospace",
fontSize: '7px',
color: scoreColor(h.score),
marginLeft: '4px',
}}>
{h.score}
</div>
</div>
)
})}
</div>
)}
{/* 说明 */}
<div style={{
fontFamily: 'VT323, monospace',
fontSize: '11px',
color: '#334155',
lineHeight: 1.4,
marginTop: 'auto',
borderTop: '1px solid #1e3a5f',
paddingTop: '8px',
}}>
Enter <br />
1-3分: 废话翻车<br />
4-6分: 小幅加成<br />
7-8分: 攻速暴增<br />
9-10分: 全场狂暴
</div>
</div>
)
}
// ── 主游戏页面 ────────────────────────────────────────────────────────────────
export default function GamePage() {
const gameRef = useRef<{ destroy: (removeCanvas: boolean) => void } | null>(null)
const [hc, setHc] = useState(200)
@@ -32,28 +308,22 @@ export default function GamePage() {
useEffect(() => {
let mounted = true
const initGame = async () => {
const Phaser = (await import('phaser')).default
const { createGameConfig } = await import('@/game/config')
const { createGameScene } = await import('@/game/GameScene')
if (!mounted) return
// 从 localStorage 读取难度,通过 window 变量传给场景
if (typeof window !== 'undefined') {
const storedDifficulty = localStorage.getItem('game-difficulty')
if (storedDifficulty === 'easy' || storedDifficulty === 'normal' || storedDifficulty === 'hard') {
;(window as any).__gameDifficulty = storedDifficulty
} else {
;(window as any).__gameDifficulty = 'normal'
}
;(window as any).__gameDifficulty =
storedDifficulty === 'easy' || storedDifficulty === 'hard'
? storedDifficulty : 'normal'
}
const GameScene = createGameScene(Phaser)
const config = createGameConfig('game-canvas-container')
config.scene = [GameScene]
if (config.scale) {
config.scale.mode = Phaser.Scale.FIT
config.scale.autoCenter = Phaser.Scale.CENTER_BOTH
@@ -61,68 +331,66 @@ export default function GamePage() {
config.type = Phaser.AUTO
if (typeof window !== 'undefined') {
(window as any).__gameOnHCChange = (val: number) => {
if (mounted) setHc(val)
}
(window as any).__gameOnHCChange = (val: number) => { if (mounted) setHc(val) }
(window as any).__gameOnTowerDeselect = () => {
if (mounted) {
selectedTowerRef.current = null
setSelectedTower(null)
}
}
;(window as any).__gameReady = () => {
if (mounted) setGameReady(true)
if (mounted) { selectedTowerRef.current = null; setSelectedTower(null) }
}
;(window as any).__gameReady = () => { if (mounted) setGameReady(true) }
}
gameRef.current = new Phaser.Game(config)
}
initGame().catch(console.error)
return () => {
mounted = false
gameRef.current?.destroy(true)
gameRef.current = null
if (typeof window !== 'undefined') {
delete (window as any).__gameOnHCChange
delete (window as any).__gameOnTowerDeselect
delete (window as any).__gameSelectTower
delete (window as any).__gameReady
delete (window as any).__gameDifficulty
;['__gameOnHCChange','__gameOnTowerDeselect','__gameSelectTower',
'__gameReady','__gameDifficulty','__gamePuaBuff'].forEach(k => {
delete (window as any)[k]
})
}
}
}, [])
return (
<div
className="w-full h-screen flex flex-col overflow-hidden"
style={{ backgroundColor: '#0A1628' }}
>
{/* Phaser canvas 区域 */}
<div
id="game-canvas-container"
className="flex-1 min-h-0 w-full"
style={{ backgroundColor: '#0A1628' }}
/>
<div className="w-full h-screen flex flex-col overflow-hidden"
style={{ backgroundColor: '#0A1628' }}>
{/* 中间行:游戏画布 + PUA面板 */}
<div className="flex-1 min-h-0 flex flex-row overflow-hidden">
{/* 游戏画布 */}
<div
id="game-canvas-container"
className="flex-1 min-w-0 min-h-0"
style={{ backgroundColor: '#0A1628' }}
/>
{/* PUA 激励台(右侧) */}
<PuaPanel gameReady={gameReady} />
</div>
{/* 底部塔选择面板 */}
<div
style={{
backgroundColor: 'rgba(10,18,40,0.97)',
borderTop: '2px solid #1e3a5f',
padding: '8px 16px',
display: 'flex',
gap: '10px',
justifyContent: 'center',
alignItems: 'center',
flexShrink: 0,
zIndex: 50,
boxShadow: '0 -4px 20px rgba(124,58,237,0.15)',
}}
>
<div style={{
backgroundColor: 'rgba(10,18,40,0.97)',
borderTop: '2px solid #1e3a5f',
padding: '6px 12px',
display: 'flex',
gap: '8px',
justifyContent: 'center',
alignItems: 'center',
flexShrink: 0,
zIndex: 50,
boxShadow: '0 -4px 20px rgba(124,58,237,0.15)',
overflowX: 'auto',
}}>
{/* 选塔提示 */}
<div style={{ fontFamily: "'Press Start 2P', monospace", fontSize: '8px', color: '#7C3AED', marginRight: '8px', whiteSpace: 'nowrap', opacity: gameReady ? 1 : 0.3 }}>
<div style={{
fontFamily: "'Press Start 2P', monospace",
fontSize: '7px', color: '#7C3AED',
marginRight: '6px', whiteSpace: 'nowrap',
opacity: gameReady ? 1 : 0.3,
}}>
{selectedTower ? '点击格子建造' : '选择塔 ▼'}
</div>
@@ -136,38 +404,30 @@ export default function GamePage() {
onClick={() => canAfford && handleSelectTower(meta.type)}
disabled={!canAfford}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '3px',
padding: '6px 10px',
display: 'flex', flexDirection: 'column', alignItems: 'center',
gap: '2px', padding: '5px 8px',
backgroundColor: isSelected ? '#1e3a5f' : '#0F1B2D',
border: `2px solid ${isSelected ? meta.color : '#1e3a5f'}`,
borderRadius: '8px',
cursor: canAfford ? 'pointer' : 'not-allowed',
minWidth: '90px',
minWidth: '76px',
opacity: canAfford ? 1 : 0.4,
transition: 'all 0.15s ease',
boxShadow: isSelected ? `0 0 12px ${meta.color}66` : 'none',
transform: isSelected ? 'translateY(-3px)' : 'none',
boxShadow: isSelected ? `0 0 10px ${meta.color}66` : 'none',
transform: isSelected ? 'translateY(-2px)' : 'none',
flexShrink: 0,
}}
>
<div style={{ width: '48px', height: '48px', position: 'relative', flexShrink: 0 }}>
<div style={{ width: '40px', height: '40px', flexShrink: 0 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={meta.img}
alt={meta.name}
style={{ width: '100%', height: '100%', objectFit: 'contain', imageRendering: 'auto' }}
/>
<img src={meta.img} alt={meta.name}
style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
</div>
<span style={{ fontFamily: 'VT323, monospace', fontSize: '13px', color: meta.color, textAlign: 'center', lineHeight: 1.1 }}>
<span style={{ fontFamily: 'VT323, monospace', fontSize: '12px', color: meta.color, textAlign: 'center', lineHeight: 1 }}>
{meta.name}
</span>
<span style={{ fontFamily: "'Press Start 2P', monospace", fontSize: '8px', color: '#A78BFA' }}>
{meta.cost} HC
</span>
<span style={{ fontFamily: 'VT323, monospace', fontSize: '11px', color: '#64748B', textAlign: 'center' }}>
{meta.desc}
<span style={{ fontFamily: "'Press Start 2P', monospace", fontSize: '7px', color: '#A78BFA' }}>
{meta.cost}HC
</span>
</button>
)