万相匣 ② 游戏+效率

工具

适配设备:Pro

作者:lrj

下载量:12 · 点赞:0

可信源码风险提示:保存或下载前请阅读源码和权限声明。
SHA-256afcceb2f684a5ca425cc57e13ebf145dc928bece0ff69ec1645ee68baaca6a45
// ============================================================
//  万相匣 ② 游戏+效率
//  平台:JSlab 手环(324 × 480 竖屏)  文件 ② / 共 3 个
//  内含 11 个独立模块:贪吃蛇、心算挑战、记忆翻牌、数独 4×4、日期差、反应测试、猜数字、秒表、进制转换、电子钟、手电筒
//  规范:每个模块为独立页面,页面底部均有独立的「返回」键
//  结构: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(),
  snake:  { body: [], dir: 'r', next: 'r', food: { x: 7, y: 5 }, score: 0, best: 0, over: true, run: false },
  math:  { a: 0, b: 0, op: '+', ans: 0, choices: [], score: 0, left: 60, run: false },
  memo:  { cards: [], open: [], moves: 0, lock: false, best: 0 },
  sudoku: { b: [], fix: [], over: false, msg: '点空格循环填 1~4' },
  datediff: { a: '', bb: '', days: 0 },
  react:  { st: 'idle', t0: 0, ms: 0, best: 0 },
  guess:  { secret: 0, lo: 1, hi: 100, tries: 0, msg: '本轮已开始,猜 1~100', done: false },
  sw:  { run: false, t0: 0, acc: 0, laps: [] },
  radix: { v: 255 },
  torchPrev:  -1,
});

const SUDOKU_ANS = [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1];

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 = {
  snake: '方向键控制,吃食物变长,撞墙或自身结束',
  math: '限时四则运算,选出正确答案',
  memo: '记住亮灯顺序并依次复现,逐关加长',
  sudoku: '4×4 数独,点空格循环填 1~4,行列宫不重复',
  datediff: '输入两个 8 位日期,算出相差天数',
  react: '灯变色立刻点击,测反应速度',
  guess: '系统出 1~100,按提示大小猜中',
  sw: '秒表,开始暂停计次,可清零重来',
  radix: '输入十进制数,换算二进制/八进制/十六进制',
  clock: '大字时钟,显示当前时分秒与日期',
  torch: '白屏当手电筒,双击屏幕退出',
};

const GAMES = [
  { id: 'snake', name: '贪吃蛇' },
  { id: 'math', name: '心算挑战' },
  { id: 'memo', name: '记忆翻牌' },
  { id: 'sudoku', name: '数独 4×4' },
  { id: 'react', name: '反应测试' },
  { id: 'guess', name: '猜数字' }
];

const TOOLS = [
  { id: 'datediff', name: '日期差' },
  { id: 'sw', name: '秒表' },
  { id: 'radix', name: '进制转换' },
  { id: 'clock', name: '电子钟' },
  { id: 'torch', name: '手电筒' }
];

let compassSub = false, lastCompass = 0;
let mathEndAt = 0;
let reactTimer = null;
let lastTorchTap = 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();
  if (reactTimer) { clearTimeout(reactTimer); reactTimer = null; }
  try { system.display.setKeepScreenOn({ keepScreenOn: false }); } catch (e) { }
  state.update(p => ({
    ...p,
    sw: { ...p.sw, run: false },
    snake: { ...p.snake, run: false },
    math: { ...p.math, run: false },
  }));
}

function backToMenu() {
  const s = state.get();
  if (s.phase === 'torch') {
    try { system.display.setValue({ value: s.torchPrev >= 0 ? s.torchPrev : 200 }); } catch (e) { }
    state.update(p => ({ ...p, torchPrev: -1 }));
  }
  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 'snake': initSnake(); break;
    case 'math': initMath(); break;
    case 'memo': initMemo(); break;
    case 'sudoku': initSudoku(); break;
    case 'datediff': initDiff(); break;
    case 'react': initReact(); break;
    case 'guess': initGuess(); break;
    case 'sw': state.update(p => ({ ...p, phase: 'sw' })); break;
    case 'radix': state.update(p => ({ ...p, phase: 'radix' })); break;
    case 'clock': state.update(p => ({ ...p, phase: 'clock', now: Date.now() })); break;
    case 'torch': openTorch(); break;
    default: state.update(p => ({ ...p, phase: 'menu' }));
  }
}

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

function newFood(body) {
  const free = [];
  for (let y = 0; y < 10; y++) {
    for (let x = 0; x < 10; x++) {
      let used = false;
      for (let i = 0; i < body.length; i++) if (body[i][0] === x && body[i][1] === y) { used = true; break; }
      if (!used) free.push({ x, y });
    }
  }
  if (!free.length) return { x: 0, y: 0 };
  return free[rndInt(free.length)];
}

