feat: 接入激励视频

This commit is contained in:
richarjiang
2026-06-29 19:55:40 +08:00
parent 411ca8c772
commit e2fd4eeda3
16 changed files with 9215 additions and 73 deletions

View File

@@ -340,6 +340,12 @@ export class PageLevel extends BaseView {
/** 是否正在解锁提示(防止双击重复触发) */ /** 是否正在解锁提示(防止双击重复触发) */
private _isUnlocking: boolean = false; private _isUnlocking: boolean = false;
/** 是否正在观看加时广告(防止双击重复触发) */
private _isAddingTime: boolean = false;
/** 激励视频广告期间是否暂停了倒计时 */
private _isCountdownPausedForRewardAd: boolean = false;
/** 下一个待解锁的线索序号2 或 3超过 3 表示全部已解锁 */ /** 下一个待解锁的线索序号2 或 3超过 3 表示全部已解锁 */
private _nextClueIndex: number = 2; private _nextClueIndex: number = 2;
@@ -1237,8 +1243,11 @@ export class PageLevel extends BaseView {
/** /**
* 点击解锁线索顺序解锁先线索2再线索3全部解锁后切换为查看答案入口 * 点击解锁线索顺序解锁先线索2再线索3全部解锁后切换为查看答案入口
*/ */
private onUnlockClue(): void { private async onUnlockClue(): Promise<void> {
// 全部已解锁后,点击"查看答案":自动填入正确答案并走通关流程 if (this._isUnlocking) {
return;
}
if (this._nextClueIndex > 3) { if (this._nextClueIndex > 3) {
if (this._isTransitioning) return; if (this._isTransitioning) return;
@@ -1251,11 +1260,24 @@ export class PageLevel extends BaseView {
this.playClickSound(); this.playClickSound();
console.log('[PageLevel] 点击查看答案,自动填充答案并触发通关流程'); console.log('[PageLevel] 点击查看答案,自动填充答案并触发通关流程');
// 填充答案到输入格distributeInputText 内部会用 _isSyncingInputText 阻止 EditBox 事件回调) this._isUnlocking = true;
this.distributeInputText(answer); try {
const rewarded = await this.showRewardedVideoAd('查看答案');
if (!rewarded || this._isTransitioning) {
return;
}
// 走提交流程(答案命中 → showSuccess → 通关弹窗) const latestAnswer = this._currentConfig?.answer;
if (!latestAnswer) {
ToastManager.show('答案暂未配置');
return;
}
this.distributeInputText(latestAnswer);
this.onSubmitAnswer(); this.onSubmitAnswer();
} finally {
this._isUnlocking = false;
}
return; return;
} }
@@ -1269,45 +1291,116 @@ export class PageLevel extends BaseView {
} }
this.playClickSound(); this.playClickSound();
this.setClue(index, clueContent); this._isUnlocking = true;
try {
const rewarded = await this.showRewardedVideoAd('查看线索');
if (!rewarded || this._isTransitioning || this._nextClueIndex !== index) {
return;
}
const latestClueContent = index === 2 ? this._currentConfig?.clue2 : this._currentConfig?.clue3;
if (!latestClueContent) {
ToastManager.show('该提示暂未配置');
return;
}
this.setClue(index, latestClueContent);
// 解锁线索后播放"出现"动画,让内容刷新不突兀
const tipsItem = this.getTipsItem(index); const tipsItem = this.getTipsItem(index);
if (tipsItem) { if (tipsItem) {
this.playClueAppearAnimation(tipsItem); this.playClueAppearAnimation(tipsItem);
} }
// 推进到下一条待解锁线索
this._nextClueIndex++; this._nextClueIndex++;
// 全部解锁完毕后不隐藏按钮,改为查看答案入口
if (this._nextClueIndex > 3) { if (this._nextClueIndex > 3) {
this.setUnlockButtonText(PageLevel.UNLOCK_BUTTON_ANSWER_TEXT); this.setUnlockButtonText(PageLevel.UNLOCK_BUTTON_ANSWER_TEXT);
} }
console.log(`[PageLevel] 解锁线索${index}`); console.log(`[PageLevel] 解锁线索${index}`);
} finally {
this._isUnlocking = false;
}
} }
/** /**
* 点击增加时间按钮(倒计时增加 60 秒) * 点击增加时间按钮(倒计时增加 60 秒)
*/ */
private onAddTime(): void { private async onAddTime(): Promise<void> {
if (this._isAddingTime) {
return;
}
if (this._isTimeUp) { if (this._isTimeUp) {
ToastManager.show('时间已结束,无法增加'); ToastManager.show('时间已结束,无法增加');
return; return;
} }
this.playClickSound();
this._isAddingTime = true;
try {
const rewarded = await this.showRewardedVideoAd('加时');
if (!rewarded) {
return;
}
if (this._isTimeUp) {
ToastManager.show('时间已结束,无法增加');
return;
}
this.addCountdownTime();
} finally {
this._isAddingTime = false;
}
}
private async showRewardedVideoAd(rewardName: string): Promise<boolean> {
this.pauseLevelForRewardedVideoAd();
try {
const rewarded = await WxSDK.showRewardedVideoAd();
if (!rewarded) {
ToastManager.show(`${rewardName}需要看完广告`);
}
return rewarded;
} finally {
this.resumeLevelAfterRewardedVideoAd();
}
}
private pauseLevelForRewardedVideoAd(): void {
this._isCountdownPausedForRewardAd = false;
if (!this._isTimeUp && !this._isTransitioning && this._countdown > 0) {
this.stopCountdown();
this._isCountdownPausedForRewardAd = true;
}
AudioManager.instance.pausePlayingAudioSources();
}
private resumeLevelAfterRewardedVideoAd(): void {
AudioManager.instance.resumePausedAudioSources();
if (!this._isCountdownPausedForRewardAd) {
return;
}
this._isCountdownPausedForRewardAd = false;
if (this.node?.isValid && !this._isTimeUp && !this._isTransitioning && this._countdown > 0) {
this.schedule(this.onCountdownTick, 1);
}
}
private addCountdownTime(): void {
const wasUrgent = this._countdown <= PageLevel.CLOCK_URGENT_THRESHOLD; const wasUrgent = this._countdown <= PageLevel.CLOCK_URGENT_THRESHOLD;
this._countdown += 60; this._countdown += 60;
// 从紧迫态跳回安全区:停掉残留脉冲并复位 scaleupdateClockLabel 会负责把颜色改回)
if (wasUrgent && this._countdown > PageLevel.CLOCK_URGENT_THRESHOLD && this.clockLabel) { if (wasUrgent && this._countdown > PageLevel.CLOCK_URGENT_THRESHOLD && this.clockLabel) {
Tween.stopAllByTarget(this.clockLabel.node); Tween.stopAllByTarget(this.clockLabel.node);
this.clockLabel.node.setScale(1, 1, 1); this.clockLabel.node.setScale(1, 1, 1);
} }
this.updateClockLabel(); this.updateClockLabel();
this.playClickSound();
ToastManager.show('已成功增加60秒'); ToastManager.show('已成功增加60秒');
console.log(`[PageLevel] 增加60秒倒计时当前剩余: ${this._countdown}s`); console.log(`[PageLevel] 增加60秒倒计时当前剩余: ${this._countdown}s`);
} }

View File

@@ -10,6 +10,7 @@ export class AudioManager {
private _clickAudio: AudioClip | null = null; private _clickAudio: AudioClip | null = null;
private _hostNode: Node | null = null; private _hostNode: Node | null = null;
private _audioSource: AudioSource | null = null; private _audioSource: AudioSource | null = null;
private _pausedAudioSources: AudioSource[] = [];
static get instance(): AudioManager { static get instance(): AudioManager {
if (!this._instance) { if (!this._instance) {
@@ -30,6 +31,34 @@ export class AudioManager {
this._playOneShot(this._clickAudio); 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 { private _playOneShot(clip: AudioClip | null): void {
if (!clip) { if (!clip) {
return; return;

View File

@@ -34,6 +34,9 @@ export interface WxPrivacySettingResult {
* 封装微信平台相关 API非微信环境下静默降级 * 封装微信平台相关 API非微信环境下静默降级
*/ */
export class WxSDK { export class WxSDK {
/** 默认激励视频广告位 */
private static readonly DEFAULT_REWARDED_VIDEO_AD_UNIT_ID = 'adunit-52a53828d4b91fe2';
/** 隐私授权请求进行中时复用同一个 Promise避免启动链路重复弹窗 */ /** 隐私授权请求进行中时复用同一个 Promise避免启动链路重复弹窗 */
private static _privacyAuthorizePromise: Promise<boolean> | null = null; private static _privacyAuthorizePromise: Promise<boolean> | null = null;
@@ -315,6 +318,12 @@ export class WxSDK {
/** 激励视频广告实例(复用) */ /** 激励视频广告实例(复用) */
private static _rewardedVideoAd: any = null; private static _rewardedVideoAd: any = null;
/** 当前复用实例对应的广告位 */
private static _rewardedVideoAdUnitId: string = '';
/** 激励视频广告展示中的 Promise避免重复拉起 */
private static _rewardedVideoAdPromise: Promise<boolean> | null = null;
/** /**
* 展示激励视频广告 * 展示激励视频广告
* 用户看完广告后返回 true中途退出或失败返回 false * 用户看完广告后返回 true中途退出或失败返回 false
@@ -322,68 +331,98 @@ export class WxSDK {
* @param adUnitId 广告单元 ID默认使用项目配置的 ID * @param adUnitId 广告单元 ID默认使用项目配置的 ID
* @returns Promise<boolean> 是否看完广告 * @returns Promise<boolean> 是否看完广告
*/ */
static showRewardedVideoAd(adUnitId: string = ''): Promise<boolean> { static showRewardedVideoAd(adUnitId: string = WxSDK.DEFAULT_REWARDED_VIDEO_AD_UNIT_ID): Promise<boolean> {
return new Promise((resolve) => { if (WxSDK._rewardedVideoAdPromise) {
return WxSDK._rewardedVideoAdPromise;
}
const wxApi = WxSDK.getWx(); const wxApi = WxSDK.getWx();
if (!wxApi) { if (!wxApi) {
console.log('[WxSDK] 非微信环境,跳过激励视频广告'); console.log('[WxSDK] 非微信环境,跳过激励视频广告');
resolve(true); return Promise.resolve(true);
return;
} }
if (typeof wxApi.createRewardedVideoAd !== 'function') { if (typeof wxApi.createRewardedVideoAd !== 'function') {
console.warn('[WxSDK] 当前微信版本不支持激励视频广告'); console.warn('[WxSDK] 当前微信版本不支持激励视频广告');
resolve(true); return Promise.resolve(true);
return;
} }
try { const resolvedAdUnitId = adUnitId || WxSDK.DEFAULT_REWARDED_VIDEO_AD_UNIT_ID;
// 复用或创建广告实例 if (!resolvedAdUnitId) {
if (!WxSDK._rewardedVideoAd) { console.warn('[WxSDK] 激励视频广告位未配置');
WxSDK._rewardedVideoAd = wxApi.createRewardedVideoAd({ return Promise.resolve(false);
adUnitId: adUnitId,
});
} }
const ad = WxSDK._rewardedVideoAd; WxSDK._rewardedVideoAdPromise = new Promise((resolve) => {
let ad: any = null;
let finished = false;
// 定义关闭回调(一次性) const cleanup = () => {
const onClose = (res: any) => { if (!ad) return;
if (typeof ad.offClose === 'function') {
ad.offClose(onClose); ad.offClose(onClose);
if (res && res.isEnded) { }
if (typeof ad.offError === 'function') {
ad.offError(onError);
}
};
const finish = (result: boolean) => {
if (finished) return;
finished = true;
cleanup();
WxSDK._rewardedVideoAdPromise = null;
resolve(result);
};
const onClose = (res: any) => {
const isEnded = res === undefined || !!res?.isEnded;
if (isEnded) {
console.log('[WxSDK] 激励视频广告观看完成'); console.log('[WxSDK] 激励视频广告观看完成');
resolve(true); finish(true);
} else { } else {
console.log('[WxSDK] 激励视频广告中途退出'); console.log('[WxSDK] 激励视频广告中途退出');
resolve(false); finish(false);
} }
}; };
// 定义错误回调(一次性)
const onError = (err: any) => { const onError = (err: any) => {
ad.offError(onError);
ad.offClose(onClose);
console.error('[WxSDK] 激励视频广告错误:', err); console.error('[WxSDK] 激励视频广告错误:', err);
resolve(false); finish(false);
}; };
try {
ad = WxSDK.getRewardedVideoAd(wxApi, resolvedAdUnitId);
ad.onClose(onClose); ad.onClose(onClose);
ad.onError(onError); ad.onError(onError);
// 先尝试 show如果广告未加载则先 load Promise.resolve(ad.show()).catch(() => {
ad.show().catch(() => { Promise.resolve(ad.load()).then(() => ad.show()).catch((loadErr: any) => {
ad.load().then(() => ad.show()).catch((loadErr: any) => {
ad.offClose(onClose);
ad.offError(onError);
console.error('[WxSDK] 激励视频广告加载失败:', loadErr); console.error('[WxSDK] 激励视频广告加载失败:', loadErr);
resolve(false); finish(false);
}); });
}); });
} catch (err) { } catch (err) {
console.error('[WxSDK] 激励视频广告异常:', 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;
} }
// ==================== 启动参数 ==================== // ==================== 启动参数 ====================

View File

@@ -0,0 +1,660 @@
# 梗中作乐 V1.0 - 源代码文档(第 1 部分)
软件名称:梗中作乐
版本号V1.0
---
## 第 1 部分:程序入口与视图管理
本部分包含 3 个源文件,是整个小游戏的启动入口、加载页与全局视图调度器。
### 1.1 程序主入口 (assets/main.ts)
```typescript
import { _decorator, Component, Prefab, AudioClip } from 'cc';
import { ViewManager } from './scripts/core/ViewManager';
import { ToastManager } from './scripts/utils/ToastManager';
import { AudioManager } from './scripts/utils/AudioManager';
import { ShareLaunchHandler } from './scripts/utils/ShareLaunchHandler';
const { ccclass, property } = _decorator;
/**
* 主入口脚本
* 负责初始化 ViewManager 并注册页面
*/
@ccclass('main')
export class main extends Component {
@property({ type: Prefab, tooltip: '首页预制体' })
pageHomePrefab: Prefab | null = null;
@property({ type: Prefab, tooltip: '关卡页面预制体' })
pageLevelPrefab: Prefab | null = null;
@property({ type: Prefab, tooltip: '写关卡页面预制体' })
pageWriteLevelsPrefab: Prefab | null = null;
@property({ type: Prefab, tooltip: '预览试卷页面预制体' })
pagePreviewLevelsPrefab: Prefab | null = null;
@property({ type: Prefab, tooltip: '挑战数据页面预制体' })
pagePKDataPrefab: Prefab | null = null;
@property({ type: Prefab, tooltip: '挑战详情页面预制体' })
pagePKDetailPrefab: Prefab | null = null;
@property({ type: Prefab, tooltip: '挑战结算页面预制体' })
pagePKEndPrefab: Prefab | null = null;
@property({ type: Prefab, tooltip: 'Toast 预制体' })
toastPrefab: Prefab | null = null;
@property({ type: AudioClip, tooltip: '通用按钮点击音效' })
buttonClickAudio: AudioClip | null = null;
/**
* onLoad 比 start 更早执行
* 确保 ViewManager 在 PageLoading.start() 之前初始化
*/
onLoad() {
this._initViewManager();
}
/**
* 初始化页面管理器
*/
private _initViewManager(): void {
// 初始化 ViewManager绑定 Canvas 作为页面容器
ViewManager.instance.init(this.node);
// 注册页面配置(通过编辑器属性引用预制体)
if (this.pageHomePrefab) {
ViewManager.instance.register('PageHome', {
prefab: this.pageHomePrefab,
cache: true,
zIndex: 0
});
}
if (this.pageLevelPrefab) {
ViewManager.instance.register('PageLevel', {
prefab: this.pageLevelPrefab,
cache: true,
zIndex: 1
});
}
if (this.pageWriteLevelsPrefab) {
ViewManager.instance.register('PageWriteLevels', {
prefab: this.pageWriteLevelsPrefab,
cache: true,
zIndex: 2
});
}
if (this.pagePreviewLevelsPrefab) {
ViewManager.instance.register('PagePreviewLevels', {
prefab: this.pagePreviewLevelsPrefab,
cache: true,
zIndex: 3
});
}
if (this.pagePKDataPrefab) {
ViewManager.instance.register('PagePKData', {
prefab: this.pagePKDataPrefab,
cache: true,
zIndex: 3
});
}
if (this.pagePKDetailPrefab) {
ViewManager.instance.register('PagePKDetail', {
prefab: this.pagePKDetailPrefab,
cache: true,
zIndex: 4
});
}
if (this.pagePKEndPrefab) {
ViewManager.instance.register('PagePKEnd', {
prefab: this.pagePKEndPrefab,
cache: true,
zIndex: 3
});
}
// 初始化 Toast 管理器
if (this.toastPrefab) {
ToastManager.instance.init(this.toastPrefab, this.node);
}
AudioManager.instance.init(this.buttonClickAudio, this.node);
// 注册 wx.onShow / wx.onHide
// 用户把小游戏退到后台后再点击好友分享卡片,能拿到最新的 shareCode 并直达分享挑战关卡。
// 必须在 PageLoading 跑之前注册,这样初始 launch 中的 shareCode 也会被作为种子记下。
ShareLaunchHandler.instance.init();
}
}
```
### 1.2 加载页 (assets/PageLoading.ts)
```typescript
import { _decorator, Component, ProgressBar, Label, assetManager } from 'cc';
import type { AssetManager } from 'cc';
import { ViewManager } from './scripts/core/ViewManager';
import { LevelDataManager } from './scripts/utils/LevelDataManager';
import { AuthManager } from './scripts/utils/AuthManager';
import { ShareManager } from './scripts/utils/ShareManager';
import { ShareLaunchHandler } from './scripts/utils/ShareLaunchHandler';
import { WxSDK } from './scripts/utils/WxSDK';
const { ccclass, property } = _decorator;
/**
* 页面加载组件
* 负责用户登录、预加载资源并显示加载进度
* 流程:登录 + game-data → 拿到 nextLevel → 加载首关图片 → 进入首页
*/
@ccclass('PageLoading')
export class PageLoading extends Component {
private static readonly FONT_BUNDLE_NAME = 'fonts';
@property(ProgressBar)
progressBar: ProgressBar | null = null;
@property(Label)
statusLabel: Label | null = null;
start() {
this._startPreload();
}
private async _startPreload(): Promise<void> {
if (this.progressBar) {
this.progressBar.progress = 0;
}
this._updateStatusLabel('正在加载...');
// 阶段0: 主动触发微信隐私授权。分享挑战直达关卡时也会先经过这里。
this._updateStatusLabel('正在确认隐私授权...');
const privacyAuthorized = await WxSDK.ensurePrivacyAuthorized();
if (!privacyAuthorized) {
console.warn('[PageLoading] 用户未同意隐私授权,继续加载基础流程');
}
// 阶段1: 登录 + 获取 game-data含 nextLevel
this._updateProgress(0);
this._updateStatusLabel('正在连接服务器...');
const loginSuccess = await AuthManager.instance.initialize();
if (loginSuccess) {
console.log('[PageLoading] 用户登录成功');
} else {
console.warn('[PageLoading] 登录失败,继续离线模式');
}
this._updateProgress(0.2);
// 阶段2: 加载首关图片(如果有 nextLevel
const nextLevel = AuthManager.instance.nextLevel;
let levelSuccess = false;
if (nextLevel) {
levelSuccess = await LevelDataManager.instance.initialize(nextLevel, (progress, message) => {
// 关卡图片加载占 20%-80% 进度
this._updateProgress(0.2 + progress * 0.6);
this._updateStatusLabel(message);
});
if (!levelSuccess) {
this._updateStatusLabel('资源加载失败,请重新打开游戏');
return;
}
} else if (loginSuccess) {
// nextLevel 为 null → 全部通关(或服务端无关卡)
console.log('[PageLoading] 全部通关或无可用关卡');
this._updateProgress(0.8);
} else {
// 登录失败且没有 nextLevel
this._updateStatusLabel('加载失败,请重新打开游戏');
return;
}
// 阶段3: 加载字体分包
const fontSuccess = await this._loadFontBundle();
if (!fontSuccess) {
this._updateStatusLabel('字体资源加载失败,请重新打开游戏');
return;
}
// 检测分享码:从微信启动参数中获取
const shareCode = WxSDK.getShareCodeFromLaunch();
if (shareCode && loginSuccess) {
this._updateStatusLabel('正在加载挑战关卡...');
const joinSuccess = await ShareManager.instance.joinShare(shareCode);
if (joinSuccess) {
// 把启动 shareCode 同步给 ShareLaunchHandler
// 避免 wx.onShow 在初始展示时拿到同一个 code 又走一遍 join。
ShareLaunchHandler.instance.markActiveShareCode(shareCode);
this._updateProgress(1);
this._updateStatusLabel('加载完成');
// 跳过首页,直接进入分享挑战关卡
ViewManager.instance.open('PageLevel', {
params: { shareMode: true },
onComplete: () => {
this.node.destroy();
},
});
return;
}
console.warn('[PageLoading] 加入分享失败,进入正常模式');
}
// 正常流程:预加载 PageHome (82-100%)
ViewManager.instance.preload('PageHome',
(progress) => {
this._updateProgress(0.82 + progress * 0.18);
this._updateStatusLabel('正在加载界面资源...');
},
() => {
this._onPreloadComplete();
}
);
}
private _updateProgress(progress: number): void {
if (this.progressBar) {
this.progressBar.progress = progress;
}
}
private _updateStatusLabel(message: string): void {
if (this.statusLabel) {
this.statusLabel.string = message;
}
}
private _onPreloadComplete(): void {
this._updateProgress(1);
this._updateStatusLabel('加载完成');
// 兜底:如果在 PageLoading 还在 preload 期间wx.onShow 已经把游戏切到了分享态
// ShareLaunchHandler 已经 joinShare 成功),这里就不应再覆盖回首页,
// 否则用户点了好友分享卡片却看到 PageHome。
if (ShareManager.instance.isShareMode) {
console.log('[PageLoading] 检测到分享态已激活,跳过首页直达 PageLevel');
ViewManager.instance.open('PageLevel', {
params: { shareMode: true },
onComplete: () => {
this.node.destroy();
},
});
return;
}
ViewManager.instance.open('PageHome', {
onComplete: () => {
this.node.destroy();
}
});
}
/**
* 加载字体分包,避免字体资源进入小游戏主包
*/
private _loadFontBundle(): Promise<boolean> {
const bundleName = PageLoading.FONT_BUNDLE_NAME;
const cachedBundle = assetManager.getBundle(bundleName);
if (cachedBundle) {
console.log(`[PageLoading] 字体分包已加载: ${bundleName}`);
this._updateProgress(0.82);
return Promise.resolve(true);
}
this._updateStatusLabel('正在加载字体资源...');
this._updateProgress(0.8);
return new Promise((resolve) => {
assetManager.loadBundle(bundleName, (err: Error | null, bundle: AssetManager.Bundle | null) => {
if (err || !bundle) {
console.error(`[PageLoading] 字体分包加载失败: ${bundleName}`, err);
resolve(false);
return;
}
console.log(`[PageLoading] 字体分包加载完成: ${bundleName}`);
this._updateProgress(0.82);
resolve(true);
});
});
}
}
```
### 1.3 视图管理器 (assets/scripts/core/ViewManager.ts)
```typescript
import { _decorator, Node, Prefab, instantiate, error } from 'cc';
import { BaseView, ViewConfig, ViewOptions } from './BaseView';
const { ccclass } = _decorator;
/**
* 已注册的页面配置映射
*/
interface RegisteredView {
config: ViewConfig;
}
/**
* 页面管理器
* 单例模式,统一管理页面的注册、打开、关闭、返回等操作
*/
@ccclass('ViewManager')
export class ViewManager {
/** 单例实例 */
private static _instance: ViewManager | null = null;
/** 获取单例 */
public static get instance(): ViewManager {
if (!ViewManager._instance) {
ViewManager._instance = new ViewManager();
}
return ViewManager._instance;
}
/** 页面容器节点 */
private _container: Node | null = null;
/** 已注册的页面配置 */
private _registeredViews: Map<string, RegisteredView> = new Map();
/** 页面栈 */
private _viewStack: BaseView[] = [];
/** 页面实例缓存(用于缓存模式的页面) */
private _viewCache: Map<string, BaseView> = new Map();
/**
* 初始化管理器
* @param container 页面容器节点(通常是 Canvas
*/
init(container: Node): void {
this._container = container;
}
/**
* 获取当前容器
*/
getContainer(): Node | null {
return this._container;
}
/**
* 注册页面
* @param viewId 页面唯一标识
* @param config 页面配置
*/
register(viewId: string, config: ViewConfig): void {
if (this._registeredViews.has(viewId)) {
error(`ViewManager: 页面 "${viewId}" 已注册`);
return;
}
this._registeredViews.set(viewId, {
config: {
cache: true, // 默认缓存
zIndex: 0, // 默认层级
...config
}
});
}
/**
* 批量注册页面
* @param views 页面配置映射
*/
registerAll(views: Record<string, ViewConfig>): void {
for (const [viewId, config] of Object.entries(views)) {
this.register(viewId, config);
}
}
/**
* 打开页面
* @param viewId 页面唯一标识
* @param options 打开选项
*/
open(viewId: string, options?: ViewOptions): void {
if (!this._container) {
const err = new Error('ViewManager: 未初始化,请先调用 init()');
options?.onError?.(err);
return;
}
const registered = this._registeredViews.get(viewId);
if (!registered) {
const err = new Error(`ViewManager: 页面 "${viewId}" 未注册`);
options?.onError?.(err);
return;
}
// 检查是否有缓存的实例
const cachedView = this._viewCache.get(viewId);
if (cachedView && cachedView.node.isValid) {
this._showView(cachedView, options);
return;
}
// 直接使用预制体引用实例化
this._instantiateView(viewId, registered.config.prefab, options);
}
/**
* 实例化视图
*/
private _instantiateView(viewId: string, prefab: Prefab, options?: ViewOptions): void {
if (!this._container) return;
const registered = this._registeredViews.get(viewId);
if (!registered) return;
const node = instantiate(prefab);
const view = node.getComponent(BaseView);
if (!view) {
error(`ViewManager: 预制体 "${viewId}" 缺少 BaseView 组件`);
node.destroy();
options?.onError?.(new Error('缺少 BaseView 组件'));
return;
}
// 设置视图属性
view.viewId = viewId;
view.config = registered.config;
view.setParams(options?.params);
// 设置层级
node.setSiblingIndex(registered.config.zIndex || 0);
// 添加到容器
this._container.addChild(node);
// 调用加载回调
view.onViewLoad();
// 缓存视图实例
if (registered.config.cache) {
this._viewCache.set(viewId, view);
}
// 显示视图
this._showView(view, options);
}
/**
* 显示视图
*/
private _showView(view: BaseView, options?: ViewOptions): void {
// 隐藏当前页面
const currentView = this.getCurrentView();
if (currentView && currentView !== view) {
currentView._doHide();
}
// 设置参数
if (options?.params !== undefined) {
view.setParams(options?.params);
}
// 入栈
if (!this._viewStack.includes(view)) {
this._viewStack.push(view);
}
// 显示
view._doShow();
// 回调
options?.onComplete?.(view);
}
/**
* 关闭当前页面
* @param options 关闭选项
*/
close(options?: { destroy?: boolean }): void {
const currentView = this._viewStack.pop();
if (!currentView) return;
const shouldDestroy = options?.destroy ?? !currentView.config?.cache;
this._hideAndDestroyView(currentView, shouldDestroy);
// 显示上一页
const prevView = this.getCurrentView();
if (prevView) {
prevView._doShow();
}
}
/**
* 返回上一页close 的别名)
*/
back(): void {
this.close();
}
/**
* 替换当前页面
* @param viewId 新页面标识
* @param options 打开选项
*/
replace(viewId: string, options?: ViewOptions): void {
const currentView = this.getCurrentView();
if (currentView) {
this._viewStack.pop();
const shouldDestroy = !currentView.config?.cache;
this._hideAndDestroyView(currentView, shouldDestroy);
}
this.open(viewId, options);
}
/**
* 隐藏并销毁视图(内部方法)
*/
private _hideAndDestroyView(view: BaseView, shouldDestroy: boolean): void {
view._doHide();
if (shouldDestroy) {
this._viewCache.delete(view.viewId);
view._doDestroy();
}
}
/**
* 获取当前页面
*/
getCurrentView(): BaseView | null {
return this._viewStack.length > 0
? this._viewStack[this._viewStack.length - 1]
: null;
}
/**
* 获取页面栈
*/
getViewStack(): BaseView[] {
return [...this._viewStack];
}
/**
* 清空所有页面
*/
clearAll(): void {
// 从栈顶开始销毁
while (this._viewStack.length > 0) {
const view = this._viewStack.pop()!;
const shouldDestroy = !view.config?.cache;
this._hideAndDestroyView(view, shouldDestroy);
}
// 销毁缓存的页面
for (const view of this._viewCache.values()) {
if (view.node.isValid) {
view._doDestroy();
}
}
this._viewCache.clear();
}
/**
* 预加载页面预制体(主包资源已随游戏加载,此方法仅为兼容性保留)
* @param viewId 页面标识
* @param onProgress 进度回调
* @param onComplete 完成回调
*/
preload(viewId: string, onProgress?: (progress: number) => void, onComplete?: () => void): void {
const registered = this._registeredViews.get(viewId);
if (!registered) {
error(`ViewManager: 页面 "${viewId}" 未注册`);
onComplete?.();
return;
}
// 主包资源已加载,直接回调
onProgress?.(1);
onComplete?.();
}
/**
* 批量预加载页面
* @param viewIds 页面标识数组
* @param onProgress 总进度回调 (0-1)
* @param onComplete 完成回调
*/
preloadAll(viewIds: string[], onProgress?: (progress: number) => void, onComplete?: () => void): void {
if (viewIds.length === 0) {
onProgress?.(1);
onComplete?.();
return;
}
let completed = 0;
const total = viewIds.length;
for (const viewId of viewIds) {
this.preload(viewId, () => {
completed++;
onProgress?.(completed / total);
if (completed === total) {
onComplete?.();
}
});
}
}
}
```

View File

@@ -0,0 +1,609 @@
# 梗中作乐 V1.0 - 源代码文档(第 2 部分)
软件名称:梗中作乐
版本号V1.0
---
## 第 2 部分:基类与类型定义
本部分包含 5 个源文件:页面 / 弹窗的基类,以及全项目共用的 API 类型与配置常量。
### 2.1 视图基类 (assets/scripts/core/BaseView.ts)
```typescript
import { _decorator, Component, Prefab } from 'cc';
const { ccclass } = _decorator;
/**
* 页面配置接口
*/
export interface ViewConfig {
prefab: Prefab; // 预制体引用(主包资源)
cache?: boolean; // 是否缓存页面,默认 true
zIndex?: number; // 层级,默认 0
}
/**
* 页面打开选项
*/
export interface ViewOptions {
params?: any; // 传递给页面的参数
onComplete?: (view: BaseView) => void;
onError?: (err: Error) => void;
}
/**
* 页面基类
* 所有页面组件应继承此类,实现生命周期方法
*/
@ccclass('BaseView')
export class BaseView extends Component {
/** 页面唯一标识 */
viewId: string = '';
/** 页面配置 */
config: ViewConfig | null = null;
/** 是否正在显示 */
isShowing: boolean = false;
/** 传递给页面的参数 */
protected _params: any = null;
/**
* 设置页面参数
*/
setParams(params: any): void {
this._params = params;
}
/**
* 获取页面参数
*/
getParams(): any {
return this._params;
}
/**
* 页面加载时调用(首次创建时)
* 子类应重写此方法
*/
onViewLoad(): void {
// 子类实现
}
/**
* 页面显示时调用(每次打开时)
* 子类应重写此方法
*/
onViewShow(): void {
// 子类实现
}
/**
* 页面隐藏时调用(关闭或被其他页面覆盖时)
* 子类应重写此方法
*/
onViewHide(): void {
// 子类实现
}
/**
* 页面销毁时调用
* 子类应重写此方法
*/
onViewDestroy(): void {
// 子类实现
}
// ========== 内部方法,由 ViewManager 调用 ==========
/**
* 内部方法:执行显示逻辑
*/
_doShow(): void {
if (this.isShowing) return;
this.isShowing = true;
this.node.active = true;
this.onViewShow();
}
/**
* 内部方法:执行隐藏逻辑
*/
_doHide(): void {
if (!this.isShowing) return;
this.isShowing = false;
this.onViewHide();
this.node.active = false;
}
/**
* 内部方法:执行销毁逻辑
*/
_doDestroy(): void {
// 标记已销毁,防止 onDestroy 中重复调用
this._destroyed = true;
this.node.destroy();
}
/** 是否已标记销毁 */
private _destroyed: boolean = false;
// ========== Cocos 生命周期 ==========
protected onDestroy(): void {
// 仅在未被 _doDestroy 调用时执行生命周期
if (!this._destroyed) {
if (this.isShowing) {
this.onViewHide();
}
}
this.onViewDestroy();
}
}
```
### 2.2 弹窗基类 (assets/scripts/core/BaseModal.ts)
```typescript
import { _decorator, Node, Tween, UIOpacity, Vec3, tween } from 'cc';
import { BaseView } from './BaseView';
const { ccclass, property } = _decorator;
@ccclass('BaseModal')
export class BaseModal extends BaseView {
@property([Node])
protected animationNodes: Node[] = [];
@property(Node)
protected backdropNode: Node | null = null;
@property
protected openAnimationEnabled: boolean = true;
@property
protected openAnimationDuration: number = 0.36;
private readonly _originalScales: Map<Node, Vec3> = new Map();
onViewShow(): void {
this.playOpenAnimation();
}
onViewHide(): void {
this.stopOpenAnimation();
}
protected playOpenAnimation(): void {
if (!this.openAnimationEnabled) {
return;
}
this.playBackdropFadeIn();
const targets = this.getAnimationTargets();
targets.forEach((target, index) => {
this.playBounceIn(target, index * 0.035);
});
}
protected stopOpenAnimation(): void {
this.getAnimationTargets().forEach((target) => {
Tween.stopAllByTarget(target);
});
const opacity = this.backdropNode?.getComponent(UIOpacity);
if (opacity) {
Tween.stopAllByTarget(opacity);
}
}
private getAnimationTargets(): Node[] {
const configuredTargets = this.animationNodes.filter((node) => node?.isValid);
return configuredTargets.length > 0 ? configuredTargets : [this.node];
}
private getOriginalScale(target: Node): Vec3 {
const cachedScale = this._originalScales.get(target);
if (cachedScale) {
return cachedScale;
}
const originalScale = target.scale.clone();
this._originalScales.set(target, originalScale);
return originalScale;
}
private playBounceIn(target: Node, delay: number): void {
const originalScale = this.getOriginalScale(target);
const startScale = this.multiplyScale(originalScale, 0.82);
const peakScale = this.multiplyScale(originalScale, 1.045);
Tween.stopAllByTarget(target);
target.setScale(startScale);
tween(target)
.delay(delay)
.to(this.openAnimationDuration * 0.58, { scale: peakScale }, { easing: 'backOut' })
.to(this.openAnimationDuration * 0.24, { scale: this.multiplyScale(originalScale, 0.985) }, { easing: 'sineOut' })
.to(this.openAnimationDuration * 0.18, { scale: originalScale }, { easing: 'sineOut' })
.start();
}
private playBackdropFadeIn(): void {
if (!this.backdropNode?.isValid) {
return;
}
const opacity = this.backdropNode.getComponent(UIOpacity) ?? this.backdropNode.addComponent(UIOpacity);
Tween.stopAllByTarget(opacity);
opacity.opacity = 0;
tween(opacity)
.to(0.18, { opacity: 255 }, { easing: 'sineOut' })
.start();
}
private multiplyScale(scale: Vec3, factor: number): Vec3 {
return new Vec3(scale.x * factor, scale.y * factor, scale.z);
}
}
```
### 2.3 接口类型定义 (assets/scripts/types/ApiTypes.ts)
```typescript
/**
* 服务端 API 通用响应类型
*/
/** 服务端标准响应封装 */
export interface ApiEnvelope<T> {
success: boolean;
data: T | null;
message: string | null;
timestamp: string;
}
/** 体力值信息 */
export interface StaminaInfo {
/** 当前体力值(已计算恢复) */
current: number;
/** 体力上限,固定为 50 */
max: number;
/** 下一点体力恢复的时间ISO 8601满体力时为 null */
nextRecoverAt: string | null;
}
/** 下一关完整数据(多个接口共用) */
export interface NextLevelData {
/** 关卡 ID */
id: string;
/** 关卡编号sortOrder */
level: number;
/** 图片1 URL */
image1Url: string;
/** 图片1 文本说明 */
image1Description: string | null;
/** 图片2 URL */
image2Url: string;
/** 图片2 文本说明 */
image2Description: string | null;
/** 答案 */
answer: string;
/** 谐音梗说明 */
punchline: string | null;
/** 线索1 */
hint1: string | null;
/** 线索2 */
hint2: string | null;
/** 线索3 */
hint3: string | null;
/** 限时null 表示不限时 */
timeLimit: number | null;
}
/** 登录响应数据 */
export interface WxLoginData {
token: string;
user: {
id: string;
nickname: string | null;
stamina: number;
};
}
/** 用户资料响应数据 */
export interface UserProfileData {
id: string;
nickname: string | null;
stamina: StaminaInfo;
}
/** 游戏数据响应Loading 页面) */
export interface GameData {
user: {
id: string;
stamina: StaminaInfo;
};
/** 已通关的关卡数量 */
completedLevelCount: number;
/** 下一个待通关的关卡(全部通关时为 null */
nextLevel: NextLevelData | null;
}
/** 进入关卡响应 */
export interface EnterLevelData {
id: string;
level: number;
image1Url: string;
image1Description: string | null;
image2Url: string;
image2Description: string | null;
answer: string;
punchline: string | null;
hint1: string | null;
hint2: string | null;
hint3: string | null;
stamina: StaminaInfo;
/** 预加载的下一关数据(无下一关时为 null */
preloadNextLevel: NextLevelData | null;
}
/** 通关上报响应 */
export interface CompleteLevelData {
firstClear: boolean;
levelId: string;
timeSpent: number;
/** 下一个待通关的关卡(全部通关时为 null */
nextLevel: NextLevelData | null;
}
/** 创建分享响应 */
export interface CreateShareData {
shareCode: string;
title: string;
levelCount: number;
}
/** 分享关卡数据 */
export interface ShareLevelData {
id: string;
level: number;
image1Url: string;
image1Description: string | null;
image2Url: string;
image2Description: string | null;
answer: string;
punchline: string | null;
hint1: string | null;
hint2: string | null;
hint3: string | null;
sortOrder: number;
}
/** 加入分享响应 */
export interface JoinShareData {
shareCode: string;
title: string;
levels: ShareLevelData[];
}
/** 分享挑战单关提交 */
export interface SubmitShareLevel {
levelId: string;
answer: string;
timeSpent: number;
}
/** 分享挑战提交后的单关结果 */
export interface SubmittedShareLevelData extends ShareLevelData {
submittedAnswer: string;
timeSpent: number;
isCorrect: boolean;
timeLimit: number | null;
withinTimeLimit: boolean;
}
/** 分享挑战整场提交响应 */
export interface SubmitShareData {
shareCode: string;
title: string;
rank: number;
correctCount: number;
levelCount: number;
participantCount: number;
totalTimeSpent: number;
levels: SubmittedShareLevelData[];
}
/** 分享挑战参与者排行摘要 */
export interface ShareParticipantRankSummary {
userId?: string | null;
participantId?: string | null;
nickname?: string | null;
nickName?: string | null;
avatarUrl?: string | null;
rank?: number | null;
correctCount?: number | null;
totalTimeSpent?: number | null;
}
/** 我创建的分享挑战条目 */
export interface CreatedShareItem {
id: string;
shareCode: string;
title: string;
levelCount: number;
participantCount: number;
userRank: number | null;
createdAt: string;
firstPlaceUser?: ShareParticipantRankSummary | null;
topParticipant?: ShareParticipantRankSummary | null;
firstParticipant?: ShareParticipantRankSummary | null;
champion?: ShareParticipantRankSummary | null;
}
/** 我创建的分享挑战列表响应 */
export interface CreatedShareListData {
items: CreatedShareItem[];
}
/** 我参与的分享挑战条目 */
export interface ParticipatedShareItem {
title: string;
participantCount: number;
userRank: number | null;
}
/** 我参与的分享挑战列表响应 */
export interface ParticipatedShareListData {
items: ParticipatedShareItem[];
}
/** 分享挑战详情响应 */
export interface ShareDetailData {
id: string;
shareCode: string;
title: string;
levelCount: number;
participantCount: number;
userRank: number | null;
createdAt: string;
rankings: ShareParticipantRankSummary[];
}
/** 已通关关卡数据(成就墙 / 关卡回看场景) */
export interface CompletedLevel {
/** 关卡 ID */
id: string;
/** 关卡编号sortOrder */
level: number;
/** 图片1 URL */
image1Url: string;
/** 图片1 文本说明 */
image1Description: string | null;
/** 图片2 URL */
image2Url: string;
/** 图片2 文本说明 */
image2Description: string | null;
/** 答案 */
answer: string;
/** 谐音梗说明 */
punchline: string | null;
/** 线索1 */
hint1: string | null;
/** 线索2 */
hint2: string | null;
/** 线索3 */
hint3: string | null;
/** 限时null 表示不限时 */
timeLimit: number | null;
/** 首次通关时长(秒) */
timeSpent: number;
/** 通关时间ISO 8601 */
completedAt: string;
}
```
### 2.4 关卡类型定义 (assets/scripts/types/LevelTypes.ts)
```typescript
import { SpriteFrame } from 'cc';
/**
* 运行时关卡配置(包含已加载的图片)
*/
export interface RuntimeLevelConfig {
/** 关卡 ID */
id: string;
/** 关卡名称 */
name: string;
/** 图片1 SpriteFrame可能为 null 如果加载失败) */
spriteFrame1: SpriteFrame | null;
/** 图片2 SpriteFrame可能为 null 如果加载失败) */
spriteFrame2: SpriteFrame | null;
/** 图片1 文本说明 */
image1Description: string | null;
/** 图片2 文本说明 */
image2Description: string | null;
/** 谐音梗说明 */
punchline: string | null;
/** 线索1 */
clue1: string | null;
/** 线索2 */
clue2: string | null;
/** 线索3 */
clue3: string | null;
/** 答案 */
answer: string | null;
/** 是否已通关 */
completed: boolean;
/** 限时null 表示不限时 */
timeLimit: number | null;
}
```
### 2.5 API 配置常量 (assets/scripts/config/ApiConfig.ts)
```typescript
/**
* API 配置常量
* 统一管理所有服务端 API 地址
*/
/** 服务端 API 基础地址 */
export const API_BASE = 'https://ilookai.cn/api/v1';
/** API 端点 */
export const API_ENDPOINTS = {
/** 微信登录 */
WX_LOGIN: `${API_BASE}/auth/wx-login`,
/** 用户资料(含实时体力) */
USER_PROFILE: `${API_BASE}/user/profile`,
/** 游戏数据(体力 + 通关进度 + 下一关) */
USER_GAME_DATA: `${API_BASE}/user/game-data`,
/** 游戏配置 */
GAME_CONFIGS: `${API_BASE}/game-configs`,
/** 分享相关 */
SHARE_CREATE: `${API_BASE}/share`,
SHARE_CREATED: `${API_BASE}/share/created`,
SHARE_PARTICIPATED: `${API_BASE}/share/participated`,
/** 用户信息 */
USER_INFO: `${API_BASE}/user/info`,
/** 用户所有已通关的关卡(成就墙 / 关卡回看) */
COMPLETED_LEVELS: `${API_BASE}/levels/completed`,
} as const;
export function getLevelEnterUrl(levelId: string): string {
return `${API_BASE}/levels/${levelId}/enter`;
}
export function getLevelCompleteUrl(levelId: string): string {
return `${API_BASE}/levels/${levelId}/complete`;
}
export function getShareJoinUrl(code: string): string {
return `${API_BASE}/share/${code}/join`;
}
export function getShareDetailUrl(code: string): string {
return `${API_BASE}/share/${code}`;
}
export function getShareSubmitUrl(code: string): string {
return `${API_BASE}/share/${code}/submit`;
}
export function getGameConfigUrl(key: string): string {
return `${API_BASE}/game-configs/${key}`;
}
/** 请求超时时间(毫秒) */
export const API_TIMEOUT = {
DEFAULT: 8000,
SHORT: 5000,
} as const;
```

View File

@@ -0,0 +1,578 @@
# 梗中作乐 V1.0 - 源代码文档(第 3 部分)
软件名称:梗中作乐
版本号V1.0
---
## 第 3 部分:认证存储与网络
本部分包含用户认证、本地存储、HTTP 网络通信三个核心工具模块。
### 3.1 微信认证管理器 (assets/scripts/utils/AuthManager.ts)
```typescript
import { HttpUtil } from './HttpUtil';
import { StorageManager } from './StorageManager';
import { WxSDK } from './WxSDK';
import { API_ENDPOINTS, API_TIMEOUT } from '../config/ApiConfig';
import { ApiEnvelope, WxLoginData, GameData, NextLevelData } from '../types/ApiTypes';
/**
* 认证管理器
* 单例模式,负责微信登录和 JWT token 管理
*/
export class AuthManager {
private static _instance: AuthManager | null = null;
private _userId: string = '';
private _isLoggedIn: boolean = false;
/** 服务端返回的已完成关卡数量,用于称号体系计算 */
private _completedLevelCount: number = 0;
/** game-data 返回的下一关数据,供 PageLoading 传给 LevelDataManager */
private _nextLevel: NextLevelData | null = null;
static get instance(): AuthManager {
if (!this._instance) {
this._instance = new AuthManager();
}
return this._instance;
}
private constructor() {}
get isLoggedIn(): boolean {
return this._isLoggedIn;
}
get userId(): string {
return this._userId;
}
get completedLevelCount(): number {
return this._completedLevelCount;
}
/** 获取 game-data 返回的下一关数据 */
get nextLevel(): NextLevelData | null {
return this._nextLevel;
}
addCompletedLevelCount(delta: number = 1): void {
this._completedLevelCount = Math.max(0, this._completedLevelCount + delta);
}
/**
* 初始化认证:尝试恢复 token 或执行微信登录
*/
async initialize(): Promise<boolean> {
const savedToken = StorageManager.getToken();
if (savedToken) {
HttpUtil.setAuthToken(savedToken);
try {
const valid = await this.validateToken();
if (valid) {
console.log('[AuthManager] Token 恢复成功');
return true;
}
} catch {
console.warn('[AuthManager] 本地 token 无效,重新登录');
}
}
return this.wxLogin();
}
private async wxLogin(): Promise<boolean> {
try {
let code: string;
if (WxSDK.isWechat()) {
code = await WxSDK.login();
} else {
console.warn('[AuthManager] 非微信环境,使用开发模式 mock code');
code = 'dev_mock_code';
}
const response = await HttpUtil.post<ApiEnvelope<WxLoginData>>(
API_ENDPOINTS.WX_LOGIN,
{ code },
API_TIMEOUT.DEFAULT
);
if (!response.success || !response.data) {
console.error('[AuthManager] 登录失败:', response.message);
return false;
}
const { token, user } = response.data;
HttpUtil.setAuthToken(token);
StorageManager.setToken(token);
this._userId = user.id;
this._isLoggedIn = true;
// 登录响应中 stamina 是原始数值(不含实时恢复),先存储默认体力
// 后续通过 game-data 接口获取完整 StaminaInfo
console.log(`[AuthManager] 登录成功,用户: ${user.id},体力: ${user.stamina}`);
// 获取通关进度和完整体力信息
await this.fetchGameData();
return true;
} catch (err) {
console.error('[AuthManager] 登录异常:', err);
return false;
}
}
private async validateToken(): Promise<boolean> {
const gameData = await this._fetchGameData();
if (!gameData) return false;
this._userId = gameData.user.id;
this._isLoggedIn = true;
StorageManager.setStamina(gameData.user.stamina);
this._completedLevelCount = gameData.completedLevelCount;
this._nextLevel = gameData.nextLevel;
console.log(`[AuthManager] Token 验证成功,体力: ${gameData.user.stamina.current}/${gameData.user.stamina.max},已完成: ${this._completedLevelCount}`);
return true;
}
/**
* 登录成功后获取游戏数据(体力 + 通关进度 + 下一关)
*/
private async fetchGameData(): Promise<void> {
const gameData = await this._fetchGameData();
if (gameData) {
this._completedLevelCount = gameData.completedLevelCount;
this._nextLevel = gameData.nextLevel;
StorageManager.setStamina(gameData.user.stamina);
}
}
/**
* 从服务端获取游戏数据(共用方法)
*/
private async _fetchGameData(): Promise<GameData | null> {
try {
const response = await HttpUtil.get<ApiEnvelope<GameData>>(
API_ENDPOINTS.USER_GAME_DATA,
API_TIMEOUT.SHORT
);
return (response.success && response.data) ? response.data : null;
} catch {
console.warn('[AuthManager] 获取游戏数据失败');
return null;
}
}
}
```
### 3.2 HTTP 工具类 (assets/scripts/utils/HttpUtil.ts)
```typescript
/**
* HTTP 请求工具类
* 封装 XMLHttpRequest支持 GET/POST 请求,支持 JWT 认证
*/
export class HttpUtil {
/** 认证 token */
private static _authToken: string | null = null;
/**
* 设置认证 token
* @param token JWT token
*/
static setAuthToken(token: string | null): void {
HttpUtil._authToken = token;
console.log(`[HttpUtil] Auth token ${token ? '已设置' : '已清除'}`);
}
/**
* 获取认证 token
*/
static getAuthToken(): string | null {
return HttpUtil._authToken;
}
/**
* 发送 GET 请求
* @param url 请求 URL
* @param timeout 超时时间(毫秒),默认 10000
* @returns Promise<Response>
*/
static get<T>(url: string, timeout: number = 10000): Promise<T> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.timeout = timeout;
xhr.responseType = 'json';
// 设置认证头
if (HttpUtil._authToken) {
xhr.setRequestHeader('Authorization', `Bearer ${HttpUtil._authToken}`);
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response as T);
} else {
reject(new Error(`HTTP 错误: ${xhr.status}`));
}
};
xhr.onerror = () => {
reject(new Error('网络请求失败'));
};
xhr.ontimeout = () => {
reject(new Error('请求超时'));
};
xhr.send();
});
}
/**
* 发送 POST 请求
* @param url 请求 URL
* @param data 请求体数据
* @param timeout 超时时间(毫秒),默认 10000
* @returns Promise<Response>
*/
static post<T>(url: string, data: object, timeout: number = 10000): Promise<T> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.timeout = timeout;
xhr.responseType = 'json';
xhr.setRequestHeader('Content-Type', 'application/json');
// 设置认证头
if (HttpUtil._authToken) {
xhr.setRequestHeader('Authorization', `Bearer ${HttpUtil._authToken}`);
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response as T);
} else {
reject(new Error(`HTTP 错误: ${xhr.status}`));
}
};
xhr.onerror = () => {
reject(new Error('网络请求失败'));
};
xhr.ontimeout = () => {
reject(new Error('请求超时'));
};
xhr.send(JSON.stringify(data));
});
}
}
```
### 3.3 本地存储管理器 (assets/scripts/utils/StorageManager.ts)
```typescript
import { sys } from 'cc';
import { StaminaInfo } from '../types/ApiTypes';
/**
* 用户进度数据结构
*/
interface UserProgress {
/** 当前关卡索引0-based */
currentLevelIndex: number;
/** 已通关的最高关卡索引 */
maxUnlockedLevelIndex: number;
}
/**
* 用户信息结构
*/
interface UserInfo {
avatarUrl: string;
nickName: string;
}
/**
* 本地存储管理器
* 统一管理用户数据的本地持久化存储
*/
export class StorageManager {
/** 体力值存储键 */
private static readonly KEY_STAMINA = 'game_stamina';
/** 用户进度存储键 */
private static readonly KEY_PROGRESS = 'game_progress';
/** 认证 token 存储键 */
private static readonly KEY_TOKEN = 'auth_token';
/** 用户信息存储键 */
private static readonly KEY_USER_INFO = 'user_info';
/** 默认体力值 */
private static readonly DEFAULT_STAMINA: StaminaInfo = {
current: 50,
max: 50,
nextRecoverAt: null,
};
/** 默认进度 */
private static readonly DEFAULT_PROGRESS: UserProgress = {
currentLevelIndex: 0,
maxUnlockedLevelIndex: 0
};
/** 进度缓存(避免重复读取 localStorage */
private static _progressCache: UserProgress | null = null;
/** 体力缓存(避免重复 JSON 解析) */
private static _staminaCache: StaminaInfo | null = null;
// ==================== 体力值管理 ====================
/**
* 获取当前体力信息(带内存缓存,避免重复 JSON 解析)
*/
static getStamina(): StaminaInfo {
if (StorageManager._staminaCache) {
return { ...StorageManager._staminaCache };
}
const stored = sys.localStorage.getItem(StorageManager.KEY_STAMINA);
if (stored === null || stored === '') {
StorageManager.setStamina(StorageManager.DEFAULT_STAMINA);
return { ...StorageManager.DEFAULT_STAMINA };
}
try {
const stamina = JSON.parse(stored) as StaminaInfo;
if (typeof stamina.current !== 'number' || typeof stamina.max !== 'number') {
StorageManager.setStamina(StorageManager.DEFAULT_STAMINA);
return { ...StorageManager.DEFAULT_STAMINA };
}
StorageManager._staminaCache = stamina;
return { ...stamina };
} catch {
StorageManager.setStamina(StorageManager.DEFAULT_STAMINA);
return { ...StorageManager.DEFAULT_STAMINA };
}
}
/**
* 设置体力信息(同时更新缓存)
*/
static setStamina(stamina: StaminaInfo): void {
StorageManager._staminaCache = stamina;
sys.localStorage.setItem(StorageManager.KEY_STAMINA, JSON.stringify(stamina));
console.log(`[StorageManager] 体力已更新: ${stamina.current}/${stamina.max}`);
}
/**
* 检查是否有足够的体力
*/
static hasStamina(): boolean {
return StorageManager.getStamina().current > 0;
}
// ==================== 认证 Token 管理 ====================
/**
* 获取认证 token
*/
static getToken(): string | null {
const token = sys.localStorage.getItem(StorageManager.KEY_TOKEN);
return (token === null || token === '') ? null : token;
}
/**
* 设置认证 token
*/
static setToken(token: string): void {
sys.localStorage.setItem(StorageManager.KEY_TOKEN, token);
console.log('[StorageManager] Token 已保存');
}
/**
* 清除认证 token
*/
static clearToken(): void {
sys.localStorage.removeItem(StorageManager.KEY_TOKEN);
console.log('[StorageManager] Token 已清除');
}
// ==================== 关卡进度管理 ====================
/**
* 获取用户进度数据(带缓存)
* @returns 用户进度对象的副本
*/
private static _getProgress(): UserProgress {
// 返回缓存副本
if (StorageManager._progressCache !== null) {
return { ...StorageManager._progressCache };
}
const stored = sys.localStorage.getItem(StorageManager.KEY_PROGRESS);
if (stored === null || stored === '') {
// 新用户,返回默认进度
StorageManager._progressCache = { ...StorageManager.DEFAULT_PROGRESS };
return { ...StorageManager._progressCache };
}
try {
const progress = JSON.parse(stored) as UserProgress;
// 验证数据有效性
if (typeof progress.currentLevelIndex !== 'number' ||
typeof progress.maxUnlockedLevelIndex !== 'number' ||
progress.currentLevelIndex < 0 ||
progress.maxUnlockedLevelIndex < 0) {
console.warn('[StorageManager] 进度数据无效,使用默认值');
StorageManager._progressCache = { ...StorageManager.DEFAULT_PROGRESS };
} else {
StorageManager._progressCache = progress;
}
return { ...StorageManager._progressCache };
} catch (e) {
console.warn('[StorageManager] 解析进度数据失败,使用默认值');
StorageManager._progressCache = { ...StorageManager.DEFAULT_PROGRESS };
return { ...StorageManager._progressCache };
}
}
/**
* 保存用户进度数据
* @param progress 进度对象
*/
private static _saveProgress(progress: UserProgress): void {
StorageManager._progressCache = progress;
sys.localStorage.setItem(StorageManager.KEY_PROGRESS, JSON.stringify(progress));
}
/**
* 获取当前关卡索引
* @returns 当前关卡索引0-based
*/
static getCurrentLevelIndex(): number {
return StorageManager._getProgress().currentLevelIndex;
}
/**
* 设置当前关卡索引
* @param index 关卡索引
*/
static setCurrentLevelIndex(index: number): void {
if (index < 0) {
console.warn('[StorageManager] 关卡索引不能为负数');
return;
}
const progress = StorageManager._getProgress();
progress.currentLevelIndex = index;
StorageManager._saveProgress(progress);
console.log(`[StorageManager] 当前关卡已更新: ${progress.currentLevelIndex}`);
}
/**
* 获取已解锁的最高关卡索引
* @returns 最高关卡索引0-based
*/
static getMaxUnlockedLevelIndex(): number {
return StorageManager._getProgress().maxUnlockedLevelIndex;
}
/**
* 通关后更新进度
* 当玩家通关第 N 关后,设置当前关卡为 N+1解锁关卡更新为 max(N, 已解锁)
* @param completedLevelIndex 刚通关的关卡索引
*/
static onLevelCompleted(completedLevelIndex: number): void {
if (completedLevelIndex < 0) {
console.warn('[StorageManager] 通关关卡索引不能为负数');
return;
}
const progress = StorageManager._getProgress();
const nextLevelIndex = completedLevelIndex + 1;
// 更新当前关卡为下一关
progress.currentLevelIndex = nextLevelIndex;
// 更新最高解锁关卡
progress.maxUnlockedLevelIndex = Math.max(progress.maxUnlockedLevelIndex, completedLevelIndex);
StorageManager._saveProgress(progress);
console.log(`[StorageManager] 通关第 ${completedLevelIndex + 1} 关,下一关: ${nextLevelIndex + 1}`);
}
/**
* 检查指定关卡是否已解锁
* @param levelIndex 关卡索引
* @returns 是否已解锁
*/
static isLevelUnlocked(levelIndex: number): boolean {
const progress = StorageManager._getProgress();
return levelIndex <= progress.maxUnlockedLevelIndex;
}
/**
* 重置所有进度
*/
static resetProgress(): void {
StorageManager._progressCache = null;
sys.localStorage.removeItem(StorageManager.KEY_PROGRESS);
console.log('[StorageManager] 进度已重置');
}
/**
* 重置所有数据(体力 + 进度)
*/
static resetAll(): void {
StorageManager.setStamina(StorageManager.DEFAULT_STAMINA);
StorageManager.resetProgress();
StorageManager.clearToken();
StorageManager.clearUserInfo();
console.log('[StorageManager] 所有数据已重置');
}
// ==================== 用户信息管理 ====================
/**
* 保存用户信息(头像、昵称)
* @param userInfo 用户信息对象
*/
static setUserInfo(userInfo: UserInfo): void {
sys.localStorage.setItem(StorageManager.KEY_USER_INFO, JSON.stringify(userInfo));
console.log('[StorageManager] 用户信息已保存');
}
/**
* 获取本地缓存的用户信息
* @returns 用户信息对象或 null
*/
static getUserInfo(): UserInfo | null {
const data = sys.localStorage.getItem(StorageManager.KEY_USER_INFO);
if (!data) return null;
try {
return JSON.parse(data) as UserInfo;
} catch {
return null;
}
}
/**
* 清除用户信息缓存
*/
static clearUserInfo(): void {
sys.localStorage.removeItem(StorageManager.KEY_USER_INFO);
console.log('[StorageManager] 用户信息已清除');
}
}
```

View File

@@ -0,0 +1,600 @@
# 梗中作乐 V1.0 - 源代码文档(第 4 部分)
软件名称:梗中作乐
版本号V1.0
---
## 第 4 部分:首页与体力系统
本部分包含小游戏首页、体力(次数)管理、背景自适应缩放三个模块。
### 4.1 首页 (assets/prefabs/PageHome.ts)
```typescript
import { _decorator, Node, Button, Label, tween, Vec3, UIOpacity, UITransform, Color, instantiate, ProgressBar } from 'cc';
import { BaseView } from 'db://assets/scripts/core/BaseView';
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
import { WxSDK } from 'db://assets/scripts/utils/WxSDK';
import { StaminaManager } from 'db://assets/scripts/utils/StaminaManager';
import { ToastManager } from 'db://assets/scripts/utils/ToastManager';
import { StaminaInfo } from 'db://assets/scripts/types/ApiTypes';
import { AuthManager } from 'db://assets/scripts/utils/AuthManager';
import { AchievementTitleManager } from 'db://assets/scripts/utils/AchievementTitleManager';
import { ShareManager } from 'db://assets/scripts/utils/ShareManager';
import { AudioManager } from 'db://assets/scripts/utils/AudioManager';
const { ccclass, property } = _decorator;
/**
* 首页组件
* 继承 BaseView实现页面生命周期
*/
@ccclass('PageHome')
export class PageHome extends BaseView {
/** 默认体力上限 */
private static readonly DEFAULT_STAMINA_MAX = 50;
@property({ type: Node, tooltip: '开始游戏按钮' })
startGameBtn: Node | null = null;
@property({ type: Node, tooltip: 'PK按钮' })
pkBtn: Node | null = null;
/** 体力值显示标签 */
@property(Label)
liveLabel: Label | null = null;
/** 首页主称号文本 */
@property(Label)
levelLabel: Label | null = null;
/** 称号进度条 */
@property(ProgressBar)
titleProgressBar: ProgressBar | null = null;
/** 称号进度提示文案 */
@property(Label)
progressLabel: Label | null = null;
/** 称号进度游标 */
@property(Node)
progressAnchor: Node | null = null;
/** 飞行动画持续时间(秒) */
private static readonly FLY_DURATION = 0.5;
/** 到达后弹跳持续时间(秒) */
private static readonly BOUNCE_DURATION = 0.15;
/** 浮动文本动画持续时间(秒) */
private static readonly FLOAT_DURATION = 0.8;
/** 浮动文本上移距离 */
private static readonly FLOAT_OFFSET_Y = 120;
/** 是否正在播放体力消耗动画 */
private _isAnimating: boolean = false;
/** 进度游标 0% 时的本地 X 坐标,根据 ProgressBar Bar 子节点的左端推导出来 */
private _progressAnchorStartX: number | null = null;
/**
* 页面首次加载时调用
*/
onViewLoad(): void {
console.log('[PageHome] onViewLoad');
this._cacheProgressAnchorStartX();
this.updateAchievementTitleInfo();
this._initButtons();
this._initWxShare();
}
/**
* 初始化微信分享功能
*/
private _initWxShare(): void {
WxSDK.initShare({
title: '写英语',
imageUrl: '',
query: ''
});
}
/**
* 初始化按钮事件
*/
private _initButtons(): void {
if (this.startGameBtn) {
this.startGameBtn.on(Button.EventType.CLICK, this._onStartGameClick, this);
}
if (this.pkBtn) {
this.pkBtn.on(Button.EventType.CLICK, this._onPkClick, this);
}
}
/**
* 开始游戏按钮点击回调
*/
private _onStartGameClick(): void {
if (this._isAnimating) return;
AudioManager.instance.playButtonClick();
console.log('[PageHome] 开始游戏按钮点击');
// 体力检查
if (!StaminaManager.instance.hasStamina()) {
ToastManager.show('体力不足,请等待恢复');
return;
}
// 兜底:清空可能残留的好友分享挑战状态,确保进入的是纯主线挑战
// 场景:用户从微信好友分享卡片进入挑战 → 退出回首页 → 再次点击开始游戏
// 若不清理,缓存的 PageLevel 仍会读到 ShareManager 的分享态数据
if (ShareManager.instance.isShareMode) {
console.log('[PageHome] 检测到残留的分享挑战状态,清理后再进入主线挑战');
ShareManager.instance.clearShareMode();
}
this._isAnimating = true;
this._playStaminaCostAnimation()
.then(() => {
ViewManager.instance.open('PageLevel', { params: { shareMode: false } });
})
.catch(err => {
console.error('[PageHome] 体力消耗动画异常:', err);
// 异常兜底:直接进入关卡
ViewManager.instance.open('PageLevel', { params: { shareMode: false } });
})
.finally(() => {
this._isAnimating = false;
});
}
/**
* PK按钮点击回调
*/
private _onPkClick(): void {
AudioManager.instance.playButtonClick();
console.log('[PageHome] PK按钮点击');
ViewManager.instance.open('PageWriteLevels');
}
// ========== 体力消耗动画 ==========
/**
* 通过节点路径查找 IconLive
*/
private _findIconLive(): Node | null {
return this.node
.getChildByName('TopLayout')
?.getChildByName('Live')
?.getChildByName('IconLive') ?? null;
}
/**
* 将节点的世界坐标转换为目标父节点的本地坐标
*/
private _worldToLocal(worldPos: Vec3, parent: Node): Vec3 {
const parentTransform = parent.getComponent(UITransform);
if (!parentTransform) return worldPos;
return parentTransform.convertToNodeSpaceAR(worldPos);
}
/**
* 获取节点的世界坐标
*/
private _getWorldPos(node: Node): Vec3 {
const transform = node.getComponent(UITransform);
if (!transform) return node.worldPosition.clone();
return transform.convertToWorldSpaceAR(Vec3.ZERO);
}
/**
* 播放体力消耗动画
* 1. 克隆 IconLive 飞向 StarGame 按钮
* 2. 到达后弹跳
* 3. "体力值-1" 浮动文本上移渐隐
* 4. 同步更新体力数字
*/
private _playStaminaCostAnimation(): Promise<void> {
return new Promise<void>((resolve) => {
const iconLive = this._findIconLive();
const targetBtn = this.startGameBtn;
if (!iconLive || !targetBtn) {
console.warn('[PageHome] 动画节点未找到,跳过动画');
resolve();
return;
}
// --- 坐标计算 ---
const iconWorldPos = this._getWorldPos(iconLive);
const targetWorldPos = this._getWorldPos(targetBtn);
const rootNode = this.node;
// 起始和终点在 root 本地空间的坐标
const startLocal = this._worldToLocal(iconWorldPos, rootNode);
const endLocal = this._worldToLocal(targetWorldPos, rootNode);
// --- 克隆飞行节点 ---
const flyNode = instantiate(iconLive);
flyNode.name = '_flyIcon';
flyNode.setPosition(startLocal);
// 保持与原始 IconLive 相同的缩放
flyNode.setScale(iconLive.worldScale.clone());
rootNode.addChild(flyNode);
// 隐藏原始 IconLive
iconLive.active = false;
// --- 飞行路径(带弧度的抛物线效果) ---
// 中间控制点x 取中点y 取较高值 + 偏移形成弧线
const midX = (startLocal.x + endLocal.x) / 2;
const midY = Math.max(startLocal.y, endLocal.y) + 150;
const midPoint = new Vec3(midX, midY, 0);
// 阶段1飞到弧线顶点
const halfDuration = PageHome.FLY_DURATION / 2;
tween(flyNode)
.to(halfDuration, { position: midPoint }, { easing: 'quadOut' })
.to(halfDuration, { position: endLocal }, { easing: 'quadIn' })
// 阶段2到达弹跳
.to(PageHome.BOUNCE_DURATION / 2, { scale: new Vec3(0.4, 0.4, 1) }, { easing: 'quadOut' })
.to(PageHome.BOUNCE_DURATION / 2, { scale: new Vec3(0.3, 0.3, 1) }, { easing: 'quadIn' })
.call(() => {
// 飞行完成 — flyNode 使命结束,立即清理
flyNode.destroy();
iconLive.active = true;
// 创建浮动文本
this._showFloatText(targetBtn, rootNode);
// 乐观更新体力数字
this._optimisticUpdateStamina();
// 等浮动文本播完再 resolve用 setTimeout不依赖已销毁节点的 tween
setTimeout(() => resolve(), PageHome.FLOAT_DURATION * 1000);
})
.start();
});
}
/**
* 显示浮动提示文本 "体力值-1"
* 从按钮位置向上漂移并渐隐
*/
private _showFloatText(anchorNode: Node, parentNode: Node): void {
// 创建文本节点
const textNode = new Node('_floatText');
textNode.addComponent(UITransform);
const label = textNode.addComponent(Label);
label.string = '体力值-1';
label.fontSize = 36;
label.lineHeight = 40;
label.color = new Color(255, 80, 80, 255);
label.isBold = true;
// 复用 liveLabel 的字体(如果有)
if (this.liveLabel?.font) {
label.font = this.liveLabel.font;
}
// 添加 UIOpacity 用于渐隐
const opacity = textNode.addComponent(UIOpacity);
opacity.opacity = 255;
// 定位到按钮上方
const anchorWorldPos = this._getWorldPos(anchorNode);
const localPos = this._worldToLocal(anchorWorldPos, parentNode);
// 起始位置在按钮上方偏移
localPos.y += 120;
textNode.setPosition(localPos);
parentNode.addChild(textNode);
// 向上漂移 + 渐隐
const floatTarget = new Vec3(localPos.x, localPos.y + PageHome.FLOAT_OFFSET_Y, 0);
tween(textNode)
.to(PageHome.FLOAT_DURATION, { position: floatTarget }, { easing: 'quadOut' })
.call(() => {
textNode.destroy();
})
.start();
tween(opacity)
.delay(PageHome.FLOAT_DURATION * 0.3)
.to(PageHome.FLOAT_DURATION * 0.7, { opacity: 0 }, { easing: 'quadIn' })
.start();
}
/**
* 乐观更新体力标签(本地 -1 预扣显示)
*/
private _optimisticUpdateStamina(): void {
if (!this.liveLabel) return;
const stamina = StaminaManager.instance.getStamina();
const maxStamina = this._getStaminaMax(stamina);
const displayCurrent = Math.max(0, stamina.current - 1);
this.liveLabel.string = `${displayCurrent}/${maxStamina}`;
}
/**
* 页面每次显示时调用
*/
onViewShow(): void {
console.log('[PageHome] onViewShow');
this.updateStaminaLabel();
this.updateAchievementTitleInfo();
// 保险恢复:防止动画中途被打断导致 IconLive 隐藏残留
const iconLive = this._findIconLive();
if (iconLive && !iconLive.active) {
iconLive.active = true;
}
}
/**
* 获取体力上限
*/
private _getStaminaMax(stamina: StaminaInfo): number {
return typeof stamina.max === 'number' ? stamina.max : PageHome.DEFAULT_STAMINA_MAX;
}
/**
* 更新体力值显示
*/
private updateStaminaLabel(): void {
if (this.liveLabel) {
const stamina = StaminaManager.instance.getStamina();
const maxStamina = this._getStaminaMax(stamina);
this.liveLabel.string = `${stamina.current}/${maxStamina}`;
}
}
/**
* 更新首页称号进度区域
*/
private updateAchievementTitleInfo(): void {
const titleInfo = AchievementTitleManager.getTitleInfo(AuthManager.instance.completedLevelCount);
const progress = this._normalizeProgress(titleInfo.nextTitleProgress);
if (this.levelLabel) {
this.levelLabel.string = titleInfo.titleText;
}
if (this.titleProgressBar) {
this.titleProgressBar.progress = progress;
}
if (this.progressLabel) {
this.progressLabel.string = titleInfo.progressText;
}
this._updateProgressAnchor(progress);
}
private _cacheProgressAnchorStartX(): void {
if (this._progressAnchorStartX !== null || !this.titleProgressBar) {
return;
}
const barSprite = this.titleProgressBar.barSprite;
if (!barSprite) {
return;
}
// Bar 节点 anchor 为 (0, 0.5),其本地 position.x 即为进度条可视左端。
// ProgressBar 与 ProgressAnchor 共享同一父节点TitleLevel
// 因此把 Bar 的本地 X 按 ProgressBar 自身的位移与缩放映射到父节点空间,
// 才是真正的「0% 起点」。直接拿 progressAnchor.position.x 当起点会导致
// 气泡始终被 prefab 摆放偏移量带跑(实测偏右 ~24px
const progressBarNode = this.titleProgressBar.node;
const barLocalX = barSprite.node.position.x;
this._progressAnchorStartX = progressBarNode.position.x + barLocalX * progressBarNode.scale.x - 30;
}
private _updateProgressAnchor(progress: number): void {
if (!this.progressAnchor) {
return;
}
this._cacheProgressAnchorStartX();
const startX = this._progressAnchorStartX ?? this.progressAnchor.position.x;
const travelWidth = this._getProgressAnchorTravelWidth();
this.progressAnchor.setPosition(startX + travelWidth * progress, this.progressAnchor.position.y, this.progressAnchor.position.z);
const percentLabel = this.progressAnchor.getChildByName('Label')?.getComponent(Label);
if (percentLabel) {
percentLabel.string = `${Math.round(progress * 100)}%`;
}
}
private _getProgressAnchorTravelWidth(): number {
if (!this.titleProgressBar) {
return 0;
}
return Math.abs(this.titleProgressBar.totalLength * this.titleProgressBar.node.scale.x);
}
private _normalizeProgress(progress: number): void {
if (!Number.isFinite(progress) || progress <= 0) {
return 0;
}
return Math.min(1, progress);
}
/**
* 页面隐藏时调用
*/
onViewHide(): void {
console.log('[PageHome] onViewHide');
}
/**
* 页面销毁时调用
*/
onViewDestroy(): void {
console.log('[PageHome] onViewDestroy');
// 移除按钮事件监听
if (this.startGameBtn) {
this.startGameBtn.off(Button.EventType.CLICK, this._onStartGameClick, this);
}
if (this.pkBtn) {
this.pkBtn.off(Button.EventType.CLICK, this._onPkClick, this);
}
}
}
```
### 4.2 体力管理器 (assets/scripts/utils/StaminaManager.ts)
```typescript
import { HttpUtil } from './HttpUtil';
import { StorageManager } from './StorageManager';
import { AuthManager } from './AuthManager';
import { API_TIMEOUT, getLevelEnterUrl, getLevelCompleteUrl } from '../config/ApiConfig';
import { ApiEnvelope, StaminaInfo, EnterLevelData, CompleteLevelData } from '../types/ApiTypes';
/**
* 体力值管理器
* 单例模式,负责体力值的服务端同步、进入关卡和通关上报
* 以服务端为准,本地 StorageManager 作为缓存
*/
export class StaminaManager {
private static _instance: StaminaManager | null = null;
static get instance(): StaminaManager {
if (!this._instance) {
this._instance = new StaminaManager();
}
return this._instance;
}
private constructor() {}
/**
* 获取当前体力信息(从本地缓存)
*/
getStamina(): StaminaInfo {
return StorageManager.getStamina();
}
/**
* 更新本地缓存的体力信息
* @param stamina 服务端返回的体力信息
*/
updateStamina(stamina: StaminaInfo): void {
StorageManager.setStamina(stamina);
}
/**
* 检查当前是否有足够的体力
*/
hasStamina(): boolean {
return StorageManager.hasStamina();
}
/**
* 进入关卡
* 消耗 1 点体力(未通关关卡),获取完整关卡详情(含答案和线索)
* @param levelId 关卡 ID
* @returns 关卡详情,失败时返回 null
*/
async enterLevel(levelId: string): Promise<EnterLevelData | null> {
if (!AuthManager.instance.isLoggedIn) {
console.warn('[StaminaManager] 未登录,无法进入关卡');
return null;
}
try {
const response = await HttpUtil.post<ApiEnvelope<EnterLevelData>>(
getLevelEnterUrl(levelId),
{},
API_TIMEOUT.DEFAULT
);
if (response.success && response.data) {
StorageManager.setStamina(response.data.stamina);
console.log(`[StaminaManager] 进入关卡 ${levelId},体力: ${response.data.stamina.current}/${response.data.stamina.max}`);
return response.data;
}
console.warn('[StaminaManager] 进入关卡失败:', response.message);
return null;
} catch (err) {
console.error('[StaminaManager] 进入关卡请求失败:', err);
return null;
}
}
/**
* 通关上报
* @param levelId 关卡 ID
* @param timeSpent 通关耗时(秒)
* @returns 通关响应,失败时返回 null
*/
async completeLevel(levelId: string, timeSpent: number): Promise<CompleteLevelData | null> {
if (!AuthManager.instance.isLoggedIn) {
console.warn('[StaminaManager] 未登录,无法上报通关');
return null;
}
try {
const response = await HttpUtil.post<ApiEnvelope<CompleteLevelData>>(
getLevelCompleteUrl(levelId),
{ timeSpent },
API_TIMEOUT.DEFAULT
);
if (response.success && response.data) {
console.log(`[StaminaManager] 通关上报成功: ${levelId}, 首次: ${response.data.firstClear}, 耗时: ${response.data.timeSpent}s`);
return response.data;
}
console.warn('[StaminaManager] 通关上报失败:', response.message);
return null;
} catch (err) {
console.error('[StaminaManager] 通关上报请求失败:', err);
return null;
}
}
}
```
### 4.3 背景缩放工具 (assets/scripts/utils/BackgroundScaler.ts)
```typescript
import { _decorator, Component, UITransform, view } from 'cc';
const { ccclass, menu, disallowMultiple } = _decorator;
@ccclass('BackgroundScaler')
@menu('Nexux/BackgroundScaler')
@disallowMultiple()
export class BackgroundScaler extends Component {
onLoad() {
const bg = this.node;
const uiTransform = bg.getComponent(UITransform);
const bgSize = uiTransform?.contentSize;
if (!bgSize) {
console.error('BackgroundScaler: bgSize is null');
return;
}
const winSize = view.getVisibleSize();
const scaleX = winSize.width / bgSize.width;
const scaleY = winSize.height / bgSize.height;
const scale = Math.max(scaleX, scaleY);
bg.setScale(scale, scale);
}
}
```

View File

@@ -0,0 +1,715 @@
# 梗中作乐 V1.0 - 源代码文档(第 5 部分)
软件名称:梗中作乐
版本号V1.0
---
## 第 5 部分:关卡核心逻辑
本部分展示关卡页 PageLevel 的核心实现:包含视图绑定、关卡数据加载、答题判定与通关流程驱动的关键代码。
### 5.1 关卡页 (assets/prefabs/PageLevel.ts节选 1-700 行)
```typescript
import { _decorator, Node, EditBox, instantiate, Vec3, Button, Label, Sprite, SpriteFrame, AudioClip, AudioSource, Prefab, EffectAsset, UITransform, UIOpacity, ProgressBar, tween, Tween, Color, Layout, Widget, sp, view, resources } from 'cc';
import { BaseView } from 'db://assets/scripts/core/BaseView';
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
import { StaminaManager } from 'db://assets/scripts/utils/StaminaManager';
import { WxSDK, getUserProfile, type WxUserInfo } from 'db://assets/scripts/utils/WxSDK';
import { LevelDataManager } from 'db://assets/scripts/utils/LevelDataManager';
import { AuthManager } from 'db://assets/scripts/utils/AuthManager';
import { RuntimeLevelConfig } from 'db://assets/scripts/types/LevelTypes';
import { ToastManager } from 'db://assets/scripts/utils/ToastManager';
import { ShareManager } from 'db://assets/scripts/utils/ShareManager';
import { StorageManager } from 'db://assets/scripts/utils/StorageManager';
import { HttpUtil } from 'db://assets/scripts/utils/HttpUtil';
import { API_ENDPOINTS, API_TIMEOUT } from 'db://assets/scripts/config/ApiConfig';
import { WrongModal } from 'db://assets/prefabs/WrongModal';
import { TimeoutModal } from 'db://assets/prefabs/TimeoutModal';
import { CommonModal } from 'db://assets/prefabs/CommonModal';
import { ApiEnvelope, StaminaInfo, NextLevelData, SubmitShareLevel } from 'db://assets/scripts/types/ApiTypes';
import { AchievementTitleManager, AchievementTitleInfo } from 'db://assets/scripts/utils/AchievementTitleManager';
import { AchievementTitleAnimator } from 'db://assets/scripts/utils/AchievementTitleAnimator';
import { applyRoundedCorner } from 'db://assets/scripts/utils/roundedMaterial.utils';
import { AudioManager } from 'db://assets/scripts/utils/AudioManager';
const { ccclass, property } = _decorator;
/**
* 关卡页面组件
* 继承 BaseView实现页面生命周期
* 关卡流程由服务端 NextLevelData 驱动,客户端不再维护关卡列表
*/
@ccclass('PageLevel')
export class PageLevel extends BaseView {
/** 静态常量:零位置 */
private static readonly ZERO_POS = new Vec3(0, 0, 0);
/** 解锁线索按钮默认文案 */
private static readonly UNLOCK_BUTTON_CLUE_TEXT = '查看线索';
/** 线索全部查看后的按钮文案 */
private static readonly UNLOCK_BUTTON_ANSWER_TEXT = '查看答案';
/** 默认体力上限,服务端未返回 max 时使用 */
private static readonly DEFAULT_STAMINA_MAX = 50;
/** 答案正确后到弹出通关弹窗之间的停留时间(不论是否有谐音梗都保持一致) */
private static readonly PASS_MODAL_DELAY_MS = 800;
/** 图片2描述默认文案 */
private static readonly DEFAULT_IMAGE2_DESCRIPTION = '这是什么?';
/** 线索解锁出现动画时长ms */
private static readonly CLUE_APPEAR_DURATION = 0.3;
/** 线索解锁出现动画起始缩放 */
private static readonly CLUE_APPEAR_START_SCALE = 0.8;
/** 倒计时进入紧迫状态的阈值(秒,≤ 该值开始警示) */
private static readonly CLOCK_URGENT_THRESHOLD = 10;
/** 紧迫状态下倒计时字体颜色(红) */
private static readonly CLOCK_URGENT_COLOR = new Color(230, 60, 60, 255);
/** 倒计时 tick 脉冲峰值缩放 */
private static readonly CLOCK_PULSE_PEAK_SCALE = 1.3;
/** 倒计时 tick 脉冲单向时长(放大、回落各一半) */
private static readonly CLOCK_PULSE_HALF_DURATION = 0.15;
/** 谐音梗揭示动画InputLayout 位移、divider 淡入时长 */
private static readonly PUNCH_REVEAL_DURATION = 0.3;
/** 谐音梗揭示动画punchLayout 出现的起始缩放 */
private static readonly PUNCH_REVEAL_START_SCALE = 0.85;
/** 谐音梗揭示动画punchLayout 在 InputLayout 动起来后再出现的延迟(让动画有节奏) */
private static readonly PUNCH_REVEAL_DELAY = 0.1;
/** 分享模式只展示提示 1 时,固定放回 TipsLayout 顶部,避免 Layout 把它排到底部被下一题按钮遮挡 */
private static readonly SHARE_MODE_TIP1_Y = 120;
/** 分享模式底部按钮默认文案 */
private static readonly SHARE_NEXT_BUTTON_TEXT = '下一题';
/** 分享模式最后一题提交文案 */
private static readonly SHARE_SUBMIT_BUTTON_TEXT = '提交答案';
/** PassNode 滑入 / 滑出动画时长(秒) */
private static readonly PASS_NODE_SLIDE_DURATION = 0.4;
/** BottomLayout / TipsLayout 透明度淡入淡出动画时长(秒) */
private static readonly BOTTOM_LAYER_FADE_DURATION = 0.25;
/** 彩带 spine 动画名(一次播放) */
private static readonly CAIDAI_ANIMATION_NAME = 'open';
/** pose 赞美音效 resources 路径assets/resources/audios/good.mp3 */
private static readonly GOOD_AUDIO_RESOURCE_PATH = 'audios/good';
/** pose 赞美段提前量:比通关动效 / 成功音效结束点提前 1 秒启动 */
private static readonly POSE_PRAISE_ADVANCE_SECONDS = 1.5;
/**
* 通关赞美 spinepose 节点)档位:[最小通关数, 动画名],倒序匹配。
* 1-5 关 → "1"6-10 关 → "2"11+ 关 → "3"。
* count <= 0 不会匹配任何档位,自然不播放。
*/
private static readonly POSE_TIERS: ReadonlyArray<readonly [number, string]> = [
[11, '3'],
[6, '2'],
[1, '1'],
];
// ========== 节点引用 ==========
@property(Node)
inputLayout: Node | null = null;
@property(Node)
punchLayout: Node | null = null;
/** Action 区域内 InputLayout 与 punchLayout 之间的分割线节点prefab 中的 border_dashline_wht */
@property(Node)
punchDivider: Node | null = null;
@property(Node)
submitButton: Node | null = null;
@property(Node)
inputTemplate: Node | null = null;
@property(Node)
actionNode: Node | null = null;
@property(Node)
iconSetting: Node | null = null;
@property(Node)
tipsLayout: Node | null = null;
@property(Node)
mainImage: Node | null = null;
@property(Node)
mainImage2: Node | null = null;
@property(Label)
image1DescLabel: Label | null = null;
@property(Label)
image2DescLabel: Label | null = null;
@property(Node)
tipsItem1: Node | null = null;
@property(Node)
tipsItem2: Node | null = null;
@property(Node)
tipsItem3: Node | null = null;
@property(Node)
unLockTipsBtn: Node | null = null;
@property(Node)
addTimeBtn: Node | null = null;
@property(Label)
clockLabel: Label | null = null;
/** 体力值显示标签prefab 中序列化名为 liveLabel保持兼容 */
@property(Label)
liveLabel: Label | null = null;
/** 关卡标题标签,显示为"第 N 关" */
@property(Label)
titleLevelLabel: Label | null = null;
/** 普通模式背景 */
@property(Node)
bgNode: Node | null = null;
/** 分享 / PK 模式背景 */
@property(Node)
pkBgNode: Node | null = null;
/** 普通模式标题容器 */
@property(Node)
titleLevelNode: Node | null = null;
/** 分享 / PK 模式标题容器 */
@property(Node)
pkTitleLevelNode: Node | null = null;
/** 分享 / PK 模式标题标签 */
@property(Label)
pkTitleLevelLabel: Label | null = null;
/** 普通模式体力区域容器 */
@property(Node)
liveNode: Node | null = null;
/** 分享 / PK 模式进度容器 */
@property(Node)
pkLevelProgressNode: Node | null = null;
/** 分享 / PK 模式进度标签 */
@property(Label)
pkLevelProgressLabel: Label | null = null;
/** 普通模式底部按钮区域 */
@property(Node)
bottomLayoutNode: Node | null = null;
/** 分享 / PK 模式底部下一题按钮 */
@property(Node)
pkNextLevelButton: Node | null = null;
// ========== PassNode通关后展示==========
/** PassNode 根节点:用户通关后从屏幕左侧滑入 */
@property(Node)
passNode: Node | null = null;
/** PassNode 内的成就体系容器TitlteLevel。分享模式下整体隐藏 */
@property(Node)
passTitleLevelNode: Node | null = null;
/** 称号文案 Label如「冷场小白1级」 */
@property(Label)
passTitleLevelLabel: Label | null = null;
/** 称号进度条 */
@property(ProgressBar)
passTitleProgressBar: ProgressBar | null = null;
/** 进度提示 Label如「还差3题解锁新成就等级」 */
@property(Label)
passProgressLabel: Label | null = null;
/** 进度条上跟随移动的 anchor 节点(其下 Label 显示百分比) */
@property(Node)
passProgressAnchor: Node | null = null;
/** 通关页「下一关 / 提交答案」按钮 */
@property(Node)
passNextLevelButton: Node | null = null;
/** 通关页「分享」按钮 */
@property(Node)
passShareButton: Node | null = null;
/** 彩带 spine 根节点(与 PassNode 同级,挂在 PageLevel 下) */
@property(Node)
caidaiNode: Node | null = null;
/** 彩带 sp.Skeleton 组件,用于播放 "open" 动画 */
@property(sp.Skeleton)
caidaiSkeleton: sp.Skeleton | null = null;
/** 通关赞美 spine 节点PassNode 子节点,根据本场通关数选择 1/2/3 动画) */
@property(Node)
poseNode: Node | null = null;
/** 通关赞美 sp.Skeleton 组件,动画名 "1" / "2" / "3"loop=false */
@property(sp.Skeleton)
poseSkeleton: sp.Skeleton | null = null;
// ========== 配置属性 ==========
@property(AudioClip)
clickAudio: AudioClip | null = null;
@property(AudioClip)
successAudio: AudioClip | null = null;
@property(AudioClip)
failAudio: AudioClip | null = null;
@property(Prefab)
wrongModalPrefab: Prefab | null = null;
@property(Prefab)
timeoutModalPrefab: Prefab | null = null;
@property(Prefab)
commonModalPrefab: Prefab | null = null;
/** 主图圆角材质 EffectAsset */
@property(EffectAsset)
roundedSpriteEffect: EffectAsset | null = null;
/** 主图圆角半径比例相对于短边0-0.5 */
@property
mainImageCornerRadius: number = 0.1;
// ========== 内部状态 ==========
/** 当前创建的输入框节点数组 */
private _inputNodes: Node[] = [];
/** InputLayout 中默认放置的输入框模板节点 */
private _inputTemplateNode: Node | null = null;
/** 当前创建的包袱展示块节点数组 */
private _punchBlockNodes: Node[] = [];
/** punchLayout 中默认放置的展示块模板节点 */
private _punchBlockTemplateNode: Node | null = null;
/** 是否正在同步输入格内容,避免设置文本时重复触发事件 */
private _isSyncingInputText: boolean = false;
/** 最近一次自动提交的答案,避免填满后重复提交同一内容 */
private _lastAutoSubmittedAnswer: string = '';
/** 当前正在编辑的输入格索引 */
private _editingInputIndex: number = -1;
/** 倒计时剩余秒数 */
private _countdown: number = 60;
/** clockLabel 非紧迫状态下的原始颜色(首次渲染时懒记录,避免 hardcode prefab 颜色) */
private _clockLabelNormalColor: Color | null = null;
/** InputLayout 原始位置prefab 中的初始 _lpos作为"有梗揭示"的目标位置) */
private _inputLayoutOriginalPos: Vec3 | null = null;
/** punchLayout 原始位置prefab 中的初始 _lpos作为"有梗揭示"的目标位置) */
private _punchLayoutOriginalPos: Vec3 | null = null;
/** "无梗居中态"下 InputLayout 的 Y 坐标InputLayout 原始 Y 与 punchLayout 原始 Y 的中点 */
private _inputLayoutCenteredY: number | null = null;
/** 关卡开始时间戳ms用于准确计算耗时 */
private _levelStartTime: number = 0;
/** 倒计时是否结束 */
private _isTimeUp: boolean = false;
/** 当前关卡配置 */
private _currentConfig: RuntimeLevelConfig | null = null;
/** 是否正在切换关卡(防止重复提交) */
private _isTransitioning: boolean = false;
/** 是否正在解锁提示(防止双击重复触发) */
private _isUnlocking: boolean = false;
/** 下一个待解锁的线索序号2 或 3超过 3 表示全部已解锁 */
private _nextClueIndex: number = 2;
/** 通关页PassNode当前是否已展示 */
private _isPassNodeShown: boolean = false;
/** PassNode 进出动画是否在进行中(防止重入) */
private _isPassNodeAnimating: boolean = false;
/** PassNode 原始 local positionprefab 摆放位),动画结束后用来回归 */
private _passNodeOriginalPos: Vec3 | null = null;
/** PassNode Widget 初始启用状态;进出场动画期间临时关闭,避免激活首帧回写位置 */
private _passNodeWidgetOriginalEnabled: boolean | null = null;
/** 通关页所用「已通关数量」(业务数据,给成就体系展示用) */
private _passCompletedLevelCount: number | null = null;
/**
* 本次进入 PageLevel 后累计通关数onViewLoad / _reinitLevelSession 时归零)。
* 普通模式 / 分享模式都计入;用于驱动 pose 赞美动画档位。
*/
private _sessionPassCount: number = 0;
/** pose Spine 隐藏延时定时器setTimeout 句柄);切关 / 关页时需清理避免穿屏触发 */
private _poseHideTimer: ReturnType<typeof setTimeout> | null = null;
/** pose 赞美延迟播放定时器;等待通关动画与成功音效结束后再触发 */
private _posePraiseDelayTimer: ReturnType<typeof setTimeout> | null = null;
/** pose 赞美延迟播放序号,用于取消 resources.load 异步回调里的过期播放 */
private _posePraiseSequenceId: number = 0;
/** good.mp3 加载缓存 */
private _goodAudioClip: AudioClip | null = null;
/** good.mp3 加载中的 Promise避免重复请求 resources */
private _goodAudioLoadPromise: Promise<AudioClip | null> | null = null;
/** 通关页动画起点(通关前)的已通关数量;为 null 表示不播跨称号过渡 */
private _passPreviousCompletedLevelCount: number | null = null;
/** 通关页称号 / 进度条动画工具,惰性初始化 */
private _titleAnimator: AchievementTitleAnimator | null = null;
/** 错误弹窗实例 */
private _wrongModalNode: Node | null = null;
/** 超时弹窗实例 */
private _timeoutModalNode: Node | null = null;
/** 通用确认弹窗实例 */
private _commonModalNode: Node | null = null;
/** 是否处于分享挑战模式 */
private _isShareMode: boolean = false;
/**
* 当前 PageLevel 实例所处分享挑战的 shareCode 缓存。
* PageLevel 注册时 cache: true复用同一个实例。
* 当用户在后台切换好友分享卡片时ShareManager.shareCode 会发生变化,
* onViewShow 通过对比这个值与最新的 ShareManager.shareCode 来判断是否需要 _reinitLevelSession。
*/
private _activeShareCode: string | null = null;
/** 体力恢复倒计时定时器 */
private _staminaTimerId: ReturnType<typeof setInterval> | null = null;
// ========== 关卡驱动状态NextLevel 驱动) ==========
/** 当前关卡 ID */
private _currentLevelId: string = '';
/** 当前关卡编号(仅显示用,来自 NextLevelData.level */
private _currentLevelNumber: number = 0;
/** 下一关数据(来自 complete 接口返回),点击"下一关"时使用 */
private _nextLevelData: NextLevelData | null = null;
/** 分享模式下的关卡索引(仅分享模式使用) */
private _shareLevelIndex: number = 0;
/** 分享模式下每关最终提交内容,等整场结束后一次性提交 */
private _shareSubmissions: Map<string, SubmitShareLevel> = new Map();
/** 是否正在提交分享挑战结果 */
private _isSubmittingShareResult: boolean = false;
/** 本场分享挑战是否已经尝试拉取过用户头像昵称,避免结算流程重复弹窗 */
private _hasRequestedShareUserInfo: boolean = false;
/**
* 页面首次加载时调用
*/
onViewLoad(): void {
console.log('[PageLevel] onViewLoad');
// 本次进入答题页的会话通关数归零;普通 / 分享模式都重新开始计数
this._sessionPassCount = 0;
// 必须在任何可能改动 InputLayout/punchLayout 位置的逻辑之前记录原始位置
this._captureActionOriginalPositions();
const params = this.getParams();
this._isShareMode = params?.shareMode === true;
if (this._isShareMode) {
this._shareLevelIndex = 0;
this._shareSubmissions.clear();
this._isSubmittingShareResult = false;
this._hasRequestedShareUserInfo = false;
this._activeShareCode = ShareManager.instance.shareCode;
console.log('[PageLevel] 进入分享挑战模式');
} else {
this._activeShareCode = null;
// 从 AuthManager 获取首关数据(由 PageLoading → game-data 提供)
const nextLevel = AuthManager.instance.nextLevel;
if (nextLevel) {
this._currentLevelId = nextLevel.id;
this._currentLevelNumber = nextLevel.level;
console.log(`[PageLevel] 进入关卡: 第 ${nextLevel.level} 关 (${nextLevel.id})`);
} else {
console.warn('[PageLevel] 没有可用关卡');
}
}
this._refreshModeUI();
this.updateStaminaLabel();
this.initIconSetting();
this.initUnlockButtons();
this.initSubmitButton();
this.initPkNextLevelButton();
this._initPassNodeState();
// 异步加载关卡资源并调用进入关卡接口,完成后启动倒计时
this._enterAndInitLevel().catch(err => {
console.error('[PageLevel] 进入关卡失败:', err);
});
}
/**
* 页面每次显示时调用
*/
onViewShow(): void {
console.log('[PageLevel] onViewShow');
// PageLevel 注册时 cache: true缓存实例会被复用。
// 必须根据本次进入时携带的 params 重新派生 _isShareMode
// 否则上一场分享挑战的状态会残留到下一次主线挑战进入。
const params = this.getParams();
const desiredShareMode = params?.shareMode === true;
// 当前 ShareManager 中的 shareCode可能因为后台切到新的分享卡片而变化
const latestShareCode = ShareManager.instance.shareCode;
const modeChanged = desiredShareMode !== this._isShareMode;
// 同样是分享模式,但 ShareManager 中的 shareCode 已经换了一份题单 —— 也必须重建会话
const shareCodeChanged = desiredShareMode && latestShareCode !== this._activeShareCode;
if (modeChanged || shareCodeChanged) {
console.log(
`[PageLevel] 检测到模式/分享码切换 mode:${this._isShareMode}->${desiredShareMode} ` +
`code:${this._activeShareCode}->${latestShareCode},重新初始化关卡会话`,
);
this._reinitLevelSession(desiredShareMode);
return;
}
// 上一次离场时如果停留在「答对后通关流程」_isTransitioning=true 由 showSuccess 置位、
// 而 _applyLevelConfig 才会重置),缓存的 PageLevel 实例会保留完成态:
// - 输入格已填入正确答案
// - 提交按钮被 _isTransitioning 锁住,无法重新提交
// - 倒计时已停、谐音梗已揭示
// 此时玩家从首页再次进入会看到一个无法操作的"死局"。必须把会话推进到下一关。
// 注意:这只可能在主线模式发生 —— 分享模式下点 iconSetting / PassNode 的入口
// 都会调用 ShareManager.clearShareMode + ViewManager.replace再次进入会被
// 上面的 modeChanged / shareCodeChanged 分支拦截走 _reinitLevelSession。
if (this._isTransitioning) {
console.log('[PageLevel] 上次离场时停留在通关后状态,自动推进到下一关');
this._resetPassNode();
this._closeWrongModal();
this._closeTimeoutModal();
this._closeCommonModal();
void this.goToNextLevel();
return;
}
this._refreshModeUI();
this.updateStaminaLabel();
if (!this._isShareMode) {
this._startStaminaRecoverTimer();
}
}
/**
* 跨模式切换时(例如分享挑战 → 主线挑战)重置会话状态并重新加载关卡。
* 仅在 onViewShow 检测到模式发生变化时调用,避免对正常的同模式连续作答产生副作用。
*/
private _reinitLevelSession(shareMode: boolean): void {
this._isShareMode = shareMode;
// 跨模式切换视为新的"本次进入"会话,赞美动画从最低档位重新开始
this._sessionPassCount = 0;
// 上一场可能遗留的弹窗 / 倒计时一并清掉,避免主线模式还看到分享态弹窗
this._resetPassNode();
this._closeWrongModal();
this._closeTimeoutModal();
this._closeCommonModal();
this.stopCountdown();
this._stopStaminaRecoverTimer();
// 复位分享态相关字段(无论切换到哪个模式,分享态都不应残留)
this._shareLevelIndex = 0;
this._shareSubmissions.clear();
this._isSubmittingShareResult = false;
this._hasRequestedShareUserInfo = false;
if (this._isShareMode) {
this._activeShareCode = ShareManager.instance.shareCode;
console.log(`[PageLevel] 切换到分享挑战模式 (shareCode=${this._activeShareCode})`);
} else {
this._activeShareCode = null;
// 主线模式:从 AuthManager 拉取最新的 nextLevel
this._nextLevelData = null;
const nextLevel = AuthManager.instance.nextLevel;
if (nextLevel) {
this._currentLevelId = nextLevel.id;
this._currentLevelNumber = nextLevel.level;
console.log(`[PageLevel] 切换到主线挑战,进入关卡: 第 ${nextLevel.level} 关 (${nextLevel.id})`);
} else {
this._currentLevelId = '';
this._currentLevelNumber = 0;
console.warn('[PageLevel] 切换到主线挑战,但没有可用关卡');
}
}
this._refreshModeUI();
this.updateStaminaLabel();
if (!this._isShareMode) {
this._startStaminaRecoverTimer();
}
// 异步加载关卡资源并调用进入关卡接口,完成后启动倒计时
this._enterAndInitLevel().catch(err => {
console.error('[PageLevel] 模式切换后重新进入关卡失败:', err);
});
}
/**
* 页面隐藏时调用
*/
onViewHide(): void {
console.log('[PageLevel] onViewHide');
this._stopStaminaRecoverTimer();
}
/**
* 页面销毁时调用
*/
onViewDestroy(): void {
console.log('[PageLevel] onViewDestroy');
this.clearInputNodes();
this.clearPunchBlocks();
this.stopCountdown();
this._resetPassNode();
this._closeWrongModal();
this._closeTimeoutModal();
this._closeCommonModal();
this._stopStaminaRecoverTimer();
// 清理事件监听
this.iconSetting?.off(Node.EventType.TOUCH_END, this.onIconSettingClick, this);
this.unLockTipsBtn?.off(Node.EventType.TOUCH_END);
this.addTimeBtn?.off(Node.EventType.TOUCH_END);
this.submitButton?.off(Node.EventType.TOUCH_END, this.onSubmitAnswer, this);
this.pkNextLevelButton?.off(Node.EventType.TOUCH_END, this.onPkNextLevelClick, this);
}
/**
* 进入关卡并初始化
* 1. 加载关卡图片资源(从缓存或 NextLevelData
* 2. 调用进入关卡接口(消耗体力,获取答案和线索)
* 3. 启动倒计时
*/
private async _enterAndInitLevel(): Promise<void> {
let config: RuntimeLevelConfig | null = null;
if (this._isShareMode) {
// 分享模式:使用 ShareManager 的关卡数据
config = await ShareManager.instance.ensureShareLevelReady(this._shareLevelIndex);
} else {
// 正常模式先尝试从缓存获取PageLoading 初始化时已加载首关)
config = LevelDataManager.instance.getLevelConfig(this._currentLevelId);
if (!config) {
// 缓存未命中,从 nextLevel 数据加载complete 返回的下一关)
const nextLevelData = this._nextLevelData ?? AuthManager.instance.nextLevel;
if (nextLevelData && nextLevelData.id === this._currentLevelId) {
console.log(`[PageLevel] 关卡 ${this._currentLevelId} 资源未缓存,开始加载...`);
config = await LevelDataManager.instance.ensureLevelReady(nextLevelData);
}
}
}
if (!config) {
console.warn(`[PageLevel] 没有找到关卡配置ID: ${this._currentLevelId}`);
return;
}
// 非分享模式下,调用进入关卡接口获取答案和线索
if (!this._isShareMode) {
const enterData = await StaminaManager.instance.enterLevel(this._currentLevelId);
if (!enterData) {
// 进入关卡失败(可能是体力不足)
const stamina = StaminaManager.instance.getStamina();
if (stamina.current <= 0) {
ToastManager.show('体力不足,请等待恢复');
this._startStaminaRecoverTimer();
} else {
ToastManager.show('进入关卡失败,请重试');
}
this.updateStaminaLabel();
return;
}
// 用 enter 接口返回的数据更新关卡配置(填充答案和线索)
LevelDataManager.instance.updateLevelDetails(
this._currentLevelId,
{
answer: enterData.answer,
image1Description: enterData.image1Description,
image2Description: enterData.image2Description,
punchline: enterData.punchline,
hint1: enterData.hint1,
hint2: enterData.hint2,
hint3: enterData.hint3,
}
);
// 重新获取更新后的配置
config = LevelDataManager.instance.getLevelConfig(this._currentLevelId);
if (!config) {
console.error('[PageLevel] 更新关卡详情后获取配置失败');
return;
}
// 更新体力显示
this.updateStaminaLabel();
// 预加载下一关图片enter 返回的 preloadNextLevel
if (enterData.preloadNextLevel) {
LevelDataManager.instance.preloadLevel(enterData.preloadNextLevel);
}
}
console.log(`[PageLevel] 初始化关卡 第${this._currentLevelNumber}关: ${config.name}`);
```

View File

@@ -0,0 +1,770 @@
# 梗中作乐 V1.0 - 源代码文档(第 6 部分)
软件名称:梗中作乐
版本号V1.0
---
## 第 6 部分:关卡周边模块
本部分包含关卡数据缓存、答错弹窗、超时弹窗、通关关卡列表四个与关卡流程紧密关联的模块。
### 6.1 关卡数据管理器 (assets/scripts/utils/LevelDataManager.ts)
```typescript
import { SpriteFrame, Texture2D, ImageAsset, assetManager } from 'cc';
import { RuntimeLevelConfig } from '../types/LevelTypes';
import { NextLevelData } from '../types/ApiTypes';
/**
* 进度回调类型
* @param progress 进度值 0-1
* @param message 进度消息
*/
export type ProgressCallback = (progress: number, message: string) => void;
/**
* 关卡数据管理器
* 单例模式,管理当前关卡和预加载关卡的图片资源
* 不再依赖全量关卡列表,由外部传入 NextLevelData 驱动
*/
export class LevelDataManager {
private static _instance: LevelDataManager | null = null;
/** 运行时关卡配置缓存(按 levelId 索引) */
private _levelConfigs: Map<string, RuntimeLevelConfig> = new Map();
/** 图片缓存URL -> SpriteFrame */
private _imageCache: Map<string, SpriteFrame> = new Map();
/** 正在加载中的关卡 ID 集合 */
private _loadingLevels: Set<string> = new Set();
/** 是否已初始化 */
private _initialized: boolean = false;
/**
* 获取单例实例
*/
static get instance(): LevelDataManager {
if (!this._instance) {
this._instance = new LevelDataManager();
}
return this._instance;
}
private constructor() {}
/**
* 初始化:加载首关图片
* 由 PageLoading 在获取 game-data 后调用,传入 nextLevel 数据
* @param nextLevel 首关数据(来自 game-data 接口)
* @param onProgress 进度回调
* @returns 是否初始化成功
*/
async initialize(nextLevel: NextLevelData, onProgress?: ProgressCallback): Promise<boolean> {
console.log(`[LevelDataManager] 开始初始化,加载关卡 ${nextLevel.level}`);
try {
onProgress?.(0.3, '正在加载游戏必备资源...');
const config = await this._loadLevelFromData(nextLevel);
if (!config) {
console.error('[LevelDataManager] 初始化失败:图片加载失败');
onProgress?.(0.3, '资源加载失败,请重新打开游戏');
return false;
}
this._initialized = true;
console.log('[LevelDataManager] 初始化完成');
onProgress?.(0.8, '游戏资源加载完成');
return true;
} catch (error) {
console.error('[LevelDataManager] 初始化失败:', error);
onProgress?.(0.3, '网络异常,请重新打开游戏');
return false;
}
}
/**
* 是否已初始化
*/
isInitialized(): boolean {
return this._initialized;
}
/**
* 获取指定关卡配置(按 ID
* @param levelId 关卡 ID
*/
getLevelConfig(levelId: string): RuntimeLevelConfig | null {
return this._levelConfigs.get(levelId) ?? null;
}
/**
* 用 enter 接口返回的数据更新运行时关卡配置(填充答案和线索)
* @param levelId 关卡 ID
* @param details enter 接口返回的详情
*/
updateLevelDetails(levelId: string, details: {
answer: string;
image1Description: string | null;
image2Description: string | null;
punchline: string | null;
hint1: string | null;
hint2: string | null;
hint3: string | null;
}): void {
const config = this._levelConfigs.get(levelId);
if (!config) {
console.warn(`[LevelDataManager] 关卡 ${levelId} 配置不存在,无法更新详情`);
return;
}
this._levelConfigs.set(levelId, {
...config,
answer: details.answer,
image1Description: details.image1Description ?? config.image1Description,
image2Description: details.image2Description ?? config.image2Description,
punchline: details.punchline ?? config.punchline,
clue1: details.hint1 ?? null,
clue2: details.hint2 ?? null,
clue3: details.hint3 ?? null,
});
console.log(`[LevelDataManager] 关卡 ${levelId} 详情已更新`);
}
/**
* 加载并缓存一个关卡(同步等待图片加载完成)
* 用于 game-data 返回的 nextLevel 或 complete 返回的 nextLevel
* @param data NextLevelData
* @returns 加载好的 RuntimeLevelConfig失败返回 null
*/
async ensureLevelReady(data: NextLevelData): Promise<RuntimeLevelConfig | null> {
// 检查缓存
const cached = this._levelConfigs.get(data.id);
if (cached) {
return cached;
}
// 检查是否正在加载
if (this._loadingLevels.has(data.id)) {
console.log(`[LevelDataManager] 关卡 ${data.id} 正在加载中...`);
return null;
}
return this._loadLevelFromData(data);
}
/**
* 预加载关卡图片(静默加载,不阻塞)
* 用于 enter 返回的 preloadNextLevel
* @param data NextLevelData
*/
preloadLevel(data: NextLevelData): void {
// 已缓存
if (this._levelConfigs.has(data.id)) {
console.log(`[LevelDataManager] 关卡 ${data.id} 已加载`);
return;
}
// 正在加载
if (this._loadingLevels.has(data.id)) {
console.log(`[LevelDataManager] 关卡 ${data.id} 正在加载中`);
return;
}
// 异步加载,不等待
console.log(`[LevelDataManager] 开始预加载关卡 ${data.id}...`);
this._loadLevelFromData(data).catch(err => {
console.error(`[LevelDataManager] 预加载关卡失败:`, err);
});
}
/**
* 检查指定关卡图片是否已加载
* @param levelId 关卡 ID
*/
isLevelImageLoaded(levelId: string): boolean {
return this._levelConfigs.has(levelId);
}
/**
* 清除缓存
*/
clearCache(): void {
this._levelConfigs.clear();
this._loadingLevels.clear();
this._imageCache.clear();
this._initialized = false;
console.log('[LevelDataManager] 缓存已清除');
}
/**
* 从 NextLevelData 加载图片并创建 RuntimeLevelConfig
*/
private async _loadLevelFromData(data: NextLevelData): Promise<RuntimeLevelConfig | null> {
this._loadingLevels.add(data.id);
console.log(`[LevelDataManager] 开始加载关卡 ${data.id} 资源...`);
try {
const [spriteFrame1, spriteFrame2] = await Promise.all([
this._loadImage(data.image1Url),
this._loadImage(data.image2Url),
]);
if (!spriteFrame1) {
console.error(`[LevelDataManager] 加载关卡 ${data.id} 图片1失败`);
return null;
}
const config = this._createRuntimeConfig(data, spriteFrame1, spriteFrame2);
this._levelConfigs.set(data.id, config);
console.log(`[LevelDataManager] 关卡 ${data.id} 资源加载完成`);
return config;
} finally {
this._loadingLevels.delete(data.id);
}
}
/**
* 创建运行时关卡配置
*/
private _createRuntimeConfig(data: NextLevelData, spriteFrame1: SpriteFrame | null, spriteFrame2: SpriteFrame | null): RuntimeLevelConfig {
return {
id: data.id,
name: `${data.level}`,
spriteFrame1,
spriteFrame2,
image1Description: data.image1Description,
image2Description: data.image2Description,
punchline: data.punchline,
clue1: data.hint1,
clue2: data.hint2,
clue3: data.hint3,
answer: data.answer,
completed: false,
timeLimit: data.timeLimit,
};
}
/**
* 加载远程图片为 SpriteFrame
* @param url 图片 URL
*/
private async _loadImage(url: string): Promise<SpriteFrame | null> {
// 检查缓存
const cached = this._imageCache.get(url);
if (cached) {
return cached;
}
return new Promise((resolve) => {
assetManager.loadRemote<ImageAsset>(url, (err, imageAsset) => {
if (err) {
console.error(`[LevelDataManager] 加载图片失败: ${url}`, err);
resolve(null);
return;
}
const texture = new Texture2D();
texture.image = imageAsset;
const spriteFrame = new SpriteFrame();
spriteFrame.texture = texture;
// 缓存
this._imageCache.set(url, spriteFrame);
resolve(spriteFrame);
});
});
}
}
```
### 6.2 答错弹窗 (assets/prefabs/WrongModal.ts)
```typescript
import { _decorator, Node, view, UITransform, Size } from 'cc';
import { BaseModal } from 'db://assets/scripts/core/BaseModal';
const { ccclass, property } = _decorator;
/**
* WrongModal 回调接口
*/
export interface WrongModalCallbacks {
/** 点击继续挑战 / 关闭按钮回调 */
onContinue?: () => void;
}
/**
* 答案错误弹窗组件
* 继承 BaseModal显示答案错误提示提供"继续挑战"和关闭按钮
*/
@ccclass('WrongModal')
export class WrongModal extends BaseModal {
/** 静态常量:弹窗层级 */
public static readonly MODAL_Z_INDEX = 999;
/** 关闭按钮 */
@property(Node)
closeBtn: Node | null = null;
/** 继续挑战按钮 */
@property(Node)
buttonHint: Node | null = null;
/** 回调函数 */
private _callbacks: WrongModalCallbacks = {};
/** 缓存的屏幕尺寸 */
private _screenSize: Size | null = null;
/**
* 设置回调函数
*/
setCallbacks(callbacks: WrongModalCallbacks): void {
this._callbacks = callbacks;
}
/**
* 页面首次加载时调用
*/
onViewLoad(): void {
console.log('[WrongModal] onViewLoad');
this._bindButtonEvents();
}
/**
* 页面每次显示时调用
*/
onViewShow(): void {
super.onViewShow();
this._updateWidget();
}
/**
* 页面销毁时调用
*/
onViewDestroy(): void {
this._unbindButtonEvents();
}
/**
* 设置弹窗尺寸为全屏
*/
private _updateWidget(): void {
if (!this._screenSize) {
this._screenSize = view.getVisibleSize();
}
const uiTransform = this.node.getComponent(UITransform);
if (uiTransform) {
uiTransform.setContentSize(this._screenSize.width, this._screenSize.height);
}
}
/**
* 绑定按钮事件
*/
private _bindButtonEvents(): void {
if (this.closeBtn) {
this.closeBtn.on(Node.EventType.TOUCH_END, this._onContinueClick, this);
}
if (this.buttonHint) {
this.buttonHint.on(Node.EventType.TOUCH_END, this._onContinueClick, this);
}
}
/**
* 解除按钮事件绑定
*/
private _unbindButtonEvents(): void {
if (this.closeBtn && this.closeBtn.isValid) {
this.closeBtn.off(Node.EventType.TOUCH_END, this._onContinueClick, this);
}
if (this.buttonHint && this.buttonHint.isValid) {
this.buttonHint.off(Node.EventType.TOUCH_END, this._onContinueClick, this);
}
}
/**
* 继续挑战 / 关闭按钮点击
*/
private _onContinueClick(): void {
console.log('[WrongModal] 点击继续挑战');
this._callbacks.onContinue?.();
}
}
```
### 6.3 超时弹窗 (assets/prefabs/TimeoutModal.ts)
```typescript
import { _decorator, Node, view, UITransform, Size } from 'cc';
import { BaseModal } from 'db://assets/scripts/core/BaseModal';
import { WxSDK } from 'db://assets/scripts/utils/WxSDK';
const { ccclass, property } = _decorator;
/**
* TimeoutModal 回调接口
*/
export interface TimeoutModalCallbacks {
/** 点击求助好友回调 */
onShare?: () => void;
/** 点击再次挑战回调 */
onRestart?: () => void;
/** 点击下一题回调 */
onNext?: () => void;
/** 点击返回主页 / 关闭按钮回调 */
onHome?: () => void;
}
interface TimeoutModalParams {
levelIndex?: number;
shareMode?: boolean;
}
/**
* 时间耗尽弹窗组件
* 继承 BaseModal显示倒计时结束提示提供"求助好友"、"再次挑战"和"返回主页"三个按钮
*/
@ccclass('TimeoutModal')
export class TimeoutModal extends BaseModal {
/** 静态常量:弹窗层级 */
public static readonly MODAL_Z_INDEX = 999;
/** 关闭按钮 */
@property(Node)
closeBtn: Node | null = null;
/** 求助好友按钮 */
@property(Node)
buttonShare: Node | null = null;
/** 再次挑战按钮 */
@property(Node)
buttonRestart: Node | null = null;
/** 下一题按钮(分享挑战超时时显示) */
@property(Node)
buttonNext: Node | null = null;
/** 返回主页按钮 */
@property(Node)
buttonHome: Node | null = null;
/** 回调函数 */
private _callbacks: TimeoutModalCallbacks = {};
/** 缓存的屏幕尺寸 */
private _screenSize: Size | null = null;
/**
* 设置回调函数
*/
setCallbacks(callbacks: TimeoutModalCallbacks): void {
this._callbacks = callbacks;
}
/**
* 页面首次加载时调用
*/
onViewLoad(): void {
console.log('[TimeoutModal] onViewLoad');
this._resolveNodes();
this._bindButtonEvents();
this._refreshModeButtons();
}
/**
* 页面每次显示时调用
*/
onViewShow(): void {
super.onViewShow();
this._updateWidget();
this._refreshModeButtons();
}
/**
* 页面销毁时调用
*/
onViewDestroy(): void {
this._unbindButtonEvents();
}
/**
* 设置弹窗尺寸为全屏
*/
private _updateWidget(): void {
if (!this._screenSize) {
this._screenSize = view.getVisibleSize();
}
const uiTransform = this.node.getComponent(UITransform);
if (uiTransform) {
uiTransform.setContentSize(this._screenSize.width, this._screenSize.height);
}
}
/**
* 绑定按钮事件
*/
private _bindButtonEvents(): void {
if (this.closeBtn) {
this.closeBtn.on(Node.EventType.TOUCH_END, this._onHomeClick, this);
}
if (this.buttonShare) {
this.buttonShare.on(Node.EventType.TOUCH_END, this._onShareClick, this);
}
if (this.buttonRestart) {
this.buttonRestart.on(Node.EventType.TOUCH_END, this._onRestartClick, this);
}
if (this.buttonNext) {
this.buttonNext.on(Node.EventType.TOUCH_END, this._onNextClick, this);
}
if (this.buttonHome) {
this.buttonHome.on(Node.EventType.TOUCH_END, this._onHomeClick, this);
}
}
/**
* 解除按钮事件绑定
*/
private _unbindButtonEvents(): void {
if (this.closeBtn && this.closeBtn.isValid) {
this.closeBtn.off(Node.EventType.TOUCH_END, this._onHomeClick, this);
}
if (this.buttonShare && this.buttonShare.isValid) {
this.buttonShare.off(Node.EventType.TOUCH_END, this._onShareClick, this);
}
if (this.buttonRestart && this.buttonRestart.isValid) {
this.buttonRestart.off(Node.EventType.TOUCH_END, this._onRestartClick, this);
}
if (this.buttonNext && this.buttonNext.isValid) {
this.buttonNext.off(Node.EventType.TOUCH_END, this._onNextClick, this);
}
if (this.buttonHome && this.buttonHome.isValid) {
this.buttonHome.off(Node.EventType.TOUCH_END, this._onHomeClick, this);
}
}
private _resolveNodes(): void {
this.closeBtn = this.closeBtn ?? this._findChild(this.node, 'closeBtn');
this.buttonShare = this.buttonShare ?? this._findChild(this.node, 'ButtonShare');
this.buttonRestart = this.buttonRestart ?? this._findChild(this.node, 'ButtonRestart');
this.buttonNext = this.buttonNext ?? this._findChild(this.node, 'ButtonNext');
this.buttonHome = this.buttonHome ?? this._findChild(this.node, 'ButtonHome');
}
private _refreshModeButtons(): void {
const isShareMode = this._params?.shareMode === true;
if (this.buttonRestart) {
this.buttonRestart.active = !isShareMode;
}
if (this.buttonNext) {
this.buttonNext.active = isShareMode;
}
}
/**
* 求助好友按钮点击
*/
private _onShareClick(): void {
console.log('[TimeoutModal] 点击求助好友');
WxSDK.shareAppMessage({
title: '这道题太难了,快来帮帮我!',
query: `level=${this._params?.levelIndex ?? 1}`
});
this._callbacks.onShare?.();
}
/**
* 再次挑战按钮点击
*/
private _onRestartClick(): void {
console.log('[TimeoutModal] 点击再次挑战');
this._callbacks.onRestart?.();
}
/**
* 下一题按钮点击
*/
private _onNextClick(): void {
console.log('[TimeoutModal] 点击下一题');
this._callbacks.onNext?.();
}
/**
* 返回主页 / 关闭按钮点击
*/
private _onHomeClick(): void {
console.log('[TimeoutModal] 点击返回主页');
this._callbacks.onHome?.();
}
private _findChild(root: Node, nodeName: string): Node | null {
if (root.name === nodeName) {
return root;
}
for (const child of root.children) {
const found = this._findChild(child, nodeName);
if (found) {
return found;
}
}
return null;
}
}
```
### 6.4 通关关卡列表管理器 (assets/scripts/utils/CompletedLevelsManager.ts)
```typescript
import { SpriteFrame, Texture2D, ImageAsset, assetManager } from 'cc';
import { HttpUtil } from './HttpUtil';
import { API_ENDPOINTS, API_TIMEOUT } from '../config/ApiConfig';
import { ApiEnvelope, CompletedLevel } from '../types/ApiTypes';
/**
* 已通关关卡管理器
* 单例模式,负责拉取当前用户所有已通关关卡 + 封面图缓存
* 适用于「成就墙」「关卡回看」「出题 / 预览」等场景
*/
export class CompletedLevelsManager {
private static _instance: CompletedLevelsManager | null = null;
/** 关卡数据按服务端返回顺序缓存 */
private _levels: CompletedLevel[] = [];
/** 是否已经成功拉取过一次 */
private _loaded: boolean = false;
/** 图片缓存URL -> SpriteFrame */
private _imageCache: Map<string, SpriteFrame> = new Map();
/** 正在进行中的请求,用于去重并发调用 */
private _inflight: Promise<CompletedLevel[] | null> | null = null;
static get instance(): CompletedLevelsManager {
if (!this._instance) {
this._instance = new CompletedLevelsManager();
}
return this._instance;
}
private constructor() {}
/**
* 获取已缓存的关卡列表(需先 fetch 或 ensureLoaded
*/
get levels(): CompletedLevel[] {
return this._levels;
}
get count(): number {
return this._levels.length;
}
isLoaded(): boolean {
return this._loaded;
}
/**
* 按索引0-based获取关卡越界返回 null
*/
getByIndex(index: number): CompletedLevel | null {
if (index < 0 || index >= this._levels.length) return null;
return this._levels[index];
}
/**
* 拉取并缓存已通关关卡列表
* - forceRefresh=true 强制重新请求
* - 并发调用会共享同一次请求
*/
async fetch(forceRefresh: boolean = false): Promise<CompletedLevel[] | null> {
if (!forceRefresh && this._loaded) {
return this._levels;
}
if (this._inflight) {
return this._inflight;
}
this._inflight = this._doFetch();
try {
return await this._inflight;
} finally {
this._inflight = null;
}
}
private async _doFetch(): Promise<CompletedLevel[] | null> {
try {
const response = await HttpUtil.get<ApiEnvelope<CompletedLevel[]>>(
API_ENDPOINTS.COMPLETED_LEVELS,
API_TIMEOUT.DEFAULT,
);
if (!response.success || !response.data) {
console.error('[CompletedLevelsManager] 拉取失败:', response.message);
return null;
}
this._levels = response.data;
this._loaded = true;
console.log(`[CompletedLevelsManager] 拉取成功,共 ${this._levels.length}`);
return this._levels;
} catch (err) {
console.error('[CompletedLevelsManager] 拉取异常:', err);
return null;
}
}
/**
* 按图片 URL 加载并缓存 SpriteFrame
* 已缓存直接返回
*/
loadImage(url: string): Promise<SpriteFrame | null> {
if (!url) return Promise.resolve(null);
const cached = this._imageCache.get(url);
if (cached) {
return Promise.resolve(cached);
}
return new Promise((resolve) => {
assetManager.loadRemote<ImageAsset>(url, (err, imageAsset) => {
if (err) {
console.error('[CompletedLevelsManager] 加载图片失败:', url, err);
resolve(null);
return;
}
const texture = new Texture2D();
texture.image = imageAsset;
const spriteFrame = new SpriteFrame();
spriteFrame.texture = texture;
this._imageCache.set(url, spriteFrame);
resolve(spriteFrame);
});
});
}
/** 清除缓存(登出等场景) */
clear(): void {
this._levels = [];
this._loaded = false;
this._imageCache.clear();
this._inflight = null;
}
}
```

View File

@@ -0,0 +1,743 @@
# 梗中作乐 V1.0 - 源代码文档(第 7 部分)
软件名称:梗中作乐
版本号V1.0
---
## 第 7 部分PK 出题选关页面
本部分展示好友 PK 挑战发起方的「出题选关」页面:用户从已通关关卡里选 6 关,命名后调用后端创建分享码。
### 7.1 出题选关页 (assets/prefabs/PageWriteLevels.ts)
```typescript
import { _decorator, Node, Button, Sprite, Label, Toggle, ScrollView, EditBox, instantiate, UITransform, Vec2, EventTouch, EffectAsset, Prefab } from 'cc';
import { BaseView } from 'db://assets/scripts/core/BaseView';
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
import { CommonModal } from 'db://assets/prefabs/CommonModal';
import { CompletedLevelsManager } from 'db://assets/scripts/utils/CompletedLevelsManager';
import { ToastManager } from 'db://assets/scripts/utils/ToastManager';
import { ShareManager } from 'db://assets/scripts/utils/ShareManager';
import { StorageManager } from 'db://assets/scripts/utils/StorageManager';
import { WxSDK, getUserProfile } from 'db://assets/scripts/utils/WxSDK';
import { AuthManager } from 'db://assets/scripts/utils/AuthManager';
import { API_ENDPOINTS, API_TIMEOUT } from 'db://assets/scripts/config/ApiConfig';
import { HttpUtil } from 'db://assets/scripts/utils/HttpUtil';
import { ApiEnvelope, CompletedLevel } from 'db://assets/scripts/types/ApiTypes';
import { applyRoundedCorner } from 'db://assets/scripts/utils/roundedMaterial.utils';
import { AudioManager } from 'db://assets/scripts/utils/AudioManager';
const { ccclass, property } = _decorator;
/**
* 布局配置
* view (ScrollView 的可视窗口) 宽 900高 1100
* 关卡 item 固定两列,纵向滚动
*
* item 的实际显示尺寸从 ListTpl 的 UITransform * scale 派生,
* 避免代码里再维护一套和 prefab 脱节的宽高。
*/
const LAYOUT_CONFIG = {
COLS: 2,
SPACING_X: 36,
SPACING_Y: 48,
EDGE_PADDING_Y: 32,
CENTER_ROWS: 2,
VIEW_WIDTH: 900,
VIEW_HEIGHT: 1300,
LIST_BOTTOM_GAP_TO_TITLE: 54,
};
/** 必须选择的关卡数量 */
const MAX_SELECTION = 6;
@ccclass('PageWriteLevels')
export class PageWriteLevels extends BaseView {
@property({ type: Node, tooltip: '返回按钮' })
backBtn: Node | null = null;
@property({ type: Node, tooltip: 'ScrollView可视区域节点' })
scrollView: Node | null = null;
@property({ type: Node, tooltip: '列表content节点' })
listContent: Node | null = null;
@property({ type: Node, tooltip: '列表项模板' })
listTemplate: Node | null = null;
@property({ type: Node, tooltip: '已选关卡提示Label节点' })
selectedLabel: Node | null = null;
@property({ type: Node, tooltip: '完成按钮节点' })
completeBtn: Node | null = null;
@property({ type: Node, tooltip: '预览按钮节点' })
previewBtn: Node | null = null;
@property({ type: Node, tooltip: '分享标题输入框节点' })
shareTitleEditBox: Node | null = null;
@property({ type: Node, tooltip: '挑战数据按钮节点' })
dataBtn: Node | null = null;
@property({ type: EffectAsset, tooltip: '关卡封面圆角材质 EffectAsset' })
roundedSpriteEffect: EffectAsset | null = null;
@property({ type: Prefab, tooltip: '通用弹窗预制体' })
commonModalPrefab: Prefab | null = null;
@property({ tooltip: '关卡封面圆角半径比例相对于短边0-0.5' })
coverCornerRadius: number = 0.1;
private _selectedIndices: Set<number> = new Set();
private _levels: CompletedLevel[] = [];
private _levelCount: number = 0;
private _itemNodes: Node[] = [];
private _scrollViewComp: ScrollView | null = null;
private _levelListLoadToken: number = 0;
/** 缓存 view 节点的 UITransform避免每次 _updateContentSize 重复查找 */
private _viewTransform: UITransform | null = null;
/** 防止重复提交 */
private _isSubmitting: boolean = false;
onViewLoad(): void {
console.log('[PageWriteLevels] onViewLoad');
this._initButtons();
this._initScrollView();
this._resizeScrollViewport();
this._updateSelectionUI();
}
private _initButtons(): void {
if (this.backBtn) {
this.backBtn.on(Button.EventType.CLICK, this._onBackClick, this);
}
if (this.previewBtn) {
this.previewBtn.on(Button.EventType.CLICK, this._onPreviewClick, this);
}
if (this.completeBtn) {
this.completeBtn.on(Button.EventType.CLICK, this._onCompleteClick, this);
}
if (this.dataBtn) {
this.dataBtn.on(Button.EventType.CLICK, this._onDataClick, this);
}
}
private _initScrollView(): void {
if (this.scrollView) {
this._scrollViewComp = this.scrollView.getComponent(ScrollView);
if (this._scrollViewComp) {
this._scrollViewComp.horizontal = false;
this._scrollViewComp.vertical = true;
this._scrollViewComp.inertia = true;
}
// 缓存 view 的 UITransform
const viewNode = this.scrollView.getChildByName('view');
if (viewNode) {
this._viewTransform = viewNode.getComponent(UITransform);
}
}
// content anchor 设为 (0, 1) 左上角,方便位置计算且符合 ScrollView 标准用法
if (this.listContent) {
const contentTransform = this.listContent.getComponent(UITransform);
if (contentTransform) {
contentTransform.setAnchorPoint(0, 1);
}
}
}
onViewShow(): void {
console.log('[PageWriteLevels] onViewShow');
this._resizeScrollViewport();
this._updateContentSize();
void this._initLevelList();
}
private _resizeScrollViewport(): void {
if (!this.scrollView || !this._viewTransform) {
return;
}
const scrollTransform = this.scrollView.getComponent(UITransform);
const scrollWidget = this.scrollView.getComponent('cc.Widget') as any;
const viewWidget = this._viewTransform.node.getComponent('cc.Widget') as any;
if (!scrollTransform) {
return;
}
scrollWidget?.updateAlignment?.();
viewWidget?.updateAlignment?.();
this._viewTransform.setContentSize(
scrollTransform.contentSize.width,
scrollTransform.contentSize.height,
);
}
private async _initLevelList(): Promise<void> {
const loadToken = ++this._levelListLoadToken;
const selectedLevelIds = this._getSelectedLevelIdSet();
// 拉取当前用户所有已通关关卡
const levels = await CompletedLevelsManager.instance.fetch(true);
if (loadToken !== this._levelListLoadToken) {
return;
}
if (levels === null) {
console.warn('[PageWriteLevels] 获取已通关关卡失败');
ToastManager.instance.show('获取关卡列表失败,请稍后重试');
return;
}
this._clearList();
this._levels = levels;
this._levelCount = this._levels.length;
this._restoreSelectedIndices(selectedLevelIds);
console.log('[PageWriteLevels] 已通关关卡总数:', this._levelCount);
if (this._levelCount === 0) {
console.warn('[PageWriteLevels] 用户尚未通关任何关卡');
ToastManager.instance.show('还没有已通关的关卡,快去玩几关吧');
this._updateContentSize();
this._updateSelectionUI();
return;
}
this._updateContentSize();
this._createItems();
if (this._scrollViewComp) {
this._scrollViewComp.scrollToTop(0);
}
}
private _getSelectedLevelIdSet(): Set<string> {
const selectedLevelIds = new Set<string>();
for (const index of this._selectedIndices) {
const level = this._levels[index] ?? null;
if (level) {
selectedLevelIds.add(level.id);
}
}
return selectedLevelIds;
}
private _restoreSelectedIndices(selectedLevelIds: Set<string>): void {
if (selectedLevelIds.size === 0) {
return;
}
for (let index = 0; index < this._levels.length; index++) {
if (selectedLevelIds.has(this._levels[index].id)) {
this._selectedIndices.add(index);
}
}
}
private _clearList(): void {
for (const node of this._itemNodes) {
if (node && node.isValid) {
node.destroy();
}
}
this._levels = [];
this._itemNodes = [];
this._selectedIndices.clear();
}
private _getViewSize(): { width: number, height: number } {
return {
width: this._viewTransform?.contentSize.width ?? LAYOUT_CONFIG.VIEW_WIDTH,
height: this._viewTransform?.contentSize.height ?? LAYOUT_CONFIG.VIEW_HEIGHT,
};
}
private _getItemDisplaySize(): { width: number, height: number } {
if (!this.listTemplate) {
return { width: 0, height: 0 };
}
const itemTransform = this.listTemplate.getComponent(UITransform);
if (!itemTransform) {
return { width: 0, height: 0 };
}
return {
width: itemTransform.contentSize.width * Math.abs(this.listTemplate.scale.x),
height: itemTransform.contentSize.height * Math.abs(this.listTemplate.scale.y),
};
}
private _getRowCount(): number {
return Math.ceil(this._levelCount / LAYOUT_CONFIG.COLS);
}
private _getHorizontalPadding(itemWidth: number): number {
const viewWidth = this._getViewSize().width;
const gridWidth = LAYOUT_CONFIG.COLS * itemWidth + (LAYOUT_CONFIG.COLS - 1) * LAYOUT_CONFIG.SPACING_X;
return Math.max(0, (viewWidth - gridWidth) / 2);
}
private _getVerticalPadding(rowCount: number, itemHeight: number): number {
const viewHeight = this._getViewSize().height;
const totalGridHeight = rowCount * itemHeight + Math.max(0, rowCount - 1) * LAYOUT_CONFIG.SPACING_Y;
if (rowCount <= LAYOUT_CONFIG.CENTER_ROWS) {
return Math.max(LAYOUT_CONFIG.EDGE_PADDING_Y, (viewHeight - totalGridHeight) / 2);
}
return LAYOUT_CONFIG.EDGE_PADDING_Y;
}
private _updateContentSize(): void {
if (!this.listContent) return;
const contentTransform = this.listContent.getComponent(UITransform);
if (!contentTransform) return;
const { width: viewWidth, height: viewHeight } = this._getViewSize();
const { width: itemWidth, height: itemHeight } = this._getItemDisplaySize();
const rowCount = this._getRowCount();
const paddingY = this._getVerticalPadding(rowCount, itemHeight);
const gridHeight = rowCount > 0
? rowCount * itemHeight + (rowCount - 1) * LAYOUT_CONFIG.SPACING_Y
: 0;
const contentHeight = Math.max(viewHeight, gridHeight + paddingY * 2);
contentTransform.setContentSize(viewWidth, contentHeight);
// content anchor=(0,1),需要贴到 view 左上角,供纵向列表按左上原点排布。
if (this._viewTransform) {
this.listContent.setPosition(-viewWidth / 2, 0, 0);
}
}
private _createItems(): void {
if (!this.listTemplate || !this.listContent) return;
for (let i = 0; i < this._levelCount; i++) {
const itemNode = this._createItem(i);
if (itemNode) {
this.listContent.addChild(itemNode);
this._itemNodes.push(itemNode);
this._loadAndRefreshCover(itemNode, i);
}
}
}
private _createItem(index: number): Node | null {
if (!this.listTemplate) return null;
const item = instantiate(this.listTemplate);
item.active = true;
item.name = `item_${index}`;
const pos = this._calculateItemPosition(index);
item.setPosition(pos.x, pos.y, 0);
// 设置默认名称和选中状态(封面由 _loadAndRefreshCover 异步填充)
this._initItemState(item, index);
// 禁用 Button 组件,防止它拦截触摸事件导致 ScrollView 无法滑动
const button = item.getComponent(Button);
if (button) {
button.enabled = false;
}
this._setupItemClick(item, index);
return item;
}
private _calculateItemPosition(index: number): { x: number, y: number } {
const { width: itemWidth, height: itemHeight } = this._getItemDisplaySize();
const rowCount = this._getRowCount();
const col = index % LAYOUT_CONFIG.COLS;
const row = Math.floor(index / LAYOUT_CONFIG.COLS);
const paddingLeft = this._getHorizontalPadding(itemWidth);
const paddingTop = this._getVerticalPadding(rowCount, itemHeight);
const x = paddingLeft
+ col * (itemWidth + LAYOUT_CONFIG.SPACING_X)
+ itemWidth / 2;
const y = -(paddingTop
+ row * (itemHeight + LAYOUT_CONFIG.SPACING_Y)
+ itemHeight / 2);
return { x, y };
}
/**
* 初始化 item 的默认名称和选中状态(不设置封面,由异步加载负责)
*/
private _initItemState(item: Node, index: number): void {
const level = this._levels[index] ?? null;
const levelName = item.getChildByName('LevelName');
if (levelName) {
const label = levelName.getComponent(Label);
if (label) {
const answerText = level?.answer?.trim();
label.string = answerText || (level ? `${level.level}` : `${index + 1}`);
}
}
const isSelected = item.getChildByName('IsSelected');
if (isSelected) {
const toggle = isSelected.getComponent(Toggle);
if (toggle) {
toggle.isChecked = this._selectedIndices.has(index);
// 禁用 Toggle 交互,仅作为视觉指示器,选中逻辑由 item Button 统一处理
toggle.interactable = false;
}
const checkmark = isSelected.getChildByName('Checkmark');
if (checkmark) {
checkmark.active = this._selectedIndices.has(index);
}
}
}
/**
* 异步加载关卡封面图并填充到 item
*/
private async _loadAndRefreshCover(item: Node, index: number): Promise<void> {
const level = this._levels[index] ?? null;
if (!level || !item.isValid) return;
const spriteFrame = await CompletedLevelsManager.instance.loadImage(level.image1Url);
if (!spriteFrame || !item.isValid) return;
const levelCover = item.getChildByName('LevelCover');
if (levelCover) {
const sprite = levelCover.getComponent(Sprite);
if (sprite) {
sprite.spriteFrame = spriteFrame;
this._applyCoverRoundedCorner(sprite);
}
}
}
private _applyCoverRoundedCorner(sprite: Sprite): void {
if (!this.roundedSpriteEffect) {
return;
}
const uiTransform = sprite.node.getComponent(UITransform);
if (!uiTransform) {
return;
}
applyRoundedCorner(
sprite,
this.roundedSpriteEffect,
uiTransform.width,
uiTransform.height,
this.coverCornerRadius
);
}
private _setupItemClick(item: Node, index: number): void {
// 用触摸事件代替 Button区分点击和滑动
// 短距离松手 = 点击(切换选中),长距离 = 滑动(交给 ScrollView
let touchStartPos: Vec2 | null = null;
item.on(Node.EventType.TOUCH_START, (event: EventTouch) => {
touchStartPos = event.getUILocation();
}, this);
item.on(Node.EventType.TOUCH_END, (event: EventTouch) => {
if (!touchStartPos) return;
const endPos = event.getUILocation();
const dx = endPos.x - touchStartPos.x;
const dy = endPos.y - touchStartPos.y;
const distance = Math.sqrt(dx * dx + dy * dy);
touchStartPos = null;
// 滑动距离小于阈值才算点击
if (distance < 20) {
const isCurrentlySelected = this._selectedIndices.has(index);
this._onItemToggle(index, !isCurrentlySelected);
}
}, this);
item.on(Node.EventType.TOUCH_CANCEL, () => {
touchStartPos = null;
}, this);
}
private _onItemToggle(index: number, selected: boolean): void {
// 如果要选中但已达上限,阻止选中
if (selected && this._selectedIndices.size >= MAX_SELECTION) {
// 恢复 toggle 的视觉状态为未选中
const item = this._itemNodes[index];
if (item) {
const isSelected = item.getChildByName('IsSelected');
if (isSelected) {
const toggle = isSelected.getComponent(Toggle);
if (toggle) {
toggle.isChecked = false;
}
const checkmark = isSelected.getChildByName('Checkmark');
if (checkmark) {
checkmark.active = false;
}
}
}
console.log(`[PageWriteLevels] 已达最大选择数量 ${MAX_SELECTION},无法继续选择`);
return;
}
if (selected) {
this._selectedIndices.add(index);
} else {
this._selectedIndices.delete(index);
}
AudioManager.instance.playButtonClick();
console.log('[PageWriteLevels] item切换选中:', index, selected, '当前已选:', this._selectedIndices.size);
const item = this._itemNodes[index];
if (item) {
const isSelected = item.getChildByName('IsSelected');
if (isSelected) {
const toggle = isSelected.getComponent(Toggle);
if (toggle) {
toggle.isChecked = selected;
}
const checkmark = isSelected.getChildByName('Checkmark');
if (checkmark) {
checkmark.active = selected;
}
}
}
this._updateSelectionUI();
}
/**
* 根据当前选中数量更新 SelectedLabel 文本和按钮可用状态。
* - 未选择任何关卡时:显示 "请选择6关"
* - 已选但不足6关时显示 "已选 x 关,还差 y 关"
* - 恰好选满6关时显示 "已选满6关",启用按钮
*/
private _updateSelectionUI(): void {
const count = this._selectedIndices.size;
const remaining = MAX_SELECTION - count;
const isFull = remaining <= 0;
// 更新 SelectedLabel 文本
if (this.selectedLabel) {
const label = this.selectedLabel.getComponent(Label);
if (label) {
if (count === 0) {
label.string = `请选择${MAX_SELECTION}`;
} else if (isFull) {
label.string = `已选满${MAX_SELECTION}`;
} else {
label.string = `已选${count}关,还差${remaining}`;
}
}
}
// 预览与分享数量不足时也要允许点击,统一弹出提示弹窗。
if (this.completeBtn) {
const btn = this.completeBtn.getComponent(Button);
if (btn) {
btn.interactable = true;
}
}
if (this.previewBtn) {
const btn = this.previewBtn.getComponent(Button);
if (btn) {
btn.interactable = true;
}
}
}
private _onBackClick(): void {
AudioManager.instance.playButtonClick();
console.log('[PageWriteLevels] 返回按钮点击');
ViewManager.instance.back();
}
private _onDataClick(): void {
AudioManager.instance.playButtonClick();
ViewManager.instance.open('PagePKData');
}
/**
* 校验是否已选满关卡,未满则弹出统一提示弹窗
* @returns true 表示校验通过
*/
private _validateSelection(): boolean {
if (this._selectedIndices.size < MAX_SELECTION) {
this._showSelectionRequiredModal();
return false;
}
return true;
}
private _showSelectionRequiredModal(): void {
if (!this.commonModalPrefab) {
console.warn('[PageWriteLevels] commonModalPrefab 未设置,回退为 Toast 提示');
ToastManager.instance.show(`请选择${MAX_SELECTION}个关卡后再预览或分享`);
return;
}
CommonModal.show(this.commonModalPrefab, {
title: '提示',
content: `要选择${MAX_SELECTION}个关卡才能分享和预览`,
buttonConfirm: '知道了',
});
}
private _onPreviewClick(): void {
AudioManager.instance.playButtonClick();
if (!this._validateSelection()) return;
const shareTitle = this.shareTitleEditBox?.getComponent(EditBox)?.string?.trim() || '';
ViewManager.instance.open('PagePreviewLevels', {
params: {
selectedIndices: Array.from(this._selectedIndices),
shareTitle: shareTitle
}
});
}
private async _onCompleteClick(): Promise<void> {
AudioManager.instance.playButtonClick();
if (!this._validateSelection()) return;
const shareTitle = this.shareTitleEditBox?.getComponent(EditBox)?.string?.trim() || '';
if (!shareTitle) {
ToastManager.instance.show('请输入分享标题');
return;
}
if (this._isSubmitting) return;
this._isSubmitting = true;
try {
const levelIds = this._getSelectedLevelIds();
if (levelIds.length !== MAX_SELECTION) {
ToastManager.instance.show('获取关卡数据失败,请重试');
return;
}
const shareCode = await ShareManager.instance.createShare(shareTitle, levelIds);
if (!shareCode) {
ToastManager.instance.show('创建分享失败,请重试');
return;
}
console.log('[PageWriteLevels] 创建分享成功, code:', shareCode);
// 获取用户头像昵称并上传
await this._uploadUserInfo();
ShareManager.instance.triggerWxShare(shareTitle, shareCode);
ToastManager.instance.show('分享创建成功!');
} catch (err) {
console.error('[PageWriteLevels] 完成按钮异常:', err);
ToastManager.instance.show('操作失败,请重试');
} finally {
this._isSubmitting = false;
}
}
/**
* 将选中的关卡索引转换为关卡 ID 数组
*/
private _getSelectedLevelIds(): string[] {
const ids: string[] = [];
const sortedIndices = Array.from(this._selectedIndices).sort((a, b) => a - b);
for (const index of sortedIndices) {
const level = this._levels[index] ?? null;
if (level) {
ids.push(level.id);
}
}
return ids;
}
/**
* 获取用户头像昵称并上传到服务端
*/
private async _uploadUserInfo(): Promise<void> {
// 先检查本地缓存
const cachedUserInfo = StorageManager.getUserInfo();
if (cachedUserInfo) {
console.log('[PageWriteLevels] 使用缓存的用户信息');
return;
}
if (!WxSDK.isWechat()) {
console.log('[PageWriteLevels] 非微信环境,跳过获取用户信息');
return;
}
// 获取当前登录用户的 ID
const userId = AuthManager.instance.userId;
if (!userId) {
console.warn('[PageWriteLevels] 用户未登录,跳过获取用户信息');
return;
}
try {
const userInfo = await getUserProfile();
// 本地缓存
StorageManager.setUserInfo(userInfo);
// 上传到服务端
const response = await HttpUtil.post<ApiEnvelope<unknown>>(
API_ENDPOINTS.USER_INFO,
{
userId: userId,
avatarUrl: userInfo.avatarUrl,
nickName: userInfo.nickName
},
API_TIMEOUT.DEFAULT
);
if (response.success) {
console.log('[PageWriteLevels] 用户信息上传成功');
} else {
console.warn('[PageWriteLevels] 用户信息上传失败:', response.message);
}
} catch (err) {
console.warn('[PageWriteLevels] 获取用户信息失败:', err);
// 不阻断主流程
}
}
onViewHide(): void {
console.log('[PageWriteLevels] onViewHide');
}
onViewDestroy(): void {
console.log('[PageWriteLevels] onViewDestroy');
if (this.backBtn) {
this.backBtn.off(Button.EventType.CLICK, this._onBackClick, this);
}
if (this.previewBtn) {
this.previewBtn.off(Button.EventType.CLICK, this._onPreviewClick, this);
}
if (this.completeBtn) {
this.completeBtn.off(Button.EventType.CLICK, this._onCompleteClick, this);
}
if (this.dataBtn) {
this.dataBtn.off(Button.EventType.CLICK, this._onDataClick, this);
}
this._clearList();
}
}
```

View File

@@ -0,0 +1,710 @@
# 梗中作乐 V1.0 - 源代码文档(第 8 部分)
软件名称:梗中作乐
版本号V1.0
---
## 第 8 部分:通关弹窗与轻提示
本部分包含通关庆祝弹窗、预览关卡项组件、轻提示组件以及 Toast 管理工具。
### 8.1 通关弹窗 (assets/prefabs/PassModal.ts)
```typescript
import { _decorator, Node, Label, AudioClip, AudioSource, view, UITransform, Size, ProgressBar, tween, Tween } from 'cc';
import { BaseModal } from 'db://assets/scripts/core/BaseModal';
import { WxSDK } from 'db://assets/scripts/utils/WxSDK';
import { AudioManager } from 'db://assets/scripts/utils/AudioManager';
const { ccclass, property } = _decorator;
/**
* PassModal 回调接口
*/
export interface PassModalCallbacks {
/** 点击下一关回调 */
onNextLevel?: () => void;
/** 点击分享回调 */
onShare?: () => void;
/** 点击返回主页回调 */
onHome?: () => void;
}
export interface PassModalTitleInfo {
titleText?: string;
nextTitleProgress?: number;
progressText?: string;
}
interface PassModalParams {
levelIndex?: number;
/** 下一步按钮文案,不传时使用 prefab 默认文案 */
nextButtonText?: string;
titleInfo?: PassModalTitleInfo;
/**
* 通关前的称号信息。传入后,本次显示会把进度条从该起点动画到 titleInfo 的终点;
* 起点与终点 titleText 不同则分两段(先填满当前等级,再切换到新等级后填到目标进度)。
* 分享模式等无本地进度变化的场景不要传。
*/
previousTitleInfo?: PassModalTitleInfo;
}
/**
* 通关弹窗组件
* 继承 BaseModal显示通关成功弹窗提供"下一关"和"分享给好友"两个按钮
*/
@ccclass('PassModal')
export class PassModal extends BaseModal {
/** 静态常量:弹窗层级 */
public static readonly MODAL_Z_INDEX = 999;
/** 下一关按钮 */
@property(Node)
nextLevelButton: Node | null = null;
/** 返回主页按钮 */
@property(Node)
settingButton: Node | null = null;
/** 分享按钮 */
@property(Node)
shareButton: Node | null = null;
/** 称号文字 */
@property(Label)
titleLevelLabel: Label | null = null;
/** 距离下一个称号的进度 */
@property(ProgressBar)
titleProgressBar: ProgressBar | null = null;
/** 进度提示文案 */
@property(Label)
progressLabel: Label | null = null;
/** 称号进度游标 */
@property(Node)
progressAnchor: Node | null = null;
/** 通关音效 */
@property(AudioClip)
successAudio: AudioClip | null = null;
/** 进度条动画起始前的等待时长(秒),等弹窗开场动画稳定后再开始 */
private static readonly PROGRESS_ANIM_START_DELAY = 0.4;
/** 单段进度条填充动画时长(秒) */
private static readonly PROGRESS_ANIM_SEGMENT_DURATION = 0.6;
/** 跨称号切换时的等级信息刷新停顿(秒),让玩家看清称号变更 */
private static readonly PROGRESS_ANIM_LEVELUP_PAUSE = 0.12;
/** 回调函数 */
private _callbacks: PassModalCallbacks = {};
/** 缓存的屏幕尺寸 */
private _screenSize: Size | null = null;
/** 称号展示数据(终态) */
private _titleInfo: PassModalTitleInfo = {
titleText: '冷场小白1级',
nextTitleProgress: 0,
progressText: '还差3题解锁新成就等级'
};
/** 动画起点。为 null 表示不做进度动画,直接展示终态 */
private _previousTitleInfo: PassModalTitleInfo | null = null;
/** 进度动画所绑定的对象,用于 Tween.stopAllByTarget */
private readonly _progressTweenTarget: { progress: number } = { progress: 0 };
/** 下一步按钮文案,为 null 时保留 prefab 默认值 */
private _nextButtonText: string | null = null;
/** 进度游标 0% 时的本地 X 坐标,根据 ProgressBar Bar 子节点的左端推导出来 */
private _progressAnchorStartX: number | null = null;
setParams(params: PassModalParams): void {
super.setParams(params);
// previousTitleInfo 可以显式传 null 来禁用动画undefined 表示"保持已有状态"
if (params && 'previousTitleInfo' in params) {
this._previousTitleInfo = params.previousTitleInfo ?? null;
}
if (params?.titleInfo) {
this.setTitleInfo(params.titleInfo);
}
if (params && 'nextButtonText' in params) {
this._nextButtonText = params.nextButtonText ?? null;
this._applyNextButtonText();
}
}
/**
* 设置回调函数
*/
setCallbacks(callbacks: PassModalCallbacks): void {
this._callbacks = callbacks;
}
/**
* 设置称号体系展示数据
*/
setTitleInfo(titleInfo: PassModalTitleInfo): void {
this._titleInfo = {
...this._titleInfo,
...titleInfo
};
this._refreshTitleView();
}
/**
* 页面首次加载时调用
*/
onViewLoad(): void {
console.log('[PassModal] onViewLoad');
this._resolveNodes();
this._resolveProgressAnchor();
this._cacheProgressAnchorStartX();
this._bindButtonEvents();
}
/**
* 页面每次显示时调用
*/
onViewShow(): void {
super.onViewShow();
this._updateWidget();
this._refreshTitleView();
this._applyNextButtonText();
this._playSuccessSound();
this._playProgressAnimation();
}
/**
* 页面隐藏时调用
*/
onViewHide(): void {
super.onViewHide();
this._stopProgressAnimation();
}
/**
* 页面销毁时调用
*/
onViewDestroy(): void {
this._stopProgressAnimation();
this._unbindButtonEvents();
}
/**
* 设置弹窗尺寸为全屏
* 动态实例化后,手动设置节点尺寸覆盖整个屏幕
*/
private _updateWidget(): void {
// 缓存屏幕尺寸,避免重复计算
if (!this._screenSize) {
this._screenSize = view.getVisibleSize();
}
const uiTransform = this.node.getComponent(UITransform);
if (uiTransform) {
uiTransform.setContentSize(this._screenSize.width, this._screenSize.height);
}
}
/**
* 绑定按钮事件
*/
private _bindButtonEvents(): void {
if (this.nextLevelButton) {
this.nextLevelButton.on(Node.EventType.TOUCH_END, this._onNextLevelClick, this);
}
if (this.settingButton) {
this.settingButton.on(Node.EventType.TOUCH_END, this._onHomeClick, this);
}
if (this.shareButton) {
this.shareButton.on(Node.EventType.TOUCH_END, this._onShareClick, this);
}
}
/**
* 解除按钮事件绑定
*/
private _unbindButtonEvents(): void {
// 节点可能在销毁过程中已被置空,需要检查 isValid
if (this.nextLevelButton && this.nextLevelButton.isValid) {
this.nextLevelButton.off(Node.EventType.TOUCH_END, this._onNextLevelClick, this);
}
if (this.settingButton && this.settingButton.isValid) {
this.settingButton.off(Node.EventType.TOUCH_END, this._onHomeClick, this);
}
if (this.shareButton && this.shareButton.isValid) {
this.shareButton.off(Node.EventType.TOUCH_END, this._onShareClick, this);
}
}
private _resolveNodes(): void {
this.nextLevelButton = this.nextLevelButton ?? this.node.getChildByName('Button') ?? null;
this.settingButton = this.settingButton ?? this.node.getChildByName('SettingButton') ?? null;
this.shareButton = this.shareButton ?? this.node.getChildByName('Share') ?? null;
}
/**
* 播放通关音效
*/
private _playSuccessSound(): void {
if (!this.successAudio) {
return;
}
const audioSource = this.node.getComponent(AudioSource) ?? this.node.addComponent(AudioSource);
audioSource.playOneShot(this.successAudio);
}
/**
* 用当前 _titleInfo 刷新视图(称号、进度条、进度文案)
* 进度条动画运行时,会由动画控制进度值,这里仍然把进度写为终态
* —— _playProgressAnimation 会在动画开始前覆盖为起点。
*/
private _refreshTitleView(): void {
this._applyTitleText(this._titleInfo.titleText);
this._applyProgressText(this._titleInfo.progressText);
this._applyProgressValue(this._titleInfo.nextTitleProgress);
}
private _applyTitleText(text: string | undefined): void {
if (this.titleLevelLabel && text !== undefined) {
this.titleLevelLabel.string = text;
}
}
private _applyNextButtonText(): void {
if (!this.nextLevelButton || this._nextButtonText === null) {
return;
}
const label = this.nextLevelButton.getChildByName('Label')?.getComponent(Label);
if (label) {
label.string = this._nextButtonText;
}
}
private _applyProgressText(text: string | undefined): void {
if (this.progressLabel && text !== undefined) {
this.progressLabel.string = text;
}
}
private _applyProgressValue(progress: number | undefined): void {
if (progress === undefined) {
return;
}
this._applyAnimatedProgress(progress);
}
/**
* 根据 _previousTitleInfo → _titleInfo 驱动进度条过渡动画
*
* 三种情况:
* 1. 无起点信息或起点/终点相同:不播动画
* 2. 同称号下涨进度:一段 tween
* 3. 跨称号:先把旧称号填到 1.0,然后切换称号文字、进度回 0再 tween 到终点进度
*/
private _playProgressAnimation(): void {
const prev = this._previousTitleInfo;
// 动画是一次性的,播放前消费掉,避免弹窗被复用时重复播
this._previousTitleInfo = null;
if (!this.titleProgressBar || !prev) {
return;
}
const startProgress = prev.nextTitleProgress;
const endProgress = this._titleInfo.nextTitleProgress;
if (startProgress === undefined || endProgress === undefined) {
return;
}
const isSameTitle = prev.titleText === undefined
|| this._titleInfo.titleText === undefined
|| prev.titleText === this._titleInfo.titleText;
// 同称号且起止相同,没必要播动画
if (isSameTitle && Math.abs(startProgress - endProgress) < 1e-4) {
return;
}
this._stopProgressAnimation();
if (isSameTitle) {
// 先展示起点,避免 _refreshTitleView 已把条填到终态
this._applyProgressValue(startProgress);
this._runProgressTween(startProgress, endProgress, PassModal.PROGRESS_ANIM_START_DELAY);
return;
}
// 跨称号:先让旧称号文字和起点进度出现在屏上
this._applyTitleText(prev.titleText);
this._applyProgressText(prev.progressText);
this._applyProgressValue(startProgress);
const self = this;
const tweenTarget = this._progressTweenTarget;
// raw 值保留 0~1下发时经 _normalizeProgress
tweenTarget.progress = Math.max(0, Math.min(1, startProgress));
const clampedEnd = Math.max(0, Math.min(1, endProgress));
const onUpdate = () => {
self._applyAnimatedProgress(tweenTarget.progress);
};
tween(tweenTarget)
.delay(PassModal.PROGRESS_ANIM_START_DELAY)
.to(
PassModal.PROGRESS_ANIM_SEGMENT_DURATION,
{ progress: 1 },
{ easing: 'sineOut', onUpdate }
)
.call(() => {
// 切到新称号。progressText/titleText 都切到终态;进度值从 0 开始
self._applyTitleText(self._titleInfo.titleText);
self._applyProgressText(self._titleInfo.progressText);
tweenTarget.progress = 0;
self._applyAnimatedProgress(0);
})
.delay(PassModal.PROGRESS_ANIM_LEVELUP_PAUSE)
.to(
PassModal.PROGRESS_ANIM_SEGMENT_DURATION,
{ progress: clampedEnd },
{ easing: 'sineOut', onUpdate }
)
.start();
}
private _runProgressTween(from: number, to: number, delay: number): void {
if (!this.titleProgressBar) {
return;
}
const tweenTarget = this._progressTweenTarget;
// raw 值保留 0~1 区间onUpdate 里经 _normalizeProgress 再下发,避免畸变区段
tweenTarget.progress = this._clampProgress(from);
this._applyAnimatedProgress(from);
const self = this;
const chain = tween(tweenTarget);
if (delay > 0) {
chain.delay(delay);
}
chain
.to(
PassModal.PROGRESS_ANIM_SEGMENT_DURATION,
{ progress: Math.max(0, Math.min(1, to)) },
{
easing: 'sineOut',
onUpdate: () => {
self._applyAnimatedProgress(tweenTarget.progress);
}
}
)
.start();
}
private _cacheProgressAnchorStartX(): void {
if (this._progressAnchorStartX !== null || !this.titleProgressBar) {
return;
}
const barSprite = this.titleProgressBar.barSprite;
if (!barSprite) {
return;
}
// Bar 节点 anchor 为 (0, 0.5),其本地 position.x 即为进度条可视左端。
// ProgressBar 与 ProgressAnchor 共享同一父节点TitleLevel
// 因此把 Bar 的本地 X 按 ProgressBar 自身的位移与缩放映射到父节点空间,
// 才是真正的「0% 起点」。直接拿 progressAnchor.position.x 当起点会导致
// 气泡始终被 prefab 摆放偏移量带跑(实测偏右 ~24px
// -40 为视觉微调,与 PageHome 保持一致。
const progressBarNode = this.titleProgressBar.node;
const barLocalX = barSprite.node.position.x;
this._progressAnchorStartX = progressBarNode.position.x + barLocalX * progressBarNode.scale.x - 30;
}
private _resolveProgressAnchor(): void {
if (this.progressAnchor?.isValid) {
return;
}
this.progressAnchor = this.node
.getChildByName('Bg')
?.getChildByName('Title')
?.getChildByName('ProgressAnchor') ?? null;
}
private _applyAnimatedProgress(progress: number): void {
const clampedProgress = this._clampProgress(progress);
if (this.titleProgressBar?.isValid) {
this.titleProgressBar.progress = this._normalizeProgress(clampedProgress);
}
this._updateProgressAnchor(clampedProgress);
}
private _updateProgressAnchor(progress: number): void {
if (!this.progressAnchor?.isValid) {
return;
}
this._cacheProgressAnchorStartX();
const startX = this._progressAnchorStartX ?? this.progressAnchor.position.x;
const travelWidth = this._getProgressAnchorTravelWidth();
this.progressAnchor.setPosition(startX + travelWidth * progress, this.progressAnchor.position.y, this.progressAnchor.position.z);
const percentLabel = this.progressAnchor.getChildByName('Label')?.getComponent(Label);
if (percentLabel) {
percentLabel.string = `${Math.round(progress * 100)}%`;
}
}
private _getProgressAnchorTravelWidth(): number {
if (!this.titleProgressBar) {
return 0;
}
return Math.abs(this.titleProgressBar.totalLength * this.titleProgressBar.node.scale.x);
}
private _stopProgressAnimation(): void {
Tween.stopAllByTarget(this._progressTweenTarget);
}
/**
* 规范化进度值
* 九宫格 Bar 的 Left+Right border = 240pxtotalLength = 925px
* 当 width < 240px 时圆角会畸变,因此 progress > 0 时强制最小值
*/
private _normalizeProgress(progress: number): number {
if (!Number.isFinite(progress) || progress <= 0) {
return 0;
}
const MIN_PROGRESS = 240 / 925;
return Math.max(MIN_PROGRESS, Math.min(1, progress));
}
private _clampProgress(progress: number): number {
if (!Number.isFinite(progress) || progress <= 0) {
return 0;
}
return Math.min(1, progress);
}
/**
* 下一关按钮点击
*/
private _onNextLevelClick(): void {
console.log('[PassModal] 点击下一关');
this._callbacks.onNextLevel?.();
}
/**
* 分享按钮点击
*/
private _onShareClick(): void {
console.log('[PassModal] 点击分享');
// 调用微信分享
WxSDK.shareAppMessage({
title: '快来一起玩这款游戏吧',
query: `level=${this._params?.levelIndex ?? 1}`
});
this._callbacks.onShare?.();
}
/**
* 返回主页按钮点击
*/
private _onHomeClick(): void {
console.log('[PassModal] 点击返回主页');
AudioManager.instance.playButtonClick();
this._callbacks.onHome?.();
}
}
```
### 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);
}
}
```

View File

@@ -0,0 +1,704 @@
# 梗中作乐 V1.0 - 源代码文档(第 9 部分)
软件名称:梗中作乐
版本号V1.0
---
## 第 9 部分:好友分享与预览
本部分包含 PK 关卡预览页、分享逻辑管理器、分享启动参数处理器。
### 9.1 分享预览页 (assets/prefabs/PagePreviewLevels.ts)
```typescript
import { _decorator, Node, Button, Label, ScrollView, instantiate, UITransform } from 'cc';
import { BaseView } from 'db://assets/scripts/core/BaseView';
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
import { CompletedLevelsManager } from 'db://assets/scripts/utils/CompletedLevelsManager';
import { AudioManager } from 'db://assets/scripts/utils/AudioManager';
import { PreviewLevelItem } from './PreviewLevelItem';
const { ccclass, property } = _decorator;
/**
* 预览试卷页面
* 垂直滚动展示用户在 PageWriteLevels 中选中的 6 个关卡
* 每个关卡展示封面图、线索1、线索2、线索3、答案
*
* 节点结构(仅 ScrollView 侧需要固定):
* PagePreviewLevels
* ├── ScrollView / view / content ← listContent 容器
* └── ListTpl ← listTemplate 模板根节点
* (挂 PreviewLevelItem 组件,字段由编辑器拖拽绑定)
*
* item 内部节点层级/命名对本文件透明:所有引用都来自 PreviewLevelItem 的 @property。
*/
/** 布局配置 — 垂直列表 */
const LAYOUT = {
/** 关卡项高度(与 ListTpl UITransform 高度一致) */
ITEM_HEIGHT: 300,
/** 关卡项之间的垂直间距 */
SPACING_Y: 30,
/** 列表顶部内边距 */
PADDING_TOP: 20,
};
@ccclass('PagePreviewLevels')
export class PagePreviewLevels extends BaseView {
@property({ type: Node, tooltip: '返回按钮(左上角)' })
backBtn: Node | null = null;
@property({ type: Node, tooltip: 'ScrollView 节点' })
scrollView: Node | null = null;
@property({ type: Node, tooltip: '列表 content 节点' })
listContent: Node | null = null;
@property({ type: Node, tooltip: '关卡模板节点' })
listTemplate: Node | null = null;
@property({ type: Node, tooltip: '底部返回按钮' })
backButton: Node | null = null;
@property({ type: Node, tooltip: '标题Label节点' })
pkTitle: Node | null = null;
/** 已创建的 item 节点列表 */
private _itemNodes: Node[] = [];
// ─── 生命周期 ───────────────────────────────────────
onViewLoad(): void {
console.log('[PagePreviewLevels] onViewLoad');
this._initButtons();
this._initScrollView();
}
onViewShow(): void {
console.log('[PagePreviewLevels] onViewShow');
this._buildList();
}
onViewHide(): void {
console.log('[PagePreviewLevels] onViewHide');
}
onViewDestroy(): void {
console.log('[PagePreviewLevels] onViewDestroy');
this._offButtons();
this._clearList();
}
// ─── 初始化 ─────────────────────────────────────────
private _initButtons(): void {
if (this.backBtn) {
this.backBtn.on(Button.EventType.CLICK, this._onBackClick, this);
}
if (this.backButton) {
this.backButton.on(Button.EventType.CLICK, this._onBackClick, this);
}
}
private _offButtons(): void {
if (this.backBtn) {
this.backBtn.off(Button.EventType.CLICK, this._onBackClick, this);
}
if (this.backButton) {
this.backButton.off(Button.EventType.CLICK, this._onBackClick, this);
}
}
private _initScrollView(): void {
if (!this.listContent) return;
const contentTransform = this.listContent.getComponent(UITransform);
if (contentTransform) {
contentTransform.setAnchorPoint(0.5, 1);
}
}
// ─── 列表构建 ───────────────────────────────────────
private _buildList(): void {
this._clearList();
const params = this.getParams();
if (!params || !params.selectedIndices || params.selectedIndices.length === 0) {
console.warn('[PagePreviewLevels] 未传入选中关卡数据');
return;
}
// 显示用户输入的标题
if (this.pkTitle) {
const label = this.pkTitle.getComponent(Label);
if (label) {
label.string = params.shareTitle || '挑战';
}
}
const indices: number[] = params.selectedIndices;
console.log('[PagePreviewLevels] 选中关卡索引:', indices);
// 更新 content 高度
this._updateContentSize(indices.length);
// 创建每个关卡 item
for (let i = 0; i < indices.length; i++) {
const levelIndex = indices[i];
const itemNode = this._createItem(i);
if (itemNode) {
this.listContent!.addChild(itemNode);
this._itemNodes.push(itemNode);
this._loadLevelData(itemNode, levelIndex, i);
}
}
// 滚动到顶部
const scrollComp = this.scrollView?.getComponent(ScrollView);
if (scrollComp) {
scrollComp.scrollToTop(0);
}
}
private _clearList(): void {
for (const node of this._itemNodes) {
if (node && node.isValid) {
node.destroy();
}
}
this._itemNodes = [];
}
private _updateContentSize(count: number): void {
if (!this.listContent) return;
const contentTransform = this.listContent.getComponent(UITransform);
if (!contentTransform) return;
const totalHeight = LAYOUT.PADDING_TOP
+ count * LAYOUT.ITEM_HEIGHT
+ (count > 0 ? (count - 1) * LAYOUT.SPACING_Y : 0)
+ LAYOUT.PADDING_TOP;
contentTransform.setContentSize(contentTransform.contentSize.width, totalHeight);
}
/**
* 创建单个关卡展示项
* content anchor=(0.5, 1)y 轴负向下
*/
private _createItem(displayIndex: number): Node | null {
if (!this.listTemplate) return null;
const item = instantiate(this.listTemplate);
item.active = true;
item.name = `preview_item_${displayIndex}`;
// 垂直居中排列x=0水平居中于 contenty 负向下
const y = -(LAYOUT.PADDING_TOP + displayIndex * (LAYOUT.ITEM_HEIGHT + LAYOUT.SPACING_Y) + LAYOUT.ITEM_HEIGHT / 2);
item.setPosition(0, y, 0);
return item;
}
/**
* 异步加载关卡数据并填充到 item 节点
*/
private async _loadLevelData(item: Node, levelIndex: number, displayIndex: number): Promise<void> {
const level = CompletedLevelsManager.instance.getByIndex(levelIndex);
if (!level || !item.isValid) return;
const view = item.getComponent(PreviewLevelItem);
if (!view) {
console.warn('[PagePreviewLevels] listTemplate 缺少 PreviewLevelItem 组件');
return;
}
view.setTexts({
answer: level.answer || '',
hint1: level.hint1 || '',
hint2: level.hint2 || '',
hint3: level.hint3 || '',
});
// 异步加载封面图(通常已由 WriteLevels 预热到缓存)
const spriteFrame = await CompletedLevelsManager.instance.loadImage(level.image1Url);
if (!spriteFrame || !item.isValid) return;
view.setCover(spriteFrame);
}
// ─── 事件处理 ───────────────────────────────────────
private _onBackClick(): void {
AudioManager.instance.playButtonClick();
console.log('[PagePreviewLevels] 返回');
ViewManager.instance.back();
}
}
```
### 9.2 分享管理器 (assets/scripts/utils/ShareManager.ts)
```typescript
import { SpriteFrame, Texture2D, ImageAsset, assetManager } from 'cc';
import { HttpUtil } from './HttpUtil';
import { WxSDK } from './WxSDK';
import { API_ENDPOINTS, getShareDetailUrl, getShareJoinUrl, getShareSubmitUrl, API_TIMEOUT } from '../config/ApiConfig';
import {
ApiEnvelope,
CreateShareData,
JoinShareData,
ShareLevelData,
CreatedShareItem,
CreatedShareListData,
ParticipatedShareItem,
ParticipatedShareListData,
ShareDetailData,
SubmitShareData,
SubmitShareLevel,
} from '../types/ApiTypes';
import { RuntimeLevelConfig } from '../types/LevelTypes';
/**
* 分享管理器
* 负责创建分享、加入分享、缓存分享关卡数据
*/
export class ShareManager {
private static _instance: ShareManager | null = null;
/** 分享模式的关卡数据null 表示正常模式) */
private _shareLevels: RuntimeLevelConfig[] | null = null;
/** API 返回的原始关卡数据(保留 image1Url/image2Url 用于懒加载) */
private _shareApiLevels: ShareLevelData[] = [];
private _shareTitle: string = '';
private _shareCode: string | null = null;
private _createdShares: CreatedShareItem[] = [];
private _participatedShares: ParticipatedShareItem[] = [];
/** 图片缓存URL -> SpriteFrame */
private _imageCache: Map<string, SpriteFrame> = new Map();
static get instance(): ShareManager {
if (!this._instance) {
this._instance = new ShareManager();
}
return this._instance;
}
private constructor() {}
get isShareMode(): boolean {
return this._shareLevels !== null && this._shareLevels.length > 0;
}
get createdShares(): CreatedShareItem[] {
return [...this._createdShares];
}
get participatedShares(): ParticipatedShareItem[] {
return [...this._participatedShares];
}
get shareCode(): string | null {
return this._shareCode;
}
get shareTitle(): string {
return this._shareTitle;
}
async createShare(title: string, levelIds: string[]): Promise<string | null> {
try {
const response = await HttpUtil.post<ApiEnvelope<CreateShareData>>(
API_ENDPOINTS.SHARE_CREATE,
{ title, levelIds },
API_TIMEOUT.DEFAULT,
);
if (!response.success || !response.data) {
console.error('[ShareManager] 创建分享失败:', response.message);
return null;
}
return response.data.shareCode;
} catch (err) {
console.error('[ShareManager] 创建分享异常:', err);
return null;
}
}
async joinShare(code: string): Promise<boolean> {
try {
const response = await HttpUtil.post<ApiEnvelope<JoinShareData>>(
getShareJoinUrl(code),
{},
API_TIMEOUT.DEFAULT,
);
if (!response.success || !response.data) {
console.error('[ShareManager] 加入分享失败:', response.message);
return false;
}
const { shareCode, title, levels } = response.data;
this._shareCode = shareCode;
this._shareTitle = title;
this._shareApiLevels = levels;
const runtimeLevels: RuntimeLevelConfig[] = levels.map((level) => ({
id: level.id,
name: `${level.level}`,
spriteFrame1: null,
spriteFrame2: null,
image1Description: level.image1Description,
image2Description: level.image2Description,
punchline: level.punchline,
clue1: level.hint1,
clue2: level.hint2,
clue3: level.hint3,
answer: level.answer,
completed: false,
timeLimit: null,
}));
// 预加载首关图片(两张并行加载)
if (levels.length > 0) {
const [sf1, sf2] = await Promise.all([
this._loadImage(levels[0].image1Url),
this._loadImage(levels[0].image2Url),
]);
if (sf1) {
runtimeLevels[0].spriteFrame1 = sf1;
}
if (sf2) {
runtimeLevels[0].spriteFrame2 = sf2;
}
}
this._shareLevels = runtimeLevels;
console.log(`[ShareManager] 加入分享成功: ${title}, ${levels.length}`);
return true;
} catch (err) {
console.error('[ShareManager] 加入分享异常:', err);
return false;
}
}
async fetchCreatedShares(): Promise<CreatedShareItem[] | null> {
try {
const response = await HttpUtil.get<ApiEnvelope<CreatedShareListData>>(
API_ENDPOINTS.SHARE_CREATED,
API_TIMEOUT.DEFAULT,
);
if (!response.success || !response.data) {
console.error('[ShareManager] 获取我创建的挑战列表失败:', response.message);
return null;
}
this._createdShares = response.data.items ?? [];
console.log(`[ShareManager] 获取我创建的挑战列表成功: ${this._createdShares.length}`);
return this.createdShares;
} catch (err) {
console.error('[ShareManager] 获取我创建的挑战列表异常:', err);
return null;
}
}
async fetchParticipatedShares(): Promise<ParticipatedShareItem[] | null> {
try {
const response = await HttpUtil.get<ApiEnvelope<ParticipatedShareListData>>(
API_ENDPOINTS.SHARE_PARTICIPATED,
API_TIMEOUT.DEFAULT,
);
if (!response.success || !response.data) {
console.error('[ShareManager] 获取我参与的挑战列表失败:', response.message);
return null;
}
this._participatedShares = response.data.items ?? [];
console.log(`[ShareManager] 获取我参与的挑战列表成功: ${this._participatedShares.length}`);
return this.participatedShares;
} catch (err) {
console.error('[ShareManager] 获取我参与的挑战列表异常:', err);
return null;
}
}
async fetchShareDetail(code: string): Promise<ShareDetailData | null> {
try {
const response = await HttpUtil.get<ApiEnvelope<ShareDetailData>>(
getShareDetailUrl(code),
API_TIMEOUT.DEFAULT,
);
if (!response.success || !response.data) {
console.error('[ShareManager] 获取分享挑战详情失败:', response.message);
return null;
}
console.log(`[ShareManager] 获取分享挑战详情成功: ${response.data.title}, ${response.data.rankings?.length ?? 0} 条排行`);
return response.data;
} catch (err) {
console.error('[ShareManager] 获取分享挑战详情异常:', err);
return null;
}
}
async ensureShareLevelReady(index: number): Promise<RuntimeLevelConfig | null> {
if (!this._shareLevels || index < 0 || index >= this._shareLevels.length) {
return null;
}
const config = this._shareLevels[index];
if (config.spriteFrame1) {
return config;
}
const apiLevel = this._shareApiLevels[index];
if (apiLevel?.image1Url) {
const [sf1, sf2] = await Promise.all([
this._loadImage(apiLevel.image1Url),
this._loadImage(apiLevel.image2Url),
]);
if (sf1) {
config.spriteFrame1 = sf1;
}
if (sf2) {
config.spriteFrame2 = sf2;
}
}
return config;
}
getShareLevelCount(): number {
return this._shareLevels?.length ?? 0;
}
getShareLevelIds(): string[] {
return this._shareApiLevels.map(level => level.id);
}
async submitShareChallenge(levels: SubmitShareLevel[]): Promise<SubmitShareData | null> {
if (!this._shareCode) {
console.warn('[ShareManager] submitShareChallenge: 无分享码,跳过提交');
return null;
}
try {
const response = await HttpUtil.post<ApiEnvelope<SubmitShareData>>(
getShareSubmitUrl(this._shareCode),
{ levels },
API_TIMEOUT.DEFAULT,
);
if (!response.success || !response.data) {
console.error('[ShareManager] 提交挑战结果失败:', response.message);
return null;
}
console.log(
`[ShareManager] 提交挑战结果成功: rank=${response.data.rank}, correct=${response.data.correctCount}/${response.data.levelCount}`,
);
return response.data;
} catch (err) {
console.error('[ShareManager] 提交挑战结果异常:', err);
return null;
}
}
triggerWxShare(title: string, shareCode: string): void {
WxSDK.shareAppMessage({
title: title || '来挑战我出的谐音梗吧!',
query: `shareCode=${shareCode}`,
});
}
clearShareMode(): void {
this._shareLevels = null;
this._shareApiLevels = [];
this._shareTitle = '';
this._shareCode = null;
this._imageCache.clear();
}
private _loadImage(url: string): Promise<SpriteFrame | null> {
const cached = this._imageCache.get(url);
if (cached) {
return Promise.resolve(cached);
}
return new Promise((resolve) => {
assetManager.loadRemote<ImageAsset>(url, (err, imageAsset) => {
if (err) {
console.error('[ShareManager] 加载图片失败:', url, err);
resolve(null);
return;
}
const texture = new Texture2D();
texture.image = imageAsset;
const spriteFrame = new SpriteFrame();
spriteFrame.texture = texture;
this._imageCache.set(url, spriteFrame);
resolve(spriteFrame);
});
});
}
}
```
### 9.3 启动分享处理器 (assets/scripts/utils/ShareLaunchHandler.ts)
```typescript
import { WxSDK } from './WxSDK';
import { ShareManager } from './ShareManager';
import { AuthManager } from './AuthManager';
import { ViewManager } from '../core/ViewManager';
/**
* 分享启动监听器
*
* 微信小游戏未被杀掉、只是退到后台时,再次通过好友分享卡片打开小游戏,
* 不会重新走启动链路PageLoading 不会再跑),因此 `wx.getLaunchOptionsSync()`
* 取到的 query 可能是上一次启动的旧值。这个 handler 通过 `wx.onShow`
* 拿到最新的 query检测 shareCode 变化后:
* 1. 清掉旧的分享态
* 2. 调用 `ShareManager.joinShare(code)` 拉取新的题单
* 3. 直接打开 `PageLevel` 进入分享挑战
*
* 对应在 PageLevel 那侧通过 `onViewShow` 检测到 ShareManager.shareCode
* 变化,重新走 `_reinitLevelSession`。
*/
export class ShareLaunchHandler {
private static _instance: ShareLaunchHandler | null = null;
static get instance(): ShareLaunchHandler {
if (!this._instance) {
this._instance = new ShareLaunchHandler();
}
return this._instance;
}
/** 已经处理过的 shareCode相同则不再重复 join */
private _activeShareCode: string | null = null;
/** 是否正在处理一次 onShow 触发的 join 流程,避免并发 */
private _isHandlingShow: boolean = false;
/** 是否已经初始化 */
private _initialized: boolean = false;
private _showHandler = (res: { query?: Record<string, any> } | undefined) => {
const code = WxSDK.extractShareCodeFromQuery(res?.query);
if (!code) {
return;
}
// 同一个 shareCode 且当前已经处于该分享态,无需重复处理
if (code === this._activeShareCode && ShareManager.instance.isShareMode) {
return;
}
// 即使不是新的 code但如果 ShareManager 已经丢失了分享态(例如挑战完成被 clear
// 用户重新点同一个分享卡片仍然应当重新加入。
void this._handleShareCode(code);
};
private _hideHandler = () => {
// 目前不在 onHide 时做任何破坏性操作;保留监听只为方便后续扩展
// (比如:暂停倒计时、上报埋点)。
console.log('[ShareLaunchHandler] 小游戏切到后台');
};
/**
* 在 main.onLoad 中调用,注册 wx.onShow / wx.onHide。
* 同时把当前启动参数中的 shareCode 作为种子,避免初次冷启动时
* 因 wx.onShow 也会被调用一次而重复触发分享流程。
*/
init(): void {
if (this._initialized) {
return;
}
this._initialized = true;
if (!WxSDK.isWechat()) {
return;
}
// 冷启动时先把当前 launch 中的 shareCode 标记成已处理,
// 避免 wx.onShow 在初始展示时拿到同一个 code 又走一遍 join。
this._activeShareCode = WxSDK.getShareCodeFromLaunch();
WxSDK.onAppShow(this._showHandler);
WxSDK.onAppHide(this._hideHandler);
console.log('[ShareLaunchHandler] 已注册 onShow/onHide 监听');
}
/**
* 由 PageLoading 在初始 join 之后调用,把已处理的 shareCode 显式同步过来,
* 让 onShow 收到相同 code 时不会重复 join。
*/
markActiveShareCode(code: string | null): void {
this._activeShareCode = code;
}
/**
* 主动取消监听(一般无需调用,留作扩展)
*/
dispose(): void {
if (!this._initialized) return;
this._initialized = false;
WxSDK.offAppShow(this._showHandler);
WxSDK.offAppHide(this._hideHandler);
}
private async _handleShareCode(code: string): Promise<void> {
if (this._isHandlingShow) {
console.log('[ShareLaunchHandler] 已有 onShow 分享流程在执行,跳过', code);
return;
}
this._isHandlingShow = true;
try {
console.log('[ShareLaunchHandler] 检测到新的 shareCode准备切换:', code);
// 确保已登录initialize 内部对已有 token 做了校验,幂等可重复调用)
const loginOk = await AuthManager.instance.initialize();
if (!loginOk) {
console.warn('[ShareLaunchHandler] 登录失败,放弃 onShow 分享切换');
return;
}
// 切到新的分享前清掉旧分享态,避免 ShareManager 内残留旧题单导致 PageLevel 错位
if (ShareManager.instance.isShareMode) {
ShareManager.instance.clearShareMode();
}
const joinOk = await ShareManager.instance.joinShare(code);
if (!joinOk) {
console.warn('[ShareLaunchHandler] 加入分享失败:', code);
return;
}
// 标记当前激活的分享码
this._activeShareCode = code;
// 跳过中间页,直接打开 PageLevel 进入分享挑战。
// PageLevel 会在 onViewShow 中根据 ShareManager.shareCode 与本地缓存比对,
// 决定是否需要 `_reinitLevelSession`。
ViewManager.instance.open('PageLevel', {
params: { shareMode: true },
});
console.log('[ShareLaunchHandler] 已切换到分享挑战:', code);
} catch (err) {
console.error('[ShareLaunchHandler] 处理 shareCode 异常:', err);
} finally {
this._isHandlingShow = false;
}
}
}
```

View File

@@ -0,0 +1,817 @@
# 梗中作乐 V1.0 - 源代码文档(第 10 部分)
软件名称:梗中作乐
版本号V1.0
---
## 第 10 部分PK 战绩与挑战结算
本部分包含 PK 战绩榜单页与 PK 挑战结算页两个核心结算页面。
### 10.1 PK 战绩页 (assets/prefabs/PagePKData.ts)
```typescript
import { _decorator, Node, Button, instantiate, Label, ScrollView, UITransform, Sprite, SpriteFrame, Texture2D, ImageAsset, assetManager } from 'cc';
import { BaseView } from 'db://assets/scripts/core/BaseView';
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
import { CreatedShareItem, ParticipatedShareItem, ShareParticipantRankSummary } from 'db://assets/scripts/types/ApiTypes';
import { ShareManager } from 'db://assets/scripts/utils/ShareManager';
import { ToastManager } from 'db://assets/scripts/utils/ToastManager';
import { AudioManager } from 'db://assets/scripts/utils/AudioManager';
const { ccclass, property } = _decorator;
@ccclass('PagePKData')
export class PagePKData extends BaseView {
private static readonly CREATED_ITEM_TOP_PADDING = 20;
private static readonly CREATED_ITEM_BOTTOM_PADDING = 20;
private static readonly CREATED_ITEM_SPACING = 20;
private static readonly PARTICIPATED_ITEM_TOP_PADDING = 20;
private static readonly PARTICIPATED_ITEM_BOTTOM_PADDING = 20;
private static readonly PARTICIPATED_ITEM_SPACING = 20;
@property({ type: Node, tooltip: '返回按钮' })
backBtn: Node | null = null;
@property({ type: Node, tooltip: '我创建的挑战列表 content 节点' })
createdListContent: Node | null = null;
@property({ type: Node, tooltip: '我创建的挑战列表条目模板 FriendsPKRankListItem' })
createdListItemTemplate: Node | null = null;
@property({ type: Node, tooltip: '我参与的挑战列表 content 节点' })
participatedListContent: Node | null = null;
@property({ type: Node, tooltip: '我参与的挑战列表条目模板 MyPKListItem' })
participatedListItemTemplate: Node | null = null;
private _createdShares: CreatedShareItem[] = [];
private _participatedShares: ParticipatedShareItem[] = [];
private _createdItemNodes: Node[] = [];
private _participatedItemNodes: Node[] = [];
private _createdButtonBindings: Array<{ node: Node; handler: () => void }> = [];
private _isLoading: boolean = false;
private _renderVersion: number = 0;
onViewLoad(): void {
this._resolveNodes();
if (this.backBtn) {
this.backBtn.on(Button.EventType.CLICK, this._onBackClick, this);
}
this._hideCreatedItemTemplate();
this._hideParticipatedItemTemplate();
}
onViewShow(): void {
this._resolveNodes();
void this._loadShareLists();
}
onViewHide(): void {
this._renderVersion++;
}
private _onBackClick(): void {
AudioManager.instance.playButtonClick();
ViewManager.instance.back();
}
private async _loadShareLists(): Promise<void> {
if (this._isLoading) {
return;
}
this._isLoading = true;
try {
const [createdItems, participatedItems] = await Promise.all([
ShareManager.instance.fetchCreatedShares(),
ShareManager.instance.fetchParticipatedShares(),
]);
if (!createdItems || !participatedItems) {
ToastManager.instance.show('获取挑战列表失败,请稍后重试');
}
this._createdShares = createdItems ?? [];
this._participatedShares = participatedItems ?? [];
console.log('[PagePKData] 我创建的挑战列表:', this._createdShares);
console.log('[PagePKData] 我参与的挑战列表:', this._participatedShares);
this._renderCreatedShares();
this._renderParticipatedShares();
} finally {
this._isLoading = false;
}
}
onViewDestroy(): void {
if (this.backBtn) {
this.backBtn.off(Button.EventType.CLICK, this._onBackClick, this);
}
this._clearCreatedItems();
this._clearParticipatedItems();
}
private _resolveNodes(): void {
if (!this.backBtn || !this.backBtn.isValid) {
this.backBtn = this.node.getChildByName('BtnBack');
}
const createdList = this.node.getChildByName('FriendsPKRankList');
const view = createdList?.getChildByName('view');
this.createdListContent = this.createdListContent ?? view?.getChildByName('content') ?? null;
this.createdListItemTemplate = this.createdListItemTemplate
?? this.createdListContent?.getChildByName('FriendsPKRankListItem')
?? null;
const participatedList = this.node.getChildByName('MyPKList');
const participatedView = participatedList?.getChildByName('view');
this.participatedListContent = this.participatedListContent ?? participatedView?.getChildByName('content') ?? null;
this.participatedListItemTemplate = this.participatedListItemTemplate
?? this.participatedListContent?.getChildByName('MyPKListItem')
?? null;
if (!this.createdListContent) {
console.warn('[PagePKData] 未找到 FriendsPKRankList/content 节点');
}
if (!this.createdListItemTemplate) {
console.warn('[PagePKData] 未找到 FriendsPKRankListItem 模板节点');
}
if (!this.participatedListContent) {
console.warn('[PagePKData] 未找到 MyPKList/content 节点');
}
if (!this.participatedListItemTemplate) {
console.warn('[PagePKData] 未找到 MyPKListItem 模板节点');
}
}
private _renderCreatedShares(): void {
this._renderVersion++;
this._clearCreatedItems();
if (!this.createdListContent || !this.createdListItemTemplate) {
return;
}
this._layoutCreatedList(this._createdShares.length);
this._hideCreatedItemTemplate();
this._createdShares.forEach((share, index) => {
const item = instantiate(this.createdListItemTemplate!);
item.name = `FriendsPKRankListItem_${index + 1}`;
item.active = true;
this.createdListContent!.addChild(item);
this._createdItemNodes.push(item);
this._positionCreatedItem(item, index);
this._applyCreatedShare(item, share, this._renderVersion);
});
this._scrollCreatedListToTop();
}
private _renderParticipatedShares(): void {
this._clearParticipatedItems();
if (!this.participatedListContent || !this.participatedListItemTemplate) {
return;
}
this._layoutParticipatedList(this._participatedShares.length);
this._hideParticipatedItemTemplate();
this._participatedShares.forEach((share, index) => {
const item = instantiate(this.participatedListItemTemplate!);
item.name = `MyPKListItem_${index + 1}`;
item.active = true;
this.participatedListContent!.addChild(item);
this._participatedItemNodes.push(item);
this._positionParticipatedItem(item, index);
this._applyParticipatedShare(item, share);
});
this._scrollParticipatedListToTop();
}
private _applyCreatedShare(item: Node, share: CreatedShareItem, version: number): void {
this._setLabel(this._findLabelIn(item, 'Name'), share.title || '未命名挑战');
this._setLabel(this._findLabelIn(item, 'ParticipateLabel'), `${share.participantCount ?? 0}人参与`);
const firstParticipant = this._getFirstParticipant(share);
const firstName = this._getParticipantName(firstParticipant);
const rankNumber = firstParticipant?.rank ?? 1;
this._setLabel(this._findLabelIn(item, 'NameLabel'), firstName || (share.participantCount > 0 ? '微信用户' : '暂无参与'));
this._setLabel(this._findLabelIn(item, 'RankNumberLabel'), firstParticipant ? `${rankNumber}` : '暂无排名');
this._loadAvatar(firstParticipant?.avatarUrl ?? '', this._findAvatarSprite(item), version);
const viewButton = this._findChild(item, 'ViewButton');
if (viewButton) {
const handler = () => {
AudioManager.instance.playButtonClick();
this._openShareDetail(share);
};
viewButton.on(Button.EventType.CLICK, handler, this);
this._createdButtonBindings.push({ node: viewButton, handler });
}
const shareButton = this._findChild(item, 'ShareButton');
if (shareButton) {
const handler = () => {
AudioManager.instance.playButtonClick();
ShareManager.instance.triggerWxShare(share.title, share.shareCode);
};
shareButton.on(Button.EventType.CLICK, handler, this);
this._createdButtonBindings.push({ node: shareButton, handler });
}
}
private _applyParticipatedShare(item: Node, share: ParticipatedShareItem): void {
this._setLabel(this._findLabelIn(item, 'ChallangeName'), share.title || '未命名挑战');
this._setLabel(this._findLabelIn(item, 'ParticipateLabel'), `${share.participantCount ?? 0}人参与`);
this._applyParticipatedRank(item, share.userRank);
}
private _openShareDetail(share: CreatedShareItem): void {
if (!share.shareCode) {
ToastManager.instance.show('挑战数据异常,请稍后重试');
return;
}
ViewManager.instance.open('PagePKDetail', {
params: {
share,
shareCode: share.shareCode,
},
onError: (err) => {
console.error('[PagePKData] 打开挑战详情失败:', err);
ToastManager.instance.show('打开挑战详情失败,请稍后重试');
},
});
}
private _getFirstParticipant(share: CreatedShareItem): ShareParticipantRankSummary | null {
return share.firstPlaceUser ?? share.topParticipant ?? share.firstParticipant ?? share.champion ?? null;
}
private _getParticipantName(participant: ShareParticipantRankSummary | null): string {
return participant?.nickname || participant?.nickName || '';
}
private _layoutCreatedList(itemCount: number): void {
if (!this.createdListContent || !this.createdListItemTemplate) {
return;
}
const contentTransform = this.createdListContent.getComponent(UITransform);
const viewTransform = this.createdListContent.parent?.getComponent(UITransform) ?? null;
const itemTransform = this.createdListItemTemplate.getComponent(UITransform);
if (!contentTransform || !viewTransform || !itemTransform) {
return;
}
const contentHeight = Math.max(
viewTransform.height,
PagePKData.CREATED_ITEM_TOP_PADDING
+ PagePKData.CREATED_ITEM_BOTTOM_PADDING
+ itemCount * itemTransform.height
+ Math.max(0, itemCount - 1) * PagePKData.CREATED_ITEM_SPACING,
);
contentTransform.setContentSize(contentTransform.width, contentHeight);
this.createdListContent.setPosition(
this.createdListContent.position.x,
viewTransform.height / 2,
this.createdListContent.position.z,
);
}
private _layoutParticipatedList(itemCount: number): void {
if (!this.participatedListContent || !this.participatedListItemTemplate) {
return;
}
const contentTransform = this.participatedListContent.getComponent(UITransform);
const viewTransform = this.participatedListContent.parent?.getComponent(UITransform) ?? null;
const itemTransform = this.participatedListItemTemplate.getComponent(UITransform);
if (!contentTransform || !viewTransform || !itemTransform) {
return;
}
const contentHeight = Math.max(
viewTransform.height,
PagePKData.PARTICIPATED_ITEM_TOP_PADDING
+ PagePKData.PARTICIPATED_ITEM_BOTTOM_PADDING
+ itemCount * itemTransform.height
+ Math.max(0, itemCount - 1) * PagePKData.PARTICIPATED_ITEM_SPACING,
);
contentTransform.setContentSize(contentTransform.width, contentHeight);
this.participatedListContent.setPosition(
this.participatedListContent.position.x,
viewTransform.height / 2,
this.participatedListContent.position.z,
);
}
private _positionCreatedItem(item: Node, index: number): void {
const itemTransform = item.getComponent(UITransform);
if (!itemTransform) {
return;
}
const y = -PagePKData.CREATED_ITEM_TOP_PADDING
- itemTransform.height / 2
- index * (itemTransform.height + PagePKData.CREATED_ITEM_SPACING);
item.setPosition(0, y, item.position.z);
}
private _positionParticipatedItem(item: Node, index: number): void {
const itemTransform = item.getComponent(UITransform);
if (!itemTransform) {
return;
}
const y = -PagePKData.PARTICIPATED_ITEM_TOP_PADDING
- itemTransform.height / 2
- index * (itemTransform.height + PagePKData.PARTICIPATED_ITEM_SPACING);
item.setPosition(0, y, item.position.z);
}
private _scrollCreatedListToTop(): void {
const scrollView = this.node.getChildByName('FriendsPKRankList')?.getComponent(ScrollView);
scrollView?.scrollToTop(0);
}
private _scrollParticipatedListToTop(): void {
const scrollView = this.node.getChildByName('MyPKList')?.getComponent(ScrollView);
scrollView?.scrollToTop(0);
}
private _applyParticipatedRank(item: Node, rank: number | null): void {
const rankRoot = this._findChild(item, 'Rank');
if (!rankRoot) {
return;
}
const rank1Badge = this._findChild(rankRoot, 'rank1badge');
const rank2Badge = this._findChild(rankRoot, 'rank2badge');
const rank3Badge = this._findChild(rankRoot, 'rank3badge');
const rankNumberNode = this._findChild(rankRoot, 'RankNumber');
const normalizedRank = rank && rank > 0 ? rank : null;
if (rank1Badge) {
rank1Badge.active = normalizedRank === 1;
}
if (rank2Badge) {
rank2Badge.active = normalizedRank === 2;
}
if (rank3Badge) {
rank3Badge.active = normalizedRank === 3;
}
if (rankNumberNode) {
rankNumberNode.active = normalizedRank !== 1 && normalizedRank !== 2 && normalizedRank !== 3;
this._setLabel(rankNumberNode.getComponent(Label), normalizedRank ? `${normalizedRank}` : '-');
}
}
private _findAvatarSprite(item: Node): Sprite | null {
const headNode = this._findChild(item, 'Head');
return headNode?.children[0]?.getComponent(Sprite) ?? headNode?.getComponent(Sprite) ?? null;
}
private _loadAvatar(url: string, sprite: Sprite | null, version: number): void {
if (!url || !sprite) {
return;
}
assetManager.loadRemote<ImageAsset>(url, (err, imageAsset) => {
if (err || !imageAsset || version !== this._renderVersion || !sprite.node.isValid) {
if (err) {
console.error('[PagePKData] 加载第一名头像失败:', url, err);
}
return;
}
const texture = new Texture2D();
texture.image = imageAsset;
const spriteFrame = new SpriteFrame();
spriteFrame.texture = texture;
sprite.spriteFrame = spriteFrame;
});
}
private _clearCreatedItems(): void {
this._unbindCreatedButtons();
for (const item of this._createdItemNodes) {
if (item.isValid) {
item.removeFromParent();
item.destroy();
}
}
this._createdItemNodes = [];
this._hideCreatedItemTemplate();
}
private _clearParticipatedItems(): void {
for (const item of this._participatedItemNodes) {
if (item.isValid) {
item.removeFromParent();
item.destroy();
}
}
this._participatedItemNodes = [];
this._hideParticipatedItemTemplate();
}
private _unbindCreatedButtons(): void {
for (const binding of this._createdButtonBindings) {
if (binding.node.isValid) {
binding.node.off(Button.EventType.CLICK, binding.handler, this);
}
}
this._createdButtonBindings = [];
}
private _hideCreatedItemTemplate(): void {
if (this.createdListItemTemplate?.isValid) {
this.createdListItemTemplate.active = false;
}
}
private _hideParticipatedItemTemplate(): void {
if (this.participatedListItemTemplate?.isValid) {
this.participatedListItemTemplate.active = false;
}
}
private _setLabel(label: Label | null, text: string): void {
if (label) {
label.string = text;
}
}
private _findLabelIn(root: Node, nodeName: string): Label | null {
return this._findChild(root, nodeName)?.getComponent(Label) ?? null;
}
private _findChild(root: Node, nodeName: string): Node | null {
if (root.name === nodeName) {
return root;
}
for (const child of root.children) {
const found = this._findChild(child, nodeName);
if (found) {
return found;
}
}
return null;
}
}
```
### 10.2 PK 结算页 (assets/prefabs/PagePKEnd.ts)
```typescript
import { _decorator, assetManager, Button, ImageAsset, instantiate, Label, Node, ScrollView, Sprite, SpriteFrame, Texture2D, UITransform } from 'cc';
import { BaseView } from 'db://assets/scripts/core/BaseView';
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
import { ShareManager } from 'db://assets/scripts/utils/ShareManager';
import { SubmitShareData, SubmittedShareLevelData } from 'db://assets/scripts/types/ApiTypes';
import { AudioManager } from 'db://assets/scripts/utils/AudioManager';
const { ccclass, property } = _decorator;
@ccclass('PagePKEnd')
export class PagePKEnd extends BaseView {
private static readonly ANSWER_ITEM_TOP_PADDING = 16;
private static readonly ANSWER_ITEM_BOTTOM_PADDING = 16;
private static readonly ANSWER_ITEM_SPACING = 16;
private static readonly COVER_IMAGE_WIDTH = 1299;
private static readonly COVER_IMAGE_HEIGHT = 1004;
@property({ type: Node, tooltip: '返回首页按钮' })
settingButton: Node | null = null;
@property({ type: Label, tooltip: '顶部排名文案例如获得了第1名' })
rankLabel: Label | null = null;
@property({ type: Label, tooltip: '答对题数文案例如答对了4题' })
rightNumberLabel: Label | null = null;
@property({ type: Label, tooltip: '本人排名文案例如您获得了第1名' })
rankNumberLabel: Label | null = null;
@property({ type: Label, tooltip: '参与人数文案例如一共66人参与了挑战' })
participateNumberLabel: Label | null = null;
@property({ type: Label, tooltip: '答案列表标题' })
answerTitleLabel: Label | null = null;
@property({ type: Node, tooltip: '答案列表 content 节点' })
answerListContent: Node | null = null;
@property({ type: Node, tooltip: '答案列表条目模板 AnswerItem' })
answerItemTemplate: Node | null = null;
private _answerItemNodes: Node[] = [];
private _answerButtonBindings: Array<{ node: Node; handler: () => void }> = [];
private _renderVersion: number = 0;
onViewLoad(): void {
this._resolveNodes();
this._bindEvents();
this._hideAnswerTemplate();
}
onViewShow(): void {
console.log('[PagePKEnd] onViewShow');
this._resolveNodes();
this._renderResult(this.getParams()?.result ?? null);
}
onViewDestroy(): void {
this._unbindEvents();
this._clearAnswerItems();
}
private _resolveNodes(): void {
if (!this.settingButton || !this.settingButton.isValid) {
this.settingButton = this.node.getChildByName('SettingButton');
}
this.rankLabel = this.rankLabel ?? this._findLabel('RankLabel');
this.rightNumberLabel = this.rightNumberLabel ?? this._findLabel('RightNumberLabel');
this.rankNumberLabel = this.rankNumberLabel ?? this._findLabel('RankNumberLabel');
this.participateNumberLabel = this.participateNumberLabel ?? this._findLabel('PartipateNumberLabel');
this.answerTitleLabel = this.answerTitleLabel ?? this._findLabel('AnswerTitle');
const answerList = this.node.getChildByName('AnswerList');
const view = answerList?.getChildByName('view');
this.answerListContent = this.answerListContent ?? view?.getChildByName('content') ?? null;
this.answerItemTemplate = this.answerItemTemplate
?? this.answerListContent?.getChildByName('AnswerItem')
?? null;
if (!this.settingButton) {
console.warn('[PagePKEnd] 未找到 SettingButton 节点');
}
if (!this.answerListContent) {
console.warn('[PagePKEnd] 未找到 AnswerList/content 节点');
}
if (!this.answerItemTemplate) {
console.warn('[PagePKEnd] 未找到 AnswerItem 模板节点');
}
}
private _bindEvents(): void {
const button = this.settingButton?.getComponent(Button);
if (!button) {
console.warn('[PagePKEnd] SettingButton 缺少 Button 组件');
return;
}
this.settingButton?.on(Button.EventType.CLICK, this._onHomeClick, this);
}
private _unbindEvents(): void {
if (this.settingButton && this.settingButton.isValid) {
this.settingButton.off(Button.EventType.CLICK, this._onHomeClick, this);
}
this._unbindAnswerButtons();
}
private _onHomeClick(): void {
AudioManager.instance.playButtonClick();
ShareManager.instance.clearShareMode();
ViewManager.instance.replace('PageHome');
}
private _renderResult(result: SubmitShareData | null): void {
this._renderVersion++;
this._clearAnswerItems();
if (!result) {
this._setLabel(this.rankLabel, '暂无排名');
this._setLabel(this.rightNumberLabel, '答对了0题');
this._setLabel(this.rankNumberLabel, '您暂未上榜');
this._setLabel(this.participateNumberLabel, '暂无参与数据');
this._setLabel(this.answerTitleLabel, '暂无挑战结果');
this._hideAnswerTemplate();
return;
}
this._setLabel(this.rankLabel, `获得了第${result.rank}`);
this._setLabel(this.rightNumberLabel, `答对了${result.correctCount}`);
this._setLabel(this.rankNumberLabel, `您获得了第${result.rank}`);
this._setLabel(this.participateNumberLabel, `一共${result.participantCount}人参与了挑战`);
this._setLabel(this.answerTitleLabel, `共用时${result.totalTimeSpent}s揭晓答案吧`);
this._renderAnswerList(result.levels ?? []);
}
private _renderAnswerList(levels: SubmittedShareLevelData[]): void {
if (!this.answerListContent || !this.answerItemTemplate) {
return;
}
this._hideAnswerTemplate();
const version = this._renderVersion;
this._layoutAnswerContent(levels.length);
levels.forEach((level, index) => {
const item = instantiate(this.answerItemTemplate!);
item.name = `AnswerItem_${index + 1}`;
item.active = true;
this.answerListContent!.addChild(item);
this._answerItemNodes.push(item);
this._positionAnswerItem(item, index);
this._applyAnswerState(item, level);
const coverSprite = this._findChild(item, 'CoverImage')?.getComponent(Sprite) ?? null;
this._prepareCoverSprite(coverSprite);
this._loadCoverImage(level.image1Url, coverSprite, version);
});
this._scrollAnswerListToTop();
}
private _layoutAnswerContent(itemCount: number): void {
if (!this.answerListContent || !this.answerItemTemplate) {
return;
}
const contentTransform = this.answerListContent.getComponent(UITransform);
const viewTransform = this.answerListContent.parent?.getComponent(UITransform) ?? null;
const itemTransform = this.answerItemTemplate.getComponent(UITransform);
if (!contentTransform || !viewTransform || !itemTransform) {
return;
}
const itemHeight = itemTransform.height;
const contentHeight = Math.max(
viewTransform.height,
PagePKEnd.ANSWER_ITEM_TOP_PADDING
+ PagePKEnd.ANSWER_ITEM_BOTTOM_PADDING
+ itemCount * itemHeight
+ Math.max(0, itemCount - 1) * PagePKEnd.ANSWER_ITEM_SPACING,
);
contentTransform.setContentSize(contentTransform.width, contentHeight);
this.answerListContent.setPosition(
this.answerListContent.position.x,
viewTransform.height / 2,
this.answerListContent.position.z,
);
}
private _positionAnswerItem(item: Node, index: number): void {
const itemTransform = item.getComponent(UITransform);
if (!itemTransform) {
return;
}
const y = -PagePKEnd.ANSWER_ITEM_TOP_PADDING
- itemTransform.height / 2
- index * (itemTransform.height + PagePKEnd.ANSWER_ITEM_SPACING);
item.setPosition(0, y, item.position.z);
}
private _applyAnswerState(item: Node, level: SubmittedShareLevelData): void {
const answerButton = this._findChild(item, 'ButtonViewAnswer');
const buttonLabel = answerButton?.getChildByName('Label')?.getComponent(Label) ?? null;
const answerLabelNode = this._findChild(item, 'AnswerLabel');
const answerLabel = answerLabelNode?.getComponent(Label) ?? null;
this._setLabel(buttonLabel, '查看答案');
this._setLabel(answerLabel, level.answer || '-');
if (level.isCorrect) {
if (answerButton) {
answerButton.active = false;
}
if (answerLabelNode) {
answerLabelNode.active = true;
}
return;
}
if (answerButton) {
answerButton.active = true;
}
if (answerLabelNode) {
answerLabelNode.active = false;
}
const handler = () => {
AudioManager.instance.playButtonClick();
if (answerButton?.isValid) {
answerButton.active = false;
}
if (answerLabelNode?.isValid) {
answerLabelNode.active = true;
}
};
if (answerButton) {
answerButton.on(Button.EventType.CLICK, handler, this);
this._answerButtonBindings.push({ node: answerButton, handler });
}
}
private _scrollAnswerListToTop(): void {
const scrollView = this.node.getChildByName('AnswerList')?.getComponent(ScrollView);
scrollView?.scrollToTop(0);
}
private _loadCoverImage(url: string, sprite: Sprite | null, version: number): void {
if (!url || !sprite) {
return;
}
this._prepareCoverSprite(sprite);
assetManager.loadRemote<ImageAsset>(url, (err, imageAsset) => {
if (err || !imageAsset || version !== this._renderVersion || !sprite.node.isValid) {
if (err) {
console.error('[PagePKEnd] 加载答案封面失败:', url, err);
}
return;
}
const texture = new Texture2D();
texture.image = imageAsset;
const spriteFrame = new SpriteFrame();
spriteFrame.texture = texture;
this._prepareCoverSprite(sprite);
sprite.spriteFrame = spriteFrame;
this._prepareCoverSprite(sprite);
});
}
private _prepareCoverSprite(sprite: Sprite | null): void {
if (!sprite?.node.isValid) {
return;
}
sprite.sizeMode = Sprite.SizeMode.CUSTOM;
const transform = sprite.node.getComponent(UITransform);
if (transform) {
transform.setContentSize(PagePKEnd.COVER_IMAGE_WIDTH, PagePKEnd.COVER_IMAGE_HEIGHT);
}
sprite.node.setScale(0.242, 0.242, 0.242);
}
private _clearAnswerItems(): void {
this._unbindAnswerButtons();
for (const item of this._answerItemNodes) {
if (item.isValid) {
item.removeFromParent();
item.destroy();
}
}
this._answerItemNodes = [];
this._hideAnswerTemplate();
}
private _unbindAnswerButtons(): void {
for (const binding of this._answerButtonBindings) {
if (binding.node.isValid) {
binding.node.off(Button.EventType.CLICK, binding.handler, this);
}
}
this._answerButtonBindings = [];
}
private _hideAnswerTemplate(): void {
if (this.answerItemTemplate?.isValid) {
this.answerItemTemplate.active = false;
}
}
private _setLabel(label: Label | null, text: string): void {
if (label) {
label.string = text;
}
}
private _findLabel(nodeName: string): Label | null {
return this._findChild(this.node, nodeName)?.getComponent(Label) ?? null;
}
private _findChild(root: Node, nodeName: string): Node | null {
if (root.name === nodeName) {
return root;
}
for (const child of root.children) {
const found = this._findChild(child, nodeName);
if (found) {
return found;
}
}
return null;
}
}
```

View File

@@ -0,0 +1,646 @@
# 梗中作乐 V1.0 - 源代码文档(第 11 部分)
软件名称:梗中作乐
版本号V1.0
---
## 第 11 部分PK 详情与圆角渲染
本部分包含 PK 单场详情页与 UI 圆角遮罩、圆角材质工具。
### 11.1 PK 详情页 (assets/prefabs/PagePKDetail.ts)
```typescript
import { _decorator, assetManager, Button, ImageAsset, instantiate, Label, Node, ScrollView, Sprite, SpriteFrame, Texture2D, UITransform } from 'cc';
import { BaseView } from 'db://assets/scripts/core/BaseView';
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
import { CreatedShareItem, ShareDetailData, ShareParticipantRankSummary } from 'db://assets/scripts/types/ApiTypes';
import { ShareManager } from 'db://assets/scripts/utils/ShareManager';
import { ToastManager } from 'db://assets/scripts/utils/ToastManager';
const { ccclass, property } = _decorator;
interface PagePKDetailParams {
share?: CreatedShareItem | null;
shareCode?: string | null;
detail?: ShareDetailData | null;
}
@ccclass('PagePKDetail')
export class PagePKDetail extends BaseView {
private static readonly RANK_ITEM_TOP_PADDING = 16;
private static readonly RANK_ITEM_BOTTOM_PADDING = 16;
private static readonly RANK_ITEM_SPACING = 16;
@property({ type: Label, tooltip: '参与人数文案例如66 人参与' })
participateLabel: Label | null = null;
private _backButton: Node | null = null;
private _titleLabel: Label | null = null;
private _championPanel: Node | null = null;
private _rankListContent: Node | null = null;
private _rankListItemTemplate: Node | null = null;
private _rankItemNodes: Node[] = [];
private _renderVersion: number = 0;
onViewLoad(): void {
this._resolveNodes();
this._bindEvents();
this._hideRankItemTemplate();
}
onViewShow(): void {
this._resolveNodes();
void this._loadAndRenderDetail();
}
onViewHide(): void {
this._renderVersion++;
}
onViewDestroy(): void {
this._unbindEvents();
this._clearRankItems();
}
private _resolveNodes(): void {
if (!this._backButton || !this._backButton.isValid) {
this._backButton = this.node.getChildByName('ButtonBack');
}
this._titleLabel = this._titleLabel
?? this.node.getChildByName('Title')?.getChildByName('Label')?.getComponent(Label)
?? null;
this.participateLabel = this.participateLabel ?? this._findLabelIn(this.node, 'ParticipateLabel');
this._championPanel = this._championPanel ?? this.node.getChildByName('ChampionPanel');
const rankList = this.node.getChildByName('RankList');
const view = rankList?.getChildByName('view');
this._rankListContent = this._rankListContent ?? view?.getChildByName('content') ?? null;
this._rankListItemTemplate = this._rankListItemTemplate
?? this._rankListContent?.getChildByName('RankListItem')
?? null;
if (!this._backButton) {
console.warn('[PagePKDetail] 未找到 ButtonBack 节点');
}
if (!this._rankListContent) {
console.warn('[PagePKDetail] 未找到 RankList/content 节点');
}
if (!this._rankListItemTemplate) {
console.warn('[PagePKDetail] 未找到 RankListItem 模板节点');
}
if (!this.participateLabel) {
console.warn('[PagePKDetail] 未找到 ParticipateLabel 节点');
}
}
private _bindEvents(): void {
if (this._backButton) {
this._backButton.on(Button.EventType.CLICK, this._onBackClick, this);
}
}
private _unbindEvents(): void {
if (this._backButton?.isValid) {
this._backButton.off(Button.EventType.CLICK, this._onBackClick, this);
}
}
private _onBackClick(): void {
ViewManager.instance.back();
}
private async _loadAndRenderDetail(): Promise<void> {
const params = this.getParams() as PagePKDetailParams | null;
const share = this._resolveShare(params);
const passedDetail = params?.detail ?? null;
const shareCode = passedDetail?.shareCode ?? params?.shareCode ?? share?.shareCode ?? null;
const version = ++this._renderVersion;
if (passedDetail) {
this._renderShareDetail(passedDetail, version);
return;
}
this._renderShareSummary(share, version);
if (!shareCode) {
ToastManager.instance.show('挑战数据异常,请稍后重试');
return;
}
const detail = await ShareManager.instance.fetchShareDetail(shareCode);
if (version !== this._renderVersion || !this.isShowing) {
return;
}
if (!detail) {
ToastManager.instance.show('获取挑战详情失败,请稍后重试');
this._renderShareSummary(share, version);
return;
}
this._renderShareDetail(detail, version);
}
private _resolveShare(params: PagePKDetailParams | null): CreatedShareItem | null {
if (params?.share) {
return params.share;
}
const shareCode = params?.shareCode ?? params?.detail?.shareCode;
if (!shareCode) {
return null;
}
return ShareManager.instance.createdShares.find((share) => share.shareCode === shareCode) ?? null;
}
private _renderShareSummary(share: CreatedShareItem | null, version: number): void {
this._clearRankItems();
this._setLabel(this._titleLabel, share?.title || '挑战详情');
this._renderParticipateCount(share);
this._renderChampion(share ? this._getFirstParticipant(share) : null, share, version);
this._layoutRankContent(0);
this._scrollRankListToTop();
}
private _renderShareDetail(detail: ShareDetailData, version: number): void {
this._clearRankItems();
const rankings = this._normalizeRankings(detail.rankings ?? []);
const champion = rankings.find((participant) => participant.rank === 1) ?? rankings[0] ?? null;
const restRankings = rankings.filter((participant) => participant !== champion);
this._setLabel(this._titleLabel, detail.title || '挑战详情');
this._renderParticipateCount(detail);
this._renderChampion(champion, detail, version);
this._renderRankList(restRankings, version);
}
private _renderParticipateCount(shareInfo: CreatedShareItem | ShareDetailData | null): void {
const participantCount = Math.max(0, shareInfo?.participantCount ?? 0);
this._setLabel(this.participateLabel, `${participantCount} 人参与`);
}
private _normalizeRankings(rankings: ShareParticipantRankSummary[]): ShareParticipantRankSummary[] {
return rankings
.map((participant, index) => ({
...participant,
rank: participant.rank ?? index + 1,
}))
.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0));
}
private _getFirstParticipant(share: CreatedShareItem): ShareParticipantRankSummary | null {
return share.firstPlaceUser ?? share.topParticipant ?? share.firstParticipant ?? share.champion ?? null;
}
private _renderChampion(
champion: ShareParticipantRankSummary | null,
shareInfo: CreatedShareItem | ShareDetailData | null,
version: number,
): void {
const panel = this._championPanel;
if (!panel) {
return;
}
this._setLabel(this._findLabelIn(panel, 'UserName'), this._getParticipantDisplayName(champion, '暂无参与'));
this._setLabel(this._findLabelIn(panel, 'RightInfo'), this._formatCorrectText(champion, shareInfo));
this._setLabel(this._findLabelIn(panel, 'UsedTime'), this._formatTimeText(champion, shareInfo));
this._loadAvatar(champion?.avatarUrl ?? '', this._findAvatarSprite(panel), version);
}
private _renderRankList(participants: ShareParticipantRankSummary[], version: number): void {
if (!this._rankListContent || !this._rankListItemTemplate) {
return;
}
this._hideRankItemTemplate();
this._layoutRankContent(participants.length);
participants.forEach((participant, index) => {
const item = instantiate(this._rankListItemTemplate!);
item.name = `RankListItem_${index + 1}`;
item.active = true;
this._rankListContent!.addChild(item);
this._rankItemNodes.push(item);
this._positionRankItem(item, index);
this._applyRankItem(item, participant, version);
});
this._scrollRankListToTop();
}
private _applyRankItem(item: Node, participant: ShareParticipantRankSummary, version: number): void {
const rank = participant.rank ?? 0;
const rank2Badge = this._findChild(item, 'rank2badge');
const rank3Badge = this._findChild(item, 'rank3badge');
const rankNumberNode = this._findChild(item, 'RankNumber');
if (rank2Badge) {
rank2Badge.active = rank === 2;
}
if (rank3Badge) {
rank3Badge.active = rank === 3;
}
if (rankNumberNode) {
rankNumberNode.active = rank !== 2 && rank !== 3;
this._setLabel(rankNumberNode.getComponent(Label), rank > 0 ? `${rank}` : '-');
}
this._setLabel(this._findLabelIn(item, 'UserName'), this._getParticipantDisplayName(participant, '微信用户'));
this._setLabel(this._findLabelIn(item, 'RightInfo'), this._formatCorrectText(participant, null));
this._setLabel(this._findLabelIn(item, 'UsedTime'), this._formatTimeText(participant));
this._loadAvatar(participant.avatarUrl ?? '', this._findAvatarSprite(item), version);
}
private _layoutRankContent(itemCount: number): void {
if (!this._rankListContent || !this._rankListItemTemplate) {
return;
}
const contentTransform = this._rankListContent.getComponent(UITransform);
const viewTransform = this._rankListContent.parent?.getComponent(UITransform) ?? null;
const itemTransform = this._rankListItemTemplate.getComponent(UITransform);
if (!contentTransform || !viewTransform || !itemTransform) {
return;
}
const contentHeight = Math.max(
viewTransform.height,
PagePKDetail.RANK_ITEM_TOP_PADDING
+ PagePKDetail.RANK_ITEM_BOTTOM_PADDING
+ itemCount * itemTransform.height
+ Math.max(0, itemCount - 1) * PagePKDetail.RANK_ITEM_SPACING,
);
contentTransform.setContentSize(contentTransform.width, contentHeight);
this._rankListContent.setPosition(
this._rankListContent.position.x,
viewTransform.height / 2,
this._rankListContent.position.z,
);
}
private _positionRankItem(item: Node, index: number): void {
const itemTransform = item.getComponent(UITransform);
if (!itemTransform) {
return;
}
const y = -PagePKDetail.RANK_ITEM_TOP_PADDING
- itemTransform.height / 2
- index * (itemTransform.height + PagePKDetail.RANK_ITEM_SPACING);
item.setPosition(0, y, item.position.z);
}
private _scrollRankListToTop(): void {
this.node.getChildByName('RankList')?.getComponent(ScrollView)?.scrollToTop(0);
}
private _formatCorrectText(
participant: ShareParticipantRankSummary | null,
shareInfo: CreatedShareItem | ShareDetailData | null,
): string {
if (participant?.correctCount !== undefined && participant.correctCount !== null) {
return `答对${participant.correctCount}`;
}
if (shareInfo && (shareInfo.participantCount ?? 0) <= 0) {
return '答对0道';
}
if (shareInfo) {
return `${shareInfo.participantCount ?? 0}人参与`;
}
return '暂无成绩';
}
private _formatTimeText(
participant: ShareParticipantRankSummary | null,
shareInfo: CreatedShareItem | ShareDetailData | null = null,
): string {
if (participant?.totalTimeSpent === undefined || participant.totalTimeSpent === null) {
if (shareInfo && (shareInfo.participantCount ?? 0) <= 0) {
return '用时0秒';
}
return '暂无用时';
}
const totalSeconds = Math.max(0, Math.round(participant.totalTimeSpent));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `用时${minutes}:${seconds.toString().padStart(2, '0')}`;
}
private _getParticipantName(participant: ShareParticipantRankSummary | null): string {
return participant?.nickname || participant?.nickName || '';
}
private _getParticipantDisplayName(
participant: ShareParticipantRankSummary | null,
emptyParticipantFallback: string,
): string {
if (!participant) {
return emptyParticipantFallback;
}
return this._getParticipantName(participant) || '微信用户';
}
private _loadAvatar(url: string, sprite: Sprite | null, version: number): void {
if (!sprite) {
return;
}
if (!url) {
return;
}
assetManager.loadRemote<ImageAsset>(url, (err, imageAsset) => {
if (err || !imageAsset || version !== this._renderVersion || !sprite.node.isValid) {
if (err) {
console.error('[PagePKDetail] 加载头像失败:', url, err);
}
return;
}
const texture = new Texture2D();
texture.image = imageAsset;
const spriteFrame = new SpriteFrame();
spriteFrame.texture = texture;
sprite.spriteFrame = spriteFrame;
});
}
private _findAvatarSprite(root: Node): Sprite | null {
const avatarNode = this._findChild(root, 'Avatar');
return avatarNode?.getComponent(Sprite) ?? null;
}
private _clearRankItems(): void {
for (const item of this._rankItemNodes) {
if (item.isValid) {
item.removeFromParent();
item.destroy();
}
}
this._rankItemNodes = [];
this._hideRankItemTemplate();
}
private _hideRankItemTemplate(): void {
if (this._rankListItemTemplate?.isValid) {
this._rankListItemTemplate.active = false;
}
}
private _setLabel(label: Label | null, text: string): void {
if (label) {
label.string = text;
}
}
private _findLabelIn(root: Node, nodeName: string): Label | null {
return this._findChild(root, nodeName)?.getComponent(Label) ?? null;
}
private _findChild(root: Node, nodeName: string): Node | null {
if (root.name === nodeName) {
return root;
}
for (const child of root.children) {
const found = this._findChild(child, nodeName);
if (found) {
return found;
}
}
return null;
}
}
```
### 11.2 圆角矩形遮罩 (assets/scripts/utils/RoundedRectMask.ts)
```typescript
import { _decorator, Component, UITransform, Graphics, Color, Mask } from 'cc';
const { ccclass, property } = _decorator;
/**
* 圆角矩形遮罩组件
* 使用 Graphics + Mask 绘制圆角矩形作为遮罩
*
* 使用方法:
* 1. 将此组件添加到需要圆角的节点上
* 2. 设置圆角半径
* 3. 该节点及其子节点会被裁剪为圆角矩形
*/
@ccclass('RoundedRectMask')
export class RoundedRectMask extends Component {
@property({
tooltip: '圆角半径(像素)'
})
radius: number = 20;
private _graphics: Graphics | null = null;
private _mask: Mask | null = null;
private _uiTransform: UITransform | null = null;
onLoad() {
this._uiTransform = this.getComponent(UITransform);
this.setupComponents();
}
/**
* 设置组件
*/
private setupComponents() {
// 获取或添加 Graphics 组件
this._graphics = this.getComponent(Graphics);
if (!this._graphics) {
this._graphics = this.addComponent(Graphics);
}
// 获取或添加 Mask 组件
this._mask = this.getComponent(Mask);
if (!this._mask) {
this._mask = this.addComponent(Mask);
}
// 设置 Mask 使用 Graphics 类型
this._mask.type = Mask.Type.GRAPHICS_STENCIL;
this.drawRoundedRect();
}
/**
* 绘制圆角矩形
*/
private drawRoundedRect() {
if (!this._graphics || !this._uiTransform) return;
const width = this._uiTransform.width;
const height = this._uiTransform.height;
const r = Math.min(this.radius, Math.min(width, height) / 2);
// 清除之前的绘制
this._graphics.clear();
// 设置填充颜色
this._graphics.fillColor = new Color(255, 255, 255, 255);
// 绘制圆角矩形路径
const halfW = width / 2;
const halfH = height / 2;
// 使用 lineTo 和 arc 绘制圆角矩形
// 从左下角开始,逆时针绘制
this._graphics.moveTo(-halfW + r, -halfH);
// 下边
this._graphics.lineTo(halfW - r, -halfH);
// 右下角圆角 (90度弧从 -90度 到 0度)
this._graphics.arc(halfW - r, -halfH + r, r, -Math.PI / 2, 0, false);
// 右边
this._graphics.lineTo(halfW, halfH - r);
// 右上角圆角 (90度弧从 0度 到 90度)
this._graphics.arc(halfW - r, halfH - r, r, 0, Math.PI / 2, false);
// 上边
this._graphics.lineTo(-halfW + r, halfH);
// 左上角圆角 (90度弧从 90度 到 180度)
this._graphics.arc(-halfW + r, halfH - r, r, Math.PI / 2, Math.PI, false);
// 左边
this._graphics.lineTo(-halfW, -halfH + r);
// 左下角圆角 (90度弧从 180度 到 270度)
this._graphics.arc(-halfW + r, -halfH + r, r, Math.PI, Math.PI * 1.5, false);
// 填充
this._graphics.fill();
}
/**
* 设置圆角半径
*/
setRadius(radius: number) {
this.radius = radius;
this.drawRoundedRect();
}
}
```
### 11.3 圆角材质工具 (assets/scripts/utils/roundedMaterial.utils.ts)
```typescript
import { EffectAsset, Material, Sprite, Vec4 } from 'cc';
/**
* 圆角 Sprite 材质工具。
*
* 基于 rounded-sprite.effect 的 SDF alpha 裁剪实现,直接作用于 Sprite 的自定义材质。
* 每个 Sprite 都会获得独立 Material避免 roundedParams / uvRect 互相覆盖。
*/
let cachedTemplate: Material | null = null;
let cachedEffectRef: EffectAsset | null = null;
const getOrCreateTemplate = (effectAsset: EffectAsset): Material => {
if (cachedTemplate && cachedEffectRef === effectAsset) {
return cachedTemplate;
}
const template = new Material();
template.initialize({
effectAsset,
defines: { USE_TEXTURE: true },
});
cachedTemplate = template;
cachedEffectRef = effectAsset;
return template;
};
const extractUvRect = (sprite: Sprite): Vec4 => {
const spriteFrame = sprite.spriteFrame;
if (!spriteFrame) {
return new Vec4(0, 0, 1, 1);
}
const uv = spriteFrame.uv;
if (!uv || uv.length < 8) {
return new Vec4(0, 0, 1, 1);
}
const u0 = uv[0];
const v0 = uv[1];
const u1 = uv[2];
const v1 = uv[3];
const u2 = uv[4];
const v2 = uv[5];
const u3 = uv[6];
const v3 = uv[7];
const minU = Math.min(u0, u1, u2, u3);
const maxU = Math.max(u0, u1, u2, u3);
const minV = Math.min(v0, v1, v2, v3);
const maxV = Math.max(v0, v1, v2, v3);
const rangeU = maxU - minU;
const rangeV = maxV - minV;
if (rangeU <= 0 || rangeV <= 0) {
return new Vec4(0, 0, 1, 1);
}
return new Vec4(minU, minV, rangeU, rangeV);
};
export const applyRoundedCorner = (
sprite: Sprite,
effectAsset: EffectAsset,
width: number,
height: number,
cornerRadius = 0.1,
grayscale = false,
): void => {
if (!sprite || !sprite.isValid) {
console.warn('[roundedMaterial] Invalid sprite, skipping');
return;
}
if (!effectAsset) {
console.warn('[roundedMaterial] EffectAsset is null, skipping');
return;
}
const template = getOrCreateTemplate(effectAsset);
const materialInstance = new Material();
const defines: Record<string, boolean> = { USE_TEXTURE: true };
if (grayscale) {
defines.IS_GRAY = true;
}
materialInstance.initialize({
effectAsset,
defines,
});
const params = new Vec4(cornerRadius, 0, width, height);
materialInstance.setProperty('roundedParams', params);
materialInstance.setProperty('uvRect', extractUvRect(sprite));
sprite.customMaterial = materialInstance;
};
```

View File

@@ -0,0 +1,673 @@
# 梗中作乐 V1.0 - 源代码文档(第 12 部分)
软件名称:梗中作乐
版本号V1.0
---
## 第 12 部分:微信 SDK、音频与成就配置
本部分包含微信小游戏 SDK 封装、音频播放管理器、成就称号配置常量。
### 12.1 微信 SDK 封装 (assets/scripts/utils/WxSDK.ts)
```typescript
import { sys } from 'cc';
/**
* 微信分享配置
*/
export interface WxShareConfig {
/** 分享标题 */
title: string;
/** 分享图片 URL 或本地路径 */
imageUrl?: string;
/** 查询字符串,从这条转发消息进入后,可通过 wx.getLaunchOptionsSync 或 wx.onShow 获取 */
query?: string;
}
/**
* 微信朋友圈分享配置
*/
export interface WxShareTimelineConfig {
/** 分享标题 */
title: string;
/** 分享图片 URL 或本地路径 */
imageUrl?: string;
/** 查询字符串 */
query?: string;
}
export interface WxPrivacySettingResult {
/** true 表示需要用户确认隐私授权 */
needAuthorization: boolean;
}
/**
* 微信小游戏 SDK 工具类
* 封装微信平台相关 API非微信环境下静默降级
*/
export class WxSDK {
/** 隐私授权请求进行中时复用同一个 Promise避免启动链路重复弹窗 */
private static _privacyAuthorizePromise: Promise<boolean> | null = null;
/**
* 是否处于微信小游戏环境
*/
static isWechat(): boolean {
return sys.platform === sys.Platform.WECHAT_GAME;
}
/**
* 获取 wx 全局对象(仅微信环境下可用)
*/
static getWx(): any {
if (!WxSDK.isWechat()) return null;
return typeof wx !== 'undefined' ? wx : null;
}
// ==================== 登录相关 ====================
/**
* 微信登录,获取临时 code
* @returns Promise<string> 登录 code
*/
static login(): Promise<string> {
return new Promise((resolve, reject) => {
const wxApi = WxSDK.getWx();
if (!wxApi) {
reject(new Error('非微信环境,无法调用 wx.login'));
return;
}
wxApi.login({
success: (res: any) => {
if (res.code) {
console.log('[WxSDK] wx.login 成功,获取到 code');
resolve(res.code);
} else {
console.error('[WxSDK] wx.login 失败:', res.errMsg);
reject(new Error(res.errMsg || 'wx.login 失败'));
}
},
fail: (err: any) => {
console.error('[WxSDK] wx.login 调用失败:', err);
reject(new Error(err.errMsg || 'wx.login 调用失败'));
}
});
});
}
// ==================== 隐私授权相关 ====================
/**
* 检查用户是否需要隐私授权。
* 低版本或非微信环境没有隐私拦截能力,按已授权处理。
*/
static checkPrivacySetting(): Promise<WxPrivacySettingResult> {
return new Promise((resolve) => {
const wxApi = WxSDK.getWx();
if (!wxApi) {
resolve({ needAuthorization: false });
return;
}
if (typeof wxApi.getPrivacySetting !== 'function') {
console.warn('[WxSDK] 当前微信版本不支持 getPrivacySetting');
resolve({ needAuthorization: false });
return;
}
wxApi.getPrivacySetting({
success: (res: any) => {
const needAuthorization = !!res?.needAuthorization;
console.log('[WxSDK] 隐私授权检查结果:', res);
resolve({ needAuthorization });
},
fail: (err: any) => {
console.warn('[WxSDK] 隐私授权检查失败:', err);
resolve({ needAuthorization: true });
}
});
});
}
/**
* 主动触发微信小游戏隐私授权流程。
* 已授权用户会直接 success低版本或非微信环境直接视为通过。
*/
static requirePrivacyAuthorize(): Promise<boolean> {
const wxApi = WxSDK.getWx();
if (!wxApi) {
console.warn('[WxSDK] 非微信环境,跳过隐私授权');
return Promise.resolve(true);
}
if (typeof wxApi.requirePrivacyAuthorize !== 'function') {
console.warn('[WxSDK] 当前微信版本不支持 requirePrivacyAuthorize');
return Promise.resolve(true);
}
if (WxSDK._privacyAuthorizePromise) {
return WxSDK._privacyAuthorizePromise;
}
WxSDK._privacyAuthorizePromise = new Promise((resolve) => {
wxApi.requirePrivacyAuthorize({
success: () => {
console.log('[WxSDK] 用户已授权隐私');
resolve(true);
},
fail: (err: any) => {
console.warn('[WxSDK] 用户拒绝或授权失败:', err);
resolve(false);
},
complete: () => {
WxSDK._privacyAuthorizePromise = null;
}
});
});
return WxSDK._privacyAuthorizePromise;
}
/**
* 启动阶段使用的隐私授权入口:主动调用 requirePrivacyAuthorize
* 让微信在合适时机触发官方隐私弹窗逻辑。
*/
static ensurePrivacyAuthorized(): Promise<boolean> {
if (!WxSDK.isWechat()) {
return Promise.resolve(true);
}
return WxSDK.requirePrivacyAuthorize();
}
// ==================== 分享相关 ====================
/**
* 开启转发/分享菜单
* 调用后用户可通过右上角菜单进行转发
* @param withShareTicket 是否带 shareTicket用于获取群信息
*/
static showShareMenu(withShareTicket: boolean = true): void {
const wxApi = WxSDK.getWx();
if (!wxApi) return;
wxApi.showShareMenu({
withShareTicket,
menus: ['shareAppMessage', 'shareTimeline'],
success: () => {
console.log('[WxSDK] showShareMenu 成功');
},
fail: (err: any) => {
console.warn('[WxSDK] showShareMenu 失败', err);
}
});
}
/**
* 设置被动分享(右上角菜单 "转发给朋友")的内容
* 需要在页面加载后尽早调用,只需调用一次
* @param config 分享配置
*/
static onShareAppMessage(config: WxShareConfig): void {
const wxApi = WxSDK.getWx();
if (!wxApi) return;
wxApi.onShareAppMessage(() => ({
title: config.title,
imageUrl: config.imageUrl ?? '',
query: config.query ?? ''
}));
console.log('[WxSDK] onShareAppMessage 已设置');
}
/**
* 设置分享到朋友圈的内容
* @param config 朋友圈分享配置
*/
static onShareTimeline(config: WxShareTimelineConfig): void {
const wxApi = WxSDK.getWx();
if (!wxApi) return;
if (typeof wxApi.onShareTimeline !== 'function') {
console.warn('[WxSDK] 当前微信版本不支持 onShareTimeline');
return;
}
wxApi.onShareTimeline(() => ({
title: config.title,
imageUrl: config.imageUrl ?? '',
query: config.query ?? ''
}));
console.log('[WxSDK] onShareTimeline 已设置');
}
/**
* 主动触发转发(拉起分享面板)
* @param config 分享配置
*/
static shareAppMessage(config: WxShareConfig): void {
const wxApi = WxSDK.getWx();
if (!wxApi) return;
wxApi.shareAppMessage({
title: config.title,
imageUrl: config.imageUrl ?? '',
query: config.query ?? ''
});
console.log('[WxSDK] shareAppMessage 已触发');
}
/**
* 一键初始化分享功能
* 开启分享菜单 + 设置被动分享内容 + 设置朋友圈分享内容
* @param config 分享配置
*/
static initShare(config: WxShareConfig): void {
if (!WxSDK.isWechat()) {
console.log('[WxSDK] 非微信环境,跳过分享初始化');
return;
}
WxSDK.showShareMenu();
WxSDK.onShareAppMessage(config);
WxSDK.onShareTimeline({
title: config.title,
imageUrl: config.imageUrl,
query: config.query
});
console.log('[WxSDK] 分享功能初始化完成');
}
// ==================== 震动相关 ====================
/**
* 触发短震动15ms
* 用于轻量级反馈,如按钮点击
*/
static vibrateShort(): void {
const wxApi = WxSDK.getWx();
if (!wxApi) return;
wxApi.vibrateShort({
type: 'medium',
success: () => {
console.log('[WxSDK] 短震动成功');
},
fail: (err: any) => {
console.warn('[WxSDK] 短震动失败', err);
}
});
}
/**
* 触发长震动400ms
* 用于重要反馈,如错误提示
*/
static vibrateLong(): void {
const wxApi = WxSDK.getWx();
if (!wxApi) return;
wxApi.vibrateLong({
success: () => {
console.log('[WxSDK] 长震动成功');
},
fail: (err: any) => {
console.warn('[WxSDK] 长震动失败', err);
}
});
}
// ==================== 激励视频广告 ====================
/** 激励视频广告实例(复用) */
private static _rewardedVideoAd: any = null;
/**
* 展示激励视频广告
* 用户看完广告后返回 true中途退出或失败返回 false
* 非微信环境直接返回 true开发模式直接通过
* @param adUnitId 广告单元 ID默认使用项目配置的 ID
* @returns Promise<boolean> 是否看完广告
*/
static showRewardedVideoAd(adUnitId: string = ''): Promise<boolean> {
return new Promise((resolve) => {
const wxApi = WxSDK.getWx();
if (!wxApi) {
console.log('[WxSDK] 非微信环境,跳过激励视频广告');
resolve(true);
return;
}
if (typeof wxApi.createRewardedVideoAd !== 'function') {
console.warn('[WxSDK] 当前微信版本不支持激励视频广告');
resolve(true);
return;
}
try {
// 复用或创建广告实例
if (!WxSDK._rewardedVideoAd) {
WxSDK._rewardedVideoAd = wxApi.createRewardedVideoAd({
adUnitId: adUnitId,
});
}
const ad = WxSDK._rewardedVideoAd;
// 定义关闭回调(一次性)
const onClose = (res: any) => {
ad.offClose(onClose);
if (res && res.isEnded) {
console.log('[WxSDK] 激励视频广告观看完成');
resolve(true);
} else {
console.log('[WxSDK] 激励视频广告中途退出');
resolve(false);
}
};
// 定义错误回调(一次性)
const onError = (err: any) => {
ad.offError(onError);
ad.offClose(onClose);
console.error('[WxSDK] 激励视频广告错误:', err);
resolve(false);
};
ad.onClose(onClose);
ad.onError(onError);
// 先尝试 show如果广告未加载则先 load
ad.show().catch(() => {
ad.load().then(() => ad.show()).catch((loadErr: any) => {
ad.offClose(onClose);
ad.offError(onError);
console.error('[WxSDK] 激励视频广告加载失败:', loadErr);
resolve(false);
});
});
} catch (err) {
console.error('[WxSDK] 激励视频广告异常:', err);
resolve(false);
}
});
}
// ==================== 启动参数 ====================
/**
* 从启动参数中获取分享码
* @returns 分享码,不存在则返回 null
*/
static getShareCodeFromLaunch(): string | null {
const wxApi = WxSDK.getWx();
if (!wxApi) return null;
try {
const options = wxApi.getLaunchOptionsSync();
const code = WxSDK.extractShareCodeFromQuery(options?.query);
if (code) {
console.log('[WxSDK] 检测到分享码:', code);
return code;
}
} catch (err) {
console.warn('[WxSDK] 获取启动参数失败:', err);
}
return null;
}
/**
* 从查询对象(来自 launch options 或 onShow 回调)中提取 shareCode
*/
static extractShareCodeFromQuery(query: Record<string, any> | null | undefined): string | null {
const code = query?.shareCode;
return typeof code === 'string' && code.length > 0 ? code : null;
}
// ==================== 前后台生命周期 ====================
/**
* 监听小游戏切到前台事件。
* 同一个回调可重复注册多次:内部用 wx.onShow请确保业务层做幂等处理或在卸载时调用 offAppShow。
* @param callback 切前台时触发,包含本次显示对应的 query / scene 等参数
*/
static onAppShow(callback: (res: { query?: Record<string, any>; scene?: number; path?: string } | undefined) => void): void {
const wxApi = WxSDK.getWx();
if (!wxApi) return;
if (typeof wxApi.onShow !== 'function') {
console.warn('[WxSDK] 当前微信版本不支持 onShow');
return;
}
wxApi.onShow(callback);
}
/**
* 取消监听小游戏切到前台事件
*/
static offAppShow(callback: (res: any) => void): void {
const wxApi = WxSDK.getWx();
if (!wxApi) return;
if (typeof wxApi.offShow === 'function') {
wxApi.offShow(callback);
}
}
/**
* 监听小游戏切到后台事件
*/
static onAppHide(callback: () => void): void {
const wxApi = WxSDK.getWx();
if (!wxApi) return;
if (typeof wxApi.onHide !== 'function') {
console.warn('[WxSDK] 当前微信版本不支持 onHide');
return;
}
wxApi.onHide(callback);
}
/**
* 取消监听小游戏切到后台事件
*/
static offAppHide(callback: () => void): void {
const wxApi = WxSDK.getWx();
if (!wxApi) return;
if (typeof wxApi.offHide === 'function') {
wxApi.offHide(callback);
}
}
}
// ==================== 隐私授权相关 ====================
/**
* 检查用户是否已授权隐私
* @returns Promise<{ needAuthorization: boolean }> needAuthorization 为 false 表示已授权
*/
export async function checkPrivacySetting(): Promise<{ needAuthorization: boolean }> {
return WxSDK.checkPrivacySetting();
}
/**
* 引导用户进行隐私授权
* 调用后会弹出微信隐私授权弹窗
* @returns Promise<void>
*/
export async function requirePrivacyAuthorize(): Promise<void> {
const authorized = await WxSDK.requirePrivacyAuthorize();
if (!authorized) {
throw new Error('隐私授权失败');
}
}
// ==================== 用户信息相关 ====================
/**
* 用户信息(头像、昵称)
*/
export interface WxUserInfo {
avatarUrl: string;
nickName: string;
country?: string;
province?: string;
city?: string;
gender?: number;
}
/**
* 获取用户头像和昵称(需要用户主动授权)
* @returns Promise<WxUserInfo>
*/
export async function getUserProfile(): Promise<WxUserInfo> {
return new Promise((resolve, reject) => {
const wxApi = WxSDK.getWx();
if (!wxApi) {
// 非微信环境,返回 mock 数据
console.warn('[WxSDK] 非微信环境,返回 mock 用户信息');
resolve({
avatarUrl: '',
nickName: '微信用户'
});
return;
}
if (typeof wxApi.getUserProfile !== 'function') {
console.warn('[WxSDK] 当前微信版本不支持 getUserProfile');
reject(new Error('当前微信版本不支持 getUserProfile'));
return;
}
wxApi.getUserProfile({
desc: '用于完善用户资料',
success: (res: any) => {
if (res.userInfo) {
console.log('[WxSDK] 获取用户信息成功');
resolve({
avatarUrl: res.userInfo.avatarUrl,
nickName: res.userInfo.nickName,
country: res.userInfo.country,
province: res.userInfo.province,
city: res.userInfo.city,
gender: res.userInfo.gender
});
} else {
console.error('[WxSDK] 获取用户信息失败: 无 userInfo');
reject(new Error('获取用户信息失败'));
}
},
fail: (err: any) => {
console.error('[WxSDK] 获取用户信息失败:', err);
reject(new Error(err.errMsg || '获取用户信息失败'));
}
});
});
}
```
### 12.2 音频管理器 (assets/scripts/utils/AudioManager.ts)
```typescript
import { AudioClip, AudioSource, Node } from 'cc';
/**
* 音效管理器
* 统一管理全局按钮点击等通用音效,避免页面各自维护 AudioSource。
*/
export class AudioManager {
private static _instance: AudioManager | null = null;
private _clickAudio: AudioClip | null = null;
private _hostNode: Node | null = null;
private _audioSource: AudioSource | null = null;
static get instance(): AudioManager {
if (!this._instance) {
this._instance = new AudioManager();
}
return this._instance;
}
private constructor() {}
init(clickAudio: AudioClip | null, hostNode: Node | null): void {
this._clickAudio = clickAudio;
this._hostNode = hostNode;
this._audioSource = null;
}
playButtonClick(): void {
this._playOneShot(this._clickAudio);
}
private _playOneShot(clip: AudioClip | null): void {
if (!clip) {
return;
}
const audioSource = this._ensureAudioSource();
audioSource?.playOneShot(clip);
}
private _ensureAudioSource(): AudioSource | null {
if (this._audioSource?.isValid) {
return this._audioSource;
}
if (!this._hostNode?.isValid) {
console.warn('[AudioManager] 未初始化宿主节点,无法播放音效');
return null;
}
this._audioSource = this._hostNode.getComponent(AudioSource) ?? this._hostNode.addComponent(AudioSource);
return this._audioSource;
}
}
```
### 12.3 成就称号配置 (assets/scripts/config/AchievementTitleConfig.ts)
```typescript
export interface AchievementTitleConfigItem {
readonly seriesName: string;
readonly levelName: string;
readonly clearsToNext: number;
}
const createSeries = (seriesName: string, levelCount: number, clearsToNext: number): AchievementTitleConfigItem[] => {
return Array.from({ length: levelCount }, (_, index) => ({
seriesName,
levelName: `${seriesName}${index + 1}`,
clearsToNext
}));
};
export const ACHIEVEMENT_TITLE_CONFIG: readonly AchievementTitleConfigItem[] = [
...createSeries('冷场小白', 2, 3),
...createSeries('尬笑学生', 2, 3),
...createSeries('浅梗游民', 3, 4),
...createSeries('热梗新秀', 6, 5),
...createSeries('笑点刺客', 6, 5),
...createSeries('爆梗高手', 8, 6),
...createSeries('幽默大师', 10, 8),
...createSeries('爆笑领主', 10, 8),
...createSeries('梗王之王', 20, 8)
];
export const INFINITE_ACHIEVEMENT_TITLE = {
seriesName: '幽默始祖',
levelNamePrefix: '幽默始祖',
clearsToNext: 8
} as const;
```

View File

@@ -0,0 +1,756 @@
# 梗中作乐 V1.0 - 源代码文档(第 13 部分)
软件名称:梗中作乐
版本号V1.0
---
## 第 13 部分:成就动画与通用弹窗
本部分包含通用确认弹窗、成就称号动画组件、成就称号管理器。
### 13.1 通用 Modal 弹窗 (assets/prefabs/CommonModal.ts)
```typescript
import { _decorator, error, instantiate, Label, Node, Prefab, Size, UITransform, Vec3, view } from 'cc';
import { BaseModal } from 'db://assets/scripts/core/BaseModal';
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
const { ccclass, property } = _decorator;
export type CommonModalAction = () => void | boolean;
export interface CommonModalCallbacks {
/** 点击关闭按钮回调,返回 false 可阻止默认关闭 */
onClose?: CommonModalAction;
/** 点击确认按钮回调,返回 false 可阻止默认关闭 */
onConfirm?: CommonModalAction;
/** 点击取消按钮回调,返回 false 可阻止默认关闭 */
onCancel?: CommonModalAction;
}
export interface CommonModalParams extends CommonModalCallbacks {
/** 弹窗标题 */
title?: string;
/** 弹窗内容 */
content?: string;
/** 确认按钮文案 */
buttonConfirm?: string;
/** 取消按钮文案,传入后展示双按钮区域 */
buttonCancel?: string;
/** 点击关闭按钮后是否自动关闭弹窗,默认 true */
closeOnClose?: boolean;
/** 点击确认按钮后是否自动关闭弹窗,默认 true */
closeOnConfirm?: boolean;
/** 点击取消按钮后是否自动关闭弹窗,默认 true */
closeOnCancel?: boolean;
/** 关闭时是否销毁节点,默认 true */
destroyOnClose?: boolean;
/** 弹窗层级,默认 CommonModal.MODAL_Z_INDEX */
zIndex?: number;
}
@ccclass('CommonModal')
export class CommonModal extends BaseModal {
public static readonly MODAL_Z_INDEX = 999;
private static readonly DEFAULT_TITLE = '温馨提示';
private static readonly DEFAULT_CONTENT = '';
private static readonly DEFAULT_BUTTON_CONFIRM = '确定';
private static readonly DEFAULT_BUTTON_CANCEL = '取消';
@property({ type: Label, tooltip: '标题文本' })
titleLabel: Label | null = null;
@property({ type: Label, tooltip: '内容文本' })
contentLabel: Label | null = null;
@property({ type: Label, tooltip: '确认按钮文本' })
buttonConfirmLabel: Label | null = null;
@property({ type: Label, tooltip: '双按钮确认文本' })
actionConfirmLabel: Label | null = null;
@property({ type: Label, tooltip: '取消按钮文本' })
buttonCancelLabel: Label | null = null;
@property({ type: Node, tooltip: '关闭按钮节点' })
closeBtn: Node | null = null;
@property({ type: Node, tooltip: '单确认按钮节点' })
buttonConfirm: Node | null = null;
@property({ type: Node, tooltip: '双按钮容器节点' })
actionDouble: Node | null = null;
@property({ type: Node, tooltip: '双按钮确认节点' })
actionConfirm: Node | null = null;
@property({ type: Node, tooltip: '双按钮取消节点' })
actionCancel: Node | null = null;
private _title: string = CommonModal.DEFAULT_TITLE;
private _content: string = CommonModal.DEFAULT_CONTENT;
private _buttonConfirmText: string = CommonModal.DEFAULT_BUTTON_CONFIRM;
private _buttonCancelText: string = CommonModal.DEFAULT_BUTTON_CANCEL;
private _callbacks: CommonModalCallbacks = {};
private _closeOnClose: boolean = true;
private _closeOnConfirm: boolean = true;
private _closeOnCancel: boolean = true;
private _destroyOnClose: boolean = true;
private _useDoubleActions: boolean = false;
private _screenSize: Size | null = null;
private _loaded: boolean = false;
/**
* 直接弹出通用弹窗。
*
* 使用示例:
* CommonModal.show(this.commonModalPrefab, {
* title: '提示',
* content: '是否继续?',
* buttonConfirm: '继续',
* onConfirm: () => this.startGame()
* });
*/
static show(prefab: Prefab, params: CommonModalParams = {}, parent?: Node): CommonModal | null {
const container = parent ?? ViewManager.instance.getContainer();
if (!container) {
error('[CommonModal] 缺少弹窗挂载节点');
return null;
}
const modalNode = instantiate(prefab);
const modal = modalNode.getComponent(CommonModal);
if (!modal) {
error('[CommonModal] 预制体缺少 CommonModal 组件');
modalNode.destroy();
return null;
}
modal.setParams(params);
modalNode.setPosition(Vec3.ZERO);
modalNode.setSiblingIndex(params.zIndex ?? CommonModal.MODAL_Z_INDEX);
container.addChild(modalNode);
modal.onViewLoad();
modal._doShow();
return modal;
}
protected start(): void {
this.onViewLoad();
if (!this.isShowing) {
this._doShow();
}
}
setParams(params: CommonModalParams = {}): void {
super.setParams(params);
this.setConfig(params);
}
setConfig(params: CommonModalParams = {}): void {
this._title = params.title ?? CommonModal.DEFAULT_TITLE;
this._content = params.content ?? CommonModal.DEFAULT_CONTENT;
this._buttonConfirmText = params.buttonConfirm ?? CommonModal.DEFAULT_BUTTON_CONFIRM;
this._buttonCancelText = params.buttonCancel ?? CommonModal.DEFAULT_BUTTON_CANCEL;
this._callbacks = {
onClose: params.onClose,
onConfirm: params.onConfirm,
onCancel: params.onCancel
};
this._closeOnClose = params.closeOnClose ?? true;
this._closeOnConfirm = params.closeOnConfirm ?? true;
this._closeOnCancel = params.closeOnCancel ?? true;
this._destroyOnClose = params.destroyOnClose ?? true;
this._useDoubleActions = this._shouldUseDoubleActions(params);
this._applyContent();
}
setTitle(title: string): void {
this._title = title;
this._applyContent();
}
setContent(content: string): void {
this._content = content;
this._applyContent();
}
setButtonConfirm(buttonConfirm: string): void {
this._buttonConfirmText = buttonConfirm;
this._applyContent();
}
setButtonCancel(buttonCancel: string): void {
this._buttonCancelText = buttonCancel;
this._useDoubleActions = true;
this._applyContent();
}
setCallbacks(callbacks: CommonModalCallbacks): void {
this._callbacks = callbacks;
}
close(destroy: boolean = this._destroyOnClose): void {
if (!this.node?.isValid) {
return;
}
if (this.isShowing) {
this._doHide();
}
if (destroy) {
this.node.destroy();
} else {
this.node.active = false;
}
}
onViewLoad(): void {
if (this._loaded) {
return;
}
this._loaded = true;
this._resolveNodes();
this._bindButtonEvents();
this._applyContent();
this._updateWidget();
}
onViewShow(): void {
this._resolveNodes();
this._applyContent();
this._updateWidget();
super.onViewShow();
}
onViewDestroy(): void {
this._unbindButtonEvents();
this._callbacks = {};
}
private _resolveNodes(): void {
const panelNode = this._findNode('dialogPanel');
this.backdropNode = this.backdropNode ?? this._findNode('BgMask');
this.animationNodes = this.animationNodes.length > 0 ? this.animationNodes : (panelNode ? [panelNode] : []);
this.closeBtn = this.closeBtn ?? this._findNode('dialogPanel/closeBtn');
this.buttonConfirm = this.buttonConfirm ?? this._findNode('dialogPanel/ButtonConfirm');
this.actionDouble = this.actionDouble ?? this._findNode('dialogPanel/ActionDouble');
this.actionConfirm = this.actionConfirm ?? this._findNode('dialogPanel/ActionDouble/ButtonConfirm');
this.actionCancel = this.actionCancel ?? this._findNode('dialogPanel/ActionDouble/ButtonCancel');
this.titleLabel = this.titleLabel ?? this._findNode('dialogPanel/Title')?.getComponent(Label) ?? null;
this.contentLabel = this.contentLabel ?? this._findNode('dialogPanel/Content')?.getComponent(Label) ?? null;
this.buttonConfirmLabel = this.buttonConfirmLabel
?? this._findNode('dialogPanel/ButtonConfirm/Label')?.getComponent(Label)
?? null;
this.actionConfirmLabel = this.actionConfirmLabel
?? this._findNode('dialogPanel/ActionDouble/ButtonConfirm/Label')?.getComponent(Label)
?? null;
this.buttonCancelLabel = this.buttonCancelLabel
?? this._findNode('dialogPanel/ActionDouble/ButtonCancel/Label')?.getComponent(Label)
?? null;
}
private _applyContent(): void {
this._resolveNodes();
this._applyActionMode();
if (this.titleLabel) {
this.titleLabel.string = this._title;
}
if (this.contentLabel) {
this.contentLabel.string = this._content;
}
if (this.buttonConfirmLabel) {
this.buttonConfirmLabel.string = this._buttonConfirmText;
}
if (this.actionConfirmLabel) {
this.actionConfirmLabel.string = this._buttonConfirmText;
}
if (this.buttonCancelLabel) {
this.buttonCancelLabel.string = this._buttonCancelText;
}
}
private _updateWidget(): void {
if (!this._screenSize) {
this._screenSize = view.getVisibleSize();
}
const uiTransform = this.node.getComponent(UITransform);
if (uiTransform) {
uiTransform.setContentSize(this._screenSize.width, this._screenSize.height);
}
}
private _bindButtonEvents(): void {
this._unbindButtonEvents();
if (this.closeBtn) {
this.closeBtn.on(Node.EventType.TOUCH_END, this._onCloseClick, this);
}
if (this.buttonConfirm) {
this.buttonConfirm.on(Node.EventType.TOUCH_END, this._onConfirmClick, this);
}
if (this.actionConfirm) {
this.actionConfirm.on(Node.EventType.TOUCH_END, this._onConfirmClick, this);
}
if (this.actionCancel) {
this.actionCancel.on(Node.EventType.TOUCH_END, this._onCancelClick, this);
}
}
private _unbindButtonEvents(): void {
if (this.closeBtn?.isValid) {
this.closeBtn.off(Node.EventType.TOUCH_END, this._onCloseClick, this);
}
if (this.buttonConfirm?.isValid) {
this.buttonConfirm.off(Node.EventType.TOUCH_END, this._onConfirmClick, this);
}
if (this.actionConfirm?.isValid) {
this.actionConfirm.off(Node.EventType.TOUCH_END, this._onConfirmClick, this);
}
if (this.actionCancel?.isValid) {
this.actionCancel.off(Node.EventType.TOUCH_END, this._onCancelClick, this);
}
}
private _onCloseClick(): void {
const shouldContinue = this._callbacks.onClose?.();
if (shouldContinue !== false && this._closeOnClose) {
this.close();
}
}
private _onConfirmClick(): void {
const shouldContinue = this._callbacks.onConfirm?.();
if (shouldContinue !== false && this._closeOnConfirm) {
this.close();
}
}
private _onCancelClick(): void {
const shouldContinue = this._callbacks.onCancel?.();
if (shouldContinue !== false && this._closeOnCancel) {
this.close();
}
}
private _applyActionMode(): void {
if (this.buttonConfirm?.isValid) {
this.buttonConfirm.active = !this._useDoubleActions;
}
if (this.actionDouble?.isValid) {
this.actionDouble.active = this._useDoubleActions;
}
}
private _shouldUseDoubleActions(params: CommonModalParams): boolean {
return params.buttonCancel !== undefined;
}
private _findNode(path: string): Node | null {
const names = path.split('/').filter(Boolean);
let current: Node | null = this.node;
for (const name of names) {
current = current?.getChildByName(name) ?? null;
if (!current) {
return null;
}
}
return current;
}
}
```
### 13.2 成就称号动画 (assets/scripts/utils/AchievementTitleAnimator.ts)
```typescript
import { Label, Node, ProgressBar, tween, Tween } from 'cc';
/**
* 称号进度展示数据
* 字段对应 AchievementTitleManager.getTitleInfo 的产物,但所有字段都是可选的,
* 调用方可以只更新需要的部分(例如分享模式只想清空文字)。
*/
export interface TitleProgressData {
/** 当前称号文案如「冷场小白1级」 */
titleText?: string;
/** 进度提示文案如「还差3题解锁新成就等级」 */
progressText?: string;
/** 当前称号到下一称号的进度0-1 */
nextTitleProgress?: number;
}
/**
* 进度条 / 称号视图所需的节点引用集合
*/
export interface TitleAnimatorBindings {
/** 称号文案 Label如「冷场小白1级」可空 */
titleLabel?: Label | null;
/** 进度提示 Label如「还差3题解锁新成就等级」可空 */
progressLabel?: Label | null;
/** 进度条组件 */
progressBar?: ProgressBar | null;
/** 进度条上跟随移动的 anchor 节点(若其下有 Label 子节点会自动写百分比) */
progressAnchor?: Node | null;
}
/**
* 进度条动画起始前的等待时长(秒)。让弹窗 / 弹起动画稳定后再开播
*/
const PROGRESS_ANIM_START_DELAY = 0.4;
/** 单段进度条填充动画时长(秒) */
const PROGRESS_ANIM_SEGMENT_DURATION = 0.6;
/** 跨称号切换时的等级信息刷新停顿(秒),让玩家看清称号变更 */
const PROGRESS_ANIM_LEVELUP_PAUSE = 0.12;
/**
* 九宫格 Bar Left+Right border = 240px、totalLength = 925px。
* width < 240px 时圆角会畸变,因此 progress > 0 时强制最小值。
*/
const MIN_PROGRESS_RATIO = 240 / 925;
/** anchor 起点的视觉微调(与 PageHome 等其他页面保持一致) */
const PROGRESS_ANCHOR_VISUAL_OFFSET = -30;
/**
* 把称号文字、进度条、跟随气泡这三件事打包成一个可复用的动画/展示工具。
* 没有引擎组件依赖(不是 cc.Component可以被任何持有节点引用的对象 new 一个出来用。
*
* 起源:原本只在 PassModal 内部实现。PassNode 替换 PassModal 后,
* PageLevel 也需要同一套行为,所以抽出来共用。
*/
export class AchievementTitleAnimator {
private _bindings: TitleAnimatorBindings = {};
/** anchor 起点 X在 progressBar 父节点空间下),首次解析后缓存 */
private _progressAnchorStartX: number | null = null;
/** Tween 共享的目标对象,方便 stopAllByTarget */
private readonly _tweenTarget: { progress: number } = { progress: 0 };
/** 绑定 / 重新绑定节点引用。任何重新绑定都会清掉缓存的 anchor 起点 */
bind(bindings: TitleAnimatorBindings): void {
this._bindings = bindings;
this._progressAnchorStartX = null;
}
/** 直接展示终态,无动画 */
setTarget(data: TitleProgressData): void {
this.stop();
this._applyTitleText(data.titleText);
this._applyProgressText(data.progressText);
this._applyProgressValue(data.nextTitleProgress);
}
/**
* 从 prev → current 播过渡动画
* - prev 为空:直接展示 current 终态
* - 同称号:单段 tween
* - 跨称号:先填满旧称号,再切到新称号 + 进度从 0 涨到 current
*/
playTransition(prev: TitleProgressData | null | undefined, current: TitleProgressData): void {
if (!prev) {
this.setTarget(current);
return;
}
const startProgress = prev.nextTitleProgress;
const endProgress = current.nextTitleProgress;
if (startProgress === undefined || endProgress === undefined) {
this.setTarget(current);
return;
}
const isSameTitle = prev.titleText === undefined
|| current.titleText === undefined
|| prev.titleText === current.titleText;
// 同称号且起止相同,没必要播动画
if (isSameTitle && Math.abs(startProgress - endProgress) < 1e-4) {
this.setTarget(current);
return;
}
this.stop();
if (isSameTitle) {
// 先展示文字 + 起点进度,再 tween 到终点
this._applyTitleText(current.titleText);
this._applyProgressText(current.progressText);
this._applyProgressValue(startProgress);
this._runSegmentTween(startProgress, endProgress, PROGRESS_ANIM_START_DELAY);
return;
}
// 跨称号:先展示旧称号 + 起点进度
this._applyTitleText(prev.titleText);
this._applyProgressText(prev.progressText);
this._applyProgressValue(startProgress);
const target = this._tweenTarget;
target.progress = this._clamp(startProgress);
const onUpdate = () => this._applyAnimatedProgress(target.progress);
tween(target)
.delay(PROGRESS_ANIM_START_DELAY)
.to(PROGRESS_ANIM_SEGMENT_DURATION, { progress: 1 }, { easing: 'sineOut', onUpdate })
.call(() => {
this._applyTitleText(current.titleText);
this._applyProgressText(current.progressText);
target.progress = 0;
this._applyAnimatedProgress(0);
})
.delay(PROGRESS_ANIM_LEVELUP_PAUSE)
.to(
PROGRESS_ANIM_SEGMENT_DURATION,
{ progress: this._clamp(endProgress) },
{ easing: 'sineOut', onUpdate },
)
.start();
}
/** 停止当前动画(不影响已展示的进度值) */
stop(): void {
Tween.stopAllByTarget(this._tweenTarget);
}
private _runSegmentTween(from: number, to: number, delay: number): void {
const target = this._tweenTarget;
target.progress = this._clamp(from);
this._applyAnimatedProgress(from);
const chain = tween(target);
if (delay > 0) {
chain.delay(delay);
}
chain.to(
PROGRESS_ANIM_SEGMENT_DURATION,
{ progress: this._clamp(to) },
{
easing: 'sineOut',
onUpdate: () => this._applyAnimatedProgress(target.progress),
},
).start();
}
private _applyTitleText(text: string | undefined): void {
if (text === undefined) return;
const label = this._bindings.titleLabel;
if (label?.isValid) {
label.string = text;
}
}
private _applyProgressText(text: string | undefined): void {
if (text === undefined) return;
const label = this._bindings.progressLabel;
if (label?.isValid) {
label.string = text;
}
}
private _applyProgressValue(progress: number | undefined): void {
if (progress === undefined) return;
this._applyAnimatedProgress(progress);
}
private _applyAnimatedProgress(progress: number): void {
const clamped = this._clamp(progress);
const bar = this._bindings.progressBar;
if (bar?.isValid) {
bar.progress = this._normalize(clamped);
}
this._updateProgressAnchor(clamped);
}
private _updateProgressAnchor(progress: number): void {
const anchor = this._bindings.progressAnchor;
if (!anchor?.isValid) return;
this._cacheProgressAnchorStartX();
const startX = this._progressAnchorStartX ?? anchor.position.x;
const travelWidth = this._getProgressAnchorTravelWidth();
anchor.setPosition(
startX + travelWidth * progress,
anchor.position.y,
anchor.position.z,
);
const percentLabel = anchor.getChildByName('Label')?.getComponent(Label);
if (percentLabel) {
percentLabel.string = `${Math.round(progress * 100)}%`;
}
}
private _getProgressAnchorTravelWidth(): number {
const bar = this._bindings.progressBar;
if (!bar) return 0;
return Math.abs(bar.totalLength * bar.node.scale.x);
}
/**
* Bar 节点 anchor 为 (0, 0.5),其本地 position.x 即进度条可视左端。
* ProgressBar 与 ProgressAnchor 共享同一父节点,因此把 Bar 的本地 X
* 按 ProgressBar 自身的位移与缩放映射到父节点空间才是真正的「0% 起点」。
* 直接拿 anchor.position.x 当起点会被 prefab 摆放偏移量带跑。
*/
private _cacheProgressAnchorStartX(): void {
if (this._progressAnchorStartX !== null) return;
const bar = this._bindings.progressBar;
const barSprite = bar?.barSprite;
if (!bar || !barSprite) return;
const barLocalX = barSprite.node.position.x;
this._progressAnchorStartX = bar.node.position.x
+ barLocalX * bar.node.scale.x
+ PROGRESS_ANCHOR_VISUAL_OFFSET;
}
private _normalize(progress: number): number {
if (!Number.isFinite(progress) || progress <= 0) return 0;
return Math.max(MIN_PROGRESS_RATIO, Math.min(1, progress));
}
private _clamp(progress: number): number {
if (!Number.isFinite(progress) || progress <= 0) return 0;
return Math.min(1, progress);
}
}
```
### 13.3 成就称号管理器 (assets/scripts/utils/AchievementTitleManager.ts)
```typescript
import { ACHIEVEMENT_TITLE_CONFIG, AchievementTitleConfigItem, INFINITE_ACHIEVEMENT_TITLE } from '../config/AchievementTitleConfig';
export interface AchievementTitleInfo {
readonly titleText: string;
readonly nextTitleText: string;
readonly nextTitleProgress: number;
readonly progressText: string;
readonly completedLevelCount: number;
readonly currentTitleStartCount: number;
readonly nextTitleRequiredCount: number;
readonly remainingToNextTitle: number;
}
interface AchievementTitleStage {
readonly titleText: string;
readonly clearsToNext: number;
readonly startCount: number;
}
export class AchievementTitleManager {
public static getTitleInfo(completedLevelCount: number): AchievementTitleInfo {
const safeCompletedCount = AchievementTitleManager.normalizeCompletedCount(completedLevelCount);
const stages = AchievementTitleManager.buildFiniteStages();
const finiteResult = AchievementTitleManager.findFiniteStage(safeCompletedCount, stages);
if (finiteResult) {
return AchievementTitleManager.createTitleInfo(safeCompletedCount, finiteResult.currentStage, finiteResult.nextStage);
}
return AchievementTitleManager.createInfiniteTitleInfo(safeCompletedCount, stages[stages.length - 1]);
}
private static normalizeCompletedCount(completedLevelCount: number): number {
if (!Number.isFinite(completedLevelCount) || completedLevelCount < 0) {
return 0;
}
return Math.floor(completedLevelCount);
}
private static buildFiniteStages(): AchievementTitleStage[] {
let startCount = 0;
return ACHIEVEMENT_TITLE_CONFIG.map((item: AchievementTitleConfigItem) => {
const stage: AchievementTitleStage = {
titleText: item.levelName,
clearsToNext: item.clearsToNext,
startCount
};
startCount += item.clearsToNext;
return stage;
});
}
private static findFiniteStage(completedLevelCount: number, stages: AchievementTitleStage[]): { currentStage: AchievementTitleStage; nextStage: AchievementTitleStage } | null {
for (let index = 0; index < stages.length; index++) {
const currentStage = stages[index];
const nextStage = stages[index + 1] ?? {
titleText: `${INFINITE_ACHIEVEMENT_TITLE.levelNamePrefix}1级`,
clearsToNext: INFINITE_ACHIEVEMENT_TITLE.clearsToNext,
startCount: currentStage.startCount + currentStage.clearsToNext
};
if (completedLevelCount < nextStage.startCount) {
return { currentStage, nextStage };
}
}
return null;
}
private static createInfiniteTitleInfo(completedLevelCount: number, lastFiniteStage: AchievementTitleStage): AchievementTitleInfo {
const infiniteStartCount = lastFiniteStage.startCount + lastFiniteStage.clearsToNext;
const completedAfterInfiniteStart = Math.max(0, completedLevelCount - infiniteStartCount);
const currentInfiniteLevel = Math.floor(completedAfterInfiniteStart / INFINITE_ACHIEVEMENT_TITLE.clearsToNext) + 1;
const currentStageStartCount = infiniteStartCount + (currentInfiniteLevel - 1) * INFINITE_ACHIEVEMENT_TITLE.clearsToNext;
const nextStage: AchievementTitleStage = {
titleText: `${INFINITE_ACHIEVEMENT_TITLE.levelNamePrefix}${currentInfiniteLevel + 1}`,
clearsToNext: INFINITE_ACHIEVEMENT_TITLE.clearsToNext,
startCount: currentStageStartCount + INFINITE_ACHIEVEMENT_TITLE.clearsToNext
};
return AchievementTitleManager.createTitleInfo(
completedLevelCount,
{
titleText: `${INFINITE_ACHIEVEMENT_TITLE.levelNamePrefix}${currentInfiniteLevel}`,
clearsToNext: INFINITE_ACHIEVEMENT_TITLE.clearsToNext,
startCount: currentStageStartCount
},
nextStage
);
}
private static createTitleInfo(completedLevelCount: number, currentStage: AchievementTitleStage, nextStage: AchievementTitleStage): AchievementTitleInfo {
const interval = Math.max(1, nextStage.startCount - currentStage.startCount);
const completedInCurrentTitle = Math.max(0, completedLevelCount - currentStage.startCount);
const remainingToNextTitle = Math.max(0, nextStage.startCount - completedLevelCount);
const nextTitleProgress = Math.max(0, Math.min(1, completedInCurrentTitle / interval));
return {
titleText: currentStage.titleText,
nextTitleText: nextStage.titleText,
nextTitleProgress,
progressText: `还差${remainingToNextTitle}题,解锁新成就等级`,
completedLevelCount,
currentTitleStartCount: currentStage.startCount,
nextTitleRequiredCount: nextStage.startCount,
remainingToNextTitle
};
}
}
```