refactor(02-06): align pathfinding structure with user scope

This commit is contained in:
unanmed 2026-09-10 11:21:53 +08:00
parent 97a559cdf8
commit 26f7ad6b2e
23 changed files with 556 additions and 992 deletions

View File

@ -81,6 +81,8 @@ Plans:
3. 寻路正确避开不可通行格(碰撞/障碍/墙体)
**Plans**: 5/5 plans executed
**Scope correction (2026-09-10):** 用户审查确认本阶段不交付未授权的 `HeroPathfinding` L3 勇士封装;当前阶段保留 L2 寻路核心、共享通行性谓词与 DataCommon 方向映射依赖,勇士或渲染侧接线不作为本阶段实现内容。
**UI hint**: yes
Plans:

View File

@ -4,10 +4,10 @@ milestone: v1.0
current_phase: 02
current_phase_name: 寻路系统
status: verifying
stopped_at: Completed 02-05-PLAN.md
last_updated: "2026-09-10T01:49:11.198Z"
last_activity: 2026-09-09
last_activity_desc: Phase 02 execution started
stopped_at: Completed direct execution correction 02-06
last_updated: "2026-09-10T11:06:00.000Z"
last_activity: 2026-09-10
last_activity_desc: Phase 02 structural correction executed from user review
state_head: 154df6a0697b0ecf1b4b96b2a151aa9e00094964
progress:
total_phases: 6
@ -30,8 +30,8 @@ See: .planning/PROJECT.md (updated 2026-09-07)
Phase: 02 (寻路系统) — EXECUTING
Plan: 3 of 3
Status: Phase complete — ready for verification
Last activity: 2026-09-09 — Phase 02 execution started
Status: Phase complete — verification must be regenerated after direct scope correction
Last activity: 2026-09-10 — Phase 02 structural correction executed from user review
Progress: [█████████░] 92%
@ -122,6 +122,9 @@ Recent decisions affecting current work:
- [Phase 02]: Phase 02 Plan 05 sets Vitest testTimeout and hookTimeout to 30 seconds to cover full-suite beforeAll import cost.
- [Phase 02]: Phase 02 Plan 05 adds deterministic pnpm test:ci while preserving interactive pnpm test.
- [Phase 02]: Phase 02 Plan 05 preserves D-11 by modifying no client click adapter or Phase 1 file.
- [Phase 02]: 2026-09-10 用户结构审查修正graph 类型及注释归回 path/types.ts删除未授权的 HeroPathfinding L3 封装及其接线。
- [Phase 02]: 2026-09-10 用户结构审查修正DirectionMapper 由 IDataCommon 主对象共享注入;通行性谓词提取为 predicate.ts 的 DefaultPassPredicate。
- [Phase 02]: 2026-09-10 直接执行摘要 02-06旧的 L3 HeroPathfinding 相关验证记录仅代表历史实现,必须按修正后的 L2 范围重新验证。
### Pending Todos

View File

@ -0,0 +1,68 @@
---
phase: 02-pathfinding
plan: 06
type: execute
execution_mode: direct-user-correction
subsystem: pathfinding-structure
tags: [pathfinding, structure, data-common, predicate, scope-correction]
requires:
- phase: 02-pathfinding
provides: Existing L2 pathfinding implementation and Phase 2 execution artifacts
provides:
- Graph contracts and their API documentation restored to data-system/src/path/types.ts
- Shared DirectionMapper mounted on IDataCommon and consumed from the map state
- Generic DefaultPassPredicate extracted to predicate.ts
- Unauthorized HeroPathfinding L3 wrapper, tests, barrel, and CoreState wiring removed
affects: [phase-02 verification, phase-04-rendering]
user_constraints:
- Graph type declarations and comments belong in types.ts
- HeroPathfinding is outside the requested Phase 2 scope and must not exist
- DirectionMapper is a shared DataCommon dependency, not a per-builder instance
- Pass predicate is generic and is named DefaultPassPredicate
files_modified:
- .planning/STATE.md
- .planning/phases/02-pathfinding/02-DISCUSSION-LOG.md
- packages-user/data-common/src/types.ts
- packages-user/data-state/src/core.ts
- packages-user/data-state/src/hero/index.ts
- packages-user/data-state/src/hero/moverImpl.ts
- packages-user/data-state/src/hero/predicate.ts
- packages-user/data-state/src/index.ts
- packages-user/data-system/src/path/types.ts
- packages-user/data-system/src/path/graph.ts
- packages-user/data-system/src/path/finder.ts
- DataCommon test fixtures using IDataCommon
files_deleted:
- packages-user/data-state/src/path/heroPathfinding.ts
- packages-user/data-state/src/path/heroPathfinding.test.ts
- packages-user/data-state/src/path/index.ts
verification:
- command: pnpm test:ci
result: 10 files passed, 58 tests passed
- command: pnpm exec eslint <changed implementation and fixture files>
result: pass
- command: pnpm check:type
result: repository baseline remains non-zero; no new diagnostic in path, predicate, or IDataCommon changes
status: complete
completed: 2026-09-10
---
# Phase 02 Direct Execution Correction
## Summary
This direct execution followed the user's structural review and intentionally skipped a new Plan phase. The L2 pathfinding contracts remain in `types.ts`, while graph construction now consumes the shared `IDataCommon.directionMapper`. The pass predicate is no longer named or declared as hero-specific and is implemented in `hero/predicate.ts` through the `DefaultPassPredicate` interface.
The previously added `HeroPathfinding` L3 wrapper was outside the requested scope. Its implementation, integration tests, barrel, and `CoreState` exposure were removed. Historical 02-03/02-05 artifacts describe the earlier implementation and are not evidence that the deleted wrapper remains in the codebase; the phase verification artifact must be regenerated against this corrected scope.
## Verification Notes
- `pnpm test:ci` passed with 10 test files and 58 tests.
- Targeted ESLint passed with no problems.
- `pnpm check:type` remains blocked by pre-existing repository diagnostics in client/legacy/TileStore areas; the changed path and predicate code introduced no reported diagnostics.

View File

@ -77,3 +77,16 @@
## Deferred Ideas
- 移动端点击地图触发寻路的渲染端接线 → Phase 4 渲染适配
---
## 执行修正2026-09-10
本次执行直接处理用户对 Phase 2 产物的结构审查意见,跳过新的 Plan 阶段;以下四项为本次执行的约束与验收依据:
1. `IPathGraphEdge`、`IPathGraphNode`、`IPathGraph`、`IPathfindingGraphBuilder` 的类型声明与注释必须位于 `data-system/src/path/types.ts`,不得放在 `graph.ts`
2. 删除未获用户要求的 `data-state/src/path/heroPathfinding.ts` 及其测试、barrel 和 `CoreState` 接线Phase 2 不交付勇士专用 L3 寻路封装。
3. `DirectionMapper` 必须作为 `IDataCommon` 的共享依赖由主对象挂载,`PathfindingGraphBuilder` 不得自行构造。
4. 通行性谓词不得命名或实现为勇士专属对象;提取到独立 `predicate.ts`,接口命名为 `DefaultPassPredicate`
本记录作为本次直接执行的范围来源;执行结果写入 `02-06-SUMMARY.md`,验证以修改后的 L2 代码和现有测试为准。

View File

