万相匣 ③ 工具集

工具,游戏集

适配设备:Pro

作者:lrj

下载量:29 · 点赞:0

可信源码风险提示:保存或下载前请阅读源码和权限声明。
SHA-25676077fa93f38146c0acbca96f11a785344da87691ffff557758125598973de4f
// ============================================================
//  万相匣 ③ 工具集
//  平台:JSlab 手环(324 × 480 竖屏)  文件 ③ / 共 3 个
//  内含 11 个独立模块:2048、迷宫探险、AA 分账、生命游戏、倒计时、石头剪刀布、BMI 计算、指南针、密码生成、抛硬币、计数器
//  规范:每个模块为独立页面,页面底部均有独立的「返回」键
//  结构: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(),
  g2048:  { cells: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], score: 0, best: 0, over: false },
  maze:  { g: [], px: 1, py: 1, moves: 0, over: false, sec: 0, t0: 0 },
  split: { total: 120, people: 3, tip: 10 },
  life:  { cells: [], run: false, gen: 0, last: 0 },
  tm:  { total: 300, left: 300, run: false, endAt: 0 },
  rps:  { you: '-', cpu: '-', win: 0, lose: 0, draw: 0, msg: '请选择手势' },
  bmi:  { h: 170, w: 60 },
  pwd:  { len: 12, out: '' },
  coin:  { face: '?', heads: 0, tails: 0 },
  cnt:  0,
});

const G2048_COLORS = {
  0: '#1b2436', 2: '#3b4759', 4: '#4d5b70', 8: '#e08b3c', 16: '#e2702a',
  32: '#e05252', 64: '#d13b6b', 128: '#b23ca8', 256: '#7b3cd1',
  512: '#3c66d1', 1024: '#2b9bd1', 2048: '#22c55e'
};
const RPS = ['石头', '剪刀', '布'];
const PWD_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%&*';

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 = {
  g2048: '方向键滑动合并相同数字,目标 2048',
  maze: '方向键走迷宫,抵达右下角终点',
  split: '输入总额与人数,算出 AA 每人应付',
  life: '康威生命游戏,点击格子切换生死',
  timer: '倒计时,可设定分钟数,归零震动提醒',
  rps: '石头剪刀布,三局两胜',
  bmi: '输入身高体重,算出 BMI 与体型等级',
  compass: '显示指南针方向与当前朝向角度',
  pwd: '生成随机密码,可切换长度',
  coin: '抛硬币,正反面随机',
  counter: '点一下加一,可减一或清零',
};

const GAMES = [
  { id: 'g2048', name: '2048' },
  { id: 'maze', name: '迷宫探险' },
  { id: 'life', name: '生命游戏' },
  { id: 'rps', name: '石头剪刀布' }
];