function initSnake() {
  const body = [[4, 5], [3, 5], [2, 5]];
  state.update(p => ({
    ...p,
    snake: { body, dir: 'r', next: 'r', food: newFood(body), score: 0, best: p.snake.best || 0, over: false, run: true },
    phase: 'snake'
  }));
}

function setSnakeDir(d) {
  const s = state.get();
  if (s.snake.over) return;
  const opp = { l: 'r', r: 'l', u: 'd', d: 'u' };
  if (opp[d] === s.snake.dir) return;
  state.update(p => ({ ...p, snake: { ...p.snake, next: d } }));
}

function endSnake() {
  const s = state.get();
  const best = Math.max(s.snake.best || 0, s.snake.score);
  state.update(p => ({ ...p, snake: { ...p.snake, run: false, over: true, best } }));
  vibLong();
  script.toast('游戏结束 得分 ' + s.snake.score, 2000);
}

function moveSnake() {
  const s = state.get();
  const sn = s.snake;
  if (!sn.run || sn.over) return;
  const body = sn.body.map(p => [p[0], p[1]]);
  const d = sn.next;
  const head = body[0];
  let nx = head[0], ny = head[1];
  if (d === 'l') nx--; else if (d === 'r') nx++; else if (d === 'u') ny--; else ny++;
  if (nx < 0 || nx > 9 || ny < 0 || ny > 9) { endSnake(); return; }
  for (let i = 0; i < body.length; i++) {
    if (body[i][0] === nx && body[i][1] === ny) { endSnake(); return; }
  }
  body.unshift([nx, ny]);
  let food = sn.food, score = sn.score;
  if (nx === food.x && ny === food.y) {
    score += 10;
    vib();
    food = newFood(body);
  } else body.pop();
  if (body.length > 90) { endSnake(); return; }
  state.update(p => ({ ...p, snake: { ...p.snake, body, dir: d, next: d, food, score, last: Date.now() } }));
}

function nextMath() {
  const s = state.get();
  const ops = ['+', '-', '×'];
  const op = ops[rndInt(3)];
  let a, b, ans;
  if (op === '+') { a = 3 + rndInt(48); b = 3 + rndInt(48); ans = a + b; }
  else if (op === '-') { a = 20 + rndInt(60); b = 2 + rndInt(18); ans = a - b; }
  else { a = 2 + rndInt(9); b = 2 + rndInt(9); ans = a * b; }
  const set = [ans];
  let guard = 0;
  while (set.length < 4 && guard < 200) {
    guard++;
    const d = ans + (rndInt(11) - 5);
    if (d >= 0 && set.indexOf(d) < 0) set.push(d);
  }
  while (set.length < 4) set.push(ans + set.length);
  const choices = shuffleArr(set);
  state.update(p => ({ ...p, math: { ...p.math, a, b, op, ans, choices } }));
}

function initMath() {
  mathEndAt = Date.now() + 60000;
  state.update(p => ({ ...p, math: { a: 0, b: 0, op: '+', ans: 0, choices: [], score: 0, left: 60, run: true }, phase: 'math' }));
  nextMath();
}

function mathPick(v) {
  const s = state.get();
  if (!s.math.run) { script.toast('已结束,点「重开」', 1200); return; }
  if (v === s.math.ans) {
    vib();
    state.update(p => ({ ...p, math: { ...p.math, score: p.math.score + 1 } }));
    nextMath();
  } else {
    script.toast('答错了,正确答案 ' + s.math.ans, 1200);
    nextMath();
  }
}

function tickMath(nw) {
  const s = state.get();
  if (!s.math.run) return;
  const left = Math.max(0, Math.ceil((mathEndAt - nw) / 1000));
  if (left === 0) {
    state.update(p => ({ ...p, math: { ...p.math, run: false, left: 0 } }));
    vibLong();
    script.toast('时间到,答对 ' + s.math.score + ' 题', 2000);
  } else if (left !== s.math.left) {
    state.update(p => ({ ...p, math: { ...p.math, left } }));
  }
}

function initMemo() {
  const deck = [];
  for (let i = 0; i < 6; i++) { deck.push(i); deck.push(i); }
  shuffleArr(deck);
  const cards = deck.map(v => ({ v, up: false, done: false }));
  state.update(p => ({ ...p, memo: { cards, open: [], moves: 0, lock: false, best: p.memo.best || 0 }, phase: 'memo' }));
}

