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);
}