Files
mp-xieyingeng/软著源代码/08-通关弹窗与提示.md
2026-06-29 19:55:40 +08:00

22 KiB
Raw Blame History

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

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


第 8 部分:通关弹窗与轻提示

本部分包含通关庆祝弹窗、预览关卡项组件、轻提示组件以及 Toast 管理工具。

8.1 通关弹窗 (assets/prefabs/PassModal.ts)

import { _decorator, Node, Label, AudioClip, AudioSource, view, UITransform, Size, ProgressBar, tween, Tween } from 'cc';
import { BaseModal } from 'db://assets/scripts/core/BaseModal';
import { WxSDK } from 'db://assets/scripts/utils/WxSDK';
import { AudioManager } from 'db://assets/scripts/utils/AudioManager';
const { ccclass, property } = _decorator;

/**
 * PassModal 回调接口
 */
export interface PassModalCallbacks {
    /** 点击下一关回调 */
    onNextLevel?: () => void;
    /** 点击分享回调 */
    onShare?: () => void;
    /** 点击返回主页回调 */
    onHome?: () => void;
}

export interface PassModalTitleInfo {
    titleText?: string;
    nextTitleProgress?: number;
    progressText?: string;
}

interface PassModalParams {
    levelIndex?: number;
    /** 下一步按钮文案,不传时使用 prefab 默认文案 */
    nextButtonText?: string;
    titleInfo?: PassModalTitleInfo;
    /**
     * 通关前的称号信息。传入后,本次显示会把进度条从该起点动画到 titleInfo 的终点;
     * 起点与终点 titleText 不同则分两段(先填满当前等级,再切换到新等级后填到目标进度)。
     * 分享模式等无本地进度变化的场景不要传。
     */
    previousTitleInfo?: PassModalTitleInfo;
}

/**
 * 通关弹窗组件
 * 继承 BaseModal显示通关成功弹窗提供"下一关"和"分享给好友"两个按钮
 */
@ccclass('PassModal')
export class PassModal extends BaseModal {
    /** 静态常量:弹窗层级 */
    public static readonly MODAL_Z_INDEX = 999;

    /** 下一关按钮 */
    @property(Node)
    nextLevelButton: Node | null = null;

    /** 返回主页按钮 */
    @property(Node)
    settingButton: Node | null = null;

    /** 分享按钮 */
    @property(Node)
    shareButton: Node | null = null;

    /** 称号文字 */
    @property(Label)
    titleLevelLabel: Label | null = null;

    /** 距离下一个称号的进度 */
    @property(ProgressBar)
    titleProgressBar: ProgressBar | null = null;

    /** 进度提示文案 */
    @property(Label)
    progressLabel: Label | null = null;

    /** 称号进度游标 */
    @property(Node)
    progressAnchor: Node | null = null;

    /** 通关音效 */
    @property(AudioClip)
    successAudio: AudioClip | null = null;

    /** 进度条动画起始前的等待时长(秒),等弹窗开场动画稳定后再开始 */
    private static readonly PROGRESS_ANIM_START_DELAY = 0.4;
    /** 单段进度条填充动画时长(秒) */
    private static readonly PROGRESS_ANIM_SEGMENT_DURATION = 0.6;
    /** 跨称号切换时的等级信息刷新停顿(秒),让玩家看清称号变更 */
    private static readonly PROGRESS_ANIM_LEVELUP_PAUSE = 0.12;

    /** 回调函数 */
    private _callbacks: PassModalCallbacks = {};

    /** 缓存的屏幕尺寸 */
    private _screenSize: Size | null = null;

    /** 称号展示数据(终态) */
    private _titleInfo: PassModalTitleInfo = {
        titleText: '冷场小白1级',
        nextTitleProgress: 0,
        progressText: '还差3题解锁新成就等级'
    };

    /** 动画起点。为 null 表示不做进度动画,直接展示终态 */
    private _previousTitleInfo: PassModalTitleInfo | null = null;

    /** 进度动画所绑定的对象,用于 Tween.stopAllByTarget */
    private readonly _progressTweenTarget: { progress: number } = { progress: 0 };

    /** 下一步按钮文案,为 null 时保留 prefab 默认值 */
    private _nextButtonText: string | null = null;

    /** 进度游标 0% 时的本地 X 坐标,根据 ProgressBar Bar 子节点的左端推导出来 */
    private _progressAnchorStartX: number | null = null;