function memoTap(i) {
  const s = state.get();
  const m = s.memo;
  if (m.lock) return;
  if (m.cards[i].up || m.cards[i].done) return;
  const cards = m.cards.map(c => ({ ...c }));
  cards[i].up = true;
  const open = m.open.concat([i]);
  vib();
  if (open.length === 1) {
    state.update(p => ({ ...p, memo: { ...m, cards, open } }));
    return;
  }
  const a = open[0], b = open[1];
  const moves = m.moves + 1;
  if (cards[a].v === cards[b].v) {
    cards[a].done = true; cards[b].done = true;
    const left = cards.filter(c => !c.done).length;
    const best = left === 0 ? (m.best ? Math.min(m.best, moves) : moves) : (m.best || 0);
    state.update(p => ({ ...p, memo: { ...m, cards, open: [], moves, best } }));
    if (left === 0) { vibLong(); script.toast('全部配对完成,' + moves + ' 次', 2000); }
  } else {
    state.update(p => ({ ...p, memo: { ...m, cards, open, moves, lock: true } }));
    setTimeout(() => {
      const st = state.get();
      if (st.phase !== 'memo') return;
      const cs = st.memo.cards.map(c => ({ ...c }));
      cs[a].up = false; cs[b].up = false;
      state.update(p => ({ ...p, memo: { ...p.memo, cards: cs, open: [], lock: false } }));
    }, 800);
  }
}

function suOk(b) {
  for (let r = 0; r < 4; r++) {
    const rs = {}, cs = {};
    for (let c = 0; c < 4; c++) {
      const rv = b[r * 4 + c], cv = b[c * 4 + r];
      if (rv) { if (rs[rv]) return false; rs[rv] = 1; }
      if (cv) { if (cs[cv]) return false; cs[cv] = 1; }
    }
  }
  for (let br = 0; br < 2; br++) {
    for (let bc = 0; bc < 2; bc++) {
      const m = {};
      for (let r = 0; r < 2; r++) {
        for (let c = 0; c < 2; c++) {
          const v = b[(br * 2 + r) * 4 + (bc * 2 + c)];
          if (v) { if (m[v]) return false; m[v] = 1; }
        }
      }
    }
  }
  return true;
}
function initSudoku() {
  const b = SUDOKU_ANS.slice();
  const fix = [];
  for (let i = 0; i < 16; i++) fix.push(0);
  let hide = 8;
  while (hide > 0) {
    const i = rndInt(16);
    if (fix[i]) continue;
    fix[i] = 1; b[i] = 0; hide--;
  }
  state.update(p => ({ ...p, sudoku: { b, fix, over: false, msg: '空格循环填 1~4' }, phase: 'sudoku' }));
}
function sudokuTap(i) {
  const s = state.get();
  const q = s.sudoku;
  if (q.over || q.fix[i]) return;
  const b = q.b.slice();
  b[i] = b[i] >= 4 ? 0 : b[i] + 1;
  vib();
  let full = true;
  for (let k = 0; k < 16; k++) if (!b[k]) full = false;
  const ok = suOk(b);
  let msg = '空格循环填 1~4', over = false;
  if (!ok) msg = '同行/列/宫不能重复';
  else if (full) { over = true; msg = '全部填好,完成!'; vibLong(); }
  state.update(p => ({ ...p, sudoku: { ...q, b, over, msg } }));
}

function ymdValid(v) { return /^\d{8}$/.test(v); }
function dayDiff(a, b2) {
  const A = new Date(Number(a.slice(0, 4)), Number(a.slice(4, 6)) - 1, Number(a.slice(6, 8)));
  const B2 = new Date(Number(b2.slice(0, 4)), Number(b2.slice(4, 6)) - 1, Number(b2.slice(6, 8)));
  return Math.round((B2 - A) / 86400000);
}
function initDiff() {
  const d = new Date();
  const t = '' + d.getFullYear() + p2(d.getMonth() + 1) + p2(d.getDate());
  state.update(p => ({ ...p, datediff: p.datediff.a ? p.datediff : { a: t, bb: t, days: 0 }, phase: 'datediff' }));
}
async function diffEdit() {
  const s = state.get();
  const r1 = await dialog.text({ title: '起始日期', message: '8 位数字,如 20261018', value: s.datediff.a, maxLength: 8, required: true });
  if (r1.action !== 'confirm') return;
  const a = (r1.value || '').trim();
  if (!ymdValid(a)) { script.toast('请输入 8 位数字', 1500); return; }
  const r2 = await dialog.text({ title: '结束日期', message: '8 位数字,如 20261231', value: s.datediff.bb, maxLength: 8, required: true });
  if (r2.action !== 'confirm') return;
  const bb = (r2.value || '').trim();
  if (!ymdValid(bb)) { script.toast('请输入 8 位数字', 1500); return; }
  const days = dayDiff(a, bb);
  state.update(p => ({ ...p, datediff: { a, bb, days } }));
  script.toast('相差 ' + days + ' 天', 1500);
}

function initReact() {
  if (reactTimer) { clearTimeout(reactTimer); reactTimer = null; }
  state.update(p => ({ ...p, react: { st: 'idle', t0: 0, ms: 0, best: p.react.best || 0 }, phase: 'react' }));
}

function reactStart() {
  if (reactTimer) { clearTimeout(reactTimer); reactTimer = null; }
  state.update(p => ({ ...p, react: { ...p.react, st: 'wait', t0: 0, ms: 0 } }));
  const delay = 1500 + rndInt(2500);
  reactTimer = setTimeout(() => {
    const st = state.get();
    if (st.phase !== 'react' || st.react.st !== 'wait') return;
    state.update(p => ({ ...p, react: { ...p.react, st: 'go', t0: Date.now() } }));
    vib();
  }, delay);
}

