diff --git a/index.html b/index.html index c24680cf..8b85d450 100644 --- a/index.html +++ b/index.html @@ -117,6 +117,7 @@ p 0 0 0 power=15 t 6 p 1 0 0 powered=true + l begin-extend t 7 p 2 0 0 extended=true p 4.0 0 0 air & @@ -135,10 +136,12 @@ p 3.0 0 0 piston_head facing=east short=false type=sticky p 2.5 -1 0 air & p 3.0 -1 0 piston_head facing=east short=false type=normal + l end-extend t 12 p 0 0 0 power=0 t 16 p 1 0 0 powered=false + l begin-retract t 17 p 3 0 0 air & p 2.5 0 0 piston_head facing=east short=true type=sticky @@ -157,6 +160,7 @@ p 2 0 0 extended=false p 2.5 -1 0 air p 2 -1 0 extended=false + l end-retract `); const f_piston_t = new WorldFrame(w_piston); const d_piston_t = new Diorama('demo-piston', f_piston_t, () => engine.requestRender()); @@ -165,7 +169,7 @@ d_piston_t.saveState(); engine.addDiorama(d_piston_t); - const s_piston_t = new SubtickScrubber('demo-piston', f_piston_t, d_piston_t) + const s_piston_t = new SubtickScrubber(d_piston_t) const c_piston = new CameraController(d_piston_t) // Fetch and build everything concurrently! diff --git a/scrubber.js b/scrubber.js index c0e66899..854b9137 100644 --- a/scrubber.js +++ b/scrubber.js @@ -1,99 +1,196 @@ // scrubber.js -export class SubtickScrubber { - constructor(elementId, frame, diorama) { - this.frame = frame; - this.diorama = diorama; - this.container = diorama.element; +const SCRUBBER_CSS = ` + .visualizer-ui { font-family: sans-serif; background: #222; padding: 12px; border-radius: 6px; display: flex; flex-direction: column; gap: 12px; color: white; user-select: none; } + .scrubber-row { display: flex; gap: 12px; align-items: center; } + .btn { background: #444; border: 1px solid #666; color: white; padding: 4px 8px; border-radius: 4px; cursor: pointer; min-width: 4ch; text-align: center; font-weight: bold; transition: background 0.1s; } + .btn:hover { background: #555; } + + .track-container { flex-grow: 1; height: 24px; padding: 0 10px; cursor: pointer; display: flex; align-items: center; } + .track-inner { position: relative; width: 100%; height: 100%; pointer-events: none; } + + .track-bg { position: absolute; left: -10px; right: -10px; top: 8px; height: 8px; background: rgba(255,255,255,0.2); border-radius: 4px; } + .track-fill { position: absolute; left: -10px; top: 8px; height: 8px; background: #f00; width: 10px; border-radius: 4px; } + .track-handle { position: absolute; width: 16px; height: 16px; background: #fff; border-radius: 50%; top: 12px; transform: translate(-50%, -50%); z-index: 3; } + + .marker { position: absolute; top: 8px; height: 8px; transform: translateX(-50%); pointer-events: none; z-index: 1; } + .marker-event { width: 2px; background: rgba(255,255,255,0.5); } + .marker-label { width: 4px; background: gold; z-index: 2; } +`; - this.playMode = 'paused'; // 'paused' | 'realtime' | 'subtick' - this.speed = 1.0; // Ticks per second scaling +export class SubtickScrubber { + constructor(diorama) { + this.container = diorama.element; + this.frame = diorama.frame; + + this.playMode = 'paused'; + this.speed = 0.5; + this.isLooping = false; 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(); - }; + if (!document.getElementById('scrubber-styles')) { + const style = document.createElement('style'); + style.id = 'scrubber-styles'; + style.textContent = SCRUBBER_CSS; + document.head.appendChild(style); + } 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; - `; + this.container.className = 'visualizer-ui'; - // Secondary Row (Subticks) this.subRow = document.createElement('div'); - this.subRow.style.cssText = `display: flex; gap: 10px; align-items: center;`; + this.subRow.className = 'scrubber-row'; this.subRow.innerHTML = ` - - -
- +
S>
+
<
+
+
+
+
+
+
+
- +
>
`; - // 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.className = 'scrubber-row'; this.tickRow.innerHTML = ` - - -
- +
R>
+
Loop
+
<<
+
+
+
+
+
+
+
- +
>>
`; this.container.appendChild(this.subRow); this.container.appendChild(this.tickRow); this.bindEvents(); + this.generateTickMarkers(); + this.syncUI(); + } + + generateTickMarkers() { + const markerContainer = this.container.querySelector('#markers-tick'); + const maxTime = this.getMaxTime(); + if (maxTime <= 0) return; + + const labeledTimes = new Set(); + for (const idx of this.frame.world.labels.values()) { + if (idx < this.frame.world.events.length) { + labeledTimes.add(this.frame.world.events[idx].time); + } + } + + for (const t of labeledTimes) { + if (t < 0) continue; + const pct = (t / maxTime) * 100; + const marker = document.createElement('div'); + marker.className = 'marker marker-label'; + marker.style.left = `${pct}%`; // Perfectly aligns relative to the inner padded box + markerContainer.appendChild(marker); + } + } + + getMaxTime() { + const evs = this.frame.world.events; + return evs.length > 0 ? evs[evs.length - 1].time : 0; + } + + setupDraggableTrack(containerId, innerId, onDrag) { + const wrapper = this.container.querySelector('#' + containerId); + const inner = this.container.querySelector('#' + innerId); + let isDragging = false; + + const update = (e) => { + const rect = inner.getBoundingClientRect(); + // Pct is calculated purely against the interior safe-zone + let pct = (e.clientX - rect.left) / rect.width; + pct = Math.max(0, Math.min(1, pct)); + onDrag(pct); + }; + + wrapper.addEventListener('mousedown', (e) => { + isDragging = true; + this.playMode = 'paused'; + update(e); + }); + + window.addEventListener('mousemove', (e) => { + if (isDragging) update(e); + }); + + window.addEventListener('mouseup', () => { + isDragging = false; + }); } bindEvents() { const getEl = id => this.container.querySelector('#' + id); - // --- Primary Actions --- + getEl('btn-loop').onclick = () => { + this.isLooping = !this.isLooping; + this.syncUI(); + }; + + // -- Primary Playback -- getEl('btn-play').onclick = () => { - this.playMode = this.playMode === 'realtime' ? 'paused' : 'realtime'; + if (this.playMode === 'realtime') { + this.playMode = 'paused'; + } else { + if (this.frame.currentTime >= this.getMaxTime() || this.frame.currentTime < 0) { + this.frame.seek(0); + } + this.playMode = 'realtime'; + } this.syncUI(); }; getEl('btn-prev').onclick = () => { this.playMode = 'paused'; - this.frame.seek(Math.floor(this.frame.currentTime - 1)); + let t = Math.floor(this.frame.currentTime - 1); + if (t < 0) t = this.isLooping ? Math.floor(this.getMaxTime()) : 0; + + this.frame.seek(t); + this.syncUI(); }; getEl('btn-next').onclick = () => { this.playMode = 'paused'; - this.frame.seek(Math.floor(this.frame.currentTime + 1)); + let t = Math.floor(this.frame.currentTime + 1); + const maxT = this.getMaxTime(); + if (t > maxT) t = this.isLooping ? 0 : maxT; + + this.frame.seek(t); + this.syncUI(); }; - const tickSlider = getEl('slider-tick'); - tickSlider.oninput = () => { - this.playMode = 'paused'; - this.frame.seek(parseFloat(tickSlider.value)); - }; + this.setupDraggableTrack('track-tick-container', 'track-tick-inner', (pct) => { + // Force integral tick seeking + const targetTime = Math.round(pct * this.getMaxTime()); + this.frame.seek(targetTime); + this.syncUI(); + }); - // --- Secondary Actions --- + // -- Secondary Playback -- getEl('btn-play-sub').onclick = () => { this.playMode = this.playMode === 'subtick' ? 'paused' : 'subtick'; this.syncUI(); @@ -101,98 +198,153 @@ export class SubtickScrubber { getEl('btn-prev-sub').onclick = () => { this.playMode = 'paused'; - this.frame.seekIndex(this.frame.currentIndex - this.frame.world.initialIndex - 1); + let idx = this.frame.currentIndex - 1; + if (idx < this.frame.world.initialIndex) { + idx = this.isLooping ? this.frame.world.events.length : this.frame.world.initialIndex; + } + + this.frame.seekIndex(idx); + this.syncUI(); }; getEl('btn-next-sub').onclick = () => { this.playMode = 'paused'; - this.frame.seekIndex(this.frame.currentIndex - this.frame.world.initialIndex + 1); + let idx = this.frame.currentIndex + 1; + if (idx > this.frame.world.events.length) { + idx = this.isLooping ? this.frame.world.initialIndex : this.frame.world.events.length; + } + + this.frame.seekIndex(idx); + this.syncUI(); }; - 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); - }; + this.setupDraggableTrack('track-sub-container', 'track-sub-inner', (pct) => { + if (this.currentTickEventCount === 0) return; + const targetLocalIdx = Math.round(pct * this.currentTickEventCount); + + // LOCK TIME: Dragging to the end of the subtick track completes the tick. + // If we don't lock currentTime, it jumps to the next tick instantly, breaking the UI drag loop. + const lockTime = this.frame.currentTime; + this.frame.seekIndex(this.currentTickStartIndex + targetLocalIdx); + this.frame.currentTime = lockTime; + + this.syncUI(); + }); } 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 + getEl('btn-play').textContent = this.playMode === 'realtime' ? '||' : 'R>'; + getEl('btn-play-sub').textContent = this.playMode === 'subtick' ? '||' : 'S>'; + + getEl('btn-loop').style.background = this.isLooping ? '#4a4' : '#444'; + getEl('btn-loop').style.borderColor = this.isLooping ? '#6c6' : '#666'; + this.subRow.style.display = this.playMode === 'realtime' ? 'none' : 'flex'; - // Sync Primary Slider - getEl('slider-tick').value = Math.max(0, this.frame.currentTime); + // --- Sync Primary Slider --- + const maxTime = this.getMaxTime(); + const displayTime = Math.max(0, this.frame.currentTime); + const tickPct = maxTime > 0 ? (displayTime / maxTime) * 100 : 0; - // --- Calculate Localized Subtick Slider Bounds --- + // Notice the exact CSS math mapping the width against the safe bounds + getEl('fill-tick').style.width = `calc(${tickPct}% + 10px)`; + getEl('handle-tick').style.left = `${tickPct}%`; + + // --- Sync Secondary Slider --- const events = this.frame.world.events; - let tickStartIdx = 0; - let tickEventCount = 0; + let startIdx = 0, count = 0; + + // Floor the current time so smooth interpolation doesn't lose the integer tick + const currentIntTick = Math.floor(this.frame.currentTime); - // 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++; + if (events[i].time === currentIntTick) { + if (count === 0) startIdx = i; + count++; } } - this.currentTickStartIndex = tickStartIdx; - const subSlider = getEl('slider-sub'); - subSlider.max = tickEventCount; + this.currentTickStartIndex = startIdx; + this.currentTickEventCount = count; - // 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; + const markerContainer = getEl('markers-sub'); + markerContainer.innerHTML = ''; + + if (count > 0) { + const labeledIndices = new Set(this.frame.world.labels.values()); + + for (let i = 0; i <= count; i++) { + const absIdx = startIdx + i; + const pct = (i / count) * 100; + + const marker = document.createElement('div'); + marker.className = 'marker'; + marker.style.left = `${pct}%`; + + if (labeledIndices.has(absIdx)) marker.classList.add('marker-label'); + else marker.classList.add('marker-event'); + + markerContainer.appendChild(marker); + } + + let localCursor = this.frame.currentIndex - startIdx; + localCursor = Math.max(0, Math.min(count, localCursor)); + + const subPct = (localCursor / count) * 100; + + getEl('fill-sub').style.width = `calc(${subPct}% + 10px)`; + getEl('handle-sub').style.left = `${subPct}%`; + getEl('handle-sub').style.display = 'block'; + } else { + getEl('fill-sub').style.width = `0px`; + getEl('handle-sub').style.display = 'none'; + } } 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; + const maxTime = this.getMaxTime(); 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 { + let nextTime = this.frame.currentTime + dt * 20 * this.speed; + + if (nextTime >= maxTime) { + if (this.isLooping) { + nextTime = nextTime % maxTime; 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); + this.frame.seek(maxTime); + this.playMode = 'paused'; + } + } else { + this.frame.seek(nextTime); + } + this.syncUI(); + + } else if (this.playMode === 'subtick') { + this.playbackAccumulator += dt * 4 * this.speed; + if (this.playbackAccumulator >= 1.0) { + const steps = Math.floor(this.playbackAccumulator); + this.playbackAccumulator -= steps; + + const nextIdx = this.frame.currentIndex + steps; + if (nextIdx > this.frame.world.events.length) { + if (this.isLooping) { + this.frame.seekIndex(this.frame.world.initialIndex); + } else { + this.frame.seekIndex(this.frame.world.events.length); + this.playMode = 'paused'; + } + } else { + this.frame.seekIndex(nextIdx); } } + this.syncUI(); } else { - this.playbackAccumulator = 0; // Reset accumulator when paused + this.playbackAccumulator = 0; } requestAnimationFrame(this._loop); diff --git a/world.js b/world.js index c0877594..51eb0a6c 100644 --- a/world.js +++ b/world.js @@ -15,7 +15,6 @@ export class World { this.labels = new Map(); this.initialState = new Map(); this.initialIndex = 0; - this.eventCount = 0; if (script) { this.#compile(script); @@ -123,8 +122,6 @@ export class World { } if (!capturedInitialState) captureInitial(); - - this.eventCount = this.events.length - this.initialIndex; } } @@ -170,13 +167,13 @@ export class WorldFrame { if (typeof target === 'string') { const absoluteIdx = this.world.labels.get(target); if (absoluteIdx === undefined) throw new Error(`Label not found: ${target}`); - this.seekIndex(absoluteIdx - this.world.initialIndex); + this.seekIndex(absoluteIdx); return; } let changed = false; - while (this.currentIndex < this.world.events.length && this.world.events[this.currentIndex].time <= targetTime) { + while (this.currentIndex < this.world.events.length && this.world.events[this.currentIndex].time < targetTime) { const batch = this.world.events[this.currentIndex]; for (const action of batch.actions) { this.#apply(action.next, action.x, action.y, action.z); @@ -185,7 +182,7 @@ export class WorldFrame { changed = true; } - while (this.currentIndex > 0 && this.world.events[this.currentIndex - 1].time > targetTime) { + while (this.currentIndex > this.world.initialIndex && this.world.events[this.currentIndex - 1].time >= targetTime) { this.currentIndex--; const batch = this.world.events[this.currentIndex]; for (let i = batch.actions.length - 1; i >= 0; i--) { @@ -199,8 +196,8 @@ export class WorldFrame { if (changed) this.notify(); } - seekIndex(targetIndex) { - targetIndex = targetIndex + this.world.initialIndex; + seekIndex(absoluteIndex) { + let targetIndex = Math.max(this.world.initialIndex, Math.min(this.world.events.length, absoluteIndex)); let changed = false; while (this.currentIndex < this.world.events.length && this.currentIndex < targetIndex) {