feat: 完善关卡创作页面

This commit is contained in:
richarjiang
2026-04-30 16:35:08 +08:00
parent f8198e0463
commit 3d246de24c
18 changed files with 2135 additions and 841 deletions

View File

@@ -131,7 +131,7 @@ export class PageHome extends BaseView {
*/ */
private _onPkClick(): void { private _onPkClick(): void {
console.log('[PageHome] PK按钮点击'); console.log('[PageHome] PK按钮点击');
ToastManager.show('功能正在开发中,敬请期待吧!'); ViewManager.instance.open('PageWriteLevels');
} }
// ========== 体力消耗动画 ========== // ========== 体力消耗动画 ==========

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,22 @@
import { _decorator, Node, Button, Sprite, Label, ScrollView, instantiate, UITransform, Vec2 } from 'cc'; import { _decorator, Node, Button, Label, ScrollView, instantiate, UITransform } from 'cc';
import { BaseView } from 'db://assets/scripts/core/BaseView'; import { BaseView } from 'db://assets/scripts/core/BaseView';
import { ViewManager } from 'db://assets/scripts/core/ViewManager'; import { ViewManager } from 'db://assets/scripts/core/ViewManager';
import { LevelDataManager } from 'db://assets/scripts/utils/LevelDataManager'; import { CompletedLevelsManager } from 'db://assets/scripts/utils/CompletedLevelsManager';
import { PreviewLevelItem } from './PreviewLevelItem';
const { ccclass, property } = _decorator; const { ccclass, property } = _decorator;
/** /**
* 预览试卷页面 * 预览试卷页面
* 垂直滚动展示用户在 PageWriteLevels 中选中的 6 个关卡 * 垂直滚动展示用户在 PageWriteLevels 中选中的 6 个关卡
* 每个关卡展示:封面图、提示1、提示2、答案 * 每个关卡展示:封面图、线索1、线索2、线索3、答案
* *
* prefab 节点结构(已在编辑器中搭建: * 节点结构(仅 ScrollView 侧需要固定:
* PagePreviewLevels * PagePreviewLevels
* ├── Bg * ├── ScrollView / view / content ← listContent 容器
* ── IconBack ← backBtn (返回按钮) * ── ListTpl ← listTemplate 模板根节点
* ├── PKTitle ← 标题 "挑战" * (挂 PreviewLevelItem 组件,字段由编辑器拖拽绑定)
* ├── ScrollView ← scrollView *
* │ ├── scrollBar * item 内部节点层级/命名对本文件透明:所有引用都来自 PreviewLevelItem 的 @property。
* │ └── view
* │ └── content ← listContent
* ├── ListTpl ← listTemplate (关卡模板)
* │ ├── LevelCover ← Sprite 封面图
* │ ├── Tips1 ← Label 提示1
* │ ├── Tips2 ← Label 提示2
* │ └── Answer ← Label 答案
* └── BackButton ← backButton (底部返回按钮)
*/ */
/** 布局配置 — 垂直列表 */ /** 布局配置 — 垂直列表 */
@@ -197,44 +190,26 @@ export class PagePreviewLevels extends BaseView {
* 异步加载关卡数据并填充到 item 节点 * 异步加载关卡数据并填充到 item 节点
*/ */
private async _loadLevelData(item: Node, levelIndex: number, displayIndex: number): Promise<void> { private async _loadLevelData(item: Node, levelIndex: number, displayIndex: number): Promise<void> {
const config = await LevelDataManager.instance.ensureLevelReady(levelIndex); const level = CompletedLevelsManager.instance.getByIndex(levelIndex);
if (!config || !item.isValid) return; if (!level || !item.isValid) return;
// 填充封面图 const view = item.getComponent(PreviewLevelItem);
const levelCover = item.getChildByName('LevelCover'); if (!view) {
if (levelCover) { console.warn('[PagePreviewLevels] listTemplate 缺少 PreviewLevelItem 组件');
const sprite = levelCover.getComponent(Sprite); return;
if (sprite && config.spriteFrame1) {
sprite.spriteFrame = config.spriteFrame1;
}
} }
// 填充提示1 view.setTexts({
const tips1 = item.getChildByName('Tips1'); answer: level.answer || '',
if (tips1) { hint1: level.hint1 || '',
const label = tips1.getComponent(Label); hint2: level.hint2 || '',
if (label) { hint3: level.hint3 || '',
label.string = `线索一:${config.clue1 || ''}`; });
}
}
// 填充提示2 // 异步加载封面图(通常已由 WriteLevels 预热到缓存)
const tips2 = item.getChildByName('Tips2'); const spriteFrame = await CompletedLevelsManager.instance.loadImage(level.image1Url);
if (tips2) { if (!spriteFrame || !item.isValid) return;
const label = tips2.getComponent(Label); view.setCover(spriteFrame);
if (label) {
label.string = `线索二:${config.clue2 || ''}`;
}
}
// 填充答案
const answer = item.getChildByName('Answer');
if (answer) {
const label = answer.getComponent(Label);
if (label) {
label.string = `答案:${config.answer || ''}`;
}
}
} }
// ─── 事件处理 ─────────────────────────────────────── // ─── 事件处理 ───────────────────────────────────────

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import { _decorator, Node, Button, Sprite, Label, Toggle, ScrollView, EditBox, instantiate, UITransform, Vec2, EventTouch } from 'cc'; import { _decorator, Node, Button, Sprite, Label, Toggle, ScrollView, EditBox, instantiate, UITransform, Vec2, EventTouch } from 'cc';
import { BaseView } from 'db://assets/scripts/core/BaseView'; import { BaseView } from 'db://assets/scripts/core/BaseView';
import { ViewManager } from 'db://assets/scripts/core/ViewManager'; import { ViewManager } from 'db://assets/scripts/core/ViewManager';
import { LevelDataManager } from 'db://assets/scripts/utils/LevelDataManager'; import { CompletedLevelsManager } from 'db://assets/scripts/utils/CompletedLevelsManager';
import { ToastManager } from 'db://assets/scripts/utils/ToastManager'; import { ToastManager } from 'db://assets/scripts/utils/ToastManager';
import { ShareManager } from 'db://assets/scripts/utils/ShareManager'; import { ShareManager } from 'db://assets/scripts/utils/ShareManager';
import { StorageManager } from 'db://assets/scripts/utils/StorageManager'; import { StorageManager } from 'db://assets/scripts/utils/StorageManager';
@@ -9,28 +9,27 @@ import { WxSDK, getUserProfile } from 'db://assets/scripts/utils/WxSDK';
import { AuthManager } from 'db://assets/scripts/utils/AuthManager'; import { AuthManager } from 'db://assets/scripts/utils/AuthManager';
import { API_ENDPOINTS, API_TIMEOUT } from 'db://assets/scripts/config/ApiConfig'; import { API_ENDPOINTS, API_TIMEOUT } from 'db://assets/scripts/config/ApiConfig';
import { HttpUtil } from 'db://assets/scripts/utils/HttpUtil'; import { HttpUtil } from 'db://assets/scripts/utils/HttpUtil';
import { ApiEnvelope } from 'db://assets/scripts/types/ApiTypes';
const { ccclass, property } = _decorator; const { ccclass, property } = _decorator;
/** /**
* 布局配置 * 布局配置
* 基于实际 prefab 尺寸计算: * view (ScrollView 的可视窗口) 宽 900高 1000anchor (0.5, 1) 顶部中点
* ScrollView / view 宽 900高 1300
* ListTpl (item) 宽 300高 400 * ListTpl (item) 宽 300高 400
* *
* 水平居中2 × 300 + 1 × 40 = 640, padding_left = (900 - 640) / 2 = 130 * 每页 2 行 × 2 列 = 4 个关卡
* 垂直居中3 × 400 + 2 × 25 = 1250, padding_top = (1300 - 1250) / 2 = 25 * PADDING 不再手写,由 VIEW / ITEM / SPACING 自动派生(见 _getHorizontalPadding / _getVerticalPadding
* 保证 item 网格在 view 内始终居中。改 SPACING 时不用再算 PADDING。
*/ */
const LAYOUT_CONFIG = { const LAYOUT_CONFIG = {
COLS: 2, COLS: 2,
ROWS: 3, ROWS: 2,
ITEM_WIDTH: 300, ITEM_WIDTH: 300,
ITEM_HEIGHT: 400, ITEM_HEIGHT: 400,
SPACING_X: 40, SPACING_X: 160,
SPACING_Y: 25, SPACING_Y: 180,
PADDING_LEFT: 130,
PADDING_TOP: 25,
VIEW_WIDTH: 900, VIEW_WIDTH: 900,
VIEW_HEIGHT: 1300, VIEW_HEIGHT: 1000,
}; };
/** 必须选择的关卡数量 */ /** 必须选择的关卡数量 */
@@ -140,7 +139,7 @@ export class PageWriteLevels extends BaseView {
} }
} }
private _onTouchStart(event: EventTouch): void { private _onTouchStart(_event: EventTouch): void {
if (!this._scrollViewComp) return; if (!this._scrollViewComp) return;
this._touchStartOffsetX = this._scrollViewComp.getScrollOffset().x; this._touchStartOffsetX = this._scrollViewComp.getScrollOffset().x;
this._touchStartTime = Date.now(); this._touchStartTime = Date.now();
@@ -149,7 +148,7 @@ export class PageWriteLevels extends BaseView {
/** /**
* 触摸结束:根据滑动距离和速度决定翻页方向 * 触摸结束:根据滑动距离和速度决定翻页方向
*/ */
private _onTouchEnd(event: EventTouch): void { private _onTouchEnd(_event: EventTouch): void {
if (!this._scrollViewComp || this._totalPages <= 1) return; if (!this._scrollViewComp || this._totalPages <= 1) return;
const currentOffsetX = this._scrollViewComp.getScrollOffset().x; const currentOffsetX = this._scrollViewComp.getScrollOffset().x;
@@ -176,20 +175,27 @@ export class PageWriteLevels extends BaseView {
console.log('[PageWriteLevels] onViewShow'); console.log('[PageWriteLevels] onViewShow');
// 仅首次初始化列表,从预览页返回时保留选中状态 // 仅首次初始化列表,从预览页返回时保留选中状态
if (this._itemNodes.length === 0) { if (this._itemNodes.length === 0) {
this._initLevelList(); void this._initLevelList();
} }
} }
private _initLevelList(): void { private async _initLevelList(): Promise<void> {
this._clearList(); this._clearList();
// TODO: LevelDataManager API 已重构为 NextLevel 驱动,此页面需要重新设计数据来源 // 拉取当前用户所有已通关关卡
// this._levelCount = LevelDataManager.instance.getLevelCount(); const levels = await CompletedLevelsManager.instance.fetch();
this._levelCount = 0; if (levels === null) {
console.log('[PageWriteLevels] 关卡总数:', this._levelCount); console.warn('[PageWriteLevels] 获取已通关关卡失败');
ToastManager.instance.show('获取关卡列表失败,请稍后重试');
return;
}
this._levelCount = levels.length;
console.log('[PageWriteLevels] 已通关关卡总数:', this._levelCount);
if (this._levelCount === 0) { if (this._levelCount === 0) {
console.warn('[PageWriteLevels] 没有关卡数据'); console.warn('[PageWriteLevels] 用户尚未通关任何关卡');
ToastManager.instance.show('还没有已通关的关卡,快去玩几关吧');
return; return;
} }
@@ -214,6 +220,25 @@ export class PageWriteLevels extends BaseView {
this._selectedIndices.clear(); this._selectedIndices.clear();
} }
/**
* 水平 padding让整行 item 在 view 宽度内居中
* padding_left = (VIEW_WIDTH - cols*ITEM_WIDTH - (cols-1)*SPACING_X) / 2
*/
private _getHorizontalPadding(): number {
const { VIEW_WIDTH, COLS, ITEM_WIDTH, SPACING_X } = LAYOUT_CONFIG;
const gridWidth = COLS * ITEM_WIDTH + (COLS - 1) * SPACING_X;
return (VIEW_WIDTH - gridWidth) / 2;
}
/**
* 垂直 padding让整列 item 在 view 高度内居中
*/
private _getVerticalPadding(): number {
const { VIEW_HEIGHT, ROWS, ITEM_HEIGHT, SPACING_Y } = LAYOUT_CONFIG;
const gridHeight = ROWS * ITEM_HEIGHT + (ROWS - 1) * SPACING_Y;
return (VIEW_HEIGHT - gridHeight) / 2;
}
private _updateContentSize(): void { private _updateContentSize(): void {
if (!this.listContent) return; if (!this.listContent) return;
@@ -223,11 +248,12 @@ export class PageWriteLevels extends BaseView {
const totalWidth = this._totalPages * LAYOUT_CONFIG.VIEW_WIDTH; const totalWidth = this._totalPages * LAYOUT_CONFIG.VIEW_WIDTH;
contentTransform.setContentSize(totalWidth, LAYOUT_CONFIG.VIEW_HEIGHT); contentTransform.setContentSize(totalWidth, LAYOUT_CONFIG.VIEW_HEIGHT);
// anchor=(0,1)将 content 左上角对齐到 view 左上角 // content anchor=(0,1)view anchor=(0.5,1)。
// view 本地坐标系下view 的左上角 = (-viewWidth/2, 0)。
// content 的 anchor 点(左上角)需要贴到 view 的左上角。
if (this._viewTransform) { if (this._viewTransform) {
const viewWidth = this._viewTransform.contentSize.width; const viewWidth = this._viewTransform.contentSize.width;
const viewHeight = this._viewTransform.contentSize.height; this.listContent.setPosition(-viewWidth / 2, 0, 0);
this.listContent.setPosition(-viewWidth / 2, viewHeight / 2, 0);
} }
} }
@@ -277,12 +303,15 @@ export class PageWriteLevels extends BaseView {
const col = localIndex % LAYOUT_CONFIG.COLS; const col = localIndex % LAYOUT_CONFIG.COLS;
const row = Math.floor(localIndex / LAYOUT_CONFIG.COLS); const row = Math.floor(localIndex / LAYOUT_CONFIG.COLS);
const paddingLeft = this._getHorizontalPadding();
const paddingTop = this._getVerticalPadding();
const x = pageIndex * LAYOUT_CONFIG.VIEW_WIDTH const x = pageIndex * LAYOUT_CONFIG.VIEW_WIDTH
+ LAYOUT_CONFIG.PADDING_LEFT + paddingLeft
+ col * (LAYOUT_CONFIG.ITEM_WIDTH + LAYOUT_CONFIG.SPACING_X) + col * (LAYOUT_CONFIG.ITEM_WIDTH + LAYOUT_CONFIG.SPACING_X)
+ LAYOUT_CONFIG.ITEM_WIDTH / 2; + LAYOUT_CONFIG.ITEM_WIDTH / 2;
const y = -(LAYOUT_CONFIG.PADDING_TOP const y = -(paddingTop
+ row * (LAYOUT_CONFIG.ITEM_HEIGHT + LAYOUT_CONFIG.SPACING_Y) + row * (LAYOUT_CONFIG.ITEM_HEIGHT + LAYOUT_CONFIG.SPACING_Y)
+ LAYOUT_CONFIG.ITEM_HEIGHT / 2); + LAYOUT_CONFIG.ITEM_HEIGHT / 2);
@@ -293,11 +322,12 @@ export class PageWriteLevels extends BaseView {
* 初始化 item 的默认名称和选中状态(不设置封面,由异步加载负责) * 初始化 item 的默认名称和选中状态(不设置封面,由异步加载负责)
*/ */
private _initItemState(item: Node, index: number): void { private _initItemState(item: Node, index: number): void {
const level = CompletedLevelsManager.instance.getByIndex(index);
const levelName = item.getChildByName('LevelName'); const levelName = item.getChildByName('LevelName');
if (levelName) { if (levelName) {
const label = levelName.getComponent(Label); const label = levelName.getComponent(Label);
if (label) { if (label) {
label.string = `${index + 1}`; label.string = level ? `${level.level}` : `${index + 1}`;
} }
} }
@@ -317,27 +347,20 @@ export class PageWriteLevels extends BaseView {
} }
/** /**
* 异步加载关卡资源并刷新封面图和名称。 * 异步加载关卡封面图并填充到 item
* TODO: LevelDataManager API 已重构为 NextLevel 驱动,此方法需要重新设计
*/ */
private async _loadAndRefreshCover(item: Node, index: number): Promise<void> { private async _loadAndRefreshCover(item: Node, index: number): Promise<void> {
// const config = await LevelDataManager.instance.ensureLevelReady(index); const level = CompletedLevelsManager.instance.getByIndex(index);
const config = null as any; // TODO: 需要适配新 API if (!level || !item.isValid) return;
if (!config || !item.isValid) return;
const spriteFrame = await CompletedLevelsManager.instance.loadImage(level.image1Url);
if (!spriteFrame || !item.isValid) return;
const levelCover = item.getChildByName('LevelCover'); const levelCover = item.getChildByName('LevelCover');
if (levelCover) { if (levelCover) {
const sprite = levelCover.getComponent(Sprite); const sprite = levelCover.getComponent(Sprite);
if (sprite && config.spriteFrame1) { if (sprite) {
sprite.spriteFrame = config.spriteFrame1; sprite.spriteFrame = spriteFrame;
}
}
const levelName = item.getChildByName('LevelName');
if (levelName) {
const label = levelName.getComponent(Label);
if (label) {
label.string = config.name;
} }
} }
} }
@@ -550,15 +573,14 @@ export class PageWriteLevels extends BaseView {
* 将选中的关卡索引转换为关卡 ID 数组 * 将选中的关卡索引转换为关卡 ID 数组
*/ */
private _getSelectedLevelIds(): string[] { private _getSelectedLevelIds(): string[] {
// TODO: LevelDataManager API 已重构为 NextLevel 驱动,此方法需要重新设计
const ids: string[] = []; const ids: string[] = [];
// const sortedIndices = Array.from(this._selectedIndices).sort((a, b) => a - b); const sortedIndices = Array.from(this._selectedIndices).sort((a, b) => a - b);
// for (const index of sortedIndices) { for (const index of sortedIndices) {
// const config = LevelDataManager.instance.getLevelConfig(index); const level = CompletedLevelsManager.instance.getByIndex(index);
// if (config) { if (level) {
// ids.push(config.id); ids.push(level.id);
// } }
// } }
return ids; return ids;
} }
@@ -592,7 +614,7 @@ export class PageWriteLevels extends BaseView {
StorageManager.setUserInfo(userInfo); StorageManager.setUserInfo(userInfo);
// 上传到服务端 // 上传到服务端
const response = await HttpUtil.post( const response = await HttpUtil.post<ApiEnvelope<unknown>>(
API_ENDPOINTS.USER_INFO, API_ENDPOINTS.USER_INFO,
{ {
userId: userId, userId: userId,

View File

@@ -0,0 +1,47 @@
import { _decorator, Component, Node, Sprite, Label, SpriteFrame } from 'cc';
const { ccclass, property } = _decorator;
/**
* 预览页单个关卡 item 的视图组件
* 挂在 PagePreviewLevels 的 listTemplate 根节点上,由编辑器拖拽绑定子节点。
* instantiate 克隆 item 时Cocos 会把这些 Node 引用自动重映射到克隆后的子节点,
* 所以每个 item 拿到的都是自己的封面/标签引用,与节点层级/命名解耦。
*/
@ccclass('PreviewLevelItem')
export class PreviewLevelItem extends Component {
@property({ type: Sprite, tooltip: '封面图 Sprite' })
levelCover: Sprite | null = null;
@property({ type: Label, tooltip: '答案 Label' })
answerLabel: Label | null = null;
@property({ type: Label, tooltip: '线索1 Label' })
tips1Label: Label | null = null;
@property({ type: Label, tooltip: '线索2 Label' })
tips2Label: Label | null = null;
@property({ type: Label, tooltip: '线索3 Label' })
tips3Label: Label | null = null;
/**
* 一次性设置所有文本字段
*/
setTexts(opts: {
answer: string;
hint1: string;
hint2: string;
hint3: string;
}): void {
if (this.answerLabel) this.answerLabel.string = `答案:${opts.answer}`;
if (this.tips1Label) this.tips1Label.string = `线索一:${opts.hint1}`;
if (this.tips2Label) this.tips2Label.string = `线索二:${opts.hint2}`;
if (this.tips3Label) this.tips3Label.string = `线索三:${opts.hint3}`;
}
setCover(spriteFrame: SpriteFrame | null): void {
if (this.levelCover && spriteFrame) {
this.levelCover.spriteFrame = spriteFrame;
}
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "5fb357ec-a670-4fff-a5cd-f47f1a2d8ea3",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -49,8 +49,8 @@
"height": 235, "height": 235,
"rawWidth": 666, "rawWidth": 666,
"rawHeight": 235, "rawHeight": 235,
"borderTop": 0, "borderTop": 120,
"borderBottom": 0, "borderBottom": 115,
"borderLeft": 120, "borderLeft": 120,
"borderRight": 120, "borderRight": 120,
"packable": true, "packable": true,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -41,18 +41,18 @@
"userData": { "userData": {
"trimThreshold": 1, "trimThreshold": 1,
"rotated": false, "rotated": false,
"offsetX": -0.5, "offsetX": 0,
"offsetY": 0, "offsetY": 0,
"trimX": 0, "trimX": 0,
"trimY": 0, "trimY": 0,
"width": 665, "width": 666,
"height": 235, "height": 235,
"rawWidth": 666, "rawWidth": 666,
"rawHeight": 235, "rawHeight": 235,
"borderTop": 112, "borderTop": 112,
"borderBottom": 112, "borderBottom": 112,
"borderLeft": 120, "borderLeft": 112,
"borderRight": 120, "borderRight": 112,
"packable": true, "packable": true,
"pixelsToUnit": 100, "pixelsToUnit": 100,
"pivotX": 0.5, "pivotX": 0.5,
@@ -60,16 +60,16 @@
"meshType": 0, "meshType": 0,
"vertices": { "vertices": {
"rawPosition": [ "rawPosition": [
-332.5, -333,
-117.5, -117.5,
0, 0,
332.5, 333,
-117.5, -117.5,
0, 0,
-332.5, -333,
117.5, 117.5,
0, 0,
332.5, 333,
117.5, 117.5,
0 0
], ],
@@ -84,30 +84,30 @@
"uv": [ "uv": [
0, 0,
235, 235,
665, 666,
235, 235,
0, 0,
0, 0,
665, 666,
0 0
], ],
"nuv": [ "nuv": [
0, 0,
0, 0,
0.9984984984984985, 1,
0, 0,
0, 0,
1, 1,
0.9984984984984985, 1,
1 1
], ],
"minPos": [ "minPos": [
-332.5, -333,
-117.5, -117.5,
0 0
], ],
"maxPos": [ "maxPos": [
332.5, 333,
117.5, 117.5,
0 0
] ]

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -0,0 +1,134 @@
{
"ver": "1.0.27",
"importer": "image",
"imported": true,
"uuid": "fca884ba-b748-4630-886d-cfebff286af8",
"files": [
".json",
".png"
],
"subMetas": {
"6c48a": {
"importer": "texture",
"uuid": "fca884ba-b748-4630-886d-cfebff286af8@6c48a",
"displayName": "Banner2",
"id": "6c48a",
"name": "texture",
"userData": {
"wrapModeS": "clamp-to-edge",
"wrapModeT": "clamp-to-edge",
"imageUuidOrDatabaseUri": "fca884ba-b748-4630-886d-cfebff286af8",
"isUuid": true,
"visible": false,
"minfilter": "linear",
"magfilter": "linear",
"mipfilter": "none",
"anisotropy": 0
},
"ver": "1.0.22",
"imported": true,
"files": [
".json"
],
"subMetas": {}
},
"f9941": {
"importer": "sprite-frame",
"uuid": "fca884ba-b748-4630-886d-cfebff286af8@f9941",
"displayName": "Banner2",
"id": "f9941",
"name": "spriteFrame",
"userData": {
"trimThreshold": 1,
"rotated": false,
"offsetX": 0,
"offsetY": 0,
"trimX": 0,
"trimY": 0,
"width": 828,
"height": 156,
"rawWidth": 828,
"rawHeight": 156,
"borderTop": 0,
"borderBottom": 0,
"borderLeft": 0,
"borderRight": 0,
"packable": true,
"pixelsToUnit": 100,
"pivotX": 0.5,
"pivotY": 0.5,
"meshType": 0,
"vertices": {
"rawPosition": [
-414,
-78,
0,
414,
-78,
0,
-414,
78,
0,
414,
78,
0
],
"indexes": [
0,
1,
2,
2,
1,
3
],
"uv": [
0,
156,
828,
156,
0,
0,
828,
0
],
"nuv": [
0,
0,
1,
0,
0,
1,
1,
1
],
"minPos": [
-414,
-78,
0
],
"maxPos": [
414,
78,
0
]
},
"isUuid": true,
"imageUuidOrDatabaseUri": "fca884ba-b748-4630-886d-cfebff286af8@6c48a",
"atlasUuid": "",
"trimType": "auto"
},
"ver": "1.0.12",
"imported": true,
"files": [
".json"
],
"subMetas": {}
}
},
"userData": {
"type": "sprite-frame",
"fixAlphaTransparencyArtifacts": false,
"hasAlpha": true,
"redirect": "fca884ba-b748-4630-886d-cfebff286af8@6c48a"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -0,0 +1,134 @@
{
"ver": "1.0.27",
"importer": "image",
"imported": true,
"uuid": "5b2a42ac-89e1-4a48-a171-108e41780903",
"files": [
".json",
".png"
],
"subMetas": {
"6c48a": {
"importer": "texture",
"uuid": "5b2a42ac-89e1-4a48-a171-108e41780903@6c48a",
"displayName": "quizThumbLine",
"id": "6c48a",
"name": "texture",
"userData": {
"wrapModeS": "clamp-to-edge",
"wrapModeT": "clamp-to-edge",
"imageUuidOrDatabaseUri": "5b2a42ac-89e1-4a48-a171-108e41780903",
"isUuid": true,
"visible": false,
"minfilter": "linear",
"magfilter": "linear",
"mipfilter": "none",
"anisotropy": 0
},
"ver": "1.0.22",
"imported": true,
"files": [
".json"
],
"subMetas": {}
},
"f9941": {
"importer": "sprite-frame",
"uuid": "5b2a42ac-89e1-4a48-a171-108e41780903@f9941",
"displayName": "quizThumbLine",
"id": "f9941",
"name": "spriteFrame",
"userData": {
"trimThreshold": 1,
"rotated": false,
"offsetX": 0,
"offsetY": 0,
"trimX": 0,
"trimY": 0,
"width": 861,
"height": 228,
"rawWidth": 861,
"rawHeight": 228,
"borderTop": 0,
"borderBottom": 0,
"borderLeft": 0,
"borderRight": 0,
"packable": true,
"pixelsToUnit": 100,
"pivotX": 0.5,
"pivotY": 0.5,
"meshType": 0,
"vertices": {
"rawPosition": [
-430.5,
-114,
0,
430.5,
-114,
0,
-430.5,
114,
0,
430.5,
114,
0
],
"indexes": [
0,
1,
2,
2,
1,
3
],
"uv": [
0,
228,
861,
228,
0,
0,
861,
0
],
"nuv": [
0,
0,
1,
0,
0,
1,
1,
1
],
"minPos": [
-430.5,
-114,
0
],
"maxPos": [
430.5,
114,
0
]
},
"isUuid": true,
"imageUuidOrDatabaseUri": "5b2a42ac-89e1-4a48-a171-108e41780903@6c48a",
"atlasUuid": "",
"trimType": "auto"
},
"ver": "1.0.12",
"imported": true,
"files": [
".json"
],
"subMetas": {}
}
},
"userData": {
"type": "sprite-frame",
"fixAlphaTransparencyArtifacts": false,
"hasAlpha": true,
"redirect": "5b2a42ac-89e1-4a48-a171-108e41780903@6c48a"
}
}

View File

@@ -22,6 +22,8 @@ export const API_ENDPOINTS = {
SHARE_PROGRESS: `${API_BASE}/share/progress`, SHARE_PROGRESS: `${API_BASE}/share/progress`,
/** 用户信息 */ /** 用户信息 */
USER_INFO: `${API_BASE}/user/info`, USER_INFO: `${API_BASE}/user/info`,
/** 用户所有已通关的关卡(成就墙 / 关卡回看) */
COMPLETED_LEVELS: `${API_BASE}/levels/completed`,
} as const; } as const;
export function getLevelEnterUrl(levelId: string): string { export function getLevelEnterUrl(levelId: string): string {

View File

@@ -156,3 +156,35 @@ export interface CreatedShareItem {
export interface CreatedShareListData { export interface CreatedShareListData {
items: CreatedShareItem[]; items: CreatedShareItem[];
} }
/** 已通关关卡数据(成就墙 / 关卡回看场景) */
export interface CompletedLevel {
/** 关卡 ID */
id: string;
/** 关卡编号sortOrder */
level: number;
/** 图片1 URL */
image1Url: string;
/** 图片1 文本说明 */
image1Description: string | null;
/** 图片2 URL */
image2Url: string;
/** 图片2 文本说明 */
image2Description: string | null;
/** 答案 */
answer: string;
/** 谐音梗说明 */
punchline: string | null;
/** 线索1 */
hint1: string | null;
/** 线索2 */
hint2: string | null;
/** 线索3 */
hint3: string | null;
/** 限时null 表示不限时 */
timeLimit: number | null;
/** 首次通关时长(秒) */
timeSpent: number;
/** 通关时间ISO 8601 */
completedAt: string;
}

View File

@@ -0,0 +1,138 @@
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;
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "991baa0f-1a4b-4f71-bfff-3de5f7470c22",
"files": [],
"subMetas": {},
"userData": {}
}