用代码重构三体宇宙:t3code交互式知识档案系统全解析

发布时间:2026/10/28 7:17:32
用代码重构三体宇宙:t3code交互式知识档案系统全解析 我第一次看到“t3code”这个名字的时候第一反应是某个框架的缩写或者某个内部工具集的代号。但把拼写拆开来看t3 对应的是 Three-Body也就是《三体》的常用缩写code 自然就是代码。换句话说t3code 是一个把三体宇宙、科幻设定和工程化开发结合起来的个人项目。我给它定的定位是一套以三体宇宙为背景的交互式知识档案系统底层是结构化数据模型上层是可视化界面同时留出接口给后续扩展思维实验和剧情推演。这篇文章我会完整拆解这个项目从 0 到 1 的过程包括数据建模、纪元换算、关键模块实现以及我踩过的那些坑。1. 项目定位与整体设计思路1.1 t3code 解决的到底是什么问题三体宇宙的信息量非常大。且不说三条主线剧情单单是人物、舰队、星球、组织、事件、几个纪元之间的换算关系就足够让一个新读者晕头转向。传统的方法是翻书、翻 wiki、划线记笔记。但作为开发者我自然想换个方式把这部作品的设定结构化成数据用代码去组织、查询、渲染甚至推演。t3code 的核心价值就是“把一块块散落的设定信息变成可查询、可联动、可计算的结构化数据”。比如你想知道“威慑纪元初期执剑人和引力波天线分别处于什么状态”传统做法是回忆剧情、翻找章节而在 t3code 里这些关系会被建模成实体和事件通过时间线引擎自动对齐点开一个节点就能看到相关人物、相关舰船、相关坐标和当时的纪年背景。适合来参考这个项目的人主要是两类。一类是三体读者想利用工具整理自己的阅读笔记或者对“把一本书变成数据库”这件事本身感兴趣另一类是前端和全栈开发者想找一个科幻题材的练手项目把数据驱动、类型安全、组件化、可视化这些技术落到一个有意思的业务场景里。你不需要懂很深的科幻理论也不需要了解所有剧情只要跟着这篇文章一步步搭就能得到一个具备完整知识架构的交互档案库。1.2 为什么选择“数据驱动 轻量前端”的架构做这类项目最容易陷入的误区是一上来就设计一堆页面把 UI 当核心。其实这类内容产品的核心永远是数据页面只是数据的投影。所以我选择了数据驱动架构所有剧情设定都先抽象成数据结构前端只负责读取和渲染不改写内容逻辑。后端的干扰项全部砍掉没有数据库没有登录系统也不接 CMS。数据层直接用 TypeScript 的类型系统来约束结构内容以 JSON 或 TS 模块文件存放运行时通过一个轻量加载器统一读取。这样做有三个好处维护成本极低。新增一个人物或一条事件只需要改对应的数据文件不需要动页面。类型即文档。每个数据接口interface本身就说明了字段含义新人拿到代码不用看冗长的说明文档。离线可用、容易部署。静态站点打包以后直接扔到任意静态托管平台就能跑连服务器都不用买。在框架选择上我用了 Vue 3 加 Vite主要是因为这个项目偏原型验证和内容展示Vue 的单文件组件写起来清晰Vite 的开发体验又足够快。如果你更习惯 React可以无缝迁移核心的数据模型和业务逻辑是不受框架影响的。1.3 里程碑规划从数据梳理到可视化呈现我给自己定了四个里程碑每个里程碑都对应一个明确成果数据建模阶段梳理核心实体类型包括人物、文明、星舰、组织、事件、地点、纪元定义好字段和关系。时间线引擎阶段实现公元纪年与三体各纪元之间的换算规则搭建可按时间轴查询的基础服务。可视化页面阶段实现实体详情页、人物关系图、舰队列表、降维动画等核心交互。检索与扩展阶段支持全文搜索、标签过滤并预留“思维实验模拟器”的接口后续可以挂接黑暗森林猜疑链、曲率驱动航线规划等逻辑。实际开发中前两个阶段占了大约 60% 的时间。数据梳理远比写页面复杂尤其是时间线的换算规则反复调整了好几次。后面我会提到具体坑在哪。2. 核心模块拆解与细节实现2.1 实体数据模型角色与文明的档案设计这个项目里最小颗粒度是“实体”。一个实体可以是人、一艘飞船、一个文明也可以是一个组织或一座城市。我用下面的 TypeScript 接口定义了通用实体export type EntityType character | civilization | ship | organization | location | event; export interface Entity { id: string; // 唯一标识如 luoji name: string; // 展示名称 entryName?: string; // 原名/别名如 罗辑 / 执剑人 type: EntityType; tags: string[]; // 索引标签如 [主角, 面壁者, 执剑人] description: string; // 一句话简介 details?: Recordstring, string | number; // 可选扩展字段 timelineIds: string[]; // 关联的事件 ID relatedEntityIds: string[]; // 关联实体 ID用于关系图 quote?: string; // 经典语录 avatar?: string; // 头像资源路径没有则用占位图 }为什么要单独拎出一个relatedEntityIds字段因为关系图渲染时需要知道节点之间的连线。每次渲染前去遍历所有实体再计算关系太浪费预存关联 ID 还能顺手做“关联推荐”比如查看某个执剑人的时候把四位面壁者一起列出来。文明这种特殊实体额外加了一套子结构用来表达文明类型和发展阶段export interface CivilizationProfile { starSystem: string; // 母星系统 level: string; // 文明等级描述 techPoints: string[]; // 关键技术 survivalStatus: active | destroyed | unknown | dispersed; }我实际把三体文明、地球文明、星舰地球银河系人类都建立了完整档案。这个过程比想象中费力因为不同读者的归纳口径不同比如“星舰地球”到底算一个组织还是算一个新文明我在初版里把它定义成了“组织”后来又改成了“文明分散体”最后干脆同时挂两个类型标签查询时按type过滤即可。数据建模的第一原则就是不要强求唯一分类让标签去表达多义性。2.2 时间线引擎公元纪年到危机纪年的换算逻辑三体世界的时间线非常特殊存在多个纪年系统。危机纪元的元年对应公元 2007 年但作品里有些事件只给纪元没有绝对年份有些又只给年份不提纪元手动换算非常容易出错。既然项目定位是知识系统就不能在时间上含糊。我实现了一个TimelineEngine模块核心入口是一个convert方法export type EraCode CE | CRISIS | DETERRENCE | BROADCAST | COVER | UNKNOWN; const ERA_OFFSET: RecordExcludeEraCode, CE | UNKNOWN, number { CRISIS: 2007, // 危机纪元元年 公元2007年 DETERRENCE: 2208, // 威慑纪元元年 公元2208年 BROADCAST: 2272, // 广播纪元元年 公元2272年 COVER: 2333, // 掩体纪元元年 公元2333年 }; export function convertEra(year: number, era: EraCode): { era: EraCode; year: number } { if (era CE) return { era: CE, year }; if (era UNKNOWN) return { era: UNKNOWN, year: -1 }; return { era: CE, year: year ERA_OFFSET[era] }; }这个实现看起来简单但实际使用时要额外处理“公元前的年份”和“具体日期缺失”两种情况。我写了一个辅助函数toDisplayTime把不同数据源的日期统一成“纪元 年”的展示模式export function toDisplayTime(era: EraCode, year: number): string { if (era UNKNOWN || year 0) return 时间未知; return ${eraLabel(era)} ${year} 年; }纪年转换最头疼的是确认每年的“元年偏移量”。我翻了大量同人资料最终采用了一个争议较小的版本并把它写进了项目的README里。这里给所有做资料型项目的同行一个忠告虽然设定类数据没有唯一的“正确答案”但一定要在项目里声明“你采用的口径是什么”否则读者拿来跟你争论的时间足够你把整个前端重写三遍。2.3 宇宙社会学互动演示黑暗森林猜疑链的参数化模拟t3code 不止是一个静态档案库我还在其中加入了一个互动实验模块。第一个实验是“黑暗森林猜疑链模拟器”。原著中的猜疑链可以简化为两个文明节点之间的博弈两个文明之间距离越近互相感知到威胁的概率越高沟通成本越高选择直接攻击的倾向越强。我将其抽象成了这样interface CivilizationNode { name: string; techLevel: number; // 技术爆炸潜力0-1 threatPerceived: number; // 感知到的威胁值0-1 attackThreshold: number; // 攻击阈值0-1越低越激进 } function attackProbability(a: CivilizationNode, b: CivilizationNode, distance: number): number { const perception Math.min(1, (a.techLevel b.techLevel) * Math.exp(-distance * 0.3) 0.2); const desire perception - b.attackThreshold; return Math.min(1, Math.max(0, desire * 1.5)); }这里没有追求天文学尺度的真实目的只是展示“参数变化如何导致完全不同的结局”。界面提供两个滑杆分别控制“初始距离”和“文明科技发展速度”点击运行后可以看到两条互动曲线一条是威胁感知增长曲线一条是攻击概率曲线。当攻击概率超过某个阈值页面顶部会出现一个红移警告框提示“文明已判定对方为威胁”。这类小实验的意义在于把抽象概念变成可交互的模型它的逻辑肯定无法还原原著的复杂宇宙社会学但用来做读书会的讨论工具、教学展示或者只是自己玩一玩已经足够。动手改参数和看结果之间的因果关系能让人对“猜疑链”“技术爆炸”“威慑平衡”有直观感受。3. 实操过程从空目录到可运行档案库3.1 项目初始化与目录结构执行下面的命令初始化项目npm create vitelatest t3code -- --template vue-ts cd t3code npm install npm install vue-router4 pinia目录结构我遵循了一个“数据与组件分离”的原则src/ data/ # 所有结构化数据 entities/ characters.ts civilizations.ts ships.ts organizations.ts events.ts timeline.ts engine/ # 业务逻辑 timeline.ts # 纪年换算 search.ts # 全文检索 simulation.ts # 黑暗森林模拟 components/ EntityCard.vue TimelineChart.vue RelationGraph.vue FlatFoldSimulator.vue # 降维动画 views/ HomeView.vue EntityListView.vue EntityDetailView.vue TimelineView.vue ExperimentView.vue stores/ entityStore.ts # 统一保存实体数据提供查询方法建议在一开始就把engine层独立出来不要什么都写在组件里。这样你的换算逻辑、搜索逻辑、模拟逻辑都能单独被测试。我用 Vitest 给时间线引擎写了一些基本单测跑一次不到五秒但后续改纪元偏移量时异常安心。3.2 数据加载与类型推导数据文件采用 TypeScript 模块而不是纯 JSON原因很简单写 JSON 的人会漏逗号、写错字段类型而 TS 模块配合satisfies关键字可以在保存时直接报错。import type { Entity } from ../../types/entity; export const characters [ { id: luoji, name: 罗辑, entryName: 罗辑 / 面壁者 / 执剑人, type: character, tags: [主角, 面壁者, 执剑人, 人类文明], description: 始为面壁者后成为执剑人建立威慑纪元。, timelineIds: [evt-luoji-facewall, evt-luoji-swordholder], relatedEntityIds: [zhangbeihai, chenxin, san-ti], quote: 给岁月以文明而不是给文明以岁月。, }, ] as const satisfies readonly Entity[];as const satisfies是 TS 5.0 之后的组合技巧。satisfies负责检查结构是否符合类型as const负责保留字面量类型。两者一起用既能得到精确推导又能在字段名写错、类型不匹配时获得准确报错。实际开发时我对每个实体文件都强制加了这个组合少了它后期数据量上来以后很难定位错误。加载逻辑里我额外做了一层“数据体检”。因为数据是手写的难免有字段引用不存在的 ID 的情况比如timelineIds指向了一个不存在的事件。我在开发环境里写了一个validateAllData函数启动时会遍历所有实体和事件检查引用的关联 ID 是否存在若存在悬挂引用就直接在控制台打印警告并统计数量。这个方法看起来土但真的能省掉大量人工核对工作。3.3 UI 组件与可视化关系图和时间线关系图组件我没有用第三方图数据库只用了 SVG 和 Vue 的响应式数据。核心逻辑是给定当前实体 ID找出relatedEntityIds再为每条关系生成一个line节点。图标尺寸可控、交互轻量二三十个节点的关系场景下性能完全够。时间线组件稍微复杂一点。我需要把不同类型的实体和事件按时间轴排列同时保留纪年显示。实现方式是先通过convertEra把所有事件统一转换成公元纪年绝对值用于排序渲染时再转回“危机纪元 / 威慑纪元 / 广播纪元”等展示格式。这个“内部统一、外部多样”的思路很好用。具体布局我写成了两种模式列表模式和画布模式。列表模式适合快速浏览画布模式适合演示讲解。画布模式用一个横向滚动容器承载全部节点根据年份线性分配left坐标再给每个节点附一条彩色小竖线标记当时的纪元。颜色规则是危机纪元用暗红色威慑纪元用金绿色广播纪元用深蓝色掩体纪元用灰色。这个方案视觉上比较直观稍微瞄一眼就能看出故事进程的推进节奏。3.4 降维动画二向箔效果的核心逻辑这个模块是纯视觉实验也是项目里最受朋友好评的功能。二向箔降维的本质是三维对象被强制映射到二维平面我实现的是一个简化版本一个几何体从三维旋转状态逐渐“压缩”到平面最后化为几个像素化的色块。实现上没有用类似 Three.js 的大型库而是选择 Canvas 2D 手写。核心思路如下在 Canvas 上绘制若干三角形面模拟三维物体的表面。每一帧通过插值函数减少 z 轴方向的坐标权重让物体逐步“扁平化”。当 z 轴权重降至 0 时将所有采样点按颜色取整映射到少量像素块上。增加一个横向的光晕线模拟降维时的能量波扩散。关键代码如下function flattenStep(vertices: number[][], progress: number): number[][] { return vertices.map(([x, y, z]) [ x, y, z * Math.pow(1 - progress, 2.5), // 非线性的压缩曲线 ]); }参数2.5是我反复测试后得出的结果。指数越小压缩过程越线性视觉上会早早就变成平面没有“向中心坍缩”的感觉指数太大又会在最后几帧突然崩塌看起来不自然。2.5这个值在 240 帧、每秒 60 帧的动画里表现刚好前 20% 阶段物体还在缓慢扭曲中间阶段压缩加速最后阶段有一个明显的“绽放为平面”的效果。需要注意动画过程中要避免重绘过重的阴影和描边否则 canvas 性能很容易掉到 30 帧以下。我最后去掉了所有阴影只保留边缘亮线帧率稳定在 59 到 60。这类动画要的不是物理真实而是“看起来像那么回事”的动态节奏。4. 常见问题与排查技巧实录4.1 数据录入混乱字段命名不统一做这个项目的头两周我最大的痛点不是业务逻辑而是数据文件越来越乱。有人物字段里放name到了舰船数据里叫shipName事件里又写title。虽然 TypeScript 接口能保证同一文件内字段统一但跨文件时很容易复制某一处写错的模板然后到处传播。我的解决方案是定义了一组基础字段别名在engine/normalize.ts里把所有字段统一到一套规范上。比如所有实体类的展示名在录入层面允许使用name/title/label最终都会统一成name。再配合一个脚本扫描自动检查是否有文件引用了不规范字段。这种统一操作一开始就要做千万别等到数据量大到几千条再回溯。4.2 状态管理过度设计项目刚开始时我想用 Pinia 把实体、事件、搜索状态、UI 状态全部放进去结果代码很快变得臃肿。后来我砍掉了大部分 store只保留一个entityStore负责原始数据加载和查询。纯粹的 UI 状态比如当前选中哪个标签、时间线是不是展开状态就留在组件内部用 ref 管理。原因并不复杂这个应用的数据方向是“读多写少”不存在多个页面并发修改同一份数据的场景。对这类项目引入重状态管理框架反而是负担。等到项目真正需要多人协同编辑、跨组件同步复杂状态时再上 store 不迟。提示状态管理不是越复杂越好当你发现自己为了“未来可能用到的功能”预先设计复杂架构时先停下来想想当前用户到底要解决什么问题。4.3 时间线精度与用户预期不一致初版的时间线组件引来很多评论主要集中在“某个事件到底发生在危机纪元的哪一年”。由于作品本身对某些事件没有给精确年份任何换算结果都存在一定推断成分。我一开始把“推断结果”和“原著明确结果”混在了同一个时间轴里导致严谨读者觉得我不专业。修正方式是给每个事件增加一个certainty字段interface EventItem { id: string; title: string; era: EraCode; year: number; certainty: canon | inferred | estimated; }时间线渲染时用三种不同样式的圆点区分可信度。canon用实心圆inferred用半透明圆estimated用带虚线的圆。同时在时间线底部加了一条说明“部分事件年份来自合理推导仅供展示和讨论”。这个小改动立竿见影讨论氛围从“你搞错了”变成了“这个推断我觉得可以再讨论”。4.4 Canvas 降维动画在小屏幕上的性能问题降维动画在桌面端跑得很流畅但放到手机浏览器上掉帧严重。排查后发现是默认 canvas 绘制了太多点。解决办法是通过设备像素比window.devicePixelRatio控制绘制尺寸缩放绘制分辨率const dpr window.devicePixelRatio || 1; canvas.width canvas.clientWidth * dpr; canvas.height canvas.clientHeight * dpr; ctx.scale(dpr, dpr);同时把采样点数量从 600 降低到 320配合requestAnimationFrame的增量计时就不会在低端设备上卡成幻灯片。如果你也做 canvas 动画建议将“点数量”和“设备性能”解耦做一个动态调节函数而不是写死。4.5 关联关系图的“蜘蛛网化”问题关系图在实体数量增多后会变成一团乱麻。线条交叉太多根本看不清哪条连到哪。我采用的优化策略是限制单屏最多展示 12 个关联节点超出部分折叠到“更多相关”按钮里。根据关联类型给线条配色人物之间用灰色人物与事件之间用蓝色文明与星舰之间用紫色。默认只展示当前实体一阶关联不无限扩散。实际下来视觉负担明显下降。如果你要做更复杂的图可以考虑引入力导向布局算法但对这个项目而言限制数量比单纯换算法更简单有效。5. 一点个人经验和后续扩展思路t3code 开发到现在我体会最深的一点是资料型项目真正的壁垒不是页面精美程度而是数据整理的严谨度和结构的可扩展性。页面布局改起来很快但底层数据结构一旦设计错了后续补丁会越打越丑最后逼着你重构。如果你也打算做类似主题的知识库或档案站我建议按这个顺序推进先画数据模型、再写时间线逻辑、再验证接口、最后才碰 UI。UI 应该是数据最忠实的表达而不是数据为了页面的美观去做不合理改造。后续我打算给 t3code 增加两个模块。一个是“航线计算器”根据星舰的曲率驱动速度、目标距离和燃料消耗推算抵达时间公式也不必多复杂能体现出“光速飞船与常规航行之间的代差”就够了。另一个是“生存决策树”把云天明童话、阶梯计划、威慑博弈这些关键选择做成分支剧情读者可以自己走一遍剧情线路。最后再分享一个实用技巧这类数据密集项目最好从一开始就用 Git 管理数据文件每次改动单独提交这样某一轮梳理出错时能够快速找到具体变更来源回滚也不会误伤页面代码。