GioGio 的 Blog

Canvas实现「墙缝透光」欢迎页开场动画:跟随光斑、径向光束、白场过渡(附完整源码)

2026-10-17canvas,网站欢迎页,Landing Page 开场动画,鼠标跟随,径向模糊,源码,Vue,前端,html
## 网站欢迎页/index的设计与实现


> 本文用一个单文件 HTML 实现网站欢迎页开场动画:像素墙缝隙透光、鼠标跟随光斑、Canvas 径向光束、点击后白场过渡。含完整可运行源码。
> 本文分两部分:**设计总结、简化源码**。
---

## 一、设计理念

> **灰色是世界的底色,彩色的光是你来过的证据。**

首页不只是"一个好看的 landing page",是一次**仪式**:
页面默认是**封存的、无光的**;访客移动鼠标只是**主动去窥见**;点击后,光是慢慢长满整屏的,然后进入探索这个尘封的世界。


---

## 二、设计总纲

### 2.1 为什么设计了"一面墙"

- 墙是阻隔。就好像访客不可能无阻碍的发现网站,点进来是一种奇迹。奇迹是光,光透过"缝"照射出来。
- 窥见比展示更勾人:**你看到的不是全部,你才想点下去。** 这是探索欲的本能

### 2.2 缝隙透光的实现原理(透明像素 + 双层叠加)

`bg.png` 图片是关键,只有透明像素的地方才会有光射出来。源图 400×224,放大取**整数倍**,`image-rendering: pixelated`

- 光层在墙**下面**,所以只有这些像素能透光;
- 光柱也在墙**上面**,所以光还能"射到墙面";
- 两层叠加,才同时有"缝里透出来的光"和"打在墙上的光柱"。
- 光属于用户鼠标,光斑坐标每帧由指针驱动,带缓动拖尾,追上了就停 rAF

**没有透明像素,就没有这个页面。** 这是素材决定效果,不是 CSS 决定效果。

### 2.3 登入颜色只在开卷时出现

- 静止时:墙皮 `grayscale(1)`,标题灰,光层 `opacity: 0`
- 光标进入:光层亮起,墙面回到正常灰度 —— 还是灰的,只是**不再死寂**。
- 点击开卷:墙才上 `sepia(0.5) saturate(0.85)` 的**低饱和暖色**,光柱 1.25s 长满全屏。
- 彩色出现得越少,出现的那一下越珍贵。

## 三、页面结构

### 3.1 五层 DOM + 一层白幕

```mermaid
flowchart TB
    subgraph stage["#stage(fixed inset:0 / z-index 9998)"]
        A["① .under 黑暗底层<br/>radial-gradient #131313 → #070707"]
        B["② .light 底层之光<br/>光核 + 放射光束 · mix-blend-mode: screen"]
        C["③ .wall 墙皮 img<br/>400×224 整数倍 · pixelated · 2.5% 透明"]
        D["④ canvas.beams 射出的光柱<br/>径向模糊 · screen"]
        E["⑤ h1.title 像素标题"]
    end
    F["⑥ .open-veil 白幕<br/>挂在 body 上 · z-index 99999"]
    A --> B --> C --> D --> E
    E -. 点击后接管 .-> F
```

> 注意 DOM 顺序:**光层在墙皮前面**(视觉上在下面),**光柱画布在墙皮后面**(视觉上在上面)。
> 不是靠 `z-index` 调出来的

### 3.2 每层DOM作用

| 层 | 作用 | 关键属性 |
|---|---|---|
| `.under` | 底色 | 中心 `#131313` → 边缘 `#070707` |
| `.light` | 一个 1200×1200 的静态光斑,只动 `transform` | `mix-blend-mode: screen`、`opacity 0→1` |
| `.light-scale` | 进入时把光扩散到全屏 | `transform: scale(4)`,1.35s |
| `.wall` | 遮住光,只留缝 | `filter: grayscale/brightness/sepia`、`pixelated` |
| `canvas.beams` | 把缝隙当成光源,做径向模糊 | 半分辨率、`screen`、`pointer-events:none` |
| `.title` | 印在墙上的像素字 | 44px / 33px(22×2、11×3)、不拦鼠标 |
| `.open-veil` | 白屏交接,活过路由切换 | `fixed`、尺寸由 JS 钉死、`no-fade` |

---

## 四、交互时间线

