实时对局选你的对手
加入
b64da5f
1080 行 · 87.1 KB 原始文件 永久链接 历史
1<!doctype html>
2<html lang="zh-CN">
3<head>
4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1">
6<title>狼人杀</title>
7<style>
8/* The 狼人杀 table people know from NetEase's app: numbered coin seats down both sides, a moon or sun medallion with
9 第N夜 / 第N天 on top, one status line ("【4号】发言中…… 47秒"), the judge's cards, speeches, the exile vote board and
10 the full-screen role card and victory screen; plus what watching agents needs: a god view (role tags, the night's
11 moves, each agent's reasoning in grey italics), a log and a night/day timeline. Replays play night by night; a live
12 room (?live) lets a seated person speak, vote and act. Assets and their licences: viewer/NOTICE. */
13@font-face { font-family: ShoalJudge; src: url(assets/judge.woff2) format('woff2'); font-weight: 700; font-display: swap; }
14@property --bg { syntax: '<color>'; inherits: true; initial-value: #0d1326; }
15@property --stage { syntax: '<color>'; inherits: true; initial-value: #141c38; }
16@property --card { syntax: '<color>'; inherits: true; initial-value: rgba(20, 28, 58, .86); }
17@property --ink { syntax: '<color>'; inherits: true; initial-value: #e9e7f2; }
18@property --muted { syntax: '<color>'; inherits: true; initial-value: #9097b4; }
19@property --line { syntax: '<color>'; inherits: true; initial-value: #263055; }
20@property --accent { syntax: '<color>'; inherits: true; initial-value: #f2d27a; }
21@property --sky { syntax: '<color>'; inherits: true; initial-value: #22336e; }
22@property --hill { syntax: '<color>'; inherits: true; initial-value: #0a1022; }
23@property --hill2 { syntax: '<color>'; inherits: true; initial-value: #121a3a; }
24:root {
25 --wolf: #d9473f; --seer: #8b5cf6; --doctor: #14a3a3; --villager: #2f9e6a; --red: #e0483e; --gold: #f5c242;
26 --ease: cubic-bezier(.32, .72, 0, 1); --spd: 1;
27 --font-ui: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", system-ui, sans-serif;
28 --font-judge: ShoalJudge, "Songti SC", "STSong", "Noto Serif CJK SC", "Source Han Serif SC", serif;
29 --coin: 64px; --side: 120px; --logw: 300px;
30}
31.night { --bg: #0d1326; --stage: #141c38; --card: rgba(20, 28, 58, .86); --ink: #e9e7f2; --muted: #9097b4; --line: #263055;
32 --accent: #f2d27a; --sky: #22336e; --hill: #0a1022; --hill2: #121a3a; --ring: #c9a45c; --chip: rgba(255, 255, 255, .08); --win: #f2d27a; }
33.day { --bg: #f3ead7; --stage: #fbf6ea; --card: rgba(255, 253, 247, .94); --ink: #2a2119; --muted: #7b6b58; --line: #e3d6bd;
34 --accent: #d98a1f; --sky: #f7d9a3; --hill: #d9c29a; --hill2: #ead9b8; --ring: #b07a3e; --chip: rgba(60, 40, 10, .07); --win: transparent; }
35@media (prefers-color-scheme: dark) {
36 .day { --bg: #1d1915; --stage: #28221b; --card: rgba(40, 34, 27, .94); --ink: #f1e9da; --muted: #a99a86; --line: #3a3127;
37 --accent: #e9a23b; --sky: #5a3f22; --hill: #17120d; --hill2: #2b2219; --chip: rgba(255, 255, 255, .07); }
38}
39* { box-sizing: border-box; }
40html, body { margin: 0; height: 100%; overflow: hidden; }
41body { background: #0d1326; color: var(--ink); font: 14px/1.5 var(--font-ui); -webkit-font-smoothing: antialiased; }
42button { font: inherit; color: inherit; }
43svg.ic { width: 1em; height: 1em; fill: currentColor; vertical-align: -.14em; flex: none; }
44#app { height: 100%; display: grid; grid-template-rows: 52px minmax(0, 1fr) 64px; background: var(--bg); color: var(--ink);
45 transition: --bg .6s, --stage .6s, --card .6s, --ink .6s, --muted .6s, --line .6s, --accent .6s, --sky .6s, --hill .6s, --hill2 .6s; }
46
47/* ---- header: title, the medallion, counts and switches ---------------------------------------------------------- */
48header { position: relative; z-index: 6; display: flex; align-items: center; gap: 12px; padding: 0 16px;
49 background: linear-gradient(180deg, rgba(0, 0, 0, .28), rgba(0, 0, 0, .12)); border-bottom: 1px solid var(--line); }
50.day header { background: linear-gradient(180deg, rgba(120, 80, 20, .1), rgba(120, 80, 20, .03)); }
51.brand { display: flex; align-items: baseline; gap: 8px; min-width: 0; white-space: nowrap; }
52.brand b { font: 700 19px var(--font-judge); letter-spacing: .08em; }
53.brand span { color: var(--muted); font-size: 13px; }
54.badge { position: absolute; left: 50%; top: 4px; z-index: 2; display: flex; align-items: center; justify-content: center; gap: 2px;
55 width: 78px; height: 78px; margin-left: -39px; border-radius: 50%; color: #fff; font-family: var(--font-judge); font-weight: 700;
56 background: radial-gradient(circle at 50% 30%, #3b57b8, #16245c 70%); border: 3px solid var(--ring);
57 box-shadow: 0 0 0 3px rgba(0, 0, 0, .35), 0 0 22px rgba(120, 150, 255, .35), inset 0 -6px 12px rgba(0, 0, 0, .35); }
58.day .badge { background: radial-gradient(circle at 50% 30%, #ffc75a, #d0731c 72%); box-shadow: 0 0 0 3px rgba(80, 40, 0, .25), 0 0 26px rgba(255, 180, 60, .55), inset 0 -6px 12px rgba(120, 50, 0, .3); }
59.badge span { font-size: 13px; padding-top: 10px; }
60.badge b { font-size: 34px; line-height: 1; text-shadow: 0 2px 0 rgba(0, 0, 0, .35); }
61.badge img { position: absolute; top: -10px; right: -12px; width: 34px; height: 34px; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, .4)); }
62.badge.flip { animation: flipY calc(.6s / var(--spd)) var(--ease); }
63@keyframes flipY { 0% { transform: rotateY(0); } 50% { transform: rotateY(90deg); } 100% { transform: rotateY(0); } }
64.ribbon { position: absolute; left: 50%; bottom: -9px; z-index: 1; width: 260px; height: 8px; margin-left: -130px; border-radius: 0 0 6px 6px;
65 background: linear-gradient(180deg, #ffd768, #e9a41e); box-shadow: 0 2px 8px rgba(233, 164, 30, .45); }
66.tools { margin-left: auto; display: flex; align-items: center; gap: 8px; white-space: nowrap; }
67.count { color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; }
68.count b { color: var(--ink); }
69.count .w { color: var(--wolf); } .count .g { color: var(--villager); }
70.switch { display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 10px 0 8px; border: 1px solid var(--line); border-radius: 15px;
71 background: var(--chip); cursor: pointer; font-size: 13px; }
72.switch i { position: relative; width: 28px; height: 16px; border-radius: 8px; background: rgba(128, 128, 128, .45); transition: background .2s; }
73.switch i::after { content: ''; position: absolute; left: 2px; top: 2px; width: 12px; height: 12px; border-radius: 50%; background: #fff; transition: transform .2s var(--ease); }
74.switch[aria-pressed=true] i { background: var(--accent); }
75.switch[aria-pressed=true] i::after { transform: translateX(12px); }
76.switch:disabled { opacity: .45; cursor: not-allowed; }
77.iconbtn { display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 1px solid var(--line); border-radius: 50%; background: var(--chip); cursor: pointer; font-size: 15px; }
78.iconbtn b { font: 700 14px/1 Georgia, serif; }
79
80/* ---- the table: two columns of seats around the stage, the log on the right -------------------------------------- */
81#table { position: relative; display: grid; min-height: 0; grid-template-columns: var(--side) minmax(0, 1fr) var(--side) var(--logw);
82 grid-template-areas: "l s r g"; }
83.col { display: flex; flex-direction: column; justify-content: space-evenly; align-items: center; min-height: 0; padding: 14px 0 8px; position: relative; z-index: 2; }
84.col.l { grid-area: l; } .col.r { grid-area: r; }
85.seat { position: relative; display: flex; flex-direction: column; align-items: center; width: 100%; padding: 0 6px; cursor: pointer;
86 transition: opacity .3s, transform .25s var(--ease); -webkit-tap-highlight-color: transparent; }
87.coin { position: relative; width: var(--coin); height: var(--coin); flex: none; }
88.face { display: block; width: 100%; height: 100%; border-radius: 50%; background: var(--tint, #f3e4c4); border: 3px solid var(--ring);
89 box-shadow: 0 0 0 2px rgba(0, 0, 0, .28), 0 6px 14px rgba(0, 0, 0, .28), inset 0 0 0 2px rgba(255, 255, 255, .5); transition: filter .4s, opacity .4s, border-color .3s, box-shadow .3s; }
90.num { position: absolute; left: 50%; bottom: -12px; min-width: 28px; height: 28px; margin-left: -14px; padding: 0 4px; border-radius: 14px;
91 background: radial-gradient(circle at 40% 30%, #4a5d96, #172152); border: 2px solid var(--ring); color: #fff; text-align: center;
92 font: 700 18px/24px var(--font-judge); text-shadow: 0 1px 0 rgba(0, 0, 0, .5); box-shadow: 0 2px 6px rgba(0, 0, 0, .35); }
93.day .num { background: radial-gradient(circle at 40% 30%, #8a5a2c, #4a2c12); }
94.tag { position: absolute; top: -7px; right: -12px; z-index: 2; height: 19px; padding: 0 7px; border-radius: 10px 10px 10px 3px; color: #fff;
95 font: 700 11px/19px var(--font-ui); white-space: nowrap; background: var(--c); box-shadow: 0 2px 6px rgba(0, 0, 0, .3); animation: pop .3s var(--ease); }
96.Werewolf { --c: var(--wolf); } .Seer { --c: var(--seer); } .Doctor { --c: var(--doctor); } .Villager { --c: var(--villager); }
97.kind { position: absolute; left: -5px; bottom: 1px; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%;
98 background: var(--stage); border: 1px solid var(--line); box-shadow: 0 1px 3px rgba(0, 0, 0, .3); }
99.kind img { width: 14px; height: 14px; }
100.kind.me { background: #3fbf5a; border-color: #2c8f42; color: #fff; font-size: 11px; font-weight: 700; }
101.vcount { position: absolute; top: -6px; left: -8px; z-index: 2; min-width: 22px; height: 22px; border-radius: 11px; background: var(--red); color: #fff;
102 font: 700 13px/22px var(--font-ui); text-align: center; box-shadow: 0 2px 6px rgba(0, 0, 0, .35); animation: pop .25s var(--ease); }
103.mic { position: absolute; top: -8px; left: -8px; z-index: 2; width: 26px; height: 26px; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, .4)); animation: pop .3s var(--ease); }
104.zzz { position: absolute; top: -10px; right: -8px; width: 26px; height: 26px; animation: doze 2.4s ease-in-out infinite; }
105@keyframes doze { 0%, 100% { transform: translateY(0); opacity: .9; } 50% { transform: translateY(-4px); opacity: .55; } }
106.marks { position: absolute; right: -14px; bottom: 6px; z-index: 2; display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
107.mk { display: flex; align-items: center; gap: 2px; height: 20px; padding: 0 5px; border-radius: 10px; color: #fff; font-size: 11px; font-weight: 700;
108 background: var(--c); box-shadow: 0 1px 4px rgba(0, 0, 0, .35); animation: pop .3s var(--ease); white-space: nowrap; }
109.mk.kill { --c: var(--wolf); } .mk.heal { --c: var(--doctor); } .mk.eye { --c: var(--seer); }
110.claws { position: absolute; inset: 6px; z-index: 1; color: var(--red); opacity: .85; animation: slash .35s var(--ease); pointer-events: none; }
111.claws svg { width: 100%; height: 100%; fill: currentColor; filter: drop-shadow(0 0 3px rgba(0, 0, 0, .6)); }
112@keyframes slash { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
113.stamp { position: absolute; left: -8px; right: -8px; top: 34%; z-index: 3; padding: 1px 0; border: 2px solid var(--red); border-radius: 5px;
114 color: var(--red); background: rgba(255, 240, 235, .85); text-align: center; font: 700 15px/1.2 var(--font-judge); letter-spacing: .25em; text-indent: .25em;
115 transform: rotate(-12deg); animation: stamp .3s cubic-bezier(.3, 1.6, .5, 1); box-shadow: 0 2px 8px rgba(0, 0, 0, .3); }
116@keyframes stamp { from { opacity: 0; transform: rotate(-12deg) scale(1.8); } }
117.seat .name { max-width: 100%; margin-top: 15px; overflow: hidden; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
118.seat .sub { max-width: 100%; min-height: 16px; overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
119.seat .sub.note { color: #e9a23b; font-style: italic; }
120.seat .sub.pick { color: var(--wolf); font-weight: 700; }
121.seat.dead .face { filter: grayscale(1) brightness(.75); opacity: .55; }
122.seat.dead .name { opacity: .6; }
123.seat.talk .face { border-color: var(--gold); animation: breathe calc(1.2s / var(--spd)) ease-in-out infinite; }
124@keyframes breathe { 0%, 100% { box-shadow: 0 0 0 2px rgba(0, 0, 0, .28), 0 0 10px 2px rgba(245, 194, 66, .45); } 50% { box-shadow: 0 0 0 2px rgba(0, 0, 0, .28), 0 0 26px 8px rgba(245, 194, 66, .75); } }
125.seat.hit .face { border-color: var(--c, var(--accent)); box-shadow: 0 0 0 2px rgba(0, 0, 0, .28), 0 0 18px 4px var(--c, var(--accent)); }
126.seat.can .face { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(0, 0, 0, .2), 0 0 16px 3px rgba(245, 194, 66, .7); }
127.seat.can:hover { transform: translateY(-2px); }
128.seat.cannot { opacity: .4; cursor: default; }
129.seat.picked .face { border-color: var(--act, var(--red)); box-shadow: 0 0 0 3px var(--act, var(--red)), 0 0 22px var(--act, var(--red)); }
130.seat .ok { position: absolute; left: 50%; top: 50%; z-index: 3; display: grid; place-items: center; width: 30px; height: 30px; margin: -15px 0 0 -15px; border-radius: 50%;
131 background: var(--act, var(--red)); color: #fff; font-size: 17px; font-weight: 700; box-shadow: 0 0 0 2px #fff, 0 2px 8px rgba(0, 0, 0, .4); animation: pop .25s var(--ease); }
132.seat.filtered .face { box-shadow: 0 0 0 3px var(--accent); }
133.config { width: calc(100% - 16px); padding: 7px 6px 8px; border: 1px solid var(--line); border-radius: 12px; background: var(--chip); text-align: center; }
134.config small { display: block; margin-bottom: 4px; color: var(--muted); font-size: 11px; letter-spacing: .1em; }
135.config div { display: grid; grid-template-columns: 1fr 1fr; gap: 3px 4px; }
136.config span { display: flex; align-items: center; justify-content: center; gap: 1px; font-size: 12px; font-variant-numeric: tabular-nums; }
137.config img { width: 22px; height: 22px; }
138
139/* ---- the stage: sky, village, status line, one scene at a time ------------------------------------------------- */
140#stage { grid-area: s; position: relative; display: flex; flex-direction: column; align-items: center; min-width: 0; min-height: 0;
141 padding: 34px 14px 12px; overflow: hidden; background: radial-gradient(ellipse 80% 70% at 70% -8%, var(--sky), transparent 70%), var(--stage);
142 border-left: 1px solid var(--line); border-right: 1px solid var(--line); }
143.night #stage::before { content: ''; position: absolute; inset: 0 0 30% 0; pointer-events: none; opacity: .8;
144 background: radial-gradient(1.5px 1.5px at 12% 20%, #fff, transparent), radial-gradient(1px 1px at 25% 8%, #fff, transparent),
145 radial-gradient(1.5px 1.5px at 38% 30%, #dfe6ff, transparent), radial-gradient(1px 1px at 52% 14%, #fff, transparent),
146 radial-gradient(1px 1px at 63% 36%, #fff, transparent), radial-gradient(1.5px 1.5px at 82% 24%, #fff, transparent),
147 radial-gradient(1px 1px at 90% 44%, #cfd8ff, transparent), radial-gradient(1px 1px at 8% 48%, #fff, transparent),
148 radial-gradient(1px 1px at 46% 52%, #fff, transparent), radial-gradient(1.5px 1.5px at 72% 6%, #fff, transparent);
149 animation: twinkle 5s ease-in-out infinite alternate; }
150@keyframes twinkle { to { opacity: .45; } }
151.night #stage::after { content: ''; position: absolute; top: 64px; left: 9%; width: 54px; height: 54px; border-radius: 50%; pointer-events: none;
152 background: radial-gradient(circle at 38% 38%, #fffbe6, #f2d27a 60%, #c9a45c); box-shadow: 0 0 40px 12px rgba(242, 210, 122, .25); opacity: .55; }
153.day #stage::after { content: ''; position: absolute; top: -30px; left: 4%; width: 140px; height: 140px; border-radius: 50%; pointer-events: none;
154 background: radial-gradient(circle, rgba(255, 214, 120, .65), rgba(255, 214, 120, 0) 70%); }
155.village { position: absolute; left: 0; right: 0; bottom: 0; width: 100%; height: 30%; pointer-events: none; }
156.village .v1 { fill: var(--hill2); } .village .v2 { fill: var(--hill); } .village .win { fill: var(--win); }
157.status { position: relative; z-index: 2; max-width: 100%; padding: 4px 16px; border-radius: 16px; background: rgba(0, 0, 0, .32); color: #fff;
158 font-size: 15px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
159.day .status { background: rgba(70, 45, 10, .72); }
160.status .sec { color: var(--gold); font-weight: 700; font-variant-numeric: tabular-nums; }
161.status .sec.warn { color: #ffb020; } .status .sec.hot { color: #ff5a4a; }
162.order { position: relative; z-index: 2; display: flex; flex-wrap: wrap; justify-content: center; gap: 4px; min-height: 26px; margin-top: 8px; }
163.order i { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: var(--chip); border: 1px solid var(--line);
164 font: 700 12px/1 var(--font-ui); font-style: normal; color: var(--ink); transition: all .3s; }
165.order i.done { opacity: .35; }
166.order i.now { background: var(--gold); border-color: var(--gold); color: #3a2a06; transform: scale(1.15); }
167.order small { align-self: center; margin-right: 4px; color: var(--muted); font-size: 11px; }
168.scene { position: relative; z-index: 2; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; min-height: 0; padding: 6px 0 4px; }
169.tabs { display: none; position: absolute; top: 6px; right: 8px; z-index: 5; border: 1px solid var(--line); border-radius: 13px; overflow: hidden; background: var(--chip); }
170.tabs button { height: 24px; padding: 0 10px; border: 0; background: none; font-size: 12px; cursor: pointer; color: var(--muted); }
171.tabs button[aria-pressed=true] { background: var(--accent); color: #2a2119; }
172
173.judge { position: relative; min-width: 240px; max-width: min(460px, 100%); padding: 14px 22px 13px; border: 1px solid var(--line); border-radius: 16px;
174 background: var(--card); text-align: center; box-shadow: 0 14px 34px rgba(0, 0, 0, .28); animation: rise calc(.45s / var(--spd)) var(--ease); }
175.night .judge { border-color: rgba(242, 210, 122, .28); }
176.judge .jl { display: inline-flex; align-items: center; gap: 5px; margin-bottom: 4px; color: var(--muted); font-size: 12px; letter-spacing: .2em; }
177.judge .jl svg { width: 16px; height: 16px; color: var(--accent); }
178.judge h3 { margin: 0; font: 700 22px/1.35 var(--font-judge); color: var(--ink); letter-spacing: .04em; }
179.night .judge h3 { color: #f6e3a6; }
180.judge h3 em, .board em, .act em, .msg em { font-style: normal; color: var(--accent); }
181.judge p { margin: 6px 0 0; color: var(--ink); font-size: 14px; }
182.judge p.god { color: var(--muted); font-size: 13px; }
183.judge .en { margin-top: 8px; color: var(--muted); font-size: 12px; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
184.judge .act { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px; margin-top: 8px; font-size: 15px; }
185.judge .act svg { width: 26px; height: 26px; color: var(--c); }
186.judge .role { display: inline-block; padding: 0 8px; border-radius: 9px; color: #fff; background: var(--c); font-size: 13px; font-weight: 700; }
187@keyframes rise { from { opacity: 0; transform: translate(-10px, 14px); } }
188@keyframes pop { from { opacity: 0; transform: scale(.6); } }
189.reveal { width: 92px; height: 112px; margin: 10px auto 2px; perspective: 600px; }
190.reveal > div { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; animation: turn calc(.9s / var(--spd)) var(--ease) forwards; }
191.reveal .fc { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; border-radius: 12px;
192 backface-visibility: hidden; border: 2px solid var(--ring); }
193.reveal .front { background: repeating-linear-gradient(45deg, #2a3570 0 8px, #222c60 8px 16px); color: var(--gold); font: 700 36px var(--font-judge); }
194.reveal .back { transform: rotateY(180deg); background: var(--card); border-color: var(--c); }
195.reveal .back img { width: 64px; height: 64px; }
196.reveal .back b { margin-top: 2px; color: var(--c); font-size: 14px; }
197@keyframes turn { 0%, 30% { transform: rotateY(0); } 100% { transform: rotateY(180deg); } }
198
199.say { position: relative; z-index: 2; display: flex; flex-direction: column; width: 100%; max-width: 540px; animation: rise calc(.4s / var(--spd)) var(--ease); }
200.bubble { position: relative; padding: 12px 18px 14px; border: 1px solid var(--line); border-radius: 18px; background: var(--card); box-shadow: 0 14px 34px rgba(0, 0, 0, .26); }
201.bubble::before { content: ''; position: absolute; top: 22px; width: 16px; height: 16px; background: var(--card); border: 1px solid var(--line); transform: rotate(45deg); }
202.say.L .bubble::before { left: -9px; border-width: 0 0 1px 1px; } .say.R .bubble::before { right: -9px; border-width: 1px 1px 0 0; }
203.bubble .who { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; color: var(--muted); font-size: 13px; }
204.bubble .who b { color: var(--ink); font-size: 14px; }
205.bubble .who .lbl { margin-left: auto; color: var(--gold); font-size: 12px; }
206.bubble .text { max-height: 34vh; overflow-y: auto; font-size: 16px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
207.bubble .text.wait { color: var(--muted); font-size: 15px; }
208.bubble .bar { height: 3px; margin-top: 10px; border-radius: 2px; background: var(--chip); overflow: hidden; }
209.bubble .bar i { display: block; height: 100%; width: 0; background: var(--gold); }
210.dots i { display: inline-block; width: 5px; height: 5px; margin-left: 3px; border-radius: 50%; background: currentColor; animation: blink 1.2s infinite; }
211.dots i:nth-child(2) { animation-delay: .2s; } .dots i:nth-child(3) { animation-delay: .4s; }
212@keyframes blink { 0%, 100% { opacity: .2; } 40% { opacity: 1; } }
213.why { display: flex; gap: 6px; margin: 8px 6px 0; color: var(--muted); font-size: 13px; font-style: italic; line-height: 1.5; cursor: pointer; }
214.why img { width: 18px; height: 18px; flex: none; margin-top: 1px; }
215.why span { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
216.why.open span { display: block; }
217.portrait { position: absolute; bottom: 4px; z-index: 1; width: 132px; height: 132px; border-radius: 50%; background: var(--tint); border: 3px solid var(--gold);
218 box-shadow: 0 0 30px rgba(245, 194, 66, .35); animation: rise calc(.5s / var(--spd)) var(--ease); }
219.portrait.L { left: 14px; } .portrait.R { right: 14px; }
220.pnum { position: absolute; bottom: 0; z-index: 1; min-width: 40px; height: 40px; border-radius: 20px; border: 2px solid var(--gold);
221 background: radial-gradient(circle at 40% 30%, #4a5d96, #172152); color: #fff; font: 700 26px/36px var(--font-judge); text-align: center; }
222.pnum.L { left: 118px; } .pnum.R { right: 118px; }
223.day .pnum { background: radial-gradient(circle at 40% 30%, #8a5a2c, #4a2c12); }
224
225.board { width: 100%; max-width: 480px; padding: 12px 16px 12px; border: 1px solid var(--line); border-radius: 16px; background: var(--card);
226 box-shadow: 0 14px 34px rgba(0, 0, 0, .26); animation: rise calc(.4s / var(--spd)) var(--ease); }
227.board.still { animation: none; }
228.board .bh { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font: 700 17px var(--font-judge); }
229.board .bh img { width: 28px; height: 28px; }
230.board .bh small { margin-left: auto; color: var(--muted); font: 12px var(--font-ui); }
231.br { display: flex; align-items: center; gap: 8px; min-height: 34px; padding: 3px 6px; border-radius: 10px; transition: background .3s; }
232.br.top { background: rgba(224, 72, 62, .16); }
233.br .tg { display: flex; align-items: center; gap: 6px; width: 38%; min-width: 0; font-weight: 600; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
234.br .ar { color: var(--muted); }
235.br .vs { flex: 1; display: flex; flex-wrap: wrap; gap: 3px; }
236.br .vs .mc.new { animation: fly calc(.3s / var(--spd)) var(--ease); }
237.br b { color: var(--red); font-variant-numeric: tabular-nums; white-space: nowrap; }
238@keyframes fly { from { opacity: 0; transform: translate(-30px, 12px) scale(.5); } }
239.board .res { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--line); text-align: center; font: 700 17px var(--font-judge); animation: pop .35s var(--ease); }
240.board .sum { margin-top: 4px; text-align: center; color: var(--muted); font-size: 13px; letter-spacing: .05em; }
241.mc { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: radial-gradient(circle at 40% 30%, #4a5d96, #172152);
242 border: 1px solid var(--ring); color: #fff; font: 700 11px/1 var(--font-ui); font-style: normal; flex: none; vertical-align: -4px; }
243.day .mc { background: radial-gradient(circle at 40% 30%, #8a5a2c, #4a2c12); }
244
245.banner { position: absolute; left: -14px; right: -14px; top: 30%; z-index: 4; display: flex; align-items: center; justify-content: center; gap: 16px; height: 96px;
246 color: #fff; font: 700 32px var(--font-judge); letter-spacing: .12em; pointer-events: none; text-shadow: 0 2px 10px rgba(0, 0, 0, .6); opacity: 0;
247 background: linear-gradient(90deg, transparent, rgba(4, 8, 26, .78) 16%, rgba(4, 8, 26, .78) 84%, transparent);
248 animation: band calc(1.6s / var(--spd)) ease forwards; }
249.banner.d { background: linear-gradient(90deg, transparent, rgba(120, 70, 10, .7) 16%, rgba(120, 70, 10, .7) 84%, transparent); }
250.banner img { width: 70px; height: 70px; animation: risebody calc(1.2s / var(--spd)) var(--ease); }
251@keyframes band { 0% { opacity: 0; transform: scaleY(.4); } 15%, 75% { opacity: 1; transform: none; } 100% { opacity: 0; } }
252@keyframes risebody { from { transform: translateY(40px); opacity: 0; } }
253#arrows { position: absolute; inset: 0; z-index: 5; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
254#arrows .a { fill: none; stroke-width: 3; stroke-linecap: round; stroke-dasharray: 1; stroke-dashoffset: 1; animation: draw calc(.5s / var(--spd)) var(--ease) forwards;
255 filter: drop-shadow(0 0 4px rgba(0, 0, 0, .5)); }
256@keyframes draw { to { stroke-dashoffset: 0; } }
257
258/* ---- the log --------------------------------------------------------------------------------------------------- */
259#log { grid-area: g; position: relative; z-index: 2; min-height: 0; overflow-y: auto; padding: 10px 12px 24px; font-size: 13px; line-height: 1.55;
260 background: rgba(0, 0, 0, .14); scrollbar-width: thin; }
261.day #log { background: rgba(120, 80, 20, .04); }
262#log h4 { margin: 0 0 6px; color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: .2em; }
263.lg { margin: 3px 0; padding: 3px 6px; border-radius: 8px; cursor: pointer; }
264.lg:hover { background: var(--chip); }
265.lg.now { background: rgba(245, 194, 66, .16); }
266.lg-div { display: flex; align-items: center; gap: 8px; margin: 12px 0 4px; color: var(--muted); font: 700 12px var(--font-judge); letter-spacing: .1em; cursor: pointer; }
267.lg-div::before, .lg-div::after { content: ''; flex: 1; height: 1px; background: var(--line); }
268.lg-j { text-align: center; color: var(--ink); font-family: var(--font-judge); font-weight: 700; }
269.lg-say { display: flex; gap: 7px; align-items: flex-start; }
270.lg-say .mc { margin-top: 1px; }
271.lg-say .t { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
272.lg-say.open .t { display: block; }
273.lg-say b { font-weight: 600; }
274.lg-why { margin-top: 2px; color: var(--muted); font-size: 12px; font-style: italic; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
275.lg-say.open .lg-why { display: block; }
276.lg-v, .lg-g { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; color: var(--muted); font-size: 12px; }
277.lg-g svg { width: 15px; height: 15px; color: var(--c); }
278.lg-g .r { color: var(--c); font-weight: 700; }
279.lg.hide { display: none; }
280
281/* ---- footer: replay controls or the seat's action bar ------------------------------------------------------------ */
282footer { position: relative; z-index: 6; display: flex; align-items: center; gap: 10px; padding: 0 16px; border-top: 1px solid var(--line);
283 background: linear-gradient(0deg, rgba(0, 0, 0, .3), rgba(0, 0, 0, .12)); }
284.day footer { background: linear-gradient(0deg, rgba(120, 80, 20, .1), rgba(120, 80, 20, .03)); }
285.ctl { display: flex; align-items: center; gap: 4px; }
286.ctl button { display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: pointer; }
287.ctl button:hover { background: var(--chip); }
288.ctl button svg { width: 18px; height: 18px; }
289.ctl button.play { width: 42px; height: 42px; background: var(--accent); color: #2a2119; }
290.spd { height: 28px; padding: 0 10px; border: 1px solid var(--line); border-radius: 14px; background: transparent; cursor: pointer; font-size: 12px; font-variant-numeric: tabular-nums; }
291.tl { flex: 1; display: flex; gap: 3px; height: 34px; min-width: 0; cursor: pointer; touch-action: none; user-select: none; }
292.seg { position: relative; flex: var(--w) 1 0; min-width: 10px; display: grid; place-items: center; overflow: hidden; border-radius: 7px; font-size: 11px; color: var(--muted); }
293.seg.n { background: rgba(70, 90, 170, .32); } .seg.d { background: rgba(232, 168, 60, .3); }
294.seg i { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: rgba(245, 194, 66, .45); }
295.seg.past i { width: 100%; background: rgba(245, 194, 66, .22); }
296.seg span { position: relative; white-space: nowrap; }
297.seg.cur span { color: var(--ink); font-weight: 700; }
298.act { flex: 1; display: flex; align-items: center; gap: 8px; min-width: 0; }
299.act .msg { flex: 1; min-width: 0; color: var(--ink); font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
300.act textarea { flex: 1; min-width: 0; height: 44px; max-height: 88px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 12px; resize: none;
301 background: var(--chip); color: var(--ink); font: 14px/1.4 var(--font-ui); outline: none; }
302.act textarea:focus { border-color: var(--accent); }
303.btn { height: 36px; padding: 0 18px; border: 0; border-radius: 18px; background: var(--accent); color: #2a2119; font-weight: 700; cursor: pointer; white-space: nowrap; }
304.btn.ghost { background: transparent; border: 1px solid var(--line); color: var(--ink); }
305.btn.red { background: var(--red); color: #fff; }
306.clock { position: relative; width: 34px; height: 34px; flex: none; }
307.clock svg { width: 100%; height: 100%; transform: rotate(-90deg); }
308.clock circle { fill: none; stroke-width: 3; }
309.clock .bg { stroke: var(--line); } .clock .fg { stroke: var(--gold); stroke-linecap: round; transition: stroke-dashoffset .25s linear; }
310.clock span { position: absolute; inset: 0; display: grid; place-items: center; font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums; }
311
312/* ---- overlays: role card, the end, credits ----------------------------------------------------------------------- */
313#over { position: fixed; inset: 0; z-index: 20; display: none; place-items: center; padding: 16px; background: rgba(5, 8, 22, .88); -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); }
314#over.on { display: grid; animation: fade .3s; }
315@keyframes fade { from { opacity: 0; } }
316.card { position: relative; width: min(360px, 100%); max-height: 100%; overflow-y: auto; padding: 22px 22px 18px; border: 2px solid var(--c); border-radius: 20px; text-align: center; color: #f1ecff;
317 background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, .12), transparent 60%), linear-gradient(180deg, #1e2650, #10152e);
318 box-shadow: 0 0 0 4px rgba(0, 0, 0, .3), 0 0 50px color-mix(in srgb, var(--c) 45%, transparent); animation: rise .5s var(--ease); }
319.card small { color: #b8bddb; letter-spacing: .3em; font-size: 12px; }
320.card h2 { margin: 2px 0 8px; font: 700 34px var(--font-judge); color: var(--c); letter-spacing: .1em; filter: brightness(1.25); }
321.card .art { width: 150px; height: 150px; margin: 4px auto 8px; border-radius: 50%; background: radial-gradient(circle, color-mix(in srgb, var(--c) 45%, transparent), transparent 70%); }
322.card .art img { width: 150px; height: 150px; }
323.card dl { margin: 0 0 14px; text-align: left; font-size: 14px; }
324.card dt { margin-top: 8px; color: var(--c); font-weight: 700; filter: brightness(1.3); }
325.card dd { margin: 2px 0 0; color: #e3e1f2; }
326.card.about { --c: #c9a45c; text-align: left; font-size: 13px; line-height: 1.6; }
327.card.about h2 { font-size: 20px; text-align: center; }
328.card.about p { margin: 6px 0; color: #d9dcf0; }
329.end { width: min(760px, 100%); max-height: 100%; overflow-y: auto; text-align: center; color: #fff; }
330.end .hero { position: relative; width: 170px; height: 170px; margin: 0 auto; display: grid; place-items: center; }
331.end .rays { position: absolute; inset: -60px; border-radius: 50%; animation: spin 24s linear infinite;
332 background: repeating-conic-gradient(rgba(255, 214, 120, .30) 0 7deg, transparent 7deg 20deg);
333 -webkit-mask: radial-gradient(circle, #000 25%, transparent 68%); mask: radial-gradient(circle, #000 25%, transparent 68%); }
334.end.Werewolves .rays { background: repeating-conic-gradient(rgba(255, 90, 70, .32) 0 7deg, transparent 7deg 20deg); }
335@keyframes spin { to { transform: rotate(360deg); } }
336.end .hero img { position: relative; width: 150px; height: 150px; filter: drop-shadow(0 8px 20px rgba(0, 0, 0, .5)); animation: pop .6s var(--ease); }
337.end h1 { margin: 6px 0 2px; font: 700 46px var(--font-judge); letter-spacing: .12em; color: #ffd768; text-shadow: 0 0 24px rgba(255, 200, 80, .6), 0 3px 0 rgba(0, 0, 0, .4);
338 animation: big .5s var(--ease); }
339.end.Werewolves h1 { color: #ff6a5a; text-shadow: 0 0 24px rgba(255, 80, 60, .6), 0 3px 0 rgba(0, 0, 0, .4); }
340@keyframes big { from { transform: scale(1.6); opacity: 0; } }
341.end .rs { margin: 0 0 4px; color: #cfd3ea; font-size: 14px; }
342.end .you { margin: 2px 0; font: 700 22px var(--font-judge); color: #9ff0b0; }
343.end .you.lost { color: #ff9a8a; }
344.end .cards { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 8px; margin: 16px 0 14px; }
345.ec { position: relative; padding: 10px 4px 8px; border: 1px solid rgba(255, 255, 255, .14); border-radius: 12px; background: rgba(255, 255, 255, .06); }
346.ec.lose { opacity: .5; filter: grayscale(.5); }
347.ec.win { border-color: rgba(255, 215, 104, .6); box-shadow: 0 0 16px rgba(255, 215, 104, .25); }
348.ec .face { width: 48px; height: 48px; margin: 0 auto; border-width: 2px; }
349.ec .rimg { position: absolute; top: 4px; right: 4px; width: 24px; height: 24px; }
350.ec .n { margin-top: 2px; font: 700 15px var(--font-judge); }
351.ec b { display: block; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
352.ec .tg { display: inline-block; margin: 3px 0 2px; padding: 0 6px; border-radius: 8px; background: var(--c); font-size: 11px; font-weight: 700; }
353.ec small { display: block; color: #b8bddb; font-size: 11px; }
354.ec.me { outline: 2px solid #3fbf5a; }
355.end .btns { display: flex; justify-content: center; gap: 10px; }
356.end .btn.ghost { color: #fff; border-color: rgba(255, 255, 255, .3); }
357.oldnote { margin: auto; padding: 24px; color: var(--muted); text-align: center; }
358
359/* ---- smaller frames ------------------------------------------------------------------------------------------- */
360@media (max-width: 900px) {
361 :root { --side: 100px; --coin: 52px; }
362 #table { grid-template-columns: var(--side) minmax(0, 1fr) var(--side); grid-template-areas: "l s r"; }
363 #log { grid-area: s; display: none; z-index: 4; background: var(--stage); border-left: 1px solid var(--line); border-right: 1px solid var(--line); padding-top: 38px; }
364 #app.logtab #log { display: block; background: var(--stage); }
365 .tabs { display: flex; }
366 .num { font-size: 16px; min-width: 24px; height: 24px; margin-left: -12px; line-height: 20px; bottom: -10px; }
367 .seat .name { margin-top: 12px; }
368 .portrait { width: 96px; height: 96px; } .pnum.L { left: 86px; } .pnum.R { right: 86px; }
369 .pnum { min-width: 32px; height: 32px; font-size: 20px; line-height: 28px; }
370 .brand span, .count .w, .count .g, .count .sep { display: none; }
371 .end .cards { gap: 6px; } .ec .face { width: 40px; height: 40px; } .ec .rimg { width: 20px; height: 20px; }
372}
373@media (max-height: 600px) and (min-width: 641px) {
374 #stage { padding-top: 30px; }
375 .bubble .text { max-height: 24vh; }
376 .judge { padding: 10px 18px; }
377 .portrait, .pnum { display: none; }
378 .end .hero { width: 96px; height: 96px; } .end .hero img { width: 88px; height: 88px; } .end h1 { font-size: 32px; } .end .cards { margin: 10px 0; }
379 .card .art, .card .art img { width: 110px; height: 110px; }
380}
381@media (max-width: 640px) {
382 :root { --side: 64px; --coin: 44px; }
383 #app { grid-template-rows: 46px minmax(0, 1fr) 58px; }
384 header { padding: 0 8px; gap: 6px; }
385 .brand b { font-size: 16px; }
386 .badge { width: 58px; height: 58px; margin-left: -29px; top: 2px; }
387 .badge b { font-size: 24px; } .badge span { font-size: 11px; padding-top: 6px; }
388 .badge img { width: 24px; height: 24px; top: -6px; right: -8px; }
389 .ribbon { width: 150px; margin-left: -75px; }
390 .count { display: none; }
391 .switch { padding: 0 6px; } .switch .lbl { display: none; }
392 #table { grid-template-columns: var(--side) minmax(0, 1fr) var(--side); grid-template-rows: auto minmax(0, 1fr); grid-template-areas: "l s r" "l g r"; }
393 .col { padding: 18px 0 6px; }
394 #stage { padding: 20px 6px 8px; border-bottom: 1px solid var(--line); }
395 #log { display: block; grid-area: g; padding: 6px 8px 16px; border-left: 1px solid var(--line); border-right: 1px solid var(--line); }
396 .tabs { display: none; }
397 .village { height: 46%; }
398 .status { font-size: 13px; padding: 3px 10px; }
399 .order { margin-top: 5px; min-height: 20px; } .order i { width: 18px; height: 18px; font-size: 10px; } .order small { display: none; }
400 .scene { flex: none; min-height: 150px; }
401 .judge { min-width: 0; width: 100%; padding: 10px 12px; } .judge h3 { font-size: 18px; }
402 .bubble { padding: 10px 12px; } .bubble .text { font-size: 14px; max-height: 22vh; }
403 .board { padding: 8px 10px; } .br .tg { width: 42%; font-size: 12px; } .board .sum { display: none; }
404 .portrait, .pnum { display: none; }
405 .seat { padding: 0 2px; }
406 .seat .name { font-size: 11px; margin-top: 11px; }
407 .seat .sub { font-size: 10px; }
408 .num { font-size: 14px; min-width: 20px; height: 20px; margin-left: -10px; line-height: 17px; bottom: -8px; border-width: 1.5px; }
409 .tag { right: -8px; font-size: 10px; height: 17px; line-height: 17px; padding: 0 5px; }
410 .kind { width: 17px; height: 17px; } .kind img { width: 12px; height: 12px; }
411 .marks { right: -10px; } .mk { height: 17px; font-size: 10px; padding: 0 4px; }
412 .stamp { font-size: 12px; left: -4px; right: -4px; }
413 .config { width: calc(100% - 6px); padding: 5px 2px; } .config div { grid-template-columns: 1fr; } .config img { width: 18px; height: 18px; }
414 .banner { font-size: 22px; height: 70px; top: 20%; } .banner img { width: 46px; height: 46px; }
415 footer { padding: 0 8px; gap: 6px; }
416 .ctl .prev, .ctl .next { display: none; }
417 .end h1 { font-size: 32px; } .end .hero { width: 120px; height: 120px; } .end .hero img { width: 110px; height: 110px; }
418 .end .cards { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
419 .act textarea { height: 40px; }
420 .btn { padding: 0 12px; }
421}
422@media (prefers-reduced-motion: reduce) {
423 *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
424 .banner { display: none; }
425}
426</style>
427</head>
428<body>
429<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>
430<symbol id="i-claw" viewBox="0 0 512 512"><path d="M182.338 22.55c152.455 20.22 254.7 176.264 228.928 258.163-34.76-89.215-143.516-189.588-300.207-196.15C255.855 103.772 355.365 245.5 342.92 329.856c-9.85.97-20.79.927-32.852-.24-41.436-84.32-146.004-172.382-292.437-178.513 142.546 18.907 241.2 156.557 232.36 241.316-15.664-.633-33.447-3.34-53.48-8.46 18.37 8.188 35.514 14.817 51.502 20.067-10.614 44.085-55.285 69.374-144.93 46.47C250 515.968 319.647 483.338 328.736 419.743c56.24 1.254 85.915-24.86 92.856-63.008 164.112-19.88 36.806-322.627-239.254-334.185zm132.808 318.083c7.97 2.704 15.666 5.077 23.07 7.11-3.122 7.893-7.546 14.993-13.35 21.095-2.393-9.222-5.635-18.663-9.72-28.205z"/></symbol>
431<symbol id="i-shield" viewBox="0 0 512 512"><path d="M48.906 19.656v10.782c0 103.173 10.53 206.07 41.313 289.53 30.78 83.463 82.763 148.094 164.53 170.563l2.188.626 2.25-.5c89.686-19.12 142.322-84.028 171.187-168.344 28.865-84.315 35.406-188.656 35.406-291.875v-10.78l-10.655 1.53C323.26 39.954 191.452 40 59.595 21.188l-10.69-1.53zM67.75 41.03c63.242 8.536 126.495 12.792 189.75 12.782v184.532h174.78c-4.905 27.572-11.31 53.747-19.592 77.937-27.348 79.884-73.757 137.33-155.157 155.564-.008-.003-.02.003-.03 0v-233.5H86.53c-12.87-60.99-18.277-128.81-18.78-197.313z"/></symbol>
432<symbol id="i-eye" viewBox="0 0 512 512"><path d="M18 18v15.797L179.434 215.41l9.484-16.424L28.04 18H18zm77.705 0l96.95 174.512 10.493-18.176L116.295 18h-20.59zm91.154 0c4.326 19.018 15.27 67.152 28.36 125.326.557 2.482 1.074 4.807 1.63 7.278l13.275-22.993C219.105 78.687 209.9 38.14 205.32 18h-18.46zM247 18v80.383l9-15.588 7.795 13.5L265 98.383V18h-18zm59.78 0l-24.51 110.297 13.314 23.058L325.22 18h-18.44zm88.925 0l-86.853 156.336 10.494 18.176L416.296 18h-20.59zm88.254 0L323.083 198.986l9.482 16.424L494 33.797V18h-10.04zM18 98.25v22.5l150.79 113.094 9.067-15.7L18 98.25zm476 0L334.145 218.143l9.064 15.7L494 120.75v-22.5zm-238 20.547l-74.656 129.308c20.297-9.102 46.254-14.35 74.656-14.35 28.402 0 54.36 5.248 74.656 14.35L256 118.797zm-238 70.34v19.226l136.05 51.018 9.122-15.804L18 189.136zm476 0l-145.172 54.44 9.123 15.804L494 208.364v-19.226zM18 249.18v18.14l122.615 15.326 9.77-16.92L18 249.18zm476 0l-132.383 16.547 9.768 16.92L494 267.32v-18.14zm-238 2.576c-14.737 0-26.49 11.75-26.49 26.488 0 8.536 3.948 16.065 10.13 20.895a28.09 28.09 0 0 1-1.384-8.603 28.09 28.09 0 0 1 28.09-28.09 28.09 28.09 0 0 1 13.02 3.223c-4.446-8.315-13.194-13.914-23.366-13.914zm-38.18 3.736c-12.656 2.568-23.93 6.328-33.082 10.904-17.63 8.815-26.21 19.758-26.21 29.594 0 9.837 8.58 20.777 26.21 29.592 17.63 8.815 43.077 14.643 71.262 14.643 28.185 0 53.632-5.828 71.262-14.643 17.63-8.815 26.21-19.755 26.21-29.592 0-9.836-8.58-20.78-26.21-29.594-9.153-4.576-20.426-8.336-33.082-10.904 4 6.67 6.31 14.453 6.31 22.752 0 24.465-20.025 44.49-44.49 44.49-24.465 0-44.49-20.025-44.49-44.49 0-8.3 2.31-16.083 6.31-22.752zm-83.867 38.694L18 308.68v18.14l104.666-13.082 11.287-19.552zm244.094 0l11.287 19.552L494 326.82v-18.14l-115.953-14.494zm-233.758 18.1l-57.112 98.92h337.644l-57.11-98.92c-5.763 12.078-17.677 22.035-32.4 29.398-20.907 10.452-48.695 16.54-79.312 16.54-30.617 0-58.405-6.088-79.31-16.54-14.725-7.363-26.64-17.32-32.4-29.4zm255.84 20.15l14.167 24.537L494 386.863v-19.226l-93.87-35.2zm-288.26.002L18 367.637v19.225l79.703-29.888 14.166-24.538zm-31.645 54.808L18 426.136v21.227l43.05-26.906 19.175-33.21zm351.55 0l19.174 33.21L494 447.364v-21.226l-62.225-38.89zm-350.82 41.96L18 484.29V494h16.238l74.05-64.795H80.956zm42.158 0L67.573 494h23.71l55.537-64.795h-23.707zm41.832 0L127.92 494h20.732l37.026-64.795h-20.733zm42.752 0c-8.665 28.96-15.66 51.885-19.61 64.794h18.82c4.678-15.314 11.433-37.537 19.573-64.795h-18.783zm39.303 0V494h18v-64.795h-18zm39.984 0L305.496 494h18.723l-18.515-64.795h-18.72zm39.338 0L363.348 494h20.732l-37.025-64.795h-20.733zm38.856 0L420.718 494h23.708l-55.54-64.795H365.18zm38.533 0L477.763 494H494v-9.71l-62.955-55.085H403.71z"/></symbol>
433<symbol id="i-gavel" viewBox="0 0 512 512"><path d="M129.75 24.47L21.625 211.655l55.78 32.188L185.532 56.656 129.75 24.47zm55.97 69.25l-75.626 130.874L326.47 349.47l75.592-130.876L185.72 93.72zm83.468.686l-11.22 19.438 84.97 49.03 11.25-19.468-85-49zM434.25 200.22L326.156 387.405l55.78 32.188 108.095-187.188-55.78-32.187zm-270.53 76.905l-11.282 19.53 84.968 49 11.25-19.5-84.937-49.03zm-3.095 45.844L61.312 494.81h67.157l82.28-142.968-50.125-28.875z"/></symbol>
434<symbol id="i-play" viewBox="0 0 24 24"><path d="M7 4.5v15l12.5-7.5z"/></symbol>
435<symbol id="i-pause" viewBox="0 0 24 24"><path d="M6 4h4.5v16H6zM13.5 4H18v16h-4.5z"/></symbol>
436<symbol id="i-prev" viewBox="0 0 24 24"><path d="M5 4h2.5v16H5zM20 4.5v15L8.5 12z"/></symbol>
437<symbol id="i-next" viewBox="0 0 24 24"><path d="M16.5 4H19v16h-2.5zM4 4.5v15L15.5 12z"/></symbol>
438<symbol id="i-look" viewBox="0 0 24 24"><path d="M12 5C6.5 5 2.7 9.4 1.5 12c1.2 2.6 5 7 10.5 7s9.3-4.4 10.5-7C21.3 9.4 17.5 5 12 5zm0 11.5A4.5 4.5 0 1 1 12 7.5a4.5 4.5 0 0 1 0 9zm0-2.5a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/></symbol>
439<symbol id="i-sound" viewBox="0 0 24 24"><path d="M3 9h4l5-4v14l-5-4H3zM15.5 8.5a5 5 0 0 1 0 7l-1.4-1.4a3 3 0 0 0 0-4.2zM18.4 5.6a9 9 0 0 1 0 12.8L17 17a7 7 0 0 0 0-9.9z"/></symbol>
440<symbol id="i-mute" viewBox="0 0 24 24"><path d="M3 9h4l5-4v14l-5-4H3zM15 9.4l1.4-1.4 2.6 2.6 2.6-2.6L23 9.4 20.4 12l2.6 2.6-1.4 1.4-2.6-2.6-2.6 2.6-1.4-1.4 2.6-2.6z"/></symbol>
441</defs></svg>
442<div id="app" class="night">
443 <header>
444 <div class="brand"><b>狼人杀</b><span id="mode">· 回放 · 7人局</span></div>
445 <div class="ribbon"></div>
446 <div class="badge" id="badge"><img alt="" src="assets/moon.png"><span>第</span><b>1</b><span>夜</span></div>
447 <div class="tools">
448 <span class="count" id="count"></span>
449 <button class="switch" id="god" aria-pressed="true"><svg class="ic"><use href="#i-look"/></svg><span class="lbl">上帝视角</span><i></i></button>
450 <button class="iconbtn" id="snd" title="声音"><svg class="ic"><use href="#i-mute"/></svg></button>
451 <button class="iconbtn" id="about" title="关于"><b>i</b></button>
452 </div>
453 </header>
454 <main id="table">
455 <section class="col l" id="colL"></section>
456 <section id="stage">
457 <div class="tabs" id="tabs"><button aria-pressed="true" data-t="stage">舞台</button><button aria-pressed="false" data-t="log">记录</button></div>
458 <div class="status" id="status">等待开局……</div>
459 <div class="order" id="order"></div>
460 <div class="scene" id="scene"></div>
461 <svg class="village" viewBox="0 0 1000 200" preserveAspectRatio="xMidYMax slice" aria-hidden="true">
462 <path class="v1" d="M0 120C90 80 170 104 260 86S420 40 520 78 700 52 800 84 940 96 1000 88V200H0Z"/>
463 <path class="v2" d="M0 200V164H34V150L56 132 78 150V164H118V156L148 134 178 156V164H222V138H238V120L254 100 270 120V138H286V164H330L352 150 374 164H424V148L452 126 480 148V164H556V158L596 136 636 158V164H694V144L720 124 746 144V164H796V154L826 136 856 154V164H898L922 150 946 164H1000V200ZM190 164 200 136 210 164ZM508 164 520 132 532 164ZM868 164 878 140 888 164Z"/>
464 <g class="win"><rect x="50" y="152" width="9" height="9"/><rect x="142" y="144" width="9" height="9"/><rect x="249" y="124" width="9" height="10"/><rect x="446" y="134" width="9" height="9"/><rect x="590" y="146" width="10" height="9"/><rect x="714" y="132" width="9" height="9"/><rect x="820" y="144" width="9" height="9"/></g>
465 </svg>
466 </section>
467 <section class="col r" id="colR"></section>
468 <aside id="log"></aside>
469 <svg id="arrows"></svg>
470 </main>
471 <footer id="foot"></footer>
472 <div id="over"></div>
473</div>
474<script src="bridge.js"></script>
475<script>
476'use strict'
477const live = new URLSearchParams(location.search).has('live')
478const $ = s => document.querySelector(s)
479const app = $('#app')
480const ROLE = {
481 Werewolf: { zh: '狼人', img: 'werewolf', team: 'Werewolves', skill: '每晚和狼队友商量,选一名玩家出局。', goal: '白天装作好人混淆视听,夜里刀人;狼人数量追平好人即胜利。' },
482 Seer: { zh: '预言家', img: 'seer', team: 'Villagers', skill: '每晚查验一名玩家,得知他的真实身份。', goal: '找出狼人,带领好人把他们放逐。' },
483 Doctor: { zh: '医生', img: 'doctor', team: 'Villagers', skill: '每晚守护一名玩家(不能守自己),他当晚不会被狼人杀死。', goal: '保护好人,放逐所有狼人。' },
484 Villager: { zh: '平民', img: 'villager', team: 'Villagers', skill: '没有特殊能力,白天发言、投票。', goal: '听发言找出狼人,放逐所有狼人。' },
485}
486const TEAM = { Werewolves: '狼人阵营', Villagers: '好人阵营' }
487const AV = ['ada', 'eli', 'finn', 'iris', 'ivy', 'jade', 'kai', 'leo', 'luna', 'max', 'mia', 'nora', 'riley', 'sam', 'theo', 'zoe']
488const TINT = ['#f4e1bd', '#dbe7f6', '#e3efd6', '#f6dcdc', '#e8def5', '#d9efec', '#f5e9cc']
489const CIRCLED = '①②③④⑤⑥⑦'
490const SECRET = new Set(['heal', 'inspect', 'wolf_vote', 'wolf_pick', 'saved'])
491const STAGE = new Set(['phase', 'heal', 'inspect', 'wolf_vote', 'wolf_pick', 'saved', 'dawn', 'out', 'talk', 'turn', 'speech', 'vote_start', 'vote', 'tally', 'over'])
492const VERB = { vote: '放逐', kill: '刀', heal: '守护', inspect: '查验' }
493const ASKS = { vote: '放逐投票:点击头像,选择要放逐的人', kill: '狼人请选择今晚的目标:点击头像', heal: '选择今晚要守护的人:点击头像', inspect: '选择要查验的人:点击头像' }
494const ACT_COLOR = { heal: 'doctor', saved: 'doctor', inspect: 'seer', vote: 'red' }
495const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
496const ic = (k, c) => `<svg class="ic"${c ? ` style="--c:var(--${c})"` : ''}><use href="#i-${k}"/></svg>`
497const mc = (s, cls = '') => s == null ? '' : `<i class="mc ${cls}">${+s + 1}</i>`
498const no = s => `<em>【${+s + 1}号】</em>`
499const zh = r => ROLE[r]?.zh || '未知'
500const left = s => +s < 4
501function pref(k, d) { try { const v = localStorage.getItem('ww.' + k); return v == null ? d : v === '1' } catch { return d } }
502function keep(k, v) { try { localStorage.setItem('ww.' + k, v ? '1' : '0') } catch {} }
503
504// ---- the game as drawn: the players, the events (in a room, a seat's own news carries _to) and what may be seen ------
505let P = [...Array(7)].map((_, i) => ({ name: `${i + 1}号`, kind: 'bot', role: null }))
506let E = [], faces = [], counts = null
507let you = [], A = null, pick = null, sent = null, ended = false, mine = null, carded = false, overShown = false
508const seenN = new Set()
509let god = live ? false : pref('god', true), snd = pref('sound', false), filter = null
510const godOn = () => god && (!live || ended)
511const seen = e => !SECRET.has(e.kind) || godOn() || (e._to != null && you.includes(String(e._to)))
512const wolfMe = () => you.some(s => P[+s].role === 'Werewolf')
513
514function fold(n) {
515 const blank = () => [...Array(7)].map(() => ({}))
516 const S = { phase: null, day: 0, alive: Array(7).fill(true), fate: Array(7).fill(null), shown: Array(7).fill(null), marks: blank(), know: blank(),
517 picks: Array(7).fill(null), order: [], spoke: [], speaker: null, votes: null, tally: null, note: Array(7).fill(null), turn: null, over: null, saved: null }
518 for (let i = 0; i < n; i++) {
519 const e = E[i]
520 if (!seen(e)) continue
521 if (e.why && e.actor != null) S.note[e.actor] = e.why
522 switch (e.kind) {
523 case 'phase':
524 S.phase = e.phase; S.day = e.day; S.marks = blank(); S.turn = null; S.speaker = null
525 if (e.phase === 'night') { S.picks = Array(7).fill(null); S.votes = null; S.tally = null; S.order = []; S.spoke = []; S.saved = null }
526 break
527 case 'heal': S.marks[e.target].heal = 1; break
528 case 'inspect': S.marks[e.target].eye = e.role; if (you.includes(String(e.actor))) S.know[e.target].eye = e.role; break
529 case 'wolf_vote': S.picks[e.actor] = e.target; break
530 case 'wolf_pick': if (e.target != null) S.marks[e.target].kill = 1; break
531 case 'saved': S.saved = e.target; break
532 case 'out': S.alive[e.who] = false; S.fate[e.who] = e; S.shown[e.who] = e.role; break
533 case 'talk': S.order = e.order || []; S.spoke = []; break
534 case 'turn': S.turn = e; if (e.what === 'chat') S.speaker = e.seat; break
535 case 'speech': S.spoke.push(e.actor); S.speaker = e.actor; S.turn = null; break
536 case 'vote_start': S.votes = {}; S.speaker = null; S.tally = null; break
537 case 'vote': (S.votes ??= {})[e.actor] = e.target; S.turn = null; break
538 case 'tally': S.tally = e; break
539 case 'over': S.over = e; break
540 }
541 }
542 return S
543}
544const fate = f => f ? (f.cause === 'night' ? `夜${f.day + 1} 被刀` : `天${f.day} 放逐`) : ''
545const phaseName = e => e.phase === 'night' ? `第${e.day + 1}夜` : `第${e.day}天`
546const reasonZh = o => !o || !o.winner ? '对局中止' : o.winner === 'Villagers' ? '狼人全部出局' : '狼人数量追平了好人'
547
548// ---- seats ------------------------------------------------------------------------------------------------------
549function buildSeats() {
550 const used = new Set()
551 faces = P.map(p => { // a name keeps its face from game to game; two seats never share one
552 let h = [...String(p.name)].reduce((a, c) => (a * 31 + c.codePointAt(0)) >>> 0, 7) % AV.length
553 while (used.has(h)) h = (h + 1) % AV.length
554 used.add(h); return AV[h]
555 })
556 const seat = s => `<div class="seat" data-s="${s}"><div class="coin" style="--tint:${TINT[s]}"><img class="face" alt="${s + 1}号" src="assets/avatars/${faces[s]}.svg"><span class="num">${s + 1}</span><span class="x"></span></div><div class="name"></div><div class="sub"></div></div>`
557 $('#colL').innerHTML = [0, 1, 2, 3].map(seat).join('')
558 $('#colR').innerHTML = [4, 5, 6].map(seat).join('') + '<div class="config" id="config"></div>'
559 drawConfig()
560}
561function drawConfig() {
562 const c = counts || { Werewolf: 2, Seer: 1, Doctor: 1, Villager: 3 }
563 $('#config').innerHTML = `<small>本局配置</small><div>${['Werewolf', 'Seer', 'Doctor', 'Villager'].map(r => `<span title="${zh(r)}"><img alt="${zh(r)}" src="assets/${ROLE[r].img}.png">×${c[r] ?? 0}</span>`).join('')}</div>`
564}
565function tagOf(S, s) {
566 if (S.shown[s]) return S.shown[s]
567 if (S.over || godOn() || you.includes(String(s))) return P[s].role
568 if (wolfMe() && P[s].role === 'Werewolf') return 'Werewolf'
569 return null
570}
571function drawSeats(S, e) {
572 const night = S.phase === 'night', got = {}
573 if (S.votes) for (const t of Object.values(S.votes)) if (t != null) got[t] = (got[t] || 0) + 1
574 const can = A && A.obs.request !== 'chat' ? new Set(A.legal.map(a => a.target).filter(t => t != null).map(String)) : null
575 for (const el of document.querySelectorAll('.seat')) {
576 const s = +el.dataset.s, p = P[s], tag = tagOf(S, s), m = S.marks[s], k = S.know[s], me = you.includes(String(s))
577 const talking = S.speaker === s && S.phase === 'day' && e && (e.kind === 'speech' || e.kind === 'turn')
578 const hit = e && SECRET.has(e.kind) && seen(e) && e.target === s ? `--c:var(--${ACT_COLOR[e.kind] || 'wolf'});` : ''
579 el.className = 'seat' + (S.alive[s] ? '' : ' dead') + (talking ? ' talk' : '') + (hit ? ' hit' : '') +
580 (can ? (can.has(String(s)) ? ' can' : ' cannot') : '') + (pick === s ? ' picked' : '') + (filter === s ? ' filtered' : '')
581 el.style.cssText = hit + (pick === s ? `--act:var(--${{ heal: 'doctor', inspect: 'seer' }[A?.obs.request] || 'wolf'})` : '')
582 let x = ''
583 if (tag) x += `<span class="tag ${tag}">${zh(tag)}</span>`
584 x += me ? '<span class="kind me">你</span>' : `<span class="kind" title="${{ human: '真人', agent: 'agent', auto: '托管' }[p.kind] || 'bot'}"><img alt="" src="assets/${p.kind === 'human' ? 'person' : 'robot'}.png"></span>`
585 if (got[s]) x += `<span class="vcount">${got[s]}</span>`
586 else if (talking) x += '<img class="mic" alt="" src="assets/mic.png">'
587 const mk = []
588 if (m.kill && S.alive[s]) mk.push(`<span class="mk kill">${ic('claw')}</span>`)
589 if (m.heal) mk.push(`<span class="mk heal">${ic('shield')}</span>`)
590 const eye = m.eye || k.eye
591 if (eye) mk.push(`<span class="mk eye">${ic('eye')}${eye === 'Werewolf' ? '狼' : '好人'}</span>`)
592 if (mk.length) x += `<span class="marks">${mk.join('')}</span>`
593 if (S.fate[s]?.cause === 'night') x += `<span class="claws">${ic('claw')}</span>`
594 if (!S.alive[s]) x += '<span class="stamp">出局</span>'
595 else if (night && !godOn() && !S.over && !(me && A)) x += '<img class="zzz" alt="" src="assets/zzz.png">'
596 if (pick === s) x += '<span class="ok">✓</span>'
597 const slot = el.querySelector('.x')
598 if (slot.dataset.h !== x) { slot.innerHTML = x; slot.dataset.h = x }
599 el.querySelector('.name').textContent = p.name
600 const sub = el.querySelector('.sub')
601 const pk = night && S.picks[s] != null && (godOn() || wolfMe()) ? `刀 → ${S.picks[s] + 1}号` : ''
602 const note = godOn() && S.note[s] && S.alive[s] ? S.note[s] : ''
603 sub.className = 'sub' + (S.fate[s] ? '' : pk ? ' pick' : note ? ' note' : '')
604 sub.textContent = S.fate[s] ? fate(S.fate[s]) : pk || note || ({ human: '真人', agent: 'agent', auto: '托管' }[p.kind] || '')
605 sub.title = note
606 }
607}
608
609// ---- the stage ---------------------------------------------------------------------------------------------------
610function judge(title, body = '', en = '', extra = '') {
611 return `<div class="judge"><div class="jl">${ic('gavel')}法官</div><h3>${title}</h3>${body}${extra}${en ? `<div class="en">${esc(String(en).split('\n')[0])}</div>` : ''}</div>`
612}
613const isCJK = ch => /[⺀-鿿＀-￯]/.test(ch)
614const typeMs = t => Math.min(24000, Math.max(800, [...String(t)].reduce((ms, ch) => ms + (isCJK(ch) ? 25 : 16), 0))) // 40 字/s, 60 letters/s
615function bubble(s, why, wait) {
616 const side = left(s) ? 'L' : 'R'
617 return `<div class="say ${side}"><div class="bubble"><div class="who">${mc(s)}<b>${esc(P[s].name)}</b><span class="lbl">发言中</span></div>` +
618 `<div class="text${wait ? ' wait' : ''}">${wait ? `正在组织语言<span class="dots"><i></i><i></i><i></i></span>` : ''}</div><div class="bar"><i></i></div></div>` +
619 (why ? `<div class="why" title="点开看全文"><img alt="想法" src="assets/thought.png"><span>${esc(why)}</span></div>` : '') + '</div>' +
620 `<img class="portrait ${side}" alt="" src="assets/avatars/${faces[s]}.svg" style="--tint:${TINT[s]}"><span class="pnum ${side}">${s + 1}</span>`
621}
622function board(S, e, fresh) {
623 const votes = S.votes || {}, by = {}
624 for (const [v, t] of Object.entries(votes)) (by[t ?? 'x'] ??= []).push(+v)
625 const rows = Object.entries(by).filter(([t]) => t !== 'x').sort((a, b) => b[1].length - a[1].length)
626 const T = e.kind === 'tally' ? e : null, newest = e.kind === 'vote' && fresh ? e.actor : null
627 const chips = vs => vs.map(v => mc(v, v === newest ? 'new' : '')).join('')
628 let h = `<div class="board${e.kind === 'vote' ? ' still' : ''}"><div class="bh"><img alt="" src="assets/ballot.png">放逐投票<small>依次投票 · 已投 ${Object.keys(votes).length} 人</small></div>`
629 for (const [t, vs] of rows) h += `<div class="br${T && +t === T.exiled ? ' top' : ''}"><span class="tg">${mc(t)}${esc(P[t].name)}</span><span class="ar">←</span><span class="vs">${chips(vs)}</span><b>${vs.length} 票</b></div>`
630 if (by.x) h += `<div class="br"><span class="tg">弃票</span><span class="ar">←</span><span class="vs">${chips(by.x)}</span><b></b></div>`
631 if (!rows.length && !by.x) h += `<div class="br"><span class="tg" style="color:var(--muted)">等待第一张票……</span></div>`
632 if (T) {
633 const top = rows.length ? rows[0][1].length : 0
634 const res = T.exiled == null ? '无人被放逐' : T.tie ? `${T.tie.map(no).join('和')}平票,随机抽中${no(T.exiled)}` : `${no(T.exiled)}被放逐(${top}票)`
635 h += `<div class="res">${res}</div><div class="sum">${rows.map(([t, vs]) => `${vs.map(v => CIRCLED[v]).join('')} → ${CIRCLED[t]}`).join(' ')}</div>`
636 }
637 return h + '</div>'
638}
639function nightCard(e) {
640 const call = { heal: '医生请睁眼', inspect: '预言家请睁眼', wolf_vote: '狼人请睁眼', wolf_pick: '狼人请闭眼', saved: '医生请闭眼' }[e.kind]
641 if (!seen(e)) return judge(call, '<p class="god">上帝视角关着:天亮才知道结果</p>')
642 const line = (c, k, h) => `<div class="act" style="--c:var(--${c})">${ic(k)}${h}</div>`
643 const body = {
644 heal: () => line('doctor', 'shield', `${no(e.actor)}守护了${no(e.target)}`),
645 inspect: () => line('seer', 'eye', `${no(e.actor)}查验${no(e.target)}:<span class="role ${e.role}">${zh(e.role)}</span>`),
646 wolf_vote: () => line('wolf', 'claw', `${no(e.actor)}${e.target == null ? '没有选择' : `选择了${no(e.target)}`}`),
647 wolf_pick: () => line('wolf', 'claw', `狼队选择了${e.target == null ? '空刀' : no(e.target)}`) + (e.tie ? `<p class="god">${e.tie.map(no).join('、')}票数相同,随机定刀</p>` : ''),
648 saved: () => line('doctor', 'shield', `医生守住了${no(e.target)}`),
649 }[e.kind]()
650 return judge(call, body)
651}
652function nightAsk(S) { // a seat woken at night: the judge's call and what this seat knows
653 const w = A.obs.request, me = +A.to
654 if (w === 'kill') {
655 const mates = (A.obs.wolves || []).map(Number).filter(s => s !== me)
656 const picks = mates.filter(s => S.picks[s] != null).map(s => `${no(s)}选择了${no(S.picks[s])}`).join(',')
657 return judge('狼人请睁眼', `<p>点击头像,选择今晚要刀的人</p>${mates.length ? `<p class="god">狼队友:${mates.map(no).join('')}${picks ? `;${picks}` : ''}</p>` : ''}`)
658 }
659 if (w === 'heal') return judge('医生请睁眼', '<p>点击头像,选择今晚要守护的人(不能守自己)</p>')
660 const known = E.filter(e => e.kind === 'inspect' && e._to === A.to).map(e => `${no(e.target)}${e.role === 'Werewolf' ? '是狼人' : '是好人'}`).join(',')
661 return judge('预言家请睁眼', `<p>点击头像,选择要查验的人</p>${known ? `<p class="god">你已查验:${known}</p>` : ''}`)
662}
663function sceneHTML(S, e, i, fresh) {
664 if (live && A && A.obs.phase === 'night') return nightAsk(S)
665 if (!e) return judge('等待开局', '<p>七位玩家:两狼、一预言家、一医生、三平民</p>')
666 const flip = r => `<div class="reveal ${r}"><div><div class="fc front">?</div><div class="fc back ${r}"><img alt="" src="assets/${ROLE[r]?.img || 'villager'}.png"><b>${zh(r)}</b></div></div></div>`
667 switch (e.kind) {
668 case 'phase': {
669 const n = e.phase === 'night'
670 return (fresh ? `<div class="banner${n ? '' : ' d'}"><img alt="" src="assets/${n ? 'moon' : 'sun'}.png">${n ? '天黑请闭眼' : '天亮了,请睁眼'}</div>` : '') +
671 judge(phaseName(e), `<p>${n ? '天黑请闭眼,医生、预言家、狼人依次行动' : '天亮了,请睁眼'}</p>`, e.en)
672 }
673 case 'dawn': {
674 let saved = null
675 for (let j = i - 1; j >= 0 && !(E[j].kind === 'phase' && E[j].phase === 'night'); j--) if (E[j].kind === 'saved' && seen(E[j])) saved = E[j].target
676 return judge(e.killed == null ? '昨晚是平安夜' : `昨晚${no(e.killed)}出局`, saved != null ? `<p class="god">(医生守住了${no(saved)})</p>` : '', e.en)
677 }
678 case 'out': return judge(`${no(e.who)}${e.cause === 'night' ? '出局' : '被放逐'},身份是${zh(e.role)}`, '', e.en, flip(e.role))
679 case 'talk': return judge(e.order?.length ? `从${no(e.order[0])}开始发言` : '开始发言', '<p>按顺序,每人发言一次</p>', e.en)
680 case 'speech': return bubble(e.actor, godOn() ? e.why : '', false)
681 case 'turn': return e.what === 'chat' ? bubble(e.seat, '', true) : board(S, e, fresh)
682 case 'vote_start': return judge('发言结束,开始放逐投票', '<p>依次投票,得票最多的人出局</p>', e.en)
683 case 'vote': case 'tally': return board(S, e, fresh)
684 case 'over': return judge(e.winner ? `${TEAM[e.winner]} 胜利` : '对局中止', `<p>${reasonZh(e)}</p>`, e.en)
685 default: return nightCard(e)
686 }
687}
688function drawStatus(S, e) {
689 const sec = live ? ' <span class="sec" id="sec"></span>' : ''
690 let h
691 if (S.over) h = S.over.winner ? `游戏结束 · ${TEAM[S.over.winner]}胜利` : '游戏结束 · 对局中止'
692 else if (!S.phase) h = '等待开局……'
693 else if (A) h = { chat: '【你】发言中……', vote: '放逐投票中……', kill: '狼人请选择今晚的目标……', heal: '医生请选择守护的人……', inspect: '预言家请选择查验的人……' }[A.obs.request] + sec
694 else if (S.phase === 'night') {
695 const k = e && SECRET.has(e.kind) && seen(e) ? e.kind : null
696 h = k === 'heal' || k === 'saved' ? '医生行动中……' : k === 'inspect' ? '预言家行动中……' : k ? '狼人行动中……' : '其他玩家行动中……'
697 } else if (S.turn) h = `${you.includes(String(S.turn.seat)) ? '【你】' : `【${S.turn.seat + 1}号】`}${S.turn.what === 'chat' ? '发言中……' : '投票中……'}${sec}`
698 else if (e?.kind === 'speech') h = `【${e.actor + 1}号】发言中……`
699 else if (S.votes && !S.tally) h = '放逐投票中……'
700 else if (S.tally) h = '投票结束'
701 else h = '天亮了'
702 $('#status').innerHTML = h
703 const o = $('#order')
704 if (S.phase === 'day' && S.order.length && !S.votes) {
705 o.innerHTML = '<small>发言顺序</small>' + S.order.map(s => `<i class="${s === S.speaker ? 'now' : S.spoke.includes(s) ? 'done' : ''}">${s + 1}</i>`).join('')
706 } else o.innerHTML = ''
707}
708function drawHeader(S, fresh) {
709 const night = S.phase !== 'day', b = $('#badge'), d = S.phase ? (night ? S.day + 1 : S.day) : 1
710 const h = `<img alt="" src="assets/${night ? 'moon' : 'sun'}.png"><span>第</span><b>${d}</b><span>${night ? '夜' : '天'}</span>`
711 if (b.dataset.h !== h) { b.innerHTML = h; b.dataset.h = h; if (fresh) { b.classList.remove('flip'); void b.offsetWidth; b.classList.add('flip') } }
712 app.classList.toggle('night', night); app.classList.toggle('day', !night)
713 let c = `出局 <b>${S.alive.filter(a => !a).length}</b>/7`
714 if (godOn() && P.every(p => p.role)) {
715 const w = P.filter((p, s) => S.alive[s] && p.role === 'Werewolf').length, g = S.alive.filter(Boolean).length - w
716 c += `<span class="sep"> · </span><span class="w">狼 ${w}</span><span class="sep"> · </span><span class="g">好人 ${g}</span>`
717 }
718 $('#count').innerHTML = c
719 const gb = $('#god')
720 gb.setAttribute('aria-pressed', String(godOn())); gb.disabled = live && !ended
721 gb.title = live && !ended ? '上帝视角在对局结束后可用' : '上帝视角:身份、夜里的行动和每个人的想法'
722}
723function drawArrows(e) {
724 const svg = $('#arrows'), show = e && e.actor != null && e.target != null && ((SECRET.has(e.kind) && seen(e)) || e.kind === 'vote')
725 if (!show) { svg.innerHTML = ''; svg.dataset.k = ''; return }
726 const key = E.indexOf(e) + ':' + innerWidth + 'x' + innerHeight
727 if (svg.dataset.k === key) return
728 svg.dataset.k = key
729 const T = $('#table').getBoundingClientRect(), st = $('#stage').getBoundingClientRect()
730 const at = s => { const r = document.querySelector(`.seat[data-s="${s}"] .coin`).getBoundingClientRect(); return [r.left + r.width / 2 - T.left, r.top + r.height / 2 - T.top] }
731 const [x1, y1] = at(e.actor), [x2, y2] = at(e.target), mx = st.left - T.left + st.width / 2
732 const qx = left(e.actor) === left(e.target) ? (left(e.actor) ? Math.min(x1, x2) + st.width * .35 : Math.max(x1, x2) - st.width * .35) : mx
733 const col = `var(--${ACT_COLOR[e.kind] || 'wolf'})`
734 svg.innerHTML = `<defs><marker id="ah" viewBox="0 0 10 10" refX="6" refY="5" markerWidth="4" markerHeight="4" orient="auto-start-reverse"><path d="M0 0L10 5 0 10z" style="fill:${col}"/></marker></defs>` +
735 `<path class="a" pathLength="1" d="M${x1} ${y1}Q${qx} ${(y1 + y2) / 2 - 40} ${x2} ${y2}" style="stroke:${col}" marker-end="url(#ah)"/>`
736}
737
738// ---- the log -------------------------------------------------------------------------------------------------------
739function logHTML(n) {
740 let h = '<h4>记录</h4>'
741 const row = (i, a, cls, body) => `<div class="lg ${cls}" data-i="${i}"${a != null ? ` data-a="${a}"` : ''}>${body}</div>`
742 const g = (c, k) => `<span style="--c:var(--${c})">${ic(k)}</span>`
743 for (let i = 0; i < n; i++) {
744 const e = E[i]
745 if (!seen(e)) continue
746 switch (e.kind) {
747 case 'phase': h += `<div class="lg-div" data-i="${i}">${phaseName(e)}</div>`; break
748 case 'heal': h += row(i, e.actor, 'lg-g', `${g('doctor', 'shield')} ${mc(e.actor)} 守护 ${mc(e.target)}`); break
749 case 'inspect': h += row(i, e.actor, 'lg-g', `${g('seer', 'eye')} ${mc(e.actor)} 查验 ${mc(e.target)}:<span class="r" style="--c:var(--${e.role === 'Werewolf' ? 'wolf' : 'villager'})">${zh(e.role)}</span>`); break
750 case 'wolf_vote': h += row(i, e.actor, 'lg-g', `${g('wolf', 'claw')} ${mc(e.actor)} → ${e.target == null ? '弃权' : mc(e.target)}`); break
751 case 'wolf_pick': h += row(i, null, 'lg-g', `${g('wolf', 'claw')} 狼队选择了 ${e.target == null ? '空刀' : mc(e.target)}${e.tie ? '(平票随机)' : ''}`); break
752 case 'saved': h += row(i, null, 'lg-g', `${g('doctor', 'shield')} 医生守住了 ${mc(e.target)}`); break
753 case 'dawn': if (e.killed == null) h += row(i, null, 'lg-j', '昨晚是平安夜'); break
754 case 'out': h += row(i, e.who, 'lg-j', `${e.cause === 'night' ? `昨晚 ${mc(e.who)} 出局` : `${mc(e.who)} 被放逐`} · ${zh(e.role)}`); break
755 case 'talk': if (e.order?.length) h += row(i, null, 'lg-j', `从 ${mc(e.order[0])} 开始发言`); break
756 case 'speech': h += row(i, e.actor, 'lg-say', `${mc(e.actor)}<div><b>${esc(P[e.actor].name)}</b>:<span class="t">${esc(e.text)}</span>${godOn() && e.why ? `<div class="lg-why">${esc(e.why)}</div>` : ''}</div>`); break
757 case 'vote_start': h += row(i, null, 'lg-j', '开始放逐投票'); break
758 case 'vote': h += row(i, e.actor, 'lg-v', `${mc(e.actor)} → ${e.target == null ? '弃票' : mc(e.target)}`); break
759 case 'tally': h += row(i, null, 'lg-j', e.exiled == null ? '无人被放逐' : `${mc(e.exiled)} 得票最多${e.tie ? '(平票,随机抽中)' : ''}`); break
760 case 'over': h += row(i, null, 'lg-j', e.winner ? `${TEAM[e.winner]}胜利` : '对局中止'); break
761 case 'control': h += row(i, e.seat, 'lg-v', `${mc(e.seat)} 改由 ${esc(e.name)}(${{ human: '真人', agent: 'agent', auto: '托管', bot: 'bot' }[e.who] || e.who})操控`); break
762 }
763 }
764 return h
765}
766function drawLog(n) {
767 const log = $('#log'), key = n + ':' + godOn() + ':' + E.length
768 if (log.dataset.k !== key) {
769 log.dataset.k = key
770 log.innerHTML = logHTML(n)
771 const rows = log.querySelectorAll('[data-i]'), last = rows[rows.length - 1]
772 if (last) { last.classList.add('now'); log.scrollTop = log.scrollHeight }
773 }
774 for (const r of log.querySelectorAll('.lg')) r.classList.toggle('hide', filter != null && r.dataset.a !== String(filter))
775}
776
777// ---- one frame ---------------------------------------------------------------------------------------------------
778let shownN = 0, sceneKey = ''
779function lastScene(n) { for (let i = n - 1; i >= 0; i--) if (STAGE.has(E[i].kind) && (seen(E[i]) || !live)) return i; return -1 }
780function render(n, fresh = false) {
781 shownN = n
782 const S = fold(n), k = lastScene(n), e = E[k]
783 drawHeader(S, fresh)
784 drawSeats(S, e)
785 drawStatus(S, e)
786 const key = [k, godOn(), e?.kind === 'vote' || e?.kind === 'turn' ? n : '', A?.version, innerWidth, innerHeight].join(':')
787 if (key !== sceneKey) {
788 sceneKey = key
789 $('#scene').innerHTML = sceneHTML(S, e, k, fresh)
790 if (e?.kind === 'speech') $('#scene .text').textContent = live || !fresh ? e.text : ''
791 }
792 drawLog(n)
793 drawArrows(live && !fresh ? null : e)
794 if (fresh && e) sfx({ phase: e.phase, vote: 'vote', out: 'out' }[e.kind])
795 drawAct(S)
796}
797
798// ---- overlays ----------------------------------------------------------------------------------------------------
799function roleCard(c) {
800 const r = ROLE[c.role] || ROLE.Villager, mates = (c.wolves || []).filter(w => w !== c.seat)
801 $('#over').innerHTML = `<div class="card ${c.role}"><small>你的身份</small><h2>${r.zh}</h2><div class="art"><img alt="" src="assets/${r.img}.png"></div>` +
802 `<dl><dt>技能</dt><dd>${r.skill}</dd><dt>目标</dt><dd>${r.goal}</dd>${mates.length ? `<dt>狼队友</dt><dd>${mates.map(w => `【${w + 1}号】`).join(' ')}</dd>` : ''}</dl>` +
803 `<button class="btn" data-k="close">知道了</button></div>`
804 $('#over').className = 'on'
805}
806function about() {
807 $('#over').innerHTML = `<div class="card about"><h2>关于这张桌子</h2><p>规则和引擎:Google 的 kaggle-environments Werewolf(Apache-2.0)。</p>` +
808 `<p>角色图和小图标:Microsoft Fluent Emoji(MIT)。头像:DiceBear Notionists,原作 Zoish(CC0)。</p>` +
809 `<p>标记图标:game-icons.net,作者 Lorc、Delapouite(CC BY 3.0)。法官字体:Noto Serif SC 子集(SIL OFL 1.1)。</p>` +
810 `<p>详见 viewer/NOTICE。</p><div style="text-align:center"><button class="btn" data-k="close">好的</button></div></div>`
811 $('#over').className = 'on'
812}
813function endScreen(over) {
814 const S = fold(E.length), w = over.winner
815 const mineWon = you.length && w && you.some(s => ROLE[over.roles?.[+s] || P[+s].role]?.team === w)
816 const cards = P.map((p, s) => {
817 const r = over.roles?.[s] || p.role, won = w && ROLE[r]?.team === w
818 return `<div class="ec ${won ? 'win' : w ? 'lose' : ''} ${r}${you.includes(String(s)) ? ' me' : ''}"><img class="face" alt="" src="assets/avatars/${faces[s]}.svg" style="--tint:${TINT[s]}"><img class="rimg" alt="" src="assets/${ROLE[r]?.img || 'villager'}.png">` +
819 `<div class="n">${s + 1}号</div><b>${esc(p.name)}</b><span class="tg">${zh(r)}</span><small>${S.fate[s] ? fate(S.fate[s]) : '存活'}</small></div>`
820 }).join('')
821 $('#over').innerHTML = `<div class="end ${w || ''}"><div class="hero"><div class="rays"></div><img alt="" src="assets/${w === 'Werewolves' ? 'werewolf' : w ? 'villager' : 'zzz'}.png"></div>` +
822 `<h1>${w ? `${TEAM[w]} 胜利` : '对局中止'}</h1><p class="rs">${reasonZh(over)}</p>` +
823 (live && you.length && w ? `<p class="you${mineWon ? '' : ' lost'}">${mineWon ? '你赢了' : '你输了'}</p>` : '') +
824 `<div class="cards">${cards}</div><div class="btns">${live ? '' : '<button class="btn" data-k="review">看复盘</button>'}<button class="btn ghost" data-k="close">看看桌面</button></div></div>`
825 $('#over').className = 'on'
826 sfx(w ? (you.length && !mineWon ? 'lose' : 'win') : null)
827}
828$('#over').addEventListener('click', ev => {
829 const k = ev.target.closest('[data-k]')?.dataset.k
830 if (!k) return
831 $('#over').className = ''
832 if (k === 'review') { god = true; keep('god', true); sceneKey = ''; go(0); play() }
833})
834
835// ---- sound: off by default, WebAudio, nothing to load ---------------------------------------------------------------
836let ac = null
837function sfx(k) {
838 const N = { night: [[196, 0, 1.4, 'sine'], [147, .06, 1.6, 'sine']], day: [[880, 0, .9, 'triangle'], [1320, .14, .9, 'triangle']], vote: [[1100, 0, .05, 'square']],
839 out: [[90, 0, .5, 'sine'], [60, .02, .6, 'sine']], turn: [[1250, 0, .25, 'sine'], [1660, .12, .3, 'sine']],
840 win: [[523, 0, .3, 'triangle'], [659, .15, .3, 'triangle'], [784, .3, .7, 'triangle']], lose: [[233, 0, 1.1, 'sawtooth'], [175, 0, 1.1, 'sine']] }[k]
841 if (!snd || !N) return
842 try {
843 ac ??= new AudioContext()
844 const t = ac.currentTime
845 for (const [f, d, len, type] of N) {
846 const o = ac.createOscillator(), g = ac.createGain()
847 o.type = type; o.frequency.value = f
848 g.gain.setValueAtTime(.0001, t + d); g.gain.exponentialRampToValueAtTime(type === 'square' ? .05 : .16, t + d + .02); g.gain.exponentialRampToValueAtTime(.0001, t + d + len)
849 o.connect(g).connect(ac.destination); o.start(t + d); o.stop(t + d + len + .05)
850 }
851 } catch {}
852}
853
854// ---- a replay: one beat per event, a timeline split by nights and days, speed ----------------------------------------
855let B = [], at = 0, t0 = 0, playing = false, speed = 1, raf = 0, segs = []
856const SPEEDS = [0.5, 1, 2, 4]
857function dur(b) {
858 const e = E[B[b]]
859 if (!e) return 1000
860 if (e.kind === 'speech') return typeMs(e.text) + 1400
861 if (SECRET.has(e.kind)) return godOn() ? 1400 : 600
862 return { phase: 1900, vote: 700, tally: 2800, dawn: 2100, out: 2100, over: 2500 }[e.kind] || 1500
863}
864function replayBar() {
865 $('#foot').innerHTML = `<div class="ctl"><button class="prev" data-go="prev" title="上一步">${ic('prev')}</button><button class="play" data-go="play" title="播放 / 暂停">${ic('play')}</button>` +
866 `<button class="next" data-go="next" title="下一步">${ic('next')}</button></div><button class="spd" id="spd" title="速度">1×</button><div class="tl" id="tl"></div>`
867 segs = []
868 B.forEach((i, b) => { if (E[i].kind === 'phase' || !segs.length) segs.push({ from: b, e: E[i], w: 0 }); segs.at(-1).w += dur(b) })
869 segs.forEach((s, k) => { s.to = k + 1 < segs.length ? segs[k + 1].from : B.length })
870 $('#tl').innerHTML = segs.map((s, k) => `<div class="seg ${s.e.phase === 'day' ? 'd' : 'n'}" data-k="${k}" style="--w:${Math.max(1, s.w / 1000).toFixed(1)}"><i></i><span>${s.e.kind === 'phase' ? (s.e.phase === 'night' ? `夜${s.e.day + 1}` : `天${s.e.day}`) : '开局'}</span></div>`).join('')
871 $('#foot').addEventListener('click', ev => {
872 const k = ev.target.closest('[data-go]')?.dataset.go
873 if (k === 'play') return playing ? pause() : play()
874 if (k === 'prev') { pause(); go(at - 1) }
875 if (k === 'next') { pause(); go(at + 1) }
876 })
877 $('#spd').onclick = () => { speed = SPEEDS[(SPEEDS.indexOf(speed) + 1) % SPEEDS.length]; $('#spd').textContent = speed + '×'; document.documentElement.style.setProperty('--spd', speed) }
878 const tl = $('#tl'), seek = ev => {
879 const seg = document.elementFromPoint(ev.clientX, ev.clientY)?.closest('.seg')
880 if (!seg) return
881 const s = segs[+seg.dataset.k], r = seg.getBoundingClientRect(), f = Math.min(.999, Math.max(0, (ev.clientX - r.left) / r.width))
882 const b = s.from + Math.floor(f * (s.to - s.from))
883 if (b !== at) go(b)
884 }
885 tl.addEventListener('pointerdown', ev => { pause(); tl.setPointerCapture(ev.pointerId); seek(ev); tl.onpointermove = seek })
886 tl.addEventListener('pointerup', () => { tl.onpointermove = null })
887}
888function drawTimeline(p) {
889 const k = segs.findIndex(s => at >= s.from && at < s.to)
890 document.querySelectorAll('.seg').forEach((el, j) => {
891 el.classList.toggle('past', j < k); el.classList.toggle('cur', j === k)
892 el.querySelector('i').style.width = j === k ? `${((at - segs[j].from + p) / (segs[j].to - segs[j].from) * 100).toFixed(1)}%` : ''
893 })
894}
895function go(b, fresh = false) {
896 at = Math.max(0, Math.min(B.length - 1, b)); t0 = performance.now()
897 $('#over').className = ''
898 render(B[at] + 1, fresh)
899 tick(fresh ? 0 : 1)
900 if (E[B[at]]?.kind === 'over') setTimeout(() => { if (E[B[at]]?.kind === 'over') endScreen(E[B[at]]) }, fresh ? 1400 / speed : 300)
901}
902function tick(p) {
903 const e = E[B[at]]
904 if (e?.kind === 'speech') {
905 const full = typeMs(e.text), q = Math.min(1, p * (full + 1400) / full), chars = [...e.text]
906 const t = $('#scene .text'); if (t) t.textContent = chars.slice(0, Math.ceil(chars.length * q)).join('')
907 const bar = $('#scene .bar i'); if (bar) bar.style.width = (p * 100).toFixed(1) + '%'
908 const lbl = $('#scene .lbl'); if (lbl) lbl.textContent = q < 1 ? '发言中' : '发言结束'
909 }
910 drawTimeline(p)
911}
912function frame(now) {
913 if (!playing) return
914 const p = Math.min(1, (now - t0) / (dur(at) / speed))
915 tick(p)
916 if (p >= 1) { if (at >= B.length - 1) return pause(); go(at + 1, true) }
917 raf = requestAnimationFrame(frame)
918}
919function play() {
920 if (at >= B.length - 1) go(0)
921 playing = true; t0 = performance.now()
922 $('[data-go=play]').innerHTML = ic('pause')
923 cancelAnimationFrame(raf); raf = requestAnimationFrame(frame)
924}
925function pause() {
926 playing = false; cancelAnimationFrame(raf)
927 const b = $('[data-go=play]'); if (b) b.innerHTML = ic('play')
928}
929$('#scene').addEventListener('click', ev => {
930 const why = ev.target.closest('.why'); if (why) return why.classList.toggle('open')
931 const e = E[B[at]]
932 if (!live && e?.kind === 'speech') { const full = typeMs(e.text) / speed; if (performance.now() - t0 < full) t0 = performance.now() - full; if (!playing) tick(1) }
933})
934addEventListener('keydown', ev => {
935 if (live || ev.target.closest?.('textarea')) return
936 if (ev.key === ' ') { ev.preventDefault(); playing ? pause() : play() }
937 if (ev.key === 'ArrowRight') { pause(); go(at + 1) }
938 if (ev.key === 'ArrowLeft') { pause(); go(at - 1) }
939})
940
941// ---- a live room: the seat's role card, what it is asked, its answers ----------------------------------------------
942function take(batch) {
943 you = (batch.you || []).map(String)
944 const before = E.length, first = !before, now = performance.now()
945 let asked = null, overNow = null
946 for (const m of batch.messages || []) {
947 if (m.n != null) { if (seenN.has(m.n)) continue; seenN.add(m.n) }
948 if (m.event) {
949 let e = { ...m.event, _n: m.n, _at: now }
950 if (e.type === 'control') { // kit/room.py's handover: kind there is the new controller's
951 e = { kind: 'control', seat: +e.seat, who: m.event.kind, name: m.event.name, _n: m.n, _at: now }
952 Object.assign(P[e.seat], { name: e.name, kind: e.who })
953 }
954 if (e.kind === 'start') { counts = e.roles; for (const [s, x] of Object.entries(e.seats || {})) Object.assign(P[+s], { name: x.name, kind: x.kind }) }
955 if (e.kind === 'over') { ended = true; overNow = e; (e.roles || []).forEach((r, s) => { if (r) P[s].role = r }) }
956 E.push(e)
957 if (A && (e.actor === +A.to || e.kind === 'phase' || e.kind === 'over')) A = null
958 if (sent && (e.actor === +sent.to || e.kind === 'phase')) sent = null
959 } else if (m.legal) {
960 if (you.includes(String(m.to))) { A = { ...m, at: now }; pick = null; sent = null; asked = A }
961 } else if (m.obs && m.to != null) {
962 const e = { ...m.obs, _to: String(m.to), _n: m.n, _at: now }
963 if (e.kind === 'role') { P[e.seat].role = e.role; for (const w of e.wolves || []) P[w].role = 'Werewolf'; if (you.includes(e._to)) mine = e }
964 E.push(e)
965 if (A && e.actor === +A.to && e._to === A.to) A = null
966 } else if (m.end) { ended = true; A = null }
967 }
968 E.sort((a, b) => (a._n ?? 0) - (b._n ?? 0)) // a seat's news revealed after the end slots back in its place
969 if (A && !you.includes(String(A.to))) A = null
970 if (first) buildSeats()
971 drawConfig()
972 $('#mode').textContent = `· 直播 · ${you.length ? `你是${+you[0] + 1}号` : '观战'}`
973 render(E.length, !first && E.length - before <= 4)
974 if (overNow && !overShown) { overShown = true; setTimeout(() => endScreen(overNow), first ? 0 : 900) }
975 else if (mine && !carded && !ended) { carded = true; roleCard(mine) }
976 if (asked && !first) { sfx('turn'); flashTitle() }
977}
978let flashT = 0
979function flashTitle() {
980 clearInterval(flashT); let k = 0
981 flashT = setInterval(() => { document.title = k++ % 2 ? '狼人杀' : '轮到你了'; if (k > 9 || !A) { clearInterval(flashT); document.title = '狼人杀' } }, 700)
982}
983let actKey = ''
984function drawAct(S) {
985 if (!live) return
986 const f = $('#foot'), me = you[0], dead = me != null && !S.alive[+me]
987 const key = [ended, A?.version, pick, sent?.version, dead, S.phase, S.turn?._n, you.join()].join('|')
988 if (key === actKey) return
989 actKey = key
990 const msg = h => `<div class="act"><div class="msg">${h}</div></div>`
991 if (ended) f.innerHTML = msg('对局结束 · 上帝视角已可用')
992 else if (A && A.obs.request === 'chat') {
993 const old = $('#say')?.value || ''
994 f.innerHTML = `<div class="act"><div class="clock" id="clock"><svg viewBox="0 0 36 36"><circle class="bg" cx="18" cy="18" r="15"/><circle class="fg" cx="18" cy="18" r="15" stroke-dasharray="94.25"/></svg><span></span></div>` +
995 `<textarea id="say" maxlength="600" placeholder="说点什么……(Enter 发送,Shift+Enter 换行)"></textarea><button class="btn ghost" id="pass" title="不发言,直接过">过</button><button class="btn" id="send">发送</button></div>`
996 const t = $('#say'); t.value = old; t.focus()
997 t.onkeydown = ev => { if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) { ev.preventDefault(); answer({ type: 'chat', message: t.value }) } }
998 $('#send').onclick = () => answer({ type: 'chat', message: t.value })
999 $('#pass').onclick = () => answer({ type: 'chat', message: '' })
1000 } else if (A) {
1001 const w = A.obs.request
1002 if (pick == null) f.innerHTML = `<div class="act"><div class="clock" id="clock"><svg viewBox="0 0 36 36"><circle class="bg" cx="18" cy="18" r="15"/><circle class="fg" cx="18" cy="18" r="15" stroke-dasharray="94.25"/></svg><span></span></div><div class="msg">${ASKS[w]}</div>${w === 'vote' ? '<button class="btn ghost" id="abst">弃票</button>' : ''}</div>`
1003 else f.innerHTML = `<div class="act"><div class="msg">${VERB[w]}${no(pick)}${esc(P[pick].name)}?</div><button class="btn ghost" id="redo">换人</button><button class="btn${w === 'vote' || w === 'kill' ? ' red' : ''}" id="yes">确认${w === 'vote' ? '投票' : ''}</button></div>`
1004 if ($('#abst')) $('#abst').onclick = () => answer({ type: 'vote', target: null })
1005 if ($('#redo')) $('#redo').onclick = () => { pick = null; render(E.length) }
1006 if ($('#yes')) $('#yes').onclick = () => answer(A.legal.find(a => a.target != null && +a.target === pick))
1007 } else if (sent) f.innerHTML = msg(sent.type === 'chat' ? '发言已送出,等待其他人……' : `已${VERB[sent.type]}${sent.target == null ? '(弃票)' : no(sent.target)},等待其他人……`)
1008 else if (!you.length) f.innerHTML = msg(S.phase === 'night' ? '天黑了 · 观战中,其他玩家行动中……' : '观战中 · 上帝视角在对局结束后可用')
1009 else if (dead) f.innerHTML = msg('你已经出局,可以继续观看')
1010 else if (S.phase === 'night') f.innerHTML = msg(P[+me].role === 'Villager' ? '天黑了,你闭着眼……' : '其他玩家行动中……')
1011 else f.innerHTML = msg(S.turn ? `等待${no(S.turn.seat)}${S.turn.what === 'chat' ? '发言' : '投票'}……` : '等待其他玩家……')
1012 clock()
1013}
1014function answer(action) {
1015 if (!A || !action) return
1016 shoalAct(A.to, A.version, action)
1017 sent = { ...action, to: A.to, version: A.version }
1018 A = null; pick = null
1019 render(E.length)
1020}
1021function clock() { // a countdown from when the request (or a person's turn) arrived
1022 let deadline = null, total = 30
1023 if (A) { total = A.obs.seconds || 30; deadline = A.at + total * 1000 }
1024 else {
1025 const i = E.findLastIndex(e => e.kind === 'turn')
1026 if (i >= 0 && !E.slice(i + 1).some(e => ['speech', 'vote', 'phase', 'over'].includes(e.kind))) { total = E[i].seconds || 30; deadline = E[i]._at + total * 1000 }
1027 }
1028 const sec = deadline == null ? null : Math.max(0, Math.ceil((deadline - performance.now()) / 1000))
1029 const el = $('#sec')
1030 if (el) { el.textContent = sec == null ? '' : sec + '秒'; el.className = 'sec' + (sec != null && sec <= 5 ? ' hot' : sec != null && sec <= 10 ? ' warn' : '') }
1031 const c = $('#clock')
1032 if (c && sec != null) { c.querySelector('span').textContent = sec; c.querySelector('.fg').style.strokeDashoffset = (94.25 * (1 - sec / total)).toFixed(1) }
1033}
1034
1035// ---- input -------------------------------------------------------------------------------------------------------
1036$('#table').addEventListener('click', ev => {
1037 const seat = ev.target.closest('.seat'); if (!seat) return
1038 const s = +seat.dataset.s
1039 if (A && A.obs.request !== 'chat') {
1040 if (A.legal.some(a => a.target != null && +a.target === s)) { pick = s; render(E.length) }
1041 return
1042 }
1043 filter = filter === s ? null : s
1044 render(shownN)
1045})
1046$('#log').addEventListener('click', ev => {
1047 const say = ev.target.closest('.lg-say'); if (say) say.classList.toggle('open')
1048 const r = ev.target.closest('[data-i]')
1049 if (!live && r && B.length) { const b = B.findIndex(i => i >= +r.dataset.i); if (b >= 0) { pause(); go(b) } }
1050})
1051$('#god').onclick = () => { if (live && !ended) return; god = !god; if (!live) keep('god', god); sceneKey = ''; render(shownN); if (!live) tick(1) }
1052$('#snd').onclick = () => { snd = !snd; keep('sound', snd); $('#snd').innerHTML = `<svg class="ic"><use href="#i-${snd ? 'sound' : 'mute'}"/></svg>`; sfx('turn') }
1053$('#about').onclick = about
1054$('#tabs').onclick = ev => {
1055 const t = ev.target.closest('[data-t]')?.dataset.t; if (!t) return
1056 app.classList.toggle('logtab', t === 'log')
1057 for (const b of document.querySelectorAll('#tabs button')) b.setAttribute('aria-pressed', String(b.dataset.t === t))
1058}
1059addEventListener('resize', () => { sceneKey = ''; $('#arrows').dataset.k = ''; render(shownN); if (!live && B.length) tick(1) })
1060
1061$('#snd').innerHTML = `<svg class="ic"><use href="#i-${snd ? 'sound' : 'mute'}"/></svg>`
1062buildSeats()
1063render(0)
1064if (live) { $('#mode').textContent = '· 直播'; shoalLive(take); setInterval(clock, 250) }
1065else shoalReplay(text => {
1066 let R = null
1067 try { R = JSON.parse(text) } catch {}
1068 if (!R || !Array.isArray(R.events) || !R.version) { $('#scene').innerHTML = '<div class="oldnote">这份回放是旧格式,牌桌放不了。</div>'; return }
1069 P = R.players.map(p => ({ name: p.name || `${p.seat + 1}号`, kind: p.kind || 'bot', role: p.role }))
1070 E = R.events
1071 if (!E.some(e => e.kind === 'over')) E.push({ kind: 'over', winner: R.winner || null, reason: R.reason || 'stopped', roles: P.map(p => p.role) })
1072 counts = P.reduce((m, p) => (m[p.role] = (m[p.role] || 0) + 1, m), {})
1073 B = E.map((e, i) => STAGE.has(e.kind) ? i : -1).filter(i => i >= 0)
1074 buildSeats(); replayBar()
1075 $('#mode').textContent = '· 回放 · 7人局'
1076 go(0); play()
1077})
1078</script>
1079</body>
1080</html>