# 梗中作乐 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 }; } } ```