| 时间 | 触发 | 事件 |
|---|---|---|
| 初始 | — | 墙 `brightness(.9)` 压暗;光层与光柱 `opacity: 0`;标题 `opacity: .72` |
| 指针第一次移动 | `is-awake` | 光层 0.9s 淡入;墙面回到 `brightness(1)`;标题到 `opacity: 1` |
| 指针移动 | rAF | `--lx/--ly` 缓动跟随(系数 0.12),每帧重算光柱 |
| 点击 | `is-opening` | 墙皮 1.1s 转暖;`.light-scale` 1.35s 放大 4 倍;画布开始白|
| +1250 ms | 画面纯白 | 生成白幕(`no-fade` 瞬间接上),标题开始 1.7s 缓慢放大 `scale(1.09)` |
| +900 ms | 停留结束 | `router.push('/home')`,欢迎页卸载 |
| +500 ms | 交接缓冲 | 白幕恢复 0.9s 过渡并淡出 |
| +1200 ms | 收尾 | 白幕移除 |

时间常数都写在文件里,**只有三个**:`OPEN_MS = 1250`、`HOLD_MS = 900`、白幕淡出 `0.9s`。

---

## 五、源码决策

| 为什么 | 理由 |
|---|---|
| 墙用 `<img>` 而不是 `background-image` | 尺寸/居中/整数倍缩放好控制;**要读它的像素**去生成裂缝贴图(`getImageData` 对跨域图片会污染画布,同源 `<img>` + canvas 最简单) |
| 放大取 `ceil` 整数倍 | 400×224 放大 2.37 倍时每个像素宽度不一致,像素边会糊、会抖。取整数倍 + `pixelated`,每个像素都是同样大的方块 |
| 光斑用 `transform: translate3d()` | 只走合成器,不触发重排/重绘;每帧改 `left/top` 会掉帧 |
| 缓动系数 0.12 + 追上就停 rAF | 光比光标慢半拍 = 温润的拖尾;差值 ≤0.5px 就取消 rAF,静止时零开销 |
| 光柱必须用 canvas,不能用 CSS | CSS 的 `blur()` 是**均匀**模糊;这里需要**以光标为中心**的径向模糊。做法:把裂缝贴图绕光标逐层放大叠加(10 层 × 0.025 步长),越外层越淡 |
| 光束画布放在墙皮**之上** + `mix-blend-mode: screen` | 光要"射到墙面",而不是只留在缝里。screen 叠加让光只加亮、不盖住墙的纹理 |
| 画布用半分辨率 | 光柱本来就是软的,半分辨率看不出来,填充率直接省一半 |
| 开卷的"全白"由画布自己长出来 | 如果只靠一层白色遮罩淡入,观感是"变到一半突然白了"。让光柱半径连续变大到覆盖全屏,再让白幕无缝接手,才是连续的 |
| 标题用 `<h1>`,白幕里的副本用 `<div>` | 页面需要一个主体标题给爬虫(`/index` 也有 Django 预渲染版本);白幕只是视觉副本,不能出现两个 `h1` |
| 字号 44px / 33px | 像素字体要整数倍:全站 22px × 2、移动端 11px × 3;且 `margin: 0`、`font-weight: normal`——h1 的默认粗体会触发**伪粗体**,像素字立刻变胖变糊 |
| 白幕挂在 `body` 上(样式不 scoped) | 它必须**活过欢迎页的卸载**,否则路由切换那一瞬会闪 |
| 白幕宽高在创建时写死 | `/home` 会挤出滚动条让视口窄 15px;若白幕跟着视口走,标题会左移 7.5px,右边还漏一条新页的缝——就是那下"抖" |
| 舞台尺寸用 `getBoundingClientRect()` | `innerWidth/clientWidth` 会随滚动条出现而变(实测 1675 ↔ 1660),谁依赖它谁的居中就会被推着走 |

---

## 六、简化版完整源码

下面这份是**单文件、零依赖**的复刻版,可以直接双击运行(同目录的 `index-demo.html` 就是它)。
和原项目相比:

