yycvip 发表于 2025-9-25 16:59:12

HTML&CSS&JS:卡片扫描动画效果



HTML&CSS

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
      @import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;500;700&display=swap");

      * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
      }

      body {
            background: #000000;
            min-height: 100vh;
            overflow: hidden;
            font-family: "Arial", sans-serif;
      }

      .controls {
            position: absolute;
            top: 20px;
            left: 20px;
            display: flex;
            gap: 10px;
            z-index: 100;
      }

      .control-btn {
            padding: 10px20px;
            background: rgba(255, 255, 255, 0.2);
            border: none;
            border-radius: 25px;
            color: white;
            font-weight: bold;
            cursor: pointer;
            backdrop-filter: blur(5px);
            transition: all 0.3s ease;
            font-size: 14px;
      }

      .control-btn:hover {
            background: rgba(255, 255, 255, 0.3);
            transform: translateY(-2px);
            box-shadow: 05px15pxrgba(0, 0, 0, 0.2);
      }

      .speed-indicator {
            position: absolute;
            top: 20px;
            right: 20px;
            color: white;
            font-size: 16px;
            background: rgba(0, 0, 0, 0.3);
            padding: 8px16px;
            border-radius: 20px;
            backdrop-filter: blur(5px);
            z-index: 100;
      }

      .info {
            position: absolute;
            bottom: 20px;
            left: 50%;
            transform: translateX(-50%);
            color: rgba(255, 255, 255, 0.9);
            text-align: center;
            font-size: 14px;
            background: rgba(0, 0, 0, 0.3);
            padding: 15px25px;
            border-radius: 20px;
            backdrop-filter: blur(5px);
            z-index: 100;
            line-height: 1.4;
      }

      .container {
            position: relative;
            width: 100vw;
            height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
      }

      .card-stream {
            position: absolute;
            width: 100vw;
            height: 180px;
            display: flex;
            align-items: center;
            overflow: visible;
      }

      .card-line {
            display: flex;
            align-items: center;
            gap: 60px;
            white-space: nowrap;
            cursor: grab;
            user-select: none;
            will-change: transform;
      }

      .card-line:active {
            cursor: grabbing;
      }

      .card-line.dragging {
            cursor: grabbing;
      }

      .card-line.css-animated {
            animation: scrollCards 40s linear infinite;
      }

      @keyframes scrollCards {
            0% {
                transform: translateX(-100%);
            }

            100% {
                transform: translateX(100vw);
            }
      }

      .card-wrapper {
            position: relative;
            width: 400px;
            height: 250px;
            flex-shrink: 0;
      }

      .card {
            position: absolute;
            top: 0;
            left: 0;
            width: 400px;
            height: 250px;
            border-radius: 15px;
            overflow: hidden;
      }

      .card-normal {
            background: transparent;
            box-shadow: 015px40pxrgba(0, 0, 0, 0.4);
            display: flex;
            flex-direction: column;
            justify-content: space-between;
            padding: 0;
            color: white;
            z-index: 2;
            position: relative;
            overflow: hidden;
      }

      .card-image {
            width: 100%;
            height: 100%;
            object-fit: cover;
            border-radius: 15px;
            transition: all 0.3s ease;
            filter: brightness(1.1) contrast(1.1);
            box-shadow: inset 0020pxrgba(0, 0, 0, 0.1);
      }

      .card-image:hover {
            filter: brightness(1.2) contrast(1.2);
      }

      .card-ascii {
            background: transparent;
            z-index: 1;
            position: absolute;
            top: 0;
            left: 0;
            width: 400px;
            height: 250px;
            border-radius: 15px;
            overflow: hidden;
      }

      .card-chip {
            width: 40px;
            height: 30px;
            background: linear-gradient(45deg, #ffd700, #ffed4e);
            border-radius: 5px;
            position: relative;
            margin-bottom: 20px;
      }

      .card-chip::before {
            content: "";
            position: absolute;
            top: 3px;
            left: 3px;
            right: 3px;
            bottom: 3px;
            background: linear-gradient(45deg, #e6c200, #f4d03f);
            border-radius: 2px;
      }

      .contactless {
            position: absolute;
            top: 60px;
            left: 20px;
            width: 25px;
            height: 25px;
            border: 2px solid rgba(255, 255, 255, 0.8);
            border-radius: 50%;
            background: radial-gradient(circle, rgba(255, 255, 255, 0.2), transparent);
      }

      .contactless::after {
            content: "";
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            width: 15px;
            height: 15px;
            border: 1px solid rgba(255, 255, 255, 0.6);
            border-radius: 50%;
      }

      .card-number {
            font-size: 22px;
            font-weight: bold;
            letter-spacing: 3px;
            margin-bottom: 15px;
            text-shadow: 02px4pxrgba(0, 0, 0, 0.3);
      }

      .card-info {
            display: flex;
            justify-content: space-between;
            align-items: flex-end;
      }

      .card-holder {
            color: white;
            font-size: 14px;
            text-transform: uppercase;
      }

      .card-expiry {
            color: white;
            font-size: 14px;
      }

      .card-logo {
            position: absolute;
            top: 20px;
            right: 20px;
            font-size: 18px;
            font-weight: bold;
            color: white;
            text-shadow: 02px4pxrgba(0, 0, 0, 0.3);
      }

      .ascii-content {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            color: rgba(220, 210, 255, 0.6);
            font-family: "Courier New", monospace;
            font-size: 11px;
            line-height: 13px;
            overflow: hidden;
            white-space: pre;
            clip-path: inset(0 calc(100% - var(--clip-left, 0%)) 00);
            animation: glitch 0.1s infinite linear alternate-reverse;
            margin: 0;
            padding: 0;
            text-align: left;
            vertical-align: top;
            box-sizing: border-box;
            -webkit-mask-image: linear-gradient(to right,
                  rgba(0, 0, 0, 1) 0%,
                  rgba(0, 0, 0, 0.8) 30%,
                  rgba(0, 0, 0, 0.6) 50%,
                  rgba(0, 0, 0, 0.4) 80%,
                  rgba(0, 0, 0, 0.2) 100%);
            mask-image: linear-gradient(to right,
                  rgba(0, 0, 0, 1) 0%,
                  rgba(0, 0, 0, 0.8) 30%,
                  rgba(0, 0, 0, 0.6) 50%,
                  rgba(0, 0, 0, 0.4) 80%,
                  rgba(0, 0, 0, 0.2) 100%);
      }

      @keyframes glitch {
            0% {
                opacity: 1;
            }

            15% {
                opacity: 0.9;
            }

            16% {
                opacity: 1;
            }

            49% {
                opacity: 0.8;
            }

            50% {
                opacity: 1;
            }

            99% {
                opacity: 0.9;
            }

            100% {
                opacity: 1;
            }
      }

      .scanner {
            display: none;
            position: absolute;
            left: 50%;
            top: 50%;
            transform: translate(-50%, -50%);
            width: 4px;
            height: 300px;
            border-radius: 30px;
            background: linear-gradient(to bottom,
                  transparent,
                  rgba(0, 255, 255, 0.8),
                  rgba(0, 255, 255, 1),
                  rgba(0, 255, 255, 0.8),
                  transparent);
            box-shadow: 0020pxrgba(0, 255, 255, 0.8), 0040pxrgba(0, 255, 255, 0.4);
            animation: scanPulse 2s ease-in-out infinite alternate;
            z-index: 10;
      }

      @keyframes scanPulse {
            0% {
                opacity: 0.8;
                transform: translate(-50%, -50%) scaleY(1);
            }

            100% {
                opacity: 1;
                transform: translate(-50%, -50%) scaleY(1.1);
            }
      }

      .scanner-label {
            position: absolute;
            bottom: -40px;
            left: 50%;
            transform: translateX(-50%);
            color: rgba(0, 255, 255, 0.9);
            font-size: 12px;
            font-weight: bold;
            text-transform: uppercase;
            letter-spacing: 2px;
            text-shadow: 0010pxrgba(0, 255, 255, 0.5);
      }

      .card-normal {
            clip-path: inset(000 var(--clip-right, 0%));
      }

      .card-ascii {
            clip-path: inset(0 calc(100% - var(--clip-left, 0%)) 00);
      }

      .scan-effect {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: linear-gradient(90deg,
                  transparent,
                  rgba(0, 255, 255, 0.4),
                  transparent);
            animation: scanEffect 0.6s ease-out;
            pointer-events: none;
            z-index: 5;
      }

      @keyframes scanEffect {
            0% {
                transform: translateX(-100%);
                opacity: 0;
            }

            50% {
                opacity: 1;
            }

            100% {
                transform: translateX(100%);
                opacity: 0;
            }
      }

      .instructions {
            position: absolute;
            top: 50%;
            right: 30px;
            transform: translateY(-50%);
            color: rgba(255, 255, 255, 0.7);
            font-size: 14px;
            max-width: 200px;
            text-align: right;
            z-index: 5;
      }

      #particleCanvas {
            position: absolute;
            top: 50%;
            left: 0;
            transform: translateY(-50%);
            width: 100vw;
            height: 250px;
            z-index: 0;
            pointer-events: none;
      }

      #scannerCanvas {
            position: absolute;
            top: 50%;
            left: -3px;
            transform: translateY(-50%);
            width: 100vw;
            height: 300px;
            z-index: 15;
            pointer-events: none;
      }
    </style>
