万相匣 ① 游戏+效率

工具

适配设备:Pro

作者:lrj

下载量:14 · 点赞:0

可信源码风险提示:保存或下载前请阅读源码和权限声明。
SHA-256e71f029421548d7195c71bddcad5c1b41f3d3b269c2ecabc4a03f8e8383431d7
// ============================================================
//  万相匣 ① 游戏+效率
//  平台:JSlab 手环(324 × 480 竖屏)  文件 ① / 共 3 个
//  内含 11 个独立模块:扫雷 5×5、井字棋、色词测试、打地鼠、番茄钟、舒尔特方格、数字华容道、抽签、单位换算、掷骰子、电子木鱼
//  规范:每个模块为独立页面,页面底部均有独立的「返回」键
//  结构:state 信号 → 工具函数 → 各 renderXxx() → ui.render(switch) → main()
// ============================================================
const state = ui.signal({
  phase:  'splash',
  menuTab:  'game',
  page:  0,
  uiBg:  '#0f172a',
  vibrateOn:  true,
  logs:  [],
  device:  { model: '未知', brand: '' },
  deviceId:  '',
  battery:  { level: -1, charging: false },
  compassOk:  false,
  heading:  -1,
  headingAcc:  0,
  protocolFrom:  'splash',
  now:  Date.now(),
  mine:  { cells: [], over: false, flagMode: false, left: 6, win: false, t0: 0, sec: 0 },
  ttt:  { b: ['', '', '', '', '', '', '', '', ''], over: false, msg: '你执 X,点击落子', win: 0, lose: 0 },
  stroop: { word: 0, color: 0, score: 0, left: 30, run: false, endAt: 0, msg: '点出字本身的颜色' },
  mole:  { hole: -1, score: 0, best: 0, left: 30, run: false, nextAt: 0, endAt: 0 },
  pomo:  { left: 1500, run: false, endAt: 0, stage: 'work', done: 0 },
  schulte: { nums: [], idx: 0, t0: 0, sec: 0, run: false, best: 0 },
  puz:  { b: [1, 2, 3, 4, 5, 6, 7, 8, 0], moves: 0, over: false, best: 0 },
  pick:  { text: '吃饭,睡觉,打豆豆,跑步,看书,摸鱼', out: '' },
  conv:  { kind: 0, v: 1 },
  dice:  { vals: [1, 1], sum: 2, roll: 0 },
  muyu:  0,
});

const TTT_LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
const STROOP_C = ['红', '绿', '蓝', '黄'];
const STROOP_V = ['#ef4444', '#22c55e', '#38bdf8', '#f59e0b'];
const CONV_KINDS = [
  { name: 'km → mi', f: v => v * 0.621371, unit: 'mi' },
  { name: 'kg → lb', f: v => v * 2.20462, unit: 'lb' },
  { name: '℃ → ℉', f: v => v * 9 / 5 + 32, unit: '℉' },
  { name: 'cm → in', f: v => v / 2.54, unit: 'in' }
];

const MAX_LOGS = 30;

const UI_BGS = [
  { label: '深蓝', val: '#0f172a' },
  { label: '墨绿', val: '#0c2a1e' },
  { label: '暗紫', val: '#1c1335' },
  { label: '炭黑', val: '#111113' },
  { label: '黛青', val: '#12262e' }
];

const HELP_DESC = {
  mine: '翻开格子避开地雷,数字为周围雷数',
  ttt: '井字棋对战电脑,先连成三子获胜',
  stroop: '屏幕出现带颜色的字,选出字的颜色而非字义',
  mole: '9 宫格地鼠随机出现,限时点击得分',
  pomo: '番茄钟,25 分钟专注 + 5 分钟休息循环',
  schulte: '按 1~25 顺序点完方格,计时并记录最佳',
  puz: '滑块拼图,按顺序还原数字',
  picker: '自定义名单随机抽人,可编辑名单',
  conv: '单位换算,切换不同换算类别',
  dice: '摇骰子,一次掷出 1~6 随机点数',
  muyu: '电子木鱼,点一下敲一次并累计功德',
};

const GAMES = [
  { id: 'mine', name: '扫雷 5×5' },
  { id: 'ttt', name: '井字棋' },
  { id: 'stroop', name: '色词测试' },
  { id: 'mole', name: '打地鼠' },
  { id: 'schulte', name: '舒尔特方格' },
  { id: 'puz', name: '数字华容道' }
];

const TOOLS = [
  { id: 'pomo', name: '番茄钟' },
  { id: 'picker', name: '抽签' },
  { id: 'conv', name: '单位换算' },
  { id: 'dice', name: '掷骰子' },
  { id: 'muyu', name: '电子木鱼' }
];

let compassSub = false, lastCompass = 0;

function p2(n) { return n < 10 ? '0' + n : '' + n; }

function rndInt(n) { return Math.floor(Math.random() * n); }

function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }

function shuffleArr(a) {
  for (let i = a.length - 1; i > 0; i--) { const j = rndInt(i + 1); const t = a[i]; a[i] = a[j]; a[j] = t; }
  return a;
}

function fmtTime(ts) {
  const d = new Date(ts);
  return p2(d.getHours()) + ':' + p2(d.getMinutes()) + ':' + p2(d.getSeconds());
}

function fmtDate(ts) {
  const d = new Date(ts);
  const wk = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()];
  return (d.getMonth() + 1) + '月' + d.getDate() + '日 周' + wk;
}

function ms2str(ms) {
  if (ms < 0) ms = 0;
  const cs = Math.floor(ms / 10) % 100;
  const sec = Math.floor(ms / 1000) % 60;
  const min = Math.floor(ms / 60000);
  return p2(min) + ':' + p2(sec) + '.' + p2(cs);
}

function sec2str(sec) {
  const s = Math.max(0, Math.floor(sec));
  return p2(Math.floor(s / 60)) + ':' + p2(s % 60);
}

function log(msg) {
  state.update(s => ({ ...s, logs: [{ t: Date.now(), m: msg }, ...s.logs].slice(0, MAX_LOGS) }));
  console.log(msg);
}

function vib() {
  const s = state.get();
  if (s.vibrateOn) { try { system.vibration.vibrate({ mode: 'short' }); } catch (e) { } }
}

function vibLong() { try { system.vibration.vibrate({ mode: 'long' }); } catch (e) { } }

async function initDevice() {
  const compassOk = script.canUse('@system.sensor.subscribeCompass');
  state.update(s => ({ ...s, compassOk }));
  log('能力检测:指南针=' + (compassOk ? '支持' : '不支持'));
  try {
    system.device.getInfo({
      success: res => state.update(s => ({ ...s, device: { model: res.model || '未知', brand: res.brand || '' } })),
      fail: () => { }
    });
  } catch (e) { }
  try {
    system.device.getDeviceId({
      success: res => state.update(s => ({ ...s, deviceId: res.deviceId || '' })),
      fail: () => { }
    });
  } catch (e) { }
  checkBattery();
}

function checkBattery() {
  try {
    system.battery.getStatus({
      success: res => {
        const level = Math.round(res.level * 100);
        state.update(p => ({ ...p, battery: { level, charging: !!res.charging } }));
      },
      fail: () => { }
    });
  } catch (e) { }
}

