OCC 星球漫游 · 系统架构
把整个项目拆开看:谁负责什么、谁调用谁、哪些数据是权威的、哪些约定一旦违反就会出静默事故。 这份文档是代码现状的映射(不是设想),与 README.md 的分工是:README 讲"怎么跑起来",本篇讲"它是怎么搭起来的"。
核对基线:2026-10-15 21:34(工作区代码)。凡涉及"行数 / 数量"的结论都取自当次实测。
0. 一句话
Three.js 0.184 前端(纯 ES Module,无打包器)+ Node/Express/ws 服务端,同端口提供作品、文档站与 WebSocket; 所有玩法状态在Server-authoritative,客户端只做预测与表现;两颗星球(苗圃 / 焦土)共用同一份确定性地形与资源数学。
1. 三层结构
| 层 | 位置 | 特征 |
|---|---|---|
| 浏览器前端 | public/(HTML/CSS/JS)+ assets/(模型与图标) | 无打包、无构建步骤;importmap 把 three 指向 public/vendor/;45 个模块 + 3 个机甲运行时模块 |
| Node 服务端 | server/(10 个 js,约 6800 行) | HTTP + WS 同进程;权威状态与落盘;100ms NPC tick 与 1Hz 基地结算两个独立定时器 |
| 共享纯数学层 | public/src/{terrain-field,resource-field,flora-harvest,noise,logistics-const}.js | 浏览器与 Node 同时 import 同一份文件,保证"哪是陆地、哪是矿脉、掉什么"两端判断完全一致 |
共享层是这个项目最关键的一处架构决定:地形、矿脉、植被掉落三张表都不是"各写一遍",而是一份代码两处跑。 任何改动都要意识到它同时影响前后端。
2. 双星球体系
两颗星球不是两个场景,而是同一个 seed 的两种参数(server/world-config.js):
焦土 battle | 苗圃 home | |
|---|---|---|
| 定位 | 公开战场,所有人可见 | 私人空间,别人进不来 |
| 半径 | 820 | 520 |
| 网格 | 500 × 250 | 320 × 160 |
| 网格边长 | ≈10.30 世界单位 | ≈10.21(π·520/160) |
| 植被密度 | floraRate 1.0 | 0.3 |
| NPC / Boss | 有 / 有 | 无 / 无 |
| 建造 | 不能建 | 唯一能建的地方 |
| seed | 20261012(共用) | 20261012(同 seed 私有分片) |
要点:
- 同 seed 私有分片:家园星不额外生成地形,只是按
private标记隔离会话。省掉一整套地形分片逻辑。 - 网格边长必然略有差异(10.30 vs 10.21):两星球段数不同但都要保持 2:1,所以用建造格做尺度统一时留了余量。
- 前端投影:
world.js的applyWorldToConfig()把cfg.worlds[id].{radius,lonSegments,latSegments}摊平到cfg.planet.*,植被数量按floraRate × 面积比缩放(家园星约为战场的 12%)。 - 切换 = 整页重载,不是运行时 dispose。
world.js头部注明了原因:两颗星球不能同时驻留显存(光植被就 10.2 万实例),而方案自己接受"切换几秒可接受"。过渡由#warp遮罩盖住。
3. 前端模块地图(52 + 3 个)
3.1 入口与配置
| 文件 | 职责 |
|---|---|
main.js | 3106 行单例装配器:登录 → 装配世界 → setAnimationLoop 主循环 → WS 消息总线(约 40 个分支)→ 瞄准与攻击结算 → HUD 节流刷新;暴露 window.__OCC__ 调试句柄 |
config.js | DEFAULT_CONFIG 默认值,被服务端 /api/world/config deepMerge 覆盖 |
input.js | 物理按键 → 动作映射;鼠标拖拽 / 滚轮 / NDC 指针;E 采集开关状态机 |
world.js | 双世界判定、参数投影、星门 travel 流程、切换过渡层 |
3.2 地形与渲染
| 文件 | 职责 |
|---|---|
noise.js | 确定性 3D Perlin / fBm / ridged / mulberry32 / 球面均匀采样 |
terrain-field.js | 地形纯数学(两端共用):海拔、生物群系、水位雪线分位数标定 |
terrain.js | 采样 768×384 高度网格;烘焙 2048×1024 地表贴图 + 1024×512 粗糙度图 |
planet.js | 球壳 + 大气辉光 Shader + 云层 + 跟随玩家的局部正方形网格层(G 键) |
sky.js | 星云穹顶 + 三层星空 Points + 太阳 Sprite |
flora.js | 植被岩石散布:6 物种 × 8×4=32 扇区 InstancedMesh,距离剔除,产出障碍物表 |
models.js | 程序化模型工厂(树 / 灌木 / 草 / 岩石 / 纸飞机几何) |
3.3 特效与表现
| 文件 | 职责 |
|---|---|
effects.js | 通用粒子池(1600)+ 闪光 Sprite + 瞬时补光 |
debris.js | 碎块与爆炸分件(硬上限 420) |
damage-numbers.js | DOM 伤害飘字(池化 48) |
boss-fx.js | Boss 气炮弹 / 火榴弹本地积分与大爆炸,带服务端广播去重 |
remote-fx.js | 远程玩家环绕无人机、纸飞机弹道、精英炮弹 |
3.4 玩家与战斗
| 文件 | 职责 |
|---|---|
physics.js | cannon-es 世界:分组掩码、重力对齐球心、静态球地面、障碍物流式加载、射线 pickAlong、AABB 刷新 |
player.js | 主角控制器:姿态与油门本地算,位置积分与碰撞交给 cannon |
orbit-camera.js | 球面第三人称相机:切平面重投影、解析避障、trauma 震屏 |
mecha.js | 机甲 GLB 加载、骨架蒙皮、动作状态机驱动、偏色染色与受击白闪 |
npc.js | NPC 汽车表现层:部件实例化、外推插值、距离剔除 |
elite-tank.js | 精英坦克:炮塔独立旋转、预警光圈、炮口火光 |
world-boss.js | 世界 Boss「天罗」:体素八足步态、双炮回旋、火榴弹筒 |
paper-planes.js | 纸飞机状态机 orbit→climb→strike→spent,已去物理自积分 |
vehicle-models.js | 载具 GLB 分件表(NPC 车 / 精英坦克) |
destructibles.js | 树石血量、塌缩动画、血条集合、摧毁与重生同步 |
3.5 玩法系统
| 文件 | 职责 |
|---|---|
resource-field.js | 矿脉纯数学(两端共用):矿带、富集度、堆簇规则、重生时长 |
resources.js | 矿产表现:GLB 按材质分桶实例化、堆心栅格流式生成、采空记账 |
mining.js | 采矿:手臂姿势覆盖、射线打矿、双圆柱光束、自研 shader 粒子、服务端结算 |
loot.js | 掉落物:逐物品小模型(32 种,见 loot-models.js)+ 光柱、三段动画、自动拾取请求 |
loot-models.js | 32 种掉落物的程序化小模型:顶点色 + 合并成单几何(每件 1 draw call),lootGeometry / lootTint |
theme.js | 星球视觉主题(per-world,七层):地表/水体色带、植被形态与配色、天空、雾光、地平线辉光/云/建造网格、小地图。纯数据 + 无 DOM/three 依赖(服务端也 import 它)——见「玩法系统现状」§1.v |
glb-loader.js | 全项目唯一的 GLTFLoader 入口:统一挂 meshopt 解码器、opt → 原始 回退、dequantizeGeometry()(量化过的几何在改顶点前必须先反量化) |
guide-steps.js | 新手引导的 16 步文案表(两端共用):id / title / hint / goal / line / key;判定不在这里 |
guide.js | 新手引导面板:渲染步骤 + 完成动画;上报 drive/craft/crystal 三个服务端看不到的信号 |
craft.js | 随身制造浮层(J):配方取自服务端 command 清单,三态格子 |
lab.js | 研究所 / 科技树浮层(U):两线 × tier 分列 + 详情面板 |
starmap.js | 星际图浮层:3 旋臂星系(不旋转)+ 30 星域节点 |
radar.js | 左上角 canvas 雷达:250ms 重烘底图 + 每帧仿射搬运,5 类 blip(精英坦克与 Boss 边缘追踪、永不省略) |
depot.js | 基地仓库浮层:左仓库右背包,单件 / 整叠存取 |
build.js | 建造模式 + 建筑悬停标签 + 三栏建筑面板 + 建筑静态物理盒 |
bag.js | 背包浮层 + 快捷栏 + 物品 / 配方 / 建筑配方清单镜像 |
logistics.js | 物流无人机可视化:分桶 InstancedMesh,大圆插值 |
logistics-const.js | 物流共享常量(两端一致) |
flora-harvest.js | 植被采集定义表(两端共用) |
3.6 音频
| 文件 | 职责 |
|---|---|
audio.js | 全项目唯一音频出口。模块级单例 —— 各玩法模块 import { ui, sfx, sfxAt, loopStart, loopStop } 直接用,未初始化时全是 no-op。四总线 ui / sfx / amb / bgm → master → DynamicsCompressor → 输出。 |
三条防炸机制(缺一条就会在 50 台 NPC + Boss 齐射时爆音):
| 机制 | 参数 |
|---|---|
| 同名限流 | MIN_GAP:脚步 0.22s、命中 0.04s、UI 点击 0.05s、默认 0.03s |
| 最大并发 | MAX_VOICES = 24,超了踢「最老的非 ui 源」(点一下必须有反馈,所以 ui 永不踢) |
| 距离降级 | 弧长 >120 只放 P0;>260 直接不发声(听觉范围比 renderDist 1100 小得多) |
3D 定位按球面弧长(locate()):距离 = 方向夹角 × 当前星球半径。 🔴 不能拿世界坐标相减 —— 两颗星球都是「单位方向 × 半径」,同样的坐标差在 R520 / R820 上是完全不同的实际距离。声像用切平面投影的左右分量。
三条设计约束(都是为了不重演 2026-10-15 的"整局冻住"):
- 任何调用都不许抛错 —— 未解锁 / 索引没到 / 文件 404 / 浏览器缺 API,一律静默降级;主循环里的调用处再包一层
safe('audio', …)。 - 不进首屏 ——
initAudio()只建AudioContext,拉索引与预载 P0 都等首次用户手势(浏览器自动播放策略本来就要求)。预载只挑kb ≤ 90的非循环条目,约 700KB。 - 循环源要排队 —— 环境音在 boot 末尾就
loopStart('amb'),那时索引还没到;loopStart遇到「索引未就绪」会进deferredLoops,索引到达后统一重放。不排队的话环境音永远不起(实测踩过:loops: [],作品进得去但没氛围)。
音效名 = 成品文件名去扩展名(ui_click / sfx_mecha_step_l / amb_home_loop), 路径映射来自 public/assets/audio/_index.json(由 tools/audio/postprocess.py 生成 —— 新生成音效后跑一次后处理就会自动出现)。
- 调试出口:
__OCC__.audio.{ready, mix, play, setBus, setEnabled, loops, stats} - 音量面板:
mountMixer()挂在右下 dock,DOM 全动态创建 + 样式内联(不动index.html/styles.css,避免踩 z-index 阶梯表) - ⚠️ 两个坑都踩过:按钮必须
position: static(.dock-btn基类是fixed);wrap 要挂 dock 末尾(否则和同样 fixed 的「作品文档」按钮静态位置重叠、被盖住)
3.7 UI 与网络
| 文件 | 职责 |
|---|---|
hud.js | HUD 总管:加载 / 连接状态 / 运行信息 / toast / 弹药 / 准星 / 血条 / 人名标签 / 击杀播报 / 心形血量 / Boss 血条 / 死亡倒计时 |
panels.js | 战绩面板 + 左上角在线玩家列表 |
item-tip.js | 物品 TIPS 共享实现(全页唯一浮层 .bag-tip),背包 / 建筑面板 / 仓库共用 |
auth-ui.js | 登录页(Email + Password)与创建角色页(Nickname / 形象 / 机甲 / 5 色配色) |
tasks.js | 右上角「进行中的任务」进度面板(研究 / 制造共用,倒数在本地做) |
net.js | HTTP 封装 + WS 连接(3s 自动重连;被顶下线不再重连) |
3.8 不在 public/src/ 里的 3 个前端模块
| 文件 | 说明 |
|---|---|
assets/mecha/model/rig.js | 机甲骨架 / 蒙皮 / 姿态应用 |
assets/mecha/model/anim.js | 程序化动作库(idle / walk / run / fly) |
assets/mecha/model/legmath.js | 腿部 IK 数学 |
它们经服务端 /assets/mecha/model 静态路由到达浏览器,被 mecha.js 静态 import。 算前端模块时容易漏掉这三个(源码目录树里看不到)。
另外 public/vendor/addons/libs/meshopt_decoder.module.js(29KB,内联 WASM)是 EXT_meshopt_compression 的解码器,被 glb-loader.js 静态 import —— 发布时不能漏(漏了就是"建筑/矿产/无人机全部不显示")。
4. 服务端模块地图(11 个)
| 文件 | 规模 | 职责 | 关键导出 / 常量 |
|---|---|---|---|
server.js | 2563 行 | HTTP + WS 主服务;世界 / NPC / Boss / 掉落 / 背包 / 建造 / 科技 / 采集的权威入口 | PORT=8790、GATHER_RANGE=46、DEPOT_RANGE=46、LOOT_PICK_RANGE=22 |
store.js | 1228 行 | 账户(scrypt 口令、会话)、背包 / 仓库 / 建筑 / 科技 / 引导操作层、落盘 | createStore(file)、SAVE_INTERVAL_MS=10000、AVATARS(6 形象) |
bag.js | 499 行 | 背包 / 仓库定长数组模型与全部原子操作 | BAG_SIZE_BASE=24、BAG_SIZE_MAX=64、QUICK_SLOTS=10、STORAGE_SLOT_MAX=500 |
items.js | 335 行 | 物品 / 配方 / 建造成本的单一定义源 | ITEMS(49 项)、RECIPES(34 条)、BLD_COST(20 条) |
tech.js | 368 行 | 科技树数据 + 效果汇总(运行时现算) | TECHS(35 节点)、effectsOf、prereqMet |
build.js | 293 行 | 家园星建筑数据层:放置 / 拆除 / 校验 / 自愈 | MAX_BUILDINGS=400、gridToDir、ensureWorlds |
sim.js | 428 行 | 基地"结算式"生产模拟(能源 / 生产 / 物流,1Hz) | SIM_PERIOD_MS=1000、BUILDING_RUNTIME(20 种)、COMMAND_RECIPES(27 条) |
guide.js | 139 行 | 新手引导的Server-authoritative推进(只前进不后退,TEST 判定表) | GUIDE_SIGNAL(白名单 3 个)、ensureGuide、evalGuide、noteGuideSignal、guideWire |
npc.js | 704 行 | Server-authoritative NPC 小汽车(含精英坦克) | createNpcManager、名字表 50 个、RESPAWN_DELAY=8 |
world-boss.js | 611 行 | 全星球唯一 Boss「天罗」 | createWorldBossManager |
world-config.js | 248 行 | Server-authoritative世界参数(前端启动时拉取覆盖) | WORLD_CONFIG(seed 20261012) |
依赖方向(箭头 = import):
server.js
├─> world-config.js
├─> store.js ──> bag.js ─> items.js
│ └─> build.js ─> items.js + world-config.js
│ └─> tech.js
│ └─> guide.js ─> public/src/guide-steps.js(步骤文案表,两端共用)
├─> npc.js (自包含球面数学)
├─> world-boss.js (自带最小球面数学)
├─> sim.js ──> items.js + build.js + public/src/{resource-field,logistics-const}.js
└─> public/src/{terrain-field,resource-field,flora-harvest}.js
口径:server.js 只做入口 + 校验 + 广播,业务状态在 store.js(账户 / 背包 / 建筑 / 科技,会落盘)与 sim.js(基地生产运行时,不落盘)。
5. 前后端共用层(确定性)
| 模块 | 共用内容 | 一旦不一致会怎样 |
|---|---|---|
terrain-field.js | 海拔 / 生物群系 / 水位雪线 | 两端对"哪里是陆地"判断不同 → 出生点落海里、矿长在水上 |
resource-field.js | 矿脉分布 / 富集度 / 堆簇 | 客户端瞄到矿、服务端说"这里没矿" |
flora-harvest.js | 树石灌木的名字 / 血量 / 掉落 | 采了不掉东西,或掉错东西 |
logistics-const.js | 无人机速度 / 太阳周期 | 无人机飞到的时刻和服务端入账时刻对不上 |
guide-steps.js | 新手引导 16 步的文案与顺序 | 面板显示 16 步、服务端按 15 步推进(顺序/数量错位) |
noise.js | Perlin / 可复现随机 | 同一个 seed 两端算出不同世界 |
反面教材:_logs/gather_tip_check.cjs 里自己内联了一份 cellToDir,与资源Search对不上,导致扫不到矿。 不要自己另写一套坐标换算——去 import 现成的。
6. 世界装配与主循环
6.1 装配(boot())
main.js 的 boot() 是一条直线流程,每步之间 await nextFrame() 让加载条刷新:
读服务端 cfg → deepMerge → 投影当前世界 → 预加载过渡 → 并行下载机甲 GLB
→ 登录 / 创建角色 → 钱包 / 出生点 / 摧毁列表(并发)
→ WebGLRenderer / scene / 光照
→ terrain → planet → flora → physics(+地面+障碍物流) → effects/debris/damageNumbers/destructibles
→ resources → buildMode → logistics → loot → (雷达与面板并行下载)
→ 载具模型 → npcs → boss + bossFx → mecha 落地 → mining → player + camera
→ paperPlanes → sky → input → 首帧渲染 → 雷达 / 面板实例化 → net.connect → setAnimationLoop
没有 createWorldRuntime / disposeWorld:切世界靠整页重载,别去找这个函数。
6.2 主循环(26 个阶段)
| # | 阶段 | 关键点 |
|---|---|---|
| 1 | dt 计算 | dt = min(realDt, 0.1) 防跳帧;realDt 只给 FPS 用 |
| 2 | 自适应分辨率 | <50fps 连续 30 帧降 pixelRatio;>58fps 连续 120 帧恢复;中间是滞回带 |
| 3 | 功能键处理 | Esc / Tab / U / J / E / 1~0 / H G C L I R |
| 4 | 障碍物流式加载 | safe('obstacleStream.update') |
| 5 | 开火判定 | 建造态左键 = 放置;否则 F = 攻击 |
| 6 | 玩家与纸飞机控制 | rig.getBasis → player.applyControl → planes.applyControl |
| 7 | 物理步进 | alignGravity → refreshAABBs → step(1/90, dt, 5) → flush() |
| 8 | 回读物理 | player.syncFromPhysics、planes.sync |
| 9 | 深水阻挡 | 贴地进深水 → revertToDry() |
| 10 | 实体 tick | NPC → Boss → 远程玩家 → 远程特效 |
| 11 | 特效 | effects(传重力方向)/ debris / 伤害数字 |
| 12 | 飞行喷口 | 左右脚各一股 |
| 13 | 相机 | rig.update;受击白闪衰减 |
| 14 | 其它 | 网格层、植被扇区剔除、可破坏物 |
| 15 | 死亡流程 | 倒计时 → 重生;摧毁物批量上报(1s 节流) |
| 16 | 血条投影 | 世界坐标 → 屏幕,交给 DOM |
| 17 | UI 节流 20Hz | 雷达 / 人名标签 / 建筑悬停 |
| 18 | 在线列表 5Hz | |
| 19 | 昼夜与自转 | 太阳方向由 Date.now() 推,跨机器一致 |
| 20 | 采矿 / 建造分支 | 建造态关采集,回来必须 mining.setActive(true) |
| 21 | 掉落 / 物流 | |
| 22 | HUD 按钮显隐 | 回苗圃 / 建基地 / 科技树 / 生产提示 |
| 23 | 渲染 | |
| 24 | 网络同步 10Hz | 发 state |
| 25 | HUD 收尾 | 光标、弹药、信息面板 |
| 26 | input.endFrame() | 必须是最后一件事 |
6.3 safe() 包装(本项目最贵的一课)
renderer.setAnimationLoop 的回调抛一次错就不再排下一帧 —— 整局冻死,键鼠全无反应,只能 F5。 而且 F5 会清控制台,玩家回头报"没有报错"。
所以主循环里每一个易错模块调用都包 safe(标签, fn):出错只丢该模块这一帧,且同一标签只 warn 一次。 2026-10-15 的冻屏事故就是这个机制的直接产物(纸飞机命中 NPC 时 normal.dot is not a function)。 新增模块进主循环时,第一件事是给它套 safe()。
7. 坐标系与球面约定(全项目铁律)
| 约定 | 内容 |
|---|---|
| 姿态 | +Y = 球面法线,+Z = 朝向 / 前方;右向量 = heading × normal(写成 normal × heading 会得到左向量,雷达曾因此左右镜像) |
| 位置 | 单位方向 × 半径;不用欧拉角,不累加世界坐标 |
| 移动 | 沿大圆精确步进 normal' = normalize(normal·cos a + heading·sin a),a = 距离 / R |
| 距离 | 球面弧长 acos(dot) × R,不是直线距离。前后端必须同口径 |
| 网格参数化 | (-cosθ·sinφ, cosφ, sinθ·sinφ),uv = (u, 1-v);terrain / resources / build / radar / flora 的反解必须逐字一致 |
| 网格比例 | lon : lat 恒为 2:1(等距圆柱下才是正方形格) |
| 建造格 | 建造格 = 地形格 ÷ 2(GRID_DIV=2),约 5.15 世界单位 |
| 障碍物流式加载 | 用 o.dir(单位向量)做角度比较;拿世界坐标点乘会把全量放进来 |
| 瞬移 | 方向差超阈值直接 copy,不许插值(否则横穿星球) |
8. Server-authoritative边界
这是"哪些能自己算、哪些必须问服务端"的清单:
| 事项 | 权威方 | 说明 |
|---|---|---|
| 移动 / 转向 / 开火 | 客户端即时 | 手感优先 |
| 伤害 / 血量 / 存活 | 服务端 | 唯一扣血来源:被别人的纸飞机打中;碰撞不掉血 |
| 击杀统计 / 连杀头衔 | 服务端 | wallet.derived 前端不要自己算 |
| 矿石储量与矿种 | 服务端 | 绝不信任客户端上报的 kind / amount |
| 背包与仓库 | 服务端 | 三铁律:不广播、客户端不做乐观更新、统一 bagOp 回包 |
| 建造 / 拆除 / 生产 | 服务端 | 只能在家园星建;距离校验在服务端 |
| 科技解锁与效果 | 服务端 | 存档只记 unlocked[],效果运行时现算 |
| NPC / Boss 位置与血量 | 服务端 | 客户端只做外推插值 |
| 障碍物被打碎 | 客户端上报 + 服务端记账 | 地图确定性生成,只记"第 N 号没了" |
| 植被采集 | 服务端反解格 | 客户端只上报方向 |
| 掉落物拾取 | 服务端 | 客户端只发 lootPick,等回包 |
| 新手引导进度 | 服务端 | server/guide.js 只前进不后退;客户端只渲染 + 上报 drive/craft/crystal |
| 广播可见性(苗圃私有) | 服务端 | sameWorld() 必须在 broadcastState / relayState / peersIn 三处都生效 |
| NPC / Boss 的"玩家名单" | 服务端 | npcTimer 的 dirs 只收 NPC_WORLD(喂给精英炮弹 / Boss 气炮 / 火榴弹 / NPC 追击目标);可见性过滤不等于伤害隔离 |
9. 运行时状态与落盘
| 状态 | 存哪 | 生命周期 |
|---|---|---|
| 账户 / 背包 / 仓库 / 建筑 / 科技 / 引导进度 | _data/state.json | 每 10 秒或改动后节流落盘;Sign out时同步写 |
| 被打碎的物件 id | state.json 的 worlds[seed].destroyed | TTL 330 秒,到点重生 |
| 矿脉采空记账 | 服务端内存 Map | 进程重启即清;家园 20 分钟 / 战场 10 分钟回满 |
| 基地生产运行时(能源 / 物流在途) | 服务端内存 | 不落盘,重启重算 |
| 客户端"哪些矿采空了" | 前端内存 | 每次开页面清空 ⇒ 靠服务端拒绝回包 code:'depleted' 兜底 |
最后一行是"幽灵矿"事故的根源,详见 玩法系统现状。
10. 已知禁区(改动前必读)
| 禁区 | 后果 |
|---|---|
主循环里裸调用模块(不包 safe) | 一抛错整局冻死 |
跨模块传方向 / 法线 / 点用普通 {x,y,z} 对象 | xxx.dot is not a function → 冻屏 |
给 public/ 静态资源加 immutable | 文件名无内容 hash,发布后老用户永远跑旧代码 |
| 改动影响 flora 生成顺序 | 障碍物 id 全部错位 → 必须同时清 _data/state.json,且改前先停服 |
在 cannon 的 collide 回调里 removeBody | narrowphase 中改数组 → undefined 崩溃;必须延后到帧末 flush |
| 服务端用硬编码的战场网格参数解释苗圃坐标 | "贴着矿却说太远" |
把 latSegments 设成等于 lonSegments | 网格变长方形,违反等距圆柱正方格前提 |
| 给冶炼加科技门槛 | 零到一死锁(铁锭只有冶炼厂能产,而冶炼厂要铁锭) |
| 中级材料(钢板 / 电路板 / 机械部件 / 塑料)只挂在装配机上 | 三重死锁:装配机成本就要这三样,而它们只有装配机能跑 ⇒ 指挥中心 / 随身制造必须兼职 |
只在 broadcast 里过滤私有世界 | relayState 是另一条独立通道,漏了就会看到别人降落在你的苗圃 |
| 以为"看不见就打不到" | 可见性过滤只管广播;伤害判定走的是 npcTimer 的 dirs,不一起过滤就会出现"在苗圃被焦土的 Boss 打死"(2026-10-16 踩过) |
把"每帧无条件执行"的调用插进 if (某模式) 分支 | 只有那个模式才跑(guide.update 曾插在 buildMode.active 里 → 进度永远 0) |
| 客户端另写一份引导判定 | 两边定义漂移;判定只在服务端,客户端只拿 guide 消息 |
| 引入后处理 / 动态增删灯光 | 着色器重编译卡顿;补光要常驻只改强度 |
| 运行时 dispose 世界做热切换 | 两颗星球不能同时驻留显存,方案已明确放弃 |