Fantasy-Map-Generator 架构解析:从单体 JS 到四层 TypeScript 的渐进式重构

原创2026-10-03 17:18:021,898 阅读
文章标签:前端图形学

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 当前做法的痛点

文档明确指出当前样式实现的四个问题:

  1. 大部分样式值以 SVG 元素属性存在,且从 DOM 读回——DOM 同时扮演"状态容器"与"渲染输出"两个角色;
  2. 预设(presets)以选择器为键,如 #stateBorders、#labels > #states;
  3. SVG 属性、自定义 data-* 属性与应用选项混在一起;
  4. 全局 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 增量迁移

按要素逐个迁移,五步:

  1. 定义该要素的类型化样式子树与默认值;
  2. 把对应内置预设值映射进该子树;
  3. 让它的 Style 控制器编辑对象而非 SVG 属性;
  4. 让渲染器接受子树并写出输出;
  5. 仅在"地图兼容代码"里读取遗留 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 被记忆化(modulePromises Map),保证每模块只 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 标志门控,使重开面板绝不叠加重复监听器(每个副本还会重做工作);
  • 关闭时清理:对话框的 close handler 清掉它生成的 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/ 已经大面积兑现了其中设计。几个关键证据:

  1. 生成管线已数据化: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 依赖图);

  2. 配置是单一对象:options-schema.ts 用 zod 定义 options 的三分区形状(map/generation/app),localStorage 键为 fmg-options;options.map 即 .map 文件的设置块。详见 configuration.md;

  3. 懒加载注册表已运行:Controllers(87 条 loader)与 Services(7 条 loader)都由 registry.ts 的 createRegistry 构建,调用即 Promise,每模块独立 chunk;

  4. 图层注册表已实现:layers.ts 以单一有序列表声明全部层(从 drawBiomes 到 drawFogging 的 30+ 个绘制函数被注册为 draw/erase 回调),注册顺序即 z 序,配套 layers.test.ts 与 layers-presets.ts;

  5. 渲染器拆分已兑现: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 三条支线深入,是最符合代码现状的阅读路径。

登录后查看全文
Fantasy-Map-Generator