function startCompass() {
  if (compassSub) return;
  if (!state.get().compassOk) { script.toast('本设备不支持指南针', 1800); return; }
  compassSub = true;
  try {
    system.sensors.subscribeCompass({
      callback: res => {
        const nw = Date.now();
        if (nw - lastCompass < 200) return;
        lastCompass = nw;
        const deg = ((res.direction * 180 / Math.PI) % 360 + 360) % 360;
        state.update(s => ({ ...s, heading: Math.round(deg), headingAcc: res.accuracy || 0 }));
      },
      fail: () => { compassSub = false; log('指南针订阅失败'); }
    });
  } catch (e) { compassSub = false; }
}

function stopCompass() {
  if (!compassSub) return;
  try { system.sensors.unsubscribeCompass(); } catch (e) { }
  compassSub = false;
  state.update(s => ({ ...s, heading: -1 }));
}

function stopAll() {
  stopCompass();
  try { system.display.setKeepScreenOn({ keepScreenOn: false }); } catch (e) { }
  state.update(p => ({
    ...p,
    pomo: { ...p.pomo, run: false },
    mole: { ...p.mole, run: false },
  }));
}

function backToMenu() {
  stopAll();
  state.update(p => ({ ...p, phase: 'menu', page: 0 }));
  log('返回主菜单');
}

function backBtn(id, flex) {
  return ui.button('返回', backToMenu, { id: id, tone: 'danger', flex: flex || 1, height: 42 });
}

function enter(id) {
  vib();
  switch (id) {
    case 'mine': initMine(); break;
    case 'ttt': initTtt(); break;
    case 'stroop': initStroop(); break;
    case 'mole': initMole(); break;
    case 'pomo': state.update(p => ({ ...p, phase: 'pomo' })); break;
    case 'schulte': initSchulte(); break;
    case 'puz': initPuz(); break;
    case 'picker': state.update(p => ({ ...p, phase: 'picker' })); break;
    case 'conv': state.update(p => ({ ...p, phase: 'conv' })); break;
    case 'dice': state.update(p => ({ ...p, phase: 'dice' })); break;
    case 'muyu': state.update(p => ({ ...p, phase: 'muyu' })); break;
    default: state.update(p => ({ ...p, phase: 'menu' }));
  }
}

// ============================================================
//  模块逻辑
// ============================================================

function initMine() {
  const cells = [];
  for (let i = 0; i < 25; i++) cells.push({ m: false, o: false, f: false, n: 0 });
  let placed = 0;
  while (placed < 6) {
    const i = rndInt(25);
    if (!cells[i].m) { cells[i].m = true; placed++; }
  }
  for (let i = 0; i < 25; i++) {
    const x = i % 5, y = Math.floor(i / 5);
    let n = 0;
    for (let dy = -1; dy <= 1; dy++) {
      for (let dx = -1; dx <= 1; dx++) {
        if (!dx && !dy) continue;
        const ax = x + dx, ay = y + dy;
        if (ax < 0 || ax > 4 || ay < 0 || ay > 4) continue;
        if (cells[ay * 5 + ax].m) n++;
      }
    }
    cells[i].n = n;
  }
  state.update(p => ({ ...p, mine: { cells, over: false, flagMode: false, left: 6, win: false, t0: Date.now(), sec: 0 }, phase: 'mine' }));
}

function mineOpen(cells, i) {
  if (i < 0 || i > 24) return;
  const c = cells[i];
  if (c.o || c.f) return;
  c.o = true;
  if (c.n === 0 && !c.m) {
    const x = i % 5, y = Math.floor(i / 5);
    for (let dy = -1; dy <= 1; dy++) {
      for (let dx = -1; dx <= 1; dx++) {
        if (!dx && !dy) continue;
        const ax = x + dx, ay = y + dy;
        if (ax < 0 || ax > 4 || ay < 0 || ay > 4) continue;
        mineOpen(cells, ay * 5 + ax);
      }
    }
  }
}

function mineTap(i) {
  const s = state.get();
  if (s.mine.over) return;
  const cells = s.mine.cells.map(c => ({ ...c }));
  const sec = s.mine.t0 ? Math.floor((Date.now() - s.mine.t0) / 1000) : 0;
  if (s.mine.flagMode) {
    if (!cells[i].o) { cells[i].f = !cells[i].f; vib(); }
    const left = cells.filter(c => c.f && c.m).length;
    state.update(p => ({ ...p, mine: { ...s.mine, cells, left: Math.max(0, 6 - cells.filter(c => c.f).length), sec } }));
    return;
  }
  if (cells[i].f) return;
  if (cells[i].m) {
    cells.forEach(c => { if (c.m) c.o = true; });
    state.update(p => ({ ...p, mine: { ...s.mine, cells, over: true, sec } }));
    vibLong();
    script.toast('踩雷了', 1500);
    return;
  }
  mineOpen(cells, i);
  const rest = cells.filter(c => !c.m && !c.o).length;
  const win = rest === 0;
  state.update(p => ({ ...p, mine: { ...s.mine, cells, win, over: win, sec } }));
  if (win) { vibLong(); script.toast('扫雷成功 ' + sec + ' 秒', 2000); }
}

function tttWinner(b) {
  for (let i = 0; i < TTT_LINES.length; i++) {
    const L = TTT_LINES[i];
    if (b[L[0]] && b[L[0]] === b[L[1]] && b[L[1]] === b[L[2]]) return b[L[0]];
  }
  return b.indexOf('') < 0 ? 'D' : '';
}

function tttAI(b) {
  for (let i = 0; i < 9; i++) {
    if (!b[i]) { const t = b.slice(); t[i] = 'O'; if (tttWinner(t) === 'O') return i; }
  }
  for (let i = 0; i < 9; i++) {
    if (!b[i]) { const t = b.slice(); t[i] = 'X'; if (tttWinner(t) === 'X') return i; }
  }
  if (!b[4]) return 4;
  const corners = [0, 2, 6, 8].filter(i => !b[i]);
  if (corners.length) return corners[rndInt(corners.length)];
  const rest = [];
  for (let i = 0; i < 9; i++) if (!b[i]) rest.push(i);
  return rest.length ? rest[rndInt(rest.length)] : -1;
}

function initTtt() {
  state.update(p => ({
    ...p,
    ttt: { b: ['', '', '', '', '', '', '', '', ''], over: false, msg: '你执 X,点击落子', win: p.ttt.win || 0, lose: p.ttt.lose || 0 },
    phase: 'ttt'
  }));
}

function tttTap(i) {
  const s = state.get();
  const t = s.ttt;
  if (t.over || t.b[i]) return;
  const b = t.b.slice();
  b[i] = 'X';
  vib();
  let w = tttWinner(b);
  let over = w !== '', msg = '轮到你落子', win = t.win, lose = t.lose;
  if (w === 'X') { msg = '你赢了!'; win++; vibLong(); }
  else if (w === 'D') { msg = '平局'; }
  else {
    const ai = tttAI(b);
    if (ai >= 0) b[ai] = 'O';
    w = tttWinner(b);
    if (w === 'O') { msg = '你输了'; lose++; over = true; vib(); }
    else if (w === 'D') { msg = '平局'; over = true; }
  }
  state.update(p => ({ ...p, ttt: { b, over, msg, win, lose } }));
}