function reactTap() {
  const s = state.get();
  const r = s.react;
  if (r.st === 'idle' || r.st === 'done') { reactStart(); return; }
  if (r.st === 'wait') {
    if (reactTimer) { clearTimeout(reactTimer); reactTimer = null; }
    state.update(p => ({ ...p, react: { ...p.react, st: 'idle', ms: -1 } }));
    script.toast('抢跑了,重新开始', 1500);
    return;
  }
  const ms = Date.now() - r.t0;
  const best = r.best ? Math.min(r.best, ms) : ms;
  state.update(p => ({ ...p, react: { st: 'done', t0: 0, ms, best } }));
  vib();
}

function initGuess() {
  const secret = 1 + rndInt(100);
  state.update(p => ({ ...p, guess: { secret, lo: 1, hi: 100, tries: 0, msg: '猜一个 1~100 的整数', done: false }, phase: 'guess' }));
}

async function guessAsk() {
  const s = state.get();
  if (s.guess.done) { script.toast('已猜中,点「再来一局」', 1500); return; }
  const mid = Math.round((s.guess.lo + s.guess.hi) / 2);
  const r = await dialog.number({ title: '猜数字', message: '范围 ' + s.guess.lo + '~' + s.guess.hi, value: mid, min: 1, max: 100, decimals: 0 });
  if (r.action !== 'confirm') return;
  const v = Math.round(r.value);
  if (!isFinite(v) || v < 1 || v > 100) { script.toast('请输入 1~100', 1500); return; }
  const g = s.guess;
  const tries = g.tries + 1;
  let lo = g.lo, hi = g.hi, msg = '', done = false;
  if (v === g.secret) { done = true; msg = '猜中了!共 ' + tries + ' 次'; vibLong(); }
  else if (v > g.secret) { hi = Math.min(hi, v - 1); msg = v + ' 太大了,范围 ' + lo + '~' + hi; vib(); }
  else { lo = Math.max(lo, v + 1); msg = v + ' 太小了,范围 ' + lo + '~' + hi; vib(); }
  state.update(p => ({ ...p, guess: { ...g, tries, lo, hi, msg, done } }));
}

function swToggle() {
  const s = state.get();
  const sw = s.sw;
  if (sw.run) {
    const acc = sw.acc + (Date.now() - sw.t0);
    state.update(p => ({ ...p, sw: { ...p.sw, run: false, acc, t0: 0 } }));
    vib();
    log('秒表暂停 ' + ms2str(acc));
  } else {
    state.update(p => ({ ...p, sw: { ...p.sw, run: true, t0: Date.now() } }));
    vib();
  }
}

function swLap() {
  const s = state.get();
  if (!s.sw.run && s.sw.acc === 0) { script.toast('先开始计时', 1200); return; }
  const cur = s.sw.acc + (s.sw.run ? Date.now() - s.sw.t0 : 0);
  const laps = [cur].concat(s.sw.laps).slice(0, 4);
  state.update(p => ({ ...p, sw: { ...p.sw, laps } }));
  vib();
}

function swReset() {
  state.update(p => ({ ...p, sw: { run: false, t0: 0, acc: 0, laps: [] } }));
  script.toast('已归零', 1200);
}

function tickSw(nw) {
  const s = state.get();
  if (!s.sw.run) return;
  state.update(p => ({ ...p, now: nw }));
}

const RADIX_D = '0123456789abcdef';
function toBase(v, b) {
  if (!v || v <= 0) return '0';
  let out = '', n = Math.floor(v);
  while (n > 0) { out = RADIX_D.charAt(n % b) + out; n = Math.floor(n / b); }
  return out;
}
async function radixEdit() {
  const s = state.get();
  const r = await dialog.number({ title: '十进制数值', message: '0 ~ 999999999', value: s.radix.v, min: 0, max: 999999999, decimals: 0 });
  if (r.action !== 'confirm') return;
  const v = Math.round(r.value);
  if (!isFinite(v) || v < 0 || v > 999999999) { script.toast('请输入 0~999999999', 1600); return; }
  state.update(p => ({ ...p, radix: { v } }));
  script.toast('已转换为 ' + v, 1200);
}

function tickClock(nw) {
  const s = state.get();
  if (s.phase === 'clock') state.update(p => ({ ...p, now: nw }));
}

function openTorch() {
  try {
    system.display.getValue({
      success: r => { state.update(p => ({ ...p, torchPrev: r.value })); },
      fail: () => { state.update(p => ({ ...p, torchPrev: 200 })); }
    });
  } catch (e) { state.update(p => ({ ...p, torchPrev: 200 })); }
  try { system.display.setValue({ value: 255 }); } catch (e) { }
  log('手电筒开启,亮度拉满');
  state.update(p => ({ ...p, phase: 'torch' }));
}

