feat(display): falling snow in three depth layers with wind, mouse and bullet time; appearance panel; dark glass tinted from the background [CI SKIP]

This commit is contained in:
justme committed 2026-10-09 03:54:49 +08:00
1 parent f8e8bb8acb
commit e49990776b
10 files changed
+1003 -7

No files matched your search

+9
View File
@@ -6,7 +6,12 @@ import { SourceButton, ThemeToggle } from '@shared/ui'
import { Compare } from './pages/Compare'
import { Line } from './pages/Line'
import { Overview } from './pages/Overview'
import { useBackgroundTint } from './backgroundTint'
import { useBackgroundParallax } from './parallax'
import { Weather } from './weather/Weather'
import { AppearancePanel } from './AppearancePanel'
import { useAppearanceVars } from './appearance'
import { snow } from './weather/presets'
function LineRoute() {
const { id } = useParams()
@@ -16,12 +21,15 @@ function LineRoute() {
export function App() {
const background = useRef<HTMLDivElement>(null)
useBackgroundParallax(background)
useBackgroundTint()
useAppearanceVars()
const location = useLocation()
const ipv6 = location.pathname.endsWith('~ipv6') || new URLSearchParams(location.search).get('ip_version') === 'ipv6'
const familyQuery = ipv6 ? '?ip_version=ipv6' : ''
return (
<>
<div ref={background} className="bg-layer" aria-hidden="true" />
<Weather preset={snow} />
<header className="topbar">
<div className="inner">
<NavLink to={`/${familyQuery}`} className="brand">
@@ -46,6 +54,7 @@ export function App() {
<Route path="/compare" element={<Compare />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<AppearancePanel />
<footer className="site-foot">
国家和运营商由 <a href="https://ip.007995.xyz/" target="_blank" rel="noreferrer">ip.007995.xyz</a> 查询
<BuildFooter />
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useRef, useState } from 'react'
import { APPEARANCE_DEFAULTS, APPEARANCE_RANGES, resetAppearance, setAppearance, useAppearance, type AppearanceSettings } from './appearance'
type Tab = 'weather' | 'background'
type SliderName = keyof typeof APPEARANCE_RANGES
interface Slider {
name: SliderName
label: string
/** 数值怎么显示:默认是百分比。 */
format?: (value: number) => string
}
const percent = (value: number) => `${Math.round(value * 100)}%`
const WEATHER_SLIDERS: Slider[] = [
{ name: 'density', label: '数量' },
{ name: 'speed', label: '速度' },
{ name: 'wind', label: '风力' },
{ name: 'size', label: '大小' },
{ name: 'blur', label: '虚化' },
{ name: 'opacity', label: '透明度' },
]
const WEATHER_SWITCHES: { name: 'mouse' | 'bullet'; label: string; hint: string }[] = [
{ name: 'mouse', label: '鼠标拨动', hint: '鼠标经过时把雪推开、带起风' },
{ name: 'bullet', label: '子弹时间', hint: '鼠标停住时,周围的雪慢下来' },
]
const BACKGROUND_SLIDERS: Slider[] = [
{ name: 'bgBlur', label: '模糊', format: (value) => `${value}px` },
{ name: 'parallax', label: '晃动' },
]
const WEATHER_KEYS: (keyof AppearanceSettings)[] = ['enabled', 'density', 'speed', 'wind', 'size', 'blur', 'opacity', 'bullet', 'mouse']
const BACKGROUND_KEYS: (keyof AppearanceSettings)[] = ['bgBlur', 'parallax']
function SliderRow({ slider, settings, disabled }: { slider: Slider; settings: AppearanceSettings; disabled?: boolean }) {
const { name, label, format = percent } = slider
return (
<label className="appearance-row">
<span>{label}</span>
<input type="range" {...APPEARANCE_RANGES[name]} value={settings[name]} disabled={disabled} onChange={(event) => setAppearance({ [name]: Number(event.target.value) })} />
<output>{format(settings[name])}</output>
</label>
)
}
function SwitchRow({ label, hint, checked, disabled, onChange, master }: { label: string; hint?: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void; master?: boolean }) {
return (
<label className={`appearance-row appearance-row-switch${master ? ' appearance-master' : ''}`} title={hint}>
<span>{label}</span>
<span className="appearance-switch">
<input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange(event.target.checked)} />
<span />
</span>
</label>
)
}
/**
* 右下角的「外观」:一个小按钮,点开是分类的设置面板,改动立刻生效并记在浏览器里。
* 分类:「下雪」(雪的数量、速度、风力等)和「背景」(背景的额外模糊、跟着鼠标晃动的幅度)。
*/
export function AppearancePanel() {
const settings = useAppearance()
const [open, setOpen] = useState(false)
const [tab, setTab] = useState<Tab>('weather')
const root = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const outside = (event: PointerEvent) => {
if (root.current && !root.current.contains(event.target as Node)) setOpen(false)
}
const escape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('pointerdown', outside)
document.addEventListener('keydown', escape)
return () => {
document.removeEventListener('pointerdown', outside)
document.removeEventListener('keydown', escape)
}
}, [open])
const keys = tab === 'weather' ? WEATHER_KEYS : BACKGROUND_KEYS
const changed = keys.some((name) => settings[name] !== APPEARANCE_DEFAULTS[name])
return (
<div ref={root} className="appearance">
<div className="appearance-panel" data-open={open} role="dialog" aria-label="外观设置" aria-hidden={!open}>
<div className="appearance-tabs" data-tab={tab} role="tablist">
<button type="button" role="tab" aria-selected={tab === 'weather'} tabIndex={open ? 0 : -1} onClick={() => setTab('weather')}>
下雪
</button>
<button type="button" role="tab" aria-selected={tab === 'background'} tabIndex={open ? 0 : -1} onClick={() => setTab('background')}>
背景
</button>
</div>
{tab === 'weather' ? (
<div className="appearance-body" key="weather" data-off={!settings.enabled}>
<SwitchRow master label="下雪" checked={settings.enabled} onChange={(enabled) => setAppearance({ enabled })} />
{WEATHER_SLIDERS.map((slider) => (
<SliderRow key={slider.name} slider={slider} settings={settings} disabled={!settings.enabled} />
))}
{WEATHER_SWITCHES.map(({ name, label, hint }) => (
<SwitchRow key={name} label={label} hint={hint} checked={settings[name]} disabled={!settings.enabled} onChange={(checked) => setAppearance({ [name]: checked })} />
))}
</div>
) : (
<div className="appearance-body" key="background">
{BACKGROUND_SLIDERS.map((slider) => (
<SliderRow key={slider.name} slider={slider} settings={settings} />
))}
</div>
)}
<button type="button" className="appearance-reset" disabled={!changed} tabIndex={open ? 0 : -1} onClick={() => resetAppearance(keys)}>
恢复默认
</button>
</div>
<button type="button" className="appearance-fab" aria-label="外观设置" aria-expanded={open} title="外观设置" onClick={() => setOpen(!open)}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true">
<path d="M12 2v20M3.3 7l17.4 10M3.3 17L20.7 7M9 3.8l3 2.4 3-2.4M9 20.2l3-2.4 3 2.4" />
</svg>
</button>
</div>
)
}
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useSyncExternalStore } from 'react'
/**
* 展示页右下角「外观」里能调的参数:都是相对默认值的倍数(1 = 原样),存在浏览器里。
* 分两类:天气(雪,见 weather/)和背景(模糊、跟着鼠标晃动)。
*/
export interface AppearanceSettings {
/** 下雪的总开关。 */
enabled: boolean
/** 数量。 */
density: number
/** 下落速度。 */
speed: number
/** 风力(风的大小和鼠标带起的风)。 */
wind: number
/** 粒子大小。 */
size: number
/** 虚化程度(1 是预设原样,小于 1 更清晰,大于 1 更糊)。 */
blur: number
/** 透明度。 */
opacity: number
/** 子弹时间(鼠标停住时周围变慢)。 */
bullet: boolean
/** 鼠标拨动雪。 */
mouse: boolean
/** 背景在预先模糊的基础上再模糊多少(像素,0 是不再模糊)。 */
bgBlur: number
/** 背景跟着鼠标晃动的幅度(0 是不动,1 是默认)。 */
parallax: number
}
export const APPEARANCE_DEFAULTS: AppearanceSettings = {
enabled: true,
density: 1,
speed: 1,
wind: 1,
size: 1,
blur: 1,
opacity: 1,
bullet: true,
mouse: true,
bgBlur: 0,
parallax: 1,
}
/** 每个滑块的范围(和 AppearanceSettings 里同名的数值项对应)。 */
export const APPEARANCE_RANGES = {
density: { min: 0.2, max: 3, step: 0.05 },
speed: { min: 0.3, max: 3, step: 0.05 },
wind: { min: 0, max: 3, step: 0.05 },
size: { min: 0.5, max: 2.5, step: 0.05 },
blur: { min: 0, max: 2, step: 0.05 },
opacity: { min: 0.2, max: 2.5, step: 0.05 },
bgBlur: { min: 0, max: 30, step: 1 },
parallax: { min: 0, max: 1.5, step: 0.05 },
} as const
const KEY = 'moshiro.appearance'
const listeners = new Set<() => void>()
function load(): AppearanceSettings {
try {
const saved = JSON.parse(localStorage.getItem(KEY) ?? '{}') as Partial<AppearanceSettings>
const merged: Record<string, unknown> = { ...APPEARANCE_DEFAULTS }
for (const name of Object.keys(APPEARANCE_DEFAULTS) as (keyof AppearanceSettings)[]) {
const value = saved[name]
if (typeof value === typeof APPEARANCE_DEFAULTS[name]) merged[name] = value
}
return merged as unknown as AppearanceSettings
} catch {
return { ...APPEARANCE_DEFAULTS }
}
}
let current = load()
export const getAppearance = () => current
export function setAppearance(patch: Partial<AppearanceSettings>) {
current = { ...current, ...patch }
try {
localStorage.setItem(KEY, JSON.stringify(current))
} catch {
// 存不了(隐私模式等)就只在这次打开期间有效
}
listeners.forEach((listener) => listener())
}
export const resetAppearance = (keys: (keyof AppearanceSettings)[]) =>
setAppearance(Object.fromEntries(keys.map((name) => [name, APPEARANCE_DEFAULTS[name]])) as Partial<AppearanceSettings>)
export function subscribeAppearance(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export const useAppearance = () => useSyncExternalStore(subscribeAppearance, getAppearance)
/** 把背景的额外模糊写进 <html> 的 --bg-filter(display.css 里 .bg-layer 用它;不模糊的时候是 none,不给浏览器多一层滤镜)。 */
export function useAppearanceVars() {
useEffect(() => {
const apply = () => {
const blur = getAppearance().bgBlur
document.documentElement.style.setProperty('--bg-filter', blur > 0 ? `blur(${blur}px)` : 'none')
}
apply()
return subscribeAppearance(apply)
}, [])
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect } from 'react'
import bgLight from './assets/bg.jpg'
import bgDark from './assets/bg-dark.jpg'
/** 取样的缩略图大小:背景本来就是预先模糊过的,缩得很小就够算平均色。 */
const SAMPLE_WIDTH = 32
const SAMPLE_HEIGHT = 18
/** 一张图的平均颜色(0–255 的 r、g、b),按每个像素的亮度加权过轻:太亮的高光(衣服、头发的亮部)不要把整体拉亮。 */
function averageColor(image: HTMLImageElement): [number, number, number] | null {
const canvas = document.createElement('canvas')
canvas.width = SAMPLE_WIDTH
canvas.height = SAMPLE_HEIGHT
const context = canvas.getContext('2d', { willReadFrequently: true })
if (!context) return null
context.drawImage(image, 0, 0, SAMPLE_WIDTH, SAMPLE_HEIGHT)
const { data } = context.getImageData(0, 0, SAMPLE_WIDTH, SAMPLE_HEIGHT)
let r = 0
let g = 0
let b = 0
let weight = 0
for (let i = 0; i < data.length; i += 4) {
const luma = (data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114) / 255
const w = 1 - 0.6 * luma
r += data[i] * w
g += data[i + 1] * w
b += data[i + 2] * w
weight += w
}
return weight > 0 ? [r / weight, g / weight, b / weight] : null
}
/** rgb(0–255)转 hsl(h 0–360,s、l 0–1)。 */
function toHsl(r: number, g: number, b: number): [number, number, number] {
const red = r / 255
const green = g / 255
const blue = b / 255
const max = Math.max(red, green, blue)
const min = Math.min(red, green, blue)
const l = (max + min) / 2
if (max === min) return [0, 0, l]
const d = max - min
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
const h = max === red ? (green - blue) / d + (green < blue ? 6 : 0) : max === green ? (blue - red) / d + 2 : (red - green) / d + 4
return [h * 60, s, l]
}
/**
* 暗色下玻璃的颜色,和盖在背景图上的底色,都从当前背景图自己的平均色算出来:
* 色相照搬图里的,饱和度打个折(免得发艳、发蓝),亮度压到很暗。写进 <html> 的 --bg-tint(玻璃)和 --bg-veil-tint(底色),
* tokens.css 里暗色的 --glass-tint / --glass-veil 用它们;切换主题时重新取。亮色不用这个。
*/
export function useBackgroundTint() {
useEffect(() => {
const root = document.documentElement
const images = new Map<string, HTMLImageElement>()
const apply = (url: string) => {
const image = images.get(url)
const average = image && averageColor(image)
if (!average) return
const [h, s] = toHsl(...average)
root.style.setProperty('--bg-tint', `hsl(${h.toFixed(0)} ${(s * 55).toFixed(0)}% 13%)`)
root.style.setProperty('--bg-veil-tint', `hsl(${h.toFixed(0)} ${(s * 45).toFixed(0)}% 8%)`)
}
const update = () => {
const url = root.dataset.theme === 'dark' ? bgDark : bgLight
if (images.has(url)) {
apply(url)
return
}
const image = new Image()
images.set(url, image)
image.onload = () => apply(url)
image.src = url
}
update()
const observer = new MutationObserver(update)
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] })
return () => observer.disconnect()
}, [])
}
+275
View File
@@ -1012,6 +1012,8 @@
background-size: cover;
background-position: center;
pointer-events: none;
/* 在预先模糊的基础上再模糊多少,右下角「外观」里调;默认 none */
filter: var(--bg-filter, none);
}
:root {
@@ -1382,3 +1384,276 @@ input {
height: 20px;
flex: none;
}
/* 天气(雪、雨):铺满窗口,和背景图同一层(在背景图后面画、所有内容的下面),不接收鼠标;所以卡片的毛玻璃会把它一起模糊,不会挡住任何组件 */
.weather-layer {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
z-index: -1;
pointer-events: none;
/* 滤色:粒子只会把背后的图提亮,边缘羽化的地方自然融进图里 */
mix-blend-mode: screen;
}
@media (max-width: 640px), ((pointer: coarse) and (max-width: 900px)) {
.weather-layer {
display: none;
}
}
/* ── 右下角的「外观」:玻璃小按钮,点开是带分类的设置面板(亚克力同一套变量);手机上不画雪也不能晃背景,这个也不显示 ── */
.appearance {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 35;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
}
.appearance-fab {
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding: 0;
border: 0;
border-radius: 50%;
color: var(--app-ink-muted);
background: var(--glass-bg);
backdrop-filter: var(--glass-filter);
box-shadow:
inset 0 0 0 1px var(--glass-ring),
inset 0 1px 0 var(--glass-top),
var(--glass-drop);
cursor: pointer;
transition: color 0.15s;
}
.appearance-fab svg {
transition: transform 0.35s cubic-bezier(0.34, 1.4, 0.64, 1);
}
.appearance-fab:hover,
.appearance-fab[aria-expanded='true'] {
color: var(--app-ink);
}
.appearance-fab[aria-expanded='true'] svg {
transform: rotate(60deg);
}
/* 面板:从按钮的位置(右下角)展开 / 收起;一直在 DOM 里,用 data-open 切换,这样收起的时候也有动画 */
.appearance-panel {
width: 300px;
padding: 14px 16px 16px;
border-radius: var(--r-card);
background: var(--glass-bg);
backdrop-filter: var(--glass-filter);
box-shadow:
inset 0 0 0 1px var(--glass-ring),
inset 0 1px 0 var(--glass-top),
var(--glass-drop);
color: var(--app-ink);
font-size: 13px;
transform-origin: bottom right;
transition:
opacity 0.22s ease,
transform 0.28s cubic-bezier(0.2, 0.9, 0.3, 1.15),
visibility 0s linear 0.28s;
}
.appearance-panel[data-open='false'] {
opacity: 0;
visibility: hidden;
transform: translateY(10px) scale(0.92);
pointer-events: none;
}
.appearance-panel[data-open='true'] {
opacity: 1;
visibility: visible;
transform: none;
transition-delay: 0s;
}
.appearance-tabs {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
margin-bottom: 12px;
padding: 3px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--app-ink) 8%, transparent);
}
/* 选中的那一格的底:随选中的分类滑过去 */
.appearance-tabs::before {
content: '';
position: absolute;
top: 3px;
bottom: 3px;
left: 3px;
width: calc(50% - 3px);
border-radius: var(--r-pill);
background: var(--app-card);
box-shadow: 0 1px 3px color-mix(in srgb, var(--glass-shade) 25%, transparent);
transform: translateX(var(--tab-offset, 0));
transition: transform 0.28s cubic-bezier(0.3, 0.9, 0.3, 1.1);
}
.appearance-tabs[data-tab='background']::before {
--tab-offset: 100%;
}
.appearance-tabs button {
position: relative;
padding: 5px 0;
border: 0;
border-radius: var(--r-pill);
color: var(--app-ink-muted);
background: none;
cursor: pointer;
transition: color 0.2s;
}
.appearance-tabs button[aria-selected='true'] {
color: var(--app-ink);
font-weight: 600;
}
/* 切换分类时内容淡入、轻轻上浮;每一行再错开一点 */
.appearance-body {
display: grid;
gap: 9px;
}
.appearance-body > * {
animation: appearance-in 0.32s ease both;
}
.appearance-body > :nth-child(2) { animation-delay: 0.03s; }
.appearance-body > :nth-child(3) { animation-delay: 0.06s; }
.appearance-body > :nth-child(4) { animation-delay: 0.09s; }
.appearance-body > :nth-child(5) { animation-delay: 0.12s; }
.appearance-body > :nth-child(6) { animation-delay: 0.15s; }
.appearance-body > :nth-child(7) { animation-delay: 0.18s; }
.appearance-body > :nth-child(8) { animation-delay: 0.21s; }
.appearance-body > :nth-child(9) { animation-delay: 0.24s; }
@keyframes appearance-in {
from {
opacity: 0;
transform: translateY(6px);
}
}
.appearance-body[data-off='true'] .appearance-row:not(.appearance-master) {
opacity: 0.45;
pointer-events: none;
}
.appearance-row {
display: grid;
grid-template-columns: 52px 1fr 44px;
align-items: center;
gap: 8px;
transition: opacity 0.2s;
}
.appearance-row-switch {
grid-template-columns: 1fr auto;
}
.appearance-row output {
text-align: right;
color: var(--app-ink-muted);
font-variant-numeric: tabular-nums;
}
.appearance-row input[type='range'] {
width: 100%;
accent-color: var(--app-primary);
}
.appearance-switch {
position: relative;
display: inline-block;
width: 34px;
height: 20px;
}
.appearance-switch input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.appearance-switch span {
position: absolute;
inset: 0;
border-radius: 10px;
background: color-mix(in srgb, var(--app-ink) 22%, transparent);
pointer-events: none;
transition: background 0.2s;
}
.appearance-switch span::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--app-card);
transition: transform 0.25s cubic-bezier(0.34, 1.4, 0.64, 1);
}
.appearance-switch input:checked + span {
background: var(--app-primary);
}
.appearance-switch input:checked + span::after {
transform: translateX(14px);
}
.appearance-reset {
margin-top: 12px;
width: 100%;
padding: 6px 0;
border: 0;
border-radius: var(--r-pill);
color: var(--app-ink-muted);
background: color-mix(in srgb, var(--app-ink) 8%, transparent);
cursor: pointer;
transition: opacity 0.2s;
}
.appearance-reset:disabled {
opacity: 0.4;
cursor: default;
}
@media (prefers-reduced-motion: reduce) {
.appearance-panel,
.appearance-tabs::before,
.appearance-body > *,
.appearance-fab svg,
.appearance-switch span::after {
animation: none;
transition: none;
}
}
@media (max-width: 640px), ((pointer: coarse) and (max-width: 900px)) {
.appearance {
display: none;
}
}
+9 -5
View File
@@ -1,9 +1,12 @@
import { useEffect, type RefObject } from 'react'
import { getAppearance } from './appearance'
/** 指针在窗口里的位置带来的偏移上限(像素):鼠标停在窗口边缘时背景偏这么多。 */
const POSITION_SHIFT = 30
/** 鼠标移动时额外的晃动上限(像素):移得越快晃得越多,停下来就慢慢回到位置对应的偏移。两项加起来不能超过 display.css 里背景层向外多出来的边距(130px)。 */
const SWAY_MAX = 90
/** 背景最多偏移多少像素(要比 display.css 里背景层向外多出来的边距 130px 小,否则会露边;设置里把幅度调大时靠它兜底)。 */
const MAX_OFFSET = 125
/** 每移动 1 像素,晃动增加多少像素。 */
const SWAY_GAIN = 0.8
/** 晃动每一帧衰减到多少:越接近 1,晃得越久。 */
@@ -40,7 +43,7 @@ export function useBackgroundParallax(layer: RefObject<HTMLElement | null>) {
const targetY = baseY + swayY
x += (targetX - x) * EASE
y += (targetY - y) * EASE
element.style.transform = `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0)`
element.style.transform = `translate3d(${Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, x)).toFixed(2)}px, ${Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, y)).toFixed(2)}px, 0)`
const moving = Math.abs(targetX - x) > 0.05 || Math.abs(targetY - y) > 0.05 || Math.abs(swayX) > 0.05 || Math.abs(swayY) > 0.05
frame = moving ? requestAnimationFrame(step) : 0
}
@@ -50,12 +53,13 @@ export function useBackgroundParallax(layer: RefObject<HTMLElement | null>) {
const clamp = (value: number) => Math.max(-SWAY_MAX, Math.min(SWAY_MAX, value))
const move = (event: PointerEvent) => {
// 位置:指针在窗口里的位置换成 -1 到 1(背景朝同一个方向偏移,想要反方向就把符号取反)
baseX = ((event.clientX / window.innerWidth) * 2 - 1) * POSITION_SHIFT
baseY = ((event.clientY / window.innerHeight) * 2 - 1) * POSITION_SHIFT
const amount = getAppearance().parallax
baseX = ((event.clientX / window.innerWidth) * 2 - 1) * POSITION_SHIFT * amount
baseY = ((event.clientY / window.innerHeight) * 2 - 1) * POSITION_SHIFT * amount
// 晃动:这一次移动了多远,就再推背景一把
if (lastX !== null && lastY !== null) {
swayX = clamp(swayX + (event.clientX - lastX) * SWAY_GAIN)
swayY = clamp(swayY + (event.clientY - lastY) * SWAY_GAIN)
swayX = clamp(swayX + (event.clientX - lastX) * SWAY_GAIN * amount)
swayY = clamp(swayY + (event.clientY - lastY) * SWAY_GAIN * amount)
}
lastX = event.clientX
lastY = event.clientY
+10
View File
@@ -0,0 +1,10 @@
import { useRef } from 'react'
import { useWeather } from './engine'
import type { Preset } from './presets'
/** 整页的天气画布(雪、雨……):preset 换一个就换一种天气,见 presets.ts。 */
export function Weather({ preset }: { preset: Preset }) {
const canvas = useRef<HTMLCanvasElement>(null)
useWeather(canvas, preset)
return <canvas ref={canvas} className="weather-layer" aria-hidden="true" />
}
+277
View File
@@ -0,0 +1,277 @@
import { useEffect, type RefObject } from 'react'
import type { Layer, Preset } from './presets'
import { getAppearance, subscribeAppearance } from '../appearance'
/** 手机和窄屏不画。 */
const PHONE = '(max-width: 640px), ((pointer: coarse) and (max-width: 900px))'
/** 单帧最长的时间步(秒):切回标签页等造成长时间没画时,别让粒子一下子跳很远。 */
const MAX_STEP = 0.05
/** 风:每隔一阵(1.5–6 秒)随机换一个目标风速(像素/秒,正是向右,负是向左),偶尔来一阵很大的阵风;实际风速慢慢追上目标,所以是「一阵一阵」的。 */
const GUST_CHANCE = 0.15
/** 风向偏向左边(自右向左吹)的概率:剩下的偶尔反向吹一下。 */
const LEFT_BIAS = 0.85
const WIND_INTERVAL = [1.5, 6] as const
/** 实际风速追上目标的时间常数(秒):越大风越「绵」。 */
const WIND_EASE_SECONDS = 1.1
/**
* 和鼠标的互动:
* - 鼠标横向快速移动,会把粒子向同一个方向吹(鼠标速度 × MOUSE_WIND,叠加在风上,之后慢慢散去);
* - 鼠标在窗口左边、右边,粒子会轻轻往那一侧飘一点(MOUSE_LEAN 像素/秒,在最边上时的值);
* - 鼠标附近 MOUSE_RADIUS 像素内的粒子会被推开。
*/
const MOUSE_WIND = 0.35
const MOUSE_WIND_MAX = 380
const MOUSE_WIND_FADE_SECONDS = 0.7
const MOUSE_LEAN = 45
const MOUSE_RADIUS = 130
const MOUSE_PUSH = 900
/**
* 子弹时间:鼠标在一个点上停住超过 BULLET_DWELL_SECONDS 秒,以它为中心 BULLET_RADIUS 像素内的雪就慢下来(中心最慢,只有 BULLET_SLOWEST 倍速,往外渐渐恢复),
* 看起来像时间在那一小块地方几乎停住;鼠标一动,慢动作就散开(BULLET_EASE_SECONDS 是进入和退出的快慢)。鼠标停住的时候不再拨动雪,只是慢下来。
*/
const BULLET_DWELL_SECONDS = 0.25
const BULLET_RADIUS = 260
const BULLET_SLOWEST = 0.08
const BULLET_EASE_SECONDS = 0.35
interface Particle {
layer: number
size: number
x: number
y: number
/** 下落速度的随机系数,以及忽快忽慢的相位和频率。 */
fall: number
pulse: number
pulseFreq: number
/** 水平速度(像素/秒),被风吹着慢慢追上风速 × 这个粒子自己吃风的系数。 */
vx: number
/** 被鼠标推开的额外速度(像素/秒),会衰减。 */
px: number
py: number
/** 这个粒子自己的时钟(秒):在子弹时间里走得慢,晃动和忽快忽慢也跟着慢。 */
clock: number
lift: number
amp: number
freq: number
phase: number
}
const random = (min: number, max: number) => min + Math.random() * (max - min)
const clamp = (value: number, limit: number) => Math.max(-limit, Math.min(limit, value))
/**
* 天气(雪、雨……)的引擎:一块铺满窗口的画布,放在背景图的上面、所有内容的下面(和背景同一层),不接收鼠标。
* - 粒子分远、中、近三层(见 preset.layers),每层大小、速度、透明度、虚化程度、吃风的多少都不同,近景最糊最大,远景最小最淡,有景深;
* - 每个粒子下落速度各不相同、还会忽快忽慢;风是随机的一阵一阵(方向、大小、间隔都随机,偶尔有阵风);鼠标会拨动粒子、带起风(见 MOUSE_*);
* - 颜色取自令牌 --weather-core / --weather-halo(亮色、暗色各一套),切换主题时自动换;
* - 帧率跟着显示器走(每个显示帧画一次,位置按真实经过的时间算,165 Hz 的屏幕上速度也一样);
* 开了「减少动态效果」,或者在手机和窄屏上,就不画;窗口大小变了就重新排布。
*/
export function useWeather(canvasRef: RefObject<HTMLCanvasElement | null>, preset: Preset) {
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const phone = window.matchMedia(PHONE)
const canvas = canvasRef.current
const context = canvas?.getContext('2d')
if (!canvas || !context) return
let width = 0
let height = 0
let ratio = 1
let particles: Particle[] = []
let settings = getAppearance()
let sprites: HTMLCanvasElement[] = preset.layers.map((layer) => preset.makeSprite('#ffffff', '#ffffff', layer))
let frame = 0
let last = 0
let time = 0
let wind = 0
let windTarget = 0
let windChangeAt = 0
// 鼠标:位置(CSS 像素,没动过为 null)、上一次的位置和时间(算速度用)、鼠标带起的风
let pointerX: number | null = null
let pointerY = 0
let lastPointer: { x: number; t: number } | null = null
let mouseWind = 0
// 子弹时间:鼠标最后一次动是什么时候(秒,引擎自己的时钟)、慢动作现在有多强(0–1)
let lastMoveAt = 0
let bullet = 0
const tuned = (layer: Layer): Layer => ({ ...layer, blur: Math.max(0, Math.min(1, layer.blur + (settings.blur - 1) * 0.6)) })
const readColors = () => {
const style = getComputedStyle(document.documentElement)
const core = style.getPropertyValue('--weather-core').trim() || '#ffffff'
const halo = style.getPropertyValue('--weather-halo').trim() || '#ffffff'
sprites = preset.layers.map((layer) => preset.makeSprite(core, halo, tuned(layer)))
}
const spawn = (layer: number, anywhere: boolean): Particle => {
const spec = preset.layers[layer]
return {
layer,
size: random(spec.size[0], spec.size[1]) * settings.size,
x: random(0, width),
y: anywhere ? random(0, height) : -spec.size[1] * 3,
fall: random(0.55, 1.7),
pulse: random(0, Math.PI * 2),
pulseFreq: random(0.25, 0.8),
vx: wind * spec.wind,
px: 0,
py: 0,
clock: 0,
lift: random(0.6, 1.4),
amp: random(5, 18) * (layer + 1) * 0.6,
freq: random(0.3, 0.9),
phase: random(0, Math.PI * 2),
}
}
// 新粒子从顶上落下:不一定从哪一块,右上最多(风大多向左吹,吹着吹着就到了画面中间),中上、左上少一些
const spawnX = () => {
const pick = Math.random()
if (pick < 0.5) return random(width * 0.65, width * 1.5)
if (pick < 0.8) return random(width * 0.3, width * 0.65)
return random(0, width * 0.3)
}
const respawn = (particle: Particle) => Object.assign(particle, spawn(particle.layer, false), { x: spawnX() })
const layout = () => {
ratio = Math.min(window.devicePixelRatio || 1, 2)
width = window.innerWidth
height = window.innerHeight
canvas.width = Math.round(width * ratio)
canvas.height = Math.round(height * ratio)
const total = Math.round(Math.max(preset.minParticles, Math.min(preset.maxParticles, Math.round((width * height) / preset.areaPerParticle))) * settings.density)
particles = []
preset.layers.forEach((spec, layer) => {
for (let i = 0; i < Math.round(total * spec.share); i++) particles.push(spawn(layer, true))
})
}
const blow = () => {
// 随机方向、随机大小;一成多的时候是阵风(风速大得多);有时候几乎无风
const strength = Math.random() < GUST_CHANCE ? random(0.45, 1) * preset.wind.gust : random(0, 1) * preset.wind.normal
windTarget = (Math.random() < LEFT_BIAS ? -1 : 1) * strength * (Math.random() < 0.12 ? 0.1 : 1)
windChangeAt = time + random(WIND_INTERVAL[0], WIND_INTERVAL[1])
}
const draw = (now: number) => {
frame = requestAnimationFrame(draw)
const frameDt = Math.min((now - (last || now)) / 1000, MAX_STEP)
last = now
const dt = frameDt
time += dt
if (time >= windChangeAt) blow()
wind += (windTarget - wind) * (1 - Math.exp(-dt / WIND_EASE_SECONDS))
mouseWind *= Math.exp(-dt / MOUSE_WIND_FADE_SECONDS)
// 鼠标在窗口左边/右边时,整体向那一侧轻轻偏一点
const lean = pointerX === null ? 0 : (pointerX / width - 0.5) * 2 * MOUSE_LEAN
const blowing = (wind + mouseWind + lean) * settings.wind
const dwelling = settings.bullet && pointerX !== null && time - lastMoveAt > BULLET_DWELL_SECONDS
bullet += ((dwelling ? 1 : 0) - bullet) * (1 - Math.exp(-dt / BULLET_EASE_SECONDS))
context.setTransform(ratio, 0, 0, ratio, 0, 0)
context.clearRect(0, 0, width, height)
for (const particle of particles) {
const spec = preset.layers[particle.layer]
// 子弹时间:离鼠标越近,这个粒子的时间走得越慢
let pace = 1
if (bullet > 0.001 && pointerX !== null) {
const near = 1 - Math.min(1, Math.hypot(particle.x - pointerX, particle.y - pointerY) / BULLET_RADIUS)
pace = 1 - bullet * (1 - BULLET_SLOWEST) * near * near * (3 - 2 * near)
}
const dt = frameDt * pace
particle.clock += dt
// 下落:层的速度 × 这个粒子自己的随机系数 × 忽快忽慢(±25%);阵风还会带一点上升或下压的扰动
const speed = spec.speed * settings.speed * particle.fall * (1 + 0.25 * Math.sin(particle.pulse + particle.clock * particle.pulseFreq))
// 水平:被风吹着,速度慢慢追上「风速 × 这一层吃风的程度 × 这个的系数」
const wanted = blowing * spec.wind * particle.lift
particle.vx += (wanted - particle.vx) * (1 - Math.exp(-dt / 0.6))
// 鼠标拨动:离鼠标越近推得越狠,推开的速度很快衰减
if (settings.mouse && pointerX !== null && !dwelling) {
const dx = particle.x - pointerX
const dy = particle.y - pointerY
const distance = Math.hypot(dx, dy)
if (distance < MOUSE_RADIUS && distance > 0.5) {
const power = (1 - distance / MOUSE_RADIUS) ** 2 * MOUSE_PUSH * dt
particle.px += (dx / distance) * power
particle.py += (dy / distance) * power
}
}
particle.px *= Math.exp(-dt / 0.45)
particle.py *= Math.exp(-dt / 0.45)
const vy = speed + (windTarget - wind) * 0.04 * particle.lift + particle.py
const vx = particle.vx + particle.px
particle.y += vy * dt
particle.x += vx * dt
if (particle.y > height + particle.size * 3) {
respawn(particle)
continue
}
// 出了左右边就绕到另一边(右边多留了半个窗口:风向左吹,粒子从右边外面进来)
const span = width * 1.5 + 80
if (particle.x < -40) particle.x += span
else if (particle.x > width * 1.5 + 40) particle.x -= span
const x = particle.x + Math.sin(particle.phase + particle.clock * particle.freq) * particle.amp
preset.draw(context, sprites[particle.layer], x, particle.y, particle.size, Math.min(1, spec.alpha * settings.opacity), vx, vy)
}
}
const start = () => {
if (frame || phone.matches || !settings.enabled) return
last = 0
layout()
frame = requestAnimationFrame(draw)
}
const stop = () => {
cancelAnimationFrame(frame)
frame = 0
context.clearRect(0, 0, canvas.width, canvas.height)
}
// 手机和窄屏:不画(转屏、拖窗口变宽变窄时跟着开关)
const toggle = () => (phone.matches ? stop() : start())
const move = (event: PointerEvent) => {
const stamp = performance.now()
if (lastPointer) {
const seconds = Math.max((stamp - lastPointer.t) / 1000, 0.008)
mouseWind = clamp(mouseWind + ((event.clientX - lastPointer.x) / seconds) * MOUSE_WIND * 0.25, MOUSE_WIND_MAX)
}
lastMoveAt = time
lastPointer = { x: event.clientX, t: stamp }
pointerX = event.clientX
pointerY = event.clientY
}
const leave = () => {
pointerX = null
lastPointer = null
}
const resize = () => {
if (!phone.matches) layout()
}
// 设置变了:开关、数量、大小要重新排布,虚化要重做贴图,其余(速度、风、透明度等)下一帧自然读到新值
const changed = () => {
const before = settings
settings = getAppearance()
if (!settings.enabled) return stop()
if (!before.enabled) return start()
if (before.blur !== settings.blur) readColors()
if (before.density !== settings.density || before.size !== settings.size) layout()
}
const unsubscribe = subscribeAppearance(changed)
readColors()
start()
phone.addEventListener('change', toggle)
const observer = new MutationObserver(readColors)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
window.addEventListener('resize', resize)
window.addEventListener('pointermove', move, { passive: true })
document.documentElement.addEventListener('mouseleave', leave)
return () => {
cancelAnimationFrame(frame)
unsubscribe()
observer.disconnect()
phone.removeEventListener('change', toggle)
window.removeEventListener('resize', resize)
window.removeEventListener('pointermove', move)
document.documentElement.removeEventListener('mouseleave', leave)
}
}, [canvasRef, preset])
}
+93
View File
@@ -0,0 +1,93 @@
/**
* 一层粒子(景深):远景小、慢、淡;中景居中;近景大、快,但是糊成一团柔光(像对焦在背景上时前面飘过的雪)。
* 大小、速度之外,blur 决定边缘有多虚:0 是一个实心的圆点,1 是整个圆都在渐变(没有实心的核)。
*/
export interface Layer {
/** 大小范围(半径,CSS 像素)。 */
size: readonly [number, number]
/** 下落速度(像素/秒),每个粒子再乘一个随机系数。 */
speed: number
alpha: number
/** 模糊程度:0 清晰,1 完全虚化。 */
blur: number
/** 这一层占总数的比例。 */
share: number
/** 这一层吃多少风(远处的轻、离得远,被风吹得少一点;近处的被吹得多)。 */
wind: number
}
/**
* 一种天气(雪、雨……):引擎只管粒子的位置、风、鼠标,长什么样由预设决定。
* 换成雨:新写一个 rain 预设(细长的线、速度快),在 App.tsx 里把 Weather 的 preset 换掉就行,引擎不用动。
*/
export interface Preset {
layers: readonly Layer[]
/** 每多少平方像素一个粒子,以及总数的上下限。 */
areaPerParticle: number
minParticles: number
maxParticles: number
/** 风速(像素/秒):普通风的上限和阵风的上限。 */
wind: { normal: number; gust: number }
/** 给某一层做一张贴图:--weather-core 是粒子本身,--weather-halo 是外面的光晕(亮色底上用来托出白色)。 */
makeSprite(core: string, halo: string, layer: Layer): HTMLCanvasElement
/** 画一个在空中的粒子(x、y 是中心;vx、vy 是此刻的速度,雨要用它拉出方向)。 */
draw(context: CanvasRenderingContext2D, sprite: HTMLCanvasElement, x: number, y: number, size: number, alpha: number, vx: number, vy: number): void
}
/** 把任意 CSS 颜色解析成 [r, g, b](借一个 1×1 的画布)。 */
function parse(color: string): [number, number, number] {
const probe = document.createElement('canvas')
probe.width = 1
probe.height = 1
const context = probe.getContext('2d')
if (!context) return [255, 255, 255]
context.fillStyle = color
context.fillRect(0, 0, 1, 1)
const [r, g, b] = context.getImageData(0, 0, 1, 1).data
return [r, g, b]
}
export const snow: Preset = {
layers: [
{ size: [1.2, 2.2], speed: 30, alpha: 0.42, blur: 0.9, share: 0.45, wind: 0.55 },
{ size: [2.2, 3.8], speed: 56, alpha: 0.55, blur: 0.65, share: 0.35, wind: 0.85 },
{ size: [7, 14], speed: 98, alpha: 0.34, blur: 0.97, share: 0.2, wind: 1.15 },
],
areaPerParticle: 8500,
minParticles: 60,
maxParticles: 220,
wind: { normal: 130, gust: 320 },
// 一个柔和的圆点:中间是 core 色(blur 越大这一块越小),往外渐变成 halo 色的光晕,边缘透明
makeSprite(core, halo, layer) {
const side = 64
const sprite = document.createElement('canvas')
sprite.width = side
sprite.height = side
const context = sprite.getContext('2d')
if (!context) return sprite
const solid = 0.42 * (1 - layer.blur)
const [cr, cg, cb] = parse(core)
const [hr, hg, hb] = parse(halo)
const gradient = context.createRadialGradient(side / 2, side / 2, 0, side / 2, side / 2, side / 2)
gradient.addColorStop(0, `rgba(${cr}, ${cg}, ${cb}, 1)`)
gradient.addColorStop(solid, `rgba(${cr}, ${cg}, ${cb}, 1)`)
// 羽化:从实心的核到边缘,透明度按余弦曲线(两头缓、中间陡)一点点退到 0,颜色同时从 core 过渡到 halo,所以没有一圈生硬的边
const steps = 8
for (let i = 1; i <= steps; i++) {
const u = i / steps
const alpha = 0.5 * (1 + Math.cos(Math.PI * u))
const r = Math.round(cr + (hr - cr) * u)
const g = Math.round(cg + (hg - cg) * u)
const b = Math.round(cb + (hb - cb) * u)
gradient.addColorStop(solid + (1 - solid) * u, `rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)})`)
}
context.fillStyle = gradient
context.fillRect(0, 0, side, side)
return sprite
},
draw(context, sprite, x, y, size, alpha) {
const radius = size * 2.2
context.globalAlpha = alpha
context.drawImage(sprite, x - radius, y - radius, radius * 2, radius * 2)
},
}