NINTENDO
SWITCH
SWITCH

在玩Splatoon 3
): Particles {
const count = positions.length / 2
const baseX = new Float32Array(count)
const baseY = new Float32Array(count)
const colors32 = new Uint32Array(count)
for (let i = 0; i < count; i++) {
// + 0.5:取像素的中心,而不是左上角,这样点是以格子中心为基准摆放的
baseX[i] = offsetX + (positions[i * 2]! + 0.5) * scale
baseY[i] = offsetY + (positions[i * 2 + 1]! + 0.5) * scale
// 小端内存里 Uint32 的字节顺序是 R、G、B、A,所以高位依次是 A、B、G、R。
// 这样直接把这个整数写进 ImageData,就是正确的颜色(不透明)。
colors32[i] =
(255 << 24) | (colors[i * 3 + 2]! << 16) | (colors[i * 3 + 1]! << 8) | colors[i * 3]!
}
return {
count,
baseX,
baseY,
dx: new Float32Array(count),
dy: new Float32Array(count),
colors32,
size: scale,
}
}
// 删掉已经"寿终正寝"的波纹。从后往前遍历,这样 splice 删除元素不会打乱还没检查的下标。
function pruneRipples(ripples: Ripple[], now: number) {
for (let i = ripples.length - 1; i >= 0; i--) {
if (now - ripples[i]!.start >= WAVE_LIFE) ripples.splice(i, 1)
}
}
/**
* 推进一帧,返回是否还需要继续动画。
* `dt` 为秒;`motion` 在 0–1 之间缩放全部力(减少动态效果时传 0.25)。
*/
function stepParticles(
p: Particles,
pointer: Pointer,
ripples: Ripple[],
now: number,
dt = 1 / 60,
motion = 1,
): boolean {
pruneRipples(ripples, now)
// 多个波纹叠加时整体增强,但不线性放大:2 个 ×1.5,3 个 ×2……
const boost = ripples.length > 0 ? 1 + 0.5 * (ripples.length - 1) : 0
// 让弹簧"与帧率无关":EASE 是 60fps 下每帧的比例,换算成当前 dt 对应的比例。
// 否则 120Hz 屏幕上同样的动画会快一倍。dt 最多按 0.05 秒算,切后台回来时不会一下子跳太远。
const ease = 1 - (1 - EASE) ** (Math.min(dt, 0.05) * 60)
const radius2 = HOVER_RADIUS * HOVER_RADIUS
let moving = false
for (let i = 0; i < p.count; i++) {
// (fx, fy) 是这一帧这个粒子"想去的位置"(相对基准位置的目标位移)。
// 指针和每个波纹各贡献一份力,最后相加。
let fx = 0
let fy = 0
// ① 指针排斥:离指针越近推得越狠。
if (pointer.active) {
// (vx, vy) 是从指针指向粒子的向量;除以长度 d 得到"单位方向",推力就沿这个方向。
// 用当前位置(含 dx)而不是基准位置,粒子被推开后受力会随之变化,手感更自然。
const vx = p.baseX[i]! + p.dx[i]! - pointer.x
const vy = p.baseY[i]! + p.dy[i]! - pointer.y
// 先比较距离的平方,只有在范围内才开方:开方比较慢,大多数粒子在范围外直接跳过。
const d2 = vx * vx + vy * vy
if (d2 > 0.1 && d2 < radius2) {
const d = Math.sqrt(d2)
const t = 1 - d / HOVER_RADIUS // 0(边缘)到 1(正中心)
// 三次方:中心处很强、往外迅速衰减,推开的感觉更集中,不是整片一起动
const force = t * t * t * HOVER_FORCE
fx += (vx / d) * force
fy += (vy / d) * force
}
}
// ② 波纹:一个以波源为圆心、不断变大的环,只推动正好在环上的粒子。
for (const ripple of ripples) {
const age = now - ripple.start
const front = (age / 1000) * WAVE_SPEED // 环现在的半径 = 速度 × 时间
const fade = 1 - age / WAVE_LIFE // 随时间线性变弱,到寿命时为 0
const vx = p.baseX[i]! - ripple.x
const vy = p.baseY[i]! - ripple.y
const d = Math.sqrt(vx * vx + vy * vy)
if (d < 0.1) continue
const off = Math.abs(d - front) // 粒子离环中心线多远
if (off < WAVE_BAND) {
// 在环中心线上最强,往两侧线性变弱;方向是从波源指向粒子,即向外推
const force = (1 - off / WAVE_BAND) * fade * WAVE_FORCE * boost
fx += (vx / d) * force
fy += (vy / d) * force
}
}
// ③ 弹簧:位移每帧向目标走一小段("差值 × ease")。
// 力变大 → 目标变远 → 粒子被推开;力消失 → 目标回到 0 → 粒子弹回原位。
// 这个"逐渐逼近"就是黏滞感的来源,也是动画有惯性、不生硬的原因。
p.dx[i]! += (fx * motion - p.dx[i]!) * ease
p.dy[i]! += (fy * motion - p.dy[i]!) * ease
// 逼近是无限接近、永远到不了 0 的,所以小到看不见时直接归零,才能判断"已静止"
if (Math.abs(p.dx[i]!) < REST) p.dx[i] = 0
if (Math.abs(p.dy[i]!) < REST) p.dy[i] = 0
if (p.dx[i] !== 0 || p.dy[i] !== 0) moving = true
}
// 还有粒子在动、还有波纹、或指针还在上面,就需要继续下一帧;否则停掉循环省电
return moving || ripples.length > 0 || pointer.active
}
// ── 2. 采样:把头像缩小并取出不透明像素 ──────────────────
const ALPHA_CUTOFF = 128 // 透明度(0–255)低于一半的像素当作背景,不生成粒子
/**
* 把 RGBA 数据里不透明的像素收集成粒子。
* canvas 的像素数据是一维数组,每个像素占 4 个数:R、G、B、A,所以第 (x, y) 个像素从 (y * width + x) * 4 开始。
*/
function extractPixels(data: ArrayLike<number>, width: number, height: number): Pixels {
if (data.length !== width * height * 4) throw new RangeError("RGBA 数据长度与宽高不符")
const positions: number[] = []
const colors: number[] = []
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4
if (data[i + 3]! < ALPHA_CUTOFF) continue
positions.push(x, y)
colors.push(data[i]!, data[i + 1]!, data[i + 2]!)
}
}
return { positions, colors }
}
/**
* 按列数把图片缩小并取出不透明像素。
* 浏览器没有直接"读图片像素"的接口,做法是:画到一个小 canvas 上,再用 getImageData 读回来。
* 顺便让 drawImage 完成"缩小",imageSmoothingQuality 保证缩小时平滑,不会出锯齿。
*/
function sampleImage(image: HTMLImageElement, columns: number) {
const canvas = document.createElement("canvas")
canvas.width = canvas.height = columns
// willReadFrequently:告诉浏览器我们会读像素,它会选更适合读取的内存方式
const context = canvas.getContext("2d", { willReadFrequently: true })
if (!context) throw new Error("浏览器无法创建 2D canvas")
context.imageSmoothingQuality = "high"
// 取中间最大的正方形(图不是正方形时裁掉两侧),再整体缩到 columns × columns
const side = Math.min(image.naturalWidth, image.naturalHeight)
context.drawImage(
image,
(image.naturalWidth - side) / 2,
(image.naturalHeight - side) / 2,
side,
side,
0,
0,
columns,
columns,
)
const pixels = extractPixels(context.getImageData(0, 0, columns, columns).data, columns, columns)
if (!pixels.positions.length) throw new Error("这张图片没有足够的不透明像素。")
return pixels
}
// ── 3. 绘制:复用 ImageData,直接写 Uint32 像素 ──────────
// 比对每个点调用一次 fillRect 快得多:先在内存里把整张图的像素写好,最后 putImageData 一次性贴上去。
/** 返回绘制函数;复用 ImageData,每帧只清零并写入方块。 */
function createPainter(context: CanvasRenderingContext2D) {
let buffer: ImageData | null = null
return (p: Particles, cssWidth: number, cssHeight: number, dpr: number) => {
// 物理像素 = CSS 像素 × 设备像素比(dpr)。高清屏 dpr 是 2 或 3,不乘的话画面会发糊
const w = Math.ceil(cssWidth * dpr)
const h = Math.ceil(cssHeight * dpr)正在找前端开发的工作
前端开发者
我用 Vue 和 TypeScript 写界面。刷 X 和 抖音 时看到让我停下来的动效,就拆开来亲手复刻一遍,再写下复现它的提示词。
4处
seg6.space · adixcode.com · Claude Storybook · X
AI 用量 · 提交记录 · 工具左右滑动 →
0
tokens · Claude Code 与 Codex
Claude Code 49%连续 20 天 · 最长 33Codex 51%
0
过去一年的贡献
在听 · 在玩在读 · 生活左右滑动 →

在玩Splatoon 3

在玩Super Mario Odyssey
动物农场在读动物农场 · 乔治·奥威尔










