实时对局选你的对手
加入
9cd2307
1084 行 · 88.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.all_abstained || (!top && Object.keys(votes).length)) ? `全部弃票,按本桌规则随机放逐${no(T.exiled)}` : 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.all_abstained ? '<p class="god">全部弃票,按本桌规则随机定刀</p>' : 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>kaggle-environments 1.33.0 七人默认变体:2 狼人、1 预言家、1 医生、3 平民。</p>` +
808 `<p>夜间狼队依次投票,平票随机定刀;医生不能自守,可以连续守同一人;预言家查验真实身份。</p>` +
809 `<p>白天每人依次发言一轮,再依次投票。最高票出局,平票随机;全部弃票仍随机放逐一人。出局时公布身份。狼人全灭则好人胜,狼人数量追平好人则狼人胜。</p>` +
810 `<p>本桌不等同于 Kaggle Game Arena 当前的八人规则,也不是女巫、猎人或警长模式。固定电脑按可见证据行动,发言中的身份声明可能是伪装。</p>` +
811 `<p>引擎:Google kaggle-environments(Apache-2.0)。</p>` +
812 `<p>角色图和小图标:Microsoft Fluent Emoji(MIT)。头像:DiceBear Notionists,原作 Zoish(CC0)。</p>` +
813 `<p>标记图标:game-icons.net,作者 Lorc、Delapouite(CC BY 3.0)。法官字体:Noto Serif SC 子集(SIL OFL 1.1)。</p>` +
814 `<p>详见 viewer/NOTICE。</p><div style="text-align:center"><button class="btn" data-k="close">好的</button></div></div>`
815 $('#over').className = 'on'
816}
817function endScreen(over) {
818 const S = fold(E.length), w = over.winner
819 const mineWon = you.length && w && you.some(s => ROLE[over.roles?.[+s] || P[+s].role]?.team === w)
820 const cards = P.map((p, s) => {
821 const r = over.roles?.[s] || p.role, won = w && ROLE[r]?.team === w
822 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">` +
823 `<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>`
824 }).join('')
825 $('#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>` +
826 `<h1>${w ? `${TEAM[w]} 胜利` : '对局中止'}</h1><p class="rs">${reasonZh(over)}</p>` +
827 (live && you.length && w ? `<p class="you${mineWon ? '' : ' lost'}">${mineWon ? '你赢了' : '你输了'}</p>` : '') +
828 `<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>`
829 $('#over').className = 'on'
830 sfx(w ? (you.length && !mineWon ? 'lose' : 'win') : null)
831}
832$('#over').addEventListener('click', ev => {
833 const k = ev.target.closest('[data-k]')?.dataset.k
834 if (!k) return
835 $('#over').className = ''
836 if (k === 'review') { god = true; keep('god', true); sceneKey = ''; go(0); play() }
837})
838
839// ---- sound: off by default, WebAudio, nothing to load ---------------------------------------------------------------
840let ac = null
841function sfx(k) {
842 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']],
843 out: [[90, 0, .5, 'sine'], [60, .02, .6, 'sine']], turn: [[1250, 0, .25, 'sine'], [1660, .12, .3, 'sine']],
844 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]
845 if (!snd || !N) return
846 try {
847 ac ??= new AudioContext()
848 const t = ac.currentTime
849 for (const [f, d, len, type] of N) {
850 const o = ac.createOscillator(), g = ac.createGain()
851 o.type = type; o.frequency.value = f
852 g.gain.setValueAtTime(.0001, t + d); g.gain.exponentialRampToValueAtTime(type === 'square' ? .05 : .16, t + d + .02); g.gain.exponentialRampToValueAtTime(.0001, t + d + len)
853 o.connect(g).connect(ac.destination); o.start(t + d); o.stop(t + d + len + .05)
854 }
855 } catch {}
856}
857
858// ---- a replay: one beat per event, a timeline split by nights and days, speed ----------------------------------------
859let B = [], at = 0, t0 = 0, playing = false, speed = 1, raf = 0, segs = []
860const SPEEDS = [0.5, 1, 2, 4]
861function dur(b) {
862 const e = E[B[b]]
863 if (!e) return 1000
864 if (e.kind === 'speech') return typeMs(e.text) + 1400
865 if (SECRET.has(e.kind)) return godOn() ? 1400 : 600
866 return { phase: 1900, vote: 700, tally: 2800, dawn: 2100, out: 2100, over: 2500 }[e.kind] || 1500
867}
868function replayBar() {
869 $('#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>` +
870 `<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>`
871 segs = []
872 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) })
873 segs.forEach((s, k) => { s.to = k + 1 < segs.length ? segs[k + 1].from : B.length })
874 $('#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('')
875 $('#foot').addEventListener('click', ev => {
876 const k = ev.target.closest('[data-go]')?.dataset.go
877 if (k === 'play') return playing ? pause() : play()
878 if (k === 'prev') { pause(); go(at - 1) }
879 if (k === 'next') { pause(); go(at + 1) }
880 })
881 $('#spd').onclick = () => { speed = SPEEDS[(SPEEDS.indexOf(speed) + 1) % SPEEDS.length]; $('#spd').textContent = speed + '×'; document.documentElement.style.setProperty('--spd', speed) }
882 const tl = $('#tl'), seek = ev => {
883 const seg = document.elementFromPoint(ev.clientX, ev.clientY)?.closest('.seg')
884 if (!seg) return
885 const s = segs[+seg.dataset.k], r = seg.getBoundingClientRect(), f = Math.min(.999, Math.max(0, (ev.clientX - r.left) / r.width))
886 const b = s.from + Math.floor(f * (s.to - s.from))
887 if (b !== at) go(b)
888 }
889 tl.addEventListener('pointerdown', ev => { pause(); tl.setPointerCapture(ev.pointerId); seek(ev); tl.onpointermove = seek })
890 tl.addEventListener('pointerup', () => { tl.onpointermove = null })
891}
892function drawTimeline(p) {
893 const k = segs.findIndex(s => at >= s.from && at < s.to)
894 document.querySelectorAll('.seg').forEach((el, j) => {
895 el.classList.toggle('past', j < k); el.classList.toggle('cur', j === k)
896 el.querySelector('i').style.width = j === k ? `${((at - segs[j].from + p) / (segs[j].to - segs[j].from) * 100).toFixed(1)}%` : ''
897 })
898}
899function go(b, fresh = false) {
900 at = Math.max(0, Math.min(B.length - 1, b)); t0 = performance.now()
901 $('#over').className = ''
902 render(B[at] + 1, fresh)
903 tick(fresh ? 0 : 1)
904 if (E[B[at]]?.kind === 'over') setTimeout(() => { if (E[B[at]]?.kind === 'over') endScreen(E[B[at]]) }, fresh ? 1400 / speed : 300)
905}
906function tick(p) {
907 const e = E[B[at]]
908 if (e?.kind === 'speech') {
909 const full = typeMs(e.text), q = Math.min(1, p * (full + 1400) / full), chars = [...e.text]
910 const t = $('#scene .text'); if (t) t.textContent = chars.slice(0, Math.ceil(chars.length * q)).join('')
911 const bar = $('#scene .bar i'); if (bar) bar.style.width = (p * 100).toFixed(1) + '%'
912 const lbl = $('#scene .lbl'); if (lbl) lbl.textContent = q < 1 ? '发言中' : '发言结束'
913 }
914 drawTimeline(p)
915}
916function frame(now) {
917 if (!playing) return
918 const p = Math.min(1, (now - t0) / (dur(at) / speed))
919 tick(p)
920 if (p >= 1) { if (at >= B.length - 1) return pause(); go(at + 1, true) }
921 raf = requestAnimationFrame(frame)
922}
923function play() {
924 if (at >= B.length - 1) go(0)
925 playing = true; t0 = performance.now()
926 $('[data-go=play]').innerHTML = ic('pause')
927 cancelAnimationFrame(raf); raf = requestAnimationFrame(frame)
928}
929function pause() {
930 playing = false; cancelAnimationFrame(raf)
931 const b = $('[data-go=play]'); if (b) b.innerHTML = ic('play')
932}
933$('#scene').addEventListener('click', ev => {
934 const why = ev.target.closest('.why'); if (why) return why.classList.toggle('open')
935 const e = E[B[at]]
936 if (!live && e?.kind === 'speech') { const full = typeMs(e.text) / speed; if (performance.now() - t0 < full) t0 = performance.now() - full; if (!playing) tick(1) }
937})
938addEventListener('keydown', ev => {
939 if (live || ev.target.closest?.('textarea')) return
940 if (ev.key === ' ') { ev.preventDefault(); playing ? pause() : play() }
941 if (ev.key === 'ArrowRight') { pause(); go(at + 1) }
942 if (ev.key === 'ArrowLeft') { pause(); go(at - 1) }
943})
944
945// ---- a live room: the seat's role card, what it is asked, its answers ----------------------------------------------
946function take(batch) {
947 you = (batch.you || []).map(String)
948 const before = E.length, first = !before, now = performance.now()
949 let asked = null, overNow = null
950 for (const m of batch.messages || []) {
951 if (m.n != null) { if (seenN.has(m.n)) continue; seenN.add(m.n) }
952 if (m.event) {
953 let e = { ...m.event, _n: m.n, _at: now }
954 if (e.type === 'control') { // kit/room.py's handover: kind there is the new controller's
955 e = { kind: 'control', seat: +e.seat, who: m.event.kind, name: m.event.name, _n: m.n, _at: now }
956 Object.assign(P[e.seat], { name: e.name, kind: e.who })
957 }
958 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 }) }
959 if (e.kind === 'over') { ended = true; overNow = e; (e.roles || []).forEach((r, s) => { if (r) P[s].role = r }) }
960 E.push(e)
961 if (A && (e.actor === +A.to || e.kind === 'phase' || e.kind === 'over')) A = null
962 if (sent && (e.actor === +sent.to || e.kind === 'phase')) sent = null
963 } else if (m.legal) {
964 if (you.includes(String(m.to))) { A = { ...m, at: now }; pick = null; sent = null; asked = A }
965 } else if (m.obs && m.to != null) {
966 const e = { ...m.obs, _to: String(m.to), _n: m.n, _at: now }
967 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 }
968 E.push(e)
969 if (A && e.actor === +A.to && e._to === A.to) A = null
970 } else if (m.end) { ended = true; A = null }
971 }
972 E.sort((a, b) => (a._n ?? 0) - (b._n ?? 0)) // a seat's news revealed after the end slots back in its place
973 if (A && !you.includes(String(A.to))) A = null
974 if (first) buildSeats()
975 drawConfig()
976 $('#mode').textContent = `· 直播 · ${you.length ? `你是${+you[0] + 1}号` : '观战'}`
977 render(E.length, !first && E.length - before <= 4)
978 if (overNow && !overShown) { overShown = true; setTimeout(() => endScreen(overNow), first ? 0 : 900) }
979 else if (mine && !carded && !ended) { carded = true; roleCard(mine) }
980 if (asked && !first) { sfx('turn'); flashTitle() }
981}
982let flashT = 0
983function flashTitle() {
984 clearInterval(flashT); let k = 0
985 flashT = setInterval(() => { document.title = k++ % 2 ? '狼人杀' : '轮到你了'; if (k > 9 || !A) { clearInterval(flashT); document.title = '狼人杀' } }, 700)
986}
987let actKey = ''
988function drawAct(S) {
989 if (!live) return
990 const f = $('#foot'), me = you[0], dead = me != null && !S.alive[+me]
991 const key = [ended, A?.version, pick, sent?.version, dead, S.phase, S.turn?._n, you.join()].join('|')
992 if (key === actKey) return
993 actKey = key
994 const msg = h => `<div class="act"><div class="msg">${h}</div></div>`
995 if (ended) f.innerHTML = msg('对局结束 · 上帝视角已可用')
996 else if (A && A.obs.request === 'chat') {
997 const old = $('#say')?.value || ''
998 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>` +
999 `<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>`
1000 const t = $('#say'); t.value = old; t.focus()
1001 t.onkeydown = ev => { if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) { ev.preventDefault(); answer({ type: 'chat', message: t.value }) } }
1002 $('#send').onclick = () => answer({ type: 'chat', message: t.value })
1003 $('#pass').onclick = () => answer({ type: 'chat', message: '' })
1004 } else if (A) {
1005 const w = A.obs.request
1006 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>${A.legal.some(a => Object.hasOwn(a, 'target') && a.target == null) ? '<button class="btn ghost" id="abst" title="全部弃票仍随机选择一人">弃票</button>' : ''}</div>`
1007 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>`
1008 if ($('#abst')) $('#abst').onclick = () => answer({ type: w, target: null })
1009 if ($('#redo')) $('#redo').onclick = () => { pick = null; render(E.length) }
1010 if ($('#yes')) $('#yes').onclick = () => answer(A.legal.find(a => a.target != null && +a.target === pick))
1011 } else if (sent) f.innerHTML = msg(sent.type === 'chat' ? '发言已送出,等待其他人……' : `已${VERB[sent.type]}${sent.target == null ? '(弃票)' : no(sent.target)},等待其他人……`)
1012 else if (!you.length) f.innerHTML = msg(S.phase === 'night' ? '天黑了 · 观战中,其他玩家行动中……' : '观战中 · 上帝视角在对局结束后可用')
1013 else if (dead) f.innerHTML = msg('你已经出局,可以继续观看')
1014 else if (S.phase === 'night') f.innerHTML = msg(P[+me].role === 'Villager' ? '天黑了,你闭着眼……' : '其他玩家行动中……')
1015 else f.innerHTML = msg(S.turn ? `等待${no(S.turn.seat)}${S.turn.what === 'chat' ? '发言' : '投票'}……` : '等待其他玩家……')
1016 clock()
1017}
1018function answer(action) {
1019 if (!A || !action) return
1020 shoalAct(A.to, A.version, action)
1021 sent = { ...action, to: A.to, version: A.version }
1022 A = null; pick = null
1023 render(E.length)
1024}
1025function clock() { // a countdown from when the request (or a person's turn) arrived
1026 let deadline = null, total = 30
1027 if (A) { total = A.obs.seconds || 30; deadline = A.at + total * 1000 }
1028 else {
1029 const i = E.findLastIndex(e => e.kind === 'turn')
1030 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 }
1031 }
1032 const sec = deadline == null ? null : Math.max(0, Math.ceil((deadline - performance.now()) / 1000))
1033 const el = $('#sec')
1034 if (el) { el.textContent = sec == null ? '' : sec + '秒'; el.className = 'sec' + (sec != null && sec <= 5 ? ' hot' : sec != null && sec <= 10 ? ' warn' : '') }
1035 const c = $('#clock')
1036 if (c && sec != null) { c.querySelector('span').textContent = sec; c.querySelector('.fg').style.strokeDashoffset = (94.25 * (1 - sec / total)).toFixed(1) }
1037}
1038
1039// ---- input -------------------------------------------------------------------------------------------------------
1040$('#table').addEventListener('click', ev => {
1041 const seat = ev.target.closest('.seat'); if (!seat) return
1042 const s = +seat.dataset.s
1043 if (A && A.obs.request !== 'chat') {
1044 if (A.legal.some(a => a.target != null && +a.target === s)) { pick = s; render(E.length) }
1045 return
1046 }
1047 filter = filter === s ? null : s
1048 render(shownN)
1049})
1050$('#log').addEventListener('click', ev => {
1051 const say = ev.target.closest('.lg-say'); if (say) say.classList.toggle('open')
1052 const r = ev.target.closest('[data-i]')
1053 if (!live && r && B.length) { const b = B.findIndex(i => i >= +r.dataset.i); if (b >= 0) { pause(); go(b) } }
1054})
1055$('#god').onclick = () => { if (live && !ended) return; god = !god; if (!live) keep('god', god); sceneKey = ''; render(shownN); if (!live) tick(1) }
1056$('#snd').onclick = () => { snd = !snd; keep('sound', snd); $('#snd').innerHTML = `<svg class="ic"><use href="#i-${snd ? 'sound' : 'mute'}"/></svg>`; sfx('turn') }
1057$('#about').onclick = about
1058$('#tabs').onclick = ev => {
1059 const t = ev.target.closest('[data-t]')?.dataset.t; if (!t) return
1060 app.classList.toggle('logtab', t === 'log')
1061 for (const b of document.querySelectorAll('#tabs button')) b.setAttribute('aria-pressed', String(b.dataset.t === t))
1062}
1063addEventListener('resize', () => { sceneKey = ''; $('#arrows').dataset.k = ''; render(shownN); if (!live && B.length) tick(1) })
1064
1065$('#snd').innerHTML = `<svg class="ic"><use href="#i-${snd ? 'sound' : 'mute'}"/></svg>`
1066buildSeats()
1067render(0)
1068if (live) { $('#mode').textContent = '· 直播'; shoalLive(take); setInterval(clock, 250) }
1069else shoalReplay(text => {
1070 let R = null
1071 try { R = JSON.parse(text) } catch {}
1072 if (!R || !Array.isArray(R.events) || !R.version) { $('#scene').innerHTML = '<div class="oldnote">这份回放是旧格式,牌桌放不了。</div>'; return }
1073 P = R.players.map(p => ({ name: p.name || `${p.seat + 1}号`, kind: p.kind || 'bot', role: p.role }))
1074 E = R.events
1075 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) })
1076 counts = P.reduce((m, p) => (m[p.role] = (m[p.role] || 0) + 1, m), {})
1077 B = E.map((e, i) => STAGE.has(e.kind) ? i : -1).filter(i => i >= 0)
1078 buildSeats(); replayBar()
1079 $('#mode').textContent = '· 回放 · 7人局'
1080 go(0); play()
1081})
1082</script>
1083</body>
1084</html>