# 梗中作乐 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(水平居中于 content),y 负向下 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 { 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 = 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 { try { const response = await HttpUtil.post>( 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 { try { const response = await HttpUtil.post>( 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 { try { const response = await HttpUtil.get>( 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 { try { const response = await HttpUtil.get>( 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 { try { const response = await HttpUtil.get>( 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 { 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 { if (!this._shareCode) { console.warn('[ShareManager] submitShareChallenge: 无分享码,跳过提交'); return null; } try { const response = await HttpUtil.post>( 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 { 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('[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 } | 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 { 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; } } } ```