// ==UserScript== // @name 【sb.sb】烧饼消消乐 · 自动求解 // @namespace sb // @version 1.0.0 // @description 在 sb.sb 消消乐页面里自动解盘:蒙特卡洛求解器逐手取最优,可练习或自动计奖。 // @author StormCream // @match *://*.sb.sb/games/match-3/* // @grant none // @run-at document-idle // @noframes // ==/UserScript== /* global unsafeWindow */ (function () { 'use strict'; // ═══════════════════════════════════════════════════════════════════════════ // 1. 求解引擎(原样移植自 match3-engine.js,去掉 module.exports 外壳) // ═══════════════════════════════════════════════════════════════════════════ const Engine = (function () { const N = 8; const CELLS = N * N; const KINDS = 7; // 计分模型:服务端规则为 points(wave)= 10 * Σ(长度>=3 的run长度) * min(round+1,3) const MODEL = { base: [0, 0, 0, 30, 40, 50, 60, 70, 80], bonus: [0, 0, 0, 0, 0, 0, 0, 0, 0], cascade: [1, 2, 3, 3, 3, 3, 3, 3, 3], }; function setModel(m) { Object.assign(MODEL, m); } function pointsForRun(len, round) { const L = Math.min(len, 8); const c = MODEL.cascade[Math.min(round, MODEL.cascade.length - 1)]; return Math.round((MODEL.base[L] + MODEL.bonus[L]) * c); } // 「棋盘是否已经铺好未来可消的图案」的启发式权重。调参最优≈2~3。 let POTENTIAL_W = 3; const DEFAULTS = { rollouts: 12, topK: 20 }; // mulberry32 function makeRng(seed) { let a = seed >>> 0; return function () { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function findRuns(b, out) { out.length = 0; for (let r = 0; r < N; r++) { const base = r * N; let c = 0; while (c < N) { const k = b[base + c]; if (k < 0) { c++; continue; } let c2 = c + 1; while (c2 < N && b[base + c2] === k) c2++; if (c2 - c >= 3) out.push({ kind: k, len: c2 - c, dir: 0, r0: r, c0: c }); c = c2; } } for (let c = 0; c < N; c++) { let r = 0; while (r < N) { const k = b[r * N + c]; if (k < 0) { r++; continue; } let r2 = r + 1; while (r2 < N && b[r2 * N + c] === k) r2++; if (r2 - r >= 3) out.push({ kind: k, len: r2 - r, dir: 1, r0: r, c0: c }); r = r2; } } return out; } function hasMatch(b) { for (let r = 0; r < N; r++) { const base = r * N; for (let c = 0; c + 2 < N; c++) { const k = b[base + c]; if (k >= 0 && k === b[base + c + 1] && k === b[base + c + 2]) return true; } } for (let c = 0; c < N; c++) { for (let r = 0; r + 2 < N; r++) { const k = b[r * N + c]; if (k >= 0 && k === b[(r + 1) * N + c] && k === b[(r + 2) * N + c]) return true; } } return false; } const _mask = new Uint8Array(CELLS); const _col = new Int8Array(N); function collapse(b, mask, rng, fill) { let newCount = 0; for (let c = 0; c < N; c++) { let n = 0; for (let r = N - 1; r >= 0; r--) { const i = r * N + c; if (mask[i]) continue; _col[n++] = b[i]; } for (let r = N - 1; r >= 0; r--) { const j = N - 1 - r; const i = r * N + c; if (j < n) b[i] = _col[j]; else { b[i] = fill ? fill[i] : (rng() * KINDS) | 0; newCount++; } } } return newCount; } const _runs = []; function scoreStableBoard(b, round, maskOut) { findRuns(b, _runs); if (!_runs.length) return 0; if (maskOut) maskOut.fill(0); let pts = 0; for (let i = 0; i < _runs.length; i++) { const run = _runs[i]; pts += pointsForRun(run.len, round); if (maskOut) { if (run.dir === 0) for (let c = run.c0; c < run.c0 + run.len; c++) maskOut[run.r0 * N + c] = 1; else for (let r = run.r0; r < run.r0 + run.len; r++) maskOut[r * N + run.c0] = 1; } } return pts; } function swapAndCheck(b, i, j) { const t = b[i]; b[i] = b[j]; b[j] = t; const ok = hasMatch(b); const u = b[i]; b[i] = b[j]; b[j] = u; return ok; } function evaluateMove(board, r1, c1, r2, c2, opts) { const rollouts = (opts && opts.rollouts) || 6; const rng = (opts && opts.rng) || makeRng(12345); const i = r1 * N + c1, j = r2 * N + c2; const t = board[i]; board[i] = board[j]; board[j] = t; findRuns(board, _runs); if (!_runs.length) { const u = board[i]; board[i] = board[j]; board[j] = u; return { valid: false }; } let firstWave = 0; let maxRun = 0; _mask.fill(0); for (let k = 0; k < _runs.length; k++) { const run = _runs[k]; firstWave += pointsForRun(run.len, 0); if (run.len > maxRun) maxRun = run.len; if (run.dir === 0) for (let c = run.c0; c < run.c0 + run.len; c++) _mask[run.r0 * N + c] = 1; else for (let r = run.r0; r < run.r0 + run.len; r++) _mask[r * N + run.c0] = 1; } let cascadeSum = 0; let potentialSum = 0; const work = new Int8Array(CELLS); const cmask = new Uint8Array(CELLS); for (let s = 0; s < rollouts; s++) { work.set(board.subarray(0, CELLS)); cmask.set(_mask.subarray(0, CELLS)); let round = 1; for (; round <= 8; round++) { collapse(work, cmask, rng, null); const pts = scoreStableBoard(work, round, cmask); if (!pts) break; cascadeSum += pts; } potentialSum += potentialScore(work); } const u = board[i]; board[i] = board[j]; board[j] = u; const expectedCascade = cascadeSum / rollouts; const potential = potentialSum / rollouts; return { valid: true, r1, c1, r2, c2, firstWave, expectedCascade, potential, maxRun, total: firstWave + expectedCascade + POTENTIAL_W * potential, }; } function potentialScore(b) { let s = 0; for (let r = 0; r < N; r++) { const base = r * N; for (let c = 0; c + 2 < N; c++) { const a = b[base + c], d = b[base + c + 1], e = b[base + c + 2]; if (a === d && a >= 0 && e >= 0 && e !== a) s += 3; if (d === e && d >= 0 && a >= 0 && a !== d) s += 3; if (a === e && a >= 0 && d >= 0 && d !== a) s += 4; } } for (let c = 0; c < N; c++) { for (let r = 0; r + 2 < N; r++) { const a = b[r * N + c], d = b[(r + 1) * N + c], e = b[(r + 2) * N + c]; if (a === d && a >= 0 && e >= 0 && e !== a) s += 3; if (d === e && d >= 0 && a >= 0 && a !== d) s += 3; if (a === e && a >= 0 && d >= 0 && d !== a) s += 4; } } return s; } function validMoves(board) { const out = []; for (let r = 0; r < N; r++) { for (let c = 0; c < N; c++) { const i = r * N + c; if (c + 1 < N && swapAndCheck(board, i, i + 1)) out.push([r, c, r, c + 1]); if (r + 1 < N && swapAndCheck(board, i, i + N)) out.push([r, c, r + 1, c]); } } return out; } function bestMove(board, opts) { opts = opts || {}; const topK = opts.topK || DEFAULTS.topK; const rollouts = opts.rollouts != null ? opts.rollouts : DEFAULTS.rollouts; const b = board instanceof Int8Array ? board : Int8Array.from(board); const moves = validMoves(b); if (!moves.length) return null; const seedRng = makeRng(opts.seed || 0x9e3779b9); const cheap = []; for (const [r1, c1, r2, c2] of moves) { const i = r1 * N + c1, j = r2 * N + c2; const t = b[i]; b[i] = b[j]; b[j] = t; findRuns(b, _runs); let pts = 0, maxRun = 0; for (let k = 0; k < _runs.length; k++) { pts += pointsForRun(_runs[k].len, 0); if (_runs[k].len > maxRun) maxRun = _runs[k].len; } const u = b[i]; b[i] = b[j]; b[j] = u; cheap.push({ r1, c1, r2, c2, pts, maxRun }); } cheap.sort((x, y) => (y.pts + y.maxRun * 10) - (x.pts + x.maxRun * 10)); let best = null; for (let k = 0; k < Math.min(topK, cheap.length); k++) { const m = cheap[k]; const ev = evaluateMove(b, m.r1, m.c1, m.r2, m.c2, { rollouts, rng: seedRng }); if (!ev.valid) continue; if (!best || ev.total > best.total) best = ev; } if (!best) { const m = cheap[0]; return { r1: m.r1, c1: m.c1, r2: m.r2, c2: m.c2, total: m.pts, firstWave: m.pts, expectedCascade: 0, potential: 0, maxRun: m.maxRun }; } return best; } return { N, CELLS, KINDS, DEFAULTS, setModel, pointsForRun, findRuns, hasMatch, collapse, potentialScore, validMoves, evaluateMove, bestMove, makeRng, setPotentialW: (v) => { POTENTIAL_W = v; }, getPotentialW: () => POTENTIAL_W, }; })(); // ═══════════════════════════════════════════════════════════════════════════ // 2. 页面配置 / 网络层 // ═══════════════════════════════════════════════════════════════════════════ const cfgEl = document.getElementById('match3-config'); const CFG = cfgEl ? JSON.parse(cfgEl.textContent) : {}; const STATE_URL = CFG.state || '/games/match-3/state/'; const MOVE_URL = CFG.move || '/games/match-3/move/'; const START_URL = CFG.start || '/games/match-3/start/'; function readCsrf() { const inp = document.querySelector('input[name="_csrf"]'); if (inp && inp.value) return inp.value; const meta = document.querySelector('meta[name="csrf-token"]'); return meta ? meta.getAttribute('content') || '' : ''; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function getState() { const r = await fetch(STATE_URL, { credentials: 'same-origin', headers: { Accept: 'application/json' } }); return await r.json(); } async function postForm(url, fields) { const fd = new FormData(); fd.set('_csrf', readCsrf()); for (const k of Object.keys(fields || {})) fd.set(k, String(fields[k])); const r = await fetch(url, { method: 'POST', body: fd, credentials: 'same-origin', headers: { Accept: 'application/json' }, }); return await r.json(); } let ridN = 0; const rid = () => 'tm-' + Date.now().toString(36) + '-' + (++ridN).toString(36) + Math.random().toString(36).slice(2, 6); // ═══════════════════════════════════════════════════════════════════════════ // 3. 渲染同步(把服务器权威棋盘画回页面,可选) // ═══════════════════════════════════════════════════════════════════════════ function render(board, score) { try { const el = document.querySelector('[data-m3-board]') || document.querySelector('.m3-board'); if (!el) return; const tiles = el.querySelectorAll('.m3-tile'); const foods = CFG.foods || []; const names = CFG.names || []; for (let i = 0; i < tiles.length && i < board.length; i++) { const k = board[i]; if (k < 0) continue; const t = tiles[i]; if (foods[k] && t.getAttribute('src') !== foods[k]) t.setAttribute('src', foods[k]); const img = t.tagName === 'IMG' ? t : t.querySelector('img'); if (img && foods[k]) img.src = foods[k]; if (names[k] != null) t.setAttribute('alt', names[k]); } const s = document.querySelector('[data-m3-score]'); if (s && score != null) s.textContent = String(score); } catch (e) { /* 渲染失败不影响对局 */ } } // ═══════════════════════════════════════════════════════════════════════════ // 4. 控制器(单循环,带中止标志) // ═══════════════════════════════════════════════════════════════════════════ const OPTS = { rollouts: 12, topK: 20, potentialW: 3, pacePad: 10, stopBeforeMs: 2000, }; Engine.setPotentialW(OPTS.potentialW); const M3 = { running: false, abort: false, board: null, version: 0, gameId: null, startAt: 0, deadlineAt: 0, clockOff: 0, score: 0, state: null, // 最近一次 state 响应 stats: null, onUpdate: null, // UI 回调 }; const sNow = () => Date.now() + M3.clockOff; const emit = () => { if (M3.onUpdate) try { M3.onUpdate(); } catch (e) {} }; function adopt(st) { if (!st) return; if (typeof st.server_now === 'number') M3.clockOff = st.server_now - Date.now(); if (typeof st.board === 'string' && st.board.length === 64) { M3.board = Int8Array.from(st.board.split('').map(Number)); } if (st.version != null) M3.version = st.version; if (st.id != null) M3.gameId = st.id; if (st.start_at) M3.startAt = st.start_at; if (st.deadline_at) M3.deadlineAt = st.deadline_at; if (st.score != null) M3.score = st.score; M3.state = st; } function newStats() { return { round: 0, rounds: 0, moves: 0, fasts: 0, stale: 0, rejects: 0, noMove: 0, score: 0, firstWave: 0, bestRun: 0, net: 0, coins: null, playedToday: null, dailyMax: null, perks: [], }; } // 返回 {ok:true} 或 {stop:'reason'} async function startRound(practice) { let st = await getState(); adopt(st); if (st.status === 'active' && M3.board) { // 已在局中,直接接管 return { ok: true, adopted: true }; } if (!practice) { const entry = st.entry || 100; if (st.coins != null && st.coins < entry) return { stop: '游戏币不足(' + st.coins + ' < ' + entry + ')' }; if (st.played_today != null && st.daily_max != null && st.played_today >= st.daily_max) return { stop: '今日计奖已达上限 ' + st.played_today + '/' + st.daily_max }; } st = await postForm(START_URL, { request: rid(), practice: practice ? 1 : 0 }); adopt(st); if (st.error) return { stop: '开局失败:' + st.error }; if (!M3.board || st.status !== 'active') return { stop: '无进行中的对局:' + JSON.stringify(st).slice(0, 160) }; return { ok: true }; } async function playRound(practice, roundNo) { const s = M3.stats; s.round = roundNo; const idAtStart = M3.gameId; const isPractice = !!(M3.state && M3.state.practice) || practice; const ws = M3.startAt - sNow(); if (ws > 0) { emit(); await sleep(ws + 120); } let nextAt = 0; let moves = 0; for (;;) { if (M3.abort) break; const now = sNow(); if (now >= M3.deadlineAt - OPTS.stopBeforeMs) break; const gap = nextAt - Date.now(); if (gap > 0) { await sleep(Math.min(gap, 300)); continue; } const mv = Engine.bestMove(M3.board, { rollouts: OPTS.rollouts, topK: OPTS.topK, seed: 1 + moves * 7919, }); if (!mv) { s.noMove++; await sleep(250); const f = await getState().catch(() => null); if (f) { adopt(f); if (f.status !== 'active') break; } emit(); continue; } if (mv.maxRun > s.bestRun) s.bestRun = mv.maxRun; const resp = await postForm(MOVE_URL, { game: M3.gameId, move: rid(), expected: M3.version, fr: mv.r1, fc: mv.c1, tr: mv.r2, tc: mv.c2, }).catch(() => null); if (!resp) { await sleep(150); continue; } if (typeof resp.server_now === 'number') M3.clockOff = resp.server_now - Date.now(); if (resp.code === 'fast') { s.fasts++; continue; } if (resp.error) { if (resp.code === 'over') break; if (resp.code === 'stale') s.stale++; else s.rejects++; if (resp.board && resp.status === 'active') adopt(resp); else { const f = await getState().catch(() => null); if (f) { adopt(f); if (f.status !== 'active') break; } } continue; } // 成功:采纳服务器权威状态 if (typeof resp.board === 'string' && resp.board.length === 64) M3.board = Int8Array.from(resp.board.split('').map(Number)); if (resp.version != null) M3.version = resp.version; if (resp.score != null) M3.score = resp.score; moves++; s.moves++; s.firstWave += mv.firstWave || 0; s.score = M3.score; if (resp.busy_until && resp.server_now != null) { const local = Date.now() + (resp.busy_until - resp.server_now); nextAt = Math.max(nextAt, local + OPTS.pacePad); } render(M3.board, M3.score); emit(); } // 结算:等服务器把本局标记为结束 let fin = null; for (let i = 0; i < 30; i++) { await sleep(i === 0 ? 350 : 700); const f = await getState().catch(() => null); if (!f) continue; if (f.id === idAtStart && f.status !== 'active') { fin = f; break; } if (f.status === 'active' && f.id !== idAtStart) { fin = f; break; } } if (fin) { adopt(fin); if (fin.score != null) s.score = fin.score; s.coins = fin.coins != null ? fin.coins : s.coins; s.playedToday = fin.played_today != null ? fin.played_today : s.playedToday; s.dailyMax = fin.daily_max != null ? fin.daily_max : s.dailyMax; const reward = fin.reward || 0; const entry = fin.entry || 0; s.perks.push({ practice: !!fin.practice, score: fin.score, reward, entry }); if (!fin.practice) s.net += (reward - entry); } emit(); return { moves, score: s.score, fin, isPractice }; } async function run(rounds, practice) { if (M3.running) return; M3.running = true; M3.abort = false; M3.stats = newStats(); M3.stats.rounds = rounds; emit(); try { for (let i = 1; i <= rounds; i++) { if (M3.abort) break; const st = await startRound(practice); if (st.stop) { ui.log('停止:' + st.stop); break; } await playRound(practice, i); if (M3.abort) break; // 计奖模式下,币/次数已耗尽就不必再开 if (!practice && M3.stats.dailyMax != null && M3.stats.playedToday >= M3.stats.dailyMax) { ui.log('今日计奖已达上限,结束'); break; } if (i < rounds) await sleep(2200); } } catch (e) { ui.log('异常:' + (e && e.message || e)); } finally { M3.running = false; emit(); ui.log('本轮任务结束'); } } // ═══════════════════════════════════════════════════════════════════════════ // 5. 界面 // ═══════════════════════════════════════════════════════════════════════════ const ui = {}; function buildUI() { const style = document.createElement('style'); style.textContent = ` #m3-panel{position:fixed;right:16px;bottom:16px;z-index:2147483000;width:280px; font:13px/1.5 -apple-system,"Segoe UI",Roboto,"Microsoft YaHei",sans-serif; color:#e8ecf1;background:#151a21;border:1px solid #2b3440;border-radius:12px; box-shadow:0 12px 32px rgba(0,0,0,.45);overflow:hidden;user-select:none} #m3-panel.collapsed #m3-body{display:none} #m3-head{display:flex;align-items:center;gap:8px;padding:10px 12px;cursor:move; background:linear-gradient(180deg,#1d242e,#171d25);border-bottom:1px solid #2b3440} #m3-head b{font-weight:600;font-size:13px;flex:1} #m3-head .dot{width:8px;height:8px;border-radius:50%;background:#435061} #m3-head .dot.on{background:#37d67a;box-shadow:0 0 8px #37d67a} #m3-head .dot.pause{background:#ffcc4d} #m3-min{cursor:pointer;color:#7d8a9a;padding:0 4px;background:none;border:0;font-size:14px} #m3-body{padding:12px} .m3-row{display:flex;align-items:center;gap:8px;margin-bottom:9px} .m3-row label{color:#9aa7b6;flex:0 0 auto} .m3-seg{display:flex;border:1px solid #2b3440;border-radius:8px;overflow:hidden;flex:1} .m3-seg button{flex:1;padding:6px 0;background:#1b2129;color:#9aa7b6;border:0;cursor:pointer;font-size:12px} .m3-seg button.sel{background:#2d6cdf;color:#fff} .m3-num{width:100%;flex:1;background:#0e1319;border:1px solid #2b3440;border-radius:8px; color:#e8ecf1;padding:6px 8px;font-size:12px} .m3-btns{display:flex;gap:8px;margin:4px 0 10px} .m3-btns button{flex:1;padding:8px 0;border:0;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600} #m3-run{background:#2d6cdf;color:#fff} #m3-run:disabled{background:#2b3440;color:#66738a;cursor:not-allowed} #m3-stop{background:#33202a;color:#ff8098} #m3-stop:disabled{opacity:.45;cursor:not-allowed} .m3-stats{display:grid;grid-template-columns:auto 1fr;gap:3px 10px;background:#0e1319; border:1px solid #232c37;border-radius:8px;padding:9px 10px;font-variant-numeric:tabular-nums} .m3-stats .k{color:#7d8a9a} .m3-stats .v{text-align:right;color:#e8ecf1} .m3-stats .v.big{color:#37d67a;font-weight:700} .m3-log{margin-top:9px;max-height:112px;overflow:auto;background:#0b0f14;border:1px solid #232c37; border-radius:8px;padding:7px 9px;font-size:11px;color:#8b98a8;white-space:pre-wrap} .m3-log div{overflow-wrap:anywhere} .m3-warn{margin-top:8px;font-size:11px;color:#ffcc4d;background:#2a2410;border:1px solid #4a3d12; border-radius:8px;padding:6px 8px;line-height:1.45} `; document.head.appendChild(style); const p = document.createElement('div'); p.id = 'm3-panel'; p.innerHTML = `
消消乐 · 自动播放
`; document.body.appendChild(p); ui.panel = p; // 拖动 const head = p.querySelector('#m3-head'); let drag = null; head.addEventListener('mousedown', (e) => { if (e.target.id === 'm3-min') return; const r = p.getBoundingClientRect(); drag = { dx: e.clientX - r.left, dy: e.clientY - r.top }; p.style.left = r.left + 'px'; p.style.top = r.top + 'px'; p.style.right = 'auto'; p.style.bottom = 'auto'; e.preventDefault(); }); window.addEventListener('mousemove', (e) => { if (!drag) return; p.style.left = Math.max(0, Math.min(window.innerWidth - 60, e.clientX - drag.dx)) + 'px'; p.style.top = Math.max(0, Math.min(window.innerHeight - 30, e.clientY - drag.dy)) + 'px'; }); window.addEventListener('mouseup', () => { drag = null; }); p.querySelector('#m3-min').addEventListener('click', () => p.classList.toggle('collapsed')); // 模式切换 const modeEl = p.querySelector('#m3-mode'); modeEl.addEventListener('click', (e) => { const b = e.target.closest('button'); if (!b) return; [...modeEl.children].forEach((x) => x.classList.toggle('sel', x === b)); refreshWarn(); }); ui.logEl = p.querySelector('#m3-log'); ui.statsEl = p.querySelector('#m3-stats'); ui.dot = p.querySelector('#m3-dot'); ui.warn = p.querySelector('#m3-warn'); ui.runBtn = p.querySelector('#m3-run'); ui.stopBtn = p.querySelector('#m3-stop'); ui.roundsEl = p.querySelector('#m3-rounds'); ui.log = (msg) => { const d = document.createElement('div'); const t = new Date().toLocaleTimeString('zh-CN', { hour12: false }); d.textContent = t + ' ' + msg; ui.logEl.appendChild(d); ui.logEl.scrollTop = ui.logEl.scrollHeight; while (ui.logEl.children.length > 200) ui.logEl.removeChild(ui.logEl.firstChild); }; const isPractice = () => modeEl.querySelector('button.sel').dataset.p === '1'; refreshWarn = function () { if (isPractice()) { ui.warn.style.display = 'none'; } else { ui.warn.style.display = ''; ui.warn.textContent = '计奖模式每局扣 100 游戏币,未满 800 分不返币;每日上限 10 局。请确认后再开始。'; } }; ui.refresh = () => { const s = M3.stats; if (!s) { ui.statsEl.innerHTML = '状态待机'; } else { const st = M3.state || {}; const coins = s.coins != null ? s.coins : (st.coins != null ? st.coins : '—'); const pt = s.playedToday != null ? s.playedToday : (st.played_today != null ? st.played_today : '—'); const dm = s.dailyMax != null ? s.dailyMax : (st.daily_max != null ? st.daily_max : '—'); ui.statsEl.innerHTML = [ ['局', s.round + ' / ' + s.rounds + (M3.abort ? '(将停止)' : '')], ['分数', '' + s.score + ''], ['落子', s.moves + ' 手 · 卡手 ' + s.noMove], ['计时', M3.deadlineAt ? Math.max(0, Math.round((M3.deadlineAt - sNow()) / 1000)) + ' 秒' : '—'], ['最快连', s.bestRun + ' 连'], ['游戏币', coins], ['今日计奖', pt + ' / ' + dm], ['净收益', (s.net >= 0 ? '+' : '') + s.net + ' 币'], ].map(([k, v]) => '' + k + '' + v + '').join(''); } ui.runBtn.disabled = M3.running; ui.stopBtn.disabled = !M3.running; ui.dot.className = 'dot' + (M3.running ? (M3.abort ? ' pause' : ' on') : ''); }; ui.runBtn.addEventListener('click', () => { const rounds = Math.max(1, Math.min(50, parseInt(ui.roundsEl.value, 10) || 1)); ui.roundsEl.value = rounds; const practice = isPractice(); ui.log('开始:' + (practice ? '练习' : '计奖') + ' × ' + rounds + ' 局'); run(rounds, practice); }); ui.stopBtn.addEventListener('click', () => { M3.abort = true; ui.log('已请求停止(当前这局打完即止)'); emit(); }); M3.onUpdate = ui.refresh; refreshWarn(); ui.refresh(); ui.log('就绪,请选择模式后开始。'); // 若页面已经在局中,把状态同步过来 getState().then((st) => { adopt(st); ui.refresh(); }).catch(() => {}); } let refreshWarn = () => {}; // 等 DOM 里出现棋盘/配置再挂 UI function boot() { if (!document.getElementById('match3-config') || !document.querySelector('[data-m3-board], .m3-board')) { return setTimeout(boot, 400); } buildUI(); window.__M3 = { M3, Engine, run, adopt, getState, playRound, OPTS }; } boot(); })();