`;
- // 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) {