remove busted timeline control

This commit is contained in:
David Allemang
2026-09-08 15:54:58 -04:00
parent 7b26478c47
commit 24687c5623
13 changed files with 109 additions and 735 deletions

View File

@@ -1,6 +1,5 @@
#import "/lib.typ": diorama, example, note, todo #import "/lib.typ": diorama, example, note, todo
= Tilesets <tilesets> = Tilesets <tilesets>
The protocols as described are very sensitive to the exact timing that the EID counter is The protocols as described are very sensitive to the exact timing that the EID counter is
@@ -14,12 +13,15 @@ sequence* is a chain of redstone components that all update in the scheduled tic
set* is a system of constructing tile sequences that enforces a particular global ordering. set* is a system of constructing tile sequences that enforces a particular global ordering.
#show raw.where(lang: "mc-diorama"): it => diorama( #show raw.where(lang: "mc-diorama"): it => diorama(
orbit: true,
pan: true,
zoom: true,
autoplay: true, autoplay: true,
loop: true,
theta: 160, theta: 160,
phi: 20, phi: 20,
it.text, it.text,
) )
```mc-diorama ```mc-diorama
p 0 -1 0 smooth_stone_slab type=top p 0 -1 0 smooth_stone_slab type=top
p 1 -1 0 smooth_stone_slab type=top p 1 -1 0 smooth_stone_slab type=top

View File

