Files
mp-xieyingeng/软著源代码/06-关卡周边模块.md
2026-06-29 19:55:40 +08:00

22 KiB
Raw Permalink Blame History

梗中作乐 V1.0 - 源代码文档(第 6 部分)

软件名称:梗中作乐 版本号V1.0


第 6 部分:关卡周边模块

本部分包含关卡数据缓存、答错弹窗、超时弹窗、通关关卡列表四个与关卡流程紧密关联的模块。

6.1 关卡数据管理器 (assets/scripts/utils/LevelDataManager.ts)

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)

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)

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)

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;
    }
}