一个好玩的窗帘
2026-04-24
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Strings</title>
<style>
@import url("https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300..900;1,300..900&display=swap");
body {
background: #eee;
margin: 0;
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
overflow: hidden;
}
canvas {
max-height: 100vh;
max-width: 100vw;
height: auto;
width: auto;
/* border: 1px solid silver; */
}
#container {
box-shadow: 0 0 20px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.1);
position: relative;
display: flex;
align-items: center;
justify-content: center;
touch-action: none;
}
h1 {
font-family: Rubik;
font-size: 100px;
font-weight: 800;
line-height: 1em;
position: absolute;
color: #fff;
}
</style>
</head>
<body>
<div id="container"></div>
<script type="module">
export const lerp = (a, b, t) => a + (b - a) * t;
export const lerpPoint = (p1, p2, t) => ({
x: lerp(p1.x, p2.x, t),
y: lerp(p1.y, p2.y, t),
});
export function smoothstep(edge0, edge1, x) {
let t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
export const randomBetween = (a, b) => a + Math.random() * (b - a);
const randomOS = Math.random() * 200;
export const hash = (x, os = randomOS) =>
Math.abs(Math.sin((x + os) * 9174986346) * 1964286753) % 1;
export function getPoint(id, w) {
return {
x: id % w,
y: Math.floor(id / w),
};
}
export function getPointID(x, y, w) {
return y * w + x;
}
export const getHorizontalEdgeId = (x, y, w) => y * w + x;
export const getVerticalEdgeId = (x, y, w, h) => w * h + w + (x * h + y);
export function getEdgeIDBetweenPoints(a, b, w, h) {
/* Check for horizontal adjacency */
if (a.y === b.y && Math.abs(a.x - b.x) === 1) {
const canonicalX = Math.min(a.x, b.x);
return getHorizontalEdgeId(canonicalX, a.y, w);
}
/* Check for vertical adjacency */
if (a.x === b.x && Math.abs(a.y - b.y) === 1) {
const canonicalY = Math.min(a.y, b.y);
return getVerticalEdgeId(a.x, canonicalY, w, h);
}
/*
console.warn("No edge between these points");
return null;
}
export function getPointsForGridId(id, w, h) {
const { x: col, y: row } = getPoint(id, w);
/* Each grid cell is defined by its top-left corner */
/* The 4 points of the cell are: top-left, top-right, bottom-right, bottom-left */
return [
{ x: col, y: row } /* top left */,
{ x: col + 1, y: row } /* top right */,
{ x: col + 1, y: row + 1 } /* bottom right */,
{ x: col, y: row + 1 } /* bottom left */,
];
}
export function getEdgeIdsForGridId(id, w, h) {
const points = getPointsForGridId(id, w, h);
return [
getEdgeIDBetweenPoints(points[0], points[1], w, h) /* top edge */,
getEdgeIDBetweenPoints(points[1], points[2], w, h) /* right edge */,
getEdgeIDBetweenPoints(points[2], points[3], w, h) /* bottom edge */,
getEdgeIDBetweenPoints(points[3], points[0], w, h) /* left edge */,
];
}
export function drawCircle(toy, r, x, y, steps) {
const TAU = Math.PI * 2;
toy.pu();
toy.jump(x, y - r);
toy.pd();
for (let i = 0; i < steps; i++) {
toy.right(TAU / steps);
toy.forward((TAU * r) / steps);
}
}
export function drawGrid(toy, w, h, wFactor, hFactor) {
for (let i = 0; i <= w; i++) {
for (let j = 0; j <= h; j++) {
drawCircle(toy, 5, i * wFactor, j * hFactor, 10);
}
}
}
console.clear();
let fullCode = "";
const w = Math.min(400, window.innerWidth - 100),
h = Math.min(400, window.innerHeight - 100);
const CONFIG = {
awidth: w,
aheight: h,
gridW: Math.min(
50,
Math.floor(w / 10),
) /* arbitrary something something */,
gridH: Math.min(50, Math.floor(w / 5)),
gravity: 0.2,
damping: 0.99,
iterationsPerFrame: 5,
compressFactor: 0.02,
stretchFactor: 1.1,
mouseSize: 5000,
mouseStrength: 4,
contain: false,
randomSolve: false,
preset: "",
};
CONFIG.cellWidth = CONFIG.awidth / (CONFIG.gridW - 1);
CONFIG.cellHeight = CONFIG.aheight / (CONFIG.gridH - 1);
window.addEventListener("resize", () => {
if (c && c.width) {
c.width = window.innerWidth;
c.height = window.innerHeight;
CONFIG.awidth = Math.min(CONFIG.width, c.width - 100);
CONFIG.aheight = Math.min(CONFIG.height, c.height - 100);
CONFIG.cellWidth = CONFIG.awidth / (CONFIG.gridW - 1);
CONFIG.cellHeight = CONFIG.aheight / (CONFIG.gridH - 1);
}
});
let rafID, input, c;
function main() {
fullCode = main.toString();
const {
awidth: width,
aheight: height,
gridW,
gridH,
gravity,
damping,
iterationsPerFrame,
compressFactor,
stretchFactor,
cellWidth,
cellHeight,
} = CONFIG;
const charCanvases = {};
const fontSize = Math.max(12, cellHeight * 1.2);
for (const ch of new Set(fullCode)) {
if (ch === " ") continue;
const off = document.createElement("canvas");
off.width = off.height = Math.ceil(fontSize * 1.4);
const octx = off.getContext("2d");
octx.font = `bold ${fontSize}px monospace`;
octx.textAlign = "center";
octx.textBaseline = "middle";
octx.fillStyle = "#333";
octx.fillText(ch, off.width / 2, off.height / 2);
charCanvases[ch] = off;
}
c = document.createElement("canvas");
container.innerHTML = "";
container.appendChild(c);
c.width = window.innerWidth;
c.height = window.innerHeight;
const ctx = c.getContext("2d");
const particles = [];
const constraints = [],
verticalConstraints = [],
horizontalConstraints = [];
const pinnedParticles = [];
input = new Input({ c, particles });
for (let i = 0; i < gridW; i++) {
for (let j = 0; j < gridH; j++) {
let x = i * cellWidth;
let y = j * cellHeight;
const id = getPointID(j, i, gridH);
const pinned = j === 0;
const charIndex = (i + j * gridW) % fullCode.length;
const char = fullCode[charIndex] || " ";
const particle = new Particle({ x, y, pinned, id, char });
particles.push(particle);
if (pinned) pinnedParticles.push(particle);
}
}
for (let i = 0; i < gridW; i++) {
for (let j = 0; j < gridH; j++) {
const id = getPointID(j, i, gridH);
const p = particles[id];
if (j < gridH - 1) {
const bottomP = particles[getPointID(j + 1, i, gridH)];
const c = new Constraint({
p1: p,
p2: bottomP,
length: cellHeight,
id: id + gridW * gridH,
compressFactor,
stretchFactor,
});
constraints.push(c);
p.downConstraint =
c; /** Cache the down ref directly on the particle */
}
if (i < gridW - 1) {
const rightP = particles[getPointID(j, i + 1, gridH)];
const hc = new Constraint({
p1: p,
p2: rightP,
length: cellWidth,
id: id + gridW * gridH * 2,
compressFactor: 0.6,
stretchFactor: 4,
isSpacer: true,
});
constraints.push(hc);
horizontalConstraints.push(hc);
}
}
}
function drawParticles() {
particles.forEach((p) => {
ctx.beginPath();
ctx.arc(...p.pos, CONFIG.pointRadius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
});
}
function drawCode() {
const offset = [
c.width / 2 - width / 2,
c.height / 2 - height / 2 - 30,
];
particles.forEach((p) => {
if (p.char && p.char !== " ") {
const constraint = p.downConstraint;
let angle = 0;
const img = charCanvases[p.char];
if (!img) return;
const half = img.width / 2;
let cos = 1,
sin = 0;
if (constraint) {
const dx = constraint.p2.pos.x - constraint.p1.pos.x;
const dy = constraint.p2.pos.y - constraint.p1.pos.y;
angle = Math.atan2(dy, dx) - Math.PI / 2;
cos = Math.cos(angle);
sin = Math.sin(angle);
}
/** ctx.translate(p.pos.x, p.pos.y); */
/** if (angle !== 0) ctx.rotate(angle); */
/** const cos = Math.cos(angle); */
/** const sin = Math.sin(angle); */
ctx.setTransform(
cos,
sin,
-sin,
cos,
p.pos.x + offset[0],
p.pos.y + offset[1],
);
/** ctx.fillText(p.char, 0, 0); */
ctx.drawImage(img, -half, -half);
/** if (angle !== 0) ctx.rotate(-angle); */
/** ctx.translate(-p.pos.x, -p.pos.y); */
}
});
/** ctx.setTransform(1, 0, 0, 1, 0, 0); */
}
function shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
}
let lastDelta = 0;
function runloop(delta) {
rafID = requestAnimationFrame(runloop);
ctx.save();
ctx.clearRect(0, 0, c.width, c.height);
/** ctx.translate(c.width/2-width/2,c.height/2-height/2); */
particles.forEach((p) => p.update(delta - lastDelta));
lastDelta = delta;
if (CONFIG.randomSolve) shuffleArray(constraints);
for (let i = 0; i < iterationsPerFrame; i++) {
for (let j = 0; j < constraints.length; j++) constraints[j].solve();
}
if (CONFIG.contain) particles.forEach((p) => p.contain());
drawCode();
ctx.restore();
}
rafID = requestAnimationFrame(runloop);
}
class Input {
constructor({ c, particles }) {
((this.c = c), (this.particles = particles));
this.mousePos = new Vec2();
this.grabRadius = 20;
this.grabbed;
this.bind();
}
pointerdown(e) {
const rect = this.rect;
this.mousePos.x = e.clientX - c.width / 2 + CONFIG.awidth / 2;
this.mousePos.y = e.clientY - c.height / 2 + CONFIG.aheight / 2;
for (const p of this.particles) {
if (this.mousePos.subtractNew(p.pos).length < this.grabRadius) {
this.grabbedParticle = p;
this.grabbedParticle.originalPinnedState =
this.grabbedParticle.pinned;
this.grabbedParticle.pinned = true;
break;
}
}
if (!this.grabbedParticle) {
this.pointerIsDown = true;
}
}
pointerup(e) {
if (this.grabbedParticle) {
this.grabbedParticle.pinned =
this.grabbedParticle.originalPinnedState;
this.grabbedParticle = null;
}
clearTimeout(this.pointerUpTimer);
this.pointerUpTimer = setTimeout(() => {
this.pointerIsDown = false;
}, 1000);
}
pointermove(e) {
const rect = this.rect;
this.mousePos.x = e.clientX - c.width / 2 + CONFIG.awidth / 2;
this.mousePos.y = e.clientY - c.height / 2 + CONFIG.aheight / 2;
if (this.grabbedParticle) {
this.grabbedParticle.pos.reset(this.mousePos.x, this.mousePos.y);
this.grabbedParticle.oldPos.reset(this.mousePos.x, this.mousePos.y);
}
for (const p of this.particles) {
const diff = this.mousePos.subtractNew(p.pos);
const ls = diff.lengthSquared;
if (ls < CONFIG.mouseSize) {
const a = diff.angle - Math.PI;
const strength =
(smoothstep(CONFIG.mouseSize, -2000, ls) *
CONFIG.mouseStrength) /
300;
const force = new Vec2(
Math.cos(a) * strength,
Math.sin(a) * strength,
);
p.applyForce(force);
}
}
}
contextmenu(e) {
e.preventDefault();
}
get rect() {
const rect = this.c.getBoundingClientRect();
rect.scale = rect.width / this.c.width;
return rect;
}
bind() {
this.pointerdown = this.pointerdown.bind(this);
this.pointerup = this.pointerup.bind(this);
this.pointermove = this.pointermove.bind(this);
this.contextmenu = this.contextmenu.bind(this);
document.addEventListener("pointerdown", this.pointerdown);
document.addEventListener("pointerup", this.pointerup);
document.addEventListener("pointermove", this.pointermove);
document.addEventListener("contextmenu", this.contextmenu);
}
unbind() {
document.removeEventListener("pointerdown", this.pointerdown);
document.removeEventListener("pointerup", this.pointerup);
document.removeEventListener("pointermove", this.pointermove);
document.removeEventListener("contextmenu", this.contextmenu);
}
}
class Vec2 {
constructor(x = 0, y = 0) {
this.reset(x, y);
}
zero() {
this.reset(0, 0);
}
reset(x = 0, y = 0) {
this.x = x;
this.y = y;
}
clone() {
return new Vec2(this.x, this.y);
}
add(v) {
this.x += v.x;
this.y += v.y;
return this;
}
addNew(v) {
return this.clone().add(v);
}
subtract(v) {
this.x -= v.x;
this.y -= v.y;
return this;
}
subtractNew(v) {
return this.clone().subtract(v);
}
multiply(v) {
this.x *= v.x;
this.y *= v.y;
return this;
}
multiplyNew(v) {
return this.clone().multiply(v);
}
scale(scalar) {
this.x *= scalar;
this.y *= scalar;
return this;
}
scaleNew(scalar) {
return this.clone().scale(scalar);
}
get array() {
return [this.x, this.y];
}
get lengthSquared() {
return this.x ** 2 + this.y ** 2;
}
get length() {
return Math.hypot(this.x, this.y);
}
get angle() {
return Math.atan2(this.y, this.x);
}
[Symbol.iterator]() {
let values = this.array;
let i = 0;
return {
next() {
if (i < values.length) {
let value = values[i];
i++;
return { value, done: false };
} else return { done: true };
},
};
}
}
class Particle {
/** Added 'char' to the constructor */
constructor({ x, y, pinned, id, char } = {}) {
this.pos = new Vec2(x, y);
this.oldPos = new Vec2(x, y);
this.velocity = new Vec2();
this.acceleration = new Vec2();
this.pinned = pinned;
this.id = id;
this.char = char;
this.gravityVec = new Vec2();
}
contain() {
if (this.pinned) return;
const radius = 5;
if (this.pos.x < radius) {
this.pos.x = radius;
this.oldPos.x =
this.pos.x + Math.abs(this.oldPos.x - this.pos.x) * 0.8;
} else if (this.pos.x > CONFIG.awidth - radius) {
this.pos.x = CONFIG.awidth - radius;
this.oldPos.x =
this.pos.x - Math.abs(this.oldPos.x - this.pos.x) * 0.8;
}
if (this.pos.y < radius) {
this.pos.y = radius;
this.oldPos.y =
this.pos.y + Math.abs(this.oldPos.y - this.pos.y) * 0.8;
} else if (this.pos.y > CONFIG.aheight - radius) {
this.pos.y = CONFIG.aheight - radius;
this.oldPos.y =
this.pos.y - Math.abs(this.oldPos.y - this.pos.y) * 0.8;
}
}
update(delta) {
if (this.pinned) {
this.acceleration.zero();
return;
}
this.velocity.reset(
(this.pos.x - this.oldPos.x) * CONFIG.damping,
(this.pos.y - this.oldPos.y) * CONFIG.damping,
);
this.oldPos.reset(...this.pos);
const dd = delta ** 2;
this.gravityVec.reset(0, CONFIG.gravity / dd);
this.applyForce(this.gravityVec);
this.pos.x += this.velocity.x + this.acceleration.x * dd;
this.pos.y += this.velocity.y + this.acceleration.y * dd;
this.acceleration.reset();
}
applyForce(v) {
this.acceleration.add(v);
}
}
class Constraint {
constructor({ p1, p2, length, id, compressFactor, stretchFactor }) {
this.p1 = p1;
this.p2 = p2;
this.length = length;
this.id = id;
this.minLength = length * compressFactor;
this.maxLength = length * stretchFactor;
c.addEventListener("update", (e) => {
this.minLength =
this.length *
(this.isSpacer ? compressFactor : e.detail.compressFactor);
this.maxLength =
this.length *
(this.isSpacer ? stretchFactor : e.detail.stretchFactor);
});
}
solve() {
/** Inline the vector math to avoid thrash */
const dx = this.p2.pos.x - this.p1.pos.x;
const dy = this.p2.pos.y - this.p1.pos.y;
const distance = Math.hypot(dx, dy);
if (distance == 0) return;
let targetLength = this.length;
if (distance < this.minLength) targetLength = this.minLength;
else if (distance > this.maxLength) targetLength = this.maxLength;
else return;
const difference = targetLength - distance;
const percent = difference / distance / 2;
const offsetX = dx * percent;
const offsetY = dy * percent;
if (!this.p1.pinned) {
this.p1.pos.x -= offsetX;
this.p1.pos.y -= offsetY;
}
if (!this.p2.pinned) {
this.p2.pos.x += offsetX;
this.p2.pos.y += offsetY;
}
}
}
setTimeout(() => main(), 500);
</script>
</body>
</html>
