攻击表现效果优化

master
humingxing 2 days ago
parent adad5d833b
commit 4a70af459f
  1. 2
      assets/bundles/battleHero/prefab/skill/冰锥.prefab
  2. 4
      assets/bundles/battleHero/prefab/skill/水弹.prefab
  3. 2
      assets/mx/module/battle/combat/DamagePipeline.ts
  4. 16
      assets/script/battle/combat/MonsterSkillHost.ts
  5. 9
      assets/script/battle/heros/Gong.ts
  6. 1
      assets/script/battle/presentation/ArrowRainPresenter.ts
  7. 16
      assets/script/battle/presentation/AtkTypePresenters.ts
  8. 163
      assets/script/battle/presentation/SkillVisualChannel.ts
  9. 1
      assets/script/battle/presentation/SpearTossPresenter.ts
  10. 8
      assets/script/battle/skill/Skill.ts
  11. 11
      assets/script/battle/skill/SkillPresentationEnv.ts
  12. 2
      assets/script/battle/view/IBattleMonsterView.ts
  13. 12
      assets/script/ui/BattleGame/Monster.ts
  14. 4
      docs/方案-战斗表现数据驱动.md

@ -83,7 +83,7 @@
},
"_lpos": {
"__type__": "cc.Vec3",
"x": 0,
"x": -94.225,
"y": 0,
"z": 0
},

@ -83,8 +83,8 @@
},
"_lpos": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"x": -43.703,
"y": -7.023,
"z": 0
},
"_lrot": {

@ -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);

@ -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);

@ -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 = () => {

@ -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,

@ -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,

@ -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);

@ -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,

@ -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 });

@ -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;

@ -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;

@ -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) {

@ -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(锁单体、伤害全在弹道) |

Loading…
Cancel
Save