Initial commit: 网页联机狼人杀(FastAPI+WebSocket 后端 / React+TS+Sass 前端)
This commit is contained in:
commit
7f5ea6bee8
55 files changed
+6999
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user