mirror of
https://github.com/motajs/template.git
synced 2026-09-12 09:48:49 +08:00
refactor(02-06): align pathfinding structure with user scope
This commit is contained in:
parent
97a559cdf8
commit
26f7ad6b2e
@ -81,6 +81,8 @@ Plans:
|
||||
3. 寻路正确避开不可通行格(碰撞/障碍/墙体)
|
||||
|
||||
**Plans**: 5/5 plans executed
|
||||
|
||||
**Scope correction (2026-09-10):** 用户审查确认本阶段不交付未授权的 `HeroPathfinding` L3 勇士封装;当前阶段保留 L2 寻路核心、共享通行性谓词与 DataCommon 方向映射依赖,勇士或渲染侧接线不作为本阶段实现内容。
|
||||
**UI hint**: yes
|
||||
Plans:
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
68
.planning/phases/02-pathfinding/02-06-SUMMARY.md
Normal file
68
.planning/phases/02-pathfinding/02-06-SUMMARY.md
Normal 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.
|
||||
@ -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 代码和现有测试为准。
|
||||
|
||||
231
.planning/phases/02-pathfinding/02-VERIFICATION.md
Normal file
231
.planning/phases/02-pathfinding/02-VERIFICATION.md
Normal 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)_
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from './moverImpl';
|
||||
export * from './predicate';
|
||||
export * from './types';
|
||||
|
||||
@ -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 通行性判断
|
||||
|
||||
114
packages-user/data-state/src/hero/predicate.ts
Normal file
114
packages-user/data-state/src/hero/predicate.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
export * from './enemy';
|
||||
export * from './hero';
|
||||
export * from './path';
|
||||
|
||||
export * from './core';
|
||||
export * from './ins';
|
||||
|
||||
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export * from './heroPathfinding';
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
/** 条目的键值,堆中键值最小的条目最先取出 */
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user