Real IMAP connection via imapflow library: - Connects to IMAP server (IMAPFlow, TLS on port 993) - Fetches recent messages (configurable limit, default 20) - Extracts: from, fromEmail, subject, date, snippet (200 chars) - Thread-to-contact matching (PRD §6.1): 1. Match sender email against persons table 2. If no person match, extract domain and match against company.website 3. Returns matchedCompany + matchedPerson for each email - Audit-logged (action: crm.mailsync:imap) - Zod validated (host, port, user, password, folder, limit) - 502 on IMAP connection failure with detail - 400 on validation failure Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
1675 lines
60 KiB
HTML
Executable File
1675 lines
60 KiB
HTML
Executable File
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
|
<title>AERIA · 留白时刻的环境智能</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=Inter:wght@300;400;500&family=Cormorant+Garamond:ital,wght@0,300;0,400;1,300;1,400&family=Noto+Serif+SC:wght@300;400&family=Noto+Sans+SC:wght@300;400;500&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--bg: #0a0e0a; /* 深林夜色 */
|
|
--bg-deep: #050805;
|
|
--ink: #ece9df; /* 米白墨色 */
|
|
--ink-mute: #8a8c80;
|
|
--line: rgba(236, 233, 223, 0.18);
|
|
--line-soft: rgba(236, 233, 223, 0.08);
|
|
--accent: #f5e7c4; /* 暖金光 */
|
|
--accent-soft: #c9b88a;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body {
|
|
background: var(--bg);
|
|
color: var(--ink);
|
|
font-family: 'Noto Sans SC', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
font-weight: 300;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
cursor: none;
|
|
-webkit-font-smoothing: antialiased;
|
|
text-rendering: optimizeLegibility;
|
|
}
|
|
body { letter-spacing: 0; }
|
|
a { color: inherit; text-decoration: none; }
|
|
.serif {
|
|
font-family: 'Cormorant Garamond', 'Noto Serif SC', serif;
|
|
font-style: italic; font-weight: 300;
|
|
}
|
|
.serif-cn { font-family: 'Noto Serif SC', serif; font-weight: 300; }
|
|
|
|
/* ---------- canvas ---------- */
|
|
#scene {
|
|
position: fixed; inset: 0;
|
|
width: 100%; height: 100%;
|
|
z-index: 1;
|
|
transition: filter 0.9s ease, opacity 0.9s ease;
|
|
}
|
|
body.page-open #scene { filter: blur(14px) brightness(0.32) saturate(0.7); }
|
|
|
|
/* ---------- vignette / atmosphere ---------- */
|
|
.vignette {
|
|
position: fixed; inset: 0; z-index: 2; pointer-events: none;
|
|
background:
|
|
radial-gradient(ellipse at 50% 35%, transparent 30%, rgba(0,0,0,0.55) 100%),
|
|
linear-gradient(180deg, rgba(255,238,200,0.04) 0%, transparent 30%, rgba(0,0,0,0.18) 100%);
|
|
mix-blend-mode: multiply;
|
|
}
|
|
.grain {
|
|
position: fixed; inset: 0; z-index: 3; pointer-events: none;
|
|
opacity: 0.08; mix-blend-mode: overlay;
|
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.55 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
|
}
|
|
|
|
/* ---------- header ---------- */
|
|
header {
|
|
position: fixed; top: 0; left: 0; right: 0;
|
|
z-index: 60;
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 24px 36px;
|
|
}
|
|
.logo {
|
|
font-family: 'Cormorant Garamond', serif;
|
|
font-style: italic; font-size: 24px; letter-spacing: 0.02em;
|
|
color: var(--ink);
|
|
}
|
|
.logo sup { font-size: 10px; vertical-align: super; opacity: 0.5; margin-left: 2px; }
|
|
nav.main {
|
|
display: flex; gap: 32px;
|
|
font-size: 12px;
|
|
color: var(--ink);
|
|
}
|
|
nav.main a {
|
|
position: relative; padding: 4px 0;
|
|
overflow: hidden; display: inline-block;
|
|
height: 24px; line-height: 24px;
|
|
}
|
|
nav.main a span {
|
|
display: block;
|
|
height: 24px; line-height: 24px;
|
|
transition: transform .55s cubic-bezier(.7,.05,.3,1);
|
|
letter-spacing: 0.06em;
|
|
}
|
|
nav.main a span.alt {
|
|
position: absolute; top: 100%; left: 0;
|
|
font-family: 'Cormorant Garamond', 'Noto Serif SC', serif;
|
|
font-style: italic; font-size: 16px;
|
|
letter-spacing: 0.04em;
|
|
color: var(--accent);
|
|
}
|
|
nav.main a:hover span.regular { transform: translateY(-100%); }
|
|
nav.main a:hover span.alt { transform: translateY(-100%); }
|
|
|
|
/* ---------- cursor ---------- */
|
|
.cursor {
|
|
position: fixed; top: 0; left: 0; z-index: 9999;
|
|
width: 12px; height: 12px; border-radius: 50%;
|
|
background: var(--accent);
|
|
pointer-events: none;
|
|
transform: translate(-50%, -50%);
|
|
mix-blend-mode: difference;
|
|
transition: width .25s, height .25s, background .25s, opacity .25s;
|
|
}
|
|
.cursor.hover {
|
|
width: 56px; height: 56px;
|
|
background: transparent;
|
|
border: 1px solid var(--accent);
|
|
}
|
|
.cursor.click { transform: translate(-50%, -50%) scale(0.6); }
|
|
|
|
/* ---------- loader ---------- */
|
|
.loader {
|
|
position: fixed; inset: 0; z-index: 200;
|
|
background: var(--bg);
|
|
display: flex; align-items: center; justify-content: center;
|
|
flex-direction: column;
|
|
transition: opacity 1.2s ease, visibility 1.2s ease;
|
|
}
|
|
.loader.gone { opacity: 0; visibility: hidden; pointer-events: none; }
|
|
.loader__title {
|
|
font-family: 'Cormorant Garamond', serif;
|
|
font-style: italic; font-size: clamp(56px, 10vw, 132px);
|
|
letter-spacing: -0.01em; line-height: 1;
|
|
color: var(--accent);
|
|
overflow: hidden;
|
|
display: flex;
|
|
}
|
|
.loader__title span {
|
|
display: inline-block;
|
|
transform: translateY(110%);
|
|
animation: rise 1.1s cubic-bezier(.2,.8,.2,1) forwards;
|
|
}
|
|
.loader__title span:nth-child(1){ animation-delay: 0.10s; }
|
|
.loader__title span:nth-child(2){ animation-delay: 0.18s; }
|
|
.loader__title span:nth-child(3){ animation-delay: 0.26s; }
|
|
.loader__title span:nth-child(4){ animation-delay: 0.34s; }
|
|
.loader__title span:nth-child(5){ animation-delay: 0.42s; }
|
|
@keyframes rise { to { transform: translateY(0); } }
|
|
.loader__sub {
|
|
margin-top: 22px; font-size: 11px; letter-spacing: 0.4em;
|
|
color: var(--ink-mute); opacity: 0; animation: fade 1s ease 0.9s forwards;
|
|
}
|
|
.loader__enter {
|
|
margin-top: 64px;
|
|
background: transparent; border: 1px solid var(--accent);
|
|
color: var(--accent); padding: 16px 42px;
|
|
font-family: 'Noto Sans SC', sans-serif; font-size: 12px; letter-spacing: 0.4em;
|
|
cursor: none;
|
|
opacity: 0; animation: fade 1s ease 1.4s forwards;
|
|
transition: background 0.4s, color 0.4s;
|
|
}
|
|
.loader__enter:hover { background: var(--accent); color: var(--bg); }
|
|
@keyframes fade { to { opacity: 1; } }
|
|
|
|
/* ---------- station overlay ---------- */
|
|
.stations {
|
|
position: fixed; inset: 0; z-index: 20; pointer-events: none;
|
|
}
|
|
.station {
|
|
position: absolute; left: 0; right: 0;
|
|
padding: 0 9vw;
|
|
opacity: 0; visibility: hidden;
|
|
transition: opacity 1.1s ease, transform 1.4s cubic-bezier(.2,.7,.3,1);
|
|
transform: translateY(20px);
|
|
will-change: opacity, transform;
|
|
}
|
|
.station.active { opacity: 1; visibility: visible; transform: translateY(0); }
|
|
|
|
.station--center {
|
|
top: 50%; transform: translateY(calc(-50% + 30px));
|
|
text-align: center;
|
|
}
|
|
.station--center.active { transform: translateY(-50%); }
|
|
|
|
.station--bottom { bottom: 12vh; }
|
|
.station--bottom.active { transform: translateY(0); }
|
|
|
|
.station h1 {
|
|
font-family: 'Noto Serif SC', serif;
|
|
font-weight: 300;
|
|
font-size: clamp(38px, 5.4vw, 84px);
|
|
line-height: 1.18; letter-spacing: 0.02em;
|
|
color: var(--ink);
|
|
}
|
|
.station h1 em {
|
|
font-style: normal; font-weight: 400;
|
|
color: var(--accent);
|
|
}
|
|
.station h2 {
|
|
font-family: 'Noto Serif SC', serif;
|
|
font-weight: 300;
|
|
font-size: clamp(28px, 3.8vw, 56px);
|
|
line-height: 1.25;
|
|
}
|
|
.station h2 em {
|
|
font-style: normal; font-weight: 400;
|
|
color: var(--accent);
|
|
}
|
|
.eyebrow {
|
|
font-size: 11px; letter-spacing: 0.3em;
|
|
color: var(--ink-mute); margin-bottom: 22px;
|
|
display: inline-flex; align-items: center; gap: 8px;
|
|
}
|
|
.eyebrow::before {
|
|
content: ''; width: 22px; height: 1px; background: var(--accent);
|
|
}
|
|
.lede {
|
|
margin-top: 28px;
|
|
max-width: 540px;
|
|
font-size: 15px; line-height: 1.85;
|
|
color: rgba(236,233,223,0.74);
|
|
}
|
|
.station--center .lede { margin-left: auto; margin-right: auto; }
|
|
|
|
/* feature grid station */
|
|
.grid-row {
|
|
display: flex; gap: 72px; align-items: flex-end;
|
|
max-width: 1180px;
|
|
}
|
|
.grid-row > div { flex: 1; }
|
|
.grid-num {
|
|
font-family: 'Cormorant Garamond', serif; font-style: italic;
|
|
font-size: 16px; color: var(--accent); margin-bottom: 14px;
|
|
}
|
|
.grid-title {
|
|
font-family: 'Noto Serif SC', serif;
|
|
font-size: 30px; line-height: 1.15; margin-bottom: 12px;
|
|
}
|
|
.grid-text {
|
|
font-size: 14px; line-height: 1.85; color: rgba(236,233,223,0.72);
|
|
}
|
|
.grid-meta-label {
|
|
font-size: 10px; letter-spacing: 0.3em;
|
|
color: var(--ink-mute); margin-bottom: 10px;
|
|
}
|
|
|
|
/* CTA */
|
|
.cta {
|
|
display: inline-flex; align-items: center; gap: 14px;
|
|
margin-top: 42px;
|
|
padding: 16px 32px;
|
|
border: 1px solid rgba(245,231,196,0.45);
|
|
border-radius: 100px;
|
|
font-size: 12px; letter-spacing: 0.3em;
|
|
color: var(--accent);
|
|
pointer-events: auto;
|
|
transition: background 0.4s, color 0.4s, border-color 0.4s;
|
|
cursor: none;
|
|
}
|
|
.cta:hover { background: var(--accent); color: var(--bg); border-color: var(--accent); }
|
|
.cta svg { width: 14px; height: 14px; }
|
|
|
|
/* hint at bottom */
|
|
.hint {
|
|
position: fixed;
|
|
bottom: 26px; left: 36px;
|
|
z-index: 50;
|
|
font-size: 10px; letter-spacing: 0.32em;
|
|
color: var(--ink-mute);
|
|
display: flex; align-items: center; gap: 14px;
|
|
transition: opacity .6s;
|
|
}
|
|
.hint.fade { opacity: 0.25; }
|
|
.hint__dot {
|
|
width: 6px; height: 6px; border-radius: 50%;
|
|
background: var(--accent);
|
|
animation: pulse 1.6s ease-in-out infinite;
|
|
}
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 0.4; transform: scale(0.9);}
|
|
50% { opacity: 1; transform: scale(1.2);}
|
|
}
|
|
|
|
/* progress bar */
|
|
.progress {
|
|
position: fixed;
|
|
bottom: 26px; right: 36px;
|
|
z-index: 50;
|
|
display: flex; align-items: center; gap: 14px;
|
|
font-size: 10px; letter-spacing: 0.32em;
|
|
color: var(--ink-mute);
|
|
}
|
|
.progress__bar {
|
|
width: 140px; height: 1px;
|
|
background: rgba(236,233,223,0.22);
|
|
position: relative;
|
|
}
|
|
.progress__fill {
|
|
position: absolute; left: 0; top: 0;
|
|
height: 100%; width: 0%;
|
|
background: var(--accent);
|
|
transition: width 0.18s ease;
|
|
}
|
|
.progress__num { width: 32px; text-align: right; font-feature-settings: 'tnum'; }
|
|
|
|
/* chapter index left */
|
|
.chapters {
|
|
position: fixed;
|
|
top: 50%; left: 36px;
|
|
transform: translateY(-50%);
|
|
z-index: 50;
|
|
display: flex; flex-direction: column; gap: 18px;
|
|
}
|
|
.chapters__item {
|
|
display: flex; align-items: center; gap: 14px;
|
|
cursor: none; pointer-events: auto;
|
|
color: var(--ink-mute); font-size: 11px; letter-spacing: 0.18em;
|
|
opacity: 0.55;
|
|
transition: opacity .4s, color .4s;
|
|
}
|
|
.chapters__item:hover { opacity: 1; }
|
|
.chapters__item.active { opacity: 1; color: var(--accent); }
|
|
.chapters__item .dash {
|
|
width: 24px; height: 1px; background: currentColor;
|
|
transition: width .4s;
|
|
}
|
|
.chapters__item.active .dash { width: 50px; }
|
|
|
|
/* ---------- sub pages ---------- */
|
|
.page {
|
|
position: fixed; inset: 0;
|
|
z-index: 100;
|
|
background: rgba(10,14,10,0.90);
|
|
backdrop-filter: blur(24px);
|
|
-webkit-backdrop-filter: blur(24px);
|
|
overflow-y: auto; overflow-x: hidden;
|
|
opacity: 0; visibility: hidden; pointer-events: none;
|
|
transition: opacity .8s ease, visibility .8s ease;
|
|
}
|
|
.page.open { opacity: 1; visibility: visible; pointer-events: auto; }
|
|
.page__inner {
|
|
max-width: 1280px; margin: 0 auto;
|
|
padding: 120px 8vw 120px;
|
|
}
|
|
.page__nav {
|
|
position: sticky; top: 0;
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 22px 0;
|
|
margin: -120px -8vw 80px;
|
|
padding-left: 8vw; padding-right: 8vw;
|
|
background: linear-gradient(180deg, rgba(10,14,10,0.96), rgba(10,14,10,0));
|
|
backdrop-filter: blur(8px);
|
|
z-index: 5;
|
|
}
|
|
.page__back {
|
|
font-size: 11px; letter-spacing: 0.3em;
|
|
color: var(--ink-mute); cursor: none;
|
|
display: flex; align-items: center; gap: 12px;
|
|
transition: color .3s;
|
|
}
|
|
.page__back:hover { color: var(--accent); }
|
|
.page__crumb {
|
|
font-family: 'Cormorant Garamond', 'Noto Serif SC', serif;
|
|
font-style: italic;
|
|
font-size: 14px; color: var(--ink-mute);
|
|
}
|
|
.page__hero {
|
|
border-top: 1px solid var(--line);
|
|
padding-top: 36px;
|
|
margin-bottom: 80px;
|
|
}
|
|
.page__eyebrow {
|
|
font-size: 11px; letter-spacing: 0.3em;
|
|
color: var(--accent); margin-bottom: 24px;
|
|
}
|
|
.page__title {
|
|
font-family: 'Noto Serif SC', serif; font-weight: 300;
|
|
font-size: clamp(48px, 7vw, 108px);
|
|
line-height: 1.1; letter-spacing: 0.02em;
|
|
}
|
|
.page__title em {
|
|
font-style: normal; font-weight: 400;
|
|
color: var(--accent);
|
|
}
|
|
.page__lede {
|
|
margin-top: 44px;
|
|
max-width: 660px;
|
|
font-size: 18px; line-height: 1.7;
|
|
color: rgba(236,233,223,0.78);
|
|
}
|
|
|
|
/* features page */
|
|
.feat-list { display: grid; gap: 0; }
|
|
.feat-row {
|
|
display: grid;
|
|
grid-template-columns: 80px 1.1fr 2fr 1.4fr;
|
|
gap: 36px;
|
|
padding: 56px 0;
|
|
border-top: 1px solid var(--line);
|
|
align-items: start;
|
|
}
|
|
.feat-row:last-child { border-bottom: 1px solid var(--line); }
|
|
.feat-row__num {
|
|
font-family: 'Cormorant Garamond', serif; font-style: italic;
|
|
font-size: 18px; color: var(--accent);
|
|
}
|
|
.feat-row__name {
|
|
font-family: 'Noto Serif SC', serif;
|
|
font-size: 38px; line-height: 1.05;
|
|
font-weight: 300;
|
|
}
|
|
.feat-row__name em {
|
|
font-family: 'Cormorant Garamond', serif;
|
|
font-style: italic; color: var(--accent);
|
|
margin-right: 8px;
|
|
}
|
|
.feat-row__desc {
|
|
font-size: 14px; line-height: 1.85; color: rgba(236,233,223,0.78);
|
|
}
|
|
.feat-row__meta {
|
|
font-size: 11px; line-height: 1.85; color: var(--ink-mute);
|
|
letter-spacing: 0.1em;
|
|
}
|
|
.feat-row__meta b {
|
|
color: var(--ink); font-weight: 400;
|
|
display: block; margin-bottom: 4px;
|
|
letter-spacing: 0.18em; font-size: 10px;
|
|
}
|
|
|
|
/* pricing */
|
|
.price-grid {
|
|
display: grid; grid-template-columns: repeat(3, 1fr);
|
|
gap: 24px;
|
|
margin-top: 60px;
|
|
}
|
|
.price-card {
|
|
border: 1px solid var(--line);
|
|
padding: 40px 32px;
|
|
display: flex; flex-direction: column; min-height: 500px;
|
|
background: rgba(245, 231, 196, 0.03);
|
|
transition: border-color .4s, background .4s, transform .4s;
|
|
}
|
|
.price-card:hover {
|
|
border-color: var(--accent);
|
|
transform: translateY(-4px);
|
|
}
|
|
.price-card.featured {
|
|
border-color: var(--accent);
|
|
background: rgba(245,231,196,0.06);
|
|
}
|
|
.price-card__name {
|
|
font-family: 'Noto Serif SC', serif;
|
|
font-size: 32px; margin-bottom: 6px; font-weight: 300;
|
|
}
|
|
.price-card__name em {
|
|
font-family: 'Cormorant Garamond', serif; font-style: italic;
|
|
color: var(--accent); margin-right: 8px;
|
|
}
|
|
.price-card__tag {
|
|
font-size: 11px; letter-spacing: 0.18em;
|
|
color: var(--ink-mute); margin-bottom: 30px;
|
|
}
|
|
.price-card__amt {
|
|
font-family: 'Cormorant Garamond', serif;
|
|
font-size: 64px; line-height: 1; margin-bottom: 6px;
|
|
color: var(--ink);
|
|
}
|
|
.price-card__amt sup { font-size: 22px; vertical-align: top; opacity: 0.6; }
|
|
.price-card__per { font-size: 12px; color: var(--ink-mute); margin-bottom: 28px; }
|
|
.price-card__feat {
|
|
list-style: none; flex-grow: 1;
|
|
border-top: 1px solid var(--line);
|
|
padding-top: 22px;
|
|
}
|
|
.price-card__feat li {
|
|
padding: 10px 0; font-size: 13px;
|
|
color: rgba(236,233,223,0.82);
|
|
border-bottom: 1px solid var(--line-soft);
|
|
line-height: 1.5;
|
|
}
|
|
.price-card__cta {
|
|
margin-top: 28px; padding: 14px 0;
|
|
text-align: center;
|
|
font-size: 11px; letter-spacing: 0.3em;
|
|
border: 1px solid var(--line); cursor: none;
|
|
transition: background .3s, color .3s, border-color .3s;
|
|
}
|
|
.price-card__cta:hover { background: var(--ink); color: var(--bg); border-color: var(--ink); }
|
|
.price-card.featured .price-card__cta { border-color: var(--accent); color: var(--accent); }
|
|
.price-card.featured .price-card__cta:hover { background: var(--accent); color: var(--bg); }
|
|
|
|
/* story */
|
|
.story-grid {
|
|
display: grid; grid-template-columns: 1fr 1fr;
|
|
gap: 80px;
|
|
margin-bottom: 80px;
|
|
align-items: start;
|
|
}
|
|
.story-img {
|
|
width: 100%; aspect-ratio: 4/5; object-fit: cover;
|
|
filter: grayscale(0.15) contrast(1.02) sepia(0.08);
|
|
}
|
|
.story-text p {
|
|
font-size: 15px; line-height: 1.95;
|
|
color: rgba(236,233,223,0.85);
|
|
margin-bottom: 24px;
|
|
}
|
|
.story-text p.first::first-letter {
|
|
font-family: 'Cormorant Garamond', serif; font-style: italic;
|
|
font-size: 88px; line-height: 0.85; float: left;
|
|
margin: 6px 18px 0 0; color: var(--accent);
|
|
}
|
|
.story-pull {
|
|
font-family: 'Noto Serif SC', serif;
|
|
font-size: 38px; line-height: 1.4; font-weight: 300;
|
|
margin: 100px auto;
|
|
max-width: 820px; text-align: center;
|
|
color: var(--ink);
|
|
position: relative;
|
|
}
|
|
.story-pull em {
|
|
font-style: normal; font-weight: 400;
|
|
color: var(--accent);
|
|
}
|
|
.story-pull::before, .story-pull::after {
|
|
content: '"';
|
|
font-family: 'Cormorant Garamond', serif;
|
|
font-style: italic;
|
|
font-size: 80px; line-height: 0;
|
|
color: var(--accent); opacity: 0.4;
|
|
position: relative; vertical-align: -0.4em;
|
|
}
|
|
.story-pull::after { content: '"'; }
|
|
.credits {
|
|
border-top: 1px solid var(--line); padding-top: 44px;
|
|
display: grid; grid-template-columns: repeat(4, 1fr); gap: 32px;
|
|
font-size: 13px;
|
|
}
|
|
.credits dt {
|
|
font-size: 10px; letter-spacing: 0.28em;
|
|
color: var(--ink-mute); margin-bottom: 10px;
|
|
}
|
|
.credits dd { color: var(--ink); line-height: 1.7; }
|
|
|
|
/* contact */
|
|
.contact-grid {
|
|
display: grid; grid-template-columns: 1.1fr 1fr;
|
|
gap: 80px;
|
|
}
|
|
.contact-form { display: flex; flex-direction: column; gap: 22px; }
|
|
.contact-form input, .contact-form textarea {
|
|
background: transparent;
|
|
border: none; border-bottom: 1px solid var(--line);
|
|
color: var(--ink); padding: 14px 0;
|
|
font-family: inherit; font-size: 14px;
|
|
outline: none; cursor: none;
|
|
transition: border-color .3s;
|
|
}
|
|
.contact-form input::placeholder, .contact-form textarea::placeholder {
|
|
color: var(--ink-mute);
|
|
}
|
|
.contact-form input:focus, .contact-form textarea:focus { border-color: var(--accent); }
|
|
.contact-form textarea { resize: none; min-height: 140px; }
|
|
.contact-form button {
|
|
align-self: flex-start;
|
|
margin-top: 14px; padding: 16px 38px;
|
|
background: var(--ink); border: none; color: var(--bg);
|
|
font-family: inherit; font-size: 11px; letter-spacing: 0.3em;
|
|
cursor: none;
|
|
transition: background .3s;
|
|
}
|
|
.contact-form button:hover { background: var(--accent); }
|
|
.contact-info { font-size: 14px; line-height: 1.9; color: rgba(236,233,223,0.78); }
|
|
.contact-info h4 {
|
|
font-family: 'Noto Serif SC', serif;
|
|
font-size: 22px; margin-bottom: 16px; color: var(--ink);
|
|
font-weight: 300;
|
|
}
|
|
.contact-info h4 em {
|
|
font-family: 'Cormorant Garamond', serif; font-style: italic;
|
|
color: var(--accent);
|
|
}
|
|
.contact-info p { margin-bottom: 32px; }
|
|
.contact-info a {
|
|
border-bottom: 1px solid var(--line);
|
|
padding-bottom: 2px;
|
|
transition: border-color .3s, color .3s;
|
|
}
|
|
.contact-info a:hover { color: var(--accent); border-color: var(--accent); }
|
|
|
|
/* page footer */
|
|
.page__foot {
|
|
margin-top: 120px;
|
|
padding-top: 40px;
|
|
border-top: 1px solid var(--line);
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
font-size: 11px; color: var(--ink-mute);
|
|
letter-spacing: 0.18em;
|
|
}
|
|
|
|
/* responsive */
|
|
@media (max-width: 900px) {
|
|
nav.main { display: none; }
|
|
.chapters { display: none; }
|
|
.progress { right: 18px; bottom: 18px; }
|
|
.progress__bar { width: 90px; }
|
|
.hint { left: 18px; bottom: 18px; }
|
|
.grid-row { flex-direction: column; gap: 32px; align-items: stretch; }
|
|
.price-grid { grid-template-columns: 1fr; }
|
|
.story-grid { grid-template-columns: 1fr; gap: 32px; }
|
|
.contact-grid { grid-template-columns: 1fr; gap: 32px; }
|
|
.feat-row { grid-template-columns: 40px 1fr; }
|
|
.feat-row__desc, .feat-row__meta { grid-column: 2; }
|
|
.credits { grid-template-columns: 1fr 1fr; }
|
|
.page__inner { padding: 100px 6vw 60px; }
|
|
.station { padding: 0 6vw; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- ============== LOADER ============== -->
|
|
<div class="loader" id="loader">
|
|
<div class="loader__title">
|
|
<span>A</span><span>E</span><span>R</span><span>I</span><span>A</span>
|
|
</div>
|
|
<div class="loader__sub">环境智能 · 始于二〇二六</div>
|
|
<button class="loader__enter" id="enter" data-hover>进入林间</button>
|
|
</div>
|
|
|
|
<!-- ============== HEADER ============== -->
|
|
<header>
|
|
<a href="#" class="logo" data-hover><em>Aeria</em><sup>®</sup></a>
|
|
<nav class="main">
|
|
<a href="#" data-route="home" data-hover><span class="regular">首页</span><span class="alt">Index</span></a>
|
|
<a href="#features" data-route="features" data-hover><span class="regular">功能</span><span class="alt">Features</span></a>
|
|
<a href="#story" data-route="story" data-hover><span class="regular">故事</span><span class="alt">Story</span></a>
|
|
<a href="#pricing" data-route="pricing" data-hover><span class="regular">价格</span><span class="alt">Pricing</span></a>
|
|
<a href="#contact" data-route="contact" data-hover><span class="regular">联系</span><span class="alt">Contact</span></a>
|
|
</nav>
|
|
</header>
|
|
|
|
<!-- ============== CANVAS + EFFECTS ============== -->
|
|
<canvas id="scene"></canvas>
|
|
<div class="vignette"></div>
|
|
<div class="grain"></div>
|
|
|
|
<!-- ============== STATIONS ============== -->
|
|
<div class="stations" id="stations">
|
|
|
|
<!-- 0: hero -->
|
|
<section class="station station--center" data-from="0.00" data-to="0.16">
|
|
<div class="eyebrow">A E R I A · 第〇〇一号</div>
|
|
<h1>一种住在<em>留白</em>里的<br/>环境<em>智能</em></h1>
|
|
<p class="lede">一个住在你房间里的小型本地模型。它倾听房间,不倾听世界;以你自己半语的声音回写。它为语言尚未抵达的瞬间而造。</p>
|
|
</section>
|
|
|
|
<!-- 1: manifesto -->
|
|
<section class="station station--bottom" data-from="0.16" data-to="0.30">
|
|
<div class="eyebrow">宣言</div>
|
|
<h2>我们为<em>语言尚未到来</em>的<br/>那一刻而造。</h2>
|
|
<p class="lede">睡前的半个念头。淋浴时未完成的一句话。星期二早晨,世界还没有变成句子之前。AERIA 是为这些时刻而生——不是为之后那场会议。</p>
|
|
</section>
|
|
|
|
<!-- 2: feature 1 - Listen -->
|
|
<section class="station station--bottom" data-from="0.30" data-to="0.46">
|
|
<div class="grid-row">
|
|
<div>
|
|
<div class="grid-num">— 其一</div>
|
|
<h2 style="font-size: clamp(36px,4.6vw,68px); line-height: 1.05;">倾听 <em style="font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--accent);">Listen</em></h2>
|
|
</div>
|
|
<div>
|
|
<p class="grid-text">永远在线,永远本地。房间成为思考的麦克风。AERIA 与你停留在此刻——除非你主动允许,否则它不会上云。</p>
|
|
</div>
|
|
<div>
|
|
<div class="grid-meta-label">运行模式</div>
|
|
<p class="grid-text" style="font-size: 12px;">本地推理 · Whisper 级语音识别<br/>声纹识别 · 零留存 · 320MB 占用</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 3: feature 2 - Reflect -->
|
|
<section class="station station--bottom" data-from="0.46" data-to="0.62">
|
|
<div class="grid-row">
|
|
<div>
|
|
<div class="grid-num">— 其二</div>
|
|
<h2 style="font-size: clamp(36px,4.6vw,68px); line-height: 1.05;">复述 <em style="font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--accent);">Reflect</em></h2>
|
|
</div>
|
|
<div>
|
|
<p class="grid-text">每个夜晚,AERIA 用更安静的声音读回你的一天。一封短小的口信——由一个在你心不在焉时仍在凝神的存在写下。</p>
|
|
</div>
|
|
<div>
|
|
<div class="grid-meta-label">节奏</div>
|
|
<p class="grid-text" style="font-size: 12px;">每日 90 秒口信<br/>可选每周文字汇编</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 4: feature 3 - Compose -->
|
|
<section class="station station--bottom" data-from="0.62" data-to="0.80">
|
|
<div class="grid-row">
|
|
<div>
|
|
<div class="grid-num">— 其三</div>
|
|
<h2 style="font-size: clamp(36px,4.6vw,68px); line-height: 1.05;">续写 <em style="font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--accent);">Compose</em></h2>
|
|
</div>
|
|
<div>
|
|
<p class="grid-text">半句话变成完整的事,呢喃变成草稿。一个能从你停顿的地方继续的笔记本——以你刚才那个语气的声音,把它写完。</p>
|
|
</div>
|
|
<div>
|
|
<div class="grid-meta-label">输出</div>
|
|
<p class="grid-text" style="font-size: 12px;">Markdown · 语音备忘 · 信件<br/>本地优先导出 · 无埋点</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 5: closing -->
|
|
<section class="station station--center" data-from="0.80" data-to="1.0">
|
|
<div class="eyebrow">走入更深</div>
|
|
<h1>让你最<em>安静</em>的时刻<br/>终于<em>被听见</em>。</h1>
|
|
<p class="lede">AERIA 现处于私密内测。我们每周邀请少量用户。请慢慢走进来。</p>
|
|
<a class="cta" href="#contact" data-hover>
|
|
申请内测
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
|
|
</a>
|
|
</section>
|
|
|
|
</div>
|
|
|
|
<!-- ============== UI: chapters / hint / progress ============== -->
|
|
<div class="chapters" id="chapters">
|
|
<div class="chapters__item active" data-go="0.0"><span class="dash"></span><span>序章</span></div>
|
|
<div class="chapters__item" data-go="0.20"><span class="dash"></span><span>宣言</span></div>
|
|
<div class="chapters__item" data-go="0.38"><span class="dash"></span><span>倾听</span></div>
|
|
<div class="chapters__item" data-go="0.54"><span class="dash"></span><span>复述</span></div>
|
|
<div class="chapters__item" data-go="0.71"><span class="dash"></span><span>续写</span></div>
|
|
<div class="chapters__item" data-go="0.92"><span class="dash"></span><span>步入</span></div>
|
|
</div>
|
|
|
|
<div class="hint" id="hint">
|
|
<span class="hint__dot"></span>
|
|
滚动 · 走入林中
|
|
</div>
|
|
|
|
<div class="progress">
|
|
<span class="progress__num" id="prog-num">000</span>
|
|
<div class="progress__bar"><div class="progress__fill" id="prog-fill"></div></div>
|
|
<span>步程</span>
|
|
</div>
|
|
|
|
<!-- ============== CURSOR ============== -->
|
|
<div class="cursor" id="cursor"></div>
|
|
|
|
<!-- ============== SUB PAGES ============== -->
|
|
|
|
<!-- FEATURES -->
|
|
<div class="page" id="page-features">
|
|
<div class="page__inner">
|
|
<div class="page__nav">
|
|
<a class="page__back" href="#" data-back data-hover>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
|
返回林间
|
|
</a>
|
|
<span class="page__crumb">Aeria · Features</span>
|
|
</div>
|
|
|
|
<div class="page__hero">
|
|
<div class="page__eyebrow">— 它能做什么</div>
|
|
<h1 class="page__title">一个<em>停留在</em><br/>房间里的模型</h1>
|
|
<p class="page__lede">AERIA 是一组为"陪伴"而非"搜索"设计的小工具。每一项能力都需主动开启,没有任何一项会喧哗。</p>
|
|
</div>
|
|
|
|
<div class="feat-list">
|
|
<div class="feat-row">
|
|
<div class="feat-row__num">I.</div>
|
|
<div class="feat-row__name"><em>Listen</em>倾听</div>
|
|
<div class="feat-row__desc">一层常驻的、本机运行的转写层。Whisper 级模型完整跑在你的设备上,仅在你标记"值得保留"的时候才记录片段。声纹只识别你自己。默认零留存——未被请求的片段会自然消散。</div>
|
|
<div class="feat-row__meta"><b>模式</b>本地 · 4-bit 量化<br/>320 MB 占用<br/>iOS · macOS · Linux</div>
|
|
</div>
|
|
<div class="feat-row">
|
|
<div class="feat-row__num">II.</div>
|
|
<div class="feat-row__name"><em>Reflect</em>复述</div>
|
|
<div class="feat-row__desc">每个夜晚被书写的一封短音频信。AERIA 将白天里被你标记的片段编织成一段 90 秒的叙事——以更慢、更安静版本的你自己的声音回放。这些信件仅存在你的设备上。</div>
|
|
<div class="feat-row__meta"><b>节奏</b>每日 90 秒口信<br/>可选每周文字汇编<br/>授权后克隆声纹</div>
|
|
</div>
|
|
<div class="feat-row">
|
|
<div class="feat-row__num">III.</div>
|
|
<div class="feat-row__name"><em>Compose</em>续写</div>
|
|
<div class="feat-row__desc">从你停下的地方开始的一面草稿纸。说半句话,AERIA 沿着你刚才的语气继续把它写完。适合写信、悼词、明信片、慢通信——任何想要"听起来像你、只是把它写完"的事情。</div>
|
|
<div class="feat-row__meta"><b>输出</b>Markdown · txt · 语音备忘<br/>本地优先导出<br/>无任何分析埋点</div>
|
|
</div>
|
|
<div class="feat-row">
|
|
<div class="feat-row__num">IV.</div>
|
|
<div class="feat-row__name"><em>Drift</em>留白</div>
|
|
<div class="feat-row__desc">为缓慢时段而设的长形态——散步、泡澡、火车上。AERIA 不再回应,只陪伴。它倾听、归档,只在你回来的时候才再开口。Drift 模式内没有任何通知。</div>
|
|
<div class="feat-row__meta"><b>会话</b>15 分钟至 8 小时<br/>仅后台运行<br/>一键开启</div>
|
|
</div>
|
|
<div class="feat-row">
|
|
<div class="feat-row__num">V.</div>
|
|
<div class="feat-row__name"><em>Grove</em>林间</div>
|
|
<div class="feat-row__desc">为"两个人"设计的共享模式——伴侣、合作者、共同抚养孩子的人。两台 AERIA 各自在自己的房间里倾听,并在每一天结束时交换简短的、被双方认可的口信。原始声音从不被传输,未经双向确认的内容不会共享。</div>
|
|
<div class="feat-row__meta"><b>隐私</b>端到端摘要<br/>对称授权<br/>每条可单独撤回</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="page__foot">
|
|
<span>© 2026 Aeria Studio · 卑尔根 · 东京 · 上海</span>
|
|
<span>文档 · v0.7 · 2026 年 5 月</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- STORY -->
|
|
<div class="page" id="page-story">
|
|
<div class="page__inner">
|
|
<div class="page__nav">
|
|
<a class="page__back" href="#" data-back data-hover>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
|
返回林间
|
|
</a>
|
|
<span class="page__crumb">Aeria · Story</span>
|
|
</div>
|
|
|
|
<div class="page__hero">
|
|
<div class="page__eyebrow">— 缘起</div>
|
|
<h1 class="page__title">一段穿过<em>某人</em><br/>沉默的<em>散步</em></h1>
|
|
</div>
|
|
|
|
<div class="story-grid">
|
|
<img class="story-img" alt="挪威清晨的桦树林" src="../assets/forest-03-birch.jpg" />
|
|
<div class="story-text">
|
|
<p class="first">AERIA 始于 2025 年十一月第三周,挪威 Voss 郊外一条被雾打湿的林间小径。天刚要亮。树是桦树,地面是潮的。创始人——一个过去八年都在做"会打断你"的语音助手的人——第一次在很久以来停下脚步,去听一种没有内容的声音。</p>
|
|
<p>那一刻浮起的念头很小:<em>为什么我们做的每一个 AI 都在喊?</em></p>
|
|
<p>过去数年里,他在斯德哥尔摩、东京、旧金山参与过的产品有一个共性——它们全部为"被询问"那个瞬间而优化。它们大声唤醒;它们按"自信度"对答案排序;它们假定使用者已经处于语言之中。</p>
|
|
<p>但大多数思考并不发生在语言里。大多数思考发生在语言尚未抵达的长长的安静里——洗澡时、散步时、星期二清晨的半梦半醒之间。好的念头不会自己宣布到来。它们只是落下来。</p>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="story-pull">我们想做一个<em>能在沉默里坐着</em>的模型——<br/>而不是一个一直忙着填补它的。</p>
|
|
|
|
<div class="story-grid">
|
|
<div class="story-text">
|
|
<p>第一版原型是 2 月在卑尔根用一个长周末搭的。一只麦克风、一个本地小模型、一条规则——模型一天只能往一个 txt 文件里回写一次,没有界面,没有实时回应,没有任何提醒。</p>
|
|
<p>我们把它发给七个人。三个人在一周内退回了笔记本。两个人在一个月后停止使用。剩下两个人到今天连续用了九个月。两人都说了同一句话:他们多年前就停止写日记了,从这之后又开始写了。</p>
|
|
<p>那已经够我们继续做下去了。</p>
|
|
</div>
|
|
<img class="story-img" alt="安静的书桌" src="../assets/forest-04-desk.jpg" />
|
|
</div>
|
|
|
|
<dl class="credits">
|
|
<div><dt>创立</dt><dd>卑尔根 · 2025 年 3 月</dd></div>
|
|
<div><dt>团队</dt><dd>九人 · 零 KPI</dd></div>
|
|
<div><dt>支持</dt><dd>Slowmade Ventures<br/>Hyll · 私人投资</dd></div>
|
|
<div><dt>状态</dt><dd>私密内测<br/>2026 年 5 月</dd></div>
|
|
</dl>
|
|
|
|
<div class="page__foot">
|
|
<span>© 2026 Aeria Studio</span>
|
|
<span>故事 · v0.4</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PRICING -->
|
|
<div class="page" id="page-pricing">
|
|
<div class="page__inner">
|
|
<div class="page__nav">
|
|
<a class="page__back" href="#" data-back data-hover>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
|
返回林间
|
|
</a>
|
|
<span class="page__crumb">Aeria · Pricing</span>
|
|
</div>
|
|
|
|
<div class="page__hero">
|
|
<div class="page__eyebrow">— 价格</div>
|
|
<h1 class="page__title">通往同一片<em>林子</em><br/>的<em>三扇门</em></h1>
|
|
<p class="page__lede">三种方案运行的是同一个本地模型。区别在于节奏、声音,以及你愿意把这片林子分享给多少人。</p>
|
|
</div>
|
|
|
|
<div class="price-grid">
|
|
<div class="price-card">
|
|
<div class="price-card__name"><em>Resident</em>居者</div>
|
|
<div class="price-card__tag">为独自倾听的人</div>
|
|
<div class="price-card__amt"><sup>¥</sup>0</div>
|
|
<div class="price-card__per">永久免费 · 单台设备</div>
|
|
<ul class="price-card__feat">
|
|
<li>· 本地 Listen 模式</li>
|
|
<li>· 每日 Reflect 口信</li>
|
|
<li>· Markdown 导出</li>
|
|
<li>· 单设备 · 单声纹</li>
|
|
<li>· 无云端 · 无遥测</li>
|
|
</ul>
|
|
<div class="price-card__cta" data-hover>静静开始</div>
|
|
</div>
|
|
|
|
<div class="price-card featured">
|
|
<div class="price-card__name"><em>Forest</em>森者</div>
|
|
<div class="price-card__tag">最多人选择</div>
|
|
<div class="price-card__amt"><sup>¥</sup>108</div>
|
|
<div class="price-card__per">每月 · 至多三台设备</div>
|
|
<ul class="price-card__feat">
|
|
<li>· 包含居者全部</li>
|
|
<li>· 无限 Compose 草稿</li>
|
|
<li>· 授权后声纹克隆</li>
|
|
<li>· Drift 留白长会话</li>
|
|
<li>· 语音备忘 · 有声书导出</li>
|
|
<li>· 可选加密备份</li>
|
|
</ul>
|
|
<div class="price-card__cta" data-hover>走进来</div>
|
|
</div>
|
|
|
|
<div class="price-card">
|
|
<div class="price-card__name"><em>Grove</em>林者</div>
|
|
<div class="price-card__tag">为工作室 / 伴侣共享</div>
|
|
<div class="price-card__amt"><sup>¥</sup>288</div>
|
|
<div class="price-card__per">每月 · 至多八个声纹</div>
|
|
<ul class="price-card__feat">
|
|
<li>· 包含森者全部</li>
|
|
<li>· 共享 Grove 口信</li>
|
|
<li>· 单声纹授权账本</li>
|
|
<li>· 工作室级留存策略</li>
|
|
<li>· 现场欢迎仪式</li>
|
|
<li>· 直通创作团队</li>
|
|
</ul>
|
|
<div class="price-card__cta" data-hover>谈一谈</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p style="margin-top: 80px; font-size: 13px; color: var(--ink-mute); max-width: 680px; line-height: 1.85;">
|
|
我们按"声纹"计费,从不按"次"。没有用量上限。没有 enterprise 版本。如果你需要超出 Grove 的安排,写信给我们,我们一起想。
|
|
</p>
|
|
|
|
<div class="page__foot">
|
|
<span>© 2026 Aeria Studio · 增值税号 NO 928 411 552</span>
|
|
<span>价格 · 2026 年 5 月</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CONTACT -->
|
|
<div class="page" id="page-contact">
|
|
<div class="page__inner">
|
|
<div class="page__nav">
|
|
<a class="page__back" href="#" data-back data-hover>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
|
返回林间
|
|
</a>
|
|
<span class="page__crumb">Aeria · Contact</span>
|
|
</div>
|
|
|
|
<div class="page__hero">
|
|
<div class="page__eyebrow">— 轻声说话</div>
|
|
<h1 class="page__title">写信给我们<br/>我们<em>一定</em>回</h1>
|
|
<p class="page__lede">内测申请、合作、媒体邀约,或一封你早就想寄给陌生人的长信。我们三人会读完所有来信。</p>
|
|
</div>
|
|
|
|
<div class="contact-grid">
|
|
<form class="contact-form" id="contact-form">
|
|
<input type="text" placeholder="您的名字" required data-hover />
|
|
<input type="email" placeholder="可以回信的邮箱" required data-hover />
|
|
<input type="text" placeholder="主题(可选)" data-hover />
|
|
<textarea placeholder="想对我们说些什么?" required data-hover></textarea>
|
|
<button type="submit" data-hover>寄出这封信</button>
|
|
</form>
|
|
|
|
<div class="contact-info">
|
|
<h4><em>Studio</em>工作室</h4>
|
|
<p>
|
|
Aeria Studio AS<br/>
|
|
Bryggen 41 · 5003 Bergen<br/>
|
|
挪威
|
|
</p>
|
|
|
|
<h4><em>Direct</em>直接写信</h4>
|
|
<p>
|
|
<a href="mailto:hello@aeria.studio" data-hover>hello@aeria.studio</a><br/>
|
|
<a href="mailto:press@aeria.studio" data-hover>press@aeria.studio</a><br/>
|
|
<a href="mailto:beta@aeria.studio" data-hover>beta@aeria.studio</a>
|
|
</p>
|
|
|
|
<h4><em>Hours</em>工作时段</h4>
|
|
<p>我们在中欧时区周二至周五 09:00–16:00 之间回信。<br/>星期一用来散步。</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="page__foot">
|
|
<span>© 2026 Aeria Studio</span>
|
|
<span>所有来信 · 必复</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ============== THREE.JS ============== -->
|
|
<script type="importmap">
|
|
{
|
|
"imports": {
|
|
"three": "https://unpkg.com/three@0.160.0/build/three.module.js"
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<script type="module">
|
|
import * as THREE from 'three';
|
|
|
|
/* ---------- renderer / scene ---------- */
|
|
const canvas = document.getElementById('scene');
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 1.35;
|
|
|
|
const scene = new THREE.Scene();
|
|
const fogColor = new THREE.Color(0x2a2a20); // 暖灰雾,不是死黑
|
|
scene.background = fogColor;
|
|
scene.fog = new THREE.FogExp2(fogColor.getHex(), 0.0028);
|
|
|
|
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 3000);
|
|
camera.position.set(0, 4.6, 60);
|
|
camera.rotation.order = 'YXZ';
|
|
|
|
/* ---------- gradient sky dome ---------- */
|
|
let skyDome;
|
|
{
|
|
const skyGeo = new THREE.SphereGeometry(2400, 32, 24);
|
|
const skyMat = new THREE.MeshBasicMaterial({ side: THREE.BackSide, fog: false, vertexColors: true });
|
|
const top = new THREE.Color(0x141a18);
|
|
const upper = new THREE.Color(0x2a2c22);
|
|
const horiz = new THREE.Color(0x5a4a30); // 暖橙水平线
|
|
const lower = new THREE.Color(0x1c1e16);
|
|
const cols = [];
|
|
const pos = skyGeo.attributes.position;
|
|
for (let i = 0; i < pos.count; i++) {
|
|
const y = pos.getY(i) / 2400; // -1..1
|
|
let c;
|
|
if (y > 0.4) c = top.clone().lerp(upper, (1 - y)/0.6);
|
|
else if (y > 0.0) c = upper.clone().lerp(horiz, 1 - y/0.4);
|
|
else if (y > -0.3) c = horiz.clone().lerp(lower, -y/0.3);
|
|
else c = lower.clone();
|
|
cols.push(c.r, c.g, c.b);
|
|
}
|
|
skyGeo.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3));
|
|
skyDome = new THREE.Mesh(skyGeo, skyMat);
|
|
scene.add(skyDome);
|
|
}
|
|
|
|
/* ---------- lights ---------- */
|
|
scene.add(new THREE.HemisphereLight(0xd6e0bd, 0x1a1812, 0.95));
|
|
const sun = new THREE.DirectionalLight(0xffe2b0, 1.95);
|
|
sun.position.set(140, 220, -180);
|
|
scene.add(sun);
|
|
|
|
const fill = new THREE.DirectionalLight(0x6da6ff, 0.30);
|
|
fill.position.set(-120, 100, 80);
|
|
scene.add(fill);
|
|
|
|
const rim = new THREE.DirectionalLight(0xffd896, 0.85);
|
|
rim.position.set(0, 30, -300);
|
|
scene.add(rim);
|
|
|
|
/* ---------- ground ---------- */
|
|
const groundCanvas = (() => {
|
|
const c = document.createElement('canvas');
|
|
c.width = c.height = 512;
|
|
const ctx = c.getContext('2d');
|
|
// base
|
|
ctx.fillStyle = '#161c14';
|
|
ctx.fillRect(0,0,512,512);
|
|
// soft mossy patches (slightly lighter dark green)
|
|
for (let i = 0; i < 1400; i++) {
|
|
const x = Math.random()*512, y = Math.random()*512;
|
|
const r = Math.random()*8 + 1;
|
|
const a = Math.random()*0.22;
|
|
ctx.fillStyle = `rgba(${30+Math.random()*50|0},${50+Math.random()*40|0},${30+Math.random()*30|0},${a})`;
|
|
ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill();
|
|
}
|
|
// darker undertones (depth)
|
|
for (let i = 0; i < 200; i++) {
|
|
const x = Math.random()*512, y = Math.random()*512;
|
|
const r = Math.random()*40 + 10;
|
|
const grd = ctx.createRadialGradient(x,y,1,x,y,r);
|
|
grd.addColorStop(0, 'rgba(0,0,0,0.35)');
|
|
grd.addColorStop(1, 'rgba(0,0,0,0)');
|
|
ctx.fillStyle = grd;
|
|
ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill();
|
|
}
|
|
// subtle warm flecks (fallen leaves)
|
|
for (let i = 0; i < 80; i++) {
|
|
const x = Math.random()*512, y = Math.random()*512;
|
|
const r = Math.random()*1.5 + 0.5;
|
|
ctx.fillStyle = `rgba(${130+Math.random()*40|0},${90+Math.random()*30|0},${50+Math.random()*20|0},0.45)`;
|
|
ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill();
|
|
}
|
|
return c;
|
|
})();
|
|
const groundTex = new THREE.CanvasTexture(groundCanvas);
|
|
groundTex.wrapS = groundTex.wrapT = THREE.RepeatWrapping;
|
|
groundTex.repeat.set(40, 40);
|
|
groundTex.colorSpace = THREE.SRGBColorSpace;
|
|
groundTex.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
|
|
|
const ground = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(4000, 4000, 1, 1),
|
|
new THREE.MeshStandardMaterial({
|
|
color: 0x1a2018, map: groundTex, roughness: 1, metalness: 0
|
|
})
|
|
);
|
|
ground.rotation.x = -Math.PI / 2;
|
|
scene.add(ground);
|
|
|
|
/* ---------- bark texture (procedural canvas — birch-like) ---------- */
|
|
const barkCanvas = (() => {
|
|
const c = document.createElement('canvas');
|
|
c.width = 128; c.height = 512;
|
|
const ctx = c.getContext('2d');
|
|
// light cream base (birch-like)
|
|
ctx.fillStyle = '#d8cdb4';
|
|
ctx.fillRect(0,0,128,512);
|
|
// soft vertical streaks (lighter)
|
|
for (let i = 0; i < 60; i++) {
|
|
const x = Math.random()*128;
|
|
const w = 0.5 + Math.random()*1.8;
|
|
const a = 0.06 + Math.random()*0.12;
|
|
ctx.fillStyle = `rgba(255,245,220,${a})`;
|
|
ctx.fillRect(x, 0, w, 512);
|
|
}
|
|
// horizontal scars — iconic birch dark stripes
|
|
for (let i = 0; i < 36; i++) {
|
|
const y = Math.random()*512;
|
|
const h = 0.6 + Math.random()*2.2;
|
|
const a = 0.45 + Math.random()*0.35;
|
|
ctx.fillStyle = `rgba(30,22,15,${a})`;
|
|
ctx.fillRect(0, y, 128, h);
|
|
}
|
|
// dark scar dots (knots)
|
|
for (let i = 0; i < 14; i++) {
|
|
const x = Math.random()*128, y = Math.random()*512;
|
|
const r = 1 + Math.random()*3;
|
|
ctx.fillStyle = `rgba(30,22,15,${0.55+Math.random()*0.3})`;
|
|
ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill();
|
|
}
|
|
// subtle vertical shadow on edges (rounding)
|
|
const grd = ctx.createLinearGradient(0,0,128,0);
|
|
grd.addColorStop(0, 'rgba(40,30,20,0.35)');
|
|
grd.addColorStop(0.5, 'rgba(40,30,20,0)');
|
|
grd.addColorStop(1, 'rgba(40,30,20,0.35)');
|
|
ctx.fillStyle = grd;
|
|
ctx.fillRect(0,0,128,512);
|
|
return c;
|
|
})();
|
|
const barkTex = new THREE.CanvasTexture(barkCanvas);
|
|
barkTex.wrapS = THREE.RepeatWrapping;
|
|
barkTex.wrapT = THREE.RepeatWrapping;
|
|
barkTex.colorSpace = THREE.SRGBColorSpace;
|
|
|
|
/* ---------- forest (instanced trunks) ---------- */
|
|
const TRUNK_COUNT = 620;
|
|
const trunkGeo = new THREE.CylinderGeometry(0.16, 0.28, 24, 8, 1, false);
|
|
trunkGeo.translate(0, 12, 0);
|
|
const trunkMat = new THREE.MeshStandardMaterial({
|
|
color: 0xffffff,
|
|
map: barkTex,
|
|
roughness: 0.94, metalness: 0
|
|
});
|
|
const trunks = new THREE.InstancedMesh(trunkGeo, trunkMat, TRUNK_COUNT);
|
|
trunks.instanceMatrix.setUsage(THREE.StaticDrawUsage);
|
|
|
|
const dummy = new THREE.Object3D();
|
|
const PATH_LENGTH = 2400;
|
|
function rand(seed) { return Math.abs(Math.sin(seed*9301+49297)) % 1; }
|
|
|
|
const colorOpts = [
|
|
new THREE.Color(0xddd4be),
|
|
new THREE.Color(0xc8bca0),
|
|
new THREE.Color(0xe5dcc6),
|
|
new THREE.Color(0xb8a888),
|
|
new THREE.Color(0xd0c4a8),
|
|
];
|
|
|
|
for (let i = 0; i < TRUNK_COUNT; i++) {
|
|
const t = i / TRUNK_COUNT;
|
|
const z = -t * PATH_LENGTH - rand(i)*10;
|
|
const curve = Math.sin(z * 0.0018) * 6;
|
|
let x = (rand(i+9)*2-1) * 90 + curve;
|
|
if (Math.abs(x - curve) < 5.5) {
|
|
x = curve + (x > curve ? 6 + rand(i+22)*5 : -6 - rand(i+22)*5);
|
|
}
|
|
dummy.position.set(x, 0, z);
|
|
dummy.rotation.set((rand(i+3)-0.5)*0.06, rand(i+7)*Math.PI*2, (rand(i+11)-0.5)*0.06);
|
|
const sx = 0.55 + rand(i+5)*1.4;
|
|
const sy = 0.6 + rand(i+13)*1.6;
|
|
dummy.scale.set(sx, sy, sx);
|
|
dummy.updateMatrix();
|
|
trunks.setMatrixAt(i, dummy.matrix);
|
|
trunks.setColorAt(i, colorOpts[i % colorOpts.length].clone().multiplyScalar(0.85 + rand(i+33)*0.4));
|
|
}
|
|
trunks.instanceMatrix.needsUpdate = true;
|
|
if (trunks.instanceColor) trunks.instanceColor.needsUpdate = true;
|
|
scene.add(trunks);
|
|
|
|
/* ---------- canopy (dark blobs above for ceiling feel) ---------- */
|
|
const canopyMat = new THREE.MeshStandardMaterial({
|
|
color: 0x0e160e, roughness: 1, metalness: 0,
|
|
transparent: true, opacity: 0.95
|
|
});
|
|
const canopyGeo = new THREE.IcosahedronGeometry(14, 1);
|
|
const CANOPY_COUNT = 70;
|
|
const canopy = new THREE.InstancedMesh(canopyGeo, canopyMat, CANOPY_COUNT);
|
|
for (let i = 0; i < CANOPY_COUNT; i++) {
|
|
dummy.position.set(
|
|
(rand(i+101)*2-1)*70,
|
|
26 + rand(i+103)*10,
|
|
-i * (PATH_LENGTH / CANOPY_COUNT) - rand(i+105)*40
|
|
);
|
|
const s = 1.8 + rand(i+107)*2.2;
|
|
dummy.scale.set(s*1.5, s*0.7, s*1.5);
|
|
dummy.rotation.set(rand(i)*Math.PI, rand(i+5)*Math.PI, rand(i+9)*Math.PI);
|
|
dummy.updateMatrix();
|
|
canopy.setMatrixAt(i, dummy.matrix);
|
|
}
|
|
scene.add(canopy);
|
|
|
|
/* ---------- ferns / undergrowth (lighter green dots) ---------- */
|
|
const fernCount = 1800;
|
|
const fernGeo = new THREE.BufferGeometry();
|
|
const fernPos = new Float32Array(fernCount*3);
|
|
const fernCol = new Float32Array(fernCount*3);
|
|
for (let i = 0; i < fernCount; i++) {
|
|
fernPos[i*3] = (rand(i+200)*2-1)*70 + Math.sin(-i*4*0.0018)*6;
|
|
fernPos[i*3+1] = 0.25 + rand(i+201)*0.8;
|
|
fernPos[i*3+2] = -rand(i+202)*PATH_LENGTH;
|
|
const g = 0.42 + rand(i+205)*0.28;
|
|
fernCol[i*3] = 0.28 + rand(i+206)*0.18;
|
|
fernCol[i*3+1] = g;
|
|
fernCol[i*3+2] = 0.20;
|
|
}
|
|
fernGeo.setAttribute('position', new THREE.BufferAttribute(fernPos, 3));
|
|
fernGeo.setAttribute('color', new THREE.BufferAttribute(fernCol, 3));
|
|
const fernMat = new THREE.PointsMaterial({
|
|
size: 0.55, sizeAttenuation: true,
|
|
vertexColors: true,
|
|
transparent: true, opacity: 0.95
|
|
});
|
|
scene.add(new THREE.Points(fernGeo, fernMat));
|
|
|
|
/* ---------- rocks scattered along path ---------- */
|
|
const rockGeo = new THREE.DodecahedronGeometry(1, 0);
|
|
const rockMat = new THREE.MeshStandardMaterial({
|
|
color: 0x6c6553, roughness: 0.95, metalness: 0
|
|
});
|
|
const ROCK_COUNT = 90;
|
|
const rocks = new THREE.InstancedMesh(rockGeo, rockMat, ROCK_COUNT);
|
|
for (let i = 0; i < ROCK_COUNT; i++) {
|
|
const z = -rand(i+401)*PATH_LENGTH;
|
|
const curve = Math.sin(z * 0.0018) * 6;
|
|
let x = (rand(i+402)*2-1) * 50 + curve;
|
|
if (Math.abs(x - curve) < 4) x += (x > curve ? 6 : -6);
|
|
const sx = 0.6 + rand(i+403)*1.6;
|
|
dummy.position.set(x, sx*0.4, z);
|
|
dummy.rotation.set(rand(i)*Math.PI, rand(i+1)*Math.PI, rand(i+2)*Math.PI);
|
|
dummy.scale.set(sx, sx*0.7, sx*1.1);
|
|
dummy.updateMatrix();
|
|
rocks.setMatrixAt(i, dummy.matrix);
|
|
const tone = 0.7 + rand(i+404)*0.6;
|
|
rocks.setColorAt(i, new THREE.Color(0x7a7261).multiplyScalar(tone));
|
|
}
|
|
if (rocks.instanceColor) rocks.instanceColor.needsUpdate = true;
|
|
scene.add(rocks);
|
|
|
|
/* ---------- fallen logs ---------- */
|
|
const logGeo = new THREE.CylinderGeometry(0.5, 0.55, 6, 8);
|
|
const logMat = new THREE.MeshStandardMaterial({
|
|
color: 0x5a4a36, map: barkTex, roughness: 0.95, metalness: 0
|
|
});
|
|
const LOG_COUNT = 14;
|
|
const logs = new THREE.InstancedMesh(logGeo, logMat, LOG_COUNT);
|
|
for (let i = 0; i < LOG_COUNT; i++) {
|
|
const z = -200 - rand(i+501) * (PATH_LENGTH-300);
|
|
const curve = Math.sin(z * 0.0018) * 6;
|
|
let x = (rand(i+502)*2-1) * 28 + curve;
|
|
if (Math.abs(x - curve) < 6) x += (x > curve ? 8 : -8);
|
|
dummy.position.set(x, 0.5, z);
|
|
dummy.rotation.set(0, rand(i+503)*Math.PI*2, Math.PI/2);
|
|
const sl = 0.7 + rand(i+504)*1.2;
|
|
dummy.scale.set(sl*0.9, sl, sl*0.9);
|
|
dummy.updateMatrix();
|
|
logs.setMatrixAt(i, dummy.matrix);
|
|
}
|
|
scene.add(logs);
|
|
|
|
/* ---------- distant tree silhouettes (cheap depth layer) ---------- */
|
|
const sillGeo = new THREE.PlaneGeometry(1, 1);
|
|
const sillTex = (() => {
|
|
const c = document.createElement('canvas');
|
|
c.width = 256; c.height = 256;
|
|
const ctx = c.getContext('2d');
|
|
ctx.clearRect(0,0,256,256);
|
|
// draw silhouette tree
|
|
ctx.fillStyle = '#000';
|
|
// trunk
|
|
ctx.fillRect(120, 130, 16, 126);
|
|
// foliage layers
|
|
ctx.beginPath();
|
|
ctx.moveTo(128, 10);
|
|
ctx.lineTo(60, 140);
|
|
ctx.lineTo(196, 140);
|
|
ctx.closePath(); ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.moveTo(128, 50);
|
|
ctx.lineTo(40, 180);
|
|
ctx.lineTo(216, 180);
|
|
ctx.closePath(); ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.moveTo(128, 90);
|
|
ctx.lineTo(20, 220);
|
|
ctx.lineTo(236, 220);
|
|
ctx.closePath(); ctx.fill();
|
|
const t = new THREE.CanvasTexture(c);
|
|
t.colorSpace = THREE.SRGBColorSpace;
|
|
return t;
|
|
})();
|
|
const sillMat = new THREE.MeshBasicMaterial({
|
|
map: sillTex, transparent: true, color: 0x121613, depthWrite: false
|
|
});
|
|
const SILL_COUNT = 40;
|
|
for (let i = 0; i < SILL_COUNT; i++) {
|
|
const m = new THREE.Mesh(sillGeo, sillMat);
|
|
const z = -600 - i * ((PATH_LENGTH-300)/SILL_COUNT) - rand(i+601)*40;
|
|
m.position.set(
|
|
(rand(i+602)*2-1) * 160,
|
|
18,
|
|
z
|
|
);
|
|
const s = 36 + rand(i+603)*36;
|
|
m.scale.set(s*0.8, s, 1);
|
|
scene.add(m);
|
|
}
|
|
|
|
/* ---------- fireflies (warm glowing points) ---------- */
|
|
const FIRE = 80;
|
|
const fireGeo = new THREE.BufferGeometry();
|
|
const firePos = new Float32Array(FIRE*3);
|
|
const fireBase = [];
|
|
for (let i = 0; i < FIRE; i++) {
|
|
const z = -100 - rand(i+701)*PATH_LENGTH;
|
|
const curve = Math.sin(z * 0.0018) * 6;
|
|
firePos[i*3] = (rand(i+702)*2-1)*30 + curve;
|
|
firePos[i*3+1] = 1 + rand(i+703)*5;
|
|
firePos[i*3+2] = z;
|
|
fireBase.push({ y: firePos[i*3+1], phase: rand(i+704)*Math.PI*2 });
|
|
}
|
|
fireGeo.setAttribute('position', new THREE.BufferAttribute(firePos, 3));
|
|
const fireMat = new THREE.PointsMaterial({
|
|
color: 0xffd66b, size: 0.55, sizeAttenuation: true,
|
|
transparent: true, opacity: 0.85,
|
|
blending: THREE.AdditiveBlending, depthWrite: false
|
|
});
|
|
const fireflies = new THREE.Points(fireGeo, fireMat);
|
|
scene.add(fireflies);
|
|
|
|
/* ---------- floating dust / pollen (warm bright) ---------- */
|
|
const DUST = 2400;
|
|
const dustGeo = new THREE.BufferGeometry();
|
|
const dustPos = new Float32Array(DUST*3);
|
|
for (let i = 0; i < DUST; i++) {
|
|
dustPos[i*3] = (Math.random()-0.5)*200;
|
|
dustPos[i*3+1] = Math.random()*32;
|
|
dustPos[i*3+2] = -Math.random()*PATH_LENGTH;
|
|
}
|
|
dustGeo.setAttribute('position', new THREE.BufferAttribute(dustPos, 3));
|
|
const dustMat = new THREE.PointsMaterial({
|
|
color: 0xffe8b8, size: 0.16, sizeAttenuation: true,
|
|
transparent: true, opacity: 0.55,
|
|
blending: THREE.AdditiveBlending, depthWrite: false
|
|
});
|
|
const dust = new THREE.Points(dustGeo, dustMat);
|
|
scene.add(dust);
|
|
|
|
/* ---------- god rays (additive planes) ---------- */
|
|
const rayGroup = new THREE.Group();
|
|
const rayTex = (() => {
|
|
const c = document.createElement('canvas');
|
|
c.width = 64; c.height = 256;
|
|
const ctx = c.getContext('2d');
|
|
const g = ctx.createLinearGradient(0,0,0,256);
|
|
g.addColorStop(0, 'rgba(255,238,190,1)');
|
|
g.addColorStop(0.45, 'rgba(255,228,170,0.28)');
|
|
g.addColorStop(1, 'rgba(255,228,170,0)');
|
|
ctx.fillStyle = g; ctx.fillRect(0,0,64,256);
|
|
const h = ctx.createLinearGradient(0,0,64,0);
|
|
h.addColorStop(0, 'rgba(0,0,0,1)');
|
|
h.addColorStop(0.5, 'rgba(0,0,0,0)');
|
|
h.addColorStop(1, 'rgba(0,0,0,1)');
|
|
ctx.globalCompositeOperation = 'destination-out';
|
|
ctx.fillStyle = h;
|
|
ctx.fillRect(0,0,64,256);
|
|
const tex = new THREE.CanvasTexture(c);
|
|
tex.colorSpace = THREE.SRGBColorSpace;
|
|
return tex;
|
|
})();
|
|
for (let i = 0; i < 30; i++) {
|
|
const m = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(20, 110),
|
|
new THREE.MeshBasicMaterial({
|
|
map: rayTex,
|
|
transparent: true,
|
|
blending: THREE.AdditiveBlending,
|
|
depthWrite: false,
|
|
opacity: 0.32 + Math.random()*0.30,
|
|
side: THREE.DoubleSide,
|
|
fog: false
|
|
})
|
|
);
|
|
m.position.set(
|
|
(Math.random()-0.5)*80,
|
|
36,
|
|
-100 - i * 80 + Math.random()*60
|
|
);
|
|
m.rotation.z = (Math.random()-0.5)*0.55;
|
|
m.rotation.y = (Math.random()-0.5)*0.35;
|
|
rayGroup.add(m);
|
|
}
|
|
scene.add(rayGroup);
|
|
|
|
/* ---------- distant glow (sun behind trees) ---------- */
|
|
const glow = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(900, 900),
|
|
new THREE.MeshBasicMaterial({
|
|
map: (() => {
|
|
const c = document.createElement('canvas');
|
|
c.width = c.height = 256;
|
|
const g = c.getContext('2d').createRadialGradient(128,128,4,128,128,128);
|
|
g.addColorStop(0, 'rgba(255,240,200,1)');
|
|
g.addColorStop(0.35, 'rgba(255,212,160,0.5)');
|
|
g.addColorStop(1, 'rgba(0,0,0,0)');
|
|
const ctx = c.getContext('2d');
|
|
ctx.fillStyle = g; ctx.fillRect(0,0,256,256);
|
|
const t = new THREE.CanvasTexture(c);
|
|
t.colorSpace = THREE.SRGBColorSpace;
|
|
return t;
|
|
})(),
|
|
transparent: true, depthWrite: false,
|
|
blending: THREE.AdditiveBlending
|
|
})
|
|
);
|
|
glow.position.set(60, 100, -PATH_LENGTH - 200);
|
|
scene.add(glow);
|
|
|
|
/* ---------- resize ---------- */
|
|
function resize() {
|
|
const w = window.innerWidth, h = window.innerHeight;
|
|
renderer.setSize(w, h, false);
|
|
camera.aspect = w/h;
|
|
camera.updateProjectionMatrix();
|
|
}
|
|
window.addEventListener('resize', resize);
|
|
resize();
|
|
|
|
/* ---------- input ---------- */
|
|
let progress = 0, progressTarget = 0;
|
|
let mouseX = 0, mouseY = 0;
|
|
let yaw = 0, pitch = 0;
|
|
let yawT = 0, pitchT = 0;
|
|
let interacted = false;
|
|
|
|
window.addEventListener('mousemove', (e) => {
|
|
mouseX = (e.clientX / window.innerWidth - 0.5);
|
|
mouseY = (e.clientY / window.innerHeight - 0.5);
|
|
});
|
|
|
|
const SCROLL_GAIN = 0.00026; // 比之前慢 60%
|
|
const TOUCH_GAIN = 0.00075;
|
|
const KEY_STEP = 0.035;
|
|
const SMOOTH = 0.038; // 相机插值系数(更慢更顺)
|
|
|
|
window.addEventListener('wheel', (e) => {
|
|
if (document.body.classList.contains('page-open')) return;
|
|
progressTarget = Math.max(0, Math.min(1, progressTarget + e.deltaY * SCROLL_GAIN));
|
|
interacted = true;
|
|
}, { passive: true });
|
|
|
|
let touchStart = null;
|
|
window.addEventListener('touchstart', (e) => {
|
|
if (document.body.classList.contains('page-open')) return;
|
|
touchStart = e.touches[0].clientY;
|
|
}, { passive: true });
|
|
window.addEventListener('touchmove', (e) => {
|
|
if (touchStart === null || document.body.classList.contains('page-open')) return;
|
|
const dy = touchStart - e.touches[0].clientY;
|
|
progressTarget = Math.max(0, Math.min(1, progressTarget + dy * TOUCH_GAIN));
|
|
touchStart = e.touches[0].clientY;
|
|
interacted = true;
|
|
}, { passive: true });
|
|
window.addEventListener('touchend', () => { touchStart = null; });
|
|
|
|
window.addEventListener('keydown', (e) => {
|
|
if (document.body.classList.contains('page-open')) return;
|
|
if (e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === ' ') {
|
|
progressTarget = Math.min(1, progressTarget + KEY_STEP);
|
|
interacted = true;
|
|
}
|
|
if (e.key === 'ArrowUp' || e.key === 'PageUp') {
|
|
progressTarget = Math.max(0, progressTarget - KEY_STEP);
|
|
interacted = true;
|
|
}
|
|
});
|
|
|
|
/* ---------- station show/hide ---------- */
|
|
const stationEls = Array.from(document.querySelectorAll('.station'));
|
|
function updateStations() {
|
|
stationEls.forEach((el) => {
|
|
const from = parseFloat(el.dataset.from);
|
|
const to = parseFloat(el.dataset.to);
|
|
el.classList.toggle('active', progress >= from && progress < to);
|
|
});
|
|
}
|
|
|
|
/* ---------- chapter dots ---------- */
|
|
const chapterEls = Array.from(document.querySelectorAll('.chapters__item'));
|
|
chapterEls.forEach((el) => {
|
|
el.addEventListener('click', () => {
|
|
progressTarget = parseFloat(el.dataset.go);
|
|
interacted = true;
|
|
});
|
|
});
|
|
function updateChapters() {
|
|
let activeIdx = 0;
|
|
for (let i = 0; i < chapterEls.length; i++) {
|
|
if (progress >= parseFloat(chapterEls[i].dataset.go) - 0.05) activeIdx = i;
|
|
}
|
|
chapterEls.forEach((el, i) => el.classList.toggle('active', i === activeIdx));
|
|
}
|
|
|
|
/* ---------- progress UI ---------- */
|
|
const progFill = document.getElementById('prog-fill');
|
|
const progNum = document.getElementById('prog-num');
|
|
const hint = document.getElementById('hint');
|
|
|
|
/* ---------- camera path ---------- */
|
|
const startZ = 60, endZ = -PATH_LENGTH + 240;
|
|
function tick() {
|
|
// smoothing
|
|
progress += (progressTarget - progress) * SMOOTH;
|
|
yawT = -mouseX * 0.38;
|
|
pitchT = -mouseY * 0.20;
|
|
yaw += (yawT - yaw) * 0.06;
|
|
pitch += (pitchT - pitch) * 0.06;
|
|
|
|
const z = startZ + (endZ - startZ) * progress;
|
|
camera.position.z = z;
|
|
camera.position.x = Math.sin(z * 0.0018) * 6 * -1;
|
|
camera.position.y = 4.6 + Math.sin(progress * Math.PI * 2.0) * 0.4;
|
|
|
|
camera.rotation.y = yaw;
|
|
camera.rotation.x = pitch;
|
|
|
|
// sky dome follows camera so the gradient is always around the viewer
|
|
skyDome.position.set(camera.position.x, 0, camera.position.z);
|
|
|
|
// dust drift
|
|
const t = performance.now() * 0.0001;
|
|
dust.rotation.y = t * 0.3;
|
|
dust.position.y = Math.sin(t*5) * 0.4;
|
|
|
|
// fireflies bob
|
|
const fp = fireflies.geometry.attributes.position;
|
|
for (let i = 0; i < fireBase.length; i++) {
|
|
fp.array[i*3+1] = fireBase[i].y + Math.sin(t*40 + fireBase[i].phase) * 0.6;
|
|
}
|
|
fp.needsUpdate = true;
|
|
fireflies.material.opacity = 0.65 + Math.sin(t*30) * 0.2;
|
|
|
|
// ray sway
|
|
rayGroup.children.forEach((r, i) => {
|
|
r.rotation.z = Math.sin(t * 5 + i) * 0.08 + (i*0.05-0.5);
|
|
r.material.opacity = 0.30 + Math.sin(t*4 + i*1.3) * 0.12;
|
|
});
|
|
|
|
// UI
|
|
updateStations();
|
|
updateChapters();
|
|
const pct = Math.round(progress*1000);
|
|
progFill.style.width = (progress*100) + '%';
|
|
progNum.textContent = String(pct).padStart(3, '0');
|
|
|
|
if (interacted) hint.classList.add('fade');
|
|
|
|
renderer.render(scene, camera);
|
|
requestAnimationFrame(tick);
|
|
}
|
|
tick();
|
|
|
|
/* ============== UI: cursor ============== */
|
|
const cursor = document.getElementById('cursor');
|
|
let cx = 0, cy = 0, tcx = 0, tcy = 0;
|
|
window.addEventListener('mousemove', (e) => { tcx = e.clientX; tcy = e.clientY; });
|
|
function cursorTick() {
|
|
cx += (tcx - cx) * 0.22;
|
|
cy += (tcy - cy) * 0.22;
|
|
cursor.style.transform = `translate(${cx}px, ${cy}px) translate(-50%, -50%)`;
|
|
requestAnimationFrame(cursorTick);
|
|
}
|
|
cursorTick();
|
|
|
|
document.querySelectorAll('[data-hover]').forEach(el => {
|
|
el.addEventListener('mouseenter', () => cursor.classList.add('hover'));
|
|
el.addEventListener('mouseleave', () => cursor.classList.remove('hover'));
|
|
});
|
|
window.addEventListener('mousedown', () => cursor.classList.add('click'));
|
|
window.addEventListener('mouseup', () => cursor.classList.remove('click'));
|
|
|
|
/* ============== loader ============== */
|
|
const loader = document.getElementById('loader');
|
|
document.getElementById('enter').addEventListener('click', () => {
|
|
loader.classList.add('gone');
|
|
});
|
|
setTimeout(() => loader.classList.add('gone'), 8000);
|
|
|
|
/* ============== sub-page routing ============== */
|
|
const pages = {
|
|
features: document.getElementById('page-features'),
|
|
story: document.getElementById('page-story'),
|
|
pricing: document.getElementById('page-pricing'),
|
|
contact: document.getElementById('page-contact')
|
|
};
|
|
|
|
function openPage(name) {
|
|
Object.values(pages).forEach(p => p.classList.remove('open'));
|
|
if (pages[name]) {
|
|
pages[name].classList.add('open');
|
|
pages[name].scrollTop = 0;
|
|
document.body.classList.add('page-open');
|
|
} else {
|
|
document.body.classList.remove('page-open');
|
|
}
|
|
}
|
|
|
|
function readHash() {
|
|
const h = (location.hash || '').replace('#', '').trim();
|
|
openPage(h);
|
|
}
|
|
window.addEventListener('hashchange', readHash);
|
|
readHash();
|
|
|
|
document.querySelectorAll('[data-back]').forEach(el => {
|
|
el.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
history.pushState(null, '', '#');
|
|
openPage('');
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.cta[href^="#"]').forEach(el => {
|
|
el.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
const target = el.getAttribute('href');
|
|
history.pushState(null, '', target);
|
|
readHash();
|
|
});
|
|
});
|
|
|
|
/* ============== contact form ============== */
|
|
const form = document.getElementById('contact-form');
|
|
if (form) {
|
|
form.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
const btn = form.querySelector('button');
|
|
btn.textContent = '已寄出 · 我们会回信';
|
|
btn.disabled = true;
|
|
btn.style.opacity = '0.6';
|
|
});
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|