33 KiB
移动 Mover
对象移动是数据端最常见的动作之一:勇士走一步、动态图块被推走、NPC 沿路径巡逻。移动器把「想怎么走」和「实际怎么走」拆开:先用链式调用排出一串移动步骤,再调用 start() 按顺序执行;每一步落在哪一格,由移动器的子类决定。
这样拆的好处是同一套执行流程可以服务不同对象。勇士的 HeroMover 负责边界、通行、踩踏与撞击,动态图块的 DynamicTileMover 只把方向换成落点。读这篇不需要先读它们的源码:先会用现成移动器,再看怎么扩展。
数据端只维护坐标与状态,画面上的位移、动画与速度表现由渲染端读取这些状态后完成。渲染端内容用 渲染端 · 移动表现 占位。
三个角色
移动相关的接口都在 @user/data-common 的 mover.ts 里。三个角色分工明确:
| 接口 | 角色 | 关键成员 |
|---|---|---|
IObjectMovable |
被移动的对象 | x、y、setPos、getCurrentFaceDirection |
IObjectMover |
移动器 | 链式规划、start()、状态、钩子 |
IMoverController |
一次执行的句柄 | done、onEnd、push、insert、stop |
关系是组合:对象实现 IObjectMovable,移动器持有对象(tile),并在每一步把新坐标写回对象。IObjectMover<T> 本身是一个可挂钩子的对象,IObjectMoverHooks<T> 是它对应的钩子族。
IMoverController 不是移动器本身,而是 start() 返回的一张「这次执行」的凭证。移动结束后仍然可以读它的 done,但不能再追加步骤。
拿到现成的移动器
多数场景不需要自己 new 移动器。引擎已经为两类对象建好了实例:
state.hero.location.mover:勇士位置对象的移动器,类型是IHeroMover。除了通用接口,它还提供config、getConfig、useTopImplementation。- 动态图块的
mover字段:类型是IObjectMover<IDynamicTile>,创建动态图块时一并创建;dynamic.step(dir)是它的单步便捷入口。
这两个实例与对象同生命周期。重复 new 会得到第二个互不相干的移动器,位置与朝向都会对不上。
IHeroMover 的配置有三个字段:
| 字段 | 含义 |
|---|---|
ignoreTerrain |
是否忽略地形碰撞检测 |
autoSave |
是否在特定时机触发自动存档 |
allowOutBound |
是否允许勇士到达地图之外 |
config 只覆盖传入的字段,没传的保持原值;getConfig 返回当前配置的只读快照。useTopImplementation 用来替换勇士的顶层判定实现,通行、边界、踩踏与撞击都由它负责。
勇士位置对象的接口见勇士位置;动态图块的创建、转换与移动见静态图块与动态图块。
排一个移动计划
规划方法是链式的:每次调用追加一个或几个步骤,返回移动器本身,所以可以连着写。规划阶段不会移动对象,对象要等 start() 才动。
| 方法 | 追加的步骤 | 作用 |
|---|---|---|
step(dir, count?) |
Dir |
朝 dir 走 count 格,同时更新移动方向与朝向 |
stepFace(move, face, count?) |
DirFace |
朝 move 走,朝向显式设为 face |
forward(count?) |
Special(Forward) |
沿当前移动方向前进 |
backward(count?) |
Special(Backward) |
沿当前朝向的反方向后退,朝向不变 |
face(dir) |
Face |
只转向,不移动 |
tp(x, y, rel?) |
Teleport |
瞬移到目标格 |
jump(x, y, rel?) |
Jump |
跳跃到目标格 |
speed(value) |
Speed |
设置后续移动的每格耗时,单位 ms |
animDir(dir) |
AnimDir |
设置后续步骤的动画播放方向 |
push(steps) |
一组步骤 | 把预先拼好的 ObjectMoveStep 数组原样追加 |
clear() |
无 | 清空尚未执行的队列 |
step 的 count 默认是 1。传 2 会追加两个 Dir 步骤,逐步执行,不是把一步拆成两格。stepFace 把移动方向与朝向分开,适合「侧身走」这类表现。
forward 与 backward 是相对步。backward 以当前朝向的反方向为基准,并保持朝向不变,所以连续后退会一直沿同一条轴线,不会因为上一步写入的反方向而左右摆动。
tp 与 jump 的 rel 省略时为 false,x、y 是目标格坐标;rel 为 true 时,x、y 是相对当前位置的偏移。数据端上两者都是直接落到目标格,区别只在步骤类型,渲染端可以据此播放不同的表现。
speed 与 animDir 只是写移动器状态,本身不产生位移。speed 的单位是毫秒每格,初始 currentSpeed 为 100;animDir 设置 ObjectAnimDirection.Forward 或 Backward。真正让速度与动画方向「看得见」的是渲染端。
push 接收的是步骤对象数组,适合把循环生成或从别处收集来的步骤一次加入。clear 只清空尚未执行的队列,不影响正在执行的那一次。
执行与等待
start() 把当前待执行队列取出来执行,并返回 IMoverController;如果移动器已经在移动,返回 null,不会接管当前移动。
import { FaceDirection } from '@user/data-common';
import { ICoreState } from '@user/data-state';
async function walkRightThenDown(state: ICoreState): Promise<void> {
const mover = state.hero.location.mover;
const before = state.hero.getLocation();
// [需改] 按需求换成你要的步数与方向
mover
.step(FaceDirection.Right, 2) // 向右两格
.face(FaceDirection.Down) // 只转向,不移动
.step(FaceDirection.Down); // 向下一格
const controller = mover.start();
if (!controller) {
// 上一次移动还没结束;这里直接放弃,避免和它抢位置
return;
}
// done 在整次执行结束时才变 true
console.log('开始', controller.done); // false
await controller.onEnd;
console.log('结束', controller.done); // true
const after = state.hero.getLocation();
console.log('前', before.x, before.y, '后', after.x, after.y);
}
onEnd 是整次执行结束时兑现的 Promise,done 是同一时点的布尔查询。等待优先用 await controller.onEnd;done 适合在同步代码里快速判断。
start() 会做三件事:把当前队列切片成本次执行的局部队列、清空移动器内部的待执行队列、把 moveDirection 重置为 Unknown。因此执行期间再用移动器规划,写的是「下一次」的队列,不是本次。
moveDirection 被重置这一点对相对步很关键:forward() 依赖当前移动方向,所以一个计划里如果要用 forward(),前面必须先有 step 或 stepFace 把移动方向确定下来。
执行中追加、插入与停止
controller 才是当前这次执行的入口。它的三个方法只在「仍在移动、没有调用过 stop、尚未 done」时生效,其余情况静默忽略:
push(...steps):追加到本次队列末尾。insert(...steps):插到当前步之后。当前步已经从队列取出,所以实际是插到剩余队列的最前面;多个步骤按参数顺序排列。stop():把内部停止标记置真,返回本次的onEnd;当前步会走完,之后不再开始新步骤。
import { FaceDirection, ObjectMoveType } from '@user/data-common';
import { ICoreState } from '@user/data-state';
async function extendWhileMoving(state: ICoreState): Promise<void> {
const controller = state.hero.location.mover
.step(FaceDirection.Right, 2)
.start();
if (!controller) return;
// 执行已经开始,但还没结束;追加的步骤会进入本次队列
controller.push({ type: ObjectMoveType.Dir, move: FaceDirection.Down });
await controller.onEnd;
}
这里有一条容易踩的边界:执行期间在移动器上调用 step、face、speed 等方法,只会写回移动器内部的待执行队列,本次执行不会消费它们。执行中要追加,只能用 controller.push 或 controller.insert。
stop() 的停止检查发生在每轮循环开头,所以不会打断正在执行的这一步。如果第一步还没开始就调用 stop(),循环会在开始第一步之前退出,结果是一步都不走;如果在某一步执行中调用,当前步会走完再停。
import { FaceDirection } from '@user/data-common';
import { ICoreState } from '@user/data-state';
async function stopBeforeFirstStep(state: ICoreState): Promise<void> {
const controller = state.hero.location.mover
.step(FaceDirection.Right, 3)
.start();
if (!controller) return;
// 立即停止:当前还没有步骤真正开始,通常一步都不走
await controller.stop();
}
在步进方法或步进钩子里调用 stop() 时不要 await 它返回的 Promise。它要等当前步结束才兑现,await 会把整次移动卡死。内置的 HeroMover 在撞击、无法进入等分支里就是直接调用、不等待。
停止后 done 会变真,本次控制器不能再追加。想继续走,要重新排计划并再次 start()。
步骤类型
规划方法只是往队列里放对象;移动器真正面对的是 ObjectMoveStep 联合类型。八种步骤对应 ObjectMoveType 的八个成员:
type |
字段 | 效果 |
|---|---|---|
ObjectMoveType.Dir |
move |
走向 move,移动方向与朝向都更新 |
ObjectMoveType.DirFace |
move、face |
走向 move,朝向设为 face |
ObjectMoveType.Speed |
value |
设置后续每格耗时 |
ObjectMoveType.Face |
value |
只改朝向 |
ObjectMoveType.Special |
direction:ObjectSpecialStep.Forward / Backward |
前进或后退 |
ObjectMoveType.AnimDir |
dir:ObjectAnimDirection.Forward / Backward |
设置动画播放方向 |
ObjectMoveType.Teleport |
x、y、rel |
瞬移 |
ObjectMoveType.Jump |
x、y、rel |
跳跃 |
Speed 与 AnimDir 不产生位移,Face 只改朝向。它们的状态更新由基类在每步开始前完成,自定义移动器只需要在计算落点时知道「这几类不移动」。
自定义移动器必须自己处理这些 type:至少要知道每个类型该返回什么落点。没有识别的类型不要抛错,原样返回当前坐标即可,这样未知步骤表现为原地不动。
ObjectSpecialStep 与 ObjectAnimDirection 都是 const enum,分别表示前进 / 后退与正向 / 反向播放。
移动器状态
移动器把一次执行需要的状态都放在自己身上:
| 成员 | 含义 | 说明 |
|---|---|---|
moving |
是否正在移动 | start() 后为真,整次结束才回到假 |
tile |
绑定的对象 | 构造时传入 |
faceDirection |
当前朝向 | 由步骤或 setFaceDir 更新 |
moveDirection |
当前移动方向 | 由步骤或 setMoveDir 更新,start() 重置为 Unknown |
currAnimDir |
当前动画方向 | 初始为 ObjectAnimDirection.Forward |
currentSpeed |
当前每格耗时 | 单位毫秒,初始为 100 |
faceHandler |
朝向处理器 | 构造时传入,负责方向与偏移的换算 |
faceDirection 与 moveDirection 是两件事:faceDirection 表示对象朝哪,moveDirection 表示这一步往哪走。stepFace 与 backward 下两者会不同,例如向右走但脸朝上,或者朝下后退时移动方向是上。
setPos、setFaceDir、setMoveDir 与链式规划不同:它们立即生效、不排队。setPos 直接写对象坐标,setFaceDir 与 setMoveDir 直接改移动器状态。三者分别触发 onSetPos、onSetFaceDir、onSetMoveDir 钩子。
移动过程中的落点写入由基类直接调用 tile.setPos,不经过移动器的 setPos,所以不会触发 onSetPos。onSetPos 只对应你显式调用移动器 setPos 的那一次。
钩子
IObjectMoverHooks<T> 是移动器上的钩子族,泛型参数就是被移动的对象类型。用 mover.addHook(...) 注册,用返回的控制器 unload() 或 removeHook(...) 移除,规则与其它钩子一致,见钩子深入。
| 钩子 | 触发时机 | 参数 | 异步 |
|---|---|---|---|
onMoveStart |
整次移动开始,子类 onMoveStart 之后 |
tile、mover |
是 |
onMoveEnd |
整次移动结束,子类 onMoveEnd 之后 |
tile、mover |
是 |
onStepStart |
单步开始前,坐标仍是移动前 | code、step、tile、mover |
是 |
onStepEnd |
单步结束,坐标已更新 | code、step、tile、mover |
是 |
onSetPos |
调用移动器 setPos 时 |
x、y、tile、mover |
否 |
onSetFaceDir |
调用移动器 setFaceDir 时 |
dir、tile、mover |
否 |
onSetMoveDir |
调用移动器 setMoveDir 时 |
dir、tile、mover |
否 |
几条与使用直接相关的时序:
onStepStart触发时坐标还是移动前,onStepEnd触发时坐标已经写入对象。两者之间是子类onStepEnd计算落点、基类写坐标、onStepSettled运行。code是子类onStepStart返回的数字。它会被传给onStepStart钩子、子类onStepEnd与onStepEnd钩子;基类不解释它的含义,由子类自己约定。- 前四个钩子返回
Promise,引擎用Promise.all并发等待。完成顺序不等于注册顺序;需要顺序时,把有依赖的步骤写进同一个钩子按await串行。细节见钩子深入。 onSetPos、onSetFaceDir、onSetMoveDir是同步的,返回void,并且只由对应的 setter 触发。
实现自己的移动器
只有现成的 HeroMover 与 DynamicTileMover 不满足需求时,才需要自己写。例如新对象有自己的落点规则、需要在每一步前后做额外处理,或者要换一套结果码。
ObjectMover 是移动器基类,负责队列、控制器、状态更新、钩子分发与坐标回写。子类只需要实现五个 protected 方法,决定每一步「能不能走、落到哪、落完之后做什么」。
如果想给自己的对象接上移动器,可以参考下面的模板。模板用 SimpleMover 作为类名,用 SimpleMoveCode 作为结果码;[需改] 标出需要按需求替换的部分。
import {
FaceDirection,
IMoverController,
IObjectMovable,
ObjectMover,
ObjectMoveStep,
shouldReplay
} from '@user/data-common';
import { IFaceHandler, ITileLocator } from '@motajs/common';
// 自定义结果码:onStepStart 返回,onStepEnd 与钩子都会收到
const enum SimpleMoveCode {
/** 这一步可以执行 */
Success,
/** 这一步不执行,保持原地 */
Blocked
}
// 模板:自定义移动器。T 换成你自己的可移动对象类型
export class SimpleMover<T extends IObjectMovable> extends ObjectMover<T> {
constructor(
readonly tile: T,
faceHandler: IFaceHandler<FaceDirection>
) {
// 初始朝向取对象当前朝向;之后由每一步的 prepareStep 维护
super(faceHandler, tile.getCurrentFaceDirection());
}
// [需改] 整次移动开始时执行;没有额外逻辑可以留空
protected async onMoveStart(
tile: T,
controller: Readonly<IMoverController>
): Promise<void> {}
// [需改] 整次移动结束时执行
protected async onMoveEnd(
tile: T,
controller: Readonly<IMoverController>
): Promise<void> {}
// [需改] 单步开始:检查这一步能否执行,返回结果码
protected async onStepStart(
step: Readonly<ObjectMoveStep>,
tile: T,
controller: Readonly<IMoverController>
): Promise<number> {
return SimpleMoveCode.Success;
}
// [需改] 单步结束:返回这一步的落点;坐标由基类写入
@shouldReplay('Simple mover step should be replayed.')
protected async onStepEnd(
code: number,
step: Readonly<ObjectMoveStep>,
tile: T,
controller: Readonly<IMoverController>
): Promise<ITileLocator> {
if (code !== SimpleMoveCode.Success) {
return { x: tile.x, y: tile.y };
}
// 模板先原地不动;按 step.type 换成你的落点规则
return { x: tile.x, y: tile.y };
}
// [需改] 单步已结束、坐标已写入之后执行
protected async onStepSettled(
before: ITileLocator,
curr: ITileLocator,
tile: T,
step: Readonly<ObjectMoveStep>,
controller: Readonly<IMoverController>
): Promise<void> {}
}
五个方法的职责:
onMoveStart与onMoveEnd包住整次执行,分别在第一步之前与最后一步之后运行,返回的 Promise 会被等待。onStepStart在每步开始前运行。此时基类已经更新过移动方向、朝向、速度与动画方向,但坐标还没变。它返回本步的结果码,供后面的逻辑与钩子使用。onStepEnd计算这一步的落点,返回一个ITileLocator。基类拿它和当前坐标比较,不同才调用tile.setPos。不要在这里自己写坐标。onStepSettled在坐标写入之后运行,参数里带着移动前坐标before与落点curr。这里适合触发进入 / 离开 / 踩踏一类的事件,或者把朝向同步回对象。- 五个方法都是
protected,外部代码不能直接调用。对外只有链式方法、start()与三个 setter。
模板里的 @shouldReplay 标在会改状态的步进方法上,用来让这一步进入录像检查;如果你的移动不需要进录像,可以去掉,原因见下文「与录像、存档的关系」。
示例:一个最小可移动对象
把模板的类型参数固定成 SimpleTile,只让四方向与瞬移产生位移,就得到一个能跑的最小移动器。下面两段可以放在同一个文件,也可以拆成两个文件;拆开时按第二段的 import 引入。
import { FaceDirection, IObjectMovable } from '@user/data-common';
// 最小可移动对象:只保存坐标与朝向
export class SimpleTile implements IObjectMovable {
constructor(
public x: number,
public y: number,
private direction: FaceDirection = FaceDirection.Down
) {}
// IObjectMovable:移动器在落点确定后通过它写回坐标
setPos(x: number, y: number): void {
this.x = x;
this.y = y;
}
// IObjectMovable:移动器构造时读取初始朝向
getCurrentFaceDirection(): FaceDirection {
return this.direction;
}
// 不在 IObjectMovable 里:移动器在 onStepSettled 把朝向写回对象
setFaceDirection(dir: FaceDirection): void {
this.direction = dir;
}
}
import {
FaceDirection,
IMoverController,
ObjectMover,
ObjectMoveStep,
ObjectMoveType,
shouldReplay
} from '@user/data-common';
import { IFaceHandler, ITileLocator } from '@motajs/common';
import { SimpleTile } from './simple-tile';
const enum SimpleMoveCode {
/** 这一步可以执行 */
Success,
/** 这一步不执行,保持原地 */
Blocked
}
// 只处理四方向与瞬移的最小移动器
export class SimpleMover extends ObjectMover<SimpleTile> {
constructor(
readonly tile: SimpleTile,
faceHandler: IFaceHandler<FaceDirection>
) {
super(faceHandler, tile.getCurrentFaceDirection());
}
protected async onMoveStart(
tile: SimpleTile,
controller: Readonly<IMoverController>
): Promise<void> {}
protected async onMoveEnd(
tile: SimpleTile,
controller: Readonly<IMoverController>
): Promise<void> {}
protected async onStepStart(
step: Readonly<ObjectMoveStep>,
tile: SimpleTile,
controller: Readonly<IMoverController>
): Promise<number> {
// 跳跃不在最小实现里;其余步骤都放行
if (step.type === ObjectMoveType.Jump) {
return SimpleMoveCode.Blocked;
}
return SimpleMoveCode.Success;
}
@shouldReplay('Simple tile moving step should be replayed.')
protected async onStepEnd(
code: number,
step: Readonly<ObjectMoveStep>,
tile: SimpleTile,
controller: Readonly<IMoverController>
): Promise<ITileLocator> {
if (code !== SimpleMoveCode.Success) {
return { x: tile.x, y: tile.y };
}
switch (step.type) {
case ObjectMoveType.Dir:
case ObjectMoveType.DirFace:
case ObjectMoveType.Special: {
// 方向 → 偏移:moveDirection 已由基类按步骤内容算好
const { x, y } = this.faceHandler.movement(this.moveDirection);
return { x: tile.x + x, y: tile.y + y };
}
case ObjectMoveType.Teleport: {
// rel 为 true 时,x / y 表示相对当前位置的偏移
const nx = step.rel ? tile.x + step.x : step.x;
const ny = step.rel ? tile.y + step.y : step.y;
return { x: nx, y: ny };
}
default:
// Face / Speed / AnimDir 不产生位移
return { x: tile.x, y: tile.y };
}
}
protected async onStepSettled(
before: ITileLocator,
curr: ITileLocator,
tile: SimpleTile,
step: Readonly<ObjectMoveStep>,
controller: Readonly<IMoverController>
): Promise<void> {
// 坐标已经写入 tile,这里把朝向同步给对象
tile.setFaceDirection(this.faceDirection);
}
}
这段示例把「方向 → 偏移 → 落点」串了起来:faceHandler.movement(this.moveDirection) 给出单格的坐标偏移,加到 tile.x、tile.y 上就是落点;基类拿到落点后负责写入坐标。
通行判断不在移动器里。SimpleMover 不检查目标格能不能进、有没有墙,也不触发踩踏事件。它只回答「这一步落到哪」。能不能走属于调用方的规则,HeroMover 就是把这件事交给顶层实现对象的。
把对象与移动器接起来:
import { Dir4FaceHandler, FaceDirection } from '@user/data-common';
import { SimpleMover } from './simple-mover';
import { SimpleTile } from './simple-tile';
async function runSimpleMover(): Promise<void> {
const tile = new SimpleTile(3, 4, FaceDirection.Down);
// 四方向对象配四方向处理器;八方向换成 Dir8FaceHandler
const mover = new SimpleMover(tile, new Dir4FaceHandler());
const controller = mover.step(FaceDirection.Right, 2).start();
if (!controller) return;
await controller.onEnd;
console.log(tile.x, tile.y); // 5, 4
}
faceHandler 决定方向体系。四方向对象要配四方向处理器,八方向对象配八方向处理器;混用会让偏移与朝向对不上。FaceHandler 系列 的接口与自定义方式由另一篇承担。
与录像、存档的关系
HeroMover 在 onStepStart、onStepEnd、onStepSettled 上标了 @shouldReplay;DynamicTileMover 标在 onStepEnd。这些方法会改变状态,所以移动会进入录像检查。自定义移动器同理:把 @shouldReplay 标在真正改状态的步进方法上,通常是 onStepEnd 或 onStepSettled,不要标在只读查询上。
标记的具体含义、检测能保证什么与不能保证什么,见录像一致性。
位置本身是存档内容。IHeroLocationSave 保存 x、y、direction、floorId;移动过程不会为每一步写存档,读档后恢复的是位置结果,不是「正在走第几步」。因此不要在移动器里另存一份位置状态,否则读档后容易与对象本身不一致。
内部执行流程
此章节讲解一下移动器的内部执行流程。下面按 ObjectMover 的源码顺序说明,只讲与使用相关的时序,不逐行翻译实现。
start() 的步骤:
- 如果
moving为真,直接返回null。 - 把
moveQueue切片成本次的局部队列queue,再清空内部队列。 - 把
shouldStop置假,把moveDirection重置为Unknown。 - 创建控制器:
done初始为假,onEnd是待兑现的 Promise;push与insert操作的是局部队列queue,stop把shouldStop置真并返回onEnd。 - 把
moving置真,启动moveProgress(queue, controller)。 moveProgress结束时,把moving置假、done置真,并兑现onEnd。
moveProgress 的顺序:
- 子类
onMoveStart,然后钩子onMoveStart,用Promise.all等待。 - 循环取步:从
queue头部取一步;如果取不到、moving已为假或shouldStop为真,退出循环。 - 调用
prepareStep,同步更新moveDirection、faceDirection、currAnimDir、currentSpeed。 - 子类
onStepStart返回结果码code。 - 钩子
onStepStart,收到code,用Promise.all等待。 - 子类
onStepEnd返回落点ITileLocator。 - 记录移动前坐标
before与落点curr;坐标不同时才调用tile.setPos。这里调用的是对象的方法,不经过移动器的setPos。 - 子类
onStepSettled(before, curr, ...)。 - 钩子
onStepEnd,收到code,用Promise.all等待。 - 回到循环开头,直到队列空或被停止。
- 子类
onMoveEnd,然后钩子onMoveEnd,用Promise.all等待。
因为 controller.push 与 controller.insert 改的就是局部队列 queue,它们能影响本次执行;而 mover.step、mover.face 等方法写的是内部待执行队列,局部队列不会读它,所以执行期间无效。
shouldStop 的检查点在每轮循环开头。当前步的 onStepStart、onStepEnd、onStepSettled 与两组钩子都会走完,才在下一轮退出。这就是 stop() 不打断当前步的原因。
边界与注意
- start 返回 null:移动器已经在移动时不会接管。先等
controller.onEnd,或调用stop()后再排新计划。 - moveDirection 被重置:
start()把它设为Unknown。计划里要用forward()时,前面先放step或stepFace确定移动方向。 - 执行期间链式方法无效:
mover.step、mover.face、mover.speed等写的是下一次的队列;执行中追加只能用controller.push或controller.insert。 - stop 不立刻中断:当前步会走完。第一步还没开始时调用,可能一步都不走。
- 不要在步进方法里 await stop:
stop()返回的 Promise 要等当前步结束才兑现,在步进方法或步进钩子里await会卡死整次移动。 - setPos 不触发步进钩子:移动器的
setPos只触发onSetPos,不触发onStepStart/onStepEnd;移动过程中的落点写入也不会触发onSetPos。 - speed 与 animDir 只是状态:它们不产生位移。没有消费方读取时,设置速度与动画方向不会产生可见效果。
- faceHandler 必须与对象方向体系一致:四方向对象配八方向处理器,或反过来,都会让偏移与朝向对不上。
- 通行判断不在移动器里:移动器只算落点。能不能走、撞到什么、踩到什么,由调用方的规则决定。
- 控制器追加可能被忽略:已经
stop或done之后,controller.push与controller.insert静默不生效。 - 不要重复 new 现成移动器:
state.hero.location.mover与动态图块的mover与对象同生命周期,另建一个不会同步位置与朝向。
怎么验证
先排一个固定的三步计划,观察坐标、done 与钩子顺序:
import { FaceDirection, IObjectMoverHooks } from '@user/data-common';
import { IHeroLocation } from '@user/data-base';
import { ICoreState } from '@user/data-state';
// 打印钩子顺序:期望 onMoveStart → 每步 onStepStart → onStepEnd → onMoveEnd
class MoveOrderHooks implements IObjectMoverHooks<IHeroLocation> {
async onMoveStart(): Promise<void> {
console.log('onMoveStart');
}
async onStepStart(code: number): Promise<void> {
console.log('onStepStart', code);
}
async onStepEnd(code: number): Promise<void> {
console.log('onStepEnd', code);
}
async onMoveEnd(): Promise<void> {
console.log('onMoveEnd');
}
}
async function verifyPlan(state: ICoreState): Promise<void> {
const mover = state.hero.location.mover;
// 注册钩子后保存控制器,验证结束再卸载
const hookController = mover.addHook(new MoveOrderHooks());
const before = state.hero.getLocation();
const controller = mover
.step(FaceDirection.Right, 2)
.face(FaceDirection.Down)
.step(FaceDirection.Down)
.start();
if (!controller) {
hookController.unload();
return;
}
console.log('done before end', controller.done); // false
await controller.onEnd;
console.log('done after end', controller.done); // true
const after = state.hero.getLocation();
console.log('before', before.x, before.y, 'after', after.x, after.y);
hookController.unload();
}
预期:done 从假变真;坐标先水平移动两格,再向下移动一格;钩子顺序与表中一致,且每一步的 onStepStart 都在对应的 onStepEnd 之前。
再验证执行中追加:调用 start() 后立刻 controller.push 一个 Dir 步骤,观察最终坐标是否多走了这一步。如果改用 mover.step 追加,最终坐标不会包含它,因为那是下一次执行的队列。
最后验证停止:在 onStepEnd 钩子里调用 controller.stop(),观察移动是否在当前步结束时停下。不要把 stop() 的 Promise 在钩子里 await,否则会卡住。
import {
FaceDirection,
IMoverController,
IObjectMoverHooks
} from '@user/data-common';
import { IHeroLocation } from '@user/data-base';
import { ICoreState } from '@user/data-state';
// 在第一步结束时叫停;不要 await stop() 返回的 Promise
class StopAfterOneStep implements IObjectMoverHooks<IHeroLocation> {
controller: Readonly<IMoverController> | null = null;
async onStepEnd(): Promise<void> {
this.controller?.stop();
}
}
async function verifyStop(state: ICoreState): Promise<void> {
const mover = state.hero.location.mover;
const hook = new StopAfterOneStep();
const hookController = mover.addHook(hook);
const before = state.hero.getLocation();
const controller = mover.step(FaceDirection.Right, 3).start();
if (!controller) {
hookController.unload();
return;
}
// start() 返回时第一步还没结束,这里补上控制器引用
hook.controller = controller;
await controller.onEnd;
const after = state.hero.getLocation();
console.log('只应移动一格', after.x - before.x);
hookController.unload();
}
预期:坐标只变化一格,done 为真,控制器上的后续追加不再生效。