星轨涂鸦
Canvas 星轨粒子涂鸦:鼠标 / 触屏拖拽留下渐变星轨,颜色随时间循环流动。
拖拽涂鸦 · 颜色循环
按住并拖动鼠标(或手指)绘画;星轨粒子会自动减速消散,形成拖尾效果。
使用体验
- 在画布上按住拖动,会持续喷出渐变色粒子并留下光轨,松手后粒子减速消散。
- “模式”可在星轨 / 辉光两种效果间切换;辉光模式叠加多重圆形渐变,更柔和。
- 支持触屏(touch-action: none 防页面滚动),移动端同样可玩。
代码要点
js
const parts = [];
let drawing = false, hue = 0;
function paint(x, y) {
// 每次移动发射 3 个粒子,带随机速度 / 寿命
for (let i = 0; i < 3; i++) {
parts.push({
x, y,
vx: (Math.random() - 0.5) * 1.6,
vy: (Math.random() - 0.5) * 1.6,
life: 1,
hue: (hue + Math.random() * 40) % 360
});
}
}
function step() {
ctx.clearRect(0, 0, W, H); // 全清画法(粒子自绘拖尾)
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
p.x += p.vx; p.y += p.vy;
p.vx *= 0.98; p.vy *= 0.98; // 减速 = 拖尾
p.life -= 0.018;
ctx.fillStyle = `hsla(${p.hue}, 80%, 60%, ${Math.max(0, p.life)})`;
ctx.beginPath();
ctx.arc(p.x, p.y, 3 * p.life + 0.6, 0, Math.PI * 2);
ctx.fill();
if (p.life <= 0) parts.splice(i, 1); // 回收死亡粒子
}
requestAnimationFrame(step);
}完整可运行工程
可直接运行纯前端 · 零依赖支持鼠标与触屏
把演示区的「星轨涂鸦」抽成独立工程:粒子上限、阻尼推进、色相流动与拖尾残影,参数全部集中在 app.js 顶部。
依赖清单:零第三方依赖(Canvas 2D + requestAnimationFrame + Pointer/Touch 事件),无需 npm install。
star-trail/
├─ index.html 画布 + 控制
├─ app.js 粒子生成 / 推进 / 渲染
├─ styles.css 样式(跟随系统深浅色)
└─ README.md 运行方式与扩展点
index.html
画布 + 配色 / 清空控制
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>星轨涂鸦 · 独立工程版</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="wrap">
<h1>星轨涂鸦</h1>
<p class="sub">鼠标 / 触屏拖拽留下渐变星轨,颜色沿色相环流动,粒子逐渐消散。</p>
<section class="panel">
<div class="row">
<button id="clear" type="button" class="btn ghost">清空画布</button>
<select id="hue">
<option value="flow">配色:流动</option>
<option value="warm">配色:暖色</option>
<option value="cool">配色:冷色</option>
</select>
<span class="msg" id="stat" style="margin:0">粒子 0</span>
</div>
</section>
<h2>画布</h2>
<div class="panel"><canvas id="board" height="340"></canvas></div>
</main>
<script src="app.js"></script>
</body>
</html>
app.js
粒子生成 / 推进 / 渲染(纯函数可测)
javascript
/* app.js —— 星轨粒子涂鸦(粒子推进为纯函数,node 可测) */
var HUES = { flow: 0, warm: 30, cool: 190 };
/* makeParticle:生成一个粒子 */
function makeParticle(x, y, hueBase, rand) {
rand = rand || Math.random;
return {
x: x, y: y,
vx: (rand() - 0.5) * 1.1,
vy: (rand() - 0.5) * 1.1,
life: 100,
hue: (hueBase + rand() * 60) % 360,
size: 1.4 + rand() * 2.4
};
}
/* stepParticles:推进一帧并剔除消亡粒子 */
function stepParticles(list) {
for (var i = list.length - 1; i >= 0; i--) {
var p = list[i];
p.x += p.vx; p.y += p.vy;
p.vx *= 0.985; p.vy *= 0.985;
p.life -= 1.1;
if (p.life <= 0) list.splice(i, 1);
}
return list;
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { HUES: HUES, makeParticle: makeParticle, stepParticles: stepParticles };
}
if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", function () {
var canvas = document.getElementById("board"), ctx = canvas.getContext("2d");
var clearBtn = document.getElementById("clear"), hueSel = document.getElementById("hue"),
statEl = document.getElementById("stat");
var W = 0, H = 340, parts = [], drawing = false, drift = 0;
function resize() {
var dpr = Math.min(window.devicePixelRatio || 1, 2);
W = canvas.clientWidth;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = "#080b10";
ctx.fillRect(0, 0, W, H);
}
function pos(e) {
var r = canvas.getBoundingClientRect();
var t = e.touches ? e.touches[0] : e;
return { x: t.clientX - r.left, y: t.clientY - r.top };
}
function emit(e) {
if (!drawing) return;
var p = pos(e);
var base = HUES[hueSel.value] + drift;
for (var i = 0; i < 3; i++) parts.push(makeParticle(p.x, p.y, base, Math.random));
statEl.textContent = "粒子 " + parts.length;
}
function loop() {
stepParticles(parts);
drift = (drift + 0.6) % 360;
ctx.fillStyle = "rgba(8, 11, 16, 0.16)";
ctx.fillRect(0, 0, W, H);
parts.forEach(function (p) {
ctx.beginPath();
ctx.fillStyle = "hsla(" + p.hue + ", 85%, 68%, " + Math.max(0, p.life / 100) + ")";
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
});
statEl.textContent = "粒子 " + parts.length;
requestAnimationFrame(loop);
}
canvas.addEventListener("mousedown", function (e) { drawing = true; emit(e); });
canvas.addEventListener("mousemove", emit);
window.addEventListener("mouseup", function () { drawing = false; });
canvas.addEventListener("touchstart", function (e) { e.preventDefault(); drawing = true; emit(e); });
canvas.addEventListener("touchmove", function (e) { e.preventDefault(); emit(e); });
canvas.addEventListener("touchend", function () { drawing = false; });
clearBtn.addEventListener("click", function () {
parts = [];
ctx.fillStyle = "#080b10";
ctx.fillRect(0, 0, W, H);
statEl.textContent = "粒子 0";
});
window.addEventListener("resize", resize);
resize();
loop();
});
}
styles.css
样式:跟随系统深浅色
css
:root {
--bg: #0e1014; --panel: #171a20; --ink: #e8ecf1; --muted: #9aa4b2; --line: #262b33; --brand: #6fb3a8;
}
@media (prefers-color-scheme: light) {
:root { --bg: #f6f7f8; --panel: #ffffff; --ink: #1a1d21; --muted: #5b636e; --line: #e3e6ea; }
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px 16px 48px; background: var(--bg); color: var(--ink);
font: 15px/1.6 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
}
.wrap { max-width: 760px; margin: 0 auto; }
h1 { font-size: 1.5rem; margin: 0 0 6px; }
h2 { font-size: 1.05rem; margin: 22px 0 10px; }
.sub { color: var(--muted); margin: 0 0 18px; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.btn {
padding: 8px 16px; border-radius: 8px; border: 1px solid var(--brand);
background: var(--brand); color: #0b0f12; cursor: pointer; font-size: .92rem;
}
.btn.ghost { background: transparent; color: var(--brand); }
.msg { color: var(--muted); margin: 12px 0 0; font-size: .92rem; }
.bars { display: grid; gap: 6px; margin-top: 10px; }
.bar { display: grid; grid-template-columns: 64px 1fr 44px; gap: 8px; align-items: center; font-size: .88rem; }
.bar i { display: block; height: 8px; border-radius: 6px; background: var(--brand); }
.bar span:first-child { color: var(--muted); }
input, select {
padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line);
background: var(--bg); color: var(--ink); font: inherit;
}
textarea { width: 100%; min-height: 90px; padding: 10px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); font: inherit; }
canvas { width: 100%; display: block; border-radius: 10px; }
.chat { max-height: 260px; overflow: auto; display: grid; gap: 10px; }
.chat .me, .chat .pet { padding: 9px 12px; border-radius: 12px; max-width: 82%; font-size: .93rem; }
.chat .me { background: rgba(111,179,168,.16); justify-self: end; }
.chat .pet { background: rgba(255,255,255,.06); justify-self: start; }
README.md
运行方式与扩展点
markdown
# 星轨涂鸦(独立可运行工程)
作品站 fun 区「星轨涂鸦」的独立工程版:纯前端、零依赖。
## 文件
- `index.html`:画布 + 配色 / 清空控制
- `app.js`:粒子生成、阻尼推进、色相流动、拖尾残影
- `styles.css`:样式
## 运行
1. 双击 `index.html`,按住鼠标在画布上拖动
2. 逻辑自测:`node -e "const m=require('./app.js');const a=[m.makeParticle(0,0,0,()=>0.5)];m.stepParticles(a);console.log(a[0].life<100)"` → 期望 `true`
## 扩展点
- 换手感:调 `makeParticle` 的初速范围与 `stepParticles` 的阻尼系数
- 加背景音乐律动:用 AnalyserNode 取低频能量驱动粒子大小
运行方式
- 新建一个目录,把上面全部文件按文件名分别保存(用「一键复制整份工程」可一次拿全)。
- 前端部分:直接双击
index.html即可运行;若浏览器限制本地文件读取,执行python -m http.server 8000后访问http://localhost:8000/。 - 逻辑自测:各文件
README.md中给出了对应的 Node / Python 自测命令(均为真实可跑的最小用例)。
标注:可直接运行、无需构建;要改造只需动 app.js 顶部常量与 styles.css 里的变量。
来源参考
实现涉及的官方文档(可直接访问):