- 墙皮不再用 `bg.png`,而是**用 canvas 现场生成**(4×4 像素噪声 + 26 条随机裂缝,用 `destination-out` 擦成透明);
- 去掉 Vue / 路由 / 后端,点击后用「推翻舞台 + 淡出白幕」模拟"开卷后进入博客";
- 保留全部核心机制:整数倍墙皮、缝里透光、径向模糊光柱、缓动跟随、开卷长白、白幕交接。

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>开卷仪式 · 简化版</title>
<style>
  /* 字体缺省回退到等宽;*/
  :root { --title-size: 44px; }
  @media (max-width: 768px) { :root { --title-size: 33px; } }

  * { box-sizing: border-box; }
  html, body { margin: 0; height: 100%; overflow: hidden; }
  body { background: #fff; font-family: "Pixel11", "Courier New", monospace; }

  /*  被欢迎页遮住的那一页  */
  #page {
    position: absolute; inset: 0; z-index: 0;
    display: grid; place-content: center; gap: 12px;
    text-align: center; color: #2b2b2b;
  }
  #page h1 { margin: 0; font-size: 28px; font-weight: normal; }
  #page p { margin: 0; color: #777; font-size: 14px; }
  #page button {
    margin: 8px auto 0; padding: 8px 18px;
    border: 1px solid #999; background: #fff; color: #333;
    font: inherit; cursor: pointer; transition: background .2s ease;
  }
  #page button:hover { background: #f0f0f0; }

  /*   舞台(fixed 铺满窗口)  */
  #stage {
    position: fixed; inset: 0; z-index: 10;
    overflow: hidden; cursor: pointer;
    background: #0b0b0b;
    user-select: none;
    -webkit-tap-highlight-color: transparent;
    --lx: 50vw; --ly: 50vh;   /* 光斑坐标,JS 每帧改写 */
  }

  /* 黑暗世界 */
  .under {
    position: absolute; inset: 0;
    background: radial-gradient(circle at 50% 50%, #131313 0%, #070707 70%);
  }

  /* 底层之光(在墙皮下面,只从透明像素里透出来)  */
  .light {
    position: absolute; left: 0; top: 0;
    width: 1200px; height: 1200px;
    margin: -600px 0 0 -600px;             /* 让 (--lx,--ly) 正好是光斑中心 */
    transform: translate3d(var(--lx), var(--ly), 0);
    will-change: transform;
    mix-blend-mode: screen;
    opacity: 0;                            /* 封存态:缝隙无光 */
    pointer-events: none;
    transition: opacity .9s ease;
  }
  #stage.awake .light { opacity: 1; }

  .light-scale {
    position: absolute; inset: 0;
    transition: transform 1.35s cubic-bezier(.45, 0, .9, .25);
  }
  #stage.opening .light-scale { transform: scale(4); }   /* 开卷:光扩散到全屏 */

  /* 丁达尔光束:repeating-conic-gradient 出放射条纹 */
  .rays {
    position: absolute; inset: 0;
    background: repeating-conic-gradient(
      from 0deg,
      rgba(255,246,161,0) 0deg,
      rgba(255,246,161,.55) 4deg,
      rgba(255,246,161,0) 11deg
    );
    -webkit-mask-image: radial-gradient(circle at 50% 50%, transparent 0%, #000 8%, #000 40%, transparent 78%);
            mask-image: radial-gradient(circle at 50% 50%, transparent 0%, #000 8%, #000 40%, transparent 78%);
    filter: blur(2px);
    opacity: .75;
    animation: spin 90s linear infinite;
  }
  .rays.fine {
    background: repeating-conic-gradient(
      from 7deg,
      rgba(255,255,255,0) 0deg,
      rgba(255,255,255,.4) 2.5deg,
      rgba(255,255,255,0) 7deg
    );
    opacity: .55;
    animation-duration: 150s;
    animation-direction: reverse;
  }
  @keyframes spin { to { transform: rotate(360deg); } }

  /* 光核:暖白的中心辉光 */
  .core {
    position: absolute; inset: 0;
    background: radial-gradient(circle at 50% 50%,
      #fff 0%, #fff 10%, #fff6a1 18%,
      rgba(255,250,200,.82) 30%,
      rgba(255,246,161,.45) 45%,
      rgba(255,246,161,.14) 60%,
      rgba(255,246,161,0) 76%);
  }

  /*  墙皮(代替bg.png ) */
  .wall {
    position: absolute; left: 50%; top: 50%;
    transform: translate(-50%, -50%);
    image-rendering: pixelated;            /* 整数倍放大 → 像素不被重采样 */
    filter: grayscale(1) brightness(.9);   /* 封存:压暗 */
    transition: filter 1.1s ease;
  }
  #stage.awake .wall { filter: grayscale(1) brightness(1); }
  #stage.opening .wall { filter: sepia(.5) saturate(.85) brightness(1.06); }

  /* 从缝隙射出的光束(画在墙皮之上)*/
  .beams {
    position: absolute; inset: 0; width: 100%; height: 100%;
    pointer-events: none;
    mix-blend-mode: screen;
    opacity: 0;
    transition: opacity .9s ease;
  }
  #stage.awake .beams { opacity: 1; }

  /* 标题 */
  .title {
    position: absolute; left: 0; right: 0; top: 50%;
    transform: translateY(-50%);
    margin: 0; text-align: center;
    font-size: var(--title-size); font-weight: normal;
    letter-spacing: 2px;
    color: #2b2b2b; text-shadow: 2px 2px 0 rgba(0,0,0,.08);
    pointer-events: none;
    opacity: .72;
    transition: opacity .9s ease;
  }
  #stage.awake .title { opacity: 1; }

  /* 白幕(挂在 body 上,生命周期到场景卸载) */
  .veil {
    position: fixed; left: 0; top: 0; z-index: 50;
    display: grid; place-items: center;
    background: #fff;
    opacity: 0;
    pointer-events: none;
    transition: opacity .9s ease;
  }
  .veil.on { opacity: 1; }
  .veil.no-fade { transition: none; }      /* 交接那一瞬不能淡入 */
  .veil b {
    font-size: var(--title-size); font-weight: normal;
    letter-spacing: 2px; color: #2b2b2b;
    text-shadow: 2px 2px 0 rgba(0,0,0,.08);
    transform: scale(1);
    transition: transform 1.7s cubic-bezier(.22,.61,.36,1);
  }
  .veil.zoom b { transform: scale(1.09); }  /* 停留时缓慢推近 */
</style>
</head>
<body>

<!-- 被欢迎页盖住的那一页,就是进入后页面 -->
<div id="page">
  <h1>GioGio's Blog</h1>
  <p>光散去,墙消失 —— GioGio's Blog</p>
  <button onclick="location.reload()">重新</button>
</div>

<div id="stage">
  <div class="under"></div>

  <div class="light">
    <div class="light-scale">
      <div class="rays"></div>
      <div class="rays fine"></div>
      <div class="core"></div>
    </div>
  </div>

  <img id="wall" class="wall" alt="" draggable="false" />

  <canvas id="beams" class="beams"></canvas>

  <h1 class="title">GioGio's Blog</h1>
</div>

<script>
(() => {
  const stage   = document.getElementById('stage');
  const wallImg = document.getElementById('wall');
  const beams   = document.getElementById('beams');
  const bctx    = beams.getContext('2d');

  const WALL_W = 400, WALL_H = 224;   // 墙皮源图尺寸(比如bg.png = 400×224)
  let wallW = WALL_W, wallH = WALL_H;
  let stageW = 0, stageH = 0;

  //  有透明“裂缝”的背景图”( bg.png)
  function makeWall() {
    const cv = document.createElement('canvas');
    cv.width = WALL_W; cv.height = WALL_H;
    const c = cv.getContext('2d');

    // 4×4 的像素块:旧墙的斑驳(色差很小,远了看是“一面灰墙”)
    const tones = ['#d8d6d1', '#d5d3ce', '#dbd9d4', '#d2d0cb', '#d7d5d0'];
    for (let y = 0; y < WALL_H; y += 4) {
      for (let x = 0; x < WALL_W; x += 4) {
        c.fillStyle = tones[(Math.random() * tones.length) | 0];
        c.fillRect(x, y, 4, 4);
      }
    }
    // 零星暗斑
    for (let i = 0; i < 220; i++) {
      c.fillStyle = `rgba(90,86,78,${(0.04 + Math.random() * 0.08).toFixed(3)})`;
      c.fillRect((Math.random() * WALL_W) | 0, (Math.random() * WALL_H) | 0, 2, 2);
    }
    // 裂缝:逐个像素擦成全透明 —— 这些像素就是“裂穿”的地方
    // (不用 stroke:抗锯齿会产生半透明边缘,透明像素就凑不够)
    c.globalCompositeOperation = 'destination-out';
    c.fillStyle = '#000';
    for (let i = 0; i < 26; i++) {
      let x = Math.random() * WALL_W;
      let y = Math.random() * WALL_H;
      let a = Math.random() * Math.PI * 2;
      const segs = 12 + ((Math.random() * 22) | 0);
      for (let j = 0; j < segs; j++) {
        a += (Math.random() - 0.5) * 1.2;
        const nx = x + Math.cos(a) * (2 + Math.random() * 7);
        const ny = y + Math.sin(a) * (2 + Math.random() * 7);
        const steps = Math.max(1, Math.ceil(Math.hypot(nx - x, ny - y)));
        for (let t = 0; t < steps; t++) {
          c.fillRect(Math.round(x + (nx - x) * t / steps),
                     Math.round(y + (ny - y) * t / steps), 1, 1);
        }
        x = nx; y = ny;
      }
    }
    c.globalCompositeOperation = 'source-over';
    return cv;
  }

  // 把透明像素烘成一张发光贴图
  function makeCrackTex(wallCv) {
    const src = wallCv.getContext('2d').getImageData(0, 0, WALL_W, WALL_H);
    const d = src.data;
    let holes = 0;
    for (let i = 0; i < d.length; i += 4) {
      const hole = d[i + 3] < 16;                  // 原图这里裂透了
      if (hole) holes++;
      d[i] = 255; d[i + 1] = 250; d[i + 2] = 205;  // 暖白
      d[i + 3] = hole ? 255 : 0;
    }
    const one = document.createElement('canvas');  // 只含缝隙的 1px 图
    one.width = WALL_W; one.height = WALL_H;
    one.getContext('2d').putImageData(src, 0, 0);

    const tex = document.createElement('canvas');
    tex.width = WALL_W; tex.height = WALL_H;
    const t = tex.getContext('2d');
    // 缝隙只有 1 像素宽,四向各补一像素,光柱才有“根”
    for (const [ox, oy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) {
      t.drawImage(one, ox, oy);
    }
    console.log('裂穿像素:', holes);
    return tex;
  }

  const wallCv = makeWall();
  wallImg.src = wallCv.toDataURL('image/png');
  const crackTex = makeCrackTex(wallCv);

  // 尺寸:墙皮整数倍放大铺满
  function fitWall() {
    const box = stage.getBoundingClientRect();   // 用元素盒子,不用 innerWidth
    stageW = box.width;
    stageH = box.height;

    const scale = Math.max(1, Math.ceil(Math.max(stageW / WALL_W, stageH / WALL_H)));
    wallW = WALL_W * scale;
    wallH = WALL_H * scale;
    wallImg.style.width  = wallW + 'px';
    wallImg.style.height = wallH + 'px';

    // 光束画布用半分辨率:光柱本来就是软的,看不出差别省一半填充
    beams.width  = Math.max(1, Math.ceil(stageW * 0.5));
    beams.height = Math.max(1, Math.ceil(stageH * 0.5));
    render();
  }

  //  光斑跟随+拖尾
  let raf = 0;
  let tx = 0, ty = 0, cx = 0, cy = 0;

  function setLight(x, y) {
    stage.style.setProperty('--lx', x.toFixed(1) + 'px');
    stage.style.setProperty('--ly', y.toFixed(1) + 'px');
  }

  function frame() {
    cx += (tx - cx) * 0.12;            // 光斑比光标慢半拍,营造拖尾手感
    cy += (ty - cy) * 0.12;
    setLight(cx, cy);
    render();                          // 光柱以光标为中心重算
    raf = (Math.abs(tx - cx) + Math.abs(ty - cy) > 0.5)
      ? requestAnimationFrame(frame)
      : 0;                             // 追上了就停
  }

  function aim(x, y) {
    tx = x; ty = y;
    if (!raf) raf = requestAnimationFrame(frame);
  }

  // 从缝隙射出的光束(径向模糊)
  const SAMPLES = 10;     // 叠几层
  const STEP    = 0.025;  // 每层向外放大多少 → 光柱能拉多长
  const REACH   = 480;    // 光柱照多远(CSS px)
  let openProgress = 0;   // 0~1,开卷进度

  function render() {
    if (!crackTex) return;
    const vw = stageW, vh = stageH;
    if (!vw || !vh) return;
    const s = beams.width / vw;        // 画布分辨率相对视口

    bctx.setTransform(1, 0, 0, 1, 0, 0);
    bctx.clearRect(0, 0, beams.width, beams.height);
    bctx.filter = 'none';
    bctx.globalAlpha = 1;

    // 墙皮在屏幕上的位置(和 <img> 完全一致),换算到画布坐标
    const dx = ((vw - wallW) / 2) * s;
    const dy = ((vh - wallH) / 2) * s;
    const dw = wallW * s;
    const dh = wallH * s;
    const px = cx * s;
    const py = cy * s;

    // 光柱越长、越亮、越柔
    const p       = openProgress;
    const stretch = 1 + p * 16;
    const gain    = 1 + p * 8;
    const soft    = 2 + p * 26;
    const samples = Math.max(4, Math.round(SAMPLES * (1 - p * 0.6)));
    const reach   = REACH * s * (1 + p * 16);

    bctx.globalCompositeOperation = 'lighter';
    bctx.filter = soft > 2.2 ? `blur(${(soft * 0.5).toFixed(1)}px)` : 'none';

    // 缝隙本体最亮
    bctx.globalAlpha = Math.min(1, 0.75 * gain);
    bctx.drawImage(crackTex, dx, dy, dw, dh);

    // 径向模糊:光柱
    for (let k = 1; k <= samples; k++) {
      const sc = 1 + k * STEP * stretch;
      bctx.globalAlpha = Math.min(1, 0.3 * (1 - k / (samples + 1)) * gain);
      bctx.setTransform(sc, 0, 0, sc, px - sc * px, py - sc * py);
      bctx.drawImage(crackTex, dx, dy, dw, dh);
    }
    bctx.filter = 'none';
    bctx.setTransform(1, 0, 0, 1, 0, 0);

    // 径向衰减:离光标越远越淡
    const fade = bctx.createRadialGradient(px, py, 0, px, py, reach);
    fade.addColorStop(0,    'rgba(255,255,255,1)');
    fade.addColorStop(.35,  'rgba(255,255,255,.9)');
    fade.addColorStop(.7,   'rgba(255,255,255,.42)');
    fade.addColorStop(1,    'rgba(255,255,255,0)');
    bctx.globalCompositeOperation = 'destination-in';
    bctx.globalAlpha = 1;
    bctx.fillStyle = fade;
    bctx.fillRect(0, 0, beams.width, beams.height);

    // 补一层白色让整体变化平滑
    const wash = Math.min(1, Math.max(0, (p - 0.6) / 0.4));
    if (wash > 0) {
      bctx.globalCompositeOperation = 'source-over';
      bctx.globalAlpha = wash;
      bctx.fillStyle = '#fff';
      bctx.fillRect(0, 0, beams.width, beams.height);
    }
    bctx.globalCompositeOperation = 'lighter';
    bctx.globalAlpha = 1;
  }

  // 交互
  let awake = false, opening = false;

  stage.addEventListener('pointermove', (e) => {
    if (opening) return;
    if (!awake) {
      awake = true;
      stage.classList.add('awake');
      cx = tx = e.clientX;   // 第一次移动:光直接从光标处亮起
      cy = ty = e.clientY;
    }
    aim(e.clientX, e.clientY);
  });

  stage.addEventListener('pointerdown', (e) => {
    // 触屏没有 hover:按下的位置就是光的位置
    if (!awake) {
      awake = true;
      stage.classList.add('awake');
    }
    aim(e.clientX, e.clientY);
  });

  const OPEN_MS = 1250;   // 光柱从缝隙射到满屏全白时间
  const HOLD_MS = 900;    // 全白之后标题停留时间

  stage.addEventListener('click', () => {
    if (opening) return;
    opening = true;
    stage.classList.add('opening');
    const start = performance.now();

    const tick = () => {
      openProgress = Math.min(1, (performance.now() - start) / OPEN_MS);
      render();
      if (openProgress < 1) requestAnimationFrame(tick);
      else handOver();
    };
    tick();
  });

  function handOver() {
    const veil = document.createElement('div');
    veil.className = 'veil no-fade';
    // 固定尺寸
    veil.style.width  = stageW + 'px';
    veil.style.height = stageH + 'px';
    veil.innerHTML = '<b>GioGio\'s Blog</b>';
    document.body.appendChild(veil);
    requestAnimationFrame(() => veil.classList.add('on', 'zoom'));

    // 此刻画面纯白,白幕接上
    setTimeout(() => {
      if (raf) { cancelAnimationFrame(raf); raf = 0; }
      window.removeEventListener('resize', fitWall);
      stage.remove();                 // 卸载

      setTimeout(() => {
        veil.classList.remove('no-fade');          // 0.9s 过渡
        requestAnimationFrame(() => veil.classList.remove('on'));  // 白屏暗下去
        setTimeout(() => veil.remove(), 1200);
      }, 500);
    }, HOLD_MS);
  }

  // 启动
  fitWall();
  cx = tx = stageW / 2;   // 还没动鼠标时,光就在中心
  cy = ty = stageH / 2;
  setLight(cx, cy);
  render();
  window.addEventListener('resize', fitWall);
})();
</script>
</body>
</html>
```

相关文章