feat: add MMGame backend and frontend source code

This commit is contained in:
gmh01
2026-08-01 21:30:23 +08:00
parent 26ce0dd600
commit c176c04aab
42 changed files with 3606 additions and 0 deletions
+12
View File
@@ -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>
+24
View File
@@ -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"
}
}
+28
View File
@@ -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
+30
View File
@@ -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>
)
}
+10
View File
@@ -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>
)
+433
View File
@@ -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>
)
}
+76
View File
@@ -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>
)
}
+53
View File
@@ -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>
)
}
+72
View File
@@ -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>
)
}
+84
View File
@@ -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>
)
}
+115
View File
@@ -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>
)
}
+50
View File
@@ -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>
)
}
+61
View File
@@ -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`),
},
}
+104
View File
@@ -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 }),
}
)
)
+154
View File
@@ -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; }
+21
View File
@@ -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"]
}
+16
View File
@@ -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,
},
},
},
})