    setParams(params: PassModalParams): void {
        super.setParams(params);

        // previousTitleInfo 可以显式传 null 来禁用动画undefined 表示"保持已有状态"
        if (params && 'previousTitleInfo' in params) {
            this._previousTitleInfo = params.previousTitleInfo ?? null;
        }

        if (params?.titleInfo) {
            this.setTitleInfo(params.titleInfo);
        }

        if (params && 'nextButtonText' in params) {
            this._nextButtonText = params.nextButtonText ?? null;
            this._applyNextButtonText();
        }
    }

    /**
     * 设置回调函数
     */
    setCallbacks(callbacks: PassModalCallbacks): void {
        this._callbacks = callbacks;
    }

    /**
     * 设置称号体系展示数据
     */
    setTitleInfo(titleInfo: PassModalTitleInfo): void {
        this._titleInfo = {
            ...this._titleInfo,
            ...titleInfo
        };
        this._refreshTitleView();
    }

    /**
     * 页面首次加载时调用
     */
    onViewLoad(): void {
        console.log('[PassModal] onViewLoad');
        this._resolveNodes();
        this._resolveProgressAnchor();
        this._cacheProgressAnchorStartX();
        this._bindButtonEvents();
    }

    /**
     * 页面每次显示时调用
     */
    onViewShow(): void {
        super.onViewShow();
        this._updateWidget();
        this._refreshTitleView();
        this._applyNextButtonText();
        this._playSuccessSound();
        this._playProgressAnimation();
    }

    /**
     * 页面隐藏时调用
     */
    onViewHide(): void {
        super.onViewHide();
        this._stopProgressAnimation();
    }

    /**
     * 页面销毁时调用
     */
    onViewDestroy(): void {
        this._stopProgressAnimation();
        this._unbindButtonEvents();
    }

    /**
     * 设置弹窗尺寸为全屏
     * 动态实例化后,手动设置节点尺寸覆盖整个屏幕
     */
    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 {
        if (this.nextLevelButton) {
            this.nextLevelButton.on(Node.EventType.TOUCH_END, this._onNextLevelClick, this);
        }
        if (this.settingButton) {
            this.settingButton.on(Node.EventType.TOUCH_END, this._onHomeClick, this);
        }
        if (this.shareButton) {
            this.shareButton.on(Node.EventType.TOUCH_END, this._onShareClick, this);
        }
    }

    /**
     * 解除按钮事件绑定
     */
    private _unbindButtonEvents(): void {
        // 节点可能在销毁过程中已被置空,需要检查 isValid
        if (this.nextLevelButton && this.nextLevelButton.isValid) {
            this.nextLevelButton.off(Node.EventType.TOUCH_END, this._onNextLevelClick, this);
        }
        if (this.settingButton && this.settingButton.isValid) {
            this.settingButton.off(Node.EventType.TOUCH_END, this._onHomeClick, this);
        }
        if (this.shareButton && this.shareButton.isValid) {
            this.shareButton.off(Node.EventType.TOUCH_END, this._onShareClick, this);
        }
    }

    private _resolveNodes(): void {
        this.nextLevelButton = this.nextLevelButton ?? this.node.getChildByName('Button') ?? null;
        this.settingButton = this.settingButton ?? this.node.getChildByName('SettingButton') ?? null;
        this.shareButton = this.shareButton ?? this.node.getChildByName('Share') ?? null;
    }

    /**
     * 播放通关音效
     */
    private _playSuccessSound(): void {
        if (!this.successAudio) {
            return;
        }

        const audioSource = this.node.getComponent(AudioSource) ?? this.node.addComponent(AudioSource);
        audioSource.playOneShot(this.successAudio);
    }

    /**
     * 用当前 _titleInfo 刷新视图(称号、进度条、进度文案)
     * 进度条动画运行时,会由动画控制进度值,这里仍然把进度写为终态
     * —— _playProgressAnimation 会在动画开始前覆盖为起点。
     */
    private _refreshTitleView(): void {
        this._applyTitleText(this._titleInfo.titleText);
        this._applyProgressText(this._titleInfo.progressText);
        this._applyProgressValue(this._titleInfo.nextTitleProgress);
    }

    private _applyTitleText(text: string | undefined): void {
        if (this.titleLevelLabel && text !== undefined) {
            this.titleLevelLabel.string = text;
        }
    }

