Files
mp-xieyingeng/软著源代码/09-分享与预览.md
2026-06-29 19:55:40 +08:00

24 KiB
Raw Permalink Blame History

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

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


第 9 部分:好友分享与预览

本部分包含 PK 关卡预览页、分享逻辑管理器、分享启动参数处理器。

9.1 分享预览页 (assets/prefabs/PagePreviewLevels.ts)

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)

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)

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