@ -0,0 +1,231 @@
---
phase: 02-pathfinding
verified: 2026-09-10T11:14:00Z
status: passed
score: 5/5 must-haves verified
covered_files:
- .planning/REQUIREMENTS.md
- .planning/ROADMAP.md
- .planning/STATE.md
- .planning/phases/02-pathfinding/02-01-PLAN.md
- .planning/phases/02-pathfinding/02-01-SUMMARY.md
- .planning/phases/02-pathfinding/02-02-PLAN.md
- .planning/phases/02-pathfinding/02-02-SUMMARY.md
- .planning/phases/02-pathfinding/02-03-PLAN.md
- .planning/phases/02-pathfinding/02-03-SUMMARY.md
- .planning/phases/02-pathfinding/02-04-PLAN.md
- .planning/phases/02-pathfinding/02-04-SUMMARY.md
- .planning/phases/02-pathfinding/02-05-PLAN.md
- .planning/phases/02-pathfinding/02-05-SUMMARY.md
- .planning/phases/02-pathfinding/02-06-SUMMARY.md
- .planning/phases/02-pathfinding/02-CONTEXT.md
- .planning/phases/02-pathfinding/02-DISCUSSION-LOG.md
- .planning/phases/02-pathfinding/02-INTERFACE-DRAFT.md
- .planning/phases/02-pathfinding/02-RESEARCH.md
- .planning/phases/02-pathfinding/02-VALIDATION.md
- .planning/phases/02-pathfinding/deferred-items.md
- package.json
- vite.config.ts
- packages/common/src/logger.json
- packages-user/data-base/src/hero/mover.ts
- packages-user/data-base/src/map/eventPath.test.ts
- packages-user/data-base/src/map/mapLifecycle.test.ts
- packages-user/data-common/src/common/mover.test.ts
- packages-user/data-common/src/common/mover.ts
- packages-user/data-common/src/types.ts
- packages-user/data-state/src/core.ts
- packages-user/data-state/src/hero/index.ts
- packages-user/data-state/src/hero/moverImpl.ts
- packages-user/data-state/src/hero/predicate.ts
- packages-user/data-state/src/index.ts
- packages-user/data-system/src/event/eventDispatch.test.ts
- packages-user/data-system/src/index.ts
- packages-user/data-system/src/path/finder.ts
- packages-user/data-system/src/path/graph.test.ts
- packages-user/data-system/src/path/graph.ts
- packages-user/data-system/src/path/index.ts
- packages-user/data-system/src/path/performance.test.ts
- packages-user/data-system/src/path/system.test.ts
- packages-user/data-system/src/path/system.ts
- packages-user/data-system/src/path/types.ts
covered_digest: "v1:sha256:9a240d6d07ec1d218c07f9e39ad36d4e2cf57344dd575db44c8063fac61f3945"
behavior_unverified: 0
overrides_applied: 0
re_verification:
previous_status: passed
previous_score: 23/24
gaps_closed: []
gaps_remaining: []
regressions: []
deferred:
- truth: "移动端点击地图上的可达格时,角色自动寻路移动到该格"
addressed_in: "Phase 4"
evidence: "ROADMAP.md scope correction and D-11 assign rendering/mobile click wiring to Phase 4."
decision_coverage:
honored: 11
total: 11
not_honored: []
---
# Phase 2: 寻路系统 Verification Report
**Phase Goal:** 引擎支持自动寻路,移动端点击地图即可触发移动
**Verified:** 2026-09-10T11:14:00Z
**Status:** passed
**Re-verification:** Yes — after the direct user-correction scope reset
## Verification Scope
This report verifies the corrected **L2-only** Phase 2 scope. The direct correction is authoritative:
- Graph contracts and their API documentation are in `packages-user/data-system/src/path/types.ts`.
- `HeroPathfinding`, its tests/barrel, and `CoreState` wiring were unauthorized and are intentionally absent. Their absence is not a regression and does not create a gap.
- `DirectionMapper` is supplied by `IDataCommon`; graph construction reads `layer.state.directionMapper` and does not instantiate a mapper.
- The generic pass predicate is in `packages-user/data-state/src/hero/predicate.ts` as `DefaultPassPredicate`; it is not hero-named.
The historical 02-03/02-04 artifacts mention the removed L3 wrapper, but those claims were not used as evidence. The live source and live tests below are authoritative.
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|---|---|---|
| 1 | L2 builds a reachable directed graph from map data, honors injected pass decisions, and marks terminal nodes | ✓ VERIFIED | `path/graph.ts` performs BFS from the start over injected `IPassPredicate`; `graph.test.ts` passed 12 tests covering directionality, walls, reachability, and terminal nodes. |
| 2 | L2 finds minimum-loss paths, supports custom costs, path-only retrieval, movement mode selection, fallback policy, and interruption | ✓ VERIFIED | `path/finder.ts` and `path/system.ts` are substantive and wired; `system.test.ts` passed 13 tests. Named movement and interruption tests each passed independently. |
| 3 | Graph construction consumes the shared `IDataCommon.directionMapper`, with no per-builder `DirectionMapper` construction | ✓ VERIFIED | `IDataCommon.directionMapper` is declared in `data-common/src/types.ts`, initialized once by `CoreState`, exposed through `IMapLayer.state`, and read at `graph.ts:114`; static scope check found no mapper construction in `path/graph.ts`. |
| 4 | Graph contracts and documentation live in `data-system/src/path/types.ts` and are exported through the L2 barrel | ✓ VERIFIED | `IPathGraphEdge`, `IPathGraphNode`, `IPathGraph`, and `IPathfindingGraphBuilder` plus their JSDoc are in `path/types.ts`; `path/index.ts` and `data-system/src/index.ts` export the path API. |
| 5 | The pass predicate is generic, lives in `data-state/src/hero/predicate.ts`, and is consumed through the existing mover predicate contract | ✓ VERIFIED | `DefaultPassPredicate`/`DefaultPassPredicateImpl` are the only predicate names in the live implementation; `moverImpl.ts` imports and constructs it, and `HeroMover` calls `predicate().canPass/shouldHit`. |
| 6 | Role-specific L3 automatic movement through `HeroPathfinding` | ⏭ OUT OF SCOPE | Explicitly removed by the direct user correction. `data-state/src/path/` is absent, `CoreState` has no pathfinding property/import, and no live source reference remains. Do not recreate it. |
| 7 | Mobile map click triggers role movement | ⏭ DEFERRED | No click adapter is present by design; D-11 and the roadmap assign this rendering integration to Phase 4. |
**Score:** 5/5 corrected in-scope truths verified. The two excluded/deferred rows are not counted as L2 must-haves.
### Scope Exclusions
| Item | Disposition | Evidence |
|---|---|---|
| `HeroPathfinding` implementation, tests, barrel, and `CoreState` wiring | Explicitly out of scope; intentionally deleted | `02-06-SUMMARY.md`, `02-DISCUSSION-LOG.md`, ROADMAP scope correction, and live absence checks. |
| Hero-specific end-to-end movement claims from historical plans | Not verified and not claimed | No live HeroPathfinding artifact exists; L2 tests use generic movable fixtures. |
### Deferred Items
| # | Item | Addressed In | Evidence |
|---|---|---|---|
| 1 | Mobile click → L2 pathfinding/movement adapter | Phase 4 | ROADMAP Phase 4 rendering/mobile scope and D-11. |
## Required Artifacts
| Artifact | Expected | Status | Details |
|---|---|---|---|
| `packages-user/data-system/src/path/types.ts` | Public graph and path contracts with API documentation | ✓ VERIFIED | Graph helper contracts and JSDoc are present in the corrected source file. |
| `packages-user/data-system/src/path/graph.ts` | Reachable directed graph builder | ✓ VERIFIED | Uses real `IMapLayer` locations, shared direction mapper, injected predicate, costs, and terminal-node classification. |
| `packages-user/data-system/src/path/finder.ts` | Minimum-loss path search | ✓ VERIFIED | Consumes `IPathGraph` and returns real `IPathfindingStep[]`; no static fallback. |
| `packages-user/data-system/src/path/system.ts` | L2 pathfinding system | ✓ VERIFIED | Wires finder, generic movable/mover bridge, path-only access, movement modes, fallback, and interruption. |
| `packages-user/data-system/src/path/index.ts` | L2 path barrel | ✓ VERIFIED | Exports finder, graph, system, and types. |
| `packages-user/data-common/src/types.ts` | Shared `IDataCommon.directionMapper` dependency | ✓ VERIFIED | `IDirectionMapper` field is part of the shared contract. |
| `packages-user/data-state/src/core.ts` | Shared mapper initialization | ✓ VERIFIED | Constructs one `DirectionMapper` for the common state; HeroPathfinding wiring is correctly absent. |
| `packages-user/data-state/src/hero/predicate.ts` | Generic `DefaultPassPredicate` contract/implementation | ✓ VERIFIED | Substantive pass-mask and hit logic, no hero-specific class/interface name. |
| `packages-user/data-state/src/hero/moverImpl.ts` | Existing mover consumes generic predicate and preserves event-source behavior | ✓ VERIFIED | Imports `DefaultPassPredicate`, guards nullable static sources, and retains dynamic event collection/order. |
| `packages-user/data-system/src/path/graph.test.ts` | Graph behavior coverage | ✓ VERIFIED | 12 active tests passed. |
| `packages-user/data-system/src/path/system.test.ts` | L2 path/system behavior coverage | ✓ VERIFIED | 13 active tests passed. |
| `packages-user/data-system/src/path/performance.test.ts` | Performance sanity coverage | ✓ VERIFIED | 3 active tests passed, including map-size and real-map cases. |
| `package.json` / `vite.config.ts` | Deterministic test gate | ✓ VERIFIED | `test:ci` runs `vitest run`; test and hook budgets are 30 seconds. |
The deleted `packages-user/data-state/src/path/*` files are intentionally not required artifacts for the corrected phase and are not reported as missing.
## Key Link Verification
| From | To | Via | Status | Details |
|---|---|---|---|---|
| `IMapLayer.state` | `IDataCommon.directionMapper` | `layer.state.directionMapper.map(this.group)` | ✓ WIRED | `IMapLayer` extends `IDataCommonExtended`; `MapLayer.state` is the shared common state. |
| `PathfindingGraphBuilder` | graph contracts | imports from `./types` | ✓ WIRED | `graph.ts` imports all graph interfaces from `types.ts`; no duplicate declarations remain. |
| `PathfindingFinder` | `PathfindingGraphBuilder` | builder construction and `build(start)` | ✓ WIRED | Each find constructs a graph from current map/predicate/cost state, then searches it. |
| `PathfindingSystem` | `PathfindingFinder` / `IObjectMover` | finder calls and mover controller | ✓ WIRED | L2 tests exercise path-only, step, teleport, fallback, and stop behavior with a generic movable fixture. |
| `DefaultHeroMoveTopImpl` | `DefaultPassPredicate` | constructor + `predicate()` | ✓ WIRED | Existing mover behavior obtains the generic predicate implementation from `predicate.ts`. |
| `data-system/src/index.ts` | `data-system/src/path/index.ts` | `export * from './path'` | ✓ WIRED | L2 path API is publicly reachable. |
| `HeroPathfinding` | L2 pathfinding | — | ⏭ OUT OF SCOPE | No link is expected after the authorized deletion. |
| Mobile click adapter | L2 pathfinding | — | ⏭ DEFERRED | Rendering integration belongs to Phase 4. |
## Data-Flow Trace (Level 4)
| Artifact | Data variable | Source | Produces Real Data | Status |
|---|---|---|---|---|
| `PathfindingGraphBuilder` | nodes/edges | Current `IMapLayer` locations + shared direction mapper + injected predicate | Yes | ✓ FLOWING |
| `PathfindingFinder` | path steps | Reachable graph + node costs from current map state | Yes | ✓ FLOWING |
| `PathfindingSystem` | movement controller/path | Current generic `IObjectMover` position → finder → mover queue | Yes | ✓ FLOWING |
| `DefaultPassPredicate` | pass/hit decisions | `IMapState` event/layer pass data | Yes | ✓ FLOWING |
| Mobile click flow | target coordinate | No client pointer/touch source in this phase | No | ⏭ DEFERRED |
## Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|---|---|---|---|
| Full corrected phase regression suite | `pnpm test:ci` | Exit 0; 10 files passed, 58 tests passed, 0 skipped, 0 failed | ✓ PASS |
| L2 movement transition | `pnpm exec vitest run packages-user/data-system/src/path/system.test.ts -t "moves step by step to the target and wraps the controller"` | 1 named test passed | ✓ PASS |
| L2 interruption transition | `pnpm exec vitest run packages-user/data-system/src/path/system.test.ts -t "interrupts the ongoing pathfinding move safely"` | 1 named test passed | ✓ PASS |
| Targeted implementation/fixture ESLint | `pnpm exec eslint` over changed implementation and fixture files | Exit 0; no problems | ✓ PASS |
| Scope/static checks | `git diff --check` plus live-source checks for HeroPathfinding, graph-local mapper construction, and hero-named predicate | No production scope violations; diff whitespace warnings only on planning files | ✓ PASS |
Filtered named-test runs report 12 Vitest-filtered tests; those are test-selection exclusions, not disabled tests. The full suite reports zero skipped tests.
## Probe Execution
No phase-declared or conventional `probe-*.sh` probe exists. Probe execution was not applicable.
## Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|---|---|---|---|---|
| PATH-01 | 02-01/02-02/02-03/02-04/02-05 | Engine supports automatic pathfinding | ✓ SATISFIED (corrected L2 scope) | Directed reachable graph, minimum-loss search, obstacle avoidance, path-only and movement APIs are live and covered by 12 graph tests, 13 system tests, 3 performance tests, and the full suite. |
| PATH-02 | 02-01/02-02/02-03/02-05 | Mobile map click triggers automatic movement | ⏭ DEFERRED | Mobile click wiring is explicitly excluded by D-11 and assigned to Phase 4; no click implementation is claimed. |
No orphaned Phase 2 requirements were found.
## Decision Coverage
`check.decision-coverage-verify` reported **11/11** trackable context decisions honored. This is a non-blocking warning gate and produced no unhonored decisions.
## Test Quality Audit
| Test File | Linked Req | Active | Skipped | Circular | Assertion Level | Verdict |
|---|---|---:|---:|---:|---|---|
| `data-system/src/path/graph.test.ts` | PATH-01 | 12 | 0 | 0 | Behavioral/value | PASS |
| `data-system/src/path/system.test.ts` | PATH-01 | 13 | 0 | 0 | Behavioral/value | PASS |
| `data-system/src/path/performance.test.ts` | PATH-01 | 3 | 0 | 0 | Status/value | PASS |
| `data-common/src/common/mover.test.ts` | PATH-01 regression | 4 | 0 | 0 | Behavioral/value | PASS |
No disabled-test pattern was found in the path test files. No requirement-linked test generates expected values from the system under test.
## Quality Gates and Baseline Diagnostics
- `pnpm test:ci`: **PASS** — 10 files, 58 tests, no skipped or failed tests.
- Targeted ESLint: **PASS** — no problems in changed implementation/fixture files.
- `pnpm check:type`: **baseline non-zero** — unrelated client/legacy and TileStore diagnostics remain. `CoreState` still reports the pre-existing TileStore TS2322/TS2345 pair; no path, predicate, `moverImpl.ts`, or `IDataCommon` diagnostic was reported.
- `pnpm check:circular`: **baseline non-zero** — 18 existing cycles reported; none traverses `data-system/src/path` or `data-state/src/hero/predicate.ts`. The existing `data-common/src/types.ts` cycle is not new: that file already imported `@motajs/common` before this correction.
- `git diff --check`: no code whitespace errors; it reports only existing LF→CRLF warnings for modified planning documents.
- Runtime warnings during tests: stale Browserslist data and expected fixture/logger warnings; none caused test failure.
## Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|---|---:|---|---|---|
| `packages-user/data-state/src/core.ts` | 154 | Existing `TODO` for serialized event registration | Info | Pre-existing Phase 1/deferred work; not a `TBD`/`FIXME`/`XXX` debt marker and unrelated to L2 pathfinding. |
| `packages-user/data-system/src/path/finder.ts`, `system.ts` | guard returns | `return []` / `return null` | Info | Contract-prescribed invalid/unreachable/no-controller results; directly covered by tests, not stubs. |
No unreferenced `TBD`, `FIXME`, or `XXX` marker was found in the corrected implementation files. No placeholder or console-only pathfinding implementation was found.
## Human Verification Required
None. This is an infrastructure/data-system phase with no user-facing UI in the corrected scope. Visual/mobile click verification belongs to Phase 4, not this report.
## Gaps Summary
The corrected L2 deliverable is present, substantive, wired, and behaviorally covered. The graph contracts and JSDoc are in the user-required `path/types.ts`; graph construction consumes the shared `IDataCommon.directionMapper`; and the generic `DefaultPassPredicate` is extracted without hero-specific naming.
The deleted `HeroPathfinding` L3 implementation and its CoreState/barrel/test wiring are intentional scope exclusions, not gaps. Mobile click integration remains a Phase 4 deferred item. Repository-wide type and circular-dependency failures are documented baseline diagnostics outside the corrected L2 path deliverable.
---
_Verified: 2026-09-10T11:14:00Z_
_Verifier: the agent (gsd-verifier)_

