import { instantiate, Node, Tween, tween, UIOpacity, UITransform, v3, Vec3, } from 'cc'; /** btnStart 下体力底与图标节点名 */ const START_POWER_BG_NODE = 'powerBg'; const START_POWER_ICON_NODE = 'power'; /** ui_vigour 下体力图标节点名 */ const VIGOUR_ICON_NODE = '体力'; /** 随机起点相对体力图标中心的最小距离(像素) */ const SPAWN_RADIUS_MIN = 50; /** 随机起点相对体力图标中心的最大距离(像素) */ const SPAWN_RADIUS_MAX = 110; /** 各散开起点彼此最小间距(像素) */ const SPAWN_GAP_MIN = 20; /** 选取散开起点最大重试次数 */ const SPAWN_PICK_TRY_MAX = 24; /** 抛物线顶点相对起终点中点的抬升高度(像素) */ const PEAK_HEIGHT = 280; /** 体力 → 随机起点基准时长(秒) */ const GATHER_SEC = 0.22; /** 随机起点飞行时长随机偏移(±秒) */ const GATHER_SEC_JITTER = 0.04; /** 起点 → power 飞行基准时长(秒) */ const FLY_SEC = 0.38; /** 起点 → power 飞行时长随机偏移(±秒) */ const FLY_SEC_JITTER = 0.06; /** 弹出时长(秒) */ const POP_SEC = 0.14; /** 到达缩小时长(秒) */ const SHRINK_SEC = 0.1; /** 左右弧线侧向偏移上限(像素) */ const SIDE_ARC_PX = 96; /** 弧线方向:左半弧 / 右半弧 */ type VigourArcSide = 'left' | 'right'; /** 抛物线插值进度 */ type ParabolaFlyState = { t: number }; /** * 首页开始游戏:体力栏弹出影子,先飞向四周随机起点,再沿抛物线飞向 btnStart/power。 */ export class PanelHomeStartVigourFlyPresenter { private _flyLayer: Node | null = null; private _vigourRoot: Node | null = null; private _btnStart: Node | null = null; private readonly _shadowNodes: Node[] = []; private readonly _flyStates: ParabolaFlyState[] = []; private _playing = false; private _flyGen = 0; private _pendingArrive = 0; private _flyComplete: (() => void) | null = null; private _targetIcon: Node | null = null; private readonly _targetScaleHome = v3(1, 1, 1); private readonly _shadowWorldScaleHome = v3(1, 1, 1); /** * 绑定飞行层与起终点容器。 * @param flyLayer 影子父节点(建议 PanelHome 根节点,避免被 ui_baseInfo 遮挡) * @param vigourRoot ui_vigour * @param btnStart 开始按钮 */ bind(flyLayer: Node | null | undefined, vigourRoot: Node | null | undefined, btnStart: Node | null | undefined): void { this._flyLayer = flyLayer?.isValid ? flyLayer : null; this._vigourRoot = vigourRoot?.isValid ? vigourRoot : null; this._btnStart = btnStart?.isValid ? btnStart : null; } /** 停止飞行并清理影子 */ unbind(): void { this._flyGen++; this._playing = false; this._flyComplete = null; this._resetTargetPulse(); this._stopShadows(); this._flyLayer = null; this._vigourRoot = null; this._btnStart = null; this._targetIcon = null; } /** 是否正在播放飞行动画 */ isPlaying(): boolean { return this._playing; } /** * 播放体力消耗飞行(纯表现,不修改 gg.data 体力)。 * @param onComplete 全部到达 * @returns 是否已开始播放 */ play(onComplete: () => void): boolean { if (this._playing) return false; this._playing = true; const flyLayer = this._flyLayer; const sourceIcon = this._resolveVigourIcon(); const targetIcon = this._resolveStartPowerIcon(); if (!flyLayer?.isValid || !sourceIcon?.isValid || !targetIcon?.isValid) { console.log('[PanelHomeStartVigourFly] 飞行参数无效,跳过'); this._playing = false; return false; } const cost = Math.max(1, gg.data.project.getBattleVigourCost()); if (!gg.data.project.checkVigourEnough()) { console.log('[PanelHomeStartVigourFly] 体力不足', gg.data.getVigour(), cost); this._playing = false; return false; } gg.audio.playEffect({ name: '体力消耗' }); this._stopShadows(); this._flyGen++; const flyGen = this._flyGen; this._flyComplete = onComplete; this._targetIcon = targetIcon; this._targetIcon.getScale(this._targetScaleHome); this._pendingArrive = cost; const endPos = this._resolveWorldCenter(targetIcon); const srcPos = this._resolveWorldCenter(sourceIcon); this._shadowWorldScaleHome.set(targetIcon.worldScale); const spawns = this._planSpawnRing(srcPos, cost); for (let i = 0; i < cost; i++) { const shadow = instantiate(targetIcon); shadow.name = 'vigourFlyShadow'; flyLayer.addChild(shadow); shadow.setSiblingIndex(flyLayer.children.length - 1); const spawn = spawns[i]; shadow.setWorldPosition(srcPos); shadow.setWorldScale(Vec3.ZERO); shadow.angle = targetIcon.angle; const opacity = shadow.getOrAddComponent(UIOpacity); opacity.opacity = 0; this._shadowNodes.push(shadow); const arcSide = this._resolveArcSideBySpawn(spawn, srcPos); Tween.stopAllByTarget(shadow); Tween.stopAllByTarget(opacity); this._scheduleShadowFly( shadow, opacity, srcPos.clone(), spawn, endPos.clone(), arcSide, this._rollGatherSec(), this._rollFlySec(), flyGen, ); } return true; } /** * 按生成点相对体力中心的左右侧决定弧线方向。 * @param spawn 生成点 * @param center 体力图标中心 */ private _resolveArcSideBySpawn(spawn: Readonly, center: Readonly): VigourArcSide { if (spawn.x < center.x) return 'left'; if (spawn.x > center.x) return 'right'; return spawn.y >= center.y ? 'left' : 'right'; } /** * 以体力为圆心,在圆环上均分扇区并随机散落起点。 * @param center 体力图标中心世界坐标 * @param count 散开数量 */ private _planSpawnRing(center: Readonly, count: number): Vec3[] { const out: Vec3[] = []; if (count <= 0) return out; const minGapSq = SPAWN_GAP_MIN * SPAWN_GAP_MIN; const baseAngle = Math.random() * Math.PI * 2; const sectorRad = (Math.PI * 2) / count; const jitterHalf = sectorRad * 0.32; for (let i = 0; i < count; i++) { const angle = baseAngle + sectorRad * i + sectorRad * 0.5 + (Math.random() * 2 - 1) * jitterHalf; let radius = SPAWN_RADIUS_MIN + Math.random() * (SPAWN_RADIUS_MAX - SPAWN_RADIUS_MIN); let pos = this._spawnOnRing(center, angle, radius); let tries = 0; while (tries < SPAWN_PICK_TRY_MAX && this._minGapSqToUsed(pos, out) < minGapSq) { radius = Math.min(SPAWN_RADIUS_MAX, radius + 8); pos = this._spawnOnRing(center, angle, radius); tries++; } if (out.length > 0 && this._minGapSqToUsed(pos, out) < minGapSq) { console.log( '[PanelHomeStartVigourFly] 散开起点间距不足', Math.sqrt(this._minGapSqToUsed(pos, out)).toFixed(1), SPAWN_GAP_MIN, count, ); } out.push(pos); } return out; } /** 圆环上一点(极坐标 → 世界坐标) */ private _spawnOnRing(center: Readonly, angleRad: number, radius: number): Vec3 { return v3( center.x + Math.cos(angleRad) * radius, center.y + Math.sin(angleRad) * radius, center.z, ); } /** 候选点与已占用散开点的最小间距平方 */ private _minGapSqToUsed(pos: Readonly, used: readonly Vec3[]): number { if (used.length <= 0) return Number.POSITIVE_INFINITY; let minSq = Number.POSITIVE_INFINITY; for (let i = 0; i < used.length; i++) { const dx = pos.x - used[i].x; const dy = pos.y - used[i].y; const sq = dx * dx + dy * dy; if (sq < minSq) minSq = sq; } return minSq; } /** btnStart/powerBg/power */ private _resolveStartPowerIcon(): Node | null { const btn = this._btnStart; if (!btn?.isValid) return null; const powerBg = btn.getChildByName(START_POWER_BG_NODE); if (!powerBg?.isValid) { console.log('[PanelHomeStartVigourFly] btnStart 缺少', START_POWER_BG_NODE); return null; } const power = powerBg.getChildByName(START_POWER_ICON_NODE); if (!power?.isValid) { console.log('[PanelHomeStartVigourFly] powerBg 缺少', START_POWER_ICON_NODE); return null; } return power; } /** ui_vigour/体力 */ private _resolveVigourIcon(): Node | null { const root = this._vigourRoot; if (!root?.isValid) return null; const icon = root.getChildByName(VIGOUR_ICON_NODE); if (!icon?.isValid) { console.log('[PanelHomeStartVigourFly] ui_vigour 缺少', VIGOUR_ICON_NODE); return null; } return icon; } /** 节点中心世界坐标 */ private _resolveWorldCenter(node: Node): Vec3 { const ut = node.getComponent(UITransform); if (ut) return ut.convertToWorldSpaceAR(v3(0, 0, 0)); return node.worldPosition.clone(); } /** 体力 → 随机起点时长(基准 ± 随机偏移) */ private _rollGatherSec(): number { const delta = (Math.random() * 2 - 1) * GATHER_SEC_JITTER; return Math.max(0.14, GATHER_SEC + delta); } /** 随机起点 → power 时长(基准 ± 随机偏移) */ private _rollFlySec(): number { const delta = (Math.random() * 2 - 1) * FLY_SEC_JITTER; return Math.max(0.26, FLY_SEC + delta); } /** 直线插值世界坐标 */ private _applyLerpPos(shadow: Node, from: Readonly, to: Readonly, t: number): void { shadow.setWorldPosition( from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t, from.z, ); } /** 抛物线插值世界坐标(顶点在起终点中点上方) */ private _applyParabolaPos( shadow: Node, from: Readonly, to: Readonly, t: number, arcSide: VigourArcSide, ): void { const sideSign = arcSide === 'left' ? -1 : 1; const midX = (from.x + to.x) * 0.5; const midY = (from.y + to.y) * 0.5; const apexY = midY + PEAK_HEIGHT; const u = 1 - t; const side = Math.sin(t * Math.PI) * SIDE_ARC_PX * sideSign; const x = u * u * from.x + 2 * u * t * midX + t * t * to.x + side; const y = u * u * from.y + 2 * u * t * apexY + t * t * to.y; shadow.setWorldPosition(x, y, from.z); } /** * 弹出 → 体力飞向随机起点 → 抛物线飞向 power → 缩小消失。 * @param shadow 影子节点 * @param opacity 透明度组件 * @param vigourPos 体力图标中心 * @param spawn 随机起点 * @param to power 终点 * @param arcSide 左/右弧 * @param gatherSec 体力 → 起点时长 * @param flySec 起点 → power 时长 * @param flyGen 世代号 */ private _scheduleShadowFly( shadow: Node, opacity: UIOpacity, vigourPos: Readonly, spawn: Readonly, to: Readonly, arcSide: VigourArcSide, gatherSec: number, flySec: number, flyGen: number, ): void { const home = this._shadowWorldScaleHome; const popScale = v3(home.x * 1.15, home.y * 1.15, home.z); const gatherState: ParabolaFlyState = { t: 0 }; const flyState: ParabolaFlyState = { t: 0 }; this._flyStates.push(gatherState); this._flyStates.push(flyState); Tween.stopAllByTarget(gatherState); Tween.stopAllByTarget(flyState); tween(shadow) .parallel( tween().to(POP_SEC, { worldScale: popScale }, { easing: 'backOut' }), tween(opacity).to(POP_SEC * 0.85, { opacity: 255 }), ) .parallel( tween(gatherState).to(gatherSec, { t: 1 }, { easing: 'quadOut', onUpdate: () => { if (!shadow.isValid) return; this._applyLerpPos(shadow, vigourPos, spawn, gatherState.t); }, }), ) .parallel( tween(flyState).to(flySec, { t: 1 }, { easing: 'sineIn', onUpdate: () => { if (!shadow.isValid) return; this._applyParabolaPos(shadow, spawn, to, flyState.t, arcSide); }, }), tween().to(flySec * 0.55, { worldScale: home.clone() }), ) .parallel( tween().to(SHRINK_SEC, { worldScale: Vec3.ZERO }), tween(opacity).to(SHRINK_SEC, { opacity: 0 }), ) .call(() => this._onShadowArrive(shadow, flyGen)) .start(); } /** 单枚到达:销毁并统计 */ private _onShadowArrive(shadow: Node, flyGen: number): void { if (flyGen !== this._flyGen) return; this._destroyShadow(shadow); this._pulseTargetIcon(); this._pendingArrive--; if (this._pendingArrive > 0) return; this._playing = false; const done = this._flyComplete; this._flyComplete = null; this._resetTargetPulse(); done?.(); } /** 目标 btnStart/power 轻微缩放反馈 */ private _pulseTargetIcon(): void { const icon = this._targetIcon; if (!icon?.isValid) return; Tween.stopAllByTarget(icon); icon.setScale(this._targetScaleHome); const pulse = v3(this._targetScaleHome.x * 1.18, this._targetScaleHome.y * 1.18, 1); tween(icon) .to(0.06, { scale: pulse }) .to(0.1, { scale: this._targetScaleHome.clone() }) .start(); } /** 复位目标图标缩放 */ private _resetTargetPulse(): void { const icon = this._targetIcon; if (!icon?.isValid) return; Tween.stopAllByTarget(icon); icon.setScale(this._targetScaleHome); } /** 销毁单枚影子 */ private _destroyShadow(shadow: Node): void { if (!shadow?.isValid) return; Tween.stopAllByTarget(shadow); const op = shadow.getComponent(UIOpacity); if (op) Tween.stopAllByTarget(op); shadow.destroy(); for (let i = this._shadowNodes.length - 1; i >= 0; i--) { if (this._shadowNodes[i] === shadow) { this._shadowNodes.splice(i, 1); } } } /** 销毁全部影子与 tween 状态 */ private _stopShadows(): void { for (let i = this._flyStates.length - 1; i >= 0; i--) { Tween.stopAllByTarget(this._flyStates[i]); } this._flyStates.length = 0; for (let i = this._shadowNodes.length - 1; i >= 0; i--) { this._destroyShadow(this._shadowNodes[i]); } this._shadowNodes.length = 0; } }