function initStroop() {
  state.update(p => ({ ...p, stroop: { word: 0, color: 0, score: 0, left: 30, run: true, endAt: Date.now() + 30000, msg: '点出字本身的颜色' }, phase: 'stroop' }));
  nextStroop();
}
function nextStroop() {
  const w = rndInt(4);
  let c = rndInt(4), guard = 0;
  while (c === w && guard < 20) { c = rndInt(4); guard++; }
  state.update(p => ({ ...p, stroop: { ...p.stroop, word: w, color: c } }));
}
function stroopTap(i) {
  const s = state.get();
  if (!s.stroop.run) { script.toast('点「重开」开始', 1200); return; }
  if (i === s.stroop.color) {
    vib();
    state.update(p => ({ ...p, stroop: { ...p.stroop, score: p.stroop.score + 1, msg: '答对 +1' } }));
  } else {
    state.update(p => ({ ...p, stroop: { ...p.stroop, msg: '错了,应为「' + STROOP_C[s.stroop.color] + '」' } }));
  }
  nextStroop();
}
function tickStroop(nw) {
  const s = state.get();
  if (!s.stroop.run) return;
  const left = Math.max(0, Math.ceil((s.stroop.endAt - nw) / 1000));
  if (left !== s.stroop.left) state.update(p => ({ ...p, stroop: { ...p.stroop, left } }));
  if (left <= 0) {
    state.update(p => ({ ...p, stroop: { ...p.stroop, run: false, msg: '结束,共答对 ' + p.stroop.score + ' 个' } }));
    vibLong();
  }
}

function initMole() {
  const now = Date.now();
  state.update(p => ({
    ...p,
    mole: { hole: -1, score: 0, best: p.mole.best || 0, left: 30, run: true, nextAt: now + 600, endAt: now + 30000 },
    phase: 'mole'
  }));
}

function tickMole(nw) {
  const s = state.get();
  const m = s.mole;
  if (!m.run) return;
  if (nw >= m.endAt) {
    const best = Math.max(m.best || 0, m.score);
    state.update(p => ({ ...p, mole: { ...p.mole, run: false, left: 0, hole: -1, best } }));
    vibLong();
    script.toast('时间到 得分 ' + m.score, 2000);
    return;
  }
  const left = Math.max(0, Math.ceil((m.endAt - nw) / 1000));
  if (nw >= m.nextAt) {
    state.update(p => ({ ...p, mole: { ...p.mole, left, hole: rndInt(9), nextAt: nw + 700 + rndInt(500) } }));
  } else if (left !== m.left) {
    state.update(p => ({ ...p, mole: { ...p.mole, left } }));
  }
}

function moleHit(i) {
  const s = state.get();
  const m = s.mole;
  if (!m.run) { script.toast('点击「重开」开始', 1200); return; }
  if (m.hole === i) {
    vib();
    state.update(p => ({ ...p, mole: { ...p.mole, score: p.mole.score + 10, hole: -1, nextAt: Date.now() + 260 } }));
  } else {
    state.update(p => ({ ...p, mole: { ...p.mole, score: Math.max(0, p.mole.score - 5) } }));
    script.toast('-5', 500);
  }
}

function pomoToggle() {
  const s = state.get();
  const m = s.pomo;
  if (m.run) {
    const left = Math.max(0, Math.ceil((m.endAt - Date.now()) / 1000));
    state.update(p => ({ ...p, pomo: { ...p.pomo, run: false, left, endAt: 0 } }));
    vib();
  } else {
    const left = m.left > 0 ? m.left : (m.stage === 'work' ? 1500 : 300);
    state.update(p => ({ ...p, pomo: { ...p.pomo, run: true, left, endAt: Date.now() + left * 1000 } }));
    vib();
  }
}

function pomoReset() {
  state.update(p => ({ ...p, pomo: { left: 1500, run: false, endAt: 0, stage: 'work', done: p.pomo.done || 0 } }));
  script.toast('已回到专注阶段', 1200);
}

function tickPomo(nw) {
  const s = state.get();
  const m = s.pomo;
  if (!m.run) return;
  const left = Math.max(0, Math.ceil((m.endAt - nw) / 1000));
  if (left === 0) {
    if (m.stage === 'work') {
      state.update(p => ({ ...p, pomo: { stage: 'rest', left: 300, run: true, endAt: nw + 300000, done: (p.pomo.done || 0) + 1 } }));
      vibLong();
      script.toast('专注满 25 分钟,休息 5 分钟', 2500);
    } else {
      state.update(p => ({ ...p, pomo: { ...p.pomo, stage: 'work', left: 1500, run: false, endAt: 0 } }));
      vibLong();
      script.toast('休息结束,开始新一轮专注', 2500);
    }
  } else if (left !== m.left) {
    state.update(p => ({ ...p, pomo: { ...p.pomo, left } }));
  }
}

function initSchulte() {
  const a = [];
  for (let i = 1; i <= 25; i++) a.push(i);
  shuffleArr(a);
  state.update(p => ({ ...p, schulte: { nums: a, idx: 0, t0: Date.now(), sec: 0, run: true, best: p.schulte.best || 0 }, phase: 'schulte' }));
}
function schulteTap(i) {
  const s = state.get();
  const q = s.schulte;
  if (!q.run) { script.toast('点「重开」开始', 1200); return; }
  if (q.nums[i] !== q.idx + 1) { script.toast('该找 ' + (q.idx + 1), 900); return; }
  vib();
  const idx = q.idx + 1;
  if (idx >= 25) {
    const sec = Math.floor((Date.now() - q.t0) / 1000);
    const best = q.best ? Math.min(q.best, sec) : sec;
    state.update(p => ({ ...p, schulte: { ...p.schulte, idx: 25, run: false, sec, best } }));
    vibLong();
    script.toast('完成!用时 ' + sec + ' 秒', 2200);
  } else {
    state.update(p => ({ ...p, schulte: { ...p.schulte, idx } }));
  }
}
function tickSchulte(nw) {
  const s = state.get();
  if (!s.schulte.run) return;
  const sec = Math.floor((nw - s.schulte.t0) / 1000);
  if (sec !== s.schulte.sec) state.update(p => ({ ...p, schulte: { ...p.schulte, sec } }));
}

function initPuz() {
  const b = [1, 2, 3, 4, 5, 6, 7, 8, 0];
  for (let i = 0; i < 80; i++) {
    const z = b.indexOf(0);
    const zx = z % 3, zy = Math.floor(z / 3);
    const opts = [];
    if (zx > 0) opts.push(z - 1);
    if (zx < 2) opts.push(z + 1);
    if (zy > 0) opts.push(z - 3);
    if (zy < 2) opts.push(z + 3);
    const t = opts[rndInt(opts.length)];
    b[z] = b[t]; b[t] = 0;
  }
  state.update(p => ({ ...p, puz: { b, moves: 0, over: false, best: p.puz.best || 0 }, phase: 'puz' }));
}

function puzTap(i) {
  const s = state.get();
  const p = s.puz;
  if (p.over) return;
  const b = p.b.slice();
  const z = b.indexOf(0);
  const zx = z % 3, zy = Math.floor(z / 3);
  const x = i % 3, y = Math.floor(i / 3);
  if (Math.abs(zx - x) + Math.abs(zy - y) !== 1) return;
  b[z] = b[i]; b[i] = 0;
  vib();
  const moves = p.moves + 1;
  const over = b.join(',') === '1,2,3,4,5,6,7,8,0';
  const best = over ? (p.best ? Math.min(p.best, moves) : moves) : (p.best || 0);
  state.update(pp => ({ ...pp, puz: { b, moves, over, best } }));
  if (over) { vibLong(); script.toast('完成!' + moves + ' 步', 2000); }
}

