diff --git a/assets/bundles/first/config/Battlebuff.json b/assets/bundles/first/config/Battlebuff.json index 57522f1..303d325 100644 --- a/assets/bundles/first/config/Battlebuff.json +++ b/assets/bundles/first/config/Battlebuff.json @@ -131,7 +131,7 @@ "id": 10, "name": "寻金尺", "limit": 1, - "desc": "铲子变为金铲子,开挖地块时获得金币", + "desc": "解锁地块时额外产生金币", "icon": "寻金尺", "quality": 4, "target": 0, @@ -179,7 +179,7 @@ "id": 13, "name": "滞行牌", "limit": 1, - "desc": "敌方怪物移动速度-10%", + "desc": "怪物行进速度-10%", "icon": "滞行牌", "quality": 1, "target": 0, diff --git a/assets/script/battle/presentation/FlySwordAtkPresenter.ts b/assets/script/battle/presentation/FlySwordAtkPresenter.ts index 6a9ba60..1243a3f 100644 --- a/assets/script/battle/presentation/FlySwordAtkPresenter.ts +++ b/assets/script/battle/presentation/FlySwordAtkPresenter.ts @@ -20,6 +20,8 @@ interface FlySwordPending { buff2: string; /** 距发射(秒) */ wait: number; + /** 发次(弧线上下交错) */ + shotIndex: number; /** 表现通道 */ channel: SkillVisualChannel; } @@ -62,6 +64,7 @@ export class FlySwordHost { buff1, buff2, wait: launched * Math.max(0, intervalSec), + shotIndex: launched, channel, }); launched += 1; @@ -84,7 +87,7 @@ export class FlySwordHost { p.wait -= dt; if (p.wait > 0) continue; this._pending.splice(i, 1); - if (!p.channel.launchFlySword(p.caster, p.abilityId, p.target, p.scale, p.buff1, p.buff2)) { + if (!p.channel.launchFlySword(p.caster, p.abilityId, p.target, p.scale, p.buff1, p.buff2, p.shotIndex)) { console.log('[FlySword] 发射失败', p.target); } } diff --git a/assets/script/battle/presentation/SkillVisualChannel.ts b/assets/script/battle/presentation/SkillVisualChannel.ts index 1c981b4..00399b5 100644 --- a/assets/script/battle/presentation/SkillVisualChannel.ts +++ b/assets/script/battle/presentation/SkillVisualChannel.ts @@ -103,6 +103,12 @@ const WATER_BALL_SPEED = BOW_ATK_ARROW_SPEED; const FLY_SWORD_PREFAB = '飞剑'; /** 飞剑默认 Spine 动画 */ const FLY_SWORD_ANIM = '飞剑'; +/** 飞剑弧高占射程比例(弦中点上下偏移) */ +const FLY_SWORD_ARC_T = 0.32; +/** 飞剑弧高下限(像素) */ +const FLY_SWORD_ARC_MIN = 56; +/** 同向高阶剑弧高相对基础的递增值 */ +const FLY_SWORD_ARC_TIER = 0.38; /** battleHero/prefab/skill/玉帝飞剑 */ const YUDI_FLY_SWORD_PREFAB = '玉帝飞剑'; /** 飞剑预制体默认动画(带光效) */ @@ -182,6 +188,49 @@ class WaterBallRecycle { } } +/** + * 飞剑飞行绑定:追踪落点 + 落地回池。 + */ +class FlySwordRecycle { + private static readonly _pool: FlySwordRecycle[] = []; + private _channel: SkillVisualChannel | null = null; + private _node: Node | null = null; + private _target: EntityId = 0; + + /** 每帧刷新终点;目标失效则沿用上次 */ + readonly resolveToWorld = (_node: Node): Vec3 | null => { + if (!(this._target > 0)) return null; + return SkillPresentationEnv.getTargetWorldPos(this._target); + }; + + /** 落地:回池飞剑 */ + readonly onLand = (): void => { + const channel = this._channel; + const node = this._node; + this._channel = null; + this._node = null; + this._target = 0; + FlySwordRecycle._pool.push(this); + if (channel && node) channel.recycleWaterBall(node); + }; + + /** + * 取得绑定。 + * @param channel 表现通道 + * @param node 飞剑 + * @param target 锁定目标 + */ + static obtain(channel: SkillVisualChannel, node: Node, target: EntityId): FlySwordRecycle { + const rec = FlySwordRecycle._pool.length > 0 + ? FlySwordRecycle._pool.pop()! + : new FlySwordRecycle(); + rec._channel = channel; + rec._node = node; + rec._target = target; + return rec; + } +} + /** 待发射的弓箭 */ interface BowArrowLaunch { /** 代数,过期则丢弃 */ @@ -1382,16 +1431,17 @@ export class SkillVisualChannel { } /** - * 飞剑武器主动:实例化「飞剑」直线飞向目标,落地结算。 + * 飞剑武器主动:弧线追踪飞向目标;多发时上下弧高交错。 * @param caster 施法者 * @param abilityId Ability id * @param target 锁定目标 * @param scale 伤害倍率 * @param buff1 atk_buff1 * @param buff2 atk_buff2 + * @param shotIndex 发次(0 起,奇偶上下弧) * @returns 是否发出 */ - launchFlySword(caster: EntityId, abilityId: string, target: EntityId, scale: number, buff1: string, buff2: string): boolean { + launchFlySword(caster: EntityId, abilityId: string, target: EntityId, scale: number, buff1: string, buff2: string, shotIndex = 0): boolean { const fromNode = this._weapon.skinAnim?.node ?? this._weapon.aim ?? this._weapon.root; const fromWorld = fromNode?.isValid ? fromNode.worldPosition @@ -1411,17 +1461,19 @@ export class SkillVisualChannel { const duration = Math.max(0.12, dist / WATER_BALL_SPEED); const node = this._spawnFlySwordPrefab(this._arrowFrom); if (!node) return false; + const arcHeight = this._flySwordArcHeight(dist, shotIndex); + const land = FlySwordRecycle.obtain(this, node, target); SkillTransformDriver.animateParabola({ node, duration, fromWorld: this._arrowFrom, toWorld: this._arrowTo, - arcHeight: 0, + arcHeight, + resolveToWorld: land.resolveToWorld, easing: SkillTransformDriver.easeLinear, faceTangent: true, }); SkillTransformDriver.stepNow(); - const land = WaterBallRecycle.obtain(this, node); getBowAtkHost().spawnVolley({ caster, target, @@ -1435,6 +1487,19 @@ export class SkillVisualChannel { return true; } + /** + * 飞剑弧高:奇偶上下交错,同向越高阶弧越大。 + * @param dist 射程 + * @param shotIndex 发次 + */ + private _flySwordArcHeight(dist: number, shotIndex: number): number { + const idx = Math.max(0, Math.floor(shotIndex)); + const base = Math.max(FLY_SWORD_ARC_MIN, dist * FLY_SWORD_ARC_T); + const sign = (idx % 2 === 0) ? 1 : -1; + const tier = Math.floor(idx / 2); + return sign * base * (1 + tier * FLY_SWORD_ARC_TIER); + } + launchVolleyShot(caster: EntityId, abilityId: string, target: EntityId, scale: number, buff1: string, buff2: string, shotIndex: number, aimWorld: Readonly): boolean { const castAnim = String(this._castAnimParams?.animation ?? '').trim(); if (this._isStaffWeapon() || this._isStaffProjectileAnim(castAnim)) { diff --git a/assets/script/battle/view/HitLeanBack.ts b/assets/script/battle/view/HitLeanBack.ts index 9bce9e9..7e19869 100644 --- a/assets/script/battle/view/HitLeanBack.ts +++ b/assets/script/battle/view/HitLeanBack.ts @@ -59,6 +59,7 @@ export function playHitLeanBack(target: Node, hitDirX: number, hitDirY = 0): voi if (!target.isValid) return; target.angle = 0; target.setScale(1, 1, 1); + delete (target as Node & { [PROXY_KEY]?: LeanProxy })[PROXY_KEY]; }) .start(); } diff --git a/assets/script/ui/BattleGame/Monster.ts b/assets/script/ui/BattleGame/Monster.ts index 1522273..4cae90c 100644 --- a/assets/script/ui/BattleGame/Monster.ts +++ b/assets/script/ui/BattleGame/Monster.ts @@ -29,6 +29,8 @@ export enum MonsterTag { const BLOOD_BAR_SORT_LAYER = 3; /** 血条底框 sortingOrder;进度子节点 +1,避免底框盖住填充 */ const BLOOD_BAR_SORT_ORDER = 8; +/** Boss 左右朝向:累计水平位移超过此值才切面(世界像素;speed=25 时约 0.5s 内可切) */ +const BOSS_FACE_DX_MIN = 2; /** 还魂丹复活特效(battleHero/prefab/skill/小怪复活效果) */ const REVIVE_FX_PREFAB = '小怪复活效果'; /** 还魂丹复活 Spine 动画 */ @@ -108,6 +110,12 @@ export class Monster extends Component { /** 加速特效朝向用:上一帧世界坐标 */ private _accelFxLastX = Number.NaN; private _accelFxLastY = Number.NaN; + /** Boss 左右朝向:上一帧世界 X */ + private _faceLastX = Number.NaN; + /** Boss 左右朝向:未达阈值的累计水平位移 */ + private _faceAccX = 0; + /** Boss 朝向:资源默认朝右,+1=朝右,-1=朝左 */ + private _faceSign = 1; /** playSpine 传入的基础倍速(不含 BattleRuntime.TimeScale) */ private _animBaseTimeScale = 1; @@ -136,6 +144,7 @@ export class Monster extends Component { this.useMxMovement = !!gg.game.CurentBattle?.useMxMovement; this.init(); + this._resetMoveFacing(); this._lastAttackTime = 0; if (this.mechanismImmobile) { this.state = MonsterState.STOP; @@ -155,12 +164,80 @@ export class Monster extends Component { } protected update(_dt: number): void { - if (!this._accelerateFx?.isValid || !this._accelerateFx.active) return; const battle = gg.game.CurentBattle; if (!battle || battle.IsGameOver || battle.IsGamePause) return; + this._tickMoveFacing(); + if (!this._accelerateFx?.isValid || !this._accelerateFx.active) return; this._tickAccelerateFxAngle(); } + /** + * Boss 按水平位移切左右朝向:只翻 skin 下怪物体节点(如 BOSS土)。 + * 血条挂在模板 root 的 bloodPos,不受影响;受击后仰作用在 skin,也不冲掉体朝向。 + */ + private _tickMoveFacing(): void { + if (!this.Data || !MonsterDataManager.isBossType(this.Data.type)) return; + if (this.IsDead || this.mechanismImmobile) return; + const body = this._bossBodyNode(); + if (!body || !this.node?.isValid) return; + const x = this.node.worldPosition.x; + if (!Number.isFinite(this._faceLastX)) { + this._faceLastX = x; + this._faceAccX = 0; + this._applyFaceSign(body, this._faceSign); + return; + } + const dx = x - this._faceLastX; + this._faceLastX = x; + this._faceAccX += dx; + if (Math.abs(this._faceAccX) >= BOSS_FACE_DX_MIN) { + // 资源默认朝右:右移 +1,左移 -1 + this._faceSign = this._faceAccX > 0 ? 1 : -1; + this._faceAccX = 0; + } + this._applyFaceSign(body, this._faceSign); + } + + /** + * skin 下的怪物体节点(挂载的皮肤预制体根)。 + */ + private _bossBodyNode(): Node | null { + const skin = this.skin; + if (!skin?.isValid) return null; + const kids = skin.children; + for (let i = 0; i < kids.length; i++) { + const child = kids[i]; + if (child?.isValid) return child; + } + return null; + } + + /** + * 写入怪物体左右朝向。 + * @param body skin 下怪物节点 + * @param sign ±1 + */ + private _applyFaceSign(body: Node, sign: number): void { + const s = sign < 0 ? -1 : 1; + const sy = Math.abs(body.scale.y) || 1; + const sz = body.scale.z || 1; + if (Math.sign(body.scale.x || 1) === s && Math.abs(Math.abs(body.scale.x) - 1) < 1e-3) return; + body.setScale(s, sy, sz); + } + + /** 复位 Boss 朝向(出池 / 回池) */ + private _resetMoveFacing(): void { + this._faceLastX = Number.NaN; + this._faceAccX = 0; + this._faceSign = 1; + if (this.skin?.isValid) { + this.skin.setScale(1, Math.abs(this.skin.scale.y) || 1, this.skin.scale.z || 1); + } + const body = this._bossBodyNode(); + if (!body) return; + body.setScale(1, Math.abs(body.scale.y) || 1, body.scale.z || 1); + } + /** 战斗 TimeScale 变化时同步 Spine 播放倍速 */ private _syncAnimBattleTimeScale(): void { if (!this.anim?.isValid || this._isDeathAnimPlaying()) return; @@ -1473,6 +1550,7 @@ export class Monster extends Component { this.clearAddHpEff(); this.clearHpCapEff(); this.clearReviveFx(); + this._resetMoveFacing(); if (this.anim?.isValid) { BattleSpinePlayback.clearPendingComplete(this.anim); BattleSpinePlayback.resetTracks(this.anim);