diff --git a/.workbuddy/memory/2026-10-23.md b/.workbuddy/memory/2026-10-23.md
new file mode 100644
index 0000000..d7735f3
--- /dev/null
+++ b/.workbuddy/memory/2026-10-23.md
@@ -0,0 +1,24 @@
+# 2026-10-23 工作日志
+
+## 首页移除「清除存档」按钮(用户要求)
+- 需求:把首页(scr-title)的「清除存档」按钮删掉。
+- 改动:仅删 `index.html:41` 的 `` 一行。`js/main.js` 里的 `wipe` handler(确认后 `Save.wipe()`)已无按钮触发,成为未接线死代码,保留无害(用户只要求删按钮,未要求删逻辑)。
+- 验证:`grep 清除存档/data-act="wipe"` 零残留;`tools/` 无引用;`death/map/check/solve/smoke/save/key/dom-smoke/ice` 九套回归全绿;预览 8123 返回 200。
+
+## 主界面新增「设置」功能(用户要求)
+- 需求:主界面加「设置」入口;可自定义用户名;可拖动调整手机操作按钮的位置与大小;点「确定」保存、有「恢复默认」按钮。
+- 新增 `js/settings.js`:`BUTTONS`(7 个手柄键 left/right/jump/shoot/down/pause/suicide,各带默认 `cx,cy,size` 百分比+像素),`meta()/place()/apply()/showUsername()`,`Settings.init/enter/confirm/reset`,`wire()` 给预览按钮绑「拖动移动 + 右下角圆点缩放」。工作副本 `work` 未点确定不落盘,点「返回」即丢弃。
+- 存档:`js/save.js` 的 `settings:{username,touch}` 走 `Save.flush()`(localStorage `iwbtc_fan_v1`)。
+- 接线:`index.html` 标题菜单加「设置」按钮 + `#title-user` + `scr-settings` 屏(用户名 input + `#touch-preview` 拖拽预览 + 确定/恢复默认);`js/main.js` 加 `settings/set-ok/set-reset` action 并 `Settings.init()`;`js/touch.js` `show()` 里 `Settings.apply()` 让自定义布局在游戏内生效。
+- CSS:`.tc-btn` 改为 `position:absolute;transform:translate(-50%,-50%)`(active 变形同步保留 translate);新增 `.tc-rsz` 缩放手柄、`.touch-preview`、`.set-*`、`#title-user` 样式。
+- 验证:新增 `tools/settings-test.mjs`(默认布局/apply 定位/enter+confirm 保存用户名并写 localStorage/reset 回出厂且不自动落盘,15 断言全过);dom-smoke(main 初始化成功)、check/smoke/map/save/key/solve/ice 全绿;预览服务返回 200 且含 `scr-settings`。
+
+## 修复:拖动设置里的手柄按钮时直接跳到右下角
+- 根因:`js/settings.js` 原 `wire()` 用「指针(clientX) − 基准偏移(off/cx0)」做**绝对坐标映射**定位;一旦 `wrap.getBoundingClientRect()` 取值与渲染偏差(预览百分比定位 + translate(-50%,-50%) 场景下易偏),按下首帧就把 cx/cy 算到极值 → 弹到右下角。
+- 修复:改为**相对位移累加**——按下只记录 `lastX/lastY` 与预览 `rect`;`pointermove` 把「相对上一帧的像素位移 ÷ rect 宽高 ×100」累加到当前 cx/cy(clamp 3~97%),缩放则按「当前指针到中心距离 ÷ 按下时距离」缩放 `size`(24~160px)。第一帧必然停在正确位置,不再跳角。还顺手加了 `e.stopPropagation` + try/catch 包裹 `setPointerCapture`。
+- 回归:`tools/settings-test.mjs` 新增「模拟拖动/缩放」4 条——派发真实 pointerdown/move/up 事件断言:cx 按位移增量变化、逆向可精确归位、缩放放大尺寸且不改位置。settings-test 共 21 断言全过,dom-smoke 初始化正常。
+
+## 修复:设置界面打开后一片空白
+- 根因:`js/ui.js` 的 `SCREENS` 数组没加 `'scr-settings'`,`UI.show('scr-settings')` 会把所有已知屏 hidden、却从不取消设置屏的 hidden——只剩画布标题背景(用户截图即此症状)。
+- 修复:SCREENS 加入 `scr-settings`(一行)。教训:**新增 screen 必须同步 SCREENS 数组**。
+- 回归:`tools/settings-test.mjs` 增加「SCREENS 含 scr-settings / 标题有设置入口」两条源码断言,防复发;settings-test 与 dom-smoke 全绿。
diff --git a/css/style.css b/css/style.css
index 04dc4c1..8bafa5f 100644
--- a/css/style.css
+++ b/css/style.css
@@ -218,6 +218,27 @@ canvas#ed-canvas{
.imp-empty{color:var(--dim);font-size:.8em;padding:10px 2px}
.modal-btns{display:flex;justify-content:flex-end;gap:6px}
+/* ---------- 设置界面 ---------- */
+.title-user{color:var(--dim);font-size:.85em;letter-spacing:2px;margin-top:1vh}
+.set-body{display:flex;flex-direction:column;gap:14px;overflow-y:auto;padding:2px}
+.set-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
+.set-row.col{flex-direction:column;align-items:stretch;gap:6px;justify-content:flex-start}
+.set-lab{color:var(--gold);letter-spacing:1px;font-size:.95em;min-width:64px}
+.set-in{background:#111119;border:1px solid var(--line);color:var(--fg);font-family:inherit;font-size:1em;padding:6px 8px;min-width:200px}
+.set-in:focus{outline:none;border-color:var(--gold)}
+.set-hint{color:var(--dim);font-size:.78em}
+.set-btns{margin-top:2px}
+/* 手机手柄预览区(与舞台同比例),按钮绝对定位可拖拽 */
+.touch-preview{
+ position:relative;width:100%;aspect-ratio:800/608;
+ background:#0d0d16;border:1px solid var(--line);overflow:hidden;max-height:46vh;
+}
+.tp-tip{
+ position:absolute;top:6px;left:50%;transform:translateX(-50%);
+ color:var(--dim);font-size:.72em;background:rgba(0,0,0,.45);
+ padding:2px 8px;border-radius:6px;pointer-events:none;z-index:3;white-space:nowrap;
+}
+
::-webkit-scrollbar{width:8px;height:8px}
::-webkit-scrollbar-track{background:#111}
::-webkit-scrollbar-thumb{background:#333;}
@@ -247,6 +268,7 @@ html:not(.touch) #touch{display:none !important}
.tc-btn{
pointer-events:auto;-webkit-user-select:none;user-select:none;touch-action:none;
+ position:absolute;transform:translate(-50%,-50%);
background:rgba(18,18,28,.52);border:2px solid rgba(255,255,255,.28);
color:#fff;font-family:inherit;display:flex;align-items:center;justify-content:center;
flex-direction:column;line-height:1;backdrop-filter:blur(2px);
@@ -255,7 +277,14 @@ html:not(.touch) #touch{display:none !important}
}
.tc-btn span{font-size:1.5em}
.tc-btn .lbl{font-size:.6em;letter-spacing:1px;opacity:.85;margin-top:2px}
-.tc-btn.on,.tc-btn:active{transform:scale(.93);background:rgba(224,48,80,.6);border-color:#ff5c7a}
+.tc-btn.on,.tc-btn:active{transform:translate(-50%,-50%) scale(.93);background:rgba(224,48,80,.6);border-color:#ff5c7a}
+
+/* 设置界面预览里的缩放手柄(真实手柄不挂此元素) */
+.tc-rsz{
+ position:absolute;right:-7px;bottom:-7px;width:16px;height:16px;border-radius:50%;
+ background:var(--gold);border:2px solid #000;cursor:nwse-resize;opacity:.92;touch-action:none;
+ box-shadow:0 1px 4px rgba(0,0,0,.5);
+}
.tc-move{width:62px;height:62px;border-radius:14px}
.tc-item{width:54px;height:54px;border-radius:50%}
@@ -267,7 +296,7 @@ html:not(.touch) #touch{display:none !important}
border:3px solid #ffd0da;box-shadow:0 6px 18px rgba(192,26,58,.5);
font-size:1.9em;font-weight:bold;color:#fff;
}
-.tc-jump.on,.tc-jump:active{transform:scale(.92);filter:brightness(1.12)}
+.tc-jump.on,.tc-jump:active{transform:translate(-50%,-50%) scale(.92);filter:brightness(1.12)}
/* 未持枪时射击键置灰 */
#tc-gun.off{opacity:.4;filter:grayscale(1);background:rgba(18,18,28,.4)}
diff --git a/index.html b/index.html
index 1abca4e..758d180 100644
--- a/index.html
+++ b/index.html
@@ -38,8 +38,9 @@
-
+
+
diff --git a/js/main.js b/js/main.js
index 9ef68ac..963a0ef 100644
--- a/js/main.js
+++ b/js/main.js
@@ -9,6 +9,7 @@ import { Save } from './save.js';
import { Touch } from './touch.js';
import { sfx, unlockAudio, Audio2 } from './audio.js';
import { Editor } from './editor.js';
+import { Settings } from './settings.js';
import { clamp } from './util.js';
const W = 800, H = 608;
@@ -298,6 +299,9 @@ UI.init({
manage: () => UI.setManage(!UI.manage),
help: () => UI.show('scr-help'),
editor: toEditor,
+ settings: () => { Settings.enter(); UI.show('scr-settings'); },
+ 'set-ok': () => Settings.confirm(),
+ 'set-reset': () => Settings.reset(),
back: toTitle,
resume,
restart: () => { restart(); },
@@ -321,6 +325,7 @@ addEventListener('keydown', unlock);
// 移动端虚拟手柄
Touch.init({ pause, suicide, shoot });
+Settings.init();
UI.show('scr-title');
requestAnimationFrame(loop);
diff --git a/js/settings.js b/js/settings.js
new file mode 100644
index 0000000..a6414f8
--- /dev/null
+++ b/js/settings.js
@@ -0,0 +1,178 @@
+// 设置:用户名 + 手机虚拟手柄按钮的自定义(位置/大小,拖动调整)
+import { Save } from './save.js';
+
+// 每个可自定义的手柄按钮:key 对应 touch.js 里的 data-touch,
+// def 为默认布局(cx/cy 为相对舞台中心的百分比,size 为按钮边长 px)
+const BUTTONS = [
+ { key: 'left', label: '◀', shape: 'round', def: { cx: 12, cy: 80, size: 62 } },
+ { key: 'right', label: '▶', shape: 'round', def: { cx: 24, cy: 80, size: 62 } },
+ { key: 'jump', label: '跳', shape: 'jump', def: { cx: 88, cy: 84, size: 94 } },
+ { key: 'shoot', label: '🔫', shape: 'item', label2: '射击', def: { cx: 79, cy: 72, size: 54 } },
+ { key: 'down', label: '▼', shape: 'item', label2: '下蹲', def: { cx: 90, cy: 72, size: 54 } },
+ { key: 'pause', label: '❚❚', shape: 'sm', def: { cx: 81, cy: 58, size: 42 } },
+ { key: 'suicide', label: '↺', shape: 'sm', def: { cx: 90, cy: 58, size: 42 } },
+];
+
+const clone = (o) => JSON.parse(JSON.stringify(o));
+const DEFAULT_TOUCH = {};
+BUTTONS.forEach(b => { DEFAULT_TOUCH[b.key] = clone(b.def); });
+const makeDefaults = () => ({ username: '', touch: clone(DEFAULT_TOUCH) });
+
+// 工作副本(未点「确定」前不落盘,点「返回」即丢弃)
+let work = makeDefaults();
+let wrap = null;
+const els = {};
+
+/** 读取/补全存档里的设置对象(保证 touch 字段齐全) */
+function meta() {
+ if (!Save.data.settings) Save.data.settings = { username: '' };
+ if (!Save.data.settings.touch) Save.data.settings.touch = clone(DEFAULT_TOUCH);
+ return Save.data.settings;
+}
+
+/** 把某个按钮按 p={cx,cy,size} 绝对定位(百分比定位 + 像素边长) */
+function place(el, p) {
+ el.style.left = p.cx + '%';
+ el.style.top = p.cy + '%';
+ el.style.width = p.size + 'px';
+ el.style.height = p.size + 'px';
+}
+
+/** 把当前存档布局应用到真正的 #touch 手柄按钮(游戏内生效) */
+function apply() {
+ const t = meta().touch;
+ for (const b of BUTTONS) {
+ const el = document.querySelector(`#touch [data-touch="${b.key}"]`);
+ if (!el) continue;
+ const p = t[b.key];
+ if (!p) continue;
+ el.style.left = p.cx + '%';
+ el.style.top = p.cy + '%';
+ el.style.width = p.size + 'px';
+ el.style.height = p.size + 'px';
+ }
+}
+
+/** 标题界面显示当前用户名 */
+function showUsername() {
+ const el = document.getElementById('title-user');
+ if (el) el.textContent = '玩家:' + (meta().username || '无名氏');
+}
+
+export const Settings = {
+ init() {
+ wrap = document.getElementById('touch-preview');
+ work = clone(meta());
+ this.build();
+ showUsername();
+ apply();
+ },
+
+ /** 构建设置界面里的可拖拽手柄预览(克隆 #touch 的按钮形状) */
+ build() {
+ if (!wrap) return;
+ wrap.querySelectorAll('.tc-btn').forEach(n => n.remove());
+ for (const b of BUTTONS) {
+ const el = document.createElement('div');
+ el.className = 'tc-btn tc-' + b.shape;
+ el.innerHTML = `${b.label}` + (b.label2 ? `${b.label2}` : '');
+ const h = document.createElement('div');
+ h.className = 'tc-rsz';
+ el.appendChild(h);
+ wrap.appendChild(el);
+ els[b.key] = el;
+ place(el, work.touch[b.key]);
+ wire(el, h, b.key);
+ }
+ },
+
+ /** 打开设置界面:从存档同步工作副本与输入框 */
+ enter() {
+ const s = meta();
+ work = { username: s.username || '', touch: clone(s.touch) };
+ const inp = document.getElementById('set-username');
+ if (inp) inp.value = work.username;
+ this.build();
+ },
+
+ /** 确定:保存用户名与手柄布局到存档 */
+ confirm() {
+ const s = meta();
+ const inp = document.getElementById('set-username');
+ work.username = (inp ? inp.value : '').trim().slice(0, 16);
+ s.username = work.username;
+ s.touch = clone(work.touch);
+ Save.flush();
+ showUsername();
+ apply();
+ },
+
+ /** 恢复默认:布局与用户名全部回到出厂值(仅影响工作副本,需点确定才落盘) */
+ reset() {
+ work = makeDefaults();
+ const inp = document.getElementById('set-username');
+ if (inp) inp.value = '';
+ this.build();
+ },
+
+ /** 供调试 / 测试使用 */
+ _state() { return work; },
+ _els() { return els; },
+};
+
+/** 给单个预览按钮绑定「拖动移动」与「右下角缩放」 */
+function wire(el, handle, key) {
+ let mode = null; // 'move' | 'resize'
+ let rect = null; // 预览区矩形(按下时记录)
+ let lastX = 0, lastY = 0; // 上一帧指针(相对位移用)
+ let startSize = 0, startDist = 1, cx0 = 0, cy0 = 0; // 缩放用:按下时按钮中心与指针距离
+
+ // 按下:只记录基准,不重算绝对坐标 —— 避免任何「按下即跳角」
+ const begin = (e, m) => {
+ e.preventDefault();
+ try { (m === 'resize' ? handle : el).setPointerCapture(e.pointerId); } catch (_) { }
+ mode = m;
+ rect = wrap.getBoundingClientRect();
+ const W = rect.width || 1, H = rect.height || 1;
+ const p = work.touch[key];
+ lastX = e.clientX; lastY = e.clientY;
+ if (m === 'resize') {
+ cx0 = rect.left + p.cx / 100 * W;
+ cy0 = rect.top + p.cy / 100 * H;
+ startSize = p.size;
+ startDist = Math.max(1, Math.hypot(e.clientX - cx0, e.clientY - cy0));
+ }
+ };
+
+ // 移动:用「相对上一帧的位移」累加,第一帧必然停在正确位置
+ const drag = (e) => {
+ if (!mode || !rect) return;
+ const p = work.touch[key];
+ if (mode === 'move') {
+ const W = rect.width || 1, H = rect.height || 1;
+ const dx = (e.clientX - lastX) / W * 100; // 像素位移 → 百分比
+ const dy = (e.clientY - lastY) / H * 100;
+ lastX = e.clientX; lastY = e.clientY;
+ p.cx = Math.max(3, Math.min(97, p.cx + dx));
+ p.cy = Math.max(3, Math.min(97, p.cy + dy));
+ } else {
+ const d = Math.max(1, Math.hypot(e.clientX - cx0, e.clientY - cy0));
+ p.size = Math.max(24, Math.min(160, startSize * (d / startDist)));
+ }
+ place(el, p);
+ };
+
+ const end = (e) => {
+ if (mode) { try { (mode === 'resize' ? handle : el).releasePointerCapture(e.pointerId); } catch (_) { } }
+ mode = null;
+ };
+
+ el.addEventListener('pointerdown', (e) => { if (e.target === handle) return; begin(e, 'move'); });
+ handle.addEventListener('pointerdown', (e) => { e.stopPropagation(); begin(e, 'resize'); });
+ el.addEventListener('pointermove', drag);
+ handle.addEventListener('pointermove', drag);
+ el.addEventListener('pointerup', end);
+ handle.addEventListener('pointerup', end);
+ el.addEventListener('pointercancel', end);
+ handle.addEventListener('pointercancel', end);
+}
diff --git a/js/touch.js b/js/touch.js
index be2ab0a..6b73dc5 100644
--- a/js/touch.js
+++ b/js/touch.js
@@ -1,6 +1,7 @@
// 移动端虚拟手柄:左手方向、右手跳跃 + 道具(参考 IWBT Creator 的触屏布局)
// 左下方:← → 移动;右下方:大跳键 + 射击/下蹲 道具键 + 暂停/重生
import { setTouch, clearTouch } from './input.js';
+import { Settings } from './settings.js';
// 持续按住型动作(通过 action 状态机驱动)
const HELD = ['left', 'right', 'jump', 'down'];
@@ -77,6 +78,7 @@ export const Touch = {
if (!root) root = q('#touch');
if (!root) return;
visible = true;
+ Settings.apply(); // 应用用户自定义的手柄布局
root.classList.remove('hidden');
},
diff --git a/js/ui.js b/js/ui.js
index 813fd07..17c4ff3 100644
--- a/js/ui.js
+++ b/js/ui.js
@@ -6,7 +6,7 @@ import { fmtTime } from './util.js';
const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(s));
-const SCREENS = ['scr-title', 'scr-levels', 'scr-help', 'scr-pause', 'scr-result', 'scr-editor'];
+const SCREENS = ['scr-title', 'scr-levels', 'scr-help', 'scr-settings', 'scr-pause', 'scr-result', 'scr-editor'];
const TABS = [
['all', '全部'], ['jump', '跳刺'], ['endur', '耐久'], ['boss', 'BOSS'], ['troll', '坑向'], ['custom', '自制'],
];
diff --git a/tools/settings-test.mjs b/tools/settings-test.mjs
new file mode 100644
index 0000000..f54371a
--- /dev/null
+++ b/tools/settings-test.mjs
@@ -0,0 +1,134 @@
+// 设置模块单元测试:默认布局 / 应用定位 / 进入 / 确定保存 / 恢复默认
+const noop = () => { };
+
+class El {
+ constructor(tag = 'DIV') {
+ this.tagName = tag.toUpperCase();
+ this.value = '';
+ this.textContent = '';
+ this._html = '';
+ this._classes = new Set();
+ this._style = {};
+ this.dataset = {};
+ this.children = [];
+ this._ev = {};
+ this.parent = null;
+ const self = this;
+ this.classList = {
+ add: c => self._classes.add(c),
+ remove: c => self._classes.delete(c),
+ contains: c => self._classes.has(c),
+ toggle: (c, f) => { const on = f === undefined ? !self._classes.has(c) : f; on ? self._classes.add(c) : self._classes.delete(c); return on; },
+ };
+ }
+ get style() { return this._style; }
+ set className(v) { this._classes = new Set(String(v).split(/\s+/).filter(Boolean)); }
+ get className() { return [...this._classes].join(' '); }
+ set innerHTML(v) { this._html = v; if (v === '') this.children = []; }
+ get innerHTML() { return this._html; }
+ appendChild(c) { c.parent = this; this.children.push(c); return c; }
+ remove() { if (this.parent) { const i = this.parent.children.indexOf(this); if (i >= 0) this.parent.children.splice(i, 1); } }
+ querySelector() { return new El(); }
+ querySelectorAll(sel) { return sel === '.tc-btn' ? this.children.filter(c => c._classes.has('tc-btn')) : []; }
+ addEventListener(type, fn) { (this._ev[type] ||= []).push(fn); }
+ removeEventListener() { }
+ dispatch(type, ev) { (this._ev[type] || []).forEach(fn => fn(ev)); }
+ setPointerCapture() { }
+ releasePointerCapture() { }
+ getBoundingClientRect() { return { left: 0, top: 0, width: 800, height: 608 }; }
+}
+
+const byId = new Map();
+const touchMap = new Map();
+globalThis.localStorage = {
+ _d: {},
+ getItem(k) { return k in this._d ? this._d[k] : null; },
+ setItem(k, v) { this._d[k] = String(v); },
+ removeItem(k) { delete this._d[k]; },
+};
+globalThis.document = {
+ getElementById(id) { if (!byId.has(id)) byId.set(id, new El()); return byId.get(id); },
+ createElement(t) { return new El(t); },
+ querySelector(sel) {
+ const m = sel.match(/data-touch="([^"]+)"/);
+ if (m) { const k = m[1]; if (!touchMap.has(k)) touchMap.set(k, new El()); return touchMap.get(k); }
+ return new El();
+ },
+ querySelectorAll() { return []; },
+};
+globalThis.window = { addEventListener: noop, removeEventListener: noop };
+globalThis.confirm = () => true;
+
+let fail = 0;
+const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; };
+
+const { Save } = await import('../js/save.js');
+const { Settings } = await import('../js/settings.js');
+
+console.log('=== 初始化 / 默认布局 ===');
+Settings.init();
+const s0 = Save.data.settings;
+ok(!!s0 && !!s0.touch, '存档含 settings.touch');
+const keys = ['left', 'right', 'jump', 'shoot', 'down', 'pause', 'suicide'];
+ok(keys.every(k => s0.touch[k]), '7 个手柄键都有默认布局');
+ok(s0.touch.left.cx === 12 && s0.touch.jump.size === 94, '默认坐标/尺寸正确');
+ok(byId.get('title-user').textContent === '玩家:无名氏', '标题默认显示「无名氏」');
+
+console.log('=== apply 把布局写到真实手柄 ===');
+const L = touchMap.get('left'), J = touchMap.get('jump');
+ok(L._style.left === '12%' && L._style.top === '80%' && L._style.width === '62px', 'left 按钮定位/尺寸已应用');
+ok(J._style.width === '94px' && J._style.left === '88%', 'jump 按钮定位/尺寸已应用');
+
+console.log('=== 进入 / 确定 保存用户名与布局 ===');
+Settings.enter();
+byId.get('set-username').value = '阿测玩家';
+Settings.confirm();
+ok(Save.data.settings.username === '阿测玩家', '用户名已保存到存档');
+ok(byId.get('title-user').textContent === '玩家:阿测玩家', '标题用户名已更新');
+ok(localStorage.getItem('iwbtc_fan_v1').includes('阿测玩家'), '已写入 localStorage');
+
+console.log('=== 恢复默认 ===');
+byId.get('set-username').value = '随便';
+Settings.reset();
+ok(Settings._state().username === '', '恢复默认后用户名为空');
+ok(Settings._state().touch.jump.size === 94 && Settings._state().touch.left.cx === 12, '恢复默认后布局回到出厂值');
+// 恢复默认后必须再点确定才落盘
+ok(Save.data.settings.username === '阿测玩家', '恢复默认未自动落盘(需点确定)');
+Settings.confirm();
+ok(Save.data.settings.username === '', '点确定后空用户名落盘');
+
+console.log('=== 布局越长越界会被限制(place 约束在交互层,这里校验默认在合法区间)===');
+ok(keys.every(k => { const p = Save.data.settings.touch[k]; return p.cx >= 4 && p.cx <= 96 && p.size >= 24 && p.size <= 160; }), '默认布局均在合法范围');
+
+console.log('=== UI SCREENS 注册(防止设置屏永远不显示)===');
+const uiSrc = (await import('node:fs')).readFileSync(new URL('../js/ui.js', import.meta.url), 'utf8');
+ok(/SCREENS\s*=\s*\[[^\]]*'scr-settings'/.test(uiSrc), 'scr-settings 已加入 SCREENS 列表');
+ok(/data-act="settings"/.test((await import('node:fs')).readFileSync(new URL('../index.html', import.meta.url), 'utf8')), '标题菜单有「设置」入口');
+
+console.log('=== 模拟拖动 / 缩放(防「按下即跳角」)===');
+Settings.enter(); // 重新同步工作副本
+const ev = (t, x, y) => ({ target: t, clientX: x, clientY: y, pointerId: 1, preventDefault: noop, stopPropagation: noop });
+const left = Settings._els().left;
+const handle = left.children.find(c => c._classes.has('tc-rsz')) || left.children[0];
+const wt = Settings._state().touch;
+const cx0 = wt.left.cx, cy0 = wt.left.cy;
+
+// 在按钮中心 (96,486) 按下,向右拖 40px(预览宽 800 → +5%)
+left.dispatch('pointerdown', ev(left, 96, 486));
+left.dispatch('pointermove', ev(left, 136, 486));
+ok(Math.abs(wt.left.cx - (cx0 + 5)) < 0.01 && wt.left.cy === cy0, '拖动:cx 按位移增量变化,无跳角');
+// 再拖回原处
+left.dispatch('pointermove', ev(left, 96, 486));
+ok(Math.abs(wt.left.cx - cx0) < 0.01, '拖动:逆向位移可精确归位');
+left.dispatch('pointerup', ev(left, 96, 486));
+
+// 缩放手柄:中心 (96,486),起点距中心 ~56,拉远到 ~113 → 尺寸翻倍
+const size0 = wt.left.size;
+handle.dispatch('pointerdown', ev(handle, 136, 526));
+handle.dispatch('pointermove', ev(handle, 176, 566));
+ok(wt.left.size > size0 && wt.left.size <= 160, `缩放:尺寸随距离放大(${size0}→${wt.left.size.toFixed(0)})`);
+handle.dispatch('pointerup', ev(handle, 176, 566));
+ok(wt.left.cx === cx0 && wt.left.cy === cy0, '缩放:不改变按钮位置');
+
+console.log(fail === 0 ? '\n✅ settings 全部断言通过' : `\n❌ 失败 ${fail} 项`);
+process.exit(fail === 0 ? 0 : 1);