Files
pixelheros/assets/script/game/skill/SkillView.ts
panFD 284d124486 feat(skill): 新增召唤技能配置与错峰发射逻辑
1.  为技能prefab新增summon_count、forceStart坐标与height_offset配置项
2.  新增全局SUMMON_INTERVAL配置用于控制召唤物发射间隔
3.  重构施法系统,支持按配置召唤多个技能实体并错峰执行
4.  为多发/召唤技能添加纵向偏移档位,优化弹道分布
5.  屏蔽召唤技能的风怒额外触发逻辑,修复普攻链计入问题
2026-08-16 18:56:15 +08:00

279 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { _decorator, Animation, CCFloat, CCInteger, Collider2D, Contact2DType, Enum, UITransform, v3, Vec3 } from "cc";
import { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS";
import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp";
import { HeroViewComp } from "../hero/HeroViewComp";
import { DTType, EType, RType, SkillConfig, SkillSet } from "../common/config/SkillSet";
import { SDataCom } from "./SDataCom";
import { HeroAttrsComp } from "../hero/HeroAttrsComp";
import { DamageQueueHelper } from "../hero/DamageQueueComp";
import { mLogger } from "../common/Logger";
import { FightSet } from "../common/config/GameSet";
import { Attrs } from "../common/config/HeroAttrs";
const { ccclass, property } = _decorator;
/** 技能视图组件:负责碰撞窗口、碰撞回调与伤害派发 */
@ccclass('SkillView')
@ecs.register('SkillView', false)
export class SkillView extends CCComp {
/** 视图层逻辑代码分离演示 */
@property({ type: CCInteger })
atk_x: number = 0
@property({ type: CCInteger })
atk_y: number = 0
/** 运动类型:直线/贝塞尔/固定起点/固定终点(由 prefab 配置,优先级高于 SkillSet */
@property({ type: Enum(RType), tooltip: "技能特效运动类型" })
runType: RType = RType.linear;
/** 消亡条件:动画结束/计时结束/碰撞次数结束(由 prefab 配置,优先级高于 SkillSet */
@property({ type: Enum(EType), tooltip: "技能特效消亡条件" })
endType: EType = EType.collision;
/** 移动速度(由 prefab 配置,<=0 时回退 SkillSet.speed */
@property({ type: CCFloat, slide: true, range: [0, 2000, 10], tooltip: "技能特效移动速度" })
speed: number = 0;
/** timeEnd 持续时间(秒),仅 endType=timeEnd 时生效(由 prefab 配置,<=0 时回退 SkillSet.time */
@property({ type: CCFloat, slide: true, range: [0, 30, 0.1], tooltip: "timeEnd 持续时间(秒)" })
time: number = 0;
/** 直线飞行距离(像素),仅 runType=linear 时生效(由 prefab 配置,<=0 时保持默认飞出屏幕的行为) */
@property({ type: CCFloat, slide: true, range: [0, 2000, 10], tooltip: "直线飞行距离(仅 linear 生效)" })
distance: number = 0;
/** 强制出生点世界坐标X-1=不强制,使用 atk_x 偏移;>=0 时强制使用该值,如 0=屏幕中心) */
@property({ type: CCFloat, tooltip: "强制出生点X-1=不强制,>=0=强制世界坐标X" })
forceStartX: number = -1;
/** 强制出生点世界坐标Y-1=不强制,使用 atk_y 偏移;>=0 时强制使用该值) */
@property({ type: CCFloat, tooltip: "强制出生点Y-1=不强制,>=0=强制世界坐标Y" })
forceStartY: number = -1;
/** 召唤数量SCastSystem 单次施法实例化的技能实体个数(<=1 为单体;>1 时按召唤物语义,不再参与风怒等额外触发) */
@property({ type: CCInteger, slide: true, range: [1, 8, 1], tooltip: "单次施法召唤的技能实体个数" })
summon_count: number = 1;
/** 抛射物高度增量(像素),贝塞尔弹道时叠加到 bezierMidHeight控制抛物线顶点抬高 */
@property({ type: CCFloat, slide: true, range: [0, 500, 5], tooltip: "抛射物高度增量(叠加到贝塞尔弹道中点高度)" })
height_offset: number = 0;
private debugMode: boolean = false;
anim: Animation = null;
group: number = 0;
SConf: SkillConfig = null;
sData: SDataCom = null;
s_uuid: number = 1001
private collider: Collider2D = null; // 缓存碰撞体引用
private pendingDisableCollider: boolean = false;
private isDisposing: boolean = false;
// 生命周期保护标记,避免在销毁阶段重复处理碰撞
init() {
this.SConf = SkillSet[this.s_uuid]
this.sData = this.ent.get(SDataCom)
this.anim = this.node.getComponent(Animation)
this.node.active = true;
this.pendingDisableCollider = false;
this.isDisposing = false;
this.collider = this.getComponent(Collider2D);
if (this.collider) {
this.collider.group = this.group;
this.collider.off(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this);
this.collider.on(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this);
this.collider.enabled = this.endType === EType.collision;
}
if (this.node.getComponent(Animation)) {
let anim = this.node.getComponent(Animation);
mLogger.log(this.debugMode, 'SkillView', "[SkillCom]:has anim", anim)
anim.off(Animation.EventType.FINISHED, this.onAnimationFinished, this);
anim.on(Animation.EventType.FINISHED, this.onAnimationFinished, this);
// 对象池复用时,需要手动播放默认动画(因为 Play On Load 只在首次生效)
if (anim.defaultClip) {
anim.play(anim.defaultClip.name);
}
}
}
onBeginContact(seCol: Collider2D, oCol: Collider2D) {
if (!this.sData || !this.SConf) {
mLogger.warn(this.debugMode, 'SkillView', '[SkillView] onBeginContact 缺少 sData 或 SConf忽略此次碰撞');
return;
}
if (this.isDisposing) return;
if (!this.node || !this.node.activeInHierarchy) return;
const targetView = oCol.getComponent(HeroViewComp);
// 不是 HeroViewComp直接忽略
if (!targetView) return;
// 🔥 方案A防御性检查 - 在获取model前强制检查ent是否存在
if (!targetView.ent) {
mLogger.warn(this.debugMode, 'SkillView', '[SkillView] onBeginContact targetView.ent为空实体已销毁忽略此次碰撞');
return;
}
if (this.debugMode) {
const casterEid = this.sData.casterEid;
const targetName = targetView?.ent?.get(HeroAttrsComp)?.hero_name ?? '非英雄对象';
const targetEid = targetView?.ent?.eid ?? '未知EID';
mLogger.log(this.debugMode, 'SkillView', `[skillView] 碰撞1 [${this.sData.group}][eid:${casterEid}]的[${seCol.group}]:[${this.SConf.name}][${this.ent.eid}]碰撞了 [${oCol.group}]:[${targetName}][${targetEid}]`);
}
if (oCol.group === seCol.group) return;
if (this.pendingDisableCollider) return;
if (this.sData.hit_count >= this.sData.max_hit_count) {
this.handle_collision_limit();
return;
}
this.sData.hit_count++;
if (this.sData.hit_count >= this.sData.max_hit_count) {
// == 新增:穿透概率判定 ==
// 仅对单体伤害技能生效DTType.single且存在穿透概率
if (this.SConf.DTType === DTType.single) {
const punctureChance = this.sData.Attrs[Attrs.puncture_chance] || 0;
if (punctureChance > 0) {
const rand = Math.random() * 100;
if (rand < punctureChance) {
// 触发穿透:不销毁,增加 max_hit_count 允许继续穿透
this.sData.max_hit_count++;
// 概率衰减(减去固定的 PUNCTURE_DOWN 值,比如 50
this.sData.Attrs[Attrs.puncture_chance] = Math.max(0, punctureChance - FightSet.PUNCTURE_DOWN);
if (this.debugMode) {
mLogger.log(this.debugMode, 'SkillView', `[SkillView] 触发穿透!剩余概率: ${this.sData.Attrs[Attrs.puncture_chance]}%`);
}
} else {
// 未触发穿透,正常销毁
this.handle_collision_limit();
}
} else {
this.handle_collision_limit();
}
} else {
this.handle_collision_limit();
}
}
// 命中次数按碰撞事件统计:不依赖是否最终造成伤害
let model = targetView.ent.get(HeroAttrsComp);
if (this.debugMode) {
mLogger.log(this.debugMode, 'SkillView', `[skillView] 碰撞3`, oCol.group, seCol.group, model);
}
if (!model) return;
if (model.is_dead) return;
if (this.sData.fac == model.fac) return;
// 检查是否已经命中过这个目标(日志安全输出)
this.apply_damage(targetView, this.sData.hit_count)
}
onAnimationFinished() {
// animationEnd 类型:动画结束后再关闭碰撞并销毁
if (this.endType == EType.animationEnd) {
this.disable_collider_now();
this.ent.destroy()
return;
}
// fixed 类型不移动,无法通过移动完成销毁;动画播完作为兜底销毁时机
if (this.runType == RType.fixed) {
this.disable_collider_now();
this.ent.destroy()
}
}
// 动画帧事件 atk仅负责开启一帧碰撞窗口不负责伤害与计数
public atk(args: any) {
if (!this.SConf) return;
if (this.endType == EType.collision) return
if (this.enable_collider_safely()) {
mLogger.log(this.debugMode, 'SkillView', `[SkillView] [${this.SConf?.name}] 开启碰撞检测`);
this.scheduleOnce(() => {
if (!this.node || !this.node.isValid || this.isDisposing) return;
this.close_collider();
mLogger.log(this.debugMode, 'SkillView', `[SkillView] [${this.SConf?.name}] 关闭碰撞检测`);
}, 0);
}
}
/**
* 仅负责伤害派发,不处理命中次数与实体销毁
* @param target 目标英雄视图
* @param punctureIndex 当前是第几个被命中的目标1=首目标;>=2 视为穿透目标,单体技能按 FightSet.PUNCTURE_DMG_RATIO 折算伤害,范围技能不衰减)
*/
apply_damage(target: HeroViewComp, punctureIndex: number = 1) {
if (target == null) return;
// 安全检查:如果目标实体已不存在,直接返回
if (!target.ent) return;
if (!this.sData) return;
if (!this.SConf) return;
if (this.debugMode) {
const targetName = target.ent.get(HeroAttrsComp)?.hero_name ?? "未知目标";
mLogger.log(this.debugMode, 'SkillView', `[skillView] 伤害 [${this.group}][eid:${this.sData.casterEid}]的 [${this.SConf.name}]对 [${target.box_group}][${targetName}][${target.ent.eid}] 第${punctureIndex}目标`);
}
// 穿透伤害衰减仅单体伤害技能DTType.single且命中第2个及以后目标时按比例折算
// 乘法模型:最终穿透系数 = PUNCTURE_DMG_RATIO * (1 + puncture_dmg_bonus/100)
// 注puncture_dmg_bonus 单位为百分点30 = +30%),与项目其他概率/加成属性保持一致
const punctureDmgBonus = this.sData.Attrs[Attrs.puncture_dmg_bonus] || 0;
const decayRatio = (this.SConf.DTType === DTType.single && punctureIndex >= 2)
? FightSet.PUNCTURE_DMG_RATIO * (1 + punctureDmgBonus / 100)
: 1;
const finalRatio = this.sData.dmg_ratio * decayRatio;
// 使用伤害队列系统处理伤害
DamageQueueHelper.addDamageToEntity(
target.ent,
this.sData.Attrs,
this.sData.casterEid,
this.sData.s_uuid,
this.sData.ext_dmg,
finalRatio,
);
}
close_collider() {
if (!this.collider) return;
if (this.pendingDisableCollider && !this.collider.enabled) return;
this.pendingDisableCollider = true;
if (this.collider.isValid) {
this.collider.enabled = false;
}
this.pendingDisableCollider = false;
}
private disable_collider_now() {
this.isDisposing = true;
this.close_collider();
}
// 碰撞上限收口先关碰撞collision 类型再延迟销毁实体
private handle_collision_limit() {
this.close_collider();
if (this.SConf?.EType !== EType.collision) return;
if (this.isDisposing) return;
this.isDisposing = true;
this.scheduleOnce(() => {
if (this.ent) {
this.ent.destroy();
}
}, 0);
}
private enable_collider_safely(): boolean {
if (!this.collider || !this.collider.isValid) return false;
if (this.isDisposing) return false;
if (!this.node || !this.node.isValid || !this.node.activeInHierarchy) return false;
this.pendingDisableCollider = false;
this.collider.group = this.group;
this.collider.off(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this);
this.collider.on(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this);
this.collider.enabled = true;
return true;
}
/** 视图对象通过 ecs.Entity.remove(ModuleViewComp) 删除组件是触发组件处理自定义释放逻辑 */
reset() {
// 清理碰撞体事件监听
if (this.collider && this.collider.isValid) {
this.collider.off(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this);
this.collider.enabled = false;
}
this.pendingDisableCollider = false;
this.isDisposing = false;
if (this.anim && this.anim.isValid) {
this.anim.off(Animation.EventType.FINISHED, this.onAnimationFinished, this);
}
// 取消所有定时器
this.unscheduleAllCallbacks();
if (this.node && this.node.isValid) {
this.node.active = false;
}
}
}