function torchTap() {
  const nw = Date.now();
  if (nw - lastTorchTap < 500) { lastTorchTap = 0; backToMenu(); }
  else lastTorchTap = nw;
}

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

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 renderSnake() {
  const s = state.get();
  const sn = s.snake;
  const N = 10, CS = 27, OX = 20;
  const nodes = [];
  for (let y = 0; y < N; y++) {
    for (let x = 0; x < N; x++) {
      nodes.push(ui.text('', {
        x: OX + x * CS + 1, y: y * CS + 1, width: CS - 2, height: CS - 2,
        background: (x + y) % 2 === 0 ? '#111a2b' : '#0d1626', radius: 2
      }));
    }
  }
  nodes.push(ui.text('', { x: OX + sn.food.x * CS + 5, y: sn.food.y * CS + 5, width: CS - 10, height: CS - 10, background: '#ef4444', radius: 5 }));
  for (let i = sn.body.length - 1; i >= 0; i--) {
    const b = sn.body[i];
    nodes.push(ui.text('', {
      x: OX + b[0] * CS + 2, y: b[1] * CS + 2, width: CS - 4, height: CS - 4,
      radius: 3, background: i === 0 ? '#22c55e' : '#15803d'
    }));
  }
  return [
    ui.column([
      ui.heading('贪吃蛇', { align: 'center', size: 18, color: '#22c55e', lines: 1 }),
      ui.spacer(2),
      ui.row([
        ui.text('得分 ' + sn.score, { size: 12, lines: 1, color: '#38bdf8', flex: 1 }),
        ui.text('最高 ' + (sn.best || 0), { size: 12, lines: 1, align: 'right', color: '#94a3b8', flex: 1 })
      ], { justify: 'between' }),
      ui.text(sn.over ? '撞到了,点「开始」重来' : (sn.run ? '运行中…' : '已暂停'), { align: 'center', size: 11, color: sn.over ? '#ef4444' : '#64748b', lines: 1 }),
      ui.spacer(2),
      ui.stack(nodes, { width: 324, height: 272, background: '#0b1220', radius: 8 }),
      ui.spacer(4),
      ui.row([
        ui.button('←', () => setSnakeDir('l'), { id: 'sn_l', tone: 'neutral', flex: 1, height: 36 }),
        ui.button('↑', () => setSnakeDir('u'), { id: 'sn_u', tone: 'neutral', flex: 1, height: 36 }),
        ui.button('↓', () => setSnakeDir('d'), { id: 'sn_d', tone: 'neutral', flex: 1, height: 36 }),
        ui.button('→', () => setSnakeDir('r'), { id: 'sn_r', tone: 'neutral', flex: 1, height: 36 })
      ], { gap: 5 }),
      ui.spacer(5),
      ui.row([
        ui.button(sn.run ? '暂停' : '开始', () => {
          const st0 = state.get();
          if (st0.snake.over) { initSnake(); return; }
          vib();
          state.update(p => ({ ...p, snake: { ...p.snake, run: !p.snake.run, last: Date.now() } }));
        }, { id: 'sn_play', tone: 'primary', flex: 1, height: 42 }),
        backBtn('sn_back')
      ], { gap: 5 })
    ], { background: s.uiBg })
  ];
}

function renderMath() {
  const s = state.get();
  const m = s.math;
  const ch = m.choices.length ? m.choices : [0, 0, 0, 0];
  return [
    ui.column([
      ui.heading('心算挑战', { align: 'center', size: 18, color: '#e2b13c', lines: 1 }),
      ui.spacer(2),
      ui.row([
        ui.text('答对 ' + m.score, { size: 12, lines: 1, color: '#22c55e', flex: 1 }),
        ui.text('剩余 ' + m.left + 's', { size: 12, lines: 1, align: 'right', color: m.left <= 10 ? '#ef4444' : '#94a3b8', flex: 1 })
      ], { justify: 'between' }),
      ui.spacer(10),
      ui.text(m.a + ' ' + m.op + ' ' + m.b + ' = ?', { align: 'center', size: 30, bold: true, color: '#e2e8f0', lines: 1 }),
      ui.spacer(12),
      ui.row([
        ui.button('' + ch[0], () => mathPick(ch[0]), { id: 'mt_0', tone: 'neutral', flex: 1, height: 54 }),
        ui.button('' + ch[1], () => mathPick(ch[1]), { id: 'mt_1', tone: 'neutral', flex: 1, height: 54 })
      ], { gap: 8 }),
      ui.spacer(8),
      ui.row([
        ui.button('' + ch[2], () => mathPick(ch[2]), { id: 'mt_2', tone: 'neutral', flex: 1, height: 54 }),
        ui.button('' + ch[3], () => mathPick(ch[3]), { id: 'mt_3', tone: 'neutral', flex: 1, height: 54 })
      ], { gap: 8 }),
      ui.spacer(10),
      ui.text(m.run ? '选一个答案,答错不扣分' : '点「重开」开始 60 秒挑战', { align: 'center', size: 11, color: '#64748b', lines: 1 }),
      ui.spacer(6),
      ui.row([
        ui.button('重开', () => { vib(); initMath(); }, { id: 'mt_new', tone: 'primary', flex: 1, height: 42 }),
        backBtn('mt_back')
      ], { gap: 5 })
    ], { background: s.uiBg })
  ];
}