</head>

<body>
    <div class="controls">
      <button class="control-btn" onclick="toggleAnimation()">⏸️ Pause</button>
      <button class="control-btn" onclick="resetPosition()">🔄 Reset</button>
      <button class="control-btn" onclick="changeDirection()">
            ↔️ Direction
      </button>
    </div>

    <div class="speed-indicator">
      Speed: <span id="speedValue">120</span> px/s
    </div>

    <div class="container">
      <canvas id="particleCanvas"></canvas>
      <canvas id="scannerCanvas"></canvas>

      <div class="scanner"></div>

      <div class="card-stream" id="cardStream">
            <div class="card-line" id="cardLine"></div>
      </div>
    </div>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>

    <script>
      const codeChars =
            "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789(){}[]<>;:,._-+=!@#$%^&*|\\/\"'`~?";

      const scannerLeft = window.innerWidth / 2 - 2;
      const scannerRight = window.innerWidth / 2 + 2;

      class CardStreamController {
            constructor() {
                this.container = document.getElementById("cardStream");
                this.cardLine = document.getElementById("cardLine");
                this.speedIndicator = document.getElementById("speedValue");

                this.position = 0;
                this.velocity = 120;
                this.direction = -1;
                this.isAnimating = true;
                this.isDragging = false;

                this.lastTime = 0;
                this.lastMouseX = 0;
                this.mouseVelocity = 0;
                this.friction = 0.95;
                this.minVelocity = 30;

                this.containerWidth = 0;
                this.cardLineWidth = 0;

                this.init();
            }

            init() {
                this.populateCardLine();
                this.calculateDimensions();
                this.setupEventListeners();
                this.updateCardPosition();
                this.animate();
                this.startPeriodicUpdates();
            }

            calculateDimensions() {
                this.containerWidth = this.container.offsetWidth;
                const cardWidth = 400;
                const cardGap = 60;
                const cardCount = this.cardLine.children.length;
                this.cardLineWidth = (cardWidth + cardGap) * cardCount;
            }

            setupEventListeners() {
                this.cardLine.addEventListener("mousedown", (e) => this.startDrag(e));
                document.addEventListener("mousemove", (e) => this.onDrag(e));
                document.addEventListener("mouseup", () => this.endDrag());

                this.cardLine.addEventListener(
                  "touchstart",
                  (e) => this.startDrag(e.touches),
                  { passive: false }
                );
                document.addEventListener("touchmove", (e) => this.onDrag(e.touches), {
                  passive: false,
                });
                document.addEventListener("touchend", () => this.endDrag());

                this.cardLine.addEventListener("wheel", (e) => this.onWheel(e));
                this.cardLine.addEventListener("selectstart", (e) => e.preventDefault());
                this.cardLine.addEventListener("dragstart", (e) => e.preventDefault());

                window.addEventListener("resize", () => this.calculateDimensions());
            }

            startDrag(e) {
                e.preventDefault();

                this.isDragging = true;
                this.isAnimating = false;
                this.lastMouseX = e.clientX;
                this.mouseVelocity = 0;

                const transform = window.getComputedStyle(this.cardLine).transform;
                if (transform !== "none") {
                  const matrix = new DOMMatrix(transform);
                  this.position = matrix.m41;
                }

                this.cardLine.style.animation = "none";
                this.cardLine.classList.add("dragging");

                document.body.style.userSelect = "none";
                document.body.style.cursor = "grabbing";
            }

            onDrag(e) {
                if (!this.isDragging) return;
                e.preventDefault();

                const deltaX = e.clientX - this.lastMouseX;
                this.position += deltaX;
                this.mouseVelocity = deltaX * 60;
                this.lastMouseX = e.clientX;

                this.cardLine.style.transform = `translateX(${this.position}px)`;
                this.updateCardClipping();
            }

            endDrag() {
                if (!this.isDragging) return;

                this.isDragging = false;
                this.cardLine.classList.remove("dragging");

                if (Math.abs(this.mouseVelocity) > this.minVelocity) {
                  this.velocity = Math.abs(this.mouseVelocity);
                  this.direction = this.mouseVelocity > 0 ? 1 : -1;
                } else {
                  this.velocity = 120;
                }

                this.isAnimating = true;
                this.updateSpeedIndicator();

                document.body.style.userSelect = "";
                document.body.style.cursor = "";
            }

            animate() {
                const currentTime = performance.now();
                const deltaTime = (currentTime - this.lastTime) / 1000;
                this.lastTime = currentTime;

                if (this.isAnimating && !this.isDragging) {
                  if (this.velocity > this.minVelocity) {
                        this.velocity *= this.friction;
                  } else {
                        this.velocity = Math.max(this.minVelocity, this.velocity);
                  }

                  this.position += this.velocity * this.direction * deltaTime;
                  this.updateCardPosition();
                  this.updateSpeedIndicator();
                }

                requestAnimationFrame(() =>this.animate());
            }

            updateCardPosition() {
                const containerWidth = this.containerWidth;
                const cardLineWidth = this.cardLineWidth;

                if (this.position < -cardLineWidth) {
                  this.position = containerWidth;
                } elseif (this.position > containerWidth) {
                  this.position = -cardLineWidth;
                }

                this.cardLine.style.transform = `translateX(${this.position}px)`;
                this.updateCardClipping();
            }

            updateSpeedIndicator() {
                this.speedIndicator.textContent = Math.round(this.velocity);
            }

            toggleAnimation() {
                this.isAnimating = !this.isAnimating;
                const btn = document.querySelector(".control-btn");
                btn.textContent = this.isAnimating ? "⏸️ Pause" : "▶️ Play";

                if (this.isAnimating) {
                  this.cardLine.style.animation = "none";
                }
            }

            resetPosition() {
                this.position = this.containerWidth;
                this.velocity = 120;
                this.direction = -1;
                this.isAnimating = true;
                this.isDragging = false;

                this.cardLine.style.animation = "none";
                this.cardLine.style.transform = `translateX(${this.position}px)`;
                this.cardLine.classList.remove("dragging");

                this.updateSpeedIndicator();

                const btn = document.querySelector(".control-btn");
                btn.textContent = "⏸️ Pause";
            }

            changeDirection() {
                this.direction *= -1;
                this.updateSpeedIndicator();
            }

            onWheel(e) {
                e.preventDefault();

                const scrollSpeed = 20;
                const delta = e.deltaY > 0 ? scrollSpeed : -scrollSpeed;

                this.position += delta;
                this.updateCardPosition();
                this.updateCardClipping();
            }

            generateCode(width, height) {
                const randInt = (min, max) =>
                  Math.floor(Math.random() * (max - min + 1)) + min;
                const pick = (arr) => arr;

                const header = [
                  "// compiled preview • scanner demo",
                  "/* generated for visual effect – not executed */",
                  "const SCAN_WIDTH = 8;",
                  "const FADE_ZONE = 35;",
                  "const MAX_PARTICLES = 2500;",
                  "const TRANSITION = 0.05;",
                ];

                const helpers = [
                  "function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }",
                  "function lerp(a, b, t) { return a + (b - a) * t; }",
                  "const now = () => performance.now();",
                  "function rng(min, max) { return Math.random() * (max - min) + min; }",
                ];

                const particleBlock = (idx) => [
                  `class Particle${idx} {`,
                  "constructor(x, y, vx, vy, r, a) {",
                  "    this.x = x; this.y = y;",
                  "    this.vx = vx; this.vy = vy;",
                  "    this.r = r; this.a = a;",
                  "}",
                  "step(dt) { this.x += this.vx * dt; this.y += this.vy * dt; }",
                  "}",
                ];

                const scannerBlock = [
                  "const scanner = {",
                  "x: Math.floor(window.innerWidth / 2),",
                  "width: SCAN_WIDTH,",
                  "glow: 3.5,",
                  "};",
                  "",
                  "function drawParticle(ctx, p) {",
                  "ctx.globalAlpha = clamp(p.a, 0, 1);",
                  "ctx.drawImage(gradient, p.x - p.r, p.y - p.r, p.r * 2, p.r * 2);",
                  "}",
                ];

                const loopBlock = [
                  "function tick(t) {",
                  "// requestAnimationFrame(tick);",
                  "const dt = 0.016;",
                  "// update & render",
                  "}",
                ];

                const misc = [
                  "const state = { intensity: 1.2, particles: MAX_PARTICLES };",
                  "const bounds = { w: window.innerWidth, h: 300 };",
                  "const gradient = document.createElement('canvas');",
                  "const ctx = gradient.getContext('2d');",
                  "ctx.globalCompositeOperation = 'lighter';",
                  "// ascii overlay is masked with a 3-phase gradient",
                ];

                const library = [];
                header.forEach((l) => library.push(l));
                helpers.forEach((l) => library.push(l));
                for (let b = 0; b < 3; b++)
                  particleBlock(b).forEach((l) => library.push(l));
                scannerBlock.forEach((l) => library.push(l));
                loopBlock.forEach((l) => library.push(l));
                misc.forEach((l) => library.push(l));

                for (let i = 0; i < 40; i++) {
                  const n1 = randInt(1, 9);
                  const n2 = randInt(10, 99);
                  library.push(`const v${i} = (${n1} + ${n2}) * 0.${randInt(1, 9)};`);
                }
                for (let i = 0; i < 20; i++) {
                  library.push(
                        `if (state.intensity > ${1 + (i % 3)}) { scanner.glow += 0.01; }`
                  );
                }

                let flow = library.join(" ");
                flow = flow.replace(/\s+/g, " ").trim();
                const totalChars = width * height;
                while (flow.length < totalChars + width) {
                  const extra = pick(library).replace(/\s+/g, " ").trim();
                  flow += " " + extra;
                }

                let out = "";
                let offset = 0;
                for (let row = 0; row < height; row++) {
                  let line = flow.slice(offset, offset + width);
                  if (line.length < width) line = line + " ".repeat(width - line.length);
                  out += line + (row < height - 1 ? "\n" : "");
                  offset += width;
                }
                return out;
            }

            calculateCodeDimensions(cardWidth, cardHeight) {
                const fontSize = 11;
                const lineHeight = 13;
                const charWidth = 6;
                const width = Math.floor(cardWidth / charWidth);
                const height = Math.floor(cardHeight / lineHeight);
                return { width, height, fontSize, lineHeight };
            }

            createCardWrapper(index) {
                const wrapper = document.createElement("div");
                wrapper.className = "card-wrapper";

                const normalCard = document.createElement("div");
                normalCard.className = "card card-normal";

                const cardImages = [
                  "https://cdn.prod.website-files.com/68789c86c8bc802d61932544/689f20b55e654d1341fb06f8_4.1.png",
                  "https://cdn.prod.website-files.com/68789c86c8bc802d61932544/689f20b5a080a31ee7154b19_1.png",
                  "https://cdn.prod.website-files.com/68789c86c8bc802d61932544/689f20b5c1e4919fd69672b8_3.png",
                  "https://cdn.prod.website-files.com/68789c86c8bc802d61932544/689f20b5f6a5e232e7beb4be_2.png",
                  "https://cdn.prod.website-files.com/68789c86c8bc802d61932544/689f20b5bea2f1b07392d936_4.png",
                ];

                const cardImage = document.createElement("img");
                cardImage.className = "card-image";
                cardImage.src = cardImages;
                cardImage.alt = "Credit Card";

                cardImage.onerror = () => {
                  const canvas = document.createElement("canvas");
                  canvas.width = 400;
                  canvas.height = 250;
                  const ctx = canvas.getContext("2d");

                  const gradient = ctx.createLinearGradient(0, 0, 400, 250);
                  gradient.addColorStop(0, "#667eea");
                  gradient.addColorStop(1, "#764ba2");

                  ctx.fillStyle = gradient;
                  ctx.fillRect(0, 0, 400, 250);

                  cardImage.src = canvas.toDataURL();
                };

                normalCard.appendChild(cardImage);

                const asciiCard = document.createElement("div");
                asciiCard.className = "card card-ascii";

                const asciiContent = document.createElement("div");
                asciiContent.className = "ascii-content";

                const { width, height, fontSize, lineHeight } =
                  this.calculateCodeDimensions(400, 250);
                asciiContent.style.fontSize = fontSize + "px";
                asciiContent.style.lineHeight = lineHeight + "px";
                asciiContent.textContent = this.generateCode(width, height);

                asciiCard.appendChild(asciiContent);
                wrapper.appendChild(normalCard);
                wrapper.appendChild(asciiCard);

                return wrapper;
            }

            updateCardClipping() {
                const scannerX = window.innerWidth / 2;
                const scannerWidth = 8;
                const scannerLeft = scannerX - scannerWidth / 2;
                const scannerRight = scannerX + scannerWidth / 2;
                let anyScanningActive = false;

                document.querySelectorAll(".card-wrapper").forEach((wrapper) => {
                  const rect = wrapper.getBoundingClientRect();
                  const cardLeft = rect.left;
                  const cardRight = rect.right;
                  const cardWidth = rect.width;

                  const normalCard = wrapper.querySelector(".card-normal");
                  const asciiCard = wrapper.querySelector(".card-ascii");

                  if (cardLeft < scannerRight && cardRight > scannerLeft) {
                        anyScanningActive = true;
                        const scannerIntersectLeft = Math.max(scannerLeft - cardLeft, 0);
                        const scannerIntersectRight = Math.min(
                            scannerRight - cardLeft,
                            cardWidth
                        );

                        const normalClipRight = (scannerIntersectLeft / cardWidth) * 100;
                        const asciiClipLeft = (scannerIntersectRight / cardWidth) * 100;

                        normalCard.style.setProperty("--clip-right", `${normalClipRight}%`);
                        asciiCard.style.setProperty("--clip-left", `${asciiClipLeft}%`);

                        if (!wrapper.hasAttribute("data-scanned") && scannerIntersectLeft > 0) {
                            wrapper.setAttribute("data-scanned", "true");
                            const scanEffect = document.createElement("div");
                            scanEffect.className = "scan-effect";
                            wrapper.appendChild(scanEffect);
                            setTimeout(() => {
                              if (scanEffect.parentNode) {
                                    scanEffect.parentNode.removeChild(scanEffect);
                              }
                            }, 600);
                        }
                  } else {
                        if (cardRight < scannerLeft) {
                            normalCard.style.setProperty("--clip-right", "100%");
                            asciiCard.style.setProperty("--clip-left", "100%");
                        } elseif (cardLeft > scannerRight) {
                            normalCard.style.setProperty("--clip-right", "0%");
                            asciiCard.style.setProperty("--clip-left", "0%");
                        }
                        wrapper.removeAttribute("data-scanned");
                  }
                });

                if (window.setScannerScanning) {
                  window.setScannerScanning(anyScanningActive);
                }
            }

            updateAsciiContent() {
                document.querySelectorAll(".ascii-content").forEach((content) => {
                  if (Math.random() < 0.15) {
                        const { width, height } = this.calculateCodeDimensions(400, 250);
                        content.textContent = this.generateCode(width, height);
                  }
                });
            }

            populateCardLine() {
                this.cardLine.innerHTML = "";
                const cardsCount = 30;
                for (let i = 0; i < cardsCount; i++) {
                  const cardWrapper = this.createCardWrapper(i);
                  this.cardLine.appendChild(cardWrapper);
                }
            }

            startPeriodicUpdates() {
                setInterval(() => {
                  this.updateAsciiContent();
                }, 200);

                const updateClipping = () => {
                  this.updateCardClipping();
                  requestAnimationFrame(updateClipping);
                };
                updateClipping();
            }
      }

      let cardStream;

      function toggleAnimation() {
            if (cardStream) {
                cardStream.toggleAnimation();
            }
      }

      function resetPosition() {
            if (cardStream) {
                cardStream.resetPosition();
            }
      }

      function changeDirection() {
            if (cardStream) {
                cardStream.changeDirection();
            }
      }

      class ParticleSystem {
            constructor() {
                this.scene = null;
                this.camera = null;
                this.renderer = null;
                this.particles = null;
                this.particleCount = 400;
                this.canvas = document.getElementById("particleCanvas");

                this.init();
            }

            init() {
                this.scene = new THREE.Scene();

                this.camera = new THREE.OrthographicCamera(
                  -window.innerWidth / 2,
                  window.innerWidth / 2,
                  125,
                  -125,
                  1,
                  1000
                );
                this.camera.position.z = 100;

                this.renderer = new THREE.WebGLRenderer({
                  canvas: this.canvas,
                  alpha: true,
                  antialias: true,
                });
                this.renderer.setSize(window.innerWidth, 250);
                this.renderer.setClearColor(0x000000, 0);

                this.createParticles();

                this.animate();

                window.addEventListener("resize", () => this.onWindowResize());
            }

            createParticles() {
                const geometry = new THREE.BufferGeometry();
                const positions = newFloat32Array(this.particleCount * 3);
                const colors = newFloat32Array(this.particleCount * 3);
                const sizes = newFloat32Array(this.particleCount);
                const velocities = newFloat32Array(this.particleCount);

                const canvas = document.createElement("canvas");
                canvas.width = 100;
                canvas.height = 100;
                const ctx = canvas.getContext("2d");

                const half = canvas.width / 2;
                const hue = 217;

                const gradient = ctx.createRadialGradient(half, half, 0, half, half, half);
                gradient.addColorStop(0.025, "#fff");
                gradient.addColorStop(0.1, `hsl(${hue}, 61%, 33%)`);
                gradient.addColorStop(0.25, `hsl(${hue}, 64%, 6%)`);
                gradient.addColorStop(1, "transparent");

                ctx.fillStyle = gradient;
                ctx.beginPath();
                ctx.arc(half, half, half, 0, Math.PI * 2);
                ctx.fill();

                const texture = new THREE.CanvasTexture(canvas);

                for (let i = 0; i < this.particleCount; i++) {
                  positions = (Math.random() - 0.5) * window.innerWidth * 2;
                  positions = (Math.random() - 0.5) * 250;
                  positions = 0;

                  colors = 1;
                  colors = 1;
                  colors = 1;

                  const orbitRadius = Math.random() * 200 + 100;
                  sizes = (Math.random() * (orbitRadius - 60) + 60) / 8;

                  velocities = Math.random() * 60 + 30;
                }

                geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
                geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
                geometry.setAttribute("size", new THREE.BufferAttribute(sizes, 1));

                this.velocities = velocities;

                const alphas = newFloat32Array(this.particleCount);
                for (let i = 0; i < this.particleCount; i++) {
                  alphas = (Math.random() * 8 + 2) / 10;
                }
                geometry.setAttribute("alpha", new THREE.BufferAttribute(alphas, 1));
                this.alphas = alphas;

                const material = new THREE.ShaderMaterial({
                  uniforms: {
                        pointTexture: { value: texture },
                        size: { value: 15.0 },
                  },
                  vertexShader: `
      attribute float alpha;
      varying float vAlpha;
      varying vec3 vColor;
      uniform float size;
      
      void main() {
          vAlpha = alpha;
          vColor = color;
          vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
          gl_PointSize = size;
          gl_Position = projectionMatrix * mvPosition;
      }
      `,
                  fragmentShader: `
      uniform sampler2D pointTexture;
      varying float vAlpha;
      varying vec3 vColor;
      
      void main() {
          gl_FragColor = vec4(vColor, vAlpha) * texture2D(pointTexture, gl_PointCoord);
      }
      `,
                  transparent: true,
                  blending: THREE.AdditiveBlending,
                  depthWrite: false,
                  vertexColors: true,
                });

                this.particles = new THREE.Points(geometry, material);
                this.scene.add(this.particles);
            }

            animate() {
                requestAnimationFrame(() =>this.animate());

                if (this.particles) {
                  const positions = this.particles.geometry.attributes.position.array;
                  const alphas = this.particles.geometry.attributes.alpha.array;
                  const time = Date.now() * 0.001;

                  for (let i = 0; i < this.particleCount; i++) {
                        positions += this.velocities * 0.016;

                        if (positions > window.innerWidth / 2 + 100) {
                            positions = -window.innerWidth / 2 - 100;
                            positions = (Math.random() - 0.5) * 250;
                        }

                        positions += Math.sin(time + i * 0.1) * 0.5;

                        const twinkle = Math.floor(Math.random() * 10);
                        if (twinkle === 1 && alphas > 0) {
                            alphas -= 0.05;
                        } elseif (twinkle === 2 && alphas < 1) {
                            alphas += 0.05;
                        }

                        alphas = Math.max(0, Math.min(1, alphas));
                  }

                  this.particles.geometry.attributes.position.needsUpdate = true;
                  this.particles.geometry.attributes.alpha.needsUpdate = true;
                }

                this.renderer.render(this.scene, this.camera);
            }

            onWindowResize() {
                this.camera.left = -window.innerWidth / 2;
                this.camera.right = window.innerWidth / 2;
                this.camera.updateProjectionMatrix();

                this.renderer.setSize(window.innerWidth, 250);
            }

            destroy() {
                if (this.renderer) {
                  this.renderer.dispose();
                }
                if (this.particles) {
                  this.scene.remove(this.particles);
                  this.particles.geometry.dispose();
                  this.particles.material.dispose();
                }
            }
      }

      let particleSystem;

      class ParticleScanner {
            constructor() {
                this.canvas = document.getElementById("scannerCanvas");
                this.ctx = this.canvas.getContext("2d");
                this.animationId = null;

                this.w = window.innerWidth;
                this.h = 300;
                this.particles = [];
                this.count = 0;
                this.maxParticles = 800;
                this.intensity = 0.8;
                this.lightBarX = this.w / 2;
                this.lightBarWidth = 3;
                this.fadeZone = 60;

                this.scanTargetIntensity = 1.8;
                this.scanTargetParticles = 2500;
                this.scanTargetFadeZone = 35;

                this.scanningActive = false;

                this.baseIntensity = this.intensity;
                this.baseMaxParticles = this.maxParticles;
                this.baseFadeZone = this.fadeZone;

                this.currentIntensity = this.intensity;
                this.currentMaxParticles = this.maxParticles;
                this.currentFadeZone = this.fadeZone;
                this.transitionSpeed = 0.05;

                this.setupCanvas();
                this.createGradientCache();
                this.initParticles();
                this.animate();

                window.addEventListener("resize", () => this.onResize());
            }

            setupCanvas() {
                this.canvas.width = this.w;
                this.canvas.height = this.h;
                this.canvas.style.width = this.w + "px";
                this.canvas.style.height = this.h + "px";
                this.ctx.clearRect(0, 0, this.w, this.h);
            }

            onResize() {
                this.w = window.innerWidth;
                this.lightBarX = this.w / 2;
                this.setupCanvas();
            }

            createGradientCache() {
                this.gradientCanvas = document.createElement("canvas");
                this.gradientCtx = this.gradientCanvas.getContext("2d");
                this.gradientCanvas.width = 16;
                this.gradientCanvas.height = 16;

                const half = this.gradientCanvas.width / 2;
                const gradient = this.gradientCtx.createRadialGradient(
                  half,
                  half,
                  0,
                  half,
                  half,
                  half
                );
                gradient.addColorStop(0, "rgba(255, 255, 255, 1)");
                gradient.addColorStop(0.3, "rgba(196, 181, 253, 0.8)");
                gradient.addColorStop(0.7, "rgba(139, 92, 246, 0.4)");
                gradient.addColorStop(1, "transparent");

                this.gradientCtx.fillStyle = gradient;
                this.gradientCtx.beginPath();
                this.gradientCtx.arc(half, half, half, 0, Math.PI * 2);
                this.gradientCtx.fill();
            }

            random(min, max) {
                if (arguments.length < 2) {
                  max = min;
                  min = 0;
                }
                returnMath.floor(Math.random() * (max - min + 1)) + min;
            }

            randomFloat(min, max) {
                returnMath.random() * (max - min) + min;
            }

            createParticle() {
                const intensityRatio = this.intensity / this.baseIntensity;
                const speedMultiplier = 1 + (intensityRatio - 1) * 1.2;
                const sizeMultiplier = 1 + (intensityRatio - 1) * 0.7;

                return {
                  x:
                        this.lightBarX +
                        this.randomFloat(-this.lightBarWidth / 2, this.lightBarWidth / 2),
                  y: this.randomFloat(0, this.h),

                  vx: this.randomFloat(0.2, 1.0) * speedMultiplier,
                  vy: this.randomFloat(-0.15, 0.15) * speedMultiplier,

                  radius: this.randomFloat(0.4, 1) * sizeMultiplier,
                  alpha: this.randomFloat(0.6, 1),
                  decay: this.randomFloat(0.005, 0.025) * (2 - intensityRatio * 0.5),
                  originalAlpha: 0,
                  life: 1.0,
                  time: 0,
                  startX: 0,

                  twinkleSpeed: this.randomFloat(0.02, 0.08) * speedMultiplier,
                  twinkleAmount: this.randomFloat(0.1, 0.25),
                };
            }

            initParticles() {
                for (let i = 0; i < this.maxParticles; i++) {
                  const particle = this.createParticle();
                  particle.originalAlpha = particle.alpha;
                  particle.startX = particle.x;
                  this.count++;
                  this.particles = particle;
                }
            }

            updateParticle(particle) {
                particle.x += particle.vx;
                particle.y += particle.vy;
                particle.time++;

                particle.alpha =
                  particle.originalAlpha * particle.life +
                  Math.sin(particle.time * particle.twinkleSpeed) * particle.twinkleAmount;

                particle.life -= particle.decay;

                if (particle.x > this.w + 10 || particle.life <= 0) {
                  this.resetParticle(particle);
                }
            }

            resetParticle(particle) {
                particle.x =
                  this.lightBarX +
                  this.randomFloat(-this.lightBarWidth / 2, this.lightBarWidth / 2);
                particle.y = this.randomFloat(0, this.h);
                particle.vx = this.randomFloat(0.2, 1.0);
                particle.vy = this.randomFloat(-0.15, 0.15);
                particle.alpha = this.randomFloat(0.6, 1);
                particle.originalAlpha = particle.alpha;
                particle.life = 1.0;
                particle.time = 0;
                particle.startX = particle.x;
            }

            drawParticle(particle) {
                if (particle.life <= 0) return;

                let fadeAlpha = 1;

                if (particle.y < this.fadeZone) {
                  fadeAlpha = particle.y / this.fadeZone;
                } elseif (particle.y > this.h - this.fadeZone) {
                  fadeAlpha = (this.h - particle.y) / this.fadeZone;
                }

                fadeAlpha = Math.max(0, Math.min(1, fadeAlpha));

                this.ctx.globalAlpha = particle.alpha * fadeAlpha;
                this.ctx.drawImage(
                  this.gradientCanvas,
                  particle.x - particle.radius,
                  particle.y - particle.radius,
                  particle.radius * 2,
                  particle.radius * 2
                );
            }

            drawLightBar() {
                const verticalGradient = this.ctx.createLinearGradient(0, 0, 0, this.h);
                verticalGradient.addColorStop(0, "rgba(255, 255, 255, 0)");
                verticalGradient.addColorStop(
                  this.fadeZone / this.h,
                  "rgba(255, 255, 255, 1)"
                );
                verticalGradient.addColorStop(
                  1 - this.fadeZone / this.h,
                  "rgba(255, 255, 255, 1)"
                );
                verticalGradient.addColorStop(1, "rgba(255, 255, 255, 0)");

                this.ctx.globalCompositeOperation = "lighter";

                const targetGlowIntensity = this.scanningActive ? 3.5 : 1;

                if (!this.currentGlowIntensity) this.currentGlowIntensity = 1;

                this.currentGlowIntensity +=
                  (targetGlowIntensity - this.currentGlowIntensity) * this.transitionSpeed;

                const glowIntensity = this.currentGlowIntensity;
                const lineWidth = this.lightBarWidth;
                const glow1Alpha = this.scanningActive ? 1.0 : 0.8;
                const glow2Alpha = this.scanningActive ? 0.8 : 0.6;
                const glow3Alpha = this.scanningActive ? 0.6 : 0.4;

                const coreGradient = this.ctx.createLinearGradient(
                  this.lightBarX - lineWidth / 2,
                  0,
                  this.lightBarX + lineWidth / 2,
                  0
                );
                coreGradient.addColorStop(0, "rgba(255, 255, 255, 0)");
                coreGradient.addColorStop(
                  0.3,
                  `rgba(255, 255, 255, ${0.9 * glowIntensity})`
                );
                coreGradient.addColorStop(0.5, `rgba(255, 255, 255, ${1 * glowIntensity})`);
                coreGradient.addColorStop(
                  0.7,
                  `rgba(255, 255, 255, ${0.9 * glowIntensity})`
                );
                coreGradient.addColorStop(1, "rgba(255, 255, 255, 0)");

                this.ctx.globalAlpha = 1;
                this.ctx.fillStyle = coreGradient;

                const radius = 15;
                this.ctx.beginPath();
                this.ctx.roundRect(
                  this.lightBarX - lineWidth / 2,
                  0,
                  lineWidth,
                  this.h,
                  radius
                );
                this.ctx.fill();

                const glow1Gradient = this.ctx.createLinearGradient(
                  this.lightBarX - lineWidth * 2,
                  0,
                  this.lightBarX + lineWidth * 2,
                  0
                );
                glow1Gradient.addColorStop(0, "rgba(139, 92, 246, 0)");
                glow1Gradient.addColorStop(
                  0.5,
                  `rgba(196, 181, 253, ${0.8 * glowIntensity})`
                );
                glow1Gradient.addColorStop(1, "rgba(139, 92, 246, 0)");

                this.ctx.globalAlpha = glow1Alpha;
                this.ctx.fillStyle = glow1Gradient;

                const glow1Radius = 25;
                this.ctx.beginPath();
                this.ctx.roundRect(
                  this.lightBarX - lineWidth * 2,
                  0,
                  lineWidth * 4,
                  this.h,
                  glow1Radius
                );
                this.ctx.fill();

                const glow2Gradient = this.ctx.createLinearGradient(
                  this.lightBarX - lineWidth * 4,
                  0,
                  this.lightBarX + lineWidth * 4,
                  0
                );
                glow2Gradient.addColorStop(0, "rgba(139, 92, 246, 0)");
                glow2Gradient.addColorStop(
                  0.5,
                  `rgba(139, 92, 246, ${0.4 * glowIntensity})`
                );
                glow2Gradient.addColorStop(1, "rgba(139, 92, 246, 0)");

                this.ctx.globalAlpha = glow2Alpha;
                this.ctx.fillStyle = glow2Gradient;

                const glow2Radius = 35;
                this.ctx.beginPath();
                this.ctx.roundRect(
                  this.lightBarX - lineWidth * 4,
                  0,
                  lineWidth * 8,
                  this.h,
                  glow2Radius
                );
                this.ctx.fill();

                if (this.scanningActive) {
                  const glow3Gradient = this.ctx.createLinearGradient(
                        this.lightBarX - lineWidth * 8,
                        0,
                        this.lightBarX + lineWidth * 8,
                        0
                  );
                  glow3Gradient.addColorStop(0, "rgba(139, 92, 246, 0)");
                  glow3Gradient.addColorStop(0.5, "rgba(139, 92, 246, 0.2)");
                  glow3Gradient.addColorStop(1, "rgba(139, 92, 246, 0)");

                  this.ctx.globalAlpha = glow3Alpha;
                  this.ctx.fillStyle = glow3Gradient;

                  const glow3Radius = 45;
                  this.ctx.beginPath();
                  this.ctx.roundRect(
                        this.lightBarX - lineWidth * 8,
                        0,
                        lineWidth * 16,
                        this.h,
                        glow3Radius
                  );
                  this.ctx.fill();
                }

                this.ctx.globalCompositeOperation = "destination-in";
                this.ctx.globalAlpha = 1;
                this.ctx.fillStyle = verticalGradient;
                this.ctx.fillRect(0, 0, this.w, this.h);
            }

            render() {
                const targetIntensity = this.scanningActive
                  ? this.scanTargetIntensity
                  : this.baseIntensity;
                const targetMaxParticles = this.scanningActive
                  ? this.scanTargetParticles
                  : this.baseMaxParticles;
                const targetFadeZone = this.scanningActive
                  ? this.scanTargetFadeZone
                  : this.baseFadeZone;

                this.currentIntensity +=
                  (targetIntensity - this.currentIntensity) * this.transitionSpeed;
                this.currentMaxParticles +=
                  (targetMaxParticles - this.currentMaxParticles) * this.transitionSpeed;
                this.currentFadeZone +=
                  (targetFadeZone - this.currentFadeZone) * this.transitionSpeed;

                this.intensity = this.currentIntensity;
                this.maxParticles = Math.floor(this.currentMaxParticles);
                this.fadeZone = this.currentFadeZone;

                this.ctx.globalCompositeOperation = "source-over";
                this.ctx.clearRect(0, 0, this.w, this.h);

                this.drawLightBar();

                this.ctx.globalCompositeOperation = "lighter";
                for (let i = 1; i <= this.count; i++) {
                  if (this.particles) {
                        this.updateParticle(this.particles);
                        this.drawParticle(this.particles);
                  }
                }

                const currentIntensity = this.intensity;
                const currentMaxParticles = this.maxParticles;

                if (Math.random() < currentIntensity && this.count < currentMaxParticles) {
                  const particle = this.createParticle();
                  particle.originalAlpha = particle.alpha;
                  particle.startX = particle.x;
                  this.count++;
                  this.particles = particle;
                }

                const intensityRatio = this.intensity / this.baseIntensity;

                if (intensityRatio > 1.1 && Math.random() < (intensityRatio - 1.0) * 1.2) {
                  const particle = this.createParticle();
                  particle.originalAlpha = particle.alpha;
                  particle.startX = particle.x;
                  this.count++;
                  this.particles = particle;
                }

                if (intensityRatio > 1.3 && Math.random() < (intensityRatio - 1.3) * 1.4) {
                  const particle = this.createParticle();
                  particle.originalAlpha = particle.alpha;
                  particle.startX = particle.x;
                  this.count++;
                  this.particles = particle;
                }

                if (intensityRatio > 1.5 && Math.random() < (intensityRatio - 1.5) * 1.8) {
                  const particle = this.createParticle();
                  particle.originalAlpha = particle.alpha;
                  particle.startX = particle.x;
                  this.count++;
                  this.particles = particle;
                }

                if (intensityRatio > 2.0 && Math.random() < (intensityRatio - 2.0) * 2.0) {
                  const particle = this.createParticle();
                  particle.originalAlpha = particle.alpha;
                  particle.startX = particle.x;
                  this.count++;
                  this.particles = particle;
                }

                if (this.count > currentMaxParticles + 200) {
                  const excessCount = Math.min(15, this.count - currentMaxParticles);
                  for (let i = 0; i < excessCount; i++) {
                        deletethis.particles;
                  }
                  this.count -= excessCount;
                }
            }

            animate() {
                this.render();
                this.animationId = requestAnimationFrame(() =>this.animate());
            }

            startScanning() {
                this.scanningActive = true;
                console.log("Scanning started - intense particle mode activated");
            }

            stopScanning() {
                this.scanningActive = false;
                console.log("Scanning stopped - normal particle mode");
            }

            setScanningActive(active) {
                this.scanningActive = active;
                console.log("Scanning mode:", active ? "active" : "inactive");
            }

            getStats() {
                return {
                  intensity: this.intensity,
                  maxParticles: this.maxParticles,
                  currentParticles: this.count,
                  lightBarWidth: this.lightBarWidth,
                  fadeZone: this.fadeZone,
                  scanningActive: this.scanningActive,
                  canvasWidth: this.w,
                  canvasHeight: this.h,
                };
            }

            destroy() {
                if (this.animationId) {
                  cancelAnimationFrame(this.animationId);
                }

                this.particles = [];
                this.count = 0;
            }
      }

      let particleScanner;

      document.addEventListener("DOMContentLoaded", () => {
            cardStream = new CardStreamController();
            particleSystem = new ParticleSystem();
            particleScanner = new ParticleScanner();

            window.setScannerScanning = (active) => {
                if (particleScanner) {
                  particleScanner.setScanningActive(active);
                }
            };

            window.getScannerStats = () => {
                if (particleScanner) {
                  return particleScanner.getStats();
                }
                returnnull;
            };
      });

    </script>
