diff --git a/idleon-clicker.user.js b/idleon-clicker.user.js index 677b534..4d45088 100644 --- a/idleon-clicker.user.js +++ b/idleon-clicker.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Clicker // @namespace nativerobot -// @version 3.5 +// @version 3.6 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-clicker.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-clicker.user.js // @description Stealthy in-page autoclicker panel for Legends of IdleOn (browser) @@ -36,8 +36,23 @@ // ---------- state ---------- let on = false, timer = null, capturing = false; - let lastX = 0, lastY = 0; - document.addEventListener('mousemove', e => { lastX = e.clientX; lastY = e.clientY; }, true); + // lastX/lastY only move while the pointer is over THIS window. In a second + // window — side by side, or simply not the one being pointed at — they go + // stale the moment it leaves, and they are 0,0 before it has ever arrived. + // Cursor mode would then click the top-left corner of the game forever, + // silently, which is not a harmless no-op: a click on bare ground is a walk + // command, so the character strolls off. ptrIn is what says whether the + // coordinates mean anything. + let lastX = 0, lastY = 0, ptrIn = false, wasBlind = false; + document.addEventListener('mousemove', e => { + lastX = e.clientX; lastY = e.clientY; ptrIn = true; + }, true); + // mouseout with a null relatedTarget is the pointer leaving the document + // altogether; leaving for the panel, or for any other element, names that + // element instead and does not count. + document.addEventListener('mouseout', e => { + if (!e.relatedTarget) ptrIn = false; + }, true); // ---------- stealth UI host (closed shadow DOM, hidden from page JS) ---------- const host = document.createElement('div'); @@ -120,7 +135,8 @@ function sync() { ivMinEl.value = cfg.ivMin; ivMaxEl.value = cfg.ivMax; jpEl.value = cfg.jitterPx; root.querySelectorAll('.seg button').forEach(b => b.classList.toggle('sel', b.dataset.m === cfg.mode)); - xyEl.textContent = cfg.mode !== 'fixed' ? '(follows cursor)' + xyEl.textContent = cfg.mode !== 'fixed' + ? (ptrIn ? '(follows cursor)' : 'cursor is in another window') : hasTarget() ? fixedPoint().map(Math.round).join(', ') : 'not set'; dot.classList.toggle('on', on); runBtn.textContent = on ? 'Stop (F8)' : 'Start (F8)'; @@ -175,8 +191,15 @@ function tick() { if (!on) return; + // Cursor mode with the pointer in another window has nothing to aim at, so + // it holds rather than clicking a stale coordinate. The timer keeps running + // and it resumes by itself when the pointer comes back. Announced, because + // the failure is otherwise invisible: the clicker looks like it is running + // and the game just never responds. + const blind = cfg.mode !== 'fixed' && !ptrIn; + if (blind !== wasBlind) { wasBlind = blind; sync(); } // Resolved every tick: the canvas rect can change under a running clicker. - if (cfg.mode !== 'fixed' || hasTarget()) { + if (!blind && (cfg.mode !== 'fixed' || hasTarget())) { const [tx, ty] = cfg.mode === 'fixed' ? fixedPoint() : [lastX, lastY]; clickAt(tx, ty); } diff --git a/idleon-suite.user.js b/idleon-suite.user.js index b765e7a..1834945 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.33 +// @version 1.37 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @description All-in-one: autoclicker + Hoops, Fishing and Darts minigame helpers for Legends of IdleOn, each one individually switchable @@ -65,7 +65,14 @@ // ---------- which helpers are on ---------- const SUITE_KEY = 'idleon_suite'; const ALL_ON = { clicker: true, hoops: true, fishing: true, darts: true }; - const suite = Object.assign({ collapsed: false, hidden: false }, + // layout: 'free' keeps the dragged-anywhere behaviour every version until now + // had, and stays the default so an upgrade moves nobody's panels. 'left' and + // 'top' dock them into one column or one row. + // solo: opening a helper closes the other helpers. Only meaningful docked, + // where they share a column; see the collapse handler. + // follow: opt in to letting the active minigame open its own helper. + const suite = Object.assign({ collapsed: false, hidden: false, + layout: 'free', solo: true, follow: false }, JSON.parse(localStorage.getItem(SUITE_KEY) || '{}')); suite.enabled = Object.assign({}, ALL_ON, suite.enabled); const saveSuite = () => localStorage.setItem(SUITE_KEY, JSON.stringify(suite)); @@ -130,6 +137,87 @@ return img; } + // ---------- docked layouts ---------- + // Five panels is a lot of furniture to arrange by hand every session, and + // only one helper is ever useful at a time — you are in exactly one minigame. + // Docking stacks them against an edge in a fixed order and takes over their + // positions; the saved px/py are left untouched so switching back to 'free' + // restores exactly where things were. + const docks = []; // { def, ui }, sorted by def.dockOrder + const DOCK_EDGE = 10, DOCK_GAP = 8; + let relayoutPending = false; + + function relayout() { + if (suite.layout === 'free') { + for (const d of docks) d.ui.place(); + return; + } + const vert = suite.layout === 'left'; + // ?? not ||: the hub is dockOrder 0, which || would treat as missing and + // sort to the bottom of its own dock. + const list = docks.slice().sort((a, b) => (a.def.dockOrder ?? 99) - (b.def.dockOrder ?? 99)); + // A run wraps rather than running off the edge. Five panels do not fit + // across a half-width window, and with several expanded they do not fit + // down a short one either — and a panel past the edge is the exact trap the + // clamping in place() exists to avoid: unreachable, and unreachable means + // undraggable, so there is no way back to it. + // + // `run` is the thickness of the current row (or column): the tallest panel + // in a row, the widest in a column, which is what the next one has to clear. + // The first panel of a run never wraps — if one panel is bigger than the + // whole viewport there is nowhere better for it, and wrapping on it would + // spin. + let x = DOCK_EDGE, y = DOCK_EDGE, run = 0; + for (const { ui } of list) { + if (ui.cfg.hidden) continue; // hidden panels are a nub, not a slot + const p = ui.panel; + p.style.right = 'auto'; + // Measured before placing: the width is pinned by style.width so the + // height does not depend on where it ends up, and the wrap has to be + // decided before the position is written. + const r = p.getBoundingClientRect(); + if (vert) { + if (y > DOCK_EDGE && y + r.height > window.innerHeight - DOCK_EDGE) { + x += run + DOCK_GAP; y = DOCK_EDGE; run = 0; + } + } else if (x > DOCK_EDGE && x + r.width > window.innerWidth - DOCK_EDGE) { + y += run + DOCK_GAP; x = DOCK_EDGE; run = 0; + } + p.style.left = x + 'px'; + p.style.top = y + 'px'; + if (vert) { y += r.height + DOCK_GAP; run = Math.max(run, r.width); } + else { x += r.width + DOCK_GAP; run = Math.max(run, r.height); } + } + } + + // The hub owns the layout controls, but a drag out of a dock has to change + // the layout from inside makePanel. This is the seam between the two. + let onLayoutChange = () => {}; + // Solo has to be an invariant, not just something the collapse button does. + // Arriving in a dock with four helpers already open gives a column that needs + // two of them to fit — which is the exact thing the dock is for avoiding. So + // entering a docked layout closes all but the first open helper. + function enforceSolo() { + if (!suite.solo || suite.layout === 'free') return; + let kept = false; + const list = docks.slice().sort((a, b) => (a.def.dockOrder ?? 99) - (b.def.dockOrder ?? 99)); + for (const d of list) { + if (!d.def.helper || d.ui.cfg.collapsed) continue; + if (!kept) { kept = true; continue; } // the first open one stays open + d.ui.cfg.collapsed = true; d.ui.save(); d.ui.chrome(); + } + } + function syncLayout() { enforceSolo(); relayout(); onLayoutChange(); } + + // Collapsing a panel changes every panel below it, so the re-stack is + // coalesced to one pass per frame rather than run per panel per change. + function relayoutSoon() { + if (relayoutPending) return; + relayoutPending = true; + requestAnimationFrame(() => { relayoutPending = false; relayout(); }); + } + window.addEventListener('resize', relayoutSoon); + // ---------- panel chrome ---------- // Every panel is the same furniture around a different body: a title bar // that drags, a roll-up toggle, a hide toggle, and a nub that brings a @@ -245,12 +333,17 @@ minBtn.textContent = cfg.collapsed ? '+' : '–'; panel.style.display = cfg.hidden ? 'none' : ''; nub.style.display = cfg.hidden ? '' : 'none'; + relayoutSoon(); // heights and occupancy just changed } // drag let dx = 0, dy = 0, drag = false; $('#hd').addEventListener('mousedown', e => { if (e.target.id === 'min') return; + // Dragging out of a dock means you want it somewhere else, so the dock + // gets out of the way rather than snapping the panel back and looking + // broken. "Reset panel layout" puts it back. + if (suite.layout !== 'free') { suite.layout = 'free'; saveSuite(); syncLayout(); } drag = true; const r = panel.getBoundingClientRect(); dx = e.clientX - r.left; dy = e.clientY - r.top; @@ -278,6 +371,7 @@ ui.save(); place(); chrome(); }, dot: $('#dot'), runBtn: $('#run'), stEl: $('#st'), nub, minBtn, body, + place, // so a dock can hand positions back on the way out save: () => {}, // replaced by the module, which owns its store // Keep every control out of the tab order and drop focus as soon as it // is released, so a Space or Enter aimed at the game can't re-fire @@ -296,10 +390,28 @@ for (const [tg, ty, fn, cap] of bound) tg.removeEventListener(ty, fn, cap); roots.delete(root); host.remove(); + const i = docks.findIndex(d => d.ui === ui); + if (i >= 0) docks.splice(i, 1); + relayoutSoon(); } }; - minBtn.addEventListener('click', () => { cfg.collapsed = !cfg.collapsed; ui.save(); chrome(); }); + docks.push({ def, ui }); + minBtn.addEventListener('click', () => { + cfg.collapsed = !cfg.collapsed; + ui.save(); + // Solo closes the other HELPERS when you open one — not the clicker, + // which is useful alongside any of them, and not the suite panel. Only + // while docked: in the free layout the panels are wherever you put them + // and collapsing one you never touched would just look like a bug. + if (!cfg.collapsed && suite.solo && suite.layout !== 'free' && def.helper) { + for (const d of docks) { + if (d.ui === ui || !d.def.helper || d.ui.cfg.collapsed) continue; + d.ui.cfg.collapsed = true; d.ui.save(); d.ui.chrome(); + } + } + chrome(); + }); nub.addEventListener('click', () => { cfg.hidden = false; ui.save(); chrome(); }); return ui; } @@ -428,6 +540,7 @@ z: 2147483646, theme: { dot: '#4ade80', ac: '#2563eb', stop: '#dc2626' }, slot: { top: 12, right: 12, width: 210, nub: 24 }, + dockOrder: 1, overlay: false, hotkeys: { F8: 'toggle', F9: 'panic', F10: 'hide' }, keyHint: 'F8', @@ -451,13 +564,23 @@ xyEl = $('#xy'), setBtn = $('#set'); let on = false, timer = null, capturing = false; - let lastX = 0, lastY = 0; - ui.on(document, 'mousemove', e => { lastX = e.clientX; lastY = e.clientY; }, true); + // lastX/lastY only move while the pointer is over THIS window, so in a + // second window they go stale on the way out and are 0,0 before it has + // ever arrived. See the standalone clicker for the whole story; ptrIn is + // what says whether the coordinates mean anything. + let lastX = 0, lastY = 0, ptrIn = false, wasBlind = false; + ui.on(document, 'mousemove', e => { + lastX = e.clientX; lastY = e.clientY; ptrIn = true; + }, true); + // A null relatedTarget is the pointer leaving the document altogether; + // leaving for a panel names that element instead and does not count. + ui.on(document, 'mouseout', e => { if (!e.relatedTarget) ptrIn = false; }, true); function sync() { ivMinEl.value = cfg.ivMin; ivMaxEl.value = cfg.ivMax; jpEl.value = cfg.jitterPx; root.querySelectorAll('.seg button').forEach(b => b.classList.toggle('sel', b.dataset.m === cfg.mode)); - xyEl.textContent = cfg.mode !== 'fixed' ? '(follows cursor)' + xyEl.textContent = cfg.mode !== 'fixed' + ? (ptrIn ? '(follows cursor)' : 'cursor is in another window') : hasTarget() ? fixedPoint().map(Math.round).join(', ') : 'not set'; dot.classList.toggle('on', on); runBtn.textContent = on ? 'Stop (F8)' : 'Start (F8)'; @@ -509,8 +632,15 @@ function tick() { if (!on) return; + // Cursor mode with the pointer in another window has nothing to aim at, so + // it holds rather than clicking a stale coordinate. The timer keeps running + // and it resumes by itself when the pointer comes back. Announced, because + // the failure is otherwise invisible: the clicker looks like it is running + // and the game just never responds. + const blind = cfg.mode !== 'fixed' && !ptrIn; + if (blind !== wasBlind) { wasBlind = blind; sync(); } // Resolved every tick: the canvas rect can change under a running clicker. - if (cfg.mode !== 'fixed' || hasTarget()) { + if (!blind && (cfg.mode !== 'fixed' || hasTarget())) { const [tx, ty] = cfg.mode === 'fixed' ? fixedPoint() : [lastX, lastY]; clickAt(tx, ty); } @@ -751,6 +881,7 @@ z: 2147483645, theme: { dot: '#f87171', ac: '#dc2626' }, slot: { top: 12, left: 220, width: 228, nub: 42 }, + dockOrder: 2, helper: true, overlay: true, hotkeys: { F7: 'toggle', F6: 'hide' }, keyHint: 'F7', @@ -1604,7 +1735,10 @@ cfg.scale = +b.dataset.s; tracks = []; save(); sync(); })); - return { loop, sync, toggle }; + // For the suite's auto-open: the platform is found every frame the court is up. + // Reusing the loop's own state rather than testing the screen again -- + // a second detector here would be one more thing to drift. + return { loop, sync, toggle, active: () => plat != null }; } }; @@ -1670,6 +1804,7 @@ z: 2147483644, theme: { dot: '#38bdf8', ac: '#0284c7' }, slot: { top: 12, left: 460, width: 214, nub: 60 }, + dockOrder: 3, helper: true, overlay: true, hotkeys: { F4: 'toggle', F3: 'hide' }, keyHint: 'F4', @@ -2633,7 +2768,10 @@ save(); }; - return { loop, sync, toggle }; + // For the suite's auto-open: the lane goes null the moment the fishing spot is off screen. + // Reusing the loop's own state rather than testing the screen again -- + // a second detector here would be one more thing to drift. + return { loop, sync, toggle, active: () => lane != null }; } }; @@ -2729,6 +2867,7 @@ z: 2147483643, theme: { dot: '#fbbf24', ac: '#d97706' }, slot: { top: 12, left: 686, width: 216, nub: 78 }, + dockOrder: 4, helper: true, overlay: true, hotkeys: { F2: 'toggle', F1: 'hide' }, keyHint: 'F2', @@ -3681,7 +3820,10 @@ save(); }; - return { loop, sync, toggle }; + // For the suite's auto-open: the board is nulled by the wall gate and after 900ms stale. + // Reusing the loop's own state rather than testing the screen again -- + // a second detector here would be one more thing to drift. + return { loop, sync, toggle, active: () => board != null }; } }; @@ -3695,6 +3837,7 @@ z: 2147483647, theme: { dot: '#a78bfa', ac: '#7c3aed' }, slot: { top: 12, left: 12, width: 196, nub: 6 }, + dockOrder: 0, overlay: false, bodyHTML: // Each row: the helper's name, its toggle hotkey, an eye that shows or @@ -3709,6 +3852,14 @@ `` ).join('\n ') + `
+
+ + +
+
+
+
+
unticking a helper stops it:
no panel, no readback, no hotkey
` @@ -3722,6 +3873,7 @@ // "all hidden" drives the button's label, so it reads as the thing it is // about to do rather than as the state it is in. const anyShown = () => MODULES.some(m => live.has(m.id) && !m.cfg.hidden); + const anyOpen = () => MODULES.some(m => live.has(m.id) && !m.cfg.collapsed); function syncHub() { for (const m of MODULES) { @@ -3732,6 +3884,13 @@ eye.className = 'eye' + (off ? ' off' : ''); } hub.$('#panels').textContent = anyShown() ? 'Hide all panels' : 'Show all panels'; + hub.$('#rollup').textContent = anyOpen() ? 'Minimise all' : 'Expand all'; + for (const l of ['free', 'left', 'top']) + hub.$('#lay-' + l).classList.toggle('sel', suite.layout === l); + hub.$('#solo').checked = !!suite.solo; + hub.$('#follow').checked = !!suite.follow; + // Both only bite in a dock; saying so beats leaving them looking broken. + hub.$('#solo').disabled = hub.$('#follow').disabled = suite.layout === 'free'; hub.chrome(); } @@ -3744,6 +3903,26 @@ syncHub(); }; } + for (const l of ['free', 'left', 'top']) + hub.$('#lay-' + l).onclick = () => { suite.layout = l; saveSuite(); syncLayout(); }; + hub.$('#solo').onchange = e => { suite.solo = e.target.checked; saveSuite(); }; + hub.$('#follow').onchange = e => { suite.follow = e.target.checked; saveSuite(); }; + onLayoutChange = syncHub; + + // Rolls every helper up to its title bar without hiding it — the panels + // stay on screen and stay clickable, which is the difference from "Hide + // all panels". In a dock that is also how you get back to one short column + // after several have been opened. + hub.$('#rollup').onclick = () => { + const roll = anyOpen(); + for (const m of MODULES) { + m.cfg.collapsed = roll; m.save(); + const inst = live.get(m.id); + if (inst) inst.ui.chrome(); + } + syncHub(); + }; + hub.$('#panels').onclick = () => { const hide = anyShown(); for (const m of MODULES) { @@ -3764,13 +3943,47 @@ else { m.cfg.px = null; m.cfg.py = null; m.cfg.hidden = false; m.cfg.collapsed = false; m.save(); } } hub.reset(); + suite.layout = 'free'; saveSuite(); + syncLayout(); syncHub(); }; + // Opt-in: the helper whose minigame is on screen opens itself and the other + // helpers close. Driven off each helper's own detection -- the variable it + // already keeps for "I can see my minigame" -- so there is no second copy + // of any detector here to drift out of step. + // + // Only acts on a CHANGE of which helper is active, so a manual collapse is + // not immediately undone; and it does nothing until a helper has been + // active for a moment, because the detectors flicker while a screen loads + // and a layout that flickers with them is worse than one that lags. + let followWas = null, followSince = 0, followCand = null; + function followTick() { + if (!suite.follow || suite.layout === 'free') { followWas = null; return; } + let now = null; + for (const m of MODULES) { + const inst = live.get(m.id); + if (m.helper && inst && inst.active && inst.active()) { now = m.id; break; } + } + const t = performance.now(); + if (now !== followCand) { followCand = now; followSince = t; return; } + if (t - followSince < 600 || now === followWas) return; + followWas = now; + for (const m of MODULES) { + if (!m.helper) continue; + const inst = live.get(m.id); + if (!inst) continue; + const want = m.id === now; + if (m.cfg.collapsed !== !want) { m.cfg.collapsed = !want; m.save(); inst.ui.chrome(); } + } + } + for (const m of MODULES) if (suite.enabled[m.id]) startModule(m); hub.settle(); syncHub(); + syncLayout(); + setInterval(followTick, 250); requestAnimationFrame(driver); } diff --git a/tools/suite/00-head.js b/tools/suite/00-head.js index 4c96ce9..dc956b2 100644 --- a/tools/suite/00-head.js +++ b/tools/suite/00-head.js @@ -65,7 +65,14 @@ // ---------- which helpers are on ---------- const SUITE_KEY = 'idleon_suite'; const ALL_ON = { clicker: true, hoops: true, fishing: true, darts: true }; - const suite = Object.assign({ collapsed: false, hidden: false }, + // layout: 'free' keeps the dragged-anywhere behaviour every version until now + // had, and stays the default so an upgrade moves nobody's panels. 'left' and + // 'top' dock them into one column or one row. + // solo: opening a helper closes the other helpers. Only meaningful docked, + // where they share a column; see the collapse handler. + // follow: opt in to letting the active minigame open its own helper. + const suite = Object.assign({ collapsed: false, hidden: false, + layout: 'free', solo: true, follow: false }, JSON.parse(localStorage.getItem(SUITE_KEY) || '{}')); suite.enabled = Object.assign({}, ALL_ON, suite.enabled); const saveSuite = () => localStorage.setItem(SUITE_KEY, JSON.stringify(suite)); @@ -130,6 +137,87 @@ return img; } + // ---------- docked layouts ---------- + // Five panels is a lot of furniture to arrange by hand every session, and + // only one helper is ever useful at a time — you are in exactly one minigame. + // Docking stacks them against an edge in a fixed order and takes over their + // positions; the saved px/py are left untouched so switching back to 'free' + // restores exactly where things were. + const docks = []; // { def, ui }, sorted by def.dockOrder + const DOCK_EDGE = 10, DOCK_GAP = 8; + let relayoutPending = false; + + function relayout() { + if (suite.layout === 'free') { + for (const d of docks) d.ui.place(); + return; + } + const vert = suite.layout === 'left'; + // ?? not ||: the hub is dockOrder 0, which || would treat as missing and + // sort to the bottom of its own dock. + const list = docks.slice().sort((a, b) => (a.def.dockOrder ?? 99) - (b.def.dockOrder ?? 99)); + // A run wraps rather than running off the edge. Five panels do not fit + // across a half-width window, and with several expanded they do not fit + // down a short one either — and a panel past the edge is the exact trap the + // clamping in place() exists to avoid: unreachable, and unreachable means + // undraggable, so there is no way back to it. + // + // `run` is the thickness of the current row (or column): the tallest panel + // in a row, the widest in a column, which is what the next one has to clear. + // The first panel of a run never wraps — if one panel is bigger than the + // whole viewport there is nowhere better for it, and wrapping on it would + // spin. + let x = DOCK_EDGE, y = DOCK_EDGE, run = 0; + for (const { ui } of list) { + if (ui.cfg.hidden) continue; // hidden panels are a nub, not a slot + const p = ui.panel; + p.style.right = 'auto'; + // Measured before placing: the width is pinned by style.width so the + // height does not depend on where it ends up, and the wrap has to be + // decided before the position is written. + const r = p.getBoundingClientRect(); + if (vert) { + if (y > DOCK_EDGE && y + r.height > window.innerHeight - DOCK_EDGE) { + x += run + DOCK_GAP; y = DOCK_EDGE; run = 0; + } + } else if (x > DOCK_EDGE && x + r.width > window.innerWidth - DOCK_EDGE) { + y += run + DOCK_GAP; x = DOCK_EDGE; run = 0; + } + p.style.left = x + 'px'; + p.style.top = y + 'px'; + if (vert) { y += r.height + DOCK_GAP; run = Math.max(run, r.width); } + else { x += r.width + DOCK_GAP; run = Math.max(run, r.height); } + } + } + + // The hub owns the layout controls, but a drag out of a dock has to change + // the layout from inside makePanel. This is the seam between the two. + let onLayoutChange = () => {}; + // Solo has to be an invariant, not just something the collapse button does. + // Arriving in a dock with four helpers already open gives a column that needs + // two of them to fit — which is the exact thing the dock is for avoiding. So + // entering a docked layout closes all but the first open helper. + function enforceSolo() { + if (!suite.solo || suite.layout === 'free') return; + let kept = false; + const list = docks.slice().sort((a, b) => (a.def.dockOrder ?? 99) - (b.def.dockOrder ?? 99)); + for (const d of list) { + if (!d.def.helper || d.ui.cfg.collapsed) continue; + if (!kept) { kept = true; continue; } // the first open one stays open + d.ui.cfg.collapsed = true; d.ui.save(); d.ui.chrome(); + } + } + function syncLayout() { enforceSolo(); relayout(); onLayoutChange(); } + + // Collapsing a panel changes every panel below it, so the re-stack is + // coalesced to one pass per frame rather than run per panel per change. + function relayoutSoon() { + if (relayoutPending) return; + relayoutPending = true; + requestAnimationFrame(() => { relayoutPending = false; relayout(); }); + } + window.addEventListener('resize', relayoutSoon); + // ---------- panel chrome ---------- // Every panel is the same furniture around a different body: a title bar // that drags, a roll-up toggle, a hide toggle, and a nub that brings a @@ -245,12 +333,17 @@ minBtn.textContent = cfg.collapsed ? '+' : '–'; panel.style.display = cfg.hidden ? 'none' : ''; nub.style.display = cfg.hidden ? '' : 'none'; + relayoutSoon(); // heights and occupancy just changed } // drag let dx = 0, dy = 0, drag = false; $('#hd').addEventListener('mousedown', e => { if (e.target.id === 'min') return; + // Dragging out of a dock means you want it somewhere else, so the dock + // gets out of the way rather than snapping the panel back and looking + // broken. "Reset panel layout" puts it back. + if (suite.layout !== 'free') { suite.layout = 'free'; saveSuite(); syncLayout(); } drag = true; const r = panel.getBoundingClientRect(); dx = e.clientX - r.left; dy = e.clientY - r.top; @@ -278,6 +371,7 @@ ui.save(); place(); chrome(); }, dot: $('#dot'), runBtn: $('#run'), stEl: $('#st'), nub, minBtn, body, + place, // so a dock can hand positions back on the way out save: () => {}, // replaced by the module, which owns its store // Keep every control out of the tab order and drop focus as soon as it // is released, so a Space or Enter aimed at the game can't re-fire @@ -296,10 +390,28 @@ for (const [tg, ty, fn, cap] of bound) tg.removeEventListener(ty, fn, cap); roots.delete(root); host.remove(); + const i = docks.findIndex(d => d.ui === ui); + if (i >= 0) docks.splice(i, 1); + relayoutSoon(); } }; - minBtn.addEventListener('click', () => { cfg.collapsed = !cfg.collapsed; ui.save(); chrome(); }); + docks.push({ def, ui }); + minBtn.addEventListener('click', () => { + cfg.collapsed = !cfg.collapsed; + ui.save(); + // Solo closes the other HELPERS when you open one — not the clicker, + // which is useful alongside any of them, and not the suite panel. Only + // while docked: in the free layout the panels are wherever you put them + // and collapsing one you never touched would just look like a bug. + if (!cfg.collapsed && suite.solo && suite.layout !== 'free' && def.helper) { + for (const d of docks) { + if (d.ui === ui || !d.def.helper || d.ui.cfg.collapsed) continue; + d.ui.cfg.collapsed = true; d.ui.save(); d.ui.chrome(); + } + } + chrome(); + }); nub.addEventListener('click', () => { cfg.hidden = false; ui.save(); chrome(); }); return ui; } diff --git a/tools/suite/01-clicker.js b/tools/suite/01-clicker.js index fbf1819..3001e6f 100644 --- a/tools/suite/01-clicker.js +++ b/tools/suite/01-clicker.js @@ -15,6 +15,7 @@ z: 2147483646, theme: { dot: '#4ade80', ac: '#2563eb', stop: '#dc2626' }, slot: { top: 12, right: 12, width: 210, nub: 24 }, + dockOrder: 1, overlay: false, hotkeys: { F8: 'toggle', F9: 'panic', F10: 'hide' }, keyHint: 'F8', @@ -38,13 +39,23 @@ xyEl = $('#xy'), setBtn = $('#set'); let on = false, timer = null, capturing = false; - let lastX = 0, lastY = 0; - ui.on(document, 'mousemove', e => { lastX = e.clientX; lastY = e.clientY; }, true); + // lastX/lastY only move while the pointer is over THIS window, so in a + // second window they go stale on the way out and are 0,0 before it has + // ever arrived. See the standalone clicker for the whole story; ptrIn is + // what says whether the coordinates mean anything. + let lastX = 0, lastY = 0, ptrIn = false, wasBlind = false; + ui.on(document, 'mousemove', e => { + lastX = e.clientX; lastY = e.clientY; ptrIn = true; + }, true); + // A null relatedTarget is the pointer leaving the document altogether; + // leaving for a panel names that element instead and does not count. + ui.on(document, 'mouseout', e => { if (!e.relatedTarget) ptrIn = false; }, true); function sync() { ivMinEl.value = cfg.ivMin; ivMaxEl.value = cfg.ivMax; jpEl.value = cfg.jitterPx; root.querySelectorAll('.seg button').forEach(b => b.classList.toggle('sel', b.dataset.m === cfg.mode)); - xyEl.textContent = cfg.mode !== 'fixed' ? '(follows cursor)' + xyEl.textContent = cfg.mode !== 'fixed' + ? (ptrIn ? '(follows cursor)' : 'cursor is in another window') : hasTarget() ? fixedPoint().map(Math.round).join(', ') : 'not set'; dot.classList.toggle('on', on); runBtn.textContent = on ? 'Stop (F8)' : 'Start (F8)'; diff --git a/tools/suite/02-hoops-post.js b/tools/suite/02-hoops-post.js index 4ab388b..a61d010 100644 --- a/tools/suite/02-hoops-post.js +++ b/tools/suite/02-hoops-post.js @@ -1,4 +1,7 @@ - return { loop, sync, toggle }; + // For the suite's auto-open: the platform is found every frame the court is up. + // Reusing the loop's own state rather than testing the screen again -- + // a second detector here would be one more thing to drift. + return { loop, sync, toggle, active: () => plat != null }; } }; diff --git a/tools/suite/02-hoops-pre.js b/tools/suite/02-hoops-pre.js index 9522e1f..ce8b1b8 100644 --- a/tools/suite/02-hoops-pre.js +++ b/tools/suite/02-hoops-pre.js @@ -14,6 +14,7 @@ z: 2147483645, theme: { dot: '#f87171', ac: '#dc2626' }, slot: { top: 12, left: 220, width: 228, nub: 42 }, + dockOrder: 2, helper: true, overlay: true, hotkeys: { F7: 'toggle', F6: 'hide' }, keyHint: 'F7', diff --git a/tools/suite/03-fish-post.js b/tools/suite/03-fish-post.js index 4ab388b..ce20310 100644 --- a/tools/suite/03-fish-post.js +++ b/tools/suite/03-fish-post.js @@ -1,4 +1,7 @@ - return { loop, sync, toggle }; + // For the suite's auto-open: the lane goes null the moment the fishing spot is off screen. + // Reusing the loop's own state rather than testing the screen again -- + // a second detector here would be one more thing to drift. + return { loop, sync, toggle, active: () => lane != null }; } }; diff --git a/tools/suite/03-fish-pre.js b/tools/suite/03-fish-pre.js index 3af108b..558bbce 100644 --- a/tools/suite/03-fish-pre.js +++ b/tools/suite/03-fish-pre.js @@ -14,6 +14,7 @@ z: 2147483644, theme: { dot: '#38bdf8', ac: '#0284c7' }, slot: { top: 12, left: 460, width: 214, nub: 60 }, + dockOrder: 3, helper: true, overlay: true, hotkeys: { F4: 'toggle', F3: 'hide' }, keyHint: 'F4', diff --git a/tools/suite/04-darts-post.js b/tools/suite/04-darts-post.js index 4ab388b..3b5636e 100644 --- a/tools/suite/04-darts-post.js +++ b/tools/suite/04-darts-post.js @@ -1,4 +1,7 @@ - return { loop, sync, toggle }; + // For the suite's auto-open: the board is nulled by the wall gate and after 900ms stale. + // Reusing the loop's own state rather than testing the screen again -- + // a second detector here would be one more thing to drift. + return { loop, sync, toggle, active: () => board != null }; } }; diff --git a/tools/suite/04-darts-pre.js b/tools/suite/04-darts-pre.js index 336d6a4..9b880b6 100644 --- a/tools/suite/04-darts-pre.js +++ b/tools/suite/04-darts-pre.js @@ -14,6 +14,7 @@ z: 2147483643, theme: { dot: '#fbbf24', ac: '#d97706' }, slot: { top: 12, left: 686, width: 216, nub: 78 }, + dockOrder: 4, helper: true, overlay: true, hotkeys: { F2: 'toggle', F1: 'hide' }, keyHint: 'F2', diff --git a/tools/suite/05-hub.js b/tools/suite/05-hub.js index af54dea..12e471b 100644 --- a/tools/suite/05-hub.js +++ b/tools/suite/05-hub.js @@ -9,6 +9,7 @@ z: 2147483647, theme: { dot: '#a78bfa', ac: '#7c3aed' }, slot: { top: 12, left: 12, width: 196, nub: 6 }, + dockOrder: 0, overlay: false, bodyHTML: // Each row: the helper's name, its toggle hotkey, an eye that shows or @@ -23,6 +24,14 @@ `` ).join('\n ') + `
+
+ + +
+
+
+
+
unticking a helper stops it:
no panel, no readback, no hotkey
` @@ -36,6 +45,7 @@ // "all hidden" drives the button's label, so it reads as the thing it is // about to do rather than as the state it is in. const anyShown = () => MODULES.some(m => live.has(m.id) && !m.cfg.hidden); + const anyOpen = () => MODULES.some(m => live.has(m.id) && !m.cfg.collapsed); function syncHub() { for (const m of MODULES) { @@ -46,6 +56,13 @@ eye.className = 'eye' + (off ? ' off' : ''); } hub.$('#panels').textContent = anyShown() ? 'Hide all panels' : 'Show all panels'; + hub.$('#rollup').textContent = anyOpen() ? 'Minimise all' : 'Expand all'; + for (const l of ['free', 'left', 'top']) + hub.$('#lay-' + l).classList.toggle('sel', suite.layout === l); + hub.$('#solo').checked = !!suite.solo; + hub.$('#follow').checked = !!suite.follow; + // Both only bite in a dock; saying so beats leaving them looking broken. + hub.$('#solo').disabled = hub.$('#follow').disabled = suite.layout === 'free'; hub.chrome(); } @@ -58,6 +75,26 @@ syncHub(); }; } + for (const l of ['free', 'left', 'top']) + hub.$('#lay-' + l).onclick = () => { suite.layout = l; saveSuite(); syncLayout(); }; + hub.$('#solo').onchange = e => { suite.solo = e.target.checked; saveSuite(); }; + hub.$('#follow').onchange = e => { suite.follow = e.target.checked; saveSuite(); }; + onLayoutChange = syncHub; + + // Rolls every helper up to its title bar without hiding it — the panels + // stay on screen and stay clickable, which is the difference from "Hide + // all panels". In a dock that is also how you get back to one short column + // after several have been opened. + hub.$('#rollup').onclick = () => { + const roll = anyOpen(); + for (const m of MODULES) { + m.cfg.collapsed = roll; m.save(); + const inst = live.get(m.id); + if (inst) inst.ui.chrome(); + } + syncHub(); + }; + hub.$('#panels').onclick = () => { const hide = anyShown(); for (const m of MODULES) { @@ -78,13 +115,47 @@ else { m.cfg.px = null; m.cfg.py = null; m.cfg.hidden = false; m.cfg.collapsed = false; m.save(); } } hub.reset(); + suite.layout = 'free'; saveSuite(); + syncLayout(); syncHub(); }; + // Opt-in: the helper whose minigame is on screen opens itself and the other + // helpers close. Driven off each helper's own detection -- the variable it + // already keeps for "I can see my minigame" -- so there is no second copy + // of any detector here to drift out of step. + // + // Only acts on a CHANGE of which helper is active, so a manual collapse is + // not immediately undone; and it does nothing until a helper has been + // active for a moment, because the detectors flicker while a screen loads + // and a layout that flickers with them is worse than one that lags. + let followWas = null, followSince = 0, followCand = null; + function followTick() { + if (!suite.follow || suite.layout === 'free') { followWas = null; return; } + let now = null; + for (const m of MODULES) { + const inst = live.get(m.id); + if (m.helper && inst && inst.active && inst.active()) { now = m.id; break; } + } + const t = performance.now(); + if (now !== followCand) { followCand = now; followSince = t; return; } + if (t - followSince < 600 || now === followWas) return; + followWas = now; + for (const m of MODULES) { + if (!m.helper) continue; + const inst = live.get(m.id); + if (!inst) continue; + const want = m.id === now; + if (m.cfg.collapsed !== !want) { m.cfg.collapsed = !want; m.save(); inst.ui.chrome(); } + } + } + for (const m of MODULES) if (suite.enabled[m.id]) startModule(m); hub.settle(); syncHub(); + syncLayout(); + setInterval(followTick, 250); requestAnimationFrame(driver); }