新建项目

This commit is contained in:
2026-07-29 10:50:33 +08:00
parent bfd3841cbd
commit 4f69b859a8
49 changed files with 5565 additions and 0 deletions

84
README.md Normal file
View File

@@ -0,0 +1,84 @@
# 藏猫猫计划
一个面向朋友聚会、校园活动、亲子活动和团队团建的线下真人捉迷藏微信小程序原型。
当前交付版本:`0.1.0-mvp`。该版本适合单设备产品演示和用户访谈,不适合直接组织真实多人户外活动。
当前版本使用微信小程序原生 JavaScript 开发,内置本地模拟数据,无需后端即可跑通:
- 创建游戏与配置规则
- 通过房间码加入游戏
- 候场与身份分配
- 准备阶段和游戏倒计时
- 模糊位置线索、雷达与静默技能
- 动态口令抓捕
- 游戏结算与战报
## 运行方式
1. 安装并打开微信开发者工具。
2. 选择“导入项目”,目录选择本仓库。
3. AppID 可使用测试号;仓库中的 `project.config.json` 默认使用游客 AppID。
4. 编译后从首页开始体验。
推荐使用演示房间码 `731204`,或创建房间后由系统自动补充模拟玩家。
## 自动检查
本项目不依赖 npm。安装 Node.js 后可直接运行:
```bash
node tests/run-all.js
```
自动检查覆盖本地游戏服务、页面事件绑定和发布文件完整性。微信特有能力仍需按照 [真机验收清单](docs/DEVICE_TEST_CHECKLIST.md) 在开发者工具和真机上确认。
## 交付文档
- [一周实施规划](docs/ONE_WEEK_MVP_PLAN.md)
- [5 分钟演示脚本](docs/DEMO_GUIDE.md)
- [测试报告](docs/TEST_REPORT.md)
- [真机验收清单](docs/DEVICE_TEST_CHECKLIST.md)
- [版本说明与已知限制](docs/RELEASE_NOTES.md)
原型中的房间、玩家和游戏状态保存在本机 `wx.setStorageSync` 中。创建房间后,候场页会自动加入模拟玩家,便于单人体验完整流程。
## 目录
```text
.
├─ docs/
│ ├─ PRD.md 产品需求文档
│ ├─ TECHNICAL_DESIGN.md 技术设计与后端演进方案
│ ├─ ONE_WEEK_MVP_PLAN.md 一周实施规划与完成记录
│ ├─ DEMO_GUIDE.md 5 分钟演示脚本
│ ├─ TEST_REPORT.md 自动回归报告
│ ├─ DEVICE_TEST_CHECKLIST.md 真机验收清单
│ └─ RELEASE_NOTES.md 版本说明与已知限制
├─ pages/
│ ├─ home/ 首页
│ ├─ create/ 创建游戏
│ ├─ join/ 加入房间
│ ├─ lobby/ 游戏候场
│ ├─ game/ 游戏进行中
│ ├─ result/ 游戏结算
│ └─ rules/ 安全规则
├─ services/
│ └─ game-service.js 本地模拟游戏服务
├─ tests/ 自动回归与发布检查
└─ utils/
├─ constants.js 模式与规则常量
└─ format.js 通用格式化方法
```
## 下一阶段
进入真实多人测试前,需要将 `services/game-service.js` 替换为云开发或自有后端,并补齐:
- 微信登录和服务端身份校验
- WebSocket 房间状态同步
- 授权后的真实定位上报
- 服务端计时、口令签名和防作弊
- 内容安全、未成年人保护和数据删除机制
详细说明见 [产品需求](docs/PRD.md) 和 [技术设计](docs/TECHNICAL_DESIGN.md)。

15
app.js Normal file
View File

@@ -0,0 +1,15 @@
App({
globalData: {
version: "0.1.0-mvp",
user: {
id: "player-me",
name: "我",
avatar: "我"
}
},
onLaunch() {
const service = require("./services/game-service");
service.ensureSeedData();
}
});

21
app.json Normal file
View File

@@ -0,0 +1,21 @@
{
"pages": [
"pages/home/home",
"pages/create/create",
"pages/join/join",
"pages/lobby/lobby",
"pages/game/game",
"pages/result/result",
"pages/rules/rules"
],
"window": {
"navigationBarTitleText": "藏猫猫计划",
"navigationBarBackgroundColor": "#F7F4EC",
"navigationBarTextStyle": "black",
"backgroundColor": "#F7F4EC",
"backgroundTextStyle": "dark"
},
"style": "v2",
"sitemapLocation": "sitemap.json",
"lazyCodeLoading": "requiredComponents"
}

218
app.wxss Normal file
View File

@@ -0,0 +1,218 @@
page {
--ink: #17231d;
--muted: #68726c;
--paper: #f7f4ec;
--card: #fffdf7;
--green: #1d6b4f;
--green-dark: #124a37;
--lime: #d9ef72;
--orange: #ef8f4a;
--red: #d9554d;
background: var(--paper);
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 30rpx;
}
view,
text,
button,
input,
slider,
switch {
box-sizing: border-box;
}
button {
display: flex;
align-items: center;
justify-content: center;
border-radius: 24rpx;
font-weight: 700;
line-height: normal;
}
button::after {
border: none;
}
.page {
min-height: 100vh;
padding: 36rpx 32rpx 64rpx;
}
.eyebrow {
color: var(--green);
font-size: 22rpx;
font-weight: 800;
letter-spacing: 4rpx;
text-transform: uppercase;
}
.title {
margin-top: 12rpx;
font-size: 54rpx;
font-weight: 900;
line-height: 1.15;
}
.subtitle {
margin-top: 16rpx;
color: var(--muted);
font-size: 28rpx;
line-height: 1.7;
}
.card {
margin-top: 24rpx;
padding: 28rpx;
background: var(--card);
border: 2rpx solid rgba(23, 35, 29, 0.08);
border-radius: 28rpx;
box-shadow: 0 12rpx 36rpx rgba(35, 46, 40, 0.06);
}
.card-title {
font-size: 32rpx;
font-weight: 800;
}
.primary-button {
height: 100rpx;
padding: 0 32rpx;
background: var(--green);
color: #fff;
font-size: 32rpx;
}
.secondary-button {
height: 94rpx;
padding: 0 32rpx;
background: var(--lime);
color: var(--ink);
font-size: 30rpx;
}
.ghost-button {
height: 86rpx;
background: transparent;
border: 2rpx solid rgba(23, 35, 29, 0.16);
color: var(--ink);
font-size: 28rpx;
}
.danger-button {
height: 86rpx;
background: #fff0ee;
color: var(--red);
font-size: 28rpx;
}
.button-stack {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-top: 32rpx;
}
.row {
display: flex;
align-items: center;
}
.between {
justify-content: space-between;
}
.pill {
display: inline-flex;
align-items: center;
min-height: 48rpx;
padding: 0 18rpx;
border-radius: 999rpx;
background: #e7eee8;
color: var(--green-dark);
font-size: 23rpx;
font-weight: 700;
}
.pill.orange {
background: #fff0df;
color: #9c531e;
}
.pill.red {
background: #ffe8e5;
color: #a63b35;
}
.section-label {
margin: 40rpx 0 16rpx;
font-size: 24rpx;
font-weight: 800;
letter-spacing: 2rpx;
color: var(--muted);
}
.muted {
color: var(--muted);
}
.divider {
height: 2rpx;
margin: 24rpx 0;
background: rgba(23, 35, 29, 0.08);
}
.safe-bottom {
height: 40rpx;
}
.state-page {
display: flex;
min-height: 80vh;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 48rpx;
text-align: center;
}
.state-icon {
display: flex;
align-items: center;
justify-content: center;
width: 100rpx;
height: 100rpx;
background: #e7eee8;
border-radius: 32rpx;
color: var(--green);
font-size: 50rpx;
font-weight: 900;
}
.state-title {
margin-top: 28rpx;
font-size: 36rpx;
font-weight: 900;
}
.state-copy {
max-width: 560rpx;
margin-top: 14rpx;
color: var(--muted);
font-size: 25rpx;
line-height: 1.65;
}
.state-action {
width: 320rpx;
margin-top: 36rpx;
}
.empty-inline {
padding: 34rpx 24rpx;
color: var(--muted);
font-size: 23rpx;
text-align: center;
}

66
docs/DEMO_GUIDE.md Normal file
View File

@@ -0,0 +1,66 @@
# 藏猫猫计划 MVP5 分钟演示脚本
版本0.1.0-mvp
## 演示前准备
1. 在微信开发者工具中导入项目并编译。
2. 清除 Storage重新编译确认演示房间 `731204` 自动生成。
3. 从首页开始录屏或讲解。
## 0:000:40 产品定位
- 展示首页和“怎么玩”。
- 说明产品是线下真人追逃的数字主持人,强调安全、少看屏幕和模糊信息。
- 打开安全规则,展示无身体接触、场地边界和安全退出原则。
## 0:401:40 创建一局
- 点击“创建一场游戏”。
- 演示经典/感染模式切换及主要规则设置。
- 勾选安全承诺并创建房间。
- 在候场页展示房间码、分享入口、规则摘要和模拟玩家。
## 1:402:30 候场与开局
- 展示全员准备状态和至少 4 人的开局条件。
- 点击“分配身份并开始”。
- 讲解寻找者和躲藏者的身份任务差异。
- 等待演示加速后的准备倒计时进入正式追逃。
## 2:303:50 核心玩法
- 使用一次雷达,展示技能使用状态会持久保存。
- 在目标列表选择一名躲藏者。
- 点击演示口令并提交抓捕。
- 感染模式下指出目标会转为寻找者;经典模式下则进入被抓状态。
- 展示实时事件、剩余人数和 30 秒动态口令机制。
## 3:504:30 安全与房主管理
- 演示暂停和继续,说明暂停期间倒计时冻结。
- 打开“安全与退出”,展示联系房主和安全退出流程。
- 强调正式版不会依靠身体接触完成抓捕。
## 4:305:00 结算与复玩
- 连续完成抓捕,或使用“提前结束”进入战报。
- 展示胜负、实际用时、事件数、角色转化和稳定排名。
- 点击“按原规则再来一局”,确认规则保留、本局状态清空。
## 备用路径
如果创建流程中的缓存状态影响演示:
1. 返回首页。
2. 点击“输入房间码加入”。
3. 使用演示房间 `731204`
4. 演示房间会自动准备并允许当前用户代行房主操作。
## 演示口径限制
- 当前版本是本地模拟 MVP不是真实多设备房间。
- 雷达、位置线索和安全边界是模拟结果,没有采集真实位置。
- 动态口令在本地按 30 秒变化,但没有服务端签名和距离校验。
- 分享卡片可以携带房间码,但不同设备之间不会同步同一份本地数据。

View File

@@ -0,0 +1,63 @@
# 第 6 天:微信开发者工具与真机验收清单
执行状态:待连接微信开发者工具和真实设备执行。
代码侧检查:已完成。
## 1. 基础环境
- [ ] 使用微信开发者工具导入项目,编译无 JS、WXML、WXSS 错误。
- [ ] 分别使用 iOS 和 Android 真机预览;如只有一台设备,记录设备型号和微信版本。
- [ ] 清空缓存后首次进入首页,页面无白屏或布局跳动。
## 2. 核心流程
- [ ] 创建经典模式房间,完成候场、开局、抓捕、结算和复玩。
- [ ] 创建感染模式房间,确认被抓玩家转为寻找者。
- [ ] 使用演示房间 `731204` 可单人完成全流程。
- [ ] 从分享卡片进入时能够自动带入房间码并加入。
## 3. 页面与屏幕适配
- [ ] 小屏设备上首页标题、倒计时、三个统计卡片不溢出。
- [ ] 16 字房间名、10 字昵称在候场和战报中不挤压按钮。
- [ ] 玩家列表达到人数上限时可以正常滚动。
- [ ] 三个安全操作按钮布局完整,没有遮挡底部安全区。
- [ ] 空事件、无效房间和战报丢失时显示可返回首页的恢复页。
## 4. 微信能力
- [ ] 复制房间码成功,并显示成功反馈。
- [ ] 候场分享卡片标题和路径正确,接收方可进入加入页。
- [ ] 战报分享卡片标题正确,接收方进入首页。
- [ ] 阶段开始、线索广播、技能和抓捕的震动反馈符合预期。
- [ ] 用户关闭系统震动时,操作不会报错或阻塞流程。
## 5. 生命周期与弱网模拟
- [ ] 准备阶段切到后台 10 秒后返回,按原时间进入正式游戏。
- [ ] 正式游戏切到后台后返回,倒计时没有重新开始。
- [ ] 暂停游戏后切换前后台,剩余时间保持冻结。
- [ ] 连续快速点击创建、加入、开始、抓捕和复玩,不产生重复数据。
- [ ] 开发者工具切换离线后,本地原型核心流程仍可运行。
## 6. 安全与权限
- [ ] 非房主界面不显示暂停和提前结束按钮。
- [ ] 安全退出确认后返回首页,不能继续进入已退出的当前房间。
- [ ] 联系房主显示原型能力边界说明。
- [ ] 躲藏者只能看到自己的口令。
- [ ] 非演示模式下寻找者不显示自动填入口令入口。
## 7. 结果记录
测试完成后记录:
- 测试日期:
- 开发者工具版本:
- 微信基础库版本:
- iOS 设备与微信版本:
- Android 设备与微信版本:
- 阻塞缺陷:
- 其他问题:
- 是否允许进入第 7 天交付回归:

434
docs/ONE_WEEK_MVP_PLAN.md Normal file
View File

