feat: 接入激励视频
This commit is contained in:
@@ -340,6 +340,12 @@ export class PageLevel extends BaseView {
|
||||
/** 是否正在解锁提示(防止双击重复触发) */
|
||||
private _isUnlocking: boolean = false;
|
||||
|
||||
/** 是否正在观看加时广告(防止双击重复触发) */
|
||||
private _isAddingTime: boolean = false;
|
||||
|
||||
/** 激励视频广告期间是否暂停了倒计时 */
|
||||
private _isCountdownPausedForRewardAd: boolean = false;
|
||||
|
||||
/** 下一个待解锁的线索序号(2 或 3),超过 3 表示全部已解锁 */
|
||||
private _nextClueIndex: number = 2;
|
||||
|
||||
@@ -1237,8 +1243,11 @@ export class PageLevel extends BaseView {
|
||||
/**
|
||||
* 点击解锁线索(顺序解锁:先线索2,再线索3;全部解锁后切换为查看答案入口)
|
||||
*/
|
||||
private onUnlockClue(): void {
|
||||
// 全部已解锁后,点击"查看答案":自动填入正确答案并走通关流程
|
||||
private async onUnlockClue(): Promise<void> {
|
||||
if (this._isUnlocking) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._nextClueIndex > 3) {
|
||||
if (this._isTransitioning) return;
|
||||
|
||||
@@ -1251,11 +1260,24 @@ export class PageLevel extends BaseView {
|
||||
this.playClickSound();
|
||||
console.log('[PageLevel] 点击查看答案,自动填充答案并触发通关流程');
|
||||
|
||||
// 填充答案到输入格(distributeInputText 内部会用 _isSyncingInputText 阻止 EditBox 事件回调)
|
||||
this.distributeInputText(answer);
|
||||
this._isUnlocking = true;
|
||||
try {
|
||||
const rewarded = await this.showRewardedVideoAd('查看答案');
|
||||
if (!rewarded || this._isTransitioning) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 走提交流程(答案命中 → showSuccess → 通关弹窗)
|
||||
const latestAnswer = this._currentConfig?.answer;
|
||||
if (!latestAnswer) {
|
||||
ToastManager.show('答案暂未配置');
|
||||
return;
|
||||
}
|
||||
|
||||
this.distributeInputText(latestAnswer);
|
||||
this.onSubmitAnswer();
|
||||
} finally {
|
||||
this._isUnlocking = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1269,45 +1291,116 @@ export class PageLevel extends BaseView {
|
||||
}
|
||||
|
||||
this.playClickSound();
|
||||
this.setClue(index, clueContent);
|
||||
this._isUnlocking = true;
|
||||
try {
|
||||
const rewarded = await this.showRewardedVideoAd('查看线索');
|
||||
if (!rewarded || this._isTransitioning || this._nextClueIndex !== index) {
|
||||
return;
|
||||
}
|
||||
|
||||
const latestClueContent = index === 2 ? this._currentConfig?.clue2 : this._currentConfig?.clue3;
|
||||
if (!latestClueContent) {
|
||||
ToastManager.show('该提示暂未配置');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setClue(index, latestClueContent);
|
||||
|
||||
// 解锁线索后播放"出现"动画,让内容刷新不突兀
|
||||
const tipsItem = this.getTipsItem(index);
|
||||
if (tipsItem) {
|
||||
this.playClueAppearAnimation(tipsItem);
|
||||
}
|
||||
|
||||
// 推进到下一条待解锁线索
|
||||
this._nextClueIndex++;
|
||||
|
||||
// 全部解锁完毕后不隐藏按钮,改为查看答案入口
|
||||
if (this._nextClueIndex > 3) {
|
||||
this.setUnlockButtonText(PageLevel.UNLOCK_BUTTON_ANSWER_TEXT);
|
||||
}
|
||||
|
||||
console.log(`[PageLevel] 解锁线索${index}`);
|
||||
} finally {
|
||||
this._isUnlocking = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击增加时间按钮(倒计时增加 60 秒)
|
||||
*/
|
||||
private onAddTime(): void {
|
||||
private async onAddTime(): Promise<void> {
|
||||
if (this._isAddingTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._isTimeUp) {
|
||||
ToastManager.show('时间已结束,无法增加');
|
||||
return;
|
||||
}
|
||||
|
||||
this.playClickSound();
|
||||
this._isAddingTime = true;
|
||||
try {
|
||||
const rewarded = await this.showRewardedVideoAd('加时');
|
||||
if (!rewarded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._isTimeUp) {
|
||||
ToastManager.show('时间已结束,无法增加');
|
||||
return;
|
||||
}
|
||||
|
||||
this.addCountdownTime();
|
||||
} finally {
|
||||
this._isAddingTime = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async showRewardedVideoAd(rewardName: string): Promise<boolean> {
|
||||
this.pauseLevelForRewardedVideoAd();
|
||||
try {
|
||||
const rewarded = await WxSDK.showRewardedVideoAd();
|
||||
if (!rewarded) {
|
||||
ToastManager.show(`${rewardName}需要看完广告`);
|
||||
}
|
||||
return rewarded;
|
||||
} finally {
|
||||
this.resumeLevelAfterRewardedVideoAd();
|
||||
}
|
||||
}
|
||||
|
||||
private pauseLevelForRewardedVideoAd(): void {
|
||||
this._isCountdownPausedForRewardAd = false;
|
||||
if (!this._isTimeUp && !this._isTransitioning && this._countdown > 0) {
|
||||
this.stopCountdown();
|
||||
this._isCountdownPausedForRewardAd = true;
|
||||
}
|
||||
|
||||
AudioManager.instance.pausePlayingAudioSources();
|
||||
}
|
||||
|
||||
private resumeLevelAfterRewardedVideoAd(): void {
|
||||
AudioManager.instance.resumePausedAudioSources();
|
||||
|
||||
if (!this._isCountdownPausedForRewardAd) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._isCountdownPausedForRewardAd = false;
|
||||
if (this.node?.isValid && !this._isTimeUp && !this._isTransitioning && this._countdown > 0) {
|
||||
this.schedule(this.onCountdownTick, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private addCountdownTime(): void {
|
||||
const wasUrgent = this._countdown <= PageLevel.CLOCK_URGENT_THRESHOLD;
|
||||
this._countdown += 60;
|
||||
|
||||
// 从紧迫态跳回安全区:停掉残留脉冲并复位 scale(updateClockLabel 会负责把颜色改回)
|
||||
if (wasUrgent && this._countdown > PageLevel.CLOCK_URGENT_THRESHOLD && this.clockLabel) {
|
||||
Tween.stopAllByTarget(this.clockLabel.node);
|
||||
this.clockLabel.node.setScale(1, 1, 1);
|
||||
}
|
||||
|
||||
this.updateClockLabel();
|
||||
this.playClickSound();
|
||||
ToastManager.show('已成功增加60秒!');
|
||||
console.log(`[PageLevel] 增加60秒倒计时,当前剩余: ${this._countdown}s`);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export class AudioManager {
|
||||
private _clickAudio: AudioClip | null = null;
|
||||
private _hostNode: Node | null = null;
|
||||
private _audioSource: AudioSource | null = null;
|
||||
private _pausedAudioSources: AudioSource[] = [];
|
||||
|
||||
static get instance(): AudioManager {
|
||||
if (!this._instance) {
|
||||
@@ -30,6 +31,34 @@ export class AudioManager {
|
||||
this._playOneShot(this._clickAudio);
|
||||
}
|
||||
|
||||
pausePlayingAudioSources(): void {
|
||||
if (!this._hostNode?.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._pausedAudioSources = [];
|
||||
const audioSources = this._hostNode.getComponentsInChildren(AudioSource);
|
||||
audioSources.forEach((audioSource) => {
|
||||
if (!audioSource?.isValid || !audioSource.playing) {
|
||||
return;
|
||||
}
|
||||
|
||||
audioSource.pause();
|
||||
this._pausedAudioSources.push(audioSource);
|
||||
});
|
||||
}
|
||||
|
||||
resumePausedAudioSources(): void {
|
||||
const pausedAudioSources = this._pausedAudioSources;
|
||||
this._pausedAudioSources = [];
|
||||
|
||||
pausedAudioSources.forEach((audioSource) => {
|
||||
if (audioSource?.isValid && !audioSource.playing) {
|
||||
audioSource.play();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _playOneShot(clip: AudioClip | null): void {
|
||||
if (!clip) {
|
||||
return;
|
||||
|
||||
@@ -34,6 +34,9 @@ export interface WxPrivacySettingResult {
|
||||
* 封装微信平台相关 API,非微信环境下静默降级
|
||||
*/
|
||||
export class WxSDK {
|
||||
/** 默认激励视频广告位 */
|
||||
private static readonly DEFAULT_REWARDED_VIDEO_AD_UNIT_ID = 'adunit-52a53828d4b91fe2';
|
||||
|
||||
/** 隐私授权请求进行中时复用同一个 Promise,避免启动链路重复弹窗 */
|
||||
private static _privacyAuthorizePromise: Promise<boolean> | null = null;
|
||||
|
||||
@@ -315,6 +318,12 @@ export class WxSDK {
|
||||
/** 激励视频广告实例(复用) */
|
||||
private static _rewardedVideoAd: any = null;
|
||||
|
||||
/** 当前复用实例对应的广告位 */
|
||||
private static _rewardedVideoAdUnitId: string = '';
|
||||
|
||||
/** 激励视频广告展示中的 Promise,避免重复拉起 */
|
||||
private static _rewardedVideoAdPromise: Promise<boolean> | null = null;
|
||||
|
||||
/**
|
||||
* 展示激励视频广告
|
||||
* 用户看完广告后返回 true,中途退出或失败返回 false
|
||||
@@ -322,68 +331,98 @@ export class WxSDK {
|
||||
* @param adUnitId 广告单元 ID(默认使用项目配置的 ID)
|
||||
* @returns Promise<boolean> 是否看完广告
|
||||
*/
|
||||
static showRewardedVideoAd(adUnitId: string = ''): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
static showRewardedVideoAd(adUnitId: string = WxSDK.DEFAULT_REWARDED_VIDEO_AD_UNIT_ID): Promise<boolean> {
|
||||
if (WxSDK._rewardedVideoAdPromise) {
|
||||
return WxSDK._rewardedVideoAdPromise;
|
||||
}
|
||||
|
||||
const wxApi = WxSDK.getWx();
|
||||
if (!wxApi) {
|
||||
console.log('[WxSDK] 非微信环境,跳过激励视频广告');
|
||||
resolve(true);
|
||||
return;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
if (typeof wxApi.createRewardedVideoAd !== 'function') {
|
||||
console.warn('[WxSDK] 当前微信版本不支持激励视频广告');
|
||||
resolve(true);
|
||||
return;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
try {
|
||||
// 复用或创建广告实例
|
||||
if (!WxSDK._rewardedVideoAd) {
|
||||
WxSDK._rewardedVideoAd = wxApi.createRewardedVideoAd({
|
||||
adUnitId: adUnitId,
|
||||
});
|
||||
const resolvedAdUnitId = adUnitId || WxSDK.DEFAULT_REWARDED_VIDEO_AD_UNIT_ID;
|
||||
if (!resolvedAdUnitId) {
|
||||
console.warn('[WxSDK] 激励视频广告位未配置');
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const ad = WxSDK._rewardedVideoAd;
|
||||
WxSDK._rewardedVideoAdPromise = new Promise((resolve) => {
|
||||
let ad: any = null;
|
||||
let finished = false;
|
||||
|
||||
// 定义关闭回调(一次性)
|
||||
const onClose = (res: any) => {
|
||||
const cleanup = () => {
|
||||
if (!ad) return;
|
||||
if (typeof ad.offClose === 'function') {
|
||||
ad.offClose(onClose);
|
||||
if (res && res.isEnded) {
|
||||
}
|
||||
if (typeof ad.offError === 'function') {
|
||||
ad.offError(onError);
|
||||
}
|
||||
};
|
||||
|
||||
const finish = (result: boolean) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
cleanup();
|
||||
WxSDK._rewardedVideoAdPromise = null;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const onClose = (res: any) => {
|
||||
const isEnded = res === undefined || !!res?.isEnded;
|
||||
if (isEnded) {
|
||||
console.log('[WxSDK] 激励视频广告观看完成');
|
||||
resolve(true);
|
||||
finish(true);
|
||||
} else {
|
||||
console.log('[WxSDK] 激励视频广告中途退出');
|
||||
resolve(false);
|
||||
finish(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 定义错误回调(一次性)
|
||||
const onError = (err: any) => {
|
||||
ad.offError(onError);
|
||||
ad.offClose(onClose);
|
||||
console.error('[WxSDK] 激励视频广告错误:', err);
|
||||
resolve(false);
|
||||
finish(false);
|
||||
};
|
||||
|
||||
try {
|
||||
ad = WxSDK.getRewardedVideoAd(wxApi, resolvedAdUnitId);
|
||||
ad.onClose(onClose);
|
||||
ad.onError(onError);
|
||||
|
||||
// 先尝试 show,如果广告未加载则先 load
|
||||
ad.show().catch(() => {
|
||||
ad.load().then(() => ad.show()).catch((loadErr: any) => {
|
||||
ad.offClose(onClose);
|
||||
ad.offError(onError);
|
||||
Promise.resolve(ad.show()).catch(() => {
|
||||
Promise.resolve(ad.load()).then(() => ad.show()).catch((loadErr: any) => {
|
||||
console.error('[WxSDK] 激励视频广告加载失败:', loadErr);
|
||||
resolve(false);
|
||||
finish(false);
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[WxSDK] 激励视频广告异常:', err);
|
||||
resolve(false);
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
|
||||
return WxSDK._rewardedVideoAdPromise;
|
||||
}
|
||||
|
||||
private static getRewardedVideoAd(wxApi: any, adUnitId: string): any {
|
||||
if (WxSDK._rewardedVideoAd && WxSDK._rewardedVideoAdUnitId === adUnitId) {
|
||||
return WxSDK._rewardedVideoAd;
|
||||
}
|
||||
|
||||
if (WxSDK._rewardedVideoAd && typeof WxSDK._rewardedVideoAd.destroy === 'function') {
|
||||
WxSDK._rewardedVideoAd.destroy();
|
||||
}
|
||||
|
||||
WxSDK._rewardedVideoAd = wxApi.createRewardedVideoAd({ adUnitId });
|
||||
WxSDK._rewardedVideoAdUnitId = adUnitId;
|
||||
return WxSDK._rewardedVideoAd;
|
||||
}
|
||||
|
||||
// ==================== 启动参数 ====================
|
||||
|
||||
660
软著源代码/01-程序入口与视图管理.md
Normal file
660
软著源代码/01-程序入口与视图管理.md
Normal file
@@ -0,0 +1,660 @@
|
||||
# 梗中作乐 V1.0 - 源代码文档(第 1 部分)
|
||||
|
||||
软件名称:梗中作乐
|
||||
版本号:V1.0
|
||||
|
||||
---
|
||||
|
||||
## 第 1 部分:程序入口与视图管理
|
||||
|
||||
本部分包含 3 个源文件,是整个小游戏的启动入口、加载页与全局视图调度器。
|
||||
|
||||
### 1.1 程序主入口 (assets/main.ts)
|
||||
|
||||
```typescript
|
||||
import { _decorator, Component, Prefab, AudioClip } from 'cc';
|
||||
import { ViewManager } from './scripts/core/ViewManager';
|
||||
import { ToastManager } from './scripts/utils/ToastManager';
|
||||
import { AudioManager } from './scripts/utils/AudioManager';
|
||||
import { ShareLaunchHandler } from './scripts/utils/ShareLaunchHandler';
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/**
|
||||
* 主入口脚本
|
||||
* 负责初始化 ViewManager 并注册页面
|
||||
*/
|
||||
@ccclass('main')
|
||||
export class main extends Component {
|
||||
@property({ type: Prefab, tooltip: '首页预制体' })
|
||||
pageHomePrefab: Prefab | null = null;
|
||||
|
||||
@property({ type: Prefab, tooltip: '关卡页面预制体' })
|
||||
pageLevelPrefab: Prefab | null = null;
|
||||
|
||||
@property({ type: Prefab, tooltip: '写关卡页面预制体' })
|
||||
pageWriteLevelsPrefab: Prefab | null = null;
|
||||
|
||||
@property({ type: Prefab, tooltip: '预览试卷页面预制体' })
|
||||
pagePreviewLevelsPrefab: Prefab | null = null;
|
||||
|
||||
@property({ type: Prefab, tooltip: '挑战数据页面预制体' })
|
||||
pagePKDataPrefab: Prefab | null = null;
|
||||
|
||||
@property({ type: Prefab, tooltip: '挑战详情页面预制体' })
|
||||
pagePKDetailPrefab: Prefab | null = null;
|
||||
|
||||
@property({ type: Prefab, tooltip: '挑战结算页面预制体' })
|
||||
pagePKEndPrefab: Prefab | null = null;
|
||||
|
||||
@property({ type: Prefab, tooltip: 'Toast 预制体' })
|
||||
toastPrefab: Prefab | null = null;
|
||||
|
||||
@property({ type: AudioClip, tooltip: '通用按钮点击音效' })
|
||||
buttonClickAudio: AudioClip | null = null;
|
||||
|
||||
/**
|
||||
* onLoad 比 start 更早执行
|
||||
* 确保 ViewManager 在 PageLoading.start() 之前初始化
|
||||
*/
|
||||
onLoad() {
|
||||
this._initViewManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化页面管理器
|
||||
*/
|
||||
private _initViewManager(): void {
|
||||
// 初始化 ViewManager,绑定 Canvas 作为页面容器
|
||||
ViewManager.instance.init(this.node);
|
||||
|
||||
// 注册页面配置(通过编辑器属性引用预制体)
|
||||
if (this.pageHomePrefab) {
|
||||
ViewManager.instance.register('PageHome', {
|
||||
prefab: this.pageHomePrefab,
|
||||
cache: true,
|
||||
zIndex: 0
|
||||
});
|
||||
}
|
||||
|
||||
if (this.pageLevelPrefab) {
|
||||
ViewManager.instance.register('PageLevel', {
|
||||
prefab: this.pageLevelPrefab,
|
||||
cache: true,
|
||||
zIndex: 1
|
||||
});
|
||||
}
|
||||
|
||||
if (this.pageWriteLevelsPrefab) {
|
||||
ViewManager.instance.register('PageWriteLevels', {
|
||||
prefab: this.pageWriteLevelsPrefab,
|
||||
cache: true,
|
||||
zIndex: 2
|
||||
});
|
||||
}
|
||||
|
||||
if (this.pagePreviewLevelsPrefab) {
|
||||
ViewManager.instance.register('PagePreviewLevels', {
|
||||
prefab: this.pagePreviewLevelsPrefab,
|
||||
cache: true,
|
||||
zIndex: 3
|
||||
});
|
||||
}
|
||||
|
||||
if (this.pagePKDataPrefab) {
|
||||
ViewManager.instance.register('PagePKData', {
|
||||
prefab: this.pagePKDataPrefab,
|
||||
cache: true,
|
||||
zIndex: 3
|
||||
});
|
||||
}
|
||||
|
||||
if (this.pagePKDetailPrefab) {
|
||||
ViewManager.instance.register('PagePKDetail', {
|
||||
prefab: this.pagePKDetailPrefab,
|
||||
cache: true,
|
||||
zIndex: 4
|
||||
});
|
||||
}
|
||||
|
||||
if (this.pagePKEndPrefab) {
|
||||
ViewManager.instance.register('PagePKEnd', {
|
||||
prefab: this.pagePKEndPrefab,
|
||||
cache: true,
|
||||
zIndex: 3
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化 Toast 管理器
|
||||
if (this.toastPrefab) {
|
||||
ToastManager.instance.init(this.toastPrefab, this.node);
|
||||
}
|
||||
|
||||
AudioManager.instance.init(this.buttonClickAudio, this.node);
|
||||
|
||||
// 注册 wx.onShow / wx.onHide:
|
||||
// 用户把小游戏退到后台后再点击好友分享卡片,能拿到最新的 shareCode 并直达分享挑战关卡。
|
||||
// 必须在 PageLoading 跑之前注册,这样初始 launch 中的 shareCode 也会被作为种子记下。
|
||||
ShareLaunchHandler.instance.init();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 加载页 (assets/PageLoading.ts)
|
||||
|
||||
```typescript
|
||||
import { _decorator, Component, ProgressBar, Label, assetManager } from 'cc';
|
||||
import type { AssetManager } from 'cc';
|
||||
import { ViewManager } from './scripts/core/ViewManager';
|
||||
import { LevelDataManager } from './scripts/utils/LevelDataManager';
|
||||
import { AuthManager } from './scripts/utils/AuthManager';
|
||||
import { ShareManager } from './scripts/utils/ShareManager';
|
||||
import { ShareLaunchHandler } from './scripts/utils/ShareLaunchHandler';
|
||||
import { WxSDK } from './scripts/utils/WxSDK';
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/**
|
||||
* 页面加载组件
|
||||
* 负责用户登录、预加载资源并显示加载进度
|
||||
* 流程:登录 + game-data → 拿到 nextLevel → 加载首关图片 → 进入首页
|
||||
*/
|
||||
@ccclass('PageLoading')
|
||||
export class PageLoading extends Component {
|
||||
private static readonly FONT_BUNDLE_NAME = 'fonts';
|
||||
|
||||
@property(ProgressBar)
|
||||
progressBar: ProgressBar | null = null;
|
||||
|
||||
@property(Label)
|
||||
statusLabel: Label | null = null;
|
||||
|
||||
start() {
|
||||
this._startPreload();
|
||||
}
|
||||
|
||||
private async _startPreload(): Promise<void> {
|
||||
if (this.progressBar) {
|
||||
this.progressBar.progress = 0;
|
||||
}
|
||||
|
||||
this._updateStatusLabel('正在加载...');
|
||||
|
||||
// 阶段0: 主动触发微信隐私授权。分享挑战直达关卡时也会先经过这里。
|
||||
this._updateStatusLabel('正在确认隐私授权...');
|
||||
const privacyAuthorized = await WxSDK.ensurePrivacyAuthorized();
|
||||
if (!privacyAuthorized) {
|
||||
console.warn('[PageLoading] 用户未同意隐私授权,继续加载基础流程');
|
||||
}
|
||||
|
||||
// 阶段1: 登录 + 获取 game-data(含 nextLevel)
|
||||
this._updateProgress(0);
|
||||
this._updateStatusLabel('正在连接服务器...');
|
||||
|
||||
const loginSuccess = await AuthManager.instance.initialize();
|
||||
|
||||
if (loginSuccess) {
|
||||
console.log('[PageLoading] 用户登录成功');
|
||||
} else {
|
||||
console.warn('[PageLoading] 登录失败,继续离线模式');
|
||||
}
|
||||
|
||||
this._updateProgress(0.2);
|
||||
|
||||
// 阶段2: 加载首关图片(如果有 nextLevel)
|
||||
const nextLevel = AuthManager.instance.nextLevel;
|
||||
let levelSuccess = false;
|
||||
|
||||
if (nextLevel) {
|
||||
levelSuccess = await LevelDataManager.instance.initialize(nextLevel, (progress, message) => {
|
||||
// 关卡图片加载占 20%-80% 进度
|
||||
this._updateProgress(0.2 + progress * 0.6);
|
||||
this._updateStatusLabel(message);
|
||||
});
|
||||
|
||||
if (!levelSuccess) {
|
||||
this._updateStatusLabel('资源加载失败,请重新打开游戏');
|
||||
return;
|
||||
}
|
||||
} else if (loginSuccess) {
|
||||
// nextLevel 为 null → 全部通关(或服务端无关卡)
|
||||
console.log('[PageLoading] 全部通关或无可用关卡');
|
||||
this._updateProgress(0.8);
|
||||
} else {
|
||||
// 登录失败且没有 nextLevel
|
||||
this._updateStatusLabel('加载失败,请重新打开游戏');
|
||||
return;
|
||||
}
|
||||
|
||||
// 阶段3: 加载字体分包
|
||||
const fontSuccess = await this._loadFontBundle();
|
||||
if (!fontSuccess) {
|
||||
this._updateStatusLabel('字体资源加载失败,请重新打开游戏');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检测分享码:从微信启动参数中获取
|
||||
const shareCode = WxSDK.getShareCodeFromLaunch();
|
||||
if (shareCode && loginSuccess) {
|
||||
this._updateStatusLabel('正在加载挑战关卡...');
|
||||
const joinSuccess = await ShareManager.instance.joinShare(shareCode);
|
||||
if (joinSuccess) {
|
||||
// 把启动 shareCode 同步给 ShareLaunchHandler,
|
||||
// 避免 wx.onShow 在初始展示时拿到同一个 code 又走一遍 join。
|
||||
ShareLaunchHandler.instance.markActiveShareCode(shareCode);
|
||||
this._updateProgress(1);
|
||||
this._updateStatusLabel('加载完成');
|
||||
// 跳过首页,直接进入分享挑战关卡
|
||||
ViewManager.instance.open('PageLevel', {
|
||||
params: { shareMode: true },
|
||||
onComplete: () => {
|
||||
this.node.destroy();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
console.warn('[PageLoading] 加入分享失败,进入正常模式');
|
||||
}
|
||||
|
||||
// 正常流程:预加载 PageHome (82-100%)
|
||||
ViewManager.instance.preload('PageHome',
|
||||
(progress) => {
|
||||
this._updateProgress(0.82 + progress * 0.18);
|
||||
this._updateStatusLabel('正在加载界面资源...');
|
||||
},
|
||||
() => {
|
||||
this._onPreloadComplete();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _updateProgress(progress: number): void {
|
||||
if (this.progressBar) {
|
||||
this.progressBar.progress = progress;
|
||||
}
|
||||
}
|
||||
|
||||
private _updateStatusLabel(message: string): void {
|
||||
if (this.statusLabel) {
|
||||
this.statusLabel.string = message;
|
||||
}
|
||||
}
|
||||
|
||||
private _onPreloadComplete(): void {
|
||||
this._updateProgress(1);
|
||||
this._updateStatusLabel('加载完成');
|
||||
|
||||
// 兜底:如果在 PageLoading 还在 preload 期间,wx.onShow 已经把游戏切到了分享态
|
||||
// (ShareLaunchHandler 已经 joinShare 成功),这里就不应再覆盖回首页,
|
||||
// 否则用户点了好友分享卡片却看到 PageHome。
|
||||
if (ShareManager.instance.isShareMode) {
|
||||
console.log('[PageLoading] 检测到分享态已激活,跳过首页直达 PageLevel');
|
||||
ViewManager.instance.open('PageLevel', {
|
||||
params: { shareMode: true },
|
||||
onComplete: () => {
|
||||
this.node.destroy();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ViewManager.instance.open('PageHome', {
|
||||
onComplete: () => {
|
||||
this.node.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载字体分包,避免字体资源进入小游戏主包
|
||||
*/
|
||||
private _loadFontBundle(): Promise<boolean> {
|
||||
const bundleName = PageLoading.FONT_BUNDLE_NAME;
|
||||
const cachedBundle = assetManager.getBundle(bundleName);
|
||||
if (cachedBundle) {
|
||||
console.log(`[PageLoading] 字体分包已加载: ${bundleName}`);
|
||||
this._updateProgress(0.82);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
this._updateStatusLabel('正在加载字体资源...');
|
||||
this._updateProgress(0.8);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
assetManager.loadBundle(bundleName, (err: Error | null, bundle: AssetManager.Bundle | null) => {
|
||||
if (err || !bundle) {
|
||||
console.error(`[PageLoading] 字体分包加载失败: ${bundleName}`, err);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[PageLoading] 字体分包加载完成: ${bundleName}`);
|
||||
this._updateProgress(0.82);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 视图管理器 (assets/scripts/core/ViewManager.ts)
|
||||
|
||||
```typescript
|
||||
import { _decorator, Node, Prefab, instantiate, error } from 'cc';
|
||||
import { BaseView, ViewConfig, ViewOptions } from './BaseView';
|
||||
const { ccclass } = _decorator;
|
||||
|
||||
/**
|
||||
* 已注册的页面配置映射
|
||||
*/
|
||||
interface RegisteredView {
|
||||
config: ViewConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面管理器
|
||||
* 单例模式,统一管理页面的注册、打开、关闭、返回等操作
|
||||
*/
|
||||
@ccclass('ViewManager')
|
||||
export class ViewManager {
|
||||
/** 单例实例 */
|
||||
private static _instance: ViewManager | null = null;
|
||||
|
||||
/** 获取单例 */
|
||||
public static get instance(): ViewManager {
|
||||
if (!ViewManager._instance) {
|
||||
ViewManager._instance = new ViewManager();
|
||||
}
|
||||
return ViewManager._instance;
|
||||
}
|
||||
|
||||
/** 页面容器节点 */
|
||||
private _container: Node | null = null;
|
||||
|
||||
/** 已注册的页面配置 */
|
||||
private _registeredViews: Map<string, RegisteredView> = new Map();
|
||||
|
||||
/** 页面栈 */
|
||||
private _viewStack: BaseView[] = [];
|
||||
|
||||
/** 页面实例缓存(用于缓存模式的页面) */
|
||||
private _viewCache: Map<string, BaseView> = new Map();
|
||||
|
||||
/**
|
||||
* 初始化管理器
|
||||
* @param container 页面容器节点(通常是 Canvas)
|
||||
*/
|
||||
init(container: Node): void {
|
||||
this._container = container;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前容器
|
||||
*/
|
||||
getContainer(): Node | null {
|
||||
return this._container;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册页面
|
||||
* @param viewId 页面唯一标识
|
||||
* @param config 页面配置
|
||||
*/
|
||||
register(viewId: string, config: ViewConfig): void {
|
||||
if (this._registeredViews.has(viewId)) {
|
||||
error(`ViewManager: 页面 "${viewId}" 已注册`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._registeredViews.set(viewId, {
|
||||
config: {
|
||||
cache: true, // 默认缓存
|
||||
zIndex: 0, // 默认层级
|
||||
...config
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量注册页面
|
||||
* @param views 页面配置映射
|
||||
*/
|
||||
registerAll(views: Record<string, ViewConfig>): void {
|
||||
for (const [viewId, config] of Object.entries(views)) {
|
||||
this.register(viewId, config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开页面
|
||||
* @param viewId 页面唯一标识
|
||||
* @param options 打开选项
|
||||
*/
|
||||
open(viewId: string, options?: ViewOptions): void {
|
||||
if (!this._container) {
|
||||
const err = new Error('ViewManager: 未初始化,请先调用 init()');
|
||||
options?.onError?.(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const registered = this._registeredViews.get(viewId);
|
||||
if (!registered) {
|
||||
const err = new Error(`ViewManager: 页面 "${viewId}" 未注册`);
|
||||
options?.onError?.(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有缓存的实例
|
||||
const cachedView = this._viewCache.get(viewId);
|
||||
if (cachedView && cachedView.node.isValid) {
|
||||
this._showView(cachedView, options);
|
||||
return;
|
||||
}
|
||||
|
||||
// 直接使用预制体引用实例化
|
||||
this._instantiateView(viewId, registered.config.prefab, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 实例化视图
|
||||
*/
|
||||
private _instantiateView(viewId: string, prefab: Prefab, options?: ViewOptions): void {
|
||||
if (!this._container) return;
|
||||
|
||||
const registered = this._registeredViews.get(viewId);
|
||||
if (!registered) return;
|
||||
|
||||
const node = instantiate(prefab);
|
||||
const view = node.getComponent(BaseView);
|
||||
|
||||
if (!view) {
|
||||
error(`ViewManager: 预制体 "${viewId}" 缺少 BaseView 组件`);
|
||||
node.destroy();
|
||||
options?.onError?.(new Error('缺少 BaseView 组件'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置视图属性
|
||||
view.viewId = viewId;
|
||||
view.config = registered.config;
|
||||
view.setParams(options?.params);
|
||||
|
||||
// 设置层级
|
||||
node.setSiblingIndex(registered.config.zIndex || 0);
|
||||
|
||||
// 添加到容器
|
||||
this._container.addChild(node);
|
||||
|
||||
// 调用加载回调
|
||||
view.onViewLoad();
|
||||
|
||||
// 缓存视图实例
|
||||
if (registered.config.cache) {
|
||||
this._viewCache.set(viewId, view);
|
||||
}
|
||||
|
||||
// 显示视图
|
||||
this._showView(view, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示视图
|
||||
*/
|
||||
private _showView(view: BaseView, options?: ViewOptions): void {
|
||||
// 隐藏当前页面
|
||||
const currentView = this.getCurrentView();
|
||||
if (currentView && currentView !== view) {
|
||||
currentView._doHide();
|
||||
}
|
||||
|
||||
// 设置参数
|
||||
if (options?.params !== undefined) {
|
||||
view.setParams(options?.params);
|
||||
}
|
||||
|
||||
// 入栈
|
||||
if (!this._viewStack.includes(view)) {
|
||||
this._viewStack.push(view);
|
||||
}
|
||||
|
||||
// 显示
|
||||
view._doShow();
|
||||
|
||||
// 回调
|
||||
options?.onComplete?.(view);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭当前页面
|
||||
* @param options 关闭选项
|
||||
*/
|
||||
close(options?: { destroy?: boolean }): void {
|
||||
const currentView = this._viewStack.pop();
|
||||
if (!currentView) return;
|
||||
|
||||
const shouldDestroy = options?.destroy ?? !currentView.config?.cache;
|
||||
this._hideAndDestroyView(currentView, shouldDestroy);
|
||||
|
||||
// 显示上一页
|
||||
const prevView = this.getCurrentView();
|
||||
if (prevView) {
|
||||
prevView._doShow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回上一页(close 的别名)
|
||||
*/
|
||||
back(): void {
|
||||
this.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换当前页面
|
||||
* @param viewId 新页面标识
|
||||
* @param options 打开选项
|
||||
*/
|
||||
replace(viewId: string, options?: ViewOptions): void {
|
||||
const currentView = this.getCurrentView();
|
||||
if (currentView) {
|
||||
this._viewStack.pop();
|
||||
const shouldDestroy = !currentView.config?.cache;
|
||||
this._hideAndDestroyView(currentView, shouldDestroy);
|
||||
}
|
||||
|
||||
this.open(viewId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏并销毁视图(内部方法)
|
||||
*/
|
||||
private _hideAndDestroyView(view: BaseView, shouldDestroy: boolean): void {
|
||||
view._doHide();
|
||||
if (shouldDestroy) {
|
||||
this._viewCache.delete(view.viewId);
|
||||
view._doDestroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前页面
|
||||
*/
|
||||
getCurrentView(): BaseView | null {
|
||||
return this._viewStack.length > 0
|
||||
? this._viewStack[this._viewStack.length - 1]
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取页面栈
|
||||
*/
|
||||
getViewStack(): BaseView[] {
|
||||
return [...this._viewStack];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有页面
|
||||
*/
|
||||
clearAll(): void {
|
||||
// 从栈顶开始销毁
|
||||
while (this._viewStack.length > 0) {
|
||||
const view = this._viewStack.pop()!;
|
||||
const shouldDestroy = !view.config?.cache;
|
||||
this._hideAndDestroyView(view, shouldDestroy);
|
||||
}
|
||||
|
||||
// 销毁缓存的页面
|
||||
for (const view of this._viewCache.values()) {
|
||||
if (view.node.isValid) {
|
||||
view._doDestroy();
|
||||
}
|
||||
}
|
||||
|
||||
this._viewCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载页面预制体(主包资源已随游戏加载,此方法仅为兼容性保留)
|
||||
* @param viewId 页面标识
|
||||
* @param onProgress 进度回调
|
||||
* @param onComplete 完成回调
|
||||
*/
|
||||
preload(viewId: string, onProgress?: (progress: number) => void, onComplete?: () => void): void {
|
||||
const registered = this._registeredViews.get(viewId);
|
||||
if (!registered) {
|
||||
error(`ViewManager: 页面 "${viewId}" 未注册`);
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// 主包资源已加载,直接回调
|
||||
onProgress?.(1);
|
||||
onComplete?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量预加载页面
|
||||
* @param viewIds 页面标识数组
|
||||
* @param onProgress 总进度回调 (0-1)
|
||||
* @param onComplete 完成回调
|
||||
*/
|
||||
preloadAll(viewIds: string[], onProgress?: (progress: number) => void, onComplete?: () => void): void {
|
||||
if (viewIds.length === 0) {
|
||||
onProgress?.(1);
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
let completed = 0;
|
||||
const total = viewIds.length;
|
||||
|
||||
for (const viewId of viewIds) {
|
||||
this.preload(viewId, () => {
|
||||
completed++;
|
||||
onProgress?.(completed / total);
|
||||
if (completed === total) {
|
||||
onComplete?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
609
软著源代码/02-基类与类型定义.md
Normal file
609
软著源代码/02-基类与类型定义.md
Normal file
@@ -0,0 +1,609 @@
|
||||
# 梗中作乐 V1.0 - 源代码文档(第 2 部分)
|
||||
|
||||
软件名称:梗中作乐
|
||||
版本号:V1.0
|
||||
|
||||
---
|
||||
|
||||
## 第 2 部分:基类与类型定义
|
||||
|
||||
本部分包含 5 个源文件:页面 / 弹窗的基类,以及全项目共用的 API 类型与配置常量。
|
||||
|
||||
### 2.1 视图基类 (assets/scripts/core/BaseView.ts)
|
||||
|
||||
```typescript
|
||||
import { _decorator, Component, Prefab } from 'cc';
|
||||
const { ccclass } = _decorator;
|
||||
|
||||
/**
|
||||
* 页面配置接口
|
||||
*/
|
||||
export interface ViewConfig {
|
||||
prefab: Prefab; // 预制体引用(主包资源)
|
||||
cache?: boolean; // 是否缓存页面,默认 true
|
||||
zIndex?: number; // 层级,默认 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面打开选项
|
||||
*/
|
||||
export interface ViewOptions {
|
||||
params?: any; // 传递给页面的参数
|
||||
onComplete?: (view: BaseView) => void;
|
||||
onError?: (err: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面基类
|
||||
* 所有页面组件应继承此类,实现生命周期方法
|
||||
*/
|
||||
@ccclass('BaseView')
|
||||
export class BaseView extends Component {
|
||||
/** 页面唯一标识 */
|
||||
viewId: string = '';
|
||||
|
||||
/** 页面配置 */
|
||||
config: ViewConfig | null = null;
|
||||
|
||||
/** 是否正在显示 */
|
||||
isShowing: boolean = false;
|
||||
|
||||
/** 传递给页面的参数 */
|
||||
protected _params: any = null;
|
||||
|
||||
/**
|
||||
* 设置页面参数
|
||||
*/
|
||||
setParams(params: any): void {
|
||||
this._params = params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取页面参数
|
||||
*/
|
||||
getParams(): any {
|
||||
return this._params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面加载时调用(首次创建时)
|
||||
* 子类应重写此方法
|
||||
*/
|
||||
onViewLoad(): void {
|
||||
// 子类实现
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面显示时调用(每次打开时)
|
||||
* 子类应重写此方法
|
||||
*/
|
||||
onViewShow(): void {
|
||||
// 子类实现
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面隐藏时调用(关闭或被其他页面覆盖时)
|
||||
* 子类应重写此方法
|
||||
*/
|
||||
onViewHide(): void {
|
||||
// 子类实现
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面销毁时调用
|
||||
* 子类应重写此方法
|
||||
*/
|
||||
onViewDestroy(): void {
|
||||
// 子类实现
|
||||
}
|
||||
|
||||
// ========== 内部方法,由 ViewManager 调用 ==========
|
||||
|
||||
/**
|
||||
* 内部方法:执行显示逻辑
|
||||
*/
|
||||
_doShow(): void {
|
||||
if (this.isShowing) return;
|
||||
this.isShowing = true;
|
||||
this.node.active = true;
|
||||
this.onViewShow();
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部方法:执行隐藏逻辑
|
||||
*/
|
||||
_doHide(): void {
|
||||
if (!this.isShowing) return;
|
||||
this.isShowing = false;
|
||||
this.onViewHide();
|
||||
this.node.active = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部方法:执行销毁逻辑
|
||||
*/
|
||||
_doDestroy(): void {
|
||||
// 标记已销毁,防止 onDestroy 中重复调用
|
||||
this._destroyed = true;
|
||||
this.node.destroy();
|
||||
}
|
||||
|
||||
/** 是否已标记销毁 */
|
||||
private _destroyed: boolean = false;
|
||||
|
||||
// ========== Cocos 生命周期 ==========
|
||||
|
||||
protected onDestroy(): void {
|
||||
// 仅在未被 _doDestroy 调用时执行生命周期
|
||||
if (!this._destroyed) {
|
||||
if (this.isShowing) {
|
||||
this.onViewHide();
|
||||
}
|
||||
}
|
||||
this.onViewDestroy();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 弹窗基类 (assets/scripts/core/BaseModal.ts)
|
||||
|
||||
```typescript
|
||||
import { _decorator, Node, Tween, UIOpacity, Vec3, tween } from 'cc';
|
||||
import { BaseView } from './BaseView';
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
@ccclass('BaseModal')
|
||||
export class BaseModal extends BaseView {
|
||||
@property([Node])
|
||||
protected animationNodes: Node[] = [];
|
||||
|
||||
@property(Node)
|
||||
protected backdropNode: Node | null = null;
|
||||
|
||||
@property
|
||||
protected openAnimationEnabled: boolean = true;
|
||||
|
||||
@property
|
||||
protected openAnimationDuration: number = 0.36;
|
||||
|
||||
private readonly _originalScales: Map<Node, Vec3> = new Map();
|
||||
|
||||
onViewShow(): void {
|
||||
this.playOpenAnimation();
|
||||
}
|
||||
|
||||
onViewHide(): void {
|
||||
this.stopOpenAnimation();
|
||||
}
|
||||
|
||||
protected playOpenAnimation(): void {
|
||||
if (!this.openAnimationEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playBackdropFadeIn();
|
||||
|
||||
const targets = this.getAnimationTargets();
|
||||
targets.forEach((target, index) => {
|
||||
this.playBounceIn(target, index * 0.035);
|
||||
});
|
||||
}
|
||||
|
||||
protected stopOpenAnimation(): void {
|
||||
this.getAnimationTargets().forEach((target) => {
|
||||
Tween.stopAllByTarget(target);
|
||||
});
|
||||
|
||||
const opacity = this.backdropNode?.getComponent(UIOpacity);
|
||||
if (opacity) {
|
||||
Tween.stopAllByTarget(opacity);
|
||||
}
|
||||
}
|
||||
|
||||
private getAnimationTargets(): Node[] {
|
||||
const configuredTargets = this.animationNodes.filter((node) => node?.isValid);
|
||||
return configuredTargets.length > 0 ? configuredTargets : [this.node];
|
||||
}
|
||||
|
||||
private getOriginalScale(target: Node): Vec3 {
|
||||
const cachedScale = this._originalScales.get(target);
|
||||
if (cachedScale) {
|
||||
return cachedScale;
|
||||
}
|
||||
|
||||
const originalScale = target.scale.clone();
|
||||
this._originalScales.set(target, originalScale);
|
||||
return originalScale;
|
||||
}
|
||||
|
||||
private playBounceIn(target: Node, delay: number): void {
|
||||
const originalScale = this.getOriginalScale(target);
|
||||
const startScale = this.multiplyScale(originalScale, 0.82);
|
||||
const peakScale = this.multiplyScale(originalScale, 1.045);
|
||||
|
||||
Tween.stopAllByTarget(target);
|
||||
target.setScale(startScale);
|
||||
|
||||
tween(target)
|
||||
.delay(delay)
|
||||
.to(this.openAnimationDuration * 0.58, { scale: peakScale }, { easing: 'backOut' })
|
||||
.to(this.openAnimationDuration * 0.24, { scale: this.multiplyScale(originalScale, 0.985) }, { easing: 'sineOut' })
|
||||
.to(this.openAnimationDuration * 0.18, { scale: originalScale }, { easing: 'sineOut' })
|
||||
.start();
|
||||
}
|
||||
|
||||
private playBackdropFadeIn(): void {
|
||||
if (!this.backdropNode?.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const opacity = this.backdropNode.getComponent(UIOpacity) ?? this.backdropNode.addComponent(UIOpacity);
|
||||
Tween.stopAllByTarget(opacity);
|
||||
opacity.opacity = 0;
|
||||
|
||||
tween(opacity)
|
||||
.to(0.18, { opacity: 255 }, { easing: 'sineOut' })
|
||||
.start();
|
||||
}
|
||||
|
||||
private multiplyScale(scale: Vec3, factor: number): Vec3 {
|
||||
return new Vec3(scale.x * factor, scale.y * factor, scale.z);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 接口类型定义 (assets/scripts/types/ApiTypes.ts)
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 服务端 API 通用响应类型
|
||||
*/
|
||||
|
||||
/** 服务端标准响应封装 */
|
||||
export interface ApiEnvelope<T> {
|
||||
success: boolean;
|
||||
data: T | null;
|
||||
message: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/** 体力值信息 */
|
||||
export interface StaminaInfo {
|
||||
/** 当前体力值(已计算恢复) */
|
||||
current: number;
|
||||
/** 体力上限,固定为 50 */
|
||||
max: number;
|
||||
/** 下一点体力恢复的时间(ISO 8601),满体力时为 null */
|
||||
nextRecoverAt: string | null;
|
||||
}
|
||||
|
||||
/** 下一关完整数据(多个接口共用) */
|
||||
export interface NextLevelData {
|
||||
/** 关卡 ID */
|
||||
id: string;
|
||||
/** 关卡编号(sortOrder) */
|
||||
level: number;
|
||||
/** 图片1 URL */
|
||||
image1Url: string;
|
||||
/** 图片1 文本说明 */
|
||||
image1Description: string | null;
|
||||
/** 图片2 URL */
|
||||
image2Url: string;
|
||||
/** 图片2 文本说明 */
|
||||
image2Description: string | null;
|
||||
/** 答案 */
|
||||
answer: string;
|
||||
/** 谐音梗说明 */
|
||||
punchline: string | null;
|
||||
/** 线索1 */
|
||||
hint1: string | null;
|
||||
/** 线索2 */
|
||||
hint2: string | null;
|
||||
/** 线索3 */
|
||||
hint3: string | null;
|
||||
/** 限时(秒),null 表示不限时 */
|
||||
timeLimit: number | null;
|
||||
}
|
||||
|
||||
/** 登录响应数据 */
|
||||
export interface WxLoginData {
|
||||
token: string;
|
||||
user: {
|
||||
id: string;
|
||||
nickname: string | null;
|
||||
stamina: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** 用户资料响应数据 */
|
||||
export interface UserProfileData {
|
||||
id: string;
|
||||
nickname: string | null;
|
||||
stamina: StaminaInfo;
|
||||
}
|
||||
|
||||
/** 游戏数据响应(Loading 页面) */
|
||||
export interface GameData {
|
||||
user: {
|
||||
id: string;
|
||||
stamina: StaminaInfo;
|
||||
};
|
||||
/** 已通关的关卡数量 */
|
||||
completedLevelCount: number;
|
||||
/** 下一个待通关的关卡(全部通关时为 null) */
|
||||
nextLevel: NextLevelData | null;
|
||||
}
|
||||
|
||||
/** 进入关卡响应 */
|
||||
export interface EnterLevelData {
|
||||
id: string;
|
||||
level: number;
|
||||
image1Url: string;
|
||||
image1Description: string | null;
|
||||
image2Url: string;
|
||||
image2Description: string | null;
|
||||
answer: string;
|
||||
punchline: string | null;
|
||||
hint1: string | null;
|
||||
hint2: string | null;
|
||||
hint3: string | null;
|
||||
stamina: StaminaInfo;
|
||||
/** 预加载的下一关数据(无下一关时为 null) */
|
||||
preloadNextLevel: NextLevelData | null;
|
||||
}
|
||||
|
||||
/** 通关上报响应 */
|
||||
export interface CompleteLevelData {
|
||||
firstClear: boolean;
|
||||
levelId: string;
|
||||
timeSpent: number;
|
||||
/** 下一个待通关的关卡(全部通关时为 null) */
|
||||
nextLevel: NextLevelData | null;
|
||||
}
|
||||
|
||||
/** 创建分享响应 */
|
||||
export interface CreateShareData {
|
||||
shareCode: string;
|
||||
title: string;
|
||||
levelCount: number;
|
||||
}
|
||||
|
||||
/** 分享关卡数据 */
|
||||
export interface ShareLevelData {
|
||||
id: string;
|
||||
level: number;
|
||||
image1Url: string;
|
||||
image1Description: string | null;
|
||||
image2Url: string;
|
||||
image2Description: string | null;
|
||||
answer: string;
|
||||
punchline: string | null;
|
||||
hint1: string | null;
|
||||
hint2: string | null;
|
||||
hint3: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
/** 加入分享响应 */
|
||||
export interface JoinShareData {
|
||||
shareCode: string;
|
||||
title: string;
|
||||
levels: ShareLevelData[];
|
||||
}
|
||||
|
||||
/** 分享挑战单关提交 */
|
||||
export interface SubmitShareLevel {
|
||||
levelId: string;
|
||||
answer: string;
|
||||
timeSpent: number;
|
||||
}
|
||||
|
||||
/** 分享挑战提交后的单关结果 */
|
||||
export interface SubmittedShareLevelData extends ShareLevelData {
|
||||
submittedAnswer: string;
|
||||
timeSpent: number;
|
||||
isCorrect: boolean;
|
||||
timeLimit: number | null;
|
||||
withinTimeLimit: boolean;
|
||||
}
|
||||
|
||||
/** 分享挑战整场提交响应 */
|
||||
export interface SubmitShareData {
|
||||
shareCode: string;
|
||||
title: string;
|
||||
rank: number;
|
||||
correctCount: number;
|
||||
levelCount: number;
|
||||
participantCount: number;
|
||||
totalTimeSpent: number;
|
||||
levels: SubmittedShareLevelData[];
|
||||
}
|
||||
|
||||
/** 分享挑战参与者排行摘要 */
|
||||
export interface ShareParticipantRankSummary {
|
||||
userId?: string | null;
|
||||
participantId?: string | null;
|
||||
nickname?: string | null;
|
||||
nickName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
rank?: number | null;
|
||||
correctCount?: number | null;
|
||||
totalTimeSpent?: number | null;
|
||||
}
|
||||
|
||||
/** 我创建的分享挑战条目 */
|
||||
export interface CreatedShareItem {
|
||||
id: string;
|
||||
shareCode: string;
|
||||
title: string;
|
||||
levelCount: number;
|
||||
participantCount: number;
|
||||
userRank: number | null;
|
||||
createdAt: string;
|
||||
firstPlaceUser?: ShareParticipantRankSummary | null;
|
||||
topParticipant?: ShareParticipantRankSummary | null;
|
||||
firstParticipant?: ShareParticipantRankSummary | null;
|
||||
champion?: ShareParticipantRankSummary | null;
|
||||
}
|
||||
|
||||
/** 我创建的分享挑战列表响应 */
|
||||
export interface CreatedShareListData {
|
||||
items: CreatedShareItem[];
|
||||
}
|
||||
|
||||
/** 我参与的分享挑战条目 */
|
||||
export interface ParticipatedShareItem {
|
||||
title: string;
|
||||
participantCount: number;
|
||||
userRank: number | null;
|
||||
}
|
||||
|
||||
/** 我参与的分享挑战列表响应 */
|
||||
export interface ParticipatedShareListData {
|
||||
items: ParticipatedShareItem[];
|
||||
}
|
||||
|
||||
/** 分享挑战详情响应 */
|
||||
export interface ShareDetailData {
|
||||
id: string;
|
||||
shareCode: string;
|
||||
title: string;
|
||||
levelCount: number;
|
||||
participantCount: number;
|
||||
userRank: number | null;
|
||||
createdAt: string;
|
||||
rankings: ShareParticipantRankSummary[];
|
||||
}
|
||||
|
||||
/** 已通关关卡数据(成就墙 / 关卡回看场景) */
|
||||
export interface CompletedLevel {
|
||||
/** 关卡 ID */
|
||||
id: string;
|
||||
/** 关卡编号(sortOrder) */
|
||||
level: number;
|
||||
/** 图片1 URL */
|
||||
image1Url: string;
|
||||
/** 图片1 文本说明 */
|
||||
image1Description: string | null;
|
||||
/** 图片2 URL */
|
||||
image2Url: string;
|
||||
/** 图片2 文本说明 */
|
||||
image2Description: string | null;
|
||||
/** 答案 */
|
||||
answer: string;
|
||||
/** 谐音梗说明 */
|
||||
punchline: string | null;
|
||||
/** 线索1 */
|
||||
hint1: string | null;
|
||||
/** 线索2 */
|
||||
hint2: string | null;
|
||||
/** 线索3 */
|
||||
hint3: string | null;
|
||||
/** 限时(秒),null 表示不限时 */
|
||||
timeLimit: number | null;
|
||||
/** 首次通关时长(秒) */
|
||||
timeSpent: number;
|
||||
/** 通关时间(ISO 8601) */
|
||||
completedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 关卡类型定义 (assets/scripts/types/LevelTypes.ts)
|
||||
|
||||
```typescript
|
||||
import { SpriteFrame } from 'cc';
|
||||
|
||||
/**
|
||||
* 运行时关卡配置(包含已加载的图片)
|
||||
*/
|
||||
export interface RuntimeLevelConfig {
|
||||
/** 关卡 ID */
|
||||
id: string;
|
||||
/** 关卡名称 */
|
||||
name: string;
|
||||
/** 图片1 SpriteFrame(可能为 null 如果加载失败) */
|
||||
spriteFrame1: SpriteFrame | null;
|
||||
/** 图片2 SpriteFrame(可能为 null 如果加载失败) */
|
||||
spriteFrame2: SpriteFrame | null;
|
||||
/** 图片1 文本说明 */
|
||||
image1Description: string | null;
|
||||
/** 图片2 文本说明 */
|
||||
image2Description: string | null;
|
||||
/** 谐音梗说明 */
|
||||
punchline: string | null;
|
||||
/** 线索1 */
|
||||
clue1: string | null;
|
||||
/** 线索2 */
|
||||
clue2: string | null;
|
||||
/** 线索3 */
|
||||
clue3: string | null;
|
||||
/** 答案 */
|
||||
answer: string | null;
|
||||
/** 是否已通关 */
|
||||
completed: boolean;
|
||||
/** 限时(秒),null 表示不限时 */
|
||||
timeLimit: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 API 配置常量 (assets/scripts/config/ApiConfig.ts)
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* API 配置常量
|
||||
* 统一管理所有服务端 API 地址
|
||||
*/
|
||||
|
||||
/** 服务端 API 基础地址 */
|
||||
export const API_BASE = 'https://ilookai.cn/api/v1';
|
||||
|
||||
/** API 端点 */
|
||||
export const API_ENDPOINTS = {
|
||||
/** 微信登录 */
|
||||
WX_LOGIN: `${API_BASE}/auth/wx-login`,
|
||||
/** 用户资料(含实时体力) */
|
||||
USER_PROFILE: `${API_BASE}/user/profile`,
|
||||
/** 游戏数据(体力 + 通关进度 + 下一关) */
|
||||
USER_GAME_DATA: `${API_BASE}/user/game-data`,
|
||||
/** 游戏配置 */
|
||||
GAME_CONFIGS: `${API_BASE}/game-configs`,
|
||||
/** 分享相关 */
|
||||
SHARE_CREATE: `${API_BASE}/share`,
|
||||
SHARE_CREATED: `${API_BASE}/share/created`,
|
||||
SHARE_PARTICIPATED: `${API_BASE}/share/participated`,
|
||||
/** 用户信息 */
|
||||
USER_INFO: `${API_BASE}/user/info`,
|
||||
/** 用户所有已通关的关卡(成就墙 / 关卡回看) */
|
||||
COMPLETED_LEVELS: `${API_BASE}/levels/completed`,
|
||||
} as const;
|
||||
|
||||
export function getLevelEnterUrl(levelId: string): string {
|
||||
return `${API_BASE}/levels/${levelId}/enter`;
|
||||
}
|
||||
|
||||
export function getLevelCompleteUrl(levelId: string): string {
|
||||
return `${API_BASE}/levels/${levelId}/complete`;
|
||||
}
|
||||
|
||||
export function getShareJoinUrl(code: string): string {
|
||||
return `${API_BASE}/share/${code}/join`;
|
||||
}
|
||||
|
||||
export function getShareDetailUrl(code: string): string {
|
||||
return `${API_BASE}/share/${code}`;
|
||||
}
|
||||
|
||||
export function getShareSubmitUrl(code: string): string {
|
||||
return `${API_BASE}/share/${code}/submit`;
|
||||
}
|
||||
|
||||
export function getGameConfigUrl(key: string): string {
|
||||
return `${API_BASE}/game-configs/${key}`;
|
||||
}
|
||||
|
||||
/** 请求超时时间(毫秒) */
|
||||
export const API_TIMEOUT = {
|
||||
DEFAULT: 8000,
|
||||
SHORT: 5000,
|
||||
} as const;
|
||||
```
|
||||
578
软著源代码/03-认证存储与网络.md
Normal file
578
软著源代码/03-认证存储与网络.md
Normal file
@@ -0,0 +1,578 @@
|
||||
# 梗中作乐 V1.0 - 源代码文档(第 3 部分)
|
||||
|
||||
软件名称:梗中作乐
|
||||
版本号:V1.0
|
||||
|
||||
---
|
||||
|
||||
## 第 3 部分:认证存储与网络
|
||||
|
||||
本部分包含用户认证、本地存储、HTTP 网络通信三个核心工具模块。
|
||||
|
||||
### 3.1 微信认证管理器 (assets/scripts/utils/AuthManager.ts)
|
||||
|
||||
```typescript
|
||||
import { HttpUtil } from './HttpUtil';
|
||||
import { StorageManager } from './StorageManager';
|
||||
import { WxSDK } from './WxSDK';
|
||||
import { API_ENDPOINTS, API_TIMEOUT } from '../config/ApiConfig';
|
||||
import { ApiEnvelope, WxLoginData, GameData, NextLevelData } from '../types/ApiTypes';
|
||||
|
||||
/**
|
||||
* 认证管理器
|
||||
* 单例模式,负责微信登录和 JWT token 管理
|
||||
*/
|
||||
export class AuthManager {
|
||||
private static _instance: AuthManager | null = null;
|
||||
|
||||
private _userId: string = '';
|
||||
private _isLoggedIn: boolean = false;
|
||||
/** 服务端返回的已完成关卡数量,用于称号体系计算 */
|
||||
private _completedLevelCount: number = 0;
|
||||
/** game-data 返回的下一关数据,供 PageLoading 传给 LevelDataManager */
|
||||
private _nextLevel: NextLevelData | null = null;
|
||||
|
||||
static get instance(): AuthManager {
|
||||
if (!this._instance) {
|
||||
this._instance = new AuthManager();
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
private constructor() {}
|
||||
|
||||
get isLoggedIn(): boolean {
|
||||
return this._isLoggedIn;
|
||||
}
|
||||
|
||||
get userId(): string {
|
||||
return this._userId;
|
||||
}
|
||||
|
||||
get completedLevelCount(): number {
|
||||
return this._completedLevelCount;
|
||||
}
|
||||
|
||||
/** 获取 game-data 返回的下一关数据 */
|
||||
get nextLevel(): NextLevelData | null {
|
||||
return this._nextLevel;
|
||||
}
|
||||
|
||||
addCompletedLevelCount(delta: number = 1): void {
|
||||
this._completedLevelCount = Math.max(0, this._completedLevelCount + delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化认证:尝试恢复 token 或执行微信登录
|
||||
*/
|
||||
async initialize(): Promise<boolean> {
|
||||
const savedToken = StorageManager.getToken();
|
||||
if (savedToken) {
|
||||
HttpUtil.setAuthToken(savedToken);
|
||||
try {
|
||||
const valid = await this.validateToken();
|
||||
if (valid) {
|
||||
console.log('[AuthManager] Token 恢复成功');
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
console.warn('[AuthManager] 本地 token 无效,重新登录');
|
||||
}
|
||||
}
|
||||
|
||||
return this.wxLogin();
|
||||
}
|
||||
|
||||
private async wxLogin(): Promise<boolean> {
|
||||
try {
|
||||
let code: string;
|
||||
|
||||
if (WxSDK.isWechat()) {
|
||||
code = await WxSDK.login();
|
||||
} else {
|
||||
console.warn('[AuthManager] 非微信环境,使用开发模式 mock code');
|
||||
code = 'dev_mock_code';
|
||||
}
|
||||
|
||||
const response = await HttpUtil.post<ApiEnvelope<WxLoginData>>(
|
||||
API_ENDPOINTS.WX_LOGIN,
|
||||
{ code },
|
||||
API_TIMEOUT.DEFAULT
|
||||
);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
console.error('[AuthManager] 登录失败:', response.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { token, user } = response.data;
|
||||
HttpUtil.setAuthToken(token);
|
||||
StorageManager.setToken(token);
|
||||
|
||||
this._userId = user.id;
|
||||
this._isLoggedIn = true;
|
||||
|
||||
// 登录响应中 stamina 是原始数值(不含实时恢复),先存储默认体力
|
||||
// 后续通过 game-data 接口获取完整 StaminaInfo
|
||||
console.log(`[AuthManager] 登录成功,用户: ${user.id},体力: ${user.stamina}`);
|
||||
|
||||
// 获取通关进度和完整体力信息
|
||||
await this.fetchGameData();
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[AuthManager] 登录异常:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async validateToken(): Promise<boolean> {
|
||||
const gameData = await this._fetchGameData();
|
||||
if (!gameData) return false;
|
||||
|
||||
this._userId = gameData.user.id;
|
||||
this._isLoggedIn = true;
|
||||
StorageManager.setStamina(gameData.user.stamina);
|
||||
this._completedLevelCount = gameData.completedLevelCount;
|
||||
this._nextLevel = gameData.nextLevel;
|
||||
|
||||
console.log(`[AuthManager] Token 验证成功,体力: ${gameData.user.stamina.current}/${gameData.user.stamina.max},已完成: ${this._completedLevelCount} 关`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录成功后获取游戏数据(体力 + 通关进度 + 下一关)
|
||||
*/
|
||||
private async fetchGameData(): Promise<void> {
|
||||
const gameData = await this._fetchGameData();
|
||||
if (gameData) {
|
||||
this._completedLevelCount = gameData.completedLevelCount;
|
||||
this._nextLevel = gameData.nextLevel;
|
||||
StorageManager.setStamina(gameData.user.stamina);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从服务端获取游戏数据(共用方法)
|
||||
*/
|
||||
private async _fetchGameData(): Promise<GameData | null> {
|
||||
try {
|
||||
const response = await HttpUtil.get<ApiEnvelope<GameData>>(
|
||||
API_ENDPOINTS.USER_GAME_DATA,
|
||||
API_TIMEOUT.SHORT
|
||||
);
|
||||
return (response.success && response.data) ? response.data : null;
|
||||
} catch {
|
||||
console.warn('[AuthManager] 获取游戏数据失败');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 HTTP 工具类 (assets/scripts/utils/HttpUtil.ts)
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* HTTP 请求工具类
|
||||
* 封装 XMLHttpRequest,支持 GET/POST 请求,支持 JWT 认证
|
||||
*/
|
||||
export class HttpUtil {
|
||||
/** 认证 token */
|
||||
private static _authToken: string | null = null;
|
||||
|
||||
/**
|
||||
* 设置认证 token
|
||||
* @param token JWT token
|
||||
*/
|
||||
static setAuthToken(token: string | null): void {
|
||||
HttpUtil._authToken = token;
|
||||
console.log(`[HttpUtil] Auth token ${token ? '已设置' : '已清除'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取认证 token
|
||||
*/
|
||||
static getAuthToken(): string | null {
|
||||
return HttpUtil._authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 GET 请求
|
||||
* @param url 请求 URL
|
||||
* @param timeout 超时时间(毫秒),默认 10000
|
||||
* @returns Promise<Response>
|
||||
*/
|
||||
static get<T>(url: string, timeout: number = 10000): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.open('GET', url, true);
|
||||
xhr.timeout = timeout;
|
||||
xhr.responseType = 'json';
|
||||
|
||||
// 设置认证头
|
||||
if (HttpUtil._authToken) {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${HttpUtil._authToken}`);
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(xhr.response as T);
|
||||
} else {
|
||||
reject(new Error(`HTTP 错误: ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(new Error('网络请求失败'));
|
||||
};
|
||||
|
||||
xhr.ontimeout = () => {
|
||||
reject(new Error('请求超时'));
|
||||
};
|
||||
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 POST 请求
|
||||
* @param url 请求 URL
|
||||
* @param data 请求体数据
|
||||
* @param timeout 超时时间(毫秒),默认 10000
|
||||
* @returns Promise<Response>
|
||||
*/
|
||||
static post<T>(url: string, data: object, timeout: number = 10000): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.open('POST', url, true);
|
||||
xhr.timeout = timeout;
|
||||
xhr.responseType = 'json';
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
|
||||
// 设置认证头
|
||||
if (HttpUtil._authToken) {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${HttpUtil._authToken}`);
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(xhr.response as T);
|
||||
} else {
|
||||
reject(new Error(`HTTP 错误: ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(new Error('网络请求失败'));
|
||||
};
|
||||
|
||||
xhr.ontimeout = () => {
|
||||
reject(new Error('请求超时'));
|
||||
};
|
||||
|
||||
xhr.send(JSON.stringify(data));
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 本地存储管理器 (assets/scripts/utils/StorageManager.ts)
|
||||
|
||||
```typescript
|
||||
import { sys } from 'cc';
|
||||
import { StaminaInfo } from '../types/ApiTypes';
|
||||
|
||||
/**
|
||||
* 用户进度数据结构
|
||||
*/
|
||||
interface UserProgress {
|
||||
/** 当前关卡索引(0-based) */
|
||||
currentLevelIndex: number;
|
||||
/** 已通关的最高关卡索引 */
|
||||
maxUnlockedLevelIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户信息结构
|
||||
*/
|
||||
interface UserInfo {
|
||||
avatarUrl: string;
|
||||
nickName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地存储管理器
|
||||
* 统一管理用户数据的本地持久化存储
|
||||
*/
|
||||
export class StorageManager {
|
||||
/** 体力值存储键 */
|
||||
private static readonly KEY_STAMINA = 'game_stamina';
|
||||
|
||||
/** 用户进度存储键 */
|
||||
private static readonly KEY_PROGRESS = 'game_progress';
|
||||
|
||||
/** 认证 token 存储键 */
|
||||
private static readonly KEY_TOKEN = 'auth_token';
|
||||
|
||||
/** 用户信息存储键 */
|
||||
private static readonly KEY_USER_INFO = 'user_info';
|
||||
|
||||
/** 默认体力值 */
|
||||
private static readonly DEFAULT_STAMINA: StaminaInfo = {
|
||||
current: 50,
|
||||
max: 50,
|
||||
nextRecoverAt: null,
|
||||
};
|
||||
|
||||
/** 默认进度 */
|
||||
private static readonly DEFAULT_PROGRESS: UserProgress = {
|
||||
currentLevelIndex: 0,
|
||||
maxUnlockedLevelIndex: 0
|
||||
};
|
||||
|
||||
/** 进度缓存(避免重复读取 localStorage) */
|
||||
private static _progressCache: UserProgress | null = null;
|
||||
|
||||
/** 体力缓存(避免重复 JSON 解析) */
|
||||
private static _staminaCache: StaminaInfo | null = null;
|
||||
|
||||
// ==================== 体力值管理 ====================
|
||||
|
||||
/**
|
||||
* 获取当前体力信息(带内存缓存,避免重复 JSON 解析)
|
||||
*/
|
||||
static getStamina(): StaminaInfo {
|
||||
if (StorageManager._staminaCache) {
|
||||
return { ...StorageManager._staminaCache };
|
||||
}
|
||||
|
||||
const stored = sys.localStorage.getItem(StorageManager.KEY_STAMINA);
|
||||
if (stored === null || stored === '') {
|
||||
StorageManager.setStamina(StorageManager.DEFAULT_STAMINA);
|
||||
return { ...StorageManager.DEFAULT_STAMINA };
|
||||
}
|
||||
try {
|
||||
const stamina = JSON.parse(stored) as StaminaInfo;
|
||||
if (typeof stamina.current !== 'number' || typeof stamina.max !== 'number') {
|
||||
StorageManager.setStamina(StorageManager.DEFAULT_STAMINA);
|
||||
return { ...StorageManager.DEFAULT_STAMINA };
|
||||
}
|
||||
StorageManager._staminaCache = stamina;
|
||||
return { ...stamina };
|
||||
} catch {
|
||||
StorageManager.setStamina(StorageManager.DEFAULT_STAMINA);
|
||||
return { ...StorageManager.DEFAULT_STAMINA };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置体力信息(同时更新缓存)
|
||||
*/
|
||||
static setStamina(stamina: StaminaInfo): void {
|
||||
StorageManager._staminaCache = stamina;
|
||||
sys.localStorage.setItem(StorageManager.KEY_STAMINA, JSON.stringify(stamina));
|
||||
console.log(`[StorageManager] 体力已更新: ${stamina.current}/${stamina.max}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有足够的体力
|
||||
*/
|
||||
static hasStamina(): boolean {
|
||||
return StorageManager.getStamina().current > 0;
|
||||
}
|
||||
|
||||
// ==================== 认证 Token 管理 ====================
|
||||
|
||||
/**
|
||||
* 获取认证 token
|
||||
*/
|
||||
static getToken(): string | null {
|
||||
const token = sys.localStorage.getItem(StorageManager.KEY_TOKEN);
|
||||
return (token === null || token === '') ? null : token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置认证 token
|
||||
*/
|
||||
static setToken(token: string): void {
|
||||
sys.localStorage.setItem(StorageManager.KEY_TOKEN, token);
|
||||
console.log('[StorageManager] Token 已保存');
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除认证 token
|
||||
*/
|
||||
static clearToken(): void {
|
||||
sys.localStorage.removeItem(StorageManager.KEY_TOKEN);
|
||||
console.log('[StorageManager] Token 已清除');
|
||||
}
|
||||
|
||||
// ==================== 关卡进度管理 ====================
|
||||
|
||||
/**
|
||||
* 获取用户进度数据(带缓存)
|
||||
* @returns 用户进度对象的副本
|
||||
*/
|
||||
private static _getProgress(): UserProgress {
|
||||
// 返回缓存副本
|
||||
if (StorageManager._progressCache !== null) {
|
||||
return { ...StorageManager._progressCache };
|
||||
}
|
||||
|
||||
const stored = sys.localStorage.getItem(StorageManager.KEY_PROGRESS);
|
||||
if (stored === null || stored === '') {
|
||||
// 新用户,返回默认进度
|
||||
StorageManager._progressCache = { ...StorageManager.DEFAULT_PROGRESS };
|
||||
return { ...StorageManager._progressCache };
|
||||
}
|
||||
|
||||
try {
|
||||
const progress = JSON.parse(stored) as UserProgress;
|
||||
// 验证数据有效性
|
||||
if (typeof progress.currentLevelIndex !== 'number' ||
|
||||
typeof progress.maxUnlockedLevelIndex !== 'number' ||
|
||||
progress.currentLevelIndex < 0 ||
|
||||
progress.maxUnlockedLevelIndex < 0) {
|
||||
console.warn('[StorageManager] 进度数据无效,使用默认值');
|
||||
StorageManager._progressCache = { ...StorageManager.DEFAULT_PROGRESS };
|
||||
} else {
|
||||
StorageManager._progressCache = progress;
|
||||
}
|
||||
return { ...StorageManager._progressCache };
|
||||
} catch (e) {
|
||||
console.warn('[StorageManager] 解析进度数据失败,使用默认值');
|
||||
StorageManager._progressCache = { ...StorageManager.DEFAULT_PROGRESS };
|
||||
return { ...StorageManager._progressCache };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户进度数据
|
||||
* @param progress 进度对象
|
||||
*/
|
||||
private static _saveProgress(progress: UserProgress): void {
|
||||
StorageManager._progressCache = progress;
|
||||
sys.localStorage.setItem(StorageManager.KEY_PROGRESS, JSON.stringify(progress));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前关卡索引
|
||||
* @returns 当前关卡索引(0-based)
|
||||
*/
|
||||
static getCurrentLevelIndex(): number {
|
||||
return StorageManager._getProgress().currentLevelIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前关卡索引
|
||||
* @param index 关卡索引
|
||||
*/
|
||||
static setCurrentLevelIndex(index: number): void {
|
||||
if (index < 0) {
|
||||
console.warn('[StorageManager] 关卡索引不能为负数');
|
||||
return;
|
||||
}
|
||||
const progress = StorageManager._getProgress();
|
||||
progress.currentLevelIndex = index;
|
||||
StorageManager._saveProgress(progress);
|
||||
console.log(`[StorageManager] 当前关卡已更新: ${progress.currentLevelIndex}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已解锁的最高关卡索引
|
||||
* @returns 最高关卡索引(0-based)
|
||||
*/
|
||||
static getMaxUnlockedLevelIndex(): number {
|
||||
return StorageManager._getProgress().maxUnlockedLevelIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通关后更新进度
|
||||
* 当玩家通关第 N 关后,设置当前关卡为 N+1,解锁关卡更新为 max(N, 已解锁)
|
||||
* @param completedLevelIndex 刚通关的关卡索引
|
||||
*/
|
||||
static onLevelCompleted(completedLevelIndex: number): void {
|
||||
if (completedLevelIndex < 0) {
|
||||
console.warn('[StorageManager] 通关关卡索引不能为负数');
|
||||
return;
|
||||
}
|
||||
const progress = StorageManager._getProgress();
|
||||
const nextLevelIndex = completedLevelIndex + 1;
|
||||
|
||||
// 更新当前关卡为下一关
|
||||
progress.currentLevelIndex = nextLevelIndex;
|
||||
|
||||
// 更新最高解锁关卡
|
||||
progress.maxUnlockedLevelIndex = Math.max(progress.maxUnlockedLevelIndex, completedLevelIndex);
|
||||
|
||||
StorageManager._saveProgress(progress);
|
||||
console.log(`[StorageManager] 通关第 ${completedLevelIndex + 1} 关,下一关: ${nextLevelIndex + 1}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定关卡是否已解锁
|
||||
* @param levelIndex 关卡索引
|
||||
* @returns 是否已解锁
|
||||
*/
|
||||
static isLevelUnlocked(levelIndex: number): boolean {
|
||||
const progress = StorageManager._getProgress();
|
||||
return levelIndex <= progress.maxUnlockedLevelIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有进度
|
||||
*/
|
||||
static resetProgress(): void {
|
||||
StorageManager._progressCache = null;
|
||||
sys.localStorage.removeItem(StorageManager.KEY_PROGRESS);
|
||||
console.log('[StorageManager] 进度已重置');
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有数据(体力 + 进度)
|
||||
*/
|
||||
static resetAll(): void {
|
||||
StorageManager.setStamina(StorageManager.DEFAULT_STAMINA);
|
||||
StorageManager.resetProgress();
|
||||
StorageManager.clearToken();
|
||||
StorageManager.clearUserInfo();
|
||||
console.log('[StorageManager] 所有数据已重置');
|
||||
}
|
||||
|
||||
// ==================== 用户信息管理 ====================
|
||||
|
||||
/**
|
||||
* 保存用户信息(头像、昵称)
|
||||
* @param userInfo 用户信息对象
|
||||
*/
|
||||
static setUserInfo(userInfo: UserInfo): void {
|
||||
sys.localStorage.setItem(StorageManager.KEY_USER_INFO, JSON.stringify(userInfo));
|
||||
console.log('[StorageManager] 用户信息已保存');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地缓存的用户信息
|
||||
* @returns 用户信息对象或 null
|
||||
*/
|
||||
static getUserInfo(): UserInfo | null {
|
||||
const data = sys.localStorage.getItem(StorageManager.KEY_USER_INFO);
|
||||
if (!data) return null;
|
||||
try {
|
||||
return JSON.parse(data) as UserInfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除用户信息缓存
|
||||
*/
|
||||
static clearUserInfo(): void {
|
||||
sys.localStorage.removeItem(StorageManager.KEY_USER_INFO);
|
||||
console.log('[StorageManager] 用户信息已清除');
|
||||
}
|
||||
}
|
||||
```
|
||||
600
软著源代码/04-首页与体力系统.md
Normal file
600
软著源代码/04-首页与体力系统.md
Normal file
@@ -0,0 +1,600 @@
|
||||
# 梗中作乐 V1.0 - 源代码文档(第 4 部分)
|
||||
|
||||
软件名称:梗中作乐
|
||||
版本号:V1.0
|
||||
|
||||
---
|
||||
|
||||
## 第 4 部分:首页与体力系统
|
||||
|
||||
本部分包含小游戏首页、体力(次数)管理、背景自适应缩放三个模块。
|
||||
|
||||
### 4.1 首页 (assets/prefabs/PageHome.ts)
|
||||
|
||||
```typescript
|
||||
import { _decorator, Node, Button, Label, tween, Vec3, UIOpacity, UITransform, Color, instantiate, ProgressBar } from 'cc';
|
||||
import { BaseView } from 'db://assets/scripts/core/BaseView';
|
||||
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
|
||||
import { WxSDK } from 'db://assets/scripts/utils/WxSDK';
|
||||
import { StaminaManager } from 'db://assets/scripts/utils/StaminaManager';
|
||||
import { ToastManager } from 'db://assets/scripts/utils/ToastManager';
|
||||
import { StaminaInfo } from 'db://assets/scripts/types/ApiTypes';
|
||||
import { AuthManager } from 'db://assets/scripts/utils/AuthManager';
|
||||
import { AchievementTitleManager } from 'db://assets/scripts/utils/AchievementTitleManager';
|
||||
import { ShareManager } from 'db://assets/scripts/utils/ShareManager';
|
||||
import { AudioManager } from 'db://assets/scripts/utils/AudioManager';
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/**
|
||||
* 首页组件
|
||||
* 继承 BaseView,实现页面生命周期
|
||||
*/
|
||||
@ccclass('PageHome')
|
||||
export class PageHome extends BaseView {
|
||||
/** 默认体力上限 */
|
||||
private static readonly DEFAULT_STAMINA_MAX = 50;
|
||||
|
||||
@property({ type: Node, tooltip: '开始游戏按钮' })
|
||||
startGameBtn: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: 'PK按钮' })
|
||||
pkBtn: Node | null = null;
|
||||
|
||||
/** 体力值显示标签 */
|
||||
@property(Label)
|
||||
liveLabel: Label | null = null;
|
||||
|
||||
/** 首页主称号文本 */
|
||||
@property(Label)
|
||||
levelLabel: Label | null = null;
|
||||
|
||||
/** 称号进度条 */
|
||||
@property(ProgressBar)
|
||||
titleProgressBar: ProgressBar | null = null;
|
||||
|
||||
/** 称号进度提示文案 */
|
||||
@property(Label)
|
||||
progressLabel: Label | null = null;
|
||||
|
||||
/** 称号进度游标 */
|
||||
@property(Node)
|
||||
progressAnchor: Node | null = null;
|
||||
|
||||
/** 飞行动画持续时间(秒) */
|
||||
private static readonly FLY_DURATION = 0.5;
|
||||
|
||||
/** 到达后弹跳持续时间(秒) */
|
||||
private static readonly BOUNCE_DURATION = 0.15;
|
||||
|
||||
/** 浮动文本动画持续时间(秒) */
|
||||
private static readonly FLOAT_DURATION = 0.8;
|
||||
|
||||
/** 浮动文本上移距离 */
|
||||
private static readonly FLOAT_OFFSET_Y = 120;
|
||||
|
||||
/** 是否正在播放体力消耗动画 */
|
||||
private _isAnimating: boolean = false;
|
||||
|
||||
/** 进度游标 0% 时的本地 X 坐标,根据 ProgressBar Bar 子节点的左端推导出来 */
|
||||
private _progressAnchorStartX: number | null = null;
|
||||
|
||||
/**
|
||||
* 页面首次加载时调用
|
||||
*/
|
||||
onViewLoad(): void {
|
||||
console.log('[PageHome] onViewLoad');
|
||||
this._cacheProgressAnchorStartX();
|
||||
this.updateAchievementTitleInfo();
|
||||
this._initButtons();
|
||||
this._initWxShare();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化微信分享功能
|
||||
*/
|
||||
private _initWxShare(): void {
|
||||
WxSDK.initShare({
|
||||
title: '写英语',
|
||||
imageUrl: '',
|
||||
query: ''
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化按钮事件
|
||||
*/
|
||||
private _initButtons(): void {
|
||||
if (this.startGameBtn) {
|
||||
this.startGameBtn.on(Button.EventType.CLICK, this._onStartGameClick, this);
|
||||
}
|
||||
if (this.pkBtn) {
|
||||
this.pkBtn.on(Button.EventType.CLICK, this._onPkClick, this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始游戏按钮点击回调
|
||||
*/
|
||||
private _onStartGameClick(): void {
|
||||
if (this._isAnimating) return;
|
||||
|
||||
AudioManager.instance.playButtonClick();
|
||||
console.log('[PageHome] 开始游戏按钮点击');
|
||||
|
||||
// 体力检查
|
||||
if (!StaminaManager.instance.hasStamina()) {
|
||||
ToastManager.show('体力不足,请等待恢复');
|
||||
return;
|
||||
}
|
||||
|
||||
// 兜底:清空可能残留的好友分享挑战状态,确保进入的是纯主线挑战
|
||||
// 场景:用户从微信好友分享卡片进入挑战 → 退出回首页 → 再次点击开始游戏
|
||||
// 若不清理,缓存的 PageLevel 仍会读到 ShareManager 的分享态数据
|
||||
if (ShareManager.instance.isShareMode) {
|
||||
console.log('[PageHome] 检测到残留的分享挑战状态,清理后再进入主线挑战');
|
||||
ShareManager.instance.clearShareMode();
|
||||
}
|
||||
|
||||
this._isAnimating = true;
|
||||
this._playStaminaCostAnimation()
|
||||
.then(() => {
|
||||
ViewManager.instance.open('PageLevel', { params: { shareMode: false } });
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[PageHome] 体力消耗动画异常:', err);
|
||||
// 异常兜底:直接进入关卡
|
||||
ViewManager.instance.open('PageLevel', { params: { shareMode: false } });
|
||||
})
|
||||
.finally(() => {
|
||||
this._isAnimating = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PK按钮点击回调
|
||||
*/
|
||||
private _onPkClick(): void {
|
||||
AudioManager.instance.playButtonClick();
|
||||
console.log('[PageHome] PK按钮点击');
|
||||
ViewManager.instance.open('PageWriteLevels');
|
||||
}
|
||||
|
||||
// ========== 体力消耗动画 ==========
|
||||
|
||||
/**
|
||||
* 通过节点路径查找 IconLive
|
||||
*/
|
||||
private _findIconLive(): Node | null {
|
||||
return this.node
|
||||
.getChildByName('TopLayout')
|
||||
?.getChildByName('Live')
|
||||
?.getChildByName('IconLive') ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将节点的世界坐标转换为目标父节点的本地坐标
|
||||
*/
|
||||
private _worldToLocal(worldPos: Vec3, parent: Node): Vec3 {
|
||||
const parentTransform = parent.getComponent(UITransform);
|
||||
if (!parentTransform) return worldPos;
|
||||
return parentTransform.convertToNodeSpaceAR(worldPos);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的世界坐标
|
||||
*/
|
||||
private _getWorldPos(node: Node): Vec3 {
|
||||
const transform = node.getComponent(UITransform);
|
||||
if (!transform) return node.worldPosition.clone();
|
||||
return transform.convertToWorldSpaceAR(Vec3.ZERO);
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放体力消耗动画
|
||||
* 1. 克隆 IconLive 飞向 StarGame 按钮
|
||||
* 2. 到达后弹跳
|
||||
* 3. "体力值-1" 浮动文本上移渐隐
|
||||
* 4. 同步更新体力数字
|
||||
*/
|
||||
private _playStaminaCostAnimation(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
const iconLive = this._findIconLive();
|
||||
const targetBtn = this.startGameBtn;
|
||||
|
||||
if (!iconLive || !targetBtn) {
|
||||
console.warn('[PageHome] 动画节点未找到,跳过动画');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 坐标计算 ---
|
||||
const iconWorldPos = this._getWorldPos(iconLive);
|
||||
const targetWorldPos = this._getWorldPos(targetBtn);
|
||||
const rootNode = this.node;
|
||||
|
||||
// 起始和终点在 root 本地空间的坐标
|
||||
const startLocal = this._worldToLocal(iconWorldPos, rootNode);
|
||||
const endLocal = this._worldToLocal(targetWorldPos, rootNode);
|
||||
|
||||
// --- 克隆飞行节点 ---
|
||||
const flyNode = instantiate(iconLive);
|
||||
flyNode.name = '_flyIcon';
|
||||
flyNode.setPosition(startLocal);
|
||||
// 保持与原始 IconLive 相同的缩放
|
||||
flyNode.setScale(iconLive.worldScale.clone());
|
||||
rootNode.addChild(flyNode);
|
||||
|
||||
// 隐藏原始 IconLive
|
||||
iconLive.active = false;
|
||||
|
||||
// --- 飞行路径(带弧度的抛物线效果) ---
|
||||
// 中间控制点:x 取中点,y 取较高值 + 偏移形成弧线
|
||||
const midX = (startLocal.x + endLocal.x) / 2;
|
||||
const midY = Math.max(startLocal.y, endLocal.y) + 150;
|
||||
const midPoint = new Vec3(midX, midY, 0);
|
||||
|
||||
// 阶段1:飞到弧线顶点
|
||||
const halfDuration = PageHome.FLY_DURATION / 2;
|
||||
|
||||
tween(flyNode)
|
||||
.to(halfDuration, { position: midPoint }, { easing: 'quadOut' })
|
||||
.to(halfDuration, { position: endLocal }, { easing: 'quadIn' })
|
||||
// 阶段2:到达弹跳
|
||||
.to(PageHome.BOUNCE_DURATION / 2, { scale: new Vec3(0.4, 0.4, 1) }, { easing: 'quadOut' })
|
||||
.to(PageHome.BOUNCE_DURATION / 2, { scale: new Vec3(0.3, 0.3, 1) }, { easing: 'quadIn' })
|
||||
.call(() => {
|
||||
// 飞行完成 — flyNode 使命结束,立即清理
|
||||
flyNode.destroy();
|
||||
iconLive.active = true;
|
||||
|
||||
// 创建浮动文本
|
||||
this._showFloatText(targetBtn, rootNode);
|
||||
|
||||
// 乐观更新体力数字
|
||||
this._optimisticUpdateStamina();
|
||||
|
||||
// 等浮动文本播完再 resolve(用 setTimeout,不依赖已销毁节点的 tween)
|
||||
setTimeout(() => resolve(), PageHome.FLOAT_DURATION * 1000);
|
||||
})
|
||||
.start();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示浮动提示文本 "体力值-1"
|
||||
* 从按钮位置向上漂移并渐隐
|
||||
*/
|
||||
private _showFloatText(anchorNode: Node, parentNode: Node): void {
|
||||
// 创建文本节点
|
||||
const textNode = new Node('_floatText');
|
||||
textNode.addComponent(UITransform);
|
||||
|
||||
const label = textNode.addComponent(Label);
|
||||
label.string = '体力值-1';
|
||||
label.fontSize = 36;
|
||||
label.lineHeight = 40;
|
||||
label.color = new Color(255, 80, 80, 255);
|
||||
label.isBold = true;
|
||||
|
||||
// 复用 liveLabel 的字体(如果有)
|
||||
if (this.liveLabel?.font) {
|
||||
label.font = this.liveLabel.font;
|
||||
}
|
||||
|
||||
// 添加 UIOpacity 用于渐隐
|
||||
const opacity = textNode.addComponent(UIOpacity);
|
||||
opacity.opacity = 255;
|
||||
|
||||
// 定位到按钮上方
|
||||
const anchorWorldPos = this._getWorldPos(anchorNode);
|
||||
const localPos = this._worldToLocal(anchorWorldPos, parentNode);
|
||||
// 起始位置在按钮上方偏移
|
||||
localPos.y += 120;
|
||||
textNode.setPosition(localPos);
|
||||
|
||||
parentNode.addChild(textNode);
|
||||
|
||||
// 向上漂移 + 渐隐
|
||||
const floatTarget = new Vec3(localPos.x, localPos.y + PageHome.FLOAT_OFFSET_Y, 0);
|
||||
tween(textNode)
|
||||
.to(PageHome.FLOAT_DURATION, { position: floatTarget }, { easing: 'quadOut' })
|
||||
.call(() => {
|
||||
textNode.destroy();
|
||||
})
|
||||
.start();
|
||||
|
||||
tween(opacity)
|
||||
.delay(PageHome.FLOAT_DURATION * 0.3)
|
||||
.to(PageHome.FLOAT_DURATION * 0.7, { opacity: 0 }, { easing: 'quadIn' })
|
||||
.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 乐观更新体力标签(本地 -1 预扣显示)
|
||||
*/
|
||||
private _optimisticUpdateStamina(): void {
|
||||
if (!this.liveLabel) return;
|
||||
|
||||
const stamina = StaminaManager.instance.getStamina();
|
||||
const maxStamina = this._getStaminaMax(stamina);
|
||||
const displayCurrent = Math.max(0, stamina.current - 1);
|
||||
this.liveLabel.string = `${displayCurrent}/${maxStamina}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面每次显示时调用
|
||||
*/
|
||||
onViewShow(): void {
|
||||
console.log('[PageHome] onViewShow');
|
||||
this.updateStaminaLabel();
|
||||
this.updateAchievementTitleInfo();
|
||||
|
||||
// 保险恢复:防止动画中途被打断导致 IconLive 隐藏残留
|
||||
const iconLive = this._findIconLive();
|
||||
if (iconLive && !iconLive.active) {
|
||||
iconLive.active = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取体力上限
|
||||
*/
|
||||
private _getStaminaMax(stamina: StaminaInfo): number {
|
||||
return typeof stamina.max === 'number' ? stamina.max : PageHome.DEFAULT_STAMINA_MAX;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新体力值显示
|
||||
*/
|
||||
private updateStaminaLabel(): void {
|
||||
if (this.liveLabel) {
|
||||
const stamina = StaminaManager.instance.getStamina();
|
||||
const maxStamina = this._getStaminaMax(stamina);
|
||||
this.liveLabel.string = `${stamina.current}/${maxStamina}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新首页称号进度区域
|
||||
*/
|
||||
private updateAchievementTitleInfo(): void {
|
||||
const titleInfo = AchievementTitleManager.getTitleInfo(AuthManager.instance.completedLevelCount);
|
||||
const progress = this._normalizeProgress(titleInfo.nextTitleProgress);
|
||||
|
||||
if (this.levelLabel) {
|
||||
this.levelLabel.string = titleInfo.titleText;
|
||||
}
|
||||
|
||||
if (this.titleProgressBar) {
|
||||
this.titleProgressBar.progress = progress;
|
||||
}
|
||||
|
||||
if (this.progressLabel) {
|
||||
this.progressLabel.string = titleInfo.progressText;
|
||||
}
|
||||
|
||||
this._updateProgressAnchor(progress);
|
||||
}
|
||||
|
||||
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)。
|
||||
const progressBarNode = this.titleProgressBar.node;
|
||||
const barLocalX = barSprite.node.position.x;
|
||||
this._progressAnchorStartX = progressBarNode.position.x + barLocalX * progressBarNode.scale.x - 30;
|
||||
}
|
||||
|
||||
private _updateProgressAnchor(progress: number): void {
|
||||
if (!this.progressAnchor) {
|
||||
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 _normalizeProgress(progress: number): void {
|
||||
if (!Number.isFinite(progress) || progress <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(1, progress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面隐藏时调用
|
||||
*/
|
||||
onViewHide(): void {
|
||||
console.log('[PageHome] onViewHide');
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面销毁时调用
|
||||
*/
|
||||
onViewDestroy(): void {
|
||||
console.log('[PageHome] onViewDestroy');
|
||||
// 移除按钮事件监听
|
||||
if (this.startGameBtn) {
|
||||
this.startGameBtn.off(Button.EventType.CLICK, this._onStartGameClick, this);
|
||||
}
|
||||
if (this.pkBtn) {
|
||||
this.pkBtn.off(Button.EventType.CLICK, this._onPkClick, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 体力管理器 (assets/scripts/utils/StaminaManager.ts)
|
||||
|
||||
```typescript
|
||||
import { HttpUtil } from './HttpUtil';
|
||||
import { StorageManager } from './StorageManager';
|
||||
import { AuthManager } from './AuthManager';
|
||||
import { API_TIMEOUT, getLevelEnterUrl, getLevelCompleteUrl } from '../config/ApiConfig';
|
||||
import { ApiEnvelope, StaminaInfo, EnterLevelData, CompleteLevelData } from '../types/ApiTypes';
|
||||
|
||||
/**
|
||||
* 体力值管理器
|
||||
* 单例模式,负责体力值的服务端同步、进入关卡和通关上报
|
||||
* 以服务端为准,本地 StorageManager 作为缓存
|
||||
*/
|
||||
export class StaminaManager {
|
||||
private static _instance: StaminaManager | null = null;
|
||||
|
||||
static get instance(): StaminaManager {
|
||||
if (!this._instance) {
|
||||
this._instance = new StaminaManager();
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* 获取当前体力信息(从本地缓存)
|
||||
*/
|
||||
getStamina(): StaminaInfo {
|
||||
return StorageManager.getStamina();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新本地缓存的体力信息
|
||||
* @param stamina 服务端返回的体力信息
|
||||
*/
|
||||
updateStamina(stamina: StaminaInfo): void {
|
||||
StorageManager.setStamina(stamina);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查当前是否有足够的体力
|
||||
*/
|
||||
hasStamina(): boolean {
|
||||
return StorageManager.hasStamina();
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入关卡
|
||||
* 消耗 1 点体力(未通关关卡),获取完整关卡详情(含答案和线索)
|
||||
* @param levelId 关卡 ID
|
||||
* @returns 关卡详情,失败时返回 null
|
||||
*/
|
||||
async enterLevel(levelId: string): Promise<EnterLevelData | null> {
|
||||
if (!AuthManager.instance.isLoggedIn) {
|
||||
console.warn('[StaminaManager] 未登录,无法进入关卡');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await HttpUtil.post<ApiEnvelope<EnterLevelData>>(
|
||||
getLevelEnterUrl(levelId),
|
||||
{},
|
||||
API_TIMEOUT.DEFAULT
|
||||
);
|
||||
|
||||
if (response.success && response.data) {
|
||||
StorageManager.setStamina(response.data.stamina);
|
||||
console.log(`[StaminaManager] 进入关卡 ${levelId},体力: ${response.data.stamina.current}/${response.data.stamina.max}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
console.warn('[StaminaManager] 进入关卡失败:', response.message);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('[StaminaManager] 进入关卡请求失败:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通关上报
|
||||
* @param levelId 关卡 ID
|
||||
* @param timeSpent 通关耗时(秒)
|
||||
* @returns 通关响应,失败时返回 null
|
||||
*/
|
||||
async completeLevel(levelId: string, timeSpent: number): Promise<CompleteLevelData | null> {
|
||||
if (!AuthManager.instance.isLoggedIn) {
|
||||
console.warn('[StaminaManager] 未登录,无法上报通关');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await HttpUtil.post<ApiEnvelope<CompleteLevelData>>(
|
||||
getLevelCompleteUrl(levelId),
|
||||
{ timeSpent },
|
||||
API_TIMEOUT.DEFAULT
|
||||
);
|
||||
|
||||
if (response.success && response.data) {
|
||||
console.log(`[StaminaManager] 通关上报成功: ${levelId}, 首次: ${response.data.firstClear}, 耗时: ${response.data.timeSpent}s`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
console.warn('[StaminaManager] 通关上报失败:', response.message);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('[StaminaManager] 通关上报请求失败:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 背景缩放工具 (assets/scripts/utils/BackgroundScaler.ts)
|
||||
|
||||
```typescript
|
||||
import { _decorator, Component, UITransform, view } from 'cc';
|
||||
const { ccclass, menu, disallowMultiple } = _decorator;
|
||||
|
||||
@ccclass('BackgroundScaler')
|
||||
@menu('Nexux/BackgroundScaler')
|
||||
@disallowMultiple()
|
||||
export class BackgroundScaler extends Component {
|
||||
onLoad() {
|
||||
const bg = this.node;
|
||||
const uiTransform = bg.getComponent(UITransform);
|
||||
const bgSize = uiTransform?.contentSize;
|
||||
|
||||
if (!bgSize) {
|
||||
console.error('BackgroundScaler: bgSize is null');
|
||||
return;
|
||||
}
|
||||
|
||||
const winSize = view.getVisibleSize();
|
||||
|
||||
const scaleX = winSize.width / bgSize.width;
|
||||
const scaleY = winSize.height / bgSize.height;
|
||||
|
||||
const scale = Math.max(scaleX, scaleY);
|
||||
|
||||
bg.setScale(scale, scale);
|
||||
}
|
||||
}
|
||||
```
|
||||
715
软著源代码/05-关卡核心逻辑.md
Normal file
715
软著源代码/05-关卡核心逻辑.md
Normal file
@@ -0,0 +1,715 @@
|
||||
# 梗中作乐 V1.0 - 源代码文档(第 5 部分)
|
||||
|
||||
软件名称:梗中作乐
|
||||
版本号:V1.0
|
||||
|
||||
---
|
||||
|
||||
## 第 5 部分:关卡核心逻辑
|
||||
|
||||
本部分展示关卡页 PageLevel 的核心实现:包含视图绑定、关卡数据加载、答题判定与通关流程驱动的关键代码。
|
||||
|
||||
### 5.1 关卡页 (assets/prefabs/PageLevel.ts,节选 1-700 行)
|
||||
|
||||
```typescript
|
||||
import { _decorator, Node, EditBox, instantiate, Vec3, Button, Label, Sprite, SpriteFrame, AudioClip, AudioSource, Prefab, EffectAsset, UITransform, UIOpacity, ProgressBar, tween, Tween, Color, Layout, Widget, sp, view, resources } from 'cc';
|
||||
import { BaseView } from 'db://assets/scripts/core/BaseView';
|
||||
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
|
||||
import { StaminaManager } from 'db://assets/scripts/utils/StaminaManager';
|
||||
import { WxSDK, getUserProfile, type WxUserInfo } from 'db://assets/scripts/utils/WxSDK';
|
||||
import { LevelDataManager } from 'db://assets/scripts/utils/LevelDataManager';
|
||||
import { AuthManager } from 'db://assets/scripts/utils/AuthManager';
|
||||
import { RuntimeLevelConfig } from 'db://assets/scripts/types/LevelTypes';
|
||||
import { ToastManager } from 'db://assets/scripts/utils/ToastManager';
|
||||
import { ShareManager } from 'db://assets/scripts/utils/ShareManager';
|
||||
import { StorageManager } from 'db://assets/scripts/utils/StorageManager';
|
||||
import { HttpUtil } from 'db://assets/scripts/utils/HttpUtil';
|
||||
import { API_ENDPOINTS, API_TIMEOUT } from 'db://assets/scripts/config/ApiConfig';
|
||||
import { WrongModal } from 'db://assets/prefabs/WrongModal';
|
||||
import { TimeoutModal } from 'db://assets/prefabs/TimeoutModal';
|
||||
import { CommonModal } from 'db://assets/prefabs/CommonModal';
|
||||
import { ApiEnvelope, StaminaInfo, NextLevelData, SubmitShareLevel } from 'db://assets/scripts/types/ApiTypes';
|
||||
import { AchievementTitleManager, AchievementTitleInfo } from 'db://assets/scripts/utils/AchievementTitleManager';
|
||||
import { AchievementTitleAnimator } from 'db://assets/scripts/utils/AchievementTitleAnimator';
|
||||
import { applyRoundedCorner } from 'db://assets/scripts/utils/roundedMaterial.utils';
|
||||
import { AudioManager } from 'db://assets/scripts/utils/AudioManager';
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/**
|
||||
* 关卡页面组件
|
||||
* 继承 BaseView,实现页面生命周期
|
||||
* 关卡流程由服务端 NextLevelData 驱动,客户端不再维护关卡列表
|
||||
*/
|
||||
@ccclass('PageLevel')
|
||||
export class PageLevel extends BaseView {
|
||||
/** 静态常量:零位置 */
|
||||
private static readonly ZERO_POS = new Vec3(0, 0, 0);
|
||||
|
||||
/** 解锁线索按钮默认文案 */
|
||||
private static readonly UNLOCK_BUTTON_CLUE_TEXT = '查看线索';
|
||||
|
||||
/** 线索全部查看后的按钮文案 */
|
||||
private static readonly UNLOCK_BUTTON_ANSWER_TEXT = '查看答案';
|
||||
|
||||
/** 默认体力上限,服务端未返回 max 时使用 */
|
||||
private static readonly DEFAULT_STAMINA_MAX = 50;
|
||||
|
||||
/** 答案正确后到弹出通关弹窗之间的停留时间(不论是否有谐音梗都保持一致) */
|
||||
private static readonly PASS_MODAL_DELAY_MS = 800;
|
||||
|
||||
/** 图片2描述默认文案 */
|
||||
private static readonly DEFAULT_IMAGE2_DESCRIPTION = '这是什么?';
|
||||
|
||||
/** 线索解锁出现动画时长(ms) */
|
||||
private static readonly CLUE_APPEAR_DURATION = 0.3;
|
||||
|
||||
/** 线索解锁出现动画起始缩放 */
|
||||
private static readonly CLUE_APPEAR_START_SCALE = 0.8;
|
||||
|
||||
/** 倒计时进入紧迫状态的阈值(秒,≤ 该值开始警示) */
|
||||
private static readonly CLOCK_URGENT_THRESHOLD = 10;
|
||||
|
||||
/** 紧迫状态下倒计时字体颜色(红) */
|
||||
private static readonly CLOCK_URGENT_COLOR = new Color(230, 60, 60, 255);
|
||||
|
||||
/** 倒计时 tick 脉冲峰值缩放 */
|
||||
private static readonly CLOCK_PULSE_PEAK_SCALE = 1.3;
|
||||
|
||||
/** 倒计时 tick 脉冲单向时长(放大、回落各一半) */
|
||||
private static readonly CLOCK_PULSE_HALF_DURATION = 0.15;
|
||||
|
||||
/** 谐音梗揭示动画:InputLayout 位移、divider 淡入时长 */
|
||||
private static readonly PUNCH_REVEAL_DURATION = 0.3;
|
||||
|
||||
/** 谐音梗揭示动画:punchLayout 出现的起始缩放 */
|
||||
private static readonly PUNCH_REVEAL_START_SCALE = 0.85;
|
||||
|
||||
/** 谐音梗揭示动画:punchLayout 在 InputLayout 动起来后再出现的延迟(让动画有节奏) */
|
||||
private static readonly PUNCH_REVEAL_DELAY = 0.1;
|
||||
|
||||
/** 分享模式只展示提示 1 时,固定放回 TipsLayout 顶部,避免 Layout 把它排到底部被下一题按钮遮挡 */
|
||||
private static readonly SHARE_MODE_TIP1_Y = 120;
|
||||
|
||||
/** 分享模式底部按钮默认文案 */
|
||||
private static readonly SHARE_NEXT_BUTTON_TEXT = '下一题';
|
||||
|
||||
/** 分享模式最后一题提交文案 */
|
||||
private static readonly SHARE_SUBMIT_BUTTON_TEXT = '提交答案';
|
||||
|
||||
/** PassNode 滑入 / 滑出动画时长(秒) */
|
||||
private static readonly PASS_NODE_SLIDE_DURATION = 0.4;
|
||||
|
||||
/** BottomLayout / TipsLayout 透明度淡入淡出动画时长(秒) */
|
||||
private static readonly BOTTOM_LAYER_FADE_DURATION = 0.25;
|
||||
|
||||
/** 彩带 spine 动画名(一次播放) */
|
||||
private static readonly CAIDAI_ANIMATION_NAME = 'open';
|
||||
|
||||
/** pose 赞美音效 resources 路径(assets/resources/audios/good.mp3) */
|
||||
private static readonly GOOD_AUDIO_RESOURCE_PATH = 'audios/good';
|
||||
|
||||
/** pose 赞美段提前量:比通关动效 / 成功音效结束点提前 1 秒启动 */
|
||||
private static readonly POSE_PRAISE_ADVANCE_SECONDS = 1.5;
|
||||
|
||||
/**
|
||||
* 通关赞美 spine(pose 节点)档位:[最小通关数, 动画名],倒序匹配。
|
||||
* 1-5 关 → "1",6-10 关 → "2",11+ 关 → "3"。
|
||||
* count <= 0 不会匹配任何档位,自然不播放。
|
||||
*/
|
||||
private static readonly POSE_TIERS: ReadonlyArray<readonly [number, string]> = [
|
||||
[11, '3'],
|
||||
[6, '2'],
|
||||
[1, '1'],
|
||||
];
|
||||
|
||||
// ========== 节点引用 ==========
|
||||
@property(Node)
|
||||
inputLayout: Node | null = null;
|
||||
|
||||
@property(Node)
|
||||
punchLayout: Node | null = null;
|
||||
|
||||
/** Action 区域内 InputLayout 与 punchLayout 之间的分割线节点(prefab 中的 border_dashline_wht) */
|
||||
@property(Node)
|
||||
punchDivider: Node | null = null;
|
||||
|
||||
@property(Node)
|
||||
submitButton: Node | null = null;
|
||||
|
||||
@property(Node)
|
||||
inputTemplate: Node | null = null;
|
||||
|
||||
@property(Node)
|
||||
actionNode: Node | null = null;
|
||||
|
||||
@property(Node)
|
||||
iconSetting: Node | null = null;
|
||||
|
||||
@property(Node)
|
||||
tipsLayout: Node | null = null;
|
||||
|
||||
@property(Node)
|
||||
mainImage: Node | null = null;
|
||||
|
||||
@property(Node)
|
||||
mainImage2: Node | null = null;
|
||||
|
||||
@property(Label)
|
||||
image1DescLabel: Label | null = null;
|
||||
|
||||
@property(Label)
|
||||
image2DescLabel: Label | null = null;
|
||||
|
||||
@property(Node)
|
||||
tipsItem1: Node | null = null;
|
||||
|
||||
@property(Node)
|
||||
tipsItem2: Node | null = null;
|
||||
|
||||
@property(Node)
|
||||
tipsItem3: Node | null = null;
|
||||
|
||||
@property(Node)
|
||||
unLockTipsBtn: Node | null = null;
|
||||
|
||||
@property(Node)
|
||||
addTimeBtn: Node | null = null;
|
||||
|
||||
@property(Label)
|
||||
clockLabel: Label | null = null;
|
||||
|
||||
/** 体力值显示标签(prefab 中序列化名为 liveLabel,保持兼容) */
|
||||
@property(Label)
|
||||
liveLabel: Label | null = null;
|
||||
|
||||
/** 关卡标题标签,显示为"第 N 关" */
|
||||
@property(Label)
|
||||
titleLevelLabel: Label | null = null;
|
||||
|
||||
/** 普通模式背景 */
|
||||
@property(Node)
|
||||
bgNode: Node | null = null;
|
||||
|
||||
/** 分享 / PK 模式背景 */
|
||||
@property(Node)
|
||||
pkBgNode: Node | null = null;
|
||||
|
||||
/** 普通模式标题容器 */
|
||||
@property(Node)
|
||||
titleLevelNode: Node | null = null;
|
||||
|
||||
/** 分享 / PK 模式标题容器 */
|
||||
@property(Node)
|
||||
pkTitleLevelNode: Node | null = null;
|
||||
|
||||
/** 分享 / PK 模式标题标签 */
|
||||
@property(Label)
|
||||
pkTitleLevelLabel: Label | null = null;
|
||||
|
||||
/** 普通模式体力区域容器 */
|
||||
@property(Node)
|
||||
liveNode: Node | null = null;
|
||||
|
||||
/** 分享 / PK 模式进度容器 */
|
||||
@property(Node)
|
||||
pkLevelProgressNode: Node | null = null;
|
||||
|
||||
/** 分享 / PK 模式进度标签 */
|
||||
@property(Label)
|
||||
pkLevelProgressLabel: Label | null = null;
|
||||
|
||||
/** 普通模式底部按钮区域 */
|
||||
@property(Node)
|
||||
bottomLayoutNode: Node | null = null;
|
||||
|
||||
/** 分享 / PK 模式底部下一题按钮 */
|
||||
@property(Node)
|
||||
pkNextLevelButton: Node | null = null;
|
||||
|
||||
// ========== PassNode(通关后展示)==========
|
||||
/** PassNode 根节点:用户通关后从屏幕左侧滑入 */
|
||||
@property(Node)
|
||||
passNode: Node | null = null;
|
||||
|
||||
/** PassNode 内的成就体系容器(TitlteLevel)。分享模式下整体隐藏 */
|
||||
@property(Node)
|
||||
passTitleLevelNode: Node | null = null;
|
||||
|
||||
/** 称号文案 Label(如「冷场小白1级」) */
|
||||
@property(Label)
|
||||
passTitleLevelLabel: Label | null = null;
|
||||
|
||||
/** 称号进度条 */
|
||||
@property(ProgressBar)
|
||||
passTitleProgressBar: ProgressBar | null = null;
|
||||
|
||||
/** 进度提示 Label(如「还差3题,解锁新成就等级」) */
|
||||
@property(Label)
|
||||
passProgressLabel: Label | null = null;
|
||||
|
||||
/** 进度条上跟随移动的 anchor 节点(其下 Label 显示百分比) */
|
||||
@property(Node)
|
||||
passProgressAnchor: Node | null = null;
|
||||
|
||||
/** 通关页「下一关 / 提交答案」按钮 */
|
||||
@property(Node)
|
||||
passNextLevelButton: Node | null = null;
|
||||
|
||||
/** 通关页「分享」按钮 */
|
||||
@property(Node)
|
||||
passShareButton: Node | null = null;
|
||||
|
||||
/** 彩带 spine 根节点(与 PassNode 同级,挂在 PageLevel 下) */
|
||||
@property(Node)
|
||||
caidaiNode: Node | null = null;
|
||||
|
||||
/** 彩带 sp.Skeleton 组件,用于播放 "open" 动画 */
|
||||
@property(sp.Skeleton)
|
||||
caidaiSkeleton: sp.Skeleton | null = null;
|
||||
|
||||
/** 通关赞美 spine 节点(PassNode 子节点,根据本场通关数选择 1/2/3 动画) */
|
||||
@property(Node)
|
||||
poseNode: Node | null = null;
|
||||
|
||||
/** 通关赞美 sp.Skeleton 组件,动画名 "1" / "2" / "3",loop=false */
|
||||
@property(sp.Skeleton)
|
||||
poseSkeleton: sp.Skeleton | null = null;
|
||||
|
||||
// ========== 配置属性 ==========
|
||||
@property(AudioClip)
|
||||
clickAudio: AudioClip | null = null;
|
||||
|
||||
@property(AudioClip)
|
||||
successAudio: AudioClip | null = null;
|
||||
|
||||
@property(AudioClip)
|
||||
failAudio: AudioClip | null = null;
|
||||
|
||||
@property(Prefab)
|
||||
wrongModalPrefab: Prefab | null = null;
|
||||
|
||||
@property(Prefab)
|
||||
timeoutModalPrefab: Prefab | null = null;
|
||||
|
||||
@property(Prefab)
|
||||
commonModalPrefab: Prefab | null = null;
|
||||
|
||||
/** 主图圆角材质 EffectAsset */
|
||||
@property(EffectAsset)
|
||||
roundedSpriteEffect: EffectAsset | null = null;
|
||||
|
||||
/** 主图圆角半径比例(相对于短边,0-0.5) */
|
||||
@property
|
||||
mainImageCornerRadius: number = 0.1;
|
||||
|
||||
// ========== 内部状态 ==========
|
||||
/** 当前创建的输入框节点数组 */
|
||||
private _inputNodes: Node[] = [];
|
||||
|
||||
/** InputLayout 中默认放置的输入框模板节点 */
|
||||
private _inputTemplateNode: Node | null = null;
|
||||
|
||||
/** 当前创建的包袱展示块节点数组 */
|
||||
private _punchBlockNodes: Node[] = [];
|
||||
|
||||
/** punchLayout 中默认放置的展示块模板节点 */
|
||||
private _punchBlockTemplateNode: Node | null = null;
|
||||
|
||||
/** 是否正在同步输入格内容,避免设置文本时重复触发事件 */
|
||||
private _isSyncingInputText: boolean = false;
|
||||
|
||||
/** 最近一次自动提交的答案,避免填满后重复提交同一内容 */
|
||||
private _lastAutoSubmittedAnswer: string = '';
|
||||
|
||||
/** 当前正在编辑的输入格索引 */
|
||||
private _editingInputIndex: number = -1;
|
||||
|
||||
/** 倒计时剩余秒数 */
|
||||
private _countdown: number = 60;
|
||||
|
||||
/** clockLabel 非紧迫状态下的原始颜色(首次渲染时懒记录,避免 hardcode prefab 颜色) */
|
||||
private _clockLabelNormalColor: Color | null = null;
|
||||
|
||||
/** InputLayout 原始位置(prefab 中的初始 _lpos,作为"有梗揭示"的目标位置) */
|
||||
private _inputLayoutOriginalPos: Vec3 | null = null;
|
||||
|
||||
/** punchLayout 原始位置(prefab 中的初始 _lpos,作为"有梗揭示"的目标位置) */
|
||||
private _punchLayoutOriginalPos: Vec3 | null = null;
|
||||
|
||||
/** "无梗居中态"下 InputLayout 的 Y 坐标:InputLayout 原始 Y 与 punchLayout 原始 Y 的中点 */
|
||||
private _inputLayoutCenteredY: number | null = null;
|
||||
|
||||
/** 关卡开始时间戳(ms),用于准确计算耗时 */
|
||||
private _levelStartTime: number = 0;
|
||||
|
||||
/** 倒计时是否结束 */
|
||||
private _isTimeUp: boolean = false;
|
||||
|
||||
/** 当前关卡配置 */
|
||||
private _currentConfig: RuntimeLevelConfig | null = null;
|
||||
|
||||
/** 是否正在切换关卡(防止重复提交) */
|
||||
private _isTransitioning: boolean = false;
|
||||
|
||||
/** 是否正在解锁提示(防止双击重复触发) */
|
||||
private _isUnlocking: boolean = false;
|
||||
|
||||
/** 下一个待解锁的线索序号(2 或 3),超过 3 表示全部已解锁 */
|
||||
private _nextClueIndex: number = 2;
|
||||
|
||||
/** 通关页(PassNode)当前是否已展示 */
|
||||
private _isPassNodeShown: boolean = false;
|
||||
|
||||
/** PassNode 进出动画是否在进行中(防止重入) */
|
||||
private _isPassNodeAnimating: boolean = false;
|
||||
|
||||
/** PassNode 原始 local position(prefab 摆放位),动画结束后用来回归 */
|
||||
private _passNodeOriginalPos: Vec3 | null = null;
|
||||
|
||||
/** PassNode Widget 初始启用状态;进出场动画期间临时关闭,避免激活首帧回写位置 */
|
||||
private _passNodeWidgetOriginalEnabled: boolean | null = null;
|
||||
|
||||
/** 通关页所用「已通关数量」(业务数据,给成就体系展示用) */
|
||||
private _passCompletedLevelCount: number | null = null;
|
||||
|
||||
/**
|
||||
* 本次进入 PageLevel 后累计通关数(onViewLoad / _reinitLevelSession 时归零)。
|
||||
* 普通模式 / 分享模式都计入;用于驱动 pose 赞美动画档位。
|
||||
*/
|
||||
private _sessionPassCount: number = 0;
|
||||
|
||||
/** pose Spine 隐藏延时定时器(setTimeout 句柄);切关 / 关页时需清理避免穿屏触发 */
|
||||
private _poseHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** pose 赞美延迟播放定时器;等待通关动画与成功音效结束后再触发 */
|
||||
private _posePraiseDelayTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** pose 赞美延迟播放序号,用于取消 resources.load 异步回调里的过期播放 */
|
||||
private _posePraiseSequenceId: number = 0;
|
||||
|
||||
/** good.mp3 加载缓存 */
|
||||
private _goodAudioClip: AudioClip | null = null;
|
||||
|
||||
/** good.mp3 加载中的 Promise,避免重复请求 resources */
|
||||
private _goodAudioLoadPromise: Promise<AudioClip | null> | null = null;
|
||||
|
||||
/** 通关页动画起点(通关前)的已通关数量;为 null 表示不播跨称号过渡 */
|
||||
private _passPreviousCompletedLevelCount: number | null = null;
|
||||
|
||||
/** 通关页称号 / 进度条动画工具,惰性初始化 */
|
||||
private _titleAnimator: AchievementTitleAnimator | null = null;
|
||||
|
||||
/** 错误弹窗实例 */
|
||||
private _wrongModalNode: Node | null = null;
|
||||
|
||||
/** 超时弹窗实例 */
|
||||
private _timeoutModalNode: Node | null = null;
|
||||
|
||||
/** 通用确认弹窗实例 */
|
||||
private _commonModalNode: Node | null = null;
|
||||
|
||||
/** 是否处于分享挑战模式 */
|
||||
private _isShareMode: boolean = false;
|
||||
|
||||
/**
|
||||
* 当前 PageLevel 实例所处分享挑战的 shareCode 缓存。
|
||||
* PageLevel 注册时 cache: true,复用同一个实例。
|
||||
* 当用户在后台切换好友分享卡片时,ShareManager.shareCode 会发生变化,
|
||||
* onViewShow 通过对比这个值与最新的 ShareManager.shareCode 来判断是否需要 _reinitLevelSession。
|
||||
*/
|
||||
private _activeShareCode: string | null = null;
|
||||
|
||||
/** 体力恢复倒计时定时器 */
|
||||
private _staminaTimerId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ========== 关卡驱动状态(NextLevel 驱动) ==========
|
||||
|
||||
/** 当前关卡 ID */
|
||||
private _currentLevelId: string = '';
|
||||
|
||||
/** 当前关卡编号(仅显示用,来自 NextLevelData.level) */
|
||||
private _currentLevelNumber: number = 0;
|
||||
|
||||
/** 下一关数据(来自 complete 接口返回),点击"下一关"时使用 */
|
||||
private _nextLevelData: NextLevelData | null = null;
|
||||
|
||||
/** 分享模式下的关卡索引(仅分享模式使用) */
|
||||
private _shareLevelIndex: number = 0;
|
||||
|
||||
/** 分享模式下每关最终提交内容,等整场结束后一次性提交 */
|
||||
private _shareSubmissions: Map<string, SubmitShareLevel> = new Map();
|
||||
|
||||
/** 是否正在提交分享挑战结果 */
|
||||
private _isSubmittingShareResult: boolean = false;
|
||||
|
||||
/** 本场分享挑战是否已经尝试拉取过用户头像昵称,避免结算流程重复弹窗 */
|
||||
private _hasRequestedShareUserInfo: boolean = false;
|
||||
|
||||
/**
|
||||
* 页面首次加载时调用
|
||||
*/
|
||||
onViewLoad(): void {
|
||||
console.log('[PageLevel] onViewLoad');
|
||||
|
||||
// 本次进入答题页的会话通关数归零;普通 / 分享模式都重新开始计数
|
||||
this._sessionPassCount = 0;
|
||||
|
||||
// 必须在任何可能改动 InputLayout/punchLayout 位置的逻辑之前记录原始位置
|
||||
this._captureActionOriginalPositions();
|
||||
|
||||
const params = this.getParams();
|
||||
this._isShareMode = params?.shareMode === true;
|
||||
|
||||
if (this._isShareMode) {
|
||||
this._shareLevelIndex = 0;
|
||||
this._shareSubmissions.clear();
|
||||
this._isSubmittingShareResult = false;
|
||||
this._hasRequestedShareUserInfo = false;
|
||||
this._activeShareCode = ShareManager.instance.shareCode;
|
||||
console.log('[PageLevel] 进入分享挑战模式');
|
||||
} else {
|
||||
this._activeShareCode = null;
|
||||
// 从 AuthManager 获取首关数据(由 PageLoading → game-data 提供)
|
||||
const nextLevel = AuthManager.instance.nextLevel;
|
||||
if (nextLevel) {
|
||||
this._currentLevelId = nextLevel.id;
|
||||
this._currentLevelNumber = nextLevel.level;
|
||||
console.log(`[PageLevel] 进入关卡: 第 ${nextLevel.level} 关 (${nextLevel.id})`);
|
||||
} else {
|
||||
console.warn('[PageLevel] 没有可用关卡');
|
||||
}
|
||||
}
|
||||
|
||||
this._refreshModeUI();
|
||||
this.updateStaminaLabel();
|
||||
this.initIconSetting();
|
||||
this.initUnlockButtons();
|
||||
this.initSubmitButton();
|
||||
this.initPkNextLevelButton();
|
||||
this._initPassNodeState();
|
||||
|
||||
// 异步加载关卡资源并调用进入关卡接口,完成后启动倒计时
|
||||
this._enterAndInitLevel().catch(err => {
|
||||
console.error('[PageLevel] 进入关卡失败:', err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面每次显示时调用
|
||||
*/
|
||||
onViewShow(): void {
|
||||
console.log('[PageLevel] onViewShow');
|
||||
|
||||
// PageLevel 注册时 cache: true,缓存实例会被复用。
|
||||
// 必须根据本次进入时携带的 params 重新派生 _isShareMode,
|
||||
// 否则上一场分享挑战的状态会残留到下一次主线挑战进入。
|
||||
const params = this.getParams();
|
||||
const desiredShareMode = params?.shareMode === true;
|
||||
|
||||
// 当前 ShareManager 中的 shareCode(可能因为后台切到新的分享卡片而变化)
|
||||
const latestShareCode = ShareManager.instance.shareCode;
|
||||
|
||||
const modeChanged = desiredShareMode !== this._isShareMode;
|
||||
// 同样是分享模式,但 ShareManager 中的 shareCode 已经换了一份题单 —— 也必须重建会话
|
||||
const shareCodeChanged = desiredShareMode && latestShareCode !== this._activeShareCode;
|
||||
|
||||
if (modeChanged || shareCodeChanged) {
|
||||
console.log(
|
||||
`[PageLevel] 检测到模式/分享码切换 mode:${this._isShareMode}->${desiredShareMode} ` +
|
||||
`code:${this._activeShareCode}->${latestShareCode},重新初始化关卡会话`,
|
||||
);
|
||||
this._reinitLevelSession(desiredShareMode);
|
||||
return;
|
||||
}
|
||||
|
||||
// 上一次离场时如果停留在「答对后通关流程」(_isTransitioning=true 由 showSuccess 置位、
|
||||
// 而 _applyLevelConfig 才会重置),缓存的 PageLevel 实例会保留完成态:
|
||||
// - 输入格已填入正确答案
|
||||
// - 提交按钮被 _isTransitioning 锁住,无法重新提交
|
||||
// - 倒计时已停、谐音梗已揭示
|
||||
// 此时玩家从首页再次进入会看到一个无法操作的"死局"。必须把会话推进到下一关。
|
||||
// 注意:这只可能在主线模式发生 —— 分享模式下点 iconSetting / PassNode 的入口
|
||||
// 都会调用 ShareManager.clearShareMode + ViewManager.replace,再次进入会被
|
||||
// 上面的 modeChanged / shareCodeChanged 分支拦截走 _reinitLevelSession。
|
||||
if (this._isTransitioning) {
|
||||
console.log('[PageLevel] 上次离场时停留在通关后状态,自动推进到下一关');
|
||||
this._resetPassNode();
|
||||
this._closeWrongModal();
|
||||
this._closeTimeoutModal();
|
||||
this._closeCommonModal();
|
||||
void this.goToNextLevel();
|
||||
return;
|
||||
}
|
||||
|
||||
this._refreshModeUI();
|
||||
this.updateStaminaLabel();
|
||||
if (!this._isShareMode) {
|
||||
this._startStaminaRecoverTimer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨模式切换时(例如分享挑战 → 主线挑战)重置会话状态并重新加载关卡。
|
||||
* 仅在 onViewShow 检测到模式发生变化时调用,避免对正常的同模式连续作答产生副作用。
|
||||
*/
|
||||
private _reinitLevelSession(shareMode: boolean): void {
|
||||
this._isShareMode = shareMode;
|
||||
|
||||
// 跨模式切换视为新的"本次进入"会话,赞美动画从最低档位重新开始
|
||||
this._sessionPassCount = 0;
|
||||
|
||||
// 上一场可能遗留的弹窗 / 倒计时一并清掉,避免主线模式还看到分享态弹窗
|
||||
this._resetPassNode();
|
||||
this._closeWrongModal();
|
||||
this._closeTimeoutModal();
|
||||
this._closeCommonModal();
|
||||
this.stopCountdown();
|
||||
this._stopStaminaRecoverTimer();
|
||||
|
||||
// 复位分享态相关字段(无论切换到哪个模式,分享态都不应残留)
|
||||
this._shareLevelIndex = 0;
|
||||
this._shareSubmissions.clear();
|
||||
this._isSubmittingShareResult = false;
|
||||
this._hasRequestedShareUserInfo = false;
|
||||
|
||||
if (this._isShareMode) {
|
||||
this._activeShareCode = ShareManager.instance.shareCode;
|
||||
console.log(`[PageLevel] 切换到分享挑战模式 (shareCode=${this._activeShareCode})`);
|
||||
} else {
|
||||
this._activeShareCode = null;
|
||||
// 主线模式:从 AuthManager 拉取最新的 nextLevel
|
||||
this._nextLevelData = null;
|
||||
const nextLevel = AuthManager.instance.nextLevel;
|
||||
if (nextLevel) {
|
||||
this._currentLevelId = nextLevel.id;
|
||||
this._currentLevelNumber = nextLevel.level;
|
||||
console.log(`[PageLevel] 切换到主线挑战,进入关卡: 第 ${nextLevel.level} 关 (${nextLevel.id})`);
|
||||
} else {
|
||||
this._currentLevelId = '';
|
||||
this._currentLevelNumber = 0;
|
||||
console.warn('[PageLevel] 切换到主线挑战,但没有可用关卡');
|
||||
}
|
||||
}
|
||||
|
||||
this._refreshModeUI();
|
||||
this.updateStaminaLabel();
|
||||
if (!this._isShareMode) {
|
||||
this._startStaminaRecoverTimer();
|
||||
}
|
||||
|
||||
// 异步加载关卡资源并调用进入关卡接口,完成后启动倒计时
|
||||
this._enterAndInitLevel().catch(err => {
|
||||
console.error('[PageLevel] 模式切换后重新进入关卡失败:', err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面隐藏时调用
|
||||
*/
|
||||
onViewHide(): void {
|
||||
console.log('[PageLevel] onViewHide');
|
||||
this._stopStaminaRecoverTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面销毁时调用
|
||||
*/
|
||||
onViewDestroy(): void {
|
||||
console.log('[PageLevel] onViewDestroy');
|
||||
this.clearInputNodes();
|
||||
this.clearPunchBlocks();
|
||||
this.stopCountdown();
|
||||
this._resetPassNode();
|
||||
this._closeWrongModal();
|
||||
this._closeTimeoutModal();
|
||||
this._closeCommonModal();
|
||||
this._stopStaminaRecoverTimer();
|
||||
|
||||
// 清理事件监听
|
||||
this.iconSetting?.off(Node.EventType.TOUCH_END, this.onIconSettingClick, this);
|
||||
this.unLockTipsBtn?.off(Node.EventType.TOUCH_END);
|
||||
this.addTimeBtn?.off(Node.EventType.TOUCH_END);
|
||||
this.submitButton?.off(Node.EventType.TOUCH_END, this.onSubmitAnswer, this);
|
||||
this.pkNextLevelButton?.off(Node.EventType.TOUCH_END, this.onPkNextLevelClick, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入关卡并初始化
|
||||
* 1. 加载关卡图片资源(从缓存或 NextLevelData)
|
||||
* 2. 调用进入关卡接口(消耗体力,获取答案和线索)
|
||||
* 3. 启动倒计时
|
||||
*/
|
||||
private async _enterAndInitLevel(): Promise<void> {
|
||||
let config: RuntimeLevelConfig | null = null;
|
||||
|
||||
if (this._isShareMode) {
|
||||
// 分享模式:使用 ShareManager 的关卡数据
|
||||
config = await ShareManager.instance.ensureShareLevelReady(this._shareLevelIndex);
|
||||
} else {
|
||||
// 正常模式:先尝试从缓存获取(PageLoading 初始化时已加载首关)
|
||||
config = LevelDataManager.instance.getLevelConfig(this._currentLevelId);
|
||||
|
||||
if (!config) {
|
||||
// 缓存未命中,从 nextLevel 数据加载(complete 返回的下一关)
|
||||
const nextLevelData = this._nextLevelData ?? AuthManager.instance.nextLevel;
|
||||
if (nextLevelData && nextLevelData.id === this._currentLevelId) {
|
||||
console.log(`[PageLevel] 关卡 ${this._currentLevelId} 资源未缓存,开始加载...`);
|
||||
config = await LevelDataManager.instance.ensureLevelReady(nextLevelData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
console.warn(`[PageLevel] 没有找到关卡配置,ID: ${this._currentLevelId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 非分享模式下,调用进入关卡接口获取答案和线索
|
||||
if (!this._isShareMode) {
|
||||
const enterData = await StaminaManager.instance.enterLevel(this._currentLevelId);
|
||||
if (!enterData) {
|
||||
// 进入关卡失败(可能是体力不足)
|
||||
const stamina = StaminaManager.instance.getStamina();
|
||||
if (stamina.current <= 0) {
|
||||
ToastManager.show('体力不足,请等待恢复');
|
||||
this._startStaminaRecoverTimer();
|
||||
} else {
|
||||
ToastManager.show('进入关卡失败,请重试');
|
||||
}
|
||||
this.updateStaminaLabel();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 用 enter 接口返回的数据更新关卡配置(填充答案和线索)
|
||||
LevelDataManager.instance.updateLevelDetails(
|
||||
this._currentLevelId,
|
||||
{
|
||||
answer: enterData.answer,
|
||||
image1Description: enterData.image1Description,
|
||||
image2Description: enterData.image2Description,
|
||||
punchline: enterData.punchline,
|
||||
hint1: enterData.hint1,
|
||||
hint2: enterData.hint2,
|
||||
hint3: enterData.hint3,
|
||||
}
|
||||
);
|
||||
|
||||
// 重新获取更新后的配置
|
||||
config = LevelDataManager.instance.getLevelConfig(this._currentLevelId);
|
||||
if (!config) {
|
||||
console.error('[PageLevel] 更新关卡详情后获取配置失败');
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新体力显示
|
||||
this.updateStaminaLabel();
|
||||
|
||||
// 预加载下一关图片(enter 返回的 preloadNextLevel)
|
||||
if (enterData.preloadNextLevel) {
|
||||
LevelDataManager.instance.preloadLevel(enterData.preloadNextLevel);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[PageLevel] 初始化关卡 第${this._currentLevelNumber}关: ${config.name}`);
|
||||
```
|
||||
770
软著源代码/06-关卡周边模块.md
Normal file
770
软著源代码/06-关卡周边模块.md
Normal file
@@ -0,0 +1,770 @@
|
||||
# 梗中作乐 V1.0 - 源代码文档(第 6 部分)
|
||||
|
||||
软件名称:梗中作乐
|
||||
版本号:V1.0
|
||||
|
||||
---
|
||||
|
||||
## 第 6 部分:关卡周边模块
|
||||
|
||||
本部分包含关卡数据缓存、答错弹窗、超时弹窗、通关关卡列表四个与关卡流程紧密关联的模块。
|
||||
|
||||
### 6.1 关卡数据管理器 (assets/scripts/utils/LevelDataManager.ts)
|
||||
|
||||
```typescript
|
||||
import { SpriteFrame, Texture2D, ImageAsset, assetManager } from 'cc';
|
||||
import { RuntimeLevelConfig } from '../types/LevelTypes';
|
||||
import { NextLevelData } from '../types/ApiTypes';
|
||||
|
||||
/**
|
||||
* 进度回调类型
|
||||
* @param progress 进度值 0-1
|
||||
* @param message 进度消息
|
||||
*/
|
||||
export type ProgressCallback = (progress: number, message: string) => void;
|
||||
|
||||
/**
|
||||
* 关卡数据管理器
|
||||
* 单例模式,管理当前关卡和预加载关卡的图片资源
|
||||
* 不再依赖全量关卡列表,由外部传入 NextLevelData 驱动
|
||||
*/
|
||||
export class LevelDataManager {
|
||||
private static _instance: LevelDataManager | null = null;
|
||||
|
||||
/** 运行时关卡配置缓存(按 levelId 索引) */
|
||||
private _levelConfigs: Map<string, RuntimeLevelConfig> = new Map();
|
||||
|
||||
/** 图片缓存:URL -> SpriteFrame */
|
||||
private _imageCache: Map<string, SpriteFrame> = new Map();
|
||||
|
||||
/** 正在加载中的关卡 ID 集合 */
|
||||
private _loadingLevels: Set<string> = new Set();
|
||||
|
||||
/** 是否已初始化 */
|
||||
private _initialized: boolean = false;
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
static get instance(): LevelDataManager {
|
||||
if (!this._instance) {
|
||||
this._instance = new LevelDataManager();
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* 初始化:加载首关图片
|
||||
* 由 PageLoading 在获取 game-data 后调用,传入 nextLevel 数据
|
||||
* @param nextLevel 首关数据(来自 game-data 接口)
|
||||
* @param onProgress 进度回调
|
||||
* @returns 是否初始化成功
|
||||
*/
|
||||
async initialize(nextLevel: NextLevelData, onProgress?: ProgressCallback): Promise<boolean> {
|
||||
console.log(`[LevelDataManager] 开始初始化,加载关卡 ${nextLevel.level}`);
|
||||
|
||||
try {
|
||||
onProgress?.(0.3, '正在加载游戏必备资源...');
|
||||
|
||||
const config = await this._loadLevelFromData(nextLevel);
|
||||
if (!config) {
|
||||
console.error('[LevelDataManager] 初始化失败:图片加载失败');
|
||||
onProgress?.(0.3, '资源加载失败,请重新打开游戏');
|
||||
return false;
|
||||
}
|
||||
|
||||
this._initialized = true;
|
||||
console.log('[LevelDataManager] 初始化完成');
|
||||
onProgress?.(0.8, '游戏资源加载完成');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[LevelDataManager] 初始化失败:', error);
|
||||
onProgress?.(0.3, '网络异常,请重新打开游戏');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已初始化
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this._initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定关卡配置(按 ID)
|
||||
* @param levelId 关卡 ID
|
||||
*/
|
||||
getLevelConfig(levelId: string): RuntimeLevelConfig | null {
|
||||
return this._levelConfigs.get(levelId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 enter 接口返回的数据更新运行时关卡配置(填充答案和线索)
|
||||
* @param levelId 关卡 ID
|
||||
* @param details enter 接口返回的详情
|
||||
*/
|
||||
updateLevelDetails(levelId: string, details: {
|
||||
answer: string;
|
||||
image1Description: string | null;
|
||||
image2Description: string | null;
|
||||
punchline: string | null;
|
||||
hint1: string | null;
|
||||
hint2: string | null;
|
||||
hint3: string | null;
|
||||
}): void {
|
||||
const config = this._levelConfigs.get(levelId);
|
||||
if (!config) {
|
||||
console.warn(`[LevelDataManager] 关卡 ${levelId} 配置不存在,无法更新详情`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._levelConfigs.set(levelId, {
|
||||
...config,
|
||||
answer: details.answer,
|
||||
image1Description: details.image1Description ?? config.image1Description,
|
||||
image2Description: details.image2Description ?? config.image2Description,
|
||||
punchline: details.punchline ?? config.punchline,
|
||||
clue1: details.hint1 ?? null,
|
||||
clue2: details.hint2 ?? null,
|
||||
clue3: details.hint3 ?? null,
|
||||
});
|
||||
|
||||
console.log(`[LevelDataManager] 关卡 ${levelId} 详情已更新`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载并缓存一个关卡(同步等待图片加载完成)
|
||||
* 用于 game-data 返回的 nextLevel 或 complete 返回的 nextLevel
|
||||
* @param data NextLevelData
|
||||
* @returns 加载好的 RuntimeLevelConfig,失败返回 null
|
||||
*/
|
||||
async ensureLevelReady(data: NextLevelData): Promise<RuntimeLevelConfig | null> {
|
||||
// 检查缓存
|
||||
const cached = this._levelConfigs.get(data.id);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// 检查是否正在加载
|
||||
if (this._loadingLevels.has(data.id)) {
|
||||
console.log(`[LevelDataManager] 关卡 ${data.id} 正在加载中...`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._loadLevelFromData(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载关卡图片(静默加载,不阻塞)
|
||||
* 用于 enter 返回的 preloadNextLevel
|
||||
* @param data NextLevelData
|
||||
*/
|
||||
preloadLevel(data: NextLevelData): void {
|
||||
// 已缓存
|
||||
if (this._levelConfigs.has(data.id)) {
|
||||
console.log(`[LevelDataManager] 关卡 ${data.id} 已加载`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 正在加载
|
||||
if (this._loadingLevels.has(data.id)) {
|
||||
console.log(`[LevelDataManager] 关卡 ${data.id} 正在加载中`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 异步加载,不等待
|
||||
console.log(`[LevelDataManager] 开始预加载关卡 ${data.id}...`);
|
||||
this._loadLevelFromData(data).catch(err => {
|
||||
console.error(`[LevelDataManager] 预加载关卡失败:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定关卡图片是否已加载
|
||||
* @param levelId 关卡 ID
|
||||
*/
|
||||
isLevelImageLoaded(levelId: string): boolean {
|
||||
return this._levelConfigs.has(levelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存
|
||||
*/
|
||||
clearCache(): void {
|
||||
this._levelConfigs.clear();
|
||||
this._loadingLevels.clear();
|
||||
this._imageCache.clear();
|
||||
this._initialized = false;
|
||||
console.log('[LevelDataManager] 缓存已清除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 NextLevelData 加载图片并创建 RuntimeLevelConfig
|
||||
*/
|
||||
private async _loadLevelFromData(data: NextLevelData): Promise<RuntimeLevelConfig | null> {
|
||||
this._loadingLevels.add(data.id);
|
||||
console.log(`[LevelDataManager] 开始加载关卡 ${data.id} 资源...`);
|
||||
|
||||
try {
|
||||
const [spriteFrame1, spriteFrame2] = await Promise.all([
|
||||
this._loadImage(data.image1Url),
|
||||
this._loadImage(data.image2Url),
|
||||
]);
|
||||
|
||||
if (!spriteFrame1) {
|
||||
console.error(`[LevelDataManager] 加载关卡 ${data.id} 图片1失败`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = this._createRuntimeConfig(data, spriteFrame1, spriteFrame2);
|
||||
this._levelConfigs.set(data.id, config);
|
||||
console.log(`[LevelDataManager] 关卡 ${data.id} 资源加载完成`);
|
||||
|
||||
return config;
|
||||
} finally {
|
||||
this._loadingLevels.delete(data.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建运行时关卡配置
|
||||
*/
|
||||
private _createRuntimeConfig(data: NextLevelData, spriteFrame1: SpriteFrame | null, spriteFrame2: SpriteFrame | null): RuntimeLevelConfig {
|
||||
return {
|
||||
id: data.id,
|
||||
name: `第${data.level}关`,
|
||||
spriteFrame1,
|
||||
spriteFrame2,
|
||||
image1Description: data.image1Description,
|
||||
image2Description: data.image2Description,
|
||||
punchline: data.punchline,
|
||||
clue1: data.hint1,
|
||||
clue2: data.hint2,
|
||||
clue3: data.hint3,
|
||||
answer: data.answer,
|
||||
completed: false,
|
||||
timeLimit: data.timeLimit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载远程图片为 SpriteFrame
|
||||
* @param url 图片 URL
|
||||
*/
|
||||
private async _loadImage(url: string): Promise<SpriteFrame | null> {
|
||||
// 检查缓存
|
||||
const cached = this._imageCache.get(url);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
assetManager.loadRemote<ImageAsset>(url, (err, imageAsset) => {
|
||||
if (err) {
|
||||
console.error(`[LevelDataManager] 加载图片失败: ${url}`, err);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const texture = new Texture2D();
|
||||
texture.image = imageAsset;
|
||||
|
||||
const spriteFrame = new SpriteFrame();
|
||||
spriteFrame.texture = texture;
|
||||
|
||||
// 缓存
|
||||
this._imageCache.set(url, spriteFrame);
|
||||
|
||||
resolve(spriteFrame);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 答错弹窗 (assets/prefabs/WrongModal.ts)
|
||||
|
||||
```typescript
|
||||
import { _decorator, Node, view, UITransform, Size } from 'cc';
|
||||
import { BaseModal } from 'db://assets/scripts/core/BaseModal';
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/**
|
||||
* WrongModal 回调接口
|
||||
*/
|
||||
export interface WrongModalCallbacks {
|
||||
/** 点击继续挑战 / 关闭按钮回调 */
|
||||
onContinue?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 答案错误弹窗组件
|
||||
* 继承 BaseModal,显示答案错误提示,提供"继续挑战"和关闭按钮
|
||||
*/
|
||||
@ccclass('WrongModal')
|
||||
export class WrongModal extends BaseModal {
|
||||
/** 静态常量:弹窗层级 */
|
||||
public static readonly MODAL_Z_INDEX = 999;
|
||||
|
||||
/** 关闭按钮 */
|
||||
@property(Node)
|
||||
closeBtn: Node | null = null;
|
||||
|
||||
/** 继续挑战按钮 */
|
||||
@property(Node)
|
||||
buttonHint: Node | null = null;
|
||||
|
||||
/** 回调函数 */
|
||||
private _callbacks: WrongModalCallbacks = {};
|
||||
|
||||
/** 缓存的屏幕尺寸 */
|
||||
private _screenSize: Size | null = null;
|
||||
|
||||
/**
|
||||
* 设置回调函数
|
||||
*/
|
||||
setCallbacks(callbacks: WrongModalCallbacks): void {
|
||||
this._callbacks = callbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面首次加载时调用
|
||||
*/
|
||||
onViewLoad(): void {
|
||||
console.log('[WrongModal] onViewLoad');
|
||||
this._bindButtonEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面每次显示时调用
|
||||
*/
|
||||
onViewShow(): void {
|
||||
super.onViewShow();
|
||||
this._updateWidget();
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面销毁时调用
|
||||
*/
|
||||
onViewDestroy(): void {
|
||||
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.closeBtn) {
|
||||
this.closeBtn.on(Node.EventType.TOUCH_END, this._onContinueClick, this);
|
||||
}
|
||||
if (this.buttonHint) {
|
||||
this.buttonHint.on(Node.EventType.TOUCH_END, this._onContinueClick, this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除按钮事件绑定
|
||||
*/
|
||||
private _unbindButtonEvents(): void {
|
||||
if (this.closeBtn && this.closeBtn.isValid) {
|
||||
this.closeBtn.off(Node.EventType.TOUCH_END, this._onContinueClick, this);
|
||||
}
|
||||
if (this.buttonHint && this.buttonHint.isValid) {
|
||||
this.buttonHint.off(Node.EventType.TOUCH_END, this._onContinueClick, this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 继续挑战 / 关闭按钮点击
|
||||
*/
|
||||
private _onContinueClick(): void {
|
||||
console.log('[WrongModal] 点击继续挑战');
|
||||
this._callbacks.onContinue?.();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 超时弹窗 (assets/prefabs/TimeoutModal.ts)
|
||||
|
||||
```typescript
|
||||
import { _decorator, Node, view, UITransform, Size } from 'cc';
|
||||
import { BaseModal } from 'db://assets/scripts/core/BaseModal';
|
||||
import { WxSDK } from 'db://assets/scripts/utils/WxSDK';
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/**
|
||||
* TimeoutModal 回调接口
|
||||
*/
|
||||
export interface TimeoutModalCallbacks {
|
||||
/** 点击求助好友回调 */
|
||||
onShare?: () => void;
|
||||
/** 点击再次挑战回调 */
|
||||
onRestart?: () => void;
|
||||
/** 点击下一题回调 */
|
||||
onNext?: () => void;
|
||||
/** 点击返回主页 / 关闭按钮回调 */
|
||||
onHome?: () => void;
|
||||
}
|
||||
|
||||
interface TimeoutModalParams {
|
||||
levelIndex?: number;
|
||||
shareMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间耗尽弹窗组件
|
||||
* 继承 BaseModal,显示倒计时结束提示,提供"求助好友"、"再次挑战"和"返回主页"三个按钮
|
||||
*/
|
||||
@ccclass('TimeoutModal')
|
||||
export class TimeoutModal extends BaseModal {
|
||||
/** 静态常量:弹窗层级 */
|
||||
public static readonly MODAL_Z_INDEX = 999;
|
||||
|
||||
/** 关闭按钮 */
|
||||
@property(Node)
|
||||
closeBtn: Node | null = null;
|
||||
|
||||
/** 求助好友按钮 */
|
||||
@property(Node)
|
||||
buttonShare: Node | null = null;
|
||||
|
||||
/** 再次挑战按钮 */
|
||||
@property(Node)
|
||||
buttonRestart: Node | null = null;
|
||||
|
||||
/** 下一题按钮(分享挑战超时时显示) */
|
||||
@property(Node)
|
||||
buttonNext: Node | null = null;
|
||||
|
||||
/** 返回主页按钮 */
|
||||
@property(Node)
|
||||
buttonHome: Node | null = null;
|
||||
|
||||
/** 回调函数 */
|
||||
private _callbacks: TimeoutModalCallbacks = {};
|
||||
|
||||
/** 缓存的屏幕尺寸 */
|
||||
private _screenSize: Size | null = null;
|
||||
|
||||
/**
|
||||
* 设置回调函数
|
||||
*/
|
||||
setCallbacks(callbacks: TimeoutModalCallbacks): void {
|
||||
this._callbacks = callbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面首次加载时调用
|
||||
*/
|
||||
onViewLoad(): void {
|
||||
console.log('[TimeoutModal] onViewLoad');
|
||||
this._resolveNodes();
|
||||
this._bindButtonEvents();
|
||||
this._refreshModeButtons();
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面每次显示时调用
|
||||
*/
|
||||
onViewShow(): void {
|
||||
super.onViewShow();
|
||||
this._updateWidget();
|
||||
this._refreshModeButtons();
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面销毁时调用
|
||||
*/
|
||||
onViewDestroy(): void {
|
||||
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.closeBtn) {
|
||||
this.closeBtn.on(Node.EventType.TOUCH_END, this._onHomeClick, this);
|
||||
}
|
||||
if (this.buttonShare) {
|
||||
this.buttonShare.on(Node.EventType.TOUCH_END, this._onShareClick, this);
|
||||
}
|
||||
if (this.buttonRestart) {
|
||||
this.buttonRestart.on(Node.EventType.TOUCH_END, this._onRestartClick, this);
|
||||
}
|
||||
if (this.buttonNext) {
|
||||
this.buttonNext.on(Node.EventType.TOUCH_END, this._onNextClick, this);
|
||||
}
|
||||
if (this.buttonHome) {
|
||||
this.buttonHome.on(Node.EventType.TOUCH_END, this._onHomeClick, this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除按钮事件绑定
|
||||
*/
|
||||
private _unbindButtonEvents(): void {
|
||||
if (this.closeBtn && this.closeBtn.isValid) {
|
||||
this.closeBtn.off(Node.EventType.TOUCH_END, this._onHomeClick, this);
|
||||
}
|
||||
if (this.buttonShare && this.buttonShare.isValid) {
|
||||
this.buttonShare.off(Node.EventType.TOUCH_END, this._onShareClick, this);
|
||||
}
|
||||
if (this.buttonRestart && this.buttonRestart.isValid) {
|
||||
this.buttonRestart.off(Node.EventType.TOUCH_END, this._onRestartClick, this);
|
||||
}
|
||||
if (this.buttonNext && this.buttonNext.isValid) {
|
||||
this.buttonNext.off(Node.EventType.TOUCH_END, this._onNextClick, this);
|
||||
}
|
||||
if (this.buttonHome && this.buttonHome.isValid) {
|
||||
this.buttonHome.off(Node.EventType.TOUCH_END, this._onHomeClick, this);
|
||||
}
|
||||
}
|
||||
|
||||
private _resolveNodes(): void {
|
||||
this.closeBtn = this.closeBtn ?? this._findChild(this.node, 'closeBtn');
|
||||
this.buttonShare = this.buttonShare ?? this._findChild(this.node, 'ButtonShare');
|
||||
this.buttonRestart = this.buttonRestart ?? this._findChild(this.node, 'ButtonRestart');
|
||||
this.buttonNext = this.buttonNext ?? this._findChild(this.node, 'ButtonNext');
|
||||
this.buttonHome = this.buttonHome ?? this._findChild(this.node, 'ButtonHome');
|
||||
}
|
||||
|
||||
private _refreshModeButtons(): void {
|
||||
const isShareMode = this._params?.shareMode === true;
|
||||
if (this.buttonRestart) {
|
||||
this.buttonRestart.active = !isShareMode;
|
||||
}
|
||||
if (this.buttonNext) {
|
||||
this.buttonNext.active = isShareMode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 求助好友按钮点击
|
||||
*/
|
||||
private _onShareClick(): void {
|
||||
console.log('[TimeoutModal] 点击求助好友');
|
||||
|
||||
WxSDK.shareAppMessage({
|
||||
title: '这道题太难了,快来帮帮我!',
|
||||
query: `level=${this._params?.levelIndex ?? 1}`
|
||||
});
|
||||
|
||||
this._callbacks.onShare?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* 再次挑战按钮点击
|
||||
*/
|
||||
private _onRestartClick(): void {
|
||||
console.log('[TimeoutModal] 点击再次挑战');
|
||||
this._callbacks.onRestart?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* 下一题按钮点击
|
||||
*/
|
||||
private _onNextClick(): void {
|
||||
console.log('[TimeoutModal] 点击下一题');
|
||||
this._callbacks.onNext?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回主页 / 关闭按钮点击
|
||||
*/
|
||||
private _onHomeClick(): void {
|
||||
console.log('[TimeoutModal] 点击返回主页');
|
||||
this._callbacks.onHome?.();
|
||||
}
|
||||
|
||||
private _findChild(root: Node, nodeName: string): Node | null {
|
||||
if (root.name === nodeName) {
|
||||
return root;
|
||||
}
|
||||
|
||||
for (const child of root.children) {
|
||||
const found = this._findChild(child, nodeName);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 通关关卡列表管理器 (assets/scripts/utils/CompletedLevelsManager.ts)
|
||||
|
||||
```typescript
|
||||
import { SpriteFrame, Texture2D, ImageAsset, assetManager } from 'cc';
|
||||
import { HttpUtil } from './HttpUtil';
|
||||
import { API_ENDPOINTS, API_TIMEOUT } from '../config/ApiConfig';
|
||||
import { ApiEnvelope, CompletedLevel } from '../types/ApiTypes';
|
||||
|
||||
/**
|
||||
* 已通关关卡管理器
|
||||
* 单例模式,负责拉取当前用户所有已通关关卡 + 封面图缓存
|
||||
* 适用于「成就墙」「关卡回看」「出题 / 预览」等场景
|
||||
*/
|
||||
export class CompletedLevelsManager {
|
||||
private static _instance: CompletedLevelsManager | null = null;
|
||||
|
||||
/** 关卡数据按服务端返回顺序缓存 */
|
||||
private _levels: CompletedLevel[] = [];
|
||||
|
||||
/** 是否已经成功拉取过一次 */
|
||||
private _loaded: boolean = false;
|
||||
|
||||
/** 图片缓存:URL -> SpriteFrame */
|
||||
private _imageCache: Map<string, SpriteFrame> = new Map();
|
||||
|
||||
/** 正在进行中的请求,用于去重并发调用 */
|
||||
private _inflight: Promise<CompletedLevel[] | null> | null = null;
|
||||
|
||||
static get instance(): CompletedLevelsManager {
|
||||
if (!this._instance) {
|
||||
this._instance = new CompletedLevelsManager();
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* 获取已缓存的关卡列表(需先 fetch 或 ensureLoaded)
|
||||
*/
|
||||
get levels(): CompletedLevel[] {
|
||||
return this._levels;
|
||||
}
|
||||
|
||||
get count(): number {
|
||||
return this._levels.length;
|
||||
}
|
||||
|
||||
isLoaded(): boolean {
|
||||
return this._loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按索引(0-based)获取关卡,越界返回 null
|
||||
*/
|
||||
getByIndex(index: number): CompletedLevel | null {
|
||||
if (index < 0 || index >= this._levels.length) return null;
|
||||
return this._levels[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取并缓存已通关关卡列表
|
||||
* - forceRefresh=true 强制重新请求
|
||||
* - 并发调用会共享同一次请求
|
||||
*/
|
||||
async fetch(forceRefresh: boolean = false): Promise<CompletedLevel[] | null> {
|
||||
if (!forceRefresh && this._loaded) {
|
||||
return this._levels;
|
||||
}
|
||||
|
||||
if (this._inflight) {
|
||||
return this._inflight;
|
||||
}
|
||||
|
||||
this._inflight = this._doFetch();
|
||||
try {
|
||||
return await this._inflight;
|
||||
} finally {
|
||||
this._inflight = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async _doFetch(): Promise<CompletedLevel[] | null> {
|
||||
try {
|
||||
const response = await HttpUtil.get<ApiEnvelope<CompletedLevel[]>>(
|
||||
API_ENDPOINTS.COMPLETED_LEVELS,
|
||||
API_TIMEOUT.DEFAULT,
|
||||
);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
console.error('[CompletedLevelsManager] 拉取失败:', response.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
this._levels = response.data;
|
||||
this._loaded = true;
|
||||
console.log(`[CompletedLevelsManager] 拉取成功,共 ${this._levels.length} 关`);
|
||||
return this._levels;
|
||||
} catch (err) {
|
||||
console.error('[CompletedLevelsManager] 拉取异常:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按图片 URL 加载并缓存 SpriteFrame
|
||||
* 已缓存直接返回
|
||||
*/
|
||||
loadImage(url: string): Promise<SpriteFrame | null> {
|
||||
if (!url) return Promise.resolve(null);
|
||||
|
||||
const cached = this._imageCache.get(url);
|
||||
if (cached) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
assetManager.loadRemote<ImageAsset>(url, (err, imageAsset) => {
|
||||
if (err) {
|
||||
console.error('[CompletedLevelsManager] 加载图片失败:', url, err);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const texture = new Texture2D();
|
||||
texture.image = imageAsset;
|
||||
const spriteFrame = new SpriteFrame();
|
||||
spriteFrame.texture = texture;
|
||||
this._imageCache.set(url, spriteFrame);
|
||||
resolve(spriteFrame);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 清除缓存(登出等场景) */
|
||||
clear(): void {
|
||||
this._levels = [];
|
||||
this._loaded = false;
|
||||
this._imageCache.clear();
|
||||
this._inflight = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
743
软著源代码/07-PK出题选关.md
Normal file
743
软著源代码/07-PK出题选关.md
Normal file
@@ -0,0 +1,743 @@
|
||||
# 梗中作乐 V1.0 - 源代码文档(第 7 部分)
|
||||
|
||||
软件名称:梗中作乐
|
||||
版本号:V1.0
|
||||
|
||||
---
|
||||
|
||||
## 第 7 部分:PK 出题选关页面
|
||||
|
||||
本部分展示好友 PK 挑战发起方的「出题选关」页面:用户从已通关关卡里选 6 关,命名后调用后端创建分享码。
|
||||
|
||||
### 7.1 出题选关页 (assets/prefabs/PageWriteLevels.ts)
|
||||
|
||||
```typescript
|
||||
import { _decorator, Node, Button, Sprite, Label, Toggle, ScrollView, EditBox, instantiate, UITransform, Vec2, EventTouch, EffectAsset, Prefab } from 'cc';
|
||||
import { BaseView } from 'db://assets/scripts/core/BaseView';
|
||||
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
|
||||
import { CommonModal } from 'db://assets/prefabs/CommonModal';
|
||||
import { CompletedLevelsManager } from 'db://assets/scripts/utils/CompletedLevelsManager';
|
||||
import { ToastManager } from 'db://assets/scripts/utils/ToastManager';
|
||||
import { ShareManager } from 'db://assets/scripts/utils/ShareManager';
|
||||
import { StorageManager } from 'db://assets/scripts/utils/StorageManager';
|
||||
import { WxSDK, getUserProfile } from 'db://assets/scripts/utils/WxSDK';
|
||||
import { AuthManager } from 'db://assets/scripts/utils/AuthManager';
|
||||
import { API_ENDPOINTS, API_TIMEOUT } from 'db://assets/scripts/config/ApiConfig';
|
||||
import { HttpUtil } from 'db://assets/scripts/utils/HttpUtil';
|
||||
import { ApiEnvelope, CompletedLevel } from 'db://assets/scripts/types/ApiTypes';
|
||||
import { applyRoundedCorner } from 'db://assets/scripts/utils/roundedMaterial.utils';
|
||||
import { AudioManager } from 'db://assets/scripts/utils/AudioManager';
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/**
|
||||
* 布局配置
|
||||
* view (ScrollView 的可视窗口) 宽 900,高 1100
|
||||
* 关卡 item 固定两列,纵向滚动
|
||||
*
|
||||
* item 的实际显示尺寸从 ListTpl 的 UITransform * scale 派生,
|
||||
* 避免代码里再维护一套和 prefab 脱节的宽高。
|
||||
*/
|
||||
const LAYOUT_CONFIG = {
|
||||
COLS: 2,
|
||||
SPACING_X: 36,
|
||||
SPACING_Y: 48,
|
||||
EDGE_PADDING_Y: 32,
|
||||
CENTER_ROWS: 2,
|
||||
VIEW_WIDTH: 900,
|
||||
VIEW_HEIGHT: 1300,
|
||||
LIST_BOTTOM_GAP_TO_TITLE: 54,
|
||||
};
|
||||
|
||||
/** 必须选择的关卡数量 */
|
||||
const MAX_SELECTION = 6;
|
||||
|
||||
@ccclass('PageWriteLevels')
|
||||
export class PageWriteLevels extends BaseView {
|
||||
@property({ type: Node, tooltip: '返回按钮' })
|
||||
backBtn: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: 'ScrollView可视区域节点' })
|
||||
scrollView: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '列表content节点' })
|
||||
listContent: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '列表项模板' })
|
||||
listTemplate: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '已选关卡提示Label节点' })
|
||||
selectedLabel: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '完成按钮节点' })
|
||||
completeBtn: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '预览按钮节点' })
|
||||
previewBtn: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '分享标题输入框节点' })
|
||||
shareTitleEditBox: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '挑战数据按钮节点' })
|
||||
dataBtn: Node | null = null;
|
||||
|
||||
@property({ type: EffectAsset, tooltip: '关卡封面圆角材质 EffectAsset' })
|
||||
roundedSpriteEffect: EffectAsset | null = null;
|
||||
|
||||
@property({ type: Prefab, tooltip: '通用弹窗预制体' })
|
||||
commonModalPrefab: Prefab | null = null;
|
||||
|
||||
@property({ tooltip: '关卡封面圆角半径比例(相对于短边,0-0.5)' })
|
||||
coverCornerRadius: number = 0.1;
|
||||
|
||||
private _selectedIndices: Set<number> = new Set();
|
||||
private _levels: CompletedLevel[] = [];
|
||||
private _levelCount: number = 0;
|
||||
private _itemNodes: Node[] = [];
|
||||
private _scrollViewComp: ScrollView | null = null;
|
||||
private _levelListLoadToken: number = 0;
|
||||
|
||||
/** 缓存 view 节点的 UITransform,避免每次 _updateContentSize 重复查找 */
|
||||
private _viewTransform: UITransform | null = null;
|
||||
|
||||
/** 防止重复提交 */
|
||||
private _isSubmitting: boolean = false;
|
||||
|
||||
onViewLoad(): void {
|
||||
console.log('[PageWriteLevels] onViewLoad');
|
||||
this._initButtons();
|
||||
this._initScrollView();
|
||||
this._resizeScrollViewport();
|
||||
this._updateSelectionUI();
|
||||
}
|
||||
|
||||
private _initButtons(): void {
|
||||
if (this.backBtn) {
|
||||
this.backBtn.on(Button.EventType.CLICK, this._onBackClick, this);
|
||||
}
|
||||
if (this.previewBtn) {
|
||||
this.previewBtn.on(Button.EventType.CLICK, this._onPreviewClick, this);
|
||||
}
|
||||
if (this.completeBtn) {
|
||||
this.completeBtn.on(Button.EventType.CLICK, this._onCompleteClick, this);
|
||||
}
|
||||
if (this.dataBtn) {
|
||||
this.dataBtn.on(Button.EventType.CLICK, this._onDataClick, this);
|
||||
}
|
||||
}
|
||||
|
||||
private _initScrollView(): void {
|
||||
if (this.scrollView) {
|
||||
this._scrollViewComp = this.scrollView.getComponent(ScrollView);
|
||||
|
||||
if (this._scrollViewComp) {
|
||||
this._scrollViewComp.horizontal = false;
|
||||
this._scrollViewComp.vertical = true;
|
||||
this._scrollViewComp.inertia = true;
|
||||
}
|
||||
|
||||
// 缓存 view 的 UITransform
|
||||
const viewNode = this.scrollView.getChildByName('view');
|
||||
if (viewNode) {
|
||||
this._viewTransform = viewNode.getComponent(UITransform);
|
||||
}
|
||||
}
|
||||
|
||||
// content anchor 设为 (0, 1) 左上角,方便位置计算且符合 ScrollView 标准用法
|
||||
if (this.listContent) {
|
||||
const contentTransform = this.listContent.getComponent(UITransform);
|
||||
if (contentTransform) {
|
||||
contentTransform.setAnchorPoint(0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onViewShow(): void {
|
||||
console.log('[PageWriteLevels] onViewShow');
|
||||
this._resizeScrollViewport();
|
||||
this._updateContentSize();
|
||||
void this._initLevelList();
|
||||
}
|
||||
|
||||
private _resizeScrollViewport(): void {
|
||||
if (!this.scrollView || !this._viewTransform) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollTransform = this.scrollView.getComponent(UITransform);
|
||||
const scrollWidget = this.scrollView.getComponent('cc.Widget') as any;
|
||||
const viewWidget = this._viewTransform.node.getComponent('cc.Widget') as any;
|
||||
|
||||
if (!scrollTransform) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollWidget?.updateAlignment?.();
|
||||
viewWidget?.updateAlignment?.();
|
||||
|
||||
this._viewTransform.setContentSize(
|
||||
scrollTransform.contentSize.width,
|
||||
scrollTransform.contentSize.height,
|
||||
);
|
||||
}
|
||||
|
||||
private async _initLevelList(): Promise<void> {
|
||||
const loadToken = ++this._levelListLoadToken;
|
||||
const selectedLevelIds = this._getSelectedLevelIdSet();
|
||||
|
||||
// 拉取当前用户所有已通关关卡
|
||||
const levels = await CompletedLevelsManager.instance.fetch(true);
|
||||
if (loadToken !== this._levelListLoadToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (levels === null) {
|
||||
console.warn('[PageWriteLevels] 获取已通关关卡失败');
|
||||
ToastManager.instance.show('获取关卡列表失败,请稍后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
this._clearList();
|
||||
this._levels = levels;
|
||||
this._levelCount = this._levels.length;
|
||||
this._restoreSelectedIndices(selectedLevelIds);
|
||||
console.log('[PageWriteLevels] 已通关关卡总数:', this._levelCount);
|
||||
|
||||
if (this._levelCount === 0) {
|
||||
console.warn('[PageWriteLevels] 用户尚未通关任何关卡');
|
||||
ToastManager.instance.show('还没有已通关的关卡,快去玩几关吧');
|
||||
this._updateContentSize();
|
||||
this._updateSelectionUI();
|
||||
return;
|
||||
}
|
||||
|
||||
this._updateContentSize();
|
||||
this._createItems();
|
||||
|
||||
if (this._scrollViewComp) {
|
||||
this._scrollViewComp.scrollToTop(0);
|
||||
}
|
||||
}
|
||||
|
||||
private _getSelectedLevelIdSet(): Set<string> {
|
||||
const selectedLevelIds = new Set<string>();
|
||||
for (const index of this._selectedIndices) {
|
||||
const level = this._levels[index] ?? null;
|
||||
if (level) {
|
||||
selectedLevelIds.add(level.id);
|
||||
}
|
||||
}
|
||||
return selectedLevelIds;
|
||||
}
|
||||
|
||||
private _restoreSelectedIndices(selectedLevelIds: Set<string>): void {
|
||||
if (selectedLevelIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let index = 0; index < this._levels.length; index++) {
|
||||
if (selectedLevelIds.has(this._levels[index].id)) {
|
||||
this._selectedIndices.add(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _clearList(): void {
|
||||
for (const node of this._itemNodes) {
|
||||
if (node && node.isValid) {
|
||||
node.destroy();
|
||||
}
|
||||
}
|
||||
this._levels = [];
|
||||
this._itemNodes = [];
|
||||
this._selectedIndices.clear();
|
||||
}
|
||||
|
||||
private _getViewSize(): { width: number, height: number } {
|
||||
return {
|
||||
width: this._viewTransform?.contentSize.width ?? LAYOUT_CONFIG.VIEW_WIDTH,
|
||||
height: this._viewTransform?.contentSize.height ?? LAYOUT_CONFIG.VIEW_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
private _getItemDisplaySize(): { width: number, height: number } {
|
||||
if (!this.listTemplate) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
const itemTransform = this.listTemplate.getComponent(UITransform);
|
||||
if (!itemTransform) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
width: itemTransform.contentSize.width * Math.abs(this.listTemplate.scale.x),
|
||||
height: itemTransform.contentSize.height * Math.abs(this.listTemplate.scale.y),
|
||||
};
|
||||
}
|
||||
|
||||
private _getRowCount(): number {
|
||||
return Math.ceil(this._levelCount / LAYOUT_CONFIG.COLS);
|
||||
}
|
||||
|
||||
private _getHorizontalPadding(itemWidth: number): number {
|
||||
const viewWidth = this._getViewSize().width;
|
||||
const gridWidth = LAYOUT_CONFIG.COLS * itemWidth + (LAYOUT_CONFIG.COLS - 1) * LAYOUT_CONFIG.SPACING_X;
|
||||
return Math.max(0, (viewWidth - gridWidth) / 2);
|
||||
}
|
||||
|
||||
private _getVerticalPadding(rowCount: number, itemHeight: number): number {
|
||||
const viewHeight = this._getViewSize().height;
|
||||
const totalGridHeight = rowCount * itemHeight + Math.max(0, rowCount - 1) * LAYOUT_CONFIG.SPACING_Y;
|
||||
|
||||
if (rowCount <= LAYOUT_CONFIG.CENTER_ROWS) {
|
||||
return Math.max(LAYOUT_CONFIG.EDGE_PADDING_Y, (viewHeight - totalGridHeight) / 2);
|
||||
}
|
||||
|
||||
return LAYOUT_CONFIG.EDGE_PADDING_Y;
|
||||
}
|
||||
|
||||
private _updateContentSize(): void {
|
||||
if (!this.listContent) return;
|
||||
|
||||
const contentTransform = this.listContent.getComponent(UITransform);
|
||||
if (!contentTransform) return;
|
||||
|
||||
const { width: viewWidth, height: viewHeight } = this._getViewSize();
|
||||
const { width: itemWidth, height: itemHeight } = this._getItemDisplaySize();
|
||||
const rowCount = this._getRowCount();
|
||||
const paddingY = this._getVerticalPadding(rowCount, itemHeight);
|
||||
const gridHeight = rowCount > 0
|
||||
? rowCount * itemHeight + (rowCount - 1) * LAYOUT_CONFIG.SPACING_Y
|
||||
: 0;
|
||||
const contentHeight = Math.max(viewHeight, gridHeight + paddingY * 2);
|
||||
|
||||
contentTransform.setContentSize(viewWidth, contentHeight);
|
||||
|
||||
// content anchor=(0,1),需要贴到 view 左上角,供纵向列表按左上原点排布。
|
||||
if (this._viewTransform) {
|
||||
this.listContent.setPosition(-viewWidth / 2, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private _createItems(): void {
|
||||
if (!this.listTemplate || !this.listContent) return;
|
||||
|
||||
for (let i = 0; i < this._levelCount; i++) {
|
||||
const itemNode = this._createItem(i);
|
||||
if (itemNode) {
|
||||
this.listContent.addChild(itemNode);
|
||||
this._itemNodes.push(itemNode);
|
||||
this._loadAndRefreshCover(itemNode, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _createItem(index: number): Node | null {
|
||||
if (!this.listTemplate) return null;
|
||||
|
||||
const item = instantiate(this.listTemplate);
|
||||
item.active = true;
|
||||
item.name = `item_${index}`;
|
||||
|
||||
const pos = this._calculateItemPosition(index);
|
||||
item.setPosition(pos.x, pos.y, 0);
|
||||
|
||||
// 设置默认名称和选中状态(封面由 _loadAndRefreshCover 异步填充)
|
||||
this._initItemState(item, index);
|
||||
|
||||
// 禁用 Button 组件,防止它拦截触摸事件导致 ScrollView 无法滑动
|
||||
const button = item.getComponent(Button);
|
||||
if (button) {
|
||||
button.enabled = false;
|
||||
}
|
||||
|
||||
this._setupItemClick(item, index);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private _calculateItemPosition(index: number): { x: number, y: number } {
|
||||
const { width: itemWidth, height: itemHeight } = this._getItemDisplaySize();
|
||||
const rowCount = this._getRowCount();
|
||||
const col = index % LAYOUT_CONFIG.COLS;
|
||||
const row = Math.floor(index / LAYOUT_CONFIG.COLS);
|
||||
|
||||
const paddingLeft = this._getHorizontalPadding(itemWidth);
|
||||
const paddingTop = this._getVerticalPadding(rowCount, itemHeight);
|
||||
|
||||
const x = paddingLeft
|
||||
+ col * (itemWidth + LAYOUT_CONFIG.SPACING_X)
|
||||
+ itemWidth / 2;
|
||||
|
||||
const y = -(paddingTop
|
||||
+ row * (itemHeight + LAYOUT_CONFIG.SPACING_Y)
|
||||
+ itemHeight / 2);
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 item 的默认名称和选中状态(不设置封面,由异步加载负责)
|
||||
*/
|
||||
private _initItemState(item: Node, index: number): void {
|
||||
const level = this._levels[index] ?? null;
|
||||
const levelName = item.getChildByName('LevelName');
|
||||
if (levelName) {
|
||||
const label = levelName.getComponent(Label);
|
||||
if (label) {
|
||||
const answerText = level?.answer?.trim();
|
||||
label.string = answerText || (level ? `第${level.level}关` : `第${index + 1}关`);
|
||||
}
|
||||
}
|
||||
|
||||
const isSelected = item.getChildByName('IsSelected');
|
||||
if (isSelected) {
|
||||
const toggle = isSelected.getComponent(Toggle);
|
||||
if (toggle) {
|
||||
toggle.isChecked = this._selectedIndices.has(index);
|
||||
// 禁用 Toggle 交互,仅作为视觉指示器,选中逻辑由 item Button 统一处理
|
||||
toggle.interactable = false;
|
||||
}
|
||||
const checkmark = isSelected.getChildByName('Checkmark');
|
||||
if (checkmark) {
|
||||
checkmark.active = this._selectedIndices.has(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步加载关卡封面图并填充到 item
|
||||
*/
|
||||
private async _loadAndRefreshCover(item: Node, index: number): Promise<void> {
|
||||
const level = this._levels[index] ?? null;
|
||||
if (!level || !item.isValid) return;
|
||||
|
||||
const spriteFrame = await CompletedLevelsManager.instance.loadImage(level.image1Url);
|
||||
if (!spriteFrame || !item.isValid) return;
|
||||
|
||||
const levelCover = item.getChildByName('LevelCover');
|
||||
if (levelCover) {
|
||||
const sprite = levelCover.getComponent(Sprite);
|
||||
if (sprite) {
|
||||
sprite.spriteFrame = spriteFrame;
|
||||
this._applyCoverRoundedCorner(sprite);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _applyCoverRoundedCorner(sprite: Sprite): void {
|
||||
if (!this.roundedSpriteEffect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uiTransform = sprite.node.getComponent(UITransform);
|
||||
if (!uiTransform) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyRoundedCorner(
|
||||
sprite,
|
||||
this.roundedSpriteEffect,
|
||||
uiTransform.width,
|
||||
uiTransform.height,
|
||||
this.coverCornerRadius
|
||||
);
|
||||
}
|
||||
|
||||
private _setupItemClick(item: Node, index: number): void {
|
||||
// 用触摸事件代替 Button,区分点击和滑动:
|
||||
// 短距离松手 = 点击(切换选中),长距离 = 滑动(交给 ScrollView)
|
||||
let touchStartPos: Vec2 | null = null;
|
||||
|
||||
item.on(Node.EventType.TOUCH_START, (event: EventTouch) => {
|
||||
touchStartPos = event.getUILocation();
|
||||
}, this);
|
||||
|
||||
item.on(Node.EventType.TOUCH_END, (event: EventTouch) => {
|
||||
if (!touchStartPos) return;
|
||||
const endPos = event.getUILocation();
|
||||
const dx = endPos.x - touchStartPos.x;
|
||||
const dy = endPos.y - touchStartPos.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
touchStartPos = null;
|
||||
|
||||
// 滑动距离小于阈值才算点击
|
||||
if (distance < 20) {
|
||||
const isCurrentlySelected = this._selectedIndices.has(index);
|
||||
this._onItemToggle(index, !isCurrentlySelected);
|
||||
}
|
||||
}, this);
|
||||
|
||||
item.on(Node.EventType.TOUCH_CANCEL, () => {
|
||||
touchStartPos = null;
|
||||
}, this);
|
||||
}
|
||||
|
||||
private _onItemToggle(index: number, selected: boolean): void {
|
||||
// 如果要选中但已达上限,阻止选中
|
||||
if (selected && this._selectedIndices.size >= MAX_SELECTION) {
|
||||
// 恢复 toggle 的视觉状态为未选中
|
||||
const item = this._itemNodes[index];
|
||||
if (item) {
|
||||
const isSelected = item.getChildByName('IsSelected');
|
||||
if (isSelected) {
|
||||
const toggle = isSelected.getComponent(Toggle);
|
||||
if (toggle) {
|
||||
toggle.isChecked = false;
|
||||
}
|
||||
const checkmark = isSelected.getChildByName('Checkmark');
|
||||
if (checkmark) {
|
||||
checkmark.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[PageWriteLevels] 已达最大选择数量 ${MAX_SELECTION},无法继续选择`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
this._selectedIndices.add(index);
|
||||
} else {
|
||||
this._selectedIndices.delete(index);
|
||||
}
|
||||
|
||||
AudioManager.instance.playButtonClick();
|
||||
|
||||
console.log('[PageWriteLevels] item切换选中:', index, selected, '当前已选:', this._selectedIndices.size);
|
||||
|
||||
const item = this._itemNodes[index];
|
||||
if (item) {
|
||||
const isSelected = item.getChildByName('IsSelected');
|
||||
if (isSelected) {
|
||||
const toggle = isSelected.getComponent(Toggle);
|
||||
if (toggle) {
|
||||
toggle.isChecked = selected;
|
||||
}
|
||||
const checkmark = isSelected.getChildByName('Checkmark');
|
||||
if (checkmark) {
|
||||
checkmark.active = selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._updateSelectionUI();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前选中数量更新 SelectedLabel 文本和按钮可用状态。
|
||||
* - 未选择任何关卡时:显示 "请选择6关"
|
||||
* - 已选但不足6关时:显示 "已选 x 关,还差 y 关"
|
||||
* - 恰好选满6关时:显示 "已选满6关",启用按钮
|
||||
*/
|
||||
private _updateSelectionUI(): void {
|
||||
const count = this._selectedIndices.size;
|
||||
const remaining = MAX_SELECTION - count;
|
||||
const isFull = remaining <= 0;
|
||||
|
||||
// 更新 SelectedLabel 文本
|
||||
if (this.selectedLabel) {
|
||||
const label = this.selectedLabel.getComponent(Label);
|
||||
if (label) {
|
||||
if (count === 0) {
|
||||
label.string = `请选择${MAX_SELECTION}关`;
|
||||
} else if (isFull) {
|
||||
label.string = `已选满${MAX_SELECTION}关`;
|
||||
} else {
|
||||
label.string = `已选${count}关,还差${remaining}关`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预览与分享数量不足时也要允许点击,统一弹出提示弹窗。
|
||||
if (this.completeBtn) {
|
||||
const btn = this.completeBtn.getComponent(Button);
|
||||
if (btn) {
|
||||
btn.interactable = true;
|
||||
}
|
||||
}
|
||||
if (this.previewBtn) {
|
||||
const btn = this.previewBtn.getComponent(Button);
|
||||
if (btn) {
|
||||
btn.interactable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _onBackClick(): void {
|
||||
AudioManager.instance.playButtonClick();
|
||||
console.log('[PageWriteLevels] 返回按钮点击');
|
||||
ViewManager.instance.back();
|
||||
}
|
||||
|
||||
private _onDataClick(): void {
|
||||
AudioManager.instance.playButtonClick();
|
||||
ViewManager.instance.open('PagePKData');
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验是否已选满关卡,未满则弹出统一提示弹窗
|
||||
* @returns true 表示校验通过
|
||||
*/
|
||||
private _validateSelection(): boolean {
|
||||
if (this._selectedIndices.size < MAX_SELECTION) {
|
||||
this._showSelectionRequiredModal();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _showSelectionRequiredModal(): void {
|
||||
if (!this.commonModalPrefab) {
|
||||
console.warn('[PageWriteLevels] commonModalPrefab 未设置,回退为 Toast 提示');
|
||||
ToastManager.instance.show(`请选择${MAX_SELECTION}个关卡后再预览或分享`);
|
||||
return;
|
||||
}
|
||||
|
||||
CommonModal.show(this.commonModalPrefab, {
|
||||
title: '提示',
|
||||
content: `要选择${MAX_SELECTION}个关卡才能分享和预览`,
|
||||
buttonConfirm: '知道了',
|
||||
});
|
||||
}
|
||||
|
||||
private _onPreviewClick(): void {
|
||||
AudioManager.instance.playButtonClick();
|
||||
if (!this._validateSelection()) return;
|
||||
const shareTitle = this.shareTitleEditBox?.getComponent(EditBox)?.string?.trim() || '';
|
||||
ViewManager.instance.open('PagePreviewLevels', {
|
||||
params: {
|
||||
selectedIndices: Array.from(this._selectedIndices),
|
||||
shareTitle: shareTitle
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _onCompleteClick(): Promise<void> {
|
||||
AudioManager.instance.playButtonClick();
|
||||
if (!this._validateSelection()) return;
|
||||
|
||||
const shareTitle = this.shareTitleEditBox?.getComponent(EditBox)?.string?.trim() || '';
|
||||
if (!shareTitle) {
|
||||
ToastManager.instance.show('请输入分享标题');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._isSubmitting) return;
|
||||
this._isSubmitting = true;
|
||||
|
||||
try {
|
||||
const levelIds = this._getSelectedLevelIds();
|
||||
if (levelIds.length !== MAX_SELECTION) {
|
||||
ToastManager.instance.show('获取关卡数据失败,请重试');
|
||||
return;
|
||||
}
|
||||
|
||||
const shareCode = await ShareManager.instance.createShare(shareTitle, levelIds);
|
||||
if (!shareCode) {
|
||||
ToastManager.instance.show('创建分享失败,请重试');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[PageWriteLevels] 创建分享成功, code:', shareCode);
|
||||
|
||||
// 获取用户头像昵称并上传
|
||||
await this._uploadUserInfo();
|
||||
|
||||
ShareManager.instance.triggerWxShare(shareTitle, shareCode);
|
||||
ToastManager.instance.show('分享创建成功!');
|
||||
} catch (err) {
|
||||
console.error('[PageWriteLevels] 完成按钮异常:', err);
|
||||
ToastManager.instance.show('操作失败,请重试');
|
||||
} finally {
|
||||
this._isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将选中的关卡索引转换为关卡 ID 数组
|
||||
*/
|
||||
private _getSelectedLevelIds(): string[] {
|
||||
const ids: string[] = [];
|
||||
const sortedIndices = Array.from(this._selectedIndices).sort((a, b) => a - b);
|
||||
for (const index of sortedIndices) {
|
||||
const level = this._levels[index] ?? null;
|
||||
if (level) {
|
||||
ids.push(level.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户头像昵称并上传到服务端
|
||||
*/
|
||||
private async _uploadUserInfo(): Promise<void> {
|
||||
// 先检查本地缓存
|
||||
const cachedUserInfo = StorageManager.getUserInfo();
|
||||
if (cachedUserInfo) {
|
||||
console.log('[PageWriteLevels] 使用缓存的用户信息');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!WxSDK.isWechat()) {
|
||||
console.log('[PageWriteLevels] 非微信环境,跳过获取用户信息');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前登录用户的 ID
|
||||
const userId = AuthManager.instance.userId;
|
||||
if (!userId) {
|
||||
console.warn('[PageWriteLevels] 用户未登录,跳过获取用户信息');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userInfo = await getUserProfile();
|
||||
|
||||
// 本地缓存
|
||||
StorageManager.setUserInfo(userInfo);
|
||||
|
||||
// 上传到服务端
|
||||
const response = await HttpUtil.post<ApiEnvelope<unknown>>(
|
||||
API_ENDPOINTS.USER_INFO,
|
||||
{
|
||||
userId: userId,
|
||||
avatarUrl: userInfo.avatarUrl,
|
||||
nickName: userInfo.nickName
|
||||
},
|
||||
API_TIMEOUT.DEFAULT
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
console.log('[PageWriteLevels] 用户信息上传成功');
|
||||
} else {
|
||||
console.warn('[PageWriteLevels] 用户信息上传失败:', response.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[PageWriteLevels] 获取用户信息失败:', err);
|
||||
// 不阻断主流程
|
||||
}
|
||||
}
|
||||
|
||||
onViewHide(): void {
|
||||
console.log('[PageWriteLevels] onViewHide');
|
||||
}
|
||||
|
||||
onViewDestroy(): void {
|
||||
console.log('[PageWriteLevels] onViewDestroy');
|
||||
if (this.backBtn) {
|
||||
this.backBtn.off(Button.EventType.CLICK, this._onBackClick, this);
|
||||
}
|
||||
if (this.previewBtn) {
|
||||
this.previewBtn.off(Button.EventType.CLICK, this._onPreviewClick, this);
|
||||
}
|
||||
if (this.completeBtn) {
|
||||
this.completeBtn.off(Button.EventType.CLICK, this._onCompleteClick, this);
|
||||
}
|
||||
if (this.dataBtn) {
|
||||
this.dataBtn.off(Button.EventType.CLICK, this._onDataClick, this);
|
||||
}
|
||||
this._clearList();
|
||||
}
|
||||
}
|
||||
```
|
||||
710
软著源代码/08-通关弹窗与提示.md
Normal file
710
软著源代码/08-通关弹窗与提示.md
Normal file
@@ -0,0 +1,710 @@
|
||||
# 梗中作乐 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);
|
||||
}
|
||||
}
|
||||
```
|
||||
704
软著源代码/09-分享与预览.md
Normal file
704
软著源代码/09-分享与预览.md
Normal file
@@ -0,0 +1,704 @@
|
||||
# 梗中作乐 V1.0 - 源代码文档(第 9 部分)
|
||||
|
||||
软件名称:梗中作乐
|
||||
版本号:V1.0
|
||||
|
||||
---
|
||||
|
||||
## 第 9 部分:好友分享与预览
|
||||
|
||||
本部分包含 PK 关卡预览页、分享逻辑管理器、分享启动参数处理器。
|
||||
|
||||
### 9.1 分享预览页 (assets/prefabs/PagePreviewLevels.ts)
|
||||
|
||||
```typescript
|
||||
import { _decorator, Node, Button, Label, ScrollView, instantiate, UITransform } from 'cc';
|
||||
import { BaseView } from 'db://assets/scripts/core/BaseView';
|
||||
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
|
||||
import { CompletedLevelsManager } from 'db://assets/scripts/utils/CompletedLevelsManager';
|
||||
import { AudioManager } from 'db://assets/scripts/utils/AudioManager';
|
||||
import { PreviewLevelItem } from './PreviewLevelItem';
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/**
|
||||
* 预览试卷页面
|
||||
* 垂直滚动展示用户在 PageWriteLevels 中选中的 6 个关卡
|
||||
* 每个关卡展示:封面图、线索1、线索2、线索3、答案
|
||||
*
|
||||
* 节点结构(仅 ScrollView 侧需要固定):
|
||||
* PagePreviewLevels
|
||||
* ├── ScrollView / view / content ← listContent 容器
|
||||
* └── ListTpl ← listTemplate 模板根节点
|
||||
* (挂 PreviewLevelItem 组件,字段由编辑器拖拽绑定)
|
||||
*
|
||||
* item 内部节点层级/命名对本文件透明:所有引用都来自 PreviewLevelItem 的 @property。
|
||||
*/
|
||||
|
||||
/** 布局配置 — 垂直列表 */
|
||||
const LAYOUT = {
|
||||
/** 关卡项高度(与 ListTpl UITransform 高度一致) */
|
||||
ITEM_HEIGHT: 300,
|
||||
/** 关卡项之间的垂直间距 */
|
||||
SPACING_Y: 30,
|
||||
/** 列表顶部内边距 */
|
||||
PADDING_TOP: 20,
|
||||
};
|
||||
|
||||
@ccclass('PagePreviewLevels')
|
||||
export class PagePreviewLevels extends BaseView {
|
||||
@property({ type: Node, tooltip: '返回按钮(左上角)' })
|
||||
backBtn: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: 'ScrollView 节点' })
|
||||
scrollView: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '列表 content 节点' })
|
||||
listContent: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '关卡模板节点' })
|
||||
listTemplate: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '底部返回按钮' })
|
||||
backButton: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '标题Label节点' })
|
||||
pkTitle: Node | null = null;
|
||||
|
||||
/** 已创建的 item 节点列表 */
|
||||
private _itemNodes: Node[] = [];
|
||||
|
||||
// ─── 生命周期 ───────────────────────────────────────
|
||||
|
||||
onViewLoad(): void {
|
||||
console.log('[PagePreviewLevels] onViewLoad');
|
||||
this._initButtons();
|
||||
this._initScrollView();
|
||||
}
|
||||
|
||||
onViewShow(): void {
|
||||
console.log('[PagePreviewLevels] onViewShow');
|
||||
this._buildList();
|
||||
}
|
||||
|
||||
onViewHide(): void {
|
||||
console.log('[PagePreviewLevels] onViewHide');
|
||||
}
|
||||
|
||||
onViewDestroy(): void {
|
||||
console.log('[PagePreviewLevels] onViewDestroy');
|
||||
this._offButtons();
|
||||
this._clearList();
|
||||
}
|
||||
|
||||
// ─── 初始化 ─────────────────────────────────────────
|
||||
|
||||
private _initButtons(): void {
|
||||
if (this.backBtn) {
|
||||
this.backBtn.on(Button.EventType.CLICK, this._onBackClick, this);
|
||||
}
|
||||
if (this.backButton) {
|
||||
this.backButton.on(Button.EventType.CLICK, this._onBackClick, this);
|
||||
}
|
||||
}
|
||||
|
||||
private _offButtons(): void {
|
||||
if (this.backBtn) {
|
||||
this.backBtn.off(Button.EventType.CLICK, this._onBackClick, this);
|
||||
}
|
||||
if (this.backButton) {
|
||||
this.backButton.off(Button.EventType.CLICK, this._onBackClick, this);
|
||||
}
|
||||
}
|
||||
|
||||
private _initScrollView(): void {
|
||||
if (!this.listContent) return;
|
||||
const contentTransform = this.listContent.getComponent(UITransform);
|
||||
if (contentTransform) {
|
||||
contentTransform.setAnchorPoint(0.5, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 列表构建 ───────────────────────────────────────
|
||||
|
||||
private _buildList(): void {
|
||||
this._clearList();
|
||||
|
||||
const params = this.getParams();
|
||||
if (!params || !params.selectedIndices || params.selectedIndices.length === 0) {
|
||||
console.warn('[PagePreviewLevels] 未传入选中关卡数据');
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示用户输入的标题
|
||||
if (this.pkTitle) {
|
||||
const label = this.pkTitle.getComponent(Label);
|
||||
if (label) {
|
||||
label.string = params.shareTitle || '挑战';
|
||||
}
|
||||
}
|
||||
|
||||
const indices: number[] = params.selectedIndices;
|
||||
console.log('[PagePreviewLevels] 选中关卡索引:', indices);
|
||||
|
||||
// 更新 content 高度
|
||||
this._updateContentSize(indices.length);
|
||||
|
||||
// 创建每个关卡 item
|
||||
for (let i = 0; i < indices.length; i++) {
|
||||
const levelIndex = indices[i];
|
||||
const itemNode = this._createItem(i);
|
||||
if (itemNode) {
|
||||
this.listContent!.addChild(itemNode);
|
||||
this._itemNodes.push(itemNode);
|
||||
this._loadLevelData(itemNode, levelIndex, i);
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到顶部
|
||||
const scrollComp = this.scrollView?.getComponent(ScrollView);
|
||||
if (scrollComp) {
|
||||
scrollComp.scrollToTop(0);
|
||||
}
|
||||
}
|
||||
|
||||
private _clearList(): void {
|
||||
for (const node of this._itemNodes) {
|
||||
if (node && node.isValid) {
|
||||
node.destroy();
|
||||
}
|
||||
}
|
||||
this._itemNodes = [];
|
||||
}
|
||||
|
||||
private _updateContentSize(count: number): void {
|
||||
if (!this.listContent) return;
|
||||
const contentTransform = this.listContent.getComponent(UITransform);
|
||||
if (!contentTransform) return;
|
||||
|
||||
const totalHeight = LAYOUT.PADDING_TOP
|
||||
+ count * LAYOUT.ITEM_HEIGHT
|
||||
+ (count > 0 ? (count - 1) * LAYOUT.SPACING_Y : 0)
|
||||
+ LAYOUT.PADDING_TOP;
|
||||
|
||||
contentTransform.setContentSize(contentTransform.contentSize.width, totalHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单个关卡展示项
|
||||
* content anchor=(0.5, 1),y 轴负向下
|
||||
*/
|
||||
private _createItem(displayIndex: number): Node | null {
|
||||
if (!this.listTemplate) return null;
|
||||
|
||||
const item = instantiate(this.listTemplate);
|
||||
item.active = true;
|
||||
item.name = `preview_item_${displayIndex}`;
|
||||
|
||||
// 垂直居中排列:x=0(水平居中于 content),y 负向下
|
||||
const y = -(LAYOUT.PADDING_TOP + displayIndex * (LAYOUT.ITEM_HEIGHT + LAYOUT.SPACING_Y) + LAYOUT.ITEM_HEIGHT / 2);
|
||||
item.setPosition(0, y, 0);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步加载关卡数据并填充到 item 节点
|
||||
*/
|
||||
private async _loadLevelData(item: Node, levelIndex: number, displayIndex: number): Promise<void> {
|
||||
const level = CompletedLevelsManager.instance.getByIndex(levelIndex);
|
||||
if (!level || !item.isValid) return;
|
||||
|
||||
const view = item.getComponent(PreviewLevelItem);
|
||||
if (!view) {
|
||||
console.warn('[PagePreviewLevels] listTemplate 缺少 PreviewLevelItem 组件');
|
||||
return;
|
||||
}
|
||||
|
||||
view.setTexts({
|
||||
answer: level.answer || '',
|
||||
hint1: level.hint1 || '',
|
||||
hint2: level.hint2 || '',
|
||||
hint3: level.hint3 || '',
|
||||
});
|
||||
|
||||
// 异步加载封面图(通常已由 WriteLevels 预热到缓存)
|
||||
const spriteFrame = await CompletedLevelsManager.instance.loadImage(level.image1Url);
|
||||
if (!spriteFrame || !item.isValid) return;
|
||||
view.setCover(spriteFrame);
|
||||
}
|
||||
|
||||
// ─── 事件处理 ───────────────────────────────────────
|
||||
|
||||
private _onBackClick(): void {
|
||||
AudioManager.instance.playButtonClick();
|
||||
console.log('[PagePreviewLevels] 返回');
|
||||
ViewManager.instance.back();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 分享管理器 (assets/scripts/utils/ShareManager.ts)
|
||||
|
||||
```typescript
|
||||
import { SpriteFrame, Texture2D, ImageAsset, assetManager } from 'cc';
|
||||
import { HttpUtil } from './HttpUtil';
|
||||
import { WxSDK } from './WxSDK';
|
||||
import { API_ENDPOINTS, getShareDetailUrl, getShareJoinUrl, getShareSubmitUrl, API_TIMEOUT } from '../config/ApiConfig';
|
||||
import {
|
||||
ApiEnvelope,
|
||||
CreateShareData,
|
||||
JoinShareData,
|
||||
ShareLevelData,
|
||||
CreatedShareItem,
|
||||
CreatedShareListData,
|
||||
ParticipatedShareItem,
|
||||
ParticipatedShareListData,
|
||||
ShareDetailData,
|
||||
SubmitShareData,
|
||||
SubmitShareLevel,
|
||||
} from '../types/ApiTypes';
|
||||
import { RuntimeLevelConfig } from '../types/LevelTypes';
|
||||
|
||||
/**
|
||||
* 分享管理器
|
||||
* 负责创建分享、加入分享、缓存分享关卡数据
|
||||
*/
|
||||
export class ShareManager {
|
||||
private static _instance: ShareManager | null = null;
|
||||
|
||||
/** 分享模式的关卡数据(null 表示正常模式) */
|
||||
private _shareLevels: RuntimeLevelConfig[] | null = null;
|
||||
|
||||
/** API 返回的原始关卡数据(保留 image1Url/image2Url 用于懒加载) */
|
||||
private _shareApiLevels: ShareLevelData[] = [];
|
||||
|
||||
private _shareTitle: string = '';
|
||||
private _shareCode: string | null = null;
|
||||
private _createdShares: CreatedShareItem[] = [];
|
||||
private _participatedShares: ParticipatedShareItem[] = [];
|
||||
|
||||
/** 图片缓存:URL -> SpriteFrame */
|
||||
private _imageCache: Map<string, SpriteFrame> = new Map();
|
||||
|
||||
static get instance(): ShareManager {
|
||||
if (!this._instance) {
|
||||
this._instance = new ShareManager();
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
private constructor() {}
|
||||
|
||||
get isShareMode(): boolean {
|
||||
return this._shareLevels !== null && this._shareLevels.length > 0;
|
||||
}
|
||||
|
||||
get createdShares(): CreatedShareItem[] {
|
||||
return [...this._createdShares];
|
||||
}
|
||||
|
||||
get participatedShares(): ParticipatedShareItem[] {
|
||||
return [...this._participatedShares];
|
||||
}
|
||||
|
||||
get shareCode(): string | null {
|
||||
return this._shareCode;
|
||||
}
|
||||
|
||||
get shareTitle(): string {
|
||||
return this._shareTitle;
|
||||
}
|
||||
|
||||
async createShare(title: string, levelIds: string[]): Promise<string | null> {
|
||||
try {
|
||||
const response = await HttpUtil.post<ApiEnvelope<CreateShareData>>(
|
||||
API_ENDPOINTS.SHARE_CREATE,
|
||||
{ title, levelIds },
|
||||
API_TIMEOUT.DEFAULT,
|
||||
);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
console.error('[ShareManager] 创建分享失败:', response.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.data.shareCode;
|
||||
} catch (err) {
|
||||
console.error('[ShareManager] 创建分享异常:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async joinShare(code: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await HttpUtil.post<ApiEnvelope<JoinShareData>>(
|
||||
getShareJoinUrl(code),
|
||||
{},
|
||||
API_TIMEOUT.DEFAULT,
|
||||
);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
console.error('[ShareManager] 加入分享失败:', response.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { shareCode, title, levels } = response.data;
|
||||
this._shareCode = shareCode;
|
||||
this._shareTitle = title;
|
||||
this._shareApiLevels = levels;
|
||||
|
||||
const runtimeLevels: RuntimeLevelConfig[] = levels.map((level) => ({
|
||||
id: level.id,
|
||||
name: `第${level.level}关`,
|
||||
spriteFrame1: null,
|
||||
spriteFrame2: null,
|
||||
image1Description: level.image1Description,
|
||||
image2Description: level.image2Description,
|
||||
punchline: level.punchline,
|
||||
clue1: level.hint1,
|
||||
clue2: level.hint2,
|
||||
clue3: level.hint3,
|
||||
answer: level.answer,
|
||||
completed: false,
|
||||
timeLimit: null,
|
||||
}));
|
||||
|
||||
// 预加载首关图片(两张并行加载)
|
||||
if (levels.length > 0) {
|
||||
const [sf1, sf2] = await Promise.all([
|
||||
this._loadImage(levels[0].image1Url),
|
||||
this._loadImage(levels[0].image2Url),
|
||||
]);
|
||||
if (sf1) {
|
||||
runtimeLevels[0].spriteFrame1 = sf1;
|
||||
}
|
||||
if (sf2) {
|
||||
runtimeLevels[0].spriteFrame2 = sf2;
|
||||
}
|
||||
}
|
||||
|
||||
this._shareLevels = runtimeLevels;
|
||||
console.log(`[ShareManager] 加入分享成功: ${title}, ${levels.length} 关`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[ShareManager] 加入分享异常:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchCreatedShares(): Promise<CreatedShareItem[] | null> {
|
||||
try {
|
||||
const response = await HttpUtil.get<ApiEnvelope<CreatedShareListData>>(
|
||||
API_ENDPOINTS.SHARE_CREATED,
|
||||
API_TIMEOUT.DEFAULT,
|
||||
);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
console.error('[ShareManager] 获取我创建的挑战列表失败:', response.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
this._createdShares = response.data.items ?? [];
|
||||
console.log(`[ShareManager] 获取我创建的挑战列表成功: ${this._createdShares.length} 条`);
|
||||
return this.createdShares;
|
||||
} catch (err) {
|
||||
console.error('[ShareManager] 获取我创建的挑战列表异常:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchParticipatedShares(): Promise<ParticipatedShareItem[] | null> {
|
||||
try {
|
||||
const response = await HttpUtil.get<ApiEnvelope<ParticipatedShareListData>>(
|
||||
API_ENDPOINTS.SHARE_PARTICIPATED,
|
||||
API_TIMEOUT.DEFAULT,
|
||||
);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
console.error('[ShareManager] 获取我参与的挑战列表失败:', response.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
this._participatedShares = response.data.items ?? [];
|
||||
console.log(`[ShareManager] 获取我参与的挑战列表成功: ${this._participatedShares.length} 条`);
|
||||
return this.participatedShares;
|
||||
} catch (err) {
|
||||
console.error('[ShareManager] 获取我参与的挑战列表异常:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchShareDetail(code: string): Promise<ShareDetailData | null> {
|
||||
try {
|
||||
const response = await HttpUtil.get<ApiEnvelope<ShareDetailData>>(
|
||||
getShareDetailUrl(code),
|
||||
API_TIMEOUT.DEFAULT,
|
||||
);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
console.error('[ShareManager] 获取分享挑战详情失败:', response.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[ShareManager] 获取分享挑战详情成功: ${response.data.title}, ${response.data.rankings?.length ?? 0} 条排行`);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
console.error('[ShareManager] 获取分享挑战详情异常:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureShareLevelReady(index: number): Promise<RuntimeLevelConfig | null> {
|
||||
if (!this._shareLevels || index < 0 || index >= this._shareLevels.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = this._shareLevels[index];
|
||||
if (config.spriteFrame1) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const apiLevel = this._shareApiLevels[index];
|
||||
if (apiLevel?.image1Url) {
|
||||
const [sf1, sf2] = await Promise.all([
|
||||
this._loadImage(apiLevel.image1Url),
|
||||
this._loadImage(apiLevel.image2Url),
|
||||
]);
|
||||
if (sf1) {
|
||||
config.spriteFrame1 = sf1;
|
||||
}
|
||||
if (sf2) {
|
||||
config.spriteFrame2 = sf2;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
getShareLevelCount(): number {
|
||||
return this._shareLevels?.length ?? 0;
|
||||
}
|
||||
|
||||
getShareLevelIds(): string[] {
|
||||
return this._shareApiLevels.map(level => level.id);
|
||||
}
|
||||
|
||||
async submitShareChallenge(levels: SubmitShareLevel[]): Promise<SubmitShareData | null> {
|
||||
if (!this._shareCode) {
|
||||
console.warn('[ShareManager] submitShareChallenge: 无分享码,跳过提交');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await HttpUtil.post<ApiEnvelope<SubmitShareData>>(
|
||||
getShareSubmitUrl(this._shareCode),
|
||||
{ levels },
|
||||
API_TIMEOUT.DEFAULT,
|
||||
);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
console.error('[ShareManager] 提交挑战结果失败:', response.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ShareManager] 提交挑战结果成功: rank=${response.data.rank}, correct=${response.data.correctCount}/${response.data.levelCount}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
console.error('[ShareManager] 提交挑战结果异常:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
triggerWxShare(title: string, shareCode: string): void {
|
||||
WxSDK.shareAppMessage({
|
||||
title: title || '来挑战我出的谐音梗吧!',
|
||||
query: `shareCode=${shareCode}`,
|
||||
});
|
||||
}
|
||||
|
||||
clearShareMode(): void {
|
||||
this._shareLevels = null;
|
||||
this._shareApiLevels = [];
|
||||
this._shareTitle = '';
|
||||
this._shareCode = null;
|
||||
this._imageCache.clear();
|
||||
}
|
||||
|
||||
private _loadImage(url: string): Promise<SpriteFrame | null> {
|
||||
const cached = this._imageCache.get(url);
|
||||
if (cached) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
assetManager.loadRemote<ImageAsset>(url, (err, imageAsset) => {
|
||||
if (err) {
|
||||
console.error('[ShareManager] 加载图片失败:', url, err);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const texture = new Texture2D();
|
||||
texture.image = imageAsset;
|
||||
const spriteFrame = new SpriteFrame();
|
||||
spriteFrame.texture = texture;
|
||||
this._imageCache.set(url, spriteFrame);
|
||||
resolve(spriteFrame);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 启动分享处理器 (assets/scripts/utils/ShareLaunchHandler.ts)
|
||||
|
||||
```typescript
|
||||
import { WxSDK } from './WxSDK';
|
||||
import { ShareManager } from './ShareManager';
|
||||
import { AuthManager } from './AuthManager';
|
||||
import { ViewManager } from '../core/ViewManager';
|
||||
|
||||
/**
|
||||
* 分享启动监听器
|
||||
*
|
||||
* 微信小游戏未被杀掉、只是退到后台时,再次通过好友分享卡片打开小游戏,
|
||||
* 不会重新走启动链路(PageLoading 不会再跑),因此 `wx.getLaunchOptionsSync()`
|
||||
* 取到的 query 可能是上一次启动的旧值。这个 handler 通过 `wx.onShow`
|
||||
* 拿到最新的 query,检测 shareCode 变化后:
|
||||
* 1. 清掉旧的分享态
|
||||
* 2. 调用 `ShareManager.joinShare(code)` 拉取新的题单
|
||||
* 3. 直接打开 `PageLevel` 进入分享挑战
|
||||
*
|
||||
* 对应在 PageLevel 那侧通过 `onViewShow` 检测到 ShareManager.shareCode
|
||||
* 变化,重新走 `_reinitLevelSession`。
|
||||
*/
|
||||
export class ShareLaunchHandler {
|
||||
private static _instance: ShareLaunchHandler | null = null;
|
||||
|
||||
static get instance(): ShareLaunchHandler {
|
||||
if (!this._instance) {
|
||||
this._instance = new ShareLaunchHandler();
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
/** 已经处理过的 shareCode,相同则不再重复 join */
|
||||
private _activeShareCode: string | null = null;
|
||||
|
||||
/** 是否正在处理一次 onShow 触发的 join 流程,避免并发 */
|
||||
private _isHandlingShow: boolean = false;
|
||||
|
||||
/** 是否已经初始化 */
|
||||
private _initialized: boolean = false;
|
||||
|
||||
private _showHandler = (res: { query?: Record<string, any> } | undefined) => {
|
||||
const code = WxSDK.extractShareCodeFromQuery(res?.query);
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
// 同一个 shareCode 且当前已经处于该分享态,无需重复处理
|
||||
if (code === this._activeShareCode && ShareManager.instance.isShareMode) {
|
||||
return;
|
||||
}
|
||||
// 即使不是新的 code,但如果 ShareManager 已经丢失了分享态(例如挑战完成被 clear),
|
||||
// 用户重新点同一个分享卡片仍然应当重新加入。
|
||||
void this._handleShareCode(code);
|
||||
};
|
||||
|
||||
private _hideHandler = () => {
|
||||
// 目前不在 onHide 时做任何破坏性操作;保留监听只为方便后续扩展
|
||||
// (比如:暂停倒计时、上报埋点)。
|
||||
console.log('[ShareLaunchHandler] 小游戏切到后台');
|
||||
};
|
||||
|
||||
/**
|
||||
* 在 main.onLoad 中调用,注册 wx.onShow / wx.onHide。
|
||||
* 同时把当前启动参数中的 shareCode 作为种子,避免初次冷启动时
|
||||
* 因 wx.onShow 也会被调用一次而重复触发分享流程。
|
||||
*/
|
||||
init(): void {
|
||||
if (this._initialized) {
|
||||
return;
|
||||
}
|
||||
this._initialized = true;
|
||||
|
||||
if (!WxSDK.isWechat()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 冷启动时先把当前 launch 中的 shareCode 标记成已处理,
|
||||
// 避免 wx.onShow 在初始展示时拿到同一个 code 又走一遍 join。
|
||||
this._activeShareCode = WxSDK.getShareCodeFromLaunch();
|
||||
|
||||
WxSDK.onAppShow(this._showHandler);
|
||||
WxSDK.onAppHide(this._hideHandler);
|
||||
|
||||
console.log('[ShareLaunchHandler] 已注册 onShow/onHide 监听');
|
||||
}
|
||||
|
||||
/**
|
||||
* 由 PageLoading 在初始 join 之后调用,把已处理的 shareCode 显式同步过来,
|
||||
* 让 onShow 收到相同 code 时不会重复 join。
|
||||
*/
|
||||
markActiveShareCode(code: string | null): void {
|
||||
this._activeShareCode = code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动取消监听(一般无需调用,留作扩展)
|
||||
*/
|
||||
dispose(): void {
|
||||
if (!this._initialized) return;
|
||||
this._initialized = false;
|
||||
WxSDK.offAppShow(this._showHandler);
|
||||
WxSDK.offAppHide(this._hideHandler);
|
||||
}
|
||||
|
||||
private async _handleShareCode(code: string): Promise<void> {
|
||||
if (this._isHandlingShow) {
|
||||
console.log('[ShareLaunchHandler] 已有 onShow 分享流程在执行,跳过', code);
|
||||
return;
|
||||
}
|
||||
this._isHandlingShow = true;
|
||||
|
||||
try {
|
||||
console.log('[ShareLaunchHandler] 检测到新的 shareCode,准备切换:', code);
|
||||
|
||||
// 确保已登录(initialize 内部对已有 token 做了校验,幂等可重复调用)
|
||||
const loginOk = await AuthManager.instance.initialize();
|
||||
if (!loginOk) {
|
||||
console.warn('[ShareLaunchHandler] 登录失败,放弃 onShow 分享切换');
|
||||
return;
|
||||
}
|
||||
|
||||
// 切到新的分享前清掉旧分享态,避免 ShareManager 内残留旧题单导致 PageLevel 错位
|
||||
if (ShareManager.instance.isShareMode) {
|
||||
ShareManager.instance.clearShareMode();
|
||||
}
|
||||
|
||||
const joinOk = await ShareManager.instance.joinShare(code);
|
||||
if (!joinOk) {
|
||||
console.warn('[ShareLaunchHandler] 加入分享失败:', code);
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记当前激活的分享码
|
||||
this._activeShareCode = code;
|
||||
|
||||
// 跳过中间页,直接打开 PageLevel 进入分享挑战。
|
||||
// PageLevel 会在 onViewShow 中根据 ShareManager.shareCode 与本地缓存比对,
|
||||
// 决定是否需要 `_reinitLevelSession`。
|
||||
ViewManager.instance.open('PageLevel', {
|
||||
params: { shareMode: true },
|
||||
});
|
||||
|
||||
console.log('[ShareLaunchHandler] 已切换到分享挑战:', code);
|
||||
} catch (err) {
|
||||
console.error('[ShareLaunchHandler] 处理 shareCode 异常:', err);
|
||||
} finally {
|
||||
this._isHandlingShow = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
817
软著源代码/10-PK战绩与结算.md
Normal file
817
软著源代码/10-PK战绩与结算.md
Normal file
@@ -0,0 +1,817 @@
|
||||
# 梗中作乐 V1.0 - 源代码文档(第 10 部分)
|
||||
|
||||
软件名称:梗中作乐
|
||||
版本号:V1.0
|
||||
|
||||
---
|
||||
|
||||
## 第 10 部分:PK 战绩与挑战结算
|
||||
|
||||
本部分包含 PK 战绩榜单页与 PK 挑战结算页两个核心结算页面。
|
||||
|
||||
### 10.1 PK 战绩页 (assets/prefabs/PagePKData.ts)
|
||||
|
||||
```typescript
|
||||
import { _decorator, Node, Button, instantiate, Label, ScrollView, UITransform, Sprite, SpriteFrame, Texture2D, ImageAsset, assetManager } from 'cc';
|
||||
import { BaseView } from 'db://assets/scripts/core/BaseView';
|
||||
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
|
||||
import { CreatedShareItem, ParticipatedShareItem, ShareParticipantRankSummary } from 'db://assets/scripts/types/ApiTypes';
|
||||
import { ShareManager } from 'db://assets/scripts/utils/ShareManager';
|
||||
import { ToastManager } from 'db://assets/scripts/utils/ToastManager';
|
||||
import { AudioManager } from 'db://assets/scripts/utils/AudioManager';
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
@ccclass('PagePKData')
|
||||
export class PagePKData extends BaseView {
|
||||
private static readonly CREATED_ITEM_TOP_PADDING = 20;
|
||||
private static readonly CREATED_ITEM_BOTTOM_PADDING = 20;
|
||||
private static readonly CREATED_ITEM_SPACING = 20;
|
||||
private static readonly PARTICIPATED_ITEM_TOP_PADDING = 20;
|
||||
private static readonly PARTICIPATED_ITEM_BOTTOM_PADDING = 20;
|
||||
private static readonly PARTICIPATED_ITEM_SPACING = 20;
|
||||
|
||||
@property({ type: Node, tooltip: '返回按钮' })
|
||||
backBtn: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '我创建的挑战列表 content 节点' })
|
||||
createdListContent: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '我创建的挑战列表条目模板 FriendsPKRankListItem' })
|
||||
createdListItemTemplate: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '我参与的挑战列表 content 节点' })
|
||||
participatedListContent: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '我参与的挑战列表条目模板 MyPKListItem' })
|
||||
participatedListItemTemplate: Node | null = null;
|
||||
|
||||
private _createdShares: CreatedShareItem[] = [];
|
||||
private _participatedShares: ParticipatedShareItem[] = [];
|
||||
private _createdItemNodes: Node[] = [];
|
||||
private _participatedItemNodes: Node[] = [];
|
||||
private _createdButtonBindings: Array<{ node: Node; handler: () => void }> = [];
|
||||
private _isLoading: boolean = false;
|
||||
private _renderVersion: number = 0;
|
||||
|
||||
onViewLoad(): void {
|
||||
this._resolveNodes();
|
||||
if (this.backBtn) {
|
||||
this.backBtn.on(Button.EventType.CLICK, this._onBackClick, this);
|
||||
}
|
||||
this._hideCreatedItemTemplate();
|
||||
this._hideParticipatedItemTemplate();
|
||||
}
|
||||
|
||||
onViewShow(): void {
|
||||
this._resolveNodes();
|
||||
void this._loadShareLists();
|
||||
}
|
||||
|
||||
onViewHide(): void {
|
||||
this._renderVersion++;
|
||||
}
|
||||
|
||||
private _onBackClick(): void {
|
||||
AudioManager.instance.playButtonClick();
|
||||
ViewManager.instance.back();
|
||||
}
|
||||
|
||||
private async _loadShareLists(): Promise<void> {
|
||||
if (this._isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._isLoading = true;
|
||||
try {
|
||||
const [createdItems, participatedItems] = await Promise.all([
|
||||
ShareManager.instance.fetchCreatedShares(),
|
||||
ShareManager.instance.fetchParticipatedShares(),
|
||||
]);
|
||||
|
||||
if (!createdItems || !participatedItems) {
|
||||
ToastManager.instance.show('获取挑战列表失败,请稍后重试');
|
||||
}
|
||||
|
||||
this._createdShares = createdItems ?? [];
|
||||
this._participatedShares = participatedItems ?? [];
|
||||
console.log('[PagePKData] 我创建的挑战列表:', this._createdShares);
|
||||
console.log('[PagePKData] 我参与的挑战列表:', this._participatedShares);
|
||||
this._renderCreatedShares();
|
||||
this._renderParticipatedShares();
|
||||
} finally {
|
||||
this._isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onViewDestroy(): void {
|
||||
if (this.backBtn) {
|
||||
this.backBtn.off(Button.EventType.CLICK, this._onBackClick, this);
|
||||
}
|
||||
this._clearCreatedItems();
|
||||
this._clearParticipatedItems();
|
||||
}
|
||||
|
||||
private _resolveNodes(): void {
|
||||
if (!this.backBtn || !this.backBtn.isValid) {
|
||||
this.backBtn = this.node.getChildByName('BtnBack');
|
||||
}
|
||||
|
||||
const createdList = this.node.getChildByName('FriendsPKRankList');
|
||||
const view = createdList?.getChildByName('view');
|
||||
this.createdListContent = this.createdListContent ?? view?.getChildByName('content') ?? null;
|
||||
this.createdListItemTemplate = this.createdListItemTemplate
|
||||
?? this.createdListContent?.getChildByName('FriendsPKRankListItem')
|
||||
?? null;
|
||||
|
||||
const participatedList = this.node.getChildByName('MyPKList');
|
||||
const participatedView = participatedList?.getChildByName('view');
|
||||
this.participatedListContent = this.participatedListContent ?? participatedView?.getChildByName('content') ?? null;
|
||||
this.participatedListItemTemplate = this.participatedListItemTemplate
|
||||
?? this.participatedListContent?.getChildByName('MyPKListItem')
|
||||
?? null;
|
||||
|
||||
if (!this.createdListContent) {
|
||||
console.warn('[PagePKData] 未找到 FriendsPKRankList/content 节点');
|
||||
}
|
||||
if (!this.createdListItemTemplate) {
|
||||
console.warn('[PagePKData] 未找到 FriendsPKRankListItem 模板节点');
|
||||
}
|
||||
if (!this.participatedListContent) {
|
||||
console.warn('[PagePKData] 未找到 MyPKList/content 节点');
|
||||
}
|
||||
if (!this.participatedListItemTemplate) {
|
||||
console.warn('[PagePKData] 未找到 MyPKListItem 模板节点');
|
||||
}
|
||||
}
|
||||
|
||||
private _renderCreatedShares(): void {
|
||||
this._renderVersion++;
|
||||
this._clearCreatedItems();
|
||||
|
||||
if (!this.createdListContent || !this.createdListItemTemplate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._layoutCreatedList(this._createdShares.length);
|
||||
this._hideCreatedItemTemplate();
|
||||
|
||||
this._createdShares.forEach((share, index) => {
|
||||
const item = instantiate(this.createdListItemTemplate!);
|
||||
item.name = `FriendsPKRankListItem_${index + 1}`;
|
||||
item.active = true;
|
||||
this.createdListContent!.addChild(item);
|
||||
this._createdItemNodes.push(item);
|
||||
this._positionCreatedItem(item, index);
|
||||
this._applyCreatedShare(item, share, this._renderVersion);
|
||||
});
|
||||
|
||||
this._scrollCreatedListToTop();
|
||||
}
|
||||
|
||||
private _renderParticipatedShares(): void {
|
||||
this._clearParticipatedItems();
|
||||
|
||||
if (!this.participatedListContent || !this.participatedListItemTemplate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._layoutParticipatedList(this._participatedShares.length);
|
||||
this._hideParticipatedItemTemplate();
|
||||
|
||||
this._participatedShares.forEach((share, index) => {
|
||||
const item = instantiate(this.participatedListItemTemplate!);
|
||||
item.name = `MyPKListItem_${index + 1}`;
|
||||
item.active = true;
|
||||
this.participatedListContent!.addChild(item);
|
||||
this._participatedItemNodes.push(item);
|
||||
this._positionParticipatedItem(item, index);
|
||||
this._applyParticipatedShare(item, share);
|
||||
});
|
||||
|
||||
this._scrollParticipatedListToTop();
|
||||
}
|
||||
|
||||
private _applyCreatedShare(item: Node, share: CreatedShareItem, version: number): void {
|
||||
this._setLabel(this._findLabelIn(item, 'Name'), share.title || '未命名挑战');
|
||||
this._setLabel(this._findLabelIn(item, 'ParticipateLabel'), `共${share.participantCount ?? 0}人参与`);
|
||||
|
||||
const firstParticipant = this._getFirstParticipant(share);
|
||||
const firstName = this._getParticipantName(firstParticipant);
|
||||
const rankNumber = firstParticipant?.rank ?? 1;
|
||||
|
||||
this._setLabel(this._findLabelIn(item, 'NameLabel'), firstName || (share.participantCount > 0 ? '微信用户' : '暂无参与'));
|
||||
this._setLabel(this._findLabelIn(item, 'RankNumberLabel'), firstParticipant ? `第 ${rankNumber} 名` : '暂无排名');
|
||||
this._loadAvatar(firstParticipant?.avatarUrl ?? '', this._findAvatarSprite(item), version);
|
||||
|
||||
const viewButton = this._findChild(item, 'ViewButton');
|
||||
if (viewButton) {
|
||||
const handler = () => {
|
||||
AudioManager.instance.playButtonClick();
|
||||
this._openShareDetail(share);
|
||||
};
|
||||
viewButton.on(Button.EventType.CLICK, handler, this);
|
||||
this._createdButtonBindings.push({ node: viewButton, handler });
|
||||
}
|
||||
|
||||
const shareButton = this._findChild(item, 'ShareButton');
|
||||
if (shareButton) {
|
||||
const handler = () => {
|
||||
AudioManager.instance.playButtonClick();
|
||||
ShareManager.instance.triggerWxShare(share.title, share.shareCode);
|
||||
};
|
||||
shareButton.on(Button.EventType.CLICK, handler, this);
|
||||
this._createdButtonBindings.push({ node: shareButton, handler });
|
||||
}
|
||||
}
|
||||
|
||||
private _applyParticipatedShare(item: Node, share: ParticipatedShareItem): void {
|
||||
this._setLabel(this._findLabelIn(item, 'ChallangeName'), share.title || '未命名挑战');
|
||||
this._setLabel(this._findLabelIn(item, 'ParticipateLabel'), `共${share.participantCount ?? 0}人参与`);
|
||||
this._applyParticipatedRank(item, share.userRank);
|
||||
}
|
||||
|
||||
private _openShareDetail(share: CreatedShareItem): void {
|
||||
if (!share.shareCode) {
|
||||
ToastManager.instance.show('挑战数据异常,请稍后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
ViewManager.instance.open('PagePKDetail', {
|
||||
params: {
|
||||
share,
|
||||
shareCode: share.shareCode,
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('[PagePKData] 打开挑战详情失败:', err);
|
||||
ToastManager.instance.show('打开挑战详情失败,请稍后重试');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _getFirstParticipant(share: CreatedShareItem): ShareParticipantRankSummary | null {
|
||||
return share.firstPlaceUser ?? share.topParticipant ?? share.firstParticipant ?? share.champion ?? null;
|
||||
}
|
||||
|
||||
private _getParticipantName(participant: ShareParticipantRankSummary | null): string {
|
||||
return participant?.nickname || participant?.nickName || '';
|
||||
}
|
||||
|
||||
private _layoutCreatedList(itemCount: number): void {
|
||||
if (!this.createdListContent || !this.createdListItemTemplate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentTransform = this.createdListContent.getComponent(UITransform);
|
||||
const viewTransform = this.createdListContent.parent?.getComponent(UITransform) ?? null;
|
||||
const itemTransform = this.createdListItemTemplate.getComponent(UITransform);
|
||||
if (!contentTransform || !viewTransform || !itemTransform) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentHeight = Math.max(
|
||||
viewTransform.height,
|
||||
PagePKData.CREATED_ITEM_TOP_PADDING
|
||||
+ PagePKData.CREATED_ITEM_BOTTOM_PADDING
|
||||
+ itemCount * itemTransform.height
|
||||
+ Math.max(0, itemCount - 1) * PagePKData.CREATED_ITEM_SPACING,
|
||||
);
|
||||
|
||||
contentTransform.setContentSize(contentTransform.width, contentHeight);
|
||||
this.createdListContent.setPosition(
|
||||
this.createdListContent.position.x,
|
||||
viewTransform.height / 2,
|
||||
this.createdListContent.position.z,
|
||||
);
|
||||
}
|
||||
|
||||
private _layoutParticipatedList(itemCount: number): void {
|
||||
if (!this.participatedListContent || !this.participatedListItemTemplate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentTransform = this.participatedListContent.getComponent(UITransform);
|
||||
const viewTransform = this.participatedListContent.parent?.getComponent(UITransform) ?? null;
|
||||
const itemTransform = this.participatedListItemTemplate.getComponent(UITransform);
|
||||
if (!contentTransform || !viewTransform || !itemTransform) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentHeight = Math.max(
|
||||
viewTransform.height,
|
||||
PagePKData.PARTICIPATED_ITEM_TOP_PADDING
|
||||
+ PagePKData.PARTICIPATED_ITEM_BOTTOM_PADDING
|
||||
+ itemCount * itemTransform.height
|
||||
+ Math.max(0, itemCount - 1) * PagePKData.PARTICIPATED_ITEM_SPACING,
|
||||
);
|
||||
|
||||
contentTransform.setContentSize(contentTransform.width, contentHeight);
|
||||
this.participatedListContent.setPosition(
|
||||
this.participatedListContent.position.x,
|
||||
viewTransform.height / 2,
|
||||
this.participatedListContent.position.z,
|
||||
);
|
||||
}
|
||||
|
||||
private _positionCreatedItem(item: Node, index: number): void {
|
||||
const itemTransform = item.getComponent(UITransform);
|
||||
if (!itemTransform) {
|
||||
return;
|
||||
}
|
||||
|
||||
const y = -PagePKData.CREATED_ITEM_TOP_PADDING
|
||||
- itemTransform.height / 2
|
||||
- index * (itemTransform.height + PagePKData.CREATED_ITEM_SPACING);
|
||||
item.setPosition(0, y, item.position.z);
|
||||
}
|
||||
|
||||
private _positionParticipatedItem(item: Node, index: number): void {
|
||||
const itemTransform = item.getComponent(UITransform);
|
||||
if (!itemTransform) {
|
||||
return;
|
||||
}
|
||||
|
||||
const y = -PagePKData.PARTICIPATED_ITEM_TOP_PADDING
|
||||
- itemTransform.height / 2
|
||||
- index * (itemTransform.height + PagePKData.PARTICIPATED_ITEM_SPACING);
|
||||
item.setPosition(0, y, item.position.z);
|
||||
}
|
||||
|
||||
private _scrollCreatedListToTop(): void {
|
||||
const scrollView = this.node.getChildByName('FriendsPKRankList')?.getComponent(ScrollView);
|
||||
scrollView?.scrollToTop(0);
|
||||
}
|
||||
|
||||
private _scrollParticipatedListToTop(): void {
|
||||
const scrollView = this.node.getChildByName('MyPKList')?.getComponent(ScrollView);
|
||||
scrollView?.scrollToTop(0);
|
||||
}
|
||||
|
||||
private _applyParticipatedRank(item: Node, rank: number | null): void {
|
||||
const rankRoot = this._findChild(item, 'Rank');
|
||||
if (!rankRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rank1Badge = this._findChild(rankRoot, 'rank1badge');
|
||||
const rank2Badge = this._findChild(rankRoot, 'rank2badge');
|
||||
const rank3Badge = this._findChild(rankRoot, 'rank3badge');
|
||||
const rankNumberNode = this._findChild(rankRoot, 'RankNumber');
|
||||
const normalizedRank = rank && rank > 0 ? rank : null;
|
||||
|
||||
if (rank1Badge) {
|
||||
rank1Badge.active = normalizedRank === 1;
|
||||
}
|
||||
if (rank2Badge) {
|
||||
rank2Badge.active = normalizedRank === 2;
|
||||
}
|
||||
if (rank3Badge) {
|
||||
rank3Badge.active = normalizedRank === 3;
|
||||
}
|
||||
if (rankNumberNode) {
|
||||
rankNumberNode.active = normalizedRank !== 1 && normalizedRank !== 2 && normalizedRank !== 3;
|
||||
this._setLabel(rankNumberNode.getComponent(Label), normalizedRank ? `${normalizedRank}` : '-');
|
||||
}
|
||||
}
|
||||
|
||||
private _findAvatarSprite(item: Node): Sprite | null {
|
||||
const headNode = this._findChild(item, 'Head');
|
||||
return headNode?.children[0]?.getComponent(Sprite) ?? headNode?.getComponent(Sprite) ?? null;
|
||||
}
|
||||
|
||||
private _loadAvatar(url: string, sprite: Sprite | null, version: number): void {
|
||||
if (!url || !sprite) {
|
||||
return;
|
||||
}
|
||||
|
||||
assetManager.loadRemote<ImageAsset>(url, (err, imageAsset) => {
|
||||
if (err || !imageAsset || version !== this._renderVersion || !sprite.node.isValid) {
|
||||
if (err) {
|
||||
console.error('[PagePKData] 加载第一名头像失败:', url, err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const texture = new Texture2D();
|
||||
texture.image = imageAsset;
|
||||
const spriteFrame = new SpriteFrame();
|
||||
spriteFrame.texture = texture;
|
||||
sprite.spriteFrame = spriteFrame;
|
||||
});
|
||||
}
|
||||
|
||||
private _clearCreatedItems(): void {
|
||||
this._unbindCreatedButtons();
|
||||
|
||||
for (const item of this._createdItemNodes) {
|
||||
if (item.isValid) {
|
||||
item.removeFromParent();
|
||||
item.destroy();
|
||||
}
|
||||
}
|
||||
this._createdItemNodes = [];
|
||||
this._hideCreatedItemTemplate();
|
||||
}
|
||||
|
||||
private _clearParticipatedItems(): void {
|
||||
for (const item of this._participatedItemNodes) {
|
||||
if (item.isValid) {
|
||||
item.removeFromParent();
|
||||
item.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
this._participatedItemNodes = [];
|
||||
this._hideParticipatedItemTemplate();
|
||||
}
|
||||
|
||||
private _unbindCreatedButtons(): void {
|
||||
for (const binding of this._createdButtonBindings) {
|
||||
if (binding.node.isValid) {
|
||||
binding.node.off(Button.EventType.CLICK, binding.handler, this);
|
||||
}
|
||||
}
|
||||
this._createdButtonBindings = [];
|
||||
}
|
||||
|
||||
private _hideCreatedItemTemplate(): void {
|
||||
if (this.createdListItemTemplate?.isValid) {
|
||||
this.createdListItemTemplate.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _hideParticipatedItemTemplate(): void {
|
||||
if (this.participatedListItemTemplate?.isValid) {
|
||||
this.participatedListItemTemplate.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _setLabel(label: Label | null, text: string): void {
|
||||
if (label) {
|
||||
label.string = text;
|
||||
}
|
||||
}
|
||||
|
||||
private _findLabelIn(root: Node, nodeName: string): Label | null {
|
||||
return this._findChild(root, nodeName)?.getComponent(Label) ?? null;
|
||||
}
|
||||
|
||||
private _findChild(root: Node, nodeName: string): Node | null {
|
||||
if (root.name === nodeName) {
|
||||
return root;
|
||||
}
|
||||
|
||||
for (const child of root.children) {
|
||||
const found = this._findChild(child, nodeName);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 PK 结算页 (assets/prefabs/PagePKEnd.ts)
|
||||
|
||||
```typescript
|
||||
import { _decorator, assetManager, Button, ImageAsset, instantiate, Label, Node, ScrollView, Sprite, SpriteFrame, Texture2D, UITransform } from 'cc';
|
||||
import { BaseView } from 'db://assets/scripts/core/BaseView';
|
||||
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
|
||||
import { ShareManager } from 'db://assets/scripts/utils/ShareManager';
|
||||
import { SubmitShareData, SubmittedShareLevelData } from 'db://assets/scripts/types/ApiTypes';
|
||||
import { AudioManager } from 'db://assets/scripts/utils/AudioManager';
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
@ccclass('PagePKEnd')
|
||||
export class PagePKEnd extends BaseView {
|
||||
private static readonly ANSWER_ITEM_TOP_PADDING = 16;
|
||||
private static readonly ANSWER_ITEM_BOTTOM_PADDING = 16;
|
||||
private static readonly ANSWER_ITEM_SPACING = 16;
|
||||
private static readonly COVER_IMAGE_WIDTH = 1299;
|
||||
private static readonly COVER_IMAGE_HEIGHT = 1004;
|
||||
|
||||
@property({ type: Node, tooltip: '返回首页按钮' })
|
||||
settingButton: Node | null = null;
|
||||
|
||||
@property({ type: Label, tooltip: '顶部排名文案,例如:获得了第1名' })
|
||||
rankLabel: Label | null = null;
|
||||
|
||||
@property({ type: Label, tooltip: '答对题数文案,例如:答对了4题' })
|
||||
rightNumberLabel: Label | null = null;
|
||||
|
||||
@property({ type: Label, tooltip: '本人排名文案,例如:您获得了第1名' })
|
||||
rankNumberLabel: Label | null = null;
|
||||
|
||||
@property({ type: Label, tooltip: '参与人数文案,例如:一共66人参与了挑战' })
|
||||
participateNumberLabel: Label | null = null;
|
||||
|
||||
@property({ type: Label, tooltip: '答案列表标题' })
|
||||
answerTitleLabel: Label | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '答案列表 content 节点' })
|
||||
answerListContent: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '答案列表条目模板 AnswerItem' })
|
||||
answerItemTemplate: Node | null = null;
|
||||
|
||||
private _answerItemNodes: Node[] = [];
|
||||
private _answerButtonBindings: Array<{ node: Node; handler: () => void }> = [];
|
||||
private _renderVersion: number = 0;
|
||||
|
||||
onViewLoad(): void {
|
||||
this._resolveNodes();
|
||||
this._bindEvents();
|
||||
this._hideAnswerTemplate();
|
||||
}
|
||||
|
||||
onViewShow(): void {
|
||||
console.log('[PagePKEnd] onViewShow');
|
||||
this._resolveNodes();
|
||||
this._renderResult(this.getParams()?.result ?? null);
|
||||
}
|
||||
|
||||
onViewDestroy(): void {
|
||||
this._unbindEvents();
|
||||
this._clearAnswerItems();
|
||||
}
|
||||
|
||||
private _resolveNodes(): void {
|
||||
if (!this.settingButton || !this.settingButton.isValid) {
|
||||
this.settingButton = this.node.getChildByName('SettingButton');
|
||||
}
|
||||
|
||||
this.rankLabel = this.rankLabel ?? this._findLabel('RankLabel');
|
||||
this.rightNumberLabel = this.rightNumberLabel ?? this._findLabel('RightNumberLabel');
|
||||
this.rankNumberLabel = this.rankNumberLabel ?? this._findLabel('RankNumberLabel');
|
||||
this.participateNumberLabel = this.participateNumberLabel ?? this._findLabel('PartipateNumberLabel');
|
||||
this.answerTitleLabel = this.answerTitleLabel ?? this._findLabel('AnswerTitle');
|
||||
|
||||
const answerList = this.node.getChildByName('AnswerList');
|
||||
const view = answerList?.getChildByName('view');
|
||||
this.answerListContent = this.answerListContent ?? view?.getChildByName('content') ?? null;
|
||||
this.answerItemTemplate = this.answerItemTemplate
|
||||
?? this.answerListContent?.getChildByName('AnswerItem')
|
||||
?? null;
|
||||
|
||||
if (!this.settingButton) {
|
||||
console.warn('[PagePKEnd] 未找到 SettingButton 节点');
|
||||
}
|
||||
if (!this.answerListContent) {
|
||||
console.warn('[PagePKEnd] 未找到 AnswerList/content 节点');
|
||||
}
|
||||
if (!this.answerItemTemplate) {
|
||||
console.warn('[PagePKEnd] 未找到 AnswerItem 模板节点');
|
||||
}
|
||||
}
|
||||
|
||||
private _bindEvents(): void {
|
||||
const button = this.settingButton?.getComponent(Button);
|
||||
if (!button) {
|
||||
console.warn('[PagePKEnd] SettingButton 缺少 Button 组件');
|
||||
return;
|
||||
}
|
||||
|
||||
this.settingButton?.on(Button.EventType.CLICK, this._onHomeClick, this);
|
||||
}
|
||||
|
||||
private _unbindEvents(): void {
|
||||
if (this.settingButton && this.settingButton.isValid) {
|
||||
this.settingButton.off(Button.EventType.CLICK, this._onHomeClick, this);
|
||||
}
|
||||
this._unbindAnswerButtons();
|
||||
}
|
||||
|
||||
private _onHomeClick(): void {
|
||||
AudioManager.instance.playButtonClick();
|
||||
ShareManager.instance.clearShareMode();
|
||||
ViewManager.instance.replace('PageHome');
|
||||
}
|
||||
|
||||
private _renderResult(result: SubmitShareData | null): void {
|
||||
this._renderVersion++;
|
||||
this._clearAnswerItems();
|
||||
|
||||
if (!result) {
|
||||
this._setLabel(this.rankLabel, '暂无排名');
|
||||
this._setLabel(this.rightNumberLabel, '答对了0题');
|
||||
this._setLabel(this.rankNumberLabel, '您暂未上榜');
|
||||
this._setLabel(this.participateNumberLabel, '暂无参与数据');
|
||||
this._setLabel(this.answerTitleLabel, '暂无挑战结果');
|
||||
this._hideAnswerTemplate();
|
||||
return;
|
||||
}
|
||||
|
||||
this._setLabel(this.rankLabel, `获得了第${result.rank}名`);
|
||||
this._setLabel(this.rightNumberLabel, `答对了${result.correctCount}题`);
|
||||
this._setLabel(this.rankNumberLabel, `您获得了第${result.rank}名`);
|
||||
this._setLabel(this.participateNumberLabel, `一共${result.participantCount}人参与了挑战`);
|
||||
this._setLabel(this.answerTitleLabel, `共用时${result.totalTimeSpent}s,揭晓答案吧`);
|
||||
this._renderAnswerList(result.levels ?? []);
|
||||
}
|
||||
|
||||
private _renderAnswerList(levels: SubmittedShareLevelData[]): void {
|
||||
if (!this.answerListContent || !this.answerItemTemplate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._hideAnswerTemplate();
|
||||
const version = this._renderVersion;
|
||||
this._layoutAnswerContent(levels.length);
|
||||
|
||||
levels.forEach((level, index) => {
|
||||
const item = instantiate(this.answerItemTemplate!);
|
||||
item.name = `AnswerItem_${index + 1}`;
|
||||
item.active = true;
|
||||
this.answerListContent!.addChild(item);
|
||||
this._answerItemNodes.push(item);
|
||||
this._positionAnswerItem(item, index);
|
||||
|
||||
this._applyAnswerState(item, level);
|
||||
|
||||
const coverSprite = this._findChild(item, 'CoverImage')?.getComponent(Sprite) ?? null;
|
||||
this._prepareCoverSprite(coverSprite);
|
||||
this._loadCoverImage(level.image1Url, coverSprite, version);
|
||||
});
|
||||
|
||||
this._scrollAnswerListToTop();
|
||||
}
|
||||
|
||||
private _layoutAnswerContent(itemCount: number): void {
|
||||
if (!this.answerListContent || !this.answerItemTemplate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentTransform = this.answerListContent.getComponent(UITransform);
|
||||
const viewTransform = this.answerListContent.parent?.getComponent(UITransform) ?? null;
|
||||
const itemTransform = this.answerItemTemplate.getComponent(UITransform);
|
||||
if (!contentTransform || !viewTransform || !itemTransform) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemHeight = itemTransform.height;
|
||||
const contentHeight = Math.max(
|
||||
viewTransform.height,
|
||||
PagePKEnd.ANSWER_ITEM_TOP_PADDING
|
||||
+ PagePKEnd.ANSWER_ITEM_BOTTOM_PADDING
|
||||
+ itemCount * itemHeight
|
||||
+ Math.max(0, itemCount - 1) * PagePKEnd.ANSWER_ITEM_SPACING,
|
||||
);
|
||||
|
||||
contentTransform.setContentSize(contentTransform.width, contentHeight);
|
||||
this.answerListContent.setPosition(
|
||||
this.answerListContent.position.x,
|
||||
viewTransform.height / 2,
|
||||
this.answerListContent.position.z,
|
||||
);
|
||||
}
|
||||
|
||||
private _positionAnswerItem(item: Node, index: number): void {
|
||||
const itemTransform = item.getComponent(UITransform);
|
||||
if (!itemTransform) {
|
||||
return;
|
||||
}
|
||||
|
||||
const y = -PagePKEnd.ANSWER_ITEM_TOP_PADDING
|
||||
- itemTransform.height / 2
|
||||
- index * (itemTransform.height + PagePKEnd.ANSWER_ITEM_SPACING);
|
||||
item.setPosition(0, y, item.position.z);
|
||||
}
|
||||
|
||||
private _applyAnswerState(item: Node, level: SubmittedShareLevelData): void {
|
||||
const answerButton = this._findChild(item, 'ButtonViewAnswer');
|
||||
const buttonLabel = answerButton?.getChildByName('Label')?.getComponent(Label) ?? null;
|
||||
const answerLabelNode = this._findChild(item, 'AnswerLabel');
|
||||
const answerLabel = answerLabelNode?.getComponent(Label) ?? null;
|
||||
|
||||
this._setLabel(buttonLabel, '查看答案');
|
||||
this._setLabel(answerLabel, level.answer || '-');
|
||||
|
||||
if (level.isCorrect) {
|
||||
if (answerButton) {
|
||||
answerButton.active = false;
|
||||
}
|
||||
if (answerLabelNode) {
|
||||
answerLabelNode.active = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (answerButton) {
|
||||
answerButton.active = true;
|
||||
}
|
||||
if (answerLabelNode) {
|
||||
answerLabelNode.active = false;
|
||||
}
|
||||
|
||||
const handler = () => {
|
||||
AudioManager.instance.playButtonClick();
|
||||
if (answerButton?.isValid) {
|
||||
answerButton.active = false;
|
||||
}
|
||||
if (answerLabelNode?.isValid) {
|
||||
answerLabelNode.active = true;
|
||||
}
|
||||
};
|
||||
if (answerButton) {
|
||||
answerButton.on(Button.EventType.CLICK, handler, this);
|
||||
this._answerButtonBindings.push({ node: answerButton, handler });
|
||||
}
|
||||
}
|
||||
|
||||
private _scrollAnswerListToTop(): void {
|
||||
const scrollView = this.node.getChildByName('AnswerList')?.getComponent(ScrollView);
|
||||
scrollView?.scrollToTop(0);
|
||||
}
|
||||
|
||||
private _loadCoverImage(url: string, sprite: Sprite | null, version: number): void {
|
||||
if (!url || !sprite) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._prepareCoverSprite(sprite);
|
||||
assetManager.loadRemote<ImageAsset>(url, (err, imageAsset) => {
|
||||
if (err || !imageAsset || version !== this._renderVersion || !sprite.node.isValid) {
|
||||
if (err) {
|
||||
console.error('[PagePKEnd] 加载答案封面失败:', url, err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const texture = new Texture2D();
|
||||
texture.image = imageAsset;
|
||||
const spriteFrame = new SpriteFrame();
|
||||
spriteFrame.texture = texture;
|
||||
this._prepareCoverSprite(sprite);
|
||||
sprite.spriteFrame = spriteFrame;
|
||||
this._prepareCoverSprite(sprite);
|
||||
});
|
||||
}
|
||||
|
||||
private _prepareCoverSprite(sprite: Sprite | null): void {
|
||||
if (!sprite?.node.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
sprite.sizeMode = Sprite.SizeMode.CUSTOM;
|
||||
const transform = sprite.node.getComponent(UITransform);
|
||||
if (transform) {
|
||||
transform.setContentSize(PagePKEnd.COVER_IMAGE_WIDTH, PagePKEnd.COVER_IMAGE_HEIGHT);
|
||||
}
|
||||
sprite.node.setScale(0.242, 0.242, 0.242);
|
||||
}
|
||||
|
||||
private _clearAnswerItems(): void {
|
||||
this._unbindAnswerButtons();
|
||||
|
||||
for (const item of this._answerItemNodes) {
|
||||
if (item.isValid) {
|
||||
item.removeFromParent();
|
||||
item.destroy();
|
||||
}
|
||||
}
|
||||
this._answerItemNodes = [];
|
||||
this._hideAnswerTemplate();
|
||||
}
|
||||
|
||||
private _unbindAnswerButtons(): void {
|
||||
for (const binding of this._answerButtonBindings) {
|
||||
if (binding.node.isValid) {
|
||||
binding.node.off(Button.EventType.CLICK, binding.handler, this);
|
||||
}
|
||||
}
|
||||
this._answerButtonBindings = [];
|
||||
}
|
||||
|
||||
private _hideAnswerTemplate(): void {
|
||||
if (this.answerItemTemplate?.isValid) {
|
||||
this.answerItemTemplate.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _setLabel(label: Label | null, text: string): void {
|
||||
if (label) {
|
||||
label.string = text;
|
||||
}
|
||||
}
|
||||
|
||||
private _findLabel(nodeName: string): Label | null {
|
||||
return this._findChild(this.node, nodeName)?.getComponent(Label) ?? null;
|
||||
}
|
||||
|
||||
private _findChild(root: Node, nodeName: string): Node | null {
|
||||
if (root.name === nodeName) {
|
||||
return root;
|
||||
}
|
||||
|
||||
for (const child of root.children) {
|
||||
const found = this._findChild(child, nodeName);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
646
软著源代码/11-PK详情与圆角.md
Normal file
646
软著源代码/11-PK详情与圆角.md
Normal file
@@ -0,0 +1,646 @@
|
||||
# 梗中作乐 V1.0 - 源代码文档(第 11 部分)
|
||||
|
||||
软件名称:梗中作乐
|
||||
版本号:V1.0
|
||||
|
||||
---
|
||||
|
||||
## 第 11 部分:PK 详情与圆角渲染
|
||||
|
||||
本部分包含 PK 单场详情页与 UI 圆角遮罩、圆角材质工具。
|
||||
|
||||
### 11.1 PK 详情页 (assets/prefabs/PagePKDetail.ts)
|
||||
|
||||
```typescript
|
||||
import { _decorator, assetManager, Button, ImageAsset, instantiate, Label, Node, ScrollView, Sprite, SpriteFrame, Texture2D, UITransform } from 'cc';
|
||||
import { BaseView } from 'db://assets/scripts/core/BaseView';
|
||||
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
|
||||
import { CreatedShareItem, ShareDetailData, ShareParticipantRankSummary } from 'db://assets/scripts/types/ApiTypes';
|
||||
import { ShareManager } from 'db://assets/scripts/utils/ShareManager';
|
||||
import { ToastManager } from 'db://assets/scripts/utils/ToastManager';
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
interface PagePKDetailParams {
|
||||
share?: CreatedShareItem | null;
|
||||
shareCode?: string | null;
|
||||
detail?: ShareDetailData | null;
|
||||
}
|
||||
|
||||
@ccclass('PagePKDetail')
|
||||
export class PagePKDetail extends BaseView {
|
||||
private static readonly RANK_ITEM_TOP_PADDING = 16;
|
||||
private static readonly RANK_ITEM_BOTTOM_PADDING = 16;
|
||||
private static readonly RANK_ITEM_SPACING = 16;
|
||||
|
||||
@property({ type: Label, tooltip: '参与人数文案,例如:66 人参与' })
|
||||
participateLabel: Label | null = null;
|
||||
|
||||
private _backButton: Node | null = null;
|
||||
private _titleLabel: Label | null = null;
|
||||
private _championPanel: Node | null = null;
|
||||
private _rankListContent: Node | null = null;
|
||||
private _rankListItemTemplate: Node | null = null;
|
||||
private _rankItemNodes: Node[] = [];
|
||||
private _renderVersion: number = 0;
|
||||
|
||||
onViewLoad(): void {
|
||||
this._resolveNodes();
|
||||
this._bindEvents();
|
||||
this._hideRankItemTemplate();
|
||||
}
|
||||
|
||||
onViewShow(): void {
|
||||
this._resolveNodes();
|
||||
void this._loadAndRenderDetail();
|
||||
}
|
||||
|
||||
onViewHide(): void {
|
||||
this._renderVersion++;
|
||||
}
|
||||
|
||||
onViewDestroy(): void {
|
||||
this._unbindEvents();
|
||||
this._clearRankItems();
|
||||
}
|
||||
|
||||
private _resolveNodes(): void {
|
||||
if (!this._backButton || !this._backButton.isValid) {
|
||||
this._backButton = this.node.getChildByName('ButtonBack');
|
||||
}
|
||||
|
||||
this._titleLabel = this._titleLabel
|
||||
?? this.node.getChildByName('Title')?.getChildByName('Label')?.getComponent(Label)
|
||||
?? null;
|
||||
this.participateLabel = this.participateLabel ?? this._findLabelIn(this.node, 'ParticipateLabel');
|
||||
this._championPanel = this._championPanel ?? this.node.getChildByName('ChampionPanel');
|
||||
|
||||
const rankList = this.node.getChildByName('RankList');
|
||||
const view = rankList?.getChildByName('view');
|
||||
this._rankListContent = this._rankListContent ?? view?.getChildByName('content') ?? null;
|
||||
this._rankListItemTemplate = this._rankListItemTemplate
|
||||
?? this._rankListContent?.getChildByName('RankListItem')
|
||||
?? null;
|
||||
|
||||
if (!this._backButton) {
|
||||
console.warn('[PagePKDetail] 未找到 ButtonBack 节点');
|
||||
}
|
||||
if (!this._rankListContent) {
|
||||
console.warn('[PagePKDetail] 未找到 RankList/content 节点');
|
||||
}
|
||||
if (!this._rankListItemTemplate) {
|
||||
console.warn('[PagePKDetail] 未找到 RankListItem 模板节点');
|
||||
}
|
||||
if (!this.participateLabel) {
|
||||
console.warn('[PagePKDetail] 未找到 ParticipateLabel 节点');
|
||||
}
|
||||
}
|
||||
|
||||
private _bindEvents(): void {
|
||||
if (this._backButton) {
|
||||
this._backButton.on(Button.EventType.CLICK, this._onBackClick, this);
|
||||
}
|
||||
}
|
||||
|
||||
private _unbindEvents(): void {
|
||||
if (this._backButton?.isValid) {
|
||||
this._backButton.off(Button.EventType.CLICK, this._onBackClick, this);
|
||||
}
|
||||
}
|
||||
|
||||
private _onBackClick(): void {
|
||||
ViewManager.instance.back();
|
||||
}
|
||||
|
||||
private async _loadAndRenderDetail(): Promise<void> {
|
||||
const params = this.getParams() as PagePKDetailParams | null;
|
||||
const share = this._resolveShare(params);
|
||||
const passedDetail = params?.detail ?? null;
|
||||
const shareCode = passedDetail?.shareCode ?? params?.shareCode ?? share?.shareCode ?? null;
|
||||
const version = ++this._renderVersion;
|
||||
|
||||
if (passedDetail) {
|
||||
this._renderShareDetail(passedDetail, version);
|
||||
return;
|
||||
}
|
||||
|
||||
this._renderShareSummary(share, version);
|
||||
if (!shareCode) {
|
||||
ToastManager.instance.show('挑战数据异常,请稍后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = await ShareManager.instance.fetchShareDetail(shareCode);
|
||||
if (version !== this._renderVersion || !this.isShowing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
ToastManager.instance.show('获取挑战详情失败,请稍后重试');
|
||||
this._renderShareSummary(share, version);
|
||||
return;
|
||||
}
|
||||
|
||||
this._renderShareDetail(detail, version);
|
||||
}
|
||||
|
||||
private _resolveShare(params: PagePKDetailParams | null): CreatedShareItem | null {
|
||||
if (params?.share) {
|
||||
return params.share;
|
||||
}
|
||||
|
||||
const shareCode = params?.shareCode ?? params?.detail?.shareCode;
|
||||
if (!shareCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ShareManager.instance.createdShares.find((share) => share.shareCode === shareCode) ?? null;
|
||||
}
|
||||
|
||||
private _renderShareSummary(share: CreatedShareItem | null, version: number): void {
|
||||
this._clearRankItems();
|
||||
this._setLabel(this._titleLabel, share?.title || '挑战详情');
|
||||
this._renderParticipateCount(share);
|
||||
this._renderChampion(share ? this._getFirstParticipant(share) : null, share, version);
|
||||
this._layoutRankContent(0);
|
||||
this._scrollRankListToTop();
|
||||
}
|
||||
|
||||
private _renderShareDetail(detail: ShareDetailData, version: number): void {
|
||||
this._clearRankItems();
|
||||
|
||||
const rankings = this._normalizeRankings(detail.rankings ?? []);
|
||||
const champion = rankings.find((participant) => participant.rank === 1) ?? rankings[0] ?? null;
|
||||
const restRankings = rankings.filter((participant) => participant !== champion);
|
||||
|
||||
this._setLabel(this._titleLabel, detail.title || '挑战详情');
|
||||
this._renderParticipateCount(detail);
|
||||
this._renderChampion(champion, detail, version);
|
||||
this._renderRankList(restRankings, version);
|
||||
}
|
||||
|
||||
private _renderParticipateCount(shareInfo: CreatedShareItem | ShareDetailData | null): void {
|
||||
const participantCount = Math.max(0, shareInfo?.participantCount ?? 0);
|
||||
this._setLabel(this.participateLabel, `${participantCount} 人参与`);
|
||||
}
|
||||
|
||||
private _normalizeRankings(rankings: ShareParticipantRankSummary[]): ShareParticipantRankSummary[] {
|
||||
return rankings
|
||||
.map((participant, index) => ({
|
||||
...participant,
|
||||
rank: participant.rank ?? index + 1,
|
||||
}))
|
||||
.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0));
|
||||
}
|
||||
|
||||
private _getFirstParticipant(share: CreatedShareItem): ShareParticipantRankSummary | null {
|
||||
return share.firstPlaceUser ?? share.topParticipant ?? share.firstParticipant ?? share.champion ?? null;
|
||||
}
|
||||
|
||||
private _renderChampion(
|
||||
champion: ShareParticipantRankSummary | null,
|
||||
shareInfo: CreatedShareItem | ShareDetailData | null,
|
||||
version: number,
|
||||
): void {
|
||||
const panel = this._championPanel;
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setLabel(this._findLabelIn(panel, 'UserName'), this._getParticipantDisplayName(champion, '暂无参与'));
|
||||
this._setLabel(this._findLabelIn(panel, 'RightInfo'), this._formatCorrectText(champion, shareInfo));
|
||||
this._setLabel(this._findLabelIn(panel, 'UsedTime'), this._formatTimeText(champion, shareInfo));
|
||||
this._loadAvatar(champion?.avatarUrl ?? '', this._findAvatarSprite(panel), version);
|
||||
}
|
||||
|
||||
private _renderRankList(participants: ShareParticipantRankSummary[], version: number): void {
|
||||
if (!this._rankListContent || !this._rankListItemTemplate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._hideRankItemTemplate();
|
||||
this._layoutRankContent(participants.length);
|
||||
|
||||
participants.forEach((participant, index) => {
|
||||
const item = instantiate(this._rankListItemTemplate!);
|
||||
item.name = `RankListItem_${index + 1}`;
|
||||
item.active = true;
|
||||
this._rankListContent!.addChild(item);
|
||||
this._rankItemNodes.push(item);
|
||||
this._positionRankItem(item, index);
|
||||
this._applyRankItem(item, participant, version);
|
||||
});
|
||||
|
||||
this._scrollRankListToTop();
|
||||
}
|
||||
|
||||
private _applyRankItem(item: Node, participant: ShareParticipantRankSummary, version: number): void {
|
||||
const rank = participant.rank ?? 0;
|
||||
const rank2Badge = this._findChild(item, 'rank2badge');
|
||||
const rank3Badge = this._findChild(item, 'rank3badge');
|
||||
const rankNumberNode = this._findChild(item, 'RankNumber');
|
||||
|
||||
if (rank2Badge) {
|
||||
rank2Badge.active = rank === 2;
|
||||
}
|
||||
if (rank3Badge) {
|
||||
rank3Badge.active = rank === 3;
|
||||
}
|
||||
if (rankNumberNode) {
|
||||
rankNumberNode.active = rank !== 2 && rank !== 3;
|
||||
this._setLabel(rankNumberNode.getComponent(Label), rank > 0 ? `${rank}` : '-');
|
||||
}
|
||||
|
||||
this._setLabel(this._findLabelIn(item, 'UserName'), this._getParticipantDisplayName(participant, '微信用户'));
|
||||
this._setLabel(this._findLabelIn(item, 'RightInfo'), this._formatCorrectText(participant, null));
|
||||
this._setLabel(this._findLabelIn(item, 'UsedTime'), this._formatTimeText(participant));
|
||||
this._loadAvatar(participant.avatarUrl ?? '', this._findAvatarSprite(item), version);
|
||||
}
|
||||
|
||||
private _layoutRankContent(itemCount: number): void {
|
||||
if (!this._rankListContent || !this._rankListItemTemplate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentTransform = this._rankListContent.getComponent(UITransform);
|
||||
const viewTransform = this._rankListContent.parent?.getComponent(UITransform) ?? null;
|
||||
const itemTransform = this._rankListItemTemplate.getComponent(UITransform);
|
||||
if (!contentTransform || !viewTransform || !itemTransform) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentHeight = Math.max(
|
||||
viewTransform.height,
|
||||
PagePKDetail.RANK_ITEM_TOP_PADDING
|
||||
+ PagePKDetail.RANK_ITEM_BOTTOM_PADDING
|
||||
+ itemCount * itemTransform.height
|
||||
+ Math.max(0, itemCount - 1) * PagePKDetail.RANK_ITEM_SPACING,
|
||||
);
|
||||
|
||||
contentTransform.setContentSize(contentTransform.width, contentHeight);
|
||||
this._rankListContent.setPosition(
|
||||
this._rankListContent.position.x,
|
||||
viewTransform.height / 2,
|
||||
this._rankListContent.position.z,
|
||||
);
|
||||
}
|
||||
|
||||
private _positionRankItem(item: Node, index: number): void {
|
||||
const itemTransform = item.getComponent(UITransform);
|
||||
if (!itemTransform) {
|
||||
return;
|
||||
}
|
||||
|
||||
const y = -PagePKDetail.RANK_ITEM_TOP_PADDING
|
||||
- itemTransform.height / 2
|
||||
- index * (itemTransform.height + PagePKDetail.RANK_ITEM_SPACING);
|
||||
item.setPosition(0, y, item.position.z);
|
||||
}
|
||||
|
||||
private _scrollRankListToTop(): void {
|
||||
this.node.getChildByName('RankList')?.getComponent(ScrollView)?.scrollToTop(0);
|
||||
}
|
||||
|
||||
private _formatCorrectText(
|
||||
participant: ShareParticipantRankSummary | null,
|
||||
shareInfo: CreatedShareItem | ShareDetailData | null,
|
||||
): string {
|
||||
if (participant?.correctCount !== undefined && participant.correctCount !== null) {
|
||||
return `答对${participant.correctCount}道`;
|
||||
}
|
||||
|
||||
if (shareInfo && (shareInfo.participantCount ?? 0) <= 0) {
|
||||
return '答对0道';
|
||||
}
|
||||
|
||||
if (shareInfo) {
|
||||
return `共${shareInfo.participantCount ?? 0}人参与`;
|
||||
}
|
||||
|
||||
return '暂无成绩';
|
||||
}
|
||||
|
||||
private _formatTimeText(
|
||||
participant: ShareParticipantRankSummary | null,
|
||||
shareInfo: CreatedShareItem | ShareDetailData | null = null,
|
||||
): string {
|
||||
if (participant?.totalTimeSpent === undefined || participant.totalTimeSpent === null) {
|
||||
if (shareInfo && (shareInfo.participantCount ?? 0) <= 0) {
|
||||
return '用时0秒';
|
||||
}
|
||||
return '暂无用时';
|
||||
}
|
||||
|
||||
const totalSeconds = Math.max(0, Math.round(participant.totalTimeSpent));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `用时${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private _getParticipantName(participant: ShareParticipantRankSummary | null): string {
|
||||
return participant?.nickname || participant?.nickName || '';
|
||||
}
|
||||
|
||||
private _getParticipantDisplayName(
|
||||
participant: ShareParticipantRankSummary | null,
|
||||
emptyParticipantFallback: string,
|
||||
): string {
|
||||
if (!participant) {
|
||||
return emptyParticipantFallback;
|
||||
}
|
||||
|
||||
return this._getParticipantName(participant) || '微信用户';
|
||||
}
|
||||
|
||||
private _loadAvatar(url: string, sprite: Sprite | null, version: number): void {
|
||||
if (!sprite) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
assetManager.loadRemote<ImageAsset>(url, (err, imageAsset) => {
|
||||
if (err || !imageAsset || version !== this._renderVersion || !sprite.node.isValid) {
|
||||
if (err) {
|
||||
console.error('[PagePKDetail] 加载头像失败:', url, err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const texture = new Texture2D();
|
||||
texture.image = imageAsset;
|
||||
const spriteFrame = new SpriteFrame();
|
||||
spriteFrame.texture = texture;
|
||||
sprite.spriteFrame = spriteFrame;
|
||||
});
|
||||
}
|
||||
|
||||
private _findAvatarSprite(root: Node): Sprite | null {
|
||||
const avatarNode = this._findChild(root, 'Avatar');
|
||||
return avatarNode?.getComponent(Sprite) ?? null;
|
||||
}
|
||||
|
||||
private _clearRankItems(): void {
|
||||
for (const item of this._rankItemNodes) {
|
||||
if (item.isValid) {
|
||||
item.removeFromParent();
|
||||
item.destroy();
|
||||
}
|
||||
}
|
||||
this._rankItemNodes = [];
|
||||
this._hideRankItemTemplate();
|
||||
}
|
||||
|
||||
private _hideRankItemTemplate(): void {
|
||||
if (this._rankListItemTemplate?.isValid) {
|
||||
this._rankListItemTemplate.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _setLabel(label: Label | null, text: string): void {
|
||||
if (label) {
|
||||
label.string = text;
|
||||
}
|
||||
}
|
||||
|
||||
private _findLabelIn(root: Node, nodeName: string): Label | null {
|
||||
return this._findChild(root, nodeName)?.getComponent(Label) ?? null;
|
||||
}
|
||||
|
||||
private _findChild(root: Node, nodeName: string): Node | null {
|
||||
if (root.name === nodeName) {
|
||||
return root;
|
||||
}
|
||||
|
||||
for (const child of root.children) {
|
||||
const found = this._findChild(child, nodeName);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11.2 圆角矩形遮罩 (assets/scripts/utils/RoundedRectMask.ts)
|
||||
|
||||
```typescript
|
||||
import { _decorator, Component, UITransform, Graphics, Color, Mask } from 'cc';
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/**
|
||||
* 圆角矩形遮罩组件
|
||||
* 使用 Graphics + Mask 绘制圆角矩形作为遮罩
|
||||
*
|
||||
* 使用方法:
|
||||
* 1. 将此组件添加到需要圆角的节点上
|
||||
* 2. 设置圆角半径
|
||||
* 3. 该节点及其子节点会被裁剪为圆角矩形
|
||||
*/
|
||||
@ccclass('RoundedRectMask')
|
||||
export class RoundedRectMask extends Component {
|
||||
|
||||
@property({
|
||||
tooltip: '圆角半径(像素)'
|
||||
})
|
||||
radius: number = 20;
|
||||
|
||||
private _graphics: Graphics | null = null;
|
||||
private _mask: Mask | null = null;
|
||||
private _uiTransform: UITransform | null = null;
|
||||
|
||||
onLoad() {
|
||||
this._uiTransform = this.getComponent(UITransform);
|
||||
this.setupComponents();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置组件
|
||||
*/
|
||||
private setupComponents() {
|
||||
// 获取或添加 Graphics 组件
|
||||
this._graphics = this.getComponent(Graphics);
|
||||
if (!this._graphics) {
|
||||
this._graphics = this.addComponent(Graphics);
|
||||
}
|
||||
|
||||
// 获取或添加 Mask 组件
|
||||
this._mask = this.getComponent(Mask);
|
||||
if (!this._mask) {
|
||||
this._mask = this.addComponent(Mask);
|
||||
}
|
||||
|
||||
// 设置 Mask 使用 Graphics 类型
|
||||
this._mask.type = Mask.Type.GRAPHICS_STENCIL;
|
||||
|
||||
this.drawRoundedRect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制圆角矩形
|
||||
*/
|
||||
private drawRoundedRect() {
|
||||
if (!this._graphics || !this._uiTransform) return;
|
||||
|
||||
const width = this._uiTransform.width;
|
||||
const height = this._uiTransform.height;
|
||||
const r = Math.min(this.radius, Math.min(width, height) / 2);
|
||||
|
||||
// 清除之前的绘制
|
||||
this._graphics.clear();
|
||||
|
||||
// 设置填充颜色
|
||||
this._graphics.fillColor = new Color(255, 255, 255, 255);
|
||||
|
||||
// 绘制圆角矩形路径
|
||||
const halfW = width / 2;
|
||||
const halfH = height / 2;
|
||||
|
||||
// 使用 lineTo 和 arc 绘制圆角矩形
|
||||
// 从左下角开始,逆时针绘制
|
||||
this._graphics.moveTo(-halfW + r, -halfH);
|
||||
|
||||
// 下边
|
||||
this._graphics.lineTo(halfW - r, -halfH);
|
||||
|
||||
// 右下角圆角 (90度弧,从 -90度 到 0度)
|
||||
this._graphics.arc(halfW - r, -halfH + r, r, -Math.PI / 2, 0, false);
|
||||
|
||||
// 右边
|
||||
this._graphics.lineTo(halfW, halfH - r);
|
||||
|
||||
// 右上角圆角 (90度弧,从 0度 到 90度)
|
||||
this._graphics.arc(halfW - r, halfH - r, r, 0, Math.PI / 2, false);
|
||||
|
||||
// 上边
|
||||
this._graphics.lineTo(-halfW + r, halfH);
|
||||
|
||||
// 左上角圆角 (90度弧,从 90度 到 180度)
|
||||
this._graphics.arc(-halfW + r, halfH - r, r, Math.PI / 2, Math.PI, false);
|
||||
|
||||
// 左边
|
||||
this._graphics.lineTo(-halfW, -halfH + r);
|
||||
|
||||
// 左下角圆角 (90度弧,从 180度 到 270度)
|
||||
this._graphics.arc(-halfW + r, -halfH + r, r, Math.PI, Math.PI * 1.5, false);
|
||||
|
||||
// 填充
|
||||
this._graphics.fill();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置圆角半径
|
||||
*/
|
||||
setRadius(radius: number) {
|
||||
this.radius = radius;
|
||||
this.drawRoundedRect();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11.3 圆角材质工具 (assets/scripts/utils/roundedMaterial.utils.ts)
|
||||
|
||||
```typescript
|
||||
import { EffectAsset, Material, Sprite, Vec4 } from 'cc';
|
||||
|
||||
/**
|
||||
* 圆角 Sprite 材质工具。
|
||||
*
|
||||
* 基于 rounded-sprite.effect 的 SDF alpha 裁剪实现,直接作用于 Sprite 的自定义材质。
|
||||
* 每个 Sprite 都会获得独立 Material,避免 roundedParams / uvRect 互相覆盖。
|
||||
*/
|
||||
|
||||
let cachedTemplate: Material | null = null;
|
||||
let cachedEffectRef: EffectAsset | null = null;
|
||||
|
||||
const getOrCreateTemplate = (effectAsset: EffectAsset): Material => {
|
||||
if (cachedTemplate && cachedEffectRef === effectAsset) {
|
||||
return cachedTemplate;
|
||||
}
|
||||
|
||||
const template = new Material();
|
||||
template.initialize({
|
||||
effectAsset,
|
||||
defines: { USE_TEXTURE: true },
|
||||
});
|
||||
|
||||
cachedTemplate = template;
|
||||
cachedEffectRef = effectAsset;
|
||||
|
||||
return template;
|
||||
};
|
||||
|
||||
const extractUvRect = (sprite: Sprite): Vec4 => {
|
||||
const spriteFrame = sprite.spriteFrame;
|
||||
if (!spriteFrame) {
|
||||
return new Vec4(0, 0, 1, 1);
|
||||
}
|
||||
|
||||
const uv = spriteFrame.uv;
|
||||
if (!uv || uv.length < 8) {
|
||||
return new Vec4(0, 0, 1, 1);
|
||||
}
|
||||
|
||||
const u0 = uv[0];
|
||||
const v0 = uv[1];
|
||||
const u1 = uv[2];
|
||||
const v1 = uv[3];
|
||||
const u2 = uv[4];
|
||||
const v2 = uv[5];
|
||||
const u3 = uv[6];
|
||||
const v3 = uv[7];
|
||||
|
||||
const minU = Math.min(u0, u1, u2, u3);
|
||||
const maxU = Math.max(u0, u1, u2, u3);
|
||||
const minV = Math.min(v0, v1, v2, v3);
|
||||
const maxV = Math.max(v0, v1, v2, v3);
|
||||
const rangeU = maxU - minU;
|
||||
const rangeV = maxV - minV;
|
||||
|
||||
if (rangeU <= 0 || rangeV <= 0) {
|
||||
return new Vec4(0, 0, 1, 1);
|
||||
}
|
||||
|
||||
return new Vec4(minU, minV, rangeU, rangeV);
|
||||
};
|
||||
|
||||
export const applyRoundedCorner = (
|
||||
sprite: Sprite,
|
||||
effectAsset: EffectAsset,
|
||||
width: number,
|
||||
height: number,
|
||||
cornerRadius = 0.1,
|
||||
grayscale = false,
|
||||
): void => {
|
||||
if (!sprite || !sprite.isValid) {
|
||||
console.warn('[roundedMaterial] Invalid sprite, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!effectAsset) {
|
||||
console.warn('[roundedMaterial] EffectAsset is null, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const template = getOrCreateTemplate(effectAsset);
|
||||
const materialInstance = new Material();
|
||||
const defines: Record<string, boolean> = { USE_TEXTURE: true };
|
||||
if (grayscale) {
|
||||
defines.IS_GRAY = true;
|
||||
}
|
||||
|
||||
materialInstance.initialize({
|
||||
effectAsset,
|
||||
defines,
|
||||
});
|
||||
|
||||
const params = new Vec4(cornerRadius, 0, width, height);
|
||||
materialInstance.setProperty('roundedParams', params);
|
||||
materialInstance.setProperty('uvRect', extractUvRect(sprite));
|
||||
|
||||
sprite.customMaterial = materialInstance;
|
||||
};
|
||||
```
|
||||
673
软著源代码/12-微信SDK与音频成就配置.md
Normal file
673
软著源代码/12-微信SDK与音频成就配置.md
Normal file
@@ -0,0 +1,673 @@
|
||||
# 梗中作乐 V1.0 - 源代码文档(第 12 部分)
|
||||
|
||||
软件名称:梗中作乐
|
||||
版本号:V1.0
|
||||
|
||||
---
|
||||
|
||||
## 第 12 部分:微信 SDK、音频与成就配置
|
||||
|
||||
本部分包含微信小游戏 SDK 封装、音频播放管理器、成就称号配置常量。
|
||||
|
||||
### 12.1 微信 SDK 封装 (assets/scripts/utils/WxSDK.ts)
|
||||
|
||||
```typescript
|
||||
import { sys } from 'cc';
|
||||
|
||||
/**
|
||||
* 微信分享配置
|
||||
*/
|
||||
export interface WxShareConfig {
|
||||
/** 分享标题 */
|
||||
title: string;
|
||||
/** 分享图片 URL 或本地路径 */
|
||||
imageUrl?: string;
|
||||
/** 查询字符串,从这条转发消息进入后,可通过 wx.getLaunchOptionsSync 或 wx.onShow 获取 */
|
||||
query?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信朋友圈分享配置
|
||||
*/
|
||||
export interface WxShareTimelineConfig {
|
||||
/** 分享标题 */
|
||||
title: string;
|
||||
/** 分享图片 URL 或本地路径 */
|
||||
imageUrl?: string;
|
||||
/** 查询字符串 */
|
||||
query?: string;
|
||||
}
|
||||
|
||||
export interface WxPrivacySettingResult {
|
||||
/** true 表示需要用户确认隐私授权 */
|
||||
needAuthorization: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信小游戏 SDK 工具类
|
||||
* 封装微信平台相关 API,非微信环境下静默降级
|
||||
*/
|
||||
export class WxSDK {
|
||||
/** 隐私授权请求进行中时复用同一个 Promise,避免启动链路重复弹窗 */
|
||||
private static _privacyAuthorizePromise: Promise<boolean> | null = null;
|
||||
|
||||
/**
|
||||
* 是否处于微信小游戏环境
|
||||
*/
|
||||
static isWechat(): boolean {
|
||||
return sys.platform === sys.Platform.WECHAT_GAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 wx 全局对象(仅微信环境下可用)
|
||||
*/
|
||||
static getWx(): any {
|
||||
if (!WxSDK.isWechat()) return null;
|
||||
return typeof wx !== 'undefined' ? wx : null;
|
||||
}
|
||||
|
||||
// ==================== 登录相关 ====================
|
||||
|
||||
/**
|
||||
* 微信登录,获取临时 code
|
||||
* @returns Promise<string> 登录 code
|
||||
*/
|
||||
static login(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wxApi = WxSDK.getWx();
|
||||
if (!wxApi) {
|
||||
reject(new Error('非微信环境,无法调用 wx.login'));
|
||||
return;
|
||||
}
|
||||
|
||||
wxApi.login({
|
||||
success: (res: any) => {
|
||||
if (res.code) {
|
||||
console.log('[WxSDK] wx.login 成功,获取到 code');
|
||||
resolve(res.code);
|
||||
} else {
|
||||
console.error('[WxSDK] wx.login 失败:', res.errMsg);
|
||||
reject(new Error(res.errMsg || 'wx.login 失败'));
|
||||
}
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.error('[WxSDK] wx.login 调用失败:', err);
|
||||
reject(new Error(err.errMsg || 'wx.login 调用失败'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 隐私授权相关 ====================
|
||||
|
||||
/**
|
||||
* 检查用户是否需要隐私授权。
|
||||
* 低版本或非微信环境没有隐私拦截能力,按已授权处理。
|
||||
*/
|
||||
static checkPrivacySetting(): Promise<WxPrivacySettingResult> {
|
||||
return new Promise((resolve) => {
|
||||
const wxApi = WxSDK.getWx();
|
||||
if (!wxApi) {
|
||||
resolve({ needAuthorization: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof wxApi.getPrivacySetting !== 'function') {
|
||||
console.warn('[WxSDK] 当前微信版本不支持 getPrivacySetting');
|
||||
resolve({ needAuthorization: false });
|
||||
return;
|
||||
}
|
||||
|
||||
wxApi.getPrivacySetting({
|
||||
success: (res: any) => {
|
||||
const needAuthorization = !!res?.needAuthorization;
|
||||
console.log('[WxSDK] 隐私授权检查结果:', res);
|
||||
resolve({ needAuthorization });
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.warn('[WxSDK] 隐私授权检查失败:', err);
|
||||
resolve({ needAuthorization: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动触发微信小游戏隐私授权流程。
|
||||
* 已授权用户会直接 success;低版本或非微信环境直接视为通过。
|
||||
*/
|
||||
static requirePrivacyAuthorize(): Promise<boolean> {
|
||||
const wxApi = WxSDK.getWx();
|
||||
if (!wxApi) {
|
||||
console.warn('[WxSDK] 非微信环境,跳过隐私授权');
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
if (typeof wxApi.requirePrivacyAuthorize !== 'function') {
|
||||
console.warn('[WxSDK] 当前微信版本不支持 requirePrivacyAuthorize');
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
if (WxSDK._privacyAuthorizePromise) {
|
||||
return WxSDK._privacyAuthorizePromise;
|
||||
}
|
||||
|
||||
WxSDK._privacyAuthorizePromise = new Promise((resolve) => {
|
||||
wxApi.requirePrivacyAuthorize({
|
||||
success: () => {
|
||||
console.log('[WxSDK] 用户已授权隐私');
|
||||
resolve(true);
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.warn('[WxSDK] 用户拒绝或授权失败:', err);
|
||||
resolve(false);
|
||||
},
|
||||
complete: () => {
|
||||
WxSDK._privacyAuthorizePromise = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return WxSDK._privacyAuthorizePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动阶段使用的隐私授权入口:主动调用 requirePrivacyAuthorize,
|
||||
* 让微信在合适时机触发官方隐私弹窗逻辑。
|
||||
*/
|
||||
static ensurePrivacyAuthorized(): Promise<boolean> {
|
||||
if (!WxSDK.isWechat()) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return WxSDK.requirePrivacyAuthorize();
|
||||
}
|
||||
|
||||
// ==================== 分享相关 ====================
|
||||
|
||||
/**
|
||||
* 开启转发/分享菜单
|
||||
* 调用后用户可通过右上角菜单进行转发
|
||||
* @param withShareTicket 是否带 shareTicket,用于获取群信息
|
||||
*/
|
||||
static showShareMenu(withShareTicket: boolean = true): void {
|
||||
const wxApi = WxSDK.getWx();
|
||||
if (!wxApi) return;
|
||||
|
||||
wxApi.showShareMenu({
|
||||
withShareTicket,
|
||||
menus: ['shareAppMessage', 'shareTimeline'],
|
||||
success: () => {
|
||||
console.log('[WxSDK] showShareMenu 成功');
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.warn('[WxSDK] showShareMenu 失败', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置被动分享(右上角菜单 "转发给朋友")的内容
|
||||
* 需要在页面加载后尽早调用,只需调用一次
|
||||
* @param config 分享配置
|
||||
*/
|
||||
static onShareAppMessage(config: WxShareConfig): void {
|
||||
const wxApi = WxSDK.getWx();
|
||||
if (!wxApi) return;
|
||||
|
||||
wxApi.onShareAppMessage(() => ({
|
||||
title: config.title,
|
||||
imageUrl: config.imageUrl ?? '',
|
||||
query: config.query ?? ''
|
||||
}));
|
||||
|
||||
console.log('[WxSDK] onShareAppMessage 已设置');
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置分享到朋友圈的内容
|
||||
* @param config 朋友圈分享配置
|
||||
*/
|
||||
static onShareTimeline(config: WxShareTimelineConfig): void {
|
||||
const wxApi = WxSDK.getWx();
|
||||
if (!wxApi) return;
|
||||
|
||||
if (typeof wxApi.onShareTimeline !== 'function') {
|
||||
console.warn('[WxSDK] 当前微信版本不支持 onShareTimeline');
|
||||
return;
|
||||
}
|
||||
|
||||
wxApi.onShareTimeline(() => ({
|
||||
title: config.title,
|
||||
imageUrl: config.imageUrl ?? '',
|
||||
query: config.query ?? ''
|
||||
}));
|
||||
|
||||
console.log('[WxSDK] onShareTimeline 已设置');
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动触发转发(拉起分享面板)
|
||||
* @param config 分享配置
|
||||
*/
|
||||
static shareAppMessage(config: WxShareConfig): void {
|
||||
const wxApi = WxSDK.getWx();
|
||||
if (!wxApi) return;
|
||||
|
||||
wxApi.shareAppMessage({
|
||||
title: config.title,
|
||||
imageUrl: config.imageUrl ?? '',
|
||||
query: config.query ?? ''
|
||||
});
|
||||
|
||||
console.log('[WxSDK] shareAppMessage 已触发');
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键初始化分享功能
|
||||
* 开启分享菜单 + 设置被动分享内容 + 设置朋友圈分享内容
|
||||
* @param config 分享配置
|
||||
*/
|
||||
static initShare(config: WxShareConfig): void {
|
||||
if (!WxSDK.isWechat()) {
|
||||
console.log('[WxSDK] 非微信环境,跳过分享初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
WxSDK.showShareMenu();
|
||||
WxSDK.onShareAppMessage(config);
|
||||
WxSDK.onShareTimeline({
|
||||
title: config.title,
|
||||
imageUrl: config.imageUrl,
|
||||
query: config.query
|
||||
});
|
||||
|
||||
console.log('[WxSDK] 分享功能初始化完成');
|
||||
}
|
||||
|
||||
// ==================== 震动相关 ====================
|
||||
|
||||
/**
|
||||
* 触发短震动(15ms)
|
||||
* 用于轻量级反馈,如按钮点击
|
||||
*/
|
||||
static vibrateShort(): void {
|
||||
const wxApi = WxSDK.getWx();
|
||||
if (!wxApi) return;
|
||||
|
||||
wxApi.vibrateShort({
|
||||
type: 'medium',
|
||||
success: () => {
|
||||
console.log('[WxSDK] 短震动成功');
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.warn('[WxSDK] 短震动失败', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发长震动(400ms)
|
||||
* 用于重要反馈,如错误提示
|
||||
*/
|
||||
static vibrateLong(): void {
|
||||
const wxApi = WxSDK.getWx();
|
||||
if (!wxApi) return;
|
||||
|
||||
wxApi.vibrateLong({
|
||||
success: () => {
|
||||
console.log('[WxSDK] 长震动成功');
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.warn('[WxSDK] 长震动失败', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 激励视频广告 ====================
|
||||
|
||||
/** 激励视频广告实例(复用) */
|
||||
private static _rewardedVideoAd: any = null;
|
||||
|
||||
/**
|
||||
* 展示激励视频广告
|
||||
* 用户看完广告后返回 true,中途退出或失败返回 false
|
||||
* 非微信环境直接返回 true(开发模式直接通过)
|
||||
* @param adUnitId 广告单元 ID(默认使用项目配置的 ID)
|
||||
* @returns Promise<boolean> 是否看完广告
|
||||
*/
|
||||
static showRewardedVideoAd(adUnitId: string = ''): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const wxApi = WxSDK.getWx();
|
||||
if (!wxApi) {
|
||||
console.log('[WxSDK] 非微信环境,跳过激励视频广告');
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof wxApi.createRewardedVideoAd !== 'function') {
|
||||
console.warn('[WxSDK] 当前微信版本不支持激励视频广告');
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 复用或创建广告实例
|
||||
if (!WxSDK._rewardedVideoAd) {
|
||||
WxSDK._rewardedVideoAd = wxApi.createRewardedVideoAd({
|
||||
adUnitId: adUnitId,
|
||||
});
|
||||
}
|
||||
|
||||
const ad = WxSDK._rewardedVideoAd;
|
||||
|
||||
// 定义关闭回调(一次性)
|
||||
const onClose = (res: any) => {
|
||||
ad.offClose(onClose);
|
||||
if (res && res.isEnded) {
|
||||
console.log('[WxSDK] 激励视频广告观看完成');
|
||||
resolve(true);
|
||||
} else {
|
||||
console.log('[WxSDK] 激励视频广告中途退出');
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 定义错误回调(一次性)
|
||||
const onError = (err: any) => {
|
||||
ad.offError(onError);
|
||||
ad.offClose(onClose);
|
||||
console.error('[WxSDK] 激励视频广告错误:', err);
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
ad.onClose(onClose);
|
||||
ad.onError(onError);
|
||||
|
||||
// 先尝试 show,如果广告未加载则先 load
|
||||
ad.show().catch(() => {
|
||||
ad.load().then(() => ad.show()).catch((loadErr: any) => {
|
||||
ad.offClose(onClose);
|
||||
ad.offError(onError);
|
||||
console.error('[WxSDK] 激励视频广告加载失败:', loadErr);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[WxSDK] 激励视频广告异常:', err);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 启动参数 ====================
|
||||
|
||||
/**
|
||||
* 从启动参数中获取分享码
|
||||
* @returns 分享码,不存在则返回 null
|
||||
*/
|
||||
static getShareCodeFromLaunch(): string | null {
|
||||
const wxApi = WxSDK.getWx();
|
||||
if (!wxApi) return null;
|
||||
|
||||
try {
|
||||
const options = wxApi.getLaunchOptionsSync();
|
||||
const code = WxSDK.extractShareCodeFromQuery(options?.query);
|
||||
if (code) {
|
||||
console.log('[WxSDK] 检测到分享码:', code);
|
||||
return code;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[WxSDK] 获取启动参数失败:', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从查询对象(来自 launch options 或 onShow 回调)中提取 shareCode
|
||||
*/
|
||||
static extractShareCodeFromQuery(query: Record<string, any> | null | undefined): string | null {
|
||||
const code = query?.shareCode;
|
||||
return typeof code === 'string' && code.length > 0 ? code : null;
|
||||
}
|
||||
|
||||
// ==================== 前后台生命周期 ====================
|
||||
|
||||
/**
|
||||
* 监听小游戏切到前台事件。
|
||||
* 同一个回调可重复注册多次:内部用 wx.onShow,请确保业务层做幂等处理或在卸载时调用 offAppShow。
|
||||
* @param callback 切前台时触发,包含本次显示对应的 query / scene 等参数
|
||||
*/
|
||||
static onAppShow(callback: (res: { query?: Record<string, any>; scene?: number; path?: string } | undefined) => void): void {
|
||||
const wxApi = WxSDK.getWx();
|
||||
if (!wxApi) return;
|
||||
|
||||
if (typeof wxApi.onShow !== 'function') {
|
||||
console.warn('[WxSDK] 当前微信版本不支持 onShow');
|
||||
return;
|
||||
}
|
||||
|
||||
wxApi.onShow(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消监听小游戏切到前台事件
|
||||
*/
|
||||
static offAppShow(callback: (res: any) => void): void {
|
||||
const wxApi = WxSDK.getWx();
|
||||
if (!wxApi) return;
|
||||
|
||||
if (typeof wxApi.offShow === 'function') {
|
||||
wxApi.offShow(callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听小游戏切到后台事件
|
||||
*/
|
||||
static onAppHide(callback: () => void): void {
|
||||
const wxApi = WxSDK.getWx();
|
||||
if (!wxApi) return;
|
||||
|
||||
if (typeof wxApi.onHide !== 'function') {
|
||||
console.warn('[WxSDK] 当前微信版本不支持 onHide');
|
||||
return;
|
||||
}
|
||||
|
||||
wxApi.onHide(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消监听小游戏切到后台事件
|
||||
*/
|
||||
static offAppHide(callback: () => void): void {
|
||||
const wxApi = WxSDK.getWx();
|
||||
if (!wxApi) return;
|
||||
|
||||
if (typeof wxApi.offHide === 'function') {
|
||||
wxApi.offHide(callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 隐私授权相关 ====================
|
||||
|
||||
/**
|
||||
* 检查用户是否已授权隐私
|
||||
* @returns Promise<{ needAuthorization: boolean }> needAuthorization 为 false 表示已授权
|
||||
*/
|
||||
export async function checkPrivacySetting(): Promise<{ needAuthorization: boolean }> {
|
||||
return WxSDK.checkPrivacySetting();
|
||||
}
|
||||
|
||||
/**
|
||||
* 引导用户进行隐私授权
|
||||
* 调用后会弹出微信隐私授权弹窗
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export async function requirePrivacyAuthorize(): Promise<void> {
|
||||
const authorized = await WxSDK.requirePrivacyAuthorize();
|
||||
if (!authorized) {
|
||||
throw new Error('隐私授权失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 用户信息相关 ====================
|
||||
|
||||
/**
|
||||
* 用户信息(头像、昵称)
|
||||
*/
|
||||
export interface WxUserInfo {
|
||||
avatarUrl: string;
|
||||
nickName: string;
|
||||
country?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
gender?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户头像和昵称(需要用户主动授权)
|
||||
* @returns Promise<WxUserInfo>
|
||||
*/
|
||||
export async function getUserProfile(): Promise<WxUserInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wxApi = WxSDK.getWx();
|
||||
if (!wxApi) {
|
||||
// 非微信环境,返回 mock 数据
|
||||
console.warn('[WxSDK] 非微信环境,返回 mock 用户信息');
|
||||
resolve({
|
||||
avatarUrl: '',
|
||||
nickName: '微信用户'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof wxApi.getUserProfile !== 'function') {
|
||||
console.warn('[WxSDK] 当前微信版本不支持 getUserProfile');
|
||||
reject(new Error('当前微信版本不支持 getUserProfile'));
|
||||
return;
|
||||
}
|
||||
|
||||
wxApi.getUserProfile({
|
||||
desc: '用于完善用户资料',
|
||||
success: (res: any) => {
|
||||
if (res.userInfo) {
|
||||
console.log('[WxSDK] 获取用户信息成功');
|
||||
resolve({
|
||||
avatarUrl: res.userInfo.avatarUrl,
|
||||
nickName: res.userInfo.nickName,
|
||||
country: res.userInfo.country,
|
||||
province: res.userInfo.province,
|
||||
city: res.userInfo.city,
|
||||
gender: res.userInfo.gender
|
||||
});
|
||||
} else {
|
||||
console.error('[WxSDK] 获取用户信息失败: 无 userInfo');
|
||||
reject(new Error('获取用户信息失败'));
|
||||
}
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.error('[WxSDK] 获取用户信息失败:', err);
|
||||
reject(new Error(err.errMsg || '获取用户信息失败'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 12.2 音频管理器 (assets/scripts/utils/AudioManager.ts)
|
||||
|
||||
```typescript
|
||||
import { AudioClip, AudioSource, Node } from 'cc';
|
||||
|
||||
/**
|
||||
* 音效管理器
|
||||
* 统一管理全局按钮点击等通用音效,避免页面各自维护 AudioSource。
|
||||
*/
|
||||
export class AudioManager {
|
||||
private static _instance: AudioManager | null = null;
|
||||
|
||||
private _clickAudio: AudioClip | null = null;
|
||||
private _hostNode: Node | null = null;
|
||||
private _audioSource: AudioSource | null = null;
|
||||
|
||||
static get instance(): AudioManager {
|
||||
if (!this._instance) {
|
||||
this._instance = new AudioManager();
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
private constructor() {}
|
||||
|
||||
init(clickAudio: AudioClip | null, hostNode: Node | null): void {
|
||||
this._clickAudio = clickAudio;
|
||||
this._hostNode = hostNode;
|
||||
this._audioSource = null;
|
||||
}
|
||||
|
||||
playButtonClick(): void {
|
||||
this._playOneShot(this._clickAudio);
|
||||
}
|
||||
|
||||
private _playOneShot(clip: AudioClip | null): void {
|
||||
if (!clip) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audioSource = this._ensureAudioSource();
|
||||
audioSource?.playOneShot(clip);
|
||||
}
|
||||
|
||||
private _ensureAudioSource(): AudioSource | null {
|
||||
if (this._audioSource?.isValid) {
|
||||
return this._audioSource;
|
||||
}
|
||||
|
||||
if (!this._hostNode?.isValid) {
|
||||
console.warn('[AudioManager] 未初始化宿主节点,无法播放音效');
|
||||
return null;
|
||||
}
|
||||
|
||||
this._audioSource = this._hostNode.getComponent(AudioSource) ?? this._hostNode.addComponent(AudioSource);
|
||||
return this._audioSource;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 12.3 成就称号配置 (assets/scripts/config/AchievementTitleConfig.ts)
|
||||
|
||||
```typescript
|
||||
export interface AchievementTitleConfigItem {
|
||||
readonly seriesName: string;
|
||||
readonly levelName: string;
|
||||
readonly clearsToNext: number;
|
||||
}
|
||||
|
||||
const createSeries = (seriesName: string, levelCount: number, clearsToNext: number): AchievementTitleConfigItem[] => {
|
||||
return Array.from({ length: levelCount }, (_, index) => ({
|
||||
seriesName,
|
||||
levelName: `${seriesName}${index + 1}级`,
|
||||
clearsToNext
|
||||
}));
|
||||
};
|
||||
|
||||
export const ACHIEVEMENT_TITLE_CONFIG: readonly AchievementTitleConfigItem[] = [
|
||||
...createSeries('冷场小白', 2, 3),
|
||||
...createSeries('尬笑学生', 2, 3),
|
||||
...createSeries('浅梗游民', 3, 4),
|
||||
...createSeries('热梗新秀', 6, 5),
|
||||
...createSeries('笑点刺客', 6, 5),
|
||||
...createSeries('爆梗高手', 8, 6),
|
||||
...createSeries('幽默大师', 10, 8),
|
||||
...createSeries('爆笑领主', 10, 8),
|
||||
...createSeries('梗王之王', 20, 8)
|
||||
];
|
||||
|
||||
export const INFINITE_ACHIEVEMENT_TITLE = {
|
||||
seriesName: '幽默始祖',
|
||||
levelNamePrefix: '幽默始祖',
|
||||
clearsToNext: 8
|
||||
} as const;
|
||||
```
|
||||
756
软著源代码/13-成就动画与通用Modal.md
Normal file
756
软著源代码/13-成就动画与通用Modal.md
Normal file
@@ -0,0 +1,756 @@
|
||||
# 梗中作乐 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
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user