| 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 | :root { --ink: #f4e8d2; --muted: #c9b48f; --gold: #f2c14e; --card: rgba(255, 244, 222, .07); --line: rgba(255, 244, 222, .12); } |
| 12 | * { box-sizing: border-box; } |
| 13 | html, body { margin: 0; height: 100%; overflow: hidden; } |
| 14 | body { background: radial-gradient(ellipse at 50% 35%, #5a4029 0%, #3a281a 55%, #1f150d 100%); color: var(--ink); |
| 15 | font: 14px/1.4 "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", system-ui, sans-serif; user-select: none; } |
| 16 | #app { display: flex; align-items: center; justify-content: center; gap: 22px; height: 100%; padding: 12px; } |
| 17 | .center { display: flex; flex-direction: column; align-items: center; gap: 10px; position: relative; } |
| 18 | canvas { display: block; border-radius: 6px; box-shadow: 0 2px 0 #8a5a24, 0 22px 50px rgba(0, 0, 0, .55); touch-action: manipulation; } |
| 19 | canvas.mine { cursor: pointer; } |
| 20 | .player { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 132px; padding: 16px 10px 14px; border: 1px solid var(--line); |
| 21 | border-radius: 18px; background: var(--card); transition: box-shadow .25s, border-color .25s, transform .25s; } |
| 22 | .player.active { border-color: rgba(242, 193, 78, .7); box-shadow: 0 0 0 1px rgba(242, 193, 78, .5), 0 0 28px rgba(242, 193, 78, .28); transform: translateY(-2px); } |
| 23 | .ava { position: relative; display: grid; place-items: center; width: 58px; height: 58px; border-radius: 50%; font-size: 20px; font-weight: 600; |
| 24 | color: #fff; background: linear-gradient(145deg, var(--a, #7a8ca8), var(--b, #4c5d78)); box-shadow: inset 0 -3px 6px rgba(0, 0, 0, .25), 0 3px 10px rgba(0, 0, 0, .3); } |
| 25 | .ava .stone { position: absolute; right: -4px; bottom: -4px; width: 22px; height: 22px; border-radius: 50%; box-shadow: 0 2px 4px rgba(0, 0, 0, .45); } |
| 26 | .stone.black { background: radial-gradient(circle at 34% 30%, #777 0%, #222 45%, #000 100%); } |
| 27 | .stone.white { background: radial-gradient(circle at 34% 30%, #fff 0%, #eee 50%, #b9b9b9 100%); } |
| 28 | .name { max-width: 100%; overflow: hidden; font-size: 15px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } |
| 29 | .role { color: var(--muted); font-size: 12px; } |
| 30 | .you { margin-left: 4px; padding: 0 6px; border-radius: 6px; background: var(--gold); color: #3a281a; font-size: 11px; font-weight: 700; vertical-align: 2px; } |
| 31 | .turn { height: 20px; padding: 0 10px; border-radius: 10px; background: rgba(242, 193, 78, .16); color: var(--gold); font-size: 12px; line-height: 20px; visibility: hidden; } |
| 32 | .player.active .turn { visibility: visible; } |
| 33 | .turn i { display: inline-block; width: 4px; height: 4px; margin-left: 3px; border-radius: 50%; background: currentColor; vertical-align: 2px; animation: blink 1.2s infinite; } |
| 34 | .turn i:nth-child(2) { animation-delay: .2s; } .turn i:nth-child(3) { animation-delay: .4s; } |
| 35 | @keyframes blink { 0%, 100% { opacity: .2; } 40% { opacity: 1; } } |
| 36 | .bar { display: flex; align-items: center; gap: 6px; height: 36px; padding: 0 8px; border-radius: 18px; background: rgba(0, 0, 0, .28); } |
| 37 | .bar button { display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--ink); font-size: 15px; line-height: 1; cursor: pointer; } |
| 38 | .bar button:hover { background: rgba(255, 255, 255, .12); } |
| 39 | .bar input { width: clamp(60px, calc(100vw - 300px), 260px); accent-color: var(--gold); } |
| 40 | .bar .at { min-width: 64px; color: var(--muted); font-size: 12px; text-align: right; } |
| 41 | .status { padding: 0 8px; color: var(--ink); font-size: 14px; } |
| 42 | .status b { color: var(--gold); } |
| 43 | .banner { position: absolute; left: 50%; top: 42%; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 18px 40px 16px; |
| 44 | border: 1px solid rgba(242, 193, 78, .55); border-radius: 20px; background: rgba(31, 21, 13, .86); box-shadow: 0 20px 50px rgba(0, 0, 0, .5); |
| 45 | transform: translate(-50%, -50%); animation: pop .35s cubic-bezier(.2, .9, .3, 1.3); pointer-events: none; white-space: nowrap; } |
| 46 | .banner b { color: var(--gold); font-size: 34px; letter-spacing: .12em; } |
| 47 | .banner small { color: var(--muted); font-size: 13px; } |
| 48 | @keyframes pop { from { opacity: 0; transform: translate(-50%, -40%) scale(.85); } } |
| 49 | .banner.docked { top: 10px; flex-direction: row; gap: 10px; padding: 6px 18px; border-radius: 14px; transform: translate(-50%, 0); animation: none; |
| 50 | transition: top .4s cubic-bezier(.2, .8, .2, 1), transform .4s cubic-bezier(.2, .8, .2, 1), padding .4s; } |
| 51 | .banner.docked b { font-size: 18px; letter-spacing: .06em; } |
| 52 | @media (max-aspect-ratio: 5/4) { |
| 53 | #app { flex-direction: column; gap: 10px; } |
| 54 | .player { flex-direction: row; width: auto; min-width: 0; padding: 8px 14px; border-radius: 14px; } |
| 55 | .ava { width: 40px; height: 40px; font-size: 15px; } |
| 56 | .ava .stone { width: 16px; height: 16px; } |
| 57 | #p0 { order: 2; } .center { order: 1; } #p1 { order: 0; } |
| 58 | } |
| 59 | </style> |
| 60 | </head> |
| 61 | <body> |
| 62 | <div id="app"> |
| 63 | <section class="player" id="p0"><div class="ava"><span></span><i class="stone black"></i></div><div class="name"></div><div class="role">执黑 · 先手</div><div class="turn">落子中<i></i><i></i><i></i></div></section> |
| 64 | <div class="center"> |
| 65 | <canvas id="board"></canvas> |
| 66 | <div class="bar" id="bar"><span class="status" id="status">等待对局…</span></div> |
| 67 | </div> |
| 68 | <section class="player" id="p1"><div class="ava"><span></span><i class="stone white"></i></div><div class="name"></div><div class="role">执白 · 后手</div><div class="turn">落子中<i></i><i></i><i></i></div></section> |
| 69 | </div> |
| 70 | <script src="bridge.js"></script> |
| 71 | <script> |
| 72 | const N = 15, STARS = [[3, 3], [11, 3], [7, 7], [3, 11], [11, 11]], LETTERS = 'ABCDEFGHIJKLMNO' |
| 73 | const live = new URLSearchParams(location.search).has('live') |
| 74 | const cv = document.getElementById('board'), g = cv.getContext('2d'), bar = document.getElementById('bar') |
| 75 | |
| 76 | const G = { moves: [], shown: 0, names: ['', ''], kinds: ['', ''], five: null, over: null, you: [], ask: null, hover: null, drop: 1 } |
| 77 | let S = 480, pad = 30, cell = 30, wood = null |
| 78 | |
| 79 | |
| 80 | function fit() { |
| 81 | const narrow = innerWidth / innerHeight < 1.25 |
| 82 | const side = narrow ? 0 : 2 * (132 + 22), extra = narrow ? 2 * 64 + 20 : 0 |
| 83 | S = Math.max(240, Math.floor(Math.min(innerWidth - side - 24, innerHeight - 24 - 46 - extra))) |
| 84 | pad = S * 0.062; cell = (S - 2 * pad) / (N - 1) |
| 85 | const r = devicePixelRatio || 1 |
| 86 | cv.width = cv.height = Math.round(S * r); cv.style.width = cv.style.height = S + 'px' |
| 87 | g.setTransform(r, 0, 0, r, 0, 0) |
| 88 | wood = grain(S) |
| 89 | draw() |
| 90 | } |
| 91 | |
| 92 | function grain(s) { |
| 93 | const c = document.createElement('canvas'), x = c.getContext('2d'), r = devicePixelRatio || 1 |
| 94 | c.width = c.height = Math.round(s * r); x.scale(r, r) |
| 95 | const lg = x.createLinearGradient(0, 0, s, s); lg.addColorStop(0, '#ecc87f'); lg.addColorStop(.55, '#e1b467'); lg.addColorStop(1, '#d2a055') |
| 96 | x.fillStyle = lg; x.fillRect(0, 0, s, s) |
| 97 | let seed = 7; const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647) |
| 98 | for (let i = 0; i < 70; i++) { |
| 99 | const y0 = rnd() * s, amp = 2 + rnd() * 6, k = 0.004 + rnd() * 0.01, ph = rnd() * 6 |
| 100 | x.strokeStyle = `rgba(${120 + rnd() * 30 | 0}, ${70 + rnd() * 20 | 0}, 25, ${0.04 + rnd() * 0.08})`; x.lineWidth = 0.6 + rnd() * 2.2 |
| 101 | x.beginPath(); for (let px = 0; px <= s; px += 6) x.lineTo(px, y0 + Math.sin(px * k + ph) * amp + Math.sin(px * k * 3.1) * amp * 0.3); x.stroke() |
| 102 | } |
| 103 | return c |
| 104 | } |
| 105 | const P = i => pad + i * cell |
| 106 | function stone(x, y, black, alpha = 1, scale = 1) { |
| 107 | const cx = P(x), cy = P(y), r = cell * 0.46 * scale |
| 108 | g.save(); g.globalAlpha = alpha |
| 109 | g.shadowColor = 'rgba(40, 20, 0, .45)'; g.shadowBlur = cell * 0.14; g.shadowOffsetX = cell * 0.05; g.shadowOffsetY = cell * 0.08 |
| 110 | const rg = g.createRadialGradient(cx - r * 0.35, cy - r * 0.4, r * 0.08, cx, cy, r) |
| 111 | if (black) { rg.addColorStop(0, '#7d7d7d'); rg.addColorStop(0.35, '#2a2a2a'); rg.addColorStop(1, '#050505') } |
| 112 | else { rg.addColorStop(0, '#ffffff'); rg.addColorStop(0.55, '#ececec'); rg.addColorStop(1, '#b5b2ab') } |
| 113 | g.fillStyle = rg; g.beginPath(); g.arc(cx, cy, r, 0, 7); g.fill(); g.restore() |
| 114 | } |
| 115 | function draw() { |
| 116 | if (!wood) return |
| 117 | g.clearRect(0, 0, S, S) |
| 118 | g.drawImage(wood, 0, 0, S, S) |
| 119 | g.strokeStyle = 'rgba(60, 38, 14, .9)'; g.lineWidth = Math.max(1, S / 560) |
| 120 | g.beginPath() |
| 121 | for (let i = 0; i < N; i++) { g.moveTo(P(0), P(i)); g.lineTo(P(N - 1), P(i)); g.moveTo(P(i), P(0)); g.lineTo(P(i), P(N - 1)) } |
| 122 | g.stroke() |
| 123 | g.lineWidth = Math.max(2, S / 260); g.strokeRect(P(0), P(0), cell * (N - 1), cell * (N - 1)) |
| 124 | g.fillStyle = 'rgba(60, 38, 14, .95)' |
| 125 | for (const [x, y] of STARS) { g.beginPath(); g.arc(P(x), P(y), Math.max(2.5, cell * 0.12), 0, 7); g.fill() } |
| 126 | g.fillStyle = 'rgba(92, 58, 22, .8)'; g.font = `${Math.max(9, cell * 0.36)}px "PingFang SC", system-ui, sans-serif`; g.textAlign = 'center'; g.textBaseline = 'middle' |
| 127 | for (let i = 0; i < N; i++) { g.fillText(LETTERS[i], P(i), S - pad * 0.45); g.fillText(String(N - i), pad * 0.42, P(i)) } |
| 128 | const shown = G.moves.slice(0, G.shown) |
| 129 | shown.forEach(([x, y], i) => { |
| 130 | const last = i === shown.length - 1, t = last ? G.drop : 1 |
| 131 | stone(x, y, i % 2 === 0, t, last ? 1.18 - 0.18 * t : 1) |
| 132 | }) |
| 133 | const last = shown.at(-1) |
| 134 | if (last && G.drop >= 1) { g.fillStyle = '#e8383d'; g.beginPath(); g.arc(P(last[0]), P(last[1]), Math.max(2.5, cell * 0.12), 0, 7); g.fill() } |
| 135 | if (G.five && G.shown === G.moves.length) { |
| 136 | const [a, b] = [G.five[0], G.five.at(-1)] |
| 137 | g.save(); g.strokeStyle = 'rgba(242, 193, 78, .95)'; g.lineWidth = Math.max(3, cell * 0.14); g.lineCap = 'round' |
| 138 | g.shadowColor = 'rgba(242, 193, 78, .9)'; g.shadowBlur = cell * 0.5 |
| 139 | g.beginPath(); g.moveTo(P(a[0]), P(a[1])); g.lineTo(P(b[0]), P(b[1])); g.stroke(); g.restore() |
| 140 | } |
| 141 | if (G.ask && G.hover && !taken(...G.hover)) stone(G.hover[0], G.hover[1], G.ask.obs.colour === 'black', 0.45) |
| 142 | } |
| 143 | let anim = 0 |
| 144 | function dropIn() { |
| 145 | cancelAnimationFrame(anim); G.drop = 0 |
| 146 | const t0 = performance.now(), step = t => { G.drop = Math.min(1, (t - t0) / 170); draw(); if (G.drop < 1) anim = requestAnimationFrame(step) } |
| 147 | anim = requestAnimationFrame(step) |
| 148 | } |
| 149 | const taken = (x, y) => G.moves.some(m => m[0] === x && m[1] === y) |
| 150 | |
| 151 | |
| 152 | const TINTS = [['#7a8ca8', '#4c5d78'], ['#c9875b', '#8e5634'], ['#6fa58a', '#3f6e57'], ['#a07ab8', '#6c4b85']] |
| 153 | function cards() { |
| 154 | const toMove = G.over || (!live && G.shown >= G.moves.length) ? -1 : G.shown % 2 |
| 155 | for (const i of [0, 1]) { |
| 156 | const el = document.getElementById('p' + i), name = G.names[i] || (i ? '白方' : '黑方') |
| 157 | const tint = TINTS[[...name].reduce((s, ch) => s + ch.charCodeAt(0), 0) % TINTS.length] |
| 158 | el.querySelector('.ava span').textContent = [...name][0].toUpperCase() |
| 159 | el.querySelector('.ava').style.cssText = `--a:${tint[0]};--b:${tint[1]}` |
| 160 | const n = el.querySelector('.name') |
| 161 | n.replaceChildren(name) |
| 162 | if (G.you.includes(String(i))) n.insertAdjacentHTML('beforeend', '<span class="you">你</span>') |
| 163 | el.querySelector('.role').textContent = (i ? '执白 · 后手' : '执黑 · 先手') + (G.kinds[i] === 'auto' ? ' · 托管' : '') |
| 164 | el.classList.toggle('active', toMove === i) |
| 165 | } |
| 166 | } |
| 167 | function banner(text, sub) { |
| 168 | document.querySelector('.banner')?.remove() |
| 169 | if (!text) return |
| 170 | const b = document.createElement('div'); b.className = 'banner' |
| 171 | b.innerHTML = '<b></b><small></small>'; b.querySelector('b').textContent = text; b.querySelector('small').textContent = sub |
| 172 | document.querySelector('.center').append(b) |
| 173 | setTimeout(() => b.classList.add('docked'), 2200) |
| 174 | } |
| 175 | const REASON = { five: '五连', 'board full': '棋盘下满', stopped: '对局中止', 'out of time or no move': '超时', 'did not start': '未能开局' } |
| 176 | const reason = r => REASON[r] ?? (r?.startsWith('illegal') ? '落子违规' : r ?? '') |
| 177 | |
| 178 | |
| 179 | let R = null, timer = 0 |
| 180 | function replayBar() { |
| 181 | bar.innerHTML = '<button data-go="first" title="开局">⏮</button><button data-go="back" title="上一手">‹</button><button data-go="play" title="播放">▶</button><button data-go="next" title="下一手">›</button><button data-go="last" title="终局">⏭</button><input type="range" min="0" value="0" aria-label="手数"><span class="at"></span>' |
| 182 | bar.addEventListener('click', e => { |
| 183 | const k = e.target.closest('button')?.dataset.go |
| 184 | if (!k) return |
| 185 | if (k === 'play') return timer ? pause() : play() |
| 186 | pause() |
| 187 | go({ first: R.opening, back: G.shown - 1, next: G.shown + 1, last: G.moves.length }[k]) |
| 188 | }) |
| 189 | bar.querySelector('input').oninput = e => (pause(), go(+e.target.value)) |
| 190 | } |
| 191 | function go(n) { |
| 192 | const was = G.shown |
| 193 | G.shown = Math.max(0, Math.min(G.moves.length, n)) |
| 194 | if (G.shown === was + 1) dropIn(); else { G.drop = 1; draw() } |
| 195 | const at = bar.querySelector('input'); at.max = G.moves.length; at.value = G.shown |
| 196 | bar.querySelector('.at').textContent = `第 ${Math.max(0, G.shown - R.opening)} / ${G.moves.length - R.opening} 手` |
| 197 | cards() |
| 198 | const end = G.shown === G.moves.length |
| 199 | banner(end ? (R.winner == null ? '和棋' : `${R.winner ? '白方' : '黑方'}胜`) : '', end ? `${R.winner == null ? '' : `${G.names[R.winner]} · `}${reason(R.reason)}` : '') |
| 200 | } |
| 201 | function play() { |
| 202 | if (G.shown >= G.moves.length) go(R.opening) |
| 203 | bar.querySelector('[data-go=play]').textContent = '❚❚' |
| 204 | timer = setInterval(() => (G.shown >= G.moves.length ? pause() : go(G.shown + 1)), 420) |
| 205 | } |
| 206 | function pause() { clearInterval(timer); timer = 0; bar.querySelector('[data-go=play]').textContent = '▶' } |
| 207 | |
| 208 | |
| 209 | let audio = null |
| 210 | function tock() { |
| 211 | try { |
| 212 | audio ??= new AudioContext() |
| 213 | const o = audio.createOscillator(), v = audio.createGain(), t = audio.currentTime |
| 214 | o.frequency.setValueAtTime(900, t); o.frequency.exponentialRampToValueAtTime(260, t + 0.06) |
| 215 | v.gain.setValueAtTime(0.25, t); v.gain.exponentialRampToValueAtTime(0.001, t + 0.09) |
| 216 | o.connect(v).connect(audio.destination); o.start(t); o.stop(t + 0.1) |
| 217 | } catch {} |
| 218 | } |
| 219 | function status() { |
| 220 | const s = document.getElementById('status') |
| 221 | if (G.over) s.textContent = '对局结束' |
| 222 | else if (!G.names.some(Boolean)) s.textContent = '等待开局…' |
| 223 | else if (G.ask) s.innerHTML = '<b>轮到你落子</b>' |
| 224 | else s.textContent = G.you.length ? '对方思考中…' : `${G.shown % 2 ? '白方' : '黑方'}思考中…` |
| 225 | cv.classList.toggle('mine', !!G.ask) |
| 226 | } |
| 227 | function take(batch) { |
| 228 | G.you = batch.you || [] |
| 229 | for (const m of batch.messages) { |
| 230 | const e = m.event |
| 231 | if (e?.type === 'start') { for (const [s, x] of Object.entries(e.seats)) { G.names[+s] = x.name; G.kinds[+s] = x.kind } } |
| 232 | else if (e?.type === 'control') { G.names[+e.seat] = e.name; G.kinds[+e.seat] = e.kind } |
| 233 | else if (e?.type === 'move') { |
| 234 | G.moves.push([e.x, e.y]); G.shown = G.moves.length |
| 235 | if (G.ask && m.n > G.ask.n) G.ask = null |
| 236 | if (batch.messages.length < 4) { dropIn(); tock() } else G.drop = 1 |
| 237 | } else if (e?.type === 'over') { G.over = e; G.five = e.five; G.ask = null } |
| 238 | else if (m.legal && G.you.includes(m.to)) G.ask = m |
| 239 | else if (m.end) G.ask = null |
| 240 | } |
| 241 | if (G.over) { |
| 242 | const w = G.over.winner, mine = w != null && G.you.includes(w), lost = w != null && G.you.length && !mine |
| 243 | banner(w == null ? (G.over.reason === 'stopped' ? '对局中止' : '和棋') : mine ? '你赢了' : lost ? '你输了' : `${w === '1' ? '白方' : '黑方'}胜`, |
| 244 | `${w == null ? '' : `${G.names[+w]} · `}${reason(G.over.reason)}`) |
| 245 | } |
| 246 | cards(); status(); draw() |
| 247 | } |
| 248 | const point = e => { |
| 249 | const r = cv.getBoundingClientRect(), x = Math.round((e.clientX - r.left - pad) / cell), y = Math.round((e.clientY - r.top - pad) / cell) |
| 250 | return x >= 0 && y >= 0 && x < N && y < N ? [x, y] : null |
| 251 | } |
| 252 | cv.addEventListener('pointermove', e => { if (!G.ask) return; const p = point(e); if (String(p) !== String(G.hover)) { G.hover = p; draw() } }) |
| 253 | cv.addEventListener('pointerleave', () => { G.hover = null; draw() }) |
| 254 | cv.addEventListener('click', e => { |
| 255 | const p = point(e), a = G.ask |
| 256 | if (!a || !p || taken(...p) || !a.legal.includes(`${p[0]},${p[1]}`)) return |
| 257 | shoalAct(a.to, a.version, `${p[0]},${p[1]}`) |
| 258 | G.ask = null; G.hover = null; status(); draw() |
| 259 | }) |
| 260 | |
| 261 | addEventListener('resize', fit) |
| 262 | fit(); cards() |
| 263 | if (live) shoalLive(take) |
| 264 | else shoalReplay(text => { |
| 265 | R = JSON.parse(text) |
| 266 | G.moves = R.moves.map(m => [m[0], m[1]]); G.five = R.five; G.names = R.players || ['黑方', '白方'] |
| 267 | replayBar(); go(R.opening); play() |
| 268 | }) |
| 269 | </script> |
| 270 | </body> |
| 271 | </html> |