项目概览
Moonlight 是 Ninentropy 的个人网站与作品集,仍在开发中。
整站以滚动叙事为主轴,承载若干互相独立的功能模块(地图、创作工具、博客、Agent Builder 等),所有模块共用一套全局状态、平滑滚动与暗黑主题。
技术栈
框架与语言
- Next.js 15.5(App Router)
- React 19 + React Compiler(babel-plugin-react-compiler)
- TypeScript 5.7
样式
- Tailwind CSS v4(PostCSS 插件,无独立 config 文件)
- 模块级 CSS Modules(动画、滚动相关样式不走 Tailwind)
动画与滚动
- GSAP 3.14 + ScrollTrigger +
@gsap/react - Lenis 1.1(平滑滚动,与 GSAP ticker 同步驱动)
- Lottie-react 2.4(首页签名动画)
地图
- MapLibre GL 5(CartoDB 免 Key,矢量瓦片,WebGL 渲染)
状态与数据
- Zustand 5(全局状态:isLoading / isScrollLocked / isHomePage / isHomePageReady)
- next-themes 0.4(暗黑模式,
data-theme属性切换) - nuqs 2.8(URL search params 类型安全管理)
数据库与持久化
- Prisma 6 + SQLite
Marker表(地图标记)+PageView表(站点分析)- SQLite 文件被 git 追踪,随代码一起部署
自定义运行时
- 自写
server.ts(Nodehttp+ Next.js 接管 +ws模块 WebSocket 升级) tsx直接运行 TypeScript 服务端源码
博客与内容管线
gray-matter/remark(GFM)/remark-rehype/rehype-stringify/github-slugger/unist-util-visit- 共享
src/lib/markdown.ts:blog 和 Agent Builder 复用同一套 Markdown → HTML 管线
Agent Builder 依赖
@xyflow/react(节点图编辑器)js-tiktoken(token 计数)
目录结构
src/
├── app/ App Router
│ ├── layout.tsx 全局元信息 + Inter 字体 + Providers
│ ├── page.tsx 首页:StartAnimation + HomePageClientWrapper (React.lazy)
│ ├── _components/ 首页专属客户端组件(Next.js 不路由)
│ │ ├── ClientWrapper.tsx 路径 → isHomePage 标记
│ │ ├── Providers.tsx next-themes + nuqs
│ │ ├── SmoothScroll.tsx Lenis + GSAP ticker 统一驱动
│ │ ├── StartAnimation.tsx Lottie 签名 → 轮询 isHomePageReady → 预加载 Brush Worker
│ │ ├── HomePageClient.tsx 首页入口 → setHomePageReady
│ │ ├── HomePageClientWrapper.tsx React.lazy + Suspense 异步 chunk
│ │ ├── BorderScroll.tsx SVG 边框描边动画
│ │ ├── sectionA.tsx / sectionB.tsx / mainSection.tsx / sectionC.tsx
│ │ └── HorizontalScroll.tsx 水平滚动轨道
│ ├── map/ 食旅纪行 & 旷野之息(MapLibre + Markers CRUD)
│ ├── life/ 浮生札记(博客 SSG + 客户端 TOC)
│ │ ├── page.tsx 列表页(日期降序)
│ │ └── [slug]/page.tsx 详情页(SSG,dynamicParams = false)
│ ├── project/
│ │ ├── tornpaper/ TornPaper SVG 撕裂纸编辑器
│ │ ├── brush/ Brush PS 风格笔刷编辑器
│ │ ├── agent/ Agent Builder(LLM 编排)
│ │ ├── stslink/ StsLink 杀戮尖塔实时观战
│ │ └── analytics/ 站点分析仪表盘
│ ├── design/ 设计作品展示(TornPaper + Brush 卡片)
│ ├── games/ 占位
│ └── sonance/ 占位
├── components/
│ ├── TornPaper.tsx SVG 撕裂纸(SplitMix32 确定性哈希)
│ ├── BrushStroke.tsx 笔刷运行时组件 + 导出渲染器
│ └── BackLink.tsx router.back() → push(fallbackHref),渲染 <a>
├── store/useAppStore.ts Zustand 全局状态
├── lib/
│ ├── db.ts Prisma 单例
│ ├── markdown.ts Markdown → HTML(blog + agent 复用)
│ ├── posts.ts Markdown 解析 + TOC 提取(life 博客)
│ ├── stslink-ws.ts WebSocket 服务端
│ ├── brush/ Brush 核心引擎
│ │ ├── deterministic.ts SplitMix32 确定性哈希
│ │ ├── compute.ts 纯计算引擎(buildStrokeData / dab / easing 分桶)
│ │ ├── cache.ts StrokeData + SVG 双缓存
│ │ ├── worker.ts Web Worker
│ │ └── preloader.ts StartAnimation 阶段预加载编排
│ └── agent/ Agent Builder 核心
└── assets/ae/ Lottie JSON + Animation 导出数据
content/posts/ 博客 Markdown 源文件
prisma/
├── schema.prisma Marker + PageView 模型
└── dev.db 开发用 SQLite(git 追踪)
server.ts 自定义服务器入口
ecosystem.config.cjs PM2 配置
.github/workflows/deploy.yml CI/CD 自动部署
代码分离 & 启动时序
HomePageClientWrapper 用 React.lazy + <Suspense fallback={null}> 将 GSAP / ScrollTrigger / sectionA/B/C / mainSection 等重型依赖打入异步 chunk,不阻塞首屏。
StartAnimation(首屏关键 chunk)在 Lottie 签名动画循环期间:
- 轮询 Zustand
isHomePageReady(异步 chunk 是否加载完毕) - 满足最短 2.5s 展示时间
- 期间通过 Web Worker 预计算 Brush 笔触 SVG 数据并缓存
四个 Zustand 状态协同:
isLoading— loading 遮罩可见性isScrollLocked— Lenis 滚动锁定isHomePage— 当前路径是否为首页isHomePageReady— 异步 chunk 就绪信号
首次访问:2.5s Lottie + 轮询 isHomePageReady → CSS 淡出 → 解锁滚动。
重访:sessionStorage['startAnimationPlayed'] → 跳过 loading,仅锁滚动 500ms。
SectionB 动画同样使用 sessionStorage['sectionBAnimationPlayed'] 短路重访。
技术选型说明
为什么用 App Router 而非 Pages Router
App Router 默认服务端组件、内置异步数据获取与 generateStaticParams,博客的 SSG、地图页的服务端初始数据加载、布局共享都能在路由层直接解决,不需要再引入额外的数据获取层。
为什么用 GSAP + ScrollTrigger 而不是 Framer Motion
首页的滚动叙事是多步、可逆、相互联动的时间线(边框描边 → 垂直层位移 → 水平轨道 → 最终扩展铺满)。GSAP 的 timeline 模型与 ScrollTrigger 的 scrub 机制对这种"按滚动进度回放任意复杂动画"的场景更合适。Framer Motion 更适合组件级的进入/退出过渡。
为什么用 Lenis 配合 GSAP
原生滚动在跨浏览器、跨设备上的曲线不一致;Lenis 提供统一的平滑滚动曲线,并能与 GSAP ticker 同步驱动,避免两个 RAF 循环错开导致的画面抖动。
为什么用 MapLibre 而非 Mapbox / Leaflet
- 不需要 Mapbox 的 API key 和按量计费
- 矢量瓦片性能优于 Leaflet 的栅格瓦片
- 渲染由 WebGL 驱动,与现有 Canvas / SVG 内容栈一致
为什么用 SQLite + Prisma
- 单文件即数据库,零运维,部署只要复制文件
- 地图标记 + 站点分析数据量级在万以内,SQLite 完全够用
- Prisma 提供类型安全的查询和 schema 管理(
prisma db push)
为什么自写 server.ts
StsLink 联机工具需要 WebSocket。Next.js 自带 server 不直接暴露升级钩子;自写一个 Node HTTP server 包住 Next,再在 upgrade 事件里挂 WS handler,是最干净的做法。
为什么博客用 Markdown 而不用数据库
- 博客是随代码部署的内容,不是用户产生的数据,进数据库没有收益
- Markdown 文件版本化、可 diff、可在 IDE 里直接编辑
- App Router 的
generateStaticParams+dynamicParams = false让所有文章在 build 时静态化,运行时零开销
为什么引入 React Compiler
React 19 的 React Compiler(babel-plugin-react-compiler)在构建时自动 memo 组件和 hooks,减少手工 useMemo/useCallback/React.memo 的心智负担,同时保证动画密集场景下的渲染性能。
为什么用 SplitMix32 确定性哈希
TornPaper 和 Brush 的 SVG 需要在服务端和客户端产生完全一致的输出,避免 hydration mismatch。Math.sin() / Math.random() 在不同环境下不可复现;SplitMix32(Math.imul + 位运算)是纯整数确定性算法,SSR 和客户端结果完全一致。
已完工模块
首页 /
- 滚动叙事主时间线由 HomePageClient 驱动
- 分段:
StartAnimation→BorderScroll(SVG 描边) →sectionA→sectionB(撕裂纸 + Brush 笔触装饰动画) →mainSection(3D 六边形卡片) →sectionC(友链) →HorizontalScroll - 平滑滚动由 SmoothScroll 整合 Lenis 与 GSAP ticker
- SectionB 的 Brush 装饰 SVG 由 Web Worker 在 StartAnimation 阶段预计算,GSAP
tl.call在指定时间点直接createElement+innerHTML插入,播放完毕animationend事件自动销毁
足迹地图 /map
- 基于 MapLibre GL 渲染世界地图,CartoDB 免 Key
- 标记分类:
food(食旅纪行)/wild(旷野之息) Marker数据通过 Server Actions 持久化于 SQLiteNODE_ENV === 'development'时开放增删改,生产只读
TornPaper 工具 /project/tornpaper
- 分栏式编辑器:图层 / 场景 / 属性 / 点位
- SplitMix32 确定性算法保证 SSR/客户端输出一致
- 支持撤销 / 重做(
Ctrl+Z、Shift+Ctrl+Z) Delete删除选中点位- 可导出为自包含 SVG
Brush 笔刷编辑器 /project/brush
- PS 风格笔刷引擎:自由手绘路径原样保留,沿路径盖章 dab
- 支持图像笔尖、收尖、抖动、散布、颜色抖动
- Web Worker 异步计算,不阻塞主线程
- 可导出带动画的自包含 SVG 装饰元素
- 详细架构见
docs/brush.md
Agent Builder /project/agent
- 可视化 LLM Agent 编排工具
- 一个 Start 节点 = 一个 Agent,BFS 自动推导编排关系
- 三条铁律:遇 Start 停、连 Start in → 编排边、节点不共享
- API 双协议(Anthropic / OpenAI),SSE 流式 → rAF 批量渲染
- 基于
@xyflow/react节点图编辑器 - 详细架构见
docs/agent相关文档
StsLink 工具 /project/stslink
- 杀戮尖塔联机辅助
- WebSocket 通道由自写
server.ts在/project/stslink/ws路径挂载升级 - 同种匹配 / 单人模式 / Mod 调试
globalThis防 HMR;每连接 10 msg/s 限流;SSE 订阅 + REST 快照
博客模块 /life
- Markdown 源文件位于
content/posts/ src/lib/posts.ts负责 frontmatter 解析、AST 注入 heading id、提取 TOC、转 HTMLsrc/lib/markdown.ts提供共享的 Markdown → HTML 管线(remark GFM → remark-rehype → rehype-stringify)/life列表页按日期降序/life/[slug]详情页是 SSG(dynamicParams = false),附带客户端 TOC(IntersectionObserver 高亮 + 平滑滚动)
设计作品展示 /design
- TornPaper + Brush 两个创作工具的展示卡片页
- 内联 SVG 预览(TornPaper 展示分层撕裂 + 闭合圆斑;Brush 展示 dab 盖章 + 收尖 + 飞白颗粒)
- 悬停动效,跳转至对应工具页
站点分析 /project/analytics
- 仪表盘:今日 PV/UV、平均停留时长、跳出率、平均访问深度
- 累计统计 + 来源分布(7 天)
- 今日每小时 PV 柱状图 + 最近 7 天 PV 趋势
- Top 10 页面排行 + 最近访问实时列表
- 数据通过
PageView模型持久化于 SQLite
未完工 / 占位模块
/games— 游戏人生(计划嵌入 web 端小游戏)/sonance— 千音雅集(练琴日记)
均为占位页。
部署
本地开发
npm install
npx prisma generate
npx prisma db push # Schema → dev.db
npm run dev # 标准 Next.js 开发
npm run dev:ws # 启用自定义 server,含 WebSocket(StsLink 需要)
生产构建
npm run build
npm run start # 标准 Next.js 启动
npm run start:ws # 自定义 server(tsx server.ts),含 WebSocket
进程管理(PM2)
配置文件 ecosystem.config.cjs:
{
name: "backend",
script: "npm",
args: "run start:ws",
env: { NODE_ENV: "production" }
}
pm2 start ecosystem.config.cjs # 首次启动
pm2 startOrReload ecosystem.config.cjs --update-env # 热重载
CI/CD(GitHub Actions)
监听 main 分支 push,在自托管 runner 上自动部署:
- Checkout —
fetch-depth: 0拉取全部历史(含 SQLite 文件) - 停服 —
pm2 stop backend,释放 SQLite 文件锁 - DB 对齐 —
git checkout prisma/dev.db,用仓库版本覆盖服务器本地改动 - 装依赖 —
npm install - Prisma 生成 —
npx prisma generate - 构建 —
npm run build - 重启 —
pm2 startOrReload ecosystem.config.cjs --update-env
完整配置见 .github/workflows/deploy.yml。
数据库
DATABASE_URL="file:./prisma/dev.db"。SQLite 文件被 git 追踪,CI 构建时会自动对齐到仓库版本。
开发时修改 schema 后:
npx prisma db push # Schema → dev.db(无需 migration)
当前进度
- 完成约 60%
- 已具备完整功能的模块:首页滚动叙事、足迹地图、TornPaper、Brush、Agent Builder、StsLink、博客、Design 展示、站点分析
- 待开发:游戏人生、千音雅集、统一的页面间导览系统