import { Node, Vec3, sp } from 'cc'; import type { BattleSession, Cell, EntityId } from 'db://assets/mx/module/battle'; import { BattleTeamId } from 'db://assets/mx/module/battle'; import { HealthComp, MovementComp, TeamComp, TransformComp, isBajiaoBlowRetreating, isYanSoulClaimed, } from 'db://assets/mx/module/battle/ecs/Components'; import { GBundle, GPath } from '../../game/ConfigRes'; import { BattleMapSpec } from '../map/BattleMapSpec'; import type { MapManager } from '../map/legacy/MapManager'; import type { Skill } from '../skill/Skill'; import type { SkillPresentationContext } from '../skill/SkillPresentationTypes'; import { SkillPresentationEnv } from '../skill/SkillPresentationEnv'; import { computeAimAngleDeg, setNodeWorldPos, shortestAngleDeltaDeg, } from '../skill/SkillTransformDriver'; import { ensureSpineSkeletonDataRuntime } from 'db://assets/mx/module/lang/spineRuntimePatch'; import { BattleSpinePlayback } from './BattleSpinePlayback'; import { buildShadowDealContext } from './SkillShadowContext'; import type { ISkillPresenter } from './SkillPresenter'; /** battleHero/prefab/skill/冲锋 */ const CHARGE_PREFAB = '冲锋'; /** 朝向转速(度/秒) */ const TURN_DEG = 540; /** 沿路径前瞻(像素) */ const LOOKAHEAD = 28; /** 沿途命中半径(像素,对齐预制体半边) */ const HIT_RADIUS = 100; /** 冲锋直伤类型 */ const DMG_TYPE = 'earth_charge'; /** Skill.skill type=1 土冲锋参数 */ interface EarthChargeParams { /** 冲锋速度(像素/秒) */ speed: number; /** 持续秒 */ duration: number; /** 伤害系数 */ scale: number; } /** 一次土冲锋运行态 */ interface EarthChargeRun { /** 施法者 */ caster: EntityId; /** 影子伤害上下文 */ ctx: SkillPresentationContext; /** 冲锋根 */ node: Node; /** 路径世界坐标 */ waypoints: Vec3[]; /** 像素/秒 */ speed: number; /** 持续秒 */ duration: number; /** 伤害系数 */ scale: number; /** 已过秒 */ elapsed: number; /** 节点朝向角 */ angle: number; /** 已命中 */ hitIds: EntityId[]; /** 上一帧世界 X */ lastX: number; /** 上一帧世界 Y */ lastY: number; } let _host: EarthChargeHost | null = null; /** 土冲锋运行时 */ export function getEarthChargeHost(): EarthChargeHost { if (!_host) _host = new EarthChargeHost(); return _host; } /** * 土属性冲锋:自枪格吸附进路格生成「冲锋」预制体,沿哪吒同款进路反冲,沿途伤+击退;飞出后独立。 */ export class EarthChargeHost { private readonly _runs: EarthChargeRun[] = []; private readonly _tmp = new Vec3(); private readonly _aim = new Vec3(); private readonly _from = new Vec3(); private _hitRun: EarthChargeRun | null = null; /** * 解析 `1,速度,时长,伤害万分比`。 * @param raw Skill.skill */ static parse(raw: string): EarthChargeParams | null { const parts = String(raw ?? '').split(',').map((s) => Number(String(s).trim())); if (parts[0] !== 1) return null; const speed = parts[1]; const duration = parts[2]; const wan = parts[3]; if (!(speed > 0) || !(duration > 0) || !(wan > 0)) { console.log('[EarthCharge] skill 参数无效'); return null; } return { speed, duration, scale: wan / 10000 }; } /** * 开战:自施法者格吸附进路生成冲锋体并立刻放锁。 * @param ctx 表现上下文 * @param host Skill 宿主 */ start(ctx: SkillPresentationContext, host: Skill): void { this._abortCaster(ctx.caster); const raw = String(ctx.params.extraSkill ?? ctx.params.skillRaw ?? ''); const params = EarthChargeHost.parse(raw); if (!params) { host.drainLogicPresentation(ctx); return; } const session = SkillPresentationEnv.getBattleSession(); const map = SkillPresentationEnv.getMap(); if (!session || !map) { console.log('[EarthCharge] 无 session/map'); host.drainLogicPresentation(ctx); return; } const target = ctx.targets[0] ?? 0; if (!(target > 0) || !session.world.isAlive(target)) { console.log('[EarthCharge] 无附近怪物', target); host.drainLogicPresentation(ctx); return; } const fromCell = this._casterCell(session, map, ctx.caster); if (!fromCell) { console.log('[EarthCharge] 无施法者格', ctx.caster); host.drainLogicPresentation(ctx); return; } const snap = this._snapApproachCell(session, ctx.caster, fromCell); if (!snap) { console.log('[EarthCharge] 进路上无吸附格', fromCell); host.drainLogicPresentation(ctx); return; } const waypoints = this._buildRetreatWorlds(session, ctx.caster, snap, params); if (waypoints.length < 1) { console.log('[EarthCharge] 反向路径为空'); host.drainLogicPresentation(ctx); return; } const parent = SkillPresentationEnv.flyLayer ?? map.ensureHeroParent(); if (!parent?.isValid) { console.log('[EarthCharge] 无父节点'); host.drainLogicPresentation(ctx); return; } let node: Node | null = null; try { node = gg.res.getNode(GPath.HeroSkillPrefab(), CHARGE_PREFAB, GBundle.BattleHero); } catch (err) { console.log('[EarthCharge] getNode 失败', String(err)); host.drainLogicPresentation(ctx); return; } if (!node?.isValid) { console.log('[EarthCharge] 预制体未加载', CHARGE_PREFAB); host.drainLogicPresentation(ctx); return; } const seq = host.beginPresentationCast(ctx); if (!(seq > 0)) { gg.res.putNode(node); return; } host.disarmAnimComplete(); parent.active = true; node.active = true; node.layer = parent.layer; node.parent = parent; node.setSiblingIndex(parent.children.length - 1); node.setScale(1, 1, 1); const start = waypoints[0]; setNodeWorldPos(node, start); const angle = this._chargeAimDeg(start.x, start.y, waypoints, 0) ?? 0; node.angle = angle; this._playPrefabAnim(node); const shadow = buildShadowDealContext( ctx, String(ctx.params.buff1 ?? ''), String(ctx.params.buff2 ?? ''), ); this._runs.push({ caster: ctx.caster, ctx: shadow, node, waypoints, speed: params.speed, duration: params.duration, scale: params.scale, elapsed: 0, angle, hitIds: [], lastX: start.x, lastY: start.y, }); ctx.releasePresentationLock(); host.finishPresentationCast(seq, false); } /** * 推进冲锋。 * @param dt 逻辑秒 */ tick(dt: number): void { if (!(dt > 0)) return; for (let i = this._runs.length - 1; i >= 0; i--) { if (!this._step(this._runs[i], dt)) { this._finish(this._runs[i]); this._runs.splice(i, 1); } } } /** 结束全部冲锋 */ abortAll(): void { for (let i = this._runs.length - 1; i >= 0; i--) { this._finish(this._runs[i]); } this._runs.length = 0; } /** * 结束该施法者未完成的冲锋。 * @param caster 施法者 */ private _abortCaster(caster: EntityId): void { for (let i = this._runs.length - 1; i >= 0; i--) { if (this._runs[i].caster !== caster) continue; this._finish(this._runs[i]); this._runs.splice(i, 1); } } /** * 单帧位移与命中。false=结束。 * @param run 运行态 * @param dt 逻辑秒 */ private _step(run: EarthChargeRun, dt: number): boolean { if (!run.ctx.isAlive || !run.node.isValid) return false; run.elapsed += dt; const cap = run.speed * Math.min(run.duration, run.elapsed); this._pointAlong(run.waypoints, cap, this._tmp); setNodeWorldPos(run.node, this._tmp); run.lastX = this._tmp.x; run.lastY = this._tmp.y; this._turn(run, dt, this._chargeAimDeg(run.lastX, run.lastY, run.waypoints, cap)); this._hitNear(run); const pathLen = this._pathLen(run.waypoints); if (run.elapsed >= run.duration || cap >= pathLen - 0.5) return false; return true; } /** * 冲锋体附近敌对怪各伤一次(含 atk_buff 击退)。 * @param run 运行态 */ private _hitNear(run: EarthChargeRun): void { const session = SkillPresentationEnv.getBattleSession(); if (!session || !session.world.isAlive(run.caster)) return; this._hitRun = run; session.world.view(HealthComp).forEach(this._hitOne); this._hitRun = null; } /** * 单体命中。 * @param id 实体 * @param hp 生命 */ private readonly _hitOne = (id: EntityId, hp: HealthComp): void => { const run = this._hitRun; const session = SkillPresentationEnv.getBattleSession(); if (!run || !session) return; if (id === run.caster) return; if (this._alreadyHit(run, id)) return; if (!session.world.isAlive(id) || !(hp.hp > 0)) return; const move = session.world.getComponent(id, MovementComp); if (move?.spawnProtected || isYanSoulClaimed(move) || isBajiaoBlowRetreating(move)) return; const casterTeam = session.world.getComponent(run.caster, TeamComp); if (!BattleTeamId.isHostile(casterTeam, session.world.getComponent(id, TeamComp))) return; const wp = SkillPresentationEnv.getTargetWorldPos(id); if (!wp) return; const dx = wp.x - run.lastX; const dy = wp.y - run.lastY; if (dx * dx + dy * dy > HIT_RADIUS * HIT_RADIUS) return; run.hitIds.push(id); run.ctx.dealDamage(id, { scale: run.scale, damageType: DMG_TYPE }); }; /** * 是否已命中。 * @param run 运行态 * @param id 实体 */ private _alreadyHit(run: EarthChargeRun, id: EntityId): boolean { const ids = run.hitIds; for (let i = 0; i < ids.length; i++) { if (ids[i] === id) return true; } return false; } /** * 回收冲锋体。 * @param run 运行态 */ private _finish(run: EarthChargeRun): void { if (!run.node?.isValid) return; const list = run.node.getComponentsInChildren(sp.Skeleton); for (let i = 0; i < list.length; i++) { BattleSpinePlayback.clearPendingComplete(list[i]); } run.node.angle = 0; run.node.setScale(1, 1, 1); gg.res.putNode(run.node); } /** * 施法者落点格(Transform / 地图格,禁 session.grid.worldToCell——世界坐标原点与逻辑格不一致)。 * @param session 会话 * @param map 地图 * @param caster 施法者 */ private _casterCell(session: BattleSession, map: MapManager, caster: EntityId): Cell | null { const tf = session.world.getComponent(caster, TransformComp); if (tf && session.grid.inBounds({ row: tf.row, col: tf.col })) { return { row: tf.row, col: tf.col }; } const center = SkillPresentationEnv.getCasterGridCenter(caster); if (!center) return null; const mg = map.getMapGridByWorldPos(center); if (!mg) return null; return { row: mg.Row, col: mg.Col }; } /** * 攻我方进路上离枪格最近的格;同距优先枪格正下方(更大 row)。 * @param session 会话 * @param caster 施法者 * @param from 施法者格 */ private _snapApproachCell(session: BattleSession, caster: EntityId, from: Cell): { Row: number; Col: number } | null { const enemyPort = this._enemyPort(session, caster); const spawn = session.topology.getSpawnCell(enemyPort); if (!spawn) { console.log('[EarthCharge] 敌方出怪口缺失', enemyPort); return null; } const forward = session.pathfinder.getApproachPath(spawn, enemyPort); if (forward.length < 1) return null; // 枪格正下方若在进路上,优先落该格 const belowRow = from.row + 1; for (let i = 0; i < forward.length; i++) { if (forward[i].row === belowRow && forward[i].col === from.col) { return { Row: forward[i].row, Col: forward[i].col }; } } let best = 0; let bestD = Number.POSITIVE_INFINITY; let bestRow = Number.NEGATIVE_INFINITY; for (let i = 0; i < forward.length; i++) { const d = Math.abs(forward[i].row - from.row) + Math.abs(forward[i].col - from.col); if (d < bestD || (d === bestD && forward[i].row > bestRow)) { bestD = d; bestRow = forward[i].row; best = i; } } return { Row: forward[best].row, Col: forward[best].col }; } /** * 攻向施法者阵营的出怪口。 * @param session 会话 * @param caster 施法者 */ private _enemyPort(session: BattleSession, caster: EntityId): number { const team = session.world.getComponent(caster, TeamComp); const owner = team?.ownerId ?? team?.team ?? 0; return owner === 0 ? 1 : 2; } /** * 从吸附格沿进路退向敌方出怪口。 * @param session 会话 * @param caster 施法者 * @param snap 吸附格 * @param params 配表 */ private _buildRetreatWorlds(session: BattleSession, caster: EntityId, snap: { Row: number; Col: number }, params: EarthChargeParams): Vec3[] { const enemyPort = this._enemyPort(session, caster); const spawn = session.topology.getSpawnCell(enemyPort); if (!spawn) { console.log('[EarthCharge] 敌方出怪口缺失', enemyPort); return []; } const cellSize = Math.max(1, BattleMapSpec.CELL_SIZE); const maxSteps = Math.ceil(params.speed * params.duration / cellSize) + 8; const from: Cell = { row: snap.Row, col: snap.Col }; const cells = session.pathfinder.buildRetreatPath(from, maxSteps, spawn, enemyPort); const out: Vec3[] = []; for (let i = 0; i < cells.length; i++) { const w = SkillPresentationEnv.getGridWorldCenterByRowCol(cells[i].row, cells[i].col); if (w) out.push(w.clone()); } return out; } /** * 沿折线取点。 * @param points 路点 * @param dist 路程 * @param out 输出 */ private _pointAlong(points: readonly Vec3[], dist: number, out: Vec3): void { if (points.length === 0) { out.set(0, 0, 0); return; } if (points.length === 1 || dist <= 0) { out.set(points[0].x, points[0].y, points[0].z); return; } let left = dist; for (let i = 0; i < points.length - 1; i++) { const a = points[i]; const b = points[i + 1]; const dx = b.x - a.x; const dy = b.y - a.y; const seg = Math.sqrt(dx * dx + dy * dy); if (seg <= 1e-4) continue; if (left <= seg) { const t = left / seg; out.set(a.x + dx * t, a.y + dy * t, a.z); return; } left -= seg; } const last = points[points.length - 1]; out.set(last.x, last.y, last.z); } /** * 折线总长。 * @param points 路点 */ private _pathLen(points: readonly Vec3[]): number { let len = 0; for (let i = 0; i < points.length - 1; i++) { const dx = points[i + 1].x - points[i].x; const dy = points[i + 1].y - points[i].y; len += Math.sqrt(dx * dx + dy * dy); } return len; } /** * 播预制体默认循环动画。 * @param root 冲锋根 */ private _playPrefabAnim(root: Node): void { const sk = root.getComponent(sp.Skeleton) ?? root.getComponentInChildren(sp.Skeleton); if (!sk?.isValid) { console.warn('[EarthCharge] 缺 Spine'); return; } if (sk.skeletonData) ensureSpineSkeletonDataRuntime(sk.skeletonData); const typed = sk as sp.Skeleton & { defaultAnimation?: string }; const name = String(typed.defaultAnimation || sk.animation || '').trim(); if (!name) { console.warn('[EarthCharge] 无默认动画'); return; } BattleSpinePlayback.play(sk, name, { loop: true, optional: true }); } /** * 节点对准冲锋前方。 * @param run 运行态 * @param dt 逻辑秒 * @param desired 目标角 */ private _turn(run: EarthChargeRun, dt: number, desired: number | null): void { if (desired == null || !run.node.isValid) return; const delta = shortestAngleDeltaDeg(run.angle, desired); const maxStep = TURN_DEG * dt; const step = delta > maxStep ? maxStep : (delta < -maxStep ? -maxStep : delta); run.angle += step; run.node.angle = run.angle; } /** * 沿路径前瞻朝向(度,0=右)。 * @param fromX 当前 X * @param fromY 当前 Y * @param waypoints 路点 * @param traveled 已走路程 */ private _chargeAimDeg(fromX: number, fromY: number, waypoints: readonly Vec3[], traveled: number): number | null { this._from.set(fromX, fromY, 0); this._pointAlong(waypoints, traveled + LOOKAHEAD, this._aim); return computeAimAngleDeg(this._from, this._aim); } } /** Skill.skill type=1 土冲锋入口 */ export class EarthChargePresenter implements ISkillPresenter { /** * 播放土冲锋。 * @param ctx 表现上下文 * @param host Skill 宿主 */ present(ctx: SkillPresentationContext, host: Skill): void { if (!ctx.isAlive) { ctx.finish(); return; } getEarthChargeHost().start(ctx, host); } }