From be29c0138da771561a7c8ade128137b666b82bda Mon Sep 17 00:00:00 2001 From: David Allemang Date: Sat, 4 Jul 2026 23:42:25 -0400 Subject: [PATCH] wip timeline --- index.html | 141 +++++++++++++++++++++------------------------------- timeline.js | 119 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 83 deletions(-) create mode 100644 timeline.js diff --git a/index.html b/index.html index f8558483..803c21ca 100644 --- a/index.html +++ b/index.html @@ -129,6 +129,7 @@ import {Engine} from './engine.js'; import {Diorama} from './diorama.js'; import {World} from './world.js'; + import {Timeline} from './timeline.js'; async function initDocument() { try { @@ -157,37 +158,6 @@ world1.set('lodestone', 0, -1, 0); world1.set('piston', 0, -2, 0, {facing: 'north', extended: 'false'}); - const world2 = new World(); - world2.set('minecraft:redstone_wire', 0, 0, 0, { - east: 'side', - west: 'none', - north: 'none', - south: 'none', - power: '0' - }); - world2.set('minecraft:repeater', 1, 0, 0, { - facing: 'east', - delay: '4', - locked: 'false', - powered: 'false' - }); - world2.set('minecraft:sticky_piston', 2, 0, 0, {facing: 'east', extended: 'false'}); - world2.set('minecraft:sticky_piston', 2, 0, 1, {facing: 'east', extended: 'true'}); - world2.set('minecraft:sticky_piston', 2, 0, 2, {facing: 'east', extended: 'true'}); - world2.set('minecraft:piston_head', 2.5, 0, 1, { - facing: 'east', - short: 'true', - type: 'sticky' - }); - world2.set('minecraft:piston_head', 3, 0, 2, { - facing: 'east', - short: 'false', - type: 'sticky' - }); - world2.set('minecraft:glass', 3, 0, 0); - world2.set('minecraft:observer', 3.5, 0, 1, {facing: 'up', powered: 'true'}); - world2.set('minecraft:redstone_block', 4, 0, 2); - const demo1 = new Diorama('demo-1', world1, () => engine.requestRender()) demo1.radius = 2; demo1.phi = 10; @@ -196,70 +166,75 @@ demo1.saveState() engine.addDiorama(demo1); - const demo2 = new Diorama('demo-2', world2, () => engine.requestRender()) + const world2 = new World(); + + // Initialize the Timeline and compile the scene/animation + const timeline = new Timeline(world2); + timeline.compile(` + @-1 + place 0 0 0 minecraft:redstone_wire power=0 east=side west=none north=none south=none + place 1 0 0 minecraft:repeater facing=east delay=4 locked=false powered=false + place 2 0 0 minecraft:sticky_piston facing=east extended=false + place 2 0 1 minecraft:sticky_piston facing=east extended=true + place 2 0 2 minecraft:sticky_piston facing=east extended=true + place 2.5 0 1 minecraft:piston_head facing=east short=true type=sticky + place 3 0 2 minecraft:piston_head facing=east short=false type=sticky + place 3 0 0 minecraft:glass + place 3.5 0 1 minecraft:observer facing=up powered=true + place 4 0 2 minecraft:redstone_block + + @0 + update 2 0 2 extended=false + place 3 0 2 minecraft:redstone_block + del 2.5 0 2 + del 3.5 0 2 + + @1 + update 2 0 2 extended=true + del 3 0 2 + place 2.5 0 2 minecraft:piston_head facing=east short=true type=sticky + place 3.5 0 2 minecraft:redstone_block + + @2 + place 3 0 2 minecraft:piston_head facing=east short=false type=sticky + del 2.5 0 2 + del 3.5 0 2 + place 4 0 2 minecraft:redstone_block + + @3 + del 4 0 2 + del 3 0 2 + place 2.5 0 2 minecraft:piston_head facing=east short=true type=sticky + place 3.5 0 2 minecraft:redstone_block + `); + + // Load the initial layout + timeline.seek(0); + + const demo2 = new Diorama('demo-2', world2, () => engine.requestRender()); demo2.radius = 4; demo2.theta = 180; demo2.phi = 90; - demo2.centerView() - demo2.saveState() + demo2.centerView(); + demo2.saveState(); engine.addDiorama(demo2); - const demo3 = new Diorama('demo-3', world2, () => engine.requestRender()) + const demo3 = new Diorama('demo-3', world2, () => engine.requestRender()); demo3.radius = 4; demo3.theta = 0; demo3.phi = 0; - demo3.centerView() - demo3.saveState() + demo3.centerView(); + demo3.saveState(); engine.addDiorama(demo3); - // Fetch and build everything concurrently! await engine.updateAll(); - function set0() { - world2.set('minecraft:sticky_piston', 2, 0, 2, {facing: 'east', extended: 'false'}); - world2.set('minecraft:redstone_block', 3, 0, 2); - world2.del(2.5, 0, 2); - world2.del(3.5, 0, 2); - setTimeout(set1, 250) - } - - function set1() { - world2.set('minecraft:sticky_piston', 2, 0, 2, {facing: 'east', extended: 'true'}); - world2.del(3, 0, 2) - world2.set('minecraft:piston_head', 2.5, 0, 2, { - facing: 'east', - short: 'true', - type: 'sticky' - }); - world2.set('minecraft:redstone_block', 3.5, 0, 2); - setTimeout(set2, 250) - } - - function set2() { - world2.set('minecraft:piston_head', 3, 0, 2, { - facing: 'east', - short: 'false', - type: 'sticky' - }); - world2.del(2.5, 0, 2); - world2.del(3.5, 0, 2); - world2.set('minecraft:redstone_block', 4, 0, 2); - setTimeout(set3, 250) - } - - function set3() { - world2.del(4, 0, 2) - world2.del(3, 0, 2) - world2.set('minecraft:piston_head', 2.5, 0, 2, { - facing: 'east', - short: 'true', - type: 'sticky' - }); - world2.set('minecraft:redstone_block', 3.5, 0, 2); - setTimeout(set0, 250) - } - - setTimeout(set3, 1000) + // Manually cycle through the timeline ticks for the demo + let currentTick = 0; + setInterval(() => { + timeline.seek(currentTick); + currentTick = (currentTick + 1) % 4; + }, 1000); } catch (err) { console.error("Renderer Initialization Failed:", err); } diff --git a/timeline.js b/timeline.js new file mode 100644 index 00000000..6668c093 --- /dev/null +++ b/timeline.js @@ -0,0 +1,119 @@ +// timeline.js + +export class Timeline { + constructor(world) { + this.world = world; + this.events = []; + this.cursorIndex = 0; + this.currentTime = -1; + } + + /** + * Parses the custom syntax and builds the reversible event array. + */ + compile(script) { + this.events = []; + let currentTime = 0; + let subTick = 0; + + // We use a shadow map to track what the world *will* look like + // at any point during parsing, so we can capture accurate undo states. + const shadowWorld = new Map(); + + const lines = script.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')); + + for (const line of lines) { + if (line.startsWith('@')) { + currentTime = parseFloat(line.substring(1)); + subTick = 0; + continue; + } + + const tokens = line.split(' '); + const action = tokens[0]; + const x = parseFloat(tokens[1]); + const y = parseFloat(tokens[2]); + const z = parseFloat(tokens[3]); + const key = `${x},${y},${z}`; + + const propsArray = tokens.slice(action === 'place' ? 5 : 4); + const state = {}; + for (const prop of propsArray) { + const [k, v] = prop.split('='); + state[k] = v; + } + + // Capture the current shadow state for the undo action + const prevState = shadowWorld.has(key) ? JSON.parse(JSON.stringify(shadowWorld.get(key))) : null; + + if (action === 'place') { + const id = tokens[4]; + const blockData = { id, state }; + + // Update shadow world + shadowWorld.set(key, blockData); + + this.events.push({ + time: currentTime, + subTick: subTick++, + forward: (world) => world.set(id, x, y, z, { ...state }), + backward: (world) => { + if (prevState) world.set(prevState.id, x, y, z, { ...prevState.state }); + else world.del(x, y, z); + } + }); + + } else if (action === 'update') { + // Update shadow world + if (shadowWorld.has(key)) { + const block = shadowWorld.get(key); + Object.assign(block.state, state); + } + + this.events.push({ + time: currentTime, + subTick: subTick++, + forward: (world) => { + const block = world.get(x, y, z); + if (block) world.set(block.id, x, y, z, { ...block.state, ...state }); + }, + backward: (world) => { + if (prevState) world.set(prevState.id, x, y, z, { ...prevState.state }); + } + }); + } else if (action === 'del') { + shadowWorld.delete(key); + + this.events.push({ + time: currentTime, + subTick: subTick++, + forward: (world) => world.del(x, y, z), + backward: (world) => { + if (prevState) world.set(prevState.id, x, y, z, { ...prevState.state }); + } + }); + } + } + } + + /** + * Scrubs the timeline to an exact tick, resolving all forward and backward + * events deterministically based on their sub-tick ordering. + */ + seek(targetTime) { + // Scrub Forward + while (this.cursorIndex < this.events.length && this.events[this.cursorIndex].time <= targetTime) { + this.events[this.cursorIndex].forward(this.world); + this.cursorIndex++; + } + + // Scrub Backward + // Notice the strictly reverse evaluation order + while (this.cursorIndex > 0 && this.events[this.cursorIndex - 1].time > targetTime) { + this.cursorIndex--; + this.events[this.cursorIndex].backward(this.world); + } + + this.currentTime = targetTime; + } +} \ No newline at end of file