Spacetime Sheet
by ArcadeLab977 lines30.0 KB
<!--ARCADELAB
title: Spacetime Sheet
description: A mesmerizing gravity sandbox — place several heavy stars that dent a rubber-sheet grid, then drag-aim and flick glowing marbles. They leave orbit trails as they spiral around a star, or weave through a ready-made binary pair.
emoji: 🌌
color: purple
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Spacetime Sheet</title>
<style>
:root {
--bg: #07041a;
--panel: rgba(26, 16, 54, 0.86);
--accent: #b98cff;
--accent2: #e0b3ff;
--text: #f1e9ff;
--muted: #bfaee6;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
overflow: hidden;
background: var(--bg);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: var(--text);
-webkit-tap-highlight-color: transparent;
}
#stage { position: fixed; inset: 0; }
canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
#hud {
position: fixed;
top: 0; left: 0; right: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
padding: clamp(10px, 2.4vw, 18px);
pointer-events: none;
z-index: 5;
}
.title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: clamp(17px, 4.6vw, 22px);
letter-spacing: 0.2px;
background: var(--panel);
border: 1px solid rgba(185, 140, 255, 0.30);
border-radius: 14px;
padding: 8px 13px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.title .em { font-size: 1.15em; }
.spacer { flex: 1 1 auto; }
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
pointer-events: auto;
}
button {
pointer-events: auto;
font: inherit;
font-size: clamp(14px, 3.6vw, 16px);
font-weight: 700;
color: var(--text);
background: var(--panel);
border: 1px solid rgba(185, 140, 255, 0.34);
border-radius: 13px;
padding: 11px 15px;
min-height: 44px;
cursor: pointer;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: transform .12s ease, background .18s ease, border-color .18s ease;
}
button:hover { background: rgba(48, 30, 88, 0.95); border-color: var(--accent); }
button:active { transform: scale(0.94); }
button.on {
background: linear-gradient(180deg, #b98cff, #7d4ed0);
color: #16092e;
border-color: var(--accent2);
}
.pgroup {
display: flex;
gap: 6px;
background: var(--panel);
border: 1px solid rgba(185, 140, 255, 0.30);
border-radius: 15px;
padding: 5px;
pointer-events: auto;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.pgroup button {
background: transparent;
border: none;
min-width: 46px;
padding: 9px 12px;
}
.pgroup button:hover { background: rgba(185, 140, 255, 0.18); }
.pgroup button.on {
background: linear-gradient(180deg, #b98cff, #7d4ed0);
color: #16092e;
}
.massbox {
display: flex;
align-items: center;
gap: 10px;
background: var(--panel);
border: 1px solid rgba(185, 140, 255, 0.30);
border-radius: 15px;
padding: 8px 14px;
pointer-events: auto;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.massbox label {
font-size: clamp(13px, 3.4vw, 15px);
font-weight: 700;
color: var(--accent2);
white-space: nowrap;
}
input[type=range] {
-webkit-appearance: none;
appearance: none;
width: clamp(96px, 26vw, 180px);
height: 30px;
background: transparent;
pointer-events: auto;
cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track {
height: 10px;
border-radius: 6px;
background: linear-gradient(90deg, #6a4aa8, #b98cff);
}
input[type=range]::-moz-range-track {
height: 10px;
border-radius: 6px;
background: linear-gradient(90deg, #6a4aa8, #b98cff);
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 28px; height: 28px;
margin-top: -9px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #fff, #c79bff);
border: 2px solid #e0b3ff;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
input[type=range]::-moz-range-thumb {
width: 28px; height: 28px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #fff, #c79bff);
border: 2px solid #e0b3ff;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
#info {
position: fixed;
left: 0; right: 0; bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: clamp(10px, 2.4vw, 18px);
pointer-events: none;
z-index: 5;
}
#hint {
background: var(--panel);
border: 1px solid rgba(185, 140, 255, 0.22);
border-radius: 13px;
padding: 8px 14px;
font-size: clamp(13px, 3.4vw, 15px);
color: var(--muted);
text-align: center;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
max-width: 92vw;
}
#howBtn { pointer-events: auto; }
#howPanel {
pointer-events: auto;
display: none;
background: var(--panel);
border: 1px solid rgba(185, 140, 255, 0.30);
border-radius: 14px;
padding: 14px 16px;
max-width: min(440px, 92vw);
font-size: clamp(13px, 3.5vw, 15px);
line-height: 1.5;
color: var(--text);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
#howPanel.show { display: block; }
#howPanel h3 { margin: 0 0 6px; font-size: 1.05em; color: var(--accent2); }
#howPanel p { margin: 6px 0; }
#stage { opacity: 0; transition: opacity .8s ease; }
#stage.ready { opacity: 1; }
</style>
</head>
<body>
<div id="stage">
<canvas id="c"></canvas>
</div>
<div id="hud">
<div class="title"><span class="em">🌌</span><span>Spacetime Sheet</span></div>
<div class="spacer"></div>
<div class="massbox">
<label for="mass">Mass</label>
<input id="mass" type="range" min="0" max="100" value="55">
</div>
<div class="pgroup">
<button id="modeFlick" type="button">Flick</button>
<button id="modeMass" type="button">+Mass</button>
</div>
<div class="controls">
<button id="binary" type="button">Binary</button>
<button id="clear" type="button">Clear</button>
</div>
</div>
<div id="info">
<div id="howPanel">
<h3>How it works</h3>
<p>Heavy things bend space around them, like a bowling ball on a stretchy sheet.</p>
<p>Marbles you flick aren't pushed by a force — they just roll straight, but the bent sheet curves their path.</p>
<p>In <b>Flick</b> mode, drag back and let go — the arrow shows your aim and speed. In <b>+Mass</b> mode, tap to add more heavy stars. Try the <b>Binary</b> button for two stars dancing together!</p>
<p>That curve is gravity! Some marbles fall in, some swing into orbits, just like planets around the Sun.</p>
</div>
<div id="hint">Flick anywhere to send a marble across the warped grid</div>
<button id="howBtn" type="button">How it works</button>
</div>
<script>
(function () {
"use strict";
var stage = document.getElementById("stage");
var canvas = document.getElementById("c");
var ctx = canvas.getContext("2d");
if (!ctx) return;
var massSlider = document.getElementById("mass");
var hintEl = document.getElementById("hint");
var W = 0, H = 0, DPR = 1;
// ---- simulation tuning ----
var GRID_N = 22; // grid cells per side (square grid)
var DT = 1 / 60;
var SUBSTEPS = 3;
var marbles = [];
var stars = []; // background twinkles
var paletteIdx = 0;
var palette = [
[196, 90, 72], [320, 88, 76], [44, 95, 70],
[150, 72, 66], [262, 88, 78], [12, 92, 72]
];
// mass: 0..1 fraction from slider — sets the size of newly placed masses
var massFrac = 0.55;
// all the heavy masses on the sheet (world coords). Each has its own
// strength `m` (0..1). The first one is created at boot.
var masses = [];
var MAX_MASSES = 6;
// interaction mode: "flick" launches marbles, "mass" places masses on tap
var mode = "flick";
function addMass(x, y, m) {
if (masses.length >= MAX_MASSES) masses.shift();
masses.push({ x: x, y: y, m: clamp(m, 0, 1) });
}
// perspective projection params (recomputed on resize)
var proj = {
cx: 0, cy: 0, // screen center of sheet
halfW: 0, // half-width of sheet in world units
tilt: 0.52, // vertical squash for perspective
scale: 1, // world->screen scale
depthScale: 0.6 // far edge shrink factor
};
function rand(a, b) { return a + Math.random() * (b - a); }
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
// ---- layout / resize ----
function resize() {
var rect = stage.getBoundingClientRect();
W = Math.max(1, rect.width);
H = Math.max(1, rect.height);
DPR = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * DPR);
canvas.height = Math.round(H * DPR);
canvas.style.width = W + "px";
canvas.style.height = H + "px";
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
// sheet spans most of the window
proj.cx = W / 2;
proj.cy = H * 0.52;
proj.halfW = 1.0; // world units: sheet from -1..1
proj.scale = Math.min(W * 0.46, H * 0.62);
proj.tilt = clamp(H / W, 0.34, 0.62); // taller screens => more tilt
makeStars();
}
function makeStars() {
stars = [];
var n = Math.round((W * H) / 12000);
n = Math.max(40, Math.min(220, n));
for (var i = 0; i < n; i++) {
stars.push({
x: Math.random() * W,
y: Math.random() * H,
r: rand(0.3, 1.4),
a: rand(0.12, 0.7),
tw: rand(0, Math.PI * 2),
sp: rand(0.4, 1.6)
});
}
}
// ---- the well: depth of the sheet at world point (x,y) ----
// depth is positive going "down". Visualized by lowering screen-y.
// Each mass adds its own funnel; we sum them.
function wellDepth(x, y) {
var core = 0.16; // core softening radius
var d = 0;
for (var i = 0; i < masses.length; i++) {
var ms = masses[i];
var dx = x - ms.x;
var dy = y - ms.y;
var r = Math.sqrt(dx * dx + dy * dy);
var strength = 0.06 + ms.m * 0.95;
d += strength * core / (r + core);
}
return d;
}
// gradient of depth (downhill direction) for marble physics.
// returns acceleration vector summed over every mass.
function gravityAccel(x, y) {
var soft = 0.026; // softening so it never blows up
var maxF = 60;
var ax = 0, ay = 0;
for (var i = 0; i < masses.length; i++) {
var ms = masses[i];
var dx = ms.x - x;
var dy = ms.y - y;
var r2 = dx * dx + dy * dy;
var r = Math.sqrt(r2 + soft);
var strength = 0.10 + ms.m * 2.6;
var f = strength / (r2 + soft);
if (f > maxF) f = maxF;
ax += dx / r * f;
ay += dy / r * f;
}
return { ax: ax, ay: ay };
}
// ---- project a world point on the (warped) sheet to screen coords ----
// world x,y in roughly -1.4..1.4. Perspective: far (negative y) is up & narrower.
function project(x, y) {
// perspective depth factor: y from -1 (far) to +1 (near)
var t = (y + proj.halfW) / (2 * proj.halfW); // 0 far .. 1 near
t = clamp(t, -0.4, 1.4);
var persp = proj.depthScale + (1 - proj.depthScale) * t;
var sx = proj.cx + x * proj.scale * persp;
// base screen y: far rows up, near rows down
var sy = proj.cy + (y * proj.scale * proj.tilt);
// apply the well: dip lowers the point on screen
var d = wellDepth(x, y);
sy += d * proj.scale * 1.05;
return { x: sx, y: sy, persp: persp, depth: d };
}
// ---- marbles ----
function makeMarble(x, y, vx, vy) {
var p = palette[paletteIdx % palette.length];
paletteIdx++;
return {
x: x, y: y, vx: vx, vy: vy,
hue: p[0], sat: p[1], lit: p[2],
r: rand(0.030, 0.046),
trail: [],
alive: true,
age: 0
};
}
function step(dt) {
for (var i = marbles.length - 1; i >= 0; i--) {
var m = marbles[i];
// velocity-Verlet
var a0 = gravityAccel(m.x, m.y);
m.vx += a0.ax * dt * 0.5;
m.vy += a0.ay * dt * 0.5;
m.x += m.vx * dt;
m.y += m.vy * dt;
var a1 = gravityAccel(m.x, m.y);
m.vx += a1.ax * dt * 0.5;
m.vy += a1.ay * dt * 0.5;
m.age += dt;
// swallowed by any mass it falls into
for (var k = 0; k < masses.length; k++) {
var ms = masses[k];
var dx = m.x - ms.x, dy = m.y - ms.y;
var swallowR = 0.05 + ms.m * 0.05;
if (dx * dx + dy * dy < swallowR * swallowR) {
m.alive = false;
break;
}
}
// wandered far off the sheet
if (m.x < -2.0 || m.x > 2.0 || m.y < -2.0 || m.y > 2.0) {
m.alive = false;
}
if (!m.alive) {
marbles.splice(i, 1);
}
}
}
function recordTrails() {
for (var i = 0; i < marbles.length; i++) {
var m = marbles[i];
m.trail.push(m.x, m.y);
if (m.trail.length > 90) {
m.trail.splice(0, m.trail.length - 90);
}
}
}
// ---- rendering ----
function roundRectS(x, y, w, h, r) {
if (r > w / 2) r = w / 2;
if (r > h / 2) r = h / 2;
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function drawBackground(time) {
ctx.fillStyle = "#07041a";
ctx.fillRect(0, 0, W, H);
var g = ctx.createRadialGradient(W * 0.5, H * 0.4, 0, W * 0.5, H * 0.4, Math.max(W, H) * 0.9);
g.addColorStop(0, "#1a1040");
g.addColorStop(1, "#05030f");
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
for (var i = 0; i < stars.length; i++) {
var s = stars[i];
var tw = 0.55 + 0.45 * Math.sin(time * 0.001 * s.sp + s.tw);
ctx.globalAlpha = s.a * tw;
ctx.fillStyle = "#e8ddff";
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawGrid(time) {
var n = GRID_N;
var lo = -proj.halfW * 1.18;
var hi = proj.halfW * 1.18;
var stepW = (hi - lo) / n;
var i, j;
ctx.lineJoin = "round";
ctx.lineCap = "round";
// lines running along x (constant y)
for (j = 0; j <= n; j++) {
var wy = lo + j * stepW;
ctx.beginPath();
var first = true;
for (i = 0; i <= n; i++) {
var wx = lo + i * stepW;
var p = project(wx, wy);
if (first) { ctx.moveTo(p.x, p.y); first = false; }
else ctx.lineTo(p.x, p.y);
}
// depth-based color: deeper rows glow warmer/brighter
var midDepth = wellDepth(0, wy);
var glow = clamp(midDepth * 1.4, 0, 1);
var alpha = 0.18 + glow * 0.55;
ctx.strokeStyle = "rgba(" + Math.round(150 + glow * 105) + ","
+ Math.round(120 + glow * 60) + ",255," + alpha + ")";
ctx.lineWidth = 1 + glow * 1.6;
ctx.stroke();
}
// lines running along y (constant x)
for (i = 0; i <= n; i++) {
var wx2 = lo + i * stepW;
ctx.beginPath();
var first2 = true;
for (j = 0; j <= n; j++) {
var wy2 = lo + j * stepW;
var p2 = project(wx2, wy2);
if (first2) { ctx.moveTo(p2.x, p2.y); first2 = false; }
else ctx.lineTo(p2.x, p2.y);
}
var midDepth2 = wellDepth(wx2, 0);
var glow2 = clamp(midDepth2 * 1.4, 0, 1);
var alpha2 = 0.18 + glow2 * 0.55;
ctx.strokeStyle = "rgba(" + Math.round(150 + glow2 * 105) + ","
+ Math.round(120 + glow2 * 60) + ",255," + alpha2 + ")";
ctx.lineWidth = 1 + glow2 * 1.6;
ctx.stroke();
}
}
function drawOneMass(ms, time, idx) {
var c = project(ms.x, ms.y);
var baseR = (0.10 + ms.m * 0.55) * proj.scale * 0.5;
baseR = Math.max(13, baseR);
var pulse = 1 + 0.05 * Math.sin(time * 0.002 + idx * 1.7);
var r = baseR * pulse;
// halo
var halo = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, r * 3.4);
halo.addColorStop(0, "rgba(200,150,255,0.55)");
halo.addColorStop(0.3, "rgba(160,110,255,0.30)");
halo.addColorStop(1, "rgba(120,80,200,0)");
ctx.fillStyle = halo;
ctx.beginPath();
ctx.arc(c.x, c.y, r * 3.4, 0, Math.PI * 2);
ctx.fill();
// sphere body
var body = ctx.createRadialGradient(
c.x - r * 0.35, c.y - r * 0.4, r * 0.1,
c.x, c.y, r);
body.addColorStop(0, "#ffffff");
body.addColorStop(0.45, "#d4a8ff");
body.addColorStop(1, "#5a2da0");
ctx.fillStyle = body;
ctx.beginPath();
ctx.arc(c.x, c.y, r, 0, Math.PI * 2);
ctx.fill();
// rim light
ctx.strokeStyle = "rgba(224,179,255,0.7)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(c.x, c.y, r, 0, Math.PI * 2);
ctx.stroke();
}
function drawMasses(time) {
// draw far masses first so nearer ones overlap correctly
var order = masses.slice().sort(function (a, b) { return a.y - b.y; });
for (var i = 0; i < order.length; i++) {
drawOneMass(order[i], time, i);
}
}
function drawMarbles(time) {
var i, k;
ctx.lineCap = "round";
ctx.lineJoin = "round";
// trails
for (i = 0; i < marbles.length; i++) {
var m = marbles[i];
var t = m.trail;
if (t.length >= 4) {
var segs = t.length / 2 - 1;
for (k = 0; k < segs; k++) {
var frac = k / segs;
var p1 = project(t[k * 2], t[k * 2 + 1]);
var p2 = project(t[k * 2 + 2], t[k * 2 + 3]);
ctx.globalAlpha = 0.05 + frac * frac * 0.55;
ctx.lineWidth = 0.6 + frac * 4;
ctx.strokeStyle = "hsl(" + m.hue + "," + m.sat + "%," + m.lit + "%)";
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
}
}
}
ctx.globalAlpha = 1;
// marble bodies
for (i = 0; i < marbles.length; i++) {
var mm = marbles[i];
var p = project(mm.x, mm.y);
var sr = Math.max(4, mm.r * proj.scale * p.persp);
var glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, sr * 3);
glow.addColorStop(0, "hsla(" + mm.hue + "," + mm.sat + "%,80%,0.5)");
glow.addColorStop(1, "hsla(" + mm.hue + "," + mm.sat + "%,80%,0)");
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(p.x, p.y, sr * 3, 0, Math.PI * 2);
ctx.fill();
var body = ctx.createRadialGradient(
p.x - sr * 0.35, p.y - sr * 0.35, 0, p.x, p.y, sr);
body.addColorStop(0, "hsl(" + mm.hue + "," + mm.sat + "%,88%)");
body.addColorStop(1, "hsl(" + mm.hue + "," + mm.sat + "%," + mm.lit + "%)");
ctx.fillStyle = body;
ctx.beginPath();
ctx.arc(p.x, p.y, sr, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(255,255,255,0.9)";
ctx.beginPath();
ctx.arc(p.x - sr * 0.32, p.y - sr * 0.32, sr * 0.35, 0, Math.PI * 2);
ctx.fill();
}
}
// aiming preview
var aiming = false;
var aimPointerId = null;
var aimStart = { x: 0, y: 0 }; // screen coords
var aimNow = { x: 0, y: 0 };
// ghost-star cursor position for +Mass mode (null when finger is off-canvas)
var ghost = { x: null, y: null };
function drawAim() {
if (!aiming || mode !== "flick") return;
var dx = aimStart.x - aimNow.x;
var dy = aimStart.y - aimNow.y;
ctx.setLineDash([7, 7]);
ctx.lineWidth = 2.5;
ctx.strokeStyle = "rgba(224,179,255,0.7)";
ctx.beginPath();
ctx.moveTo(aimNow.x, aimNow.y);
ctx.lineTo(aimStart.x, aimStart.y);
ctx.stroke();
ctx.setLineDash([]);
var len = Math.sqrt(dx * dx + dy * dy);
if (len > 6) {
var ang = Math.atan2(dy, dx);
var ah = 13;
ctx.fillStyle = "rgba(224,179,255,0.9)";
ctx.beginPath();
ctx.moveTo(aimStart.x, aimStart.y);
ctx.lineTo(aimStart.x - ah * Math.cos(ang - 0.4), aimStart.y - ah * Math.sin(ang - 0.4));
ctx.lineTo(aimStart.x - ah * Math.cos(ang + 0.4), aimStart.y - ah * Math.sin(ang + 0.4));
ctx.closePath();
ctx.fill();
// speed readout near the finger — 0..100 scaled by drag length
var spd = Math.min(100, Math.round(len / 2.6));
var label = "Speed " + spd;
ctx.font = "700 13px system-ui, sans-serif";
var tw = ctx.measureText(label).width;
var bx = aimNow.x + 16, by = aimNow.y + 16;
if (bx + tw + 14 > W) bx = aimNow.x - tw - 30;
if (by + 24 > H) by = aimNow.y - 30;
ctx.fillStyle = "rgba(26,16,54,0.92)";
roundRectS(bx, by, tw + 18, 24, 9);
ctx.fill();
ctx.fillStyle = "#e0b3ff";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(label, bx + 9, by + 13);
}
// ghost marble at start
var pg = ctx.createRadialGradient(aimNow.x, aimNow.y, 0, aimNow.x, aimNow.y, 16);
pg.addColorStop(0, "rgba(255,255,255,0.9)");
pg.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = pg;
ctx.beginPath();
ctx.arc(aimNow.x, aimNow.y, 16, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#fff";
ctx.beginPath();
ctx.arc(aimNow.x, aimNow.y, 5, 0, Math.PI * 2);
ctx.fill();
}
function draw(time) {
drawBackground(time);
drawGrid(time);
drawMasses(time);
drawMarbles(time);
drawAim();
drawMassGhost(time);
}
// in +Mass mode, show a faint ghost star where a tap will drop a new mass.
// Follows the live finger position while placing, or the last hover point.
function drawMassGhost(time) {
if (mode !== "mass") return;
var gx, gy;
if (aiming) { gx = aimNow.x; gy = aimNow.y; }
else if (ghost.x !== null) { gx = ghost.x; gy = ghost.y; }
else return;
var r = (0.10 + massFrac * 0.55) * proj.scale * 0.5;
r = Math.max(13, r);
var pulse = 0.5 + 0.2 * Math.sin(time * 0.006);
ctx.globalAlpha = pulse;
ctx.strokeStyle = "rgba(224,179,255,0.9)";
ctx.setLineDash([6, 6]);
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(gx, gy, r, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
// plus sign in the centre
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(gx - 8, gy); ctx.lineTo(gx + 8, gy);
ctx.moveTo(gx, gy - 8); ctx.lineTo(gx, gy + 8);
ctx.stroke();
ctx.globalAlpha = 1;
}
// ---- main loop ----
function frame(time) {
var sub = DT / SUBSTEPS;
for (var s = 0; s < SUBSTEPS; s++) {
step(sub);
}
recordTrails();
draw(time);
requestAnimationFrame(frame);
}
// ---- screen <-> world conversion ----
// Invert the flat part of the projection (ignore the well dip — good enough
// for placing a marble where the child taps).
function screenToWorld(sx, sy) {
// first guess assuming near row (persp ~ 1)
var wy = (sy - proj.cy) / (proj.scale * proj.tilt);
wy = clamp(wy, -1.3, 1.3);
// iterate to account for perspective shrink in x
var wx = 0;
for (var it = 0; it < 4; it++) {
var t = (wy + proj.halfW) / (2 * proj.halfW);
t = clamp(t, -0.4, 1.4);
var persp = proj.depthScale + (1 - proj.depthScale) * t;
wx = (sx - proj.cx) / (proj.scale * persp);
}
return { x: wx, y: wy };
}
// ---- input ----
function pointFromEvent(e) {
var rect = canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
canvas.addEventListener("pointerdown", function (e) {
e.preventDefault();
if (aiming) return;
var pt = pointFromEvent(e);
aiming = true;
aimPointerId = e.pointerId;
aimStart.x = pt.x; aimStart.y = pt.y;
aimNow.x = pt.x; aimNow.y = pt.y;
ghost.x = pt.x; ghost.y = pt.y;
if (canvas.setPointerCapture) {
try { canvas.setPointerCapture(e.pointerId); } catch (err) {}
}
});
canvas.addEventListener("pointermove", function (e) {
var pt = pointFromEvent(e);
// keep the +Mass ghost cursor following any hover/drag
ghost.x = pt.x; ghost.y = pt.y;
if (!aiming || e.pointerId !== aimPointerId) return;
e.preventDefault();
aimNow.x = pt.x; aimNow.y = pt.y;
});
function endAim(e) {
if (!aiming || e.pointerId !== aimPointerId) return;
e.preventDefault();
aiming = false;
aimPointerId = null;
// ---- +Mass mode: a tap or drag drops a new heavy star ----
if (mode === "mass") {
var mw = screenToWorld(aimNow.x, aimNow.y);
mw.x = clamp(mw.x, -1.15, 1.15);
mw.y = clamp(mw.y, -1.15, 1.15);
addMass(mw.x, mw.y, massFrac);
hintEl.textContent = masses.length >= MAX_MASSES
? "That's a lot of stars! Flick marbles to watch them weave."
: "Star added! Tap again, or switch to Flick to launch marbles.";
return;
}
// marble starts where the pointer currently is; velocity along the flick.
var startW = screenToWorld(aimNow.x, aimNow.y);
var endW = screenToWorld(aimStart.x, aimStart.y);
// flick direction = from aimNow toward aimStart (pull-back style)
var vx = (aimStart.x - aimNow.x);
var vy = (aimStart.y - aimNow.y);
var pxLen = Math.sqrt(vx * vx + vy * vy);
// convert screen drag to world velocity
var velScale = 2.4 / proj.scale;
var wvx = vx * velScale;
var wvy = (vy * velScale) / proj.tilt;
var near = nearestMass(startW.x, startW.y);
if (pxLen < 8 && near) {
// a tap: drop a marble that gets a gentle sideways nudge for an orbit
// around whichever mass is closest.
var dx = startW.x - near.x;
var dy = startW.y - near.y;
var r = Math.sqrt(dx * dx + dy * dy) || 0.001;
// tangential direction
var orbitSpeed = Math.sqrt((0.10 + near.m * 2.6) / r) * 0.85;
wvx = -dy / r * orbitSpeed;
wvy = dx / r * orbitSpeed;
}
// don't spawn right on top of a mass
if (near) {
var ddx = startW.x - near.x, ddy = startW.y - near.y;
if (ddx * ddx + ddy * ddy < 0.02) {
startW.x += 0.25;
}
}
if (marbles.length > 40) marbles.shift();
marbles.push(makeMarble(startW.x, startW.y, wvx, wvy));
}
// find the mass closest to a world point (null if there are none)
function nearestMass(x, y) {
var best = null, bestD = Infinity;
for (var i = 0; i < masses.length; i++) {
var ms = masses[i];
var dx = ms.x - x, dy = ms.y - y;
var d = dx * dx + dy * dy;
if (d < bestD) { bestD = d; best = ms; }
}
return best;
}
canvas.addEventListener("pointerup", endAim);
canvas.addEventListener("pointercancel", function (e) {
if (aiming && e.pointerId === aimPointerId) {
aiming = false;
aimPointerId = null;
}
});
// ---- controls ----
// The slider sets the size of new stars AND live-resizes the most recent
// one, so with a single star it behaves just like before.
massSlider.addEventListener("input", function () {
var v = parseFloat(massSlider.value);
if (isNaN(v)) v = 55;
massFrac = clamp(v / 100, 0, 1);
if (masses.length > 0) masses[masses.length - 1].m = massFrac;
});
var modeFlickBtn = document.getElementById("modeFlick");
var modeMassBtn = document.getElementById("modeMass");
function setMode(m) {
mode = m;
if (modeFlickBtn) modeFlickBtn.classList.toggle("on", m === "flick");
if (modeMassBtn) modeMassBtn.classList.toggle("on", m === "mass");
hintEl.textContent = m === "mass"
? "Tap the sheet to place a heavy star (up to " + MAX_MASSES + ")"
: "Flick anywhere to send a marble across the warped grid";
}
if (modeFlickBtn) modeFlickBtn.addEventListener("click", function () { setMode("flick"); });
if (modeMassBtn) modeMassBtn.addEventListener("click", function () { setMode("mass"); });
// Binary: two equal stars side by side with marbles orbiting the pair.
// The pair sits a little above world-center so the deep well it digs
// still leaves both stars comfortably on screen (not clipped at the bottom).
var BINARY_CY = -0.48;
document.getElementById("binary").addEventListener("click", function () {
masses = [];
marbles = [];
var bm = Math.max(0.55, massFrac);
addMass(-0.42, BINARY_CY, bm);
addMass(0.42, BINARY_CY, bm);
setMode("flick");
seedBinary(bm);
hintEl.textContent = "Two stars! Watch marbles weave around the pair.";
});
document.getElementById("clear").addEventListener("click", function () {
marbles = [];
});
var howBtn = document.getElementById("howBtn");
var howPanel = document.getElementById("howPanel");
howBtn.addEventListener("click", function () {
howPanel.classList.toggle("show");
});
// ---- seed a couple of pleasant orbits around the first mass ----
function seed() {
var center = masses[0] || { x: 0, y: 0, m: massFrac };
var radii = [0.42, 0.62];
for (var i = 0; i < radii.length; i++) {
var ang = rand(0, Math.PI * 2);
var r = radii[i];
var x = center.x + Math.cos(ang) * r;
var y = center.y + Math.sin(ang) * r;
var orbitSpeed = Math.sqrt((0.10 + center.m * 2.6) / r) * 0.92;
var dir = i % 2 === 0 ? 1 : -1;
var vx = -Math.sin(ang) * orbitSpeed * dir;
var vy = Math.cos(ang) * orbitSpeed * dir;
marbles.push(makeMarble(x, y, vx, vy));
}
}
// ---- seed marbles that loop around the whole binary pair ----
function seedBinary(bm) {
var radii = [0.95, 1.18, 1.18];
for (var i = 0; i < radii.length; i++) {
var ang = rand(0, Math.PI * 2);
var r = radii[i];
// orbit around the binary pair's centre (which is lifted by BINARY_CY)
var x = Math.cos(ang) * r;
var y = BINARY_CY + Math.sin(ang) * r;
// pair acts like one mass of ~2x bm at the centre for a wide orbit
var orbitSpeed = Math.sqrt((0.10 + bm * 2.6 * 2) / r) * 0.78;
var dir = i % 2 === 0 ? 1 : -1;
var vx = -Math.sin(ang) * orbitSpeed * dir;
var vy = Math.cos(ang) * orbitSpeed * dir;
marbles.push(makeMarble(x, y, vx, vy));
}
}
// ---- boot ----
window.addEventListener("resize", resize);
resize();
massFrac = clamp(parseFloat(massSlider.value) / 100, 0, 1);
addMass(0, 0, massFrac); // the starting central star
setMode("flick");
seed();
requestAnimationFrame(function () {
requestAnimationFrame(frame);
});
requestAnimationFrame(function () {
stage.classList.add("ready");
});
})();
</script>
</body>
</html>
Game Source: Spacetime Sheet
Creator: ArcadeLab
Libraries: none
Complexity: complex (977 lines, 30.0 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: spacetime-sheet-arcadelab" to link back to the original. Then publish at arcadelab.ai/publish.