feat: 楼层切换

This commit is contained in:
unanmed 2026-07-26 20:22:36 +08:00
parent 4bf39062a8
commit f4c77d152f
15 changed files with 449 additions and 23 deletions

View File

@ -1,9 +1,22 @@
import { IFacedTileLocator } from '@motajs/common';
import {
Hookable,
HookController,
IFacedTileLocator,
IHookController
} from '@motajs/common';
import { FaceDirection, IDataCommon, IFaceHandler } from '@user/data-common';
import { HeroMover } from './mover';
import { IHeroLocation, IHeroLocationSave, IHeroMover } from './types';
import {
IHeroLocation,
IHeroLocationHook,
IHeroLocationSave,
IHeroMover
} from './types';
export class HeroLocation implements IHeroLocation {
export class HeroLocation
extends Hookable<IHeroLocationHook>
implements IHeroLocation
{
x: number;
y: number;
floorId: string | undefined = undefined;
@ -16,6 +29,7 @@ export class HeroLocation implements IHeroLocation {
loc: IFacedTileLocator,
faceHandler: IFaceHandler<FaceDirection>
) {
super();
this.state = state;
this.x = loc.x;
this.y = loc.y;
@ -24,9 +38,21 @@ export class HeroLocation implements IHeroLocation {
this.mover = mover;
}
protected createController(
hook: Partial<IHeroLocationHook>
): IHookController<IHeroLocationHook> {
return new HookController(this, hook);
}
setFloor(floorId: string | undefined): void {
this.floorId = floorId;
this.forEachHook(hook => hook.onSetFloor?.(floorId));
}
setPos(x: number, y: number): void {
this.x = x;
this.y = y;
this.forEachHook(hook => hook.onSetPos?.(x, y));
}
getCurrentFaceDirection(): FaceDirection {

View File

@ -40,7 +40,7 @@ export class HeroMover<T extends IHeroLocation>
readonly tile: T,
faceHandler: IFaceHandler<FaceDirection>
) {
super(faceHandler);
super(faceHandler, tile.getCurrentFaceDirection());
this.state = tile.state;
}

View File

@ -15,7 +15,9 @@ import {
IHeroState,
IHeroStateSave,
IModifierStateSave,
IReadonlyHeroAttribute
IReadonlyHeroAttribute,
IHeroChangeFloorInfo,
IHeroStateHook
} from './types';
import {
FaceDirection,
@ -23,9 +25,18 @@ import {
IFaceHandler,
SaveCompression
} from '@user/data-common';
import { IFacedTileLocator, logger } from '@motajs/common';
import {
Hookable,
HookController,
IFacedTileLocator,
IHookController,
logger
} from '@motajs/common';
export class HeroState<THero> implements IHeroState<THero> {
export class HeroState<THero>
extends Hookable<IHeroStateHook>
implements IHeroState<THero>
{
/** 修饰器工厂函数注册表 */
private readonly registry: Map<
string,
@ -43,6 +54,7 @@ export class HeroState<THero> implements IHeroState<THero> {
faceHandler: IFaceHandler<FaceDirection>,
public attribute: IHeroAttribute<THero>
) {
super();
this.rendering = new HeroRendering(state);
const defaultLoc: IFacedTileLocator = {
x: 0,
@ -59,8 +71,10 @@ export class HeroState<THero> implements IHeroState<THero> {
this.equip = new HeroEquipment(this.items.equipment, this.attribute);
}
attachAttribute(attribute: IHeroAttribute<THero>): void {
this.attribute = attribute;
protected createController(
hook: Partial<IHeroStateHook>
): IHookController<IHeroStateHook> {
return new HookController(this, hook);
}
getLocation(): IFacedTileLocator {
@ -71,6 +85,12 @@ export class HeroState<THero> implements IHeroState<THero> {
};
}
//#region 属性相关
attachAttribute(attribute: IHeroAttribute<THero>): void {
this.attribute = attribute;
}
getModifiableAttribute(): IHeroAttribute<THero> {
return this.attribute;
}
@ -109,6 +129,22 @@ export class HeroState<THero> implements IHeroState<THero> {
return modifier;
}
//#endregion
async changeFloor(info: IHeroChangeFloorInfo): Promise<void> {
await Promise.all(
this.forEachHook(hook => hook.onBeforeChangeFloor?.(info))
);
const mover = this.location.mover;
mover.setFaceDir(info.face);
mover.setMoveDir(info.face);
mover.setPos(info.x, info.y);
this.location.setFloor(info.target);
await Promise.all(
this.forEachHook(hook => hook.onAfterChangeFloor?.(info))
);
}
saveState(compression: SaveCompression): IHeroStateSave<THero> {
const modifiers: IModifierStateSave<THero>[] = [];
for (const [name, modifier] of this.attribute.iterateModifiers()) {

View File

@ -227,6 +227,21 @@ export interface IHeroAttribute<THero> extends IReadonlyHeroAttribute<THero> {
//#region 勇士位置
export interface IHeroLocationHook extends IHookBase {
/**
*
* @param x
* @param y
*/
onSetPos?(x: number, y: number): void;
/**
*
* @param floorId idundefined
*/
onSetFloor?(floorId: string | undefined): void;
}
export interface IHeroLocationSave {
/** 当前横坐标 */
readonly x: number;
@ -242,11 +257,18 @@ export interface IHeroLocation
extends
ISaveableContent<IHeroLocationSave>,
IObjectMovable,
IHookable<IHeroLocationHook>,
IDataCommonExtended {
/** 当前所在楼层 idundefined 表示尚不处于任何楼层 */
readonly floorId: string | undefined;
/** 勇士的移动对象 */
readonly mover: IHeroMover<this>;
/**
* id
* @param floorId id
*/
setFloor(floorId: string | undefined): void;
}
//#endregion
@ -728,6 +750,33 @@ export interface IHeroEquipment<THero>
//#region 勇士状态
export interface IHeroChangeFloorInfo {
/** 要切换至的目标楼层 */
readonly target: string;
/** 要切换至的目标位置横坐标 */
readonly x: number;
/** 要切换至的目标位置纵坐标 */
readonly y: number;
/** 要切换至的目标朝向 */
readonly face: FaceDirection;
}
export interface IHeroStateHook extends IHookBase {
/**
* `IHeroLocation` `onSetFloor`
*
* @param info
*/
onBeforeChangeFloor?(info: IHeroChangeFloorInfo): Promise<void>;
/**
* `IHeroLocation` `onSetFloor`
*
* @param info
*/
onAfterChangeFloor?(info: IHeroChangeFloorInfo): Promise<void>;
}
export interface IHeroStateSave<THero> {
/** 勇士属性状态 */
readonly attribute: THero;
@ -745,9 +794,8 @@ export interface IHeroStateSave<THero> {
readonly equip: IHeroEquipmentSave;
}
export interface IHeroState<THero> extends ISaveableContent<
IHeroStateSave<THero>
> {
export interface IHeroState<THero>
extends ISaveableContent<IHeroStateSave<THero>>, IHookable<IHeroStateHook> {
/** 勇士移动对象 */
readonly location: IHeroLocation;
/** 勇士属性对象 */
@ -809,6 +857,12 @@ export interface IHeroState<THero> extends ISaveableContent<
type: string,
name: K
): IHeroModifier<THero[K], V> | null;
/**
*
* @param info
*/
changeFloor(info: IHeroChangeFloorInfo): Promise<void>;
}
//#endregion

View File

@ -1,5 +1,5 @@
export * from './layerState';
export * from './mapLayer';
export * from './mapStore';
export * from './mapState';
export * from './mover';
export * from './types';

View File

@ -1,5 +1,5 @@
import { logger } from '@motajs/common';
import { IDataCommon, SaveCompression } from '@user/data-common';
import { IDataCommon, IMapRawData, SaveCompression } from '@user/data-common';
import { ITileStore } from '@user/data-common';
import {
ILayerState,
@ -11,7 +11,7 @@ import {
MapArea
} from './types';
import { LayerState } from './layerState';
import { uniq } from 'lodash-es';
import { isNil, uniq } from 'lodash-es';
export class MapState implements IMapState {
/** 楼层 id 到状态对象的映射 */
@ -39,6 +39,56 @@ export class MapState implements IMapState {
//#region 楼层管理
fromRaw(raw: IMapRawData): ILayerState | null {
let length = 0;
const entries = Object.entries(raw.map);
for (const [_, map] of Object.entries(raw.map)) {
if (length > 0 && map.length !== length) {
logger.error(60, map.length.toString(), length.toString());
return null;
} else {
length = map.length;
}
}
if (length % raw.width !== 0) {
logger.error(61, length.toString(), raw.width.toString());
return null;
}
// 设置楼层图块数据
const height = length / raw.width;
const state = this.createLayerState(raw.floorId, raw.width, height);
for (const [zIndex, map] of entries) {
const z = Number(zIndex);
if (isNaN(z)) {
logger.error(62, 'IMapRawData.map', String(zIndex));
continue;
}
const layer = state.addLayer();
const alias = raw.layerAlias[z];
layer.setMapRef(new Uint32Array(map));
layer.setZIndex(z);
state.setLayerAlias(layer, alias);
// 设置图块静态触发器
const extra = raw.blockData[z];
for (const [index, data] of Object.entries(extra)) {
const indexNum = Number(index);
if (isNaN(indexNum)) {
logger.error(62, 'IMapRawData.blockData', String(index));
continue;
}
if (!isNil(data.trigger)) {
const x = indexNum % raw.width;
const y = Math.floor(indexNum / raw.width);
layer.setTriggerType(data.trigger, x, y);
}
}
}
return state;
}
createLayerState(id: string, width: number, height: number): ILayerState {
if (this.mapData.has(id)) {
logger.warn(121, id);

View File

@ -18,7 +18,7 @@ const enum DynamicMoveCode {
export class DynamicTileMover extends ObjectMover<IDynamicTile> {
constructor(public readonly tile: IDynamicTile) {
const face = tile.state.faceManager;
super(face.get(DYNAMIC_MOVER_FACE)!);
super(face.get(DYNAMIC_MOVER_FACE)!, tile.getCurrentFaceDirection());
}
protected onMoveStart(): Promise<void> {

View File

@ -1,7 +1,14 @@
import { IHookable, IHookBase, IHookController } from '@motajs/common';
import {
IHookable,
IHookBase,
IHookController,
ITileLocator
} from '@motajs/common';
import {
FaceDirection,
IDataCommonExtended,
IMapBlockRawData,
IMapRawData,
IMoverController,
IObjectMovable,
IObjectMover,
@ -21,6 +28,8 @@ export interface IMapLayerData {
}
export interface ILayerLocation {
/** 该点的位置 */
readonly locator: ITileLocator;
/** 该点的静态图块数字 */
readonly tile: number;
/** 该点的静态图块信息 */
@ -29,6 +38,8 @@ export interface ILayerLocation {
readonly trigger: number;
/** 该点包含的所有动态图块 */
readonly dynamics: Iterable<IDynamicTile>;
/** 该点的静态图块原始数据 */
readonly block: IMapBlockRawData;
}
export interface IMapLayerHooks extends IHookBase {
@ -442,6 +453,12 @@ export interface IMapState
*/
getActiveMap(id: string): ILayerState | null;
/**
*
* @param raw
*/
fromRaw(raw: IMapRawData): ILayerState | null;
/**
* id
* @param id id

View File

@ -371,9 +371,13 @@ export abstract class ObjectMover<T extends IObjectMovable>
readonly faceHandler: IFaceHandler<FaceDirection>;
constructor(faceHandler: IFaceHandler<FaceDirection>) {
constructor(
faceHandler: IFaceHandler<FaceDirection>,
direction: FaceDirection
) {
super();
this.faceHandler = faceHandler;
this.faceDirection = direction;
}
protected createController(
@ -651,7 +655,6 @@ export abstract class ObjectMover<T extends IObjectMovable>
this.clear();
this.shouldStop = false;
this.faceDirection = this.tile.getCurrentFaceDirection();
this.moveDirection = FaceDirection.Unknown;
const { promise, resolve } = Promise.withResolvers<void>();

View File

@ -1,5 +1,7 @@
//#region tile
import { IFacedTileLocator } from '@motajs/common';
export const enum TileType {
/** 未知或尚未归类的图块 */
Unknown,
@ -253,6 +255,54 @@ export interface IItemStore<THero, TLegacy> {
//#region map
export const enum ChangeFloorType {
/** 指定具体位置的楼层切换方式 */
Specified,
/** 仅指定相对关系的楼层切换方式 */
Related
}
export const enum ChangeFloorTarget {
/** 切换至前一层 */
Previous,
/** 切换至后一层 */
Next
}
export const enum ChangeFloorPos {
/** 原地不动 */
Stand,
/** 左右对称位置 */
SymmetryX,
/** 上下对称位置 */
SymmetryY,
/** 中心对称位置 */
CentralSymmetry
}
export interface IChangeFloorData {
/** 切换至的楼层 id 的切换方式 */
readonly floorType: ChangeFloorType;
/** 指定具体位置的切换楼层方式下,切换至的具体楼层 */
readonly targetFloor?: string;
/** 相对关系的楼层切换方式下,切换至的楼层代号 */
readonly relatedFloor?: ChangeFloorTarget;
/** 切换至的目标位置的切换方式 */
readonly posType: ChangeFloorType;
/** 指定具体位置的位置切换方式下,切换至的具体位置,朝向填写 `FaceDirection.Unknown` 视为维持 */
readonly targetPos?: IFacedTileLocator;
/** 相对关系的位置切换方式下,切换至的位置代号 */
readonly relatedPos?: ChangeFloorPos;
}
export interface IMapBlockRawData {
/** 此点的静态触发器类型(仅对该点生效,不会跟随任何图块移动) */
readonly trigger?: number;
/** 楼层切换信息 */
readonly changeFloor?: IChangeFloorData;
}
export interface IMapRawData {
/** 楼层 id */
readonly floorId: string;
@ -262,6 +312,8 @@ export interface IMapRawData {
readonly map: Record<number, number[]>;
/** 每个地图图层的字符串别名 */
readonly layerAlias: Record<number, string>;
/** 每个地图图层中,指定位置图块的额外数据,外层 key 为图层 zIndex内层 key 为图块位置索引 */
readonly blockData: Record<number, Record<number, IMapBlockRawData>>;
}
export interface IMapStore {

View File

@ -0,0 +1,162 @@
import { IFacedTileLocator, logger } from '@motajs/common';
import {
IHeroChangeFloorInfo,
IHeroState,
IMapLayer,
IMapState,
IStateBase
} from '@user/data-base';
import {
ChangeFloorPos,
ChangeFloorTarget,
ChangeFloorType,
FaceDirection,
IChangeFloorData,
IHeroAttr
} from '@user/data-common';
import {
BaseTrigger,
ITrigger,
ITriggerCollection,
ITriggerHandler,
TriggerCollection
} from '@user/data-system';
import { isNil } from 'lodash-es';
export const enum TriggerType {
/** 楼层切换触发器 */
ChangeFloor
}
export class ChangeFloorTrigger extends BaseTrigger implements ITrigger {
readonly type: number = TriggerType.ChangeFloor;
readonly priority: number = 10;
constructor(
state: IStateBase,
readonly maps: IMapState,
readonly hero: IHeroState<IHeroAttr>
) {
super(state);
}
/**
*
* @param data
*/
private getFloorTarget(data: IChangeFloorData): string | undefined {
if (data.floorType === ChangeFloorType.Specified) {
if (isNil(data.targetFloor)) {
logger.warn(165);
return void 0;
}
return data.targetFloor;
} else {
if (isNil(data.relatedFloor)) {
logger.warn(166);
return void 0;
}
const curr = this.hero.location.floorId;
if (isNil(curr)) {
logger.warn(167);
return void 0;
}
const index = this.maps.maps.indexOf(curr);
if (index === -1) {
logger.warn(168);
return void 0;
}
if (data.relatedFloor === ChangeFloorTarget.Next) {
const next = this.maps.maps[index + 1];
if (isNil(next)) {
logger.warn(169);
return void 0;
}
return next;
} else {
const next = this.maps.maps[index - 1];
if (isNil(next)) {
logger.warn(169);
return void 0;
}
}
}
}
private getPosTarget(
data: IChangeFloorData,
layer: IMapLayer
): IFacedTileLocator | undefined {
if (data.posType === ChangeFloorType.Specified) {
if (!data.targetPos) {
logger.warn(165);
return void 0;
}
return data.targetPos;
} else {
if (isNil(data.relatedPos)) {
logger.warn(166);
return void 0;
}
const { x, y, floorId } = this.hero.location;
if (isNil(floorId)) {
logger.warn(167);
return void 0;
}
const { width, height } = layer;
switch (data.relatedPos) {
case ChangeFloorPos.Stand:
return {
direction: FaceDirection.Unknown,
x,
y
};
case ChangeFloorPos.SymmetryX:
return {
direction: FaceDirection.Unknown,
x: width - x - 1,
y
};
case ChangeFloorPos.SymmetryY:
return {
direction: FaceDirection.Unknown,
x,
y: height - y - 1
};
case ChangeFloorPos.CentralSymmetry:
return {
direction: FaceDirection.Unknown,
x: width - x - 1,
y: height - y - 1
};
}
}
}
async trigger(handler: ITriggerHandler): Promise<void> {
if (!handler.layer || !handler.locator || !handler.mapLayer) {
logger.warn(164);
return;
}
const { x, y } = handler.locator;
const data = handler.mapLayer.getLocationData(x, y);
if (!data || !data.block.changeFloor) return;
const floor = this.getFloorTarget(data.block.changeFloor);
const pos = this.getPosTarget(data.block.changeFloor, handler.mapLayer);
if (isNil(floor) || isNil(pos)) return;
const hero = handler.state.hero;
const isUnknown = pos.direction === FaceDirection.Unknown;
const defaults = hero.location.mover.faceDirection;
const info: IHeroChangeFloorInfo = {
target: floor,
x: pos.x,
y: pos.y,
face: isUnknown ? defaults : pos.direction
};
return handler.state.hero.changeFloor(info);
}
collection(): ITriggerCollection {
return new TriggerCollection([this]);
}
}

View File

@ -225,7 +225,7 @@ export class CoreState implements ICoreState {
this.initTileStore(core.maps.blocksInfo);
this.initItemStore(core.items.items);
this.initEnemyManager(enemys_fcae963b_31c9_42b4_b48c_bb48d09f3f80);
this.initMapStore(
this.initMapState(
core.floorIds,
core.floors as Record<FloorIds, ResolvedFloor>
);
@ -323,7 +323,7 @@ export class CoreState implements ICoreState {
manager.compareWith(reference);
}
private initMapStore(
private initMapState(
floors: FloorIds[],
data: Record<FloorIds, ResolvedFloor>
) {

View File

@ -1,4 +1,5 @@
export * from './collection';
export * from './collector';
export * from './registry';
export * from './trigger';
export * from './types';

View File

@ -0,0 +1,16 @@
import { IStateBase } from '@user/data-base';
import { ITrigger, ITriggerCollection, ITriggerHandler } from './types';
import { TriggerCollection } from './collection';
export abstract class BaseTrigger implements ITrigger {
abstract type: number;
abstract priority: number;
constructor(readonly state: IStateBase) {}
abstract trigger(handler: ITriggerHandler): Promise<void>;
collection(): ITriggerCollection {
return new TriggerCollection([this]);
}
}

View File

@ -58,7 +58,10 @@
"56": "Cannot convert legacy tile data since no tile legacy converter is attached to TileStore.",
"57": "Cannot convert legacy item data since no item legacy converter is attached to ItemStore.",
"58": "Cannot find max equipment uid while loading equipment store.",
"59": "Cannot find item raw data for tile num $1."
"59": "Cannot find item raw data for tile num $1.",
"60": "Cannot create layer state from raw data since the area of every layer of the map does not equal: $1 vs $2.",
"61": "Cannot create layer state from raw data since map area cannot be divided by width evenly: area = $1 while divider = $2.",
"62": "Expected a number for the key of $1, but got $2."
},
"warn": {
"1": "Resource with type of 'none' is loaded.",
@ -223,6 +226,12 @@
"160": "Replay safety collection ending function is called outside replay safety collection unexpectedly.",
"161": "Replay safety detected. Followings are simplified detect info. To get detailed info, run: '$1'.\n$2",
"162": "Cannot find replay safety detail info of code $1. The detail info may expired, or it just has not been existed.",
"163": "Replay command code $1 has already been registered. This will not replace the old registry."
"163": "Replay command code $1 has already been registered. This will not replace the old registry.",
"164": "Change floor trigger needs specified locator.",
"165": "Target floor id or target position needs to be specified while change floor type is 'ChangeFloorType.Specified'.",
"166": "Related floor id or related position needs to be specified while change floor type is 'ChangeFloorType.Related'.",
"167": "Hero must at a specified floor before using related change floor method.",
"168": "The current floor of hero does not in the specified map list.",
"169": "The current floor is the last or the first floor in the map list, so related floor change method will not work."
}
}