const TOOLS = [
  { id: 'split', name: 'AA 分账' },
  { id: 'timer', name: '倒计时' },
  { id: 'bmi', name: 'BMI 计算' },
  { id: 'compass', name: '指南针' },
  { id: 'pwd', name: '密码生成' },
  { id: 'coin', name: '抛硬币' },
  { id: 'counter', 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,
    life: { ...p.life, 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 'g2048': init2048(); break;
    case 'maze': initMaze(); break;
    case 'split': state.update(p => ({ ...p, phase: 'split' })); break;
    case 'life': initLife(); break;
    case 'timer': state.update(p => ({ ...p, phase: 'timer' })); break;
    case 'rps': initRps(); break;
    case 'bmi': state.update(p => ({ ...p, phase: 'bmi' })); break;
    case 'compass': startCompass(); state.update(p => ({ ...p, phase: 'compass' })); break;
    case 'pwd': state.update(p => ({ ...p, phase: 'pwd' })); break;
    case 'coin': state.update(p => ({ ...p, phase: 'coin' })); break;
    case 'counter': state.update(p => ({ ...p, phase: 'counter' })); break;
    default: state.update(p => ({ ...p, phase: 'menu' }));
  }
}

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

function addTile2048(cells) {
  const empty = [];
  for (let i = 0; i < 16; i++) if (cells[i] === 0) empty.push(i);
  if (!empty.length) return false;
  cells[empty[rndInt(empty.length)]] = Math.random() < 0.9 ? 2 : 4;
  return true;
}

function canMove2048(cells) {
  for (let i = 0; i < 16; i++) if (cells[i] === 0) return true;
  for (let r = 0; r < 4; r++) {
    for (let c = 0; c < 4; c++) {
      const v = cells[r * 4 + c];
      if (c < 3 && v === cells[r * 4 + c + 1]) return true;
      if (r < 3 && v === cells[(r + 1) * 4 + c]) return true;
    }
  }
  return false;
}

function init2048() {
  const cells = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
  addTile2048(cells); addTile2048(cells);
  state.update(p => ({ ...p, g2048: { cells, score: 0, best: p.g2048.best || 0, over: false }, phase: 'g2048' }));
}

function move2048(dir) {
  const s = state.get();
  if (s.g2048.over) return;
  const cells = s.g2048.cells.slice();
  let score = s.g2048.score, moved = false;
  for (let line = 0; line < 4; line++) {
    const idxs = [];
    for (let i = 0; i < 4; i++) {
      let r, c;
      if (dir === 'l') { r = line; c = i; }
      else if (dir === 'r') { r = line; c = 3 - i; }
      else if (dir === 'u') { r = i; c = line; }
      else { r = 3 - i; c = line; }
      idxs.push(r * 4 + c);
    }
    const vals = [];
    for (let i = 0; i < 4; i++) if (cells[idxs[i]] !== 0) vals.push(cells[idxs[i]]);
    const merged = [];
    for (let i = 0; i < vals.length; i++) {
      if (i + 1 < vals.length && vals[i] === vals[i + 1]) {
        merged.push(vals[i] * 2);
        score += vals[i] * 2;
        i++;
      } else merged.push(vals[i]);
    }
    while (merged.length < 4) merged.push(0);
    for (let i = 0; i < 4; i++) {
      if (cells[idxs[i]] !== merged[i]) moved = true;
      cells[idxs[i]] = merged[i];
    }
  }
  if (!moved) return;
  vib();
  addTile2048(cells);
  const over = !canMove2048(cells);
  const best = Math.max(s.g2048.best || 0, score);
  state.update(p => ({ ...p, g2048: { cells, score, best, over } }));
  if (over) { vibLong(); script.toast('无路可走,得分 ' + score, 2000); }
}

function initMaze() {
  const N = 9, CW = 4;
  const g = [];
  for (let i = 0; i < N * N; i++) g.push(1);
  const seen = {};
  const stack = [[0, 0]];
  g[1 * N + 1] = 0;
  seen['0,0'] = true;
  while (stack.length) {
    const cur = stack[stack.length - 1];
    const cx = cur[0], cy = cur[1];
    const dirs = shuffleArr([[1, 0], [-1, 0], [0, 1], [0, -1]]);
    let moved = false;
    for (let k = 0; k < dirs.length; k++) {
      const nx = cx + dirs[k][0], ny = cy + dirs[k][1];
      if (nx < 0 || nx >= CW || ny < 0 || ny >= CW) continue;
      if (seen[nx + ',' + ny]) continue;
      seen[nx + ',' + ny] = true;
      const wx = 2 * cx + 1 + dirs[k][0], wy = 2 * cy + 1 + dirs[k][1];
      g[wy * N + wx] = 0;
      g[(2 * ny + 1) * N + (2 * nx + 1)] = 0;
      stack.push([nx, ny]);
      moved = true;
      break;
    }
    if (!moved) stack.pop();
  }
  state.update(p => ({ ...p, maze: { g, px: 1, py: 1, moves: 0, over: false, sec: 0, t0: Date.now() }, phase: 'maze' }));
}

function mazeMove(dx, dy) {
  const s = state.get();
  const m = s.maze;
  if (m.over) return;
  const nx = m.px + dx, ny = m.py + dy;
  if (nx < 0 || nx > 8 || ny < 0 || ny > 8) return;
  if (m.g[ny * 9 + nx] !== 0) return;
  const moves = m.moves + 1;
  const sec = Math.floor((Date.now() - (m.t0 || Date.now())) / 1000);
  const over = nx === 7 && ny === 7;
  vib();
  state.update(p => ({ ...p, maze: { ...m, px: nx, py: ny, moves, sec, over } }));
  if (over) { vibLong(); script.toast('抵达终点!' + moves + ' 步 / ' + sec + ' 秒', 2500); }
}

function splitPer() {
  const s = state.get();
  const n = Math.max(1, s.split.people);
  return (s.split.total * (1 + s.split.tip / 100)) / n;
}
async function splitEdit() {
  const s = state.get();
  const r1 = await dialog.number({ title: '消费总额', message: '单位:元', value: s.split.total, min: 1, max: 99999, decimals: 0 });
  if (r1.action !== 'confirm') return;
  const total = Math.round(r1.value);
  if (!isFinite(total) || total < 1 || total > 99999) { script.toast('金额需 1~99999', 1500); return; }
  const r2 = await dialog.number({ title: '参与人数', message: '1~20 人', value: s.split.people, min: 1, max: 20, decimals: 0 });
  if (r2.action !== 'confirm') return;
  const people = Math.round(r2.value);
  if (!isFinite(people) || people < 1 || people > 20) { script.toast('人数需 1~20', 1500); return; }
  state.update(p => ({ ...p, split: { ...p.split, total, people } }));
  script.toast('已更新', 1200);
}
function splitTipCycle() {
  const s = state.get();
  const opts = [0, 5, 10, 15, 20];
  const i = opts.indexOf(s.split.tip);
  const tip = opts[(i + 1) % opts.length];
  state.update(p => ({ ...p, split: { ...p.split, tip } }));
  script.toast('小费 ' + tip + '%', 1200);
}

function initLife() {
  const cells = [];
  for (let i = 0; i < 64; i++) cells.push(Math.random() < 0.32 ? 1 : 0);
  state.update(p => ({ ...p, life: { cells, run: false, gen: 0, last: 0 }, phase: 'life' }));
}

function lifeToggle(i) {
  const s = state.get();
  const cells = s.life.cells.slice();
  cells[i] = cells[i] ? 0 : 1;
  vib();
  state.update(p => ({ ...p, life: { ...p.life, cells } }));
}

function stepLife() {
  const s = state.get();
  const c = s.life.cells;
  const N = 8;
  const nx = [];
  for (let i = 0; i < N * N; i++) {
    const x = i % N, y = Math.floor(i / N);
    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 + N) % N, ay = (y + dy + N) % N;
        if (c[ay * N + ax]) n++;
      }
    }
    nx.push(c[i] ? (n === 2 || n === 3 ? 1 : 0) : (n === 3 ? 1 : 0));
  }
  state.update(p => ({ ...p, life: { ...p.life, cells: nx, gen: p.life.gen + 1, last: Date.now() } }));
}

