771 lines
22 KiB
Markdown
771 lines
22 KiB
Markdown
# 梗中作乐 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;
|
||
}
|
||
}
|
||
```
|