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,3 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1339
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': set this to true or false
|
||||
esbuild: set this to true or false
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()}`
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// 样式在 global.scss 的 .theme-toggle 中统一维护
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: '🛡️',
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user