    private _applyNextButtonText(): void {
        if (!this.nextLevelButton || this._nextButtonText === null) {
            return;
        }

        const label = this.nextLevelButton.getChildByName('Label')?.getComponent(Label);
        if (label) {
            label.string = this._nextButtonText;
        }
    }

    private _applyProgressText(text: string | undefined): void {
        if (this.progressLabel && text !== undefined) {
            this.progressLabel.string = text;
        }
    }

    private _applyProgressValue(progress: number | undefined): void {
        if (progress === undefined) {
            return;
        }

        this._applyAnimatedProgress(progress);
    }

    /**
     * 根据 _previousTitleInfo → _titleInfo 驱动进度条过渡动画
     *
     * 三种情况:
     * 1. 无起点信息或起点/终点相同:不播动画
     * 2. 同称号下涨进度:一段 tween
     * 3. 跨称号:先把旧称号填到 1.0,然后切换称号文字、进度回 0再 tween 到终点进度
     */
    private _playProgressAnimation(): void {
        const prev = this._previousTitleInfo;
        // 动画是一次性的,播放前消费掉,避免弹窗被复用时重复播
        this._previousTitleInfo = null;

        if (!this.titleProgressBar || !prev) {
            return;
        }

        const startProgress = prev.nextTitleProgress;
        const endProgress = this._titleInfo.nextTitleProgress;
        if (startProgress === undefined || endProgress === undefined) {
            return;
        }

        const isSameTitle = prev.titleText === undefined
            || this._titleInfo.titleText === undefined
            || prev.titleText === this._titleInfo.titleText;

        // 同称号且起止相同,没必要播动画
        if (isSameTitle && Math.abs(startProgress - endProgress) < 1e-4) {
            return;
        }

        this._stopProgressAnimation();

        if (isSameTitle) {
            // 先展示起点,避免 _refreshTitleView 已把条填到终态
            this._applyProgressValue(startProgress);
            this._runProgressTween(startProgress, endProgress, PassModal.PROGRESS_ANIM_START_DELAY);
            return;
        }

        // 跨称号:先让旧称号文字和起点进度出现在屏上
        this._applyTitleText(prev.titleText);
        this._applyProgressText(prev.progressText);
        this._applyProgressValue(startProgress);

        const self = this;
        const tweenTarget = this._progressTweenTarget;
        // raw 值保留 0~1下发时经 _normalizeProgress
        tweenTarget.progress = Math.max(0, Math.min(1, startProgress));
        const clampedEnd = Math.max(0, Math.min(1, endProgress));

        const onUpdate = () => {
            self._applyAnimatedProgress(tweenTarget.progress);
        };

        tween(tweenTarget)
            .delay(PassModal.PROGRESS_ANIM_START_DELAY)
            .to(
                PassModal.PROGRESS_ANIM_SEGMENT_DURATION,
                { progress: 1 },
                { easing: 'sineOut', onUpdate }
            )
            .call(() => {
                // 切到新称号。progressText/titleText 都切到终态;进度值从 0 开始
                self._applyTitleText(self._titleInfo.titleText);
                self._applyProgressText(self._titleInfo.progressText);
                tweenTarget.progress = 0;
                self._applyAnimatedProgress(0);
            })
            .delay(PassModal.PROGRESS_ANIM_LEVELUP_PAUSE)
            .to(
                PassModal.PROGRESS_ANIM_SEGMENT_DURATION,
                { progress: clampedEnd },
                { easing: 'sineOut', onUpdate }
            )
            .start();
    }

    private _runProgressTween(from: number, to: number, delay: number): void {
        if (!this.titleProgressBar) {
            return;
        }

        const tweenTarget = this._progressTweenTarget;
        // raw 值保留 0~1 区间onUpdate 里经 _normalizeProgress 再下发,避免畸变区段
        tweenTarget.progress = this._clampProgress(from);
        this._applyAnimatedProgress(from);

        const self = this;
        const chain = tween(tweenTarget);
        if (delay > 0) {
            chain.delay(delay);
        }

        chain
            .to(
                PassModal.PROGRESS_ANIM_SEGMENT_DURATION,
                { progress: Math.max(0, Math.min(1, to)) },
                {
                    easing: 'sineOut',
                    onUpdate: () => {
                        self._applyAnimatedProgress(tweenTarget.progress);
                    }
                }
            )
            .start();
    }

