import { Label, Node, ProgressBar, tween, Tween } from 'cc'; /** * 称号进度展示数据 * 字段对应 AchievementTitleManager.getTitleInfo 的产物,但所有字段都是可选的, * 调用方可以只更新需要的部分(例如分享模式只想清空文字)。 */ export interface TitleProgressData { /** 当前称号文案(如「冷场小白1级」) */ titleText?: string; /** 进度提示文案(如「还差3题,解锁新成就等级」) */ progressText?: string; /** 当前称号到下一称号的进度,0-1 */ nextTitleProgress?: number; } /** * 进度条 / 称号视图所需的节点引用集合 */ export interface TitleAnimatorBindings { /** 称号文案 Label(如「冷场小白1级」),可空 */ titleLabel?: Label | null; /** 进度提示 Label(如「还差3题,解锁新成就等级」),可空 */ progressLabel?: Label | null; /** 进度条组件 */ progressBar?: ProgressBar | null; /** 进度条上跟随移动的 anchor 节点(若其下有 Label 子节点会自动写百分比) */ progressAnchor?: Node | null; } /** * 进度条动画起始前的等待时长(秒)。让弹窗 / 弹起动画稳定后再开播 */ const PROGRESS_ANIM_START_DELAY = 0.4; /** 单段进度条填充动画时长(秒) */ const PROGRESS_ANIM_SEGMENT_DURATION = 0.6; /** 跨称号切换时的等级信息刷新停顿(秒),让玩家看清称号变更 */ const PROGRESS_ANIM_LEVELUP_PAUSE = 0.12; /** * 九宫格 Bar Left+Right border = 240px、totalLength = 925px。 * width < 240px 时圆角会畸变,因此 progress > 0 时强制最小值。 */ const MIN_PROGRESS_RATIO = 240 / 925; /** anchor 起点的视觉微调(与 PageHome 等其他页面保持一致) */ const PROGRESS_ANCHOR_VISUAL_OFFSET = -30; /** * 把称号文字、进度条、跟随气泡这三件事打包成一个可复用的动画/展示工具。 * 没有引擎组件依赖(不是 cc.Component),可以被任何持有节点引用的对象 new 一个出来用。 * * 起源:原本只在 PassModal 内部实现。PassNode 替换 PassModal 后, * PageLevel 也需要同一套行为,所以抽出来共用。 */ export class AchievementTitleAnimator { private _bindings: TitleAnimatorBindings = {}; /** anchor 起点 X(在 progressBar 父节点空间下),首次解析后缓存 */ private _progressAnchorStartX: number | null = null; /** Tween 共享的目标对象,方便 stopAllByTarget */ private readonly _tweenTarget: { progress: number } = { progress: 0 }; /** 绑定 / 重新绑定节点引用。任何重新绑定都会清掉缓存的 anchor 起点 */ bind(bindings: TitleAnimatorBindings): void { this._bindings = bindings; this._progressAnchorStartX = null; } /** 直接展示终态,无动画 */ setTarget(data: TitleProgressData): void { this.stop(); this._applyTitleText(data.titleText); this._applyProgressText(data.progressText); this._applyProgressValue(data.nextTitleProgress); } /** * 从 prev → current 播过渡动画 * - prev 为空:直接展示 current 终态 * - 同称号:单段 tween * - 跨称号:先填满旧称号,再切到新称号 + 进度从 0 涨到 current */ playTransition(prev: TitleProgressData | null | undefined, current: TitleProgressData): void { if (!prev) { this.setTarget(current); return; } const startProgress = prev.nextTitleProgress; const endProgress = current.nextTitleProgress; if (startProgress === undefined || endProgress === undefined) { this.setTarget(current); return; } const isSameTitle = prev.titleText === undefined || current.titleText === undefined || prev.titleText === current.titleText; // 同称号且起止相同,没必要播动画 if (isSameTitle && Math.abs(startProgress - endProgress) < 1e-4) { this.setTarget(current); return; } this.stop(); if (isSameTitle) { // 先展示文字 + 起点进度,再 tween 到终点 this._applyTitleText(current.titleText); this._applyProgressText(current.progressText); this._applyProgressValue(startProgress); this._runSegmentTween(startProgress, endProgress, PROGRESS_ANIM_START_DELAY); return; } // 跨称号:先展示旧称号 + 起点进度 this._applyTitleText(prev.titleText); this._applyProgressText(prev.progressText); this._applyProgressValue(startProgress); const target = this._tweenTarget; target.progress = this._clamp(startProgress); const onUpdate = () => this._applyAnimatedProgress(target.progress); tween(target) .delay(PROGRESS_ANIM_START_DELAY) .to(PROGRESS_ANIM_SEGMENT_DURATION, { progress: 1 }, { easing: 'sineOut', onUpdate }) .call(() => { this._applyTitleText(current.titleText); this._applyProgressText(current.progressText); target.progress = 0; this._applyAnimatedProgress(0); }) .delay(PROGRESS_ANIM_LEVELUP_PAUSE) .to( PROGRESS_ANIM_SEGMENT_DURATION, { progress: this._clamp(endProgress) }, { easing: 'sineOut', onUpdate }, ) .start(); } /** 停止当前动画(不影响已展示的进度值) */ stop(): void { Tween.stopAllByTarget(this._tweenTarget); } private _runSegmentTween(from: number, to: number, delay: number): void { const target = this._tweenTarget; target.progress = this._clamp(from); this._applyAnimatedProgress(from); const chain = tween(target); if (delay > 0) { chain.delay(delay); } chain.to( PROGRESS_ANIM_SEGMENT_DURATION, { progress: this._clamp(to) }, { easing: 'sineOut', onUpdate: () => this._applyAnimatedProgress(target.progress), }, ).start(); } private _applyTitleText(text: string | undefined): void { if (text === undefined) return; const label = this._bindings.titleLabel; if (label?.isValid) { label.string = text; } } private _applyProgressText(text: string | undefined): void { if (text === undefined) return; const label = this._bindings.progressLabel; if (label?.isValid) { label.string = text; } } private _applyProgressValue(progress: number | undefined): void { if (progress === undefined) return; this._applyAnimatedProgress(progress); } private _applyAnimatedProgress(progress: number): void { const clamped = this._clamp(progress); const bar = this._bindings.progressBar; if (bar?.isValid) { bar.progress = this._normalize(clamped); } this._updateProgressAnchor(clamped); } private _updateProgressAnchor(progress: number): void { const anchor = this._bindings.progressAnchor; if (!anchor?.isValid) return; this._cacheProgressAnchorStartX(); const startX = this._progressAnchorStartX ?? anchor.position.x; const travelWidth = this._getProgressAnchorTravelWidth(); anchor.setPosition( startX + travelWidth * progress, anchor.position.y, anchor.position.z, ); const percentLabel = anchor.getChildByName('Label')?.getComponent(Label); if (percentLabel) { percentLabel.string = `${Math.round(progress * 100)}%`; } } private _getProgressAnchorTravelWidth(): number { const bar = this._bindings.progressBar; if (!bar) return 0; return Math.abs(bar.totalLength * bar.node.scale.x); } /** * Bar 节点 anchor 为 (0, 0.5),其本地 position.x 即进度条可视左端。 * ProgressBar 与 ProgressAnchor 共享同一父节点,因此把 Bar 的本地 X * 按 ProgressBar 自身的位移与缩放映射到父节点空间,才是真正的「0% 起点」。 * 直接拿 anchor.position.x 当起点会被 prefab 摆放偏移量带跑。 */ private _cacheProgressAnchorStartX(): void { if (this._progressAnchorStartX !== null) return; const bar = this._bindings.progressBar; const barSprite = bar?.barSprite; if (!bar || !barSprite) return; const barLocalX = barSprite.node.position.x; this._progressAnchorStartX = bar.node.position.x + barLocalX * bar.node.scale.x + PROGRESS_ANCHOR_VISUAL_OFFSET; } private _normalize(progress: number): number { if (!Number.isFinite(progress) || progress <= 0) return 0; return Math.max(MIN_PROGRESS_RATIO, Math.min(1, progress)); } private _clamp(progress: number): number { if (!Number.isFinite(progress) || progress <= 0) return 0; return Math.min(1, progress); } }