From e2fd4eeda3af5d73fb00af64311a54785bce70bf Mon Sep 17 00:00:00 2001 From: richarjiang Date: Mon, 29 Jun 2026 19:55:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E6=BF=80=E5=8A=B1?= =?UTF-8?q?=E8=A7=86=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/prefabs/PageLevel.ts | 141 ++++- assets/scripts/utils/AudioManager.ts | 29 + assets/scripts/utils/WxSDK.ts | 137 +++-- 软著源代码/01-程序入口与视图管理.md | 660 ++++++++++++++++++++ 软著源代码/02-基类与类型定义.md | 609 ++++++++++++++++++ 软著源代码/03-认证存储与网络.md | 578 +++++++++++++++++ 软著源代码/04-首页与体力系统.md | 600 ++++++++++++++++++ 软著源代码/05-关卡核心逻辑.md | 715 ++++++++++++++++++++++ 软著源代码/06-关卡周边模块.md | 770 +++++++++++++++++++++++ 软著源代码/07-PK出题选关.md | 743 ++++++++++++++++++++++ 软著源代码/08-通关弹窗与提示.md | 710 +++++++++++++++++++++ 软著源代码/09-分享与预览.md | 704 +++++++++++++++++++++ 软著源代码/10-PK战绩与结算.md | 817 +++++++++++++++++++++++++ 软著源代码/11-PK详情与圆角.md | 646 +++++++++++++++++++ 软著源代码/12-微信SDK与音频成就配置.md | 673 ++++++++++++++++++++ 软著源代码/13-成就动画与通用Modal.md | 756 +++++++++++++++++++++++ 16 files changed, 9215 insertions(+), 73 deletions(-) create mode 100644 软著源代码/01-程序入口与视图管理.md create mode 100644 软著源代码/02-基类与类型定义.md create mode 100644 软著源代码/03-认证存储与网络.md create mode 100644 软著源代码/04-首页与体力系统.md create mode 100644 软著源代码/05-关卡核心逻辑.md create mode 100644 软著源代码/06-关卡周边模块.md create mode 100644 软著源代码/07-PK出题选关.md create mode 100644 软著源代码/08-通关弹窗与提示.md create mode 100644 软著源代码/09-分享与预览.md create mode 100644 软著源代码/10-PK战绩与结算.md create mode 100644 软著源代码/11-PK详情与圆角.md create mode 100644 软著源代码/12-微信SDK与音频成就配置.md create mode 100644 软著源代码/13-成就动画与通用Modal.md diff --git a/assets/prefabs/PageLevel.ts b/assets/prefabs/PageLevel.ts index d4a35ad..84729f6 100644 --- a/assets/prefabs/PageLevel.ts +++ b/assets/prefabs/PageLevel.ts @@ -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 { + 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 → 通关弹窗) - this.onSubmitAnswer(); + 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 tipsItem = this.getTipsItem(index); - if (tipsItem) { - this.playClueAppearAnimation(tipsItem); + 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; } - - // 推进到下一条待解锁线索 - this._nextClueIndex++; - - // 全部解锁完毕后不隐藏按钮,改为查看答案入口 - if (this._nextClueIndex > 3) { - this.setUnlockButtonText(PageLevel.UNLOCK_BUTTON_ANSWER_TEXT); - } - - console.log(`[PageLevel] 解锁线索${index}`); } /** * 点击增加时间按钮(倒计时增加 60 秒) */ - private onAddTime(): void { + private async onAddTime(): Promise { + 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 { + 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`); } diff --git a/assets/scripts/utils/AudioManager.ts b/assets/scripts/utils/AudioManager.ts index 2ff8e7b..6abb9ab 100644 --- a/assets/scripts/utils/AudioManager.ts +++ b/assets/scripts/utils/AudioManager.ts @@ -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; diff --git a/assets/scripts/utils/WxSDK.ts b/assets/scripts/utils/WxSDK.ts index 46a18ff..479878e 100644 --- a/assets/scripts/utils/WxSDK.ts +++ b/assets/scripts/utils/WxSDK.ts @@ -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 | null = null; @@ -315,6 +318,12 @@ export class WxSDK { /** 激励视频广告实例(复用) */ private static _rewardedVideoAd: any = null; + /** 当前复用实例对应的广告位 */ + private static _rewardedVideoAdUnitId: string = ''; + + /** 激励视频广告展示中的 Promise,避免重复拉起 */ + private static _rewardedVideoAdPromise: Promise | null = null; + /** * 展示激励视频广告 * 用户看完广告后返回 true,中途退出或失败返回 false @@ -322,68 +331,98 @@ export class WxSDK { * @param adUnitId 广告单元 ID(默认使用项目配置的 ID) * @returns Promise 是否看完广告 */ - static showRewardedVideoAd(adUnitId: string = ''): Promise { - return new Promise((resolve) => { - const wxApi = WxSDK.getWx(); - if (!wxApi) { - console.log('[WxSDK] 非微信环境,跳过激励视频广告'); - resolve(true); - return; - } + static showRewardedVideoAd(adUnitId: string = WxSDK.DEFAULT_REWARDED_VIDEO_AD_UNIT_ID): Promise { + if (WxSDK._rewardedVideoAdPromise) { + return WxSDK._rewardedVideoAdPromise; + } - if (typeof wxApi.createRewardedVideoAd !== 'function') { - console.warn('[WxSDK] 当前微信版本不支持激励视频广告'); - resolve(true); - return; - } + const wxApi = WxSDK.getWx(); + if (!wxApi) { + console.log('[WxSDK] 非微信环境,跳过激励视频广告'); + return Promise.resolve(true); + } + + if (typeof wxApi.createRewardedVideoAd !== 'function') { + console.warn('[WxSDK] 当前微信版本不支持激励视频广告'); + return Promise.resolve(true); + } + + const resolvedAdUnitId = adUnitId || WxSDK.DEFAULT_REWARDED_VIDEO_AD_UNIT_ID; + if (!resolvedAdUnitId) { + console.warn('[WxSDK] 激励视频广告位未配置'); + return Promise.resolve(false); + } + + WxSDK._rewardedVideoAdPromise = new Promise((resolve) => { + let ad: any = null; + let finished = false; + + const cleanup = () => { + if (!ad) return; + if (typeof ad.offClose === 'function') { + ad.offClose(onClose); + } + 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] 激励视频广告观看完成'); + finish(true); + } else { + console.log('[WxSDK] 激励视频广告中途退出'); + finish(false); + } + }; + + const onError = (err: any) => { + console.error('[WxSDK] 激励视频广告错误:', err); + finish(false); + }; 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 = 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; } // ==================== 启动参数 ==================== diff --git a/软著源代码/01-程序入口与视图管理.md b/软著源代码/01-程序入口与视图管理.md new file mode 100644 index 0000000..2492a25 --- /dev/null +++ b/软著源代码/01-程序入口与视图管理.md @@ -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 { + 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 { + 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 = new Map(); + + /** 页面栈 */ + private _viewStack: BaseView[] = []; + + /** 页面实例缓存(用于缓存模式的页面) */ + private _viewCache: Map = 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): 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?.(); + } + }); + } + } +} +``` diff --git a/软著源代码/02-基类与类型定义.md b/软著源代码/02-基类与类型定义.md new file mode 100644 index 0000000..a14b1fa --- /dev/null +++ b/软著源代码/02-基类与类型定义.md @@ -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 = 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 { + 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; +``` diff --git a/软著源代码/03-认证存储与网络.md b/软著源代码/03-认证存储与网络.md new file mode 100644 index 0000000..7bab0c1 --- /dev/null +++ b/软著源代码/03-认证存储与网络.md @@ -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 { + 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 { + 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>( + 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 { + 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 { + const gameData = await this._fetchGameData(); + if (gameData) { + this._completedLevelCount = gameData.completedLevelCount; + this._nextLevel = gameData.nextLevel; + StorageManager.setStamina(gameData.user.stamina); + } + } + + /** + * 从服务端获取游戏数据(共用方法) + */ + private async _fetchGameData(): Promise { + try { + const response = await HttpUtil.get>( + 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 + */ + static get(url: string, timeout: number = 10000): Promise { + 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 + */ + static post(url: string, data: object, timeout: number = 10000): Promise { + 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] 用户信息已清除'); + } +} +``` diff --git a/软著源代码/04-首页与体力系统.md b/软著源代码/04-首页与体力系统.md new file mode 100644 index 0000000..183a94e --- /dev/null +++ b/软著源代码/04-首页与体力系统.md @@ -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 { + return new Promise((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 { + if (!AuthManager.instance.isLoggedIn) { + console.warn('[StaminaManager] 未登录,无法进入关卡'); + return null; + } + + try { + const response = await HttpUtil.post>( + 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 { + if (!AuthManager.instance.isLoggedIn) { + console.warn('[StaminaManager] 未登录,无法上报通关'); + return null; + } + + try { + const response = await HttpUtil.post>( + 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); + } +} +``` diff --git a/软著源代码/05-关卡核心逻辑.md b/软著源代码/05-关卡核心逻辑.md new file mode 100644 index 0000000..b9132ae --- /dev/null +++ b/软著源代码/05-关卡核心逻辑.md @@ -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 = [ + [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 | null = null; + + /** pose 赞美延迟播放定时器;等待通关动画与成功音效结束后再触发 */ + private _posePraiseDelayTimer: ReturnType | null = null; + + /** pose 赞美延迟播放序号,用于取消 resources.load 异步回调里的过期播放 */ + private _posePraiseSequenceId: number = 0; + + /** good.mp3 加载缓存 */ + private _goodAudioClip: AudioClip | null = null; + + /** good.mp3 加载中的 Promise,避免重复请求 resources */ + private _goodAudioLoadPromise: Promise | 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 | 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 = 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 { + 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}`); +``` diff --git a/软著源代码/06-关卡周边模块.md b/软著源代码/06-关卡周边模块.md new file mode 100644 index 0000000..8a9951e --- /dev/null +++ b/软著源代码/06-关卡周边模块.md @@ -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 = new Map(); + + /** 图片缓存:URL -> SpriteFrame */ + private _imageCache: Map = new Map(); + + /** 正在加载中的关卡 ID 集合 */ + private _loadingLevels: Set = 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 { + 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 { + // 检查缓存 + 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 { + 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 { + // 检查缓存 + const cached = this._imageCache.get(url); + if (cached) { + return cached; + } + + return new Promise((resolve) => { + assetManager.loadRemote(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 = new Map(); + + /** 正在进行中的请求,用于去重并发调用 */ + private _inflight: Promise | 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 { + 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 { + try { + const response = await HttpUtil.get>( + 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 { + if (!url) return Promise.resolve(null); + + const cached = this._imageCache.get(url); + if (cached) { + return Promise.resolve(cached); + } + + return new Promise((resolve) => { + assetManager.loadRemote(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; + } +} +``` diff --git a/软著源代码/07-PK出题选关.md b/软著源代码/07-PK出题选关.md new file mode 100644 index 0000000..4660c93 --- /dev/null +++ b/软著源代码/07-PK出题选关.md @@ -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 = 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 { + 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 { + const selectedLevelIds = new Set(); + for (const index of this._selectedIndices) { + const level = this._levels[index] ?? null; + if (level) { + selectedLevelIds.add(level.id); + } + } + return selectedLevelIds; + } + + private _restoreSelectedIndices(selectedLevelIds: Set): 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 { + 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 { + 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 { + // 先检查本地缓存 + 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>( + 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(); + } +} +``` diff --git a/软著源代码/08-通关弹窗与提示.md b/软著源代码/08-通关弹窗与提示.md new file mode 100644 index 0000000..b3f0cd8 --- /dev/null +++ b/软著源代码/08-通关弹窗与提示.md @@ -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); + } +} +``` diff --git a/软著源代码/09-分享与预览.md b/软著源代码/09-分享与预览.md new file mode 100644 index 0000000..fc3e5e6 --- /dev/null +++ b/软著源代码/09-分享与预览.md @@ -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 { + 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 = 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 { + try { + const response = await HttpUtil.post>( + 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 { + try { + const response = await HttpUtil.post>( + 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 { + try { + const response = await HttpUtil.get>( + 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 { + try { + const response = await HttpUtil.get>( + 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 { + try { + const response = await HttpUtil.get>( + 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 { + 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 { + if (!this._shareCode) { + console.warn('[ShareManager] submitShareChallenge: 无分享码,跳过提交'); + return null; + } + + try { + const response = await HttpUtil.post>( + 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 { + const cached = this._imageCache.get(url); + if (cached) { + return Promise.resolve(cached); + } + + return new Promise((resolve) => { + assetManager.loadRemote(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 } | 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 { + 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; + } + } +} +``` diff --git a/软著源代码/10-PK战绩与结算.md b/软著源代码/10-PK战绩与结算.md new file mode 100644 index 0000000..fbc7535 --- /dev/null +++ b/软著源代码/10-PK战绩与结算.md @@ -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 { + 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(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(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; + } +} +``` diff --git a/软著源代码/11-PK详情与圆角.md b/软著源代码/11-PK详情与圆角.md new file mode 100644 index 0000000..f3e60de --- /dev/null +++ b/软著源代码/11-PK详情与圆角.md @@ -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 { + 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(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 = { 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; +}; +``` diff --git a/软著源代码/12-微信SDK与音频成就配置.md b/软著源代码/12-微信SDK与音频成就配置.md new file mode 100644 index 0000000..4c43557 --- /dev/null +++ b/软著源代码/12-微信SDK与音频成就配置.md @@ -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 | 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 登录 code + */ + static login(): Promise { + 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 { + 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 { + 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 { + 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 是否看完广告 + */ + static showRewardedVideoAd(adUnitId: string = ''): Promise { + 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 | 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; 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 + */ +export async function requirePrivacyAuthorize(): Promise { + 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 + */ +export async function getUserProfile(): Promise { + 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; +``` diff --git a/软著源代码/13-成就动画与通用Modal.md b/软著源代码/13-成就动画与通用Modal.md new file mode 100644 index 0000000..e9f3440 --- /dev/null +++ b/软著源代码/13-成就动画与通用Modal.md @@ -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 + }; + } +} +```