View File

@ -10,6 +10,7 @@ interface TestModules {
MapState: typeof import('./mapState').MapState;
TileStore: typeof import('@user/data-common').TileStore;
RoleFaceBinder: typeof import('@user/data-common').RoleFaceBinder;
DirectionMapper: typeof import('@motajs/common').DirectionMapper;
logger: typeof import('@motajs/common').logger;
}
@ -35,6 +36,7 @@ beforeAll(async () => {
MapState: mapModule.MapState,
TileStore: commonModule.TileStore,
RoleFaceBinder: commonModule.RoleFaceBinder,
DirectionMapper: loggerModule.DirectionMapper,
logger: loggerModule.logger
};
});
@ -58,6 +60,7 @@ function createMapState() {
eventStore: {},
roleFace: new modules.RoleFaceBinder(),
faceManager: {},
directionMapper: new modules.DirectionMapper(),
saveSystem: {}
} as never;
return new modules.MapState(tileStore, state);

View File

@ -29,6 +29,7 @@ interface TestModules {
RoleFaceBinder: typeof import('@user/data-common').RoleFaceBinder;
FaceManager: typeof import('@user/data-common').FaceManager;
Dir8FaceHandler: typeof import('@user/data-common').Dir8FaceHandler;
DirectionMapper: typeof import('@motajs/common').DirectionMapper;
}
let modules: TestModules;
@ -38,12 +39,14 @@ beforeAll(async () => {
vi.stubGlobal('location', { origin: 'http://localhost' });
const mapModule = await import('./mapState');
const commonModule = await import('@user/data-common');
const motaModule = await import('@motajs/common');
modules = {
MapState: mapModule.MapState,
TileStore: commonModule.TileStore,
RoleFaceBinder: commonModule.RoleFaceBinder,
FaceManager: commonModule.FaceManager,
Dir8FaceHandler: commonModule.Dir8FaceHandler
Dir8FaceHandler: commonModule.Dir8FaceHandler,
DirectionMapper: motaModule.DirectionMapper
};
});
@ -77,6 +80,7 @@ function createMapState(
eventStore: {},
roleFace: new modules.RoleFaceBinder(),
faceManager,
directionMapper: new modules.DirectionMapper(),
saveSystem: {}
} as never;
const mapState = new modules.MapState(tileStore, state);