@@ -0,0 +1,434 @@
# 藏猫猫计划:一周 MVP 设计与实施规划书
版本1.0
周期7 天
目标形态:微信小程序可演示 MVP
规划日期2026-07-27
## 1. 项目目标
在一周内交付一个能够在微信开发者工具和真机上稳定运行的小程序,让一名用户无需后端、无需真实好友在线,即可完整体验:
```text
创建/加入房间 → 候场 → 身份分配 → 准备倒计时
→ 正式追逃 → 技能/线索/口令抓捕 → 自动结算 → 再来一局
```
本周期的核心不是上线真实多人服务,而是完成一个流程闭环、规则清楚、状态可靠、适合演示和用户访谈的产品版本,为下一阶段熟人多人内测提供依据。
### 1.1 成功标准
- 新用户能在 2 分钟内创建或加入一局游戏。
- 单人在模拟玩家辅助下可以走完全部游戏流程。
- 经典模式和感染模式均能正确完成身份、抓捕与结算。
- 页面刷新、前后台切换后,当前房间和游戏状态可以恢复。
- 关键错误有明确提示,不出现无法继续操作的死路。
- 安全规则在创建、候场和游戏过程中均有可见入口。
- 核心流程通过开发者工具和至少 1 台真机验证。
## 2. 本周范围
### 2.1 必须交付P0
1. 首页
- 创建游戏、加入房间、安全规则入口。
- 继续上一场未完成游戏。
2. 创建游戏
- 房间名称、游戏模式、时长、准备时间、人数、寻找者数量、线索间隔、场地半径和技能开关。
- 基础参数校验和安全承诺。
3. 加入与候场
- 六位房间码和演示房间。
- 昵称确认、房间预览、模拟玩家补充。
- 玩家准备、房主开局、规则摘要和邀请入口。
4. 游戏主流程
- 准备阶段与正式游戏阶段。
- 寻找者/躲藏者差异化界面。
- 周期性模糊线索、雷达、静默和四位抓捕口令。
- 经典模式淘汰和感染模式转化。
- 超时或全部抓捕后的自动结算。
5. 结算与复玩
- 获胜阵营、玩家表现、抓捕数和参与结果。
- 按原规则再来一局、返回首页和分享入口。
6. 稳定性与体验
- 本地数据异常时的兜底处理。
- 防止重复开局、重复结算和重复抓捕。
- 页面卸载时清理计时器。
- 空状态、按钮禁用态和错误提示。
### 2.2 尽量完成P1
- 游戏暂停与继续。
- 房主提前结束游戏。
- 模拟玩家自动产生少量抓捕或事件,使躲藏者视角也能完成演示。
- 技能冷却或剩余次数展示。
- 结算称号根据实际数据生成。
- 简单的游戏历史记录。
### 2.3 本周不做
- 微信正式登录和账号体系。
- 多设备实时房间同步、WebSocket 和断线重连。
- 真实地图选区、定位上报和地理围栏。
- 服务端动态口令、距离验证及完整防作弊。
- 附近陌生人组局、聊天、商城、积分和排行榜。
- 运营后台、举报审核和复杂未成年人体系。
- 正式发布所需的完整隐私合规与安全备案工作。
上述能力不能用前端模拟结果包装为真实能力。界面中涉及位置和防作弊的内容应明确标注为原型演示。
## 3. 用户与业务设计
### 3.1 核心用户
- 房主:配置规则、组织玩家、开始和结束游戏。
- 普通玩家:加入房间、准备、获得身份并参与游戏。
- 演示用户:单人使用模拟队友验证全部流程。
### 3.2 核心状态机
房间状态:
```text
waiting → preparing → playing → paused可选 → finished
```
玩家状态:
```text
idle ↔ ready
ready → hiding / seeking
hiding → caught经典模式
hiding → seeking感染模式
```
必须遵守的状态规则:
- 只有 `waiting` 状态允许加入和切换准备。
- 至少 4 名玩家且寻找者少于总人数时才允许开局。
- 只有 `preparing` 可以进入 `playing`
- 只有 `playing` 可以使用技能和提交抓捕。
- `finished` 后不再接受任何游戏操作。
- 重开时清空角色、抓捕、技能和计时数据,但保留房间设置与玩家。
### 3.3 胜负规则
- 经典模式:全部躲藏者被抓,寻找者胜;倒计时结束仍有躲藏者,躲藏者胜。
- 感染模式:被抓者转为寻找者;最后一个躲藏者被转化后寻找者胜;倒计时结束仍有人存活则躲藏者胜。
## 4. 技术设计
### 4.1 本周架构
```text
微信小程序页面 pages/*
本地领域服务 services/game-service.js
wx Storage房间、玩家、事件、结果
```
继续使用原生 JavaScript不新增 npm 依赖,不在一周期限内引入后端。页面不得直接修改房间对象,所有业务状态变化统一经过游戏服务,以便后续替换为云函数或 HTTP API。
### 4.2 模块职责
- `pages/*`:页面展示、用户输入、导航和生命周期管理。
- `services/game-service.js`:房间状态机、角色分配、技能、抓捕、结算和持久化。
- `utils/constants.js`:模式、默认规则、演示数据和枚举。
- `utils/format.js`:倒计时、时间和通用数据处理。
- `wx Storage`:保存当前版本的房间快照和当前房间索引。
### 4.3 本周重点改造
1. 将所有操作增加房间状态和角色权限校验。
2. 为房间增加数据版本或 schema 版本,避免旧缓存导致页面崩溃。
3. 让线索间隔使用创建时配置;演示加速通过独立配置控制。
4. 将技能使用次数保存到房间玩家数据,而不是只存在页面内存。
5. 抓捕时明确目标,或在演示模式中明确提示“当前模拟目标”。
6. 将动态口令绑定目标和时间窗口,至少在本地实现定时变化与重复使用限制。
7. 统一使用 `phaseEndsAt` 计算倒计时,恢复页面时不重新开始计时。
8. 结算形成稳定快照,防止当前时间变化造成排名变化。
9. 限制事件数量,并区分全员、寻找者和个人可见事件。
10. 为不存在、损坏或已经结束的房间提供恢复路径。
### 4.4 为后端演进预留的接口
页面层保持以下语义接口:
- `createRoom(payload)`
- `getRoom(code)`
- `joinRoom(code, player)`
- `toggleReady(code, playerId)`
- `startGame(code)`
- `useSkill(code, skill)`
- `capture(code, credential)`
- `pauseGame(code)` / `resumeGame(code)`
- `finishGame(code, reason)`
- `resetRoom(code)`
下一阶段可将接口内部替换为云函数或网络请求,而无需重写页面流程。
## 5. 一周实施排期
### 第 1 天:范围冻结与主流程体检
状态:**已完成2026-07-27**
- [x] 冻结 P0 范围和验收清单。
- [x] 按现有代码和模拟运行完整走查创建、加入、候场、开局、技能、抓捕、结算与复玩流程。
- [x] 记录创建、加入、开局、技能、抓捕、结算和复玩的缺陷。
- [x] 梳理房间与玩家状态字段,补充缺省值和兼容策略。
- [x] 建立本地游戏服务基线测试。
- [x] 产出缺陷清单、状态模型和可稳定启动的基线版本。
第 1 天实施结果:
- 房间数据初始新增 `schemaVersion`;第 4 天因技能和口令状态扩展已升级为 `schemaVersion: 2`
- 启动时自动补齐旧缓存中的房间设置、玩家字段、事件字段和时间字段。
- 非对象顶层缓存、无效房间和非法房间码会被安全忽略或清理。
- 缺失或非法的房间/玩家状态会回退到有效状态,避免页面直接崩溃。
- 事件历史统一限制为最近 20 条。
- 新增本地服务回归测试,覆盖旧数据迁移、损坏缓存、完整游戏流程和复玩重置。
主流程体检缺陷清单:
| 编号 | 优先级 | 模块 | 问题 | 计划处理 |
| --- | --- | --- | --- | --- |
| D1-01 | P0 | 创建/开局 | 创建时可能出现寻找者数量不小于人数上限 | **已完成(第 2 天)** |
| D1-02 | P0 | 候场 | 普通房间开局未校验房主权限和全员准备状态 | **已完成(第 2 天)** |
| D1-03 | P0 | 状态机 | 重复开局、重复结束等状态迁移保护不足 | **已完成(第 3 天)** |
| D1-04 | P0 | 计时 | 线索固定为 15 秒,未使用房间配置 | **已完成(第 3 天)** |
| D1-05 | P0 | 技能 | 技能使用状态只在页面内存中,重新进入后可重复使用 | **已完成(第 4 天)** |
| D1-06 | P0 | 抓捕 | 口令不随时间变化,且无法选择明确目标 | **已完成(第 4 天)** |
| D1-07 | P0 | 抓捕 | 抓捕发起者角色校验不足 | **已完成(第 4 天)** |
| D1-08 | P1 | 结算 | 同分排名使用当前时间参与比较,结果可能变化 | **已完成(第 5 天)** |
| D1-09 | P1 | 安全 | 安全退出弹窗未真正更新玩家或房间状态 | **已完成(第 5 天)** |
| D1-10 | P1 | 演示 | 躲藏者视角缺少自动事件,单人演示难以自然结束 | 第 5 天(有余量时) |
说明:微信开发者工具和真实设备上的视觉、震动、分享及前后台能力仍按第 6 天计划执行真机验收;第 1 天完成的是代码路径走查与本地服务模拟回归。
### 第 2 天:创建、加入与候场
状态:**已完成2026-07-27**
- [x] 完善创建参数校验,处理寻找者数量与人数上限冲突。
- [x] 完善无房间、房间已满、已开局、已取消和已结束提示。
- [x] 修复准备状态、房主权限和开局条件。
- [x] 确保分享参数和演示房间可进入。
- [x] 扩充创建、加入与候场服务回归测试。
- [x] 产出从首页到成功开局的稳定链路。
第 2 天实施结果:
- 创建房间由服务层统一校验名称、模式、游戏时间、准备时间、人数上限、寻找者数量、线索间隔和活动半径。
- 寻找者数量必须小于人数上限;页面切换人数上限时会自动调整冲突选项。
- 六位码格式错误、房间不存在、房间已满、游戏已开始、已结束或已取消分别显示明确提示。
- 只有房主可以开始普通房间;演示房间保留当前用户代操作能力。
- 开局前统一校验至少 4 人、寻找者少于当前玩家数以及所有玩家已准备。
- 游戏开始后禁止继续修改准备状态或重复开始。
- 通过分享卡片携带的 `code` 参数仍可直接预览并加入;演示房间加入者自动准备,保证单人演示链路可启动。
### 第 3 天:游戏状态机与计时
状态:**已完成2026-07-27**
- [x] 加固 `waiting → preparing → playing → finished` 状态迁移。
- [x] 统一阶段时间和页面恢复逻辑。
- [x] 使用规则配置驱动正式线索间隔,同时保留独立的演示加速配置。
- [x] 处理前后台切换、页面重复进入和重复结算。
- [x] 扩充状态迁移及正式/演示计时回归测试。
- [x] 产出可靠运行的准备阶段和正式游戏倒计时。
第 3 天实施结果:
- 开始、进入追逃、线索广播、技能使用和结束游戏均增加合法房间状态校验。
- 禁止重复开局、重复进入追逃阶段、非游戏阶段广播线索以及重复结算。
- 房间新增独立 `runtime` 配置;当前本地 MVP 明确启用演示加速,不再把加速数值散落在业务代码中。
- 关闭演示加速后,准备时长、游戏时长和线索间隔均严格使用创建房间时的分钟配置。
- 阶段切换以原定 `phaseEndsAt` 作为下一阶段开始时间;应用回到前台时可以补算真实经过时间,不会重新开始倒计时。
- 游戏页进入后台时停止本地轮询,回到前台立即恢复并校准状态,避免重复计时器。
- 演示模式保持准备分钟按秒压缩、正式游戏最长 180 秒、线索每 15 秒一次,便于单人快速走查。
### 第 4 天:身份、技能与抓捕
状态:**已完成2026-07-27**
- [x] 验证不同人数和寻找者配置下的角色数量合法性。
- [x] 完成经典/感染两种抓捕行为。
- [x] 技能次数持久化,技能关闭时隐藏入口。
- [x] 改造本地动态口令的目标绑定、时效与重复使用限制。
- [x] 补充寻找者和躲藏者两种视角及权限走查。
- [x] 扩充身份、技能、抓捕与模式分支回归测试。
- [x] 产出可重复验证的核心玩法闭环。
第 4 天实施结果:
- 玩家新增持久化的 `skillsUsed``silentPending`;重新进入页面后仍能识别技能已使用,重开房间时统一清零。
- 本局关闭技能时不展示技能入口;开启技能时每名玩家每局只能使用一次对应身份技能。
- 寻找者只能使用雷达,仍在躲藏的玩家只能使用静默;被抓或状态异常的玩家不能使用技能。
- 寻找者抓捕前必须明确选择一名仍在躲藏的目标;躲藏者页面只显示属于自己的口令。
- 四位口令绑定房间、目标玩家和 30 秒时间窗,到期自动变化。
- 已使用口令保存在房间数据中并限制最近 50 条,不能重复提交。
- 抓捕接口校验发起者必须是 `seeking` 状态的寻找者,目标必须是 `hiding` 状态的躲藏者。
- 经典模式下目标转为 `caught` 并进入观战提示;感染模式下目标转为 `seeker/seeking` 并继续参与。
- 最后一名躲藏者被抓后仍自动结算为寻找者胜利。
### 第 5 天:结算、安全与异常流程
状态:**已完成2026-07-27**
- [x] 固化胜负判断、排名和结果文案。
- [x] 完善再来一局的数据重置。
- [x] 补充暂停/继续、安全退出、提前结束及异常操作提示。
- [x] 检查并收紧敏感信息在错误角色界面的展示。
- [x] 扩充暂停、退出、权限、结算快照和重开回归测试。
- [x] 产出可以收口的正常流程和主要异常流程。
第 5 天实施结果:
- 游戏结束时生成不可变 `resultSnapshot`,固定玩家排名、初始/最终身份、抓捕数、存活时间、实际用时和事件数量。
- 排名依次按抓捕数、存活时间、加入时间和玩家 ID 排序,不再依赖打开结果页时的当前时间。
- 感染模式战报可展示“躲藏者→寻找者”,避免只展示最终角色造成误解。
- 再来一局会清理阶段计时、暂停信息、口令使用记录、技能、抓捕、角色和结算快照,同时保留房间规则与玩家。
- 房主可以暂停和继续游戏;暂停期间游戏倒计时与下一次线索倒计时均冻结。
- 房主可以确认后提前结束并生成战报;非房主不能执行暂停、继续或提前结束。
- “安全退出”会实际把玩家标记为 `quit`、清除当前房间入口并记录安全事件;最后一名躲藏者退出时自动结算。
- “联系房主”在原型阶段明确提示使用约定的电话或微信,不伪造即时通讯能力。
- 静默事件不再公开具体使用者姓名;躲藏者仍只看到自己的口令,寻找者的演示口令入口仅在明确的演示加速模式展示。
### 第 6 天:体验打磨与真机测试
状态:**功能实现与代码检查已完成2026-07-27真机人工验收待连接设备执行**
- [x] 统一加载态、空状态、禁用态、Toast 和弹窗文案。
- [x] 完成小屏、长昵称、人数上限和事件列表的代码侧适配。
- [x] 增加页面 WXML 事件与 JS 方法绑定契约检查。
- [x] 防止创建、加入、开局、抓捕和复玩重复提交。
- [x] 形成震动、分享、剪贴板、前后台切换和本地缓存真机验收清单。
- [ ] 在微信开发者工具和真机执行清单(需要人工连接真实设备)。
- [x] 代码侧未发现阻塞级与高优先级缺陷。
- [x] 产出候选演示版本代码。
第 6 天实施结果:
- 加入页会在输入完整房间码后展示明确的不存在、已满、已开始、已结束或已取消状态,并只在允许加入时启用按钮。
- 创建、加入、开局、抓捕和复玩增加提交中状态,防止快速重复点击。
- 候场、游戏和战报页面在房间数据丢失时不再白屏,统一显示说明和返回首页入口。
- 游戏事件为空时展示空状态;已取消的当前房间不会继续出现在首页。
- 房间码复制成功后提供明确反馈。
- 候场玩家昵称和战报昵称增加单行省略,房间名允许安全换行;游戏头部增加小屏适配。
- 三个安全管理按钮重新布局,避免第三个按钮挤压。
- 新增页面绑定契约测试,自动检查 WXML 引用的事件处理函数是否在对应页面 JS 中存在。
- 新增独立真机验收清单 `docs/DEVICE_TEST_CHECKLIST.md`,覆盖双端屏幕、震动、分享、剪贴板、前后台、快速点击和安全权限。
限制说明:当前执行环境无法启动微信开发者工具或连接用户真机,因此不能把真机清单虚假标记为通过。完成清单后才可将第 6 天整体状态更新为“全部验收完成”。
### 第 7 天:回归、演示与交付
状态:**代码与交付材料已完成2026-07-27微信开发者工具及真机发布门禁待人工执行**
- [x] 按验收用例执行游戏服务、页面契约和发布就绪自动回归。
- [x] 在模拟 Storage 中清理缓存并验证首次启动、演示数据补种和完整流程。
- [x] 准备 5 分钟演示脚本和已知限制说明。
- [x] 更新项目导入、自动检查和运行文档。
- [x] 将 MVP 版本冻结为 `0.1.0-mvp`
- [x] 整理下一阶段多人化进入条件。
- [x] 产出测试报告、演示说明、版本说明和后续清单。
- [ ] 在微信开发者工具完成最终编译并执行真机发布门禁(需要人工连接设备)。
第 7 天实施结果:
- 新增统一自动回归入口 `node tests/run-all.js`
- 新增发布就绪检查,验证小程序页面注册、页面四件套文件、项目类型、版本号和交付文档完整性。
- 自动回归结果为游戏服务、页面事件契约、发布就绪检查全部通过。
- `app.globalData.version` 固定为 `0.1.0-mvp`README 同步标注版本用途和安全边界。
- 新增 5 分钟演示脚本,覆盖定位说明、创建、候场、核心玩法、安全管理、结算和复玩。
- 新增测试报告,明确自动覆盖范围、未覆盖的微信运行时能力和发布判断。
- 新增版本说明,集中记录已交付能力、已知限制和下一阶段进入条件。
- 当前版本可用于单设备产品演示和用户访谈,不允许描述为可直接承载真实多人户外活动的生产版本。
自动回归结果:
```text
game-service tests passed
page contract tests passed
release readiness tests passed
all automated checks passed
```
最终发布门禁:完成 `docs/DEVICE_TEST_CHECKLIST.md` 后,才可将第 6、7 天的设备验收项标记为完成,并决定是否交付真机演示。
## 6. 验收用例
### 6.1 主流程
1. 用户创建感染模式房间,模拟玩家补足至可开局人数。
2. 房主开始游戏,系统正确分配指定数量的寻找者。
3. 准备倒计时结束后自动进入正式追逃。
4. 寻找者使用雷达并通过有效口令完成抓捕。
5. 被抓躲藏者转化为寻找者,人数统计同步变化。
6. 最后一名躲藏者被抓后自动进入寻找者胜利结算。
7. 用户按原规则重开,所有本局数据正确清零。
### 6.2 分支流程
- 经典模式中被抓者保持被抓状态,不转为寻找者。
- 游戏超时且仍有存活者时,躲藏者获胜。
- 技能关闭时不能使用雷达或静默。
- 错误、过期或已使用口令不能完成抓捕。
- 非房主不能开始或提前结束普通房间。
- 不足 4 人、寻找者数量非法时不能开始。
### 6.3 恢复与异常
- 关闭并重新进入小程序后可以继续当前房间。
- 游戏页面退出再进入后倒计时基于原结束时间恢复。
- 房间数据不存在或损坏时返回首页并给出提示。
- 快速重复点击开始、抓捕和结束不会产生重复结果。
- 房间结束后不能继续使用技能或提交抓捕。
## 7. 质量要求
- P0 流程无阻塞级缺陷。
- 页面 JS 无未捕获异常WXML 无明显渲染警告。
- 核心按钮在常见手机尺寸下可见且易于单手点击。
- 重要操作有文字反馈,关键阶段变化可配合震动。
- 本地房间事件限制数量,避免数据无限增长。
- 所有演示能力与真实能力边界清晰,不误导测试用户。
## 8. 人力与协作建议
按 1 名熟悉微信小程序的开发者估算,一周可完成本规划中的 P0P1 仅在 P0 提前完成时进入。建议每天结束前进行一次 2030 分钟的完整流程回归,不将全部测试集中到最后一天。
如果有第二名成员,优先分工如下:
- 开发者:状态机、页面逻辑、数据兼容和缺陷修复。
- 产品/测试:规则确认、真机用例、文案、安全流程和演示材料。
## 9. 风险与应对
| 风险 | 影响 | 应对方式 |
| --- | --- | --- |
| 一周内加入真实多人同步 | 架构和联调工作不可控 | 本周固定使用本地模拟,下一阶段单独建设后端 |
| 原型时间压缩与正式规则混杂 | 测试结果失真 | 增加明确的演示加速配置,不直接改业务规则 |
| 页面直接依赖本地数据结构 | 后续迁移成本增加 | 状态变更统一收口到服务层 |
| 动态口令被误认为安全机制 | 产生错误安全预期 | 明确标注本地演示,正式版必须服务端校验 |
| 真机能力验证太晚 | 最后一天出现兼容问题 | 第 3 天开始至少每天一次真机回归 |
| 持续增加玩法 | 核心闭环无法按时稳定 | P0 冻结,新增需求统一进入下一版本 |
## 10. 交付物
- 可导入微信开发者工具并正常编译的小程序源码。
- 可单人完成全流程的经典模式与感染模式。
- 演示房间和模拟玩家数据。
- 产品需求、技术设计和本实施规划书。
- 核心验收用例与测试结果。
- 已知限制和下一阶段多人化改造清单。
## 11. 下一阶段建议
一周 MVP 完成后,用 35 场用户访谈或现场模拟验证规则是否易懂、线索节奏是否合理、玩家是否频繁看手机以及安全提示是否有效。确认玩法成立后,再进入 24 周的熟人多人版建设,依次接入微信登录、云端房间、实时同步、服务端倒计时、定位与抓捕校验。

369
docs/PRD.md Normal file
View File