async function pickerEdit() {
  const s = state.get();
  const r = await dialog.text({ title: '编辑候选项', message: '用英文逗号分隔,最多 12 项', value: s.pick.text, maxLength: 120, required: true });
  if (r.action !== 'confirm') return;
  const txt = (r.value || '').trim();
  const items = txt.split(',').map(t => t.trim()).filter(t => t.length > 0).slice(0, 12);
  if (items.length < 2) { script.toast('至少需要 2 个候选项', 1800); return; }
  state.update(p => ({ ...p, pick: { text: items.join(','), out: '' } }));
  script.toast('已保存 ' + items.length + ' 项', 1500);
}

function pickerDraw() {
  const s = state.get();
  const items = s.pick.text.split(',').map(t => t.trim()).filter(t => t.length > 0);
  if (items.length < 2) { script.toast('候选不足,请先编辑', 1500); return; }
  const out = items[rndInt(items.length)];
  vibLong();
  state.update(p => ({ ...p, pick: { ...p.pick, out } }));
}

async function convEdit() {
  const s = state.get();
  const r = await dialog.number({ title: '输入数值', message: CONV_KINDS[s.conv.kind].name, value: s.conv.v, decimals: 3 });
  if (r.action !== 'confirm') return;
  const v = r.value;
  if (!isFinite(v)) { script.toast('数值无效', 1200); return; }
  state.update(p => ({ ...p, conv: { ...p.conv, v } }));
}

function convCycle() {
  const s = state.get();
  state.update(p => ({ ...p, conv: { ...p.conv, kind: (s.conv.kind + 1) % CONV_KINDS.length } }));
  vib();
}

function diceRoll() {
  const n = 2;
  const vals = [];
  let sum = 0;
  for (let i = 0; i < n; i++) { const v = 1 + rndInt(6); vals.push(v); sum += v; }
  vib();
  state.update(p => ({ ...p, dice: { vals, sum, roll: (p.dice.roll || 0) + 1 } }));
}

function diceOne() {
  const v = 1 + rndInt(6);
  vib();
  state.update(p => ({ ...p, dice: { vals: [v], sum: v, roll: (p.dice.roll || 0) + 1 } }));
}

function muyuTap() {
  vib();
  state.update(p => ({ ...p, muyu: (p.muyu || 0) + 1 }));
}

// ============================================================
//  渲染
// ============================================================

function renderSplash() {
  return [
    ui.column([
      ui.spacer(120),
      ui.heading('万相匣', { align: 'center', size: 34, color: '#e2b13c', lines: 1 }),
      ui.spacer(10),
      ui.text('游戏百宝 · 工具随身', { align: 'center', size: 16, color: '#94a3b8', lines: 1 }),
      ui.spacer(30),
      ui.text('正在加载...', { align: 'center', size: 13, color: '#64748b', lines: 1 })
    ], { background: state.get().uiBg })
  ];
}

function renderProtocol() {
  const fromAbout = state.get().protocolFrom === 'about';
  return [
    ui.column([
      ui.heading('用户协议与免责声明', { align: 'center', size: 18, color: '#e2b13c', lines: 1 }),
      ui.spacer(6),
      ui.text('一、软件性质\n本软件《万相匣》由人工智能(AI)自动生成,仅供个人学习、研究、技术交流使用,不构成任何商业产品,不提供任何服务承诺。', { size: 13, color: '#cbd5e1', lines: 4, lineHeight: 18 }),
      ui.text('二、使用许可\n1. 用户可出于学习目的自由使用、研究本软件。\n2. 禁止对本软件代码洗稿、二次打包、倒卖、出租、出售或用于商业盈利。\n3. 禁止用于任何违法用途。', { size: 13, color: '#cbd5e1', lines: 6, lineHeight: 18 }),
      ui.text('三、数据与隐私\n1. 指南针、设备、电量等信息仅在本机处理,不上传任何服务器。\n2. 不收集、不存储任何个人身份信息。\n3. 游戏分数与设置仅保存在本机。', { size: 13, color: '#cbd5e1', lines: 6, lineHeight: 18 }),
      ui.text('四、免责\n1. 指南针、BMI、单位换算结果仅供参考,可能因硬件与环境存在误差。\n2. 手电筒会拉满亮度,长时间使用增加耗电与发热。\n3. 请遵守当地法律法规,注意人身安全。', { size: 13, color: '#cbd5e1', lines: 6, lineHeight: 18 }),
      ui.spacer(6),
      ui.text('本人已阅读并同意以上全部条款。', { align: 'center', size: 14, bold: true, color: '#e2b13c', lines: 1 }),
      ui.spacer(6),
      ui.button(fromAbout ? '返回' : '同意并进入', () => {
        state.update(p => ({ ...p, phase: fromAbout ? 'about' : 'menu', page: 0 }));
      }, { id: 'pr_ok', tone: 'primary', height: 46 }),
      ui.spacer(5),
      ui.button('不同意,退出', () => ui.hide(), { id: 'pr_no', tone: 'danger', height: 42 })
    ], { background: state.get().uiBg })
  ];
}

function menuMeta() {
  const s = state.get();
  const list = s.menuTab === 'game' ? GAMES : TOOLS;
  const PER = 4;
  const pages = Math.max(1, Math.ceil(list.length / PER));
  const page = clamp(s.page, 0, pages - 1);
  return { list, pages, page, PER, items: list.slice(page * PER, page * PER + PER) };
}

