Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b3a187835 |
@@ -1,87 +0,0 @@
|
|||||||
# 设计文档:「与湛兮(花名)的数字思维体对话」作品集重设计
|
|
||||||
|
|
||||||
日期:2026-06-11
|
|
||||||
状态:已批准
|
|
||||||
备份:`../wang-yuanyou-fluid-portfolio-backup-20260611-110154/`
|
|
||||||
|
|
||||||
## 概念
|
|
||||||
|
|
||||||
整站是访客与「湛兮(花名)的 AI 分身」的一场对话记录。简历核心(最近一年做 SSE 流式对话、打字机渲染、思考态动画)即设计语言来源——网站本身就是其工作成果的活演示。3D 流体 =「思维体」,是画面中唯一的角色。
|
|
||||||
|
|
||||||
决策记录:
|
|
||||||
|
|
||||||
- 设计隐喻:AI 对话流(vs 职业河流 / 跨端粒子宇宙)
|
|
||||||
- 推进方式:滚动驱动的对话(vs 真·对话界面 / 滚动+问答入口)
|
|
||||||
- 流体形态:单一思维流体 + 三状态状态机(vs raymarched metaballs / GPGPU 粒子流)
|
|
||||||
- 视觉基调:深色 AI 实验室(vs 浅色编辑风 / 双主题)
|
|
||||||
- 技术路线:原生 Three.js 自写 shader + GSAP ScrollTrigger + Lenis(vs 纯手写 / R3F)
|
|
||||||
|
|
||||||
## 信息架构:7 轮问答
|
|
||||||
|
|
||||||
| # | 访客提问 | 回答内容 | 数据源 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 0 | —(开机自检) | 「你好,我是湛兮(花名)。」+ 身份一行 + 6 指标 token 式吐出 | metrics |
|
|
||||||
| 1 | 先介绍一下你自己? | 4 条概述逐行流式输出 | resumeSignals |
|
|
||||||
| 2 | 最近一年具体在做什么? | 4 张焦点卡依次「生成」 | focusAreas |
|
|
||||||
| 3 | 有实际项目证明吗? | 4 个项目,各为一轮子对话:项目名打字机 → 模块逐条吐出 | projects |
|
|
||||||
| 4 | 技能栈展开讲讲? | 7 组技能标签 token 流喷发归位 | skills |
|
|
||||||
| 5 | 之前的团队经历? | 3 段经历沿垂直对话流时间线生成 | experiences |
|
|
||||||
| 6 | 怎么联系你? | 联系方式 + 流体归于平静,「对话已保存」收尾 | — |
|
|
||||||
|
|
||||||
页面右侧(移动端顶部)设「会话进度轨」:7 节点对应 7 轮,可点击跳转,保证 HR 快速扫读。
|
|
||||||
|
|
||||||
## 思维流体(Three.js 自写 shader)
|
|
||||||
|
|
||||||
- 几何:高细分 IcosahedronGeometry;顶点着色器 3 层 FBM simplex 噪声液态位移
|
|
||||||
- 片元:Fresnel 边缘辉光 + 双色渐变(青 #22d3ee ↔ 紫 #a78bfa)+ 加性内核光晕
|
|
||||||
- 伴生粒子:约 2000 GPU 粒子,curl noise 流场
|
|
||||||
- 状态机(uniform 插值过渡):
|
|
||||||
- `idle`:低频低幅呼吸,粒子懒散环绕
|
|
||||||
- `thinking`:收缩 0.85x,噪声频率 ×3 搅动,色温升高,粒子吸入
|
|
||||||
- `answering`:回弹 1.1x 归位,粒子向内容区喷发消散,辉光脉冲与文字生成同步
|
|
||||||
- 流体位置随轮次左右缓移(lerp)与内容互让;鼠标轻微视差
|
|
||||||
|
|
||||||
## 非线性动画编排(GSAP ScrollTrigger + Lenis)
|
|
||||||
|
|
||||||
每轮问答为一个 pin 区段,滚动量映射轮内时间轴:
|
|
||||||
|
|
||||||
1. 提问(0→15%):访客气泡逐字打出,steps() 离散节奏
|
|
||||||
2. 思考(15→35%):流体 thinking,内容区仅 shimmer 占位——刻意停顿即非线性核心
|
|
||||||
3. 爆发生成(35→75%):expo.out 爆发;标题打字机、卡片不等间隔 stagger(模拟 token 不均匀到达)、数字滚动跳变
|
|
||||||
4. 余韵(75→100%):流体回 idle,内容微视差上浮,解除 pin
|
|
||||||
|
|
||||||
每轮思考时长 / 爆发曲线 / stagger 间隔均不同(场景配置驱动),避免节奏雷同。向上滚动时时间轴反播,内容「被收回」。
|
|
||||||
|
|
||||||
降级:`prefers-reduced-motion` → 关 pin 与打字机、内容直出、流体仅呼吸;移动端粒子减半、DPR ≤ 2、细分降档。
|
|
||||||
|
|
||||||
## 视觉系统
|
|
||||||
|
|
||||||
- 背景 #070b14 近黑蓝 + 极淡网格点阵 + 流体环境光溢出
|
|
||||||
- 强调色:青 #22d3ee(访客/交互)、紫 #a78bfa(思维体/回答)、琥珀 #fbbf24(仅指标数字)
|
|
||||||
- 气泡语言:提问 = 右对齐细边框气泡;回答 = 无框流式文本块 + 左侧渐变「生成光标」竖线
|
|
||||||
- 字体:等宽(JetBrains Mono / 思源等宽回退)用于指标、token、技能标签;正文 Inter + 思源黑体
|
|
||||||
- 细节:回答块尾闪烁光标 ▋;项目 logo 复用 public/logos/
|
|
||||||
|
|
||||||
## 文件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
main.js # 入口:装配 + Lenis + 进度轨
|
|
||||||
resume-data.js # 不动(数据即简历)
|
|
||||||
dialogue.js # 7 轮问答文案与节奏配置
|
|
||||||
render.js # 由 resume-data 生成各轮 DOM
|
|
||||||
choreography.js # GSAP ScrollTrigger 时间轴编排
|
|
||||||
mind/
|
|
||||||
mind.js # 流体场景、状态机、粒子
|
|
||||||
shaders.js # GLSL
|
|
||||||
styles.css # 重写
|
|
||||||
index.html # 重写骨架
|
|
||||||
```
|
|
||||||
|
|
||||||
新依赖:gsap、lenis。重写 main.js / styles.css / index.html(旧版已整目录备份)。
|
|
||||||
|
|
||||||
## 性能与验收
|
|
||||||
|
|
||||||
- 单 canvas 固定底层;rAF 与 GSAP ticker 合并;目标桌面 60fps、移动 30fps+
|
|
||||||
- 文字全部真实 DOM(可选中/可索引),打字机仅控制 reveal;进度轨 + 锚点保证可跳转
|
|
||||||
- 验收:7 轮完整走查(含回滚反播)、375px 移动端、reduced-motion 降级、Lighthouse 不低于现版本
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# 「数字思维体对话」作品集重设计 实现计划
|
|
||||||
|
|
||||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
||||||
|
|
||||||
**Goal:** 将现有作品集重写为「与湛兮(花名)的 AI 分身对话」式单页站点:滚动驱动的 7 轮问答 + shader 思维流体状态机。
|
|
||||||
|
|
||||||
**Architecture:** Vite 原生 JS。`resume-data.js` 不动;`dialogue.js` 定义 7 轮问答节奏配置;`render.js` 生成 DOM;`mind/` 为 Three.js shader 流体(idle/thinking/answering 状态机 + GPU 粒子);`choreography.js` 用 GSAP ScrollTrigger pin+scrub 编排每轮「提问→思考→爆发生成→余韵」;Lenis 平滑滚动。
|
|
||||||
|
|
||||||
**Tech Stack:** Vite 7, Three.js 0.184, GSAP ScrollTrigger, Lenis, 自写 GLSL(FBM simplex + Fresnel)。
|
|
||||||
|
|
||||||
**设计文档:** `docs/plans/2026-06-11-ai-dialogue-redesign-design.md`(已批准)
|
|
||||||
**备份:** `../wang-yuanyou-fluid-portfolio-backup-20260611-110154/`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: 依赖安装
|
|
||||||
- `pnpm add gsap lenis`
|
|
||||||
- 验证: package.json 出现两依赖,`pnpm dev` 可启动。
|
|
||||||
|
|
||||||
### Task 2: `src/dialogue.js` — 轮次配置
|
|
||||||
- 7 轮:boot / intro(resumeSignals) / focus(focusAreas) / projects / skills / experience / contact。
|
|
||||||
- 每轮字段:id、label(进度轨)、question、type、side(流体停靠 -1/1)、palette(双色)、pinLength、qEnd/thinkEnd(相位)、staggerEach——逐轮不同以保证节奏差异。
|
|
||||||
- 联系信息常量:邮箱 419021733@qq.com / wmagmgema521@gmail.com、电话 19980439383、GitHub zhanBoss、男·29岁·电子科技大学 信息管理与信息系统 本科。
|
|
||||||
|
|
||||||
### Task 3: `index.html` — 骨架重写
|
|
||||||
- canvas#mind-canvas(fixed 底层)、.grid-overlay、header.hud(左:姓名/DIGITAL MIND;右:● ONLINE 状态灯)、nav#session-rail、main#dialogue(空,由 render.js 填充)、Google Fonts(JetBrains Mono + Inter, display=swap)。
|
|
||||||
|
|
||||||
### Task 4: `src/styles.css` — 深色 AI 实验室
|
|
||||||
- 背景 #070b14、点阵网格 overlay、青 #22d3ee / 紫 #a78bfa / 琥珀 #fbbf24。
|
|
||||||
- 气泡语言:.q-bubble 右对齐细边框;.answer 左侧渐变生成竖线 + 尾部 ▋ 闪烁光标;token/指标/技能用等宽字体。
|
|
||||||
- 各轮内容样式:metrics token、focus 卡、project 块、skill 标签云、timeline、contact。
|
|
||||||
- 进度轨右侧固定(移动端转顶部水平);≤768px 移动端布局;prefers-reduced-motion 关闭闪烁。
|
|
||||||
|
|
||||||
### Task 5: `src/render.js` — DOM 生成
|
|
||||||
- 每轮 section.round 结构:q-bubble(空文本,编排时打字)→ .thinking(三点 shimmer)→ .answer(.gen 子项供 stagger)。
|
|
||||||
- 按 type 分发渲染器,全部读 resume-data.js;boot 轮含问候打字行 + 身份行 + metrics token。
|
|
||||||
- 文案全部真实 DOM(初始由 CSS/GSAP 隐藏,reduced-motion 时直接可见)。
|
|
||||||
|
|
||||||
### Task 6: `src/mind/shaders.js` + `src/mind/mind.js` — 思维流体
|
|
||||||
- 顶点:Ashima simplex 3D + 3 octave FBM 位移;片元:Fresnel 辉光 + uColorA/uColorB 渐变 + uHeat 色温 + AdditiveBlending。
|
|
||||||
- 粒子:~2000(移动端 900),属性 seed/radius/speed/phase,轨道半径受 uAttract(吸入)与 uBurst(喷发,setState('answering') 时置 1 后衰减)控制,全 GPU。
|
|
||||||
- 状态机 STATES{idle, thinking, answering},每帧 lerp uniforms;setSide(dir) 左右停靠(移动端居中缩小);setPalette 双色渐变;指针视差;DPR≤2;resize。
|
|
||||||
|
|
||||||
### Task 7: `src/choreography.js` — 滚动编排
|
|
||||||
- Lenis + ScrollTrigger 集成(gsap.ticker 驱动)。
|
|
||||||
- boot 轮:页面加载后时间驱动的开场时间轴(非滚动)。
|
|
||||||
- 轮 1-6:pin + scrub 时间轴,相位 0→qEnd 打字(steps 离散)→thinkEnd 思考 shimmer→0.78 爆发 stagger(expo.out,不等间隔)→1 余韵视差;onUpdate 按相位切 mind 状态;enter/enterBack 切 side+palette。
|
|
||||||
- 进度轨高亮与 lenis.scrollTo 跳转。
|
|
||||||
- reduced-motion:跳过 pin/打字机,内容直出。
|
|
||||||
|
|
||||||
### Task 8: `src/main.js` — 装配
|
|
||||||
- 渲染 DOM → 建 rail → createMind → choreography → boot 开场。
|
|
||||||
|
|
||||||
### Task 9: 验证
|
|
||||||
- `pnpm dev` + Playwright:桌面 1440px 走查 7 轮(截图 hero/中段/尾段)、向上回滚反播、375px 移动端、console 无错误。
|
|
||||||
- `pnpm build` 成功。
|
|
||||||
+176
-22
@@ -1,41 +1,195 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>湛兮(花名) · 数字思维体 / Digital Mind</title>
|
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="湛兮(花名) — 7 年 Web 与跨端前端工程师。一场与他的数字思维体的对话:AI 对话链路、控制台业务、跨端内容流与工程实践。"
|
content="王元有个人网站,前端工程师,7 年 Web 与跨端经验,近一年参与 SeaBuzz、SeaCloud 等 AI 产品前端,覆盖对话、内容流、计费、权限和国际化。"
|
||||||
/>
|
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
||||||
<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@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
/>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const saved = localStorage.getItem("wy-fluid-theme");
|
||||||
|
if (saved) document.documentElement.dataset.theme = saved;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
background: #07080d;
|
||||||
|
color: #f5f7fb;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] {
|
||||||
|
background: #f2f5f7;
|
||||||
|
color: #11131a;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: inherit;
|
||||||
|
color: inherit;
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
"Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<link rel="stylesheet" href="/src/styles.css" />
|
<link rel="stylesheet" href="/src/styles.css" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<title>王元有 - 前端工程师</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="mind-canvas" aria-hidden="true"></canvas>
|
<div class="progress" id="scroll-progress"></div>
|
||||||
<div class="grid-overlay" aria-hidden="true"></div>
|
<canvas id="fluid-canvas" aria-hidden="true"></canvas>
|
||||||
|
<div class="scene-backdrop" id="scene-backdrop" aria-hidden="true"></div>
|
||||||
|
<div class="grain" aria-hidden="true"></div>
|
||||||
|
|
||||||
<header class="hud">
|
<header class="site-header">
|
||||||
<div class="hud-id">
|
<a class="brand-mark" href="#top" aria-label="王元有个人网站首页">
|
||||||
<span class="hud-name">ZHAN XI</span>
|
<span>WY</span>
|
||||||
<span class="hud-sub">DIGITAL MIND · v7.0</span>
|
<strong>王元有</strong>
|
||||||
</div>
|
</a>
|
||||||
<div class="hud-status">
|
<nav class="site-nav" aria-label="页面导航">
|
||||||
<span class="status-dot" aria-hidden="true"></span>
|
<a href="#ai-fit">方向</a>
|
||||||
<span class="status-text">ONLINE</span>
|
<a href="#proof">证据</a>
|
||||||
|
<a href="#projects">项目</a>
|
||||||
|
<a href="#skills">能力</a>
|
||||||
|
<a href="#experience">经历</a>
|
||||||
|
<a href="#contact">联系</a>
|
||||||
|
</nav>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a class="icon-link" href="https://github.com/zhanBoss" target="_blank" rel="noreferrer" aria-label="GitHub">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 2C6.48 2 2 6.58 2 12.23c0 4.52 2.87 8.35 6.84 9.7.5.1.68-.22.68-.5v-1.75c-2.78.62-3.37-1.37-3.37-1.37-.45-1.19-1.11-1.5-1.11-1.5-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.9 1.56 2.35 1.11 2.92.85.09-.66.35-1.11.63-1.37-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.71 0 0 .84-.28 2.75 1.05A9.35 9.35 0 0 1 12 6.9c.85 0 1.71.12 2.51.34 1.91-1.33 2.75-1.05 2.75-1.05.55 1.41.2 2.45.1 2.71.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.81-4.57 5.06.36.32.68.94.68 1.9v2.82c0 .28.18.6.69.5A10.08 10.08 0 0 0 22 12.23C22 6.58 17.52 2 12 2Z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<button class="icon-link" id="theme-toggle" type="button" aria-label="切换明暗主题">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path fill="currentColor" d="M12 2a10 10 0 1 0 0 20V2Zm2 2.29A8 8 0 0 1 14 19.71V4.29Z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav id="session-rail" aria-label="会话进度"></nav>
|
<main id="top">
|
||||||
|
<section class="hero" aria-labelledby="hero-title">
|
||||||
|
<div class="hero-meta reveal" data-drift="-24">
|
||||||
|
<p class="eyebrow">Frontend Engineer / Product UI / Cross-platform</p>
|
||||||
|
<h1 id="hero-title">王元有</h1>
|
||||||
|
<p class="hero-subtitle">前端工程师,近一年在 AI 产品团队做前端</p>
|
||||||
|
<p class="hero-lede">
|
||||||
|
做过 B 端供应链系统、小程序、微前端平台,也参与过 SeaBuzz 和 SeaCloud 这类 AI 产品。现在更擅长把对话流、
|
||||||
|
内容流、计费、权限、国际化这些复杂链路整理成稳定的前端界面。
|
||||||
|
</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a class="button primary" href="#projects">看项目经历</a>
|
||||||
|
<a class="button" href="mailto:419021733@qq.com">419021733@qq.com</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main id="dialogue"></main>
|
<div class="signal-board reveal" data-drift="28" aria-label="候选人摘要">
|
||||||
|
<div>
|
||||||
|
<span>前端经验</span>
|
||||||
|
<strong>7 年</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>近一年方向</span>
|
||||||
|
<strong>对话与控制台</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>主要场景</span>
|
||||||
|
<strong>对话 / 内容 / 控制台</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>协作方式</span>
|
||||||
|
<strong>能独立推进模块</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-panel reveal" data-drift="20" aria-label="工作方式摘要">
|
||||||
|
<div>
|
||||||
|
<span>最近职责</span>
|
||||||
|
<strong>SeaBuzz 跨端应用、SeaCloud 控制台</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>更擅长</span>
|
||||||
|
<strong>把复杂状态、权限和数据边界梳理清楚</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>常用技术</span>
|
||||||
|
<strong>React / Next.js / React Native / TypeScript</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>工作习惯</span>
|
||||||
|
<strong>先对齐业务边界,再拆组件和状态模型</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="ai-fit" class="ai-fit-section reveal" data-drift="36" aria-labelledby="ai-fit-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<p>Current Direction</p>
|
||||||
|
<h2 id="ai-fit-title">AI 是最近一年的工作场景,不是拿来堆关键词的标签。</h2>
|
||||||
|
</div>
|
||||||
|
<div class="ai-focus-grid" id="ai-focus-grid"></div>
|
||||||
|
<div class="resume-signal-panel reveal" data-drift="-30">
|
||||||
|
<div>
|
||||||
|
<p>Resume Summary</p>
|
||||||
|
<h3>前端工程师|AI 产品方向|Web / React Native</h3>
|
||||||
|
</div>
|
||||||
|
<ul id="resume-signal-list"></ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="proof" class="proof-strip reveal" data-drift="-40" aria-labelledby="proof-title">
|
||||||
|
<div class="section-kicker">Evidence</div>
|
||||||
|
<h2 id="proof-title">一些可以核对的经历,先放在前面。</h2>
|
||||||
|
<div class="metric-row" id="metric-row"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="projects" class="work-section" aria-labelledby="projects-title">
|
||||||
|
<div class="section-heading reveal" data-drift="42">
|
||||||
|
<p>Selected Work</p>
|
||||||
|
<h2 id="projects-title">最近两段经历和 AI 产品相关,但履历底子仍然是前端工程交付。</h2>
|
||||||
|
</div>
|
||||||
|
<div class="project-stack" id="project-stack"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="skills" class="skills-section" aria-labelledby="skills-title">
|
||||||
|
<div class="section-heading reveal" data-drift="-42">
|
||||||
|
<p>Capabilities</p>
|
||||||
|
<h2 id="skills-title">按实际做过的工作来组织技能,而不是按热门词来分类。</h2>
|
||||||
|
</div>
|
||||||
|
<div class="skill-matrix" id="skill-matrix"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="experience" class="experience-section" aria-labelledby="experience-title">
|
||||||
|
<div class="section-heading reveal" data-drift="34">
|
||||||
|
<p>Experience</p>
|
||||||
|
<h2 id="experience-title">职责从核心交付演进到架构设计和团队规范建设。</h2>
|
||||||
|
</div>
|
||||||
|
<div class="timeline" id="timeline"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="contact" class="contact-section reveal" data-drift="-26" aria-labelledby="contact-title">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker">Contact</p>
|
||||||
|
<h2 id="contact-title">正在看前端、跨端、AI 产品前端相关机会。</h2>
|
||||||
|
<span>男 · 29 岁 · 19980439383 · 电子科技大学 信息管理与信息系统 本科</span>
|
||||||
|
</div>
|
||||||
|
<div class="contact-actions">
|
||||||
|
<a class="button primary" href="mailto:419021733@qq.com">发送邮件</a>
|
||||||
|
<a class="button" href="mailto:wmagmgema521@gmail.com">wmagmgema521@gmail.com</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>© 2026 王元有. Built as an independent fluid portfolio.</footer>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
+2
-40
@@ -8,11 +8,10 @@
|
|||||||
"name": "wang-yuanyou-fluid-portfolio",
|
"name": "wang-yuanyou-fluid-portfolio",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"gsap": "^3.15.0",
|
|
||||||
"lenis": "^1.3.23",
|
|
||||||
"three": "^0.184.0",
|
"three": "^0.184.0",
|
||||||
"vite": "^7.2.7"
|
"vite": "^7.2.7"
|
||||||
}
|
},
|
||||||
|
"devDependencies": {}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.7",
|
"version": "0.27.7",
|
||||||
@@ -833,43 +832,6 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/gsap": {
|
|
||||||
"version": "3.15.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
|
|
||||||
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
|
|
||||||
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
|
||||||
},
|
|
||||||
"node_modules/lenis": {
|
|
||||||
"version": "1.3.23",
|
|
||||||
"resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz",
|
|
||||||
"integrity": "sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
"packages/*",
|
|
||||||
"playground",
|
|
||||||
"playground/*"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/darkroomengineering"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@nuxt/kit": ">=3.0.0",
|
|
||||||
"react": ">=17.0.0",
|
|
||||||
"vue": ">=3.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@nuxt/kit": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"vue": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.12",
|
"version": "3.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||||
|
|||||||
+2
-5
@@ -6,14 +6,11 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:test": "PUBLIC_APP_ENV_LABEL=测试环境 vite build",
|
|
||||||
"build:prod": "PUBLIC_APP_ENV_LABEL=生产环境 vite build",
|
|
||||||
"preview": "vite preview --host 0.0.0.0"
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"gsap": "^3.15.0",
|
|
||||||
"lenis": "^1.3.23",
|
|
||||||
"three": "^0.184.0",
|
"three": "^0.184.0",
|
||||||
"vite": "^7.2.7"
|
"vite": "^7.2.7"
|
||||||
}
|
},
|
||||||
|
"devDependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-27
@@ -8,12 +8,6 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
gsap:
|
|
||||||
specifier: ^3.15.0
|
|
||||||
version: 3.15.0
|
|
||||||
lenis:
|
|
||||||
specifier: ^1.3.23
|
|
||||||
version: 1.3.23
|
|
||||||
three:
|
three:
|
||||||
specifier: ^0.184.0
|
specifier: ^0.184.0
|
||||||
version: 0.184.0
|
version: 0.184.0
|
||||||
@@ -326,23 +320,6 @@ packages:
|
|||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
gsap@3.15.0:
|
|
||||||
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
|
|
||||||
|
|
||||||
lenis@1.3.23:
|
|
||||||
resolution: {integrity: sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==}
|
|
||||||
peerDependencies:
|
|
||||||
'@nuxt/kit': '>=3.0.0'
|
|
||||||
react: '>=17.0.0'
|
|
||||||
vue: '>=3.0.0'
|
|
||||||
peerDependenciesMeta:
|
|
||||||
'@nuxt/kit':
|
|
||||||
optional: true
|
|
||||||
react:
|
|
||||||
optional: true
|
|
||||||
vue:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
nanoid@3.3.12:
|
nanoid@3.3.12:
|
||||||
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
|
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
@@ -608,10 +585,6 @@ snapshots:
|
|||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
gsap@3.15.0: {}
|
|
||||||
|
|
||||||
lenis@1.3.23: {}
|
|
||||||
|
|
||||||
nanoid@3.3.12: {}
|
nanoid@3.3.12: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|||||||
@@ -1,301 +0,0 @@
|
|||||||
import gsap from "gsap";
|
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
||||||
import Lenis from "lenis";
|
|
||||||
import { rounds } from "./dialogue";
|
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
|
||||||
|
|
||||||
/* Lenis 平滑滚动 + ScrollTrigger 集成 */
|
|
||||||
export function initSmoothScroll() {
|
|
||||||
const lenis = new Lenis({ lerp: 0.1 });
|
|
||||||
lenis.on("scroll", ScrollTrigger.update);
|
|
||||||
gsap.ticker.add((time) => lenis.raf(time * 1000));
|
|
||||||
gsap.ticker.lagSmoothing(0);
|
|
||||||
return lenis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 打字机:时间轴上的离散字符 reveal(时间驱动,进入视口后完整播放) */
|
|
||||||
function addTypewriter(tl, textEl, caretEl, fullText, start, duration) {
|
|
||||||
const proxy = { p: 0 };
|
|
||||||
tl.to(
|
|
||||||
proxy,
|
|
||||||
{
|
|
||||||
p: 1,
|
|
||||||
duration,
|
|
||||||
ease: `steps(${fullText.length})`,
|
|
||||||
onUpdate() {
|
|
||||||
const n = Math.round(proxy.p * fullText.length);
|
|
||||||
textEl.textContent = fullText.slice(0, n);
|
|
||||||
if (caretEl) caretEl.style.opacity = proxy.p >= 1 ? "0" : "1";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
start
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 根据文案长度推导打字时长,保持各轮节奏差异 */
|
|
||||||
const typingDuration = (text) => Math.min(1.5, 0.34 + text.length * 0.07);
|
|
||||||
|
|
||||||
/* 开场(时间驱动,非滚动)*/
|
|
||||||
export function playBoot(mind) {
|
|
||||||
const boot = document.querySelector("#r-boot");
|
|
||||||
const typedLine = boot.querySelector(".typed-line");
|
|
||||||
const items = boot.querySelectorAll(".gen");
|
|
||||||
gsap.set(boot.querySelector(".answer"), { opacity: 1 });
|
|
||||||
gsap.set(items, { y: 26, autoAlpha: 0 });
|
|
||||||
|
|
||||||
const text = "这是我的数字思维体,向它提问即可。";
|
|
||||||
const proxy = { p: 0 };
|
|
||||||
const tl = gsap.timeline({ delay: 0.3 });
|
|
||||||
|
|
||||||
tl.call(() => mind.setState("thinking"))
|
|
||||||
.to(proxy, {
|
|
||||||
p: 1,
|
|
||||||
duration: 1.4,
|
|
||||||
ease: `steps(${text.length})`,
|
|
||||||
onUpdate: () => {
|
|
||||||
typedLine.textContent = text.slice(0, Math.round(proxy.p * text.length));
|
|
||||||
},
|
|
||||||
}, 0.5)
|
|
||||||
.call(() => mind.setState("answering"), null, 1.9)
|
|
||||||
.to(items, {
|
|
||||||
y: 0,
|
|
||||||
autoAlpha: 1,
|
|
||||||
duration: 0.7,
|
|
||||||
ease: "expo.out",
|
|
||||||
stagger: { each: 0.09, from: "start" },
|
|
||||||
}, 2.0)
|
|
||||||
.call(() => mind.setState("idle"), null, 3.4);
|
|
||||||
return tl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 单轮问答时间轴:提问打字 → 思考 → 回答爆发生成(一次性播放) */
|
|
||||||
function buildRoundTimeline({ round, qText, qCaret, thinking, answer, items, mind }) {
|
|
||||||
const typeDur = typingDuration(round.question);
|
|
||||||
const tl = gsap.timeline({ paused: true });
|
|
||||||
|
|
||||||
// 1. 提问打字
|
|
||||||
addTypewriter(tl, qText, qCaret, round.question, 0, typeDur);
|
|
||||||
// 2. 思考 shimmer(刻意停顿 = 非线性蓄力)
|
|
||||||
tl.call(() => mind.setState("thinking"), null, typeDur)
|
|
||||||
.to(thinking, { opacity: 1, duration: 0.22 }, typeDur)
|
|
||||||
.to({}, { duration: 0.5 })
|
|
||||||
.to(thinking, { opacity: 0, duration: 0.18 })
|
|
||||||
// 3. 爆发生成:不等间隔 stagger 模拟 token 到达
|
|
||||||
.call(() => mind.setState("answering"))
|
|
||||||
.to(answer, { opacity: 1, duration: 0.2 }, "<")
|
|
||||||
.to(
|
|
||||||
items,
|
|
||||||
{
|
|
||||||
y: 0,
|
|
||||||
autoAlpha: 1,
|
|
||||||
duration: 0.7,
|
|
||||||
ease: "expo.out",
|
|
||||||
stagger: { each: Math.max(0.05, round.staggerEach), from: "start" },
|
|
||||||
},
|
|
||||||
"<0.05"
|
|
||||||
)
|
|
||||||
.call(() => mind.setState("idle"), null, "+=0.4");
|
|
||||||
return tl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 轮次编排:进入视口即播放(不再依赖滚动 scrub,修复"不滚动则不加载") */
|
|
||||||
export function initChoreography(mind, setActive = () => {}) {
|
|
||||||
rounds
|
|
||||||
.filter((r) => r.question)
|
|
||||||
.forEach((round) => {
|
|
||||||
const section = document.getElementById(`r-${round.id}`);
|
|
||||||
const qText = section.querySelector(".q-text");
|
|
||||||
const qCaret = section.querySelector(".q-caret");
|
|
||||||
const thinking = section.querySelector(".thinking");
|
|
||||||
const answer = section.querySelector(".answer");
|
|
||||||
const items = section.querySelectorAll(".gen");
|
|
||||||
|
|
||||||
gsap.set(items, { y: 34, autoAlpha: 0 });
|
|
||||||
|
|
||||||
if (round.flow) {
|
|
||||||
buildFlowRound({ round, section, qText, qCaret, thinking, answer, items, mind, setActive });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tl = buildRoundTimeline({ round, qText, qCaret, thinking, answer, items, mind });
|
|
||||||
|
|
||||||
ScrollTrigger.create({
|
|
||||||
trigger: section,
|
|
||||||
start: "top 60%",
|
|
||||||
end: "bottom 40%",
|
|
||||||
onToggle(self) {
|
|
||||||
if (self.isActive) setActive(round.id);
|
|
||||||
},
|
|
||||||
onEnter: () => {
|
|
||||||
mind.setSide(round.side);
|
|
||||||
mind.setPalette(round.palette);
|
|
||||||
tl.play();
|
|
||||||
},
|
|
||||||
onEnterBack: () => {
|
|
||||||
mind.setSide(round.side);
|
|
||||||
mind.setPalette(round.palette);
|
|
||||||
},
|
|
||||||
onLeave: () => mind.setState("idle"),
|
|
||||||
onLeaveBack: () => mind.setState("idle"),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 长内容轮:提问/思考时间驱动一次播放,内容块各自进入视口时「生成」 */
|
|
||||||
function buildFlowRound({ round, section, qText, qCaret, thinking, answer, items, mind, setActive }) {
|
|
||||||
gsap.set(answer, { opacity: 1 });
|
|
||||||
|
|
||||||
const typeDur = typingDuration(round.question);
|
|
||||||
const qTl = gsap.timeline({ paused: true });
|
|
||||||
addTypewriter(qTl, qText, qCaret, round.question, 0, typeDur);
|
|
||||||
qTl.to(thinking, { opacity: 1, duration: 0.2 }, typeDur)
|
|
||||||
.to(thinking, { opacity: 0, duration: 0.2 }, "+=0.5");
|
|
||||||
|
|
||||||
ScrollTrigger.create({
|
|
||||||
trigger: section,
|
|
||||||
start: "top 70%",
|
|
||||||
once: true,
|
|
||||||
onEnter: () => qTl.play(),
|
|
||||||
});
|
|
||||||
|
|
||||||
items.forEach((item, i) => {
|
|
||||||
gsap.to(item, {
|
|
||||||
y: 0,
|
|
||||||
autoAlpha: 1,
|
|
||||||
duration: 0.8,
|
|
||||||
delay: (i % 3) * 0.09, // 不等间隔,模拟 token 到达
|
|
||||||
ease: "expo.out",
|
|
||||||
scrollTrigger: { trigger: item, start: "top 88%" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
ScrollTrigger.create({
|
|
||||||
trigger: section,
|
|
||||||
start: "top 60%",
|
|
||||||
end: "bottom 55%",
|
|
||||||
onToggle(self) {
|
|
||||||
if (self.isActive) {
|
|
||||||
setActive(round.id);
|
|
||||||
mind.setSide(round.side);
|
|
||||||
mind.setPalette(round.palette);
|
|
||||||
} else {
|
|
||||||
mind.setState("idle");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onUpdate(self) {
|
|
||||||
mind.setState(self.progress < 0.1 ? "thinking" : "answering");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 进度轨:高亮由编排回调驱动,这里只负责节点与跳转 */
|
|
||||||
export function initRail(lenis) {
|
|
||||||
const setActive = (id) => {
|
|
||||||
document.querySelectorAll(".rail-node").forEach((n) => {
|
|
||||||
n.classList.toggle("active", n.dataset.round === id);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
ScrollTrigger.create({
|
|
||||||
trigger: "#r-boot",
|
|
||||||
start: "top top",
|
|
||||||
end: "bottom 40%",
|
|
||||||
onToggle(self) {
|
|
||||||
if (self.isActive) setActive("boot");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const jump = (id) => {
|
|
||||||
const target = document.getElementById(`r-${id}`);
|
|
||||||
if (!target) return;
|
|
||||||
if (lenis) lenis.scrollTo(target, { offset: 2, duration: 1.2 });
|
|
||||||
else target.scrollIntoView({ behavior: "smooth" });
|
|
||||||
};
|
|
||||||
|
|
||||||
return { jump, setActive };
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 模块吸附:滚动停止后,若停在两轮之间的中间态,自动滚动到目标轮顶部,
|
|
||||||
再由 onEnter 正常触发打字机动画(修复"滚不到位则动画错位") */
|
|
||||||
export function initRoundSnap(lenis) {
|
|
||||||
const sections = rounds
|
|
||||||
.map((round) => document.getElementById(`r-${round.id}`))
|
|
||||||
.filter(Boolean);
|
|
||||||
if (!sections.length) return () => {};
|
|
||||||
|
|
||||||
let timer = 0;
|
|
||||||
let lockUntil = 0;
|
|
||||||
|
|
||||||
const readY = () => (typeof lenis?.scroll === "number" ? lenis.scroll : window.scrollY);
|
|
||||||
|
|
||||||
const snapTo = (section) => {
|
|
||||||
lockUntil = performance.now() + 700;
|
|
||||||
const targetY = section.getBoundingClientRect().top + readY();
|
|
||||||
if (lenis) {
|
|
||||||
lenis.scrollTo(targetY, {
|
|
||||||
duration: 0.55,
|
|
||||||
easing: (t) => 1 - Math.pow(1 - t, 3),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
window.scrollTo({ top: targetY, behavior: "smooth" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const settle = () => {
|
|
||||||
if (performance.now() < lockUntil) return;
|
|
||||||
const vh = window.innerHeight || document.documentElement.clientHeight;
|
|
||||||
|
|
||||||
for (const section of sections) {
|
|
||||||
const top = section.getBoundingClientRect().top;
|
|
||||||
// 已有模块对齐视口顶部 → 状态正常,无需吸附
|
|
||||||
if (Math.abs(top) < 8) return;
|
|
||||||
// 模块露出一半(未到打字机区域)→ 吸附进入该模块
|
|
||||||
const halfEntered = top > vh * 0.12 && top < vh * 0.72;
|
|
||||||
// 模块占据主屏但未对齐 → 吸附回正
|
|
||||||
const nearlyAligned = top > -vh * 0.45 && top <= vh * 0.12;
|
|
||||||
if (halfEntered || nearlyAligned) {
|
|
||||||
snapTo(section);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 长内容轮(flow)内部深处不匹配任何区间 → 自由滚动,不干预
|
|
||||||
};
|
|
||||||
|
|
||||||
const onScroll = () => {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
// lenis 惯性收尾阶段速度趋零:立即判定吸附,不等事件完全静止
|
|
||||||
const velocity =
|
|
||||||
lenis && typeof lenis.velocity === "number" ? Math.abs(lenis.velocity) : null;
|
|
||||||
if (velocity !== null && velocity < 0.3) {
|
|
||||||
settle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
timer = window.setTimeout(settle, 90);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (lenis) lenis.on("scroll", onScroll);
|
|
||||||
window.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
window.addEventListener("resize", onScroll);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
window.removeEventListener("scroll", onScroll);
|
|
||||||
window.removeEventListener("resize", onScroll);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 降级:reduced motion 直接展示全部内容 */
|
|
||||||
export function applyReducedMotion() {
|
|
||||||
rounds
|
|
||||||
.filter((r) => r.question)
|
|
||||||
.forEach((round) => {
|
|
||||||
const section = document.getElementById(`r-${round.id}`);
|
|
||||||
section.querySelector(".q-text").textContent = round.question;
|
|
||||||
const answer = section.querySelector(".answer");
|
|
||||||
answer.style.opacity = "1";
|
|
||||||
});
|
|
||||||
const boot = document.querySelector("#r-boot");
|
|
||||||
boot.querySelector(".typed-line").textContent = "这是我的数字思维体。";
|
|
||||||
document.querySelectorAll(".gen").forEach((n) => (n.style.opacity = "1"));
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
// 7 轮问答的文案与节奏配置 —— 每轮节奏参数刻意不同,避免线性重复
|
|
||||||
export const contact = {
|
|
||||||
emails: ["419021733@qq.com", "wmagmgema521@gmail.com"],
|
|
||||||
phone: "19980439383",
|
|
||||||
github: "https://github.com/zhanBoss",
|
|
||||||
identity: "男 · 29 岁 · 电子科技大学 信息管理与信息系统 本科",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const rounds = [
|
|
||||||
{
|
|
||||||
id: "boot",
|
|
||||||
label: "BOOT",
|
|
||||||
type: "boot",
|
|
||||||
question: null,
|
|
||||||
side: 1,
|
|
||||||
palette: ["#22d3ee", "#a78bfa"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "intro",
|
|
||||||
label: "WHO",
|
|
||||||
type: "signals",
|
|
||||||
question: "先介绍一下你自己?",
|
|
||||||
side: -1,
|
|
||||||
palette: ["#2dd4bf", "#a78bfa"],
|
|
||||||
staggerEach: 0.055,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "focus",
|
|
||||||
label: "NOW",
|
|
||||||
type: "focus",
|
|
||||||
question: "最近一年具体在做什么?",
|
|
||||||
side: 1,
|
|
||||||
palette: ["#22d3ee", "#c084fc"],
|
|
||||||
staggerEach: 0.085,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "projects",
|
|
||||||
label: "PROOF",
|
|
||||||
type: "projects",
|
|
||||||
question: "有实际项目证明吗?",
|
|
||||||
side: -1,
|
|
||||||
palette: ["#38bdf8", "#a78bfa"],
|
|
||||||
flow: true, // 内容超过一屏:逐块流式生成
|
|
||||||
staggerEach: 0.045,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "skills",
|
|
||||||
label: "STACK",
|
|
||||||
type: "skills",
|
|
||||||
question: "技能栈展开讲讲?",
|
|
||||||
side: 1,
|
|
||||||
palette: ["#67e8f9", "#8b5cf6"],
|
|
||||||
staggerEach: 0.02,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "experience",
|
|
||||||
label: "PATH",
|
|
||||||
type: "experience",
|
|
||||||
question: "之前的团队经历?",
|
|
||||||
side: -1,
|
|
||||||
palette: ["#5eead4", "#a78bfa"],
|
|
||||||
staggerEach: 0.1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "contact",
|
|
||||||
label: "PING",
|
|
||||||
type: "contact",
|
|
||||||
question: "怎么联系你?",
|
|
||||||
side: 0,
|
|
||||||
palette: ["#22d3ee", "#f0abfc"],
|
|
||||||
staggerEach: 0.09,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
+663
-30
@@ -1,36 +1,669 @@
|
|||||||
import gsap from "gsap";
|
import * as THREE from "three";
|
||||||
import { renderDialogue, renderRail } from "./render";
|
import { experiences, focusAreas, metrics, projects, resumeSignals, skills } from "./resume-data";
|
||||||
import { createMind } from "./mind/mind";
|
|
||||||
import {
|
|
||||||
applyReducedMotion,
|
|
||||||
initChoreography,
|
|
||||||
initRail,
|
|
||||||
initRoundSnap,
|
|
||||||
initSmoothScroll,
|
|
||||||
playBoot,
|
|
||||||
} from "./choreography";
|
|
||||||
|
|
||||||
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
const $ = (selector) => document.querySelector(selector);
|
||||||
|
const $$ = (selector) => [...document.querySelectorAll(selector)];
|
||||||
|
|
||||||
/* 1. 由简历数据生成对话 DOM */
|
const metricRow = $("#metric-row");
|
||||||
renderDialogue(document.getElementById("dialogue"));
|
const aiFocusGrid = $("#ai-focus-grid");
|
||||||
|
const resumeSignalList = $("#resume-signal-list");
|
||||||
|
const projectStack = $("#project-stack");
|
||||||
|
const skillMatrix = $("#skill-matrix");
|
||||||
|
const timeline = $("#timeline");
|
||||||
|
const progress = $("#scroll-progress");
|
||||||
|
const themeToggle = $("#theme-toggle");
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
/* 2. 思维流体 */
|
const clamp = (value, min = 0, max = 1) => Math.min(max, Math.max(min, value));
|
||||||
const mind = createMind(document.getElementById("mind-canvas"), { reduced });
|
const lerp = (from, to, amount) => from + (to - from) * amount;
|
||||||
window.__mind = mind;
|
const smoothstep = (edge0, edge1, value) => {
|
||||||
gsap.ticker.add(() => mind.tick());
|
const t = clamp((value - edge0) / (edge1 - edge0));
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
};
|
||||||
|
|
||||||
/* 3. 进度轨 + 滚动编排 */
|
const fluidScenes = [
|
||||||
let lenis = null;
|
{
|
||||||
if (!reduced) lenis = initSmoothScroll();
|
id: "top",
|
||||||
|
name: "hero",
|
||||||
|
colors: ["#64cbb8", "#8aa4d6", "#d77b91"],
|
||||||
|
desktop: { x: 2.05, y: 0.22, z: -0.78, scale: 1.08 },
|
||||||
|
compact: { x: 1.34, y: 1.14, z: -1.08, scale: 0.38 },
|
||||||
|
flow: 0.62,
|
||||||
|
twist: 0.48,
|
||||||
|
ring: 0.82,
|
||||||
|
particle: 0.78,
|
||||||
|
angle: 128,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ai-fit",
|
||||||
|
name: "ai-fit",
|
||||||
|
colors: ["#74c8bd", "#8097d4", "#d4b56c"],
|
||||||
|
desktop: { x: 1.84, y: 0.08, z: -0.88, scale: 0.96 },
|
||||||
|
compact: { x: 1.18, y: 1.05, z: -1.14, scale: 0.34 },
|
||||||
|
flow: 0.48,
|
||||||
|
twist: 0.72,
|
||||||
|
ring: 0.62,
|
||||||
|
particle: 0.58,
|
||||||
|
angle: 154,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "proof",
|
||||||
|
name: "proof",
|
||||||
|
colors: ["#d4b56c", "#6dc4b1", "#c97a8d"],
|
||||||
|
desktop: { x: 2.22, y: -0.04, z: -0.84, scale: 0.9 },
|
||||||
|
compact: { x: 1.48, y: 1.0, z: -1.18, scale: 0.31 },
|
||||||
|
flow: 0.36,
|
||||||
|
twist: 0.34,
|
||||||
|
ring: 0.92,
|
||||||
|
particle: 0.52,
|
||||||
|
angle: 96,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "projects",
|
||||||
|
name: "projects",
|
||||||
|
colors: ["#889fd4", "#c97991", "#68c7b2"],
|
||||||
|
desktop: { x: 1.68, y: 0.26, z: -0.7, scale: 1.18 },
|
||||||
|
compact: { x: 1.22, y: 1.18, z: -1.02, scale: 0.4 },
|
||||||
|
flow: 0.82,
|
||||||
|
twist: 0.92,
|
||||||
|
ring: 0.78,
|
||||||
|
particle: 0.9,
|
||||||
|
angle: 202,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "skills",
|
||||||
|
name: "skills",
|
||||||
|
colors: ["#75bd9a", "#d1b46d", "#879dd0"],
|
||||||
|
desktop: { x: 2.3, y: 0.04, z: -0.96, scale: 0.86 },
|
||||||
|
compact: { x: 1.52, y: 1.08, z: -1.22, scale: 0.31 },
|
||||||
|
flow: 0.42,
|
||||||
|
twist: 0.56,
|
||||||
|
ring: 1.08,
|
||||||
|
particle: 0.46,
|
||||||
|
angle: 72,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "experience",
|
||||||
|
name: "experience",
|
||||||
|
colors: ["#9a8fcb", "#70beb0", "#849bd0"],
|
||||||
|
desktop: { x: 1.96, y: -0.1, z: -0.92, scale: 1.02 },
|
||||||
|
compact: { x: 1.36, y: 1.0, z: -1.14, scale: 0.36 },
|
||||||
|
flow: 0.3,
|
||||||
|
twist: 0.82,
|
||||||
|
ring: 0.72,
|
||||||
|
particle: 0.64,
|
||||||
|
angle: 232,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "contact",
|
||||||
|
name: "contact",
|
||||||
|
colors: ["#c97991", "#d0b56f", "#70bda8"],
|
||||||
|
desktop: { x: 1.46, y: 0.02, z: -0.66, scale: 1.24 },
|
||||||
|
compact: { x: 1.16, y: 1.14, z: -1.02, scale: 0.42 },
|
||||||
|
flow: 0.24,
|
||||||
|
twist: 0.28,
|
||||||
|
ring: 0.5,
|
||||||
|
particle: 0.42,
|
||||||
|
angle: 312,
|
||||||
|
},
|
||||||
|
].map((scene) => ({
|
||||||
|
...scene,
|
||||||
|
colorObjects: scene.colors.map((color) => new THREE.Color(color)),
|
||||||
|
}));
|
||||||
|
|
||||||
const rail = initRail(lenis);
|
function getSceneFrame() {
|
||||||
renderRail(document.getElementById("session-rail"), rail.jump);
|
const center = window.scrollY + window.innerHeight * 0.28;
|
||||||
|
const points = fluidScenes.map((scene) => ({
|
||||||
if (!reduced) {
|
scene,
|
||||||
initChoreography(mind, rail.setActive);
|
top: scene.id === "top" ? 0 : document.getElementById(scene.id)?.offsetTop ?? document.documentElement.scrollHeight,
|
||||||
initRoundSnap(lenis);
|
}));
|
||||||
playBoot(mind);
|
let index = 0;
|
||||||
} else {
|
for (let i = 0; i < points.length - 1; i += 1) {
|
||||||
applyReducedMotion();
|
if (center >= points[i + 1].top) index = i + 1;
|
||||||
}
|
}
|
||||||
|
const current = points[index];
|
||||||
|
const next = points[Math.min(points.length - 1, index + 1)];
|
||||||
|
const span = Math.max(1, next.top - current.top);
|
||||||
|
const blend = current === next ? 0 : smoothstep(0.18, 0.86, (center - current.top) / span);
|
||||||
|
return { current: current.scene, next: next.scene, blend };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mixScene(frame, compact) {
|
||||||
|
const layoutA = compact ? frame.current.compact : frame.current.desktop;
|
||||||
|
const layoutB = compact ? frame.next.compact : frame.next.desktop;
|
||||||
|
return {
|
||||||
|
name: frame.blend > 0.58 ? frame.next.name : frame.current.name,
|
||||||
|
x: lerp(layoutA.x, layoutB.x, frame.blend),
|
||||||
|
y: lerp(layoutA.y, layoutB.y, frame.blend),
|
||||||
|
z: lerp(layoutA.z, layoutB.z, frame.blend),
|
||||||
|
scale: lerp(layoutA.scale, layoutB.scale, frame.blend),
|
||||||
|
flow: lerp(frame.current.flow, frame.next.flow, frame.blend),
|
||||||
|
twist: lerp(frame.current.twist, frame.next.twist, frame.blend),
|
||||||
|
ring: lerp(frame.current.ring, frame.next.ring, frame.blend),
|
||||||
|
particle: lerp(frame.current.particle, frame.next.particle, frame.blend),
|
||||||
|
angle: lerp(frame.current.angle, frame.next.angle, frame.blend),
|
||||||
|
colors: frame.current.colorObjects.map((color, index) => color.clone().lerp(frame.next.colorObjects[index], frame.blend)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSceneCss(scene, scroll) {
|
||||||
|
root.dataset.scene = scene.name;
|
||||||
|
root.style.setProperty("--scene-a", scene.colors[0].getStyle());
|
||||||
|
root.style.setProperty("--scene-b", scene.colors[1].getStyle());
|
||||||
|
root.style.setProperty("--scene-c", scene.colors[2].getStyle());
|
||||||
|
root.style.setProperty("--scene-angle", `${scene.angle.toFixed(2)}deg`);
|
||||||
|
root.style.setProperty("--scene-opacity", (0.18 + scene.flow * 0.12).toFixed(3));
|
||||||
|
root.style.setProperty("--scene-drift-x", `${(-4 + scene.x * 1.4 - scroll * 3).toFixed(2)}vw`);
|
||||||
|
root.style.setProperty("--scene-drift-y", `${(-2 + scene.y * 2 + scroll * 2.5).toFixed(2)}vh`);
|
||||||
|
root.style.setProperty("--scene-rotate", `${(-1 + scene.twist * 2.5 + scroll * 3).toFixed(2)}deg`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMetrics() {
|
||||||
|
metricRow.innerHTML = metrics
|
||||||
|
.map((item, index) => `<article class="reveal" data-drift="${index % 2 === 0 ? 28 : -28}"><strong>${item.value}</strong><span>${item.label}</span></article>`)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFocusAreas() {
|
||||||
|
aiFocusGrid.innerHTML = focusAreas
|
||||||
|
.map(
|
||||||
|
(item, index) => `
|
||||||
|
<article class="ai-focus-card reveal" data-drift="${index % 2 === 0 ? -34 : 34}">
|
||||||
|
<span>${String(index + 1).padStart(2, "0")}</span>
|
||||||
|
<h3>${item.title}</h3>
|
||||||
|
<p>${item.summary}</p>
|
||||||
|
<div>${item.points.map((point) => `<em>${point}</em>`).join("")}</div>
|
||||||
|
</article>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResumeSignals() {
|
||||||
|
resumeSignalList.innerHTML = resumeSignals.map((signal) => `<li>${signal}</li>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjects() {
|
||||||
|
projectStack.innerHTML = projects
|
||||||
|
.map(
|
||||||
|
(project, index) => `
|
||||||
|
<article class="project-card reveal" data-drift="${index % 2 === 0 ? -52 : 52}">
|
||||||
|
<div class="project-index">
|
||||||
|
<span class="logo-frame"><img src="${project.logo}" alt="" loading="lazy" decoding="async" /></span>
|
||||||
|
<span>${String(index + 1).padStart(2, "0")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-body">
|
||||||
|
<div class="project-head">
|
||||||
|
<div>
|
||||||
|
<h3>${project.name}</h3>
|
||||||
|
<p>${project.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<time>${project.period}</time>
|
||||||
|
</div>
|
||||||
|
<p class="project-summary">${project.summary}</p>
|
||||||
|
<ul>${project.modules.map((item) => `<li>${item}</li>`).join("")}</ul>
|
||||||
|
<div class="tech-row">${project.tech.map((item) => `<span>${item}</span>`).join("")}</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSkills() {
|
||||||
|
skillMatrix.innerHTML = skills
|
||||||
|
.map(
|
||||||
|
(skill, index) => `
|
||||||
|
<article class="skill-group reveal" data-drift="${index % 3 === 0 ? -36 : index % 3 === 1 ? 18 : 42}">
|
||||||
|
<h3>${skill.group}</h3>
|
||||||
|
<div>${skill.items.map((item) => `<span>${item}</span>`).join("")}</div>
|
||||||
|
</article>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExperience() {
|
||||||
|
timeline.innerHTML = experiences
|
||||||
|
.map(
|
||||||
|
(item, index) => `
|
||||||
|
<article class="reveal" data-drift="${index % 2 === 0 ? 38 : -38}">
|
||||||
|
<div class="timeline-side">
|
||||||
|
<span class="logo-frame"><img src="${item.logo}" alt="" loading="lazy" decoding="async" /></span>
|
||||||
|
<time>${item.period}</time>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>${item.company}</h3>
|
||||||
|
<p>${item.role}</p>
|
||||||
|
<ul>${item.points.map((point) => `<li>${point}</li>`).join("")}</ul>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupReveal() {
|
||||||
|
$$(".reveal").forEach((node) => {
|
||||||
|
node.style.setProperty("--drift", node.dataset.drift || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) entry.target.classList.add("is-visible");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.14 },
|
||||||
|
);
|
||||||
|
|
||||||
|
$$(".reveal").forEach((node) => observer.observe(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScrollProgress() {
|
||||||
|
const max = document.documentElement.scrollHeight - window.innerHeight;
|
||||||
|
const value = max > 0 ? window.scrollY / max : 0;
|
||||||
|
root.style.setProperty("--scroll-progress", value.toFixed(4));
|
||||||
|
root.style.setProperty("--bg-grid-x", `${(-220 * value).toFixed(2)}px`);
|
||||||
|
root.style.setProperty("--bg-grid-y", `${(150 * value).toFixed(2)}px`);
|
||||||
|
root.style.setProperty("--bg-grid-x-2", `${(140 * value).toFixed(2)}px`);
|
||||||
|
root.style.setProperty("--bg-grid-y-2", `${(-190 * value).toFixed(2)}px`);
|
||||||
|
progress.style.transform = `scaleX(${Math.min(1, Math.max(0, value))})`;
|
||||||
|
|
||||||
|
const frame = getSceneFrame();
|
||||||
|
const activeScene = frame.blend > 0.58 ? frame.next : frame.current;
|
||||||
|
const sections = fluidScenes
|
||||||
|
.filter((scene) => scene.id !== "top")
|
||||||
|
.map((scene) => document.getElementById(scene.id))
|
||||||
|
.filter(Boolean);
|
||||||
|
const current = activeScene.id === "top" ? null : sections.find((section) => section.id === activeScene.id);
|
||||||
|
$$(".site-nav a").forEach((link) => link.classList.toggle("active", current && link.getAttribute("href") === `#${current.id}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreHashPosition() {
|
||||||
|
const id = decodeURIComponent(location.hash.slice(1));
|
||||||
|
if (!id) return;
|
||||||
|
const target = document.getElementById(id);
|
||||||
|
if (!target) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.scrollTo({ top: target.offsetTop, behavior: "auto" });
|
||||||
|
updateScrollProgress();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupTheme() {
|
||||||
|
const saved = localStorage.getItem("wy-fluid-theme");
|
||||||
|
if (saved) document.documentElement.dataset.theme = saved;
|
||||||
|
themeToggle.addEventListener("click", () => {
|
||||||
|
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
|
||||||
|
document.documentElement.dataset.theme = next;
|
||||||
|
localStorage.setItem("wy-fluid-theme", next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupTilts() {
|
||||||
|
$$(".project-card").forEach((card) => {
|
||||||
|
card.addEventListener("pointerenter", () => card.classList.add("is-hovered"));
|
||||||
|
card.addEventListener("pointerleave", () => card.classList.remove("is-hovered"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupFluidScene() {
|
||||||
|
const canvas = $("#fluid-canvas");
|
||||||
|
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
if (!canvas || reduceMotion) return;
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({
|
||||||
|
canvas,
|
||||||
|
antialias: true,
|
||||||
|
alpha: true,
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
|
powerPreference: "high-performance",
|
||||||
|
});
|
||||||
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
renderer.setClearColor(0x000000, 0);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const camera = new THREE.PerspectiveCamera(39, 1, 0.1, 100);
|
||||||
|
camera.position.set(0, 0, 6.2);
|
||||||
|
|
||||||
|
const pointer = new THREE.Vector2(0.18, -0.08);
|
||||||
|
const pointerTarget = new THREE.Vector2(0.18, -0.08);
|
||||||
|
const pointerVisual = new THREE.Vector2(58, 46);
|
||||||
|
const pointerVisualTarget = new THREE.Vector2(58, 46);
|
||||||
|
const pointerState = {
|
||||||
|
down: false,
|
||||||
|
lastX: window.innerWidth * 0.58,
|
||||||
|
lastY: window.innerHeight * 0.46,
|
||||||
|
velocity: 0,
|
||||||
|
targetVelocity: 0,
|
||||||
|
impact: 0,
|
||||||
|
pressure: 0,
|
||||||
|
};
|
||||||
|
const group = new THREE.Group();
|
||||||
|
scene.add(group);
|
||||||
|
|
||||||
|
const uniforms = {
|
||||||
|
uTime: { value: 0 },
|
||||||
|
uPointer: { value: pointer },
|
||||||
|
uScroll: { value: 0 },
|
||||||
|
uImpact: { value: 0 },
|
||||||
|
uFlow: { value: fluidScenes[0].flow },
|
||||||
|
uTwist: { value: fluidScenes[0].twist },
|
||||||
|
uColorA: { value: fluidScenes[0].colorObjects[0].clone() },
|
||||||
|
uColorB: { value: fluidScenes[0].colorObjects[1].clone() },
|
||||||
|
uColorC: { value: fluidScenes[0].colorObjects[2].clone() },
|
||||||
|
uTheme: { value: document.documentElement.dataset.theme === "light" ? 1 : 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const vertexShader = `
|
||||||
|
uniform float uTime;
|
||||||
|
uniform vec2 uPointer;
|
||||||
|
uniform float uScroll;
|
||||||
|
uniform float uImpact;
|
||||||
|
uniform float uFlow;
|
||||||
|
uniform float uTwist;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
varying vec3 vWorld;
|
||||||
|
varying float vWave;
|
||||||
|
|
||||||
|
float ridge(float value) {
|
||||||
|
return 1.0 - abs(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec3 p = position;
|
||||||
|
float t = uTime;
|
||||||
|
float sceneBreath = sin(uScroll * 6.283 + t * (0.18 + uFlow * 0.22));
|
||||||
|
float waveA = sin(p.x * (2.15 + uTwist * 1.25) + t * (0.58 + uFlow * 0.82) + sin(p.y * 1.4 + t * 0.32));
|
||||||
|
float waveB = cos(p.y * (2.6 + uTwist * 1.8) - t * (0.42 + uFlow * 0.74) + p.z * (1.35 + uTwist));
|
||||||
|
float waveC = sin((p.x + p.y + p.z) * (1.58 + uTwist * 1.1) + t * (0.86 + uFlow * 1.05));
|
||||||
|
vec2 pointerPlane = uPointer * vec2(1.55, 1.08);
|
||||||
|
float pointerDistance = distance(p.xy, pointerPlane);
|
||||||
|
float pointerWave = ridge(sin(pointerDistance * (4.8 + uImpact * 1.2) - t * (1.8 + uImpact * 0.8)));
|
||||||
|
float pointerPull = smoothstep(1.62, 0.18, pointerDistance);
|
||||||
|
float scrollWave = sin(uScroll * (5.5 + uFlow * 5.0) + p.z * (2.4 + uTwist * 1.6) + sceneBreath) * (0.045 + uTwist * 0.055);
|
||||||
|
float displacement = waveA * (0.11 + uFlow * 0.065) + waveB * (0.085 + uTwist * 0.04) + waveC * (0.052 + uFlow * 0.035) + pointerWave * (0.055 + uImpact * 0.11) + pointerPull * (0.05 + uImpact * 0.075) + scrollWave;
|
||||||
|
p += normal * displacement;
|
||||||
|
vec2 pointerDir = normalize((p.xy - pointerPlane) + vec2(0.0001, -0.0001));
|
||||||
|
p.xy += pointerDir * pointerPull * (0.024 + uImpact * 0.055);
|
||||||
|
p.x += sin(t * (0.34 + uFlow * 0.28) + p.y * (2.1 + uTwist * 0.9)) * (0.04 + uFlow * 0.03);
|
||||||
|
p.y += cos(t * (0.28 + uFlow * 0.26) + p.x * (1.8 + uTwist * 0.75)) * (0.032 + uTwist * 0.025);
|
||||||
|
vec4 world = modelMatrix * vec4(p, 1.0);
|
||||||
|
vNormal = normalize(normalMatrix * normal);
|
||||||
|
vWorld = world.xyz;
|
||||||
|
vWave = displacement;
|
||||||
|
gl_Position = projectionMatrix * viewMatrix * world;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fragmentShader = `
|
||||||
|
uniform float uTime;
|
||||||
|
uniform float uTheme;
|
||||||
|
uniform vec3 uColorA;
|
||||||
|
uniform vec3 uColorB;
|
||||||
|
uniform vec3 uColorC;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
varying vec3 vWorld;
|
||||||
|
varying float vWave;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec3 viewDir = normalize(cameraPosition - vWorld);
|
||||||
|
float fresnel = pow(1.0 - max(dot(normalize(vNormal), viewDir), 0.0), 2.4);
|
||||||
|
float scan = sin((vWorld.x - vWorld.y) * 8.0 + uTime * 1.8) * 0.5 + 0.5;
|
||||||
|
vec3 darkA = uColorA;
|
||||||
|
vec3 darkB = uColorB;
|
||||||
|
vec3 darkC = uColorC;
|
||||||
|
vec3 lightA = mix(uColorA, vec3(0.95, 1.0, 0.96), 0.42);
|
||||||
|
vec3 lightB = mix(uColorB, vec3(0.9, 0.94, 1.0), 0.34);
|
||||||
|
vec3 lightC = mix(uColorC, vec3(1.0, 0.94, 0.9), 0.3);
|
||||||
|
vec3 a = mix(darkA, lightA, uTheme);
|
||||||
|
vec3 b = mix(darkB, lightB, uTheme);
|
||||||
|
vec3 c = mix(darkC, lightC, uTheme);
|
||||||
|
vec3 color = mix(a, b, smoothstep(-0.22, 0.38, vWave));
|
||||||
|
color = mix(color, c, fresnel * 0.86 + scan * 0.12);
|
||||||
|
color += fresnel * 0.16;
|
||||||
|
float alpha = 0.56 + fresnel * 0.24 + abs(vWave) * 0.06;
|
||||||
|
gl_FragColor = vec4(color, alpha);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const material = new THREE.ShaderMaterial({
|
||||||
|
uniforms,
|
||||||
|
vertexShader,
|
||||||
|
fragmentShader,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shellMaterial = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xffffff,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.13,
|
||||||
|
wireframe: true,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new THREE.Mesh(new THREE.SphereGeometry(1.52, 128, 128), material);
|
||||||
|
const shell = new THREE.Mesh(new THREE.SphereGeometry(1.72, 48, 32), shellMaterial);
|
||||||
|
shell.rotation.set(0.6, 0.1, -0.4);
|
||||||
|
group.add(blob, shell);
|
||||||
|
|
||||||
|
const ringMaterial = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x74a7ff,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.16,
|
||||||
|
wireframe: true,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
});
|
||||||
|
const rings = Array.from({ length: 4 }, (_, index) => {
|
||||||
|
const ring = new THREE.Mesh(new THREE.TorusGeometry(1.9 + index * 0.16, 0.008, 8, 180), ringMaterial.clone());
|
||||||
|
ring.rotation.set(Math.PI / 2.3 + index * 0.18, index * 0.5, index * 0.8);
|
||||||
|
ring.material.opacity = 0.08 + index * 0.018;
|
||||||
|
group.add(ring);
|
||||||
|
return ring;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rayMaterial = new THREE.LineBasicMaterial({
|
||||||
|
color: 0x56f7c5,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.025,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
});
|
||||||
|
const rayGeometry = new THREE.BufferGeometry();
|
||||||
|
const rayPositions = new Float32Array(18 * 3);
|
||||||
|
rayGeometry.setAttribute("position", new THREE.BufferAttribute(rayPositions, 3));
|
||||||
|
const interactionRay = new THREE.LineSegments(rayGeometry, rayMaterial);
|
||||||
|
group.add(interactionRay);
|
||||||
|
|
||||||
|
const particleCount = 950;
|
||||||
|
const positions = new Float32Array(particleCount * 3);
|
||||||
|
const colors = new Float32Array(particleCount * 3);
|
||||||
|
const colorA = new THREE.Color("#56f7c5");
|
||||||
|
const colorB = new THREE.Color("#ff5ea8");
|
||||||
|
const colorC = new THREE.Color("#74a7ff");
|
||||||
|
for (let index = 0; index < particleCount; index += 1) {
|
||||||
|
const radius = 2.05 + Math.random() * 2.3;
|
||||||
|
const theta = Math.random() * Math.PI * 2;
|
||||||
|
const phi = Math.acos(Math.random() * 2 - 1);
|
||||||
|
positions[index * 3] = Math.sin(phi) * Math.cos(theta) * radius;
|
||||||
|
positions[index * 3 + 1] = Math.sin(phi) * Math.sin(theta) * radius;
|
||||||
|
positions[index * 3 + 2] = Math.cos(phi) * radius;
|
||||||
|
const color = index % 3 === 0 ? colorA : index % 3 === 1 ? colorB : colorC;
|
||||||
|
colors[index * 3] = color.r;
|
||||||
|
colors[index * 3 + 1] = color.g;
|
||||||
|
colors[index * 3 + 2] = color.b;
|
||||||
|
}
|
||||||
|
const particleGeometry = new THREE.BufferGeometry();
|
||||||
|
particleGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||||
|
particleGeometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
|
||||||
|
const particles = new THREE.Points(
|
||||||
|
particleGeometry,
|
||||||
|
new THREE.PointsMaterial({
|
||||||
|
size: 0.018,
|
||||||
|
vertexColors: true,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.78,
|
||||||
|
depthWrite: false,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
group.add(particles);
|
||||||
|
|
||||||
|
let width = 0;
|
||||||
|
let height = 0;
|
||||||
|
const basePosition = new THREE.Vector3(fluidScenes[0].desktop.x, fluidScenes[0].desktop.y, fluidScenes[0].desktop.z);
|
||||||
|
const sceneVisual = {
|
||||||
|
name: fluidScenes[0].name,
|
||||||
|
x: basePosition.x,
|
||||||
|
y: basePosition.y,
|
||||||
|
z: basePosition.z,
|
||||||
|
scale: fluidScenes[0].desktop.scale,
|
||||||
|
flow: fluidScenes[0].flow,
|
||||||
|
twist: fluidScenes[0].twist,
|
||||||
|
ring: fluidScenes[0].ring,
|
||||||
|
particle: fluidScenes[0].particle,
|
||||||
|
angle: fluidScenes[0].angle,
|
||||||
|
colors: fluidScenes[0].colorObjects.map((color) => color.clone()),
|
||||||
|
};
|
||||||
|
function resize() {
|
||||||
|
width = window.innerWidth;
|
||||||
|
height = window.innerHeight;
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
renderer.setPixelRatio(dpr);
|
||||||
|
renderer.setSize(width, height, false);
|
||||||
|
camera.aspect = width / height;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
const compact = width < 760;
|
||||||
|
const target = mixScene(getSceneFrame(), compact);
|
||||||
|
basePosition.set(target.x, target.y, target.z);
|
||||||
|
group.position.copy(basePosition);
|
||||||
|
group.scale.setScalar(target.scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event) {
|
||||||
|
const dx = event.clientX - pointerState.lastX;
|
||||||
|
const dy = event.clientY - pointerState.lastY;
|
||||||
|
pointerState.lastX = event.clientX;
|
||||||
|
pointerState.lastY = event.clientY;
|
||||||
|
const speed = Math.hypot(dx, dy);
|
||||||
|
pointerState.targetVelocity = Math.min(1, speed / 170);
|
||||||
|
pointerState.impact = Math.max(pointerState.impact, Math.min(0.26, pointerState.targetVelocity * 0.2));
|
||||||
|
pointerTarget.x = (event.clientX / window.innerWidth) * 2 - 1;
|
||||||
|
pointerTarget.y = -((event.clientY / window.innerHeight) * 2 - 1);
|
||||||
|
pointerVisualTarget.set((event.clientX / window.innerWidth) * 100, (event.clientY / window.innerHeight) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(event) {
|
||||||
|
pointerState.down = true;
|
||||||
|
pointerState.impact = Math.max(pointerState.impact, 0.46);
|
||||||
|
root.classList.add("is-pointer-down");
|
||||||
|
onPointerMove(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
pointerState.down = false;
|
||||||
|
root.classList.remove("is-pointer-down");
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("resize", resize);
|
||||||
|
window.addEventListener("pointermove", onPointerMove, { passive: true });
|
||||||
|
window.addEventListener("pointerdown", onPointerDown, { passive: true });
|
||||||
|
window.addEventListener("pointerup", onPointerUp, { passive: true });
|
||||||
|
window.addEventListener("pointercancel", onPointerUp, { passive: true });
|
||||||
|
resize();
|
||||||
|
|
||||||
|
const startedAt = performance.now();
|
||||||
|
function animate() {
|
||||||
|
const elapsed = (performance.now() - startedAt) / 1000;
|
||||||
|
const scrollMax = document.documentElement.scrollHeight - window.innerHeight;
|
||||||
|
const scroll = scrollMax > 0 ? window.scrollY / scrollMax : 0;
|
||||||
|
const compact = width < 760;
|
||||||
|
const sceneTarget = mixScene(getSceneFrame(), compact);
|
||||||
|
sceneVisual.name = sceneTarget.name;
|
||||||
|
sceneVisual.x = lerp(sceneVisual.x, sceneTarget.x, 0.045);
|
||||||
|
sceneVisual.y = lerp(sceneVisual.y, sceneTarget.y, 0.045);
|
||||||
|
sceneVisual.z = lerp(sceneVisual.z, sceneTarget.z, 0.045);
|
||||||
|
sceneVisual.scale = lerp(sceneVisual.scale, sceneTarget.scale, 0.045);
|
||||||
|
sceneVisual.flow = lerp(sceneVisual.flow, sceneTarget.flow, 0.04);
|
||||||
|
sceneVisual.twist = lerp(sceneVisual.twist, sceneTarget.twist, 0.04);
|
||||||
|
sceneVisual.ring = lerp(sceneVisual.ring, sceneTarget.ring, 0.04);
|
||||||
|
sceneVisual.particle = lerp(sceneVisual.particle, sceneTarget.particle, 0.04);
|
||||||
|
sceneVisual.angle = lerp(sceneVisual.angle, sceneTarget.angle, 0.035);
|
||||||
|
sceneVisual.colors.forEach((color, index) => color.lerp(sceneTarget.colors[index], 0.045));
|
||||||
|
basePosition.set(sceneVisual.x, sceneVisual.y, sceneVisual.z);
|
||||||
|
writeSceneCss(sceneVisual, scroll);
|
||||||
|
pointer.lerp(pointerTarget, 0.024);
|
||||||
|
pointerVisual.lerp(pointerVisualTarget, 0.07);
|
||||||
|
root.style.setProperty("--cursor-x", `${pointerVisual.x.toFixed(2)}vw`);
|
||||||
|
root.style.setProperty("--cursor-y", `${pointerVisual.y.toFixed(2)}vh`);
|
||||||
|
pointerState.velocity += (pointerState.targetVelocity - pointerState.velocity) * 0.045;
|
||||||
|
pointerState.targetVelocity *= 0.82;
|
||||||
|
pointerState.pressure += ((pointerState.down ? 1 : 0) - pointerState.pressure) * 0.075;
|
||||||
|
pointerState.impact *= pointerState.down ? 0.985 : 0.955;
|
||||||
|
uniforms.uImpact.value = Math.min(0.72, pointerState.velocity * 0.24 + pointerState.impact * 0.72 + pointerState.pressure * 0.12);
|
||||||
|
uniforms.uTime.value = elapsed;
|
||||||
|
uniforms.uScroll.value = scroll;
|
||||||
|
uniforms.uFlow.value = sceneVisual.flow;
|
||||||
|
uniforms.uTwist.value = sceneVisual.twist;
|
||||||
|
uniforms.uColorA.value.copy(sceneVisual.colors[0]);
|
||||||
|
uniforms.uColorB.value.copy(sceneVisual.colors[1]);
|
||||||
|
uniforms.uColorC.value.copy(sceneVisual.colors[2]);
|
||||||
|
uniforms.uTheme.value += ((document.documentElement.dataset.theme === "light" ? 1 : 0) - uniforms.uTheme.value) * 0.08;
|
||||||
|
|
||||||
|
group.position.x = basePosition.x + pointer.x * (0.06 + uniforms.uImpact.value * 0.035);
|
||||||
|
group.position.y = basePosition.y + Math.sin(elapsed * 0.47) * 0.07 + pointer.y * (0.045 + uniforms.uImpact.value * 0.03);
|
||||||
|
group.position.z = basePosition.z + uniforms.uImpact.value * 0.025;
|
||||||
|
group.scale.setScalar(sceneVisual.scale + Math.sin(elapsed * 0.18 + scroll * 5.2) * 0.018);
|
||||||
|
group.rotation.x = Math.sin(elapsed * 0.28) * 0.14 + pointer.y * (0.11 + uniforms.uImpact.value * 0.055);
|
||||||
|
group.rotation.y = elapsed * (0.055 + sceneVisual.flow * 0.065 + uniforms.uImpact.value * 0.018) + pointer.x * (0.18 + uniforms.uImpact.value * 0.075) + scroll * (0.5 + sceneVisual.twist * 0.5);
|
||||||
|
group.rotation.z = Math.cos(elapsed * 0.18) * 0.055 + pointer.x * pointer.y * 0.055 + sceneVisual.twist * 0.06;
|
||||||
|
blob.scale.setScalar(1 + Math.sin(elapsed * 0.62) * 0.025 + uniforms.uImpact.value * 0.014);
|
||||||
|
shell.rotation.y -= 0.0014;
|
||||||
|
shell.rotation.z += 0.001;
|
||||||
|
particles.material.opacity = 0.42 + sceneVisual.particle * 0.36;
|
||||||
|
particles.rotation.y -= 0.00035 + sceneVisual.flow * 0.00045 + uniforms.uImpact.value * 0.00035;
|
||||||
|
particles.rotation.x = Math.sin(elapsed * (0.14 + sceneVisual.flow * 0.12)) * (0.055 + sceneVisual.twist * 0.06);
|
||||||
|
rings.forEach((ring, index) => {
|
||||||
|
ring.material.opacity = (0.04 + index * 0.012) * (0.75 + sceneVisual.ring * 0.7);
|
||||||
|
ring.rotation.z += 0.0007 + sceneVisual.ring * 0.00075 + index * 0.00035 + uniforms.uImpact.value * 0.00055;
|
||||||
|
ring.rotation.x += Math.sin(elapsed * 0.24 + index) * 0.00048 + pointer.y * 0.00022;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let index = 0; index < 9; index += 1) {
|
||||||
|
const offset = index * 6;
|
||||||
|
const angle = elapsed * (0.28 + sceneVisual.flow * 0.22 + index * 0.025) + index * 0.7 + scroll * sceneVisual.twist * 2.4;
|
||||||
|
const length = 0.42 + sceneVisual.ring * 0.18 + uniforms.uImpact.value * 0.72;
|
||||||
|
rayPositions[offset] = pointer.x * 0.82;
|
||||||
|
rayPositions[offset + 1] = pointer.y * 0.58;
|
||||||
|
rayPositions[offset + 2] = 0.38;
|
||||||
|
rayPositions[offset + 3] = pointer.x * 0.82 + Math.cos(angle) * length;
|
||||||
|
rayPositions[offset + 4] = pointer.y * 0.58 + Math.sin(angle) * length * 0.42;
|
||||||
|
rayPositions[offset + 5] = 0.12 + Math.sin(angle * 0.7) * 0.28;
|
||||||
|
}
|
||||||
|
rayGeometry.attributes.position.needsUpdate = true;
|
||||||
|
rayMaterial.opacity = 0.025 + uniforms.uImpact.value * 0.13;
|
||||||
|
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMetrics();
|
||||||
|
renderFocusAreas();
|
||||||
|
renderResumeSignals();
|
||||||
|
renderProjects();
|
||||||
|
renderSkills();
|
||||||
|
renderExperience();
|
||||||
|
setupTheme();
|
||||||
|
setupReveal();
|
||||||
|
setupTilts();
|
||||||
|
setupFluidScene();
|
||||||
|
updateScrollProgress();
|
||||||
|
restoreHashPosition();
|
||||||
|
window.addEventListener("scroll", () => requestAnimationFrame(updateScrollProgress), { passive: true });
|
||||||
|
window.addEventListener("hashchange", () => {
|
||||||
|
restoreHashPosition();
|
||||||
|
updateScrollProgress();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
import * as THREE from "three";
|
|
||||||
import { blobFragment, blobVertex, particleFragment, particleVertex } from "./shaders";
|
|
||||||
|
|
||||||
// 三状态目标值 —— 每帧向目标插值,无硬切
|
|
||||||
const STATES = {
|
|
||||||
idle: { freq: 1.15, amp: 0.24, speed: 0.22, heat: 0.0, scale: 1.0, attract: 0.12 },
|
|
||||||
thinking: { freq: 2.6, amp: 0.27, speed: 0.95, heat: 0.7, scale: 0.86, attract: 1.0 },
|
|
||||||
answering: { freq: 1.9, amp: 0.46, speed: 0.5, heat: 0.22, scale: 1.09, attract: 0.0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const lerp = (a, b, t) => a + (b - a) * t;
|
|
||||||
|
|
||||||
export function createMind(canvas, { reduced = false } = {}) {
|
|
||||||
const isMobile = window.innerWidth < 880;
|
|
||||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobile ? 1.5 : 2));
|
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
|
||||||
const camera = new THREE.PerspectiveCamera(42, window.innerWidth / window.innerHeight, 0.1, 30);
|
|
||||||
camera.position.z = 6;
|
|
||||||
|
|
||||||
const group = new THREE.Group();
|
|
||||||
scene.add(group);
|
|
||||||
|
|
||||||
/* ---- 流体球 ---- */
|
|
||||||
const blobUniforms = {
|
|
||||||
uTime: { value: 0 },
|
|
||||||
uFreq: { value: STATES.idle.freq },
|
|
||||||
uAmp: { value: STATES.idle.amp },
|
|
||||||
uSpeed: { value: STATES.idle.speed },
|
|
||||||
uHeat: { value: 0 },
|
|
||||||
uOpacity: { value: 1 },
|
|
||||||
uColorA: { value: new THREE.Color("#22d3ee") },
|
|
||||||
uColorB: { value: new THREE.Color("#a78bfa") },
|
|
||||||
};
|
|
||||||
const blob = new THREE.Mesh(
|
|
||||||
new THREE.IcosahedronGeometry(1.35, isMobile ? 48 : 96),
|
|
||||||
new THREE.ShaderMaterial({
|
|
||||||
vertexShader: blobVertex,
|
|
||||||
fragmentShader: blobFragment,
|
|
||||||
uniforms: blobUniforms,
|
|
||||||
transparent: true,
|
|
||||||
blending: THREE.AdditiveBlending,
|
|
||||||
depthWrite: false,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
group.add(blob);
|
|
||||||
|
|
||||||
/* ---- 伴生粒子 ---- */
|
|
||||||
const COUNT = isMobile ? 900 : 2000;
|
|
||||||
const seeds = new Float32Array(COUNT * 3);
|
|
||||||
const radii = new Float32Array(COUNT);
|
|
||||||
const speeds = new Float32Array(COUNT);
|
|
||||||
for (let i = 0; i < COUNT; i++) {
|
|
||||||
seeds[i * 3] = Math.random();
|
|
||||||
seeds[i * 3 + 1] = Math.random();
|
|
||||||
seeds[i * 3 + 2] = Math.random();
|
|
||||||
radii[i] = 1.7 + Math.random() * 1.5;
|
|
||||||
speeds[i] = 0.25 + Math.random() * 0.6;
|
|
||||||
}
|
|
||||||
const pGeo = new THREE.BufferGeometry();
|
|
||||||
pGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(COUNT * 3), 3));
|
|
||||||
pGeo.setAttribute("aSeed", new THREE.BufferAttribute(seeds, 3));
|
|
||||||
pGeo.setAttribute("aRadius", new THREE.BufferAttribute(radii, 1));
|
|
||||||
pGeo.setAttribute("aSpeed", new THREE.BufferAttribute(speeds, 1));
|
|
||||||
|
|
||||||
const particleUniforms = {
|
|
||||||
uTime: { value: 0 },
|
|
||||||
uAttract: { value: STATES.idle.attract },
|
|
||||||
uBurst: { value: 0 },
|
|
||||||
uDir: { value: -1 },
|
|
||||||
uSize: { value: isMobile ? 9 : 12 },
|
|
||||||
uMouse: { value: new THREE.Vector3(999, 999, 999) },
|
|
||||||
uMouseForce: { value: 0 },
|
|
||||||
uGrab: { value: 0 },
|
|
||||||
uHeat: { value: 0 },
|
|
||||||
uOpacity: { value: 1 },
|
|
||||||
uColorA: blobUniforms.uColorA,
|
|
||||||
uColorB: blobUniforms.uColorB,
|
|
||||||
};
|
|
||||||
const particles = new THREE.Points(
|
|
||||||
pGeo,
|
|
||||||
new THREE.ShaderMaterial({
|
|
||||||
vertexShader: particleVertex,
|
|
||||||
fragmentShader: particleFragment,
|
|
||||||
uniforms: particleUniforms,
|
|
||||||
transparent: true,
|
|
||||||
blending: THREE.AdditiveBlending,
|
|
||||||
depthWrite: false,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
group.add(particles);
|
|
||||||
|
|
||||||
/* ---- 状态机 ---- */
|
|
||||||
let current = { ...STATES.idle };
|
|
||||||
let target = STATES.idle;
|
|
||||||
let stateName = "idle";
|
|
||||||
let burst = 0;
|
|
||||||
|
|
||||||
const colorA = blobUniforms.uColorA.value;
|
|
||||||
const colorB = blobUniforms.uColorB.value;
|
|
||||||
const targetColorA = colorA.clone();
|
|
||||||
const targetColorB = colorB.clone();
|
|
||||||
|
|
||||||
// 停靠位置:side -1 内容在右 → 流体去左;1 → 右;0 → 居中偏后
|
|
||||||
const dock = new THREE.Vector3();
|
|
||||||
const dockTarget = new THREE.Vector3();
|
|
||||||
const pointer = { x: 0, y: 0 };
|
|
||||||
|
|
||||||
function dockFor(side) {
|
|
||||||
if (window.innerWidth < 880) {
|
|
||||||
// 移动端:居中上移、退后、缩小,让内容可读
|
|
||||||
return new THREE.Vector3(side * 0.3, 1.25, -1.6);
|
|
||||||
}
|
|
||||||
if (side === 0) return new THREE.Vector3(0, 0.95, -2.8);
|
|
||||||
return new THREE.Vector3(side * 2.05, 0.1, -0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
let side = 1;
|
|
||||||
let opacityTarget = 1;
|
|
||||||
let hasPointer = false;
|
|
||||||
let mouseSnapped = false;
|
|
||||||
// 按压牵引弹簧(欠阻尼 → 过冲回弹的阻尼感)
|
|
||||||
let grab = 0;
|
|
||||||
let grabVel = 0;
|
|
||||||
let grabTarget = 0;
|
|
||||||
const mouseRay = new THREE.Vector3();
|
|
||||||
const mouseLocal = new THREE.Vector3();
|
|
||||||
|
|
||||||
const api = {
|
|
||||||
setState(name) {
|
|
||||||
if (name === stateName || reduced) return;
|
|
||||||
stateName = name;
|
|
||||||
target = STATES[name];
|
|
||||||
if (name === "answering") burst = 1; // 喷发脉冲,随帧衰减
|
|
||||||
},
|
|
||||||
setSide(s) {
|
|
||||||
side = s;
|
|
||||||
dockTarget.copy(dockFor(s));
|
|
||||||
particleUniforms.uDir.value = s === 0 ? 0 : -s; // 粒子喷向内容区
|
|
||||||
opacityTarget = s === 0 ? 0.42 : 1; // 居中停靠时减淡,保证内容可读
|
|
||||||
},
|
|
||||||
setPalette([a, b]) {
|
|
||||||
targetColorA.set(a);
|
|
||||||
targetColorB.set(b);
|
|
||||||
},
|
|
||||||
get state() {
|
|
||||||
return stateName;
|
|
||||||
},
|
|
||||||
debug() {
|
|
||||||
return { stateName, side, dock: dock.toArray(), dockTarget: dockTarget.toArray(), group: group.position.toArray() };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
api.setSide(1);
|
|
||||||
dock.copy(dockTarget);
|
|
||||||
group.position.copy(dock);
|
|
||||||
|
|
||||||
window.addEventListener("pointermove", (e) => {
|
|
||||||
pointer.x = (e.clientX / window.innerWidth) * 2 - 1;
|
|
||||||
pointer.y = (e.clientY / window.innerHeight) * 2 - 1;
|
|
||||||
hasPointer = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("pointerdown", (e) => {
|
|
||||||
pointer.x = (e.clientX / window.innerWidth) * 2 - 1;
|
|
||||||
pointer.y = (e.clientY / window.innerHeight) * 2 - 1;
|
|
||||||
hasPointer = true;
|
|
||||||
grabTarget = 1;
|
|
||||||
});
|
|
||||||
const release = () => { grabTarget = 0; };
|
|
||||||
window.addEventListener("pointerup", release);
|
|
||||||
window.addEventListener("pointercancel", release);
|
|
||||||
window.addEventListener("blur", release);
|
|
||||||
|
|
||||||
window.addEventListener("resize", () => {
|
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
||||||
camera.aspect = window.innerWidth / window.innerHeight;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
api.setSide(side);
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ---- 帧循环(由外部 ticker 驱动) ---- */
|
|
||||||
const clock = new THREE.Clock();
|
|
||||||
function tick() {
|
|
||||||
const dt = Math.min(clock.getDelta(), 0.05);
|
|
||||||
const t = clock.elapsedTime;
|
|
||||||
const k = 1 - Math.pow(0.0014, dt); // 帧率无关的平滑系数
|
|
||||||
|
|
||||||
current.freq = lerp(current.freq, target.freq, k);
|
|
||||||
current.amp = lerp(current.amp, target.amp, k);
|
|
||||||
current.speed = lerp(current.speed, target.speed, k);
|
|
||||||
current.heat = lerp(current.heat, target.heat, k);
|
|
||||||
current.scale = lerp(current.scale, target.scale, k);
|
|
||||||
current.attract = lerp(current.attract, target.attract, k);
|
|
||||||
burst = lerp(burst, 0, 1 - Math.pow(0.25, dt)); // 脉冲衰减
|
|
||||||
|
|
||||||
blobUniforms.uTime.value = reduced ? t * 0.25 : t;
|
|
||||||
blobUniforms.uFreq.value = current.freq;
|
|
||||||
blobUniforms.uAmp.value = current.amp;
|
|
||||||
blobUniforms.uSpeed.value = current.speed;
|
|
||||||
blobUniforms.uHeat.value = current.heat;
|
|
||||||
|
|
||||||
particleUniforms.uTime.value = reduced ? t * 0.25 : t;
|
|
||||||
particleUniforms.uAttract.value = current.attract;
|
|
||||||
particleUniforms.uBurst.value = burst;
|
|
||||||
particleUniforms.uHeat.value = current.heat;
|
|
||||||
|
|
||||||
colorA.lerp(targetColorA, k);
|
|
||||||
colorB.lerp(targetColorB, k);
|
|
||||||
|
|
||||||
const op = lerp(blobUniforms.uOpacity.value, opacityTarget, k);
|
|
||||||
blobUniforms.uOpacity.value = op;
|
|
||||||
particleUniforms.uOpacity.value = op;
|
|
||||||
|
|
||||||
const breathe = 1 + Math.sin(t * 1.4) * 0.018;
|
|
||||||
group.scale.setScalar(current.scale * breathe);
|
|
||||||
|
|
||||||
dock.lerp(dockTarget, 1 - Math.pow(0.02, dt));
|
|
||||||
group.position.set(
|
|
||||||
dock.x + pointer.x * 0.14,
|
|
||||||
dock.y - pointer.y * 0.1,
|
|
||||||
dock.z
|
|
||||||
);
|
|
||||||
group.rotation.y += dt * 0.12;
|
|
||||||
group.rotation.x = pointer.y * 0.08;
|
|
||||||
|
|
||||||
// 鼠标牵引:把指针投影到流体所在深度平面,转到 group 局部空间后平滑跟随
|
|
||||||
if (hasPointer && !reduced) {
|
|
||||||
mouseRay.set(pointer.x, -pointer.y, 0.5).unproject(camera).sub(camera.position).normalize();
|
|
||||||
const planeDist = (group.position.z - camera.position.z) / mouseRay.z;
|
|
||||||
mouseLocal.copy(camera.position).addScaledVector(mouseRay, planeDist);
|
|
||||||
group.updateMatrixWorld();
|
|
||||||
group.worldToLocal(mouseLocal);
|
|
||||||
if (!mouseSnapped) {
|
|
||||||
particleUniforms.uMouse.value.copy(mouseLocal);
|
|
||||||
mouseSnapped = true;
|
|
||||||
} else {
|
|
||||||
particleUniforms.uMouse.value.lerp(mouseLocal, 1 - Math.pow(0.01, dt));
|
|
||||||
}
|
|
||||||
particleUniforms.uMouseForce.value = lerp(particleUniforms.uMouseForce.value, 0.85, k);
|
|
||||||
|
|
||||||
// 按压牵引:欠阻尼弹簧积分 —— 按下缓冲吸入,松开过冲散开再归位
|
|
||||||
grabVel += (grabTarget - grab) * 26 * dt;
|
|
||||||
grabVel *= Math.exp(-3.2 * dt);
|
|
||||||
grab += grabVel * dt;
|
|
||||||
particleUniforms.uGrab.value = grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...api, tick };
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
// 思维流体 GLSL —— Ashima simplex noise + 3 octave FBM + Fresnel
|
|
||||||
const SIMPLEX = /* glsl */ `
|
|
||||||
vec3 mod289(vec3 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
|
|
||||||
vec4 mod289(vec4 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
|
|
||||||
vec4 permute(vec4 x){ return mod289(((x*34.0)+1.0)*x); }
|
|
||||||
vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }
|
|
||||||
|
|
||||||
float snoise(vec3 v){
|
|
||||||
const vec2 C = vec2(1.0/6.0, 1.0/3.0);
|
|
||||||
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
|
|
||||||
|
|
||||||
vec3 i = floor(v + dot(v, C.yyy));
|
|
||||||
vec3 x0 = v - i + dot(i, C.xxx);
|
|
||||||
|
|
||||||
vec3 g = step(x0.yzx, x0.xyz);
|
|
||||||
vec3 l = 1.0 - g;
|
|
||||||
vec3 i1 = min(g.xyz, l.zxy);
|
|
||||||
vec3 i2 = max(g.xyz, l.zxy);
|
|
||||||
|
|
||||||
vec3 x1 = x0 - i1 + C.xxx;
|
|
||||||
vec3 x2 = x0 - i2 + C.yyy;
|
|
||||||
vec3 x3 = x0 - D.yyy;
|
|
||||||
|
|
||||||
i = mod289(i);
|
|
||||||
vec4 p = permute(permute(permute(
|
|
||||||
i.z + vec4(0.0, i1.z, i2.z, 1.0))
|
|
||||||
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
|
|
||||||
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
|
|
||||||
|
|
||||||
float n_ = 0.142857142857;
|
|
||||||
vec3 ns = n_ * D.wyz - D.xzx;
|
|
||||||
|
|
||||||
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
|
|
||||||
|
|
||||||
vec4 x_ = floor(j * ns.z);
|
|
||||||
vec4 y_ = floor(j - 7.0 * x_);
|
|
||||||
|
|
||||||
vec4 x = x_ * ns.x + ns.yyyy;
|
|
||||||
vec4 y = y_ * ns.x + ns.yyyy;
|
|
||||||
vec4 h = 1.0 - abs(x) - abs(y);
|
|
||||||
|
|
||||||
vec4 b0 = vec4(x.xy, y.xy);
|
|
||||||
vec4 b1 = vec4(x.zw, y.zw);
|
|
||||||
|
|
||||||
vec4 s0 = floor(b0) * 2.0 + 1.0;
|
|
||||||
vec4 s1 = floor(b1) * 2.0 + 1.0;
|
|
||||||
vec4 sh = -step(h, vec4(0.0));
|
|
||||||
|
|
||||||
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
|
|
||||||
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
|
|
||||||
|
|
||||||
vec3 p0 = vec3(a0.xy, h.x);
|
|
||||||
vec3 p1 = vec3(a0.zw, h.y);
|
|
||||||
vec3 p2 = vec3(a1.xy, h.z);
|
|
||||||
vec3 p3 = vec3(a1.zw, h.w);
|
|
||||||
|
|
||||||
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
|
|
||||||
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
|
|
||||||
|
|
||||||
vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
|
|
||||||
m = m * m;
|
|
||||||
return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
|
|
||||||
}
|
|
||||||
|
|
||||||
float fbm(vec3 p){
|
|
||||||
float sum = 0.0;
|
|
||||||
float amp = 0.5;
|
|
||||||
for (int i = 0; i < 3; i++) {
|
|
||||||
sum += amp * snoise(p);
|
|
||||||
p *= 2.1;
|
|
||||||
amp *= 0.5;
|
|
||||||
}
|
|
||||||
return sum;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const blobVertex = /* glsl */ `
|
|
||||||
uniform float uTime;
|
|
||||||
uniform float uFreq;
|
|
||||||
uniform float uAmp;
|
|
||||||
uniform float uSpeed;
|
|
||||||
|
|
||||||
varying float vDisp;
|
|
||||||
varying vec3 vNormal;
|
|
||||||
varying vec3 vViewDir;
|
|
||||||
|
|
||||||
${SIMPLEX}
|
|
||||||
|
|
||||||
void main(){
|
|
||||||
float t = uTime * uSpeed;
|
|
||||||
float d = fbm(normal * uFreq + vec3(t, t * 0.7, -t * 0.4));
|
|
||||||
vDisp = d;
|
|
||||||
vec3 displaced = position + normal * d * uAmp;
|
|
||||||
|
|
||||||
vec4 mvPosition = modelViewMatrix * vec4(displaced, 1.0);
|
|
||||||
vNormal = normalize(normalMatrix * normal);
|
|
||||||
vViewDir = normalize(-mvPosition.xyz);
|
|
||||||
gl_Position = projectionMatrix * mvPosition;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const blobFragment = /* glsl */ `
|
|
||||||
uniform vec3 uColorA;
|
|
||||||
uniform vec3 uColorB;
|
|
||||||
uniform float uHeat;
|
|
||||||
uniform float uOpacity;
|
|
||||||
|
|
||||||
varying float vDisp;
|
|
||||||
varying vec3 vNormal;
|
|
||||||
varying vec3 vViewDir;
|
|
||||||
|
|
||||||
void main(){
|
|
||||||
float fresnel = pow(1.0 - max(dot(vNormal, vViewDir), 0.0), 2.4);
|
|
||||||
vec3 base = mix(uColorA, uColorB, vDisp * 0.5 + 0.5);
|
|
||||||
base = mix(base, vec3(1.0, 0.62, 0.38), uHeat * 0.4);
|
|
||||||
vec3 color = base * (0.32 + fresnel * 1.6) + base * smoothstep(0.35, 0.9, vDisp) * 0.45;
|
|
||||||
float alpha = (0.22 + fresnel * 0.85) * uOpacity;
|
|
||||||
gl_FragColor = vec4(color, alpha);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const particleVertex = /* glsl */ `
|
|
||||||
uniform float uTime;
|
|
||||||
uniform float uAttract; // 1 = 吸入核心 (thinking)
|
|
||||||
uniform float uBurst; // 1 = 向外喷发 (answering)
|
|
||||||
uniform float uDir; // 喷发的水平方向(指向内容区)
|
|
||||||
uniform float uSize;
|
|
||||||
uniform vec3 uMouse; // 指针在粒子局部空间的位置
|
|
||||||
uniform float uMouseForce;
|
|
||||||
uniform float uGrab; // 按压牵引强度(弹簧驱动,可为负 → 回弹散开)
|
|
||||||
|
|
||||||
attribute vec3 aSeed; // 每粒子随机种子
|
|
||||||
attribute float aRadius;
|
|
||||||
attribute float aSpeed;
|
|
||||||
|
|
||||||
varying float vFade;
|
|
||||||
|
|
||||||
void main(){
|
|
||||||
float t = uTime * aSpeed;
|
|
||||||
// 进动轨道:两组相位叠加,避免规则圆轨
|
|
||||||
float theta = aSeed.x * 6.2831 + t;
|
|
||||||
float phi = aSeed.y * 3.1415 + sin(t * 0.6 + aSeed.z * 6.2831) * 0.7;
|
|
||||||
|
|
||||||
float r = aRadius * mix(1.0, 0.3, uAttract);
|
|
||||||
r += uBurst * (0.8 + aSeed.z * 2.6);
|
|
||||||
|
|
||||||
vec3 pos = vec3(
|
|
||||||
cos(theta) * sin(phi) * r,
|
|
||||||
cos(phi) * r * 0.85,
|
|
||||||
sin(theta) * sin(phi) * r
|
|
||||||
);
|
|
||||||
// 喷发时整体偏向内容区一侧
|
|
||||||
pos.x += uBurst * uDir * (0.6 + aSeed.y * 1.8);
|
|
||||||
|
|
||||||
// 鼠标牵引:靠近指针的粒子被吸过去,绕指针形成小漩涡
|
|
||||||
float md = distance(pos, uMouse);
|
|
||||||
float pull = smoothstep(2.8, 0.2, md) * uMouseForce * (0.35 + aSeed.z * 0.65);
|
|
||||||
vec3 swirl = vec3(
|
|
||||||
sin(t * 2.2 + aSeed.x * 6.2831),
|
|
||||||
cos(t * 1.7 + aSeed.y * 6.2831),
|
|
||||||
sin(t * 1.3 + aSeed.z * 6.2831)
|
|
||||||
) * (0.12 + aSeed.y * 0.3);
|
|
||||||
pos = mix(pos, uMouse + swirl, pull);
|
|
||||||
|
|
||||||
// 按住鼠标:全体粒子被牵向按压点,逐粒错相往复脉动;
|
|
||||||
// uGrab 为负(松开回弹)时 mix 外推 → 粒子向外散开再被拉回
|
|
||||||
float osc = 0.55 + 0.45 * sin(uTime * (1.2 + aSeed.x * 1.6) + aSeed.y * 6.2831);
|
|
||||||
float gpull = clamp(uGrab, -0.35, 1.25) * osc * (0.5 + aSeed.z * 0.5);
|
|
||||||
gpull = clamp(gpull, -0.4, 0.96);
|
|
||||||
vec3 gswirl = vec3(
|
|
||||||
sin(t * 1.8 + aSeed.y * 6.2831),
|
|
||||||
cos(t * 1.4 + aSeed.z * 6.2831),
|
|
||||||
sin(t * 1.1 + aSeed.x * 6.2831)
|
|
||||||
) * (0.18 + aSeed.x * 0.5);
|
|
||||||
pos = mix(pos, uMouse + gswirl, gpull);
|
|
||||||
|
|
||||||
vFade = 1.0 - uBurst * aSeed.z * 0.9;
|
|
||||||
vFade = min(vFade + pull * 0.5 + max(gpull, 0.0) * 0.35, 1.2);
|
|
||||||
|
|
||||||
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
|
|
||||||
gl_PointSize = uSize * (0.6 + aSeed.z) * (1.0 + pull * 0.5 + max(gpull, 0.0) * 0.3) * (3.4 / -mvPosition.z);
|
|
||||||
gl_Position = projectionMatrix * mvPosition;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const particleFragment = /* glsl */ `
|
|
||||||
uniform vec3 uColorA;
|
|
||||||
uniform vec3 uColorB;
|
|
||||||
uniform float uHeat;
|
|
||||||
uniform float uOpacity;
|
|
||||||
|
|
||||||
varying float vFade;
|
|
||||||
|
|
||||||
void main(){
|
|
||||||
vec2 uv = gl_PointCoord - 0.5;
|
|
||||||
float d = length(uv);
|
|
||||||
float mask = smoothstep(0.5, 0.05, d);
|
|
||||||
vec3 color = mix(uColorA, uColorB, vFade);
|
|
||||||
color = mix(color, vec3(1.0, 0.62, 0.38), uHeat * 0.35);
|
|
||||||
gl_FragColor = vec4(color, mask * vFade * 0.75 * uOpacity);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
-241
@@ -1,241 +0,0 @@
|
|||||||
import {
|
|
||||||
experiences,
|
|
||||||
focusAreas,
|
|
||||||
metrics,
|
|
||||||
projects,
|
|
||||||
resumeSignals,
|
|
||||||
skills,
|
|
||||||
} from "./resume-data";
|
|
||||||
import { contact, rounds } from "./dialogue";
|
|
||||||
|
|
||||||
const el = (tag, className, html) => {
|
|
||||||
const node = document.createElement(tag);
|
|
||||||
if (className) node.className = className;
|
|
||||||
if (html != null) node.innerHTML = html;
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
|
|
||||||
const externalLink = (className, href, text, ariaLabel = text) => {
|
|
||||||
const node = el("a", className);
|
|
||||||
node.href = href;
|
|
||||||
node.target = "_blank";
|
|
||||||
node.rel = "noreferrer";
|
|
||||||
node.textContent = text;
|
|
||||||
node.setAttribute("aria-label", ariaLabel);
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---------- 各轮回答内容 ---------- */
|
|
||||||
|
|
||||||
function bootContent(answer) {
|
|
||||||
answer.append(
|
|
||||||
el(
|
|
||||||
"h1",
|
|
||||||
"boot-greeting",
|
|
||||||
`你好,我是<span class="grad">湛兮(花名)</span>。<br /><span class="typed-line"></span>`,
|
|
||||||
),
|
|
||||||
el(
|
|
||||||
"p",
|
|
||||||
"boot-identity gen",
|
|
||||||
`7 年 Web 与跨端前端 · 最近一年在 <em>AI 产品团队</em> 做对话链路与控制台`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const stream = el("div", "metric-stream");
|
|
||||||
metrics.forEach((m) => {
|
|
||||||
const token = el("div", "metric-token gen");
|
|
||||||
token.append(el("span", "v", m.value), el("span", "l", m.label));
|
|
||||||
stream.append(token);
|
|
||||||
});
|
|
||||||
answer.append(stream, el("div", "boot-hint gen", "SCROLL TO ASK"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function signalsContent(answer) {
|
|
||||||
answer.append(el("div", "a-title gen", "Self Introduction"));
|
|
||||||
resumeSignals.forEach((line, i) => {
|
|
||||||
const row = el("div", "signal-line gen");
|
|
||||||
row.append(
|
|
||||||
el("span", "idx", String(i + 1).padStart(2, "0")),
|
|
||||||
el("p", null, line),
|
|
||||||
);
|
|
||||||
answer.append(row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusContent(answer) {
|
|
||||||
answer.append(el("div", "a-title gen", "Last 12 Months"));
|
|
||||||
const grid = el("div", "focus-grid");
|
|
||||||
answer.append(grid);
|
|
||||||
focusAreas.forEach((f) => {
|
|
||||||
const card = el("div", "focus-card gen");
|
|
||||||
card.append(el("h3", null, f.title), el("p", null, f.summary));
|
|
||||||
const tags = el("div", "tag-row");
|
|
||||||
f.points.forEach((p) => tags.append(el("span", "tag", p)));
|
|
||||||
card.append(tags);
|
|
||||||
grid.append(card);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function projectsContent(answer) {
|
|
||||||
answer.append(el("div", "a-title gen", "Selected Proof"));
|
|
||||||
projects.forEach((p) => {
|
|
||||||
const block = el("article", "project-block gen");
|
|
||||||
const head = el("div", "project-head");
|
|
||||||
const meta = el("div", "project-meta");
|
|
||||||
meta.append(
|
|
||||||
p.url
|
|
||||||
? externalLink("name entity-link", p.url, p.name, `打开 ${p.name}`)
|
|
||||||
: el("span", "name", p.name),
|
|
||||||
);
|
|
||||||
if (p.links?.length > 1) {
|
|
||||||
const links = el("div", "project-links");
|
|
||||||
p.links.forEach((link) => {
|
|
||||||
links.append(
|
|
||||||
externalLink("mini-link", link.url, link.label, `打开 ${link.label}`),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
meta.append(links);
|
|
||||||
}
|
|
||||||
head.append(
|
|
||||||
Object.assign(el("img"), { src: p.logo, alt: p.name, loading: "lazy" }),
|
|
||||||
meta,
|
|
||||||
el("span", "period", p.period),
|
|
||||||
);
|
|
||||||
block.append(
|
|
||||||
head,
|
|
||||||
el("div", "project-sub", p.subtitle),
|
|
||||||
el("p", "project-summary", p.summary),
|
|
||||||
);
|
|
||||||
const ul = el("ul", "project-modules");
|
|
||||||
p.modules.forEach((m) => ul.append(el("li", "gen-sub", m)));
|
|
||||||
block.append(ul);
|
|
||||||
const tags = el("div", "tag-row");
|
|
||||||
p.tech.forEach((t) => tags.append(el("span", "tag", t)));
|
|
||||||
block.append(tags);
|
|
||||||
answer.append(block);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function skillsContent(answer) {
|
|
||||||
answer.append(el("div", "a-title gen", "Token Stream / Skills"));
|
|
||||||
const HOT = new Set([
|
|
||||||
"SSE 流式通信",
|
|
||||||
"Next.js",
|
|
||||||
"React",
|
|
||||||
"React Native",
|
|
||||||
"TypeScript",
|
|
||||||
"Qiankun",
|
|
||||||
]);
|
|
||||||
skills.forEach((group) => {
|
|
||||||
const wrap = el("div", "skill-group");
|
|
||||||
wrap.append(el("div", "g-name", group.group));
|
|
||||||
const row = el("div", "tag-row");
|
|
||||||
group.items.forEach((item) => {
|
|
||||||
row.append(
|
|
||||||
el("span", `skill-token gen${HOT.has(item) ? " hot" : ""}`, item),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
wrap.append(row);
|
|
||||||
answer.append(wrap);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function experienceContent(answer) {
|
|
||||||
answer.append(el("div", "a-title gen", "Career Path"));
|
|
||||||
experiences.forEach((exp) => {
|
|
||||||
const item = el("div", "exp-item gen");
|
|
||||||
const head = el("div", "exp-head");
|
|
||||||
head.append(
|
|
||||||
Object.assign(el("img"), {
|
|
||||||
src: exp.logo,
|
|
||||||
alt: exp.company,
|
|
||||||
loading: "lazy",
|
|
||||||
}),
|
|
||||||
exp.url
|
|
||||||
? externalLink(
|
|
||||||
"co entity-link",
|
|
||||||
exp.url,
|
|
||||||
exp.company,
|
|
||||||
`打开 ${exp.company}`,
|
|
||||||
)
|
|
||||||
: el("span", "co", exp.company),
|
|
||||||
el("span", "period", exp.period),
|
|
||||||
);
|
|
||||||
item.append(head, el("div", "exp-role", exp.role));
|
|
||||||
const ul = el("ul", "exp-points");
|
|
||||||
exp.points.forEach((pt) => ul.append(el("li", null, pt)));
|
|
||||||
item.append(ul);
|
|
||||||
answer.append(item);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function contactContent(answer) {
|
|
||||||
answer.append(
|
|
||||||
el("h2", "contact-lead gen", "随时可以开启下一轮对话。"),
|
|
||||||
el("p", "contact-id gen", contact.identity),
|
|
||||||
);
|
|
||||||
const actions = el("div", "contact-actions gen");
|
|
||||||
const mail = el("a", "contact-btn primary", "发送邮件");
|
|
||||||
mail.href = `mailto:${contact.emails[0]}`;
|
|
||||||
const mail2 = el("a", "contact-btn", contact.emails[1]);
|
|
||||||
mail2.href = `mailto:${contact.emails[1]}`;
|
|
||||||
const gh = el("a", "contact-btn", "GitHub / zhanBoss");
|
|
||||||
gh.href = contact.github;
|
|
||||||
gh.target = "_blank";
|
|
||||||
gh.rel = "noreferrer";
|
|
||||||
const tel = el("a", "contact-btn", contact.phone);
|
|
||||||
tel.href = `tel:${contact.phone}`;
|
|
||||||
actions.append(mail, mail2, gh, tel);
|
|
||||||
answer.append(actions, el("div", "session-end gen", "SESSION SAVED · 2026"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONTENT = {
|
|
||||||
boot: bootContent,
|
|
||||||
signals: signalsContent,
|
|
||||||
focus: focusContent,
|
|
||||||
projects: projectsContent,
|
|
||||||
skills: skillsContent,
|
|
||||||
experience: experienceContent,
|
|
||||||
contact: contactContent,
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---------- 装配 ---------- */
|
|
||||||
|
|
||||||
export function renderDialogue(mount) {
|
|
||||||
rounds.forEach((round) => {
|
|
||||||
const section = el("section", "round");
|
|
||||||
section.id = `r-${round.id}`;
|
|
||||||
section.dataset.side = String(round.side);
|
|
||||||
const inner = el("div", "round-inner");
|
|
||||||
|
|
||||||
if (round.question) {
|
|
||||||
const qRow = el("div", "q-row");
|
|
||||||
const bubble = el("div", "q-bubble");
|
|
||||||
bubble.append(el("span", "q-text"), el("span", "q-caret"));
|
|
||||||
qRow.append(bubble);
|
|
||||||
const thinking = el("div", "thinking");
|
|
||||||
const dots = el("span", "dots");
|
|
||||||
dots.append(el("span", "dot"), el("span", "dot"), el("span", "dot"));
|
|
||||||
thinking.append(dots, el("span", null, "THINKING"));
|
|
||||||
inner.append(qRow, thinking);
|
|
||||||
}
|
|
||||||
|
|
||||||
const answer = el("div", "answer");
|
|
||||||
CONTENT[round.type](answer);
|
|
||||||
if (round.type !== "boot") answer.append(el("span", "a-caret", "▋"));
|
|
||||||
inner.append(answer);
|
|
||||||
section.append(inner);
|
|
||||||
mount.append(section);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderRail(mount, onJump) {
|
|
||||||
rounds.forEach((round) => {
|
|
||||||
const node = el("button", "rail-node");
|
|
||||||
node.type = "button";
|
|
||||||
node.dataset.round = round.id;
|
|
||||||
node.setAttribute("aria-label", `跳转到 ${round.label}`);
|
|
||||||
node.append(el("span", "rail-label", round.label));
|
|
||||||
node.addEventListener("click", () => onJump(round.id));
|
|
||||||
mount.append(node);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -73,11 +73,6 @@ export const projects = [
|
|||||||
id: "vtrix",
|
id: "vtrix",
|
||||||
name: "SeaCloud / Vtrix",
|
name: "SeaCloud / Vtrix",
|
||||||
logo: "/logos/vtrix.png",
|
logo: "/logos/vtrix.png",
|
||||||
url: "https://cloud.seaart.ai/",
|
|
||||||
links: [
|
|
||||||
{ label: "SeaCloud", url: "https://cloud.seaart.ai/" },
|
|
||||||
{ label: "Vtrix", url: "https://www.vtrix.ai/" },
|
|
||||||
],
|
|
||||||
period: "2025.09 - 至今",
|
period: "2025.09 - 至今",
|
||||||
subtitle: "模型服务平台控制台",
|
subtitle: "模型服务平台控制台",
|
||||||
summary:
|
summary:
|
||||||
@@ -94,7 +89,6 @@ export const projects = [
|
|||||||
id: "seabuzz",
|
id: "seabuzz",
|
||||||
name: "SeaBuzz",
|
name: "SeaBuzz",
|
||||||
logo: "/logos/seabuzz.webp",
|
logo: "/logos/seabuzz.webp",
|
||||||
url: "https://play.google.com/store/apps/details?id=app.seahot.ai",
|
|
||||||
period: "2025.05 - 至今",
|
period: "2025.05 - 至今",
|
||||||
subtitle: "资讯、搜索与对话应用",
|
subtitle: "资讯、搜索与对话应用",
|
||||||
summary:
|
summary:
|
||||||
@@ -112,7 +106,6 @@ export const projects = [
|
|||||||
id: "bawang",
|
id: "bawang",
|
||||||
name: "霸王功夫",
|
name: "霸王功夫",
|
||||||
logo: "/logos/chagee.png",
|
logo: "/logos/chagee.png",
|
||||||
url: "https://bwcj.com/",
|
|
||||||
period: "2024.03 - 2025.03",
|
period: "2024.03 - 2025.03",
|
||||||
subtitle: "门店、食安与供应链系统",
|
subtitle: "门店、食安与供应链系统",
|
||||||
summary:
|
summary:
|
||||||
@@ -129,7 +122,6 @@ export const projects = [
|
|||||||
id: "jingyingbang",
|
id: "jingyingbang",
|
||||||
name: "经营帮平台 + 经营帮拉新",
|
name: "经营帮平台 + 经营帮拉新",
|
||||||
logo: "/logos/jingyingbang.png",
|
logo: "/logos/jingyingbang.png",
|
||||||
url: "https://jingyingbang.com/",
|
|
||||||
period: "2022.04 - 2024.03",
|
period: "2022.04 - 2024.03",
|
||||||
subtitle: "微前端平台与小程序",
|
subtitle: "微前端平台与小程序",
|
||||||
summary:
|
summary:
|
||||||
@@ -148,7 +140,6 @@ export const experiences = [
|
|||||||
{
|
{
|
||||||
company: "成都海艺互娱科技有限公司",
|
company: "成都海艺互娱科技有限公司",
|
||||||
logo: "/logos/seaart.webp",
|
logo: "/logos/seaart.webp",
|
||||||
url: "https://www.seaart.ai/",
|
|
||||||
role: "React Native 开发工程师",
|
role: "React Native 开发工程师",
|
||||||
period: "2025.05 - 至今",
|
period: "2025.05 - 至今",
|
||||||
points: [
|
points: [
|
||||||
@@ -160,7 +151,6 @@ export const experiences = [
|
|||||||
{
|
{
|
||||||
company: "四川茶姬企业管理有限公司",
|
company: "四川茶姬企业管理有限公司",
|
||||||
logo: "/logos/chagee.png",
|
logo: "/logos/chagee.png",
|
||||||
url: "https://bwcj.com/",
|
|
||||||
role: "高级 Web 前端开发",
|
role: "高级 Web 前端开发",
|
||||||
period: "2024.03 - 2025.03",
|
period: "2024.03 - 2025.03",
|
||||||
points: [
|
points: [
|
||||||
@@ -172,7 +162,6 @@ export const experiences = [
|
|||||||
{
|
{
|
||||||
company: "中钧科技有限公司四川分公司",
|
company: "中钧科技有限公司四川分公司",
|
||||||
logo: "/logos/zhongjun.png",
|
logo: "/logos/zhongjun.png",
|
||||||
url: "https://zhongjunkeji.com/",
|
|
||||||
role: "前端开发组长",
|
role: "前端开发组长",
|
||||||
period: "2022.04 - 2024.03",
|
period: "2022.04 - 2024.03",
|
||||||
points: [
|
points: [
|
||||||
|
|||||||
+848
-636
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user