前端嘛 Logo
前端嘛
一个好玩的窗帘

一个好玩的窗帘

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>