RPG 虚拟按键

移动端RPG虚拟键。

目前為 2025-09-29 提交的版本,檢視 最新版本

// ==UserScript==
// @name         RPG 虚拟按键
// @namespace    https://douglas.example
// @version      1.1.0
// @description  移动端RPG虚拟键。
// @author       you
// @match        *://*.oraclecloud.com/*
// @run-at       document-end
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  /********************** 键位映射与事件派发 *************************/
  function mapCharToKey(char) {
      return { key: char, code: '', keyCode: char.charCodeAt(0) };
  }

  const SPECIAL = {
    'ArrowUp':    { key: 'ArrowUp',    code: 'ArrowUp',    keyCode: 38 },
    'ArrowDown':  { key: 'ArrowDown',  code: 'ArrowDown',  keyCode: 40 },
    'ArrowLeft':  { key: 'ArrowLeft',  code: 'ArrowLeft',  keyCode: 37 },
    'ArrowRight': { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
    'Enter':      { key: 'Enter',      code: 'Enter',      keyCode: 13 },
    'Escape':     { key: 'Escape',     code: 'Escape',     keyCode: 27 },
    'Space':      { key: ' ',          code: 'Space',      keyCode: 32 },
    'Shift':      { key: 'Shift',      code: 'ShiftLeft',  keyCode: 16 },
    'Control':    { key: 'Control',    code: 'ControlLeft',keyCode: 17 },
    'Z':          mapCharToKey('z'),
    'X':          mapCharToKey('x'),
    'C':          mapCharToKey('c')
  };

  function makeKeyEvent(type, def) {
    const init = {
      key: def.key, code: def.code, bubbles: true, cancelable: true,
      keyCode: def.keyCode, which: def.keyCode, charCode: 0,
      repeat: type === 'keydown' ? false : undefined
    };
    const ev = new KeyboardEvent(type, init);
    try {
      Object.defineProperty(ev, 'keyCode', { get: () => def.keyCode });
      Object.defineProperty(ev, 'which',   { get: () => def.keyCode });
    } catch (_) {}
    return ev;
  }

  function dispatchKey(type, def) {
    const canvas = document.querySelector('canvas');
    const targets = canvas ? [canvas, document, window] : [document, window];
    const ev = makeKeyEvent(type, def);
    for (const t of targets) { try { t.dispatchEvent(ev); } catch (_) {} }
  }

  /********************** 样式 *************************/
  const style = document.createElement('style');
  style.textContent = `
  .vg-toggle {
    position: fixed; left: 10px; top: 10px; z-index: 2147483646;
    padding: 6px 10px; background: rgba(0,0,0,.6); color: #fff; border-radius: 8px;
    font-size: 12px; user-select: none; -webkit-user-select: none; backdrop-filter: saturate(150%) blur(4px);
  }
  .vg-toggle:active { transform: scale(0.98); }

  .vg-panel {
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483645;
    width: 100vw; background: rgba(20,20,24,.55); color: #fff;
    border-radius: 16px 16px 0 0; box-shadow: 0 -8px 24px rgba(0,0,0,.25);
    font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, "PingFang SC","Microsoft Yahei", sans-serif;
    -webkit-user-select: none; user-select: none; touch-action: none; backdrop-filter: blur(6px);
  }
  .vg-header {
    display: flex; align-items: center; justify-content: space-between;
    padding: 6px 10px; border-bottom: 1px solid rgba(255,255,255,.08);
  }
  .vg-handle {
    width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center;
    border-radius: 50%; background: rgba(255,255,255,.12); cursor: grab; font-size: 16px;
  }
  .vg-locked .vg-handle { display: none; }
  .vg-actions { display: flex; gap: 8px; }
  .vg-lock {
    width: 28px; height: 28px; border-radius: 50%;
    display: inline-flex; align-items: center; justify-content: center;
    background: rgba(255,255,255,.12); font-size: 14px;
  }

  .vg-body {
    display: flex; align-items: stretch; justify-content: space-between; gap: 8px;
    padding: 8px 10px 10px;
  }
  .vg-left, .vg-right { flex: 1 1 0; display: flex; align-items: center; justify-content: center; }
  .vg-left { justify-content: flex-start; }
  .vg-right{ justify-content: flex-end; }

  /* 方向键(左侧) */
  .vg-arrows { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(2, 48px); gap: 6px; }
  .vg-arrows .spacer { visibility: hidden; }
  .vg-btn {
    display: inline-flex; align-items: center; justify-content: center;
    min-width: 60px; height: 48px; padding: 0 12px; border-radius: 12px;
    background: rgba(255,255,255,.12); color: #fff; font-weight: 600; font-size: 14px;
    box-shadow: inset 0 0 0 1px rgba(255,255,255,.08);
    -webkit-tap-highlight-color: transparent; border: 0; outline: none; pointer-events: auto;
  }
  .vg-btn:active { transform: scale(.98); }
  .vg-pill { border-radius: 999px; }
  .vg-wide { min-width: 96px; }

  /* 功能键(右侧) */
  .vg-grid {
    display: grid; gap: 6px;
    grid-template-columns: repeat(3, minmax(60px, 1fr));
    grid-auto-rows: 48px;
  }

  /* 小屏优化:纵向堆叠仍保持左右优先 */
  @media (max-width: 520px) {
    .vg-btn { min-width: 48px; height: 44px; font-size: 13px; }
    .vg-arrows { grid-template-rows: repeat(2, 44px); }
    .vg-grid { grid-template-columns: repeat(3, minmax(48px, 1fr)); grid-auto-rows: 44px; }
  }

  .vg-hidden { display: none !important; }
  `;
  document.documentElement.appendChild(style);

  /********************** 顶部“隐藏/显示”按钮 *************************/
  const toggle = document.createElement('button');
  toggle.className = 'vg-toggle';
  toggle.type = 'button';
  toggle.textContent = '隐藏';
  toggle.setAttribute('tabindex', '-1');
  toggle.addEventListener('mousedown', e => e.preventDefault(), { passive: false });
  toggle.addEventListener('touchstart', e => e.preventDefault(), { passive: false });
  document.body.appendChild(toggle);

  /********************** 面板结构 *************************/
  const panel = document.createElement('div');
  panel.className = 'vg-panel';

  // 头部:拖动手柄 + 无文字锁定按钮
  const header = document.createElement('div');
  header.className = 'vg-header';

  const handle = document.createElement('div');
  handle.className = 'vg-handle';
  handle.setAttribute('title', '拖动面板');
  handle.setAttribute('aria-label', '拖动面板');
  handle.textContent = '⤒⤓'; // 暗示可上下拖动

  const actions = document.createElement('div');
  actions.className = 'vg-actions';
  const lockBtn = document.createElement('button');
  lockBtn.className = 'vg-lock';
  lockBtn.type = 'button';
  lockBtn.setAttribute('tabindex', '-1');
  lockBtn.setAttribute('title', '锁定/解锁');
  lockBtn.textContent = '🔓'; 

  actions.appendChild(lockBtn);
  header.appendChild(handle);
  header.appendChild(actions);
  panel.appendChild(header);

  // 内容:左右分区
  const body = document.createElement('div');
  body.className = 'vg-body';

  // 左:方向键
  const left = document.createElement('div');
  left.className = 'vg-left';
  const arrows = document.createElement('div');
  arrows.className = 'vg-arrows';
  arrows.innerHTML = `
    <div class="spacer"></div>
    <button class="vg-btn vg-pill" data-key="ArrowUp" tabindex="-1">↑</button>
    <div class="spacer"></div>
    <button class="vg-btn vg-pill" data-key="ArrowLeft" tabindex="-1">←</button>
    <button class="vg-btn vg-pill" data-key="ArrowDown" tabindex="-1">↓</button>
    <button class="vg-btn vg-pill" data-key="ArrowRight" tabindex="-1">→</button>
  `;
  left.appendChild(arrows);

  // 右:功能键
  const right = document.createElement('div');
  right.className = 'vg-right';
  const grid = document.createElement('div');
  grid.className = 'vg-grid';
  const keys = [
    { label: 'Z', key: 'Z' },
    { label: 'X', key: 'X' },
    { label: 'C', key: 'C' },
    { label: 'Space', key: 'Space', wide: true },
    { label: 'Enter', key: 'Enter', wide: true },
    { label: 'Esc', key: 'Escape' },
    { label: 'Ctrl', key: 'Control' },
    { label: 'Shift', key: 'Shift' }
  ];
  for (const k of keys) {
    const btn = document.createElement('button');
    btn.className = 'vg-btn' + (k.wide ? ' vg-wide' : '');
    btn.textContent = k.label;
    btn.dataset.key = k.key;
    btn.setAttribute('tabindex', '-1');
    btn.addEventListener('mousedown', e => e.preventDefault(), { passive: false });
    btn.addEventListener('touchstart', e => e.preventDefault(), { passive: false });
    grid.appendChild(btn);
  }
  right.appendChild(grid);

  body.appendChild(left);
  body.appendChild(right);
  panel.appendChild(body);
  document.body.appendChild(panel);

  /********************** 状态保存(Y轴位置/锁定/显示) *************************/
  const LS_KEY = `vg-fullwidth-pos-lock-${location.host}`;
  function saveState() {
    const rect = panel.getBoundingClientRect();
    const locked = panel.classList.contains('vg-locked');
    const hidden = panel.classList.contains('vg-hidden');
    localStorage.setItem(LS_KEY, JSON.stringify({ top: rect.top, locked, hidden }));
  }
  function loadState() {
    try {
      const s = JSON.parse(localStorage.getItem(LS_KEY) || '{}');
      if (typeof s.top === 'number') {
        panel.style.top = `${Math.max(0, Math.min(window.innerHeight - 120, s.top))}px`;
        panel.style.bottom = 'auto';
      }
      if (s.locked) setLocked(true);
      if (s.hidden) setHidden(true);
    } catch (_) {}
  }

  function setHidden(v) {
    if (v) {
      panel.classList.add('vg-hidden');
      toggle.textContent = '显示';
    } else {
      panel.classList.remove('vg-hidden');
      toggle.textContent = '隐藏';
    }
  }

  toggle.addEventListener('click', () => { setHidden(!panel.classList.contains('vg-hidden')); saveState(); });

  function setLocked(locked) {
    if (locked) {
      panel.classList.add('vg-locked');
      lockBtn.textContent = '🔒';
      // 锁定时隐藏标题(拖动手柄)
      handle.style.display = 'none';
    } else {
      panel.classList.remove('vg-locked');
      lockBtn.textContent = '🔓';
      handle.style.display = '';
    }
  }
  lockBtn.addEventListener('click', () => { setLocked(!panel.classList.contains('vg-locked')); saveState(); });

  loadState();

  /********************** 拖动(上下拖动) *************************/
  (function dragVerticalOnly() {
    let dragging = false;
    let startY = 0;
    let originTop = 0;

    function onDown(e) {
      if (panel.classList.contains('vg-locked')) return;
      dragging = true;
      const pt = getPoint(e);
      startY = pt.clientY;
      originTop = panel.getBoundingClientRect().top;
      handle.style.cursor = 'grabbing';
      e.preventDefault();
    }
    function onMove(e) {
      if (!dragging) return;
      const pt = getPoint(e);
      const dy = pt.clientY - startY;
      const top = Math.max(0, Math.min(window.innerHeight - 120, originTop + dy));
      panel.style.top = `${top}px`;
      panel.style.bottom = 'auto';
    }
    function onUp() {
      if (!dragging) return;
      dragging = false;
      handle.style.cursor = 'grab';
      saveState();
    }
    function getPoint(e) { return (e.touches && e.touches[0]) || e; }

    handle.addEventListener('mousedown', onDown, { passive: false });
    window.addEventListener('mousemove', onMove, { passive: false });
    window.addEventListener('mouseup',   onUp,   { passive: true });

    handle.addEventListener('touchstart', onDown, { passive: false });
    window.addEventListener('touchmove',  onMove, { passive: false });
    window.addEventListener('touchend',   onUp,   { passive: true });
    window.addEventListener('touchcancel',onUp,   { passive: true });
  })();

  /********************** 按键绑定 *************************/
  const activePresses = new Map(); // id -> {def, timer}

  function startPress(def, id) {
    if (activePresses.has(id)) return;
    dispatchKey('keydown', def);
    const timer = setInterval(() => dispatchKey('keydown', def), 65);
    activePresses.set(id, { def, timer });
  }
  function endPress(id) {
    const rec = activePresses.get(id);
    if (!rec) return;
    clearInterval(rec.timer);
    activePresses.delete(id);
    dispatchKey('keyup', rec.def);
  }
  function defFromDatasetKey(dsKey) {
    if (SPECIAL[dsKey]) return SPECIAL[dsKey];
    if (dsKey === 'Space') return SPECIAL['Space'];
    return mapCharToKey(dsKey);
  }
  function bindButton(el) {
    const dsKey = el.dataset.key;
    const def = defFromDatasetKey(dsKey);
    const id = Symbol(dsKey);
    const prevent = (e) => { e.preventDefault(); e.stopPropagation(); };
    el.addEventListener('mousedown', (e) => { prevent(e); startPress(def, id); }, { passive: false });
    el.addEventListener('mouseup',   (e) => { prevent(e); endPress(id); },       { passive: false });
    el.addEventListener('mouseleave',(e) => { prevent(e); endPress(id); },       { passive: false });
    el.addEventListener('touchstart', (e) => { prevent(e); startPress(def, id); }, { passive: false });
    el.addEventListener('touchend',   (e) => { prevent(e); endPress(id); },        { passive: false });
    el.addEventListener('touchcancel',(e) => { prevent(e); endPress(id); },        { passive: false });
    el.setAttribute('tabindex', '-1');
  }

  panel.querySelectorAll('.vg-btn').forEach(bindButton);

  // 页面不可见时防止“卡键”
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState !== 'visible') {
      for (const [id, rec] of activePresses.entries()) {
        clearInterval(rec.timer);
        dispatchKey('keyup', rec.def);
      }
      activePresses.clear();
    }
  });

})();