feat: 接入激励视频

This commit is contained in:
richarjiang
2026-06-29 19:55:40 +08:00
parent 411ca8c772
commit e2fd4eeda3
16 changed files with 9215 additions and 73 deletions

View File

@@ -0,0 +1,756 @@
# 梗中作乐 V1.0 - 源代码文档(第 13 部分)
软件名称:梗中作乐
版本号V1.0
---
## 第 13 部分:成就动画与通用弹窗
本部分包含通用确认弹窗、成就称号动画组件、成就称号管理器。
### 13.1 通用 Modal 弹窗 (assets/prefabs/CommonModal.ts)
```typescript
import { _decorator, error, instantiate, Label, Node, Prefab, Size, UITransform, Vec3, view } from 'cc';
import { BaseModal } from 'db://assets/scripts/core/BaseModal';
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
const { ccclass, property } = _decorator;
export type CommonModalAction = () => void | boolean;
export interface CommonModalCallbacks {
/** 点击关闭按钮回调,返回 false 可阻止默认关闭 */
onClose?: CommonModalAction;
/** 点击确认按钮回调,返回 false 可阻止默认关闭 */
onConfirm?: CommonModalAction;
/** 点击取消按钮回调,返回 false 可阻止默认关闭 */
onCancel?: CommonModalAction;
}
export interface CommonModalParams extends CommonModalCallbacks {
/** 弹窗标题 */
title?: string;
/** 弹窗内容 */
content?: string;
/** 确认按钮文案 */
buttonConfirm?: string;
/** 取消按钮文案,传入后展示双按钮区域 */
buttonCancel?: string;
/** 点击关闭按钮后是否自动关闭弹窗,默认 true */
closeOnClose?: boolean;
/** 点击确认按钮后是否自动关闭弹窗,默认 true */
closeOnConfirm?: boolean;
/** 点击取消按钮后是否自动关闭弹窗,默认 true */
closeOnCancel?: boolean;
/** 关闭时是否销毁节点,默认 true */
destroyOnClose?: boolean;
/** 弹窗层级,默认 CommonModal.MODAL_Z_INDEX */
zIndex?: number;
}
@ccclass('CommonModal')
export class CommonModal extends BaseModal {
public static readonly MODAL_Z_INDEX = 999;
private static readonly DEFAULT_TITLE = '温馨提示';
private static readonly DEFAULT_CONTENT = '';
private static readonly DEFAULT_BUTTON_CONFIRM = '确定';
private static readonly DEFAULT_BUTTON_CANCEL = '取消';
@property({ type: Label, tooltip: '标题文本' })
titleLabel: Label | null = null;
@property({ type: Label, tooltip: '内容文本' })
contentLabel: Label | null = null;
@property({ type: Label, tooltip: '确认按钮文本' })
buttonConfirmLabel: Label | null = null;
@property({ type: Label, tooltip: '双按钮确认文本' })
actionConfirmLabel: Label | null = null;
@property({ type: Label, tooltip: '取消按钮文本' })
buttonCancelLabel: Label | null = null;
@property({ type: Node, tooltip: '关闭按钮节点' })
closeBtn: Node | null = null;
@property({ type: Node, tooltip: '单确认按钮节点' })
buttonConfirm: Node | null = null;
@property({ type: Node, tooltip: '双按钮容器节点' })
actionDouble: Node | null = null;
@property({ type: Node, tooltip: '双按钮确认节点' })
actionConfirm: Node | null = null;
@property({ type: Node, tooltip: '双按钮取消节点' })
actionCancel: Node | null = null;
private _title: string = CommonModal.DEFAULT_TITLE;
private _content: string = CommonModal.DEFAULT_CONTENT;
private _buttonConfirmText: string = CommonModal.DEFAULT_BUTTON_CONFIRM;
private _buttonCancelText: string = CommonModal.DEFAULT_BUTTON_CANCEL;
private _callbacks: CommonModalCallbacks = {};
private _closeOnClose: boolean = true;
private _closeOnConfirm: boolean = true;
private _closeOnCancel: boolean = true;
private _destroyOnClose: boolean = true;
private _useDoubleActions: boolean = false;
private _screenSize: Size | null = null;
private _loaded: boolean = false;
/**
* 直接弹出通用弹窗。
*
* 使用示例:
* CommonModal.show(this.commonModalPrefab, {
* title: '提示',
* content: '是否继续?',
* buttonConfirm: '继续',
* onConfirm: () => this.startGame()
* });
*/
static show(prefab: Prefab, params: CommonModalParams = {}, parent?: Node): CommonModal | null {
const container = parent ?? ViewManager.instance.getContainer();
if (!container) {
error('[CommonModal] 缺少弹窗挂载节点');
return null;
}
const modalNode = instantiate(prefab);
const modal = modalNode.getComponent(CommonModal);
if (!modal) {
error('[CommonModal] 预制体缺少 CommonModal 组件');
modalNode.destroy();
return null;
}
modal.setParams(params);
modalNode.setPosition(Vec3.ZERO);
modalNode.setSiblingIndex(params.zIndex ?? CommonModal.MODAL_Z_INDEX);
container.addChild(modalNode);
modal.onViewLoad();
modal._doShow();
return modal;
}
protected start(): void {
this.onViewLoad();
if (!this.isShowing) {
this._doShow();
}
}
setParams(params: CommonModalParams = {}): void {
super.setParams(params);
this.setConfig(params);
}
setConfig(params: CommonModalParams = {}): void {
this._title = params.title ?? CommonModal.DEFAULT_TITLE;
this._content = params.content ?? CommonModal.DEFAULT_CONTENT;
this._buttonConfirmText = params.buttonConfirm ?? CommonModal.DEFAULT_BUTTON_CONFIRM;
this._buttonCancelText = params.buttonCancel ?? CommonModal.DEFAULT_BUTTON_CANCEL;
this._callbacks = {
onClose: params.onClose,
onConfirm: params.onConfirm,
onCancel: params.onCancel
};
this._closeOnClose = params.closeOnClose ?? true;
this._closeOnConfirm = params.closeOnConfirm ?? true;
this._closeOnCancel = params.closeOnCancel ?? true;
this._destroyOnClose = params.destroyOnClose ?? true;
this._useDoubleActions = this._shouldUseDoubleActions(params);
this._applyContent();
}
setTitle(title: string): void {
this._title = title;
this._applyContent();
}
setContent(content: string): void {
this._content = content;
this._applyContent();
}
setButtonConfirm(buttonConfirm: string): void {
this._buttonConfirmText = buttonConfirm;
this._applyContent();
}
setButtonCancel(buttonCancel: string): void {
this._buttonCancelText = buttonCancel;
this._useDoubleActions = true;
this._applyContent();
}
setCallbacks(callbacks: CommonModalCallbacks): void {
this._callbacks = callbacks;
}
close(destroy: boolean = this._destroyOnClose): void {
if (!this.node?.isValid) {
return;
}
if (this.isShowing) {
this._doHide();
}
if (destroy) {
this.node.destroy();
} else {
this.node.active = false;
}
}
onViewLoad(): void {
if (this._loaded) {
return;
}
this._loaded = true;
this._resolveNodes();
this._bindButtonEvents();
this._applyContent();
this._updateWidget();
}
onViewShow(): void {
this._resolveNodes();
this._applyContent();
this._updateWidget();
super.onViewShow();
}
onViewDestroy(): void {
this._unbindButtonEvents();
this._callbacks = {};
}
private _resolveNodes(): void {
const panelNode = this._findNode('dialogPanel');
this.backdropNode = this.backdropNode ?? this._findNode('BgMask');
this.animationNodes = this.animationNodes.length > 0 ? this.animationNodes : (panelNode ? [panelNode] : []);
this.closeBtn = this.closeBtn ?? this._findNode('dialogPanel/closeBtn');
this.buttonConfirm = this.buttonConfirm ?? this._findNode('dialogPanel/ButtonConfirm');
this.actionDouble = this.actionDouble ?? this._findNode('dialogPanel/ActionDouble');
this.actionConfirm = this.actionConfirm ?? this._findNode('dialogPanel/ActionDouble/ButtonConfirm');
this.actionCancel = this.actionCancel ?? this._findNode('dialogPanel/ActionDouble/ButtonCancel');
this.titleLabel = this.titleLabel ?? this._findNode('dialogPanel/Title')?.getComponent(Label) ?? null;
this.contentLabel = this.contentLabel ?? this._findNode('dialogPanel/Content')?.getComponent(Label) ?? null;
this.buttonConfirmLabel = this.buttonConfirmLabel
?? this._findNode('dialogPanel/ButtonConfirm/Label')?.getComponent(Label)
?? null;
this.actionConfirmLabel = this.actionConfirmLabel
?? this._findNode('dialogPanel/ActionDouble/ButtonConfirm/Label')?.getComponent(Label)
?? null;
this.buttonCancelLabel = this.buttonCancelLabel
?? this._findNode('dialogPanel/ActionDouble/ButtonCancel/Label')?.getComponent(Label)
?? null;
}
private _applyContent(): void {
this._resolveNodes();
this._applyActionMode();
if (this.titleLabel) {
this.titleLabel.string = this._title;
}
if (this.contentLabel) {
this.contentLabel.string = this._content;
}
if (this.buttonConfirmLabel) {
this.buttonConfirmLabel.string = this._buttonConfirmText;
}
if (this.actionConfirmLabel) {
this.actionConfirmLabel.string = this._buttonConfirmText;
}
if (this.buttonCancelLabel) {
this.buttonCancelLabel.string = this._buttonCancelText;
}
}
private _updateWidget(): void {
if (!this._screenSize) {
this._screenSize = view.getVisibleSize();
}
const uiTransform = this.node.getComponent(UITransform);
if (uiTransform) {
uiTransform.setContentSize(this._screenSize.width, this._screenSize.height);
}
}
private _bindButtonEvents(): void {
this._unbindButtonEvents();
if (this.closeBtn) {
this.closeBtn.on(Node.EventType.TOUCH_END, this._onCloseClick, this);
}
if (this.buttonConfirm) {
this.buttonConfirm.on(Node.EventType.TOUCH_END, this._onConfirmClick, this);
}
if (this.actionConfirm) {
this.actionConfirm.on(Node.EventType.TOUCH_END, this._onConfirmClick, this);
}
if (this.actionCancel) {
this.actionCancel.on(Node.EventType.TOUCH_END, this._onCancelClick, this);
}
}
private _unbindButtonEvents(): void {
if (this.closeBtn?.isValid) {
this.closeBtn.off(Node.EventType.TOUCH_END, this._onCloseClick, this);
}
if (this.buttonConfirm?.isValid) {
this.buttonConfirm.off(Node.EventType.TOUCH_END, this._onConfirmClick, this);
}
if (this.actionConfirm?.isValid) {
this.actionConfirm.off(Node.EventType.TOUCH_END, this._onConfirmClick, this);
}
if (this.actionCancel?.isValid) {
this.actionCancel.off(Node.EventType.TOUCH_END, this._onCancelClick, this);
}
}
private _onCloseClick(): void {
const shouldContinue = this._callbacks.onClose?.();
if (shouldContinue !== false && this._closeOnClose) {
this.close();
}
}
private _onConfirmClick(): void {
const shouldContinue = this._callbacks.onConfirm?.();
if (shouldContinue !== false && this._closeOnConfirm) {
this.close();
}
}
private _onCancelClick(): void {
const shouldContinue = this._callbacks.onCancel?.();
if (shouldContinue !== false && this._closeOnCancel) {
this.close();
}
}
private _applyActionMode(): void {
if (this.buttonConfirm?.isValid) {
this.buttonConfirm.active = !this._useDoubleActions;
}
if (this.actionDouble?.isValid) {
this.actionDouble.active = this._useDoubleActions;
}
}
private _shouldUseDoubleActions(params: CommonModalParams): boolean {
return params.buttonCancel !== undefined;
}
private _findNode(path: string): Node | null {
const names = path.split('/').filter(Boolean);
let current: Node | null = this.node;
for (const name of names) {
current = current?.getChildByName(name) ?? null;
if (!current) {
return null;
}
}
return current;
}
}
```
### 13.2 成就称号动画 (assets/scripts/utils/AchievementTitleAnimator.ts)
```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);
}
}
```
### 13.3 成就称号管理器 (assets/scripts/utils/AchievementTitleManager.ts)
```typescript
import { ACHIEVEMENT_TITLE_CONFIG, AchievementTitleConfigItem, INFINITE_ACHIEVEMENT_TITLE } from '../config/AchievementTitleConfig';
export interface AchievementTitleInfo {
readonly titleText: string;
readonly nextTitleText: string;
readonly nextTitleProgress: number;
readonly progressText: string;
readonly completedLevelCount: number;
readonly currentTitleStartCount: number;
readonly nextTitleRequiredCount: number;
readonly remainingToNextTitle: number;
}
interface AchievementTitleStage {
readonly titleText: string;
readonly clearsToNext: number;
readonly startCount: number;
}
export class AchievementTitleManager {
public static getTitleInfo(completedLevelCount: number): AchievementTitleInfo {
const safeCompletedCount = AchievementTitleManager.normalizeCompletedCount(completedLevelCount);
const stages = AchievementTitleManager.buildFiniteStages();
const finiteResult = AchievementTitleManager.findFiniteStage(safeCompletedCount, stages);
if (finiteResult) {
return AchievementTitleManager.createTitleInfo(safeCompletedCount, finiteResult.currentStage, finiteResult.nextStage);
}
return AchievementTitleManager.createInfiniteTitleInfo(safeCompletedCount, stages[stages.length - 1]);
}
private static normalizeCompletedCount(completedLevelCount: number): number {
if (!Number.isFinite(completedLevelCount) || completedLevelCount < 0) {
return 0;
}
return Math.floor(completedLevelCount);
}
private static buildFiniteStages(): AchievementTitleStage[] {
let startCount = 0;
return ACHIEVEMENT_TITLE_CONFIG.map((item: AchievementTitleConfigItem) => {
const stage: AchievementTitleStage = {
titleText: item.levelName,
clearsToNext: item.clearsToNext,
startCount
};
startCount += item.clearsToNext;
return stage;
});
}
private static findFiniteStage(completedLevelCount: number, stages: AchievementTitleStage[]): { currentStage: AchievementTitleStage; nextStage: AchievementTitleStage } | null {
for (let index = 0; index < stages.length; index++) {
const currentStage = stages[index];
const nextStage = stages[index + 1] ?? {
titleText: `${INFINITE_ACHIEVEMENT_TITLE.levelNamePrefix}1级`,
clearsToNext: INFINITE_ACHIEVEMENT_TITLE.clearsToNext,
startCount: currentStage.startCount + currentStage.clearsToNext
};
if (completedLevelCount < nextStage.startCount) {
return { currentStage, nextStage };
}
}
return null;
}
private static createInfiniteTitleInfo(completedLevelCount: number, lastFiniteStage: AchievementTitleStage): AchievementTitleInfo {
const infiniteStartCount = lastFiniteStage.startCount + lastFiniteStage.clearsToNext;
const completedAfterInfiniteStart = Math.max(0, completedLevelCount - infiniteStartCount);
const currentInfiniteLevel = Math.floor(completedAfterInfiniteStart / INFINITE_ACHIEVEMENT_TITLE.clearsToNext) + 1;
const currentStageStartCount = infiniteStartCount + (currentInfiniteLevel - 1) * INFINITE_ACHIEVEMENT_TITLE.clearsToNext;
const nextStage: AchievementTitleStage = {
titleText: `${INFINITE_ACHIEVEMENT_TITLE.levelNamePrefix}${currentInfiniteLevel + 1}`,
clearsToNext: INFINITE_ACHIEVEMENT_TITLE.clearsToNext,
startCount: currentStageStartCount + INFINITE_ACHIEVEMENT_TITLE.clearsToNext
};
return AchievementTitleManager.createTitleInfo(
completedLevelCount,
{
titleText: `${INFINITE_ACHIEVEMENT_TITLE.levelNamePrefix}${currentInfiniteLevel}`,
clearsToNext: INFINITE_ACHIEVEMENT_TITLE.clearsToNext,
startCount: currentStageStartCount
},
nextStage
);
}
private static createTitleInfo(completedLevelCount: number, currentStage: AchievementTitleStage, nextStage: AchievementTitleStage): AchievementTitleInfo {
const interval = Math.max(1, nextStage.startCount - currentStage.startCount);
const completedInCurrentTitle = Math.max(0, completedLevelCount - currentStage.startCount);
const remainingToNextTitle = Math.max(0, nextStage.startCount - completedLevelCount);
const nextTitleProgress = Math.max(0, Math.min(1, completedInCurrentTitle / interval));
return {
titleText: currentStage.titleText,
nextTitleText: nextStage.titleText,
nextTitleProgress,
progressText: `还差${remainingToNextTitle}题,解锁新成就等级`,
completedLevelCount,
currentTitleStartCount: currentStage.startCount,
nextTitleRequiredCount: nextStage.startCount,
remainingToNextTitle
};
}
}
```