336 lines
13 KiB
JavaScript
336 lines
13 KiB
JavaScript
// 设置:用户名 + 设备相关自定义
|
||
// · PC(电脑端)→ 键盘按键映射
|
||
// · 移动端 → 手机虚拟手柄按钮的位置/大小(拖动调整)
|
||
import { Save } from './save.js';
|
||
import { applyBindings, defaultBindings, BIND } from './input.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 } },
|
||
];
|
||
|
||
// 设置界面里可重绑的键盘动作(标签 + action)
|
||
const KEY_ACTIONS = [
|
||
['left', '左移'], ['right', '右移'],
|
||
['up', '上(上浮)'],
|
||
['jump', '跳跃'], ['shoot', '射击'],
|
||
['suicide', '自杀 / 重生'], ['restart', '整关重来'],
|
||
['pause', '暂停'],
|
||
];
|
||
|
||
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), keys: defaultBindings() });
|
||
|
||
// 工作副本(未点「确定」前不落盘,点「返回」即丢弃)
|
||
let work = makeDefaults();
|
||
let wrap = null;
|
||
let pcMode = false; // true=电脑端(键盘映射),false=移动端(手柄按钮)
|
||
let listening = null; // 重绑监听:{act, idx}
|
||
let conflict = null; // 冲突警告:{code, label, act}
|
||
const els = {};
|
||
let keysBox = null; // 键盘映射容器
|
||
|
||
/** 读取/补全存档里的设置对象(保证 touch / keys 字段齐全) */
|
||
function meta() {
|
||
if (!Save.data.settings) Save.data.settings = {};
|
||
const s = Save.data.settings;
|
||
if (typeof s.username !== 'string') s.username = '';
|
||
if (!s.touch) s.touch = clone(DEFAULT_TOUCH);
|
||
if (!s.keys) s.keys = defaultBindings();
|
||
return s;
|
||
}
|
||
|
||
/** 把某个按钮按 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 || '无名氏');
|
||
}
|
||
|
||
/** 把键盘 code 转成可读标签 */
|
||
function keyLabel(code) {
|
||
const M = {
|
||
Space: '空格', ShiftLeft: 'Shift', ShiftRight: 'Shift', ControlLeft: 'Ctrl', ControlRight: 'Ctrl',
|
||
AltLeft: 'Alt', AltRight: 'Alt', Tab: 'Tab', Backspace: '⌫', Escape: 'Esc', Enter: 'Enter',
|
||
ArrowLeft: '←', ArrowRight: '→', ArrowUp: '↑', ArrowDown: '↓',
|
||
MetaLeft: 'Win', MetaRight: 'Win', ContextMenu: '菜单',
|
||
};
|
||
if (M[code]) return M[code];
|
||
if (code.startsWith('Key')) return code.slice(3);
|
||
if (code.startsWith('Digit')) return code.slice(5);
|
||
if (code.startsWith('Numpad')) return '小键' + code.slice(6);
|
||
if (/^F\d{1,2}$/.test(code)) return code;
|
||
return code;
|
||
}
|
||
|
||
/** 构建设置界面里的键盘映射行(点击按键后按新键重绑) */
|
||
function renderKeys() {
|
||
if (!keysBox) return;
|
||
keysBox.innerHTML = '';
|
||
// 冲突警告横幅(始终置于面板顶部,醒目)
|
||
if (conflict) {
|
||
const w = document.createElement('div');
|
||
w.className = 'kwarb';
|
||
w.textContent = `「${keyLabel(conflict.code)}」已被【${conflict.label}】占用,请换一个键(Esc 取消)`;
|
||
keysBox.appendChild(w);
|
||
}
|
||
for (const [act, label] of KEY_ACTIONS) {
|
||
const row = document.createElement('div');
|
||
row.className = 'krow';
|
||
const nm = document.createElement('span');
|
||
nm.className = 'kact'; nm.textContent = label;
|
||
const keys = document.createElement('div');
|
||
keys.className = 'kkeys';
|
||
(work.keys[act] || []).forEach((code, idx) => {
|
||
const chip = document.createElement('button');
|
||
chip.type = 'button';
|
||
const isListen = listening && listening.act === act && listening.idx === idx;
|
||
chip.className = 'kchip' + (isListen ? ' listen' : '');
|
||
chip.dataset.kact = act; chip.dataset.kidx = idx;
|
||
chip.textContent = keyLabel(code);
|
||
const rm = document.createElement('b');
|
||
rm.className = 'krm'; rm.dataset.krm = act; rm.dataset.kidx = idx; rm.textContent = '×';
|
||
chip.appendChild(rm);
|
||
keys.appendChild(chip);
|
||
});
|
||
const add = document.createElement('button');
|
||
add.type = 'button';
|
||
add.className = 'kadd'; add.dataset.kadd = act; add.textContent = '+ 添加';
|
||
keys.appendChild(add);
|
||
if (listening && listening.act === act) {
|
||
const tip = document.createElement('span');
|
||
const isClash = conflict && conflict.act === act;
|
||
tip.className = 'klisten' + (isClash ? ' bad' : '');
|
||
tip.textContent = isClash ? '该键冲突,请换一个键(Esc 取消)' : '按下一个键…(Esc 取消)';
|
||
keys.appendChild(tip);
|
||
}
|
||
row.appendChild(nm); row.appendChild(keys);
|
||
keysBox.appendChild(row);
|
||
}
|
||
}
|
||
|
||
/** 检测 code 是否已绑定到其它动作(返回冲突动作的标签,否则 null) */
|
||
function conflictInfo(code, act, idx) {
|
||
for (const [a, label] of KEY_ACTIONS) {
|
||
const arr = work.keys[a] || [];
|
||
for (let i = 0; i < arr.length; i++) {
|
||
if (arr[i] === code && !(a === act && i === idx)) return label;
|
||
}
|
||
}
|
||
// 不可重绑的系统动作也算冲突(如「菜单」用的 KeyQ)
|
||
if (BIND.menu && BIND.menu.includes(code)) return '菜单';
|
||
return null;
|
||
}
|
||
|
||
/** 重绑监听:捕获下一次按键并写入 work.keys(检测到冲突则拒绝并提示) */
|
||
function onCaptureKey(e) {
|
||
if (!listening) return;
|
||
e.preventDefault();
|
||
if (e.code === 'Escape') { listening = null; conflict = null; renderKeys(); return; }
|
||
const { act, idx } = listening;
|
||
const clash = conflictInfo(e.code, act, idx);
|
||
if (clash) { // 该键已被其它动作占用 → 拒绝并提示
|
||
conflict = { code: e.code, label: clash, act };
|
||
renderKeys();
|
||
return; // 停留在监听态,等待换键或 Esc
|
||
}
|
||
if (idx >= (work.keys[act] || []).length) (work.keys[act] = work.keys[act] || []).push(e.code);
|
||
else work.keys[act][idx] = e.code;
|
||
conflict = null;
|
||
listening = null;
|
||
renderKeys();
|
||
}
|
||
|
||
/** 给键盘映射容器绑定点击委托(重绑 / 添加 / 删除) */
|
||
let keysWired = false;
|
||
function wireKeys() {
|
||
if (!keysBox || keysWired) return;
|
||
keysWired = true;
|
||
keysBox.addEventListener('click', (e) => {
|
||
const rm = e.target.closest('.krm');
|
||
if (rm) {
|
||
const a = rm.dataset.krm, i = +rm.dataset.kidx;
|
||
work.keys[a].splice(i, 1);
|
||
if (listening && listening.act === a) listening = null;
|
||
renderKeys();
|
||
return;
|
||
}
|
||
const chip = e.target.closest('.kchip');
|
||
if (chip) { listening = { act: chip.dataset.kact, idx: +chip.dataset.kidx }; renderKeys(); return; }
|
||
const add = e.target.closest('.kadd');
|
||
if (add) { listening = { act: add.dataset.kadd, idx: 1e9 }; renderKeys(); return; }
|
||
});
|
||
}
|
||
|
||
export const Settings = {
|
||
/** isTouch:是否移动端(由 main.js 依据 ?touch 覆盖与设备检测传入) */
|
||
init(isTouch) {
|
||
pcMode = !isTouch;
|
||
wrap = document.getElementById('touch-preview');
|
||
keysBox = document.getElementById('set-keys');
|
||
work = clone(meta());
|
||
this.build();
|
||
renderKeys();
|
||
wireKeys();
|
||
showUsername();
|
||
apply();
|
||
applyBindings(meta().keys);
|
||
document.addEventListener('keydown', onCaptureKey);
|
||
},
|
||
|
||
/** 构建设置界面里的可拖拽手柄预览(克隆 #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), keys: clone(s.keys) };
|
||
const inp = document.getElementById('set-username');
|
||
if (inp) inp.value = work.username;
|
||
listening = null;
|
||
conflict = null;
|
||
this.build();
|
||
renderKeys();
|
||
// 按设备显示对应面板:PC→键盘映射;移动端→手柄按钮
|
||
const keysRow = document.getElementById('set-keys-row');
|
||
const touchRow = document.getElementById('set-touch-row');
|
||
if (keysRow) keysRow.classList.toggle('hidden', !pcMode);
|
||
if (touchRow) touchRow.classList.toggle('hidden', pcMode);
|
||
},
|
||
|
||
/** 确定:保存用户名 / 手柄布局 / 键位到存档,并应用,然后由调用方返回上一页 */
|
||
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);
|
||
s.keys = clone(work.keys);
|
||
Save.flush();
|
||
showUsername();
|
||
apply();
|
||
applyBindings(s.keys);
|
||
},
|
||
|
||
/** 恢复默认:用户名/手柄布局/键位全部回到出厂值(仅影响工作副本,需点确定才落盘) */
|
||
reset() {
|
||
work = makeDefaults();
|
||
listening = null;
|
||
conflict = null;
|
||
const inp = document.getElementById('set-username');
|
||
if (inp) inp.value = '';
|
||
this.build();
|
||
renderKeys();
|
||
},
|
||
|
||
/** 供调试 / 测试使用 */
|
||
_state() { return work; },
|
||
_els() { return els; },
|
||
_pcMode() { return pcMode; },
|
||
_conflict() { return conflict; },
|
||
_listening() { return listening; },
|
||
};
|
||
|
||
/** 给单个预览按钮绑定「拖动移动」与「右下角缩放」 */
|
||
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);
|
||
}
|