OCC 星球漫游 · 玩法系统现状
逐系统记录代码里实际跑着的东西:机制、数值、界面入口、以及每个系统踩过的坑。 与 玩法方案-Planet Civilization-v1 的关系:方案写"打算怎么做",本篇写"现在是什么样"; 两者的差距由 未完成清单 对照。
核对基线:2026-10-15 23:53。所有数值取自代码常量,不是估算。
0. 核心循环
焦土战场:打 NPC / 精英 / Boss → 掉结晶、合金、废铁
↓ 星门(脱战 5s + 冷却 30s)
苗圃家园:采矿机产矿 → 无人机运回 → 冶炼出锭 → 造建筑
↓ 研究所消耗战场材料
科技树解锁:新建筑 + 属性乘数(伤害 / 速度 / 血量 / 无人机数)
↓ 更强的机甲与产能
回到焦土战场(闭环)
战场材料是科技树每一项成本的硬门槛(每项 cost 必含 crystal 或 alloy)—— 这是让玩家非去战场不可的那根绳子。
1. 采集与矿产资源
1.1 矿脉分布(resource-field.js,两端共用)
| 项 | 值 |
|---|---|
| 矿种 | 5 种:铜 copper / 铁 iron / 锡 tin / 石油 oil / 石料 stone |
| 富集度 | 1~3 星,产出倍率 1 / 1.6 / 2.4 |
| 堆簇规则 | 全局固定栅格 PILE.STEP = 5(51 世界单位)找堆心,每堆 3~4 块,堆内散布 0.85 格 |
| 储量 | 一堆 = 一个格坐标;储量按 pileSize(i,j) 乘堆内块数 |
| 流式生成 | 玩家周围 20 格(≈206 单位)内生成,保留半径 44 格 |
| 采空 TTL | 战场 10 分钟 / 家园 20 分钟 |
要点:
- 🔴
PILE.STEP = 5必须整除两星球的网格段数(500 / 250 / 320 / 160)。不整除会在跨 180° 经线处出现"假堆"—— 同一堆被算成两堆。 - 去重按格坐标排序(与玩家位置无关),保证不同玩家看到同一堆矿。
- 阈值基于 fbm 实测值域(0.265~0.335),不是归一化值,改动噪声参数会让矿脉整体消失或泛滥。
- 矿石总量必须乘堆内块数,否则产出掉到 1/5。
1.2 采集(mining.js + server.js)
| 项 | 值 |
|---|---|
| 客户端射程 | BEAM_RANGE = 34(按球面弧长判定,不是直线距离) |
| 服务端校验 | GATHER_RANGE = 46 |
| 结算频率 | 每 0.5s 发一次 gather |
射线 far | 400(从相机出发,不是从手臂) |
两个范围不是等号关系:服务端必须 ≥ 客户端对堆内散布(≈8.8)的余量,否则会出现"能射不能采"。 🔴 服务端绝不能比客户端更严。
操作方式(两入口共享同一套状态):
| 入口 | 语义 |
|---|---|
| 鼠标左键按住 | 持续采集,松开即停 |
| E 键 | 开关:按一下持续采集,再按一下关;鼠标移开资源自动断开 |
🔴 E 的三个约束:
- 判定用
mining.aimInfo(),不能用hit——hit只在"正在采"时才有值,拿它做条件会自锁。 - "界面开着也算不在资源上"。射线用的是鼠标 NDC,面板挡住了看不见矿,但射线照样打得到 ⇒ 不挡就变成"开着背包在背后偷偷采集"。
- 开关必须看得见:血条名称后挂
· 连续采集中绿标。
1.3 采空与"幽灵矿"(本项目最典型的一类事故)
"矿消失"有两条路径,缺一条就出幽灵矿:
| 路径 | 触发 | 带不带命中点 |
|---|---|---|
| 正常回包 | gatherResult 的 left <= 0 | 有(来自 pendingPick.point) |
| 服务端拒绝 | 回包 code:'depleted' + i, j | 可能没有(pendingPick 已被上一次回包消费) |
幽灵矿的症状:刷新后矿堆满血、光束照打、每 0.5 秒被拒一次,却永远不消失。 根因是"服务端储量记账在内存、客户端采空表每次开页面都是空的"。
修法三条:
- 服务端拒绝回包必须带机器可读的
code:'depleted'和i, j(只回中文 error 客户端没法识别)。 onDepleted要先补点、再摘矿 —— 顺序不能颠倒(摘掉节点就查不到方向了)。补点用resources.pointOfCell(i,j),且必须返回THREE.Vector3:返回普通对象会让法线带上半径长度(R≈520)进粒子系统。onDepleted要幂等闸门:markDepleted返回false就 return,否则两条路径先后叫到同一堆会连炸两下。
1.4 植被采集与"采完消失"
树 / 灌木 / 岩石走的是另一条链(gatherFlora + 共享表 flora-harvest.js),与矿脉无关:
| 项 | 值 |
|---|---|
| 采集方式 | 同上(左键按住 / E 开关),射线打的是障碍物而不是矿堆 |
| 单株血量 | 树 / 岩石按碰撞半径分档(1~5,见 §7.2);灌木固定 BUSH_HP = 3 |
| 采样节奏 | 每 0.5s 掉 1 点血(与射程 34 / 服务端 46 同口径) |
| 掉落 | 树 → 木材;灌木 → 木材;岩石 → 石料 |
| 采完表现 | 原地塌缩消失(InstancedMesh 矩阵缩到 0)+ 木屑 / 石屑粒子 |
"消失"其实是两套机制,因为灌木和树石不是一个物种:
| 类别 | 有物理体? | 判定 | 是否上报服务端 |
|---|---|---|---|
| 树 / 岩石 | ✅(在 flora.obstacles 里,自带 hp/maxHp) | 归零走 destructibles.damage():血条 + 塌缩 + 上报(obstacleStream.release + reportQueue) | ✅ 重连后不复活 |
| 灌木 | ❌(刻意不给碰撞体,车能压过去) | 本地血量表 bushHp: Map(键 = mesh.uuid:instanceId),采满 3 次直接缩到 0 | ❌ 无需上报 |
🔴 第二条兜底:整片采光。服务端按"格"记账(depKey = worldId:flora:kind:ii,jj,储量 = def.hp), 耗尽时回 code: 'floraDepleted' —— 与矿脉的 code:'depleted' 同一套机器可读口径。 少了它,客户端本地血量表只记得"采过几次",一片林子被采空后画面里的植被还在。
⚠️ flora(障碍物上报)与 gatherFlora 是两条不同消息:前者供 NPC 避障,不结算、不落库,别混。
2. 建造
| 项 | 值 |
|---|---|
| 建造地点 | 只能在家园星(苗圃) |
| 建造格 | 地形格 ÷ 2(GRID_DIV=2),约 5.15 世界单位 |
| 建筑上限 | 400 |
| 唯一性 | 指挥中心、星门各限一座 |
| 拆除返还 | 100% 放回背包:建筑本体(bld_*)+ 它库存里的东西一起进背包。库存零头(采矿机的浮点产出)向下取整;背包放不下就整单拒绝(不会“先拆再丢”);星门不可拆 |
交互链路:
- 入口:背包里双击建筑 → 进入建造模式;或 HUD「开始建基地」按钮(只在"家园星 + 0 建筑 + 背包有可放置物"时出现)。
- 模式内:幽灵预览 + 占地框 + 底座圈 + 建造网格线;鼠标射线与球面求交
solve();R 键旋转 90°;Esc Sign out。 - 合法性校验:在水里 / 雪线上 / 没有该物品 / 与已有建筑重叠都会被拒。
- 拆除:点建筑 → 面板右上角「拆除」 → 按钮变成「确认拆除?」→ 5 秒内再点一次才真的拆(防误触);拆下来的建筑本体和它库存里的东西一起回背包。
- 放置顺序:先扣背包、再写存档;放置失败要把物品还回去。
建筑面板按类型长得不一样(这一点最容易做错):
| 建筑类型 | 打开什么 |
|---|---|
command(指挥中心) | 简介卡(400×190),没有三栏工作台 |
storage(储物仓) | 直接开仓库浮层 #depot |
stargate(星门) | 开银河星图 #starmap |
| 其余 | 三栏工作台(配方 / 库存 / 背包) |
1.x 两条科技线对战场材料的口径(2026-10-16 改)
| 线 | 是否要战场掉落物(结晶 / 合金) | 为什么 |
|---|---|---|
| 生产线(9 项) | 不要 | 基地这条线必须能在苗圃里自给自足推到底:矿石 → 锭 → 钢板 / 塑料 → 电路板 / 机械部件 → 建筑。以前每条硬塞 2~10 个结晶,等于"想建第一台采矿机就得先去战场捡结晶",而引导第 1~7 步全在家园星 —— 顺序直接打架 |
| 战场线(26 项) | 要 | 这条线本来就是"打 → 拿 → 造 → 更强"的兑现处,战场材料是它存在的理由 |
替换表(保持 tier 曲线,语义尽量贴):
| 科技 | 原成本里的战场材料 | 换成 |
|---|---|---|
| 采掘 I | crystal 2 | 石头 30 |
| 冶金 I | crystal 2 | 锡锭 8 |
| 电力 I | crystal 2 | 锡锭 10 |
| 石化 I | crystal 3 | 原油 20 |
| 物流 I | crystal 4 | 电路板 6 |
| 装配 I | crystal 6 + alloy 2 | 机械部件 6 + 塑料 20 |
| 载具工厂 | crystal 6 + alloy 2 | 电路板 12 + 锡锭 20 |
| 轨道平台 | crystal 10 + alloy 6 | 机械部件 20 + 塑料 40 |
⚠️ 别把 scrap(废铁)当家园材料 —— 它在 items.js 里的定义就是"战场残骸",同理不能进生产线成本。
1.y 科技研究要时间(2026-10-16)
techUnlock 的语义从"瞬时解锁"改成"开始研究":
- 时长按 tier:T1 15s · T2 40s · T3 90s · T4 180s(单项可用
sec覆盖,见server/tech.js的techSec)。 - 入队时就把材料扣掉(不是完成时扣)—— 否则玩家能"同时排好几个"(材料只够一份)变成变相排队刷。
也不做"取消退还":十几秒到几分钟的量级,加取消要处理"扣了就退"的各种边角,不值。
- 结算:
store.tickTasks()每秒由server.js的taskTimer扫一遍在线连接;
离线期间到点的任务在下次 hello 一次结清(定时器只活在内存里,PM2 reload 就没了)。
1.z 制造 / 研究队列(2026-10-16 晚,老大;2026-10-17 大改)
原话(第一版):「制造和研究要有队列,同类型的可以一个队列、数量叠加,制造时还是一个个的造。 队列数量当前 5 个上限。制造和研究的进度和队列展示在制造和研究界面也要有。」
原话(2026-10-17 第二版):「在制造队列中,会显示每一项材料的制造步骤……现在可以把它提升到 20 种」; 「缺少铜矿、铁矿这些东西,就会列出总体缺少的东西,这些就是需要去采集的」; 「身上不够,就会有无人机从仓库运送到自己身上来,这样就可以开始制造了」; 「材料不够时,我也可以选择把它加入制造队列」。
规则(server/store.js,2026-10-17 后):
| 规则 | |
|---|---|
| 队列订单数 | 最多 20 种(TASK_QUEUE_MAX)—— 一种 = 玩家点过的一个成品 |
| 展开后的条目 | 上限 160(TASK_STEPS_MAX,防呆用;一次"造装配机"约 7 条) |
| 同配方叠加 | 同配方入队合并成一条、count += times,单条 ≤ 999(TASK_STACK_MAX) |
| 执行方式 | 串行:只有队首在跑;队首缺料时把"现在能做"的提上来(不卡死整条队列) |
| 材料 | 每件开工时扣(不是入队即扣,见下) |
| 研究 | 同一项不能重复排;研究不展开(老大:「研究先不用这种方式」) |
⚠️ 上限算"订单种类"不是"条目数":一次"造采矿机"会展开成 冶炼铁锭 / 冶炼铜锭 / 造钢板 / 采矿机 4 条,按条目数限 20 的话排 5 个建筑就满了 —— 而老大要的正是"选一个就想把整条链都排上去"。
数据结构(老存档由 ensureTaskQueues() 惰性迁移):
craftQ = [{ recipe, item, n, totalMs, count, done, paid, auto, order, waiting?, blocked? }]
craftAt = 流水线时钟(开始时间;等料/换件时重置为 now)
researchQ = [{ id, totalMs }]
researchAt = 同上
🔴 paid = 已扣过料的件数,2026-10-17 新增: 扣料从"入队即扣"挪到"每件开工时扣",因为展开后的队列里后面那条要的材料是前面那条刚造出来的—— 入队那一刻背包里根本没有铁锭,一扣就报"材料不足",整条链自相矛盾。 老存档条目迁移成 paid = count(旧语义是入队即扣),否则会被再扣一遍材料。
🔴 tickTasks 用 while + at += 该项耗时 推进,不是"每 tick 只走一件": 区别在离线 —— 玩家下线 1 小时回来,排的都该造完了。
🔴 进度字段叫 made,不叫 done:craftResult 回包里的 done: true 是"完成通知"的布尔标记, ...done.craft 展开时如果进度也用 done 就会把它覆盖成数字(实测踩过)。
界面:进度 + 队列出现在 3 处 —— 右上角常驻面板(tasks.js)、 制造界面(craft.js)、研究所界面(lab.js)。后两处共用 queueHtml(), 队列条目会标上 · 冶炼厂(这道工序通常在哪做) 与 自动(系统补的中间件)。
1.y 制造链展开 / 缺料汇总 / 自动补给(2026-10-17 老大)
三件套都在 server/bom.js(纯函数,可单测):
| 函数 | 干什么 |
|---|---|
explodeCraft({recipeId, count, recipes, byItem, pool}) | 把"造一个 X"递归拆成拓扑序的工序链 + 基础料缺口 |
queueShortage({queue, recipes, byItem, pool}) | 已排队列还缺多少(时间序模拟,结果折算成基础料) |
toBasics(item, qty, {recipes, byItem}) | 任意材料 → 基础料(矿石/木材/原油),缺料显示只显示这一层 |
🔴 三条铁律:
- 先吃库存再展开(
pool= 背包 + 仓库 + 队列净余量)。整份把"队列里还没做出来的产物"
算成可用会重复计入 —— 实测:排完装配机,队列里的采矿机凭空缺 12 个铁锭。
- 顶层不扣池:玩家点"造采矿机",不该因为背包里已有一个就不造。
- 缺料只显示基础料:合并队列后中间件也可能短板(顺序不总是拓扑序),
但"铁锭缺 13"玩家没法去采,折成"铁矿石缺 26"才是可执行的指令。
队列顺序:新工序整批 append(同一次展开内部已是拓扑序 ⇒ 成品总在它的中间件之后); 同配方合并到已有那条(合并只会让它"造得更多、更早",不影响后续可用量)。
自动补给(store.payEntry):某件开工时身上不够 ⇒ 从仓库搬过来(记账)+ 下发 {t:'supply', from, list} ⇒ 客户端 logistics.addSupply() 画一条「仓库 → 玩家身上」的无人机航线。 ⚠️ 取舍:材料到账即开工,无人机的飞行是纯表现(要做严格同步得把结算改成事件驱动, 为一条表现延迟停工不值得)。
背包刷新:产物到点由 tickTasks 投递,applyTaskResults 把 bag 快照塞进 craftResult → craft.js 的 onMessage 转给 onCrafted → main.js 喂给 bagUI.handleMessage (全链路已验:tasks_ui.cjs ⑧ 断言"造完 → 背包数量增加")。
1.x 随身制造也要时间
同一套模型(store.craftStart / tickTasks),时长直接用配方的 sec(冶炼 3s、钢板 5s、电路板 6s…)。 ⚠️ 产物到点才进背包;如果那时背包满了,任务不算完成,下一 tick 再试并推一次"腾个格子"的提示 (不能把产物凭空丢掉 —— 玩家白等一场;blocked 标记做幂等,玩家动过背包后自动清掉)。
1.w 去战场要先建星门(2026-10-16)
服务端 travel 硬校验:home → battle 必须在基地里放过星门(stargate / stargate2)。 判据是"基地里有这座建筑",不是"背包里有那件物品" —— 起步套装会把星门发进背包,光"手里有"不算。 ⚠️ 只挡这一个方向:回苗圃(召回)永远放行,否则在战场被围死、又因为"家里没星门"回不去,直接卡死。
1.v 星球视觉主题(per-world theme,2026-10-16)
老大:「焦土的风格和名称不合,需要重新设计和制造焦土这个星球」。
问题:焦土(名字来自"焦土政策",而且是战场星:NPC 车 / 天罗 Boss / 废铁掉落) 当时长成一颗绿色温带星球 + 蓝色星空 —— 地表走「沙滩黄 → 绿草 → 深绿 → 灰岩 → 白雪」, 植被是绿松树 / 阔叶 / 灌木,雾是浅蓝 8fb4da。 根子是两球共用同一套视觉:色带在 terrain-field.js、植被在 flora.js、 天空在 sky.js、雾与光照写死在 main.js —— 根本没有"分星球"的概念。
改法:新增 public/src/theme.js(纯数据,无 DOM / three 依赖,服务端也能 import), 在 WORLD_CONFIG.worlds[].theme 上标一个名字,七层各自去取:
| 层 | 取什么 | 焦土(scorched) |
|---|---|---|
| 地表 | landStops | 灰烬滩 → 焦土 → 焦褐 → 暗红炭 → 铁锈红 → 浮灰(最后一条刻意不白:是火山灰,不是雪) |
| 水体 | waterStops | 浑浊暗红(滚水 / 焦油池) |
| 植被 | flora.* | 碳化枯树(光秃黑树干 + 枯枝)、焦桩、灰堆;草与岩石压成炭灰 |
| 天空 | sky.* | 暗红黑烟尘底 + 血橙星云 + 星点压成余烬色且只剩 1/3 + 被烟遮住的暗橙日芯 |
| 雾 / 光照 | fog / sun / ambient / hemi | 暖褐雾;太阳偏红但照度给得比 verdant 更高(暗地表吸光) |
| 地平线辉光 / 云 / 建造网格 | atmosphere / clouds / grid | 余烬橙光晕且铺得更宽(power 3.2→2.8,像贴地一层浮尘);云换灰浆色且更薄;G 键网格亮线换余烬橙 |
| 小地图 | radar | 同色带一起换(否则焦土上顶着一张绿色小地图) |
🔴 这个文件只放"看着不一样"的东西,绝不放会影响判定的东西: BIOME 阈值、elevation() 地貌、植被的数量与 accept、collide 半径一律不动 —— 那些一动就会改出生点 / 障碍物 id / 玩法平衡。 ⇒ 焦土上树还是那些树、位置一模一样,只是变成了烧焦的。
⚠️ 四处踩坑(都值得记):
- 明度不能一路往下压。第一版按"越黑越焦"做(0x26~0x4a),实机整颗星球几乎全黑、
看不出地形起伏。实测中段平均亮度只有原来绿色版的 57%。"焦"要靠色相与饱和度表达, 现在把每一档的明度对齐 verdant、只往暖褐偏(实测约 71%,比原来暗、但能开车)。
buildFlora(scene, cfg.flora, …)的cfg是"植被数量子对象",里面没有theme
⇒ 在函数里 themeOf(cfg) 会静默回落成 verdant,现象是"地表明明焦了、树还是绿的"。 改成显式传 theme。
- 草叶的绿色画死在贴图里(
makeGrassTexture当map用),逐实例tint乘不掉绿色。
要换草色只能换贴图 ⇒ makeGrassTexture(colors) 支持逐根草叶配色。
- 🔴 地平线那圈辉光是第二个模块(
planet.js的atmosphereshader,硬编码0x63c8ff),
和 sky.js 完全无关。第一轮漏了它,结果"地表全焦了、地平线还是一圈浅蓝", 而且两颗星球一模一样 —— 一眼就穿帮。同一模块里还有云层(0xf3f8ff)与 G 键建造网格线(0x08141c / 0x8fe4ff)也是硬编码,一并接了主题。 验收方法:两球同一机位的实拍图摆一起,任何一处颜色相同都值得怀疑。
2.1 苗圃是「每人一份」的私有世界
老大(2026-10-15)原话:"苗圃星就是每一个玩家都有一个自己的星球,他们的建造也都在上面,跟其他玩家是不会在一起的。 所以我们在苗圃星球上也不会看到有其他玩家降落。"
实现上没有真的给每个玩家生成一颗星球(省掉一整套地形分片):苗圃仍然是同一个 home 世界, 只是按 private 标记隔离会话可见性。同 seed,地形一致,建造数据各自存在 profile.worlds.home。
| 通道 | 过滤方式 |
|---|---|
broadcastState(中继位姿) | sameWorld(a, b) |
relayState(独立通道,最容易漏) | sameWorld(a, b) |
peersIn(worldId, self) | 私有世界只返回自己 |
welcome.others | 不再下发(join 时按世界广播) |
| 掉落物 | 每条 loot 带 world;travel / lootPick / sweepLoot 都按世界过滤 |
| 建造 | 服务端强制 ws.worldId === 'home',否则回「不能在该星球进行建造物品和建筑」 |
| 伤害 | npcTimer 的玩家名单 dirs 只收 NPC_WORLD;hit 过 sameWorld();npcHit/bossHit 校验世界 |
dayCount | 私有世界恒为 1(不随战场推进天数走) |
🔴 三处过滤缺一不可。第一次只改了 broadcast 和 broadcastState,relayState 漏了 ⇒ 苗圃里仍然会看到别人(而且这个人多半是刚切过去的一瞬间的残留)。 这条已经作为红线写进 系统架构 §10 与 接口与协议 §7。
🔴 另一条独立的绳子:伤害(2026-10-16 老大报"在苗圃星球仍然被焦土的 BOSS 范围攻击伤害到")。 可见性过滤只管"看不看得见",不管"打不打得到" —— NPC 与 Boss 判定"哪些玩家"用的是 npcTimer 里那份 dirs(在线玩家位置表),它当时收的是所有连接: 苗圃玩家的方向被当成"战场上的一个玩家"参与距离判定,于是隔着两颗星球照样掉血。 dirs 一共喂给四处:精英坦克炮弹命中 · Boss 气炮命中 · Boss 火榴弹 AoE · NPC 的"玩家附近配额"与追击目标(最后这条不扣血但也错)。 修法:dirs 只收 NPC_WORLD(= WORLD_CONFIG.worlds[*].npc,现在就是焦土), 另外 hit / npcHit / bossHit 各加一道世界校验。 ⚠️ 这类 bug 没有任何报错,客户端表现完全正常(伤害本来就是Server-authoritative的), 玩家只能描述成"我莫名其妙掉血" ⇒ 回归用 tools/e2e/boss_crossworld.mjs(带对照组)。
3. 生产与物流(sim.js)
3.1 结算式模拟
| 项 | 值 |
|---|---|
| 周期 | 1Hz(1000ms),只遍历"有玩家在线的家园基地" |
| 建筑动态 | public/src/building-fx.js(每帧,main 主循环 safe('build.bldFx') 调):1 指挥中心顶部 4 盏警示灯绕塔顶旋转 + 红/琥珀交替闪 + 主灯脉冲 + 光晕盘 2 仓库顶部两扇门(无人机到货时开 → 放完自动关)+ 中央发光块自转呼吸 3 采矿机(见下行)4 太阳能板一条光带斜着从左扫到右、扫完停 1.2s 再一圈(夜里停并压暗)。离玩家 >320 单位的建筑不建、不更新 |
| 采矿机动态 | building-fx.js 的 updateMiner(每帧,不走 20Hz 的 UI 节流):在采的机器有齿轮工作盘 + 悬臂钻头 + 「钻头 → 目标矿格」采集光柱 + 矿格脉动光环 + 矿尘粒子,配 sfx_build_work_loop 循环音(只给最近一台发声);停产全部停住。转速跟着能源降速系数走 |
| 采矿机产出 | MINER_RATE = 0.5/s × 富集度 × 速度乘数。必须先由玩家设定一种矿(面板「采集目标」);机器在占地外扩 3 格(7×7)内找该矿种、取最近的一格采;找不到就停产(但照样耗电,功率恒定 2) |
| 制造产出 | CRAFT_RATE = 1/s 推进配方进度,一次只跑一条 |
| 能源不足 | 按缺口全局降速(下限 0.15),不是随机停机 |
| 无人机 | 不逐架模拟:派单时算好到达时刻,客户端只画表现 |
3.2 能源
不做布线电网,用全局能源池:发电建筑(太阳能板 / 能源核心)充池,耗电建筑从池取。 池容量下限 100;太阳能按太阳方向实时发电(SUN_PERIOD 与客户端同一常量)。
3.3 配方归属(只有一个口径)
| 项 | 值 |
|---|---|
| 配方总数 | 34 条 = 手写 15 条(冶炼 / 装配 / 弹药 / 装备)+ 自动生成 19 条建筑配方 |
| 建筑配方 | 从 BLD_COST 自动生成,制造时间 BUILD_RECIPE_SEC = 6 秒,by: 'command' |
| 指挥中心 / 随身制造 | 能跑 27 条 = 3 条冶炼 + 5 条中级材料 + 19 条建筑 |
| 建筑运行时 | BUILDING_RUNTIME 20 种,字段含 power / kind / capacity / recipes / fuel |
配方清单按三段固定顺序排(客户端面板就照这个顺序显示):冶炼 → 材料 → 建筑。 🔴 顺序有讲究:上次 19 条建筑配方排在最前,玩家握着铁矿石打开面板根本找不到冶炼入口。
🔴 零到一死锁:冶炼厂的成本要铁锭,而铁锭只有冶炼厂能产。 解法是让指挥中心兼职初级冶炼(smelt_copper / iron / tin)—— 相当于"手搓第一批零件"。 ⚠️ 因此 冶炼绝不能加科技门槛(加了就回到死锁);科技门槛只对建筑类 deployable 生效。
🔴 三重死锁(2026-10-15 新解):中级材料也全部只挂在装配机上,而装配机自己就要它们:
| 材料 | 配方 | 原 by | 卡住谁 |
|---|---|---|---|
steel_plate 钢板 | make_steel | assembler | bld_assembler 成本 = 钢板 40 ⇒ 第一台装配机造不出来 |
circuit 电路板 | make_circuit | assembler | 同上(装配机要 16 个) |
mech_part 机械部件 | make_mech_part | assembler | 同上(装配机要 4 个) |
plastic 塑料 | refine_plastic | refinery | bld_refinery 成本含塑料 10 ⇒ 炼油厂自己也造不出来,连锁卡死太阳能 / 电池 / 物流站 / 无人机厂 |
launcher_mk1 发射器 Mk1 | make_launcher_mk1 | assembler | 装配机要 t3 才解锁,而"造武器强化自己"是新手必经一步(引导第 14 步) |
⇒ 把这 5 条也放进随身制造(COMMAND_MATERIAL_RECIPES)。装配机仍是量产手段(排产队列 + 批量 + mult.craft 加速), 指挥中心只保证"第一份拿得到"。Mk2 / 穿甲弹 / 兴奋剂仍只在装配机,保持"建了装配机才强"的梯度。
🔴 配方归属由服务端 recipeIdsOf(type) 单口径下发(随登录 bldRecipes)。 客户端禁止靠 r.by === type 自己推断 —— 曾翻车:面板显示 19 条 vs 服务端实际 22 条。
3.4 物流无人机(替代传送带)
- 自动收集(老大 2026-10-17):物流站的空闲无人机自动去「采矿机 / 伐木机」取 1 单位货、
送进仓库、再飞回站归位,如此往复 —— 三段路径(kind:'collect' + mid 中途点)。 「只要有产出,无人机空闲的时候就会去运到仓库」。与下面的"站↔站供需运输"并存, 共用同一批无人机(inFlightFrom 合起来数);收集先派单(自动的基本盘优先于玩家配的供需线)。
- 🔴 运力/速度(2026-10-17 晚定档):
DRONE.capacity = 10、DRONE.speed = 32;
机型 hauler 10/32 · heavy 24/24 · swift 10/55。 依据:采矿机产出 = MINER_RATE(0.5) × 富集度(×1/×1.6/×2.4) ⇒ 最高 1.2 个/秒; 老大基地单趟全程约 183 米,10/32 ⇒ 5.7 秒一趟 = 1.75 个/秒(实测 30 秒搬 40 个,1.33/秒)。 ⚠️ 早期 capacity 3 + speed 26 只有 0.43 个/秒,搬得比产得慢 —— 采矿机堆到 22586、仓库才 1066, 看起来就像"根本没在搬"。
- 🔴 派车门槛 = 1(3c/3b 都是):
有多少搬多少,运力只是上限。
原来写"库存 ≥ 运力才发车",运力提到 10 之后,采矿机攒了 7 个就永远等不到发车、货一直堆着不动。
- 🔴
fleetOf()对"既没有fleet也没有drones"必须补 1 台标配(产品约定"建成先送 1 台")。
原来给的是 {}(0 台)⇒ 任何没写 fleet 的物流站永远不派单(采矿机堆满、天上一架都没有)。
- 无人机台数:
building.fleet={ 机型: 台数 }(如{ drone_hauler: 1, drone_heavy: 2 }),
建成先送 1 台标配机,其余靠物流站自己造;DRONE.perStation = 10 是单站编制上限 (派单上限 + 面板格数)。老存档的裸数字 b.drones 由 fleetOf() 一次性迁成标配机队 (迁完就删字段,只留一个真相源);下发给客户端的 drones 是 fleet 的和。
- 机队分型(
logistics-const.js的DRONE_TYPES,两端共用):三种机型各用各的 GLB
(d01_cargo 运输 / d02_builder 重型 / d03_scout 高速,2026-10-17 晚)。 🔴 这三个模型的材质是「纯黑底 + 自发光贴图」(baseColorFactor: [0,0,0],颜色全在 emissiveTexture 里)—— 染色必须打 emissive;乘在 color 上是"黑乘任何色还是黑"(踩过:三种机型一模一样、全是黑的)。 另外 color 也要给个中灰底(tint × 0.7),否则白天也是一坨黑、看不出形体。 三种机型在运力/速度/外观上都不同 —— drone_hauler 运输(1 单位 · 45 速)、drone_heavy 重型(2 单位 · 32 速)、 drone_swift 高速(1 单位 · 78 速)。派单按 DRONE_PRIORITY(运力大的先上)展开 "一台一个座位",第 n 台空闲机就用它自己的运力与速度算装货量与 ETA —— 客户端据此缩放/染色。
- 加工区块(造无人机):
BUILDING_RUNTIME.logistics.recipes= 全部by:'logistics'的配方
(make_drone_hauler / make_drone_heavy / make_drone_swift)。材料从这栋站的库存扣, 产物不进库存,当场编入 b.fleet(sim.js 的产出拦截)。编制满 10 台就停工,不再吃材料。
- 制造计划(
building.plan = [{recipe, count, made}],串行):面板底部「制造计划」条,
Shift + 点加工品格子排 1 个(同配方合并计数,≤20 种 / 每种 ≤999,与随身制造同一套上限)。 🔴 计划是承诺:队首原料没齐就停工等料,绝不改造成其它配方(点名 b.recipe 则相反, 原料不够会退化成"自动挑够料的")。
- 🔴 建筑 ↔ 仓库的双向物流(2026-10-17 晚,
sim.js的 3d):
老大「冶炼厂有设置工作时,无人机应该检查对应的材料只要仓库有,就自动搬运给冶炼厂。 冶炼厂完成的东西也自动搬运到仓库」。 · kind:'refill' 补料:站 → 仓库(装货)→ 建筑(卸货),mid = 仓库; · kind:'haul' 回收:站 → 建筑(装货)→ 仓库(卸货),mid = 建筑。 两者都是三段路径(与采集同构,客户端一套插值就能画)。 · 原料/产物按这座建筑当前在用的配方取并集(plan 队列 > 点名 > 自动全配方), 不能用全表 —— 否则会把不相干的货也搬过来。 · 补料目标水位 = 单份需求 × 10;产物攒到「运力」或 3 个(取小)才发车。 · 🔴 有成品积压时禁止补料抢座位:1 台机时补料航班一走 5 秒、flyingOf 一直把座位算占用 ⇒ 回收永远排不上(实测冶炼厂积了 8 个铁锭、仓库一个没收到)。加了这道闸之后自动交替。 · ⚠️ 3d 排在 3c 采集之前:采矿机积压只是缓冲,冶炼厂缺料是直接停产。
- 机制:物流站设供需槽位(
ioSet)→ 服务端派单 → 算好 ETA 到点入账。 - 表现:客户端按大圆插值 + 起降抛物线绘制,零寻路。
- 站点编队:每个物流站按机型明细(
st.fleet)在站周围摆一圈停靠在站里的无人机
(logistics.js 的 setStations() + parkPos(),位置由 build.js.logisticsStations() 给)—— 派单飞走几台就少几台、回港再补回来。这就是"去物流站找无人机"的落点。
- 飞行表现:三段路径插值(站→采矿机→仓库→站,只有第 2 段载货);
载货段在机身下方挂资源图标(InstancedMesh + billboard,按物品分桶、常用货预加载贴图); 四个旋翼是程序化加的(模型部件名无语义,猜不得),每片自转 + 相位错开 —— 飞行与停靠待命时都在转(待命转得慢些)。三种机型各一套 InstancedMesh(材质克隆 + tint), 重型机放大 1.3×、高速机 0.86×。
- 物流站面板(
build.js的droneBlock/planBar):三栏(加工品 | 库存 | 我的背包)——
左栏是三种无人机配方(能造的亮起 + ×N + 计划里的有顶部绿边),中栏上半是无人机编队 (机型分组、每台一格:在站 / 飞出去=灰+半透明、图标仍在 / 空位虚线框)+ 任务列表(搬 铜矿石:采矿机 → 储物仓,每行带机型标签),下半才是物资库存。
- 可见性(2026-10-17 晚大修):老大「整个过程中都没有看到无人机在进行搬运……飞太高了」。
实测根因三条叠在一起:1 巡航高度 26 米 —— 比星门(30.7)还矮,而玩家出生在指挥中心门口, 一栋楼就能把整段航线挡住;2 机身只有 3.5 单位(≈一台机甲),百米外只有 20 来个像素; 3 夜里纯 PBR 材质不受光、几乎全黑。 现在:巡航 34(星门 + 3,高于基地里任何建筑,任何角度都不再被挡)· 机身 8.0 · 材质加自发光 · 每架在飞的无人机脚下有一道穿墙光柱(depthTest:false)+ 顶端航行灯, 远处先看到"那边有一道光",走过去就是真 3D 的无人机 · 速度 45 → 26(一趟约 4 秒,来得及看清)。
- 参数:
logistics-const.js两端共用(单站编制 10、同屏渲染上限 40 架、三种机型数值)。
3.5 建筑的制造计划(所有能生产的建筑)
| 项 | 值 |
|---|---|
| 存档 | building.plan = [{ recipe, count, made }],串行(队首做完才轮下一项) |
| 上限 | ≤ 20 种(TASK_QUEUE_MAX)/ 每种 ≤ 999(TASK_STACK_MAX)—— 与随身制造同一套 |
| 消息 | bldPlan { id, recipe, n }:n>0 追加、n===0 去掉该项、clear:true 清空 |
| 显示 | 面板底部「制造计划」条(baseState.plans)+ 底栏「当前选择」显示计划优先 |
🔴 优先级:b.plan 队首 > b.recipe(点名)> 自动(挑第一条原料够的)。 计划存在时只按计划走,原料不齐就空闲等料 —— 否则"我排了 3 台重型"会变成"造了一堆标配"。
4. 科技树(tech.js)
| 项 | 值 |
|---|---|
| 总节点 | 35 = 生产线 9 + 战场线 26 |
| 起点 | root(基础工程,tier 0,落地即解锁) |
| 存档 | 只记 tech.unlocked: string[],效果运行时现算(改平衡不用迁移存档) |
4.1 生产线(9 个)
t1_mining / t1_metallurgy / t1_power → t2_petro / t2_logistics → t3_assembly / t3_vehicle → t4_orbital
4.2 战场线(26 个)
普通链 t1_analysis → t2_weapon → t3_armor → t4_overload,旁支 t3_bossintel、t3_regen; 外加 4 条 5 级改造线:
| 线 | 效果 | 满级 |
|---|---|---|
wpn1~5 | 伤害 +1 / 级 | +5 |
spd1~5 | 速度 ×1.06 / 级 | |
hull1~5 | 血量 +2 / 级 | +10 |
drn1~5 | 无人机 +1 / 级 | +5 |
4.3 效果汇总(effectsOf)
| 字段 | 叠加方式 |
|---|---|
unlock[] | 解锁建筑 |
mult{gather,craft,power,damage,speed,droneCapacity} | 同类相乘 |
hp / damageAdd / droneAdd / regen | 相加 |
recallMul | 相乘 |
bossIntel | 开关 |
🔴 T0 默认送 bld_command / bld_stargate / bld_storage;root 必须落地即解锁,否则整棵科技树全灰。 老存档由 ensureWorlds 自动补 root。
5. 背包与仓库
5.1 物品(items.js,52 件 / 8 类)
| 分类 | 件数 | 堆叠上限 |
|---|---|---|
material 原材料 | 8 | 999 |
intermediate 中间品 | 7 | 500 |
consumable 消耗品 | 2 | 50 |
ammo 弹药 | 3 | 200 |
equipment 装备 | 6 | 1 |
deployable 可建造物 | 20 | 10 |
tech 科技材料 | 3 | 99 |
drone 无人机 | 3 | 1 |
🔴 tech 类(crystal / alloy / boss_core)刻意不可丢弃,死亡掉落也跳过它们。
🔴 drone 类(drone_hauler / drone_heavy / drone_swift)不进背包、不进仓库: 它们是物流站的机队资产,配方在物流站里跑、产物当场编入 b.fleet。 物品定义只承担两件事:给面板/TIPS 提供图标与文案、当配方的产物 id。 所以它不在 DROPPABLE 里(不可丢弃),也不占背包页签(CAT_LABELS 里单独给中文名)。 拆掉一座物流站时机队按配方材料退回背包(无人机没法单独携带)。
5.2 容量
| 项 | 值 |
|---|---|
| 背包 | 初始 24 格,每次扩容 +8,上限 64 |
| 快捷栏 | 10 格 |
| 装备槽 | 4 个,v1 只开放 weapon / tool(armor / engine 有结构但拒绝装备) |
| 仓库 | 500 格 |
| 操作距离 | 46 世界单位(校验在服务端) |
5.3 背包与仓库是两块独立面板
| 界面 | 打开方式 | 说明 |
|---|---|---|
背包 #bag | Tab 键 | 含页签、Search、装备槽、快捷栏、右键菜单 |
仓库 #depot | 点储物仓建筑(弧长 ≤46) | 只能在仓库面板里开,指挥中心已无此入口 |
三铁律:不广播、客户端不做乐观更新、统一 bagOp 回包。 存取的顺序:先往目的地 add、按实际放进的数量再扣来源(反过来会在背包满时丢东西)。
5.4 随身制造(J 键)
| 项 | 值 |
|---|---|
| 打开 | J 键(#craft 浮层) |
| 配方来源 | 服务端下发的指挥中心清单(27 条) |
| 材料与产物 | 都走背包;身上不够时自动从仓库补(无人机送货,见 §1.y) |
| 可造数量 | 🔴 从原材料按整条链算(老大 2026-10-17 晚):把配方原料递归折算成基础料(矿石/木材/原油),拿「背包 + 仓库」的总量比,中间件库存参与抵扣。旧算法只看手上中间件 —— 背包没铁锭就显示 0,哪怕仓库堆着 300 个铁矿石。实现是二分(线性公式在"存货刚好够 1 个"的边界会算错:12 个铁锭刚好够 1 台机器时除法得 0)。缺料文案也按基础料报("缺铁矿石 32"而不是"缺铁锭 12"——钢板能现造,矿石只能去采)。仓库物料由 bagWire 的 store 字段随每条 bag 消息下发,任何变动都会重算。 |
| 格子三态 | 可造 / 缺料(灰)/ 未解锁(不显示)。缺料仍然可以点 —— 老大 2026-10-17: 「材料不够时,我也可以选择把它加入制造队列」 |
| 未解锁的配方 | 列表里不出现(visibleList() 直接过滤掉 state === 'lock';标题留一句"还有 N 条待研究解锁")。只在制造界面这样——研究所科技树仍要显示锁定项,那是"接下来的目标" |
| 点击效果 | 把整条制造链排进队列(缺的中间件自动补工序),不必自己一道道来 |
| 进度 + 队列 | 常驻(queueHtml(msg, kind, live, {always:true})):空闲时也画出「空闲 + 空轨道 + 队列 0/20」,高度与干活时一致 |
| 连续点击 | 制造回包只原地更新(patchRecipes 改 class / 角标 / title,不重建 DOM)+ 进度区常驻 + 底栏高度锁死 ⇒ 鼠标不动也能一格接一格连点 |
| 缺料提示 | 右上角 HUD 的制造进度那一行下方(#pg-short):只列基础料(矿石/原油),带图标与数量 |
⚠️ J 键不能用 uiOpenKeyGuard()(会导致关不掉)。 ⚠️ 建筑制造才有科技门槛,冶炼与中级材料都没有(这是解死锁的关键,见 §3.3)。
6. 掉落与拾取(loot.js)
| 项 | 值 |
|---|---|
| 掉落来源 | 击毁 NPC / 精英 / Boss;玩家死亡掉背包 50%;主动丢弃 |
| 视觉 | 逐物品的小模型(29 种,loot-models.js) + 光柱;同处多件按黄金角铺圈 |
| 模型做法 | 程序化几何 + 顶点色,mergeGeometries() 合并成单几何;材质全局共享 1 个 ⇒ 每件 1 draw call |
| 光柱颜色 | lootTint(itemId) 按类别给色(远处"看色识物"),不是每件一个材质 |
| 三段动画 | fall(重力 46、反弹 ≤2 次)→ idle(浮动)→ fly(0.5s 收进胸口) |
| 自动拾取 | 每 0.4s 扫一次(≤18 单位、单次最多 12 个 id)发 lootPick |
| 拾取范围 | 服务端 22(按玩家所在世界半径换算) |
| 上限 | 同屏 48 个;服务端 LOOT_LIMIT = 300;TTL 120 秒 |
预览页:public/_loot_preview.html(开发用样品板,一次看全 29 个模型)。
关键机制:
- 掉落物挂在"掉落发生的位置",不是击杀者脚下(远程击杀战利品掉自己脚下、0.4s 被自动捡走,这个坑踩过)。
- 被拒(服务端没回
lootDel)时 1.6s 后退回原位;试满 3 次不再自动捡。 - NPC 掉落流程三段:掉到地上 → 飞进机甲 → 进背包。
- 掉落物带
world字段,跨世界不串(苗圃丢的东西不会画到战场同一坐标上)。 - 物品进背包后,右侧「获得物品」飘字条(
hud.gain())向上飘动、同类合并、1.8s 后淡出(同屏最多 6 条),绝不重叠。
掉落表:
| 目标 | 掉落 |
|---|---|
| 普通 NPC | 废铁 1~2 |
| 精英坦克 | 结晶 1~3 + 废铁 2~4 |
| 世界 Boss | 合金 3~5 + 结晶 5 |
7. 战斗与血量
7.1 纸飞机(弹药)
| 项 | 值 |
|---|---|
| 弹量 | 3 发(科技 drn* 可增),弹药数 = 处于 orbit 状态的飞机数 |
| 补弹 | 2.4s |
| 出击速度 | 780~1700 |
| 状态机 | orbit → climb → strike → spent |
关键设计:
- 指哪打哪:瞄准 = 鼠标指针位置(
input.pointer.ndcX/Y+Raycaster),屏幕中心没有准星;主角回到画面正中。 - 纸飞机已去物理体:自己积分位置 + ttl + 贴地飞行。碰撞只走
physics.pickAlong()(mask 绝不能带 GROUND)与onStrikeSegment()。 - 两处自伤豁免:离主人 34 单位内不检测障碍物;近身不炸自己。
🔴 "怎么瞄都打不中"有三根绳子,缺一根就废(2026-10-15 一次全修):
| # | 绳子 | 症状 | 做法 | ||
|---|---|---|---|---|---|
| 1 | 瞄准要目标优先 | 对着地面上的车/坦克射,弹道却从它头顶飞过 | pickAimTarget(origin, dir):射线 × 目标包围球,命中就把瞄准点定在它身上(AIM_PICK_R = 3.2),而不是射线与地形的交点 | ||
| 2 | 贴地基线必须用 player.R | 人在空中(飞的)时候射地上的东西,弹道整体抬到高空 | 基线用 player.R(球半径),不能用 player.position.length()(飞行时含高度) | ||
| 3 | 目标贴地时每帧钉回 clear 球壳 | 目标贴着地,弹道贴地飞过去却从它身下穿过 | p.grazing 标记(` | p.target.length() - groundR | < 8),每帧把弹道钉回 clear 球壳;该标记随 onLaunch` 广播给远端 |
- 命中半径按体积算(归一化
d²/r²,不是固定值):NPC 车HIT_R + 1.2/ 精英+2.2/ 玩家+1.6/ Boss+8。体积大的目标自然更好打。 - 🔴 跨模块传方向 / 法线 / 点必须 copy 成
Vector3:onStrikeSegment()返回的是普通{x,y,z},
spawnImpact 里 .dot() 会 is not a function ⇒ 击毁 NPC 整局冻住(见 §9 / 系统架构 §6.3)。
7.2 血量
| 项 | 值 |
|---|---|
| 玩家血量 | 10 心(右上角心形图标) |
| 唯一扣血来源 | 被别人的纸飞机打中 |
| 碰撞 | 不掉血,只出火星 + 镜头震动 |
| 自修复 | 血量 ≥2 秒无伤害后按 regen 速率回血(服务端 1Hz,只加不减) |
| 死亡 | 掉背包 50% → 重生遮罩倒计时 60 秒 → 家园星重生 |
可破坏物(树 / 石头)血量按碰撞半径分档(不是缩放值,这样跨物种一致):
| 碰撞半径 | 血量 |
|---|---|
| < 1.6 | 1(小石头一下碎) |
| 1.6 ~ 2.4 | 3 |
| 2.4 ~ 3.6 | 4(大树) |
| ≥ 3.6 | 5(超大石头 / 最大的树) |
血条只在被攻击后出现,HP_BAR_HOLD = 2 秒后自动隐藏;碎掉时 0.22 秒原地塌缩。
7.3 操控手感(两个"跟不上手"的坑)
| 坑 | 症状 | 做法 |
|---|---|---|
| 右键拖拽转向慢半拍 | 按住右键拖视角,车身转向追不上鼠标 | 区分两种转向:右键拖拽 = _snapTo()(无速率上限,1:1 跟手);键盘 / 其它 = _steerTo(dt)(保留 0.65 rad/帧 的限速) |
| 转弯慢一帧 | 松开鼠标后转向还在"补"上一帧的量 | rig.consumeMouse(input) 必须在 rig.getBasis() 之前调用;_mouseTaken 标志防止一次拖拽被消费两次 |
判定用 input.rightDragging(mouse.dragging === true && _dragButton === 2),不要用 mouse.dx/dy(会被 consumeMouse 清零)。
8. NPC 与世界 Boss
8.1 NPC(npc.js)
| 项 | 值 |
|---|---|
| 总数 | 50 台(固定;死一台 8 秒后在别处重生) |
| 精英 | 24 台(eliteCount 配置) |
| 血量 | 普通 3 / 精英 12 |
| 移动 | 全局黄金角均匀铺开;到站后 0.3 概率朝玩家附近移动;只绕路避水避障 |
| 精英攻击 | 警戒 420,每 5 秒一发真弹道炮弹(初速 230、伤害 2) |
| 渲染 | renderDist 1100 距离剔除 + 250 内才进阴影 |
🔴 NPC 固定在世界里,只在被打掉时才换地方,绝不"离玩家太远就回收重部署"(两次被否掉)。 🔴 死亡先进残骸状态、8 秒后别处重生,不能当场回满血换位置。
8.2 世界 Boss「天罗」
| 项 | 值 |
|---|---|
| 唯一性 | 全星球唯一实例 |
| 血量 | 120 |
| 重生 | 击毁后 120 秒,在星球另一侧(角距 ≥π/2)的陆地 |
| 移动 | 巡逻 16 / 追击 20(玩家跑速 52,跑得掉);警戒 600、脱离 900、逼近停距 240 |
| 技能 1 · 气炮 | 左右交替各 5 发(一轮 10 发)、射程 760、伤害 2、间隔 0.3s、两轮间隔 9s |
| 技能 2 · 火榴弹 | 冷却 22s、5 颗、飞行 2.2s、落点围绕锁定目标散布半径 46、爆炸半径 60、伤害 4(AoE) |
| 奖励 | 击杀记录 + 掉落合金 3~5 + 结晶 5(掉在倒地处,多人可捡) |
🔴 火榴弹必须围绕锁定目标的落点散布,不允许围着 Boss 自己随机撒。 🔴 一轮结束只重置本轮冷却(曾经无条件重置,导致技能永远攒不满)。
9. 雷达与星图
9.1 雷达(radar.js,常驻左上角)
| 项 | 值 |
|---|---|
| 底图 | 96×96 canvas,250ms 重烘 + 每帧仿射搬运 |
| 视野 | 扇形固定朝上(±32°) |
| 范围 | 380 |
blip 规则:
| 类型 | 样式 | 省略规则 |
|---|---|---|
| 玩家 | 红点 | 永不省略 |
| NPC | 黄点 | 范围外只留最近 6 |
| 世界 Boss | 紫菱形 | 永不省略,边缘追踪 |
| 精英坦克 | 橙菱形(白描边) | 永不省略,边缘追踪 + 显示距离 |
| 指挥中心 | 青方块 | 永不省略(仅家园星) |
边缘追踪:目标超出 380 时,把标记钉在圆环边缘(按方位角取 (sin,cos),半径 = 雷达半径 − 6), 并带一位小数距离(123m)。🔴 上表"永不省略"的行不能进"只留最近 N 个"的裁剪逻辑 —— 精英坦克曾在范围外被裁掉(&& !it.isElite 缺了一半),老大原话是"精英怪刷新少且缺乏追踪机制"。 精英上限 EDGE_ELITE_MAX = 8(避免满屏菱形)。
🔴 右向量是 heading × normal(写反会左右镜像,这个坑踩过)。
9.2 银河星图(starmap.js)
- 入口:点击星门建筑(不是快捷键)→ 打开
#starmap浮层。 - 内容:canvas 画好的 3 旋臂星系(不旋转,只有闪烁 / 呼吸等不变形动效)+ 30 个星域节点(前 2 个可跃迁)。
- 🔴 不要"顺手加转盘":需求明确要求不旋转。
- 星点与节点必须用同一套公式换算。
9.3 世界往返
| 入口 | 说明 |
|---|---|
| 星门建筑 | 焦土 ← 苗圃 双向(方案 §1.3 的"建筑交互"形态) |
| Y 键 | 快捷往返(保留的全局快捷方式) |
| HUD「回苗圃」按钮 | 只在焦土显示 |
| 星图「去往」 | 与上面共用同一条 goToWorld(id) |
限制:战场 → 家园需脱战 5 秒,且有 30 秒冷却(前端先挡、服务端再校验)。
10. 新手引导(2026-10-15 新增)
老大需求:"创建之后,至少要做 10 步的引导",三条主线 —— 1 基础操作(怎么采集、怎么制造);2 循环发展(去战场拿解锁科技的关键物品 → 解锁科技 → 造更多东西); 3 自身强化(造武器、研究能力)。
10.1 16 步(public/src/guide-steps.js,两端共用)
| # | 步 | 主线 | 要按什么 | 达成条件 |
|---|---|---|---|---|
| 1 | 开动你的机甲 | 基础 | WASD | 走够 50 米(客户端上报 drive) |
| 2 | 砍一棵树 | 基础 | 左键 | 木材 ×3 |
| 3 | 采些石头 | 基础 | 左键 | 石头 ×5 |
| 4 | 放下第一座建筑 | 基础 | 🏗 按钮 | 基地建筑 ≥1 |
| 5 | 随身制造一件东西 | 基础 | J | 制造成功 1 次(客户端上报 craft) |
| 6 | 冶炼出第一块锭 | 基础 | 建筑面板「生产」区 | 铜锭或铁锭 ≥1 |
| 7 | 造一块钢板 | 基础 | J | 钢板 ≥1 |
| 8 | 走星门去焦土 | 循环 | 星门面板 | seenWorlds 含 battle |
| 9 | 打掉一台 NPC 车 | 循环 | F | stats.npcKills ≥ 1 |
| 10 | 抢一块战场结晶 | 循环 | 打精英 | 拿到过结晶(客户端上报 crystal) |
| 11 | 回家园解锁第一项科技 | 循环 | U | tech.unlocked.length ≥ 2(root + 第一项) |
| 12 | 放一台采矿机 | 循环 | 科技解锁后 | 基地有 miner |
| 13 | 给基地通电 | 循环 | — | 基地有 powercore / solar / battery |
| 14 | 建一座物流站 | 循环 | — | 基地有 logistics |
| 15 | 造一把武器强化自己 | 强化 | 装配机 | 拥有并装备一把武器(launcher_mk1/2) |
| 16 | 研究强化自己的能力 | 强化 | U | 解锁 1 项战场线科技(wpn*) |
顺序不是拍脑袋排的:它由 tech.js 的硬约束决定 —— 每项科技成本必含结晶,而初始只解锁 root(不含采矿机)。 ⇒ 玩家必然先"砍树采石 → 冶炼 → 去战场 → 打精英拿结晶 → 回家解锁科技 → 采矿机"。 (玩法方案 §5.3 的时间表把"采矿机"排在战场之前,与 t1_mining 的成本矛盾,这里按代码事实排。)
10.2 Server-authoritative(server/guide.js)
| 项 | 做法 |
|---|---|
| 判定数据 | 全部取权威状态:bagOf(背包)/ homeOf(基地 + 科技)/ statsOf(战绩)/ ws.worldId |
| 推进模型 | 只前进不后退;每次相关动作后跑一遍 evalGuide(),把当前所有已满足的步骤一次标记完成 |
| 老玩家 | 第一次登录就能直接对齐到真实进度(可能一次跳好几步) |
| 存档 | profile.guide = {step, done[], signals[], seenWorlds[], at, finished};读时补默认值(ensureGuide),零迁移 |
| 待补 | 判定失败一律 try/catch 兜住 ⇒ 单条判定写错不会卡住整条链 |
🔴 为什么判定必须在服务端:刷一下页面就能跳过(进度存在服务端),而且改了判定不会两边不一致。
客户端上报的只有 3 个服务端看不到的信号(GUIDE_SIGNAL 白名单,其余 id 一律忽略):
| 信号 | 谁上报 | 为什么服务端看不到 |
|---|---|---|
drive | 客户端 | 移动距离不落库(服务端只收"我在哪",不累计里程) |
craft | 客户端 | "随身制造过"是动作,不是状态(材料可能已经花掉) |
crystal | 服务端(lootPick 回包里出现 crystal 时自己记) | 结晶可能立刻被科技吃掉,背包里查不到 |
🔴 crystal 一开始也设计成客户端上报,但客户端从来没写过发送点 —— 白名单认它、判定用它,全项目却找不到 signal('crystal') ⇒ 第 10 步永远卡死。 (2026-10-15 发布前用 grep 全项目扫出来的;docs/接口与协议.md 里列了 guideSignal 的三种 id, 但接口"存在"不等于"有人在调"。) 现在改成服务端在 lootPick 里自己判:拾取回包真的含结晶就落信号 —— 拾取本身就是权威事件, 比让客户端上报更稳。
10.3 界面与接线
- 面板:
#guide在左上角左列、雷达卡片正下方(老大 2026-10-16:"调整到雷达下面";此前在左下角,与雷达分处两端)。两块由#left-col这层 flex 列绑在一起 ——
雷达卡片高度是动态的(名片头显隐、在线列表长短随人数变),各自 position: fixed 去算 top 必然对不齐。 层级统一由 #left-col 给(22);整列 pointer-events: none,#online 与 #guide 各自收回。 展开步骤表时列高不够,两块一起让位:雷达先压 #online-list,引导的 #guide-list 自己滚动。
- 按三条主线分组显示,当前步高亮、已完成打勾。
- 消息:服务端 →
guide({steps[], done[], step, finished, total, justDone[]});客户端 →guideSignal。 - 推送时机:
hello补发;每次tickGuide(ws)后(背包操作 / 采集 / 采植被 / 拾取 / 击杀 NPC / 切星球 / 建造 / 解锁科技 / 制造)。 - 🔴
guide.update在主循环里无条件每帧跑(曾插进if (buildMode.active)分支 → 只有建造模式才推进,debug.moved永远 0)。 - 🔴
drive的判定不能用input.mouse.dx—— 它已经被rig.consumeMouse()消费清零了;改用粘性的mouse.dragging。
🔴 moved 必须过滤"传送"(TELEPORT_STEP = 10 世界单位/帧): 开屏阶段 player.position 会跳几次(物理体首次步进从原点吸附到地表、落体再落一段)。 实测一个完全没动过的新号,moved 已经攒到 1549,远超 DRIVE_DIST = 50 ⇒ "走够 50 米"这一步等于白送(只要拖过一下视角就自动完成)。 正常位移上限算得出来:runSpeed 52 + climbSpeed 20,dt 上限 0.1s ⇒ 单帧最大约 5.6,所以 10 是安全阈值。
10.4 回归脚本
| 脚本 | 验什么 |
|---|---|
tools/e2e/guide_docs_check.cjs | 面板渲染 16 步 / 当前步高亮 / 无未捕获异常 / 「作品文档」按钮 + /docs/ 三个页面都是 200 |
tools/e2e/guide_drive_check.cjs | 第 1 步 drive 全链路:按住 Shift+W 跑满 50 米 + 拖视角 → 客户端上报 → 服务端推进到 chop、面板变 1/16 |
tools/e2e/guide_drive_probe.cjs | 诊断:打印 moved 的逐帧增量与单帧最大位移(用来确认传送闸门生效) |
⚠️ 三个脚本都要等首屏那一次整页重载(world.js:切世界 = 重载)再断言, 否则会撞 Execution context was destroyed, most likely because of a navigation。 软渲染下真实帧间距约 2 秒、单帧 dt 被钳在 0.1 秒 ⇒ 走路每帧只走 1 单位,跑满 50 米要按分钟算, 所以校验脚本用 Shift 奔跑(每帧 5.2 单位)。
11. 数值总表(速查)
| 类别 | 参数 | 值 |
|---|---|---|
| 主角 | 步行 / 奔跑速度 | 10 / 52 |
| 主角 | 起飞最高高度 | 34(树最高约 26) |
| 主角 | 相机默认距离 | 60 |
| 采集 | 客户端射程 / 服务端校验 | 34 / 46 |
| 建造 | 建造格边长 | 5.15 |
| 建造 | 建筑上限 | 400 |
| 背包 | 初始 / 上限 / 扩容步长 | 24 / 64 / 8 |
| 仓库 | 格数 / 操作距离 | 500 / 46 |
| 制造 | 随身制造配方数 | 27(3 冶炼 + 5 中级材料 + 19 建筑) |
| 生产 | 采矿速率 / 制造速率 | 0.5/s / 1/s |
| 科技 | 节点总数(生产 / 战场) | 35(9 / 26) |
| 引导 | 步骤数(基础 / 循环 / 强化) | 16(7 / 7 / 2) |
| NPC | 总数 / 精英 | 50 / 24 |
| Boss | 血量 / 重生 | 120 / 120s |
| 掉落 | 小模型数 / 拾取范围 / 上限 / TTL | 29 / 22 / 300 / 120s |
| 重生 | 玩家 / 物件 | 60s / 330s |
| 玩家血量 | 满血心数 | 10 |
相关文档
- 分层与权威边界:系统架构
- 协议与接口:接口与协议
- 设计意图与里程碑:玩法方案-Planet Civilization-v1
- 进度对照:未完成清单