24 KiB
24 KiB
梗中作乐 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(水平居中于 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<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;
}
}
}