View File

@ -1,4 +1,4 @@
import { ITileLocator } from '@motajs/common';
import { IDirectionMapper, ITileLocator } from '@motajs/common';
import { IFaceManager, IRoleFaceBinder } from './common';
import { IGameEventStore, IItemStore, IMapStore, ITileStore } from './store';
import { ISaveSystem } from './save';
@ -56,6 +56,8 @@ export interface IDataCommon {
readonly roleFace: IRoleFaceBinder;
/** 朝向管理 */
readonly faceManager: IFaceManager;
/** 方向组映射器 */
readonly directionMapper: IDirectionMapper;
/** 存档系统 */
readonly saveSystem: ISaveSystem;
}

View File

@ -75,9 +75,8 @@ import {
} from './legacy';
import { ILoadProgressTotal, LoadProgressTotal } from '@motajs/loader';
import { isNil } from 'lodash-es';
import { logger } from '@motajs/common';
import { DirectionMapper, IDirectionMapper, logger } from '@motajs/common';
import { DefaultHeroMoveTopImpl } from './hero';
import { HeroPathfinding } from './path';
export class CoreState implements ICoreState {
// Layer 0 公共层,最底层的接口,不会依赖任何其他内容,一般是工具性接口及不需要存档的数据
@ -88,6 +87,7 @@ export class CoreState implements ICoreState {
readonly itemStore: IItemStore<IHeroAttr, LegacyItemData>;
readonly mapStore: IMapStore;
readonly eventStore: IGameEventStore;
readonly directionMapper: IDirectionMapper;
// Layer 1 数据层,所有可存档内容都在这,一般用于数据存储
readonly maps: IMapState;
@ -102,9 +102,6 @@ export class CoreState implements ICoreState {
// Layer 3 用户层,也就是最顶层的内容,一般仅用于初始化以及仅供渲染端调用的顶层模块
readonly loadProgress: ILoadProgressTotal;
readonly dataLoader: IMotaDataLoader;
/** 勇士寻路入口 */
readonly pathfinding: HeroPathfinding;
/** 可存档对象映射 */
private readonly saveables: Map<string, ISaveableContent<any>> = new Map();
/** 所有已添加的可存档对象 */
@ -153,6 +150,7 @@ export class CoreState implements ICoreState {
// 游戏事件
const eventStore = new GameEventStore();
this.eventStore = eventStore;
this.directionMapper = new DirectionMapper();
// TODO: 后续在此初始化路径注册外部序列化事件定义与地图事件 id 绑定。
//#endregion
@ -238,7 +236,6 @@ export class CoreState implements ICoreState {
// 勇士顶层初始化
const heroMoveTopImpl = new DefaultHeroMoveTopImpl(this);
this.hero.location.mover.useTopImplementation(heroMoveTopImpl);
this.pathfinding = new HeroPathfinding(this, heroMoveTopImpl);
//#endregion
}

View File

@ -1,2 +1,3 @@
export * from './moverImpl';
export * from './predicate';
export * from './types';

View File

@ -7,12 +7,12 @@ import {
IHeroMoveTopHandler,
IHeroMoveTopImpl,
IMapState,
IPassCheckHandler,
IPassPredicate,
IReadonlyTileBase
} from '@user/data-base';
import { EventTrigger, FaceDirection, PassBit } from '@user/data-common';
import { EventTrigger } from '@user/data-common';
import { IGameEventExecutor, IStateSystem } from '@user/data-system';
import { DefaultPassPredicate, DefaultPassPredicateImpl } from './predicate';
import { isNil } from 'lodash-es';
interface IEventSource {
@ -22,129 +22,18 @@ interface IEventSource {
readonly tile: IReadonlyTileBase | null;
}
interface IDefaultHeroPassPredicate extends IPassPredicate {}
class DefaultHeroPassPredicate implements IDefaultHeroPassPredicate {
constructor(private readonly maps: IMapState) {}
private directionToPassBit(dir: FaceDirection): number {
switch (dir) {
case FaceDirection.Up:
return PassBit.Up;
case FaceDirection.Right:
return PassBit.Right;
case FaceDirection.Down:
return PassBit.Down;
case FaceDirection.Left:
return PassBit.Left;
default:
return 0;
}
}
private oppositeDirection(dir: FaceDirection): FaceDirection {
switch (dir) {
case FaceDirection.Up:
return FaceDirection.Down;
case FaceDirection.Right:
return FaceDirection.Left;
case FaceDirection.Down:
return FaceDirection.Up;
case FaceDirection.Left:
return FaceDirection.Right;
case FaceDirection.LeftUp:
return FaceDirection.RightDown;
case FaceDirection.RightUp:
return FaceDirection.LeftDown;
case FaceDirection.LeftDown:
return FaceDirection.RightUp;
case FaceDirection.RightDown:
return FaceDirection.LeftUp;
default:
return FaceDirection.Unknown;
}
}
canPass(handler: IPassCheckHandler): boolean {
const { currLoc, nextLoc, direction, floorId } = handler;
if (isNil(floorId)) return false;
if (
direction === FaceDirection.LeftDown ||
direction === FaceDirection.LeftUp ||
direction === FaceDirection.RightDown ||
direction === FaceDirection.RightUp
) {
return true;
}
const map = this.maps.getMap(floorId);
if (!map) return false;
const event = map.eventLayer;
if (!event) return false;
const { x, y } = currLoc;
const { x: nx, y: ny } = nextLoc;
const leaveMask = this.directionToPassBit(direction);
const enterMask = this.directionToPassBit(
this.oppositeDirection(direction)
);
let canLeave = true;
let canEnter = true;
const curr = event.getLocationData(x, y);
const next = event.getLocationData(nx, ny);
const currRaw = curr?.static?.raw();
const nextRaw = next?.static?.raw();
if (currRaw) canLeave = !!(leaveMask & currRaw.pass.outPass);
if (nextRaw) canEnter = !!(enterMask & nextRaw.pass.inPass);
if (!canLeave || !canEnter) return false;
for (const layer of map.layerList) {
if (layer === event) continue;
const curr = layer.getLocationData(x, y);
const next = layer.getLocationData(nx, ny);
let canLeave = true;
let canEnter = true;
const currRaw = curr?.static?.raw();
const nextRaw = next?.static?.raw();
if (currRaw?.pass.onlyEvents) {
canLeave = !!(leaveMask & currRaw.pass.outPass);
}
if (nextRaw?.pass.onlyEvents) {
canEnter = !!(enterMask & nextRaw.pass.inPass);
}
if (!canLeave || !canEnter) return false;
}
return true;
}
shouldHit(handler: IPassCheckHandler): boolean {
const { nextLoc, floorId } = handler;
if (isNil(floorId)) return false;
const map = this.maps.getMap(floorId);
if (!map) return false;
const eventLayer = map.eventLayer;
if (!eventLayer) return false;
const next = eventLayer.getLocationData(nextLoc.x, nextLoc.y);
const nextRaw = next?.static?.raw();
return !!nextRaw && !nextRaw.eventPass;
}
}
export class DefaultHeroMoveTopImpl implements IHeroMoveTopImpl {
/** 地图存储对象 */
private readonly maps: IMapState;
/** 游戏事件执行器 */
private readonly executor: IGameEventExecutor;
/** 勇士移动使用的通行性谓词 */
private readonly passPredicate: IPassPredicate;
private readonly passPredicate: DefaultPassPredicate;
constructor(state: IStateSystem) {
this.maps = state.maps;
this.executor = state.eventSystem.executor;
this.passPredicate = new DefaultHeroPassPredicate(this.maps);
this.passPredicate = new DefaultPassPredicateImpl(this.maps);
}
//#region 通行性判断

View File

@ -0,0 +1,114 @@
import { IMapState, IPassCheckHandler, IPassPredicate } from '@user/data-base';
import { FaceDirection, PassBit } from '@user/data-common';
import { isNil } from 'lodash-es';
export interface DefaultPassPredicate extends IPassPredicate {}
export class DefaultPassPredicateImpl implements DefaultPassPredicate {
constructor(private readonly maps: IMapState) {}
private directionToPassBit(dir: FaceDirection): number {
switch (dir) {
case FaceDirection.Up:
return PassBit.Up;
case FaceDirection.Right:
return PassBit.Right;
case FaceDirection.Down:
return PassBit.Down;
case FaceDirection.Left:
return PassBit.Left;
default:
return 0;
}
}
private oppositeDirection(dir: FaceDirection): FaceDirection {
switch (dir) {
case FaceDirection.Up:
return FaceDirection.Down;
case FaceDirection.Right:
return FaceDirection.Left;
case FaceDirection.Down:
return FaceDirection.Up;
case FaceDirection.Left:
return FaceDirection.Right;
case FaceDirection.LeftUp:
return FaceDirection.RightDown;
case FaceDirection.RightUp:
return FaceDirection.LeftDown;
case FaceDirection.LeftDown:
return FaceDirection.RightUp;
case FaceDirection.RightDown:
return FaceDirection.LeftUp;
default:
return FaceDirection.Unknown;
}
}
canPass(handler: IPassCheckHandler): boolean {
const { currLoc, nextLoc, direction, floorId } = handler;
if (isNil(floorId)) return false;
if (
direction === FaceDirection.LeftDown ||
direction === FaceDirection.LeftUp ||
direction === FaceDirection.RightDown ||
direction === FaceDirection.RightUp
) {
return true;
}
const map = this.maps.getMap(floorId);
if (!map) return false;
const event = map.eventLayer;
if (!event) return false;
const { x, y } = currLoc;
const { x: nx, y: ny } = nextLoc;
const leaveMask = this.directionToPassBit(direction);
const enterMask = this.directionToPassBit(
this.oppositeDirection(direction)
);
let canLeave = true;
let canEnter = true;
const curr = event.getLocationData(x, y);
const next = event.getLocationData(nx, ny);
const currRaw = curr?.static?.raw();
const nextRaw = next?.static?.raw();
if (currRaw) canLeave = !!(leaveMask & currRaw.pass.outPass);
if (nextRaw) canEnter = !!(enterMask & nextRaw.pass.inPass);
if (!canLeave || !canEnter) return false;
for (const layer of map.layerList) {
if (layer === event) continue;
const curr = layer.getLocationData(x, y);
const next = layer.getLocationData(nx, ny);
let canLeave = true;
let canEnter = true;
const currRaw = curr?.static?.raw();
const nextRaw = next?.static?.raw();
if (currRaw?.pass.onlyEvents) {
canLeave = !!(leaveMask & currRaw.pass.outPass);
}
if (nextRaw?.pass.onlyEvents) {
canEnter = !!(enterMask & nextRaw.pass.inPass);
}
if (!canLeave || !canEnter) return false;
}
return true;
}
shouldHit(handler: IPassCheckHandler): boolean {
const { nextLoc, floorId } = handler;
if (isNil(floorId)) return false;
const map = this.maps.getMap(floorId);
if (!map) return false;
const eventLayer = map.eventLayer;
if (!eventLayer) return false;
const next = eventLayer.getLocationData(nextLoc.x, nextLoc.y);
const nextRaw = next?.static?.raw();
return !!nextRaw && !nextRaw.eventPass;
}
}

View File

@ -1,6 +1,5 @@
export * from './enemy';
export * from './hero';
export * from './path';
export * from './core';
export * from './ins';

View File

@ -1,358 +0,0 @@
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { ITileStore } from '@user/data-common';
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;
};
});
interface TestModules {
EventExecutor: typeof import('@user/data-system').EventExecutor;
DefaultHeroMoveTopImpl: typeof import('../hero').DefaultHeroMoveTopImpl;
HeroMover: typeof import('@user/data-base').HeroMover;
HeroPathfinding: typeof import('./heroPathfinding').HeroPathfinding;
MapState: typeof import('@user/data-base').MapState;
TileStore: typeof import('@user/data-common').TileStore;
Dir8FaceHandler: typeof import('@user/data-common').Dir8FaceHandler;
FaceManager: typeof import('@user/data-common').FaceManager;
RoleFaceBinder: typeof import('@user/data-common').RoleFaceBinder;
}
interface EventCall {
readonly id: string;
readonly trigger: number;
readonly hero: Readonly<{ x: number; y: number }>;
}
interface HeroFixture {
x: number;
y: number;
floorId: string;
state: unknown;
mover?: { readonly faceDirection: number };
setPos(x: number, y: number): void;
getCurrentFaceDirection(): number;
}
let modules: TestModules;
beforeAll(async () => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
const base = await import('@user/data-base');
const common = await import('@user/data-common');
const state = await import('../hero');
const path = await import('./heroPathfinding');
const event = await import('@user/data-system');
modules = {
EventExecutor: event.EventExecutor,
DefaultHeroMoveTopImpl: state.DefaultHeroMoveTopImpl,
HeroMover: base.HeroMover,
HeroPathfinding: path.HeroPathfinding,
MapState: base.MapState,
TileStore: common.TileStore,
Dir8FaceHandler: common.Dir8FaceHandler,
FaceManager: common.FaceManager,
RoleFaceBinder: common.RoleFaceBinder
};
});
interface FixtureOptions {
readonly middleEvent?: boolean;
readonly targetNoPass?: boolean;
readonly sealedTarget?: boolean;
}
function createFixture(options: FixtureOptions = {}) {
const tileStore: ITileStore = new modules.TileStore() as never;
tileStore.addTile({
num: 1,
id: 'floor',
events: {},
type: 0,
pass: { onlyEvents: false, outPass: 15, inPass: 15 },
eventPass: true
});
tileStore.addTile({
num: 2,
id: 'wall',
events: { 40: 'wall-touch' },
type: 0,
pass: { onlyEvents: false, outPass: 0, inPass: 0 },
eventPass: true
});
const faceManager = new modules.FaceManager();
const faceHandler = new modules.Dir8FaceHandler();
faceManager.register(1, faceHandler);
const commonState = {
tileStore,
itemStore: {},
mapStore: {},
eventStore: {},
roleFace: new modules.RoleFaceBinder(),
faceManager,
saveSystem: {}
} as never;
const maps = new modules.MapState(tileStore, commonState);
const eventMap = maps.fromRaw({
floorId: 'F1',
width: 3,
map: {
0: options.sealedTarget
? [1, 2, 2]
: options.targetNoPass
? [1, 1, 2]
: [1, 1, 1]
},
layerAlias: { 0: 'event' },
events: {
0:
options.middleEvent === false
? {}
: { 1: { 30: 'middle-enter' } }
}
})!;
const events = new Map<string, object>();
const store = {
addEvent() {},
getEvent<
_P extends Record<string, unknown>,
_E extends Record<string, unknown>,
_R = void
>(id: string) {
return events.get(id) as never;
}
};
const executor = new modules.EventExecutor({} as never, () => store);
const state = {
maps,
eventSystem: { executor }
};
const hero: HeroFixture = {
x: 0,
y: 0,
floorId: 'F1',
state,
setPos(x: number, y: number) {
hero.x = x;
hero.y = y;
},
getCurrentFaceDirection() {
return 4;
}
};
const mover = new modules.HeroMover(hero as never, faceHandler);
hero.mover = mover;
const heroState = { location: hero };
const fixtureState = { ...state, hero: heroState } as never;
const topImpl = new modules.DefaultHeroMoveTopImpl(fixtureState);
mover.useTopImplementation(topImpl);
const pathfinding = new modules.HeroPathfinding(fixtureState, topImpl);
return {
eventMap,
events,
executor,
hero: heroState.location,
pathfinding,
state: fixtureState
};
}
function addEvent(
events: Map<string, object>,
id: string,
trigger: number,
calls: EventCall[]
): void {
events.set(id, {
trigger,
execute: async (
_param: unknown,
env: {
trigger: number;
heroLocator: Readonly<{ x: number; y: number }>;
}
) => {
calls.push({ id, trigger: env.trigger, hero: env.heroLocator });
return true;
}
});
}
function addBlockingEvent(
events: Map<string, object>,
id: string,
trigger: number,
calls: EventCall[],
blocked: Promise<void>
): void {
events.set(id, {
trigger,
execute: async (
_param: unknown,
env: {
trigger: number;
heroLocator: Readonly<{ x: number; y: number }>;
}
) => {
calls.push({ id, trigger: env.trigger, hero: env.heroLocator });
await blocked;
return true;
}
});
}
describe('hero pathfinding integration', () => {
// 验证勇士按最小路径逐步移动并按事件链顺序执行途经事件
it('moves the hero to the target and triggers the traversed event', async () => {
const calls: EventCall[] = [];
const fixture = createFixture();
addEvent(fixture.events, 'middle-enter', 2, calls);
const path = fixture.pathfinding.getPath({ x: 2, y: 0 });
expect(path.map(step => step.to)).toEqual([
{ x: 1, y: 0 },
{ x: 2, y: 0 }
]);
const result = fixture.pathfinding.moveTo({ x: 2, y: 0 });
expect(result).not.toBeNull();
await result!.controller.onEnd;
expect({ x: fixture.hero.x, y: fixture.hero.y }).toEqual({
x: 2,
y: 0
});
expect(calls).toEqual([
{ id: 'middle-enter', trigger: 2, hero: { x: 1, y: 0 } }
]);
});
// 验证无事件路径的瞬移一步到达目标
it('teleports directly when the path has no events', async () => {
const fixture = createFixture({ middleEvent: false });
const result = fixture.pathfinding.teleportTo({ x: 2, y: 0 });
expect(result).not.toBeNull();
await result!.controller.onEnd;
expect({ x: fixture.hero.x, y: fixture.hero.y }).toEqual({
x: 2,
y: 0
});
});
// 验证默认策略检测途经事件并自动回退为逐步移动
it('falls back to step movement when the path has an event', async () => {
const calls: EventCall[] = [];
const fixture = createFixture();
addEvent(fixture.events, 'middle-enter', 2, calls);
const result = fixture.pathfinding.teleportTo({ x: 2, y: 0 });
expect(result).not.toBeNull();
await result!.controller.onEnd;
expect({ x: fixture.hero.x, y: fixture.hero.y }).toEqual({
x: 2,
y: 0
});
expect(calls).toEqual([
{ id: 'middle-enter', trigger: 2, hero: { x: 1, y: 0 } }
]);
});
// 验证 no-pass 目标移动至相邻格、面朝目标并派发 OnTouch
it('touches a no-pass target from its reachable adjacent cell', async () => {
const calls: EventCall[] = [];
const fixture = createFixture({
middleEvent: false,
targetNoPass: true
});
addEvent(fixture.events, 'wall-touch', 1, calls);
const result = fixture.pathfinding.moveTo({ x: 2, y: 0 });
expect(result).not.toBeNull();
await result!.controller.onEnd;
expect({ x: fixture.hero.x, y: fixture.hero.y }).toEqual({
x: 1,
y: 0
});
expect(fixture.hero.mover!.faceDirection).toBe(3);
expect(calls).toEqual([
{ id: 'wall-touch', trigger: 1, hero: { x: 1, y: 0 } }
]);
});
// 验证四邻无可达格时不可达目标不移动且不触发事件
it('ignores a no-pass target without a reachable adjacent cell', () => {
const fixture = createFixture({ sealedTarget: true });
const path = fixture.pathfinding.getPath({ x: 2, y: 0 });
expect(path).toEqual([]);
expect(fixture.pathfinding.moveTo({ x: 2, y: 0 })).toBeNull();
expect({ x: fixture.hero.x, y: fixture.hero.y }).toEqual({
x: 0,
y: 0
});
});
// 验证新寻路会停止旧移动并从兑现后的最新坐标接管
it('hands over a moving path to a new target', async () => {
const calls: EventCall[] = [];
let release: () => void = () => {};
const blocked = new Promise<void>(resolve => {
release = resolve;
});
const fixture = createFixture();
addBlockingEvent(fixture.events, 'middle-enter', 2, calls, blocked);
fixture.pathfinding.moveTo({ x: 2, y: 0 });
await Promise.resolve();
await Promise.resolve();
const next = fixture.pathfinding.moveTo({ x: 0, y: 0 });
expect(next).not.toBeNull();
release();
await next!.controller.onEnd;
expect({ x: fixture.hero.x, y: fixture.hero.y }).toEqual({
x: 0,
y: 0
});
});
// 验证显式打断后移动器可再次启动新的寻路
it('leaves the mover restartable after explicit interruption', async () => {
const calls: EventCall[] = [];
let release: () => void = () => {};
const blocked = new Promise<void>(resolve => {
release = resolve;
});
const fixture = createFixture();
addBlockingEvent(fixture.events, 'middle-enter', 2, calls, blocked);
fixture.pathfinding.moveTo({ x: 2, y: 0 });
await Promise.resolve();
await Promise.resolve();
const interrupted = fixture.pathfinding.interrupt();
release();
await interrupted;
const resumed = fixture.pathfinding.moveTo({ x: 0, y: 0 });
expect(resumed).not.toBeNull();
await resumed!.controller.onEnd;
expect({ x: fixture.hero.x, y: fixture.hero.y }).toEqual({
x: 0,
y: 0
});
});
});

View File

@ -1,421 +0,0 @@
import { ITileLocator, logger } from '@motajs/common';
import {
BlockEventType,
IBlockEventEnv,
IBlockEventParam,
IGameEventInvocation,
IHeroMoveTopImpl,
IHeroState,
IMapLayer,
IMapState,
IReadonlyTileBase,
IPassPredicate
} from '@user/data-base';
import {
EventTrigger,
FaceDirection,
IHeroAttr,
IMoverController,
IObjectMovable,
IObjectMover,
ObjectMoveStep
} from '@user/data-common';
import {
IPathfinder,
IPathfindingController,
IPathfindingStep,
IPathfindingSystem,
PathCostFunction,
PathFallbackPolicy,
PathfindingSystem
} from '@user/data-system';
import { IStateSystem } from '@user/data-system';
interface IHeroPathfindingState extends IStateSystem {
readonly hero: IHeroState<IHeroAttr>;
}
interface IHeroPathfinding extends IPathfindingSystem {}
interface IEventSource {
readonly priority: number;
readonly id: string;
readonly type: BlockEventType;
readonly tile: IReadonlyTileBase | null;
}
interface IResolvedPath {
readonly path: IPathfindingStep[];
readonly adjacent: Readonly<ITileLocator> | null;
readonly target: Readonly<ITileLocator> | null;
}
interface IHeroPathfindingController extends IMoverController {}
class HeroPathfindingController implements IHeroPathfindingController {
private completed: boolean = false;
private readonly completion: Promise<void>;
constructor(
private readonly delegate: Readonly<IMoverController>,
afterMove: () => Promise<void>
) {
this.completion = delegate.onEnd.then(async () => {
await afterMove();
this.completed = true;
});
}
get done(): boolean {
return this.completed;
}
get onEnd(): Promise<void> {
return this.completion;
}
push(...steps: Readonly<ObjectMoveStep>[]): void {
this.delegate.push(...steps);
}
insert(...steps: Readonly<ObjectMoveStep>[]): void {
this.delegate.insert(...steps);
}
stop(): Promise<void> {
return this.delegate.stop();
}
}
class QueuedHeroPathfindingController implements IPathfindingController {
private current: IPathfindingController | null = null;
private currentPath: IPathfindingStep[] = [];
private cancelled: boolean = false;
private completed: boolean = false;
private readonly completion: Promise<void>;
constructor(start: () => Promise<IPathfindingController | null>) {
this.completion = Promise.resolve()
.then(start)
.then(async result => {
if (!result) {
this.completed = true;
return;
}
this.current = result;
this.currentPath = [...result.path];
if (this.cancelled) {
await result.controller.stop();
this.completed = true;
return;
}
await result.controller.onEnd;
this.completed = true;
});
}
get controller(): Readonly<IMoverController> {
return this;
}
get path(): readonly IPathfindingStep[] {
return this.currentPath;
}
get done(): boolean {
return this.completed;
}
get onEnd(): Promise<void> {
return this.completion;
}
isCancelled(): boolean {
return this.cancelled;
}
push(...steps: Readonly<ObjectMoveStep>[]): void {
this.current?.controller.push(...steps);
}
insert(...steps: Readonly<ObjectMoveStep>[]): void {
this.current?.controller.insert(...steps);
}
stop(): Promise<void> {
this.cancelled = true;
if (this.current) return this.current.controller.stop();
return this.completion;
}
}
export class HeroPathfinding implements IHeroPathfinding {
readonly state: IHeroPathfindingState;
readonly finder: IPathfinder;
private readonly system: PathfindingSystem;
private active: IPathfindingController | null = null;
constructor(state: IHeroPathfindingState, topImpl: IHeroMoveTopImpl) {
this.state = state;
this.system = new PathfindingSystem(state);
this.finder = this.system.finder;
this.system.useMover(state.hero.location.mover);
this.finder.useMapState(state.maps);
this.finder.usePassPredicate(topImpl.predicate());
this.system.useFallbackPolicy(path => this.hasEvent(path));
this.bindCurrentLayer();
}
private hasEvent(path: readonly IPathfindingStep[]): boolean {
const layer = this.finderLayer();
if (!layer) return false;
for (const step of path) {
const loc = layer.getLocationData(step.to.x, step.to.y);
if (!loc) continue;
const point = layer.getPointEvent(step.to.x, step.to.y);
if (point && point.size > 0) return true;
if (loc.static && loc.static.tileEvent().get().size > 0) {
return true;
}
for (const tile of loc.dynamics) {
if (tile.tileEvent().get().size > 0) return true;
}
}
return false;
}
private finderLayer(): IMapLayer | null {
const floorId = this.state.hero.location.floorId;
const map = floorId ? this.state.maps.getMap(floorId) : null;
return map?.eventLayer ?? null;
}
private bindCurrentLayer(): void {
const floorId = this.state.hero.location.floorId;
const map = floorId ? this.state.maps.getMap(floorId) : null;
const layer: IMapLayer | null = map?.eventLayer ?? null;
this.finder.useMapLayer(layer);
}
useMover(mover: IObjectMover<IObjectMovable> | null): void {
this.system.useMover(mover);
}
useMovable(movable: IObjectMovable | null): void {
this.system.useMovable(movable);
}
useFallbackPolicy(policy: PathFallbackPolicy | null): void {
this.system.useFallbackPolicy(policy);
}
getPath(target: ITileLocator): IPathfindingStep[] {
this.bindCurrentLayer();
return this.resolvePath(target).path;
}
moveTo(target: ITileLocator): IPathfindingController | null {
if (this.isMoving()) return this.queueAfterInterrupt(target, false);
this.bindCurrentLayer();
const resolved = this.resolvePath(target);
return this.startResolved(target, resolved, false);
}
teleportTo(target: ITileLocator): IPathfindingController | null {
if (this.isMoving()) return this.queueAfterInterrupt(target, true);
this.bindCurrentLayer();
const resolved = this.resolvePath(target);
return this.startResolved(target, resolved, true);
}
private isMoving(): boolean {
return this.active !== null && !this.active.controller.done;
}
private queueAfterInterrupt(
target: ITileLocator,
teleport: boolean
): IPathfindingController {
const queued = new QueuedHeroPathfindingController(async () => {
await this.system.interrupt();
if (queued.isCancelled()) return null;
this.bindCurrentLayer();
const resolved = this.resolvePath(target);
return this.startResolved(target, resolved, teleport);
});
this.active = queued;
return queued;
}
private startResolved(
target: ITileLocator,
resolved: IResolvedPath,
teleport: boolean
): IPathfindingController | null {
if (resolved.path.length === 0) return null;
const destination = resolved.adjacent ?? target;
const result = teleport
? this.system.teleportTo(destination)
: this.system.moveTo(destination);
if (!result) {
logger.error(65);
return null;
}
const wrapped =
resolved.adjacent && resolved.target
? {
controller: new HeroPathfindingController(
result.controller,
() => this.finishAdjacentTouch(resolved)
),
path: result.path
}
: result;
this.active = wrapped;
void wrapped.controller.onEnd.then(() => {
if (this.active === wrapped) this.active = null;
});
return wrapped;
}
private resolvePath(target: ITileLocator): IResolvedPath {
const path = this.system.getPath(target);
if (path.length > 0) {
return { path, adjacent: null, target: null };
}
const layer = this.finderLayer();
if (!layer || !this.isNoPass(layer, target)) {
return { path: [], adjacent: null, target: null };
}
const start = this.state.hero.location;
const candidates: ReadonlyArray<Readonly<ITileLocator>> = [
{ x: target.x, y: target.y - 1 },
{ x: target.x + 1, y: target.y },
{ x: target.x, y: target.y + 1 },
{ x: target.x - 1, y: target.y }
];
for (const adjacent of candidates) {
if (!layer.inMap(adjacent.x, adjacent.y)) continue;
const adjacentPath = this.finder.find(
{ x: start.x, y: start.y },
adjacent
);
if (adjacentPath.length > 0) {
return { path: adjacentPath, adjacent, target };
}
}
return { path: [], adjacent: null, target: null };
}
private isNoPass(layer: IMapLayer, target: ITileLocator): boolean {
const raw = layer.getLocationData(target.x, target.y)?.static?.raw();
if (!raw) return false;
return (
!raw.eventPass || raw.pass.inPass === 0 || raw.pass.outPass === 0
);
}
private directionTo(target: ITileLocator): FaceDirection {
const hero = this.state.hero.location;
if (target.x > hero.x) return FaceDirection.Right;
if (target.x < hero.x) return FaceDirection.Left;
if (target.y > hero.y) return FaceDirection.Down;
return FaceDirection.Up;
}
private async finishAdjacentTouch(resolved: IResolvedPath): Promise<void> {
const adjacent = resolved.adjacent!;
const target = resolved.target!;
const mover = this.state.hero.location.mover;
mover.setFaceDir(this.directionTo(target));
await this.dispatchTouch(adjacent, target);
}
private async dispatchTouch(
heroLoc: Readonly<ITileLocator>,
target: Readonly<ITileLocator>
): Promise<void> {
const layer = this.finderLayer();
if (!layer) return;
const pointSources: IEventSource[] = [];
const tileSources: IEventSource[] = [];
const point = layer.getPointEvent(target.x, target.y);
const loc = layer.getLocationData(target.x, target.y);
if (point) {
for (const [priority, id] of point) {
pointSources.push({
priority,
id,
type: BlockEventType.PointEvent,
tile: null
});
}
}
if (loc) {
if (loc.static) {
for (const [priority, id] of loc.static.tileEvent().get()) {
tileSources.push({
priority,
id,
type: BlockEventType.TileEvent,
tile: loc.static
});
}
}
for (const tile of loc.dynamics) {
for (const [priority, id] of tile.tileEvent().get()) {
tileSources.push({
priority,
id,
type: BlockEventType.TileEvent,
tile
});
}
}
}
pointSources.sort((a, b) => b.priority - a.priority);
tileSources.sort((a, b) => b.priority - a.priority);
const invocations: IGameEventInvocation[] = [];
for (const source of [...pointSources, ...tileSources]) {
const env: IBlockEventEnv = {
state: this.state,
type: source.type,
trigger: EventTrigger.OnTouch,
heroLocator: heroLoc,
triggerLocator: target,
tile: source.tile,
layer,
map: layer.map
};
invocations.push({ id: source.id, env });
}
if (invocations.length === 0) return;
const param: IBlockEventParam = { custom: {} };
await this.state.eventSystem.executor.execute<void>(invocations, param);
}
interrupt(): Promise<void> {
const active = this.active;
this.active = null;
if (!active) return this.system.interrupt();
return active.controller.stop().then(() => this.system.interrupt());
}
useMapState(maps: IMapState | null): void {
this.finder.useMapState(maps);
}
useMapLayer(layer: IMapLayer | null): void {
this.finder.useMapLayer(layer);
}
useCostFunction(cost: PathCostFunction | null): void {
this.finder.useCostFunction(cost);
}
usePassPredicate(predicate: IPassPredicate | null): void {
this.finder.usePassPredicate(predicate);
}
}

View File

@ -1 +0,0 @@
export * from './heroPathfinding';

View File

@ -63,6 +63,7 @@ interface TestModules {
RoleFaceBinder: typeof import('@user/data-common').RoleFaceBinder;
FaceManager: typeof import('@user/data-common').FaceManager;
Dir8FaceHandler: typeof import('@user/data-common').Dir8FaceHandler;
DirectionMapper: typeof import('@motajs/common').DirectionMapper;
EventTrigger: typeof eventTriggers;
BlockEventType: typeof blockEventTypes;
EventExecuteMode: typeof eventExecuteModes;
@ -95,6 +96,7 @@ beforeAll(async () => {
RoleFaceBinder: commonModule.RoleFaceBinder,
FaceManager: commonModule.FaceManager,
Dir8FaceHandler: commonModule.Dir8FaceHandler,
DirectionMapper: (await import('@motajs/common')).DirectionMapper,
EventTrigger: eventTriggers,
BlockEventType: blockEventTypes,
EventExecuteMode: eventExecuteModes,
@ -133,6 +135,7 @@ function createFixture(
eventStore: {},
roleFace: new modules.RoleFaceBinder(),
faceManager,
directionMapper: new modules.DirectionMapper(),
saveSystem: {}
} as never;
const maps = new modules.MapState(tileStore, commonState);

View File

@ -7,8 +7,12 @@ import {
} from '@user/data-base';
import { isNil } from 'lodash-es';
import { PathfindingGraphBuilder } from './graph';
import { IPathfinder, IPathfindingStep, PathCostFunction } from './types';
import { IPathGraph } from './graph';
import {
IPathfinder,
IPathfindingStep,
IPathGraph,
PathCostFunction
} from './types';
interface IDistanceHeapEntry {
/** 条目的键值,堆中键值最小的条目最先取出 */

View File

@ -260,6 +260,7 @@ function createFixture(
eventStore: {},
roleFace: new modules.RoleFaceBinder(),
faceManager,
directionMapper: new modules.DirectionMapper(),
saveSystem: {}
} as never;
const maps = new modules.MapState(tileStore, commonState);

View File

@ -1,7 +1,5 @@
import {
DirectionMapper,
IDirectionDescriptor,
IDirectionMapper,
InternalDirectionGroup,
ITileLocator,
logger
@ -15,79 +13,13 @@ import {
IPassPredicate
} from '@user/data-base';
import { isNil } from 'lodash-es';
import { PathCostFunction } from './types';
export interface IPathGraphEdge {
/** 本条边对应的移动方向 */
readonly dir: FaceDirection;
/** 边指向的节点索引,值为 y * width + x */
readonly to: number;
}
export interface IPathGraphNode {
/** 节点索引,值为 y * width + x */
readonly index: number;
/** 节点横坐标 */
readonly x: number;
/** 节点纵坐标 */
readonly y: number;
/** 节点对应的位置信息 */
readonly block: ILayerLocation;
/** 进入该节点的损失,构建图时由损失函数计算 */
readonly cost: number;
/** 该节点是否仅可作为路径终点,不可作为中间节点 */
readonly terminal: boolean;
/** 该节点的全部出边 */
readonly edges: readonly IPathGraphEdge[];
}
export interface IPathGraph {
/** 图宽度 */
readonly width: number;
/** 图高度 */
readonly height: number;
/** 图内全部节点,键为节点索引 */
readonly nodes: ReadonlyMap<number, IPathGraphNode>;
}
export interface IPathfindingGraphBuilder {
/**
* id
* @param maps
*/
useMapState(maps: IMapState | null): void;
/**
*
* @param layer
*/
useMapLayer(layer: IMapLayer | null): void;
/**
* 使 1
* @param cost
*/
useCostFunction(cost: PathCostFunction | null): void;
/**
*
* @param predicate
*/
usePassPredicate(predicate: IPassPredicate | null): void;
/**
* 使
* @param group
*/
useDirGroup(group: number): void;
/**
* 沿 BFS
*
* @param start BFS
*/
build(start: ITileLocator): IPathGraph;
}
import {
IPathGraph,
IPathGraphEdge,
IPathGraphNode,
IPathfindingGraphBuilder,
PathCostFunction
} from './types';
/**
*
@ -118,9 +50,6 @@ export class PathfindingGraphBuilder implements IPathfindingGraphBuilder {
/** 邻域方向组别,默认四正交方向 */
private group: number = InternalDirectionGroup.Dir4;
/** 方向组解析对象 */
private readonly mapper: IDirectionMapper = new DirectionMapper();
useMapState(maps: IMapState | null): void {
this.maps = maps;
}
@ -181,7 +110,9 @@ export class PathfindingGraphBuilder implements IPathfindingGraphBuilder {
const height = layer.height;
const floorId = this.resolveFloorId();
const state = layer.state;
const dirs: IDirectionDescriptor[] = [...this.mapper.map(this.group)];
const dirs: IDirectionDescriptor[] = [
...layer.state.directionMapper.map(this.group)
];
const terminals: Set<number> = new Set();
const adjacency: Map<number, IPathGraphEdge[]> = new Map();

View File

@ -40,6 +40,7 @@ interface TestModules {
ObjectMover: typeof import('@user/data-common').ObjectMover;
FaceManager: typeof import('@user/data-common').FaceManager;
Dir8FaceHandler: typeof import('@user/data-common').Dir8FaceHandler;
DirectionMapper: typeof import('@motajs/common').DirectionMapper;
RoleFaceBinder: typeof import('@user/data-common').RoleFaceBinder;
}
@ -51,6 +52,7 @@ beforeAll(async () => {
const systemModule = await import('./system');
const baseModule = await import('@user/data-base');
const commonModule = await import('@user/data-common');
const motaModule = await import('@motajs/common');
modules = {
PathfindingSystem: systemModule.PathfindingSystem,
MapState: baseModule.MapState,
@ -58,7 +60,8 @@ beforeAll(async () => {
ObjectMover: commonModule.ObjectMover,
FaceManager: commonModule.FaceManager,
Dir8FaceHandler: commonModule.Dir8FaceHandler,
RoleFaceBinder: commonModule.RoleFaceBinder
RoleFaceBinder: commonModule.RoleFaceBinder,
DirectionMapper: motaModule.DirectionMapper
};
});
@ -289,6 +292,7 @@ function createPerformanceSystem(
eventStore: {},
roleFace: new modules.RoleFaceBinder(),
faceManager,
directionMapper: new modules.DirectionMapper(),
saveSystem: {}
} as never;
const maps = new modules.MapState(tileStore, commonState);

View File

@ -43,6 +43,7 @@ interface TestModules {
ObjectMover: typeof import('@user/data-common').ObjectMover;
FaceManager: typeof import('@user/data-common').FaceManager;
Dir8FaceHandler: typeof import('@user/data-common').Dir8FaceHandler;
DirectionMapper: typeof import('@motajs/common').DirectionMapper;
RoleFaceBinder: typeof import('@user/data-common').RoleFaceBinder;
logger: typeof import('@motajs/common').logger;
}
@ -63,6 +64,7 @@ beforeAll(async () => {
ObjectMover: commonModule.ObjectMover,
FaceManager: commonModule.FaceManager,
Dir8FaceHandler: commonModule.Dir8FaceHandler,
DirectionMapper: motaModule.DirectionMapper,
RoleFaceBinder: commonModule.RoleFaceBinder,
logger: motaModule.logger
};
@ -319,6 +321,7 @@ function createSystem(rows: number[], width: number): SystemFixture {
eventStore: {},
roleFace: new modules.RoleFaceBinder(),
faceManager,
directionMapper: new modules.DirectionMapper(),
saveSystem: {}
} as never;
const maps = new modules.MapState(tileStore, commonState);

View File

@ -12,6 +12,78 @@ import {
IObjectMovable
} from '@user/data-common';
export interface IPathGraphEdge {
/** 本条边对应的移动方向 */
readonly dir: FaceDirection;
/** 边指向的节点索引,值为 y * width + x */
readonly to: number;
}
export interface IPathGraphNode {
/** 节点索引,值为 y * width + x */
readonly index: number;
/** 节点横坐标 */
readonly x: number;
/** 节点纵坐标 */
readonly y: number;
/** 节点对应的位置信息 */
readonly block: ILayerLocation;
/** 进入该节点的损失,构建图时由损失函数计算 */
readonly cost: number;
/** 该节点是否仅可作为路径终点,不可作为中间节点 */
readonly terminal: boolean;
/** 该节点的全部出边 */
readonly edges: readonly IPathGraphEdge[];
}
export interface IPathGraph {
/** 图宽度 */
readonly width: number;
/** 图高度 */
readonly height: number;
/** 图内全部节点,键为节点索引 */
readonly nodes: ReadonlyMap<number, IPathGraphNode>;
}
export interface IPathfindingGraphBuilder {
/**
* id
* @param maps
*/
useMapState(maps: IMapState | null): void;
/**
*
* @param layer
*/
useMapLayer(layer: IMapLayer | null): void;
/**
* 使 1
* @param cost
*/
useCostFunction(cost: PathCostFunction | null): void;
/**
*
* @param predicate
*/
usePassPredicate(predicate: IPassPredicate | null): void;
/**
* 使
* @param group
*/
useDirGroup(group: number): void;
/**
* 沿 BFS
*
* @param start BFS
*/
build(start: ITileLocator): IPathGraph;
}
export interface IPathfindingStep {
/** 移动方向 */
readonly dir: FaceDirection;