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.
 
 
 
 
AdLike2048/assets/script/battle/presentation/ThrownCircleAtkPresenter.ts

656 lines
23 KiB

import { isValid, Node, UITransform, Vec3, sp } from 'cc';
import type { EntityId } from 'db://assets/mx/module/ecs';
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 { logicalWorldToCocos } from '../bridge/CocosBattleMovementBridge';
import type { Skill } from '../skill/Skill';
import { SkillPresentationEnv } from '../skill/SkillPresentationEnv';
import type { SkillPresentationContext } from '../skill/SkillPresentationTypes';
import { SkillTransformDriver } from '../skill/SkillTransformDriver';
import { BattleSpinePlayback } from './BattleSpinePlayback';
import { buildShadowDealContext } from './SkillShadowContext';
const DMG_TYPE = 'thrown_circle';
const FLY_SPEED = 576;
/** battleHero/prefab/skill/飞棍 */
const FLY_STICK_PREFAB = '飞棍';
/** 飞棍 Spine 循环动画 */
const FLY_STICK_ANIM = '原地旋转大风车';
/**
* 飞棍预制体 UITransform 边长 350:scale=1 时视觉半径 175。
* 落地缩放 = atk_para 半径 / 该值,使圈伤范围与表现对齐。
*/
const FLY_STICK_DESIGN_RADIUS = 175;
/**
* 以落点为圆心扫描当前敌对单位并回调(每段重新索敌,不锁施法瞬间名单)。
* @param session 战斗会话
* @param caster 施法者
* @param x 圆心逻辑 X
* @param y 圆心逻辑 Y
* @param radius 半径像素
* @param onHit 命中回调
*/
export function pulseHostileCircle(
session: NonNullable<ReturnType<typeof SkillPresentationEnv.getBattleSession>>,
caster: EntityId,
x: number,
y: number,
radius: number,
onHit: (id: EntityId) => void,
): void {
if (!(radius > 0)) return;
const casterTeam = session.world.getComponent(caster, TeamComp);
const cell = session.grid.worldToCell(x, y);
const radiusCells = radius / Math.max(1, session.grid.cellSize);
const ids = session.spatial.queryRadius(cell, radiusCells + 1);
const r2 = radius * radius;
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
if (id === caster) continue;
if (!session.world.isAlive(id)) continue;
const hp = session.world.getComponent(id, HealthComp);
if (!hp || !(hp.hp > 0)) continue;
const move = session.world.getComponent(id, MovementComp);
if (move?.spawnProtected || isYanSoulClaimed(move) || isBajiaoBlowRetreating(move)) continue;
if (!BattleTeamId.isHostile(casterTeam, session.world.getComponent(id, TeamComp))) continue;
const pos = logicPosOf(session, id);
if (!pos) continue;
const dx = pos.x - x;
const dy = pos.y - y;
if (dx * dx + dy * dy > r2) continue;
onHit(id);
}
}
/**
* 实体逻辑坐标。
* @param session 战斗会话
* @param entity 实体
*/
export function logicPosOf(
session: NonNullable<ReturnType<typeof SkillPresentationEnv.getBattleSession>>,
entity: EntityId,
): { x: number; y: number } | null {
const move = session.world.getComponent(entity, MovementComp);
if (move && (move.worldX !== 0 || move.worldY !== 0)) {
return { x: move.worldX, y: move.worldY };
}
const tf = session.world.getComponent(entity, TransformComp);
if (!tf) return null;
return session.grid.cellToWorld({ row: tf.row, col: tf.col });
}
/** atk_type=18 独立圈伤任务 */
interface ThrownCircleRun {
/** 施法者 */
caster: EntityId;
/** 影子伤害上下文 */
ctx: SkillPresentationContext;
/** 圆心逻辑 X */
x: number;
/** 圆心逻辑 Y */
y: number;
/** 半径像素 */
radius: number;
/** 伤害倍率 */
scale: number;
/** 总段数 */
hitCount: number;
/** 段间隔秒 */
intervalSec: number;
/** 已结算段数 */
done: number;
/** 距下一段剩余秒 */
wait: number;
/** 飞出的飞棍节点 */
flyNode: Node | null;
}
/** 飞出结果 */
interface ThrowFlyResult {
/** 飞棍节点 */
node: Node;
/** 飞行秒数(落地后才开始段伤) */
duration: number;
}
let _host: ThrownCircleHost | null = null;
/** atk_type=18 丢出独立圈伤宿主 */
export function getThrownCircleHost(): ThrownCircleHost {
if (!_host) _host = new ThrownCircleHost();
return _host;
}
/**
* atk_type=18:瞬间释放施法锁后,丢出飞棍到主目标落点,按段数/间隔独立圈伤。
*/
export class ThrownCircleHost {
private readonly _runs: ThrownCircleRun[] = [];
/**
* 开战:锁定圆心并启动独立段伤。
* @param ctx 表现上下文
* @param host Skill 宿主
*/
start(ctx: SkillPresentationContext, host: Skill): void {
const hitCount = Math.max(0, Math.floor(Number(ctx.params.hitCount) || 0));
const intervalSec = Math.max(0, Number(ctx.params.hitIntervalSec) || 0);
const radius = Math.max(0, Number(ctx.params.aroundPrimaryRadius) || 0);
const wan = Number(ctx.params.damageWan);
if (!(hitCount > 0) || !(radius > 0) || !Number.isFinite(wan)) {
console.log('[ThrownCircle] atk_para 无效', ctx.params.skillId, ctx.params.atkPara);
return;
}
const session = SkillPresentationEnv.getBattleSession();
if (!session) {
console.log('[ThrownCircle] 无战斗会话', ctx.params.skillId);
return;
}
const primary = ctx.targets[0] ?? 0;
const lockId = primary > 0 ? primary : ctx.caster;
const center = this._logicPos(session, lockId) ?? this._logicPos(session, ctx.caster);
if (!center) {
console.log('[ThrownCircle] 无落点', ctx.params.skillId);
return;
}
const scale = wan / 10000;
const shadow = buildShadowDealContext(
ctx,
String(ctx.params.buff1 ?? ''),
String(ctx.params.buff2 ?? ''),
);
const fly = this._throwFlyStick(host, ctx.caster, lockId, center, radius);
this._runs.push({
caster: ctx.caster,
ctx: shadow,
x: center.x,
y: center.y,
radius,
scale,
hitCount,
intervalSec,
done: 0,
wait: fly?.duration ?? 0,
flyNode: fly?.node ?? null,
});
}
/**
* 推进独立段伤。
* @param dt 逻辑秒
*/
tick(dt: number): void {
if (!(dt > 0) || this._runs.length <= 0) return;
const session = SkillPresentationEnv.getBattleSession();
for (let i = this._runs.length - 1; i >= 0; i--) {
const run = this._runs[i];
if (!session || !session.world.isAlive(run.caster) || !run.ctx.isAlive) {
this._drop(run);
this._runs.splice(i, 1);
continue;
}
run.wait -= dt;
while (run.done < run.hitCount && run.wait <= 0) {
this._pulse(session, run);
run.done += 1;
run.wait += run.intervalSec;
}
if (run.done >= run.hitCount) {
this._drop(run);
this._runs.splice(i, 1);
}
}
}
/** 战斗结束清空 */
abortAll(): void {
for (let i = 0; i < this._runs.length; i++) this._drop(this._runs[i]);
this._runs.length = 0;
}
private _pulse(session: NonNullable<ReturnType<typeof SkillPresentationEnv.getBattleSession>>, run: ThrownCircleRun): void {
this._activePulse = run;
pulseHostileCircle(session, run.caster, run.x, run.y, run.radius, this._onPulseHit);
this._activePulse = null;
}
private _activePulse: ThrownCircleRun | null = null;
private readonly _onPulseHit = (id: EntityId): void => {
const run = this._activePulse;
if (!run) return;
run.ctx.dealDamage(id, { scale: run.scale, damageType: DMG_TYPE });
};
/**
* 实例化飞棍飞向锁定怪落点,缩放对齐圈伤半径。
* @param host Skill 宿主
* @param caster 施法者
* @param lockId 锁定目标实体(主目标优先)
* @param center 圈伤逻辑坐标
* @param radius 圈伤半径像素
*/
private _throwFlyStick(host: Skill, caster: EntityId, lockId: EntityId, center: { x: number; y: number }, radius: number): ThrowFlyResult | null {
const flyLayer = SkillPresentationEnv.flyLayer;
if (!flyLayer?.isValid) {
console.log('[ThrownCircle] 无飞行层');
return null;
}
let node: Node | null = null;
try {
node = gg.res.getNode(GPath.HeroSkillPrefab(), FLY_STICK_PREFAB, GBundle.BattleHero);
} catch (e) {
console.log('[ThrownCircle] 飞棍加载异常', String(e));
return null;
}
if (!node?.isValid) {
console.log('[ThrownCircle] 飞棍预制体未加载', FLY_STICK_PREFAB);
return null;
}
const ut = flyLayer.getComponent(UITransform);
if (!ut) {
gg.res.putNode(node);
return null;
}
const startWorld = this._resolveThrowStartWorld(host, caster);
if (!startWorld) {
console.log('[ThrownCircle] 无飞出起点', caster);
gg.res.putNode(node);
return null;
}
const targetWorld = this._resolveThrowTargetWorld(lockId, center, startWorld.z);
if (!targetWorld) {
console.log('[ThrownCircle] 无飞向落点', lockId);
gg.res.putNode(node);
return null;
}
const visualScale = Math.max(0.05, radius / FLY_STICK_DESIGN_RADIUS);
flyLayer.active = true;
node.parent = flyLayer;
node.active = true;
node.layer = flyLayer.layer;
node.setScale(visualScale, visualScale, 1);
const startLocal = ut.convertToNodeSpaceAR(startWorld);
const targetLocal = ut.convertToNodeSpaceAR(targetWorld);
node.setPosition(startLocal);
node.setSiblingIndex(flyLayer.children.length - 1);
this._playFlyStickAnim(node);
const dist = Vec3.distance(startWorld, targetWorld);
const duration = Math.max(0.06, dist / FLY_SPEED);
SkillTransformDriver.animate({
node,
duration,
fromPos: startLocal,
toPos: targetLocal,
easing: SkillTransformDriver.easeSineInOut,
});
return { node, duration };
}
/**
* 飞出起点:优先武器挂点,否则施法者格心。
* @param host Skill 宿主
* @param caster 施法者
*/
private _resolveThrowStartWorld(host: Skill, caster: EntityId): Vec3 | null {
host.visualChannel.ensureWeapon();
const weaponRoot = host.visualChannel.weapon?.root;
if (weaponRoot?.isValid) return weaponRoot.worldPosition.clone();
const grid = SkillPresentationEnv.getCasterGridCenter(caster);
return grid ? new Vec3(grid.x, grid.y, grid.z) : null;
}
/**
* 飞向落点:锁定怪 Cocos 世界坐标(禁止 logicX/-logicY 绝对取反)。
* @param lockId 锁定目标
* @param center 圈伤逻辑坐标(无怪节点时回退)
* @param z 与起点同 Z
*/
private _resolveThrowTargetWorld(lockId: EntityId, center: { x: number; y: number }, z: number): Vec3 | null {
const fromMonster = SkillPresentationEnv.getTargetWorldPos(lockId);
if (fromMonster) {
fromMonster.z = z;
return fromMonster;
}
const session = SkillPresentationEnv.getBattleSession();
const map = SkillPresentationEnv.getMap();
if (!session || !map) return null;
const tf = session.world.getComponent(lockId, TransformComp);
if (!tf) return null;
const cocos = logicalWorldToCocos(session, map, tf.row, tf.col, center.x, center.y);
cocos.z = z;
return cocos;
}
/**
* 播飞棍旋转动画。
* @param node 飞棍根
*/
private _playFlyStickAnim(node: Node): void {
const sk = node.getChildByName('anim')?.getComponent(sp.Skeleton)
?? node.getComponentInChildren(sp.Skeleton);
if (!sk?.isValid) {
console.log('[ThrownCircle] 飞棍缺 Spine', FLY_STICK_PREFAB);
return;
}
sk.enabled = true;
sk.paused = false;
if (!BattleSpinePlayback.play(sk, FLY_STICK_ANIM, { loop: true })) {
console.log('[ThrownCircle] 飞棍无动画', FLY_STICK_ANIM);
}
}
private _drop(run: ThrownCircleRun): void {
const node = run.flyNode;
run.flyNode = null;
if (!node?.isValid) return;
SkillTransformDriver.cancelNode(node);
const sk = node.getChildByName('anim')?.getComponent(sp.Skeleton)
?? node.getComponentInChildren(sp.Skeleton);
BattleSpinePlayback.clearPendingComplete(sk);
BattleSpinePlayback.resetTracks(sk);
if (node['resKey']) gg.res.putNode(node);
else if (isValid(node)) node.destroy();
const flyLayer = SkillPresentationEnv.flyLayer;
if (flyLayer?.isValid && flyLayer.children.length === 0) flyLayer.active = false;
}
private _logicPos(session: NonNullable<ReturnType<typeof SkillPresentationEnv.getBattleSession>>, entity: EntityId): { x: number; y: number } | null {
return logicPosOf(session, entity);
}
}
const GROUND_DMG_TYPE = 'ground_circle';
/** battleHero/prefab/skill/暴风雪 */
const BLIZZARD_PREFAB = '暴风雪';
/**
* 暴风雪预制体 UITransform 边长 400:scale=1 时视觉半径 200。
* 落地缩放 = atk_para 半径 / 该值。
*/
const BLIZZARD_DESIGN_RADIUS = 200;
/** atk_type=20 地面圈伤任务 */
interface GroundCircleRun {
/** 施法者 */
caster: EntityId;
/** 影子伤害上下文 */
ctx: SkillPresentationContext;
/** Skill 宿主(占锁收招) */
host: Skill | null;
/** 播控序号 */
seq: number;
/** 圆心逻辑 X */
x: number;
/** 圆心逻辑 Y */
y: number;
/** 半径像素 */
radius: number;
/** 伤害倍率 */
scale: number;
/** 总段数 */
hitCount: number;
/** 段间隔秒 */
intervalSec: number;
/** 已结算段数 */
done: number;
/** 距下一段剩余秒 */
wait: number;
/** 暴风雪节点 */
fxNode: Node | null;
}
let _groundHost: GroundCircleHost | null = null;
/** atk_type=20 目标点地面圈伤宿主 */
export function getGroundCircleHost(): GroundCircleHost {
if (!_groundHost) _groundHost = new GroundCircleHost();
return _groundHost;
}
/**
* atk_type=20:以主目标为圆心放置暴风雪(预制体默认循环),按段独立圈伤;占锁至结束。
*/
export class GroundCircleHost {
private readonly _runs: GroundCircleRun[] = [];
private _activePulse: GroundCircleRun | null = null;
/**
* 开战:锁主目标圆心并放置暴风雪。
* @param ctx 表现上下文
* @param host Skill 宿主
* @param seq 播控序号
*/
start(ctx: SkillPresentationContext, host: Skill, seq: number): void {
const hitCount = Math.max(0, Math.floor(Number(ctx.params.hitCount) || 0));
const intervalSec = Math.max(0, Number(ctx.params.hitIntervalSec) || 0);
const radius = Math.max(0, Number(ctx.params.aroundPrimaryRadius) || 0);
const wan = Number(ctx.params.damageWan);
if (!(hitCount > 0) || !(radius > 0) || !Number.isFinite(wan)) {
console.log('[GroundCircle] atk_para 无效', ctx.params.skillId, ctx.params.atkPara);
this._finishCast(host, seq);
return;
}
const session = SkillPresentationEnv.getBattleSession();
if (!session) {
console.log('[GroundCircle] 无战斗会话', ctx.params.skillId);
this._finishCast(host, seq);
return;
}
const primary = ctx.targets[0] ?? 0;
if (!(primary > 0)) {
console.log('[GroundCircle] 无索敌目标', ctx.params.skillId);
this._finishCast(host, seq);
return;
}
const center = logicPosOf(session, primary);
if (!center) {
console.log('[GroundCircle] 无落点', ctx.params.skillId, primary);
this._finishCast(host, seq);
return;
}
const fxNode = this._placeBlizzard(primary, center, radius);
this._runs.push({
caster: ctx.caster,
ctx: buildShadowDealContext(ctx, String(ctx.params.buff1 ?? ''), String(ctx.params.buff2 ?? '')),
host,
seq,
x: center.x,
y: center.y,
radius,
scale: wan / 10000,
hitCount,
intervalSec,
done: 0,
wait: 0,
fxNode,
});
}
/**
* 推进独立段伤。
* @param dt 逻辑秒
*/
tick(dt: number): void {
if (!(dt > 0) || this._runs.length <= 0) return;
const session = SkillPresentationEnv.getBattleSession();
for (let i = this._runs.length - 1; i >= 0; i--) {
const run = this._runs[i];
if (!session || !session.world.isAlive(run.caster) || !run.ctx.isAlive) {
this._endRun(run);
this._runs.splice(i, 1);
continue;
}
if (run.host && !run.host.isPresentationCast(run.seq)) {
this._drop(run);
this._runs.splice(i, 1);
continue;
}
run.wait -= dt;
while (run.done < run.hitCount && run.wait <= 0) {
this._pulse(session, run);
run.done += 1;
run.wait += run.intervalSec;
}
if (run.done >= run.hitCount) {
this._endRun(run);
this._runs.splice(i, 1);
}
}
}
/**
* 打断指定施法者的暴风雪。
* @param caster 施法实体
*/
abortCaster(caster: EntityId): void {
for (let i = this._runs.length - 1; i >= 0; i--) {
if (this._runs[i].caster !== caster) continue;
this._drop(this._runs[i]);
this._runs.splice(i, 1);
}
}
/** 战斗结束清空 */
abortAll(): void {
for (let i = 0; i < this._runs.length; i++) this._drop(this._runs[i]);
this._runs.length = 0;
}
/**
* 收招并结束播控。
* @param host Skill 宿主
* @param seq 播控序号
*/
private _finishCast(host: Skill | null, seq: number): void {
if (!host?.isValid) return;
host.visualChannel.endHoldCast();
if (host.isPresentationCast(seq)) host.finishPresentationCast(seq, false);
}
private _endRun(run: GroundCircleRun): void {
const host = run.host;
const seq = run.seq;
run.host = null;
this._drop(run);
this._finishCast(host, seq);
}
private _pulse(session: NonNullable<ReturnType<typeof SkillPresentationEnv.getBattleSession>>, run: GroundCircleRun): void {
this._activePulse = run;
pulseHostileCircle(session, run.caster, run.x, run.y, run.radius, this._onPulseHit);
this._activePulse = null;
}
private readonly _onPulseHit = (id: EntityId): void => {
const run = this._activePulse;
if (!run) return;
run.ctx.dealDamage(id, { scale: run.scale, damageType: GROUND_DMG_TYPE });
};
/**
* 在锁定怪位置放置暴风雪,缩放对齐圈伤半径;不强制播动画名,只恢复预制体默认循环。
* @param lockId 锁定目标
* @param center 圈伤逻辑坐标
* @param radius 圈伤半径像素
*/
private _placeBlizzard(lockId: EntityId, center: { x: number; y: number }, radius: number): Node | null {
const flyLayer = SkillPresentationEnv.flyLayer;
if (!flyLayer?.isValid) {
console.log('[GroundCircle] 无飞行层');
return null;
}
let node: Node | null = null;
try {
node = gg.res.getNode(GPath.HeroSkillPrefab(), BLIZZARD_PREFAB, GBundle.BattleHero);
} catch (e) {
console.log('[GroundCircle] 暴风雪加载异常', String(e));
return null;
}
if (!node?.isValid) {
console.log('[GroundCircle] 暴风雪预制体未加载', BLIZZARD_PREFAB);
return null;
}
const ut = flyLayer.getComponent(UITransform);
if (!ut) {
gg.res.putNode(node);
return null;
}
const world = this._resolvePlaceWorld(lockId, center);
if (!world) {
console.log('[GroundCircle] 无放置世界坐标', lockId);
gg.res.putNode(node);
return null;
}
const visualScale = Math.max(0.05, radius / BLIZZARD_DESIGN_RADIUS);
flyLayer.active = true;
node.parent = flyLayer;
node.active = true;
node.layer = flyLayer.layer;
node.setScale(visualScale, visualScale, 1);
node.setPosition(ut.convertToNodeSpaceAR(world));
node.setSiblingIndex(flyLayer.children.length - 1);
this._resumePrefabDefaultLoop(node);
return node;
}
/**
* 放置世界坐标:优先怪节点,否则逻辑坐标映射。
* @param lockId 锁定目标
* @param center 逻辑圆心
*/
private _resolvePlaceWorld(lockId: EntityId, center: { x: number; y: number }): Vec3 | null {
const fromMonster = SkillPresentationEnv.getTargetWorldPos(lockId);
if (fromMonster) return fromMonster;
const session = SkillPresentationEnv.getBattleSession();
const map = SkillPresentationEnv.getMap();
if (!session || !map) return null;
const tf = session.world.getComponent(lockId, TransformComp);
if (!tf) return null;
return logicalWorldToCocos(session, map, tf.row, tf.col, center.x, center.y);
}
/**
* 出池后恢复 Spine:只播预制体 defaultAnimation(循环),不硬编码技能动画名。
* @param root 暴风雪根
*/
private _resumePrefabDefaultLoop(root: Node): void {
const skins = root.getComponentsInChildren(sp.Skeleton);
for (let i = 0; i < skins.length; i++) {
const sk = skins[i];
if (!sk?.isValid) continue;
sk.enabled = true;
sk.paused = false;
const typed = sk as sp.Skeleton & { defaultAnimation?: string };
const name = String(typed.defaultAnimation || sk.animation || '').trim();
if (!name) continue;
if (!BattleSpinePlayback.play(sk, name, { loop: true })) {
console.log('[GroundCircle] 暴风雪默认动画失败', name);
}
}
}
private _drop(run: GroundCircleRun): void {
const node = run.fxNode;
run.fxNode = null;
if (!node?.isValid) return;
const skins = node.getComponentsInChildren(sp.Skeleton);
for (let i = 0; i < skins.length; i++) {
BattleSpinePlayback.clearPendingComplete(skins[i]);
BattleSpinePlayback.resetTracks(skins[i]);
}
if (node['resKey']) gg.res.putNode(node);
else if (isValid(node)) node.destroy();
const flyLayer = SkillPresentationEnv.flyLayer;
if (flyLayer?.isValid && flyLayer.children.length === 0) flyLayer.active = false;
}
}