253 lines
9.1 KiB
TypeScript
253 lines
9.1 KiB
TypeScript
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);
|
||
}
|
||
}
|