Fantasy-Map-Generator 架构解析:从单体 JS 到四层 TypeScript 的渐进式重构
Fantasy-Map-Generator 架构解析:从单体 JS 到四层 TypeScript 的渐进式重构
本文围绕 Fantasy-Map-Generator(FMG)仓库中的 architecture.md 展开,系统梳理其规划中的 FMG 2.0 目标架构:以"世界数据(state)→ 生成器(generators)→ 编辑器(editors)→ 渲染器(renderers)"四层模型为核心,结合分层目录结构、配置驱动生成、懒加载注册表、图层注册与内存纪律等设计,并对照当前 src/ 下的真实实现(pipeline.ts、registry.ts、layers.ts 等)给出源码级佐证。读完本文,你将理解该项目的架构演进方向、每层职责与模块写法约定,以及如何在不大改现有 .map 文件兼容性的前提下逐步将巨型 JS 应用迁移为模块化、可测试的 TypeScript 系统。
一、背景与目标:为什么需要 FMG 2.0 架构
Fantasy-Map-Generator 是一个纯浏览器端运行的交互式奇幻地图生成器,其功能规模已经膨胀到数十万格单元(cells)、几十种编辑器与渲染层的程度。文档明确指出:当前架构是"不同风格与模式的混合体",难以理解和维护。规划中的 FMG 2.0 架构旨在把这一庞大的、紧耦合的原生 JavaScript 单体,渐进式地改造成模块化、可维护、可测试的系统。
主要目标包括:
- 性能底线:在浏览器中保持快速与响应,即使面对约 10 万格单元的大地图;
- 内存有界:按需构建 UI,关闭时释放,保证长时间会话内存不会无限增长;
- 关注点分离:把程序化生成(procedural generation)与渲染、UI 逻辑分离;
- 数据独立于 DOM:世界数据不依赖 SVG / DOM 操作;
- 减少隐式全局状态与副作用;
- 降低贡献门槛:让新人更容易参与和上手;
- 渐进式 JS → TS 迁移,且不破坏现有
.map文件; - 为未来替代渲染器留出空间(如 WebGL)。
关键词是"渐进式":旧代码继续工作,新子系统采用更干净的新架构,两条路并行推进。
二、核心架构愿景:四层模型
文档给出的总体架构模型如下:
settings
│
▼
GENERATORS
│
▼
WORLD
(state: data + style)
│
┌─────────────┴─────────────┐
▼ ▼
EDITORS RENDERERS
│ │
▼ ▼
data mutations SVG or WebGL Canvas
更形式化的表述是:
world data + styles (state)
↑↓
generators (model)
↑↓
editors (controllers)
↓
renderers (view)
概念上项目被划分为四层:世界数据层(state,状态)→ 生成器层(generators,模型)→ 编辑器层(editors,控制器)→ 渲染器层(renderers,视图)。上层依赖下层、下层不感知上层,形成一个单向的数据流闭环:设置(settings)驱动生成器产出世界数据;编辑器和渲染器都只消费世界数据;编辑器产生数据变更,渲染器把数据投影成可视化输出。
一个关键约定是:所有与地图相关的状态都由一个巨大的 map 对象表示,保存 .map 文件时,该对象被转换为一个 JSON 文件;加载时再原样还原。这保证了世界数据可以序列化、可迁移、与渲染实现解耦。
三、四层职责详解
3.1 世界数据层(State):中央事实源
世界数据层是设计的"单一事实来源"(central source of truth),承担:
- 存储所有生成的世界信息;
- 存储渲染样式配置;
- 保持归一化的数据结构(normalized data structures);
- 提供与
.map文件的序列化兼容; - 保持渲染器无关(renderer-agnostic)。
该层有严格的约束:
- 不含任何渲染代码(即使样式配置也只说明"渲染什么",不说明"如何渲染");
- 不含 DOM 元素、不含 SVG;
- 业务逻辑最少甚至为零——纯数据容器。
存储的实体示例包括:Cells(格单元)、Burgs(市镇)、States(国家)、Cultures(文化)、Religions(宗教)、Rivers(河流)、Biomes(生物群系)、Routes(路线)、Military(军事)、Zones(区域)、Labels/addedLabels(标签)、Style configuration(样式配置)。
生成器与编辑器都应通过受控方式(controlled mutations)变更这一层状态。从当前仓库看,map 的实体模型已落实到 src/types 下(如 GridGraph.ts、PackedGraph.ts),而配置对象则由 options-schema.ts(zod 校验 schema)与 options-model.ts(模型)承载。
3.2 生成器层(Model):程序化模拟引擎
生成器负责程序化模拟与内容创建,职责包括:地形生成、气候模拟、河流生成、国家扩张、文化放置、市镇(burg)生成、路线生成、经济模拟、军事计算。
关键设计思想:
- 生成器只操作纯世界数据;
- 输入输出确定性(可播种,seeded)——同一个 seed 复现同一个世界;
- 生成器不得直接操作 SVG 或 UI;
- 各系统尽量可独立运行;
- 管线阶段尽可能可组合。
长期愿景是把模拟过程表达为一条清晰的管线:
seed → terrain → climate → hydrology → cultures → states → burgs → routes → economy
这带来两个直接收益:形成更清晰的模拟管线,以及支持局部再生成(partial regeneration)。
文档特别指出:"今天的序列已经作为一条管线被声明"——参见 generation-pipeline.md。这正是仓库中已落地实现的 generation-pipeline.ts:完整生成顺序不再硬编码在 generate() 里的手写调用列表中,而是声明为数据(步骤数组)。
3.3 编辑器层(Controllers):交互式生成器
编辑器被视作"交互式生成器":用户操作驱动状态变更。职责包括:
- 用户驱动的数据变更(mutations);
- 校验与约束(validation and constraints);
- 编辑工作流;
- 工具交互;
- 受控状态更新。
典型例子:River editor(河流编辑器)、States editor(国家编辑器)、Burg editor(市镇编辑器)、Religion editor(宗教编辑器)、Province editor(行省编辑器)、Heightmap editor(高度图编辑器)——在仓库中分别对应 river-editor.ts、states-editor.ts、burg-editor.ts、religions-editor.ts、provinces-editor.ts、heightmap-editor.ts 等。
重要概念:编辑器不应直接拥有渲染。正确流程是:
User action
↓
Editor mutates world state
↓
Renderer reacts to updated state
这显著降低了 UI 工具与渲染实现之间的耦合。
3.4 渲染器层(View):纯可视化步骤
渲染器是"纯可视化"的一步,职责:
- 把世界数据转换为 SVG / WebGL / canvas 输出;
- 在分配给它的图层组(layer group)内绘制标签与几何(排序与可见性由 layers registry 拥有);
- 从序列化的样式状态应用视觉样式;
- 视觉优化。
重要限制:
- 渲染器不得修改世界状态;
- 渲染应当是幂等的(idempotent);
- 渲染理想状态下无状态(stateless)。
同一份世界状态理论上可以同时支撑:SVG 渲染器、WebGL 渲染器、3D 渲染器、外部引擎导出、服务端渲染(SSR)。这正是架构文档把渲染器设计成"世界数据的纯投影"的动机——它让未来替换渲染后端成为可能。
四、地图样式模型:样式是状态,不是 DOM
4.1 当前做法的痛点
文档明确指出当前样式实现的四个问题:
- 大部分样式值以 SVG 元素属性存在,且从 DOM 读回——DOM 同时扮演"状态容器"与"渲染输出"两个角色;
- 预设(presets)以选择器为键,如
#stateBorders、#labels > #states; - SVG 属性、自定义
data-*属性与应用选项混在一起; - 全局
style对象只覆盖少数子系统(Label Groups、Burg icon groups、anchor groups),其余样式仍附着在 SVG 节点上;Style UI 直接改渲染出的 SVG,并在属性影响几何时调用绘图函数。
这导致:预设文件、保存、加载、迁移全部耦合在当前的 SVG 结构上——即使可见特征没变,重命名或嵌套一个 SVG group 也可能变成一次数据格式变更。
4.2 期望的 style 对象
期望模型是:一个纯 JSON 兼容的 style 对象,包含复现地图外观所需的全部信息;SVG 属性及其他渲染输出只是该对象的投影(projection),永远不是事实源。
style 对象按"地图要素"而非"DOM 选择器"组织:相关部分嵌套存放,重复的用户自定义样式放在带键的 groups 对象中。文档给出的示意 schema:
const style = {
borders: {
state: { opacity: 0.8, stroke: "#56566d", "stroke-width": 1, "line-cap": "butt", filter: null },
province: { opacity: 0.8, stroke: "#56566d", "stroke-width": 0.5, "line-cap": "round", filter: null }
}
};
现有选择器片段映射为嵌套结构,例如:
#statesBody/#statesHalo→style.states.body/style.states.halo;#freshwater、#salt及其他湖泊类型 →style.lakes.groups中的条目;#rural/#urban→style.population.rural/style.population.urban;#stateEmblems、#provinceEmblems、#burgEmblems→ 嵌套的 emblem 样式;#goodsCells、#goodsIcons、#goodsBurgs→style.goods的嵌套部分;#legendBox、#scaleBarBack和罗盘玫瑰 → 其所属要素的嵌套部分。
需要强调:这种分组只是组织性的,并不引入通用样式框架、CSS 级联或继承系统——每个渲染器拥有自己要素的小型类型化样式形状。
4.3 命名与取值约定
- 使用 HTML snake case 属性名,如
stroke-width、font-size、data-dx; - 保留用户今天拥有的全部样式能力:颜色、不透明度、描边、排版、滤镜、遮罩、纹理、图案、尺寸、偏移以及要素专属渲染选项。
4.4 所有权与数据流
Style 控制器编辑序列化对象,然后请求受影响的渲染器重绘:
User changes a style
↓
Style controller mutates style.<feature>
↓
Feature renderer reads world data + style.<feature>
↓
SVG / WebGL / canvas output
渲染器把要素样式翻译成自己的输出格式。它可以写 SVG 属性,但绝不能把属性读回当作当前样式;从同一份世界数据与样式重新渲染必须得到相同结果。可复用样式属于全局 style 对象;现有的实体专属视觉覆盖(如某标签的尺寸或偏移)可留在实体数据中——它们是"可复用组样式的例外",而非另一套全局样式系统。
在仓库中,样式子系统已初步落地为 styles.ts(默认样式解析、styles.parse/set/write/apply)与 styles-schema.ts,默认样式数据位于 default-styles.json,并配套 styles.test.ts、styles.dom.test.ts 等测试。
4.5 预设与持久化
内置预设、自定义预设与 .map 文件中存储的样式使用同一套完整对象 schema:
- 应用预设 = 替换当前
style对象并重绘受影响要素; - 保存时存储解析后的完整对象而非预设名,因此地图在无法访问原始预设的环境下打开也外观一致;
- 自定义预设存储可作为应用偏好(app preference),但其内容使用与地图样式状态相同的 schema;
- 基于选择器的预设文件通过"把每个选择器与属性映射到语义化对象路径与字段"来完成迁移。
4.6 增量迁移
按要素逐个迁移,五步:
- 定义该要素的类型化样式子树与默认值;
- 把对应内置预设值映射进该子树;
- 让它的 Style 控制器编辑对象而非 SVG 属性;
- 让渲染器接受子树并写出输出;
- 仅在"地图兼容代码"里读取遗留 SVG 属性,并把转换后的值存入 style 对象。
迁移期间,对象可同时包含现代要素子树与既有 group 样式条目。一旦某要素迁移完成,其常规编辑器、渲染器、保存、加载路径就不得再从 DOM 重建样式;现有地图与预设在整个转换期间都应保持外观不变。
五、项目结构:按角色分层的目录布局
四层模型是概念核心,但真实应用还需要持久化、应用外壳生命周期、静态内容与共享工具等代码。当前仓库的目录布局如下:
| 文件夹 | 层 | 存放内容 |
|---|---|---|
src/generators/ |
Model | 程序化生成器与领域逻辑 |
src/renderers/ |
View | 绘制 SVG / WebGL 层的代码 |
src/controllers/ |
Controller | 瞬态编辑器、工具、对话框、面板、概览 |
src/components/ |
Application | 应用状态与可复用 UI |
src/data/ |
— | 静态内容 / 参考数据 |
src/services/ |
— | 应用外壳与平台基础设施 |
src/utils/ |
— | 纯工具函数:无环境状态、至少 2 个消费者 |
src/types/ |
Shape | 共享 TypeScript 接口 / 领域模型 |
5.1 什么是"controller"
src/controllers/ 是比教科书 MVC 中的"控制器"更广的 UI / 交互层,容纳三类 UI:
- Editors(编辑器)——用户驱动的世界数据变更(
coastline-editor、cultures-editor、states-editor),是概念模型的"C"; - Tools(工具)——交互式地图工具与工作流;
- Overviews / visualizations(概览 / 可视化)——只读视图,呈现而不变更地图状态(
market-overview、charts-overview、production-chains、elevation-profile)。
统一规则是:"被打开又关闭、且变更或呈现地图状态的 UI"。controller 不持有纯静态数据、服务或序列化逻辑——那些各有自己的文件夹。
5.2 什么是"component"
src/components/ 存放"不属于某一个编辑器所有"的应用状态与 UI,分四类:
- Application state(应用状态)——有状态的应用级模块、活动图层及其顺序、视口缩放与位置;
- Web components——与地图无关的可复用自定义元素(
fill-box、slider-input,见 components/shared); - App-level UI——覆盖在地图之上但内容与地图无关的对话框与小部件:如 About 对话框(
app-info)。它们拥有 controller 的生命周期但没有 controller 的主题,因此放这里并随外壳(shell)加载; - Shared editor behaviour(共享编辑行为)——多个编辑器调用而非复制的交互助手:对话框助手、tooltip、默认地图事件(
viewbox-events)、地图上的画笔(map-brush,它拥有尺寸控制、半径圆与手势,而每个工具提供一次笔画做什么)、以及annex-mode等地图模式(States 与 Provinces 编辑器都用自己的合并逻辑驱动它)。
像 hierarchy-tree、minimap 这样的小部件,如果将来通用化,可能移入 components/。
5.3 跨层子系统
大多数文件夹是扁平的。当一个特性横跨多层时,每个部分都放在其角色对应的文件夹下。纹章(heraldry)是当前范例:
src/data/emblems/——静态纹章目录;src/generators/emblems-generator.ts——纹章生成(注册window.Emblems);src/renderers/emblems/——纹章 SVG 绘制(注册window.EmblemRenderer)。
5.4 为什么没有 core/
文件夹按角色命名,绝不按模糊的"重要性"命名。一个通用的 core/ 会变成杂物抽屉——"一切都很核心",于是无关代码在那里堆积,名字失去意义。如果确实需要真正基础性的桶,优先使用有意义的名称如 src/state/(存放 pack/grid 容器与序列化契约),而不是 core/。
5.5 第三方库策略
- 新打包代码从 npm 导入第三方依赖,由 Vite tree-shake 进依赖图(如 d3 v7 用
import { select } from "d3");src/下没有 vendor 化的libs/; public/libs/*.min.js(d3 v5、jQuery、three 等)仅为仍然依赖运行时全局变量的经典public/**/*.js通过<script>标签加载。它是遗留专用的,并随模块迁移而缩减:某特性移植进src/后,其依赖从 vendor 全局脚本变为 npm import,一旦没有经典代码需要它,vendor 脚本就被移除。
5.6 我的文件应该放哪?
文档给出了清晰的决策清单(结合当前仓库文件逐一对应):
- 由用户输入变更世界状态 →
controllers/下的 editor(如river-editor.ts、lakes-editor.ts、ice-editor.ts); - 在用户打开/关闭的对话框中只读呈现地图状态 →
controllers/下的 overview(如markets-overview.ts、production-overview.ts、regiments-overview.ts); - 始终在屏幕上呈现地图状态 →
components/下的 chrome; - 与地图无关、随外壳加载的对话框/小部件(About)→
components/; - 仅在打开时加载的瞬态 UI(如取色器
color-picker)→controllers/; - 绘制 SVG / WebGL 层(含像
trade-animation这样的有状态动画引擎)→renderers/,且该层本身要在components/layers.ts的注册表中声明; - 为某层把坐标转成路径数据、或只派生绘制所需的数据 →
renderers/<subject>.ts,无draw-前缀(如 heightmap-contours.ts、heightmap-hachures.ts、coastal-waves.ts、lake-ripples.ts); - 绘制自清理的瞬态反馈(高亮、画笔圆、雾)→
renderers/overlays/(见 overlays); - 生成或模拟世界数据 →
generators/; - 序列化、保存、加载或导出状态 →
services/io/(见 services/io 下的save.ts、load.ts、export.ts、export-json.ts、cloud.ts、auto-update.ts); - 管理浏览器/应用生命周期、平台资源或应用偏好 →
services/(如platform.ts、fonts.ts、url-params.ts、versioning.ts); - 代表用户与项目帮助网关通信 →
services/help/(见 services/help 的api.ts、auth.ts、conversation.ts); - 常量列表或模板、无行为 →
data/(如name-bases.ts、icons-list.ts、heightmap-templates.ts); - 不读取环境状态且有 ≥2 个消费者的助手 →
utils/(如arrayUtils.ts、pathUtils.ts、quadtree.ts); - 共享类型 / 接口 →
types/。
5.7 依赖方向:只向下,不向上
打包代码导入它调用的东西——迁移后的模块不得通过 window.* 全局够到已迁移的函数。全局变量是为经典 public/**/*.js 服务的,不是给 src/ 的。
但导入也是依赖,依赖只允许下坡:
components / controllers / services ← 可以导入下面任何层
↑
renderers ← utils, types, data, generators
↑
generators ← utils, types, data
↑
utils / types / data ← 只能互相导入
因此 states-editor 可以从 components/tooltips 导入 tip,但想显示 tooltip 的 generator 或 renderer 不行——那会向上指向栈顶。这些少数调用点保留 window 桥接并注释原因;真正的修复是把消息移到拥有该交互的 controller,而不是跨层导入。utils/registry.ts 是现成的例子:它通过 window.tip 显示加载提示,因为 utils 绝不允许依赖 UI。
六、模块设计规范:每层模块应有的形状
四层回答了"责任在哪里",本节回答"每类模块应有的形状":
- 简单而简洁——能清晰读出的最短代码,活动部件越少越好;
- 有表现力——命名与结构表达意图,读者不必在脑子里运行代码;
- 不搞复杂的抽象——只为消除真实重复或命名真实概念才引入抽象;
- 干净——副作用推到边缘,每模块单一清晰责任,显式输入输出。
6.1 生成器(Model)
生成器把输入转成世界数据:
- 显式输入、显式输出:读状态 + seed/options,写出要写入的数据。读到的隐藏输入(环境全局量)越少越容易推理与测试。新生成器必须自带单元测试(
*-generator.test.ts),从第一行起就为此设计——仓库中几乎每个生成器都有对应测试文件(如 states-generator.test.ts、routes-generator.test.ts); - 确定性:同一 seed 复现同一世界。一开始就播种 RNG 一次,绝不依赖墙钟时间或未指定的迭代顺序;
- 无视图、无 UI:生成器绝不读 DOM、建 SVG 或开对话框;如果需要"展示"什么,那是 renderer 或 controller 的工作;
- 把数据留在外面:查找表、配方、调参常量是数据而非算法。领域的固定属性作为共置参考数据存放(见上文 Configurations and data);任何用户可能想改的参数应进入地图配置而非魔法数字(见 Generation is configuration-driven)。
6.2 渲染器(View)
渲染器是状态到视觉的纯投影:
- 幂等且无状态:同一状态画两次得到同一输出,重复运行绝不累积。从当前状态构建层、替换它、完成;
- 只读:渲染器绝不修改世界数据。如果绘制需要某个状态里没有的"世界事实",该事实就该放进状态——在生成器里计算,而不是在视图里;
- 只有绘制需要的数据(hachure 渐变、wave-dash 放置、涟漪从岸边消退多远)每次渲染时派生,绝不存到
grid/pack上或保存; - 路径数学与 DOM 写入分离:
draw-*.ts读取环境状态(grid、pack、styles)并写层;把坐标转成路径数据的是无前缀的兄弟模块(heightmap-contours.ts、heightmap-hachures.ts、coastal-waves.ts、lake-ripples.ts):纯函数、参数驱动、可播种、无 DOM 的单元测试。它们留在renderers/而非utils/,因为只有一个消费者且输出 SVG; - 无业务逻辑:只有几何、布局与样式。渲染器若去决定世界"真实"是什么,就是在抢生成器的活;
- 隔离少见的有状态情况:拥有帧或缓存的所有权动画引擎是例外:封装其运行时状态并给出显式 reset,让其余渲染器保持为状态的普通函数;
- Overlays 是另一个例外,住在
renderers/overlays/:高亮脉冲、画笔圆、雾化——这些从用户"正在做什么"绘制,而非世界状态,所以既非幂等也不可派生。它们仍是视图代码(写 SVG、不返回内容),所以归renderers/,但在自己文件夹中隔离,并且必须自清理:overlay 结束时移除自己的节点。是世界状态的投影的视觉是 layer,不是 overlay; - 无框架、直接注入:渲染就是把普通标记直接写进 DOM——拼装 HTML/SVG 字符串一次性注入。无虚拟 DOM、无组件运行时、无 diff 层:渲染器保持对输出的完全、细粒度控制;
- 优先原生 JS:d3.js 有真实的记忆成本且容易被过度使用。把它留给真正擅长的领域——几何、路径、比例尺、投影、四叉树——节点创建、属性与事件接线用纯字符串 /
createElement。把简单 DOM 工作绕道 d3 selections 是常见且可避免的膨胀来源。
6.3 控制器(Editors)
控制器是用户动作与状态之间的薄接缝:
- 薄:把意图翻译成一次显式状态变更,然后请渲染器重绘。校验与约束在这里;模拟与绘制不在;
- 编辑器变更,概览不变:编辑器改世界数据并触发重绘;概览只读呈现状态。两者必须诚实区分;
- 可安全重入:两次打开同一面板必须无害:一次性 handler 只接线一次,每会话状态保持最小且局部;
- 单一对象、懒加载到达:控制器导出单个命名对象——
export const StatesEditor = { open }——通过Controllers注册表到达(Controllers.StatesEditor.open()),绝不急切导入。见 Lazy module registry; - 瞬态 overlay 拥有自己的样式:仅在打开期间存在的调色板、弹出层、气泡可以在标记旁渲染自己的
<style>块,并以其自身 id 为作用域,使规则随元素一起创建与移除。public/index.css只留给外壳与src/index.html中静态的小部件。omnibar 是参考案例:它的样式表是omnibar.ts中的一块,作用域为#omnibar。
6.4 配置与数据
静态内容:查找表、模板、调参常量、参考列表。
- 数据,而非行为:导出纯值;无逻辑、无副作用。这是数据驱动设计:小泛型算法读取数据,数据描述世界;
- 共置,再抽取:只服务一个生成器的表可作为该文件顶部的
const。只有当它大到遮蔽逻辑、或被共享时,才拆成独立模块。
6.5 IO(序列化)
- 序列化形状是契约:保存的
.map必须原样重载,因此每个写入字段必须能读回。保持(反)序列化显式且对称——一个静默丢失的字段会破坏存档; - 纯函数:序列化读状态、返回字节,不持有自身状态。
6.6 服务
- 无世界状态:服务处理应用外壳与平台关注点(安装、字体、生命周期),绝不读写
pack/grid。碰世界数据的服务是放错了地方——它其实是生成器、编辑器、io 模块,或者(若只是呈现状态且始终在屏)chrome; - 应用偏好是服务:来自 Two scopes of configuration 的
localStorage作用域——UI 偏好、固定请求、"不再询问"标志——是按浏览器划分的平台状态,永远不属于.map。地图配置不是服务;它是状态; - IO 是服务:保存/加载/导出位于
src/services/io/。与控制器一样,每个 service/io 模块导出单一命名对象(Save、Load、ExportMap……)通过Services注册表到达(Services.Save.toMachine()); - 帮助网关客户端是服务:
src/services/help/(api、auth、conversation)是与 ask.azgaarsfmg.com 通信的唯一代码;controllers/help-assistant.ts是它的 UI。客户端把 Discord 登录 token 放在localStorage,只把服务器签发的会话 id 放sessionStorage,绝不存放对话内容。
6.7 懒加载模块注册表(源码级)
控制器与服务从不被调用方急切导入,而是通过两个类型化注册表到达——Controllers(src/controllers/index.ts)与 Services(src/services/index.ts)——由一个工厂支撑:src/utils/registry.ts 的 createRegistry(loaders)。
该实现把"loader thunk 的映射"转成"可调用、类型化的对象"。其核心机制(registry.ts):
- 访问
Registry.Name.method(...args)时,触发import()加载所属模块(独立 chunk,最多求值一次),然后分发调用,始终返回 Promise——调用方永远不写import()或.then(m => …); eager(value)助手注册已导入的值,使懒/急切换对调用方不可见;- 模块 Promise 被记忆化(
modulePromisesMap),保证每模块只 fetch/求值一次; - 加载期间通过
window.tip("Loading…")显示加载提示,完成后clearMainTip()——utils 层通过 window 桥接而不是 import,正是"utils 不得依赖 UI"规则的实例。
约束与约定:
- 每模块一个导出(约定):每个注册模块导出单个命名对象,其属性即公共方法——
export const StatesEditor = { open }、export const Save = { toStorage, toMachine, toDropbox, prepareMapData, writeToStorage }。注册表键与该导出名一致。暴露数据或嵌套对象的模块用方法门面包装(如CloudStorage展平Cloud.providers.dropbox)以适配分发契约; - 默认懒、调用点异步:
Controllers.X.method(...)首次使用时动态导入模块(其独立代码分割 chunk,只求值一次)再分发——因此每次调用都返回 Promise。工厂推断每个模块的真实签名,调用方获得精确、类型检查的契约而非any; - 处处同一句柄:迁移的 TS 导入
{ Controllers }/{ Services };遗留public/**/*.js与内联 handler 使用window.Controllers/window.Services全局变量; - 不得做成 thenable:工厂的逐条目代理对
then与 symbol 键返回undefined,所以await Registry.Name是空操作而非幽灵方法调用; - 命名导出:懒模块不允许模块级
window.X = new Thing()自注册(那是急切加载生成器如markets-generator.ts的模式); - 验证懒加载:
npm run build后ls dist/assets | grep market-overview,应看到market-overview-<hash>.js独立文件;若其代码出现在主入口 chunk 内,说明急切图中某处静态导入了它。
生成器、渲染器与组件则不同:它们急切加载并自注册自身全局(window.Markets、window.drawRoutes、window.tip),因为经典代码直接调用它们,且(chrome 的情况)不存在"被打开"的时刻。完整模式见 lazy-loading.md。
七、地图图层注册表:单一事实源
地图图层是地图 z 序中的一个槽位:一个 SVG 组、绘制它的代码、以及它当前是否开启。图层是用户切换、重排与保存的单元,所以它们是应用状态,不是样式也不是世界数据。它们住在一个注册表——src/components/layers.ts——它是图层身份、顺序与可见性的单一事实源。
每层被恰好声明一次,作为一个有序列表中的一个值。注册顺序就是 z 序、初始化顺序与绘制顺序,因此 SVG、Layers 标签页与绘制序列不可能漂移。声明(LayerParams)包含:图层 id、SVG 组及其父根(viewbox 或 map)、任何常驻子元素与静态属性、以及 draw / erase 函数。另有几个关键参数:permanent(结构性层:一开始就开、永不关闭、不保存为状态)、keepContent(关闭时内容保留在 DOM 中,供重建昂贵或持有用户编辑的层选择)。
活动集与图层顺序随地图序列化(data[50]),加载时以 Layers.restore 重新应用——它采用状态而不重绘已加载 SVG 携带的内容。restore 容忍双向版本错位:未知 id 被忽略,文件早于的层按注册顺序前驱之后插入。
八、性能与资源纪律:架构约束而非后期润色
整个工具跑在浏览器里——没有服务器承担重活——地图可达数十万格单元。因此速度与低内存占用是架构约束,不是润色步骤。每层通过"做最少的工作、持有最少的状态"来挣得自己的位置;经验法则是触碰更少的东西——更少的对象、更少的 DOM 节点、更少的重绘、更少的监听器。
8.1 状态与内存
- 结构数组 + 类型化数组:每格数据住在并行的类型化数组(
pack.cells.h、cells.biome……)里,而不是格对象数组; - 只存规范数据,派生数据可弃:存事实源;查找表按需重建而不是持有并序列化——更小的存档、没有过期副本;
- 不复制世界:通过所属生成器原地变更;把
structuredClone/大数组 spread 留给真正的快照(如恢复默认值)。
8.2 生成器(计算)
- 一次性工作、正确的粒度:首次构建用完整
generate();编辑用定向重跑(regeneratePlacement(id))——绝不为了改一个东西而重新生成世界; - 为查询选对结构:空间查找用四叉树(quadtree.ts);最廉价路径增长用优先队列(
FlatQueue)。在地图规模下对格做 O(n²) 扫描是 bug; - 迭代数组,不物化对象:循环
cells.i并索引并行数组;避免构建一次性对象数组来遍历; - 把重烘焙移出交互路径,使长计算永不冻结输入。
8.3 渲染器(DOM / SVG——通常的瓶颈)
每个 SVG 节点都是活的 DOM 元素;数千个会拖慢样式计算、命中测试与 reflow。最小化元素数量是最大的渲染杠杆:
- 一次构建、一次挂载:把层拼成单个字符串一次注入,而不是循环 append 节点——一次解析、一次 reflow;
- 不用 d3 构建 DOM:一个缓存 selection 做单次
.html(str)写入可以;逐节点selectAll().data().enter().append()链在每元素成本上保留数据联接与闭包。用字符串 /createElement建节点,把 d3 留给几何、比例尺与投影; - 更少、更大的路径:把相邻同值格合并成几个区域多边形 / 等值线,而不是每格一条路径;
- 复用而非复制:在
<defs>定义字形,用<use href>盖章;按 id 共享渐变、滤镜与裁剪路径。DOM 保留一个定义而非 N 份拷贝; - 关闭零成本:隐藏层用
display: none隐藏其组并丢弃内容,而不是保留成千上万个隐藏节点——注册表两者都做。只有重建昂贵或持有用户编辑的内容选择keepContent。只重渲染变更真正触碰的层,通过Layers.draw(...); - 路径数据四舍五入坐标(
rn):更短的字符串解析与绘制更快,且缩小保存的 SVG。
8.4 控制器(监听器与清理)
长生命周期单页会话中最大的泄漏风险是从未释放的 handler 与分离节点:
- handler 只接一次:一次性
.on(...)设置用 init 标志门控,使重开面板绝不叠加重复监听器(每个副本还会重做工作); - 关闭时清理:对话框的
closehandler 清掉它生成的innerHTML,拆除它启动的东西——定时器、动画循环、监听器——使大块分离子树不再被引用; - 同类目标用委托:父级上一个读取
event.target/dataset的监听器胜过每行或每格一个监听器:更少的保留闭包、更少的移除; - teardown 时取消异步:进行中的动画或定时器检查代际 token(或被清除),使其在用户离开后不再触碰 DOM。
8.5 内存管理
应用是长寿 SPA,世界(grid/pack)在每次生成、加载、变换与高度图编辑时整体替换。几乎每种泄漏都归于几个固定形态,新代码不得再增加一种:
- 缓存不得活得比它的世界久:任何模块级引用
pack、grid或大派生副本,在世界被替换时必须释放。身份检查(sourcePack !== pack)只会在下次绘制时重建——在那之前它让整个前一个世界存活。要配合 teardown; clear()要丢弃:跟踪"当前图"的模块在 clear 时必须置空该引用;把指针指向新对象而缓存仍在描述旧对象,会让旧图保持可达;- Layers 注册表是 teardown 接缝:缓存派生数据或启动工作的层声明
erase(layer),同时丢弃 SVG 内容与模块状态——注册表不必知道 scene,调用方不得调用逐要素清理。在世界替换点调用Layers.eraseAll()(或undraw()),让注册表执行所有 teardown。在新地图被采用之前擦除,否则你会擦掉刚加载的东西; - 守卫自延续循环:
requestAnimationFrame/setTimeout链是幂等的(running flag),单一取消点;teardown 重置标志、取消句柄、释放循环触碰的东西。可在 teardown 后解析的异步工作,在触碰场景前检查active标志; - 销毁对话框,不要移除它们:关闭 jQuery-UI 对话框用
destroyDialog(id)/.dialog("destroy"),让 widget、其.ui-dialog包装与分离子树一起走;对活动对话框内容做裸element.remove()会让包装器成为孤儿; - 每元素一个监听器,归模块所有:用属性赋值、
.on(...)或AbortController绑定——绝不从每次打开都运行的代码对页面生命周期元素addEventListener。在使用它们的模块内创建瞬态输入(createFileInput),而不是停在index.html; - 限制无界历史:撤销栈、会话日志与缓存存增量或定长窗口;热路径上没有任何东西每次动作、加载或地图加一条;
- 关闭即释放:编辑器在对话框关闭时置空其最后查看的实体 / 分离的 SVG 槽、清除定时器;填充的
<select>与document.fonts在 append 前 diff 而非盲目 append; - 带生命周期地处置资源:
<script>标签、对象 URL、FontFace、<defs>副本、three.js 几何 / 材质 / 纹理 / 渲染目标由创建它们的代码释放;WebGL 上下文在dispose()后用forceContextLoss()释放。
审查每次变更时对照这份清单:说明它添加的每个长寿引用的所有者,以及释放它的钩子。
8.6 加载时间与度量
- 把少用特性拆成按需 chunk,保持初始包小——见 lazy-loading.md;
- 度量,不要猜:用
TIME && console.time(…)/console.timeEnd(…)守卫热路径并保留守卫;在大格数地图上实测剖析,优化测得的瓶颈。
九、设置、选项与 UI 生命周期
设置、选项与样式面板占了应用表面很大份额——也是内存成本的大头。配置数据是 State,编辑它的面板是 Controllers(编辑器与概览之外的设置子类型)。
9.1 配置是状态,不是 DOM
- 每个选项都是显式、类型化配置对象中的一个字段,存放在状态里。面板是那个对象的视图/编辑器;读取或写入设置永远不需要面板打开;
- DOM 不是事实源,任何面板都可以在关闭时销毁而不丢值。
9.2 配置的两个作用域
每个可配置值要么是"关于这张地图"的真相,要么是"这个浏览器"想要的。一个对象同时容纳两者,分属不同生命周期的分区:
| 作用域 | 分区 | 持久化到 | 示例 |
|---|---|---|---|
| 地图配置 | options.map |
localStorage + .map 文件 |
seed、范围、世界位置、气候、单位、lore、定义集 |
| 应用偏好 | options.generation、options.app |
localStorage(按浏览器) |
下一次地图的请求、查看器偏好、UI 偏好 |
- 地图配置随地图走,必须经 IO 往返;在另一台机器打开必须外观一致。
options.map就是文件设置块——保存写它、加载替换它,因此没有第二个对象需要保持同步; - 应用偏好永不进入
.map——这是本浏览器的选择,不是地图的。保持分区独立,使用户的 UI 微调不会搭共享地图的便车; options.generation存请求;options.map存发生了什么。请求与结果是不同分区、不同名称、不同值,绝不互为两份拷贝;options.map由生成、派生或文件加载写入——也由拥有其值的编辑器写入。为下一张地图问的滑块写generation,不改变屏幕上任何东西。这正是一个已保存文件与其描述的地图保持一致的原因。
9.3 生成由配置驱动
生成器从配置对象读取其可调参数,而不是埋在算法里的魔法数字:它从 options.generation 取请求,把它产生的、对地图保持为真的参数写进 options.map。目标是让生成的每个重要杠杆——数量、比率、阈值、间距、权重——都能被最终用户不修改代码地改变。许多高级用户把工具当沙盒用,因此可配置性本身就是特性,不只是开发者便利。
- 把有意义的常量提升为配置:检验标准是"用户是否可能想改这个?"是,则成为配置对象的命名字段;若是领域固定属性(biome 环数、配方比例),则保持为参考常量;
- 一个统一配置——无基础/高级拆分:所有生成设置住在一个扁平命名空间。"高级"最多是编辑器的呈现提示,绝不是独立数据结构:拆分 schema 会割裂序列化并强迫对每个值住哪做武断决定;
- 为通用编辑器而生:因为每个参数都是对象上的命名、平实类型字段,一个规划中的通用控制器可以无专属 UI 地让用户编辑任何值。保持字段自描述,让该编辑器保持简单;
- 默认值是 schema 的一部分:新地图从配置默认值开始;加载的
.map恢复其已存设置,用户改过的值在重载时精确复现; - 数量是数据,不是配置:一张地图有多少国家由世界回答,不由产生它的请求回答;只有生成后仍被持续查阅的比率、比例与种类才是配置。
9.4 编辑 UI 是控制器
Options、Style、Units 与各实体编辑器都是控制器。样式/选项面板遵循与任何编辑器相同的数据流:变更配置状态,然后请受影响的渲染器重新渲染——样式变更重绘受影响的视觉要素;生成器参数变更重跑该生成器。面板本身绝不绘制地图。
9.5 瞬态 UI:打开时构建,关闭时销毁
这是内存设计,适用于每个控制器——设置面板尤其如此,因为它们又大又多。遗留单体把每个对话框、面板与列表烤进 index.html 并仅显示/隐藏;在大型按实体列表(每州、每文化、每市镇一行)下,那个保留的 DOM——连同其监听器与闭包——是会话攀升到 GB 级的主因。目标:
- 最小静态 DOM:
index.html只持有始终存在的外壳——地图、工具栏、图层按钮。面板不预烤进去; - 打开时构建:控制器打开时构建其 DOM、接线监听器、挂载它;
- 关闭时销毁——永远:
close()移除生成的子树(element.remove())、丢弃监听器、取消定时器/观察者/动画循环、释放引用。隐藏不等于关闭:隐藏面板仍承担完整 DOM、监听器与保留闭包; - 对称所有权:每个
build都有同一控制器中匹配的teardown。如果open创建了它,close就销毁它——没有孤儿子树、没有半释放状态; - 绑定大列表:覆盖 N 实体的面板在 N 大时不得物化 N 行:只渲染可见窗口(windowing——不是虚拟 DOM)或分页,滚动时重建;
- 构建时接线,而非"接一次永远":监听器随 DOM 生、随 DOM 死;每次打开重接廉价且无泄漏,而为避免重接而保持 DOM 存活本身就是 bug。
净效果:静止时应用持有地图加一层薄外壳;内存里唯一的重型 UI 是当前在屏的,关闭面板即归还内存。占用跟踪的是用户现在看的东西——而不是他们本会话打开过的一切。
十、迁移策略与 TypeScript 采纳
10.1 迁移策略
重构明确是增量的,而且已在推进中。项目太大不适合全量重写,因此架构目标为:
- 渐进抽取(progressive extraction);
- 兼容桥(compatibility bridges);
- JS/TS 混编运行(mixed JS/TS operation);
- 逐步模块隔离;
- 按部就班现代化。
关键策略:旧代码继续工作,新子系统采用更干净的架构。
10.2 TypeScript 采纳
现代化的一大方向是渐进式 TS 迁移,目标:强类型、更好的编辑器工具、更安全的重构、系统间显式契约、对贡献者更好的可发现性。迁移被设计为与遗留 JavaScript 共存——实际上,src/ 下已经是近乎全 TS 的现状,src/types/ 承载共享领域模型,各层测试(*.test.ts)遍布 generators/、controllers/、components/、utils/,正是"新子系统干净架构"的落地证明。
十一、仓库现状对照:文档如何落进代码
架构文档不是空中楼阁,当前 src/ 已经大面积兑现了其中设计。几个关键证据:
-
生成管线已数据化:generation-pipeline.ts 把完整生成声明为
generationPipelineSteps数组(grid→heightmap→markupGrid→……→journeys,共 39 步),并由 pipeline.ts 中泛型Pipeline顺序执行;高度图擦除则用独立的ErasePipeline(相同步骤、从markupGrid到zones,按erosion上下文参数化)。pipeline.ts 的run()展示了文档所述的行为:INFO/TIME门控分组计时、步骤失败重抛为`${pipelineName} failed at step "${id}"`并带cause——失败时命名阶段,parseError(commonUtils.ts)沿cause链展开,生成错误对话框同时展示阶段与底层栈。这些细节在 generation-pipeline.md 中有完整展开(含每阶段写入内容表与 mermaid 依赖图); -
配置是单一对象:options-schema.ts 用 zod 定义
options的三分区形状(map/generation/app),localStorage键为fmg-options;options.map即.map文件的设置块。详见 configuration.md; -
懒加载注册表已运行:
Controllers(87 条 loader)与Services(7 条 loader)都由 registry.ts 的createRegistry构建,调用即Promise,每模块独立 chunk; -
图层注册表已实现:layers.ts 以单一有序列表声明全部层(从
drawBiomes到drawFogging的 30+ 个绘制函数被注册为draw/erase回调),注册顺序即 z 序,配套 layers.test.ts 与layers-presets.ts; -
渲染器拆分已兑现:renderers 下
draw-*.ts与无前缀的路径数学模块(heightmap-contours.ts、coastal-waves.ts、lake-ripples.ts、isoline-fills.ts)并存,overlays/子目录隔离自清理的瞬态反馈,且大多数带.test.ts。
这份文档的价值在于:它既是新代码的归属裁判("我的文件放哪"清单),也是评审清单(性能与内存纪律),更是理解这个庞大仓库如何从遗留单体演化为分层 TS 系统的地图。对想贡献或深入阅读 FMG 源码的开发者而言,从 architecture.md 起步、沿 generation-pipeline.md、configuration.md、lazy-loading.md 三条支线深入,是最符合代码现状的阅读路径。