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