function renderMemo() {
  const s = state.get();
  const m = s.memo;
  const rows = [];
  for (let r = 0; r < 3; r++) {
    const cells = [];
    for (let c = 0; c < 4; c++) {
      const i = r * 4 + c;
      const card = m.cards[i];
      const show = card.up || card.done;
      cells.push(ui.button(show ? '' + (card.v + 1) : '?', () => memoTap(i), {
        id: 'mm_' + i, tone: 'neutral', flex: 1, height: 54,
        background: card.done ? '#14532d' : (show ? '#1e3a5f' : '#16233a'),
        color: card.done ? '#22c55e' : (show ? '#e2b13c' : '#64748b')
      }));
    }
    rows.push(ui.row(cells, { gap: 5 }));
    rows.push(ui.spacer(5));
  }
  return [
    ui.column([
      ui.heading('记忆翻牌', { align: 'center', size: 18, color: '#38bdf8', lines: 1 }),
      ui.spacer(2),
      ui.row([
        ui.text('翻牌 ' + m.moves + ' 次', { size: 12, lines: 1, color: '#cbd5e1', flex: 1 }),
        ui.text('最佳 ' + (m.best ? m.best + ' 次' : '--'), { size: 12, lines: 1, align: 'right', color: '#94a3b8', flex: 1 })
      ], { justify: 'between' }),
      ui.text('翻开两张相同的图案即可消除', { align: 'center', size: 11, color: '#64748b', lines: 1 }),
      ui.spacer(6),
      ui.column(rows, { width: 324 }),
      ui.spacer(4),
      ui.button('重新开局', () => { vib(); initMemo(); }, { id: 'mm_new', tone: 'primary', height: 42 }),
      ui.spacer(5),
      backBtn('mm_back')
    ], { background: s.uiBg })
  ];
}

function renderSudoku() {
  const s = state.get();
  const q = s.sudoku;
  const rows = [];
  for (let r = 0; r < 4; r++) {
    const cells = [];
    for (let c = 0; c < 4; c++) {
      const i = r * 4 + c;
      const v = q.b[i];
      const fixed = q.fix[i] === 1;
      cells.push(ui.button(v ? '' + v : '·', () => sudokuTap(i), {
        id: 'sd_' + i, tone: 'neutral', flex: 1, height: 54,
        background: fixed ? '#1e3a5f' : '#16233a',
        color: fixed ? '#94a3b8' : (v ? '#e2b13c' : '#475569')
      }));
    }
    rows.push(ui.row(cells, { gap: 5 }));
    rows.push(ui.spacer(5));
  }
  return [
    ui.column([
      ui.heading('数独 4×4', { align: 'center', size: 18, color: '#38bdf8', lines: 1 }),
      ui.spacer(2),
      ui.text(q.over ? '恭喜完成!' : '灰底为题目,空格点一下加 1', { align: 'center', size: 11, color: q.over ? '#22c55e' : '#64748b', lines: 1 }),
      ui.spacer(6),
      ui.column(rows, { width: 324 })
    ].concat([
      ui.text(q.msg, { align: 'center', size: 12, color: q.over ? '#22c55e' : '#cbd5e1', lines: 1 }),
      ui.spacer(6),
      ui.row([
        ui.button('换一题', () => { vib(); initSudoku(); }, { id: 'sd_new', tone: 'primary', flex: 1, height: 42 }),
        backBtn('sd_back')
      ], { gap: 5 })
    ]), { background: s.uiBg })
  ];
}

function renderDiff() {
  const s = state.get();
  const d = s.datediff;
  const fmt = v => (v && v.length === 8) ? v.slice(0, 4) + '-' + v.slice(4, 6) + '-' + v.slice(6, 8) : '--';
  return [
    ui.column([
      ui.heading('日期差', { align: 'center', size: 18, color: '#38bdf8', lines: 1 }),
      ui.spacer(8),
      ui.row([
        ui.text('起始', { size: 12, lines: 1, color: '#94a3b8', flex: 1 }),
        ui.text(fmt(d.a), { size: 13, lines: 1, align: 'right', color: '#cbd5e1', flex: 1 })
      ], { justify: 'between' }),
      ui.row([
        ui.text('结束', { size: 12, lines: 1, color: '#94a3b8', flex: 1 }),
        ui.text(fmt(d.bb), { size: 13, lines: 1, align: 'right', color: '#cbd5e1', flex: 1 })
      ], { justify: 'between' }),
      ui.spacer(12),
      ui.text('' + d.days, { align: 'center', size: 50, bold: true, color: '#e2b13c', lines: 1 }),
      ui.text('天', { align: 'center', size: 14, color: '#94a3b8', lines: 1 }),
      ui.spacer(4),
      ui.text(d.days < 0 ? '结束日期早于起始日期' : '结束日期减去起始日期', { align: 'center', size: 11, color: '#64748b', lines: 1 }),
      ui.spacer(12),
      ui.button('修改两个日期', diffEdit, { id: 'dd_edit', tone: 'primary', height: 48 }),
      ui.spacer(6),
      ui.text('输入格式为 8 位数字:YYYYMMDD', { align: 'center', size: 10, color: '#475569', lines: 1 }),
      ui.spacer(8),
      backBtn('dd_back')
    ], { background: s.uiBg })
  ];
}