function timerToggle() {
  const s = state.get();
  const t = s.tm;
  if (t.run) {
    const left = Math.max(0, Math.ceil((t.endAt - Date.now()) / 1000));
    state.update(p => ({ ...p, tm: { ...p.tm, run: false, left } }));
    vib();
  } else {
    if (t.left <= 0) { script.toast('时间已到,请重设', 1500); return; }
    state.update(p => ({ ...p, tm: { ...p.tm, run: true, endAt: Date.now() + t.left * 1000 } }));
    vib();
  }
}

function timerReset() {
  state.update(p => ({ ...p, tm: { ...p.tm, run: false, left: p.tm.total, endAt: 0 } }));
  script.toast('已重置', 1200);
}

async function timerSetTotal() {
  const s = state.get();
  const r = await dialog.number({ title: '倒计时分钟', message: '1~120 分钟', value: Math.round(s.tm.total / 60), min: 1, max: 120, decimals: 0 });
  if (r.action !== 'confirm') return;
  const m = Math.round(r.value);
  if (!isFinite(m) || m < 1 || m > 120) { script.toast('请输入 1~120', 1500); return; }
  state.update(p => ({ ...p, tm: { total: m * 60, left: m * 60, run: false, endAt: 0 } }));
  script.toast('已设为 ' + m + ' 分钟', 1500);
}

function tickTimer(nw) {
  const s = state.get();
  const t = s.tm;
  if (!t.run) return;
  const left = Math.max(0, Math.ceil((t.endAt - nw) / 1000));
  if (left === 0) {
    state.update(p => ({ ...p, tm: { ...p.tm, left: 0, run: false, endAt: 0 } }));
    vibLong();
    script.toast('倒计时结束', 2500);
  } else if (left !== t.left) {
    state.update(p => ({ ...p, tm: { ...p.tm, left } }));
  }
}

function initRps() {
  state.update(p => ({ ...p, rps: { you: '-', cpu: '-', win: p.rps.win || 0, lose: p.rps.lose || 0, draw: p.rps.draw || 0, msg: '请选择手势' }, phase: 'rps' }));
}

function rpsPlay(i) {
  const s = state.get();
  const cpu = rndInt(3);
  let win = s.rps.win, lose = s.rps.lose, draw = s.rps.draw, msg = '';
  if (i === cpu) { draw++; msg = '平局'; }
  else if ((i === 0 && cpu === 1) || (i === 1 && cpu === 2) || (i === 2 && cpu === 0)) { win++; msg = '你赢了!'; vibLong(); }
  else { lose++; msg = '你输了'; vib(); }
  state.update(p => ({ ...p, rps: { you: RPS[i], cpu: RPS[cpu], win, lose, draw, msg } }));
}

async function bmiEdit() {
  const s = state.get();
  const r1 = await dialog.number({ title: '身高 cm', message: '100~220', value: s.bmi.h, min: 100, max: 220, decimals: 0 });
  if (r1.action !== 'confirm') return;
  const h = Math.round(r1.value);
  if (!isFinite(h) || h < 100 || h > 220) { script.toast('身高需 100~220', 1500); return; }
  const r2 = await dialog.number({ title: '体重 kg', message: '20~200', value: s.bmi.w, min: 20, max: 200, decimals: 1 });
  if (r2.action !== 'confirm') return;
  const w = Math.round(r2.value * 10) / 10;
  if (!isFinite(w) || w < 20 || w > 200) { script.toast('体重需 20~200', 1500); return; }
  state.update(p => ({ ...p, bmi: { h, w } }));
  script.toast('已更新', 1200);
}

