You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
344 lines
13 KiB
344 lines
13 KiB
import { _decorator, Component, sp, Tween, v3, Vec3, Node, tween, Color } from 'cc';
|
|
import { SuDace } from './SuDace';
|
|
import { MoveDirection, SuDacePlayer } from './SuDacePlayer';
|
|
import { SuDacePoint } from './SuDacePoint';
|
|
|
|
const { ccclass, property } = _decorator;
|
|
|
|
/**
|
|
* 移动管线与 SuDacePlayer.tickDirectionalInput 一致:有效方向 → stepAxisLockedWorldMove + onMoveStatusChange。
|
|
* 意图方向暂由追击逻辑直接算出(等价于玩家侧 setMoveInput,此处不设摇杆)。
|
|
*/
|
|
@ccclass('SuDaceEnemy')
|
|
export class SuDaceEnemy extends Component {
|
|
@property
|
|
moveSpeed = 100;
|
|
|
|
@property
|
|
maxHp = 20;
|
|
|
|
@property
|
|
hp = 20;
|
|
|
|
@property
|
|
level = 1;
|
|
|
|
@property
|
|
attackDamage = 1;
|
|
|
|
@property
|
|
attackInterval = 0.6;
|
|
|
|
@property
|
|
attackYTolerance = 80;
|
|
|
|
/** 由脚下路点 SuDacePoint.floor 得到(平台可走处需有带 floor 的路点或占位) */
|
|
currentFloor: number = 0;
|
|
|
|
anim: sp.Skeleton = null;
|
|
|
|
/**正在攻击 */
|
|
isAttacking = false;
|
|
|
|
/**正在爬楼梯 */
|
|
isClimbing = false;
|
|
|
|
/** 本帧希望移动的方向(对应玩家 _inputDir;默认向左,可由外部或后续 AI 改写) */
|
|
private _moveIntent: MoveDirection = MoveDirection.LEFT;
|
|
/** 上一帧有效位移方向(与玩家 _lastInputDir 一致) */
|
|
private _lastInputDir: MoveDirection = MoveDirection.NONE;
|
|
|
|
/** 须从 NONE 开始,否则首帧 onMoveStatusChange(LEFT) 会因「已是 LEFT」被整段跳过 */
|
|
private _curentStatus: MoveDirection = MoveDirection.NONE;
|
|
|
|
private _player: SuDacePlayer = null;
|
|
private _attackAcc = 0;
|
|
|
|
protected onLoad(): void {
|
|
this.anim = this.node.getComponentInChildren(sp.Skeleton);
|
|
}
|
|
|
|
init(level: number, hp: number, moveSpeed: number): void {
|
|
this.level = Math.max(1, level);
|
|
this.maxHp = Math.max(1, Math.floor(hp));
|
|
this.hp = this.maxHp;
|
|
this.moveSpeed = Math.max(10, moveSpeed);
|
|
this._moveIntent = MoveDirection.LEFT;
|
|
this._lastInputDir = MoveDirection.NONE;
|
|
this._curentStatus = MoveDirection.NONE;
|
|
this._player = SuDace.instance?.map.player;
|
|
this._attackAcc = 0;
|
|
this.isAttacking = false;
|
|
this.isClimbing = false;
|
|
}
|
|
|
|
update(dt: number): void {
|
|
const su = SuDace.instance;
|
|
if (!su?.map) return;
|
|
|
|
if (!this._player?.node?.isValid) {
|
|
this._player = su.map.player;
|
|
}
|
|
if (!this._player?.node?.isValid) {
|
|
this._moveIntent = MoveDirection.NONE;
|
|
this.tickMoveByIntent(dt);
|
|
return;
|
|
}
|
|
|
|
let distance = Vec3.distance(this.node.worldPosition, this._player.node.worldPosition);
|
|
const yDiff = Math.abs(this.node.worldPosition.y - this._player.node.worldPosition.y);
|
|
// 梯口吸附后两者 y 可能存在几十像素偏差;容差过小会出现“只打一下就脱战”
|
|
this.isAttacking = distance < 100 && yDiff <= Math.max(0, this.attackYTolerance);
|
|
|
|
if (this.isAttacking) {
|
|
this._moveIntent = MoveDirection.NONE;
|
|
this._attackAcc += dt;
|
|
if (this._attackAcc >= Math.max(0.05, this.attackInterval)) {
|
|
this._attackAcc -= Math.max(0.05, this.attackInterval);
|
|
this._player.takeDamage(this.attackDamage);
|
|
this.playAttackSpine();
|
|
}
|
|
} else {
|
|
this._attackAcc = 0;
|
|
let playerY = this._player.node.worldPosition.y;
|
|
let playerX = this._player.node.worldPosition.x;
|
|
let enemyY = this.node.worldPosition.y;
|
|
let enemyX = this.node.worldPosition.x;
|
|
// worldPosition 是浮点数,使用容差判断同一高度,避免因精度抖动导致一直不相等
|
|
if (Math.abs(enemyY - playerY) <= 2) {
|
|
this.isClimbing = false;
|
|
if (enemyX > playerX) {
|
|
this._moveIntent = MoveDirection.LEFT;
|
|
} else {
|
|
this._moveIntent = MoveDirection.RIGHT;
|
|
}
|
|
} else {
|
|
if (!this.isClimbing) {
|
|
const needUp = enemyY < playerY;
|
|
const grid = this.findNearestStairEntrance(
|
|
needUp,
|
|
enemyX,
|
|
enemyY,
|
|
su.map.gridNode?.children ?? []
|
|
);
|
|
|
|
if (grid) {
|
|
let disX = Math.abs(enemyX - grid.worldPosition.x);
|
|
let disY = Math.abs(enemyY - grid.worldPosition.y);
|
|
if (disX < 50 && disY < 50) {
|
|
this.isClimbing = true;
|
|
}
|
|
|
|
if (enemyX > grid.worldPosition.x) {
|
|
this._moveIntent = MoveDirection.LEFT;
|
|
} else {
|
|
this._moveIntent = MoveDirection.RIGHT;
|
|
}
|
|
} else {
|
|
// 没找到梯口时继续巡逻,避免原地卡死
|
|
this._moveIntent = MoveDirection.LEFT;
|
|
}
|
|
} else {
|
|
if (Math.abs(enemyY - playerY) <= 2) {
|
|
// 高度已接近时,也把位置吸附到目标梯口,避免结束爬梯后还偏离梯口
|
|
const arriveByUp = enemyY < playerY;
|
|
const nearestExitStair = this.findNearestStairEntrance(
|
|
!arriveByUp,
|
|
enemyX,
|
|
enemyY,
|
|
su.map.gridNode?.children ?? []
|
|
);
|
|
this.finishClimbAtStair(nearestExitStair, playerX);
|
|
} else if (enemyY < playerY) {
|
|
this._moveIntent = MoveDirection.UP;
|
|
// 如果到达最近下梯口,则终止爬楼梯
|
|
const nearestDownStair = this.findNearestStairEntrance(
|
|
false,
|
|
enemyX,
|
|
enemyY,
|
|
su.map.gridNode?.children ?? []
|
|
);
|
|
if (this.isReachedStair(nearestDownStair, enemyX, enemyY)) {
|
|
this.finishClimbAtStair(nearestDownStair, playerX);
|
|
}
|
|
} else {
|
|
this._moveIntent = MoveDirection.DOWN;
|
|
// 如果到达最近上梯口,则终止爬楼梯
|
|
const nearestUpStair = this.findNearestStairEntrance(
|
|
true,
|
|
enemyX,
|
|
enemyY,
|
|
su.map.gridNode?.children ?? []
|
|
);
|
|
if (this.isReachedStair(nearestUpStair, enemyX, enemyY)) {
|
|
this.finishClimbAtStair(nearestUpStair, playerX);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
this.tickMoveByIntent(dt);
|
|
}
|
|
|
|
/**
|
|
* 选梯口:先选同高度带内最近,避免选到楼上/楼下另一端;没有再回退全量最近。
|
|
*/
|
|
private findNearestStairEntrance(needUp: boolean, enemyX: number, enemyY: number, cells: Node[]): Node | null {
|
|
let bestNear: Node | null = null;
|
|
let bestNearScore = Number.POSITIVE_INFINITY;
|
|
let bestAny: Node | null = null;
|
|
let bestAnyScore = Number.POSITIVE_INFINITY;
|
|
const sameBand = 90;
|
|
|
|
for (let i = 0; i < cells.length; i++) {
|
|
const g = cells[i];
|
|
const point = g.getComponent(SuDacePoint);
|
|
if (!point) continue;
|
|
if (needUp && !point.isUpStairs) continue;
|
|
if (!needUp && !point.isDownStairs) continue;
|
|
|
|
const dx = Math.abs(g.worldPosition.x - enemyX);
|
|
const dy = Math.abs(g.worldPosition.y - enemyY);
|
|
const score = dx + dy * 0.35;
|
|
|
|
if (score < bestAnyScore) {
|
|
bestAnyScore = score;
|
|
bestAny = g;
|
|
}
|
|
if (dy <= sameBand && score < bestNearScore) {
|
|
bestNearScore = score;
|
|
bestNear = g;
|
|
}
|
|
}
|
|
return bestNear ?? bestAny;
|
|
}
|
|
|
|
private isReachedStair(stair: Node | null, enemyX: number, enemyY: number): boolean {
|
|
if (!stair) return false;
|
|
const reachX = 50;
|
|
const reachY = 50;
|
|
const disX = Math.abs(enemyX - stair.worldPosition.x);
|
|
const disY = Math.abs(enemyY - stair.worldPosition.y);
|
|
return disX <= reachX && disY <= reachY;
|
|
}
|
|
|
|
private finishClimbAtStair(stair: Node | null, playerX: number): void {
|
|
if (stair?.isValid) {
|
|
const z = this.node.worldPosition.z;
|
|
this.node.setWorldPosition(stair.worldPosition.x, stair.worldPosition.y, z);
|
|
}
|
|
this.isClimbing = false;
|
|
this._moveIntent = this.node.worldPosition.x > playerX ? MoveDirection.LEFT : MoveDirection.RIGHT;
|
|
}
|
|
|
|
/**
|
|
* 与 SuDacePlayer.tickDirectionalInput 对齐(不含楼梯:不解析 isUpStairs / 不攀爬)。
|
|
* 后续若要有楼梯,可在此接入与玩家相同的 resolveStairMoveDirection + isClimbing。
|
|
*/
|
|
private tickMoveByIntent(dt: number): void {
|
|
const effective = this._moveIntent;
|
|
|
|
if (effective === MoveDirection.NONE) {
|
|
if (this._lastInputDir !== MoveDirection.NONE) {
|
|
this.onMoveStatusChange(MoveDirection.NONE);
|
|
}
|
|
this._lastInputDir = MoveDirection.NONE;
|
|
return;
|
|
}
|
|
|
|
if (this._lastInputDir === MoveDirection.NONE) {
|
|
Tween.stopAllByTarget(this.node);
|
|
}
|
|
this._lastInputDir = effective;
|
|
this.stepAxisLockedWorldMove(effective, dt);
|
|
this.onMoveStatusChange(effective);
|
|
}
|
|
|
|
/** 与 SuDacePlayer.stepAxisLockedWorldMove 一致 */
|
|
private stepAxisLockedWorldMove(dir: MoveDirection, dt: number): void {
|
|
const p = this.node.worldPosition;
|
|
const step = this.moveSpeed * dt;
|
|
switch (dir) {
|
|
case MoveDirection.RIGHT:
|
|
this.node.setWorldPosition(p.x + step, p.y, p.z);
|
|
break;
|
|
case MoveDirection.LEFT:
|
|
this.node.setWorldPosition(p.x - step, p.y, p.z);
|
|
break;
|
|
case MoveDirection.UP:
|
|
this.node.setWorldPosition(p.x, p.y + step, p.z);
|
|
break;
|
|
case MoveDirection.DOWN:
|
|
this.node.setWorldPosition(p.x, p.y - step, p.z);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/** 与 SuDacePlayer.onMoveStatusChange 一致(ZClips / 节点显隐 / scale) */
|
|
onMoveStatusChange(status: MoveDirection): void {
|
|
if (this._curentStatus === status) return;
|
|
this._curentStatus = status;
|
|
if (this._curentStatus !== MoveDirection.NONE) {
|
|
this.playWalkSpine();
|
|
}
|
|
|
|
switch (status) {
|
|
case MoveDirection.NONE:
|
|
break;
|
|
case MoveDirection.RIGHT:
|
|
this.node.scale = v3(-1, 1, 1);
|
|
break;
|
|
case MoveDirection.LEFT:
|
|
this.node.scale = v3(1, 1, 1);
|
|
break;
|
|
case MoveDirection.UP:
|
|
break;
|
|
case MoveDirection.DOWN:
|
|
break;
|
|
}
|
|
}
|
|
|
|
hit(damage: number): void {
|
|
this.hp -= Math.max(0, damage);
|
|
if (this.hp <= 0) {
|
|
this.die();
|
|
}
|
|
SuDace.instance.playHit();
|
|
Tween.stopAllByTarget(this.anim)
|
|
tween(this.anim)
|
|
.call(() => { this.anim.color = new Color(255, 83, 83, 255) })
|
|
.delay(0.35)
|
|
.call(() => { this.anim.color = new Color(255, 255, 255, 255) })
|
|
.start()
|
|
}
|
|
|
|
die(): void {
|
|
SuDace.instance?.recycleMonster(this.node);
|
|
}
|
|
|
|
/**播放走路动画 */
|
|
playWalkSpine(callback: Function = null, timeScale = 1, loop = true): void {
|
|
this.playSpine('走路', callback, timeScale, loop);
|
|
}
|
|
|
|
/**播放攻击动画 */
|
|
playAttackSpine(callback: Function = null, timeScale = 1, loop = false): void {
|
|
this.playSpine('攻击', callback, timeScale, loop);
|
|
}
|
|
|
|
/**播放spine动画 */
|
|
playSpine(name: string, callback: Function = null, timeScale = 1, loop = false): void {
|
|
if (this.anim.animation == name && loop) {
|
|
return;
|
|
}
|
|
this.anim.setCompleteListener(() => {
|
|
this.anim.setCompleteListener(null);
|
|
if (callback) callback();
|
|
});
|
|
this.anim.timeScale = timeScale;
|
|
this.anim.setAnimation(0, name, loop);
|
|
}
|
|
}
|
|
|