// scrubber.js export class SubtickScrubber { constructor(elementId, frame, diorama) { this.frame = frame; this.diorama = diorama; this.container = diorama.element; this.playMode = 'paused'; // 'paused' | 'realtime' | 'subtick' this.speed = 1.0; // Ticks per second scaling this.lastFrameTime = performance.now(); this.playbackAccumulator = 0; // Hook into the Diorama tap event we just created this.diorama.onTap = () => { if (this.playMode !== 'paused') this.playMode = 'paused'; else this.playMode = 'realtime'; this.syncUI(); }; this.buildUI(); this.frame.subscribe(() => this.syncUI()); // Start playback loop this._loop = this.loop.bind(this); requestAnimationFrame(this._loop); } buildUI() { this.container.style.cssText = ` font-family: monospace; background: #333; padding: 10px; border-radius: 4px; display: flex; flex-direction: column; gap: 8px; `; // Secondary Row (Subticks) this.subRow = document.createElement('div'); this.subRow.style.cssText = `display: flex; gap: 10px; align-items: center;`; this.subRow.innerHTML = `
`; // Primary Row (Ticks) this.tickRow = document.createElement('div'); this.tickRow.style.cssText = `display: flex; gap: 10px; align-items: center;`; // Determine absolute timeline boundaries const maxTime = this.frame.world.events.length > 0 ? this.frame.world.events[this.frame.world.events.length - 1].time : 0; this.tickRow.innerHTML = `
`; this.container.appendChild(this.subRow); this.container.appendChild(this.tickRow); this.bindEvents(); } bindEvents() { const getEl = id => this.container.querySelector('#' + id); // --- Primary Actions --- getEl('btn-play').onclick = () => { this.playMode = this.playMode === 'realtime' ? 'paused' : 'realtime'; this.syncUI(); }; getEl('btn-prev').onclick = () => { this.playMode = 'paused'; this.frame.seek(Math.floor(this.frame.currentTime - 1)); }; getEl('btn-next').onclick = () => { this.playMode = 'paused'; this.frame.seek(Math.floor(this.frame.currentTime + 1)); }; const tickSlider = getEl('slider-tick'); tickSlider.oninput = () => { this.playMode = 'paused'; this.frame.seek(parseFloat(tickSlider.value)); }; // --- Secondary Actions --- getEl('btn-play-sub').onclick = () => { this.playMode = this.playMode === 'subtick' ? 'paused' : 'subtick'; this.syncUI(); }; getEl('btn-prev-sub').onclick = () => { this.playMode = 'paused'; this.frame.seekIndex(this.frame.currentIndex - this.frame.world.initialIndex - 1); }; getEl('btn-next-sub').onclick = () => { this.playMode = 'paused'; this.frame.seekIndex(this.frame.currentIndex - this.frame.world.initialIndex + 1); }; const subSlider = getEl('slider-sub'); subSlider.oninput = () => { this.playMode = 'paused'; // Translate the localized sub-slider value back into an absolute global index const localValue = parseInt(subSlider.value); this.frame.seekIndex((this.currentTickStartIndex - this.frame.world.initialIndex) + localValue); }; } syncUI() { const getEl = id => this.container.querySelector('#' + id); getEl('btn-play').textContent = this.playMode === 'realtime' ? '||' : 'Play'; getEl('btn-play-sub').textContent = this.playMode === 'subtick' ? '||' : 'S-Play'; // Hide secondary scrubber in realtime mode this.subRow.style.display = this.playMode === 'realtime' ? 'none' : 'flex'; // Sync Primary Slider getEl('slider-tick').value = Math.max(0, this.frame.currentTime); // --- Calculate Localized Subtick Slider Bounds --- const events = this.frame.world.events; let tickStartIdx = 0; let tickEventCount = 0; // Find the absolute bounds of the current tick in the event array for (let i = 0; i < events.length; i++) { if (events[i].time === this.frame.currentTime) { if (tickEventCount === 0) tickStartIdx = i; tickEventCount++; } } this.currentTickStartIndex = tickStartIdx; const subSlider = getEl('slider-sub'); subSlider.max = tickEventCount; // Calculate where the cursor is relative to the start of this specific tick let localCursor = this.frame.currentIndex - tickStartIdx; if (localCursor < 0) localCursor = 0; if (localCursor > tickEventCount) localCursor = tickEventCount; subSlider.value = localCursor; } loop(time) { const dt = (time - this.lastFrameTime) / 1000; this.lastFrameTime = time; const maxTime = this.frame.world.events.length > 0 ? this.frame.world.events[this.frame.world.events.length - 1].time : 0; if (this.playMode === 'realtime') { // 20 ticks per second * speed multiplier this.playbackAccumulator += dt * 20 * this.speed; if (this.playbackAccumulator >= 1.0) { const ticksToAdvance = Math.floor(this.playbackAccumulator); this.playbackAccumulator -= ticksToAdvance; const nextTime = this.frame.currentTime + ticksToAdvance; if (nextTime > maxTime) { this.frame.seek(maxTime); this.playMode = 'paused'; // Auto-pause at end } else { this.frame.seek(nextTime); } } } else if (this.playMode === 'subtick') { // Slower playback for sequence animation (e.g., 4 events per second) this.playbackAccumulator += dt * 4 * this.speed; if (this.playbackAccumulator >= 1.0) { this.playbackAccumulator = 0; if (this.frame.currentIndex >= this.frame.world.events.length) { this.playMode = 'paused'; // Auto-pause at end } else { this.frame.seekIndex(this.frame.currentIndex - this.frame.world.initialIndex + 1); } } } else { this.playbackAccumulator = 0; // Reset accumulator when paused } requestAnimationFrame(this._loop); } }