SOURCE / PINNED RELEASE
Made of little things.
Impossible Machine
- Release
- 841127ac75f3…
- Author-recorded commit
- 123625b81870…
- License
- LICENSE
- Author’s source reference
- nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-47d97da9d80
Archive hash verified: db7c8101167c4b07…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import {
storage,
themeGet,
themeOnChanged,
config,
type Theme,
type Subscription,
} from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { step, caught, endpoints, type Piece, type Ball } from './physics.js';
import './styles.css';
const $ = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T;
const canvas = $<HTMLCanvasElement>('board'),
ctx = canvas.getContext('2d')!;
const levels = [
{
start: { x: 125, y: 90 },
cup: { x: 435, y: 505 },
hint: 'Catch the fall with a tilted ramp. Build a path to the cup.',
pieces: [
{ x: 160, y: 230, angle: 0.28 },
{ x: 345, y: 380, angle: -0.2 },
],
},
{
start: { x: 100, y: 80 },
cup: { x: 495, y: 510 },
hint: 'A longer journey. Try a chain of ramps, or a well-placed bumper.',
pieces: [
{ x: 140, y: 210, angle: 0.4 },
{ x: 335, y: 350, angle: 0.2 },
],
},
{
start: { x: 470, y: 75 },
cup: { x: 115, y: 510 },
hint: 'Send it back the other way. Negative angles roll the marble left.',
pieces: [
{ x: 425, y: 220, angle: -0.3 },
{ x: 220, y: 365, angle: 0.2 },
],
},
{
start: { x: 170, y: 75 },
cup: { x: 420, y: 510 },
hint: 'No rules, no rush. Build something delightfully unnecessary.',
pieces: [{ x: 205, y: 220, angle: 0.3 }],
},
];
let level = 0,
pieces: Piece[] = [],
selected = 0,
id = 0,
ball: Ball = { x: 0, y: 0, vx: 0, vy: 0 },
mode: 'edit' | 'run' | 'won' | 'miss' = 'edit',
attempt = 0,
time = 0,
trail: { x: number; y: number }[] = [],
ghost: { x: number; y: number }[] = [],
solved = new Set<number>(),
showTrail = true;
let scale = 1,
ox = 0,
oy = 0,
w = 0,
h = 0,
drag: { id: number; dx: number; dy: number } | null = null;
const subscriptions: Subscription[] = [];
function status(s: string) {
$('status').textContent = s;
}
function update() {
const select = $<HTMLSelectElement>('piece');
select.replaceChildren(
...pieces.map((p, i) => {
const o = document.createElement('option');
o.value = String(p.id);
o.textContent = `${p.kind === 'ramp' ? 'Ramp' : 'Bumper'} ${i + 1}`;
return o;
}),
);
select.value = String(selected);
const p = pieces.find((p) => p.id === selected);
for (const key of ['left', 'right'])
$<HTMLButtonElement>(key).disabled =
!p || p.kind !== 'ramp' || mode === 'run';
$<HTMLButtonElement>('remove').disabled = !p || mode === 'run';
for (const key of ['ramp', 'bumper'])
$<HTMLButtonElement>(key).disabled = pieces.length >= 12 || mode === 'run';
$('solved').textContent = `${solved.size} / 3`;
$('phase').textContent =
mode === 'run'
? 'LET GRAVITY DO ITS THING'
: mode === 'won'
? 'A WORK OF GENIUS'
: 'YOUR WORKBENCH';
$('attempt').textContent =
`TAKE ${String(Math.max(1, attempt)).padStart(2, '0')}`;
$<HTMLButtonElement>('drop').disabled = mode === 'run';
}
function retry() {
if (trail.length) ghost = [...trail];
trail = [];
mode = 'edit';
time = 0;
ball = { ...levels[level].start, vx: 0, vy: 0 };
$('celebration').hidden = true;
status('Ready for another little experiment.');
update();
}
function load() {
pieces = levels[level].pieces.map((p) => ({ ...p, id: ++id, kind: 'ramp' }));
pieces.push({ id: ++id, kind: 'bumper', x: 480, y: 330, angle: 0 });
selected = pieces[0].id;
attempt = 0;
ghost = [];
trail = [];
retry();
$('hint').textContent = levels[level].hint;
status('Drag to move · Select a ramp, then ↶ ↷ to tilt');
}
function edit() {
if (mode !== 'edit') retry();
}
async function save() {
if (!runtimeHasDomain('storage')) {
status('Progress stays in this session. Saving is unavailable here.');
return;
}
try {
await storage.setItem(
'impossible-machine:solved',
JSON.stringify([...solved]),
);
status('Progress saved. Your next experiment awaits.');
} catch {
status('Could not save. You can keep playing in this session.');
}
}
$('save').onclick = () => void save();
$<HTMLSelectElement>('level').onchange = (e) => {
level = Number((e.target as HTMLSelectElement).value);
load();
};
$('reset').onclick = load;
$('retry').onclick = retry;
$('drop').onclick = () => {
retry();
mode = 'run';
attempt++;
status('Rolling… You can retry at any time.');
update();
};
$('next').onclick = () => {
level = (level + 1) % levels.length;
$<HTMLSelectElement>('level').value = String(level);
load();
};
for (const kind of ['ramp', 'bumper'] as const)
$(kind).onclick = () => {
edit();
if (pieces.length >= 12) return;
pieces.push({
id: ++id,
kind,
x: 300 + ((pieces.length % 3) - 1) * 25,
y: 280,
angle: 0.25,
});
selected = id;
update();
};
$<HTMLSelectElement>('piece').onchange = (e) => {
selected = Number((e.target as HTMLSelectElement).value);
update();
};
for (const [key, delta] of [
['left', -0.13],
['right', 0.13],
] as const)
$(key).onclick = () => {
edit();
const p = pieces.find((p) => p.id === selected);
if (p) p.angle = Math.max(-1.4, Math.min(1.4, p.angle + delta));
update();
};
$('remove').onclick = () => {
edit();
pieces = pieces.filter((p) => p.id !== selected);
selected = pieces[0]?.id ?? 0;
update();
};
for (const b of document.querySelectorAll<HTMLButtonElement>('.nudges button'))
b.onclick = () => {
edit();
const p = pieces.find((p) => p.id === selected);
if (p) {
p.x = Math.max(40, Math.min(560, p.x + Number(b.dataset.dx ?? 0)));
p.y = Math.max(110, Math.min(550, p.y + Number(b.dataset.dy ?? 0)));
}
};
function pos(e: PointerEvent) {
const r = canvas.getBoundingClientRect();
return {
x: (e.clientX - r.left - ox) / scale,
y: (e.clientY - r.top - oy) / scale,
};
}
canvas.onpointerdown = (e) => {
if (mode === 'run') return;
const q = pos(e);
const p = [...pieces].reverse().find((p) => {
if (p.kind === 'bumper') return Math.hypot(q.x - p.x, q.y - p.y) < 40;
const [ax, ay, bx, by] = endpoints(p);
const t = Math.max(
0,
Math.min(1, ((q.x - ax) * (bx - ax) + (q.y - ay) * (by - ay)) / 19600),
);
return Math.hypot(q.x - ax - t * (bx - ax), q.y - ay - t * (by - ay)) < 29;
});
if (!p) return;
edit();
selected = p.id;
drag = { id: p.id, dx: q.x - p.x, dy: q.y - p.y };
canvas.setPointerCapture(e.pointerId);
update();
};
canvas.onpointermove = (e) => {
if (!drag) return;
const p = pieces.find((p) => p.id === drag!.id)!;
const q = pos(e);
p.x = Math.max(35, Math.min(565, q.x - drag.dx));
p.y = Math.max(110, Math.min(555, q.y - drag.dy));
};
canvas.onpointerup = canvas.onpointercancel = () => {
drag = null;
};
function line(
ax: number,
ay: number,
bx: number,
by: number,
color: string,
width: number,
) {
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.stroke();
}
function circle(
x: number,
y: number,
r: number,
color: string | CanvasGradient,
) {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
}
function draw() {
const style = getComputedStyle(document.documentElement),
fg = style.getPropertyValue('--fg').trim(),
muted = style.getPropertyValue('--muted').trim();
ctx.clearRect(0, 0, w, h);
ctx.save();
ctx.translate(ox, oy);
ctx.scale(scale, scale);
const L = levels[level];
ctx.globalAlpha = 0.15;
line(35, 565, 565, 565, fg, 1);
ctx.globalAlpha = 1;
ctx.font = '11px ui-monospace,monospace';
ctx.fillStyle = muted;
ctx.textAlign = 'center';
ctx.fillText('THE DROP', L.start.x, L.start.y - 30);
line(L.start.x, L.start.y + 22, L.start.x, L.start.y + 42, '#b0b7ac', 2);
if (showTrail) {
for (const [path, alpha] of [
[ghost, 0.18],
[trail, 0.45],
] as const) {
ctx.globalAlpha = alpha;
path.forEach((p, i) => {
if (i % 4 === 0) circle(p.x, p.y, 2.5, '#719c89');
});
}
ctx.globalAlpha = 1;
}
for (const p of pieces) {
if (p.id === selected && mode !== 'run') {
ctx.setLineDash([4, 5]);
ctx.strokeStyle = '#729889';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(p.x, p.y, p.kind === 'ramp' ? 84 : 37, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
if (p.kind === 'ramp') {
const [ax, ay, bx, by] = endpoints(p);
line(ax, ay + 5, bx, by + 5, '#00000015', 15);
line(ax, ay, bx, by, '#9b6739', 13);
line(ax, ay - 2, bx, by - 2, '#d9ac71', 9);
circle(p.x, p.y, 3, '#8d6037');
circle(p.x - 1, p.y - 1, 1, '#f7dfbb');
} else {
circle(p.x, p.y + 4, 25, '#00000015');
circle(p.x, p.y, 25, '#b56151');
circle(p.x, p.y - 2, 21, '#e7987b');
circle(p.x, p.y - 2, 13, '#f0b99c');
circle(p.x, p.y - 2, 6, '#ba6755');
}
}
const c = L.cup;
ctx.fillStyle = '#78a294';
ctx.beginPath();
ctx.moveTo(c.x - 38, c.y);
ctx.lineTo(c.x - 29, c.y + 49);
ctx.quadraticCurveTo(c.x, c.y + 60, c.x + 29, c.y + 49);
ctx.lineTo(c.x + 38, c.y);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = '#426c5f';
ctx.lineWidth = 3;
ctx.stroke();
ctx.fillStyle = '#34594f';
ctx.beginPath();
ctx.ellipse(c.x, c.y, 36, 8, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#b6d3bf';
ctx.stroke();
ctx.font = '18px Georgia';
ctx.fillStyle = '#e8eee0';
ctx.fillText('★', c.x, c.y + 35);
if (mode !== 'won') {
circle(ball.x + 2, ball.y + 4, 11, '#00000018');
const g = ctx.createRadialGradient(
ball.x - 4,
ball.y - 5,
1,
ball.x,
ball.y,
11,
);
g.addColorStop(0, '#fff1bf');
g.addColorStop(0.4, '#e9b951');
g.addColorStop(1, '#b87c25');
circle(ball.x, ball.y, 10, g);
circle(ball.x - 3, ball.y - 4, 2, '#fff8db');
}
ctx.restore();
}
function resize() {
const r = canvas.getBoundingClientRect();
w = r.width;
h = r.height;
const dpr = Math.min(devicePixelRatio, 2);
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
scale = Math.min(w / 600, h / 630);
ox = (w - 600 * scale) / 2;
oy = (h - 600 * scale) / 2 + 8;
}
const observer = new ResizeObserver(resize);
observer.observe(canvas);
let previous = 0,
accumulator = 0,
raf = 0;
function frame(now: number) {
const delta = previous ? Math.min((now - previous) / 1000, 0.05) : 0;
previous = now;
if (mode === 'run') {
accumulator += delta;
while (accumulator >= 1 / 240 && mode === 'run') {
step(ball, pieces, 1 / 240);
time += 1 / 240;
accumulator -= 1 / 240;
if (caught(ball, levels[level].cup)) {
mode = 'won';
if (level < 3) solved.add(level);
$('celebration').hidden = false;
status('In the cup! Try the next puzzle, or refine your machine.');
if (runtimeHasDomain('storage')) void save();
update();
} else if (ball.y > 650 || ball.x < -50 || ball.x > 650 || time > 12) {
mode = 'miss';
status('Almost! Move a piece, or tap Retry to try again.');
update();
}
}
if (trail.length < 1200) trail.push({ x: ball.x, y: ball.y });
} else accumulator = 0;
draw();
raf = requestAnimationFrame(frame);
}
function applyTheme(t: Theme) {
const s = document.documentElement.style;
s.setProperty('--bg', t.colors.background);
s.setProperty('--fg', t.colors.text);
s.setProperty('--primary', t.colors.primary);
}
if (runtimeHasDomain('theme')) {
try {
themeGet()
.then(applyTheme)
.catch(() => {});
subscriptions.push(themeOnChanged(applyTheme));
} catch {}
}
if (runtimeHasDomain('config')) {
try {
subscriptions.push(
config.subscribe((v) => {
showTrail = v.showTrail !== false;
}),
);
} catch {}
}
if (runtimeHasDomain('storage'))
try {
storage
.getItem('impossible-machine:solved')
.then((raw) => {
try {
const a = JSON.parse(raw ?? '[]');
if (Array.isArray(a))
a.filter(
(n: unknown) =>
typeof n === 'number' && Number.isInteger(n) && n >= 0 && n < 3,
).forEach((n: number) => solved.add(n));
update();
} catch {}
})
.catch(() => {});
} catch {}
window.addEventListener(
'pagehide',
() => {
subscriptions.forEach((s) => s.close());
observer.disconnect();
cancelAnimationFrame(raf);
},
{ once: true },
);
load();
resize();
raf = requestAnimationFrame(frame);