    private _cacheProgressAnchorStartX(): void {
        if (this._progressAnchorStartX !== null || !this.titleProgressBar) {
            return;
        }

        const barSprite = this.titleProgressBar.barSprite;
        if (!barSprite) {
            return;
        }

        // Bar 节点 anchor 为 (0, 0.5),其本地 position.x 即为进度条可视左端。
        // ProgressBar 与 ProgressAnchor 共享同一父节点TitleLevel
        // 因此把 Bar 的本地 X 按 ProgressBar 自身的位移与缩放映射到父节点空间,
        // 才是真正的「0% 起点」。直接拿 progressAnchor.position.x 当起点会导致
        // 气泡始终被 prefab 摆放偏移量带跑(实测偏右 ~24px        // -40 为视觉微调,与 PageHome 保持一致。
        const progressBarNode = this.titleProgressBar.node;
        const barLocalX = barSprite.node.position.x;
        this._progressAnchorStartX = progressBarNode.position.x + barLocalX * progressBarNode.scale.x - 30;
    }

    private _resolveProgressAnchor(): void {
        if (this.progressAnchor?.isValid) {
            return;
        }

        this.progressAnchor = this.node
            .getChildByName('Bg')
            ?.getChildByName('Title')
            ?.getChildByName('ProgressAnchor') ?? null;
    }

    private _applyAnimatedProgress(progress: number): void {
        const clampedProgress = this._clampProgress(progress);

        if (this.titleProgressBar?.isValid) {
            this.titleProgressBar.progress = this._normalizeProgress(clampedProgress);
        }

        this._updateProgressAnchor(clampedProgress);
    }

    private _updateProgressAnchor(progress: number): void {
        if (!this.progressAnchor?.isValid) {
            return;
        }

        this._cacheProgressAnchorStartX();

        const startX = this._progressAnchorStartX ?? this.progressAnchor.position.x;
        const travelWidth = this._getProgressAnchorTravelWidth();
        this.progressAnchor.setPosition(startX + travelWidth * progress, this.progressAnchor.position.y, this.progressAnchor.position.z);

        const percentLabel = this.progressAnchor.getChildByName('Label')?.getComponent(Label);
        if (percentLabel) {
            percentLabel.string = `${Math.round(progress * 100)}%`;
        }
    }

    private _getProgressAnchorTravelWidth(): number {
        if (!this.titleProgressBar) {
            return 0;
        }

        return Math.abs(this.titleProgressBar.totalLength * this.titleProgressBar.node.scale.x);
    }

    private _stopProgressAnimation(): void {
        Tween.stopAllByTarget(this._progressTweenTarget);
    }

    /**
     * 规范化进度值
     * 九宫格 Bar 的 Left+Right border = 240pxtotalLength = 925px
     * 当 width < 240px 时圆角会畸变,因此 progress > 0 时强制最小值
     */
    private _normalizeProgress(progress: number): number {
        if (!Number.isFinite(progress) || progress <= 0) {
            return 0;
        }

        const MIN_PROGRESS = 240 / 925;
        return Math.max(MIN_PROGRESS, Math.min(1, progress));
    }

    private _clampProgress(progress: number): number {
        if (!Number.isFinite(progress) || progress <= 0) {
            return 0;
        }

        return Math.min(1, progress);
    }

    /**
     * 下一关按钮点击
     */
    private _onNextLevelClick(): void {
        console.log('[PassModal] 点击下一关');
        this._callbacks.onNextLevel?.();
    }

    /**
     * 分享按钮点击
     */
    private _onShareClick(): void {
        console.log('[PassModal] 点击分享');

        // 调用微信分享
        WxSDK.shareAppMessage({
            title: '快来一起玩这款游戏吧',
            query: `level=${this._params?.levelIndex ?? 1}`
        });

        this._callbacks.onShare?.();
    }

    /**
     * 返回主页按钮点击
     */
    private _onHomeClick(): void {
        console.log('[PassModal] 点击返回主页');
        AudioManager.instance.playButtonClick();
        this._callbacks.onHome?.();
    }
}

8.2 预览关卡项 (assets/prefabs/PreviewLevelItem.ts)

import { _decorator, Component, Node, Sprite, Label, SpriteFrame } from 'cc';
const { ccclass, property } = _decorator;

