fea: 添加设置功能

This commit is contained in:
cancerai committed 2026-10-23 17:49:29 +08:00
1 parent 22157d4baa
commit ada64fe4be
8 files changed
+405 -4

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# 2026-10-23 工作日志
## 首页移除「清除存档」按钮(用户要求)
- 需求:把首页(scr-title)的「清除存档」按钮删掉。
- 改动:仅删 `index.html:41` 的 `<button class="btn small" data-act="wipe">清除存档</button>` 一行。`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 全绿。
+31 -2
View File
@@ -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)}
+30 -1
View File
@@ -38,8 +38,9 @@
<button class="btn big" data-act="levels">关卡选择</button>
<button class="btn" data-act="editor">自制关卡</button>
<button class="btn" data-act="help">操作说明</button>
<button class="btn small" data-act="wipe">清除存档</button>
<button class="btn" data-act="settings">设置</button>
</div>
<div id="title-user" class="title-user"></div>
<div class="foot">非商业同人作品 · 致敬 I Wanna Be The Guy / I Wanna Be The Creator · 素材与代码原创</div>
</div>
@@ -134,6 +135,34 @@
</div>
</div>
<!-- ===== SETTINGS ===== -->
<div class="screen hidden" id="scr-settings">
<div class="panel">
<div class="panel-head">
<h2>设置</h2>
<button class="btn tiny" data-act="back">返回</button>
</div>
<div class="set-body">
<div class="set-row">
<label class="set-lab" for="set-username">用户名</label>
<input id="set-username" class="set-in" maxlength="16" placeholder="无名氏">
<span class="set-hint">显示在标题界面</span>
</div>
<div class="set-row col">
<label class="set-lab">手机操作按钮</label>
<span class="set-hint">拖动按钮调整位置,拖动右下角圆点调整大小(仅移动端生效)</span>
<div id="touch-preview" class="touch-preview">
<div class="tp-tip">拖动按钮移动位置 · 拖动右下角圆点缩放大小</div>
</div>
</div>
</div>
<div class="modal-btns set-btns">
<button class="btn tiny" data-act="set-reset">恢复默认</button>
<button class="btn tiny go" data-act="set-ok">确定</button>
</div>
</div>
</div>
<!-- ===== EDITOR ===== -->
<div class="screen hidden" id="scr-editor">
<div class="panel">
+5
View File
@@ -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);
+178
View File
@@ -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 = `<span>${b.label}</span>` + (b.label2 ? `<span class="lbl">${b.label2}</span>` : '');
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);
}
+2
View File
@@ -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');
},
+1 -1
View File
@@ -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', '自制'],
];
+134
View File
@@ -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);