@@ -1,5 +1,3 @@
// cache.js
export class Cache { export class Cache {
constructor(root, onResolve) { constructor(root, onResolve) {
this.root = root; this.root = root;

View File

@@ -1,4 +1,3 @@
/* --- Component Structural Styles --- */
mc-world, mc-timeline, mc-camera, mc-frame { mc-world, mc-timeline, mc-camera, mc-frame {
display: none; display: none;
} }
@@ -9,256 +8,88 @@ mc-diorama {
width: 100%; width: 100%;
height: 20em; height: 20em;
& canvas { canvas {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
}
.spacetime-overlay {
position: absolute;
inset: 0; /* Replaces top, left, width, height */
padding: 5px;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: space-between;
box-sizing: border-box;
z-index: 10;
font-family: sans-serif;
/* Core icon engine rules */ .overlay {
position: absolute;
& .icon { inset: 0;
display: inline-block;
width: 1.2em;
height: 1.2em;
background-color: currentColor; /* Inherits text color (white, or hover states) */
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
/* Map classes to your local SVG paths */
&.ico-play {
mask-image: url('ico-play.svg');
-webkit-mask-image: url('ico-play.svg');
}
&.ico-pause {
mask-image: url('ico-pause.svg');
-webkit-mask-image: url('ico-pause.svg');
}
&.ico-auto-events {
mask-image: url('ico-auto-events.svg');
-webkit-mask-image: url('ico-auto-events.svg');
}
&.ico-prev {
mask-image: url('ico-prev.svg');
-webkit-mask-image: url('ico-prev.svg');
}
&.ico-next {
mask-image: url('ico-next.svg');
-webkit-mask-image: url('ico-next.svg');
}
&.ico-prev-event {
mask-image: url('ico-prev-event.svg');
-webkit-mask-image: url('ico-prev-event.svg');
}
&.ico-next-event {
mask-image: url('ico-next-event.svg');
-webkit-mask-image: url('ico-next-event.svg');
}
&.ico-reset {
mask-image: url('ico-reset.svg');
-webkit-mask-image: url('ico-reset.svg');
}
/* Spatial indicators (mapped for future integration if desired) */
&.ico-rotate {
mask-image: url('ico-rotate.svg');
-webkit-mask-image: url('ico-rotate.svg');
}
&.ico-pan {
mask-image: url('ico-pan.svg');
-webkit-mask-image: url('ico-pan.svg');
}
&.ico-zoom {
mask-image: url('ico-zoom.svg');
-webkit-mask-image: url('ico-zoom.svg');
}
}
/* Define the safe zones for the top cluster */
& .top-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
pointer-events: none;
}
/* Spatial Tool Hints */
& .spacetime-indicator {
display: flex;
gap: 4px;
/* Mimic the .btn box model so baselines perfectly align with the Play button */
padding: 4px 8px;
border: 1px solid transparent;
color: rgba(255, 255, 255, 0.4);
font-family: monospace;
font-size: 14px;
opacity: 1;
transition: opacity 0.2s ease;
pointer-events: auto;
}
/* Unified Button Design Language */
& .btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px; /* Spacing between icon and text for the reset button */
}
& .btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
text-align: center;
font-weight: bold;
font-family: inherit;
backdrop-filter: blur(4px);
pointer-events: auto;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
& .top-row .btn {
background: rgba(0, 0, 0, 0.6);
visibility: hidden;
&:hover {
background: rgba(0, 0, 0, 0.8);
}
}
/* Bottom: Timeline Panel Context */
& .visualizer-ui {
pointer-events: none;
background: transparent;
margin: -5px;
padding: 5px; padding: 5px;
pointer-events: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; justify-content: space-between;
color: white; box-sizing: border-box;
user-select: none; z-index: 10;
border: 1px solid transparent; font-family: sans-serif;
transition: all 0.2s ease;
& .scrubber-row { .icon {
display: flex; display: inline-block;
gap: 4px; width: 1.2em;
align-items: center; height: 1.2em;
} background-color: currentColor;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
& .track-container { &.ico-reset {
flex-grow: 1; mask-image: url('ico-reset.svg');
height: 24px; -webkit-mask-image: url('ico-reset.svg');
padding: 0 8px; /* Safe zone so the rounded handle doesn't bleed out */ }
cursor: pointer;
display: flex;
align-items: center;
touch-action: none;
& .track-inner { &.ico-rotate {
position: relative; mask-image: url('ico-rotate.svg');
width: 100%; -webkit-mask-image: url('ico-rotate.svg');
height: 8px; /* Defines the actual track geometry */ }
background: rgba(255, 255, 255, 0.2);
pointer-events: none;
& .track-fill { &.ico-pan {
position: absolute; mask-image: url('ico-pan.svg');
left: 0; -webkit-mask-image: url('ico-pan.svg');
top: 0; }
bottom: 0; /* Snaps tightly to inner geometry */
background: #f00;
}
& .track-handle { &.ico-zoom {
position: absolute; mask-image: url('ico-zoom.svg');
width: 16px; -webkit-mask-image: url('ico-zoom.svg');
height: 16px;
background: #fff;
border-radius: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 3;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
}
& .marker {
position: absolute;
top: 0;
bottom: 0; /* Matches track height */
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;
}
}
} }
} }
}
}
/* --- Hover Visibility Logic --- */ .top-row {
.spacetime-hover-reveal { display: flex;
opacity: 0; justify-content: flex-start;
pointer-events: none; align-items: flex-start;
transition: opacity 0.2s ease; gap: 4px;
}
.spacetime-container:hover { padding: 4px 8px;
& .spacetime-hover-reveal {
opacity: 1; color: rgba(255, 255, 255, 0.4);
pointer-events: auto;
transition: opacity 0.2s ease;
pointer-events: auto;
}
.ico-reset {
margin-inline-start: auto;
cursor: pointer;
}
} }
& .visualizer-ui { .spacetime-hover-reveal {
pointer-events: auto; opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
&:hover {
.spacetime-hover-reveal {
opacity: 1;
pointer-events: auto;
}
} }
} }

View File

@@ -1,4 +1,3 @@
// components.js
import {Engine} from './engine.js'; import {Engine} from './engine.js';
import {World, WorldFrame} from './world.js'; import {World, WorldFrame} from './world.js';
import {Diorama} from './diorama.js'; import {Diorama} from './diorama.js';

View File

@@ -1,5 +1,3 @@
// controller.js
export class SpacetimeController { export class SpacetimeController {
constructor(diorama, options = {}) { constructor(diorama, options = {}) {
this.diorama = diorama; this.diorama = diorama;
@@ -7,136 +5,68 @@ export class SpacetimeController {
this.container = diorama.element; this.container = diorama.element;
this.canvas = diorama.canvas; this.canvas = diorama.canvas;
this.opts = { this.opts = {orbit: true, pan: true, zoom: true, speed: 1.0, ...options};
orbit: true,
pan: true,
zoom: true,
timeline: true,
subticks: true,
autoplay: false,
loop: false,
speed: 1.0, ...options
};
this.hasSpatial = this.opts.orbit || this.opts.pan || this.opts.zoom; this.hasSpatial = this.opts.orbit || this.opts.pan || this.opts.zoom;
// Camera State
this.isDragging = false; this.isDragging = false;
this.hasDragged = false;
this.dragButton = 0; this.dragButton = 0;
this.lastMouse = {x: 0, y: 0}; this.lastMouse = {x: 0, y: 0};
// Timeline State
this.playMode = this.opts.autoplay ? 'realtime' : 'paused';
this.isLooping = this.opts.loop;
this.speed = this.opts.speed; this.speed = this.opts.speed;
this.lastFrameTime = performance.now(); this.lastFrameTime = performance.now();
this.uiTickTime = 0;
this.playbackAccumulator = 0;
this.container.classList.add('spacetime-container');
this.buildUI(); this.buildUI();
this.attachEvents(); this.attachEvents();
if (this.opts.timeline || this.opts.autoplay) { requestAnimationFrame(this.loop.bind(this));
this.frame.subscribe(() => this.syncUI());
this._loop = this.loop.bind(this);
requestAnimationFrame(this._loop);
}
} }
buildUI() { buildUI() {
this.overlay = document.createElement('div'); const overlay = document.createElement('div');
this.overlay.className = 'spacetime-overlay'; overlay.className = 'overlay';
this.container.appendChild(overlay);
// --- Top Row Wrapper --- const header = document.createElement('div');
const topRow = document.createElement('div'); header.className = 'top-row';
topRow.className = 'top-row'; overlay.appendChild(header);
// --- Feature Indicator (Top Left) ---
this.indicator = document.createElement('div');
this.indicator.className = 'spacetime-indicator';
// Inject individual SVG icons with structural tooltips
if (this.opts.orbit) { if (this.opts.orbit) {
this.indicator.innerHTML += `<span class="icon ico-rotate" title="Rotate view: left drag"></span>`; let elt = document.createElement('span');
elt.className = 'icon ico-rotate';
elt.title = 'Rotate view: left drag';
header.appendChild(elt);
} }
if (this.opts.pan) { if (this.opts.pan) {
this.indicator.innerHTML += `<span class="icon ico-pan" title="Move view: middle drag (or shift left drag)"></span>`; let elt = document.createElement('span');
elt.className = 'icon ico-pan';
elt.title = 'Move view: middle drag (or shift left drag)';
header.appendChild(elt);
} }
if (this.opts.zoom) { if (this.opts.zoom) {
this.indicator.innerHTML += `<span class="icon ico-zoom" title="Zoom view: right drag (or ctrl left drag)"></span>`; let elt = document.createElement('span');
elt.className = 'icon ico-zoom';
elt.title = 'Zoom view: right drag (or ctrl left drag)';
header.appendChild(elt);
} }
topRow.appendChild(this.indicator);
// --- Reset Button (Top Right) ---
if (this.hasSpatial) { if (this.hasSpatial) {
this.resetBtn = document.createElement('div'); let elt = document.createElement('span');
this.resetBtn.className = 'btn spacetime-hover-reveal'; elt.className = 'spacetime-hover-reveal icon ico-reset';
this.resetBtn.innerHTML = `<span class="icon ico-reset" title="Reset camera"></span>`; elt.title = 'Reset camera';
header.appendChild(elt);
this.resetBtn.addEventListener('click', () => { elt.addEventListener('click', () => {
this.diorama.loadState(); this.diorama.loadState();
this.resetBtn.style.visibility = 'hidden'; this.resetBtn.style.visibility = 'hidden';
this.resetBtn = elt;
}); });
this.resetBtn = elt;
topRow.appendChild(this.resetBtn);
} }
this.overlay.appendChild(topRow);
// --- Bottom: Timeline Controls ---
if (this.opts.timeline) {
this.uiContainer = document.createElement('div');
this.uiContainer.className = 'visualizer-ui';
if (this.opts.subticks) {
this.subRow = document.createElement('div');
this.subRow.className = 'scrubber-row spacetime-hover-reveal';
this.subRow.innerHTML = `
<div class="btn" id="btn-play-sub" title="Animate block updates"><span class="icon ico-auto-events"></span></div>
<div class="btn" id="btn-prev-sub" title="Previous block update"><span class="icon ico-next-event" style="transform: scaleX(-1);"></span></div>
<div class="track-container" id="track-sub-container" title="Select block update (in current tick)">
<div class="track-inner" id="track-sub-inner">
<div class="track-fill" id="fill-sub"></div>
<div id="markers-sub"></div>
<div class="track-handle" id="handle-sub"></div>
</div>
</div>
<div class="btn" id="btn-next-sub" title="Next block update"><span class="icon ico-next-event"></span></div>
`;
this.uiContainer.appendChild(this.subRow);
}
this.tickRow = document.createElement('div');
this.tickRow.className = 'scrubber-row';
this.tickRow.innerHTML = `
<div class="btn" id="btn-play" title="Animate gameticks" style="pointer-events: auto;"><span class="icon ico-play"></span></div>
<div class="btn spacetime-hover-reveal" id="btn-loop" title="Repeat playback"><span class="icon ico-rotate"></span></div>
<div class="btn spacetime-hover-reveal" id="btn-prev" title="Previous gametick"><span class="icon ico-next" style="transform: scaleX(-1);"></span></div>
<div class="track-container spacetime-hover-reveal" id="track-tick-container" title="Select gametick">
<div class="track-inner" id="track-tick-inner">
<div class="track-fill" id="fill-tick"></div>
<div id="markers-tick"></div>
<div class="track-handle" id="handle-tick"></div>
</div>
</div>
<div class="btn spacetime-hover-reveal" id="btn-next" title="Next gametick"><span class="icon ico-next"></span></div>
`;
this.uiContainer.appendChild(this.tickRow);
this.overlay.appendChild(this.uiContainer);
this.bindTimelineEvents();
this.generateTickMarkers();
this.syncUI();
}
this.container.appendChild(this.overlay);
} }
// --- SPATIAL CONTROL ---
orbit(dx, dy) { orbit(dx, dy) {
if (!this.opts.orbit) return; if (!this.opts.orbit) return;
this.diorama.theta -= dx * 0.4; this.diorama.theta -= dx * 0.4;
@@ -160,15 +90,9 @@ export class SpacetimeController {
this.diorama.target[2] += (-rightZ * dx + upZ * dy) * panSpeed; this.diorama.target[2] += (-rightZ * dx + upZ * dy) * panSpeed;
} }
zoomRatio(ratio) {
if (!this.opts.zoom) return;
this.diorama.radius *= ratio;
this.diorama.radius = Math.max(1, Math.min(50, this.diorama.radius));
}
zoomLinear(delta) { zoomLinear(delta) {
if (!this.opts.zoom) return; if (!this.opts.zoom) return;
this.diorama.radius += delta * 0.05; this.diorama.radius += delta * 0.0125;
this.diorama.radius = Math.max(1, Math.min(50, this.diorama.radius)); this.diorama.radius = Math.max(1, Math.min(50, this.diorama.radius));
} }
@@ -178,21 +102,17 @@ export class SpacetimeController {
e.preventDefault() e.preventDefault()
}); });
// Correctly set or remove the grab cursor
this.canvas.style.cursor = this.hasSpatial ? 'grab' : 'default'; this.canvas.style.cursor = this.hasSpatial ? 'grab' : 'default';
const notifyCameraChange = () => { const notifyCameraChange = () => {
this.hasDragged = true;
if (this.resetBtn && this.diorama.checkpoint) this.resetBtn.style.visibility = 'visible'; if (this.resetBtn && this.diorama.checkpoint) this.resetBtn.style.visibility = 'visible';
this.diorama.requestRender(); this.diorama.requestRender();
}; };
this.canvas.addEventListener('mousedown', (e) => { this.canvas.addEventListener('mousedown', (e) => {
// Short-circuit drag logic entirely if no spatial controls are enabled
if (!this.hasSpatial) return; if (!this.hasSpatial) return;
this.isDragging = true; this.isDragging = true;
this.hasDragged = false;
this.lastMouse = {x: e.clientX, y: e.clientY}; this.lastMouse = {x: e.clientX, y: e.clientY};
this.dragButton = e.button; this.dragButton = e.button;
@@ -222,17 +142,6 @@ export class SpacetimeController {
} }
}); });
this.canvas.addEventListener('click', () => {
// hasDragged will always be false if hasSpatial is false,
// ensuring static scenes still act as tap-to-play toggles.
if (!this.hasDragged && this.opts.timeline) {
this.playMode = this.playMode === 'paused' ? 'realtime' : 'paused';
this.syncUI();
}
});
// --- MOBILE TOUCH LOGIC ("Wait and See" Scroll vs Camera) ---
this.touchEngaged = false; this.touchEngaged = false;
this.touchTimer = null; this.touchTimer = null;
this.touchStartPos = new Map(); this.touchStartPos = new Map();
@@ -240,7 +149,6 @@ export class SpacetimeController {
this.canvas.addEventListener('touchstart', (e) => { this.canvas.addEventListener('touchstart', (e) => {
if (!this.hasSpatial) return; if (!this.hasSpatial) return;
// DO NOT call e.preventDefault() here, or the page will never scroll!
for (let i = 0; i < e.changedTouches.length; i++) { for (let i = 0; i < e.changedTouches.length; i++) {
const t = e.changedTouches[i]; const t = e.changedTouches[i];
@@ -248,22 +156,20 @@ export class SpacetimeController {
this.lastTouchPos.set(t.identifier, {x: t.clientX, y: t.clientY}); this.lastTouchPos.set(t.identifier, {x: t.clientX, y: t.clientY});
} }
// A new finger restarts the timeout
clearTimeout(this.touchTimer); clearTimeout(this.touchTimer);
this.touchEngaged = false; this.touchEngaged = false;
this.touchTimer = setTimeout(() => { this.touchTimer = setTimeout(() => {
this.touchEngaged = true; this.touchEngaged = true;
// Once engaged, disable click propagation so it doesn't toggle play/pause
this.hasDragged = true;
}, 200); }, 200);
}, {passive: false}); }, {passive: false});
this.canvas.addEventListener('touchmove', (e) => { this.canvas.addEventListener('touchmove', (e) => {
if (!this.hasSpatial) return; if (!this.hasSpatial) {
return; // Let the browser scroll the page
}
if (!this.touchEngaged) { if (!this.touchEngaged) {
// Check if they are swiping quickly before the timer completes.
let maxDist = 0; let maxDist = 0;
for (let i = 0; i < e.touches.length; i++) { for (let i = 0; i < e.touches.length; i++) {
const t = e.touches[i]; const t = e.touches[i];
@@ -272,14 +178,12 @@ export class SpacetimeController {
maxDist = Math.max(maxDist, Math.hypot(t.clientX - start.x, t.clientY - start.y)); maxDist = Math.max(maxDist, Math.hypot(t.clientX - start.x, t.clientY - start.y));
} }
} }
// If they drag more than 10px before the 200ms timer, assume page scroll intent
if (maxDist > 10) clearTimeout(this.touchTimer); if (maxDist > 10) clearTimeout(this.touchTimer);
return; // Let the browser scroll the page return; // Let the browser scroll the page
} }
// --- CAMERA IS ENGAGED --- // Fall through. Stop the page from scrolling and handle orbit/zoom.
e.preventDefault(); // Stop the page from scrolling e.preventDefault();
this.hasDragged = true;
if (e.touches.length === 1) { if (e.touches.length === 1) {
// 1 Finger: Orbit // 1 Finger: Orbit
@@ -333,338 +237,26 @@ export class SpacetimeController {
this.canvas.addEventListener('touchcancel', handleTouchEnd); this.canvas.addEventListener('touchcancel', handleTouchEnd);
} }
// --- TEMPORAL CONTROL ---
getMaxTime() { getMaxTime() {
const evs = this.frame.world.events; const evs = this.frame.world.events;
// Add +1 so the final tick plays out its full duration!
return evs.length > 0 ? evs[evs.length - 1].time + 1 : 0; return evs.length > 0 ? evs[evs.length - 1].time + 1 : 0;
} }
generateTickMarkers() {
const markerContainer = this.uiContainer.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}%`;
markerContainer.appendChild(marker);
}
}
setupDraggableTrack(containerId, innerId, onDrag) {
const wrapper = this.uiContainer.querySelector('#' + containerId);
const inner = this.uiContainer.querySelector('#' + innerId);
let isTrackDragging = false;
const update = (e) => {
const rect = inner.getBoundingClientRect();
let pct = (e.clientX - rect.left) / rect.width;
pct = Math.max(0, Math.min(1, pct));
onDrag(pct);
};
wrapper.addEventListener('pointerdown', (e) => {
wrapper.setPointerCapture(e.pointerId);
isTrackDragging = true;
this.playMode = 'paused';
update(e);
});
wrapper.addEventListener('pointermove', (e) => {
if (isTrackDragging) update(e);
});
const stopDrag = (e) => {
if (isTrackDragging) {
isTrackDragging = false;
wrapper.releasePointerCapture(e.pointerId);
}
};
wrapper.addEventListener('pointerup', stopDrag);
wrapper.addEventListener('pointercancel', stopDrag);
}
bindTimelineEvents() {
const getEl = id => this.uiContainer.querySelector('#' + id);
getEl('btn-loop').onclick = () => {
this.isLooping = !this.isLooping;
this.syncUI();
};
getEl('btn-play').onclick = () => {
if (this.playMode === 'realtime') this.playMode = 'paused';
else {
// Check if time is exhausted OR if all events have been processed
const isAtEnd = this.frame.currentTime >= this.getMaxTime() || this.frame.currentIndex >= this.frame.world.events.length;
if (isAtEnd || this.frame.currentTime < 0) {
this.frame.seek(0);
this.uiTickTime = 0; // Explicitly reset UI sync
}
this.playMode = 'realtime';
}
this.syncUI();
};
getEl('btn-prev').onclick = () => {
this.playMode = 'paused';
let prevTime = Math.floor(this.uiTickTime - 1);
if (prevTime < 0) prevTime = this.isLooping ? this.getMaxTime() : 0;
this.frame.seek(prevTime);
this.uiTickTime = prevTime; // Explicit jump
this.syncUI();
};
getEl('btn-next').onclick = () => {
this.playMode = 'paused';
let nextTime = Math.floor(this.uiTickTime + 1);
if (nextTime > this.getMaxTime()) nextTime = this.isLooping ? 0 : this.getMaxTime();
this.frame.seek(nextTime);
this.uiTickTime = nextTime; // Explicit jump
this.syncUI();
};
this.setupDraggableTrack('track-tick-container', 'track-tick-inner', (pct) => {
const targetTime = Math.round(pct * this.getMaxTime());
this.frame.seek(targetTime);
this.uiTickTime = targetTime; // Explicit jump. Do not snap to event index!
this.syncUI();
});
if (this.opts.subticks) {
getEl('btn-play-sub').onclick = () => {
if (this.playMode === 'subtick') this.playMode = 'paused';
else {
// Check if all events are processed
if (this.frame.currentIndex >= this.frame.world.events.length) {
this.frame.seekIndex(this.frame.world.initialIndex || 0);
const events = this.frame.world.events;
this.uiTickTime = events.length > 0 ? events[0].time : 0;
}
this.playMode = 'subtick';
}
this.syncUI();
};
getEl('btn-prev-sub').onclick = () => {
this.playMode = 'paused';
let idx = this.frame.currentIndex - 1;
const events = this.frame.world.events;
const initialIndex = this.frame.world.initialIndex || 0;
if (idx < initialIndex) idx = this.isLooping ? events.length : initialIndex;
this.frame.seekIndex(idx);
// Allow the UI to fall backwards naturally
if (idx < events.length && idx >= 0) this.uiTickTime = events[idx].time;
this.syncUI();
};
getEl('btn-next-sub').onclick = () => {
this.playMode = 'paused';
let idx = this.frame.currentIndex + 1;
const events = this.frame.world.events;
const initialIndex = this.frame.world.initialIndex || 0;
if (idx > events.length) idx = this.isLooping ? initialIndex : events.length;
this.frame.seekIndex(idx);
// Force UI to advance if the button naturally crossed the threshold
if (idx < events.length && idx >= 0) {
this.uiTickTime = events[idx].time;
} else if (events.length > 0) {
this.uiTickTime = events[events.length - 1].time;
}
this.syncUI();
};
this.setupDraggableTrack('track-sub-container', 'track-sub-inner', (pct) => {
if (this.currentTickEventCount === 0) return;
let targetIdx = this.currentTickStartIndex + Math.round(pct * this.currentTickEventCount);
this.frame.seekIndex(targetIdx);
// Intentionally omit updating uiTickTime to let scrubbing "stick" to the current tick.
this.syncUI();
});
}
}
syncUI() {
if (!this.opts.timeline) return;
const getEl = id => this.uiContainer.querySelector('#' + id);
const playButton = getEl('btn-play');
const playIcon = playButton.querySelector('.icon');
if (this.playMode === 'realtime') {
playIcon.className = 'icon ico-pause';
playButton.setAttribute('title', 'Pause gametick animation'); // Dynamic Tooltip
} else {
playIcon.className = 'icon ico-play';
playButton.setAttribute('title', 'Animate gameticks'); // Dynamic Tooltip
}
getEl('btn-loop').style.background = this.isLooping ? 'rgba(102, 204, 102, 0.4)' : 'rgba(255, 255, 255, 0.1)';
getEl('btn-loop').style.borderColor = this.isLooping ? '#6c6' : 'rgba(255, 255, 255, 0.2)';
if (this.opts.subticks) {
const subPlayButton = getEl('btn-play-sub');
const subPlayIcon = subPlayButton.querySelector('.icon');
if (this.playMode === 'subtick') {
subPlayIcon.className = 'icon ico-pause';
subPlayButton.setAttribute('title', 'Pause block update animation'); // Dynamic Tooltip
} else {
subPlayIcon.className = 'icon ico-auto-events';
subPlayButton.setAttribute('title', 'Animate block updates'); // Dynamic Tooltip
}
this.subRow.style.display = this.playMode === 'realtime' ? 'none' : 'flex';
}
// Force uiTickTime to perfectly match realtime flow
if (this.playMode === 'realtime') {
this.uiTickTime = Math.floor(this.frame.currentTime);
}
const maxTime = this.getMaxTime();
// Fix: Decouple the UI tick track from realtime when scrubbed/paused
let displayTime = Math.max(0, this.frame.currentTime);
if (this.playMode !== 'realtime') {
displayTime = Math.max(0, this.uiTickTime);
}
const tickPct = maxTime > 0 ? (displayTime / maxTime) * 100 : 0;
getEl('fill-tick').style.width = `${tickPct}%`;
getEl('handle-tick').style.left = `${tickPct}%`;
if (!this.opts.subticks) return;
const events = this.frame.world.events;
if (events.length === 0) return;
// --- Verify uiTickTime is valid for the current index ---
let startIdx = -1, count = 0;
for (let i = 0; i < events.length; i++) {
if (events[i].time === this.uiTickTime) {
if (startIdx === -1) startIdx = i;
count++;
}
}
// Fix: Only auto-correct if we are NOT in realtime AND the tick isn't empty.
// This stops empty ticks from auto-snapping to filled ticks.
if (this.playMode !== 'realtime' && startIdx !== -1) {
// Because scrubbing to 100% means currentIndex == startIdx + count,
// this > condition strictly permits the inclusive UI overlap!
if (this.frame.currentIndex < startIdx || this.frame.currentIndex > startIdx + count) {
if (this.frame.currentIndex < events.length) {
this.uiTickTime = events[this.frame.currentIndex].time;
} else {
this.uiTickTime = events[events.length - 1].time;
}
// Recalculate bounds for the newly corrected tick
startIdx = -1;
count = 0;
for (let i = 0; i < events.length; i++) {
if (events[i].time === this.uiTickTime) {
if (startIdx === -1) startIdx = i;
count++;
}
}
}
}
this.currentTickStartIndex = startIdx;
this.currentTickEventCount = count;
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 markerPct = (i / count) * 100;
const marker = document.createElement('div');
marker.className = 'marker';
marker.style.left = `${markerPct}%`;
if (labeledIndices.has(startIdx + i)) marker.classList.add('marker-label'); else marker.classList.add('marker-event');
markerContainer.appendChild(marker);
}
let localCursor = Math.max(0, Math.min(count, this.frame.currentIndex - startIdx));
const subPct = (localCursor / count) * 100;
getEl('fill-sub').style.width = `${subPct}%`;
getEl('handle-sub').style.left = `${subPct}%`;
getEl('handle-sub').style.display = 'block';
} else {
getEl('fill-sub').style.width = `0%`;
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.getMaxTime();
if (this.playMode === 'realtime') { let nextTime = this.frame.currentTime + dt * 20 * this.speed;
let nextTime = this.frame.currentTime + dt * 20 * this.speed; if (nextTime >= maxTime) {
if (nextTime >= maxTime) { if (maxTime > 0) {
if (this.isLooping && maxTime > 0) { this.frame.seek(nextTime % maxTime);
this.frame.seek(nextTime % maxTime);
} else {
this.frame.seek(maxTime);
this.uiTickTime = maxTime; // Snap UI to the very end
this.playMode = 'paused';
}
} else { } else {
this.frame.seek(nextTime); this.frame.seek(maxTime);
} }
if (this.opts.timeline) 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;
const events = this.frame.world.events;
const initialIndex = this.frame.world.initialIndex || 0;
if (nextIdx > events.length) {
if (this.isLooping) {
this.frame.seekIndex(initialIndex);
if (events.length > 0) this.uiTickTime = events[0].time;
} else {
this.frame.seekIndex(events.length);
this.playMode = 'paused';
}
} else {
this.frame.seekIndex(nextIdx);
if (nextIdx < events.length) this.uiTickTime = events[nextIdx].time;
}
}
if (this.opts.timeline) this.syncUI();
} else { } else {
this.playbackAccumulator = 0; this.frame.seek(nextTime);
} }
requestAnimationFrame(this._loop); requestAnimationFrame(this.loop.bind(this));
} }
} }

View File

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.-->
<path
d="M500.7 138.7L512 149.4L512 96C512 78.3 526.3 64 544 64C561.7 64 576 78.3 576 96L576 224C576 241.7 561.7 256 544 256L416 256C398.3 256 384 241.7 384 224C384 206.3 398.3 192 416 192L463.9 192L456.3 184.8C456.1 184.6 455.9 184.4 455.7 184.2C380.7 109.2 259.2 109.2 184.2 184.2C109.2 259.2 109.2 380.7 184.2 455.7C259.2 530.7 380.7 530.7 455.7 455.7C463.9 447.5 471.2 438.8 477.6 429.6C487.7 415.1 507.7 411.6 522.2 421.7C536.7 431.8 540.2 451.8 530.1 466.3C521.6 478.5 511.9 490.1 501 501C401 601 238.9 601 139 501C39.1 401 39 239 139 139C238.9 39.1 400.7 39 500.7 138.7z"/>
<path
transform="scale(0.5) translate(320, 320)"
d="M259.1 73.5C262.1 58.7 275.2 48 290.4 48L350.2 48C365.4 48 378.5 58.7 381.5 73.5L396 143.5C410.1 149.5 423.3 157.2 435.3 166.3L503.1 143.8C517.5 139 533.3 145 540.9 158.2L570.8 210C578.4 223.2 575.7 239.8 564.3 249.9L511 297.3C511.9 304.7 512.3 312.3 512.3 320C512.3 327.7 511.8 335.3 511 342.7L564.4 390.2C575.8 400.3 578.4 417 570.9 430.1L541 481.9C533.4 495 517.6 501.1 503.2 496.3L435.4 473.8C423.3 482.9 410.1 490.5 396.1 496.6L381.7 566.5C378.6 581.4 365.5 592 350.4 592L290.6 592C275.4 592 262.3 581.3 259.3 566.5L244.9 496.6C230.8 490.6 217.7 482.9 205.6 473.8L137.5 496.3C123.1 501.1 107.3 495.1 99.7 481.9L69.8 430.1C62.2 416.9 64.9 400.3 76.3 390.2L129.7 342.7C128.8 335.3 128.4 327.7 128.4 320C128.4 312.3 128.9 304.7 129.7 297.3L76.3 249.8C64.9 239.7 62.3 223 69.8 209.9L99.7 158.1C107.3 144.9 123.1 138.9 137.5 143.7L205.3 166.2C217.4 157.1 230.6 149.5 244.6 143.4L259.1 73.5zM320.3 400C364.5 399.8 400.2 363.9 400 319.7C399.8 275.5 363.9 239.8 319.7 240C275.5 240.2 239.8 276.1 240 320.3C240.2 364.5 276.1 400.2 320.3 400z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.-->
<rect x="0" y="0" width="640" height="640" fill="none" stroke="red"></rect>
<path transform="translate(120, 0)"
d="M441.3 299.8C451.5 312.4 450.8 330.9 439.1 342.6L311.1 470.6C301.9 479.8 288.2 482.5 276.2 477.5C264.2 472.5 256.5 460.9 256.5 448L256.5 192C256.5 179.1 264.3 167.4 276.3 162.4C288.3 157.4 302 160.2 311.2 169.3L439.2 297.3L441.4 299.7z"/>
<path
transform="scale(0.5) translate(120, 320)"
d="M259.1 73.5C262.1 58.7 275.2 48 290.4 48L350.2 48C365.4 48 378.5 58.7 381.5 73.5L396 143.5C410.1 149.5 423.3 157.2 435.3 166.3L503.1 143.8C517.5 139 533.3 145 540.9 158.2L570.8 210C578.4 223.2 575.7 239.8 564.3 249.9L511 297.3C511.9 304.7 512.3 312.3 512.3 320C512.3 327.7 511.8 335.3 511 342.7L564.4 390.2C575.8 400.3 578.4 417 570.9 430.1L541 481.9C533.4 495 517.6 501.1 503.2 496.3L435.4 473.8C423.3 482.9 410.1 490.5 396.1 496.6L381.7 566.5C378.6 581.4 365.5 592 350.4 592L290.6 592C275.4 592 262.3 581.3 259.3 566.5L244.9 496.6C230.8 490.6 217.7 482.9 205.6 473.8L137.5 496.3C123.1 501.1 107.3 495.1 99.7 481.9L69.8 430.1C62.2 416.9 64.9 400.3 76.3 390.2L129.7 342.7C128.8 335.3 128.4 327.7 128.4 320C128.4 312.3 128.9 304.7 129.7 297.3L76.3 249.8C64.9 239.7 62.3 223 69.8 209.9L99.7 158.1C107.3 144.9 123.1 138.9 137.5 143.7L205.3 166.2C217.4 157.1 230.6 149.5 244.6 143.4L259.1 73.5zM320.3 400C364.5 399.8 400.2 363.9 400 319.7C399.8 275.5 363.9 239.8 319.7 240C275.5 240.2 239.8 276.1 240 320.3C240.2 364.5 276.1 400.2 320.3 400z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M441.3 299.8C451.5 312.4 450.8 330.9 439.1 342.6L311.1 470.6C301.9 479.8 288.2 482.5 276.2 477.5C264.2 472.5 256.5 460.9 256.5 448L256.5 192C256.5 179.1 264.3 167.4 276.3 162.4C288.3 157.4 302 160.2 311.2 169.3L439.2 297.3L441.4 299.7z"/></svg>

Before

Width:  |  Height:  |  Size: 467 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M176 96C149.5 96 128 117.5 128 144L128 496C128 522.5 149.5 544 176 544L240 544C266.5 544 288 522.5 288 496L288 144C288 117.5 266.5 96 240 96L176 96zM400 96C373.5 96 352 117.5 352 144L352 496C352 522.5 373.5 544 400 544L464 544C490.5 544 512 522.5 512 496L512 144C512 117.5 490.5 96 464 96L400 96z"/></svg>

Before

Width:  |  Height:  |  Size: 528 B

View File

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.-->
<path
d="M187.2 100.9C174.8 94.1 159.8 94.4 147.6 101.6C135.4 108.8 128 121.9 128 136L128 504C128 518.1 135.5 531.2 147.6 538.4C159.7 545.6 174.8 545.9 187.2 539.1L523.2 355.1C536 348.1 544 334.6 544 320C544 305.4 536 291.9 523.2 284.9L187.2 100.9z"/>
</svg>

Before

Width:  |  Height:  |  Size: 486 B

View File

@@ -1,17 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.-->
<rect x="0" y="0" width="640" height="640" fill="none" stroke="red"></rect>
<path
transform="translate(-120, 0)"
d="M199.7 299.8C189.4 312.4 190.2 330.9 201.9 342.6L329.9 470.6C339.1 479.8 352.8 482.5 364.8 477.5C376.8 472.5 384.6 460.9 384.6 447.9L384.6 191.9C384.6 179 376.8 167.3 364.8 162.3C352.8 157.3 339.1 160.1 329.9 169.2L201.9 297.2L199.7 299.6z"/>
<!-- <path transform="translate(120, 0)"-->
<!-- d="M441.3 299.8C451.5 312.4 450.8 330.9 439.1 342.6L311.1 470.6C301.9 479.8 288.2 482.5 276.2 477.5C264.2 472.5 256.5 460.9 256.5 448L256.5 192C256.5 179.1 264.3 167.4 276.3 162.4C288.3 157.4 302 160.2 311.2 169.3L439.2 297.3L441.4 299.7z"/>-->
<path
transform="scale(0.5) translate(520, 320)"
d="M259.1 73.5C262.1 58.7 275.2 48 290.4 48L350.2 48C365.4 48 378.5 58.7 381.5 73.5L396 143.5C410.1 149.5 423.3 157.2 435.3 166.3L503.1 143.8C517.5 139 533.3 145 540.9 158.2L570.8 210C578.4 223.2 575.7 239.8 564.3 249.9L511 297.3C511.9 304.7 512.3 312.3 512.3 320C512.3 327.7 511.8 335.3 511 342.7L564.4 390.2C575.8 400.3 578.4 417 570.9 430.1L541 481.9C533.4 495 517.6 501.1 503.2 496.3L435.4 473.8C423.3 482.9 410.1 490.5 396.1 496.6L381.7 566.5C378.6 581.4 365.5 592 350.4 592L290.6 592C275.4 592 262.3 581.3 259.3 566.5L244.9 496.6C230.8 490.6 217.7 482.9 205.6 473.8L137.5 496.3C123.1 501.1 107.3 495.1 99.7 481.9L69.8 430.1C62.2 416.9 64.9 400.3 76.3 390.2L129.7 342.7C128.8 335.3 128.4 327.7 128.4 320C128.4 312.3 128.9 304.7 129.7 297.3L76.3 249.8C64.9 239.7 62.3 223 69.8 209.9L99.7 158.1C107.3 144.9 123.1 138.9 137.5 143.7L205.3 166.2C217.4 157.1 230.6 149.5 244.6 143.4L259.1 73.5zM320.3 400C364.5 399.8 400.2 363.9 400 319.7C399.8 275.5 363.9 239.8 319.7 240C275.5 240.2 239.8 276.1 240 320.3C240.2 364.5 276.1 400.2 320.3 400z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M199.7 299.8C189.4 312.4 190.2 330.9 201.9 342.6L329.9 470.6C339.1 479.8 352.8 482.5 364.8 477.5C376.8 472.5 384.6 460.9 384.6 447.9L384.6 191.9C384.6 179 376.8 167.3 364.8 162.3C352.8 157.3 339.1 160.1 329.9 169.2L201.9 297.2L199.7 299.6z"/></svg>

Before

Width:  |  Height:  |  Size: 471 B

View File

@@ -1,5 +1,3 @@
// world.js
export class Block { export class Block {
constructor(id, pos, state) { constructor(id, pos, state) {
this.id = id; this.id = id;
@@ -10,7 +8,6 @@ export class Block {
export class World { export class World {
constructor(script = '') { constructor(script = '') {
// Pure Data State
this.events = []; this.events = [];
this.labels = new Map(); this.labels = new Map();
this.initialState = new Map(); this.initialState = new Map();