@@ -0,0 +1,369 @@
# 藏猫猫计划:产品需求文档
版本0.1
阶段MVP 可交互原型
产品形态:微信小程序
## 1. 产品概述
### 1.1 产品定位
“藏猫猫计划”是一款帮助用户快速组织线下真人追逃游戏的小程序。它通过房间邀请、身份分配、地图边界、模糊位置线索、抓捕确认和自动结算,承担线下游戏的主持人角色。
产品不鼓励玩家持续盯着手机,而是利用语音、震动、倒计时和少量关键交互,让现实空间成为主要游戏场景。
一句话表达:
> 选择一块场地,邀请几个朋友,现实世界立刻变成游戏地图。
### 1.2 目标用户
- 朋友聚会:希望快速开始一场有互动感的户外游戏。
- 校园社团:需要低成本组织多人活动。
- 亲子家庭:希望进行范围可控的户外探索。
- 企业团建:需要可配置、可复盘的团队项目。
- 景区与营地:希望将场地包装成互动游戏内容。
### 1.3 核心价值
- 两分钟内完成组局,降低组织成本。
- 自动处理分队、倒计时、线索和结算。
- 通过有限位置信息保留躲藏和推理乐趣。
- 用安全边界、越界提醒和紧急集合降低户外风险。
- 通过战报和称号促进复玩与社交传播。
### 1.4 产品原则
1. 安全高于胜负。
2. 模糊信息高于精确导航。
3. 线下互动高于屏幕操作。
4. 每位玩家尽量持续参与,减少被淘汰后的等待。
5. 基础规则公平,付费内容不直接提供竞技优势。
## 2. 游戏核心循环
```text
创建房间 → 邀请好友 → 候场确认 → 分配身份
躲藏准备 → 正式追逃 → 线索与技能 → 抓捕确认
阵营结算 → 趣味战报 → 分享/再来一局
```
一次标准游戏建议配置:
- 人数612 人
- 场地:公园、校园、营地等低车流开放区域
- 半径300800 米
- 时长3045 分钟
- 躲藏准备35 分钟
- 位置线索:每 5 分钟一次,只展示网格或大致区域
## 3. 游戏模式
### 3.1 MVP 模式
#### 经典模式
- 玩家分为寻找者和躲藏者。
- 寻找者在规定时间内抓到全部躲藏者即获胜。
- 任意躲藏者存活至倒计时结束,则躲藏阵营获胜。
- 被抓玩家进入观战状态,不再显示其他躲藏者的实时信息。
#### 感染模式
- 初始仅有少量寻找者。
- 躲藏者被抓后转化为寻找者并继续参与。
- 最后一名未被抓的玩家获得“最终幸存者”称号。
- 推荐作为默认模式,因为玩家被抓后无需长时间等待。
### 3.2 后续模式
- 占点模式:双方争夺地图中的虚拟据点。
- 护送模式:护送目标到达终点,另一方负责拦截。
- 宝藏模式:结合地图线索、二维码和现实任务点。
- 暗号模式:通过线下对话或动作确认队友身份。
- 伪装者模式:队伍中存在秘密身份和隐藏任务。
- 亲子模式:小范围、短时长、监护人可见全员位置。
- 自定义剧本:主办方配置角色、任务、道具与胜利条件。
## 4. 角色和能力
### 4.1 躲藏者
- 目标:在时间结束前保持未被抓状态。
- 可见信息:剩余时间、个人状态、安全边界、公开事件。
- 基础技能“静默”:跳过一次周期性位置暴露。
- 后续能力:假信号、分身、队友换位、紧急撤离提示。
### 4.2 寻找者
- 目标:在时间结束前完成抓捕。
- 可见信息:剩余人数、周期性模糊线索、公开事件。
- 基础技能“雷达”:检测附近是否存在未被抓的躲藏者,但不显示精确方向。
- 后续能力:足迹、缩圈、虚拟封锁区、连续追踪奖励。
### 4.3 房主
- 配置场地、时长、模式和人数。
- 开始、暂停或提前结束游戏。
- 移除异常玩家。
- 发起全员集合。
- 处理抓捕争议和安全事件。
## 5. 功能需求
### 5.1 首页
- 展示产品核心价值和主操作入口。
- 提供“创建游戏”和“加入房间”。
- 展示最近一场未结束的游戏,可继续进入。
- 提供安全规则入口。
- 后续增加历史战绩、常用队伍和附近活动。
### 5.2 创建游戏
房主填写:
- 房间名称。
- 游戏模式:经典或感染。
- 游戏时长1590 分钟。
- 准备时长110 分钟。
- 最大人数430 人。
- 初始寻找者人数。
- 线索间隔310 分钟。
- 场地半径2001500 米。
- 是否开启技能。
校验规则:
- 初始寻找者必须少于最大人数。
- 游戏和准备时间必须在允许范围内。
- 创建前必须勾选安全承诺。
### 5.3 加入房间
- 输入六位房间码加入。
- 支持通过小程序分享卡片携带房间参数直接加入。
- 展示房间名称、模式、房主和当前人数。
- 玩家确认昵称和安全规则后进入候场。
- 房间已满、已开始或已结束时给出明确提示。
### 5.4 候场
- 展示房间码、规则摘要和玩家列表。
- 房主可以邀请好友、调整规则和开始游戏。
- 玩家可以切换“已准备/未准备”状态。
- 开始前进行定位、电量与网络提示。
- 分配身份后,身份只对本人可见。
- 开始游戏时先进入躲藏准备阶段。
MVP 原型为便于单人演示,会自动加入模拟玩家。
### 5.5 游戏进行中
公共信息:
- 当前阶段、倒计时、存活人数和边界状态。
- 游戏事件流,如线索广播、技能使用和抓捕结果。
- 安全按钮:暂停参与、紧急集合、联系房主。
寻找者界面:
- 模糊区域线索。
- 雷达技能和冷却状态。
- 输入动态口令完成抓捕。
躲藏者界面:
- 下次位置暴露倒计时。
- 静默技能。
- 供寻找者验证的动态四位数口令。
- 自身是否越界或接近边界。
### 5.6 抓捕确认
推荐正式版流程:
1. 寻找者在线下发现目标。
2. 双方保持安全距离,不进行身体接触。
3. 躲藏者展示周期性变化的二维码或四位数口令。
4. 寻找者扫码或输入口令。
5. 服务端验证双方距离、口令时效和玩家状态。
6. 双方收到震动和结果提示。
异常处理:
- 口令错误时不泄露目标身份。
- 相同口令不可重复使用。
- 玩家可在短时间内发起争议。
- 房主可查看事件记录并作出裁定。
### 5.7 结算和战报
- 展示获胜阵营和个人结果。
- 展示存活时间、抓捕数、技能使用和移动距离。
- 生成“最终幸存者”“最佳猎人”“极限逃脱”等称号。
- 支持分享战报卡片。
- 支持按原规则再来一局。
### 5.8 安全中心
- 游戏区域必须由房主明确设置。
- 接近边界和越界时持续提醒。
- 禁止将机动车道、水域、施工区和私人区域设为任务点。
- 禁止身体冲撞、拉扯和强行进入封闭空间。
- 提供一键退出、全员集合和紧急联系人。
- 低电量、定位关闭或长时间失联时提醒本人和房主。
- 未成年人参与公开活动时要求监护人同意。
## 6. 隐私与合规
### 6.1 数据最小化
- 只在一局游戏期间采集实现玩法所需的位置数据。
- 默认向其他玩家展示模糊区域,不展示精确坐标。
- 游戏结束后删除精确轨迹,战报只保留聚合结果。
- 陌生人房间隐藏真实姓名、微信号和完整头像信息。
### 6.2 权限使用
- 在用户开始需要定位的玩法前,再解释并请求定位授权。
- 拒绝定位后仍可浏览规则,但不能进入正式游戏。
- 相册和摄像头权限只在扫码或生成战报时按需申请。
- 提供数据导出、历史清除和账号注销入口。
### 6.3 风险提示
- 小程序不是人身安全保障工具。
- 房主必须确认场地合法、安全且适合活动。
- 不建议在夜间、恶劣天气、高车流区域或陌生复杂地形进行游戏。
- 发生危险时立即停止游戏并联系当地紧急服务。
## 7. 状态模型
房间状态:
- `waiting`:等待玩家加入。
- `preparing`:身份已分配,躲藏者准备中。
- `playing`:正式游戏进行中。
- `paused`:房主暂停。
- `finished`:游戏结束。
- `cancelled`:房主取消。
玩家状态:
- `idle`:未准备。
- `ready`:已准备。
- `hiding`:躲藏中。
- `seeking`:寻找中。
- `caught`:已被抓。
- `spectating`:观战。
- `offline`:暂时离线。
- `quit`:退出游戏。
## 8. 非功能需求
- 房间关键事件同步延迟目标小于 1 秒。
- 倒计时由服务端时间校准,客户端不得作为最终依据。
- 弱网重连后能恢复身份、状态和剩余时间。
- 精确坐标传输和存储必须加密并设置短生命周期。
- 单局至少支持 30 人,后续活动版支持 100 人。
- 关键按钮应适合户外单手操作,并提供震动或声音反馈。
- 页面应在主流微信版本和常见屏幕尺寸下可用。
## 9. MVP 范围
### 9.1 本仓库已实现的原型
- 首页、创建、加入、候场、游戏、结算和安全规则页面。
- 本地房间和玩家模拟。
- 经典/感染模式配置。
- 准备与游戏倒计时。
- 角色分配、模糊线索、雷达、静默和口令抓捕。
- 基础战报与再来一局。
### 9.2 真实多人版必须补齐
- 微信服务端登录与用户体系。
- 云数据库或自有后端。
- WebSocket 实时同步。
- 地图选区与地理围栏。
- 后台定位策略和真机兼容性验证。
- 服务端口令和技能校验。
- 分享卡片、订阅消息和内容安全。
- 埋点、崩溃监控、举报和客服流程。
### 9.3 暂不进入 MVP
- 附近陌生人公开组局。
- 付费商城。
- 用户自制剧本市场。
- 景区和企业管理后台。
- 赛季、段位和复杂成长体系。
## 10. 数据指标
北极星指标:
- 每周成功完成的多人游戏局数。
核心漏斗:
- 首页访问 → 创建/加入房间。
- 进入房间 → 完成准备。
- 完成准备 → 正式开局。
- 正式开局 → 正常结算。
- 正常结算 → 分享或再来一局。
质量指标:
- 平均每局人数和时长。
- 中途退出率、失联率和争议率。
- 创建房间至开局的时间。
- 位置授权成功率和弱网重连成功率。
- 7 日内再次开局的房主比例。
安全指标:
- 越界事件数。
- 紧急暂停和集合事件数。
- 举报率及处理时长。
## 11. 版本路线
### V0.1 可交互原型
- 验证规则、信息节奏和页面流程。
- 用模拟玩家完成单人走查。
### V0.2 熟人内测
- 接入真实登录、房间同步和地理围栏。
- 支持 612 人熟人局。
- 完成隐私与安全流程。
### V0.3 公测
- 优化弱网、后台切换和耗电。
- 增加分享战报、历史记录和常用队伍。
- 上线感染模式完整技能和平衡参数。
### V1.0 场景扩展
- 增加宝藏、占点和亲子玩法。
- 提供活动模板和主办方工具。
- 探索营地、景区和团建商业合作。
## 12. 验收标准
MVP 进入熟人内测前至少满足:
1. 6 名玩家可通过分享进入同一房间。
2. 所有玩家看到一致的阶段、倒计时和抓捕结果。
3. 身份信息不会错误地展示给其他阵营。
4. 拒绝定位或越界时能阻止继续参与并清晰提示。
5. 抓捕口令过期、重复或距离不符时无法通过。
6. 短暂断网后能在 10 秒内恢复游戏状态。
7. 房主可暂停、集合和结束游戏。
8. 游戏结束后精确位置按策略完成删除。

34
docs/RELEASE_NOTES.md Normal file
View File

@@ -0,0 +1,34 @@
# 藏猫猫计划 0.1.0-mvp 发布说明
发布日期2026-07-27
版本类型:本地可交互演示版
## 已交付
- 首页、创建、加入、候场、游戏、安全规则和结算页面。
- 经典模式和感染模式。
- 本地房间、模拟玩家、角色分配和阶段倒计时。
- 配置化正式计时与独立演示加速。
- 雷达、静默、目标选择和 30 秒动态抓捕口令。
- 抓捕权限、口令时效与重复使用校验。
- 暂停、继续、提前结束和安全退出。
- 稳定战报快照、角色转化展示和按原规则复玩。
- 旧缓存迁移、损坏数据恢复和异常页面兜底。
- 游戏服务、页面契约和发布就绪自动检查。
## 已知限制
- 房间和玩家数据只保存在当前设备,无法跨设备实时同步。
- 没有微信登录、云数据库、WebSocket 或服务端状态裁决。
- 没有接入真实地图、定位、地理围栏或移动轨迹。
- 雷达和模糊区域线索属于随机演示数据。
- 本地动态口令不具备正式防作弊安全性。
- 联系房主、紧急集合和分享战报仍是原型能力。
- 微信真机上的震动、分享、剪贴板和不同屏幕兼容性需要按验收清单人工确认。
## 下一阶段进入条件
- 完成 `DEVICE_TEST_CHECKLIST.md` 并解决所有阻塞问题。
- 通过至少 35 场现场模拟,验证规则理解、线索节奏和安全流程。
- 冻结熟人多人版的数据模型和服务端状态机。

229
docs/TECHNICAL_DESIGN.md Normal file
View File

@@ -0,0 +1,229 @@
# 技术设计
## 1. 当前原型架构
当前版本采用微信小程序原生 JavaScript不依赖 npm 包和远程服务。
```text
页面层 pages/*
本地游戏服务 services/game-service.js
wx Storage房间、玩家、事件和结果
```
`game-service.js` 将页面与数据源隔离。接入真实后端时,页面调用接口可以尽量保持不变,只需将服务实现替换为网络请求和实时订阅。
## 2. 推荐生产架构
初期建议采用微信云开发,减少登录、部署和运维成本;验证规模后可迁移为自有服务。
```text
微信小程序
├─ HTTPS API创建、加入、抓捕、技能、结算
├─ WebSocket房间状态和事件推送
└─ 定位模块:低频位置上报与边界判断
应用服务
├─ 身份与权限
├─ 房间状态机
├─ 游戏规则引擎
├─ 位置模糊化
├─ 口令与防作弊
└─ 安全事件
数据层
├─ 持久数据库:用户、房间、结果
├─ Redis在线状态、口令、房间实时状态
└─ 短期位置存储:设置自动过期时间
```
## 3. 核心实体
### User
```js
{
id,
openId,
displayName,
avatarUrl,
safetySettings,
createdAt
}
```
### Room
```js
{
id,
code,
hostId,
name,
mode,
status,
settings: {
maxPlayers,
durationMinutes,
prepareMinutes,
clueIntervalMinutes,
seekerCount,
radiusMeters,
skillsEnabled
},
center,
phaseEndsAt,
version,
createdAt
}
```
### Player
```js
{
id,
roomId,
userId,
displayName,
role,
status,
ready,
caughtBy,
caughtAt,
joinedAt,
lastSeenAt
}
```
### GameEvent
```js
{
id,
roomId,
type,
actorId,
targetId,
visibility,
payload,
createdAt
}
```
### LocationPing
```js
{
roomId,
playerId,
latitude,
longitude,
accuracy,
capturedAt,
expiresAt
}
```
生产环境中 `LocationPing` 不应作为永久战绩保存。
## 4. API 草案
- `POST /rooms`:创建房间。
- `GET /rooms/:code`:读取可公开的房间摘要。
- `POST /rooms/:code/join`:加入房间。
- `POST /rooms/:id/ready`:切换准备状态。
- `POST /rooms/:id/start`:房主开始游戏。
- `POST /rooms/:id/location`:上报位置。
- `POST /rooms/:id/skills/:skill`:使用技能。
- `POST /rooms/:id/captures`:提交抓捕凭证。
- `POST /rooms/:id/pause`:暂停游戏。
- `POST /rooms/:id/assemble`:发起全员集合。
- `POST /rooms/:id/finish`:结束游戏。
- `GET /rooms/:id/result`:读取结算结果。
所有改变游戏状态的接口都应:
- 校验用户身份和房间成员关系。
- 校验当前房间状态和角色权限。
- 使用服务端时间。
- 支持幂等键,避免弱网重试造成重复事件。
- 更新房间版本号并广播事件。
## 5. 实时同步
WebSocket 消息建议仅发送必要状态变化:
```js
{
eventId,
roomId,
roomVersion,
type: "PLAYER_CAUGHT",
payload: {},
serverTime
}
```
客户端策略:
1. 进入候场或游戏页后建立连接。
2.`eventId` 去重。
3. 检测版本跳跃时重新拉取房间快照。
4. 退到后台后按微信能力选择保活或恢复时重连。
5. UI 倒计时基于 `phaseEndsAt - serverTimeOffset` 计算。
## 6. 位置和地理围栏
- 客户端获取坐标并附带定位精度和采集时间。
- 服务端校验坐标新鲜度、速度异常和精度阈值。
- 使用服务端保存的场地中心和半径判断越界。
- 向寻找者发送的线索由服务端完成网格化或随机偏移。
- 不将躲藏者原始坐标发送给其他普通玩家。
- 位置记录设置短期 TTL游戏结束后触发删除任务。
线索模糊化可采用:
- H3/Geohash 网格降级。
- 以真实点为中心生成受边界约束的随机圆。
- 仅返回目标与玩家的距离档位,如“很近、附近、较远”。
## 7. 抓捕防作弊
动态口令建议由服务端生成:
```text
token = HMAC(roomSecret, targetPlayerId + timeWindow + nonce)
```
- 口令窗口建议 30 秒。
- 提交时校验寻找者与目标的最近位置距离。
- 校验双方都在线、角色正确且目标尚未被抓。
- 使用事务写入抓捕结果,防止两个寻找者同时抓到同一目标。
- 口令只作为相遇证明之一,不能替代安全规则。
## 8. 安全设计
- 房主开始前必须完成场地和规则确认。
- 服务端保留暂停、集合和取消的高优先级事件通道。
- 客户端进入后台、定位失效、低电量或离线时产生状态提示。
- 精确位置访问纳入服务端审计,不允许普通运营后台随意查看。
- 公开组局上线前必须具备举报、封禁、内容安全和年龄策略。
## 9. 测试策略
- 单元测试:房间状态机、身份分配、倒计时、抓捕和结算。
- 属性测试:不同人数与寻找者数量下角色数量始终合法。
- 集成测试:多人并发加入、重复抓捕、断线重连和版本冲突。
- 真机测试:前后台切换、弱网、定位精度、耗电和不同微信版本。
- 户外测试:边界提醒时机、线索节奏、屏幕使用时间和安全流程。
## 10. 迁移顺序
1. 保留页面与服务接口,先把本地房间读写替换为云函数。
2. 接入微信登录并使每个玩家拥有稳定服务端 ID。
3. 引入实时房间事件和服务端倒计时。
4. 接入地图选区、定位上报和模糊线索。
5. 将抓捕、技能和结算全部迁到服务端裁决。
6. 增加日志、监控、隐私删除和运营安全能力。

51
docs/TEST_REPORT.md Normal file
View File

@@ -0,0 +1,51 @@
# 藏猫猫计划 0.1.0-mvp 测试报告
测试日期2026-07-27
测试范围:代码语法、本地领域服务、页面事件契约、发布文件完整性
测试结论:自动化检查通过;真机检查待执行
## 自动化结果
执行命令:
```bash
node tests/run-all.js
```
覆盖内容:
- 旧缓存迁移和损坏数据恢复。
- 创建参数、加入状态、房主权限和全员准备校验。
- 房间状态迁移、演示计时和正式配置计时。
- 角色数量、经典淘汰和感染转化。
- 技能持久化、技能关闭、静默消费和身份权限。
- 口令目标绑定、30 秒时效、重复使用和抓捕权限。
- 暂停、继续、提前结束、安全退出和自动结算。
- 结算快照及复玩数据重置。
- WXML 事件处理函数与页面 JS 的绑定完整性。
- `app.json` 页面文件、项目类型、版本号和交付文档完整性。
结果:
```text
game-service tests passed
page contract tests passed
release readiness tests passed
all automated checks passed
```
## 未自动覆盖
- 微信开发者工具编译器特有的 WXML/WXSS 兼容提示。
- iOS、Android 真机屏幕适配。
- 震动强度、剪贴板授权和分享卡片效果。
- 微信前后台调度差异和低电量行为。
以上项目使用 `DEVICE_TEST_CHECKLIST.md` 人工验收。
## 发布判断
- 用于产品演示和用户访谈:可以。
- 用于单设备流程验证:可以。
- 用于真实多人户外活动:不可以,必须先完成服务端、定位、合规和真机验证。

116
pages/create/create.js Normal file
View File

@@ -0,0 +1,116 @@
const service = require("../../services/game-service");
const { GAME_MODES, DEFAULT_SETTINGS } = require("../../utils/constants");
Page({
data: {
name: "周末藏猫猫",
mode: DEFAULT_SETTINGS.mode,
modes: Object.values(GAME_MODES),
durationOptions: [15, 20, 30, 45, 60],
durationIndex: 2,
prepareOptions: [1, 3, 5, 8],
prepareIndex: 1,
maxPlayerOptions: [4, 6, 8, 10, 12, 20],
maxPlayerIndex: 2,
seekerOptions: [1, 2, 3, 4],
seekerIndex: 1,
clueOptions: [3, 5, 8, 10],
clueIndex: 1,
radiusOptions: [200, 300, 500, 800, 1000],
radiusIndex: 2,
skillsEnabled: true,
safetyAccepted: false,
submitting: false
},
updateName(event) {
this.setData({ name: event.detail.value });
},
chooseMode(event) {
this.setData({ mode: event.currentTarget.dataset.mode });
},
onDurationChange(event) {
this.setData({ durationIndex: Number(event.detail.value) });
},
onPrepareChange(event) {
this.setData({ prepareIndex: Number(event.detail.value) });
},
onMaxPlayerChange(event) {
const maxPlayerIndex = Number(event.detail.value);
const maxPlayers = this.data.maxPlayerOptions[maxPlayerIndex];
let seekerIndex = this.data.seekerIndex;
while (
seekerIndex > 0 &&
this.data.seekerOptions[seekerIndex] >= maxPlayers
) {
seekerIndex -= 1;
}
this.setData({ maxPlayerIndex, seekerIndex });
},
onSeekerChange(event) {
const seekerIndex = Number(event.detail.value);
const seekers = this.data.seekerOptions[seekerIndex];
const maxPlayers = this.data.maxPlayerOptions[this.data.maxPlayerIndex];
if (seekers >= maxPlayers) {
wx.showToast({ title: "寻找者必须少于人数上限", icon: "none" });
return;
}
this.setData({ seekerIndex });
},
onClueChange(event) {
this.setData({ clueIndex: Number(event.detail.value) });
},
onRadiusChange(event) {
this.setData({ radiusIndex: Number(event.detail.value) });
},
onSkillsChange(event) {
this.setData({ skillsEnabled: event.detail.value });
},
onSafetyChange(event) {
this.setData({ safetyAccepted: event.detail.value.length > 0 });
},
openRules() {
wx.navigateTo({ url: "/pages/rules/rules" });
},
submit() {
if (!this.data.name.trim()) {
wx.showToast({ title: "请填写房间名称", icon: "none" });
return;
}
if (!this.data.safetyAccepted) {
wx.showToast({ title: "请先确认安全规则", icon: "none" });
return;
}
this.setData({ submitting: true });
const data = this.data;
try {
const room = service.createRoom({
name: data.name.trim(),
mode: data.mode,
durationMinutes: data.durationOptions[data.durationIndex],
prepareMinutes: data.prepareOptions[data.prepareIndex],
maxPlayers: data.maxPlayerOptions[data.maxPlayerIndex],
seekerCount: data.seekerOptions[data.seekerIndex],
clueIntervalMinutes: data.clueOptions[data.clueIndex],
radiusMeters: data.radiusOptions[data.radiusIndex],
skillsEnabled: data.skillsEnabled
});
wx.redirectTo({ url: `/pages/lobby/lobby?code=${room.code}` });
} catch (error) {
this.setData({ submitting: false });
wx.showToast({ title: error.message, icon: "none" });
}
}
});

3
pages/create/create.json Normal file
View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "创建游戏"
}

148
pages/create/create.wxml Normal file
View File

