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