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/config/baseAtk/SkillAbilityBuilder.ts

805 lines
33 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,
1 week ago
isFlySwordSkillId,
3 weeks ago
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;
2 weeks ago
/** atk_type=5 / 10 / 14 / 16 / 18 / 20 总段数(10=连砸次数) */
3 weeks ago
hitCount?: number;
2 weeks ago
/** atk_type=5 / 10 / 14 / 16 / 18 / 20 段间间隔(秒) */
3 weeks ago
hitIntervalSec?: number;
2 weeks ago
/** atk_type=18 / 20 主目标圆半径(像素) */
aroundPrimaryRadius?: number;
/** atk_type=18 / 20 每段伤害万分比 */
damageWan?: number;
3 weeks ago
}
/**
* 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) : {}),
2 weeks ago
...(atkHead === 16 ? this._fanMultiHitParams(row, atkParts) : {}),
2 weeks ago
...(atkHead === 18 || atkHead === 20 ? this._circleMultiHitParams(row, atkParts, atkHead) : {}),
3 weeks ago
};
}
2 weeks ago
/**
* atk_type=18 / 20:段数,间隔秒,半径像素,伤害万分比。
* @param row Skill 行
* @param atkParts atk_para 分段
* @param atkHead 攻击类型头
*/
private static _circleMultiHitParams(row: ITableSkill, atkParts: number[], atkHead: number): Pick<SkillPresentationParams, 'hitCount' | 'hitIntervalSec' | 'aroundPrimaryRadius' | 'damageWan'> {
const parsed = this._parseCircleMultiHit(row, atkParts, atkHead);
if (!parsed) return {};
return {
hitCount: parsed.hitCount,
hitIntervalSec: parsed.intervalSec,
aroundPrimaryRadius: parsed.radiusPx,
damageWan: parsed.wan,
};
}
/**
* 解析「段数,间隔秒,半径,伤害万分比」。非法则 warn 并返回 null。
* @param row Skill 行
* @param atkParts atk_para 分段
* @param atkHead 攻击类型头
*/
private static _parseCircleMultiHit(row: ITableSkill, atkParts: number[], atkHead: number): { hitCount: number; intervalSec: number; radiusPx: number; wan: number } | null {
const hitCount = Math.floor(atkParts[0] || 0);
const intervalSec = Number(atkParts[1]);
const radiusPx = Number(atkParts[2]);
const wan = Number(atkParts[3]);
if (atkParts.length < 4 || !(hitCount > 0) || !Number.isFinite(intervalSec) || intervalSec < 0 || !(radiusPx > 0) || !Number.isFinite(wan)) {
console.log(`[SkillAbilityBuilder] atk_type=${atkHead} 参数须为「段数,间隔秒,半径,伤害万分比」`, row.id, row.atk_para);
return null;
}
return { hitCount, intervalSec, radiusPx, wan };
}
2 weeks ago
/**
* atk_type=16:段数=第1段,间隔=第2段(角度/系数供索敌与伤害用,不进表现 params)。
* @param row Skill 行
* @param atkParts atk_para 分段
*/
private static _fanMultiHitParams(row: ITableSkill, atkParts: number[]): Pick<SkillPresentationParams, 'hitCount' | 'hitIntervalSec'> {
const parsed = this._parseFanMultiHit(row, atkParts);
if (!parsed) return {};
return { hitCount: parsed.hitCount, hitIntervalSec: parsed.intervalSec };
}
3 weeks ago
/**
* 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 };
}
2 weeks ago
/**
* atk_type=16 扇形多段:atk_para=伤害段数,伤害间隔秒,角度,伤害万分比。
* 非法则 warn 并返回 null。
* @param row Skill 行
* @param atkParts atk_para 分段
*/
private static _parseFanMultiHit(row: ITableSkill, atkParts: number[]): { hitCount: number; intervalSec: number; angleDeg: number; wan: number } | null {
const hitCount = Math.floor(atkParts[0] || 0);
const intervalSec = Number(atkParts[1]);
const angleDeg = Number(atkParts[2]);
const wan = Number(atkParts[3]);
if (atkParts.length < 4 || !(hitCount > 0) || !Number.isFinite(intervalSec) || intervalSec < 0 || !(angleDeg > 0) || !Number.isFinite(wan)) {
console.log('[SkillAbilityBuilder] atk_type=16 参数须为「伤害段数,伤害间隔秒,角度,伤害万分比」', row.id, row.atk_para);
return null;
}
return { hitCount, intervalSec, angleDeg, wan };
}
3 weeks ago
/**
* 怪物 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
2 weeks ago
// 16 扇形多段:atk_para=[段数,间隔秒,角度,伤害万分比];角度取第3段
3 weeks ago
// 索敌/命中圈用 Skill.range(≤0 则塔射程),勿把伤害万分比当半径
2 weeks ago
if (atkHead === 3 || atkHead === 6 || atkHead === 14 || atkHead === 16) {
let angle = 360;
if (atkHead === 6) angle = Math.max(1, atkParts[0] || 90);
else if (atkHead === 16) angle = Math.max(1, atkParts[2] || 60);
3 weeks ago
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] 为半径扩圆
2 weeks ago
// 18 / 20 目标圆形多段:半径取 atk_para[2]
if (atkHead === 15 || atkHead === 18 || atkHead === 20) {
const aroundRadius = atkHead === 15 ? Number(atkParts[0]) : Number(atkParts[2]);
3 weeks ago
if (!(aroundRadius > 0)) {
2 weeks ago
console.log(`[SkillAbilityBuilder] atk_type=${atkHead} 缺少半径`, row.id, row.atk_para);
3 weeks ago
}
return this._withAttackRangeFallback(rangePx, {
team,
selector: 'Nearest',
shape: 'Circle',
maxCount: 64,
params: {
...baseParams,
aroundPrimaryRadius: aroundRadius > 0 ? aroundRadius : 0,
pickCount: 32,
},
});
}
2 weeks ago
// 4 抛物线 / 12 散射瞄准 / 13 连发 / 17 不穿透散射:开战只锁主目标
if (atkHead === 4 || atkHead === 12 || atkHead === 13 || atkHead === 17) {
3 weeks ago
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,
};
}
// 19 全屏直线冰锥:atk_para=数量,伤害万分比;按威胁锁最多 N 个全屏敌
if (atkHead === 19) {
const maxCount = Math.max(0, Math.floor(atkParts[0] || 0));
const wan = Number(atkParts[1]);
if (!(maxCount > 0) || !Number.isFinite(wan)) {
console.log('[SkillAbilityBuilder] atk_type=19 参数须为「数量,伤害万分比」', row.id, row.atk_para);
}
return {
team,
selector: 'AllEnemies',
shape: 'Single',
maxCount,
params: { ...baseParams, pickCount: maxCount },
};
}
3 weeks ago
// 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,
},
}]];
}
// 12 散射 / 13 连发 / 17 不穿透散射 / 19 冰锥:伤害由 BowAtkHost 弹道结算,无 deferred 段
2 weeks ago
// 18 丢出独立持续伤:伤害由 ThrownCircleHost 结算,无 deferred 段
1 week ago
// 飞剑主动(atk_type=10 子集):落地结算,无 deferred 段
if (atkHead === 12 || atkHead === 13 || atkHead === 17 || atkHead === 18 || atkHead === 19
|| isFlySwordSkillId(row.id)) {
3 weeks ago
return [];
}
if ((extraParts[0] ?? 0) === 1 || (extraParts[0] ?? 0) === 2 || (extraParts[0] ?? 0) === 6 || (extraParts[0] ?? 0) === 7) {
return [];
}
2 weeks ago
if (atkHead === 5 || atkHead === 10 || atkHead === 14 || atkHead === 16 || atkHead === 20) {
3 weeks ago
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;
}
2 weeks ago
if (atkHead === 16) {
const parsed = this._parseFanMultiHit(row, atkParts);
if (!parsed) return [];
hitCount = parsed.hitCount;
}
2 weeks ago
if (atkHead === 20) {
const parsed = this._parseCircleMultiHit(row, atkParts, atkHead);
if (!parsed) return [];
hitCount = parsed.hitCount;
}
3 weeks ago
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);
2 weeks ago
case 16:
return this._damageScale(atkParts[3] ?? 10000);
2 weeks ago
case 18:
case 20:
return this._damageScale(atkParts[3] ?? 10000);
3 weeks ago
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);
case 19:
return this._damageScale(atkParts[1] ?? 10000);
3 weeks ago
default:
return this._damageScale(atkParts[atkParts.length - 1] ?? 10000);
}
}
}