From dd8f6b227706f3df241cfbf12c76c2bd1e4fa9d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?PROFERIS=20-=20Mi=C2=B3osz=20Stocki?= Date: Thu, 9 Jul 2026 14:01:00 +0200 Subject: [PATCH] Idle animations. Closes #14 --- index.html | 1 + web/js/idle-effects.js | 146 +++++++++++++++++++++++++++++ web/js/motion.js | 48 +++++++--- web/js/sections/board-control.js | 21 +++++ web/js/sections/input-text.js | 21 +++++ web/js/sections/sequence-editor.js | 21 +++++ 6 files changed, 244 insertions(+), 14 deletions(-) create mode 100644 web/js/idle-effects.js diff --git a/index.html b/index.html index 8af4c97..eac1358 100644 --- a/index.html +++ b/index.html @@ -126,6 +126,7 @@ import BoardControlSection from './web/js/sections/board-control.js'; import InputTextSection from './web/js/sections/input-text.js'; import SequenceEditorSection from './web/js/sections/sequence-editor.js'; + import './web/js/idle-effects.js'; // ── Register routes ──────────────────────────────────────────── Router.register('home', HomeSection); diff --git a/web/js/idle-effects.js b/web/js/idle-effects.js new file mode 100644 index 0000000..cf02638 --- /dev/null +++ b/web/js/idle-effects.js @@ -0,0 +1,146 @@ +import IK from './kinematics.js'; +import Motion from './motion.js'; + +let activeMode = 'none'; // 'none', 'circles', 'erratic', 'park-bounce' +let parkPosition = { x: 0, y: 80 }; +let anchorPos = { x: 0, y: 80 }; +let timerId = null; +let currentAngle = 0; +let bounceState = 'to-park'; // 'to-park' or 'to-anchor' + +const IDLE_WAIT_MS = 5000; +const CONTINUOUS_WAIT_MS = 500; // 0.5s pause between idle movements +const RADIUS = 20; + +export const IdleEffects = { + setMode(mode) { + activeMode = mode || 'none'; + localStorage.setItem('wiji_idle_mode', activeMode); + + // Update all dropdowns in UI to keep them in sync + document.querySelectorAll('.idle-mode-select').forEach(el => { + if (el.value !== activeMode) el.value = activeMode; + }); + + if (activeMode === 'none') { + this.stopTimer(); + } else { + if (!Motion.isMoving && localStorage.getItem('wiji_homed') === 'true') { + this.updateAnchorPos(); + this.startTimer(IDLE_WAIT_MS); + } + } + }, + + getMode() { + return activeMode; + }, + + setParkPosition(x, y) { + parkPosition = { x, y }; + }, + + updateAnchorPos() { + const { steps1, steps2 } = Motion.getSteps(); + const pos = IK.forward(IK.stepsToRad(steps1), IK.stepsToRad(steps2)); + if (pos.valid) { + anchorPos = { x: pos.endX, y: pos.endY }; + currentAngle = 0; + bounceState = 'to-park'; + } + }, + + startTimer(ms) { + this.stopTimer(); + if (activeMode === 'none') return; + + timerId = setTimeout(() => { + this.triggerNextMove(); + }, ms); + }, + + stopTimer() { + if (timerId) { + clearTimeout(timerId); + timerId = null; + } + }, + + async triggerNextMove() { + if (activeMode === 'none' || localStorage.getItem('wiji_homed') !== 'true') return; + + let targetX = anchorPos.x; + let targetY = anchorPos.y; + let motionMode = 'direct'; + + if (activeMode === 'circles') { + currentAngle += Math.PI / 4; // 45 degrees + targetX = anchorPos.x + RADIUS * Math.cos(currentAngle); + targetY = anchorPos.y + RADIUS * Math.sin(currentAngle); + motionMode = 'direct'; + } else if (activeMode === 'erratic') { + const randAngle = Math.random() * Math.PI * 2; + const randR = Math.random() * RADIUS; + targetX = anchorPos.x + randR * Math.cos(randAngle); + targetY = anchorPos.y + randR * Math.sin(randAngle); + motionMode = 'erratic'; + } else if (activeMode === 'park-bounce') { + if (bounceState === 'to-park') { + targetX = parkPosition.x; + targetY = parkPosition.y; + bounceState = 'to-anchor'; + } else { + targetX = anchorPos.x; + targetY = anchorPos.y; + bounceState = 'to-park'; + } + motionMode = 'direct'; + } + + // Workspace check + const check = IK.checkWorkspace(targetX, targetY); + if (!check.ok) { + // If out of bounds, skip this move and wait for next + this.startTimer(CONTINUOUS_WAIT_MS); + return; + } + + // Fire the idle move! + try { + await Motion.goto(targetX, targetY, motionMode, true); + } catch(e) { + // If aborted, it's fine. + } + }, + + init() { + const saved = localStorage.getItem('wiji_idle_mode'); + if (saved) activeMode = saved; + + document.addEventListener('wiji:motion-start', (e) => { + const isIdleMove = e.detail && e.detail.isIdleMove; + if (!isIdleMove) { + this.stopTimer(); + } + }); + + document.addEventListener('wiji:motion-end', (e) => { + if (activeMode === 'none' || localStorage.getItem('wiji_homed') !== 'true') return; + + const isIdleMove = e.detail && e.detail.isIdleMove; + if (!isIdleMove) { + this.updateAnchorPos(); + this.startTimer(IDLE_WAIT_MS); + } else { + this.startTimer(CONTINUOUS_WAIT_MS); + } + }); + + document.addEventListener('wiji:unhomed', () => { + this.setMode('none'); + }); + } +}; + +// Auto-init globally +IdleEffects.init(); diff --git a/web/js/motion.js b/web/js/motion.js index 1c9dbe8..7a6869e 100644 --- a/web/js/motion.js +++ b/web/js/motion.js @@ -52,21 +52,39 @@ function getSteps() { let motionQueue = []; let isMoving = false; +let currentIsIdleMove = false; let currentResolve = null; let queueCheckInterval = null; let currentMotionMode = 'direct'; let hasHesitated = false; +function stopCurrentMove() { + motionQueue = []; + isMoving = false; + if (queueCheckInterval) { + clearInterval(queueCheckInterval); + queueCheckInterval = null; + } + if (currentResolve) { + currentResolve(false); // resolve false because aborted + currentResolve = null; + } +} + async function executeQueue() { if (motionQueue.length === 0) { isMoving = false; - if (queueCheckInterval) clearInterval(queueCheckInterval); + if (queueCheckInterval) { + clearInterval(queueCheckInterval); + queueCheckInterval = null; + } // Restore speed if it was altered await MotionEffects.onSequenceEnd(currentMotionMode); if (currentResolve) currentResolve(true); currentResolve = null; + document.dispatchEvent(new CustomEvent('wiji:motion-end', { detail: { isIdleMove: currentIsIdleMove } })); return; } @@ -83,11 +101,8 @@ async function executeQueue() { await BLE.write(cmd); } catch (e) { UI.log(`BLE error: ${e.message}`, 'error'); - motionQueue = []; // clear queue on error - isMoving = false; - if (queueCheckInterval) clearInterval(queueCheckInterval); - if (currentResolve) currentResolve(false); - currentResolve = null; + stopCurrentMove(); + document.dispatchEvent(new CustomEvent('wiji:motion-end', { detail: { isIdleMove: currentIsIdleMove } })); return; } @@ -102,12 +117,9 @@ async function executeQueue() { // Check for timeout stall if (Date.now() - waypointStartTime > CONFIG.WAYPOINT_TIMEOUT_MS) { - clearInterval(queueCheckInterval); - motionQueue = []; - isMoving = false; + stopCurrentMove(); forceUnhome('Motion timeout! Arms may be stuck. Rehoming required.'); - if (currentResolve) currentResolve(false); - currentResolve = null; + document.dispatchEvent(new CustomEvent('wiji:motion-end', { detail: { isIdleMove: currentIsIdleMove } })); return; } @@ -222,12 +234,18 @@ function buildSegments(startX, startY, endX, endY, mode) { * Dynamically slices the path into segments to prevent out-of-bounds arcs. * @returns {Promise} Resolves to true if successful, false if aborted/error. */ -async function goto(targetX, targetY, mode = 'direct') { +async function goto(targetX, targetY, mode = 'direct', isIdleMove = false) { if (isMoving) { - UI.log('Arm is currently moving! Wait for it to finish.', 'warn'); - return false; + // If the currently executing move is an idle move, and this is a user move, we can interrupt it. + if (currentIsIdleMove && !isIdleMove) { + stopCurrentMove(); // Abort the idle move + } else { + UI.log('Arm is currently moving! Wait for it to finish.', 'warn'); + return false; + } } + currentIsIdleMove = isIdleMove; mode = MotionEffects.resolveMode(mode); currentMotionMode = mode; @@ -283,6 +301,8 @@ async function goto(targetX, targetY, mode = 'direct') { await MotionEffects.onSequenceStart(mode); + document.dispatchEvent(new CustomEvent('wiji:motion-start', { detail: { isIdleMove: currentIsIdleMove } })); + return new Promise((resolve) => { currentResolve = resolve; executeQueue(); diff --git a/web/js/sections/board-control.js b/web/js/sections/board-control.js index ea0f990..7c12057 100644 --- a/web/js/sections/board-control.js +++ b/web/js/sections/board-control.js @@ -4,6 +4,7 @@ import IK from '../kinematics.js'; import Settings from '../settings.js'; import Motion from '../motion.js'; import { buildQuickSequencesHTML, initQuickSequences } from '../quick-sequences.js'; +import { IdleEffects } from '../idle-effects.js'; let eventCleanup = []; let isHomed = false; @@ -161,6 +162,16 @@ function buildHTML() { +
+ + +
+
+
+ + +
+
@@ -266,6 +277,7 @@ async function loadSpots() { if (!parsedSpots.find(s => s.id === 'PARK')) { parsedSpots.push({ id: 'PARK', label: 'Park Position', x: parkPosition.x, y: parkPosition.y }); } + IdleEffects.setParkPosition(parkPosition.x, parkPosition.y); spots = parsedSpots; document.getElementById('bg-image').src = `web/assets/backgrounds/${currentBg}/bg.svg?v=${Date.now()}`; @@ -355,6 +367,15 @@ export default { updateHomingUI(); loadSpots(); + // ── Idle Effects Selection ────────────────────────────────── + const idleSelect = document.getElementById('it-idle-mode-select'); + if (idleSelect) { + idleSelect.value = IdleEffects.getMode(); + idleSelect.addEventListener('change', () => { + IdleEffects.setMode(idleSelect.value); + }); + } + // ── Background Selection ────────────────────────────────── const bgSelect = document.getElementById('bg-select'); if (bgSelect) { diff --git a/web/js/sections/sequence-editor.js b/web/js/sections/sequence-editor.js index a1ac1c3..e9a2555 100644 --- a/web/js/sections/sequence-editor.js +++ b/web/js/sections/sequence-editor.js @@ -4,6 +4,7 @@ import IK from '../kinematics.js'; import Settings from '../settings.js'; import SequenceRunner from '../sequence-runner.js'; import Motion from '../motion.js'; +import { IdleEffects } from '../idle-effects.js'; let eventCleanup = []; let isHomed = false; @@ -282,6 +283,16 @@ function buildHTML() {
+
+ + +
+
@@ -432,6 +443,7 @@ async function loadSpots() { if (!parsedSpots.find(s => s.id === 'PARK')) { parsedSpots.push({ id: 'PARK', label: 'Park Position', x: parkPosition.x, y: parkPosition.y }); } + IdleEffects.setParkPosition(parkPosition.x, parkPosition.y); spots = parsedSpots; document.getElementById('text-input-row').style.display = hasAlphabet ? 'grid' : 'none'; @@ -578,6 +590,15 @@ export default { }); } + // ── Idle Effects Selection ────────────────────────────────── + const idleSelect = document.getElementById('se-idle-mode-select'); + if (idleSelect) { + idleSelect.value = IdleEffects.getMode(); + idleSelect.addEventListener('change', () => { + IdleEffects.setMode(idleSelect.value); + }); + } + // ── Settings Sliders ────────────────────────────────────── const sSpeed = document.getElementById('slider-speed'); const sAccel = document.getElementById('slider-accel');