998 lines
36 KiB
TypeScript
998 lines
36 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||
import { TutorialModal, useTutorial } from './TutorialModal'
|
||
|
||
// ── 塔的完整元数据(用于底部面板 + Tooltip) ──────────────────────────────
|
||
interface TowerInfo {
|
||
type: string
|
||
name: string
|
||
cost: number
|
||
color: string
|
||
img: string
|
||
// Tooltip 内容
|
||
quote: string // 人物一句话名言/自我介绍
|
||
atk: string // 攻击类型
|
||
dmg: string // 伤害数值
|
||
range: string // 射程
|
||
speed: string // 攻速
|
||
skill: string // 特殊技能名
|
||
skillDesc: string // 技能描述
|
||
tactics: string // 建议打法
|
||
}
|
||
|
||
const TOWER_META: TowerInfo[] = [
|
||
{
|
||
type: 'outsource', name: '外包程序员', cost: 30, color: '#94A3B8',
|
||
img: '/game-assets/tower-outsource.png',
|
||
quote: '"能跑就行,文档?没有!"',
|
||
atk: '近战', dmg: '8', range: '2格', speed: '0.7次/s',
|
||
skill: 'Bug自助餐',
|
||
skillDesc: '每次攻击30%概率丢空;5%概率Bug反弹,精力瞬间归零',
|
||
tactics: '前期凑数用,价格最便宜。不要指望他,放在路口堵一堵就够了。',
|
||
},
|
||
{
|
||
type: 'intern', name: '00后实习生', cost: 50, color: '#22C55E',
|
||
img: '/game-assets/tower-intern.png',
|
||
quote: '"准点下班,法律护身。"',
|
||
atk: '近战', dmg: '15', range: '1.5格', speed: '1.5次/s',
|
||
skill: '整顿职场',
|
||
skillDesc: '每次攻击5%概率直接秒杀普通怪物;每秒有1%概率自动"跑路",退还25HC',
|
||
tactics: '性价比最高的前排,攒够HC先多建几个。秒杀概率虽低但惊喜不断。',
|
||
},
|
||
{
|
||
type: 'hrbp', name: 'HRBP', cost: 80, color: '#EC4899',
|
||
img: '/game-assets/tower-hrbp.png',
|
||
quote: '"公司是你家,福报靠大家。"',
|
||
atk: '辅助(无伤害)', dmg: '0', range: '1格光环', speed: '每0.5s触发',
|
||
skill: '打鸡血',
|
||
skillDesc: '持续给周围1格内所有塔附加+20%攻速BUFF;每次消耗自身5点精力',
|
||
tactics: '永远放在塔群中央!搭配P6或实习生使用效果翻倍,但别让她精力耗完。',
|
||
},
|
||
{
|
||
type: 'ops', name: '运营专员', cost: 90, color: '#8B5CF6',
|
||
img: '/game-assets/tower-ops.png',
|
||
quote: '"数据不好看?A/B测!"',
|
||
atk: '远程 范围溅射', dmg: '18', range: '3格', speed: '1.2次/s',
|
||
skill: '增长黑客',
|
||
skillDesc: '每次命中20%概率触发"双倍HC",目标死亡时奖励翻倍;命中范围内相邻怪物也受溅射伤害',
|
||
tactics: '人多的时候最强,专门对付密集的应届生群体。顺手赚HC。',
|
||
},
|
||
{
|
||
type: 'ppt', name: 'PPT大师', cost: 100, color: '#F59E0B',
|
||
img: '/game-assets/tower-ppt.png',
|
||
quote: '"底层逻辑要打通,顶层设计要对齐。"',
|
||
atk: 'AOE范围', dmg: '5(范围)', range: '3格圆形', speed: '1.5次/s',
|
||
skill: '黑话领域',
|
||
skillDesc: '攻击圆形范围内所有怪物并降低移速40%,持续2秒;越多怪物越划算',
|
||
tactics: '减速神器!放在路径转弯处效果最佳。搭配P6的DOT伤害组合拳秒杀老员工。',
|
||
},
|
||
{
|
||
type: 'senior', name: 'P6资深开发', cost: 120, color: '#3B82F6',
|
||
img: '/game-assets/tower-senior.png',
|
||
quote: '"这行代码谁写的?别问,是我。"',
|
||
atk: '远程直线', dmg: '30 + DOT 10/s×3s', range: '5格', speed: '1.0次/s',
|
||
skill: '代码屎山',
|
||
skillDesc: '子弹命中后附加DOT(每秒10伤,持续3秒);远程射程5格,直线穿透感',
|
||
tactics: '主力输出,射程超长。优先打护盾老员工和Boss,DOT能绕过护盾持续掉血。',
|
||
},
|
||
{
|
||
type: 'pm', name: '产品经理', cost: 160, color: '#06B6D4',
|
||
img: '/game-assets/tower-pm.png',
|
||
quote: '"这个需求很简单,下班前能上线吗?"',
|
||
atk: '远程曲线', dmg: '20', range: '4格', speed: '0.8次/s',
|
||
skill: '需求变更',
|
||
skillDesc: '每4次攻击触发"需求变更",将目标强制打回2个路径节点,大幅拖延到达时间',
|
||
tactics: '最贵但效果独特!把Boss和老员工不断打回去,配合其他输出塔可以让怪走不出去。',
|
||
},
|
||
]
|
||
|
||
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
|
||
similarity?: number
|
||
similarTo?: string
|
||
isDuplicate?: boolean
|
||
penaltyMultiplier?: number
|
||
}
|
||
|
||
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是福报,感恩公司给的机会',
|
||
'我们要有狼性精神,卷赢对手!',
|
||
'大家加油,相信自己!',
|
||
]
|
||
|
||
// ── 塔的悬浮信息卡片 ─────────────────────────────────────────────────────────
|
||
function TowerTooltip({
|
||
tower,
|
||
pos,
|
||
canAfford,
|
||
}: {
|
||
tower: TowerInfo
|
||
pos: { left: number; bottom: number }
|
||
canAfford: boolean
|
||
}) {
|
||
return (
|
||
<div style={{
|
||
position: 'fixed',
|
||
left: pos.left,
|
||
bottom: pos.bottom,
|
||
transform: 'translateX(-50%)',
|
||
zIndex: 9999,
|
||
width: '220px',
|
||
backgroundColor: '#0a1628',
|
||
border: `2px solid ${tower.color}`,
|
||
borderRadius: '10px',
|
||
padding: '12px',
|
||
boxShadow: `0 0 24px ${tower.color}44, 0 8px 32px rgba(0,0,0,0.7)`,
|
||
pointerEvents: 'none',
|
||
}}>
|
||
{/* 小三角指示器 */}
|
||
<div style={{
|
||
position: 'absolute', bottom: '-8px', left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
width: 0, height: 0,
|
||
borderLeft: '7px solid transparent',
|
||
borderRight: '7px solid transparent',
|
||
borderTop: `7px solid ${tower.color}`,
|
||
}} />
|
||
|
||
{/* 头部:头像 + 名字 + 费用 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img src={tower.img} alt={tower.name}
|
||
style={{ width: '40px', height: '40px', objectFit: 'contain', flexShrink: 0 }} />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{
|
||
fontFamily: "'Press Start 2P', monospace",
|
||
fontSize: '8px', color: tower.color, lineHeight: 1.4,
|
||
}}>
|
||
{tower.name}
|
||
</div>
|
||
<div style={{
|
||
fontFamily: "'Press Start 2P', monospace",
|
||
fontSize: '7px',
|
||
color: canAfford ? '#A78BFA' : '#EF4444',
|
||
marginTop: '3px',
|
||
}}>
|
||
{tower.cost} HC {!canAfford && '(不足)'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 名言 */}
|
||
<div style={{
|
||
fontFamily: 'VT323, monospace', fontSize: '13px',
|
||
color: '#94A3B8', fontStyle: 'italic',
|
||
borderLeft: `2px solid ${tower.color}66`,
|
||
paddingLeft: '8px', marginBottom: '8px', lineHeight: 1.3,
|
||
}}>
|
||
{tower.quote}
|
||
</div>
|
||
|
||
{/* 数值属性 */}
|
||
<div style={{
|
||
display: 'grid', gridTemplateColumns: '1fr 1fr',
|
||
gap: '3px 8px', marginBottom: '8px',
|
||
}}>
|
||
{[
|
||
['攻击', tower.atk],
|
||
['射程', tower.range],
|
||
['伤害', tower.dmg],
|
||
['攻速', tower.speed],
|
||
].map(([label, val]) => (
|
||
<div key={label} style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<span style={{ fontFamily: 'VT323, monospace', fontSize: '10px', color: '#475569' }}>
|
||
{label}
|
||
</span>
|
||
<span style={{ fontFamily: 'VT323, monospace', fontSize: '13px', color: '#E2E8F0' }}>
|
||
{val}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 技能 */}
|
||
<div style={{
|
||
backgroundColor: `${tower.color}15`,
|
||
border: `1px solid ${tower.color}40`,
|
||
borderRadius: '6px',
|
||
padding: '6px 8px',
|
||
marginBottom: '7px',
|
||
}}>
|
||
<div style={{
|
||
fontFamily: "'Press Start 2P', monospace",
|
||
fontSize: '7px', color: tower.color, marginBottom: '4px',
|
||
}}>
|
||
✦ {tower.skill}
|
||
</div>
|
||
<div style={{
|
||
fontFamily: 'VT323, monospace', fontSize: '12px',
|
||
color: '#CBD5E1', lineHeight: 1.35,
|
||
}}>
|
||
{tower.skillDesc}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 打法建议 */}
|
||
<div style={{
|
||
fontFamily: 'VT323, monospace', fontSize: '12px',
|
||
color: '#64748B', lineHeight: 1.35,
|
||
borderTop: '1px solid #1e3a5f', paddingTop: '6px',
|
||
}}>
|
||
💡 {tower.tactics}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── 费用计算:基于当前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 historyTexts = useRef<string[]>([])
|
||
const [insufficient, setInsufficient] = useState(false)
|
||
const [dupWarning, setDupWarning] = useState<string | null>(null)
|
||
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
|
||
|
||
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)
|
||
setDupWarning(null)
|
||
setInsufficient(false)
|
||
|
||
try {
|
||
const res = await fetch('/api/pua-score', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
text: text.trim(),
|
||
history: historyTexts.current,
|
||
}),
|
||
})
|
||
const data: PuaResult = await res.json()
|
||
data.cost = actualCost
|
||
|
||
// 处理重复惩罚:额外扣 HC
|
||
const penalty = data.penaltyMultiplier ?? 0
|
||
if (penalty > 0 && data.isDuplicate) {
|
||
const extraCost = Math.round(actualCost * penalty)
|
||
const penaltyPaid = spendHC?.(extraCost) ?? false
|
||
if (penaltyPaid) {
|
||
data.cost = actualCost + extraCost
|
||
// 显示重复警告
|
||
const sim = data.similarity ?? 0
|
||
const level = sim >= 0.8 ? '严重重复' : '内容相似'
|
||
setDupWarning(
|
||
`检测到${level}!额外扣除 ${extraCost} HC` +
|
||
(data.similarTo ? `\n(与"${data.similarTo}"相似)` : '')
|
||
)
|
||
}
|
||
}
|
||
|
||
setResult(data)
|
||
// 更新历史原文列表(最近8条)
|
||
historyTexts.current = [text.trim(), ...historyTexts.current].slice(0, 8)
|
||
setHistory(prev => [data, ...prev].slice(0, 5))
|
||
|
||
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={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
padding: '10px 10px 6px',
|
||
gap: '8px',
|
||
overflow: 'hidden auto',
|
||
flex: 1,
|
||
}}>
|
||
{/* 标题 */}
|
||
<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',
|
||
}}>
|
||
HC 不足!先去打怪赚钱!
|
||
</div>
|
||
)}
|
||
|
||
{/* 重复惩罚警告 */}
|
||
{dupWarning && (
|
||
<div style={{
|
||
backgroundColor: '#431407',
|
||
border: '1px solid #F97316',
|
||
borderRadius: '6px',
|
||
padding: '7px 8px',
|
||
fontFamily: 'VT323, monospace',
|
||
fontSize: '13px',
|
||
color: '#FED7AA',
|
||
lineHeight: 1.4,
|
||
whiteSpace: 'pre-line',
|
||
}}>
|
||
{dupWarning}
|
||
</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>
|
||
{/* 相似度指示(有重复时显示) */}
|
||
{(result.similarity ?? 0) >= 0.3 && (
|
||
<div style={{
|
||
backgroundColor: '#431407',
|
||
border: '1px solid #F97316',
|
||
borderRadius: '4px',
|
||
padding: '4px 6px',
|
||
fontFamily: 'VT323, monospace',
|
||
fontSize: '12px',
|
||
color: '#FED7AA',
|
||
}}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }}>
|
||
<span>重复检测</span>
|
||
<span style={{ color: (result.similarity ?? 0) >= 0.8 ? '#EF4444' : '#F97316' }}>
|
||
{Math.round((result.similarity ?? 0) * 100)}%
|
||
</span>
|
||
</div>
|
||
{/* 相似度进度条 */}
|
||
<div style={{ height: '3px', backgroundColor: '#1c0a04', borderRadius: '2px' }}>
|
||
<div style={{
|
||
height: '100%',
|
||
width: `${(result.similarity ?? 0) * 100}%`,
|
||
backgroundColor: (result.similarity ?? 0) >= 0.8 ? '#EF4444' : '#F97316',
|
||
borderRadius: '2px',
|
||
transition: 'width 0.3s ease',
|
||
}} />
|
||
</div>
|
||
</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 />
|
||
──────────<br />
|
||
相似60%: 效果降级<br />
|
||
相似80%: 强制翻车<br />
|
||
重复均额外扣HC<br />
|
||
──────────<br />
|
||
9-10分: 全场狂暴<br />
|
||
7-8分: 攻速暴增<br />
|
||
4-6分: 攻击+HC<br />
|
||
1-2分: 废话翻车
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── 主游戏页面 ────────────────────────────────────────────────────────────────
|
||
export default function GamePage() {
|
||
const gameRef = useRef<{ destroy: (removeCanvas: boolean) => void } | null>(null)
|
||
const [hc, setHc] = useState(170)
|
||
const [selectedTower, setSelectedTower] = useState<TowerType | null>(null)
|
||
const [gameReady, setGameReady] = useState(false)
|
||
const [waveStarted, setWaveStarted] = useState(false)
|
||
const [inMeeting, setInMeeting] = useState(false)
|
||
const [waveBtn, setWaveBtn] = useState<{ text: string; disabled: boolean }>({
|
||
text: '▶ 召唤下一波',
|
||
disabled: false,
|
||
})
|
||
const selectedTowerRef = useRef<TowerType | null>(null)
|
||
const { show: showTutorial, dismiss: dismissTutorial } = useTutorial()
|
||
|
||
const handleMeeting = useCallback(() => {
|
||
if (!gameReady) return
|
||
if (!inMeeting) {
|
||
const ok = typeof window !== 'undefined' && (window as any).__gamePause?.()
|
||
if (ok) setInMeeting(true)
|
||
} else {
|
||
const ok = typeof window !== 'undefined' && (window as any).__gameResume?.()
|
||
if (ok) setInMeeting(false)
|
||
}
|
||
}, [gameReady, inMeeting])
|
||
|
||
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)
|
||
}
|
||
}, [])
|
||
|
||
// Tooltip 状态
|
||
const [hoveredTower, setHoveredTower] = useState<TowerInfo | null>(null)
|
||
const [tooltipPos, setTooltipPos] = useState<{ left: number; bottom: number } | null>(null)
|
||
|
||
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) }
|
||
// HUD 通过此回调更新召唤按钮状态
|
||
;(window as any).__gameSetWaveBtn = (s: { text: string; disabled: boolean }) => {
|
||
;(window as any).__gameWaveBtnState = s
|
||
if (mounted) setWaveBtn({ ...s })
|
||
}
|
||
// 轮询 __gameWaveStarted(Phaser 设置后通知 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',
|
||
'__gameSetWaveBtn','__gameWaveBtnState','__gameOnWaveClick',
|
||
'__gamePause','__gameResume','__gameIsPaused'].forEach(k => {
|
||
delete (window as any)[k]
|
||
})
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
return (
|
||
<div className="w-full h-screen flex flex-col overflow-hidden"
|
||
style={{ backgroundColor: '#0A1628' }}>
|
||
|
||
{/* 中间行:游戏画布 + 右侧控制面板 */}
|
||
<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' }}
|
||
/>
|
||
|
||
{/* 右侧面板:HC + 开会按钮 + 召唤按钮 + PUA激励台 */}
|
||
<div style={{
|
||
width: '240px',
|
||
flexShrink: 0,
|
||
backgroundColor: 'rgba(10,18,40,0.97)',
|
||
borderLeft: `2px solid ${inMeeting ? '#22C55E' : '#1e3a5f'}`,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
overflow: 'hidden',
|
||
transition: 'border-color 0.2s',
|
||
boxShadow: inMeeting ? 'inset 0 0 20px rgba(34,197,94,0.08)' : 'none',
|
||
}}>
|
||
{/* HC 数量 + 开会按钮(同一行) */}
|
||
<div style={{
|
||
padding: '8px 12px',
|
||
borderBottom: '1px solid #1e3a5f',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '8px',
|
||
}}>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontFamily: 'VT323, monospace', fontSize: '11px', color: '#475569', marginBottom: '1px' }}>
|
||
人才储备
|
||
</div>
|
||
<div style={{
|
||
fontFamily: "'Press Start 2P', monospace",
|
||
fontSize: '11px',
|
||
color: '#A78BFA',
|
||
letterSpacing: '1px',
|
||
}}>
|
||
{hc} HC
|
||
</div>
|
||
</div>
|
||
{/* 开会按钮 */}
|
||
<button
|
||
onClick={handleMeeting}
|
||
disabled={!gameReady || !waveStarted}
|
||
title={inMeeting ? '结束开会,恢复游戏' : '开会暂停,发起激励'}
|
||
style={{
|
||
flexShrink: 0,
|
||
padding: '6px 10px',
|
||
backgroundColor: inMeeting ? '#14532D' : '#0F1B2D',
|
||
border: `2px solid ${inMeeting ? '#22C55E' : '#1e3a5f'}`,
|
||
borderRadius: '8px',
|
||
color: inMeeting ? '#4ADE80' : (!gameReady || !waveStarted ? '#334155' : '#94A3B8'),
|
||
fontFamily: 'VT323, monospace',
|
||
fontSize: '14px',
|
||
cursor: !gameReady || !waveStarted ? 'not-allowed' : 'pointer',
|
||
transition: 'all 0.15s',
|
||
whiteSpace: 'nowrap',
|
||
lineHeight: 1.2,
|
||
boxShadow: inMeeting ? '0 0 10px rgba(34,197,94,0.3)' : 'none',
|
||
}}
|
||
>
|
||
{inMeeting ? '📋 结束开会' : '📋 开会'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* 开会中提示条 */}
|
||
{inMeeting && (
|
||
<div style={{
|
||
backgroundColor: '#14532D',
|
||
borderBottom: '1px solid #22C55E',
|
||
padding: '5px 12px',
|
||
fontFamily: 'VT323, monospace',
|
||
fontSize: '13px',
|
||
color: '#86EFAC',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '6px',
|
||
}}>
|
||
<span style={{ animation: 'pulse 1.5s ease-in-out infinite' }}>●</span>
|
||
游戏已暂停,可安心激励
|
||
</div>
|
||
)}
|
||
|
||
{/* 召唤下一波按钮(开会时禁用) */}
|
||
<div style={{ padding: '8px 12px', borderBottom: '2px solid #1e3a5f' }}>
|
||
<button
|
||
onClick={() => {
|
||
if (inMeeting) return
|
||
if (typeof window !== 'undefined') {
|
||
(window as any).__gameOnWaveClick?.()
|
||
}
|
||
}}
|
||
disabled={waveBtn.disabled || !gameReady || inMeeting}
|
||
style={{
|
||
width: '100%',
|
||
padding: '10px 8px',
|
||
backgroundColor: waveBtn.disabled || !gameReady || inMeeting ? '#0F172A' : '#1e3a5f',
|
||
border: `2px solid ${waveBtn.disabled || !gameReady || inMeeting ? '#1e293b' : '#7C3AED'}`,
|
||
borderRadius: '8px',
|
||
color: waveBtn.disabled || !gameReady || inMeeting ? '#4B5563' : '#C4B5FD',
|
||
fontFamily: 'VT323, monospace',
|
||
fontSize: '20px',
|
||
cursor: waveBtn.disabled || !gameReady || inMeeting ? 'not-allowed' : 'pointer',
|
||
transition: 'all 0.15s ease',
|
||
letterSpacing: '1px',
|
||
lineHeight: 1.2,
|
||
boxShadow: waveBtn.disabled || !gameReady || inMeeting ? 'none' : '0 0 12px rgba(124,58,237,0.3)',
|
||
}}
|
||
onMouseEnter={e => {
|
||
if (!waveBtn.disabled && gameReady && !inMeeting) {
|
||
(e.currentTarget as HTMLButtonElement).style.backgroundColor = '#2d3a5e'
|
||
;(e.currentTarget as HTMLButtonElement).style.boxShadow = '0 0 18px rgba(124,58,237,0.5)'
|
||
}
|
||
}}
|
||
onMouseLeave={e => {
|
||
if (!waveBtn.disabled && gameReady && !inMeeting) {
|
||
(e.currentTarget as HTMLButtonElement).style.backgroundColor = '#1e3a5f'
|
||
;(e.currentTarget as HTMLButtonElement).style.boxShadow = '0 0 12px rgba(124,58,237,0.3)'
|
||
}
|
||
}}
|
||
>
|
||
{inMeeting ? '开会中...' : waveBtn.text}
|
||
</button>
|
||
</div>
|
||
|
||
{/* PUA 激励台 */}
|
||
<PuaPanel gameReady={gameReady} hc={hc} waveStarted={waveStarted || inMeeting} />
|
||
</div>
|
||
</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}
|
||
onClick={() => canAfford && handleSelectTower(meta.type as TowerType)}
|
||
disabled={!canAfford}
|
||
onMouseEnter={(e) => {
|
||
const rect = (e.currentTarget as HTMLButtonElement).getBoundingClientRect()
|
||
setTooltipPos({
|
||
left: rect.left + rect.width / 2,
|
||
bottom: window.innerHeight - rect.top + 10,
|
||
})
|
||
setHoveredTower(meta)
|
||
}}
|
||
onMouseLeave={() => {
|
||
setHoveredTower(null)
|
||
setTooltipPos(null)
|
||
}}
|
||
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>
|
||
|
||
{/* 塔 Tooltip 弹窗 */}
|
||
{hoveredTower && tooltipPos && (
|
||
<TowerTooltip
|
||
tower={hoveredTower}
|
||
pos={tooltipPos}
|
||
canAfford={hc >= hoveredTower.cost}
|
||
/>
|
||
)}
|
||
|
||
{/* 首次游玩教程弹窗 */}
|
||
{showTutorial && (
|
||
<TutorialModal onClose={dismissTutorial} />
|
||
)}
|
||
</div>
|
||
)
|
||
}
|