@@ -0,0 +1,148 @@
<view class="page">
<view class="eyebrow">CREATE A GAME</view>
<view class="title">设置这场追逃</view>
<view class="subtitle">先用推荐规则开局,之后可以在候场页继续调整。</view>
<view class="section-label">房间名称</view>
<view class="input-card">
<input
maxlength="16"
value="{{name}}"
placeholder="给这场游戏起个名字"
bindinput="updateName"
/>
<text>{{name.length}}/16</text>
</view>
<view class="section-label">游戏模式</view>
<view
wx:for="{{modes}}"
wx:key="id"
class="mode-card {{mode === item.id ? 'selected' : ''}}"
data-mode="{{item.id}}"
bindtap="chooseMode"
>
<view class="mode-icon">{{item.icon}}</view>
<view class="mode-main">
<view class="mode-name">{{item.name}}</view>
<view class="mode-description">{{item.description}}</view>
</view>
<view class="radio">{{mode === item.id ? '✓' : ''}}</view>
</view>
<view class="section-label">基础规则</view>
<view class="settings-card">
<picker
mode="selector"
range="{{durationOptions}}"
value="{{durationIndex}}"
bindchange="onDurationChange"
>
<view class="setting-row">
<view>
<view class="setting-name">游戏时长</view>
<view class="setting-hint">正式追逃阶段</view>
</view>
<view class="setting-value">{{durationOptions[durationIndex]}} 分钟 </view>
</view>
</picker>
<picker
mode="selector"
range="{{prepareOptions}}"
value="{{prepareIndex}}"
bindchange="onPrepareChange"
>
<view class="setting-row">
<view>
<view class="setting-name">躲藏准备</view>
<view class="setting-hint">寻找者原地等待</view>
</view>
<view class="setting-value">{{prepareOptions[prepareIndex]}} 分钟 </view>
</view>
</picker>
<picker
mode="selector"
range="{{maxPlayerOptions}}"
value="{{maxPlayerIndex}}"
bindchange="onMaxPlayerChange"
>
<view class="setting-row">
<view>
<view class="setting-name">人数上限</view>
<view class="setting-hint">至少 4 人开局</view>
</view>
<view class="setting-value">{{maxPlayerOptions[maxPlayerIndex]}} 人 </view>
</view>
</picker>
<picker
mode="selector"
range="{{seekerOptions}}"
value="{{seekerIndex}}"
bindchange="onSeekerChange"
>
<view class="setting-row">
<view>
<view class="setting-name">初始寻找者</view>
<view class="setting-hint">建议约占总人数 1/4</view>
</view>
<view class="setting-value">{{seekerOptions[seekerIndex]}} 人 </view>
</view>
</picker>
<picker
mode="selector"
range="{{clueOptions}}"
value="{{clueIndex}}"
bindchange="onClueChange"
>
<view class="setting-row">
<view>
<view class="setting-name">位置线索</view>
<view class="setting-hint">只广播模糊区域</view>
</view>
<view class="setting-value">每 {{clueOptions[clueIndex]}} 分钟 </view>
</view>
</picker>
<picker
mode="selector"
range="{{radiusOptions}}"
value="{{radiusIndex}}"
bindchange="onRadiusChange"
>
<view class="setting-row">
<view>
<view class="setting-name">活动半径</view>
<view class="setting-hint">原型暂以文字展示</view>
</view>
<view class="setting-value">{{radiusOptions[radiusIndex]}} 米 </view>
</view>
</picker>
<view class="setting-row no-border">
<view>
<view class="setting-name">启用角色技能</view>
<view class="setting-hint">雷达、静默等轻量能力</view>
</view>
<switch
color="#1d6b4f"
checked="{{skillsEnabled}}"
bindchange="onSkillsChange"
/>
</view>
</view>
<checkbox-group class="safety-check" bindchange="onSafetyChange">
<label>
<checkbox value="accepted" color="#1d6b4f" />
<text>我已确认场地安全,并同意</text>
<text class="link" catchtap="openRules">《户外游戏安全规则》</text>
</label>
</checkbox-group>
<button
class="primary-button"
loading="{{submitting}}"
disabled="{{submitting}}"
bindtap="submit"
>
创建房间
</button>
</view>

129
pages/create/create.wxss Normal file
View File

@@ -0,0 +1,129 @@
.input-card {
display: flex;
align-items: center;
padding: 10rpx 26rpx;
background: #fffdf7;
border: 2rpx solid rgba(23, 35, 29, 0.1);
border-radius: 24rpx;
}
.input-card input {
flex: 1;
height: 86rpx;
font-size: 30rpx;
}
.input-card text {
color: #8b938e;
font-size: 22rpx;
}
.mode-card {
display: flex;
align-items: center;
gap: 22rpx;
margin-bottom: 18rpx;
padding: 26rpx;
background: #fffdf7;
border: 3rpx solid transparent;
border-radius: 26rpx;
}
.mode-card.selected {
border-color: #1d6b4f;
background: #f2f8ef;
}
.mode-icon {
display: flex;
align-items: center;
justify-content: center;
width: 74rpx;
height: 74rpx;
background: #d9ef72;
border-radius: 22rpx;
font-size: 36rpx;
font-weight: 900;
}
.mode-main {
flex: 1;
}
.mode-name {
font-size: 29rpx;
font-weight: 800;
}
.mode-description {
margin-top: 8rpx;
color: #68726c;
font-size: 23rpx;
line-height: 1.5;
}
.radio {
display: flex;
align-items: center;
justify-content: center;
width: 42rpx;
height: 42rpx;
border: 2rpx solid #9ba39e;
border-radius: 50%;
color: #fff;
}
.selected .radio {
background: #1d6b4f;
border-color: #1d6b4f;
}
.settings-card {
padding: 0 26rpx;
background: #fffdf7;
border-radius: 28rpx;
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 120rpx;
border-bottom: 2rpx solid rgba(23, 35, 29, 0.07);
}
.setting-row.no-border {
border-bottom: none;
}
.setting-name {
font-size: 27rpx;
font-weight: 750;
}
.setting-hint {
margin-top: 7rpx;
color: #8b938e;
font-size: 22rpx;
}
.setting-value {
color: #1d6b4f;
font-size: 26rpx;
font-weight: 700;
}
.safety-check {
margin: 32rpx 4rpx;
color: #68726c;
font-size: 24rpx;
line-height: 1.7;
}
.safety-check checkbox {
transform: scale(0.8);
}
.link {
color: #1d6b4f;
}

278
pages/game/game.js Normal file
View File

@@ -0,0 +1,278 @@
const service = require("../../services/game-service");
const { formatCountdown, formatTime } = require("../../utils/format");
Page({
data: {
code: "",
room: null,
me: null,
readyToRender: false,
stageLabel: "",
countdown: "00:00",
survivors: 0,
seekers: 0,
captureCode: "",
captureTargets: [],
selectedTargetId: "",
submittedCode: "",
skillUsed: false,
canManage: false,
showDemoCode: false,
captureSubmitting: false,
errorMessage: "",
eventViews: []
},
onLoad(options) {
this.setData({ code: options.code || "" });
this.tick();
},
onShow() {
if (!this.data.code) return;
this.startTimer();
this.tick();
},
onHide() {
this.stopTimer();
},
onUnload() {
this.stopTimer();
},
startTimer() {
this.stopTimer();
this.timer = setInterval(() => this.tick(), 1000);
},
stopTimer() {
if (!this.timer) return;
clearInterval(this.timer);
this.timer = null;
},
tick() {
let room = service.getRoom(this.data.code);
if (!room) {
this.stopTimer();
this.setData({ readyToRender: false, errorMessage: "房间不存在或游戏数据已被清理" });
return;
}
if (room.status === "preparing" && Date.now() >= room.phaseEndsAt) {
room = service.beginPlaying(room.code);
wx.vibrateLong();
}
if (room.status === "playing") {
if (Date.now() >= room.phaseEndsAt) {
service.finishGame(room.code, "hiders", null, "timeout");
this.goResult();
return;
}
if (room.nextClueAt && Date.now() >= room.nextClueAt) {
service.revealClue(room.code);
wx.vibrateShort({ type: "medium" });
}
}
if (room.status === "finished") {
this.goResult();
return;
}
this.refresh();
},
refresh() {
const room = service.getRoom(this.data.code);
if (!room) {
this.stopTimer();
this.setData({ readyToRender: false, errorMessage: "房间不存在或游戏数据已被清理" });
return;
}
const me =
room.players.find((player) => player.id === service.USER_ID) ||
room.players[0];
const seconds = room.status === "paused"
? Math.ceil(room.pausedRemainingMs / 1000)
: Math.ceil((room.phaseEndsAt - Date.now()) / 1000);
const survivors = room.players.filter(
(player) => player.role === "hider" && player.status === "hiding"
).length;
const seekers = room.players.filter(
(player) => player.role === "seeker"
).length;
const captureTargets = room.players.filter(
(player) => player.role === "hider" && player.status === "hiding"
);
const selectedTargetId = captureTargets.some(
(player) => player.id === this.data.selectedTargetId
)
? this.data.selectedTargetId
: captureTargets.length ? captureTargets[0].id : "";
const currentSkill = me.role === "seeker" ? "radar" : "silent";
const captureCode = me.role === "seeker"
? service.getCaptureCode(room.code, selectedTargetId)
: service.getCaptureCode(room.code, me.id);
this.setData({
room,
errorMessage: "",
me,
readyToRender: true,
stageLabel: room.status === "preparing"
? "躲藏准备"
: room.status === "paused" ? "游戏已暂停" : "追逃进行中",
countdown: formatCountdown(seconds),
survivors,
seekers,
captureTargets,
selectedTargetId,
captureCode,
skillUsed: Boolean(me.skillsUsed && me.skillsUsed[currentSkill] > 0),
canManage: room.hostId === service.USER_ID || room.code === "731204",
showDemoCode: Boolean(room.runtime && room.runtime.demoAccelerated),
eventViews: room.events.slice(0, 6).map((event) => ({
...event,
time: formatTime(event.createdAt)
}))
});
},
useSkill() {
if (this.data.skillUsed) {
wx.showToast({ title: "本局体验技能已使用", icon: "none" });
return;
}
const skill = this.data.me.role === "seeker" ? "radar" : "silent";
try {
service.useSkill(this.data.code, skill);
wx.vibrateShort({ type: "heavy" });
this.refresh();
} catch (error) {
wx.showToast({ title: error.message, icon: "none" });
}
},
chooseCaptureTarget(event) {
this.setData({
selectedTargetId: event.currentTarget.dataset.id,
submittedCode: ""
});
this.refresh();
},
inputCaptureCode(event) {
this.setData({
submittedCode: event.detail.value.replace(/\D/g, "").slice(0, 4)
});
},
fillDemoCode() {
this.setData({ submittedCode: this.data.captureCode });
},
submitCapture() {
if (this.data.captureSubmitting) return;
this.setData({ captureSubmitting: true });
try {
const room = service.capture(
this.data.code,
this.data.selectedTargetId,
this.data.submittedCode
);
wx.vibrateLong();
this.setData({ submittedCode: "" });
if (room.status === "finished") {
this.goResult();
} else {
this.setData({ captureSubmitting: false });
wx.showToast({ title: "抓捕成功", icon: "success" });
this.refresh();
}
} catch (error) {
this.setData({ captureSubmitting: false });
wx.showToast({ title: error.message, icon: "none" });
}
},
showSafety() {
wx.showActionSheet({
itemList: ["我已安全,继续游戏", "联系房主", "退出并前往集合点"],
success: (result) => {
if (result.tapIndex === 1) {
wx.showModal({
title: "联系房主",
content: "当前原型暂不提供即时通讯,请通过约定的电话或微信联系房主。",
showCancel: false
});
}
if (result.tapIndex === 2) {
wx.showModal({
title: "确认退出?",
content: "请先确保自己处于安全位置,并前往约定集合点。",
confirmText: "安全退出",
confirmColor: "#d9554d",
success: (modalResult) => {
if (!modalResult.confirm) return;
try {
const room = service.quitGame(this.data.code);
if (room.status === "finished") {
this.goResult();
} else {
this.stopTimer();
service.clearCurrentRoom();
wx.reLaunch({ url: "/pages/home/home" });
}
} catch (error) {
wx.showToast({ title: error.message, icon: "none" });
}
}
});
}
}
});
},
togglePause() {
try {
if (this.data.room.status === "paused") {
service.resumeGame(this.data.code);
} else {
service.pauseGame(this.data.code);
}
this.refresh();
} catch (error) {
wx.showToast({ title: error.message, icon: "none" });
}
},
finishEarly() {
wx.showModal({
title: "提前结束游戏?",
content: "系统将根据当前存活情况立即生成本局战报。",
confirmText: "结束游戏",
confirmColor: "#d9554d",
success: (result) => {
if (!result.confirm) return;
try {
service.finishGame(this.data.code, "", service.USER_ID, "host_ended");
this.goResult();
} catch (error) {
wx.showToast({ title: error.message, icon: "none" });
}
}
});
},
goResult() {
this.stopTimer();
wx.redirectTo({
url: `/pages/result/result?code=${this.data.code}`
});
},
backHome() {
this.stopTimer();
service.clearCurrentRoom();
wx.reLaunch({ url: "/pages/home/home" });
}
});

4
pages/game/game.json Normal file
View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "游戏进行中",
"disableScroll": false
}

137
pages/game/game.wxml Normal file
View File

@@ -0,0 +1,137 @@
<view class="game-page" wx:if="{{readyToRender}}">
<view class="game-head {{me.role === 'seeker' ? 'seeker-head' : 'hider-head'}}">
<view class="row between">
<view class="live-pill"><view class="live-dot"></view>{{stageLabel}}</view>
<view class="safe-state">● 边界内</view>
</view>
<view class="role-label">你的身份</view>
<view class="role-name">{{me.role === 'seeker' ? '寻找者' : '躲藏者'}}</view>
<view class="role-task">
{{me.role === 'seeker' ? '根据线索找到目标,用动态口令完成抓捕。' : '保持隐蔽,在倒计时结束前不要被发现。'}}
</view>
<view class="countdown">{{countdown}}</view>
</view>
<view class="page game-content">
<view class="stat-grid">
<view class="stat-card">
<view class="stat-value">{{survivors}}</view>
<view class="stat-label">仍在躲藏</view>
</view>
<view class="stat-card">
<view class="stat-value">{{seekers}}</view>
<view class="stat-label">正在寻找</view>
</view>
<view class="stat-card">
<view class="stat-value">{{room.settings.radiusMeters}}m</view>
<view class="stat-label">安全半径</view>
</view>
</view>
<view wx:if="{{room.status === 'preparing'}}" class="preparing-card">
<view class="preparing-icon">◌</view>
<view class="card-title">准备阶段</view>
<view class="preparing-copy">
{{me.role === 'seeker' ? '请留在起点,不要观察躲藏者的方向。倒计时结束后会震动提醒。' : '现在出发寻找安全藏身处,不要进入封闭、危险或私人区域。'}}
</view>
</view>
<view wx:elif="{{room.status === 'paused'}}" class="preparing-card">
<view class="preparing-icon">Ⅱ</view>
<view class="card-title">游戏暂停中</view>
<view class="preparing-copy">请留在安全位置并等待房主继续游戏,暂停期间倒计时和线索计时均不会推进。</view>
</view>
<block wx:else>
<view wx:if="{{room.settings.skillsEnabled && (me.status === 'seeking' || me.status === 'hiding')}}">
<view class="section-label">角色能力</view>
<view class="skill-card">
<view class="skill-icon">{{me.role === 'seeker' ? '⌁' : '◐'}}</view>
<view class="skill-main">
<view class="card-title">{{me.role === 'seeker' ? '区域雷达' : '静默信号'}}</view>
<view class="skill-copy">
{{me.role === 'seeker' ? '检测附近是否可能存在躲藏者,不显示精确方向。' : '跳过下一次周期性位置广播。'}}
</view>
</view>
<button
class="skill-button {{skillUsed ? 'used' : ''}}"
disabled="{{skillUsed}}"
bindtap="useSkill"
>{{skillUsed ? '已使用' : '使用'}}</button>
</view>
</view>
<view wx:if="{{me.role === 'seeker' && me.status === 'seeking'}}">
<view class="section-label">抓捕确认</view>
<view class="capture-card">
<view class="capture-title">选择你在线下找到的目标</view>
<view class="target-list">
<view
wx:for="{{captureTargets}}"
wx:key="id"
class="target-chip {{selectedTargetId === item.id ? 'selected' : ''}}"
data-id="{{item.id}}"
bindtap="chooseCaptureTarget"
>{{item.name}}</view>
</view>
<view class="capture-copy">请在安全距离内完成确认,禁止追逐中发生身体接触。</view>
<input
type="number"
maxlength="4"
value="{{submittedCode}}"
placeholder="0000"
bindinput="inputCaptureCode"
/>
<view wx:if="{{showDemoCode}}" class="demo-code" bindtap="fillDemoCode">
原型演示:点击填入口令 {{captureCode}}
</view>
<button
class="secondary-button capture-button"
loading="{{captureSubmitting}}"
disabled="{{captureSubmitting || !selectedTargetId || submittedCode.length !== 4}}"
bindtap="submitCapture"
>确认抓捕</button>
</view>
</view>
<view wx:elif="{{me.role === 'hider' && me.status === 'hiding'}}">
<view class="section-label">你的动态口令</view>
<view class="token-card">
<view class="token-label">被找到后,将口令展示给寻找者</view>
<view class="token-code">{{captureCode}}</view>
<view class="token-note">每 30 秒刷新 · 不要远程发送口令</view>
</view>
</view>
<view wx:else class="preparing-card player-finished-card">
<view class="card-title">你已结束躲藏</view>
<view class="preparing-copy">经典模式下可以留在安全区域观战,请勿向其他玩家透露信息。</view>
</view>
<view class="section-label">实时事件</view>
<view class="event-list">
<view wx:if="{{eventViews.length === 0}}" class="empty-inline">还没有游戏事件</view>
<view wx:for="{{eventViews}}" wx:key="id" class="event-row">
<view class="event-mark {{item.type}}"></view>
<view class="event-message">{{item.message}}</view>
<view class="event-time">{{item.time}}</view>
</view>
</view>
</block>
<view class="safety-actions">
<button class="danger-button" bindtap="showSafety">安全与退出</button>
<button wx:if="{{canManage}}" class="ghost-button" bindtap="togglePause">
{{room.status === 'paused' ? '继续游戏' : '暂停游戏'}}
</button>
<button wx:if="{{canManage}}" class="ghost-button" bindtap="finishEarly">提前结束</button>
</view>
</view>
</view>
<view class="state-page" wx:elif="{{errorMessage}}">
<view class="state-icon">!</view>
<view class="state-title">无法恢复游戏</view>
<view class="state-copy">{{errorMessage}}</view>
<button class="primary-button state-action" bindtap="backHome">返回首页</button>
</view>

333
pages/game/game.wxss Normal file
View File

