html
复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>缅怀先祖 · 祠堂牌位</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:opsz,wght@17..88,200..900&display=swap" rel="stylesheet">
<style>
/* ========== CSS @property 自定义属性 (2024-2026) ========== */
@property --flame-flicker {
syntax: '<number>';
initial-value: 1;
inherits: false;
}
@property --smoke-drift {
syntax: '<length>';
initial-value: 0px;
inherits: false;
}
@property --glow-rotate {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
@property --mist-flow {
syntax: '<number>';
initial-value: 0.5;
inherits: false;
}
/* ========== CSS @layer 分层管理 ========== */
@layer reset, base, layout, plaques, candles, incense, anim, responsive;
/* ========== reset ========== */
@layer reset {
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html { color-scheme: dark; text-size-adjust: 100%; scroll-behavior: smooth; }
body {
min-height: 100vh; min-height: 100dvh;
font-family: 'Noto Serif SC', 'KaiTi', '楷体', 'SimSun', '宋体', serif;
background: #03030d;
color: #e8e0d0;
cursor: crosshair;
-webkit-tap-highlight-color: transparent;
overflow-x: hidden;
display: flex;
flex-direction: column;
align-items: center;
}
canvas { display: block; position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; }
}
/* ========== base ========== */
@layer base {
:root {
--gold: #c9a96e;
--gold-light: #e0c78a;
--gold-bright: #f0d890;
--wood-dark: #1a1208;
--wood-mid: #2a1c0e;
--wood-light: #3d2814;
--text-gold: #d4b870;
--shadow-glow: 0 0 30px rgba(200,160,60,0.45);
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
}
/* :has() 父选择器 ------ 任意牌位被悬停时,整体微亮 */
body:has(.plaque-card:hover) {
background: #050512;
}
}
/* ========== layout ========== */
@layer layout {
.shrine-wrapper {
container: shrine / inline-size;
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-xl) var(--space-md) var(--space-2xl);
width: 100%;
max-width: 1200px;
min-height: 100vh;
min-height: 100dvh;
justify-content: center;
gap: var(--space-xl);
}
.shrine-title {
text-align: center;
margin-bottom: var(--space-sm);
& h1 {
font-size: clamp(1.8rem, 4vw, 2.8rem);
font-weight: 300;
letter-spacing: 0.3em;
color: var(--gold-light);
text-shadow: 0 0 25px rgba(210,170,80,0.6), 0 0 60px rgba(180,140,50,0.3);
font-variation-settings: 'wght' 300;
animation: titleWeight 8s ease-in-out infinite alternate;
}
& p {
font-size: 0.9rem;
color: #a0896c;
letter-spacing: 0.25em;
margin-top: 0.3em;
}
}
@keyframes titleWeight {
0% { font-variation-settings: 'wght' 200; }
100% { font-variation-settings: 'wght' 500; }
}
/* 牌位网格 */
.plaques-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: flex-end;
gap: var(--space-lg) var(--space-md);
padding: var(--space-md) 0;
}
}
/* ========== plaques 牌位卡片 ========== */
@layer plaques {
.plaque-card {
container: plaque / inline-size;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
view-transition-name: var(--plaque-vt, plaque-default);
transition: filter 0.3s, transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
&:hover {
filter: brightness(1.15);
transform: translateY(-6px);
z-index: 5;
}
&:active {
transform: scale(0.96);
transition: transform 0.1s;
}
/* 牌位主体 */
& .plaque-body {
position: relative;
width: clamp(70px, 10cqw, 110px);
height: clamp(140px, 22cqw, 220px);
background: linear-gradient(
180deg,
var(--wood-mid) 0%,
var(--wood-dark) 15%,
#1f140a 30%,
var(--wood-dark) 50%,
#1a1008 70%,
var(--wood-mid) 85%,
var(--wood-light) 100%
);
border: 2.5px solid #8b6914;
border-radius: 18px 18px 5px 5px / 25px 25px 6px 6px;
box-shadow:
0 0 0 1px rgba(160,120,50,0.5),
0 0 0 4px rgba(25,15,8,0.8),
0 0 0 5px rgba(120,80,30,0.3),
0 0 18px rgba(200,150,60,0.3),
0 6px 24px rgba(0,0,0,0.55),
inset 0 1px 0 rgba(200,160,80,0.1),
inset 0 -1px 0 rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
/* 木纹纹理 */
&::before {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
90deg,
transparent,
transparent 2px,
rgba(0,0,0,0.06) 2px,
rgba(0,0,0,0.06) 3px,
transparent 3px,
transparent 5px
);
pointer-events: none;
z-index: 1;
opacity: 0.5;
}
/* 顶部金色云纹装饰 */
&::after {
content: '';
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
width: 40%;
height: 2px;
background: linear-gradient(90deg, transparent, var(--gold-light), transparent);
border-radius: 50%;
opacity: 0.7;
z-index: 2;
}
}
/* 牌位文字 */
& .plaque-text {
position: relative;
z-index: 3;
writing-mode: vertical-rl;
text-orientation: upright;
color: var(--text-gold);
font-size: clamp(0.7rem, 1.5cqw, 1rem);
letter-spacing: 0.15em;
line-height: 2;
text-shadow:
0 0 10px rgba(210,170,80,0.5),
0 0 25px rgba(180,140,50,0.25);
font-weight: 400;
max-height: 80%;
text-align: center;
padding: 0.5em 0;
}
/* 底座 */
& .plaque-base {
width: clamp(90px, 13cqw, 140px);
height: clamp(14px, 2.5cqw, 22px);
background: linear-gradient(180deg, var(--wood-light), var(--wood-dark));
border: 2px solid #6b4f1a;
border-radius: 3px 3px 8px 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.5), 0 0 8px rgba(180,140,60,0.2);
margin-top: -2px;
position: relative;
&::after {
content: '';
position: absolute;
top: 2px;
left: 10%;
right: 10%;
height: 1px;
background: rgba(180,140,70,0.3);
}
}
/* 小烛火 */
& .mini-flame {
position: absolute;
bottom: -32px;
left: 50%;
transform: translateX(-50%);
width: 14px;
height: 22px;
z-index: 4;
pointer-events: none;
& .mf-outer {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%) scaleY(var(--flame-flicker));
width: 12px;
height: 18px;
background: radial-gradient(ellipse at center 65%, rgba(255,140,25,0.75), transparent);
border-radius: 50% 50% 50% 50% / 55% 55% 25% 25%;
filter: blur(2px);
animation: flamePulse 0.18s ease-in-out infinite alternate;
}
& .mf-inner {
position: absolute;
bottom: 2px;
left: 50%;
transform: translateX(-50%) scaleY(calc(var(--flame-flicker) * 1.15));
width: 5px;
height: 10px;
background: radial-gradient(ellipse at center 55%, #fffde8, #ffbb33);
border-radius: 50% 50% 40% 40% / 50% 50% 25% 25%;
filter: blur(0.4px);
animation: flamePulse 0.14s ease-in-out infinite alternate;
animation-delay: 0.05s;
}
}
@keyframes flamePulse {
0% { --flame-flicker: 0.85; }
100% { --flame-flicker: 1.25; }
}
/* 主牌位更华丽 */
&.plaque-main {
& .plaque-body {
width: clamp(85px, 12cqw, 130px);
height: clamp(160px, 26cqw, 260px);
border-width: 3px;
border-color: #b8942e;
box-shadow:
0 0 0 1px rgba(200,160,50,0.6),
0 0 0 5px rgba(25,15,8,0.85),
0 0 0 6px rgba(160,100,30,0.4),
0 0 30px rgba(220,170,60,0.45),
0 0 60px rgba(200,140,40,0.2),
0 8px 30px rgba(0,0,0,0.6),
inset 0 1px 0 rgba(220,180,90,0.15);
}
& .plaque-text {
font-size: clamp(0.8rem, 1.8cqw, 1.15rem);
color: var(--gold-bright);
letter-spacing: 0.2em;
}
& .plaque-base {
border-color: #b8942e;
box-shadow: 0 4px 16px rgba(0,0,0,0.5), 0 0 12px rgba(200,150,50,0.3);
}
}
}
}
/* ========== candles 供桌蜡烛 ========== */
@layer candles {
.offering-candles {
display: flex;
gap: var(--space-xl);
align-items: flex-end;
margin-top: var(--space-sm);
}
.candle-pair {
display: flex;
flex-direction: column;
align-items: center;
animation: candleFloat 5s ease-in-out infinite;
&:nth-child(2) { animation-delay: -2.5s; }
& .candle-stick {
width: 16px;
height: 55px;
background: linear-gradient(180deg, #fdf8f0, #ecd9b0, #d4b888);
border-radius: 3px 3px 2px 2px;
box-shadow: 0 0 14px rgba(240,180,100,0.35), 0 3px 12px rgba(0,0,0,0.4);
position: relative;
}
& .candle-flame {
position: absolute;
top: -24px;
left: 50%;
transform: translateX(-50%);
width: 16px;
height: 26px;
& .cf-o {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%) scaleY(var(--flame-flicker));
width: 14px;
height: 22px;
background: radial-gradient(ellipse at center 65%, rgba(255,150,30,0.8), transparent);
border-radius: 50% 50% 50% 50% / 55% 55% 25% 25%;
filter: blur(3px);
animation: flamePulse 0.16s ease-in-out infinite alternate;
}
& .cf-i {
position: absolute;
bottom: 3px;
left: 50%;
transform: translateX(-50%) scaleY(calc(var(--flame-flicker) * 1.2));
width: 6px;
height: 11px;
background: radial-gradient(ellipse at center 55%, #ffffee, #ffaa20);
border-radius: 50% 50% 38% 38% / 48% 48% 22% 22%;
filter: blur(0.4px);
animation: flamePulse 0.13s ease-in-out infinite alternate;
animation-delay: 0.06s;
}
}
}
@keyframes candleFloat {
0%,100% { transform: translateY(0); }
50% { transform: translateY(-7px); }
}
}
/* ========== incense 香炉 ========== */
@layer incense {
.incense-burner {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
margin-top: var(--space-md);
& .burner-body {
width: 60px;
height: 40px;
background: radial-gradient(ellipse at 50% 40%, #6b5a3a, #3d2b14);
border-radius: 30px 30px 20px 20px;
border: 2px solid #8b6914;
box-shadow: 0 0 20px rgba(200,150,50,0.3), 0 4px 16px rgba(0,0,0,0.5);
position: relative;
&::before {
content: '';
position: absolute;
top: -3px;
left: 10%;
right: 10%;
height: 6px;
background: #5c401a;
border-radius: 3px;
}
}
/* 三足 */
& .burner-legs {
display: flex;
gap: 16px;
margin-top: -2px;
& span {
width: 6px;
height: 10px;
background: #3d2b14;
border-radius: 0 0 3px 3px;
border: 1px solid #5c401a;
}
}
/* 香烟 */
& .smoke-streams {
position: absolute;
top: -50px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
pointer-events: none;
& .smoke-wisp {
width: 3px;
height: 30px;
background: rgba(160,140,110,0.5);
border-radius: 50%;
filter: blur(5px);
animation: smokeRise 3s ease-out infinite;
&:nth-child(1) { animation-delay: 0s; }
&:nth-child(2) { animation-delay: 0.8s; }
&:nth-child(3) { animation-delay: 1.6s; }
&:nth-child(4) { animation-delay: 0.4s; }
&:nth-child(5) { animation-delay: 1.2s; }
}
}
}
@keyframes smokeRise {
0% {
opacity: 0.5;
transform: translateY(0) scaleX(1) scaleY(1);
}
40% {
opacity: 0.35;
transform: translateY(-25px) scaleX(1.8) scaleY(2.5);
}
100% {
opacity: 0;
transform: translateY(-60px) scaleX(3) scaleY(4);
}
}
}
/* ========== anim 滚动驱动 & 背景 ========== */
@layer anim {
.scroll-mist-layer {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: 32vh;
background: linear-gradient(to top, rgba(120,100,70,0.18), transparent);
pointer-events: none;
z-index: 1;
opacity: var(--mist-flow);
animation: mistScroll linear;
animation-timeline: scroll(root);
animation-range: 0% 100%;
}
@keyframes mistScroll {
0% { --mist-flow: 0.4; transform: translateY(0); }
100% { --mist-flow: 0.8; transform: translateY(-18px); }
}
/* 供桌装饰线 */
.altar-line {
width: 70%;
max-width: 900px;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(180,140,60,0.35), transparent);
margin: var(--space-sm) auto;
opacity: 0.6;
}
}
/* ========== responsive ========== */
@layer responsive {
@container shrine (max-width: 700px) {
.plaques-grid {
gap: var(--space-sm);
}
.plaque-card .plaque-body {
width: clamp(55px, 14cqw, 80px);
height: clamp(110px, 28cqw, 160px);
}
.plaque-card .plaque-text {
font-size: 0.65rem;
letter-spacing: 0.08em;
}
.plaque-card .plaque-base {
width: clamp(70px, 16cqw, 100px);
height: 10px;
}
.plaque-card.plaque-main .plaque-body {
width: clamp(65px, 16cqw, 100px);
height: clamp(130px, 32cqw, 190px);
}
.offering-candles { gap: var(--space-md); }
.candle-pair .candle-stick { height: 40px; width: 12px; }
}
@container shrine (max-width: 420px) {
.plaques-grid {
gap: 0.4rem;
justify-content: center;
}
.plaque-card .plaque-body {
width: 50px;
height: 100px;
border-radius: 12px 12px 3px 3px / 16px 16px 4px 4px;
border-width: 1.5px;
}
.plaque-card .plaque-text {
font-size: 0.55rem;
letter-spacing: 0.04em;
line-height: 1.6;
}
.plaque-card .plaque-base {
width: 60px;
height: 8px;
border-width: 1px;
}
.plaque-card.plaque-main .plaque-body {
width: 58px;
height: 120px;
}
.plaque-card .mini-flame {
bottom: -22px;
width: 10px;
height: 16px;
}
.incense-burner .burner-body {
width: 40px;
height: 28px;
}
.incense-burner .smoke-streams { top: -35px; gap: 4px; }
}
}
/* ========== Dialog 祭文弹窗 ========== */
#prayer-dialog {
background: rgba(18,13,7,0.96);
backdrop-filter: blur(20px);
border: 2px solid #c9a96e;
border-radius: 14px;
color: #f0e5d0;
padding: 2rem;
text-align: center;
max-width: 440px;
width: 88vw;
box-shadow: 0 0 60px rgba(200,160,60,0.5), 0 20px 50px rgba(0,0,0,0.7);
animation: dialogIn 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
}
#prayer-dialog::backdrop {
background: rgba(0,0,0,0.65);
backdrop-filter: blur(5px);
}
@keyframes dialogIn {
from { opacity: 0; transform: scale(0.85) translateY(30px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
#prayer-dialog h2 {
font-size: 1.4rem;
color: #e0c78a;
margin-bottom: 0.5rem;
font-weight: 400;
}
#prayer-dialog .verse-content {
font-style: italic;
line-height: 2;
color: #c0a878;
margin: 1rem 0;
font-size: 0.95rem;
}
#prayer-dialog .close-btn {
background: #5c3a11;
border: 1px solid #8b6914;
color: #f0e5d0;
padding: 0.5rem 1.8rem;
border-radius: 24px;
font-family: inherit;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.25s;
&:hover {
background: #7a4f1a;
box-shadow: 0 0 18px rgba(200,150,50,0.4);
}
}
/* ========== Popover 提示 ========== */
#hint-popover {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(10,8,5,0.8);
backdrop-filter: blur(10px);
border: 1px solid #c9a96e;
border-radius: 22px;
padding: 0.5rem 1.4rem;
font-size: 0.85rem;
color: #e0c78a;
letter-spacing: 0.06em;
opacity: 0;
transition: opacity 0.35s;
pointer-events: none;
&:popover-open {
opacity: 1;
}
}
</style>
</head>
<body>
<!-- 背景粒子画布 (Web Worker + OffscreenCanvas) -->
<canvas id="particleCanvas"></canvas>
<!-- 滚动驱动的烟雾层 -->
<div class="scroll-mist-layer"></div>
<!-- 主祠堂容器 -->
<main class="shrine-wrapper" id="shrineWrapper">
<!-- 标题 -->
<div class="shrine-title">
<h1>缅 怀 先 祖</h1>
<p>慎终追远 · 民德归厚 · 祖德流芳</p>
</div>
<!-- 牌位网格 -->
<div class="plaques-grid" id="plaquesGrid">
<!-- 牌位数据由JS动态生成,此处为占位 -->
</div>
<!-- 供桌装饰线 -->
<div class="altar-line"></div>
<!-- 供桌蜡烛一对 -->
<div class="offering-candles">
<div class="candle-pair">
<div class="candle-stick">
<div class="candle-flame"><div class="cf-o"></div><div class="cf-i"></div></div>
</div>
</div>
<div class="candle-pair">
<div class="candle-stick">
<div class="candle-flame"><div class="cf-o"></div><div class="cf-i"></div></div>
</div>
</div>
</div>
<!-- 香炉 -->
<div class="incense-burner">
<div class="smoke-streams">
<span class="smoke-wisp"></span>
<span class="smoke-wisp"></span>
<span class="smoke-wisp"></span>
<span class="smoke-wisp"></span>
<span class="smoke-wisp"></span>
</div>
<div class="burner-body"></div>
<div class="burner-legs">
<span></span><span></span><span></span>
</div>
</div>
<div class="altar-line"></div>
</main>
<!-- 祭文对话框 -->
<dialog id="prayer-dialog">
<h2 id="dialogTitle">先祖在上</h2>
<div class="verse-content" id="dialogVerse"></div>
<button class="close-btn" id="dialogClose">🕯️ 诚心叩拜</button>
</dialog>
<!-- Popover 提示 -->
<div id="hint-popover" popover>🕯️ 点击牌位诵祭文 · 处处寄哀思</div>
<script>
(() => {
// ==================== 牌位数据 ====================
const plaqueData = [
{
id: 'main',
name: '张氏历代先祖之位',
title: '张氏始祖',
verse: '开基立业,德泽绵长。\n积善之家,必有余庆。\n愿先祖在天之灵,\n护佑张氏子孙,世代昌隆。',
isMain: true
},
{
id: 'anc1',
name: '曾祖考张公\n讳德厚之位',
title: '曾祖考张公',
verse: '仁德传家,福荫后世。\n敦厚持重,乡里称贤。\n一瓣心香,遥寄追思。',
isMain: false
},
{
id: 'anc2',
name: '曾祖妣李太君之位',
title: '曾祖妣李太君',
verse: '慈惠贤良,母仪足式。\n相夫教子,淑德流芳。\n追思慈容,永怀恩泽。',
isMain: false
},
{
id: 'anc3',
name: '祖考张公\n讳仁安之位',
title: '祖考张公',
verse: '勤俭持家,恩泽子孙。\n敦亲睦族,德被乡邻。\n祖德巍巍,山高水长。',
isMain: false
},
{
id: 'anc4',
name: '祖妣王太君之位',
title: '祖妣王太君',
verse: '温恭淑慎,垂范后昆。\n勤俭慈爱,家风永传。\n音容虽远,慈辉永存。',
isMain: false
},
{
id: 'anc5',
name: '先考张公\n讳文远之位',
title: '先考张公',
verse: '严慈并济,教诲难忘。\n养育之恩,山高海深。\n子欲养而亲不待,\n此恨绵绵无绝期。',
isMain: false
},
{
id: 'anc6',
name: '先妣陈太君之位',
title: '先妣陈太君',
verse: '慈爱无疆,思念永存。\n手中线,身上衣,\n临行密密缝,意恐迟迟归。\n母爱如天,永世不忘。',
isMain: false
}
];
// ==================== 动态生成牌位 ====================
const plaquesGrid = document.getElementById('plaquesGrid');
plaqueData.forEach((data, index) => {
const card = document.createElement('div');
card.className = `plaque-card${data.isMain ? ' plaque-main' : ''}`;
card.style.setProperty('--plaque-vt', `plaque-${data.id}`);
card.setAttribute('data-index', index);
card.setAttribute('data-title', data.title);
card.setAttribute('data-verse', data.verse);
card.innerHTML = `
<div class="plaque-body">
<div class="plaque-text">${data.name.replace(/\n/g, '<br>')}</div>
</div>
<div class="plaque-base"></div>
<div class="mini-flame">
<div class="mf-outer"></div>
<div class="mf-inner"></div>
</div>
`;
card.addEventListener('click', () => openPlaqueDialog(index));
plaquesGrid.appendChild(card);
});
// ==================== Dialog 逻辑 ====================
const dialog = document.getElementById('prayer-dialog');
const dialogTitle = document.getElementById('dialogTitle');
const dialogVerse = document.getElementById('dialogVerse');
const dialogClose = document.getElementById('dialogClose');
function openPlaqueDialog(index) {
const data = plaqueData[index];
dialogTitle.textContent = '📿 ' + data.title;
dialogVerse.textContent = data.verse;
if (document.startViewTransition) {
document.startViewTransition(() => {
dialog.showModal();
});
} else {
dialog.showModal();
}
}
dialogClose.addEventListener('click', () => {
if (document.startViewTransition) {
document.startViewTransition(() => {
dialog.close();
});
} else {
dialog.close();
}
});
dialog.addEventListener('click', (e) => {
if (e.target === dialog) dialog.close();
});
// ==================== Web Audio API 钟声 ====================
function playChime() {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return;
const ctx = new AudioCtx();
const now = ctx.currentTime;
// 低频钟声
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(180, now);
osc.frequency.linearRampToValueAtTime(90, now + 2);
gain.gain.setValueAtTime(0.35, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 2.5);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 2.5);
// 泛音
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.type = 'triangle';
osc2.frequency.setValueAtTime(360, now + 0.1);
osc2.frequency.linearRampToValueAtTime(180, now + 2.2);
gain2.gain.setValueAtTime(0.2, now + 0.1);
gain2.gain.exponentialRampToValueAtTime(0.001, now + 2.8);
osc2.connect(gain2);
gain2.connect(ctx.destination);
osc2.start(now + 0.1);
osc2.stop(now + 2.8);
}
// 点击蜡烛播放钟声
document.querySelectorAll('.candle-stick').forEach(c => {
c.style.cursor = 'pointer';
c.addEventListener('click', (e) => {
e.stopPropagation();
playChime();
});
});
// 点击香炉也播放
document.querySelector('.burner-body').style.cursor = 'pointer';
document.querySelector('.burner-body').addEventListener('click', (e) => {
e.stopPropagation();
playChime();
});
// ==================== Web Worker + OffscreenCanvas 粒子 ====================
const particleCanvas = document.getElementById('particleCanvas');
let offscreen;
try {
offscreen = particleCanvas.transferControlToOffscreen();
} catch (e) {
offscreen = null;
}
if (offscreen) {
const workerCode = `
let canvas, ctx, particles = [];
const COUNT = 130;
self.onmessage = function(e) {
if (e.data.canvas) {
canvas = e.data.canvas;
ctx = canvas.getContext('2d');
init(canvas.width, canvas.height);
requestAnimationFrame(loop);
}
if (e.data.resize) {
canvas.width = e.data.resize.w;
canvas.height = e.data.resize.h;
init(canvas.width, canvas.height);
}
};
function init(w, h) {
particles = [];
for (let i = 0; i < COUNT; i++) {
particles.push({
x: Math.random() * w,
y: Math.random() * h,
r: Math.random() * 2.2 + 0.4,
vy: Math.random() * 0.5 + 0.15,
vx: (Math.random() - 0.5) * 0.35,
alpha: Math.random() * 0.7 + 0.15,
wobble: Math.random() * Math.PI * 2,
wobbleSpeed: Math.random() * 0.02 + 0.005,
});
}
}
function loop() {
if (!canvas) return;
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
for (let p of particles) {
p.y -= p.vy;
p.x += p.vx + Math.sin(p.wobble) * 0.4;
p.wobble += p.wobbleSpeed;
if (p.y < -15) { p.y = h + 10; p.x = Math.random() * w; }
if (p.x < -15) p.x = w + 10;
if (p.x > w + 15) p.x = -10;
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r * 3);
grad.addColorStop(0, \`rgba(240,210,140,\${p.alpha})\`);
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * 3, 0, Math.PI*2);
ctx.fill();
}
requestAnimationFrame(loop);
}
`;
const blob = new Blob([workerCode], { type: 'text/javascript' });
const workerUrl = URL.createObjectURL(blob);
const worker = new Worker(workerUrl);
URL.revokeObjectURL(workerUrl);
function resizeOffscreen() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = window.innerWidth;
const h = window.innerHeight;
particleCanvas.width = w * dpr;
particleCanvas.height = h * dpr;
particleCanvas.style.width = w + 'px';
particleCanvas.style.height = h + 'px';
worker.postMessage({ resize: { w: w * dpr, h: h * dpr } });
}
window.addEventListener('resize', resizeOffscreen);
resizeOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
} else {
// 降级:主线程 Canvas 2D
const ctx = particleCanvas.getContext('2d');
let particles = [];
const COUNT = 100;
function init2D() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = window.innerWidth;
const h = window.innerHeight;
particleCanvas.width = w * dpr;
particleCanvas.height = h * dpr;
particleCanvas.style.width = w + 'px';
particleCanvas.style.height = h + 'px';
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
particles = [];
for (let i = 0; i < COUNT; i++) {
particles.push({
x: Math.random() * w, y: Math.random() * h,
r: Math.random() * 2 + 0.4, vy: Math.random() * 0.5 + 0.15,
vx: (Math.random() - 0.5) * 0.35, alpha: Math.random() * 0.7 + 0.15,
wobble: Math.random() * Math.PI * 2, wobbleSpeed: Math.random() * 0.02 + 0.005,
});
}
}
function loop2D() {
const w = window.innerWidth, h = window.innerHeight;
ctx.clearRect(0, 0, w, h);
for (let p of particles) {
p.y -= p.vy; p.x += p.vx + Math.sin(p.wobble) * 0.4; p.wobble += p.wobbleSpeed;
if (p.y < -15) { p.y = h + 10; p.x = Math.random() * w; }
if (p.x < -15) p.x = w + 10; if (p.x > w + 15) p.x = -10;
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r * 3);
grad.addColorStop(0, `rgba(240,210,140,${p.alpha})`);
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r * 3, 0, Math.PI * 2); ctx.fill();
}
requestAnimationFrame(loop2D);
}
init2D();
window.addEventListener('resize', init2D);
loop2D();
}
// ==================== Popover 提示 ====================
const hint = document.getElementById('hint-popover');
if (hint.showPopover) {
setTimeout(() => {
hint.showPopover();
setTimeout(() => hint.hidePopover(), 5000);
}, 1200);
} else {
hint.style.opacity = '1';
setTimeout(() => { hint.style.opacity = '0'; }, 5000);
}
// 全局点击空白处播放细微钟声
document.addEventListener('click', (e) => {
if (
e.target.closest('.plaque-card') ||
e.target.closest('.candle-stick') ||
e.target.closest('.burner-body') ||
e.target.closest('dialog') ||
e.target.closest('#hint-popover')
) return;
// 轻声回响
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return;
const ctx = new AudioCtx();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(260, now);
osc.frequency.linearRampToValueAtTime(130, now + 1);
gain.gain.setValueAtTime(0.12, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 1.5);
osc.connect(gain); gain.connect(ctx.destination);
osc.start(now); osc.stop(now + 1.5);
});
console.log('🏛️ 祠堂牌位 · 2026技术栈:CSS @property · CSS Nesting · :has() · View Transitions · Popover · Dialog · Container Queries · Scroll-driven Animations · Web Worker + OffscreenCanvas · Web Audio API · 可变字体');
console.log('🕯️ 七位先祖牌位已立,点击牌位诵读祭文,点击蜡烛聆听钟声。');
})();
</script>
</body>
</html>