feat: add MMGame backend and frontend source code
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI剧本杀</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "mmgame-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { Home } from './pages/Home'
|
||||
import { Login } from './pages/Login'
|
||||
import { ScriptList } from './pages/ScriptList'
|
||||
import { ScriptDetail } from './pages/ScriptDetail'
|
||||
import { Game } from './pages/Game'
|
||||
import { Replay } from './pages/Replay'
|
||||
import { History } from './pages/History'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="app-container">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/scripts" element={<ScriptList />} />
|
||||
<Route path="/scripts/:id" element={<ScriptDetail />} />
|
||||
<Route path="/game/:id" element={<Game />} />
|
||||
<Route path="/replay/:id" element={<Replay />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useStore } from '../store'
|
||||
|
||||
export function Navbar() {
|
||||
const { user, logout } = useStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<nav>
|
||||
<Link to="/" className="logo">AI 剧本杀</Link>
|
||||
<div className="nav-links">
|
||||
<Link to="/scripts">剧本库</Link>
|
||||
{user ? (
|
||||
<>
|
||||
<Link to="/history">历史记录</Link>
|
||||
<span style={{ color: '#aaa', fontSize: 14 }}>{user.nickname}</span>
|
||||
<button className="btn-ghost" onClick={handleLogout}>退出</button>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login">登录</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/global.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,433 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { api } from '../store/api'
|
||||
|
||||
interface Player {
|
||||
id: string
|
||||
role_id: string
|
||||
role_name: string
|
||||
is_human: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
sender_role_id: string
|
||||
sender_name: string
|
||||
message_type: string
|
||||
content: string
|
||||
target_role_id?: string
|
||||
clue_id?: string
|
||||
phase_index: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function Game() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [gameState, setGameState] = useState<any>(null)
|
||||
const [players, setPlayers] = useState<Player[]>([])
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [clues, setClues] = useState<{ clue_id: string; status: string }[]>([])
|
||||
const [script, setScript] = useState<any>(null)
|
||||
const [phase, setPhase] = useState('')
|
||||
const [phaseIndex, setPhaseIndex] = useState(0)
|
||||
const [status, setStatus] = useState('')
|
||||
const [inputText, setInputText] = useState('')
|
||||
const [chatMode, setChatMode] = useState<'public' | 'private'>('public')
|
||||
const [privateTarget, setPrivateTarget] = useState<string | null>(null)
|
||||
const [voteTarget, setVoteTarget] = useState('')
|
||||
const [voteReason, setVoteReason] = useState('')
|
||||
const [voted, setVoted] = useState(false)
|
||||
const [votingOpen, setVotingOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [wsConnected, setWsConnected] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const [humanRoleId] = useState(() => sessionStorage.getItem('mm-role-id') || '')
|
||||
|
||||
const addMessages = useCallback((newMsgs: Message[], replaceLocalId?: string, replaceWithRealId?: string) => {
|
||||
setMessages((prev) => {
|
||||
let msgs = prev
|
||||
if (replaceLocalId && replaceWithRealId) {
|
||||
msgs = msgs.map((m) => m.id === replaceLocalId ? { ...m, id: replaceWithRealId } : m)
|
||||
}
|
||||
const existingIds = new Set(msgs.map((m) => m.id))
|
||||
const toAdd: Message[] = []
|
||||
for (const msg of newMsgs) {
|
||||
if (existingIds.has(msg.id)) continue
|
||||
const localIdx = msgs.findIndex(
|
||||
(m) => m.id.startsWith('local-') && m.sender_role_id === msg.sender_role_id && m.content === msg.content
|
||||
)
|
||||
if (localIdx >= 0) {
|
||||
msgs[localIdx] = { ...msg }
|
||||
existingIds.add(msg.id)
|
||||
} else {
|
||||
toAdd.push(msg)
|
||||
existingIds.add(msg.id)
|
||||
}
|
||||
}
|
||||
return toAdd.length > 0 ? [...msgs, ...toAdd] : msgs
|
||||
})
|
||||
}, [])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages, scrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
const load = async () => {
|
||||
try {
|
||||
const state = await api.games.state(id)
|
||||
setGameState(state)
|
||||
setPlayers(state.players)
|
||||
setMessages(state.messages || [])
|
||||
setClues(state.clues || [])
|
||||
setScript(state.script)
|
||||
setPhase(state.phase)
|
||||
setPhaseIndex(state.phase_index)
|
||||
setStatus(state.status)
|
||||
} catch (err: any) {
|
||||
alert('加载游戏失败: ' + err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
|
||||
const connectWs = () => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/games/ws/${id}?role_id=${humanRoleId}`
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => setWsConnected(true)
|
||||
ws.onclose = () => {
|
||||
setWsConnected(false)
|
||||
setTimeout(connectWs, 3000)
|
||||
}
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
handleWsMessage(data)
|
||||
} catch {}
|
||||
}
|
||||
wsRef.current = ws
|
||||
}
|
||||
|
||||
connectWs()
|
||||
return () => {
|
||||
wsRef.current?.close()
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const handleWsMessage = (data: any) => {
|
||||
switch (data.type) {
|
||||
case 'chat_message':
|
||||
addMessages([data.data as Message])
|
||||
break
|
||||
case 'phase_change':
|
||||
setPhase(data.data.phase)
|
||||
setPhaseIndex(data.data.phase_index)
|
||||
if (data.data.message) {
|
||||
addMessages([data.data.message])
|
||||
}
|
||||
if (data.data.new_clues) {
|
||||
setClues((prev) => [...prev, ...data.data.new_clues.map((c: string) => ({ clue_id: c, status: 'released' }))])
|
||||
}
|
||||
break
|
||||
case 'clue_revealed':
|
||||
setClues((prev) =>
|
||||
prev.map((c) => c.clue_id === data.data.clue_id ? { ...c, status: 'revealed' } : c)
|
||||
)
|
||||
break
|
||||
case 'vote_cast':
|
||||
break
|
||||
case 'vote_result':
|
||||
setStatus('completed')
|
||||
if (data.data.message) {
|
||||
addMessages([data.data.message])
|
||||
}
|
||||
setTimeout(() => navigate(`/replay/${id}`), 3000)
|
||||
break
|
||||
case 'game_started':
|
||||
setStatus('playing')
|
||||
if (data.data?.message) {
|
||||
addMessages([data.data.message])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartGame = async () => {
|
||||
if (!id) return
|
||||
await api.games.start(id)
|
||||
setStatus('playing')
|
||||
}
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputText.trim() || !id || !humanRoleId) return
|
||||
const targetId = chatMode === 'private' && privateTarget ? privateTarget : undefined
|
||||
const localId = 'local-' + Date.now()
|
||||
const newMsg: Message = {
|
||||
id: localId,
|
||||
sender_role_id: humanRoleId,
|
||||
sender_name: players.find((p) => p.role_id === humanRoleId)?.role_name || humanRoleId,
|
||||
message_type: chatMode,
|
||||
content: inputText,
|
||||
target_role_id: targetId,
|
||||
phase_index: phaseIndex,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
addMessages([newMsg])
|
||||
setInputText('')
|
||||
try {
|
||||
const res = await api.games.chat(id, humanRoleId, chatMode, inputText, targetId)
|
||||
addMessages([], localId, res.message_id)
|
||||
if (res.npc_responses) {
|
||||
addMessages(res.npc_responses.map((n: any) => n.data))
|
||||
}
|
||||
} catch (err: any) {
|
||||
setMessages((prev) => prev.map((m) => m.id === localId ? { ...m, content: m.content + ' (发送失败)' } : m))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdvancePhase = async () => {
|
||||
if (!id) return
|
||||
await api.games.advancePhase(id)
|
||||
}
|
||||
|
||||
const handleRevealClue = async (clueId: string) => {
|
||||
if (!id) return
|
||||
await api.games.revealClue(id, clueId)
|
||||
}
|
||||
|
||||
const handleVote = async () => {
|
||||
if (!id || !voteTarget) return
|
||||
await api.games.vote(id, humanRoleId, voteTarget, voteReason)
|
||||
setVoted(true)
|
||||
setVotingOpen(false)
|
||||
}
|
||||
|
||||
const handleEndVote = async () => {
|
||||
if (!id) return
|
||||
await api.games.endVote(id)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="page"><p style={{ textAlign: 'center', padding: 40 }}>加载游戏中...</p></div>
|
||||
}
|
||||
|
||||
const npcPlayers = players.filter((p) => !p.is_human)
|
||||
const humanPlayer = players.find((p) => p.is_human)
|
||||
const releasedClues = clues.filter((c) => c.status !== 'unreleased')
|
||||
const revealedClues = clues.filter((c) => c.status === 'revealed')
|
||||
|
||||
return (
|
||||
<div className="game-layout">
|
||||
{/* Left: Role Panel */}
|
||||
<div className="role-panel">
|
||||
<h3>角色列表</h3>
|
||||
{players.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="role-item"
|
||||
onClick={() => {
|
||||
if (chatMode === 'private' && privateTarget === p.role_id) {
|
||||
setChatMode('public')
|
||||
setPrivateTarget(null)
|
||||
} else {
|
||||
setChatMode('private')
|
||||
setPrivateTarget(p.role_id)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: chatMode === 'private' && privateTarget === p.role_id ? '#0f3460' : undefined,
|
||||
border: p.is_human ? '1px solid #e94560' : 'none',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<div className="avatar">{p.role_name[0]}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14 }}>{p.role_name}</div>
|
||||
<div style={{ fontSize: 11, color: '#888' }}>
|
||||
{p.is_human ? '你' : 'AI'}
|
||||
{chatMode === 'private' && privateTarget === p.role_id ? ' (私聊中)' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`status-dot ${p.is_human ? 'active' : 'thinking'}`} />
|
||||
</div>
|
||||
))}
|
||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button className="btn-ghost" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => { setChatMode('public'); setPrivateTarget(null) }}>
|
||||
公聊模式 {chatMode === 'public' ? '✓' : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Chat Area */}
|
||||
<div className="chat-area">
|
||||
<div className="chat-header">
|
||||
<div>
|
||||
<span className="phase-name">{phase}</span>
|
||||
<span style={{ marginLeft: 16, fontSize: 13, color: '#888' }}>
|
||||
{status === 'waiting' ? '等待开始' : status === 'playing' ? '进行中' : '已结束'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: wsConnected ? '#4caf50' : '#ff9800', display: 'inline-block',
|
||||
}} />
|
||||
{status === 'waiting' && (
|
||||
<button className="btn-primary" onClick={handleStartGame}>开始游戏</button>
|
||||
)}
|
||||
{status === 'playing' && (
|
||||
<>
|
||||
<button className="btn-ghost" onClick={handleAdvancePhase}>下一幕</button>
|
||||
<button className="btn-secondary" onClick={() => setVotingOpen(true)}>投票</button>
|
||||
</>
|
||||
)}
|
||||
{status === 'playing' && voted && (
|
||||
<button className="btn-primary" onClick={handleEndVote}>结束投票</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chat-messages">
|
||||
{messages.map((m) => (
|
||||
<div key={m.id} className={`message ${m.message_type === 'system' ? 'system' : m.sender_role_id === 'dm' ? 'dm' : m.sender_role_id === humanPlayer?.role_id ? 'player' : 'npc'}`}>
|
||||
{m.message_type !== 'system' && (
|
||||
<div className="sender">
|
||||
{m.sender_name}
|
||||
{m.message_type === 'private' && m.target_role_id && (
|
||||
<span style={{ color: '#e94560' }}> → 私聊</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ whiteSpace: 'pre-wrap' }}>{m.content}</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="chat-input">
|
||||
{chatMode === 'private' && (
|
||||
<span style={{
|
||||
background: '#0f3460', padding: '6px 12px', borderRadius: 4,
|
||||
fontSize: 12, whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: 4,
|
||||
}}>
|
||||
私聊 {players.find((p) => p.role_id === privateTarget)?.role_name}
|
||||
<button
|
||||
onClick={() => { setChatMode('public'); setPrivateTarget(null) }}
|
||||
style={{ background: 'none', border: 'none', color: '#e94560', cursor: 'pointer', padding: 0, fontSize: 14 }}
|
||||
>×</button>
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
placeholder={chatMode === 'public' ? '公聊发言...' : '私聊发言...'}
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button className="btn-primary" onClick={handleSendMessage}>发送</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Clue Panel */}
|
||||
<div className="right-panel">
|
||||
<h3>线索牌堆</h3>
|
||||
{releasedClues.length === 0 && (
|
||||
<p style={{ fontSize: 13, color: '#888' }}>暂无线索</p>
|
||||
)}
|
||||
{releasedClues.map((c) => {
|
||||
const clueInfo = script?.clues?.find((cl: any) => cl.id === c.clue_id)
|
||||
const isRevealed = c.status === 'revealed'
|
||||
return (
|
||||
<div
|
||||
key={c.clue_id}
|
||||
className="clue-card"
|
||||
onClick={() => !isRevealed && handleRevealClue(c.clue_id)}
|
||||
style={{ opacity: isRevealed ? 1 : 0.7, cursor: isRevealed ? 'default' : 'pointer' }}
|
||||
>
|
||||
<div className="clue-name">
|
||||
{clueInfo?.name || c.clue_id}
|
||||
{isRevealed ? ' 🔍' : ' 📌'}
|
||||
</div>
|
||||
<div className="clue-desc">
|
||||
{isRevealed ? clueInfo?.description || '' : '点击查看'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{revealedClues.length > 0 && (
|
||||
<>
|
||||
<h3 style={{ marginTop: 20 }}>已揭示的线索</h3>
|
||||
{revealedClues.map((c) => {
|
||||
const clueInfo = script?.clues?.find((cl: any) => cl.id === c.clue_id)
|
||||
return (
|
||||
<div key={c.clue_id} className="clue-card" style={{ borderColor: '#e94560' }}>
|
||||
<div className="clue-name">{clueInfo?.name || c.clue_id}</div>
|
||||
<div className="clue-desc">{clueInfo?.description || ''}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vote Modal */}
|
||||
{votingOpen && (
|
||||
<div className="modal-overlay" onClick={() => setVotingOpen(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>投票指认</h2>
|
||||
{players.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`vote-option ${voteTarget === p.role_id ? 'selected' : ''}`}
|
||||
onClick={() => setVoteTarget(p.role_id)}
|
||||
>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: '50%', background: '#0f3460',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{p.role_name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<div>{p.role_name}</div>
|
||||
<div style={{ fontSize: 12, color: '#888' }}>{p.is_human ? '你' : 'AI NPC'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<textarea
|
||||
placeholder="理由(可选)"
|
||||
value={voteReason}
|
||||
onChange={(e) => setVoteReason(e.target.value)}
|
||||
style={{ marginTop: 12, minHeight: 60, resize: 'vertical' }}
|
||||
/>
|
||||
<div className="modal-actions">
|
||||
<button className="btn-ghost" onClick={() => setVotingOpen(false)}>取消</button>
|
||||
<button className="btn-primary" onClick={handleVote} disabled={!voteTarget}>提交投票</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Navbar } from '../components/Navbar'
|
||||
import { api } from '../store/api'
|
||||
import { useStore } from '../store'
|
||||
|
||||
export function History() {
|
||||
const [games, setGames] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const user = useStore((s) => s.user)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
api.auth.history()
|
||||
.then(setGames)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [user])
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="page">
|
||||
<p style={{ textAlign: 'center', padding: 40 }}>请先登录</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="page">
|
||||
<h1 style={{ color: '#e94560', marginBottom: 24 }}>历史记录</h1>
|
||||
{loading ? (
|
||||
<p style={{ textAlign: 'center', padding: 40 }}>加载中...</p>
|
||||
) : games.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', padding: 40, color: '#888' }}>暂无游戏记录</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{games.map((g) => (
|
||||
<div key={g.id} className="card" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>
|
||||
{new Date(g.created_at).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{g.script_id}</strong>
|
||||
<span style={{
|
||||
marginLeft: 12, fontSize: 12, padding: '2px 8px', borderRadius: 4,
|
||||
background: g.status === 'completed' ? '#4caf50' : '#ff9800', color: '#fff',
|
||||
}}>
|
||||
{g.status === 'completed' ? '已完成' : g.status === 'playing' ? '进行中' : g.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{g.status === 'completed' && (
|
||||
<Link to={`/replay/${g.id}`}><button className="btn-ghost">复盘</button></Link>
|
||||
)}
|
||||
{g.status === 'playing' && (
|
||||
<Link to={`/game/${g.id}`}><button className="btn-primary">继续</button></Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Navbar } from '../components/Navbar'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../store/api'
|
||||
|
||||
export function Home() {
|
||||
const navigate = useNavigate()
|
||||
const [scripts, setScripts] = useState<any[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
api.scripts.list().then(setScripts).catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="hero">
|
||||
<h1>AI 剧本杀</h1>
|
||||
<p>随时开局,人人都是主角。1 人即可开局,AI 队友补位。</p>
|
||||
<div className="actions">
|
||||
<button className="btn-primary" onClick={() => navigate('/scripts')}>
|
||||
开始游戏
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={() => navigate('/login')}>
|
||||
登录 / 注册
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page">
|
||||
<h2 style={{ marginBottom: 16 }}>推荐剧本</h2>
|
||||
<div className="script-grid">
|
||||
{scripts.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="card script-card"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/scripts/${s.id}`)}
|
||||
>
|
||||
<h3>{s.title}</h3>
|
||||
<div className="tags">
|
||||
<span className="tag">{s.type}</span>
|
||||
<span className="tag difficulty">{'⭐'.repeat(s.difficulty)}</span>
|
||||
<span className="tag">{s.duration}</span>
|
||||
<span className="tag">{s.min_players}-{s.max_players}人</span>
|
||||
</div>
|
||||
<p style={{ color: '#aaa', fontSize: 14, lineHeight: 1.6 }}>{s.background}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../store/api'
|
||||
import { useStore } from '../store'
|
||||
|
||||
export function Login() {
|
||||
const [tab, setTab] = useState<'login' | 'register'>('login')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [nickname, setNickname] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const setUser = useStore((s) => s.setUser)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const fn = tab === 'login' ? api.auth.login : api.auth.register
|
||||
const data = tab === 'login'
|
||||
? await fn({ email, password } as any)
|
||||
: await fn({ email, password, nickname })
|
||||
setUser(data)
|
||||
navigate('/scripts')
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGuest = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.auth.guest()
|
||||
setUser(data)
|
||||
navigate('/scripts')
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-box">
|
||||
<h1>AI 剧本杀</h1>
|
||||
<div className="tab-bar">
|
||||
<button className={tab === 'login' ? 'active' : ''} onClick={() => setTab('login')}>登录</button>
|
||||
<button className={tab === 'register' ? 'active' : ''} onClick={() => setTab('register')}>注册</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
{tab === 'register' && (
|
||||
<input placeholder="昵称" value={nickname} onChange={(e) => setNickname(e.target.value)} required />
|
||||
)}
|
||||
<input type="email" placeholder="邮箱" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
<input type="password" placeholder="密码" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
{error && <p style={{ color: '#e94560', fontSize: 14 }}>{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? '处理中...' : tab === 'login' ? '登录' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="guest-btn">
|
||||
<button onClick={handleGuest} disabled={loading}>游客模式体验</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { Navbar } from '../components/Navbar'
|
||||
import { api } from '../store/api'
|
||||
|
||||
export function Replay() {
|
||||
const { id } = useParams()
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
api.games.replay(id)
|
||||
.then(setData)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
if (loading) return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="page"><p style={{ textAlign: 'center', padding: 40 }}>加载复盘数据...</p></div>
|
||||
</div>
|
||||
)
|
||||
if (!data) return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="page"><p style={{ textAlign: 'center', padding: 40 }}>复盘数据不存在</p></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="replay-page">
|
||||
<Link to="/scripts" style={{ color: '#0f3460', fontSize: 14 }}>← 返回剧本库</Link>
|
||||
<h1>{data.script_title} - 复盘报告</h1>
|
||||
|
||||
<div className="replay-truth">
|
||||
<h2>案件真相</h2>
|
||||
<p style={{ lineHeight: 1.8 }}>{data.truth}</p>
|
||||
</div>
|
||||
|
||||
<h2 style={{ color: '#e94560', marginBottom: 16 }}>角色秘密全公开</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16, marginBottom: 24 }}>
|
||||
{data.roles?.map((role: any, i: number) => (
|
||||
<div key={i} className="card">
|
||||
<h3 style={{ color: '#e94560', marginBottom: 8 }}>{role.name}</h3>
|
||||
<p style={{ fontSize: 13, color: '#aaa', marginBottom: 4 }}>公开身份:{role.publicProfile}</p>
|
||||
<p style={{ fontSize: 13, color: '#ff9800', marginBottom: 4 }}>秘密:{role.secretProfile}</p>
|
||||
<p style={{ fontSize: 13, color: '#888' }}>目标:{role.goal}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 style={{ color: '#e94560', marginBottom: 16 }}>投票记录</h2>
|
||||
{data.votes?.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 24 }}>
|
||||
{data.votes.map((v: any, i: number) => (
|
||||
<div key={i} className="card" style={{ padding: 12, fontSize: 14 }}>
|
||||
<strong>{v.voter_name}</strong> 投票给 <strong>{v.target_name}</strong>
|
||||
{v.reason && <span style={{ color: '#aaa' }}> — {v.reason}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: '#888', marginBottom: 24 }}>暂无投票记录</p>
|
||||
)}
|
||||
|
||||
<h2 style={{ color: '#e94560', marginBottom: 16 }}>对话回放</h2>
|
||||
<div className="replay-messages">
|
||||
{data.messages?.map((m: any) => (
|
||||
<div key={m.id} className="replay-msg">
|
||||
<strong style={{ color: m.type === 'system' ? '#ff9800' : m.sender_name === 'DM' ? '#e94560' : '#0f3460' }}>
|
||||
{m.sender_name}
|
||||
</strong>
|
||||
:{m.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Navbar } from '../components/Navbar'
|
||||
import { api } from '../store/api'
|
||||
|
||||
export function ScriptDetail() {
|
||||
const { id } = useParams()
|
||||
const [script, setScript] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
api.scripts.detail(id)
|
||||
.then(setScript)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!script || !selectedRoleId) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const { game_id } = await api.games.create(script.id, selectedRoleId)
|
||||
sessionStorage.setItem('mm-role-id', selectedRoleId)
|
||||
navigate(`/game/${game_id}`)
|
||||
} catch (err: any) {
|
||||
alert(err.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="page"><p style={{ textAlign: 'center', padding: 40 }}>加载中...</p></div>
|
||||
</div>
|
||||
)
|
||||
if (!script) return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="page"><p style={{ textAlign: 'center', padding: 40 }}>剧本不存在</p></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="page">
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ color: '#e94560', marginBottom: 12 }}>{script.title}</h1>
|
||||
<div className="tags" style={{ marginBottom: 16 }}>
|
||||
<span className="tag">{script.type}</span>
|
||||
<span className="tag difficulty">{'⭐'.repeat(script.difficulty)}</span>
|
||||
<span className="tag">{script.duration}</span>
|
||||
<span className="tag">{script.playerCount.min}-{script.playerCount.max}人</span>
|
||||
</div>
|
||||
<p style={{ lineHeight: 1.8, marginBottom: 16 }}>{script.background}</p>
|
||||
</div>
|
||||
|
||||
<h2 style={{ color: '#e94560', marginBottom: 16 }}>选择你的角色</h2>
|
||||
<p style={{ color: '#888', marginBottom: 16, fontSize: 14 }}>点击选择一个角色,其他角色将由 AI 扮演</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16, marginBottom: 24 }}>
|
||||
{script.roles?.map((role: any) => (
|
||||
<div
|
||||
key={role.id}
|
||||
className="card"
|
||||
onClick={() => setSelectedRoleId(role.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor: selectedRoleId === role.id ? '#e94560' : '#0f3460',
|
||||
borderWidth: selectedRoleId === role.id ? 2 : 1,
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: '50%', background: '#0f3460',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, fontWeight: 'bold',
|
||||
color: selectedRoleId === role.id ? '#e94560' : '#e0e0e0',
|
||||
}}>
|
||||
{role.name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ marginBottom: 4 }}>
|
||||
{role.name}
|
||||
{selectedRoleId === role.id && <span style={{ color: '#e94560', fontSize: 12, marginLeft: 8 }}>✓ 已选</span>}
|
||||
</h3>
|
||||
<p style={{ fontSize: 13, color: '#aaa' }}>{role.publicProfile}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: '#888', marginBottom: 8 }}>目标:{role.goal}</p>
|
||||
<p style={{ fontSize: 13, color: '#888' }}>性格:{role.personality}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleStart}
|
||||
disabled={creating || !selectedRoleId}
|
||||
style={{ padding: '14px 48px', fontSize: 18 }}
|
||||
>
|
||||
{creating ? '创建中...' : selectedRoleId ? '开始游戏' : '请先选择一个角色'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Navbar } from '../components/Navbar'
|
||||
import { api } from '../store/api'
|
||||
|
||||
export function ScriptList() {
|
||||
const [scripts, setScripts] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
api.scripts.list()
|
||||
.then(setScripts)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="page">
|
||||
<h1 style={{ color: '#e94560', marginBottom: 8 }}>剧本库</h1>
|
||||
<p style={{ color: '#aaa', marginBottom: 24 }}>选择一个剧本开始你的推理之旅</p>
|
||||
{loading ? (
|
||||
<p style={{ textAlign: 'center', padding: 40 }}>加载中...</p>
|
||||
) : (
|
||||
<div className="script-grid">
|
||||
{scripts.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="card script-card"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/scripts/${s.id}`)}
|
||||
>
|
||||
<h3>{s.title}</h3>
|
||||
<div className="tags">
|
||||
<span className="tag">{s.type}</span>
|
||||
<span className="tag difficulty">{'⭐'.repeat(s.difficulty)}</span>
|
||||
<span className="tag">{s.duration}</span>
|
||||
<span className="tag">{s.min_players}-{s.max_players}人</span>
|
||||
</div>
|
||||
<p style={{ color: '#aaa', fontSize: 14, lineHeight: 1.6 }}>{s.background}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
const API_BASE = '/api'
|
||||
|
||||
async function request(path: string, options: RequestInit = {}) {
|
||||
const stored = JSON.parse(localStorage.getItem('mmgame-storage') || '{}')?.state?.user
|
||||
const token = stored?.token
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
}
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
const res = await fetch(`${API_BASE}${path}`, { ...options, headers })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
||||
throw new Error(err.detail || '请求失败')
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
auth: {
|
||||
register: (data: { email: string; password: string; nickname: string }) =>
|
||||
request('/auth/register', { method: 'POST', body: JSON.stringify(data) }),
|
||||
login: (data: { email: string; password: string }) =>
|
||||
request('/auth/login', { method: 'POST', body: JSON.stringify(data) }),
|
||||
guest: (nickname?: string) =>
|
||||
request('/auth/guest', { method: 'POST', body: JSON.stringify({ nickname: nickname || '游客' }) }),
|
||||
logout: () => request('/auth/logout', { method: 'POST' }),
|
||||
profile: () => request('/auth/profile'),
|
||||
history: () => request('/auth/history'),
|
||||
},
|
||||
scripts: {
|
||||
list: () => request('/scripts'),
|
||||
detail: (id: string) => request(`/scripts/${id}`),
|
||||
},
|
||||
games: {
|
||||
create: (script_id: string, human_role_id?: string) =>
|
||||
request('/games/create', { method: 'POST', body: JSON.stringify({ script_id, human_role_id: human_role_id || '' }) }),
|
||||
join: (game_id: string, role_id: string) =>
|
||||
request(`/games/${game_id}/join`, { method: 'POST', body: JSON.stringify({ role_id }) }),
|
||||
start: (game_id: string) =>
|
||||
request(`/games/${game_id}/start`, { method: 'POST' }),
|
||||
state: (game_id: string) => request(`/games/${game_id}/state`),
|
||||
advancePhase: (game_id: string) =>
|
||||
request(`/games/${game_id}/phase/advance`, { method: 'POST' }),
|
||||
revealClue: (game_id: string, clue_id: string) =>
|
||||
request(`/games/${game_id}/clue/${clue_id}/reveal`, { method: 'POST' }),
|
||||
vote: (game_id: string, voter_role_id: string, target_role_id: string, reason?: string) =>
|
||||
request(`/games/${game_id}/vote`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ voter_role_id, target_role_id, reason: reason || '' }),
|
||||
}),
|
||||
endVote: (game_id: string) =>
|
||||
request(`/games/${game_id}/vote/end`, { method: 'POST' }),
|
||||
chat: (game_id: string, sender_role_id: string, type: string, content: string, target_role_id?: string) =>
|
||||
request(`/games/${game_id}/chat`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sender_role_id, type, content, target_role_id }),
|
||||
}),
|
||||
replay: (game_id: string) => request(`/games/${game_id}/replay`),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
interface UserInfo {
|
||||
token: string
|
||||
user_id: string
|
||||
nickname: string
|
||||
is_guest: boolean
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
sender_role_id: string
|
||||
sender_name: string
|
||||
message_type: string
|
||||
content: string
|
||||
target_role_id?: string
|
||||
clue_id?: string
|
||||
phase_index: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface PlayerInfo {
|
||||
id: string
|
||||
role_id: string
|
||||
role_name: string
|
||||
is_human: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
interface ClueInfo {
|
||||
clue_id: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
user: UserInfo | null
|
||||
setUser: (user: UserInfo) => void
|
||||
logout: () => void
|
||||
|
||||
currentGame: string | null
|
||||
gamePhase: string
|
||||
gamePhaseIndex: number
|
||||
players: PlayerInfo[]
|
||||
clues: ClueInfo[]
|
||||
messages: Message[]
|
||||
script: any
|
||||
ws: WebSocket | null
|
||||
|
||||
setGame: (id: string) => void
|
||||
setGameState: (state: Partial<{
|
||||
phase: string
|
||||
phase_index: number
|
||||
players: PlayerInfo[]
|
||||
clues: ClueInfo[]
|
||||
messages: Message[]
|
||||
script: any
|
||||
}>) => void
|
||||
addMessage: (msg: Message) => void
|
||||
setWs: (ws: WebSocket | null) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useStore = create<GameState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
setUser: (user) => set({ user }),
|
||||
logout: () => {
|
||||
get().ws?.close()
|
||||
set({ user: null, currentGame: null, gamePhase: '', gamePhaseIndex: 0, players: [], clues: [], messages: [], script: null, ws: null })
|
||||
},
|
||||
|
||||
currentGame: null,
|
||||
gamePhase: '',
|
||||
gamePhaseIndex: 0,
|
||||
players: [],
|
||||
clues: [],
|
||||
messages: [],
|
||||
script: null,
|
||||
ws: null,
|
||||
|
||||
setGame: (id) => set({ currentGame: id }),
|
||||
setGameState: (state) => set({
|
||||
gamePhase: state.phase ?? get().gamePhase,
|
||||
gamePhaseIndex: state.phase_index ?? get().gamePhaseIndex,
|
||||
players: state.players ?? get().players,
|
||||
clues: state.clues ?? get().clues,
|
||||
messages: state.messages ?? get().messages,
|
||||
script: state.script ?? get().script,
|
||||
}),
|
||||
addMessage: (msg) => set({ messages: [...get().messages, msg] }),
|
||||
setWs: (ws) => set({ ws }),
|
||||
reset: () => set({
|
||||
currentGame: null, gamePhase: '', gamePhaseIndex: 0,
|
||||
players: [], clues: [], messages: [], script: null, ws: null,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'mmgame-storage',
|
||||
partialize: (state) => ({ user: state.user }),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.app-container { min-height: 100vh; }
|
||||
a { color: #0f3460; text-decoration: none; }
|
||||
button {
|
||||
cursor: pointer; border: none; border-radius: 6px; padding: 10px 24px;
|
||||
font-size: 15px; font-family: inherit;
|
||||
}
|
||||
input, textarea {
|
||||
background: #16213e; border: 1px solid #0f3460; border-radius: 6px;
|
||||
padding: 10px 14px; color: #e0e0e0; font-size: 15px; font-family: inherit;
|
||||
outline: none; width: 100%;
|
||||
}
|
||||
input:focus, textarea:focus { border-color: #e94560; }
|
||||
|
||||
.btn-primary { background: #e94560; color: #fff; }
|
||||
.btn-primary:hover { background: #d63851; }
|
||||
.btn-secondary { background: #0f3460; color: #fff; }
|
||||
.btn-secondary:hover { background: #1a4a8a; }
|
||||
.btn-ghost { background: transparent; color: #e0e0e0; border: 1px solid #0f3460; }
|
||||
.btn-ghost:hover { background: #16213e; }
|
||||
|
||||
.card {
|
||||
background: #16213e; border-radius: 10px; padding: 24px;
|
||||
border: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.page { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||
|
||||
/* Login */
|
||||
.login-page {
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
min-height: 100vh; flex-direction: column; gap: 24px;
|
||||
}
|
||||
.login-box {
|
||||
background: #16213e; border-radius: 12px; padding: 40px;
|
||||
width: 400px; border: 1px solid #0f3460;
|
||||
}
|
||||
.login-box h1 { text-align: center; margin-bottom: 32px; color: #e94560; }
|
||||
.login-box form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.login-box .tab-bar { display: flex; margin-bottom: 20px; }
|
||||
.login-box .tab-bar button {
|
||||
flex: 1; background: transparent; color: #888; padding: 12px;
|
||||
border-bottom: 2px solid transparent; border-radius: 0;
|
||||
}
|
||||
.login-box .tab-bar button.active { color: #e94560; border-bottom-color: #e94560; }
|
||||
.guest-btn { margin-top: 16px; text-align: center; }
|
||||
.guest-btn button { color: #0f3460; background: transparent; text-decoration: underline; }
|
||||
|
||||
/* Home */
|
||||
.hero {
|
||||
text-align: center; padding: 80px 24px;
|
||||
background: linear-gradient(135deg, #1a1a2e, #0f3460);
|
||||
}
|
||||
.hero h1 { font-size: 48px; color: #e94560; margin-bottom: 16px; }
|
||||
.hero p { font-size: 18px; color: #aaa; margin-bottom: 32px; }
|
||||
.hero .actions { display: flex; gap: 16px; justify-content: center; }
|
||||
|
||||
/* Script List */
|
||||
.script-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; margin-top: 24px; }
|
||||
.script-card:hover { border-color: #e94560; transform: translateY(-2px); transition: all 0.2s; }
|
||||
.script-card h3 { font-size: 18px; margin-bottom: 8px; }
|
||||
.script-card .tags { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.tag { padding: 4px 10px; border-radius: 4px; font-size: 12px; background: #0f3460; }
|
||||
.tag.difficulty { background: #e94560; }
|
||||
|
||||
/* Game Layout */
|
||||
.game-layout { display: grid; grid-template-columns: 200px 1fr 260px; height: 100vh; overflow: hidden; }
|
||||
.role-panel { background: #16213e; border-right: 1px solid #0f3460; padding: 16px; overflow-y: auto; }
|
||||
.role-panel h3 { color: #e94560; margin-bottom: 16px; font-size: 14px; }
|
||||
.role-item {
|
||||
padding: 10px; border-radius: 6px; margin-bottom: 8px; cursor: pointer;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.role-item:hover { background: #0f3460; }
|
||||
.role-item .avatar {
|
||||
width: 36px; height: 36px; border-radius: 50%; background: #0f3460;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 16px;
|
||||
}
|
||||
.role-item .status-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.role-item .status-dot.active { background: #4caf50; }
|
||||
.role-item .status-dot.thinking { background: #ff9800; }
|
||||
|
||||
.chat-area { display: flex; flex-direction: column; height: 100vh; }
|
||||
.chat-header {
|
||||
padding: 12px 20px; background: #16213e; border-bottom: 1px solid #0f3460;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.chat-header .phase-name { color: #e94560; font-weight: bold; }
|
||||
.chat-messages { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.message {
|
||||
max-width: 80%; padding: 12px 16px; border-radius: 10px;
|
||||
line-height: 1.5; font-size: 14px;
|
||||
}
|
||||
.message.system { background: #0f3460; color: #ff9800; align-self: center; max-width: 90%; text-align: center; }
|
||||
.message.dm { background: #1a1a2e; border: 1px solid #e94560; align-self: flex-start; }
|
||||
.message.npc { background: #16213e; border: 1px solid #0f3460; align-self: flex-start; }
|
||||
.message.player { background: #e94560; color: #fff; align-self: flex-end; }
|
||||
.message .sender { font-size: 12px; opacity: 0.7; margin-bottom: 4px; }
|
||||
.chat-input {
|
||||
padding: 16px 20px; background: #16213e; border-top: 1px solid #0f3460;
|
||||
display: flex; gap: 12px;
|
||||
}
|
||||
.chat-input input { flex: 1; }
|
||||
|
||||
.right-panel { background: #16213e; border-left: 1px solid #0f3460; padding: 16px; overflow-y: auto; }
|
||||
.right-panel h3 { color: #e94560; margin-bottom: 16px; font-size: 14px; }
|
||||
.clue-card {
|
||||
background: #1a1a2e; border: 1px solid #0f3460; border-radius: 8px;
|
||||
padding: 12px; margin-bottom: 10px; cursor: pointer;
|
||||
}
|
||||
.clue-card:hover { border-color: #e94560; }
|
||||
.clue-card .clue-name { font-weight: bold; margin-bottom: 4px; }
|
||||
.clue-card .clue-desc { font-size: 13px; color: #aaa; }
|
||||
|
||||
/* Vote Modal */
|
||||
.modal-overlay {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
.modal { background: #16213e; border-radius: 12px; padding: 32px; width: 480px; border: 1px solid #0f3460; }
|
||||
.modal h2 { margin-bottom: 20px; color: #e94560; }
|
||||
.vote-option {
|
||||
padding: 10px 16px; border: 1px solid #0f3460; border-radius: 8px;
|
||||
margin-bottom: 8px; cursor: pointer; display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.vote-option:hover, .vote-option.selected { border-color: #e94560; background: #1a1a2e; }
|
||||
.modal-actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px; }
|
||||
|
||||
/* Replay */
|
||||
.replay-page { max-width: 800px; margin: 0 auto; padding: 24px; }
|
||||
.replay-page h1 { color: #e94560; margin-bottom: 24px; }
|
||||
.replay-truth { background: #16213e; border-radius: 10px; padding: 24px; margin-bottom: 24px; border: 1px solid #e94560; }
|
||||
.replay-truth h2 { color: #e94560; margin-bottom: 12px; }
|
||||
.replay-messages { display: flex; flex-direction: column; gap: 8px; }
|
||||
.replay-msg { padding: 8px 12px; border-radius: 6px; background: #16213e; font-size: 14px; }
|
||||
|
||||
nav {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 16px 24px; background: #16213e; border-bottom: 1px solid #0f3460;
|
||||
}
|
||||
nav .logo { font-size: 20px; font-weight: bold; color: #e94560; }
|
||||
nav .nav-links { display: flex; gap: 20px; align-items: center; }
|
||||
nav .nav-links a, nav .nav-links button { color: #e0e0e0; background: none; padding: 8px 12px; }
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #1a1a2e; }
|
||||
::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user