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

705 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 梗中作乐 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水平居中于 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)
```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<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)
```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<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;
}
}
}
```