sapdon 3.5.3 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,332 @@
1
+ # 运行期 API 参考(`@sapdon/runtime`)
2
+
3
+ `@sapdon/runtime` 是 Sapdon 的**运行期**库:它的代码会被**打包进脚本包**,在 Minecraft Script API 环境(`@minecraft/server`)里执行,用来做「游戏里真正跑」的事 —— 自定义组件注册、动态属性(存档)读写等。
4
+
5
+ ```typescript
6
+ import {
7
+ registerBlockComponent, registerItemComponent,
8
+ pendingComponentCount, registeredComponents,
9
+ saveChunked, loadChunked, clearChunked, CHUNK_SIZE,
10
+ } from '@sapdon/runtime'
11
+ ```
12
+
13
+ > 本页覆盖本轮新增与常用的运行期 API。OC 的 ECS 部分(`ComponentManager` / `MinecraftMain` / `ScriptEvent` 等)属源码开发者文档,见 [doc/dev/oc.md](../../dev/oc.md)。
14
+
15
+ ---
16
+
17
+ ## 0. ★ 分层铁律:构建期用 `@sapdon/core`,运行期用 `@sapdon/runtime`
18
+
19
+ 一句话判断法则:
20
+
21
+ > **写 `main.ts`(生成 JSON)→ `@sapdon/core`;写 `scripts/*`(游戏里跑)→ `@sapdon/runtime`。**
22
+
23
+ 原因(框架侧,已核对源码):
24
+
25
+ | 包 | 层 | 打包行为 | 依据 |
26
+ |---|---|---|---|
27
+ | `@sapdon/core` | **构建期**(`main.ts` 生成 JSON) | 打包时是 **external**,产物里保留 `import '@sapdon/core'` 原样语句 | `src/cli/build.js` 的 `rollupIgnores = ['rollup', 'typescript', '@sapdon/core', '@sapdon/cli', '@minecraft']` |
28
+ | `@sapdon/runtime` | **运行期**(`scripts/*`) | **不在**该列表里 ⇒ 被 rollup **打包进**脚本包,产物里没有这个包名 | 同上 |
29
+
30
+ ⚠️ **后果**:运行期脚本里写 `import '@sapdon/core'`,会让产物 `BP/scripts/index.js` 里留下一个**游戏解析不了的裸包名**(`@sapdon/core`),
31
+ **整个脚本包挂掉** —— 不是"那一行功能失效",而是脚本上下文创建失败、整包脚本不运行。
32
+
33
+ 为什么 Bedrock 解析不了:脚本侧能 import 的模块由游戏提供,且必须在包清单 `dependencies` 里按官方模块名声明;
34
+ Bedrock Wiki 的模块表列出的全部是 `@minecraft/*`(`@minecraft/server`、`@minecraft/server-ui`、`@minecraft/common` …),
35
+ 见 [Bedrock Wiki: API Modules](https://wiki.bedrock.dev/scripting/api-modules)。`@sapdon/core` 不是其中之一。
36
+
37
+ ### 包是怎么落到项目里的
38
+
39
+ 框架仓库的 `prod/` 就是这三个包的源,`sapdon lib`(或 `npm i` 触发的 `postinstall`)把它们拷进项目的 `node_modules/@sapdon/`:
40
+
41
+ | 框架仓库目录 | 项目里的包名 |
42
+ |---|---|
43
+ | `prod/core/` | `@sapdon/core` |
44
+ | `prod/cli/` | `@sapdon/cli` |
45
+ | `prod/oc/` | **`@sapdon/runtime`** |
46
+
47
+ 依据:`src/cli/dev-server/syncFiles.js` 的 `writeLib()`(`fs.cpSync(rootDir/oc → node_modules/@sapdon/runtime)`,
48
+ 并写入 `{ "name": "@sapdon/runtime", "main": "index.js", "types": "index.d.ts" }`)。
49
+
50
+ ---
51
+
52
+ ## 1. 自定义组件注册
53
+
54
+ ### 1.1 `registerBlockComponent` / `registerItemComponent`
55
+
56
+ ```typescript
57
+ import type { BlockCustomComponent, ItemCustomComponent } from '@minecraft/server'
58
+
59
+ function registerBlockComponent(id: string, handlers: BlockCustomComponent): void
60
+ function registerItemComponent(id: string, handlers: ItemCustomComponent): void
61
+ ```
62
+
63
+ | 参数 | 类型 | 说明 |
64
+ |------|------|------|
65
+ | `id` | `string` | 组件标识符,格式 `命名空间:组件名`(如 `fz:machine`)。**必须是脚本与方块/物品 JSON 里同名的那一个** |
66
+ | `handlers` | `BlockCustomComponent` / `ItemCustomComponent` | 事件名 → 处理函数的对象。**handler 就是普通闭包**,可自由 import 共享模块 |
67
+
68
+ **它替你解决的问题是「时机」**:手写样板很容易把注册时机写错,而框架保证在
69
+ `system.beforeEvents.startup` 里替你调用 `init.blockComponentRegistry.registerCustomComponent()` /
70
+ `init.itemComponentRegistry.registerCustomComponent()`。你只管在运行期脚本里声明 id + handler。
71
+
72
+ > ⚠️ **必须在脚本模块加载期调用**(顶层语句,或顶层 `import` 的某个模块的顶层)。不要在事件回调、定时器、`worldLoad` 里调用。
73
+
74
+ **方块侧还要在方块 JSON 里声明同名组件**,否则脚本注册了也没人绑:
75
+
76
+ ```typescript
77
+ // main.ts(构建期)
78
+ block.addComponent(BlockComponent.setCustomComponents(['fz:machine']))
79
+ ```
80
+
81
+ **物品侧**:物品要能触发 `onUse`,还必须在物品上声明 `minecraft:interact_button`(见 [known-pitfalls §2.3](../../dev/known-pitfalls.md))。
82
+
83
+ **已知事件名**(`@minecraft/server` 2.x;名字写错**只 warn,不阻断**)
84
+
85
+ | 组件 | 事件名 |
86
+ |---|---|
87
+ | 方块 | `onBlockStateChange`、`onBreak`、`onEntity`、`onEntityFallOn`、`onPlace`、`onPlayerBreak`、`onPlayerInteract`、`onRandomTick`、`onRedstoneUpdate`、`onStepOff`、`onStepOn`、`onTick`(另保留旧名 `beforeOnPlayerPlace` 以免误报) |
88
+ | 物品 | `onBeforeDurabilityDamage`、`onCompleteUse`、`onConsume`、`onHitEntity`、`onMineBlock`、`onUse`、`onUseOn` |
89
+
90
+ ### 1.2 最小示例:handler 直接 import 共享模块
91
+
92
+ 这正是运行期注册(路线 B)相对构建期声明(路线 A)的价值所在 —— 详见
93
+ [方块 API](./block.md) 的「两条路线的分工(路线 A vs 路线 B)」一节。
94
+
95
+ ```typescript
96
+ // scripts/machine/base.ts —— 共享模块:与 handler 同处一个 bundle
97
+ import type { BlockCustomComponent } from '@minecraft/server'
98
+
99
+ export const machineHandlers: BlockCustomComponent = {
100
+ onPlayerInteract: (e) => {
101
+ // e.block / e.player 由 @minecraft/server 的类型提供
102
+ // withState → 新 permutation,再用 setPermutation 应用到方块(见 Bedrock Wiki: Block States)
103
+ e.block.setPermutation(e.block.permutation.withState('fz:running', true))
104
+ },
105
+ onTick: (e) => {
106
+ // ...20 台机器共用这一份实现,不需要各自复制
107
+ void e.block
108
+ },
109
+ }
110
+ ```
111
+
112
+ > 上面用到的 `BlockPermutation.withState()` + `Block.setPermutation()` 是脚本侧读写方块状态的官方手段,
113
+ > 见 [Bedrock Wiki: Block States](https://wiki.bedrock.dev/blocks/block-states)。
114
+ > 注意状态本身必须在方块 JSON 里**声明过**(`registerState` / `states`),且**单个状态最多 16 个取值** ——
115
+ > 见 [Bedrock Wiki: Block States](https://wiki.bedrock.dev/blocks/block-states) 与
116
+ > [Block Permutations](https://wiki.bedrock.dev/blocks/block-permutations)(单方块最多 65,536 个 permutation)。
117
+
118
+ ```typescript
119
+ // scripts/index.ts —— 脚本入口
120
+ import { registerBlockComponent, registerItemComponent } from '@sapdon/runtime'
121
+ import { machineHandlers } from './machine/base.js' // ← 路线 A 做不到这一点
122
+
123
+ registerBlockComponent('fz:machine', machineHandlers)
124
+
125
+ registerItemComponent('fz:guide_book', {
126
+ onUse: (e) => {
127
+ // e.source: Player、e.itemStack?: ItemStack
128
+ e.source.sendMessage('打开了手册')
129
+ },
130
+ })
131
+ ```
132
+
133
+ > 用 `BlockCustomComponent` / `ItemCustomComponent` 这两个类型给共享 handler 标注,
134
+ > 既能让 `tsc` 检查事件名与参数,也避免了 handler 写在别的模块时因为丢失上下文推断而报 `any`。
135
+
136
+ ### 1.3 诊断:`pendingComponentCount()` / `registeredComponents()`
137
+
138
+ ```typescript
139
+ function pendingComponentCount(): number // 已排队但**尚未注册**的组件数
140
+ function registeredComponents(): string[] // 已成功注册的组件列表,形如 "block:fz:machine"
141
+ ```
142
+
143
+ 用途是自检「排了几个 / 注册了几个」:
144
+
145
+ - **启动前**(脚本加载期)`pendingComponentCount()` > 0 属正常 —— 说明声明已经排上队。
146
+ - **启动后**(`system.beforeEvents.startup` 已触发)`pendingComponentCount()` 应回到 `0`,
147
+ 且 `registeredComponents()` 应包含你声明的每一个 id(`"block:fz:machine"` / `"item:fz:guide_book"`)。
148
+
149
+ ```typescript
150
+ import { system } from '@minecraft/server'
151
+ import { pendingComponentCount, registeredComponents } from '@sapdon/runtime'
152
+
153
+ system.afterEvents.scriptEventReceive.subscribe((e) => {
154
+ if (e.id !== 'fz:components') return
155
+ console.warn(`[diag] 未注册=${pendingComponentCount()} 已注册=${registeredComponents().join(', ')}`)
156
+ })
157
+ ```
158
+
159
+ > 运行期诊断请用 `console.warn`:它稳定落到 `ContentLog*.txt`,而 `world.sendMessage()` **不会**进日志。
160
+
161
+ ### 1.4 错误与守卫(读源码得出的三类行为)
162
+
163
+ | 情况 | 行为 |
164
+ |---|---|
165
+ | `system.beforeEvents.startup` **已经触发过**之后才调用 | ❌ **抛错** —— `自定义组件 "x" 注册得太晚:system.beforeEvents.startup 已经触发过了`(宁可炸也不静默) |
166
+ | 同一个 id 用同一类注册表**重复注册** | ❌ **抛错** —— `自定义组件 "x" 被重复注册(同一个 id 只能用一条路线注册一次)` |
167
+ | 事件名**拼错**(不在已知列表里) | ⚠️ 只 `console.warn`,不阻断 —— 引擎以后新增事件名时不该被框架挡住 |
168
+ | `id` 不是非空字符串 / `handlers` 不是对象 / `handlers` 是空对象 / 某个 handler 不是函数 | ❌ **抛错** |
169
+ | 当前的 `@minecraft/server` 没有 `system.beforeEvents.startup`(1.x) | ❌ 首次调用时**抛错**,提示升级到 2.x 或改用路线 A |
170
+
171
+ > ⚠️ **同一个组件 id 不要同时用两条路线注册**(路线 A:`BlockCustomComponentBuilder`;
172
+ > 路线 B:本模块)。重复注册会抛错,这是刻意设计。
173
+
174
+ > ⚠️ **游戏内行为未验证**:本轮只在桩环境里验证了注册时机与注册表(本环境无法启动 Minecraft)。
175
+ > 真实触发效果请进游戏确认,并把结论回写到 [doc/dev/known-pitfalls.md](../../dev/known-pitfalls.md) 的待确认清单。
176
+
177
+ ---
178
+
179
+ ## 2. 分块持久化
180
+
181
+ ### 2.1 为什么需要
182
+
183
+ 动态属性(dynamic property)是脚本侧最常用的存档手段(`world.setDynamicProperty` / `getDynamicProperty`,
184
+ 见 [Bedrock Wiki: Script Core Features](https://wiki.bedrock.dev/scripting/script-server) 的 *Saving and Loading data* 一节)。
185
+ 但**单个动态属性值有长度上限**:框架侧记录为**约 32KB 量级**,超限时 `setDynamicProperty` **抛错**。
186
+
187
+ > ⚠️ **「约 32KB」这个具体数字「未验证」**:我在 Bedrock Wiki 上没有核对到这个上限(没有可引用的 wiki 依据),
188
+ > 该数字来自本项目自己的源码注释 / 踩坑记录(`digitCircuit` 事故、`lr-framework` 的 `BaseEngine`)。
189
+ > **真正要记住的不是数字,而是"超限会抛错"这件事**:具体阈值请在真机确认。
190
+
191
+ 一旦项目侧把这段包进 `try { ... } catch {}`,就变成**静默丢存档**:写入失败被吞掉、内存里数据是新的、磁盘上一直是早期小快照。
192
+ **症状是「重进世界后数据回到早期状态」**(`digitCircuit` 的电路/chip 绑定消失就是这个原因)。
193
+
194
+ 所以框架把「分块 + 清理残留 + **不吞异常**」固化成接口。
195
+
196
+ ### 2.2 API
197
+
198
+ ```typescript
199
+ import {
200
+ CHUNK_SIZE, MAX_CHUNK_SCAN, CHUNK_SUFFIX,
201
+ saveChunked, loadChunked, clearChunked,
202
+ chunkKey, chunkMetaJson, parseChunkCount, looksLikeChunkMeta, splitValue,
203
+ } from '@sapdon/runtime'
204
+ import type { DynamicPropertyTarget, DynamicPropertyValue } from '@sapdon/runtime'
205
+
206
+ const CHUNK_SIZE = 24000 // 单个数据块的字符数上限
207
+ const CHUNK_SUFFIX = '#' // 分块键后缀:`<key>#<index>`
208
+ const MAX_CHUNK_SCAN = 256 // 无 getDynamicPropertyIds() 时的兜底扫描上界
209
+
210
+ function saveChunked(target: DynamicPropertyTarget, key: string, value: string): void
211
+ function loadChunked(target: DynamicPropertyTarget, key: string): string | undefined
212
+ function clearChunked(target: DynamicPropertyTarget, key: string): void
213
+ ```
214
+
215
+ | API | 说明 |
216
+ |---|---|
217
+ | `saveChunked(target, key, value)` | 写入字符串。`value` **必须是字符串**(不是字符串会抛错,请自己 `JSON.stringify` 后传入)。幂等:同一份数据连存两次结果一致 |
218
+ | `loadChunked(target, key)` | 读回字符串。**空值语义见 2.4** |
219
+ | `clearChunked(target, key)` | 彻底清除:主 key **加所有**数据块(含旧存档残留的更高序号块)。清完 `loadChunked` 返回 `undefined` |
220
+ | `CHUNK_SIZE` | `24000`。单值不超过它时**直接存原文**,不切块 |
221
+
222
+ 其余导出(`chunkKey` / `chunkMetaJson` / `parseChunkCount` / `looksLikeChunkMeta` / `splitValue`)
223
+ 是底层纯函数,给单测与「要和别的实现手工对齐」的场景用;日常只需用上面三个。
224
+
225
+ ### 2.3 存储格式(与既有实现互通)
226
+
227
+ - **小数据**(`value.length <= CHUNK_SIZE`):主 key 直接存**原字符串**。
228
+ - **大数据**:数据块 `"<key>#0" … "<key>#N-1"`,主 key 存分块元数据 JSON `{"_chunks":N}`。
229
+ - **提交点**:**数据块先写、主 key 后写**。中途失败时主 key 仍指向上一份完整数据,不会留下"半新半旧"的可读结果。
230
+ - **残留清理**:新数据块数变少时,会删掉 `index >= N` 的旧块(有 `getDynamicPropertyIds()` 时精确删,否则从 `N` 扫到 `MAX_CHUNK_SCAN`)。
231
+ - **元数据歧义规避**:内容**恰好是** `{"_chunks":N}` 形态的原文会被**强制分块**,否则读回时会被误判成元数据。
232
+
233
+ **与既有实现互通**:这套格式与 `examples/lr-framework` 的 `BaseEngine.save/load`、
234
+ `examples/digitCircuit` 的 `CIRCUIT_CHUNK = 24000` 方案**完全一致**,可以互相读取。
235
+
236
+ > ⚠️ 与 `BaseEngine` 的差别:那个实现的 `load` 有"用真值判断空值"的坑(把空串当没存过),本模块没有(见下)。
237
+
238
+ ### 2.4 ★ 空值语义
239
+
240
+ ```typescript
241
+ const v = loadChunked(world, 'fz:data')
242
+
243
+ if (v === undefined) { /* 从没存过 —— 首次运行,走初始化 */ }
244
+ else { /* 存过(可能是 '') */ }
245
+ ```
246
+
247
+ | 返回值 | 含义 |
248
+ |---|---|
249
+ | `undefined` | **从没存过** |
250
+ | `''` | **存过空串** |
251
+ | 其它字符串 | 存过的内容 |
252
+
253
+ ⚠️ **判断必须用 `=== undefined`,不要用真值判断**:`if (!v)` 会把 `''` 当成"没存过",
254
+ 于是每次启动都跑一遍初始化 —— 这正是 `BaseEngine.load` 踩过的坑。
255
+
256
+ ### 2.5 异常约定:本模块**不吞任何异常**
257
+
258
+ | 情况 | 行为 |
259
+ |---|---|
260
+ | `value` 不是字符串 / `key` 为空 | **抛错** |
261
+ | 底层 `setDynamicProperty` 抛错(例如超限) | **原样抛出** |
262
+ | 主 key 声明 N 块但某块缺失 / 不是字符串 | **抛错**(`分块存档损坏 —— 主 key "x" 声明 N 块,但 "x#i" 是 缺失`),**不返回半截数据** |
263
+ | 主 key 被非字符串值占用 | **抛错**(本接口只读写字符串) |
264
+
265
+ 调用方若确实要容错,**自己 catch 并至少打日志**:
266
+
267
+ ```typescript
268
+ try {
269
+ saveChunked(world, 'fz:data', JSON.stringify(model))
270
+ } catch (err) {
271
+ console.warn(`[fz] 存档写入失败:${err}`) // ← 至少留痕,绝不静默
272
+ }
273
+ ```
274
+
275
+ ### 2.6 `target` 参数
276
+
277
+ 只要是满足下面这个最小接口的对象即可 —— **`world` / `Entity` / `ItemStack` 都满足**(已在 `@minecraft/server` 2.8.0 的
278
+ `index.d.ts` 里逐个核对:三者都有 `getDynamicProperty` / `setDynamicProperty` / `getDynamicPropertyIds`):
279
+
280
+ ```typescript
281
+ type DynamicPropertyValue = boolean | number | string | Vector3
282
+
283
+ interface DynamicPropertyTarget {
284
+ getDynamicProperty(identifier: string): DynamicPropertyValue | undefined
285
+ setDynamicProperty(identifier: string, value?: DynamicPropertyValue): void
286
+ getDynamicPropertyIds?(): string[] // 可选:有它就能精确清理残留分块
287
+ }
288
+ ```
289
+
290
+ 因为签名是结构化的,**单测里可以用内存对象顶替**,不需要 `@minecraft/server`。
291
+
292
+ ### 2.7 完整示例
293
+
294
+ ```typescript
295
+ // scripts/store.ts —— 运行期脚本
296
+ import { world } from '@minecraft/server'
297
+ import { saveChunked, loadChunked, clearChunked } from '@sapdon/runtime'
298
+
299
+ const KEY = 'fz:machines'
300
+ let machines: Record<string, unknown> = {}
301
+
302
+ export function loadMachines(): void {
303
+ const raw = loadChunked(world, KEY)
304
+ if (raw === undefined) { // ★ 用 === undefined,不要用 if (!raw)
305
+ machines = {} // 从没存过 → 首次运行
306
+ return
307
+ }
308
+ machines = JSON.parse(raw) as Record<string, unknown>
309
+ }
310
+
311
+ export function saveMachines(): void {
312
+ saveChunked(world, KEY, JSON.stringify(machines)) // 超限会抛错,别吞
313
+ }
314
+
315
+ export function resetMachines(): void {
316
+ clearChunked(world, KEY) // 主 key + 所有数据块一起清
317
+ machines = {}
318
+ }
319
+
320
+ // 实体 / 物品上的动态属性同样可用:
321
+ // saveChunked(entity, 'fz:state', JSON.stringify(state))
322
+ // saveChunked(itemStack, 'fz:charge', String(charge))
323
+ ```
324
+
325
+ ---
326
+
327
+ ## 相关文档
328
+
329
+ - [doc/dev/oc.md](../../dev/oc.md) —— OC 运行时(ECS)整体结构,源码开发者
330
+ - [doc/dev/known-pitfalls.md](../../dev/known-pitfalls.md) —— 已知坑清单:§2 自定义组件、§3 持久化、§4 方块容器
331
+ - [方块 API](./block.md) —— `BlockComponent`(含 `setBlockEntity` / `setInventory`)
332
+ - [扩展模块 API](./extra.md) —— `ClientEntityApperance` / `BaseVehicle`
package/doc/user/faq.md CHANGED
@@ -31,15 +31,83 @@ npm update sapdon
31
31
 
32
32
  构建时框架会自动将依赖注入到行为包的 `manifest.json` 中。
33
33
 
34
+ ⚠️ 但 `manifest.json` **只在文件不存在时才生成**(为了保留 uuid)—— 改完 `dependencies` 后必须删掉 `dev/<项目名>_BP/manifest.json` 才会重新生成,详见第 4 条。
35
+
34
36
  ---
35
37
 
36
38
  ## 3. 构建输出在哪?
37
39
 
38
- 执行 `sapdon build <项目名>` 后,构建产物会输出到项目根目录的 `dev/` 文件夹中。该目录包含完整的资源包和行为包结构,可直接用于开发测试。
40
+ 执行 `sapdon build <项目名>`(或 `sapdon compile`)后,构建产物输出到项目根目录的 `dev/` 文件夹中:
41
+
42
+ ```
43
+ dev/
44
+ ├── <项目名>_BP/ # 行为包(Behavior Pack)—— 大写 BP
45
+ │ ├── manifest.json
46
+ │ ├── pack_icon.png
47
+ │ ├── blocks/*.json # 方块(行为侧)
48
+ │ ├── entities/*.json
49
+ │ ├── items/*.json
50
+ │ ├── recipes/*.json
51
+ │ └── scripts/index.js # 打包后的脚本
52
+ ├── <项目名>_RP/ # 资源包(Resource Pack)—— 大写 RP
53
+ │ ├── manifest.json
54
+ │ ├── pack_icon.png
55
+ │ ├── blocks.json # ← 方块音效表(属资源包,不是行为包!自 2026-09 起只含 format_version)
56
+ │ ├── entity/*.json
57
+ │ ├── textures/item_texture.json
58
+ │ ├── textures/terrain_texture.json
59
+ │ └── textures/blocks|items/*.png
60
+ ├── .sapdon_generated_<项目名>.json # 框架的产物清单(请勿手工编辑)
61
+ ├── .sapdon_synced_<项目名>.json # 部署清单:上次同步到游戏开发包的文件(请勿手工编辑)
62
+ └── .sapdon_res_<项目名>.json # 资源清单:上次从 res/ 拷进 RP 的文件(请勿手工编辑)
63
+ ```
64
+
65
+ > 三份清单都只用来**删除框架自己上次写过的、这次不再生成的文件**(从项目里删掉的方块/配方/资源
66
+ > 会真正从游戏的开发包里消失,不再残留报 `not present in the Schema`)。它们**从不扫目录删未知文件**,
67
+ > 所以你自己放进 `dev/` 或游戏开发包里的文件不会被碰。
68
+
69
+ ⚠️ **`_BP` / `_RP` 一定是大写**。历史版本曾用小写 `_bp`/`_rp`,在 Windows 上因为大小写不敏感看不出问题,但在 Linux/macOS 下会**分叉成两个目录**(构建写一个、打包读另一个 → 打出空包)。
70
+
71
+ ### 改过名 / 删过方块后 `dev/` 里可能留有旧文件
72
+
73
+ 框架用**产物清单**(`dev/.sapdon_generated_<项目名>.json`)记录每次构建写出的文件,下次构建时只删除「上次有、这次没有」的产物 —— 所以**改方块名、删掉某个方块之后,对应的旧 JSON 会被自动清理**。
74
+
75
+ 但有两种情况清不掉,需要你**手工删除**:
76
+
77
+ | 情况 | 原因 |
78
+ |------|------|
79
+ | **重命名了项目** | 新项目名的清单管不到旧名字的 `dev/<旧名>_BP/`、`dev/<旧名>_RP/` 目录 → 旧目录整个残留 |
80
+ | **该项目从未成功构建过** | 没有清单文件,框架不知道哪些是它生成的,因此不会删任何东西 |
81
+
82
+ 这也是框架**不会**采用「扫描 `dev/` 删除所有未知文件」策略的原因:那会把你从 `res/` 拷进来、以及手写的文件一起误删。
83
+
84
+ ---
85
+
86
+ ## 4. 构建显示成功但产物还是旧的?
87
+
88
+ **框架现在会因失败而非 0 退出** —— 但「构建成功」这句话本身依然不可信,请用下面两个判据确认。
89
+
90
+ ### 成功 / 失败的判据
91
+
92
+ | 判据 | 说明 |
93
+ |------|------|
94
+ | ✅ **退出码为 0** | 构建脚本子进程非 0 退出、脚本打包失败,都会让 CLI 以 `exit 1` 结束 |
95
+ | ✅ **日志里出现 `处理数据: <name> <root> <path>`** | 这是「产物真的生成了」的**唯一判据** —— 每个写出的注册项都会打一行 |
96
+
97
+ 历史行为是:构建脚本失败被**静默吞掉**,CLI 照样打印「构建完成」并 `exit 0`,于是 `dev/` 里留下的是**上一次的旧产物**。这是本框架真实踩过的坑,所以现在失败一律非 0 退出。
98
+
99
+ ### 常见误解:只跑 `sapdon compile` 并不会重新生成所有东西
100
+
101
+ - **`manifest.json` 只在文件不存在时才生成**(目的是保留 uuid)。
102
+ ⇒ 改了 `build.config` 里的 `dependencies`(例如把 `@minecraft/server` 升到 `2.6.0`)后,旧 manifest 会被**保留**、新依赖**不会**写进去。
103
+ **解决**:删掉 `dev/<项目名>_BP/manifest.json` 后重新 `sapdon compile`。删它不影响 uuid —— **uuid 存在项目的 `mod.info` 里**。
104
+ - **`res.hint.ts` 由 `initResourceDir()` 生成,而它只在 `sapdon build` 与 `sapdon res` 命令里被调用**,`sapdon compile` **不调用**。
105
+ ⇒ 在 `res/` 里加了资源后只跑 `compile`,`res.hint.ts` 不会刷新 —— 需要单独跑 `sapdon res`。
106
+ - 改过方块名 / 删过方块时,`dev/` 里的旧 JSON 由**产物清单**自动清理;但**重命名项目**或**从未成功构建过**的情况清不掉,需手工删除(见第 3 条)。
39
107
 
40
108
  ---
41
109
 
42
- ## 4. 如何同步到 Minecraft?
110
+ ## 5. 如何同步到 Minecraft?
43
111
 
44
112
  框架支持自动同步功能。构建完成后,产物会自动复制到 Minecraft 开发包目录(`com.mojang` 开发包文件夹)。前提是已正确配置开发包路径。
45
113
 
@@ -47,7 +115,7 @@ npm update sapdon
47
115
 
48
116
  ---
49
117
 
50
- ## 5. 如何切换 release/beta 版本?
118
+ ## 6. 如何切换 release/beta 版本?
51
119
 
52
120
  在 `build.config` 中设置 `versionType` 字段:
53
121
 
@@ -64,11 +132,13 @@ npm update sapdon
64
132
  | `"release"` | 正式版 |
65
133
  | `"beta"` | beta 测试版 |
66
134
 
67
- 切换后会影响生成的 `manifest.json` 中的 `header.name` 后缀和 `min_engine_version` 等配置。
135
+ 切换后**只影响构建产物的同步目标路径**(release beta `com.mojang` 位置不同)。
136
+
137
+ ⚠️ 它**不会**改变 `manifest.json` 里的 `header.name`、`min_engine_version` 或依赖版本 —— 那些来自 `mod.info` 与 `build.config` 的 `dependencies`。
68
138
 
69
139
  ---
70
140
 
71
- ## 6. 如何覆盖 Minecraft 路径?
141
+ ## 7. 如何覆盖 Minecraft 路径?
72
142
 
73
143
  通过设置环境变量来指定 Minecraft 开发包目录:
74
144
 
@@ -87,19 +157,29 @@ $env:MC_PATH = "C:\Users\<用户名>\AppData\Local\Packages\Microsoft.MinecraftU
87
157
 
88
158
  ---
89
159
 
90
- ## 7. 构建报错 "tsc-alias not found"?
160
+ ## 8. 构建报错 "tsc-alias not found"?
161
+
162
+ **先确认是哪个构建在报错**:
163
+
164
+ - **`tsc-alias` 是框架仓库自身的 devDependency**(`sapdon/package.json`),只在**框架自身**的构建里被调用(`scripts/build.cjs` 执行 `npx tsc-alias`)。
165
+ - **用户项目不会用到它**:`sapdon compile` / `sapdon build` 走的是 rollup + TypeScript 插件,不调用 `tsc-alias`;项目模板的 `package.json`(`src/templates/*/package.json`)也**没有**这个依赖。
166
+
167
+ 所以:
91
168
 
92
- 确保已在项目目录安装依赖(sapdon 作为本地 devDependency,`npm install` 会一并安装,无需全局安装):
169
+ | 出现场合 | 处理 |
170
+ |---------|------|
171
+ | 在 **sapdon 框架仓库**里跑 `npm run build` 时报这个错 | 在框架仓库目录执行 `npm install`(`node_modules` 不完整) |
172
+ | 在**你自己的 addon 项目**里看到这个错 | 说明你的项目自己配置了 `tsc-alias`(例如自建构建脚本)。这是你项目侧的依赖问题,`npm install` 安装你项目的 devDependencies |
93
173
 
94
174
  ```bash
95
- npm install
175
+ npm install # 在报错的那个仓库/项目目录下执行
96
176
  ```
97
177
 
98
- 此错误通常是因为依赖未正确安装或 `node_modules` 目录不完整导致的。
178
+ 此错误通常是**依赖未正确安装或 `node_modules` 目录不完整**造成的。
99
179
 
100
180
  ---
101
181
 
102
- ## 8. 为什么实体/物品 JSON 没有生成?
182
+ ## 9. 为什么实体/物品 JSON 没有生成?
103
183
 
104
184
  常见原因:
105
185
 
@@ -115,7 +195,7 @@ npm install
115
195
 
116
196
  ---
117
197
 
118
- ## 9. 热更新不生效?
198
+ ## 10. 热更新不生效?
119
199
 
120
200
  检查 `build.config` 中是否启用了热更新:
121
201
 
@@ -128,12 +208,14 @@ npm install
128
208
  确保 `useHMR` 设置为 `true`。如果已启用但仍不生效,请检查:
129
209
 
130
210
  - Minecraft 是否正在运行并加载了开发包
131
- - 网络连接是否正常(HMR 通过 WebSocket 通信)
132
- - 修改的文件是否在框架的监听范围内
211
+ - **HMR CLI 进程内的文件监听(`fs.watch`)触发重建,不走网络** —— 框架里**没有** WebSocket / 端口通信参与 HMR;它只负责重新构建并把产物拷到 Minecraft 开发包目录,**游戏内是否重新加载属游戏行为,未验证**
212
+ - 命令是否带 `keepServer: true`:默认构建完成后服务器会自动退出,服务器退出后**就不会再监听文件变更**了
213
+ - 是否用 `sapdon compile` 而非 `sapdon build`:`compile` 只构建一次,**不启动** HMR 监听
214
+ - 修改的文件是否在框架的监听范围内(`.js`、`.ts`、`build.config`、`mod.info`;排除点文件、`dev/` 构建目录、`.tmp`)
133
215
 
134
216
  ---
135
217
 
136
- ## 10. 如何手动运行 sapdon lib?
218
+ ## 11. 如何手动运行 sapdon lib?
137
219
 
138
220
  在项目目录下直接执行:
139
221
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sapdon",
3
- "version": "3.5.3",
3
+ "version": "3.6.0",
4
4
  "scripts": {
5
5
  "build": "node scripts/build.cjs",
6
6
  "test": "tsc && tsc-alias && node --test \"tests/*.test.mjs\"",
package/prod/cli/index.js CHANGED
@@ -1 +1 @@
1
- import e from"http";Symbol.metadata||(Symbol.metadata=Symbol("[[metadata]]"));const r=Symbol("isRawJSON");const t=["boolean","number"],n=["string","undefined"];function o(e,o){const s=typeof o;if(null===s)return null;if(n.includes(s))return o;if(t.includes(s))return JSON.rawJSON(o);if("object"===s)return JSON.isRawJSON(o)?o:function(e){return!0===e?.[r]}(o)?JSON.rawJSON(o.rawJSON):o;if("bigint"===s)return JSON.rawJSON(o.toString());throw new Error("Unexpected value")}const s={encode:e=>JSON.stringify(e,o),decode:JSON.parse};function i(e,r=s){return r.encode(e)}const a={port:49037},{port:c}=a;const{port:l}=a;const d=new class{cliServerHandlers=new Map;listening=!1;isListening(){return this.listening}bootstrap(){this.listening=!0;const r=e.createServer(async(e,r)=>{const t=this.cliServerHandlers.get((e.url??"/").slice(1));if(t){try{const{promise:r,resolve:n,reject:o}=Promise.withResolvers();let i=Buffer.alloc(0);e.on("data",e=>i=Buffer.concat([i,e])),e.on("end",()=>{try{n(function(e,r=s){return r.decode(e)}(i))}catch(e){o(e)}}),await t(...await r)}catch(e){return console.error(e),r.writeHead(500),void r.end()}r.writeHead(200),r.end()}else r.writeHead(404),r.end()}).listen(l,()=>console.log(`Dev Server listening on port ${l}`));return r.on("error",e=>{throw this.listening=!1,function(e){return"object"==typeof e&&null!==e&&"EADDRINUSE"===e.code}(e)&&(console.error(`[sapdon] Dev Server 端口 ${l} 已被其他 sapdon 进程占用。`),console.error("[sapdon] 请先结束残留的 sapdon 进程,再重新构建,否则本次构建的数据可能被写入错误的包目录。"),process.exit(1)),e}),r}handle(e,r){this.cliServerHandlers.set(e,r)}getHandler(e){return this.cliServerHandlers.get(e)}interceptHandler(e,r){const t=r(this.getHandler(e)??Function.prototype);return this.cliServerHandlers.set(e,t),t}},u={call:async(e,...r)=>await async function(e,...r){try{await fetch(`http://localhost:${c}/${e}`,{method:"POST",headers:{"Content-Type":"application/json"},body:i(r)})}catch(e){console.error(e),console.error("尝试在构建脚本中使用 server.startDevServer() 启动开发服务器")}}(e,...r)};export{u as client,d as devServer};
1
+ import e from"http";Symbol.metadata||(Symbol.metadata=Symbol("[[metadata]]"));const r=Symbol("isRawJSON");const t=["boolean","number"],n=["string","undefined"];function o(e,o){const s=typeof o;if(null===s)return null;if(n.includes(s))return o;if(t.includes(s))return JSON.rawJSON(o);if("object"===s)return JSON.isRawJSON(o)?o:function(e){return!0===e?.[r]}(o)?JSON.rawJSON(o.rawJSON):o;if("bigint"===s)return JSON.rawJSON(o.toString());throw new Error("Unexpected value")}const s={encode:e=>JSON.stringify(e,o),decode:JSON.parse};function i(e,r=s){return r.encode(e)}const a={port:function(){const e=process.env.SAPDON_DEV_SERVER_PORT,r=e?parseInt(e,10):NaN;return Number.isInteger(r)&&r>0?r:49037}()},{port:c}=a;const{port:l}=a;const d=new class{cliServerHandlers=new Map;listening=!1;isListening(){return this.listening}bootstrap(){this.listening=!0;const r=e.createServer(async(e,r)=>{const t=this.cliServerHandlers.get((e.url??"/").slice(1));if(t){try{const{promise:r,resolve:n,reject:o}=Promise.withResolvers();let i=Buffer.alloc(0);e.on("data",e=>i=Buffer.concat([i,e])),e.on("end",()=>{try{n(function(e,r=s){return r.decode(e)}(i))}catch(e){o(e)}}),await t(...await r)}catch(e){return console.error(e),r.writeHead(500),void r.end()}r.writeHead(200),r.end()}else r.writeHead(404),r.end()}).listen(l,()=>console.log(`Dev Server listening on port ${l}`));return r.on("error",e=>{throw this.listening=!1,function(e){return"object"==typeof e&&null!==e&&"EADDRINUSE"===e.code}(e)&&(console.error(`[sapdon] Dev Server 端口 ${l} 已被其他 sapdon 进程占用。`),console.error("[sapdon] 请先结束残留的 sapdon 进程,再重新构建,否则本次构建的数据可能被写入错误的包目录。"),process.exit(1)),e}),r}handle(e,r){this.cliServerHandlers.set(e,r)}getHandler(e){return this.cliServerHandlers.get(e)}interceptHandler(e,r){const t=r(this.getHandler(e)??Function.prototype);return this.cliServerHandlers.set(e,t),t}},u={call:async(e,...r)=>await async function(e,...r){try{await fetch(`http://localhost:${c}/${e}`,{method:"POST",headers:{"Content-Type":"application/json"},body:i(r)})}catch(e){console.error(e),console.error("尝试在构建脚本中使用 server.startDevServer() 启动开发服务器")}}(e,...r)};export{u as client,d as devServer};