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.
694 lines
27 KiB
694 lines
27 KiB
|
3 weeks ago
|
import type { AbilityDef, EffectSpec, TargetSpec, TimelineEvent, TriggerSpec } from 'db://assets/mx/module/battle';
|
||
|
|
import { BattleMapSpec } from '../../map/BattleMapSpec';
|
||
|
|
import { TableNames } from '../../../game/ConfigTableData';
|
||
|
|
import { TableAnomaly } from '../../../game/TableAnomaly';
|
||
|
|
import {
|
||
|
|
entityHeroAbilityId,
|
||
|
|
entityMonsterAbilityId,
|
||
|
|
entityWeaponAbilityId,
|
||
|
|
normalAbilityId,
|
||
|
|
} from '../weapon1/Weapon1BattleIds';
|
||
|
|
|
||
|
|
/** 技能槽位(驱动 cue / Ability id) */
|
||
|
|
export type SkillAbilityKind = 'normal' | 'hero' | 'weapon' | 'monster';
|
||
|
|
|
||
|
|
/** 表现 params 公共字段 */
|
||
|
|
export interface SkillPresentationParams {
|
||
|
|
/** cue:attack / skill / weaponSkill / monsterSkill */
|
||
|
|
cue: string;
|
||
|
|
skillKind: SkillAbilityKind;
|
||
|
|
skillId: number;
|
||
|
|
baseAtkId: number;
|
||
|
|
animation: string;
|
||
|
|
atkType: string;
|
||
|
|
atkPara: string;
|
||
|
|
atkTypeHead: number;
|
||
|
|
fly: string;
|
||
|
|
/** Skill.showeffect:延迟秒,预制体名;空则无预兆 */
|
||
|
|
showeffect: string;
|
||
|
|
priority: number;
|
||
|
|
cd: number;
|
||
|
|
extraSkill: string;
|
||
|
|
buff1: string;
|
||
|
|
buff2: string;
|
||
|
|
skillParts: number[];
|
||
|
|
skillType: number;
|
||
|
|
skillRaw: string;
|
||
|
|
stageStrategy: number;
|
||
|
|
stageRangePx: number;
|
||
|
|
pickCount: number;
|
||
|
|
/** atk_type=5 / 10 / 14 总段数(10=连砸次数) */
|
||
|
|
hitCount?: number;
|
||
|
|
/** atk_type=5 / 10 / 14 段间间隔(秒) */
|
||
|
|
hitIntervalSec?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Skill 表 → MX AbilityDef(新配表语义)。
|
||
|
|
* 按引用构建,勿对全表盲目注册普攻/大招。
|
||
|
|
*/
|
||
|
|
export class SkillAbilityBuilder {
|
||
|
|
/**
|
||
|
|
* 构建普攻 Ability。
|
||
|
|
* @param row Skill 行(可已覆盖 atk_type / atk_para)
|
||
|
|
* @param abilityId 缺省为共享 `ability_norm_{id}`
|
||
|
|
*/
|
||
|
|
static buildNormal(row: ITableSkill, abilityId?: string): AbilityDef {
|
||
|
|
return this._build(row, 'normal', abilityId ?? normalAbilityId(row.id));
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 构建英雄主动(实体专属 id) */
|
||
|
|
static buildHero(row: ITableSkill, entityId: number): AbilityDef {
|
||
|
|
return this._build(row, 'hero', entityHeroAbilityId(row.id, entityId));
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 构建武器主动(实体专属 id) */
|
||
|
|
static buildWeapon(row: ITableSkill, entityId: number): AbilityDef {
|
||
|
|
return this._build(row, 'weapon', entityWeaponAbilityId(row.id, entityId));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 构建怪物技能(实体专属 id)。
|
||
|
|
* 默认仅 PlayPresentation;atk_type=8/11 挂 SummonFromSkill;非 6/9/12 且非 target_stage=2 的 atk_buff 挂 ApplySkillAtkBuff。
|
||
|
|
* @param row Skill 行
|
||
|
|
* @param entityId 怪物实体
|
||
|
|
* @param startOnCooldown Boss 等:刷出后先满 CD,不立即施法
|
||
|
|
*/
|
||
|
|
static buildMonster(row: ITableSkill, entityId: number, startOnCooldown = false): AbilityDef {
|
||
|
|
return this._build(row, 'monster', entityMonsterAbilityId(row.id, entityId), startOnCooldown);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** @deprecated 兼容旧调用:有 atk_type 当普攻,有 skill 当主动 */
|
||
|
|
static registerAll(provider: { registerAbility(id: string, def: AbilityDef): void }): void {
|
||
|
|
const list = gg.data.table.getTableList<ITableSkill>(TableNames.Skill) ?? [];
|
||
|
|
const seen: number[] = [];
|
||
|
|
for (const row of list) {
|
||
|
|
if (seen.indexOf(row.id) >= 0) continue;
|
||
|
|
seen.push(row.id);
|
||
|
|
if (this._hasAtkShape(row)) {
|
||
|
|
provider.registerAbility(normalAbilityId(row.id), this.buildNormal(row));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 旧 API:按行建普攻 */
|
||
|
|
static buildNormalAttack(row: ITableSkill): AbilityDef {
|
||
|
|
return this.buildNormal(row);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 旧 API:按行建主动(无实体时用共享 ult 风格 id,仅注册用) */
|
||
|
|
static buildActiveSkill(row: ITableSkill): AbilityDef {
|
||
|
|
return this._build(row, 'hero', `ability_ult_${row.id}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 词条改参后同步(兼容旧接口) */
|
||
|
|
static syncUltAbilitySkillParts(def: AbilityDef, parts: number[]): void {
|
||
|
|
const skillType = parts[0] ?? 0;
|
||
|
|
for (const ev of def.timeline) {
|
||
|
|
for (const fx of ev.effects) {
|
||
|
|
if (fx.params) {
|
||
|
|
if (fx.type === 'PlayPresentation' || fx.type === 'DealDamage') {
|
||
|
|
fx.params.skillParts = parts;
|
||
|
|
fx.params.skillRaw = parts.join(',');
|
||
|
|
fx.params.skillType = skillType;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param row Skill 行
|
||
|
|
* @param kind 槽位
|
||
|
|
* @param abilityId Ability id
|
||
|
|
* @param startOnCooldown 怪物:首次 OnTick 是否先满 CD
|
||
|
|
*/
|
||
|
|
private static _build(row: ITableSkill, kind: SkillAbilityKind, abilityId: string, startOnCooldown = false): AbilityDef {
|
||
|
|
const atkHead = this._atkHead(row);
|
||
|
|
const atkParts = this._splitNums(row.atk_para);
|
||
|
|
const extraParts = this._splitNums(row.skill);
|
||
|
|
const buffHead = this._splitNums(row.atk_buff1)[0] ?? 0;
|
||
|
|
const targeting = this._targeting(row, atkHead, atkParts);
|
||
|
|
if (kind === 'monster' && (row.target_stage || 1) === 1) {
|
||
|
|
targeting.params = { ...(targeting.params ?? {}), monsterEnemyHero: atkHead !== 9 };
|
||
|
|
if (buffHead === 12) targeting.params.monsterBasicHero = true;
|
||
|
|
if (buffHead === 6) targeting.params.monsterRandomPick = true;
|
||
|
|
}
|
||
|
|
if (kind === 'monster' && (row.target_stage || 1) === 2) {
|
||
|
|
targeting.params = { ...(targeting.params ?? {}), monsterSameSide: true };
|
||
|
|
}
|
||
|
|
if (kind === 'monster' && (extraParts[0] ?? 0) === 5) {
|
||
|
|
targeting.params = {
|
||
|
|
...(targeting.params ?? {}),
|
||
|
|
monsterDevourMinion: true,
|
||
|
|
stageStrategy: 1,
|
||
|
|
};
|
||
|
|
const maxN = Math.floor(extraParts[1] || 0);
|
||
|
|
if (!(maxN > 0)) {
|
||
|
|
console.log('[SkillAbilityBuilder] skill 类型5缺少最大数量', row.id, row.skill);
|
||
|
|
} else {
|
||
|
|
targeting.maxCount = maxN;
|
||
|
|
targeting.params.pickCount = maxN;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const hitStages = kind === 'monster' && atkHead !== 8 && atkHead !== 11
|
||
|
|
? []
|
||
|
|
: this._buildHitStages(row, atkHead, atkParts, extraParts);
|
||
|
|
if (kind === 'monster') {
|
||
|
|
const buffFx = this._monsterBuffStage(row);
|
||
|
|
if (buffFx) hitStages.push(buffFx);
|
||
|
|
}
|
||
|
|
const presentation = this._presentationParams(row, kind, atkHead, atkParts, extraParts);
|
||
|
|
const isActive = kind !== 'normal';
|
||
|
|
const cd = Math.max(0, Number(row.cd) || 0);
|
||
|
|
|
||
|
|
const timeline = this._deferredPipeline(presentation, hitStages);
|
||
|
|
|
||
|
|
const priority = Number(row.priority) || 0;
|
||
|
|
|
||
|
|
if (!isActive) {
|
||
|
|
return {
|
||
|
|
id: abilityId,
|
||
|
|
triggers: [{ type: 'OnTick', interruptibleByCast: true }],
|
||
|
|
conditions: [{ type: 'HasTargetInRange' }],
|
||
|
|
targeting,
|
||
|
|
timeline,
|
||
|
|
priority: 0,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const extraHead = extraParts[0] ?? 0;
|
||
|
|
const interval = cd > 0 ? cd : 9999;
|
||
|
|
const costs = cd > 0 ? [{ type: 'Cooldown' as const, params: { cd, abilityId } }] : [];
|
||
|
|
// 2 万箭:全场无敌方行军怪不放(CD 保持就绪);表现仍沿敌方来路射箭
|
||
|
|
if (kind !== 'monster' && extraHead === 2) {
|
||
|
|
return {
|
||
|
|
id: abilityId,
|
||
|
|
triggers: [this._activeTick(interval, kind, startOnCooldown)],
|
||
|
|
costs,
|
||
|
|
conditions: [{ type: 'HasTargetInRange' }],
|
||
|
|
targeting: {
|
||
|
|
team: 'enemy',
|
||
|
|
selector: 'AllEnemies',
|
||
|
|
shape: 'Single',
|
||
|
|
maxCount: 1,
|
||
|
|
params: { monsterHostile: true, stageStrategy: row.target_stage || 1 },
|
||
|
|
},
|
||
|
|
timeline: this._deferredPipeline(presentation, []),
|
||
|
|
priority,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
// 1 冲锋:开战不锁敌。skill 9 仅半屏迷雾 / atk_buff 9 封格:不锁英雄
|
||
|
|
// atk_type=15 + skill 9:仍锁射程内主目标并扩圆
|
||
|
|
if ((kind !== 'monster' && extraHead === 1)
|
||
|
|
|| (kind === 'monster' && ((extraHead === 9 && atkHead === 0) || buffHead === 9))) {
|
||
|
|
return {
|
||
|
|
id: abilityId,
|
||
|
|
triggers: [this._activeTick(interval, kind, startOnCooldown)],
|
||
|
|
costs,
|
||
|
|
conditions: [],
|
||
|
|
targeting: {
|
||
|
|
team: 'self',
|
||
|
|
selector: 'Nearest',
|
||
|
|
shape: 'Single',
|
||
|
|
maxCount: 1,
|
||
|
|
params: { stageStrategy: row.target_stage || 1, stageRangePx: this._rangePx(row) },
|
||
|
|
},
|
||
|
|
timeline: this._deferredPipeline(presentation, []),
|
||
|
|
priority,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: abilityId,
|
||
|
|
triggers: [this._activeTick(interval, kind, startOnCooldown)],
|
||
|
|
costs,
|
||
|
|
conditions: targeting.team === 'self' || targeting.team === 'ally'
|
||
|
|
? []
|
||
|
|
: [{ type: 'HasTargetInRange' }],
|
||
|
|
targeting,
|
||
|
|
timeline,
|
||
|
|
priority,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 主动 OnTick。普通怪出生即就绪;Boss 刷出后先满 CD。
|
||
|
|
* @param interval CD 秒
|
||
|
|
* @param kind 槽位
|
||
|
|
* @param startOnCooldown 是否先满 CD
|
||
|
|
*/
|
||
|
|
private static _activeTick(interval: number, kind: SkillAbilityKind, startOnCooldown = false): TriggerSpec {
|
||
|
|
if (kind === 'monster') {
|
||
|
|
const firstDelaySec = startOnCooldown ? interval : 0;
|
||
|
|
return { type: 'OnTick', interval, interruptibleByCast: false, firstDelaySec };
|
||
|
|
}
|
||
|
|
return { type: 'OnTick', interval, interruptibleByCast: false };
|
||
|
|
}
|
||
|
|
|
||
|
|
private static _deferredPipeline(presentation: SkillPresentationParams, logicStages: EffectSpec[][]): TimelineEvent[] {
|
||
|
|
const timeline: TimelineEvent[] = [
|
||
|
|
{ at: 0, effects: [{ type: 'PlayPresentation', params: { ...presentation } }] },
|
||
|
|
];
|
||
|
|
for (let i = 0; i < logicStages.length; i++) {
|
||
|
|
const effects = logicStages[i];
|
||
|
|
if (!effects.length) continue;
|
||
|
|
timeline.push({ at: 0, stageIndex: i, effects });
|
||
|
|
}
|
||
|
|
return timeline;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static _presentationParams(
|
||
|
|
row: ITableSkill,
|
||
|
|
kind: SkillAbilityKind,
|
||
|
|
atkHead: number,
|
||
|
|
atkParts: number[],
|
||
|
|
extraParts: number[],
|
||
|
|
): SkillPresentationParams {
|
||
|
|
const cue = kind === 'normal'
|
||
|
|
? 'attack'
|
||
|
|
: kind === 'weapon'
|
||
|
|
? 'weaponSkill'
|
||
|
|
: kind === 'monster'
|
||
|
|
? 'monsterSkill'
|
||
|
|
: 'skill';
|
||
|
|
return {
|
||
|
|
cue,
|
||
|
|
skillKind: kind,
|
||
|
|
skillId: row.id,
|
||
|
|
baseAtkId: row.id,
|
||
|
|
animation: row.animation ?? '',
|
||
|
|
atkType: String(row.atk_type ?? ''),
|
||
|
|
atkPara: String(row.atk_para ?? ''),
|
||
|
|
atkTypeHead: atkHead,
|
||
|
|
fly: row.fly ?? '',
|
||
|
|
showeffect: String(row.showeffect ?? ''),
|
||
|
|
priority: Number(row.priority) || 0,
|
||
|
|
cd: Number(row.cd) || 0,
|
||
|
|
extraSkill: String(row.skill ?? ''),
|
||
|
|
buff1: row.atk_buff1 ?? '',
|
||
|
|
buff2: row.atk_buff2 ?? '',
|
||
|
|
skillParts: extraParts.length > 0 ? extraParts : atkParts,
|
||
|
|
skillType: extraParts[0] ?? atkHead,
|
||
|
|
skillRaw: String(row.skill ?? row.atk_para ?? ''),
|
||
|
|
stageStrategy: row.target_stage || 1,
|
||
|
|
stageRangePx: Math.max(0, row.range || 0),
|
||
|
|
pickCount: 1,
|
||
|
|
...(atkHead === 5 ? {
|
||
|
|
hitCount: Math.max(1, Math.floor(atkParts[2] || 1)),
|
||
|
|
hitIntervalSec: Math.max(0, Number(atkParts[1]) || 0),
|
||
|
|
} : {}),
|
||
|
|
...(atkHead === 10 || atkHead === 14 ? this._countIntervalHitParams(row, atkParts, atkHead) : {}),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* atk_type=10 / 14:数量,间隔秒,伤害万分比。
|
||
|
|
* @param row Skill 行
|
||
|
|
* @param atkParts atk_para 分段
|
||
|
|
* @param atkHead 攻击类型头
|
||
|
|
*/
|
||
|
|
private static _countIntervalHitParams(row: ITableSkill, atkParts: number[], atkHead: number): Pick<SkillPresentationParams, 'hitCount' | 'hitIntervalSec'> {
|
||
|
|
const parsed = this._parseCountIntervalWan(row, atkParts, atkHead);
|
||
|
|
if (!parsed) return {};
|
||
|
|
return { hitCount: parsed.hitCount, hitIntervalSec: parsed.intervalSec };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 解析「数量,间隔秒,伤害万分比」。非法则 warn 并返回 null。
|
||
|
|
* @param row Skill 行
|
||
|
|
* @param atkParts atk_para 分段
|
||
|
|
* @param atkHead 攻击类型头
|
||
|
|
*/
|
||
|
|
private static _parseCountIntervalWan(row: ITableSkill, atkParts: number[], atkHead: number): { hitCount: number; intervalSec: number; wan: number } | null {
|
||
|
|
const hitCount = Math.floor(atkParts[0] || 0);
|
||
|
|
const intervalSec = Number(atkParts[1]);
|
||
|
|
const wan = Number(atkParts[2]);
|
||
|
|
if (atkParts.length < 3 || !(hitCount > 0) || !Number.isFinite(intervalSec) || intervalSec < 0 || !Number.isFinite(wan)) {
|
||
|
|
console.log(`[SkillAbilityBuilder] atk_type=${atkHead} 参数须为「数量,间隔秒,伤害万分比」`, row.id, row.atk_para);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return { hitCount, intervalSec, wan };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 怪物 atk_buff1/2(类型 6/9/12、target_stage=2 由 Host 处理全部目标,不走此阶段)。
|
||
|
|
* @param row Skill 行
|
||
|
|
*/
|
||
|
|
private static _monsterBuffStage(row: ITableSkill): EffectSpec[] | null {
|
||
|
|
const buff1 = String(row.atk_buff1 ?? '').trim();
|
||
|
|
const buff2 = String(row.atk_buff2 ?? '').trim();
|
||
|
|
if (!buff1 && !buff2) return null;
|
||
|
|
const head = this._splitNums(buff1)[0] ?? 0;
|
||
|
|
if (head === 6 || head === 9 || head === 12) return null;
|
||
|
|
if ((row.target_stage || 1) === 2) return null;
|
||
|
|
return [{ type: 'ApplySkillAtkBuff', params: { buff1, buff2 } }];
|
||
|
|
}
|
||
|
|
|
||
|
|
private static _hasAtkShape(row: ITableSkill): boolean {
|
||
|
|
const t = String(row.atk_type ?? '').trim();
|
||
|
|
return t.length > 0 && t !== '0';
|
||
|
|
}
|
||
|
|
|
||
|
|
private static _atkHead(row: ITableSkill): number {
|
||
|
|
const parts = String(row.atk_type ?? '').split(',').map(Number);
|
||
|
|
return parts[0] || 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static _splitNums(raw: string | null | undefined): number[] {
|
||
|
|
const s = String(raw ?? '').trim();
|
||
|
|
if (!s) return [];
|
||
|
|
return s.split(',').map(Number);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static _rangePx(row: ITableSkill): number {
|
||
|
|
if (row.range < 0) return 99 * BattleMapSpec.CELL_SIZE;
|
||
|
|
if (row.range > 0) return row.range;
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Skill.range<=0 时用塔 Combat 射程 */
|
||
|
|
private static _withAttackRangeFallback(
|
||
|
|
rangePx: number,
|
||
|
|
base: Omit<TargetSpec, 'rangeRef'> & { rangeRef?: TargetSpec['rangeRef'] },
|
||
|
|
): TargetSpec {
|
||
|
|
if (rangePx > 0) {
|
||
|
|
return { ...base, params: { ...(base.params ?? {}), range: rangePx } };
|
||
|
|
}
|
||
|
|
return { ...base, rangeRef: 'attackRange' };
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 伤害万分比转倍率;缺省由调用方 `?? 10000`;配表写 0 表示无直伤 */
|
||
|
|
private static _damageScale(wan: number): number {
|
||
|
|
if (!(wan > 0)) return 0;
|
||
|
|
return wan / 10000;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static _team(row: ITableSkill): TargetSpec['team'] {
|
||
|
|
const stage = row.target_stage || 1;
|
||
|
|
if (stage === 3) return 'self';
|
||
|
|
if (stage === 2) return 'ally';
|
||
|
|
return 'enemy';
|
||
|
|
}
|
||
|
|
|
||
|
|
private static _targeting(row: ITableSkill, atkHead: number, atkParts: number[]): TargetSpec {
|
||
|
|
const team = this._team(row);
|
||
|
|
const rangePx = this._rangePx(row);
|
||
|
|
const baseParams: Record<string, unknown> = {
|
||
|
|
stageStrategy: row.target_stage || 1,
|
||
|
|
stageRangePx: rangePx,
|
||
|
|
};
|
||
|
|
|
||
|
|
if (team === 'self') {
|
||
|
|
return { team: 'self', selector: 'Nearest', shape: 'Single', maxCount: 1, params: baseParams };
|
||
|
|
}
|
||
|
|
|
||
|
|
// 1 单体 / 0 默认
|
||
|
|
if (atkHead === 1 || atkHead === 0) {
|
||
|
|
return this._withAttackRangeFallback(rangePx, {
|
||
|
|
team,
|
||
|
|
selector: 'Nearest',
|
||
|
|
shape: 'Single',
|
||
|
|
maxCount: 1,
|
||
|
|
params: baseParams,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (atkHead === 10) {
|
||
|
|
const parsed = this._parseCountIntervalWan(row, atkParts, 10);
|
||
|
|
const maxCount = parsed ? parsed.hitCount : 0;
|
||
|
|
return this._withAttackRangeFallback(rangePx, {
|
||
|
|
team,
|
||
|
|
selector: 'Nearest',
|
||
|
|
shape: 'Single',
|
||
|
|
maxCount,
|
||
|
|
params: { ...baseParams, pickCount: maxCount },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
// 2 直线矩形:atk_para=[横向宽(px), 伤害万分比];长度=射程(Skill.range 或塔射程)
|
||
|
|
if (atkHead === 2) {
|
||
|
|
const rectWidthPx = Math.max(1, atkParts[0] || 1);
|
||
|
|
return this._withAttackRangeFallback(rangePx, {
|
||
|
|
team,
|
||
|
|
selector: 'AllInRange',
|
||
|
|
shape: 'Rect',
|
||
|
|
maxCount: 64,
|
||
|
|
params: {
|
||
|
|
...baseParams,
|
||
|
|
rectWidthPx,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
// 3 360°:atk_para=[伤害系数];6 扇形:atk_para=[角度,伤害系数]
|
||
|
|
// 14 360°多段:atk_para=[段数,间隔秒,伤害万分比];圈半径仍用 Skill.range
|
||
|
|
// 索敌/命中圈用 Skill.range(≤0 则塔射程),勿把伤害万分比当半径
|
||
|
|
if (atkHead === 3 || atkHead === 6 || atkHead === 14) {
|
||
|
|
const angle = atkHead === 6 ? Math.max(1, atkParts[0] || 90) : 360;
|
||
|
|
const radius = rangePx > 0 ? rangePx : undefined;
|
||
|
|
return this._withAttackRangeFallback(rangePx, {
|
||
|
|
team,
|
||
|
|
selector: 'AllInRange',
|
||
|
|
shape: 'Circle',
|
||
|
|
maxCount: 64,
|
||
|
|
params: {
|
||
|
|
...baseParams,
|
||
|
|
...(radius != null ? { radius } : {}),
|
||
|
|
angle,
|
||
|
|
pickCount: 32,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
// 15 目标圆形:先按 Skill.range 锁主目标,再以主目标为圆心、atk_para[0] 为半径扩圆
|
||
|
|
if (atkHead === 15) {
|
||
|
|
const aroundRadius = Number(atkParts[0]);
|
||
|
|
if (!(aroundRadius > 0)) {
|
||
|
|
console.log('[SkillAbilityBuilder] atk_type=15 缺少半径', row.id, row.atk_para);
|
||
|
|
}
|
||
|
|
return this._withAttackRangeFallback(rangePx, {
|
||
|
|
team,
|
||
|
|
selector: 'Nearest',
|
||
|
|
shape: 'Circle',
|
||
|
|
maxCount: 64,
|
||
|
|
params: {
|
||
|
|
...baseParams,
|
||
|
|
aroundPrimaryRadius: aroundRadius > 0 ? aroundRadius : 0,
|
||
|
|
pickCount: 32,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
// 4 抛物线 / 12 散射瞄准 / 13 连发:开战只锁主目标
|
||
|
|
if (atkHead === 4 || atkHead === 12 || atkHead === 13) {
|
||
|
|
return this._withAttackRangeFallback(rangePx, {
|
||
|
|
team,
|
||
|
|
selector: 'Nearest',
|
||
|
|
shape: 'Single',
|
||
|
|
maxCount: 1,
|
||
|
|
params: baseParams,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
// 5 直线多段:atk_para=[宽(px),间隔秒,段数,伤害万分比]
|
||
|
|
if (atkHead === 5) {
|
||
|
|
const rectWidthPx = Math.max(1, atkParts[0] || 1);
|
||
|
|
return this._withAttackRangeFallback(rangePx, {
|
||
|
|
team,
|
||
|
|
selector: 'AllInRange',
|
||
|
|
shape: 'Rect',
|
||
|
|
maxCount: 64,
|
||
|
|
params: {
|
||
|
|
...baseParams,
|
||
|
|
rectWidthPx,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
// 7 全屏 / 9 全屏指定将(atk_para 将id=Hero.weapon)
|
||
|
|
if (atkHead === 7 || atkHead === 9) {
|
||
|
|
const maxCount = atkHead === 7 ? Math.max(1, atkParts[0] || 64) : Math.max(1, atkParts[1] || 64);
|
||
|
|
const params: Record<string, unknown> = { ...baseParams };
|
||
|
|
if (atkHead === 9) {
|
||
|
|
const weaponType = Math.floor(atkParts[0] || 0);
|
||
|
|
if (!(weaponType > 0)) {
|
||
|
|
console.log('[SkillAbilityBuilder] atk_type=9 缺少将id', row.id, row.atk_para);
|
||
|
|
} else {
|
||
|
|
params.weaponType = weaponType;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
team,
|
||
|
|
selector: 'AllEnemies',
|
||
|
|
shape: 'Single',
|
||
|
|
maxCount,
|
||
|
|
params,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
// 8 / 11 召唤:可无目标
|
||
|
|
if (atkHead === 8 || atkHead === 11) {
|
||
|
|
return {
|
||
|
|
team: 'self',
|
||
|
|
selector: 'Nearest',
|
||
|
|
shape: 'Single',
|
||
|
|
maxCount: 1,
|
||
|
|
params: baseParams,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
TableAnomaly.warn('Skill', row.id, 'atk_type', '头类型无索敌实现,不按单体兜底', row.atk_type);
|
||
|
|
return {
|
||
|
|
team,
|
||
|
|
selector: 'Nearest',
|
||
|
|
shape: 'Single',
|
||
|
|
maxCount: 0,
|
||
|
|
params: baseParams,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private static _buildHitStages(
|
||
|
|
row: ITableSkill,
|
||
|
|
atkHead: number,
|
||
|
|
atkParts: number[],
|
||
|
|
extraParts: number[],
|
||
|
|
): EffectSpec[][] {
|
||
|
|
if (atkHead === 8) {
|
||
|
|
const parts = atkParts.length >= 2 ? atkParts : [];
|
||
|
|
const monsterId = parts[0] ?? 0;
|
||
|
|
const count = Math.max(1, Math.floor(parts[1] || 1));
|
||
|
|
const intervalSec = Math.max(0, Number(parts[2]) || 0);
|
||
|
|
const spawnMode = Math.floor(parts[3] ?? 0);
|
||
|
|
if (monsterId !== -1 && !(monsterId > 0)) {
|
||
|
|
console.log('[SkillAbilityBuilder] atk_type=8 缺少怪物 id', row.id, row.atk_para);
|
||
|
|
}
|
||
|
|
if (atkParts.length < 4) {
|
||
|
|
console.log('[SkillAbilityBuilder] atk_type=8 缺少出怪类型(第4段 1施法处跳入/2自身周围)', row.id, row.atk_para);
|
||
|
|
} else if (spawnMode !== 1 && spawnMode !== 2) {
|
||
|
|
console.log('[SkillAbilityBuilder] atk_type=8 出怪类型须为1施法处跳入或2自身周围', row.id, row.atk_para);
|
||
|
|
}
|
||
|
|
return [[{
|
||
|
|
type: 'SummonFromSkill',
|
||
|
|
params: {
|
||
|
|
monsterId,
|
||
|
|
count,
|
||
|
|
intervalSec,
|
||
|
|
spawnMode,
|
||
|
|
buff1: row.atk_buff1 ?? '',
|
||
|
|
buff2: row.atk_buff2 ?? '',
|
||
|
|
skillId: row.id,
|
||
|
|
},
|
||
|
|
}]];
|
||
|
|
}
|
||
|
|
|
||
|
|
if (atkHead === 12 || atkHead === 13) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
if ((extraParts[0] ?? 0) === 1 || (extraParts[0] ?? 0) === 2 || (extraParts[0] ?? 0) === 6 || (extraParts[0] ?? 0) === 7) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
if (atkHead === 5 || atkHead === 10 || atkHead === 14) {
|
||
|
|
let hitCount = Math.max(1, Math.floor(atkParts[2] || 1));
|
||
|
|
if (atkHead === 10 || atkHead === 14) {
|
||
|
|
const parsed = this._parseCountIntervalWan(row, atkParts, atkHead);
|
||
|
|
if (!parsed) return [];
|
||
|
|
hitCount = parsed.hitCount;
|
||
|
|
}
|
||
|
|
const scale = this._resolveDamageScale(atkHead, atkParts, extraParts);
|
||
|
|
const stages: EffectSpec[][] = [];
|
||
|
|
for (let i = 0; i < hitCount; i++) {
|
||
|
|
const effects: EffectSpec[] = [{
|
||
|
|
type: 'DealDamage',
|
||
|
|
params: {
|
||
|
|
scale,
|
||
|
|
skillAtkBuff1: row.atk_buff1 ?? '',
|
||
|
|
skillAtkBuff2: row.atk_buff2 ?? '',
|
||
|
|
atkTypeHead: atkHead,
|
||
|
|
skillParts: extraParts.length > 0 ? extraParts : atkParts,
|
||
|
|
skillType: extraParts[0] ?? atkHead,
|
||
|
|
},
|
||
|
|
}];
|
||
|
|
stages.push(effects);
|
||
|
|
if (extraParts.length > 0 && i === hitCount - 1) {
|
||
|
|
const extraHead = extraParts[0] ?? 0;
|
||
|
|
if (extraHead !== 3 && extraHead !== 4 && extraHead !== 6 && extraHead !== 8) {
|
||
|
|
effects.push({
|
||
|
|
type: 'PlayPresentation',
|
||
|
|
params: {
|
||
|
|
cue: 'extraSkill',
|
||
|
|
skillParts: extraParts,
|
||
|
|
skillType: extraHead,
|
||
|
|
skillRaw: extraParts.join(','),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return stages;
|
||
|
|
}
|
||
|
|
|
||
|
|
const single = this._hitEffects(row, atkHead, atkParts, extraParts);
|
||
|
|
return single.length > 0 ? [single] : [];
|
||
|
|
}
|
||
|
|
|
||
|
|
private static _hitEffects(row: ITableSkill, atkHead: number, atkParts: number[], extraParts: number[]): EffectSpec[] {
|
||
|
|
const effects: EffectSpec[] = [];
|
||
|
|
const scale = this._resolveDamageScale(atkHead, atkParts, extraParts);
|
||
|
|
const dmgParams: Record<string, unknown> = {
|
||
|
|
scale,
|
||
|
|
skillAtkBuff1: row.atk_buff1 ?? '',
|
||
|
|
skillAtkBuff2: row.atk_buff2 ?? '',
|
||
|
|
atkTypeHead: atkHead,
|
||
|
|
skillParts: extraParts.length > 0 ? extraParts : atkParts,
|
||
|
|
skillType: extraParts[0] ?? atkHead,
|
||
|
|
};
|
||
|
|
|
||
|
|
// 召唤类:无直伤;timeline 由 SummonFromSkill 驱动
|
||
|
|
if (atkHead === 8 || atkHead === 11) {
|
||
|
|
return effects;
|
||
|
|
}
|
||
|
|
|
||
|
|
effects.push({ type: 'DealDamage', params: dmgParams });
|
||
|
|
|
||
|
|
// 额外效果标记。类型 3/4/6/8 不发 extraSkill cue
|
||
|
|
const extraHead = extraParts[0] ?? 0;
|
||
|
|
if (extraParts.length > 0 && extraHead !== 3 && extraHead !== 4 && extraHead !== 6 && extraHead !== 8) {
|
||
|
|
effects.push({
|
||
|
|
type: 'PlayPresentation',
|
||
|
|
params: {
|
||
|
|
cue: 'extraSkill',
|
||
|
|
skillParts: extraParts,
|
||
|
|
skillType: extraHead,
|
||
|
|
skillRaw: extraParts.join(','),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return effects;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static _resolveDamageScale(atkHead: number, atkParts: number[], extraParts: number[]): number {
|
||
|
|
switch (atkHead) {
|
||
|
|
case 1:
|
||
|
|
case 4:
|
||
|
|
return this._damageScale(atkParts[0] ?? 10000);
|
||
|
|
case 2:
|
||
|
|
return this._damageScale(atkParts[1] ?? 10000);
|
||
|
|
case 5:
|
||
|
|
return this._damageScale(atkParts[3] ?? atkParts[atkParts.length - 1] ?? 10000);
|
||
|
|
case 3:
|
||
|
|
return this._damageScale(atkParts[0] ?? 10000);
|
||
|
|
case 14:
|
||
|
|
return this._damageScale(atkParts[2] ?? 10000);
|
||
|
|
case 15:
|
||
|
|
return this._damageScale(atkParts[1] ?? 10000);
|
||
|
|
case 6:
|
||
|
|
return this._damageScale(atkParts[1] ?? 10000);
|
||
|
|
case 7:
|
||
|
|
return this._damageScale(atkParts[1] ?? 10000);
|
||
|
|
case 10:
|
||
|
|
return this._damageScale(atkParts[2] ?? 10000);
|
||
|
|
case 9:
|
||
|
|
return this._damageScale(atkParts[2] ?? 10000);
|
||
|
|
case 12:
|
||
|
|
case 13:
|
||
|
|
return this._damageScale(atkParts[2] ?? 10000);
|
||
|
|
default:
|
||
|
|
return this._damageScale(atkParts[atkParts.length - 1] ?? 10000);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|