function renderMenu() {
  const s = state.get();
  const mm = menuMeta();
  const nodes = [];
  nodes.push(ui.heading('万相匣', { align: 'center', size: 22, color: '#e2b13c', lines: 1 }));
  nodes.push(ui.spacer(2));
  nodes.push(ui.text(s.device.model + (s.deviceId ? ' · ' + s.deviceId.slice(0, 6) + '****' : ''), { align: 'center', size: 11, color: '#64748b', lines: 1 }));
  nodes.push(ui.spacer(4));
  nodes.push(ui.row([
    ui.button('游戏 ' + GAMES.length, () => { vib(); state.update(p => ({ ...p, menuTab: 'game', page: 0 })); }, { id: 'tab_g', tone: s.menuTab === 'game' ? 'primary' : 'neutral', flex: 1, height: 36 }),
    ui.button('工具 ' + TOOLS.length, () => { vib(); state.update(p => ({ ...p, menuTab: 'tool', page: 0 })); }, { id: 'tab_t', tone: s.menuTab === 'tool' ? 'primary' : 'neutral', flex: 1, height: 36 })
  ], { gap: 6 }));
  nodes.push(ui.spacer(4));
  nodes.push(ui.row([
    ui.text('电量 ' + (s.battery.level >= 0 ? s.battery.level + '%' : '--') + (s.battery.charging ? ' 充电中' : ''), { size: 11, lines: 1, color: s.battery.level >= 0 && s.battery.level <= 20 ? '#ef4444' : '#94a3b8', flex: 1 }),
    ui.text((mm.page + 1) + ' / ' + mm.pages, { size: 11, lines: 1, align: 'right', color: '#64748b', flex: 1 })
  ], { justify: 'between' }));
  nodes.push(ui.spacer(6));
  for (let i = 0; i < mm.items.length; i++) {
    const it = mm.items[i];
    const n = mm.page * mm.PER + i + 1;
    nodes.push(ui.button(n + '. ' + it.name, () => enter(it.id), { id: 'mn_' + it.id, tone: 'neutral', height: 46 }));
    nodes.push(ui.spacer(4));
  }
  nodes.push(ui.row([
    ui.button('◀ 上页', () => {
      const p = state.get().page;
      if (p > 0) { vib(); state.update(x => ({ ...x, page: p - 1 })); } else script.toast('已是第一页', 1000);
    }, { id: 'pg_prev', tone: 'neutral', flex: 1, height: 38 }),
    ui.button('下页 ▶', () => {
      const st0 = state.get();
      const n = st0.menuTab === 'game' ? GAMES.length : TOOLS.length;
      const pages = Math.max(1, Math.ceil(n / 4));
      if (st0.page < pages - 1) { vib(); state.update(x => ({ ...x, page: st0.page + 1 })); } else script.toast('已是最后一页', 1000);
    }, { id: 'pg_next', tone: 'neutral', flex: 1, height: 38 })
  ], { gap: 6 }));
  nodes.push(ui.spacer(5));
  nodes.push(ui.row([
    ui.button('设置', () => state.update(p => ({ ...p, phase: 'settings' })), { id: 'm_set', tone: 'neutral', flex: 1, height: 36 }),
    ui.button('说明', () => state.update(p => ({ ...p, phase: 'help' })), { id: 'm_hp', tone: 'neutral', flex: 1, height: 36 }),
    ui.button('关于', () => state.update(p => ({ ...p, phase: 'about' })), { id: 'm_ab', tone: 'neutral', flex: 1, height: 36 })
  ], { gap: 6 }));
  return [ui.column(nodes, { background: s.uiBg })];
}

function renderMine() {
  const s = state.get();
  const m = s.mine;
  const NUMC = ['#cbd5e1', '#38bdf8', '#22c55e', '#f59e0b', '#f97316', '#ef4444', '#a855f7', '#e2b13c'];
  const rows = [];
  for (let r = 0; r < 5; r++) {
    const cells = [];
    for (let c = 0; c < 5; c++) {
      const i = r * 5 + c;
      const cell = m.cells[i];
      let txt = '·', bg = '#1e3a5f', col = '#94a3b8';
      if (cell.f && !cell.o) { txt = '旗'; bg = '#7c2d12'; col = '#f59e0b'; }
      else if (cell.o && cell.m) { txt = '雷'; bg = '#991b1b'; col = '#ffffff'; }
      else if (cell.o) {
        bg = '#0f172a';
        if (cell.n > 0) { txt = '' + cell.n; col = NUMC[cell.n]; }
        else { txt = ''; }
      }
      cells.push(ui.button(txt, () => mineTap(i), {
        id: 'mi_' + i, tone: 'neutral', flex: 1, height: 34, background: bg, color: col
      }));
    }
    rows.push(ui.row(cells, { gap: 4 }));
    if (r < 4) rows.push(ui.spacer(4));
  }
  return [
    ui.column([
      ui.heading('扫雷 5×5', { align: 'center', size: 18, color: '#ef4444', lines: 1 }),
      ui.spacer(2),
      ui.row([
        ui.text('剩余雷 ' + m.left, { size: 12, lines: 1, color: '#f59e0b', flex: 1 }),
        ui.text('用时 ' + m.sec + 's', { size: 12, lines: 1, align: 'right', color: '#94a3b8', flex: 1 })
      ], { justify: 'between' }),
      ui.text(m.over ? (m.win ? '扫雷成功!' : '踩雷了,点「重开」') : '共 6 颗雷,数字为周围雷数', {
        align: 'center', size: 11, color: m.over ? (m.win ? '#22c55e' : '#ef4444') : '#64748b', lines: 1
      }),
      ui.spacer(6),
      ui.column(rows, { width: 324 }),
      ui.spacer(6),
      ui.row([
        ui.button(m.flagMode ? '插旗模式' : '挖开模式', () => {
          vib();
          state.update(p => ({ ...p, mine: { ...p.mine, flagMode: !p.mine.flagMode } }));
        }, { id: 'mi_mode', tone: m.flagMode ? 'primary' : 'neutral', flex: 1, height: 40 }),
        ui.button('重开', () => { vib(); initMine(); }, { id: 'mi_new', tone: 'neutral', flex: 1, height: 40 })
      ], { gap: 5 }),
      ui.spacer(5),
      backBtn('mi_back')
    ], { background: s.uiBg })
  ];
}

function renderTtt() {
  const s = state.get();
  const t = s.ttt;
  const rows = [];
  for (let r = 0; r < 3; r++) {
    const cells = [];
    for (let c = 0; c < 3; c++) {
      const i = r * 3 + c;
      const v = t.b[i];
      cells.push(ui.button(v || '·', () => tttTap(i), {
        id: 'tt_' + i, tone: 'neutral', flex: 1, height: 62,
        background: '#16233a', color: v === 'X' ? '#38bdf8' : (v === 'O' ? '#f59e0b' : '#475569')
      }));
    }
    rows.push(ui.row(cells, { gap: 6 }));
    if (r < 2) rows.push(ui.spacer(6));
  }
  return [
    ui.column([
      ui.heading('井字棋', { align: 'center', size: 18, color: '#22c55e', lines: 1 }),
      ui.spacer(2),
      ui.row([
        ui.text('胜 ' + t.win, { size: 12, lines: 1, color: '#22c55e', flex: 1 }),
        ui.text('负 ' + t.lose, { size: 12, lines: 1, align: 'right', color: '#ef4444', flex: 1 })
      ], { justify: 'between' }),
      ui.text(t.msg, { align: 'center', size: 13, color: t.over ? '#e2b13c' : '#cbd5e1', lines: 1 }),
      ui.spacer(6),
      ui.column(rows, { width: 324 }),
      ui.spacer(8),
      ui.button('重新开局', () => { vib(); initTtt(); }, { id: 'tt_new', tone: 'primary', height: 42 }),
      ui.spacer(5),
      backBtn('tt_back')
    ], { background: s.uiBg })
  ];
}

function renderStroop() {
  const s = state.get();
  const q = s.stroop;
  const rows = [];
  for (let r = 0; r < 2; r++) {
    const cells = [];
    for (let c = 0; c < 2; c++) {
      const i = r * 2 + c;
      cells.push(ui.button(STROOP_C[i], () => stroopTap(i), {
        id: 'st_' + i, tone: 'neutral', flex: 1, height: 54,
        background: '#16233a', color: STROOP_V[i]
      }));
    }
    rows.push(ui.row(cells, { gap: 6 }));
    rows.push(ui.spacer(6));
  }
  return [
    ui.column([
      ui.heading('色词测试', { align: 'center', size: 18, color: '#38bdf8', lines: 1 }),
      ui.spacer(2),
      ui.row([
        ui.text('答对 ' + q.score, { size: 12, lines: 1, color: '#22c55e', flex: 1 }),
        ui.text('剩余 ' + q.left + 's', { size: 12, lines: 1, align: 'right', color: q.left <= 5 ? '#ef4444' : '#94a3b8', flex: 1 })
      ], { justify: 'between' }),
      ui.spacer(8),
      ui.text(STROOP_C[q.word], { align: 'center', size: 56, bold: true, color: STROOP_V[q.color], lines: 1 }),
      ui.spacer(4),
      ui.text('选出这个「字」的颜色,不是字义', { align: 'center', size: 11, color: '#64748b', lines: 1 }),
      ui.spacer(6)
    ].concat(rows).concat([
      ui.text(q.msg, { align: 'center', size: 12, color: '#cbd5e1', lines: 2 }),
      ui.spacer(6),
      ui.row([
        ui.button('重开', () => { vib(); initStroop(); }, { id: 'st_new', tone: 'primary', flex: 1, height: 42 }),
        backBtn('st_back')
      ], { gap: 5 })
    ]), { background: s.uiBg })
  ];
}

