Files
test1/app/game/page.tsx

991 lines
36 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'
// ── 塔的完整元数据(用于底部面板 + 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: '主力输出射程超长。优先打护盾老员工和BossDOT能绕过护盾持续掉血。',
},
{
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 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 })
}
// 轮询 __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',
'__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}
/>
)}
</div>
)
}