# 梗中作乐 V1.0 - 源代码文档(第 8 部分) 软件名称:梗中作乐 版本号:V1.0 --- ## 第 8 部分:通关弹窗与轻提示 本部分包含通关庆祝弹窗、预览关卡项组件、轻提示组件以及 Toast 管理工具。 ### 8.1 通关弹窗 (assets/prefabs/PassModal.ts) ```typescript 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 = 240px,totalLength = 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) ```typescript 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) ```typescript 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) ```typescript 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); } } ```