function renderMole() {
  const s = state.get();
  const m = s.mole;
  const rows = [];
  for (let r = 0; r < 3; r++) {
    const cells = [];
    for (let c = 0; c < 3; c++) {
      const i = r * 3 + c;
      const on = m.hole === i;
      cells.push(ui.button(on ? '●' : '·', () => moleHit(i), {
        id: 'mo_' + i, tone: 'neutral', flex: 1, height: 62,
        background: on ? '#14532d' : '#16233a', color: on ? '#22c55e' : '#475569'
      }));
    }
    rows.push(ui.row(cells, { gap: 6 }));
    if (r < 2) rows.push(ui.spacer(6));
  }
  return [
    ui.column([
      ui.heading('打地鼠', { align: 'center', size: 18, color: '#f59e0b', lines: 1 }),
      ui.spacer(2),
      ui.row([
        ui.text('得分 ' + m.score, { size: 12, lines: 1, color: '#38bdf8', flex: 1 }),
        ui.text('剩余 ' + m.left + 's', { size: 12, lines: 1, align: 'center', color: m.left <= 5 ? '#ef4444' : '#cbd5e1', flex: 1 }),
        ui.text('最高 ' + (m.best || 0), { size: 12, lines: 1, align: 'right', color: '#94a3b8', flex: 1 })
      ], { justify: 'between' }),
      ui.text(m.run ? '冒头就打,点错扣 5 分' : '点「重开」开始 30 秒挑战', { align: 'center', size: 11, color: m.run ? '#64748b' : '#f59e0b', lines: 1 }),
      ui.spacer(6),
      ui.column(rows, { width: 324 }),
      ui.spacer(8),
      ui.row([
        ui.button('重开', () => { vib(); initMole(); }, { id: 'mo_new', tone: 'primary', flex: 1, height: 44 }),
        backBtn('mo_back')
      ], { gap: 5 })
    ], { background: s.uiBg })
  ];
}

function renderPomo() {
  const s = state.get();
  const m = s.pomo;
  const isWork = m.stage === 'work';
  const total = isWork ? 1500 : 300;
  const pct = clamp(Math.round((1 - m.left / total) * 100), 0, 100);
  return [
    ui.column([
      ui.heading('番茄钟', { align: 'center', size: 18, color: isWork ? '#ef4444' : '#22c55e', lines: 1 }),
      ui.spacer(4),
      ui.text(isWork ? '专注阶段 25 分钟' : '休息阶段 5 分钟', { align: 'center', size: 13, bold: true, color: isWork ? '#ef4444' : '#22c55e', lines: 1 }),
      ui.spacer(6),
      ui.text(sec2str(m.left), { align: 'center', size: 44, bold: true, color: '#e2b13c', lines: 1 }),
      ui.text('已完成 ' + (m.done || 0) + ' 个番茄', { align: 'center', size: 12, color: '#94a3b8', lines: 1 }),
      ui.spacer(8),
      ui.progress(isWork ? '专注进度' : '休息进度', pct, { accent: isWork ? '#ef4444' : '#22c55e' }),
      ui.spacer(8),
      ui.row([
        ui.button(m.run ? '暂停' : '开始', pomoToggle, { id: 'pm_play', tone: 'primary', flex: 1, height: 50 }),
        ui.button('重置', pomoReset, { id: 'pm_reset', tone: 'neutral', flex: 1, height: 50 })
      ], { gap: 6 }),
      ui.spacer(8),
      ui.text('专注结束自动进入 5 分钟休息', { align: 'center', size: 10, color: '#475569', lines: 1 }),
      ui.spacer(6),
      backBtn('pm_back')
    ], { background: s.uiBg })
  ];
}

function renderSchulte() {
  const s = state.get();
  const q = s.schulte;
  const nums = q.nums || [];
  const rows = [];
  for (let r = 0; r < 5; r++) {
    const cells = [];
    for (let c = 0; c < 5; c++) {
      const i = r * 5 + c;
      const v = nums[i] || 0;
      const done = v > 0 && v <= q.idx;
      cells.push(ui.button(v ? '' + v : '·', () => schulteTap(i), {
        id: 'sc_' + i, tone: 'neutral', flex: 1, height: 38,
        background: done ? '#14532d' : '#16233a', color: done ? '#22c55e' : '#cbd5e1'
      }));
    }
    rows.push(ui.row(cells, { gap: 4, width: 324 }));
    if (r < 4) rows.push(ui.spacer(4));
  }
  return [
    ui.column([
      ui.heading('舒尔特方格', { align: 'center', size: 18, color: '#38bdf8', lines: 1 }),
      ui.spacer(2),
      ui.row([
        ui.text('下一个 ' + (q.idx >= 25 ? '完成' : q.idx + 1), { size: 12, lines: 1, color: '#e2b13c', flex: 1 }),
        ui.text('用时 ' + q.sec + 's', { size: 12, lines: 1, align: 'center', color: '#cbd5e1', flex: 1 }),
        ui.text('最佳 ' + (q.best ? q.best + 's' : '--'), { size: 12, lines: 1, align: 'right', color: '#94a3b8', flex: 1 })
      ], { justify: 'between' }),
      ui.text('从 1 开始按顺序点完 25 格', { align: 'center', size: 11, color: '#64748b', lines: 1 }),
      ui.spacer(5)
    ].concat(rows).concat([
      ui.spacer(5),
      ui.row([
        ui.button('重开', () => { vib(); initSchulte(); }, { id: 'sc_new', tone: 'primary', flex: 1, height: 42 }),
        backBtn('sc_back')
      ], { gap: 5 })
    ]), { background: s.uiBg })
  ];
}

function renderPuz() {
  const s = state.get();
  const p = s.puz;
  const rows = [];
  for (let r = 0; r < 3; r++) {
    const cells = [];
    for (let c = 0; c < 3; c++) {
      const i = r * 3 + c;
      const v = p.b[i];
      cells.push(ui.button(v ? '' + v : '·', () => puzTap(i), {
        id: 'pz_' + i, tone: 'neutral', flex: 1, height: 62,
        background: v ? '#16233a' : '#0b1220',
        color: v ? (p.over ? '#22c55e' : '#e2b13c') : '#1e293b'
      }));
    }
    rows.push(ui.row(cells, { gap: 6 }));
    if (r < 2) rows.push(ui.spacer(6));
  }
  return [
    ui.column([
      ui.heading('数字华容道', { align: 'center', size: 18, color: '#38bdf8', lines: 1 }),
      ui.spacer(2),
      ui.row([
        ui.text('步数 ' + p.moves, { size: 12, lines: 1, color: '#cbd5e1', flex: 1 }),
        ui.text('最佳 ' + (p.best || '--'), { size: 12, lines: 1, align: 'right', color: '#94a3b8', flex: 1 })
      ], { justify: 'between' }),
      ui.text(p.over ? '拼好了!' : '点空格相邻方块即可移动', { align: 'center', size: 11, color: p.over ? '#22c55e' : '#64748b', lines: 1 }),
      ui.spacer(6),
      ui.column(rows, { width: 324 }),
      ui.spacer(8),
      ui.row([
        ui.button('打乱重开', () => { vib(); initPuz(); }, { id: 'pz_new', tone: 'primary', flex: 1, height: 42 }),
        backBtn('pz_back')
      ], { gap: 5 })
    ], { background: s.uiBg })
  ];
}

