GeoFS Mod Menu -cool-

Mod Menu for GeoFS flight model variables using console-modifiable input fields

当前为 2025-10-18 提交的版本,查看 最新版本

// ==UserScript==
// @name         GeoFS Mod Menu -cool-
// @namespace    http://tampermonkey.net/
// @version      1.6
// @description  Mod Menu for GeoFS flight model variables using console-modifiable input fields
// @author       Jasp
// @match        https://www.geo-fs.com/*
// @grant        none
// @license      MIT
// ==/UserScript==


(function () {
  'use strict';

  /* ===========================
     Data / Variable Definitions
     =========================== */

  // Variables that will be editable (order & names shown in UI)
  const allVars = [
    "maxRPM","minRPM","starterRPM","idleThrottle","fuelFlow","enginePower","brakeRPM",
    "wingArea","dragFactor","liftFactor","CD0","CLmax","elevatorFactor","rudderFactor","aileronFactor",
    "mass","emptyWeight","maxWeight","inertia","pitchMoment","yawMoment","rollMoment",
    "gearDrag","gearCompression","gearLength"
  ];

  const explanations = {
    maxRPM: "Maximum revolutions per minute of the engine.",
    minRPM: "Minimum engine RPM (idle lower bound).",
    starterRPM: "RPM used when starting the engine.",
    idleThrottle: "Throttle percentage at idle.",
    fuelFlow: "Fuel flow rate scaling.",
    enginePower: "Base engine power factor.",
    brakeRPM: "RPM threshold for brakes.",
    wingArea: "Wing area used for lift calculations.",
    dragFactor: "General drag multiplier.",
    liftFactor: "Lift multiplier applied to wing calculations.",
    CD0: "Parasitic drag coefficient.",
    CLmax: "Maximum lift coefficient before stall.",
    elevatorFactor: "Elevator control effectiveness.",
    rudderFactor: "Rudder control effectiveness.",
    aileronFactor: "Aileron control effectiveness.",
    mass: "Aircraft mass.",
    emptyWeight: "Empty weight of aircraft.",
    maxWeight: "Maximum allowable weight.",
    inertia: "Rotational inertia parameter.",
    pitchMoment: "Pitch moment (stability).",
    yawMoment: "Yaw moment (stability).",
    rollMoment: "Roll moment (stability).",
    gearDrag: "Drag contribution from gear.",
    gearCompression: "Suspension stiffness / compression.",
    gearLength: "Landing gear strut length."
  };

  /* ===========================
     Theme definitions & helpers
     =========================== */

  const PALETTE_DARK = {
    bg: "#0f1720", panel: "#111827", accent: "#2b9af3",
    text: "#e6eef8", muted: "#9aa8b6", glass: "rgba(255,255,255,0.03)"
  };
  const PALETTE_LIGHT = {
    bg: "#f6fbff", panel: "#e9f2fb", accent: "#1e6fb8",
    text: "#07263a", muted: "#3f6b83", glass: "rgba(4,20,31,0.03)"
  };

  function hexToRgb(hex) {
    const h = hex.replace("#", "");
    if (h.length === 3) return [parseInt(h[0]+h[0],16), parseInt(h[1]+h[1],16), parseInt(h[2]+h[2],16)];
    const bigint = parseInt(h,16); return [(bigint>>16)&255, (bigint>>8)&255, bigint&255];
  }
  function rgbToHex(rgb){ return "#" + rgb.map(v=>{const s=Math.round(v).toString(16); return s.length===1?"0"+s:s;}).join(""); }
  function lerpColor(a,b,t){ const A=hexToRgb(a), B=hexToRgb(b); return rgbToHex([A[0]+(B[0]-A[0])*t, A[1]+(B[1]-A[1])*t, A[2]+(B[2]-A[2])*t]); }
  function lerpGlass(gA,gB,t){
    const parseAlpha = s => { const m = s.match(/rgba\([^,]+,[^,]+,[^,]+,([^)]+)\)/); return m?parseFloat(m[1]):0.03; };
    const a=parseAlpha(gA), b=parseAlpha(gB), alpha=a+(b-a)*t; return `rgba(0,0,0,${alpha})`;
  }
  function applyThemeToDocument(doc, t){
    const bg = lerpColor(PALETTE_DARK.bg, PALETTE_LIGHT.bg, t);
    const panel = lerpColor(PALETTE_DARK.panel, PALETTE_LIGHT.panel, t);
    const accent = lerpColor(PALETTE_DARK.accent, PALETTE_LIGHT.accent, t);
    const text = lerpColor(PALETTE_DARK.text, PALETTE_LIGHT.text, t);
    const muted = lerpColor(PALETTE_DARK.muted, PALETTE_LIGHT.muted, t);
    const glass = lerpGlass(PALETTE_DARK.glass, PALETTE_LIGHT.glass, t);
    const root = doc.documentElement;
    root.style.setProperty('--mod-bg', bg);
    root.style.setProperty('--mod-panel', panel);
    root.style.setProperty('--mod-accent', accent);
    root.style.setProperty('--mod-text', text);
    root.style.setProperty('--mod-muted', muted);
    root.style.setProperty('--mod-glass', glass);
    (doc.querySelectorAll?.('.theme-range')||[]).forEach(el=>{ try{ el.style.accentColor=accent; }catch(e){} });
  }

  /* ===========================
     Injection: single-document UI builder
     =========================== */

  const injectedDocs = new WeakSet();

  function injectIntoWindow(targetWin) {
    if (!targetWin || !targetWin.document) return;
    const doc = targetWin.document;
    if (injectedDocs.has(doc)) return;
    injectedDocs.add(doc);

    // CSS (uses CSS vars)
    const css = `
      :root{ --mod-bg:${PALETTE_DARK.bg}; --mod-panel:${PALETTE_DARK.panel}; --mod-accent:${PALETTE_DARK.accent}; --mod-text:${PALETTE_DARK.text}; --mod-muted:${PALETTE_DARK.muted}; --mod-glass:${PALETTE_DARK.glass}; }
      #geofsModMenu{ position:fixed; top:80px; right:18px; width:360px; max-height:86vh; overflow-y:auto; color:var(--mod-text); border-radius:14px; padding:12px; box-shadow:0 10px 40px rgba(2,6,23,0.6); backdrop-filter:blur(8px); z-index:2147483000; border:1px solid rgba(255,255,255,0.04); display:none; font-family:"Segoe UI",Roboto,Arial; font-size:13px; background:linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)); }
      #geofsModMenu .panel{ background:linear-gradient(180deg,var(--mod-panel),rgba(0,0,0,0.015)); border-radius:10px; padding:10px; box-shadow:inset 0 1px 0 rgba(255,255,255,0.02); }
      header{ display:flex; align-items:center; gap:10px; margin-bottom:8px; }
      #geofsModMenu h1{ font-size:15px; margin:0; color:var(--mod-text); font-weight:600; }
      #themeControl{ margin-left:auto; display:flex; align-items:center; gap:8px; }
      .theme-label{ font-size:12px; color:var(--mod-muted); width:36px; text-align:center; }
      #themeSlider{ width:140px; appearance:none; height:8px; border-radius:999px; background:linear-gradient(90deg,var(--mod-accent),#00b4ff 50%,#9ad6ff); outline:none; box-shadow:inset 0 -1px 0 rgba(0,0,0,0.2); }
      #themeSlider::-webkit-slider-thumb{ -webkit-appearance:none; width:18px; height:18px; border-radius:50%; background:#fff; box-shadow:0 2px 6px rgba(0,0,0,0.35); border:2px solid rgba(0,0,0,0.2); cursor:pointer; }
      #presetButtons{ display:flex; flex-wrap:wrap; gap:8px; margin-bottom:10px; }
      .action-button{ background:linear-gradient(180deg,rgba(255,255,255,0.03),rgba(0,0,0,0.04)); border:1px solid rgba(255,255,255,0.03); color:var(--mod-text); padding:6px 10px; border-radius:8px; cursor:pointer; font-weight:600; font-size:13px; }
      .action-button.primary{ background:linear-gradient(180deg,rgba(255,255,255,0.02),rgba(0,0,0,0.06)); border:1px solid rgba(255,255,255,0.04); }
      h2.section-title{ margin:12px 0 6px; font-size:13px; color:var(--mod-accent); text-transform:uppercase; letter-spacing:0.6px; }
      .var-row{ display:flex; flex-direction:column; gap:6px; margin-bottom:8px; }
      .label-row{ display:flex; justify-content:space-between; align-items:center; gap:12px; }
      label.name{ font-weight:600; color:var(--mod-text); font-size:13px; }
      .explain{ font-size:12px; color:var(--mod-muted); margin-top:-4px; }
      input[type="number"].num-input{ width:140px; background:transparent; border:1px solid rgba(255,255,255,0.04); color:var(--mod-text); padding:6px 8px; border-radius:8px; text-align:right; }
      #menuToggleBtn{ position:fixed; top:18px; right:18px; width:46px; height:46px; border-radius:50%; display:flex; align-items:center; justify-content:center; background:var(--mod-panel); color:var(--mod-text); border:1px solid rgba(255,255,255,0.03); box-shadow:0 6px 20px rgba(2,6,23,0.5); z-index:2147483001; cursor:pointer; }
      #helpOverlay{ position:fixed; top:0; left:0; width:100%; height:100%; background: rgba(2,6,23,0.9); color:#eaf6ff; padding:28px; z-index:2147483002; display:none; overflow-y:auto; font-size:14px; }
      #closeHelp{ position:absolute; top:18px; right:24px; font-size:22px; cursor:pointer; }
      @media (max-width:520px){ #geofsModMenu{ width:92%; left:4%; right:4%; top:80px; } }
    `;
    const styleEl = doc.createElement('style');
    styleEl.textContent = css;
    doc.head.appendChild(styleEl);

    // Create elements
    const menu = doc.createElement('div'); menu.id = 'geofsModMenu'; menu.className = 'panel';
    const toggleBtn = doc.createElement('div'); toggleBtn.id = 'menuToggleBtn'; toggleBtn.title = 'Toggle GeoFS Mod Menu'; toggleBtn.textContent = '⚙️';
    const helpOverlay = doc.createElement('div'); helpOverlay.id = 'helpOverlay';
    helpOverlay.innerHTML = `<div id="closeHelp">✕</div><h1>GeoFS Mod Menu — Help</h1><div id="helpContent"></div>`;

    // Header with theme slider
    const header = doc.createElement('header');
    header.innerHTML = `<h1>GeoFS Mod Menu</h1>`;
    const themeControl = doc.createElement('div'); themeControl.id = 'themeControl';
    themeControl.innerHTML = `<div class="theme-label">Dark</div><input id="themeSlider" class="theme-range" type="range" min="0" max="100" value="0" /><div class="theme-label">Light</div>`;
    header.appendChild(themeControl);

    // Action buttons (no presets)
    const actionRow = doc.createElement('div');
    actionRow.id = 'presetButtons';
    // Reset button
    const resetBtn = doc.createElement('button'); resetBtn.className = 'action-button'; resetBtn.textContent = 'Reset'; actionRow.appendChild(resetBtn);
    // Help button
    const helpBtn = doc.createElement('button'); helpBtn.className = 'action-button primary'; helpBtn.textContent = 'Help'; actionRow.appendChild(helpBtn);

    const bodyWrap = doc.createElement('div'); bodyWrap.className = 'panel';

    menu.appendChild(header);
    menu.appendChild(actionRow);
    menu.appendChild(bodyWrap);

    // Append to document
    try { doc.body.appendChild(menu); doc.body.appendChild(toggleBtn); doc.body.appendChild(helpOverlay); }
    catch (e) { console.error('[ModMenu] append failed', e); return; }

    // Fill help content
    const helpContent = helpOverlay.querySelector('#helpContent');
    helpContent.innerHTML = '<p>Type any numeric value into the boxes. Each box accepts up to 9 digits (digits only; decimal point and minus sign are allowed). Use Reset to set all values to 0.</p>';
    for (const [k,v] of Object.entries(explanations)) {
      const p = doc.createElement('p'); p.innerHTML = `<strong>${k}</strong>: ${v}`; helpContent.appendChild(p);
    }
    helpOverlay.querySelector('#closeHelp').addEventListener('click', ()=>{ helpOverlay.style.display='none'; menu.style.display='block'; });

    helpBtn.addEventListener('click', ()=>{ menu.style.display='none'; helpOverlay.style.display='block'; });

    // Map to hold inputs in this doc
    const inputsMap = {};

    // Input digit cap: 9 digits (excluding '.' and '-')
    function enforceNineDigitCap(str) {
      // remove all characters except digits, '.', '-'
      // do not allow 'e' / exponential notation
      let cleaned = str.replace(/[^\d\.\-]/g, '');
      // prevent multiple minus signs; minus only at start
      const minusMatches = cleaned.match(/-/g);
      if (minusMatches && minusMatches.length > 1) {
        cleaned = cleaned.replace(/-/g, '');
        cleaned = '-' + cleaned;
      }
      if (cleaned.indexOf('-') > 0) cleaned = cleaned.replace('-', '');
      // prevent multiple dots
      const parts = cleaned.split('.');
      if (parts.length > 2) {
        cleaned = parts.shift() + '.' + parts.join('');
      }
      // count digits (exclude dot and minus)
      const digitsOnly = cleaned.replace(/[^0-9]/g, '');
      if (digitsOnly.length > 9) {
        // truncate to first 9 digits while preserving decimal & minus placement as best effort
        const allowedDigits = digitsOnly.slice(0,9);
        // rebuild: if there was a dot, try to keep fractional digits after truncation
        if (cleaned.includes('.')) {
          const [intPart, fracPart=''] = cleaned.split('.');
          const intDigits = intPart.replace(/[^0-9\-]/g,'').replace('-','');
          // take as many int digits as possible (up to allowedDigits length)
          const keepInt = Math.min(intDigits.length, allowedDigits.length);
          const newInt = intDigits.slice(0, keepInt);
          const newFrac = allowedDigits.slice(keepInt);
          cleaned = (cleaned.startsWith('-')?'-':'') + (newInt || '0') + (newFrac?'.'+newFrac:'');
        } else {
          // no dot: integer-only — keep first 9 digits (respect minus)
          const wasNegative = cleaned.startsWith('-');
          const d = allowedDigits;
          cleaned = (wasNegative?'-':'') + d;
        }
      }
      return cleaned;
    }

    function createNumberControl(varName) {
      const container = doc.createElement('div'); container.className = 'var-row';
      const labelRow = doc.createElement('div'); labelRow.className='label-row';
      const label = doc.createElement('label'); label.className='name'; label.textContent = varName;
      const input = doc.createElement('input'); input.type='number'; input.className='num-input'; input.setAttribute('inputmode','decimal');
      input.placeholder = "0";
      // allow any numeric value but cap digits to 9
      input.addEventListener('input', (e)=>{
        const original = input.value;
        const enforced = enforceNineDigitCap(original);
        if (enforced !== original) {
          // set value preserving cursor where reasonable
          input.value = enforced;
        }
        // try to update model on any numeric parse
        const parsed = parseFloat(input.value);
        if (!Number.isNaN(parsed)) updateModelInDoc(doc, varName, parsed);
      }, {passive:true});

      // also sanitize on paste
      input.addEventListener('paste', (ev)=>{
        ev.preventDefault();
        const text = (ev.clipboardData || window.clipboardData).getData('text') || '';
        const enforced = enforceNineDigitCap(text);
        input.value = enforced;
        const parsed = parseFloat(enforced);
        if (!Number.isNaN(parsed)) updateModelInDoc(doc, varName, parsed);
      });

      // on blur, ensure value is valid number (or set 0)
      input.addEventListener('blur', ()=>{
        if (input.value === '' || input.value === '-' || input.value === '.' || input.value === '-.') {
          input.value = '0';
          updateModelInDoc(doc, varName, 0);
        } else {
          const parsed = parseFloat(input.value);
          if (Number.isNaN(parsed)) { input.value = '0'; updateModelInDoc(doc, varName, 0); }
          else updateModelInDoc(doc, varName, parsed);
        }
      });

      labelRow.appendChild(label); labelRow.appendChild(input); container.appendChild(labelRow);

      const explain = doc.createElement('div'); explain.className='explain'; explain.textContent = explanations[varName] || '';
      container.appendChild(explain);

      inputsMap[varName] = { input, container };
      return container;
    }

    // Build UI groups (grouped for readability)
    const groups = {
      Engine: ["maxRPM","minRPM","starterRPM","idleThrottle","fuelFlow","enginePower","brakeRPM"],
      Aerodynamics: ["wingArea","dragFactor","liftFactor","CD0","CLmax","elevatorFactor","rudderFactor","aileronFactor"],
      "Flight Model": ["mass","emptyWeight","maxWeight","inertia","pitchMoment","yawMoment","rollMoment"],
      "Landing Gear": ["gearDrag","gearCompression","gearLength"]
    };

    for (const [groupName, vars] of Object.entries(groups)) {
      const h = doc.createElement('h2'); h.className='section-title'; h.textContent = groupName; bodyWrap.appendChild(h);
      for (const v of vars) {
        if (!allVars.includes(v)) continue;
        const ctrl = createNumberControl(v);
        bodyWrap.appendChild(ctrl);
      }
    }

    // Reset button behavior: set all to 0 and update model
    resetBtn.addEventListener('click', ()=>{
      for (const [k,entry] of Object.entries(inputsMap)) {
        entry.input.value = '0';
        updateModelInDoc(doc, k, 0);
      }
    });

    // Model update helper (works in this doc's window)
    function updateModelInDoc(docRef, variable, value) {
      const win = docRef.defaultView || window;
      try {
        const def = win.geofs?.aircraft?.instance?.definition || win.geofs?.aircraft?.definition;
        if (def && variable in def) {
          def[variable] = value;
        } else {
          // fallback attempt (safe)
          if (win.geofs && win.geofs.aircraft && win.geofs.aircraft.instance && win.geofs.aircraft.instance.definition) {
            win.geofs.aircraft.instance.definition[variable] = value;
          }
        }
      } catch (err) {
        // ignore: update may fail if geofs internal structure differs
      }
    }

    // Theme slider logic (smooth interpolation)
    const themeSlider = doc.getElementById('themeSlider');
    let themeT = parseFloat(themeSlider.value)/100;
    applyThemeToDocument(doc, themeT);
    let themeAnim = null;
    function animateThemeToDoc(docRef, targetT, duration=320) {
      const startT = themeT, delta = targetT - startT, startTime = performance.now();
      if (themeAnim) cancelAnimationFrame(themeAnim);
      function step(now) {
        const elapsed = now - startTime; const p = Math.min(1, elapsed/duration);
        const eased = p<0.5?2*p*p:-1+(4-2*p)*p;
        themeT = startT + delta*eased;
        applyThemeToDocument(docRef, themeT);
        if (p < 1) themeAnim = requestAnimationFrame(step); else themeAnim = null;
      }
      themeAnim = requestAnimationFrame(step);
    }
    themeSlider.addEventListener('input', (e)=> {
      const v = parseInt(e.target.value,10)/100; animateThemeToDoc(doc, v, 320);
    });

    // Toggle button behavior
    toggleBtn.addEventListener('click', ()=> {
      const open = menu.style.display === 'block';
      menu.style.display = open? 'none':'block';
      if (!open) toggleBtn.style.boxShadow = '0 10px 26px rgba(2,6,23,0.6)'; else toggleBtn.style.boxShadow = '0 6px 20px rgba(2,6,23,0.5)';
    });

    // Keybinding inside this doc
    doc.addEventListener('keydown', (e)=> { if (e.key === '#') toggleBtn.click(); });

    // initialize all inputs to 0 (so entries exist)
    for (const [k,entry] of Object.entries(inputsMap)) { entry.input.value = '0'; updateModelInDoc(doc,k,0); }

    console.log('[ModMenu] injected numeric-only overlay into', doc.location && doc.location.href ? doc.location.href : 'unknown');
  } // end injectIntoWindow

  /* ===========================
     Inject into main document and any iframes (observe changes)
     =========================== */

  function attemptInjectionAll() {
    try { injectIntoWindow(window); } catch(e){/*ignore*/}

    const iframes = Array.from(document.getElementsByTagName('iframe'));
    for (const iframe of iframes) {
      try {
        const src = iframe.getAttribute('src') || '';
        // heuristics: likely sim iframe
        if (src.includes('geofs.php') || src.includes('geo-fs') || (iframe.id && /geofs|map|game/i.test(iframe.id))) {
          try {
            if (iframe.contentWindow && iframe.contentWindow.document) injectIntoWindow(iframe.contentWindow);
          } catch(err){}
        }
      } catch(err){}
    }
  }

  attemptInjectionAll();

  // Watch for new iframes / attribute changes (when you click Fly)
  const observer = new MutationObserver(muts => {
    for (const m of muts) {
      if (m.type === 'childList' && m.addedNodes && m.addedNodes.length) {
        for (const n of m.addedNodes) {
          if (n.tagName && n.tagName.toLowerCase() === 'iframe') {
            const iframe = n;
            setTimeout(()=> {
              try { if (iframe.contentWindow && iframe.contentWindow.document) injectIntoWindow(iframe.contentWindow); } catch(e){}
            }, 300);
          }
        }
      }
      if (m.type === 'attributes' && m.target && m.target.tagName && m.target.tagName.toLowerCase()==='iframe') {
        const iframe = m.target;
        setTimeout(()=> { try { if (iframe.contentWindow && iframe.contentWindow.document) injectIntoWindow(iframe.contentWindow); } catch(e){} }, 300);
      }
    }
  });
  observer.observe(document.documentElement || document.body, { childList:true, subtree:true, attributes:true, attributeFilter:['src'] });

  // Poll occasionally as a fallback
  const poll = setInterval(()=> { attemptInjectionAll(); }, 1200);
  setTimeout(()=> clearInterval(poll), 90000); // stop after 90s

  console.log('[ModMenu] numeric-only overlay injector running. Click the cog in the top-right of the flight view to open the panel.');
})();