function bmiOf(h, w) {
  if (!h) return 0;
  const m = h / 100;
  return w / (m * m);
}

function bmiLevel(v) {
  if (v < 18.5) return { t: '偏瘦', c: '#38bdf8' };
  if (v < 24) return { t: '正常', c: '#22c55e' };
  if (v < 28) return { t: '偏胖', c: '#f59e0b' };
  return { t: '肥胖', c: '#ef4444' };
}

function pwdMake() {
  const s = state.get();
  const len = s.pwd.len;
  let out = '';
  for (let i = 0; i < len; i++) out += PWD_CHARS.charAt(rndInt(PWD_CHARS.length));
  vib();
  state.update(p => ({ ...p, pwd: { ...p.pwd, out } }));
}

function pwdLenCycle() {
  const s = state.get();
  const opts = [8, 12, 16, 20];
  const i = opts.indexOf(s.pwd.len);
  const len = opts[(i + 1) % opts.length];
  state.update(p => ({ ...p, pwd: { ...p.pwd, len, out: '' } }));
  script.toast('长度 ' + len, 1200);
}

function coinFlip() {
  const heads = Math.random() < 0.5;
  vib();
  state.update(p => ({
    ...p,
    coin: {
      face: heads ? '正' : '反',
      heads: (p.coin.heads || 0) + (heads ? 1 : 0),
      tails: (p.coin.tails || 0) + (heads ? 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 render2048() {
  const s = state.get();
  const g = s.g2048;
  const rows = [];
  for (let r = 0; r < 4; r++) {
    const cells = [];
    for (let c = 0; c < 4; c++) {
      const v = g.cells[r * 4 + c];
      cells.push(ui.button(v ? '' + v : ' ', () => { script.toast('请用方向键滑动', 900); }, {
        id: 'g2c' + (r * 4 + c), tone: 'neutral', flex: 1, height: 52,
        background: G2048_COLORS[v] || '#2b9bd1', color: v ? '#ffffff' : '#1b2436'
      }));
    }
    rows.push(ui.row(cells, { gap: 5 }));
    rows.push(ui.spacer(5));
  }
  return [
    ui.column([
      ui.heading('2048', { align: 'center', size: 18, color: '#e2b13c', lines: 1 }),
      ui.spacer(2),
      ui.row([
        ui.text('得分 ' + g.score, { size: 12, lines: 1, color: '#38bdf8', flex: 1 }),
        ui.text('最高 ' + (g.best || 0), { size: 12, lines: 1, align: 'right', color: '#94a3b8', flex: 1 })
      ], { justify: 'between' }),
      ui.text(g.over ? '无路可走,点「重开」' : '用下方方向键合并同数方块', { align: 'center', size: 11, color: g.over ? '#ef4444' : '#64748b', lines: 1 }),
      ui.spacer(4),
      ui.column(rows, { width: 324 })
    ].concat([
      ui.row([
        ui.button('←', () => move2048('l'), { id: 'g2_l', tone: 'neutral', flex: 1, height: 40 }),
        ui.button('↑', () => move2048('u'), { id: 'g2_u', tone: 'neutral', flex: 1, height: 40 }),
        ui.button('↓', () => move2048('d'), { id: 'g2_d', tone: 'neutral', flex: 1, height: 40 }),
        ui.button('→', () => move2048('r'), { id: 'g2_r', tone: 'neutral', flex: 1, height: 40 })
      ], { gap: 5 }),
      ui.spacer(5),
      ui.row([
        ui.button('重开', () => { vib(); init2048(); }, { id: 'g2_new', tone: 'primary', flex: 1, height: 42 }),
        backBtn('g2_back')
      ], { gap: 5 })
    ]), { background: s.uiBg })
  ];
}

function renderMaze() {
  const s = state.get();
  const m = s.maze;
  const N = 9, CS = 27, OX = 20;
  const nodes = [];
  for (let y = 0; y < N; y++) {
    for (let x = 0; x < N; x++) {
      const wall = m.g[y * N + x] === 1;
      nodes.push(ui.text('', {
        x: OX + x * CS + 1, y: y * CS + 1, width: CS - 2, height: CS - 2,
        background: wall ? '#1e293b' : '#0b1220', radius: 2
      }));
    }
  }
  nodes.push(ui.text('', { x: OX + 7 * CS + 7, y: 7 * CS + 7, width: CS - 14, height: CS - 14, background: '#f59e0b', radius: 4 }));
  nodes.push(ui.text('', { x: OX + m.px * CS + 6, y: m.py * CS + 6, width: CS - 12, height: CS - 12, background: '#38bdf8', radius: 5 }));
  return [
    ui.column([
      ui.heading('迷宫探险', { align: 'center', size: 18, color: '#22c55e', lines: 1 }),
      ui.spacer(2),
      ui.row([
        ui.text('步数 ' + m.moves, { size: 12, lines: 1, color: '#cbd5e1', flex: 1 }),
        ui.text('用时 ' + m.sec + 's', { size: 12, lines: 1, align: 'right', color: '#94a3b8', flex: 1 })
      ], { justify: 'between' }),
      ui.text(m.over ? '到达终点!' : '蓝点是你,走到右下角黄点', { align: 'center', size: 11, color: m.over ? '#22c55e' : '#64748b', lines: 1 }),
      ui.spacer(3),
      ui.stack(nodes, { width: 324, height: 245, background: '#0b1220', radius: 8 }),
      ui.spacer(4),
      ui.row([
        ui.button('←', () => mazeMove(-1, 0), { id: 'mz_l', tone: 'neutral', flex: 1, height: 40 }),
        ui.button('↑', () => mazeMove(0, -1), { id: 'mz_u', tone: 'neutral', flex: 1, height: 40 }),
        ui.button('↓', () => mazeMove(0, 1), { id: 'mz_d', tone: 'neutral', flex: 1, height: 40 }),
        ui.button('→', () => mazeMove(1, 0), { id: 'mz_r', tone: 'neutral', flex: 1, height: 40 })
      ], { gap: 5 }),
      ui.spacer(5),
      ui.row([
        ui.button('换一张', () => { vib(); initMaze(); }, { id: 'mz_new', tone: 'primary', flex: 1, height: 42 }),
        backBtn('mz_back')
      ], { gap: 5 })
    ], { background: s.uiBg })
  ];
}

function renderSplit() {
  const s = state.get();
  const q = s.split;
  const per = splitPer();
  const all = q.total * (1 + q.tip / 100);
  return [
    ui.column([
      ui.heading('AA 分账', { align: 'center', size: 18, color: '#38bdf8', lines: 1 }),
      ui.spacer(4),
      ui.row([
        ui.text('总额', { size: 12, lines: 1, color: '#94a3b8', flex: 1 }),
        ui.text(q.total + ' 元', { 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(q.people + ' 人', { 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(q.tip + '%', { size: 13, lines: 1, align: 'right', color: '#cbd5e1', flex: 1 })
      ], { justify: 'between' }),
      ui.spacer(10),
      ui.text(per.toFixed(2), { align: 'center', size: 44, bold: true, color: '#e2b13c', lines: 1 }),
      ui.text('每人应付(元)', { align: 'center', size: 12, color: '#94a3b8', lines: 1 }),
      ui.spacer(2),
      ui.text('含小费合计 ' + all.toFixed(2) + ' 元', { align: 'center', size: 11, color: '#64748b', lines: 1 }),
      ui.spacer(12),
      ui.button('修改金额与人数', splitEdit, { id: 'sp_edit', tone: 'primary', height: 46 }),
      ui.spacer(6),
      ui.row([
        ui.button('切换小费', splitTipCycle, { id: 'sp_tip', tone: 'neutral', flex: 1, height: 42 }),
        backBtn('sp_back')
      ], { gap: 5 })
    ], { background: s.uiBg })
  ];
}

function renderLife() {
  const s = state.get();
  const L = s.life;
  const N = 8;
  const rows = [];
  for (let r = 0; r < N; r++) {
    const cells = [];
    for (let c = 0; c < N; c++) {
      const i = r * N + c;
      cells.push(ui.button('', () => lifeToggle(i), {
        id: 'lf_' + i, tone: 'neutral', flex: 1, height: 24,
        background: L.cells[i] ? '#22c55e' : '#16233a', color: '#000000'
      }));
    }
    rows.push(ui.row(cells, { gap: 2, width: 324 }));
    if (r < N - 1) rows.push(ui.spacer(2));
  }
  return [
    ui.column([
      ui.heading('生命游戏', { align: 'center', size: 18, color: '#22c55e', lines: 1 }),
      ui.spacer(2),
      ui.row([
        ui.text('第 ' + L.gen + ' 代', { size: 12, lines: 1, color: '#cbd5e1', flex: 1 }),
        ui.text(L.run ? '演化中' : '已暂停', { size: 12, lines: 1, align: 'right', color: L.run ? '#22c55e' : '#64748b', flex: 1 })
      ], { justify: 'between' }),
      ui.text('点格子切换生死,绿为活细胞', { align: 'center', size: 11, color: '#64748b', lines: 1 }),
      ui.spacer(4),
      ui.column(rows, { width: 324 }),
      ui.spacer(6),
      ui.row([
        ui.button('单步', () => { vib(); stepLife(); }, { id: 'lf_step', tone: 'neutral', flex: 1, height: 38 }),
        ui.button(L.run ? '暂停' : '自动', () => {
          const run = !state.get().life.run;
          vib();
          state.update(p => ({ ...p, life: { ...p.life, run, last: Date.now() } }));
        }, { id: 'lf_run', tone: 'primary', flex: 1, height: 38 }),
        ui.button('重置', () => { vib(); initLife(); }, { id: 'lf_reset', tone: 'neutral', flex: 1, height: 38 })
      ], { gap: 5 }),
      ui.spacer(5),
      backBtn('lf_back')
    ], { background: s.uiBg })
  ];
}

function renderTimer() {
  const s = state.get();
  const t = s.tm;
  return [
    ui.column([
      ui.heading('倒计时', { align: 'center', size: 18, color: '#38bdf8', lines: 1 }),
      ui.spacer(4),
      ui.text(sec2str(t.left), { align: 'center', size: 44, bold: true, color: t.left <= 10 && t.run ? '#ef4444' : '#e2b13c', lines: 1 }),
      ui.text('预设总时长 ' + sec2str(t.total), { align: 'center', size: 12, color: '#94a3b8', lines: 1 }),
      ui.spacer(8),
      ui.progress('已用进度', t.total > 0 ? clamp(Math.round((1 - t.left / t.total) * 100), 0, 100) : 0, { accent: '#38bdf8' }),
      ui.spacer(8),
      ui.row([
        ui.button(t.run ? '暂停' : '开始', timerToggle, { id: 'tm_play', tone: 'primary', flex: 1, height: 50 }),
        ui.button('重置', timerReset, { id: 'tm_reset', tone: 'neutral', flex: 1, height: 50 })
      ], { gap: 6 }),
      ui.spacer(6),
      ui.button('设置分钟数', timerSetTotal, { id: 'tm_set', tone: 'neutral', height: 42 }),
      ui.spacer(6),
      ui.text('支持 1~120 分钟,可随时暂停与重置', { align: 'center', size: 10, color: '#475569', lines: 1 }),
      ui.spacer(6),
      backBtn('tm_back')
    ], { background: s.uiBg })
  ];
}

function renderRps() {
  const s = state.get();
  const r = s.rps;
  return [
    ui.column([
      ui.heading('石头剪刀布', { align: 'center', size: 18, color: '#e2b13c', lines: 1 }),
      ui.spacer(6),
      ui.row([
        ui.column([ui.text('你', { align: 'center', size: 11, color: '#94a3b8', lines: 1 }), ui.text(r.you, { align: 'center', size: 20, bold: true, color: '#38bdf8', lines: 1 })], { flex: 1 }),
        ui.text('VS', { align: 'center', size: 13, color: '#64748b', lines: 1, width: 40 }),
        ui.column([ui.text('电脑', { align: 'center', size: 11, color: '#94a3b8', lines: 1 }), ui.text(r.cpu, { align: 'center', size: 20, bold: true, color: '#f59e0b', lines: 1 })], { flex: 1 })
      ], { justify: 'between' }),
      ui.spacer(4),
      ui.text(r.msg, { align: 'center', size: 16, bold: true, color: r.msg === '你赢了!' ? '#22c55e' : (r.msg === '你输了' ? '#ef4444' : '#cbd5e1'), lines: 1 }),
      ui.spacer(6),
      ui.row([
        ui.text('胜 ' + r.win, { size: 12, lines: 1, color: '#22c55e', flex: 1 }),
        ui.text('平 ' + r.draw, { size: 12, lines: 1, align: 'center', color: '#94a3b8', flex: 1 }),
        ui.text('负 ' + r.lose, { size: 12, lines: 1, align: 'right', color: '#ef4444', flex: 1 })
      ], { justify: 'between' }),
      ui.spacer(12),
      ui.row([
        ui.button('石头', () => rpsPlay(0), { id: 'rp_0', tone: 'primary', flex: 1, height: 56 }),
        ui.button('剪刀', () => rpsPlay(1), { id: 'rp_1', tone: 'primary', flex: 1, height: 56 }),
        ui.button('布', () => rpsPlay(2), { id: 'rp_2', tone: 'primary', flex: 1, height: 56 })
      ], { gap: 6 }),
      ui.spacer(8),
      ui.button('清零战绩', () => { vib(); initRps(); }, { id: 'rp_new', tone: 'neutral', height: 40 }),
      ui.spacer(6),
      backBtn('rp_back')
    ], { background: s.uiBg })
  ];
}

function renderBmi() {
  const s = state.get();
  const v = bmiOf(s.bmi.h, s.bmi.w);
  const lv = bmiLevel(v);
  return [
    ui.column([
      ui.heading('BMI 计算', { align: 'center', size: 18, color: '#38bdf8', lines: 1 }),
      ui.spacer(8),
      ui.text(v ? v.toFixed(1) : '--', { align: 'center', size: 50, bold: true, color: lv.c, lines: 1 }),
      ui.text(v ? lv.t : '请输入身高体重', { align: 'center', size: 16, bold: true, color: lv.c, lines: 1 }),
      ui.spacer(8),
      ui.row([
        ui.text('身高', { size: 12, lines: 1, color: '#94a3b8', flex: 1 }),
        ui.text(s.bmi.h + ' cm', { 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(s.bmi.w + ' kg', { size: 13, lines: 1, align: 'right', color: '#cbd5e1', flex: 1 })
      ], { justify: 'between' }),
      ui.spacer(8),
      ui.button('修改身高体重', bmiEdit, { id: 'bm_edit', tone: 'primary', height: 48 }),
      ui.spacer(8),
      ui.text('偏瘦 <18.5 · 正常 18.5~24 · 偏胖 24~28 · 肥胖 ≥28', { align: 'center', size: 10, color: '#64748b', lines: 2 }),
      ui.spacer(4),
      ui.text('结果仅供参考,不作为医学诊断依据', { align: 'center', size: 10, color: '#475569', lines: 1 }),
      ui.spacer(8),
      backBtn('bm_back')
    ], { background: s.uiBg })
  ];
}

function renderCompass() {
  const s = state.get();
  const cx = 162, cy = 148, r = 86;
  const ang = s.heading >= 0 ? s.heading : 0;
  const rad = ang * Math.PI / 180;
  const px = cx + r * Math.sin(rad);
  const py = cy - r * Math.cos(rad);
  const names = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'];
  const dirName = names[Math.round(((ang % 360) + 360) % 360 / 45) % 8];
  const nodes = [
    ui.text('N', { x: 150, y: 12, size: 18, bold: true, color: '#ef4444', lines: 1, width: 24, align: 'center' }),
    ui.text('E', { x: 262, y: 138, size: 18, bold: true, color: '#94a3b8', lines: 1, width: 24, align: 'center' }),
    ui.text('S', { x: 150, y: 264, size: 18, bold: true, color: '#94a3b8', lines: 1, width: 24, align: 'center' }),
    ui.text('W', { x: 38, y: 138, size: 18, bold: true, color: '#94a3b8', lines: 1, width: 24, align: 'center' }),
    ui.text('●', { x: px - 9, y: py - 9, size: 18, color: '#e2b13c', lines: 1 }),
    ui.text(ang + '°', { x: 110, y: 128, size: 26, bold: true, color: '#e2b13c', lines: 1, width: 104, align: 'center' }),
    ui.text(dirName, { x: 110, y: 162, size: 14, color: '#38bdf8', lines: 1, width: 104, align: 'center' })
  ];
  return [
    ui.column([
      ui.heading('指南针', { align: 'center', size: 18, color: '#e2b13c', lines: 1 }),
      ui.spacer(2),
      ui.text(s.compassOk ? (s.heading >= 0 ? '精度 ' + Math.round(s.headingAcc) + '°' : '正在读取…') : '本设备不支持指南针', {
        align: 'center', size: 11, color: s.compassOk ? '#64748b' : '#ef4444', lines: 1
      }),
      ui.stack(nodes, { width: 324, height: 292 }),
      ui.spacer(2),
      ui.text('北=0°,顺时针递增', { align: 'center', size: 10, color: '#475569', lines: 1 }),
      ui.spacer(6),
      ui.row([
        ui.button('重新校准', () => { stopCompass(); startCompass(); script.toast('请画 8 字校准', 2000); }, { id: 'cp_cal', tone: 'neutral', flex: 1, height: 42 }),
        backBtn('cp_back')
      ], { gap: 5 })
    ], { background: s.uiBg })
  ];
}

function renderPwd() {
  const s = state.get();
  return [
    ui.column([
      ui.heading('密码生成', { align: 'center', size: 18, color: '#38bdf8', lines: 1 }),
      ui.spacer(2),
      ui.text('长度 ' + s.pwd.len + ' 位 · 大小写+数字+符号', { align: 'center', size: 11, color: '#94a3b8', lines: 1 }),
      ui.spacer(8),
      ui.text(s.pwd.out || '点击生成', { align: 'center', size: s.pwd.out ? 16 : 13, bold: !!s.pwd.out, color: s.pwd.out ? '#22c55e' : '#475569', lines: 4, lineHeight: 22 }),
      ui.spacer(10),
      ui.button('生成密码', pwdMake, { id: 'pw_gen', tone: 'primary', height: 54 }),
      ui.spacer(6),
      ui.row([
        ui.button('切换长度', pwdLenCycle, { id: 'pw_len', tone: 'neutral', flex: 1, height: 42 }),
        backBtn('pw_back')
      ], { gap: 5 }),
      ui.spacer(8),
      ui.text('8 / 12 / 16 / 20 位循环切换', { align: 'center', size: 10, color: '#475569', lines: 1 }),
      ui.spacer(4),
      ui.text('密码仅在本机生成,不会保存或上传', { align: 'center', size: 10, color: '#475569', lines: 1 })
    ], { background: s.uiBg })
  ];
}

function renderCoin() {
  const s = state.get();
  const col = s.coin.face === '正' ? '#f59e0b' : (s.coin.face === '反' ? '#38bdf8' : '#475569');
  return [
    ui.column([
      ui.heading('抛硬币', { align: 'center', size: 18, color: '#e2b13c', lines: 1 }),
      ui.spacer(10),
      ui.text(s.coin.face, { align: 'center', size: 60, bold: true, color: col, lines: 1 }),
      ui.spacer(4),
      ui.text(s.coin.face === '正' ? '正面' : (s.coin.face === '反' ? '反面' : '点击开始'), { align: 'center', size: 13, color: '#94a3b8', lines: 1 }),
      ui.spacer(14),
      ui.row([
        ui.text('正 ' + (s.coin.heads || 0), { size: 13, lines: 1, color: '#f59e0b', flex: 1 }),
        ui.text('反 ' + (s.coin.tails || 0), { size: 13, lines: 1, align: 'right', color: '#38bdf8', flex: 1 })
      ], { justify: 'between' }),
      ui.spacer(10),
      ui.button('抛一次', coinFlip, { id: 'cn_flip', tone: 'primary', height: 56 }),
      ui.spacer(6),
      ui.row([
        ui.button('清空统计', () => { vib(); state.update(p => ({ ...p, coin: { face: '?', heads: 0, tails: 0 } })); }, { id: 'cn_reset', tone: 'neutral', flex: 1, height: 42 }),
        backBtn('cn_back')
      ], { gap: 5 })
    ], { background: s.uiBg })
  ];
}

function renderCounter() {
  const s = state.get();
  return [
    ui.column([
      ui.heading('计数器', { align: 'center', size: 18, color: '#38bdf8', lines: 1 }),
      ui.spacer(12),
      ui.text('' + (s.cnt || 0), { align: 'center', size: 62, bold: true, color: '#e2b13c', lines: 1 }),
      ui.spacer(6),
      ui.text('点下方按钮累加', { align: 'center', size: 12, color: '#94a3b8', lines: 1 }),
      ui.spacer(10),
      ui.button('+1', () => { vib(); state.update(p => ({ ...p, cnt: (p.cnt || 0) + 1 })); }, { id: 'ct_inc', tone: 'primary', height: 72 }),
      ui.spacer(6),
      ui.row([
        ui.button('+10', () => { vib(); state.update(p => ({ ...p, cnt: (p.cnt || 0) + 10 })); }, { id: 'ct_10', tone: 'neutral', flex: 1, height: 44 }),
        ui.button('-1', () => { vib(); state.update(p => ({ ...p, cnt: Math.max(0, (p.cnt || 0) - 1) })); }, { id: 'ct_dec', tone: 'neutral', flex: 1, height: 44 }),
        ui.button('清零', () => { vib(); state.update(p => ({ ...p, cnt: 0 })); }, { id: 'ct_zero', tone: 'danger', flex: 1, height: 44 })
      ], { gap: 5 }),
      ui.spacer(8),
      backBtn('ct_back')
    ], { 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 'g2048': return render2048();
    case 'maze': return renderMaze();
    case 'split': return renderSplit();
    case 'life': return renderLife();
    case 'timer': return renderTimer();
    case 'rps': return renderRps();
    case 'bmi': return renderBmi();
    case 'compass': return renderCompass();
    case 'pwd': return renderPwd();
    case 'coin': return renderCoin();
    case 'counter': return renderCounter();
    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 === 'maze' && !st.maze.over && st.maze.t0) {
      const sec = Math.floor((nw - st.maze.t0) / 1000);
      if (sec !== st.maze.sec) state.update(p => ({ ...p, maze: { ...p.maze, sec } }));
    }
    if (st.phase === 'life' && st.life.run) {
      if (!st.life.last || nw - st.life.last > 500) stepLife();
    }
    if (st.phase === 'timer') tickTimer(nw);
  }, 250);

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