mirror of
https://github.com/motajs/template.git
synced 2026-10-16 18:40:17 +08:00
refactor: 插件系统 & 数据端顶层移植
This commit is contained in:
parent
b0229c627f
commit
2f3d3387a0
176
.planning/phases/09-minimum-browser-runnable/09-20-SUMMARY.md
Normal file
176
.planning/phases/09-minimum-browser-runnable/09-20-SUMMARY.md
Normal file
@ -0,0 +1,176 @@
|
||||
---
|
||||
phase: 09-minimum-browser-runnable
|
||||
plan: 20
|
||||
subsystem: data-state + client-modules (engine-side plugin system)
|
||||
tags: [plugin-system, dependency-topology, cycle-detection, logger-codes, runtime-guard]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 09-minimum-browser-runnable
|
||||
provides: "用户 WIP 新增的数据端插件类型(PluginStatus / IDataPlugin / IDataPluginDefine / ICoreState.getDataPlugin / ICoreState.load / ICoreStateConfig.plugins)与 core.ts 空壳"
|
||||
provides:
|
||||
- "packages-user/data-state/src/plugin.ts:orderPlugins(依赖拓扑排序 + 环检测 + 依赖不存在检测)+ IPluginDepends / IPluginHolder / IPluginOrderResult"
|
||||
- "CoreState 插件驱动:plugins 注册表(config.plugins ?? [] 守卫 + 重复 id 报 78 且不覆盖)+ 四段式 awake/initialize/afterLoad(protected)+ load() 编排 + getDataPlugin"
|
||||
- "渲染端镜像:IClientPlugin / IClientPluginDefine / clientPlugins / getClientPlugin + ClientCore 对齐实现(orderPlugins 自 @user/data-state 复用)"
|
||||
- "logger.json error 段新增 76(循环依赖)/ 77(依赖不存在)/ 78(重复 id)"
|
||||
affects: [09-21, 入口/配置接线(用户后续)]
|
||||
|
||||
# Actuals (#2632) — pairs with the plan's estimate to calibrate future estimates.
|
||||
# Same estimateTokens scale (chars/4 over the realized diff), never a harness token count.
|
||||
actuals:
|
||||
tokens: 6274
|
||||
tasks: 2
|
||||
commits: 0
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "共享纯函数 orderPlugins 作为数据端与渲染端唯一的拓扑/环/缺失检测实现"
|
||||
- "依赖拓扑序驱动的四段式插件生命周期(awake 同步 → initialize 异步阻塞 → loader 加载 → afterLoad 异步阻塞)"
|
||||
- "构造器注册表以 ?? [] 运行时守卫建立,兼容未接线插件配置的既有调用点"
|
||||
- "插件错误码集中:环 → 76 / 依赖不存在 → 77 / 重复 id → 78(且不覆盖)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- packages-user/data-state/src/plugin.ts
|
||||
- packages-user/data-state/src/__test__/plugin.test.ts
|
||||
modified:
|
||||
- packages-user/data-state/src/core.ts
|
||||
- packages-user/data-state/src/index.ts
|
||||
- packages-user/client-modules/src/types.ts
|
||||
- packages-user/client-modules/src/client.ts
|
||||
- packages/common/src/logger.json
|
||||
|
||||
key-decisions:
|
||||
- "共享纯函数 orderPlugins 计算可初始化拓扑序、环上插件(cyclic)与直接缺失依赖(missing);被排除集合 = 环上 ∪ 缺失 ∪ 其下游;下游仅缺席于 order,不单独报码"
|
||||
- "插件错误处置(D-92):循环依赖 → logger.error(76, ...);依赖不存在 → 逐条 logger.error(77, pluginId, missingDepId);重复 id → logger.error(78, id) 且保留先注册者不覆盖;报错插件及下游全部不进入加载流程(status 保持 NotAwakened)"
|
||||
- "运行时守卫(D-91/D-56):CoreState 用 config.plugins ?? []、ClientCore 用 config.clientPlugins ?? [],未接线插件配置的既有调用点不改动且不崩溃"
|
||||
- "渲染端复用数据端 PluginStatus(自 @user/data-state 导入,未自建);orderPlugins 亦自 @user/data-state 导入"
|
||||
- "CoreState.awake/initialize/afterLoad 由 private 改 protected,供 ClientCore 以 super.<stage>() 先驱动数据端、再驱动渲染端(数据端先、渲染端后)"
|
||||
- "渲染插件 state 参数类型默认 ICClientCore(OI-5)"
|
||||
|
||||
patterns-established:
|
||||
- "插件排序: 单文件零 import 的纯函数,数据端与渲染端复用"
|
||||
- "生命周期: 单一公开入口 load() 编排四段,子类覆写 protected 阶段实现同阶段横向插入"
|
||||
|
||||
requirements-completed: []
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "数据端插件驱动:CoreState 注册表(守卫 + 重复 id 不覆盖)+ 四段式拓扑序驱动 + 状态推进 + 环/依赖不存在(含下游)排除"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "structural gate (data plugin) — orderPlugins/config.plugins ?? []/this.plugins.has/logger.error(76,77,78)/protected phases/loader.start() — PASS"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "运行期 vitest 门禁(plugin.test.ts)因用户并发 WIP 删除 ./enemy 与 ./event 目录而无法解析 ../core,属已知阻断;需用户接线后运行期复核"
|
||||
- id: D2
|
||||
description: "渲染端镜像:IClientPlugin / IClientPluginDefine / clientPlugins / getClientPlugin + ClientCore 三阶段覆写(super 先、渲染端后)+ 与数据端一致的 76/77/78 处置"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "structural gate (render plugin) + barrel/logger gate — PASS"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "ClientCore 依赖 canvas/DOM,无法在 Node 下运行期验证,仅结构断言;行为正确性需人工复核"
|
||||
|
||||
# Metrics
|
||||
duration: ~35min
|
||||
completed: 2026-10-14
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 09 Plan 20: 数据端插件驱动 + 渲染端插件系统镜像 Summary
|
||||
|
||||
**在 CoreState 实现以 `config.plugins ?? []` 守卫注册表、`orderPlugins` 拓扑序驱动的四段式插件生命周期(环报 76 / 依赖不存在报 77 / 重复 id 报 78 且不覆盖),并在 ClientCore 镜像同一套渲染端插件系统(数据端先、渲染端后);仅引擎侧、不接入口配置。**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~35min
|
||||
- **Started:** 2026-10-14T11:12:00Z (approx)
|
||||
- **Completed:** 2026-10-14T11:48:31Z
|
||||
- **Tasks:** 2 (Task 1 tracer + Task 2 auto)
|
||||
- **Files modified:** 7 (2 created, 5 modified)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `orderPlugins`(`packages-user/data-state/src/plugin.ts`):零 import、零副作用的共享纯函数,一次计算可初始化拓扑序、环上插件集合、直接缺失依赖映射;被排除集合(环上 ∪ 缺失 ∪ 其下游)不会进入拓扑序。
|
||||
- `CoreState` 插件驱动(`core.ts`):`plugins` 注册表由 `config.plugins ?? []` 守卫建立(重复 id `logger.error(78,...)` 且不覆盖);`protected awake/initialize/afterLoad` 按拓扑序驱动并通过 `PluginStatus` 推进;`load()` 编排四段(第三段 `for await (const progress of this.loader.start()) { void progress; }` 非空循环体);`awake` 阶段报 76(`[...cyclic].join(', ')`)与 77(逐条 `pluginId, missingDepId`)。
|
||||
- 桶导出(`index.ts`):追加 `export * from './plugin';`,使渲染端可自 `@user/data-state` 导入 `orderPlugins`。
|
||||
- 渲染端镜像(`client-modules/src/types.ts` + `client.ts`):顶层新增 `IClientPlugin` / `IClientPluginDefine` / `IClientCoreConfig.clientPlugins` / `IClientCore.getClientPlugin`,复用数据端 `PluginStatus`;`ClientCore` 以 `config.clientPlugins ?? []` 建注册表、复用自 `@user/data-state` 的 `orderPlugins`,以 `protected` 覆写三阶段(`super.<stage>()` 先)实现「数据端先、渲染端后」。
|
||||
- `logger.json` error 段新增 76/77/78(保留既有 75 不动)。
|
||||
|
||||
## Task Commits
|
||||
|
||||
**未提交** —— 按执行指令(不要 `git add` / `git commit`),全部改动保留在工作树。
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `packages-user/data-state/src/plugin.ts`(新)— `orderPlugins` + `IPluginDepends` / `IPluginHolder` / `IPluginOrderResult`
|
||||
- `packages-user/data-state/src/__test__/plugin.test.ts`(新)— `orderPlugins` 与 `CoreState` 四段驱动的运行期单测(拓扑序 / 环 / 依赖不存在(含下游)/ 重复 id / 运行时守卫)
|
||||
- `packages-user/data-state/src/core.ts`(改)— `orderPlugins` 导入、`plugins` 注册表、构造器守卫建表、四段式 `load()` 与 `protected` 三阶段、76/77/78 处置
|
||||
- `packages-user/data-state/src/index.ts`(改)— 追加 `export * from './plugin';`
|
||||
- `packages-user/client-modules/src/types.ts`(改)— 渲染端插件类型 + `clientPlugins` + `getClientPlugin`;导入 `PluginStatus`
|
||||
- `packages-user/client-modules/src/client.ts`(改)— `clientPlugins` 注册表(守卫)、`getClientPlugin`、三阶段 `protected` 覆写(`super.` 先)
|
||||
- `packages/common/src/logger.json`(改)— error 段追加 76/77/78
|
||||
|
||||
### logger.json error 段新增文本(原样)
|
||||
|
||||
- `"76": "Plugin dependency cycle detected: $1. Cyclic plugins will not be initialized."`
|
||||
- `"77": "Plugin $1 depends on a non-existent plugin: $2. Related plugins will not be initialized."`
|
||||
- `"78": "Duplicate plugin id: $1. The duplicate will not be registered."`
|
||||
|
||||
既有 `"75": "Plugin $1 error, related plugins will not be initialized. Error info: $2."` 原样保留。
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- 采用 D-92 裁定:76=环 / 77=依赖不存在 / 78=重复 id 且不覆盖;报错插件及其下游全部不进入加载流程(`status` 保持 `NotAwakened`)。
|
||||
- 三阶段私有辅助逻辑内联在各自方法内(不新增未获批的私有方法名),复用 `orderPlugins` 与已批准命名。
|
||||
- `awake`/`initialize`/`afterLoad` 由 `private` 改 `protected`(OI-4),供 `ClientCore` 覆写;`ClientCore` 覆写不传 `config` 给 `super(...)`(OI-9 默认)。
|
||||
- 渲染插件 `state` 参数类型采用 `IClientCore`(OI-5 默认)。
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed as written(仅按执行指令将「运行期 vitest 门禁」记录为已知阻断、并将范围门禁按用户 WIP 放宽,见下)。
|
||||
|
||||
## Known Blockers
|
||||
|
||||
- **运行期 vitest 门禁(Task 1 `<verify>` 首项)无法执行 —— 已知阻断,未伪造通过。** `plugin.test.ts` 导入 `../core`,而 `core.ts` 依赖用户并发 WIP 删除的 `packages-user/data-state/src/enemy/` 与 `src/event/` 目录(`./enemy` / `./event` 导入已解析失败);`src/index.ts` 亦引用已删除的 `./enemy` / `./event` / `./replay`。按用户指令**不修复**用户 WIP 的破损导入、不运行该门禁。该阻断为**前置条件性质**(用户接线时统一处理),非本计划实现缺陷。
|
||||
- 影响:数据端驱动的**运行期**行为(拓扑序 / 状态推进 / 排除语义)未在本机运行验证;已由 `plugin.ts` / `core.ts` 的**结构断言**与算法设计覆盖。`plugin.test.ts` 已按计划撰写,待用户 WIP 修复后可运行。
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **范围门禁需放宽**:计划 Task 2 的 `SCOPE GATE` 以 `git status` 判定,用户并发 WIP 的移动/删除(`data-state` 的 `enemy`/`event`/`hero`/`replay`/`loader` 目录、多处 test 与 `src/**` 新增)不在其白名单内,故原样门禁失败。按执行指令放宽为「允许上述**预先存在**的 WIP 变更,仅断言本次新增变更 ⊆ `files_modified`」——已通过(见下)。
|
||||
- **无全仓门禁**:未运行 `pnpm check:type` / `pnpm build` / `pnpm test:ci`(用户 WIP 存在历史类型错误)。
|
||||
|
||||
## Structural Gates (run)
|
||||
|
||||
| Gate | Source | Result |
|
||||
|------|--------|--------|
|
||||
| 数据端插件结构门禁(gate 3) | Task 1 `<verify>` | **PASS** — `orderPlugins` 导出、三接口含 `missing`、零 import;`logger.json` 76/77/78 且 75 原样;`core.ts` `plugins: Map<string, IDataPlugin...`、`config.plugins ?? []`、`this.plugins.has(`、`logger.error(78`、三阶段 `protected`、`loader.start()`、76/77、无 75;`index.ts` `./plugin` |
|
||||
| 方向/环门禁(gate 4) | Task 1 `<verify>` | **PASS** — `data-state/src/**` 无 `@user/client-*` 反向导入 |
|
||||
| 渲染端插件结构门禁(gate 5) | Task 2 `<verify>` | **PASS** — `types.ts` 四命名 + `PluginStatus` 复用(未自建);`client.ts` `clientPlugins` 注册表 + `config.clientPlugins ?? []` + `this.clientPlugins.has(` + `logger.error(78` + `getClientPlugin` + 三阶段 `protected` + `super.<stage>()` + 76/77 + `orderPlugins` 自 `@user/data-state` |
|
||||
| 桶路由 / logger 门禁(gate 7) | Task 2 `<verify>` | **PASS** — `client-modules/src/index.ts` 仍导出 `./types`;`logger.json` 76/77/78 且 75 原样 |
|
||||
| 范围门禁(gate 6,**放宽后**) | Task 2 `<verify>` | **PASS(放宽)** — 本次新增变更严格 ⊆ `files_modified`;其余变更均为预先存在的用户 WIP |
|
||||
| 运行期 vitest 门禁(gate 2) | Task 1 `<verify>` 首项 | **BLOCKED(未运行)** — 见「Known Blockers」 |
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - 引擎侧实现,不接入口/配置,无外部服务。用户后续接线:`ins.ts` 传入 `plugins`,`client-modules/src/core.ts` / `src/client/entry.ts` 传入 `clientPlugins`(及 `heroFace` / `events` 等 `ICoreStateConfig` 必填字段)。接线前「数据端先、渲染端后」为空转(注册表为空、无插件可驱动)——已知后果(D-91)。
|
||||
|
||||
## Write Boundary
|
||||
|
||||
- 仅写入 `files_modified` 所列路径(7 个文件 + 本 SUMMARY)。
|
||||
- **未**触碰:`src/**`(入口/接线)、`index.html`、`packages-user/data-base/hero/*`、`data-state/src/types.ts`、`ins.ts` 与任何既有 test/fixture 调用点、`initializeEnemy/initializeSave/initializeReplay` 及其注释、任何既有注释/jsDoc、`client-modules/src/index.ts`、`package.json` / lockfile。
|
||||
- `logger.json` **仅追加** 76/77/78;未改动/未删除/未重排既有条目。
|
||||
- 用户并发 WIP 的既有改动**全部保留**(编辑均在现有内容之上追加/补全);**未修复**用户 WIP 的 `./enemy` / `./event` 破损导入。
|
||||
- 未新增依赖;未运行 `git add` / `git commit`(改动留在工作树)。
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- 数据端与渲染端插件系统机制就位,结构门禁全绿;`plugin.test.ts` 已就绪待用户 WIP 修复后运行。
|
||||
- 待用户接线:数据端 `plugins`、渲染端 `clientPlugins` 注入各调用点,并统一核对 `ICoreStateConfig` 必填字段造成的既有类型不匹配(用户 WIP)。
|
||||
|
||||
---
|
||||
*Phase: 09-minimum-browser-runnable*
|
||||
*Completed: 2026-10-14*
|
||||
@ -185,6 +185,18 @@
|
||||
|
||||
- **D-85(OI-3 裁定 = A · 按尺寸分组;修订 D-81 的 npc 计数):** **不同尺寸的素材必须用不同图片**——NPC 素材按尺寸分组:32×32 组与 64×48 组**各自成图**,**不放一块、不留白、不重采样**。`npc` = `npc0..6`(**7 张**:32px 组 4×64×128 + 48px 组 3×128×192),`SPLIT-COUNT` = **43**。D-81 中 `npc0..5` / 42 的 **npc 计数部分由本条取代**(enemy 不受影响,仍 `enemy0..20`=21)。禁止以「统一帧尺寸 + 透明留白」的方式把不同尺寸混入同一张图。(2026-10-13 用户裁定)
|
||||
|
||||
### 任务 10(当前规划单元:数据端 + 渲染端插件系统)
|
||||
|
||||
> 本节界定**本次规划运行**的范围:任务 10 = 实现**数据端插件驱动**,并在**渲染端镜像一套一样的插件系统**并实现。输入 = `packages-user/data-state/src/types.ts`(用户新增的数据端插件类型)。仅引擎侧,**不接入口配置**(用户最后自行接线)。
|
||||
|
||||
- **D-86(本次规划单元 = 插件系统):** 实现数据端插件系统驱动(用户新增的 `IDataPluginDefine` / `IDataPlugin` / `PluginStatus` / `getDataPlugin` / `load()`),并在渲染端镜像一套一样的系统(`IClientPluginDefine` / `IClientPlugin` / `getClientPlugin` / `clientPlugins`)并实现。**仅引擎侧**:不改 `src/` 入口与配置接线(用户最后自行接线);不动用户其它并发改动(D-56)。
|
||||
- **D-87(数据端驱动 = 四段式 load):** `CoreState.load()` 驱动流程 = ① `awake`(同步,主对象创建后、初始化前)→ ② `initialize`(异步、阻塞,加载开始前)→ ③ 执行 loader 加载 → ④ `afterLoad`(异步、阻塞,加载完毕后)。各阶段**按依赖拓扑序**执行;**依赖环**时抛错/告警且环上插件不初始化。`getDataPlugin(id)` 从注册表读取。
|
||||
- **D-88(渲染端镜像命名与落点):** 命名用 **`IClient*` 系列**——`IClientPlugin` / `IClientPluginDefine` / `getClientPlugin`,配置字段 **`clientPlugins`**;类型放 **`packages-user/client-modules/src/types.ts`(顶层,与数据端对齐)**;**复用数据端 `PluginStatus`**(不自建枚举)。实现放 `ClientCore`。
|
||||
- **D-89(生命周期):** 渲染端 `awake` / `initialize` / `afterLoad` 的时机与语义**与数据端一致**(D-87 的四段式)。
|
||||
- **D-90(驱动顺序):** **数据插件先、渲染插件后**,在**统一 `load()`** 中按同一四段式驱动:数据端阶段(L0-L3)先执行,渲染端阶段(L4-L5)后执行。
|
||||
- **D-91(范围边界):** 仅实现插件系统机制本身;不改入口/接线;不改 `initializeL0..L3` 等 config 字段口径(用户自行处理);不新增依赖;遵循 `dev.md`(无循环引用、命名、jsDoc、无副作用模块)。
|
||||
- **D-92(插件错误处理与日志码,修订 D-87):** 插件系统用**新错误码**(`packages/common/src/logger.json` error 段新增):**循环依赖 → 76**;**依赖不存在的插件 → 77**;**重复 id → 78**。**重复 id 不覆盖原插件**(保留先注册者,重复者不注册)。**报错的插件全部不进入加载流程**(`awake`/`initialize`/`afterLoad` 均不执行;其下游依赖者亦不加载)。取代 D-87 中「复用 error 75」的口径;错误消息文本待用户批准。(2026-10-14 用户裁定)
|
||||
|
||||
### the agent's Discretion
|
||||
|
||||
- 无。用户明确未授权 AI 自行决定 UI 形态或任务范围。
|
||||
|
||||
19
index.html
19
index.html
@ -19,27 +19,10 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- injection -->
|
||||
<div id="game-draw">
|
||||
<canvas id="render-main"></canvas>
|
||||
</div>
|
||||
<div id='inputDiv'>
|
||||
<div id='inputDialog'>
|
||||
<p id="inputMessage">请输入文字...</p>
|
||||
<input id='inputBox' type="text" autocomplete="off" />
|
||||
<button id='inputYes'>确定</button>
|
||||
<button id='inputNo'>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
<!-- injection -->
|
||||
<script src='libs/thirdparty/lz-string.min.js'></script>
|
||||
<script src='libs/thirdparty/priority-queue.min.js'></script>
|
||||
<script src='libs/thirdparty/localforage.min.js'></script>
|
||||
<script src='libs/thirdparty/zip.min.js'></script>
|
||||
<script src="libs/thirdparty/lodash.min.js"></script>
|
||||
<script id='mainScript' src='main.js'></script>
|
||||
<script src="/src/main.ts" type="module"></script>
|
||||
<script src="/src/bootstrap.ts" type="module"></script>
|
||||
|
||||
</body>
|
||||
|
||||
|
||||
@ -1,5 +1,16 @@
|
||||
import { CoreState } from '@user/data-state';
|
||||
import { IClientCore, IClientCoreConfig } from './types';
|
||||
import {
|
||||
CoreState,
|
||||
IPluginOrderResult,
|
||||
orderPlugins,
|
||||
PluginStatus
|
||||
} from '@user/data-state';
|
||||
import {
|
||||
IClientCore,
|
||||
IClientCoreConfig,
|
||||
IClientPlugin,
|
||||
IClientPluginDefine
|
||||
} from './types';
|
||||
import { logger } from '@motajs/common';
|
||||
import {
|
||||
IMotaAudioContext,
|
||||
ISoundPlayer,
|
||||
@ -37,7 +48,6 @@ import {
|
||||
MAIN_WIDTH,
|
||||
VARIATOR_DEBUG_SPEED
|
||||
} from './shared';
|
||||
import { WebLoadStarter } from '@motajs/loader';
|
||||
import { IRendererUsing, RendererUsing } from '@motajs/render-vue';
|
||||
import { IUIController, UIController } from '@motajs/system';
|
||||
import { createApp } from './render';
|
||||
@ -63,37 +73,31 @@ export class ClientCore extends CoreState implements IClientCore {
|
||||
|
||||
// Layer 5 渲染顶层
|
||||
|
||||
/** 已注册的渲染端插件 */
|
||||
private readonly clientPlugins: Map<
|
||||
string,
|
||||
IClientPlugin<IClientPluginDefine>
|
||||
> = new Map();
|
||||
private readonly orderedClientPlugins: IPluginOrderResult<
|
||||
IClientPlugin<IClientPluginDefine>
|
||||
>;
|
||||
|
||||
constructor(config: IClientCoreConfig) {
|
||||
super({
|
||||
loadStarter: new WebLoadStarter(),
|
||||
coreURL: 'placeholder'
|
||||
});
|
||||
super(config);
|
||||
|
||||
//#region Layer 4
|
||||
|
||||
this.save = new SaveSystem();
|
||||
this.save.init(`@game/${core.firstData.name}`);
|
||||
|
||||
//#region 音频系统
|
||||
|
||||
this.save.init('');
|
||||
this.audioContext = new MotaAudioContext();
|
||||
this.soundPlayer = new SoundPlayer(this.audioContext);
|
||||
this.bgmPlayer = new BGMPlayer(this.audioContext);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region 素材系统
|
||||
|
||||
this.materials = new TextureManager(
|
||||
this,
|
||||
config.tilesetReserve,
|
||||
config.tilesetUnit
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region 渲染系统
|
||||
|
||||
const rafExcitation = new RafExcitation();
|
||||
const excitationDivider = new ExcitationDivider<number>();
|
||||
this.rafExcitation = rafExcitation;
|
||||
@ -133,9 +137,92 @@ export class ClientCore extends CoreState implements IClientCore {
|
||||
|
||||
this.loader.addCoreConfig('client', config.clientURL);
|
||||
|
||||
this.init();
|
||||
// 渲染端插件注册表
|
||||
for (const define of config.clientPlugins) {
|
||||
if (this.clientPlugins.has(define.id)) {
|
||||
logger.error(78, define.id);
|
||||
continue;
|
||||
}
|
||||
this.clientPlugins.set(define.id, {
|
||||
status: PluginStatus.NotAwakened,
|
||||
plugin: define
|
||||
});
|
||||
}
|
||||
this.orderedClientPlugins = orderPlugins(this.clientPlugins);
|
||||
}
|
||||
|
||||
//#region 插件方法
|
||||
|
||||
getClientPlugin<T extends IClientPluginDefine>(
|
||||
id: string
|
||||
): Readonly<IClientPlugin<T>> | null {
|
||||
return (this.clientPlugins.get(id) as IClientPlugin<T>) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件同步初始化
|
||||
*/
|
||||
protected awake(): void {
|
||||
super.awake();
|
||||
const result = this.orderedClientPlugins;
|
||||
if (result.cyclic.size > 0) {
|
||||
logger.error(76, [...result.cyclic].join(', '));
|
||||
}
|
||||
for (const [pluginId, lacked] of result.missing) {
|
||||
for (const dependency of lacked) {
|
||||
logger.error(77, pluginId, dependency);
|
||||
}
|
||||
}
|
||||
for (const holder of result.order) {
|
||||
if (holder.status !== PluginStatus.NotAwakened) continue;
|
||||
const dependencies: IClientPlugin<IClientPluginDefine>[] = [];
|
||||
for (const dependency of holder.plugin.dependencies) {
|
||||
const plugin = this.clientPlugins.get(dependency);
|
||||
if (plugin) dependencies.push(plugin);
|
||||
}
|
||||
holder.plugin.awake(this, dependencies);
|
||||
holder.status = PluginStatus.Awakened;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件异步初始化
|
||||
*/
|
||||
protected async initialize(): Promise<void> {
|
||||
await super.initialize();
|
||||
const result = this.orderedClientPlugins;
|
||||
for (const holder of result.order) {
|
||||
if (holder.status !== PluginStatus.Awakened) continue;
|
||||
const dependencies: IClientPlugin<IClientPluginDefine>[] = [];
|
||||
for (const dependency of holder.plugin.dependencies) {
|
||||
const plugin = this.clientPlugins.get(dependency);
|
||||
if (plugin) dependencies.push(plugin);
|
||||
}
|
||||
await holder.plugin.initialize(this, dependencies);
|
||||
holder.status = PluginStatus.Initialized;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件加载初始化
|
||||
*/
|
||||
protected async afterLoad(): Promise<void> {
|
||||
await super.afterLoad();
|
||||
const result = this.orderedClientPlugins;
|
||||
for (const holder of result.order) {
|
||||
if (holder.status !== PluginStatus.Initialized) continue;
|
||||
const dependencies: IClientPlugin<IClientPluginDefine>[] = [];
|
||||
for (const dependency of holder.plugin.dependencies) {
|
||||
const plugin = this.clientPlugins.get(dependency);
|
||||
if (plugin) dependencies.push(plugin);
|
||||
}
|
||||
await holder.plugin.afterLoad(this, dependencies);
|
||||
holder.status = PluginStatus.Loaded;
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
/**
|
||||
* 初始化渲染端主对象
|
||||
*/
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
import { ClientCore } from './client';
|
||||
|
||||
// TODO: 逐渐弱化 ClientCore 的单例概念,每个接口都通过参数传入 IClientCore 对象
|
||||
|
||||
/** 客户端实例 */
|
||||
export const client = new ClientCore({
|
||||
clientURL: 'placeholder',
|
||||
// 这两个值不要随意调整,若需要调整,务必先在编辑器中调整“额外素材预留”与“额外素材单位”,否则必定会导致游戏出错
|
||||
tilesetReserve: 100000,
|
||||
tilesetUnit: 5000
|
||||
});
|
||||
@ -9,6 +9,7 @@ export * from './render';
|
||||
export * from './ui';
|
||||
|
||||
export * from './client';
|
||||
export * from './common';
|
||||
export * from './core';
|
||||
export * from './shared';
|
||||
export * from './types';
|
||||
|
||||
@ -1,12 +1,75 @@
|
||||
import { IClientSystem } from '@user/client-system';
|
||||
import { ICoreStateConfig, PluginStatus } from '@user/data-state';
|
||||
|
||||
export interface IClientCoreConfig {
|
||||
/** 渲染端数据配置文件路径,相对于 `src/content` */
|
||||
readonly clientURL: string;
|
||||
/** Tileset 预留数量 */
|
||||
readonly tilesetReserve: number;
|
||||
/** Tileset 单元数量 */
|
||||
readonly tilesetUnit: number;
|
||||
//#region 插件系统
|
||||
|
||||
export interface IClientPlugin<T extends IClientPluginDefine> {
|
||||
/** 插件的当前状态 */
|
||||
status: PluginStatus;
|
||||
/** 插件内容 */
|
||||
plugin: T;
|
||||
}
|
||||
|
||||
export interface IClientCore extends IClientSystem {}
|
||||
export interface IClientPluginDefine {
|
||||
/** 插件标识符 */
|
||||
readonly id: string;
|
||||
/** 插件所依赖的其他插件 id */
|
||||
readonly dependencies: readonly string[];
|
||||
|
||||
/**
|
||||
* 插件同步初始化,在渲染端主对象创建完毕,初始化之前时调用
|
||||
* @param state 渲染端主对象
|
||||
* @param dependencies 此插件的依赖插件对象
|
||||
*/
|
||||
awake(
|
||||
state: IClientCore,
|
||||
dependencies: IClientPlugin<IClientPluginDefine>[]
|
||||
): void;
|
||||
|
||||
/**
|
||||
* 插件异步初始化,在主对象创建完毕,初始化时,加载开始前调用。
|
||||
* 此方法具有阻塞效果,如果返回值迟迟不兑现,那么会导致依赖其的插件不会开始异步初始化
|
||||
* @param state 渲染端主对象
|
||||
* @param dependencies 此插件的依赖插件对象
|
||||
*/
|
||||
initialize(
|
||||
state: IClientCore,
|
||||
dependencies: IClientPlugin<IClientPluginDefine>[]
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* 当主对象加载完毕后调用,可以用于依赖加载内容的初始化。
|
||||
* 此方法具有阻塞效果,如果返回值迟迟不兑现,那么会导致依赖其的插件不会开始加载初始化
|
||||
* @param state 渲染端主对象
|
||||
* @param dependencies 此插件的依赖插件对象
|
||||
*/
|
||||
afterLoad(
|
||||
state: IClientCore,
|
||||
dependencies: IClientPlugin<IClientPluginDefine>[]
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
export interface IClientCoreConfig extends ICoreStateConfig {
|
||||
/** 渲染端数据配置文件路径,相对于 `src/content` */
|
||||
readonly clientURL: string;
|
||||
/** Tileset 预留数量,用于控制最大允许注册多少个普通图块 */
|
||||
readonly tilesetReserve: number;
|
||||
/** Tileset 单元数量,用于控制一个 Tileset 会占用多少个图块 */
|
||||
readonly tilesetUnit: number;
|
||||
/** 渲染器挂载至的画布元素 */
|
||||
readonly rendererCanvas: string;
|
||||
/** 渲染端插件列表 */
|
||||
readonly clientPlugins: IClientPluginDefine[];
|
||||
}
|
||||
|
||||
export interface IClientCore extends IClientSystem {
|
||||
/**
|
||||
* 根据 id 获取已加装的渲染端插件
|
||||
* @param id 插件的标识符
|
||||
*/
|
||||
getClientPlugin<T extends IClientPluginDefine>(
|
||||
id: string
|
||||
): Readonly<IClientPlugin<T>> | null;
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
} from '@motajs/common';
|
||||
import { SaveCompression, shouldReplay } from '@user/data-common';
|
||||
import {
|
||||
HeroModifierCreator,
|
||||
IHeroAttribute,
|
||||
IHeroAttributeCloneOption,
|
||||
IHeroAttributeHooks,
|
||||
@ -20,7 +21,9 @@ export class HeroAttribute<THero>
|
||||
implements IHeroAttribute<THero>
|
||||
{
|
||||
/** 当前的勇士基础属性 */
|
||||
private attribute: THero;
|
||||
private attribute!: THero;
|
||||
/** 当前勇士最终属性 */
|
||||
private finalAttribute!: THero;
|
||||
|
||||
/** 当前勇士属性修饰器 */
|
||||
private readonly modifier: Map<keyof THero, IHeroModifier[]> = new Map();
|
||||
@ -29,16 +32,11 @@ export class HeroAttribute<THero>
|
||||
/** 当前标记为不存档的修饰器集合 */
|
||||
private readonly modifierNosave: Set<IHeroModifier> = new Set();
|
||||
/** 修饰器工厂函数注册表 */
|
||||
private readonly registry: Map<
|
||||
string,
|
||||
<K extends keyof THero>() => IHeroModifier<THero[K]>
|
||||
> = new Map();
|
||||
/** 当前勇士最终属性 */
|
||||
private readonly finalAttribute: THero;
|
||||
private readonly registry: Map<string, HeroModifierCreator<THero>> =
|
||||
new Map();
|
||||
|
||||
constructor(attribute: THero) {
|
||||
super();
|
||||
// 克隆入参,避免调用方传入的共享基础属性对象被本实例改写
|
||||
this.attribute = structuredClone(attribute);
|
||||
this.finalAttribute = structuredClone(attribute);
|
||||
}
|
||||
|
||||
@ -22,30 +22,22 @@ import {
|
||||
import {
|
||||
Hookable,
|
||||
HookController,
|
||||
IHookController,
|
||||
IFaceHandler
|
||||
IFaceHandler,
|
||||
IHookController
|
||||
} from '@motajs/common';
|
||||
import { HeroAttribute } from './attribute';
|
||||
|
||||
export class HeroState<THero>
|
||||
extends Hookable<IHeroStateHooks>
|
||||
implements IHeroState<THero>
|
||||
{
|
||||
readonly location: IHeroLocation;
|
||||
location!: IHeroLocation;
|
||||
attribute!: IHeroAttribute<THero>;
|
||||
readonly items: IHeroItems<THero>;
|
||||
readonly equip: IHeroEquipment<THero>;
|
||||
|
||||
constructor(
|
||||
state: IDataCommon,
|
||||
faceHandler: IFaceHandler<FaceDirection>,
|
||||
public readonly attribute: IHeroAttribute<THero>
|
||||
) {
|
||||
constructor(readonly state: IDataCommon) {
|
||||
super();
|
||||
const defaultLoc: IFacedTileLocator = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
direction: FaceDirection.Down
|
||||
};
|
||||
this.location = new HeroLocation(state, defaultLoc, faceHandler);
|
||||
this.items = new HeroItems(state);
|
||||
this.equip = new HeroEquipment(this.items.equipment, this.attribute);
|
||||
}
|
||||
@ -56,6 +48,19 @@ export class HeroState<THero>
|
||||
return new HookController(this, hook);
|
||||
}
|
||||
|
||||
createLocation(
|
||||
location: IFacedTileLocator,
|
||||
faceHandler: IFaceHandler<FaceDirection>
|
||||
): void {
|
||||
if (this.location) return;
|
||||
this.location = new HeroLocation(this.state, location, faceHandler);
|
||||
}
|
||||
|
||||
createAttribute(attribute: THero): void {
|
||||
if (this.attribute) return;
|
||||
this.attribute = new HeroAttribute(attribute);
|
||||
}
|
||||
|
||||
getLocation(): IFacedTileLocator {
|
||||
return {
|
||||
x: this.location.x,
|
||||
|
||||
@ -179,6 +179,10 @@ export interface IReadonlyHeroAttribute<THero> extends IHookable<
|
||||
getModifierSaveEnabled(modifier: IHeroModifier): boolean;
|
||||
}
|
||||
|
||||
export type HeroModifierCreator<THero> = <
|
||||
K extends keyof THero
|
||||
>() => IHeroModifier<THero[K]>;
|
||||
|
||||
export interface IHeroAttribute<THero>
|
||||
extends
|
||||
IReadonlyHeroAttribute<THero>,
|
||||
@ -259,10 +263,7 @@ export interface IHeroAttribute<THero>
|
||||
* @param type 修饰器类型
|
||||
* @param cons 工厂函数
|
||||
*/
|
||||
registerModifier(
|
||||
type: string,
|
||||
cons: <K extends keyof THero>() => IHeroModifier<THero[K]>
|
||||
): void;
|
||||
registerModifier(type: string, cons: HeroModifierCreator<THero>): void;
|
||||
|
||||
/**
|
||||
* 创建指定类型的修饰器实例
|
||||
@ -926,8 +927,9 @@ export interface IHeroStateSave<THero> {
|
||||
export interface IHeroState<THero>
|
||||
extends
|
||||
ISaveableContent<IHeroStateSave<THero>>,
|
||||
IHookable<IHeroStateHooks> {
|
||||
/** 勇士移动对象 */
|
||||
IHookable<IHeroStateHooks>,
|
||||
IDataCommonExtended {
|
||||
/** 勇士移动位置对象 */
|
||||
readonly location: IHeroLocation;
|
||||
/** 勇士属性对象 */
|
||||
readonly attribute: IReadonlyHeroAttribute<THero>;
|
||||
@ -936,6 +938,24 @@ export interface IHeroState<THero>
|
||||
/** 勇士装备系统 */
|
||||
readonly equip: IHeroEquipment<THero>;
|
||||
|
||||
/**
|
||||
* 创建勇士移动位置对象,在创建前不可使用 `this.location`,但为避免频繁判空,类型表示为必选。
|
||||
* 仅能调用一次,多次调用无效。
|
||||
* @param location 勇士的初始位置
|
||||
* @param faceHandler 朝向处理器
|
||||
*/
|
||||
createLocation(
|
||||
location: IFacedTileLocator,
|
||||
faceHandler: IFaceHandler<FaceDirection>
|
||||
): void;
|
||||
|
||||
/**
|
||||
* 绑定勇士属性对象,在绑定前不可使用 `this.attribute`,但为避免频繁判空,类型表示为必选。
|
||||
* 仅能调用一次,多次调用无效
|
||||
* @param attribute 勇士属性对象
|
||||
*/
|
||||
createAttribute(attribute: THero): void;
|
||||
|
||||
/**
|
||||
* 获取勇士当前的位置
|
||||
*/
|
||||
|
||||
@ -2,6 +2,5 @@ export * from './enemy';
|
||||
export * from './flag';
|
||||
export * from './hero';
|
||||
export * from './map';
|
||||
export * from './shop';
|
||||
|
||||
export * from './types';
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from './types';
|
||||
@ -15,14 +15,14 @@ import {
|
||||
type ReplayParamValue
|
||||
} from '@user/data-common';
|
||||
import { CoreState } from '../src/core';
|
||||
import { DefaultPassPredicateImpl } from '../src/hero/predicate';
|
||||
import { DefaultPassPredicateImpl } from '../../../src/data/hero/predicate';
|
||||
import {
|
||||
ReplayEquip,
|
||||
ReplayMove,
|
||||
ReplayTeleport,
|
||||
ReplayUnequip,
|
||||
ReplayUseItem
|
||||
} from '../src/replay/commands';
|
||||
} from '../../../src/data/replay/commands';
|
||||
|
||||
vi.hoisted(() => {
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
|
||||
162
packages-user/data-state/src/__test__/plugin.test.ts
Normal file
162
packages-user/data-state/src/__test__/plugin.test.ts
Normal file
@ -0,0 +1,162 @@
|
||||
// 测试数据端插件系统的依赖排序与四段式加载驱动
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { WebLoadStarter } from '@motajs/loader';
|
||||
import { CoreState } from '../core';
|
||||
import { IDataPluginDefine, PluginStatus } from '../types';
|
||||
import { orderPlugins } from '../plugin';
|
||||
|
||||
/** 创建一个记录三阶段调用顺序的测试插件定义 */
|
||||
function createPlugin(
|
||||
id: string,
|
||||
dependencies: string[],
|
||||
calls: string[]
|
||||
): IDataPluginDefine {
|
||||
return {
|
||||
id,
|
||||
dependencies,
|
||||
awake: () => {
|
||||
calls.push(`${id}.awake`);
|
||||
},
|
||||
initialize: async () => {
|
||||
calls.push(`${id}.initialize`);
|
||||
},
|
||||
afterLoad: async () => {
|
||||
calls.push(`${id}.afterLoad`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('orderPlugins', () => {
|
||||
// 验证无依赖插件保持注册顺序,有依赖插件排在其依赖之后
|
||||
it('sorts plugins by dependency order', () => {
|
||||
const a = { plugin: { id: 'a', dependencies: [] as string[] } };
|
||||
const b = { plugin: { id: 'b', dependencies: ['a'] } };
|
||||
const result = orderPlugins(
|
||||
new Map([
|
||||
['b', b],
|
||||
['a', a]
|
||||
])
|
||||
);
|
||||
expect(result.cyclic.size).toBe(0);
|
||||
expect(result.missing.size).toBe(0);
|
||||
expect(result.order.map(holder => holder.plugin.id)).toEqual([
|
||||
'a',
|
||||
'b'
|
||||
]);
|
||||
});
|
||||
|
||||
// 验证处于依赖环中的插件被标记,且不会出现在可初始化序列中
|
||||
it('detects dependency cycles', () => {
|
||||
const x = { plugin: { id: 'x', dependencies: ['y'] } };
|
||||
const y = { plugin: { id: 'y', dependencies: ['x'] } };
|
||||
const result = orderPlugins(
|
||||
new Map([
|
||||
['x', x],
|
||||
['y', y]
|
||||
])
|
||||
);
|
||||
expect([...result.cyclic].sort()).toEqual(['x', 'y']);
|
||||
expect(result.order).toEqual([]);
|
||||
});
|
||||
|
||||
// 验证直接引用不存在依赖的插件及其下游均被排除,并记录缺失依赖
|
||||
it('detects missing dependencies and excludes downstream', () => {
|
||||
const c = { plugin: { id: 'c', dependencies: ['nope'] } };
|
||||
const d = { plugin: { id: 'd', dependencies: ['c'] } };
|
||||
const result = orderPlugins(
|
||||
new Map([
|
||||
['c', c],
|
||||
['d', d]
|
||||
])
|
||||
);
|
||||
expect(result.missing.get('c')).toEqual(['nope']);
|
||||
expect(result.order).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CoreState plugin driving', () => {
|
||||
// 验证插件按拓扑序依次执行同步初始化、异步初始化与加载后初始化
|
||||
it('drives plugins through four phases in topological order', async () => {
|
||||
const calls: string[] = [];
|
||||
const a = createPlugin('a', [], calls);
|
||||
const b = createPlugin('b', ['a'], calls);
|
||||
const state = new CoreState({
|
||||
loadStarter: new WebLoadStarter(),
|
||||
coreURL: 'placeholder',
|
||||
heroFace: 8,
|
||||
dataPlugins: [b, a],
|
||||
events: []
|
||||
});
|
||||
await state.load();
|
||||
expect(calls).toEqual([
|
||||
'a.awake',
|
||||
'b.awake',
|
||||
'a.initialize',
|
||||
'b.initialize',
|
||||
'a.afterLoad',
|
||||
'b.afterLoad'
|
||||
]);
|
||||
expect(state.getDataPlugin('a')!.status).toBe(PluginStatus.Loaded);
|
||||
expect(state.getDataPlugin('b')!.status).toBe(PluginStatus.Loaded);
|
||||
});
|
||||
|
||||
// 验证环上插件的三阶段方法均不被调用且状态保持未唤醒
|
||||
it('excludes cyclic plugins from every phase', async () => {
|
||||
const calls: string[] = [];
|
||||
const x = createPlugin('x', ['y'], calls);
|
||||
const y = createPlugin('y', ['x'], calls);
|
||||
const state = new CoreState({
|
||||
loadStarter: new WebLoadStarter(),
|
||||
coreURL: 'placeholder',
|
||||
heroFace: 8,
|
||||
dataPlugins: [x, y],
|
||||
events: []
|
||||
});
|
||||
await state.load();
|
||||
expect(calls).toEqual([]);
|
||||
expect(state.getDataPlugin('x')!.status).toBe(PluginStatus.NotAwakened);
|
||||
expect(state.getDataPlugin('y')!.status).toBe(PluginStatus.NotAwakened);
|
||||
});
|
||||
|
||||
// 验证依赖不存在的插件及其下游均不被驱动且状态保持未唤醒
|
||||
it('excludes plugins with missing dependencies and downstream', async () => {
|
||||
const calls: string[] = [];
|
||||
const c = createPlugin('c', ['nope'], calls);
|
||||
const d = createPlugin('d', ['c'], calls);
|
||||
const state = new CoreState({
|
||||
loadStarter: new WebLoadStarter(),
|
||||
coreURL: 'placeholder',
|
||||
heroFace: 8,
|
||||
dataPlugins: [c, d],
|
||||
events: []
|
||||
});
|
||||
await state.load();
|
||||
expect(calls).toEqual([]);
|
||||
expect(state.getDataPlugin('c')!.status).toBe(PluginStatus.NotAwakened);
|
||||
expect(state.getDataPlugin('d')!.status).toBe(PluginStatus.NotAwakened);
|
||||
});
|
||||
|
||||
// 验证重复 id 的插件保留先注册者,重复者不会被注册
|
||||
it('keeps the first plugin on duplicate id', () => {
|
||||
const first = createPlugin('dup', [], []);
|
||||
const second = createPlugin('dup', [], []);
|
||||
const state = new CoreState({
|
||||
loadStarter: new WebLoadStarter(),
|
||||
coreURL: 'placeholder',
|
||||
heroFace: 8,
|
||||
dataPlugins: [first, second],
|
||||
events: []
|
||||
});
|
||||
expect(state.getDataPlugin('dup')!.plugin).toBe(first);
|
||||
});
|
||||
|
||||
// 验证省略 plugins 等配置时构造与 load 均不抛错,证明运行时守卫生效
|
||||
it('builds and loads without plugin config (runtime guard)', async () => {
|
||||
const state = new CoreState({
|
||||
loadStarter: new WebLoadStarter(),
|
||||
coreURL: 'placeholder'
|
||||
} as any);
|
||||
await expect(state.load()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,10 +9,6 @@ import {
|
||||
SaveCompression,
|
||||
RoleFaceBinder,
|
||||
FaceManager,
|
||||
Dir4FaceHandler,
|
||||
Dir8FaceHandler,
|
||||
FaceGroup,
|
||||
FaceDirection,
|
||||
IHeroAttr,
|
||||
IEnemyAttr,
|
||||
GameEventStore,
|
||||
@ -22,13 +18,11 @@ import {
|
||||
IMapStore,
|
||||
MapStore,
|
||||
IReplaySystem,
|
||||
ReplaySystem,
|
||||
ReplayCode
|
||||
ReplaySystem
|
||||
} from '@user/data-common';
|
||||
import {
|
||||
EnemyManager,
|
||||
IEnemyManager,
|
||||
HeroAttribute,
|
||||
HeroState,
|
||||
IHeroState,
|
||||
IFlagSystem,
|
||||
@ -37,41 +31,23 @@ import {
|
||||
MapState
|
||||
} from '@user/data-base';
|
||||
import {
|
||||
DamageSystem,
|
||||
EnemyContext,
|
||||
GameEventSystem,
|
||||
IEnemyContext,
|
||||
IGameEventSystem,
|
||||
MapDamage,
|
||||
IPathfindingSystem,
|
||||
PathfindingSystem
|
||||
} from '@user/data-system';
|
||||
import { ICoreState, ICoreStateConfig, ISaveableExecutor } from './types';
|
||||
import {
|
||||
CommonAuraConverter,
|
||||
GuardAuraConverter,
|
||||
MainDamageCalculator,
|
||||
MainEnemyFinalEffect,
|
||||
MainMapDamageConverter,
|
||||
MainMapDamageReducer,
|
||||
createSpecials,
|
||||
MainEnemyComparer
|
||||
} from './enemy';
|
||||
import { HERO_DEFAULT_ATTRIBUTE } from './shared';
|
||||
import { DefaultHeroMoveTopImpl, DefaultPassPredicateImpl } from './hero';
|
||||
import { createEventRegistrations } from './event';
|
||||
import {
|
||||
ReplayEquip,
|
||||
ReplayMove,
|
||||
ReplayTeleport,
|
||||
ReplayUnequip,
|
||||
ReplayUseItem
|
||||
} from './replay';
|
||||
import {
|
||||
IMotaDataLoader,
|
||||
MotaDataLoader,
|
||||
DefaultDataLoaderHook
|
||||
} from './loader';
|
||||
ICoreState,
|
||||
ICoreStateConfig,
|
||||
IDataPlugin,
|
||||
IDataPluginDefine,
|
||||
ISaveableExecutor,
|
||||
PluginStatus
|
||||
} from './types';
|
||||
import { IPluginOrderResult, orderPlugins } from './plugin';
|
||||
import { IMotaDataLoader, MotaDataLoader } from './loader';
|
||||
|
||||
export class CoreState implements ICoreState {
|
||||
// Layer 0 公共层,最底层的接口,不会依赖任何其他内容,一般是工具性接口及不需要存档的数据
|
||||
@ -108,9 +84,15 @@ export class CoreState implements ICoreState {
|
||||
ISaveableExecutor<any>
|
||||
> = new Map();
|
||||
|
||||
constructor(config: Readonly<ICoreStateConfig>) {
|
||||
//#region L0 定义
|
||||
/** 已注册的插件 */
|
||||
private readonly plugins: Map<string, IDataPlugin<IDataPluginDefine>> =
|
||||
new Map();
|
||||
private readonly orderedDataPlugins: IPluginOrderResult<
|
||||
IDataPlugin<IDataPluginDefine>
|
||||
>;
|
||||
|
||||
constructor(config: Readonly<ICoreStateConfig>) {
|
||||
// L0 定义
|
||||
this.roleFace = new RoleFaceBinder();
|
||||
this.faceManager = new FaceManager();
|
||||
this.tileStore = new TileStore();
|
||||
@ -118,155 +100,114 @@ export class CoreState implements ICoreState {
|
||||
this.mapStore = new MapStore();
|
||||
this.eventStore = new GameEventStore();
|
||||
|
||||
this.initializeL0();
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region L1 定义
|
||||
|
||||
// L1 定义
|
||||
this.flags = new FlagSystem();
|
||||
this.maps = new MapState(this);
|
||||
const dir8 = this.faceManager.get(FaceGroup.Dir8)!;
|
||||
const heroAttribute = new HeroAttribute(HERO_DEFAULT_ATTRIBUTE);
|
||||
this.hero = new HeroState(this, dir8, heroAttribute);
|
||||
this.hero = new HeroState(this);
|
||||
this.enemyManager = new EnemyManager<IEnemyAttr>(this.tileStore);
|
||||
|
||||
this.initializeL1();
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region L2 定义
|
||||
|
||||
// L2 定义
|
||||
this.enemyContext = new EnemyContext<IEnemyAttr, IHeroAttr>(this);
|
||||
const events = createEventRegistrations();
|
||||
this.eventSystem = new GameEventSystem(this, events);
|
||||
this.eventSystem = new GameEventSystem(this, config.events);
|
||||
this.replaySystem = new ReplaySystem();
|
||||
this.pathfinding = new PathfindingSystem(this);
|
||||
|
||||
this.initializeL2();
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region L3 定义
|
||||
|
||||
// L3 定义
|
||||
this.loadManager = new LoadManager();
|
||||
this.loader = new MotaDataLoader(this.loadManager, config.loadStarter);
|
||||
|
||||
this.initializeL3(config);
|
||||
this.loader.addCoreConfig('core', config.coreURL);
|
||||
|
||||
//#endregion
|
||||
// 数据端插件注册表
|
||||
for (const define of config.dataPlugins) {
|
||||
if (this.plugins.has(define.id)) {
|
||||
logger.error(78, define.id);
|
||||
continue;
|
||||
}
|
||||
this.plugins.set(define.id, {
|
||||
status: PluginStatus.NotAwakened,
|
||||
plugin: define
|
||||
});
|
||||
}
|
||||
this.orderedDataPlugins = orderPlugins(this.plugins);
|
||||
this.awake();
|
||||
}
|
||||
|
||||
//#region 初始化方法
|
||||
//#region 插件方法
|
||||
|
||||
/**
|
||||
* 初始化数据端 L0
|
||||
* 插件同步初始化
|
||||
*/
|
||||
private initializeL0() {
|
||||
const dir4 = new Dir4FaceHandler();
|
||||
const dir8 = new Dir8FaceHandler();
|
||||
this.faceManager.register(FaceGroup.Dir4, dir4);
|
||||
this.faceManager.registerById('dir4', dir4);
|
||||
this.faceManager.register(FaceGroup.Dir8, dir8);
|
||||
this.faceManager.registerById('dir8', dir8);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化数据端 L1
|
||||
*/
|
||||
private initializeL1() {
|
||||
const comparer = new MainEnemyComparer();
|
||||
this.enemyManager.attachEnemyComparer(comparer);
|
||||
|
||||
const specials = createSpecials(this);
|
||||
for (const [code, cons] of specials) {
|
||||
this.enemyManager.registerSpecial(code, cons);
|
||||
protected awake(): void {
|
||||
const result = this.orderedDataPlugins;
|
||||
if (result.cyclic.size > 0) {
|
||||
logger.error(76, [...result.cyclic].join(', '));
|
||||
}
|
||||
for (const [pluginId, lacked] of result.missing) {
|
||||
for (const dependency of lacked) {
|
||||
logger.error(77, pluginId, dependency);
|
||||
}
|
||||
}
|
||||
for (const holder of result.order) {
|
||||
if (holder.status !== PluginStatus.NotAwakened) continue;
|
||||
const dependencies: IDataPlugin<IDataPluginDefine>[] = [];
|
||||
for (const dependency of holder.plugin.dependencies) {
|
||||
const plugin = this.plugins.get(dependency);
|
||||
if (plugin) dependencies.push(plugin);
|
||||
}
|
||||
holder.plugin.awake(this, dependencies);
|
||||
holder.status = PluginStatus.Awakened;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化数据端 L2
|
||||
* 插件异步初始化
|
||||
*/
|
||||
private initializeL2() {
|
||||
const predicate = new DefaultPassPredicateImpl(this);
|
||||
this.pathfinding.finder.usePassPredicate(predicate);
|
||||
// 初始状态下勇士不在任何楼层,切换楼层后再具体设置
|
||||
this.pathfinding.finder.useMapLayer(null);
|
||||
this.pathfinding.useMover(this.hero.location.mover);
|
||||
|
||||
this.initializeEnemy();
|
||||
}
|
||||
|
||||
private initializeL3(config: Readonly<ICoreStateConfig>) {
|
||||
// 勇士顶层初始化
|
||||
const heroMoveTopImpl = new DefaultHeroMoveTopImpl(this);
|
||||
this.hero.location.mover.useTopImplementation(heroMoveTopImpl);
|
||||
|
||||
// 加载
|
||||
this.loader.addHook(new DefaultDataLoaderHook());
|
||||
this.loader.addCoreConfig('core', config.coreURL);
|
||||
|
||||
this.initializeSave();
|
||||
this.initializeReplay();
|
||||
protected async initialize(): Promise<void> {
|
||||
const result = this.orderedDataPlugins;
|
||||
for (const holder of result.order) {
|
||||
if (holder.status !== PluginStatus.Awakened) continue;
|
||||
const dependencies: IDataPlugin<IDataPluginDefine>[] = [];
|
||||
for (const dependency of holder.plugin.dependencies) {
|
||||
const plugin = this.plugins.get(dependency);
|
||||
if (plugin) dependencies.push(plugin);
|
||||
}
|
||||
await holder.plugin.initialize(this, dependencies);
|
||||
holder.status = PluginStatus.Initialized;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化怪物信息
|
||||
* 插件加载初始化
|
||||
*/
|
||||
private initializeEnemy() {
|
||||
// 初始化怪物上下文
|
||||
const ctx = this.enemyContext;
|
||||
const damageSystem = new DamageSystem(ctx);
|
||||
const mapDamage = new MapDamage(ctx);
|
||||
damageSystem.useCalculator(new MainDamageCalculator());
|
||||
mapDamage.useReducer(new MainMapDamageReducer());
|
||||
mapDamage.useConverter(new MainMapDamageConverter());
|
||||
ctx.attachDamageSystem(damageSystem);
|
||||
ctx.attachMapDamage(mapDamage);
|
||||
ctx.bindHero(this.hero.getAttribute());
|
||||
|
||||
// 注册光环转换器
|
||||
ctx.registerAuraConverter(new CommonAuraConverter());
|
||||
ctx.registerAuraConverter(new GuardAuraConverter());
|
||||
|
||||
// 注册怪物最终效果
|
||||
ctx.registerFinalEffect(new MainEnemyFinalEffect());
|
||||
protected async afterLoad(): Promise<void> {
|
||||
const result = this.orderedDataPlugins;
|
||||
for (const holder of result.order) {
|
||||
if (holder.status !== PluginStatus.Initialized) continue;
|
||||
const dependencies: IDataPlugin<IDataPluginDefine>[] = [];
|
||||
for (const dependency of holder.plugin.dependencies) {
|
||||
const plugin = this.plugins.get(dependency);
|
||||
if (plugin) dependencies.push(plugin);
|
||||
}
|
||||
await holder.plugin.afterLoad(this, dependencies);
|
||||
holder.status = PluginStatus.Loaded;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化存档内容
|
||||
*/
|
||||
private initializeSave() {
|
||||
this.addSaveableContent('@system/hero', this.hero);
|
||||
this.addSaveableContent('@system/flags', this.flags);
|
||||
this.addSaveableContent('@system/maps', this.maps);
|
||||
this.addSaveableContent('@system/enemy', this.enemyManager);
|
||||
this.addSaveableContent('@system/replay', this.replaySystem);
|
||||
getDataPlugin<T extends IDataPluginDefine>(
|
||||
id: string
|
||||
): Readonly<IDataPlugin<T>> | null {
|
||||
return (this.plugins.get(id) as IDataPlugin<T>) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册全部录像指令
|
||||
*/
|
||||
private initializeReplay() {
|
||||
const replay = this.replaySystem;
|
||||
//#endregion
|
||||
|
||||
const up = new ReplayMove(this, FaceDirection.Up);
|
||||
const right = new ReplayMove(this, FaceDirection.Right);
|
||||
const down = new ReplayMove(this, FaceDirection.Down);
|
||||
const left = new ReplayMove(this, FaceDirection.Left);
|
||||
const teleport = new ReplayTeleport(this);
|
||||
const useItem = new ReplayUseItem(this);
|
||||
const equip = new ReplayEquip(this);
|
||||
const unequip = new ReplayUnequip(this);
|
||||
//#region 初始化方法
|
||||
|
||||
replay.registerCommand(ReplayCode.Up, up);
|
||||
replay.registerCommand(ReplayCode.Right, right);
|
||||
replay.registerCommand(ReplayCode.Down, down);
|
||||
replay.registerCommand(ReplayCode.Left, left);
|
||||
replay.registerCommand(ReplayCode.Teleport, teleport);
|
||||
replay.registerCommand(ReplayCode.UseItem, useItem);
|
||||
replay.registerCommand(ReplayCode.Equip, equip);
|
||||
replay.registerCommand(ReplayCode.Unequip, unequip);
|
||||
async *load(): AsyncIterator<number, void> {
|
||||
await this.initialize();
|
||||
yield* this.loader.start();
|
||||
await this.afterLoad();
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
@ -1,438 +0,0 @@
|
||||
// 测试光环转换器:光环范围与参数选择、属性加成结算、支援光环注册与守卫定位符添加
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { type ITileLocator } from '@motajs/common';
|
||||
import { type IEnemyAttr, type IHeroAttr } from '@user/data-common';
|
||||
import {
|
||||
type IEnemyContext,
|
||||
type IEnemyView,
|
||||
type IReadonlyEnemyHandler,
|
||||
type IEnemyHandler
|
||||
} from '@user/data-system';
|
||||
import {
|
||||
type IEnemy,
|
||||
type IReadonlyEnemy,
|
||||
type ISpecial
|
||||
} from '@user/data-base';
|
||||
import { type ISpecial23Value } from '../special';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
Map.prototype.getOrInsertComputed ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
callback: (key: K) => V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
const value = callback(key);
|
||||
this.set(key, value);
|
||||
return value;
|
||||
};
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
defaultValue: V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
this.set(key, defaultValue);
|
||||
return defaultValue;
|
||||
};
|
||||
});
|
||||
|
||||
interface TestModules {
|
||||
CommonAura: typeof import('../aura').CommonAura;
|
||||
CommonAuraConverter: typeof import('../aura').CommonAuraConverter;
|
||||
GuardAura: typeof import('../aura').GuardAura;
|
||||
GuardAuraConverter: typeof import('../aura').GuardAuraConverter;
|
||||
FullRange: typeof import('@motajs/common').FullRange;
|
||||
RectRange: typeof import('@motajs/common').RectRange;
|
||||
ManhattanRange: typeof import('@motajs/common').ManhattanRange;
|
||||
}
|
||||
|
||||
let modules: TestModules;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
const auraModule = await import('../aura');
|
||||
const motaModule = await import('@motajs/common');
|
||||
modules = {
|
||||
CommonAura: auraModule.CommonAura,
|
||||
CommonAuraConverter: auraModule.CommonAuraConverter,
|
||||
GuardAura: auraModule.GuardAura,
|
||||
GuardAuraConverter: auraModule.GuardAuraConverter,
|
||||
FullRange: motaModule.FullRange,
|
||||
RectRange: motaModule.RectRange,
|
||||
ManhattanRange: motaModule.ManhattanRange
|
||||
};
|
||||
});
|
||||
|
||||
interface FakeEnemy {
|
||||
/** 可写怪物对象 */
|
||||
enemy: IEnemy<IEnemyAttr>;
|
||||
/** 当前怪物属性,供断言使用 */
|
||||
attrs: IEnemyAttr;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只实现光环所需能力的内联假怪物
|
||||
* @param attrs 覆盖的怪物属性
|
||||
*/
|
||||
function createEnemy(attrs: Partial<IEnemyAttr> = {}): FakeEnemy {
|
||||
const values: IEnemyAttr = {
|
||||
hp: 20,
|
||||
atk: 8,
|
||||
def: 5,
|
||||
money: 2,
|
||||
exp: 3,
|
||||
point: 1,
|
||||
guard: new Set(),
|
||||
...attrs
|
||||
};
|
||||
const enemy = {
|
||||
id: 'test-enemy',
|
||||
code: 1,
|
||||
getSpecial: () => null,
|
||||
hasSpecial: () => false,
|
||||
iterateSpecials: () => [],
|
||||
getAttribute: (key: string) => (values as never)[key],
|
||||
cloneAttributes: () => structuredClone(values),
|
||||
clone: () => enemy,
|
||||
addSpecial: () => {},
|
||||
deleteSpecial: () => {},
|
||||
setAttribute: (key: string, value: unknown) => {
|
||||
// 假怪物按动态字符串键读写属性,固定形状的 IEnemyAttr 无法表达字符串索引
|
||||
(values as unknown as Record<string, unknown>)[key] = value;
|
||||
},
|
||||
addAttribute: (key: string, value: number) => {
|
||||
(values as unknown as Record<string, number>)[key] += value;
|
||||
},
|
||||
copyFrom: () => {},
|
||||
saveState: () => ({
|
||||
attrs: structuredClone(values),
|
||||
specials: new Map()
|
||||
}),
|
||||
loadState: () => {}
|
||||
} as never;
|
||||
return { enemy, attrs: values };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只携带代码与数值的内联假特殊属性
|
||||
* @param code 特殊属性代码
|
||||
* @param value 特殊属性数值
|
||||
*/
|
||||
function createSpecial<T>(code: number, value: T): ISpecial<T> {
|
||||
return { code, value } as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只实现视图反查的内联假怪物上下文
|
||||
* @param resolve 根据计算后怪物对象查找视图的回调
|
||||
*/
|
||||
function createContext(
|
||||
resolve?: (
|
||||
enemy: IReadonlyEnemy<IEnemyAttr>
|
||||
) => IEnemyView<IEnemyAttr> | null
|
||||
): IEnemyContext<IEnemyAttr, IHeroAttr> {
|
||||
return {
|
||||
width: 8,
|
||||
height: 8,
|
||||
getViewByComputed: (enemy: IReadonlyEnemy<IEnemyAttr>) =>
|
||||
resolve ? resolve(enemy) : null
|
||||
} as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只用于占据视图位置的内联假视图
|
||||
*/
|
||||
function createView(): IEnemyView<IEnemyAttr> {
|
||||
return {} as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装一个光环转换所需的最小只读信息对象
|
||||
* @param enemy 怪物对象
|
||||
* @param locator 怪物定位符
|
||||
*/
|
||||
function createReadonlyHandler(
|
||||
enemy: IReadonlyEnemy<IEnemyAttr>,
|
||||
locator: ITileLocator
|
||||
): IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr> {
|
||||
return { enemy, locator } as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装一个光环结算所需的最小可写信息对象
|
||||
* @param enemy 可写怪物对象
|
||||
* @param locator 怪物定位符
|
||||
*/
|
||||
function createWritableHandler(
|
||||
enemy: IEnemy<IEnemyAttr>,
|
||||
locator: ITileLocator
|
||||
): IEnemyHandler<IEnemyAttr, IHeroAttr> {
|
||||
return { enemy, locator } as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造一个完整的光环数值
|
||||
* @param overrides 覆盖的光环字段
|
||||
*/
|
||||
function createHalo(overrides: Partial<ISpecial23Value> = {}): ISpecial23Value {
|
||||
return {
|
||||
range: 0,
|
||||
square: false,
|
||||
hpBuff: 0,
|
||||
atkBuff: 0,
|
||||
defBuff: 0,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('CommonAuraConverter', () => {
|
||||
// 验证只有代码 25 的特殊属性会被光环转换器接管
|
||||
it('converts only special code 25', () => {
|
||||
const converter = new modules.CommonAuraConverter();
|
||||
const halo = createSpecial(25, createHalo());
|
||||
const guard = createSpecial(26, undefined);
|
||||
|
||||
expect(converter.shouldConvert(halo)).toBe(true);
|
||||
expect(converter.shouldConvert(guard)).toBe(false);
|
||||
});
|
||||
|
||||
// 验证转换结果为携带怪物、特殊属性与定位符的光环视图
|
||||
it('converts a code 25 special into a common aura', () => {
|
||||
const converter = new modules.CommonAuraConverter();
|
||||
const { enemy } = createEnemy();
|
||||
const locator: ITileLocator = { x: 3, y: 4 };
|
||||
const special = createSpecial(25, createHalo());
|
||||
|
||||
const aura = converter.convert(
|
||||
special,
|
||||
createReadonlyHandler(enemy, locator)
|
||||
);
|
||||
|
||||
expect(aura).toBeInstanceOf(modules.CommonAura);
|
||||
expect(aura.enemy).toBe(enemy);
|
||||
expect(aura.locator).toBe(locator);
|
||||
expect(aura.special).toBe(special);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CommonAura range', () => {
|
||||
// 验证光环范围不大于 0 时使用全图范围且无范围参数
|
||||
it('uses the full range when the halo range is not positive', () => {
|
||||
const { enemy } = createEnemy();
|
||||
const aura = new modules.CommonAura(
|
||||
enemy,
|
||||
createSpecial(25, createHalo({ range: 0 })),
|
||||
{ x: 3, y: 4 }
|
||||
);
|
||||
|
||||
expect(aura.range).toBeInstanceOf(modules.FullRange);
|
||||
expect(aura.getRangeParam()).toBeUndefined();
|
||||
});
|
||||
|
||||
// 验证九宫格光环使用以定位符为中心的矩形范围参数
|
||||
it('uses a centered rect for a square halo', () => {
|
||||
const { enemy } = createEnemy();
|
||||
const aura = new modules.CommonAura(
|
||||
enemy,
|
||||
createSpecial(25, createHalo({ range: 2, square: true })),
|
||||
{ x: 3, y: 4 }
|
||||
);
|
||||
|
||||
expect(aura.range).toBeInstanceOf(modules.RectRange);
|
||||
expect(aura.getRangeParam()).toEqual({ x: 1, y: 2, w: 5, h: 5 });
|
||||
});
|
||||
|
||||
// 验证十字光环使用以定位符为中心的曼哈顿范围参数
|
||||
it('uses a manhattan range for a cross halo', () => {
|
||||
const { enemy } = createEnemy();
|
||||
const aura = new modules.CommonAura(
|
||||
enemy,
|
||||
createSpecial(25, createHalo({ range: 2, square: false })),
|
||||
{ x: 3, y: 4 }
|
||||
);
|
||||
|
||||
expect(aura.range).toBeInstanceOf(modules.ManhattanRange);
|
||||
expect(aura.getRangeParam()).toEqual({ cx: 3, cy: 4, radius: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('CommonAura apply', () => {
|
||||
// 验证生命、攻击与防御加成按基础属性比例取整后加到目标怪物上
|
||||
it('adds floored attribute buffs to the target enemy', () => {
|
||||
const base = createEnemy({ hp: 100, atk: 50, def: 30 });
|
||||
const target = createEnemy({ hp: 20, atk: 8, def: 5 });
|
||||
const aura = new modules.CommonAura(
|
||||
base.enemy,
|
||||
createSpecial(
|
||||
25,
|
||||
createHalo({ hpBuff: 10, atkBuff: 50, defBuff: 20 })
|
||||
),
|
||||
{ x: 0, y: 0 }
|
||||
);
|
||||
|
||||
aura.apply(
|
||||
createWritableHandler(target.enemy, { x: 1, y: 0 }),
|
||||
base.enemy
|
||||
);
|
||||
|
||||
expect(target.attrs.hp).toBe(30);
|
||||
expect(target.attrs.atk).toBe(33);
|
||||
expect(target.attrs.def).toBe(11);
|
||||
});
|
||||
|
||||
// 验证加成为 0 时不修改目标怪物的任何属性
|
||||
it('leaves the target untouched when every buff is zero', () => {
|
||||
const base = createEnemy({ hp: 100, atk: 50, def: 30 });
|
||||
const target = createEnemy({ hp: 20, atk: 8, def: 5 });
|
||||
const aura = new modules.CommonAura(
|
||||
base.enemy,
|
||||
createSpecial(25, createHalo()),
|
||||
{ x: 0, y: 0 }
|
||||
);
|
||||
|
||||
aura.apply(
|
||||
createWritableHandler(target.enemy, { x: 1, y: 0 }),
|
||||
base.enemy
|
||||
);
|
||||
|
||||
expect(target.attrs).toMatchObject({ hp: 20, atk: 8, def: 5 });
|
||||
});
|
||||
|
||||
// 验证光环的优先级与修饰能力声明,且不产生特殊属性修饰器
|
||||
it('reports its priority, capabilities and no special modifier', () => {
|
||||
const { enemy } = createEnemy();
|
||||
const aura = new modules.CommonAura(
|
||||
enemy,
|
||||
createSpecial(25, createHalo()),
|
||||
{ x: 0, y: 0 }
|
||||
);
|
||||
|
||||
expect(aura.priority).toBe(25);
|
||||
expect(aura.couldApplyBase).toBe(true);
|
||||
expect(aura.couldApplySpecial).toBe(false);
|
||||
expect(aura.applySpecial()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GuardAuraConverter', () => {
|
||||
// 验证只有代码 26 的特殊属性会被支援光环转换器接管
|
||||
it('converts only special code 26', () => {
|
||||
const converter = new modules.GuardAuraConverter();
|
||||
|
||||
expect(converter.shouldConvert(createSpecial(26, undefined))).toBe(
|
||||
true
|
||||
);
|
||||
expect(converter.shouldConvert(createSpecial(25, undefined))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
// 验证转换结果为携带上下文、怪物、特殊属性与定位符的支援光环视图
|
||||
it('converts a code 26 special into a guard aura', () => {
|
||||
const converter = new modules.GuardAuraConverter();
|
||||
const { enemy } = createEnemy();
|
||||
const locator: ITileLocator = { x: 3, y: 4 };
|
||||
const special = createSpecial(26, undefined);
|
||||
|
||||
const aura = converter.convert(
|
||||
special,
|
||||
createReadonlyHandler(enemy, locator),
|
||||
createContext(() => createView())
|
||||
);
|
||||
|
||||
expect(aura).toBeInstanceOf(modules.GuardAura);
|
||||
expect(aura.enemy).toBe(enemy);
|
||||
expect(aura.locator).toBe(locator);
|
||||
expect(aura.special).toBe(special);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GuardAura', () => {
|
||||
// 验证支援光环的范围参数为以定位符为中心的 3x3 矩形
|
||||
it('uses a 3x3 rect centered on the locator', () => {
|
||||
const { enemy } = createEnemy();
|
||||
const aura = new modules.GuardAura(
|
||||
createContext(() => createView()),
|
||||
enemy,
|
||||
createSpecial(26, undefined),
|
||||
{ x: 3, y: 4 }
|
||||
);
|
||||
|
||||
expect(aura.getRangeParam()).toEqual({ x: 2, y: 3, w: 3, h: 3 });
|
||||
});
|
||||
|
||||
// 验证支援光环把来源定位符添加到周围怪物的支援集合
|
||||
it('adds the source locator to a different target enemy guard set', () => {
|
||||
const source = createEnemy();
|
||||
const target = createEnemy();
|
||||
const sourceLocator: ITileLocator = { x: 1, y: 1 };
|
||||
const context = createContext(() => createView());
|
||||
const aura = new modules.GuardAura(
|
||||
context,
|
||||
source.enemy,
|
||||
createSpecial(26, undefined),
|
||||
sourceLocator
|
||||
);
|
||||
|
||||
aura.apply(createWritableHandler(target.enemy, { x: 2, y: 1 }));
|
||||
|
||||
expect(
|
||||
(target.attrs.guard as Set<ITileLocator>).has(sourceLocator)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// 验证支援光环不会把来源定位符添加到自身
|
||||
it('does not add the source locator to the source itself', () => {
|
||||
const source = createEnemy();
|
||||
const sourceLocator: ITileLocator = { x: 1, y: 1 };
|
||||
const aura = new modules.GuardAura(
|
||||
createContext(() => createView()),
|
||||
source.enemy,
|
||||
createSpecial(26, undefined),
|
||||
sourceLocator
|
||||
);
|
||||
|
||||
aura.apply(createWritableHandler(source.enemy, sourceLocator));
|
||||
|
||||
expect(source.attrs.guard.size).toBe(0);
|
||||
});
|
||||
|
||||
// 验证来源视图不存在时不添加任何支援定位符
|
||||
it('does nothing when the source view is missing', () => {
|
||||
const source = createEnemy();
|
||||
const target = createEnemy();
|
||||
const aura = new modules.GuardAura(
|
||||
createContext(() => null),
|
||||
source.enemy,
|
||||
createSpecial(26, undefined),
|
||||
{ x: 1, y: 1 }
|
||||
);
|
||||
|
||||
aura.apply(createWritableHandler(target.enemy, { x: 2, y: 1 }));
|
||||
|
||||
expect(target.attrs.guard.size).toBe(0);
|
||||
});
|
||||
|
||||
// 验证支援光环的优先级与修饰能力声明,且不产生特殊属性修饰器
|
||||
it('reports its priority, capabilities and no special modifier', () => {
|
||||
const { enemy } = createEnemy();
|
||||
const aura = new modules.GuardAura(
|
||||
createContext(() => createView()),
|
||||
enemy,
|
||||
createSpecial(26, undefined),
|
||||
{ x: 1, y: 1 }
|
||||
);
|
||||
|
||||
expect(aura.priority).toBe(26);
|
||||
expect(aura.couldApplyBase).toBe(true);
|
||||
expect(aura.couldApplySpecial).toBe(false);
|
||||
expect(aura.range).toBeInstanceOf(modules.RectRange);
|
||||
expect(aura.applySpecial()).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -1,554 +0,0 @@
|
||||
// 测试内置伤害计算器:基础伤害、无敌、魔攻、连击、多段、支援、先攻、破甲、反击、净化、吸血、负伤、固伤、仇恨与临界上界
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { type ITileLocator } from '@motajs/common';
|
||||
import { type IEnemyAttr, type IHeroAttr } from '@user/data-common';
|
||||
import {
|
||||
type IEnemyContext,
|
||||
type IEnemyView,
|
||||
type IReadonlyEnemyHandler
|
||||
} from '@user/data-system';
|
||||
import {
|
||||
type IEnemy,
|
||||
type IReadonlyHeroAttribute,
|
||||
type ISpecial,
|
||||
type IStateBase
|
||||
} from '@user/data-base';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
Map.prototype.getOrInsertComputed ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
callback: (key: K) => V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
const value = callback(key);
|
||||
this.set(key, value);
|
||||
return value;
|
||||
};
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
defaultValue: V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
this.set(key, defaultValue);
|
||||
return defaultValue;
|
||||
};
|
||||
});
|
||||
|
||||
interface TestModules {
|
||||
MainDamageCalculator: typeof import('../calculator').MainDamageCalculator;
|
||||
logger: typeof import('@motajs/common').logger;
|
||||
}
|
||||
|
||||
let modules: TestModules;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
const calculatorModule = await import('../calculator');
|
||||
const motaModule = await import('@motajs/common');
|
||||
modules = {
|
||||
MainDamageCalculator: calculatorModule.MainDamageCalculator,
|
||||
logger: motaModule.logger
|
||||
};
|
||||
});
|
||||
|
||||
interface FakeEnemy {
|
||||
/** 可写怪物对象 */
|
||||
enemy: IEnemy<IEnemyAttr>;
|
||||
/** 当前怪物属性,供断言与后续修改 */
|
||||
attrs: IEnemyAttr;
|
||||
}
|
||||
|
||||
interface FakeEnemyOptions {
|
||||
/** 覆盖的怪物属性 */
|
||||
attrs?: Partial<IEnemyAttr>;
|
||||
/** 怪物特殊属性表,键为代码,值为该特殊属性的数值 */
|
||||
specials?: Map<number, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只实现计算器所需能力的内联假怪物,特殊属性以代码表驱动
|
||||
* @param options 怪物属性与特殊属性配置
|
||||
*/
|
||||
function createEnemy(options: FakeEnemyOptions = {}): FakeEnemy {
|
||||
const specials = options.specials ?? new Map<number, unknown>();
|
||||
const attrs: IEnemyAttr = {
|
||||
hp: 20,
|
||||
atk: 8,
|
||||
def: 5,
|
||||
money: 2,
|
||||
exp: 3,
|
||||
point: 1,
|
||||
guard: new Set(),
|
||||
...options.attrs
|
||||
};
|
||||
const enemy = {
|
||||
id: 'test-enemy',
|
||||
code: 1,
|
||||
getSpecial: (code: number): ISpecial<any> | null =>
|
||||
specials.has(code)
|
||||
? ({
|
||||
code,
|
||||
value: specials.get(code),
|
||||
deepEqualsTo: () => false
|
||||
} as unknown as ISpecial<any>)
|
||||
: null,
|
||||
hasSpecial: (code: number) => specials.has(code),
|
||||
iterateSpecials: () => [],
|
||||
getAttribute: (key: string) => (attrs as never)[key],
|
||||
cloneAttributes: () => structuredClone(attrs),
|
||||
clone: () => enemy,
|
||||
addSpecial: () => {},
|
||||
deleteSpecial: () => {},
|
||||
setAttribute: (key: string, value: unknown) => {
|
||||
// 假怪物按动态字符串键读写属性,固定形状的 IEnemyAttr 无法表达字符串索引
|
||||
(attrs as unknown as Record<string, unknown>)[key] = value;
|
||||
},
|
||||
addAttribute: (key: string, value: number) => {
|
||||
(attrs as unknown as Record<string, number>)[key] += value;
|
||||
},
|
||||
copyFrom: () => {},
|
||||
saveState: () => ({
|
||||
attrs: structuredClone(attrs),
|
||||
specials: new Map()
|
||||
}),
|
||||
loadState: () => {}
|
||||
} as IEnemy<IEnemyAttr>;
|
||||
return { enemy, attrs };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只提供基础与最终属性读取的内联假勇士
|
||||
* @param attrs 覆盖的勇士属性
|
||||
*/
|
||||
function createHero(
|
||||
attrs: Partial<IHeroAttr> = {}
|
||||
): IReadonlyHeroAttribute<IHeroAttr> {
|
||||
const values: IHeroAttr = {
|
||||
name: 'hero',
|
||||
hp: 100,
|
||||
hpmax: 100,
|
||||
atk: 20,
|
||||
def: 5,
|
||||
mdef: 0,
|
||||
mana: 0,
|
||||
manamax: 0,
|
||||
money: 0,
|
||||
exp: 0,
|
||||
...attrs
|
||||
};
|
||||
return {
|
||||
getBaseAttribute: (name: string) => (values as never)[name],
|
||||
getFinalAttribute: (name: string) => (values as never)[name]
|
||||
} as unknown as IReadonlyHeroAttribute<IHeroAttr>;
|
||||
}
|
||||
|
||||
interface FakeStateOptions {
|
||||
/** 十字架数量,未配置表示背包中没有十字架 */
|
||||
cross?: number;
|
||||
/** flag 字段值表 */
|
||||
flags?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只实现计算器所需状态读取的内联假状态对象
|
||||
* @param options 十字架数量与 flag 字段配置
|
||||
*/
|
||||
function createState(options: FakeStateOptions = {}): IStateBase {
|
||||
const flags = options.flags ?? {};
|
||||
return {
|
||||
hero: {
|
||||
items: {
|
||||
getItemState: (id: string) =>
|
||||
id === 'cross' && options.cross !== undefined
|
||||
? { count: options.cross }
|
||||
: null
|
||||
}
|
||||
},
|
||||
flags: {
|
||||
getFieldValueDefaults: (name: string, defaultValue: unknown) =>
|
||||
name in flags ? flags[name] : defaultValue
|
||||
}
|
||||
} as IStateBase;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只实现定位符查找的内联假地图上下文
|
||||
* @param resolve 按定位符查找怪物视图的回调
|
||||
*/
|
||||
function createContext(
|
||||
resolve?: (locator: ITileLocator) => IEnemyView<IEnemyAttr> | null
|
||||
): IEnemyContext<IEnemyAttr, IHeroAttr> {
|
||||
return {
|
||||
width: 8,
|
||||
height: 8,
|
||||
getEnemyByLocator: (locator: ITileLocator) =>
|
||||
resolve ? resolve(locator) : null
|
||||
} as IEnemyContext<IEnemyAttr, IHeroAttr>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将一个计算后怪物对象包装为只提供该对象读取的假视图
|
||||
* @param computed 计算后怪物对象
|
||||
*/
|
||||
function createView(computed: IEnemy<IEnemyAttr>): IEnemyView<IEnemyAttr> {
|
||||
return {
|
||||
getComputedEnemy: () => computed
|
||||
} as unknown as IEnemyView<IEnemyAttr>;
|
||||
}
|
||||
|
||||
interface HandlerOptions {
|
||||
/** 怪物对象 */
|
||||
enemy: IEnemy<IEnemyAttr>;
|
||||
/** 勇士属性 */
|
||||
hero?: IReadonlyHeroAttribute<IHeroAttr>;
|
||||
/** 状态对象 */
|
||||
state?: IStateBase;
|
||||
/** 地图上下文 */
|
||||
context?: IEnemyContext<IEnemyAttr, IHeroAttr>;
|
||||
/** 怪物定位符 */
|
||||
locator?: ITileLocator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装一个计算器所需的最小只读信息对象
|
||||
* @param options 信息对象各字段
|
||||
*/
|
||||
function createHandler(
|
||||
options: HandlerOptions
|
||||
): IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr> {
|
||||
return {
|
||||
enemy: options.enemy,
|
||||
context: options.context ?? createContext(),
|
||||
locator: options.locator ?? { x: 0, y: 0 },
|
||||
hero: options.hero ?? createHero(),
|
||||
state: options.state ?? createState()
|
||||
} as IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个带特殊属性代码表的怪物属性配置
|
||||
* @param entries 特殊属性代码与数值对
|
||||
*/
|
||||
function specialsOf(entries: Array<[number, unknown]>): Map<number, unknown> {
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个计算器实例
|
||||
*/
|
||||
function createCalculator(): InstanceType<TestModules['MainDamageCalculator']> {
|
||||
return new modules.MainDamageCalculator();
|
||||
}
|
||||
|
||||
describe('MainDamageCalculator base and defeat branches', () => {
|
||||
// 验证无特殊属性时按攻防差计算每轮伤害与回合数
|
||||
it('computes damage and turns from the attack and defense difference', () => {
|
||||
const { enemy } = createEnemy();
|
||||
const calculator = createCalculator();
|
||||
|
||||
const info = calculator.calculate(createHandler({ enemy }));
|
||||
|
||||
expect(info).toEqual({ damage: 3, turn: 2 });
|
||||
});
|
||||
|
||||
// 验证勇士无法破防时返回无敌伤害与 0 回合
|
||||
it('returns infinite damage when the hero cannot break the defense', () => {
|
||||
const { enemy } = createEnemy({ attrs: { def: 20 } });
|
||||
const calculator = createCalculator();
|
||||
|
||||
const info = calculator.calculate(createHandler({ enemy }));
|
||||
|
||||
expect(info).toEqual({ damage: Infinity, turn: 0 });
|
||||
});
|
||||
|
||||
// 验证无敌怪物在没有十字架时不可战胜,持有十字架后恢复正常计算
|
||||
it('blocks 无敌 without a cross and allows it with one', () => {
|
||||
const { enemy } = createEnemy({
|
||||
specials: specialsOf([[20, undefined]])
|
||||
});
|
||||
const calculator = createCalculator();
|
||||
|
||||
const blocked = calculator.calculate(createHandler({ enemy }));
|
||||
const allowed = calculator.calculate(
|
||||
createHandler({ enemy, state: createState({ cross: 1 }) })
|
||||
);
|
||||
|
||||
expect(blocked).toEqual({ damage: Infinity, turn: 0 });
|
||||
expect(allowed).toEqual({ damage: 3, turn: 2 });
|
||||
});
|
||||
|
||||
// 验证魔攻怪物的每轮伤害不减免勇士防御
|
||||
it('ignores hero defense for 魔攻', () => {
|
||||
const { enemy } = createEnemy({
|
||||
specials: specialsOf([[2, undefined]])
|
||||
});
|
||||
const calculator = createCalculator();
|
||||
|
||||
const info = calculator.calculate(createHandler({ enemy }));
|
||||
|
||||
expect(info).toEqual({ damage: 8, turn: 2 });
|
||||
});
|
||||
|
||||
// 验证 2连击与 3连击分别把每轮伤害乘以 2 和 3
|
||||
it('multiplies enemy damage for 2连击 and 3连击', () => {
|
||||
const calculator = createCalculator();
|
||||
const double = createEnemy({ specials: specialsOf([[4, undefined]]) });
|
||||
const triple = createEnemy({ specials: specialsOf([[5, undefined]]) });
|
||||
|
||||
expect(
|
||||
calculator.calculate(createHandler({ enemy: double.enemy }))
|
||||
).toEqual({ damage: 6, turn: 2 });
|
||||
expect(
|
||||
calculator.calculate(createHandler({ enemy: triple.enemy }))
|
||||
).toEqual({ damage: 9, turn: 2 });
|
||||
});
|
||||
|
||||
// 验证多段伤害按特殊属性数值倍乘每轮伤害
|
||||
it('multiplies enemy damage by the 多段 value', () => {
|
||||
const { enemy } = createEnemy({ specials: specialsOf([[6, 4]]) });
|
||||
const calculator = createCalculator();
|
||||
|
||||
const info = calculator.calculate(createHandler({ enemy }));
|
||||
|
||||
expect(info).toEqual({ damage: 12, turn: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('MainDamageCalculator support and additive branches', () => {
|
||||
// 验证支援怪会递归累加其回合与伤害,且同一支援怪只被计算一次
|
||||
it('adds the guard turn and damage through recursion', () => {
|
||||
const guard = createEnemy();
|
||||
const { enemy } = createEnemy({
|
||||
attrs: { guard: new Set<ITileLocator>([{ x: 1, y: 0 }]) }
|
||||
});
|
||||
const calls: string[] = [];
|
||||
const context = createContext(locator => {
|
||||
calls.push(`${locator.x},${locator.y}`);
|
||||
return locator.x === 1 ? createView(guard.enemy) : null;
|
||||
});
|
||||
const calculator = createCalculator();
|
||||
|
||||
const info = calculator.calculate(createHandler({ enemy, context }));
|
||||
|
||||
expect(info).toEqual({ damage: 12, turn: 4 });
|
||||
expect(calls).toEqual(['1,0']);
|
||||
});
|
||||
|
||||
// 验证同一计算器连续两次顶层计算互不影响,支援标记不会泄漏
|
||||
it('does not leak the in-guard flag across top-level calls', () => {
|
||||
const guard = createEnemy();
|
||||
const { enemy } = createEnemy({
|
||||
attrs: { guard: new Set<ITileLocator>([{ x: 1, y: 0 }]) }
|
||||
});
|
||||
const context = createContext(locator =>
|
||||
locator.x === 1 ? createView(guard.enemy) : null
|
||||
);
|
||||
const calculator = createCalculator();
|
||||
const handler = createHandler({ enemy, context });
|
||||
|
||||
const first = calculator.calculate(handler);
|
||||
const second = calculator.calculate(handler);
|
||||
|
||||
expect(first).toEqual({ damage: 12, turn: 4 });
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
// 验证支援怪不存在时告警 137 并跳过该支援
|
||||
it('warns 137 when a guard locator has no enemy', () => {
|
||||
const { enemy } = createEnemy({
|
||||
attrs: { guard: new Set<ITileLocator>([{ x: 2, y: 0 }]) }
|
||||
});
|
||||
const calculator = createCalculator();
|
||||
|
||||
const result = modules.logger.catch(() =>
|
||||
calculator.calculate(createHandler({ enemy }))
|
||||
);
|
||||
|
||||
expect(result.ret).toEqual({ damage: 3, turn: 2 });
|
||||
expect(result.info.map(item => item.code)).toContain(137);
|
||||
});
|
||||
|
||||
// 验证先攻会额外附加一次每轮伤害
|
||||
it('adds one enemy hit for 先攻', () => {
|
||||
const { enemy } = createEnemy({
|
||||
specials: specialsOf([[1, undefined]])
|
||||
});
|
||||
const calculator = createCalculator();
|
||||
|
||||
const info = calculator.calculate(createHandler({ enemy }));
|
||||
|
||||
expect(info).toEqual({ damage: 6, turn: 2 });
|
||||
});
|
||||
|
||||
// 验证破甲按比例附加勇士防御作为伤害
|
||||
it('adds a share of hero defense for 破甲', () => {
|
||||
const { enemy } = createEnemy({ specials: specialsOf([[7, 100]]) });
|
||||
const calculator = createCalculator();
|
||||
|
||||
const info = calculator.calculate(createHandler({ enemy }));
|
||||
|
||||
expect(info).toEqual({ damage: 8, turn: 2 });
|
||||
});
|
||||
|
||||
// 验证伤害最终向下取整
|
||||
it('floors the final damage value', () => {
|
||||
const { enemy } = createEnemy({ specials: specialsOf([[7, 33]]) });
|
||||
const calculator = createCalculator();
|
||||
|
||||
const info = calculator.calculate(createHandler({ enemy }));
|
||||
|
||||
expect(info).toEqual({ damage: 4, turn: 2 });
|
||||
});
|
||||
|
||||
// 验证反击按比例把勇士攻击附加到每轮伤害
|
||||
it('adds a share of hero attack for 反击', () => {
|
||||
const { enemy } = createEnemy({ specials: specialsOf([[8, 50]]) });
|
||||
const calculator = createCalculator();
|
||||
|
||||
const info = calculator.calculate(createHandler({ enemy }));
|
||||
|
||||
expect(info).toEqual({ damage: 13, turn: 2 });
|
||||
});
|
||||
|
||||
// 验证净化按倍数附加勇士魔防,且随后的减伤仍扣除一次魔防
|
||||
it('adds hero mdef for 净化 and then subtracts it once', () => {
|
||||
const { enemy } = createEnemy({ specials: specialsOf([[9, 2]]) });
|
||||
const calculator = createCalculator();
|
||||
|
||||
const info = calculator.calculate(
|
||||
createHandler({ enemy, hero: createHero({ mdef: 3 }) })
|
||||
);
|
||||
|
||||
expect(info).toEqual({ damage: 6, turn: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('MainDamageCalculator vampire and bypass branches', () => {
|
||||
// 验证吸血按勇士生命上限比例附加伤害,不回复自身时不改变怪物血量
|
||||
it('adds vampire damage without healing the enemy by default', () => {
|
||||
const { enemy } = createEnemy({
|
||||
specials: specialsOf([[11, { vampire: 10, add: false }]])
|
||||
});
|
||||
const calculator = createCalculator();
|
||||
|
||||
const info = calculator.calculate(
|
||||
createHandler({ enemy, hero: createHero({ hp: 200 }) })
|
||||
);
|
||||
|
||||
expect(info).toEqual({ damage: 23, turn: 2 });
|
||||
});
|
||||
|
||||
// 验证开启回复后吸血数值加到怪物血量上并延长回合数
|
||||
it('adds vampire damage to the enemy health when add is true', () => {
|
||||
const { enemy } = createEnemy({
|
||||
specials: specialsOf([[11, { vampire: 10, add: true }]])
|
||||
});
|
||||
const calculator = createCalculator();
|
||||
|
||||
const info = calculator.calculate(
|
||||
createHandler({ enemy, hero: createHero({ hp: 200 }) })
|
||||
);
|
||||
|
||||
expect(info).toEqual({ damage: 26, turn: 3 });
|
||||
});
|
||||
|
||||
// 验证未开启负伤时负伤害被夹到 0
|
||||
it('clamps negative damage to zero when negative damage is disabled', () => {
|
||||
const { enemy } = createEnemy({ attrs: { atk: 0 } });
|
||||
const calculator = createCalculator();
|
||||
|
||||
const info = calculator.calculate(
|
||||
createHandler({ enemy, hero: createHero({ mdef: 5 }) })
|
||||
);
|
||||
|
||||
expect(info).toEqual({ damage: 0, turn: 2 });
|
||||
});
|
||||
|
||||
// 验证开启负伤后负伤害被保留
|
||||
it('keeps negative damage when negative damage is enabled', () => {
|
||||
const { enemy } = createEnemy({ attrs: { atk: 0 } });
|
||||
const calculator = createCalculator();
|
||||
|
||||
const info = calculator.calculate(
|
||||
createHandler({
|
||||
enemy,
|
||||
hero: createHero({ mdef: 5 }),
|
||||
state: createState({ flags: { enableNegativeDamage: true } })
|
||||
})
|
||||
);
|
||||
|
||||
expect(info).toEqual({ damage: -5, turn: 2 });
|
||||
});
|
||||
|
||||
// 验证固伤在扣除魔防之后附加,不受魔防影响
|
||||
it('adds 固伤 after the mdef subtraction', () => {
|
||||
const { enemy } = createEnemy({ specials: specialsOf([[22, 7]]) });
|
||||
const calculator = createCalculator();
|
||||
|
||||
const info = calculator.calculate(
|
||||
createHandler({ enemy, hero: createHero({ mdef: 2 }) })
|
||||
);
|
||||
|
||||
expect(info).toEqual({ damage: 8, turn: 2 });
|
||||
});
|
||||
|
||||
// 验证仇恨按 flag 字段值附加伤害且不受魔防影响
|
||||
it('adds the hatred flag value for 仇恨', () => {
|
||||
const { enemy } = createEnemy({
|
||||
specials: specialsOf([[17, undefined]])
|
||||
});
|
||||
const calculator = createCalculator();
|
||||
|
||||
const info = calculator.calculate(
|
||||
createHandler({
|
||||
enemy,
|
||||
hero: createHero({ mdef: 2 }),
|
||||
state: createState({ flags: { hatred: 5 } })
|
||||
})
|
||||
);
|
||||
|
||||
expect(info).toEqual({ damage: 6, turn: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('MainDamageCalculator critical limit', () => {
|
||||
// 验证攻击临界上界为怪物防御加生命,坚固怪物则为无穷
|
||||
it('returns def plus hp for atk, and Infinity for 坚固', () => {
|
||||
const normal = createEnemy();
|
||||
const solid = createEnemy({ specials: specialsOf([[3, undefined]]) });
|
||||
const calculator = createCalculator();
|
||||
|
||||
expect(
|
||||
calculator.getCriticalLimit(
|
||||
createHandler({ enemy: normal.enemy }),
|
||||
'atk'
|
||||
)
|
||||
).toBe(25);
|
||||
expect(
|
||||
calculator.getCriticalLimit(
|
||||
createHandler({ enemy: solid.enemy }),
|
||||
'atk'
|
||||
)
|
||||
).toBe(Infinity);
|
||||
});
|
||||
|
||||
// 验证非攻击属性直接返回勇士对应最终属性
|
||||
it('returns the hero final attribute for other attributes', () => {
|
||||
const { enemy } = createEnemy();
|
||||
const calculator = createCalculator();
|
||||
|
||||
const limit = calculator.getCriticalLimit(
|
||||
createHandler({ enemy, hero: createHero({ def: 7 }) }),
|
||||
'def'
|
||||
);
|
||||
|
||||
expect(limit).toBe(7);
|
||||
});
|
||||
});
|
||||
@ -1,153 +0,0 @@
|
||||
// 测试怪物比较器:基础属性全部一致且特殊属性集合深比较相等时判定为相同
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { type IEnemyAttr } from '@user/data-common';
|
||||
import { type IReadonlyEnemy, type ISpecial } from '@user/data-base';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
Map.prototype.getOrInsertComputed ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
callback: (key: K) => V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
const value = callback(key);
|
||||
this.set(key, value);
|
||||
return value;
|
||||
};
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
defaultValue: V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
this.set(key, defaultValue);
|
||||
return defaultValue;
|
||||
};
|
||||
});
|
||||
|
||||
interface TestModules {
|
||||
MainEnemyComparer: typeof import('../comparer').MainEnemyComparer;
|
||||
}
|
||||
|
||||
let modules: TestModules;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
const comparerModule = await import('../comparer');
|
||||
modules = {
|
||||
MainEnemyComparer: comparerModule.MainEnemyComparer
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建一个按代码与数值做深比较的内联假特殊属性
|
||||
* @param code 特殊属性代码
|
||||
* @param value 特殊属性数值
|
||||
*/
|
||||
function createSpecial(code: number, value: unknown): ISpecial<any> {
|
||||
return {
|
||||
code,
|
||||
value,
|
||||
deepEqualsTo: (other: ISpecial<any>) =>
|
||||
other.code === code &&
|
||||
JSON.stringify(other.value) === JSON.stringify(value)
|
||||
} as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只实现比较器所需读取能力的内联假怪物
|
||||
* @param attrs 覆盖的怪物属性
|
||||
* @param specials 怪物拥有的特殊属性列表
|
||||
*/
|
||||
function createEnemy(
|
||||
attrs: Partial<IEnemyAttr> = {},
|
||||
specials: ISpecial<any>[] = []
|
||||
): IReadonlyEnemy<IEnemyAttr> {
|
||||
const values: IEnemyAttr = {
|
||||
hp: 20,
|
||||
atk: 8,
|
||||
def: 5,
|
||||
money: 2,
|
||||
exp: 3,
|
||||
point: 1,
|
||||
guard: new Set(),
|
||||
...attrs
|
||||
};
|
||||
return {
|
||||
id: 'test-enemy',
|
||||
code: 1,
|
||||
getSpecial: (code: number) =>
|
||||
specials.find(special => special.code === code) ?? null,
|
||||
hasSpecial: (code: number) =>
|
||||
specials.some(special => special.code === code),
|
||||
iterateSpecials: () => specials,
|
||||
getAttribute: (key: string) => (values as never)[key],
|
||||
cloneAttributes: () => structuredClone(values),
|
||||
clone: () => createEnemy(attrs, specials)
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe('MainEnemyComparer', () => {
|
||||
// 验证基础属性与特殊属性都一致时判定为相同
|
||||
it('returns true for identical attributes and specials', () => {
|
||||
const comparer = new modules.MainEnemyComparer();
|
||||
const enemyA = createEnemy({}, [createSpecial(6, 3)]);
|
||||
const enemyB = createEnemy({}, [createSpecial(6, 3)]);
|
||||
|
||||
expect(comparer.compare(enemyA, enemyB)).toBe(true);
|
||||
});
|
||||
|
||||
// 验证任一基础属性不同都会判定为不同
|
||||
it('returns false when any base attribute differs', () => {
|
||||
const comparer = new modules.MainEnemyComparer();
|
||||
const base = createEnemy();
|
||||
const cases: Array<[keyof IEnemyAttr, number]> = [
|
||||
['hp', 21],
|
||||
['atk', 9],
|
||||
['def', 6],
|
||||
['money', 3],
|
||||
['exp', 4],
|
||||
['point', 2]
|
||||
];
|
||||
|
||||
for (const [key, value] of cases) {
|
||||
const other = createEnemy({ [key]: value });
|
||||
expect(comparer.compare(base, other)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
// 验证特殊属性数量不同时判定为不同
|
||||
it('returns false when the special counts differ', () => {
|
||||
const comparer = new modules.MainEnemyComparer();
|
||||
const enemyA = createEnemy({}, [createSpecial(6, 3)]);
|
||||
const enemyB = createEnemy({}, [
|
||||
createSpecial(6, 3),
|
||||
createSpecial(7, 0)
|
||||
]);
|
||||
|
||||
expect(comparer.compare(enemyA, enemyB)).toBe(false);
|
||||
});
|
||||
|
||||
// 验证特殊属性代码集合不同时判定为不同
|
||||
it('returns false when the special codes differ', () => {
|
||||
const comparer = new modules.MainEnemyComparer();
|
||||
const enemyA = createEnemy({}, [createSpecial(6, 3)]);
|
||||
const enemyB = createEnemy({}, [createSpecial(7, 3)]);
|
||||
|
||||
expect(comparer.compare(enemyA, enemyB)).toBe(false);
|
||||
});
|
||||
|
||||
// 验证同代码特殊属性数值不同时判定为不同
|
||||
it('returns false when a special deep comparison fails', () => {
|
||||
const comparer = new modules.MainEnemyComparer();
|
||||
const enemyA = createEnemy({}, [createSpecial(6, 3)]);
|
||||
const enemyB = createEnemy({}, [createSpecial(6, 5)]);
|
||||
|
||||
expect(comparer.compare(enemyA, enemyB)).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -1,189 +0,0 @@
|
||||
// 测试怪物最终效果:坚固把防御提升到勇士攻击减一、模仿复制勇士攻防、无相关特殊属性时保持不变
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { type IEnemyAttr, type IHeroAttr } from '@user/data-common';
|
||||
import { type IEnemyHandler } from '@user/data-system';
|
||||
import { type IEnemy, type IReadonlyHeroAttribute } from '@user/data-base';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
Map.prototype.getOrInsertComputed ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
callback: (key: K) => V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
const value = callback(key);
|
||||
this.set(key, value);
|
||||
return value;
|
||||
};
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
defaultValue: V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
this.set(key, defaultValue);
|
||||
return defaultValue;
|
||||
};
|
||||
});
|
||||
|
||||
interface TestModules {
|
||||
MainEnemyFinalEffect: typeof import('../final').MainEnemyFinalEffect;
|
||||
}
|
||||
|
||||
let modules: TestModules;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
const finalModule = await import('../final');
|
||||
modules = {
|
||||
MainEnemyFinalEffect: finalModule.MainEnemyFinalEffect
|
||||
};
|
||||
});
|
||||
|
||||
interface FakeEnemy {
|
||||
/** 可写怪物对象 */
|
||||
enemy: IEnemy<IEnemyAttr>;
|
||||
/** 当前怪物属性,供断言使用 */
|
||||
attrs: IEnemyAttr;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只实现最终效果所需能力的内联假怪物
|
||||
* @param attrs 覆盖的怪物属性
|
||||
* @param specials 怪物拥有的特殊属性代码
|
||||
*/
|
||||
function createEnemy(
|
||||
attrs: Partial<IEnemyAttr> = {},
|
||||
specials: number[] = []
|
||||
): FakeEnemy {
|
||||
const values: IEnemyAttr = {
|
||||
hp: 20,
|
||||
atk: 8,
|
||||
def: 5,
|
||||
money: 2,
|
||||
exp: 3,
|
||||
point: 1,
|
||||
guard: new Set(),
|
||||
...attrs
|
||||
};
|
||||
const enemy = {
|
||||
id: 'test-enemy',
|
||||
code: 1,
|
||||
getSpecial: () => null,
|
||||
hasSpecial: (code: number) => specials.includes(code),
|
||||
iterateSpecials: () => [],
|
||||
getAttribute: (key: string) => (values as never)[key],
|
||||
cloneAttributes: () => structuredClone(values),
|
||||
clone: () => enemy,
|
||||
addSpecial: () => {},
|
||||
deleteSpecial: () => {},
|
||||
setAttribute: (key: string, value: unknown) => {
|
||||
// 假怪物按动态字符串键读写属性,固定形状的 IEnemyAttr 无法表达字符串索引
|
||||
(values as unknown as Record<string, unknown>)[key] = value;
|
||||
},
|
||||
addAttribute: (key: string, value: number) => {
|
||||
(values as unknown as Record<string, number>)[key] += value;
|
||||
},
|
||||
copyFrom: () => {},
|
||||
saveState: () => ({
|
||||
attrs: structuredClone(values),
|
||||
specials: new Map()
|
||||
}),
|
||||
loadState: () => {}
|
||||
} as never;
|
||||
return { enemy, attrs: values };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只提供最终属性读取的内联假勇士
|
||||
* @param attrs 覆盖的勇士属性
|
||||
*/
|
||||
function createHero(
|
||||
attrs: Partial<IHeroAttr> = {}
|
||||
): IReadonlyHeroAttribute<IHeroAttr> {
|
||||
const values: IHeroAttr = {
|
||||
name: 'hero',
|
||||
hp: 100,
|
||||
hpmax: 100,
|
||||
atk: 20,
|
||||
def: 7,
|
||||
mdef: 0,
|
||||
mana: 0,
|
||||
manamax: 0,
|
||||
money: 0,
|
||||
exp: 0,
|
||||
...attrs
|
||||
};
|
||||
return {
|
||||
getBaseAttribute: (name: string) => (values as never)[name],
|
||||
getFinalAttribute: (name: string) => (values as never)[name]
|
||||
} as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装一个最终效果所需的最小信息对象
|
||||
* @param enemy 怪物对象
|
||||
* @param hero 勇士属性对象
|
||||
*/
|
||||
function createHandler(
|
||||
enemy: IEnemy<IEnemyAttr>,
|
||||
hero: IReadonlyHeroAttribute<IHeroAttr> = createHero()
|
||||
): IEnemyHandler<IEnemyAttr, IHeroAttr> {
|
||||
return { enemy, hero } as never;
|
||||
}
|
||||
|
||||
describe('MainEnemyFinalEffect', () => {
|
||||
// 验证最终效果的优先级为 0
|
||||
it('reports priority 0', () => {
|
||||
const effect = new modules.MainEnemyFinalEffect();
|
||||
|
||||
expect(effect.priority).toBe(0);
|
||||
});
|
||||
|
||||
// 验证坚固把低于勇士攻击减一的防御提升到该值
|
||||
it('raises defense to hero attack minus one for 坚固', () => {
|
||||
const { enemy, attrs } = createEnemy({ def: 5 }, [3]);
|
||||
const effect = new modules.MainEnemyFinalEffect();
|
||||
|
||||
effect.apply(createHandler(enemy));
|
||||
|
||||
expect(attrs.def).toBe(19);
|
||||
});
|
||||
|
||||
// 验证坚固不会降低已高于勇士攻击减一的防御
|
||||
it('keeps a defense already above hero attack minus one for 坚固', () => {
|
||||
const { enemy, attrs } = createEnemy({ def: 25 }, [3]);
|
||||
const effect = new modules.MainEnemyFinalEffect();
|
||||
|
||||
effect.apply(createHandler(enemy));
|
||||
|
||||
expect(attrs.def).toBe(25);
|
||||
});
|
||||
|
||||
// 验证模仿把怪物的攻击与防御改成勇士的最终攻击与防御
|
||||
it('copies hero attack and defense for 模仿', () => {
|
||||
const { enemy, attrs } = createEnemy({ atk: 3, def: 3 }, [10]);
|
||||
const effect = new modules.MainEnemyFinalEffect();
|
||||
|
||||
effect.apply(createHandler(enemy));
|
||||
|
||||
expect(attrs.atk).toBe(20);
|
||||
expect(attrs.def).toBe(7);
|
||||
});
|
||||
|
||||
// 验证没有相关特殊属性时怪物的攻击与防御保持不变
|
||||
it('leaves enemies without the effect specials untouched', () => {
|
||||
const { enemy, attrs } = createEnemy({ atk: 3, def: 3 }, [1, 2]);
|
||||
const effect = new modules.MainEnemyFinalEffect();
|
||||
|
||||
effect.apply(createHandler(enemy));
|
||||
|
||||
expect(attrs.atk).toBe(3);
|
||||
expect(attrs.def).toBe(3);
|
||||
});
|
||||
});
|
||||
@ -1,592 +0,0 @@
|
||||
// 测试地图伤害:领域/阻击/激光/夹击/捕捉五种视图、转换器按特殊属性组装视图、合并器求和与额外信息并集
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { type ITileLocator } from '@motajs/common';
|
||||
import { type IEnemyAttr, type IHeroAttr } from '@user/data-common';
|
||||
import {
|
||||
type IDamageSystem,
|
||||
type IEnemyContext,
|
||||
type IEnemyView,
|
||||
type IMapDamageInfo
|
||||
} from '@user/data-system';
|
||||
import {
|
||||
type IReadonlyEnemy,
|
||||
type IReadonlyHeroAttribute,
|
||||
type ISpecial
|
||||
} from '@user/data-base';
|
||||
import { type ISpecial13Value } from '../special';
|
||||
|
||||
const testGlobals = vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
const core = { flags: { betweenAttackMax: false } };
|
||||
vi.stubGlobal('core', core);
|
||||
Map.prototype.getOrInsertComputed ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
callback: (key: K) => V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
const value = callback(key);
|
||||
this.set(key, value);
|
||||
return value;
|
||||
};
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
defaultValue: V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
this.set(key, defaultValue);
|
||||
return defaultValue;
|
||||
};
|
||||
return { core };
|
||||
});
|
||||
|
||||
interface TestModules {
|
||||
ZoneDamageView: typeof import('../mapDamage').ZoneDamageView;
|
||||
RepulseDamageView: typeof import('../mapDamage').RepulseDamageView;
|
||||
LaserDamageView: typeof import('../mapDamage').LaserDamageView;
|
||||
BetweenDamageView: typeof import('../mapDamage').BetweenDamageView;
|
||||
AmbushDamageView: typeof import('../mapDamage').AmbushDamageView;
|
||||
MainMapDamageConverter: typeof import('../mapDamage').MainMapDamageConverter;
|
||||
MainMapDamageReducer: typeof import('../mapDamage').MainMapDamageReducer;
|
||||
MapDamageType: typeof import('../types').MapDamageType;
|
||||
ManhattanRange: typeof import('@motajs/common').ManhattanRange;
|
||||
RectRange: typeof import('@motajs/common').RectRange;
|
||||
FaceGroup: typeof import('@user/data-common').FaceGroup;
|
||||
}
|
||||
|
||||
let modules: TestModules;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
const mapDamageModule = await import('../mapDamage');
|
||||
const typesModule = await import('../types');
|
||||
const motaModule = await import('@motajs/common');
|
||||
const commonModule = await import('@user/data-common');
|
||||
modules = {
|
||||
ZoneDamageView: mapDamageModule.ZoneDamageView,
|
||||
RepulseDamageView: mapDamageModule.RepulseDamageView,
|
||||
LaserDamageView: mapDamageModule.LaserDamageView,
|
||||
BetweenDamageView: mapDamageModule.BetweenDamageView,
|
||||
AmbushDamageView: mapDamageModule.AmbushDamageView,
|
||||
MainMapDamageConverter: mapDamageModule.MainMapDamageConverter,
|
||||
MainMapDamageReducer: mapDamageModule.MainMapDamageReducer,
|
||||
MapDamageType: {
|
||||
Unknown: typesModule.MapDamageType.Unknown,
|
||||
Zone: typesModule.MapDamageType.Zone,
|
||||
Layer: typesModule.MapDamageType.Layer,
|
||||
Repulse: typesModule.MapDamageType.Repulse,
|
||||
Between: typesModule.MapDamageType.Between
|
||||
},
|
||||
ManhattanRange: motaModule.ManhattanRange,
|
||||
RectRange: motaModule.RectRange,
|
||||
FaceGroup: {
|
||||
Dir4: commonModule.FaceGroup.Dir4,
|
||||
Dir8: commonModule.FaceGroup.Dir8
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建一个只携带代码与数值的内联假特殊属性
|
||||
* @param code 特殊属性代码
|
||||
* @param value 特殊属性数值
|
||||
*/
|
||||
function createSpecial<T>(code: number, value: T): ISpecial<T> {
|
||||
return { code, value } as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只实现地图伤害所需读取能力的内联假怪物
|
||||
* @param specials 怪物特殊属性表
|
||||
*/
|
||||
function createEnemy(
|
||||
specials: Map<number, unknown> = new Map()
|
||||
): IReadonlyEnemy<IEnemyAttr> {
|
||||
return {
|
||||
id: 'test-enemy',
|
||||
code: 1,
|
||||
getSpecial: (code: number) =>
|
||||
specials.has(code) ? createSpecial(code, specials.get(code)) : null,
|
||||
hasSpecial: (code: number) => specials.has(code),
|
||||
iterateSpecials: () => [],
|
||||
getAttribute: () => 0,
|
||||
cloneAttributes: () => ({}) as IEnemyAttr,
|
||||
clone: () => createEnemy(specials)
|
||||
} as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只提供生命值读取的内联假勇士
|
||||
* @param hp 勇士最终生命值
|
||||
*/
|
||||
function createHero(hp: number = 100): IReadonlyHeroAttribute<IHeroAttr> {
|
||||
return {
|
||||
getBaseAttribute: () => hp,
|
||||
getFinalAttribute: () => hp
|
||||
} as never;
|
||||
}
|
||||
|
||||
interface FakeContextOptions {
|
||||
/** 地图宽度 */
|
||||
width?: number;
|
||||
/** 地图高度 */
|
||||
height?: number;
|
||||
/** 按坐标存放的怪物视图 */
|
||||
enemies?: Map<string, IEnemyView<IEnemyAttr>>;
|
||||
/** 绑定的伤害系统 */
|
||||
damageSystem?: IDamageSystem<IEnemyAttr, IHeroAttr> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只实现地图伤害所需能力的内联假怪物上下文
|
||||
* @param options 宽度、高度、怪物视图与伤害系统配置
|
||||
*/
|
||||
function createContext(
|
||||
options: FakeContextOptions = {}
|
||||
): IEnemyContext<IEnemyAttr, IHeroAttr> {
|
||||
const width = options.width ?? 6;
|
||||
const height = options.height ?? 6;
|
||||
const enemies =
|
||||
options.enemies ?? new Map<string, IEnemyView<IEnemyAttr>>();
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
indexer: {
|
||||
locToIndex: (x: number, y: number) => y * width + x,
|
||||
locaterToIndex: (locator: ITileLocator) =>
|
||||
locator.y * width + locator.x,
|
||||
indexToLocator: (index: number) => ({
|
||||
x: index % width,
|
||||
y: Math.floor(index / width)
|
||||
})
|
||||
},
|
||||
getEnemyByLoc: (x: number, y: number) =>
|
||||
enemies.get(`${x},${y}`) ?? null,
|
||||
getDamageSystem: () => options.damageSystem ?? null
|
||||
} as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将一个计算后怪物对象包装为只提供该对象读取的假视图
|
||||
* @param computed 计算后怪物对象
|
||||
*/
|
||||
function createView(
|
||||
computed: IReadonlyEnemy<IEnemyAttr>
|
||||
): IEnemyView<IEnemyAttr> {
|
||||
return { getComputedEnemy: () => computed } as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个按固定方向表返回移动描述的假朝向处理器
|
||||
* @param dirs 朝向与坐标增量对
|
||||
*/
|
||||
function createFace(dirs: Array<[number, { x: number; y: number }]>): {
|
||||
mapMovement: () => Array<[number, { x: number; y: number }]>;
|
||||
} {
|
||||
return { mapMovement: () => dirs };
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造一条地图伤害信息
|
||||
* @param damage 伤害值
|
||||
* @param type 伤害类型
|
||||
* @param extra 额外信息
|
||||
*/
|
||||
function createInfo(
|
||||
damage: number,
|
||||
type: number,
|
||||
extra: Partial<IMapDamageInfo['extra']> = {}
|
||||
): IMapDamageInfo {
|
||||
return {
|
||||
damage,
|
||||
type,
|
||||
extra: {
|
||||
catch: extra.catch ?? new Set(),
|
||||
repulse: extra.repulse ?? new Set()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('ZoneDamageView', () => {
|
||||
// 验证十字领域使用曼哈顿范围参数并输出领域伤害
|
||||
it('builds a manhattan range and zone damage for a cross zone', () => {
|
||||
const special = createSpecial<ISpecial13Value>(15, {
|
||||
zone: 7,
|
||||
square: false,
|
||||
range: 1
|
||||
});
|
||||
const view = new modules.ZoneDamageView(
|
||||
createContext(),
|
||||
{ x: 2, y: 3 },
|
||||
special
|
||||
);
|
||||
|
||||
expect(view.getRange()).toBeInstanceOf(modules.ManhattanRange);
|
||||
expect(view.getRangeParam()).toEqual({ cx: 2, cy: 3, radius: 1 });
|
||||
expect(view.getDamageWithoutCheck()).toEqual(
|
||||
createInfo(7, modules.MapDamageType.Zone)
|
||||
);
|
||||
});
|
||||
|
||||
// 验证九宫格领域使用矩形范围参数并输出领域伤害
|
||||
it('builds a rect range and zone damage for a square zone', () => {
|
||||
const special = createSpecial<ISpecial13Value>(15, {
|
||||
zone: 4,
|
||||
square: true,
|
||||
range: 2
|
||||
});
|
||||
const view = new modules.ZoneDamageView(
|
||||
createContext(),
|
||||
{ x: 2, y: 3 },
|
||||
special
|
||||
);
|
||||
|
||||
expect(view.getRange()).toBeInstanceOf(modules.RectRange);
|
||||
expect(view.getRangeParam()).toEqual({ x: 0, y: 1, w: 5, h: 5 });
|
||||
expect(view.getDamageWithoutCheck()).toEqual(
|
||||
createInfo(4, modules.MapDamageType.Zone)
|
||||
);
|
||||
});
|
||||
|
||||
// 验证领域范围判定命中范围内的坐标并忽略范围外坐标
|
||||
it('checks coordinates against the zone range', () => {
|
||||
const special = createSpecial<ISpecial13Value>(15, {
|
||||
zone: 7,
|
||||
square: false,
|
||||
range: 1
|
||||
});
|
||||
const view = new modules.ZoneDamageView(
|
||||
createContext(),
|
||||
{ x: 2, y: 3 },
|
||||
special
|
||||
);
|
||||
|
||||
expect(view.getDamageAt({ x: 2, y: 4 })).toEqual(
|
||||
createInfo(7, modules.MapDamageType.Zone)
|
||||
);
|
||||
expect(view.getDamageAt({ x: 5, y: 5 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RepulseDamageView', () => {
|
||||
// 验证阻击范围半径为 1 且在来源点返回空
|
||||
it('uses a radius one range and returns null at the source', () => {
|
||||
const special = createSpecial(18, 4);
|
||||
const source: ITileLocator = { x: 2, y: 3 };
|
||||
const view = new modules.RepulseDamageView(
|
||||
createContext(),
|
||||
source,
|
||||
special
|
||||
);
|
||||
|
||||
expect(view.getRangeParam()).toEqual({ cx: 2, cy: 3, radius: 1 });
|
||||
expect(view.getDamageWithoutCheck(source)).toBeNull();
|
||||
});
|
||||
|
||||
// 验证阻击在相邻点输出伤害并把来源定位符放入额外信息
|
||||
it('returns repulse damage carrying the source locator', () => {
|
||||
const special = createSpecial(18, 4);
|
||||
const source: ITileLocator = { x: 2, y: 3 };
|
||||
const view = new modules.RepulseDamageView(
|
||||
createContext(),
|
||||
source,
|
||||
special
|
||||
);
|
||||
|
||||
const info = view.getDamageWithoutCheck({ x: 3, y: 3 });
|
||||
|
||||
expect(info?.damage).toBe(4);
|
||||
expect(info?.type).toBe(modules.MapDamageType.Repulse);
|
||||
expect(info?.extra.repulse.has(source)).toBe(true);
|
||||
});
|
||||
|
||||
// 验证阻击范围判定忽略范围外的坐标
|
||||
it('ignores coordinates outside the repulse range', () => {
|
||||
const special = createSpecial(18, 4);
|
||||
const view = new modules.RepulseDamageView(
|
||||
createContext(),
|
||||
{ x: 2, y: 3 },
|
||||
special
|
||||
);
|
||||
|
||||
expect(view.getDamageAt({ x: 5, y: 5 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LaserDamageView', () => {
|
||||
// 验证激光范围参数取自朝向处理器的移动方向并输出激光伤害
|
||||
it('uses the face handler movements as the ray directions', () => {
|
||||
const dirs: Array<[number, { x: number; y: number }]> = [
|
||||
[0, { x: 0, y: 0 }],
|
||||
[1, { x: 1, y: 0 }]
|
||||
];
|
||||
const special = createSpecial(24, 9);
|
||||
const view = new modules.LaserDamageView(
|
||||
createContext(),
|
||||
{ x: 2, y: 3 },
|
||||
special,
|
||||
createFace(dirs) as never
|
||||
);
|
||||
|
||||
expect(view.getRangeParam()).toEqual({
|
||||
cx: 2,
|
||||
cy: 3,
|
||||
dir: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0 }
|
||||
]
|
||||
});
|
||||
expect(view.getDamageWithoutCheck()).toEqual(
|
||||
createInfo(9, modules.MapDamageType.Layer)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BetweenDamageView', () => {
|
||||
// 验证夹击只在向下的相邻格命中并要求镜像位置存在同类怪物
|
||||
it('returns half hero hp for a qualifying adjacent pair', () => {
|
||||
const source = createEnemy();
|
||||
const mirror = createEnemy(new Map([[16, undefined]]));
|
||||
const context = createContext({
|
||||
enemies: new Map([['3,1', createView(mirror)]])
|
||||
});
|
||||
const view = new modules.BetweenDamageView(
|
||||
context,
|
||||
source,
|
||||
{ x: 1, y: 1 },
|
||||
createHero(100)
|
||||
);
|
||||
|
||||
expect(view.getDamageWithoutCheck({ x: 2, y: 1 })).toEqual(
|
||||
createInfo(50, modules.MapDamageType.Between)
|
||||
);
|
||||
});
|
||||
|
||||
// 验证向左与向上方向的相邻格不构成夹击
|
||||
it('ignores the left and upper adjacent locators', () => {
|
||||
const source = createEnemy();
|
||||
const mirror = createEnemy(new Map([[16, undefined]]));
|
||||
const context = createContext({
|
||||
enemies: new Map([
|
||||
['1,1', createView(mirror)],
|
||||
['-1,1', createView(mirror)]
|
||||
])
|
||||
});
|
||||
const view = new modules.BetweenDamageView(
|
||||
context,
|
||||
source,
|
||||
{ x: 1, y: 1 },
|
||||
createHero(100)
|
||||
);
|
||||
|
||||
expect(view.getDamageWithoutCheck({ x: 0, y: 1 })).toBeNull();
|
||||
expect(view.getDamageWithoutCheck({ x: 1, y: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
// 验证斜向相邻格与非相邻格都不构成夹击
|
||||
it('ignores diagonal and non-adjacent locators', () => {
|
||||
const source = createEnemy();
|
||||
const mirror = createEnemy(new Map([[16, undefined]]));
|
||||
const context = createContext({
|
||||
enemies: new Map([['3,2', createView(mirror)]])
|
||||
});
|
||||
const view = new modules.BetweenDamageView(
|
||||
context,
|
||||
source,
|
||||
{ x: 1, y: 1 },
|
||||
createHero(100)
|
||||
);
|
||||
|
||||
expect(view.getDamageWithoutCheck({ x: 2, y: 2 })).toBeNull();
|
||||
expect(view.getDamageWithoutCheck({ x: 4, y: 1 })).toBeNull();
|
||||
});
|
||||
|
||||
// 验证镜像位置没有怪物时不构成夹击
|
||||
it('returns null when the mirrored locator has no enemy', () => {
|
||||
const source = createEnemy();
|
||||
const view = new modules.BetweenDamageView(
|
||||
createContext(),
|
||||
source,
|
||||
{ x: 1, y: 1 },
|
||||
createHero(100)
|
||||
);
|
||||
|
||||
expect(view.getDamageWithoutCheck({ x: 2, y: 1 })).toBeNull();
|
||||
});
|
||||
|
||||
// 验证镜像怪物没有夹击属性时不构成夹击
|
||||
it('returns null when the mirrored enemy lacks special 16', () => {
|
||||
const source = createEnemy();
|
||||
const mirror = createEnemy();
|
||||
const context = createContext({
|
||||
enemies: new Map([['3,1', createView(mirror)]])
|
||||
});
|
||||
const view = new modules.BetweenDamageView(
|
||||
context,
|
||||
source,
|
||||
{ x: 1, y: 1 },
|
||||
createHero(100)
|
||||
);
|
||||
|
||||
expect(view.getDamageWithoutCheck({ x: 2, y: 1 })).toBeNull();
|
||||
});
|
||||
|
||||
// 验证存在伤害系统时夹击取半血与双方伤害的最小值
|
||||
it('takes the minimum of half hp and both damage infos', () => {
|
||||
const source = createEnemy();
|
||||
const mirror = createEnemy(new Map([[16, undefined]]));
|
||||
const half = createHero(100);
|
||||
const damageSystem = {
|
||||
getDamageInfoByComputed: (enemy: IReadonlyEnemy<IEnemyAttr>) => ({
|
||||
damage: enemy === source ? 30 : 40,
|
||||
turn: 1
|
||||
})
|
||||
} as never;
|
||||
const context = createContext({
|
||||
enemies: new Map([['3,1', createView(mirror)]]),
|
||||
damageSystem
|
||||
});
|
||||
const view = new modules.BetweenDamageView(
|
||||
context,
|
||||
source,
|
||||
{ x: 1, y: 1 },
|
||||
half
|
||||
);
|
||||
|
||||
testGlobals.core.flags.betweenAttackMax = true;
|
||||
const info = view.getDamageWithoutCheck({ x: 2, y: 1 });
|
||||
testGlobals.core.flags.betweenAttackMax = false;
|
||||
|
||||
expect(info).toEqual(createInfo(30, modules.MapDamageType.Between));
|
||||
});
|
||||
|
||||
// 验证缺少伤害系统时夹击回退为半血
|
||||
it('falls back to half hp when the damage system is missing', () => {
|
||||
const source = createEnemy();
|
||||
const mirror = createEnemy(new Map([[16, undefined]]));
|
||||
const context = createContext({
|
||||
enemies: new Map([['3,1', createView(mirror)]])
|
||||
});
|
||||
const view = new modules.BetweenDamageView(
|
||||
context,
|
||||
source,
|
||||
{ x: 1, y: 1 },
|
||||
createHero(100)
|
||||
);
|
||||
|
||||
testGlobals.core.flags.betweenAttackMax = true;
|
||||
const info = view.getDamageWithoutCheck({ x: 2, y: 1 });
|
||||
testGlobals.core.flags.betweenAttackMax = false;
|
||||
|
||||
expect(info).toEqual(createInfo(50, modules.MapDamageType.Between));
|
||||
});
|
||||
});
|
||||
|
||||
describe('AmbushDamageView', () => {
|
||||
// 验证捕捉视图输出 0 点未知伤害并把定位符放入捕捉信息
|
||||
it('returns zero unknown damage carrying the locator', () => {
|
||||
const locator: ITileLocator = { x: 2, y: 3 };
|
||||
const view = new modules.AmbushDamageView(createContext(), locator);
|
||||
|
||||
const info = view.getDamageWithoutCheck();
|
||||
|
||||
expect(info?.damage).toBe(0);
|
||||
expect(info?.type).toBe(modules.MapDamageType.Unknown);
|
||||
expect(info?.extra.catch.has(locator)).toBe(true);
|
||||
expect(info?.extra.repulse.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MainMapDamageConverter', () => {
|
||||
// 验证转换器按领域、夹击、阻击、激光、捕捉的顺序为每个特殊属性各建一个视图
|
||||
it('builds one view per present special in a stable order', () => {
|
||||
const specials = new Map<number, unknown>([
|
||||
[15, { zone: 3, zoneSquare: false, range: 1 }],
|
||||
[16, undefined],
|
||||
[18, 4],
|
||||
[24, 9],
|
||||
[27, undefined]
|
||||
]);
|
||||
const enemy = createEnemy(specials);
|
||||
const faceCalls: number[] = [];
|
||||
const face = createFace([[1, { x: 1, y: 0 }]]);
|
||||
const handler = {
|
||||
enemy,
|
||||
locator: { x: 2, y: 3 },
|
||||
hero: createHero(),
|
||||
state: {
|
||||
faceManager: {
|
||||
get: (group: number) => {
|
||||
faceCalls.push(group);
|
||||
return face;
|
||||
}
|
||||
}
|
||||
}
|
||||
} as never;
|
||||
const converter = new modules.MainMapDamageConverter();
|
||||
|
||||
const views = converter.convert(handler, createContext());
|
||||
|
||||
expect(views).toHaveLength(5);
|
||||
expect(views[0]).toBeInstanceOf(modules.ZoneDamageView);
|
||||
expect(views[1]).toBeInstanceOf(modules.BetweenDamageView);
|
||||
expect(views[2]).toBeInstanceOf(modules.RepulseDamageView);
|
||||
expect(views[3]).toBeInstanceOf(modules.LaserDamageView);
|
||||
expect(views[4]).toBeInstanceOf(modules.AmbushDamageView);
|
||||
expect(faceCalls).toEqual([modules.FaceGroup.Dir4]);
|
||||
});
|
||||
|
||||
// 验证没有相关特殊属性时不产生任何视图
|
||||
it('builds no view when no map damage special is present', () => {
|
||||
const handler = {
|
||||
enemy: createEnemy(),
|
||||
locator: { x: 2, y: 3 },
|
||||
hero: createHero(),
|
||||
state: { faceManager: { get: () => null } }
|
||||
} as never;
|
||||
const converter = new modules.MainMapDamageConverter();
|
||||
|
||||
expect(converter.convert(handler, createContext())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MainMapDamageReducer', () => {
|
||||
// 验证合并器求和、取最大伤害项的类型并合并捕捉与阻击定位符
|
||||
it('sums damage, picks the max type and unions the extras', () => {
|
||||
const repulse: ITileLocator = { x: 1, y: 0 };
|
||||
const caught: ITileLocator = { x: 0, y: 1 };
|
||||
const reducer = new modules.MainMapDamageReducer();
|
||||
|
||||
const info = reducer.reduce([
|
||||
createInfo(3, modules.MapDamageType.Zone),
|
||||
createInfo(5, modules.MapDamageType.Repulse, {
|
||||
repulse: new Set([repulse])
|
||||
}),
|
||||
createInfo(5, modules.MapDamageType.Between, {
|
||||
catch: new Set([caught])
|
||||
})
|
||||
]);
|
||||
|
||||
expect(info.damage).toBe(13);
|
||||
expect(info.type).toBe(modules.MapDamageType.Repulse);
|
||||
expect([...info.extra.repulse]).toEqual([repulse]);
|
||||
expect([...info.extra.catch]).toEqual([caught]);
|
||||
});
|
||||
|
||||
// 验证空输入返回 0 点未知伤害与空的额外信息
|
||||
it('returns zero unknown damage for an empty input', () => {
|
||||
const reducer = new modules.MainMapDamageReducer();
|
||||
|
||||
const info = reducer.reduce([]);
|
||||
|
||||
expect(info.damage).toBe(0);
|
||||
expect(info.type).toBe(modules.MapDamageType.Unknown);
|
||||
expect(info.extra.catch.size).toBe(0);
|
||||
expect(info.extra.repulse.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@ -1,170 +0,0 @@
|
||||
// 测试怪物特殊属性注册:代码 0-27 全量注册、守卫默认值、名称与描述生成
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { type ISpecial } from '@user/data-base';
|
||||
import { type ISpecial23Value } from '../special';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
Map.prototype.getOrInsertComputed ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
callback: (key: K) => V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
const value = callback(key);
|
||||
this.set(key, value);
|
||||
return value;
|
||||
};
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
defaultValue: V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
this.set(key, defaultValue);
|
||||
return defaultValue;
|
||||
};
|
||||
});
|
||||
|
||||
interface TestModules {
|
||||
registerSpecials: typeof import('../special').createSpecials;
|
||||
}
|
||||
|
||||
let modules: TestModules;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
const specialModule = await import('../special');
|
||||
modules = { registerSpecials: specialModule.createSpecials };
|
||||
});
|
||||
|
||||
type SpecialCreation = (enemy: unknown) => ISpecial<any>;
|
||||
|
||||
interface FakeEnemyManager {
|
||||
/** 记录注册的属性默认值 */
|
||||
defaults: Map<string, unknown>;
|
||||
/** 记录注册的代码与创建函数 */
|
||||
specials: Map<number, SpecialCreation>;
|
||||
/** 记录每个代码的注册次数 */
|
||||
registerCounts: Map<number, number>;
|
||||
/** 注册一个特殊属性 */
|
||||
registerSpecial(code: number, cons: SpecialCreation): void;
|
||||
/** 注册一个属性默认值 */
|
||||
setAttributeDefaults(name: string, value: unknown): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个只实现注册接口的内联假怪物管理器
|
||||
*/
|
||||
function createManager(): FakeEnemyManager {
|
||||
const defaults = new Map<string, unknown>();
|
||||
const specials = new Map<number, SpecialCreation>();
|
||||
const registerCounts = new Map<number, number>();
|
||||
return {
|
||||
defaults,
|
||||
specials,
|
||||
registerCounts,
|
||||
registerSpecial: (code, cons) => {
|
||||
specials.set(code, cons);
|
||||
registerCounts.set(code, (registerCounts.get(code) ?? 0) + 1);
|
||||
},
|
||||
setAttributeDefaults: (name, value) => {
|
||||
defaults.set(name, value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出注册表中的一个特殊属性创建函数并实例化
|
||||
* @param manager 假怪物管理器
|
||||
* @param code 特殊属性代码
|
||||
*/
|
||||
function createSpecial(manager: FakeEnemyManager, code: number): ISpecial<any> {
|
||||
const creation = manager.specials.get(code);
|
||||
if (!creation) throw new Error(`special ${code} was not registered`);
|
||||
return creation(undefined);
|
||||
}
|
||||
|
||||
describe('registerSpecials', () => {
|
||||
// 验证代码 0 到 27 每个都被恰好注册一次
|
||||
it('registers every special code from 0 to 27 exactly once', () => {
|
||||
const manager = createManager();
|
||||
modules.registerSpecials(manager as never);
|
||||
|
||||
const codes = [...manager.specials.keys()].sort((a, b) => a - b);
|
||||
const counts = [...manager.registerCounts.values()];
|
||||
|
||||
expect(codes).toEqual([...Array(28).keys()]);
|
||||
expect(counts).toEqual(Array(28).fill(1));
|
||||
});
|
||||
|
||||
// 验证注册时写入守卫属性默认值为空集合
|
||||
it('registers an empty set default for the guard attribute', () => {
|
||||
const manager = createManager();
|
||||
modules.registerSpecials(manager as never);
|
||||
|
||||
expect(manager.defaults.has('guard')).toBe(true);
|
||||
expect(manager.defaults.get('guard')).toBeInstanceOf(Set);
|
||||
expect((manager.defaults.get('guard') as Set<unknown>).size).toBe(0);
|
||||
});
|
||||
|
||||
// 验证每个注册的特殊属性都提供名称与描述方法
|
||||
it('provides a name and description accessor for every special', () => {
|
||||
const manager = createManager();
|
||||
modules.registerSpecials(manager as never);
|
||||
|
||||
for (const code of manager.specials.keys()) {
|
||||
const special = createSpecial(manager, code);
|
||||
expect(typeof special.getSpecialName).toBe('function');
|
||||
expect(typeof special.getDescription).toBe('function');
|
||||
}
|
||||
});
|
||||
|
||||
// 验证可序列化特殊属性按当前数值生成名称与描述
|
||||
it('builds the name and description of a serializable special', () => {
|
||||
const manager = createManager();
|
||||
modules.registerSpecials(manager as never);
|
||||
const combo = createSpecial(manager, 6);
|
||||
|
||||
expect(combo.getSpecialName()).toBe('4连击');
|
||||
combo.setValue(5);
|
||||
expect(combo.getSpecialName()).toBe('5连击');
|
||||
expect(combo.getDescription()).toBe('怪物每回合攻击5次。');
|
||||
});
|
||||
|
||||
// 验证光环特殊属性按范围与加成生成描述
|
||||
it('builds the description of the halo special from its value', () => {
|
||||
const manager = createManager();
|
||||
modules.registerSpecials(manager as never);
|
||||
const halo = createSpecial(manager, 25);
|
||||
|
||||
expect(halo.getSpecialName()).toBe('光环');
|
||||
expect(halo.getDescription()).toContain('同楼层所有怪物');
|
||||
expect(halo.getDescription()).toContain('线性叠加。');
|
||||
|
||||
const value: ISpecial23Value = {
|
||||
range: 2,
|
||||
square: true,
|
||||
hpBuff: 10,
|
||||
atkBuff: 0,
|
||||
defBuff: 0
|
||||
};
|
||||
halo.setValue(value);
|
||||
expect(halo.getDescription()).toContain('九宫格2格范围内所有怪物');
|
||||
expect(halo.getDescription()).toContain('生命提升10%');
|
||||
});
|
||||
|
||||
// 验证无属性特殊属性提供固定名称与描述
|
||||
it('provides a fixed name and description for a none-property special', () => {
|
||||
const manager = createManager();
|
||||
modules.registerSpecials(manager as never);
|
||||
const firstStrike = createSpecial(manager, 1);
|
||||
|
||||
expect(firstStrike.getSpecialName()).toBe('先攻');
|
||||
expect(firstStrike.getDescription()).toBe('怪物首先攻击。');
|
||||
});
|
||||
});
|
||||
@ -1,29 +0,0 @@
|
||||
export interface IEnemyAttr {
|
||||
/** 怪物生命值 */
|
||||
hp: number;
|
||||
/** 怪物攻击力 */
|
||||
atk: number;
|
||||
/** 怪物防御力 */
|
||||
def: number;
|
||||
/** 怪物金币 */
|
||||
money: number;
|
||||
/** 怪物经验值 */
|
||||
exp: number;
|
||||
/** 怪物加点量 */
|
||||
point: number;
|
||||
/** 支援来源怪物坐标索引列表 */
|
||||
guard: Set<number>;
|
||||
}
|
||||
|
||||
export const enum MapDamageType {
|
||||
/** 未知伤害 */
|
||||
Unknown,
|
||||
/** 领域伤害 */
|
||||
Zone,
|
||||
/** 激光伤害 */
|
||||
Layer,
|
||||
/** 阻击伤害 */
|
||||
Repulse,
|
||||
/** 夹击伤害 */
|
||||
Between
|
||||
}
|
||||
@ -1,390 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { WebLoadStarter } from '@motajs/loader';
|
||||
import { logger } from '@motajs/common';
|
||||
import { Statement, StatementType } from '@motajs/anon-tokyo';
|
||||
import {
|
||||
FaceDirection,
|
||||
IGameEvent,
|
||||
ObjectMoveType,
|
||||
EventTrigger,
|
||||
TileType
|
||||
} from '@user/data-common';
|
||||
import { BlockEventType, IBlockEventEnv } from '@user/data-system';
|
||||
import { CoreState } from '../../core';
|
||||
import {
|
||||
createEventRegistrations,
|
||||
EventRemoveBlock,
|
||||
EventInsertEvent,
|
||||
EventInsertEvents,
|
||||
EventMoveBlock,
|
||||
EventMoveHero,
|
||||
EventStepHero,
|
||||
EventSetBlock
|
||||
} from '../index';
|
||||
import { EventSetBlock as MapEventSetBlock } from '../map';
|
||||
import { EventInsertEvent as ControlEventInsertEvent } from '../event';
|
||||
import * as dataStateRoot from '../../index';
|
||||
|
||||
vi.hoisted(() => {
|
||||
Map.prototype.getOrInsertComputed ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
callback: (key: K) => V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
const value = callback(key);
|
||||
this.set(key, value);
|
||||
return value;
|
||||
};
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
defaultValue: V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
this.set(key, defaultValue);
|
||||
return defaultValue;
|
||||
};
|
||||
});
|
||||
|
||||
interface EventFixture {
|
||||
readonly state: CoreState;
|
||||
readonly map: ReturnType<CoreState['maps']['createMap']>;
|
||||
readonly layer: ReturnType<
|
||||
ReturnType<CoreState['maps']['createMap']>['addLayer']
|
||||
>;
|
||||
readonly env: IBlockEventEnv;
|
||||
}
|
||||
|
||||
type RegisteredBuiltin = ReturnType<typeof createEventRegistrations>[number];
|
||||
|
||||
function invokeBuiltin<TParam>(
|
||||
registration: RegisteredBuiltin,
|
||||
param: TParam,
|
||||
env: IBlockEventEnv
|
||||
): Promise<void> {
|
||||
return Promise.resolve(registration.func(param, env));
|
||||
}
|
||||
|
||||
function getRegistration(name: string): RegisteredBuiltin {
|
||||
const registration = createEventRegistrations().find(
|
||||
item => item.name === name
|
||||
);
|
||||
if (!registration) throw new Error(`${name} registration missing`);
|
||||
return registration;
|
||||
}
|
||||
|
||||
function createFixture(): EventFixture {
|
||||
const state = new CoreState({
|
||||
loadStarter: new WebLoadStarter(),
|
||||
coreURL: 'placeholder'
|
||||
});
|
||||
state.tileStore.addTile({
|
||||
num: 1,
|
||||
id: 'floor',
|
||||
events: {},
|
||||
type: TileType.Terrain,
|
||||
pass: { onlyEvents: false, outPass: 15, inPass: 15 },
|
||||
eventPass: true
|
||||
});
|
||||
state.tileStore.addTile({
|
||||
num: 2,
|
||||
id: 'block',
|
||||
events: {},
|
||||
type: TileType.Terrain,
|
||||
pass: { onlyEvents: false, outPass: 15, inPass: 15 },
|
||||
eventPass: true
|
||||
});
|
||||
const map = state.maps.createMap('F1', 4, 1);
|
||||
const layer = map.addLayer('event');
|
||||
layer.setMapRef(new Uint32Array([1, 1, 1, 1]));
|
||||
map.setEventLayer(layer);
|
||||
state.hero.location.setFloor(map);
|
||||
state.hero.location.setPos(0, 0);
|
||||
state.hero.location.mover.setFaceDir(FaceDirection.Right);
|
||||
const env: IBlockEventEnv = {
|
||||
state,
|
||||
type: BlockEventType.CommonEvent,
|
||||
trigger: EventTrigger.None,
|
||||
system: state.eventSystem,
|
||||
heroLocator: state.hero.getLocation(),
|
||||
heroFloor: 'F1',
|
||||
triggerLocator: null,
|
||||
tile: null,
|
||||
layer,
|
||||
map
|
||||
};
|
||||
return { state, map, layer, env };
|
||||
}
|
||||
|
||||
function createEvent(
|
||||
state: CoreState,
|
||||
trigger: EventTrigger,
|
||||
execute: (env: IBlockEventEnv) => Promise<void>
|
||||
): IGameEvent<Record<string, never>, IBlockEventEnv, void> {
|
||||
return {
|
||||
interpreter: state.eventSystem.executor.interpreter,
|
||||
trigger,
|
||||
rawEvent: [],
|
||||
compiled: null,
|
||||
compile: () => null,
|
||||
execute: async (_param, env) => execute(env),
|
||||
setTrigger: () => {},
|
||||
setRaw: () => {}
|
||||
};
|
||||
}
|
||||
|
||||
describe('event built-ins', () => {
|
||||
// 验证真实注册项设置图块并安全跳过无效图块
|
||||
it('sets a block and safely skips an unknown tile', async () => {
|
||||
const fixture = createFixture();
|
||||
const registration = getRegistration('setBlock');
|
||||
await invokeBuiltin(
|
||||
registration,
|
||||
{ x: 1, y: 0, tile: 'block' },
|
||||
fixture.env
|
||||
);
|
||||
expect(fixture.layer.getBlock(1, 0)).toBe(2);
|
||||
await invokeBuiltin(
|
||||
registration,
|
||||
{ x: 1, y: 0, tile: 'missing' },
|
||||
fixture.env
|
||||
);
|
||||
expect(fixture.layer.getBlock(1, 0)).toBe(2);
|
||||
});
|
||||
|
||||
// 验证 setBlock 遇到未注册图块时告警码 1001 并跳过
|
||||
it('warns code 1001 when setBlock receives an unknown tile', async () => {
|
||||
const fixture = createFixture();
|
||||
const warn = vi.spyOn(logger, 'warn');
|
||||
|
||||
await invokeBuiltin(
|
||||
getRegistration('setBlock'),
|
||||
{ x: 1, y: 0, tile: 'missing' },
|
||||
fixture.env
|
||||
);
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(1001);
|
||||
});
|
||||
|
||||
// 验证真实注册项移动动态图块并按 safe 分支还原
|
||||
it('moves a dynamic block and respects safe static transfer', async () => {
|
||||
const fixture = createFixture();
|
||||
await invokeBuiltin(
|
||||
getRegistration('moveBlock'),
|
||||
{
|
||||
x: 1,
|
||||
y: 0,
|
||||
steps: [
|
||||
{ type: ObjectMoveType.Teleport, x: 2, y: 0, rel: false }
|
||||
],
|
||||
safe: true
|
||||
},
|
||||
fixture.env
|
||||
);
|
||||
expect([...fixture.layer.getDynamicTilesAt(2, 0)]).toHaveLength(1);
|
||||
expect(fixture.layer.getBlock(2, 0)).toBe(1);
|
||||
});
|
||||
|
||||
// 验证真实注册项移除静态图块,并可按 dynamic 选项一并移除动态图块
|
||||
it('removes static blocks and optionally dynamic blocks', async () => {
|
||||
const fixture = createFixture();
|
||||
fixture.layer.setBlock(2, 1, 0);
|
||||
await invokeBuiltin(
|
||||
getRegistration('removeBlock'),
|
||||
{ x: 1, y: 0 },
|
||||
fixture.env
|
||||
);
|
||||
expect(fixture.layer.getBlock(1, 0)).toBe(0);
|
||||
|
||||
fixture.layer.setBlock(1, 1, 0);
|
||||
fixture.layer.transferToDynamic(1, 0);
|
||||
await invokeBuiltin(
|
||||
getRegistration('removeBlock'),
|
||||
{ x: 1, y: 0, dynamic: true },
|
||||
fixture.env
|
||||
);
|
||||
expect([...fixture.layer.getDynamicTilesAt(1, 0)]).toHaveLength(0);
|
||||
expect(fixture.layer.getBlock(1, 0)).toBe(0);
|
||||
});
|
||||
|
||||
// 验证真实注册项的勇士移动序列和向前一步都等待移动结束
|
||||
it('awaits hero sequence and forward-step movement', async () => {
|
||||
const fixture = createFixture();
|
||||
await invokeBuiltin(
|
||||
getRegistration('moveHero'),
|
||||
{
|
||||
steps: [{ type: ObjectMoveType.Dir, move: FaceDirection.Right }]
|
||||
},
|
||||
fixture.env
|
||||
);
|
||||
expect(fixture.state.hero.location.x).toBe(1);
|
||||
await invokeBuiltin(getRegistration('stepHero'), {}, fixture.env);
|
||||
expect(fixture.state.hero.location.x).toBe(2);
|
||||
});
|
||||
|
||||
// 验证真实注册项按顺序等待临时事件序列并直接执行语句体
|
||||
it('awaits id sequences and executes a direct statement body', async () => {
|
||||
const fixture = createFixture();
|
||||
const calls: string[] = [];
|
||||
fixture.state.eventStore.addEvent(
|
||||
'first',
|
||||
createEvent(fixture.state, EventTrigger.None, async () => {
|
||||
await Promise.resolve();
|
||||
calls.push('first');
|
||||
})
|
||||
);
|
||||
fixture.state.eventStore.addEvent(
|
||||
'second',
|
||||
createEvent(fixture.state, EventTrigger.None, async () => {
|
||||
calls.push('second');
|
||||
})
|
||||
);
|
||||
await invokeBuiltin(
|
||||
getRegistration('insertEvents'),
|
||||
{ ids: ['first', 'second', 'missing'] },
|
||||
fixture.env
|
||||
);
|
||||
const body: Statement[] = [
|
||||
{
|
||||
type: StatementType.Call,
|
||||
functionName: 'setBlock',
|
||||
builtIn: true,
|
||||
async: true,
|
||||
parameters: { x: 3, y: 0, tile: 2 }
|
||||
}
|
||||
];
|
||||
await invokeBuiltin(getRegistration('insertEvent'), body, fixture.env);
|
||||
expect(calls).toEqual(['first', 'second']);
|
||||
expect(fixture.layer.getBlock(3, 0)).toBe(2);
|
||||
expect(fixture.state.eventStore.getEvent('inline-body')).toBeNull();
|
||||
});
|
||||
|
||||
// 验证默认注册项只包含批准的七个稳定名称
|
||||
it('registers exactly the approved built-ins in AnonTokyo', () => {
|
||||
const fixture = createFixture();
|
||||
const names = createEventRegistrations().map(item => item.name);
|
||||
expect(names).toEqual([
|
||||
'setBlock',
|
||||
'moveBlock',
|
||||
'removeBlock',
|
||||
'moveHero',
|
||||
'stepHero',
|
||||
'insertEvents',
|
||||
'insertEvent'
|
||||
]);
|
||||
for (const name of names) {
|
||||
expect(
|
||||
fixture.state.eventSystem.executor.interpreter.getBuiltInFunction(
|
||||
name
|
||||
)
|
||||
).toMatchObject({
|
||||
name,
|
||||
func: expect.any(Function)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 验证真实注册项在缺失地图和事件 id 时都安全返回
|
||||
it('safely skips missing targets and event ids', async () => {
|
||||
const fixture = createFixture();
|
||||
const missingEnv: IBlockEventEnv = {
|
||||
...fixture.env,
|
||||
map: null,
|
||||
layer: null,
|
||||
heroFloor: 'missing'
|
||||
};
|
||||
await expect(
|
||||
invokeBuiltin(
|
||||
getRegistration('moveBlock'),
|
||||
{ x: 0, y: 0, steps: [] },
|
||||
missingEnv
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
invokeBuiltin(
|
||||
getRegistration('removeBlock'),
|
||||
{ x: 0, y: 0 },
|
||||
missingEnv
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
invokeBuiltin(getRegistration('insertEvent'), [], fixture.env)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('event registration ownership', () => {
|
||||
// 验证单一注册函数按地图勇士控制三类顺序返回全部七个新鲜实例
|
||||
it('returns the seven fresh registrations in stable category order', () => {
|
||||
const registrations = createEventRegistrations();
|
||||
expect(registrations).toHaveLength(7);
|
||||
expect(registrations.map(item => item.name)).toEqual([
|
||||
'setBlock',
|
||||
'moveBlock',
|
||||
'removeBlock',
|
||||
'moveHero',
|
||||
'stepHero',
|
||||
'insertEvents',
|
||||
'insertEvent'
|
||||
]);
|
||||
expect(registrations[0]).toBeInstanceOf(EventSetBlock);
|
||||
expect(registrations[1]).toBeInstanceOf(EventMoveBlock);
|
||||
expect(registrations[2]).toBeInstanceOf(EventRemoveBlock);
|
||||
expect(registrations[3]).toBeInstanceOf(EventMoveHero);
|
||||
expect(registrations[4]).toBeInstanceOf(EventStepHero);
|
||||
expect(registrations[5]).toBeInstanceOf(EventInsertEvents);
|
||||
expect(registrations[6]).toBeInstanceOf(EventInsertEvent);
|
||||
expect(createEventRegistrations()).not.toBe(registrations);
|
||||
expect(createEventRegistrations()[0]).not.toBe(registrations[0]);
|
||||
});
|
||||
|
||||
// 验证每个注册实例直接拥有自己的稳定名称与可调用函数
|
||||
it('owns a stable name and callable function on every instance', () => {
|
||||
for (const registration of createEventRegistrations()) {
|
||||
expect(typeof registration.name).toBe('string');
|
||||
expect(registration.name.length).toBeGreaterThan(0);
|
||||
expect(typeof registration.func).toBe('function');
|
||||
}
|
||||
});
|
||||
|
||||
// 验证注册类分别来自地图勇士事件模块且勇士面前注册类归 hero 所有
|
||||
it('originates registration classes from their owning event modules', () => {
|
||||
expect(EventSetBlock).toBe(MapEventSetBlock);
|
||||
expect(EventInsertEvent).toBe(ControlEventInsertEvent);
|
||||
});
|
||||
|
||||
// 验证事件 barrel 仅含导出语句且既有公开事件符号仍可从根索引导入
|
||||
it('keeps both barrels export-only and preserves public event symbols', () => {
|
||||
const eventBarrel = readFileSync(
|
||||
new URL('../index.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const rootBarrel = readFileSync(
|
||||
new URL('../../index.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
expect(eventBarrel).not.toMatch(/^\s*import\s/m);
|
||||
expect(rootBarrel).not.toMatch(/^\s*import\s/m);
|
||||
expect(eventBarrel).not.toMatch(
|
||||
/^\s*(?:export\s+)?(?:async\s+)?function\s/m
|
||||
);
|
||||
const reachableNames = [
|
||||
'createEventRegistrations',
|
||||
'EventSetBlock',
|
||||
'EventMoveBlock',
|
||||
'EventRemoveBlock',
|
||||
'EventMoveHero',
|
||||
'EventStepHero',
|
||||
'EventInsertEvents',
|
||||
'EventInsertEvent'
|
||||
];
|
||||
const rootKeys = Object.keys(dataStateRoot);
|
||||
for (const name of reachableNames) {
|
||||
expect(rootKeys).toContain(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,5 +0,0 @@
|
||||
//#region 勇士属性
|
||||
|
||||
export interface IHeroAttr {}
|
||||
|
||||
//#endregion
|
||||
@ -1,11 +1,7 @@
|
||||
export * from './enemy';
|
||||
export * from './event';
|
||||
export * from './hero';
|
||||
export * from './loader';
|
||||
export * from './replay';
|
||||
|
||||
export * from './common';
|
||||
export * from './core';
|
||||
export * from './ins';
|
||||
export * from './shared';
|
||||
export * from './types';
|
||||
export * from './plugin';
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
import { WebLoadStarter } from '@motajs/loader';
|
||||
import { CoreState } from './core';
|
||||
|
||||
// TODO: 逐渐弱化 CoreState 的单例概念,每个接口都通过参数传入 ICoreState 对象
|
||||
|
||||
/**
|
||||
* 数据端核心状态,目前处于过渡阶段,仅服务于渲染,不负责任何逻辑计算,会在后续把核心逻辑逐渐移动至此对象。
|
||||
* 此对象是数据端状态,本身不负责任何渲染操作,仅会向渲染端发送数据让渲染端渲染,不要把渲染操作直接放到此对象上,
|
||||
* 否则可能导致录像验证失败。
|
||||
*/
|
||||
export const state = new CoreState({
|
||||
loadStarter: new WebLoadStarter(),
|
||||
coreURL: 'placeholder'
|
||||
});
|
||||
@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { ILoadTask, LoadDataType } from '@motajs/loader';
|
||||
import { logger } from '@motajs/common';
|
||||
import { PrefixedJSONCProcessor } from '../jsoncProcessor';
|
||||
import { DefaultDataLoaderHook } from '../hook';
|
||||
import { DefaultDataLoaderHook } from '../../../../../src/data/loader/hook';
|
||||
import { MotaDataLoader } from '../loader';
|
||||
|
||||
/** 与生产 loader 一致的 jsonc 前缀分隔串 */
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
export * from './hook';
|
||||
export * from './jsoncProcessor';
|
||||
export * from './loader';
|
||||
export * from './types';
|
||||
|
||||
123
packages-user/data-state/src/plugin.ts
Normal file
123
packages-user/data-state/src/plugin.ts
Normal file
@ -0,0 +1,123 @@
|
||||
//#region 插件排序接口
|
||||
|
||||
export interface IPluginDepends {
|
||||
/** 插件标识符 */
|
||||
readonly id: string;
|
||||
/** 插件所依赖的其他插件 id */
|
||||
readonly dependencies: readonly string[];
|
||||
}
|
||||
|
||||
export interface IPluginHolder<T extends IPluginDepends> {
|
||||
/** 插件内容 */
|
||||
readonly plugin: T;
|
||||
}
|
||||
|
||||
export interface IPluginOrderResult<T> {
|
||||
/** 可初始化的插件拓扑序 */
|
||||
readonly order: T[];
|
||||
/** 处于依赖环中的插件 id */
|
||||
readonly cyclic: Set<string>;
|
||||
/** 直接引用了不存在依赖的插件 id,与其缺失的依赖 id 列表 */
|
||||
readonly missing: Map<string, string[]>;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region 依赖排序
|
||||
|
||||
/**
|
||||
* 依据插件的依赖关系计算可初始化的拓扑序,并检测依赖环与不存在的依赖
|
||||
* @param plugins 以插件 id 为键的插件映射表
|
||||
*/
|
||||
export function orderPlugins<H extends IPluginHolder<IPluginDepends>>(
|
||||
plugins: ReadonlyMap<string, H>
|
||||
): IPluginOrderResult<H> {
|
||||
const cyclic = new Set<string>();
|
||||
const missing = new Map<string, string[]>();
|
||||
|
||||
// 依赖不存在检测:记录直接引用了不存在依赖的插件及其缺失的依赖
|
||||
for (const holder of plugins.values()) {
|
||||
const define = holder.plugin;
|
||||
const lacked: string[] = [];
|
||||
for (const dependency of define.dependencies) {
|
||||
if (!plugins.has(dependency)) {
|
||||
lacked.push(dependency);
|
||||
}
|
||||
}
|
||||
if (lacked.length > 0) {
|
||||
missing.set(define.id, lacked);
|
||||
}
|
||||
}
|
||||
|
||||
// 环检测:从每个插件出发沿依赖边搜索,若能回到自身则其处于依赖环中
|
||||
for (const id of plugins.keys()) {
|
||||
const start = plugins.get(id)!;
|
||||
const visited = new Set<string>();
|
||||
const pending: string[] = [];
|
||||
for (const dependency of start.plugin.dependencies) {
|
||||
pending.push(dependency);
|
||||
}
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop()!;
|
||||
if (current === id) {
|
||||
cyclic.add(id);
|
||||
break;
|
||||
}
|
||||
if (visited.has(current)) continue;
|
||||
visited.add(current);
|
||||
const node = plugins.get(current);
|
||||
if (!node) continue;
|
||||
for (const dependency of node.plugin.dependencies) {
|
||||
pending.push(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 被排除集合:环上插件、依赖不存在的插件,以及依赖它们的下游插件
|
||||
const blocked = new Set<string>();
|
||||
for (const id of cyclic) blocked.add(id);
|
||||
for (const id of missing.keys()) blocked.add(id);
|
||||
|
||||
let expanded = true;
|
||||
while (expanded) {
|
||||
expanded = false;
|
||||
for (const holder of plugins.values()) {
|
||||
const define = holder.plugin;
|
||||
if (blocked.has(define.id)) continue;
|
||||
for (const dependency of define.dependencies) {
|
||||
if (blocked.has(dependency)) {
|
||||
blocked.add(define.id);
|
||||
expanded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 拓扑排序:反复扫描,逐轮输出依赖均已就绪的插件,保证依赖先于被依赖者
|
||||
const order: H[] = [];
|
||||
const emitted = new Set<string>();
|
||||
let progressed = true;
|
||||
while (progressed) {
|
||||
progressed = false;
|
||||
for (const holder of plugins.values()) {
|
||||
const define = holder.plugin;
|
||||
if (blocked.has(define.id) || emitted.has(define.id)) continue;
|
||||
let ready = true;
|
||||
for (const dependency of define.dependencies) {
|
||||
if (!emitted.has(dependency)) {
|
||||
ready = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ready) continue;
|
||||
order.push(holder);
|
||||
emitted.add(define.id);
|
||||
progressed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { order, cyclic, missing };
|
||||
}
|
||||
|
||||
//#endregion
|
||||
@ -1,443 +0,0 @@
|
||||
import { logger } from '@motajs/common';
|
||||
import {
|
||||
FaceDirection,
|
||||
IReplayStepHandler,
|
||||
IMoverController,
|
||||
beginReplaySafetyCollection,
|
||||
endReplaySafetyCollection,
|
||||
logReplaySafetyDetail,
|
||||
shouldReplay,
|
||||
ReplayCode,
|
||||
ReplayCommandResult
|
||||
} from '@user/data-common';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WebLoadStarter } from '@motajs/loader';
|
||||
import { CoreState } from '../../core';
|
||||
import { ReplaySystem } from '../../../../data-common/src/replay/system';
|
||||
import {
|
||||
ReplayEquip,
|
||||
ReplayMove,
|
||||
ReplayTeleport,
|
||||
ReplayUnequip,
|
||||
ReplayUseItem
|
||||
} from '../commands';
|
||||
|
||||
/** 供测试读取的稳定指令码顺序 */
|
||||
export const REPLAY_COMMAND_ORDER: readonly ReplayCode[] = [
|
||||
ReplayCode.Up,
|
||||
ReplayCode.Right,
|
||||
ReplayCode.Down,
|
||||
ReplayCode.Left,
|
||||
ReplayCode.Teleport,
|
||||
ReplayCode.UseItem,
|
||||
ReplayCode.Equip,
|
||||
ReplayCode.Unequip
|
||||
];
|
||||
|
||||
function createCoreState(): CoreState {
|
||||
return new CoreState({
|
||||
loadStarter: new WebLoadStarter(),
|
||||
coreURL: 'placeholder'
|
||||
});
|
||||
}
|
||||
|
||||
function step(
|
||||
command: number,
|
||||
params: IReplayStepHandler['params']
|
||||
): IReplayStepHandler {
|
||||
return { command, params, index: 0 };
|
||||
}
|
||||
|
||||
function controller(onEnd: Promise<void>): Readonly<IMoverController> {
|
||||
return {
|
||||
done: false,
|
||||
onEnd,
|
||||
push: () => {},
|
||||
insert: () => {},
|
||||
stop: () => onEnd
|
||||
};
|
||||
}
|
||||
|
||||
describe('replay commands', () => {
|
||||
// 验证每个 CoreState 都按稳定顺序装配八个指令
|
||||
it('registers the eight stable commands in order on every CoreState', () => {
|
||||
const state = createCoreState();
|
||||
expect(REPLAY_COMMAND_ORDER).toEqual([
|
||||
ReplayCode.Up,
|
||||
ReplayCode.Right,
|
||||
ReplayCode.Down,
|
||||
ReplayCode.Left,
|
||||
ReplayCode.Teleport,
|
||||
ReplayCode.UseItem,
|
||||
ReplayCode.Equip,
|
||||
ReplayCode.Unequip
|
||||
]);
|
||||
expect(
|
||||
REPLAY_COMMAND_ORDER.every(code =>
|
||||
state.replaySystem.getCommand(code)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// 验证每个 CoreState 独立装配,且四向共用一个参数化移动类
|
||||
it('assembles an independent command set per CoreState', () => {
|
||||
const first = createCoreState();
|
||||
const second = createCoreState();
|
||||
const firstCommands = REPLAY_COMMAND_ORDER.map(
|
||||
code => first.replaySystem.getCommand(code)!
|
||||
);
|
||||
const secondCommands = REPLAY_COMMAND_ORDER.map(
|
||||
code => second.replaySystem.getCommand(code)!
|
||||
);
|
||||
expect(new Set(firstCommands).size).toBe(8);
|
||||
expect(new Set(secondCommands).size).toBe(8);
|
||||
for (let index = 0; index < firstCommands.length; index++) {
|
||||
expect(firstCommands[index]).not.toBe(secondCommands[index]);
|
||||
}
|
||||
expect(firstCommands.map(command => command.constructor.name)).toEqual([
|
||||
'ReplayMove',
|
||||
'ReplayMove',
|
||||
'ReplayMove',
|
||||
'ReplayMove',
|
||||
'ReplayTeleport',
|
||||
'ReplayUseItem',
|
||||
'ReplayEquip',
|
||||
'ReplayUnequip'
|
||||
]);
|
||||
});
|
||||
|
||||
// 验证移动指令只加入方向步,由 notExecuted 统一启动并等待
|
||||
it('steps direction without starting and finalizes through notExecuted', async () => {
|
||||
const state = createCoreState();
|
||||
const mover = state.hero.location.mover;
|
||||
const move = vi.spyOn(mover, 'step');
|
||||
const first = Promise.withResolvers<void>();
|
||||
const start = vi
|
||||
.spyOn(mover, 'start')
|
||||
.mockReturnValueOnce(controller(first.promise));
|
||||
const command = new ReplayMove(state, FaceDirection.Right);
|
||||
|
||||
await expect(command.execute(step(ReplayCode.Right, []))).resolves.toBe(
|
||||
ReplayCommandResult.Success
|
||||
);
|
||||
expect(move).toHaveBeenCalledWith(FaceDirection.Right);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
|
||||
let result: ReplayCommandResult | undefined;
|
||||
const pending = command.finalize().then(value => {
|
||||
result = value;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeUndefined();
|
||||
first.resolve();
|
||||
await pending;
|
||||
expect(result).toBe(ReplayCommandResult.Success);
|
||||
});
|
||||
|
||||
// 验证移动已在进行中或无法启动时返回 false 并记录错误码
|
||||
it('rejects a move while moving and a missing controller', async () => {
|
||||
const state = createCoreState();
|
||||
const error = vi.spyOn(logger, 'error');
|
||||
const mover = state.hero.location.mover;
|
||||
(mover as unknown as { moving: boolean }).moving = true;
|
||||
const command = new ReplayMove(state, FaceDirection.Up);
|
||||
await expect(command.execute(step(ReplayCode.Up, []))).resolves.toBe(
|
||||
ReplayCommandResult.Failed
|
||||
);
|
||||
expect(error).toHaveBeenCalledWith(2003);
|
||||
|
||||
(mover as unknown as { moving: boolean }).moving = false;
|
||||
vi.spyOn(mover, 'start').mockReturnValueOnce(null);
|
||||
await expect(command.finalize()).resolves.toBe(
|
||||
ReplayCommandResult.Failed
|
||||
);
|
||||
expect(error).toHaveBeenCalledWith(2004);
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
// 验证瞬移等待寻路控制器完成,无路径时返回 false
|
||||
it('teleports and awaits the pathfinding controller', async () => {
|
||||
const state = createCoreState();
|
||||
const first = Promise.withResolvers<void>();
|
||||
const teleport = vi
|
||||
.spyOn(state.pathfinding, 'teleportTo')
|
||||
.mockReturnValueOnce({
|
||||
controller: controller(first.promise),
|
||||
path: []
|
||||
});
|
||||
const command = new ReplayTeleport(state);
|
||||
|
||||
let result: ReplayCommandResult | undefined;
|
||||
const pending = command
|
||||
.execute(step(ReplayCode.Teleport, [2, 3]))
|
||||
.then(value => {
|
||||
result = value;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(teleport).toHaveBeenCalledWith({ x: 2, y: 3 });
|
||||
expect(result).toBeUndefined();
|
||||
first.resolve();
|
||||
await pending;
|
||||
expect(result).toBe(ReplayCommandResult.Success);
|
||||
|
||||
const error = vi.spyOn(logger, 'error');
|
||||
teleport.mockReturnValueOnce(null);
|
||||
await expect(
|
||||
command.execute(step(ReplayCode.Teleport, [4, 5]))
|
||||
).resolves.toBe(ReplayCommandResult.Failed);
|
||||
expect(error).toHaveBeenCalledWith(2005, '4', '5');
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
// 验证道具使用直接返回既有状态接口结果,失败时记录错误码
|
||||
it('returns the hero item-use result', async () => {
|
||||
const state = createCoreState();
|
||||
const error = vi.spyOn(logger, 'error');
|
||||
const useItem = vi
|
||||
.spyOn(state.hero.items, 'useItem')
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce(false);
|
||||
const command = new ReplayUseItem(state);
|
||||
|
||||
await expect(
|
||||
command.execute(step(ReplayCode.UseItem, [12]))
|
||||
).resolves.toBe(ReplayCommandResult.Success);
|
||||
await expect(
|
||||
command.execute(step(ReplayCode.UseItem, [34]))
|
||||
).resolves.toBe(ReplayCommandResult.Failed);
|
||||
expect(useItem).toHaveBeenNthCalledWith(1, 12);
|
||||
expect(error).toHaveBeenCalledWith(2006, '34');
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
// 验证装备指令复用既有装备边界并区分三种失败位置
|
||||
// 验证装备指令复用既有装备边界并以最终槽位校验结果
|
||||
it('equips through the existing equipment boundary', async () => {
|
||||
const state = createCoreState();
|
||||
const equipment = state.hero.equip;
|
||||
const error = vi.spyOn(logger, 'error');
|
||||
const getEquipped = vi.spyOn(equipment, 'getEquipped');
|
||||
const equip = vi
|
||||
.spyOn(equipment, 'equip')
|
||||
.mockImplementation(() => undefined);
|
||||
const command = new ReplayEquip(state);
|
||||
|
||||
// 装备后槽位为指定 uid
|
||||
getEquipped.mockReturnValueOnce(99);
|
||||
await expect(
|
||||
command.execute(step(ReplayCode.Equip, [99, 0, true]))
|
||||
).resolves.toBe(ReplayCommandResult.Success);
|
||||
expect(equip).toHaveBeenCalledWith(99, 0, true);
|
||||
|
||||
// 装备后槽位未变为指定 uid
|
||||
getEquipped.mockReturnValueOnce(undefined);
|
||||
await expect(
|
||||
command.execute(step(ReplayCode.Equip, [99, 1, false]))
|
||||
).resolves.toBe(ReplayCommandResult.Failed);
|
||||
expect(error).toHaveBeenCalledWith(2007, '99', '1');
|
||||
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
// 验证卸下指令复用既有装备边界并以最终槽位校验结果
|
||||
it('unequips through the existing equipment boundary', async () => {
|
||||
const state = createCoreState();
|
||||
const equipment = state.hero.equip;
|
||||
const error = vi.spyOn(logger, 'error');
|
||||
const getEquipped = vi.spyOn(equipment, 'getEquipped');
|
||||
const unequip = vi
|
||||
.spyOn(equipment, 'unequip')
|
||||
.mockImplementation(() => undefined);
|
||||
const command = new ReplayUnequip(state);
|
||||
|
||||
// 卸下后槽位为空
|
||||
getEquipped.mockReturnValueOnce(undefined);
|
||||
await expect(
|
||||
command.execute(step(ReplayCode.Unequip, [0]))
|
||||
).resolves.toBe(ReplayCommandResult.Success);
|
||||
expect(unequip).toHaveBeenCalledWith(0);
|
||||
|
||||
// 卸下后槽位仍有装备
|
||||
getEquipped.mockReturnValueOnce(88);
|
||||
await expect(
|
||||
command.execute(step(ReplayCode.Unequip, [1]))
|
||||
).resolves.toBe(ReplayCommandResult.Failed);
|
||||
expect(error).toHaveBeenCalledWith(2008, '1');
|
||||
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
// 验证沙箱在下一步指令前先结束上一步的移动批次
|
||||
it('finalizes the previous move before executing a different command', async () => {
|
||||
const state = createCoreState();
|
||||
const mover = state.hero.location.mover;
|
||||
const move = vi.spyOn(mover, 'step');
|
||||
const first = Promise.withResolvers<void>();
|
||||
const start = vi
|
||||
.spyOn(mover, 'start')
|
||||
.mockReturnValueOnce(controller(first.promise));
|
||||
const useItem = vi
|
||||
.spyOn(state.hero.items, 'useItem')
|
||||
.mockReturnValueOnce(true);
|
||||
const replay = state.replaySystem;
|
||||
replay.record(ReplayCode.Right);
|
||||
replay.record(ReplayCode.UseItem, 5);
|
||||
const sandbox = replay.createReplaySandbox({
|
||||
route: replay.array,
|
||||
reseter: { reset: () => {} }
|
||||
});
|
||||
|
||||
await expect(sandbox.step()).resolves.toBe(true);
|
||||
expect(move).toHaveBeenCalledWith(FaceDirection.Right);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
|
||||
const next = sandbox.step();
|
||||
await Promise.resolve();
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(useItem).not.toHaveBeenCalled();
|
||||
first.resolve();
|
||||
await expect(next).resolves.toBe(true);
|
||||
expect(useItem).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
// 验证参数数量或类型不符的指令以 false 结束
|
||||
it('returns false for invalid command parameters', async () => {
|
||||
const state = createCoreState();
|
||||
const move = new ReplayMove(state, FaceDirection.Up);
|
||||
const teleport = new ReplayTeleport(state);
|
||||
const useItem = new ReplayUseItem(state);
|
||||
const equip = new ReplayEquip(state);
|
||||
const unequip = new ReplayUnequip(state);
|
||||
const invalid = [
|
||||
move.execute(step(ReplayCode.Up, [1])),
|
||||
teleport.execute(step(ReplayCode.Teleport, ['x', 1])),
|
||||
teleport.execute(step(ReplayCode.Teleport, [1])),
|
||||
useItem.execute(step(ReplayCode.UseItem, [])),
|
||||
useItem.execute(step(ReplayCode.UseItem, ['id'])),
|
||||
equip.execute(step(ReplayCode.Equip, [1, 0])),
|
||||
equip.execute(step(ReplayCode.Equip, [1, 0, 'x'])),
|
||||
unequip.execute(step(ReplayCode.Unequip, ['slot']))
|
||||
];
|
||||
await expect(Promise.all(invalid)).resolves.toEqual([
|
||||
ReplayCommandResult.Failed,
|
||||
ReplayCommandResult.Failed,
|
||||
ReplayCommandResult.Failed,
|
||||
ReplayCommandResult.Failed,
|
||||
ReplayCommandResult.Failed,
|
||||
ReplayCommandResult.Failed,
|
||||
ReplayCommandResult.Failed,
|
||||
ReplayCommandResult.Failed
|
||||
]);
|
||||
});
|
||||
|
||||
// 验证注册直接写在 CoreState 内,且指令模块自包含、不含 replay safety
|
||||
it('keeps registration direct and the command module self-contained', () => {
|
||||
const commands = readFileSync(
|
||||
new URL('../commands.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
expect(commands).not.toContain('createReplayCommandItems');
|
||||
expect(commands).not.toContain('registerReplayCommandItems');
|
||||
expect(commands).not.toContain('prettier-ignore');
|
||||
expect(commands).not.toContain('function isNumber');
|
||||
expect(commands).not.toContain('function resolveSlot');
|
||||
expect(commands).not.toContain('shouldReplay');
|
||||
|
||||
const core = readFileSync(
|
||||
new URL('../../core.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
expect(core).not.toContain('createReplayCommandItems');
|
||||
expect(core).not.toContain('registerReplayCommandItems');
|
||||
expect(core).toContain('private registerReplayCommands()');
|
||||
expect((core.match(/new ReplayMove\(this,/g) ?? []).length).toBe(4);
|
||||
expect(core).toContain('new ReplayTeleport(this)');
|
||||
expect(core).toContain('new ReplayUseItem(this)');
|
||||
expect(core).toContain('new ReplayEquip(this)');
|
||||
expect(core).toContain('new ReplayUnequip(this)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replay safety decorators', () => {
|
||||
// 验证同步 decorator 在方法返回后恢复嵌套 collection 上下文
|
||||
it('restores collection context after synchronous nested actions', () => {
|
||||
interface DecoratedFixture {
|
||||
inner(): void;
|
||||
outer(): void;
|
||||
}
|
||||
const inner = shouldReplay('inner')(
|
||||
function (this: DecoratedFixture): void {},
|
||||
{ name: 'inner' } as ClassMethodDecoratorContext<
|
||||
DecoratedFixture,
|
||||
(this: DecoratedFixture) => void
|
||||
>
|
||||
);
|
||||
const outer = shouldReplay('outer')(
|
||||
function (this: DecoratedFixture): void {
|
||||
this.inner();
|
||||
},
|
||||
{ name: 'outer' } as ClassMethodDecoratorContext<
|
||||
DecoratedFixture,
|
||||
(this: DecoratedFixture) => void
|
||||
>
|
||||
);
|
||||
const fixture: DecoratedFixture = {
|
||||
inner,
|
||||
outer
|
||||
};
|
||||
|
||||
const replay = new ReplaySystem();
|
||||
const warning = vi.spyOn(logger, 'warn');
|
||||
const group = vi.spyOn(console, 'group').mockImplementation(() => {});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
beginReplaySafetyCollection(replay);
|
||||
try {
|
||||
fixture.outer();
|
||||
fixture.inner();
|
||||
endReplaySafetyCollection();
|
||||
|
||||
const detail = warning.mock.calls.find(call => call[0] === 161);
|
||||
expect(detail).toBeDefined();
|
||||
const command = String(detail![1]);
|
||||
const match = command.match(/\((\d+)\)/);
|
||||
expect(match).not.toBeNull();
|
||||
logReplaySafetyDetail(Number(match![1]));
|
||||
expect(group).toHaveBeenCalled();
|
||||
expect(
|
||||
output.mock.calls.filter(call =>
|
||||
String(call[0]).startsWith('inner:')
|
||||
)
|
||||
).toHaveLength(2);
|
||||
} finally {
|
||||
warning.mockRestore();
|
||||
group.mockRestore();
|
||||
output.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// 验证同步 collection 在结束后可重新开始且不会残留旧上下文
|
||||
it('resets the collection lifecycle after completion', () => {
|
||||
const replay = new ReplaySystem();
|
||||
const warning = vi.spyOn(logger, 'warn');
|
||||
beginReplaySafetyCollection(replay);
|
||||
endReplaySafetyCollection();
|
||||
beginReplaySafetyCollection(replay);
|
||||
endReplaySafetyCollection();
|
||||
expect(warning).not.toHaveBeenCalledWith(159);
|
||||
warning.mockRestore();
|
||||
});
|
||||
|
||||
// 验证用户拥有的勇士属性变更方法带有 replay decorator 标注
|
||||
it('annotates user-owned attribute mutations with replay decorators', () => {
|
||||
const source = readFileSync(
|
||||
new URL(
|
||||
'../../../../data-base/src/hero/attribute.ts',
|
||||
import.meta.url
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
expect(source).toContain('@shouldReplay');
|
||||
});
|
||||
});
|
||||
@ -1 +0,0 @@
|
||||
export * from './commands';
|
||||
@ -3,6 +3,68 @@ import { ILoadManager, ILoadTaskStarter } from '@motajs/loader';
|
||||
import { IStateSystem } from '@user/data-system';
|
||||
import { ISaveableContent } from '@user/data-common';
|
||||
import { IMotaDataLoader } from './loader/types';
|
||||
import { BuiltInFunction } from '@motajs/anon-tokyo';
|
||||
|
||||
//#region 插件系统
|
||||
|
||||
export const enum PluginStatus {
|
||||
/** 还未进行插件同步初始化 */
|
||||
NotAwakened,
|
||||
/** 已经进行了插件同步初始化,但尚未执行异步初始化 */
|
||||
Awakened,
|
||||
/** 已经进行了插件异步初始化,但尚未执行加载初始化 */
|
||||
Initialized,
|
||||
/** 插件已经完全加载完毕 */
|
||||
Loaded
|
||||
}
|
||||
|
||||
export interface IDataPlugin<T extends IDataPluginDefine> {
|
||||
/** 插件的当前状态 */
|
||||
status: PluginStatus;
|
||||
/** 插件内容 */
|
||||
plugin: T;
|
||||
}
|
||||
|
||||
export interface IDataPluginDefine {
|
||||
/** 插件标识符 */
|
||||
readonly id: string;
|
||||
/** 插件所依赖的其他插件 id。依赖插件不允许循环依赖,否则会抛出错误,且所有在环上的插件都不会初始化 */
|
||||
readonly dependencies: string[];
|
||||
|
||||
/**
|
||||
* 插件同步初始化,在数据端主对象创建完毕,初始化之前时调用
|
||||
* @param state 数据端主对象
|
||||
* @param dependencies 此插件的依赖插件对象
|
||||
*/
|
||||
awake(
|
||||
state: ICoreState,
|
||||
dependencies: IDataPlugin<IDataPluginDefine>[]
|
||||
): void;
|
||||
|
||||
/**
|
||||
* 插件异步初始化,在主对象创建完毕,初始化时,加载开始前调用。
|
||||
* 此方法具有阻塞效果,如果返回值迟迟不兑现,那么会导致依赖其的插件不会开始异步初始化,游戏加载也不会开始
|
||||
* @param state 数据端主对象
|
||||
* @param dependencies 此插件的依赖插件对象
|
||||
*/
|
||||
initialize(
|
||||
state: ICoreState,
|
||||
dependencies: IDataPlugin<IDataPluginDefine>[]
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* 当主对象加载完毕后调用,可以用于依赖加载内容的初始化。
|
||||
* 此方法具有阻塞效果,如果返回值迟迟不兑现,那么会导致依赖其的插件不会开始加载初始化,游戏也不会开始
|
||||
* @param state 数据端主对象
|
||||
* @param dependencies 此插件的依赖插件对象
|
||||
*/
|
||||
afterLoad(
|
||||
state: ICoreState,
|
||||
dependencies: IDataPlugin<IDataPluginDefine>[]
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
export interface ICoreStateConfig {
|
||||
/**
|
||||
@ -12,6 +74,10 @@ export interface ICoreStateConfig {
|
||||
readonly loadStarter: ILoadTaskStarter;
|
||||
/** 核心配置文件的 URL 路径,也就是 `core.jsonc` 的文件路径 */
|
||||
readonly coreURL: string;
|
||||
/** 设置游戏使用的插件列表 */
|
||||
readonly dataPlugins: IDataPluginDefine[];
|
||||
/** 游戏的事件内建函数列表 */
|
||||
readonly events: Readonly<BuiltInFunction>[];
|
||||
}
|
||||
|
||||
export interface ISaveableExecutor<T> {
|
||||
@ -30,6 +96,19 @@ export interface ICoreState
|
||||
/** 加载管理器 */
|
||||
readonly loadManager: ILoadManager;
|
||||
|
||||
/**
|
||||
* 根据 id 获取已加装的插件
|
||||
* @param id 插件的标识符
|
||||
*/
|
||||
getDataPlugin<T extends IDataPluginDefine>(
|
||||
id: string
|
||||
): Readonly<IDataPlugin<T>> | null;
|
||||
|
||||
/**
|
||||
* 执行数据端主对象的加载行为,并进行必要的初始化
|
||||
*/
|
||||
load(): AsyncIterator<number, void>;
|
||||
|
||||
/**
|
||||
* 将某个存档执行器绑定至指定的可存档对象,一个可存档对象只能绑定一个执行器,
|
||||
* 但一个执行器可以绑定多个可存档对象,主要用来在读档后进行一些全局性的操作
|
||||
|
||||
@ -89,7 +89,7 @@ beforeAll(async () => {
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
const executorModule = await import('../executor');
|
||||
const systemModule = await import('../system');
|
||||
const stateModule = await import('../../../../data-state/src/hero');
|
||||
const stateModule = await import('../../../../../src/data/hero');
|
||||
const baseModule = await import('@user/data-base');
|
||||
const commonModule = await import('@user/data-common');
|
||||
modules = {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
export * from './combat';
|
||||
export * from './event';
|
||||
export * from './path';
|
||||
export * from './shop';
|
||||
|
||||
export * from './types';
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from './types';
|
||||
@ -1,73 +0,0 @@
|
||||
import {
|
||||
IDataBaseExtended,
|
||||
IHeroState,
|
||||
IShopManager,
|
||||
IShopState
|
||||
} from '@user/data-base';
|
||||
|
||||
export interface IShopHeroAttr {
|
||||
/** 勇士拥有的金币 */
|
||||
money: number;
|
||||
}
|
||||
|
||||
export const enum ShopTradeResult {
|
||||
/** 成功购入或售出 */
|
||||
Success,
|
||||
/** 操作不合法,一般是因为道具不存在或未打开商店 */
|
||||
Invalid,
|
||||
/** 商店库存不足,无法购入这么多道具 */
|
||||
ShopLack,
|
||||
/** 玩家库存不足,无法卖出这么多道具 */
|
||||
HeroLack,
|
||||
/** 玩家货币不足,无法购入这么多道具 */
|
||||
MoneyLack
|
||||
}
|
||||
|
||||
export interface IShopSystem extends IDataBaseExtended {
|
||||
/** 当前使用的商店管理器 */
|
||||
readonly manager: IShopManager | null;
|
||||
/** 当前绑定的勇士状态对象 */
|
||||
readonly hero: IHeroState<IShopHeroAttr> | null;
|
||||
|
||||
/**
|
||||
* 绑定商店管理器
|
||||
* @param manager 商店管理器
|
||||
*/
|
||||
bindManager(manager: IShopManager | null): void;
|
||||
|
||||
/**
|
||||
* 绑定系统使用的勇士状态对象
|
||||
* @param hero 勇士状态对象
|
||||
*/
|
||||
bindHero(hero: IHeroState<IShopHeroAttr> | null): void;
|
||||
|
||||
/**
|
||||
* 打开指定商店,仅数据端操作,不会打开商店 UI
|
||||
* @param shop 商店状态对象
|
||||
*/
|
||||
openShop(shop: IShopState): void;
|
||||
|
||||
/**
|
||||
* 获取当前已打开的商店状态对象
|
||||
*/
|
||||
getCurrentShop(): IShopState | null;
|
||||
|
||||
/**
|
||||
* 关闭当前打开的商店
|
||||
*/
|
||||
closeShop(): void;
|
||||
|
||||
/**
|
||||
* 在当前打开的商店里面购买指定道具
|
||||
* @param item 道具数字或 id
|
||||
* @param count 购买的道具数量
|
||||
*/
|
||||
buyItem(item: number | string, count: number): ShopTradeResult;
|
||||
|
||||
/**
|
||||
* 在当前打开的商店里面售出指定道具
|
||||
* @param item 道具数字或 id
|
||||
* @param count 卖出的道具数量
|
||||
*/
|
||||
sellItem(item: number | string, count: number): ShopTradeResult;
|
||||
}
|
||||
@ -74,6 +74,11 @@
|
||||
"72": "Replay error when replaying $1 replay step, detailed error message will show together with this error info.",
|
||||
"73": "No passive replay needed currently, but a 'getPassive' call detected, check if there's an issue on replay array or your replay logic.",
|
||||
"74": "A UI base props creator binding is needed before using ui controller.",
|
||||
"75": "Plugin $1 error, related plugins will not be initialized. Error info: $2.",
|
||||
"76": "Plugin dependency cycle detected: $1. Cyclic plugins will not be initialized.",
|
||||
"77": "Plugin $1 depends on a non-existent plugin: $2. Related plugins will not be initialized.",
|
||||
"78": "Duplicate plugin id: $1. The duplicate will not be registered.",
|
||||
"79": "Hero attribute initialize failed since no core config loaded.",
|
||||
"2001": "Replay($1): Replay parameter count mismatch: expected $2, got $3.",
|
||||
"2002": "Replay($1): Replay parameter type mismatch: index $2, expected $3, got $4.",
|
||||
"2003": "Replay(move): Expected hero to be stopped before execute moving step.",
|
||||
|
||||
12
src/bootstrap.ts
Normal file
12
src/bootstrap.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { WebLoadStarter } from '@motajs/loader';
|
||||
import { createClientCore } from './client/entry';
|
||||
import { IClientCore } from '@user/client-modules';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
mainClient: IClientCore;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await createClientCore(new WebLoadStarter());
|
||||
window.mainClient = client;
|
||||
38
src/client/entry.ts
Normal file
38
src/client/entry.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { ILoadTaskStarter } from '@motajs/loader';
|
||||
import {
|
||||
ClientCore,
|
||||
IClientCore,
|
||||
IClientCoreConfig
|
||||
} from '@user/client-modules';
|
||||
import { CLIENT_CORE_URL, TILESET_RESERVE, TILESET_UNIT } from '../constant';
|
||||
import { createCoreStateConfig } from '../data/entry';
|
||||
|
||||
/**
|
||||
* 创建渲染端配置对象
|
||||
* @param starter 加载启动器
|
||||
*/
|
||||
export function createClientCoreConfig(
|
||||
starter: ILoadTaskStarter
|
||||
): IClientCoreConfig {
|
||||
const config: IClientCoreConfig = {
|
||||
clientURL: CLIENT_CORE_URL,
|
||||
tilesetReserve: TILESET_RESERVE,
|
||||
tilesetUnit: TILESET_UNIT,
|
||||
|
||||
...createCoreStateConfig(starter)
|
||||
};
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建渲染端主对象,返回经过初始化的主对象实例
|
||||
* @param starter 加载启动器
|
||||
*/
|
||||
export async function createClientCore(
|
||||
starter: ILoadTaskStarter
|
||||
): Promise<IClientCore> {
|
||||
const config = createClientCoreConfig(starter);
|
||||
const state = new ClientCore(config);
|
||||
await state.load();
|
||||
return state;
|
||||
}
|
||||
23
src/constant.ts
Normal file
23
src/constant.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { FaceGroup } from '@user/data-common';
|
||||
|
||||
// 本文件是游戏的底层核心配置,如果不懂的话千万不要动
|
||||
|
||||
//#region 数据端配置
|
||||
|
||||
/** 数据端核心配置对象,不懂的话就不要动 */
|
||||
export const CORE_URL = 'content/core.jsonc';
|
||||
/** 勇士的默认朝向组,用于控制勇士的移动,不懂的话就不要动 */
|
||||
export const HERO_FACE = FaceGroup.Dir8;
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region 渲染端配置
|
||||
|
||||
/** 渲染端核心配置对象,不懂的话就不要动 */
|
||||
export const CLIENT_CORE_URL = 'content/client.jsonc';
|
||||
/** Tileset 预留数量,用于控制最大允许注册多少个普通图块,例如填写 10000 表示最大 10000 个普通图块,不懂的话不要动 */
|
||||
export const TILESET_RESERVE = 100000;
|
||||
/** 一个 Tileset 的图块数量单位,例如 10000 时表示一个 Tileset 会占用 10000 或 10000 的倍数个图块,不懂的话不要动 */
|
||||
export const TILESET_UNIT = 10000;
|
||||
|
||||
//#endregion
|
||||
@ -2,6 +2,7 @@ export * from './aura';
|
||||
export * from './calculator';
|
||||
export * from './comparer';
|
||||
export * from './final';
|
||||
export * from './initialize';
|
||||
export * from './mapDamage';
|
||||
export * from './special';
|
||||
export * from './types';
|
||||
48
src/data/enemy/initialize.ts
Normal file
48
src/data/enemy/initialize.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { ICoreState } from '@user/data-state';
|
||||
import { createSpecials } from './special';
|
||||
import { DamageSystem, MapDamage } from '@user/data-system';
|
||||
import {
|
||||
SpecialAuraConverter,
|
||||
CommonAuraConverter,
|
||||
GuardAuraConverter
|
||||
} from './aura';
|
||||
import { MainEnemyFinalEffect } from './final';
|
||||
import { MainDamageCalculator } from './calculator';
|
||||
import { MainEnemyComparer } from './comparer';
|
||||
import { MainMapDamageConverter, MainMapDamageReducer } from './mapDamage';
|
||||
|
||||
/**
|
||||
* 初始化怪物相关内容
|
||||
* @param state 数据端主对象
|
||||
* @param config 数据端配置
|
||||
*/
|
||||
export function initializeEnemy(state: ICoreState) {
|
||||
// 怪物管理器初始化
|
||||
const comparer = new MainEnemyComparer();
|
||||
state.enemyManager.attachEnemyComparer(comparer);
|
||||
|
||||
// 特殊属性定义注册
|
||||
const specials = createSpecials(state);
|
||||
for (const [code, cons] of specials) {
|
||||
state.enemyManager.registerSpecial(code, cons);
|
||||
}
|
||||
|
||||
// 初始化怪物上下文
|
||||
const ctx = state.enemyContext;
|
||||
const damageSystem = new DamageSystem(ctx);
|
||||
const mapDamage = new MapDamage(ctx);
|
||||
damageSystem.useCalculator(new MainDamageCalculator());
|
||||
mapDamage.useReducer(new MainMapDamageReducer());
|
||||
mapDamage.useConverter(new MainMapDamageConverter());
|
||||
ctx.attachDamageSystem(damageSystem);
|
||||
ctx.attachMapDamage(mapDamage);
|
||||
ctx.bindHero(state.hero.getAttribute());
|
||||
|
||||
// 注册光环转换器
|
||||
ctx.registerAuraConverter(new CommonAuraConverter());
|
||||
ctx.registerAuraConverter(new GuardAuraConverter());
|
||||
ctx.registerAuraConverter(new SpecialAuraConverter());
|
||||
|
||||
// 注册怪物最终效果
|
||||
ctx.registerFinalEffect(new MainEnemyFinalEffect());
|
||||
}
|
||||
@ -3,7 +3,9 @@ import {
|
||||
defineNonePropertySpecial,
|
||||
SpecialCreation
|
||||
} from '@user/data-base';
|
||||
import { IStateSystem } from '@user/data-system';
|
||||
import { ICoreState } from '@user/data-state';
|
||||
|
||||
// 该文件中的一些注释会充当编辑器的识别模式,如果删除可能导致编辑器无法识别,谨慎删除!!!
|
||||
|
||||
// 重要!!!下面这一行的注释不要删
|
||||
//#region 复合属性值类型
|
||||
@ -81,7 +83,7 @@ export interface ISpecial26Value {
|
||||
* 8. 重生属性:在插件系统中,目前尚未实装
|
||||
*/
|
||||
export function createSpecials(
|
||||
state: IStateSystem
|
||||
state: ICoreState
|
||||
): ReadonlyMap<number, SpecialCreation<any>> {
|
||||
const hero = state.hero;
|
||||
|
||||
12
src/data/enemy/types.ts
Normal file
12
src/data/enemy/types.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export const enum MapDamageType {
|
||||
/** 未知伤害 */
|
||||
Unknown,
|
||||
/** 领域伤害 */
|
||||
Zone,
|
||||
/** 激光伤害 */
|
||||
Layer,
|
||||
/** 阻击伤害 */
|
||||
Repulse,
|
||||
/** 夹击伤害 */
|
||||
Between
|
||||
}
|
||||
35
src/data/entry.ts
Normal file
35
src/data/entry.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { CoreState, ICoreState, ICoreStateConfig } from '@user/data-state';
|
||||
import { CORE_URL, HERO_FACE } from '../constant';
|
||||
import { ILoadTaskStarter } from '@motajs/loader';
|
||||
import { MainDataPlugin } from './initialize';
|
||||
import { dataPlugins } from './plugin';
|
||||
import { createEventRegistrations } from './event';
|
||||
|
||||
/**
|
||||
* 创建数据端配置对象
|
||||
* @param starter 加载启动器
|
||||
*/
|
||||
export function createCoreStateConfig(
|
||||
starter: ILoadTaskStarter
|
||||
): ICoreStateConfig {
|
||||
const config: ICoreStateConfig = {
|
||||
loadStarter: starter,
|
||||
coreURL: CORE_URL,
|
||||
dataPlugins: [new MainDataPlugin(HERO_FACE), ...dataPlugins],
|
||||
events: createEventRegistrations()
|
||||
};
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建数据端主对象,返回经过初始化的主对象实例
|
||||
* @param starter 加载启动器
|
||||
*/
|
||||
export async function createCoreState(
|
||||
starter: ILoadTaskStarter
|
||||
): Promise<ICoreState> {
|
||||
const config = createCoreStateConfig(starter);
|
||||
const state = new CoreState(config);
|
||||
await state.load();
|
||||
return state;
|
||||
}
|
||||
@ -1,3 +1,2 @@
|
||||
export * from './moverImpl';
|
||||
export * from './predicate';
|
||||
export * from './types';
|
||||
82
src/data/initialize.ts
Normal file
82
src/data/initialize.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import {
|
||||
Dir4FaceHandler,
|
||||
Dir8FaceHandler,
|
||||
FaceDirection,
|
||||
FaceGroup,
|
||||
IFacedTileLocator
|
||||
} from '@user/data-common';
|
||||
import {
|
||||
ICoreState,
|
||||
ICoreStateCoreConfig,
|
||||
IDataPluginDefine
|
||||
} from '@user/data-state';
|
||||
import { initializeEnemy } from './enemy';
|
||||
import { initializeReplay } from './replay';
|
||||
import { logger } from '@motajs/common';
|
||||
import { DefaultHeroMoveTopImpl, DefaultPassPredicateImpl } from './hero';
|
||||
import { DefaultDataLoaderHook } from './loader';
|
||||
|
||||
export class MainDataPlugin implements IDataPluginDefine {
|
||||
readonly id: string = 'main-data-plugin';
|
||||
readonly dependencies: string[] = [];
|
||||
|
||||
constructor(readonly heroFace: FaceGroup) {}
|
||||
|
||||
awake(state: ICoreState): void {
|
||||
// 朝向组初始化
|
||||
const dir4 = new Dir4FaceHandler();
|
||||
const dir8 = new Dir8FaceHandler();
|
||||
state.faceManager.register(FaceGroup.Dir4, dir4);
|
||||
state.faceManager.registerById('dir4', dir4);
|
||||
state.faceManager.register(FaceGroup.Dir8, dir8);
|
||||
state.faceManager.registerById('dir8', dir8);
|
||||
|
||||
// 寻路初始化
|
||||
const predicate = new DefaultPassPredicateImpl(state);
|
||||
state.pathfinding.finder.usePassPredicate(predicate);
|
||||
// 初始状态下勇士不在任何楼层,切换楼层后再具体设置
|
||||
state.pathfinding.finder.useMapLayer(null);
|
||||
state.pathfinding.useMover(state.hero.location.mover);
|
||||
|
||||
// 勇士同步初始化
|
||||
const heroMoveTopImpl = new DefaultHeroMoveTopImpl(state);
|
||||
state.hero.location.mover.useTopImplementation(heroMoveTopImpl);
|
||||
|
||||
// 加载
|
||||
state.loader.addHook(new DefaultDataLoaderHook());
|
||||
|
||||
// 其他复杂初始化
|
||||
initializeEnemy(state);
|
||||
initializeReplay(state);
|
||||
|
||||
// 存档初始化
|
||||
state.addSaveableContent('@system/hero', state.hero);
|
||||
state.addSaveableContent('@system/flags', state.flags);
|
||||
state.addSaveableContent('@system/maps', state.maps);
|
||||
state.addSaveableContent('@system/enemy', state.enemyManager);
|
||||
state.addSaveableContent('@system/replay', state.replaySystem);
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {}
|
||||
|
||||
async afterLoad(state: ICoreState): Promise<void> {
|
||||
// 进行勇士的加载后初始化
|
||||
const coreConfig = state.loader.getConfig<ICoreStateCoreConfig>('core');
|
||||
if (!coreConfig) {
|
||||
logger.error(79);
|
||||
return;
|
||||
}
|
||||
const dir = state.faceManager.get(this.heroFace);
|
||||
if (!dir) {
|
||||
logger.error(80);
|
||||
return;
|
||||
}
|
||||
state.hero.createAttribute(coreConfig.init.hero.attribute);
|
||||
const location: IFacedTileLocator = {
|
||||
x: coreConfig.init.hero.x,
|
||||
y: coreConfig.init.hero.y,
|
||||
direction: FaceDirection.Down
|
||||
};
|
||||
state.hero.createLocation(location, dir);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
import { logger } from '@motajs/common';
|
||||
import { ICoreStateCoreConfig } from '../common';
|
||||
import { IMotaDataLoader, IMotaDataLoaderHooks } from './types';
|
||||
import {
|
||||
ICoreStateCoreConfig,
|
||||
IMotaDataLoader,
|
||||
IMotaDataLoaderHooks
|
||||
} from '@user/data-state';
|
||||
|
||||
export class DefaultDataLoaderHook implements IMotaDataLoaderHooks {
|
||||
onCoreConfigLoaded(loader: IMotaDataLoader): Promise<void> {
|
||||
1
src/data/loader/index.ts
Normal file
1
src/data/loader/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './hook';
|
||||
1
src/data/plugin/index.ts
Normal file
1
src/data/plugin/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './list';
|
||||
5
src/data/plugin/list.ts
Normal file
5
src/data/plugin/list.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { IDataPluginDefine } from '@user/data-state';
|
||||
import { ShopPlugin } from './shop';
|
||||
|
||||
/** 数据端插件列表 */
|
||||
export const dataPlugins: IDataPluginDefine[] = [new ShopPlugin()];
|
||||
1
src/data/plugin/shop/index.ts
Normal file
1
src/data/plugin/shop/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './shop';
|
||||
@ -1,14 +1,15 @@
|
||||
import { IDataCommon, SaveCompression } from '@user/data-common';
|
||||
import { SaveCompression } from '@user/data-common';
|
||||
import { IShopManager, IShopManagerSave, IShopState } from './types';
|
||||
import { logger } from '@motajs/common';
|
||||
import { ShopState } from './state';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { ICoreState } from '@user/data-state';
|
||||
|
||||
export class ShopManager implements IShopManager {
|
||||
/** 当前管理器包含的商店列表 */
|
||||
private readonly shops: Map<string, IShopState> = new Map();
|
||||
|
||||
constructor(readonly state: IDataCommon) {}
|
||||
constructor(readonly state: ICoreState) {}
|
||||
|
||||
/**
|
||||
* 传入商店 id 或实例,输出商店实例
|
||||
32
src/data/plugin/shop/shop.ts
Normal file
32
src/data/plugin/shop/shop.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { ICoreState } from '@user/data-state';
|
||||
import { IShopManager, IShopPlugin, IShopState, IShopSystem } from './types';
|
||||
import { ShopManager } from './manager';
|
||||
import { ShopState } from './state';
|
||||
import { ShopSystem } from './system';
|
||||
|
||||
export class ShopPlugin implements IShopPlugin {
|
||||
id: string = 'shop-plugin';
|
||||
dependencies: string[] = [];
|
||||
|
||||
shop!: IShopSystem;
|
||||
manager!: IShopManager;
|
||||
|
||||
/** 数据端主对象 */
|
||||
private state!: ICoreState;
|
||||
|
||||
createShopState(id: string): IShopState {
|
||||
return new ShopState(id, this.state);
|
||||
}
|
||||
|
||||
awake(state: ICoreState): void {
|
||||
this.state = state;
|
||||
this.shop = new ShopSystem(state);
|
||||
this.manager = new ShopManager(state);
|
||||
|
||||
state.addSaveableContent('@plugin/shop', this.manager);
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {}
|
||||
|
||||
async afterLoad(): Promise<void> {}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { IDataCommon } from '@user/data-common';
|
||||
import { IShopState, IShopStateSave } from './types';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { logger } from '@motajs/common';
|
||||
import { ICoreState } from '@user/data-state';
|
||||
|
||||
export class ShopState implements IShopState {
|
||||
enabled: boolean = false;
|
||||
@ -15,7 +15,7 @@ export class ShopState implements IShopState {
|
||||
|
||||
constructor(
|
||||
readonly id: string,
|
||||
readonly state: IDataCommon
|
||||
readonly state: ICoreState
|
||||
) {}
|
||||
|
||||
addItem(token: number | string, count: number): void {
|
||||
@ -1,13 +1,15 @@
|
||||
import {
|
||||
IShopHeroAttr,
|
||||
IShopManager,
|
||||
IHeroState,
|
||||
IShopState,
|
||||
IStateBase
|
||||
} from '@user/data-base';
|
||||
import { IShopHeroAttr, IShopSystem, ShopTradeResult } from './types';
|
||||
IShopSystem,
|
||||
ShopTradeResult
|
||||
} from './types';
|
||||
import { logger } from '@motajs/common';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { shouldReplay } from '@user/data-common';
|
||||
import { ICoreState } from '@user/data-state';
|
||||
import { IHeroState } from '@user/data-base';
|
||||
|
||||
export class ShopSystem implements IShopSystem {
|
||||
manager: IShopManager | null = null;
|
||||
@ -15,7 +17,7 @@ export class ShopSystem implements IShopSystem {
|
||||
|
||||
private current: IShopState | null = null;
|
||||
|
||||
constructor(readonly state: IStateBase) {}
|
||||
constructor(readonly state: ICoreState) {}
|
||||
|
||||
bindManager(manager: IShopManager | null): void {
|
||||
this.closeShop();
|
||||
@ -1,4 +1,19 @@
|
||||
import { IDataCommonExtended, ISaveableContent } from '@user/data-common';
|
||||
import { IHeroState } from '@user/data-base';
|
||||
import { ISaveableContent } from '@user/data-common';
|
||||
import { ICoreStateExtended, IDataPluginDefine } from '@user/data-state';
|
||||
|
||||
export interface IShopPlugin extends IDataPluginDefine {
|
||||
/** 商店插件所使用的商店系统对象 */
|
||||
readonly shop: IShopSystem;
|
||||
/** 商店插件所使用的商店管理器对象 */
|
||||
readonly manager: IShopManager;
|
||||
|
||||
/**
|
||||
* 创建商店状态对象
|
||||
* @param id 商店状态对象的标识符
|
||||
*/
|
||||
createShopState(id: string): IShopState;
|
||||
}
|
||||
|
||||
export interface IShopStateSave {
|
||||
/** 商店 id */
|
||||
@ -14,7 +29,7 @@ export interface IShopStateSave {
|
||||
}
|
||||
|
||||
export interface IShopState
|
||||
extends IDataCommonExtended, ISaveableContent<IShopStateSave> {
|
||||
extends ICoreStateExtended, ISaveableContent<IShopStateSave> {
|
||||
/** 当前商店是否已经启用 */
|
||||
readonly enabled: boolean;
|
||||
/** 当前商店的标识符 */
|
||||
@ -69,7 +84,7 @@ export interface IShopManagerSave {
|
||||
}
|
||||
|
||||
export interface IShopManager
|
||||
extends IDataCommonExtended, ISaveableContent<IShopManagerSave> {
|
||||
extends ICoreStateExtended, ISaveableContent<IShopManagerSave> {
|
||||
/**
|
||||
* 添加新的商店
|
||||
* @param shop 商店状态对象
|
||||
@ -121,3 +136,70 @@ export interface IShopManager
|
||||
*/
|
||||
disableShop(shop: string | IShopState): void;
|
||||
}
|
||||
|
||||
export interface IShopHeroAttr {
|
||||
/** 勇士拥有的金币 */
|
||||
money: number;
|
||||
}
|
||||
|
||||
export const enum ShopTradeResult {
|
||||
/** 成功购入或售出 */
|
||||
Success,
|
||||
/** 操作不合法,一般是因为道具不存在或未打开商店 */
|
||||
Invalid,
|
||||
/** 商店库存不足,无法购入这么多道具 */
|
||||
ShopLack,
|
||||
/** 玩家库存不足,无法卖出这么多道具 */
|
||||
HeroLack,
|
||||
/** 玩家货币不足,无法购入这么多道具 */
|
||||
MoneyLack
|
||||
}
|
||||
|
||||
export interface IShopSystem extends ICoreStateExtended {
|
||||
/** 当前使用的商店管理器 */
|
||||
readonly manager: IShopManager | null;
|
||||
/** 当前绑定的勇士状态对象 */
|
||||
readonly hero: IHeroState<IShopHeroAttr> | null;
|
||||
|
||||
/**
|
||||
* 绑定商店管理器
|
||||
* @param manager 商店管理器
|
||||
*/
|
||||
bindManager(manager: IShopManager | null): void;
|
||||
|
||||
/**
|
||||
* 绑定系统使用的勇士状态对象
|
||||
* @param hero 勇士状态对象
|
||||
*/
|
||||
bindHero(hero: IHeroState<IShopHeroAttr> | null): void;
|
||||
|
||||
/**
|
||||
* 打开指定商店,仅数据端操作,不会打开商店 UI
|
||||
* @param shop 商店状态对象
|
||||
*/
|
||||
openShop(shop: IShopState): void;
|
||||
|
||||
/**
|
||||
* 获取当前已打开的商店状态对象
|
||||
*/
|
||||
getCurrentShop(): IShopState | null;
|
||||
|
||||
/**
|
||||
* 关闭当前打开的商店
|
||||
*/
|
||||
closeShop(): void;
|
||||
|
||||
/**
|
||||
* 在当前打开的商店里面购买指定道具
|
||||
* @param item 道具数字或 id
|
||||
* @param count 购买的道具数量
|
||||
*/
|
||||
buyItem(item: number | string, count: number): ShopTradeResult;
|
||||
|
||||
/**
|
||||
* 在当前打开的商店里面售出指定道具
|
||||
* @param item 道具数字或 id
|
||||
* @param count 卖出的道具数量
|
||||
*/
|
||||
sellItem(item: number | string, count: number): ShopTradeResult;
|
||||
}
|
||||
2
src/data/replay/index.ts
Normal file
2
src/data/replay/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './commands';
|
||||
export * from './initialize';
|
||||
35
src/data/replay/initialize.ts
Normal file
35
src/data/replay/initialize.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { FaceDirection, ReplayCode } from '@user/data-common';
|
||||
import { ICoreState } from '@user/data-state';
|
||||
import {
|
||||
ReplayEquip,
|
||||
ReplayMove,
|
||||
ReplayTeleport,
|
||||
ReplayUnequip,
|
||||
ReplayUseItem
|
||||
} from './commands';
|
||||
|
||||
/**
|
||||
* 初始化录像指令
|
||||
* @param state 数据端主对象
|
||||
*/
|
||||
export function initializeReplay(state: ICoreState) {
|
||||
const replay = state.replaySystem;
|
||||
|
||||
const up = new ReplayMove(state, FaceDirection.Up);
|
||||
const right = new ReplayMove(state, FaceDirection.Right);
|
||||
const down = new ReplayMove(state, FaceDirection.Down);
|
||||
const left = new ReplayMove(state, FaceDirection.Left);
|
||||
const teleport = new ReplayTeleport(state);
|
||||
const useItem = new ReplayUseItem(state);
|
||||
const equip = new ReplayEquip(state);
|
||||
const unequip = new ReplayUnequip(state);
|
||||
|
||||
replay.registerCommand(ReplayCode.Up, up);
|
||||
replay.registerCommand(ReplayCode.Right, right);
|
||||
replay.registerCommand(ReplayCode.Down, down);
|
||||
replay.registerCommand(ReplayCode.Left, left);
|
||||
replay.registerCommand(ReplayCode.Teleport, teleport);
|
||||
replay.registerCommand(ReplayCode.UseItem, useItem);
|
||||
replay.registerCommand(ReplayCode.Equip, equip);
|
||||
replay.registerCommand(ReplayCode.Unequip, unequip);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user