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

+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
*.local
+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>狼人杀 · Werewolf</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": "werewolf-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.2"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"sass": "^1.79.5",
"typescript": "^5.6.3",
"vite": "^5.4.8"
}
}
+1339
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
'@parcel/watcher': set this to true or false
esbuild: set this to true or false
+14
View File
@@ -0,0 +1,14 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import Lobby from './pages/Lobby'
import Room from './pages/Room'
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Lobby />} />
<Route path="/room/:code" element={<Room />} />
</Routes>
</BrowserRouter>
)
}
+60
View File
@@ -0,0 +1,60 @@
// 带自动重连的 WebSocket 客户端封装
export class WsClient {
private ws: WebSocket | null = null
private url: string
private closedByUser = false
private retryTimer: number | null = null
onMessage: (data: any) => void = () => {}
onClose: (code: number) => void = () => {}
onStateChange: (connected: boolean) => void = () => {}
constructor(url: string) {
this.url = url
}
connect() {
this.closedByUser = false
const ws = new WebSocket(this.url)
this.ws = ws
ws.onopen = () => this.onStateChange(true)
ws.onmessage = (ev) => {
try {
this.onMessage(JSON.parse(ev.data))
} catch {
/* 忽略非 JSON 消息 */
}
}
ws.onclose = (ev) => {
this.onStateChange(false)
this.ws = null
this.onClose(ev.code)
if (!this.closedByUser && ev.code !== 4404 && ev.code !== 4403) {
this.retryTimer = window.setTimeout(() => this.connect(), 2000)
}
}
ws.onerror = () => ws.close()
}
send(data: unknown): boolean {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data))
return true
}
return false
}
close() {
this.closedByUser = true
if (this.retryTimer) window.clearTimeout(this.retryTimer)
if (this.ws) this.ws.close()
this.ws = null
}
}
export function wsUrl(code: string, name: string, token: string | null): string {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const params = new URLSearchParams({ name })
if (token) params.set('token', token)
return `${proto}://${location.host}/ws/${code}?${params.toString()}`
}
+165
View File
@@ -0,0 +1,165 @@
@use '../styles/variables' as *;
.action-panel {
flex-shrink: 0;
background: $panel;
border: 1px solid $border;
border-radius: $radius;
padding: 12px 16px;
position: relative;
overflow: hidden;
}
.timer-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--border);
.fill {
height: 100%;
background: linear-gradient(90deg, var(--gold), var(--accent));
transition: width 0.5s linear;
&.urgent {
background: var(--accent);
}
}
}
.waiting {
text-align: center;
color: $text-dim;
font-size: 14px;
padding: 10px 0;
}
.prompt {
font-size: 14px;
color: $text;
margin-bottom: 10px;
font-weight: 600;
}
.prompt-row,
.speech-box,
.pick-box {
animation: fade-up 0.2s ease;
}
.btns {
display: flex;
gap: 10px;
}
/* ---------- 发言 ---------- */
.speech-row {
display: flex;
gap: 10px;
textarea {
flex: 1;
line-height: 1.5;
}
}
.tiny-hint {
margin-top: 6px;
font-size: 11px;
color: $text-faint;
text-align: right;
}
/* ---------- 选择目标 ---------- */
.seat-btns {
display: flex;
gap: 8px;
flex-wrap: wrap;
max-height: 120px;
overflow-y: auto;
}
.seat-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
min-width: 72px;
padding: 8px 10px;
border-radius: 10px;
b {
font-size: 13px;
}
span {
font-size: 11px;
color: $text-dim;
}
&.skip {
border-style: dashed;
color: $text-dim;
}
&:hover:not(:disabled) {
border-color: var(--gold);
color: var(--gold);
}
}
/* ---------- 大厅 ---------- */
.ap-lobby-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
.inline {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: $text-dim;
select {
padding: 7px 10px;
}
}
.spacer {
flex: 1;
}
}
.seat-list {
margin-top: 10px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.seat-pill {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: $text-dim;
border: 1px solid $border;
padding: 4px 10px;
border-radius: 999px;
.x {
border: none;
background: none;
color: $text-faint;
padding: 0 2px;
font-size: 11px;
&:hover {
color: $accent;
}
}
}
+219
View File
@@ -0,0 +1,219 @@
import { useState } from 'react'
import type { PendingView, Snapshot } from '../types'
import './ActionPanel.scss'
interface Props {
snap: Snapshot
pending: PendingView | null
isMyTurn: boolean
onAction: (key: string, target: number | null) => void
onSpeech: (text: string) => void
onControl: (op: string, extra?: Record<string, unknown>) => void
}
const SKIP_LABELS: Record<string, string> = {
vote: '弃票',
kill: '空刀',
protect: '空守',
witch_save: '不使用解药',
witch_poison: '不使用毒药',
shoot: '不开枪',
badge: '撕毁警徽',
}
function totalSeconds(snap: Snapshot, key: string): number {
const s = snap.settings
if (key === 'speech') return s.speech_seconds
if (['vote', 'sheriff_vote', 'elect', 'shoot', 'badge'].includes(key)) return s.vote_seconds
return s.night_seconds
}
export default function ActionPanel(props: Props) {
const { snap } = props
if (snap.phase === 'game_over') return null
if (snap.phase === 'lobby') return <LobbyControls {...props} />
return <GameControls {...props} />
}
/* ============================ 大厅控制 ============================ */
function LobbyControls({ snap, onControl }: Props) {
const isHost = snap.me?.is_host
const occupied = snap.players.filter((p) => !p.empty)
const canStart = occupied.length === snap.seat_count
if (!isHost) {
return (
<footer className="action-panel">
<div className="waiting">
<span className="spinner" /> 等待房主操作…({occupied.length}/{snap.seat_count} 人已入座)
</div>
</footer>
)
}
return (
<footer className="action-panel ap-lobby">
<div className="ap-lobby-row">
<button onClick={() => onControl('add_ai')}>+ 添加 AI</button>
<button onClick={() => onControl('fill_ai')}>⚡ 一键补满 AI</button>
<label className="inline">
胜负规则
<select
value={snap.settings.win_rule}
onChange={(e) =>
onControl('update_settings', { settings: { win_rule: e.target.value } })
}
>
<option value="bian">屠边(杀光神职或村民)</option>
<option value="cheng">屠城(狼数 ≥ 好人数)</option>
</select>
</label>
<div className="spacer" />
<button
className="primary"
disabled={!canStart}
title={canStart ? '' : '座位未满'}
onClick={() => onControl('start')}
>
🎮 开始游戏
</button>
</div>
<div className="seat-list">
{occupied.map((p) => (
<span key={p.seat} className="seat-pill">
{p.seat}号 {p.name}
{!p.is_ai && p.seat !== snap.host_seat && (
<button
className="x"
title="移除"
onClick={() => onControl('remove', { seat: p.seat })}
>
✕
</button>
)}
{p.is_ai && (
<button className="x" title="移除" onClick={() => onControl('remove', { seat: p.seat })}>
✕
</button>
)}
</span>
))}
</div>
</footer>
)
}
/* ============================ 对局控制 ============================ */
function GameControls({ snap, pending, isMyTurn, onAction, onSpeech }: Props) {
const [text, setText] = useState('')
const me = snap.me
let body: JSX.Element
if (!pending) {
const hint = snap.spectator
? ' 👁️ 观战中,紧张的对局正在进行…'
: me && !me.alive
? ' 💀 你已出局,正在观战。'
: ' ⏳ 等待其他玩家行动…'
body = <div className="waiting">{hint}</div>
} else if (pending.input_text) {
const isElection = pending.purpose === 'election'
body = (
<div className="speech-box">
<div className="prompt">
🎤 {pending.text}
{isElection ? '(竞选发言)' : ''}
</div>
<div className="speech-row">
<textarea
value={text}
maxLength={300}
rows={2}
placeholder="输入你的发言…(像真人一样说话)"
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
if (text.trim()) {
onSpeech(text)
setText('')
}
}
}}
/>
<button
className="primary"
disabled={!text.trim()}
onClick={() => {
onSpeech(text)
setText('')
}}
>
发言
</button>
</div>
<div className="tiny-hint">Ctrl+Enter 发送 · {text.length}/300</div>
</div>
)
} else if (pending.key === 'elect') {
body = (
<div className="prompt-row">
<div className="prompt">{pending.text}</div>
<div className="btns">
<button className="gold" onClick={() => onAction('elect', 1)}>
🙋 我要上警
</button>
<button onClick={() => onAction('elect', null)}>不参与</button>
</div>
</div>
)
} else {
const skipLabel = pending.allow_skip ? SKIP_LABELS[pending.key] : null
const optionLabel = (seat: number): string => {
if (pending.key === 'witch_save') return `救 ${seat}号`
if (pending.key === 'witch_poison') return `毒 ${seat}号`
if (pending.key === 'kill') return `刀 ${seat}号`
return `${seat}号`
}
body = (
<div className="pick-box">
<div className="prompt">{pending.text}</div>
<div className="seat-btns">
{pending.options.map((seat) => {
const p = snap.players.find((x) => x.seat === seat)
return (
<button key={seat} className="seat-btn" onClick={() => onAction(pending.key, seat)}>
<b>{optionLabel(seat)}</b>
<span>{p?.name}</span>
</button>
)
})}
{skipLabel && (
<button className="seat-btn skip" onClick={() => onAction(pending.key, null)}>
<b>{skipLabel}</b>
</button>
)}
</div>
</div>
)
}
const pct = pending
? Math.max(0, Math.min(100, (pending.seconds_left / totalSeconds(snap, pending.key)) * 100))
: 0
return (
<footer className="action-panel">
{isMyTurn && (
<div className="timer-bar">
<div
className={`fill ${pending && pending.seconds_left <= 10 ? 'urgent' : ''}`}
style={{ width: `${pct}%` }}
/>
</div>
)}
{body}
</footer>
)
}
+81
View File
@@ -0,0 +1,81 @@
@use '../styles/variables' as *;
.game-log {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.log-title {
padding: 10px 14px;
font-size: 13px;
color: $text-dim;
border-bottom: 1px solid $border;
flex-shrink: 0;
}
.log-body {
flex: 1;
overflow-y: auto;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 7px;
}
.entry {
font-size: 13px;
line-height: 1.55;
color: $text-dim;
word-break: break-all;
.lock {
margin-right: 4px;
font-size: 11px;
}
&.tag-phase {
text-align: center;
color: $gold;
font-weight: 700;
letter-spacing: 2px;
margin-top: 6px;
}
&.tag-speech {
color: $text;
.text {
background: $hover;
border-left: 2px solid var(--gold);
display: block;
padding: 6px 10px;
border-radius: 0 8px 8px 0;
}
}
&.tag-night {
color: $night;
}
&.tag-vote {
color: $purple;
}
&.private {
color: $purple;
background: $purple-soft;
border-radius: 8px;
padding: 6px 10px;
white-space: pre-wrap;
}
&.tag-identity {
white-space: pre-wrap;
background: $gold-soft;
border-radius: 8px;
padding: 8px 10px;
color: $text;
}
}
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useRef } from 'react'
import type { LogEntry } from '../types'
import './GameLog.scss'
export default function GameLog({ log }: { log: LogEntry[] }) {
const boxRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = boxRef.current
if (el) el.scrollTop = el.scrollHeight
}, [log])
return (
<div className="game-log panel">
<div className="log-title">📜 对局记录</div>
<div className="log-body" ref={boxRef}>
{log.map((e, i) => (
<div key={i} className={`entry tag-${e.tag} ${e.kind === 'private' ? 'private' : ''}`}>
{e.kind === 'private' && <span className="lock">🔒</span>}
<span className="text">{e.text}</span>
</div>
))}
</div>
</div>
)
}
+106
View File
@@ -0,0 +1,106 @@
@use '../styles/variables' as *;
.game-over {
position: fixed;
inset: 0;
background: $overlay;
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fade-up 0.3s ease;
}
.dialog {
width: min(680px, 92vw);
max-height: 88vh;
overflow-y: auto;
background: $dialog-bg;
border: 1px solid $border;
border-radius: 18px;
padding: 28px;
text-align: center;
box-shadow: $shadow;
}
.verdict {
font-size: 26px;
font-weight: 800;
letter-spacing: 2px;
&.wolf {
color: $accent;
text-shadow: 0 0 24px $glow-accent;
}
&.good {
color: $green;
text-shadow: 0 0 24px var(--green-soft);
}
}
.sub {
margin-top: 8px;
color: $text-dim;
font-size: 13px;
}
.reveal-grid {
margin-top: 20px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.reveal {
display: flex;
align-items: center;
gap: 8px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 10px;
font-size: 13px;
.r-emoji {
font-size: 18px;
}
.r-name {
color: var(--text);
white-space: nowrap;
}
.r-role {
margin-left: auto;
font-size: 11px;
color: $text-dim;
&.werewolf {
color: $accent;
}
&.seer,
&.witch,
&.hunter,
&.guard {
color: $gold;
}
}
&.dead {
opacity: 0.55;
}
}
.btns {
margin-top: 22px;
display: flex;
justify-content: center;
gap: 12px;
a {
text-decoration: none;
}
}
+48
View File
@@ -0,0 +1,48 @@
import type { Snapshot } from '../types'
import { ROLE_EMOJI } from '../types'
import './GameOver.scss'
interface Props {
snap: Snapshot
isHost?: boolean
onReset: () => void
}
export default function GameOver({ snap, isHost, onReset }: Props) {
const wolfWin = snap.winner === 'wolf'
return (
<div className="game-over">
<div className="dialog">
<div className={`verdict ${wolfWin ? 'wolf' : 'good'}`}>
{wolfWin ? '🐺 狼人阵营胜利' : '🎉 好人阵营胜利'}
</div>
{snap.winner_text && <p className="sub">{snap.winner_text}</p>}
<div className="reveal-grid">
{snap.players.map((p) => (
<div key={p.seat} className={`reveal ${p.alive ? '' : 'dead'}`}>
<span className="r-emoji">
{p.alive ? ROLE_EMOJI[p.role_key || ''] || '🧑' : '💀'}
</span>
<span className="r-name">
{p.seat}号 {p.name}
</span>
<span className={`r-role ${p.role_key}`}>{p.role_name}</span>
</div>
))}
</div>
<div className="btns">
{isHost && (
<button className="primary" onClick={onReset}>
🔄 回到房间(再来一局)
</button>
)}
<a href="/">
<button className="ghost">返回大厅</button>
</a>
</div>
</div>
</div>
)
}
+134
View File
@@ -0,0 +1,134 @@
@use '../styles/variables' as *;
.info-panel {
padding: 14px;
flex-shrink: 0;
}
.spectate {
text-align: center;
font-size: 15px;
color: $night;
padding: 6px 0;
span {
font-size: 20px;
}
.note {
margin-top: 6px;
font-size: 12px;
color: $text-faint;
}
}
.role-card {
display: flex;
gap: 12px;
align-items: flex-start;
.role-emoji {
font-size: 34px;
filter: drop-shadow(0 0 10px $glow-gold);
}
.role-name {
font-size: 17px;
font-weight: 700;
color: $text;
.camp {
margin-left: 8px;
font-size: 11px;
font-weight: 400;
color: $text-dim;
border: 1px solid $border;
padding: 1px 7px;
border-radius: 999px;
vertical-align: 2px;
}
}
.role-desc {
margin-top: 4px;
font-size: 12px;
color: $text-dim;
line-height: 1.5;
}
&.waiting {
opacity: 0.75;
}
}
.meta-row {
display: flex;
gap: 6px;
margin-top: 10px;
flex-wrap: wrap;
}
.sec {
margin-top: 12px;
.sec-title {
font-size: 12px;
color: $text-dim;
margin-bottom: 6px;
}
}
.chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.chip {
font-size: 12px;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid $border;
color: $text-dim;
&.host-chip {
color: $gold;
border-color: $gold;
}
&.sheriff-chip {
color: $gold;
border-color: $gold;
background: $gold-soft;
}
&.seat-chip {
color: $text;
}
&.dead-chip {
color: $accent;
border-color: $accent;
}
&.mate-chip {
color: $accent;
border-color: var(--accent);
background: $accent-soft;
}
&.good {
color: $green;
border-color: var(--green);
}
&.bad {
color: $accent;
border-color: var(--accent);
}
&.used {
opacity: 0.45;
text-decoration: line-through;
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { Snapshot } from '../types'
import { ROLE_EMOJI } from '../types'
import './InfoPanel.scss'
const CAMP_NAME: Record<string, string> = {
wolf: '狼人阵营',
god: '神职',
civilian: '村民',
}
export default function InfoPanel({ snap }: { snap: Snapshot }) {
const me = snap.me
return (
<div className="info-panel panel">
{snap.spectator || !me ? (
<div className="spectate">
<span>👁️</span> 观战模式
{snap.spectator_note && <p className="note">{snap.spectator_note}</p>}
</div>
) : me.role_key ? (
<>
<div className="role-card">
<span className="role-emoji">{me.role_emoji || ROLE_EMOJI[me.role_key]}</span>
<div>
<div className="role-name">
{me.role_name}
<span className="camp">{CAMP_NAME[me.role_camp || ''] || ''}</span>
</div>
<div className="role-desc">{me.role_desc}</div>
</div>
</div>
<div className="meta-row">
<span className="chip seat-chip">我是 {me.seat} 号</span>
{me.is_host && <span className="chip host-chip">房主</span>}
{snap.sheriff_seat === me.seat && <span className="chip sheriff-chip">⭐ 警长</span>}
{!me.alive && <span className="chip dead-chip">💀 已出局</span>}
</div>
{me.teammates && me.teammates.length > 0 && (
<div className="sec">
<div className="sec-title">🐺 狼队友</div>
<div className="chips">
{me.teammates.map((s) => (
<span key={s} className="chip mate-chip">
{s}号
</span>
))}
</div>
</div>
)}
{me.seer_results && me.seer_results.length > 0 && (
<div className="sec">
<div className="sec-title">🔮 查验记录</div>
<div className="chips">
{me.seer_results.map((r) => (
<span key={r.seat} className={`chip ${r.result === '狼人' ? 'bad' : 'good'}`}>
{r.seat}号 · {r.result}
</span>
))}
</div>
</div>
)}
{me.potions && (
<div className="sec">
<div className="sec-title">🧪 药水</div>
<div className="chips">
<span className={`chip ${me.potions.antidote ? 'good' : 'used'}`}>
解药 {me.potions.antidote ? '✓' : '已用'}
</span>
<span className={`chip ${me.potions.poison ? 'bad' : 'used'}`}>
毒药 {me.potions.poison ? '✓' : '已用'}
</span>
</div>
</div>
)}
</>
) : (
<div className="role-card waiting">
<span className="role-emoji">🎭</span>
<div>
<div className="role-name">身份待分发</div>
<div className="role-desc">等待房主开始游戏</div>
</div>
</div>
)}
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
@use '../styles/variables' as *;
.phase-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 18px;
border-radius: 999px;
border: 1px solid $border;
font-size: 15px;
font-weight: 600;
letter-spacing: 1px;
color: $text;
background: $panel;
&.night {
background: $night-soft;
border-color: var(--night);
}
&.day {
background: $gold-soft;
border-color: var(--gold);
}
.day-chip {
font-size: 12px;
font-weight: 500;
color: $text-dim;
border: 1px solid $border;
padding: 2px 9px;
border-radius: 999px;
}
.countdown {
font-variant-numeric: tabular-nums;
font-size: 14px;
color: $gold;
min-width: 36px;
text-align: right;
}
&.urgent {
border-color: $accent;
animation: pulse-ring 1s ease-out infinite;
.countdown {
color: $accent;
}
}
}
+23
View File
@@ -0,0 +1,23 @@
import type { Snapshot } from '../types'
import './PhaseBanner.scss'
export default function PhaseBanner({ snap }: { snap: Snapshot }) {
const { phase, phase_text, day, pending } = snap
const isNight = phase === 'night'
const urgent = !!pending && pending.seconds_left <= 10 && pending.seconds_left > 0
return (
<div className={`phase-banner ${isNight ? 'night' : 'day'} ${urgent ? 'urgent' : ''}`}>
{day > 0 && phase !== 'game_over' && (
<span className="day-chip">{day > 0 ? `第${day}天` : ''}</span>
)}
<span className="phase-name">{phase_text}</span>
{pending && !pending.input_text && (
<span className="countdown">{pending.seconds_left}s</span>
)}
{pending && pending.input_text && (
<span className="countdown">{pending.seconds_left}s</span>
)}
</div>
)
}
+203
View File
@@ -0,0 +1,203 @@
@use '../styles/variables' as *;
.seat-circle {
position: absolute;
inset: 0;
}
.center-info {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
text-align: center;
width: 220px;
pointer-events: none;
.phase-icon {
font-size: 42px;
margin-bottom: 6px;
&.moon {
filter: drop-shadow(0 0 22px $glow-night);
}
&.sun {
filter: drop-shadow(0 0 22px $glow-gold);
}
}
.center-text {
font-size: 17px;
font-weight: 600;
letter-spacing: 1px;
color: $text;
}
.sheriff-line {
margin-top: 8px;
font-size: 13px;
color: $gold;
}
}
.seat {
position: absolute;
transform: translate(-50%, -50%);
width: 108px;
text-align: center;
cursor: default;
user-select: none;
.avatar {
width: 62px;
height: 62px;
margin: 0 auto;
border-radius: 50%;
background: $avatar-grad;
border: 2px solid $border-strong;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
transition: all 0.2s ease;
}
.seat-name {
margin-top: 6px;
font-size: 12px;
color: $text-dim;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.role-tag {
margin-top: 3px;
font-size: 11px;
color: $text-faint;
white-space: nowrap;
&.werewolf {
color: $accent;
}
&.seer,
&.witch,
&.hunter,
&.guard {
color: $gold;
}
}
&.me .avatar {
border-color: $gold;
box-shadow: 0 0 12px $glow-gold;
}
&.me .seat-name {
color: $gold;
}
&.speaking .avatar {
border-color: $accent;
animation: pulse-ring 1.2s ease-out infinite;
}
&.dead .avatar {
filter: grayscale(1);
opacity: 0.45;
border-style: dashed;
}
&.dead .seat-name {
text-decoration: line-through;
opacity: 0.5;
}
&.offline .avatar {
border-style: dashed;
border-color: $accent;
}
&.empty .avatar {
opacity: 0.35;
border-style: dotted;
font-size: 24px;
}
&.pickable {
cursor: pointer;
.avatar {
border-color: $green;
box-shadow: 0 0 0 4px $green-soft;
transform: scale(1.08);
}
&:hover .avatar {
border-color: $gold;
box-shadow: 0 0 0 6px $glow-gold;
transform: scale(1.15);
}
.seat-name {
color: $green;
}
}
.badge {
position: absolute;
top: -6px;
font-size: 11px;
padding: 1px 7px;
border-radius: 999px;
white-space: nowrap;
z-index: 2;
}
.sheriff-badge {
left: 8px;
background: $gold-soft;
border: 1px solid $gold;
}
.cand-badge {
right: -4px;
background: $night-soft;
border: 1px solid $night;
color: $night;
}
.vote-badge {
right: -4px;
bottom: 26px;
background: $accent-soft;
border: 1px solid $accent;
color: $accent;
}
}
@media (max-width: 980px) {
.seat {
width: 84px;
.avatar {
width: 48px;
height: 48px;
font-size: 24px;
}
}
.center-info {
width: 150px;
.phase-icon {
font-size: 30px;
}
.center-text {
font-size: 14px;
}
}
}
+109
View File
@@ -0,0 +1,109 @@
import type { PlayerView, Snapshot } from '../types'
import { ROLE_EMOJI } from '../types'
import './SeatCircle.scss'
interface Props {
snap: Snapshot
pickable: Set<number>
onPick: (seat: number) => void
}
export default function SeatCircle({ snap, pickable, onPick }: Props) {
const radius = 40
const players = snap.players
const voteCount = (seat: number): number => {
if (snap.phase !== 'vote' && snap.phase !== 'sheriff_elect') return 0
return snap.last_votes.filter((v) => v.target === seat).length
}
return (
<div className="seat-circle">
<div className="center-info">
<div className={`phase-icon ${snap.phase === 'night' ? 'moon' : 'sun'}`}>
{snap.phase === 'night' ? '🌙' : snap.phase === 'game_over' ? '🏁' : '☀️'}
</div>
<div className="center-text">{snap.phase_text}</div>
{snap.sheriff_seat != null && (
<div className="sheriff-line">⭐ 警长:{snap.sheriff_seat}号</div>
)}
</div>
{players.map((p) => (
<SeatCard
key={p.seat}
p={p}
angle={(p.seat / players.length) * 2 * Math.PI - Math.PI / 2}
radiusX={radius}
radiusY={radius}
snap={snap}
pickable={pickable.has(p.seat)}
votes={voteCount(p.seat)}
onPick={onPick}
/>
))}
</div>
)
}
interface SeatProps {
p: PlayerView
angle: number
radiusX: number
radiusY: number
snap: Snapshot
pickable: boolean
votes: number
onPick: (seat: number) => void
}
function SeatCard({ p, angle, radiusX, radiusY, snap, pickable, votes, onPick }: SeatProps) {
// 用椭圆半径铺满舞台,组件内再换算成 px 由 CSS 缩放
const x = 50 + radiusX * Math.cos(angle)
const y = 50 + radiusY * Math.sin(angle)
const isMe = p.is_me
const dead = !p.empty && !p.alive
const myRoleVisible = !!p.role_name
const emoji = p.empty
? '➕'
: dead
? '💀'
: p.is_ai
? '🤖'
: '🧑'
const mate = snap.me?.teammates?.includes(p.seat)
return (
<div
className={[
'seat',
p.empty ? 'empty' : '',
dead ? 'dead' : '',
isMe ? 'me' : '',
p.speaking ? 'speaking' : '',
p.sheriff ? 'sheriff' : '',
pickable ? 'pickable' : '',
p.connected ? '' : 'offline',
].join(' ')}
style={{ left: `${x}%`, top: `${y}%` }}
onClick={pickable ? () => onPick(p.seat) : undefined}
title={pickable ? `选择 ${p.seat} 号` : undefined}
>
{p.sheriff && <span className="badge sheriff-badge">⭐</span>}
{p.candidate && <span className="badge cand-badge">上警</span>}
{votes > 0 && <span className="badge vote-badge">{votes}票</span>}
<div className="avatar">{emoji}</div>
<div className="seat-name">
{p.empty ? '空位' : `${p.seat}号 ${p.name}`}
</div>
{myRoleVisible && (
<div className={`role-tag ${p.role_key}`}>
{ROLE_EMOJI[p.role_key || ''] || ''} {p.role_name}
{mate ? ' ·队友' : ''}
</div>
)}
</div>
)
}
+1
View File
@@ -0,0 +1 @@
// 样式在 global.scss 的 .theme-toggle 中统一维护
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react'
import './ThemeToggle.scss'
export type Theme = 'light' | 'dark'
/** 在 React 渲染前调用,避免主题闪烁 */
export function initTheme(): Theme {
const saved = localStorage.getItem('ww_theme') as Theme | null
const theme: Theme = saved === 'dark' ? 'dark' : 'light'
document.documentElement.dataset.theme = theme
return theme
}
export default function ThemeToggle() {
const [theme, setTheme] = useState<Theme>(
() => (document.documentElement.dataset.theme as Theme) || initTheme(),
)
useEffect(() => {
document.documentElement.dataset.theme = theme
localStorage.setItem('ww_theme', theme)
}, [theme])
return (
<button
className="theme-toggle"
title={theme === 'light' ? '切换到暗色模式' : '切换到亮色模式'}
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
>
{theme === 'light' ? '🌙' : '☀️'}
</button>
)
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { initTheme } from './components/ThemeToggle'
import './styles/global.scss'
initTheme()
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+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>
)
}
+44
View File
@@ -0,0 +1,44 @@
// 主题变量:颜色全部走 CSS 自定义属性(定义见 global.scss 的 :root 与 [data-theme]),
// 因此亮/暗切换即时生效;组件继续沿用这些 Sass 变量名即可。
// 亮色为默认主题(清爽浅色:白底 + 靛蓝主色 + 琥珀警徽金)。
// ---- 不随主题变化的常量 ----
$radius: 12px;
$radius-sm: 8px;
$font: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
// ---- 语义色引用 ----
$bg-glow-a: var(--bg-glow-a);
$bg-glow-b: var(--bg-glow-b);
$panel: var(--panel);
$panel-strong: var(--panel-strong);
$hover: var(--hover);
$border: var(--border);
$border-strong: var(--border-strong);
$input-bg: var(--input-bg);
$text: var(--text);
$text-dim: var(--text-dim);
$text-faint: var(--text-faint);
$primary: var(--primary);
$primary-deep: var(--primary-deep);
$primary-soft: var(--primary-soft);
$accent: var(--accent);
$accent-soft: var(--accent-soft);
$gold: var(--gold);
$gold-deep: var(--gold-deep);
$gold-soft: var(--gold-soft);
$night: var(--night);
$night-soft: var(--night-soft);
$green: var(--green);
$green-soft: var(--green-soft);
$purple: var(--purple);
$purple-soft: var(--purple-soft);
$overlay: var(--overlay);
$dialog-bg: var(--dialog-bg);
$shadow: var(--shadow);
$toast-bg: var(--toast-bg);
$logo-grad: var(--logo-grad);
$avatar-grad: var(--avatar-grad);
$glow-gold: var(--glow-gold);
$glow-night: var(--glow-night);
$glow-accent: var(--glow-accent);
+267
View File
@@ -0,0 +1,267 @@
@use './variables' as *;
/* ================= 主题定义 ================= */
:root {
color-scheme: light;
--bg-glow-a: rgba(91, 108, 240, 0.08);
--bg-glow-b: rgba(217, 153, 46, 0.08);
--panel: rgba(255, 255, 255, 0.72);
--panel-strong: #ffffff;
--hover: rgba(35, 48, 80, 0.06);
--border: rgba(35, 48, 80, 0.12);
--border-strong: rgba(35, 48, 80, 0.24);
--input-bg: #ffffff;
--text: #252b3b;
--text-dim: #5a6376;
--text-faint: #9aa2b5;
--primary: #5566ee;
--primary-deep: #3f50d8;
--primary-soft: rgba(85, 102, 238, 0.1);
--accent: #d9534e;
--accent-soft: rgba(217, 83, 78, 0.1);
--gold: #c98f1f;
--gold-deep: #a87614;
--gold-soft: rgba(201, 143, 31, 0.12);
--night: #4a63d2;
--night-soft: rgba(74, 99, 210, 0.1);
--green: #27995f;
--green-soft: rgba(39, 153, 95, 0.12);
--purple: #7d4fd0;
--purple-soft: rgba(125, 79, 208, 0.1);
--overlay: rgba(240, 242, 248, 0.75);
--dialog-bg: linear-gradient(170deg, #ffffff, #f2f4fa);
--shadow: 0 12px 36px rgba(35, 48, 80, 0.12);
--toast-bg: rgba(255, 255, 255, 0.96);
--logo-grad: linear-gradient(135deg, #4353d8, #8a5fd6);
--avatar-grad: linear-gradient(160deg, #ffffff, #eef1f8);
--glow-gold: rgba(201, 143, 31, 0.35);
--glow-night: rgba(74, 99, 210, 0.3);
--glow-accent: rgba(217, 83, 78, 0.3);
}
html[data-theme='dark'] {
color-scheme: dark;
--bg-glow-a: rgba(99, 116, 240, 0.1);
--bg-glow-b: rgba(212, 169, 78, 0.06);
--panel: rgba(255, 255, 255, 0.045);
--panel-strong: rgba(255, 255, 255, 0.08);
--hover: rgba(255, 255, 255, 0.09);
--border: rgba(255, 255, 255, 0.09);
--border-strong: rgba(255, 255, 255, 0.18);
--input-bg: rgba(0, 0, 0, 0.3);
--text: #e7eaf3;
--text-dim: #9aa2b8;
--text-faint: #6d7488;
--primary: #6b7cf5;
--primary-deep: #4c5de0;
--primary-soft: rgba(107, 124, 245, 0.14);
--accent: #e0615c;
--accent-soft: rgba(224, 97, 92, 0.16);
--gold: #d4a94e;
--gold-deep: #b8873a;
--gold-soft: rgba(212, 169, 78, 0.14);
--night: #6f88e8;
--night-soft: rgba(111, 136, 232, 0.14);
--green: #4fc07a;
--green-soft: rgba(79, 192, 122, 0.14);
--purple: #a678e0;
--purple-soft: rgba(166, 120, 224, 0.14);
--overlay: rgba(10, 12, 20, 0.78);
--dialog-bg: linear-gradient(170deg, #232839, #171b28);
--shadow: 0 10px 34px rgba(0, 0, 0, 0.4);
--toast-bg: rgba(30, 34, 48, 0.95);
--logo-grad: linear-gradient(135deg, #aebbe8, #d4a94e);
--avatar-grad: linear-gradient(160deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.04));
--glow-gold: rgba(212, 169, 78, 0.45);
--glow-night: rgba(111, 136, 232, 0.4);
--glow-accent: rgba(224, 97, 92, 0.4);
}
/* ================= 基础 ================= */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
font-family: $font;
color: $text;
background:
radial-gradient(1100px 600px at 80% -10%, $bg-glow-a, transparent 60%),
radial-gradient(900px 520px at 10% 110%, $bg-glow-b, transparent 55%),
linear-gradient(160deg, var(--app-bg-a, #f7f8fc), var(--app-bg-b, #eef1f7) 70%);
background-attachment: fixed;
-webkit-font-smoothing: antialiased;
}
html[data-theme='dark'] body {
background:
radial-gradient(1100px 600px at 80% -10%, $bg-glow-a, transparent 60%),
radial-gradient(900px 520px at 10% 110%, $bg-glow-b, transparent 55%),
linear-gradient(160deg, #1a1e2b, #12141d 70%);
}
button {
font-family: inherit;
cursor: pointer;
border: 1px solid $border-strong;
background: $panel-strong;
color: $text;
border-radius: $radius-sm;
padding: 8px 16px;
font-size: 14px;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background: $hover;
border-color: var(--border-strong);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&.primary {
background: linear-gradient(135deg, $primary, $primary-deep);
border-color: transparent;
color: #fff;
font-weight: 600;
&:hover:not(:disabled) {
filter: brightness(1.08);
background: linear-gradient(135deg, $primary, $primary-deep);
}
}
&.gold {
background: linear-gradient(135deg, $gold, $gold-deep);
border-color: transparent;
color: #fff;
font-weight: 600;
&:hover:not(:disabled) {
filter: brightness(1.08);
}
}
&.ghost {
background: transparent;
}
}
input,
textarea,
select {
font-family: inherit;
background: $input-bg;
border: 1px solid $border;
color: $text;
border-radius: $radius-sm;
padding: 10px 12px;
font-size: 14px;
outline: none;
transition: border-color 0.15s;
&:focus {
border-color: var(--primary);
}
&::placeholder {
color: $text-faint;
}
}
textarea {
resize: none;
}
select option {
color: initial;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
.panel {
background: $panel;
border: 1px solid $border;
border-radius: $radius;
}
/* ================= 主题切换按钮 ================= */
.theme-toggle {
width: 36px;
height: 36px;
padding: 0;
border-radius: 50%;
font-size: 17px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--border-strong);
border-top-color: $text;
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: -2px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse-ring {
0% {
box-shadow: 0 0 0 0 $glow-accent;
}
100% {
box-shadow: 0 0 0 12px rgba(0, 0, 0, 0);
}
}
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+109
View File
@@ -0,0 +1,109 @@
// 与后端快照协议对齐的类型定义
export type Phase =
| 'lobby'
| 'role_reveal'
| 'night'
| 'dawn'
| 'sheriff_elect'
| 'speech'
| 'vote'
| 'game_over'
export interface Settings {
board: string
board_name: string
win_rule: 'bian' | 'cheng'
night_seconds: number
speech_seconds: number
vote_seconds: number
}
export interface PlayerView {
seat: number
name: string | null
empty: boolean
alive: boolean
is_ai: boolean
connected: boolean
speaking: boolean
sheriff: boolean
candidate: boolean
role_key: string | null
role_name: string | null
is_me: boolean
}
export interface MeView {
seat: number
name: string
alive: boolean
is_host: boolean
is_ai: boolean
role_key?: string
role_name?: string
role_camp?: string
role_emoji?: string
role_desc?: string
teammates?: number[]
seer_results?: { seat: number; result: string }[]
potions?: { antidote: boolean; poison: boolean }
}
export interface PendingView {
key: string
options: number[]
text: string
allow_skip: boolean
input_text: boolean
purpose: string
seconds_left: number
}
export interface LogEntry {
text: string
kind: 'public' | 'private'
tag: string
}
export interface VotePair {
voter: number
target: number | null
}
export interface Snapshot {
room_code: string
phase: Phase
day: number
phase_text: string
seat_count: number
host_seat: number | null
spectator: boolean
spectator_note: string
settings: Settings
players: PlayerView[]
me: MeView | null
token: string
log: LogEntry[]
pending: PendingView | null
sheriff_seat: number | null
candidates: number[]
current_speaker: number | null
last_votes: VotePair[]
winner: 'wolf' | 'good' | null
winner_text: string | null
}
export interface BoardInfo {
key: string
name: string
seat_count: number
}
export const ROLE_EMOJI: Record<string, string> = {
werewolf: '🐺',
villager: '👨‍🌾',
seer: '🔮',
witch: '🧪',
hunter: '🏹',
guard: '🛡️',
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// 开发模式:把 /api 与 /ws 代理到 FastAPI 后端
export default defineConfig({
plugins: [react()],
css: {
preprocessorOptions: {
scss: { api: 'modern-compiler' },
},
},
server: {
port: 5173,
proxy: {
'/api': { target: 'http://127.0.0.1:8000', changeOrigin: true },
'/ws': { target: 'ws://127.0.0.1:8000', ws: true },
},
},
})