From 25d196263b7e903da2f42d38c1fef5f9e486a19d Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sun, 26 Apr 2026 17:08:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E5=85=B3=E5=8D=A1?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api/game-api.md | 469 ++++++++++-------- ...ser-level-progress.repository.interface.ts | 1 + .../user-level-progress.repository.ts | 4 + src/modules/level/dto/complete-level.dto.ts | 10 +- src/modules/level/dto/enter-level.dto.ts | 9 + src/modules/level/dto/level-list.dto.ts | 53 -- src/modules/level/dto/next-level.dto.ts | 39 ++ src/modules/level/level.controller.ts | 18 +- src/modules/level/level.service.ts | 96 ++-- src/modules/level/next-level.helper.ts | 35 ++ src/modules/user/dto/user-profile.dto.ts | 12 +- src/modules/user/user.module.ts | 3 +- src/modules/user/user.service.ts | 15 +- 13 files changed, 437 insertions(+), 327 deletions(-) delete mode 100644 src/modules/level/dto/level-list.dto.ts create mode 100644 src/modules/level/dto/next-level.dto.ts create mode 100644 src/modules/level/next-level.helper.ts diff --git a/docs/api/game-api.md b/docs/api/game-api.md index be78770..657c512 100644 --- a/docs/api/game-api.md +++ b/docs/api/game-api.md @@ -8,21 +8,31 @@ - [认证方式](#认证方式) - [通用响应格式](#通用响应格式) - [体力值系统说明](#体力值系统说明) +- [通用数据结构](#通用数据结构) - [接口列表](#接口列表) - [1. 微信登录](#1-微信登录) - [2. 获取用户资料](#2-获取用户资料) - [3. 获取游戏数据](#3-获取游戏数据) - - [4. 获取关卡列表](#4-获取关卡列表) - - [5. 进入关卡](#5-进入关卡) - - [6. 通关上报](#6-通关上报) - - [7. 获取游戏配置](#7-获取游戏配置) - - [8. 获取单个游戏配置](#8-获取单个游戏配置) + - [4. 进入关卡](#4-进入关卡) + - [5. 通关上报](#5-通关上报) + - [6. 获取游戏配置](#6-获取游戏配置) + - [7. 获取单个游戏配置](#7-获取单个游戏配置) - [错误码说明](#错误码说明) - [接入流程](#接入流程) - [Cocos Creator 调用示例](#cocos-creator-调用示例) --- +## Changelog + +- **2026-04-26**: + - **删除** `GET /api/v1/levels`(关卡列表接口),客户端不再需要拉取全量关卡列表 + - **变更** `GET /api/v1/user/game-data`:移除 `completedLevelIds`,新增 `completedLevelCount`(数字)和 `nextLevel`(下一关完整数据) + - **变更** `POST /api/v1/levels/:id/enter`:新增 `preloadNextLevel`(预加载下一关数据) + - **变更** `POST /api/v1/levels/:id/complete`:新增 `nextLevel`(通关后的下一关数据) + +--- + ## 概述 MemeMind 核心玩法接口分为以下模块: @@ -31,7 +41,7 @@ MemeMind 核心玩法接口分为以下模块: |------|----------|------| | 认证 | `/api/v1/auth` | 微信登录、JWT 签发 | | 用户 | `/api/v1/user` | 用户资料、体力值、游戏数据 | -| 关卡 | `/api/v1/levels` | 关卡列表、进入关卡、通关上报 | +| 关卡 | `/api/v1/levels` | 进入关卡、通关上报 | | 游戏配置 | `/api/v1/game-configs` | 游戏全局配置 | ### 与旧版 API 的变更(⚠️ Breaking Changes) @@ -41,8 +51,9 @@ MemeMind 核心玩法接口分为以下模块: | `GET /api/v1/user/assets` | `GET /api/v1/user/profile` | | `POST /api/v1/user/assets/consume` | 已删除,体力在「进入关卡」时自动扣减 | | `POST /api/v1/user/assets/earn` | 已删除,通关不再奖励积分 | -| `GET /api/v1/user/game-data` | `GET /api/v1/user/game-data`(路径不变,响应结构变更) | -| `GET /api/v1/wechat-game/levels` | `GET /api/v1/levels`(需鉴权) | +| `GET /api/v1/user/game-data`(旧版返回 completedLevelIds) | `GET /api/v1/user/game-data`(新版返回 completedLevelCount + nextLevel) | +| `GET /api/v1/levels` | 已删除,下一关数据由 `game-data` / `enter` / `complete` 接口直接返回 | +| `GET /api/v1/wechat-game/levels` | 已删除 | | `GET /api/v1/wechat-game/levels/:id` | `POST /api/v1/levels/:id/enter`(需鉴权 + 消耗体力) | | `GET /api/v1/wechat-game/configs` | `GET /api/v1/game-configs` | | `GET /api/v1/wechat-game/configs/:key` | `GET /api/v1/game-configs/:key` | @@ -123,6 +134,36 @@ interface StaminaInfo { --- +## 通用数据结构 + +### NextLevel — 关卡完整数据 + +多个接口返回此结构,表示一个关卡的完整内容(供客户端渲染和预加载): + +```typescript +interface NextLevel { + id: string; // 关卡 ID + level: number; // 关卡编号(sortOrder) + image1Url: string; // 图片1 URL + image1Description: string | null; // 图片1 文本说明 + image2Url: string; // 图片2 URL + image2Description: string | null; // 图片2 文本说明 + answer: string; // 答案 + punchline: string | null; // 谐音梗说明 + hint1: string | null; // 线索1 + hint2: string | null; // 线索2 + hint3: string | null; // 线索3 + timeLimit: number | null; // 限时(秒),null 表示不限时 +} +``` + +**出现位置**: +- `GET /api/v1/user/game-data` → `nextLevel` +- `POST /api/v1/levels/:id/enter` → `preloadNextLevel` +- `POST /api/v1/levels/:id/complete` → `nextLevel` + +--- + ## 接口列表 ### 1. 微信登录 @@ -229,7 +270,7 @@ interface StaminaInfo { ### 3. 获取游戏数据 -获取用户体力值和通关进度,适用于游戏 Loading 页面一次性加载。 +获取用户体力值、通关进度和下一关数据,适用于游戏 Loading 页面一次性加载。 **接口地址**:`GET /api/v1/user/game-data` @@ -245,7 +286,8 @@ interface StaminaInfo { id: string; stamina: StaminaInfo; }; - completedLevelIds: string[]; // 已通关的关卡 ID 列表 + completedLevelCount: number; // 已通关的关卡数量 + nextLevel: NextLevel | null; // 下一个待通关的关卡(全部通关时为 null) } ``` @@ -263,110 +305,58 @@ interface StaminaInfo { "nextRecoverAt": null } }, - "completedLevelIds": ["level_001", "level_002", "level_005"] + "completedLevelCount": 3, + "nextLevel": { + "id": "level_004", + "level": 4, + "image1Url": "https://cdn.example.com/levels/004_1.png", + "image1Description": "一只狗在看电视", + "image2Url": "https://cdn.example.com/levels/004_2.png", + "image2Description": "一个遥控器", + "answer": "汪汪队", + "punchline": "谐音梗:汪和旺", + "hint1": "和动物有关", + "hint2": "和声音有关", + "hint3": null, + "timeLimit": null + } }, "message": null, "timestamp": "2026-04-10T12:00:00.000Z" } ``` -**客户端调用时机**: -- 游戏 Loading 页面 -- 进入关卡选择页面前 - ---- - -### 4. 获取关卡列表 - -获取所有关卡列表。**已通关的关卡**返回答案、谐音梗说明和线索,**未通关的关卡**不返回敏感数据。 - -**接口地址**:`GET /api/v1/levels` - -**是否需要认证**:是 - -**请求参数**:无 - -**响应数据**: - -```typescript -{ - levels: LevelListItem[]; - total: number; -} - -interface LevelListItem { - id: string; // 关卡 ID - level: number; // 关卡编号(从 1 开始) - image1Url: string; // 图片1 URL - image1Description: string | null; // 图片1 文本说明 - image2Url: string; // 图片2 URL - image2Description: string | null; // 图片2 文本说明 - answer: string | null; // 答案(仅已通关时返回,否则 null) - punchline: string | null; // 谐音梗说明(仅已通关时返回,否则 null) - hint1: string | null; // 线索1(仅已通关时返回,否则 null) - hint2: string | null; // 线索2(仅已通关时返回,否则 null) - hint3: string | null; // 线索3(仅已通关时返回,否则 null) - completed: boolean; // 是否已通关 - timeSpent: number | null; // 通关时长(秒),未通关时为 null -} -``` - -**成功响应示例**: +**全部通关时的响应示例**: ```json { "success": true, "data": { - "levels": [ - { - "id": "level_001", - "level": 1, - "image1Url": "https://cdn.example.com/levels/001_1.png", - "image1Description": "一只猫在看鱼", - "image2Url": "https://cdn.example.com/levels/001_2.png", - "image2Description": "一条鱼在飞", - "answer": "梗答案", - "punchline": "谐音梗:鱼和猫的故事", - "hint1": "这是一个经典的...", - "hint2": "和某个明星有关", - "hint3": null, - "completed": true, - "timeSpent": 45 - }, - { - "id": "level_002", - "level": 2, - "image1Url": "https://cdn.example.com/levels/002_1.png", - "image1Description": "一个人在走路", - "image2Url": "https://cdn.example.com/levels/002_2.png", - "image2Description": "一辆车在跑", - "answer": null, - "punchline": null, - "hint1": null, - "hint2": null, - "hint3": null, - "completed": false, - "timeSpent": null - } - ], - "total": 2 + "user": { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "stamina": { "current": 50, "max": 50, "nextRecoverAt": null } + }, + "completedLevelCount": 20, + "nextLevel": null }, "message": null, "timestamp": "2026-04-10T12:00:00.000Z" } ``` -**客户端使用说明**: -- 关卡选择页面使用此接口获取关卡列表 -- 每个关卡有两张图片(`image1Url`、`image2Url`)和对应的文本说明 -- 根据 `completed` 字段展示不同的 UI 状态(已通关/未通关) -- 未通关关卡的 `answer`、`punchline`、`hint1`、`hint2`、`hint3` 均为 `null`,**客户端不应缓存这些字段** +**业务逻辑**: +- `nextLevel` 为按 `sortOrder` 排序的第一个用户未通关的关卡 +- 全部通关时 `nextLevel` 为 `null`,客户端应展示通关庆祝页面 + +**客户端调用时机**: +- 游戏 Loading 页面 +- 进入主页面前 --- -### 5. 进入关卡 +### 4. 进入关卡 -消耗 1 点体力进入关卡,获取完整的关卡详情(含答案和线索)。 +消耗 1 点体力进入关卡,获取完整的关卡详情(含答案和线索),并预加载下一关数据。 **接口地址**:`POST /api/v1/levels/{id}/enter` @@ -395,7 +385,8 @@ interface LevelListItem { hint1: string | null; hint2: string | null; hint3: string | null; - stamina: StaminaInfo; // 消耗后的体力信息 + stamina: StaminaInfo; // 消耗后的体力信息 + preloadNextLevel: NextLevel | null; // 预加载的下一关数据(无下一关时为 null) } ``` @@ -405,21 +396,35 @@ interface LevelListItem { { "success": true, "data": { - "id": "level_002", - "level": 2, - "image1Url": "https://cdn.example.com/levels/002_1.png", - "image1Description": "一个人在走路", - "image2Url": "https://cdn.example.com/levels/002_2.png", - "image2Description": "一辆车在跑", - "answer": "这是答案", - "punchline": "谐音梗:走和跑的故事", - "hint1": "第一个线索", - "hint2": "第二个线索", + "id": "level_004", + "level": 4, + "image1Url": "https://cdn.example.com/levels/004_1.png", + "image1Description": "一只狗在看电视", + "image2Url": "https://cdn.example.com/levels/004_2.png", + "image2Description": "一个遥控器", + "answer": "汪汪队", + "punchline": "谐音梗:汪和旺", + "hint1": "和动物有关", + "hint2": "和声音有关", "hint3": null, "stamina": { "current": 47, "max": 50, "nextRecoverAt": "2026-04-10T12:10:00.000Z" + }, + "preloadNextLevel": { + "id": "level_005", + "level": 5, + "image1Url": "https://cdn.example.com/levels/005_1.png", + "image1Description": "一个杯子", + "image2Url": "https://cdn.example.com/levels/005_2.png", + "image2Description": "一个人在喝水", + "answer": "杯具", + "punchline": "谐音梗:杯和悲", + "hint1": "和容器有关", + "hint2": null, + "hint3": null, + "timeLimit": 60 } }, "message": null, @@ -435,6 +440,10 @@ interface LevelListItem { | 再次进入已通关关卡 | ❌ 不消耗 | 直接返回关卡详情 | | 体力为 0 且关卡未通关 | ❌ 返回错误 | 返回 401 体力不足 | +- `preloadNextLevel` 为按 `sortOrder` 排在当前关卡之后的第一个未完成关卡 +- 当前关卡是最后一关时,`preloadNextLevel` 为 `null` +- 客户端可在用户答题时后台预加载 `preloadNextLevel` 中的图片资源 + **客户端调用时机**: - 用户在关卡选择页面点击某个关卡进入时 - **必须**调用此接口获取关卡详情后才能开始游戏 @@ -442,9 +451,9 @@ interface LevelListItem { --- -### 6. 通关上报 +### 5. 通关上报 -用户通关后上报通关时长。同一关卡不会重复记录。 +用户通关后上报通关时长,返回下一关数据。同一关卡不会重复记录。 **接口地址**:`POST /api/v1/levels/{id}/complete` @@ -472,9 +481,10 @@ interface LevelListItem { ```typescript { - firstClear: boolean; // 是否为首次通关 - levelId: string; // 关卡 ID - timeSpent: number; // 记录的通关时长(秒) + firstClear: boolean; // 是否为首次通关 + levelId: string; // 关卡 ID + timeSpent: number; // 记录的通关时长(秒) + nextLevel: NextLevel | null; // 下一个待通关的关卡(全部通关时为 null) } ``` @@ -485,8 +495,22 @@ interface LevelListItem { "success": true, "data": { "firstClear": true, - "levelId": "level_002", - "timeSpent": 45 + "levelId": "level_004", + "timeSpent": 45, + "nextLevel": { + "id": "level_005", + "level": 5, + "image1Url": "https://cdn.example.com/levels/005_1.png", + "image1Description": "一个杯子", + "image2Url": "https://cdn.example.com/levels/005_2.png", + "image2Description": "一个人在喝水", + "answer": "杯具", + "punchline": "谐音梗:杯和悲", + "hint1": "和容器有关", + "hint2": null, + "hint3": null, + "timeLimit": 60 + } }, "message": null, "timestamp": "2026-04-10T12:00:00.000Z" @@ -500,8 +524,38 @@ interface LevelListItem { "success": true, "data": { "firstClear": false, - "levelId": "level_002", - "timeSpent": 30 + "levelId": "level_004", + "timeSpent": 30, + "nextLevel": { + "id": "level_005", + "level": 5, + "image1Url": "https://cdn.example.com/levels/005_1.png", + "image1Description": "一个杯子", + "image2Url": "https://cdn.example.com/levels/005_2.png", + "image2Description": "一个人在喝水", + "answer": "杯具", + "punchline": "谐音梗:杯和悲", + "hint1": "和容器有关", + "hint2": null, + "hint3": null, + "timeLimit": 60 + } + }, + "message": null, + "timestamp": "2026-04-10T12:00:00.000Z" +} +``` + +**全部通关时的响应示例**: + +```json +{ + "success": true, + "data": { + "firstClear": true, + "levelId": "level_020", + "timeSpent": 60, + "nextLevel": null }, "message": null, "timestamp": "2026-04-10T12:00:00.000Z" @@ -511,14 +565,17 @@ interface LevelListItem { **业务逻辑**: - 首次通关:记录 `timeSpent`,返回 `firstClear: true` - 重复通关:不覆盖记录,返回首次通关的 `timeSpent`,`firstClear: false` +- `nextLevel` 为通关后按 `sortOrder` 排序的第一个未完成关卡 +- 全部通关时 `nextLevel` 为 `null` **客户端调用时机**: - 用户成功回答正确答案后调用 - 只在通关成功时调用,答错不需要上报 +- 收到响应后,可直接使用 `nextLevel` 数据进入下一关(调用 `enter` 接口) --- -### 7. 获取游戏配置 +### 6. 获取游戏配置 获取所有激活的游戏配置。 @@ -573,7 +630,7 @@ interface GameConfig { --- -### 8. 获取单个游戏配置 +### 7. 获取单个游戏配置 根据配置 key 获取单个配置。 @@ -633,32 +690,46 @@ interface GameConfig { ┌──────────────────────────────────────────────────────────────────────┐ │ Phase 2: Loading 页面 │ ├──────────────────────────────────────────────────────────────────────┤ -│ 4. GET /api/v1/user/game-data → 获取体力值 + 已通关关卡列表 │ +│ 4. GET /api/v1/user/game-data → 获取体力 + 通关数 + 下一关数据 │ │ 5. GET /api/v1/game-configs → 获取游戏配置(可选) │ +│ 6. nextLevel 不为 null → 预加载 nextLevel 的图片资源 │ +│ nextLevel 为 null → 全部通关,展示庆祝页面 │ └──────────────────────┬───────────────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────────────┐ -│ Phase 3: 关卡选择页面 │ +│ Phase 3: 进入关卡 │ ├──────────────────────────────────────────────────────────────────────┤ -│ 6. GET /api/v1/levels → 获取关卡列表(含通关状态) │ -│ 7. 展示关卡网格,已通关的显示已完成状态 │ -│ 8. 用户点击某个关卡 │ -│ ├─ 体力足够 → Phase 4 │ +│ 7. 客户端使用 game-data 中的 nextLevel.id 调用 enter 接口 │ +│ 8. POST /api/v1/levels/{id}/enter → 消耗体力,获取关卡详情 │ +│ ├─ 体力足够 → 返回关卡数据 + preloadNextLevel │ │ └─ 体力不足 → 提示等待恢复(显示 nextRecoverAt 倒计时) │ +│ 9. 后台预加载 preloadNextLevel 的图片资源(如果不为 null) │ └──────────────────────┬───────────────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────────────┐ -│ Phase 4: 关卡游玩 │ +│ Phase 4: 关卡游玩 & 通关 │ ├──────────────────────────────────────────────────────────────────────┤ -│ 9. POST /api/v1/levels/{id}/enter → 消耗体力,获取关卡详情 │ │ 10. 展示关卡图片,开始计时 │ │ 11. 用户输入答案 │ │ ├─ 答案正确 → 停止计时 │ -│ │ └─ POST /api/v1/levels/{id}/complete → 上报通关时长 │ +│ │ └─ POST /api/v1/levels/{id}/complete → 上报 + 获取 nextLevel │ +│ │ ├─ nextLevel 不为 null → 用 nextLevel.id 调用 enter │ +│ │ └─ nextLevel 为 null → 全部通关 │ │ └─ 答案错误 → 提示错误,可使用线索 │ └──────────────────────────────────────────────────────────────────────┘ ``` +### 关卡数据获取链路 + +``` +game-data.nextLevel → enter(nextLevel.id) → complete(id) → nextLevel + ↓ ↓ ↓ + 首关数据 preloadNextLevel 下一关数据 + (用于图片预加载) (用于无缝衔接) +``` + +客户端不需要维护关卡列表,服务端在每个接口中直接告知下一关。 + ### 客户端状态管理建议 ```typescript @@ -668,22 +739,19 @@ interface GameState { userId: string | null; // 体力信息 - stamina: { - current: number; - max: number; - nextRecoverAt: string | null; - }; + stamina: StaminaInfo; - // 关卡数据 - completedLevelIds: Set; // 已通关关卡 ID - currentLevel: { // 当前正在玩的关卡 - id: string; - answer: string; - punchline: string | null; - hints: (string | null)[]; - images: { url: string; description: string | null }[]; - startTime: number; // 开始时间戳,用于计算 timeSpent - } | null; + // 关卡进度 + completedLevelCount: number; + + // 当前关卡 + currentLevel: NextLevel | null; // 来自 game-data.nextLevel 或 complete.nextLevel + + // 预加载的下一关 + preloadedLevel: NextLevel | null; // 来自 enter.preloadNextLevel + + // 游戏中状态 + startTime: number | null; // 开始时间戳,用于计算 timeSpent } ``` @@ -746,6 +814,21 @@ export interface StaminaInfo { nextRecoverAt: string | null; } +export interface NextLevel { + id: string; + level: number; + image1Url: string; + image1Description: string | null; + image2Url: string; + image2Description: string | null; + answer: string; + punchline: string | null; + hint1: string | null; + hint2: string | null; + hint3: string | null; + timeLimit: number | null; +} + class HttpManager { private baseUrl = 'https://your-api-domain.com/api'; private token: string | null = null; @@ -828,7 +911,8 @@ async function wxLogin() { ```typescript interface GameData { user: { id: string; stamina: StaminaInfo }; - completedLevelIds: string[]; + completedLevelCount: number; + nextLevel: NextLevel | null; } async function loadGameData(): Promise { @@ -840,35 +924,7 @@ async function loadGameData(): Promise { } ``` -### 4. 获取关卡列表 - -```typescript -interface LevelListItem { - id: string; - level: number; - image1Url: string; - image1Description: string | null; - image2Url: string; - image2Description: string | null; - answer: string | null; - punchline: string | null; - hint1: string | null; - hint2: string | null; - hint3: string | null; - completed: boolean; - timeSpent: number | null; -} - -async function getLevels(): Promise { - const resp = await http.get<{ levels: LevelListItem[]; total: number }>('/v1/levels'); - if (resp.success && resp.data) { - return resp.data.levels; - } - throw new Error(resp.message || '获取关卡列表失败'); -} -``` - -### 5. 进入关卡(核心接口) +### 4. 进入关卡(核心接口) ```typescript interface EnterLevelResponse { @@ -884,6 +940,7 @@ interface EnterLevelResponse { hint2: string | null; hint3: string | null; stamina: StaminaInfo; + preloadNextLevel: NextLevel | null; } async function enterLevel(levelId: string): Promise { @@ -892,35 +949,29 @@ async function enterLevel(levelId: string): Promise { if (resp.success && resp.data) { // 更新本地体力状态 updateLocalStamina(resp.data.stamina); + + // 后台预加载下一关图片 + if (resp.data.preloadNextLevel) { + preloadImages([ + resp.data.preloadNextLevel.image1Url, + resp.data.preloadNextLevel.image2Url, + ]); + } + return resp.data; } throw new Error(resp.message || '进入关卡失败'); } - -// 调用示例 -async function onClickLevel(levelId: string, currentStamina: number, isCompleted: boolean) { - if (!isCompleted && currentStamina <= 0) { - showToast('体力不足,请等待恢复'); - return; - } - - try { - const levelData = await enterLevel(levelId); - // 跳转到游戏页面,传入关卡数据 - startGame(levelData); - } catch (err) { - showToast(err.message); - } -} ``` -### 6. 通关上报 +### 5. 通关上报 ```typescript interface CompleteLevelResponse { firstClear: boolean; levelId: string; timeSpent: number; + nextLevel: NextLevel | null; } async function completeLevel( @@ -936,20 +987,23 @@ async function completeLevel( if (resp.data.firstClear) { showToast('恭喜通关!'); } + + if (resp.data.nextLevel) { + // 有下一关,可以直接进入 + const nextData = await enterLevel(resp.data.nextLevel.id); + startGame(nextData); + } else { + // 全部通关 + showCelebration(); + } + return resp.data; } throw new Error(resp.message || '上报通关失败'); } - -// 调用示例:用户答对时 -async function onAnswerCorrect(levelId: string, startTime: number) { - const timeSpent = Math.floor((Date.now() - startTime) / 1000); - const result = await completeLevel(levelId, timeSpent); - // 更新关卡选择页面的状态 -} ``` -### 7. 完整启动流程 +### 6. 完整启动流程 ```typescript // GameEntry.ts @@ -970,17 +1024,28 @@ export class GameEntry extends Component { const loginData = await wxLogin(); console.log('登录成功:', loginData.user.id); - // 3. 加载游戏数据 + // 3. 加载游戏数据(含下一关) const gameData = await loadGameData(); console.log('体力:', gameData.user.stamina.current); - console.log('已通关:', gameData.completedLevelIds.length, '关'); + console.log('已通关:', gameData.completedLevelCount, '关'); // 4. 启动体力恢复倒计时 startStaminaTimer(gameData.user.stamina); - // 5. 获取关卡列表,进入关卡选择页面 - const levels = await getLevels(); - showLevelSelect(levels); + // 5. 根据 nextLevel 决定流程 + if (gameData.nextLevel) { + // 预加载下一关图片 + preloadImages([ + gameData.nextLevel.image1Url, + gameData.nextLevel.image2Url, + ]); + + // 进入游戏主页面,展示下一关入口 + showMainPage(gameData); + } else { + // 全部通关 + showCelebration(); + } } catch (error) { console.error('启动失败:', error); @@ -996,10 +1061,12 @@ export class GameEntry extends Component { 1. **Token 有效期**:JWT Token 有效期 7 天,建议本地缓存并在启动时尝试复用 2. **体力值以服务端为准**:客户端倒计时仅为 UI 展示,实际体力以接口返回为准 -3. **进入关卡必须调用接口**:不要使用列表接口中缓存的关卡数据直接开始游戏,必须调用 `enter` 接口 +3. **进入关卡必须调用接口**:不要使用缓存的关卡数据直接开始游戏,必须调用 `enter` 接口 4. **已通关关卡免费进入**:已通关关卡再次进入不消耗体力 5. **通关上报仅限成功**:只在用户答对后调用 `complete` 接口,答错不需要上报 6. **hint 字段**:`hint1/hint2/hint3` 可能为 `null`,表示该线索未配置 -7. **punchline 字段**:谐音梗说明,仅已通关时返回,未通关时为 `null` +7. **punchline 字段**:谐音梗说明 8. **双图结构**:每个关卡有两张图片(`image1Url`、`image2Url`),分别有对应的文本说明 9. **网络异常处理**:建议所有接口调用加 loading 状态,并处理 401(重新登录)和网络错误 +10. **预加载策略**:`enter` 返回的 `preloadNextLevel` 用于图片预加载,在用户答题时后台加载下一关图片可优化体验 +11. **关卡列表已废弃**:不再需要 `GET /api/v1/levels`,服务端通过 `nextLevel` 字段直接告知下一关 diff --git a/src/modules/auth/repositories/user-level-progress.repository.interface.ts b/src/modules/auth/repositories/user-level-progress.repository.interface.ts index d50e162..e7c2ef6 100644 --- a/src/modules/auth/repositories/user-level-progress.repository.interface.ts +++ b/src/modules/auth/repositories/user-level-progress.repository.interface.ts @@ -2,6 +2,7 @@ import { UserLevelProgress } from '../entities/user-level-progress.entity'; export interface IUserLevelProgressRepository { findByUserId(userId: string): Promise; + countByUserId(userId: string): Promise; findByUserAndLevel( userId: string, levelId: string, diff --git a/src/modules/auth/repositories/user-level-progress.repository.ts b/src/modules/auth/repositories/user-level-progress.repository.ts index 822d448..5c1b2b2 100644 --- a/src/modules/auth/repositories/user-level-progress.repository.ts +++ b/src/modules/auth/repositories/user-level-progress.repository.ts @@ -15,6 +15,10 @@ export class UserLevelProgressRepository implements IUserLevelProgressRepository return this.repository.find({ where: { userId } }); } + async countByUserId(userId: string): Promise { + return this.repository.count({ where: { userId } }); + } + async findByUserAndLevel( userId: string, levelId: string, diff --git a/src/modules/level/dto/complete-level.dto.ts b/src/modules/level/dto/complete-level.dto.ts index d32c23c..29df9e9 100644 --- a/src/modules/level/dto/complete-level.dto.ts +++ b/src/modules/level/dto/complete-level.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; +import { IsNotEmpty, IsNumber, Min } from 'class-validator'; +import { NextLevelDto } from './next-level.dto'; export class CompleteLevelRequestDto { @ApiProperty({ description: '通关时长(秒)' }) @@ -18,4 +19,11 @@ export class CompleteLevelResponseDto { @ApiProperty({ description: '通关时长(秒)' }) timeSpent!: number; + + @ApiProperty({ + description: '下一个待通关的关卡(全部通关时为 null)', + nullable: true, + type: NextLevelDto, + }) + nextLevel!: NextLevelDto | null; } diff --git a/src/modules/level/dto/enter-level.dto.ts b/src/modules/level/dto/enter-level.dto.ts index e6d4a91..fa35eb4 100644 --- a/src/modules/level/dto/enter-level.dto.ts +++ b/src/modules/level/dto/enter-level.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { StaminaInfoDto } from '../../user/dto/user-profile.dto'; +import { NextLevelDto } from './next-level.dto'; export class EnterLevelResponseDto { @ApiProperty({ description: '关卡 ID' }) @@ -37,4 +38,12 @@ export class EnterLevelResponseDto { @ApiProperty({ description: '消耗体力后的体力信息' }) stamina!: StaminaInfoDto; + + @ApiProperty({ + description: + '预加载的下一关数据(用于客户端预加载资源,无下一关时为 null)', + nullable: true, + type: NextLevelDto, + }) + preloadNextLevel!: NextLevelDto | null; } diff --git a/src/modules/level/dto/level-list.dto.ts b/src/modules/level/dto/level-list.dto.ts deleted file mode 100644 index 4617fda..0000000 --- a/src/modules/level/dto/level-list.dto.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; - -export class LevelListItemDto { - @ApiProperty({ description: '关卡 ID' }) - id!: string; - - @ApiProperty({ description: '关卡编号' }) - level!: number; - - @ApiProperty({ description: '图片1 URL' }) - image1Url!: string; - - @ApiProperty({ description: '图片1 文本说明', nullable: true }) - image1Description!: string | null; - - @ApiProperty({ description: '图片2 URL' }) - image2Url!: string; - - @ApiProperty({ description: '图片2 文本说明', nullable: true }) - image2Description!: string | null; - - @ApiProperty({ description: '答案(仅已通关时返回)', nullable: true }) - answer!: string | null; - - @ApiProperty({ description: '谐音梗说明(始终返回)', nullable: true }) - punchline!: string | null; - - @ApiProperty({ description: '线索1(始终返回)', nullable: true }) - hint1!: string | null; - - @ApiProperty({ description: '线索2(仅已通关时返回)', nullable: true }) - hint2!: string | null; - - @ApiProperty({ description: '线索3(仅已通关时返回)', nullable: true }) - hint3!: string | null; - - @ApiProperty({ description: '是否已通关' }) - completed!: boolean; - - @ApiProperty({ - description: '通关时长(秒),未通关时为 null', - nullable: true, - }) - timeSpent!: number | null; -} - -export class LevelListResponseDto { - @ApiProperty({ type: [LevelListItemDto], description: '关卡列表' }) - levels!: LevelListItemDto[]; - - @ApiProperty({ description: '关卡总数' }) - total!: number; -} diff --git a/src/modules/level/dto/next-level.dto.ts b/src/modules/level/dto/next-level.dto.ts new file mode 100644 index 0000000..68a1a0c --- /dev/null +++ b/src/modules/level/dto/next-level.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class NextLevelDto { + @ApiProperty({ description: '关卡 ID' }) + id!: string; + + @ApiProperty({ description: '关卡编号(sortOrder)' }) + level!: number; + + @ApiProperty({ description: '图片1 URL' }) + image1Url!: string; + + @ApiProperty({ description: '图片1 文本说明', nullable: true }) + image1Description!: string | null; + + @ApiProperty({ description: '图片2 URL' }) + image2Url!: string; + + @ApiProperty({ description: '图片2 文本说明', nullable: true }) + image2Description!: string | null; + + @ApiProperty({ description: '答案' }) + answer!: string; + + @ApiProperty({ description: '谐音梗说明', nullable: true }) + punchline!: string | null; + + @ApiProperty({ description: '线索1', nullable: true }) + hint1!: string | null; + + @ApiProperty({ description: '线索2', nullable: true }) + hint2!: string | null; + + @ApiProperty({ description: '线索3', nullable: true }) + hint3!: string | null; + + @ApiProperty({ description: '限时(秒)', nullable: true }) + timeLimit!: number | null; +} diff --git a/src/modules/level/level.controller.ts b/src/modules/level/level.controller.ts index 882a2e8..8ff824c 100644 --- a/src/modules/level/level.controller.ts +++ b/src/modules/level/level.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, @@ -6,7 +6,6 @@ import { ApiTags, } from '@nestjs/swagger'; import { LevelService } from './level.service'; -import { LevelListResponseDto } from './dto/level-list.dto'; import { EnterLevelResponseDto } from './dto/enter-level.dto'; import { CompleteLevelRequestDto, @@ -24,21 +23,6 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator'; export class LevelController { constructor(private readonly levelService: LevelService) {} - @Get() - @ApiOperation({ - summary: '获取关卡列表', - description: - '获取所有关卡列表。已通关的关卡返回答案和线索,未通关的不返回敏感数据', - }) - @ApiResponse({ status: 200, description: '成功' }) - @ApiResponse({ status: 401, description: '未授权' }) - async getLevels( - @CurrentUser() user: JwtPayload, - ): Promise> { - const data = await this.levelService.getLevelList(user.sub); - return ApiResponseDto.success(data); - } - @Post(':id/enter') @ApiOperation({ summary: '进入关卡', diff --git a/src/modules/level/level.service.ts b/src/modules/level/level.service.ts index bdffa5d..264d24d 100644 --- a/src/modules/level/level.service.ts +++ b/src/modules/level/level.service.ts @@ -2,16 +2,13 @@ import { Injectable, NotFoundException, Logger } from '@nestjs/common'; import { LevelRepository } from '../wechat-game/repositories/level.repository'; import { UserLevelProgressRepository } from '../auth/repositories/user-level-progress.repository'; import { UserService } from '../user/user.service'; -import { LevelListResponseDto, LevelListItemDto } from './dto/level-list.dto'; import { EnterLevelResponseDto } from './dto/enter-level.dto'; import { CompleteLevelRequestDto, CompleteLevelResponseDto, } from './dto/complete-level.dto'; -import { - pickLevelImageFields, - pickLevelImageFieldsMasked, -} from '../wechat-game/level-fields.helper'; +import { pickLevelImageFields } from '../wechat-game/level-fields.helper'; +import { findNextUncompletedLevels, toNextLevelDto } from './next-level.helper'; @Injectable() export class LevelService { @@ -24,35 +21,7 @@ export class LevelService { ) {} /** - * 获取关卡列表(已通关的返回答案/线索,未通关的不返回) - */ - async getLevelList(userId: string): Promise { - const [levels, progressList] = await Promise.all([ - this.levelRepository.findAllOrdered(), - this.userLevelProgressRepository.findByUserId(userId), - ]); - - const progressMap = new Map(progressList.map((p) => [p.levelId, p])); - - const items: LevelListItemDto[] = levels.map((level, index) => { - const progress = progressMap.get(level.id); - const completed = !!progress; - - return { - id: level.id, - level: index + 1, - ...pickLevelImageFieldsMasked(level, completed), - answer: completed ? level.answer : null, - completed, - timeSpent: completed ? progress.timeSpent : null, - }; - }); - - return { levels: items, total: items.length }; - } - - /** - * 进入关卡:消耗 1 体力,返回完整关卡详情 + * 进入关卡:消耗 1 体力,返回完整关卡详情 + 预加载下一关 */ async enterLevel( userId: string, @@ -79,17 +48,34 @@ export class LevelService { this.logger.log(`用户 ${userId} 进入关卡 ${levelId},消耗 1 体力`); } + // 计算预加载的下一关(当前关卡之后的第一个未完成关卡) + const [allLevels, progressList] = await Promise.all([ + this.levelRepository.findAllOrdered(), + this.userLevelProgressRepository.findByUserId(userId), + ]); + const completedIds = new Set(progressList.map((p) => p.levelId)); + // 当前关卡不算已完成(用户正在玩),找当前关卡之后的第一个未完成关卡 + const levelsAfterCurrent = allLevels.filter( + (l) => l.sortOrder > level.sortOrder, + ); + const nextLevels = findNextUncompletedLevels( + levelsAfterCurrent, + completedIds, + 1, + ); + return { id: level.id, level: level.sortOrder, ...pickLevelImageFields(level), answer: level.answer, stamina: staminaInfo, + preloadNextLevel: nextLevels[0] ? toNextLevelDto(nextLevels[0]) : null, }; } /** - * 通关上报:记录通关时长 + * 通关上报:记录通关时长,返回下一关数据 */ async completeLevel( userId: string, @@ -105,28 +91,40 @@ export class LevelService { throw new NotFoundException(`关卡 ${levelId} 不存在`); } + let firstClear: boolean; + let timeSpent: number; + if (existing) { this.logger.warn(`用户 ${userId} 已通关关卡 ${levelId},不重复记录`); - return { - firstClear: false, + firstClear = false; + timeSpent = existing.timeSpent; + } else { + const progress = this.userLevelProgressRepository.create({ + userId, levelId, - timeSpent: existing.timeSpent, - }; + timeSpent: dto.timeSpent, + }); + await this.userLevelProgressRepository.save(progress); + this.logger.log( + `用户 ${userId} 通关 ${levelId},用时 ${dto.timeSpent} 秒`, + ); + firstClear = true; + timeSpent = dto.timeSpent; } - const progress = this.userLevelProgressRepository.create({ - userId, - levelId, - timeSpent: dto.timeSpent, - }); - await this.userLevelProgressRepository.save(progress); - - this.logger.log(`用户 ${userId} 通关 ${levelId},用时 ${dto.timeSpent} 秒`); + // 计算下一关 + const [allLevels, allProgress] = await Promise.all([ + this.levelRepository.findAllOrdered(), + this.userLevelProgressRepository.findByUserId(userId), + ]); + const completedIds = new Set(allProgress.map((p) => p.levelId)); + const nextLevels = findNextUncompletedLevels(allLevels, completedIds, 1); return { - firstClear: true, + firstClear, levelId, - timeSpent: dto.timeSpent, + timeSpent, + nextLevel: nextLevels[0] ? toNextLevelDto(nextLevels[0]) : null, }; } } diff --git a/src/modules/level/next-level.helper.ts b/src/modules/level/next-level.helper.ts new file mode 100644 index 0000000..d7178ea --- /dev/null +++ b/src/modules/level/next-level.helper.ts @@ -0,0 +1,35 @@ +import { Level } from '../wechat-game/entities/level.entity'; +import { NextLevelDto } from './dto/next-level.dto'; +import { pickLevelImageFields } from '../wechat-game/level-fields.helper'; + +/** + * Convert a Level entity to a NextLevelDto for client consumption. + */ +export function toNextLevelDto(level: Level): NextLevelDto { + return { + id: level.id, + level: level.sortOrder, + ...pickLevelImageFields(level), + answer: level.answer, + timeLimit: level.timeLimit, + }; +} + +/** + * Given all levels (sorted by sortOrder ASC) and the set of completed level IDs, + * return the next `count` uncompleted levels. + */ +export function findNextUncompletedLevels( + allLevelsOrdered: Level[], + completedLevelIds: Set, + count: number, +): Level[] { + const result: Level[] = []; + for (const level of allLevelsOrdered) { + if (!completedLevelIds.has(level.id)) { + result.push(level); + if (result.length >= count) break; + } + } + return result; +} diff --git a/src/modules/user/dto/user-profile.dto.ts b/src/modules/user/dto/user-profile.dto.ts index 3257b06..25b30a6 100644 --- a/src/modules/user/dto/user-profile.dto.ts +++ b/src/modules/user/dto/user-profile.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { NextLevelDto } from '../../level/dto/next-level.dto'; export class StaminaInfoDto { @ApiProperty({ description: '当前体力值' }) @@ -32,6 +33,13 @@ export class GameDataResponseDto { stamina: StaminaInfoDto; }; - @ApiProperty({ description: '已完成的关卡 ID 列表' }) - completedLevelIds!: string[]; + @ApiProperty({ description: '已通关的关卡数量' }) + completedLevelCount!: number; + + @ApiProperty({ + description: '下一个待通关的关卡(全部通关时为 null)', + nullable: true, + type: NextLevelDto, + }) + nextLevel!: NextLevelDto | null; } diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index 9c63193..4a5f579 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { UserController } from './user.controller'; import { UserService } from './user.service'; import { AuthModule } from '../auth/auth.module'; +import { WechatGameModule } from '../wechat-game/wechat-game.module'; @Module({ - imports: [AuthModule], + imports: [AuthModule, WechatGameModule], controllers: [UserController], providers: [UserService], exports: [UserService], diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index c70699f..70e7a3a 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -5,6 +5,7 @@ import { } from '@nestjs/common'; import { UserRepository } from '../auth/repositories/user.repository'; import { UserLevelProgressRepository } from '../auth/repositories/user-level-progress.repository'; +import { LevelRepository } from '../wechat-game/repositories/level.repository'; import { User } from '../auth/entities/user.entity'; import { StaminaInfoDto, @@ -15,6 +16,10 @@ import { MAX_STAMINA, RECOVER_INTERVAL_MS, } from '../../common/constants/game.constants'; +import { + findNextUncompletedLevels, + toNextLevelDto, +} from '../level/next-level.helper'; export { MAX_STAMINA, RECOVER_INTERVAL_MS }; @@ -23,6 +28,7 @@ export class UserService { constructor( private readonly userRepository: UserRepository, private readonly userLevelProgressRepository: UserLevelProgressRepository, + private readonly levelRepository: LevelRepository, ) {} /** @@ -122,17 +128,20 @@ export class UserService { } async getGameData(userId: string): Promise { - const [user, progressList] = await Promise.all([ + const [user, progressList, allLevels] = await Promise.all([ this.findUserOrThrow(userId), this.userLevelProgressRepository.findByUserId(userId), + this.levelRepository.findAllOrdered(), ]); const stamina = this.computeStamina(user); - const completedLevelIds = progressList.map((p) => p.levelId); + const completedIds = new Set(progressList.map((p) => p.levelId)); + const nextLevels = findNextUncompletedLevels(allLevels, completedIds, 1); return { user: { id: user.id, stamina }, - completedLevelIds, + completedLevelCount: completedIds.size, + nextLevel: nextLevels[0] ? toNextLevelDto(nextLevels[0]) : null, }; }