function renderPicker() {
  const s = state.get();
  const items = s.pick.text.split(',').map(t => t.trim()).filter(t => t.length > 0);
  return [
    ui.column([
      ui.heading('抽签', { align: 'center', size: 18, color: '#e2b13c', lines: 1 }),
      ui.spacer(2),
      ui.text('共 ' + items.length + ' 项候选', { align: 'center', size: 12, color: '#94a3b8', lines: 1 }),
      ui.spacer(10),
      ui.text(s.pick.out || '?', { align: 'center', size: 32, bold: true, color: s.pick.out ? '#22c55e' : '#475569', lines: 2, lineHeight: 38 }),
      ui.spacer(10),
      ui.button('抽一个', pickerDraw, { id: 'pk_draw', tone: 'primary', height: 54 }),
      ui.spacer(6),
      ui.button('编辑候选项', pickerEdit, { id: 'pk_edit', tone: 'neutral', height: 42 }),
      ui.spacer(8),
      ui.text('当前:' + s.pick.text, { size: 11, color: '#64748b', lines: 4, lineHeight: 16 }),
      ui.spacer(6),
      backBtn('pk_back')
    ], { background: s.uiBg })
  ];
}

function renderConv() {
  const s = state.get();
  const k = CONV_KINDS[s.conv.kind];
  const out = k.f(s.conv.v);
  return [
    ui.column([
      ui.heading('单位换算', { align: 'center', size: 18, color: '#38bdf8', lines: 1 }),
      ui.spacer(2),
      ui.text(k.name, { align: 'center', size: 12, color: '#94a3b8', lines: 1 }),
      ui.spacer(10),
      ui.text('' + s.conv.v, { align: 'center', size: 26, bold: true, color: '#cbd5e1', lines: 1 }),
      ui.text('↓', { align: 'center', size: 14, color: '#475569', lines: 1 }),
      ui.text(out.toFixed(3) + ' ' + k.unit, { align: 'center', size: 32, bold: true, color: '#22c55e', lines: 1 }),
      ui.spacer(12),
      ui.button('切换换算类型', convCycle, { id: 'cv_cycle', tone: 'neutral', height: 46 }),
      ui.spacer(6),
      ui.button('输入新数值', convEdit, { id: 'cv_edit', tone: 'primary', height: 46 }),
      ui.spacer(8),
      ui.text('支持 km/mi、kg/lb、℃/℉、cm/in', { align: 'center', size: 10, color: '#64748b', lines: 1 }),
      ui.spacer(6),
      backBtn('cv_back')
    ], { background: s.uiBg })
  ];
}

function renderDice() {
  const s = state.get();
  const d = s.dice;
  const face = n => (['', '⚀', '⚁', '⚂', '⚃', '⚄', '⚅'][n] || '');
  return [
    ui.column([
      ui.heading('掷骰子', { align: 'center', size: 18, color: '#e2b13c', lines: 1 }),
      ui.spacer(10),
      ui.row(d.vals.map(v => ui.text(face(v), {
        align: 'center', size: 52, color: '#e2b13c', lines: 1, flex: 1,
        background: '#16233a', height: 76, radius: 10
      })), { gap: 8 }),
      ui.spacer(10),
      ui.text(d.vals.length > 1 ? '总和 ' + d.sum : '点数 ' + d.sum, { align: 'center', size: 22, bold: true, color: '#38bdf8', lines: 1 }),
      ui.text('已掷 ' + (d.roll || 0) + ' 次', { align: 'center', size: 11, color: '#64748b', lines: 1 }),
      ui.spacer(12),
      ui.button('掷两颗', diceRoll, { id: 'dc_two', tone: 'primary', height: 54 }),
      ui.spacer(6),
      ui.row([
        ui.button('掷一颗', diceOne, { id: 'dc_one', tone: 'neutral', flex: 1, height: 44 }),
        backBtn('dc_back')
      ], { gap: 5 })
    ], { background: s.uiBg })
  ];
}

function renderMuyu() {
  const s = state.get();
  return [
    ui.column([
      ui.heading('电子木鱼', { align: 'center', size: 18, color: '#e2b13c', lines: 1 }),
      ui.spacer(2),
      ui.text('功德 ' + (s.muyu || 0), { align: 'center', size: 15, bold: true, color: '#22c55e', lines: 1 }),
      ui.spacer(8),
      ui.button('叩', muyuTap, { id: 'my_tap', tone: 'primary', height: 190 }),
      ui.spacer(6),
      ui.text('每敲一次,功德 +1', { align: 'center', size: 12, color: '#94a3b8', lines: 1 }),
      ui.spacer(8),
      ui.row([
        ui.button('归零', () => { vib(); state.update(p => ({ ...p, muyu: 0 })); }, { id: 'my_reset', tone: 'neutral', flex: 1, height: 42 }),
        backBtn('my_back')
      ], { gap: 5 })
    ], { background: s.uiBg })
  ];
}

function renderSettings() {
  const s = state.get();
  const nodes = [
    ui.heading('设置', { align: 'center', size: 19, color: '#38bdf8', lines: 1 }),
    ui.spacer(4),
    ui.text('设备:' + s.device.model, { size: 12, lines: 1, color: '#94a3b8' }),
    ui.text('罗盘:' + (s.compassOk ? '支持' : '无') + ' 电量:' + (s.battery.level >= 0 ? s.battery.level + '%' : '--') + (s.battery.charging ? '(充)' : ''), { size: 12, lines: 1, color: '#94a3b8' }),
    ui.spacer(6),
    ui.text('界面配色', { size: 14, lines: 1, color: '#e2e8f0' }),
    ui.buttonRow(UI_BGS.map(bg => ({
      text: bg.label,
      onPress: () => { state.update(p => ({ ...p, uiBg: bg.val })); log('界面配色=' + bg.label); },
      tone: s.uiBg === bg.val ? 'primary' : 'neutral'
    }))),
    ui.spacer(6),
    ui.switch('震动反馈', s.vibrateOn, v => { state.update(p => ({ ...p, vibrateOn: v })); log('震动=' + (v ? '开' : '关')); }, { id: 'st_vib' }),
    ui.spacer(4),
    ui.text('实时日志', { size: 13, bold: true, color: '#e2b13c', lines: 1 })
  ];
  const logs = s.logs.slice(0, 4);
  if (logs.length) {
    for (let i = 0; i < logs.length; i++) {
      nodes.push(ui.text(fmtTime(logs[i].t) + ' ' + logs[i].m, { size: 11, color: '#cbd5e1', lines: 1 }));
    }
  } else {
    nodes.push(ui.text('暂无日志', { size: 11, color: '#475569', lines: 1 }));
  }
  nodes.push(ui.spacer(4));
  nodes.push(ui.button('保存设置', async () => {
    await script.data.set('wx_set', { uiBg: s.uiBg, vibrateOn: s.vibrateOn });
    script.toast('已保存', 1500);
    log('设置已保存');
  }, { id: 'st_save', tone: 'primary', height: 42 }));
  nodes.push(ui.spacer(4));
  nodes.push(ui.row([
    ui.button('清空日志', () => state.update(p => ({ ...p, logs: [] })), { id: 'st_clr', tone: 'neutral', flex: 1, height: 40 }),
    ui.button('返回', () => state.update(p => ({ ...p, phase: 'menu' })), { id: 'st_back', tone: 'primary', flex: 1, height: 40 })
  ], { gap: 5 }));
  return [ui.column(nodes, { background: s.uiBg })];
}