function renderReact() {
  const s = state.get();
  const r = s.react;
  let title = '点击开始', sub = '等方块变绿后立刻点击', bg = '#16233a', col = '#94a3b8';
  if (r.ms < 0) { title = '抢跑'; sub = '点下方按钮重来'; bg = '#7c2d12'; col = '#ef4444'; }
  else if (r.st === 'wait') { title = '等待…'; sub = '现在点击算抢跑'; bg = '#7c2d12'; col = '#f59e0b'; }
  else if (r.st === 'go') { title = '点!'; sub = '快!'; bg = '#14532d'; col = '#22c55e'; }
  else if (r.st === 'done') {
    title = r.ms + ' ms';
    sub = r.ms < 200 ? '神反应' : (r.ms < 300 ? '很快' : (r.ms < 450 ? '正常水平' : '再快一点'));
    bg = '#1e3a5f'; col = '#38bdf8';
  }
  return [
    ui.column([
      ui.heading('反应测试', { align: 'center', size: 18, color: '#38bdf8', lines: 1 }),
      ui.spacer(2),
      ui.text('最佳 ' + (r.best ? r.best + ' ms' : '--'), { align: 'center', size: 12, color: '#94a3b8', lines: 1 }),
      ui.spacer(8),
      ui.button(title, reactTap, { id: 'rc_pad', background: bg, color: col, height: 180 }),
      ui.spacer(6),
      ui.text(sub, { align: 'center', size: 13, color: '#64748b', lines: 2 }),
      ui.spacer(8),
      ui.row([
        ui.button('再来一次', () => { vib(); reactStart(); }, { id: 'rc_again', tone: 'primary', flex: 1, height: 42 }),
        backBtn('rc_back')
      ], { gap: 5 })
    ], { background: s.uiBg })
  ];
}

function renderGuess() {
  const s = state.get();
  const g = s.guess;
  return [
    ui.column([
      ui.heading('猜数字', { align: 'center', size: 18, color: '#38bdf8', lines: 1 }),
      ui.spacer(6),
      ui.text(g.done ? '答案就是' : '当前范围', { align: 'center', size: 11, color: '#64748b', lines: 1 }),
      ui.text(g.done ? '' + g.secret : g.lo + ' ~ ' + g.hi, { align: 'center', size: 26, bold: true, color: '#e2b13c', lines: 1 }),
      ui.spacer(8),
      ui.row([
        ui.text('已猜 ' + g.tries + ' 次', { size: 12, lines: 1, color: '#cbd5e1', flex: 1 }),
        ui.text('1~100 之间', { size: 11, lines: 1, align: 'right', color: '#64748b', flex: 1 })
      ], { justify: 'between' }),
      ui.spacer(4),
      ui.text(g.msg, { align: 'center', size: 13, color: g.done ? '#22c55e' : '#cbd5e1', lines: 3, lineHeight: 18 }),
      ui.spacer(10),
      ui.button('输入一个数', guessAsk, { id: 'gs_in', tone: 'primary', height: 50 }),
      ui.spacer(6),
      ui.button('再来一局', () => { vib(); initGuess(); }, { id: 'gs_new', tone: 'neutral', height: 42 }),
      ui.spacer(6),
      backBtn('gs_back')
    ], { background: s.uiBg })
  ];
}

