Idle animations. Closes #14
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
+34
-14
@@ -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<boolean>} 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();
|
||||
|
||||
@@ -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() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 12px;">
|
||||
<label class="form-label">Idle Animation</label>
|
||||
<select id="bc-idle-mode-select" class="form-input idle-mode-select" style="width:100%;">
|
||||
<option value="none">None</option>
|
||||
<option value="circles">Circles</option>
|
||||
<option value="erratic">Erratic</option>
|
||||
<option value="park-bounce">Park Bounce</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="flex:1; display:flex; flex-direction:column;">
|
||||
<label class="form-label">Available Spots</label>
|
||||
<select id="spot-list" size="10" class="form-input" style="flex:1; width: 100%; margin-bottom: 12px; font-family: monospace; font-size: 1rem; padding: 8px;">
|
||||
@@ -248,6 +259,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()}`;
|
||||
renderSpots();
|
||||
@@ -325,6 +337,15 @@ export default {
|
||||
updateHomingUI();
|
||||
loadSpots();
|
||||
|
||||
// ── Idle Effects Selection ──────────────────────────────────
|
||||
const idleSelect = document.getElementById('bc-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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -149,6 +150,16 @@ function buildHTML() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 12px;">
|
||||
<label class="form-label">Idle Animation</label>
|
||||
<select id="it-idle-mode-select" class="form-input idle-mode-select" style="width:100%;">
|
||||
<option value="none">None</option>
|
||||
<option value="circles">Circles</option>
|
||||
<option value="erratic">Erratic</option>
|
||||
<option value="park-bounce">Park Bounce</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div style="background: var(--surface-2); padding: 10px; border-radius: var(--radius-sm); margin-bottom: 12px;">
|
||||
<div class="setting-row">
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 12px;">
|
||||
<label class="form-label">Idle Animation</label>
|
||||
<select id="se-idle-mode-select" class="form-input idle-mode-select" style="width:100%;">
|
||||
<option value="none">None</option>
|
||||
<option value="circles">Circles</option>
|
||||
<option value="erratic">Erratic</option>
|
||||
<option value="park-bounce">Park Bounce</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--surface-2); padding: 10px; border-radius: var(--radius-sm);">
|
||||
<div class="setting-row">
|
||||
<label class="form-label" style="margin:0;">Speed</label>
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user