@@ -0,0 +1,333 @@
.game-page {
min-height: 100vh;
background: #f7f4ec;
}
.game-head {
padding: 34rpx 34rpx 46rpx;
color: #fff;
}
.seeker-head {
background: #183f31;
}
.hider-head {
background: #914a22;
}
.live-pill,
.safe-state {
display: flex;
align-items: center;
gap: 10rpx;
color: rgba(255, 255, 255, 0.75);
font-size: 22rpx;
}
.live-pill {
padding: 10rpx 16rpx;
background: rgba(255, 255, 255, 0.1);
border-radius: 99rpx;
}
.live-dot {
width: 12rpx;
height: 12rpx;
background: #d9ef72;
border-radius: 50%;
}
.role-label {
margin-top: 38rpx;
color: rgba(255, 255, 255, 0.6);
font-size: 22rpx;
font-weight: 700;
}
.role-name {
margin-top: 7rpx;
font-size: 58rpx;
font-weight: 900;
}
.role-task {
width: 75%;
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.72);
font-size: 24rpx;
line-height: 1.55;
}
.countdown {
margin-top: 34rpx;
color: #d9ef72;
font-size: 90rpx;
font-weight: 900;
letter-spacing: 4rpx;
}
.game-content {
padding-top: 0;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14rpx;
margin-top: -24rpx;
}
.stat-card {
padding: 24rpx 12rpx;
background: #fffdf7;
border-radius: 22rpx;
box-shadow: 0 10rpx 30rpx rgba(35, 46, 40, 0.07);
text-align: center;
}
.stat-value {
font-size: 33rpx;
font-weight: 900;
}
.stat-label {
margin-top: 7rpx;
color: #7c8580;
font-size: 19rpx;
}
.preparing-card {
margin-top: 32rpx;
padding: 48rpx 32rpx;
background: #fffdf7;
border-radius: 30rpx;
text-align: center;
}
.preparing-icon {
color: #ef8f4a;
font-size: 90rpx;
}
.preparing-copy {
margin-top: 16rpx;
color: #68726c;
font-size: 25rpx;
line-height: 1.65;
}
.skill-card {
display: flex;
align-items: center;
gap: 18rpx;
padding: 24rpx;
background: #fffdf7;
border-radius: 26rpx;
}
.skill-icon {
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
background: #d9ef72;
border-radius: 22rpx;
font-size: 36rpx;
}
.skill-main {
flex: 1;
}
.skill-copy {
margin-top: 7rpx;
color: #68726c;
font-size: 21rpx;
line-height: 1.45;
}
.skill-button {
width: 120rpx;
height: 64rpx;
margin: 0;
background: #1d6b4f;
color: #fff;
font-size: 23rpx;
}
.skill-button.used {
background: #dfe4e0;
color: #7c8580;
}
.capture-card,
.token-card {
padding: 28rpx;
background: #183f31;
border-radius: 28rpx;
color: #fff;
}
.capture-title {
font-size: 29rpx;
font-weight: 800;
}
.capture-copy,
.token-note {
margin-top: 9rpx;
color: rgba(255, 255, 255, 0.62);
font-size: 21rpx;
line-height: 1.5;
}
.target-list {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 20rpx;
}
.target-chip {
padding: 12rpx 20rpx;
border: 2rpx solid rgba(255, 255, 255, 0.24);
border-radius: 99rpx;
color: rgba(255, 255, 255, 0.72);
font-size: 22rpx;
}
.target-chip.selected {
background: #d9ef72;
border-color: #d9ef72;
color: #17231d;
font-weight: 800;
}
.player-finished-card {
margin-top: 32rpx;
}
.capture-card input {
height: 120rpx;
margin-top: 24rpx;
background: rgba(255, 255, 255, 0.1);
border-radius: 20rpx;
color: #d9ef72;
font-size: 60rpx;
font-weight: 900;
letter-spacing: 24rpx;
text-align: center;
}
.demo-code {
padding: 18rpx 0;
color: #d9ef72;
font-size: 22rpx;
text-align: center;
}
.capture-button {
height: 80rpx;
font-size: 26rpx;
}
.token-card {
text-align: center;
}
.token-label {
color: rgba(255, 255, 255, 0.7);
font-size: 23rpx;
}
.token-code {
margin: 20rpx 0;
color: #d9ef72;
font-size: 74rpx;
font-weight: 900;
letter-spacing: 18rpx;
}
.event-list {
overflow: hidden;
background: #fffdf7;
border-radius: 26rpx;
}
.event-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 22rpx 24rpx;
border-bottom: 2rpx solid rgba(23, 35, 29, 0.07);
}
.event-row:last-child {
border-bottom: none;
}
.event-mark {
width: 14rpx;
height: 14rpx;
background: #7c8580;
border-radius: 50%;
}
.event-mark.clue {
background: #ef8f4a;
}
.event-mark.skill {
background: #1d6b4f;
}
.event-mark.capture {
background: #d9554d;
}
.event-message {
flex: 1;
font-size: 23rpx;
}
.event-time {
color: #939b96;
font-size: 20rpx;
}
.safety-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
margin-top: 32rpx;
}
.safety-actions button {
width: 100%;
padding: 0;
font-size: 23rpx;
}
.safety-actions button:nth-child(3) {
grid-column: 1 / -1;
}
@media (max-width: 340px) {
.role-task {
width: 100%;
}
.countdown {
font-size: 76rpx;
}
.stat-grid {
gap: 8rpx;
}
}
button[disabled] {
opacity: 0.5;
}

49
pages/home/home.js Normal file
View File

@@ -0,0 +1,49 @@
const service = require("../../services/game-service");
const { GAME_MODES } = require("../../utils/constants");
Page({
data: {
currentRoom: null,
modeName: ""
},
onShow() {
let currentRoom = service.getCurrentRoom();
if (currentRoom && currentRoom.status === "cancelled") {
service.clearCurrentRoom();
currentRoom = null;
}
this.setData({
currentRoom,
modeName: currentRoom
? GAME_MODES[currentRoom.settings.mode].name
: ""
});
},
createGame() {
wx.navigateTo({ url: "/pages/create/create" });
},
joinGame() {
wx.navigateTo({ url: "/pages/join/join" });
},
openRules() {
wx.navigateTo({ url: "/pages/rules/rules" });
},
continueGame() {
const room = this.data.currentRoom;
if (!room) return;
const page =
room.status === "finished"
? "result"
: room.status === "waiting"
? "lobby"
: "game";
wx.navigateTo({
url: `/pages/${page}/${page}?code=${room.code}`
});
}
});

3
pages/home/home.json Normal file
View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "藏猫猫计划"
}

63
pages/home/home.wxml Normal file
View File

@@ -0,0 +1,63 @@
<view class="page home-page">
<view class="hero">
<view class="hero-orbit orbit-one"></view>
<view class="hero-orbit orbit-two"></view>
<view class="eyebrow">OUTDOOR SOCIAL GAME</view>
<view class="hero-title">把城市一角,<br />变成游戏地图。</view>
<view class="hero-copy">
邀请好友、自动分队、接收线索,在真实世界里完成一场紧张又安全的追逃。
</view>
<view class="hero-players">
<view class="avatar avatar-a">藏</view>
<view class="avatar avatar-b">找</view>
<view class="avatar avatar-c">跑</view>
<text>建议 612 人 · 30 分钟</text>
</view>
</view>
<view class="button-stack actions">
<button class="primary-button" bindtap="createGame">创建一场游戏</button>
<button class="secondary-button" bindtap="joinGame">输入房间码加入</button>
</view>
<view wx:if="{{currentRoom}}" class="card continue-card" bindtap="continueGame">
<view class="row between">
<view>
<view class="continue-label">继续上一场</view>
<view class="card-title">{{currentRoom.name}}</view>
</view>
<view class="arrow">→</view>
</view>
<view class="room-meta">{{modeName}} · 房间 {{currentRoom.code}}</view>
</view>
<view class="section-label">怎么玩</view>
<view class="steps">
<view class="step">
<view class="step-number">01</view>
<view>
<view class="step-title">圈定安全场地</view>
<view class="step-copy">选择公园、校园或营地,设置活动范围。</view>
</view>
</view>
<view class="step">
<view class="step-number">02</view>
<view>
<view class="step-title">分享给好友</view>
<view class="step-copy">系统自动分配寻找者和躲藏者身份。</view>
</view>
</view>
<view class="step">
<view class="step-number">03</view>
<view>
<view class="step-title">开始真实追逃</view>
<view class="step-copy">利用模糊线索和技能,在倒计时内完成挑战。</view>
</view>
</view>
</view>
<view class="safety-link" bindtap="openRules">
<text>户外游戏安全规则</text>
<text>查看 →</text>
</view>
</view>

165
pages/home/home.wxss Normal file
View File

@@ -0,0 +1,165 @@
.home-page {
overflow: hidden;
}
.hero {
position: relative;
overflow: hidden;
padding: 52rpx 38rpx 42rpx;
background: #183f31;
border-radius: 40rpx;
color: #fff;
}
.hero .eyebrow {
position: relative;
color: #d9ef72;
}
.hero-title {
position: relative;
margin-top: 22rpx;
font-size: 62rpx;
font-weight: 900;
line-height: 1.18;
letter-spacing: -2rpx;
}
.hero-copy {
position: relative;
width: 88%;
margin-top: 26rpx;
color: rgba(255, 255, 255, 0.76);
font-size: 27rpx;
line-height: 1.7;
}
.hero-players {
position: relative;
display: flex;
align-items: center;
margin-top: 38rpx;
color: rgba(255, 255, 255, 0.68);
font-size: 23rpx;
}
.avatar {
display: flex;
align-items: center;
justify-content: center;
width: 54rpx;
height: 54rpx;
margin-right: -12rpx;
border: 4rpx solid #183f31;
border-radius: 50%;
color: #17231d;
font-size: 20rpx;
font-weight: 800;
}
.avatar-c {
margin-right: 24rpx;
}
.avatar-a {
background: #d9ef72;
}
.avatar-b {
background: #ef8f4a;
}
.avatar-c {
background: #f9e6c4;
}
.hero-orbit {
position: absolute;
border: 2rpx solid rgba(217, 239, 114, 0.23);
border-radius: 50%;
}
.orbit-one {
top: -120rpx;
right: -100rpx;
width: 420rpx;
height: 420rpx;
}
.orbit-two {
top: -32rpx;
right: -20rpx;
width: 220rpx;
height: 220rpx;
}
.actions {
margin-top: 28rpx;
}
.continue-card {
border-left: 10rpx solid #ef8f4a;
}
.continue-label {
margin-bottom: 10rpx;
color: #9c531e;
font-size: 22rpx;
font-weight: 800;
}
.arrow {
font-size: 44rpx;
}
.room-meta {
margin-top: 14rpx;
color: #68726c;
font-size: 24rpx;
}
.steps {
overflow: hidden;
background: #fffdf7;
border-radius: 28rpx;
}
.step {
display: flex;
gap: 24rpx;
padding: 26rpx 28rpx;
border-bottom: 2rpx solid rgba(23, 35, 29, 0.07);
}
.step:last-child {
border-bottom: none;
}
.step-number {
color: #1d6b4f;
font-size: 22rpx;
font-weight: 900;
letter-spacing: 2rpx;
}
.step-title {
font-size: 28rpx;
font-weight: 800;
}
.step-copy {
margin-top: 8rpx;
color: #68726c;
font-size: 24rpx;
line-height: 1.5;
}
.safety-link {
display: flex;
justify-content: space-between;
margin-top: 34rpx;
padding: 24rpx 4rpx;
color: #1d6b4f;
font-size: 25rpx;
font-weight: 700;
}

82
pages/join/join.js Normal file
View File

@@ -0,0 +1,82 @@
const service = require("../../services/game-service");
Page({
data: {
code: "",
name: "我",
avatar: "我",
preview: null,
previewHint: "",
canJoin: false,
submitting: false
},
onLoad(options) {
if (options.code) {
this.setData({ code: options.code });
this.previewRoom(options.code);
}
},
onCodeInput(event) {
const code = event.detail.value.replace(/\D/g, "").slice(0, 6);
this.setData({ code, preview: null, previewHint: "", canJoin: false });
if (code.length === 6) this.previewRoom(code);
},
onNameInput(event) {
const name = event.detail.value;
this.setData({ name, avatar: name.slice(0, 1) || "?" });
},
previewRoom(code) {
const preview = service.getRoom(code);
let previewHint = "";
let canJoin = false;
if (!preview) {
previewHint = "没有找到这个房间,请检查房间码";
} else if (preview.status === "finished") {
previewHint = "这场游戏已经结束";
} else if (preview.status === "cancelled") {
previewHint = "房间已被房主取消";
} else if (preview.status !== "waiting") {
previewHint = "游戏已经开始,暂时无法加入";
} else {
const alreadyJoined = preview.players.some(
(player) => player.id === service.USER_ID
);
canJoin = alreadyJoined || preview.players.length < preview.settings.maxPlayers;
if (!canJoin) previewHint = "房间人数已满";
}
this.setData({ preview, previewHint, canJoin });
},
useDemoCode() {
this.setData({ code: "731204" });
this.previewRoom("731204");
},
join() {
if (this.data.submitting) return;
if (this.data.code.length !== 6) {
wx.showToast({ title: "请输入六位房间码", icon: "none" });
return;
}
if (!this.data.name.trim()) {
wx.showToast({ title: "请输入游戏昵称", icon: "none" });
return;
}
if (!this.data.canJoin) {
wx.showToast({ title: this.data.previewHint || "当前不能加入房间", icon: "none" });
return;
}
this.setData({ submitting: true });
try {
const room = service.joinRoom(this.data.code, this.data.name.trim());
wx.redirectTo({ url: `/pages/lobby/lobby?code=${room.code}` });
} catch (error) {
this.setData({ submitting: false });
wx.showToast({ title: error.message, icon: "none" });
}
}
});

3
pages/join/join.json Normal file
View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "加入游戏"
}

57
pages/join/join.wxml Normal file
View File

@@ -0,0 +1,57 @@
<view class="page join-page">
<view class="eyebrow">JOIN THE GAME</view>
<view class="title">找到你的队伍</view>
<view class="subtitle">向房主索取六位房间码,或直接打开好友分享的小程序卡片。</view>
<view class="code-card">
<view class="code-label">房间码</view>
<input
type="number"
maxlength="6"
value="{{code}}"
placeholder="000000"
focus
bindinput="onCodeInput"
/>
<view class="demo-link" bindtap="useDemoCode">没有房间?使用演示房间 731204</view>
</view>
<view wx:if="{{preview}}" class="card room-preview">
<view class="row between">
<view>
<view class="preview-label">找到房间</view>
<view class="card-title">{{preview.name}}</view>
</view>
<view class="pill">{{preview.players.length}} / {{preview.settings.maxPlayers}} 人</view>
</view>
<view class="divider"></view>
<view class="preview-meta">
<text>{{preview.settings.mode === 'infection' ? '感染模式' : '经典模式'}}</text>
<text>{{preview.settings.durationMinutes}} 分钟</text>
<text>{{preview.settings.radiusMeters}} 米范围</text>
</view>
</view>
<view wx:if="{{previewHint}}" class="preview-error">{{previewHint}}</view>
<view class="section-label">你的游戏昵称</view>
<view class="name-input">
<view class="name-avatar">{{avatar}}</view>
<input
maxlength="10"
value="{{name}}"
placeholder="输入昵称"
bindinput="onNameInput"
/>
</view>
<button
class="primary-button join-button"
loading="{{submitting}}"
disabled="{{submitting || !canJoin}}"
bindtap="join"
>
加入房间
</button>
<view class="privacy-tip">加入即表示你同意本局安全规则。精确位置不会直接展示给其他玩家。</view>
</view>

91
pages/join/join.wxss Normal file
View File

@@ -0,0 +1,91 @@
.code-card {
margin-top: 44rpx;
padding: 34rpx 30rpx;
background: #183f31;
border-radius: 32rpx;
color: #fff;
text-align: center;
}
.code-label {
color: rgba(255, 255, 255, 0.66);
font-size: 23rpx;
font-weight: 700;
}
.code-card input {
height: 130rpx;
color: #d9ef72;
font-size: 70rpx;
font-weight: 900;
letter-spacing: 16rpx;
}
.demo-link {
color: rgba(255, 255, 255, 0.7);
font-size: 23rpx;
text-decoration: underline;
}
.preview-label {
margin-bottom: 8rpx;
color: #1d6b4f;
font-size: 22rpx;
font-weight: 800;
}
.preview-meta {
display: flex;
flex-wrap: wrap;
gap: 24rpx;
color: #68726c;
font-size: 23rpx;
}
.preview-error {
margin-top: 18rpx;
padding: 20rpx 24rpx;
background: #fff0ee;
border-radius: 20rpx;
color: #a63b35;
font-size: 23rpx;
line-height: 1.5;
}
.name-input {
display: flex;
align-items: center;
gap: 20rpx;
padding: 18rpx 24rpx;
background: #fffdf7;
border-radius: 24rpx;
}
.name-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 70rpx;
height: 70rpx;
background: #d9ef72;
border-radius: 50%;
font-weight: 900;
}
.name-input input {
flex: 1;
height: 70rpx;
font-size: 29rpx;
}
.join-button {
margin-top: 40rpx;
}
.privacy-tip {
padding: 22rpx;
color: #7c8580;
font-size: 22rpx;
line-height: 1.6;
text-align: center;
}

116
pages/lobby/lobby.js Normal file
View File

@@ -0,0 +1,116 @@
const service = require("../../services/game-service");
const { GAME_MODES } = require("../../utils/constants");
Page({
data: {
code: "",
room: null,
modeName: "",
isHost: false,
showStartButton: false,
meReady: false,
canStart: false,
startHint: "",
errorMessage: "",
starting: false
},
onLoad(options) {
this.setData({ code: options.code || "" });
this.loadRoom(true);
},
onShow() {
if (this.data.code) this.loadRoom(false);
},
onPullDownRefresh() {
this.loadRoom(false);
wx.stopPullDownRefresh();
},
loadRoom(addBots) {
let room = service.getRoom(this.data.code);
if (!room) {
this.setData({ room: null, errorMessage: "房间不存在或本地数据已被清理" });
return;
}
if (addBots && room.players.length < Math.min(6, room.settings.maxPlayers)) {
room = service.addDemoPlayers(
room.code,
Math.min(6, room.settings.maxPlayers)
);
}
if (room.status !== "waiting") {
const target = room.status === "finished" ? "result" : "game";
wx.redirectTo({
url: `/pages/${target}/${target}?code=${room.code}`
});
return;
}
const me = room.players.find((player) => player.id === service.USER_ID);
const isHost = room.hostId === service.USER_ID;
const canOperate = isHost || room.code === "731204";
const enoughPlayers = room.players.length >= 4;
const seekersValid = room.settings.seekerCount < room.players.length;
const everyoneReady = room.players.every((player) => player.ready);
let startHint = "分配身份并开始";
if (!enoughPlayers) startHint = "至少需要 4 人";
else if (!seekersValid) startHint = "寻找者人数过多";
else if (!everyoneReady) startHint = "等待所有玩家准备";
this.setData({
room,
errorMessage: "",
modeName: GAME_MODES[room.settings.mode].name,
isHost,
showStartButton: canOperate,
meReady: me ? me.ready : false,
canStart: canOperate && enoughPlayers && seekersValid && everyoneReady,
startHint
});
},
toggleReady() {
try {
service.toggleReady(this.data.code);
wx.vibrateShort({ type: "light" });
this.loadRoom(false);
} catch (error) {
wx.showToast({ title: error.message, icon: "none" });
}
},
startGame() {
if (this.data.starting) return;
this.setData({ starting: true });
try {
service.startGame(this.data.code);
wx.redirectTo({
url: `/pages/game/game?code=${this.data.code}`
});
} catch (error) {
this.setData({ starting: false });
wx.showToast({ title: error.message, icon: "none" });
}
},
copyCode() {
wx.setClipboardData({
data: this.data.code,
success: () => wx.showToast({ title: "房间码已复制", icon: "success" })
});
},
backHome() {
service.clearCurrentRoom();
wx.reLaunch({ url: "/pages/home/home" });
},
onShareAppMessage() {
return {
title: `来加入「${this.data.room.name}`,
path: `/pages/join/join?code=${this.data.code}`
};
}
});

4
pages/lobby/lobby.json Normal file
View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "游戏候场",
"enablePullDownRefresh": true
}

89
pages/lobby/lobby.wxml Normal file
View File

@@ -0,0 +1,89 @@
<view class="page lobby-page" wx:if="{{room}}">
<view class="room-head">
<view class="row between">
<view class="pill">{{modeName}}</view>
<view class="room-status"><view class="pulse"></view>等待加入</view>
</view>
<view class="room-name">{{room.name}}</view>
<view class="code-wrap" bindtap="copyCode">
<view>
<view class="code-label">房间码 · 点击复制</view>
<view class="room-code">{{room.code}}</view>
</view>
<button class="share-mini" open-type="share">邀请好友</button>
</view>
</view>
<view class="rule-strip">
<view>
<text class="rule-number">{{room.settings.durationMinutes}}</text>
<text class="rule-unit">分钟</text>
<view class="rule-name">游戏时间</view>
</view>
<view>
<text class="rule-number">{{room.settings.radiusMeters}}</text>
<text class="rule-unit">米</text>
<view class="rule-name">活动半径</view>
</view>
<view>
<text class="rule-number">{{room.settings.seekerCount}}</text>
<text class="rule-unit">人</text>
<view class="rule-name">寻找者</view>
</view>
</view>
<view class="row between player-heading">
<view class="section-label">玩家 {{room.players.length}} / {{room.settings.maxPlayers}}</view>
<view class="muted demo-note">已加入模拟队友</view>
</view>
<view class="player-list">
<view wx:for="{{room.players}}" wx:key="id" class="player-row">
<view class="player-avatar {{item.isHost ? 'host' : ''}}">{{item.avatar}}</view>
<view class="player-main">
<view class="player-name">
{{item.name}}
<text wx:if="{{item.isHost}}" class="host-tag">房主</text>
<text wx:if="{{item.isBot}}" class="bot-tag">模拟</text>
</view>
<view class="player-state">{{item.ready ? '已准备好' : '正在确认装备'}}</view>
</view>
<view class="ready-dot {{item.ready ? 'ready' : ''}}">
{{item.ready ? '✓' : '…'}}
</view>
</view>
</view>
<view class="tip-card">
<view class="tip-icon">!</view>
<view>
<view class="tip-title">开局前安全确认</view>
<view class="tip-copy">确保所有人定位可用、电量充足,并约定紧急集合点。抓捕只用口令确认,禁止身体接触。</view>
</view>
</view>
<view class="button-stack">
<button
wx:if="{{showStartButton}}"
class="primary-button"
loading="{{starting}}"
disabled="{{starting || !canStart}}"
bindtap="startGame"
>
{{startHint}}
</button>
<button
wx:else
class="{{meReady ? 'ghost-button' : 'secondary-button'}}"
bindtap="toggleReady"
>
{{meReady ? '取消准备' : '我准备好了'}}
</button>
</view>
</view>
<view class="state-page" wx:elif="{{errorMessage}}">
<view class="state-icon">!</view>
<view class="state-title">无法进入候场</view>
<view class="state-copy">{{errorMessage}}</view>
<button class="primary-button state-action" bindtap="backHome">返回首页</button>
</view>