function renderSw() {
  const s = state.get();
  const cur = s.sw.acc + (s.sw.run ? Math.max(0, Date.now() - s.sw.t0) : 0);
  const nodes = [
    ui.heading('秒表', { align: 'center', size: 18, color: '#38bdf8', lines: 1 }),
    ui.spacer(8),
    ui.text(ms2str(cur), { align: 'center', size: 40, bold: true, color: '#e2b13c', lines: 1 }),
    ui.text(s.sw.run ? '计时中' : (s.sw.acc > 0 ? '已暂停' : '待开始'), { align: 'center', size: 12, color: '#94a3b8', lines: 1 }),
    ui.spacer(10),
    ui.row([
      ui.button(s.sw.run ? '暂停' : '开始', swToggle, { id: 'sw_play', tone: 'primary', flex: 1, height: 50 }),
      ui.button('分段', swLap, { id: 'sw_lap', tone: 'neutral', flex: 1, height: 50 })
    ], { gap: 6 }),
    ui.spacer(6),
    ui.button('归零', swReset, { id: 'sw_reset', tone: 'neutral', height: 40 }),
    ui.spacer(8),
    ui.text('分段记录', { size: 12, bold: true, color: '#94a3b8', lines: 1 })
  ];
  if (s.sw.laps.length) {
    for (let i = 0; i < s.sw.laps.length; i++) {
      nodes.push(ui.text('第' + (i + 1) + '次  ' + ms2str(s.sw.laps[i]), { size: 12, color: '#cbd5e1', lines: 1 }));
    }
  } else {
    nodes.push(ui.text('暂无分段', { size: 12, color: '#475569', lines: 1 }));
  }
  nodes.push(ui.spacer(6));
  nodes.push(backBtn('sw_back'));
  return [ui.column(nodes, { background: s.uiBg })];
}

function renderRadix() {
  const s = state.get();
  const v = s.radix.v;
  const rows = [
    { k: '二进制', val: toBase(v, 2) },
    { k: '八进制', val: toBase(v, 8) },
    { k: '十进制', val: '' + v },
    { k: '十六进制', val: toBase(v, 16).toUpperCase() }
  ];
  const nodes = [
    ui.heading('进制转换', { align: 'center', size: 18, color: '#38bdf8', lines: 1 }),
    ui.spacer(2),
    ui.text('输入一个十进制数,自动换算出其他进制', { align: 'center', size: 11, color: '#64748b', lines: 1 }),
    ui.spacer(8)
  ];
  for (let i = 0; i < rows.length; i++) {
    nodes.push(ui.text(rows[i].k, { size: 12, color: '#94a3b8', lines: 1 }));
    nodes.push(ui.text(rows[i].val, { size: 16, bold: true, color: '#22c55e', lines: 3, lineHeight: 22 }));
    nodes.push(ui.spacer(6));
  }
  nodes.push(ui.button('输入新数值', radixEdit, { id: 'rx_edit', tone: 'primary', height: 46 }));
  nodes.push(ui.spacer(8));
  nodes.push(backBtn('rx_back'));
  return [ui.column(nodes, { background: s.uiBg })];
}

function renderClock() {
  const s = state.get();
  return [
    ui.column([
      ui.heading('电子钟', { align: 'center', size: 18, color: '#38bdf8', lines: 1 }),
      ui.spacer(12),
      ui.text(fmtTime(s.now), { align: 'center', size: 44, bold: true, color: '#e2b13c', lines: 1 }),
      ui.spacer(4),
      ui.text(fmtDate(s.now), { align: 'center', size: 15, color: '#cbd5e1', lines: 1 }),
      ui.spacer(14),
      ui.row([
        ui.text('电池', { size: 12, lines: 1, color: '#94a3b8', flex: 1 }),
        ui.text((s.battery.level >= 0 ? s.battery.level + '%' : '--') + (s.battery.charging ? ' 充电中' : ''), { size: 12, lines: 1, align: 'right', color: s.battery.charging ? '#22c55e' : '#cbd5e1', flex: 1 })
      ], { justify: 'between' }),
      ui.spacer(2),
      ui.progress('电量', s.battery.level >= 0 ? s.battery.level : 0, { accent: s.battery.level >= 0 && s.battery.level <= 20 ? '#ef4444' : '#22c55e' }),
      ui.spacer(10),
      ui.text('设备 ' + s.device.model, { align: 'center', size: 11, color: '#64748b', lines: 1 }),
      ui.spacer(10),
      backBtn('ck_back')
    ], { background: s.uiBg })
  ];
}

function renderTorch() {
  return [
    ui.column([
      ui.stack([
        ui.button('', torchTap, { id: 'to_btn', background: '#ffffff', color: '#64748b', height: 300, width: 324 })
      ], { height: 300 }),
      ui.text('双击白色区域,或点下方「返回」退出', { align: 'center', size: 12, color: '#94a3b8', lines: 1 }),
      ui.spacer(6),
      backBtn('to_back')
    ], { background: '#ffffff' })
  ];
}

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 'snake': return renderSnake();
    case 'math': return renderMath();
    case 'memo': return renderMemo();
    case 'sudoku': return renderSudoku();
    case 'datediff': return renderDiff();
    case 'react': return renderReact();
    case 'guess': return renderGuess();
    case 'sw': return renderSw();
    case 'radix': return renderRadix();
    case 'clock': return renderClock();
    case 'torch': return renderTorch();
    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 === 'snake' && st.snake.run && !st.snake.over) {
      if (!st.snake.last || nw - st.snake.last > 420) moveSnake();
    }
    if (st.phase === 'math') tickMath(nw);
    if (st.phase === 'sw') tickSw(nw);
    if (st.phase === 'clock') tickClock(nw);
  }, 250);

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