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

26 KiB
Raw Blame History

梗中作乐 V1.0 - 源代码文档(第 13 部分)

软件名称:梗中作乐 版本号V1.0


第 13 部分:成就动画与通用弹窗

本部分包含通用确认弹窗、成就称号动画组件、成就称号管理器。

13.1 通用 Modal 弹窗 (assets/prefabs/CommonModal.ts)

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)

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)

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
        };
    }
}