diff --git a/assets/bundles/battleHero/prefab/skill/冰锥.prefab b/assets/bundles/battleHero/prefab/skill/冰锥.prefab index 45d489b..65d565f 100644 --- a/assets/bundles/battleHero/prefab/skill/冰锥.prefab +++ b/assets/bundles/battleHero/prefab/skill/冰锥.prefab @@ -83,7 +83,7 @@ }, "_lpos": { "__type__": "cc.Vec3", - "x": 0, + "x": -94.225, "y": 0, "z": 0 }, diff --git a/assets/bundles/battleHero/prefab/skill/水弹.prefab b/assets/bundles/battleHero/prefab/skill/水弹.prefab index bf71a1b..0a249d8 100644 --- a/assets/bundles/battleHero/prefab/skill/水弹.prefab +++ b/assets/bundles/battleHero/prefab/skill/水弹.prefab @@ -83,8 +83,8 @@ }, "_lpos": { "__type__": "cc.Vec3", - "x": 0, - "y": 0, + "x": -43.703, + "y": -7.023, "z": 0 }, "_lrot": { diff --git a/assets/mx/module/battle/combat/DamagePipeline.ts b/assets/mx/module/battle/combat/DamagePipeline.ts index bfa0e3c..f416bff 100644 --- a/assets/mx/module/battle/combat/DamagePipeline.ts +++ b/assets/mx/module/battle/combat/DamagePipeline.ts @@ -314,7 +314,9 @@ export class DamagePipeline { } } ctx.vars.set(VAR_LAST_CRIT, isCrit ? 1 : 0); + // atk=1 × 系数<1 时 floor 会变 0(木1/水1/火1/土1);有正伤害贡献时至少 1 let amount = Math.max(0, Math.floor(atk)); + if (amount === 0 && atk > 0) amount = 1; const additivePercentWan = params.additivePercentMaxHpWan as number | undefined; if (additivePercentWan != null && additivePercentWan > 0 && ctx.targets[0] != null) { const hp = this._world.getComponent(ctx.targets[0], HealthComp); diff --git a/assets/script/battle/combat/MonsterSkillHost.ts b/assets/script/battle/combat/MonsterSkillHost.ts index 1300ee5..0ff665f 100644 --- a/assets/script/battle/combat/MonsterSkillHost.ts +++ b/assets/script/battle/combat/MonsterSkillHost.ts @@ -21,6 +21,7 @@ import { SkillAbilityBuilder } from '../config/baseAtk/SkillAbilityBuilder'; import { entityMonsterAbilityId, isMonsterAbilityId } from '../config/weapon1/Weapon1BattleIds'; import { BattleMapSpec } from '../map/BattleMapSpec'; import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback'; +import { SkillVisualChannel } from '../presentation/SkillVisualChannel'; import type { ProjectBattleHandle } from '../runtime/BattleRuntime'; import { findGridIndexForEntity } from '../runtime/DeployEntityQuery'; import { buildSkillPresentationContext } from '../skill/SkillPresentationBridge'; @@ -463,9 +464,18 @@ export class MonsterSkillHost { let wait = 0; if (playAnim) { - const delay = BattleSpinePlayback.resolveLogicalCompleteDelay(sk, playAnim, 1); - // Boss/怪物主动技能:动画倍速固定 1(不跟攻速) - view.playSpineAnim?.(playAnim, false); + const baseCd = Math.max(0, Number(ctx.params.cd) || 0); + const intervalSec = baseCd > 0 + ? SkillPresentationEnv.getFinalCooldown(event.caster, baseCd) + : 0; + const dur = BattleSpinePlayback.getAnimDuration(sk, playAnim); + const animScale = SkillVisualChannel.fitAnimTimeScale( + dur, + intervalSec > 0 ? intervalSec : Number.POSITIVE_INFINITY, + ); + const delay = BattleSpinePlayback.resolveLogicalCompleteDelay(sk, playAnim, animScale); + // Boss/怪物主动:仅当 CD 短于动画时长时加速,从不减速 + view.playSpineAnim?.(playAnim, false, undefined, animScale); wait = delay != null && delay > 0 ? delay : 0; } this._playGourdIfNeeded(event.caster, ctx); diff --git a/assets/script/battle/heros/Gong.ts b/assets/script/battle/heros/Gong.ts index bfb4468..89a2329 100644 --- a/assets/script/battle/heros/Gong.ts +++ b/assets/script/battle/heros/Gong.ts @@ -11,6 +11,7 @@ import { import { BattleAudio } from '../audio/BattleAudio'; import { HeroSkin } from '../view/HeroSkin'; import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback'; +import { SkillVisualChannel } from '../presentation/SkillVisualChannel'; import { AttrId } from 'db://assets/mx/module/battle/ecs/Components'; import { BOW_ATK_ARROW_SPEED, SCATTER_RANGE_MUL, getBowAtkHost } from '../combat/BowAtkRuntime'; import { getBattleBuffDigitStatHost } from '../combat/BattleBuffDigitStatHost'; @@ -327,8 +328,12 @@ export class Gong extends HeroActor { this._supersedePendingCast(ctx); const seq = ++this._attackSeq; this._pendingCtx = ctx; - const aspdScale = Math.max(0.01, SkillPresentationEnv.getFinalAttackSpeed(ctx.caster)); - const bowAnimTs = Math.max(aspdScale, RELEASE_AT_BASE / BOW_ATTACK_MAX_SEC); + const aspd = Math.max(0.01, SkillPresentationEnv.getFinalAttackSpeed(ctx.caster)); + const drawDur = this.skillAnim?.isValid + ? BattleSpinePlayback.getAnimDuration(this.skillAnim, SPINE_SKILL_ATTACK2) + : null; + const fitScale = SkillVisualChannel.fitAnimTimeScale(drawDur, 1 / aspd); + const bowAnimTs = Math.max(fitScale, RELEASE_AT_BASE / BOW_ATTACK_MAX_SEC); let unlocked = false; const unlockCast = () => { diff --git a/assets/script/battle/presentation/ArrowRainPresenter.ts b/assets/script/battle/presentation/ArrowRainPresenter.ts index 2937e08..d52e133 100644 --- a/assets/script/battle/presentation/ArrowRainPresenter.ts +++ b/assets/script/battle/presentation/ArrowRainPresenter.ts @@ -416,6 +416,7 @@ export class ArrowRainHost { animation: run.animation, followWeapon: follow, skillKind: String(run.ctx.params.skillKind ?? ''), + skillCdSec: Number(run.ctx.params.cd) || 0, primaryTarget: null, skipBowArrow: true, animTimeScale: run.fireScale, diff --git a/assets/script/battle/presentation/AtkTypePresenters.ts b/assets/script/battle/presentation/AtkTypePresenters.ts index 68f02a2..dc76a07 100644 --- a/assets/script/battle/presentation/AtkTypePresenters.ts +++ b/assets/script/battle/presentation/AtkTypePresenters.ts @@ -28,6 +28,7 @@ export class InstantAtkPresenter implements ISkillPresenter { animation, followWeapon, skillKind, + skillCdSec: Number(ctx.params.cd) || 0, primaryTarget: ctx.targets[0] ?? null, keepRestAim: atkHead === 7, onAnimComplete: host.onPresentationAnimCompleteBound, @@ -54,6 +55,7 @@ export class LineRectAtkPresenter implements ISkillPresenter { animation: String(ctx.params.animation ?? ''), followWeapon, skillKind, + skillCdSec: Number(ctx.params.cd) || 0, primaryTarget: ctx.targets[0] ?? null, onAnimComplete: host.onPresentationAnimCompleteBound, }); @@ -83,6 +85,7 @@ export class LineMultiHitAtkPresenter implements ISkillPresenter { animation: String(ctx.params.animation ?? ''), followWeapon, skillKind, + skillCdSec: Number(ctx.params.cd) || 0, primaryTarget: ctx.targets[0] ?? null, holdCasting: loopCast, loopWeapon: loopCast, @@ -117,6 +120,7 @@ export class GroundCircleMultiHitAtkPresenter implements ISkillPresenter { animation: String(ctx.params.animation ?? ''), followWeapon, skillKind, + skillCdSec: Number(ctx.params.cd) || 0, primaryTarget: ctx.targets[0] ?? null, holdCasting: true, loopWeapon: true, @@ -159,8 +163,6 @@ export class SequentialSingleAtkPresenter implements ISkillPresenter { const seq = host.beginPresentationCast(ctx); if (!(seq > 0)) return; const animation = String(ctx.params.animation ?? ''); - const animTs = SkillVisualChannel.resolveCastAnimTimeScale(ctx.caster, skillKind); - host.schedulePresentationMultiHit(seq, ctx, host.visualChannel.resolveSkillHitDelay(animation, animTs)); const ch = host.visualChannel; ch.ensureWeapon(); ch.syncWeaponPivot(ctx.caster); @@ -183,16 +185,17 @@ export class SequentialSingleAtkPresenter implements ISkillPresenter { }); ch.weapon.setVisible(false); } - ch.playCastAnim({ + const animTs = ch.playCastAnim({ caster: ctx.caster, animation, followWeapon: false, skillKind, + skillCdSec: Number(ctx.params.cd) || 0, primaryTarget: ctx.targets[0] ?? null, holdCasting: true, - animTimeScale: animTs, onAnimComplete: host.onPresentationAnimCompleteBound, }); + host.schedulePresentationMultiHit(seq, ctx, host.visualChannel.resolveSkillHitDelay(animation, animTs)); } } @@ -214,6 +217,7 @@ export class SummonAtkPresenter implements ISkillPresenter { animation: String(ctx.params.animation ?? ''), followWeapon, skillKind, + skillCdSec: Number(ctx.params.cd) || 0, primaryTarget: null, keepRestAim: true, onAnimComplete: host.onPresentationAnimCompleteBound, @@ -261,6 +265,7 @@ export class ProjectileAtkPresenter implements ISkillPresenter { animation: String(ctx.params.animation ?? ''), followWeapon, skillKind, + skillCdSec: Number(ctx.params.cd) || 0, primaryTarget: landTarget > 0 ? landTarget : null, throwWeapon, onProjectileLand: throwWeapon ? undefined : host.onBowProjectileLandBound, @@ -318,6 +323,7 @@ export class IceConeAtkPresenter implements ISkillPresenter { animation, followWeapon, skillKind, + skillCdSec: Number(ctx.params.cd) || 0, primaryTarget: ctx.targets[0] ?? null, holdCasting: true, onAnimComplete: host.onIceConeAnimCompleteBound, @@ -351,6 +357,7 @@ export class ScatterPierceAtkPresenter implements ISkillPresenter { animation: String(ctx.params.animation ?? ''), followWeapon, skillKind, + skillCdSec: Number(ctx.params.cd) || 0, primaryTarget: ctx.targets[0] ?? null, skipBowArrow: true, aimDirect: true, @@ -385,6 +392,7 @@ export class VolleyArrowAtkPresenter implements ISkillPresenter { animation, followWeapon, skillKind, + skillCdSec: Number(ctx.params.cd) || 0, primaryTarget: ctx.targets[0] ?? null, skipBowArrow: true, holdCasting: true, diff --git a/assets/script/battle/presentation/SkillVisualChannel.ts b/assets/script/battle/presentation/SkillVisualChannel.ts index 9c5a529..439de92 100644 --- a/assets/script/battle/presentation/SkillVisualChannel.ts +++ b/assets/script/battle/presentation/SkillVisualChannel.ts @@ -28,6 +28,14 @@ const STAFF_PROJECTILE_RELEASE_AT = 0.6333; const STAFF_PROJECTILE_MIN_AT = 0.3; /** 法杖出手特效骨(水弹/冰锥/暴风雪共用「挥砍」) */ const STAFF_RELEASE_BONE = '挥砍'; +/** 法杖头部发光骨(水弹/冰锥发射起点) */ +const STAFF_MUZZLE_BONE = '聚集光能'; +/** + * 相对发光骨的本地微调(Spine 节点空间像素,正 X 右、正 Y 上)。 + * 用于对齐杖头水晶发光中心;骨骼缺失时作为相对 skin 节点的发射偏移。 + */ +const STAFF_MUZZLE_OFFSET_X = 0; +const STAFF_MUZZLE_OFFSET_Y = 0; /** * weaponType4 / 弓类武器+专属技能:「攻击」「攻击2」「攻击3」槽位「箭矢*」附件清空帧。 * 整段动画 1.5s,卸箭=拉完放箭时刻(非动画结束)。 @@ -84,10 +92,9 @@ const WATER_BALL_PREFAB = '水弹'; /** battleHero/prefab/skill/冰锥(水属性冰锥技能发射物) */ const ICE_CONE_PREFAB = '冰锥'; /** - * 水弹/冰锥「飞行水弹」原图约 322×122;缩到约一格可视大小。 - * 缩放比 = CELL_SIZE / 原图像素宽。 + * 缩放比 */ -const WATER_BALL_SCALE = BattleMapSpec.CELL_SIZE / 322; +const WATER_BALL_SCALE =1; /** 水弹飞行像素/秒(与弓箭同量级) */ const WATER_BALL_SPEED = BOW_ATK_ARROW_SPEED; /** battleHero/prefab/skill/飞剑(武器主动) */ @@ -208,15 +215,17 @@ export interface CastAnimParams { followWeapon: boolean; /** 主目标(瞄准) */ primaryTarget: EntityId | null; - /** Ability 槽:normal 跟攻速;hero/weapon/monster 固定 1 */ + /** Ability 槽:normal 按攻击间隔;hero/weapon/monster 按技能 CD */ skillKind?: string; + /** 主动技能配表 CD(秒);缺省且非普攻时不加速 */ + skillCdSec?: number; /** 跳过默认单箭(atk_type 12/13 自行发射) */ skipBowArrow?: boolean; /** atk_type=4 非弓:出手立刻飞出玉帝飞剑 */ throwWeapon?: boolean; /** 弓对准目标连线,无抛物线偏角(散射) */ aimDirect?: boolean; - /** 覆盖攻速:动画与瞄准倍速 */ + /** 显式倍速覆盖(箭雨等已算好的);未传则按动画时长与间隔/CD 对齐 */ animTimeScale?: number; /** 播完不收招待机(连发由外部收招) */ holdCasting?: boolean; @@ -275,6 +284,8 @@ export class SkillVisualChannel { private readonly _arrowFrom = new Vec3(); private readonly _arrowTo = new Vec3(); private readonly _arrowCtrl = new Vec3(); + /** 法杖发射点本地缓冲(骨坐标 + 偏移) */ + private readonly _muzzleLocal = new Vec3(); private readonly _scatterDegs: number[] = []; /** 本段释放弓对准目标连线 */ private _aimDirect = false; @@ -382,16 +393,45 @@ export class SkillVisualChannel { } /** - * 释放动画倍速:hero/weapon/monster 固定 1;普攻跟攻速;显式 override 优先。 - * @param caster 施法实体 - * @param skillKind Ability 槽 - * @param override 显式倍速 + * 间隔/CD 与动画时长对齐:仅当间隔短于动画时加速,从不减速。 + * @param animDurationSec 动画原始时长;无效则 1 + * @param intervalSec 攻击间隔或技能 CD;≤0 则 1 + */ + static fitAnimTimeScale(animDurationSec: number | null | undefined, intervalSec: number): number { + const dur = Number(animDurationSec); + if (!(dur > 0)) return 1; + const gap = Number(intervalSec); + if (!(gap > 0)) return 1; + return Math.max(1, dur / gap); + } + + /** + * 释放动画倍速:普攻用 1/攻速;主动用最终 CD;仅加速不减速。 + * @param opts.caster 施法实体 + * @param opts.skillKind Ability 槽 + * @param opts.animDurationSec 动画时长 + * @param opts.skillCdSec 主动技能配表 CD + * @param opts.override 显式倍速 */ - static resolveCastAnimTimeScale(caster: EntityId, skillKind?: string, override?: number): number { - if (override != null && override > 0) return override; - const kind = String(skillKind ?? ''); - if (kind === 'hero' || kind === 'weapon' || kind === 'monster') return 1; - return Math.max(0.01, SkillPresentationEnv.getFinalAttackSpeed(caster)); + static resolveCastAnimTimeScale(opts: { + caster: EntityId; + skillKind?: string; + animDurationSec?: number | null; + skillCdSec?: number | null; + override?: number; + }): number { + if (opts.override != null && opts.override > 0) return opts.override; + const kind = String(opts.skillKind ?? ''); + let intervalSec: number; + if (kind === 'hero' || kind === 'weapon' || kind === 'monster') { + const baseCd = Math.max(0, Number(opts.skillCdSec) || 0); + if (!(baseCd > 0)) return 1; + intervalSec = SkillPresentationEnv.getFinalCooldown(opts.caster, baseCd); + } else { + const aspd = Math.max(0.01, SkillPresentationEnv.getFinalAttackSpeed(opts.caster)); + intervalSec = 1 / aspd; + } + return SkillVisualChannel.fitAnimTimeScale(opts.animDurationSec, intervalSec); } /** 绑定施法实体(用于首字格定位) */ @@ -471,6 +511,29 @@ export class SkillVisualChannel { return !!this._weapon.root?.active; } + /** + * 读本次释放动画原始时长(跟武器优先,否则字形「攻击」)。 + * @param weaponAnim Skill.animation + * @param followWeapon 是否跟播武器 + */ + private _peekCastAnimDuration(weaponAnim: string, followWeapon: boolean): number | null { + if (followWeapon) { + const sk = this._weapon.drawAnim ?? this._weapon.skinAnim; + if (sk && weaponAnim) { + const dur = BattleSpinePlayback.getAnimDuration(sk, weaponAnim); + if (dur != null) return dur; + } + } + if (!this._attackOnlyWeapon) { + const glyph = this._skin?.anim ?? this._glyphAnim; + if (glyph) { + const dur = BattleSpinePlayback.getAnimDuration(glyph, GLYPH_CAST_ANIM); + if (dur != null) return dur; + } + } + return null; + } + /** 取消字形/武器上未触发的释放完成回调 */ clearCastCompletes(): void { BattleSpinePlayback.clearPendingComplete(this._weapon.skinAnim); @@ -520,15 +583,9 @@ export class SkillVisualChannel { * 释放表现:组合字形播「攻击」;五行数字不播字形攻击。 * 有武器时武器播 Skill.animation(缺省「攻击」)。跟随武器时等字形与武器都播完再收招。 * 玉帝飞剑:不播原武器攻击,直接隐藏。 - * @returns 本段释放动画倍速(供命中延迟换算;主动技能为 1) + * @returns 本段释放动画倍速(供命中延迟换算;≥1,仅加速) */ playCastAnim(params: CastAnimParams): number { - const animScale = SkillVisualChannel.resolveCastAnimTimeScale( - params.caster, - params.skillKind, - params.animTimeScale, - ); - params.animTimeScale = animScale; this._caster = params.caster; this._aimDirect = params.aimDirect === true; this._castAnimParams = params; @@ -538,7 +595,17 @@ export class SkillVisualChannel { if (!weaponAnim) { console.warn('[SkillVisualChannel] Skill.animation 为空', params.caster); } - const follow = params.followWeapon && this._useWeapon && this.ensureWeapon(); + const followWanted = !!(params.followWeapon && this._useWeapon); + if (followWanted) this.ensureWeapon(); + const animScale = SkillVisualChannel.resolveCastAnimTimeScale({ + caster: params.caster, + skillKind: params.skillKind, + animDurationSec: this._peekCastAnimDuration(weaponAnim, followWanted), + skillCdSec: params.skillCdSec, + override: params.animTimeScale, + }); + params.animTimeScale = animScale; + const follow = followWanted && this.ensureWeapon(); const playGlyph = !this._attackOnlyWeapon; if (follow) { this._cancelRestHold(); @@ -806,8 +873,7 @@ export class SkillVisualChannel { if (!params) return; if (!params.holdCasting) { this._weaponCasting = false; - const aspd = Math.max(0.01, SkillPresentationEnv.getFinalAttackSpeed(params.caster)); - const restScale = params.animTimeScale != null && params.animTimeScale > 0 ? params.animTimeScale : aspd; + const restScale = params.animTimeScale != null && params.animTimeScale > 0 ? params.animTimeScale : 1; if (this._isBowWeapon() && this._weapon.bowAtkShowing) { this._weapon.freezeBowDraw(); // 弓与枪相同:连击窗保持当前角,超时再回待机 @@ -1002,6 +1068,41 @@ export class SkillVisualChannel { return Math.max(STAFF_PROJECTILE_MIN_AT, releaseAt); } + /** + * 水弹/冰锥发射世界坐标:法杖「聚集光能」骨 + 本地偏移;无骨则 skin/aim 原点 + 同偏移。 + * @param out 输出世界坐标 + * @param caster 施法者(无武器节点时回落格心) + */ + private _fillStaffProjectileFromWorld(out: Vec3, caster: EntityId): boolean { + const sk = this._weapon.skinAnim; + if (sk?.isValid) { + const bone = typeof sk.findBone === 'function' ? sk.findBone(STAFF_MUZZLE_BONE) : null; + const ui = sk.node?.getComponent(UITransform); + if (bone && ui?.isValid) { + this._muzzleLocal.set(bone.worldX + STAFF_MUZZLE_OFFSET_X, bone.worldY + STAFF_MUZZLE_OFFSET_Y, 0); + ui.convertToWorldSpaceAR(this._muzzleLocal, out); + return true; + } + } + const fromNode = sk?.node ?? this._weapon.aim ?? this._weapon.root; + if (fromNode?.isValid) { + const ui = fromNode.getComponent(UITransform); + if (ui?.isValid) { + this._muzzleLocal.set(STAFF_MUZZLE_OFFSET_X, STAFF_MUZZLE_OFFSET_Y, 0); + ui.convertToWorldSpaceAR(this._muzzleLocal, out); + return true; + } + out.set(fromNode.worldPosition); + out.x += STAFF_MUZZLE_OFFSET_X; + out.y += STAFF_MUZZLE_OFFSET_Y; + return true; + } + const grid = SkillPresentationEnv.getCasterGridCenter(caster); + if (!grid) return false; + out.set(grid); + return true; + } + /** * 法杖水弹/冰锥首次出手延迟(逻辑秒):动画关键帧 / 攻速倍速。 * @param animScale playCastAnim 返回的倍速 @@ -1219,11 +1320,7 @@ export class SkillVisualChannel { } this._weaponCasting = true; this._weapon.setVisible(true); - const fromNode = this._weapon.skinAnim?.node ?? this._weapon.aim ?? this._weapon.root; - const fromWorld = fromNode?.isValid - ? fromNode.worldPosition - : SkillPresentationEnv.getCasterGridCenter(caster); - if (!fromWorld) { + if (!this._fillStaffProjectileFromWorld(this._arrowFrom, caster)) { console.log('[SkillVisualChannel] 冰锥无起点'); return false; } @@ -1233,7 +1330,6 @@ export class SkillVisualChannel { console.log('[SkillVisualChannel] 冰锥无目标坐标', target); return false; } - 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); @@ -1537,11 +1633,7 @@ export class SkillVisualChannel { this._weaponCasting = true; // 出手瞬间再确保法杖可见(避免在飞弹道占 _weaponFlies 时藏武器) this._weapon.setVisible(true); - const fromNode = this._weapon.skinAnim?.node ?? this._weapon.aim ?? this._weapon.root; - const fromWorld = fromNode?.isValid - ? fromNode.worldPosition - : SkillPresentationEnv.getCasterGridCenter(caster); - if (!fromWorld) { + if (!this._fillStaffProjectileFromWorld(this._arrowFrom, caster)) { console.log('[SkillVisualChannel] 水弹无起点'); return false; } @@ -1551,7 +1643,6 @@ export class SkillVisualChannel { console.log('[SkillVisualChannel] 水弹无目标坐标', target); return false; } - 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/presentation/SpearTossPresenter.ts b/assets/script/battle/presentation/SpearTossPresenter.ts index dc58cb6..f471c62 100644 --- a/assets/script/battle/presentation/SpearTossPresenter.ts +++ b/assets/script/battle/presentation/SpearTossPresenter.ts @@ -211,6 +211,7 @@ export class SpearTossHost { animation, followWeapon: follow, skillKind: String(ctx.params.skillKind ?? ''), + skillCdSec: Number(ctx.params.cd) || 0, primaryTarget: victim, holdCasting: true, animTimeScale: 1, diff --git a/assets/script/battle/skill/Skill.ts b/assets/script/battle/skill/Skill.ts index 2d21aeb..fb590c4 100644 --- a/assets/script/battle/skill/Skill.ts +++ b/assets/script/battle/skill/Skill.ts @@ -853,11 +853,15 @@ export class Skill extends Component { /** * 伴生字纯表现:只播文本「攻击」再回待机,不碰 hit/finish。 * 由 SkillController 在主战字 onAttack/onSkill 时同步调用。 + * @param attackSpeed 主战字最终攻速(次/秒);按动画时长对齐且仅加速 */ - playPartnerAttackVisual(timeScale = 1): void { + playPartnerAttackVisual(attackSpeed = 1): void { const anim = this.getComposeTextAnim(); if (!anim?.isValid) return; - const combined = this.combinePresentationTimeScale(timeScale); + const aspd = Math.max(0.01, attackSpeed); + const dur = BattleSpinePlayback.getAnimDuration(anim, '攻击'); + const scale = SkillVisualChannel.fitAnimTimeScale(dur, 1 / aspd); + const combined = this.combinePresentationTimeScale(scale); this.playSpine(anim, '攻击', () => { if (anim?.isValid) { BattleSpinePlayback.play(anim, '待机', { loop: true, optional: true }); diff --git a/assets/script/battle/skill/SkillPresentationEnv.ts b/assets/script/battle/skill/SkillPresentationEnv.ts index 3ee59fc..577485d 100644 --- a/assets/script/battle/skill/SkillPresentationEnv.ts +++ b/assets/script/battle/skill/SkillPresentationEnv.ts @@ -509,6 +509,17 @@ export class SkillPresentationEnv { return session.attributes.getFinalAttackSpeed(caster); } + /** + * 最终技能 CD(秒):baseCd / (1 + cd_add)。 + * @param caster 施法实体 + * @param baseCd 配表 CD + */ + static getFinalCooldown(caster: EntityId, baseCd: number): number { + const session = this._battle?.session; + if (!session) return Math.max(0, Number(baseCd) || 0); + return session.attributes.getFinalCooldown(caster, baseCd); + } + static getCasterGridCenter(caster: EntityId): Vec3 | null { const session = this._battle?.session; const map = this._map; diff --git a/assets/script/battle/view/IBattleMonsterView.ts b/assets/script/battle/view/IBattleMonsterView.ts index 457cc44..6d5e436 100644 --- a/assets/script/battle/view/IBattleMonsterView.ts +++ b/assets/script/battle/view/IBattleMonsterView.ts @@ -21,7 +21,7 @@ export interface IBattleMonsterView extends Component { /** 机制怪落地后不可移动(由 Presenter 赋值) */ mechanismImmobile?: boolean; /** 播放 Spine 动画(机制怪下落等) */ - playSpineAnim?(name: string, loop?: boolean, onComplete?: () => void): void; + playSpineAnim?(name: string, loop?: boolean, onComplete?: () => void, timeScale?: number): void; /** 空降兵落地动画 */ playParatroopLand?(): void; setData(data: ITableMonster, hp: number): void; diff --git a/assets/script/ui/BattleGame/Monster.ts b/assets/script/ui/BattleGame/Monster.ts index e7c0ffb..4f6d250 100644 --- a/assets/script/ui/BattleGame/Monster.ts +++ b/assets/script/ui/BattleGame/Monster.ts @@ -1350,9 +1350,15 @@ export class Monster extends Component { this.playSpine('1降落', null, 1, false); } - /** 机制施法等:对外播放 Spine(如白骨精「攻击」/「待机」) */ - playSpineAnim(name: string, loop = true, onComplete?: () => void): void { - this.playSpine(name, onComplete ?? null, 1, loop); + /** + * 机制施法等:对外播放 Spine(如白骨精「攻击」/「待机」)。 + * @param name 动画名 + * @param loop 是否循环 + * @param onComplete 播完 + * @param timeScale 用户倍速(默认 1;战斗倍速在 Playback 内叠加) + */ + playSpineAnim(name: string, loop = true, onComplete?: () => void, timeScale = 1): void { + this.playSpine(name, onComplete ?? null, Math.max(0.01, timeScale), loop); } private playSpine(name: string, callback: Function = null, timeScale = 1, loop = true) { diff --git a/docs/方案-战斗表现数据驱动.md b/docs/方案-战斗表现数据驱动.md index e4dcca0..c5ee136 100644 --- a/docs/方案-战斗表现数据驱动.md +++ b/docs/方案-战斗表现数据驱动.md @@ -50,7 +50,7 @@ PlayPresentation cue | 通道 | 条件 | 职责 | |------|------|------| | 字形 | 组合英雄 | 播「攻击」;**五行数字不播字形「攻击」**(保持待机皮) | -| 武器 | 组合英雄且有 weaponId;五行数字按 `Hero.herotype` | 激活后常驻挂 `weaponType*`(格子左侧 / 首字格中心偏左),**不切换显隐**;攻击跟播 `Skill.animation`(缺省「攻击」);组合英雄 **字形与武器都播完才 finish**,五行仅等武器。组合弓:藏 `weaponType4`,显 `武器弓-攻击` 播「攻击」,拉满发射「投掷物」;`拉弓动画` 与 `投掷物/anim` 皮肤=当前 `Weapon.skin`。五行弓:常驻 `weaponType4` 播配表动画。玉帝飞剑:不播原武器攻击,直接隐藏,飞剑播预制体默认「玉帝普攻」。**主动技能(hero/weapon)动画倍速固定 1;普攻跟攻速**。悟空主动 `Skill.animation=悟空技能`:武器不瞄准,沿组合两字包围盒中心椭圆绕一圈(长半径=挂点 X 到中心,短半径=半格,时长=该动画时长-0.2s) | +| 武器 | 组合英雄且有 weaponId;五行数字按 `Hero.herotype` | 激活后常驻挂 `weaponType*`(格子左侧 / 首字格中心偏左),**不切换显隐**;攻击跟播 `Skill.animation`(缺省「攻击」);组合英雄 **字形与武器都播完才 finish**,五行仅等武器。组合弓:藏 `weaponType4`,显 `武器弓-攻击` 播「攻击」,拉满发射「投掷物」;`拉弓动画` 与 `投掷物/anim` 皮肤=当前 `Weapon.skin`。五行弓:常驻 `weaponType4` 播配表动画。玉帝飞剑:不播原武器攻击,直接隐藏,飞剑播预制体默认「玉帝普攻」。**动画倍速:`max(1, 动画时长 / 间隔或CD)`——仅当攻击间隔(普攻 1/攻速)或技能 CD 短于美术动画时长时加速,从不减速**。悟空主动 `Skill.animation=悟空技能`:武器不瞄准,沿组合两字包围盒中心椭圆绕一圈(长半径=挂点 X 到中心,短半径=半格,时长=该动画时长-0.2s) | ### 4.3 atk_type 通用 @@ -155,4 +155,6 @@ M| 1 / 3 / 6 / 7 / 15 | 通道播动画 → drain hit(15:逻辑已锁主目 | 2026-10-09 | 法杖出手必显武器:勿因在飞水弹/冰锥占 `_weaponFlies` 跳过 `setVisible`,避免无施法动作却出弹 | | 2026-10-09 | 法杖水弹出手改读 Spine 骨骼「挥砍」首关键帧(约 0.6333s,聚能后砸出);资源更新后随 skeletonJson 自适应 | | 2026-10-09 | 水属性发射物统一:水弹(atk13)+冰锥(atk19) 均播配表动画,按「挥砍」关键帧出手(下限 0.3s);冰锥不再瞬发 | +| 2026-10-15 | 攻击/技能动画倍速改为 `max(1, 动画时长/间隔或CD)`:配表间隔短于美术时长才加速;慢攻不再把动画拖慢(避免表现锁拉长有效出手) | +| 2026-10-15 | 水弹/冰锥发射点改读法杖「聚集光能」骨 + `STAFF_MUZZLE_OFFSET_*` 本地微调,对齐杖头发光中心 | | 2026-09-30 | 棍 200005 环形刀光:GunRingAtkPresenter,攻效播完从字位置按 atk_para(起始角,角度间隔,伤害系数)逆时针发射龙影(prefab/skill/棍),体盒弹道首中即消耗;atk_type 改 17(锁单体、伤害全在弹道) |