/**
 * 预览页单个关卡 item 的视图组件
 * 挂在 PagePreviewLevels 的 listTemplate 根节点上,由编辑器拖拽绑定子节点。
 * instantiate 克隆 item 时Cocos 会把这些 Node 引用自动重映射到克隆后的子节点,
 * 所以每个 item 拿到的都是自己的封面/标签引用,与节点层级/命名解耦。
 */
@ccclass('PreviewLevelItem')
export class PreviewLevelItem extends Component {
    @property({ type: Sprite, tooltip: '封面图 Sprite' })
    levelCover: Sprite | null = null;

    @property({ type: Label, tooltip: '答案 Label' })
    answerLabel: Label | null = null;

    @property({ type: Label, tooltip: '线索1 Label' })
    tips1Label: Label | null = null;

    @property({ type: Label, tooltip: '线索2 Label' })
    tips2Label: Label | null = null;

    @property({ type: Label, tooltip: '线索3 Label' })
    tips3Label: Label | null = null;

    /**
     * 一次性设置所有文本字段
     */
    setTexts(opts: {
        answer: string;
        hint1: string;
        hint2: string;
        hint3: string;
    }): void {
        if (this.answerLabel) this.answerLabel.string = `答案:${opts.answer}`;
        if (this.tips1Label) this.tips1Label.string = opts.hint1;
        if (this.tips2Label) this.tips2Label.string = opts.hint2;
        if (this.tips3Label) this.tips3Label.string = opts.hint3;
    }

    setCover(spriteFrame: SpriteFrame | null): void {
        if (this.levelCover && spriteFrame) {
            this.levelCover.spriteFrame = spriteFrame;
        }
    }
}

8.3 Toast 组件 (assets/prefabs/Toast.ts)

import { _decorator, Component, Label, tween, UIOpacity } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('Toast')
export class Toast extends Component {
    @property(Label)
    contentLabel: Label | null = null;

    private _uiOpacity: UIOpacity | null = null;

    onLoad() {
        // 获取或添加 UIOpacity 组件用于透明度动画
        this._uiOpacity = this.node.getComponent(UIOpacity);
        if (!this._uiOpacity) {
            this._uiOpacity = this.node.addComponent(UIOpacity);
        }
    }

    /**
     * 显示 Toast
     * @param content 提示内容
     * @param duration 显示时长(毫秒),默认 2000ms
     */
    show(content: string, duration: number = 2000): void {
        if (this.contentLabel) {
            this.contentLabel.string = content;
        }

        // 重置透明度
        this._uiOpacity!.opacity = 255;

        // 延迟后执行渐隐动画
        this.scheduleOnce(() => {
            this._fadeOut();
        }, duration / 1000);
    }

    /**
     * 渐隐动画并销毁
     */
    private _fadeOut(): void {
        tween(this._uiOpacity!)
            .to(0.3, { opacity: 0 })
            .call(() => {
                this.node.destroy();
            })
            .start();
    }
}

8.4 Toast 管理器 (assets/scripts/utils/ToastManager.ts)

import { Node, Prefab, instantiate, find } from 'cc';

/**
 * Toast 管理器
 * 单例模式,统一管理 Toast 提示的显示
 */
export class ToastManager {
    private static _instance: ToastManager | null = null;
    private _prefab: Prefab | null = null;
    private _container: Node | null = null;

    static get instance(): ToastManager {
        if (!this._instance) {
            this._instance = new ToastManager();
        }
        return this._instance;
    }

    private constructor() {}

    /**
     * 初始化 Toast 管理器
     * @param prefab Toast 预制体
     * @param container Toast 容器节点(默认为 Canvas
     */
    init(prefab: Prefab, container?: Node): void {
        this._prefab = prefab;
        this._container = container ?? find('Canvas');
    }

    /**
     * 显示 Toast 提示
     * @param content 提示内容
     * @param duration 显示时长(毫秒),默认 2000ms
     */
    show(content: string, duration: number = 2000): void {
        if (!this._prefab || !this._container) {
            console.error('[ToastManager] 未初始化,请先调用 init()');
            return;
        }

        const node = instantiate(this._prefab);
        this._container.addChild(node);

        // 动态获取 Toast 组件
        const toast = node.getComponent('Toast') as any;
        if (toast && typeof toast.show === 'function') {
            toast.show(content, duration);
        }
    }

    /**
     * 静态快捷方法
     */
    static show(content: string, duration: number = 2000): void {
        ToastManager.instance.show(content, duration);
    }
}