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, isEarthChargeSkillId, isFlySwordSkillId, normalAbilityId, } from '../weapon1/Weapon1BattleIds'; /** 冰球术 Skill.id(表现层落地结算) */ const ICE_BALL_SKILL_ID = 305201; /** 技能槽位(驱动 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 / 16 / 18 / 20 总段数(10=连砸次数) */ hitCount?: number; /** atk_type=5 / 10 / 14 / 16 / 18 / 20 段间间隔(秒) */ hitIntervalSec?: number; /** atk_type=18 主目标圆 / 20 释放点圆 半径(像素) */ aroundPrimaryRadius?: number; /** atk_type=14 / 18 / 20 每段伤害万分比 */ damageWan?: 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(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; // 火 Boss 41101:写死不锁当前场上等级最高的数字 if (row.id === 41101) targeting.params.monsterSkipMaxLevel = true; // Skill.range<=0 的单体锁敌:全图索敌(金/火 Boss 等),避免仅靠 Monster.atkdistance 近战圈永远放不出 if (this._rangePx(row) <= 0 && (atkHead === 0 || atkHead === 1)) { targeting.selector = 'AllEnemies'; delete targeting.rangeRef; if (targeting.params) delete targeting.params.range; } } 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, }; } // 土冲锋:射程内锁附近怪;飞出后独立,不占普攻 if (kind !== 'monster' && extraHead === 1 && isEarthChargeSkillId(row.id)) { const rangePx = this._rangePx(row); return { id: abilityId, triggers: [this._activeTick(interval, kind, startOnCooldown)], costs, conditions: [{ type: 'HasTargetInRange' }], targeting: this._withAttackRangeFallback(rangePx, { team: 'enemy', selector: 'Nearest', shape: 'Single', maxCount: 1, params: { stageStrategy: row.target_stage || 1, stageRangePx: rangePx }, }), 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) : {}), ...(atkHead === 16 ? this._fanMultiHitParams(row, atkParts) : {}), ...(atkHead === 18 || atkHead === 20 ? this._circleMultiHitParams(row, atkParts, atkHead) : {}), }; } /** * atk_type=18 / 20:段数,间隔秒,半径像素,伤害万分比。 * @param row Skill 行 * @param atkParts atk_para 分段 * @param atkHead 攻击类型头 */ private static _circleMultiHitParams(row: ITableSkill, atkParts: number[], atkHead: number): Pick { 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 }; } /** * atk_type=16:段数=第1段,间隔=第2段(角度/系数供索敌与伤害用,不进表现 params)。 * @param row Skill 行 * @param atkParts atk_para 分段 */ private static _fanMultiHitParams(row: ITableSkill, atkParts: number[]): Pick { const parsed = this._parseFanMultiHit(row, atkParts); if (!parsed) return {}; return { hitCount: parsed.hitCount, hitIntervalSec: parsed.intervalSec }; } /** * atk_type=10 / 14:数量,间隔秒,伤害万分比。 * @param row Skill 行 * @param atkParts atk_para 分段 * @param atkHead 攻击类型头 */ private static _countIntervalHitParams(row: ITableSkill, atkParts: number[], atkHead: number): Pick { const parsed = this._parseCountIntervalWan(row, atkParts, atkHead); if (!parsed) return {}; return { hitCount: parsed.hitCount, hitIntervalSec: parsed.intervalSec, damageWan: parsed.wan }; } /** * 解析「数量,间隔秒,伤害万分比」。非法则 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_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 }; } /** * 怪物 atk_buff1/2(类型 1+atk_type3 圈晕、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 (head === 1 && this._atkHead(row) === 3) 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 & { 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 = { 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 // 16 扇形多段:atk_para=[段数,间隔秒,角度,伤害万分比];角度取第3段 // 索敌/命中圈用 Skill.range(≤0 则塔射程),勿把伤害万分比当半径 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); 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] 为半径扩圆 // 18 / 20 目标圆形多段:半径取 atk_para[2](开战锁主目标;伤害由表现层每段重扫) if (atkHead === 15 || atkHead === 18 || atkHead === 20) { const aroundRadius = atkHead === 15 ? Number(atkParts[0]) : Number(atkParts[2]); if (!(aroundRadius > 0)) { console.log(`[SkillAbilityBuilder] atk_type=${atkHead} 缺少半径`, 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 连发 / 17 不穿透散射:开战只锁主目标 if (atkHead === 4 || atkHead === 12 || atkHead === 13 || atkHead === 17) { 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 = { ...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 个(Skill.range≤0 用塔射程) 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 this._withAttackRangeFallback(rangePx, { team, selector: 'Nearest', shape: 'Single', maxCount, params: { ...baseParams, pickCount: maxCount }, }); } // 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 段 // 14 360°多段 / 18 / 20:每段按圆重新索敌结算,无 deferred 段 // 飞剑主动(atk_type=10 子集)/ 冰球术 305201:表现层落地结算,无 deferred 段 if (atkHead === 12 || atkHead === 13 || atkHead === 14 || atkHead === 17 || atkHead === 18 || atkHead === 19 || atkHead === 20 || isFlySwordSkillId(row.id) || row.id === ICE_BALL_SKILL_ID) { 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 === 16) { let hitCount = Math.max(1, Math.floor(atkParts[2] || 1)); if (atkHead === 10) { const parsed = this._parseCountIntervalWan(row, atkParts, atkHead); if (!parsed) return []; hitCount = parsed.hitCount; } if (atkHead === 16) { const parsed = this._parseFanMultiHit(row, atkParts); 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 = { 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 16: return this._damageScale(atkParts[3] ?? 10000); case 18: case 20: return this._damageScale(atkParts[3] ?? 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); case 19: return this._damageScale(atkParts[1] ?? 10000); default: return this._damageScale(atkParts[atkParts.length - 1] ?? 10000); } } }