221
pages/lobby/lobby.wxss Normal file
View File

@@ -0,0 +1,221 @@
.room-head {
padding: 30rpx;
background: #183f31;
border-radius: 34rpx;
color: #fff;
}
.room-status {
display: flex;
align-items: center;
gap: 10rpx;
color: rgba(255, 255, 255, 0.72);
font-size: 23rpx;
}
.pulse {
width: 14rpx;
height: 14rpx;
background: #d9ef72;
border-radius: 50%;
box-shadow: 0 0 0 8rpx rgba(217, 239, 114, 0.12);
}
.room-name {
margin-top: 28rpx;
font-size: 43rpx;
font-weight: 900;
overflow-wrap: anywhere;
}
.code-wrap {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-top: 34rpx;
padding-top: 26rpx;
border-top: 2rpx solid rgba(255, 255, 255, 0.12);
}
.code-label {
color: rgba(255, 255, 255, 0.6);
font-size: 21rpx;
}
.room-code {
margin-top: 5rpx;
color: #d9ef72;
font-size: 50rpx;
font-weight: 900;
letter-spacing: 7rpx;
}
.share-mini {
width: 180rpx;
height: 70rpx;
margin: 0;
background: #d9ef72;
color: #17231d;
font-size: 24rpx;
}
.rule-strip {
display: flex;
justify-content: space-around;
margin-top: 22rpx;
padding: 26rpx 10rpx;
background: #fffdf7;
border-radius: 26rpx;
text-align: center;
}
.rule-number {
font-size: 36rpx;
font-weight: 900;
}
.rule-unit {
margin-left: 4rpx;
color: #68726c;
font-size: 20rpx;
}
.rule-name {
margin-top: 6rpx;
color: #7c8580;
font-size: 21rpx;
}
.player-heading .section-label {
margin-bottom: 12rpx;
}
.demo-note {
margin-top: 26rpx;
font-size: 20rpx;
}
.player-list {
overflow: hidden;
background: #fffdf7;
border-radius: 28rpx;
}
.player-row {
display: flex;
align-items: center;
gap: 20rpx;
padding: 22rpx 26rpx;
border-bottom: 2rpx solid rgba(23, 35, 29, 0.07);
}
.player-row:last-child {
border-bottom: none;
}
.player-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 66rpx;
height: 66rpx;
background: #e7eee8;
border-radius: 22rpx;
font-size: 25rpx;
font-weight: 800;
}
.player-avatar.host {
background: #d9ef72;
}
.player-main {
flex: 1;
min-width: 0;
}
.player-name {
overflow: hidden;
font-size: 27rpx;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.host-tag,
.bot-tag {
margin-left: 10rpx;
padding: 3rpx 10rpx;
background: #e7eee8;
border-radius: 99rpx;
color: #1d6b4f;
font-size: 18rpx;
}
.bot-tag {
background: #fff0df;
color: #9c531e;
}
.player-state {
margin-top: 6rpx;
color: #89918c;
font-size: 21rpx;
}
.ready-dot {
display: flex;
align-items: center;
justify-content: center;
width: 42rpx;
height: 42rpx;
background: #eef0ed;
border-radius: 50%;
color: #8b938e;
font-size: 22rpx;
}
.ready-dot.ready {
background: #1d6b4f;
color: #fff;
}
.tip-card {
display: flex;
gap: 20rpx;
margin-top: 26rpx;
padding: 24rpx;
background: #fff0df;
border-radius: 24rpx;
}
.tip-icon {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 46rpx;
height: 46rpx;
background: #ef8f4a;
border-radius: 50%;
color: #fff;
font-weight: 900;
}
.tip-title {
color: #813f16;
font-size: 25rpx;
font-weight: 800;
}
.tip-copy {
margin-top: 8rpx;
color: #9c531e;
font-size: 22rpx;
line-height: 1.55;
}
button[disabled] {
background: #c8ceca;
color: #fff;
}

98
pages/result/result.js Normal file
View File

@@ -0,0 +1,98 @@
const service = require("../../services/game-service");
const { formatCountdown } = require("../../utils/format");
Page({
data: {
code: "",
room: null,
winnerName: "",
winnerCopy: "",
ranking: [],
elapsedText: "00:00",
eventCount: 0,
errorMessage: "",
replaying: false
},
onLoad(options) {
const code = options.code || "";
const room = service.getRoom(code);
if (!room) {
this.setData({ errorMessage: "战报不存在或本地数据已被清理" });
return;
}
const hidersWin = room.winner === "hiders";
const snapshot = room.resultSnapshot;
const sourcePlayers = snapshot
? snapshot.players
: room.players.map((player) => ({
...player,
initialRole: player.initialRole || player.role,
finalRole: player.role,
survivedSeconds: 0
}));
const ranking = [...sourcePlayers]
.sort((a, b) => {
if (b.captures !== a.captures) return b.captures - a.captures;
if (b.survivedSeconds !== a.survivedSeconds) {
return b.survivedSeconds - a.survivedSeconds;
}
if (a.joinedAt !== b.joinedAt) return a.joinedAt - b.joinedAt;
return a.id.localeCompare(b.id);
})
.map((player, index) => ({
...player,
rank: index + 1,
roleText: player.initialRole === "hider" && player.finalRole === "seeker"
? "躲藏者→寻找者"
: player.initialRole === "seeker" ? "寻找者" : "躲藏者",
resultText:
player.captures > 0
? `完成 ${player.captures} 次抓捕`
: player.caughtAt
? "勇敢参与追逃"
: player.initialRole === "hider" ? "坚持到游戏结束" : "持续追踪目标"
}));
this.setData({
code,
room,
errorMessage: "",
winnerName: hidersWin ? "躲藏者胜利" : "寻找者胜利",
winnerCopy: hidersWin
? "有人成功坚持到了倒计时结束。"
: "所有躲藏者都已被找到。",
ranking,
elapsedText: formatCountdown(snapshot ? snapshot.elapsedSeconds : 0),
eventCount: snapshot ? snapshot.eventCount : room.events.length
});
},
replay() {
if (this.data.replaying) return;
this.setData({ replaying: true });
try {
service.resetRoom(this.data.code);
wx.redirectTo({
url: `/pages/lobby/lobby?code=${this.data.code}`
});
} catch (error) {
this.setData({ replaying: false });
wx.showToast({ title: error.message, icon: "none" });
}
},
backHome() {
service.clearCurrentRoom();
wx.reLaunch({ url: "/pages/home/home" });
},
onShareAppMessage() {
if (!this.data.room) {
return { title: "藏猫猫计划", path: "/pages/home/home" };
}
return {
title: `${this.data.room.name}${this.data.winnerName}`,
path: "/pages/home/home"
};
}
});

3
pages/result/result.json Normal file
View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "本局战报"
}

65
pages/result/result.wxml Normal file
View File

@@ -0,0 +1,65 @@
<view class="page result-page" wx:if="{{room}}">
<view class="result-hero">
<view class="confetti confetti-a"></view>
<view class="confetti confetti-b"></view>
<view class="trophy">★</view>
<view class="result-eyebrow">GAME COMPLETE</view>
<view class="winner">{{winnerName}}</view>
<view class="winner-copy">{{winnerCopy}}</view>
<view class="result-meta">{{room.name}} · 房间 {{room.code}}</view>
</view>
<view class="highlight-grid">
<view class="highlight">
<view class="highlight-value">{{room.players.length}}</view>
<view class="highlight-label">参与玩家</view>
</view>
<view class="highlight">
<view class="highlight-value">{{elapsedText}}</view>
<view class="highlight-label">实际用时</view>
</view>
<view class="highlight">
<view class="highlight-value">{{eventCount}}</view>
<view class="highlight-label">关键事件</view>
</view>
</view>
<view class="section-label">本局表现</view>
<view class="ranking-list">
<view wx:for="{{ranking}}" wx:key="id" class="ranking-row">
<view class="rank {{item.rank <= 3 ? 'top' : ''}}">{{item.rank}}</view>
<view class="rank-avatar">{{item.avatar}}</view>
<view class="rank-main">
<view class="rank-name">{{item.name}}</view>
<view class="rank-copy">{{item.resultText}}</view>
</view>
<view class="rank-role">{{item.roleText}}</view>
</view>
</view>
<view class="badge-card">
<view class="badge-icon">↗</view>
<view>
<view class="badge-title">本局称号 · 城市追踪者</view>
<view class="badge-copy">完成了第一场真实世界追逃。下一局试试更大的场地吧。</view>
</view>
</view>
<view class="button-stack">
<button class="secondary-button" open-type="share">分享本局战报</button>
<button
class="primary-button"
loading="{{replaying}}"
disabled="{{replaying}}"
bindtap="replay"
>按原规则再来一局</button>
<button class="ghost-button" bindtap="backHome">返回首页</button>
</view>
</view>
<view class="state-page" wx:elif="{{errorMessage}}">
<view class="state-icon">!</view>
<view class="state-title">战报已不可用</view>
<view class="state-copy">{{errorMessage}}</view>
<button class="primary-button state-action" bindtap="backHome">返回首页</button>
</view>

200
pages/result/result.wxss Normal file
View File

@@ -0,0 +1,200 @@
.result-hero {
position: relative;
overflow: hidden;
padding: 50rpx 30rpx;
background: #183f31;
border-radius: 36rpx;
color: #fff;
text-align: center;
}
.trophy {
display: flex;
align-items: center;
justify-content: center;
width: 100rpx;
height: 100rpx;
margin: 0 auto 24rpx;
background: #d9ef72;
border-radius: 32rpx;
color: #17231d;
font-size: 54rpx;
}
.result-eyebrow {
color: rgba(255, 255, 255, 0.58);
font-size: 20rpx;
font-weight: 800;
letter-spacing: 4rpx;
}
.winner {
margin-top: 12rpx;
font-size: 52rpx;
font-weight: 900;
}
.winner-copy {
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.72);
font-size: 25rpx;
}
.result-meta {
margin-top: 32rpx;
padding-top: 24rpx;
border-top: 2rpx solid rgba(255, 255, 255, 0.12);
color: #d9ef72;
font-size: 22rpx;
}
.confetti {
position: absolute;
width: 20rpx;
height: 70rpx;
background: #ef8f4a;
transform: rotate(28deg);
}
.confetti-a {
top: 70rpx;
left: 45rpx;
}
.confetti-b {
top: 120rpx;
right: 50rpx;
height: 48rpx;
background: #d9ef72;
transform: rotate(-35deg);
}
.highlight-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
margin-top: 20rpx;
}
.highlight {
padding: 24rpx 10rpx;
background: #fffdf7;
border-radius: 22rpx;
text-align: center;
}
.highlight-value {
font-size: 38rpx;
font-weight: 900;
}
.highlight-label {
margin-top: 5rpx;
color: #7c8580;
font-size: 20rpx;
}
.ranking-list {
overflow: hidden;
background: #fffdf7;
border-radius: 28rpx;
}
.ranking-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 22rpx 20rpx;
border-bottom: 2rpx solid rgba(23, 35, 29, 0.07);
}
.ranking-row:last-child {
border-bottom: none;
}
.rank {
width: 32rpx;
color: #8b938e;
font-size: 23rpx;
font-weight: 900;
text-align: center;
}
.rank.top {
color: #ef8f4a;
}
.rank-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 62rpx;
height: 62rpx;
background: #e7eee8;
border-radius: 20rpx;
font-size: 23rpx;
font-weight: 800;
}
.rank-main {
flex: 1;
min-width: 0;
}
.rank-name {
overflow: hidden;
font-size: 26rpx;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank-copy {
margin-top: 5rpx;
color: #7c8580;
font-size: 20rpx;
}
.rank-role {
flex: 0 0 auto;
max-width: 150rpx;
color: #1d6b4f;
font-size: 21rpx;
font-weight: 700;
text-align: right;
}
.badge-card {
display: flex;
gap: 20rpx;
margin-top: 26rpx;
padding: 26rpx;
background: #fff0df;
border-radius: 26rpx;
}
.badge-icon {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 70rpx;
height: 70rpx;
background: #ef8f4a;
border-radius: 22rpx;
color: #fff;
font-size: 36rpx;
}
.badge-title {
color: #813f16;
font-size: 26rpx;
font-weight: 800;
}
.badge-copy {
margin-top: 8rpx;
color: #9c531e;
font-size: 21rpx;
line-height: 1.5;
}

37
pages/rules/rules.js Normal file
View File

@@ -0,0 +1,37 @@
Page({
data: {
rules: [
{
number: "01",
title: "选择安全场地",
copy: "仅在公园、校园、营地等获准且低车流区域游戏。远离机动车道、水域、施工区和陡坡。"
},
{
number: "02",
title: "抓捕不发生身体接触",
copy: "发现目标后保持安全距离,通过动态口令或扫码确认。禁止拉扯、冲撞、围堵和危险追逐。"
},
{
number: "03",
title: "尊重公共与私人空间",
copy: "不得进入住宅、厕所、商铺后场、封闭建筑或未经许可的区域躲藏。不给路人造成困扰。"
},
{
number: "04",
title: "随时可以安全退出",
copy: "感到不适、迷路、天气变化或设备异常时立即停止游戏,告知房主并前往约定集合点。"
},
{
number: "05",
title: "保护位置隐私",
copy: "不要截图或转发他人的位置和口令。游戏结束后不应保留或公开他人的精确行动轨迹。"
}
]
},
accept() {
wx.navigateBack({
fail: () => wx.reLaunch({ url: "/pages/home/home" })
});
}
});

3
pages/rules/rules.json Normal file
View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "安全规则"
}

25
pages/rules/rules.wxml Normal file
View File

@@ -0,0 +1,25 @@
<view class="page">
<view class="rules-hero">
<view class="rules-icon">+</view>
<view class="eyebrow">SAFETY FIRST</view>
<view class="title">安全永远高于胜负</view>
<view class="subtitle">开始前请由房主带领所有玩家确认场地、集合点和以下规则。</view>
</view>
<view class="rule-list">
<view wx:for="{{rules}}" wx:key="number" class="rule-card">
<view class="rule-number">{{item.number}}</view>
<view>
<view class="rule-title">{{item.title}}</view>
<view class="rule-copy">{{item.copy}}</view>
</view>
</view>
</view>
<view class="emergency-card">
<view class="emergency-title">遇到紧急情况</view>
<view class="emergency-copy">立即停止游戏并移动到安全位置,联系同行人员或当地紧急服务。小程序不能替代专业救援。</view>
</view>
<button class="primary-button" bindtap="accept">我已了解</button>
</view>

74
pages/rules/rules.wxss Normal file
View File

@@ -0,0 +1,74 @@
.rules-hero {
position: relative;
overflow: hidden;
padding: 38rpx 30rpx;
background: #fffdf7;
border-radius: 32rpx;
}
.rules-icon {
display: flex;
align-items: center;
justify-content: center;
width: 70rpx;
height: 70rpx;
margin-bottom: 24rpx;
background: #d9ef72;
border-radius: 24rpx;
color: #183f31;
font-size: 45rpx;
font-weight: 900;
}
.rule-list {
margin: 32rpx 0;
}
.rule-card {
display: flex;
gap: 22rpx;
margin-bottom: 16rpx;
padding: 26rpx;
background: #fffdf7;
border-radius: 24rpx;
}
.rule-number {
flex: 0 0 auto;
color: #1d6b4f;
font-size: 22rpx;
font-weight: 900;
letter-spacing: 2rpx;
}
.rule-title {
font-size: 28rpx;
font-weight: 800;
}
.rule-copy {
margin-top: 10rpx;
color: #68726c;
font-size: 23rpx;
line-height: 1.65;
}
.emergency-card {
margin-bottom: 28rpx;
padding: 26rpx;
background: #ffe8e5;
border-radius: 24rpx;
}
.emergency-title {
color: #a63b35;
font-size: 27rpx;
font-weight: 800;
}
.emergency-copy {
margin-top: 10rpx;
color: #9b504a;
font-size: 23rpx;
line-height: 1.6;
}

48
project.config.json Normal file
View File

