Files
pixelheros/assets/script/game/hero/SCastSystem.ts
pan f92502f4f5 feat(skill): 新增召唤目标选择模式与技能优化
1. 新增SummonTMode枚举,支持同一敌人/随机敌人/去重三种召唤目标模式
2. 为多个技能prefab新增召唤参数配置字段
3. 重构索敌逻辑,支持按配置模式选择召唤目标
4. 优化技能伤害与动画配置,新增maxm7技能与动画资源
5. 修复部分技能的碰撞次数与偏移参数
2026-09-22 15:56:27 +08:00

986 lines
50 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 { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS";
import { Vec3, Prefab, instantiate, tween, Node, UITransform } from "cc";
import { HeroAttrsComp } from "./HeroAttrsComp";
import { HeroViewComp } from "./HeroViewComp";
import { DTType, RType, SkillConfig, SkillKind, SkillSet, SummonTMode, TGroup, SkillOverrides, mergeSkillParams } from "../common/config/SkillSet";
import { Skill } from "../skill/Skill";
import { SkillView } from "../skill/SkillView";
import { smc } from "../common/SingletonModuleComp";
import { HeroDisVal, HeroInfo, HType } from "../common/config/heroSet";
import { Attrs } from "../common/config/HeroAttrs";
import { BoxSet, FacSet, FightSet } from "../common/config/GameSet";
import { oops } from "db://oops-framework/core/Oops";
import { GameEvent } from "../common/config/GameEvent";
import { SkillTriggerType } from "../common/config/heroSet";
import { SkillTriggerHelper } from "./SkillTriggerHelper";
import { MissionEconomy } from "../map/MissionEconomy";
import { mLogger } from "../common/Logger";
import { buffManager } from "./BuffManager";
import { BuffList } from "../common/config/BuffSet";
import { CameraFX } from "../common/CameraFX";
import { ScreenShake } from "../common/ScreenShake";
/** 召唤参数:数量 / XY 步进偏移 / 生成间隔 / 目标选择模式,均由技能 prefab 上的 SkillView 配置 */
export interface SummonParams {
count: number;
x_offset: number;
y_offset: number;
interval: number;
/** 多次施放的目标选择模式(同一敌人/随机敌人/避免相同敌人) */
target_mode: SummonTMode;
}
/**
* ==================== 自动施法系统 ====================
*
* 职责:
* 1. 检测可施放的技能
* 2. 根据策略自动施法(AI)
* 3. 选择目标
* 4. 添加施法请求标记
*
* 设计理念:
* - 负责"何时施法"的决策
*/
@ecs.register('SCastSystem')
export class SCastSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate {
static instance: SCastSystem | null = null;
debugMode: boolean = true; // 是否启用调试模式
constructor() {
super();
SCastSystem.instance = this;
// 监听触发技能事件
oops.message.on(GameEvent.TriggerSkill, this.onTriggerSkill, this);
}
/** 系统被销毁或重置时,必须注销全局事件监听,避免内存泄漏与重复触发 */
onDestroy() {
oops.message.off(GameEvent.TriggerSkill, this.onTriggerSkill, this);
}
private onTriggerSkill(event: string, args: {
s_uuid: number,
heroAttrs?: HeroAttrsComp,
heroView?: HeroViewComp,
triggerType?: string,
isCardSkill?: boolean,
rarity?: number,
targetPos?: Vec3,
overrides?: any
}) {
if (!args || !args.s_uuid) return;
// 卡牌技能直接触发
if (args.isCardSkill) {
this.forceCastCardSkill(args.s_uuid, args.rarity || 1, args.targetPos || new Vec3(FightSet.CSKILL_START_X, FightSet.CSKILL_START_Y, 0), args.overrides);
return;
}
// 常规英雄技能触发
if (!args.heroAttrs || !args.heroView) return;
this.forceCastTriggerSkill(args.s_uuid, args.heroAttrs, args.heroView, args.triggerType, args.overrides);
}
/**
* 强制执行卡牌技能
* 卡牌技能没有施法者主体,直接从指定坐标释放,或者对全体/随机友方生效
*/
public forceCastCardSkill(s_uuid: number, rarity: number, spawnPos: Vec3, overrides?: SkillOverrides) {
let config = SkillSet[s_uuid];
if (!config) return;
config = mergeSkillParams(config, overrides);
// 如果是敌方目标,没有战斗时不释放
const isEnemyTarget = !this.isSelfSkill(config.TGroup) && !this.isFriendlySkill(config.TGroup);
if (isEnemyTarget && !smc.mission.in_fight) return;
let isFriendly = false;
let targetEids: number[] = [];
if (this.isFriendlySkill(config.TGroup) || this.isSelfSkill(config.TGroup)) {
isFriendly = true;
targetEids = this.collectFriendlyTargetEids(FacSet.HERO, undefined, true); // 获取所有英雄阵营的目标
}
const summon = this.resolveSummonParams(config);
const cNum = summon.count > 1 ? 0 : Math.min(2, Math.max(0, Math.floor(overrides?.num ?? 0)));
const castTimes = Math.max(summon.count, 1 + cNum);
const castInterval = this.resolveCastInterval(summon);
// 构造一个模拟的 HeroAttrsComp 用于数值计算,只包含基础卡牌伤害计算所需的属性
const mockAttrs = new HeroAttrsComp();
// 动态计算卡牌的虚拟攻击力:
// 1. 根据卡牌等级给予基础成长(同英雄升级公式,基准设为 100)
let baseAp = 100 * Math.pow(FightSet.HERO_LV_MULTIPLIER, rarity - 1);
let highestAp = baseAp;
// 2. 获取场上最高攻击力的英雄,保证后期奶量/增益绝对够用
for (const eid of smc.mission.heroGrid) {
if (eid >= 0) {
const entity = ecs.getEntityByEid(eid);
if (entity) {
const attr = entity.get(HeroAttrsComp);
if (attr && !attr.is_dead && attr.ap > highestAp) {
highestAp = attr.ap;
}
}
}
}
mockAttrs.ap = highestAp;
mockAttrs.critical = 0;
mockAttrs.freeze_chance = 0;
mockAttrs.stun_chance = 0;
mockAttrs.paralyze_chance = 0;
mockAttrs.puncture_chance = 0;
mockAttrs.fac = FacSet.HERO;
mockAttrs.type = HType.Long; // 假定为远程,拥有较长索敌范围
mockAttrs.dis = 2000; // 给予全屏以上的索敌范围
// 伪造一个 view 供找敌逻辑使用,位置为 spawnPos(random/unique 模式实时索敌也复用)
const mockView = {
node: { position: spawnPos }
} as any;
let targetPos: Vec3 | null = null;
if (!isFriendly) {
// 获取全屏索敌范围
const maxRange = this.resolveMaxCastRange(mockAttrs, mockAttrs.type as HType);
const target = this.findNearestEnemyInRange(mockAttrs, mockView, maxRange);
if (target && target.node) {
targetPos = this.resolveEnemyCastTargetPos(config, mockAttrs, mockView, target, maxRange);
}
// 如果全屏都没找到敌人,直接放弃释放伤害技能
if (!targetPos) {
console.log("[SCastSystem] forceCastCardSkill: no enemy found for skill", s_uuid);
return;
}
}
mLogger.log(this.debugMode, "SCastSystem", `[卡牌施法] 释放技能「${config.name}」(s_uuid:${s_uuid}) rarity:${rarity} castTimes:${castTimes} targetPos:${targetPos ? `(${targetPos.x.toFixed(0)},${targetPos.y.toFixed(0)})` : "none"} isFriendly:${isFriendly}`);
// unique 模式去重记录:本次施法序列内已锁定过的敌人 eid
const usedTargetEids = new Set<number>();
for (let i = 0; i < castTimes; i++) {
// 多发/召唤按间隔错峰执行:i=0 立即,其余延迟 i*castInterval(召唤间隔由 prefab 配置)
const exec = () => {
if (!smc.mission.play || smc.mission.pause) return;
if (isFriendly) {
const friendlyTargets = this.resolveFriendlyTargets(targetEids, FacSet.HERO);
mLogger.log(this.debugMode, "SCastSystem", "friendlyTargets count =", friendlyTargets.length);
if (friendlyTargets.length === 0) return;
this.applyFriendlySkillEffects(s_uuid, rarity, config, null as any, mockAttrs, friendlyTargets, spawnPos);
} else {
const enemyTargetPos = this.resolveSummonCastTargetPos(config, mockAttrs, mockView, targetPos, i, summon, usedTargetEids);
this.createSkillEntityForCard(s_uuid, rarity, mockAttrs, spawnPos, enemyTargetPos, i, overrides, summon);
}
};
if (i === 0) {
exec();
} else {
setTimeout(exec, i * castInterval * 1000);
}
}
// 施法后概率触发额外技能(proc_skill,原命中触发改为出手即判定;卡牌技能无视 CD、无施法主体)
this.tryProcSkill(config, null, null, SkillTriggerType.Atking);
}
/** 专用于卡牌施放的技能实体生成 */
private createSkillEntityForCard(s_uuid: number, skillLv: number, mockAttrs: HeroAttrsComp, startPos: Vec3, targetPos: Vec3 | null, castIndex: number = 0, overrides?: SkillOverrides, summon?: SummonParams) {
const scene = smc.map.MapView.scene;
const parent = scene.entityLayer?.node?.getChildByName("SKILL");
if (!parent || !targetPos) {
console.log("[SCastSystem] createSkillEntityForCard failed: parent or targetPos missing", !!parent, !!targetPos);
return;
}
const skill = ecs.getEntity<Skill>(Skill);
const actualStartPos = this.resolveRepeatCastStartPos(startPos, castIndex, summon);
// 伪造一个简单的 heroView 供 Skill 初始化使用,只包含方向信息
const mockView = {
node: { scale: new Vec3(1, 1, 1), position: actualStartPos },
ent: { eid: -1 },
box_group: BoxSet.HERO
} as any;
skill.load(actualStartPos, parent, s_uuid, targetPos.clone(), mockView, mockAttrs, skillLv, 0, overrides);
console.log("[SCastSystem] createSkillEntityForCard success for skill", s_uuid);
}
/** 空施法计划:用于“当前无可施法技能”时的统一返回 */
private readonly emptyCastPlan = { skillId: 0, skillLv: 1, isFriendly: false, targetPos: null as Vec3 | null, targetEids: [] as number[], overrides: undefined as SkillOverrides | undefined };
/** 查询缓存:避免每帧重复创建 matcher */
private heroMatcher: ecs.IMatcher | null = null;
/** 技能 prefab 召唤参数缓存:sp_name -> 召唤参数(数量/XY偏移/间隔) */
private summonParamsCache: Map<string, SummonParams> = new Map();
/**
* 解析单次施法的召唤参数。
* 数量优先级:合并后配置上的 summon_count(heroSet overrides 可覆写) > prefab 上 SkillView.summon_count;
* XY 偏移、间隔与目标选择模式仅由 prefab 配置。
*/
private resolveSummonParams(config: SkillConfig): SummonParams {
const fromPrefab = this.readSummonParamsFromPrefab(config.sp_name);
const count = typeof config.summon_count === "number"
? Math.max(1, Math.floor(config.summon_count))
: fromPrefab.count;
return { count, x_offset: fromPrefab.x_offset, y_offset: fromPrefab.y_offset, interval: fromPrefab.interval, target_mode: fromPrefab.target_mode };
}
/**
* 读取 prefab 上 SkillView 的召唤参数(带缓存)。
* prefab 回退路径仅使用已缓存的资源(oops.res.get 同步命中),未加载时回退默认值(单体、无偏移),不引入异步等待。
*/
private readSummonParamsFromPrefab(sp_name: string): SummonParams {
const cached = this.summonParamsCache.get(sp_name);
if (cached) return cached;
const path = `game/skill/atk/${sp_name}`;
let params: SummonParams = { count: 1, x_offset: 0, y_offset: 0, interval: 0, target_mode: SummonTMode.same };
const prefab = oops.res.get(path, Prefab) as Prefab | null;
if (prefab) {
const view = prefab.data?.getComponent(SkillView);
if (view) {
params = {
count: Math.max(1, Math.floor(view.summon_count)),
x_offset: view.summon_x_offset,
y_offset: view.summon_y_offset,
interval: view.summon_interval,
target_mode: view.summon_target_mode ?? SummonTMode.same,
};
}
this.summonParamsCache.set(sp_name, params);
}
return params;
}
/** 解析错峰施法间隔:召唤技能优先 prefab 配置(>0),其余场景回退全局 SUMMON_INTERVAL */
private resolveCastInterval(summon: SummonParams): number {
return summon.count > 1 && summon.interval > 0 ? summon.interval : FightSet.SUMMON_INTERVAL;
}
/** 获取英雄查询条件(包含属性与视图组件) */
private getHeroMatcher(): ecs.IMatcher {
if (!this.heroMatcher) {
this.heroMatcher = ecs.allOf(HeroAttrsComp, HeroViewComp);
}
return this.heroMatcher;
}
private isOutOfBattleBounds(x: number): boolean {
return x < BoxSet.LETF_END || x > BoxSet.RIGHT_END;
}
/** 系统过滤器:仅处理英雄实体 */
filter(): ecs.IMatcher {
return this.getHeroMatcher();
}
/**
* 每帧更新:
* 1. 战斗状态校验
* 2. 英雄施法CD更新与显示
* 3. 选取本帧可施放技能并执行施法
*/
update(e: ecs.Entity): void {
if (!smc.mission.play) return;
if (smc.mission.pause) return
if (!smc.mission.in_fight) return
const heroAttrs = e.get(HeroAttrsComp);
const heroView = e.get(HeroViewComp);
if (!heroAttrs || !heroView || !heroView.node) return;
if (this.isOutOfBattleBounds(heroView.node.position.x)) return;
if (heroAttrs.is_dead || heroAttrs.is_reviving) return;
// 冰冻不影响 CD 值:冰冻/击晕期间 CD 照常刷新(击晕内部停涨),但不允许施法
heroAttrs.updateCD(this.dt);
heroView.cd_show();
if (heroAttrs.isFrost() || heroAttrs.isStun()) return;
const castPlan = this.pickCastSkill(heroAttrs, heroView);
if (!this.hasCastTarget(castPlan)) return;
this.castSkill(castPlan, heroAttrs, heroView);
}
/**
* 强制执行触发技能(召唤/死亡触发)
* 忽略CD、状态、动画前摇,直接生效
*/
public forceCastTriggerSkill(s_uuid: number, heroAttrs: HeroAttrsComp, heroView: HeroViewComp, triggerType?: string, overrides?: SkillOverrides) {
// 播放相应的触发动画
if (triggerType === 'call') {
heroView.playReady("yellow");
} else if (triggerType === 'dead') {
heroView.playOther("dead");
} else {
heroView.playOther('yellow')
}
// 播放特殊技能触发音效
oops.audio.playEffect("music/flash");
// 如果是敌方攻击技能,必须在战斗中才能释放;友方增益/护盾则允许在非战斗中释放
let config = SkillSet[s_uuid];
if (!config) return;
config = mergeSkillParams(config, overrides);
const isEnemyTarget = !this.isSelfSkill(config.TGroup) && !this.isFriendlySkill(config.TGroup);
if (isEnemyTarget && !smc.mission.in_fight) return;
const skillLv = heroAttrs.getSkillLevel(s_uuid) || 1;
let isFriendly = false;
let targetPos: Vec3 | null = null;
let targetEids: number[] = [];
const selfEid = heroView.ent?.eid;
const type = heroAttrs.type as HType;
const maxRange = this.resolveMaxCastRange(heroAttrs, type);
if (this.isSelfSkill(config.TGroup)) {
isFriendly = true;
if (typeof selfEid === "number") targetEids = [selfEid];
} else if (this.isFriendlySkill(config.TGroup)) {
isFriendly = true;
const includeSelf = config.TGroup === TGroup.Ally;
targetEids = this.collectFriendlyTargetEids(heroAttrs.fac, selfEid, includeSelf);
} else {
const target = this.findNearestEnemyInRange(heroAttrs, heroView, maxRange);
if (target && target.node) {
targetPos = this.resolveEnemyCastTargetPos(config, heroAttrs, heroView, target, maxRange);
}
}
const summon = this.resolveSummonParams(config);
const cNum = summon.count > 1 ? 0 : Math.min(2, Math.max(0, Math.floor(overrides?.num ?? 0)));
const castTimes = Math.max(summon.count, 1 + cNum);
const castInterval = this.resolveCastInterval(summon);
let val = ""
if (castTimes > 1) {
val = "*" + castTimes.toString()
}
heroView.skill_name(val, s_uuid)
mLogger.log(this.debugMode, "SCastSystem", `[触发施法] ${heroAttrs.hero_name}(uuid:${heroAttrs.hero_uuid}) 触发类型:${triggerType ?? "none"} 释放技能「${config.name}」(s_uuid:${s_uuid}) lv:${skillLv} ${isFriendly ? "友方" : "对敌"} castTimes:${castTimes}`);
// unique 模式去重记录:本次施法序列内已锁定过的敌人 eid
const usedTargetEids = new Set<number>();
for (let i = 0; i < castTimes; i++) {
if (!heroView.node || !heroView.node.isValid) return;
// 多发/召唤按间隔错峰执行:i=0 立即,其余延迟 i*castInterval(召唤间隔由 prefab 配置)
heroView.scheduleOnce(() => {
if (!smc.mission.play || smc.mission.pause) return;
if (!heroView.node || !heroView.node.isValid || heroAttrs.is_dead) return;
if (isFriendly) {
const friendlyTargets = this.resolveFriendlyTargets(targetEids, heroAttrs.fac);
if (friendlyTargets.length === 0) return;
this.applyFriendlySkillEffects(s_uuid, skillLv, config, heroView, heroAttrs, friendlyTargets, null);
return;
}
const enemyTargetPos = this.resolveSummonCastTargetPos(config, heroAttrs, heroView, targetPos, i, summon, usedTargetEids);
this.applyEnemySkillEffects(s_uuid, skillLv, config, heroView, heroAttrs, enemyTargetPos, i, overrides, summon);
}, i * castInterval);
}
// 施法后概率触发额外技能(proc_skill,原命中触发改为出手即判定,无视 CD)
this.tryProcSkill(config, heroAttrs, heroView, triggerType);
}
/**
* 选择当前应释放的技能。
* 选择顺序:技能候选列表顺序 + 条件过滤(CD、目标可达、目标类型匹配)。
* 返回内容同时包含“对敌施法”和“友方施法”两种执行所需数据。
*/
private pickCastSkill(heroAttrs: HeroAttrsComp, heroView: HeroViewComp): { skillId: number; skillLv: number; isFriendly: boolean; targetPos: Vec3 | null; targetEids: number[]; overrides?: SkillOverrides } {
const type = heroAttrs.type as HType;
const maxRange = this.resolveMaxCastRange(heroAttrs, type);
const target = this.findNearestEnemyInRange(heroAttrs, heroView, maxRange);
const skillCandidates = this.buildSkillCandidates(heroAttrs.getSkillIds());
const selfEid = heroView.ent?.eid;
for (const s_uuid of skillCandidates) {
if (!s_uuid) continue;
let config = SkillSet[s_uuid];
if (!config) continue;
if (!heroAttrs.isSkillReady(s_uuid)) continue;
const skillLv = heroAttrs.getSkillLevel(s_uuid);
const overrides = heroAttrs.skills[s_uuid]?.overrides;
config = mergeSkillParams(config, overrides);
if (this.isSelfSkill(config.TGroup)) {
if (typeof selfEid !== "number") continue;
return { skillId: s_uuid, skillLv, isFriendly: true, targetPos: null, targetEids: [selfEid], overrides };
}
if (this.isFriendlySkill(config.TGroup)) {
const includeSelf = config.TGroup === TGroup.Ally;
const friendlyEids = this.collectFriendlyTargetEids(heroAttrs.fac, selfEid, includeSelf);
if (friendlyEids.length === 0) continue;
return { skillId: s_uuid, skillLv, isFriendly: true, targetPos: null, targetEids: friendlyEids, overrides };
}
if (!target || !heroView.node || !target.node) continue;
const targetPos = this.resolveEnemyCastTargetPos(config, heroAttrs, heroView, target, maxRange);
if (!targetPos) continue;
return { skillId: s_uuid, skillLv, isFriendly: false, targetPos, targetEids: [], overrides };
}
return this.emptyCastPlan;
}
/**
* 执行施法:
* - 播放前摇与技能动作
* - 延迟到出手时机后,按技能目标类型分发到对敌/友方效果处理
* - 触发技能CD
*/
private castSkill(castPlan: { skillId: number; skillLv: number; isFriendly: boolean; targetPos: Vec3 | null; targetEids: number[]; overrides?: SkillOverrides }, heroAttrs: HeroAttrsComp, heroView: HeroViewComp) {
if (!smc.mission.in_fight) return;
const s_uuid = castPlan.skillId;
const skillLv = castPlan.skillLv;
const overrides = castPlan.overrides;
let config = SkillSet[s_uuid];
if (!config) return;
config = mergeSkillParams(config, overrides);
const summon = this.resolveSummonParams(config);
const isSummonSkill = summon.count > 1;
const cNum = isSummonSkill ? 0 : Math.min(2, Math.max(0, Math.floor(overrides?.num ?? 0)));
const castTimes = Math.max(summon.count, 1 + cNum);
const castInterval = this.resolveCastInterval(summon);
mLogger.log(this.debugMode, "SCastSystem", `[施法] ${heroAttrs.hero_name}(uuid:${heroAttrs.hero_uuid}) 释放技能「${config.name}」(s_uuid:${s_uuid}) lv:${skillLv} ${castPlan.isFriendly ? "友方" : "对敌"} castTimes:${castTimes}`);
//播放前摇技能动画
heroView.playReady(config.readyAnm);
//播放角色攻击动画
heroView.playSkillAnm(config.act);
// 大招(主动技)专属镜头表现:判定依据为施法动画为 max 开头(普攻为 "atk")
// 镜头聚焦拉近 + 技能名横幅 + 命中瞬间震屏,三段式叠加增强打击感
const isMaxSkill = config.act.startsWith("max");
if (isMaxSkill) {
// 1. 镜头聚焦:画面瞬间向施法者拉近再回弹
CameraFX.focusOn(heroView.node, 1.2, 0.5);
// 2. 技能名横幅:与触发技路径表现统一(castSkill 此前未调用)
let bannerVal = "";
if (castTimes > 1) bannerVal = "*" + castTimes.toString();
heroView.skill_name(bannerVal, s_uuid);
}
// 因为 castSkill 只由 update 循环调用,处理的必然是 heroAttrs.skills 中的普通技能
// 特殊触发技能(call/dead/atking等)走的是 forceCastTriggerSkill,不会调用 castSkill
// 召唤类技能(summon_count>1)不再计入普攻链,避免触发 atking 附加技能
const skillIds = heroAttrs.getSkillIds();
if (!isSummonSkill && skillIds.includes(s_uuid)) {
heroAttrs.atk_count++;
this.checkAndTriggerAtkingSkills(heroAttrs, heroView);
}
// 施法后概率触发额外技能(proc_skill,出手即判定,无视 CD,不再依赖命中)
this.tryProcSkill(config, heroAttrs, heroView, SkillTriggerType.Atking);
// 优先使用技能配置的前摇时间,否则使用全局默认值
// 注意:这里仍然是基于时间的延迟,受帧率波动影响。
// 若需精确同步,建议在动画中添加帧事件并在 HeroViewComp 中监听。
const delay = config.ready > 0 ? config.ready : FightSet.SKILL_CAST_DELAY;
// unique 模式去重记录:本次施法序列内已锁定过的敌人 eid
const usedTargetEids = new Set<number>();
heroView.scheduleOnce(() => {
if (!smc.mission.play || smc.mission.pause || !smc.mission.in_fight) return;
if (!heroView.node || !heroView.node.isValid || heroAttrs.is_dead) return;
for (let i = 0; i < castTimes; i++) {
// 多发/召唤按间隔错峰执行:i=0 立即,其余延迟 i*castInterval(召唤间隔由 prefab 配置)
heroView.scheduleOnce(() => {
if (!smc.mission.play || smc.mission.pause || !smc.mission.in_fight) return;
if (!heroView.node || !heroView.node.isValid || heroAttrs.is_dead) return;
if (castPlan.isFriendly) {
const friendlyTargets = this.resolveFriendlyTargets(castPlan.targetEids, heroAttrs.fac);
if (friendlyTargets.length === 0) return;
this.applyFriendlySkillEffects(s_uuid, skillLv, config, heroView, heroAttrs, friendlyTargets, null);
return;
}
const enemyTargetPos = this.resolveSummonCastTargetPos(config, heroAttrs, heroView, castPlan.targetPos, i, summon, usedTargetEids);
this.applyEnemySkillEffects(s_uuid, skillLv, config, heroView, heroAttrs, enemyTargetPos, i, overrides, summon);
}, i * castInterval);
}
}, delay);
// 3. 命中瞬间震屏:与技能实体生成时机对齐(前摇结束时)
if (isMaxSkill) {
ScreenShake.shake(8, 0.3);
}
heroAttrs.triggerSkillCD(s_uuid);
}
/** 检查并触发攻击附加技能 (atking) */
private checkAndTriggerAtkingSkills(heroAttrs: HeroAttrsComp, heroView: HeroViewComp) {
SkillTriggerHelper.trigger(SkillTriggerType.Atking, heroAttrs, heroView);
}
/**
* 施法后概率触发额外技能(proc_skill)。
* 语义由“命中触发”调整为“施法出手即判定”,一次施法只判定一次(与召唤/多发的实体数量无关),
* 走 TriggerSkill 通道(forceCastTriggerSkill),无视 CD、无施法前摇。
* @param config 合并后(含 overrides)的技能配置
* @param heroAttrs 施法者属性组件(卡牌技能传 null)
* @param heroView 施法者视图组件(卡牌技能传 null)
* @param triggerType 触发类型语义(仅日志标识用)
*/
private tryProcSkill(config: SkillConfig, heroAttrs: HeroAttrsComp | null, heroView: HeroViewComp | null, triggerType?: string) {
const proc = config.proc_skill;
if (!proc) return;
const chance = proc.chance ?? 100;
const rand = chance >= 100 ? 0 : Math.random() * 100;
const casterName = heroAttrs?.hero_name ?? "卡牌";
if (chance >= 100 || rand < chance) {
if (!heroAttrs || !heroView) return; // 卡牌技能无施法主体,proc 仅支持英雄触发
mLogger.log(this.debugMode, "SCastSystem", `[概率判定] 额外技能「${SkillSet[proc.s_uuid]?.name ?? "?"}」 概率:${chance.toFixed(1)}% 随机:${rand.toFixed(1)} -> ✔成功(技能「${config.name}」施法后触发,由 ${casterName} 释放)`);
SkillTriggerHelper.dispatchSinglePublic(proc.s_uuid, heroAttrs, heroView, SkillTriggerType.Atking, proc.overrides);
} else {
mLogger.log(this.debugMode, "SCastSystem", `[概率判定] 额外技能「${SkillSet[proc.s_uuid]?.name ?? "?"}」 概率:${chance.toFixed(1)}% 随机:${rand.toFixed(1)} -> ✘失败(技能「${config.name}」施法后未触发)`);
}
}
/** 多发的纵向扇形偏移档位(像素),索引按 中/上/下/上上/下下... 循环;召唤技能偏移由 prefab 配置 */
private static readonly REPEAT_Y_OFFSETS: number[] = [0, 15, -15, 30, -30, 45, -45, 60, -60];
private resolveRepeatCastTargetPos(targetPos: Vec3 | null, castIndex: number, summon?: SummonParams): Vec3 | null {
if (!targetPos) return null;
const offset = this.resolveCastOffset(castIndex, summon);
if (offset.x === 0 && offset.y === 0) return targetPos;
return new Vec3(targetPos.x + offset.x, targetPos.y + offset.y, targetPos.z);
}
/**
* 解析第 castIndex 次施放的对敌目标点(召唤目标选择模式入口)。
* - same(默认):沿用施法时锁定的目标点并按召唤偏移对称铺开(原行为);
* - random:每次出手时从射程内敌人中随机锁定一个(错峰施放时实时索敌,目标状态最新鲜);
* - unique:优先锁定本轮未选过的敌人,敌人不足一轮时重置去重记录再来一轮;
* 实时索敌失败(敌人死亡/移动出射程)时回退施法时锁定的目标点,保证技能不落空。
*/
private resolveSummonCastTargetPos(config: SkillConfig, heroAttrs: HeroAttrsComp, heroView: HeroViewComp | null, baseTargetPos: Vec3 | null, castIndex: number, summon: SummonParams | undefined, usedEids: Set<number>): Vec3 | null {
if (!summon || summon.count <= 1 || summon.target_mode === SummonTMode.same) {
return this.resolveRepeatCastTargetPos(baseTargetPos, castIndex, summon);
}
const maxRange = this.resolveMaxCastRange(heroAttrs, heroAttrs.type as HType);
const enemies = this.collectEnemiesInRange(heroAttrs, heroView, maxRange);
const picked = enemies.length > 0 ? this.pickEnemyByTargetMode(enemies, summon.target_mode, usedEids) : null;
if (picked?.node) {
mLogger.log(this.debugMode, "SCastSystem", `[召唤索敌] 「${config.name}」第${castIndex + 1}个实体锁定目标 eid:${picked.ent?.eid} 模式:${SummonTMode[summon.target_mode]}`);
// 与 resolveEnemyCastTargetPos 保持一致:贝塞尔取脚底原始坐标,其余补半身高命中身体中心
const pos = config.RType === RType.bezier ? picked.node.position.clone() : this.buildEnemyCastTargetPos(picked);
// 召唤偏移铺开仍生效:random 模式重复锁定同一敌人时,避免实体完全重叠
const offset = this.resolveCastOffset(castIndex, summon);
pos.x += offset.x;
pos.y += offset.y;
return pos;
}
// 实时索敌失败:回退施法时锁定的目标点
return this.resolveRepeatCastTargetPos(baseTargetPos, castIndex, summon);
}
/** 按目标模式从射程内候选敌人中挑选本次施放的锁定目标,并写入去重记录 */
private pickEnemyByTargetMode(enemies: HeroViewComp[], mode: SummonTMode, usedEids: Set<number>): HeroViewComp | null {
let pool = enemies;
if (mode === SummonTMode.unique) {
const unused = enemies.filter(view => !view.ent || !usedEids.has(view.ent.eid));
if (unused.length > 0) {
pool = unused;
} else {
// 敌人不足一轮:重置已选记录,重新开始去重,保证尽量分散
usedEids.clear();
}
}
const picked = pool[Math.floor(Math.random() * pool.length)];
if (picked?.ent) usedEids.add(picked.ent.eid);
return picked ?? null;
}
/** 收集施法者射程内的全部敌方目标(随机/去重目标模式实时索敌用),网格遍历与 findNearestEnemyByGrid 同源 */
private collectEnemiesInRange(heroAttrs: HeroAttrsComp, heroView: HeroViewComp | null, maxRange: number): HeroViewComp[] {
const result: HeroViewComp[] = [];
if (!heroView?.node) return result;
const grid = heroAttrs.fac === FacSet.HERO ? smc.mission.monGrid : smc.mission.heroGrid;
const currentX = heroView.node.position.x;
for (const eid of grid) {
if (eid < 0) continue;
const entity = ecs.getEntityByEid(eid);
if (!entity) continue;
const model = entity.get(HeroAttrsComp);
const view = entity.get(HeroViewComp);
if (!model || model.is_dead || model.is_reviving || !view?.node) continue;
if (Math.abs(currentX - view.node.position.x) <= maxRange) {
result.push(view);
}
}
return result;
}
/** 解析第 castIndex 个实体的偏移:召唤技能按 prefab 配置对称交替铺开(0, +1, -1, +2, -2... 档 × 步进值);多发回落纵向扇形档位 */
private resolveCastOffset(castIndex: number, summon?: SummonParams): { x: number; y: number } {
if (summon && summon.count > 1) {
// 对称交替:偶数索引(>=2)取负档,奇数索引取正档,使召唤物以原点为中心向两侧展开
const level = Math.ceil(castIndex / 2);
const sign = castIndex % 2 === 0 ? -1 : 1;
const factor = castIndex === 0 ? 0 : sign * level;
return { x: factor * summon.x_offset, y: factor * summon.y_offset };
}
return { x: 0, y: SCastSystem.REPEAT_Y_OFFSETS[castIndex % SCastSystem.REPEAT_Y_OFFSETS.length] };
}
/** 构建技能尝试顺序:优先主动技能,其次普攻 */
private buildSkillCandidates(skillIds: number[]): number[] {
if (!skillIds || skillIds.length === 0) return [];
if (skillIds.length === 1) return [skillIds[0]];
return [...skillIds.slice(1), skillIds[0]];
}
/**
* 创建技能实体(投射物/范围体等)。
* 仅用于对敌伤害技能的实体化表现与碰撞伤害分发。
*/
private createSkillEntity(s_uuid: number, skillLv: number, caster: HeroViewComp, cAttrsComp: HeroAttrsComp, targetPos: Vec3, castIndex: number = 0, overrides?: SkillOverrides, summon?: SummonParams) {
if (!caster.node || !caster.node.isValid) return;
const parent = caster.node.parent;
if (!parent) return;
const skill = ecs.getEntity<Skill>(Skill);
const startPos = this.resolveRepeatCastStartPos(caster.node.position, castIndex, summon);
skill.load(startPos, parent, s_uuid, targetPos.clone(), caster, cAttrsComp, skillLv, 0, overrides);
}
/**
* 对敌技能效果处理。
* 当前职责:仅处理伤害技能并创建技能实体。
*/
private applyEnemySkillEffects(s_uuid: number, skillLv: number, config: SkillConfig, heroView: HeroViewComp, cAttrsComp: HeroAttrsComp, targetPos: Vec3 | null, castIndex: number = 0, overrides?: SkillOverrides, summon?: SummonParams) {
const kind = config.kind ?? SkillKind.Damage;
if (kind !== SkillKind.Damage) return;
if (config.ap <= 0 || !targetPos) return;
this.createSkillEntity(s_uuid, skillLv, heroView, cAttrsComp, targetPos, castIndex, overrides, summon);
}
private resolveRepeatCastStartPos(startPos: Readonly<Vec3>, castIndex: number, summon?: SummonParams): Vec3 {
const offset = this.resolveCastOffset(castIndex, summon);
if (offset.x === 0 && offset.y === 0) return startPos.clone();
return new Vec3(startPos.x + offset.x, startPos.y + offset.y, startPos.z);
}
/**
* 友方技能效果处理。
* 当前职责:
* 1. 处理治疗与护盾
* 2. 处理 buffs 配置追加
* 3. 保留完整施法信息参数,便于后续扩展更多友方效果
*/
private applyFriendlySkillEffects(_s_uuid: number, _skillLv: number, config: SkillConfig, _heroView: HeroViewComp, _cAttrsComp: HeroAttrsComp, targets: HeroViewComp[], _targetPos: Vec3 | null, isCardSkill: boolean = false) {
const kind = config.kind ?? SkillKind.Support;
// 技能成长数值已由 SkillConfig/SkillOverrides 提供,无需再叠加 SkillUpList
const sAp = config.ap;
const sHit = config.hit_count;
const applyTargets = kind === SkillKind.Heal
? this.pickHealTargetsByMostMissingHp(targets, sHit)
: this.pickRandomFriendlyTargets(targets, sHit);
for (const target of applyTargets) {
this.applyActualFriendlyEffect(target, kind, sAp, _cAttrsComp, config, _skillLv);
}
}
/** 解析 buff 增益特效动画名:攻击提升→up_ap,攻速提升→up_cd,其余→up_blue */
private resolveBuffEndAnm(attr?: Attrs): string {
if (attr === Attrs.ap) return "up_ap";
if (attr === Attrs.speed) return "up_cd";
return "up_blue";
}
private applyActualFriendlyEffect(target: HeroViewComp, kind: SkillKind, sAp: number, _cAttrsComp: HeroAttrsComp, config: SkillConfig, _skillLv: number = 1) {
if (!target.ent) return;
const model = target.ent.get(HeroAttrsComp);
if (!model || model.is_dead) return;
mLogger.log(this.debugMode, "SCastSystem", `[友方效果] 技能「${config.name}」(s_uuid:${config.uuid}) -> ${model.hero_name} kind:${kind} buff_type:${config.buff_type ?? "-"} timed_buff_id:${config.timed_buff_id ?? "-"} buff_value:${config.buff_value ?? "-"}`);
if (config.endAnm && config.endAnm !== "") {
target.playEnd(config.endAnm);
}
if (kind === SkillKind.Heal && sAp !== 0) {
// 治疗量 = 被治疗者最终最大生命(getFinalHpMax 含驻场光环与计时 buff 加成)× 技能 ap%
// 治疗强化(heal_power,百分点):辅助专精天赋写入,按施法者放大治疗量
const addHp = Math.floor(model.getFinalHpMax() * sAp / 100 * (1 + _cAttrsComp.heal_power / 100));
model.add_hp(addHp);
target.health(addHp);
if (_cAttrsComp.fac === FacSet.HERO) {
// 【评分系统 - 防御分】统计团队造成的总治疗量
smc.vmdata.scores.heal_total += addHp;
}
} else if (kind === SkillKind.Shield && sAp !== 0) {
// 护盾值 = ap% × 施法者最终最大生命(getFinalHpMax 含驻场光环与计时 buff 加成)
const addShield = Math.max(0, Math.floor(_cAttrsComp.getFinalHpMax() * sAp / 100));
// shield_duration 缺省/<=0 表示永久护盾
model.add_shield(addShield, config.shield_duration ?? 0);
}
if (config.buff_type !== undefined) {
// 计时性 buff:配置了 timed_buff_id 时走 buffManager 限时路径,
// 同一技能不应既永久加成又限时加成,故提前 return 跳过下方永久 add_* 逻辑
if (config.timed_buff_id) {
// 汇总 buff 覆写参数:value 覆写修饰数值,duration 覆写持续时间
const buffOverrides: { value?: number; duration?: number } = {};
if (config.buff_value !== undefined) buffOverrides.value = config.buff_value;
if (config.buff_duration !== undefined) buffOverrides.duration = config.buff_duration;
// buff 强化(buff_power,百分点):辅助专精天赋写入,按施法者放大 buff 数值。
// 基准值优先取技能 buff_value,未配置时反查 BuffList(modifiers[0].value 优先,其次 tick.damage_or_heal)
if (_cAttrsComp.buff_power > 0) {
const buffCfg = BuffList[config.timed_buff_id];
const baseValue = config.buff_value
?? buffCfg?.modifiers?.[0]?.value
?? buffCfg?.tick?.damage_or_heal;
if (baseValue !== undefined) {
buffOverrides.value = Math.floor(baseValue * (1 + _cAttrsComp.buff_power / 100));
}
}
buffManager.applyBuff(
target.ent,
config.timed_buff_id,
_cAttrsComp.hero_uuid,
_cAttrsComp.ap,
Object.keys(buffOverrides).length > 0 ? buffOverrides : undefined
);
// 播放计时 buff 增益特效:按修饰属性区分(攻击提升/攻速提升/其他)
target.playEnd(this.resolveBuffEndAnm(BuffList[config.timed_buff_id]?.modifiers?.[0]?.attr));
return;
}
// 永久 buff 统一按百分比结算:攻击/生命按基础值相对放大,其余按百分点累加
// buff 强化(buff_power,百分点):辅助专精天赋写入,按施法者放大永久 buff 数值
const totalBuffValue = config.ap * (1 + _cAttrsComp.buff_power / 100);
// 播放永久 buff 增益特效:按 buff 属性区分(攻击提升/攻速提升/其他)
target.playEnd(this.resolveBuffEndAnm(config.buff_type));
switch (config.buff_type) {
case Attrs.ap: {
// 攻击力按基础 ap 的百分比提升
const addAp = Math.floor(model.ap * totalBuffValue / 100);
model.add_ap(addAp);
break;
}
case Attrs.hp_max: {
// 最大生命按基础 hp_max 的百分比提升
const addHp = Math.floor(model.hp_max * totalBuffValue / 100);
model.add_hp_max(addHp);
break;
}
default:
// 暴击/击晕/穿透/攻速等概率类属性按百分点直接累加
model.add_special_attr(config.buff_type, totalBuffValue);
break;
}
}
}
private pickRandomFriendlyTargets(targets: HeroViewComp[], hitCount: number): HeroViewComp[] {
if (!targets || targets.length === 0) return [];
const validHitCount = Math.max(1, Math.floor(hitCount));
if (validHitCount >= targets.length) return [...targets];
const pool = [...targets];
const selected: HeroViewComp[] = [];
while (selected.length < validHitCount && pool.length > 0) {
const index = Math.floor(Math.random() * pool.length);
const [target] = pool.splice(index, 1);
if (!target) continue;
selected.push(target);
}
return selected;
}
private pickHealTargetsByMostMissingHp(targets: HeroViewComp[], hitCount: number): HeroViewComp[] {
if (!targets || targets.length === 0) return [];
const validHitCount = Math.max(1, Math.floor(hitCount));
const sortedTargets = this.sortTargetsByMostMissingHp(targets);
return sortedTargets.slice(0, Math.min(validHitCount, sortedTargets.length));
}
private sortTargetsByMostMissingHp(targets: HeroViewComp[]): HeroViewComp[] {
return [...targets].sort((a, b) => {
const aModel = a.ent?.get(HeroAttrsComp);
const bModel = b.ent?.get(HeroAttrsComp);
const aMissingHp = aModel && aModel.hp_max > 0 ? Math.max(0, aModel.hp_max - aModel.hp) : -1;
const bMissingHp = bModel && bModel.hp_max > 0 ? Math.max(0, bModel.hp_max - bModel.hp) : -1;
if (aMissingHp !== bMissingHp) return bMissingHp - aMissingHp;
const aRatio = aModel && aModel.hp_max > 0 ? aModel.hp / aModel.hp_max : 1;
const bRatio = bModel && bModel.hp_max > 0 ? bModel.hp / bModel.hp_max : 1;
return aRatio - bRatio;
});
}
/** 根据目标 eid 列表解析出有效友方视图目标 */
private resolveFriendlyTargets(targetEids: number[], fac: number): HeroViewComp[] {
const targets: HeroViewComp[] = [];
for (const eid of targetEids) {
const entity = ecs.getEntityByEid(eid);
if (!entity) continue;
const model = entity.get(HeroAttrsComp);
const view = entity.get(HeroViewComp);
if (!model || !view?.node) continue;
if (model.fac !== fac) continue;
if (model.is_dead || model.is_reviving) continue;
targets.push(view);
}
return targets;
}
/**
* 收集可作为友方技能目标的实体 eid。
* includeSelf 控制是否包含施法者自身。
*/
private collectFriendlyTargetEids(fac: number, selfEid: number | undefined, includeSelf: boolean): number[] {
const eids: number[] = [];
const grid = fac === FacSet.HERO ? smc.mission.heroGrid : smc.mission.monGrid;
for (const eid of grid) {
if (eid >= 0) {
if (!includeSelf && typeof selfEid === "number" && eid === selfEid) continue;
const entity = ecs.getEntityByEid(eid);
if (entity) {
const model = entity.get(HeroAttrsComp);
if (model && !model.is_dead && !model.is_reviving) {
eids.push(eid);
}
}
}
}
return eids;
}
/** 判定施法计划是否具备有效目标 */
private hasCastTarget(castPlan: { skillId: number; skillLv: number; isFriendly: boolean; targetPos: Vec3 | null; targetEids: number[]; overrides?: SkillOverrides }): boolean {
if (castPlan.skillId === 0) return false;
if (castPlan.isFriendly) return castPlan.targetEids.length > 0;
return !!castPlan.targetPos;
}
/** 是否为友方目标技能(队友/友军) */
private isFriendlySkill(group: TGroup): boolean {
return group === TGroup.Team || group === TGroup.Ally;
}
/** 是否为自我目标技能 */
private isSelfSkill(group: TGroup): boolean {
return group === TGroup.Self;
}
/**
* 根据网格快速查找最近的敌人
* 英雄查找怪物:按列从前往后(列0 -> 列3),列内优先同排
* 怪物查找英雄:按列从前往后(列0 -> 列1),列内优先中路或同排
*/
private findNearestEnemyByGrid(heroAttrs: HeroAttrsComp, heroView: HeroViewComp, maxRange: number): HeroViewComp | null {
if (!heroView.node) return null;
const isHero = heroAttrs.fac === FacSet.HERO;
const myPosIndex = heroAttrs.posIndex;
let myRow = myPosIndex >= 0 ? myPosIndex % 3 : 1; // 默认中路
const currentX = heroView.node.position.x;
let targetView: HeroViewComp | null = null;
let minCol = -1;
if (isHero) {
// 英雄找怪物
for (let col = 0; col < 4; col++) {
// 列内顺序:优先同排,其次中路,再次其他
const rowOrder = myRow === 1 ? [1, 0, 2] : [myRow, 1, myRow === 0 ? 2 : 0];
for (const row of rowOrder) {
const idx = col * 3 + row;
const eid = smc.mission.monGrid[idx];
if (eid >= 0) {
const target = ecs.getEntityByEid(eid);
if (target) {
const tModel = target.get(HeroAttrsComp);
const tView = target.get(HeroViewComp);
if (tModel && !tModel.is_dead && !tModel.is_reviving && tView && tView.node) {
const dist = Math.abs(currentX - tView.node.position.x);
if (dist <= maxRange) {
return tView; // 找到列内最优且在射程内的目标,直接返回
}
}
}
}
}
}
} else {
// 怪物找英雄
for (let col = 0; col < 2; col++) {
// 列内顺序:怪物配置优先中路
const rowOrder = [1, myRow, myRow === 0 ? 2 : 0];
for (const row of rowOrder) {
const idx = col * 3 + row;
const eid = smc.mission.heroGrid[idx];
if (eid >= 0) {
const target = ecs.getEntityByEid(eid);
if (target) {
const tModel = target.get(HeroAttrsComp);
const tView = target.get(HeroViewComp);
if (tModel && !tModel.is_dead && !tModel.is_reviving && tView && tView.node) {
const dist = Math.abs(currentX - tView.node.position.x);
if (dist <= maxRange) {
return tView;
}
}
}
}
}
}
}
return null;
}
/**
* 在施法距离内查找最近敌人。
* 替换为网格化查找,大幅提升性能并解决同排优先问题。
*/
private findNearestEnemyInRange(heroAttrs: HeroAttrsComp, heroView: HeroViewComp, maxRange: number): HeroViewComp | null {
return this.findNearestEnemyByGrid(heroAttrs, heroView, maxRange);
}
/**
* 在施法距离内查找“最前排”敌人。
* 依据网格排布,列0天然是最前排,因此直接复用网格查找即可。
*/
private findFrontEnemyInRange(heroAttrs: HeroAttrsComp, heroView: HeroViewComp, maxRange: number, nearestEnemy: HeroViewComp): HeroViewComp | null {
return this.findNearestEnemyByGrid(heroAttrs, heroView, maxRange);
}
/**
* 解析对敌技能目标点:
* - 非范围技能:按施法方向生成目标点
* - range 范围技能:优先前排敌人位置
*/
private resolveEnemyCastTargetPos(config: SkillConfig, heroAttrs: HeroAttrsComp, heroView: HeroViewComp, nearestEnemy: HeroViewComp, maxRange: number): Vec3 | null {
if (config.RType === RType.bezier) {
return nearestEnemy.node?.position.clone() ?? null;
}
if (config.DTType !== DTType.range) {
return this.buildEnemyCastTargetPos(nearestEnemy);
}
const frontEnemy = this.findFrontEnemyInRange(heroAttrs, heroView, maxRange, nearestEnemy);
if (!frontEnemy?.node) return null;
return frontEnemy.node.position.clone();
}
/** 计算英雄最大施法距离(优先缓存,其次按英雄配置的基础距离) */
private resolveMaxCastRange(heroAttrs: HeroAttrsComp, type: HType): number {
const cached = heroAttrs.getCachedMaxSkillDistance();
if (cached > 0) return cached;
return heroAttrs.dis;
}
/** 生成对敌施法目标坐标:直接返回目标的真实坐标;y 为脚底(碰撞体底部),补半身高以命中身体中心 */
private buildEnemyCastTargetPos(target: HeroViewComp): Vec3 {
const transform = target.node?.getComponent(UITransform);
const halfHeight = transform ? transform.height / 2 : 40; // 无 UITransform 时给默认高度偏移
const pos = target.node.position.clone();
pos.y += halfHeight;
// 至于最终投射物是否要飞出屏幕(例如线性弹道延长至 +-500),由 SMoveSystem 统一处理
return pos;
}
}