</body>

</html>

HTML
<div class="controls">
<button onclick="toggleAnimation()">⏸️ Pause</button>
<button onclick="resetPosition()">🔄 Reset</button>
<button onclick="changeDirection()">↔️ Direction</button>
</div>

<div class="speed-indicator">Speed: <span id="speedValue">120</span> px/s</div>

<div class="container">
<canvas id="particleCanvas"></canvas>
<canvas id="scannerCanvas"></canvas>
<div class="scanner"></div>
<div class="card-stream" id="cardStream">
    <div class="card-line" id="cardLine"></div>
</div>
</div>
[*].controls:控制按钮区域,控制动画的暂停、重置、方向。
[*].speed-indicator:显示当前滚动速度。
[*].container:主容器,包含:
[*]particleCanvas:Three.js 粒子背景。
[*]scannerCanvas:扫描光束粒子效果。
[*].scanner:CSS 实现的扫描光束。
[*].card-stream:信用卡滚动区域。
CSS

卡片样式

.card-wrapper {
position: relative;
width: 400px;
height: 250px;
flex-shrink: 0;
}
.card-normal {
clip-path: inset(000 var(--clip-right, 0%));
}
.card-ascii {
clip-path: inset(0 calc(100% - var(--clip-left, 0%)) 00);
}
[*]每张卡片由两层组成:
.card-normal:显示信用卡图片。.card-ascii:显示 ASCII 字符。
[*]使用 clip-path 实现扫描过渡效果:
当卡片进入扫描区域时,左侧显示 ASCII,右侧显示图片。--clip-left 和 --clip-right 是动态更新的 CSS 变量。扫描光束.scanner {
display: none;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 4px;
height: 300px;
background: linear-gradient(...);
animation: scanPulse 2s ease-in-out infinite alternate;
}

[*]中间有一条垂直光束,模拟扫描线。
[*]实际扫描逻辑由 JS 控制,CSS 只负责视觉效果。
JS 逻辑部分1. CardStreamController:卡片滚动控制控制卡片左右滚动(支持拖拽、滚轮、触摸)。检测卡片是否进入扫描区域。动态更新 clip-path 实现扫描过渡。随机更新 ASCII 内容。核心方法: updateCardClipping():计算卡片与扫描线的交集,更新 clip-path。createCardWrapper():生成每张卡片的 DOM 结构(图片 + ASCII)。generateCode():生成伪代码字符串,用于 ASCII 显示。2. ParticleSystem:Three.js 背景粒子使用 Three.js 创建横向流动的粒子背景。粒子从右向左移动,模拟“数据流动”。3. ParticleScanner:扫描粒子效果使用 Canvas2D 在扫描线附近生成粒子。当卡片进入扫描区域时:粒子密度增加。光束变亮。粒子速度加快。
页: [1]
查看完整版本: HTML&CSS&JS:卡片扫描动画效果