| 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 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 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; } |
| 40 | html, body { margin: 0; height: 100%; overflow: hidden; } |
| 41 | body { background: #0d1326; color: var(--ink); font: 14px/1.5 var(--font-ui); -webkit-font-smoothing: antialiased; } |
| 42 | button { font: inherit; color: inherit; } |
| 43 | svg.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 | |
| 48 | header { 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 | |
| 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 | |
| 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 | |
| 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 | |
| 282 | footer { 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 | |
| 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 | |
| 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' |
| 477 | const live = new URLSearchParams(location.search).has('live') |
| 478 | const $ = s => document.querySelector(s) |
| 479 | const app = $('#app') |
| 480 | const 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 | } |
| 486 | const TEAM = { Werewolves: '狼人阵营', Villagers: '好人阵营' } |
| 487 | const AV = ['ada', 'eli', 'finn', 'iris', 'ivy', 'jade', 'kai', 'leo', 'luna', 'max', 'mia', 'nora', 'riley', 'sam', 'theo', 'zoe'] |
| 488 | const TINT = ['#f4e1bd', '#dbe7f6', '#e3efd6', '#f6dcdc', '#e8def5', '#d9efec', '#f5e9cc'] |
| 489 | const CIRCLED = '①②③④⑤⑥⑦' |
| 490 | const SECRET = new Set(['heal', 'inspect', 'wolf_vote', 'wolf_pick', 'saved']) |
| 491 | const STAGE = new Set(['phase', 'heal', 'inspect', 'wolf_vote', 'wolf_pick', 'saved', 'dawn', 'out', 'talk', 'turn', 'speech', 'vote_start', 'vote', 'tally', 'over']) |
| 492 | const VERB = { vote: '放逐', kill: '刀', heal: '守护', inspect: '查验' } |
| 493 | const ASKS = { vote: '放逐投票:点击头像,选择要放逐的人', kill: '狼人请选择今晚的目标:点击头像', heal: '选择今晚要守护的人:点击头像', inspect: '选择要查验的人:点击头像' } |
| 494 | const ACT_COLOR = { heal: 'doctor', saved: 'doctor', inspect: 'seer', vote: 'red' } |
| 495 | const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]) |
| 496 | const ic = (k, c) => `<svg class="ic"${c ? ` style="--c:var(--${c})"` : ''}><use href="#i-${k}"/></svg>` |
| 497 | const mc = (s, cls = '') => s == null ? '' : `<i class="mc ${cls}">${+s + 1}</i>` |
| 498 | const no = s => `<em>【${+s + 1}号】</em>` |
| 499 | const zh = r => ROLE[r]?.zh || '未知' |
| 500 | const left = s => +s < 4 |
| 501 | function pref(k, d) { try { const v = localStorage.getItem('ww.' + k); return v == null ? d : v === '1' } catch { return d } } |
| 502 | function keep(k, v) { try { localStorage.setItem('ww.' + k, v ? '1' : '0') } catch {} } |
| 503 | |
| 504 | |
| 505 | let P = [...Array(7)].map((_, i) => ({ name: `${i + 1}号`, kind: 'bot', role: null })) |
| 506 | let E = [], faces = [], counts = null |
| 507 | let you = [], A = null, pick = null, sent = null, ended = false, mine = null, carded = false, overShown = false |
| 508 | const seenN = new Set() |
| 509 | let god = live ? false : pref('god', true), snd = pref('sound', false), filter = null |
| 510 | const godOn = () => god && (!live || ended) |
| 511 | const seen = e => !SECRET.has(e.kind) || godOn() || (e._to != null && you.includes(String(e._to))) |
| 512 | const wolfMe = () => you.some(s => P[+s].role === 'Werewolf') |
| 513 | |
| 514 | function 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 | } |
| 544 | const fate = f => f ? (f.cause === 'night' ? `夜${f.day + 1} 被刀` : `天${f.day} 放逐`) : '' |
| 545 | const phaseName = e => e.phase === 'night' ? `第${e.day + 1}夜` : `第${e.day}天` |
| 546 | const reasonZh = o => !o || !o.winner ? '对局中止' : o.winner === 'Villagers' ? '狼人全部出局' : '狼人数量追平了好人' |
| 547 | |
| 548 | |
| 549 | function buildSeats() { |
| 550 | const used = new Set() |
| 551 | faces = P.map(p => { |
| 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 | } |
| 561 | function 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 | } |
| 565 | function 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 | } |
| 571 | function 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 | |
| 610 | function 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 | } |
| 613 | const isCJK = ch => /[⺀-鿿-]/.test(ch) |
| 614 | const typeMs = t => Math.min(24000, Math.max(800, [...String(t)].reduce((ms, ch) => ms + (isCJK(ch) ? 25 : 16), 0))) |
| 615 | function 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 | } |
| 622 | function 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 | } |
| 639 | function 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 | } |
| 652 | function nightAsk(S) { |
| 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 | } |
| 663 | function 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 | } |
| 688 | function 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 | } |
| 708 | function 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 | } |
| 723 | function 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 | |
| 739 | function 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 | } |
| 766 | function 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 | |
| 778 | let shownN = 0, sceneKey = '' |
| 779 | function lastScene(n) { for (let i = n - 1; i >= 0; i--) if (STAGE.has(E[i].kind) && (seen(E[i]) || !live)) return i; return -1 } |
| 780 | function 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 | |
| 799 | function 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 | } |
| 806 | function 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 | } |
| 813 | function 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 | |
| 836 | let ac = null |
| 837 | function 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 | |
| 855 | let B = [], at = 0, t0 = 0, playing = false, speed = 1, raf = 0, segs = [] |
| 856 | const SPEEDS = [0.5, 1, 2, 4] |
| 857 | function 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 | } |
| 864 | function 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 | } |
| 888 | function 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 | } |
| 895 | function 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 | } |
| 902 | function 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 | } |
| 912 | function 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 | } |
| 919 | function 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 | } |
| 925 | function 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 | }) |
| 934 | addEventListener('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 | |
| 942 | function 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') { |
| 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)) |
| 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 | } |
| 978 | let flashT = 0 |
| 979 | function flashTitle() { |
| 980 | clearInterval(flashT); let k = 0 |
| 981 | flashT = setInterval(() => { document.title = k++ % 2 ? '狼人杀' : '轮到你了'; if (k > 9 || !A) { clearInterval(flashT); document.title = '狼人杀' } }, 700) |
| 982 | } |
| 983 | let actKey = '' |
| 984 | function 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 | } |
| 1014 | function 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 | } |
| 1021 | function clock() { |
| 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 | |
| 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 | } |
| 1059 | addEventListener('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>` |
| 1062 | buildSeats() |
| 1063 | render(0) |
| 1064 | if (live) { $('#mode').textContent = '· 直播'; shoalLive(take); setInterval(clock, 250) } |
| 1065 | else 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> |