feat(skill): add multi-summon positioning and interval configuration

1. 新增技能召唤物XY轴步进偏移与召唤间隔可配置项
2. 重构召唤逻辑,支持按配置对称排布召唤物
3. 替换全局固定间隔为技能预制体可配置的错峰召唤间隔
4. 优化召唤参数缓存与解析逻辑,统一参数获取流程
5. 调整maxm3技能预制体的缩放与尺寸配置作为示例
This commit is contained in:
panFD
2026-09-14 21:28:50 +08:00
parent 2dc44b2393
commit 7640957325
3 changed files with 111 additions and 57 deletions

View File

@@ -20,6 +20,14 @@ 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;
}
/**
* ==================== 自动施法系统 ====================
*
@@ -93,9 +101,10 @@ export class SCastSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
targetEids = this.collectFriendlyTargetEids(FacSet.HERO, undefined, true); // 获取所有英雄阵营的目标
}
const summonCount = this.resolveSummonCount(config);
const cNum = summonCount > 1 ? 0 : Math.min(2, Math.max(0, Math.floor(overrides?.num ?? 0)));
const castTimes = Math.max(summonCount, 1 + cNum);
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();
@@ -149,7 +158,7 @@ export class SCastSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
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}`);
for (let i = 0; i < castTimes; i++) {
// 多发/召唤按间隔错峰执行:i=0 立即,其余延迟 i*SUMMON_INTERVAL(卡牌无施法主体,用 setTimeout 并在回调内做效期校验)
// 多发/召唤按间隔错峰执行:i=0 立即,其余延迟 i*castInterval(召唤间隔由 prefab 配置)
const exec = () => {
if (!smc.mission.play || smc.mission.pause) return;
if (isFriendly) {
@@ -158,14 +167,14 @@ export class SCastSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
if (friendlyTargets.length === 0) return;
this.applyFriendlySkillEffects(s_uuid, rarity, config, null as any, mockAttrs, friendlyTargets, spawnPos);
} else {
const enemyTargetPos = this.resolveRepeatCastTargetPos(targetPos, i);
this.createSkillEntityForCard(s_uuid, rarity, mockAttrs, spawnPos, enemyTargetPos, i, overrides);
const enemyTargetPos = this.resolveRepeatCastTargetPos(targetPos, i, summon);
this.createSkillEntityForCard(s_uuid, rarity, mockAttrs, spawnPos, enemyTargetPos, i, overrides, summon);
}
};
if (i === 0) {
exec();
} else {
setTimeout(exec, i * FightSet.SUMMON_INTERVAL * 1000);
setTimeout(exec, i * castInterval * 1000);
}
}
// 施法后概率触发额外技能(proc_skill,原命中触发改为出手即判定;卡牌技能无视 CD、无施法主体)
@@ -173,7 +182,7 @@ export class SCastSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
}
/** 专用于卡牌施放的技能实体生成 */
private createSkillEntityForCard(s_uuid: number, skillLv: number, mockAttrs: HeroAttrsComp, startPos: Vec3, targetPos: Vec3 | null, castIndex: number = 0, overrides?: SkillOverrides) {
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) {
@@ -182,7 +191,7 @@ export class SCastSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
}
const skill = ecs.getEntity<Skill>(Skill);
const actualStartPos = this.resolveRepeatCastStartPos(startPos, castIndex);
const actualStartPos = this.resolveRepeatCastStartPos(startPos, castIndex, summon);
// 伪造一个简单的 heroView 供 Skill 初始化使用,只包含方向信息
const mockView = {
@@ -199,31 +208,50 @@ export class SCastSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
/** 查询缓存:避免每帧重复创建 matcher */
private heroMatcher: ecs.IMatcher | null = null;
/** 技能 prefab 召唤数量缓存:sp_name -> summon_count */
private summonCountCache: Map<string, number> = new Map();
/** 技能 prefab 召唤参数缓存:sp_name -> 召唤参数(数量/XY偏移/间隔) */
private summonParamsCache: Map<string, SummonParams> = new Map();
/**
* 解析单次施法的召唤数量。
* 优先级:合并后配置上的 summon_count(heroSet overrides 可覆写) > prefab 上 SkillView.summon_count。
* prefab 回退路径仅使用已缓存的资源(oops.res.get 同步命中),未加载时回退 1(单体),不引入异步等待。
* 解析单次施法的召唤参数。
* 数量优先级:合并后配置上的 summon_count(heroSet overrides 可覆写) > prefab 上 SkillView.summon_count;
* XY 偏移与间隔仅由 prefab 配置。
*/
private resolveSummonCount(config: SkillConfig): number {
if (typeof config.summon_count === "number") {
return Math.max(1, Math.floor(config.summon_count));
}
const cached = this.summonCountCache.get(config.sp_name);
if (cached !== undefined) return cached;
const path = `game/skill/atk/${config.sp_name}`;
let count = 1;
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 };
}
/**
* 读取 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 };
const prefab = oops.res.get(path, Prefab) as Prefab | null;
if (prefab) {
const view = prefab.data?.getComponent(SkillView);
if (view) {
count = Math.max(1, Math.floor(view.summon_count));
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,
};
}
this.summonCountCache.set(config.sp_name, count);
this.summonParamsCache.set(sp_name, params);
}
return count;
return params;
}
/** 解析错峰施法间隔:召唤技能优先 prefab 配置(>0),其余场景回退全局 SUMMON_INTERVAL */
private resolveCastInterval(summon: SummonParams): number {
return summon.count > 1 && summon.interval > 0 ? summon.interval : FightSet.SUMMON_INTERVAL;
}
/** 获取英雄查询条件(包含属性与视图组件) */
@@ -317,9 +345,10 @@ export class SCastSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
}
}
const summonCount = this.resolveSummonCount(config);
const cNum = summonCount > 1 ? 0 : Math.min(2, Math.max(0, Math.floor(overrides?.num ?? 0)));
const castTimes = Math.max(summonCount, 1 + cNum);
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()
@@ -328,7 +357,7 @@ export class SCastSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
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}`);
for (let i = 0; i < castTimes; i++) {
if (!heroView.node || !heroView.node.isValid) return;
// 多发/召唤按间隔错峰执行:i=0 立即,其余延迟 i*SUMMON_INTERVAL
// 多发/召唤按间隔错峰执行: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;
@@ -338,9 +367,9 @@ export class SCastSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
this.applyFriendlySkillEffects(s_uuid, skillLv, config, heroView, heroAttrs, friendlyTargets, null);
return;
}
const enemyTargetPos = this.resolveRepeatCastTargetPos(targetPos, i);
this.applyEnemySkillEffects(s_uuid, skillLv, config, heroView, heroAttrs, enemyTargetPos, i, overrides);
}, i * FightSet.SUMMON_INTERVAL);
const enemyTargetPos = this.resolveRepeatCastTargetPos(targetPos, i, summon);
this.applyEnemySkillEffects(s_uuid, skillLv, config, heroView, heroAttrs, enemyTargetPos, i, overrides, summon);
}, i * castInterval);
}
// 施法后概率触发额外技能(proc_skill,原命中触发改为出手即判定,无视 CD)
this.tryProcSkill(config, heroAttrs, heroView, triggerType);
@@ -399,10 +428,11 @@ export class SCastSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
config = mergeSkillParams(config, overrides);
const summonCount = this.resolveSummonCount(config);
const isSummonSkill = summonCount > 1;
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(summonCount, 1 + cNum);
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}`);
@@ -444,7 +474,7 @@ export class SCastSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
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*SUMMON_INTERVAL
// 多发/召唤按间隔错峰执行: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;
@@ -454,9 +484,9 @@ export class SCastSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
this.applyFriendlySkillEffects(s_uuid, skillLv, config, heroView, heroAttrs, friendlyTargets, null);
return;
}
const enemyTargetPos = this.resolveRepeatCastTargetPos(castPlan.targetPos, i);
this.applyEnemySkillEffects(s_uuid, skillLv, config, heroView, heroAttrs, enemyTargetPos, i, overrides);
}, i * FightSet.SUMMON_INTERVAL);
const enemyTargetPos = this.resolveRepeatCastTargetPos(castPlan.targetPos, i, summon);
this.applyEnemySkillEffects(s_uuid, skillLv, config, heroView, heroAttrs, enemyTargetPos, i, overrides, summon);
}, i * castInterval);
}
}, delay);
@@ -497,14 +527,26 @@ export class SCastSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
}
}
/** 多发/召唤的纵向扇形偏移档位(像素),索引按 中/上/下/上上/下下... 循环 */
/** 多发的纵向扇形偏移档位(像素),索引按 中/上/下/上上/下下... 循环;召唤技能偏移由 prefab 配置 */
private static readonly REPEAT_Y_OFFSETS: number[] = [0, 15, -15, 30, -30, 45, -45, 60, -60];
private resolveRepeatCastTargetPos(targetPos: Vec3 | null, castIndex: number): Vec3 | null {
private resolveRepeatCastTargetPos(targetPos: Vec3 | null, castIndex: number, summon?: SummonParams): Vec3 | null {
if (!targetPos) return null;
const offset = SCastSystem.REPEAT_Y_OFFSETS[castIndex % SCastSystem.REPEAT_Y_OFFSETS.length];
if (offset === 0) return targetPos;
return new Vec3(targetPos.x, targetPos.y + offset, targetPos.z);
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 个实体的偏移:召唤技能按 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] };
}
/** 构建技能尝试顺序:优先主动技能,其次普攻 */
@@ -518,12 +560,12 @@ export class SCastSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
* 创建技能实体(投射物/范围体等)。
* 仅用于对敌伤害技能的实体化表现与碰撞伤害分发。
*/
private createSkillEntity(s_uuid: number, skillLv: number, caster: HeroViewComp, cAttrsComp: HeroAttrsComp, targetPos: Vec3, castIndex: number = 0, overrides?: SkillOverrides) {
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);
const startPos = this.resolveRepeatCastStartPos(caster.node.position, castIndex, summon);
skill.load(startPos, parent, s_uuid, targetPos.clone(), caster, cAttrsComp, skillLv, 0, overrides);
}
@@ -531,17 +573,17 @@ export class SCastSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
* 对敌技能效果处理。
* 当前职责:仅处理伤害技能并创建技能实体。
*/
private applyEnemySkillEffects(s_uuid: number, skillLv: number, config: SkillConfig, heroView: HeroViewComp, cAttrsComp: HeroAttrsComp, targetPos: Vec3 | null, castIndex: number = 0, overrides?: SkillOverrides) {
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);
this.createSkillEntity(s_uuid, skillLv, heroView, cAttrsComp, targetPos, castIndex, overrides, summon);
}
private resolveRepeatCastStartPos(startPos: Readonly<Vec3>, castIndex: number): Vec3 {
const offset = SCastSystem.REPEAT_Y_OFFSETS[castIndex % SCastSystem.REPEAT_Y_OFFSETS.length];
if (offset === 0) return startPos.clone();
return new Vec3(startPos.x, startPos.y + offset, startPos.z);
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);
}
/**