feat: 重构关卡接口
This commit is contained in:
@@ -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
|
||||
"user": {
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"stamina": { "current": 50, "max": 50, "nextRecoverAt": null }
|
||||
},
|
||||
{
|
||||
"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
|
||||
"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`
|
||||
|
||||
@@ -396,6 +386,7 @@ interface LevelListItem {
|
||||
hint2: string | null;
|
||||
hint3: string | null;
|
||||
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`
|
||||
|
||||
@@ -475,6 +484,7 @@ interface LevelListItem {
|
||||
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<string>; // 已通关关卡 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<GameData> {
|
||||
@@ -840,35 +924,7 @@ async function loadGameData(): Promise<GameData> {
|
||||
}
|
||||
```
|
||||
|
||||
### 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<LevelListItem[]> {
|
||||
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<EnterLevelResponse> {
|
||||
@@ -892,35 +949,29 @@ async function enterLevel(levelId: string): Promise<EnterLevelResponse> {
|
||||
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` 字段直接告知下一关
|
||||
|
||||
@@ -2,6 +2,7 @@ import { UserLevelProgress } from '../entities/user-level-progress.entity';
|
||||
|
||||
export interface IUserLevelProgressRepository {
|
||||
findByUserId(userId: string): Promise<UserLevelProgress[]>;
|
||||
countByUserId(userId: string): Promise<number>;
|
||||
findByUserAndLevel(
|
||||
userId: string,
|
||||
levelId: string,
|
||||
|
||||
@@ -15,6 +15,10 @@ export class UserLevelProgressRepository implements IUserLevelProgressRepository
|
||||
return this.repository.find({ where: { userId } });
|
||||
}
|
||||
|
||||
async countByUserId(userId: string): Promise<number> {
|
||||
return this.repository.count({ where: { userId } });
|
||||
}
|
||||
|
||||
async findByUserAndLevel(
|
||||
userId: string,
|
||||
levelId: string,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
39
src/modules/level/dto/next-level.dto.ts
Normal file
39
src/modules/level/dto/next-level.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<ApiResponseDto<LevelListResponseDto>> {
|
||||
const data = await this.levelService.getLevelList(user.sub);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
|
||||
@Post(':id/enter')
|
||||
@ApiOperation({
|
||||
summary: '进入关卡',
|
||||
|
||||
@@ -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<LevelListResponseDto> {
|
||||
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,
|
||||
levelId,
|
||||
timeSpent: existing.timeSpent,
|
||||
};
|
||||
}
|
||||
|
||||
firstClear = false;
|
||||
timeSpent = existing.timeSpent;
|
||||
} else {
|
||||
const progress = this.userLevelProgressRepository.create({
|
||||
userId,
|
||||
levelId,
|
||||
timeSpent: dto.timeSpent,
|
||||
});
|
||||
await this.userLevelProgressRepository.save(progress);
|
||||
this.logger.log(
|
||||
`用户 ${userId} 通关 ${levelId},用时 ${dto.timeSpent} 秒`,
|
||||
);
|
||||
firstClear = true;
|
||||
timeSpent = dto.timeSpent;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
35
src/modules/level/next-level.helper.ts
Normal file
35
src/modules/level/next-level.helper.ts
Normal file
@@ -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<string>,
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<GameDataResponseDto> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user