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:
1 parent
f8e8bb8acb
commit
e49990776b
10 files changed
+1003
-7
No files matched your search
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}, [])
|
||||
}
|
||||
@@ -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()
|
||||
}, [])
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -48,6 +48,10 @@
|
||||
--glass-shade: var(--app-shadow); /* 投影 */
|
||||
--glass-veil: var(--app-bg); /* 盖在背景图上的那层底色 */
|
||||
|
||||
/* 雪花的颜色:亮色背景是白的,纯白的雪看不见,所以用偏蓝的灰;暗色就是白色 */
|
||||
--weather-core: #f3f7ff; /* 不用纯白:带一点点冷色的白 */
|
||||
--weather-halo: #bfd0f0; /* 粒子边缘羽化出去的颜色 */
|
||||
|
||||
--chart-1: #2a5da8;
|
||||
--chart-2: #b9770e;
|
||||
--chart-3: #a23e2c;
|
||||
@@ -78,11 +82,15 @@
|
||||
--app-scrim: #000000;
|
||||
|
||||
/* 暗色的玻璃:偏蓝紫的冷色深蓝灰(和背景插画的色调一致),描边和上沿反光用白色,投影用带一点蓝的近黑 */
|
||||
--glass-tint: #1b1e2d;
|
||||
/* 暗色的玻璃和盖在背景图上的底色:从背景图自己的平均色算出来(见 display/src/backgroundTint.ts 写进 --bg-tint 的值,压暗、降低饱和度),不再是固定的蓝;没算出来之前用下面的回退色 */
|
||||
--glass-tint: var(--bg-tint, #1d1c24);
|
||||
--glass-light: #ffffff;
|
||||
--glass-line: #ffffff;
|
||||
--glass-shade: #04061a;
|
||||
--glass-veil: #12141d;
|
||||
--glass-veil: var(--bg-veil-tint, #131217);
|
||||
|
||||
--weather-core: #e6edfb;
|
||||
--weather-halo: #b4c4ea;
|
||||
|
||||
--app-bg: #181714;
|
||||
--app-card: #211f1b;
|
||||
|
||||
Reference in new issue
Block a user