Files
mp-xieyingeng/assets/prefabs/PassModal.ts
2026-05-19 22:56:31 +08:00

527 lines
17 KiB
TypeScript
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.
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?.();
}
}