progress on scrubbers. pending tick boundary handling fix
This commit is contained in:
@@ -117,6 +117,7 @@
|
|||||||
p 0 0 0 power=15
|
p 0 0 0 power=15
|
||||||
t 6
|
t 6
|
||||||
p 1 0 0 powered=true
|
p 1 0 0 powered=true
|
||||||
|
l begin-extend
|
||||||
t 7
|
t 7
|
||||||
p 2 0 0 extended=true
|
p 2 0 0 extended=true
|
||||||
p 4.0 0 0 air &
|
p 4.0 0 0 air &
|
||||||
@@ -135,10 +136,12 @@
|
|||||||
p 3.0 0 0 piston_head facing=east short=false type=sticky
|
p 3.0 0 0 piston_head facing=east short=false type=sticky
|
||||||
p 2.5 -1 0 air &
|
p 2.5 -1 0 air &
|
||||||
p 3.0 -1 0 piston_head facing=east short=false type=normal
|
p 3.0 -1 0 piston_head facing=east short=false type=normal
|
||||||
|
l end-extend
|
||||||
t 12
|
t 12
|
||||||
p 0 0 0 power=0
|
p 0 0 0 power=0
|
||||||
t 16
|
t 16
|
||||||
p 1 0 0 powered=false
|
p 1 0 0 powered=false
|
||||||
|
l begin-retract
|
||||||
t 17
|
t 17
|
||||||
p 3 0 0 air &
|
p 3 0 0 air &
|
||||||
p 2.5 0 0 piston_head facing=east short=true type=sticky
|
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 0 0 extended=false
|
||||||
p 2.5 -1 0 air
|
p 2.5 -1 0 air
|
||||||
p 2 -1 0 extended=false
|
p 2 -1 0 extended=false
|
||||||
|
l end-retract
|
||||||
`);
|
`);
|
||||||
const f_piston_t = new WorldFrame(w_piston);
|
const f_piston_t = new WorldFrame(w_piston);
|
||||||
const d_piston_t = new Diorama('demo-piston', f_piston_t, () => engine.requestRender());
|
const d_piston_t = new Diorama('demo-piston', f_piston_t, () => engine.requestRender());
|
||||||
@@ -165,7 +169,7 @@
|
|||||||
d_piston_t.saveState();
|
d_piston_t.saveState();
|
||||||
engine.addDiorama(d_piston_t);
|
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)
|
const c_piston = new CameraController(d_piston_t)
|
||||||
|
|
||||||
// Fetch and build everything concurrently!
|
// Fetch and build everything concurrently!
|
||||||
|
|||||||
366
scrubber.js
366
scrubber.js
@@ -1,99 +1,196 @@
|
|||||||
// scrubber.js
|
// scrubber.js
|
||||||
|
|
||||||
export class SubtickScrubber {
|
const SCRUBBER_CSS = `
|
||||||
constructor(elementId, frame, diorama) {
|
.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; }
|
||||||
this.frame = frame;
|
.scrubber-row { display: flex; gap: 12px; align-items: center; }
|
||||||
this.diorama = diorama;
|
.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; }
|
||||||
this.container = diorama.element;
|
.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'
|
export class SubtickScrubber {
|
||||||
this.speed = 1.0; // Ticks per second scaling
|
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.lastFrameTime = performance.now();
|
||||||
this.playbackAccumulator = 0;
|
this.playbackAccumulator = 0;
|
||||||
|
|
||||||
// Hook into the Diorama tap event we just created
|
if (!document.getElementById('scrubber-styles')) {
|
||||||
this.diorama.onTap = () => {
|
const style = document.createElement('style');
|
||||||
if (this.playMode !== 'paused') this.playMode = 'paused';
|
style.id = 'scrubber-styles';
|
||||||
else this.playMode = 'realtime';
|
style.textContent = SCRUBBER_CSS;
|
||||||
this.syncUI();
|
document.head.appendChild(style);
|
||||||
};
|
}
|
||||||
|
|
||||||
this.buildUI();
|
this.buildUI();
|
||||||
this.frame.subscribe(() => this.syncUI());
|
this.frame.subscribe(() => this.syncUI());
|
||||||
|
|
||||||
// Start playback loop
|
|
||||||
this._loop = this.loop.bind(this);
|
this._loop = this.loop.bind(this);
|
||||||
requestAnimationFrame(this._loop);
|
requestAnimationFrame(this._loop);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildUI() {
|
buildUI() {
|
||||||
this.container.style.cssText = `
|
this.container.className = 'visualizer-ui';
|
||||||
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 = document.createElement('div');
|
||||||
this.subRow.style.cssText = `display: flex; gap: 10px; align-items: center;`;
|
this.subRow.className = 'scrubber-row';
|
||||||
this.subRow.innerHTML = `
|
this.subRow.innerHTML = `
|
||||||
<button id="btn-play-sub" style="width: 40px;">S-Play</button>
|
<div class="btn" id="btn-play-sub" title="Animate Events">S></div>
|
||||||
<button id="btn-prev-sub"><</button>
|
<div class="btn" id="btn-prev-sub" title="Previous Event"><</div>
|
||||||
<div style="flex-grow: 1; position: relative;">
|
<div class="track-container" id="track-sub-container">
|
||||||
<input type="range" id="slider-sub" min="0" max="1" step="1" style="width: 100%;">
|
<div class="track-inner" id="track-sub-inner">
|
||||||
|
<div class="track-bg"></div>
|
||||||
|
<div class="track-fill" id="fill-sub"></div>
|
||||||
|
<div id="markers-sub"></div>
|
||||||
|
<div class="track-handle" id="handle-sub"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button id="btn-next-sub">></button>
|
<div class="btn" id="btn-next-sub" title="Next Event">></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Primary Row (Ticks)
|
|
||||||
this.tickRow = document.createElement('div');
|
this.tickRow = document.createElement('div');
|
||||||
this.tickRow.style.cssText = `display: flex; gap: 10px; align-items: center;`;
|
this.tickRow.className = 'scrubber-row';
|
||||||
|
|
||||||
// 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.tickRow.innerHTML = `
|
||||||
<button id="btn-play" style="width: 40px;">Play</button>
|
<div class="btn" id="btn-play" title="Play Realtime">R></div>
|
||||||
<button id="btn-prev"><</button>
|
<div class="btn" id="btn-loop" title="Toggle Loop">Loop</div>
|
||||||
<div style="flex-grow: 1; position: relative;">
|
<div class="btn" id="btn-prev" title="Previous Tick"><<</div>
|
||||||
<input type="range" id="slider-tick" min="0" max="${Math.max(1, maxTime)}" step="0.1" style="width: 100%;">
|
<div class="track-container" id="track-tick-container">
|
||||||
|
<div class="track-inner" id="track-tick-inner">
|
||||||
|
<div class="track-bg"></div>
|
||||||
|
<div class="track-fill" id="fill-tick"></div>
|
||||||
|
<div id="markers-tick"></div>
|
||||||
|
<div class="track-handle" id="handle-tick"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button id="btn-next">></button>
|
<div class="btn" id="btn-next" title="Next Tick">>></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.container.appendChild(this.subRow);
|
this.container.appendChild(this.subRow);
|
||||||
this.container.appendChild(this.tickRow);
|
this.container.appendChild(this.tickRow);
|
||||||
|
|
||||||
this.bindEvents();
|
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() {
|
bindEvents() {
|
||||||
const getEl = id => this.container.querySelector('#' + id);
|
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 = () => {
|
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();
|
this.syncUI();
|
||||||
};
|
};
|
||||||
|
|
||||||
getEl('btn-prev').onclick = () => {
|
getEl('btn-prev').onclick = () => {
|
||||||
this.playMode = 'paused';
|
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 = () => {
|
getEl('btn-next').onclick = () => {
|
||||||
this.playMode = 'paused';
|
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');
|
this.setupDraggableTrack('track-tick-container', 'track-tick-inner', (pct) => {
|
||||||
tickSlider.oninput = () => {
|
// Force integral tick seeking
|
||||||
this.playMode = 'paused';
|
const targetTime = Math.round(pct * this.getMaxTime());
|
||||||
this.frame.seek(parseFloat(tickSlider.value));
|
this.frame.seek(targetTime);
|
||||||
};
|
this.syncUI();
|
||||||
|
});
|
||||||
|
|
||||||
// --- Secondary Actions ---
|
// -- Secondary Playback --
|
||||||
getEl('btn-play-sub').onclick = () => {
|
getEl('btn-play-sub').onclick = () => {
|
||||||
this.playMode = this.playMode === 'subtick' ? 'paused' : 'subtick';
|
this.playMode = this.playMode === 'subtick' ? 'paused' : 'subtick';
|
||||||
this.syncUI();
|
this.syncUI();
|
||||||
@@ -101,98 +198,153 @@ export class SubtickScrubber {
|
|||||||
|
|
||||||
getEl('btn-prev-sub').onclick = () => {
|
getEl('btn-prev-sub').onclick = () => {
|
||||||
this.playMode = 'paused';
|
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 = () => {
|
getEl('btn-next-sub').onclick = () => {
|
||||||
this.playMode = 'paused';
|
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');
|
this.setupDraggableTrack('track-sub-container', 'track-sub-inner', (pct) => {
|
||||||
subSlider.oninput = () => {
|
if (this.currentTickEventCount === 0) return;
|
||||||
this.playMode = 'paused';
|
const targetLocalIdx = Math.round(pct * this.currentTickEventCount);
|
||||||
// Translate the localized sub-slider value back into an absolute global index
|
|
||||||
const localValue = parseInt(subSlider.value);
|
// LOCK TIME: Dragging to the end of the subtick track completes the tick.
|
||||||
this.frame.seekIndex((this.currentTickStartIndex - this.frame.world.initialIndex) + localValue);
|
// 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() {
|
syncUI() {
|
||||||
const getEl = id => this.container.querySelector('#' + id);
|
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';
|
this.subRow.style.display = this.playMode === 'realtime' ? 'none' : 'flex';
|
||||||
|
|
||||||
// Sync Primary Slider
|
// --- Sync Primary Slider ---
|
||||||
getEl('slider-tick').value = Math.max(0, this.frame.currentTime);
|
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;
|
const events = this.frame.world.events;
|
||||||
let tickStartIdx = 0;
|
let startIdx = 0, count = 0;
|
||||||
let tickEventCount = 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++) {
|
for (let i = 0; i < events.length; i++) {
|
||||||
if (events[i].time === this.frame.currentTime) {
|
if (events[i].time === currentIntTick) {
|
||||||
if (tickEventCount === 0) tickStartIdx = i;
|
if (count === 0) startIdx = i;
|
||||||
tickEventCount++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentTickStartIndex = tickStartIdx;
|
this.currentTickStartIndex = startIdx;
|
||||||
const subSlider = getEl('slider-sub');
|
this.currentTickEventCount = count;
|
||||||
subSlider.max = tickEventCount;
|
|
||||||
|
|
||||||
// Calculate where the cursor is relative to the start of this specific tick
|
const markerContainer = getEl('markers-sub');
|
||||||
let localCursor = this.frame.currentIndex - tickStartIdx;
|
markerContainer.innerHTML = '';
|
||||||
if (localCursor < 0) localCursor = 0;
|
|
||||||
if (localCursor > tickEventCount) localCursor = tickEventCount;
|
if (count > 0) {
|
||||||
|
const labeledIndices = new Set(this.frame.world.labels.values());
|
||||||
subSlider.value = localCursor;
|
|
||||||
|
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) {
|
loop(time) {
|
||||||
const dt = (time - this.lastFrameTime) / 1000;
|
const dt = (time - this.lastFrameTime) / 1000;
|
||||||
this.lastFrameTime = time;
|
this.lastFrameTime = time;
|
||||||
|
const maxTime = this.getMaxTime();
|
||||||
const maxTime = this.frame.world.events.length > 0 ?
|
|
||||||
this.frame.world.events[this.frame.world.events.length - 1].time : 0;
|
|
||||||
|
|
||||||
if (this.playMode === 'realtime') {
|
if (this.playMode === 'realtime') {
|
||||||
// 20 ticks per second * speed multiplier
|
let nextTime = this.frame.currentTime + dt * 20 * this.speed;
|
||||||
this.playbackAccumulator += dt * 20 * this.speed;
|
|
||||||
|
if (nextTime >= maxTime) {
|
||||||
if (this.playbackAccumulator >= 1.0) {
|
if (this.isLooping) {
|
||||||
const ticksToAdvance = Math.floor(this.playbackAccumulator);
|
nextTime = nextTime % maxTime;
|
||||||
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);
|
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 {
|
} 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 {
|
} else {
|
||||||
this.playbackAccumulator = 0; // Reset accumulator when paused
|
this.playbackAccumulator = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(this._loop);
|
requestAnimationFrame(this._loop);
|
||||||
|
|||||||
13
world.js
13
world.js
@@ -15,7 +15,6 @@ export class World {
|
|||||||
this.labels = new Map();
|
this.labels = new Map();
|
||||||
this.initialState = new Map();
|
this.initialState = new Map();
|
||||||
this.initialIndex = 0;
|
this.initialIndex = 0;
|
||||||
this.eventCount = 0;
|
|
||||||
|
|
||||||
if (script) {
|
if (script) {
|
||||||
this.#compile(script);
|
this.#compile(script);
|
||||||
@@ -123,8 +122,6 @@ export class World {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!capturedInitialState) captureInitial();
|
if (!capturedInitialState) captureInitial();
|
||||||
|
|
||||||
this.eventCount = this.events.length - this.initialIndex;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,13 +167,13 @@ export class WorldFrame {
|
|||||||
if (typeof target === 'string') {
|
if (typeof target === 'string') {
|
||||||
const absoluteIdx = this.world.labels.get(target);
|
const absoluteIdx = this.world.labels.get(target);
|
||||||
if (absoluteIdx === undefined) throw new Error(`Label not found: ${target}`);
|
if (absoluteIdx === undefined) throw new Error(`Label not found: ${target}`);
|
||||||
this.seekIndex(absoluteIdx - this.world.initialIndex);
|
this.seekIndex(absoluteIdx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let changed = false;
|
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];
|
const batch = this.world.events[this.currentIndex];
|
||||||
for (const action of batch.actions) {
|
for (const action of batch.actions) {
|
||||||
this.#apply(action.next, action.x, action.y, action.z);
|
this.#apply(action.next, action.x, action.y, action.z);
|
||||||
@@ -185,7 +182,7 @@ export class WorldFrame {
|
|||||||
changed = true;
|
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--;
|
this.currentIndex--;
|
||||||
const batch = this.world.events[this.currentIndex];
|
const batch = this.world.events[this.currentIndex];
|
||||||
for (let i = batch.actions.length - 1; i >= 0; i--) {
|
for (let i = batch.actions.length - 1; i >= 0; i--) {
|
||||||
@@ -199,8 +196,8 @@ export class WorldFrame {
|
|||||||
if (changed) this.notify();
|
if (changed) this.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
seekIndex(targetIndex) {
|
seekIndex(absoluteIndex) {
|
||||||
targetIndex = targetIndex + this.world.initialIndex;
|
let targetIndex = Math.max(this.world.initialIndex, Math.min(this.world.events.length, absoluteIndex));
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
while (this.currentIndex < this.world.events.length && this.currentIndex < targetIndex) {
|
while (this.currentIndex < this.world.events.length && this.currentIndex < targetIndex) {
|
||||||
|
|||||||
Reference in New Issue
Block a user