|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Inventing on Principle — Time Travel Debugger</title> |
|
<style> |
|
:root{ |
|
--sky-top:#7fb2e5; --sky-bot:#cfe6f7; |
|
--paper:#fbfaf6; --ink:#2a2a30; --ink-dim:#8a8a92; |
|
--kw:#a33ea1; --num:#1a53d6; --comment:#3f8f4f; |
|
--future:#ff8c1a; --past:#9aa4ad; --accent:#1a53d6; |
|
--panel:#1d232b; --panel-ink:#dfe6ee; |
|
font-size:15px; |
|
} |
|
*{box-sizing:border-box; margin:0; padding:0;} |
|
body{ |
|
background:#12161c; color:var(--panel-ink); |
|
font-family:"Avenir Next","Segoe UI",system-ui,sans-serif; |
|
min-height:100vh; display:flex; flex-direction:column; align-items:center; |
|
padding:24px 16px 48px; |
|
} |
|
header{max-width:1060px; width:100%; margin-bottom:14px;} |
|
header h1{font-size:1.35rem; font-weight:600; letter-spacing:.01em;} |
|
header p{color:#9fb0c2; font-size:.9rem; margin-top:4px; max-width:70ch;} |
|
header .credit{color:#6d7c8c;} |
|
.stage{ |
|
display:grid; grid-template-columns: minmax(340px,560px) minmax(320px,440px); |
|
gap:0; max-width:1060px; width:100%; |
|
border-radius:10px; overflow:hidden; |
|
box-shadow:0 20px 60px rgba(0,0,0,.5); |
|
} |
|
.game-side{background:linear-gradient(var(--sky-top),var(--sky-bot)); position:relative;} |
|
canvas{display:block; width:100%; height:auto;} |
|
.code-side{ |
|
background:var(--paper); color:var(--ink); |
|
font-family:"SF Mono",Menlo,Consolas,"Liberation Mono",monospace; |
|
font-size:.82rem; line-height:1.75; padding:22px 20px 18px 24px; |
|
border-left:1px solid #d8d5cc; |
|
user-select:none; |
|
} |
|
.code-side .cm{color:var(--comment);} |
|
.code-side .kw{color:var(--kw);} |
|
.code-side .plain{color:var(--ink);} |
|
.num{ |
|
color:var(--num); background:#e4ecff; border-radius:3px; |
|
padding:0 3px; cursor:ew-resize; font-weight:600; |
|
border-bottom:2px solid #b9c9f5; |
|
} |
|
.num.active{background:#1a53d6; color:#fff;} |
|
.hint{color:var(--ink-dim); font-size:.72rem; font-family:inherit; margin-top:14px;} |
|
/* transport bar */ |
|
.transport{ |
|
grid-column:1 / -1; background:var(--panel); padding:14px 18px; |
|
display:flex; align-items:center; gap:14px; flex-wrap:wrap; |
|
} |
|
.transport button{ |
|
background:#2e3946; color:#eaf1f8; border:none; border-radius:6px; |
|
padding:8px 16px; font-size:.9rem; cursor:pointer; font-weight:600; |
|
min-width:88px; |
|
} |
|
.transport button:hover{background:#3a4756;} |
|
.transport button:focus-visible{outline:2px solid var(--future); outline-offset:2px;} |
|
.transport input[type=range]{ |
|
flex:1; min-width:180px; accent-color:var(--future); height:4px; |
|
} |
|
.transport label{display:flex; align-items:center; gap:7px; font-size:.82rem; color:#b8c5d3; cursor:pointer;} |
|
.transport .frame-readout{font-family:Menlo,Consolas,monospace; font-size:.78rem; color:#8fa0b1; min-width:110px; text-align:right;} |
|
/* change log */ |
|
.log{ |
|
max-width:1060px; width:100%; margin-top:16px; |
|
background:#181e26; border:1px solid #26303b; border-radius:10px; padding:14px 18px; |
|
} |
|
.log h2{font-size:.85rem; text-transform:uppercase; letter-spacing:.1em; color:#8fa0b1; margin-bottom:8px;} |
|
.log ul{list-style:none; max-height:170px; overflow-y:auto;} |
|
.log li{ |
|
font-family:Menlo,Consolas,monospace; font-size:.78rem; color:#c9d5e0; |
|
padding:4px 0; border-bottom:1px dashed #242e39; display:flex; gap:12px; |
|
} |
|
.log li .t{color:#68788a; min-width:82px;} |
|
.log li .delta{color:var(--future);} |
|
.log li.reset .delta{color:#7fd18a;} |
|
.log .empty{color:#5c6b7b; font-style:italic; font-size:.8rem;} |
|
@media (max-width:820px){ |
|
.stage{grid-template-columns:1fr;} |
|
.code-side{border-left:none; border-top:1px solid #d8d5cc;} |
|
} |
|
@media (prefers-reduced-motion: reduce){ *{scroll-behavior:auto;} } |
|
</style> |
|
</head> |
|
<body> |
|
<header> |
|
<h1>Time travel debugger — trails, futures, and scrubbable code</h1> |
|
<p>Press <strong>Pause</strong> to freeze the world. Drag the timeline to move through the past, then <strong>drag the blue numbers in the code</strong> left or right: the orange trail is the character's future, recomputed live. <span class="credit">Homage to Bret Victor's "Inventing on Principle."</span></p> |
|
</header> |
|
|
|
<div class="stage"> |
|
<div class="game-side"> |
|
<canvas id="game" width="560" height="470" aria-label="Platformer game with a character, platforms and a star"></canvas> |
|
</div> |
|
|
|
<div class="code-side" id="code"> |
|
<div><span class="cm">// GamePlayer — drag blue values to rewrite physics</span></div> |
|
<div> </div> |
|
<div><span class="kw">this</span><span class="plain">.runSpeed = </span><span class="num" data-p="runSpeed" data-min="40" data-max="320" data-step="1" tabindex="0" role="slider" aria-label="run speed"></span><span class="plain">;</span></div> |
|
<div><span class="kw">this</span><span class="plain">.gravity = </span><span class="num" data-p="gravity" data-min="150" data-max="1400" data-step="5" tabindex="0" role="slider" aria-label="gravity"></span><span class="plain">;</span></div> |
|
<div><span class="kw">this</span><span class="plain">.jumpVelocity = -</span><span class="num" data-p="jumpVelocity" data-min="120" data-max="640" data-step="2" tabindex="0" role="slider" aria-label="jump velocity"></span><span class="plain">;</span></div> |
|
<div> </div> |
|
<div><span class="kw">if</span><span class="plain"> (hitInfo.hitStar) {</span></div> |
|
<div><span class="plain"> </span><span class="kw">this</span><span class="plain">.yVelocity = -</span><span class="num" data-p="starBounce" data-min="100" data-max="700" data-step="2" tabindex="0" role="slider" aria-label="star bounce velocity"></span><span class="plain">;</span></div> |
|
<div><span class="plain"> hitInfo.hitStar.stomp();</span></div> |
|
<div><span class="plain">}</span></div> |
|
<div> </div> |
|
<div><span class="cm">// future lookahead (frames)</span></div> |
|
<div><span class="kw">var</span><span class="plain"> lookahead = </span><span class="num" data-p="lookahead" data-min="60" data-max="600" data-step="10" tabindex="0" role="slider" aria-label="future lookahead frames"></span><span class="plain">;</span></div> |
|
<div class="hint">Drag a value horizontally, or focus it and use arrow keys. While paused, the future updates as you scrub.</div> |
|
</div> |
|
|
|
<div class="transport"> |
|
<button id="playPause">Pause</button> |
|
<input type="range" id="timeline" min="0" max="0" value="0" step="1" aria-label="timeline scrubber" disabled> |
|
<span class="frame-readout" id="frameReadout">frame 0</span> |
|
<label><input type="checkbox" id="altFutures"> Show alternate futures</label> |
|
<button id="resetBtn">Reset</button> |
|
</div> |
|
</div> |
|
|
|
<div class="log"> |
|
<h2>Change log</h2> |
|
<ul id="logList"><li class="empty">No changes yet. Pause the game and drag a value in the code.</li></ul> |
|
</div> |
|
|
|
<script> |
|
(function(){ |
|
"use strict"; |
|
var canvas = document.getElementById("game"); |
|
var ctx = canvas.getContext("2d"); |
|
var DT = 1/60; |
|
var W = canvas.width, H = canvas.height; |
|
|
|
// ---------- parameters (the "code") ---------- |
|
var DEFAULTS = { runSpeed:150, gravity:620, jumpVelocity:330, starBounce:420, lookahead:300 }; |
|
var params = Object.assign({}, DEFAULTS); |
|
|
|
// ---------- world ---------- |
|
var platforms = [ |
|
{x:0, y:400, w:250, h:70}, // left ground |
|
{x:330, y:400, w:230, h:70}, // right ground (gap = water) |
|
{x:355, y:255, w:130, h:22}, // star platform |
|
{x:120, y:315, w:90, h:18} // mid step |
|
]; |
|
var star = {x:405, y:222, r:14}; |
|
var jumpTriggers = [70, 195, 300, 470]; // x positions where a grounded runner jumps |
|
var SPAWN = {x:20, y:376, vy:0, dir:1, onGround:true, frame:0}; |
|
var PW = 18, PH = 24; |
|
|
|
function cloneState(s){ return {x:s.x, y:s.y, vy:s.vy, dir:s.dir, onGround:s.onGround, frame:s.frame}; } |
|
|
|
// ---------- deterministic simulation step ---------- |
|
function step(s, p){ |
|
var n = cloneState(s); |
|
var prevX = n.x; |
|
// horizontal |
|
n.x += p.runSpeed * n.dir * DT; |
|
if (n.x < 0){ n.x = 0; n.dir = 1; } |
|
if (n.x + PW > W){ n.x = W - PW; n.dir = -1; } |
|
// wall collisions against platform sides |
|
for (var i=0;i<platforms.length;i++){ |
|
var q = platforms[i]; |
|
if (n.y + PH > q.y + 4 && n.y < q.y + q.h){ |
|
if (n.dir > 0 && prevX + PW <= q.x && n.x + PW > q.x){ n.x = q.x - PW; n.dir = -1; } |
|
else if (n.dir < 0 && prevX >= q.x + q.w && n.x < q.x + q.w){ n.x = q.x + q.w; n.dir = 1; } |
|
} |
|
} |
|
// jump triggers |
|
if (n.onGround){ |
|
for (var j=0;j<jumpTriggers.length;j++){ |
|
var tx = jumpTriggers[j]; |
|
var crossed = (n.dir > 0 && prevX < tx && n.x >= tx) || (n.dir < 0 && prevX > tx && n.x <= tx); |
|
if (crossed){ n.vy = -p.jumpVelocity; n.onGround = false; break; } |
|
} |
|
} |
|
// vertical |
|
n.vy += p.gravity * DT; |
|
var prevY = n.y; |
|
n.y += n.vy * DT; |
|
n.onGround = false; |
|
if (n.vy >= 0){ |
|
for (var k=0;k<platforms.length;k++){ |
|
var q2 = platforms[k]; |
|
if (n.x + PW > q2.x + 2 && n.x < q2.x + q2.w - 2 && |
|
prevY + PH <= q2.y + 1 && n.y + PH >= q2.y){ |
|
n.y = q2.y - PH; n.vy = 0; n.onGround = true; break; |
|
} |
|
} |
|
} |
|
// star bounce |
|
var cx = n.x + PW/2, cy = n.y + PH/2; |
|
var dx = cx - star.x, dy = cy - star.y; |
|
if (dx*dx + dy*dy < (star.r + 14)*(star.r + 14)){ |
|
n.vy = -p.starBounce; n.onGround = false; |
|
} |
|
// fell into the water gap |
|
if (n.y > H + 60){ var r = cloneState(SPAWN); r.frame = n.frame + 1; return r; } |
|
n.frame = s.frame + 1; |
|
return n; |
|
} |
|
|
|
function simulateFuture(from, p, frames){ |
|
var out = [], s = cloneState(from); |
|
for (var i=0;i<frames;i++){ s = step(s, p); out.push(s); } |
|
return out; |
|
} |
|
|
|
// ---------- history / playback ---------- |
|
var MAX_HISTORY = 60 * 60; // 60 seconds |
|
var history = [cloneState(SPAWN)]; |
|
var cursor = 0; // index into history when paused |
|
var playing = true; |
|
var future = []; |
|
var altFutureSets = []; |
|
|
|
var timeline = document.getElementById("timeline"); |
|
var frameReadout = document.getElementById("frameReadout"); |
|
var playBtn = document.getElementById("playPause"); |
|
var altToggle = document.getElementById("altFutures"); |
|
var logList = document.getElementById("logList"); |
|
var logEmpty = true; |
|
|
|
function currentState(){ return history[playing ? history.length - 1 : cursor]; } |
|
|
|
function recomputeFuture(){ |
|
var from = currentState(); |
|
future = simulateFuture(from, params, Math.round(params.lookahead)); |
|
altFutureSets = []; |
|
if (altToggle.checked){ |
|
var factors = [0.55, 0.775, 1.225, 1.45]; |
|
for (var i=0;i<factors.length;i++){ |
|
var p2 = Object.assign({}, params); |
|
p2.jumpVelocity = params.jumpVelocity * factors[i]; |
|
p2.starBounce = params.starBounce * factors[i]; |
|
altFutureSets.push(simulateFuture(from, p2, Math.round(params.lookahead))); |
|
} |
|
} |
|
} |
|
|
|
function syncTimeline(){ |
|
timeline.max = history.length - 1; |
|
timeline.value = playing ? history.length - 1 : cursor; |
|
timeline.disabled = playing; |
|
frameReadout.textContent = "frame " + currentState().frame + |
|
(playing ? "" : " / paused"); |
|
} |
|
|
|
// ---------- logging ---------- |
|
function fmt(p, v){ |
|
v = Math.round(v); |
|
if (p === "jumpVelocity" || p === "starBounce") return "-" + v; |
|
return String(v); |
|
} |
|
function addLog(param, oldV, newV, cls){ |
|
if (logEmpty){ logList.innerHTML = ""; logEmpty = false; } |
|
var li = document.createElement("li"); |
|
if (cls) li.className = cls; |
|
var t = document.createElement("span"); t.className = "t"; |
|
t.textContent = "frame " + currentState().frame; |
|
var body = document.createElement("span"); |
|
body.innerHTML = param + ": " + fmt(param, oldV) + |
|
' <span class="delta">→ ' + fmt(param, newV) + "</span>"; |
|
li.appendChild(t); li.appendChild(body); |
|
logList.insertBefore(li, logList.firstChild); |
|
} |
|
|
|
// ---------- scrubbable numbers ---------- |
|
var numEls = Array.prototype.slice.call(document.querySelectorAll(".num")); |
|
function refreshNums(){ |
|
numEls.forEach(function(el){ |
|
var p = el.dataset.p; |
|
el.textContent = Math.round(params[p]); |
|
el.setAttribute("aria-valuenow", Math.round(params[p])); |
|
el.setAttribute("aria-valuemin", el.dataset.min); |
|
el.setAttribute("aria-valuemax", el.dataset.max); |
|
}); |
|
} |
|
function commitChange(el, startVal){ |
|
var p = el.dataset.p; |
|
if (Math.round(startVal) !== Math.round(params[p])){ |
|
addLog("this." + p, startVal, params[p]); |
|
} |
|
} |
|
numEls.forEach(function(el){ |
|
var p = el.dataset.p; |
|
var min = +el.dataset.min, max = +el.dataset.max, stepV = +el.dataset.step; |
|
var dragging = false, startX = 0, startVal = 0; |
|
|
|
el.addEventListener("pointerdown", function(e){ |
|
dragging = true; startX = e.clientX; startVal = params[p]; |
|
el.classList.add("active"); |
|
el.setPointerCapture(e.pointerId); |
|
e.preventDefault(); |
|
}); |
|
el.addEventListener("pointermove", function(e){ |
|
if (!dragging) return; |
|
var delta = (e.clientX - startX) * ((max - min) / 260); |
|
var v = Math.min(max, Math.max(min, startVal + delta)); |
|
v = Math.round(v / stepV) * stepV; |
|
if (v !== params[p]){ |
|
params[p] = v; refreshNums(); recomputeFuture(); |
|
} |
|
}); |
|
el.addEventListener("pointerup", function(e){ |
|
if (!dragging) return; |
|
dragging = false; el.classList.remove("active"); |
|
commitChange(el, startVal); |
|
}); |
|
el.addEventListener("keydown", function(e){ |
|
var dir = e.key === "ArrowRight" || e.key === "ArrowUp" ? 1 : |
|
e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : 0; |
|
if (!dir) return; |
|
e.preventDefault(); |
|
var oldV = params[p]; |
|
var v = Math.min(max, Math.max(min, params[p] + dir * stepV * (e.shiftKey ? 5 : 1))); |
|
if (v !== oldV){ |
|
params[p] = v; refreshNums(); recomputeFuture(); |
|
addLog("this." + p, oldV, v); |
|
} |
|
}); |
|
}); |
|
|
|
// ---------- transport ---------- |
|
playBtn.addEventListener("click", function(){ |
|
playing = !playing; |
|
if (!playing){ |
|
cursor = history.length - 1; |
|
recomputeFuture(); |
|
playBtn.textContent = "Play"; |
|
} else { |
|
// resume from the cursor: truncate any "unlived" history after it |
|
history = history.slice(0, cursor + 1); |
|
playBtn.textContent = "Pause"; |
|
} |
|
syncTimeline(); |
|
}); |
|
|
|
timeline.addEventListener("input", function(){ |
|
cursor = +timeline.value; |
|
recomputeFuture(); |
|
syncTimeline(); |
|
}); |
|
|
|
altToggle.addEventListener("change", function(){ recomputeFuture(); }); |
|
|
|
document.getElementById("resetBtn").addEventListener("click", function(){ |
|
var changed = Object.keys(DEFAULTS).some(function(k){ return params[k] !== DEFAULTS[k]; }); |
|
if (changed) addLog("params", NaN, NaN, "reset"); |
|
if (changed){ |
|
logList.firstChild.lastChild.innerHTML = 'restored defaults <span class="delta">↶</span>'; |
|
} |
|
params = Object.assign({}, DEFAULTS); |
|
history = [cloneState(SPAWN)]; |
|
cursor = 0; playing = true; |
|
playBtn.textContent = "Pause"; |
|
refreshNums(); syncTimeline(); |
|
}); |
|
|
|
// ---------- drawing ---------- |
|
function drawPlayerShape(x, y, dir, color, alpha, outline){ |
|
ctx.save(); |
|
ctx.globalAlpha = alpha; |
|
// body |
|
ctx.fillStyle = color; |
|
ctx.fillRect(x, y+6, PW, PH-6); |
|
// head |
|
ctx.fillStyle = outline ? color : "#f2c89b"; |
|
ctx.fillRect(x+2, y, PW-4, 9); |
|
// cap |
|
ctx.fillStyle = color; |
|
ctx.fillRect(x, y-2, PW, 5); |
|
// eye |
|
if (!outline){ |
|
ctx.fillStyle = "#222"; |
|
ctx.fillRect(dir > 0 ? x+PW-6 : x+3, y+3, 3, 3); |
|
} |
|
ctx.restore(); |
|
} |
|
|
|
function drawStar(cx, cy, r, glow){ |
|
ctx.save(); |
|
if (glow){ |
|
ctx.fillStyle = "rgba(255,220,80,0.25)"; |
|
ctx.beginPath(); ctx.arc(cx, cy, r*2.1, 0, Math.PI*2); ctx.fill(); |
|
} |
|
ctx.fillStyle = "#ffd23e"; |
|
ctx.strokeStyle = "#d99a00"; ctx.lineWidth = 2; |
|
ctx.beginPath(); |
|
for (var i=0;i<10;i++){ |
|
var ang = -Math.PI/2 + i * Math.PI/5; |
|
var rad = i % 2 === 0 ? r : r*0.45; |
|
var px = cx + Math.cos(ang)*rad, py = cy + Math.sin(ang)*rad; |
|
if (i===0) ctx.moveTo(px,py); else ctx.lineTo(px,py); |
|
} |
|
ctx.closePath(); ctx.fill(); ctx.stroke(); |
|
ctx.restore(); |
|
} |
|
|
|
function drawBricks(q){ |
|
ctx.fillStyle = "#c98a4b"; |
|
ctx.fillRect(q.x, q.y, q.w, q.h); |
|
// grass lip on thick ground |
|
ctx.fillStyle = "#63b34e"; |
|
ctx.fillRect(q.x, q.y, q.w, 6); |
|
ctx.strokeStyle = "rgba(120,70,25,0.55)"; ctx.lineWidth = 1; |
|
for (var yy = q.y + 6; yy < q.y + q.h; yy += 14){ |
|
ctx.beginPath(); ctx.moveTo(q.x, yy); ctx.lineTo(q.x + q.w, yy); ctx.stroke(); |
|
var off = (Math.floor((yy - q.y) / 14) % 2) * 14; |
|
for (var xx = q.x + off; xx < q.x + q.w; xx += 28){ |
|
ctx.beginPath(); ctx.moveTo(xx, yy); ctx.lineTo(xx, Math.min(yy+14, q.y+q.h)); ctx.stroke(); |
|
} |
|
} |
|
} |
|
|
|
function drawTrail(list, color, stride, headAlpha){ |
|
for (var i=0;i<list.length;i+=stride){ |
|
var s = list[i]; |
|
var t = i / list.length; |
|
drawPlayerShape(s.x, s.y, s.dir, color, headAlpha * (0.12 + 0.5 * (1 - Math.abs(0.5 - t))), true); |
|
} |
|
} |
|
|
|
function render(){ |
|
// sky |
|
var g = ctx.createLinearGradient(0,0,0,H); |
|
g.addColorStop(0,"#7fb2e5"); g.addColorStop(1,"#d6ecfb"); |
|
ctx.fillStyle = g; ctx.fillRect(0,0,W,H); |
|
// clouds |
|
ctx.fillStyle = "rgba(255,255,255,0.85)"; |
|
[[110,90,34],[160,80,26],[400,140,30],[445,132,22],[270,60,20]].forEach(function(c){ |
|
ctx.beginPath(); ctx.arc(c[0],c[1],c[2],0,Math.PI*2); ctx.fill(); |
|
}); |
|
// water in the gap |
|
ctx.fillStyle = "#3f7fca"; |
|
ctx.fillRect(250, 435, 80, H-435); |
|
ctx.fillStyle = "rgba(255,255,255,0.35)"; |
|
ctx.fillRect(250, 435, 80, 4); |
|
platforms.forEach(drawBricks); |
|
drawStar(star.x, star.y, star.r, true); |
|
|
|
var now = currentState(); |
|
|
|
if (!playing){ |
|
// past trail (gray) |
|
var past = history.slice(Math.max(0, cursor - 260), cursor); |
|
drawTrail(past, "#5f6b76", 7, 0.9); |
|
// alternate futures (pale blue fan) |
|
altFutureSets.forEach(function(f){ |
|
drawTrail(f, "#7f9fe0", 8, 0.55); |
|
}); |
|
// primary future (orange) |
|
drawTrail(future, "#ff8c1a", 6, 1.0); |
|
// paused banner |
|
ctx.fillStyle = "rgba(0,0,0,0.55)"; |
|
ctx.fillRect(0, 0, W, 26); |
|
ctx.fillStyle = "#ffd9ad"; |
|
ctx.font = "600 12px Menlo, Consolas, monospace"; |
|
ctx.fillText("PAUSED \u2014 gray = past orange = future" + |
|
(altFutureSets.length ? " blue = alternate jump strengths" : ""), 10, 17); |
|
} |
|
|
|
// present character |
|
drawPlayerShape(now.x, now.y, now.dir, "#d63b2f", 1, false); |
|
} |
|
|
|
// ---------- main loop ---------- |
|
function tick(){ |
|
if (playing){ |
|
var next = step(history[history.length - 1], params); |
|
history.push(next); |
|
if (history.length > MAX_HISTORY) history.shift(); |
|
syncTimeline(); |
|
} |
|
render(); |
|
requestAnimationFrame(tick); |
|
} |
|
|
|
refreshNums(); |
|
syncTimeline(); |
|
recomputeFuture(); |
|
requestAnimationFrame(tick); |
|
})(); |
|
</script> |
|
</body> |
|
</html> |