function renderAbout() {
  const s = state.get();
  return [
    ui.column([
      ui.heading('关于万相匣', { align: 'center', size: 19, color: '#e2b13c', lines: 1 }),
      ui.spacer(3),
      ui.text('版本 v1.0 · 学习交流用途', { align: 'center', size: 12, color: '#94a3b8', lines: 1 }),
      ui.spacer(5),
      ui.text('共 ' + (GAMES.length + TOOLS.length) + ' 个模块:' + GAMES.length + ' 个游戏 + ' + TOOLS.length + ' 个工具。每个模块均为独立页面,底部都设有独立「返回」键。', { size: 12, color: '#cbd5e1', lines: 4, lineHeight: 17 }),
      ui.spacer(4),
      ui.text('小游戏', { size: 13, bold: true, color: '#e2b13c', lines: 1 }),
      ui.text(GAMES.map((g, i) => (i + 1) + '.' + g.name).join('  '), { size: 11, color: '#cbd5e1', lines: 5, lineHeight: 16 }),
      ui.spacer(4),
      ui.text('实用工具', { size: 13, bold: true, color: '#e2b13c', lines: 1 }),
      ui.text(TOOLS.map((g, i) => (i + 1) + '.' + g.name).join('  '), { size: 11, color: '#cbd5e1', lines: 5, lineHeight: 16 }),
      ui.spacer(4),
      ui.text('操作说明', { size: 13, bold: true, color: '#e2b13c', lines: 1 }),
      ui.text('1. 顶部切换「游戏 / 工具」;\n2. 每页 4 项,上/下页翻找;\n3. 点名称进入,底部为「返回」。', { size: 11, color: '#cbd5e1', lines: 4, lineHeight: 16 }),
      ui.spacer(4),
      ui.text('声明:本软件由 AI 生成,仅供学习交流,禁止倒卖与商用。所有数据仅存本地。', { size: 11, color: '#cbd5e1', lines: 2, lineHeight: 16 }),
      ui.spacer(5),
      ui.button('查看完整协议', () => state.update(p => ({ ...p, protocolFrom: 'about', phase: 'protocol' })), { id: 'ab_pro', tone: 'neutral', height: 40 }),
      ui.spacer(4),
      ui.button('返回', () => state.update(p => ({ ...p, phase: 'menu' })), { id: 'ab_back', tone: 'primary', height: 42 })
    ], { background: s.uiBg })
  ];
}

function renderHelp() {
  const s = state.get();
  const gs = GAMES.slice();
  const ts = TOOLS.slice();
  const nodes = [];
  nodes.push(ui.heading('使用说明', { align: 'center', size: 18, color: '#e2b13c', lines: 1 }));
  nodes.push(ui.spacer(2));
  nodes.push(ui.text('本文件共 ' + (gs.length + ts.length) + ' 个模块,每个模块页面底部都有「返回」键,点按即可回到本菜单。', { size: 11, color: '#94a3b8', lines: 3, lineHeight: 15 }));
  nodes.push(ui.spacer(4));
  if (gs.length) {
    nodes.push(ui.text('游戏 ' + gs.length + ' 个', { size: 12, bold: true, color: '#38bdf8', lines: 1 }));
    for (let i = 0; i < gs.length; i++) {
      nodes.push(ui.text((i + 1) + '. ' + gs[i].name + ':' + (HELP_DESC[gs[i].id] || ''), { size: 11, color: '#cbd5e1', lines: 2, lineHeight: 15 }));
    }
    nodes.push(ui.spacer(4));
  }
  if (ts.length) {
    nodes.push(ui.text('工具 ' + ts.length + ' 个', { size: 12, bold: true, color: '#38bdf8', lines: 1 }));
    for (let i = 0; i < ts.length; i++) {
      nodes.push(ui.text((i + 1) + '. ' + ts[i].name + ':' + (HELP_DESC[ts[i].id] || ''), { size: 11, color: '#cbd5e1', lines: 2, lineHeight: 15 }));
    }
    nodes.push(ui.spacer(4));
  }
  nodes.push(ui.text('菜单「游戏 / 工具」标签切换分组,左右翻页查看全部;「设置」可换配色、开关震动;数据均保存在本机。', { size: 11, color: '#64748b', lines: 3, lineHeight: 15 }));
  nodes.push(ui.spacer(6));
  nodes.push(backBtn('hp_back'));
  return [ui.column(nodes, { background: s.uiBg })];
}

ui.render(() => {
  const s = state.get();
  switch (s.phase) {
    case 'splash': return renderSplash();
    case 'protocol': return renderProtocol();
    case 'menu': return renderMenu();
    case 'mine': return renderMine();
    case 'ttt': return renderTtt();
    case 'stroop': return renderStroop();
    case 'mole': return renderMole();
    case 'pomo': return renderPomo();
    case 'schulte': return renderSchulte();
    case 'puz': return renderPuz();
    case 'picker': return renderPicker();
    case 'conv': return renderConv();
    case 'dice': return renderDice();
    case 'muyu': return renderMuyu();
    case 'settings': return renderSettings();
    case 'about': return renderAbout();
    case 'help': return renderHelp();
    default: return renderSplash();
  }
});
ui.setTitle('万相匣 ①');
ui.show();

async function main() {
  const saved = await script.data.get('wx_set_①', null);
  if (saved) {
    state.update(s => ({
      ...s,
      uiBg: saved.uiBg || '#0f172a',
      vibrateOn: saved.vibrateOn !== undefined ? saved.vibrateOn : true
    }));
  }
  await initDevice();
  log('万相匣 ① 启动完成(11 模块)');
  setTimeout(() => state.update(p => ({ ...p, phase: 'protocol' })), 1800);

  setInterval(() => {
    const st = state.get();
    const nw = Date.now();
    if (st.phase === 'mine' && !st.mine.over && st.mine.t0) {
      const sec = Math.floor((nw - st.mine.t0) / 1000);
      if (sec !== st.mine.sec) state.update(p => ({ ...p, mine: { ...p.mine, sec } }));
    }
    if (st.phase === 'stroop') tickStroop(nw);
    if (st.phase === 'mole') tickMole(nw);
    if (st.phase === 'pomo') tickPomo(nw);
    if (st.phase === 'schulte') tickSchulte(nw);
  }, 250);

  setInterval(checkBattery, 60000);
}
return main();