Files
test1/app/game/page.tsx

550 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
const TOWER_META = [
{ 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
cost?: number // 实际扣除的 HC前端填写
}
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是福报感恩公司给的机会',
'我们要有狼性精神,卷赢对手!',
'大家加油,相信自己!',
]
// ── 费用计算基于当前HC的15%最低20最高200取整到10的倍数 ────────────
function calcPuaCost(hc: number): number {
const raw = Math.ceil(hc * 0.15)
const rounded = Math.ceil(raw / 10) * 10 // 向上取到整十
return Math.max(20, Math.min(200, rounded))
}
// ── PUA 输入面板 ─────────────────────────────────────────────────────────────
function PuaPanel({ gameReady, hc, waveStarted }: { gameReady: boolean; hc: number; waveStarted: boolean }) {
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<PuaResult | null>(null)
const [history, setHistory] = useState<PuaResult[]>([])
const [insufficient, setInsufficient] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const placeholder = useRef(PUA_PLACEHOLDERS[Math.floor(Math.random() * PUA_PLACEHOLDERS.length)]).current
const cost = calcPuaCost(hc)
const canAfford = hc >= cost
const handleSubmit = useCallback(async () => {
if (!text.trim() || loading || !gameReady || !waveStarted) return
// 先从游戏扣除 HC扣不到则拒绝
const spendHC: ((n: number) => boolean) | undefined =
typeof window !== 'undefined' ? (window as any).__gameSpendHC : undefined
const currentHC: number =
typeof window !== 'undefined'
? ((window as any).__gameGetHC?.() ?? hc)
: hc
const actualCost = calcPuaCost(currentHC)
if (!spendHC?.(actualCost)) {
setInsufficient(true)
setTimeout(() => setInsufficient(false), 2000)
return
}
setLoading(true)
setResult(null)
setInsufficient(false)
try {
const res = await fetch('/api/pua-score', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text.trim() }),
})
const data: PuaResult & { cost?: number } = await res.json()
data.cost = actualCost
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, hc, waveStarted])
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',
}}>
{!waveStarted ? '⚠ 召唤第一波后才能激励' : '输入打鸡血的话AI判断鸡血值'}
</div>
</div>
{/* 费用提示 */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#0a1628',
borderRadius: '6px',
padding: '6px 8px',
border: `1px solid ${insufficient ? '#EF4444' : canAfford ? '#1e3a5f' : '#7F1D1D'}`,
transition: 'border-color 0.2s',
}}>
<span style={{
fontFamily: 'VT323, monospace',
fontSize: '13px',
color: '#64748B',
}}>
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{
fontFamily: "'Press Start 2P', monospace",
fontSize: '9px',
color: canAfford ? '#A78BFA' : '#EF4444',
transition: 'color 0.2s',
}}>
-{cost} HC
</span>
{!canAfford && (
<span style={{
fontFamily: 'VT323, monospace',
fontSize: '11px',
color: '#EF4444',
}}>
</span>
)}
</div>
</div>
{/* HC 不足闪烁提示 */}
{insufficient && (
<div style={{
backgroundColor: '#7F1D1D',
border: '1px solid #EF4444',
borderRadius: '6px',
padding: '6px 8px',
fontFamily: 'VT323, monospace',
fontSize: '14px',
color: '#FCA5A5',
textAlign: 'center',
animation: 'none',
}}>
HC
</div>
)}
{/* 输入框 */}
<textarea
ref={textareaRef}
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={loading || !gameReady || !waveStarted}
rows={4}
style={{
width: '100%',
backgroundColor: '#0F1B2D',
border: `1px solid ${!waveStarted ? '#1e3a5f' : canAfford ? '#1e3a5f' : '#7F1D1D'}`,
borderRadius: '6px',
color: '#E2E8F0',
fontFamily: 'VT323, monospace',
fontSize: '14px',
padding: '8px',
resize: 'none',
outline: 'none',
lineHeight: 1.4,
opacity: gameReady && waveStarted ? 1 : 0.4,
transition: 'border-color 0.2s',
}}
/>
{/* 提交按钮 */}
<button
onClick={handleSubmit}
disabled={loading || !text.trim() || !gameReady || !canAfford || !waveStarted}
style={{
width: '100%',
padding: '8px',
backgroundColor: loading ? '#1e3a5f' : !waveStarted ? '#1e3a5f' : canAfford ? '#7C3AED' : '#4C1D95',
border: 'none',
borderRadius: '6px',
color: '#E2E8F0',
fontFamily: "'Press Start 2P', monospace",
fontSize: '8px',
cursor: loading || !text.trim() || !gameReady || !canAfford || !waveStarted ? 'not-allowed' : 'pointer',
opacity: !text.trim() || !gameReady || !canAfford || !waveStarted ? 0.45 : 1,
transition: 'all 0.15s',
letterSpacing: '0.5px',
}}
>
{loading ? '分析中...' : !waveStarted ? '战斗开始后可用' : !canAfford ? 'HC不足' : `发起激励 -${cost}HC`}
</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={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<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,
}}>
{em.label}
</div>
{result.cost && (
<span style={{
fontFamily: "'Press Start 2P', monospace",
fontSize: '7px',
color: '#475569',
}}>
-{result.cost}HC
</span>
)}
</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.5,
marginTop: 'auto',
borderTop: '1px solid #1e3a5f',
paddingTop: '8px',
}}>
<span style={{ color: '#475569' }}> = HC × 15%</span><br />
<span style={{ color: '#334155' }}>20 200</span><br />
<br />
Enter <br />
1-2分: 废话翻车<br />
4-6分: 攻击+HC<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)
const [selectedTower, setSelectedTower] = useState<TowerType | null>(null)
const [gameReady, setGameReady] = useState(false)
const [waveStarted, setWaveStarted] = useState(false) // 第一波开始后才允许激励
const selectedTowerRef = useRef<TowerType | null>(null)
const handleSelectTower = useCallback((type: TowerType) => {
const next = selectedTowerRef.current === type ? null : type
selectedTowerRef.current = next
setSelectedTower(next)
if (typeof window !== 'undefined') {
(window as any).__gameSelectTower?.(next)
}
}, [])
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
if (typeof window !== 'undefined') {
const storedDifficulty = localStorage.getItem('game-difficulty')
;(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
}
config.type = Phaser.AUTO
if (typeof window !== 'undefined') {
(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) }
// 轮询 __gameWaveStartedPhaser 设置后通知 React
const checkWaveStarted = setInterval(() => {
if ((window as any).__gameWaveStarted) {
if (mounted) setWaveStarted(true)
clearInterval(checkWaveStarted)
}
}, 200)
}
gameRef.current = new Phaser.Game(config)
}
initGame().catch(console.error)
return () => {
mounted = false
gameRef.current?.destroy(true)
gameRef.current = null
if (typeof window !== 'undefined') {
;['__gameOnHCChange','__gameOnTowerDeselect','__gameSelectTower',
'__gameReady','__gameDifficulty','__gamePuaBuff',
'__gameGetHC','__gameSpendHC','__gameWaveStarted'].forEach(k => {
delete (window as any)[k]
})
}
}
}, [])
return (
<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} hc={hc} waveStarted={waveStarted} />
</div>
{/* 底部塔选择面板 */}
<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: '7px', color: '#7C3AED',
marginRight: '6px', whiteSpace: 'nowrap',
opacity: gameReady ? 1 : 0.3,
}}>
{selectedTower ? '点击格子建造' : '选择塔 ▼'}
</div>
{TOWER_META.map((meta) => {
const canAfford = hc >= meta.cost
const isSelected = selectedTower === meta.type
return (
<button
key={meta.type}
title={meta.tip}
onClick={() => canAfford && handleSelectTower(meta.type)}
disabled={!canAfford}
style={{
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: '76px',
opacity: canAfford ? 1 : 0.4,
transition: 'all 0.15s ease',
boxShadow: isSelected ? `0 0 10px ${meta.color}66` : 'none',
transform: isSelected ? 'translateY(-2px)' : 'none',
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' }} />
</div>
<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: '7px', color: '#A78BFA' }}>
{meta.cost}HC
</span>
</button>
)
})}
</div>
</div>
)
}