@@ -0,0 +1,48 @@
{
"description": "藏猫猫计划微信小程序原型",
"packOptions": {
"ignore": [
{
"value": "docs",
"type": "folder"
}
],
"include": []
},
"setting": {
"useCompilerPlugins": false,
"es6": true,
"enhance": true,
"postcss": true,
"minified": true,
"urlCheck": false,
"coverView": true,
"showShadowRootInWxmlPanel": true,
"compileHotReLoad": true,
"lazyloadPlaceholderEnable": false,
"compileWorklet": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"condition": false,
"swc": false,
"disableSWC": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"compileType": "miniprogram",
"libVersion": "3.17.0",
"appid": "wxfbaabed3c4ce758b",
"projectname": "mini-zmc",
"condition": {},
"simulatorPluginLibVersion": {},
"editorSetting": {}
}

View File

@@ -0,0 +1,22 @@
{
"libVersion": "3.16.2",
"projectname": "mini-zmc",
"condition": {},
"setting": {
"urlCheck": false,
"coverView": true,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"useApiHook": true,
"showShadowRootInWxmlPanel": true,
"useStaticServer": false,
"useLanDebug": false,
"showES6CompileOption": false,
"compileHotReLoad": true,
"checkInvalidKey": true,
"ignoreDevUnusedFiles": true,
"bigPackageSizeSupport": false
}
}

812
services/game-service.js Normal file
View File

@@ -0,0 +1,812 @@
const {
DEFAULT_SETTINGS,
BOT_NAMES,
AREA_CLUES
} = require("../utils/constants");
const { randomCode, clone } = require("../utils/format");
const ROOMS_KEY = "zmc_rooms";
const CURRENT_ROOM_KEY = "zmc_current_room";
const USER_ID = "player-me";
const ROOM_SCHEMA_VERSION = 2;
const ROOM_STATUSES = ["waiting", "preparing", "playing", "paused", "finished", "cancelled"];
const PLAYER_ROLES = ["", "seeker", "hider"];
const PLAYER_STATUSES = ["idle", "ready", "hiding", "seeking", "caught", "spectating", "offline", "quit"];
const DEFAULT_RUNTIME = {
demoAccelerated: true,
prepareSecondsPerMinute: 1,
maxGameSeconds: 180,
demoClueIntervalSeconds: 15
};
function isRecord(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function asNumber(value, fallback) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function assertIntegerInRange(value, min, max, message) {
if (!Number.isInteger(value) || value < min || value > max) {
throw new Error(message);
}
}
function validateRoomPayload(payload) {
const source = isRecord(payload) ? payload : {};
const name = String(source.name || "").trim();
if (!name) throw new Error("请填写房间名称");
const settings = normalizeSettings(source);
assertIntegerInRange(settings.durationMinutes, 15, 90, "游戏时长应为 1590 分钟");
assertIntegerInRange(settings.prepareMinutes, 1, 10, "准备时长应为 110 分钟");
assertIntegerInRange(settings.maxPlayers, 4, 30, "房间人数应为 430 人");
assertIntegerInRange(settings.seekerCount, 1, 29, "初始寻找者人数应为 129 人");
assertIntegerInRange(settings.clueIntervalMinutes, 3, 10, "线索间隔应为 310 分钟");
assertIntegerInRange(settings.radiusMeters, 200, 1500, "活动半径应为 2001500 米");
if (settings.seekerCount >= settings.maxPlayers) {
throw new Error("初始寻找者必须少于房间人数上限");
}
return {
...settings,
name: name.slice(0, 16)
};
}
function normalizeSettings(settings) {
const source = isRecord(settings) ? settings : {};
const merged = { ...DEFAULT_SETTINGS, ...source };
return {
mode: merged.mode === "classic" ? "classic" : "infection",
durationMinutes: asNumber(merged.durationMinutes, DEFAULT_SETTINGS.durationMinutes),
prepareMinutes: asNumber(merged.prepareMinutes, DEFAULT_SETTINGS.prepareMinutes),
maxPlayers: asNumber(merged.maxPlayers, DEFAULT_SETTINGS.maxPlayers),
seekerCount: asNumber(merged.seekerCount, DEFAULT_SETTINGS.seekerCount),
clueIntervalMinutes: asNumber(merged.clueIntervalMinutes, DEFAULT_SETTINGS.clueIntervalMinutes),
radiusMeters: asNumber(merged.radiusMeters, DEFAULT_SETTINGS.radiusMeters),
skillsEnabled: merged.skillsEnabled !== false
};
}
function normalizeRuntime(runtime) {
const source = isRecord(runtime) ? runtime : {};
return {
demoAccelerated: source.demoAccelerated !== false,
prepareSecondsPerMinute: Math.max(1, asNumber(
source.prepareSecondsPerMinute,
DEFAULT_RUNTIME.prepareSecondsPerMinute
)),
maxGameSeconds: Math.max(1, asNumber(
source.maxGameSeconds,
DEFAULT_RUNTIME.maxGameSeconds
)),
demoClueIntervalSeconds: Math.max(1, asNumber(
source.demoClueIntervalSeconds,
DEFAULT_RUNTIME.demoClueIntervalSeconds
))
};
}
function getPrepareDurationSeconds(room) {
if (room.runtime.demoAccelerated) {
return room.settings.prepareMinutes * room.runtime.prepareSecondsPerMinute;
}
return room.settings.prepareMinutes * 60;
}
function getGameDurationSeconds(room) {
const configured = room.settings.durationMinutes * 60;
return room.runtime.demoAccelerated
? Math.min(configured, room.runtime.maxGameSeconds)
: configured;
}
function getClueIntervalSeconds(room) {
return room.runtime.demoAccelerated
? room.runtime.demoClueIntervalSeconds
: room.settings.clueIntervalMinutes * 60;
}
function normalizePlayer(player, index, roomCreatedAt) {
const source = isRecord(player) ? player : {};
const fallbackName = `玩家${index + 1}`;
const name = String(source.name || fallbackName).slice(0, 10);
const ready = Boolean(source.ready);
return {
...source,
id: String(source.id || `legacy-player-${index}`),
name,
avatar: String(source.avatar || name.slice(0, 1) || "?"),
isHost: Boolean(source.isHost),
isBot: Boolean(source.isBot),
ready,
role: PLAYER_ROLES.includes(source.role) ? source.role : "",
status: PLAYER_STATUSES.includes(source.status)
? source.status
: ready ? "ready" : "idle",
caughtAt: source.caughtAt || null,
caughtBy: source.caughtBy || null,
captures: Math.max(0, asNumber(source.captures, 0)),
initialRole: PLAYER_ROLES.includes(source.initialRole) ? source.initialRole : source.role || "",
skillsUsed: {
radar: Math.max(0, asNumber(source.skillsUsed && source.skillsUsed.radar, 0)),
silent: Math.max(0, asNumber(source.skillsUsed && source.skillsUsed.silent, 0))
},
silentPending: Boolean(source.silentPending),
joinedAt: asNumber(source.joinedAt, roomCreatedAt)
};
}
function normalizeEvent(event, index, roomCreatedAt) {
const source = isRecord(event) ? event : {};
return {
...source,
id: String(source.id || `legacy-event-${roomCreatedAt}-${index}`),
type: String(source.type || "system"),
message: String(source.message || "游戏状态已更新"),
createdAt: asNumber(source.createdAt, roomCreatedAt)
};
}
function normalizeRoom(room, codeHint = "") {
if (!isRecord(room)) return null;
const code = String(room.code || codeHint || "");
if (!/^\d{6}$/.test(code)) return null;
const createdAt = asNumber(room.createdAt, Date.now());
const players = Array.isArray(room.players)
? room.players.map((player, index) => normalizePlayer(player, index, createdAt))
: [];
const events = Array.isArray(room.events)
? room.events.slice(0, 20).map((event, index) => normalizeEvent(event, index, createdAt))
: [];
return {
...room,
schemaVersion: ROOM_SCHEMA_VERSION,
id: String(room.id || `room-${code}`),
code,
name: String(room.name || "未命名游戏").slice(0, 16),
hostId: String(room.hostId || (players[0] && players[0].id) || ""),
status: ROOM_STATUSES.includes(room.status) ? room.status : "waiting",
settings: normalizeSettings(room.settings),
runtime: normalizeRuntime(room.runtime),
players,
events,
captureTokensUsed: Array.isArray(room.captureTokensUsed)
? room.captureTokensUsed.slice(-50).map(String)
: [],
winner: room.winner === "hiders" || room.winner === "seekers" ? room.winner : "",
phaseStartedAt: room.phaseStartedAt || null,
phaseEndsAt: room.phaseEndsAt || null,
nextClueAt: room.nextClueAt || null,
finishedAt: room.finishedAt || null,
pausedAt: room.pausedAt || null,
pausedRemainingMs: Math.max(0, asNumber(room.pausedRemainingMs, 0)),
pausedClueRemainingMs: Math.max(0, asNumber(room.pausedClueRemainingMs, 0)),
finishReason: String(room.finishReason || ""),
resultSnapshot: isRecord(room.resultSnapshot) ? room.resultSnapshot : null,
createdAt,
updatedAt: asNumber(room.updatedAt, createdAt)
};
}
function readRooms() {
try {
const rooms = wx.getStorageSync(ROOMS_KEY);
return isRecord(rooms) ? rooms : {};
} catch (error) {
return {};
}
}
function writeRooms(rooms) {
wx.setStorageSync(ROOMS_KEY, rooms);
}
function saveRoom(room) {
const rooms = readRooms();
const normalized = normalizeRoom(room);
if (!normalized) throw new Error("房间数据无效");
normalized.updatedAt = Date.now();
rooms[normalized.code] = normalized;
writeRooms(rooms);
return clone(normalized);
}
function makePlayer(id, name, isHost = false, isBot = false) {
return {
id,
name,
avatar: name.slice(0, 1),
isHost,
isBot,
ready: isHost,
role: "",
initialRole: "",
status: "idle",
caughtAt: null,
caughtBy: null,
captures: 0,
skillsUsed: { radar: 0, silent: 0 },
silentPending: false,
joinedAt: Date.now()
};
}
function makeDemoRoom() {
const room = {
schemaVersion: ROOM_SCHEMA_VERSION,
id: "room-demo",
code: "731204",
name: "周末公园追逃局",
hostId: "demo-host",
status: "waiting",
settings: {
...DEFAULT_SETTINGS,
durationMinutes: 20,
prepareMinutes: 1
},
runtime: { ...DEFAULT_RUNTIME },
players: [
makePlayer("demo-host", "阿树", true, true),
makePlayer("demo-2", "小满", false, true),
makePlayer("demo-3", "木木", false, true)
],
events: [],
createdAt: Date.now()
};
room.players.forEach((player) => {
player.ready = true;
});
return room;
}
function ensureSeedData() {
const rooms = readRooms();
let changed = false;
Object.keys(rooms).forEach((code) => {
const normalized = normalizeRoom(rooms[code], code);
if (normalized) {
rooms[code] = normalized;
} else {
delete rooms[code];
}
changed = true;
});
if (!rooms["731204"]) {
rooms["731204"] = makeDemoRoom();
changed = true;
}
if (changed) {
writeRooms(rooms);
}
}
function createRoom(payload) {
const validated = validateRoomPayload(payload);
const rooms = readRooms();
let code = randomCode();
while (rooms[code]) code = randomCode();
const room = {
schemaVersion: ROOM_SCHEMA_VERSION,
id: `room-${Date.now()}`,
code,
name: validated.name,
hostId: USER_ID,
status: "waiting",
settings: {
...DEFAULT_SETTINGS,
...validated
},
runtime: { ...DEFAULT_RUNTIME },
players: [makePlayer(USER_ID, "我", true, false)],
events: [
{
id: `event-${Date.now()}`,
type: "system",
message: "房间已创建,邀请好友加入吧",
createdAt: Date.now()
}
],
createdAt: Date.now()
};
saveRoom(room);
wx.setStorageSync(CURRENT_ROOM_KEY, code);
return clone(room);
}
function getRoom(code) {
const room = normalizeRoom(readRooms()[code], code);
return room ? clone(room) : null;
}
function getCurrentRoom() {
let code = "";
try {
code = wx.getStorageSync(CURRENT_ROOM_KEY);
} catch (error) {
return null;
}
return code ? getRoom(code) : null;
}
function setCurrentRoom(code) {
wx.setStorageSync(CURRENT_ROOM_KEY, code);
}
function joinRoom(code, displayName = "我") {
const normalizedCode = String(code || "").trim();
if (!/^\d{6}$/.test(normalizedCode)) {
throw new Error("请输入正确的六位房间码");
}
const normalizedName = String(displayName || "").trim().slice(0, 10);
if (!normalizedName) throw new Error("请输入游戏昵称");
const rooms = readRooms();
const room = normalizeRoom(rooms[normalizedCode], normalizedCode);
if (!room) {
throw new Error("没有找到这个房间,请检查房间码");
}
if (room.status === "finished") throw new Error("这场游戏已经结束");
if (room.status === "cancelled") throw new Error("房间已被房主取消");
if (room.status !== "waiting") throw new Error("游戏已经开始,暂时无法加入");
const existing = room.players.find((player) => player.id === USER_ID);
if (!existing) {
if (room.players.length >= room.settings.maxPlayers) {
throw new Error("房间人数已满");
}
const player = makePlayer(USER_ID, normalizedName, false, false);
if (room.code === "731204") {
player.ready = true;
player.status = "ready";
}
room.players.push(player);
} else {
existing.name = normalizedName;
existing.avatar = normalizedName.slice(0, 1);
}
saveRoom(room);
setCurrentRoom(normalizedCode);
return clone(room);
}
function addDemoPlayers(code, targetCount = 6) {
const room = getRoom(code);
if (!room || room.status !== "waiting") return room;
const usedNames = room.players.map((player) => player.name);
const available = BOT_NAMES.filter((name) => !usedNames.includes(name));
while (
room.players.length < targetCount &&
room.players.length < room.settings.maxPlayers &&
available.length
) {
const name = available.shift();
const player = makePlayer(`bot-${Date.now()}-${room.players.length}`, name, false, true);
player.ready = true;
room.players.push(player);
}
return saveRoom(room);
}
function toggleReady(code, playerId = USER_ID) {
const room = getRoom(code);
if (!room) throw new Error("房间不存在");
if (room.status !== "waiting") throw new Error("游戏已开始,不能修改准备状态");
const player = room && room.players.find((item) => item.id === playerId);
if (!player) throw new Error("你不在当前房间中");
player.ready = !player.ready;
player.status = player.ready ? "ready" : "idle";
return saveRoom(room);
}
function startGame(code, operatorId = USER_ID) {
const room = getRoom(code);
if (!room) throw new Error("房间不存在");
if (room.status !== "waiting") throw new Error("当前房间不能重复开始");
const canControlDemo = room.code === "731204" && operatorId === USER_ID;
if (room.hostId !== operatorId && !canControlDemo) {
throw new Error("只有房主可以开始游戏");
}
if (room.players.length < 4) throw new Error("至少需要 4 名玩家");
if (room.settings.seekerCount >= room.players.length) {
throw new Error("寻找者人数必须少于当前玩家人数");
}
if (room.players.some((player) => !player.ready)) {
throw new Error("还有玩家尚未准备好");
}
const seekerCount = room.settings.seekerCount;
const sorted = [...room.players].sort((a, b) => {
if (a.id === USER_ID) return -1;
if (b.id === USER_ID) return 1;
return a.joinedAt - b.joinedAt;
});
sorted.forEach((player, index) => {
player.role = index < seekerCount ? "seeker" : "hider";
player.initialRole = player.role;
player.status = player.role === "seeker" ? "seeking" : "hiding";
player.ready = true;
player.skillsUsed = { radar: 0, silent: 0 };
player.silentPending = false;
});
room.players = sorted;
room.status = "preparing";
room.phaseStartedAt = Date.now();
room.phaseEndsAt = room.phaseStartedAt + getPrepareDurationSeconds(room) * 1000;
room.gameDurationSeconds = getGameDurationSeconds(room);
room.captureTokensUsed = [];
room.events.unshift({
id: `event-${Date.now()}`,
type: "system",
message: "身份已分配,躲藏者请立即出发",
createdAt: Date.now()
});
return saveRoom(room);
}
function beginPlaying(code) {
const room = getRoom(code);
if (!room) throw new Error("房间不存在");
if (room.status !== "preparing") throw new Error("当前房间不能进入追逃阶段");
const scheduledStartAt = room.phaseEndsAt || Date.now();
room.status = "playing";
room.phaseStartedAt = scheduledStartAt;
room.gameDurationSeconds = room.gameDurationSeconds || getGameDurationSeconds(room);
room.phaseEndsAt = scheduledStartAt + room.gameDurationSeconds * 1000;
room.nextClueAt = scheduledStartAt + getClueIntervalSeconds(room) * 1000;
room.events.unshift({
id: `event-${Date.now()}`,
type: "system",
message: "追逃开始!请留意安全边界",
createdAt: Date.now()
});
return saveRoom(room);
}
function addEvent(code, message, type = "system") {
const room = getRoom(code);
if (!room) return null;
room.events.unshift({
id: `event-${Date.now()}-${Math.random()}`,
type,
message,
createdAt: Date.now()
});
room.events = room.events.slice(0, 20);
return saveRoom(room);
}
function revealClue(code) {
const room = getRoom(code);
if (!room) throw new Error("房间不存在");
if (room.status !== "playing") throw new Error("当前阶段不能广播线索");
const silentPlayers = room.players.filter(
(player) => player.role === "hider" && player.status === "hiding" && player.silentPending
);
silentPlayers.forEach((player) => {
player.silentPending = false;
});
const area = AREA_CLUES[Math.floor(Math.random() * AREA_CLUES.length)];
const message = silentPlayers.length
? `${silentPlayers.length} 名目标的静默已生效;${area}`
: area;
room.events.unshift({
id: `event-${Date.now()}-${Math.random()}`,
type: "clue",
message,
createdAt: Date.now()
});
room.events = room.events.slice(0, 20);
room.nextClueAt = Date.now() + getClueIntervalSeconds(room) * 1000;
return saveRoom(room);
}
function useSkill(code, skill, playerId = USER_ID) {
const room = getRoom(code);
if (!room) throw new Error("房间不存在");
if (room.status !== "playing") throw new Error("当前阶段不能使用技能");
if (!room.settings.skillsEnabled) throw new Error("本局未启用角色技能");
const player = room.players.find((item) => item.id === playerId);
if (!player) throw new Error("你不在当前房间中");
const expectedSkill = player.role === "seeker" ? "radar" : "silent";
if (skill !== expectedSkill) throw new Error("当前身份不能使用这个技能");
if (player.status !== (player.role === "seeker" ? "seeking" : "hiding")) {
throw new Error("当前玩家状态不能使用技能");
}
if (player.skillsUsed[skill] >= 1) throw new Error("本局技能已经使用过");
player.skillsUsed[skill] += 1;
if (skill === "radar") {
const nearby = Math.random() > 0.45;
room.events.unshift({
id: `event-${Date.now()}-${Math.random()}`,
type: "skill",
message: nearby ? "雷达回应:附近 150 米内可能有人" : "雷达回应:附近暂未检测到目标",
createdAt: Date.now()
});
} else {
player.silentPending = true;
room.events.unshift({
id: `event-${Date.now()}-${Math.random()}`,
type: "skill",
message: "有目标已开启静默信号",
createdAt: Date.now()
});
}
room.events = room.events.slice(0, 20);
return saveRoom(room);
}
function captureTokenKey(targetId, timeWindow) {
return `${targetId}:${timeWindow}`;
}
function hashCaptureToken(value) {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return String(Math.abs(hash) % 10000).padStart(4, "0");
}
function getCaptureCode(code, targetId = USER_ID, now = Date.now()) {
const room = getRoom(code);
if (!room) return "";
const target = room.players.find(
(player) => player.id === targetId && player.role === "hider" && player.status === "hiding"
);
if (!target) return "";
const timeWindow = Math.floor(now / 30000);
return hashCaptureToken(`${room.code}:${target.id}:${timeWindow}`);
}
function capture(code, targetId, submittedCode, seekerId = USER_ID, now = Date.now()) {
const room = getRoom(code);
if (!room || room.status !== "playing") {
throw new Error("当前不能提交抓捕");
}
const seeker = room.players.find((player) => player.id === seekerId);
if (!seeker || seeker.role !== "seeker" || seeker.status !== "seeking") {
throw new Error("只有正在游戏的寻找者可以提交抓捕");
}
const target = room.players.find((player) => player.id === targetId);
if (!target) throw new Error("没有找到抓捕目标");
const timeWindow = Math.floor(now / 30000);
const tokenKey = captureTokenKey(target.id, timeWindow);
if (room.captureTokensUsed.includes(tokenKey)) throw new Error("口令已经使用过");
if (target.role !== "hider" || target.status !== "hiding") {
throw new Error("目标已不在躲藏状态");
}
if (String(submittedCode) !== getCaptureCode(code, target.id, now)) {
throw new Error("口令不正确或已经失效");
}
room.captureTokensUsed.push(tokenKey);
room.captureTokensUsed = room.captureTokensUsed.slice(-50);
target.caughtAt = now;
target.caughtBy = seeker.id;
seeker.captures += 1;
if (room.settings.mode === "infection") {
target.role = "seeker";
target.status = "seeking";
} else {
target.status = "caught";
}
room.events.unshift({
id: `event-${Date.now()}`,
type: "capture",
message: `${seeker.name} 抓到了 ${target.name}`,
createdAt: Date.now()
});
const survivors = room.players.filter(
(player) => player.role === "hider" && player.status === "hiding"
);
if (!survivors.length) {
finishGameObject(room, "seekers");
}
return saveRoom(room);
}
function makeResultSnapshot(room, winner, finishedAt, reason) {
const gameStartedAt = room.phaseStartedAt || room.createdAt;
const elapsedSeconds = Math.max(0, Math.floor((finishedAt - gameStartedAt) / 1000));
const players = room.players.map((player) => {
const survivedUntil = player.caughtAt || finishedAt;
return {
id: player.id,
name: player.name,
avatar: player.avatar,
initialRole: player.initialRole || player.role,
finalRole: player.role,
finalStatus: player.status,
captures: player.captures,
caughtAt: player.caughtAt,
survivedSeconds: player.initialRole === "hider"
? Math.max(0, Math.floor((survivedUntil - gameStartedAt) / 1000))
: 0,
joinedAt: player.joinedAt
};
});
players.sort((a, b) => {
if (b.captures !== a.captures) return b.captures - a.captures;
if (b.survivedSeconds !== a.survivedSeconds) return b.survivedSeconds - a.survivedSeconds;
if (a.joinedAt !== b.joinedAt) return a.joinedAt - b.joinedAt;
return a.id.localeCompare(b.id);
});
return {
winner,
reason,
finishedAt,
elapsedSeconds,
eventCount: room.events.length,
players
};
}
function finishGameObject(room, winner, reason = "completed") {
const finishedAt = Date.now();
room.status = "finished";
room.finishedAt = finishedAt;
room.winner = winner;
room.finishReason = reason;
room.phaseEndsAt = finishedAt;
room.resultSnapshot = makeResultSnapshot(room, winner, finishedAt, reason);
}
function finishGame(code, winner, operatorId = USER_ID, reason = "manual") {
const room = getRoom(code);
if (!room) throw new Error("房间不存在");
if (!["preparing", "playing", "paused"].includes(room.status)) {
throw new Error("当前房间不能结束游戏");
}
const canControlDemo = room.code === "731204" && operatorId === USER_ID;
if (operatorId && room.hostId !== operatorId && !canControlDemo) {
throw new Error("只有房主可以提前结束游戏");
}
const survivors = room.players.filter(
(player) => player.role === "hider" && player.status === "hiding"
);
finishGameObject(room, winner || (survivors.length ? "hiders" : "seekers"), reason);
return saveRoom(room);
}
function pauseGame(code, operatorId = USER_ID) {
const room = getRoom(code);
if (!room) throw new Error("房间不存在");
if (room.status !== "playing") throw new Error("只有追逃阶段可以暂停");
const canControlDemo = room.code === "731204" && operatorId === USER_ID;
if (room.hostId !== operatorId && !canControlDemo) throw new Error("只有房主可以暂停游戏");
const now = Date.now();
room.status = "paused";
room.pausedAt = now;
room.pausedRemainingMs = Math.max(0, room.phaseEndsAt - now);
room.pausedClueRemainingMs = room.nextClueAt
? Math.max(0, room.nextClueAt - now)
: 0;
room.events.unshift({
id: `event-${now}`,
type: "system",
message: "房主暂停了游戏,请所有玩家留在安全位置",
createdAt: now
});
return saveRoom(room);
}
function resumeGame(code, operatorId = USER_ID) {
const room = getRoom(code);
if (!room) throw new Error("房间不存在");
if (room.status !== "paused") throw new Error("当前游戏没有暂停");
const canControlDemo = room.code === "731204" && operatorId === USER_ID;
if (room.hostId !== operatorId && !canControlDemo) throw new Error("只有房主可以继续游戏");
const now = Date.now();
room.status = "playing";
room.phaseEndsAt = now + room.pausedRemainingMs;
room.nextClueAt = room.pausedClueRemainingMs
? now + room.pausedClueRemainingMs
: now + getClueIntervalSeconds(room) * 1000;
room.pausedAt = null;
room.pausedRemainingMs = 0;
room.pausedClueRemainingMs = 0;
room.events.unshift({
id: `event-${now}`,
type: "system",
message: "房主继续了游戏",
createdAt: now
});
return saveRoom(room);
}
function quitGame(code, playerId = USER_ID) {
const room = getRoom(code);
if (!room) throw new Error("房间不存在");
if (!["preparing", "playing", "paused"].includes(room.status)) {
throw new Error("当前不能退出游戏");
}
const player = room.players.find((item) => item.id === playerId);
if (!player) throw new Error("你不在当前房间中");
if (player.status === "quit") throw new Error("玩家已经退出游戏");
player.status = "quit";
player.ready = false;
room.events.unshift({
id: `event-${Date.now()}`,
type: "safety",
message: `${player.name} 已安全退出并前往集合点`,
createdAt: Date.now()
});
const survivors = room.players.filter(
(item) => item.role === "hider" && item.status === "hiding"
);
if (!survivors.length) finishGameObject(room, "seekers", "all_hiders_left");
return saveRoom(room);
}
function resetRoom(code) {
const room = getRoom(code);
if (!room) return null;
room.status = "waiting";
room.winner = "";
room.finishReason = "";
room.phaseEndsAt = null;
room.phaseStartedAt = null;
room.nextClueAt = null;
room.gameDurationSeconds = null;
room.finishedAt = null;
room.pausedAt = null;
room.pausedRemainingMs = 0;
room.pausedClueRemainingMs = 0;
room.resultSnapshot = null;
room.events = [];
room.players.forEach((player) => {
player.role = "";
player.initialRole = "";
player.status = "ready";
player.ready = true;
player.caughtAt = null;
player.caughtBy = null;
player.captures = 0;
player.skillsUsed = { radar: 0, silent: 0 };
player.silentPending = false;
});
room.captureTokensUsed = [];
return saveRoom(room);
}
function clearCurrentRoom() {
wx.removeStorageSync(CURRENT_ROOM_KEY);
}
module.exports = {
USER_ID,
ensureSeedData,
createRoom,
getRoom,
getCurrentRoom,
setCurrentRoom,
joinRoom,
addDemoPlayers,
toggleReady,
startGame,
beginPlaying,
revealClue,
useSkill,
getCaptureCode,
capture,
finishGame,
pauseGame,
resumeGame,
quitGame,
resetRoom,
clearCurrentRoom
};

9
sitemap.json Normal file
View File

@@ -0,0 +1,9 @@
{
"desc": "藏猫猫计划页面索引配置",
"rules": [
{
"action": "allow",
"page": "*"
}
]
}

340
tests/game-service.test.js Normal file
View File

@@ -0,0 +1,340 @@
const assert = require("assert");
const storage = {};
global.wx = {
getStorageSync(key) {
return storage[key];
},
setStorageSync(key, value) {
storage[key] = JSON.parse(JSON.stringify(value));
},
removeStorageSync(key) {
delete storage[key];
}
};
const service = require("../services/game-service");
function testLegacyRoomMigration() {
storage.zmc_rooms = {
123456: {
code: "123456",
name: "旧房间",
players: [{ id: "legacy-host", name: "旧玩家", ready: true }]
},
broken: "invalid"
};
service.ensureSeedData();
const room = service.getRoom("123456");
assert.strictEqual(room.schemaVersion, 2);
assert.strictEqual(room.status, "waiting");
assert.strictEqual(room.settings.mode, "infection");
assert.strictEqual(room.players[0].status, "ready");
assert.deepStrictEqual(room.events, []);
assert.strictEqual(storage.zmc_rooms.broken, undefined);
assert.ok(service.getRoom("731204"));
}
function testBrokenTopLevelStorage() {
storage.zmc_rooms = [];
assert.doesNotThrow(() => service.ensureSeedData());
assert.ok(service.getRoom("731204"));
}
function testMainFlowAndReplay() {
const joined = service.joinRoom("731204", "测试玩家");
assert.strictEqual(joined.players.length, 4);
const preparing = service.startGame("731204");
assert.strictEqual(preparing.status, "preparing");
assert.strictEqual(
preparing.players.filter((player) => player.role === "seeker").length,
preparing.settings.seekerCount
);
let room = service.beginPlaying("731204");
assert.strictEqual(room.status, "playing");
while (room.status === "playing") {
const target = room.players.find(
(player) => player.role === "hider" && player.status === "hiding"
);
const captureCode = service.getCaptureCode(room.code, target.id);
assert.strictEqual(captureCode.length, 4);
room = service.capture(room.code, target.id, captureCode);
}
assert.strictEqual(room.status, "finished");
assert.strictEqual(room.winner, "seekers");
const replay = service.resetRoom(room.code);
assert.strictEqual(replay.status, "waiting");
assert.ok(replay.players.every((player) => player.role === ""));
assert.ok(replay.players.every((player) => player.captures === 0));
assert.strictEqual(replay.resultSnapshot, null);
assert.strictEqual(replay.nextClueAt, null);
assert.strictEqual(replay.captureTokensUsed.length, 0);
}
function validPayload(overrides = {}) {
return {
name: "第二天测试房间",
mode: "classic",
durationMinutes: 30,
prepareMinutes: 3,
maxPlayers: 4,
seekerCount: 1,
clueIntervalMinutes: 5,
radiusMeters: 500,
skillsEnabled: true,
...overrides
};
}
function testCreateValidation() {
assert.throws(
() => service.createRoom(validPayload({ seekerCount: 4 })),
/初始寻找者必须少于房间人数上限/
);
assert.throws(
() => service.createRoom(validPayload({ durationMinutes: 10 })),
/游戏时长应为 1590 分钟/
);
assert.throws(
() => service.createRoom(validPayload({ name: " " })),
/请填写房间名称/
);
const room = service.createRoom(validPayload());
assert.strictEqual(room.name, "第二天测试房间");
assert.strictEqual(room.settings.seekerCount, 1);
}
function testLobbyPermissionsAndReadiness() {
const room = service.createRoom(validPayload({ maxPlayers: 6 }));
service.addDemoPlayers(room.code, 4);
assert.throws(
() => service.startGame(room.code, "not-the-host"),
/只有房主可以开始游戏/
);
const rooms = storage.zmc_rooms;
rooms[room.code].players[1].ready = false;
rooms[room.code].players[1].status = "idle";
storage.zmc_rooms = rooms;
assert.throws(() => service.startGame(room.code), /还有玩家尚未准备好/);
service.toggleReady(room.code, rooms[room.code].players[1].id);
const started = service.startGame(room.code);
assert.strictEqual(started.status, "preparing");
assert.throws(() => service.startGame(room.code), /不能重复开始/);
}
function testJoinStatusMessages() {
assert.throws(() => service.joinRoom("12", "玩家"), /六位房间码/);
assert.throws(() => service.joinRoom("999999", "玩家"), /没有找到/);
const room = service.createRoom(validPayload());
storage.zmc_rooms[room.code].status = "finished";
assert.throws(() => service.joinRoom(room.code, "玩家"), /已经结束/);
storage.zmc_rooms[room.code].status = "cancelled";
assert.throws(() => service.joinRoom(room.code, "玩家"), /房主取消/);
}
function prepareRoom(overrides = {}) {
const room = service.createRoom(validPayload({ maxPlayers: 6, ...overrides }));
service.addDemoPlayers(room.code, 4);
return room.code;
}
function testStateTransitionsAndDemoTiming() {
const code = prepareRoom();
assert.throws(() => service.beginPlaying(code), /不能进入追逃阶段/);
assert.throws(() => service.revealClue(code), /不能广播线索/);
assert.throws(() => service.useSkill(code, "radar"), /不能使用技能/);
const preparing = service.startGame(code);
assert.strictEqual(
preparing.phaseEndsAt - preparing.phaseStartedAt,
preparing.settings.prepareMinutes * 1000
);
const scheduledStartAt = preparing.phaseEndsAt;
const playing = service.beginPlaying(code);
assert.strictEqual(playing.phaseStartedAt, scheduledStartAt);
assert.strictEqual(playing.phaseEndsAt - playing.phaseStartedAt, 180000);
assert.strictEqual(playing.nextClueAt - playing.phaseStartedAt, 15000);
assert.throws(() => service.beginPlaying(code), /不能进入追逃阶段/);
const finished = service.finishGame(code, "hiders");
assert.strictEqual(finished.status, "finished");
assert.throws(() => service.finishGame(code), /不能结束游戏/);
}
function testConfiguredProductionTiming() {
const code = prepareRoom({
durationMinutes: 20,
prepareMinutes: 5,
clueIntervalMinutes: 8
});
storage.zmc_rooms[code].runtime.demoAccelerated = false;
const preparing = service.startGame(code);
assert.strictEqual(preparing.phaseEndsAt - preparing.phaseStartedAt, 5 * 60 * 1000);
const playing = service.beginPlaying(code);
assert.strictEqual(playing.phaseEndsAt - playing.phaseStartedAt, 20 * 60 * 1000);
assert.strictEqual(playing.nextClueAt - playing.phaseStartedAt, 8 * 60 * 1000);
}
function startPlayableRoom(overrides = {}) {
const code = prepareRoom(overrides);
const preparing = service.startGame(code);
return service.beginPlaying(preparing.code);
}
function testRoleCountsAndGameModes() {
let room = startPlayableRoom({ mode: "classic", maxPlayers: 8, seekerCount: 3 });
assert.strictEqual(room.players.length, 4);
assert.strictEqual(room.players.filter((player) => player.role === "seeker").length, 3);
let target = room.players.find((player) => player.role === "hider");
let token = service.getCaptureCode(room.code, target.id);
room = service.capture(room.code, target.id, token);
target = room.players.find((player) => player.id === target.id);
assert.strictEqual(target.role, "hider");
assert.strictEqual(target.status, "caught");
assert.strictEqual(room.status, "finished");
room = startPlayableRoom({ mode: "infection", seekerCount: 1 });
target = room.players.find((player) => player.role === "hider");
token = service.getCaptureCode(room.code, target.id);
room = service.capture(room.code, target.id, token);
target = room.players.find((player) => player.id === target.id);
assert.strictEqual(target.role, "seeker");
assert.strictEqual(target.status, "seeking");
}
function testPersistedSkills() {
let room = startPlayableRoom({ mode: "classic", skillsEnabled: true });
const seeker = room.players.find((player) => player.role === "seeker");
const hider = room.players.find((player) => player.role === "hider");
room = service.useSkill(room.code, "radar", seeker.id);
assert.strictEqual(
room.players.find((player) => player.id === seeker.id).skillsUsed.radar,
1
);
assert.throws(
() => service.useSkill(room.code, "radar", seeker.id),
/已经使用过/
);
room = service.useSkill(room.code, "silent", hider.id);
assert.strictEqual(
room.players.find((player) => player.id === hider.id).silentPending,
true
);
assert.ok(!room.events[0].message.includes(hider.name));
room = service.revealClue(room.code);
assert.strictEqual(
room.players.find((player) => player.id === hider.id).silentPending,
false
);
assert.match(room.events[0].message, /静默已生效/);
assert.throws(
() => service.useSkill(room.code, "radar", hider.id),
/当前身份不能使用/
);
room = startPlayableRoom({ skillsEnabled: false });
assert.throws(() => service.useSkill(room.code, "radar"), /未启用角色技能/);
}
function testCaptureTokenAndPermissions() {
const now = 1800000000000;
let room = startPlayableRoom({ mode: "classic" });
const seeker = room.players.find((player) => player.role === "seeker");
const target = room.players.find((player) => player.role === "hider");
const anotherHider = room.players.find(
(player) => player.role === "hider" && player.id !== target.id
);
const token = service.getCaptureCode(room.code, target.id, now);
assert.strictEqual(token.length, 4);
assert.notStrictEqual(service.getCaptureCode(room.code, anotherHider.id, now), token);
assert.notStrictEqual(service.getCaptureCode(room.code, target.id, now + 30000), token);
assert.throws(
() => service.capture(room.code, target.id, token, anotherHider.id, now),
/只有正在游戏的寻找者/
);
assert.throws(
() => service.capture(room.code, target.id, token, seeker.id, now + 30000),
/口令不正确或已经失效/
);
room = service.capture(room.code, target.id, token, seeker.id, now);
assert.strictEqual(room.players.find((player) => player.id === seeker.id).captures, 1);
assert.throws(
() => service.capture(room.code, target.id, token, seeker.id, now),
/口令已经使用过/
);
}
function testPauseResumeAndHostFinish() {
let room = startPlayableRoom({ mode: "classic" });
assert.throws(() => service.pauseGame(room.code, "not-host"), /只有房主/);
const phaseEndsAt = room.phaseEndsAt;
room = service.pauseGame(room.code);
assert.strictEqual(room.status, "paused");
assert.ok(room.pausedRemainingMs > 0);
assert.ok(room.pausedRemainingMs <= phaseEndsAt - room.pausedAt + 5);
assert.throws(() => service.pauseGame(room.code), /只有追逃阶段可以暂停/);
room = service.resumeGame(room.code);
assert.strictEqual(room.status, "playing");
assert.strictEqual(room.pausedRemainingMs, 0);
assert.throws(() => service.resumeGame(room.code), /没有暂停/);
assert.throws(
() => service.finishGame(room.code, "", "not-host", "host_ended"),
/只有房主可以提前结束/
);
room = service.finishGame(room.code, "", service.USER_ID, "host_ended");
assert.strictEqual(room.status, "finished");
assert.strictEqual(room.finishReason, "host_ended");
}
function testSafetyQuitAndStableResult() {
let room = startPlayableRoom({ mode: "classic", seekerCount: 1 });
const hiders = room.players.filter((player) => player.role === "hider");
room = service.quitGame(room.code, hiders[0].id);
assert.strictEqual(
room.players.find((player) => player.id === hiders[0].id).status,
"quit"
);
assert.strictEqual(room.status, "playing");
for (let index = 1; index < hiders.length; index += 1) {
room = service.quitGame(room.code, hiders[index].id);
}
assert.strictEqual(room.status, "finished");
assert.strictEqual(room.finishReason, "all_hiders_left");
assert.ok(room.resultSnapshot);
assert.strictEqual(room.resultSnapshot.players.length, room.players.length);
assert.deepStrictEqual(
room.resultSnapshot.players.map((player) => player.id),
service.getRoom(room.code).resultSnapshot.players.map((player) => player.id)
);
assert.throws(() => service.quitGame(room.code, hiders[0].id), /当前不能退出/);
}
testLegacyRoomMigration();
testBrokenTopLevelStorage();
testMainFlowAndReplay();
testCreateValidation();
testLobbyPermissionsAndReadiness();
testJoinStatusMessages();
testStateTransitionsAndDemoTiming();
testConfiguredProductionTiming();
testRoleCountsAndGameModes();
testPersistedSkills();
testCaptureTokenAndPermissions();
testPauseResumeAndHostFinish();
testSafetyQuitAndStableResult();
console.log("game-service tests passed");

View File

@@ -0,0 +1,28 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const pagesRoot = path.join(__dirname, "..", "pages");
const pageNames = fs.readdirSync(pagesRoot);
pageNames.forEach((pageName) => {
const pageRoot = path.join(pagesRoot, pageName);
const jsPath = path.join(pageRoot, `${pageName}.js`);
const wxmlPath = path.join(pageRoot, `${pageName}.wxml`);
if (!fs.existsSync(jsPath) || !fs.existsSync(wxmlPath)) return;
const js = fs.readFileSync(jsPath, "utf8");
const wxml = fs.readFileSync(wxmlPath, "utf8");
const handlers = [...wxml.matchAll(/(?:bind|catch)[a-z]+="([A-Za-z0-9_]+)"/g)]
.map((match) => match[1]);
handlers.forEach((handler) => {
assert.match(
js,
new RegExp(`\\n\\s{2}${handler}\\s*\\(`),
`${pageName}.wxml references missing handler ${handler}`
);
});
});
console.log("page contract tests passed");

View File

@@ -0,0 +1,38 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const projectRoot = path.join(__dirname, "..");
const appConfig = JSON.parse(fs.readFileSync(path.join(projectRoot, "app.json"), "utf8"));
const projectConfig = JSON.parse(
fs.readFileSync(path.join(projectRoot, "project.config.json"), "utf8")
);
assert.strictEqual(projectConfig.compileType, "miniprogram");
assert.ok(Array.isArray(appConfig.pages) && appConfig.pages.length > 0);
assert.strictEqual(new Set(appConfig.pages).size, appConfig.pages.length, "app pages must be unique");
appConfig.pages.forEach((pagePath) => {
["js", "json", "wxml", "wxss"].forEach((extension) => {
const file = path.join(projectRoot, `${pagePath}.${extension}`);
assert.ok(fs.existsSync(file), `missing page file: ${pagePath}.${extension}`);
});
});
[
"README.md",
"docs/PRD.md",
"docs/TECHNICAL_DESIGN.md",
"docs/ONE_WEEK_MVP_PLAN.md",
"docs/DEVICE_TEST_CHECKLIST.md",
"docs/DEMO_GUIDE.md",
"docs/TEST_REPORT.md",
"docs/RELEASE_NOTES.md"
].forEach((relativePath) => {
assert.ok(fs.existsSync(path.join(projectRoot, relativePath)), `missing deliverable: ${relativePath}`);
});
const appSource = fs.readFileSync(path.join(projectRoot, "app.js"), "utf8");
assert.match(appSource, /version:\s*"0\.1\.0-mvp"/);
console.log("release readiness tests passed");

17
tests/run-all.js Normal file
View File

@@ -0,0 +1,17 @@
const path = require("path");
const { spawnSync } = require("child_process");
const testFiles = [
"game-service.test.js",
"page-contract.test.js",
"release-readiness.test.js"
];
testFiles.forEach((testFile) => {
const result = spawnSync(process.execPath, [path.join(__dirname, testFile)], {
stdio: "inherit"
});
if (result.status !== 0) process.exit(result.status || 1);
});
console.log("all automated checks passed");

41
utils/constants.js Normal file
View File

@@ -0,0 +1,41 @@
const GAME_MODES = {
classic: {
id: "classic",
name: "经典模式",
description: "寻找者在时间结束前抓到全部躲藏者。",
icon: "◎"
},
infection: {
id: "infection",
name: "感染模式",
description: "被抓的躲藏者会转化为寻找者,继续参与追捕。",
icon: "↗"
}
};
const DEFAULT_SETTINGS = {
mode: "infection",
durationMinutes: 30,
prepareMinutes: 3,
maxPlayers: 8,
seekerCount: 2,
clueIntervalMinutes: 5,
radiusMeters: 500,
skillsEnabled: true
};
const BOT_NAMES = ["阿树", "小满", "木木", "大川", "橘子", "青禾", "野原"];
const AREA_CLUES = [
"目标可能在西北侧树林附近",
"南侧入口附近检测到活动",
"中心草坪周边出现模糊信号",
"东侧步道附近有人移动",
"目标可能靠近北侧边界"
];
module.exports = {
GAME_MODES,
DEFAULT_SETTINGS,
BOT_NAMES,
AREA_CLUES
};

30
utils/format.js Normal file
View File

@@ -0,0 +1,30 @@
function pad(value) {
return String(value).padStart(2, "0");
}
function formatCountdown(totalSeconds) {
const safe = Math.max(0, Math.floor(totalSeconds || 0));
const minutes = Math.floor(safe / 60);
const seconds = safe % 60;
return `${pad(minutes)}:${pad(seconds)}`;
}
function formatTime(timestamp) {
const date = new Date(timestamp);
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function randomCode() {
return String(Math.floor(100000 + Math.random() * 900000));
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
module.exports = {
formatCountdown,
formatTime,
randomCode,
clone
};