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>
```