diff --git a/assets/bundles/first/config/Skill.json b/assets/bundles/first/config/Skill.json index b506836..bec0b3c 100644 --- a/assets/bundles/first/config/Skill.json +++ b/assets/bundles/first/config/Skill.json @@ -916,7 +916,7 @@ "range": 0, "atk_type": "10", "fly": "", - "atk_para": "5,15000", + "atk_para": "5,0,15000", "atk_buff1": "", "atk_buff2": "", "skill": "", @@ -982,7 +982,7 @@ "range": 0, "atk_type": "10", "fly": "", - "atk_para": "6,20000", + "atk_para": "6,0,20000", "atk_buff1": "", "atk_buff2": "", "skill": "", @@ -1048,7 +1048,7 @@ "range": 0, "atk_type": "10", "fly": "", - "atk_para": "7,25000", + "atk_para": "7,0,25000", "atk_buff1": "", "atk_buff2": "", "skill": "", @@ -1114,7 +1114,7 @@ "range": 0, "atk_type": "10", "fly": "", - "atk_para": "8,30000", + "atk_para": "8,0,30000", "atk_buff1": "", "atk_buff2": "", "skill": "", diff --git a/assets/mx/module/battle/combat/AbilitySystem.ts b/assets/mx/module/battle/combat/AbilitySystem.ts index a4baa43..1958e51 100644 --- a/assets/mx/module/battle/combat/AbilitySystem.ts +++ b/assets/mx/module/battle/combat/AbilitySystem.ts @@ -223,7 +223,7 @@ export class AbilitySystem implements ISystem { if (this._services.castBlockPredicate?.(entityId)) { return false; } - if (this._services.buffs.hasTag(entityId, 'debuff_8')) { + if (this._services.buffs.hasTag(entityId, 'debuff_8') || this._services.buffs.hasTag(entityId, 'debuff_13')) { return false; } const abilities = this._world.getComponent(entityId, AbilitySetComp); diff --git a/assets/mx/module/battle/ecs/Components.ts b/assets/mx/module/battle/ecs/Components.ts index 5fd46d2..dbdc723 100644 --- a/assets/mx/module/battle/ecs/Components.ts +++ b/assets/mx/module/battle/ecs/Components.ts @@ -83,14 +83,14 @@ export class BuffContainerComp extends EcsComponent { static readonly ecsTypeId = 'mx.BuffContainerComp'; constructor(public buffs: BuffInstance[] = []) { super(); } - /** MovementSystem O(1):是否定身(debuff_4 眩晕 / debuff_bind_net 缚仙网 / debuff_fire_trap 三昧真火,移速归零) */ + /** MovementSystem O(1):是否定身(眩晕 / 缚仙网 / 三昧真火 / 冰冻,移速归零) */ hasDebuff4 = false; /** buff 列表变更后同步定身标记 */ refreshDebuff4Flag(): void { for (let i = 0; i < this.buffs.length; i++) { const id = this.buffs[i].buffId; - if (id === 'debuff_4' || id === 'debuff_bind_net' || id === 'debuff_fire_trap') { + if (id === 'debuff_4' || id === 'debuff_bind_net' || id === 'debuff_fire_trap' || id === 'debuff_13') { this.hasDebuff4 = true; return; } diff --git a/assets/script/battle/combat/PassiveSkillRuntime.ts b/assets/script/battle/combat/PassiveSkillRuntime.ts index b7225cb..71e300e 100644 --- a/assets/script/battle/combat/PassiveSkillRuntime.ts +++ b/assets/script/battle/combat/PassiveSkillRuntime.ts @@ -81,6 +81,8 @@ export enum PassiveResultKind { SwordWave = 11, /** 闪电链:蔓延范围像素,蔓延数量,伤害系数万分比 */ LightningChain = 12, + /** 分裂冰锥:索敌半径像素,分裂数量,伤害系数万分比 */ + IceConeSplit = 13, /** 爆炸:圆形半径像素,伤害系数万分比 */ Explosion = 14, } @@ -103,6 +105,10 @@ const SWORD_WAVE_DMG = 'passive_sword_wave'; const LIGHTNING_CHAIN_DMG = 'passive_lightning_chain'; /** 爆炸伤害类型(不回触发 condition) */ const EXPLOSION_DMG = 'passive_explosion'; +/** 分裂冰锥伤害类型(不回触发 condition) */ +const ICE_CONE_DMG = 'passive_ice_cone'; +/** 分裂冰锥飞行像素/秒(与水弹一致) */ +const ICE_CONE_SPEED = 1280; /** * 是否为被动 result 造成的伤害(不回触发任何 condition)。 @@ -122,6 +128,8 @@ export const LINE_DRAGON_EVENT = 'LineDragonApplied'; export const SWORD_WAVE_EVENT = 'SwordWaveApplied'; /** 闪电链表现事件名 */ export const LIGHTNING_CHAIN_EVENT = 'LightningChainApplied'; +/** 分裂冰锥表现事件名 */ +export const ICE_CONE_SPLIT_EVENT = 'IceConeSplitApplied'; /** 直线火龙预制体宽 100,命中半长(再加半格占地) */ const LINE_DRAGON_HALF_LEN = 50; /** 直线火龙预制体高 30,命中半宽(再加半格占地) */ @@ -341,6 +349,38 @@ interface LineDragonFlight { hitIds: EntityId[]; } +/** 分裂冰锥一枚(待落地) */ +interface IceConePending { + /** 施法者 */ + caster: EntityId; + /** 锁定目标 */ + target: EntityId; + /** Skill.id */ + skillId: number; + /** 伤害倍率 */ + scale: number; + /** 距落地(秒) */ + wait: number; + /** atk_buff1 */ + buff1: string; + /** atk_buff2 */ + buff2: string; +} + +/** 分裂冰锥表现 cue(一枚) */ +export interface IceConeSplitCue { + /** 起点逻辑 X */ + fromX: number; + /** 起点逻辑 Y */ + fromY: number; + /** 终点逻辑 X */ + toX: number; + /** 终点逻辑 Y */ + toY: number; + /** 飞行秒 */ + duration: number; +} + /** 待按间隔放出的剑气 */ interface SwordWavePending { /** 施法者 */ @@ -423,6 +463,7 @@ export class PassiveSkillHost { private readonly _wavePending: SwordWavePending[] = []; private readonly _waves: SwordWaveFlight[] = []; private readonly _chains: LightningChainRun[] = []; + private readonly _iceCones: IceConePending[] = []; private _session: BattleSession | null = null; /** result 结算中(含其产生的伤害/击杀),禁止再判任何 condition */ private _resultGate = 0; @@ -446,6 +487,7 @@ export class PassiveSkillHost { this._wavePending.length = 0; this._waves.length = 0; this._chains.length = 0; + this._iceCones.length = 0; this._session = null; this._atkOverride = null; } @@ -460,6 +502,7 @@ export class PassiveSkillHost { this._removeZonesByCaster(entityId); this._removeWavesByCaster(entityId); this._removeChainsByCaster(entityId); + this._removeIceConesByCaster(entityId); const idx = this._indexOf(entityId); if (idx >= 0) this._binds.splice(idx, 1); } @@ -518,6 +561,7 @@ export class PassiveSkillHost { this._tickSwordWavePending(dt); this._tickSwordWaves(dt); this._tickLightningChains(dt); + this._tickIceCones(dt); } /** @@ -781,6 +825,9 @@ export class PassiveSkillHost { case PassiveResultKind.LightningChain: this._applyLightningChain(session, caster, target, bind.skillId, p); return; + case PassiveResultKind.IceConeSplit: + this._applyIceConeSplit(session, caster, target, bind, p); + return; case PassiveResultKind.Explosion: this._applyExplosion(session, caster, target, bind, p); return; @@ -1409,6 +1456,134 @@ export class PassiveSkillHost { return false; } + /** + * 分裂冰锥:从触发目标向半径内其它敌发射最多 N 枚,落地伤并施加行上 atk_buff。 + * @param session 战斗会话 + * @param caster 施法者 + * @param target 触发目标 + * @param bind 被动绑定 + * @param p 索敌半径, 分裂数量, 伤害万分比 + */ + private _applyIceConeSplit(session: BattleSession, caster: EntityId, target: EntityId, bind: PassiveSkillBind, p: number[]): void { + const radius = p[0]; + const count = Math.floor(p[1]); + const coeffWan = p[2]; + if (!(radius > 0) || !(count > 0) || !(coeffWan > 0)) { + console.log(`[PassiveSkill] result 13 参数无效 skill=${bind.skillId}`, p.join(',')); + return; + } + const origin = this._logicPos(session, target > 0 ? target : caster) + ?? this._logicPos(session, caster); + if (!origin) { + console.log(`[PassiveSkill] result 13 无落点 skill=${bind.skillId}`); + return; + } + const casterTeam = session.world.getComponent(caster, TeamComp); + const cell = session.grid.worldToCell(origin.x, origin.y); + const radiusCells = radius / Math.max(1, session.grid.cellSize); + const ids = session.spatial.queryRadius(cell, radiusCells + 1); + const r2 = radius * radius; + const candidates: { id: EntityId; dist2: number }[] = []; + for (let i = 0; i < ids.length; i++) { + const id = ids[i]; + if (id === caster || id === target) continue; + if (!session.world.isAlive(id)) continue; + const hp = session.world.getComponent(id, HealthComp); + if (!hp || !(hp.hp > 0)) continue; + const move = session.world.getComponent(id, MovementComp); + if (move?.spawnProtected || isYanSoulClaimed(move) || isBajiaoBlowRetreating(move)) continue; + if (!BattleTeamId.isHostile(casterTeam, session.world.getComponent(id, TeamComp))) continue; + const pos = this._logicPos(session, id); + if (!pos) continue; + const dx = pos.x - origin.x; + const dy = pos.y - origin.y; + const dist2 = dx * dx + dy * dy; + if (dist2 > r2) continue; + candidates.push({ id, dist2 }); + } + if (candidates.length === 0) return; + this._sortByDist2(candidates); + const scale = coeffWan / 10000; + const buff1 = String(bind.atkBuff1 ?? '').trim(); + const buff2 = String(bind.atkBuff2 ?? '').trim(); + const n = Math.min(count, candidates.length); + for (let i = 0; i < n; i++) { + const tid = candidates[i].id; + const aim = this._logicPos(session, tid); + if (!aim) continue; + const dx = aim.x - origin.x; + const dy = aim.y - origin.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const duration = Math.max(0.12, dist / ICE_CONE_SPEED); + this._iceCones.push({ + caster, + target: tid, + skillId: bind.skillId, + scale, + wait: duration, + buff1, + buff2, + }); + const cue: IceConeSplitCue = { + fromX: origin.x, + fromY: origin.y, + toX: aim.x, + toY: aim.y, + duration, + }; + session.events.emit(BattleEventFactory.custom(ICE_CONE_SPLIT_EVENT, cue)); + } + } + + /** + * 推进分裂冰锥落地。 + * @param dt 逻辑秒 + */ + private _tickIceCones(dt: number): void { + if (!(dt > 0) || this._iceCones.length <= 0) return; + const session = this._session; + if (!session) return; + for (let i = this._iceCones.length - 1; i >= 0; i--) { + const d = this._iceCones[i]; + d.wait -= dt; + if (d.wait > 0) continue; + this._iceCones.splice(i, 1); + if (!session.world.isAlive(d.caster) || !session.world.isAlive(d.target)) continue; + const hp = session.world.getComponent(d.target, HealthComp); + if (!hp || !(hp.hp > 0)) continue; + const move = session.world.getComponent(d.target, MovementComp); + if (move?.spawnProtected || isYanSoulClaimed(move) || isBajiaoBlowRetreating(move)) continue; + this._dealResultDamage(session, d.caster, d.target, d.skillId, d.scale, ICE_CONE_DMG); + if (!d.buff1 && !d.buff2) continue; + if (!session.world.isAlive(d.target)) continue; + const abilityCtx = createAbilityContext(session, d.caster, `passive_${d.skillId}`, [d.target]); + SkillAtkBuffExecutor.applyPair(session, abilityCtx, d.target, d.buff1, d.buff2, { fromPassiveResult: true }); + } + } + + /** 卸下施法者时清其分裂冰锥 */ + private _removeIceConesByCaster(caster: EntityId): void { + for (let i = this._iceCones.length - 1; i >= 0; i--) { + if (this._iceCones[i].caster === caster) this._iceCones.splice(i, 1); + } + } + + /** + * 按 dist2 升序(插入排序,避免函数内回调)。 + * @param list 候选 + */ + private _sortByDist2(list: { id: EntityId; dist2: number }[]): void { + for (let i = 1; i < list.length; i++) { + const cur = list[i]; + let j = i - 1; + while (j >= 0 && list[j].dist2 > cur.dist2) { + list[j + 1] = list[j]; + j -= 1; + } + list[j + 1] = cur; + } + } + /** * 爆炸:以触发目标为圆心圈伤;行上 atk_buff 非空时对命中目标施加。 * @param session 战斗会话 diff --git a/assets/script/battle/combat/SkillAtkBuffExecutor.ts b/assets/script/battle/combat/SkillAtkBuffExecutor.ts index eb78eae..8ace42f 100644 --- a/assets/script/battle/combat/SkillAtkBuffExecutor.ts +++ b/assets/script/battle/combat/SkillAtkBuffExecutor.ts @@ -101,8 +101,8 @@ export class SkillAtkBuffExecutor { private static _applyLoadoutRaw(services: BattleServices, entity: EntityId, raw: string): void { const parts = this._parse(raw); if (!parts.length) return; - // 11 射程已计入 setup;14 点燃仅命中施加 - if (parts[0] === 11 || parts[0] === 14) return; + // 11 射程已计入 setup;13 冰冻 / 14 点燃仅命中施加 + if (parts[0] === 11 || parts[0] === 13 || parts[0] === 14) return; this._applyKind(services, null, entity, parts, {}); } @@ -189,6 +189,9 @@ export class SkillAtkBuffExecutor { case 11: this._attackRange(services, target, parts[1] ?? 0, parts[2] ?? -1); break; + case 13: + this._freeze(services, ctx, target, parts); + break; case 14: this._ignite(services, ctx, target, parts, opts); break; @@ -198,6 +201,29 @@ export class SkillAtkBuffExecutor { } } + /** + * 冰冻:定身 + 禁施法(debuff_13)。 + * @param services 战斗服务 + * @param ctx 施法上下文 + * @param target 目标 + * @param parts `13,概率,冰冻秒` + */ + private static _freeze( + services: BattleServices, + ctx: AbilityContext | null, + target: EntityId, + parts: number[], + ): void { + const duration = Math.max(0, Number(parts[2]) || 0); + if (!(duration > 0)) return; + if (!services.config.getBuff('debuff_13')) { + console.log('[SkillAtkBuff] 缺 buff debuff_13'); + return; + } + const caster = ctx?.caster ?? target; + services.buffs.add(target, 'debuff_13', caster, 1, { duration }); + } + /** * 点燃:立即首跳,再按间隔跳至结束。 * @param services 战斗服务 diff --git a/assets/script/battle/config/ChapterLevelBuilder.ts b/assets/script/battle/config/ChapterLevelBuilder.ts index 3f553cb..3870d6d 100644 --- a/assets/script/battle/config/ChapterLevelBuilder.ts +++ b/assets/script/battle/config/ChapterLevelBuilder.ts @@ -59,8 +59,6 @@ export interface WaveIntervalConfig { /** 双方基地默认血量(后续养成覆盖) */ export const DEFAULT_BATTLE_WALL_HP = 3; -/** 开战初始桃子(写死,不读 Level.startnumber) */ -const START_PEACH = 1000; /** 单怪出怪间隔默认秒数(常量表无对应行) */ const DEFAULT_MONSTER_SPAWN_INTERVAL_SEC = 1; /** 对齐 Const 表 id=10(ConstNumType.WaveInterval),避免从 ConfigProjectData 拉环 */ @@ -77,7 +75,11 @@ export class ChapterLevelBuilder { } const difficultyRow = Math.max(1, options.rankDifficultyBonus ?? (Number(levelRow.diffcult) || 1)); - const startGold = START_PEACH; + const startGold = Math.floor(Number(levelRow.startnumber)); + if (!(startGold >= 0) || !Number.isFinite(startGold)) { + console.error('[ChapterLevelBuilder] Level.startnumber 无效', options.levelId, levelRow.startnumber); + return null; + } const spawnInterval = this.resolveMonsterSpawnIntervalSec(options.spawnInterval); const waveTiming = this.resolveWaveIntervalConfig(); const firstWaveDelay = options.firstWaveDelay ?? waveTiming.firstWaveDelaySec; diff --git a/assets/script/battle/config/DebuffBuffBuilder.ts b/assets/script/battle/config/DebuffBuffBuilder.ts index d2ce8b9..fe2d195 100644 --- a/assets/script/battle/config/DebuffBuffBuilder.ts +++ b/assets/script/battle/config/DebuffBuffBuilder.ts @@ -66,5 +66,12 @@ export function buildBasicDebuffDefs(): BuffDef[] { category: 'confusion', removeGroup: 'debuff_8', }, + { + id: 'debuff_13', + duration: 3, + polarity: 'negative', + category: 'freeze', + removeGroup: 'debuff_13', + }, ]; } diff --git a/assets/script/battle/config/baseAtk/SkillAbilityBuilder.ts b/assets/script/battle/config/baseAtk/SkillAbilityBuilder.ts index 6201674..9ba91f5 100644 --- a/assets/script/battle/config/baseAtk/SkillAbilityBuilder.ts +++ b/assets/script/battle/config/baseAtk/SkillAbilityBuilder.ts @@ -593,6 +593,21 @@ export class SkillAbilityBuilder { 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 }, + }; + } // 8 / 11 召唤:可无目标 if (atkHead === 8 || atkHead === 11) { return { @@ -647,9 +662,9 @@ export class SkillAbilityBuilder { }]]; } - // 12 散射 / 13 连发 / 17 不穿透散射:伤害由 BowAtkHost 弹道结算,无 deferred 段 + // 12 散射 / 13 连发 / 17 不穿透散射 / 19 冰锥:伤害由 BowAtkHost 弹道结算,无 deferred 段 // 18 丢出独立持续伤:伤害由 ThrownCircleHost 结算,无 deferred 段 - if (atkHead === 12 || atkHead === 13 || atkHead === 17 || atkHead === 18) { + if (atkHead === 12 || atkHead === 13 || atkHead === 17 || atkHead === 18 || atkHead === 19) { return []; } @@ -777,6 +792,8 @@ export class SkillAbilityBuilder { 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); } diff --git a/assets/script/battle/presentation/AtkTypePresenters.ts b/assets/script/battle/presentation/AtkTypePresenters.ts index 847ce1e..6c2886a 100644 --- a/assets/script/battle/presentation/AtkTypePresenters.ts +++ b/assets/script/battle/presentation/AtkTypePresenters.ts @@ -269,6 +269,49 @@ export class FallbackAtkPresenter implements ISkillPresenter { } } +/** + * atk_type=19 全屏直线冰锥:瞬发,对锁定的最多 N 个敌各发一枚水弹,落地结算。 + */ +export class IceConeAtkPresenter implements ISkillPresenter { + present(ctx: SkillPresentationContext, host: Skill): void { + if (!ctx.isAlive) { + ctx.finish(); + return; + } + const raw = String(ctx.params.atkPara ?? '').split(','); + const count = Math.floor(Number(String(raw[0] ?? '').trim()) || 0); + const wan = Number(String(raw[1] ?? '').trim()); + if (!(count > 0) || !Number.isFinite(wan) || !(wan > 0)) { + console.log('[IceConeAtkPresenter] atk_para 无效', ctx.params.skillId, ctx.params.atkPara); + ctx.finish(); + return; + } + const skillKind = String(ctx.params.skillKind ?? ''); + const followWeapon = skillKind === 'weapon' || host.visualChannel.useWeapon; + if (followWeapon) host.visualChannel.ensureWeapon(); + const seq = host.beginPresentationCast(ctx); + if (!(seq > 0)) return; + host.disarmAnimComplete(); + const scale = wan / 10000; + const buff1 = String(ctx.params.buff1 ?? ''); + const buff2 = String(ctx.params.buff2 ?? ''); + const targets = ctx.targets; + let launched = 0; + for (let i = 0; i < targets.length && launched < count; i++) { + const tid = targets[i]; + if (!(tid > 0)) continue; + if (host.visualChannel.launchIceCone(ctx.caster, ctx.abilityId, tid, scale, buff1, buff2)) { + launched += 1; + } + } + if (launched <= 0) { + console.log('[IceConeAtkPresenter] 无有效发射', ctx.params.skillId); + } + ctx.releasePresentationLock(); + host.finishPresentationCast(seq, false); + } +} + /** * atk_type=12 穿透散射 / 17 不穿透散射:出手时刻扇出直线箭,伤害由 BowAtkHost 结算。 */ diff --git a/assets/script/battle/presentation/SkillPresenterRouter.ts b/assets/script/battle/presentation/SkillPresenterRouter.ts index 4043790..c8abb5b 100644 --- a/assets/script/battle/presentation/SkillPresenterRouter.ts +++ b/assets/script/battle/presentation/SkillPresenterRouter.ts @@ -3,6 +3,7 @@ import type { SkillPresentationContext } from '../skill/SkillPresentationTypes'; import type { ISkillPresenter } from './SkillPresenter'; import { FallbackAtkPresenter, + IceConeAtkPresenter, InstantAtkPresenter, LineMultiHitAtkPresenter, LineRectAtkPresenter, @@ -139,6 +140,7 @@ export class SkillPresenterRouter { this.registerAtkType(17, new ScatterPierceAtkPresenter()); this.registerAtkType(13, new VolleyArrowAtkPresenter()); this.registerAtkType(18, new ThrownCircleMultiHitAtkPresenter()); + this.registerAtkType(19, new IceConeAtkPresenter()); this.registerAtkType(20, new SegmentAnimMultiHitAtkPresenter()); } diff --git a/assets/script/battle/presentation/SkillVisualChannel.ts b/assets/script/battle/presentation/SkillVisualChannel.ts index 12abf87..1b54020 100644 --- a/assets/script/battle/presentation/SkillVisualChannel.ts +++ b/assets/script/battle/presentation/SkillVisualChannel.ts @@ -1170,6 +1170,25 @@ export class SkillVisualChannel { * @param aimWorld 瞄准点(目标死后用最后坐标) * @returns 是否发出 */ + /** + * atk_type=19 冰锥:直线水弹飞向目标,落地结算(不依赖法杖判定)。 + * @param caster 施法者 + * @param abilityId Ability id + * @param target 锁定目标 + * @param scale 伤害倍率 + * @param buff1 atk_buff1 + * @param buff2 atk_buff2 + * @returns 是否发出 + */ + launchIceCone(caster: EntityId, abilityId: string, target: EntityId, scale: number, buff1: string, buff2: string): boolean { + const aim = target > 0 ? SkillPresentationEnv.getTargetWorldPos(target) : null; + if (!aim) { + console.log('[SkillVisualChannel] 冰锥无目标坐标', target); + return false; + } + return this._launchWaterBall(caster, abilityId, target, scale, buff1, buff2, aim); + } + launchVolleyShot(caster: EntityId, abilityId: string, target: EntityId, scale: number, buff1: string, buff2: string, shotIndex: number, aimWorld: Readonly): boolean { if (this._isStaffWeapon()) { return this._launchWaterBall(caster, abilityId, target, scale, buff1, buff2, aimWorld); @@ -1389,7 +1408,10 @@ export class SkillVisualChannel { private _launchWaterBall(caster: EntityId, abilityId: string, target: EntityId, scale: number, buff1: string, buff2: string, aimWorld: Readonly): boolean { this._weaponCasting = true; const fromNode = this._weapon.skinAnim?.node ?? this._weapon.aim ?? this._weapon.root; - if (!fromNode?.isValid) { + const fromWorld = fromNode?.isValid + ? fromNode.worldPosition + : SkillPresentationEnv.getCasterGridCenter(caster); + if (!fromWorld) { console.log('[SkillVisualChannel] 水弹无起点'); return false; } @@ -1399,7 +1421,7 @@ export class SkillVisualChannel { console.log('[SkillVisualChannel] 水弹无目标坐标', target); return false; } - this._arrowFrom.set(fromNode.worldPosition); + this._arrowFrom.set(fromWorld); this._arrowTo.set(to); const dist = Vec3.distance(this._arrowFrom, this._arrowTo); const duration = Math.max(0.12, dist / WATER_BALL_SPEED); diff --git a/assets/script/battle/view/CocosBattleCombatPresenter.ts b/assets/script/battle/view/CocosBattleCombatPresenter.ts index 7e9dbab..36026d4 100644 --- a/assets/script/battle/view/CocosBattleCombatPresenter.ts +++ b/assets/script/battle/view/CocosBattleCombatPresenter.ts @@ -20,16 +20,19 @@ import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback'; import { SkillTransformDriver, computeAimAngleDeg } from '../skill/SkillTransformDriver'; import { HOMING_DRAGON_EVENT, + ICE_CONE_SPLIT_EVENT, LINE_DRAGON_EVENT, SWORD_ARRAY_EVENT, SWORD_WAVE_EVENT, LIGHTNING_CHAIN_EVENT, type HomingDragonCue, + type IceConeSplitCue, type LineDragonCue, type SwordArrayCue, type SwordWaveCue, type LightningChainCue, } from '../combat/PassiveSkillRuntime'; +import { BattleMapSpec } from '../map/BattleMapSpec'; import { traceCombatDamage } from 'db://assets/mx/tools/CombatDamageTrace'; type MonsterView = IBattleMonsterView & { @@ -63,11 +66,16 @@ const BURN_BUFF_ID = 'debuff_1'; const BLEED_BUFF_ID = 'debuff_2'; const GHOST_BITE_BUFF_ID = 'debuff_6'; const ELEC_BUFF_ID = 'debuff_7'; +const FREEZE_BUFF_ID = 'debuff_13'; const BIND_NET_BUFF_ID = 'debuff_bind_net'; const FIRE_TRAP_BUFF_ID = 'debuff_fire_trap'; const KNOCKBACK_APPLIED_EVENT = 'KnockbackApplied'; const SWORD_ARRAY_PREFAB = '剑阵'; const SWORD_ARRAY_PREFAB_PATH = 'prefab/skill/'; +/** 分裂冰锥表现用「水弹」 */ +const ICE_CONE_PREFAB = '水弹'; +/** 水弹预制体原图宽约 322,缩到约一格 */ +const ICE_CONE_SRC_W = 322; /** 剑阵根节点参考边长(预制体 UITransform 200,直径=边长) */ const SWORD_ARRAY_REF_SIDE = 200; const HOMING_DRAGON_PREFAB = '追踪火龙'; @@ -106,8 +114,11 @@ export class CocosBattleCombatPresenter { private readonly _lineDragonFx: Node[] = []; private readonly _swordWaveFx: Node[] = []; private readonly _lightningFx: Node[] = []; + private readonly _iceConeFx: Node[] = []; private readonly _dragonFrom = new Vec3(); private readonly _dragonTo = new Vec3(); + private readonly _iceConeFrom = new Vec3(); + private readonly _iceConeTo = new Vec3(); bind(battle: ProjectBattleHandle, monsterPresenter: CocosBattleMonsterPresenter, displayRoot: Node): void { this.unbind(); @@ -126,6 +137,7 @@ export class CocosBattleCombatPresenter { this._recycleAllLineDragons(); this._recycleAllSwordWaves(); this._recycleAllLightning(); + this._recycleAllIceCones(); if (this._battle && this._listener) { this._battle.off(BATTLE_EVENT_ALL, this._listener); } @@ -173,6 +185,8 @@ export class CocosBattleCombatPresenter { this._onStunApplied(event.target, event.duration); } else if (event.buffId === SLOW_BUFF_ID) { this._onSlowApplied(event.target, event.duration); + } else if (event.buffId === FREEZE_BUFF_ID) { + this._onFreezeApplied(event.target, event.duration); } else if (event.buffId === BURN_BUFF_ID) { this._onBurnApplied(event.target, event.duration); } else if (event.buffId === BLEED_BUFF_ID) { @@ -192,6 +206,8 @@ export class CocosBattleCombatPresenter { this._onStunRemoved(event.target); } else if (event.buffId === SLOW_BUFF_ID) { this._onSlowRemoved(event.target); + } else if (event.buffId === FREEZE_BUFF_ID) { + this._onFreezeRemoved(event.target); } else if (event.buffId === BURN_BUFF_ID) { this._onBurnRemoved(event.target); } else if (event.buffId === BLEED_BUFF_ID) { @@ -205,6 +221,7 @@ export class CocosBattleCombatPresenter { case BattleEventType.EntityKilled: this._onStunRemoved(event.victim); this._onSlowRemoved(event.victim); + this._onFreezeRemoved(event.victim); this._onBurnRemoved(event.victim); this._onBleedRemoved(event.victim); this._onGhostBiteRemoved(event.victim); @@ -213,6 +230,7 @@ export class CocosBattleCombatPresenter { case BattleEventType.UnitDespawned: this._onStunRemoved(event.entity); this._onSlowRemoved(event.entity); + this._onFreezeRemoved(event.entity); this._onBurnRemoved(event.entity); this._onBleedRemoved(event.entity); this._onGhostBiteRemoved(event.entity); @@ -234,6 +252,8 @@ export class CocosBattleCombatPresenter { this._playSwordWave(event.payload as SwordWaveCue | undefined); } else if (event.name === LIGHTNING_CHAIN_EVENT) { this._playLightningChain(event.payload as LightningChainCue | undefined); + } else if (event.name === ICE_CONE_SPLIT_EVENT) { + this._playIceConeSplit(event.payload as IceConeSplitCue | undefined); } break; } @@ -261,6 +281,18 @@ export class CocosBattleCombatPresenter { this._getMonsterView(target)?.clearSlowFx?.(); } + /** 冰冻:复用怪_冰表现并飘「冰冻」 */ + private _onFreezeApplied(target: EntityId, duration: number): void { + const monster = this._getMonsterView(target); + if (!monster || !Number.isFinite(duration) || duration <= 0) return; + monster.showSlowFx?.(Math.max(0.05, duration)); + this._playStatusText(target, '冰冻'); + } + + private _onFreezeRemoved(target: EntityId): void { + this._getMonsterView(target)?.clearSlowFx?.(); + } + private _onBurnApplied(target: EntityId, duration: number): void { const monster = this._getMonsterView(target); if (!monster || !Number.isFinite(duration) || duration <= 0) return; @@ -873,6 +905,91 @@ export class CocosBattleCombatPresenter { } } + /** + * 分裂冰锥:水弹从触发点飞向目标。 + * @param cue 逻辑层 cue + */ + private _playIceConeSplit(cue: IceConeSplitCue | undefined): void { + if (!cue || !(cue.duration > 0)) return; + const parent = SkillPresentationEnv.flyLayer ?? this._displayRoot; + if (!parent?.isValid) { + console.log('[CombatPresenter] 冰锥无父节点'); + return; + } + this._iceConeFrom.set(cue.fromX, cue.fromY, 0); + this._iceConeTo.set(cue.toX, cue.toY, 0); + const angleDeg = computeAimAngleDeg(this._iceConeFrom, this._iceConeTo); + if (angleDeg == null) { + console.log('[CombatPresenter] 冰锥朝向为零'); + return; + } + let node: Node | null = null; + try { + node = gg.res.getNode(SWORD_ARRAY_PREFAB_PATH, ICE_CONE_PREFAB, GBundle.BattleHero); + } catch (err) { + console.log('[CombatPresenter] 冰锥 getNode 失败', String(err)); + return; + } + if (!node?.isValid) { + console.log('[CombatPresenter] 冰锥预制体未加载', ICE_CONE_PREFAB); + return; + } + parent.active = true; + const flyUt = parent.getComponent(UITransform); + const scale = BattleMapSpec.CELL_SIZE / ICE_CONE_SRC_W; + node.active = true; + node.layer = parent.layer; + node.setScale(scale, scale, 1); + node.parent = parent; + const startLocal = flyUt + ? flyUt.convertToNodeSpaceAR(this._iceConeFrom) + : this._iceConeFrom.clone(); + const endLocal = flyUt + ? flyUt.convertToNodeSpaceAR(this._iceConeTo) + : this._iceConeTo.clone(); + node.setPosition(startLocal); + node.angle = angleDeg; + node.setSiblingIndex(parent.children.length - 1); + this._iceConeFx.push(node); + SkillTransformDriver.cancelNode(node); + SkillTransformDriver.animate({ + node, + duration: cue.duration, + fromPos: startLocal, + toPos: endLocal, + fromAngle: angleDeg, + toAngle: angleDeg, + easing: SkillTransformDriver.easeLinear, + onComplete: this._onIceConeArrived, + }); + } + + private readonly _onIceConeArrived = (node?: Node): void => { + if (node) this._recycleIceCone(node); + }; + + /** 回收全部分裂冰锥表现 */ + private _recycleAllIceCones(): void { + for (let i = this._iceConeFx.length - 1; i >= 0; i--) { + this._recycleIceCone(this._iceConeFx[i]); + } + } + + /** + * 停飞并回池冰锥表现。 + * @param node 水弹节点 + */ + private _recycleIceCone(node: Node): void { + for (let i = this._iceConeFx.length - 1; i >= 0; i--) { + if (this._iceConeFx[i] === node) this._iceConeFx.splice(i, 1); + } + if (!node?.isValid) return; + SkillTransformDriver.cancelNode(node); + node.angle = 0; + node.setScale(1, 1, 1); + gg.res.putNode(node); + } + /** * 停飞并回池。 * @param node 刀光节点 diff --git a/assets/script/game/TableAnomaly.ts b/assets/script/game/TableAnomaly.ts index 433bdc9..b7c99e1 100644 --- a/assets/script/game/TableAnomaly.ts +++ b/assets/script/game/TableAnomaly.ts @@ -9,7 +9,7 @@ export interface TableRowSource { } /** 索敌已实现的 atk_type 头 */ -const ATK_LOGIC: readonly number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 20]; +const ATK_LOGIC: readonly number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; /** Skill.skill 额外效果已落地的头 */ const EXTRA_SKILL: readonly number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; /** Weapon.type(1刀 2枪 3棍 4弓 5燃烧 6增幅) */ diff --git a/docs/方案-战斗技能逻辑层.md b/docs/方案-战斗技能逻辑层.md index b9dc732..16a88e5 100644 --- a/docs/方案-战斗技能逻辑层.md +++ b/docs/方案-战斗技能逻辑层.md @@ -131,10 +131,22 @@ Hero(合成等级 level) 参数与 18 相同。开战占施法锁;**每段伤害前重播 `Skill.animation`**(如暴风雪),段间隔从段起点起跳;命中走 deferred `DealDamage` + `atk_buff`。 +### atk_type 19 全屏直线冰锥 + +`atk_para`=`数量,伤害万分比`。全屏按威胁锁最多 N 个敌;瞬发不占普攻,对每个目标发直线「水弹」冰锥,落地结算伤害与 `atk_buff`。 + +### atk_buff 13 冰冻 + +参数:`概率,冰冻秒`。挂 `debuff_13`(定身 + 禁施法),表现复用 `怪_冰` 并飘「冰冻」。部署 loadout 不施加。 + ### atk_buff 14 点燃 参数:`间隔秒,伤害系数万分比,持续秒`。挂 `debuff_1`,立即首跳再按间隔跳伤(`burn_dot`)。被动 result 附带施加时用 `passive_burn` / `fromPassiveResult`,不回触发 condition。部署 loadout 不施加。 +### result 13 分裂冰锥 + +参数:`索敌半径像素,分裂数量,伤害万分比`。从触发目标向半径内其它敌(按距排序)发最多 N 枚冰锥,落地伤;行上 `atk_buff1/2` 非空时对落地目标 `applyPair`。 + ### result 14 爆炸 参数:`爆炸圆形半径,伤害系数万分比`。以触发目标为圆心圈伤(`fromPassiveResult`)。**仅当行上 `atk_buff1/2` 非空**时对命中目标 `applyPair`(不为 desc 补效果)。 @@ -178,3 +190,4 @@ Hero(合成等级 level) | 2026-09-11 | target_stage=2:maxCount 仅为上限,每人一次,不循环补位(悟净治疗术/悟能疾行术) | | 2026-09-30 | atk_type=16:扇形多段(段数,间隔秒,角度,伤害万分比);atk_buff1/2 命中效果保持不变(100001~100005) | | 2026-10-04 | atk_type=18:丢出独立圈伤(瞬发不占普攻);atk_type=20:占锁且每段播 animation;result 14 爆炸;atk_buff 14 点燃 | +| 2026-10-08 | atk_type=19 全屏直线冰锥;atk_buff 13 冰冻;result 13 分裂冰锥;飞棍 320301~350301 atk_para 补间隔段 | diff --git a/docs/方案-战斗表现数据驱动.md b/docs/方案-战斗表现数据驱动.md index 9ccd27b..fff8faa 100644 --- a/docs/方案-战斗表现数据驱动.md +++ b/docs/方案-战斗表现数据驱动.md @@ -142,4 +142,5 @@ M| 1 / 3 / 6 / 7 / 15 | 通道播动画 → drain hit(15:逻辑已锁主目 | 2026-09-30 | atk_type=17 不穿透散射箭:散射扇出同 12,首中即结算并当场回收表现箭(400002/400003) | | 2026-09-30 | 修复弓字形塔散射不发射:Gong 宿主 12/17 委托自身「英雄弓-攻击/投掷物」扇出(BowScatterAtkPresenter),result 10 覆盖普攻形态同步分流 | | 2026-09-30 | 补注册 atk_type=11 召唤(10051~10055)→ SummonAtkPresenter;表内 1~17 表现已全覆盖,仅 9(将武器形 30406)按兜底结算待策划定义攻效 | +| 2026-10-08 | atk_type=19 冰锥 → IceConeAtkPresenter(水弹直线落地);18/20 已有独立 Presenter | | 2026-09-30 | 棍 200005 环形刀光:GunRingAtkPresenter,攻效播完从字位置按 atk_para(起始角,角度间隔,伤害系数)逆时针发射龙影(prefab/skill/棍),体盒弹道首中即消耗;atk_type 改 17(锁单体、伤害全在弹道) |