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); } }