Files
mp-xieyingeng/软著源代码/13-成就动画与通用Modal.md
2026-06-29 19:55:40 +08:00

757 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 梗中作乐 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
};
}
}
```