Initial commit: 网页联机狼人杀(FastAPI+WebSocket 后端 / React+TS+Sass 前端)

This commit is contained in:
hhhge committed 2026-10-03 20:47:44 +08:00
commit 7f5ea6bee8
55 files changed
+6999

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
@use '../styles/variables' as *;
.lobby {
min-height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.lobby-card {
position: relative;
width: 420px;
max-width: 100%;
background: $panel-strong;
border: 1px solid $border;
border-radius: 18px;
padding: 36px 32px;
animation: fade-up 0.35s ease;
box-shadow: $shadow;
}
.card-top {
position: absolute;
top: 14px;
right: 14px;
}
.logo {
text-align: center;
margin-bottom: 28px;
.moon {
font-size: 40px;
display: block;
margin-bottom: 4px;
filter: drop-shadow(0 0 18px $glow-gold);
}
h1 {
font-size: 34px;
letter-spacing: 10px;
text-indent: 10px;
background: $logo-grad;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.subtitle {
margin-top: 6px;
font-size: 12px;
letter-spacing: 3px;
color: $text-faint;
}
}
.field {
margin-bottom: 18px;
label {
display: block;
font-size: 13px;
color: $text-dim;
margin-bottom: 6px;
}
input,
select {
width: 100%;
}
.hint {
margin-top: 4px;
font-size: 12px;
color: $text-faint;
}
}
button.big {
width: 100%;
padding: 12px;
font-size: 16px;
}
.divider {
display: flex;
align-items: center;
gap: 12px;
color: $text-faint;
font-size: 12px;
margin: 20px 0;
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: $border;
}
}
.join-row {
display: flex;
gap: 10px;
.code-input {
flex: 1;
text-transform: uppercase;
letter-spacing: 4px;
font-weight: 600;
text-align: center;
}
}
.error {
margin-top: 14px;
color: $accent;
font-size: 13px;
text-align: center;
}
.ai-status {
margin-top: 20px;
text-align: center;
font-size: 12px;
color: $text-dim;
.ok {
color: $green;
}
.fallback {
color: $gold;
}
}
+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import type { BoardInfo } from '../types'
import ThemeToggle from '../components/ThemeToggle'
import './Lobby.scss'
export function getNickname(): string {
return localStorage.getItem('ww_name') || ''
}
export function setNickname(name: string) {
localStorage.setItem('ww_name', name)
}
export default function Lobby() {
const nav = useNavigate()
const [name, setName] = useState(getNickname())
const [boards, setBoards] = useState<BoardInfo[]>([])
const [board, setBoard] = useState('classic12')
const [joinCode, setJoinCode] = useState('')
const [health, setHealth] = useState<{ llm_configured: boolean; model: string | null } | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
fetch('/api/health').then((r) => r.json()).then(setHealth).catch(() => {})
fetch('/api/boards').then((r) => r.json()).then((b: BoardInfo[]) => {
setBoards(b)
if (b.length && !b.some((x) => x.key === board)) setBoard(b[0].key)
}).catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const ensureName = (): boolean => {
if (!name.trim()) {
setError('请先输入你的昵称')
return false
}
setNickname(name.trim())
return true
}
const createRoom = async () => {
if (!ensureName()) return
setBusy(true)
setError('')
try {
const res = await fetch('/api/rooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ board, host_name: name.trim() }),
})
const data = await res.json()
nav(`/room/${data.code}`)
} catch {
setError('创建房间失败,请确认后端已启动')
} finally {
setBusy(false)
}
}
const joinRoom = () => {
if (!ensureName()) return
const code = joinCode.trim().toUpperCase()
if (!code) {
setError('请输入房间号')
return
}
nav(`/room/${code}`)
}
const selectedBoard = boards.find((b) => b.key === board)
return (
<div className="lobby">
<div className="lobby-card">
<div className="card-top">
<ThemeToggle />
</div>
<div className="logo">
<span className="moon">🌕</span>
<h1>狼人杀</h1>
<p className="subtitle">WEREWOLVES · 人机混战 · 多人联机</p>
</div>
<div className="field">
<label>你的昵称</label>
<input
value={name}
maxLength={12}
placeholder="给自己起个名字"
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && createRoom()}
/>
</div>
<div className="field">
<label>选择板子</label>
<select value={board} onChange={(e) => setBoard(e.target.value)}>
{boards.map((b) => (
<option key={b.key} value={b.key}>
{b.name}
</option>
))}
</select>
{selectedBoard && <p className="hint">共 {selectedBoard.seat_count} 个座位</p>}
</div>
<button className="primary big" disabled={busy} onClick={createRoom}>
{busy ? '创建中…' : '创建房间'}
</button>
<div className="divider">或者</div>
<div className="join-row">
<input
className="code-input"
value={joinCode}
maxLength={4}
placeholder="房间号"
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
onKeyDown={(e) => e.key === 'Enter' && joinRoom()}
/>
<button onClick={joinRoom}>加入房间</button>
</div>
{error && <p className="error">{error}</p>}
{health && (
<p className="ai-status">
AI 玩家:
{health.llm_configured ? (
<span className="ok">大模型已接入({health.model})</span>
) : (
<span className="fallback">未配置密钥,使用内置启发式 AI</span>
)}
</p>
)}
</div>
</div>
)
}
+162
View File
@@ -0,0 +1,162 @@
@use '../styles/variables' as *;
.room {
height: 100%;
display: flex;
flex-direction: column;
padding: 12px 16px;
gap: 12px;
max-width: 1500px;
margin: 0 auto;
&.theme-night .stage {
background:
radial-gradient(600px 300px at 50% 0%, $night-soft, transparent 70%),
$panel;
}
&.theme-day .stage {
background:
radial-gradient(600px 300px at 50% 0%, $gold-soft, transparent 70%),
$panel;
}
}
.room-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
// 结算弹窗出现时顶栏仍可操作(主题切换/退出)
position: relative;
z-index: 101;
.left,
.right {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
.right {
justify-content: flex-end;
}
}
.code-chip {
padding: 6px 12px;
font-size: 14px;
user-select: none;
b {
letter-spacing: 2px;
color: $gold;
}
}
.board-chip {
font-size: 12px;
color: $text-dim;
border: 1px solid $border;
padding: 5px 10px;
border-radius: 999px;
white-space: nowrap;
background: $panel;
}
.conn {
font-size: 12px;
&.on {
color: $green;
}
&.off {
color: $accent;
}
}
.exit {
color: $text-dim;
font-size: 13px;
text-decoration: none;
border: 1px solid $border;
padding: 5px 12px;
border-radius: 999px;
&:hover {
color: $text;
border-color: $border-strong;
}
}
.room-main {
flex: 1;
display: flex;
gap: 12px;
min-height: 0;
}
.stage {
flex: 1;
min-width: 0;
position: relative;
transition: background 0.6s ease;
}
.sidebar {
width: 340px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.room-gone {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
color: $text-dim;
font-size: 16px;
}
.toasts {
position: fixed;
top: 18px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 200;
.toast {
background: $toast-bg;
border: 1px solid $border-strong;
color: $text;
padding: 9px 18px;
border-radius: 999px;
font-size: 13px;
animation: fade-up 0.2s ease;
box-shadow: $shadow;
}
}
@media (max-width: 980px) {
.room-main {
flex-direction: column;
overflow: auto;
}
.sidebar {
width: 100%;
}
.stage {
min-height: 420px;
}
}
+172
View File
@@ -0,0 +1,172 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { WsClient, wsUrl } from '../api/ws'
import type { Snapshot } from '../types'
import GameLog from '../components/GameLog'
import InfoPanel from '../components/InfoPanel'
import PhaseBanner from '../components/PhaseBanner'
import SeatCircle from '../components/SeatCircle'
import ActionPanel from '../components/ActionPanel'
import GameOver from '../components/GameOver'
import ThemeToggle from '../components/ThemeToggle'
import './Room.scss'
interface SnapState {
snap: Snapshot
receivedAt: number
}
export default function Room() {
const { code = '' } = useParams()
const [state, setState] = useState<SnapState | null>(null)
const [connected, setConnected] = useState(false)
const [gone, setGone] = useState(false)
const [toasts, setToasts] = useState<{ id: number; text: string }[]>([])
const [now, setNow] = useState(Date.now())
const clientRef = useRef<WsClient | null>(null)
const toastId = useRef(0)
const pushToast = useCallback((text: string) => {
const id = ++toastId.current
setToasts((ts) => [...ts, { id, text }])
window.setTimeout(() => setToasts((ts) => ts.filter((t) => t.id !== id)), 3200)
}, [])
useEffect(() => {
// token 存 sessionStorage:同浏览器多标签页互不干扰,刷新仍可断线重连
const token = sessionStorage.getItem(`ww_token_${code}`)
const name = localStorage.getItem('ww_name') || '玩家'
const client = new WsClient(wsUrl(code, name, token))
clientRef.current = client
client.onMessage = (data) => {
if (data.type === 'snapshot') {
const snap = data.data as Snapshot
if (snap.token) sessionStorage.setItem(`ww_token_${code}`, snap.token)
setState({ snap, receivedAt: Date.now() })
} else if (data.type === 'error') {
pushToast(data.message || '操作失败')
}
}
client.onStateChange = setConnected
client.onClose = (c) => {
if (c === 4404) setGone(true)
}
client.connect()
const timer = window.setInterval(() => setNow(Date.now()), 500)
return () => {
window.clearInterval(timer)
client.close()
}
}, [code, pushToast])
const sendAction = useCallback((key: string, target: number | null) => {
clientRef.current?.send({ type: 'action', action: key, target })
}, [])
const sendSpeech = useCallback((text: string) => {
clientRef.current?.send({ type: 'speech', text })
}, [])
const sendControl = useCallback((op: string, extra: Record<string, unknown> = {}) => {
clientRef.current?.send({ type: 'control', op, ...extra })
}, [])
if (gone) {
return (
<div className="room-gone">
<p>🌙 房间 {code} 不存在或已解散</p>
<Link to="/">
<button className="primary">返回大厅</button>
</Link>
</div>
)
}
if (!state) {
return (
<div className="room-gone">
<span className="spinner" /> 正在连接房间 {code} …
</div>
)
}
const { snap, receivedAt } = state
const secondsLeft = snap.pending
? Math.max(0, Math.ceil(snap.pending.seconds_left - (now - receivedAt) / 1000))
: 0
const pending = snap.pending ? { ...snap.pending, seconds_left: secondsLeft } : null
const isMyTurn =
!!pending && !snap.spectator && snap.me?.alive !== false
const pickable =
isMyTurn && pending && !pending.input_text && pending.key !== 'elect'
? new Set(pending.options)
: new Set<number>()
return (
<div className={`room theme-${snap.phase === 'night' ? 'night' : 'day'}`}>
<header className="room-header">
<div className="left">
<button
className="code-chip"
title="点击复制房间号"
onClick={() => {
navigator.clipboard?.writeText(snap.room_code)
pushToast('房间号已复制')
}}
>
房间 <b>{snap.room_code}</b> 📋
</button>
<span className={`conn ${connected ? 'on' : 'off'}`}>
{connected ? '已连接' : '重连中…'}
</span>
</div>
<PhaseBanner snap={snap} />
<div className="right">
<ThemeToggle />
<span className="board-chip">
{snap.settings.board_name} · {snap.settings.win_rule === 'bian' ? '屠边' : '屠城'}
</span>
<Link to="/" className="exit">
退出
</Link>
</div>
</header>
<main className="room-main">
<section className="stage panel">
<SeatCircle
snap={snap}
pickable={pickable}
onPick={(seat) => pending && sendAction(pending.key, seat)}
/>
</section>
<aside className="sidebar">
<InfoPanel snap={snap} />
<GameLog log={snap.log} />
</aside>
</main>
<ActionPanel
snap={snap}
pending={pending}
isMyTurn={isMyTurn}
onAction={sendAction}
onSpeech={sendSpeech}
onControl={sendControl}
/>
{snap.phase === 'game_over' && (
<GameOver snap={snap} isHost={snap.me?.is_host} onReset={() => sendControl('reset')} />
)}
<div className="toasts">
{toasts.map((t) => (
<div key={t.id} className="toast">
{t.text}
</div>
))}
</div>
</div>
)
}